feat: 实现登录失败锁定功能
新增了登录失败次数统计和临时锁定机制,包括: 1. 定义登录锁定数据结构和数据库表结构 2. 实现租户级别的锁定状态持久化接口 3. 添加登录速率限制安全策略配置 4. 在登录流程中加入锁定前置检查和失败计数逻辑 5. 提供管理员手动解锁的内部API接口 6. 新增中文本地化的锁定提示文案
This commit is contained in:
@@ -285,6 +285,16 @@ type TenantRegistrationInviteAdapter interface {
|
||||
TenantRegistrationInviteSyncUsage(t.TenantID, int64) error
|
||||
}
|
||||
|
||||
// TenantLoginLockoutAdapter persists per-account login failure counters and
|
||||
// lock state in each tenant business database.
|
||||
type TenantLoginLockoutAdapter interface {
|
||||
TenantLoginLockoutGet(t.TenantID, string) (*t.LoginLockout, error)
|
||||
TenantLoginLockoutRecordFailure(t.TenantID, string, *int64, time.Time, time.Time) (*t.LoginLockout, error)
|
||||
TenantLoginLockoutLock(t.TenantID, string, time.Time, time.Time) error
|
||||
TenantLoginLockoutReset(t.TenantID, string) error
|
||||
TenantLoginLockoutRelease(t.TenantID, string, time.Time) error
|
||||
}
|
||||
|
||||
// TenantAuthAdapter is the tenant-aware authentication and credential capability.
|
||||
// It is separate from Adapter while the remaining persistence domains are migrated.
|
||||
type TenantAuthAdapter interface {
|
||||
|
||||
@@ -38,6 +38,16 @@ func ensureIMAdminGovernanceTables(exec sqlExecutor) error {
|
||||
INDEX idx_im_login_failure_type_time(failure_type, attempt_at DESC, id DESC),
|
||||
INDEX idx_im_login_failure_request(request_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS im_login_lockouts (
|
||||
id BIGINT NOT NULL, user_id BIGINT,
|
||||
account_hash CHAR(64) NOT NULL,
|
||||
failed_attempts INT NOT NULL DEFAULT 0,
|
||||
window_started_at DATETIME(3), locked_until DATETIME(3), last_attempt_at DATETIME(3),
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY(id), UNIQUE INDEX uk_im_login_lockouts_account(account_hash),
|
||||
INDEX idx_im_login_lockouts_user(user_id), INDEX idx_im_login_lockouts_locked(locked_until)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS im_message_admin_actions (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT, action_id VARCHAR(96) NOT NULL,
|
||||
command_id VARCHAR(96) NOT NULL, conversation_type VARCHAR(16) NOT NULL,
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
//go:build mysql
|
||||
// +build mysql
|
||||
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
t "github.com/tinode/chat/server/store/types"
|
||||
)
|
||||
|
||||
func (a *adapter) routedLoginLockoutAdapter(id t.TenantID) (*adapter, error) {
|
||||
if err := validTenant(id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if a.tenantScopedDB {
|
||||
return a, nil
|
||||
}
|
||||
tenantAdp, err := a.routedTenantAdapter(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tenantAdp, nil
|
||||
}
|
||||
|
||||
type loginLockoutRow struct {
|
||||
ID int64 `db:"id"`
|
||||
UserID sql.NullInt64 `db:"user_id"`
|
||||
AccountHash string `db:"account_hash"`
|
||||
FailedAttempts int `db:"failed_attempts"`
|
||||
WindowStartAt sql.NullTime `db:"window_started_at"`
|
||||
LockedUntil sql.NullTime `db:"locked_until"`
|
||||
LastAttemptAt sql.NullTime `db:"last_attempt_at"`
|
||||
UpdatedAt sql.NullTime `db:"updated_at"`
|
||||
}
|
||||
|
||||
func loginLockoutFromRow(id t.TenantID, row loginLockoutRow) *t.LoginLockout {
|
||||
out := &t.LoginLockout{
|
||||
TenantID: id,
|
||||
ID: row.ID,
|
||||
AccountHash: row.AccountHash,
|
||||
FailedAttempts: row.FailedAttempts,
|
||||
}
|
||||
if row.UserID.Valid {
|
||||
out.UserID = row.UserID.Int64
|
||||
}
|
||||
if row.WindowStartAt.Valid {
|
||||
v := row.WindowStartAt.Time
|
||||
out.WindowStartAt = &v
|
||||
}
|
||||
if row.LockedUntil.Valid {
|
||||
v := row.LockedUntil.Time
|
||||
out.LockedUntil = &v
|
||||
}
|
||||
if row.LastAttemptAt.Valid {
|
||||
v := row.LastAttemptAt.Time
|
||||
out.LastAttemptAt = &v
|
||||
}
|
||||
if row.UpdatedAt.Valid {
|
||||
v := row.UpdatedAt.Time
|
||||
out.UpdatedAt = &v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const loginLockoutColumns = `id,user_id,account_hash,failed_attempts,window_started_at,locked_until,last_attempt_at,updated_at`
|
||||
|
||||
// TenantLoginLockoutGet reads the lockout counter for the given account hash.
|
||||
func (a *adapter) TenantLoginLockoutGet(id t.TenantID, accountHash string) (*t.LoginLockout, error) {
|
||||
tenantAdp, err := a.routedLoginLockoutAdapter(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tenantAdp != a {
|
||||
return tenantAdp.TenantLoginLockoutGet(id, accountHash)
|
||||
}
|
||||
if accountHash == "" {
|
||||
return nil, t.ErrMalformed
|
||||
}
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
var row loginLockoutRow
|
||||
err = a.db.GetContext(ctx, &row,
|
||||
`SELECT `+loginLockoutColumns+` FROM im_login_lockouts WHERE account_hash=?`, accountHash)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, t.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return loginLockoutFromRow(id, row), nil
|
||||
}
|
||||
|
||||
// TenantLoginLockoutRecordFailure atomically inserts or increments the failure
|
||||
// counter. windowCutoff is "now minus window seconds": an older window start
|
||||
// resets the counter to 1. The updated row is returned to the caller.
|
||||
func (a *adapter) TenantLoginLockoutRecordFailure(id t.TenantID, accountHash string, userID *int64, windowCutoff, now time.Time) (*t.LoginLockout, error) {
|
||||
tenantAdp, err := a.routedLoginLockoutAdapter(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tenantAdp != a {
|
||||
return tenantAdp.TenantLoginLockoutRecordFailure(id, accountHash, userID, windowCutoff, now)
|
||||
}
|
||||
if accountHash == "" || now.IsZero() {
|
||||
return nil, t.ErrMalformed
|
||||
}
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
rowID, err := nextSnowflakeID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// MySQL evaluates ON DUPLICATE KEY UPDATE assignments left to right, so
|
||||
// failed_attempts is recomputed before window_started_at is overwritten.
|
||||
_, err = a.db.ExecContext(ctx, `INSERT INTO im_login_lockouts
|
||||
(id,user_id,account_hash,failed_attempts,window_started_at,last_attempt_at)
|
||||
VALUES(?,?,?,1,?,?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
failed_attempts = IF(window_started_at IS NULL OR window_started_at < ?, 1, failed_attempts + 1),
|
||||
window_started_at = IF(window_started_at IS NULL OR window_started_at < ?, ?, window_started_at),
|
||||
last_attempt_at = ?,
|
||||
user_id = COALESCE(user_id, ?)`,
|
||||
rowID, userID, accountHash, now, now,
|
||||
windowCutoff, windowCutoff, now, now, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var row loginLockoutRow
|
||||
err = a.db.GetContext(ctx, &row,
|
||||
`SELECT `+loginLockoutColumns+` FROM im_login_lockouts WHERE account_hash=?`, accountHash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return loginLockoutFromRow(id, row), nil
|
||||
}
|
||||
|
||||
// TenantLoginLockoutLock sets locked_until unless a newer lock already covers now.
|
||||
func (a *adapter) TenantLoginLockoutLock(id t.TenantID, accountHash string, lockedUntil, now time.Time) error {
|
||||
tenantAdp, err := a.routedLoginLockoutAdapter(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tenantAdp != a {
|
||||
return tenantAdp.TenantLoginLockoutLock(id, accountHash, lockedUntil, now)
|
||||
}
|
||||
if accountHash == "" || lockedUntil.IsZero() || now.IsZero() {
|
||||
return t.ErrMalformed
|
||||
}
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx,
|
||||
`UPDATE im_login_lockouts SET locked_until=?, updated_at=? WHERE account_hash=? AND (locked_until IS NULL OR locked_until < ?)`,
|
||||
lockedUntil, now, accountHash, now)
|
||||
return err
|
||||
}
|
||||
|
||||
// TenantLoginLockoutReset removes the counter after a successful login.
|
||||
func (a *adapter) TenantLoginLockoutReset(id t.TenantID, accountHash string) error {
|
||||
tenantAdp, err := a.routedLoginLockoutAdapter(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tenantAdp != a {
|
||||
return tenantAdp.TenantLoginLockoutReset(id, accountHash)
|
||||
}
|
||||
if accountHash == "" {
|
||||
return t.ErrMalformed
|
||||
}
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, `DELETE FROM im_login_lockouts WHERE account_hash=?`, accountHash)
|
||||
return err
|
||||
}
|
||||
|
||||
// TenantLoginLockoutRelease clears the lock and zeroes the counter (admin unlock).
|
||||
func (a *adapter) TenantLoginLockoutRelease(id t.TenantID, accountHash string, now time.Time) error {
|
||||
tenantAdp, err := a.routedLoginLockoutAdapter(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tenantAdp != a {
|
||||
return tenantAdp.TenantLoginLockoutRelease(id, accountHash, now)
|
||||
}
|
||||
if accountHash == "" || now.IsZero() {
|
||||
return t.ErrMalformed
|
||||
}
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx,
|
||||
`UPDATE im_login_lockouts SET failed_attempts=0, window_started_at=NULL, locked_until=NULL, updated_at=? WHERE account_hash=?`,
|
||||
now, accountHash)
|
||||
return err
|
||||
}
|
||||
Reference in New Issue
Block a user