Skip to main content

lib/break_glass/break_glass.ex

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