package adminauth import ( "context" "encoding/json" "errors" "fmt" "strings" "time" "hfb_sys/backend/internal/captcha" "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) { 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 } if admin.TokenVersion <= 0 || admin.TokenVersion != tokenVersion { return nil, ErrInvalidRefreshToken } return &admin, nil } func (r *Repository) RevokeTokens(ctx context.Context, adminID uint64) error { return r.db.WithContext(ctx).Model(&model.AdminUser{}). Where("id = ?", adminID). UpdateColumn("token_version", gorm.Expr("token_version + 1")).Error } 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) 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 // 加载权限 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 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 }