Malachi.Auth.LockoutManager (malachi v0.22.7)

View Source

Account-lockout facade over the replicated lockout store (Malachi.Auth.LockoutServer, a dedicated ra cluster). Applies progressive lockout after repeated failed logins, keyed by {username, ip}.

Replaces the old node-local ETS store: lockouts now replicate across the cluster, so brute-force protection is cluster-wide (an attacker cannot spread attempts across nodes to dodge the limit) and survives a restart (a restart cannot reset a lockout). The pure lockout logic lives in Malachi.Auth.LockoutRegistry; this module reads the lockout config, routes reads to the local replica and writes through the Raft log, and performs the observable side effects (metrics, logging, audit) for a newly applied or cleared lockout.

Progressive Lockout

  • 1st lockout (5 failures): 5 minutes
  • 2nd lockout (10 failures): 15 minutes
  • 3rd lockout (15 failures): 45 minutes
  • 4th lockout (20 failures): 2 hours
  • 5th+ lockout (25+ failures): 6 hours (maximum)

Process role and blocking

Reads (locked?/get_failed_attempts/list_locked_accounts) are direct local queries, no consensus, no GenServer round-trip. The hot-path writes (record_failed_attempt/record_successful_auth) run in a background task on Malachi.TaskSupervisor, so the auth path never blocks on a consensus round-trip or an ra leader election, and no single process funnels every write; ra serializes the concurrent writes itself, and the rate/connection limiters bound the task volume. unlock_account is a synchronous admin action (the operator wants the result). The GenServer owns only the periodic cleanup timer. Malachi.Application forms the ra lockout cluster before this process starts.

Summary

Types

An IP as the acceptor supplies it (tuple) or already formatted (string).

Functions

Returns a specification to start this module under a supervisor.

Returns the number of failed attempts for a username + IP.

Lists all currently locked accounts as info maps (username, ip, locked_until, ...).

Checks if an account is locked.

Records a failed authentication attempt, applying a lockout once the limit is reached.

Records a successful authentication, clearing failed attempts and any lockout for the username + IP.

Starts the lockout manager, which owns the periodic cleanup timer for the replicated store.

Unlocks an account manually (administrative action).

Types

ip()

@type ip() :: :inet.ip_address() | String.t()

An IP as the acceptor supplies it (tuple) or already formatted (string).

Functions

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

get_failed_attempts(username, ip)

@spec get_failed_attempts(String.t(), ip()) :: non_neg_integer()

Returns the number of failed attempts for a username + IP.

list_locked_accounts()

@spec list_locked_accounts() :: [map()]

Lists all currently locked accounts as info maps (username, ip, locked_until, ...).

locked?(username, ip)

@spec locked?(String.t(), ip()) :: :not_locked | {:locked, non_neg_integer()}

Checks if an account is locked.

Returns :not_locked, or {:locked, time_remaining_ms} when the account is locked. A direct read of the local replica (no consensus round-trip).

record_failed_attempt(username, ip)

@spec record_failed_attempt(String.t(), ip()) :: :ok

Records a failed authentication attempt, applying a lockout once the limit is reached.

Non-blocking: the consensus write runs in a background task (see the module doc), so the auth path is never coupled to ra latency. Returns :ok immediately.

record_successful_auth(username, ip)

@spec record_successful_auth(String.t(), ip()) :: :ok

Records a successful authentication, clearing failed attempts and any lockout for the username + IP.

Non-blocking (background task). Skips the write entirely on the common case (a clean login with no prior failures to reset). Returns :ok immediately.

start_link(opts)

@spec start_link(keyword()) :: GenServer.on_start()

Starts the lockout manager, which owns the periodic cleanup timer for the replicated store.

unlock_account(username, ip \\ :all)

@spec unlock_account(String.t(), ip() | :all) ::
  :ok | {:ok, non_neg_integer()} | {:error, term()}

Unlocks an account manually (administrative action).

ip is a specific IP or :all to unlock every IP for the user. Synchronous, returns :ok (single IP) or {:ok, cleared_count} (:all) once the change is committed, or {:error, reason} if the store is unreachable.