204 lines
6.1 KiB
Elixir
204 lines
6.1 KiB
Elixir
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
|