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

@@ -7,6 +7,7 @@ import (
"github.com/freefire/jiang13-bbs/config"
"github.com/freefire/jiang13-bbs/model"
"github.com/freefire/jiang13-bbs/router"
"github.com/freefire/jiang13-bbs/service"
)
func main() {
@@ -19,6 +20,11 @@ func main() {
log.Fatalf("数据库初始化失败: %v", err)
}
// 生产 HTTPS 启用 __Host- 认证 cookie 前缀(须在路由注册、读写 cookie 前配置)
service.ConfigureCookieNames(!cfg.DevMode)
// 过期/已吊销 refresh token 定期清理
service.StartRefreshTokenCleanup(model.DB)
r, err := router.Setup(cfg)
if err != nil {
log.Fatalf("路由初始化失败: %v", err)

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})
}

View File

@@ -1,6 +1,7 @@
package middleware
import (
"crypto/subtle"
"net/http"
"github.com/freefire/jiang13-bbs/service"
@@ -24,7 +25,9 @@ func CSRFMiddleware() gin.HandlerFunc {
}
headerToken := c.GetHeader(service.CSRFHeaderName)
if headerToken == "" || headerToken != cookieToken {
// 常量时间比较,避免通过响应耗时逐字节猜测 CSRF token
if headerToken == "" ||
subtle.ConstantTimeCompare([]byte(headerToken), []byte(cookieToken)) != 1 {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "CSRF 校验失败"})
return
}

View File

@@ -1,6 +1,8 @@
package model
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"log"
@@ -21,12 +23,23 @@ func InitDB(dsn string) error {
return fmt.Errorf("连接 PostgreSQL 失败: %w", err)
}
// 旧表 refresh_tokens.token(明文)→ token_hash 体系:必须在 AutoMigrate
// 创建 NOT NULL 列/唯一索引之前完成回填
if err := prepareRefreshTokenMigration(db); err != nil {
return fmt.Errorf("refresh token 旧数据迁移失败: %w", err)
}
if err := db.AutoMigrate(
&User{}, &Board{}, &Post{}, &Comment{}, &RefreshToken{}, &Like{}, &Notification{},
); err != nil {
return fmt.Errorf("自动迁移失败: %w", err)
}
// 新表结构就位后删除遗留的明文列
if err := dropLegacyRefreshTokenColumn(db); err != nil {
return fmt.Errorf("refresh token 旧列清理失败: %w", err)
}
DB = db
seedDefaultBoards(db)
log.Println("[model] PostgreSQL 数据库初始化完成")
@@ -45,6 +58,87 @@ func PingDB() error {
return sqlDB.Ping()
}
// prepareRefreshTokenMigration 旧版 refresh_tokens 表把明文存在 token 列,
// 新版改为 token_hash(SHA-256,NOT NULL+唯一索引)。在 AutoMigrate 之前:
// 1. 新增可带默认值的 token_hash 列(避免对存量行加 NOT NULL 列失败)
// 2. 用存量明文回填哈希
// 3. AutoMigrate 随后补唯一索引/其余新列
//
// 旧行无法回填 TokenCipher(密钥在 service 层),仅影响该行下一次轮转的
// 并发重放宽限,属一次性边界;轮转后即完全进入新体系。
func prepareRefreshTokenMigration(db *gorm.DB) error {
var tableCount int64
if err := db.Raw(`SELECT count(1) FROM information_schema.tables WHERE table_name = 'refresh_tokens'`).
Scan(&tableCount).Error; err != nil {
return err
}
if tableCount == 0 {
return nil // 全新数据库,AutoMigrate 直接建新表
}
var hasHashCol int64
if err := db.Raw(`SELECT count(1) FROM information_schema.columns
WHERE table_name = 'refresh_tokens' AND column_name = 'token_hash'`).
Scan(&hasHashCol).Error; err != nil {
return err
}
if hasHashCol > 0 {
return nil // 已是新结构
}
var hasLegacyCol int64
if err := db.Raw(`SELECT count(1) FROM information_schema.columns
WHERE table_name = 'refresh_tokens' AND column_name = 'token'`).
Scan(&hasLegacyCol).Error; err != nil {
return err
}
if hasLegacyCol == 0 {
return nil
}
if err := db.Exec(`DELETE FROM refresh_tokens WHERE token IS NULL OR token = ''`).Error; err != nil {
return err
}
if err := db.Exec(`ALTER TABLE refresh_tokens ADD COLUMN token_hash varchar(64) NOT NULL DEFAULT ''`).Error; err != nil {
return err
}
type legacyRow struct {
ID uint
Token string
}
var rows []legacyRow
if err := db.Raw(`SELECT id, token FROM refresh_tokens`).Scan(&rows).Error; err != nil {
return err
}
for _, r := range rows {
sum := sha256.Sum256([]byte(r.Token))
if err := db.Exec(
`UPDATE refresh_tokens SET token_hash = ? WHERE id = ?`,
hex.EncodeToString(sum[:]), r.ID,
).Error; err != nil {
return err
}
}
log.Printf("[model] refresh_tokens 已回填 %d 行 token_hash", len(rows))
return nil
}
// dropLegacyRefreshTokenColumn 新结构就位后删除明文 token 列(PostgreSQL
// 会连带删除该列上的旧唯一索引)
func dropLegacyRefreshTokenColumn(db *gorm.DB) error {
var hasLegacyCol int64
if err := db.Raw(`SELECT count(1) FROM information_schema.columns
WHERE table_name = 'refresh_tokens' AND column_name = 'token'`).
Scan(&hasLegacyCol).Error; err != nil {
return err
}
if hasLegacyCol == 0 {
return nil
}
return db.Exec(`ALTER TABLE refresh_tokens DROP COLUMN token`).Error
}
// seedDefaultBoards 写入默认板块
func seedDefaultBoards(db *gorm.DB) {
defaults := []Board{

View File

@@ -43,15 +43,22 @@ type User struct {
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// RefreshToken 刷新令牌表(支持服务端撤销与轮转)
// RefreshToken 刷新令牌表(支持服务端撤销、一次性轮转与盗用检测)
// - TokenHash: token 的 SHA-256,数据库不存明文
// - TokenCipher: token 的 AES-GCM 密文,仅存在于"当前有效"行;轮转后旧行立即抹除。
// 保留它是为了在轮转宽限期内把【同一个】新 token 返回给并发重放请求(见 service 层)
// - RotatedTo/RotatedAt: 轮转链,用于宽限重放判定与盗用(吊销后重放)杀全家族
type RefreshToken struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"index;not null" json:"user_id"`
Token string `gorm:"uniqueIndex;size:128;not null" json:"-"`
ExpiresAt time.Time `gorm:"index;not null" json:"expires_at"`
Revoked bool `gorm:"default:false" json:"revoked"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"index;not null" json:"user_id"`
TokenHash string `gorm:"uniqueIndex;size:64;not null" json:"-"`
TokenCipher string `gorm:"size:512;default:''" json:"-"`
ExpiresAt time.Time `gorm:"index;not null" json:"expires_at"`
Revoked bool `gorm:"default:false;index" json:"revoked"`
RotatedTo uint `gorm:"index;default:0" json:"-"`
RotatedAt *time.Time `gorm:"index" json:"-"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Board 论坛板块

View File

@@ -1,27 +1,28 @@
package service
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"io"
"log"
"time"
"github.com/freefire/jiang13-bbs/model"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const (
RoleUser = "user"
RoleAdmin = "admin"
// CookieName JWT access token 存储的 HttpOnly cookie 名
CookieName = "j13_token"
// RefreshCookieName refresh token 存储的 HttpOnly cookie 名
RefreshCookieName = "j13_refresh"
// CSRFCookieName CSRF token cookie 名(非 HttpOnly,前端可读)
CSRFCookieName = "j13_csrf"
// CSRFHeaderName 前端传递 CSRF token 的 header 名
CSRFHeaderName = "X-CSRF-Token"
@@ -29,8 +30,35 @@ const (
AccessTokenTTL = 15 * time.Minute
// RefreshTokenTTL refresh token 有效期(长期)
RefreshTokenTTL = 7 * 24 * time.Hour
// RefreshRotationGrace 轮转宽限期:同枚旧 refresh 在此窗口内被并发重放时,
// 返回同一个新 token 对(放行),超过窗口的重放视为盗用(OAuth BCP 模式)
RefreshRotationGrace = 60 * time.Second
// RefreshTokenRetention 已过期/已吊销行的保留期:过期后仍需短期留痕用于盗用检测
RefreshTokenRetention = 30 * 24 * time.Hour
)
// 认证 cookie 名(变量,生产 HTTPS 下通过 ConfigureCookieNames 启用 __Host- 前缀)
var (
// CookieName JWT access token 存储的 HttpOnly cookie 名
CookieName = "j13_token"
// RefreshCookieName refresh token 存储的 HttpOnly cookie 名
RefreshCookieName = "j13_refresh"
// CSRFCookieName CSRF token cookie 名(非 HttpOnly,前端可读)
CSRFCookieName = "j13_csrf"
)
// ConfigureCookieNames 生产环境(HTTPS)启用 __Host- 前缀:
// 浏览器强制 Secure、Path=/、禁止 Domain,进一步收窄 cookie 作用面。
// 必须在路由注册前调用一次;前端按 NODE_ENV=production 同步启用。
func ConfigureCookieNames(useHostPrefix bool) {
if !useHostPrefix {
return
}
CookieName = "__Host-j13_token"
RefreshCookieName = "__Host-j13_refresh"
CSRFCookieName = "__Host-j13_csrf"
}
// UserClaims JWT 中携带的用户信息
type UserClaims struct {
ID uint `json:"id"`
@@ -44,12 +72,19 @@ type UserClaims struct {
type AuthService struct {
db *gorm.DB
jwtSecret []byte
encKey []byte // refresh token 落库密文的 AES-GCM 密钥(由 JWT 密钥派生)
}
func NewAuthService(db *gorm.DB, jwtSecret string) *AuthService {
return &AuthService{db: db, jwtSecret: []byte(jwtSecret)}
// 域分离派生:即使与 JWT 共用主密钥,密文用途也不可互换
keySum := sha256.Sum256([]byte("j13-refresh-token-aesgcm-v1:" + jwtSecret))
return &AuthService{db: db, jwtSecret: []byte(jwtSecret), encKey: keySum[:]}
}
// dummyPasswordHash 用户不存在时也执行一次 bcrypt 比较,避免通过响应耗时枚举用户名
var dummyPasswordHash, _ = bcrypt.GenerateFromPassword(
[]byte("j13-dummy-password-for-constant-timing"), bcrypt.DefaultCost)
// Register 用户注册
func (s *AuthService) Register(username, email, password string) (*model.User, error) {
// 检查用户名是否已存在
@@ -81,14 +116,17 @@ func (s *AuthService) Register(username, email, password string) (*model.User, e
func (s *AuthService) Login(username, password string) (string, string, *model.User, error) {
var user model.User
if err := s.db.Where("username = ?", username).First(&user).Error; err != nil {
// 仍然消耗一次 bcrypt 比较的时间,避免用户名存在性时序枚举
_ = bcrypt.CompareHashAndPassword(dummyPasswordHash, []byte(password))
return "", "", nil, errors.New("用户名或密码错误")
}
if user.Banned {
return "", "", nil, errors.New("账号已被封禁")
}
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil {
return "", "", nil, errors.New("用户名或密码错误")
}
// 封禁判定放在密码比较之后,同样避免时序差异
if user.Banned {
return "", "", nil, errors.New("账号已被封禁")
}
accessToken, err := s.generateToken(&user)
if err != nil {
@@ -179,13 +217,73 @@ func generateRandomToken() string {
return base64.URLEncoding.EncodeToString(b)
}
// CreateRefreshToken 创建并存储 refresh token
// refresh token 相关错误
var (
ErrRefreshInvalid = errors.New("refresh token 无效")
ErrRefreshExpired = errors.New("refresh token 已过期")
ErrRefreshReused = errors.New("refresh token 存在重用嫌疑")
)
// hashRefreshToken refresh token 只以 SHA-256 形式落库
func hashRefreshToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
// encryptToken / decryptToken:当前有效的 refresh token 保留一份 AES-GCM
// 密文,仅用于轮转宽限期内向并发重放返回同一个新 token;行一旦吊销立即抹除
func (s *AuthService) encryptToken(token string) (string, error) {
block, err := aes.NewCipher(s.encKey)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
cipherText := gcm.Seal(nonce, nonce, []byte(token), nil)
return base64.StdEncoding.EncodeToString(cipherText), nil
}
func (s *AuthService) decryptToken(encoded string) (string, error) {
data, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return "", err
}
block, err := aes.NewCipher(s.encKey)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
if len(data) < gcm.NonceSize() {
return "", errors.New("密文过短")
}
plain, err := gcm.Open(nil, data[:gcm.NonceSize()], data[gcm.NonceSize():], nil)
if err != nil {
return "", err
}
return string(plain), nil
}
// CreateRefreshToken 创建并存储 refresh token(存哈希 + 密文)
func (s *AuthService) CreateRefreshToken(userID uint) (string, error) {
token := generateRandomToken()
cipherText, err := s.encryptToken(token)
if err != nil {
return "", err
}
rt := &model.RefreshToken{
UserID: userID,
Token: token,
ExpiresAt: time.Now().Add(RefreshTokenTTL),
UserID: userID,
TokenHash: hashRefreshToken(token),
TokenCipher: cipherText,
ExpiresAt: time.Now().Add(RefreshTokenTTL),
}
if err := s.db.Create(rt).Error; err != nil {
return "", err
@@ -193,20 +291,10 @@ func (s *AuthService) CreateRefreshToken(userID uint) (string, error) {
return token, nil
}
// ValidateRefreshToken 校验 refresh token 并返回所属用户
func (s *AuthService) ValidateRefreshToken(token string) (*model.User, error) {
var rt model.RefreshToken
if err := s.db.Where("token = ?", token).First(&rt).Error; err != nil {
return nil, errors.New("refresh token 无效")
}
if rt.Revoked {
return nil, errors.New("refresh token 已撤销")
}
if time.Now().After(rt.ExpiresAt) {
return nil, errors.New("refresh token 已过期")
}
// loadActiveUser 加载用户并实时校验封禁状态
func (s *AuthService) loadActiveUser(tx *gorm.DB, userID uint) (*model.User, error) {
var user model.User
if err := s.db.First(&user, rt.UserID).Error; err != nil {
if err := tx.First(&user, userID).Error; err != nil {
return nil, errors.New("用户不存在")
}
if user.Banned {
@@ -215,34 +303,155 @@ func (s *AuthService) ValidateRefreshToken(token string) (*model.User, error) {
return &user, nil
}
// RotateRefreshToken 轮转 refresh token:撤销旧的,签发新的
// ValidateRefreshToken 校验 refresh token 并返回所属用户
func (s *AuthService) ValidateRefreshToken(token string) (*model.User, error) {
var rt model.RefreshToken
if err := s.db.Where("token_hash = ?", hashRefreshToken(token)).First(&rt).Error; err != nil {
return nil, ErrRefreshInvalid
}
if rt.Revoked {
return nil, errors.New("refresh token 已撤销")
}
if time.Now().After(rt.ExpiresAt) {
return nil, ErrRefreshExpired
}
return s.loadActiveUser(s.db, rt.UserID)
}
// RotateRefreshToken 一次性轮转 refresh token(整个判定与写库在单事务内完成):
// - 正常:旧行吊销并链接到新行,返回新 access + 新 refresh
// - 宽限期并发重放(RSC 预取/多实例几乎同时到达):返回【同一个】新 token 对,
// 不再轮转,避免浏览器收到互相吊销的两套 cookie 而随机掉线
// - 吊销后重放(被盗信号):撤销该用户全部 refresh token,强制重新登录
func (s *AuthService) RotateRefreshToken(oldToken string) (string, string, *model.User, error) {
user, err := s.ValidateRefreshToken(oldToken)
if err != nil {
return "", "", nil, err
var (
accessToken, newRefresh string
outUser *model.User
outErr error
)
txErr := s.db.Transaction(func(tx *gorm.DB) error {
var rt model.RefreshToken
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("token_hash = ?", hashRefreshToken(oldToken)).First(&rt).Error; err != nil {
outErr = ErrRefreshInvalid
return nil
}
now := time.Now()
if now.After(rt.ExpiresAt) {
outErr = ErrRefreshExpired
return nil
}
user, err := s.loadActiveUser(tx, rt.UserID)
if err != nil {
outErr = err
return nil
}
if rt.Revoked {
// 宽限期内、且轮转链指向的新 token 仍健康:并发重放,返回同一对
if rt.RotatedTo != 0 && rt.RotatedAt != nil && now.Sub(*rt.RotatedAt) <= RefreshRotationGrace {
var next model.RefreshToken
if e := tx.First(&next, rt.RotatedTo).Error; e == nil &&
next.UserID == rt.UserID && !next.Revoked && now.Before(next.ExpiresAt) &&
next.TokenCipher != "" {
if sameRefresh, e := s.decryptToken(next.TokenCipher); e == nil {
if at, e := s.generateToken(user); e == nil {
accessToken, newRefresh, outUser = at, sameRefresh, user
return nil
}
}
}
}
// 其他吊销后重放:判定为盗用,撤销该用户整个 refresh token 家族
if err := tx.Model(&model.RefreshToken{}).
Where("user_id = ? AND revoked = ?", rt.UserID, false).
Updates(map[string]any{"revoked": true, "token_cipher": "", "updated_at": now}).Error; err != nil {
return err
}
log.Printf("[auth] 检测到 refresh token 吊销后重放,已撤销 user=%d 的全部 refresh token", rt.UserID)
outErr = ErrRefreshReused
return nil
}
// 正常轮转:先建新行,再吊销旧行并建立轮转链,同时抹除旧行密文
plain := generateRandomToken()
cipherText, err := s.encryptToken(plain)
if err != nil {
return err
}
nr := &model.RefreshToken{
UserID: rt.UserID,
TokenHash: hashRefreshToken(plain),
TokenCipher: cipherText,
ExpiresAt: now.Add(RefreshTokenTTL),
}
if err := tx.Create(nr).Error; err != nil {
return err
}
rotatedAt := now
if err := tx.Model(&rt).Updates(map[string]any{
"revoked": true,
"rotated_to": nr.ID,
"rotated_at": rotatedAt,
"token_cipher": "",
"updated_at": now,
}).Error; err != nil {
return err
}
at, err := s.generateToken(user)
if err != nil {
return err
}
accessToken, newRefresh, outUser = at, plain, user
return nil
})
if txErr != nil {
return "", "", nil, txErr
}
// 撤销旧 token
s.db.Model(&model.RefreshToken{}).Where("token = ?", oldToken).Update("revoked", true)
// 签发新 access + refresh
accessToken, err := s.generateToken(user)
if err != nil {
return "", "", nil, err
if outErr != nil {
return "", "", nil, outErr
}
newRefresh, err := s.CreateRefreshToken(user.ID)
if err != nil {
return "", "", nil, err
}
return accessToken, newRefresh, user, nil
return accessToken, newRefresh, outUser, nil
}
// RevokeRefreshToken 撤销单个 refresh token(登出时用)
// RevokeRefreshToken 撤销单个 refresh token(单设备登出时用)
func (s *AuthService) RevokeRefreshToken(token string) {
s.db.Model(&model.RefreshToken{}).Where("token = ?", token).Update("revoked", true)
if token == "" {
return
}
s.db.Model(&model.RefreshToken{}).Where("token_hash = ?", hashRefreshToken(token)).
Updates(map[string]any{"revoked": true, "token_cipher": ""})
}
// RevokeAllUserRefreshTokens 撤销用户所有 refresh token(改密码/封禁时用)
// RevokeAllUserRefreshTokens 撤销用户所有 refresh token(改密码/封禁/盗用处置时用)
func (s *AuthService) RevokeAllUserRefreshTokens(userID uint) {
s.db.Model(&model.RefreshToken{}).Where("user_id = ?", userID).Update("revoked", true)
s.db.Model(&model.RefreshToken{}).Where("user_id = ? AND revoked = ?", userID, false).
Updates(map[string]any{"revoked": true, "token_cipher": ""})
}
// PurgeExpiredRefreshTokens 物理清理过期或吊销超过保留期的行
// (保留期内仍需留痕,用于吊销后重放的盗用检测)
func PurgeExpiredRefreshTokens(db *gorm.DB) {
cutoff := time.Now().Add(-RefreshTokenRetention)
if err := db.Where("expires_at < ?", cutoff).Delete(&model.RefreshToken{}).Error; err != nil {
log.Printf("[auth] 清理过期 refresh token 失败: %v", err)
}
if err := db.Where("revoked = ? AND updated_at < ?", true, cutoff).
Delete(&model.RefreshToken{}).Error; err != nil {
log.Printf("[auth] 清理已吊销 refresh token 失败: %v", err)
}
}
// StartRefreshTokenCleanup 启动周期清理任务(立即执行一次,之后每 6 小时)
func StartRefreshTokenCleanup(db *gorm.DB) {
go func() {
PurgeExpiredRefreshTokens(db)
ticker := time.NewTicker(6 * time.Hour)
defer ticker.Stop()
for range ticker.C {
PurgeExpiredRefreshTokens(db)
}
}()
}
// IncrementTokenVersion 递增用户 token 版本,使所有已有 JWT 失效