diff --git a/scripts/dev-seed.sql b/scripts/dev-seed.sql index 504743a..1b45afe 100644 --- a/scripts/dev-seed.sql +++ b/scripts/dev-seed.sql @@ -4,14 +4,17 @@ INSERT INTO domains (id, name, punycode_name, is_active) VALUES ('00000000-0000-0000-0000-000000000001', 'example.test', 'example.test', true) ON CONFLICT (name) DO NOTHING; --- Password: "dev-password-only" hashed with Argon2id - never use in production. +-- Password: "dev-password-only", a real Argon2id PHC hash - never use in +-- production. The value here used to be a hand-written placeholder whose +-- digest was 24 bytes instead of 32, matching no password at all; nothing +-- caught it while the auth service did not verify passwords. INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash, is_active) VALUES ( '00000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000001', 'postmaster', 'postmaster@example.test', - '$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG', + '$argon2id$v=19$m=65536,t=3,p=4$uPTQ1A3ZH47pN8AIf2h2dQ$rqu1BtCd9yDUtZQ4oUZlK7djiQlY4heqE6DJUn29lRg', true ) ON CONFLICT (domain_id, local_part) DO NOTHING; diff --git a/services/auth/package-lock.json b/services/auth/package-lock.json index 57df668..47042f1 100644 --- a/services/auth/package-lock.json +++ b/services/auth/package-lock.json @@ -8,6 +8,7 @@ "name": "@lambdamail/auth", "version": "0.1.0", "dependencies": { + "hash-wasm": "^4.12.0", "ioredis": "^5.4.1", "pg": "^8.12.0" }, @@ -514,9 +515,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -531,9 +529,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -548,9 +543,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -565,9 +557,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -582,9 +571,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -599,9 +585,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -616,9 +599,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -633,9 +613,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -650,9 +627,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -667,9 +641,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -684,9 +655,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -701,9 +669,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -718,9 +683,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1126,6 +1088,12 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/hash-wasm": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/hash-wasm/-/hash-wasm-4.12.0.tgz", + "integrity": "sha512-+/2B2rYLb48I/evdOIhP+K/DD2ca2fgBjp6O+GBEnCDk2e4rpeXIK8GvIyRPjTezgmWn9gmKwkQjjx6BtqDHVQ==", + "license": "MIT" + }, "node_modules/ioredis": { "version": "5.11.1", "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.1.tgz", diff --git a/services/auth/package.json b/services/auth/package.json index 4d1ca44..de65b35 100644 --- a/services/auth/package.json +++ b/services/auth/package.json @@ -10,8 +10,9 @@ "lint": "tsc --noEmit" }, "dependencies": { - "pg": "^8.12.0", - "ioredis": "^5.4.1" + "hash-wasm": "^4.12.0", + "ioredis": "^5.4.1", + "pg": "^8.12.0" }, "devDependencies": { "@types/node": "^20.14.0", diff --git a/services/auth/src/auth.test.ts b/services/auth/src/auth.test.ts index 84c1621..df3cd7b 100644 --- a/services/auth/src/auth.test.ts +++ b/services/auth/src/auth.test.ts @@ -5,11 +5,36 @@ import { generateRecoveryCodes, generateAppPassword, hashAppPassword, verifyAppP import { createJwt, verifyJwt, isSurfaceAuthorized, isStepUpMfaRequired } from "./session.js"; describe("crypto module", () => { - it("hashes and verifies passwords correctly", () => { + it("hashes and verifies passwords correctly", async () => { const password = "SecretPassword123!"; - const hash = hashPassword(password); - expect(verifyPassword(password, hash)).toBe(true); - expect(verifyPassword("WrongPassword", hash)).toBe(false); + const hash = await hashPassword(password); + expect(hash.startsWith("$argon2id$")).toBe(true); + expect(await verifyPassword(password, hash)).toBe(true); + expect(await verifyPassword("WrongPassword", hash)).toBe(false); + }); + + // The Go service (alexedwards/argon2id) writes the hashes this service has + // to accept, and vice versa. A mismatch means a password set through the + // mail stack cannot be used to log into the webmail. + it("verifies an Argon2id hash produced by the Go service", async () => { + const goHash = "$argon2id$v=19$m=65536,t=1,p=16$lw9+BftyPYW4ILsZu9ZAPw$ueO26QNhturq/vuNMlEgVZhXTZwQi+tPYeUFjaeRdIs"; + expect(await verifyPassword("dev-password-only", goHash)).toBe(true); + expect(await verifyPassword("wrong", goHash)).toBe(false); + }); + + // Regression: verifyPassword used to fall back to comparing the password + // against the stored hash, so anyone holding a database dump could + // authenticate by sending the hash back as the password. + it("refuses to accept the stored hash itself as the password", async () => { + const hash = await hashPassword("RealPassword123!"); + expect(await verifyPassword(hash, hash)).toBe(false); + }); + + it("rejects any hash that is not argon2id, instead of guessing a scheme", async () => { + const sha256OfPassword = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"; + expect(await verifyPassword("password", sha256OfPassword)).toBe(false); + expect(await verifyPassword("password", "pbkdf2$sha512$100000$abc$def")).toBe(false); + expect(await verifyPassword("password", "")).toBe(false); }); it("encrypts and decrypts secrets using AES-256-GCM", () => { @@ -51,18 +76,19 @@ describe("totp module", () => { }); describe("mfaHelpers module", () => { - it("generates 10 recovery codes and hashes them", () => { - const { rawCodes, hashedCodes } = generateRecoveryCodes(10); + it("generates 10 recovery codes and hashes them", async () => { + const { rawCodes, hashedCodes } = await generateRecoveryCodes(10); expect(rawCodes.length).toBe(10); expect(hashedCodes.length).toBe(10); - expect(verifyPassword(rawCodes[0], hashedCodes[0])).toBe(true); + expect(await verifyPassword(rawCodes[0], hashedCodes[0])).toBe(true); + expect(hashedCodes.every((h) => h.startsWith("$argon2id$"))).toBe(true); }); - it("generates app passwords with prefix", () => { + it("generates app passwords with prefix", async () => { const appPass = generateAppPassword(); expect(appPass.startsWith("lmp_")).toBe(true); - const hash = hashAppPassword(appPass); - expect(verifyAppPassword(appPass, hash)).toBe(true); + const hash = await hashAppPassword(appPass); + expect(await verifyAppPassword(appPass, hash)).toBe(true); }); }); @@ -76,6 +102,7 @@ describe("surface isolation and session module", () => { surface: "user", aud: "lambdamail:user", mfaSatisfied: false, + purpose: "session", }); const parsed = verifyJwt(userToken); @@ -95,6 +122,7 @@ describe("surface isolation and session module", () => { aud: "lambdamail:admin", mfaSatisfied: true, mfaSatisfiedAt: Date.now(), + purpose: "session", }); const parsed = verifyJwt(adminToken); @@ -114,9 +142,33 @@ describe("surface isolation and session module", () => { aud: "lambdamail:admin", mfaSatisfied: true, mfaSatisfiedAt: oldTimestamp, + purpose: "session", }); const parsed = verifyJwt(adminToken)!; expect(isStepUpMfaRequired(parsed, 15)).toBe(true); }); }); + +// A challenge token is issued after the password but before the second +// factor. Treating it as a session would skip MFA entirely. +describe("challenge tokens are not sessions", () => { + it("refuses a challenge token wherever a session is required", () => { + const challenge = createJwt({ + sub: "mailbox-123", + email: "admin@domain.com", + role: "SUPER_ADMIN", + domainId: "domain-123", + surface: "admin", + aud: "lambdamail:admin", + mfaSatisfied: true, + mfaSatisfiedAt: Date.now(), + purpose: "mfa_challenge", + }); + + const parsed = verifyJwt(challenge); + expect(parsed).not.toBeNull(); + expect(isSurfaceAuthorized(parsed, "admin")).toBe(false); + expect(isSurfaceAuthorized(parsed, "user")).toBe(false); + }); +}); diff --git a/services/auth/src/crypto.ts b/services/auth/src/crypto.ts index 1108785..a578d9e 100644 --- a/services/auth/src/crypto.ts +++ b/services/auth/src/crypto.ts @@ -1,32 +1,66 @@ import crypto from "node:crypto"; +import { argon2id, argon2Verify } from "hash-wasm"; const MASTER_KEY_ENV = "LAMBDAMAIL_MASTER_KEY"; -const DEFAULT_KEY_FOR_DEV = "lambdamail_default_master_key_32bytes!!"; +// Argon2id parameters. These match what the Go side (alexedwards/argon2id) +// and PLAN.md section 9 expect, so a password set through the mail stack +// verifies here and vice versa - both produce and consume the same PHC string. +const ARGON2_MEMORY_KIB = 65536; +const ARGON2_ITERATIONS = 3; +const ARGON2_PARALLELISM = 4; +const ARGON2_HASH_LENGTH = 32; +const ARGON2_SALT_BYTES = 16; + +/** + * Derives the key-encryption key. Unlike the previous implementation there is + * no development fallback: a missing master key silently encrypting every TOTP + * secret under a value published in the source tree is worse than refusing to + * start (PLAN.md risk R9). + */ function getMasterKey(): Buffer { - const envKey = process.env[MASTER_KEY_ENV] || DEFAULT_KEY_FOR_DEV; + const envKey = process.env[MASTER_KEY_ENV]; + if (!envKey || envKey.length < 16) { + throw new Error( + `${MASTER_KEY_ENV} must be set to at least 16 characters; refusing to encrypt secrets under a default key`, + ); + } return crypto.createHash("sha256").update(envKey).digest(); } -export function hashPassword(password: string): string { - const salt = crypto.randomBytes(16).toString("hex"); - const hash = crypto.pbkdf2Sync(password, salt, 100000, 64, "sha512").toString("hex"); - return `pbkdf2$sha512$100000$${salt}$${hash}`; +/** Produces an Argon2id PHC string, the only format this service stores. */ +export async function hashPassword(password: string): Promise { + return argon2id({ + password, + salt: crypto.randomBytes(ARGON2_SALT_BYTES), + parallelism: ARGON2_PARALLELISM, + iterations: ARGON2_ITERATIONS, + memorySize: ARGON2_MEMORY_KIB, + hashLength: ARGON2_HASH_LENGTH, + outputType: "encoded", + }); } -export function verifyPassword(password: string, storedHash: string): boolean { - if (!storedHash.startsWith("pbkdf2$sha512$")) { - // Fallback for simple SHA256 hashes in tests/seeds if present - const simpleHash = crypto.createHash("sha256").update(password).digest("hex"); - return simpleHash === storedHash || password === storedHash; +/** + * Verifies a password against an Argon2id PHC string. + * + * Anything that is not an argon2id PHC string is rejected outright. The + * previous implementation fell back to comparing the password against the + * stored hash itself, which turned every stored hash into a usable credential: + * anyone holding a database dump could authenticate by sending the hash back. + * There is no format to be lenient about here - the schema stores exactly one. + */ +export async function verifyPassword(password: string, storedHash: string): Promise { + if (!storedHash || !storedHash.startsWith("$argon2id$")) { + return false; + } + try { + return await argon2Verify({ password, hash: storedHash }); + } catch { + // A malformed stored hash is a failed login, not a crash that would take + // the login endpoint down for everyone. + return false; } - const parts = storedHash.split("$"); - if (parts.length !== 5) return false; - const iterations = parseInt(parts[2], 10); - const salt = parts[3]; - const originalHash = parts[4]; - const hash = crypto.pbkdf2Sync(password, salt, iterations, 64, "sha512").toString("hex"); - return crypto.timingSafeEqual(Buffer.from(hash, "hex"), Buffer.from(originalHash, "hex")); } export function encryptSecret(plaintext: string): { encrypted: Buffer; nonce: Buffer; keyVersion: number } { @@ -37,11 +71,21 @@ export function encryptSecret(plaintext: string): { encrypted: Buffer; nonce: Bu return { encrypted, nonce, keyVersion: 1 }; } -export function decryptSecret(encrypted: Buffer, nonce: Buffer, keyVersion = 1): string { +export function decryptSecret(encrypted: Buffer, nonce: Buffer, _keyVersion = 1): string { const key = getMasterKey(); const authTag = encrypted.subarray(encrypted.length - 16); const ciphertext = encrypted.subarray(0, encrypted.length - 16); const decipher = crypto.createDecipheriv("aes-256-gcm", key, nonce); decipher.setAuthTag(authTag); - return decipher.update(ciphertext) + decipher.final("utf8"); + // Concatenated as buffers before decoding: decoding each chunk separately + // would corrupt any multi-byte character that straddles the boundary. + return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8"); +} + +/** Constant-time comparison of two short strings of possibly different length. */ +export function safeEqual(a: string, b: string): boolean { + const bufA = Buffer.from(a); + const bufB = Buffer.from(b); + if (bufA.length !== bufB.length) return false; + return crypto.timingSafeEqual(bufA, bufB); } diff --git a/services/auth/src/db.ts b/services/auth/src/db.ts new file mode 100644 index 0000000..5233df7 --- /dev/null +++ b/services/auth/src/db.ts @@ -0,0 +1,34 @@ +import { Pool, type QueryResultRow } from "pg"; + +let pool: Pool | null = null; + +/** + * Lazily opens the shared connection pool. It is lazy so that unit tests which + * never touch the database do not need one running. + */ +export function getPool(): Pool { + if (!pool) { + pool = new Pool({ + connectionString: process.env.DATABASE_URL, + max: Number(process.env.PG_POOL_MAX ?? 10), + }); + } + return pool; +} + +export async function query(text: string, params: unknown[] = []): Promise { + const result = await getPool().query(text, params); + return result.rows; +} + +export async function queryOne(text: string, params: unknown[] = []): Promise { + const rows = await query(text, params); + return rows.length > 0 ? rows[0] : null; +} + +export async function closePool(): Promise { + if (pool) { + await pool.end(); + pool = null; + } +} diff --git a/services/auth/src/mfaHelpers.ts b/services/auth/src/mfaHelpers.ts index 2d7b7e7..3674626 100644 --- a/services/auth/src/mfaHelpers.ts +++ b/services/auth/src/mfaHelpers.ts @@ -1,30 +1,28 @@ import crypto from "node:crypto"; import { hashPassword, verifyPassword } from "./crypto.js"; -export function generateRecoveryCodes(count = 10): { rawCodes: string[]; hashedCodes: string[] } { +/** + * Generates single-use recovery codes. Only the hashes are ever stored, so a + * database dump cannot be turned back into usable codes (PLAN.md section 9). + */ +export async function generateRecoveryCodes(count = 10): Promise<{ rawCodes: string[]; hashedCodes: string[] }> { const rawCodes: string[] = []; - const hashedCodes: string[] = []; - for (let i = 0; i < count; i++) { - const raw = crypto.randomBytes(6).toString("hex").substring(0, 10).toUpperCase(); - rawCodes.push(raw); - hashedCodes.push(hashPassword(raw)); + rawCodes.push(crypto.randomBytes(6).toString("hex").substring(0, 10).toUpperCase()); } - + const hashedCodes = await Promise.all(rawCodes.map((c) => hashPassword(c))); return { rawCodes, hashedCodes }; } export function generateAppPassword(): string { - const part1 = crypto.randomBytes(4).toString("hex"); - const part2 = crypto.randomBytes(4).toString("hex"); - const part3 = crypto.randomBytes(4).toString("hex"); - return `lmp_${part1}-${part2}-${part3}`; + const part = () => crypto.randomBytes(4).toString("hex"); + return `lmp_${part()}-${part()}-${part()}`; } -export function hashAppPassword(rawPassword: string): string { +export async function hashAppPassword(rawPassword: string): Promise { return hashPassword(rawPassword); } -export function verifyAppPassword(rawPassword: string, storedHash: string): boolean { +export async function verifyAppPassword(rawPassword: string, storedHash: string): Promise { return verifyPassword(rawPassword, storedHash); } diff --git a/services/auth/src/repository.ts b/services/auth/src/repository.ts new file mode 100644 index 0000000..7ee1f30 --- /dev/null +++ b/services/auth/src/repository.ts @@ -0,0 +1,395 @@ +import { query, queryOne } from "./db.js"; +import { verifyPassword, hashPassword, encryptSecret, decryptSecret } from "./crypto.js"; +import { verifyTotpCode } from "./totp.js"; + +// A locked account stays locked for this long once the threshold is reached. +// PLAN.md section 5.2 asks for a temporary block after N failures rather than a +// permanent one, so a wrong-password storm cannot lock a user out for good. +const MAX_FAILED_LOGINS = 5; +const LOCKOUT_MINUTES = 15; + +export interface MailboxRecord { + id: string; + email_address: string; + password_hash: string; + role: "USER" | "DOMAIN_ADMIN" | "SUPER_ADMIN"; + domain_id: string; + locale: string; + is_active: boolean; + failed_login_count: number; + locked_until: Date | null; + mfa_policy: string; +} + +export type LoginOutcome = + | { status: "OK"; mailbox: MailboxRecord; mfaRequired: boolean } + | { status: "INVALID_CREDENTIALS" } + | { status: "LOCKED"; until: Date } + | { status: "INACTIVE" }; + +export async function findMailboxByEmail(email: string): Promise { + return queryOne( + `SELECT m.id, m.email_address, m.password_hash, m.role, m.domain_id, m.locale, + m.is_active, m.failed_login_count, m.locked_until, d.mfa_policy + FROM mailboxes m + JOIN domains d ON d.id = m.domain_id + WHERE m.email_address = $1`, + [email], + ); +} + +export async function hasConfirmedTotp(mailboxId: string): Promise { + const row = await queryOne<{ count: string }>( + `SELECT count(*)::text AS count FROM mfa_totp WHERE mailbox_id = $1 AND status = 'CONFIRMED'`, + [mailboxId], + ); + return Number(row?.count ?? 0) > 0; +} + +/** + * Decides whether this account must present a second factor now. + * + * This is driven by what is actually enrolled, not by policy: a factor that + * does not exist cannot be demanded, or an account the policy covers could + * never reach the screen where enrollment happens. Policy decides who *must* + * enroll (see isMfaEnrollmentRequired) and the admin surface refuses to open + * at all until they have, which is where PLAN.md section 14.5 is enforced. + */ +export async function isMfaRequired(mailbox: MailboxRecord): Promise { + return hasConfirmedTotp(mailbox.id); +} + +/** Whether policy obliges this account to enroll a second factor. */ +export function isMfaEnrollmentRequired(mailbox: MailboxRecord): boolean { + if (mailbox.mfa_policy === "required_all") return true; + if (mailbox.mfa_policy === "required_admins" && mailbox.role !== "USER") return true; + return false; +} + +/** + * Authenticates a password against the stored Argon2id hash. + * + * The failure counter is advanced on every wrong password and cleared on + * success, and a locked account is refused before the hash is even computed - + * which also stops the lockout window from being used as an oracle. + */ +export async function authenticate(email: string, password: string): Promise { + const mailbox = await findMailboxByEmail(email); + + if (!mailbox) { + // Hash anyway so that a missing account and a wrong password take + // comparable time, instead of the response latency revealing which + // addresses exist. + await verifyPassword(password, "$argon2id$v=19$m=65536,t=3,p=4$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); + return { status: "INVALID_CREDENTIALS" }; + } + + if (mailbox.locked_until && mailbox.locked_until.getTime() > Date.now()) { + return { status: "LOCKED", until: mailbox.locked_until }; + } + + if (!mailbox.is_active) { + return { status: "INACTIVE" }; + } + + const ok = await verifyPassword(password, mailbox.password_hash); + if (!ok) { + await registerFailedLogin(mailbox.id); + return { status: "INVALID_CREDENTIALS" }; + } + + await query( + `UPDATE mailboxes SET failed_login_count = 0, locked_until = NULL, updated_at = NOW() WHERE id = $1`, + [mailbox.id], + ); + + return { status: "OK", mailbox, mfaRequired: await isMfaRequired(mailbox) }; +} + +async function registerFailedLogin(mailboxId: string): Promise { + await query( + `UPDATE mailboxes + SET failed_login_count = failed_login_count + 1, + locked_until = CASE WHEN failed_login_count + 1 >= $2 + THEN NOW() + ($3 || ' minutes')::INTERVAL + ELSE locked_until END, + updated_at = NOW() + WHERE id = $1`, + [mailboxId, MAX_FAILED_LOGINS, String(LOCKOUT_MINUTES)], + ); +} + +// --------------------------------------------------------------------- TOTP + +/** + * Starts enrollment: the secret is stored PENDING and encrypted at rest, and + * only the freshly generated one is returned to the caller. It is never + * accepted back from the client - a secret the client chooses is a secret the + * client can replay for somebody else's account. + */ +export async function startTotpEnrollment(mailboxId: string, base32Secret: string, label = "Authenticator"): Promise { + const { encrypted, nonce, keyVersion } = encryptSecret(base32Secret); + // Any earlier unconfirmed attempt is replaced, so a user who restarts + // enrollment is not left with rows nobody can complete. + await query(`DELETE FROM mfa_totp WHERE mailbox_id = $1 AND status = 'PENDING'`, [mailboxId]); + await query( + `INSERT INTO mfa_totp (mailbox_id, label, secret_enc, secret_nonce, key_version, status) + VALUES ($1, $2, $3, $4, $5, 'PENDING')`, + [mailboxId, label, encrypted, nonce, keyVersion], + ); +} + +interface TotpRow { + id: string; + secret_enc: Buffer; + secret_nonce: Buffer; + key_version: number; + last_used_step: string | null; +} + +async function loadTotp(mailboxId: string, status: "PENDING" | "CONFIRMED"): Promise { + return queryOne( + `SELECT id, secret_enc, secret_nonce, key_version, last_used_step + FROM mfa_totp WHERE mailbox_id = $1 AND status = $2 + ORDER BY created_at DESC LIMIT 1`, + [mailboxId, status], + ); +} + +/** Confirms enrollment by proving one code against the stored secret. */ +export async function confirmTotpEnrollment(mailboxId: string, code: string): Promise { + const row = await loadTotp(mailboxId, "PENDING"); + if (!row) return false; + + const secret = decryptSecret(row.secret_enc, row.secret_nonce, row.key_version); + const result = verifyTotpCode(secret, code, row.last_used_step === null ? null : Number(row.last_used_step)); + if (!result.valid) return false; + + await query( + `UPDATE mfa_totp SET status = 'CONFIRMED', confirmed_at = NOW(), last_used_step = $2 WHERE id = $1`, + [row.id, result.step], + ); + return true; +} + +/** + * Verifies a code for an already-enrolled account and burns the step it used, + * so the same code cannot be replayed inside its own validity window + * (RFC 6238 section 5.2). + */ +export async function verifyTotpForLogin(mailboxId: string, code: string): Promise { + const row = await loadTotp(mailboxId, "CONFIRMED"); + if (!row) return false; + + const secret = decryptSecret(row.secret_enc, row.secret_nonce, row.key_version); + const result = verifyTotpCode(secret, code, row.last_used_step === null ? null : Number(row.last_used_step)); + if (!result.valid) return false; + + await query(`UPDATE mfa_totp SET last_used_step = $2 WHERE id = $1`, [row.id, result.step]); + return true; +} + +// ---------------------------------------------------------- recovery codes + +export async function storeRecoveryCodes(mailboxId: string, hashes: string[]): Promise { + await query(`DELETE FROM mfa_recovery_codes WHERE mailbox_id = $1`, [mailboxId]); + for (const hash of hashes) { + await query(`INSERT INTO mfa_recovery_codes (mailbox_id, code_hash) VALUES ($1, $2)`, [mailboxId, hash]); + } +} + +/** + * Consumes a recovery code. Each row has its own Argon2id hash, so every + * unused code is tried; the matching one is marked used and can never be + * accepted again. + */ +export async function consumeRecoveryCode(mailboxId: string, code: string, ip: string | null): Promise { + const rows = await query<{ id: string; code_hash: string }>( + `SELECT id, code_hash FROM mfa_recovery_codes WHERE mailbox_id = $1 AND used_at IS NULL`, + [mailboxId], + ); + + // Each code carries its own Argon2id hash, so this is linear in the number + // of unused codes. That is a few seconds at worst on a path used once in a + // lost-phone emergency, which is a fair trade for not weakening the hash. + const normalised = code.trim().toUpperCase(); + for (const row of rows) { + if (await verifyPassword(normalised, row.code_hash)) { + // Guarded on used_at so two concurrent requests cannot both spend it. + const updated = await query<{ id: string }>( + `UPDATE mfa_recovery_codes SET used_at = NOW(), used_ip = $2 + WHERE id = $1 AND used_at IS NULL RETURNING id`, + [row.id, ip], + ); + return updated.length === 1; + } + } + return false; +} + +export async function countUnusedRecoveryCodes(mailboxId: string): Promise { + const row = await queryOne<{ count: string }>( + `SELECT count(*)::text AS count FROM mfa_recovery_codes WHERE mailbox_id = $1 AND used_at IS NULL`, + [mailboxId], + ); + return Number(row?.count ?? 0); +} + +// ------------------------------------------------------------ app passwords + +export async function listAppPasswords(mailboxId: string): Promise> { + return query( + `SELECT id, label, created_at, last_used_at FROM app_passwords WHERE mailbox_id = $1 ORDER BY created_at DESC`, + [mailboxId], + ); +} + +export async function createAppPassword(mailboxId: string, label: string, rawPassword: string): Promise { + const hash = await hashPassword(rawPassword); + const row = await queryOne<{ id: string }>( + `INSERT INTO app_passwords (mailbox_id, label, password_hash) VALUES ($1, $2, $3) RETURNING id`, + [mailboxId, label, hash], + ); + return row!.id; +} + +export async function deleteAppPassword(mailboxId: string, id: string): Promise { + const rows = await query<{ id: string }>( + `DELETE FROM app_passwords WHERE id = $1 AND mailbox_id = $2 RETURNING id`, + [id, mailboxId], + ); + return rows.length === 1; +} + +// ---------------------------------------------------------------- sessions + +export async function recordSession( + mailboxId: string, + surface: "user" | "admin", + refreshTokenHash: string, + expiresAt: Date, + ip: string | null, + userAgent: string | null, +): Promise { + await query( + `INSERT INTO web_sessions (mailbox_id, surface, refresh_token_hash, mfa_satisfied, mfa_satisfied_at, ip_address, user_agent, expires_at) + VALUES ($1, $2, $3, true, NOW(), $4, $5, $6)`, + [mailboxId, surface, refreshTokenHash, ip, userAgent, expiresAt], + ); +} + +export async function setLocale(mailboxId: string, locale: string): Promise { + await query(`UPDATE mailboxes SET locale = $2, updated_at = NOW() WHERE id = $1`, [mailboxId, locale]); +} + +// ------------------------------------------------------------ admin views +// +// These read the real tables. The previous implementation returned constants, +// which meant the console showed a healthy system no matter what the server +// was actually doing. + +export async function dashboardStats(): Promise> { + const row = await queryOne>( + `SELECT + (SELECT count(*) FROM email_messages WHERE received_at > NOW() - INTERVAL '24 hours')::text AS inbound_24h, + (SELECT count(*) FROM outbound_jobs WHERE created_at > NOW() - INTERVAL '24 hours')::text AS outbound_24h, + (SELECT count(*) FROM outbound_jobs WHERE status IN ('QUEUED','DEFERRED','SENDING'))::text AS queue_depth, + (SELECT count(*) FROM outbound_jobs WHERE status = 'BOUNCED' + AND created_at > NOW() - INTERVAL '24 hours')::text AS bounced_24h, + (SELECT count(*) FROM domains WHERE dns_status = 'VERIFIED')::text AS domains_verified, + (SELECT count(*) FROM domains)::text AS domains_total, + (SELECT count(*) FROM mailboxes WHERE is_active)::text AS mailboxes_active, + (SELECT COALESCE(sum(used_bytes),0) FROM mailboxes)::text AS storage_used_bytes, + (SELECT COALESCE(sum(quota_bytes),0) FROM mailboxes)::text AS storage_quota_bytes`, + ); + + const num = (k: string) => Number(row?.[k] ?? 0); + const outbound = num("outbound_24h"); + return { + inbound_24h: num("inbound_24h"), + outbound_24h: outbound, + queue_depth: num("queue_depth"), + bounce_rate: outbound > 0 ? Number((num("bounced_24h") / outbound).toFixed(4)) : 0, + domains_verified: num("domains_verified"), + domains_total: num("domains_total"), + mailboxes_active: num("mailboxes_active"), + storage_used_bytes: num("storage_used_bytes"), + storage_quota_bytes: num("storage_quota_bytes"), + }; +} + +export async function listDomains(): Promise { + return query( + `SELECT id, name, dns_status, dmarc_policy, mta_sts_mode, dane_enabled, + is_active, dns_last_checked_at, + (SELECT count(*) FROM mailboxes m WHERE m.domain_id = d.id)::int AS mailbox_count + FROM domains d ORDER BY name`, + ); +} + +export async function listMailboxes(): Promise { + return query( + `SELECT m.id, m.email_address, m.role, m.is_active, m.quota_bytes, m.used_bytes, + m.locale, m.locked_until, d.name AS domain_name, + EXISTS (SELECT 1 FROM mfa_totp t WHERE t.mailbox_id = m.id AND t.status = 'CONFIRMED') AS mfa_enrolled + FROM mailboxes m JOIN domains d ON d.id = m.domain_id + ORDER BY m.email_address LIMIT 200`, + ); +} + +export async function dmarcSummary(): Promise> { + const totals = await queryOne>( + `SELECT COALESCE(sum(count),0)::text AS total_messages, + COALESCE(sum(count) FILTER (WHERE spf_result = 'pass'),0)::text AS spf_pass_count, + COALESCE(sum(count) FILTER (WHERE dkim_result = 'pass'),0)::text AS dkim_pass_count, + COALESCE(sum(count) FILTER (WHERE disposition = 'none'),0)::text AS dmarc_pass_count + FROM dmarc_report_records`, + ); + const sources = await query( + `SELECT source_ip::text AS ip, sum(count)::int AS count, + max(spf_result) AS spf, max(dkim_result) AS dkim + FROM dmarc_report_records GROUP BY source_ip ORDER BY sum(count) DESC LIMIT 20`, + ); + return { + total_messages: Number(totals?.total_messages ?? 0), + spf_pass_count: Number(totals?.spf_pass_count ?? 0), + dkim_pass_count: Number(totals?.dkim_pass_count ?? 0), + dmarc_pass_count: Number(totals?.dmarc_pass_count ?? 0), + sources, + }; +} + +export async function queueSummary(): Promise> { + const byStatus = await query<{ status: string; count: string }>( + `SELECT status, count(*)::text AS count FROM outbound_jobs GROUP BY status`, + ); + const recent = await query( + `SELECT id, envelope_from, envelope_to, destination_domain, status, attempt, + next_attempt_at, last_smtp_code, last_error, tls_policy_used + FROM outbound_jobs ORDER BY created_at DESC LIMIT 50`, + ); + return { + by_status: Object.fromEntries(byStatus.map((r) => [r.status, Number(r.count)])), + recent, + }; +} + +export async function preflightSummary(): Promise> { + // The authoritative preflight lives in the Go service, which owns the + // sockets and the resolver. What the console can report on its own is the + // persisted state, so it reports that and says where the rest comes from. + const domains = await query<{ name: string; dns_status: string; dane_enabled: boolean }>( + `SELECT name, dns_status, dane_enabled FROM domains ORDER BY name`, + ); + const checks = domains.map((d) => ({ + name: `DNS records for ${d.name}`, + status: d.dns_status === "VERIFIED" ? "PASS" : d.dns_status === "PENDING" ? "PENDING" : "FAIL", + detail: d.dns_status, + })); + const failing = checks.filter((c) => c.status === "FAIL").length; + return { + status: failing > 0 ? "DEGRADED" : checks.length === 0 ? "UNKNOWN" : "HEALTHY", + checks, + note: "Socket-level checks (port 25 egress, PTR, RBL) are reported by the protocols service preflight", + }; +} diff --git a/services/auth/src/router.test.ts b/services/auth/src/router.test.ts index ae8ca36..b2a8805 100644 --- a/services/auth/src/router.test.ts +++ b/services/auth/src/router.test.ts @@ -1,80 +1,278 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, beforeAll, afterAll } from "vitest"; import { createServer, Server } from "node:http"; -import { handleApiRequest } from "./router.js"; +import crypto from "node:crypto"; -function startTestServer(): Promise<{ server: Server; url: string }> { +// The master key has to exist before crypto.ts is imported anywhere, because +// encryptSecret refuses to run without one. +process.env.LAMBDAMAIL_MASTER_KEY ||= "test-master-key-at-least-16-chars"; +process.env.DATABASE_URL = process.env.TEST_DATABASE_URL ?? ""; + +const hasDatabase = Boolean(process.env.TEST_DATABASE_URL); +const describeDb = hasDatabase ? describe : describe.skip; + +const { handleApiRequest } = await import("./router.js"); +const { hashPassword } = await import("./crypto.js"); +const { query, closePool } = await import("./db.js"); +const { generateTotpSecret, generateHotp, base32Decode, getCurrentStep } = await import("./totp.js"); + +const DOMAIN_ID = "11111111-1111-1111-1111-111111111111"; +const USER_ID = "22222222-2222-2222-2222-222222222222"; +const ADMIN_ID = "33333333-3333-3333-3333-333333333333"; +const PASSWORD = "CorrectHorseBatteryStaple1!"; + +let server: Server; +let base: string; + +function startServer(): Promise { return new Promise((resolve) => { - const server = createServer((req, res) => { + server = createServer((req, res) => { if (handleApiRequest(req, res)) return; res.statusCode = 404; res.end(); }); server.listen(0, () => { const addr = server.address(); - const port = typeof addr === "object" && addr ? addr.port : 0; - resolve({ server, url: `http://localhost:${port}` }); + base = `http://localhost:${typeof addr === "object" && addr ? addr.port : 0}`; + resolve(); }); }); } -describe("API Router & Surface Isolation", () => { - it("enforces strict surface isolation rejecting user token on admin endpoint", async () => { - const { server, url } = await startTestServer(); - try { - // 1. Login as User - const userLoginRes = await fetch(`${url}/api/v1/auth/user/login`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email: "user@example.com", password: "Password123" }), - }); - const userLoginData = await userLoginRes.json(); - expect(userLoginRes.status).toBe(200); - expect(userLoginData.token).toBeDefined(); +async function seed(): Promise { + const hash = await hashPassword(PASSWORD); + await query(`DELETE FROM domains WHERE id = $1`, [DOMAIN_ID]); + // name is CITEXT and punycode_name is VARCHAR, so the value is bound twice: + // reusing one placeholder for both makes Postgres refuse to deduce a type. + const domainName = `authtest-${DOMAIN_ID}.invalid`; + await query( + `INSERT INTO domains (id, name, punycode_name, mfa_policy) VALUES ($1, $2, $3, 'required_admins')`, + [DOMAIN_ID, domainName, domainName], + ); + for (const [id, local, role] of [ + [USER_ID, "user", "USER"], + [ADMIN_ID, "admin", "SUPER_ADMIN"], + ] as const) { + await query( + `INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash, role) + VALUES ($1, $2, $3, $4, $5, $6)`, + [id, DOMAIN_ID, local, `${local}@authtest-${DOMAIN_ID}.invalid`, hash, role], + ); + } +} - // 2. Attempt to access /api/v1/admin/dashboard using user token - const adminReqRes = await fetch(`${url}/api/v1/admin/dashboard`, { - headers: { Authorization: `Bearer ${userLoginData.token}` }, - }); - expect(adminReqRes.status).toBe(401); - const errorData = await adminReqRes.json(); - expect(errorData.error).toBe("UNAUTHORIZED"); - } finally { - server.close(); +const userEmail = () => `user@authtest-${DOMAIN_ID}.invalid`; +const adminEmail = () => `admin@authtest-${DOMAIN_ID}.invalid`; + +async function post(path: string, body: unknown, token?: string) { + const res = await fetch(`${base}${path}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify(body), + }); + return { status: res.status, body: await res.json().catch(() => ({})) as Record }; +} + +async function get(path: string, token?: string) { + const res = await fetch(`${base}${path}`, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + return { status: res.status, body: await res.json().catch(() => ({})) as Record }; +} + +/** Enrolls a real second factor and returns the shared secret. */ +async function enrollTotp(mailboxId: string, token: string): Promise { + const enroll = await post("/api/v1/user/mfa/totp/enroll", {}, token); + const secret = enroll.body.secret as unknown as string; + const code = generateHotp(base32Decode(secret), getCurrentStep()); + const confirm = await post("/api/v1/user/mfa/totp/confirm", { code }, token); + expect(confirm.status).toBe(200); + // Enrollment burns the step it used; clear it so the login test can reuse + // the current window instead of waiting 30 seconds. + await query(`UPDATE mfa_totp SET last_used_step = NULL WHERE mailbox_id = $1`, [mailboxId]); + return secret; +} + +describeDb("auth API against a real database", () => { + beforeAll(async () => { + await startServer(); + await seed(); + }); + + afterAll(async () => { + server?.close(); + await query(`DELETE FROM domains WHERE id = $1`, [DOMAIN_ID]).catch(() => undefined); + await closePool(); + }); + + // The headline regression: login used to accept any password at all. + it("rejects a wrong password", async () => { + const res = await post("/api/v1/auth/user/login", { email: userEmail(), password: "not-the-password" }); + expect(res.status).toBe(401); + expect(res.body.error).toBe("INVALID_CREDENTIALS"); + }); + + it("rejects an unknown account with the same answer as a wrong password", async () => { + const res = await post("/api/v1/auth/user/login", { email: "ghost@nowhere.invalid", password: "whatever" }); + expect(res.status).toBe(401); + expect(res.body.error).toBe("INVALID_CREDENTIALS"); + }); + + it("refuses the stored hash presented as the password", async () => { + const row = await query<{ password_hash: string }>(`SELECT password_hash FROM mailboxes WHERE id = $1`, [USER_ID]); + const res = await post("/api/v1/auth/user/login", { email: userEmail(), password: row[0].password_hash }); + expect(res.status).toBe(401); + }); + + it("issues a session for the correct password", async () => { + const res = await post("/api/v1/auth/user/login", { email: userEmail(), password: PASSWORD }); + expect(res.status).toBe(200); + expect(res.body.token).toBeDefined(); + }); + + // The second headline regression: any non-empty MFA code used to work. + it("rejects a wrong TOTP code and accepts only the real one", { timeout: 60000 }, async () => { + const login = await post("/api/v1/auth/user/login", { email: userEmail(), password: PASSWORD }); + const secret = await enrollTotp(USER_ID, login.body.token as unknown as string); + + // With a factor enrolled, login now demands it. + const challenge = await post("/api/v1/auth/user/login", { email: userEmail(), password: PASSWORD }); + expect(challenge.body.mfa_required).toBe(true); + + const wrong = await post("/api/v1/auth/mfa/verify", { + challenge_token: challenge.body.challenge_token, + code: "000000", + }); + expect(wrong.status).toBe(401); + expect(wrong.body.error).toBe("INVALID_MFA_CODE"); + + const right = await post("/api/v1/auth/mfa/verify", { + challenge_token: challenge.body.challenge_token, + code: generateHotp(base32Decode(secret), getCurrentStep()), + }); + expect(right.status).toBe(200); + expect(right.body.token).toBeDefined(); + }); + + // PLAN.md F4 acceptance criterion: a /user token must be refused by + // /api/v1/admin/*. + it("rejects a user token on an admin endpoint", async () => { + const login = await post("/api/v1/auth/user/login", { email: userEmail(), password: PASSWORD }); + const token = (login.body.token ?? login.body.challenge_token) as unknown as string; + const res = await get("/api/v1/admin/dashboard", token); + expect(res.status).toBe(401); + expect(res.body.error).toBe("UNAUTHORIZED"); + }); + + it("rejects a challenge token on a protected endpoint", async () => { + const challenge = await post("/api/v1/auth/admin/login", { email: adminEmail(), password: PASSWORD }); + // The admin has no factor enrolled yet, so this is refused outright. + expect([403, 200]).toContain(challenge.status); + if (challenge.body.challenge_token) { + const res = await get("/api/v1/admin/dashboard", challenge.body.challenge_token); + expect(res.status).toBe(401); } }); - it("allows access to admin endpoint with valid MFA-satisfied admin session token", async () => { - const { server, url } = await startTestServer(); - try { - // 1. Login as Admin -> requires MFA challenge - const adminLoginRes = await fetch(`${url}/api/v1/auth/admin/login`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email: "admin@example.com", password: "AdminPassword123" }), - }); - const adminLoginData = await adminLoginRes.json(); - expect(adminLoginRes.status).toBe(200); - expect(adminLoginData.mfa_required).toBe(true); + it("refuses the admin console until a second factor is enrolled", async () => { + const res = await post("/api/v1/auth/admin/login", { email: adminEmail(), password: PASSWORD }); + expect(res.status).toBe(403); + expect(res.body.error).toBe("MFA_ENROLLMENT_REQUIRED"); + }); - // 2. Verify MFA code - const mfaVerifyRes = await fetch(`${url}/api/v1/auth/mfa/verify`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ challenge_token: adminLoginData.challenge_token, code: "123456" }), - }); - const mfaVerifyData = await mfaVerifyRes.json(); - expect(mfaVerifyRes.status).toBe(200); - expect(mfaVerifyData.token).toBeDefined(); + it("grants the admin console after a real second factor", { timeout: 60000 }, async () => { + // Enrollment happens on the user surface, which the admin also owns. + const userLogin = await post("/api/v1/auth/user/login", { email: adminEmail(), password: PASSWORD }); + const secret = await enrollTotp(ADMIN_ID, userLogin.body.token as unknown as string); - // 3. Access admin dashboard with valid admin token - const adminDashboardRes = await fetch(`${url}/api/v1/admin/dashboard`, { - headers: { Authorization: `Bearer ${mfaVerifyData.token}` }, - }); - expect(adminDashboardRes.status).toBe(200); - const dashboardData = await adminDashboardRes.json(); - expect(dashboardData.preflight_status).toBe("HEALTHY"); - } finally { - server.close(); + const challenge = await post("/api/v1/auth/admin/login", { email: adminEmail(), password: PASSWORD }); + expect(challenge.body.mfa_required).toBe(true); + + const verified = await post("/api/v1/auth/mfa/verify", { + challenge_token: challenge.body.challenge_token, + code: generateHotp(base32Decode(secret), getCurrentStep()), + }); + expect(verified.status).toBe(200); + + const dashboard = await get("/api/v1/admin/dashboard", verified.body.token); + expect(dashboard.status).toBe(200); + expect(dashboard.body).toHaveProperty("queue_depth"); + }); + + it("locks the account after repeated failures", async () => { + const email = `lock@authtest-${DOMAIN_ID}.invalid`; + await query( + `INSERT INTO mailboxes (domain_id, local_part, email_address, password_hash) + VALUES ($1, 'lock', $2, $3)`, + [DOMAIN_ID, email, await hashPassword(PASSWORD)], + ); + + for (let i = 0; i < 5; i++) { + await post("/api/v1/auth/user/login", { email, password: "wrong" }); } + + // Even the correct password is refused while the lockout holds. + const res = await post("/api/v1/auth/user/login", { email, password: PASSWORD }); + expect(res.status).toBe(423); + expect(res.body.error).toBe("ACCOUNT_LOCKED"); + }); + + it("consumes a recovery code exactly once", { timeout: 60000 }, async () => { + const email = `rec@authtest-${DOMAIN_ID}.invalid`; + const id = crypto.randomUUID(); + await query( + `INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash) + VALUES ($1, $2, 'rec', $3, $4)`, + [id, DOMAIN_ID, email, await hashPassword(PASSWORD)], + ); + + const login = await post("/api/v1/auth/user/login", { email, password: PASSWORD }); + const confirm = await post("/api/v1/user/mfa/totp/confirm", { code: "x" }, login.body.token); + expect(confirm.status).toBe(400); + + const enroll = await post("/api/v1/user/mfa/totp/enroll", {}, login.body.token); + const code = generateHotp(base32Decode(enroll.body.secret as unknown as string), getCurrentStep()); + const confirmed = await post("/api/v1/user/mfa/totp/confirm", { code }, login.body.token); + const recoveryCode = (confirmed.body.recovery_codes as unknown as string[])[0]; + + const challenge = await post("/api/v1/auth/user/login", { email, password: PASSWORD }); + const first = await post("/api/v1/auth/mfa/verify", { + challenge_token: challenge.body.challenge_token, + code: recoveryCode, + }); + expect(first.status).toBe(200); + + const challenge2 = await post("/api/v1/auth/user/login", { email, password: PASSWORD }); + const second = await post("/api/v1/auth/mfa/verify", { + challenge_token: challenge2.body.challenge_token, + code: recoveryCode, + }); + expect(second.status).toBe(401); + }); + + it("never returns the TOTP secret to an unauthenticated caller", async () => { + const res = await post("/api/v1/user/mfa/totp/enroll", {}); + expect(res.status).toBe(401); }); }); + +// Guards that need no database, so they still run in a bare checkout. +describe("surface routing", () => { + it("ignores anything outside /api/v1/", async () => { + const { handleApiRequest: handler } = await import("./router.js"); + const req = { url: "/health/ready", method: "GET", headers: {} } as never; + const res = { setHeader() {}, end() {} } as never; + expect(handler(req, res)).toBe(false); + }); +}); + +if (!hasDatabase) { + // eslint-disable-next-line no-console + console.warn("TEST_DATABASE_URL not set - auth API integration tests skipped"); +} + +// generateTotpSecret is exercised through the enrollment flow above; this +// keeps the import meaningful when the database suite is skipped. +void generateTotpSecret; diff --git a/services/auth/src/router.ts b/services/auth/src/router.ts index 2ba1f51..b1bfeb0 100644 --- a/services/auth/src/router.ts +++ b/services/auth/src/router.ts @@ -1,259 +1,378 @@ import { IncomingMessage, ServerResponse } from "node:http"; -import { verifyPassword, hashPassword, encryptSecret } from "./crypto.js"; -import { generateTotpSecret, verifyTotpCode } from "./totp.js"; -import { generateRecoveryCodes, generateAppPassword, hashAppPassword } from "./mfaHelpers.js"; -import { createJwt, verifyJwt, isSurfaceAuthorized } from "./session.js"; +import crypto from "node:crypto"; +import { generateTotpSecret } from "./totp.js"; +import { generateRecoveryCodes, generateAppPassword } from "./mfaHelpers.js"; +import { createJwt, verifyJwt, isSurfaceAuthorized, type SessionTokenPayload } from "./session.js"; +import * as repo from "./repository.js"; + +const CHALLENGE_TTL_SECONDS = 300; +const SESSION_TTL_SECONDS = 28800; + +/** Cookie scoped to the whole origin: the API lives under /api, so a cookie + * pinned to /user or /admin would never be sent with the requests that need + * it. Isolation between surfaces comes from the audience claim, which is + * checked on every request, not from the cookie path. */ +function sessionCookie(name: string, value: string, maxAge: number, secure: boolean): string { + const attrs = [`${name}=${value}`, "Path=/", "HttpOnly", "SameSite=Strict", `Max-Age=${maxAge}`]; + if (secure) attrs.push("Secure"); + return attrs.join("; "); +} + +function readBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + let body = ""; + let size = 0; + req.on("data", (chunk) => { + size += chunk.length; + // A login endpoint has no reason to accept a large body; refusing early + // keeps an unauthenticated caller from buffering memory here. + if (size > 64 * 1024) { + reject(new Error("PAYLOAD_TOO_LARGE")); + req.destroy(); + return; + } + body += chunk; + }); + req.on("end", () => resolve(body)); + req.on("error", reject); + }); +} + +function clientIp(req: IncomingMessage): string | null { + return req.socket.remoteAddress ?? null; +} + +/** True when the deployment terminates TLS, which is always the case behind + * Traefik. Only a plain local run drops the Secure attribute. */ +function isSecureRequest(): boolean { + return process.env.NODE_ENV === "production" || process.env.COOKIE_SECURE === "true"; +} export function handleApiRequest(req: IncomingMessage, res: ServerResponse): boolean { - const url = req.url || "/"; - const method = req.method || "GET"; - + const url = (req.url || "/").split("?")[0]; if (!url.startsWith("/api/v1/")) { return false; } + void route(req, res, url); + return true; +} +function sendJson(res: ServerResponse, statusCode: number, data: unknown): void { res.setHeader("Content-Type", "application/json"); + res.statusCode = statusCode; + res.end(JSON.stringify(data)); +} - // Auxiliary JSON response helper - const sendJson = (statusCode: number, data: unknown) => { - res.statusCode = statusCode; - res.end(JSON.stringify(data)); - }; - - // Helper to extract Bearer token or Cookie +function extractToken(req: IncomingMessage, url: string): string { const authHeader = req.headers["authorization"] || ""; - let token = authHeader.startsWith("Bearer ") ? authHeader.substring(7) : ""; - if (!token && req.headers.cookie) { - const cookies = req.headers.cookie.split(";").map((c) => c.trim()); - const userCookie = cookies.find((c) => c.startsWith("lm_user_session=")); - const adminCookie = cookies.find((c) => c.startsWith("lm_admin_session=")); - if (url.startsWith("/api/v1/admin/") && adminCookie) { - token = adminCookie.split("=")[1]; - } else if (userCookie) { - token = userCookie.split("=")[1]; + if (authHeader.startsWith("Bearer ")) { + return authHeader.substring(7); + } + const raw = req.headers.cookie; + if (!raw) return ""; + const wanted = url.startsWith("/api/v1/admin/") ? "lm_admin_session" : "lm_user_session"; + for (const part of raw.split(";")) { + const trimmed = part.trim(); + const eq = trimmed.indexOf("="); + if (eq > 0 && trimmed.slice(0, eq) === wanted) { + // sliced rather than split so a value containing "=" survives intact. + return trimmed.slice(eq + 1); } } + return ""; +} + +async function route(req: IncomingMessage, res: ServerResponse, url: string): Promise { + const method = req.method || "GET"; + + try { + const token = extractToken(req, url); + const session = token ? verifyJwt(token) : null; - const sessionPayload = token ? verifyJwt(token) : null; + // Surface isolation (PLAN.md section 14.1). A /user token carries + // aud: lambdamail:user and can never satisfy an admin route. + if (url.startsWith("/api/v1/admin/") && !isSurfaceAuthorized(session, "admin")) { + return sendJson(res, 401, { error: "UNAUTHORIZED", message: "Admin session required" }); + } + if (url.startsWith("/api/v1/user/") && !isSurfaceAuthorized(session, "user")) { + return sendJson(res, 401, { error: "UNAUTHORIZED", message: "User session required" }); + } - // STRICT SURFACE ISOLATION (PLAN.md ADR-008 & Section 14.1) - // Token presented to /api/v1/admin/* MUST have aud: "lambdamail:admin" - if (url.startsWith("/api/v1/admin/")) { - if (!isSurfaceAuthorized(sessionPayload, "admin")) { - sendJson(401, { error: "UNAUTHORIZED", message: "Admin session required" }); - return true; + if (url === "/api/v1/auth/user/login" && method === "POST") return login(req, res, "user"); + if (url === "/api/v1/auth/admin/login" && method === "POST") return login(req, res, "admin"); + if (url === "/api/v1/auth/mfa/verify" && method === "POST") return mfaVerify(req, res); + if (url === "/api/v1/auth/logout" && method === "POST") return logout(res); + + if (url === "/api/v1/user/me" && method === "GET") return me(res, session!); + if (url === "/api/v1/user/locale" && method === "PUT") return updateLocale(req, res, session!); + if (url === "/api/v1/user/mfa/totp/enroll" && method === "POST") return totpEnroll(res, session!); + if (url === "/api/v1/user/mfa/totp/confirm" && method === "POST") return totpConfirm(req, res, session!); + if (url === "/api/v1/user/app-passwords" && method === "GET") return appPasswordsList(res, session!); + if (url === "/api/v1/user/app-passwords" && method === "POST") return appPasswordCreate(req, res, session!); + if (url.startsWith("/api/v1/user/app-passwords/") && method === "DELETE") { + return appPasswordDelete(res, session!, url.substring("/api/v1/user/app-passwords/".length)); } + + if (url === "/api/v1/admin/dashboard" && method === "GET") return adminDashboard(res); + if (url === "/api/v1/admin/domains" && method === "GET") return adminDomains(res); + if (url === "/api/v1/admin/mailboxes" && method === "GET") return adminMailboxes(res); + if (url === "/api/v1/admin/dmarc" && method === "GET") return adminDmarc(res); + if (url === "/api/v1/admin/queue" && method === "GET") return adminQueue(res); + if (url === "/api/v1/admin/preflight" && method === "GET") return adminPreflight(res); + + return sendJson(res, 404, { error: "NOT_FOUND", message: "API endpoint not found" }); + } catch (err) { + const message = err instanceof Error ? err.message : "unknown"; + if (message === "PAYLOAD_TOO_LARGE") { + return sendJson(res, 413, { error: "PAYLOAD_TOO_LARGE", message: "Request body too large" }); + } + // Never leak an internal message (SQL text, connection strings) to a + // caller that may be unauthenticated. + console.error(`auth: unhandled error on ${url}:`, err); + return sendJson(res, 500, { error: "INTERNAL_ERROR", message: "Request could not be processed" }); + } +} + +async function parseJsonBody(req: IncomingMessage): Promise | null> { + const raw = await readBody(req); + try { + return JSON.parse(raw || "{}") as Record; + } catch { + return null; } +} - if (url.startsWith("/api/v1/user/") && !url.startsWith("/api/v1/user/login")) { - if (!isSurfaceAuthorized(sessionPayload, "user") && !isSurfaceAuthorized(sessionPayload, "admin")) { - sendJson(401, { error: "UNAUTHORIZED", message: "User session required" }); - return true; - } +// ------------------------------------------------------------------- login + +async function login(req: IncomingMessage, res: ServerResponse, surface: "user" | "admin"): Promise { + const body = await parseJsonBody(req); + if (!body) return sendJson(res, 400, { error: "BAD_REQUEST", message: "Invalid JSON" }); + + const email = typeof body.email === "string" ? body.email.trim().toLowerCase() : ""; + const password = typeof body.password === "string" ? body.password : ""; + if (!email || !password) { + return sendJson(res, 400, { error: "INVALID_INPUT", message: "Email and password required" }); + } + + const outcome = await repo.authenticate(email, password); + + if (outcome.status === "LOCKED") { + return sendJson(res, 423, { error: "ACCOUNT_LOCKED", message: "Too many failed attempts, try again later" }); + } + if (outcome.status !== "OK") { + // One message for every failure mode: distinguishing "no such account" + // from "wrong password" hands an attacker a list of valid addresses. + return sendJson(res, 401, { error: "INVALID_CREDENTIALS", message: "Invalid email or password" }); } - // --- AUTH ENDPOINTS --- - if (url === "/api/v1/auth/user/login" && method === "POST") { - let body = ""; - req.on("data", (chunk) => (body += chunk)); - req.on("end", () => { - try { - const { email, password } = JSON.parse(body || "{}"); - if (!email || !password) { - return sendJson(400, { error: "INVALID_INPUT", message: "Email and password required" }); - } - - // Demo/seeded user check - const domain = email.split("@")[1] || "example.com"; - const isMfaActive = email.includes("mfa"); - - if (isMfaActive) { - const challengeToken = createJwt({ - sub: "user-challenge-id", - email, - role: "USER", - domainId: "domain-1", - surface: "user", - aud: "lambdamail:user", - mfaSatisfied: false, - }, 300); - return sendJson(200, { mfa_required: true, challenge_token: challengeToken }); - } - - const userToken = createJwt({ - sub: "user-123", - email, - role: "USER", - domainId: "domain-1", - surface: "user", - aud: "lambdamail:user", - mfaSatisfied: false, - }); - - res.setHeader("Set-Cookie", `lm_user_session=${userToken}; Path=/user; HttpOnly; Secure; SameSite=Strict`); - return sendJson(200, { token: userToken, surface: "user", role: "USER" }); - } catch { - return sendJson(400, { error: "BAD_REQUEST", message: "Invalid JSON" }); - } - }); - return true; - } - - if (url === "/api/v1/auth/admin/login" && method === "POST") { - let body = ""; - req.on("data", (chunk) => (body += chunk)); - req.on("end", () => { - try { - const { email, password } = JSON.parse(body || "{}"); - if (!email || !password) { - return sendJson(400, { error: "INVALID_INPUT", message: "Email and password required" }); - } - - const challengeToken = createJwt({ - sub: "admin-123", - email, - role: "SUPER_ADMIN", - domainId: "domain-1", - surface: "admin", - aud: "lambdamail:admin", - mfaSatisfied: false, - }, 300); - - return sendJson(200, { mfa_required: true, challenge_token: challengeToken }); - } catch { - return sendJson(400, { error: "BAD_REQUEST", message: "Invalid JSON" }); - } - }); - return true; - } - - if (url === "/api/v1/auth/mfa/verify" && method === "POST") { - let body = ""; - req.on("data", (chunk) => (body += chunk)); - req.on("end", () => { - try { - const { challenge_token, code } = JSON.parse(body || "{}"); - const payload = verifyJwt(challenge_token); - if (!payload) { - return sendJson(401, { error: "CHALLENGE_EXPIRED", message: "Challenge token expired or invalid" }); - } - - if (!code || code.trim().length === 0) { - return sendJson(400, { error: "CODE_REQUIRED", message: "Verification code required" }); - } - - const surface = payload.surface; - const aud = surface === "admin" ? "lambdamail:admin" : "lambdamail:user"; - - const sessionToken = createJwt({ - sub: payload.sub, - email: payload.email, - role: payload.role, - domainId: payload.domainId, - surface: surface as "user" | "admin", - aud: aud as "lambdamail:user" | "lambdamail:admin", - mfaSatisfied: true, - mfaSatisfiedAt: Date.now(), - }); - - const cookieName = surface === "admin" ? "lm_admin_session" : "lm_user_session"; - const cookiePath = surface === "admin" ? "/admin" : "/user"; - res.setHeader("Set-Cookie", `${cookieName}=${sessionToken}; Path=${cookiePath}; HttpOnly; Secure; SameSite=Strict`); - - return sendJson(200, { token: sessionToken, surface, role: payload.role, mfa_satisfied: true }); - } catch { - return sendJson(400, { error: "BAD_REQUEST", message: "Invalid JSON" }); - } + const { mailbox, mfaRequired } = outcome; + + // An admin whose second factor is not enrolled must not fall through to a + // full session: the admin surface requires MFA unconditionally. + if (surface === "admin" && !(await repo.hasConfirmedTotp(mailbox.id))) { + return sendJson(res, 403, { + error: "MFA_ENROLLMENT_REQUIRED", + message: "Enroll a second factor from the webmail settings before using the admin console", }); - return true; - } - - // --- USER MFA ENROLLMENT --- - if (url === "/api/v1/user/mfa/totp/enroll" && method === "POST") { - const email = sessionPayload?.email || "user@lambdamail.local"; - const secretData = generateTotpSecret(email); - return sendJson(200, { secret: secretData.base32Secret, uri: secretData.uri }); - } - - if (url === "/api/v1/user/mfa/totp/confirm" && method === "POST") { - let body = ""; - req.on("data", (chunk) => (body += chunk)); - req.on("end", () => { - try { - const { secret, code } = JSON.parse(body || "{}"); - const verification = verifyTotpCode(secret, code); - if (!verification.valid) { - return sendJson(400, { error: "INVALID_TOTP_CODE", message: "TOTP verification failed" }); - } - - const recovery = generateRecoveryCodes(10); - return sendJson(200, { - confirmed: true, - recovery_codes: recovery.rawCodes, - }); - } catch { - return sendJson(400, { error: "BAD_REQUEST", message: "Invalid JSON" }); - } - }); - return true; - } - - // --- ADMIN ENDPOINTS --- - if (url === "/api/v1/admin/dashboard" && method === "GET") { - return sendJson(200, { - inbound_24h: 1420, - outbound_24h: 890, - queue_depth: 3, - bounce_rate: 0.12, - spam_score_avg: 0.45, - disk_used_percent: 18.4, - domains_verified: 2, - domains_total: 2, - preflight_status: "HEALTHY", - }); } - if (url === "/api/v1/admin/domains" && method === "GET") { - return sendJson(200, [ + if (mfaRequired) { + const challengeToken = createJwt( { - id: "domain-1", - name: "example.com", - dns_status: "VERIFIED", - dmarc_policy: "quarantine", - mta_sts_mode: "testing", - dane_enabled: false, - records_count: 13, + sub: mailbox.id, + email: mailbox.email_address, + role: mailbox.role, + domainId: mailbox.domain_id, + surface, + aud: surface === "admin" ? "lambdamail:admin" : "lambdamail:user", + mfaSatisfied: false, + purpose: "mfa_challenge", }, - ]); - } - - if (url === "/api/v1/admin/dmarc" && method === "GET") { - return sendJson(200, { - total_messages: 1250, - spf_pass_count: 1220, - dkim_pass_count: 1210, - dmarc_pass_count: 1200, - sources: [ - { ip: "209.85.220.41", org: "Google LLC", count: 850, spf: "pass", dkim: "pass" }, - { ip: "40.107.0.50", org: "Microsoft Corp", count: 350, spf: "pass", dkim: "pass" }, - { ip: "192.0.2.45", org: "Unknown Host", count: 50, spf: "fail", dkim: "fail" }, - ], - }); + CHALLENGE_TTL_SECONDS, + ); + return sendJson(res, 200, { mfa_required: true, challenge_token: challengeToken }); + } + + // No factor enrolled yet. The session is issued so the user can reach the + // settings screen and enroll; when policy obliges them to, the response says + // so and the UI keeps them on that screen. + await issueSession(req, res, mailbox, surface, repo.isMfaEnrollmentRequired(mailbox)); +} + +async function issueSession( + req: IncomingMessage, + res: ServerResponse, + mailbox: repo.MailboxRecord, + surface: "user" | "admin", + enrollmentRequired = false, +): Promise { + const sessionToken = createJwt({ + sub: mailbox.id, + email: mailbox.email_address, + role: mailbox.role, + domainId: mailbox.domain_id, + surface, + aud: surface === "admin" ? "lambdamail:admin" : "lambdamail:user", + mfaSatisfied: true, + mfaSatisfiedAt: Date.now(), + purpose: "session", + }); + + await repo.recordSession( + mailbox.id, + surface, + crypto.createHash("sha256").update(sessionToken).digest("hex"), + new Date(Date.now() + SESSION_TTL_SECONDS * 1000), + clientIp(req), + req.headers["user-agent"] ?? null, + ); + + const cookieName = surface === "admin" ? "lm_admin_session" : "lm_user_session"; + res.setHeader("Set-Cookie", sessionCookie(cookieName, sessionToken, SESSION_TTL_SECONDS, isSecureRequest())); + sendJson(res, 200, { + token: sessionToken, + surface, + role: mailbox.role, + locale: mailbox.locale, + mfa_satisfied: true, + mfa_enrollment_required: enrollmentRequired, + }); +} + +async function mfaVerify(req: IncomingMessage, res: ServerResponse): Promise { + const body = await parseJsonBody(req); + if (!body) return sendJson(res, 400, { error: "BAD_REQUEST", message: "Invalid JSON" }); + + const challengeToken = typeof body.challenge_token === "string" ? body.challenge_token : ""; + const code = typeof body.code === "string" ? body.code : ""; + const payload = challengeToken ? verifyJwt(challengeToken) : null; + + // Only a challenge token gets past here. A full session token must not be + // replayable into this endpoint to refresh its own MFA timestamp. + if (!payload || payload.purpose !== "mfa_challenge") { + return sendJson(res, 401, { error: "CHALLENGE_EXPIRED", message: "Challenge token expired or invalid" }); + } + if (!code.trim()) { + return sendJson(res, 400, { error: "CODE_REQUIRED", message: "Verification code required" }); + } + + // A TOTP code is six digits; anything else is treated as a recovery code, + // which is single-use and consumed on success. + const isTotpShaped = /^\d{6}$/.test(code.trim()); + const accepted = isTotpShaped + ? await repo.verifyTotpForLogin(payload.sub, code) + : await repo.consumeRecoveryCode(payload.sub, code, clientIp(req)); + + if (!accepted) { + return sendJson(res, 401, { error: "INVALID_MFA_CODE", message: "Invalid verification code" }); + } + + const mailbox = await repo.findMailboxByEmail(payload.email); + if (!mailbox || !mailbox.is_active) { + return sendJson(res, 401, { error: "INVALID_CREDENTIALS", message: "Account unavailable" }); + } + + await issueSession(req, res, mailbox, payload.surface); +} + +function logout(res: ServerResponse): void { + res.setHeader("Set-Cookie", [ + sessionCookie("lm_user_session", "", 0, isSecureRequest()), + sessionCookie("lm_admin_session", "", 0, isSecureRequest()), + ] as unknown as string); + sendJson(res, 200, { ok: true }); +} + +// -------------------------------------------------------------- user area + +async function me(res: ServerResponse, session: SessionTokenPayload): Promise { + const mailbox = await repo.findMailboxByEmail(session.email); + if (!mailbox) return sendJson(res, 404, { error: "NOT_FOUND", message: "Mailbox not found" }); + sendJson(res, 200, { + id: mailbox.id, + email: mailbox.email_address, + role: mailbox.role, + locale: mailbox.locale, + mfa_enrolled: await repo.hasConfirmedTotp(mailbox.id), + recovery_codes_left: await repo.countUnusedRecoveryCodes(mailbox.id), + }); +} + +async function updateLocale(req: IncomingMessage, res: ServerResponse, session: SessionTokenPayload): Promise { + const body = await parseJsonBody(req); + const locale = typeof body?.locale === "string" ? body.locale : ""; + if (!["en", "pt-BR", "es"].includes(locale)) { + return sendJson(res, 400, { error: "INVALID_LOCALE", message: "Supported locales: en, pt-BR, es" }); + } + await repo.setLocale(session.sub, locale); + sendJson(res, 200, { locale }); +} + +async function totpEnroll(res: ServerResponse, session: SessionTokenPayload): Promise { + const secret = generateTotpSecret(session.email); + // Stored server-side; the confirm step reads it from the database rather + // than trusting the client to send it back. + await repo.startTotpEnrollment(session.sub, secret.base32Secret); + sendJson(res, 200, { secret: secret.base32Secret, uri: secret.uri }); +} + +async function totpConfirm(req: IncomingMessage, res: ServerResponse, session: SessionTokenPayload): Promise { + const body = await parseJsonBody(req); + const code = typeof body?.code === "string" ? body.code : ""; + if (!(await repo.confirmTotpEnrollment(session.sub, code))) { + return sendJson(res, 400, { error: "INVALID_TOTP_CODE", message: "TOTP verification failed" }); } + const recovery = await generateRecoveryCodes(10); + await repo.storeRecoveryCodes(session.sub, recovery.hashedCodes); + // The raw codes are shown exactly once; only their hashes are kept. + sendJson(res, 200, { confirmed: true, recovery_codes: recovery.rawCodes }); +} + +async function appPasswordsList(res: ServerResponse, session: SessionTokenPayload): Promise { + sendJson(res, 200, await repo.listAppPasswords(session.sub)); +} + +async function appPasswordCreate(req: IncomingMessage, res: ServerResponse, session: SessionTokenPayload): Promise { + const body = await parseJsonBody(req); + const label = typeof body?.label === "string" && body.label.trim() ? body.label.trim().slice(0, 64) : ""; + if (!label) return sendJson(res, 400, { error: "INVALID_INPUT", message: "Label required" }); + + const raw = generateAppPassword(); + const id = await repo.createAppPassword(session.sub, label, raw); + // Shown once, stored hashed - same contract as the recovery codes. + sendJson(res, 201, { id, label, password: raw }); +} - if (url === "/api/v1/admin/preflight" && method === "GET") { - return sendJson(200, { - status: "HEALTHY", - checks: [ - { name: "Outbound Port 25 Egress", status: "PASS" }, - { name: "PTR Record Matching Host", status: "PASS" }, - { name: "FCrDNS Verification", status: "PASS" }, - { name: "IP Clean in RBLs", status: "PASS" }, - { name: "Cloudflare API Token Scope", status: "PASS" }, - { name: "Certificates in acme.json", status: "PASS" }, - ], - }); +async function appPasswordDelete(res: ServerResponse, session: SessionTokenPayload, id: string): Promise { + if (!(await repo.deleteAppPassword(session.sub, id))) { + return sendJson(res, 404, { error: "NOT_FOUND", message: "App password not found" }); } + sendJson(res, 200, { ok: true }); +} + +// ------------------------------------------------------------- admin area + +async function adminDashboard(res: ServerResponse): Promise { + const stats = await repo.dashboardStats(); + sendJson(res, 200, stats); +} + +async function adminDomains(res: ServerResponse): Promise { + sendJson(res, 200, await repo.listDomains()); +} + +async function adminMailboxes(res: ServerResponse): Promise { + sendJson(res, 200, await repo.listMailboxes()); +} + +async function adminDmarc(res: ServerResponse): Promise { + sendJson(res, 200, await repo.dmarcSummary()); +} + +async function adminQueue(res: ServerResponse): Promise { + sendJson(res, 200, await repo.queueSummary()); +} - return sendJson(404, { error: "NOT_FOUND", message: "API endpoint not found" }); +async function adminPreflight(res: ServerResponse): Promise { + sendJson(res, 200, await repo.preflightSummary()); } diff --git a/services/auth/src/session.ts b/services/auth/src/session.ts index 0dce6a6..0e55bc8 100644 --- a/services/auth/src/session.ts +++ b/services/auth/src/session.ts @@ -9,6 +9,10 @@ export interface SessionTokenPayload { aud: "lambdamail:user" | "lambdamail:admin"; mfaSatisfied: boolean; mfaSatisfiedAt?: number; + // Separates the short-lived token handed out between password and second + // factor from a real session. Without it, a challenge token - issued before + // any second factor was proven - would be accepted anywhere a session is. + purpose: "mfa_challenge" | "session"; iat: number; exp: number; } @@ -87,6 +91,9 @@ export function isSurfaceAuthorized( requiredSurface: "user" | "admin", ): boolean { if (!payload) return false; + // A challenge token proves only that a password was right. Treating it as a + // session would let the whole second factor be skipped by presenting it. + if (payload.purpose !== "session") return false; const expectedAud = requiredSurface === "admin" ? "lambdamail:admin" : "lambdamail:user"; if (payload.aud !== expectedAud || payload.surface !== requiredSurface) { return false;