feat: 实现登录失败锁定功能

新增了登录失败次数统计和临时锁定机制,包括:
1. 定义登录锁定数据结构和数据库表结构
2. 实现租户级别的锁定状态持久化接口
3. 添加登录速率限制安全策略配置
4. 在登录流程中加入锁定前置检查和失败计数逻辑
5. 提供管理员手动解锁的内部API接口
6. 新增中文本地化的锁定提示文案
This commit is contained in:
Austin
2026-09-20 17:08:38 +08:00
parent 21063d4af0
commit 841c0c77cd
12 changed files with 563 additions and 3 deletions
+75
View File
@@ -4438,6 +4438,80 @@ func actionTokenCacheID(uid types.Uid, tokenID, action, target string) string {
return uid.String() + ":" + strings.TrimSpace(tokenID) + ":" + strings.TrimSpace(action) + ":" + strings.TrimSpace(target)
}
// LoginLockoutPersistenceInterface stores per-account login failure counters
// and temporary lock state in each tenant business database.
type LoginLockoutPersistenceInterface interface {
Get(tenantID types.TenantID, accountHash string) (*types.LoginLockout, error)
RecordFailure(tenantID types.TenantID, accountHash string, userID *int64, windowCutoff, now time.Time) (*types.LoginLockout, error)
Lock(tenantID types.TenantID, accountHash string, lockedUntil, now time.Time) error
Reset(tenantID types.TenantID, accountHash string) error
Release(tenantID types.TenantID, accountHash string, now time.Time) error
}
type loginLockoutMapper struct{}
// LoginLockouts is the singleton for login lockout state.
var LoginLockouts LoginLockoutPersistenceInterface
func tenantLoginLockoutAdapter(tenantID types.TenantID) (adapter.TenantLoginLockoutAdapter, error) {
if !tenantID.IsValid() {
return nil, types.ErrMalformed
}
if router, ok := adp.(adapter.TenantRoutedAdapter); ok {
business, _, err := router.TenantAdapterFor(tenantID)
if err != nil {
return nil, err
}
if lockAdp, ok := business.(adapter.TenantLoginLockoutAdapter); ok {
return lockAdp, nil
}
}
if lockAdp, ok := adp.(adapter.TenantLoginLockoutAdapter); ok {
return lockAdp, nil
}
return nil, types.ErrUnsupported
}
func (loginLockoutMapper) Get(tenantID types.TenantID, accountHash string) (*types.LoginLockout, error) {
lockAdp, err := tenantLoginLockoutAdapter(tenantID)
if err != nil {
return nil, err
}
return lockAdp.TenantLoginLockoutGet(tenantID, accountHash)
}
func (loginLockoutMapper) RecordFailure(tenantID types.TenantID, accountHash string, userID *int64, windowCutoff, now time.Time) (*types.LoginLockout, error) {
lockAdp, err := tenantLoginLockoutAdapter(tenantID)
if err != nil {
return nil, err
}
return lockAdp.TenantLoginLockoutRecordFailure(tenantID, accountHash, userID, windowCutoff, now)
}
func (loginLockoutMapper) Lock(tenantID types.TenantID, accountHash string, lockedUntil, now time.Time) error {
lockAdp, err := tenantLoginLockoutAdapter(tenantID)
if err != nil {
return err
}
return lockAdp.TenantLoginLockoutLock(tenantID, accountHash, lockedUntil, now)
}
func (loginLockoutMapper) Reset(tenantID types.TenantID, accountHash string) error {
lockAdp, err := tenantLoginLockoutAdapter(tenantID)
if err != nil {
return err
}
return lockAdp.TenantLoginLockoutReset(tenantID, accountHash)
}
func (loginLockoutMapper) Release(tenantID types.TenantID, accountHash string, now time.Time) error {
lockAdp, err := tenantLoginLockoutAdapter(tenantID)
if err != nil {
return err
}
return lockAdp.TenantLoginLockoutRelease(tenantID, accountHash, now)
}
func tenantRegistrationInviteAdapter(tenantID types.TenantID) (adapter.TenantRegistrationInviteAdapter, error) {
if !tenantID.IsValid() {
return nil, types.ErrMalformed
@@ -4606,6 +4680,7 @@ func init() {
PCache = pcacheMapper{}
Verifications = verificationMapper{}
RegistrationInvites = registrationInviteMapper{}
LoginLockouts = loginLockoutMapper{}
Security = userSecurityMapper{}
Relations = relationsMapper{}
Favorites = favoritesMapper{}
+17
View File
@@ -0,0 +1,17 @@
package types
import "time"
// LoginLockout is the per-account login failure counter and lock state
// stored in each tenant business database (table im_login_lockouts).
type LoginLockout struct {
TenantID TenantID `json:"tenant_id"`
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
AccountHash string `json:"account_hash"`
FailedAttempts int `json:"failed_attempts"`
WindowStartAt *time.Time `json:"window_started_at"`
LockedUntil *time.Time `json:"locked_until"`
LastAttemptAt *time.Time `json:"last_attempt_at"`
UpdatedAt *time.Time `json:"updated_at"`
}
+28
View File
@@ -7,9 +7,37 @@ type SecurityPolicy struct {
GlobalFriendEnabled bool `json:"global_friend_enabled"`
FriendDisabledPolicy map[string]any `json:"friend_disabled_policy"`
MessageCurfewEnabled bool `json:"message_curfew_enabled"`
LoginRateLimit *LoginRateLimitPolicy `json:"login_rate_limit"`
CurfewRules []*MessageCurfewRule `json:"curfew_rules"`
}
// LoginRateLimitPolicy configures temporary account lockout after repeated
// login password failures. Zero values keep the feature disabled.
type LoginRateLimitPolicy struct {
Enabled bool `json:"enabled"`
PasswordMaxAttempts int `json:"password_max_attempts"`
LockSeconds int `json:"lock_seconds"`
WindowSeconds int `json:"window_seconds"`
}
// Normalized returns defaults-completed limits; ok=false when disabled or invalid.
func (p *LoginRateLimitPolicy) Normalized() (LoginRateLimitPolicy, bool) {
if p == nil || !p.Enabled {
return LoginRateLimitPolicy{}, false
}
normalized := *p
if normalized.PasswordMaxAttempts <= 0 {
normalized.PasswordMaxAttempts = 5
}
if normalized.LockSeconds <= 0 {
normalized.LockSeconds = 900
}
if normalized.WindowSeconds <= 0 {
normalized.WindowSeconds = 1800
}
return normalized, true
}
type MessageCurfewRule struct {
RuleCode string `json:"rule_code"`
RuleName string `json:"rule_name"`