Files
cmroubao/backend-api/internal/transport/webui/auth_handler.go
T

314 lines
7.4 KiB
Go
Raw Normal View History

package webui
import (
"bytes"
"context"
"errors"
"net/http"
"net/url"
"path"
"strconv"
"strings"
"time"
"cmroubao/backend-api/internal/transport/authcommon"
"github.com/gin-gonic/gin"
)
const (
AdminSessionCookieName = authcommon.AdminSessionCookieName
maxLoginFormBytes = 16 << 10
)
var (
ErrInvalidCredentials = errors.New("invalid credentials")
ErrAccountDisabled = errors.New("account disabled")
)
type AdminSessionService interface {
LoginAdmin(context.Context, AdminLoginInput) (AdminLoginResult, error)
LogoutAdmin(context.Context, string) error
}
type AdminLoginInput struct {
Username string
Password string
}
type AdminLoginResult struct {
Token string
ExpiresAt time.Time
}
type AuthHandler struct {
sessions AdminSessionService
renderer *Renderer
limiter authcommon.AttemptLimiter
}
func NewAuthHandler(
sessions AdminSessionService,
renderer *Renderer,
limiter authcommon.AttemptLimiter,
) (*AuthHandler, error) {
if sessions == nil {
return nil, errors.New("admin session service is required")
}
if renderer == nil {
return nil, errors.New("admin auth renderer is required")
}
if limiter == nil {
return nil, errors.New("admin login limiter is required")
}
return &AuthHandler{
sessions: sessions,
renderer: renderer,
limiter: limiter,
}, nil
}
func (h *AuthHandler) RegisterPublic(routes gin.IRoutes) {
routes.GET("/login", SecurityHeaders(), h.LoginPage)
routes.POST("/login", SecurityHeaders(), h.Login)
}
func (h *AuthHandler) RegisterProtected(routes gin.IRoutes) {
routes.POST("/logout", SecurityHeaders(), h.Logout)
}
func (h *AuthHandler) LoginPage(ctx *gin.Context) {
token, err := csrfToken(ctx)
if err != nil {
h.renderError(ctx, http.StatusInternalServerError)
return
}
h.render(ctx, http.StatusOK, loginPage{
Page: pageView{Title: "管理端登录"},
CSRFToken: token,
Next: safeNext(ctx.Query("next")),
})
}
func (h *AuthHandler) Login(ctx *gin.Context) {
previousSessionToken := ""
if cookie, err := ctx.Request.Cookie(
authcommon.AdminSessionCookieName,
); err == nil {
previousSessionToken = cookie.Value
}
ctx.Request.Body = http.MaxBytesReader(
ctx.Writer,
ctx.Request.Body,
maxLoginFormBytes,
)
if err := ctx.Request.ParseForm(); err != nil {
h.renderError(ctx, http.StatusBadRequest)
return
}
next := safeNext(ctx.PostForm("next"))
page := loginPage{
Page: pageView{Title: "管理端登录"},
CSRFToken: strings.TrimSpace(
ctx.PostForm(authcommon.CSRFFormField),
),
Next: next,
Username: strings.TrimSpace(ctx.PostForm("username")),
}
if !validCSRF(ctx) {
page.Message = "登录页面已失效,请刷新后重试。"
h.render(ctx, http.StatusForbidden, page)
return
}
password := ctx.PostForm("password")
if page.Username == "" {
page.UsernameError = "请输入账号。"
}
if password == "" {
page.PasswordError = "请输入密码。"
}
if page.UsernameError != "" || page.PasswordError != "" {
page.Message = "请检查并补全必填项。"
h.render(ctx, http.StatusUnprocessableEntity, page)
return
}
attemptKey := authcommon.LoginAttemptKey(
"admin",
ctx.Request.RemoteAddr,
)
if allowed, wait := h.limiter.Allow(attemptKey); !allowed {
ctx.Header(
"Retry-After",
strconv.Itoa(authcommon.RetryAfterSeconds(wait)),
)
page.Message = "登录尝试次数过多,请稍后再试。"
h.render(ctx, http.StatusTooManyRequests, page)
return
}
result, err := h.sessions.LoginAdmin(
ctx.Request.Context(),
AdminLoginInput{
Username: page.Username,
Password: password,
},
)
if err != nil {
switch {
case errors.Is(err, ErrInvalidCredentials),
errors.Is(err, ErrAccountDisabled):
page.Message = "账号或密码不正确。"
h.render(ctx, http.StatusUnauthorized, page)
case errors.Is(err, context.DeadlineExceeded),
errors.Is(err, ErrUnavailable):
page.Message = "登录服务暂时不可用,请稍后重试。"
h.render(ctx, http.StatusServiceUnavailable, page)
default:
h.renderError(ctx, http.StatusInternalServerError)
}
return
}
h.limiter.Reset(attemptKey)
if previousSessionToken != "" &&
previousSessionToken != result.Token {
if err := h.sessions.LogoutAdmin(
ctx.Request.Context(),
previousSessionToken,
); err != nil {
_ = h.sessions.LogoutAdmin(
ctx.Request.Context(),
result.Token,
)
clearAdminSessionCookie(ctx)
h.renderError(ctx, http.StatusServiceUnavailable)
return
}
}
setAdminSessionCookie(ctx, result)
if _, err := rotateCSRFToken(ctx); err != nil {
_ = h.sessions.LogoutAdmin(ctx.Request.Context(), result.Token)
clearAdminSessionCookie(ctx)
h.renderError(ctx, http.StatusInternalServerError)
return
}
ctx.Redirect(http.StatusSeeOther, next)
}
func (h *AuthHandler) Logout(ctx *gin.Context) {
if !validCSRF(ctx) {
h.renderError(ctx, http.StatusForbidden)
return
}
cookie, err := ctx.Request.Cookie(authcommon.AdminSessionCookieName)
if err == nil && cookie.Value != "" {
if err := h.sessions.LogoutAdmin(
ctx.Request.Context(),
cookie.Value,
); err != nil &&
!errors.Is(err, ErrNotFound) {
h.renderError(ctx, http.StatusServiceUnavailable)
return
}
}
clearAdminSessionCookie(ctx)
_, _ = rotateCSRFToken(ctx)
ctx.Redirect(http.StatusSeeOther, "/login")
}
func setAdminSessionCookie(
ctx *gin.Context,
result AdminLoginResult,
) {
maxAge := int(time.Until(result.ExpiresAt).Seconds())
if maxAge < 1 {
maxAge = 1
}
http.SetCookie(ctx.Writer, &http.Cookie{
Name: authcommon.AdminSessionCookieName,
Value: result.Token,
Path: "/",
Expires: result.ExpiresAt.UTC(),
MaxAge: maxAge,
HttpOnly: true,
Secure: ctx.Request.TLS != nil,
SameSite: http.SameSiteLaxMode,
})
}
func clearAdminSessionCookie(ctx *gin.Context) {
http.SetCookie(ctx.Writer, &http.Cookie{
Name: authcommon.AdminSessionCookieName,
Value: "",
Path: "/",
Expires: time.Unix(1, 0).UTC(),
MaxAge: -1,
HttpOnly: true,
Secure: ctx.Request.TLS != nil,
SameSite: http.SameSiteLaxMode,
})
}
func safeNext(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return "/tasks"
}
if strings.Contains(value, `\`) ||
strings.HasPrefix(value, "//") {
return "/tasks"
}
parsed, err := url.Parse(value)
if err != nil ||
parsed.IsAbs() ||
parsed.Host != "" ||
parsed.Fragment != "" ||
path.Clean(parsed.Path) != parsed.Path {
return "/tasks"
}
if parsed.Path != "/tasks" &&
!strings.HasPrefix(parsed.Path, "/tasks/") &&
parsed.Path != "/freight" &&
2026-07-29 10:48:06 +08:00
!strings.HasPrefix(parsed.Path, "/freight/") {
return "/tasks"
}
return parsed.String()
}
func (h *AuthHandler) render(
ctx *gin.Context,
status int,
page loginPage,
) {
var output bytes.Buffer
if err := h.renderer.Execute(&output, "login", page); err != nil {
ctx.Data(
http.StatusInternalServerError,
formContentType,
[]byte("页面暂时无法显示,请稍后重试。"),
)
return
}
ctx.Data(status, formContentType, output.Bytes())
}
func (h *AuthHandler) renderError(ctx *gin.Context, status int) {
token, _ := csrfToken(ctx)
h.render(ctx, status, loginPage{
Page: pageView{Title: "管理端登录"},
CSRFToken: token,
Next: "/tasks",
Message: "操作失败,请刷新页面后重试。",
})
}
type loginPage struct {
Page pageView
CSRFToken string
Next string
Username string
Message string
UsernameError string
PasswordError string
}