Malachi.Auth.LockoutRegistry (malachi v0.22.7)

View Source

The pure state of account lockouts: failed-auth counters and progressive lockouts, keyed by {username, ip}. It is the deterministic core replicated by Malachi.Auth.LockoutMachine over a dedicated ra cluster (like Malachi.Auth.UserRegistry), 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): what the old node-local ETS store could not do.

Progressive lockout after max_attempts failures, escalating on each further multiple: base → ×3 → ×9 → ×24 → ×72 (capped). Because apply/3 must be deterministic across replicas, it reads no config and no clock: the config (max_attempts, base_duration_ms, progressive) travels in the command (the caller reads it once) and the time comes from the ra leader's system_time (now).

Summary

Functions

Applies a command at time now (the ra leader's system_time, ms). Returns {new_state, reply}. Deterministic given now: never reads a clock or config itself.

Every command shape, mapped to the machine version that introduced it (see Malachi.Cluster.MachineVersion).

The number of failed attempts recorded for key (0 if none).

Every account currently locked at now, as info maps.

:not_locked, or {:locked, time_remaining_ms} when key is locked at now.

An empty registry.

Types

attempt()

@type attempt() :: %{count: pos_integer(), first_at: integer(), last_at: integer()}

command()

@type command() ::
  {:failed_attempt, key(), config()}
  | {:successful_auth, key()}
  | {:unlock_user, String.t()}
  | {:unlock_key, key()}
  | {:cleanup, attempt_ttl_ms :: non_neg_integer()}

config()

@type config() :: %{
  max_attempts: pos_integer(),
  base_duration_ms: pos_integer(),
  progressive: boolean()
}

ip()

@type ip() :: String.t()

key()

@type key() :: {String.t(), ip()}

lockout()

@type lockout() :: %{locked_until: integer(), count: pos_integer()}

t()

@type t() :: %Malachi.Auth.LockoutRegistry{
  attempts: %{required(key()) => attempt()},
  lockouts: %{required(key()) => lockout()}
}

Functions

apply(state, arg2, now)

@spec apply(t(), command(), integer()) :: {t(), term()}

Applies a command at time now (the ra leader's system_time, ms). Returns {new_state, reply}. Deterministic given now: never reads a clock or config itself.

command_versions()

Every command shape, mapped to the machine version that introduced it (see Malachi.Cluster.MachineVersion).

failed_attempts(lockout_registry, key)

@spec failed_attempts(t(), key()) :: non_neg_integer()

The number of failed attempts recorded for key (0 if none).

list_locked(lockout_registry, now)

@spec list_locked(t(), integer()) :: [map()]

Every account currently locked at now, as info maps.

locked?(lockout_registry, key, now)

@spec locked?(t(), key(), integer()) :: :not_locked | {:locked, non_neg_integer()}

:not_locked, or {:locked, time_remaining_ms} when key is locked at now.

new()

@spec new() :: t()

An empty registry.