初始化项目:基础设施 + 用户认证 + 后台管理系统 + AGPL 3.0 许可
This commit is contained in:
141
internal/service/admin_service.go
Normal file
141
internal/service/admin_service.go
Normal file
@ -0,0 +1,141 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"metazone.cc/metalab/internal/common"
|
||||
"metazone.cc/metalab/internal/model"
|
||||
"metazone.cc/metalab/internal/repository"
|
||||
)
|
||||
|
||||
// AdminService 管理后台业务逻辑
|
||||
// 集中处理所有权限边界检查,避免胖控制器
|
||||
type AdminService struct {
|
||||
userRepo *repository.UserRepo
|
||||
}
|
||||
|
||||
// NewAdminService 构造函数
|
||||
func NewAdminService(userRepo *repository.UserRepo) *AdminService {
|
||||
return &AdminService{userRepo: userRepo}
|
||||
}
|
||||
|
||||
// ListUsersParams 用户列表查询参数
|
||||
type ListUsersParams struct {
|
||||
Keyword string // 搜索关键字(邮箱/用户名)
|
||||
Role string // 角色筛选
|
||||
Status string // 状态筛选
|
||||
Page int // 页码
|
||||
PageSize int // 每页条数
|
||||
}
|
||||
|
||||
// ListUsersResult 用户列表查询结果
|
||||
type ListUsersResult struct {
|
||||
Users []model.User `json:"users"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
}
|
||||
|
||||
// ListUsers 分页搜索用户列表
|
||||
func (s *AdminService) ListUsers(params ListUsersParams) (*ListUsersResult, error) {
|
||||
if params.Page < 1 {
|
||||
params.Page = 1
|
||||
}
|
||||
if params.PageSize < 1 || params.PageSize > 100 {
|
||||
params.PageSize = 20
|
||||
}
|
||||
offset := (params.Page - 1) * params.PageSize
|
||||
|
||||
total, err := s.userRepo.CountSearchUsers(params.Keyword, params.Role, params.Status)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
users, err := s.userRepo.SearchUsers(params.Keyword, params.Role, params.Status, offset, params.PageSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ListUsersResult{
|
||||
Users: users,
|
||||
Total: total,
|
||||
Page: params.Page,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UpdateUserStatus 修改用户状态
|
||||
// 权限规则:
|
||||
// - 不可操作自己
|
||||
// - Admin 只能设置 active/banned,且只能操作 RoleUser
|
||||
// - Owner 可设置 active/banned/locked,可操作任何人(除自己)
|
||||
func (s *AdminService) UpdateUserStatus(operatorUID, targetUID uint, newStatus string) error {
|
||||
// 校验状态值合法性
|
||||
switch newStatus {
|
||||
case model.StatusActive, model.StatusBanned, model.StatusLocked:
|
||||
default:
|
||||
return common.ErrPermissionDenied
|
||||
}
|
||||
|
||||
return s.checkAndOperate(operatorUID, targetUID, func(operator, target *model.User) error {
|
||||
// locked 相关操作仅 owner
|
||||
if target.Status == model.StatusLocked || newStatus == model.StatusLocked {
|
||||
if !model.HasMinRole(operator.Role, model.RoleOwner) {
|
||||
return common.ErrPermissionDenied
|
||||
}
|
||||
}
|
||||
if err := s.userRepo.UpdateStatus(target.ID, newStatus); err != nil {
|
||||
return err
|
||||
}
|
||||
// 锁定(删除)→ GORM 软删除释放邮箱;解锁 → 先查邮箱冲突再恢复
|
||||
if newStatus == model.StatusLocked {
|
||||
if err := s.userRepo.SoftDelete(target.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if target.Status == model.StatusLocked && newStatus == model.StatusActive {
|
||||
// 检查邮箱是否已被新用户注册(软删期间邮箱释放了)
|
||||
collision, err := s.userRepo.ExistsByEmailExclude(target.Email, target.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if collision {
|
||||
return common.ErrEmailExists
|
||||
}
|
||||
if err := s.userRepo.Restore(target.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// 修改状态必须立刻让 JWT 失效
|
||||
return s.userRepo.IncrementTokenVersion(target.ID)
|
||||
})
|
||||
}
|
||||
|
||||
// ResetUserToken 强制下线(递增 token_version)
|
||||
func (s *AdminService) ResetUserToken(operatorUID, targetUID uint) error {
|
||||
return s.checkAndOperate(operatorUID, targetUID, func(_ *model.User, target *model.User) error {
|
||||
return s.userRepo.IncrementTokenVersion(target.ID)
|
||||
})
|
||||
}
|
||||
|
||||
// checkAndOperate 通用权限检查 + 执行操作
|
||||
func (s *AdminService) checkAndOperate(operatorUID, targetUID uint, operate func(*model.User, *model.User) error) error {
|
||||
// 1. 不可操作自己
|
||||
if operatorUID == targetUID {
|
||||
return common.ErrPermissionDenied
|
||||
}
|
||||
|
||||
// 2. 查操作者
|
||||
operator, err := s.userRepo.FindByIDUnscoped(operatorUID)
|
||||
if err != nil {
|
||||
return common.ErrUserNotFound
|
||||
}
|
||||
|
||||
// 3. 查目标(含软删除用户,locked 解锁需要查到)
|
||||
target, err := s.userRepo.FindByIDUnscoped(targetUID)
|
||||
if err != nil {
|
||||
return common.ErrUserNotFound
|
||||
}
|
||||
|
||||
// 4. Admin 只能操作 user 角色
|
||||
if operator.Role == model.RoleAdmin && target.Role != model.RoleUser {
|
||||
return common.ErrPermissionDenied
|
||||
}
|
||||
|
||||
return operate(operator, target)
|
||||
}
|
||||
182
internal/service/auth_service.go
Normal file
182
internal/service/auth_service.go
Normal file
@ -0,0 +1,182 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
|
||||
"metazone.cc/metalab/internal/common"
|
||||
"metazone.cc/metalab/internal/config"
|
||||
"metazone.cc/metalab/internal/model"
|
||||
"metazone.cc/metalab/internal/repository"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AuthService 认证业务逻辑
|
||||
type AuthService struct {
|
||||
userRepo *repository.UserRepo
|
||||
tokenService *TokenService
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
// NewAuthService 构造函数
|
||||
func NewAuthService(userRepo *repository.UserRepo, tokenSvc *TokenService, 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
|
||||
)
|
||||
|
||||
var pwLetter = regexp.MustCompile(`[a-zA-Z]`)
|
||||
var pwDigit = regexp.MustCompile(`\d`)
|
||||
|
||||
// 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(关浏览器即清除)
|
||||
func (s *AuthService) Register(req model.RegisterRequest) (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,
|
||||
}
|
||||
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 登录
|
||||
// 流程:按邮箱查找 → 检查状态 → 验证密码 → 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) {
|
||||
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 {
|
||||
user.Status = model.StatusActive
|
||||
user.DeletedAt = gorm.DeletedAt{}
|
||||
if err := s.userRepo.Update(user); err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
// 恢复后继续正常登录流程
|
||||
}
|
||||
|
||||
// 永久锁定
|
||||
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
|
||||
}
|
||||
|
||||
// 生成 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
|
||||
}
|
||||
|
||||
// CheckEmail 检查邮箱是否已被注册(无需认证的轻量检查)
|
||||
func (s *AuthService) CheckEmail(email string) (bool, error) {
|
||||
return s.userRepo.ExistsByEmail(email)
|
||||
}
|
||||
|
||||
// InvalidateSessions 吊销某用户所有 JWT(递增 token_version,强制所有设备重新登录)
|
||||
// 适用场景:修改密码、账号被盗、管理员强制下线
|
||||
func (s *AuthService) InvalidateSessions(userID uint) error {
|
||||
return s.userRepo.IncrementTokenVersion(userID)
|
||||
}
|
||||
123
internal/service/token_service.go
Normal file
123
internal/service/token_service.go
Normal file
@ -0,0 +1,123 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"metazone.cc/metalab/internal/common"
|
||||
"metazone.cc/metalab/internal/config"
|
||||
"metazone.cc/metalab/internal/model"
|
||||
"metazone.cc/metalab/internal/repository"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// TokenService JWT 令牌签发与刷新
|
||||
// 独立于认证业务逻辑,供 AuthService 和其他需要签发令牌的服务使用
|
||||
type TokenService struct {
|
||||
cfg *config.Config
|
||||
userRepo *repository.UserRepo
|
||||
}
|
||||
|
||||
// NewTokenService 构造函数
|
||||
func NewTokenService(cfg *config.Config, userRepo *repository.UserRepo) *TokenService {
|
||||
return &TokenService{cfg: cfg, userRepo: userRepo}
|
||||
}
|
||||
|
||||
// BuildAccessToken 构建 access JWT(含 uid/email/username/role/ver/exp/iat)
|
||||
// ver = user.TokenVersion,用于即时吊销:版本号不匹配则拒绝
|
||||
func (ts *TokenService) BuildAccessToken(user *model.User) (string, error) {
|
||||
expire := time.Duration(ts.cfg.JWT.AccessExpire) * time.Minute
|
||||
now := time.Now()
|
||||
expAt := now.Add(expire)
|
||||
log.Printf("[BuildAccessToken] AccessExpire=%d min → expire=%v | now=%v | exp=%v (%d) | iat=%d ver=%d",
|
||||
ts.cfg.JWT.AccessExpire, expire, now, expAt, expAt.Unix(), now.Unix(), user.TokenVersion)
|
||||
claims := jwt.MapClaims{
|
||||
"uid": user.ID,
|
||||
"email": user.Email,
|
||||
"username": user.Username,
|
||||
"role": user.Role,
|
||||
"ver": user.TokenVersion,
|
||||
"exp": expAt.Unix(),
|
||||
"iat": now.Unix(),
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(ts.cfg.JWT.Secret))
|
||||
}
|
||||
|
||||
// BuildRefreshToken 构建 refresh JWT(含 purpose:"refresh" 防止 access 冒充)
|
||||
// rememberMe=true → 使用 remember_expire(30天);false → 使用 refresh_expire(7天,session 模式)
|
||||
// ver = user.TokenVersion,刷新时校验:版本号不匹配则拒绝
|
||||
func (ts *TokenService) BuildRefreshToken(user *model.User, rememberMe bool) (string, error) {
|
||||
var expireHours int
|
||||
if rememberMe {
|
||||
expireHours = ts.cfg.JWT.RememberExpire
|
||||
} else {
|
||||
expireHours = ts.cfg.JWT.RefreshExpire
|
||||
}
|
||||
expire := time.Duration(expireHours) * time.Hour
|
||||
now := time.Now()
|
||||
claims := jwt.MapClaims{
|
||||
"uid": user.ID,
|
||||
"email": user.Email,
|
||||
"username": user.Username,
|
||||
"role": user.Role,
|
||||
"ver": user.TokenVersion,
|
||||
"exp": now.Add(expire).Unix(),
|
||||
"iat": now.Unix(),
|
||||
"purpose": "refresh",
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(ts.cfg.JWT.Secret))
|
||||
}
|
||||
|
||||
// RefreshAccessToken 用 refresh token 换取新的 access token
|
||||
// 校验 token_version:若 DB 中版本已递增,拒绝刷新 → 即时吊销
|
||||
func (ts *TokenService) RefreshAccessToken(refreshTokenStr string) (string, *model.User, error) {
|
||||
if refreshTokenStr == "" {
|
||||
return "", nil, ErrTokenInvalid
|
||||
}
|
||||
|
||||
token, err := jwt.Parse(refreshTokenStr, func(t *jwt.Token) (interface{}, error) {
|
||||
return []byte(ts.cfg.JWT.Secret), nil
|
||||
})
|
||||
if err != nil || !token.Valid {
|
||||
return "", nil, ErrTokenExpired
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return "", nil, ErrTokenInvalid
|
||||
}
|
||||
|
||||
// 只接受 refresh 用途的 token,防止 access token 被用于刷新
|
||||
if purpose, _ := claims["purpose"].(string); purpose != "refresh" {
|
||||
return "", nil, ErrTokenInvalid
|
||||
}
|
||||
|
||||
uid := uint(claims["uid"].(float64))
|
||||
tokenVer := int(claims["ver"].(float64))
|
||||
|
||||
// 即时吊销检查 + 获取最新用户数据(角色/状态可能在 JWT 签发后已变更)
|
||||
// 用 FindByIDForAuth 而非从 claims 重建,确保 access token 承载最新数据
|
||||
user, err := ts.userRepo.FindByIDForAuth(uid)
|
||||
if err != nil {
|
||||
return "", nil, common.ErrTokenRevoked
|
||||
}
|
||||
|
||||
if tokenVer != user.TokenVersion {
|
||||
log.Printf("[RefreshAccessToken] REVOKED: uid=%d tokenVer=%d dbVer=%d", uid, tokenVer, user.TokenVersion)
|
||||
return "", nil, common.ErrTokenRevoked
|
||||
}
|
||||
|
||||
// 防止封禁用户通过 refresh 续期
|
||||
if user.Status == model.StatusBanned {
|
||||
return "", nil, common.ErrUserBanned
|
||||
}
|
||||
|
||||
accessToken, err := ts.BuildAccessToken(user)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return accessToken, user, nil
|
||||
}
|
||||
Reference in New Issue
Block a user