towerops/lib/towerops_web/controllers/user_credential_controller.ex
2026-01-17 15:00:52 -06:00

206 lines
6.1 KiB
Elixir

defmodule ToweropsWeb.UserCredentialController do
@moduledoc false
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