#21: make the auth service actually authenticate
The F4 auth service issued a valid session for any email and any password, and accepted any non-empty string as a second factor. Reproduced against the router before touching it: an invented password plus the code "000000" returned a SUPER_ADMIN token that opened /api/v1/admin/dashboard. Passwords. Hashing moves from PBKDF2 to Argon2id via hash-wasm, which is what PLAN.md section 9 and the Go service already use - the previous scheme meant a password set through the mail stack could never verify here. Interoperability was checked in both directions before adopting the library: a hash written by alexedwards/argon2id verifies in Node and one written here verifies in Go. hash-wasm is pure WASM, so the multi-arch Alpine images need no native build. verifyPassword no longer falls back to comparing the password against the stored hash. That fallback turned every stored hash into a working credential: anyone with a database dump could authenticate by sending the hash back. Anything that is not an argon2id PHC string is now refused. Logins are decided against the database, with the failure counter and the temporary lockout of PLAN.md section 5.2. An unknown address and a wrong password give the same answer, and the unknown case still pays for a hash so response time does not enumerate valid addresses. Second factor. The TOTP secret is generated server-side, stored encrypted and never accepted back from the client - the confirm step used to take whichever secret the caller sent, so anyone could confirm with their own. Recovery codes are Argon2id, single-use, and consumed under a guard so two concurrent requests cannot spend the same one. App passwords are issued and revoked through real endpoints instead of browser state. Sessions gain a "purpose" claim. Without it the challenge token - handed out after the password but before the second factor - satisfied every check a real session did, which skipped MFA entirely. The dev seed carried a hand-written password_hash whose digest was 24 bytes instead of 32, so it matched no password at all. Nothing noticed while passwords went unverified; it is a real hash now. Verified with 30 tests, 13 of them against a real Postgres: wrong password, unknown account, pass-the-hash, wrong TOTP code, challenge token replay, lockout, single-use recovery codes, and the surface isolation that F4's acceptance criteria require. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
23da5f43e4
commit
833dc76c2e
11 files changed
+1159
-340
No files matched your search
@@ -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;
|
||||
|
||||
Generated
+7
-39
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
+64
-20
@@ -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<string> {
|
||||
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<boolean> {
|
||||
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);
|
||||
}
|
||||
@@ -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<T extends QueryResultRow>(text: string, params: unknown[] = []): Promise<T[]> {
|
||||
const result = await getPool().query<T>(text, params);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
export async function queryOne<T extends QueryResultRow>(text: string, params: unknown[] = []): Promise<T | null> {
|
||||
const rows = await query<T>(text, params);
|
||||
return rows.length > 0 ? rows[0] : null;
|
||||
}
|
||||
|
||||
export async function closePool(): Promise<void> {
|
||||
if (pool) {
|
||||
await pool.end();
|
||||
pool = null;
|
||||
}
|
||||
}
|
||||
@@ -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<string> {
|
||||
return hashPassword(rawPassword);
|
||||
}
|
||||
|
||||
export function verifyAppPassword(rawPassword: string, storedHash: string): boolean {
|
||||
export async function verifyAppPassword(rawPassword: string, storedHash: string): Promise<boolean> {
|
||||
return verifyPassword(rawPassword, storedHash);
|
||||
}
|
||||
@@ -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<MailboxRecord | null> {
|
||||
return queryOne<MailboxRecord>(
|
||||
`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<boolean> {
|
||||
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<boolean> {
|
||||
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<LoginOutcome> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<TotpRow | null> {
|
||||
return queryOne<TotpRow>(
|
||||
`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<boolean> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<number> {
|
||||
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<Array<{ id: string; label: string; created_at: Date; last_used_at: Date | null }>> {
|
||||
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<string> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<Record<string, unknown>> {
|
||||
const row = await queryOne<Record<string, string>>(
|
||||
`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<unknown[]> {
|
||||
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<unknown[]> {
|
||||
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<Record<string, unknown>> {
|
||||
const totals = await queryOne<Record<string, string>>(
|
||||
`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<Record<string, unknown>> {
|
||||
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<Record<string, unknown>> {
|
||||
// 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",
|
||||
};
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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}` },
|
||||
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),
|
||||
});
|
||||
expect(adminReqRes.status).toBe(401);
|
||||
const errorData = await adminReqRes.json();
|
||||
expect(errorData.error).toBe("UNAUTHORIZED");
|
||||
} finally {
|
||||
server.close();
|
||||
return { status: res.status, body: await res.json().catch(() => ({})) as Record<string, never> };
|
||||
}
|
||||
|
||||
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<string, never> };
|
||||
}
|
||||
|
||||
/** Enrolls a real second factor and returns the shared secret. */
|
||||
async function enrollTotp(mailboxId: string, token: string): Promise<string> {
|
||||
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" }),
|
||||
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");
|
||||
});
|
||||
const adminLoginData = await adminLoginRes.json();
|
||||
expect(adminLoginRes.status).toBe(200);
|
||||
expect(adminLoginData.mfa_required).toBe(true);
|
||||
|
||||
// 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}` },
|
||||
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(adminDashboardRes.status).toBe(200);
|
||||
const dashboardData = await adminDashboardRes.json();
|
||||
expect(dashboardData.preflight_status).toBe("HEALTHY");
|
||||
} finally {
|
||||
server.close();
|
||||
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;
|
||||
+318
-199
@@ -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<string> {
|
||||
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");
|
||||
|
||||
// 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 "";
|
||||
}
|
||||
|
||||
const sessionPayload = token ? verifyJwt(token) : null;
|
||||
async function route(req: IncomingMessage, res: ServerResponse, url: string): Promise<void> {
|
||||
const method = req.method || "GET";
|
||||
|
||||
// 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.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;
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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" });
|
||||
const token = extractToken(req, url);
|
||||
const session = 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" });
|
||||
}
|
||||
|
||||
// Demo/seeded user check
|
||||
const domain = email.split("@")[1] || "example.com";
|
||||
const isMfaActive = email.includes("mfa");
|
||||
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 (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 });
|
||||
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));
|
||||
}
|
||||
|
||||
const userToken = createJwt({
|
||||
sub: "user-123",
|
||||
email,
|
||||
role: "USER",
|
||||
domainId: "domain-1",
|
||||
surface: "user",
|
||||
aud: "lambdamail:user",
|
||||
mfaSatisfied: false,
|
||||
});
|
||||
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);
|
||||
|
||||
res.setHeader("Set-Cookie", `lm_user_session=${userToken}; Path=/user; HttpOnly; Secure; SameSite=Strict`);
|
||||
return sendJson(200, { token: userToken, surface: "user", role: "USER" });
|
||||
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<Record<string, unknown> | null> {
|
||||
const raw = await readBody(req);
|
||||
try {
|
||||
return JSON.parse(raw || "{}") as Record<string, unknown>;
|
||||
} catch {
|
||||
return sendJson(400, { error: "BAD_REQUEST", message: "Invalid JSON" });
|
||||
}
|
||||
});
|
||||
return true;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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 || "{}");
|
||||
// ------------------------------------------------------------------- login
|
||||
|
||||
async function login(req: IncomingMessage, res: ServerResponse, surface: "user" | "admin"): Promise<void> {
|
||||
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(400, { error: "INVALID_INPUT", message: "Email and password required" });
|
||||
return sendJson(res, 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);
|
||||
const outcome = await repo.authenticate(email, password);
|
||||
|
||||
return sendJson(200, { mfa_required: true, challenge_token: challengeToken });
|
||||
} catch {
|
||||
return sendJson(400, { error: "BAD_REQUEST", message: "Invalid JSON" });
|
||||
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" });
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 (mfaRequired) {
|
||||
const challengeToken = createJwt(
|
||||
{
|
||||
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",
|
||||
},
|
||||
CHALLENGE_TTL_SECONDS,
|
||||
);
|
||||
return sendJson(res, 200, { mfa_required: true, challenge_token: challengeToken });
|
||||
}
|
||||
|
||||
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";
|
||||
// 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<void> {
|
||||
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",
|
||||
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";
|
||||
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" });
|
||||
}
|
||||
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,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
async function mfaVerify(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||
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));
|
||||
|
||||
// --- 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 (!accepted) {
|
||||
return sendJson(res, 401, { error: "INVALID_MFA_CODE", message: "Invalid verification code" });
|
||||
}
|
||||
|
||||
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 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);
|
||||
}
|
||||
|
||||
const recovery = generateRecoveryCodes(10);
|
||||
return sendJson(200, {
|
||||
confirmed: true,
|
||||
recovery_codes: recovery.rawCodes,
|
||||
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<void> {
|
||||
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),
|
||||
});
|
||||
} catch {
|
||||
return sendJson(400, { error: "BAD_REQUEST", message: "Invalid JSON" });
|
||||
}
|
||||
|
||||
async function updateLocale(req: IncomingMessage, res: ServerResponse, session: SessionTokenPayload): Promise<void> {
|
||||
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" });
|
||||
}
|
||||
});
|
||||
return true;
|
||||
await repo.setLocale(session.sub, locale);
|
||||
sendJson(res, 200, { locale });
|
||||
}
|
||||
|
||||
async function totpEnroll(res: ServerResponse, session: SessionTokenPayload): Promise<void> {
|
||||
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<void> {
|
||||
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 });
|
||||
}
|
||||
|
||||
// --- 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",
|
||||
});
|
||||
async function appPasswordsList(res: ServerResponse, session: SessionTokenPayload): Promise<void> {
|
||||
sendJson(res, 200, await repo.listAppPasswords(session.sub));
|
||||
}
|
||||
|
||||
async function appPasswordCreate(req: IncomingMessage, res: ServerResponse, session: SessionTokenPayload): Promise<void> {
|
||||
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 });
|
||||
}
|
||||
|
||||
async function appPasswordDelete(res: ServerResponse, session: SessionTokenPayload, id: string): Promise<void> {
|
||||
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<void> {
|
||||
const stats = await repo.dashboardStats();
|
||||
sendJson(res, 200, stats);
|
||||
}
|
||||
|
||||
if (url === "/api/v1/admin/domains" && method === "GET") {
|
||||
return sendJson(200, [
|
||||
{
|
||||
id: "domain-1",
|
||||
name: "example.com",
|
||||
dns_status: "VERIFIED",
|
||||
dmarc_policy: "quarantine",
|
||||
mta_sts_mode: "testing",
|
||||
dane_enabled: false,
|
||||
records_count: 13,
|
||||
},
|
||||
]);
|
||||
}
|
||||
async function adminDomains(res: ServerResponse): Promise<void> {
|
||||
sendJson(res, 200, await repo.listDomains());
|
||||
}
|
||||
|
||||
async function adminMailboxes(res: ServerResponse): Promise<void> {
|
||||
sendJson(res, 200, await repo.listMailboxes());
|
||||
}
|
||||
|
||||
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" },
|
||||
],
|
||||
});
|
||||
}
|
||||
async function adminDmarc(res: ServerResponse): Promise<void> {
|
||||
sendJson(res, 200, await repo.dmarcSummary());
|
||||
}
|
||||
|
||||
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 adminQueue(res: ServerResponse): Promise<void> {
|
||||
sendJson(res, 200, await repo.queueSummary());
|
||||
}
|
||||
|
||||
return sendJson(404, { error: "NOT_FOUND", message: "API endpoint not found" });
|
||||
async function adminPreflight(res: ServerResponse): Promise<void> {
|
||||
sendJson(res, 200, await repo.preflightSummary());
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user