Files
jiang13-bbs/backend/service/auth.go
freefire 24c6dea38c feat: 完成头像上传、站点主题配置与UI优化
- 新增附件模型与上传服务,拆分头像上传/管理逻辑
- 重构用户资料接口,移除直接修改头像字段,改用专用上传接口
- 添加站点公开设置API与管理员外观配置页
- 全局替换hover背景为accent-soft,优化交互一致性
- 完善头像组件,支持图片加载失败回退与src更新重置
- 新增用户状态事件总线,解决同页头像更新不及时问题
- 调整依赖包顺序与CI依赖版本
- 重构板块列表接口,新增帖子计数
- 迁移账号设置到用户主页tab,合并重复代码
2026-09-15 01:06:12 +08:00

535 lines
17 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 (
RoleUser = "user"
RoleAdmin = "admin"
// 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 作用面。
// 必须在路由注册前调用一次;前端按 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 版本号,用于服务端撤销
}
// 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 用户注册
func (s *AuthService) Register(username, email, password string) (*model.User, error) {
// 检查用户名是否已存在
var count int64
s.db.Model(&model.User{}).Where("username = ?", username).Count(&count)
if count > 0 {
return nil, errors.New("用户名已被使用")
}
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
user := &model.User{
Username: username,
Email: email,
Password: string(hashed),
Nickname: username,
Role: model.RoleUser,
}
if err := s.db.Create(user).Error; err != nil {
return nil, err
}
return user, nil
}
// Login 用户登录,返回 access token + refresh token + user
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 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 {
return "", "", nil, err
}
refreshToken, err := s.CreateRefreshToken(user.ID)
if err != nil {
return "", "", nil, err
}
return accessToken, refreshToken, &user, nil
}
// generateToken 签发 access token(短期)
func (s *AuthService) generateToken(user *model.User) (string, error) {
claims := &tokenClaims{
UserClaims: UserClaims{
ID: user.ID,
Username: user.Username,
Role: string(user.Role),
Banned: user.Banned,
TokenVersion: user.TokenVersion,
},
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 版本不匹配 → 已被撤销(改密码/封禁/管理员操作)
if user.TokenVersion != claims.TokenVersion {
return nil, errors.New("token 已失效")
}
// 实时校验封禁状态(不依赖 JWT 中的缓存值)
if user.Banned {
return nil, errors.New("账号已被封禁")
}
return &user, nil
}
// 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)
}
// 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,
TokenHash: hashRefreshToken(token),
TokenCipher: cipherText,
ExpiresAt: time.Now().Add(RefreshTokenTTL),
}
if err := s.db.Create(rt).Error; err != nil {
return "", err
}
return token, 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, errors.New("账号已被封禁")
}
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 {
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) {
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
}
if outErr != nil {
return "", "", nil, outErr
}
return accessToken, newRefresh, outUser, nil
}
// RevokeRefreshToken 撤销单个 refresh token(单设备登出时用)
func (s *AuthService) RevokeRefreshToken(token string) {
if token == "" {
return
}
s.db.Model(&model.RefreshToken{}).Where("token_hash = ?", hashRefreshToken(token)).
Updates(map[string]any{"revoked": true, "token_cipher": ""})
}
// RevokeAllUserRefreshTokens 撤销用户所有 refresh token(改密码/封禁/盗用处置时用)
func (s *AuthService) RevokeAllUserRefreshTokens(userID uint) {
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 失效
// 用于:改密码、封禁用户、管理员强制下线
func (s *AuthService) IncrementTokenVersion(userID uint) error {
result := s.db.Model(&model.User{}).Where("id = ?", userID).UpdateColumn("token_version", gorm.Expr("token_version + 1"))
if result.Error != nil {
return result.Error
}
s.RevokeAllUserRefreshTokens(userID)
return nil
}
// ChangePassword 修改密码:校验旧密码,更新新密码,递增 token_version 使旧 token 失效
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
}
result := s.db.Model(&model.User{}).Where("id = ?", userID).Update("password", string(hashed))
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return errors.New("密码更新失败")
}
// 递增 token_version,使所有旧 JWT 和 refresh token 失效
return s.IncrementTokenVersion(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) {
nickname = strings.TrimSpace(nickname)
if nickname == "" {
return nil, errors.New("昵称不能为空")
}
if len(nickname) > 64 {
return nil, errors.New("昵称不能超过 64 个字符")
}
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)
}