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).
68 lines
2.1 KiB
Elixir
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
|