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:
Jeff Emmett 2026-02-15 09:35:53 -07:00
parent 38636862d8
commit 89fba95e40
5 changed files with 477 additions and 1 deletions

View File

@ -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"

View File

@ -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",

View File

@ -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
// ============================================================================ // ============================================================================

View File

@ -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);

View File

@ -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;">&#128274;</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">&#128274;</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)))}
`); `);