276 lines
8.6 KiB
Go
276 lines
8.6 KiB
Go
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
|
|
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 {
|
|
// Return the default value together with the database-not-ready
|
|
// error. Callers such as login/runtime policy consumers intentionally
|
|
// ignore the error and continue with that default, while the HTTP
|
|
// settings endpoint still reports the failure.
|
|
if value != nil {
|
|
return value, err
|
|
}
|
|
return nil, err
|
|
}
|
|
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) 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()
|
|
}
|