**迁移文件优化**: - 001_users.up.sql: 添加完整表和列注释,说明密码哈希算法 (bcrypt) 和 token 哈希算法 (SHA-256) - 002_messages.up.sql: 收紧 role 字段至 VARCHAR(10),添加 tokens_used 非负约束,补充完整注释 - 003_sessions.up.sql: 收紧 title 字段至 VARCHAR(100) 并添加长度约束 (1-100),详细说明 config JSONB 结构 - 004_user_scenarios.up.sql: 修复 greeting 字段冲突 (VARCHAR(500)),扩大 icon 至 VARCHAR(20) 支持复合 Emoji,prompt 改为 TEXT 无上限,优化约束逻辑 **后端代码修改**: - auth.go: 移除用户名最小长度限制 (3 字符),仅保留最大长度 64 **前端国际化修改**: - 更新三种语言的登录/注册表单 placeholder 文本,移除字符长度要求提示 - zh-CN: "请输入用户名" / "请输入密码" - en-US: "Enter username" / "Enter password" - ja-JP: "ユーザー名を入力" / "パスワードを入力" **删除文件**: - 移除临时修复迁移文件 (005_fix_user_scenarios_description.*) - 删除临时诊断脚本 (fix_user_scenarios.sql, check_constraints.sql) 所有约束修改都是放宽限制,不影响现有数据。
245 lines
6.2 KiB
Go
245 lines
6.2 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"
|
|
"github.com/hhs/camtalk/internal/trace"
|
|
)
|
|
|
|
// 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) {
|
|
log := trace.FromContext(c.Request.Context())
|
|
clientIP := c.ClientIP()
|
|
|
|
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 {
|
|
log.Warnw("register failed",
|
|
"username", req.Username,
|
|
"client_ip", clientIP,
|
|
"error", err)
|
|
handleAuthError(c, err)
|
|
return
|
|
}
|
|
|
|
log.Infow("register success",
|
|
"username", req.Username,
|
|
"client_ip", clientIP)
|
|
c.JSON(http.StatusCreated, resp)
|
|
}
|
|
|
|
// Login POST /api/auth/login — 用户登录。
|
|
func (h *AuthHandler) Login(c *gin.Context) {
|
|
log := trace.FromContext(c.Request.Context())
|
|
clientIP := c.ClientIP()
|
|
|
|
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 {
|
|
log.Warnw("login failed",
|
|
"username", req.Username,
|
|
"client_ip", clientIP,
|
|
"error", err)
|
|
handleAuthError(c, err)
|
|
return
|
|
}
|
|
|
|
log.Infow("login success",
|
|
"username", req.Username,
|
|
"client_ip", clientIP)
|
|
c.JSON(http.StatusOK, resp)
|
|
}
|
|
|
|
// Refresh POST /api/auth/refresh — 刷新令牌。
|
|
func (h *AuthHandler) Refresh(c *gin.Context) {
|
|
log := trace.FromContext(c.Request.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 {
|
|
log.Warnw("token refresh failed",
|
|
"client_ip", c.ClientIP(),
|
|
"error", err)
|
|
handleAuthError(c, err)
|
|
return
|
|
}
|
|
|
|
log.Infow("token refresh success",
|
|
"client_ip", c.ClientIP())
|
|
c.JSON(http.StatusOK, resp)
|
|
}
|
|
|
|
// Logout POST /api/auth/logout — 登出(需要认证)。
|
|
func (h *AuthHandler) Logout(c *gin.Context) {
|
|
log := trace.FromContext(c.Request.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 {
|
|
log.Errorw("logout failed",
|
|
"user_id", userID,
|
|
"error", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{
|
|
"code": apperr.CodeInternalError,
|
|
"message": "failed to logout",
|
|
})
|
|
return
|
|
}
|
|
|
|
log.Infow("logout success",
|
|
"user_id", userID)
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"message": "logged out successfully",
|
|
})
|
|
}
|
|
|
|
// validateCredentials 校验用户名和密码格式。
|
|
// 返回空字符串表示校验通过,否则返回错误描述。
|
|
func validateCredentials(username, password string) string {
|
|
if len(username) > 64 {
|
|
return "username must not exceed 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",
|
|
})
|
|
}
|
|
}
|