defmodule BreakGlass do @moduledoc """ Public façade for the `break_glass_ex` emergency access library. This module is the primary entry point for all host-application controllers. Internal modules (`BreakGlass.RateLimiter`, `BreakGlass.OtpStore`, etc.) are considered private to the library. ## Purpose `break_glass_ex` provides an in-process emergency access subsystem for Elixir and Phoenix applications. When all normal admin accounts are locked out, a pre-configured break-glass credential can be used to regain access through a two-factor authentication flow (password → emailed OTP). ## Installation Add `break_glass_ex` to your `mix.exs` dependencies: {:break_glass_ex, "~> 0.1"} ## Configuration All configuration lives under `config :break_glass_ex` in your `runtime.exs`: config :break_glass_ex, email: System.fetch_env!("BREAK_GLASS_EMAIL"), password_hash: System.fetch_env!("BREAK_GLASS_PASSWORD_HASH"), user_provider: MyApp.BreakGlassUserProvider, allowed_ips: ~w[10.0.0.0/8 127.0.0.1], alert_emails: ["security@example.com"], mailer: MyApp.Mailer See the Configuration Reference in `README.md` for the full list of keys. ## Supervision Tree Setup Add `BreakGlass.Supervisor` to your application's supervision tree **before** your endpoint or router: children = [ # ... other children ... {BreakGlass.Supervisor, []}, MyAppWeb.Endpoint ] ## UserProvider Behaviour Implement `BreakGlass.UserProvider` in your host application to return your own user struct on successful break-glass authentication: defmodule MyApp.BreakGlassUserProvider do @behaviour BreakGlass.UserProvider def build_user(attrs) do %MyApp.User{ id: attrs.sentinel_id, email: attrs.email, break_glass: true } end end ## Security Considerations - **Physical IP only.** Always pass `conn.remote_ip` (formatted via `:inet.ntoa/1`) and never derive the IP from `x-forwarded-for` or any proxy header. - **`x-forwarded-for` WARNING.** The library has no way to enforce IP origin. It is a documented contract that the host controller MUST use the physical IP. - **Password hash storage.** Never store the plaintext break-glass password in source control. Use `mix break_glass.gen_hash ` to generate a hash and store it in an environment variable or secrets manager. - **Lockout defaults.** The default rate limit is 5 attempts with a 900-second (15 minute) lockout window. Configure `:max_attempts` and `:lockout_seconds` to suit your security policy. """ require Logger alias BreakGlass.{CIDR, Notifier, OtpStore, RateLimiter} @default_allowed_ips ["127.0.0.1", "::1"] # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- @doc """ Step 1 of the two-factor authentication flow: verify email, IP, and password. ## Arguments - `email` — the email address submitted by the operator - `password` — the plaintext password to verify against the configured bcrypt hash - `ip` — the physical source IP address (from `conn.remote_ip`, never from headers) ## Returns - `{:ok, :otp_required}` — credentials correct; OTP sent; host should redirect to OTP form - `{:error, :rate_limited}` — IP is currently locked out after too many failed attempts - `{:error, :ip_not_allowed}` — IP is not in the configured whitelist - `:error` — email or password did not match (intentionally ambiguous) All rejection paths call `Bcrypt.no_user_verify/0` to maintain constant-time response latency regardless of which check fails. """ @spec authenticate(email :: String.t(), password :: String.t(), ip :: String.t()) :: {:ok, :otp_required} | {:error, :ip_not_allowed} | {:error, :rate_limited} | :error def authenticate(email, password, ip) when is_binary(email) and is_binary(password) and is_binary(ip) do cond do locked_out?(ip) -> Bcrypt.no_user_verify() RateLimiter.record_failed_attempt(ip) {:error, :rate_limited} not ip_allowed?(ip) -> Bcrypt.no_user_verify() {:error, :ip_not_allowed} email != fetch_email() -> Bcrypt.no_user_verify() RateLimiter.record_failed_attempt(ip) :error not Bcrypt.verify_pass(password, fetch_password_hash()) -> RateLimiter.record_failed_attempt(ip) :error true -> otp = OtpStore.generate() Task.start(fn -> Notifier.send_otp(email, otp, ip) end) {:ok, :otp_required} end end def authenticate(_email, _password, _ip), do: :error @doc """ Step 2 of the two-factor authentication flow: verify the OTP code. ## Arguments - `code` — the 6-digit OTP string submitted by the operator - `ip` — the physical source IP address (must match the IP used in step 1) ## Returns - `{:ok, user}` — OTP verified; `user` is the term returned by the configured `UserProvider.build_user/1` callback - `{:error, :invalid_otp}` — code did not match, had expired, or no OTP was pending On success, the rate limit counter for `ip` is reset and a `Logger.warning` is emitted. On failure, a `Logger.warning` is emitted with the IP and failure reason. """ @spec verify_otp(code :: String.t(), ip :: String.t()) :: {:ok, user :: term()} | {:error, :invalid_otp} def verify_otp(code, ip) when is_binary(code) and is_binary(ip) do case OtpStore.verify(code) do :ok -> RateLimiter.reset_attempts(ip) Logger.warning("[BreakGlass] SUCCESSFUL LOGIN from #{ip}") Task.start(fn -> Notifier.alert(ip) end) user = fetch_user_provider().build_user(%{ email: fetch_email(), sentinel_id: sentinel_id(), authenticated_at: DateTime.utc_now(), break_glass: true }) {:ok, user} {:error, reason} -> RateLimiter.record_failed_attempt(ip) Logger.warning("[BreakGlass] Failed OTP from #{ip}: #{reason}") {:error, :invalid_otp} end end def verify_otp(_code, _ip), do: {:error, :invalid_otp} @doc """ Returns `true` if `ip` is in the configured IP whitelist. The whitelist is read from `config :break_glass_ex, :allowed_ips` and defaults to `["127.0.0.1", "::1"]` if the key is absent. Each entry may be an exact IP address or a CIDR range (e.g. `"10.0.0.0/8"`). Host-app controllers can call this function directly before invoking `authenticate/3` to short-circuit a request early and return a proper HTTP response. """ @spec ip_allowed?(ip :: String.t()) :: boolean() def ip_allowed?(ip) when is_binary(ip) do allowed_ips = Application.get_env(:break_glass_ex, :allowed_ips, @default_allowed_ips) CIDR.check(ip, allowed_ips) end def ip_allowed?(_ip), do: false @doc """ Returns `true` if `ip` is currently locked out due to too many failed attempts. Delegates to `BreakGlass.RateLimiter.locked_out?/1`. """ @spec locked_out?(ip :: String.t()) :: boolean() def locked_out?(ip), do: RateLimiter.locked_out?(ip) @doc """ Returns the number of remaining authentication attempts for `ip`. Delegates to `BreakGlass.RateLimiter.remaining_attempts/1`. """ @spec remaining_attempts(ip :: String.t()) :: non_neg_integer() def remaining_attempts(ip), do: RateLimiter.remaining_attempts(ip) @doc """ Returns the configured sentinel ID (default: `0`). The sentinel ID is a value that can never match a real database primary key, used as the break-glass user's identifier to prevent accidental DB writes. """ @spec sentinel_id() :: non_neg_integer() def sentinel_id do Application.get_env(:break_glass_ex, :sentinel_id, 0) end @doc """ Returns a list containing the configured break-glass email address. Returns `[email]` where `email` is read from `config :break_glass_ex, :email`. """ @spec emails() :: [String.t()] def emails do [fetch_email()] end @doc """ Returns `true` if the given user term has `break_glass: true`. Handles both plain maps with atom keys and structs that implement the `break_glass` field. iex> BreakGlass.active?(%{break_glass: true}) true iex> BreakGlass.active?(%{break_glass: false}) false iex> BreakGlass.active?(%{}) false """ @spec active?(user :: term()) :: boolean() def active?(%{break_glass: true}), do: true def active?(_), do: false # --------------------------------------------------------------------------- # Private helpers # --------------------------------------------------------------------------- defp fetch_email do Application.get_env(:break_glass_ex, :email) end defp fetch_password_hash do Application.get_env(:break_glass_ex, :password_hash) end defp fetch_user_provider do Application.get_env(:break_glass_ex, :user_provider, BreakGlass.DefaultUserProvider) end end