package biz import ( "context" "errors" "fmt" "strconv" "strings" "sync" "time" "unicode" "unicode/utf8" ) type SecurityConfig struct { ID uint CreatedAt time.Time UpdatedAt time.Time CaptchaOpen int CaptchaTimeout int KeyLong int ImgWidth int ImgHeight int PwdMinLength int PwdRequireUpper bool PwdRequireLower bool PwdRequireDigit bool PwdRequireSpecial bool LimitEnable bool LimitWindow int LimitCount int LockEnable bool LockThreshold int LockDuration int PwdExpireEnable bool PwdExpireDays int ForceNewUserChangePassword bool } type SecurityRepo interface { SecurityConfig(context.Context) (*SecurityConfig, error) SaveSecurityConfig(context.Context, *SecurityConfig) error } type SecurityUsecase struct { repo SecurityRepo cache Cache settings RuntimeSettings tokens *TokenUsecase mu sync.RWMutex cachedConfig *SecurityConfig } var ErrTokenRevoke = errors.New("token revoke failed") type TokenRevokeError struct{ Err error } func (e *TokenRevokeError) Error() string { return e.Err.Error() } func (e *TokenRevokeError) Is(target error) bool { return target == ErrTokenRevoke } func NewSecurityUsecase(repo SecurityRepo, cache Cache, settings RuntimeSettings, tokens *TokenUsecase) *SecurityUsecase { return &SecurityUsecase{repo: repo, cache: cache, settings: settings, tokens: tokens} } func (uc *SecurityUsecase) UpdateSecurity(ctx context.Context, value *SecurityConfig) error { if err := uc.repo.SaveSecurityConfig(ctx, value); err != nil { return err } uc.mu.Lock() copy := *value uc.cachedConfig = © uc.mu.Unlock() return nil } func (uc *SecurityUsecase) Current(ctx context.Context) (*SecurityConfig, error) { uc.mu.RLock() if uc.cachedConfig != nil { copy := *uc.cachedConfig uc.mu.RUnlock() return ©, nil } uc.mu.RUnlock() value, err := uc.repo.SecurityConfig(ctx) if err != nil { return nil, err } uc.mu.Lock() copy := *value uc.cachedConfig = © uc.mu.Unlock() return value, nil } func (uc *SecurityUsecase) ValidatePassword(value *SecurityConfig, password string) error { if value.PwdMinLength > 0 && utf8.RuneCountInString(password) < value.PwdMinLength { return fmt.Errorf("密码长度不能少于 %d 位", value.PwdMinLength) } hasUpper, hasLower, hasDigit, hasSpecial := false, false, false, false for _, ch := range password { switch { case unicode.IsUpper(ch): hasUpper = true case unicode.IsLower(ch): hasLower = true case unicode.IsDigit(ch): hasDigit = true case unicode.IsPunct(ch) || unicode.IsSymbol(ch): hasSpecial = true } } missing := make([]string, 0, 4) if value.PwdRequireUpper && !hasUpper { missing = append(missing, "大写字母") } if value.PwdRequireLower && !hasLower { missing = append(missing, "小写字母") } if value.PwdRequireDigit && !hasDigit { missing = append(missing, "数字") } if value.PwdRequireSpecial && !hasSpecial { missing = append(missing, "特殊字符") } if len(missing) > 0 { return fmt.Errorf("密码必须包含%s", strings.Join(missing, "、")) } return nil } func (uc *SecurityUsecase) ValidateCurrentPassword(ctx context.Context, password string) error { config, err := uc.Current(ctx) if err != nil { return err } return uc.ValidatePassword(config, password) } func (uc *SecurityUsecase) LoginLocked(ctx context.Context, username string) (bool, error) { _, locked, err := uc.cache.Get(ctx, loginLockKey(username)) return locked, err } func (uc *SecurityUsecase) IncrementLoginFailure(ctx context.Context, username string, expiration time.Duration) (int64, error) { return uc.cache.Increment(ctx, loginFailureKey(username), expiration) } func (uc *SecurityUsecase) LockLogin(ctx context.Context, username string, expiration time.Duration) error { return uc.cache.Set(ctx, loginLockKey(username), "1", expiration) } func (uc *SecurityUsecase) ClearLoginState(ctx context.Context, username string) { _ = uc.cache.Delete(ctx, loginFailureKey(username)) _ = uc.cache.Delete(ctx, loginLockKey(username)) } func (uc *SecurityUsecase) EnsureLoginIPCounter(ctx context.Context, ip string, expiration time.Duration) (int, error) { value, exists, err := uc.cache.Get(ctx, ip) if err != nil { return 0, err } if exists { return strconv.Atoi(value) } if expiration <= 0 { expiration = time.Hour } if err = uc.cache.Set(ctx, ip, "1", expiration); err != nil { return 0, err } return 1, nil } func (uc *SecurityUsecase) IncrementLoginIP(ctx context.Context, ip string, expiration time.Duration) (int64, error) { return uc.cache.Increment(ctx, ip, expiration) } func (uc *SecurityUsecase) IncrementRateLimit(ctx context.Context, key string, expiration time.Duration) (int64, error) { return uc.cache.Increment(ctx, key, expiration) } func (uc *SecurityUsecase) SetCaptcha(ctx context.Context, id, value string, expiration time.Duration) error { return uc.cache.Set(ctx, "CAPTCHA_"+id, value, expiration) } func (uc *SecurityUsecase) GetCaptcha(ctx context.Context, id string) (string, bool, error) { return uc.cache.Get(ctx, "CAPTCHA_"+id) } func (uc *SecurityUsecase) DeleteCaptcha(ctx context.Context, id string) error { return uc.cache.Delete(ctx, "CAPTCHA_"+id) } func (uc *SecurityUsecase) VerifyCaptcha(ctx context.Context, id, answer string, clear bool) bool { if id == "" || answer == "" { return false } value, ok, err := uc.GetCaptcha(ctx, id) if err != nil || !ok { return false } if clear { _ = uc.DeleteCaptcha(ctx, id) } return strings.EqualFold(value, answer) } // The currently active multipoint token is stored under the username itself. // Keep the same key so a rolling deployment can revoke sessions issued by the // other implementation instead of silently creating a second active session. func activeTokenKey(username string) string { return username } func loginFailureKey(username string) string { return "login_fail:" + username } func loginLockKey(username string) string { return "login_lock:" + username } func (uc *SecurityUsecase) ActiveToken(ctx context.Context, username string) (string, error) { value, _, err := uc.cache.Get(ctx, activeTokenKey(username)) return value, err } func (uc *SecurityUsecase) ActiveTokenMatches(ctx context.Context, username, token string) (bool, error) { if !uc.settings.UseMultipoint() { return true, nil } active, ok, err := uc.cache.Get(ctx, activeTokenKey(username)) return ok && active == token, err } func (uc *SecurityUsecase) RotateActiveToken(ctx context.Context, username, oldToken, newToken string, expiration time.Duration) error { if !uc.settings.UseMultipoint() { return nil } if oldToken != "" && oldToken != newToken { if err := uc.tokens.BlacklistToken(ctx, oldToken); err != nil { return &TokenRevokeError{Err: err} } } return uc.cache.Set(ctx, activeTokenKey(username), newToken, expiration) } func (uc *SecurityUsecase) UseMultipoint() bool { return uc.settings.UseMultipoint() } func (uc *SecurityUsecase) CaptchaRuntimeSettings() CaptchaSettings { return uc.settings.CaptchaSettings() }