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:
@@ -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 失效
|
||||
|
||||
Reference in New Issue
Block a user