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
|
- PORT=3000
|
||||||
- JWT_SECRET=${JWT_SECRET:-change-this-in-production}
|
- JWT_SECRET=${JWT_SECRET:-change-this-in-production}
|
||||||
- DATABASE_URL=postgres://encryptid:${ENCRYPTID_DB_PASSWORD:-encryptid}@encryptid-db:5432/encryptid
|
- 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:
|
labels:
|
||||||
# Traefik auto-discovery
|
# Traefik auto-discovery
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
|
|
|
||||||
|
|
@ -18,10 +18,12 @@
|
||||||
"@lit/reactive-element": "^2.0.4",
|
"@lit/reactive-element": "^2.0.4",
|
||||||
"hono": "^4.11.7",
|
"hono": "^4.11.7",
|
||||||
"postgres": "^3.4.5",
|
"postgres": "^3.4.5",
|
||||||
|
"nodemailer": "^6.9.0",
|
||||||
"perfect-arrows": "^0.3.7",
|
"perfect-arrows": "^0.3.7",
|
||||||
"perfect-freehand": "^1.2.2"
|
"perfect-freehand": "^1.2.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/nodemailer": "^6.4.0",
|
||||||
"@types/node": "^22.10.1",
|
"@types/node": "^22.10.1",
|
||||||
"bun-types": "^1.1.38",
|
"bun-types": "^1.1.38",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
|
|
|
||||||
|
|
@ -189,6 +189,74 @@ export async function cleanExpiredChallenges(): Promise<number> {
|
||||||
return result.count;
|
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
|
// HEALTH CHECK
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,12 @@ CREATE TABLE IF NOT EXISTS users (
|
||||||
username TEXT UNIQUE NOT NULL,
|
username TEXT UNIQUE NOT NULL,
|
||||||
display_name TEXT,
|
display_name TEXT,
|
||||||
did TEXT,
|
did TEXT,
|
||||||
|
email TEXT,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS credentials (
|
CREATE TABLE IF NOT EXISTS credentials (
|
||||||
credential_id TEXT PRIMARY KEY,
|
credential_id TEXT PRIMARY KEY,
|
||||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
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)
|
-- Auto-clean expired challenges (run periodically or use pg_cron)
|
||||||
CREATE INDEX IF NOT EXISTS idx_challenges_expires_at ON challenges(expires_at);
|
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 { logger } from 'hono/logger';
|
||||||
import { serveStatic } from 'hono/bun';
|
import { serveStatic } from 'hono/bun';
|
||||||
import { sign, verify } from 'hono/jwt';
|
import { sign, verify } from 'hono/jwt';
|
||||||
|
import { createTransport, type Transporter } from 'nodemailer';
|
||||||
import {
|
import {
|
||||||
initDatabase,
|
initDatabase,
|
||||||
storeCredential,
|
storeCredential,
|
||||||
|
|
@ -22,10 +23,18 @@ import {
|
||||||
getChallenge,
|
getChallenge,
|
||||||
deleteChallenge,
|
deleteChallenge,
|
||||||
cleanExpiredChallenges,
|
cleanExpiredChallenges,
|
||||||
|
cleanExpiredRecoveryTokens,
|
||||||
checkDatabaseHealth,
|
checkDatabaseHealth,
|
||||||
createUser,
|
createUser,
|
||||||
|
setUserEmail,
|
||||||
|
getUserByEmail,
|
||||||
|
getUserById,
|
||||||
|
storeRecoveryToken,
|
||||||
|
getRecoveryToken,
|
||||||
|
markRecoveryTokenUsed,
|
||||||
type StoredCredential,
|
type StoredCredential,
|
||||||
type StoredChallenge,
|
type StoredChallenge,
|
||||||
|
type StoredRecoveryToken,
|
||||||
} from './db.js';
|
} from './db.js';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -39,6 +48,15 @@ const CONFIG = {
|
||||||
jwtSecret: process.env.JWT_SECRET || 'dev-secret-change-in-production',
|
jwtSecret: process.env.JWT_SECRET || 'dev-secret-change-in-production',
|
||||||
sessionDuration: 15 * 60, // 15 minutes
|
sessionDuration: 15 * 60, // 15 minutes
|
||||||
refreshDuration: 7 * 24 * 60 * 60, // 7 days
|
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: [
|
allowedOrigins: [
|
||||||
'https://encryptid.jeffemmett.com',
|
'https://encryptid.jeffemmett.com',
|
||||||
'https://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
|
// 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
|
// HELPER FUNCTIONS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -435,6 +646,178 @@ async function generateSessionToken(userId: string, username: string): Promise<s
|
||||||
return sign(payload, CONFIG.jwtSecret);
|
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
|
// SERVE STATIC FILES
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -617,9 +1000,10 @@ initDatabase().catch(err => {
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clean expired challenges every 10 minutes
|
// Clean expired challenges and recovery tokens every 10 minutes
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
cleanExpiredChallenges().catch(() => {});
|
cleanExpiredChallenges().catch(() => {});
|
||||||
|
cleanExpiredRecoveryTokens().catch(() => {});
|
||||||
}, 10 * 60 * 1000);
|
}, 10 * 60 * 1000);
|
||||||
|
|
||||||
console.log(`
|
console.log(`
|
||||||
|
|
@ -632,6 +1016,7 @@ console.log(`
|
||||||
║ Port: ${CONFIG.port} ║
|
║ Port: ${CONFIG.port} ║
|
||||||
║ RP ID: ${CONFIG.rpId} ║
|
║ RP ID: ${CONFIG.rpId} ║
|
||||||
║ Storage: PostgreSQL ║
|
║ 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