package handler import ( "errors" "net/http" "strconv" "strings" "time" "backend/internal/config" "backend/internal/model" "backend/internal/service" "github.com/gin-gonic/gin" ) type AuthHandler struct { cfg *config.Config auth *service.AuthService limiter *service.RateLimitService } func NewAuthHandler(cfg *config.Config, auth *service.AuthService, limiter *service.RateLimitService) *AuthHandler { return &AuthHandler{ cfg: cfg, auth: auth, limiter: limiter, } } func (h *AuthHandler) Config(c *gin.Context) { data, err := h.auth.AuthConfig(c.Request.Context()) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load auth config"}) return } c.JSON(http.StatusOK, data) } func (h *AuthHandler) SendCode(c *gin.Context) { var body struct { Email string `json:"email"` Purpose string `json:"purpose"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"}) return } ip := clientIP(c) if err := h.enforceRateLimit(c, "auth:send-code:ip:"+ip, 5, time.Hour); err != nil { return } if email, err := service.ValidateEmail(body.Email); err == nil { if err := h.enforceRateLimit(c, "auth:send-code:email:"+email, 3, 10*time.Minute); err != nil { return } } if err := h.auth.SendCode(c.Request.Context(), body.Email, body.Purpose); err != nil { c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"ok": true}) } func (h *AuthHandler) Register(c *gin.Context) { var body struct { Email string `json:"email"` Username string `json:"username"` Name string `json:"name"` Password string `json:"password"` InviteCode string `json:"invite_code"` EmailCode string `json:"email_code"` Code string `json:"code"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"}) return } username := strings.TrimSpace(body.Username) if username == "" { username = strings.TrimSpace(body.Name) } if err := h.enforceRateLimit(c, "auth:register:ip:"+clientIP(c), 10, time.Hour); err != nil { return } emailCode := strings.TrimSpace(body.EmailCode) if emailCode == "" { emailCode = strings.TrimSpace(body.Code) } user, token, session, err := h.auth.Register( c.Request.Context(), body.Email, username, body.Password, body.InviteCode, emailCode, clientIP(c), ) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) return } h.writeSession(c, token, session, user) } func (h *AuthHandler) Login(c *gin.Context) { var body struct { Identifier string `json:"identifier"` Email string `json:"email"` Username string `json:"username"` Password string `json:"password"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"}) return } identifier := strings.TrimSpace(body.Identifier) if identifier == "" { if strings.TrimSpace(body.Email) != "" { identifier = strings.TrimSpace(body.Email) } else { identifier = strings.TrimSpace(body.Username) } } if identifier == "" || body.Password == "" { c.JSON(http.StatusBadRequest, gin.H{"detail": "账号或密码不能为空"}) return } ip := clientIP(c) if err := h.enforceRateLimit(c, "auth:login:ip:"+ip, 20, 15*time.Minute); err != nil { return } if normalized, err := service.ValidateLoginIdentifier(identifier); err == nil { if err := h.enforceRateLimit(c, "auth:login:target:"+ip+":"+strings.ToLower(normalized), 8, 15*time.Minute); err != nil { return } } user, token, session, err := h.auth.Login(c.Request.Context(), identifier, body.Password, ip) if err != nil { if writeLoginLocked(c, err) { return } if err == service.ErrAuthFailed { c.JSON(http.StatusUnauthorized, gin.H{"detail": "账号或密码错误"}) return } c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) return } h.writeSession(c, token, session, user) } func (h *AuthHandler) ResetPassword(c *gin.Context) { var body struct { Email string `json:"email"` Password string `json:"password"` EmailCode string `json:"email_code"` Code string `json:"code"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"}) return } ip := clientIP(c) if err := h.enforceRateLimit(c, "auth:reset:ip:"+ip, 5, time.Hour); err != nil { return } if email, err := service.ValidateEmail(body.Email); err == nil { if err := h.enforceRateLimit(c, "auth:reset:email:"+email, 5, time.Hour); err != nil { return } } emailCode := strings.TrimSpace(body.EmailCode) if emailCode == "" { emailCode = strings.TrimSpace(body.Code) } if err := h.auth.ResetPassword(c.Request.Context(), body.Email, body.Password, emailCode, ip); err != nil { if writeLoginLocked(c, err) { return } c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"ok": true}) } func (h *AuthHandler) ChangePassword(c *gin.Context) { user := currentUser(c) if user == nil { c.JSON(http.StatusUnauthorized, gin.H{"detail": "未登录或会话已过期"}) return } if err := h.enforceRateLimit(c, "auth:change-password:user:"+user.ID, 10, 30*time.Minute); err != nil { return } var body struct { CurrentPassword string `json:"current_password"` Current string `json:"current"` NewPassword string `json:"new_password"` Password string `json:"password"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"}) return } current := strings.TrimSpace(body.CurrentPassword) if current == "" { current = strings.TrimSpace(body.Current) } next := strings.TrimSpace(body.NewPassword) if next == "" { next = body.Password } if err := h.auth.ChangePassword(c.Request.Context(), user.ID, current, next); err != nil { c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"ok": true}) } func (h *AuthHandler) Checkin(c *gin.Context) { user := currentUser(c) if user == nil { c.JSON(http.StatusUnauthorized, gin.H{"detail": "未登录或会话已过期"}) return } result, err := h.auth.Checkin(c.Request.Context(), user.ID) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "ok": true, "already": result.Already, "awarded": result.Awarded, "streak": result.Streak, "credits": result.Credits, }) } func (h *AuthHandler) Invites(c *gin.Context) { user := currentUser(c) if user == nil { c.JSON(http.StatusUnauthorized, gin.H{"detail": "未登录或会话已过期"}) return } items, err := h.auth.InviteList(c.Request.Context(), user.ID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load invites"}) return } c.JSON(http.StatusOK, gin.H{"data": items, "reward": h.auth.InviteReward(c.Request.Context())}) } func (h *AuthHandler) Logout(c *gin.Context) { token := service.ParseBearer(c.GetHeader("Authorization")) if token == "" { token = readCookie(c, h.cfg.SessionCookieName) } _ = h.auth.Logout(c.Request.Context(), token) c.SetCookie(h.cfg.SessionCookieName, "", -1, "/", "", h.cfg.CookieSecure, true) c.JSON(http.StatusOK, gin.H{"ok": true}) } func (h *AuthHandler) Me(c *gin.Context) { userValue, ok := c.Get("current_user") if !ok { c.JSON(http.StatusUnauthorized, gin.H{"detail": "未登录或会话已过期"}) return } sessionValue, ok := c.Get("current_session") if !ok { c.JSON(http.StatusUnauthorized, gin.H{"detail": "未登录或会话已过期"}) return } user, _ := userValue.(*model.User) session, _ := sessionValue.(*service.SessionPayload) if user == nil || session == nil { c.JSON(http.StatusUnauthorized, gin.H{"detail": "账号或密码错误"}) return } publicUser, err := h.auth.PublicUser(c.Request.Context(), user) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load user profile"}) return } c.JSON(http.StatusOK, gin.H{ "ok": true, "expires_at": session.ExpiresAt, "user": publicUser, }) } // writeLoginLocked maps a LoginGuard lockout error to HTTP 429 with a // Retry-After header (mirrors Python api/auth.py:226-237). Returns true when it // handled the error so the caller stops processing. func writeLoginLocked(c *gin.Context, err error) bool { var locked *service.LoginLockedError if errors.As(err, &locked) { c.Header("Retry-After", strconv.Itoa(locked.RetryAfter)) c.JSON(http.StatusTooManyRequests, gin.H{"detail": locked.Error()}) return true } return false } func clientIP(c *gin.Context) string { if fwd := strings.TrimSpace(c.GetHeader("X-Forwarded-For")); fwd != "" { parts := strings.Split(fwd, ",") return strings.TrimSpace(parts[0]) } if real := strings.TrimSpace(c.GetHeader("X-Real-Ip")); real != "" { return real } return c.ClientIP() } func (h *AuthHandler) enforceRateLimit(c *gin.Context, bucket string, limit int64, window time.Duration) error { if h.limiter == nil { return nil } if err := h.limiter.Enforce(c.Request.Context(), bucket, limit, window); err != nil { if errors.Is(err, service.ErrRateLimited) { c.JSON(http.StatusTooManyRequests, gin.H{"detail": err.Error()}) return err } c.JSON(http.StatusInternalServerError, gin.H{"detail": "rate limiter unavailable"}) return err } return nil } func (h *AuthHandler) writeSession(c *gin.Context, token string, session *service.SessionPayload, user *model.User) { c.SetSameSite(http.SameSiteLaxMode) c.SetCookie(h.cfg.SessionCookieName, token, int(h.cfg.SessionTTL.Seconds()), "/", "", h.cfg.CookieSecure, true) publicUser, err := h.auth.PublicUser(c.Request.Context(), user) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load user profile"}) return } c.JSON(http.StatusOK, gin.H{ "ok": true, "token": token, "expires_at": session.ExpiresAt, "user": publicUser, }) }