remove passkeys for now
This commit is contained in:
parent
22ae257b60
commit
3c00dcf37c
27 changed files with 11 additions and 2467 deletions
|
|
@ -1,122 +0,0 @@
|
|||
// Discoverable passkey authentication (usernameless login)
|
||||
import { bufferToBase64url, base64urlToBuffer, getCsrfToken } from './webauthn_utils'
|
||||
import type { WebAuthnAuthChallengeData, ErrorResponse, AuthSuccessResponse } from './types/webauthn'
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const discoverableBtn = document.getElementById('passkey-discoverable-btn') as HTMLButtonElement | null
|
||||
const errorDiv = document.getElementById('passkey-discoverable-error')
|
||||
|
||||
if (!discoverableBtn || !errorDiv) return
|
||||
|
||||
const showError = (message: string) => {
|
||||
errorDiv.classList.remove('hidden')
|
||||
const errorText = errorDiv.querySelector('p')
|
||||
if (errorText) {
|
||||
errorText.textContent = message
|
||||
}
|
||||
}
|
||||
|
||||
const hideError = () => {
|
||||
errorDiv.classList.add('hidden')
|
||||
const errorText = errorDiv.querySelector('p')
|
||||
if (errorText) {
|
||||
errorText.textContent = ''
|
||||
}
|
||||
}
|
||||
|
||||
const authenticateWithDiscoverablePasskey = async () => {
|
||||
try {
|
||||
hideError()
|
||||
discoverableBtn.disabled = true
|
||||
discoverableBtn.textContent = 'Authenticating...'
|
||||
|
||||
// Fetch challenge from server (no email required)
|
||||
const challengeResponse = await fetch('/api/webauthn/authentication/challenge', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': getCsrfToken()
|
||||
}
|
||||
})
|
||||
|
||||
if (!challengeResponse.ok) {
|
||||
const error: ErrorResponse = await challengeResponse.json()
|
||||
showError(error.error || 'Failed to get authentication challenge')
|
||||
return
|
||||
}
|
||||
|
||||
const challengeData: WebAuthnAuthChallengeData = await challengeResponse.json()
|
||||
|
||||
// Convert challenge to ArrayBuffer
|
||||
const challenge = base64urlToBuffer(challengeData.challenge)
|
||||
|
||||
const publicKeyCredentialRequestOptions: PublicKeyCredentialRequestOptions = {
|
||||
challenge,
|
||||
rpId: challengeData.rpId,
|
||||
timeout: challengeData.timeout,
|
||||
userVerification: challengeData.userVerification as UserVerificationRequirement
|
||||
// No allowCredentials - let the authenticator show all credentials for this RP ID
|
||||
}
|
||||
|
||||
// Get assertion from authenticator
|
||||
const assertion = await navigator.credentials.get({
|
||||
publicKey: publicKeyCredentialRequestOptions
|
||||
}) as PublicKeyCredential
|
||||
|
||||
if (!assertion.response || !(assertion.response instanceof AuthenticatorAssertionResponse)) {
|
||||
throw new Error('Invalid assertion response')
|
||||
}
|
||||
|
||||
const authData = {
|
||||
assertion: {
|
||||
id: assertion.id,
|
||||
rawId: bufferToBase64url(assertion.rawId),
|
||||
type: assertion.type,
|
||||
response: {
|
||||
clientDataJSON: bufferToBase64url(assertion.response.clientDataJSON),
|
||||
authenticatorData: bufferToBase64url(assertion.response.authenticatorData),
|
||||
signature: bufferToBase64url(assertion.response.signature),
|
||||
userHandle: assertion.response.userHandle ?
|
||||
bufferToBase64url(assertion.response.userHandle) : null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send to server
|
||||
const authResponse = await fetch('/api/webauthn/authenticate', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': getCsrfToken()
|
||||
},
|
||||
body: JSON.stringify(authData)
|
||||
})
|
||||
|
||||
if (authResponse.ok) {
|
||||
const result: AuthSuccessResponse = await authResponse.json()
|
||||
// Redirect to the provided URL
|
||||
window.location.href = result.redirect
|
||||
} else {
|
||||
showError('Authentication failed')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Discoverable authentication error:', error)
|
||||
|
||||
let errorMessage = 'Failed to authenticate'
|
||||
if ((error as Error).name === 'NotAllowedError') {
|
||||
errorMessage = 'Authentication was cancelled or timed out'
|
||||
} else if ((error as Error).name === 'NotSupportedError') {
|
||||
errorMessage = 'Your browser or authenticator does not support passkeys'
|
||||
} else if ((error as Error).message) {
|
||||
errorMessage = (error as Error).message
|
||||
}
|
||||
|
||||
showError(errorMessage)
|
||||
} finally {
|
||||
discoverableBtn.disabled = false
|
||||
discoverableBtn.innerHTML = '<svg class="h-5 w-5"><use href="#hero-key"></use></svg> Log in with passkey'
|
||||
}
|
||||
}
|
||||
|
||||
discoverableBtn.addEventListener('click', authenticateWithDiscoverablePasskey)
|
||||
})
|
||||
|
|
@ -1,144 +0,0 @@
|
|||
// Passkey authentication for login page
|
||||
import { bufferToBase64url, base64urlToBuffer, getCsrfToken } from './webauthn_utils'
|
||||
import type { WebAuthnAuthChallengeData, ErrorResponse, AuthSuccessResponse } from './types/webauthn'
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const passkeyLoginBtn = document.getElementById('passkey-login-btn') as HTMLButtonElement | null
|
||||
const emailInput = document.querySelector<HTMLInputElement>('#passkey_login_form input[type="email"]')
|
||||
const errorDiv = document.getElementById('passkey-login-error')
|
||||
|
||||
if (!passkeyLoginBtn || !emailInput || !errorDiv) return
|
||||
|
||||
const showError = (message: string) => {
|
||||
errorDiv.classList.remove('hidden')
|
||||
const errorText = errorDiv.querySelector('p')
|
||||
if (errorText) {
|
||||
errorText.textContent = message
|
||||
}
|
||||
}
|
||||
|
||||
const hideError = () => {
|
||||
errorDiv.classList.add('hidden')
|
||||
const errorText = errorDiv.querySelector('p')
|
||||
if (errorText) {
|
||||
errorText.textContent = ''
|
||||
}
|
||||
}
|
||||
|
||||
const authenticateWithPasskey = async () => {
|
||||
const email = emailInput.value.trim()
|
||||
|
||||
if (!email) {
|
||||
showError('Please enter your email address')
|
||||
emailInput.focus()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
hideError()
|
||||
passkeyLoginBtn.disabled = true
|
||||
passkeyLoginBtn.textContent = 'Authenticating...'
|
||||
|
||||
// Fetch challenge from server
|
||||
const challengeResponse = await fetch('/api/webauthn/authentication/challenge', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': getCsrfToken()
|
||||
},
|
||||
body: JSON.stringify({ email })
|
||||
})
|
||||
|
||||
if (!challengeResponse.ok) {
|
||||
const error: ErrorResponse = await challengeResponse.json()
|
||||
showError(error.error || 'No passkeys found for this email')
|
||||
return
|
||||
}
|
||||
|
||||
const challengeData: WebAuthnAuthChallengeData = await challengeResponse.json()
|
||||
|
||||
// Convert challenge to ArrayBuffer
|
||||
const challenge = base64urlToBuffer(challengeData.challenge)
|
||||
|
||||
const allowCredentials: PublicKeyCredentialDescriptor[] = challengeData.allowCredentials.map(cred => ({
|
||||
type: cred.type as PublicKeyCredentialType,
|
||||
id: base64urlToBuffer(cred.id),
|
||||
transports: cred.transports as AuthenticatorTransport[]
|
||||
}))
|
||||
|
||||
const publicKeyCredentialRequestOptions: PublicKeyCredentialRequestOptions = {
|
||||
challenge,
|
||||
rpId: challengeData.rpId,
|
||||
allowCredentials,
|
||||
timeout: challengeData.timeout,
|
||||
userVerification: challengeData.userVerification as UserVerificationRequirement
|
||||
}
|
||||
|
||||
// Get assertion from authenticator
|
||||
const assertion = await navigator.credentials.get({
|
||||
publicKey: publicKeyCredentialRequestOptions
|
||||
}) as PublicKeyCredential
|
||||
|
||||
if (!assertion.response || !(assertion.response instanceof AuthenticatorAssertionResponse)) {
|
||||
throw new Error('Invalid assertion response')
|
||||
}
|
||||
|
||||
const authData = {
|
||||
assertion: {
|
||||
id: assertion.id,
|
||||
rawId: bufferToBase64url(assertion.rawId),
|
||||
type: assertion.type,
|
||||
response: {
|
||||
clientDataJSON: bufferToBase64url(assertion.response.clientDataJSON),
|
||||
authenticatorData: bufferToBase64url(assertion.response.authenticatorData),
|
||||
signature: bufferToBase64url(assertion.response.signature),
|
||||
userHandle: assertion.response.userHandle ?
|
||||
bufferToBase64url(assertion.response.userHandle) : null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send to server
|
||||
const authResponse = await fetch('/api/webauthn/authenticate', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': getCsrfToken()
|
||||
},
|
||||
body: JSON.stringify(authData)
|
||||
})
|
||||
|
||||
if (authResponse.ok) {
|
||||
const result: AuthSuccessResponse = await authResponse.json()
|
||||
// Redirect to the provided URL
|
||||
window.location.href = result.redirect
|
||||
} else {
|
||||
showError('Authentication failed')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Authentication error:', error)
|
||||
|
||||
let errorMessage = 'Failed to authenticate'
|
||||
if ((error as Error).name === 'NotAllowedError') {
|
||||
errorMessage = 'Authentication was cancelled or timed out'
|
||||
} else if ((error as Error).message) {
|
||||
errorMessage = (error as Error).message
|
||||
}
|
||||
|
||||
showError(errorMessage)
|
||||
} finally {
|
||||
passkeyLoginBtn.disabled = false
|
||||
passkeyLoginBtn.innerHTML = '<svg class="h-5 w-5"><use href="#hero-key"></use></svg> Log in with passkey'
|
||||
}
|
||||
}
|
||||
|
||||
passkeyLoginBtn.addEventListener('click', authenticateWithPasskey)
|
||||
|
||||
// Allow pressing Enter in email field to trigger passkey login
|
||||
emailInput.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
authenticateWithPasskey()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -1,181 +0,0 @@
|
|||
// Passkey management for settings page
|
||||
import { bufferToBase64url, base64urlToBuffer, getCsrfToken } from './webauthn_utils'
|
||||
import type { WebAuthnChallengeData, ErrorResponse } from './types/webauthn'
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const addPasskeyBtn = document.getElementById('add-passkey-btn')
|
||||
const modal = document.getElementById('passkey-modal')
|
||||
const nameInput = document.getElementById('passkey-name-input') as HTMLInputElement | null
|
||||
const confirmBtn = document.getElementById('confirm-add-passkey') as HTMLButtonElement | null
|
||||
const cancelBtn = document.getElementById('cancel-add-passkey')
|
||||
const errorDiv = document.getElementById('passkey-error')
|
||||
|
||||
if (!addPasskeyBtn || !modal || !nameInput || !confirmBtn || !cancelBtn || !errorDiv) return
|
||||
|
||||
// Show modal when clicking "Add Passkey"
|
||||
addPasskeyBtn.addEventListener('click', () => {
|
||||
modal.classList.remove('hidden')
|
||||
nameInput.value = ''
|
||||
nameInput.focus()
|
||||
hideError()
|
||||
})
|
||||
|
||||
// Hide modal when clicking cancel or background
|
||||
const hideModal = () => {
|
||||
modal.classList.add('hidden')
|
||||
nameInput.value = ''
|
||||
hideError()
|
||||
}
|
||||
|
||||
cancelBtn.addEventListener('click', hideModal)
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal || (e.target as HTMLElement).classList.contains('bg-zinc-500')) {
|
||||
hideModal()
|
||||
}
|
||||
})
|
||||
|
||||
// Handle escape key
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && !modal.classList.contains('hidden')) {
|
||||
hideModal()
|
||||
}
|
||||
})
|
||||
|
||||
// Show error in modal
|
||||
const showError = (message: string) => {
|
||||
errorDiv.classList.remove('hidden')
|
||||
const errorText = errorDiv.querySelector('p')
|
||||
if (errorText) {
|
||||
errorText.textContent = message
|
||||
}
|
||||
}
|
||||
|
||||
const hideError = () => {
|
||||
errorDiv.classList.add('hidden')
|
||||
const errorText = errorDiv.querySelector('p')
|
||||
if (errorText) {
|
||||
errorText.textContent = ''
|
||||
}
|
||||
}
|
||||
|
||||
// Handle passkey registration
|
||||
const registerPasskey = async () => {
|
||||
const name = nameInput.value.trim()
|
||||
|
||||
if (!name) {
|
||||
showError('Please enter a name for this passkey')
|
||||
nameInput.focus()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
confirmBtn.disabled = true
|
||||
confirmBtn.textContent = 'Registering...'
|
||||
|
||||
// Fetch challenge from server
|
||||
const challengeResponse = await fetch('/api/webauthn/registration/challenge', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': getCsrfToken()
|
||||
}
|
||||
})
|
||||
|
||||
if (!challengeResponse.ok) {
|
||||
const error: ErrorResponse = await challengeResponse.json()
|
||||
showError(error.error || 'Failed to get registration challenge')
|
||||
return
|
||||
}
|
||||
|
||||
const challengeData: WebAuthnChallengeData = await challengeResponse.json()
|
||||
|
||||
// Convert challenge and user ID to ArrayBuffer
|
||||
const challenge = base64urlToBuffer(challengeData.challenge)
|
||||
const userId = base64urlToBuffer(challengeData.user.id)
|
||||
|
||||
const publicKeyCredentialCreationOptions: PublicKeyCredentialCreationOptions = {
|
||||
challenge,
|
||||
rp: challengeData.rp,
|
||||
user: {
|
||||
id: userId,
|
||||
name: challengeData.user.name,
|
||||
displayName: challengeData.user.displayName
|
||||
},
|
||||
pubKeyCredParams: challengeData.pubKeyCredParams as PublicKeyCredentialParameters[],
|
||||
authenticatorSelection: challengeData.authenticatorSelection,
|
||||
timeout: challengeData.timeout,
|
||||
attestation: (challengeData.attestation as AttestationConveyancePreference) || 'none'
|
||||
}
|
||||
|
||||
// Hide modal before showing authenticator prompt
|
||||
modal.classList.add('hidden')
|
||||
|
||||
// Create credential
|
||||
const credential = await navigator.credentials.create({
|
||||
publicKey: publicKeyCredentialCreationOptions
|
||||
}) as PublicKeyCredential
|
||||
|
||||
if (!credential.response || !(credential.response instanceof AuthenticatorAttestationResponse)) {
|
||||
throw new Error('Invalid credential response')
|
||||
}
|
||||
|
||||
const registrationData = {
|
||||
name: name,
|
||||
attestation: {
|
||||
id: credential.id,
|
||||
rawId: bufferToBase64url(credential.rawId),
|
||||
type: credential.type,
|
||||
response: {
|
||||
clientDataJSON: bufferToBase64url(credential.response.clientDataJSON),
|
||||
attestationObject: bufferToBase64url(credential.response.attestationObject)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send to server
|
||||
const registerResponse = await fetch('/api/webauthn/register', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': getCsrfToken()
|
||||
},
|
||||
body: JSON.stringify(registrationData)
|
||||
})
|
||||
|
||||
if (registerResponse.ok) {
|
||||
// Reload page to show new passkey
|
||||
window.location.reload()
|
||||
} else {
|
||||
const error: ErrorResponse = await registerResponse.json()
|
||||
modal.classList.remove('hidden')
|
||||
showError(error.error || 'Failed to register passkey')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error)
|
||||
|
||||
let errorMessage = 'Failed to register passkey'
|
||||
if ((error as Error).name === 'NotAllowedError') {
|
||||
errorMessage = 'Registration was cancelled or timed out'
|
||||
} else if ((error as Error).message) {
|
||||
errorMessage = (error as Error).message
|
||||
}
|
||||
|
||||
modal.classList.remove('hidden')
|
||||
showError(errorMessage)
|
||||
} finally {
|
||||
confirmBtn.disabled = false
|
||||
confirmBtn.textContent = 'Continue'
|
||||
}
|
||||
}
|
||||
|
||||
// Register on button click
|
||||
confirmBtn.addEventListener('click', registerPasskey)
|
||||
|
||||
// Register on Enter key
|
||||
nameInput.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
registerPasskey()
|
||||
}
|
||||
})
|
||||
})
|
||||
73
assets/js/types/webauthn.d.ts
vendored
73
assets/js/types/webauthn.d.ts
vendored
|
|
@ -1,73 +0,0 @@
|
|||
// Type definitions for WebAuthn/Passkey authentication
|
||||
|
||||
export interface WebAuthnChallengeData {
|
||||
challenge: string;
|
||||
rp: {
|
||||
name: string;
|
||||
id: string;
|
||||
};
|
||||
user: {
|
||||
id: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
};
|
||||
pubKeyCredParams: Array<{
|
||||
type: string;
|
||||
alg: number;
|
||||
}>;
|
||||
authenticatorSelection?: {
|
||||
authenticatorAttachment?: string;
|
||||
requireResidentKey?: boolean;
|
||||
residentKey?: string;
|
||||
userVerification?: string;
|
||||
};
|
||||
timeout: number;
|
||||
attestation?: string;
|
||||
}
|
||||
|
||||
export interface WebAuthnAuthChallengeData {
|
||||
challenge: string;
|
||||
rpId: string;
|
||||
allowCredentials: Array<{
|
||||
type: string;
|
||||
id: string;
|
||||
transports?: string[];
|
||||
}>;
|
||||
timeout: number;
|
||||
userVerification: string;
|
||||
}
|
||||
|
||||
export interface WebAuthnRegistrationResult {
|
||||
name: string;
|
||||
attestation: {
|
||||
id: string;
|
||||
rawId: string;
|
||||
type: string;
|
||||
response: {
|
||||
clientDataJSON: string;
|
||||
attestationObject: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface WebAuthnAuthenticationResult {
|
||||
assertion: {
|
||||
id: string;
|
||||
rawId: string;
|
||||
type: string;
|
||||
response: {
|
||||
clientDataJSON: string;
|
||||
authenticatorData: string;
|
||||
signature: string;
|
||||
userHandle: string | null;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface ErrorResponse {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface AuthSuccessResponse {
|
||||
redirect: string;
|
||||
}
|
||||
|
|
@ -1,216 +0,0 @@
|
|||
// WebAuthn utility functions for passkey authentication
|
||||
import { base64urlToBuffer, bufferToBase64url, getCsrfToken } from './webauthn_utils'
|
||||
import type {
|
||||
WebAuthnChallengeData,
|
||||
WebAuthnAuthChallengeData,
|
||||
WebAuthnRegistrationResult,
|
||||
WebAuthnAuthenticationResult,
|
||||
ErrorResponse
|
||||
} from './types/webauthn'
|
||||
import type { LiveViewHook } from './types/liveview'
|
||||
|
||||
const WebAuthn = {
|
||||
// Registration flow
|
||||
async register(challengeData: WebAuthnChallengeData, credentialName: string): Promise<WebAuthnRegistrationResult> {
|
||||
// Convert challenge and user ID to ArrayBuffer
|
||||
const challenge = base64urlToBuffer(challengeData.challenge)
|
||||
const userId = base64urlToBuffer(challengeData.user.id)
|
||||
|
||||
const publicKeyCredentialCreationOptions: PublicKeyCredentialCreationOptions = {
|
||||
challenge,
|
||||
rp: challengeData.rp,
|
||||
user: {
|
||||
id: userId,
|
||||
name: challengeData.user.name,
|
||||
displayName: challengeData.user.displayName
|
||||
},
|
||||
pubKeyCredParams: challengeData.pubKeyCredParams as PublicKeyCredentialParameters[],
|
||||
authenticatorSelection: challengeData.authenticatorSelection,
|
||||
timeout: challengeData.timeout,
|
||||
attestation: (challengeData.attestation as AttestationConveyancePreference) || 'none'
|
||||
}
|
||||
|
||||
try {
|
||||
const credential = await navigator.credentials.create({
|
||||
publicKey: publicKeyCredentialCreationOptions
|
||||
}) as PublicKeyCredential
|
||||
|
||||
if (!credential.response || !(credential.response instanceof AuthenticatorAttestationResponse)) {
|
||||
throw new Error('Invalid credential response')
|
||||
}
|
||||
|
||||
return {
|
||||
name: credentialName,
|
||||
attestation: {
|
||||
id: credential.id,
|
||||
rawId: bufferToBase64url(credential.rawId),
|
||||
type: credential.type,
|
||||
response: {
|
||||
clientDataJSON: bufferToBase64url(credential.response.clientDataJSON),
|
||||
attestationObject: bufferToBase64url(credential.response.attestationObject)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('WebAuthn registration error:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
// Authentication flow
|
||||
async authenticate(challengeData: WebAuthnAuthChallengeData): Promise<WebAuthnAuthenticationResult> {
|
||||
const challenge = base64urlToBuffer(challengeData.challenge)
|
||||
|
||||
const allowCredentials: PublicKeyCredentialDescriptor[] = challengeData.allowCredentials.map(cred => ({
|
||||
type: cred.type as PublicKeyCredentialType,
|
||||
id: base64urlToBuffer(cred.id),
|
||||
transports: cred.transports as AuthenticatorTransport[]
|
||||
}))
|
||||
|
||||
const publicKeyCredentialRequestOptions: PublicKeyCredentialRequestOptions = {
|
||||
challenge,
|
||||
rpId: challengeData.rpId,
|
||||
allowCredentials,
|
||||
timeout: challengeData.timeout,
|
||||
userVerification: challengeData.userVerification as UserVerificationRequirement
|
||||
}
|
||||
|
||||
try {
|
||||
const assertion = await navigator.credentials.get({
|
||||
publicKey: publicKeyCredentialRequestOptions
|
||||
}) as PublicKeyCredential
|
||||
|
||||
if (!assertion.response || !(assertion.response instanceof AuthenticatorAssertionResponse)) {
|
||||
throw new Error('Invalid assertion response')
|
||||
}
|
||||
|
||||
return {
|
||||
assertion: {
|
||||
id: assertion.id,
|
||||
rawId: bufferToBase64url(assertion.rawId),
|
||||
type: assertion.type,
|
||||
response: {
|
||||
clientDataJSON: bufferToBase64url(assertion.response.clientDataJSON),
|
||||
authenticatorData: bufferToBase64url(assertion.response.authenticatorData),
|
||||
signature: bufferToBase64url(assertion.response.signature),
|
||||
userHandle: assertion.response.userHandle ?
|
||||
bufferToBase64url(assertion.response.userHandle) : null
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('WebAuthn authentication error:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// LiveView Hook for Registration (in credential management page)
|
||||
export const WebAuthnRegister: LiveViewHook = {
|
||||
mounted() {
|
||||
this.handleEvent?.("start_registration", async ({ name }: { name: string }) => {
|
||||
try {
|
||||
// Fetch challenge from server
|
||||
const response = await fetch('/api/webauthn/registration/challenge', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': getCsrfToken()
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error: ErrorResponse = await response.json()
|
||||
this.pushEvent?.("registration_failed", { error: error.error || 'Failed to get registration challenge' })
|
||||
return
|
||||
}
|
||||
|
||||
const challengeData: WebAuthnChallengeData = await response.json()
|
||||
|
||||
// Perform WebAuthn registration
|
||||
const registrationData = await WebAuthn.register(challengeData, name)
|
||||
|
||||
// Send to server
|
||||
const registerResponse = await fetch('/api/webauthn/register', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': getCsrfToken()
|
||||
},
|
||||
body: JSON.stringify(registrationData)
|
||||
})
|
||||
|
||||
if (registerResponse.ok) {
|
||||
this.pushEvent?.("registration_success", {})
|
||||
} else {
|
||||
const error: ErrorResponse = await registerResponse.json()
|
||||
this.pushEvent?.("registration_failed", { error: error.error || 'Failed to register passkey' })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error)
|
||||
this.pushEvent?.("registration_failed", {
|
||||
error: (error as Error).name === 'NotAllowedError'
|
||||
? 'Registration was cancelled or timed out'
|
||||
: (error as Error).message || 'Failed to register passkey'
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// LiveView Hook for Authentication (on login page)
|
||||
export const WebAuthnLogin: LiveViewHook = {
|
||||
mounted() {
|
||||
this.handleEvent?.("start_authentication", async ({ email }: { email: string }) => {
|
||||
try {
|
||||
// Fetch challenge from server
|
||||
const response = await fetch('/api/webauthn/authentication/challenge', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': getCsrfToken()
|
||||
},
|
||||
body: JSON.stringify({ email })
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error: ErrorResponse = await response.json()
|
||||
this.pushEvent?.("authentication_failed", {
|
||||
error: error.error || 'No passkeys found for this email'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const challengeData: WebAuthnAuthChallengeData = await response.json()
|
||||
|
||||
// Perform WebAuthn authentication
|
||||
const authData = await WebAuthn.authenticate(challengeData)
|
||||
|
||||
// Send to server
|
||||
const authResponse = await fetch('/api/webauthn/authenticate', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': getCsrfToken()
|
||||
},
|
||||
body: JSON.stringify(authData)
|
||||
})
|
||||
|
||||
if (authResponse.ok) {
|
||||
const result = await authResponse.json()
|
||||
// Redirect to the provided URL
|
||||
window.location.href = result.redirect
|
||||
} else {
|
||||
this.pushEvent?.("authentication_failed", { error: 'Authentication failed' })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Authentication error:', error)
|
||||
this.pushEvent?.("authentication_failed", {
|
||||
error: (error as Error).name === 'NotAllowedError'
|
||||
? 'Authentication was cancelled or timed out'
|
||||
: (error as Error).message || 'Failed to authenticate'
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
// WebAuthn utility functions
|
||||
|
||||
// Convert Base64URL string to ArrayBuffer
|
||||
export function base64urlToBuffer(base64url: string): ArrayBuffer {
|
||||
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padLen = (4 - (base64.length % 4)) % 4
|
||||
const padded = base64 + '='.repeat(padLen)
|
||||
const binary = atob(padded)
|
||||
const buffer = new ArrayBuffer(binary.length)
|
||||
const bytes = new Uint8Array(buffer)
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i)
|
||||
}
|
||||
return buffer
|
||||
}
|
||||
|
||||
// Convert ArrayBuffer to Base64URL string
|
||||
export function bufferToBase64url(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer)
|
||||
let binary = ''
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i])
|
||||
}
|
||||
return btoa(binary)
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=/g, '')
|
||||
}
|
||||
|
||||
// Get CSRF token from meta tag
|
||||
export function getCsrfToken(): string {
|
||||
const metaTag = document.querySelector<HTMLMetaElement>("meta[name='csrf-token']")
|
||||
if (!metaTag) {
|
||||
throw new Error('CSRF token meta tag not found')
|
||||
}
|
||||
return metaTag.getAttribute('content') || ''
|
||||
}
|
||||
|
|
@ -136,9 +136,4 @@ config :towerops,
|
|||
ecto_repos: [Towerops.Repo],
|
||||
generators: [timestamp_type: :utc_datetime, binary_id: true]
|
||||
|
||||
# WebAuthn / Passkey Configuration
|
||||
config :towerops,
|
||||
webauthn_rp_id: "localhost",
|
||||
webauthn_origin: "http://localhost:4000"
|
||||
|
||||
import_config "#{config_env()}.exs"
|
||||
|
|
|
|||
|
|
@ -11,10 +11,8 @@ defmodule Towerops.Accounts do
|
|||
alias Towerops.Accounts.User
|
||||
alias Towerops.Accounts.UserAgentParser
|
||||
alias Towerops.Accounts.UserConsent
|
||||
alias Towerops.Accounts.UserCredential
|
||||
alias Towerops.Accounts.UserNotifier
|
||||
alias Towerops.Accounts.UserToken
|
||||
alias Towerops.Accounts.WebAuthn
|
||||
alias Towerops.GeoIP
|
||||
alias Towerops.Repo
|
||||
|
||||
|
|
@ -592,216 +590,6 @@ defmodule Towerops.Accounts do
|
|||
end)
|
||||
end
|
||||
|
||||
## WebAuthn / Passkey Authentication
|
||||
|
||||
@doc """
|
||||
Checks if a user is allowed to register passkeys.
|
||||
Requires the user's email to be confirmed.
|
||||
"""
|
||||
def passkey_registration_allowed?(%User{confirmed_at: nil}), do: false
|
||||
def passkey_registration_allowed?(%User{}), do: true
|
||||
|
||||
@doc """
|
||||
Lists all credentials for a user.
|
||||
"""
|
||||
def list_user_credentials(user_id) do
|
||||
Repo.all(from c in UserCredential, where: c.user_id == ^user_id, order_by: [desc: c.last_used_at])
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a credential by its credential_id (the WebAuthn credential identifier).
|
||||
"""
|
||||
def get_credential_by_credential_id(credential_id) do
|
||||
Repo.get_by(UserCredential, credential_id: credential_id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generates a WebAuthn registration challenge for a user.
|
||||
|
||||
Returns challenge data that should be sent to the client and stored
|
||||
temporarily in the session.
|
||||
"""
|
||||
def generate_registration_challenge(user) do
|
||||
challenge = :crypto.strong_rand_bytes(32)
|
||||
challenge_base64 = Base.url_encode64(challenge, padding: false)
|
||||
|
||||
%{
|
||||
challenge: challenge_base64,
|
||||
user: %{
|
||||
id: Base.url_encode64(user.id, padding: false),
|
||||
name: user.email,
|
||||
displayName: user.email
|
||||
},
|
||||
rp: %{
|
||||
name: "Towerops",
|
||||
id: get_rp_id()
|
||||
},
|
||||
timeout: 60_000,
|
||||
attestation: "none",
|
||||
pubKeyCredParams: [
|
||||
%{type: "public-key", alg: -7},
|
||||
%{type: "public-key", alg: -257}
|
||||
],
|
||||
authenticatorSelection: %{
|
||||
authenticatorAttachment: "platform",
|
||||
requireResidentKey: false,
|
||||
residentKey: "preferred",
|
||||
userVerification: "preferred"
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generates a WebAuthn authentication challenge for a user.
|
||||
|
||||
Returns challenge data with allowed credentials that should be sent
|
||||
to the client.
|
||||
"""
|
||||
def generate_authentication_challenge(user) do
|
||||
challenge = :crypto.strong_rand_bytes(32)
|
||||
challenge_base64 = Base.url_encode64(challenge, padding: false)
|
||||
|
||||
credentials = list_user_credentials(user.id)
|
||||
|
||||
%{
|
||||
challenge: challenge_base64,
|
||||
timeout: 60_000,
|
||||
rpId: get_rp_id(),
|
||||
allowCredentials:
|
||||
Enum.map(credentials, fn cred ->
|
||||
%{
|
||||
type: "public-key",
|
||||
id: Base.url_encode64(cred.credential_id, padding: false),
|
||||
transports: cred.transports
|
||||
}
|
||||
end),
|
||||
userVerification: "preferred"
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generates a WebAuthn authentication challenge for discoverable credentials.
|
||||
|
||||
This allows usernameless authentication where the authenticator presents
|
||||
available credentials for the RP ID.
|
||||
"""
|
||||
def generate_discoverable_authentication_challenge do
|
||||
challenge = :crypto.strong_rand_bytes(32)
|
||||
challenge_base64 = Base.url_encode64(challenge, padding: false)
|
||||
|
||||
%{
|
||||
challenge: challenge_base64,
|
||||
timeout: 60_000,
|
||||
rpId: get_rp_id(),
|
||||
# Empty allowCredentials allows any credential for this RP ID
|
||||
allowCredentials: [],
|
||||
userVerification: "preferred"
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Registers a new WebAuthn credential for a user.
|
||||
|
||||
Verifies the attestation response and creates the credential record.
|
||||
"""
|
||||
def register_credential(user, params, challenge) do
|
||||
origin = get_origin()
|
||||
rp_id = get_rp_id()
|
||||
|
||||
with {:ok, _} <- verify_passkey_registration_allowed(user),
|
||||
{:ok, credential_data} <-
|
||||
WebAuthn.verify_attestation(
|
||||
params["attestation"]["response"],
|
||||
challenge,
|
||||
origin,
|
||||
rp_id
|
||||
) do
|
||||
create_credential(user, credential_data, params["name"])
|
||||
end
|
||||
end
|
||||
|
||||
defp verify_passkey_registration_allowed(user) do
|
||||
if passkey_registration_allowed?(user) do
|
||||
{:ok, :allowed}
|
||||
else
|
||||
{:error, :email_not_confirmed}
|
||||
end
|
||||
end
|
||||
|
||||
defp create_credential(user, credential_data, name) do
|
||||
%UserCredential{}
|
||||
|> UserCredential.changeset(%{
|
||||
user_id: user.id,
|
||||
credential_id: credential_data.credential_id,
|
||||
public_key: credential_data.public_key,
|
||||
sign_count: credential_data.sign_count,
|
||||
name: name || "Passkey",
|
||||
aaguid: credential_data.aaguid,
|
||||
attestation_format: credential_data.attestation_format
|
||||
})
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Authenticates a user using a WebAuthn credential.
|
||||
|
||||
Verifies the assertion response and returns the user if valid.
|
||||
"""
|
||||
def authenticate_with_credential(params, challenge) do
|
||||
origin = get_origin()
|
||||
rp_id = get_rp_id()
|
||||
|
||||
with {:ok, credential_id_base64} <- Map.fetch(params["assertion"], "rawId"),
|
||||
{:ok, credential_id} <- Base.url_decode64(credential_id_base64, padding: false),
|
||||
%UserCredential{} = credential <- get_credential_by_credential_id(credential_id),
|
||||
{:ok, new_sign_count} <-
|
||||
WebAuthn.verify_assertion(
|
||||
params["assertion"]["response"],
|
||||
challenge,
|
||||
origin,
|
||||
rp_id,
|
||||
credential.public_key,
|
||||
credential.sign_count
|
||||
),
|
||||
{:ok, _credential} <- update_credential_usage(credential, new_sign_count) do
|
||||
user = Repo.get!(User, credential.user_id)
|
||||
{:ok, user}
|
||||
else
|
||||
:error -> {:error, :invalid_credential}
|
||||
nil -> {:error, :invalid_credential}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp update_credential_usage(credential, new_sign_count) do
|
||||
credential
|
||||
|> Ecto.Changeset.change(%{
|
||||
last_used_at: DateTime.utc_now(:second),
|
||||
sign_count: new_sign_count
|
||||
})
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes a credential.
|
||||
|
||||
Only allows deletion if the credential belongs to the specified user.
|
||||
"""
|
||||
def delete_credential(credential_id, user_id) do
|
||||
case Repo.get_by(UserCredential, id: credential_id, user_id: user_id) do
|
||||
nil -> {:error, :not_found}
|
||||
credential -> Repo.delete(credential)
|
||||
end
|
||||
end
|
||||
|
||||
defp get_origin do
|
||||
Application.get_env(:towerops, :webauthn_origin, "http://localhost:4000")
|
||||
end
|
||||
|
||||
defp get_rp_id do
|
||||
Application.get_env(:towerops, :webauthn_rp_id, "localhost")
|
||||
end
|
||||
|
||||
## User Consent
|
||||
|
||||
@doc """
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ defmodule Towerops.Accounts.User do
|
|||
|
||||
Users can own or be members of multiple organizations and authenticate via:
|
||||
- Email/password
|
||||
- WebAuthn passkeys
|
||||
- TOTP (Time-based One-Time Password) two-factor authentication
|
||||
- Session tokens
|
||||
"""
|
||||
|
|
@ -13,7 +12,6 @@ defmodule Towerops.Accounts.User do
|
|||
import Ecto.Changeset
|
||||
|
||||
alias Ecto.Association.NotLoaded
|
||||
alias Towerops.Accounts.UserCredential
|
||||
alias Towerops.Organizations.Membership
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
|
|
@ -34,7 +32,6 @@ defmodule Towerops.Accounts.User do
|
|||
|
||||
has_many :memberships, Membership
|
||||
has_many :organizations, through: [:memberships, :organization]
|
||||
has_many :credentials, UserCredential
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
|
@ -53,7 +50,6 @@ defmodule Towerops.Accounts.User do
|
|||
totp_verified_at: DateTime.t() | nil,
|
||||
memberships: NotLoaded.t() | [Membership.t()],
|
||||
organizations: NotLoaded.t() | [Towerops.Organizations.Organization.t()],
|
||||
credentials: NotLoaded.t() | [UserCredential.t()],
|
||||
inserted_at: DateTime.t(),
|
||||
updated_at: DateTime.t()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,85 +0,0 @@
|
|||
defmodule Towerops.Accounts.UserCredential do
|
||||
@moduledoc """
|
||||
Represents a WebAuthn/passkey credential for a user.
|
||||
|
||||
Each credential corresponds to a specific authenticator (device) that
|
||||
the user has registered for passwordless authentication.
|
||||
"""
|
||||
use Ecto.Schema
|
||||
|
||||
import Ecto.Changeset
|
||||
|
||||
alias Towerops.Accounts.User
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
|
||||
schema "user_credentials" do
|
||||
field :credential_id, :binary
|
||||
field :public_key, :binary
|
||||
field :sign_count, :integer, default: 0
|
||||
field :name, :string
|
||||
field :last_used_at, :utc_datetime
|
||||
field :aaguid, :binary
|
||||
field :transports, {:array, :string}, default: []
|
||||
field :backup_eligible, :boolean, default: false
|
||||
field :backup_state, :boolean, default: false
|
||||
field :attestation_format, :string
|
||||
|
||||
belongs_to :user, User
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
id: Ecto.UUID.t(),
|
||||
credential_id: binary(),
|
||||
public_key: binary(),
|
||||
sign_count: integer(),
|
||||
name: String.t(),
|
||||
last_used_at: DateTime.t() | nil,
|
||||
aaguid: binary(),
|
||||
transports: [String.t()],
|
||||
backup_eligible: boolean(),
|
||||
backup_state: boolean(),
|
||||
attestation_format: String.t() | nil,
|
||||
user_id: Ecto.UUID.t(),
|
||||
user: Ecto.Association.NotLoaded.t() | User.t(),
|
||||
inserted_at: DateTime.t(),
|
||||
updated_at: DateTime.t()
|
||||
}
|
||||
|
||||
@doc """
|
||||
Changeset for creating or updating a credential.
|
||||
"""
|
||||
def changeset(credential, attrs) do
|
||||
credential
|
||||
|> cast(attrs, [
|
||||
:user_id,
|
||||
:credential_id,
|
||||
:public_key,
|
||||
:sign_count,
|
||||
:name,
|
||||
:last_used_at,
|
||||
:aaguid,
|
||||
:transports,
|
||||
:backup_eligible,
|
||||
:backup_state,
|
||||
:attestation_format
|
||||
])
|
||||
|> validate_required([:user_id, :credential_id, :public_key, :name])
|
||||
|> validate_length(:name, min: 1, max: 100)
|
||||
|> unique_constraint(:credential_id)
|
||||
|> foreign_key_constraint(:user_id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Changeset for updating credential usage tracking.
|
||||
"""
|
||||
def touch_changeset(credential) do
|
||||
change(credential, %{
|
||||
last_used_at: DateTime.utc_now(:second),
|
||||
sign_count: credential.sign_count + 1
|
||||
})
|
||||
end
|
||||
end
|
||||
|
|
@ -1,337 +0,0 @@
|
|||
defmodule Towerops.Accounts.WebAuthn do
|
||||
@moduledoc """
|
||||
WebAuthn verification for passkey authentication.
|
||||
|
||||
This module handles registration (attestation) and authentication (assertion)
|
||||
verification for WebAuthn/FIDO2 credentials using CBOR decoding and
|
||||
standard Erlang crypto libraries.
|
||||
"""
|
||||
|
||||
import Bitwise
|
||||
|
||||
require Logger
|
||||
|
||||
@doc """
|
||||
Verifies a WebAuthn registration (attestation) response.
|
||||
|
||||
Returns `{:ok, credential_data}` with the parsed credential information,
|
||||
or `{:error, reason}` if verification fails.
|
||||
"""
|
||||
def verify_attestation(attestation, challenge, origin, rp_id) do
|
||||
with {:ok, client_data} <- decode_client_data(attestation["clientDataJSON"]),
|
||||
:ok <- verify_client_data_type(client_data, "webauthn.create"),
|
||||
:ok <- verify_challenge(client_data, challenge),
|
||||
:ok <- verify_origin(client_data, origin),
|
||||
{:ok, attestation_object} <- decode_attestation_object(attestation["attestationObject"]),
|
||||
{:ok, auth_data} <- parse_authenticator_data(attestation_object["authData"]),
|
||||
:ok <- verify_rp_id_hash(auth_data.rp_id_hash, rp_id),
|
||||
:ok <- verify_user_present(auth_data.flags),
|
||||
{:ok, credential_data} <- extract_credential_data(auth_data) do
|
||||
{:ok,
|
||||
%{
|
||||
credential_id: credential_data.credential_id,
|
||||
public_key: credential_data.public_key,
|
||||
aaguid: auth_data.aaguid,
|
||||
sign_count: auth_data.sign_count,
|
||||
attestation_format: attestation_object["fmt"]
|
||||
}}
|
||||
else
|
||||
{:error, reason} = error ->
|
||||
Logger.error("WebAuthn attestation verification failed: #{inspect(reason)}")
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Verifies a WebAuthn authentication (assertion) response.
|
||||
|
||||
Returns `{:ok, sign_count}` if verification succeeds,
|
||||
or `{:error, reason}` if verification fails.
|
||||
"""
|
||||
def verify_assertion(assertion, challenge, origin, rp_id, public_key, stored_sign_count) do
|
||||
with {:ok, client_data} <- decode_client_data(assertion["clientDataJSON"]),
|
||||
:ok <- verify_client_data_type(client_data, "webauthn.get"),
|
||||
:ok <- verify_challenge(client_data, challenge),
|
||||
:ok <- verify_origin(client_data, origin),
|
||||
{:ok, auth_data_bytes} <- decode_base64url(assertion["authenticatorData"]),
|
||||
{:ok, auth_data} <- parse_authenticator_data(auth_data_bytes),
|
||||
:ok <- verify_rp_id_hash(auth_data.rp_id_hash, rp_id),
|
||||
:ok <- verify_user_present(auth_data.flags),
|
||||
:ok <- verify_sign_count(auth_data.sign_count, stored_sign_count),
|
||||
{:ok, signature} <- decode_base64url(assertion["signature"]),
|
||||
{:ok, client_data_json} <- decode_base64url(assertion["clientDataJSON"]),
|
||||
:ok <- verify_signature(auth_data_bytes, client_data_json, signature, public_key) do
|
||||
{:ok, auth_data.sign_count}
|
||||
else
|
||||
{:error, reason} = error ->
|
||||
Logger.error("WebAuthn assertion verification failed: #{inspect(reason)}")
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
# Private functions
|
||||
|
||||
defp decode_client_data(base64_client_data) do
|
||||
with {:ok, json_bytes} <- decode_base64url(base64_client_data),
|
||||
{:ok, client_data} <- Jason.decode(json_bytes) do
|
||||
{:ok, client_data}
|
||||
else
|
||||
{:error, reason} = error ->
|
||||
Logger.error("Failed to decode client data: #{inspect(reason)}")
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
defp decode_attestation_object(base64_attestation) do
|
||||
with {:ok, cbor_bytes} <- decode_base64url(base64_attestation),
|
||||
{:ok, attestation_object, _} <- CBOR.decode(cbor_bytes) do
|
||||
{:ok, attestation_object}
|
||||
else
|
||||
_ -> {:error, :invalid_attestation_object}
|
||||
end
|
||||
end
|
||||
|
||||
defp decode_base64url(base64_string) do
|
||||
decoded = Base.url_decode64!(base64_string, padding: false)
|
||||
{:ok, decoded}
|
||||
rescue
|
||||
_ -> {:error, :invalid_base64}
|
||||
end
|
||||
|
||||
defp verify_client_data_type(client_data, expected_type) do
|
||||
received_type = client_data["type"]
|
||||
|
||||
if received_type == expected_type do
|
||||
:ok
|
||||
else
|
||||
Logger.error("Client data type mismatch. Expected: #{inspect(expected_type)}, Received: #{inspect(received_type)}")
|
||||
|
||||
{:error, :invalid_client_data_type}
|
||||
end
|
||||
end
|
||||
|
||||
defp verify_challenge(client_data, expected_challenge) do
|
||||
received_challenge = client_data["challenge"]
|
||||
|
||||
if received_challenge == expected_challenge do
|
||||
:ok
|
||||
else
|
||||
Logger.error(
|
||||
"Challenge mismatch. Expected: #{inspect(expected_challenge)}, Received: #{inspect(received_challenge)}"
|
||||
)
|
||||
|
||||
{:error, :challenge_mismatch}
|
||||
end
|
||||
end
|
||||
|
||||
defp verify_origin(client_data, expected_origin) do
|
||||
received_origin = client_data["origin"]
|
||||
|
||||
if received_origin == expected_origin do
|
||||
:ok
|
||||
else
|
||||
Logger.error("Origin mismatch. Expected: #{inspect(expected_origin)}, Received: #{inspect(received_origin)}")
|
||||
|
||||
{:error, :origin_mismatch}
|
||||
end
|
||||
end
|
||||
|
||||
defp verify_rp_id_hash(rp_id_hash, rp_id) do
|
||||
expected_hash = :crypto.hash(:sha256, rp_id)
|
||||
|
||||
if rp_id_hash == expected_hash do
|
||||
:ok
|
||||
else
|
||||
{:error, :rp_id_mismatch}
|
||||
end
|
||||
end
|
||||
|
||||
defp verify_user_present(flags) do
|
||||
# Bit 0: User Present (UP)
|
||||
user_present = (flags &&& 0x01) != 0
|
||||
|
||||
if user_present do
|
||||
:ok
|
||||
else
|
||||
{:error, :user_not_present}
|
||||
end
|
||||
end
|
||||
|
||||
defp verify_sign_count(new_count, stored_count) do
|
||||
cond do
|
||||
new_count == 0 and stored_count == 0 ->
|
||||
# Both zero is acceptable (authenticator doesn't support counters)
|
||||
:ok
|
||||
|
||||
new_count > stored_count ->
|
||||
# Counter increased as expected
|
||||
:ok
|
||||
|
||||
true ->
|
||||
# Counter decreased or didn't increase - possible cloned authenticator
|
||||
{:error, :invalid_sign_count}
|
||||
end
|
||||
end
|
||||
|
||||
defp verify_signature(auth_data_bytes, client_data_json, signature, public_key) do
|
||||
# Construct the signed data: authenticator data || SHA-256(client data JSON)
|
||||
client_data_hash = :crypto.hash(:sha256, client_data_json)
|
||||
signed_data = auth_data_bytes <> client_data_hash
|
||||
|
||||
# Parse the public key and verify signature
|
||||
case parse_public_key(public_key) do
|
||||
{:ok, parsed_key} ->
|
||||
if :public_key.verify(signed_data, :sha256, signature, parsed_key) do
|
||||
:ok
|
||||
else
|
||||
{:error, :signature_verification_failed}
|
||||
end
|
||||
|
||||
{:error, _} = error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
# Handle CBOR.Tag wrapped authenticator data
|
||||
defp parse_authenticator_data(%CBOR.Tag{tag: :bytes, value: binary_data}) do
|
||||
parse_authenticator_data(binary_data)
|
||||
end
|
||||
|
||||
defp parse_authenticator_data(auth_data) when is_binary(auth_data) do
|
||||
# Authenticator data structure:
|
||||
# - 32 bytes: RP ID hash
|
||||
# - 1 byte: Flags
|
||||
# - 4 bytes: Sign count (big-endian)
|
||||
# - Variable: Attested credential data (if present)
|
||||
# - Variable: Extensions (if present)
|
||||
|
||||
case auth_data do
|
||||
<<rp_id_hash::binary-size(32), flags::8, sign_count::32, rest::binary>> ->
|
||||
# Check if attested credential data is present (bit 6 of flags)
|
||||
attested_credential_present = (flags &&& 0x40) != 0
|
||||
|
||||
if attested_credential_present do
|
||||
parse_attested_credential_data(rest, rp_id_hash, flags, sign_count)
|
||||
else
|
||||
{:ok,
|
||||
%{
|
||||
rp_id_hash: rp_id_hash,
|
||||
flags: flags,
|
||||
sign_count: sign_count,
|
||||
aaguid: nil,
|
||||
credential_data: nil
|
||||
}}
|
||||
end
|
||||
|
||||
_ ->
|
||||
{:error, :invalid_authenticator_data}
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_attested_credential_data(data, rp_id_hash, flags, sign_count) do
|
||||
# Attested credential data structure:
|
||||
# - 16 bytes: AAGUID
|
||||
# - 2 bytes: Credential ID length (big-endian)
|
||||
# - Variable: Credential ID
|
||||
# - Variable: Credential public key (CBOR-encoded COSE_Key)
|
||||
|
||||
with <<aaguid::binary-size(16), cred_id_length::16, rest::binary>> <- data,
|
||||
true <- byte_size(rest) >= cred_id_length,
|
||||
<<credential_id::binary-size(^cred_id_length), public_key_bytes::binary>> <- rest,
|
||||
{:ok, cose_key, remaining} <- CBOR.decode(public_key_bytes),
|
||||
# Calculate the actual length of the COSE key CBOR data
|
||||
public_key_cbor_length = byte_size(public_key_bytes) - byte_size(remaining),
|
||||
<<public_key_cbor::binary-size(^public_key_cbor_length), _::binary>> <-
|
||||
public_key_bytes do
|
||||
{:ok,
|
||||
%{
|
||||
rp_id_hash: rp_id_hash,
|
||||
flags: flags,
|
||||
sign_count: sign_count,
|
||||
aaguid: aaguid,
|
||||
credential_data: %{
|
||||
credential_id: credential_id,
|
||||
public_key: public_key_cbor,
|
||||
cose_key: cose_key
|
||||
}
|
||||
}}
|
||||
else
|
||||
false -> {:error, :invalid_credential_id_length}
|
||||
{:error, _} -> {:error, :invalid_public_key}
|
||||
_ -> {:error, :invalid_attested_credential_data}
|
||||
end
|
||||
end
|
||||
|
||||
defp extract_credential_data(%{credential_data: nil}) do
|
||||
{:error, :no_credential_data}
|
||||
end
|
||||
|
||||
defp extract_credential_data(%{credential_data: credential_data}) do
|
||||
# The public_key is already CBOR-encoded, ready for storage
|
||||
{:ok,
|
||||
%{
|
||||
credential_id: credential_data.credential_id,
|
||||
public_key: credential_data.public_key
|
||||
}}
|
||||
end
|
||||
|
||||
defp parse_public_key(public_key_binary) do
|
||||
# Decode the stored COSE key
|
||||
case CBOR.decode(public_key_binary) do
|
||||
{:ok, cose_key, _} ->
|
||||
cose_key_to_erlang_key(cose_key)
|
||||
|
||||
_ ->
|
||||
{:error, :invalid_stored_public_key}
|
||||
end
|
||||
end
|
||||
|
||||
defp cose_key_to_erlang_key(cose_key) do
|
||||
# COSE key format (CBOR map):
|
||||
# 1 (kty): Key type (2 = EC2, 3 = RSA)
|
||||
# 3 (alg): Algorithm (-7 = ES256, -257 = RS256)
|
||||
# -1 (crv): EC2 curve (1 = P-256)
|
||||
# -2 (x): EC2 x-coordinate
|
||||
# -3 (y): EC2 y-coordinate
|
||||
# For RSA: -1 (n): modulus, -2 (e): exponent
|
||||
|
||||
kty = Map.get(cose_key, 1)
|
||||
alg = Map.get(cose_key, 3)
|
||||
|
||||
case {kty, alg} do
|
||||
# EC2 with ES256 (P-256)
|
||||
{2, -7} ->
|
||||
x = Map.get(cose_key, -2)
|
||||
y = Map.get(cose_key, -3)
|
||||
|
||||
if x && y do
|
||||
# Convert to Erlang public key format
|
||||
# EC point in uncompressed format: 0x04 || x || y
|
||||
point = <<0x04, x::binary, y::binary>>
|
||||
|
||||
ec_params = {:namedCurve, {1, 2, 840, 10_045, 3, 1, 7}}
|
||||
{:ok, {:ECPoint, point, ec_params}}
|
||||
else
|
||||
{:error, :invalid_ec_key}
|
||||
end
|
||||
|
||||
# RSA with RS256
|
||||
{3, -257} ->
|
||||
n = Map.get(cose_key, -1)
|
||||
e = Map.get(cose_key, -2)
|
||||
|
||||
if n && e do
|
||||
# Convert binary to integer
|
||||
n_int = :binary.decode_unsigned(n)
|
||||
e_int = :binary.decode_unsigned(e)
|
||||
|
||||
{:ok, {:RSAPublicKey, n_int, e_int}}
|
||||
else
|
||||
{:error, :invalid_rsa_key}
|
||||
end
|
||||
|
||||
_ ->
|
||||
{:error, :unsupported_key_type}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -4,7 +4,6 @@ defmodule ToweropsWeb.Api.AccountDataController do
|
|||
"""
|
||||
use ToweropsWeb, :controller
|
||||
|
||||
alias Towerops.Accounts
|
||||
alias Towerops.Admin
|
||||
alias Towerops.Admin.AuditLogger
|
||||
alias Towerops.Alerts
|
||||
|
|
@ -23,7 +22,6 @@ defmodule ToweropsWeb.Api.AccountDataController do
|
|||
# Gather all user data
|
||||
data = %{
|
||||
profile: build_profile_data(user),
|
||||
credentials: build_credentials_data(user),
|
||||
organizations: build_organizations_data(user),
|
||||
devices: build_devices_data(user),
|
||||
alerts: build_alerts_data(user),
|
||||
|
|
@ -58,21 +56,6 @@ defmodule ToweropsWeb.Api.AccountDataController do
|
|||
}
|
||||
end
|
||||
|
||||
# Build credentials data
|
||||
defp build_credentials_data(user) do
|
||||
user.id
|
||||
|> Accounts.list_user_credentials()
|
||||
|> Enum.map(fn credential ->
|
||||
%{
|
||||
id: credential.id,
|
||||
name: credential.name,
|
||||
public_key: Base.encode64(credential.public_key),
|
||||
last_used_at: credential.last_used_at,
|
||||
inserted_at: credential.inserted_at
|
||||
}
|
||||
end)
|
||||
end
|
||||
|
||||
# Build organizations data
|
||||
defp build_organizations_data(user) do
|
||||
user.id
|
||||
|
|
|
|||
|
|
@ -1,206 +0,0 @@
|
|||
defmodule ToweropsWeb.UserCredentialController do
|
||||
@moduledoc false
|
||||
|
||||
use ToweropsWeb, :controller
|
||||
|
||||
alias Towerops.Accounts
|
||||
alias Towerops.Organizations
|
||||
|
||||
@doc """
|
||||
Generates a WebAuthn registration challenge.
|
||||
Returns challenge data for the browser to use during credential creation.
|
||||
"""
|
||||
def registration_challenge(conn, _params) do
|
||||
user = conn.assigns.current_scope.user
|
||||
|
||||
if Accounts.passkey_registration_allowed?(user) do
|
||||
challenge_data = Accounts.generate_registration_challenge(user)
|
||||
|
||||
conn
|
||||
|> put_session(:webauthn_challenge, challenge_data.challenge)
|
||||
|> json(challenge_data)
|
||||
else
|
||||
conn
|
||||
|> put_status(:forbidden)
|
||||
|> json(%{error: "Email confirmation required before registering passkeys"})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Completes WebAuthn registration.
|
||||
Receives attestation from the browser and creates the credential.
|
||||
"""
|
||||
def register(conn, params) do
|
||||
user = conn.assigns.current_scope.user
|
||||
challenge = get_session(conn, :webauthn_challenge)
|
||||
|
||||
case Accounts.register_credential(user, params, challenge) do
|
||||
{:ok, credential} ->
|
||||
conn
|
||||
|> delete_session(:webauthn_challenge)
|
||||
|> put_status(:created)
|
||||
|> json(%{
|
||||
id: credential.id,
|
||||
name: credential.name,
|
||||
created_at: credential.inserted_at
|
||||
})
|
||||
|
||||
{:error, :email_not_confirmed} ->
|
||||
conn
|
||||
|> put_status(:forbidden)
|
||||
|> json(%{error: "Email confirmation required"})
|
||||
|
||||
{:error, _reason} ->
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(%{error: "Failed to register passkey"})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generates a WebAuthn authentication challenge.
|
||||
Returns challenge data for the browser to use during authentication.
|
||||
Supports both discoverable credentials (no email) and account-specific (with email).
|
||||
"""
|
||||
def authentication_challenge(conn, params) do
|
||||
case params do
|
||||
%{"email" => email} when is_binary(email) ->
|
||||
# Account-specific authentication
|
||||
handle_email_authentication_challenge(conn, email)
|
||||
|
||||
_ ->
|
||||
# Discoverable credential authentication (usernameless)
|
||||
handle_discoverable_authentication_challenge(conn)
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_email_authentication_challenge(conn, email) do
|
||||
case Accounts.get_user_by_email(email) do
|
||||
nil ->
|
||||
# Don't reveal if user exists
|
||||
conn
|
||||
|> put_status(:not_found)
|
||||
|> json(%{error: "No passkeys found for this email"})
|
||||
|
||||
user ->
|
||||
credentials = Accounts.list_user_credentials(user.id)
|
||||
|
||||
if Enum.empty?(credentials) do
|
||||
conn
|
||||
|> put_status(:not_found)
|
||||
|> json(%{error: "No passkeys found for this email"})
|
||||
else
|
||||
challenge_data = Accounts.generate_authentication_challenge(user)
|
||||
|
||||
conn
|
||||
|> put_session(:webauthn_challenge, challenge_data.challenge)
|
||||
|> put_session(:webauthn_user_id, user.id)
|
||||
|> json(challenge_data)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_discoverable_authentication_challenge(conn) do
|
||||
# For discoverable credentials, we don't need to know the user ahead of time
|
||||
# The authenticator will present credentials for this RP ID
|
||||
challenge_data = Accounts.generate_discoverable_authentication_challenge()
|
||||
|
||||
conn
|
||||
|> put_session(:webauthn_challenge, challenge_data.challenge)
|
||||
|> json(challenge_data)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Completes WebAuthn authentication.
|
||||
Verifies the assertion and logs the user in.
|
||||
Supports both discoverable and account-specific authentication.
|
||||
"""
|
||||
def authenticate(conn, params) do
|
||||
challenge = get_session(conn, :webauthn_challenge)
|
||||
expected_user_id = get_session(conn, :webauthn_user_id)
|
||||
|
||||
cond do
|
||||
is_nil(challenge) ->
|
||||
conn
|
||||
|> put_status(:unauthorized)
|
||||
|> json(%{error: "Authentication failed"})
|
||||
|
||||
# Discoverable credential (no expected user)
|
||||
is_nil(expected_user_id) ->
|
||||
handle_discoverable_authentication(conn, params, challenge)
|
||||
|
||||
# Account-specific authentication
|
||||
true ->
|
||||
handle_account_authentication(conn, params, challenge, expected_user_id)
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_discoverable_authentication(conn, params, challenge) do
|
||||
case Accounts.authenticate_with_credential(params, challenge) do
|
||||
{:ok, user} ->
|
||||
conn
|
||||
|> delete_session(:webauthn_challenge)
|
||||
|> put_status(:ok)
|
||||
|> json(%{success: true, redirect: get_post_login_path(user)})
|
||||
|
||||
{:error, _reason} ->
|
||||
conn
|
||||
|> put_status(:unauthorized)
|
||||
|> json(%{error: "Authentication failed"})
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_account_authentication(conn, params, challenge, expected_user_id) do
|
||||
with {:ok, user} <- Accounts.authenticate_with_credential(params, challenge),
|
||||
true <- user.id == expected_user_id do
|
||||
conn
|
||||
|> delete_session(:webauthn_challenge)
|
||||
|> delete_session(:webauthn_user_id)
|
||||
|> put_status(:ok)
|
||||
|> json(%{success: true, redirect: get_post_login_path(user)})
|
||||
else
|
||||
false ->
|
||||
conn
|
||||
|> put_status(:unauthorized)
|
||||
|> json(%{error: "Authentication failed"})
|
||||
|
||||
{:error, _reason} ->
|
||||
conn
|
||||
|> put_status(:unauthorized)
|
||||
|> json(%{error: "Authentication failed"})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes a credential.
|
||||
Only allows deletion if the credential belongs to the current user.
|
||||
"""
|
||||
def delete(conn, %{"id" => credential_id}) do
|
||||
user = conn.assigns.current_scope.user
|
||||
|
||||
case Accounts.delete_credential(credential_id, user.id) do
|
||||
{:ok, _credential} ->
|
||||
conn
|
||||
|> put_flash(:info, "Passkey deleted successfully.")
|
||||
|> redirect(to: ~p"/users/settings")
|
||||
|
||||
{:error, :not_found} ->
|
||||
conn
|
||||
|> put_flash(:error, "Passkey not found.")
|
||||
|> redirect(to: ~p"/users/settings")
|
||||
|
||||
{:error, _reason} ->
|
||||
conn
|
||||
|> put_flash(:error, "Failed to delete passkey.")
|
||||
|> redirect(to: ~p"/users/settings")
|
||||
end
|
||||
end
|
||||
|
||||
defp get_post_login_path(user) do
|
||||
# Get the first organization or redirect to orgs list
|
||||
case Organizations.list_user_organizations(user.id) do
|
||||
[org | _] -> ~p"/orgs/#{org.slug}"
|
||||
[] -> ~p"/orgs"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -143,153 +143,4 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider" />
|
||||
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold leading-6 text-gray-900 dark:text-white">
|
||||
Passkeys
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
Use your device's biometrics (Face ID, Touch ID, Windows Hello) or security keys to sign in
|
||||
</p>
|
||||
|
||||
<%= if !@can_register_passkey do %>
|
||||
<div class="mt-4 rounded-lg bg-amber-50 p-4 dark:bg-amber-900/20">
|
||||
<p class="text-sm text-amber-800 dark:text-amber-200">
|
||||
Please confirm your email address before registering a passkey. Check your inbox for the confirmation link.
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="mt-6 space-y-4">
|
||||
<%= if Enum.empty?(@credentials) do %>
|
||||
<div class="rounded-lg border border-dashed border-gray-300 p-8 text-center dark:border-white/10">
|
||||
<.icon name="hero-key" class="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h3 class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
No passkeys registered
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Add a passkey to enable quick, secure sign-in with biometrics
|
||||
</p>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="space-y-3">
|
||||
<%= for credential <- @credentials do %>
|
||||
<div class="flex items-center justify-between rounded-lg border border-gray-200 p-4 dark:border-white/10">
|
||||
<div class="flex items-center gap-3">
|
||||
<.icon name="hero-key" class="h-5 w-5 text-gray-400" />
|
||||
<div>
|
||||
<p class="font-medium text-gray-900 dark:text-white">
|
||||
{credential.name}
|
||||
</p>
|
||||
<%= if credential.last_used_at do %>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Last used {Calendar.strftime(credential.last_used_at, "%B %d, %Y")}
|
||||
</p>
|
||||
<% else %>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Never used</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<.link
|
||||
href={~p"/users/credentials/#{credential.id}"}
|
||||
method="delete"
|
||||
data-confirm="Are you sure you want to delete this passkey?"
|
||||
class="text-sm font-semibold text-red-600 hover:text-red-500 dark:text-red-400 dark:hover:text-red-300"
|
||||
>
|
||||
Delete
|
||||
</.link>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if @can_register_passkey do %>
|
||||
<button
|
||||
type="button"
|
||||
id="add-passkey-btn"
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-gray-900 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-gray-700 dark:bg-gray-50 dark:text-gray-900 dark:hover:bg-gray-300"
|
||||
>
|
||||
<.icon name="hero-plus" class="h-4 w-4" /> Add Passkey
|
||||
</button>
|
||||
|
||||
<!-- Passkey Registration Modal -->
|
||||
<div
|
||||
id="passkey-modal"
|
||||
class="hidden fixed inset-0 z-50 overflow-y-auto"
|
||||
aria-labelledby="modal-title"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div class="flex min-h-screen items-end justify-center px-4 pb-20 pt-4 text-center sm:block sm:p-0">
|
||||
<!-- Background overlay -->
|
||||
<div
|
||||
class="fixed inset-0 z-0 bg-gray-500 bg-opacity-75 transition-opacity dark:bg-gray-950 dark:bg-opacity-75"
|
||||
aria-hidden="true"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Center modal -->
|
||||
<span
|
||||
class="relative z-10 hidden sm:inline-block sm:h-screen sm:align-middle"
|
||||
aria-hidden="true"
|
||||
>
|
||||
​
|
||||
</span>
|
||||
|
||||
<div class="relative z-10 inline-block transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left align-bottom shadow-xl transition-all dark:bg-gray-800/95 sm:my-8 sm:w-full sm:max-w-lg sm:p-6 sm:align-middle">
|
||||
<div>
|
||||
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900">
|
||||
<.icon name="hero-key" class="h-6 w-6 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div class="mt-3 text-center sm:mt-5">
|
||||
<h3
|
||||
class="text-lg font-semibold leading-6 text-gray-900 dark:text-white"
|
||||
id="modal-title"
|
||||
>
|
||||
Add Passkey
|
||||
</h3>
|
||||
<div class="mt-2">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Give this passkey a name to help you identify it later (e.g., "MacBook Touch ID", "iPhone").
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<input
|
||||
type="text"
|
||||
id="passkey-name-input"
|
||||
placeholder="e.g., MacBook Touch ID"
|
||||
class="block w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-white/10 dark:bg-gray-800/50 dark:text-white sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
id="passkey-error"
|
||||
class="mt-3 hidden rounded-lg bg-red-50 p-3 dark:bg-red-900/20"
|
||||
>
|
||||
<p class="text-sm text-red-800 dark:text-red-200"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-5 sm:mt-6 sm:grid sm:grid-flow-row-dense sm:grid-cols-2 sm:gap-3">
|
||||
<button
|
||||
type="button"
|
||||
id="confirm-add-passkey"
|
||||
class="inline-flex w-full justify-center rounded-lg bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:opacity-50 disabled:cursor-not-allowed sm:col-start-2"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
id="cancel-add-passkey"
|
||||
class="mt-3 inline-flex w-full justify-center rounded-lg bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-gray-800/50 dark:text-white dark:ring-white/10 dark:hover:bg-gray-800 sm:col-start-1 sm:mt-0"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</Layouts.authenticated>
|
||||
|
|
|
|||
|
|
@ -41,7 +41,6 @@ defmodule ToweropsWeb.AccountLive.MyData do
|
|||
# Gather user data - only include org/devices/alerts if user is an owner
|
||||
user_data = %{
|
||||
profile: get_user_profile(user),
|
||||
credentials: get_user_credentials(user),
|
||||
consents: get_user_consents(user),
|
||||
organizations: if(is_owner, do: organizations, else: []),
|
||||
devices: if(is_owner, do: get_user_devices(user), else: []),
|
||||
|
|
@ -199,56 +198,6 @@ defmodule ToweropsWeb.AccountLive.MyData do
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<!-- WebAuthn Credentials -->
|
||||
<section class="bg-white dark:bg-gray-900 shadow rounded-lg overflow-hidden">
|
||||
<div class="px-4 py-5 sm:px-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 class="text-lg font-medium text-gray-900 dark:text-white">
|
||||
Security Credentials
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
WebAuthn passkeys registered to your account
|
||||
</p>
|
||||
</div>
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<%= if Enum.empty?(@user_data.credentials) do %>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
No WebAuthn credentials registered.
|
||||
</p>
|
||||
<% else %>
|
||||
<div class="space-y-4">
|
||||
<%= for credential <- @user_data.credentials do %>
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<dl class="grid grid-cols-1 gap-x-4 gap-y-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Name</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-white">{credential.name}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Created</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
|
||||
{Calendar.strftime(credential.inserted_at, "%B %d, %Y")}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
Last Used
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
|
||||
<%= if credential.last_used_at do %>
|
||||
{Calendar.strftime(credential.last_used_at, "%B %d, %Y at %I:%M %p")}
|
||||
<% else %>
|
||||
Never
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Consent Records -->
|
||||
<section class="bg-white dark:bg-gray-900 shadow rounded-lg overflow-hidden">
|
||||
<div class="px-4 py-5 sm:px-6 border-b border-gray-200 dark:border-gray-700">
|
||||
|
|
@ -622,11 +571,6 @@ defmodule ToweropsWeb.AccountLive.MyData do
|
|||
}
|
||||
end
|
||||
|
||||
# Helper function to get user's WebAuthn credentials
|
||||
defp get_user_credentials(user) do
|
||||
Accounts.list_user_credentials(user.id)
|
||||
end
|
||||
|
||||
# Helper function to get user's organizations
|
||||
defp get_user_organizations(user) do
|
||||
user.id
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ defmodule ToweropsWeb.UserSettingsLive do
|
|||
|> assign(:current_token, current_token)
|
||||
|> assign_changesets()
|
||||
|> assign_profile_form()
|
||||
|> assign_credentials()
|
||||
|> assign_mobile_sessions()
|
||||
|> assign_api_tokens()
|
||||
|> assign_organizations()
|
||||
|
|
@ -306,14 +305,6 @@ defmodule ToweropsWeb.UserSettingsLive do
|
|||
|> assign(:password_form, user |> Accounts.change_user_password() |> to_form())
|
||||
end
|
||||
|
||||
defp assign_credentials(socket) do
|
||||
user = socket.assigns.current_scope.user
|
||||
|
||||
socket
|
||||
|> assign(:credentials, Accounts.list_user_credentials(user.id))
|
||||
|> assign(:can_register_passkey, Accounts.passkey_registration_allowed?(user))
|
||||
end
|
||||
|
||||
defp assign_mobile_sessions(socket) do
|
||||
user = socket.assigns.current_scope.user
|
||||
assign(socket, :mobile_sessions, MobileSessions.list_user_sessions(user.id))
|
||||
|
|
@ -484,20 +475,6 @@ defmodule ToweropsWeb.UserSettingsLive do
|
|||
Account
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="#"
|
||||
phx-click="switch_tab"
|
||||
phx-value-tab="security"
|
||||
class={
|
||||
if @active_tab == "security",
|
||||
do: "text-indigo-600 dark:text-indigo-400",
|
||||
else: ""
|
||||
}
|
||||
>
|
||||
Security
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="#"
|
||||
|
|
@ -991,98 +968,6 @@ defmodule ToweropsWeb.UserSettingsLive do
|
|||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Security Tab -->
|
||||
<%= if @active_tab == "security" do %>
|
||||
<!-- Passkeys Section -->
|
||||
<div class="grid max-w-7xl grid-cols-1 gap-x-8 gap-y-10 px-4 py-16 sm:px-6 md:grid-cols-3 lg:px-8">
|
||||
<div>
|
||||
<h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">
|
||||
Passkeys
|
||||
</h2>
|
||||
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
|
||||
Use your device's biometrics (Face ID, Touch ID, Windows Hello) or security keys to sign in.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<%= if !@can_register_passkey do %>
|
||||
<div class="rounded-lg bg-amber-50 p-4 dark:bg-amber-900/20">
|
||||
<p class="text-sm text-amber-800 dark:text-amber-200">
|
||||
Please confirm your email address before registering a passkey. Check your inbox for the confirmation link.
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if Enum.empty?(@credentials) do %>
|
||||
<div class={"text-center rounded-lg border border-dashed border-gray-300 px-6 py-10 dark:border-gray-700 #{if !@can_register_passkey, do: "mt-4"}"}>
|
||||
<.icon name="hero-key" class="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h3 class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
No passkeys
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Add a passkey to enable quick, secure sign-in with biometrics.
|
||||
</p>
|
||||
<%= if @can_register_passkey do %>
|
||||
<div class="mt-6">
|
||||
<button
|
||||
type="button"
|
||||
id="add-passkey-btn"
|
||||
class="inline-flex items-center gap-x-1.5 rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:bg-indigo-500 dark:hover:bg-indigo-400"
|
||||
>
|
||||
<.icon name="hero-plus" class="-ml-0.5 h-5 w-5" /> Add Passkey
|
||||
</button>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<ul
|
||||
role="list"
|
||||
class={"divide-y divide-gray-200 dark:divide-white/10 #{if !@can_register_passkey, do: "mt-4"}"}
|
||||
>
|
||||
<%= for credential <- @credentials do %>
|
||||
<li class="flex items-center justify-between gap-x-6 py-5">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-start gap-x-3">
|
||||
<p class="text-sm/6 font-semibold text-gray-900 dark:text-white">
|
||||
{credential.name}
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-1 text-xs/5 text-gray-500 dark:text-gray-400">
|
||||
<%= if credential.last_used_at do %>
|
||||
Last used {Calendar.strftime(credential.last_used_at, "%B %d, %Y")}
|
||||
<% else %>
|
||||
Never used
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<.link
|
||||
href={~p"/users/credentials/#{credential.id}"}
|
||||
method="delete"
|
||||
data-confirm="Are you sure you want to delete this passkey?"
|
||||
class="rounded-md bg-white px-2.5 py-1.5 text-sm font-semibold text-gray-900 shadow-xs inset-ring-1 inset-ring-gray-300 hover:bg-gray-100 dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/10 dark:hover:bg-white/20"
|
||||
>
|
||||
Delete
|
||||
</.link>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
|
||||
<%= if @can_register_passkey do %>
|
||||
<div class="mt-6 flex">
|
||||
<button
|
||||
type="button"
|
||||
id="add-passkey-btn"
|
||||
class="inline-flex items-center gap-x-1.5 rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:bg-indigo-500 dark:hover:bg-indigo-400"
|
||||
>
|
||||
<.icon name="hero-plus" class="-ml-0.5 h-5 w-5" /> Add Passkey
|
||||
</button>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Sessions Tab -->
|
||||
<%= if @active_tab == "sessions" do %>
|
||||
<!-- Security Alert Banner -->
|
||||
|
|
|
|||
|
|
@ -109,21 +109,6 @@ defmodule ToweropsWeb.Router do
|
|||
post "/geoip/import", GeoipController, :import_database
|
||||
end
|
||||
|
||||
# WebAuthn API routes
|
||||
scope "/api/webauthn", ToweropsWeb do
|
||||
pipe_through [:browser]
|
||||
|
||||
post "/authentication/challenge", UserCredentialController, :authentication_challenge
|
||||
post "/authenticate", UserCredentialController, :authenticate
|
||||
end
|
||||
|
||||
scope "/api/webauthn", ToweropsWeb do
|
||||
pipe_through [:browser, :require_authenticated_user]
|
||||
|
||||
post "/registration/challenge", UserCredentialController, :registration_challenge
|
||||
post "/register", UserCredentialController, :register
|
||||
end
|
||||
|
||||
# Enable LiveDashboard in production with authentication
|
||||
scope "/admin" do
|
||||
pipe_through [:browser, :require_authenticated_user, :require_superuser]
|
||||
|
|
@ -168,7 +153,6 @@ defmodule ToweropsWeb.Router do
|
|||
pipe_through [:browser, :require_authenticated_user]
|
||||
|
||||
get "/users/settings/confirm-email/:token", UserSettingsController, :confirm_email
|
||||
delete "/users/credentials/:id", UserCredentialController, :delete
|
||||
end
|
||||
|
||||
scope "/", ToweropsWeb do
|
||||
|
|
|
|||
|
|
@ -1,31 +0,0 @@
|
|||
defmodule Towerops.Repo.Migrations.CreateUserCredentials do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:user_credentials, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false
|
||||
|
||||
# WebAuthn credential data
|
||||
add :credential_id, :binary, null: false
|
||||
add :public_key, :binary, null: false
|
||||
add :sign_count, :bigint, default: 0, null: false
|
||||
|
||||
# Metadata for user-friendly display
|
||||
add :name, :string, null: false
|
||||
add :last_used_at, :utc_datetime
|
||||
|
||||
# Authenticator metadata
|
||||
add :aaguid, :binary
|
||||
add :transports, {:array, :string}, default: []
|
||||
add :backup_eligible, :boolean, default: false
|
||||
add :backup_state, :boolean, default: false
|
||||
add :attestation_format, :string
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
create index(:user_credentials, [:user_id])
|
||||
create unique_index(:user_credentials, [:credential_id])
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
defmodule Towerops.Repo.Migrations.DropUserCredentials do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
drop table(:user_credentials)
|
||||
end
|
||||
end
|
||||
|
|
@ -2,6 +2,10 @@ Devices Working
|
|||
* Mikrotik RouterOS
|
||||
* Ubiquiti AC, LTU, AirFiber
|
||||
|
||||
2026-01-31
|
||||
* Small ui tweaks
|
||||
* Backend refactoring
|
||||
|
||||
2026-01-30
|
||||
* Completely overhaul poller agent to not track any state
|
||||
* Complete overhaul of snmp engine
|
||||
|
|
|
|||
|
|
@ -105,23 +105,4 @@ defmodule Towerops.AccountsFixtures do
|
|||
set: [inserted_at: dt, authenticated_at: dt]
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a test credential for a user.
|
||||
This is a helper for testing - in production, credentials are created via WebAuthn.
|
||||
"""
|
||||
def credential_fixture(user, attrs \\ %{}) do
|
||||
default_attrs = %{
|
||||
name: "Test Credential #{System.unique_integer()}",
|
||||
credential_id: :crypto.strong_rand_bytes(32),
|
||||
public_key: :crypto.strong_rand_bytes(64),
|
||||
sign_count: 0
|
||||
}
|
||||
|
||||
attrs = Enum.into(attrs, default_attrs)
|
||||
|
||||
%Accounts.UserCredential{}
|
||||
|> Accounts.UserCredential.changeset(Map.put(attrs, :user_id, user.id))
|
||||
|> Towerops.Repo.insert!()
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -142,25 +142,4 @@ defmodule Towerops.AccountsFixturesTest do
|
|||
assert DateTime.diff(user_token.authenticated_at, custom_time, :second) == 0
|
||||
end
|
||||
end
|
||||
|
||||
describe "credential_fixture/2" do
|
||||
test "creates a credential for a user" do
|
||||
user = user_fixture()
|
||||
credential = credential_fixture(user)
|
||||
|
||||
assert credential.user_id == user.id
|
||||
assert credential.name =~ "Test Credential"
|
||||
assert is_binary(credential.credential_id)
|
||||
assert is_binary(credential.public_key)
|
||||
assert credential.sign_count == 0
|
||||
end
|
||||
|
||||
test "creates a credential with custom attributes" do
|
||||
user = user_fixture()
|
||||
credential = credential_fixture(user, %{name: "Custom Credential", sign_count: 5})
|
||||
|
||||
assert credential.name == "Custom Credential"
|
||||
assert credential.sign_count == 5
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,200 +0,0 @@
|
|||
defmodule Towerops.Accounts.UserCredentialTest do
|
||||
use Towerops.DataCase
|
||||
|
||||
alias Towerops.Accounts.UserCredential
|
||||
|
||||
describe "changeset/2" do
|
||||
test "valid changeset with required fields" do
|
||||
attrs = %{
|
||||
user_id: Ecto.UUID.generate(),
|
||||
credential_id: :crypto.strong_rand_bytes(16),
|
||||
public_key: :crypto.strong_rand_bytes(65),
|
||||
name: "My Passkey"
|
||||
}
|
||||
|
||||
changeset = UserCredential.changeset(%UserCredential{}, attrs)
|
||||
|
||||
assert changeset.valid?
|
||||
assert get_field(changeset, :name) == "My Passkey"
|
||||
assert get_field(changeset, :credential_id) == attrs.credential_id
|
||||
assert get_field(changeset, :public_key) == attrs.public_key
|
||||
end
|
||||
|
||||
test "requires user_id" do
|
||||
attrs = %{
|
||||
credential_id: :crypto.strong_rand_bytes(16),
|
||||
public_key: :crypto.strong_rand_bytes(65),
|
||||
name: "My Passkey"
|
||||
}
|
||||
|
||||
changeset = UserCredential.changeset(%UserCredential{}, attrs)
|
||||
|
||||
refute changeset.valid?
|
||||
assert %{user_id: ["can't be blank"]} = errors_on(changeset)
|
||||
end
|
||||
|
||||
test "requires credential_id" do
|
||||
attrs = %{
|
||||
user_id: Ecto.UUID.generate(),
|
||||
public_key: :crypto.strong_rand_bytes(65),
|
||||
name: "My Passkey"
|
||||
}
|
||||
|
||||
changeset = UserCredential.changeset(%UserCredential{}, attrs)
|
||||
|
||||
refute changeset.valid?
|
||||
assert %{credential_id: ["can't be blank"]} = errors_on(changeset)
|
||||
end
|
||||
|
||||
test "requires public_key" do
|
||||
attrs = %{
|
||||
user_id: Ecto.UUID.generate(),
|
||||
credential_id: :crypto.strong_rand_bytes(16),
|
||||
name: "My Passkey"
|
||||
}
|
||||
|
||||
changeset = UserCredential.changeset(%UserCredential{}, attrs)
|
||||
|
||||
refute changeset.valid?
|
||||
assert %{public_key: ["can't be blank"]} = errors_on(changeset)
|
||||
end
|
||||
|
||||
test "requires name" do
|
||||
attrs = %{
|
||||
user_id: Ecto.UUID.generate(),
|
||||
credential_id: :crypto.strong_rand_bytes(16),
|
||||
public_key: :crypto.strong_rand_bytes(65)
|
||||
}
|
||||
|
||||
changeset = UserCredential.changeset(%UserCredential{}, attrs)
|
||||
|
||||
refute changeset.valid?
|
||||
assert %{name: ["can't be blank"]} = errors_on(changeset)
|
||||
end
|
||||
|
||||
test "validates name length minimum" do
|
||||
attrs = %{
|
||||
user_id: Ecto.UUID.generate(),
|
||||
credential_id: :crypto.strong_rand_bytes(16),
|
||||
public_key: :crypto.strong_rand_bytes(65),
|
||||
name: ""
|
||||
}
|
||||
|
||||
changeset = UserCredential.changeset(%UserCredential{}, attrs)
|
||||
|
||||
refute changeset.valid?
|
||||
# Empty string triggers "can't be blank" before length validation
|
||||
assert %{name: ["can't be blank"]} = errors_on(changeset)
|
||||
end
|
||||
|
||||
test "validates name length maximum" do
|
||||
attrs = %{
|
||||
user_id: Ecto.UUID.generate(),
|
||||
credential_id: :crypto.strong_rand_bytes(16),
|
||||
public_key: :crypto.strong_rand_bytes(65),
|
||||
name: String.duplicate("a", 101)
|
||||
}
|
||||
|
||||
changeset = UserCredential.changeset(%UserCredential{}, attrs)
|
||||
|
||||
refute changeset.valid?
|
||||
assert %{name: ["should be at most 100 character(s)"]} = errors_on(changeset)
|
||||
end
|
||||
|
||||
test "accepts name at maximum length" do
|
||||
attrs = %{
|
||||
user_id: Ecto.UUID.generate(),
|
||||
credential_id: :crypto.strong_rand_bytes(16),
|
||||
public_key: :crypto.strong_rand_bytes(65),
|
||||
name: String.duplicate("a", 100)
|
||||
}
|
||||
|
||||
changeset = UserCredential.changeset(%UserCredential{}, attrs)
|
||||
|
||||
assert changeset.valid?
|
||||
end
|
||||
|
||||
test "accepts optional fields" do
|
||||
last_used_at = DateTime.truncate(DateTime.utc_now(), :second)
|
||||
|
||||
attrs = %{
|
||||
user_id: Ecto.UUID.generate(),
|
||||
credential_id: :crypto.strong_rand_bytes(16),
|
||||
public_key: :crypto.strong_rand_bytes(65),
|
||||
name: "My Passkey",
|
||||
sign_count: 42,
|
||||
last_used_at: last_used_at,
|
||||
aaguid: :crypto.strong_rand_bytes(16),
|
||||
transports: ["usb", "nfc"],
|
||||
backup_eligible: true,
|
||||
backup_state: true,
|
||||
attestation_format: "packed"
|
||||
}
|
||||
|
||||
changeset = UserCredential.changeset(%UserCredential{}, attrs)
|
||||
|
||||
assert changeset.valid?
|
||||
assert get_field(changeset, :sign_count) == 42
|
||||
assert DateTime.compare(get_field(changeset, :last_used_at), last_used_at) == :eq
|
||||
assert get_field(changeset, :aaguid) == attrs.aaguid
|
||||
assert get_field(changeset, :transports) == ["usb", "nfc"]
|
||||
assert get_field(changeset, :backup_eligible) == true
|
||||
assert get_field(changeset, :backup_state) == true
|
||||
assert get_field(changeset, :attestation_format) == "packed"
|
||||
end
|
||||
|
||||
test "uses default values for optional fields when not provided" do
|
||||
attrs = %{
|
||||
user_id: Ecto.UUID.generate(),
|
||||
credential_id: :crypto.strong_rand_bytes(16),
|
||||
public_key: :crypto.strong_rand_bytes(65),
|
||||
name: "My Passkey"
|
||||
}
|
||||
|
||||
changeset = UserCredential.changeset(%UserCredential{}, attrs)
|
||||
|
||||
assert changeset.valid?
|
||||
# These defaults come from the schema definition
|
||||
assert get_field(changeset, :sign_count) == 0
|
||||
assert get_field(changeset, :transports) == []
|
||||
assert get_field(changeset, :backup_eligible) == false
|
||||
assert get_field(changeset, :backup_state) == false
|
||||
end
|
||||
end
|
||||
|
||||
describe "touch_changeset/1" do
|
||||
test "updates last_used_at to current time" do
|
||||
old_time = DateTime.add(DateTime.utc_now(), -3600, :second)
|
||||
|
||||
credential = %UserCredential{
|
||||
sign_count: 5,
|
||||
last_used_at: old_time
|
||||
}
|
||||
|
||||
changeset = UserCredential.touch_changeset(credential)
|
||||
last_used_at = get_field(changeset, :last_used_at)
|
||||
|
||||
# Should be more recent than the old time
|
||||
assert DateTime.after?(last_used_at, old_time)
|
||||
# Should be within last 2 seconds (account for timing + truncation)
|
||||
diff = DateTime.diff(DateTime.utc_now(), last_used_at, :second)
|
||||
assert diff <= 2
|
||||
end
|
||||
|
||||
test "increments sign_count by 1" do
|
||||
credential = %UserCredential{sign_count: 5}
|
||||
|
||||
changeset = UserCredential.touch_changeset(credential)
|
||||
|
||||
assert get_field(changeset, :sign_count) == 6
|
||||
end
|
||||
|
||||
test "increments sign_count from 0" do
|
||||
credential = %UserCredential{sign_count: 0}
|
||||
|
||||
changeset = UserCredential.touch_changeset(credential)
|
||||
|
||||
assert get_field(changeset, :sign_count) == 1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -45,27 +45,6 @@ defmodule ToweropsWeb.Api.AccountDataControllerTest do
|
|||
assert data["profile"]["is_superuser"] == user.is_superuser
|
||||
end
|
||||
|
||||
test "includes empty credentials when user has no WebAuthn credentials", %{conn: conn} do
|
||||
conn = get(conn, ~p"/api/v1/account/data")
|
||||
data = json_response(conn, 200)
|
||||
|
||||
assert data["credentials"] == []
|
||||
end
|
||||
|
||||
test "includes credentials when user has WebAuthn credentials", %{conn: conn, user: user} do
|
||||
# Create a WebAuthn credential using the fixture
|
||||
credential = credential_fixture(user, %{name: "Test Key"})
|
||||
|
||||
conn = get(conn, ~p"/api/v1/account/data")
|
||||
data = json_response(conn, 200)
|
||||
|
||||
assert length(data["credentials"]) == 1
|
||||
[cred] = data["credentials"]
|
||||
assert cred["id"] == credential.id
|
||||
assert cred["name"] == "Test Key"
|
||||
assert is_binary(cred["public_key"])
|
||||
end
|
||||
|
||||
test "includes organizations data", %{conn: conn, user: user} do
|
||||
{:ok, org} = Organizations.create_organization(%{name: "Test Org"}, user.id)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,176 +0,0 @@
|
|||
defmodule ToweropsWeb.UserCredentialControllerTest do
|
||||
use ToweropsWeb.ConnCase, async: true
|
||||
|
||||
import Towerops.AccountsFixtures
|
||||
|
||||
alias Towerops.Accounts
|
||||
|
||||
describe "POST /api/webauthn/registration/challenge" do
|
||||
setup :register_and_log_in_user
|
||||
|
||||
test "returns challenge for authenticated user with confirmed email", %{conn: conn, user: user} do
|
||||
conn = post(conn, ~p"/api/webauthn/registration/challenge")
|
||||
assert %{"challenge" => challenge, "user" => user_data} = json_response(conn, 200)
|
||||
assert is_binary(challenge)
|
||||
assert user_data["name"] == user.email
|
||||
end
|
||||
|
||||
test "returns error for user without confirmed email" do
|
||||
# Create unconfirmed user
|
||||
user = unconfirmed_user_fixture()
|
||||
conn = log_in_user(build_conn(), user)
|
||||
|
||||
conn = post(conn, ~p"/api/webauthn/registration/challenge")
|
||||
assert %{"error" => error} = json_response(conn, 403)
|
||||
assert error =~ "Email confirmation required"
|
||||
end
|
||||
|
||||
test "requires authentication" do
|
||||
conn = build_conn()
|
||||
conn = post(conn, ~p"/api/webauthn/registration/challenge")
|
||||
assert redirected_to(conn) == ~p"/users/log-in"
|
||||
end
|
||||
end
|
||||
|
||||
describe "POST /api/webauthn/register" do
|
||||
setup :register_and_log_in_user
|
||||
|
||||
test "registers credential successfully", %{conn: conn} do
|
||||
# Set challenge in session
|
||||
conn = put_session(conn, :webauthn_challenge, "test_challenge_123")
|
||||
|
||||
# Mock successful registration params
|
||||
params = %{
|
||||
"name" => "My Passkey",
|
||||
"rawId" => Base.url_encode64("credential_id", padding: false),
|
||||
"response" => %{
|
||||
"clientDataJSON" =>
|
||||
Base.url_encode64(~s({"type":"webauthn.create","challenge":"test_challenge_123"}), padding: false),
|
||||
"attestationObject" => Base.url_encode64("mock_attestation", padding: false)
|
||||
},
|
||||
"type" => "public-key"
|
||||
}
|
||||
|
||||
conn = post(conn, ~p"/api/webauthn/register", params)
|
||||
|
||||
# Should return error since WebAuthn verification will fail in test
|
||||
# But this tests the controller flow
|
||||
assert conn.status in [201, 422]
|
||||
end
|
||||
|
||||
test "returns error when email not confirmed", %{conn: _conn} do
|
||||
# Create unconfirmed user
|
||||
unconfirmed_user = unconfirmed_user_fixture()
|
||||
conn = log_in_user(build_conn(), unconfirmed_user)
|
||||
conn = put_session(conn, :webauthn_challenge, "test_challenge")
|
||||
|
||||
params = %{
|
||||
"name" => "My Passkey",
|
||||
"rawId" => Base.url_encode64("credential_id", padding: false)
|
||||
}
|
||||
|
||||
conn = post(conn, ~p"/api/webauthn/register", params)
|
||||
assert %{"error" => error} = json_response(conn, 403)
|
||||
assert error =~ "Email confirmation required"
|
||||
end
|
||||
|
||||
test "returns error on registration failure", %{conn: conn} do
|
||||
conn = put_session(conn, :webauthn_challenge, "test_challenge")
|
||||
|
||||
# Invalid params
|
||||
params = %{"invalid" => "data"}
|
||||
|
||||
conn = post(conn, ~p"/api/webauthn/register", params)
|
||||
assert %{"error" => _} = json_response(conn, 422)
|
||||
end
|
||||
|
||||
test "requires authentication" do
|
||||
conn = build_conn()
|
||||
conn = post(conn, ~p"/api/webauthn/register", %{})
|
||||
assert redirected_to(conn) == ~p"/users/log-in"
|
||||
end
|
||||
end
|
||||
|
||||
describe "POST /api/webauthn/authentication/challenge" do
|
||||
test "returns challenge for user with passkeys", %{conn: conn} do
|
||||
user = user_fixture()
|
||||
_credential = credential_fixture(user, %{name: "Test Key"})
|
||||
|
||||
conn = post(conn, ~p"/api/webauthn/authentication/challenge", %{"email" => user.email})
|
||||
assert %{"challenge" => challenge} = json_response(conn, 200)
|
||||
assert is_binary(challenge)
|
||||
end
|
||||
|
||||
test "returns challenge for discoverable authentication", %{conn: conn} do
|
||||
# No email provided - discoverable credential flow
|
||||
conn = post(conn, ~p"/api/webauthn/authentication/challenge", %{})
|
||||
assert %{"challenge" => challenge} = json_response(conn, 200)
|
||||
assert is_binary(challenge)
|
||||
end
|
||||
|
||||
test "returns error for user without passkeys", %{conn: conn} do
|
||||
user = user_fixture()
|
||||
|
||||
conn = post(conn, ~p"/api/webauthn/authentication/challenge", %{"email" => user.email})
|
||||
assert %{"error" => _} = json_response(conn, 404)
|
||||
end
|
||||
|
||||
test "returns error for non-existent user", %{conn: conn} do
|
||||
conn =
|
||||
post(conn, ~p"/api/webauthn/authentication/challenge", %{
|
||||
"email" => "nonexistent@example.com"
|
||||
})
|
||||
|
||||
assert %{"error" => _} = json_response(conn, 404)
|
||||
end
|
||||
|
||||
test "does not require authentication", %{conn: conn} do
|
||||
user = user_fixture()
|
||||
conn = post(conn, ~p"/api/webauthn/authentication/challenge", %{"email" => user.email})
|
||||
# Should not redirect to login
|
||||
assert conn.status in [200, 404]
|
||||
end
|
||||
end
|
||||
|
||||
describe "POST /api/webauthn/authenticate" do
|
||||
test "returns error when no challenge in session" do
|
||||
conn = build_conn()
|
||||
conn = post(conn, ~p"/api/webauthn/authenticate", %{})
|
||||
assert %{"error" => _} = json_response(conn, 401)
|
||||
end
|
||||
end
|
||||
|
||||
describe "DELETE /users/credentials/:id" do
|
||||
setup :register_and_log_in_user
|
||||
|
||||
test "deletes credential successfully", %{conn: conn, user: user} do
|
||||
# Create a test credential
|
||||
credential = credential_fixture(user, %{name: "Test Key"})
|
||||
|
||||
conn = delete(conn, ~p"/users/credentials/#{credential.id}")
|
||||
assert redirected_to(conn) == ~p"/users/settings"
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "deleted successfully"
|
||||
|
||||
# Verify credential is deleted
|
||||
assert Accounts.list_user_credentials(user.id) == []
|
||||
end
|
||||
|
||||
test "cannot delete another user's credential", %{conn: conn} do
|
||||
other_user = user_fixture()
|
||||
credential = credential_fixture(other_user, %{name: "Other Key"})
|
||||
|
||||
conn = delete(conn, ~p"/users/credentials/#{credential.id}")
|
||||
assert redirected_to(conn) == ~p"/users/settings"
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "not found"
|
||||
|
||||
# Verify credential still exists
|
||||
assert length(Accounts.list_user_credentials(other_user.id)) == 1
|
||||
end
|
||||
|
||||
test "requires authentication" do
|
||||
conn = build_conn()
|
||||
conn = delete(conn, ~p"/users/credentials/#{Ecto.UUID.generate()}")
|
||||
assert redirected_to(conn) == ~p"/users/log-in"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -16,34 +16,12 @@ defmodule ToweropsWeb.UserSettingsHTMLTest do
|
|||
current_scope: nil,
|
||||
email_changeset: email_changeset,
|
||||
password_changeset: password_changeset,
|
||||
credentials: [],
|
||||
can_register_passkey: true,
|
||||
mobile_sessions: []
|
||||
)
|
||||
|
||||
assert html =~ "Account Settings"
|
||||
assert html =~ "Change Email"
|
||||
assert html =~ "Save Password"
|
||||
assert html =~ "Passkeys"
|
||||
assert html =~ "No passkeys registered"
|
||||
end
|
||||
|
||||
test "renders warning when cannot register passkey" do
|
||||
email_changeset = User.email_changeset(%User{email: "user@example.com"}, %{})
|
||||
password_changeset = User.password_changeset(%User{}, %{})
|
||||
|
||||
html =
|
||||
render_to_string(ToweropsWeb.UserSettingsHTML, "edit", "html",
|
||||
flash: %{},
|
||||
current_scope: nil,
|
||||
email_changeset: email_changeset,
|
||||
password_changeset: password_changeset,
|
||||
credentials: [],
|
||||
can_register_passkey: false,
|
||||
mobile_sessions: []
|
||||
)
|
||||
|
||||
assert html =~ "Please confirm your email address before registering a passkey"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ defmodule ToweropsWeb.AccountLive.MyDataTest do
|
|||
|
||||
assert html =~ "My Data"
|
||||
assert html =~ "Profile Information"
|
||||
assert html =~ "Security Credentials"
|
||||
assert html =~ "Organizations"
|
||||
assert html =~ "Devices"
|
||||
assert html =~ "Audit Log"
|
||||
|
|
@ -48,7 +47,6 @@ defmodule ToweropsWeb.AccountLive.MyDataTest do
|
|||
|
||||
assert html =~ "My Data"
|
||||
assert html =~ "Profile Information"
|
||||
assert html =~ "Security Credentials"
|
||||
assert html =~ "Organizations"
|
||||
assert html =~ "Devices"
|
||||
assert html =~ "Recent Alerts"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue