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

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

3. **工程化改进**:
   修复Next.js类型文件导入路径,统一前后端配置与命名规范
2026-09-12 15:23:13 +08:00

237 lines
7.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handler
import (
"net/http"
"time"
"github.com/freefire/jiang13-bbs/middleware"
"github.com/freefire/jiang13-bbs/model"
"github.com/freefire/jiang13-bbs/service"
"github.com/gin-gonic/gin"
)
var (
accessCookieMaxAge = int(service.AccessTokenTTL.Seconds()) // 15 分钟
refreshCookieMaxAge = int(service.RefreshTokenTTL.Seconds()) // 7 天
)
// setAuthCookies 设置认证 cookie:
// - 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()
http.SetCookie(c.Writer, &http.Cookie{
Name: service.CookieName,
Value: accessToken,
Path: "/",
MaxAge: accessCookieMaxAge,
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
})
http.SetCookie(c.Writer, &http.Cookie{
Name: service.RefreshCookieName,
Value: refreshToken,
Path: "/",
MaxAge: refreshCookieMaxAge,
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
})
http.SetCookie(c.Writer, &http.Cookie{
Name: service.CSRFCookieName,
Value: csrfToken,
Path: "/",
MaxAge: refreshCookieMaxAge,
HttpOnly: false,
Secure: secure,
SameSite: http.SameSiteLaxMode,
})
}
// 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.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,
Value: "",
Path: ck.path,
MaxAge: -1,
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
Expires: time.Unix(0, 0),
})
}
}
// RegisterRequest 注册请求
type RegisterRequest struct {
Username string `json:"username" binding:"required,min=3,max=32"`
Email string `json:"email" binding:"omitempty,email"`
Password string `json:"password" binding:"required,min=6,max=64"`
}
// LoginRequest 登录请求
type LoginRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
// Register 用户注册
func (h *Handlers) Register(c *gin.Context) {
var req RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
user, err := h.Auth.Register(req.Username, req.Email, req.Password)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"id": user.ID,
"username": user.Username,
})
}
// Login 用户登录:access + refresh token 写入 HttpOnly cookie,CSRF 写入可读 cookie
func (h *Handlers) Login(c *gin.Context) {
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
accessToken, refreshToken, user, err := h.Auth.Login(req.Username, req.Password)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
// dev 模式不设 Secure,生产环境需 HTTPS
setAuthCookies(c, accessToken, refreshToken, !h.Cfg.DevMode)
c.JSON(http.StatusOK, gin.H{
"user": gin.H{
"id": user.ID,
"username": user.Username,
"nickname": user.Nickname,
"avatar": user.Avatar,
"role": user.Role,
},
})
}
// Refresh 刷新 access token:验证 refresh token,轮转后签发新 access + refresh
func (h *Handlers) Refresh(c *gin.Context) {
refreshToken, err := c.Cookie(service.RefreshCookieName)
if err != nil || refreshToken == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
return
}
accessToken, newRefresh, user, err := h.Auth.RotateRefreshToken(refreshToken)
if err != nil {
clearAuthCookies(c, !h.Cfg.DevMode)
c.JSON(http.StatusUnauthorized, gin.H{"error": "登录已过期,请重新登录"})
return
}
setAuthCookies(c, accessToken, newRefresh, !h.Cfg.DevMode)
c.JSON(http.StatusOK, gin.H{
"user": gin.H{
"id": user.ID,
"username": user.Username,
"nickname": user.Nickname,
"avatar": user.Avatar,
"role": user.Role,
},
})
}
// Logout 登出:仅撤销本次请求携带的那枚 refresh token(单设备登出语义,
// 不影响该用户其他设备的登录态),并清除所有认证 cookie。
// access JWT 无状态、15 分钟自然过期;登出后 tv 不递增,属可接受的短窗口。
func (h *Handlers) Logout(c *gin.Context) {
if refreshToken, err := c.Cookie(service.RefreshCookieName); err == nil && refreshToken != "" {
h.Auth.RevokeRefreshToken(refreshToken)
}
clearAuthCookies(c, !h.Cfg.DevMode)
c.JSON(http.StatusOK, gin.H{"message": "已登出"})
}
// ChangePasswordRequest 修改密码请求
type ChangePasswordRequest struct {
OldPassword string `json:"old_password" binding:"required"`
NewPassword string `json:"new_password" binding:"required,min=6,max=64"`
}
// ChangePassword 修改密码:校验旧密码,更新新密码,清除所有登录态
func (h *Handlers) ChangePassword(c *gin.Context) {
claims := middleware.CurrentUser(c)
var req ChangePasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.Auth.ChangePassword(claims.ID, req.OldPassword, req.NewPassword); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 密码已改,token_version 已递增,旧 token 全部失效,清除 cookie 要求重新登录
clearAuthCookies(c, !h.Cfg.DevMode)
c.JSON(http.StatusOK, gin.H{"message": "密码修改成功,请重新登录"})
}
// 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, "unread_count": 0})
return
}
user, err := h.Auth.GetUserByID(claims.ID)
if err != nil {
c.JSON(http.StatusOK, gin.H{"user": nil, "unread_count": 0})
return
}
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})
}