defmodule ToweropsWeb.UserCredentialController do use ToweropsWeb, :controller alias Towerops.Accounts alias Towerops.Organizations @doc """ Generates a WebAuthn registration challenge. Returns challenge data for the browser to use during credential creation. """ def registration_challenge(conn, _params) do user = conn.assigns.current_scope.user if Accounts.passkey_registration_allowed?(user) do challenge_data = Accounts.generate_registration_challenge(user) conn |> put_session(:webauthn_challenge, challenge_data.challenge) |> json(challenge_data) else conn |> put_status(:forbidden) |> json(%{error: "Email confirmation required before registering passkeys"}) end end @doc """ Completes WebAuthn registration. Receives attestation from the browser and creates the credential. """ def register(conn, params) do user = conn.assigns.current_scope.user challenge = get_session(conn, :webauthn_challenge) case Accounts.register_credential(user, params, challenge) do {:ok, credential} -> conn |> delete_session(:webauthn_challenge) |> put_status(:created) |> json(%{ id: credential.id, name: credential.name, created_at: credential.inserted_at }) {:error, :email_not_confirmed} -> conn |> put_status(:forbidden) |> json(%{error: "Email confirmation required"}) {:error, _reason} -> conn |> put_status(:unprocessable_entity) |> json(%{error: "Failed to register passkey"}) end end @doc """ Generates a WebAuthn authentication challenge. Returns challenge data for the browser to use during authentication. Supports both discoverable credentials (no email) and account-specific (with email). """ def authentication_challenge(conn, params) do case params do %{"email" => email} when is_binary(email) -> # Account-specific authentication handle_email_authentication_challenge(conn, email) _ -> # Discoverable credential authentication (usernameless) handle_discoverable_authentication_challenge(conn) end end defp handle_email_authentication_challenge(conn, email) do case Accounts.get_user_by_email(email) do nil -> # Don't reveal if user exists conn |> put_status(:not_found) |> json(%{error: "No passkeys found for this email"}) user -> credentials = Accounts.list_user_credentials(user.id) if Enum.empty?(credentials) do conn |> put_status(:not_found) |> json(%{error: "No passkeys found for this email"}) else challenge_data = Accounts.generate_authentication_challenge(user) conn |> put_session(:webauthn_challenge, challenge_data.challenge) |> put_session(:webauthn_user_id, user.id) |> json(challenge_data) end end end defp handle_discoverable_authentication_challenge(conn) do # For discoverable credentials, we don't need to know the user ahead of time # The authenticator will present credentials for this RP ID challenge_data = Accounts.generate_discoverable_authentication_challenge() conn |> put_session(:webauthn_challenge, challenge_data.challenge) |> json(challenge_data) end @doc """ Completes WebAuthn authentication. Verifies the assertion and logs the user in. Supports both discoverable and account-specific authentication. """ def authenticate(conn, params) do challenge = get_session(conn, :webauthn_challenge) expected_user_id = get_session(conn, :webauthn_user_id) cond do is_nil(challenge) -> conn |> put_status(:unauthorized) |> json(%{error: "Authentication failed"}) # Discoverable credential (no expected user) is_nil(expected_user_id) -> handle_discoverable_authentication(conn, params, challenge) # Account-specific authentication true -> handle_account_authentication(conn, params, challenge, expected_user_id) end end defp handle_discoverable_authentication(conn, params, challenge) do case Accounts.authenticate_with_credential(params, challenge) do {:ok, user} -> conn |> delete_session(:webauthn_challenge) |> put_status(:ok) |> json(%{success: true, redirect: get_post_login_path(user)}) {:error, _reason} -> conn |> put_status(:unauthorized) |> json(%{error: "Authentication failed"}) end end defp handle_account_authentication(conn, params, challenge, expected_user_id) do with {:ok, user} <- Accounts.authenticate_with_credential(params, challenge), true <- user.id == expected_user_id do conn |> delete_session(:webauthn_challenge) |> delete_session(:webauthn_user_id) |> put_status(:ok) |> json(%{success: true, redirect: get_post_login_path(user)}) else false -> conn |> put_status(:unauthorized) |> json(%{error: "Authentication failed"}) {:error, _reason} -> conn |> put_status(:unauthorized) |> json(%{error: "Authentication failed"}) end end @doc """ Deletes a credential. Only allows deletion if the credential belongs to the current user. """ def delete(conn, %{"id" => credential_id}) do user = conn.assigns.current_scope.user case Accounts.delete_credential(credential_id, user.id) do {:ok, _credential} -> conn |> put_flash(:info, "Passkey deleted successfully.") |> redirect(to: ~p"/users/settings") {:error, :not_found} -> conn |> put_flash(:error, "Passkey not found.") |> redirect(to: ~p"/users/settings") {:error, _reason} -> conn |> put_flash(:error, "Failed to delete passkey.") |> redirect(to: ~p"/users/settings") end end defp get_post_login_path(user) do # Get the first organization or redirect to orgs list case Organizations.list_user_organizations(user.id) do [org | _] -> ~p"/orgs/#{org.slug}" [] -> ~p"/orgs" end end end