Files
im_server/server/db/mysql/login_lockouts.go
T
Austin 841c0c77cd feat: 实现登录失败锁定功能
新增了登录失败次数统计和临时锁定机制,包括:
1. 定义登录锁定数据结构和数据库表结构
2. 实现租户级别的锁定状态持久化接口
3. 添加登录速率限制安全策略配置
4. 在登录流程中加入锁定前置检查和失败计数逻辑
5. 提供管理员手动解锁的内部API接口
6. 新增中文本地化的锁定提示文案
2026-09-20 17:08:38 +08:00

207 lines
6.0 KiB
Go

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