新增了登录失败次数统计和临时锁定机制,包括: 1. 定义登录锁定数据结构和数据库表结构 2. 实现租户级别的锁定状态持久化接口 3. 添加登录速率限制安全策略配置 4. 在登录流程中加入锁定前置检查和失败计数逻辑 5. 提供管理员手动解锁的内部API接口 6. 新增中文本地化的锁定提示文案
207 lines
6.0 KiB
Go
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
|
|
}
|