feat: add email recovery with Mailcow SMTP and recovery page
- Add email column to users table, recovery_tokens table - Add recovery endpoints (set/request/verify email) - Integrate nodemailer with Mailcow SMTP (mx.jeffemmett.com) - Add branded HTML recovery email template - Add /recover landing page with passkey registration - Add SMTP env vars to docker-compose Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
38636862d8
commit
89fba95e40
|
|
@ -16,6 +16,12 @@ services:
|
|||
- PORT=3000
|
||||
- JWT_SECRET=${JWT_SECRET:-change-this-in-production}
|
||||
- DATABASE_URL=postgres://encryptid:${ENCRYPTID_DB_PASSWORD:-encryptid}@encryptid-db:5432/encryptid
|
||||
- SMTP_HOST=${SMTP_HOST:-mx.jeffemmett.com}
|
||||
- SMTP_PORT=${SMTP_PORT:-587}
|
||||
- SMTP_USER=${SMTP_USER:-noreply@jeffemmett.com}
|
||||
- SMTP_PASS=${SMTP_PASS}
|
||||
- SMTP_FROM=${SMTP_FROM:-EncryptID <noreply@jeffemmett.com>}
|
||||
- RECOVERY_URL=${RECOVERY_URL:-https://encryptid.jeffemmett.com/recover}
|
||||
labels:
|
||||
# Traefik auto-discovery
|
||||
- "traefik.enable=true"
|
||||
|
|
|
|||
|
|
@ -18,10 +18,12 @@
|
|||
"@lit/reactive-element": "^2.0.4",
|
||||
"hono": "^4.11.7",
|
||||
"postgres": "^3.4.5",
|
||||
"nodemailer": "^6.9.0",
|
||||
"perfect-arrows": "^0.3.7",
|
||||
"perfect-freehand": "^1.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/nodemailer": "^6.4.0",
|
||||
"@types/node": "^22.10.1",
|
||||
"bun-types": "^1.1.38",
|
||||
"typescript": "^5.7.2",
|
||||
|
|
|
|||
|
|
@ -189,6 +189,74 @@ export async function cleanExpiredChallenges(): Promise<number> {
|
|||
return result.count;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// USER EMAIL OPERATIONS
|
||||
// ============================================================================
|
||||
|
||||
export async function setUserEmail(userId: string, email: string): Promise<void> {
|
||||
await sql`UPDATE users SET email = ${email} WHERE id = ${userId}`;
|
||||
}
|
||||
|
||||
export async function getUserByEmail(email: string) {
|
||||
const [user] = await sql`SELECT * FROM users WHERE email = ${email}`;
|
||||
return user || null;
|
||||
}
|
||||
|
||||
export async function getUserById(userId: string) {
|
||||
const [user] = await sql`SELECT * FROM users WHERE id = ${userId}`;
|
||||
return user || null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// RECOVERY TOKEN OPERATIONS
|
||||
// ============================================================================
|
||||
|
||||
export interface StoredRecoveryToken {
|
||||
token: string;
|
||||
userId: string;
|
||||
type: 'email_verify' | 'account_recovery';
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
used: boolean;
|
||||
}
|
||||
|
||||
export async function storeRecoveryToken(rt: StoredRecoveryToken): Promise<void> {
|
||||
await sql`
|
||||
INSERT INTO recovery_tokens (token, user_id, type, created_at, expires_at, used)
|
||||
VALUES (
|
||||
${rt.token},
|
||||
${rt.userId},
|
||||
${rt.type},
|
||||
${new Date(rt.createdAt)},
|
||||
${new Date(rt.expiresAt)},
|
||||
${rt.used}
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
export async function getRecoveryToken(token: string): Promise<StoredRecoveryToken | null> {
|
||||
const rows = await sql`SELECT * FROM recovery_tokens WHERE token = ${token}`;
|
||||
if (rows.length === 0) return null;
|
||||
const row = rows[0];
|
||||
return {
|
||||
token: row.token,
|
||||
userId: row.user_id,
|
||||
type: row.type as 'email_verify' | 'account_recovery',
|
||||
createdAt: new Date(row.created_at).getTime(),
|
||||
expiresAt: new Date(row.expires_at).getTime(),
|
||||
used: row.used,
|
||||
};
|
||||
}
|
||||
|
||||
export async function markRecoveryTokenUsed(token: string): Promise<void> {
|
||||
await sql`UPDATE recovery_tokens SET used = TRUE WHERE token = ${token}`;
|
||||
}
|
||||
|
||||
export async function cleanExpiredRecoveryTokens(): Promise<number> {
|
||||
const result = await sql`DELETE FROM recovery_tokens WHERE expires_at < NOW()`;
|
||||
return result.count;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HEALTH CHECK
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -6,9 +6,12 @@ CREATE TABLE IF NOT EXISTS users (
|
|||
username TEXT UNIQUE NOT NULL,
|
||||
display_name TEXT,
|
||||
did TEXT,
|
||||
email TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS credentials (
|
||||
credential_id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
|
|
@ -31,3 +34,15 @@ CREATE TABLE IF NOT EXISTS challenges (
|
|||
|
||||
-- Auto-clean expired challenges (run periodically or use pg_cron)
|
||||
CREATE INDEX IF NOT EXISTS idx_challenges_expires_at ON challenges(expires_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS recovery_tokens (
|
||||
token TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
type TEXT NOT NULL CHECK (type IN ('email_verify', 'account_recovery')),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
used BOOLEAN DEFAULT FALSE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_recovery_tokens_user_id ON recovery_tokens(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_recovery_tokens_expires_at ON recovery_tokens(expires_at);
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { cors } from 'hono/cors';
|
|||
import { logger } from 'hono/logger';
|
||||
import { serveStatic } from 'hono/bun';
|
||||
import { sign, verify } from 'hono/jwt';
|
||||
import { createTransport, type Transporter } from 'nodemailer';
|
||||
import {
|
||||
initDatabase,
|
||||
storeCredential,
|
||||
|
|
@ -22,10 +23,18 @@ import {
|
|||
getChallenge,
|
||||
deleteChallenge,
|
||||
cleanExpiredChallenges,
|
||||
cleanExpiredRecoveryTokens,
|
||||
checkDatabaseHealth,
|
||||
createUser,
|
||||
setUserEmail,
|
||||
getUserByEmail,
|
||||
getUserById,
|
||||
storeRecoveryToken,
|
||||
getRecoveryToken,
|
||||
markRecoveryTokenUsed,
|
||||
type StoredCredential,
|
||||
type StoredChallenge,
|
||||
type StoredRecoveryToken,
|
||||
} from './db.js';
|
||||
|
||||
// ============================================================================
|
||||
|
|
@ -39,6 +48,15 @@ const CONFIG = {
|
|||
jwtSecret: process.env.JWT_SECRET || 'dev-secret-change-in-production',
|
||||
sessionDuration: 15 * 60, // 15 minutes
|
||||
refreshDuration: 7 * 24 * 60 * 60, // 7 days
|
||||
smtp: {
|
||||
host: process.env.SMTP_HOST || 'mx.jeffemmett.com',
|
||||
port: parseInt(process.env.SMTP_PORT || '587'),
|
||||
secure: false, // STARTTLS on 587
|
||||
user: process.env.SMTP_USER || 'noreply@jeffemmett.com',
|
||||
pass: process.env.SMTP_PASS || '',
|
||||
from: process.env.SMTP_FROM || 'EncryptID <noreply@jeffemmett.com>',
|
||||
},
|
||||
recoveryUrl: process.env.RECOVERY_URL || 'https://encryptid.jeffemmett.com/recover',
|
||||
allowedOrigins: [
|
||||
'https://encryptid.jeffemmett.com',
|
||||
'https://jeffemmett.com',
|
||||
|
|
@ -57,6 +75,97 @@ const CONFIG = {
|
|||
],
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// SMTP TRANSPORT
|
||||
// ============================================================================
|
||||
|
||||
let smtpTransport: Transporter | null = null;
|
||||
|
||||
if (CONFIG.smtp.pass) {
|
||||
smtpTransport = createTransport({
|
||||
host: CONFIG.smtp.host,
|
||||
port: CONFIG.smtp.port,
|
||||
secure: CONFIG.smtp.port === 465,
|
||||
auth: {
|
||||
user: CONFIG.smtp.user,
|
||||
pass: CONFIG.smtp.pass,
|
||||
},
|
||||
});
|
||||
|
||||
// Verify connection on startup
|
||||
smtpTransport.verify().then(() => {
|
||||
console.log('EncryptID: SMTP connected to', CONFIG.smtp.host);
|
||||
}).catch((err) => {
|
||||
console.error('EncryptID: SMTP connection failed —', err.message);
|
||||
console.error('EncryptID: Email recovery will not work until SMTP is configured');
|
||||
smtpTransport = null;
|
||||
});
|
||||
} else {
|
||||
console.warn('EncryptID: SMTP_PASS not set — email recovery disabled (tokens logged to console)');
|
||||
}
|
||||
|
||||
async function sendRecoveryEmail(to: string, token: string, username: string): Promise<boolean> {
|
||||
const recoveryLink = `${CONFIG.recoveryUrl}?token=${encodeURIComponent(token)}`;
|
||||
|
||||
if (!smtpTransport) {
|
||||
console.log(`EncryptID: [NO SMTP] Recovery link for ${to}: ${recoveryLink}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
await smtpTransport.sendMail({
|
||||
from: CONFIG.smtp.from,
|
||||
to,
|
||||
subject: 'EncryptID — Account Recovery',
|
||||
text: [
|
||||
`Hi ${username},`,
|
||||
'',
|
||||
'A recovery request was made for your EncryptID account.',
|
||||
'Use the link below to add a new passkey:',
|
||||
'',
|
||||
recoveryLink,
|
||||
'',
|
||||
'This link expires in 30 minutes.',
|
||||
'If you did not request this, you can safely ignore this email.',
|
||||
'',
|
||||
'— EncryptID',
|
||||
].join('\n'),
|
||||
html: `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"></head>
|
||||
<body style="margin:0;padding:0;background:#1a1a2e;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background:#1a1a2e;padding:40px 20px;">
|
||||
<tr><td align="center">
|
||||
<table width="480" cellpadding="0" cellspacing="0" style="background:#16213e;border-radius:12px;border:1px solid rgba(255,255,255,0.1);">
|
||||
<tr><td style="padding:32px 32px 24px;text-align:center;">
|
||||
<div style="font-size:36px;margin-bottom:8px;">🔒</div>
|
||||
<h1 style="margin:0;font-size:24px;background:linear-gradient(90deg,#00d4ff,#7c3aed);-webkit-background-clip:text;-webkit-text-fill-color:transparent;">EncryptID</h1>
|
||||
</td></tr>
|
||||
<tr><td style="padding:0 32px 24px;color:#e2e8f0;font-size:15px;line-height:1.6;">
|
||||
<p>Hi <strong>${username}</strong>,</p>
|
||||
<p>A recovery request was made for your EncryptID account. Click below to add a new passkey:</p>
|
||||
</td></tr>
|
||||
<tr><td style="padding:0 32px 32px;text-align:center;">
|
||||
<a href="${recoveryLink}" style="display:inline-block;padding:12px 32px;background:linear-gradient(90deg,#00d4ff,#7c3aed);color:#fff;text-decoration:none;border-radius:8px;font-weight:600;font-size:15px;">Recover Account</a>
|
||||
</td></tr>
|
||||
<tr><td style="padding:0 32px 32px;color:#94a3b8;font-size:13px;line-height:1.5;">
|
||||
<p>This link expires in <strong>30 minutes</strong>.</p>
|
||||
<p>If you didn't request this, you can safely ignore this email — your account is secure.</p>
|
||||
<p style="margin-top:16px;padding-top:16px;border-top:1px solid rgba(255,255,255,0.1);font-size:12px;color:#64748b;">
|
||||
Can't click the button? Copy this link:<br>
|
||||
<span style="color:#94a3b8;word-break:break-all;">${recoveryLink}</span>
|
||||
</p>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HONO APP
|
||||
// ============================================================================
|
||||
|
|
@ -407,6 +516,108 @@ app.get('/api/user/credentials', async (c) => {
|
|||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// RECOVERY ENDPOINTS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Set recovery email for authenticated user
|
||||
*/
|
||||
app.post('/api/recovery/email/set', async (c) => {
|
||||
const authHeader = c.req.header('Authorization');
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
return c.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await verify(authHeader.slice(7), CONFIG.jwtSecret);
|
||||
const { email } = await c.req.json();
|
||||
|
||||
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
return c.json({ error: 'Valid email required' }, 400);
|
||||
}
|
||||
|
||||
await setUserEmail(payload.sub as string, email);
|
||||
return c.json({ success: true, email });
|
||||
} catch {
|
||||
return c.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Request account recovery via email — sends a recovery token
|
||||
*/
|
||||
app.post('/api/recovery/email/request', async (c) => {
|
||||
const { email } = await c.req.json();
|
||||
|
||||
if (!email) {
|
||||
return c.json({ error: 'Email required' }, 400);
|
||||
}
|
||||
|
||||
const user = await getUserByEmail(email);
|
||||
// Always return success to avoid email enumeration
|
||||
if (!user) {
|
||||
return c.json({ success: true, message: 'If an account exists with this email, a recovery link has been sent.' });
|
||||
}
|
||||
|
||||
// Generate recovery token
|
||||
const token = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url');
|
||||
const rt: StoredRecoveryToken = {
|
||||
token,
|
||||
userId: user.id,
|
||||
type: 'account_recovery',
|
||||
createdAt: Date.now(),
|
||||
expiresAt: Date.now() + 30 * 60 * 1000, // 30 minutes
|
||||
used: false,
|
||||
};
|
||||
await storeRecoveryToken(rt);
|
||||
|
||||
// Send recovery email via Mailcow SMTP
|
||||
try {
|
||||
await sendRecoveryEmail(email, token, user.username);
|
||||
} catch (err) {
|
||||
console.error('EncryptID: Failed to send recovery email:', err);
|
||||
}
|
||||
|
||||
return c.json({ success: true, message: 'If an account exists with this email, a recovery link has been sent.' });
|
||||
});
|
||||
|
||||
/**
|
||||
* Verify recovery token — returns temporary auth to register a new passkey
|
||||
*/
|
||||
app.post('/api/recovery/email/verify', async (c) => {
|
||||
const { token: recoveryToken } = await c.req.json();
|
||||
|
||||
if (!recoveryToken) {
|
||||
return c.json({ error: 'Recovery token required' }, 400);
|
||||
}
|
||||
|
||||
const rt = await getRecoveryToken(recoveryToken);
|
||||
if (!rt || rt.used || Date.now() > rt.expiresAt) {
|
||||
return c.json({ error: 'Invalid or expired recovery token' }, 400);
|
||||
}
|
||||
|
||||
await markRecoveryTokenUsed(recoveryToken);
|
||||
|
||||
// Get user info
|
||||
const user = await getUserById(rt.userId);
|
||||
if (!user) {
|
||||
return c.json({ error: 'Account not found' }, 404);
|
||||
}
|
||||
|
||||
// Issue a short-lived recovery session token
|
||||
const sessionToken = await generateSessionToken(rt.userId, user.username);
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
token: sessionToken,
|
||||
userId: rt.userId,
|
||||
username: user.username,
|
||||
did: user.did,
|
||||
message: 'Recovery successful. Use this token to register a new passkey.',
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
|
|
@ -435,6 +646,178 @@ async function generateSessionToken(userId: string, username: string): Promise<s
|
|||
return sign(payload, CONFIG.jwtSecret);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// RECOVERY PAGE
|
||||
// ============================================================================
|
||||
|
||||
app.get('/recover', (c) => {
|
||||
return c.html(`
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>EncryptID — Account Recovery</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
}
|
||||
.card {
|
||||
background: rgba(255,255,255,0.05);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 16px;
|
||||
padding: 2.5rem;
|
||||
max-width: 440px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
.logo { font-size: 3rem; margin-bottom: 0.5rem; }
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
background: linear-gradient(90deg, #00d4ff, #7c3aed);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
.subtitle { color: #94a3b8; margin-bottom: 1.5rem; font-size: 0.9rem; }
|
||||
.status { padding: 1rem; border-radius: 8px; margin-bottom: 1rem; font-size: 0.9rem; }
|
||||
.status.loading { background: rgba(6,182,212,0.1); color: #06b6d4; }
|
||||
.status.success { background: rgba(34,197,94,0.1); color: #22c55e; }
|
||||
.status.error { background: rgba(239,68,68,0.1); color: #ef4444; }
|
||||
.btn {
|
||||
display: inline-block; padding: 0.75rem 2rem;
|
||||
background: linear-gradient(90deg, #00d4ff, #7c3aed);
|
||||
color: #fff; border: none; border-radius: 8px;
|
||||
font-weight: 600; font-size: 1rem; cursor: pointer;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.btn:hover { transform: translateY(-2px); }
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
|
||||
.hidden { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="logo">🔒</div>
|
||||
<h1>Account Recovery</h1>
|
||||
<p class="subtitle">Verify your recovery link and add a new passkey</p>
|
||||
|
||||
<div id="status" class="status loading">Verifying recovery token...</div>
|
||||
|
||||
<div id="register-section" class="hidden">
|
||||
<button id="register-btn" class="btn" onclick="registerNewPasskey()">
|
||||
Add New Passkey
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="success-section" class="hidden">
|
||||
<p style="color:#94a3b8;margin-top:1rem;">You can now sign in with your new passkey on any r*.online app.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const recoveryToken = params.get('token');
|
||||
let sessionToken = null;
|
||||
let userId = null;
|
||||
let username = null;
|
||||
|
||||
const statusEl = document.getElementById('status');
|
||||
const registerSection = document.getElementById('register-section');
|
||||
const successSection = document.getElementById('success-section');
|
||||
|
||||
async function verifyToken() {
|
||||
if (!recoveryToken) {
|
||||
statusEl.className = 'status error';
|
||||
statusEl.textContent = 'No recovery token found in link.';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch('/api/recovery/email/verify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: recoveryToken }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) {
|
||||
statusEl.className = 'status error';
|
||||
statusEl.textContent = data.error || 'Invalid or expired recovery link.';
|
||||
return;
|
||||
}
|
||||
sessionToken = data.token;
|
||||
userId = data.userId;
|
||||
username = data.username;
|
||||
statusEl.className = 'status success';
|
||||
statusEl.textContent = 'Recovery verified! Now register a new passkey for your account.';
|
||||
registerSection.classList.remove('hidden');
|
||||
} catch (e) {
|
||||
statusEl.className = 'status error';
|
||||
statusEl.textContent = 'Failed to verify recovery token.';
|
||||
}
|
||||
}
|
||||
|
||||
async function registerNewPasskey() {
|
||||
const btn = document.getElementById('register-btn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Registering...';
|
||||
try {
|
||||
// Start registration
|
||||
const startRes = await fetch('/api/register/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + sessionToken },
|
||||
body: JSON.stringify({ username, displayName: username }),
|
||||
});
|
||||
const { options } = await startRes.json();
|
||||
|
||||
// Decode challenge for WebAuthn
|
||||
options.challenge = Uint8Array.from(atob(options.challenge.replace(/-/g,'+').replace(/_/g,'/')), c => c.charCodeAt(0));
|
||||
options.user.id = Uint8Array.from(atob(options.user.id.replace(/-/g,'+').replace(/_/g,'/')), c => c.charCodeAt(0));
|
||||
|
||||
// WebAuthn ceremony
|
||||
const credential = await navigator.credentials.create({ publicKey: options });
|
||||
const credentialData = {
|
||||
credentialId: btoa(String.fromCharCode(...new Uint8Array(credential.rawId))).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=+$/,''),
|
||||
publicKey: btoa(String.fromCharCode(...new Uint8Array(credential.response.getPublicKey?.() || new ArrayBuffer(0)))).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=+$/,''),
|
||||
transports: credential.response.getTransports?.() || [],
|
||||
};
|
||||
|
||||
// Complete registration
|
||||
const completeRes = await fetch('/api/register/complete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ challenge: options.challenge, credential: credentialData, userId, username }),
|
||||
});
|
||||
const result = await completeRes.json();
|
||||
if (result.success) {
|
||||
statusEl.className = 'status success';
|
||||
statusEl.textContent = 'New passkey registered successfully!';
|
||||
registerSection.classList.add('hidden');
|
||||
successSection.classList.remove('hidden');
|
||||
} else {
|
||||
throw new Error(result.error || 'Registration failed');
|
||||
}
|
||||
} catch (e) {
|
||||
statusEl.className = 'status error';
|
||||
statusEl.textContent = 'Failed to register passkey: ' + e.message;
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Add New Passkey';
|
||||
}
|
||||
}
|
||||
|
||||
verifyToken();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// SERVE STATIC FILES
|
||||
// ============================================================================
|
||||
|
|
@ -617,9 +1000,10 @@ initDatabase().catch(err => {
|
|||
process.exit(1);
|
||||
});
|
||||
|
||||
// Clean expired challenges every 10 minutes
|
||||
// Clean expired challenges and recovery tokens every 10 minutes
|
||||
setInterval(() => {
|
||||
cleanExpiredChallenges().catch(() => {});
|
||||
cleanExpiredRecoveryTokens().catch(() => {});
|
||||
}, 10 * 60 * 1000);
|
||||
|
||||
console.log(`
|
||||
|
|
@ -632,6 +1016,7 @@ console.log(`
|
|||
║ Port: ${CONFIG.port} ║
|
||||
║ RP ID: ${CONFIG.rpId} ║
|
||||
║ Storage: PostgreSQL ║
|
||||
║ SMTP: ${CONFIG.smtp.pass ? CONFIG.smtp.host : 'disabled (no SMTP_PASS)'}${' '.repeat(Math.max(0, 36 - (CONFIG.smtp.pass ? CONFIG.smtp.host.length : 26)))}║
|
||||
║ ║
|
||||
╚═══════════════════════════════════════════════════════════╝
|
||||
`);
|
||||
|
|
|
|||
Loading…
Reference in New Issue