remove passkeys for now

This commit is contained in:
Graham McIntire 2026-01-31 09:50:35 -06:00
parent 22ae257b60
commit 3c00dcf37c
27 changed files with 11 additions and 2467 deletions

View file

@ -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 = '<svg class="h-5 w-5"><use href="#hero-key"></use></svg> Log in with passkey'
}
}
discoverableBtn.addEventListener('click', authenticateWithDiscoverablePasskey)
})

View file

@ -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<HTMLInputElement>('#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 = '<svg class="h-5 w-5"><use href="#hero-key"></use></svg> 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()
}
})
})

View file

@ -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()
}
})
})

View file

@ -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;
}

View file

@ -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<WebAuthnRegistrationResult> {
// 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<WebAuthnAuthenticationResult> {
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'
})
}
})
}
}

View file

@ -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<HTMLMetaElement>("meta[name='csrf-token']")
if (!metaTag) {
throw new Error('CSRF token meta tag not found')
}
return metaTag.getAttribute('content') || ''
}

View file

@ -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"

View file

@ -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 """

View file

@ -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()
}

View file

@ -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

View file

@ -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
<<rp_id_hash::binary-size(32), flags::8, sign_count::32, rest::binary>> ->
# 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 <<aaguid::binary-size(16), cred_id_length::16, rest::binary>> <- data,
true <- byte_size(rest) >= cred_id_length,
<<credential_id::binary-size(^cred_id_length), public_key_bytes::binary>> <- 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_cbor::binary-size(^public_key_cbor_length), _::binary>> <-
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

View file

@ -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

View file

@ -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

View file

@ -143,153 +143,4 @@
</div>
</div>
<div class="divider" />
<div>
<h3 class="text-lg font-semibold leading-6 text-gray-900 dark:text-white">
Passkeys
</h3>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
Use your device's biometrics (Face ID, Touch ID, Windows Hello) or security keys to sign in
</p>
<%= if !@can_register_passkey do %>
<div class="mt-4 rounded-lg bg-amber-50 p-4 dark:bg-amber-900/20">
<p class="text-sm text-amber-800 dark:text-amber-200">
Please confirm your email address before registering a passkey. Check your inbox for the confirmation link.
</p>
</div>
<% end %>
<div class="mt-6 space-y-4">
<%= if Enum.empty?(@credentials) do %>
<div class="rounded-lg border border-dashed border-gray-300 p-8 text-center dark:border-white/10">
<.icon name="hero-key" class="mx-auto h-12 w-12 text-gray-400" />
<h3 class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">
No passkeys registered
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Add a passkey to enable quick, secure sign-in with biometrics
</p>
</div>
<% else %>
<div class="space-y-3">
<%= for credential <- @credentials do %>
<div class="flex items-center justify-between rounded-lg border border-gray-200 p-4 dark:border-white/10">
<div class="flex items-center gap-3">
<.icon name="hero-key" class="h-5 w-5 text-gray-400" />
<div>
<p class="font-medium text-gray-900 dark:text-white">
{credential.name}
</p>
<%= if credential.last_used_at do %>
<p class="text-sm text-gray-500 dark:text-gray-400">
Last used {Calendar.strftime(credential.last_used_at, "%B %d, %Y")}
</p>
<% else %>
<p class="text-sm text-gray-500 dark:text-gray-400">Never used</p>
<% end %>
</div>
</div>
<.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
</.link>
</div>
<% end %>
</div>
<% end %>
<%= if @can_register_passkey do %>
<button
type="button"
id="add-passkey-btn"
class="inline-flex items-center gap-2 rounded-lg bg-gray-900 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-gray-700 dark:bg-gray-50 dark:text-gray-900 dark:hover:bg-gray-300"
>
<.icon name="hero-plus" class="h-4 w-4" /> Add Passkey
</button>
<!-- Passkey Registration Modal -->
<div
id="passkey-modal"
class="hidden fixed inset-0 z-50 overflow-y-auto"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true"
>
<div class="flex min-h-screen items-end justify-center px-4 pb-20 pt-4 text-center sm:block sm:p-0">
<!-- Background overlay -->
<div
class="fixed inset-0 z-0 bg-gray-500 bg-opacity-75 transition-opacity dark:bg-gray-950 dark:bg-opacity-75"
aria-hidden="true"
>
</div>
<!-- Center modal -->
<span
class="relative z-10 hidden sm:inline-block sm:h-screen sm:align-middle"
aria-hidden="true"
>
&#8203;
</span>
<div class="relative z-10 inline-block transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left align-bottom shadow-xl transition-all dark:bg-gray-800/95 sm:my-8 sm:w-full sm:max-w-lg sm:p-6 sm:align-middle">
<div>
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900">
<.icon name="hero-key" class="h-6 w-6 text-blue-600 dark:text-blue-400" />
</div>
<div class="mt-3 text-center sm:mt-5">
<h3
class="text-lg font-semibold leading-6 text-gray-900 dark:text-white"
id="modal-title"
>
Add Passkey
</h3>
<div class="mt-2">
<p class="text-sm text-gray-500 dark:text-gray-400">
Give this passkey a name to help you identify it later (e.g., "MacBook Touch ID", "iPhone").
</p>
</div>
<div class="mt-4">
<input
type="text"
id="passkey-name-input"
placeholder="e.g., MacBook Touch ID"
class="block w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-white/10 dark:bg-gray-800/50 dark:text-white sm:text-sm"
/>
</div>
<div
id="passkey-error"
class="mt-3 hidden rounded-lg bg-red-50 p-3 dark:bg-red-900/20"
>
<p class="text-sm text-red-800 dark:text-red-200"></p>
</div>
</div>
</div>
<div class="mt-5 sm:mt-6 sm:grid sm:grid-flow-row-dense sm:grid-cols-2 sm:gap-3">
<button
type="button"
id="confirm-add-passkey"
class="inline-flex w-full justify-center rounded-lg bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:opacity-50 disabled:cursor-not-allowed sm:col-start-2"
>
Continue
</button>
<button
type="button"
id="cancel-add-passkey"
class="mt-3 inline-flex w-full justify-center rounded-lg bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-gray-800/50 dark:text-white dark:ring-white/10 dark:hover:bg-gray-800 sm:col-start-1 sm:mt-0"
>
Cancel
</button>
</div>
</div>
</div>
</div>
<% end %>
</div>
</div>
</Layouts.authenticated>

View file

@ -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
</div>
</section>
<!-- WebAuthn Credentials -->
<section class="bg-white dark:bg-gray-900 shadow rounded-lg overflow-hidden">
<div class="px-4 py-5 sm:px-6 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-lg font-medium text-gray-900 dark:text-white">
Security Credentials
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
WebAuthn passkeys registered to your account
</p>
</div>
<div class="px-4 py-5 sm:p-6">
<%= if Enum.empty?(@user_data.credentials) do %>
<p class="text-sm text-gray-500 dark:text-gray-400">
No WebAuthn credentials registered.
</p>
<% else %>
<div class="space-y-4">
<%= for credential <- @user_data.credentials do %>
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<dl class="grid grid-cols-1 gap-x-4 gap-y-3 sm:grid-cols-2">
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Name</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">{credential.name}</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Created</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
{Calendar.strftime(credential.inserted_at, "%B %d, %Y")}
</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">
Last Used
</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
<%= if credential.last_used_at do %>
{Calendar.strftime(credential.last_used_at, "%B %d, %Y at %I:%M %p")}
<% else %>
Never
<% end %>
</dd>
</div>
</dl>
</div>
<% end %>
</div>
<% end %>
</div>
</section>
<!-- Consent Records -->
<section class="bg-white dark:bg-gray-900 shadow rounded-lg overflow-hidden">
<div class="px-4 py-5 sm:px-6 border-b border-gray-200 dark:border-gray-700">
@ -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

View file

@ -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
</a>
</li>
<li>
<a
href="#"
phx-click="switch_tab"
phx-value-tab="security"
class={
if @active_tab == "security",
do: "text-indigo-600 dark:text-indigo-400",
else: ""
}
>
Security
</a>
</li>
<li>
<a
href="#"
@ -991,98 +968,6 @@ defmodule ToweropsWeb.UserSettingsLive do
</div>
<% end %>
<!-- Security Tab -->
<%= if @active_tab == "security" do %>
<!-- Passkeys Section -->
<div class="grid max-w-7xl grid-cols-1 gap-x-8 gap-y-10 px-4 py-16 sm:px-6 md:grid-cols-3 lg:px-8">
<div>
<h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">
Passkeys
</h2>
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
Use your device's biometrics (Face ID, Touch ID, Windows Hello) or security keys to sign in.
</p>
</div>
<div class="md:col-span-2">
<%= if !@can_register_passkey do %>
<div class="rounded-lg bg-amber-50 p-4 dark:bg-amber-900/20">
<p class="text-sm text-amber-800 dark:text-amber-200">
Please confirm your email address before registering a passkey. Check your inbox for the confirmation link.
</p>
</div>
<% end %>
<%= if Enum.empty?(@credentials) do %>
<div class={"text-center rounded-lg border border-dashed border-gray-300 px-6 py-10 dark:border-gray-700 #{if !@can_register_passkey, do: "mt-4"}"}>
<.icon name="hero-key" class="mx-auto h-12 w-12 text-gray-400" />
<h3 class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">
No passkeys
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Add a passkey to enable quick, secure sign-in with biometrics.
</p>
<%= if @can_register_passkey do %>
<div class="mt-6">
<button
type="button"
id="add-passkey-btn"
class="inline-flex items-center gap-x-1.5 rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:bg-indigo-500 dark:hover:bg-indigo-400"
>
<.icon name="hero-plus" class="-ml-0.5 h-5 w-5" /> Add Passkey
</button>
</div>
<% end %>
</div>
<% else %>
<ul
role="list"
class={"divide-y divide-gray-200 dark:divide-white/10 #{if !@can_register_passkey, do: "mt-4"}"}
>
<%= for credential <- @credentials do %>
<li class="flex items-center justify-between gap-x-6 py-5">
<div class="min-w-0">
<div class="flex items-start gap-x-3">
<p class="text-sm/6 font-semibold text-gray-900 dark:text-white">
{credential.name}
</p>
</div>
<div class="mt-1 text-xs/5 text-gray-500 dark:text-gray-400">
<%= if credential.last_used_at do %>
Last used {Calendar.strftime(credential.last_used_at, "%B %d, %Y")}
<% else %>
Never used
<% end %>
</div>
</div>
<.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
</.link>
</li>
<% end %>
</ul>
<%= if @can_register_passkey do %>
<div class="mt-6 flex">
<button
type="button"
id="add-passkey-btn"
class="inline-flex items-center gap-x-1.5 rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:bg-indigo-500 dark:hover:bg-indigo-400"
>
<.icon name="hero-plus" class="-ml-0.5 h-5 w-5" /> Add Passkey
</button>
</div>
<% end %>
<% end %>
</div>
</div>
<% end %>
<!-- Sessions Tab -->
<%= if @active_tab == "sessions" do %>
<!-- Security Alert Banner -->

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,7 @@
defmodule Towerops.Repo.Migrations.DropUserCredentials do
use Ecto.Migration
def change do
drop table(:user_credentials)
end
end

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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"