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