Переглянути джерело

fix: reduce dovecot auth bridge contention

AI-Co-Authored-By: Codex
chendeben 1 місяць тому
батько
коміт
8944ac7ec1

+ 2 - 2
docker/dovecot/auth.lua

@@ -27,8 +27,8 @@ function script_init()
     auto_retry = "no",
     request_max_attempts = 1,
     connect_timeout = "1s",
-    request_timeout = "10s",
-    request_absolute_timeout = "10s"
+    request_timeout = "30s",
+    request_absolute_timeout = "30s"
   }
   return 0
 end

+ 17 - 0
src/auth-rate-limit.js

@@ -124,6 +124,23 @@ export function authenticateWithRateLimit({ limiter = authenticationRateLimiter,
   return null;
 }
 
+export async function authenticateWithRateLimitAsync({
+  limiter = authenticationRateLimiter,
+  ip,
+  account,
+  authenticate
+}) {
+  const identity = { ip, account };
+  if (limiter.isBlocked(identity)) return null;
+  const result = await authenticate();
+  if (result) {
+    limiter.recordSuccess(identity);
+    return result;
+  }
+  limiter.recordFailure(identity);
+  return null;
+}
+
 export function normalizeAuthenticationIp(value) {
   const raw = String(value || '').trim().toLowerCase();
   if (!raw) return 'unknown';

+ 79 - 24
src/db.js

@@ -5,9 +5,12 @@ import { DatabaseSync } from 'node:sqlite';
 import { dkimPublicFromPrivateKey } from './dkim.js';
 import {
   consumeDummyPasswordVerification,
+  consumeDummyPasswordVerificationAsync,
   hashPassword,
+  hashPasswordAsync,
   isLegacyPasswordHash,
-  verifyPassword
+  verifyPassword,
+  verifyPasswordAsync
 } from './password-hash.js';
 import { decryptTrackingTarget, hashTrackingToken } from './tracking.js';
 import {
@@ -34,6 +37,7 @@ const auditDescriptorWrapperKeyPattern = /^(change|context|descriptor|meta)$/i;
 const auditValueLikeKeyPattern = /^(value|from|to|old|new|old_?value|new_?value|before|after)$/i;
 const maxAccountTokenTtlMinutes = 7 * 24 * 60;
 const defaultApiTokenScopes = ['send'];
+const pendingLegacyMailboxPasswordUpgrades = new Map();
 
 export function initDatabase(dataDir, secret = '') {
   secretKey = String(secret || process.env.SESSION_SECRET || process.env.API_TOKEN || process.env.ADMIN_PASSWORD || '');
@@ -1191,29 +1195,7 @@ export function verifyInboundMailboxCredential(username, password) {
     consumeDummyPasswordVerification(password);
     return null;
   }
-  const row = requireDb()
-    .prepare(`
-      SELECT
-        m.*,
-        d.domain,
-        0 AS message_count,
-        0 AS unread_count,
-        NULL AS last_message_at,
-        u.id AS auth_user_id,
-        u.username AS auth_username,
-        u.email,
-        u.role,
-        u.status AS user_status
-      FROM inbound_mailboxes m
-      JOIN domains d ON d.id = m.domain_id
-      JOIN users u ON u.id = m.user_id
-      WHERE m.address = ?
-        AND m.status = 'active'
-        AND m.deleted_at IS NULL
-        AND (m.expires_at IS NULL OR m.expires_at = '' OR m.expires_at > ?)
-      LIMIT 1
-    `)
-    .get(mailboxAddress, now());
+  const row = inboundMailboxCredentialRow(mailboxAddress);
   if (!row?.password_hash || row.user_status !== 'active') {
     consumeDummyPasswordVerification(password);
     return null;
@@ -1239,6 +1221,59 @@ export function verifyInboundMailboxCredential(username, password) {
       .get(row.id)?.password_hash || row.password_hash;
     row.updated_at = upgradedAt;
   }
+  return inboundMailboxCredentialResult(row);
+}
+
+export async function verifyInboundMailboxCredentialAsync(username, password) {
+  const mailboxAddress = normalizeInboundAddress(username);
+  if (!mailboxAddress) {
+    await consumeDummyPasswordVerificationAsync(password);
+    return null;
+  }
+  const row = inboundMailboxCredentialRow(mailboxAddress);
+  if (!row?.password_hash || row.user_status !== 'active') {
+    await consumeDummyPasswordVerificationAsync(password);
+    return null;
+  }
+  const legacyPasswordHash = isLegacyPasswordHash(row.password_hash);
+  const verified = legacyPasswordHash
+    ? verifyPassword(password, row.password_hash)
+    : await verifyPasswordAsync(password, row.password_hash);
+  if (!verified) {
+    if (legacyPasswordHash) await consumeDummyPasswordVerificationAsync(password);
+    return null;
+  }
+  if (legacyPasswordHash) scheduleLegacyInboundMailboxPasswordUpgrade(row.id, row.password_hash, password);
+  return inboundMailboxCredentialResult(row);
+}
+
+function inboundMailboxCredentialRow(mailboxAddress) {
+  return requireDb()
+    .prepare(`
+      SELECT
+        m.*,
+        d.domain,
+        0 AS message_count,
+        0 AS unread_count,
+        NULL AS last_message_at,
+        u.id AS auth_user_id,
+        u.username AS auth_username,
+        u.email,
+        u.role,
+        u.status AS user_status
+      FROM inbound_mailboxes m
+      JOIN domains d ON d.id = m.domain_id
+      JOIN users u ON u.id = m.user_id
+      WHERE m.address = ?
+        AND m.status = 'active'
+        AND m.deleted_at IS NULL
+        AND (m.expires_at IS NULL OR m.expires_at = '' OR m.expires_at > ?)
+      LIMIT 1
+    `)
+    .get(mailboxAddress, now());
+}
+
+function inboundMailboxCredentialResult(row) {
   return {
     user: {
       id: row.auth_user_id,
@@ -1251,6 +1286,26 @@ export function verifyInboundMailboxCredential(username, password) {
   };
 }
 
+function scheduleLegacyInboundMailboxPasswordUpgrade(mailboxId, currentHash, password) {
+  const key = `${mailboxId}:${currentHash}`;
+  if (pendingLegacyMailboxPasswordUpgrades.has(key)) return;
+  const pending = (async () => {
+    const upgradedHash = await hashPasswordAsync(password);
+    requireDb()
+      .prepare(`
+        UPDATE inbound_mailboxes
+        SET password_hash = ?, updated_at = ?
+        WHERE id = ? AND password_hash = ?
+      `)
+      .run(upgradedHash, now(), mailboxId, currentHash);
+  })()
+    .catch(() => null)
+    .finally(() => {
+      pendingLegacyMailboxPasswordUpgrades.delete(key);
+    });
+  pendingLegacyMailboxPasswordUpgrades.set(key, pending);
+}
+
 export function resolveInboundRecipient(address) {
   const recipient = normalizeInboundAddress(address);
   if (!recipient) return null;

+ 67 - 14
src/dovecot-auth-server.js

@@ -3,19 +3,19 @@ import { readFileSync } from 'node:fs';
 import http from 'node:http';
 import { isIP } from 'node:net';
 
-import { authenticateWithRateLimit, authenticationRateLimiter } from './auth-rate-limit.js';
-import { verifyInboundMailboxCredential } from './db.js';
+import { authenticateWithRateLimitAsync, authenticationRateLimiter } from './auth-rate-limit.js';
+import { verifyInboundMailboxCredentialAsync } from './db.js';
 
 const authPath = '/internal/dovecot/auth';
 const defaultBodyLimit = 8 * 1024;
-const defaultRequestTimeoutMs = 5_000;
-const defaultAuthCacheTtlMs = 120_000;
+const defaultRequestTimeoutMs = 30_000;
+const defaultAuthCacheTtlMs = 600_000;
 const defaultAuthCacheMaxEntries = 4096;
 
 export function createDovecotAuthServer(options = {}) {
   const sharedSecretDigest = digestSecret(readSharedSecret(options.secretFile));
   const limiter = options.authRateLimiter || authenticationRateLimiter;
-  const verifyCredential = options.verifyCredential || verifyInboundMailboxCredential;
+  const verifyCredential = options.verifyCredential || verifyInboundMailboxCredentialAsync;
   const logger = options.logger || console;
   const requestTimeoutMs = positiveInteger(options.requestTimeoutMs, defaultRequestTimeoutMs);
   const authCache = options.authCache || new SuccessfulAuthCache({
@@ -23,6 +23,10 @@ export function createDovecotAuthServer(options = {}) {
     maxEntries: options.authCacheMaxEntries,
     secretDigest: sharedSecretDigest
   });
+  const inFlightAuth = options.inFlightAuth || new InFlightAuthChecks({
+    maxEntries: options.inFlightAuthMaxEntries,
+    secretDigest: sharedSecretDigest
+  });
 
   const server = http.createServer((req, res) => {
     void handleRequest(req, res, {
@@ -31,7 +35,8 @@ export function createDovecotAuthServer(options = {}) {
       verifyCredential,
       logger,
       bodyLimit: defaultBodyLimit,
-      authCache
+      authCache,
+      inFlightAuth
     });
   });
   server.requestTimeout = requestTimeoutMs;
@@ -81,7 +86,7 @@ async function handleRequest(req, res, context) {
   if (!request) return sendJson(res, 400, { error: 'Invalid request.' });
 
   try {
-    const authenticated = authenticateWithRateLimit({
+    const authenticated = await authenticateWithRateLimitAsync({
       limiter: context.limiter,
       ip: request.remoteIp,
       account: request.username,
@@ -95,17 +100,22 @@ async function handleRequest(req, res, context) {
   }
 }
 
-function verifyCachedCredential(request, context) {
+async function verifyCachedCredential(request, context) {
   const cachedUser = context.authCache?.get(request.username, request.password);
   if (cachedUser) return { user: cachedUser };
 
-  const authenticated = context.verifyCredential(request.username, request.password);
-  if (!authenticated) return null;
+  return context.inFlightAuth.run(request.username, request.password, async () => {
+    const recheckedUser = context.authCache?.get(request.username, request.password);
+    if (recheckedUser) return { user: recheckedUser };
 
-  const user = canonicalMailboxAddress(authenticated);
-  if (!user) throw new Error('Credential verifier returned an invalid mailbox');
-  context.authCache?.set(request.username, request.password, user);
-  return { user };
+    const authenticated = await context.verifyCredential(request.username, request.password);
+    if (!authenticated) return null;
+
+    const user = canonicalMailboxAddress(authenticated);
+    if (!user) throw new Error('Credential verifier returned an invalid mailbox');
+    context.authCache?.set(request.username, request.password, user);
+    return { user };
+  });
 }
 
 function readSharedSecret(filePath) {
@@ -188,6 +198,49 @@ class SuccessfulAuthCache {
   }
 }
 
+class InFlightAuthChecks {
+  constructor({
+    maxEntries = defaultAuthCacheMaxEntries,
+    secretDigest = crypto.randomBytes(32)
+  } = {}) {
+    this.maxEntries = Math.max(0, Number(maxEntries ?? defaultAuthCacheMaxEntries) || 0);
+    this.secretDigest = Buffer.from(secretDigest);
+    this.entries = new Map();
+  }
+
+  run(username, password, authenticate) {
+    if (!this.enabled()) return authenticate();
+    const key = this.key(username, password);
+    const existing = this.entries.get(key);
+    if (existing) return existing;
+    const pending = Promise.resolve()
+      .then(authenticate)
+      .finally(() => {
+        this.entries.delete(key);
+      });
+    this.entries.set(key, pending);
+    while (this.entries.size > this.maxEntries) {
+      const oldestKey = this.entries.keys().next().value;
+      if (oldestKey === undefined || oldestKey === key) break;
+      this.entries.delete(oldestKey);
+    }
+    return pending;
+  }
+
+  enabled() {
+    return this.maxEntries > 0;
+  }
+
+  key(username, password) {
+    return crypto
+      .createHmac('sha256', this.secretDigest)
+      .update(String(username || '').trim().toLowerCase())
+      .update('\0')
+      .update(String(password || ''))
+      .digest('hex');
+  }
+}
+
 function requestPathname(req) {
   try {
     return new URL(req.url || '/', 'http://mailhub.internal').pathname;

+ 27 - 0
src/password-hash.js

@@ -1,6 +1,8 @@
 import crypto from 'node:crypto';
+import { promisify } from 'node:util';
 
 const MD5_CRYPT_ALPHABET = './0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
+const scryptAsync = promisify(crypto.scrypt);
 const dummyScryptSalt = 'mailhub-dummy-auth-v1';
 const dummyScryptHash = `scrypt$${dummyScryptSalt}$${crypto
   .scryptSync('mailhub-dummy-password', dummyScryptSalt, 64)
@@ -12,11 +14,22 @@ export function hashPassword(password) {
   return `scrypt$${salt}$${hash}`;
 }
 
+export async function hashPasswordAsync(password) {
+  const salt = crypto.randomBytes(16).toString('hex');
+  const hash = await scryptAsync(String(password), salt, 64);
+  return `scrypt$${salt}$${Buffer.from(hash).toString('hex')}`;
+}
+
 export function verifyPassword(password, stored) {
   if (isLegacyPasswordHash(stored)) return verifyVestaPassword(password, stored);
   return verifyScryptPassword(password, stored);
 }
 
+export async function verifyPasswordAsync(password, stored) {
+  if (isLegacyPasswordHash(stored)) return verifyVestaPassword(password, stored);
+  return verifyScryptPasswordAsync(password, stored);
+}
+
 export function verifyScryptPassword(password, stored) {
   const parts = String(stored || '').split('$');
   if (parts.length !== 3) return false;
@@ -26,11 +39,25 @@ export function verifyScryptPassword(password, stored) {
   return safeEqual(actual, Buffer.from(expectedHex, 'hex'));
 }
 
+export async function verifyScryptPasswordAsync(password, stored) {
+  const parts = String(stored || '').split('$');
+  if (parts.length !== 3) return false;
+  const [scheme, salt, expectedHex] = parts;
+  if (scheme !== 'scrypt' || !salt || !/^[0-9a-f]{128}$/i.test(expectedHex)) return false;
+  const actual = await scryptAsync(String(password), salt, 64);
+  return safeEqual(Buffer.from(actual), Buffer.from(expectedHex, 'hex'));
+}
+
 export function consumeDummyPasswordVerification(password) {
   verifyScryptPassword(password, dummyScryptHash);
   return false;
 }
 
+export async function consumeDummyPasswordVerificationAsync(password) {
+  await verifyScryptPasswordAsync(password, dummyScryptHash);
+  return false;
+}
+
 export function isLegacyPasswordHash(stored) {
   return Boolean(parseVestaPasswordHash(stored));
 }

+ 44 - 0
test/dovecot-auth-server.test.js

@@ -148,6 +148,42 @@ test('Dovecot authentication bridge caches only successful credential checks', a
   }
 });
 
+test('Dovecot authentication bridge coalesces concurrent credential checks', async () => {
+  let verifierCalls = 0;
+  let releaseVerifier;
+  const verifierGate = new Promise((resolve) => {
+    releaseVerifier = resolve;
+  });
+  const server = createDovecotAuthServer({
+    secretFile: writeSecret(sharedSecret),
+    verifyCredential: async (_username, password) => {
+      verifierCalls += 1;
+      await verifierGate;
+      return password === 'correct-password'
+        ? { mailbox: { address: 'Alice@Example.com' } }
+        : null;
+    }
+  });
+  await listen(server);
+
+  try {
+    const responses = Promise.all([
+      request(server, { body: authBody({ password: 'correct-password' }) }),
+      request(server, { body: authBody({ password: 'correct-password' }) }),
+      request(server, { body: authBody({ password: 'correct-password' }) })
+    ]);
+    await waitFor(() => verifierCalls === 1);
+    releaseVerifier();
+    for (const response of await responses) {
+      assert.equal(response.status, 200);
+      assert.deepEqual(response.json, { authenticated: true, user: 'alice@example.com' });
+    }
+    assert.equal(verifierCalls, 1);
+  } finally {
+    await close(server);
+  }
+});
+
 test('Dovecot authentication bridge applies the shared limiter to the supplied remote IP', async () => {
   let verifierCalls = 0;
   const limiter = new AuthenticationRateLimiter({
@@ -250,6 +286,14 @@ function close(server) {
   });
 }
 
+async function waitFor(predicate) {
+  for (let attempt = 0; attempt < 50; attempt += 1) {
+    if (predicate()) return;
+    await new Promise((resolve) => setTimeout(resolve, 10));
+  }
+  assert.fail('Timed out waiting for condition');
+}
+
 function request(server, {
   method = 'POST',
   requestPath = '/internal/dovecot/auth',

+ 2 - 2
test/dovecot-config.test.js

@@ -96,8 +96,8 @@ test('Lua passdb sends both IMAP and POP3 to the private auth bridge', () => {
   assert.match(authLua, /protocol ~= "imap" and protocol ~= "pop3"/);
   assert.match(authLua, /request_max_attempts = 1/);
   assert.match(authLua, /auto_retry = "no"/);
-  assert.match(authLua, /request_timeout = "10s"/);
-  assert.match(authLua, /request_absolute_timeout = "10s"/);
+  assert.match(authLua, /request_timeout = "30s"/);
+  assert.match(authLua, /request_absolute_timeout = "30s"/);
   assert.match(authLua, /add_header\("connection", "close"\)/);
   assert.match(authLua, /status ~= 200[\s\S]*PASSDB_RESULT_INTERNAL_FAILURE/);
   assert.doesNotMatch(authLua, /status == (?:401|403|404)/);

+ 12 - 0
test/password-hash.test.js

@@ -2,8 +2,11 @@ import assert from 'node:assert/strict';
 import { test } from 'node:test';
 import {
   hashPassword,
+  hashPasswordAsync,
   isLegacyPasswordHash,
   verifyPassword,
+  verifyPasswordAsync,
+  verifyScryptPasswordAsync,
   verifyScryptPassword,
   verifyVestaPassword
 } from '../src/password-hash.js';
@@ -17,6 +20,15 @@ test('hashes and verifies current scrypt passwords', () => {
   assert.equal(verifyPassword('wrong password', stored), false);
 });
 
+test('hashes and verifies current scrypt passwords asynchronously', async () => {
+  const stored = await hashPasswordAsync('correct horse battery staple');
+
+  assert.match(stored, /^scrypt\$[0-9a-f]{32}\$[0-9a-f]{128}$/);
+  assert.equal(await verifyScryptPasswordAsync('correct horse battery staple', stored), true);
+  assert.equal(await verifyPasswordAsync('correct horse battery staple', stored), true);
+  assert.equal(await verifyPasswordAsync('wrong password', stored), false);
+});
+
 test('verifies Vesta MD5-CRYPT passwords with known vectors', () => {
   const passwordVector = '$1$hfT7jp2q$G3yf0NUx7mUkX.LIFWQxN.';
   const unicodeVector = '$1$salt1234$VwTk0ScCcREDNl.8aCJCc0';