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
This commit is contained in:
@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"metazone.cc/metalab/internal/common"
|
||||
@ -25,18 +26,21 @@ func NewAuthService(userRepo userAuthStore, tokenSvc tokenProvider, cfg *config.
|
||||
}
|
||||
|
||||
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
|
||||
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]`)
|
||||
@ -68,7 +72,8 @@ func validatePassword(pw string) error {
|
||||
// Register 注册
|
||||
// 流程:密码强度 → 邮箱查重 → 哈希 → 生成唯一用户名 → 创建 → access JWT + refresh JWT
|
||||
// rememberMe=true: refresh Cookie 持久化(30天);false: session cookie(关浏览器即清除)
|
||||
func (s *AuthService) Register(req model.RegisterRequest) (string, string, *model.User, error) {
|
||||
// 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
|
||||
@ -102,6 +107,7 @@ func (s *AuthService) Register(req model.RegisterRequest) (string, string, *mode
|
||||
Username: username,
|
||||
Role: model.RoleUser,
|
||||
Status: model.StatusActive,
|
||||
RegIP: regIP,
|
||||
}
|
||||
if err := s.userRepo.Create(user); err != nil {
|
||||
return "", "", nil, err
|
||||
@ -123,24 +129,25 @@ func (s *AuthService) Register(req model.RegisterRequest) (string, string, *mode
|
||||
}
|
||||
|
||||
// Login 登录
|
||||
// 流程:按邮箱查找 → 检查状态 → 验证密码 → access JWT + refresh JWT
|
||||
// 流程:按邮箱查找 → 已注销则验证密码后要求二次确认 → 检查状态 → 验证密码 → 记录登录 IP/时间 → access JWT + refresh JWT
|
||||
// 防时序攻击:邮箱不存在时仍执行完整 bcrypt 比对
|
||||
// rememberMe=true: refresh Cookie 持久化(30天);false: session cookie(关浏览器即清除)
|
||||
func (s *AuthService) Login(req model.LoginRequest) (string, string, *model.User, error) {
|
||||
// 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)→ 自动恢复
|
||||
// 已注销(deleted)→ 验证密码后要求二次确认,不自动恢复
|
||||
if user.Status == model.StatusDeleted {
|
||||
user.Status = model.StatusActive
|
||||
user.DeletedAt = gorm.DeletedAt{}
|
||||
if err := s.userRepo.Update(user); err != nil {
|
||||
return "", "", nil, err
|
||||
if !common.CheckPassword(req.Password, user.PasswordHash) {
|
||||
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password))
|
||||
return "", "", nil, ErrInvalidCred
|
||||
}
|
||||
// 恢复后继续正常登录流程
|
||||
return "", "", nil, ErrNeedsConfirmRestore
|
||||
}
|
||||
|
||||
// 永久锁定
|
||||
@ -158,6 +165,14 @@ func (s *AuthService) Login(req model.LoginRequest) (string, string, *model.User
|
||||
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 {
|
||||
@ -173,6 +188,47 @@ func (s *AuthService) Login(req model.LoginRequest) (string, string, *model.User
|
||||
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)
|
||||
@ -183,6 +239,68 @@ 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 {
|
||||
|
||||
Reference in New Issue
Block a user