Macaw.Auth.Native (macaw_auth v0.4.1)

Copy Markdown View Source

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: 14

and mix macaw_auth.gen.migration for the tables. The pieces:

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

admin_reset(user, opts \\ [])

@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).

authenticate(credentials, opts \\ [])

@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.

bootstrap_admin(attrs)

@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.

check_config!()

@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.

create_user(attrs)

@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}.

disable(user, code, opts \\ [])

@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}.

disable_user(username)

@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}.

enable_user(username)

@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}.

enroll_begin(user, opts \\ [])

@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.

enroll_confirm(user, code, opts \\ [])

@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).

get_user(username)

@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.

list_users()

@spec list_users() :: [Macaw.Auth.Native.UserRecord.t()]

Every user, local and directory, by username.

recovery_codes_left(user)

@spec recovery_codes_left(Macaw.Auth.User.t() | term()) :: non_neg_integer()

How many unspent recovery codes the user has.

regenerate_recovery_codes(user, opts \\ [])

@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}.

session_idle_seconds(opts)

@spec session_idle_seconds(keyword()) :: pos_integer()

The idle limit, session_idle_days in seconds.

set_password(username, password, opts \\ [])

@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.

set_roles(username, roles)

@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.

to_user(r)

A users row as the contract's %Macaw.Auth.User{}, its second_factor from the row and the configured policy.

verify_second_factor(session_id, code, opts \\ [])

@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.