feat: 实现登录失败锁定功能
新增了登录失败次数统计和临时锁定机制,包括: 1. 定义登录锁定数据结构和数据库表结构 2. 实现租户级别的锁定状态持久化接口 3. 添加登录速率限制安全策略配置 4. 在登录流程中加入锁定前置检查和失败计数逻辑 5. 提供管理员手动解锁的内部API接口 6. 新增中文本地化的锁定提示文案
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
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 with the rounded
|
||||
// down remaining minutes; sub-minute remainders are reported as "within 1 minute".
|
||||
func loginLockedMessage(retryAfter time.Duration) string {
|
||||
if minutes := int(retryAfter.Minutes()); minutes >= 1 {
|
||||
return fmt.Sprintf("登录错误次数过多,账号已锁定,请 %d 分钟后再试", minutes)
|
||||
}
|
||||
return "登录错误次数过多,账号已锁定,请 1 分钟内再试"
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user