Files
mce/internal/service/auth_service.go
Victor_Jay 0800ee2f3e feat: 消息通知中心 + 账号安全体系 + Bug修复
## 新增功能

### 消息通知中心 (全新模块)
- 新增 MessageController / NotificationService / NotificationRepo
- SSR 消息页面 (/messages):左侧边栏 + 右侧卡片列表,noindex 元标签
- 通知能力:列表分页、单条标为已读、一键全部标为已读
- 未读数角标 (1/66/99+):侧边栏 + 导航栏铃铛图标
- 导航栏轮询 /api/messages/unread 每 60 秒刷新未读数
- 审核通过/驳回时自动 fire-and-forget 推送通知

### 密码修改
- ChangePassword:验证当前密码 → 新密码强度校验(8位+字母+数字) → 哈希更新
- 修改后递增 token_version 强制所有设备退登

### 账号自助注销
- DeleteAccount:验证密码 → 设置 deleted 状态 → 记录原因 → 吊销 JWT
- 注销后登录二次确认:Login 检测 deleted → 返回 confirm_restore
- ConfirmRestore 恢复账号,重新签发 token
- 注销页文案:"账号将在 7 天后正式注销,期间可随时重新登录恢复"

### IP 审计记录
- 注册时记录 RegIP,登录时记录 LastLoginIP + LastLoginAt
- clientIP() 支持 X-Forwarded-For / X-Real-IP 反向代理

### 安全加固
- Login 防时序攻击:用户不存在时仍执行完整 bcrypt 比对
- FindByEmail 改用 Unscoped() 覆盖软删除用户
- 站长 (owner) 不允许自主注销,避免权限体系死锁

## Bug 修复

1. 注销按钮不触发:JS IIFE 中 profile 代码 return 阻塞了 account 标签页处理器注册
   → 拆分为两层 IIFE,profile 放在内层
2. label 缺少 for 属性导致控制台警告 → 全部补充 for 属性
3. 注销后未自动退登:DeleteAccount 只设状态未吊销 JWT → 末尾加 InvalidateSessions
4. 退登后重定向 500 panic:authenticateToken 中 err||versionMismatch 合并判断
   在 err=nil 但版本不匹配时返回 (nil,nil) → 拆为两个独立判断
   injectUserContext 增加 claims==nil / 类型断言空安全守卫
5. 注销后登录直接提示"登录成功":FindByEmail 默认 scope 排除软删除记录
   → 改用 Unscoped()

## 文件变更
- 新建 17 个文件 (消息/审核/站点设置完整模块)
- 修改 25 个文件 (认证/设置/中间件/前端)
- 统计:+3229 / -161,42 files changed
2026-05-27 13:32:45 +08:00

358 lines
10 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 (
"regexp"
"time"
"unicode/utf8"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/config"
"metazone.cc/metalab/internal/model"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
// AuthService 认证业务逻辑
type AuthService struct {
userRepo userAuthStore
tokenService tokenProvider
cfg *config.Config
}
// NewAuthService 构造函数
func NewAuthService(userRepo userAuthStore, tokenSvc tokenProvider, cfg *config.Config) *AuthService {
return &AuthService{userRepo: userRepo, tokenService: tokenSvc, cfg: cfg}
}
var (
ErrEmailExists = common.ErrEmailExists
ErrUsernameTaken = common.ErrUsernameTaken
ErrInvalidCred = common.ErrInvalidCred
ErrUserNotFound = common.ErrUserNotFound
ErrUserBanned = common.ErrUserBanned
ErrUserLocked = common.ErrUserLocked
ErrUserDeleted = common.ErrUserDeleted
ErrWeakPassword = common.ErrWeakPassword
ErrTokenExpired = common.ErrTokenExpired
ErrTokenInvalid = common.ErrTokenInvalid
ErrTokenRevoked = common.ErrTokenRevoked
ErrPermissionDenied = common.ErrPermissionDenied
ErrIncorrectPassword = common.ErrIncorrectPassword
ErrNeedsConfirmRestore = common.ErrNeedsConfirmRestore
ErrOwnerCannotDelete = common.ErrOwnerCannotDelete
)
var pwLetter = regexp.MustCompile(`[a-zA-Z]`)
var pwDigit = regexp.MustCompile(`\d`)
// usernamePattern 用户名合法字符:中文、英文大小写、数字、下划线、连字符
var usernamePattern = regexp.MustCompile(`^[\p{Han}a-zA-Z0-9_-]+$`)
// dummyHash 防时序攻击:当用户不存在时,仍对虚拟哈希执行完整的 bcrypt 比对
// 在 init() 中按当前 bcrypt 成本生成,确保时序与真实校验一致
var dummyHash []byte
func init() {
hash, err := bcrypt.GenerateFromPassword([]byte("metazone-dummy-hash-2026"), 12)
if err != nil {
panic("failed to generate bcrypt dummy hash: " + err.Error())
}
dummyHash = hash
}
// validatePassword 密码强度:至少 8 位 + 包含字母 + 包含数字
func validatePassword(pw string) error {
if len(pw) < 8 || !pwLetter.MatchString(pw) || !pwDigit.MatchString(pw) {
return ErrWeakPassword
}
return nil
}
// 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) {
// 1. 密码强度
if err := validatePassword(req.Password); err != nil {
return "", "", nil, err
}
// 2. 检查邮箱
exists, err := s.userRepo.ExistsByEmail(req.Email)
if err != nil {
return "", "", nil, err
}
if exists {
return "", "", nil, ErrEmailExists
}
// 3. 哈希密码
hash, err := common.HashPassword(req.Password, s.cfg.Bcrypt.Cost)
if err != nil {
return "", "", nil, err
}
// 4. 生成唯一用户名
username, err := common.GenerateUsername(s.userRepo, 20)
if err != nil {
return "", "", nil, err
}
// 5. 创建用户
user := &model.User{
Email: req.Email,
PasswordHash: hash,
Username: username,
Role: model.RoleUser,
Status: model.StatusActive,
RegIP: regIP,
}
if err := s.userRepo.Create(user); err != nil {
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
}
// Login 登录
// 流程:按邮箱查找 → 已注销则验证密码后要求二次确认 → 检查状态 → 验证密码 → 记录登录 IP/时间 → access JWT + refresh JWT
// 防时序攻击:邮箱不存在时仍执行完整 bcrypt 比对
// rememberMe=true: refresh Cookie 持久化30天false: session cookie关浏览器即清除
// loginIP: 登录 IP 地址
// 若用户处于 deleted 状态且密码正确 → 返回 ErrNeedsConfirmRestore不自动恢复不签发 token
func (s *AuthService) Login(req model.LoginRequest, loginIP string) (string, string, *model.User, error) {
user, err := s.userRepo.FindByEmail(req.Email)
if err != nil {
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password))
return "", "", nil, ErrInvalidCred
}
// 已注销deleted→ 验证密码后要求二次确认,不自动恢复
if user.Status == model.StatusDeleted {
if !common.CheckPassword(req.Password, user.PasswordHash) {
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password))
return "", "", nil, ErrInvalidCred
}
return "", "", nil, ErrNeedsConfirmRestore
}
// 永久锁定
if user.Status == model.StatusLocked {
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password))
return "", "", nil, ErrUserLocked
}
// 封禁
if user.Status == model.StatusBanned {
return "", "", nil, ErrUserBanned
}
if !common.CheckPassword(req.Password, user.PasswordHash) {
return "", "", nil, ErrInvalidCred
}
// 记录登录 IP 和时间
now := time.Now()
user.LastLoginIP = loginIP
user.LastLoginAt = &now
if err := s.userRepo.Update(user); err != nil {
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
}
// ConfirmRestore 二次确认恢复已注销账号
// 流程:按邮箱查找 → 验证密码 → 恢复为 active → 记录登录 IP/时间 → 签发 token
func (s *AuthService) ConfirmRestore(req model.LoginRequest, loginIP string) (string, string, *model.User, error) {
user, err := s.userRepo.FindByEmail(req.Email)
if err != nil {
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password))
return "", "", nil, ErrInvalidCred
}
// 仅 deleted 状态允许恢复
if user.Status != model.StatusDeleted {
return "", "", nil, ErrUserNotFound
}
if !common.CheckPassword(req.Password, user.PasswordHash) {
return "", "", nil, ErrInvalidCred
}
// 恢复账号
user.Status = model.StatusActive
user.DeletedAt = gorm.DeletedAt{}
now := time.Now()
user.LastLoginIP = loginIP
user.LastLoginAt = &now
if err := s.userRepo.Update(user); err != nil {
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
}
// CheckEmail 检查邮箱是否已被注册(无需认证的轻量检查)
func (s *AuthService) CheckEmail(email string) (bool, error) {
return s.userRepo.ExistsByEmail(email)
}
// GetProfile 获取当前用户资料(供 SettingsController 使用)
func (s *AuthService) GetProfile(userID uint) (*model.User, error) {
return s.userRepo.FindByID(userID)
}
// ChangePassword 修改密码
// 流程:验证当前密码 → 校验新密码强度 → 哈希 → 更新 → 吊销所有 JWT强制重新登录
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 ErrIncorrectPassword
}
// 新密码强度校验
if err := validatePassword(newPassword); err != nil {
return err
}
// 哈希新密码
hash, err := common.HashPassword(newPassword, s.cfg.Bcrypt.Cost)
if err != nil {
return err
}
user.PasswordHash = hash
if err := s.userRepo.Update(user); err != nil {
return err
}
// 强制所有设备重新登录(安全性)
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 ErrOwnerCannotDelete
}
// 验证当前密码(防止 CSRF 或未授权操作)
if !common.CheckPassword(password, user.PasswordHash) {
return ErrIncorrectPassword
}
user.Status = model.StatusDeleted
user.DeleteReason = reason
if err := s.userRepo.Update(user); err != nil {
return err
}
// 递增 token_version即时吊销所有 JWT 强制退登
return s.InvalidateSessions(userID)
}
// InvalidateSessions 吊销某用户所有 JWT递增 token_version强制所有设备重新登录
// 适用场景:修改密码、账号被盗、管理员强制下线
func (s *AuthService) InvalidateSessions(userID uint) error {
return s.userRepo.IncrementTokenVersion(userID)
}
// UpdateProfile 修改个人资料(用户名 + 个性签名)
// 用户名校验非空、1-16 字符、白名单、去重
// 个性签名校验0-128 字符纯文本Go 模板自动 HTML 转义防 XSS
// 任一字段无变更时跳过该字段的写库操作
func (s *AuthService) UpdateProfile(userID uint, username, bio string) error {
// 查当前用户
user, err := s.userRepo.FindByID(userID)
if err != nil {
return common.ErrUserNotFound
}
needsUpdate := false
// 用户名:校验 + 更新
if n := utf8.RuneCountInString(username); n == 0 {
return common.ErrUsernameInvalid
} else if n > 16 {
return common.ErrUsernameInvalid
}
if !usernamePattern.MatchString(username) {
return common.ErrUsernameInvalid
}
if username != user.Username {
exists, err := s.userRepo.ExistsByUsername(username)
if err != nil {
return err
}
if exists {
return common.ErrUsernameTaken
}
user.Username = username
needsUpdate = true
}
// 个性签名:长度校验 + 更新
if utf8.RuneCountInString(bio) > 128 {
return common.ErrBioTooLong
}
if bio != user.Bio {
user.Bio = bio
needsUpdate = true
}
if !needsUpdate {
return nil
}
return s.userRepo.Update(user)
}