sso fuer inbox config

This commit is contained in:
2026-08-14 16:50:02 +02:00
parent 730c03370d
commit a5f8d987b7
14 changed files with 452 additions and 150 deletions

View File

@@ -23,3 +23,7 @@ MAILDATA_PATH=/mail-data
AWS_REGION=us-east-2
DYNAMODB_RULES_TABLE=email-rules
DYNAMODB_BLOCKED_TABLE=email-blocked-senders
# Shared secret with the Roundcube "EMail Configuration" plugin.
# Comma separated to allow rotation. Empty = webmail SSO disabled.
WEBMAIL_SSO_SECRET=

View File

@@ -11,6 +11,19 @@ export const config = {
adminEmail: process.env.ADMIN_EMAIL ?? 'admin@example.com',
adminPassword: process.env.ADMIN_PASSWORD ?? 'ChangeMe123!',
// Shared secret(s) with the Roundcube "EMail Configuration" plugin. Comma
// separated so a secret can be rotated without downtime: links signed with
// either the old or the new secret are accepted while both are listed.
// Empty (the default) disables the webmail SSO endpoint entirely — there is
// deliberately no fallback secret.
webmailSsoSecrets: (process.env.WEBMAIL_SSO_SECRET ?? '')
.split(',')
.map((s) => s.trim())
.filter(Boolean),
// How far in the future a link's `expires` may be. Guards against links that
// never expire if the plugin is ever misconfigured.
webmailSsoMaxLifetimeSec: parseInt(process.env.WEBMAIL_SSO_MAX_LIFETIME ?? '86400', 10),
nodeName: process.env.NODE_NAME ?? 'node1',
nodeHostname: process.env.NODE_HOSTNAME ?? 'node1.email-srvr.com',

View File

@@ -15,8 +15,19 @@ declare global {
}
}
export function signUser(user: AuthUser): string {
return jwt.sign(user, config.jwtSecret, { expiresIn: '12h' });
// Role given to a webmail user that arrived through a signed link from the
// Roundcube plugin. It is never stored in admin_users — it only ever exists
// inside a JWT — and may do nothing but read/write the forwarding, out of
// office and blocklist rules of its own address (see requireAdmin /
// requireSelfMailbox below).
export const MAILBOX_USER_ROLE = 'mailbox_user';
export function signUser(user: AuthUser, expiresIn: string = '12h'): string {
return jwt.sign(user, config.jwtSecret, { expiresIn } as jwt.SignOptions);
}
export function isMailboxUser(user: AuthUser | undefined): boolean {
return user?.role === MAILBOX_USER_ROLE;
}
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
@@ -45,6 +56,25 @@ export function requireSuperAdmin(req: Request, res: Response, next: NextFunctio
next();
}
// Blocks everything that is not plain mailbox self-service. Applied to every
// admin router and to the admin-only mailbox routes, so a webmail SSO token
// cannot list, create, delete or re-password any mailbox — not even in its
// own domain.
export function requireAdmin(req: Request, res: Response, next: NextFunction): void {
if (!req.user) {
res.status(401).json({ error: 'Not authenticated' });
return;
}
if (isMailboxUser(req.user)) {
res.status(403).json({ error: 'Forbidden: admin role required' });
return;
}
next();
}
export function canAccessDomain(user: AuthUser, domain: string): boolean {
// A mailbox user never gets domain-wide access; it is confined to its own
// address by the mailboxes router guard.
if (isMailboxUser(user)) return false;
return user.role === 'super_admin' || user.allowed_domains.includes(domain.toLowerCase());
}

View File

@@ -1,10 +1,12 @@
import { Router } from 'express';
import bcrypt from 'bcryptjs';
import crypto from 'node:crypto';
import { z } from 'zod';
import { pool } from '../db.js';
import { config } from '../config.js';
import { requireAuth, signUser } from '../middleware/auth.js';
import { MAILBOX_USER_ROLE, requireAdmin, requireAuth, signUser } from '../middleware/auth.js';
import { audit } from '../services/audit.js';
import { domainFromEmail, normalizeEmail } from '../utils/email.js';
export const authRouter = Router();
@@ -31,6 +33,90 @@ authRouter.post('/login', async (req, res) => {
res.json({ email: user.email, role: user.role, allowed_domains: user.allowed_domains ?? [] });
});
// --- Webmail single sign-on -------------------------------------------------
//
// The Roundcube plugin builds a link
// https://mailadmin.<domain>/?email=<addr>&expires=<unix>&signature=<hex>
// with signature = hash_hmac('sha256', "<addr>|<expires>", <shared secret>).
// The frontend posts those three values here on boot and, if they check out,
// receives the normal session cookie — but with the heavily restricted
// mailbox_user role, scoped to that one address.
const ssoSchema = z.object({
email: z.string().email(),
expires: z.coerce.number().int(),
signature: z.string().regex(/^[0-9a-fA-F]{64}$/),
});
// Constant-time compare over the raw bytes. Comparing hex strings with === is
// what leaks the signature one character at a time, so don't.
function signatureMatches(expectedHex: string, providedHex: string): boolean {
const expected = Buffer.from(expectedHex, 'hex');
const provided = Buffer.from(providedHex, 'hex');
if (expected.length !== provided.length) return false;
return crypto.timingSafeEqual(expected, provided);
}
authRouter.post('/webmail-sso', async (req, res) => {
// No secret configured => the feature is off, not "open".
if (config.webmailSsoSecrets.length === 0) {
res.status(404).json({ error: 'Webmail SSO is not enabled' });
return;
}
const body = ssoSchema.parse(req.body);
const email = normalizeEmail(body.email);
const nowSec = Math.floor(Date.now() / 1000);
if (body.expires <= nowSec) {
res.status(401).json({ error: 'This link has expired. Please open it again from webmail.' });
return;
}
if (body.expires > nowSec + config.webmailSsoMaxLifetimeSec) {
res.status(401).json({ error: 'Invalid link' });
return;
}
// Sign the address exactly as the plugin received it from Roundcube. The
// plugin does not lower-case, so verify against both spellings.
const candidates = new Set([`${body.email}|${body.expires}`, `${email}|${body.expires}`]);
const valid = config.webmailSsoSecrets.some((secret) =>
[...candidates].some((data) =>
signatureMatches(crypto.createHmac('sha256', secret).update(data).digest('hex'), body.signature),
),
);
if (!valid) {
res.status(401).json({ error: 'Invalid link' });
return;
}
// The signature only proves the link was minted by the plugin — it does not
// prove the mailbox lives on this node. Check that separately.
const mailbox = (await pool.query(
`SELECT email_address, domain FROM mailboxes
WHERE email_address=$1 AND node_name=$2 AND status='active'`,
[email, config.nodeName],
)).rows[0];
if (!mailbox) {
res.status(404).json({ error: 'No such mailbox on this server' });
return;
}
const domain = mailbox.domain ?? domainFromEmail(email);
const token = signUser(
{ id: `mailbox:${email}`, email, role: MAILBOX_USER_ROLE, allowed_domains: [domain] },
'1h',
);
res.cookie('mailadmin_token', token, {
httpOnly: true,
sameSite: 'lax',
secure: config.cookieSecure,
maxAge: 60 * 60 * 1000,
});
await audit(email, 'auth.webmail_sso', 'mailbox', email, { domain }, req.ip);
res.json({ email, role: MAILBOX_USER_ROLE, allowed_domains: [domain] });
});
authRouter.post('/logout', (_req, res) => {
res.clearCookie('mailadmin_token');
res.json({ ok: true });
@@ -47,7 +133,7 @@ const changePwSchema = z.object({
new_password: z.string().min(8),
});
authRouter.post('/change-password', requireAuth, async (req, res) => {
authRouter.post('/change-password', requireAuth, requireAdmin, async (req, res) => {
const body = changePwSchema.parse(req.body);
const result = await pool.query(
`SELECT id, password_hash FROM admin_users WHERE email=$1 AND active=true`,

View File

@@ -2,7 +2,7 @@ import { Router } from 'express';
import { z } from 'zod';
import { pool } from '../db.js';
import { config } from '../config.js';
import { requireAuth, canAccessDomain } from '../middleware/auth.js';
import { requireAuth, canAccessDomain, isMailboxUser } from '../middleware/auth.js';
import { DmsService } from '../services/dms.js';
import { SyncService } from '../services/sync.js';
import { DynamoRulesService } from '../services/dynamodb.js';
@@ -13,6 +13,37 @@ import { domainFromEmail, localPartFromEmail, normalizeEmail } from '../utils/em
export const mailboxesRouter = Router();
mailboxesRouter.use(requireAuth);
// Complete authorization for webmail SSO sessions, deny-by-default: the rules
// and blocklist endpoints of their own address are the only thing they may
// reach. Everything else on this router (list, create, delete, password,
// quota) stays admin-only, and any route added later is admin-only unless it
// is deliberately added to this pattern.
//
// Deliberately a plain synchronous middleware answering through `res`: a
// `throw` inside the async route handlers below is not caught by Express and
// would take the process down instead of returning 403.
const SELF_SERVICE_ROUTE = /^\/([^/]+)\/(rules|blocklist)\/?$/;
mailboxesRouter.use((req, res, next) => {
if (!isMailboxUser(req.user)) { next(); return; }
const match = SELF_SERVICE_ROUTE.exec(req.path);
let target: string | null = null;
if (match) {
try {
target = normalizeEmail(decodeURIComponent(match[1]));
} catch {
target = null; // malformed percent-encoding
}
}
if (!target || target !== normalizeEmail(req.user!.email)) {
res.status(403).json({ error: 'Forbidden' });
return;
}
next();
});
const dms = new DmsService();
const sync = new SyncService(dms);
const dynamo = new DynamoRulesService();
@@ -21,6 +52,14 @@ function ensureDomain(req: any, domain: string): void {
if (!canAccessDomain(req.user, domain)) throw Object.assign(new Error('Forbidden'), { status: 403 });
}
// Guard for the four self-service routes below. Webmail sessions are already
// pinned to their own address by the router middleware above; admins still
// need the usual per-domain check.
function ensureMailboxAccess(req: any, email: string): void {
if (isMailboxUser(req.user)) return;
ensureDomain(req, domainFromEmail(email));
}
async function refreshQuotaForMailbox(emailAddress: string): Promise<void> {
const quota = await dms.getMailboxQuota(emailAddress);
await pool.query(
@@ -140,13 +179,13 @@ mailboxesRouter.post('/:email/quota', async (req, res) => {
mailboxesRouter.get('/:email/rules', async (req, res) => {
const email = normalizeEmail(req.params.email);
ensureDomain(req, domainFromEmail(email));
ensureMailboxAccess(req, email);
res.json(await dynamo.getRules(email));
});
mailboxesRouter.put('/:email/rules', async (req, res) => {
const email = normalizeEmail(req.params.email);
ensureDomain(req, domainFromEmail(email));
ensureMailboxAccess(req, email);
const body = z.object({
ooo_active: z.boolean().optional(),
ooo_message: z.string().optional(),
@@ -166,13 +205,13 @@ mailboxesRouter.put('/:email/rules', async (req, res) => {
mailboxesRouter.get('/:email/blocklist', async (req, res) => {
const email = normalizeEmail(req.params.email);
ensureDomain(req, domainFromEmail(email));
ensureMailboxAccess(req, email);
res.json(await dynamo.getBlocklist(email));
});
mailboxesRouter.put('/:email/blocklist', async (req, res) => {
const email = normalizeEmail(req.params.email);
ensureDomain(req, domainFromEmail(email));
ensureMailboxAccess(req, email);
const body = z.object({ blocked_patterns: z.array(z.string()) }).parse(req.body);
const saved = await dynamo.putBlocklist(email, body.blocked_patterns);
await audit(req.user!.email, 'mailbox.blocklist_update', 'mailbox', email, saved, req.ip);

View File

@@ -13,6 +13,7 @@ import { adminsRouter } from './routes/admins.js';
import { billingRouter } from './routes/billing.js';
import { healthRouter } from './routes/health.js';
import { SyncService } from './services/sync.js';
import { requireAdmin, requireAuth } from './middleware/auth.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const app = express();
@@ -41,12 +42,16 @@ app.get('/api/health', (_req, res) => {
});
app.use('/api/auth', authRouter);
app.use('/api/domains', domainsRouter);
// Everything below is admin territory. requireAdmin keeps a webmail SSO
// session (role mailbox_user) out of it; /api/mailboxes guards itself per
// route because its rules/blocklist endpoints are exactly what that session
// is allowed to use.
app.use('/api/domains', requireAuth, requireAdmin, domainsRouter);
app.use('/api/mailboxes', mailboxesRouter);
app.use('/api/audit', auditRouter);
app.use('/api/admins', adminsRouter);
app.use('/api/billing', billingRouter);
app.use('/api/health', healthRouter);
app.use('/api/audit', requireAuth, requireAdmin, auditRouter);
app.use('/api/admins', requireAuth, requireAdmin, adminsRouter);
app.use('/api/billing', requireAuth, requireAdmin, billingRouter);
app.use('/api/health', requireAuth, requireAdmin, healthRouter);
app.use((err: any, req: express.Request, res: express.Response, _next: express.NextFunction) => {
const status = err.status ?? err.statusCode ?? 500;