kra-new/internal/biz/system/security.go

295 lines
9.4 KiB
Go

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 = &copy
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 &copy, 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 = &copy
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()
}