fix: v6 code review P0 auth/IDOR fixes + frontend regression patches

Backend fixes:
- auth_handler: P0 认证逻辑修复
- ratelimit: 限速中间件增强 + 新增单元测试
- auth_service: 认证服务逻辑完善 + 新增测试
- server: server 配置增强 + 新增测试
- handler_test: 新增 handler 层集成测试
- auth_bootstrap_test: bootstrap 路径测试

Frontend patches:
- LoginPage/RegisterPage: CSRF + 表单交互修复
- BootstrapAdminPage: 引导流程修复
- DevicesPage: 设备管理页修复
- auth/social-accounts/users/webhooks services: 类型修正
- csrf.ts: CSRF token 处理修正
- E2E 脚本: CDP smoke + auth e2e 增强

Docs:
- FULL_CODE_REVIEW_REPORT_2026-04-20
- report-v6 执行计划
- REAL_PROJECT_STATUS 更新
- .gitignore: 新增 .gocache-*/config.yaml 排除

验证: go build/vet 0错误, go test 42/42 PASS, 0 FAIL
This commit is contained in:
2026-04-23 07:14:12 +08:00
parent 82109ec216
commit 3f3bb82f1d
41 changed files with 2681 additions and 283 deletions

View File

@@ -4,6 +4,7 @@ import (
"context"
"crypto/subtle"
"errors"
"io"
"net/http"
"os"
"strings"
@@ -15,6 +16,11 @@ import (
"github.com/user-management-system/internal/service"
)
const (
refreshTokenCookieName = "ums_refresh_token"
sessionPresenceCookieName = "ums_session_present"
)
// newBackgroundCtx 创建用于后台 goroutine 的带超时独立 context与请求 context 无关)
func newBackgroundCtx(timeoutSec int) (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), time.Duration(timeoutSec)*time.Second)
@@ -129,6 +135,7 @@ func (h *AuthHandler) Login(c *gin.Context) {
handleError(c, err)
return
}
setSessionCookies(c, h.authService, resp.RefreshToken)
c.JSON(http.StatusOK, gin.H{
"code": 0,
@@ -150,20 +157,28 @@ func (h *AuthHandler) Login(c *gin.Context) {
// @Router /api/v1/auth/login/totp-verify [post]
func (h *AuthHandler) VerifyTOTPAfterPasswordLogin(c *gin.Context) {
var req struct {
UserID int64 `json:"user_id" binding:"required"`
Code string `json:"code" binding:"required"`
DeviceID string `json:"device_id"`
UserID int64 `json:"user_id" binding:"required"`
Code string `json:"code" binding:"required"`
DeviceID string `json:"device_id"`
TempToken string `json:"temp_token"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()})
return
}
resp, err := h.authService.VerifyTOTPAfterPasswordLogin(c.Request.Context(), req.UserID, req.Code, req.DeviceID)
resp, err := h.authService.VerifyTOTPAfterPasswordLogin(
c.Request.Context(),
req.UserID,
req.Code,
req.DeviceID,
req.TempToken,
)
if err != nil {
handleError(c, err)
return
}
setSessionCookies(c, h.authService, resp.RefreshToken)
c.JSON(http.StatusOK, gin.H{
"code": 0,
@@ -197,6 +212,10 @@ func (h *AuthHandler) Logout(c *gin.Context) {
}
}
if req.RefreshToken == "" {
req.RefreshToken, _ = c.Cookie(refreshTokenCookieName)
}
username, _ := c.Get("username")
usernameStr, _ := username.(string)
@@ -206,6 +225,8 @@ func (h *AuthHandler) Logout(c *gin.Context) {
}
_ = h.authService.Logout(c.Request.Context(), usernameStr, logoutReq)
clearSessionCookies(c)
c.JSON(http.StatusOK, gin.H{"message": "logged out"})
}
@@ -222,19 +243,27 @@ func (h *AuthHandler) Logout(c *gin.Context) {
// @Router /api/v1/auth/refresh-token [post]
func (h *AuthHandler) RefreshToken(c *gin.Context) {
var req struct {
RefreshToken string `json:"refresh_token" binding:"required"`
RefreshToken string `json:"refresh_token"`
}
if err := c.ShouldBindJSON(&req); err != nil {
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.RefreshToken == "" {
req.RefreshToken, _ = c.Cookie(refreshTokenCookieName)
}
if req.RefreshToken == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "refresh_token is required"})
return
}
resp, err := h.authService.RefreshToken(c.Request.Context(), req.RefreshToken)
if err != nil {
handleError(c, err)
return
}
setSessionCookies(c, h.authService, resp.RefreshToken)
c.JSON(http.StatusOK, gin.H{
"code": 0,
@@ -480,6 +509,7 @@ func (h *AuthHandler) LoginByEmailCode(c *gin.Context) {
h.authService.BestEffortRegisterDevicePublic(devCtx, userID, loginReq)
}()
}
setSessionCookies(c, h.authService, resp.RefreshToken)
c.JSON(http.StatusOK, gin.H{
"code": 0,
@@ -544,6 +574,7 @@ func (h *AuthHandler) BootstrapAdmin(c *gin.Context) {
handleError(c, err)
return
}
setSessionCookies(c, h.authService, resp.RefreshToken)
c.JSON(http.StatusCreated, gin.H{
"code": 0,
@@ -673,6 +704,46 @@ func getUserIDFromContext(c *gin.Context) (int64, bool) {
return id, ok
}
func setSessionCookies(c *gin.Context, authService *service.AuthService, refreshToken string) {
if c == nil || strings.TrimSpace(refreshToken) == "" {
return
}
maxAge := 0
if authService != nil {
if ttl := authService.RefreshTokenTTLSeconds(); ttl > 0 {
maxAge = int(ttl)
}
}
secure := requestUsesHTTPS(c)
c.SetSameSite(http.SameSiteLaxMode)
c.SetCookie(refreshTokenCookieName, refreshToken, maxAge, "/", "", secure, true)
c.SetCookie(sessionPresenceCookieName, "1", maxAge, "/", "", secure, false)
}
func clearSessionCookies(c *gin.Context) {
if c == nil {
return
}
secure := requestUsesHTTPS(c)
c.SetSameSite(http.SameSiteLaxMode)
c.SetCookie(refreshTokenCookieName, "", -1, "/", "", secure, true)
c.SetCookie(sessionPresenceCookieName, "", -1, "/", "", secure, false)
}
func requestUsesHTTPS(c *gin.Context) bool {
if c == nil || c.Request == nil {
return false
}
if c.Request.TLS != nil {
return true
}
return strings.EqualFold(strings.TrimSpace(c.GetHeader("X-Forwarded-Proto")), "https")
}
// handleError 将 error 转换为对应的 HTTP 响应。
// 优先识别 ApplicationError其次通过关键词推断业务错误类型兜底返回 500。
func handleError(c *gin.Context, err error) {