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/") { 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 }