package system 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 } // ErrDatabaseNotInitialized is returned by storage-backed settings that are // intentionally unavailable during the first-install bootstrap phase. var ErrDatabaseNotInitialized = errors.New("database not initialized") // DefaultSecurityConfig matches the seed values used for a fresh installation. // It is owned by biz so bootstrap callers do not need to depend on data. func DefaultSecurityConfig() *SecurityConfig { return &SecurityConfig{ ID: 1, CaptchaTimeout: 3600, KeyLong: 6, ImgWidth: 240, ImgHeight: 80, PwdMinLength: 8, LimitWindow: 60, LimitCount: 30, LockThreshold: 5, LockDuration: 30, PwdExpireDays: 90, ForceNewUserChangePassword: false, } } type SecurityRepo interface { SecurityConfig(context.Context) (*SecurityConfig, error) SaveSecurityConfig(context.Context, *SecurityConfig) error BackfillPasswordUpdatedAt(context.Context, time.Time) 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 { previous, err := uc.repo.SecurityConfig(ctx) if err != nil { return err } value.ID = previous.ID value.CreatedAt = previous.CreatedAt value.UpdatedAt = previous.UpdatedAt if err := uc.repo.SaveSecurityConfig(ctx, value); err != nil { return err } uc.mu.Lock() copy := *value uc.cachedConfig = © uc.mu.Unlock() // Keep the same observable ordering as the administration backend: // persist and activate the new configuration first, then backfill legacy // users when password expiration changes from disabled to enabled. A // backfill failure is returned to the caller without rolling back the // already effective configuration. if value.PwdExpireEnable && !previous.PwdExpireEnable { return uc.repo.BackfillPasswordUpdatedAt(ctx, time.Now()) } 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 { // The login page and captcha are also the entry point for first install. // Do not cache this fallback: after initdb succeeds the next request must // load the persisted row instead of serving bootstrap defaults forever. if errors.Is(err, ErrDatabaseNotInitialized) { return DefaultSecurityConfig(), nil } return nil, err } if value == nil { return nil, errors.New("security config is nil") } uc.mu.Lock() copy := *value uc.cachedConfig = © uc.mu.Unlock() return value, nil } // Security returns the persisted row for the administration endpoint. Runtime // policy consumers use Current so their hot path remains cached, while the // settings page mirrors the reference behavior and always observes storage. func (uc *SecurityUsecase) Security(ctx context.Context) (*SecurityConfig, error) { return uc.repo.SecurityConfig(ctx) } 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) 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) 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() } // TokenDisabled reports whether the token sits on the revocation blacklist. The // blacklist belongs to the token usecase; exposing it here keeps authentication // from reaching through this usecase into another one's dependencies. func (uc *SecurityUsecase) TokenDisabled(ctx context.Context, token string) (bool, error) { if uc == nil || uc.tokens == nil { return false, nil } return uc.tokens.IsTokenDisabled(ctx, token) } func (uc *SecurityUsecase) CaptchaRuntimeSettings() CaptchaSettings { return uc.settings.CaptchaSettings() }