Parcourir la source

fix: stabilize dovecot auth bridge

AI-Co-Authored-By: Codex
chendeben il y a 1 mois
Parent
commit
3ea2d9f009

+ 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 = "2s",
-    request_absolute_timeout = "2s"
+    request_timeout = "10s",
+    request_absolute_timeout = "10s"
   }
   return 0
 end

+ 82 - 5
src/dovecot-auth-server.js

@@ -9,6 +9,8 @@ import { verifyInboundMailboxCredential } from './db.js';
 const authPath = '/internal/dovecot/auth';
 const defaultBodyLimit = 8 * 1024;
 const defaultRequestTimeoutMs = 5_000;
+const defaultAuthCacheTtlMs = 120_000;
+const defaultAuthCacheMaxEntries = 4096;
 
 export function createDovecotAuthServer(options = {}) {
   const sharedSecretDigest = digestSecret(readSharedSecret(options.secretFile));
@@ -16,6 +18,11 @@ export function createDovecotAuthServer(options = {}) {
   const verifyCredential = options.verifyCredential || verifyInboundMailboxCredential;
   const logger = options.logger || console;
   const requestTimeoutMs = positiveInteger(options.requestTimeoutMs, defaultRequestTimeoutMs);
+  const authCache = options.authCache || new SuccessfulAuthCache({
+    ttlMs: options.authCacheTtlMs,
+    maxEntries: options.authCacheMaxEntries,
+    secretDigest: sharedSecretDigest
+  });
 
   const server = http.createServer((req, res) => {
     void handleRequest(req, res, {
@@ -23,7 +30,8 @@ export function createDovecotAuthServer(options = {}) {
       limiter,
       verifyCredential,
       logger,
-      bodyLimit: defaultBodyLimit
+      bodyLimit: defaultBodyLimit,
+      authCache
     });
   });
   server.requestTimeout = requestTimeoutMs;
@@ -77,18 +85,29 @@ async function handleRequest(req, res, context) {
       limiter: context.limiter,
       ip: request.remoteIp,
       account: request.username,
-      authenticate: () => context.verifyCredential(request.username, request.password)
+      authenticate: () => verifyCachedCredential(request, context)
     });
     if (!authenticated) return sendJson(res, 200, { authenticated: false });
-    const user = canonicalMailboxAddress(authenticated);
-    if (!user) throw new Error('Credential verifier returned an invalid mailbox');
-    return sendJson(res, 200, { authenticated: true, user });
+    return sendJson(res, 200, { authenticated: true, user: authenticated.user });
   } catch {
     context.logger.error?.('Dovecot authentication bridge request failed.');
     return sendJson(res, 503, { error: 'Service unavailable.' });
   }
 }
 
+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;
+
+  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) {
   if (!filePath || typeof filePath !== 'string') {
     throw new Error('Dovecot authentication secret file is required');
@@ -111,6 +130,64 @@ function digestSecret(value) {
   return crypto.createHash('sha256').update(value).digest();
 }
 
+class SuccessfulAuthCache {
+  constructor({
+    ttlMs = defaultAuthCacheTtlMs,
+    maxEntries = defaultAuthCacheMaxEntries,
+    secretDigest = crypto.randomBytes(32),
+    now = () => Date.now()
+  } = {}) {
+    this.ttlMs = Math.max(0, Number(ttlMs ?? defaultAuthCacheTtlMs) || 0);
+    this.maxEntries = Math.max(0, Number(maxEntries ?? defaultAuthCacheMaxEntries) || 0);
+    this.secretDigest = Buffer.from(secretDigest);
+    this.now = now;
+    this.entries = new Map();
+  }
+
+  get(username, password) {
+    if (!this.enabled()) return '';
+    const key = this.key(username, password);
+    const entry = this.entries.get(key);
+    if (!entry) return '';
+    if (entry.expiresAt <= this.now()) {
+      this.entries.delete(key);
+      return '';
+    }
+    this.entries.delete(key);
+    this.entries.set(key, entry);
+    return entry.user;
+  }
+
+  set(username, password, user) {
+    if (!this.enabled()) return;
+    const cleanUser = String(user || '').trim().toLowerCase();
+    if (!cleanUser) return;
+    const key = this.key(username, password);
+    this.entries.set(key, {
+      user: cleanUser,
+      expiresAt: this.now() + this.ttlMs
+    });
+    while (this.entries.size > this.maxEntries) {
+      const oldestKey = this.entries.keys().next().value;
+      if (oldestKey === undefined) break;
+      this.entries.delete(oldestKey);
+    }
+  }
+
+  enabled() {
+    return this.ttlMs > 0 && 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;

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

@@ -117,6 +117,37 @@ test('Dovecot authentication bridge validates transport and returns fixed DTOs',
   }
 });
 
+test('Dovecot authentication bridge caches only successful credential checks', async () => {
+  let verifierCalls = 0;
+  const server = createDovecotAuthServer({
+    secretFile: writeSecret(sharedSecret),
+    authCacheTtlMs: 60_000,
+    verifyCredential(_username, password) {
+      verifierCalls += 1;
+      if (password !== 'correct-password') return null;
+      return { mailbox: { address: 'Alice@Example.com' } };
+    }
+  });
+  await listen(server);
+
+  try {
+    const first = await request(server, { body: authBody({ password: 'correct-password' }) });
+    assert.deepEqual(first.json, { authenticated: true, user: 'alice@example.com' });
+
+    const cached = await request(server, { body: authBody({ password: 'correct-password' }) });
+    assert.deepEqual(cached.json, { authenticated: true, user: 'alice@example.com' });
+    assert.equal(verifierCalls, 1);
+
+    const failed = await request(server, { body: authBody({ password: 'wrong-password' }) });
+    assert.deepEqual(failed.json, { authenticated: false });
+    const failedAgain = await request(server, { body: authBody({ password: 'wrong-password' }) });
+    assert.deepEqual(failedAgain.json, { authenticated: false });
+    assert.equal(verifierCalls, 3);
+  } 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({

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

@@ -96,7 +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_absolute_timeout = "2s"/);
+  assert.match(authLua, /request_timeout = "10s"/);
+  assert.match(authLua, /request_absolute_timeout = "10s"/);
   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)/);