Files
jiang13-bbs/backend/service/auth.go
freefire 46e0cdc0b3 feat: 媒体库 blob 去重存储与品牌/注册等前端调整
新增:
- blob_store 二进制去重存储 + blob_dedup 测试

其它:
- 后端 settings/auth/media_library/upload/operations 服务与 handler 调整
- 前端品牌(BrandLockup/BrandCropModal/BrandSeoPanel)、注册、媒体库、分析详情页、Header/SiteChrome 等更新
2026-10-03 02:38:11 +08:00

723 lines
26 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 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
}
// 邮箱统一小写归一化,避免大小写变体重复注册
email = strings.ToLower(strings.TrimSpace(email))
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("用户名已被使用")
}
// 邮箱唯一(忽略大小写,兜底防绕过;入口发码处已拦一次)
if email != "" {
var emailCount int64
if err := tx.Model(&model.User{}).Where("LOWER(email) = ?", email).Count(&emailCount).Error; err != nil {
return err
}
if emailCount > 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.ToLower(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("LOWER(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)
}