add passkey

This commit is contained in:
Graham McIntire 2026-01-09 12:26:32 -06:00
parent 9228d4a758
commit 4f924ba9a7
No known key found for this signature in database
21 changed files with 1823 additions and 7 deletions

View file

@ -25,6 +25,10 @@ import {LiveSocket} from "phoenix_live_view"
import {hooks as colocatedHooks} from "phoenix-colocated/towerops"
import topbar from "../vendor/topbar"
import Chart from "../vendor/chart"
import {WebAuthnRegister, WebAuthnLogin} from "./webauthn"
import "./passkey_settings"
import "./passkey_login"
import "./passkey_discoverable"
// Generic Sensor Chart Hook (for CPU, Memory, Storage, etc.)
const SensorChart = {
@ -260,7 +264,7 @@ const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute
const liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500,
params: {_csrf_token: csrfToken},
hooks: {...colocatedHooks, SensorChart},
hooks: {...colocatedHooks, SensorChart, WebAuthnRegister, WebAuthnLogin},
})
// Show progress bar on live navigation and form submits

View file

@ -0,0 +1,111 @@
// Discoverable passkey authentication (usernameless login)
import { bufferToBase64url, base64urlToBuffer, getCsrfToken } from './webauthn_utils'
document.addEventListener('DOMContentLoaded', () => {
const discoverableBtn = document.getElementById('passkey-discoverable-btn')
const errorDiv = document.getElementById('passkey-discoverable-error')
if (!discoverableBtn || !errorDiv) return
const showError = (message) => {
errorDiv.classList.remove('hidden')
errorDiv.querySelector('p').textContent = message
}
const hideError = () => {
errorDiv.classList.add('hidden')
errorDiv.querySelector('p').textContent = ''
}
const authenticateWithDiscoverablePasskey = async () => {
try {
hideError()
discoverableBtn.disabled = true
discoverableBtn.textContent = 'Authenticating...'
// Fetch challenge from server (no email required)
const challengeResponse = await fetch('/api/webauthn/authentication/challenge', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': getCsrfToken()
}
})
if (!challengeResponse.ok) {
const error = await challengeResponse.json()
showError(error.error || 'Failed to get authentication challenge')
return
}
const challengeData = await challengeResponse.json()
// Convert challenge to ArrayBuffer
const challenge = base64urlToBuffer(challengeData.challenge)
const publicKeyCredentialRequestOptions = {
challenge,
rpId: challengeData.rpId,
timeout: challengeData.timeout,
userVerification: challengeData.userVerification
// No allowCredentials - let the authenticator show all credentials for this RP ID
}
// Get assertion from authenticator
const assertion = await navigator.credentials.get({
publicKey: publicKeyCredentialRequestOptions
})
const authData = {
assertion: {
id: assertion.id,
rawId: bufferToBase64url(assertion.rawId),
type: assertion.type,
response: {
clientDataJSON: bufferToBase64url(assertion.response.clientDataJSON),
authenticatorData: bufferToBase64url(assertion.response.authenticatorData),
signature: bufferToBase64url(assertion.response.signature),
userHandle: assertion.response.userHandle ?
bufferToBase64url(assertion.response.userHandle) : null
}
}
}
// Send to server
const authResponse = await fetch('/api/webauthn/authenticate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': getCsrfToken()
},
body: JSON.stringify(authData)
})
if (authResponse.ok) {
const result = await authResponse.json()
// Redirect to the provided URL
window.location.href = result.redirect
} else {
showError('Authentication failed')
}
} catch (error) {
console.error('Discoverable authentication error:', error)
let errorMessage = 'Failed to authenticate'
if (error.name === 'NotAllowedError') {
errorMessage = 'Authentication was cancelled or timed out'
} else if (error.name === 'NotSupportedError') {
errorMessage = 'Your browser or authenticator does not support passkeys'
} else if (error.message) {
errorMessage = error.message
}
showError(errorMessage)
} finally {
discoverableBtn.disabled = false
discoverableBtn.innerHTML = '<svg class="h-5 w-5"><use href="#hero-key"></use></svg> Log in with passkey'
}
}
discoverableBtn.addEventListener('click', authenticateWithDiscoverablePasskey)
})

133
assets/js/passkey_login.js Normal file
View file

@ -0,0 +1,133 @@
// Passkey authentication for login page
import { bufferToBase64url, base64urlToBuffer, getCsrfToken } from './webauthn_utils'
document.addEventListener('DOMContentLoaded', () => {
const passkeyLoginBtn = document.getElementById('passkey-login-btn')
const emailInput = document.querySelector('#passkey_login_form input[type="email"]')
const errorDiv = document.getElementById('passkey-login-error')
if (!passkeyLoginBtn || !emailInput || !errorDiv) return
const showError = (message) => {
errorDiv.classList.remove('hidden')
errorDiv.querySelector('p').textContent = message
}
const hideError = () => {
errorDiv.classList.add('hidden')
errorDiv.querySelector('p').textContent = ''
}
const authenticateWithPasskey = async () => {
const email = emailInput.value.trim()
if (!email) {
showError('Please enter your email address')
emailInput.focus()
return
}
try {
hideError()
passkeyLoginBtn.disabled = true
passkeyLoginBtn.textContent = 'Authenticating...'
// Fetch challenge from server
const challengeResponse = await fetch('/api/webauthn/authentication/challenge', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': getCsrfToken()
},
body: JSON.stringify({ email })
})
if (!challengeResponse.ok) {
const error = await challengeResponse.json()
showError(error.error || 'No passkeys found for this email')
return
}
const challengeData = await challengeResponse.json()
// Convert challenge to ArrayBuffer
const challenge = base64urlToBuffer(challengeData.challenge)
const allowCredentials = challengeData.allowCredentials.map(cred => ({
type: cred.type,
id: base64urlToBuffer(cred.id),
transports: cred.transports
}))
const publicKeyCredentialRequestOptions = {
challenge,
rpId: challengeData.rpId,
allowCredentials,
timeout: challengeData.timeout,
userVerification: challengeData.userVerification
}
// Get assertion from authenticator
const assertion = await navigator.credentials.get({
publicKey: publicKeyCredentialRequestOptions
})
const authData = {
assertion: {
id: assertion.id,
rawId: bufferToBase64url(assertion.rawId),
type: assertion.type,
response: {
clientDataJSON: bufferToBase64url(assertion.response.clientDataJSON),
authenticatorData: bufferToBase64url(assertion.response.authenticatorData),
signature: bufferToBase64url(assertion.response.signature),
userHandle: assertion.response.userHandle ?
bufferToBase64url(assertion.response.userHandle) : null
}
}
}
// Send to server
const authResponse = await fetch('/api/webauthn/authenticate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': getCsrfToken()
},
body: JSON.stringify(authData)
})
if (authResponse.ok) {
const result = await authResponse.json()
// Redirect to the provided URL
window.location.href = result.redirect
} else {
showError('Authentication failed')
}
} catch (error) {
console.error('Authentication error:', error)
let errorMessage = 'Failed to authenticate'
if (error.name === 'NotAllowedError') {
errorMessage = 'Authentication was cancelled or timed out'
} else if (error.message) {
errorMessage = error.message
}
showError(errorMessage)
} finally {
passkeyLoginBtn.disabled = false
passkeyLoginBtn.innerHTML = '<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

@ -0,0 +1,170 @@
// Passkey management for settings page
import { bufferToBase64url, base64urlToBuffer, getCsrfToken } from './webauthn_utils'
document.addEventListener('DOMContentLoaded', () => {
const addPasskeyBtn = document.getElementById('add-passkey-btn')
const modal = document.getElementById('passkey-modal')
const nameInput = document.getElementById('passkey-name-input')
const confirmBtn = document.getElementById('confirm-add-passkey')
const cancelBtn = document.getElementById('cancel-add-passkey')
const errorDiv = document.getElementById('passkey-error')
if (!addPasskeyBtn || !modal) return
// Show modal when clicking "Add Passkey"
addPasskeyBtn.addEventListener('click', () => {
modal.classList.remove('hidden')
nameInput.value = ''
nameInput.focus()
hideError()
})
// Hide modal when clicking cancel or background
const hideModal = () => {
modal.classList.add('hidden')
nameInput.value = ''
hideError()
}
cancelBtn.addEventListener('click', hideModal)
modal.addEventListener('click', (e) => {
if (e.target === modal || e.target.classList.contains('bg-zinc-500')) {
hideModal()
}
})
// Handle escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !modal.classList.contains('hidden')) {
hideModal()
}
})
// Show error in modal
const showError = (message) => {
errorDiv.classList.remove('hidden')
errorDiv.querySelector('p').textContent = message
}
const hideError = () => {
errorDiv.classList.add('hidden')
errorDiv.querySelector('p').textContent = ''
}
// Handle passkey registration
const registerPasskey = async () => {
const name = nameInput.value.trim()
if (!name) {
showError('Please enter a name for this passkey')
nameInput.focus()
return
}
try {
confirmBtn.disabled = true
confirmBtn.textContent = 'Registering...'
// Fetch challenge from server
const challengeResponse = await fetch('/api/webauthn/registration/challenge', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': getCsrfToken()
}
})
if (!challengeResponse.ok) {
const error = await challengeResponse.json()
showError(error.error || 'Failed to get registration challenge')
return
}
const challengeData = await challengeResponse.json()
// Convert challenge and user ID to ArrayBuffer
const challenge = base64urlToBuffer(challengeData.challenge)
const userId = base64urlToBuffer(challengeData.user.id)
const publicKeyCredentialCreationOptions = {
challenge,
rp: challengeData.rp,
user: {
id: userId,
name: challengeData.user.name,
displayName: challengeData.user.displayName
},
pubKeyCredParams: challengeData.pubKeyCredParams,
authenticatorSelection: challengeData.authenticatorSelection,
timeout: challengeData.timeout,
attestation: challengeData.attestation
}
// Hide modal before showing authenticator prompt
modal.classList.add('hidden')
// Create credential
const credential = await navigator.credentials.create({
publicKey: publicKeyCredentialCreationOptions
})
const registrationData = {
name: name,
attestation: {
id: credential.id,
rawId: bufferToBase64url(credential.rawId),
type: credential.type,
response: {
clientDataJSON: bufferToBase64url(credential.response.clientDataJSON),
attestationObject: bufferToBase64url(credential.response.attestationObject)
}
}
}
// Send to server
const registerResponse = await fetch('/api/webauthn/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': getCsrfToken()
},
body: JSON.stringify(registrationData)
})
if (registerResponse.ok) {
// Reload page to show new passkey
window.location.reload()
} else {
const error = await registerResponse.json()
modal.classList.remove('hidden')
showError(error.error || 'Failed to register passkey')
}
} catch (error) {
console.error('Registration error:', error)
let errorMessage = 'Failed to register passkey'
if (error.name === 'NotAllowedError') {
errorMessage = 'Registration was cancelled or timed out'
} else if (error.message) {
errorMessage = error.message
}
modal.classList.remove('hidden')
showError(errorMessage)
} finally {
confirmBtn.disabled = false
confirmBtn.textContent = 'Continue'
}
}
// Register on button click
confirmBtn.addEventListener('click', registerPasskey)
// Register on Enter key
nameInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
e.preventDefault()
registerPasskey()
}
})
})

200
assets/js/webauthn.js Normal file
View file

@ -0,0 +1,200 @@
// WebAuthn utility functions for passkey authentication
import { base64urlToBuffer, bufferToBase64url, getCsrfToken } from './webauthn_utils'
const WebAuthn = {
// Registration flow
async register(challengeData, credentialName) {
// Convert challenge and user ID to ArrayBuffer
const challenge = base64urlToBuffer(challengeData.challenge);
const userId = base64urlToBuffer(challengeData.user.id);
const publicKeyCredentialCreationOptions = {
challenge,
rp: challengeData.rp,
user: {
id: userId,
name: challengeData.user.name,
displayName: challengeData.user.displayName
},
pubKeyCredParams: challengeData.pubKeyCredParams,
authenticatorSelection: challengeData.authenticatorSelection,
timeout: challengeData.timeout,
attestation: challengeData.attestation
};
try {
const credential = await navigator.credentials.create({
publicKey: publicKeyCredentialCreationOptions
});
return {
name: credentialName,
attestation: {
id: credential.id,
rawId: bufferToBase64url(credential.rawId),
type: credential.type,
response: {
clientDataJSON: bufferToBase64url(credential.response.clientDataJSON),
attestationObject: bufferToBase64url(credential.response.attestationObject)
}
}
};
} catch (error) {
console.error('WebAuthn registration error:', error);
throw error;
}
},
// Authentication flow
async authenticate(challengeData) {
const challenge = base64urlToBuffer(challengeData.challenge);
const allowCredentials = challengeData.allowCredentials.map(cred => ({
type: cred.type,
id: base64urlToBuffer(cred.id),
transports: cred.transports
}));
const publicKeyCredentialRequestOptions = {
challenge,
rpId: challengeData.rpId,
allowCredentials,
timeout: challengeData.timeout,
userVerification: challengeData.userVerification
};
try {
const assertion = await navigator.credentials.get({
publicKey: publicKeyCredentialRequestOptions
});
return {
assertion: {
id: assertion.id,
rawId: bufferToBase64url(assertion.rawId),
type: assertion.type,
response: {
clientDataJSON: bufferToBase64url(assertion.response.clientDataJSON),
authenticatorData: bufferToBase64url(assertion.response.authenticatorData),
signature: bufferToBase64url(assertion.response.signature),
userHandle: assertion.response.userHandle ?
bufferToBase64url(assertion.response.userHandle) : null
}
}
};
} catch (error) {
console.error('WebAuthn authentication error:', error);
throw error;
}
}
};
// LiveView Hook for Registration (in credential management page)
export const WebAuthnRegister = {
mounted() {
this.handleEvent("start_registration", async ({ name }) => {
try {
// Fetch challenge from server
const response = await fetch('/api/webauthn/registration/challenge', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': getCsrfToken()
}
});
if (!response.ok) {
const error = await response.json();
this.pushEvent("registration_failed", { error: error.error || 'Failed to get registration challenge' });
return;
}
const challengeData = await response.json();
// Perform WebAuthn registration
const registrationData = await WebAuthn.register(challengeData, name);
// Send to server
const registerResponse = await fetch('/api/webauthn/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': getCsrfToken()
},
body: JSON.stringify(registrationData)
});
if (registerResponse.ok) {
this.pushEvent("registration_success", {});
} else {
const error = await registerResponse.json();
this.pushEvent("registration_failed", { error: error.error || 'Failed to register passkey' });
}
} catch (error) {
console.error('Registration error:', error);
this.pushEvent("registration_failed", {
error: error.name === 'NotAllowedError'
? 'Registration was cancelled or timed out'
: error.message || 'Failed to register passkey'
});
}
});
}
};
// LiveView Hook for Authentication (on login page)
export const WebAuthnLogin = {
mounted() {
this.handleEvent("start_authentication", async ({ email }) => {
try {
// Fetch challenge from server
const response = await fetch('/api/webauthn/authentication/challenge', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': getCsrfToken()
},
body: JSON.stringify({ email })
});
if (!response.ok) {
const error = await response.json();
this.pushEvent("authentication_failed", {
error: error.error || 'No passkeys found for this email'
});
return;
}
const challengeData = await response.json();
// Perform WebAuthn authentication
const authData = await WebAuthn.authenticate(challengeData);
// Send to server
const authResponse = await fetch('/api/webauthn/authenticate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': getCsrfToken()
},
body: JSON.stringify(authData)
});
if (authResponse.ok) {
const result = await authResponse.json();
// Redirect to the provided URL
window.location.href = result.redirect;
} else {
this.pushEvent("authentication_failed", { error: 'Authentication failed' });
}
} catch (error) {
console.error('Authentication error:', error);
this.pushEvent("authentication_failed", {
error: error.name === 'NotAllowedError'
? 'Authentication was cancelled or timed out'
: error.message || 'Failed to authenticate'
});
}
});
}
};

View file

@ -0,0 +1,33 @@
// WebAuthn utility functions
// Convert Base64URL string to ArrayBuffer
export function base64urlToBuffer(base64url) {
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
const padLen = (4 - (base64.length % 4)) % 4
const padded = base64 + '='.repeat(padLen)
const binary = atob(padded)
const buffer = new ArrayBuffer(binary.length)
const bytes = new Uint8Array(buffer)
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i)
}
return buffer
}
// Convert ArrayBuffer to Base64URL string
export function bufferToBase64url(buffer) {
const bytes = new Uint8Array(buffer)
let binary = ''
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i])
}
return btoa(binary)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '')
}
// Get CSRF token from meta tag
export function getCsrfToken() {
return document.querySelector("meta[name='csrf-token']").getAttribute('content')
}

View file

@ -78,4 +78,9 @@ config :towerops,
ecto_repos: [Towerops.Repo],
generators: [timestamp_type: :utc_datetime, binary_id: true]
# WebAuthn / Passkey Configuration
config :towerops,
webauthn_rp_id: "localhost",
webauthn_origin: "http://localhost:4000"
import_config "#{config_env()}.exs"

View file

@ -6,8 +6,10 @@ defmodule Towerops.Accounts do
import Ecto.Query, warn: false
alias Towerops.Accounts.User
alias Towerops.Accounts.UserCredential
alias Towerops.Accounts.UserNotifier
alias Towerops.Accounts.UserToken
alias Towerops.Accounts.WebAuthn
alias Towerops.Repo
## Database getters
@ -348,4 +350,214 @@ defmodule Towerops.Accounts do
end
end)
end
## WebAuthn / Passkey Authentication
@doc """
Checks if a user is allowed to register passkeys.
Requires the user's email to be confirmed.
"""
def passkey_registration_allowed?(%User{confirmed_at: nil}), do: false
def passkey_registration_allowed?(%User{}), do: true
@doc """
Lists all credentials for a user.
"""
def list_user_credentials(user_id) do
Repo.all(from c in UserCredential, where: c.user_id == ^user_id, order_by: [desc: c.last_used_at])
end
@doc """
Gets a credential by its credential_id (the WebAuthn credential identifier).
"""
def get_credential_by_credential_id(credential_id) do
Repo.get_by(UserCredential, credential_id: credential_id)
end
@doc """
Generates a WebAuthn registration challenge for a user.
Returns challenge data that should be sent to the client and stored
temporarily in the session.
"""
def generate_registration_challenge(user) do
challenge = :crypto.strong_rand_bytes(32)
challenge_base64 = Base.url_encode64(challenge, padding: false)
%{
challenge: challenge_base64,
user: %{
id: Base.url_encode64(user.id, padding: false),
name: user.email,
displayName: user.email
},
rp: %{
name: "Towerops",
id: get_rp_id()
},
timeout: 60_000,
attestation: "none",
pubKeyCredParams: [
%{type: "public-key", alg: -7},
%{type: "public-key", alg: -257}
],
authenticatorSelection: %{
authenticatorAttachment: "platform",
requireResidentKey: false,
residentKey: "preferred",
userVerification: "preferred"
}
}
end
@doc """
Generates a WebAuthn authentication challenge for a user.
Returns challenge data with allowed credentials that should be sent
to the client.
"""
def generate_authentication_challenge(user) do
challenge = :crypto.strong_rand_bytes(32)
challenge_base64 = Base.url_encode64(challenge, padding: false)
credentials = list_user_credentials(user.id)
%{
challenge: challenge_base64,
timeout: 60_000,
rpId: get_rp_id(),
allowCredentials:
Enum.map(credentials, fn cred ->
%{
type: "public-key",
id: Base.url_encode64(cred.credential_id, padding: false),
transports: cred.transports
}
end),
userVerification: "preferred"
}
end
@doc """
Generates a WebAuthn authentication challenge for discoverable credentials.
This allows usernameless authentication where the authenticator presents
available credentials for the RP ID.
"""
def generate_discoverable_authentication_challenge do
challenge = :crypto.strong_rand_bytes(32)
challenge_base64 = Base.url_encode64(challenge, padding: false)
%{
challenge: challenge_base64,
timeout: 60_000,
rpId: get_rp_id(),
# Empty allowCredentials allows any credential for this RP ID
allowCredentials: [],
userVerification: "preferred"
}
end
@doc """
Registers a new WebAuthn credential for a user.
Verifies the attestation response and creates the credential record.
"""
def register_credential(user, params, challenge) do
origin = get_origin()
rp_id = get_rp_id()
with {:ok, _} <- verify_passkey_registration_allowed(user),
{:ok, credential_data} <-
WebAuthn.verify_attestation(
params["attestation"]["response"],
challenge,
origin,
rp_id
) do
create_credential(user, credential_data, params["name"])
end
end
defp verify_passkey_registration_allowed(user) do
if passkey_registration_allowed?(user) do
{:ok, :allowed}
else
{:error, :email_not_confirmed}
end
end
defp create_credential(user, credential_data, name) do
%UserCredential{}
|> UserCredential.changeset(%{
user_id: user.id,
credential_id: credential_data.credential_id,
public_key: credential_data.public_key,
sign_count: credential_data.sign_count,
name: name || "Passkey",
aaguid: credential_data.aaguid,
attestation_format: credential_data.attestation_format
})
|> Repo.insert()
end
@doc """
Authenticates a user using a WebAuthn credential.
Verifies the assertion response and returns the user if valid.
"""
def authenticate_with_credential(params, challenge) do
origin = get_origin()
rp_id = get_rp_id()
with {:ok, credential_id_base64} <- Map.fetch(params["assertion"], "rawId"),
{:ok, credential_id} <- Base.url_decode64(credential_id_base64, padding: false),
%UserCredential{} = credential <- get_credential_by_credential_id(credential_id),
{:ok, new_sign_count} <-
WebAuthn.verify_assertion(
params["assertion"]["response"],
challenge,
origin,
rp_id,
credential.public_key,
credential.sign_count
),
{:ok, _credential} <- update_credential_usage(credential, new_sign_count) do
user = Repo.get!(User, credential.user_id)
{:ok, user}
else
:error -> {:error, :invalid_credential}
nil -> {:error, :invalid_credential}
{:error, reason} -> {:error, reason}
end
end
defp update_credential_usage(credential, new_sign_count) do
credential
|> Ecto.Changeset.change(%{
last_used_at: DateTime.utc_now(:second),
sign_count: new_sign_count
})
|> Repo.update()
end
@doc """
Deletes a credential.
Only allows deletion if the credential belongs to the specified user.
"""
def delete_credential(credential_id, user_id) do
case Repo.get_by(UserCredential, id: credential_id, user_id: user_id) do
nil -> {:error, :not_found}
credential -> Repo.delete(credential)
end
end
defp get_origin do
Application.get_env(:towerops, :webauthn_origin, "http://localhost:4000")
end
defp get_rp_id do
Application.get_env(:towerops, :webauthn_rp_id, "localhost")
end
end

View file

@ -16,6 +16,7 @@ defmodule Towerops.Accounts.User do
has_many :memberships, Towerops.Organizations.Membership
has_many :organizations, through: [:memberships, :organization]
has_many :credentials, Towerops.Accounts.UserCredential
timestamps(type: :utc_datetime)
end

View file

@ -0,0 +1,65 @@
defmodule Towerops.Accounts.UserCredential do
@moduledoc """
Represents a WebAuthn/passkey credential for a user.
Each credential corresponds to a specific authenticator (device) that
the user has registered for passwordless authentication.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "user_credentials" do
field :credential_id, :binary
field :public_key, :binary
field :sign_count, :integer, default: 0
field :name, :string
field :last_used_at, :utc_datetime
field :aaguid, :binary
field :transports, {:array, :string}, default: []
field :backup_eligible, :boolean, default: false
field :backup_state, :boolean, default: false
field :attestation_format, :string
belongs_to :user, Towerops.Accounts.User
timestamps(type: :utc_datetime)
end
@doc """
Changeset for creating or updating a credential.
"""
def changeset(credential, attrs) do
credential
|> cast(attrs, [
:user_id,
:credential_id,
:public_key,
:sign_count,
:name,
:last_used_at,
:aaguid,
:transports,
:backup_eligible,
:backup_state,
:attestation_format
])
|> validate_required([:user_id, :credential_id, :public_key, :name])
|> validate_length(:name, min: 1, max: 100)
|> unique_constraint(:credential_id)
|> foreign_key_constraint(:user_id)
end
@doc """
Changeset for updating credential usage tracking.
"""
def touch_changeset(credential) do
change(credential, %{
last_used_at: DateTime.utc_now(:second),
sign_count: credential.sign_count + 1
})
end
end

View file

@ -0,0 +1,341 @@
defmodule Towerops.Accounts.WebAuthn do
@moduledoc """
WebAuthn verification for passkey authentication.
This module handles registration (attestation) and authentication (assertion)
verification for WebAuthn/FIDO2 credentials using CBOR decoding and
standard Erlang crypto libraries.
"""
import Bitwise
require Logger
@doc """
Verifies a WebAuthn registration (attestation) response.
Returns `{:ok, credential_data}` with the parsed credential information,
or `{:error, reason}` if verification fails.
"""
def verify_attestation(attestation, challenge, origin, rp_id) do
with {:ok, client_data} <- decode_client_data(attestation["clientDataJSON"]),
:ok <- verify_client_data_type(client_data, "webauthn.create"),
:ok <- verify_challenge(client_data, challenge),
:ok <- verify_origin(client_data, origin),
{:ok, attestation_object} <- decode_attestation_object(attestation["attestationObject"]),
{:ok, auth_data} <- parse_authenticator_data(attestation_object["authData"]),
:ok <- verify_rp_id_hash(auth_data.rp_id_hash, rp_id),
:ok <- verify_user_present(auth_data.flags),
{:ok, credential_data} <- extract_credential_data(auth_data) do
{:ok,
%{
credential_id: credential_data.credential_id,
public_key: credential_data.public_key,
aaguid: auth_data.aaguid,
sign_count: auth_data.sign_count,
attestation_format: attestation_object["fmt"]
}}
else
{:error, reason} = error ->
Logger.error("WebAuthn attestation verification failed: #{inspect(reason)}")
error
end
end
@doc """
Verifies a WebAuthn authentication (assertion) response.
Returns `{:ok, sign_count}` if verification succeeds,
or `{:error, reason}` if verification fails.
"""
def verify_assertion(assertion, challenge, origin, rp_id, public_key, stored_sign_count) do
with {:ok, client_data} <- decode_client_data(assertion["clientDataJSON"]),
:ok <- verify_client_data_type(client_data, "webauthn.get"),
:ok <- verify_challenge(client_data, challenge),
:ok <- verify_origin(client_data, origin),
{:ok, auth_data_bytes} <- decode_base64url(assertion["authenticatorData"]),
{:ok, auth_data} <- parse_authenticator_data(auth_data_bytes),
:ok <- verify_rp_id_hash(auth_data.rp_id_hash, rp_id),
:ok <- verify_user_present(auth_data.flags),
:ok <- verify_sign_count(auth_data.sign_count, stored_sign_count),
{:ok, signature} <- decode_base64url(assertion["signature"]),
{:ok, client_data_json} <- decode_base64url(assertion["clientDataJSON"]),
:ok <- verify_signature(auth_data_bytes, client_data_json, signature, public_key) do
{:ok, auth_data.sign_count}
else
{:error, reason} = error ->
Logger.error("WebAuthn assertion verification failed: #{inspect(reason)}")
error
end
end
# Private functions
defp decode_client_data(base64_client_data) do
with {:ok, json_bytes} <- decode_base64url(base64_client_data),
{:ok, client_data} <- Jason.decode(json_bytes) do
{:ok, client_data}
else
{:error, reason} = error ->
Logger.error("Failed to decode client data: #{inspect(reason)}")
error
error ->
Logger.error("Failed to decode client data JSON: #{inspect(error)}")
{:error, :invalid_client_data}
end
end
defp decode_attestation_object(base64_attestation) do
with {:ok, cbor_bytes} <- decode_base64url(base64_attestation),
{:ok, attestation_object, _} <- CBOR.decode(cbor_bytes) do
{:ok, attestation_object}
else
_ -> {:error, :invalid_attestation_object}
end
end
defp decode_base64url(base64_string) do
decoded = Base.url_decode64!(base64_string, padding: false)
{:ok, decoded}
rescue
_ -> {:error, :invalid_base64}
end
defp verify_client_data_type(client_data, expected_type) do
received_type = client_data["type"]
if received_type == expected_type do
:ok
else
Logger.error("Client data type mismatch. Expected: #{inspect(expected_type)}, Received: #{inspect(received_type)}")
{:error, :invalid_client_data_type}
end
end
defp verify_challenge(client_data, expected_challenge) do
received_challenge = client_data["challenge"]
if received_challenge == expected_challenge do
:ok
else
Logger.error(
"Challenge mismatch. Expected: #{inspect(expected_challenge)}, Received: #{inspect(received_challenge)}"
)
{:error, :challenge_mismatch}
end
end
defp verify_origin(client_data, expected_origin) do
received_origin = client_data["origin"]
if received_origin == expected_origin do
:ok
else
Logger.error("Origin mismatch. Expected: #{inspect(expected_origin)}, Received: #{inspect(received_origin)}")
{:error, :origin_mismatch}
end
end
defp verify_rp_id_hash(rp_id_hash, rp_id) do
expected_hash = :crypto.hash(:sha256, rp_id)
if rp_id_hash == expected_hash do
:ok
else
{:error, :rp_id_mismatch}
end
end
defp verify_user_present(flags) do
# Bit 0: User Present (UP)
user_present = (flags &&& 0x01) != 0
if user_present do
:ok
else
{:error, :user_not_present}
end
end
defp verify_sign_count(new_count, stored_count) do
cond do
new_count == 0 and stored_count == 0 ->
# Both zero is acceptable (authenticator doesn't support counters)
:ok
new_count > stored_count ->
# Counter increased as expected
:ok
true ->
# Counter decreased or didn't increase - possible cloned authenticator
{:error, :invalid_sign_count}
end
end
defp verify_signature(auth_data_bytes, client_data_json, signature, public_key) do
# Construct the signed data: authenticator data || SHA-256(client data JSON)
client_data_hash = :crypto.hash(:sha256, client_data_json)
signed_data = auth_data_bytes <> client_data_hash
# Parse the public key and verify signature
case parse_public_key(public_key) do
{:ok, parsed_key} ->
if :public_key.verify(signed_data, :sha256, signature, parsed_key) do
:ok
else
{:error, :signature_verification_failed}
end
{:error, _} = error ->
error
end
end
# Handle CBOR.Tag wrapped authenticator data
defp parse_authenticator_data(%CBOR.Tag{tag: :bytes, value: binary_data}) do
parse_authenticator_data(binary_data)
end
defp parse_authenticator_data(auth_data) when is_binary(auth_data) do
# Authenticator data structure:
# - 32 bytes: RP ID hash
# - 1 byte: Flags
# - 4 bytes: Sign count (big-endian)
# - Variable: Attested credential data (if present)
# - Variable: Extensions (if present)
case auth_data do
<<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

@ -0,0 +1,204 @@
defmodule ToweropsWeb.UserCredentialController do
use ToweropsWeb, :controller
alias Towerops.Accounts
alias Towerops.Organizations
@doc """
Generates a WebAuthn registration challenge.
Returns challenge data for the browser to use during credential creation.
"""
def registration_challenge(conn, _params) do
user = conn.assigns.current_scope.user
if Accounts.passkey_registration_allowed?(user) do
challenge_data = Accounts.generate_registration_challenge(user)
conn
|> put_session(:webauthn_challenge, challenge_data.challenge)
|> json(challenge_data)
else
conn
|> put_status(:forbidden)
|> json(%{error: "Email confirmation required before registering passkeys"})
end
end
@doc """
Completes WebAuthn registration.
Receives attestation from the browser and creates the credential.
"""
def register(conn, params) do
user = conn.assigns.current_scope.user
challenge = get_session(conn, :webauthn_challenge)
case Accounts.register_credential(user, params, challenge) do
{:ok, credential} ->
conn
|> delete_session(:webauthn_challenge)
|> put_status(:created)
|> json(%{
id: credential.id,
name: credential.name,
created_at: credential.inserted_at
})
{:error, :email_not_confirmed} ->
conn
|> put_status(:forbidden)
|> json(%{error: "Email confirmation required"})
{:error, _reason} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{error: "Failed to register passkey"})
end
end
@doc """
Generates a WebAuthn authentication challenge.
Returns challenge data for the browser to use during authentication.
Supports both discoverable credentials (no email) and account-specific (with email).
"""
def authentication_challenge(conn, params) do
case params do
%{"email" => email} when is_binary(email) ->
# Account-specific authentication
handle_email_authentication_challenge(conn, email)
_ ->
# Discoverable credential authentication (usernameless)
handle_discoverable_authentication_challenge(conn)
end
end
defp handle_email_authentication_challenge(conn, email) do
case Accounts.get_user_by_email(email) do
nil ->
# Don't reveal if user exists
conn
|> put_status(:not_found)
|> json(%{error: "No passkeys found for this email"})
user ->
credentials = Accounts.list_user_credentials(user.id)
if Enum.empty?(credentials) do
conn
|> put_status(:not_found)
|> json(%{error: "No passkeys found for this email"})
else
challenge_data = Accounts.generate_authentication_challenge(user)
conn
|> put_session(:webauthn_challenge, challenge_data.challenge)
|> put_session(:webauthn_user_id, user.id)
|> json(challenge_data)
end
end
end
defp handle_discoverable_authentication_challenge(conn) do
# For discoverable credentials, we don't need to know the user ahead of time
# The authenticator will present credentials for this RP ID
challenge_data = Accounts.generate_discoverable_authentication_challenge()
conn
|> put_session(:webauthn_challenge, challenge_data.challenge)
|> json(challenge_data)
end
@doc """
Completes WebAuthn authentication.
Verifies the assertion and logs the user in.
Supports both discoverable and account-specific authentication.
"""
def authenticate(conn, params) do
challenge = get_session(conn, :webauthn_challenge)
expected_user_id = get_session(conn, :webauthn_user_id)
cond do
is_nil(challenge) ->
conn
|> put_status(:unauthorized)
|> json(%{error: "Authentication failed"})
# Discoverable credential (no expected user)
is_nil(expected_user_id) ->
handle_discoverable_authentication(conn, params, challenge)
# Account-specific authentication
true ->
handle_account_authentication(conn, params, challenge, expected_user_id)
end
end
defp handle_discoverable_authentication(conn, params, challenge) do
case Accounts.authenticate_with_credential(params, challenge) do
{:ok, user} ->
conn
|> delete_session(:webauthn_challenge)
|> put_status(:ok)
|> json(%{success: true, redirect: get_post_login_path(user)})
{:error, _reason} ->
conn
|> put_status(:unauthorized)
|> json(%{error: "Authentication failed"})
end
end
defp handle_account_authentication(conn, params, challenge, expected_user_id) do
with {:ok, user} <- Accounts.authenticate_with_credential(params, challenge),
true <- user.id == expected_user_id do
conn
|> delete_session(:webauthn_challenge)
|> delete_session(:webauthn_user_id)
|> put_status(:ok)
|> json(%{success: true, redirect: get_post_login_path(user)})
else
false ->
conn
|> put_status(:unauthorized)
|> json(%{error: "Authentication failed"})
{:error, _reason} ->
conn
|> put_status(:unauthorized)
|> json(%{error: "Authentication failed"})
end
end
@doc """
Deletes a credential.
Only allows deletion if the credential belongs to the current user.
"""
def delete(conn, %{"id" => credential_id}) do
user = conn.assigns.current_scope.user
case Accounts.delete_credential(credential_id, user.id) do
{:ok, _credential} ->
conn
|> put_flash(:info, "Passkey deleted successfully.")
|> redirect(to: ~p"/users/settings")
{:error, :not_found} ->
conn
|> put_flash(:error, "Passkey not found.")
|> redirect(to: ~p"/users/settings")
{:error, _reason} ->
conn
|> put_flash(:error, "Failed to delete passkey.")
|> redirect(to: ~p"/users/settings")
end
end
defp get_post_login_path(user) do
# Get the first organization or redirect to orgs list
case Organizations.list_user_organizations(user.id) do
[org | _] -> ~p"/orgs/#{org.slug}"
[] -> ~p"/orgs"
end
end
end

View file

@ -41,6 +41,34 @@
</div>
<div class="mt-8 space-y-6">
<!-- Passkey Login (Discoverable) -->
<div>
<div
id="passkey-discoverable-error"
class="hidden mb-3 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>
<button
type="button"
id="passkey-discoverable-btn"
class="w-full inline-flex items-center justify-center gap-2 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"
>
<.icon name="hero-key" class="h-5 w-5" /> Log in with passkey
</button>
</div>
<div class="relative">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-zinc-300 dark:border-zinc-700"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="bg-zinc-50 px-2 text-zinc-600 dark:bg-zinc-950 dark:text-zinc-400">
or use email
</span>
</div>
</div>
<.form :let={f} for={@form} as={:user} id="login_form_magic" action={~p"/users/log-in"}>
<.input
readonly={!!@current_scope}
@ -49,7 +77,6 @@
label="Email"
autocomplete="email"
required
phx-mounted={JS.focus()}
/>
<.button class="w-full" variant="primary">
Log in with email <span aria-hidden="true">→</span>
@ -62,7 +89,7 @@
</div>
<div class="relative flex justify-center text-sm">
<span class="bg-zinc-50 px-2 text-zinc-600 dark:bg-zinc-950 dark:text-zinc-400">
or
or use password
</span>
</div>
</div>

View file

@ -74,5 +74,7 @@ defmodule ToweropsWeb.UserSettingsController do
conn
|> assign(:email_changeset, Accounts.change_user_email(user))
|> assign(:password_changeset, Accounts.change_user_password(user))
|> assign(:credentials, Accounts.list_user_credentials(user.id))
|> assign(:can_register_passkey, Accounts.passkey_registration_allowed?(user))
end
end

View file

@ -37,4 +37,154 @@
Save Password
</.button>
</.form>
<div class="divider" />
<div>
<h3 class="text-lg font-semibold leading-6 text-zinc-900 dark:text-zinc-100">
Passkeys
</h3>
<p class="mt-1 text-sm text-zinc-600 dark:text-zinc-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-zinc-300 p-8 text-center dark:border-zinc-700">
<.icon name="hero-key" class="mx-auto h-12 w-12 text-zinc-400" />
<h3 class="mt-2 text-sm font-semibold text-zinc-900 dark:text-zinc-100">
No passkeys registered
</h3>
<p class="mt-1 text-sm text-zinc-500 dark:text-zinc-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-zinc-200 p-4 dark:border-zinc-800">
<div class="flex items-center gap-3">
<.icon name="hero-key" class="h-5 w-5 text-zinc-400" />
<div>
<p class="font-medium text-zinc-900 dark:text-zinc-100">
{credential.name}
</p>
<%= if credential.last_used_at do %>
<p class="text-sm text-zinc-500 dark:text-zinc-400">
Last used {Calendar.strftime(credential.last_used_at, "%B %d, %Y")}
</p>
<% else %>
<p class="text-sm text-zinc-500 dark:text-zinc-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-zinc-900 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-zinc-700 dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-zinc-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-zinc-500 bg-opacity-75 transition-opacity dark:bg-zinc-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-zinc-900 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-zinc-900 dark:text-zinc-100"
id="modal-title"
>
Add Passkey
</h3>
<div class="mt-2">
<p class="text-sm text-zinc-500 dark:text-zinc-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-zinc-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 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-zinc-900 shadow-sm ring-1 ring-inset ring-zinc-300 hover:bg-zinc-50 dark:bg-zinc-800 dark:text-zinc-100 dark:ring-zinc-700 dark:hover:bg-zinc-700 sm:col-start-1 sm:mt-0"
>
Cancel
</button>
</div>
</div>
</div>
</div>
<% end %>
</div>
</div>
</Layouts.authenticated>

View file

@ -29,10 +29,20 @@ defmodule ToweropsWeb.Router do
get "/", PageController, :home
end
# Other scopes may use custom stacks.
# scope "/api", ToweropsWeb do
# pipe_through :api
# end
# WebAuthn API routes
scope "/api/webauthn", ToweropsWeb do
pipe_through [:browser]
post "/authentication/challenge", UserCredentialController, :authentication_challenge
post "/authenticate", UserCredentialController, :authenticate
end
scope "/api/webauthn", ToweropsWeb do
pipe_through [:browser, :require_authenticated_user]
post "/registration/challenge", UserCredentialController, :registration_challenge
post "/register", UserCredentialController, :register
end
# Enable LiveDashboard in production with authentication
scope "/dashboard" do
@ -65,6 +75,7 @@ defmodule ToweropsWeb.Router do
get "/users/settings", UserSettingsController, :edit
put "/users/settings", UserSettingsController, :update
get "/users/settings/confirm-email/:token", UserSettingsController, :confirm_email
delete "/users/credentials/:id", UserCredentialController, :delete
end
scope "/", ToweropsWeb do

View file

@ -56,6 +56,7 @@ defmodule Towerops.MixProject do
{:heroicons,
github: "tailwindlabs/heroicons", tag: "v2.2.0", sparse: "optimized", app: false, compile: false, depth: 1},
{:swoosh, "~> 1.16"},
{:cbor, "~> 1.0"},
{:req, "~> 0.5"},
{:snmpkit, "~> 1.3"},
{:yaml_elixir, "~> 2.9"},

View file

@ -2,6 +2,7 @@
"bandit": {:hex, :bandit, "1.10.1", "6b1f8609d947ae2a74da5bba8aee938c94348634e54e5625eef622ca0bbbb062", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "4b4c35f273030e44268ace53bf3d5991dfc385c77374244e2f960876547671aa"},
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"},
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
"cbor": {:hex, :cbor, "1.0.1", "39511158e8ea5a57c1fcb9639aaa7efde67129678fee49ebbda780f6f24959b0", [:mix], [], "hexpm", "5431acbe7a7908f17f6a9cd43311002836a34a8ab01876918d8cfb709cd8b6a2"},
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
"credo": {:hex, :credo, "1.7.15", "283da72eeb2fd3ccf7248f4941a0527efb97afa224bcdef30b4b580bc8258e1c", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "291e8645ea3fea7481829f1e1eb0881b8395db212821338e577a90bf225c5607"},

View file

@ -0,0 +1,31 @@
defmodule Towerops.Repo.Migrations.CreateUserCredentials do
use Ecto.Migration
def change do
create table(:user_credentials, primary_key: false) do
add :id, :binary_id, primary_key: true
add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false
# WebAuthn credential data
add :credential_id, :binary, null: false
add :public_key, :binary, null: false
add :sign_count, :bigint, default: 0, null: false
# Metadata for user-friendly display
add :name, :string, null: false
add :last_used_at, :utc_datetime
# Authenticator metadata
add :aaguid, :binary
add :transports, {:array, :string}, default: []
add :backup_eligible, :boolean, default: false
add :backup_state, :boolean, default: false
add :attestation_format, :string
timestamps(type: :utc_datetime)
end
create index(:user_credentials, [:user_id])
create unique_index(:user_credentials, [:credential_id])
end
end

View file

@ -86,4 +86,23 @@ defmodule Towerops.AccountsFixtures do
set: [inserted_at: dt, authenticated_at: dt]
)
end
@doc """
Creates a test credential for a user.
This is a helper for testing - in production, credentials are created via WebAuthn.
"""
def credential_fixture(user, attrs \\ %{}) do
default_attrs = %{
name: "Test Credential #{System.unique_integer()}",
credential_id: :crypto.strong_rand_bytes(32),
public_key: :crypto.strong_rand_bytes(64),
sign_count: 0
}
attrs = Enum.into(attrs, default_attrs)
%Accounts.UserCredential{}
|> Accounts.UserCredential.changeset(Map.put(attrs, :user_id, user.id))
|> Towerops.Repo.insert!()
end
end

View file

@ -0,0 +1,95 @@
defmodule ToweropsWeb.UserCredentialControllerTest do
use ToweropsWeb.ConnCase, async: true
import Towerops.AccountsFixtures
alias Towerops.Accounts
describe "POST /api/webauthn/registration/challenge" do
setup :register_and_log_in_user
test "returns challenge for authenticated user with confirmed email", %{conn: conn, user: user} do
conn = post(conn, ~p"/api/webauthn/registration/challenge")
assert %{"challenge" => challenge, "user" => user_data} = json_response(conn, 200)
assert is_binary(challenge)
assert user_data["name"] == user.email
end
test "returns error for user without confirmed email" do
# Create unconfirmed user
user = unconfirmed_user_fixture()
conn = log_in_user(build_conn(), user)
conn = post(conn, ~p"/api/webauthn/registration/challenge")
assert %{"error" => error} = json_response(conn, 403)
assert error =~ "Email confirmation required"
end
test "requires authentication" do
conn = build_conn()
conn = post(conn, ~p"/api/webauthn/registration/challenge")
assert redirected_to(conn) == ~p"/users/log-in"
end
end
describe "POST /api/webauthn/authentication/challenge" do
test "returns challenge for user with passkeys", %{conn: conn} do
user = user_fixture()
# Note: In a real test, we'd create a credential here
# For now, we test the error path
conn = post(conn, ~p"/api/webauthn/authentication/challenge", %{"email" => user.email})
assert %{"error" => _} = json_response(conn, 404)
end
test "returns error for non-existent user", %{conn: conn} do
conn =
post(conn, ~p"/api/webauthn/authentication/challenge", %{
"email" => "nonexistent@example.com"
})
assert %{"error" => _} = json_response(conn, 404)
end
test "does not require authentication", %{conn: conn} do
user = user_fixture()
conn = post(conn, ~p"/api/webauthn/authentication/challenge", %{"email" => user.email})
# Should not redirect to login
assert conn.status in [200, 404]
end
end
describe "DELETE /users/credentials/:id" do
setup :register_and_log_in_user
test "deletes credential successfully", %{conn: conn, user: user} do
# Create a test credential
credential = credential_fixture(user, %{name: "Test Key"})
conn = delete(conn, ~p"/users/credentials/#{credential.id}")
assert redirected_to(conn) == ~p"/users/settings"
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "deleted successfully"
# Verify credential is deleted
assert Accounts.list_user_credentials(user.id) == []
end
test "cannot delete another user's credential", %{conn: conn} do
other_user = user_fixture()
credential = credential_fixture(other_user, %{name: "Other Key"})
conn = delete(conn, ~p"/users/credentials/#{credential.id}")
assert redirected_to(conn) == ~p"/users/settings"
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "not found"
# Verify credential still exists
assert length(Accounts.list_user_credentials(other_user.id)) == 1
end
test "requires authentication" do
conn = build_conn()
conn = delete(conn, ~p"/users/credentials/#{Ecto.UUID.generate()}")
assert redirected_to(conn) == ~p"/users/log-in"
end
end
end