package httpapi import ( "context" "crypto/rand" "crypto/sha256" "encoding/hex" "net" "net/http" "strings" "time" "github.com/gin-gonic/gin" "git.pcux.cn/xuans/Scert/backend/internal/auth" "git.pcux.cn/xuans/Scert/backend/internal/config" "git.pcux.cn/xuans/Scert/backend/internal/repository" ) const ( authCookieName = "scert_session" authSessionLifetime = 30 * time.Minute rememberSessionLife = 14 * 24 * time.Hour loginWindow = 15 * time.Minute loginBlockDuration = 5 * time.Minute loginFailureLimit = 5 authRequestBodyLimit = 16 * 1024 ) type authSession struct { UserID string Username string Email string Role string PasswordHash string ExpiresAt time.Time AbsoluteExpiresAt time.Time } type loginAttempt struct { Failures int WindowStart time.Time BlockedTill time.Time } type authLoginRequest struct { Identifier string `json:"identifier"` Password string `json:"password"` Remember bool `json:"remember"` } func (s *Server) authLogin(c *gin.Context) { var request authLoginRequest if err := bindJSON(c, &request, authRequestBodyLimit); err != nil { s.writeError(c, http.StatusBadRequest, "登录请求参数无效", err) return } s.configMu.RLock() cfg := s.config s.configMu.RUnlock() if !cfg.SetupReady { s.writeError(c, http.StatusConflict, "请先完成初始化配置", nil) return } key := loginAttemptKey(c.Request, request.Identifier) if !s.allowLogin(key) { s.writeError(c, http.StatusTooManyRequests, "登录失败次数过多,请稍后再试", nil) return } if !s.reserveLogin(c.Request, key) { c.Header("Retry-After", "60") s.writeError(c, http.StatusTooManyRequests, "登录请求过于频繁或服务繁忙,请稍后再试", nil) return } defer s.releaseLogin(c.Request, key) account, userID, passwordHash, err := s.authenticateAccount(c.Request.Context(), request, cfg) if err != nil { s.recordLoginFailure(key) s.recordAuthAudit(c, "登录失败", "failed", "登录凭据校验失败") s.writeError(c, http.StatusUnauthorized, "账号或密码错误", nil) return } s.clearLoginFailures(key) token, err := newAuthToken() if err != nil { s.writeError(c, http.StatusInternalServerError, "无法创建登录会话,请稍后重试", err) return } now := s.now() absoluteExpiresAt := now.Add(authSessionLifetime) if request.Remember { absoluteExpiresAt = now.Add(rememberSessionLife) } expiresAt := minTime(now.Add(authSessionLifetime), absoluteExpiresAt) s.authMu.Lock() s.pruneAuthSessionsLocked(now) userSessions := 0 for _, session := range s.authSessions { if session.UserID == userID { userSessions++ } } if len(s.authSessions) >= authStateCapacity || userSessions >= authUserSessionCapacity { s.authMu.Unlock() s.writeError(c, http.StatusServiceUnavailable, "登录会话数量已达上限,请退出其他设备或稍后重试", nil) return } s.authSessions[token] = authSession{ UserID: userID, Username: account.Username, Email: account.Email, Role: account.Role, PasswordHash: passwordHash, ExpiresAt: expiresAt, AbsoluteExpiresAt: absoluteExpiresAt, } s.authMu.Unlock() s.setAuthCookie(c, token, expiresAt) s.recordAuthAudit(c, "登录成功", "success", "用户登录成功") c.JSON(http.StatusOK, gin.H{ "authenticated": true, "account": account.Username, "email": account.Email, "role": account.Role, "is_admin": account.Role == repository.UserRoleSuperAdmin || account.Role == repository.UserRoleAdmin, "next_step": "complete", "required_steps": []string{}, "expires_at": expiresAt, }) } func (s *Server) authenticateAccount(ctx context.Context, request authLoginRequest, cfg config.Config) (auth.AdminAccount, string, string, error) { account, err := auth.AuthenticateAdmin(request.Identifier, request.Password, cfg.Admin.Username, cfg.Admin.Email, cfg.Admin.PasswordHash) if err == nil { return account, "primary-super-admin", cfg.Admin.PasswordHash, nil } if s.users == nil { return auth.AdminAccount{}, "", "", auth.ErrInvalidCredentials } user, lookupErr := s.users.FindByIdentifier(ctx, request.Identifier) if lookupErr != nil || user.Status != repository.UserStatusActive { return auth.AdminAccount{}, "", "", auth.ErrInvalidCredentials } account, err = auth.AuthenticateAccount(request.Identifier, request.Password, user.Username, user.Email, user.PasswordHash, user.Role) if err != nil { return auth.AdminAccount{}, "", "", auth.ErrInvalidCredentials } return account, user.ID, user.PasswordHash, nil } func (s *Server) authSession(c *gin.Context) { session, ok := s.currentAuthSession(c) if !ok { c.JSON(http.StatusUnauthorized, gin.H{"authenticated": false}) return } c.JSON(http.StatusOK, gin.H{ "authenticated": true, "account": session.Username, "email": session.Email, "role": session.Role, "is_admin": session.Role == repository.UserRoleSuperAdmin || session.Role == repository.UserRoleAdmin, "next_step": "complete", "required_steps": []string{}, "expires_at": session.ExpiresAt, }) } func (s *Server) authLogout(c *gin.Context) { if token, err := c.Cookie(authCookieName); err == nil && token != "" { s.authMu.Lock() delete(s.authSessions, token) s.authMu.Unlock() } http.SetCookie(c.Writer, &http.Cookie{ Name: authCookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: s.requestIsHTTPS(c.Request), }) c.JSON(http.StatusOK, gin.H{"authenticated": false, "message": "已退出登录"}) } func (s *Server) currentAuthSession(c *gin.Context) (authSession, bool) { token, err := c.Cookie(authCookieName) if err != nil || strings.TrimSpace(token) == "" { return authSession{}, false } now := s.now() s.authMu.RLock() session, ok := s.authSessions[token] s.authMu.RUnlock() if !ok || !session.usableAt(now) { if ok { s.authMu.Lock() delete(s.authSessions, token) s.authMu.Unlock() } s.clearAuthCookie(c) return authSession{}, false } if !s.authSessionAccountIsValid(c.Request.Context(), session) { s.authMu.Lock() delete(s.authSessions, token) s.authMu.Unlock() s.clearAuthCookie(c) return authSession{}, false } s.authMu.Lock() current, exists := s.authSessions[token] if !exists || current.UserID != session.UserID || current.PasswordHash != session.PasswordHash || current.Role != session.Role || current.Username != session.Username || current.Email != session.Email || !current.AbsoluteExpiresAt.Equal(session.AbsoluteExpiresAt) { s.authMu.Unlock() return authSession{}, false } // 账号存储查询可能很慢,返回后必须按当前时间再次校验,不能复活过期会话。 now = s.now() if !current.usableAt(now) { delete(s.authSessions, token) s.authMu.Unlock() s.clearAuthCookie(c) return authSession{}, false } newExpiresAt := minTime(now.Add(authSessionLifetime), current.AbsoluteExpiresAt) refreshed := newExpiresAt.After(current.ExpiresAt) if refreshed { current.ExpiresAt = newExpiresAt s.authSessions[token] = current } session = current s.authMu.Unlock() if refreshed { s.setAuthCookie(c, token, session.ExpiresAt) } return session, true } func (s *Server) authSessionAccountIsValid(ctx context.Context, session authSession) bool { if !session.complete() { return false } if session.UserID == "primary-super-admin" { s.configMu.RLock() cfg := s.config s.configMu.RUnlock() if !cfg.SetupReady || session.Role != repository.UserRoleSuperAdmin { return false } if !strings.EqualFold(strings.TrimSpace(session.Username), strings.TrimSpace(cfg.Admin.Username)) || !strings.EqualFold(strings.TrimSpace(session.Email), strings.TrimSpace(cfg.Admin.Email)) { return false } return session.PasswordHash == cfg.Admin.PasswordHash } if s.users == nil { return false } user, err := s.users.GetUserByID(ctx, session.UserID) if err != nil || user.Status != repository.UserStatusActive { return false } if user.Role != session.Role || !strings.EqualFold(user.Username, session.Username) || !strings.EqualFold(user.Email, session.Email) { return false } return session.PasswordHash == user.PasswordHash } func (s *Server) setAuthCookie(c *gin.Context, token string, expiresAt time.Time) { maxAge := int(expiresAt.Sub(s.now()).Seconds()) http.SetCookie(c.Writer, &http.Cookie{ Name: authCookieName, Value: token, Path: "/", Expires: expiresAt, MaxAge: max(1, maxAge), HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: s.requestIsHTTPS(c.Request), }) } func (s *Server) clearAuthCookie(c *gin.Context) { http.SetCookie(c.Writer, &http.Cookie{ Name: authCookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: s.requestIsHTTPS(c.Request), }) } func (s *Server) invalidateUserSessions(userID string) { userID = strings.TrimSpace(userID) if userID == "" { return } s.authMu.Lock() defer s.authMu.Unlock() for token, session := range s.authSessions { if session.UserID == userID { delete(s.authSessions, token) } } } func (s *Server) allowLogin(key string) bool { now := s.now() s.loginMu.Lock() defer s.loginMu.Unlock() s.pruneLoginAttemptsLocked(now) attempt, ok := s.loginAttempts[key] if !ok { return len(s.loginAttempts) < authStateCapacity } if !attempt.BlockedTill.IsZero() && attempt.BlockedTill.After(now) { return false } if attempt.WindowStart.IsZero() || now.Sub(attempt.WindowStart) >= loginWindow || (!attempt.BlockedTill.IsZero() && !attempt.BlockedTill.After(now)) { delete(s.loginAttempts, key) } return true } func (s *Server) recordLoginFailure(key string) { now := s.now() s.loginMu.Lock() defer s.loginMu.Unlock() s.pruneLoginAttemptsLocked(now) if _, exists := s.loginAttempts[key]; !exists && len(s.loginAttempts) >= authStateCapacity { return } attempt := s.loginAttempts[key] if attempt.WindowStart.IsZero() || now.Sub(attempt.WindowStart) >= loginWindow { attempt = loginAttempt{WindowStart: now} } attempt.Failures++ if attempt.Failures >= loginFailureLimit { attempt.BlockedTill = now.Add(loginBlockDuration) } s.loginAttempts[key] = attempt } func (s *Server) clearLoginFailures(key string) { s.loginMu.Lock() delete(s.loginAttempts, key) s.loginMu.Unlock() } func (s *Server) recordAuthAudit(c *gin.Context, action, status, message string) { if s.audit == nil { return } if err := s.audit.CreateAuditLog(c.Request.Context(), repository.AuditLog{ Action: action, ResourceType: "auth", ResourceID: "login", Status: status, Message: message, }); err != nil && s.logger != nil { s.logger.Warn("保存登录审计日志失败", "error", err) } } func loginAttemptKey(request *http.Request, identifier string) string { remote := "unknown" if request != nil { remote = request.RemoteAddr if host, _, err := net.SplitHostPort(remote); err == nil { remote = host } } digest := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(identifier)))) return strings.ToLower(strings.TrimSpace(remote)) + "|" + hex.EncodeToString(digest[:]) } func newAuthToken() (string, error) { buffer := make([]byte, 32) if _, err := rand.Read(buffer); err != nil { return "", err } return hex.EncodeToString(buffer), nil } func (s *Server) requestIsHTTPS(request *http.Request) bool { if request == nil { return false } if request.TLS != nil { return true } s.configMu.RLock() proxyMode := s.config.Server.HTTPSMode == "proxy" s.configMu.RUnlock() if !proxyMode { return false } forwardedProto := strings.TrimSpace(strings.Split(request.Header.Get("X-Forwarded-Proto"), ",")[0]) return strings.EqualFold(forwardedProto, "https") } func minTime(left, right time.Time) time.Time { if right.IsZero() || left.Before(right) { return left } return right } func max(left, right int) int { if left > right { return left } return right }