|
@@ -9,6 +9,8 @@ import { verifyInboundMailboxCredential } 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 defaultRequestTimeoutMs = 5_000;
|
|
|
|
|
+const defaultAuthCacheTtlMs = 120_000;
|
|
|
|
|
+const defaultAuthCacheMaxEntries = 4096;
|
|
|
|
|
|
|
|
export function createDovecotAuthServer(options = {}) {
|
|
export function createDovecotAuthServer(options = {}) {
|
|
|
const sharedSecretDigest = digestSecret(readSharedSecret(options.secretFile));
|
|
const sharedSecretDigest = digestSecret(readSharedSecret(options.secretFile));
|
|
@@ -16,6 +18,11 @@ export function createDovecotAuthServer(options = {}) {
|
|
|
const verifyCredential = options.verifyCredential || verifyInboundMailboxCredential;
|
|
const verifyCredential = options.verifyCredential || verifyInboundMailboxCredential;
|
|
|
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({
|
|
|
|
|
+ ttlMs: options.authCacheTtlMs,
|
|
|
|
|
+ maxEntries: options.authCacheMaxEntries,
|
|
|
|
|
+ secretDigest: sharedSecretDigest
|
|
|
|
|
+ });
|
|
|
|
|
|
|
|
const server = http.createServer((req, res) => {
|
|
const server = http.createServer((req, res) => {
|
|
|
void handleRequest(req, res, {
|
|
void handleRequest(req, res, {
|
|
@@ -23,7 +30,8 @@ export function createDovecotAuthServer(options = {}) {
|
|
|
limiter,
|
|
limiter,
|
|
|
verifyCredential,
|
|
verifyCredential,
|
|
|
logger,
|
|
logger,
|
|
|
- bodyLimit: defaultBodyLimit
|
|
|
|
|
|
|
+ bodyLimit: defaultBodyLimit,
|
|
|
|
|
+ authCache
|
|
|
});
|
|
});
|
|
|
});
|
|
});
|
|
|
server.requestTimeout = requestTimeoutMs;
|
|
server.requestTimeout = requestTimeoutMs;
|
|
@@ -77,18 +85,29 @@ async function handleRequest(req, res, context) {
|
|
|
limiter: context.limiter,
|
|
limiter: context.limiter,
|
|
|
ip: request.remoteIp,
|
|
ip: request.remoteIp,
|
|
|
account: request.username,
|
|
account: request.username,
|
|
|
- authenticate: () => context.verifyCredential(request.username, request.password)
|
|
|
|
|
|
|
+ authenticate: () => verifyCachedCredential(request, context)
|
|
|
});
|
|
});
|
|
|
if (!authenticated) return sendJson(res, 200, { authenticated: false });
|
|
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 {
|
|
} catch {
|
|
|
context.logger.error?.('Dovecot authentication bridge request failed.');
|
|
context.logger.error?.('Dovecot authentication bridge request failed.');
|
|
|
return sendJson(res, 503, { error: 'Service unavailable.' });
|
|
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) {
|
|
function readSharedSecret(filePath) {
|
|
|
if (!filePath || typeof filePath !== 'string') {
|
|
if (!filePath || typeof filePath !== 'string') {
|
|
|
throw new Error('Dovecot authentication secret file is required');
|
|
throw new Error('Dovecot authentication secret file is required');
|
|
@@ -111,6 +130,64 @@ function digestSecret(value) {
|
|
|
return crypto.createHash('sha256').update(value).digest();
|
|
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) {
|
|
function requestPathname(req) {
|
|
|
try {
|
|
try {
|
|
|
return new URL(req.url || '/', 'http://mailhub.internal').pathname;
|
|
return new URL(req.url || '/', 'http://mailhub.internal').pathname;
|