Skip to main content

README.md

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