|
@@ -3,19 +3,19 @@ import { readFileSync } from 'node:fs';
|
|
|
import http from 'node:http';
|
|
import http from 'node:http';
|
|
|
import { isIP } from 'node:net';
|
|
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 authPath = '/internal/dovecot/auth';
|
|
|
const defaultBodyLimit = 8 * 1024;
|
|
const defaultBodyLimit = 8 * 1024;
|
|
|
-const defaultRequestTimeoutMs = 5_000;
|
|
|
|
|
-const defaultAuthCacheTtlMs = 120_000;
|
|
|
|
|
|
|
+const defaultRequestTimeoutMs = 30_000;
|
|
|
|
|
+const defaultAuthCacheTtlMs = 600_000;
|
|
|
const defaultAuthCacheMaxEntries = 4096;
|
|
const defaultAuthCacheMaxEntries = 4096;
|
|
|
|
|
|
|
|
export function createDovecotAuthServer(options = {}) {
|
|
export function createDovecotAuthServer(options = {}) {
|
|
|
const sharedSecretDigest = digestSecret(readSharedSecret(options.secretFile));
|
|
const sharedSecretDigest = digestSecret(readSharedSecret(options.secretFile));
|
|
|
const limiter = options.authRateLimiter || authenticationRateLimiter;
|
|
const limiter = options.authRateLimiter || authenticationRateLimiter;
|
|
|
- const verifyCredential = options.verifyCredential || verifyInboundMailboxCredential;
|
|
|
|
|
|
|
+ const verifyCredential = options.verifyCredential || verifyInboundMailboxCredentialAsync;
|
|
|
const logger = options.logger || console;
|
|
const logger = options.logger || console;
|
|
|
const requestTimeoutMs = positiveInteger(options.requestTimeoutMs, defaultRequestTimeoutMs);
|
|
const requestTimeoutMs = positiveInteger(options.requestTimeoutMs, defaultRequestTimeoutMs);
|
|
|
const authCache = options.authCache || new SuccessfulAuthCache({
|
|
const authCache = options.authCache || new SuccessfulAuthCache({
|
|
@@ -23,6 +23,10 @@ export function createDovecotAuthServer(options = {}) {
|
|
|
maxEntries: options.authCacheMaxEntries,
|
|
maxEntries: options.authCacheMaxEntries,
|
|
|
secretDigest: sharedSecretDigest
|
|
secretDigest: sharedSecretDigest
|
|
|
});
|
|
});
|
|
|
|
|
+ const inFlightAuth = options.inFlightAuth || new InFlightAuthChecks({
|
|
|
|
|
+ maxEntries: options.inFlightAuthMaxEntries,
|
|
|
|
|
+ secretDigest: sharedSecretDigest
|
|
|
|
|
+ });
|
|
|
|
|
|
|
|
const server = http.createServer((req, res) => {
|
|
const server = http.createServer((req, res) => {
|
|
|
void handleRequest(req, res, {
|
|
void handleRequest(req, res, {
|
|
@@ -31,7 +35,8 @@ export function createDovecotAuthServer(options = {}) {
|
|
|
verifyCredential,
|
|
verifyCredential,
|
|
|
logger,
|
|
logger,
|
|
|
bodyLimit: defaultBodyLimit,
|
|
bodyLimit: defaultBodyLimit,
|
|
|
- authCache
|
|
|
|
|
|
|
+ authCache,
|
|
|
|
|
+ inFlightAuth
|
|
|
});
|
|
});
|
|
|
});
|
|
});
|
|
|
server.requestTimeout = requestTimeoutMs;
|
|
server.requestTimeout = requestTimeoutMs;
|
|
@@ -81,7 +86,7 @@ async function handleRequest(req, res, context) {
|
|
|
if (!request) return sendJson(res, 400, { error: 'Invalid request.' });
|
|
if (!request) return sendJson(res, 400, { error: 'Invalid request.' });
|
|
|
|
|
|
|
|
try {
|
|
try {
|
|
|
- const authenticated = authenticateWithRateLimit({
|
|
|
|
|
|
|
+ const authenticated = await authenticateWithRateLimitAsync({
|
|
|
limiter: context.limiter,
|
|
limiter: context.limiter,
|
|
|
ip: request.remoteIp,
|
|
ip: request.remoteIp,
|
|
|
account: request.username,
|
|
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);
|
|
const cachedUser = context.authCache?.get(request.username, request.password);
|
|
|
if (cachedUser) return { user: cachedUser };
|
|
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) {
|
|
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) {
|
|
function requestPathname(req) {
|
|
|
try {
|
|
try {
|
|
|
return new URL(req.url || '/', 'http://mailhub.internal').pathname;
|
|
return new URL(req.url || '/', 'http://mailhub.internal').pathname;
|