The native backend: users and sessions in the host's database, passwords checked by a chain of sources (local password hashes, LDAP directories), sign-in failures throttled.
config :macaw_auth, backend: Macaw.Auth.Native
config :macaw_auth, Macaw.Auth.Native,
repo: MyApp.Repo,
sources: [%{type: :password}, %{type: :ldap, name: "nas", ...}],
session_idle_days: 14and mix macaw_auth.gen.migration for the tables. The pieces:
Macaw.Auth.Native.Config: the configuration, parsed once, every fault named;Macaw.Auth.Native.Source: the chain (the first source that knows the user decides) and its two sources,PasswordandLdap;Macaw.Auth.Native.Provision: directory users' rows, made at their first sign-in and their roles re-derived at every one;Macaw.Auth.Native.Hasher: bcrypt or argon2, rehashing at sign-in;Macaw.Auth.Native.Throttle: backoff after repeated failures, per username and per client address;Macaw.Auth.Native.Sessions: hashed session tokens, idle expiry, steps;Macaw.Auth.Native.Totp: the second factor, TOTP and recovery codes, its secret sealed at rest (Macaw.Auth.Native.Seal);Macaw.Auth.Native.UserRecord,Macaw.Auth.Native.SessionRecordandMacaw.Auth.Native.RecoveryCodeRecord: themacaw_auth_users,macaw_auth_sessionsandmacaw_auth_recovery_codesschemas.
One refusal. Whatever a source's reason, the caller sees
:invalid_credentials, or :disabled for a disabled user who gave
the right password, or :throttled while a wait runs. The specific
reason, and the source, go to telemetry
([:macaw_auth, :native, :authenticate]) and to
mix macaw_auth.ldap.check.
The second factor. A user's second_factor is :enrolled once they
confirm a TOTP enrollment, else :required when policy
(totp_required) requires one, else :none; Macaw.Auth.pending_step/2
turns that into the step a session owes. The enrollment and
verification functions here put every code check behind the throttle:
a wrong code is a failure for the user's second-factor key and the
client's address, with the same backoff as a wrong password, so six
digits can't be guessed. The second factor's username key is its own
(a password success doesn't reset it, or each sign-in would buy a
guesser who has the password five more free guesses at the code); the
address key is the one sign-in uses. Configure it with:
config :macaw_auth, Macaw.Auth.Native,
totp_issuer: "My App",
totp_required: ["admin"], # or true, or false (the default)
sealing_key: {MyApp.Secrets, :sealing_key, []}
Summary
Functions
An administrator clears a user's second factor and revokes their sessions,
telling their open pages unless option announce: false
(Macaw.Auth.Native.Totp.admin_reset/2).
Checks %{username: _, password: _} through the source chain:
{:ok, %Macaw.Auth.User{}}, or {:error, :invalid_credentials | :disabled | :throttled}. Options: :remote_ip (a string or an address
tuple; the client's, for the throttle's address key), :now (a
DateTime, for the throttle's clock).
Makes the first user, a local one with role :role, only when no user
exists: {:ok, record} when it made one, :exists when any user is
there (local or a directory's, whoever it is), so it is safe to call at
every boot. Takes :username, :password and :role, and optionally
:display_name and :must_change_password.
Reads and checks the configuration, and keeps it
(Macaw.Auth.Native.Config.load!/0): :ok, or
Macaw.Auth.Native.Config.Error naming the fault. The application
calls it at start, so a bad configuration refuses boot.
Makes a local user (:username, :password, and optionally
:display_name, :roles, :must_change_password, :extra), the
password hashed with the configured hasher: {:ok, record} or
{:error, changeset}.
The user turns their second factor off with a current code or a recovery
code, behind the throttle: :ok, or {:error, :invalid_code | :throttled | :required}.
Disables a user: their sign-in is refused, and each of their sessions at
its next look-up (Macaw.Auth.Native.Sessions), so open pages leave at
their next re-check. A user already disabled keeps when it was first
done. {:ok, record} or {:error, :not_found}.
Enables a disabled user again: {:ok, record} or {:error, :not_found}.
Starts (or resumes) user's TOTP enrollment: {:ok, %{secret, uri}},
the otpauth:// URI for the page's QR code. Not active until
enroll_confirm/3. See Macaw.Auth.Native.Totp.enroll_begin/2.
Confirms the enrollment with a valid code, behind the throttle:
{:ok, recovery_codes} to show once, or {:error, :invalid_code | :throttled | :not_started | :already_enrolled}. A session that owed
:second_factor_enroll then completes it (Macaw.Auth.Plug.complete_step/2).
The user named username (trimmed and lower-cased, as sign-in matches
it), or nil, also for a string that can't be a username.
Every user, local and directory, by username.
How many unspent recovery codes the user has.
Replaces an enrolled user's recovery codes: {:ok, codes} or {:error, :not_enrolled}.
The idle limit, session_idle_days in seconds.
Replaces a local user's password, hashed with the configured hasher, and
revokes every session of theirs (reason :password_changed), so whoever
held the old one is signed out. {:ok, record}, or {:error, :not_found | :not_local | :no_password}: a directory user's password is the
directory's, and is never set here.
Replaces a local user's roles with roles, a list of non-empty strings:
{:ok, record}, or {:error, :not_found | :not_local | :invalid_roles}.
A directory user's roles are re-derived from the directory at each
sign-in (Macaw.Auth.Native.Provision), so they are never set here.
A users row as the contract's %Macaw.Auth.User{}, its second_factor
from the row and the configured policy.
Checks a second-factor code (TOTP, or a recovery code) for the user of
session session_id, behind the throttle; on success the session's
:second_factor step is completed. :ok, or {:error, :invalid_code | :throttled}, or the session's own refusal (:invalid | :expired | :disabled). Options: :remote_ip, :now.
Functions
@spec admin_reset(Macaw.Auth.User.t() | term(), keyword()) :: :ok
An administrator clears a user's second factor and revokes their sessions,
telling their open pages unless option announce: false
(Macaw.Auth.Native.Totp.admin_reset/2).
@spec authenticate(map(), keyword()) :: {:ok, Macaw.Auth.User.t()} | {:error, :invalid_credentials | :disabled | :throttled}
Checks %{username: _, password: _} through the source chain:
{:ok, %Macaw.Auth.User{}}, or {:error, :invalid_credentials | :disabled | :throttled}. Options: :remote_ip (a string or an address
tuple; the client's, for the throttle's address key), :now (a
DateTime, for the throttle's clock).
Throttled. While the username or the address waits
(Macaw.Auth.Native.Throttle), the password is not checked, but a
hash's time is still spent (the hasher's no_user_verify/0), so a
blocked attempt takes as long as a checked one and the response time
doesn't tell a guesser when the block starts or ends. A throttled
refusal is not counted; every checked refusal counts as a failure, and
a success resets the username's count.
Emits [:macaw_auth, :native, :authenticate] with %{count: 1} and
%{result: :ok | :error, reason: atom | nil, source: name | nil, user_id: id | nil}; never the username or password.
@spec bootstrap_admin(map()) :: {:ok, Macaw.Auth.Native.UserRecord.t()} | :exists | {:error, :no_password | :no_role | Ecto.Changeset.t()}
Makes the first user, a local one with role :role, only when no user
exists: {:ok, record} when it made one, :exists when any user is
there (local or a directory's, whoever it is), so it is safe to call at
every boot. Takes :username, :password and :role, and optionally
:display_name and :must_change_password.
With no user yet, the password and the role are required:
{:error, :no_password}, {:error, :no_role}, or {:error, changeset}
(an invalid username). When users exist neither is looked at, so a host
may stop providing the password once its admin exists.
Two first boots at once with the same username make one user: the other
call's insert meets the unique index and answers :exists.
@spec check_config!() :: :ok
Reads and checks the configuration, and keeps it
(Macaw.Auth.Native.Config.load!/0): :ok, or
Macaw.Auth.Native.Config.Error naming the fault. The application
calls it at start, so a bad configuration refuses boot.
@spec create_user(map()) :: {:ok, Macaw.Auth.Native.UserRecord.t()} | {:error, Ecto.Changeset.t()}
Makes a local user (:username, :password, and optionally
:display_name, :roles, :must_change_password, :extra), the
password hashed with the configured hasher: {:ok, record} or
{:error, changeset}.
@spec disable(Macaw.Auth.User.t(), term(), keyword()) :: :ok | {:error, :invalid_code | :throttled | :required}
The user turns their second factor off with a current code or a recovery
code, behind the throttle: :ok, or {:error, :invalid_code | :throttled | :required}.
@spec disable_user(String.t()) :: {:ok, Macaw.Auth.Native.UserRecord.t()} | {:error, :not_found}
Disables a user: their sign-in is refused, and each of their sessions at
its next look-up (Macaw.Auth.Native.Sessions), so open pages leave at
their next re-check. A user already disabled keeps when it was first
done. {:ok, record} or {:error, :not_found}.
@spec enable_user(String.t()) :: {:ok, Macaw.Auth.Native.UserRecord.t()} | {:error, :not_found}
Enables a disabled user again: {:ok, record} or {:error, :not_found}.
@spec enroll_begin(Macaw.Auth.User.t(), keyword()) :: {:ok, %{secret: binary(), uri: String.t()}} | {:error, :already_enrolled | :not_found}
Starts (or resumes) user's TOTP enrollment: {:ok, %{secret, uri}},
the otpauth:// URI for the page's QR code. Not active until
enroll_confirm/3. See Macaw.Auth.Native.Totp.enroll_begin/2.
@spec enroll_confirm(Macaw.Auth.User.t(), term(), keyword()) :: {:ok, [String.t()]} | {:error, :invalid_code | :throttled | :not_started | :already_enrolled}
Confirms the enrollment with a valid code, behind the throttle:
{:ok, recovery_codes} to show once, or {:error, :invalid_code | :throttled | :not_started | :already_enrolled}. A session that owed
:second_factor_enroll then completes it (Macaw.Auth.Plug.complete_step/2).
@spec get_user(term()) :: Macaw.Auth.Native.UserRecord.t() | nil
The user named username (trimmed and lower-cased, as sign-in matches
it), or nil, also for a string that can't be a username.
@spec list_users() :: [Macaw.Auth.Native.UserRecord.t()]
Every user, local and directory, by username.
@spec recovery_codes_left(Macaw.Auth.User.t() | term()) :: non_neg_integer()
How many unspent recovery codes the user has.
@spec regenerate_recovery_codes(Macaw.Auth.User.t(), keyword()) :: {:ok, [String.t()]} | {:error, :not_enrolled}
Replaces an enrolled user's recovery codes: {:ok, codes} or {:error, :not_enrolled}.
@spec session_idle_seconds(keyword()) :: pos_integer()
The idle limit, session_idle_days in seconds.
@spec set_password(String.t(), term(), keyword()) :: {:ok, Macaw.Auth.Native.UserRecord.t()} | {:error, :not_found | :not_local | :no_password}
Replaces a local user's password, hashed with the configured hasher, and
revokes every session of theirs (reason :password_changed), so whoever
held the old one is signed out. {:ok, record}, or {:error, :not_found | :not_local | :no_password}: a directory user's password is the
directory's, and is never set here.
Option :announce (default true): tell the user's open pages, which
disconnect (Macaw.Auth.announce_revoked/2). A process that is not the
app's node, such as a release's eval or a mix task, passes false:
there is no page there to tell, and the pages leave at their next
re-check.
@spec set_roles(String.t(), [String.t()]) :: {:ok, Macaw.Auth.Native.UserRecord.t()} | {:error, :not_found | :not_local | :invalid_roles}
Replaces a local user's roles with roles, a list of non-empty strings:
{:ok, record}, or {:error, :not_found | :not_local | :invalid_roles}.
A directory user's roles are re-derived from the directory at each
sign-in (Macaw.Auth.Native.Provision), so they are never set here.
@spec to_user(Macaw.Auth.Native.UserRecord.t()) :: Macaw.Auth.User.t()
A users row as the contract's %Macaw.Auth.User{}, its second_factor
from the row and the configured policy.
@spec verify_second_factor(term(), term(), keyword()) :: :ok | {:error, :invalid_code | :throttled | :invalid | :expired | :disabled}
Checks a second-factor code (TOTP, or a recovery code) for the user of
session session_id, behind the throttle; on success the session's
:second_factor step is completed. :ok, or {:error, :invalid_code | :throttled}, or the session's own refusal (:invalid | :expired | :disabled). Options: :remote_ip, :now.