defmodule GitGudWeb.WebauthnController do
@moduledoc """
JSON endpoints that drive the browser WebAuthn ceremony.
* `POST /api/webauthn/register/start` — authed user; returns
`PublicKeyCredentialCreationOptions` JSON. The challenge is also
stashed in the session so the browser doesn't need to round-trip
sensitive state.
* `POST /api/webauthn/register/finish` — authed user; verifies the
attestation and stores the new credential.
* `POST /api/webauthn/auth/start` — during the 2FA login
challenge; expects `:two_factor_user_id` in session. Returns
`PublicKeyCredentialRequestOptions`.
* `POST /api/webauthn/auth/finish` — verifies the assertion.
On success, stashes a flag in the session so the challenge
LiveView can redirect to the session-finalizing endpoint.
"""
use GitGudWeb, :controller
alias GitGud.Accounts
alias GitGud.Accounts.Webauthn
# ── registration (authed user) ──────────────────────────────────────
def register_start(conn, _params) do
user = require_user(conn)
challenge = Webauthn.registration_challenge(user)
options = Webauthn.serialize_registration_options(user, challenge)
conn
|> put_session(:webauthn_register_challenge, serialize_challenge(challenge))
|> json(%{"publicKey" => options})
end
def register_finish(conn, params) do
user = require_user(conn)
challenge = load_session_challenge(conn, :webauthn_register_challenge)
case challenge do
nil ->
conn |> put_status(:bad_request) |> json(%{"error" => "no_challenge"})
%Wax.Challenge{} = ch ->
case Webauthn.register_credential(user, params, ch) do
{:ok, cred, recovery_codes} ->
conn
|> delete_session(:webauthn_register_challenge)
|> json(%{
"ok" => true,
"id" => cred.id,
"name" => cred.name,
# Only present on the first credential — these are the
# one-shot recovery codes, shown once. Caller renders.
"recoveryCodes" => recovery_codes
})
{:error, reason} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{"error" => inspect(reason)})
end
end
end
# ── authentication (mid-2FA) ────────────────────────────────────────
def auth_start(conn, _params) do
with %{} = user <- pending_two_factor_user(conn) do
challenge = Webauthn.authentication_challenge(user)
options = Webauthn.serialize_authentication_options(challenge)
conn
|> put_session(:webauthn_auth_challenge, serialize_challenge(challenge))
|> json(%{"publicKey" => options})
else
_ -> conn |> put_status(:unauthorized) |> json(%{"error" => "no_pending_2fa"})
end
end
def auth_finish(conn, params) do
with %{} = user <- pending_two_factor_user(conn),
%Wax.Challenge{} = challenge <-
load_session_challenge(conn, :webauthn_auth_challenge),
{:ok, _cred} <- Webauthn.verify_assertion(user, params, challenge) do
conn
|> delete_session(:webauthn_auth_challenge)
|> put_session(:webauthn_verified_user_id, user.id)
|> json(%{
"ok" => true,
"redirect" => ~p"/users/log-in/two-factor/complete?u=#{user.id}"
})
else
nil ->
conn |> put_status(:unauthorized) |> json(%{"error" => "no_pending_2fa"})
{:error, reason} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{"error" => inspect(reason)})
end
end
# ── helpers ────────────────────────────────────────────────────────
defp require_user(conn) do
case conn.assigns[:current_scope] && conn.assigns.current_scope.user do
nil -> raise Plug.Conn.WrapperError, conn: conn, reason: :unauthenticated
user -> user
end
end
defp pending_two_factor_user(conn) do
case get_session(conn, :two_factor_user_id) do
uid when is_integer(uid) -> Accounts.get_user!(uid)
_ -> nil
end
end
defp serialize_challenge(%Wax.Challenge{} = ch),
do: :erlang.term_to_binary(ch) |> Base.encode64()
defp load_session_challenge(conn, key) do
case get_session(conn, key) do
bin when is_binary(bin) and bin != "" ->
with {:ok, decoded} <- Base.decode64(bin) do
# Trust boundary — we serialized the struct ourselves and
# never echo this back to the user. binary_to_term/2 with
# :safe gives belt-and-braces protection.
:erlang.binary_to_term(decoded, [:safe])
else
_ -> nil
end
_ ->
nil
end
end
end
4.9 KiB · text
5af55d9