refactor: 完成前后端认证体系重构与安全增强

本提交重构了全栈的认证与安全体系,包含以下核心变更:
1. **后端安全增强**:
   - 使用常量时间比较修复CSRF校验时序漏洞
   - 重构refresh token存储为哈希+密文,支持轮转宽限期与盗用检测
   - 新增cookie前缀自动配置,支持__Host-前缀强化生产环境安全
   - 优化登出逻辑为单设备登出,合并用户信息与未读通知接口
   - 增加数据库迁移脚本,兼容旧版refresh token数据升级
   - 新增定时清理过期refresh token任务

2. **前端安全与体验优化**:
   - 新增cookie工具库,统一管理认证cookie名与转发逻辑
   - 修复开放重定向漏洞,实现安全跳转校验
   - 重构SSR请求逻辑,仅转发必要的access token cookie
   - 优化middleware轮转逻辑,修复并发请求去重与边缘部署兼容性
   - 修复登录态静默校正逻辑,兼容旧版cookie路径与名称
   - 新增环境变量示例配置文件

3. **工程化改进**:
   修复Next.js类型文件导入路径,统一前后端配置与命名规范
This commit is contained in:
2026-09-12 15:23:13 +08:00
parent 995e0b2c1f
commit 055688a6cf
17 changed files with 665 additions and 188 deletions

View File

@@ -5,6 +5,7 @@ import (
"time"
"github.com/freefire/jiang13-bbs/middleware"
"github.com/freefire/jiang13-bbs/model"
"github.com/freefire/jiang13-bbs/service"
"github.com/gin-gonic/gin"
)
@@ -15,14 +16,16 @@ var (
)
// setAuthCookies 设置认证 cookie:
// - j13_token: access token(HttpOnly,15min)
// - j13_refresh: refresh token(HttpOnly,7天,Path=/,供 Next middleware 在
// 页面/RSC 请求中读取并静默轮转;HttpOnly+SameSite=Strict+CSRF 校验已足够防护)
// - j13_csrf: CSRF token(可读,7天,与 refresh 同生命周期,确保 refresh 流程可用)
// - access token(HttpOnly,15min)
// - refresh token(HttpOnly,7天,Path=/,供 Next middleware 在页面/RSC
// 请求中读取并静默轮转;仅 /api/auth/refresh 端点消费)
// - CSRF token(JS 可读,7天,双提交校验)
// SameSite=Lax:允许外站顶级链接进入时保留登录态(Strict 会导致从外站
// 跳转进来的第一次请求丢 cookie,把已登录用户误判为游客);状态变更请求
// 另有 CSRF 双提交 token 兜底。生产 HTTPS 下 cookie 名带 __Host- 前缀。
func setAuthCookies(c *gin.Context, accessToken, refreshToken string, secure bool) {
csrfToken := service.GenerateCSRFToken()
// Access token cookie
http.SetCookie(c.Writer, &http.Cookie{
Name: service.CookieName,
Value: accessToken,
@@ -30,10 +33,8 @@ func setAuthCookies(c *gin.Context, accessToken, refreshToken string, secure boo
MaxAge: accessCookieMaxAge,
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteStrictMode,
SameSite: http.SameSiteLaxMode,
})
// Refresh token cookie:Path=/ 以支持 SSR middleware 续期;
// 仅 /api/auth/refresh 端点消费,其他路径不读取
http.SetCookie(c.Writer, &http.Cookie{
Name: service.RefreshCookieName,
Value: refreshToken,
@@ -41,9 +42,8 @@ func setAuthCookies(c *gin.Context, accessToken, refreshToken string, secure boo
MaxAge: refreshCookieMaxAge,
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteStrictMode,
SameSite: http.SameSiteLaxMode,
})
// CSRF cookie(前端可读,放入 X-CSRF-Token header;与 refresh 同寿命以支持 refresh 流程)
http.SetCookie(c.Writer, &http.Cookie{
Name: service.CSRFCookieName,
Value: csrfToken,
@@ -51,19 +51,31 @@ func setAuthCookies(c *gin.Context, accessToken, refreshToken string, secure boo
MaxAge: refreshCookieMaxAge,
HttpOnly: false,
Secure: secure,
SameSite: http.SameSiteStrictMode,
SameSite: http.SameSiteLaxMode,
})
}
// clearAuthCookies 清除所有认证 cookie
func clearAuthCookies(c *gin.Context) {
// refresh cookie 同时清除两个路径:Path=/api/auth 为旧版兼容
// clearAuthCookies 清除所有认证 cookie。
// 除当前名称外,还需兼容清除:
// - 启用 __Host- 前缀之前的旧无前缀名称(Path=/)
// - 更早期 Path=/api/auth 的旧 refresh cookie
func clearAuthCookies(c *gin.Context, secure bool) {
cookies := []struct{ name, path string }{
{service.CookieName, "/"},
{service.RefreshCookieName, "/"},
{service.RefreshCookieName, "/api/auth"},
{service.CSRFCookieName, "/"},
}
// 当前生产名称带 __Host- 前缀时,旧无前缀 cookie 仍残留在浏览器中
if service.CookieName != "j13_token" {
cookies = append(cookies,
struct{ name, path string }{"j13_token", "/"},
struct{ name, path string }{"j13_refresh", "/"},
struct{ name, path string }{"j13_csrf", "/"},
)
}
// 更早期的 refresh cookie 只挂在 /api/auth 下
cookies = append(cookies, struct{ name, path string }{"j13_refresh", "/api/auth"})
for _, ck := range cookies {
http.SetCookie(c.Writer, &http.Cookie{
Name: ck.name,
@@ -71,8 +83,8 @@ func clearAuthCookies(c *gin.Context) {
Path: ck.path,
MaxAge: -1,
HttpOnly: true,
Secure: false,
SameSite: http.SameSiteStrictMode,
Secure: secure,
SameSite: http.SameSiteLaxMode,
Expires: time.Unix(0, 0),
})
}
@@ -143,7 +155,7 @@ func (h *Handlers) Refresh(c *gin.Context) {
}
accessToken, newRefresh, user, err := h.Auth.RotateRefreshToken(refreshToken)
if err != nil {
clearAuthCookies(c)
clearAuthCookies(c, !h.Cfg.DevMode)
c.JSON(http.StatusUnauthorized, gin.H{"error": "登录已过期,请重新登录"})
return
}
@@ -159,14 +171,14 @@ func (h *Handlers) Refresh(c *gin.Context) {
})
}
// Logout 登出:撤销 refresh token,清除所有认证 cookie
// Logout 登出:仅撤销本次请求携带的那枚 refresh token(单设备登出语义,
// 不影响该用户其他设备的登录态),并清除所有认证 cookie。
// access JWT 无状态、15 分钟自然过期;登出后 tv 不递增,属可接受的短窗口。
func (h *Handlers) Logout(c *gin.Context) {
claims := middleware.CurrentUser(c)
if claims != nil {
// 撤销该用户所有 refresh token(防止 refresh token 被盗用)
h.Auth.RevokeAllUserRefreshTokens(claims.ID)
if refreshToken, err := c.Cookie(service.RefreshCookieName); err == nil && refreshToken != "" {
h.Auth.RevokeRefreshToken(refreshToken)
}
clearAuthCookies(c)
clearAuthCookies(c, !h.Cfg.DevMode)
c.JSON(http.StatusOK, gin.H{"message": "已登出"})
}
@@ -189,29 +201,36 @@ func (h *Handlers) ChangePassword(c *gin.Context) {
return
}
// 密码已改,token_version 已递增,旧 token 全部失效,清除 cookie 要求重新登录
clearAuthCookies(c)
clearAuthCookies(c, !h.Cfg.DevMode)
c.JSON(http.StatusOK, gin.H{"message": "密码修改成功,请重新登录"})
}
// Me 获取当前用户信息
// meUserBody 统一的用户信息响应体(Me/Login/Refresh 共用形状)
func meUserBody(user *model.User) gin.H {
return gin.H{
"id": user.ID,
"username": user.Username,
"nickname": user.Nickname,
"avatar": user.Avatar,
"role": user.Role,
}
}
// Me 获取当前用户信息 + 未读通知数(合并为一次请求,供 SSR layout 直出)
func (h *Handlers) Me(c *gin.Context) {
claims := middleware.CurrentUser(c)
if claims == nil {
c.JSON(http.StatusOK, gin.H{"user": nil})
c.JSON(http.StatusOK, gin.H{"user": nil, "unread_count": 0})
return
}
user, err := h.Auth.GetUserByID(claims.ID)
if err != nil {
c.JSON(http.StatusOK, gin.H{"user": nil})
c.JSON(http.StatusOK, gin.H{"user": nil, "unread_count": 0})
return
}
c.JSON(http.StatusOK, gin.H{
"user": gin.H{
"id": user.ID,
"username": user.Username,
"nickname": user.Nickname,
"avatar": user.Avatar,
"role": user.Role,
},
})
var unread int64
if n, err := h.Notification.UnreadCount(claims.ID); err == nil {
unread = n
}
c.JSON(http.StatusOK, gin.H{"user": meUserBody(user), "unread_count": unread})
}