add passkey
This commit is contained in:
parent
9228d4a758
commit
4f924ba9a7
21 changed files with 1823 additions and 7 deletions
|
|
@ -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
|
||||
|
|
|
|||
111
assets/js/passkey_discoverable.js
Normal file
111
assets/js/passkey_discoverable.js
Normal 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
133
assets/js/passkey_login.js
Normal 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
170
assets/js/passkey_settings.js
Normal file
170
assets/js/passkey_settings.js
Normal 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
200
assets/js/webauthn.js
Normal 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'
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
33
assets/js/webauthn_utils.js
Normal file
33
assets/js/webauthn_utils.js
Normal 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')
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
65
lib/towerops/accounts/user_credential.ex
Normal file
65
lib/towerops/accounts/user_credential.ex
Normal 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
|
||||
341
lib/towerops/accounts/webauthn.ex
Normal file
341
lib/towerops/accounts/webauthn.ex
Normal 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
|
||||
204
lib/towerops_web/controllers/user_credential_controller.ex
Normal file
204
lib/towerops_web/controllers/user_credential_controller.ex
Normal 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
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
​
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
1
mix.exs
1
mix.exs
|
|
@ -56,6 +56,7 @@ defmodule Towerops.MixProject do
|
|||
{:heroicons,
|
||||
github: "tailwindlabs/heroicons", tag: "v2.2.0", sparse: "optimized", app: false, compile: false, depth: 1},
|
||||
{:swoosh, "~> 1.16"},
|
||||
{:cbor, "~> 1.0"},
|
||||
{:req, "~> 0.5"},
|
||||
{:snmpkit, "~> 1.3"},
|
||||
{:yaml_elixir, "~> 2.9"},
|
||||
|
|
|
|||
1
mix.lock
1
mix.lock
|
|
@ -2,6 +2,7 @@
|
|||
"bandit": {:hex, :bandit, "1.10.1", "6b1f8609d947ae2a74da5bba8aee938c94348634e54e5625eef622ca0bbbb062", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "4b4c35f273030e44268ace53bf3d5991dfc385c77374244e2f960876547671aa"},
|
||||
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"},
|
||||
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
|
||||
"cbor": {:hex, :cbor, "1.0.1", "39511158e8ea5a57c1fcb9639aaa7efde67129678fee49ebbda780f6f24959b0", [:mix], [], "hexpm", "5431acbe7a7908f17f6a9cd43311002836a34a8ab01876918d8cfb709cd8b6a2"},
|
||||
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
|
||||
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
|
||||
"credo": {:hex, :credo, "1.7.15", "283da72eeb2fd3ccf7248f4941a0527efb97afa224bcdef30b4b580bc8258e1c", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "291e8645ea3fea7481829f1e1eb0881b8395db212821338e577a90bf225c5607"},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue