Files
im_server/server/login_lockout.go
T
Austin a147dc718a refactor(server): 优化登录锁定提示文案和时长计算逻辑
更新了loginLockedMessage函数,细化了锁定时长的提示分级,增加了过期回退文案,让提示更准确友好
2026-09-21 13:55:02 +08:00

146 lines
5.5 KiB
Go

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)
}
}