- Add DISCOVERY_LOG_LEVEL=debug - Add DISCOVERY_SHOW_PROGRESS=true - Temporary changes for debugging InitializerProcessor fixes on production
678 lines
24 KiB
JavaScript
678 lines
24 KiB
JavaScript
// modules/api-manager/BiometricAuthManager.js
|
||
import { Logger } from '../../core/logger.js';
|
||
|
||
/**
|
||
* Biometric Authentication Manager - WebAuthn API
|
||
*/
|
||
export class BiometricAuthManager {
|
||
constructor(config = {}) {
|
||
this.config = {
|
||
rpName: config.rpName || 'Custom PHP Framework',
|
||
rpId: config.rpId || window.location.hostname,
|
||
timeout: config.timeout || 60000,
|
||
userVerification: config.userVerification || 'preferred',
|
||
authenticatorSelection: {
|
||
authenticatorAttachment: 'platform', // Prefer built-in authenticators
|
||
userVerification: 'preferred',
|
||
requireResidentKey: false,
|
||
...config.authenticatorSelection
|
||
},
|
||
...config
|
||
};
|
||
|
||
this.credentials = new Map();
|
||
this.authSessions = new Map();
|
||
|
||
// Check WebAuthn support
|
||
this.support = {
|
||
webAuthn: 'credentials' in navigator && 'create' in navigator.credentials,
|
||
conditionalUI: 'conditional' in window.PublicKeyCredential || false,
|
||
userVerifyingPlatformAuthenticator: false,
|
||
residentKey: false
|
||
};
|
||
|
||
// Enhanced feature detection
|
||
this.detectFeatures();
|
||
|
||
Logger.info('[BiometricAuthManager] Initialized with support:', this.support);
|
||
}
|
||
|
||
async detectFeatures() {
|
||
if (!this.support.webAuthn) return;
|
||
|
||
try {
|
||
// Check for user-verifying platform authenticator
|
||
const available = await window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
|
||
this.support.userVerifyingPlatformAuthenticator = available;
|
||
|
||
// Check for resident key support
|
||
if (window.PublicKeyCredential.isConditionalMediationAvailable) {
|
||
this.support.conditionalUI = await window.PublicKeyCredential.isConditionalMediationAvailable();
|
||
}
|
||
|
||
Logger.info('[BiometricAuthManager] Enhanced features detected:', {
|
||
platform: this.support.userVerifyingPlatformAuthenticator,
|
||
conditional: this.support.conditionalUI
|
||
});
|
||
|
||
} catch (error) {
|
||
Logger.warn('[BiometricAuthManager] Feature detection failed:', error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Register new biometric credential
|
||
*/
|
||
async register(userInfo, options = {}) {
|
||
if (!this.support.webAuthn) {
|
||
throw new Error('WebAuthn not supported');
|
||
}
|
||
|
||
const {
|
||
challenge = null,
|
||
excludeCredentials = [],
|
||
extensions = {}
|
||
} = options;
|
||
|
||
try {
|
||
// Generate challenge if not provided
|
||
const challengeBuffer = challenge ?
|
||
this.base64ToArrayBuffer(challenge) :
|
||
crypto.getRandomValues(new Uint8Array(32));
|
||
|
||
// Create credential creation options
|
||
const createOptions = {
|
||
rp: {
|
||
name: this.config.rpName,
|
||
id: this.config.rpId
|
||
},
|
||
user: {
|
||
id: this.stringToArrayBuffer(userInfo.id || userInfo.username),
|
||
name: userInfo.username || userInfo.email,
|
||
displayName: userInfo.displayName || userInfo.name || userInfo.username
|
||
},
|
||
challenge: challengeBuffer,
|
||
pubKeyCredParams: [
|
||
{ type: 'public-key', alg: -7 }, // ES256
|
||
{ type: 'public-key', alg: -35 }, // ES384
|
||
{ type: 'public-key', alg: -36 }, // ES512
|
||
{ type: 'public-key', alg: -257 }, // RS256
|
||
{ type: 'public-key', alg: -258 }, // RS384
|
||
{ type: 'public-key', alg: -259 } // RS512
|
||
],
|
||
authenticatorSelection: this.config.authenticatorSelection,
|
||
timeout: this.config.timeout,
|
||
attestation: 'direct',
|
||
extensions: {
|
||
credProps: true,
|
||
...extensions
|
||
}
|
||
};
|
||
|
||
// Add exclude list if provided
|
||
if (excludeCredentials.length > 0) {
|
||
createOptions.excludeCredentials = excludeCredentials.map(cred => ({
|
||
type: 'public-key',
|
||
id: typeof cred === 'string' ? this.base64ToArrayBuffer(cred) : cred,
|
||
transports: ['internal', 'hybrid']
|
||
}));
|
||
}
|
||
|
||
Logger.info('[BiometricAuthManager] Starting registration...');
|
||
|
||
// Create credential
|
||
const credential = await navigator.credentials.create({
|
||
publicKey: createOptions
|
||
});
|
||
|
||
if (!credential) {
|
||
throw new Error('Credential creation failed');
|
||
}
|
||
|
||
// Process the credential
|
||
const processedCredential = this.processCredential(credential, 'registration');
|
||
|
||
// Store credential info locally
|
||
const credentialInfo = {
|
||
id: processedCredential.id,
|
||
rawId: processedCredential.rawId,
|
||
userId: userInfo.id || userInfo.username,
|
||
userDisplayName: userInfo.displayName || userInfo.name,
|
||
createdAt: Date.now(),
|
||
lastUsed: null,
|
||
counter: processedCredential.response.counter || 0,
|
||
transports: credential.response.getTransports?.() || ['internal']
|
||
};
|
||
|
||
this.credentials.set(processedCredential.id, credentialInfo);
|
||
|
||
Logger.info('[BiometricAuthManager] Registration successful:', {
|
||
id: processedCredential.id,
|
||
user: userInfo.username,
|
||
authenticator: credentialInfo.transports
|
||
});
|
||
|
||
return {
|
||
success: true,
|
||
credential: processedCredential,
|
||
info: credentialInfo,
|
||
attestation: this.parseAttestation(credential.response)
|
||
};
|
||
|
||
} catch (error) {
|
||
Logger.error('[BiometricAuthManager] Registration failed:', error);
|
||
return {
|
||
success: false,
|
||
error: error.message,
|
||
name: error.name
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Authenticate with biometric credential
|
||
*/
|
||
async authenticate(options = {}) {
|
||
if (!this.support.webAuthn) {
|
||
throw new Error('WebAuthn not supported');
|
||
}
|
||
|
||
const {
|
||
challenge = null,
|
||
allowCredentials = [],
|
||
userVerification = this.config.userVerification,
|
||
conditional = false,
|
||
extensions = {}
|
||
} = options;
|
||
|
||
try {
|
||
// Generate challenge if not provided
|
||
const challengeBuffer = challenge ?
|
||
this.base64ToArrayBuffer(challenge) :
|
||
crypto.getRandomValues(new Uint8Array(32));
|
||
|
||
// Create authentication options
|
||
const getOptions = {
|
||
challenge: challengeBuffer,
|
||
timeout: this.config.timeout,
|
||
userVerification,
|
||
extensions: {
|
||
credProps: true,
|
||
...extensions
|
||
}
|
||
};
|
||
|
||
// Add allow list if provided
|
||
if (allowCredentials.length > 0) {
|
||
getOptions.allowCredentials = allowCredentials.map(cred => ({
|
||
type: 'public-key',
|
||
id: typeof cred === 'string' ? this.base64ToArrayBuffer(cred) : cred,
|
||
transports: ['internal', 'hybrid', 'usb', 'nfc', 'ble']
|
||
}));
|
||
}
|
||
|
||
Logger.info('[BiometricAuthManager] Starting authentication...', { conditional });
|
||
|
||
// Authenticate
|
||
const credential = conditional && this.support.conditionalUI ?
|
||
await navigator.credentials.get({
|
||
publicKey: getOptions,
|
||
mediation: 'conditional'
|
||
}) :
|
||
await navigator.credentials.get({
|
||
publicKey: getOptions
|
||
});
|
||
|
||
if (!credential) {
|
||
throw new Error('Authentication failed');
|
||
}
|
||
|
||
// Process the credential
|
||
const processedCredential = this.processCredential(credential, 'authentication');
|
||
|
||
// Update credential usage
|
||
const credentialInfo = this.credentials.get(processedCredential.id);
|
||
if (credentialInfo) {
|
||
credentialInfo.lastUsed = Date.now();
|
||
credentialInfo.counter = processedCredential.response.counter || 0;
|
||
}
|
||
|
||
// Create authentication session
|
||
const sessionId = this.generateSessionId();
|
||
const authSession = {
|
||
id: sessionId,
|
||
credentialId: processedCredential.id,
|
||
userId: credentialInfo?.userId,
|
||
authenticatedAt: Date.now(),
|
||
userAgent: navigator.userAgent,
|
||
ipAddress: await this.getClientIP().catch(() => 'unknown')
|
||
};
|
||
|
||
this.authSessions.set(sessionId, authSession);
|
||
|
||
Logger.info('[BiometricAuthManager] Authentication successful:', {
|
||
sessionId,
|
||
credentialId: processedCredential.id,
|
||
userId: credentialInfo?.userId
|
||
});
|
||
|
||
return {
|
||
success: true,
|
||
credential: processedCredential,
|
||
session: authSession,
|
||
info: credentialInfo
|
||
};
|
||
|
||
} catch (error) {
|
||
Logger.error('[BiometricAuthManager] Authentication failed:', error);
|
||
return {
|
||
success: false,
|
||
error: error.message,
|
||
name: error.name
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Check if biometric authentication is available
|
||
*/
|
||
async isAvailable() {
|
||
const availability = {
|
||
webAuthn: this.support.webAuthn,
|
||
platform: this.support.userVerifyingPlatformAuthenticator,
|
||
conditional: this.support.conditionalUI,
|
||
hasCredentials: this.credentials.size > 0,
|
||
recommended: false
|
||
};
|
||
|
||
// Overall recommendation
|
||
availability.recommended = availability.webAuthn &&
|
||
(availability.platform || availability.hasCredentials);
|
||
|
||
return availability;
|
||
}
|
||
|
||
/**
|
||
* Set up conditional UI for seamless authentication
|
||
*/
|
||
async setupConditionalUI(inputSelector = 'input[type="email"], input[type="text"]', options = {}) {
|
||
if (!this.support.conditionalUI) {
|
||
Logger.warn('[BiometricAuthManager] Conditional UI not supported');
|
||
return null;
|
||
}
|
||
|
||
const inputs = document.querySelectorAll(inputSelector);
|
||
if (inputs.length === 0) {
|
||
Logger.warn('[BiometricAuthManager] No suitable inputs found for conditional UI');
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
// Set up conditional UI
|
||
inputs.forEach(input => {
|
||
input.addEventListener('focus', async () => {
|
||
Logger.info('[BiometricAuthManager] Setting up conditional authentication');
|
||
|
||
try {
|
||
const result = await this.authenticate({
|
||
...options,
|
||
conditional: true
|
||
});
|
||
|
||
if (result.success) {
|
||
// Trigger custom event for successful authentication
|
||
const event = new CustomEvent('biometric-auth-success', {
|
||
detail: result
|
||
});
|
||
input.dispatchEvent(event);
|
||
|
||
// Auto-fill username if available
|
||
if (result.info?.userDisplayName) {
|
||
input.value = result.info.userDisplayName;
|
||
}
|
||
}
|
||
} catch (error) {
|
||
Logger.warn('[BiometricAuthManager] Conditional auth failed:', error);
|
||
}
|
||
});
|
||
});
|
||
|
||
Logger.info('[BiometricAuthManager] Conditional UI setup complete');
|
||
return { inputs: inputs.length, selector: inputSelector };
|
||
|
||
} catch (error) {
|
||
Logger.error('[BiometricAuthManager] Conditional UI setup failed:', error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Create a complete biometric login flow
|
||
*/
|
||
createLoginFlow(options = {}) {
|
||
const {
|
||
registerSelector = '#register-biometric',
|
||
loginSelector = '#login-biometric',
|
||
statusSelector = '#biometric-status',
|
||
onRegister = null,
|
||
onLogin = null,
|
||
onError = null
|
||
} = options;
|
||
|
||
return {
|
||
async init() {
|
||
const availability = await this.isAvailable();
|
||
|
||
// Update status display
|
||
const statusEl = document.querySelector(statusSelector);
|
||
if (statusEl) {
|
||
statusEl.innerHTML = this.createStatusHTML(availability);
|
||
}
|
||
|
||
// Set up register button
|
||
const registerBtn = document.querySelector(registerSelector);
|
||
if (registerBtn) {
|
||
registerBtn.style.display = availability.webAuthn ? 'block' : 'none';
|
||
registerBtn.onclick = () => this.handleRegister();
|
||
}
|
||
|
||
// Set up login button
|
||
const loginBtn = document.querySelector(loginSelector);
|
||
if (loginBtn) {
|
||
loginBtn.style.display = availability.hasCredentials ? 'block' : 'none';
|
||
loginBtn.onclick = () => this.handleLogin();
|
||
}
|
||
|
||
// Set up conditional UI
|
||
if (availability.conditional) {
|
||
await this.setupConditionalUI();
|
||
}
|
||
|
||
return availability;
|
||
},
|
||
|
||
async handleRegister() {
|
||
try {
|
||
// Get user information (could be from form or prompt)
|
||
const userInfo = await this.getUserInfo();
|
||
if (!userInfo) return;
|
||
|
||
const result = await this.register(userInfo);
|
||
|
||
if (result.success) {
|
||
if (onRegister) onRegister(result);
|
||
this.showSuccess('Biometric authentication registered successfully!');
|
||
} else {
|
||
if (onError) onError(result);
|
||
this.showError(result.error);
|
||
}
|
||
} catch (error) {
|
||
if (onError) onError({ error: error.message });
|
||
this.showError(error.message);
|
||
}
|
||
},
|
||
|
||
async handleLogin() {
|
||
try {
|
||
const result = await this.authenticate();
|
||
|
||
if (result.success) {
|
||
if (onLogin) onLogin(result);
|
||
this.showSuccess('Biometric authentication successful!');
|
||
} else {
|
||
if (onError) onError(result);
|
||
this.showError(result.error);
|
||
}
|
||
} catch (error) {
|
||
if (onError) onError({ error: error.message });
|
||
this.showError(error.message);
|
||
}
|
||
},
|
||
|
||
async getUserInfo() {
|
||
// Try to get from form first
|
||
const usernameInput = document.querySelector('input[name="username"], input[name="email"]');
|
||
const nameInput = document.querySelector('input[name="name"], input[name="display_name"]');
|
||
|
||
if (usernameInput?.value) {
|
||
return {
|
||
id: usernameInput.value,
|
||
username: usernameInput.value,
|
||
displayName: nameInput?.value || usernameInput.value
|
||
};
|
||
}
|
||
|
||
// Fallback to prompt
|
||
const username = prompt('Please enter your username or email:');
|
||
if (!username) return null;
|
||
|
||
const displayName = prompt('Please enter your display name:') || username;
|
||
|
||
return {
|
||
id: username,
|
||
username,
|
||
displayName
|
||
};
|
||
},
|
||
|
||
createStatusHTML(availability) {
|
||
if (!availability.webAuthn) {
|
||
return '<div class="alert alert-warning">⚠️ Biometric authentication not supported in this browser</div>';
|
||
}
|
||
|
||
if (!availability.platform && !availability.hasCredentials) {
|
||
return '<div class="alert alert-info">ℹ️ No biometric authenticators available</div>';
|
||
}
|
||
|
||
if (availability.hasCredentials) {
|
||
return '<div class="alert alert-success">✅ Biometric authentication available</div>';
|
||
}
|
||
|
||
return '<div class="alert alert-info">🔐 Biometric authentication can be set up</div>';
|
||
},
|
||
|
||
showSuccess(message) {
|
||
this.showMessage(message, 'success');
|
||
},
|
||
|
||
showError(message) {
|
||
this.showMessage(message, 'error');
|
||
},
|
||
|
||
showMessage(message, type = 'info') {
|
||
// Create toast notification
|
||
const toast = document.createElement('div');
|
||
toast.className = `biometric-toast toast-${type}`;
|
||
toast.style.cssText = `
|
||
position: fixed;
|
||
top: 20px;
|
||
right: 20px;
|
||
background: ${type === 'error' ? '#f44336' : type === 'success' ? '#4caf50' : '#2196f3'};
|
||
color: white;
|
||
padding: 1rem;
|
||
border-radius: 4px;
|
||
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
|
||
z-index: 10001;
|
||
max-width: 300px;
|
||
`;
|
||
toast.textContent = message;
|
||
|
||
document.body.appendChild(toast);
|
||
|
||
setTimeout(() => {
|
||
if (toast.parentNode) {
|
||
document.body.removeChild(toast);
|
||
}
|
||
}, 5000);
|
||
}
|
||
};
|
||
}
|
||
|
||
// Helper methods
|
||
|
||
processCredential(credential, type) {
|
||
const processed = {
|
||
id: this.arrayBufferToBase64(credential.rawId),
|
||
rawId: this.arrayBufferToBase64(credential.rawId),
|
||
type: credential.type,
|
||
response: {}
|
||
};
|
||
|
||
if (type === 'registration') {
|
||
processed.response = {
|
||
attestationObject: this.arrayBufferToBase64(credential.response.attestationObject),
|
||
clientDataJSON: this.arrayBufferToBase64(credential.response.clientDataJSON),
|
||
transports: credential.response.getTransports?.() || []
|
||
};
|
||
} else {
|
||
processed.response = {
|
||
authenticatorData: this.arrayBufferToBase64(credential.response.authenticatorData),
|
||
clientDataJSON: this.arrayBufferToBase64(credential.response.clientDataJSON),
|
||
signature: this.arrayBufferToBase64(credential.response.signature),
|
||
userHandle: credential.response.userHandle ?
|
||
this.arrayBufferToBase64(credential.response.userHandle) : null
|
||
};
|
||
}
|
||
|
||
return processed;
|
||
}
|
||
|
||
parseAttestation(response) {
|
||
try {
|
||
// Basic attestation parsing
|
||
const clientDataJSON = JSON.parse(this.arrayBufferToString(response.clientDataJSON));
|
||
|
||
return {
|
||
format: 'packed', // Simplified
|
||
clientData: clientDataJSON,
|
||
origin: clientDataJSON.origin,
|
||
challenge: clientDataJSON.challenge
|
||
};
|
||
} catch (error) {
|
||
Logger.warn('[BiometricAuthManager] Attestation parsing failed:', error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async getClientIP() {
|
||
try {
|
||
// Use a public IP service (consider privacy implications)
|
||
const response = await fetch('https://api.ipify.org?format=json');
|
||
const data = await response.json();
|
||
return data.ip;
|
||
} catch (error) {
|
||
return 'unknown';
|
||
}
|
||
}
|
||
|
||
generateSessionId() {
|
||
return `biometric_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||
}
|
||
|
||
// Encoding/decoding utilities
|
||
|
||
arrayBufferToBase64(buffer) {
|
||
const bytes = new Uint8Array(buffer);
|
||
let binary = '';
|
||
for (let i = 0; i < bytes.byteLength; i++) {
|
||
binary += String.fromCharCode(bytes[i]);
|
||
}
|
||
return btoa(binary);
|
||
}
|
||
|
||
base64ToArrayBuffer(base64) {
|
||
const binary = atob(base64);
|
||
const bytes = new Uint8Array(binary.length);
|
||
for (let i = 0; i < binary.length; i++) {
|
||
bytes[i] = binary.charCodeAt(i);
|
||
}
|
||
return bytes.buffer;
|
||
}
|
||
|
||
stringToArrayBuffer(str) {
|
||
const encoder = new TextEncoder();
|
||
return encoder.encode(str);
|
||
}
|
||
|
||
arrayBufferToString(buffer) {
|
||
const decoder = new TextDecoder();
|
||
return decoder.decode(buffer);
|
||
}
|
||
|
||
/**
|
||
* Get all registered credentials
|
||
*/
|
||
getCredentials() {
|
||
return Array.from(this.credentials.values());
|
||
}
|
||
|
||
/**
|
||
* Get active authentication sessions
|
||
*/
|
||
getActiveSessions() {
|
||
return Array.from(this.authSessions.values());
|
||
}
|
||
|
||
/**
|
||
* Revoke a credential
|
||
*/
|
||
revokeCredential(credentialId) {
|
||
const removed = this.credentials.delete(credentialId);
|
||
if (removed) {
|
||
Logger.info(`[BiometricAuthManager] Credential revoked: ${credentialId}`);
|
||
}
|
||
return removed;
|
||
}
|
||
|
||
/**
|
||
* End authentication session
|
||
*/
|
||
endSession(sessionId) {
|
||
const removed = this.authSessions.delete(sessionId);
|
||
if (removed) {
|
||
Logger.info(`[BiometricAuthManager] Session ended: ${sessionId}`);
|
||
}
|
||
return removed;
|
||
}
|
||
|
||
/**
|
||
* Cleanup expired sessions
|
||
*/
|
||
cleanupSessions(maxAge = 24 * 60 * 60 * 1000) { // 24 hours
|
||
const now = Date.now();
|
||
let cleaned = 0;
|
||
|
||
for (const [sessionId, session] of this.authSessions.entries()) {
|
||
if (now - session.authenticatedAt > maxAge) {
|
||
this.authSessions.delete(sessionId);
|
||
cleaned++;
|
||
}
|
||
}
|
||
|
||
if (cleaned > 0) {
|
||
Logger.info(`[BiometricAuthManager] Cleaned up ${cleaned} expired sessions`);
|
||
}
|
||
|
||
return cleaned;
|
||
}
|
||
|
||
/**
|
||
* Get comprehensive status report
|
||
*/
|
||
async getStatusReport() {
|
||
const availability = await this.isAvailable();
|
||
|
||
return {
|
||
availability,
|
||
credentials: this.credentials.size,
|
||
sessions: this.authSessions.size,
|
||
support: this.support,
|
||
config: {
|
||
rpName: this.config.rpName,
|
||
rpId: this.config.rpId,
|
||
timeout: this.config.timeout
|
||
},
|
||
timestamp: Date.now()
|
||
};
|
||
}
|
||
} |