Files
image2api/backend/internal/http/handler/auth.go
T
chiyiandClaude Opus 4.8 606caaf047 Initial open-source release (MIT): image2api AI gateway
Full Go backend + Vue 3 frontend, OpenAI-compatible API, multi-provider
account pools, billing/admin, Docker one-command deploy with auto HTTPS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 22:59:04 +08:00

351 lines
10 KiB
Go

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,
})
}