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 <password>` 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