modlue# BreakGlassEx
A standalone, production-ready Elixir library that provides an in-process emergency access subsystem (**break-glass authentication**) for any Elixir or Phoenix application. 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), with mandatory brute-force rate limiting, IP whitelisting, tamper-resistant audit logging, and out-of-band alerting.
The library is fully self-contained — it ships its own OTP supervision tree and has **no Phoenix dependency**.
---
## Compatibility
| Elixir | OTP 26 | OTP 27 |
|--------|--------|--------|
| 1.15 | ✓ | — |
| 1.16 | ✓ | ✓ |
| 1.17 | ✓ | ✓ |
| 1.18+ | ✓ | ✓ |
**Notes:**
- This library has **no Phoenix dependency**. It works in any Elixir/OTP application.
- `import Bitwise` was deprecated in Elixir 1.16. The library uses `use Bitwise` throughout for clean compilation across all supported versions.
---
## Quick Start
### 1. Add to `mix.exs`
```elixir
def deps do
[
{:breakglass, "~> 0.1"}
]
end
```
### 2. Implement `UserProvider`
Create a module in your host application that implements the `BreakGlass.UserProvider` behaviour:
```elixir
defmodule MyApp.BreakGlassUserProvider do
@behaviour BreakGlass.UserProvider
@impl true
def build_user(attrs) do
# attrs contains: :email, :sentinel_id, :authenticated_at, :break_glass (true)
%MyApp.User{
id: attrs.sentinel_id,
email: attrs.email,
break_glass: true
}
end
end
```
### 3. Configure `config/runtime.exs`
```elixir
config :breakglass,
# Required
email: System.fetch_env!("BREAK_GLASS_EMAIL"),
password_hash: System.fetch_env!("BREAK_GLASS_PASSWORD_HASH"),
user_provider: MyApp.BreakGlassUserProvider,
# IP allowlist (exact IPs or CIDR ranges)
allowed_ips: ~w[10.0.0.0/8 192.168.1.0/24 127.0.0.1],
# Rate limiting
max_attempts: 5,
lockout_seconds: 900,
# Email alerting (uses your existing Swoosh mailer)
mailer: MyApp.Mailer,
from_email: "noreply@example.com",
alert_emails: ["security@example.com", "ops@example.com"],
# Webhook alerting
alert_webhook_url: System.get_env("BREAK_GLASS_WEBHOOK_URL"),
# Development: log OTP at warning level (never use in production)
dev_otp_log: false
```
Generate the bcrypt hash for your password using the provided Mix task:
```bash
mix break_glass.gen_hash "my_secure_password"
# => $2b$12$...
```
Store the output in the `BREAK_GLASS_PASSWORD_HASH` environment variable or your secrets manager. **Never store the plaintext password in source control.**
### 4. Add `BreakGlass.Supervisor` to your supervision tree
In your `Application.start/2` callback, add the supervisor **before** your endpoint or router:
```elixir
children = [
MyApp.Repo,
{BreakGlass.Supervisor, []},
MyAppWeb.Endpoint
]
Supervisor.start_link(children, strategy: :one_for_one, name: MyApp.Supervisor)
```
### 5. Controller integration
Typical controller flow:
```elixir
defmodule MyAppWeb.BreakGlassController do
use MyAppWeb, :controller
# Step 1: authenticate (password check)
def authenticate(conn, %{"email" => email, "password" => password}) do
ip = conn.remote_ip |> :inet.ntoa() |> to_string()
case BreakGlass.authenticate(email, password, ip) do
{:ok, :otp_required} ->
redirect(conn, to: ~p"/break-glass/otp")
{:error, :rate_limited} ->
conn
|> put_flash(:error, "Too many attempts. Try again later.")
|> redirect(to: ~p"/break-glass")
{:error, :ip_not_allowed} ->
conn
|> put_flash(:error, "Access denied.")
|> redirect(to: ~p"/break-glass")
:error ->
conn
|> put_flash(:error, "Invalid credentials.")
|> redirect(to: ~p"/break-glass")
end
end
# Step 2: verify OTP
def verify_otp(conn, %{"code" => code}) do
ip = conn.remote_ip |> :inet.ntoa() |> to_string()
case BreakGlass.verify_otp(code, ip) do
{:ok, user} ->
# Log the user in using your existing session mechanism
conn
|> MyApp.UserAuth.log_in_user(user)
|> redirect(to: ~p"/admin/dashboard")
{:error, :invalid_otp} ->
conn
|> put_flash(:error, "Invalid or expired OTP.")
|> redirect(to: ~p"/break-glass/otp")
end
end
end
```
> **Always use `conn.remote_ip`** — never derive the IP from `x-forwarded-for` or any proxy header. See [Security Notes](#security-notes) below.
---
## Configuration Reference
All keys live under `config :breakglass`.
| Key | Type | Required | Default | Description |
|-----|------|----------|---------|-------------|
| `:email` | `String.t()` | Yes | — | Break-glass email address |
| `:password_hash` | `String.t()` | Yes | — | Bcrypt hash of the break-glass password |
| `:user_provider` | `module()` | Yes | — | Module implementing `BreakGlass.UserProvider` |
| `:sentinel_id` | `integer()` | No | `0` | ID used for the synthetic user struct; should be a value that cannot match a real database primary key |
| `:allowed_ips` | `[String.t()]` | No | `["127.0.0.1", "::1"]` | Exact IPs or CIDR ranges allowed to authenticate; if absent a `Logger.warning` is emitted at startup |
| `:max_attempts` | `pos_integer()` | No | `5` | Failed attempts before lockout |
| `:lockout_seconds` | `pos_integer()` | No | `900` | Lockout window duration in seconds (15 minutes) |
| `:mailer` | `module()` | No | — | Swoosh mailer module (required for email OTP delivery and alert emails) |
| `:from_email` | `String.t()` | No | — | Sender address for outbound emails |
| `:alert_emails` | `[String.t()]` | No | `[]` | Recipients for break-glass alert emails sent on every successful login |
| `:alert_webhook_url` | `String.t() \| nil` | No | `nil` | URL to POST a JSON alert payload on every successful login |
| `:dev_otp_log` | `boolean()` | No | `false` | Log OTP at `Logger.warning` level in development (never enable in production) |
---
## Security Notes
### Physical IP requirement
Always pass `conn.remote_ip` (the TCP-level transport IP) to `BreakGlass.authenticate/3` and `BreakGlass.verify_otp/2`:
```elixir
ip = conn.remote_ip |> :inet.ntoa() |> to_string()
```
### `x-forwarded-for` WARNING
**Never** derive the IP address from the `x-forwarded-for`, `x-real-ip`, or any other proxy header. These headers are trivially spoofable by any client and completely defeat the IP whitelist. The library cannot enforce this — it is a documented contract with the host controller.
### Password hash storage
1. Generate a bcrypt hash: `mix break_glass.gen_hash "your_password"`
2. Store the printed hash in an environment variable or secrets manager (e.g. AWS Secrets Manager, Vault)
3. Reference it in `runtime.exs` via `System.fetch_env!/1`
4. **Never** commit the plaintext password to source control
### Lockout defaults rationale
The defaults of 5 attempts / 900-second lockout are intentionally conservative:
- **5 attempts**: enough to accommodate genuine typos while blocking automated credential stuffing
- **900 seconds (15 minutes)**: long enough to add significant friction to any attack while short enough to unblock a legitimate operator
Adjust `:max_attempts` and `:lockout_seconds` in your configuration to match your security policy.
---
## Changelog
## 0.1.0
- Initial release
- Two-factor authentication flow: password → emailed OTP
- Per-IP rate limiting with configurable thresholds and lockout window
- CIDR-aware IP whitelist supporting IPv4 and IPv6
- In-memory ETS-backed OTP store (single-use, 600-second TTL)
- In-memory ETS-backed token store (invalidated on node restart)
- Out-of-band alerting via Swoosh email and/or HTTP webhook
- `BreakGlass.UserProvider` behaviour for host-app integration
- `BreakGlass.DefaultUserProvider` for zero-config test use
- Structured `Logger.warning` on every successful break-glass login
- `mix break_glass.gen_hash` Mix task for safe credential generation
- Full ExDoc documentation