新增模块: - 友链(friendlink)后端处理 + 前端管理页与友链板 - 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot - 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉 - 排行榜 service 测试、站点页面测试、时间线发布(timeline_release) 其它改动: - 后端 handlers/services 全量小幅调整 - 前端组件、库函数、URL/品牌/站点 URL 工具更新 - Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
710 lines
25 KiB
Go
710 lines
25 KiB
Go
package service
|
||
|
||
import (
|
||
"crypto/aes"
|
||
"crypto/cipher"
|
||
"crypto/rand"
|
||
"crypto/sha256"
|
||
"encoding/base64"
|
||
"encoding/hex"
|
||
"errors"
|
||
"io"
|
||
"log"
|
||
"strings"
|
||
"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 (
|
||
// CSRFHeaderName 前端传递 CSRF token 的 header 名
|
||
CSRFHeaderName = "X-CSRF-Token"
|
||
|
||
// AccessTokenTTL access token 有效期。安全性由每请求实时校验
|
||
// (token_version + 封禁 + 会话家族活性)保证,吊销即时生效,故取 7 天:
|
||
// 登录后 7 天内即使零轮转也保证不掉线,覆盖移动端长期待机场景。
|
||
// (15min 短期 TTL 时代待机后必删 cookie,唤醒恢复全靠轮转链,
|
||
// 是手机端弹"登录已失效"的主因)
|
||
AccessTokenTTL = 7 * 24 * time.Hour
|
||
// RefreshTokenTTL refresh token 有效期(每次轮转重置 = 活跃用户滑动续期),
|
||
// 30 天内有过访问即持续在线;连续 30 天完全不访问才需重新登录
|
||
RefreshTokenTTL = 30 * 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 作用面。
|
||
// 必须在路由注册前调用一次;前端以 DEV_MODE 为准(与后端 !DevMode 对齐),
|
||
// 未设置 DEV_MODE 时回退 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"`
|
||
Username string `json:"username"`
|
||
Role string `json:"role"`
|
||
Banned bool `json:"banned"`
|
||
TokenVersion int `json:"tv"` // token 版本号,用于服务端撤销
|
||
FamilyID uint `json:"fid"` // 登录会话家族,剔除设备时使该会话 JWT 立即失效
|
||
}
|
||
|
||
// AuthService 认证服务
|
||
type AuthService struct {
|
||
db *gorm.DB
|
||
jwtSecret []byte
|
||
encKey []byte // refresh token 落库密文的 AES-GCM 密钥(由 JWT 密钥派生)
|
||
devMode bool
|
||
}
|
||
|
||
func NewAuthService(db *gorm.DB, jwtSecret string) *AuthService {
|
||
// 域分离派生:即使与 JWT 共用主密钥,密文用途也不可互换
|
||
keySum := sha256.Sum256([]byte("j13-refresh-token-aesgcm-v1:" + jwtSecret))
|
||
return &AuthService{db: db, jwtSecret: []byte(jwtSecret), encKey: keySum[:]}
|
||
}
|
||
|
||
// WithDevMode dev 模式放宽轮转宽限期:开发中 go run / next dev 重启频繁,
|
||
// 轮换响应丢失后的重放往往远超 60s 才回来,同链重放不应升级为盗用判定
|
||
func (s *AuthService) WithDevMode(dev bool) *AuthService {
|
||
s.devMode = dev
|
||
return s
|
||
}
|
||
|
||
// rotationGrace 轮转宽限期:dev 放宽(见 RefreshRotationGrace 注释)
|
||
func (s *AuthService) rotationGrace() time.Duration {
|
||
if s.devMode {
|
||
return 15 * time.Minute
|
||
}
|
||
return RefreshRotationGrace
|
||
}
|
||
|
||
// dummyPasswordHash 用户不存在时也执行一次 bcrypt 比较,避免通过响应耗时枚举用户名
|
||
var dummyPasswordHash, _ = bcrypt.GenerateFromPassword(
|
||
[]byte("j13-dummy-password-for-constant-timing"), bcrypt.DefaultCost)
|
||
|
||
// Register 用户注册(事务 + 咨询锁,保证空库至多一名 owner)
|
||
func (s *AuthService) Register(username, email, password string) (*model.User, error) {
|
||
if err := NewOperations(s.db, nil).Filter("username", username, 0); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
var user model.User
|
||
err = s.db.Transaction(func(tx *gorm.DB) error {
|
||
// 与站长角色迁移共用锁空间,避免并发首注抢多 owner
|
||
if err := tx.Exec("SELECT pg_advisory_xact_lock(?)", int64(0x6a31336f776e6572)).Error; err != nil {
|
||
return err
|
||
}
|
||
var count int64
|
||
if err := tx.Model(&model.User{}).Where("username = ?", username).Count(&count).Error; err != nil {
|
||
return err
|
||
}
|
||
if count > 0 {
|
||
return errors.New("用户名已被使用")
|
||
}
|
||
var n int64
|
||
if err := tx.Model(&model.User{}).Count(&n).Error; err != nil {
|
||
return err
|
||
}
|
||
role := model.RoleUser
|
||
if n == 0 {
|
||
role = model.RoleOwner
|
||
}
|
||
user = model.User{
|
||
Username: username,
|
||
Email: email,
|
||
Password: string(hashed),
|
||
Nickname: username,
|
||
Role: role,
|
||
}
|
||
return tx.Create(&user).Error
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return &user, nil
|
||
}
|
||
|
||
// Login 用户登录,返回 access token + refresh token + user;
|
||
// kicked 为本次登录因超出会话上限被淘汰的最旧会话数(多会话并存:
|
||
// 新登录不踢旧设备,仅超过 maxActiveSessions 时裁剪;>0 供上层广播告知)
|
||
func (s *AuthService) Login(username, password, ip, ua string) (string, string, *model.User, int, 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, 0, errors.New("用户名或密码错误")
|
||
}
|
||
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil {
|
||
return "", "", nil, 0, errors.New("用户名或密码错误")
|
||
}
|
||
// 封禁判定放在密码比较之后,同样避免时序差异
|
||
if user.Banned {
|
||
return "", "", nil, 0, ErrAccountBanned
|
||
}
|
||
|
||
refreshToken, familyID, kicked, err := s.CreateRefreshToken(user.ID, ip, ua)
|
||
if err != nil {
|
||
return "", "", nil, 0, err
|
||
}
|
||
accessToken, err := s.generateToken(&user, familyID)
|
||
if err != nil {
|
||
return "", "", nil, 0, err
|
||
}
|
||
return accessToken, refreshToken, &user, kicked, nil
|
||
}
|
||
|
||
// generateToken 签发 access token(短期)
|
||
func (s *AuthService) generateToken(user *model.User, familyID uint) (string, error) {
|
||
claims := &tokenClaims{
|
||
UserClaims: UserClaims{
|
||
ID: user.ID,
|
||
Username: user.Username,
|
||
Role: string(user.Role),
|
||
Banned: user.Banned,
|
||
TokenVersion: user.TokenVersion,
|
||
FamilyID: familyID,
|
||
},
|
||
RegisteredClaims: jwt.RegisteredClaims{
|
||
Subject: user.Username,
|
||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(AccessTokenTTL)),
|
||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||
},
|
||
}
|
||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||
return token.SignedString(s.jwtSecret)
|
||
}
|
||
|
||
type tokenClaims struct {
|
||
UserClaims
|
||
jwt.RegisteredClaims
|
||
}
|
||
|
||
// ParseToken 解析 JWT(仅校验签名和过期,不查 DB)
|
||
func (s *AuthService) ParseToken(tokenStr string) (*UserClaims, error) {
|
||
claims := &tokenClaims{}
|
||
_, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) {
|
||
return s.jwtSecret, nil
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return &claims.UserClaims, nil
|
||
}
|
||
|
||
// ValidateClaims 校验 claims 是否仍然有效(查 DB:token_version 匹配且未封禁)
|
||
// 用于中间件在每个请求上做实时权限校验
|
||
func (s *AuthService) ValidateClaims(claims *UserClaims) (*model.User, error) {
|
||
var user model.User
|
||
if err := s.db.First(&user, claims.ID).Error; err != nil {
|
||
return nil, errors.New("用户不存在")
|
||
}
|
||
// 封禁优先于版本判定:封禁必然伴随 token_version 递增,
|
||
// 但前端需要明确知道"被封禁"而非笼统的登录失效
|
||
if user.Banned {
|
||
return nil, ErrAccountBanned
|
||
}
|
||
// token 版本不匹配 → 已被撤销(改密码/封禁/管理员操作)
|
||
if user.TokenVersion != claims.TokenVersion {
|
||
return nil, ErrTokenRevoked
|
||
}
|
||
// 单设备剔除:该会话家族已无有效 refresh token 则立即失效
|
||
if claims.FamilyID != 0 && !s.familySessionActive(user.ID, claims.FamilyID) {
|
||
return nil, ErrTokenRevoked
|
||
}
|
||
return &user, nil
|
||
}
|
||
|
||
func (s *AuthService) familySessionActive(userID, familyID uint) bool {
|
||
var n int64
|
||
s.db.Model(&model.RefreshToken{}).
|
||
Where("user_id = ? AND revoked = ? AND expires_at > ? AND (family_id = ? OR (family_id = 0 AND id = ?))",
|
||
userID, false, time.Now(), familyID, familyID).
|
||
Count(&n)
|
||
return n > 0
|
||
}
|
||
|
||
// GetUserByID 根据 ID 获取用户
|
||
func (s *AuthService) GetUserByID(id uint) (*model.User, error) {
|
||
var user model.User
|
||
if err := s.db.First(&user, id).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
return &user, nil
|
||
}
|
||
|
||
// GenerateCSRFToken 生成随机 CSRF token
|
||
func GenerateCSRFToken() string {
|
||
b := make([]byte, 32)
|
||
_, _ = rand.Read(b)
|
||
return base64.URLEncoding.EncodeToString(b)
|
||
}
|
||
|
||
// generateRandomToken 生成随机令牌字符串
|
||
func generateRandomToken() string {
|
||
b := make([]byte, 32)
|
||
_, _ = rand.Read(b)
|
||
return base64.URLEncoding.EncodeToString(b)
|
||
}
|
||
|
||
// 账号级登录态错误:前端据此区分"被封禁强制下线"与普通登录过期
|
||
var (
|
||
// ErrAccountBanned 账号已被封禁(登录/刷新/鉴权全链路统一返回,便于前端识别并告知用户)
|
||
ErrAccountBanned = errors.New("账号已被封禁")
|
||
// ErrTokenRevoked 凭据版本失配:改密/管理员强制下线等导致旧 JWT 立即作废
|
||
ErrTokenRevoked = errors.New("登录态已失效")
|
||
)
|
||
|
||
// refresh token 相关错误
|
||
var (
|
||
ErrRefreshInvalid = errors.New("refresh token 无效")
|
||
ErrRefreshExpired = errors.New("refresh token 已过期")
|
||
// errReplayChainDead 宽限期外重放时,token 所在轮转链已无有效头节点
|
||
// (家族被整体吊销:设备剔除/超限淘汰/改密/封禁),属真实失效
|
||
errReplayChainDead = 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(存哈希 + 密文 + 设备信息)。
|
||
// 多会话并存:各设备登录互不影响,新会话构成独立的轮转家族(family_id);
|
||
// 返回值 kicked 为因超出会话上限被淘汰的最旧会话行数。
|
||
func (s *AuthService) CreateRefreshToken(userID uint, ip, ua string) (string, uint, int, error) {
|
||
token := generateRandomToken()
|
||
cipherText, err := s.encryptToken(token)
|
||
if err != nil {
|
||
return "", 0, 0, err
|
||
}
|
||
now := time.Now()
|
||
rt := &model.RefreshToken{
|
||
UserID: userID,
|
||
TokenHash: hashRefreshToken(token),
|
||
TokenCipher: cipherText,
|
||
IP: truncateStr(ip, 45),
|
||
UserAgent: truncateStr(ua, 500),
|
||
LoggedInAt: now,
|
||
LastUsedAt: now,
|
||
ExpiresAt: now.Add(RefreshTokenTTL),
|
||
}
|
||
if err := s.db.Create(rt).Error; err != nil {
|
||
return "", 0, 0, err
|
||
}
|
||
if err := s.db.Model(rt).Update("family_id", rt.ID).Error; err != nil {
|
||
return "", 0, 0, err
|
||
}
|
||
rt.FamilyID = rt.ID
|
||
kicked := s.enforceSessionCap(userID)
|
||
return token, rt.FamilyID, kicked, nil
|
||
}
|
||
|
||
// loadActiveUser 加载用户并实时校验封禁状态
|
||
func (s *AuthService) loadActiveUser(tx *gorm.DB, userID uint) (*model.User, error) {
|
||
var user model.User
|
||
if err := tx.First(&user, userID).Error; err != nil {
|
||
return nil, errors.New("用户不存在")
|
||
}
|
||
if user.Banned {
|
||
return nil, ErrAccountBanned
|
||
}
|
||
return &user, nil
|
||
}
|
||
|
||
// 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 {
|
||
// 撤销常源于封禁/改密:补查封禁状态,让被封禁用户的刷新请求
|
||
// 得到可识别的原因(前端据此弹封禁提示,而非笼统的登录过期)
|
||
var owner model.User
|
||
if err := s.db.Select("banned").First(&owner, rt.UserID).Error; err == nil && owner.Banned {
|
||
return nil, ErrAccountBanned
|
||
}
|
||
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 而随机掉线
|
||
// - 宽限期外重放(轮换响应丢失后浏览器补投旧 token):轮转链头节点仍健康时
|
||
// 返回头节点 token 对自愈放行;整链已死才是真失效(见 healReplayFromHead)
|
||
func (s *AuthService) RotateRefreshToken(oldToken, ip, ua string) (string, string, *model.User, error) {
|
||
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 {
|
||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||
// 数据库故障 ≠ 凭据无效:透传给上层按"服务暂不可用"处理,
|
||
// 不得据此清除用户 cookie(否则 DB 抖动会变成全员强制重登)
|
||
return err
|
||
}
|
||
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) <= s.rotationGrace() {
|
||
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, sessionFamilyID(next)); e == nil {
|
||
accessToken, newRefresh, outUser = at, sameRefresh, user
|
||
return nil
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// 宽限期外重放:真实浏览器里最常见成因不是盗用,而是轮换响应丢失
|
||
// (RSC 预取被浏览器中止、移动端切后台挂起截断请求、middleware 超时
|
||
// 放弃、网络抖动)——后端已完成轮转落库,但 Set-Cookie 没进浏览器
|
||
// jar,旧 token 因此变成"已吊销"。若按盗用吊销整个家族,等价于把
|
||
// 无辜设备强制下线("登录已失效"弹窗的主要来源)。故沿 rotated_to
|
||
// 链找头节点:仍健康则补发头节点 token 对自愈;整链已死才是真失效。
|
||
at, plain, healedUser, healErr := s.healReplayFromHead(tx, &rt, now)
|
||
if healErr == nil {
|
||
accessToken, newRefresh, outUser = at, plain, healedUser
|
||
return nil
|
||
}
|
||
if !errors.Is(healErr, errReplayChainDead) {
|
||
// 封禁等账号级错误透传,让前端给出对应告知
|
||
outErr = healErr
|
||
return nil
|
||
}
|
||
log.Printf("[auth] refresh 宽限期外重放且轮转链已失效,拒绝 family=%d", sessionFamilyID(rt))
|
||
outErr = ErrRefreshInvalid
|
||
return nil
|
||
}
|
||
|
||
// 正常轮转:先建新行,再吊销旧行并建立轮转链,同时抹除旧行密文
|
||
plain := generateRandomToken()
|
||
cipherText, err := s.encryptToken(plain)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
familyID := sessionFamilyID(rt)
|
||
ipOut := truncateStr(ip, 45)
|
||
if ipOut == "" {
|
||
ipOut = rt.IP
|
||
}
|
||
uaOut := truncateStr(ua, 500)
|
||
if uaOut == "" {
|
||
uaOut = rt.UserAgent
|
||
}
|
||
nr := &model.RefreshToken{
|
||
UserID: rt.UserID,
|
||
FamilyID: familyID,
|
||
TokenHash: hashRefreshToken(plain),
|
||
TokenCipher: cipherText,
|
||
IP: ipOut,
|
||
UserAgent: uaOut,
|
||
LoggedInAt: tokenLoggedInAt(rt),
|
||
LastUsedAt: now,
|
||
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, familyID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
accessToken, newRefresh, outUser = at, plain, user
|
||
return nil
|
||
})
|
||
if txErr != nil {
|
||
return "", "", nil, txErr
|
||
}
|
||
if outErr != nil {
|
||
return "", "", nil, outErr
|
||
}
|
||
return accessToken, newRefresh, outUser, nil
|
||
}
|
||
|
||
// healReplayFromHead 宽限期外的同链重放自愈:沿 rotated_to 轮转链走到头节点
|
||
// (该会话家族当前生效的 refresh token),头节点仍健康则返回其 token 对——
|
||
// 等价于把丢失的轮换响应补投给浏览器。链已断/头已死(家族被整体吊销)时
|
||
// 返回 errReplayChainDead;用户被封禁时透传 ErrAccountBanned。
|
||
// 安全取舍:放行重放意味着偷到旧 token 者也能追上当前会话,故仅在此处用
|
||
// 可用性换安全(HttpOnly cookie 下旧 token 外泄门槛高,且改密/封禁/剔除
|
||
// 设备仍可随时终止会话);头部吊销型处置(改密、封禁、剔除、超限淘汰)
|
||
// 之后链上无活头节点,自愈自然失效,不会被绕过。
|
||
func (s *AuthService) healReplayFromHead(tx *gorm.DB, rt *model.RefreshToken, now time.Time) (string, string, *model.User, error) {
|
||
cur := *rt
|
||
// 迭代上限防御脏数据成环;正常链长即 7 天寿命内的轮转次数
|
||
for i := 0; cur.Revoked && i < 128; i++ {
|
||
if cur.RotatedTo == 0 {
|
||
return "", "", nil, errReplayChainDead
|
||
}
|
||
var next model.RefreshToken
|
||
if err := tx.First(&next, cur.RotatedTo).Error; err != nil {
|
||
return "", "", nil, errReplayChainDead
|
||
}
|
||
cur = next
|
||
}
|
||
if cur.Revoked || cur.UserID != rt.UserID || now.After(cur.ExpiresAt) || cur.TokenCipher == "" {
|
||
return "", "", nil, errReplayChainDead
|
||
}
|
||
user, err := s.loadActiveUser(tx, cur.UserID)
|
||
if err != nil {
|
||
return "", "", nil, err
|
||
}
|
||
plain, err := s.decryptToken(cur.TokenCipher)
|
||
if err != nil {
|
||
return "", "", nil, errReplayChainDead
|
||
}
|
||
at, err := s.generateToken(user, sessionFamilyID(cur))
|
||
if err != nil {
|
||
return "", "", nil, err
|
||
}
|
||
log.Printf("[auth] refresh 宽限期外重放,轮转链头节点仍健康,自愈放行 family=%d", sessionFamilyID(cur))
|
||
return at, plain, user, nil
|
||
}
|
||
|
||
// RevokeRefreshToken 撤销单个 refresh token(单设备登出时用)
|
||
func (s *AuthService) RevokeRefreshToken(token string) error {
|
||
if token == "" {
|
||
return nil
|
||
}
|
||
return s.db.Model(&model.RefreshToken{}).Where("token_hash = ?", hashRefreshToken(token)).
|
||
Updates(map[string]any{"revoked": true, "token_cipher": ""}).Error
|
||
}
|
||
|
||
// RevokeAllUserRefreshTokens 撤销用户所有 refresh token(改密码/封禁/盗用处置时用)
|
||
func (s *AuthService) RevokeAllUserRefreshTokens(userID uint) error {
|
||
return revokeAllUserRefreshTokensTx(s.db, userID)
|
||
}
|
||
|
||
func revokeAllUserRefreshTokensTx(db *gorm.DB, userID uint) error {
|
||
return db.Model(&model.RefreshToken{}).Where("user_id = ? AND revoked = ?", userID, false).
|
||
Updates(map[string]any{"revoked": true, "token_cipher": ""}).Error
|
||
}
|
||
|
||
// 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 版本并吊销全部 refresh(单事务)
|
||
func (s *AuthService) IncrementTokenVersion(userID uint) error {
|
||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||
if err := tx.Model(&model.User{}).Where("id = ?", userID).
|
||
UpdateColumn("token_version", gorm.Expr("token_version + 1")).Error; err != nil {
|
||
return err
|
||
}
|
||
return revokeAllUserRefreshTokensTx(tx, userID)
|
||
})
|
||
}
|
||
|
||
// ChangePassword 修改密码:校验旧密码,更新新密码,事务内使旧会话全部失效
|
||
func (s *AuthService) ChangePassword(userID uint, oldPassword, newPassword string) error {
|
||
var user model.User
|
||
if err := s.db.First(&user, userID).Error; err != nil {
|
||
return errors.New("用户不存在")
|
||
}
|
||
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(oldPassword)); err != nil {
|
||
return errors.New("旧密码错误")
|
||
}
|
||
if len(newPassword) < 6 {
|
||
return errors.New("新密码至少 6 位")
|
||
}
|
||
hashed, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||
result := tx.Model(&model.User{}).Where("id = ?", userID).Update("password", string(hashed))
|
||
if result.Error != nil {
|
||
return result.Error
|
||
}
|
||
if result.RowsAffected == 0 {
|
||
return errors.New("密码更新失败")
|
||
}
|
||
if err := tx.Model(&model.User{}).Where("id = ?", userID).
|
||
UpdateColumn("token_version", gorm.Expr("token_version + 1")).Error; err != nil {
|
||
return err
|
||
}
|
||
return revokeAllUserRefreshTokensTx(tx, userID)
|
||
})
|
||
}
|
||
|
||
// UpdateProfile 更新昵称/邮箱/签名。头像只能通过上传接口(/api/upload/avatar)
|
||
// 或历史头像选用接口(/api/avatar/use)修改,这里不接收头像字段,防止写入任意外链。
|
||
// - nickname 非空、不超过 64 字符,且全站唯一(忽略大小写,排除自身;注册时昵称=用户名,用户名本身唯一)
|
||
// - email 可为空;非空时需符合邮箱格式且不与他人重复
|
||
// - signature 不超过 255 字符
|
||
func (s *AuthService) UpdateProfile(userID uint, nickname, email, signature string) (*model.User, error) {
|
||
if err := NewOperations(s.db, nil).Filter("username", nickname, userID); err != nil {
|
||
return nil, err
|
||
}
|
||
nickname = strings.TrimSpace(nickname)
|
||
if nickname == "" {
|
||
return nil, errors.New("昵称不能为空")
|
||
}
|
||
if len(nickname) > 64 {
|
||
return nil, errors.New("昵称不能超过 64 个字符")
|
||
}
|
||
// 昵称全站唯一(排除自身):与用户名同级,保证 @提及 昵称→用户名 映射无歧义
|
||
var nickCount int64
|
||
s.db.Model(&model.User{}).
|
||
Where("LOWER(nickname) = LOWER(?) AND id <> ?", nickname, userID).
|
||
Count(&nickCount)
|
||
if nickCount > 0 {
|
||
return nil, errors.New("该昵称已被使用")
|
||
}
|
||
email = strings.TrimSpace(email)
|
||
if email != "" {
|
||
if !strings.Contains(email, "@") || len(email) > 128 {
|
||
return nil, errors.New("邮箱格式不正确")
|
||
}
|
||
// 邮箱唯一性校验(排除自身)
|
||
var count int64
|
||
s.db.Model(&model.User{}).Where("email = ? AND id <> ?", email, userID).Count(&count)
|
||
if count > 0 {
|
||
return nil, errors.New("该邮箱已被使用")
|
||
}
|
||
}
|
||
if len(signature) > 255 {
|
||
return nil, errors.New("签名不能超过 255 个字符")
|
||
}
|
||
|
||
updates := map[string]any{
|
||
"nickname": nickname,
|
||
"email": email,
|
||
"signature": signature,
|
||
}
|
||
if err := s.db.Model(&model.User{}).Where("id = ?", userID).Updates(updates).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
return s.GetUserByID(userID)
|
||
}
|