Files
CamTalk/backend/internal/api/auth.go
hhs 7adf81c6e5 feat: 集成限流器到服务
- main.go 初始化限流器(根据 Redis 可用性选择内存/Redis 实现)
- WebSocket handler 添加 query 消息限流(按 userID)
- Auth API 添加登录/注册限流(按 IP)
- refresh 和 logout 不限流(避免影响正常用户操作)
- 修复所有测试(传递 nil limiter 参数)
- 所有测试通过(包括 ws 和 api 集成测试)
2026-06-21 00:00:24 +08:00

211 lines
5.3 KiB
Go

package api
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/hhs/camtalk/internal/auth"
apperr "github.com/hhs/camtalk/internal/errors"
"github.com/hhs/camtalk/internal/ratelimit"
)
// AuthHandler 提供认证相关的 REST 端点。
type AuthHandler struct {
authService auth.Service
tokenMgr *auth.TokenManager
}
// NewAuthHandler 创建 AuthHandler。
func NewAuthHandler(authService auth.Service, tokenMgr *auth.TokenManager) *AuthHandler {
return &AuthHandler{
authService: authService,
tokenMgr: tokenMgr,
}
}
// RegisterRoutes 注册认证相关路由到给定的路由组。
func (h *AuthHandler) RegisterRoutes(rg *gin.RouterGroup, limiter ratelimit.Limiter) {
authGroup := rg.Group("/auth")
{
// 注册和登录端点添加限流中间件(按 IP 限流)
if limiter != nil {
authGroup.POST("/register",
ratelimit.Middleware(limiter, func(c *gin.Context) string {
return c.ClientIP() + ":register"
}),
h.Register)
authGroup.POST("/login",
ratelimit.Middleware(limiter, func(c *gin.Context) string {
return c.ClientIP() + ":login"
}),
h.Login)
} else {
authGroup.POST("/register", h.Register)
authGroup.POST("/login", h.Login)
}
// refresh 和 logout 不限流
authGroup.POST("/refresh", h.Refresh)
authGroup.POST("/logout", auth.AuthMiddleware(h.tokenMgr), h.Logout)
}
}
// Register POST /api/auth/register — 用户注册。
func (h *AuthHandler) Register(c *gin.Context) {
var req auth.RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": apperr.CodeInvalidInput,
"message": "invalid request body",
})
return
}
if msg := validateCredentials(req.Username, req.Password); msg != "" {
c.JSON(http.StatusBadRequest, gin.H{
"code": apperr.CodeInvalidInput,
"message": msg,
})
return
}
resp, err := h.authService.Register(c.Request.Context(), req)
if err != nil {
handleAuthError(c, err)
return
}
c.JSON(http.StatusCreated, resp)
}
// Login POST /api/auth/login — 用户登录。
func (h *AuthHandler) Login(c *gin.Context) {
var req auth.LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": apperr.CodeInvalidInput,
"message": "invalid request body",
})
return
}
if msg := validateCredentials(req.Username, req.Password); msg != "" {
c.JSON(http.StatusBadRequest, gin.H{
"code": apperr.CodeInvalidInput,
"message": msg,
})
return
}
resp, err := h.authService.Login(c.Request.Context(), req)
if err != nil {
handleAuthError(c, err)
return
}
c.JSON(http.StatusOK, resp)
}
// Refresh POST /api/auth/refresh — 刷新令牌。
func (h *AuthHandler) Refresh(c *gin.Context) {
var req auth.RefreshRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": apperr.CodeInvalidInput,
"message": "invalid request body",
})
return
}
if req.RefreshToken == "" {
c.JSON(http.StatusBadRequest, gin.H{
"code": apperr.CodeInvalidInput,
"message": "refresh_token is required",
})
return
}
resp, err := h.authService.Refresh(c.Request.Context(), req)
if err != nil {
handleAuthError(c, err)
return
}
c.JSON(http.StatusOK, resp)
}
// Logout POST /api/auth/logout — 登出(需要认证)。
func (h *AuthHandler) Logout(c *gin.Context) {
userID := c.GetString(auth.ContextKeyUserID)
var req struct {
RefreshToken string `json:"refresh_token"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": apperr.CodeInvalidInput,
"message": "invalid request body",
})
return
}
if req.RefreshToken == "" {
c.JSON(http.StatusBadRequest, gin.H{
"code": apperr.CodeInvalidInput,
"message": "refresh_token is required",
})
return
}
if err := h.authService.Logout(c.Request.Context(), userID, req.RefreshToken); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"code": apperr.CodeInternalError,
"message": "failed to logout",
})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "logged out successfully",
})
}
// validateCredentials 校验用户名和密码格式。
// 返回空字符串表示校验通过,否则返回错误描述。
func validateCredentials(username, password string) string {
if len(username) < 3 || len(username) > 64 {
return "username must be 3-64 characters"
}
if len(password) < 8 || len(password) > 72 {
return "password must be 8-72 characters"
}
return ""
}
// handleAuthError 将 auth 层错误映射为 HTTP 响应。
func handleAuthError(c *gin.Context, err error) {
switch {
case errors.Is(err, auth.ErrUsernameTaken):
c.JSON(http.StatusConflict, gin.H{
"code": apperr.CodeUsernameTaken,
"message": "username already taken",
})
case errors.Is(err, auth.ErrInvalidCredentials):
c.JSON(http.StatusUnauthorized, gin.H{
"code": apperr.CodeInvalidCredentials,
"message": "invalid username or password",
})
case errors.Is(err, auth.ErrRefreshTokenUsed):
c.JSON(http.StatusUnauthorized, gin.H{
"code": apperr.CodeInvalidToken,
"message": "refresh token has been used or expired",
})
default:
c.JSON(http.StatusInternalServerError, gin.H{
"code": apperr.CodeInternalError,
"message": "internal server error",
})
}
}