Malachi.Auth.LockoutServer (malachi v0.22.7)

View Source

A thin named facade over Malachi.Cluster.RaCluster for the cluster's account-lockout store (Malachi.Auth.LockoutMachine): start the dedicated Raft cluster, submit lockout commands through the log, and read the replicated lockout state. Mirrors Malachi.Auth.UserServer; ra must already be running. This module owns only the lockout cluster, not ra's lifecycle.

Writes (record_failed_attempt/record_successful_auth/unlock_user/unlock_key/cleanup) go through the log: replicated by consensus, so brute-force protection is cluster-wide (attempts spread across nodes still count against one limit) and survives a restart. record_failed_attempt returns the machine reply %{count, locked} so the caller can drive metrics/logging/audit for a newly applied lockout.

Reads (locked?/failed_attempts/list_locked) use :ra.local_query against the local replica: fast (no consensus round-trip) and adequate for the auth hot path, which checks the lock once per attempt. Expiry is evaluated against a caller-supplied now (the local node's clock), seconds of clock skew are irrelevant to minutes-long lockouts, and reads are eventually consistent (a just-written lockout propagates within replication lag), which is acceptable for brute-force defense.

Summary

Functions

Compacts expired lockouts and attempts older than attempt_ttl_ms. Machine reply is :ok.

Stops and deletes the lockout store's Raft cluster (removing its on-disk state).

The number of failed attempts recorded for key.

Every account currently locked at now, as info maps.

Whether key is locked at now (ms): :not_locked or {:locked, time_remaining_ms}.

Ensures this node participates in the lockout cluster (self-join), so a staggered boot converges to a fully-replicated lockout store. Idempotent and best-effort. Mirrors UserServer.reconcile/2.

Records a failed authentication attempt for key ({username, ip}), applying config (%{max_attempts, base_duration_ms, progressive}). Machine reply is %{count, locked} where locked is nil or %{duration_ms, locked_until} when this attempt (re)applied a lockout.

Clears failed attempts and any lockout for key after a successful login. Machine reply is :ok.

Starts the lockout store's Raft cluster named cluster_name across nodes (default the local node) and returns a server_id addressing a real member (the local node when it is one, else the first).

Unlocks a single {username, ip} key (admin action). Machine reply is :ok.

Unlocks every IP for username (admin action). Machine reply is {:ok, cleared_count}.

Types

cluster_name()

@type cluster_name() :: atom()

server_id()

@type server_id() :: {cluster_name(), node()}

Functions

cleanup(server_id, attempt_ttl_ms)

@spec cleanup(server_id(), non_neg_integer()) :: {:ok, :ok} | {:error, term()}

Compacts expired lockouts and attempts older than attempt_ttl_ms. Machine reply is :ok.

delete(cluster_name)

@spec delete(cluster_name()) :: :ok

Stops and deletes the lockout store's Raft cluster (removing its on-disk state).

failed_attempts(server_id, key)

@spec failed_attempts(server_id(), Malachi.Auth.LockoutRegistry.key()) ::
  {:ok, non_neg_integer()} | {:error, term()}

The number of failed attempts recorded for key.

list_locked(server_id, now)

@spec list_locked(server_id(), integer()) :: {:ok, [map()]} | {:error, term()}

Every account currently locked at now, as info maps.

locked?(server_id, key, now)

@spec locked?(server_id(), Malachi.Auth.LockoutRegistry.key(), integer()) ::
  {:ok, :not_locked | {:locked, non_neg_integer()}} | {:error, term()}

Whether key is locked at now (ms): :not_locked or {:locked, time_remaining_ms}.

reconcile(cluster_name, nodes)

@spec reconcile(cluster_name(), [node()]) :: :ok

Ensures this node participates in the lockout cluster (self-join), so a staggered boot converges to a fully-replicated lockout store. Idempotent and best-effort. Mirrors UserServer.reconcile/2.

record_failed_attempt(server_id, key, config)

@spec record_failed_attempt(
  server_id(),
  Malachi.Auth.LockoutRegistry.key(),
  Malachi.Auth.LockoutRegistry.config()
) :: {:ok, map()} | {:error, term()}

Records a failed authentication attempt for key ({username, ip}), applying config (%{max_attempts, base_duration_ms, progressive}). Machine reply is %{count, locked} where locked is nil or %{duration_ms, locked_until} when this attempt (re)applied a lockout.

record_successful_auth(server_id, key)

@spec record_successful_auth(server_id(), Malachi.Auth.LockoutRegistry.key()) ::
  {:ok, :ok} | {:error, term()}

Clears failed attempts and any lockout for key after a successful login. Machine reply is :ok.

start(cluster_name, nodes \\ [node()])

@spec start(cluster_name(), [node()]) :: {:ok, server_id()} | {:error, term()}

Starts the lockout store's Raft cluster named cluster_name across nodes (default the local node) and returns a server_id addressing a real member (the local node when it is one, else the first).

unlock_key(server_id, key)

@spec unlock_key(server_id(), Malachi.Auth.LockoutRegistry.key()) ::
  {:ok, :ok} | {:error, term()}

Unlocks a single {username, ip} key (admin action). Machine reply is :ok.

unlock_user(server_id, username)

@spec unlock_user(server_id(), String.t()) ::
  {:ok, {:ok, non_neg_integer()}} | {:error, term()}

Unlocks every IP for username (admin action). Machine reply is {:ok, cleared_count}.