From 3c00dcf37ced09efe0317032abd13d715880a6a1 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sat, 31 Jan 2026 09:50:35 -0600 Subject: [PATCH] remove passkeys for now --- assets/js/passkey_discoverable.ts | 122 ------- assets/js/passkey_login.ts | 144 -------- assets/js/passkey_settings.ts | 181 ---------- assets/js/types/webauthn.d.ts | 73 ---- assets/js/webauthn.ts | 216 ----------- assets/js/webauthn_utils.ts | 37 -- config/config.exs | 5 - lib/towerops/accounts.ex | 212 ----------- lib/towerops/accounts/user.ex | 4 - lib/towerops/accounts/user_credential.ex | 85 ----- lib/towerops/accounts/webauthn.ex | 337 ------------------ .../api/account_data_controller.ex | 17 - .../controllers/user_credential_controller.ex | 206 ----------- .../user_settings_html/edit.html.heex | 149 -------- lib/towerops_web/live/account_live/my_data.ex | 56 --- lib/towerops_web/live/user_settings_live.ex | 115 ------ lib/towerops_web/router.ex | 16 - ...20260108160055_create_user_credentials.exs | 31 -- .../20260131154549_drop_user_credentials.exs | 7 + priv/static/changelog.txt | 4 + test/support/fixtures/accounts_fixtures.ex | 19 - .../fixtures/accounts_fixtures_test.exs | 21 -- .../accounts/user_credential_test.exs | 200 ----------- .../api/account_data_controller_test.exs | 21 -- .../user_credential_controller_test.exs | 176 --------- .../controllers/user_settings_html_test.exs | 22 -- test/towerops_web/live/my_data_live_test.exs | 2 - 27 files changed, 11 insertions(+), 2467 deletions(-) delete mode 100644 assets/js/passkey_discoverable.ts delete mode 100644 assets/js/passkey_login.ts delete mode 100644 assets/js/passkey_settings.ts delete mode 100644 assets/js/types/webauthn.d.ts delete mode 100644 assets/js/webauthn.ts delete mode 100644 assets/js/webauthn_utils.ts delete mode 100644 lib/towerops/accounts/user_credential.ex delete mode 100644 lib/towerops/accounts/webauthn.ex delete mode 100644 lib/towerops_web/controllers/user_credential_controller.ex delete mode 100644 priv/repo/migrations/20260108160055_create_user_credentials.exs create mode 100644 priv/repo/migrations/20260131154549_drop_user_credentials.exs delete mode 100644 test/towerops/accounts/user_credential_test.exs delete mode 100644 test/towerops_web/controllers/user_credential_controller_test.exs diff --git a/assets/js/passkey_discoverable.ts b/assets/js/passkey_discoverable.ts deleted file mode 100644 index 47498b2b..00000000 --- a/assets/js/passkey_discoverable.ts +++ /dev/null @@ -1,122 +0,0 @@ -// Discoverable passkey authentication (usernameless login) -import { bufferToBase64url, base64urlToBuffer, getCsrfToken } from './webauthn_utils' -import type { WebAuthnAuthChallengeData, ErrorResponse, AuthSuccessResponse } from './types/webauthn' - -document.addEventListener('DOMContentLoaded', () => { - const discoverableBtn = document.getElementById('passkey-discoverable-btn') as HTMLButtonElement | null - const errorDiv = document.getElementById('passkey-discoverable-error') - - if (!discoverableBtn || !errorDiv) return - - const showError = (message: string) => { - errorDiv.classList.remove('hidden') - const errorText = errorDiv.querySelector('p') - if (errorText) { - errorText.textContent = message - } - } - - const hideError = () => { - errorDiv.classList.add('hidden') - const errorText = errorDiv.querySelector('p') - if (errorText) { - errorText.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: ErrorResponse = await challengeResponse.json() - showError(error.error || 'Failed to get authentication challenge') - return - } - - const challengeData: WebAuthnAuthChallengeData = await challengeResponse.json() - - // Convert challenge to ArrayBuffer - const challenge = base64urlToBuffer(challengeData.challenge) - - const publicKeyCredentialRequestOptions: PublicKeyCredentialRequestOptions = { - challenge, - rpId: challengeData.rpId, - timeout: challengeData.timeout, - userVerification: challengeData.userVerification as UserVerificationRequirement - // No allowCredentials - let the authenticator show all credentials for this RP ID - } - - // Get assertion from authenticator - const assertion = await navigator.credentials.get({ - publicKey: publicKeyCredentialRequestOptions - }) as PublicKeyCredential - - if (!assertion.response || !(assertion.response instanceof AuthenticatorAssertionResponse)) { - throw new Error('Invalid assertion response') - } - - 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: AuthSuccessResponse = 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 as Error).name === 'NotAllowedError') { - errorMessage = 'Authentication was cancelled or timed out' - } else if ((error as Error).name === 'NotSupportedError') { - errorMessage = 'Your browser or authenticator does not support passkeys' - } else if ((error as Error).message) { - errorMessage = (error as 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.ts b/assets/js/passkey_login.ts deleted file mode 100644 index 33334012..00000000 --- a/assets/js/passkey_login.ts +++ /dev/null @@ -1,144 +0,0 @@ -// Passkey authentication for login page -import { bufferToBase64url, base64urlToBuffer, getCsrfToken } from './webauthn_utils' -import type { WebAuthnAuthChallengeData, ErrorResponse, AuthSuccessResponse } from './types/webauthn' - -document.addEventListener('DOMContentLoaded', () => { - const passkeyLoginBtn = document.getElementById('passkey-login-btn') as HTMLButtonElement | null - 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: string) => { - errorDiv.classList.remove('hidden') - const errorText = errorDiv.querySelector('p') - if (errorText) { - errorText.textContent = message - } - } - - const hideError = () => { - errorDiv.classList.add('hidden') - const errorText = errorDiv.querySelector('p') - if (errorText) { - errorText.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: ErrorResponse = await challengeResponse.json() - showError(error.error || 'No passkeys found for this email') - return - } - - const challengeData: WebAuthnAuthChallengeData = await challengeResponse.json() - - // Convert challenge to ArrayBuffer - const challenge = base64urlToBuffer(challengeData.challenge) - - const allowCredentials: PublicKeyCredentialDescriptor[] = challengeData.allowCredentials.map(cred => ({ - type: cred.type as PublicKeyCredentialType, - id: base64urlToBuffer(cred.id), - transports: cred.transports as AuthenticatorTransport[] - })) - - const publicKeyCredentialRequestOptions: PublicKeyCredentialRequestOptions = { - challenge, - rpId: challengeData.rpId, - allowCredentials, - timeout: challengeData.timeout, - userVerification: challengeData.userVerification as UserVerificationRequirement - } - - // Get assertion from authenticator - const assertion = await navigator.credentials.get({ - publicKey: publicKeyCredentialRequestOptions - }) as PublicKeyCredential - - if (!assertion.response || !(assertion.response instanceof AuthenticatorAssertionResponse)) { - throw new Error('Invalid assertion response') - } - - 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: AuthSuccessResponse = 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 as Error).name === 'NotAllowedError') { - errorMessage = 'Authentication was cancelled or timed out' - } else if ((error as Error).message) { - errorMessage = (error as 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.ts b/assets/js/passkey_settings.ts deleted file mode 100644 index 094e501c..00000000 --- a/assets/js/passkey_settings.ts +++ /dev/null @@ -1,181 +0,0 @@ -// Passkey management for settings page -import { bufferToBase64url, base64urlToBuffer, getCsrfToken } from './webauthn_utils' -import type { WebAuthnChallengeData, ErrorResponse } from './types/webauthn' - -document.addEventListener('DOMContentLoaded', () => { - const addPasskeyBtn = document.getElementById('add-passkey-btn') - const modal = document.getElementById('passkey-modal') - const nameInput = document.getElementById('passkey-name-input') as HTMLInputElement | null - const confirmBtn = document.getElementById('confirm-add-passkey') as HTMLButtonElement | null - const cancelBtn = document.getElementById('cancel-add-passkey') - const errorDiv = document.getElementById('passkey-error') - - if (!addPasskeyBtn || !modal || !nameInput || !confirmBtn || !cancelBtn || !errorDiv) 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 as HTMLElement).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: string) => { - errorDiv.classList.remove('hidden') - const errorText = errorDiv.querySelector('p') - if (errorText) { - errorText.textContent = message - } - } - - const hideError = () => { - errorDiv.classList.add('hidden') - const errorText = errorDiv.querySelector('p') - if (errorText) { - errorText.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: ErrorResponse = await challengeResponse.json() - showError(error.error || 'Failed to get registration challenge') - return - } - - const challengeData: WebAuthnChallengeData = await challengeResponse.json() - - // Convert challenge and user ID to ArrayBuffer - const challenge = base64urlToBuffer(challengeData.challenge) - const userId = base64urlToBuffer(challengeData.user.id) - - const publicKeyCredentialCreationOptions: PublicKeyCredentialCreationOptions = { - challenge, - rp: challengeData.rp, - user: { - id: userId, - name: challengeData.user.name, - displayName: challengeData.user.displayName - }, - pubKeyCredParams: challengeData.pubKeyCredParams as PublicKeyCredentialParameters[], - authenticatorSelection: challengeData.authenticatorSelection, - timeout: challengeData.timeout, - attestation: (challengeData.attestation as AttestationConveyancePreference) || 'none' - } - - // Hide modal before showing authenticator prompt - modal.classList.add('hidden') - - // Create credential - const credential = await navigator.credentials.create({ - publicKey: publicKeyCredentialCreationOptions - }) as PublicKeyCredential - - if (!credential.response || !(credential.response instanceof AuthenticatorAttestationResponse)) { - throw new Error('Invalid credential response') - } - - 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: ErrorResponse = 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 as Error).name === 'NotAllowedError') { - errorMessage = 'Registration was cancelled or timed out' - } else if ((error as Error).message) { - errorMessage = (error as 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/types/webauthn.d.ts b/assets/js/types/webauthn.d.ts deleted file mode 100644 index e336094c..00000000 --- a/assets/js/types/webauthn.d.ts +++ /dev/null @@ -1,73 +0,0 @@ -// Type definitions for WebAuthn/Passkey authentication - -export interface WebAuthnChallengeData { - challenge: string; - rp: { - name: string; - id: string; - }; - user: { - id: string; - name: string; - displayName: string; - }; - pubKeyCredParams: Array<{ - type: string; - alg: number; - }>; - authenticatorSelection?: { - authenticatorAttachment?: string; - requireResidentKey?: boolean; - residentKey?: string; - userVerification?: string; - }; - timeout: number; - attestation?: string; -} - -export interface WebAuthnAuthChallengeData { - challenge: string; - rpId: string; - allowCredentials: Array<{ - type: string; - id: string; - transports?: string[]; - }>; - timeout: number; - userVerification: string; -} - -export interface WebAuthnRegistrationResult { - name: string; - attestation: { - id: string; - rawId: string; - type: string; - response: { - clientDataJSON: string; - attestationObject: string; - }; - }; -} - -export interface WebAuthnAuthenticationResult { - assertion: { - id: string; - rawId: string; - type: string; - response: { - clientDataJSON: string; - authenticatorData: string; - signature: string; - userHandle: string | null; - }; - }; -} - -export interface ErrorResponse { - error?: string; -} - -export interface AuthSuccessResponse { - redirect: string; -} diff --git a/assets/js/webauthn.ts b/assets/js/webauthn.ts deleted file mode 100644 index cc725c15..00000000 --- a/assets/js/webauthn.ts +++ /dev/null @@ -1,216 +0,0 @@ -// WebAuthn utility functions for passkey authentication -import { base64urlToBuffer, bufferToBase64url, getCsrfToken } from './webauthn_utils' -import type { - WebAuthnChallengeData, - WebAuthnAuthChallengeData, - WebAuthnRegistrationResult, - WebAuthnAuthenticationResult, - ErrorResponse -} from './types/webauthn' -import type { LiveViewHook } from './types/liveview' - -const WebAuthn = { - // Registration flow - async register(challengeData: WebAuthnChallengeData, credentialName: string): Promise { - // Convert challenge and user ID to ArrayBuffer - const challenge = base64urlToBuffer(challengeData.challenge) - const userId = base64urlToBuffer(challengeData.user.id) - - const publicKeyCredentialCreationOptions: PublicKeyCredentialCreationOptions = { - challenge, - rp: challengeData.rp, - user: { - id: userId, - name: challengeData.user.name, - displayName: challengeData.user.displayName - }, - pubKeyCredParams: challengeData.pubKeyCredParams as PublicKeyCredentialParameters[], - authenticatorSelection: challengeData.authenticatorSelection, - timeout: challengeData.timeout, - attestation: (challengeData.attestation as AttestationConveyancePreference) || 'none' - } - - try { - const credential = await navigator.credentials.create({ - publicKey: publicKeyCredentialCreationOptions - }) as PublicKeyCredential - - if (!credential.response || !(credential.response instanceof AuthenticatorAttestationResponse)) { - throw new Error('Invalid credential response') - } - - 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: WebAuthnAuthChallengeData): Promise { - const challenge = base64urlToBuffer(challengeData.challenge) - - const allowCredentials: PublicKeyCredentialDescriptor[] = challengeData.allowCredentials.map(cred => ({ - type: cred.type as PublicKeyCredentialType, - id: base64urlToBuffer(cred.id), - transports: cred.transports as AuthenticatorTransport[] - })) - - const publicKeyCredentialRequestOptions: PublicKeyCredentialRequestOptions = { - challenge, - rpId: challengeData.rpId, - allowCredentials, - timeout: challengeData.timeout, - userVerification: challengeData.userVerification as UserVerificationRequirement - } - - try { - const assertion = await navigator.credentials.get({ - publicKey: publicKeyCredentialRequestOptions - }) as PublicKeyCredential - - if (!assertion.response || !(assertion.response instanceof AuthenticatorAssertionResponse)) { - throw new Error('Invalid assertion response') - } - - 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: LiveViewHook = { - mounted() { - this.handleEvent?.("start_registration", async ({ name }: { name: string }) => { - 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: ErrorResponse = await response.json() - this.pushEvent?.("registration_failed", { error: error.error || 'Failed to get registration challenge' }) - return - } - - const challengeData: WebAuthnChallengeData = 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: ErrorResponse = 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 as Error).name === 'NotAllowedError' - ? 'Registration was cancelled or timed out' - : (error as Error).message || 'Failed to register passkey' - }) - } - }) - } -} - -// LiveView Hook for Authentication (on login page) -export const WebAuthnLogin: LiveViewHook = { - mounted() { - this.handleEvent?.("start_authentication", async ({ email }: { email: string }) => { - 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: ErrorResponse = await response.json() - this.pushEvent?.("authentication_failed", { - error: error.error || 'No passkeys found for this email' - }) - return - } - - const challengeData: WebAuthnAuthChallengeData = 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 as Error).name === 'NotAllowedError' - ? 'Authentication was cancelled or timed out' - : (error as Error).message || 'Failed to authenticate' - }) - } - }) - } -} diff --git a/assets/js/webauthn_utils.ts b/assets/js/webauthn_utils.ts deleted file mode 100644 index 1008c136..00000000 --- a/assets/js/webauthn_utils.ts +++ /dev/null @@ -1,37 +0,0 @@ -// WebAuthn utility functions - -// Convert Base64URL string to ArrayBuffer -export function base64urlToBuffer(base64url: string): ArrayBuffer { - 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: ArrayBuffer): string { - 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(): string { - const metaTag = document.querySelector("meta[name='csrf-token']") - if (!metaTag) { - throw new Error('CSRF token meta tag not found') - } - return metaTag.getAttribute('content') || '' -} diff --git a/config/config.exs b/config/config.exs index e45dc559..d4a92d0f 100644 --- a/config/config.exs +++ b/config/config.exs @@ -136,9 +136,4 @@ 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 ab9bb631..7b56dbc6 100644 --- a/lib/towerops/accounts.ex +++ b/lib/towerops/accounts.ex @@ -11,10 +11,8 @@ defmodule Towerops.Accounts do alias Towerops.Accounts.User alias Towerops.Accounts.UserAgentParser alias Towerops.Accounts.UserConsent - alias Towerops.Accounts.UserCredential alias Towerops.Accounts.UserNotifier alias Towerops.Accounts.UserToken - alias Towerops.Accounts.WebAuthn alias Towerops.GeoIP alias Towerops.Repo @@ -592,216 +590,6 @@ defmodule Towerops.Accounts do 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 - ## User Consent @doc """ diff --git a/lib/towerops/accounts/user.ex b/lib/towerops/accounts/user.ex index bf491633..70eb7f21 100644 --- a/lib/towerops/accounts/user.ex +++ b/lib/towerops/accounts/user.ex @@ -4,7 +4,6 @@ defmodule Towerops.Accounts.User do Users can own or be members of multiple organizations and authenticate via: - Email/password - - WebAuthn passkeys - TOTP (Time-based One-Time Password) two-factor authentication - Session tokens """ @@ -13,7 +12,6 @@ defmodule Towerops.Accounts.User do import Ecto.Changeset alias Ecto.Association.NotLoaded - alias Towerops.Accounts.UserCredential alias Towerops.Organizations.Membership @primary_key {:id, :binary_id, autogenerate: true} @@ -34,7 +32,6 @@ defmodule Towerops.Accounts.User do has_many :memberships, Membership has_many :organizations, through: [:memberships, :organization] - has_many :credentials, UserCredential timestamps(type: :utc_datetime) end @@ -53,7 +50,6 @@ defmodule Towerops.Accounts.User do totp_verified_at: DateTime.t() | nil, memberships: NotLoaded.t() | [Membership.t()], organizations: NotLoaded.t() | [Towerops.Organizations.Organization.t()], - credentials: NotLoaded.t() | [UserCredential.t()], inserted_at: DateTime.t(), updated_at: DateTime.t() } diff --git a/lib/towerops/accounts/user_credential.ex b/lib/towerops/accounts/user_credential.ex deleted file mode 100644 index fb544f91..00000000 --- a/lib/towerops/accounts/user_credential.ex +++ /dev/null @@ -1,85 +0,0 @@ -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 - - alias Towerops.Accounts.User - - @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, User - - timestamps(type: :utc_datetime) - end - - @type t :: %__MODULE__{ - id: Ecto.UUID.t(), - credential_id: binary(), - public_key: binary(), - sign_count: integer(), - name: String.t(), - last_used_at: DateTime.t() | nil, - aaguid: binary(), - transports: [String.t()], - backup_eligible: boolean(), - backup_state: boolean(), - attestation_format: String.t() | nil, - user_id: Ecto.UUID.t(), - user: Ecto.Association.NotLoaded.t() | User.t(), - inserted_at: DateTime.t(), - updated_at: DateTime.t() - } - - @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 deleted file mode 100644 index 0fbee704..00000000 --- a/lib/towerops/accounts/webauthn.ex +++ /dev/null @@ -1,337 +0,0 @@ -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 - 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/api/account_data_controller.ex b/lib/towerops_web/controllers/api/account_data_controller.ex index 36c85c1f..2ee7cfe9 100644 --- a/lib/towerops_web/controllers/api/account_data_controller.ex +++ b/lib/towerops_web/controllers/api/account_data_controller.ex @@ -4,7 +4,6 @@ defmodule ToweropsWeb.Api.AccountDataController do """ use ToweropsWeb, :controller - alias Towerops.Accounts alias Towerops.Admin alias Towerops.Admin.AuditLogger alias Towerops.Alerts @@ -23,7 +22,6 @@ defmodule ToweropsWeb.Api.AccountDataController do # Gather all user data data = %{ profile: build_profile_data(user), - credentials: build_credentials_data(user), organizations: build_organizations_data(user), devices: build_devices_data(user), alerts: build_alerts_data(user), @@ -58,21 +56,6 @@ defmodule ToweropsWeb.Api.AccountDataController do } end - # Build credentials data - defp build_credentials_data(user) do - user.id - |> Accounts.list_user_credentials() - |> Enum.map(fn credential -> - %{ - id: credential.id, - name: credential.name, - public_key: Base.encode64(credential.public_key), - last_used_at: credential.last_used_at, - inserted_at: credential.inserted_at - } - end) - end - # Build organizations data defp build_organizations_data(user) do user.id diff --git a/lib/towerops_web/controllers/user_credential_controller.ex b/lib/towerops_web/controllers/user_credential_controller.ex deleted file mode 100644 index de8a17ed..00000000 --- a/lib/towerops_web/controllers/user_credential_controller.ex +++ /dev/null @@ -1,206 +0,0 @@ -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 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 3f052486..b4155e26 100644 --- a/lib/towerops_web/controllers/user_settings_html/edit.html.heex +++ b/lib/towerops_web/controllers/user_settings_html/edit.html.heex @@ -143,153 +143,4 @@ -
- -
-

- 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-gray-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-gray-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/live/account_live/my_data.ex b/lib/towerops_web/live/account_live/my_data.ex index a3d46e0d..b5eccec6 100644 --- a/lib/towerops_web/live/account_live/my_data.ex +++ b/lib/towerops_web/live/account_live/my_data.ex @@ -41,7 +41,6 @@ defmodule ToweropsWeb.AccountLive.MyData do # Gather user data - only include org/devices/alerts if user is an owner user_data = %{ profile: get_user_profile(user), - credentials: get_user_credentials(user), consents: get_user_consents(user), organizations: if(is_owner, do: organizations, else: []), devices: if(is_owner, do: get_user_devices(user), else: []), @@ -199,56 +198,6 @@ defmodule ToweropsWeb.AccountLive.MyData do
- -
-
-

- Security Credentials -

-

- WebAuthn passkeys registered to your account -

-
-
- <%= if Enum.empty?(@user_data.credentials) do %> -

- No WebAuthn credentials registered. -

- <% else %> -
- <%= for credential <- @user_data.credentials do %> -
-
-
-
Name
-
{credential.name}
-
-
-
Created
-
- {Calendar.strftime(credential.inserted_at, "%B %d, %Y")} -
-
-
-
- Last Used -
-
- <%= if credential.last_used_at do %> - {Calendar.strftime(credential.last_used_at, "%B %d, %Y at %I:%M %p")} - <% else %> - Never - <% end %> -
-
-
-
- <% end %> -
- <% end %> -
-
-
@@ -622,11 +571,6 @@ defmodule ToweropsWeb.AccountLive.MyData do } end - # Helper function to get user's WebAuthn credentials - defp get_user_credentials(user) do - Accounts.list_user_credentials(user.id) - end - # Helper function to get user's organizations defp get_user_organizations(user) do user.id diff --git a/lib/towerops_web/live/user_settings_live.ex b/lib/towerops_web/live/user_settings_live.ex index 5a0d8a39..73ca1f6f 100644 --- a/lib/towerops_web/live/user_settings_live.ex +++ b/lib/towerops_web/live/user_settings_live.ex @@ -21,7 +21,6 @@ defmodule ToweropsWeb.UserSettingsLive do |> assign(:current_token, current_token) |> assign_changesets() |> assign_profile_form() - |> assign_credentials() |> assign_mobile_sessions() |> assign_api_tokens() |> assign_organizations() @@ -306,14 +305,6 @@ defmodule ToweropsWeb.UserSettingsLive do |> assign(:password_form, user |> Accounts.change_user_password() |> to_form()) end - defp assign_credentials(socket) do - user = socket.assigns.current_scope.user - - socket - |> assign(:credentials, Accounts.list_user_credentials(user.id)) - |> assign(:can_register_passkey, Accounts.passkey_registration_allowed?(user)) - end - defp assign_mobile_sessions(socket) do user = socket.assigns.current_scope.user assign(socket, :mobile_sessions, MobileSessions.list_user_sessions(user.id)) @@ -484,20 +475,6 @@ defmodule ToweropsWeb.UserSettingsLive do Account -
  • - - Security - -
  • <% end %> - - <%= if @active_tab == "security" do %> - -
    -
    -

    - 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-gray-400" /> -

    - No passkeys -

    -

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

    - <%= if @can_register_passkey do %> -
    - -
    - <% end %> -
    - <% else %> -
      - <%= for credential <- @credentials do %> -
    • -
      -
      -

      - {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="rounded-md bg-white px-2.5 py-1.5 text-sm font-semibold text-gray-900 shadow-xs inset-ring-1 inset-ring-gray-300 hover:bg-gray-100 dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/10 dark:hover:bg-white/20" - > - Delete - -
    • - <% end %> -
    - - <%= if @can_register_passkey do %> -
    - -
    - <% end %> - <% end %> -
    -
    - <% end %> - <%= if @active_tab == "sessions" do %> diff --git a/lib/towerops_web/router.ex b/lib/towerops_web/router.ex index 626598dd..a61cc838 100644 --- a/lib/towerops_web/router.ex +++ b/lib/towerops_web/router.ex @@ -109,21 +109,6 @@ defmodule ToweropsWeb.Router do post "/geoip/import", GeoipController, :import_database 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 "/admin" do pipe_through [:browser, :require_authenticated_user, :require_superuser] @@ -168,7 +153,6 @@ defmodule ToweropsWeb.Router do pipe_through [:browser, :require_authenticated_user] get "/users/settings/confirm-email/:token", UserSettingsController, :confirm_email - delete "/users/credentials/:id", UserCredentialController, :delete end scope "/", ToweropsWeb do diff --git a/priv/repo/migrations/20260108160055_create_user_credentials.exs b/priv/repo/migrations/20260108160055_create_user_credentials.exs deleted file mode 100644 index 4a1a792e..00000000 --- a/priv/repo/migrations/20260108160055_create_user_credentials.exs +++ /dev/null @@ -1,31 +0,0 @@ -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/priv/repo/migrations/20260131154549_drop_user_credentials.exs b/priv/repo/migrations/20260131154549_drop_user_credentials.exs new file mode 100644 index 00000000..d4bffba7 --- /dev/null +++ b/priv/repo/migrations/20260131154549_drop_user_credentials.exs @@ -0,0 +1,7 @@ +defmodule Towerops.Repo.Migrations.DropUserCredentials do + use Ecto.Migration + + def change do + drop table(:user_credentials) + end +end diff --git a/priv/static/changelog.txt b/priv/static/changelog.txt index 3c9add58..f8535fb1 100644 --- a/priv/static/changelog.txt +++ b/priv/static/changelog.txt @@ -2,6 +2,10 @@ Devices Working * Mikrotik RouterOS * Ubiquiti AC, LTU, AirFiber +2026-01-31 +* Small ui tweaks +* Backend refactoring + 2026-01-30 * Completely overhaul poller agent to not track any state * Complete overhaul of snmp engine diff --git a/test/support/fixtures/accounts_fixtures.ex b/test/support/fixtures/accounts_fixtures.ex index 13b8377f..c0e18580 100644 --- a/test/support/fixtures/accounts_fixtures.ex +++ b/test/support/fixtures/accounts_fixtures.ex @@ -105,23 +105,4 @@ 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/support/fixtures/accounts_fixtures_test.exs b/test/support/fixtures/accounts_fixtures_test.exs index 1498faa0..d17830e4 100644 --- a/test/support/fixtures/accounts_fixtures_test.exs +++ b/test/support/fixtures/accounts_fixtures_test.exs @@ -142,25 +142,4 @@ defmodule Towerops.AccountsFixturesTest do assert DateTime.diff(user_token.authenticated_at, custom_time, :second) == 0 end end - - describe "credential_fixture/2" do - test "creates a credential for a user" do - user = user_fixture() - credential = credential_fixture(user) - - assert credential.user_id == user.id - assert credential.name =~ "Test Credential" - assert is_binary(credential.credential_id) - assert is_binary(credential.public_key) - assert credential.sign_count == 0 - end - - test "creates a credential with custom attributes" do - user = user_fixture() - credential = credential_fixture(user, %{name: "Custom Credential", sign_count: 5}) - - assert credential.name == "Custom Credential" - assert credential.sign_count == 5 - end - end end diff --git a/test/towerops/accounts/user_credential_test.exs b/test/towerops/accounts/user_credential_test.exs deleted file mode 100644 index 25191446..00000000 --- a/test/towerops/accounts/user_credential_test.exs +++ /dev/null @@ -1,200 +0,0 @@ -defmodule Towerops.Accounts.UserCredentialTest do - use Towerops.DataCase - - alias Towerops.Accounts.UserCredential - - describe "changeset/2" do - test "valid changeset with required fields" do - attrs = %{ - user_id: Ecto.UUID.generate(), - credential_id: :crypto.strong_rand_bytes(16), - public_key: :crypto.strong_rand_bytes(65), - name: "My Passkey" - } - - changeset = UserCredential.changeset(%UserCredential{}, attrs) - - assert changeset.valid? - assert get_field(changeset, :name) == "My Passkey" - assert get_field(changeset, :credential_id) == attrs.credential_id - assert get_field(changeset, :public_key) == attrs.public_key - end - - test "requires user_id" do - attrs = %{ - credential_id: :crypto.strong_rand_bytes(16), - public_key: :crypto.strong_rand_bytes(65), - name: "My Passkey" - } - - changeset = UserCredential.changeset(%UserCredential{}, attrs) - - refute changeset.valid? - assert %{user_id: ["can't be blank"]} = errors_on(changeset) - end - - test "requires credential_id" do - attrs = %{ - user_id: Ecto.UUID.generate(), - public_key: :crypto.strong_rand_bytes(65), - name: "My Passkey" - } - - changeset = UserCredential.changeset(%UserCredential{}, attrs) - - refute changeset.valid? - assert %{credential_id: ["can't be blank"]} = errors_on(changeset) - end - - test "requires public_key" do - attrs = %{ - user_id: Ecto.UUID.generate(), - credential_id: :crypto.strong_rand_bytes(16), - name: "My Passkey" - } - - changeset = UserCredential.changeset(%UserCredential{}, attrs) - - refute changeset.valid? - assert %{public_key: ["can't be blank"]} = errors_on(changeset) - end - - test "requires name" do - attrs = %{ - user_id: Ecto.UUID.generate(), - credential_id: :crypto.strong_rand_bytes(16), - public_key: :crypto.strong_rand_bytes(65) - } - - changeset = UserCredential.changeset(%UserCredential{}, attrs) - - refute changeset.valid? - assert %{name: ["can't be blank"]} = errors_on(changeset) - end - - test "validates name length minimum" do - attrs = %{ - user_id: Ecto.UUID.generate(), - credential_id: :crypto.strong_rand_bytes(16), - public_key: :crypto.strong_rand_bytes(65), - name: "" - } - - changeset = UserCredential.changeset(%UserCredential{}, attrs) - - refute changeset.valid? - # Empty string triggers "can't be blank" before length validation - assert %{name: ["can't be blank"]} = errors_on(changeset) - end - - test "validates name length maximum" do - attrs = %{ - user_id: Ecto.UUID.generate(), - credential_id: :crypto.strong_rand_bytes(16), - public_key: :crypto.strong_rand_bytes(65), - name: String.duplicate("a", 101) - } - - changeset = UserCredential.changeset(%UserCredential{}, attrs) - - refute changeset.valid? - assert %{name: ["should be at most 100 character(s)"]} = errors_on(changeset) - end - - test "accepts name at maximum length" do - attrs = %{ - user_id: Ecto.UUID.generate(), - credential_id: :crypto.strong_rand_bytes(16), - public_key: :crypto.strong_rand_bytes(65), - name: String.duplicate("a", 100) - } - - changeset = UserCredential.changeset(%UserCredential{}, attrs) - - assert changeset.valid? - end - - test "accepts optional fields" do - last_used_at = DateTime.truncate(DateTime.utc_now(), :second) - - attrs = %{ - user_id: Ecto.UUID.generate(), - credential_id: :crypto.strong_rand_bytes(16), - public_key: :crypto.strong_rand_bytes(65), - name: "My Passkey", - sign_count: 42, - last_used_at: last_used_at, - aaguid: :crypto.strong_rand_bytes(16), - transports: ["usb", "nfc"], - backup_eligible: true, - backup_state: true, - attestation_format: "packed" - } - - changeset = UserCredential.changeset(%UserCredential{}, attrs) - - assert changeset.valid? - assert get_field(changeset, :sign_count) == 42 - assert DateTime.compare(get_field(changeset, :last_used_at), last_used_at) == :eq - assert get_field(changeset, :aaguid) == attrs.aaguid - assert get_field(changeset, :transports) == ["usb", "nfc"] - assert get_field(changeset, :backup_eligible) == true - assert get_field(changeset, :backup_state) == true - assert get_field(changeset, :attestation_format) == "packed" - end - - test "uses default values for optional fields when not provided" do - attrs = %{ - user_id: Ecto.UUID.generate(), - credential_id: :crypto.strong_rand_bytes(16), - public_key: :crypto.strong_rand_bytes(65), - name: "My Passkey" - } - - changeset = UserCredential.changeset(%UserCredential{}, attrs) - - assert changeset.valid? - # These defaults come from the schema definition - assert get_field(changeset, :sign_count) == 0 - assert get_field(changeset, :transports) == [] - assert get_field(changeset, :backup_eligible) == false - assert get_field(changeset, :backup_state) == false - end - end - - describe "touch_changeset/1" do - test "updates last_used_at to current time" do - old_time = DateTime.add(DateTime.utc_now(), -3600, :second) - - credential = %UserCredential{ - sign_count: 5, - last_used_at: old_time - } - - changeset = UserCredential.touch_changeset(credential) - last_used_at = get_field(changeset, :last_used_at) - - # Should be more recent than the old time - assert DateTime.after?(last_used_at, old_time) - # Should be within last 2 seconds (account for timing + truncation) - diff = DateTime.diff(DateTime.utc_now(), last_used_at, :second) - assert diff <= 2 - end - - test "increments sign_count by 1" do - credential = %UserCredential{sign_count: 5} - - changeset = UserCredential.touch_changeset(credential) - - assert get_field(changeset, :sign_count) == 6 - end - - test "increments sign_count from 0" do - credential = %UserCredential{sign_count: 0} - - changeset = UserCredential.touch_changeset(credential) - - assert get_field(changeset, :sign_count) == 1 - end - end -end diff --git a/test/towerops_web/controllers/api/account_data_controller_test.exs b/test/towerops_web/controllers/api/account_data_controller_test.exs index c167a3d2..3c8999e6 100644 --- a/test/towerops_web/controllers/api/account_data_controller_test.exs +++ b/test/towerops_web/controllers/api/account_data_controller_test.exs @@ -45,27 +45,6 @@ defmodule ToweropsWeb.Api.AccountDataControllerTest do assert data["profile"]["is_superuser"] == user.is_superuser end - test "includes empty credentials when user has no WebAuthn credentials", %{conn: conn} do - conn = get(conn, ~p"/api/v1/account/data") - data = json_response(conn, 200) - - assert data["credentials"] == [] - end - - test "includes credentials when user has WebAuthn credentials", %{conn: conn, user: user} do - # Create a WebAuthn credential using the fixture - credential = credential_fixture(user, %{name: "Test Key"}) - - conn = get(conn, ~p"/api/v1/account/data") - data = json_response(conn, 200) - - assert length(data["credentials"]) == 1 - [cred] = data["credentials"] - assert cred["id"] == credential.id - assert cred["name"] == "Test Key" - assert is_binary(cred["public_key"]) - end - test "includes organizations data", %{conn: conn, user: user} do {:ok, org} = Organizations.create_organization(%{name: "Test Org"}, user.id) diff --git a/test/towerops_web/controllers/user_credential_controller_test.exs b/test/towerops_web/controllers/user_credential_controller_test.exs deleted file mode 100644 index afc28269..00000000 --- a/test/towerops_web/controllers/user_credential_controller_test.exs +++ /dev/null @@ -1,176 +0,0 @@ -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/register" do - setup :register_and_log_in_user - - test "registers credential successfully", %{conn: conn} do - # Set challenge in session - conn = put_session(conn, :webauthn_challenge, "test_challenge_123") - - # Mock successful registration params - params = %{ - "name" => "My Passkey", - "rawId" => Base.url_encode64("credential_id", padding: false), - "response" => %{ - "clientDataJSON" => - Base.url_encode64(~s({"type":"webauthn.create","challenge":"test_challenge_123"}), padding: false), - "attestationObject" => Base.url_encode64("mock_attestation", padding: false) - }, - "type" => "public-key" - } - - conn = post(conn, ~p"/api/webauthn/register", params) - - # Should return error since WebAuthn verification will fail in test - # But this tests the controller flow - assert conn.status in [201, 422] - end - - test "returns error when email not confirmed", %{conn: _conn} do - # Create unconfirmed user - unconfirmed_user = unconfirmed_user_fixture() - conn = log_in_user(build_conn(), unconfirmed_user) - conn = put_session(conn, :webauthn_challenge, "test_challenge") - - params = %{ - "name" => "My Passkey", - "rawId" => Base.url_encode64("credential_id", padding: false) - } - - conn = post(conn, ~p"/api/webauthn/register", params) - assert %{"error" => error} = json_response(conn, 403) - assert error =~ "Email confirmation required" - end - - test "returns error on registration failure", %{conn: conn} do - conn = put_session(conn, :webauthn_challenge, "test_challenge") - - # Invalid params - params = %{"invalid" => "data"} - - conn = post(conn, ~p"/api/webauthn/register", params) - assert %{"error" => _} = json_response(conn, 422) - end - - test "requires authentication" do - conn = build_conn() - conn = post(conn, ~p"/api/webauthn/register", %{}) - 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() - _credential = credential_fixture(user, %{name: "Test Key"}) - - conn = post(conn, ~p"/api/webauthn/authentication/challenge", %{"email" => user.email}) - assert %{"challenge" => challenge} = json_response(conn, 200) - assert is_binary(challenge) - end - - test "returns challenge for discoverable authentication", %{conn: conn} do - # No email provided - discoverable credential flow - conn = post(conn, ~p"/api/webauthn/authentication/challenge", %{}) - assert %{"challenge" => challenge} = json_response(conn, 200) - assert is_binary(challenge) - end - - test "returns error for user without passkeys", %{conn: conn} do - user = user_fixture() - - 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 "POST /api/webauthn/authenticate" do - test "returns error when no challenge in session" do - conn = build_conn() - conn = post(conn, ~p"/api/webauthn/authenticate", %{}) - assert %{"error" => _} = json_response(conn, 401) - 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 diff --git a/test/towerops_web/controllers/user_settings_html_test.exs b/test/towerops_web/controllers/user_settings_html_test.exs index f9123aa9..5f3f10b0 100644 --- a/test/towerops_web/controllers/user_settings_html_test.exs +++ b/test/towerops_web/controllers/user_settings_html_test.exs @@ -16,34 +16,12 @@ defmodule ToweropsWeb.UserSettingsHTMLTest do current_scope: nil, email_changeset: email_changeset, password_changeset: password_changeset, - credentials: [], - can_register_passkey: true, mobile_sessions: [] ) assert html =~ "Account Settings" assert html =~ "Change Email" assert html =~ "Save Password" - assert html =~ "Passkeys" - assert html =~ "No passkeys registered" - end - - test "renders warning when cannot register passkey" do - email_changeset = User.email_changeset(%User{email: "user@example.com"}, %{}) - password_changeset = User.password_changeset(%User{}, %{}) - - html = - render_to_string(ToweropsWeb.UserSettingsHTML, "edit", "html", - flash: %{}, - current_scope: nil, - email_changeset: email_changeset, - password_changeset: password_changeset, - credentials: [], - can_register_passkey: false, - mobile_sessions: [] - ) - - assert html =~ "Please confirm your email address before registering a passkey" end end end diff --git a/test/towerops_web/live/my_data_live_test.exs b/test/towerops_web/live/my_data_live_test.exs index ad098644..816e53e9 100644 --- a/test/towerops_web/live/my_data_live_test.exs +++ b/test/towerops_web/live/my_data_live_test.exs @@ -12,7 +12,6 @@ defmodule ToweropsWeb.AccountLive.MyDataTest do assert html =~ "My Data" assert html =~ "Profile Information" - assert html =~ "Security Credentials" assert html =~ "Organizations" assert html =~ "Devices" assert html =~ "Audit Log" @@ -48,7 +47,6 @@ defmodule ToweropsWeb.AccountLive.MyDataTest do assert html =~ "My Data" assert html =~ "Profile Information" - assert html =~ "Security Credentials" assert html =~ "Organizations" assert html =~ "Devices" assert html =~ "Recent Alerts"