From 4f924ba9a755bc3bd78c4276e6dd7876e88c8f9f Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Fri, 9 Jan 2026 12:26:32 -0600 Subject: [PATCH] add passkey --- assets/js/app.js | 6 +- assets/js/passkey_discoverable.js | 111 ++++++ assets/js/passkey_login.js | 133 +++++++ assets/js/passkey_settings.js | 170 +++++++++ assets/js/webauthn.js | 200 ++++++++++ assets/js/webauthn_utils.js | 33 ++ config/config.exs | 5 + lib/towerops/accounts.ex | 212 +++++++++++ lib/towerops/accounts/user.ex | 1 + lib/towerops/accounts/user_credential.ex | 65 ++++ lib/towerops/accounts/webauthn.ex | 341 ++++++++++++++++++ .../controllers/user_credential_controller.ex | 204 +++++++++++ .../user_session_html/new.html.heex | 31 +- .../controllers/user_settings_controller.ex | 2 + .../user_settings_html/edit.html.heex | 150 ++++++++ lib/towerops_web/router.ex | 19 +- mix.exs | 1 + mix.lock | 1 + ...20260108160055_create_user_credentials.exs | 31 ++ test/support/fixtures/accounts_fixtures.ex | 19 + .../user_credential_controller_test.exs | 95 +++++ 21 files changed, 1823 insertions(+), 7 deletions(-) create mode 100644 assets/js/passkey_discoverable.js create mode 100644 assets/js/passkey_login.js create mode 100644 assets/js/passkey_settings.js create mode 100644 assets/js/webauthn.js create mode 100644 assets/js/webauthn_utils.js create mode 100644 lib/towerops/accounts/user_credential.ex create mode 100644 lib/towerops/accounts/webauthn.ex create mode 100644 lib/towerops_web/controllers/user_credential_controller.ex create mode 100644 priv/repo/migrations/20260108160055_create_user_credentials.exs create mode 100644 test/towerops_web/controllers/user_credential_controller_test.exs diff --git a/assets/js/app.js b/assets/js/app.js index 1b44f83e..579f6b4c 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -25,6 +25,10 @@ import {LiveSocket} from "phoenix_live_view" import {hooks as colocatedHooks} from "phoenix-colocated/towerops" import topbar from "../vendor/topbar" import Chart from "../vendor/chart" +import {WebAuthnRegister, WebAuthnLogin} from "./webauthn" +import "./passkey_settings" +import "./passkey_login" +import "./passkey_discoverable" // Generic Sensor Chart Hook (for CPU, Memory, Storage, etc.) const SensorChart = { @@ -260,7 +264,7 @@ const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute const liveSocket = new LiveSocket("/live", Socket, { longPollFallbackMs: 2500, params: {_csrf_token: csrfToken}, - hooks: {...colocatedHooks, SensorChart}, + hooks: {...colocatedHooks, SensorChart, WebAuthnRegister, WebAuthnLogin}, }) // Show progress bar on live navigation and form submits diff --git a/assets/js/passkey_discoverable.js b/assets/js/passkey_discoverable.js new file mode 100644 index 00000000..0fa889a5 --- /dev/null +++ b/assets/js/passkey_discoverable.js @@ -0,0 +1,111 @@ +// Discoverable passkey authentication (usernameless login) +import { bufferToBase64url, base64urlToBuffer, getCsrfToken } from './webauthn_utils' + +document.addEventListener('DOMContentLoaded', () => { + const discoverableBtn = document.getElementById('passkey-discoverable-btn') + const errorDiv = document.getElementById('passkey-discoverable-error') + + if (!discoverableBtn || !errorDiv) return + + const showError = (message) => { + errorDiv.classList.remove('hidden') + errorDiv.querySelector('p').textContent = message + } + + const hideError = () => { + errorDiv.classList.add('hidden') + errorDiv.querySelector('p').textContent = '' + } + + const authenticateWithDiscoverablePasskey = async () => { + try { + hideError() + discoverableBtn.disabled = true + discoverableBtn.textContent = 'Authenticating...' + + // Fetch challenge from server (no email required) + const challengeResponse = await fetch('/api/webauthn/authentication/challenge', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': getCsrfToken() + } + }) + + if (!challengeResponse.ok) { + const error = await challengeResponse.json() + showError(error.error || 'Failed to get authentication challenge') + return + } + + const challengeData = await challengeResponse.json() + + // Convert challenge to ArrayBuffer + const challenge = base64urlToBuffer(challengeData.challenge) + + const publicKeyCredentialRequestOptions = { + challenge, + rpId: challengeData.rpId, + timeout: challengeData.timeout, + userVerification: challengeData.userVerification + // No allowCredentials - let the authenticator show all credentials for this RP ID + } + + // Get assertion from authenticator + const assertion = await navigator.credentials.get({ + publicKey: publicKeyCredentialRequestOptions + }) + + const authData = { + assertion: { + id: assertion.id, + rawId: bufferToBase64url(assertion.rawId), + type: assertion.type, + response: { + clientDataJSON: bufferToBase64url(assertion.response.clientDataJSON), + authenticatorData: bufferToBase64url(assertion.response.authenticatorData), + signature: bufferToBase64url(assertion.response.signature), + userHandle: assertion.response.userHandle ? + bufferToBase64url(assertion.response.userHandle) : null + } + } + } + + // Send to server + const authResponse = await fetch('/api/webauthn/authenticate', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': getCsrfToken() + }, + body: JSON.stringify(authData) + }) + + if (authResponse.ok) { + const result = await authResponse.json() + // Redirect to the provided URL + window.location.href = result.redirect + } else { + showError('Authentication failed') + } + } catch (error) { + console.error('Discoverable authentication error:', error) + + let errorMessage = 'Failed to authenticate' + if (error.name === 'NotAllowedError') { + errorMessage = 'Authentication was cancelled or timed out' + } else if (error.name === 'NotSupportedError') { + errorMessage = 'Your browser or authenticator does not support passkeys' + } else if (error.message) { + errorMessage = error.message + } + + showError(errorMessage) + } finally { + discoverableBtn.disabled = false + discoverableBtn.innerHTML = ' Log in with passkey' + } + } + + discoverableBtn.addEventListener('click', authenticateWithDiscoverablePasskey) +}) diff --git a/assets/js/passkey_login.js b/assets/js/passkey_login.js new file mode 100644 index 00000000..54c53507 --- /dev/null +++ b/assets/js/passkey_login.js @@ -0,0 +1,133 @@ +// Passkey authentication for login page +import { bufferToBase64url, base64urlToBuffer, getCsrfToken } from './webauthn_utils' + +document.addEventListener('DOMContentLoaded', () => { + const passkeyLoginBtn = document.getElementById('passkey-login-btn') + const emailInput = document.querySelector('#passkey_login_form input[type="email"]') + const errorDiv = document.getElementById('passkey-login-error') + + if (!passkeyLoginBtn || !emailInput || !errorDiv) return + + const showError = (message) => { + errorDiv.classList.remove('hidden') + errorDiv.querySelector('p').textContent = message + } + + const hideError = () => { + errorDiv.classList.add('hidden') + errorDiv.querySelector('p').textContent = '' + } + + const authenticateWithPasskey = async () => { + const email = emailInput.value.trim() + + if (!email) { + showError('Please enter your email address') + emailInput.focus() + return + } + + try { + hideError() + passkeyLoginBtn.disabled = true + passkeyLoginBtn.textContent = 'Authenticating...' + + // Fetch challenge from server + const challengeResponse = await fetch('/api/webauthn/authentication/challenge', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': getCsrfToken() + }, + body: JSON.stringify({ email }) + }) + + if (!challengeResponse.ok) { + const error = await challengeResponse.json() + showError(error.error || 'No passkeys found for this email') + return + } + + const challengeData = await challengeResponse.json() + + // Convert challenge to ArrayBuffer + const challenge = base64urlToBuffer(challengeData.challenge) + + const allowCredentials = challengeData.allowCredentials.map(cred => ({ + type: cred.type, + id: base64urlToBuffer(cred.id), + transports: cred.transports + })) + + const publicKeyCredentialRequestOptions = { + challenge, + rpId: challengeData.rpId, + allowCredentials, + timeout: challengeData.timeout, + userVerification: challengeData.userVerification + } + + // Get assertion from authenticator + const assertion = await navigator.credentials.get({ + publicKey: publicKeyCredentialRequestOptions + }) + + const authData = { + assertion: { + id: assertion.id, + rawId: bufferToBase64url(assertion.rawId), + type: assertion.type, + response: { + clientDataJSON: bufferToBase64url(assertion.response.clientDataJSON), + authenticatorData: bufferToBase64url(assertion.response.authenticatorData), + signature: bufferToBase64url(assertion.response.signature), + userHandle: assertion.response.userHandle ? + bufferToBase64url(assertion.response.userHandle) : null + } + } + } + + // Send to server + const authResponse = await fetch('/api/webauthn/authenticate', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': getCsrfToken() + }, + body: JSON.stringify(authData) + }) + + if (authResponse.ok) { + const result = await authResponse.json() + // Redirect to the provided URL + window.location.href = result.redirect + } else { + showError('Authentication failed') + } + } catch (error) { + console.error('Authentication error:', error) + + let errorMessage = 'Failed to authenticate' + if (error.name === 'NotAllowedError') { + errorMessage = 'Authentication was cancelled or timed out' + } else if (error.message) { + errorMessage = error.message + } + + showError(errorMessage) + } finally { + passkeyLoginBtn.disabled = false + passkeyLoginBtn.innerHTML = ' Log in with passkey' + } + } + + passkeyLoginBtn.addEventListener('click', authenticateWithPasskey) + + // Allow pressing Enter in email field to trigger passkey login + emailInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + e.preventDefault() + authenticateWithPasskey() + } + }) +}) diff --git a/assets/js/passkey_settings.js b/assets/js/passkey_settings.js new file mode 100644 index 00000000..65107893 --- /dev/null +++ b/assets/js/passkey_settings.js @@ -0,0 +1,170 @@ +// Passkey management for settings page +import { bufferToBase64url, base64urlToBuffer, getCsrfToken } from './webauthn_utils' + +document.addEventListener('DOMContentLoaded', () => { + const addPasskeyBtn = document.getElementById('add-passkey-btn') + const modal = document.getElementById('passkey-modal') + const nameInput = document.getElementById('passkey-name-input') + const confirmBtn = document.getElementById('confirm-add-passkey') + const cancelBtn = document.getElementById('cancel-add-passkey') + const errorDiv = document.getElementById('passkey-error') + + if (!addPasskeyBtn || !modal) return + + // Show modal when clicking "Add Passkey" + addPasskeyBtn.addEventListener('click', () => { + modal.classList.remove('hidden') + nameInput.value = '' + nameInput.focus() + hideError() + }) + + // Hide modal when clicking cancel or background + const hideModal = () => { + modal.classList.add('hidden') + nameInput.value = '' + hideError() + } + + cancelBtn.addEventListener('click', hideModal) + modal.addEventListener('click', (e) => { + if (e.target === modal || e.target.classList.contains('bg-zinc-500')) { + hideModal() + } + }) + + // Handle escape key + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && !modal.classList.contains('hidden')) { + hideModal() + } + }) + + // Show error in modal + const showError = (message) => { + errorDiv.classList.remove('hidden') + errorDiv.querySelector('p').textContent = message + } + + const hideError = () => { + errorDiv.classList.add('hidden') + errorDiv.querySelector('p').textContent = '' + } + + // Handle passkey registration + const registerPasskey = async () => { + const name = nameInput.value.trim() + + if (!name) { + showError('Please enter a name for this passkey') + nameInput.focus() + return + } + + try { + confirmBtn.disabled = true + confirmBtn.textContent = 'Registering...' + + // Fetch challenge from server + const challengeResponse = await fetch('/api/webauthn/registration/challenge', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': getCsrfToken() + } + }) + + if (!challengeResponse.ok) { + const error = await challengeResponse.json() + showError(error.error || 'Failed to get registration challenge') + return + } + + const challengeData = await challengeResponse.json() + + // Convert challenge and user ID to ArrayBuffer + const challenge = base64urlToBuffer(challengeData.challenge) + const userId = base64urlToBuffer(challengeData.user.id) + + const publicKeyCredentialCreationOptions = { + challenge, + rp: challengeData.rp, + user: { + id: userId, + name: challengeData.user.name, + displayName: challengeData.user.displayName + }, + pubKeyCredParams: challengeData.pubKeyCredParams, + authenticatorSelection: challengeData.authenticatorSelection, + timeout: challengeData.timeout, + attestation: challengeData.attestation + } + + // Hide modal before showing authenticator prompt + modal.classList.add('hidden') + + // Create credential + const credential = await navigator.credentials.create({ + publicKey: publicKeyCredentialCreationOptions + }) + + const registrationData = { + name: name, + attestation: { + id: credential.id, + rawId: bufferToBase64url(credential.rawId), + type: credential.type, + response: { + clientDataJSON: bufferToBase64url(credential.response.clientDataJSON), + attestationObject: bufferToBase64url(credential.response.attestationObject) + } + } + } + + // Send to server + const registerResponse = await fetch('/api/webauthn/register', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': getCsrfToken() + }, + body: JSON.stringify(registrationData) + }) + + if (registerResponse.ok) { + // Reload page to show new passkey + window.location.reload() + } else { + const error = await registerResponse.json() + modal.classList.remove('hidden') + showError(error.error || 'Failed to register passkey') + } + } catch (error) { + console.error('Registration error:', error) + + let errorMessage = 'Failed to register passkey' + if (error.name === 'NotAllowedError') { + errorMessage = 'Registration was cancelled or timed out' + } else if (error.message) { + errorMessage = error.message + } + + modal.classList.remove('hidden') + showError(errorMessage) + } finally { + confirmBtn.disabled = false + confirmBtn.textContent = 'Continue' + } + } + + // Register on button click + confirmBtn.addEventListener('click', registerPasskey) + + // Register on Enter key + nameInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + e.preventDefault() + registerPasskey() + } + }) +}) diff --git a/assets/js/webauthn.js b/assets/js/webauthn.js new file mode 100644 index 00000000..1d560cb3 --- /dev/null +++ b/assets/js/webauthn.js @@ -0,0 +1,200 @@ +// WebAuthn utility functions for passkey authentication +import { base64urlToBuffer, bufferToBase64url, getCsrfToken } from './webauthn_utils' + +const WebAuthn = { + // Registration flow + async register(challengeData, credentialName) { + // Convert challenge and user ID to ArrayBuffer + const challenge = base64urlToBuffer(challengeData.challenge); + const userId = base64urlToBuffer(challengeData.user.id); + + const publicKeyCredentialCreationOptions = { + challenge, + rp: challengeData.rp, + user: { + id: userId, + name: challengeData.user.name, + displayName: challengeData.user.displayName + }, + pubKeyCredParams: challengeData.pubKeyCredParams, + authenticatorSelection: challengeData.authenticatorSelection, + timeout: challengeData.timeout, + attestation: challengeData.attestation + }; + + try { + const credential = await navigator.credentials.create({ + publicKey: publicKeyCredentialCreationOptions + }); + + return { + name: credentialName, + attestation: { + id: credential.id, + rawId: bufferToBase64url(credential.rawId), + type: credential.type, + response: { + clientDataJSON: bufferToBase64url(credential.response.clientDataJSON), + attestationObject: bufferToBase64url(credential.response.attestationObject) + } + } + }; + } catch (error) { + console.error('WebAuthn registration error:', error); + throw error; + } + }, + + // Authentication flow + async authenticate(challengeData) { + const challenge = base64urlToBuffer(challengeData.challenge); + + const allowCredentials = challengeData.allowCredentials.map(cred => ({ + type: cred.type, + id: base64urlToBuffer(cred.id), + transports: cred.transports + })); + + const publicKeyCredentialRequestOptions = { + challenge, + rpId: challengeData.rpId, + allowCredentials, + timeout: challengeData.timeout, + userVerification: challengeData.userVerification + }; + + try { + const assertion = await navigator.credentials.get({ + publicKey: publicKeyCredentialRequestOptions + }); + + return { + assertion: { + id: assertion.id, + rawId: bufferToBase64url(assertion.rawId), + type: assertion.type, + response: { + clientDataJSON: bufferToBase64url(assertion.response.clientDataJSON), + authenticatorData: bufferToBase64url(assertion.response.authenticatorData), + signature: bufferToBase64url(assertion.response.signature), + userHandle: assertion.response.userHandle ? + bufferToBase64url(assertion.response.userHandle) : null + } + } + }; + } catch (error) { + console.error('WebAuthn authentication error:', error); + throw error; + } + } +}; + +// LiveView Hook for Registration (in credential management page) +export const WebAuthnRegister = { + mounted() { + this.handleEvent("start_registration", async ({ name }) => { + try { + // Fetch challenge from server + const response = await fetch('/api/webauthn/registration/challenge', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': getCsrfToken() + } + }); + + if (!response.ok) { + const error = await response.json(); + this.pushEvent("registration_failed", { error: error.error || 'Failed to get registration challenge' }); + return; + } + + const challengeData = await response.json(); + + // Perform WebAuthn registration + const registrationData = await WebAuthn.register(challengeData, name); + + // Send to server + const registerResponse = await fetch('/api/webauthn/register', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': getCsrfToken() + }, + body: JSON.stringify(registrationData) + }); + + if (registerResponse.ok) { + this.pushEvent("registration_success", {}); + } else { + const error = await registerResponse.json(); + this.pushEvent("registration_failed", { error: error.error || 'Failed to register passkey' }); + } + } catch (error) { + console.error('Registration error:', error); + this.pushEvent("registration_failed", { + error: error.name === 'NotAllowedError' + ? 'Registration was cancelled or timed out' + : error.message || 'Failed to register passkey' + }); + } + }); + } +}; + +// LiveView Hook for Authentication (on login page) +export const WebAuthnLogin = { + mounted() { + this.handleEvent("start_authentication", async ({ email }) => { + try { + // Fetch challenge from server + const response = await fetch('/api/webauthn/authentication/challenge', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': getCsrfToken() + }, + body: JSON.stringify({ email }) + }); + + if (!response.ok) { + const error = await response.json(); + this.pushEvent("authentication_failed", { + error: error.error || 'No passkeys found for this email' + }); + return; + } + + const challengeData = await response.json(); + + // Perform WebAuthn authentication + const authData = await WebAuthn.authenticate(challengeData); + + // Send to server + const authResponse = await fetch('/api/webauthn/authenticate', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': getCsrfToken() + }, + body: JSON.stringify(authData) + }); + + if (authResponse.ok) { + const result = await authResponse.json(); + // Redirect to the provided URL + window.location.href = result.redirect; + } else { + this.pushEvent("authentication_failed", { error: 'Authentication failed' }); + } + } catch (error) { + console.error('Authentication error:', error); + this.pushEvent("authentication_failed", { + error: error.name === 'NotAllowedError' + ? 'Authentication was cancelled or timed out' + : error.message || 'Failed to authenticate' + }); + } + }); + } +}; diff --git a/assets/js/webauthn_utils.js b/assets/js/webauthn_utils.js new file mode 100644 index 00000000..fac19e13 --- /dev/null +++ b/assets/js/webauthn_utils.js @@ -0,0 +1,33 @@ +// WebAuthn utility functions + +// Convert Base64URL string to ArrayBuffer +export function base64urlToBuffer(base64url) { + const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/') + const padLen = (4 - (base64.length % 4)) % 4 + const padded = base64 + '='.repeat(padLen) + const binary = atob(padded) + const buffer = new ArrayBuffer(binary.length) + const bytes = new Uint8Array(buffer) + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i) + } + return buffer +} + +// Convert ArrayBuffer to Base64URL string +export function bufferToBase64url(buffer) { + const bytes = new Uint8Array(buffer) + let binary = '' + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]) + } + return btoa(binary) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, '') +} + +// Get CSRF token from meta tag +export function getCsrfToken() { + return document.querySelector("meta[name='csrf-token']").getAttribute('content') +} diff --git a/config/config.exs b/config/config.exs index b75354ff..862bc8d9 100644 --- a/config/config.exs +++ b/config/config.exs @@ -78,4 +78,9 @@ config :towerops, ecto_repos: [Towerops.Repo], generators: [timestamp_type: :utc_datetime, binary_id: true] +# WebAuthn / Passkey Configuration +config :towerops, + webauthn_rp_id: "localhost", + webauthn_origin: "http://localhost:4000" + import_config "#{config_env()}.exs" diff --git a/lib/towerops/accounts.ex b/lib/towerops/accounts.ex index 50116ac3..0ebd4b41 100644 --- a/lib/towerops/accounts.ex +++ b/lib/towerops/accounts.ex @@ -6,8 +6,10 @@ defmodule Towerops.Accounts do import Ecto.Query, warn: false alias Towerops.Accounts.User + alias Towerops.Accounts.UserCredential alias Towerops.Accounts.UserNotifier alias Towerops.Accounts.UserToken + alias Towerops.Accounts.WebAuthn alias Towerops.Repo ## Database getters @@ -348,4 +350,214 @@ defmodule Towerops.Accounts do end end) end + + ## WebAuthn / Passkey Authentication + + @doc """ + Checks if a user is allowed to register passkeys. + Requires the user's email to be confirmed. + """ + def passkey_registration_allowed?(%User{confirmed_at: nil}), do: false + def passkey_registration_allowed?(%User{}), do: true + + @doc """ + Lists all credentials for a user. + """ + def list_user_credentials(user_id) do + Repo.all(from c in UserCredential, where: c.user_id == ^user_id, order_by: [desc: c.last_used_at]) + end + + @doc """ + Gets a credential by its credential_id (the WebAuthn credential identifier). + """ + def get_credential_by_credential_id(credential_id) do + Repo.get_by(UserCredential, credential_id: credential_id) + end + + @doc """ + Generates a WebAuthn registration challenge for a user. + + Returns challenge data that should be sent to the client and stored + temporarily in the session. + """ + def generate_registration_challenge(user) do + challenge = :crypto.strong_rand_bytes(32) + challenge_base64 = Base.url_encode64(challenge, padding: false) + + %{ + challenge: challenge_base64, + user: %{ + id: Base.url_encode64(user.id, padding: false), + name: user.email, + displayName: user.email + }, + rp: %{ + name: "Towerops", + id: get_rp_id() + }, + timeout: 60_000, + attestation: "none", + pubKeyCredParams: [ + %{type: "public-key", alg: -7}, + %{type: "public-key", alg: -257} + ], + authenticatorSelection: %{ + authenticatorAttachment: "platform", + requireResidentKey: false, + residentKey: "preferred", + userVerification: "preferred" + } + } + end + + @doc """ + Generates a WebAuthn authentication challenge for a user. + + Returns challenge data with allowed credentials that should be sent + to the client. + """ + def generate_authentication_challenge(user) do + challenge = :crypto.strong_rand_bytes(32) + challenge_base64 = Base.url_encode64(challenge, padding: false) + + credentials = list_user_credentials(user.id) + + %{ + challenge: challenge_base64, + timeout: 60_000, + rpId: get_rp_id(), + allowCredentials: + Enum.map(credentials, fn cred -> + %{ + type: "public-key", + id: Base.url_encode64(cred.credential_id, padding: false), + transports: cred.transports + } + end), + userVerification: "preferred" + } + end + + @doc """ + Generates a WebAuthn authentication challenge for discoverable credentials. + + This allows usernameless authentication where the authenticator presents + available credentials for the RP ID. + """ + def generate_discoverable_authentication_challenge do + challenge = :crypto.strong_rand_bytes(32) + challenge_base64 = Base.url_encode64(challenge, padding: false) + + %{ + challenge: challenge_base64, + timeout: 60_000, + rpId: get_rp_id(), + # Empty allowCredentials allows any credential for this RP ID + allowCredentials: [], + userVerification: "preferred" + } + end + + @doc """ + Registers a new WebAuthn credential for a user. + + Verifies the attestation response and creates the credential record. + """ + def register_credential(user, params, challenge) do + origin = get_origin() + rp_id = get_rp_id() + + with {:ok, _} <- verify_passkey_registration_allowed(user), + {:ok, credential_data} <- + WebAuthn.verify_attestation( + params["attestation"]["response"], + challenge, + origin, + rp_id + ) do + create_credential(user, credential_data, params["name"]) + end + end + + defp verify_passkey_registration_allowed(user) do + if passkey_registration_allowed?(user) do + {:ok, :allowed} + else + {:error, :email_not_confirmed} + end + end + + defp create_credential(user, credential_data, name) do + %UserCredential{} + |> UserCredential.changeset(%{ + user_id: user.id, + credential_id: credential_data.credential_id, + public_key: credential_data.public_key, + sign_count: credential_data.sign_count, + name: name || "Passkey", + aaguid: credential_data.aaguid, + attestation_format: credential_data.attestation_format + }) + |> Repo.insert() + end + + @doc """ + Authenticates a user using a WebAuthn credential. + + Verifies the assertion response and returns the user if valid. + """ + def authenticate_with_credential(params, challenge) do + origin = get_origin() + rp_id = get_rp_id() + + with {:ok, credential_id_base64} <- Map.fetch(params["assertion"], "rawId"), + {:ok, credential_id} <- Base.url_decode64(credential_id_base64, padding: false), + %UserCredential{} = credential <- get_credential_by_credential_id(credential_id), + {:ok, new_sign_count} <- + WebAuthn.verify_assertion( + params["assertion"]["response"], + challenge, + origin, + rp_id, + credential.public_key, + credential.sign_count + ), + {:ok, _credential} <- update_credential_usage(credential, new_sign_count) do + user = Repo.get!(User, credential.user_id) + {:ok, user} + else + :error -> {:error, :invalid_credential} + nil -> {:error, :invalid_credential} + {:error, reason} -> {:error, reason} + end + end + + defp update_credential_usage(credential, new_sign_count) do + credential + |> Ecto.Changeset.change(%{ + last_used_at: DateTime.utc_now(:second), + sign_count: new_sign_count + }) + |> Repo.update() + end + + @doc """ + Deletes a credential. + + Only allows deletion if the credential belongs to the specified user. + """ + def delete_credential(credential_id, user_id) do + case Repo.get_by(UserCredential, id: credential_id, user_id: user_id) do + nil -> {:error, :not_found} + credential -> Repo.delete(credential) + end + end + + defp get_origin do + Application.get_env(:towerops, :webauthn_origin, "http://localhost:4000") + end + + defp get_rp_id do + Application.get_env(:towerops, :webauthn_rp_id, "localhost") + end end diff --git a/lib/towerops/accounts/user.ex b/lib/towerops/accounts/user.ex index ed628a39..27c3d431 100644 --- a/lib/towerops/accounts/user.ex +++ b/lib/towerops/accounts/user.ex @@ -16,6 +16,7 @@ defmodule Towerops.Accounts.User do has_many :memberships, Towerops.Organizations.Membership has_many :organizations, through: [:memberships, :organization] + has_many :credentials, Towerops.Accounts.UserCredential timestamps(type: :utc_datetime) end diff --git a/lib/towerops/accounts/user_credential.ex b/lib/towerops/accounts/user_credential.ex new file mode 100644 index 00000000..aba5ee68 --- /dev/null +++ b/lib/towerops/accounts/user_credential.ex @@ -0,0 +1,65 @@ +defmodule Towerops.Accounts.UserCredential do + @moduledoc """ + Represents a WebAuthn/passkey credential for a user. + + Each credential corresponds to a specific authenticator (device) that + the user has registered for passwordless authentication. + """ + use Ecto.Schema + + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "user_credentials" do + field :credential_id, :binary + field :public_key, :binary + field :sign_count, :integer, default: 0 + field :name, :string + field :last_used_at, :utc_datetime + field :aaguid, :binary + field :transports, {:array, :string}, default: [] + field :backup_eligible, :boolean, default: false + field :backup_state, :boolean, default: false + field :attestation_format, :string + + belongs_to :user, Towerops.Accounts.User + + timestamps(type: :utc_datetime) + end + + @doc """ + Changeset for creating or updating a credential. + """ + def changeset(credential, attrs) do + credential + |> cast(attrs, [ + :user_id, + :credential_id, + :public_key, + :sign_count, + :name, + :last_used_at, + :aaguid, + :transports, + :backup_eligible, + :backup_state, + :attestation_format + ]) + |> validate_required([:user_id, :credential_id, :public_key, :name]) + |> validate_length(:name, min: 1, max: 100) + |> unique_constraint(:credential_id) + |> foreign_key_constraint(:user_id) + end + + @doc """ + Changeset for updating credential usage tracking. + """ + def touch_changeset(credential) do + change(credential, %{ + last_used_at: DateTime.utc_now(:second), + sign_count: credential.sign_count + 1 + }) + end +end diff --git a/lib/towerops/accounts/webauthn.ex b/lib/towerops/accounts/webauthn.ex new file mode 100644 index 00000000..2264199e --- /dev/null +++ b/lib/towerops/accounts/webauthn.ex @@ -0,0 +1,341 @@ +defmodule Towerops.Accounts.WebAuthn do + @moduledoc """ + WebAuthn verification for passkey authentication. + + This module handles registration (attestation) and authentication (assertion) + verification for WebAuthn/FIDO2 credentials using CBOR decoding and + standard Erlang crypto libraries. + """ + + import Bitwise + + require Logger + + @doc """ + Verifies a WebAuthn registration (attestation) response. + + Returns `{:ok, credential_data}` with the parsed credential information, + or `{:error, reason}` if verification fails. + """ + def verify_attestation(attestation, challenge, origin, rp_id) do + with {:ok, client_data} <- decode_client_data(attestation["clientDataJSON"]), + :ok <- verify_client_data_type(client_data, "webauthn.create"), + :ok <- verify_challenge(client_data, challenge), + :ok <- verify_origin(client_data, origin), + {:ok, attestation_object} <- decode_attestation_object(attestation["attestationObject"]), + {:ok, auth_data} <- parse_authenticator_data(attestation_object["authData"]), + :ok <- verify_rp_id_hash(auth_data.rp_id_hash, rp_id), + :ok <- verify_user_present(auth_data.flags), + {:ok, credential_data} <- extract_credential_data(auth_data) do + {:ok, + %{ + credential_id: credential_data.credential_id, + public_key: credential_data.public_key, + aaguid: auth_data.aaguid, + sign_count: auth_data.sign_count, + attestation_format: attestation_object["fmt"] + }} + else + {:error, reason} = error -> + Logger.error("WebAuthn attestation verification failed: #{inspect(reason)}") + error + end + end + + @doc """ + Verifies a WebAuthn authentication (assertion) response. + + Returns `{:ok, sign_count}` if verification succeeds, + or `{:error, reason}` if verification fails. + """ + def verify_assertion(assertion, challenge, origin, rp_id, public_key, stored_sign_count) do + with {:ok, client_data} <- decode_client_data(assertion["clientDataJSON"]), + :ok <- verify_client_data_type(client_data, "webauthn.get"), + :ok <- verify_challenge(client_data, challenge), + :ok <- verify_origin(client_data, origin), + {:ok, auth_data_bytes} <- decode_base64url(assertion["authenticatorData"]), + {:ok, auth_data} <- parse_authenticator_data(auth_data_bytes), + :ok <- verify_rp_id_hash(auth_data.rp_id_hash, rp_id), + :ok <- verify_user_present(auth_data.flags), + :ok <- verify_sign_count(auth_data.sign_count, stored_sign_count), + {:ok, signature} <- decode_base64url(assertion["signature"]), + {:ok, client_data_json} <- decode_base64url(assertion["clientDataJSON"]), + :ok <- verify_signature(auth_data_bytes, client_data_json, signature, public_key) do + {:ok, auth_data.sign_count} + else + {:error, reason} = error -> + Logger.error("WebAuthn assertion verification failed: #{inspect(reason)}") + error + end + end + + # Private functions + + defp decode_client_data(base64_client_data) do + with {:ok, json_bytes} <- decode_base64url(base64_client_data), + {:ok, client_data} <- Jason.decode(json_bytes) do + {:ok, client_data} + else + {:error, reason} = error -> + Logger.error("Failed to decode client data: #{inspect(reason)}") + error + + error -> + Logger.error("Failed to decode client data JSON: #{inspect(error)}") + {:error, :invalid_client_data} + end + end + + defp decode_attestation_object(base64_attestation) do + with {:ok, cbor_bytes} <- decode_base64url(base64_attestation), + {:ok, attestation_object, _} <- CBOR.decode(cbor_bytes) do + {:ok, attestation_object} + else + _ -> {:error, :invalid_attestation_object} + end + end + + defp decode_base64url(base64_string) do + decoded = Base.url_decode64!(base64_string, padding: false) + {:ok, decoded} + rescue + _ -> {:error, :invalid_base64} + end + + defp verify_client_data_type(client_data, expected_type) do + received_type = client_data["type"] + + if received_type == expected_type do + :ok + else + Logger.error("Client data type mismatch. Expected: #{inspect(expected_type)}, Received: #{inspect(received_type)}") + + {:error, :invalid_client_data_type} + end + end + + defp verify_challenge(client_data, expected_challenge) do + received_challenge = client_data["challenge"] + + if received_challenge == expected_challenge do + :ok + else + Logger.error( + "Challenge mismatch. Expected: #{inspect(expected_challenge)}, Received: #{inspect(received_challenge)}" + ) + + {:error, :challenge_mismatch} + end + end + + defp verify_origin(client_data, expected_origin) do + received_origin = client_data["origin"] + + if received_origin == expected_origin do + :ok + else + Logger.error("Origin mismatch. Expected: #{inspect(expected_origin)}, Received: #{inspect(received_origin)}") + + {:error, :origin_mismatch} + end + end + + defp verify_rp_id_hash(rp_id_hash, rp_id) do + expected_hash = :crypto.hash(:sha256, rp_id) + + if rp_id_hash == expected_hash do + :ok + else + {:error, :rp_id_mismatch} + end + end + + defp verify_user_present(flags) do + # Bit 0: User Present (UP) + user_present = (flags &&& 0x01) != 0 + + if user_present do + :ok + else + {:error, :user_not_present} + end + end + + defp verify_sign_count(new_count, stored_count) do + cond do + new_count == 0 and stored_count == 0 -> + # Both zero is acceptable (authenticator doesn't support counters) + :ok + + new_count > stored_count -> + # Counter increased as expected + :ok + + true -> + # Counter decreased or didn't increase - possible cloned authenticator + {:error, :invalid_sign_count} + end + end + + defp verify_signature(auth_data_bytes, client_data_json, signature, public_key) do + # Construct the signed data: authenticator data || SHA-256(client data JSON) + client_data_hash = :crypto.hash(:sha256, client_data_json) + signed_data = auth_data_bytes <> client_data_hash + + # Parse the public key and verify signature + case parse_public_key(public_key) do + {:ok, parsed_key} -> + if :public_key.verify(signed_data, :sha256, signature, parsed_key) do + :ok + else + {:error, :signature_verification_failed} + end + + {:error, _} = error -> + error + end + end + + # Handle CBOR.Tag wrapped authenticator data + defp parse_authenticator_data(%CBOR.Tag{tag: :bytes, value: binary_data}) do + parse_authenticator_data(binary_data) + end + + defp parse_authenticator_data(auth_data) when is_binary(auth_data) do + # Authenticator data structure: + # - 32 bytes: RP ID hash + # - 1 byte: Flags + # - 4 bytes: Sign count (big-endian) + # - Variable: Attested credential data (if present) + # - Variable: Extensions (if present) + + case auth_data do + <> -> + # Check if attested credential data is present (bit 6 of flags) + attested_credential_present = (flags &&& 0x40) != 0 + + if attested_credential_present do + parse_attested_credential_data(rest, rp_id_hash, flags, sign_count) + else + {:ok, + %{ + rp_id_hash: rp_id_hash, + flags: flags, + sign_count: sign_count, + aaguid: nil, + credential_data: nil + }} + end + + _ -> + {:error, :invalid_authenticator_data} + end + end + + defp parse_attested_credential_data(data, rp_id_hash, flags, sign_count) do + # Attested credential data structure: + # - 16 bytes: AAGUID + # - 2 bytes: Credential ID length (big-endian) + # - Variable: Credential ID + # - Variable: Credential public key (CBOR-encoded COSE_Key) + + with <> <- data, + true <- byte_size(rest) >= cred_id_length, + <> <- rest, + {:ok, cose_key, remaining} <- CBOR.decode(public_key_bytes), + # Calculate the actual length of the COSE key CBOR data + public_key_cbor_length = byte_size(public_key_bytes) - byte_size(remaining), + <> <- + public_key_bytes do + {:ok, + %{ + rp_id_hash: rp_id_hash, + flags: flags, + sign_count: sign_count, + aaguid: aaguid, + credential_data: %{ + credential_id: credential_id, + public_key: public_key_cbor, + cose_key: cose_key + } + }} + else + false -> {:error, :invalid_credential_id_length} + {:error, _} -> {:error, :invalid_public_key} + _ -> {:error, :invalid_attested_credential_data} + end + end + + defp extract_credential_data(%{credential_data: nil}) do + {:error, :no_credential_data} + end + + defp extract_credential_data(%{credential_data: credential_data}) do + # The public_key is already CBOR-encoded, ready for storage + {:ok, + %{ + credential_id: credential_data.credential_id, + public_key: credential_data.public_key + }} + end + + defp parse_public_key(public_key_binary) do + # Decode the stored COSE key + case CBOR.decode(public_key_binary) do + {:ok, cose_key, _} -> + cose_key_to_erlang_key(cose_key) + + _ -> + {:error, :invalid_stored_public_key} + end + end + + defp cose_key_to_erlang_key(cose_key) do + # COSE key format (CBOR map): + # 1 (kty): Key type (2 = EC2, 3 = RSA) + # 3 (alg): Algorithm (-7 = ES256, -257 = RS256) + # -1 (crv): EC2 curve (1 = P-256) + # -2 (x): EC2 x-coordinate + # -3 (y): EC2 y-coordinate + # For RSA: -1 (n): modulus, -2 (e): exponent + + kty = Map.get(cose_key, 1) + alg = Map.get(cose_key, 3) + + case {kty, alg} do + # EC2 with ES256 (P-256) + {2, -7} -> + x = Map.get(cose_key, -2) + y = Map.get(cose_key, -3) + + if x && y do + # Convert to Erlang public key format + # EC point in uncompressed format: 0x04 || x || y + point = <<0x04, x::binary, y::binary>> + + ec_params = {:namedCurve, {1, 2, 840, 10_045, 3, 1, 7}} + {:ok, {:ECPoint, point, ec_params}} + else + {:error, :invalid_ec_key} + end + + # RSA with RS256 + {3, -257} -> + n = Map.get(cose_key, -1) + e = Map.get(cose_key, -2) + + if n && e do + # Convert binary to integer + n_int = :binary.decode_unsigned(n) + e_int = :binary.decode_unsigned(e) + + {:ok, {:RSAPublicKey, n_int, e_int}} + else + {:error, :invalid_rsa_key} + end + + _ -> + {:error, :unsupported_key_type} + end + end +end diff --git a/lib/towerops_web/controllers/user_credential_controller.ex b/lib/towerops_web/controllers/user_credential_controller.ex new file mode 100644 index 00000000..dbbdeb5b --- /dev/null +++ b/lib/towerops_web/controllers/user_credential_controller.ex @@ -0,0 +1,204 @@ +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 diff --git a/lib/towerops_web/controllers/user_session_html/new.html.heex b/lib/towerops_web/controllers/user_session_html/new.html.heex index d3d4b4b0..b30c1afd 100644 --- a/lib/towerops_web/controllers/user_session_html/new.html.heex +++ b/lib/towerops_web/controllers/user_session_html/new.html.heex @@ -41,6 +41,34 @@
+ +
+ + +
+ +
+
+
+
+
+ + or use email + +
+
+ <.form :let={f} for={@form} as={:user} id="login_form_magic" action={~p"/users/log-in"}> <.input readonly={!!@current_scope} @@ -49,7 +77,6 @@ label="Email" autocomplete="email" required - phx-mounted={JS.focus()} /> <.button class="w-full" variant="primary"> Log in with email @@ -62,7 +89,7 @@
- or + or use password
diff --git a/lib/towerops_web/controllers/user_settings_controller.ex b/lib/towerops_web/controllers/user_settings_controller.ex index 206fe706..af43b41d 100644 --- a/lib/towerops_web/controllers/user_settings_controller.ex +++ b/lib/towerops_web/controllers/user_settings_controller.ex @@ -74,5 +74,7 @@ defmodule ToweropsWeb.UserSettingsController do conn |> assign(:email_changeset, Accounts.change_user_email(user)) |> assign(:password_changeset, Accounts.change_user_password(user)) + |> assign(:credentials, Accounts.list_user_credentials(user.id)) + |> assign(:can_register_passkey, Accounts.passkey_registration_allowed?(user)) end end diff --git a/lib/towerops_web/controllers/user_settings_html/edit.html.heex b/lib/towerops_web/controllers/user_settings_html/edit.html.heex index e1543096..a21d1d11 100644 --- a/lib/towerops_web/controllers/user_settings_html/edit.html.heex +++ b/lib/towerops_web/controllers/user_settings_html/edit.html.heex @@ -37,4 +37,154 @@ Save Password + +
+ +
+

+ Passkeys +

+

+ Use your device's biometrics (Face ID, Touch ID, Windows Hello) or security keys to sign in +

+ + <%= if !@can_register_passkey do %> +
+

+ Please confirm your email address before registering a passkey. Check your inbox for the confirmation link. +

+
+ <% end %> + +
+ <%= if Enum.empty?(@credentials) do %> +
+ <.icon name="hero-key" class="mx-auto h-12 w-12 text-zinc-400" /> +

+ No passkeys registered +

+

+ Add a passkey to enable quick, secure sign-in with biometrics +

+
+ <% else %> +
+ <%= for credential <- @credentials do %> +
+
+ <.icon name="hero-key" class="h-5 w-5 text-zinc-400" /> +
+

+ {credential.name} +

+ <%= if credential.last_used_at do %> +

+ Last used {Calendar.strftime(credential.last_used_at, "%B %d, %Y")} +

+ <% else %> +

Never used

+ <% end %> +
+
+ <.link + href={~p"/users/credentials/#{credential.id}"} + method="delete" + data-confirm="Are you sure you want to delete this passkey?" + class="text-sm font-semibold text-red-600 hover:text-red-500 dark:text-red-400 dark:hover:text-red-300" + > + Delete + +
+ <% end %> +
+ <% end %> + + <%= if @can_register_passkey do %> + + + + + <% end %> +
+
diff --git a/lib/towerops_web/router.ex b/lib/towerops_web/router.ex index 7c74ae41..422429b1 100644 --- a/lib/towerops_web/router.ex +++ b/lib/towerops_web/router.ex @@ -29,10 +29,20 @@ defmodule ToweropsWeb.Router do get "/", PageController, :home end - # Other scopes may use custom stacks. - # scope "/api", ToweropsWeb do - # pipe_through :api - # end + # WebAuthn API routes + scope "/api/webauthn", ToweropsWeb do + pipe_through [:browser] + + post "/authentication/challenge", UserCredentialController, :authentication_challenge + post "/authenticate", UserCredentialController, :authenticate + end + + scope "/api/webauthn", ToweropsWeb do + pipe_through [:browser, :require_authenticated_user] + + post "/registration/challenge", UserCredentialController, :registration_challenge + post "/register", UserCredentialController, :register + end # Enable LiveDashboard in production with authentication scope "/dashboard" do @@ -65,6 +75,7 @@ defmodule ToweropsWeb.Router do get "/users/settings", UserSettingsController, :edit put "/users/settings", UserSettingsController, :update get "/users/settings/confirm-email/:token", UserSettingsController, :confirm_email + delete "/users/credentials/:id", UserCredentialController, :delete end scope "/", ToweropsWeb do diff --git a/mix.exs b/mix.exs index b0ecd07c..63d6c3d2 100644 --- a/mix.exs +++ b/mix.exs @@ -56,6 +56,7 @@ defmodule Towerops.MixProject do {:heroicons, github: "tailwindlabs/heroicons", tag: "v2.2.0", sparse: "optimized", app: false, compile: false, depth: 1}, {:swoosh, "~> 1.16"}, + {:cbor, "~> 1.0"}, {:req, "~> 0.5"}, {:snmpkit, "~> 1.3"}, {:yaml_elixir, "~> 2.9"}, diff --git a/mix.lock b/mix.lock index 1791f65e..dd7369f1 100644 --- a/mix.lock +++ b/mix.lock @@ -2,6 +2,7 @@ "bandit": {:hex, :bandit, "1.10.1", "6b1f8609d947ae2a74da5bba8aee938c94348634e54e5625eef622ca0bbbb062", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "4b4c35f273030e44268ace53bf3d5991dfc385c77374244e2f960876547671aa"}, "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, + "cbor": {:hex, :cbor, "1.0.1", "39511158e8ea5a57c1fcb9639aaa7efde67129678fee49ebbda780f6f24959b0", [:mix], [], "hexpm", "5431acbe7a7908f17f6a9cd43311002836a34a8ab01876918d8cfb709cd8b6a2"}, "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, "comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"}, "credo": {:hex, :credo, "1.7.15", "283da72eeb2fd3ccf7248f4941a0527efb97afa224bcdef30b4b580bc8258e1c", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "291e8645ea3fea7481829f1e1eb0881b8395db212821338e577a90bf225c5607"}, diff --git a/priv/repo/migrations/20260108160055_create_user_credentials.exs b/priv/repo/migrations/20260108160055_create_user_credentials.exs new file mode 100644 index 00000000..4a1a792e --- /dev/null +++ b/priv/repo/migrations/20260108160055_create_user_credentials.exs @@ -0,0 +1,31 @@ +defmodule Towerops.Repo.Migrations.CreateUserCredentials do + use Ecto.Migration + + def change do + create table(:user_credentials, primary_key: false) do + add :id, :binary_id, primary_key: true + add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false + + # WebAuthn credential data + add :credential_id, :binary, null: false + add :public_key, :binary, null: false + add :sign_count, :bigint, default: 0, null: false + + # Metadata for user-friendly display + add :name, :string, null: false + add :last_used_at, :utc_datetime + + # Authenticator metadata + add :aaguid, :binary + add :transports, {:array, :string}, default: [] + add :backup_eligible, :boolean, default: false + add :backup_state, :boolean, default: false + add :attestation_format, :string + + timestamps(type: :utc_datetime) + end + + create index(:user_credentials, [:user_id]) + create unique_index(:user_credentials, [:credential_id]) + end +end diff --git a/test/support/fixtures/accounts_fixtures.ex b/test/support/fixtures/accounts_fixtures.ex index 20da980f..3db66836 100644 --- a/test/support/fixtures/accounts_fixtures.ex +++ b/test/support/fixtures/accounts_fixtures.ex @@ -86,4 +86,23 @@ defmodule Towerops.AccountsFixtures do set: [inserted_at: dt, authenticated_at: dt] ) end + + @doc """ + Creates a test credential for a user. + This is a helper for testing - in production, credentials are created via WebAuthn. + """ + def credential_fixture(user, attrs \\ %{}) do + default_attrs = %{ + name: "Test Credential #{System.unique_integer()}", + credential_id: :crypto.strong_rand_bytes(32), + public_key: :crypto.strong_rand_bytes(64), + sign_count: 0 + } + + attrs = Enum.into(attrs, default_attrs) + + %Accounts.UserCredential{} + |> Accounts.UserCredential.changeset(Map.put(attrs, :user_id, user.id)) + |> Towerops.Repo.insert!() + end end diff --git a/test/towerops_web/controllers/user_credential_controller_test.exs b/test/towerops_web/controllers/user_credential_controller_test.exs new file mode 100644 index 00000000..52665bf8 --- /dev/null +++ b/test/towerops_web/controllers/user_credential_controller_test.exs @@ -0,0 +1,95 @@ +defmodule ToweropsWeb.UserCredentialControllerTest do + use ToweropsWeb.ConnCase, async: true + + import Towerops.AccountsFixtures + + alias Towerops.Accounts + + describe "POST /api/webauthn/registration/challenge" do + setup :register_and_log_in_user + + test "returns challenge for authenticated user with confirmed email", %{conn: conn, user: user} do + conn = post(conn, ~p"/api/webauthn/registration/challenge") + assert %{"challenge" => challenge, "user" => user_data} = json_response(conn, 200) + assert is_binary(challenge) + assert user_data["name"] == user.email + end + + test "returns error for user without confirmed email" do + # Create unconfirmed user + user = unconfirmed_user_fixture() + conn = log_in_user(build_conn(), user) + + conn = post(conn, ~p"/api/webauthn/registration/challenge") + assert %{"error" => error} = json_response(conn, 403) + assert error =~ "Email confirmation required" + end + + test "requires authentication" do + conn = build_conn() + conn = post(conn, ~p"/api/webauthn/registration/challenge") + assert redirected_to(conn) == ~p"/users/log-in" + end + end + + describe "POST /api/webauthn/authentication/challenge" do + test "returns challenge for user with passkeys", %{conn: conn} do + user = user_fixture() + + # Note: In a real test, we'd create a credential here + # For now, we test the error path + conn = post(conn, ~p"/api/webauthn/authentication/challenge", %{"email" => user.email}) + assert %{"error" => _} = json_response(conn, 404) + end + + test "returns error for non-existent user", %{conn: conn} do + conn = + post(conn, ~p"/api/webauthn/authentication/challenge", %{ + "email" => "nonexistent@example.com" + }) + + assert %{"error" => _} = json_response(conn, 404) + end + + test "does not require authentication", %{conn: conn} do + user = user_fixture() + conn = post(conn, ~p"/api/webauthn/authentication/challenge", %{"email" => user.email}) + # Should not redirect to login + assert conn.status in [200, 404] + end + end + + describe "DELETE /users/credentials/:id" do + setup :register_and_log_in_user + + test "deletes credential successfully", %{conn: conn, user: user} do + # Create a test credential + credential = credential_fixture(user, %{name: "Test Key"}) + + conn = delete(conn, ~p"/users/credentials/#{credential.id}") + assert redirected_to(conn) == ~p"/users/settings" + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "deleted successfully" + + # Verify credential is deleted + assert Accounts.list_user_credentials(user.id) == [] + end + + test "cannot delete another user's credential", %{conn: conn} do + other_user = user_fixture() + credential = credential_fixture(other_user, %{name: "Other Key"}) + + conn = delete(conn, ~p"/users/credentials/#{credential.id}") + assert redirected_to(conn) == ~p"/users/settings" + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "not found" + + # Verify credential still exists + assert length(Accounts.list_user_credentials(other_user.id)) == 1 + end + + test "requires authentication" do + conn = build_conn() + conn = delete(conn, ~p"/users/credentials/#{Ecto.UUID.generate()}") + assert redirected_to(conn) == ~p"/users/log-in" + end + end +end