chore(init): 初始化解忧商行项目并完成 Phase 0 基座
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
package adminauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"hfb_sys/backend/internal/model"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type BootstrapConfig struct {
|
||||
AppEnv string
|
||||
Username string
|
||||
Password string
|
||||
Nickname string
|
||||
}
|
||||
|
||||
// BootstrapAdmin 在管理员表为空时按部署配置创建首个超级管理员。
|
||||
func BootstrapAdmin(ctx context.Context, db *gorm.DB, cfg BootstrapConfig) error {
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
var count int64
|
||||
if err := db.WithContext(ctx).Model(&model.AdminUser{}).Count(&count).Error; err != nil {
|
||||
if isAdminTableMissing(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
username := strings.TrimSpace(cfg.Username)
|
||||
password := cfg.Password
|
||||
if username == "" || password == "" {
|
||||
if strings.EqualFold(strings.TrimSpace(cfg.AppEnv), "production") {
|
||||
return errors.New("admin bootstrap credentials are required when no admin exists in production")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(cfg.AppEnv), "production") && !bootstrapPasswordStrong(password) {
|
||||
return errors.New("ADMIN_BOOTSTRAP_PASSWORD must be at least 12 chars and include letters and digits in production")
|
||||
}
|
||||
nickname := strings.TrimSpace(cfg.Nickname)
|
||||
if nickname == "" {
|
||||
nickname = username
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
admin := model.AdminUser{
|
||||
Username: username,
|
||||
PasswordHash: string(hash),
|
||||
Nickname: nickname,
|
||||
Status: "active",
|
||||
TokenVersion: 1,
|
||||
PasswordMustChange: true,
|
||||
}
|
||||
if err := tx.Create(&admin).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var superAdminRole model.Role
|
||||
if err := tx.Where("code = ?", "super_admin").First(&superAdminRole).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&model.AdminUserRole{
|
||||
AdminID: admin.ID,
|
||||
RoleID: superAdminRole.ID,
|
||||
}).Error
|
||||
})
|
||||
}
|
||||
|
||||
func bootstrapPasswordStrong(value string) bool {
|
||||
if len([]rune(value)) < 12 {
|
||||
return false
|
||||
}
|
||||
hasLetter := false
|
||||
hasDigit := false
|
||||
for _, r := range value {
|
||||
if unicode.IsLetter(r) {
|
||||
hasLetter = true
|
||||
}
|
||||
if unicode.IsDigit(r) {
|
||||
hasDigit = true
|
||||
}
|
||||
}
|
||||
return hasLetter && hasDigit
|
||||
}
|
||||
|
||||
func isAdminTableMissing(err error) bool {
|
||||
message := strings.ToLower(err.Error())
|
||||
return strings.Contains(message, "admin_users") && (strings.Contains(message, "doesn't exist") || strings.Contains(message, "no such table"))
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package adminauth
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/modules/auth"
|
||||
)
|
||||
|
||||
type AdminDTO struct {
|
||||
ID uint64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Nickname string `json:"nickname"`
|
||||
Status string `json:"status"`
|
||||
SupportStatus string `json:"support_status"`
|
||||
PasswordMustChange bool `json:"password_must_change"`
|
||||
Roles []RoleDTO `json:"roles"`
|
||||
Permissions []string `json:"permissions"`
|
||||
LastLoginAt *time.Time `json:"last_login_at"`
|
||||
}
|
||||
|
||||
type RoleDTO struct {
|
||||
ID uint64 `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
CaptchaID string `json:"captcha_id" binding:"required"`
|
||||
CaptchaCode string `json:"captcha_code" binding:"required"`
|
||||
}
|
||||
|
||||
type LoginResult struct {
|
||||
Admin AdminDTO `json:"admin"`
|
||||
Tokens auth.TokenPair `json:"tokens"`
|
||||
}
|
||||
|
||||
type CaptchaDTO struct {
|
||||
CaptchaID string `json:"captcha_id"`
|
||||
Image string `json:"image"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
}
|
||||
|
||||
type UpdateSupportStatusRequest struct {
|
||||
Status string `json:"status" binding:"required,oneof=online offline busy"`
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package adminauth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"hfb_sys/backend/internal/middleware"
|
||||
"hfb_sys/backend/internal/modules/auth"
|
||||
"hfb_sys/backend/pkg/response"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
adminAccessCookieName = middleware.AdminAccessCookieName
|
||||
adminRefreshCookieName = "hfb_admin_refresh"
|
||||
adminRefreshCookieMaxAge = 14 * 24 * 60 * 60
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
service *Service
|
||||
}
|
||||
|
||||
func NewHandler(service *Service) *Handler {
|
||||
return &Handler{service: service}
|
||||
}
|
||||
|
||||
func (h *Handler) Captcha(c *gin.Context) {
|
||||
item, err := h.service.Captcha(c.Request.Context())
|
||||
if err != nil {
|
||||
writeAdminAuthError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, item)
|
||||
}
|
||||
|
||||
func (h *Handler) Login(c *gin.Context) {
|
||||
var req LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "用户名、密码和验证码不能为空")
|
||||
return
|
||||
}
|
||||
result, err := h.service.Login(c.Request.Context(), strings.TrimSpace(req.Username), req.Password, strings.TrimSpace(req.CaptchaID), strings.TrimSpace(req.CaptchaCode), c.ClientIP())
|
||||
if err != nil {
|
||||
writeAdminAuthError(c, err)
|
||||
return
|
||||
}
|
||||
setAdminAuthCookies(c, result.Tokens)
|
||||
response.OK(c, gin.H{"admin": result.Admin})
|
||||
}
|
||||
|
||||
func (h *Handler) Me(c *gin.Context) {
|
||||
value, ok := c.Get(middleware.ContextAdminID)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "缺少管理员上下文")
|
||||
return
|
||||
}
|
||||
adminID, ok := value.(uint64)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "管理员上下文无效")
|
||||
return
|
||||
}
|
||||
admin, err := h.service.Me(c.Request.Context(), adminID)
|
||||
if err != nil {
|
||||
writeAdminAuthError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, admin)
|
||||
}
|
||||
|
||||
func (h *Handler) Logout(c *gin.Context) {
|
||||
adminID, ok := currentAdminID(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "缺少管理员上下文")
|
||||
return
|
||||
}
|
||||
if err := h.service.Logout(c.Request.Context(), adminID); err != nil {
|
||||
writeAdminAuthError(c, err)
|
||||
return
|
||||
}
|
||||
clearAdminAuthCookies(c)
|
||||
response.OK(c, gin.H{"logged_out": true})
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateSupportStatus(c *gin.Context) {
|
||||
adminID, ok := currentAdminID(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "缺少管理员上下文")
|
||||
return
|
||||
}
|
||||
var req UpdateSupportStatusRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "状态值无效,必须是 online、offline 或 busy")
|
||||
return
|
||||
}
|
||||
if err := h.service.UpdateSupportStatus(c.Request.Context(), adminID, req.Status); err != nil {
|
||||
writeAdminAuthError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"updated": true, "support_status": req.Status})
|
||||
}
|
||||
|
||||
func currentAdminID(c *gin.Context) (uint64, bool) {
|
||||
value, ok := c.Get(middleware.ContextAdminID)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
adminID, ok := value.(uint64)
|
||||
return adminID, ok
|
||||
}
|
||||
|
||||
type AdminRefreshRequest struct {
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
|
||||
func (h *Handler) Refresh(c *gin.Context) {
|
||||
var req AdminRefreshRequest
|
||||
if c.Request.Body != nil && c.Request.ContentLength != 0 {
|
||||
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
|
||||
response.BadRequest(c, "refresh_token 格式不正确")
|
||||
return
|
||||
}
|
||||
}
|
||||
refreshToken := strings.TrimSpace(req.RefreshToken)
|
||||
refreshTokenSource := "body"
|
||||
if refreshToken == "" {
|
||||
if cookieValue, err := c.Cookie(adminRefreshCookieName); err == nil {
|
||||
refreshToken = strings.TrimSpace(cookieValue)
|
||||
refreshTokenSource = "cookie"
|
||||
}
|
||||
}
|
||||
if refreshToken == "" {
|
||||
response.BadRequest(c, "refresh_token 不能为空")
|
||||
return
|
||||
}
|
||||
tokens, err := h.service.Refresh(c.Request.Context(), refreshToken)
|
||||
if err != nil {
|
||||
reason, tokenVersion, currentVersion := middleware.AdminValidationFailure(err)
|
||||
middleware.RecordAdminAuthFailure(c, reason, refreshTokenSource, tokenVersion, currentVersion, middleware.AdminValidationFailureDetail(err))
|
||||
writeAdminAuthError(c, err)
|
||||
return
|
||||
}
|
||||
setAdminAuthCookies(c, *tokens)
|
||||
response.OK(c, gin.H{"refreshed": true, "expires_in": tokens.ExpiresInSeconds})
|
||||
}
|
||||
|
||||
func setAdminAuthCookies(c *gin.Context, tokens auth.TokenPair) {
|
||||
secure := isHTTPSRequest(c)
|
||||
httpOnly := true
|
||||
sameSite := http.SameSiteStrictMode
|
||||
c.SetSameSite(sameSite)
|
||||
c.SetCookie(adminAccessCookieName, tokens.AccessToken, int(tokens.ExpiresInSeconds), "/api/admin", "", secure, httpOnly)
|
||||
c.SetCookie(adminRefreshCookieName, tokens.RefreshToken, adminRefreshCookieMaxAge, "/api/admin/auth/refresh", "", secure, httpOnly)
|
||||
}
|
||||
|
||||
func clearAdminAuthCookies(c *gin.Context) {
|
||||
secure := isHTTPSRequest(c)
|
||||
c.SetSameSite(http.SameSiteStrictMode)
|
||||
c.SetCookie(adminAccessCookieName, "", -1, "/api/admin", "", secure, true)
|
||||
c.SetCookie(adminRefreshCookieName, "", -1, "/api/admin/auth/refresh", "", secure, true)
|
||||
}
|
||||
|
||||
func isHTTPSRequest(c *gin.Context) bool {
|
||||
if c.Request.TLS != nil {
|
||||
return true
|
||||
}
|
||||
return strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https")
|
||||
}
|
||||
|
||||
func writeAdminAuthError(c *gin.Context, err error) {
|
||||
response.RecordError(c, err)
|
||||
switch {
|
||||
case errors.Is(err, ErrDependencyUnavailable):
|
||||
response.ServiceUnavailable(c, "数据库未连接")
|
||||
case errors.Is(err, ErrInvalidRefreshToken):
|
||||
response.Unauthorized(c, "刷新令牌无效或已过期")
|
||||
case errors.Is(err, ErrInvalidCredential):
|
||||
response.BadRequest(c, "用户名或密码错误")
|
||||
case errors.Is(err, ErrCaptchaInvalid):
|
||||
response.BadRequest(c, "验证码错误或已过期")
|
||||
case errors.Is(err, ErrLoginLocked):
|
||||
response.Error(c, http.StatusTooManyRequests, "login_locked", "登录失败次数过多,请稍后再试")
|
||||
case errors.Is(err, ErrAdminDisabled):
|
||||
response.Error(c, http.StatusForbidden, "admin_disabled", "管理员已禁用")
|
||||
default:
|
||||
response.Error(c, http.StatusInternalServerError, "internal_error", "后台认证服务暂时不可用")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
package adminauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/auditlog"
|
||||
"hfb_sys/backend/internal/captcha"
|
||||
"hfb_sys/backend/internal/logging"
|
||||
"hfb_sys/backend/internal/model"
|
||||
"hfb_sys/backend/internal/modules/auth"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
captchaTTL = 3 * time.Minute
|
||||
loginFailureTTL = 15 * time.Minute
|
||||
loginLockTTL = 15 * time.Minute
|
||||
loginMaxFailureCount = 5
|
||||
)
|
||||
|
||||
type Repository struct {
|
||||
db *gorm.DB
|
||||
redis *redis.Client
|
||||
jwt *auth.JWTManager
|
||||
}
|
||||
|
||||
func NewRepository(db *gorm.DB, redis *redis.Client, jwt *auth.JWTManager) *Repository {
|
||||
return &Repository{db: db, redis: redis, jwt: jwt}
|
||||
}
|
||||
|
||||
func (r *Repository) Captcha(ctx context.Context) (*CaptchaDTO, error) {
|
||||
item, err := captcha.Generate(ctx, r.redis, "admin", captchaTTL)
|
||||
if errors.Is(err, captcha.ErrDependencyUnavailable) {
|
||||
return nil, ErrDependencyUnavailable
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &CaptchaDTO{
|
||||
CaptchaID: item.CaptchaID,
|
||||
Image: item.Image,
|
||||
ExpiresIn: item.ExpiresIn,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *Repository) Login(ctx context.Context, username string, password string, captchaID string, captchaCode string, clientIP string) (LoginResult, error) {
|
||||
if err := r.verifyCaptcha(ctx, captchaID, captchaCode); err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
if err := r.ensureLoginNotLocked(ctx, username, clientIP); err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
var admin model.AdminUser
|
||||
if err := r.db.WithContext(ctx).Where("username = ?", username).First(&admin).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
r.recordLoginFailure(ctx, username, clientIP)
|
||||
return LoginResult{}, ErrInvalidCredential
|
||||
}
|
||||
return LoginResult{}, err
|
||||
}
|
||||
if admin.Status != "active" {
|
||||
return LoginResult{}, ErrAdminDisabled
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(password)); err != nil {
|
||||
r.recordLoginFailure(ctx, username, clientIP)
|
||||
return LoginResult{}, ErrInvalidCredential
|
||||
}
|
||||
now := time.Now()
|
||||
admin.LastLoginAt = &now
|
||||
if admin.TokenVersion <= 0 {
|
||||
admin.TokenVersion = 1
|
||||
}
|
||||
if err := r.db.WithContext(ctx).Save(&admin).Error; err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
r.clearLoginFailures(ctx, username, clientIP)
|
||||
tokens, err := r.jwt.GenerateSubjectPairWithVersion(admin.ID, admin.Username, "admin", admin.TokenVersion)
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
dto := toDTO(admin)
|
||||
r.loadRolesAndPerms(ctx, &dto)
|
||||
return LoginResult{Admin: dto, Tokens: tokens}, nil
|
||||
}
|
||||
|
||||
func (r *Repository) FindActiveForToken(ctx context.Context, id uint64, tokenVersion int64) (*model.AdminUser, error) {
|
||||
if r.db == nil {
|
||||
return nil, newTokenDependencyError(ErrDependencyUnavailable)
|
||||
}
|
||||
var admin model.AdminUser
|
||||
if err := r.db.WithContext(ctx).First(&admin, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, newTokenValidationError("admin_not_found", ErrAdminNotFound)
|
||||
}
|
||||
return nil, newTokenDependencyError(err)
|
||||
}
|
||||
if admin.Status != "active" {
|
||||
return nil, newTokenValidationError("admin_disabled", ErrAdminDisabled)
|
||||
}
|
||||
if admin.TokenVersion <= 0 || admin.TokenVersion != tokenVersion {
|
||||
return nil, newTokenVersionMismatchError(tokenVersion, admin.TokenVersion)
|
||||
}
|
||||
return &admin, nil
|
||||
}
|
||||
|
||||
func (r *Repository) RevokeTokens(ctx context.Context, adminID uint64) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&model.AdminUser{}).
|
||||
Where("id = ?", adminID).
|
||||
UpdateColumn("token_version", gorm.Expr("token_version + 1")).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return appendTokenVersionAudit(tx, ctx, adminID, "logout")
|
||||
})
|
||||
}
|
||||
|
||||
func appendTokenVersionAudit(tx *gorm.DB, ctx context.Context, adminID uint64, reason string) error {
|
||||
bizID := adminID
|
||||
return auditlog.Append(tx, auditlog.Entry{
|
||||
ActorType: "admin",
|
||||
ActorID: logging.AdminIDFromContext(ctx),
|
||||
Action: "auth.token_version.bump",
|
||||
BizType: "admin_user",
|
||||
BizID: &bizID,
|
||||
Meta: auditlog.Meta{RequestID: logging.RequestIDFromContext(ctx)},
|
||||
Detail: map[string]any{
|
||||
"target_admin_id": adminID,
|
||||
"reason": reason,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (r *Repository) verifyCaptcha(ctx context.Context, captchaID string, captchaCode string) error {
|
||||
err := captcha.Verify(ctx, r.redis, "admin", captchaID, captchaCode)
|
||||
if errors.Is(err, captcha.ErrDependencyUnavailable) {
|
||||
return ErrDependencyUnavailable
|
||||
}
|
||||
if errors.Is(err, captcha.ErrInvalid) {
|
||||
return ErrCaptchaInvalid
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repository) FindByID(ctx context.Context, id uint64) (*AdminDTO, error) {
|
||||
var admin model.AdminUser
|
||||
if err := r.db.WithContext(ctx).First(&admin, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if admin.Status != "active" {
|
||||
return nil, ErrAdminDisabled
|
||||
}
|
||||
dto := toDTO(admin)
|
||||
r.loadRolesAndPerms(ctx, &dto)
|
||||
return &dto, nil
|
||||
}
|
||||
|
||||
func (r *Repository) FindActiveForPasswordGate(ctx context.Context, id uint64, tokenVersion int64) (*AdminDTO, error) {
|
||||
admin, err := r.FindActiveForToken(ctx, id, tokenVersion)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 认证只需要用户名和强制改密标识;复用已校验的查询结果,避免每个后台请求再查询一次数据库。
|
||||
return &AdminDTO{
|
||||
Username: admin.Username,
|
||||
PasswordMustChange: admin.PasswordMustChange,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *Repository) UpdateSupportStatus(ctx context.Context, adminID uint64, status string) error {
|
||||
if r.db == nil {
|
||||
return ErrDependencyUnavailable
|
||||
}
|
||||
if status != "online" && status != "offline" && status != "busy" {
|
||||
return errors.New("invalid support status")
|
||||
}
|
||||
return r.db.WithContext(ctx).Model(&model.AdminUser{}).
|
||||
Where("id = ?", adminID).
|
||||
Update("support_status", status).Error
|
||||
}
|
||||
|
||||
func toDTO(admin model.AdminUser) AdminDTO {
|
||||
return AdminDTO{
|
||||
ID: admin.ID,
|
||||
Username: admin.Username,
|
||||
Nickname: admin.Nickname,
|
||||
Status: admin.Status,
|
||||
SupportStatus: admin.SupportStatus,
|
||||
PasswordMustChange: admin.PasswordMustChange,
|
||||
LastLoginAt: admin.LastLoginAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Repository) loadRolesAndPerms(ctx context.Context, dto *AdminDTO) {
|
||||
if r.db == nil {
|
||||
return
|
||||
}
|
||||
// 加载角色
|
||||
var roles []RoleDTO
|
||||
r.db.WithContext(ctx).Table("roles").
|
||||
Joins("JOIN admin_user_roles aur ON aur.role_id = roles.id").
|
||||
Where("aur.admin_user_id = ?", dto.ID).
|
||||
Find(&roles)
|
||||
dto.Roles = roles
|
||||
dto.PasswordMustChange = dto.PasswordMustChange && rolesRequireInitialPasswordChange(roles)
|
||||
|
||||
// 加载权限
|
||||
for _, role := range roles {
|
||||
if role.Code == "super_admin" {
|
||||
dto.Permissions = []string{"*"}
|
||||
cachePermissions(ctx, r, dto.ID, dto.Permissions)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var permCodes []string
|
||||
r.db.WithContext(ctx).Table("permissions").
|
||||
Select("DISTINCT permissions.code").
|
||||
Joins("JOIN role_permissions rp ON rp.permission_id = permissions.id").
|
||||
Joins("JOIN admin_user_roles aur ON aur.role_id = rp.role_id").
|
||||
Where("aur.admin_user_id = ?", dto.ID).
|
||||
Pluck("code", &permCodes)
|
||||
dto.Permissions = permCodes
|
||||
|
||||
// 缓存权限到 Redis
|
||||
cachePermissions(ctx, r, dto.ID, permCodes)
|
||||
}
|
||||
|
||||
func rolesRequireInitialPasswordChange(roles []RoleDTO) bool {
|
||||
for _, role := range roles {
|
||||
if role.Code == "super_admin" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func cachePermissions(ctx context.Context, r *Repository, adminID uint64, permCodes []string) {
|
||||
if r.redis == nil || len(permCodes) == 0 {
|
||||
return
|
||||
}
|
||||
key := fmt.Sprintf("admin:perms:%d", adminID)
|
||||
raw, _ := json.Marshal(permCodes)
|
||||
r.redis.Set(ctx, key, string(raw), 2*time.Hour)
|
||||
}
|
||||
|
||||
func (r *Repository) ensureLoginNotLocked(ctx context.Context, username string, clientIP string) error {
|
||||
if r.redis == nil {
|
||||
return nil
|
||||
}
|
||||
locked, err := r.redis.Exists(ctx, loginLockKey(username, clientIP)).Result()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if locked > 0 {
|
||||
return ErrLoginLocked
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) recordLoginFailure(ctx context.Context, username string, clientIP string) {
|
||||
if r.redis == nil {
|
||||
return
|
||||
}
|
||||
key := loginFailureKey(username, clientIP)
|
||||
count, err := r.redis.Incr(ctx, key).Result()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if count == 1 {
|
||||
_ = r.redis.Expire(ctx, key, loginFailureTTL).Err()
|
||||
}
|
||||
if count >= loginMaxFailureCount {
|
||||
_ = r.redis.Set(ctx, loginLockKey(username, clientIP), "1", loginLockTTL).Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Repository) clearLoginFailures(ctx context.Context, username string, clientIP string) {
|
||||
if r.redis == nil {
|
||||
return
|
||||
}
|
||||
_ = r.redis.Del(ctx, loginFailureKey(username, clientIP), loginLockKey(username, clientIP)).Err()
|
||||
}
|
||||
|
||||
func loginFailureKey(username string, clientIP string) string {
|
||||
return "admin:login:fail:" + loginKeyPart(clientIP) + ":" + loginKeyPart(username)
|
||||
}
|
||||
|
||||
func loginLockKey(username string, clientIP string) string {
|
||||
return "admin:login:lock:" + loginKeyPart(clientIP) + ":" + loginKeyPart(username)
|
||||
}
|
||||
|
||||
func loginKeyPart(value string) string {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
value = strings.ReplaceAll(value, ":", "_")
|
||||
value = strings.ReplaceAll(value, "/", "_")
|
||||
if value == "" {
|
||||
return "_"
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package adminauth
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRolesRequireInitialPasswordChange(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
roles []RoleDTO
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "超级管理员需要修改初始密码",
|
||||
roles: []RoleDTO{{Code: "super_admin", Name: "超级管理员"}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "客服不强制修改初始密码",
|
||||
roles: []RoleDTO{{Code: "cs", Name: "客服"}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "运营不强制修改初始密码",
|
||||
roles: []RoleDTO{{Code: "ops", Name: "运营"}},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := rolesRequireInitialPasswordChange(tt.roles); got != tt.want {
|
||||
t.Fatalf("rolesRequireInitialPasswordChange() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package adminauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"hfb_sys/backend/internal/modules/auth"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrDependencyUnavailable = errors.New("dependency unavailable")
|
||||
ErrInvalidCredential = errors.New("invalid credential")
|
||||
ErrCaptchaInvalid = errors.New("captcha invalid")
|
||||
ErrAdminDisabled = errors.New("admin disabled")
|
||||
ErrInvalidRefreshToken = errors.New("invalid refresh token")
|
||||
ErrLoginLocked = errors.New("login locked")
|
||||
ErrAdminNotFound = errors.New("admin not found")
|
||||
ErrTokenVersionMismatch = errors.New("admin token version mismatch")
|
||||
)
|
||||
|
||||
// TokenValidationError 为后台会话失败保留安全的诊断类别,响应仍使用统一文案。
|
||||
type TokenValidationError struct {
|
||||
Reason string
|
||||
TokenVersion int64
|
||||
CurrentVersion int64
|
||||
Detail string
|
||||
Unavailable bool
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *TokenValidationError) Error() string {
|
||||
return "admin token validation failed: " + e.Reason
|
||||
}
|
||||
|
||||
func (e *TokenValidationError) Unwrap() error {
|
||||
return e.err
|
||||
}
|
||||
|
||||
// AuthFailureReason 供中间件读取失败类别并写入结构化日志。
|
||||
func (e *TokenValidationError) AuthFailureReason() string {
|
||||
return e.Reason
|
||||
}
|
||||
|
||||
// AuthFailureVersions 返回令牌声明版本和数据库当前版本;非版本不匹配时均为 0。
|
||||
func (e *TokenValidationError) AuthFailureVersions() (int64, int64) {
|
||||
return e.TokenVersion, e.CurrentVersion
|
||||
}
|
||||
|
||||
// AuthFailureDetail 返回仅供服务端日志使用的依赖错误摘要,不包含令牌内容。
|
||||
func (e *TokenValidationError) AuthFailureDetail() string {
|
||||
return e.Detail
|
||||
}
|
||||
|
||||
// AuthFailureUnavailable 标识当前失败是否由临时依赖故障引起。
|
||||
func (e *TokenValidationError) AuthFailureUnavailable() bool {
|
||||
return e.Unavailable
|
||||
}
|
||||
|
||||
func newTokenValidationError(reason string, err error) error {
|
||||
return &TokenValidationError{Reason: reason, err: err}
|
||||
}
|
||||
|
||||
func newTokenDependencyError(err error) error {
|
||||
detail := strings.Join(strings.Fields(err.Error()), " ")
|
||||
if len(detail) > 240 {
|
||||
detail = detail[:240]
|
||||
}
|
||||
return &TokenValidationError{
|
||||
Reason: "admin_db_query_failed",
|
||||
Detail: detail,
|
||||
Unavailable: true,
|
||||
err: errors.Join(ErrDependencyUnavailable, err),
|
||||
}
|
||||
}
|
||||
|
||||
func newTokenVersionMismatchError(tokenVersion, currentVersion int64) error {
|
||||
return &TokenValidationError{
|
||||
Reason: "token_version_mismatch",
|
||||
TokenVersion: tokenVersion,
|
||||
CurrentVersion: currentVersion,
|
||||
err: ErrTokenVersionMismatch,
|
||||
}
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repo *Repository
|
||||
jwt *auth.JWTManager
|
||||
}
|
||||
|
||||
func NewService(repo *Repository, jwt *auth.JWTManager) *Service {
|
||||
return &Service{repo: repo, jwt: jwt}
|
||||
}
|
||||
|
||||
func (s *Service) Refresh(ctx context.Context, refreshToken string) (*auth.TokenPair, error) {
|
||||
if s.jwt == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
}
|
||||
claims, err := s.jwt.ParseSubject(refreshToken, "refresh", "admin")
|
||||
if err != nil {
|
||||
return nil, newTokenValidationError(auth.TokenFailureReason(err), ErrInvalidRefreshToken)
|
||||
}
|
||||
if s.repo == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
}
|
||||
admin, err := s.repo.FindActiveForToken(ctx, claims.UserID, claims.TokenVersion)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrDependencyUnavailable) {
|
||||
return nil, err
|
||||
}
|
||||
if errors.Is(err, ErrAdminDisabled) {
|
||||
return nil, ErrAdminDisabled
|
||||
}
|
||||
return nil, invalidRefreshTokenError(err)
|
||||
}
|
||||
pair, err := s.jwt.GenerateSubjectPairWithVersion(admin.ID, admin.Username, "admin", admin.TokenVersion)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &pair, nil
|
||||
}
|
||||
|
||||
func invalidRefreshTokenError(err error) error {
|
||||
reason := "invalid"
|
||||
var tokenErr *TokenValidationError
|
||||
if errors.As(err, &tokenErr) {
|
||||
reason = tokenErr.Reason
|
||||
return &TokenValidationError{
|
||||
Reason: reason,
|
||||
TokenVersion: tokenErr.TokenVersion,
|
||||
CurrentVersion: tokenErr.CurrentVersion,
|
||||
err: ErrInvalidRefreshToken,
|
||||
}
|
||||
}
|
||||
return newTokenValidationError(reason, ErrInvalidRefreshToken)
|
||||
}
|
||||
|
||||
func (s *Service) Captcha(ctx context.Context) (*CaptchaDTO, error) {
|
||||
if s.repo == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
}
|
||||
return s.repo.Captcha(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) Login(ctx context.Context, username string, password string, captchaID string, captchaCode string, clientIP string) (LoginResult, error) {
|
||||
if s.repo == nil {
|
||||
return LoginResult{}, ErrDependencyUnavailable
|
||||
}
|
||||
if username == "" || password == "" || captchaID == "" || captchaCode == "" {
|
||||
return LoginResult{}, ErrInvalidCredential
|
||||
}
|
||||
return s.repo.Login(ctx, username, password, captchaID, captchaCode, clientIP)
|
||||
}
|
||||
|
||||
func (s *Service) Me(ctx context.Context, adminID uint64) (*AdminDTO, error) {
|
||||
if s.repo == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
}
|
||||
return s.repo.FindByID(ctx, adminID)
|
||||
}
|
||||
|
||||
func (s *Service) UpdateSupportStatus(ctx context.Context, adminID uint64, status string) error {
|
||||
if s.repo == nil {
|
||||
return ErrDependencyUnavailable
|
||||
}
|
||||
return s.repo.UpdateSupportStatus(ctx, adminID, status)
|
||||
}
|
||||
|
||||
func (s *Service) Logout(ctx context.Context, adminID uint64) error {
|
||||
if s.repo == nil {
|
||||
return ErrDependencyUnavailable
|
||||
}
|
||||
return s.repo.RevokeTokens(ctx, adminID)
|
||||
}
|
||||
Reference in New Issue
Block a user