auth: security audit fixes, rate limits on every route
ci / check (push) Successful in 1m3s
ci / images (push) Successful in 19s
ci / check (pull_request) Successful in 1m5s
ci / images (pull_request) Successful in 5s

Two separate reviews of the SSO branch and all 207 API routes (spec 12 §8.6). Every route was
guarded as meant. Fixed, each with a test:
- login: a directory 503 counts as a failed sign-in; a locked directory account is a refused
  password, not "unavailable"
- open redirect after OpenID sign-in (a tab in next)
- directory email matching only for non-admins without their own password; others connect on
  My settings (POST /api/me/identities/ldap); only admins disconnect a directory account
- OpenID session check reads the refreshed ID token's claims too
- connect is POST /api/auth/oidc/connect (CSRF header)
- stored bind password, client secret and SMTP password tied to their server
- 409 role_managed when a provider sets the role; empty Contrack PATCH is a 400
- @fastify/rate-limit on every route (RATE_LIMIT_PER_MINUTE), 10/min on Test buttons; a
  password asked again shares the login limiter
Live tests pass against lldap 0.6.3, Samba 4 AD and Keycloak 26.8.1.
This commit is contained in:
iamdoubz committed 2026-10-26 09:46:23 -05:00
1 parent 19610777a4
commit 97e67e347d
39 files changed
+1059 -186

No files matched your search

+1
View File
@@ -12,6 +12,7 @@
},
"dependencies": {
"@fastify/cookie": "^11.1.3",
"@fastify/rate-limit": "^11.2.1",
"@fastify/static": "^10.1.6",
"@fastify/websocket": "^11.3.4",
"@node-rs/argon2": "^2.2.2",
+11 -1
View File
@@ -3,6 +3,7 @@ import { existsSync } from 'node:fs';
import { resolve } from 'node:path';
import Fastify from 'fastify';
import fastifyCookie from '@fastify/cookie';
import fastifyRateLimit from '@fastify/rate-limit';
import fastifyStatic from '@fastify/static';
import fastifyWebsocket from '@fastify/websocket';
import { sql } from 'drizzle-orm';
@@ -11,7 +12,7 @@ import type { Db } from './db/client.ts';
import type { Generator } from './generator/index.ts';
import type { AmiClient } from './ami/client.ts';
import { settingsRoutes } from './routes/settings.ts';
import { errorHandler, forbidden, notFound, sendError } from './errors.ts';
import { HttpError, errorHandler, forbidden, notFound, sendError } from './errors.ts';
import { authRoutes } from './routes/auth.ts';
import { userRoutes } from './routes/users.ts';
import { oidcRoutes } from './routes/oidc.ts';
@@ -92,6 +93,15 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public'))
app.decorateRequest('sessionId', null);
app.register(fastifyCookie);
app.register(fastifyWebsocket);
// Every route, per client IP (08 §2.4). Before the routes, so each one gets it; routes with
// their own limits (login, /prov) or a stricter one say so in `config.rateLimit`.
if (config.RATE_LIMIT_PER_MINUTE > 0)
app.register(fastifyRateLimit, {
max: config.RATE_LIMIT_PER_MINUTE,
timeWindow: 60_000,
errorResponseBuilder: (_req, ctx) =>
new HttpError(429, 'rate_limited', `Too many requests, try again in ${ctx.after}`),
});
app.setErrorHandler(errorHandler);
// CSRF (04 §2.2): browsers can't add this header cross-site without CORS, and CORS is off.
+7 -1
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// preHandler guards (04 §2.4). Routes list them in `preHandler`.
// preHandler guards (04 §2.4). Routes list them in `preHandler`; the rate limit presets go in
// `config`.
import type { FastifyReply, FastifyRequest } from 'fastify';
import type { users } from '../db/schema.ts';
import { forbidden, unauthorized } from '../errors.ts';
@@ -47,3 +48,8 @@ export async function requireAdmin(req: FastifyRequest, reply: FastifyReply): Pr
await requireAuth(req, reply);
if (req.user!.role !== 'admin') throw forbidden('Admins only');
}
/** Route `config` for what calls out or does real work (Test buttons, downloads): 10 a minute. */
export const SLOW = { rateLimit: { max: 10, timeWindow: 60_000 } };
/** Route `config` for routes with their own limit (/prov) or only Asterisk on this machine. */
export const NO_LIMIT = { rateLimit: false as const };
+2
View File
@@ -35,6 +35,8 @@ const EnvSchema = z
API_PORT: z.coerce.number().int().min(1).max(65535).default(8080),
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
TRUST_PROXY: z.stringbool().default(true),
/** Requests per minute per client IP, every route (08 §2.4); stricter ones per route. 0: off. */
RATE_LIMIT_PER_MINUTE: z.coerce.number().int().min(0).default(600),
/** Call audio (RTP) ports, both ends included: the firewall opens this UDP range (01 §3.6). */
RTP_PORT_START: z.coerce.number().int().min(1024).max(65534).default(10000),
RTP_PORT_END: z.coerce.number().int().min(1025).max(65535).default(20000),
+2 -1
View File
@@ -7,6 +7,7 @@ import { readFile, stat } from 'node:fs/promises';
import type { FastifyInstance } from 'fastify';
import type { AboutDto } from '@selfphoneng/shared';
import { notFound } from '../errors.ts';
import { SLOW } from '../auth/guards.ts';
export const REPO_URL = 'https://git.dou.bet/spng/selfphoneng';
/** In the api's working directory (/app in the image), like ./public. */
@@ -35,7 +36,7 @@ export async function aboutRoutes(app: FastifyInstance) {
};
});
app.get('/api/about/source', async (_req, reply) => {
app.get('/api/about/source', { config: SLOW }, async (_req, reply) => {
if (!(await stat(SOURCE_ARCHIVE).catch(() => null)))
throw notFound('The source archive is only in builds made with Docker');
return reply
+30 -11
View File
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// /api/auth/* (04 §2, §7.3).
import { and, eq, isNull, lt, or, sql } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import type { FastifyInstance, FastifyRequest } from 'fastify';
import {
ChangePasswordSchema,
LoginSchema,
@@ -140,14 +140,24 @@ export async function authRoutes(app: FastifyInstance) {
return { secret, otpauthUrl: url, qr: await QRCode.toDataURL(url, { margin: 2, width: 240 }) };
});
/**
* A signed-in person's own password, asked again (two-step on/off, recovery codes, a new
* password). Wrong ones count in the login limiter, so a stolen session can't guess it.
*/
async function myPasswordOk(req: FastifyRequest, hash: string | null, password: string) {
if (isLoginBlocked(req.ip, req.user!.email)) throw rateLimited();
if (hash && (await verifyPassword(hash, password))) return true;
recordLoginFailure(req.ip, req.user!.email);
return false;
}
app.post('/api/auth/totp/enable', { preHandler: requireAuth }, async (req) => {
adminOnly(req.user!);
const { secret, code, password } = TotpEnableSchema.parse(req.body);
const u = await myRow(req.user!.id);
if (u.totpSecretEnc) throw conflict('Two-step sign-in is already on');
// Like turning it off: a stolen session alone can't lock the owner out with its own secret.
if (!u.passwordHash || !(await verifyPassword(u.passwordHash, password)))
throw badRequest('Wrong password');
if (!(await myPasswordOk(req, u.passwordHash, password))) throw badRequest('Wrong password');
const step = checkTotp(secret, code, null);
if (step === null)
throw badRequest('That code doesn’t match. Check the phone’s clock and try again.');
@@ -168,16 +178,15 @@ export async function authRoutes(app: FastifyInstance) {
});
/** Turning it off or making new recovery codes needs the password again (on does too). */
async function confirmPassword(id: string, body: unknown) {
const { password } = PasswordConfirmSchema.parse(body);
const u = await myRow(id);
if (!u.passwordHash || !(await verifyPassword(u.passwordHash, password)))
throw badRequest('Wrong password');
async function confirmPassword(req: FastifyRequest) {
const { password } = PasswordConfirmSchema.parse(req.body);
const u = await myRow(req.user!.id);
if (!(await myPasswordOk(req, u.passwordHash, password))) throw badRequest('Wrong password');
return u;
}
app.post('/api/auth/totp/disable', { preHandler: requireAuth }, async (req) => {
await confirmPassword(req.user!.id, req.body);
await confirmPassword(req);
await db
.update(users)
.set({ totpSecretEnc: null, totpLastStep: null, totpRecoveryHashes: [] })
@@ -194,7 +203,7 @@ export async function authRoutes(app: FastifyInstance) {
});
app.post('/api/auth/totp/recovery-codes', { preHandler: requireAuth }, async (req) => {
const u = await confirmPassword(req.user!.id, req.body);
const u = await confirmPassword(req);
if (!u.totpSecretEnc) throw badRequest('Two-step sign-in is off');
const { codes, hashes } = newRecoveryCodes();
await db.update(users).set({ totpRecoveryHashes: hashes }).where(eq(users.id, u.id));
@@ -236,6 +245,16 @@ export async function authRoutes(app: FastifyInstance) {
try {
entry = await ldapAuthenticate(ldap, login, body.password);
} catch (err) {
// Counted like a wrong password: a 503 must not be a free guess at the local one.
recordLoginFailure(req.ip, login);
await audit(db, {
actorUserId: user?.id,
actorIp: req.ip,
action: 'login_failed',
entityType: 'user',
entityId: user?.id,
summary: `Failed login for ${login} (the directory can't be reached)`,
});
req.log.warn({ event: 'auth.ldap', err: (err as Error).message }, 'directory unavailable');
await directoryWarning(
db,
@@ -345,7 +364,7 @@ export async function authRoutes(app: FastifyInstance) {
const user = req.user!;
if (!user.passwordHash)
throw badRequest('You have no password here. Ask an admin to reset it to get one.');
if (!(await verifyPassword(user.passwordHash, body.currentPassword)))
if (!(await myPasswordOk(req, user.passwordHash, body.currentPassword)))
throw badRequest('Current password is wrong');
if (body.newPassword === body.currentPassword)
throw badRequest('Choose a password different from the current one');
+51 -41
View File
@@ -16,7 +16,7 @@ import {
type ContactShareDto,
type ListResponse,
} from '@selfphoneng/shared';
import { requireAdmin, requireAuth } from '../auth/guards.ts';
import { NO_LIMIT, SLOW, requireAdmin, requireAuth } from '../auth/guards.ts';
import {
addToBook,
assertBuiltin,
@@ -162,33 +162,39 @@ export async function contactRoutes(app: FastifyInstance) {
* The dialplan's caller-name lookup (11 §6 C4): only Asterisk on this machine (no proxy in
* between) with the token. Never waits on Contrack. Not logged: the token is in the query.
*/
app.get('/api/internal/caller-name', { logLevel: 'silent' }, async (req, reply) => {
const q = z
.object({
ext: z.string().regex(/^\d{1,10}$/),
number: z.string().regex(/^\d{3,20}$/),
token: z.string().max(100),
})
.safeParse(req.query);
const local = ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(req.socket.remoteAddress ?? '');
if (
!local ||
req.headers['x-forwarded-for'] ||
!q.success ||
!sameToken(q.data.token, callerNameToken(app.config.APP_SECRET))
)
return reply.code(404).send('');
const [e] = await db
.select({ userId: extensions.userId })
.from(extensions)
.where(eq(extensions.number, q.data.ext));
const names = await contactNames(db, e?.userId ?? null, [q.data.number], { wait: false }).catch(
() => new Map<string, string>(),
);
return reply
.type('text/plain; charset=utf-8')
.send(phoneSafeName(names.get(q.data.number) ?? ''));
});
app.get(
'/api/internal/caller-name',
{ logLevel: 'silent', config: NO_LIMIT },
async (req, reply) => {
const q = z
.object({
ext: z.string().regex(/^\d{1,10}$/),
number: z.string().regex(/^\d{3,20}$/),
token: z.string().max(100),
})
.safeParse(req.query);
const local = ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(
req.socket.remoteAddress ?? '',
);
if (
!local ||
req.headers['x-forwarded-for'] ||
!q.success ||
!sameToken(q.data.token, callerNameToken(app.config.APP_SECRET))
)
return reply.code(404).send('');
const [e] = await db
.select({ userId: extensions.userId })
.from(extensions)
.where(eq(extensions.number, q.data.ext));
const names = await contactNames(db, e?.userId ?? null, [q.data.number], {
wait: false,
}).catch(() => new Map<string, string>());
return reply
.type('text/plain; charset=utf-8')
.send(phoneSafeName(names.get(q.data.number) ?? ''));
},
);
/** Copy the builtin book into Contrack (11 §5, C6): mine, or (admin) the company's. */
app.post(
@@ -214,19 +220,23 @@ export async function contactRoutes(app: FastifyInstance) {
);
/** Settings → Contacts → Test (11 §4.3): the form's address, and its token or the saved one. */
app.post('/api/contacts/contrack/test', { preHandler: requireAdmin }, async (req) => {
const b = z
.object({ url: z.string().trim().min(1), companyToken: z.string().trim().optional() })
.parse(req.body);
const saved = await getSetting(db, 'contacts.contrack');
const token =
b.companyToken || (saved?.companyTokenEnc ? decrypt(saved.companyTokenEnc) : null);
try {
return { ok: true, detail: await contrackCheck(b.url, token) };
} catch (err) {
return { ok: false, error: (err as Error).message };
}
});
app.post(
'/api/contacts/contrack/test',
{ preHandler: requireAdmin, config: SLOW },
async (req) => {
const b = z
.object({ url: z.string().trim().min(1), companyToken: z.string().trim().optional() })
.parse(req.body);
const saved = await getSetting(db, 'contacts.contrack');
const token =
b.companyToken || (saved?.companyTokenEnc ? decrypt(saved.companyTokenEnc) : null);
try {
return { ok: true, detail: await contrackCheck(b.url, token) };
} catch (err) {
return { ok: false, error: (err as Error).message };
}
},
);
// ───── sharing: an offer each, accepted or denied by the recipient (11 §2.1) ─────
app.post('/api/me/contacts/share', { preHandler: requireAuth }, async (req) => {
+2 -2
View File
@@ -13,7 +13,7 @@ import {
type WebhookDto,
type WebhookEvent,
} from '@selfphoneng/shared';
import { requireAdmin } from '../auth/guards.ts';
import { SLOW, requireAdmin } from '../auth/guards.ts';
import { generateToken, hashToken } from '../auth/tokens.ts';
import { decrypt, encrypt } from '../crypto/secrets.ts';
import { apiTokens, users, webhookDeliveries, webhooks } from '../db/schema.ts';
@@ -103,7 +103,7 @@ export async function integrationRoutes(app: FastifyInstance) {
});
/** Sends a `webhook.test` event now, not queued: the answer shows right away. */
app.post('/api/webhooks/:id/test', async (req) => {
app.post('/api/webhooks/:id/test', { config: SLOW }, async (req) => {
const h = await getHook(IdParams.parse(req.params).id);
const body = JSON.stringify({
id: crypto.randomUUID(),
+3
View File
@@ -180,6 +180,9 @@ export async function meRoutes(app: FastifyInstance) {
/** Log my calls in Contrack, with or without their summaries (11 §4.4). */
app.patch('/api/me/contrack', async (req) => {
const o = ContrackOptionsSchema.parse(req.body);
// An empty update would be `UPDATE … SET` with nothing to set: a 500, not a 400.
if (o.logCalls === undefined && o.logSummaries === undefined)
throw badRequest('Nothing to update');
const [u] = await db
.select({ logCalls: users.contrackLogCalls })
.from(users)
+88 -29
View File
@@ -5,13 +5,22 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
import { decrypt, encrypt } from '../crypto/secrets.ts';
import { sessions, userIdentities, users } from '../db/schema.ts';
import { HttpError, notFound, unauthorized } from '../errors.ts';
import {
HttpError,
badRequest,
forbidden,
notFound,
rateLimited,
unauthorized,
} from '../errors.ts';
import { requireAdmin, requireAuth } from '../auth/guards.ts';
import { isLoginBlocked, recordLoginFailure } from '../auth/login-limiter.ts';
import { createSession, setSessionCookie } from '../auth/sessions.ts';
import { audit } from '../services/audit.ts';
import { getSetting } from '../services/settings.ts';
import { decide, verifiedEmail } from '../sso/claims.ts';
import { connectDirectory } from '../sso/directory.ts';
import { ldapAuthenticate } from '../sso/ldap.ts';
import { applyClaims } from '../sso/oidcCheck.ts';
import {
finishSignIn,
@@ -24,8 +33,14 @@ import {
export const OIDC_COOKIE = 'sp_oidc';
const COOKIE_PATH = '/api/auth/oidc';
/** Same-site paths only: never send the browser to another origin. */
const safeNext = (p: string | undefined) => (p && /^\/(?![/\\])/.test(p) ? p : undefined);
/**
* Same-site paths only: never send the browser to another origin. No backslash or control
* character anywhere: browsers drop tabs and newlines from URLs, so "/<tab>/evil.example"
* would become "//evil.example".
*/
export const safeNext = (p: string | undefined) =>
// eslint-disable-next-line no-control-regex -- control characters are what it refuses
p && /^\/(?!\/)[^\\\x00-\x1f\x7f]*$/.test(p) ? p : undefined;
const withParam = (path: string, k: string, v: string) =>
`${path}${path.includes('?') ? '&' : '?'}${k}=${v}`;
@@ -41,34 +56,46 @@ export async function oidcRoutes(app: FastifyInstance) {
return s;
}
/** The sp_oidc cookie: what the callback checks the provider's answer against. */
async function begin(reply: FastifyReply, s: OidcSettings, next: string, connect: string | null) {
const { url, pending } = await startSignIn(s, host, next, connect);
reply.setCookie(OIDC_COOKIE, encrypt(JSON.stringify(pending)), {
httpOnly: true,
secure: true,
sameSite: 'lax',
path: COOKIE_PATH,
maxAge: 600,
});
return url.href;
}
app.get('/api/auth/oidc/start', async (req, reply) => {
const s = await enabled();
const q = z
.object({ next: z.string().max(500).optional(), connect: z.literal('1').optional() })
.parse(req.query);
let connectSessionId: string | null = null;
let next = safeNext(q.next);
if (q.connect) {
await requireAuth(req, reply);
if (!req.sessionId) throw unauthorized(); // an API token can't connect an account
connectSessionId = req.sessionId;
next ??= req.user!.role === 'admin' ? '/admin/account' : '/me/settings';
}
const q = z.object({ next: z.string().max(500).optional() }).parse(req.query);
try {
const { url, pending } = await startSignIn(s, host, next ?? '/', connectSessionId);
reply.setCookie(OIDC_COOKIE, encrypt(JSON.stringify(pending)), {
httpOnly: true,
secure: true,
sameSite: 'lax',
path: COOKIE_PATH,
maxAge: 600,
});
return reply.redirect(url.href);
return reply.redirect(await begin(reply, s, safeNext(q.next) ?? '/', null));
} catch (err) {
req.log.warn({ event: 'auth.oidc', err: oidcError(err) }, 'OpenID discovery failed');
return reply.redirect(
withParam(connectSessionId ? next! : '/login', 'sso_error', 'provider'),
);
return reply.redirect(withParam('/login', 'sso_error', 'provider'));
}
});
/**
* Connect from My account (12 §3.3). A POST, so it needs the CSRF header: another site can't
* start it and connect whatever provider account the browser is signed in to. The page then
* sends the browser to `url`.
*/
app.post('/api/auth/oidc/connect', { preHandler: requireAuth }, async (req, reply) => {
const s = await enabled();
if (!req.sessionId) throw unauthorized(); // an API token can't connect an account
const q = z.object({ next: z.string().max(500).optional() }).parse(req.body ?? {});
const next =
safeNext(q.next) ?? (req.user!.role === 'admin' ? '/admin/account' : '/me/settings');
try {
return { url: await begin(reply, s, next, req.sessionId) };
} catch (err) {
req.log.warn({ event: 'auth.oidc', err: oidcError(err) }, 'OpenID discovery failed');
throw new HttpError(502, 'provider', `${s.label} can't be reached`);
}
});
@@ -264,9 +291,41 @@ export async function oidcRoutes(app: FastifyInstance) {
}
const Kind = z.object({ kind: z.enum(['oidc', 'ldap']) });
app.delete('/api/me/identities/:kind', { preHandler: requireAuth }, (req, reply) =>
disconnect(req, reply, req.user!.id, Kind.parse(req.params).kind),
);
app.delete('/api/me/identities/:kind', { preHandler: requireAuth }, (req, reply) => {
const { kind } = Kind.parse(req.params);
// The directory account is how a leaver gets disabled (12 §4.5): only an admin drops it.
if (kind === 'ldap' && req.user!.role !== 'admin')
throw forbidden('Ask an admin to disconnect your directory account', 'admin_only');
return disconnect(req, reply, req.user!.id, kind);
});
/**
* Connect my directory account (12 §4.3): its username and password, checked by the directory,
* while signed in here. Counted like a sign-in by the login limiter.
*/
app.post('/api/me/identities/ldap', { preHandler: requireAuth }, async (req) => {
if (!req.sessionId) throw unauthorized(); // an API token can't connect an account
const { login, password } = z
.object({ login: z.string().trim().min(1).max(254), password: z.string().min(1).max(200) })
.parse(req.body);
const ldap = await getSetting(db, 'auth.ldap');
if (!ldap?.enabled) throw notFound();
if (isLoginBlocked(req.ip, login)) throw rateLimited();
let entry;
try {
entry = await ldapAuthenticate(ldap, login, password);
} catch {
recordLoginFailure(req.ip, login);
throw new HttpError(503, 'directory_unavailable', 'The directory can’t be reached');
}
if (!entry) {
recordLoginFailure(req.ip, login);
throw badRequest('The directory refused that username or password');
}
if ((await connectDirectory(db, req.log, req.user!.id, entry, req.ip)) === 'taken')
throw new HttpError(409, 'taken', 'That directory account belongs to another user');
return { ok: true };
});
app.delete('/api/users/:id/identities/:kind', { preHandler: requireAdmin }, (req, reply) => {
const p = Kind.extend({ id: z.uuid() }).parse(req.params);
return disconnect(req, reply, p.id, p.kind);
+2 -1
View File
@@ -14,6 +14,7 @@ import { allowProvRequest, recordProvFailure } from '../provisioning/limiter.ts'
import { grandstream, grandstreamPhonebook } from '../provisioning/templates/grandstream.ts';
import { yealink, yealinkCommon, yealinkPhonebook } from '../provisioning/templates/yealink.ts';
import { getSetting } from '../services/settings.ts';
import { NO_LIMIT } from '../auth/guards.ts';
/** File name → what it is. Anything else is a plain 404 (phones ask for files we don't serve). */
const FILES: {
@@ -73,7 +74,7 @@ export async function provisioningRoutes(app: FastifyInstance) {
const deny = (reply: FastifyReply, code: number, text: string) =>
reply.code(code).header('Cache-Control', 'no-store').type('text/plain').send(text);
app.get('/prov/*', async (req, reply) => {
app.get('/prov/*', { config: NO_LIMIT }, async (req, reply) => {
if (!(await getSetting(db, 'provisioning.enabled'))) return deny(reply, 404, 'Not found');
const local = await isLocal(req.ip);
if ((await getSetting(db, 'provisioning.lanOnly')) && !local)
+57 -32
View File
@@ -24,7 +24,7 @@ import {
import type { Db } from '../db/client.ts';
import { dids, extensions, trunks } from '../db/schema.ts';
import { HttpError, badRequest } from '../errors.ts';
import { requireAdmin } from '../auth/guards.ts';
import { SLOW, requireAdmin } from '../auth/guards.ts';
import { encrypt } from '../crypto/secrets.ts';
import { audit } from '../services/audit.ts';
import { assertDestinationExists } from '../services/destinations.ts';
@@ -152,15 +152,21 @@ export async function settingsRoutes(app: FastifyInstance) {
throw badRequest((err as Error).message);
}
const old = await getSetting(db, 'auth.ldap');
const url = v.url.replace(/\/$/, '');
// The stored password only goes back to the server and account it was typed for: else a
// changed address (or Test) would send it to whoever answers there.
const same = old?.url === url && old.bindDn === v.bindDn;
if (!v.bindPassword && !v.clearBindPassword && !same && v.bindDn && old?.bindPasswordEnc)
throw badRequest('The address or service account changed: enter its password again');
return {
enabled: v.enabled,
preset: v.preset,
url: v.url.replace(/\/$/, ''),
url,
startTls: v.startTls,
bindDn: v.bindDn,
bindPasswordEnc: v.bindPassword
? encrypt(v.bindPassword)
: v.clearBindPassword
: v.clearBindPassword || !same
? null
: (old?.bindPasswordEnc ?? null),
baseDn: v.baseDn,
@@ -181,6 +187,10 @@ export async function settingsRoutes(app: FastifyInstance) {
throw badRequest((err as Error).message);
}
const old = await getSetting(db, 'auth.oidc');
// As for the directory: the stored secret only goes to the provider it was typed for.
const same = old?.issuer === input.issuer && old.clientId === input.clientId;
if (!input.clientSecret && !input.clearClientSecret && !same && old?.clientSecretEnc)
throw badRequest('The issuer or client ID changed: enter the client secret again');
return {
label: input.label,
issuer: input.issuer,
@@ -189,7 +199,9 @@ export async function settingsRoutes(app: FastifyInstance) {
? encrypt(input.clientSecret)
: input.clearClientSecret
? null
: (old?.clientSecretEnc ?? null),
: same
? (old.clientSecretEnc ?? null)
: null,
scopes: input.scopes,
groupsClaim: input.groupsClaim,
adminGroups: input.adminGroups,
@@ -217,7 +229,12 @@ export async function settingsRoutes(app: FastifyInstance) {
secure: input.secure,
username: input.username,
from: input.from,
passwordEnc: input.password ? encrypt(input.password) : (old?.passwordEnc ?? ''),
// Kept only for the same server and account: a new host doesn't get the old password.
passwordEnc: input.password
? encrypt(input.password)
: old?.host === input.host && old.username === input.username
? old.passwordEnc
: '',
};
} else if (key === 'contacts.contrack') {
// The company token is write-only too (11 §4.3).
@@ -311,7 +328,7 @@ export async function settingsRoutes(app: FastifyInstance) {
return toDto(db);
});
app.post('/api/settings/smtp/test', async (req) => {
app.post('/api/settings/smtp/test', { config: SLOW }, async (req) => {
const { to } = z.object({ to: z.email() }).parse(req.body);
try {
await app.sendMail({
@@ -326,7 +343,7 @@ export async function settingsRoutes(app: FastifyInstance) {
});
/** Settings → Emergency → Test notifications (06 §6.5): email + ring-out, never dials 911. */
app.post('/api/settings/emergency/test', async (req) => {
app.post('/api/settings/emergency/test', { config: SLOW }, async (req) => {
const [ext] = await db.select().from(extensions).where(eq(extensions.userId, req.user!.id));
const notified = await notifyEmergency(
{ db, ami: app.ami, live: app.live, send: app.sendMail, log: req.log },
@@ -352,34 +369,42 @@ export async function settingsRoutes(app: FastifyInstance) {
* Settings → Sign-in → Directory → Test, from the unsaved form (12 §4.1). The test password
* is used once and never stored or logged.
*/
app.post('/api/settings/auth/ldap/test', async (req): Promise<LdapTestResult> => {
const { testLogin, testPassword, ...form } = LdapInputSchema.extend({
testLogin: z.string().trim().max(254).optional(),
testPassword: z.string().max(200).optional(),
}).parse(req.body);
const s = (await ldapFromInput(form))!;
try {
const r = await testLdap(s, testLogin || undefined, testPassword);
if (!r.user) return { ok: true, matching: r.matching };
const matches = await directoryUserFor(db, r.user);
return {
ok: true,
matching: r.matching,
user: { ...r.user, matches: matches?.email ?? null },
};
} catch (err) {
return { ok: false, error: (err as Error).message };
}
});
app.post(
'/api/settings/auth/ldap/test',
{ config: SLOW },
async (req): Promise<LdapTestResult> => {
const { testLogin, testPassword, ...form } = LdapInputSchema.extend({
testLogin: z.string().trim().max(254).optional(),
testPassword: z.string().max(200).optional(),
}).parse(req.body);
const s = (await ldapFromInput(form))!;
try {
const r = await testLdap(s, testLogin || undefined, testPassword);
if (!r.user) return { ok: true, matching: r.matching };
const matches = await directoryUserFor(db, r.user);
return {
ok: true,
matching: r.matching,
user: { ...r.user, matches: matches?.email ?? null },
};
} catch (err) {
return { ok: false, error: (err as Error).message };
}
},
);
/** Settings → Sign-in → Test, from the unsaved form (12 §3.1). */
app.post('/api/settings/auth/oidc/test', async (req): Promise<OidcTestResult> => {
const s = await oidcFromInput(OidcInputSchema.parse(req.body));
return testOidc(s!, app.config.PUBLIC_HOSTNAME);
});
app.post(
'/api/settings/auth/oidc/test',
{ config: SLOW },
async (req): Promise<OidcTestResult> => {
const s = await oidcFromInput(OidcInputSchema.parse(req.body));
return testOidc(s!, app.config.PUBLIC_HOSTNAME);
},
);
/** The server's public IPv4, as an outside service sees it (only sent when asked). */
app.post('/api/settings/network/detect-public-ip', async () => {
app.post('/api/settings/network/detect-public-ip', { config: SLOW }, async () => {
const ip = await detectPublicIp();
if (ip) return { ip };
throw new HttpError(502, 'unavailable', "Couldn't detect the public IP. Enter it by hand.");
@@ -401,7 +426,7 @@ export async function settingsRoutes(app: FastifyInstance) {
});
/** Settings → AI → Test (spec 10): the values in the form, saved or not. */
app.post('/api/ai/test', async (req): Promise<AiTestResult> => {
app.post('/api/ai/test', { config: SLOW }, async (req): Promise<AiTestResult> => {
const { kind } = z.object({ kind: z.enum(['llm', 'stt', 'tts']) }).parse(req.body);
const ep = (kind === 'tts' ? AiTtsEndpointSchema : AiEndpointSchema).parse(
(req.body as { endpoint?: unknown }).endpoint,
+13 -9
View File
@@ -13,7 +13,7 @@ import type {
SystemStatus,
} from '@selfphoneng/shared';
import type { FastifyInstance } from 'fastify';
import { requireAdmin } from '../auth/guards.ts';
import { SLOW, requireAdmin } from '../auth/guards.ts';
import { audit } from '../services/audit.ts';
import { getAllSettings } from '../services/settings.ts';
import { systemEvents } from '../db/schema.ts';
@@ -175,14 +175,18 @@ export async function systemRoutes(app: FastifyInstance) {
app.get('/api/system/diagnostics', async (): Promise<DiagnosticsDto> => ({
checks: await insideChecks(diag()),
}));
app.post('/api/system/diagnostics/network', async (req): Promise<NetworkTestDto> => {
const { mode } = z
.object({ mode: z.enum(['quick', 'complete']).default('quick') })
.parse(req.body ?? {});
if (!(await getAllSettings(app.db))['network.probeUrl'])
throw badRequest('No network test probe set (Settings → Network)');
return networkTest(diag(), mode);
});
app.post(
'/api/system/diagnostics/network',
{ config: SLOW },
async (req): Promise<NetworkTestDto> => {
const { mode } = z
.object({ mode: z.enum(['quick', 'complete']).default('quick') })
.parse(req.body ?? {});
if (!(await getAllSettings(app.db))['network.probeUrl'])
throw badRequest('No network test probe set (Settings → Network)');
return networkTest(diag(), mode);
},
);
app.get('/api/system/generated-files', async () => ({
files: await Promise.all(
+2 -2
View File
@@ -18,7 +18,7 @@ import {
import type { Db } from '../db/client.ts';
import { dids, outboundRoutes, trunks } from '../db/schema.ts';
import { badRequest, conflict, HttpError, notFound } from '../errors.ts';
import { requireAdmin } from '../auth/guards.ts';
import { SLOW, requireAdmin } from '../auth/guards.ts';
import { decrypt, encrypt } from '../crypto/secrets.ts';
import {
getDids,
@@ -202,7 +202,7 @@ export async function trunkRoutes(app: FastifyInstance) {
return { ok: true };
});
app.post('/api/trunks/:id/test', async (req): Promise<TrunkTestResult> => {
app.post('/api/trunks/:id/test', { config: SLOW }, async (req): Promise<TrunkTestResult> => {
const { id } = IdParams.parse(req.params);
const t = await getTrunk(db, id);
if (!t.enabled) throw badRequest('Turn the trunk on first');
+35 -3
View File
@@ -11,7 +11,7 @@ import {
} from '@selfphoneng/shared';
import type { Db } from '../db/client.ts';
import { extensions, userIdentities, users } from '../db/schema.ts';
import { badRequest, conflict, notFound } from '../errors.ts';
import { HttpError, badRequest, conflict, notFound } from '../errors.ts';
import { requireAdmin } from '../auth/guards.ts';
import { hashPassword } from '../auth/passwords.ts';
import { deleteUserSessions } from '../auth/sessions.ts';
@@ -32,6 +32,7 @@ export const toUserDto = (
u: UserRow,
extension: UserDto['extension'] = null,
ids: IdentityRow[] = [],
from: RoleSources = { oidc: null, ldap: false },
): UserDto => ({
id: u.id,
email: u.email,
@@ -44,8 +45,26 @@ export const toUserDto = (
createdAt: u.createdAt.toISOString(),
extension,
signIn: signInWays(u, ids),
roleFrom: roleFrom(ids, from),
});
/** The providers that set roles now (12 §1): adminGroups / adminFilter set and in use. */
type RoleSources = { oidc: string | null; ldap: boolean };
async function roleSources(db: Db): Promise<RoleSources> {
const [oidc, ldap, mode] = await Promise.all([
getSetting(db, 'auth.oidc'),
getSetting(db, 'auth.ldap'),
getSetting(db, 'auth.mode'),
]);
return {
oidc: oidc && mode !== 'password' && oidc.adminGroups.length > 0 ? oidc.label : null,
ldap: Boolean(ldap?.enabled && ldap.adminFilter),
};
}
const roleFrom = (ids: { kind: string }[], from: RoleSources) =>
(from.oidc && ids.some((i) => i.kind === 'oidc') && from.oidc) ||
(from.ldap && ids.some((i) => i.kind === 'ldap') ? 'the directory' : null);
/** Connected accounts (12 §2), for one user or all of them. */
const identitiesOf = (db: Db, userId?: string): Promise<IdentityRow[]> =>
db
@@ -116,12 +135,14 @@ export async function userRoutes(app: FastifyInstance) {
.leftJoin(extensions, eq(extensions.userId, users.id))
.orderBy(asc(users.displayName), asc(users.id));
const ids = await identitiesOf(db);
const from = await roleSources(db);
return {
items: rows.map((r) =>
toUserDto(
r.u,
r.extId && r.extNumber ? { id: r.extId, number: r.extNumber } : null,
ids.filter((i) => i.userId === r.u.id),
from,
),
),
nextCursor: null,
@@ -130,7 +151,12 @@ export async function userRoutes(app: FastifyInstance) {
app.get('/api/users/:id', async (req) => {
const { id } = IdParams.parse(req.params);
return toUserDto(await getUser(db, id), await extOf(db, id), await identitiesOf(db, id));
return toUserDto(
await getUser(db, id),
await extOf(db, id),
await identitiesOf(db, id),
await roleSources(db),
);
});
app.post('/api/users', async (req, reply) => {
@@ -169,6 +195,12 @@ export async function userRoutes(app: FastifyInstance) {
const body = UserUpdateSchema.parse(req.body);
if (Object.keys(body).length === 0) throw badRequest('Nothing to update');
const before = await getUser(db, id);
const ids = await identitiesOf(db, id);
const from = await roleSources(db);
// 12 §1: a provider that sets the role would undo this at the next sign-in or check.
const managed = roleFrom(ids, from);
if (body.role !== undefined && body.role !== before.role && managed)
throw new HttpError(409, 'role_managed', `The role comes from ${managed}`);
const losesAdmin =
before.role === 'admin' &&
before.isActive &&
@@ -199,7 +231,7 @@ export async function userRoutes(app: FastifyInstance) {
after: Object.fromEntries(changed.map((k) => [k, after[k]])),
},
});
return toUserDto(after, await extOf(db, id), await identitiesOf(db, id));
return toUserDto(after, await extOf(db, id), ids, from);
});
app.delete('/api/users/:id', async (req) => {
+43 -1
View File
@@ -23,7 +23,49 @@ export async function directoryUserFor(db: Db, e: DirectoryEntry): Promise<UserR
if (byId) return byId.u;
if (!e.email) return null;
const [byEmail] = await db.select().from(users).where(eq(users.email, e.email));
return byEmail ?? null;
return byEmail && byEmailOk(byEmail) ? byEmail : null;
}
/**
* Who a directory email may connect to on its own (12 §4.3 step 2): not an admin, and nobody
* who chose their own password (none, or only the temporary one from Users). Anyone else
* connects on My settings with both passwords: a directory that lets people edit their own
* `mail` must not hand out the admin's account.
*/
const byEmailOk = (u: UserRow) =>
u.role !== 'admin' && (u.passwordHash === null || u.mustChangePassword);
/** Connect a directory account the person proved (My settings, 12 §4.3). */
export async function connectDirectory(
db: Db,
log: { info(o: object, m: string): void },
userId: string,
e: DirectoryEntry,
ip: string,
): Promise<'connected' | 'taken'> {
const [owner] = await db
.select({ userId: userIdentities.userId })
.from(userIdentities)
.where(and(eq(userIdentities.kind, 'ldap'), eq(userIdentities.subject, e.id)));
if (owner && owner.userId !== userId) return 'taken';
await db.transaction(async (tx) => {
await tx
.delete(userIdentities)
.where(and(eq(userIdentities.userId, userId), eq(userIdentities.kind, 'ldap')));
await tx
.insert(userIdentities)
.values({ userId, kind: 'ldap', subject: e.id, email: e.email, lastUsedAt: new Date() });
});
await audit(db, {
actorUserId: userId,
actorIp: ip,
action: 'ldap_connected',
entityType: 'user',
entityId: userId,
summary: `Connected directory account ${e.dn}`,
});
await applyRole(db, log, userId, e.admin, 'the directory', ip);
return 'connected';
}
/**
+40 -2
View File
@@ -1,10 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import { BerWriter } from 'ldapts';
import { describe, expect, it } from 'vitest';
import { BerWriter, Client, UnwillingToPerformError } from 'ldapts';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
anyLogin,
assertLdap,
byIdFilter,
DirectoryUnavailable,
ldapAuthenticate,
withLogin,
type LdapSettings,
@@ -61,3 +62,40 @@ describe('LDAP (12 §4)', () => {
expect(await ldapAuthenticate(s, 'ann', '')).toBeNull();
});
});
describe('a directory that answers no (12 §4.2)', () => {
afterEach(() => vi.restoreAllMocks());
const s = {
url: 'ldaps://dir.example.com',
startTls: false,
bindDn: null,
baseDn: 'dc=example,dc=com',
userFilter: '(uid={login})',
idAttribute: 'entryUUID',
emailAttribute: 'mail',
nameAttribute: 'displayName',
adminFilter: null,
} as LdapSettings;
it('a locked account (389 DS, FreeIPA: not code 49) is a refused password, not "unavailable"', async () => {
vi.spyOn(Client.prototype, 'search').mockResolvedValue({
searchEntries: [{ dn: 'uid=ann,dc=example,dc=com', entryUUID: 'id-1' }],
searchReferences: [],
});
vi.spyOn(Client.prototype, 'unbind').mockResolvedValue();
vi.spyOn(Client.prototype, 'bind').mockRejectedValue(
new UnwillingToPerformError('Account locked'),
);
expect(await ldapAuthenticate(s, 'ann', 'right-but-locked')).toBeNull();
});
it('no answer at all is "unavailable"', async () => {
vi.spyOn(Client.prototype, 'search').mockResolvedValue({
searchEntries: [{ dn: 'uid=ann,dc=example,dc=com', entryUUID: 'id-1' }],
searchReferences: [],
});
vi.spyOn(Client.prototype, 'unbind').mockResolvedValue();
vi.spyOn(Client.prototype, 'bind').mockRejectedValue(new Error('connect ECONNREFUSED'));
await expect(ldapAuthenticate(s, 'ann', 'pw')).rejects.toThrow(DirectoryUnavailable);
});
});
+5 -1
View File
@@ -8,6 +8,7 @@ import {
FilterParser,
InvalidCredentialsError,
NoSuchObjectError,
ResultCodeError,
SizeLimitExceededError,
escapeFilter,
type Entry,
@@ -150,7 +151,10 @@ async function passwordWorks(s: LdapSettings, dn: string, password: string): Pro
await u.bind(dn, password);
return true;
} catch (err) {
if (err instanceof InvalidCredentialsError) return false;
// The server answered (wrong password, locked, expired: 389 DS and FreeIPA say 19 or 53
// for a locked account): a refused password. Only no answer at all is "unavailable", so
// locking someone's account can't turn their sign-in into a 503.
if (err instanceof ResultCodeError) return false;
throw new DirectoryUnavailable((err as Error).message);
} finally {
await u.unbind().catch(() => {});
+23 -4
View File
@@ -8,7 +8,7 @@ import { decrypt, encrypt } from '../crypto/secrets.ts';
import { sessions, userIdentities, users } from '../db/schema.ts';
import { audit } from '../services/audit.ts';
import { getSetting } from '../services/settings.ts';
import { decide, type ClaimsDecision } from './claims.ts';
import { claimAt, decide, type ClaimsDecision } from './claims.ts';
import { applyRole } from './role.ts';
import { oidcConfig, oidcError, type OidcSettings } from './oidc.ts';
@@ -95,9 +95,13 @@ export async function checkOidcSessions(d: { db: Db; log: Log; now?: Date }): Pr
try {
tokens = await oidc.refreshTokenGrant(config, decrypt(row.refreshEnc!));
const sub = row.subject.slice(row.subject.indexOf(' ') + 1);
claims = config.serverMetadata().userinfo_endpoint
? { ...(await oidc.fetchUserInfo(config, tokens.access_token, sub)) }
: { ...tokens.claims() };
// As at sign-in: the ID token's claims, userinfo's on top. Entra's app roles and
// "ID token only" mappers are in the ID token alone.
claims = {
...tokens.claims(),
...(config.serverMetadata().userinfo_endpoint &&
(await oidc.fetchUserInfo(config, tokens.access_token, sub))),
};
} catch (err) {
// The provider said no: disabled, signed out there, or the refresh token expired.
if (err instanceof oidc.ResponseBodyError && err.error === 'invalid_grant') {
@@ -108,6 +112,21 @@ export async function checkOidcSessions(d: { db: Db; log: Log; now?: Date }): Pr
log.warn?.({ err: oidcError(err) }, `OpenID check of a session failed`);
continue;
}
// No groups claim at all (the refresh brought no ID token, and userinfo doesn't carry it):
// nothing to decide groups on, so not "in no groups". Checked again next run.
const groupsKnown = claimAt(claims, s.groupsClaim) !== undefined;
const ruled = s.allowedGroups.length > 0 || s.adminGroups.length > 0;
if (ruled && !groupsKnown) {
log.info({ userId: row.userId }, 'OpenID check: no groups claim, role and access kept');
await db
.update(sessions)
.set({
idpCheckedAt: now,
...(tokens.refresh_token && { idpRefreshEnc: encrypt(tokens.refresh_token) }),
})
.where(eq(sessions.id, row.id));
continue;
}
const verdict = decide(s, claims);
if (!verdict.allowed) {
await end(row, `no longer in ${s.label}'s allowed groups`);
+7 -1
View File
@@ -94,8 +94,14 @@ describe.skipIf(!hasTestDb || !URL_)('Active Directory with a real Samba AD DC (
});
t = await createTestApp();
for (const sam of [ann, bob, dan, cat])
// New users with the temporary password: the directory email may connect them (12 §4.3).
for (const sam of [ann, bob, dan, cat]) {
await t.addUser(`${sam}@example.com`, 'local-password-1');
await t.db
.update(users)
.set({ mustChangePassword: true })
.where(eq(users.email, `${sam}@example.com`));
}
await setSetting(t.db, 'auth.ldap', {
enabled: true,
preset: 'ad',
+1
View File
@@ -66,6 +66,7 @@ export async function createTestApp(
INITIAL_ADMIN_PASSWORD: ADMIN.password,
AMI_SECRET: 'ami-secret-123',
LOG_LEVEL: 'fatal',
RATE_LIMIT_PER_MINUTE: '0', // the rate limit has its own test
RECORDINGS_DIR: mkdtempSync(join(tmpdir(), 'sp-rec-')),
SOUNDS_DIR: mkdtempSync(join(tmpdir(), 'sp-sounds-')),
MOH_DIR: mkdtempSync(join(tmpdir(), 'sp-moh-')),
+119
View File
@@ -0,0 +1,119 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// 12 §4.3: who a directory entry may connect to by email, who may disconnect it (§4.5), and a
// directory that's down counting as a failed sign-in (§4.2).
import { eq } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { resetLoginLimiter } from '../src/auth/login-limiter.ts';
import { userIdentities, users } from '../src/db/schema.ts';
import { setSetting } from '../src/services/settings.ts';
import { directorySignIn } from '../src/sso/directory.ts';
import type { DirectoryEntry } from '../src/sso/ldap.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
const log = { info: () => {} };
describe.skipIf(!hasTestDb)('directory accounts by email (12 §4.3)', () => {
let t: Awaited<ReturnType<typeof createTestApp>>;
let n = 0;
const entry = (email: string): DirectoryEntry => ({
dn: `uid=e${++n}`,
id: `id-${n}`,
email,
name: email,
admin: null,
});
const connected = async (userId: string) =>
(await t.db.select().from(userIdentities).where(eq(userIdentities.userId, userId))).length;
beforeAll(async () => {
t = await createTestApp();
await t.clearMustChange(ADMIN.email);
});
afterAll(() => t?.close());
it('never connects an admin, or someone who set their own password', async () => {
const admin = await t.addUser('boss@example.com', 'boss-password-1', 'admin');
const own = await t.addUser('own@example.com', 'own-password-1');
for (const u of [admin, own]) {
expect(await directorySignIn(t.db, log, entry(u.email), '127.0.0.1')).toBeNull();
expect(await connected(u.id)).toBe(0);
}
});
it('connects someone with no password, or only the temporary one', async () => {
const fresh = await t.addUser('fresh@example.com', 'temporary-pw-1');
await t.db.update(users).set({ mustChangePassword: true }).where(eq(users.id, fresh.id));
const none = await t.addUser('none@example.com', 'unused-pw-1');
await t.db.update(users).set({ passwordHash: null }).where(eq(users.id, none.id));
for (const u of [fresh, none]) {
expect((await directorySignIn(t.db, log, entry(u.email), '127.0.0.1'))?.id).toBe(u.id);
expect(await connected(u.id)).toBe(1);
}
});
it('only an admin can disconnect a directory account (a leaver must stay checked)', async () => {
const u = await t.addUser('leaver@example.com', 'leaver-password-1');
await t.db.insert(userIdentities).values({ userId: u.id, kind: 'ldap', subject: 'leaver-id' });
const mine = await t.login(u.email, 'leaver-password-1');
const self = await t.app.inject({
method: 'DELETE',
url: '/api/me/identities/ldap',
headers: { ...H, cookie: mine },
});
expect(self.statusCode).toBe(403);
expect(await connected(u.id)).toBe(1);
const admin = await t.login(ADMIN.email, ADMIN.password);
const byAdmin = await t.app.inject({
method: 'DELETE',
url: `/api/users/${u.id}/identities/ldap`,
headers: { ...H, cookie: admin },
});
expect(byAdmin.statusCode).toBe(200);
expect(await connected(u.id)).toBe(0);
});
it('a directory that is down still counts each failed password: no free guesses', async () => {
resetLoginLimiter();
await setSetting(t.db, 'auth.ldap', {
enabled: true,
preset: 'lldap',
url: 'ldap://127.0.0.1:1', // nothing listens there
startTls: false,
bindDn: null,
bindPasswordEnc: null,
baseDn: 'dc=example,dc=com',
userFilter: '(uid={login})',
idAttribute: 'entryUUID',
emailAttribute: 'mail',
nameAttribute: 'displayName',
adminFilter: null,
});
try {
const codes: number[] = [];
for (let i = 0; i < 6; i++)
codes.push(
(
await t.app.inject({
method: 'POST',
url: '/api/auth/login',
headers: H,
payload: { login: ADMIN.email, password: `guess-${i}` },
})
).statusCode,
);
expect(codes).toEqual([503, 503, 503, 503, 503, 429]);
// Even the right password waits now: the guesses were counted.
const right = await t.app.inject({
method: 'POST',
url: '/api/auth/login',
headers: H,
payload: { login: ADMIN.email, password: ADMIN.password },
});
expect(right.statusCode).toBe(429);
} finally {
await setSetting(t.db, 'auth.ldap', null);
resetLoginLimiter();
}
});
});
+9 -2
View File
@@ -11,6 +11,8 @@ export type FakeUser = {
email_verified?: boolean;
/** Only in userinfo, as Keycloak's mappers and Authelia send them. */
groups?: string[];
/** Only in the ID token, as Entra's app roles. */
roles?: string[];
};
const b64u = (b: Buffer | string) => Buffer.from(b).toString('base64url');
@@ -38,6 +40,8 @@ export async function startFakeOidc(alg: 'RS256' | 'ES256' = 'RS256') {
/** true: the token endpoint answers 503, as a provider that's down. */
down: false,
refreshes: 0,
/** false: a refresh brings no new ID token (allowed by OIDC Core §12.2). */
refreshIdToken: true,
};
const codes = new Map<string, { nonce: string; challenge: string; user: FakeUser }>();
const access = new Map<string, FakeUser>();
@@ -101,7 +105,8 @@ export async function startFakeOidc(alg: 'RS256' | 'ES256' = 'RS256') {
st.refreshes++;
if (!user || !st.refreshOk) return json(400, { error: 'invalid_grant' });
// Today's claims, as a real provider gives on refresh.
return json(200, tokens(st.user.sub === user.sub ? { ...st.user } : user, null));
const t = tokens(st.user.sub === user.sub ? { ...st.user } : user, null);
return json(200, st.refreshIdToken ? t : { ...t, id_token: undefined });
}
const c = codes.get(form.get('code') ?? '');
codes.delete(form.get('code') ?? '');
@@ -113,7 +118,8 @@ export async function startFakeOidc(alg: 'RS256' | 'ES256' = 'RS256') {
if (url.pathname === '/userinfo') {
const user = access.get((req.headers.authorization ?? '').replace(/^Bearer /, ''));
if (!user) return json(401, { error: 'invalid_token' });
return json(200, { ...user, ...(st.tamper.userinfoSub && { sub: 'someone-else' }) });
const { roles: _, ...info } = user;
return json(200, { ...info, ...(st.tamper.userinfoSub && { sub: 'someone-else' }) });
}
if (url.pathname === '/logout') return json(200, {});
json(404, {});
@@ -130,6 +136,7 @@ export async function startFakeOidc(alg: 'RS256' | 'ES256' = 'RS256') {
exp: t.expired ? now - 3600 : now + 300,
...(nonce && { nonce: t.nonce ? 'wrong-nonce' : nonce }),
...(user.email && { email: user.email, email_verified: user.email_verified }),
...(user.roles && { roles: user.roles }),
};
const at = b64u(randomBytes(16));
const rt = b64u(randomBytes(16));
+32 -6
View File
@@ -38,6 +38,7 @@ describe.skipIf(!hasTestDb || !URL_ || !HTTP)('LDAP with a real lldap (12 S4)',
const ann = `ann${tag}`;
const bob = `bob${tag}`;
const cat = `cat${tag}`;
const dee = `dee${tag}`;
const group = `pbx-admins-${tag}`;
let t: Awaited<ReturnType<typeof createTestApp>>;
let jwt: string;
@@ -86,6 +87,7 @@ describe.skipIf(!hasTestDb || !URL_ || !HTTP)('LDAP with a real lldap (12 S4)',
[ann, 'Ann Lee'],
[bob, 'Bob Roe'],
[cat, 'Cat Poe'],
[dee, 'Dee Fox'],
])
await gql(
`mutation { createUser(user: { id: "${id}", email: "${id}@example.com", displayName: "${name}" }) { id } }`,
@@ -96,7 +98,7 @@ describe.skipIf(!hasTestDb || !URL_ || !HTTP)('LDAP with a real lldap (12 S4)',
await gql(`mutation { addUserToGroup(userId: "${ann}", groupId: ${g.createGroup.id}) { ok } }`);
const c = new Client({ url: URL_! });
await c.bind(`uid=admin,ou=people,${BASE}`, ADMIN_PASS);
for (const id of [ann, bob, cat])
for (const id of [ann, bob, cat, dee])
await c.exop(
'1.3.6.1.4.1.4203.1.11.1',
passwordModify(`uid=${id},ou=people,${BASE}`, `${id}-directory-pw`),
@@ -106,14 +108,21 @@ describe.skipIf(!hasTestDb || !URL_ || !HTTP)('LDAP with a real lldap (12 S4)',
t = await createTestApp();
await t.clearMustChange(ADMIN.email);
admin = await t.login(ADMIN.email, ADMIN.password);
await t.addUser(`${ann}@example.com`, 'ann-local-pw-12');
await t.addUser(`${cat}@example.com`, 'cat-local-pw-12');
// New users with the temporary password from Users: the directory email may connect them.
for (const who of [ann, cat]) {
await t.addUser(`${who}@example.com`, `${who}-local-pw-12`);
await t.db
.update(users)
.set({ mustChangePassword: true })
.where(eq(users.email, `${who}@example.com`));
}
await t.addUser(`${dee}@example.com`, 'dee-own-password-12'); // chose her own password
await setSetting(t.db, 'auth.ldap', settings());
}, 60_000);
afterAll(async () => {
await t?.close();
for (const id of [ann, bob, cat])
for (const id of [ann, bob, cat, dee])
await gql(`mutation { deleteUser(userId: "${id}") { ok } }`).catch(() => {});
});
@@ -142,7 +151,7 @@ describe.skipIf(!hasTestDb || !URL_ || !HTTP)('LDAP with a real lldap (12 S4)',
it('signs in by email too, and the local password still works', async () => {
resetLoginLimiter();
expect((await login(`${ann}@example.com`, `${ann}-directory-pw`)).statusCode).toBe(200);
expect((await login(`${ann}@example.com`, 'ann-local-pw-12')).statusCode).toBe(200);
expect((await login(`${ann}@example.com`, `${ann}-local-pw-12`)).statusCode).toBe(200);
});
it('a wrong password, an empty one, and a stranger are refused alike', async () => {
@@ -170,7 +179,7 @@ describe.skipIf(!hasTestDb || !URL_ || !HTTP)('LDAP with a real lldap (12 S4)',
const r = await login(ann, `${ann}-directory-pw`);
expect(r.statusCode).toBe(503);
expect(r.json().error.code).toBe('directory_unavailable');
expect((await login(`${ann}@example.com`, 'ann-local-pw-12')).statusCode).toBe(200);
expect((await login(`${ann}@example.com`, `${ann}-local-pw-12`)).statusCode).toBe(200);
} finally {
await setSetting(t.db, 'auth.ldap', settings());
}
@@ -198,6 +207,23 @@ describe.skipIf(!hasTestDb || !URL_ || !HTTP)('LDAP with a real lldap (12 S4)',
expect(bad.json()).toMatchObject({ ok: false, error: expect.stringContaining('refused') });
});
it('someone with their own password connects on My settings, never by email alone', async () => {
resetLoginLimiter();
expect((await login(dee, `${dee}-directory-pw`)).statusCode).toBe(401);
const mine = await t.login(`${dee}@example.com`, 'dee-own-password-12');
const connect = (password: string) =>
t.app.inject({
method: 'POST',
url: '/api/me/identities/ldap',
headers: { ...H, cookie: mine },
payload: { login: dee, password },
});
expect((await connect('wrong-password')).statusCode).toBe(400);
expect((await connect(`${dee}-directory-pw`)).statusCode).toBe(200);
resetLoginLimiter();
expect((await login(dee, `${dee}-directory-pw`)).statusCode).toBe(200);
});
it('directoryCheck disables someone removed from the directory, and only them (§4.5)', async () => {
resetLoginLimiter();
expect((await login(cat, `${cat}-directory-pw`)).statusCode).toBe(200); // connects cat
+15 -5
View File
@@ -128,12 +128,22 @@ describe.skipIf(!hasTestDb || !KC)('OpenID Connect with a real Keycloak (12 S3)'
/** start → Keycloak's login form, filled in like a person would → our callback. */
async function flow(cookie?: string, query = '') {
const start = await t.app.inject({
url: `/api/auth/oidc/start${query}`,
headers: cookie ? { cookie } : {},
});
const connecting = query === '?connect=1';
// Connect is a POST from My account (with the CSRF header); sign-in a GET from the button.
const start = connecting
? await t.app.inject({
method: 'POST',
url: '/api/auth/oidc/connect',
headers: { ...H, cookie: cookie! },
payload: {},
})
: await t.app.inject({
url: `/api/auth/oidc/start${query}`,
headers: cookie ? { cookie } : {},
});
const location = connecting ? (start.json() as { url: string }).url : start.headers.location;
const pending = start.cookies.find((c) => c.name === 'sp_oidc')!;
let res = await browser.get(start.headers.location as string);
let res = await browser.get(location as string);
if (res.status === 200) {
// Not signed in at Keycloak yet: post its login form.
const html = await res.text();
+78 -7
View File
@@ -3,7 +3,7 @@
// provider (test/fake-oidc.ts).
import { and, desc, eq } from 'drizzle-orm';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { encrypt } from '../src/crypto/secrets.ts';
import { decrypt, encrypt } from '../src/crypto/secrets.ts';
import { sessions, userIdentities, users } from '../src/db/schema.ts';
import { resetLoginLimiter } from '../src/auth/login-limiter.ts';
import { setSetting } from '../src/services/settings.ts';
@@ -49,18 +49,29 @@ for (const alg of ['RS256', 'ES256'] as const)
idp.st.user = { sub: 'u-1', email: 'ann@example.com', email_verified: true };
idp.st.refreshOk = true;
idp.st.down = false;
idp.st.refreshIdToken = true;
});
/** The browser's round trip: start → provider → callback. */
async function flow(cookie?: string, query = '') {
const start = await t.app.inject({
url: `/api/auth/oidc/start${query}`,
headers: cookie ? { cookie } : {},
});
expect(start.statusCode).toBe(302);
const connecting = query === '?connect=1';
// Connect is a POST from My account (with the CSRF header); sign-in a GET from the button.
const start = connecting
? await t.app.inject({
method: 'POST',
url: '/api/auth/oidc/connect',
headers: { ...H, cookie: cookie! },
payload: {},
})
: await t.app.inject({
url: `/api/auth/oidc/start${query}`,
headers: cookie ? { cookie } : {},
});
const location = connecting ? (start.json() as { url: string }).url : start.headers.location;
expect(start.statusCode).toBe(connecting ? 200 : 302);
const pending = start.cookies.find((c) => c.name === 'sp_oidc');
if (!pending) return { start, callback: null };
const atIdp = await fetch(start.headers.location as string, { redirect: 'manual' });
const atIdp = await fetch(location as string, { redirect: 'manual' });
const back = new URL(atIdp.headers.get('location')!);
expect(back.origin).toBe('https://pbx.test');
const callback = await t.app.inject({
@@ -81,6 +92,43 @@ for (const alg of ['RS256', 'ES256'] as const)
expect(r.json()).toEqual({ mode: 'mixed', ldap: false, oidc: { label: 'Keycloak' } });
});
it('never sends the browser to another site after sign-in (a tab, a backslash, //)', async () => {
await t.addUser('nextcheck@example.com', 'next-check-pw-1');
idp.st.user = { sub: 'u-next', email: 'nextcheck@example.com', email_verified: true };
const pw = await t.login('nextcheck@example.com', 'next-check-pw-1');
await flow(pw, '?connect=1');
for (const next of [
'/\t/evil.example',
'/\n/evil.example',
'/\\evil.example',
'//evil.example',
])
expect(
(await flow(undefined, `?next=${encodeURIComponent(next)}`)).callback!.headers.location,
).toBe('/');
expect((await flow(undefined, '?next=/me/calls')).callback!.headers.location).toBe(
'/me/calls',
);
});
it('connect is a POST with the CSRF header; a GET with connect=1 only signs in', async () => {
const ann = await t.login(ADMIN.email, ADMIN.password);
const forged = await t.app.inject({
method: 'POST',
url: '/api/auth/oidc/connect',
headers: { cookie: ann },
payload: {},
});
expect(forged.statusCode).toBe(403);
expect(forged.json().error.code).toBe('csrf');
const r = await t.app.inject({
url: '/api/auth/oidc/start?connect=1',
headers: { cookie: ann },
});
const pending = r.cookies.find((c) => c.name === 'sp_oidc')!;
expect(JSON.parse(decrypt(pending.value)).connectSessionId).toBeNull();
});
it('starts with PKCE S256, state and nonce, and a short-lived cookie', async () => {
const r = await t.app.inject({ url: '/api/auth/oidc/start?next=/me/calls' });
const to = new URL(r.headers.location as string);
@@ -403,4 +451,27 @@ for (const alg of ['RS256', 'ES256'] as const)
await setSetting(t.db, 'auth.oidc', oidcSettings);
}
});
it('the session check reads roles that are only in the ID token, and keeps a session whose refresh brings no groups', async () => {
idp.st.user = { ...idp.st.user, roles: ['pbx-users'] }; // Entra: app roles, ID token only
await setSetting(t.db, 'auth.oidc', {
...oidcSettings,
groupsClaim: 'roles',
allowedGroups: ['pbx-users'],
});
try {
const { id } = await annSession();
await check(20);
expect(await row(id)).toBeTruthy();
idp.st.refreshIdToken = false; // no new ID token: no roles claim at all
await check(40);
expect(await row(id)).toBeTruthy();
idp.st.refreshIdToken = true;
idp.st.user.roles = []; // the claim is there, and empty: ended
await check(60);
expect(await row(id)).toBeUndefined();
} finally {
await setSetting(t.db, 'auth.oidc', oidcSettings);
}
});
});
+71
View File
@@ -0,0 +1,71 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// 08 §2.4: every route is rate limited per client IP, Test buttons more, and a signed-in
// person's password asked again counts like a sign-in.
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { resetLoginLimiter } from '../src/auth/login-limiter.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
describe.skipIf(!hasTestDb)('rate limits (08 §2.4)', () => {
let t: Awaited<ReturnType<typeof createTestApp>>;
let admin: string;
beforeAll(async () => {
t = await createTestApp(undefined, { RATE_LIMIT_PER_MINUTE: '30' });
await t.clearMustChange(ADMIN.email);
admin = await t.login(ADMIN.email, ADMIN.password);
});
afterAll(() => t?.close());
it('Test buttons: 10 a minute', async () => {
const codes: number[] = [];
for (let i = 0; i < 11; i++)
codes.push(
(
await t.app.inject({
method: 'POST',
url: '/api/settings/smtp/test',
headers: { ...H, cookie: admin },
payload: {},
})
).statusCode,
);
expect(codes.slice(0, 10)).not.toContain(429);
expect(codes[10]).toBe(429);
});
it('a password asked again counts like a sign-in: guessing it is stopped', async () => {
resetLoginLimiter();
const codes: number[] = [];
for (let i = 0; i < 6; i++)
codes.push(
(
await t.app.inject({
method: 'POST',
url: '/api/auth/change-password',
headers: { ...H, cookie: admin },
payload: { currentPassword: `guess-${i}-wrong`, newPassword: 'another-password-1' },
})
).statusCode,
);
expect(codes.slice(0, 5)).toEqual([400, 400, 400, 400, 400]);
expect(codes[5]).toBe(429);
resetLoginLimiter();
});
it('/prov keeps its own limits (07 §2), outside the global one', async () => {
const prov = await t.app.inject({ url: '/prov/001565000000.cfg' });
expect(prov.headers['x-ratelimit-limit']).toBeUndefined();
const api = await t.app.inject({ url: '/api/auth/methods' });
expect(api.headers['x-ratelimit-limit']).toBe('30');
});
it('every other route: the global limit per IP, as a 429 rate_limited', async () => {
let r;
for (let i = 0; i < 40; i++) {
r = await t.app.inject({ url: '/api/auth/methods' });
if (r.statusCode === 429) break;
}
expect(r!.statusCode).toBe(429);
expect(r!.json().error.code).toBe('rate_limited');
});
});
+119
View File
@@ -111,4 +111,123 @@ describe.skipIf(!hasTestDb)('sign-in (12 S1)', () => {
});
expect(del.statusCode).toBe(200);
});
const patch = (payload: object, cookie = admin) =>
t.app.inject({ method: 'PATCH', url: '/api/settings', payload, headers: { ...H, cookie } });
it('a stored secret never goes to a new server: the directory, OpenID and email', async () => {
const ldap = {
enabled: false,
preset: 'lldap',
url: 'ldaps://dir.example.com',
startTls: false,
bindDn: 'uid=svc,dc=example,dc=com',
bindPassword: 'service-pw-1',
baseDn: 'dc=example,dc=com',
userFilter: '(uid={login})',
idAttribute: 'entryUUID',
emailAttribute: 'mail',
nameAttribute: 'displayName',
adminFilter: '',
};
expect((await patch({ 'auth.ldap': ldap })).statusCode).toBe(200);
const { bindPassword: _, ...keep } = ldap;
expect((await patch({ 'auth.ldap': keep })).statusCode).toBe(200); // same server: kept
const moved = await patch({ 'auth.ldap': { ...keep, url: 'ldaps://evil.example' } });
expect(moved.statusCode).toBe(400);
expect(moved.json().error.message).toMatch(/enter its password again/);
const test = await post(
'/api/settings/auth/ldap/test',
{ ...keep, url: 'ldaps://evil.example' },
admin,
);
expect(test.statusCode).toBe(400); // Test too: it would bind there with the stored password
const otherDn = await patch({
'auth.ldap': { ...keep, bindDn: 'uid=other,dc=example,dc=com' },
});
expect(otherDn.statusCode).toBe(400);
const oidc = {
label: 'Keycloak',
issuer: 'https://kc.example.com/realms/x',
clientId: 'selfphoneng',
clientSecret: 'client-secret-1',
scopes: 'openid email profile',
groupsClaim: 'groups',
adminGroups: [],
allowedGroups: [],
trustEmail: false,
};
expect((await patch({ 'auth.oidc': oidc })).statusCode).toBe(200);
const { clientSecret: __, ...noSecret } = oidc;
const newIssuer = await patch({ 'auth.oidc': { ...noSecret, issuer: 'https://evil.example' } });
expect(newIssuer.statusCode).toBe(400);
expect(newIssuer.json().error.message).toMatch(/client secret again/);
expect((await patch({ 'auth.oidc': noSecret })).json()['auth.oidc'].hasClientSecret).toBe(true);
const smtp = {
host: 'mail.example.com',
port: 587,
secure: false,
username: 'pbx',
password: 'smtp-pw-1',
from: 'PBX <pbx@example.com>',
};
expect((await patch({ smtp })).statusCode).toBe(200);
const { password: ___, ...smtpKeep } = smtp;
expect((await patch({ smtp: smtpKeep })).json().smtp.hasPassword).toBe(true);
expect(
(await patch({ smtp: { ...smtpKeep, host: 'evil.example' } })).json().smtp.hasPassword,
).toBe(false);
await patch({ 'auth.ldap': null, 'auth.oidc': null, smtp: null });
});
it('a role set by the directory or the provider is read-only here (409 role_managed)', async () => {
const u = await t.addUser('managed@example.com', 'managed-pw-12');
await t.db.insert(userIdentities).values({ userId: u.id, kind: 'ldap', subject: 'managed-id' });
const ldap = {
enabled: true,
preset: 'lldap' as const,
url: 'ldaps://dir.example.com',
startTls: false,
bindDn: null,
bindPasswordEnc: null,
baseDn: 'dc=example,dc=com',
userFilter: '(uid={login})',
idAttribute: 'entryUUID',
emailAttribute: 'mail',
nameAttribute: 'displayName',
adminFilter: null,
};
await setSetting(t.db, 'auth.ldap', ldap);
const setRole = (role: string) =>
t.app.inject({
method: 'PATCH',
url: `/api/users/${u.id}`,
payload: { role },
headers: { ...H, cookie: admin },
});
try {
expect((await setRole('admin')).statusCode).toBe(200); // no admin filter: set here
await setRole('user');
await setSetting(t.db, 'auth.ldap', { ...ldap, adminFilter: '(memberOf=cn=pbx-admins)' });
const r = await setRole('admin');
expect(r.statusCode).toBe(409);
expect(r.json().error.code).toBe('role_managed');
const dto = await t.app.inject({ url: `/api/users/${u.id}`, headers: { cookie: admin } });
expect(dto.json().roleFrom).toBe('the directory');
} finally {
await setSetting(t.db, 'auth.ldap', null);
}
});
it('an empty Contrack update is a 400, not a 500', async () => {
const r = await t.app.inject({
method: 'PATCH',
url: '/api/me/contrack',
payload: {},
headers: { ...H, cookie: admin },
});
expect(r.statusCode).toBe(400);
});
});