prop/lib/microwaveprop_web/controllers/api/v1/auth_controller.ex
Graham McIntire c6d2c48264
feat: secure /api/v1 REST API for regular-user actions
Adds bearer-token authenticated REST API at /api/v1 covering every
action a non-admin user can perform on the website: contact + beacon
submission, beacon-monitor management, propagation queries, profile
read/update, and self-service API token issuance/revocation.

Security: SHA-256-hashed bearer tokens (mwp_ prefix, plaintext shown
once at creation), RFC 9457 problem+json error responses, RFC 9651
RateLimit-* headers backed by an ETS bucket (600/min per token,
60/min per anonymous IP, 30/min on /auth/tokens), private-contact
filtering by viewer.

Docs at docs/api/README.md (prose reference) and docs/api/openapi.yaml
(OpenAPI 3.1 spec covering every endpoint, response, and schema).

Tests: 124 new tests across schema, plug, error renderer, rate
limiter, fallback, and every controller. 16/17 API modules at 100%
line coverage; FallbackController at 87.5% (one defmodule line, an
Erlang-cover artifact for action_fallback-only modules).
2026-05-09 08:59:54 -05:00

68 lines
2.1 KiB
Elixir

defmodule MicrowavepropWeb.Api.V1.AuthController do
@moduledoc """
Email/password login that mints a long-lived `/api/v1` bearer token.
This is the only `/api/v1` endpoint that accepts a password — every
other endpoint authenticates with the bearer token returned here.
"""
use Phoenix.Controller, formats: [:json]
alias Microwaveprop.Accounts
alias MicrowavepropWeb.Api.ErrorJSON
alias MicrowavepropWeb.Api.V1.TokenJSON
plug :accepts, ["json"]
@doc """
POST /api/v1/auth/tokens
Body: `{"email": "...", "password": "...", "name": "device label",
"expires_at": "ISO8601" (optional)}`. Returns the plaintext token
and the persisted record.
"""
def create(conn, params) do
with {:ok, email} <- fetch_string(params, "email"),
{:ok, password} <- fetch_string(params, "password"),
{:ok, name} <- fetch_string(params, "name"),
%Microwaveprop.Accounts.User{} = user <-
Accounts.get_user_by_email_and_password(email, password) do
token_attrs = %{name: name, expires_at: parse_expiry(params["expires_at"])}
case Accounts.create_api_token(user, token_attrs) do
{:ok, {plaintext, record}} ->
conn
|> put_status(:created)
|> json(TokenJSON.show_with_plaintext(record, plaintext))
{:error, changeset} ->
ErrorJSON.send_changeset(conn, changeset)
end
else
nil ->
ErrorJSON.send_problem(conn, 401, "unauthorized", "Invalid email or password.")
{:error, field} ->
ErrorJSON.send_problem(conn, 400, "bad_request", "Missing or invalid `#{field}`.")
end
end
defp fetch_string(params, key) do
case Map.get(params, key) do
value when is_binary(value) and byte_size(value) > 0 -> {:ok, value}
_ -> {:error, key}
end
end
defp parse_expiry(nil), do: nil
defp parse_expiry(""), do: nil
defp parse_expiry(string) when is_binary(string) do
case DateTime.from_iso8601(string) do
{:ok, dt, _offset} -> dt
{:error, _} -> :invalid
end
end
defp parse_expiry(_), do: :invalid
end