package adminauth import ( "context" "crypto/rand" "encoding/base64" "encoding/json" "errors" "fmt" "html" "math/big" "strings" "time" "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) { if r.redis == nil { return nil, ErrDependencyUnavailable } captchaID, err := randomToken(16) if err != nil { return nil, err } code, err := randomCaptchaCode(4) if err != nil { return nil, err } if err := r.redis.Set(ctx, captchaKey(captchaID), strings.ToUpper(code), captchaTTL).Err(); err != nil { return nil, err } return &CaptchaDTO{ CaptchaID: captchaID, Image: captchaImageDataURL(code), ExpiresIn: int64(captchaTTL.Seconds()), }, 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 { if r.redis == nil { return ErrDependencyUnavailable } key := captchaKey(captchaID) stored, err := r.redis.Get(ctx, key).Result() if errors.Is(err, redis.Nil) { return ErrCaptchaInvalid } if err != nil { return err } _ = r.redis.Del(ctx, key).Err() if strings.ToUpper(strings.TrimSpace(captchaCode)) != stored { return ErrCaptchaInvalid } return nil } 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 } func captchaKey(id string) string { return "admin:captcha:" + id } func randomToken(length int) (string, error) { buf := make([]byte, length) if _, err := rand.Read(buf); err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(buf), nil } func randomCaptchaCode(length int) (string, error) { const alphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ" result := make([]byte, length) for i := range result { n, err := rand.Int(rand.Reader, big.NewInt(int64(len(alphabet)))) if err != nil { return "", err } result[i] = alphabet[n.Int64()] } return string(result), nil } func captchaImageDataURL(code string) string { safeCode := html.EscapeString(strings.ToUpper(code)) svg := fmt.Sprintf(` %s `, safeCode) return "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte(svg)) }