package main import ( "errors" "fmt" "strings" "time" "github.com/tinode/chat/server/logs" "github.com/tinode/chat/server/store" "github.com/tinode/chat/server/store/types" ) // loginAuditRiskActionLocked marks failure-log rows recorded at the moment the // account lockout is triggered; the admin console uses it to offer unlocking. const loginAuditRiskActionLocked = "account_locked" // loginLockoutAccount extracts the account name supplied with a basic-auth secret. func loginLockoutAccount(secret []byte) string { parts := strings.SplitN(string(secret), ":", 2) if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" { return "" } return strings.ToLower(strings.TrimSpace(parts[0])) } // tenantLoginRateLimitPolicy loads the published login rate limit policy for // the tenant. Any error or disabled policy yields ok=false (feature off, the // login flow then behaves exactly as before). func tenantLoginRateLimitPolicy(tenantCode string) (types.LoginRateLimitPolicy, bool) { if strings.TrimSpace(tenantCode) == "" { return types.LoginRateLimitPolicy{}, false } policy, err := store.ModerationConfig.SecurityPolicy(tenantCode) if err != nil { if !errors.Is(err, types.ErrUnsupported) { logs.Warn.Printf("login lockout policy load failed tenant:%s err:%v", tenantCode, err) } return types.LoginRateLimitPolicy{}, false } return policy.LoginRateLimit.Normalized() } // loginLockedMessage renders the user-facing lockout message: sub-minute // remainders are reported as "within 1 minute", sub-hour as "within 1 hour", // sub-day rounded down to whole hours, otherwise rounded down to whole days. // A non-positive remaining time (e.g. the lock already expired while rendering) // falls back to a generic message instead of a misleading duration. func loginLockedMessage(retryAfter time.Duration) string { const fallback = "登录错误次数过多,账号已锁定,请稍后再试或联系管理员" if retryAfter <= 0 { return fallback } prefix := "登录错误次数过多,账号已锁定,请 " switch { case retryAfter < time.Minute: return prefix + "1 分钟以内再试" case retryAfter < time.Hour: return prefix + "1 小时以内再试" case retryAfter < 24*time.Hour: return fmt.Sprintf("%s%d 小时后再试", prefix, int(retryAfter.Hours())) default: return fmt.Sprintf("%s%d 天后再试", prefix, int(retryAfter.Hours())/24) } } // loginLockoutGuard enforces the configured login failure lockout for one // login attempt of the "basic" scheme. A nil guard means the feature is not // applicable (non-basic scheme, unknown tenant, or disabled policy). type loginLockoutGuard struct { session *Session account string accountHash string policy types.LoginRateLimitPolicy } func newLoginLockoutGuard(s *Session, scheme string, secret []byte) *loginLockoutGuard { if s == nil || !s.tenantID.IsValid() || strings.TrimSpace(s.tenantCode) == "" || !strings.EqualFold(strings.TrimSpace(scheme), "basic") { return nil } account := loginLockoutAccount(secret) if account == "" { return nil } policy, ok := tenantLoginRateLimitPolicy(s.tenantCode) if !ok { return nil } return &loginLockoutGuard{session: s, account: account, accountHash: hashLoginAccount(account), policy: policy} } // preCheck rejects the attempt up front when the account is currently locked, // without comparing the password. func (g *loginLockoutGuard) preCheck() (time.Duration, bool) { rec, err := store.LoginLockouts.Get(g.session.tenantID, g.accountHash) if err != nil { if !errors.Is(err, types.ErrNotFound) && !errors.Is(err, types.ErrUnsupported) { logs.Warn.Printf("login lockout pre-check failed tenant:%d account_hash:%s err:%v", g.session.tenantID, g.accountHash, err) } return 0, false } now := time.Now() if rec.LockedUntil == nil || !now.Before(*rec.LockedUntil) { return 0, false } return time.Until(*rec.LockedUntil), true } // recordFailure counts one failed password attempt and, when the configured // threshold is reached, persists the lock. It returns the remaining lock time // and whether the account is now locked. func (g *loginLockoutGuard) recordFailure() (time.Duration, bool) { now := time.Now() cutoff := now.Add(-time.Duration(g.policy.WindowSeconds) * time.Second) rec, err := store.LoginLockouts.RecordFailure(g.session.tenantID, g.accountHash, nil, cutoff, now) if err != nil { if !errors.Is(err, types.ErrUnsupported) { logs.Warn.Printf("login lockout record failed tenant:%d account_hash:%s err:%v", g.session.tenantID, g.accountHash, err) } return 0, false } if rec.FailedAttempts < g.policy.PasswordMaxAttempts { return 0, false } until := now.Add(time.Duration(g.policy.LockSeconds) * time.Second) if rec.LockedUntil != nil && rec.LockedUntil.After(now) { // Lock already active (e.g. recorded within the same window): keep it. until = *rec.LockedUntil } else if err = store.LoginLockouts.Lock(g.session.tenantID, g.accountHash, until, now); err != nil { if !errors.Is(err, types.ErrUnsupported) { logs.Warn.Printf("login lockout persist failed tenant:%d account_hash:%s err:%v", g.session.tenantID, g.accountHash, err) } return 0, false } return time.Until(until), true } // clear resets the counter after a successful password check. func (g *loginLockoutGuard) clear() { if err := store.LoginLockouts.Reset(g.session.tenantID, g.accountHash); err != nil && !errors.Is(err, types.ErrNotFound) && !errors.Is(err, types.ErrUnsupported) { logs.Warn.Printf("login lockout reset failed tenant:%d account_hash:%s err:%v", g.session.tenantID, g.accountHash, err) } }