refactor: JWT 无状态认证替换为服务端 Session,修复记住我掉线问题

- 新增 internal/session 包:Session 结构体、Store 接口、MemoryStore(内存+后台清理)、RedisStore 预留
- Cookie 从 3 个精简为 1 个 mlb_sid(HttpOnly),移除 JWT access/refresh cookie
- 会话过期采用滑动窗口:每次请求自动续期,记住我 30 天无操作过期
- AuthMiddleware/AuthAdmin/Maintenance 中间件改用 SessionManager.Validate()
- AuthService Login/Register/ConfirmRestore 去除 token 生成,返回 *model.User
- Controller 层在登录/注册后调用 SessionManager.Create 创建会话
- AdminService UpdateUserStatus/ResetUserToken 同步销毁 session 实现即时退登
- 改密/注销时通过 DestroyByUID 删除所有 session(替换 TokenVersion 检查)
- config.yaml 新增 session 配置段(idle_timeout/remember_timeout/cleanup_interval)
- TokenService/auth_parser/auth_token 标记废弃,移除 JWT 到期字段依赖
This commit is contained in:
2026-05-30 23:36:52 +08:00
parent 53f84bfcc4
commit daf87f895b
28 changed files with 617 additions and 482 deletions

View File

@ -8,6 +8,7 @@ import (
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/config"
"metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/session"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
@ -15,15 +16,15 @@ import (
// AuthService 认证业务逻辑
type AuthService struct {
userRepo userAuthStore
tokenService tokenProvider
cfg *config.Config
siteSettings *config.SiteSettings
userRepo userAuthStore
sessionManager *session.Manager
cfg *config.Config
siteSettings *config.SiteSettings
}
// NewAuthService 构造函数
func NewAuthService(userRepo userAuthStore, tokenSvc tokenProvider, cfg *config.Config, siteSettings *config.SiteSettings) *AuthService {
return &AuthService{userRepo: userRepo, tokenService: tokenSvc, cfg: cfg, siteSettings: siteSettings}
func NewAuthService(userRepo userAuthStore, sm *session.Manager, cfg *config.Config, siteSettings *config.SiteSettings) *AuthService {
return &AuthService{userRepo: userRepo, sessionManager: sm, cfg: cfg, siteSettings: siteSettings}
}
var pwLetter = regexp.MustCompile(`[a-zA-Z]`)
@ -33,7 +34,6 @@ var pwDigit = regexp.MustCompile(`\d`)
var usernamePattern = regexp.MustCompile(`^[\p{Han}a-zA-Z0-9_-]+$`)
// dummyHash 防时序攻击:当用户不存在时,仍对虚拟哈希执行完整的 bcrypt 比对
// 在 init() 中按当前 bcrypt 成本生成,确保时序与真实校验一致
var dummyHash []byte
func init() {
@ -58,46 +58,44 @@ func (s *AuthService) IsRegistrationEnabled() bool {
}
// Register 注册
// 流程:密码强度 → 邮箱查重 → 哈希 → 生成唯一用户名 → 创建 → access JWT + refresh JWT
// rememberMe=true: refresh Cookie 持久化30天false: session cookie关浏览器即清除
// regIP: 注册 IP 地址
func (s *AuthService) Register(req model.RegisterRequest, regIP string) (string, string, *model.User, error) {
// 流程:密码强度 → 邮箱查重 → 哈希 → 生成唯一用户名 → 创建 → 返回 usersession 由 controller 创建)
func (s *AuthService) Register(req model.RegisterRequest, regIP string) (*model.User, error) {
// 0. 维护期间禁止注册
if s.siteSettings.IsMaintenanceEnabled() {
return "", "", nil, common.ErrMaintenanceMode
return nil, common.ErrMaintenanceMode
}
// 1. 检查注册开关
if !s.siteSettings.IsRegistrationEnabled() {
return "", "", nil, common.ErrRegistrationDisabled
return nil, common.ErrRegistrationDisabled
}
// 1. 密码强度
// 2. 密码强度
if err := validatePassword(req.Password); err != nil {
return "", "", nil, err
return nil, err
}
// 2. 检查邮箱
// 3. 检查邮箱
exists, err := s.userRepo.ExistsByEmail(req.Email)
if err != nil {
return "", "", nil, err
return nil, err
}
if exists {
return "", "", nil, common.ErrEmailExists
return nil, common.ErrEmailExists
}
// 3. 哈希密码
// 4. 哈希密码
hash, err := common.HashPassword(req.Password, s.cfg.Bcrypt.Cost)
if err != nil {
return "", "", nil, err
return nil, err
}
// 4. 生成唯一用户名
// 5. 生成唯一用户名
username, err := common.GenerateUsername(s.userRepo, 20)
if err != nil {
return "", "", nil, err
return nil, err
}
// 5. 创建用户
// 6. 创建用户
user := &model.User{
Email: req.Email,
PasswordHash: hash,
@ -107,70 +105,56 @@ func (s *AuthService) Register(req model.RegisterRequest, regIP string) (string,
RegIP: regIP,
}
if err := s.userRepo.Create(user); err != nil {
return "", "", nil, err
return nil, err
}
// 6. 生成 access JWT
accessToken, err := s.tokenService.BuildAccessToken(user)
if err != nil {
return "", "", nil, err
}
// 7. 生成 refresh JWT不勾选"记住我"也用 session cookie关浏览器即清除
refreshToken, err := s.tokenService.BuildRefreshToken(user, req.RememberMe)
if err != nil {
return "", "", nil, err
}
return accessToken, refreshToken, user, nil
return user, nil
}
// Login 登录
// 流程:按邮箱查找 → 维护期间非站长统一拦截(模拟 bcrypt 防时序)→ 状态检查 → 验证密码 → 签发 token
// rememberMe=true: refresh Cookie 持久化30天false: session cookie关浏览器即清除
func (s *AuthService) Login(req model.LoginRequest, loginIP string) (string, string, *model.User, error) {
// 流程:按邮箱查找 → 维护期间非站长统一拦截 → 状态检查 → 验证密码 → 记录登录信息 → 返回 user
func (s *AuthService) Login(req model.LoginRequest, loginIP string) (*model.User, error) {
user, err := s.userRepo.FindByEmail(req.Email)
// 维护期间:非站长一律返回统一提示(模拟 bcrypt 防时序攻击,不区分账号是否存在
// 维护期间:非站长一律返回统一提示(模拟 bcrypt 防时序攻击)
if s.siteSettings.IsMaintenanceEnabled() {
if err != nil {
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password))
return "", "", nil, common.ErrMaintenanceMode
return nil, common.ErrMaintenanceMode
}
if !model.HasMinRole(user.Role, model.RoleOwner) {
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password))
return "", "", nil, common.ErrMaintenanceMode
return nil, common.ErrMaintenanceMode
}
// 站长 → 继续正常登录流程
}
if err != nil {
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password))
return "", "", nil, common.ErrInvalidCred
return nil, common.ErrInvalidCred
}
// 已注销deleted→ 验证密码后要求二次确认,不自动恢复
// 已注销deleted→ 验证密码后要求二次确认
if user.Status == model.StatusDeleted {
if !common.CheckPassword(req.Password, user.PasswordHash) {
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password))
return "", "", nil, common.ErrInvalidCred
return nil, common.ErrInvalidCred
}
return "", "", nil, common.ErrNeedsConfirmRestore
return nil, common.ErrNeedsConfirmRestore
}
// 永久锁定
if user.Status == model.StatusLocked {
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password))
return "", "", nil, common.ErrUserLocked
return nil, common.ErrUserLocked
}
// 封禁
if user.Status == model.StatusBanned {
return "", "", nil, common.ErrUserBanned
return nil, common.ErrUserBanned
}
if !common.CheckPassword(req.Password, user.PasswordHash) {
return "", "", nil, common.ErrInvalidCred
return nil, common.ErrInvalidCred
}
// 记录登录 IP 和时间
@ -178,40 +162,26 @@ func (s *AuthService) Login(req model.LoginRequest, loginIP string) (string, str
user.LastLoginIP = loginIP
user.LastLoginAt = &now
if err := s.userRepo.Update(user); err != nil {
return "", "", nil, err
return nil, err
}
// 生成 access JWT
accessToken, err := s.tokenService.BuildAccessToken(user)
if err != nil {
return "", "", nil, err
}
// 生成 refresh JWT不勾选"记住我"也生成Cookie 用 session 模式)
refreshToken, err := s.tokenService.BuildRefreshToken(user, req.RememberMe)
if err != nil {
return "", "", nil, err
}
return accessToken, refreshToken, user, nil
return user, nil
}
// ConfirmRestore 二次确认恢复已注销账号
// 流程:按邮箱查找 → 验证密码 → 恢复为 active → 记录登录 IP/时间 → 签发 token
func (s *AuthService) ConfirmRestore(req model.LoginRequest, loginIP string) (string, string, *model.User, error) {
func (s *AuthService) ConfirmRestore(req model.LoginRequest, loginIP string) (*model.User, error) {
user, err := s.userRepo.FindByEmail(req.Email)
if err != nil {
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password))
return "", "", nil, common.ErrInvalidCred
return nil, common.ErrInvalidCred
}
// 仅 deleted 状态允许恢复
if user.Status != model.StatusDeleted {
return "", "", nil, common.ErrUserNotFound
return nil, common.ErrUserNotFound
}
if !common.CheckPassword(req.Password, user.PasswordHash) {
return "", "", nil, common.ErrInvalidCred
return nil, common.ErrInvalidCred
}
// 恢复账号
@ -221,51 +191,38 @@ func (s *AuthService) ConfirmRestore(req model.LoginRequest, loginIP string) (st
user.LastLoginIP = loginIP
user.LastLoginAt = &now
if err := s.userRepo.Update(user); err != nil {
return "", "", nil, err
return nil, err
}
accessToken, err := s.tokenService.BuildAccessToken(user)
if err != nil {
return "", "", nil, err
}
refreshToken, err := s.tokenService.BuildRefreshToken(user, req.RememberMe)
if err != nil {
return "", "", nil, err
}
return accessToken, refreshToken, user, nil
return user, nil
}
// CheckEmail 检查邮箱是否已被注册(无需认证的轻量检查)
// CheckEmail 检查邮箱是否已被注册
func (s *AuthService) CheckEmail(email string) (bool, error) {
return s.userRepo.ExistsByEmail(email)
}
// GetProfile 获取当前用户资料(供 SettingsController 使用)
// GetProfile 获取当前用户资料
func (s *AuthService) GetProfile(userID uint) (*model.User, error) {
return s.userRepo.FindByID(userID)
}
// ChangePassword 修改密码
// 流程:验证当前密码 → 校验新密码强度 → 哈希 → 更新 → 吊销所有 JWT(强制重新登录)
// 流程:验证当前密码 → 校验新密码强度 → 哈希 → 更新 → 删除所有 session(强制重新登录)
func (s *AuthService) ChangePassword(userID uint, currentPassword, newPassword string) error {
user, err := s.userRepo.FindByID(userID)
if err != nil {
return common.ErrUserNotFound
}
// 验证当前密码
if !common.CheckPassword(currentPassword, user.PasswordHash) {
return common.ErrIncorrectPassword
}
// 新密码强度校验
if err := validatePassword(newPassword); err != nil {
return err
}
// 哈希新密码
hash, err := common.HashPassword(newPassword, s.cfg.Bcrypt.Cost)
if err != nil {
return err
@ -276,24 +233,21 @@ func (s *AuthService) ChangePassword(userID uint, currentPassword, newPassword s
return err
}
// 强制所有设备重新登录(安全性)
return s.InvalidateSessions(userID)
// 删除所有 session强制所有设备重新登录
return s.invalidateSessions(userID)
}
// DeleteAccount 用户自主注销
// 流程:检查角色 → 验证密码 → 记录原因 → 设置 deleted 状态 → 吊销所有 JWT → 7 天冷却期内登录需二次确认恢复
func (s *AuthService) DeleteAccount(userID uint, password, reason string) error {
user, err := s.userRepo.FindByID(userID)
if err != nil {
return common.ErrUserNotFound
}
// 站长不允许自主注销(避免权限体系死锁)
if user.Role == model.RoleOwner {
return common.ErrOwnerCannotDelete
}
// 验证当前密码(防止 CSRF 或未授权操作)
if !common.CheckPassword(password, user.PasswordHash) {
return common.ErrIncorrectPassword
}
@ -305,22 +259,28 @@ func (s *AuthService) DeleteAccount(userID uint, password, reason string) error
return err
}
// 递增 token_version,即时吊销所有 JWT 强制退登
return s.InvalidateSessions(userID)
}
// InvalidateSessions 吊销某用户所有 JWT递增 token_version强制所有设备重新登录
// 适用场景:修改密码、账号被盗、管理员强制下线
func (s *AuthService) InvalidateSessions(userID uint) error {
// 删除所有 session + 递增 token_version(向后兼容)
if err := s.invalidateSessions(userID); err != nil {
return err
}
return s.userRepo.IncrementTokenVersion(userID)
}
// UpdateProfile 修改个人资料(用户名 + 个性签名
// 用户名校验非空、1-16 字符、白名单、去重
// 个性签名校验0-128 字符纯文本Go 模板自动 HTML 转义防 XSS
// 任一字段无变更时跳过该字段的写库操作
// invalidateSessions 销毁某用户的所有 session内部方法
func (s *AuthService) invalidateSessions(userID uint) error {
return s.sessionManager.DestroyByUID(userID)
}
// InvalidateSessions 公开方法:销毁用户所有 session供 admin 等外部调用)
func (s *AuthService) InvalidateSessions(userID uint) error {
if err := s.invalidateSessions(userID); err != nil {
return err
}
return s.userRepo.IncrementTokenVersion(userID)
}
// UpdateProfile 修改个人资料
func (s *AuthService) UpdateProfile(userID uint, username, bio string) error {
// 查当前用户
user, err := s.userRepo.FindByID(userID)
if err != nil {
return common.ErrUserNotFound
@ -328,7 +288,6 @@ func (s *AuthService) UpdateProfile(userID uint, username, bio string) error {
needsUpdate := false
// 用户名:校验 + 更新
if n := utf8.RuneCountInString(username); n == 0 {
return common.ErrUsernameInvalid
} else if n > 16 {
@ -349,7 +308,6 @@ func (s *AuthService) UpdateProfile(userID uint, username, bio string) error {
needsUpdate = true
}
// 个性签名:长度校验 + 更新
if utf8.RuneCountInString(bio) > 128 {
return common.ErrBioTooLong
}