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 有效期(短期,降低被盗窗口) 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 作用面。 // 必须在路由注册前调用一次;前端以 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 密钥派生) } 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[:]} } // 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 为本次登录顶掉的旧会话数(真·单会话:任何新登录踢掉所有旧设备;>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 已过期") 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(存哈希 + 密文 + 设备信息)。 // 返回值 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.revokeAllSessionsExcept(userID, rt.FamilyID) 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 而随机掉线 // - 吊销后重放(被盗信号):撤销该用户全部 refresh token,强制重新登录 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 { 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, sessionFamilyID(next)); 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 } 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 } // 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) }