From 89fba95e40afac0a937e80b0c4d2094c05dec227 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sun, 15 Feb 2026 09:35:53 -0700 Subject: [PATCH] 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 --- docker-compose.encryptid.yml | 6 + package.json | 2 + src/encryptid/db.ts | 68 ++++++ src/encryptid/schema.sql | 15 ++ src/encryptid/server.ts | 387 ++++++++++++++++++++++++++++++++++- 5 files changed, 477 insertions(+), 1 deletion(-) diff --git a/docker-compose.encryptid.yml b/docker-compose.encryptid.yml index 6a8827f..2bc51e0 100644 --- a/docker-compose.encryptid.yml +++ b/docker-compose.encryptid.yml @@ -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 } + - RECOVERY_URL=${RECOVERY_URL:-https://encryptid.jeffemmett.com/recover} labels: # Traefik auto-discovery - "traefik.enable=true" diff --git a/package.json b/package.json index f95f83a..4489ea2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/encryptid/db.ts b/src/encryptid/db.ts index 5202608..b2c5472 100644 --- a/src/encryptid/db.ts +++ b/src/encryptid/db.ts @@ -189,6 +189,74 @@ export async function cleanExpiredChallenges(): Promise { return result.count; } +// ============================================================================ +// USER EMAIL OPERATIONS +// ============================================================================ + +export async function setUserEmail(userId: string, email: string): Promise { + 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 { + 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 { + 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 { + await sql`UPDATE recovery_tokens SET used = TRUE WHERE token = ${token}`; +} + +export async function cleanExpiredRecoveryTokens(): Promise { + const result = await sql`DELETE FROM recovery_tokens WHERE expires_at < NOW()`; + return result.count; +} + // ============================================================================ // HEALTH CHECK // ============================================================================ diff --git a/src/encryptid/schema.sql b/src/encryptid/schema.sql index 28ad930..b1d4606 100644 --- a/src/encryptid/schema.sql +++ b/src/encryptid/schema.sql @@ -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); diff --git a/src/encryptid/server.ts b/src/encryptid/server.ts index d011333..198730b 100644 --- a/src/encryptid/server.ts +++ b/src/encryptid/server.ts @@ -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 ', + }, + 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 { + 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: ` + + + + + + +
+ + + + + +
+
🔒
+

EncryptID

+
+

Hi ${username},

+

A recovery request was made for your EncryptID account. Click below to add a new passkey:

+
+ Recover Account +
+

This link expires in 30 minutes.

+

If you didn't request this, you can safely ignore this email — your account is secure.

+

+ Can't click the button? Copy this link:
+ ${recoveryLink} +

+
+
+ +`, + }); + + 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 { + return c.html(` + + + + + + EncryptID — Account Recovery + + + +
+ +

Account Recovery

+

Verify your recovery link and add a new passkey

+ +
Verifying recovery token...
+ + + + +
+ + + + + `); +}); + // ============================================================================ // 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)))}║ ║ ║ ╚═══════════════════════════════════════════════════════════╝ `);