- 移除未使用的DTO类型(ChangePasswordRequest/DeleteAccountRequest) - 移除service层未引用的错误重导出(auth_service/audit_service) - 删除未使用的SiteSettingRepo(数据访问由config.SiteSettings直接处理) - 删除未使用的FindByStatus方法(user_repo) - 删除未使用的AutoMigrate方法(audit_repo/notification_repo) - 删除未使用的PaginatedResult/NewPaginatedResult(pagination.go) - 修复接口注释:notifProvider(5→4)、auditUseCase(4→3)、siteSettingUseCase(3→4) - 移除auditStatusProvider未使用的FindByUsername方法 - 统一使用common.ErrXxx直接引用,消除跨service错误重导出耦合
340 lines
9.8 KiB
Go
340 lines
9.8 KiB
Go
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 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 common.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, common.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, common.ErrInvalidCred
|
||
}
|
||
|
||
// 已注销(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.ErrNeedsConfirmRestore
|
||
}
|
||
|
||
// 永久锁定
|
||
if user.Status == model.StatusLocked {
|
||
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password))
|
||
return "", "", nil, common.ErrUserLocked
|
||
}
|
||
|
||
// 封禁
|
||
if user.Status == model.StatusBanned {
|
||
return "", "", nil, common.ErrUserBanned
|
||
}
|
||
|
||
if !common.CheckPassword(req.Password, user.PasswordHash) {
|
||
return "", "", nil, common.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, common.ErrInvalidCred
|
||
}
|
||
|
||
// 仅 deleted 状态允许恢复
|
||
if user.Status != model.StatusDeleted {
|
||
return "", "", nil, common.ErrUserNotFound
|
||
}
|
||
|
||
if !common.CheckPassword(req.Password, user.PasswordHash) {
|
||
return "", "", nil, common.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 common.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 common.ErrOwnerCannotDelete
|
||
}
|
||
|
||
// 验证当前密码(防止 CSRF 或未授权操作)
|
||
if !common.CheckPassword(password, user.PasswordHash) {
|
||
return common.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)
|
||
}
|