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>
351 lines
10 KiB
Go
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,
|
|
})
|
|
}
|