Files
jiang13-bbs/backend/service/auth.go
freefire 31f5360c26 feat: 登录设备管理与编辑器图片上传
- 新增登录设备列表与会话下线(ListLoginDevices/RevokeLoginDevice),按设备指纹识别当前会话
- Markdown 编辑器新增图片插入弹窗与图片上传(ImageInsertModal)
- 实时总线、账号守卫与用户事件适配
2026-09-26 01:10:10 +08:00

629 lines
21 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 有效期(短期,降低被盗窗口)
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)
}