Account lockout logic for brute force prevention.
Checks and manages lockout state based on failed login attempts. The lockout counter tracks failed password attempts only. After reaching the threshold, the account is temporarily locked for a configurable duration and auto-unlocks when the duration expires.
Security Properties
- Lockout check happens before password hash verification (saves CPU).
- Messages are enumeration-safe (generic text via
Sigra.Error.safe_message/1). - Counter resets on successful login.
- Auto-unlocks after duration expires.
- Hooks via telemetry only (
[:sigra, :security, :lockout]).
Options
All functions accept the following options:
:threshold- Number of failed attempts before lockout. Default:5.:duration- Lockout duration in seconds. Default:900(15 minutes).
Examples
case Sigra.Lockout.check(user) do
:ok -> # proceed to password verification
{:error, :account_locked, remaining} -> # account is locked
end
Summary
Functions
Record a lockout event in the audit log (standalone, D-28).
Check if user is locked out.
Increment failed login attempts. Sets locked_at when threshold reached.
Return lock status with remaining seconds.
Check if user is currently locked.
Reset lockout state after successful login.
Functions
@spec audit_lockout(keyword()) :: :ok
Record a lockout event in the audit log (standalone, D-28).
Invoked by Sigra.Auth after a lockout is triggered. Exposed as a no-op-safe helper so callers don't need to build Sigra.Audit opts manually. Uses Sigra.Audit.log_safe which skips when audit_schema is nil.
@spec check( struct() | nil, keyword() ) :: :ok | {:error, :account_locked, non_neg_integer()}
Check if user is locked out.
Returns :ok or {:error, :account_locked, remaining_seconds}.
Returns :ok for nil users (non-existent account) to support
enumeration-safe flows where a dummy hash is performed regardless.
Examples
iex> Sigra.Lockout.check(nil)
:ok
iex> Sigra.Lockout.check(%{failed_login_attempts: 3, locked_at: nil})
:ok
Increment failed login attempts. Sets locked_at when threshold reached.
Must be called within a transaction or after failed password verification.
Only sets locked_at when failed_login_attempts reaches the threshold,
not before.
Examples
updated_user = Sigra.Lockout.increment!(MyApp.Repo, user)
@spec lock_status( struct() | nil, keyword() ) :: :unlocked | {:locked, non_neg_integer()}
Return lock status with remaining seconds.
Returns {:locked, remaining_seconds} or :unlocked.
Returns :unlocked for nil users.
Examples
case Sigra.Lockout.lock_status(user) do
:unlocked -> # user can log in
{:locked, seconds} -> # locked for N more seconds
end
Check if user is currently locked.
Returns true when within lockout window, false otherwise.
Returns false for nil users.
Examples
iex> Sigra.Lockout.locked?(nil)
false
Reset lockout state after successful login.
Sets failed_login_attempts to 0 and locked_at to nil.
Examples
updated_user = Sigra.Lockout.reset!(MyApp.Repo, user)