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
@@ -15,6 +15,7 @@ APP_UID=1000
APP_GID=1000
LOG_LEVEL=info
TRUST_PROXY=true # true when behind Caddy/nginx
RATE_LIMIT_PER_MINUTE=600 # requests per minute per client IP, every route; 0 = off
RTP_PORT_START=10000 # call audio (UDP), both ends included: open this range on the
RTP_PORT_END=20000 # firewall and router. Two ports per call; 200 is plenty for a small office
# Local AI (admin guide: AI). Starting values for Settings → AI; that page wins once saved.
+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);
});
});
+5
View File
@@ -172,6 +172,11 @@ export const authApi = {
methods: () => api<AuthMethods>('GET', '/api/auth/methods'),
/** 12 §3.3: disconnect my OpenID account. */
disconnectOidc: () => api<{ ok: true }>('DELETE', '/api/me/identities/oidc'),
/** 12 §3.3: where to send the browser to connect my OpenID account. */
connectOidc: (next: string) => api<{ url: string }>('POST', '/api/auth/oidc/connect', { next }),
/** 12 §4.3: connect my directory account with its username and password. */
connectLdap: (login: string, password: string) =>
api<{ ok: true }>('POST', '/api/me/identities/ldap', { login, password }),
changePassword: (currentPassword: string, newPassword: string) =>
api<{ ok: true }>('POST', '/api/auth/change-password', { currentPassword, newPassword }),
};
+75 -9
View File
@@ -1,10 +1,12 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// My sign-in ways (12 §5): the password, and connecting or disconnecting an OpenID account.
// My sign-in ways (12 §5): the password, connecting or disconnecting an OpenID account, and
// connecting a directory account.
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link, useLocation, useSearchParams } from 'react-router';
import { authApi } from '../api/client.ts';
import { useMe } from '../auth/RequireAuth.tsx';
import { Alert, Button } from './ui.tsx';
import { Alert, Button, Field, inputClass } from './ui.tsx';
/** What `?sso_error=` means (12 §3.2), for the login page and here. */
export function ssoErrorText(code: string, label: string): string {
@@ -34,6 +36,10 @@ export function SignInCard() {
mutationFn: authApi.disconnectOidc,
onSuccess: () => qc.invalidateQueries({ queryKey: ['me'] }),
});
const connect = useMutation({
mutationFn: () => authApi.connectOidc(loc.pathname),
onSuccess: (r) => window.location.assign(r.url),
});
const ways = me.data?.signIn;
const oidc = methods.data?.oidc;
if (!ways) return null;
@@ -59,10 +65,13 @@ export function SignInCard() {
</p>
)}
</div>
{ways.ldap && (
{ways.ldap ? (
<p className="text-sm">
Your company directory account is connected: its password works here too.
Your company directory account is connected: its password works here too. Only an admin
can disconnect it.
</p>
) : (
methods.data?.ldap && <DirectoryConnect />
)}
{oidc && (
<div className="flex flex-col gap-2 text-sm">
@@ -87,14 +96,15 @@ export function SignInCard() {
Connect your {oidc.label} account to sign in with it as well.
</p>
<div>
{/* A full page load: the browser goes to the provider and comes back. */}
<a
className="inline-block rounded-md border border-zinc-300 px-3 py-1.5 font-medium hover:bg-zinc-100 dark:border-zinc-700 dark:hover:bg-zinc-800"
href={`/api/auth/oidc/start?connect=1&next=${encodeURIComponent(loc.pathname)}`}
{/* The browser goes to the provider and comes back (a POST first: 12 §3.3). */}
<Button
onClick={() => connect.mutate()}
disabled={connect.isPending || connect.isSuccess}
>
Connect {oidc.label}
</a>
</Button>
</div>
{connect.error && <Alert>{connect.error.message}</Alert>}
</>
)}
</div>
@@ -102,3 +112,59 @@ export function SignInCard() {
</section>
);
}
/** Connect my directory account (12 §4.3): its username and password, checked by the directory. */
function DirectoryConnect() {
const qc = useQueryClient();
const [v, setV] = useState({ login: '', password: '' });
const connect = useMutation({
mutationFn: () => authApi.connectLdap(v.login, v.password),
onSuccess: () => {
setV({ login: '', password: '' });
return qc.invalidateQueries({ queryKey: ['me'] });
},
});
return (
<form
className="flex flex-col gap-2 text-sm"
onSubmit={(e) => {
e.preventDefault();
connect.mutate();
}}
>
<h3 className="font-medium">Company directory</h3>
<p className="text-zinc-500">
Connect your directory account to sign in with its password as well.
</p>
<Field label="Directory username or email">
{(a) => (
<input
{...a}
className={inputClass}
autoComplete="username"
value={v.login}
onChange={(e) => setV({ ...v, login: e.target.value })}
/>
)}
</Field>
<Field label="Directory password">
{(a) => (
<input
{...a}
type="password"
className={inputClass}
autoComplete="current-password"
value={v.password}
onChange={(e) => setV({ ...v, password: e.target.value })}
/>
)}
</Field>
<div>
<Button type="submit" disabled={connect.isPending || !v.login || !v.password}>
Connect directory account
</Button>
</div>
{connect.error && <Alert>{connect.error.message}</Alert>}
</form>
);
}
+11 -2
View File
@@ -192,11 +192,20 @@ function GeneralTab({
label="Role"
error={errors.role?.message}
hint={
isSelf ? 'You cannot remove your own admin role if you are the last admin.' : undefined
user?.roleFrom
? `From ${user.roleFrom}: change it there.`
: isSelf
? 'You cannot remove your own admin role if you are the last admin.'
: undefined
}
>
{(a) => (
<select {...a} {...form.register('role')} className={inputClass}>
<select
{...a}
{...form.register('role')}
disabled={Boolean(user?.roleFrom)}
className={inputClass}
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
+16 -3
View File
@@ -118,9 +118,22 @@ Keycloak (26.8.1) is the one SelfPhoneNG is tested against.
## Directory (LDAP)
With a directory, people sign in with their **directory username or email and password**. When
the password isn't the one set in SelfPhoneNG, SelfPhoneNG asks the directory. The first time,
they're matched to their user here by the directory's email; nobody gets a user just for being in
the directory. Their SelfPhoneNG password, if they have one, keeps working too.
the password isn't the one set in SelfPhoneNG, SelfPhoneNG asks the directory. Nobody gets a user
just for being in the directory, and their SelfPhoneNG password, if they have one, keeps working
too.
The first time, the directory account is connected to their user here:
- **New users** (still on the temporary password from Users, or with none): automatically, by the
directory's email.
- **Everyone else, and every admin**: they sign in with their SelfPhoneNG password once, then
**My settings** (admins: **My account**) → **Sign-in** → **Connect directory account** with their
directory username and password. An email alone never connects an admin or someone who chose
their own password, so a directory that lets people edit their own email can't hand out an
account.
Only an admin can disconnect a directory account (Users → the user → Sign-in): it's what the
hourly leaver check follows.
**Settings → Sign-in → Directory (LDAP)**:
+1
View File
@@ -121,6 +121,7 @@ APP_UID=1000
APP_GID=1000
LOG_LEVEL=info
TRUST_PROXY=true # true when behind Caddy/nginx
RATE_LIMIT_PER_MINUTE=600 # requests per minute per client IP, every route; 0 = off
#NODE_EXTRA_CA_CERTS=/var/lib/selfphoneng/tls/idp-ca.pem # a private CA for the OpenID provider or LDAPS (12 §1)
```
+6 -1
View File
@@ -33,7 +33,12 @@
- **CSRF:** every non-GET `/api/*` request with cookie auth must carry the header
`X-Requested-With: selfphoneng`. The web client always sends it. Browsers can't send
it cross-site without CORS, and CORS is off.
- Login rate limit: 5 failures per IP per 15 min and 10 per email per hour → HTTP 429.
- Login rate limit: 5 failures per IP per 15 min and 10 per email per hour → HTTP 429. A signed-in
person's password asked again (change password, two-step on/off, recovery codes) counts the same.
- Every route: `@fastify/rate-limit`, `RATE_LIMIT_PER_MINUTE` (default 600) per client IP → 429
`rate_limited`. Test buttons, diagnostics and the source download: 10 a minute (`SLOW` in
`auth/guards.ts`). `/prov` (its own limits, 07 §2) and the caller-name route (Asterisk on this
machine) are outside it (`NO_LIMIT`).
Write `login_failed` to the audit log with the IP.
- If `must_change_password`, every route except `/api/auth/*` returns 403 `{code:'password_change_required'}`.
- Single sign-on (spec 12, as built in S1): the body is `{login, password, totp?}` (`login`: email, or a
+2
View File
@@ -77,6 +77,8 @@ get the proxy's address only (below). The setting is on Settings → Security, w
`Permissions-Policy: microphone=(self), camera=()`.
- HSTS is set by the reverse proxy (Caddy does it by default; nginx example adds `add_header Strict-Transport-Security "max-age=31536000" always;`).
- `TRUST_PROXY=true` → Fastify `trustProxy: '127.0.0.1'` only.
- Rate limits on every route (04 §2.2): `RATE_LIMIT_PER_MINUTE` per client IP, stricter on Test
buttons; login and a password asked again share the login limiter.
- Admin TOTP (M11): RFC 6238, 30 s, 6 digits, ±1 window, 10 single-use recovery codes (stored hashed).
- As built (M11-T1), TOTP (`auth/totp.ts`, node:crypto only, RFC 6238 test vectors in its test):
opt-in for admins on **My account** (header → Account): scan the QR or type the key, confirm one
+53 -9
View File
@@ -36,7 +36,9 @@ the softphone's SIP login are untouched: a phone never talks to the identity pro
warning).
- **Extensions** stay manual: an admin assigns one on Users.
- **Our own session**: every sign-in makes the same `sp_session` cookie as today (04 §2.2, 14 days,
sliding). `sessions.method` says how it was made. Provider tokens never reach the browser.
sliding). `sessions.method` says how it was made. Provider tokens never reach the browser, except
the ID token as `id_token_hint` in the sign-out redirect to the provider (§3.5; RP-initiated
logout works that way).
- **Two-step sign-in** (08 §2.4, owner 2026-10-25): password sign-ins ask for our TOTP code when
it's on, whether the password was checked here or by the directory. OpenID sign-ins don't: MFA
there is the provider's job.
@@ -155,7 +157,10 @@ rotation, and builds the end-session URL. We don't hand-roll any of that.
1. Login page (`mixed`, `oidc`): **Sign in with <label>**, a plain link to
`/api/auth/oidc/start?next=<path>` (same-site paths only, as `next` is today).
My account: **Connect <label>**, a link to `/api/auth/oidc/start?connect=1` (needs a session).
My account: **Connect <label>** posts `POST /api/auth/oidc/connect` (`{ next? }`, a session, the
CSRF header) and sends the browser to the `url` it answers. A POST, so another site can't start
a connect and attach whatever provider account the browser is signed in to (security audit,
2026-10-26).
2. `GET /api/auth/oidc/start`: 404 in `password` mode. Makes `state`, `nonce` and a PKCE verifier
(always all three, even when the provider advertises PKCE), stores them with `next` (and, to
connect, the session id) in the cookie `sp_oidc = encrypt(JSON)`;
@@ -241,7 +246,9 @@ lasts 14 days, so a person disabled in Keycloak must lose it sooner:
- Sessions with `method = 'oidc'`, a refresh token and `idp_checked_at` older than 15 min →
`refreshTokenGrant`, store the new refresh and ID tokens when rotated, fetch userinfo with the new
access token, run `applyClaims`. The provider refuses (`invalid_grant`: user disabled, session
access token, run `applyClaims` on the new ID token's claims with userinfo's on top (as at
sign-in: Entra's app roles are in the ID token only). No groups claim at all (the refresh brought
no ID token and userinfo lacks it) → role and access unchanged this run; an empty one counts. The provider refuses (`invalid_grant`: user disabled, session
revoked, refresh token expired) or `applyClaims` says not allowed → delete that session (the live
socket closes within 30 s, 04 §6). Role, groups and email changes apply. The provider unreachable
or 5xx → keep the session, try next run (an outage doesn't sign everyone out).
@@ -346,7 +353,10 @@ accepted, so older scripts keep working).
2. Connect, StartTLS if set, bind the service account (or none).
3. Search `baseDn`, subtree, `userFilter` with every `{login}` replaced by the escaped `login`
(`escapeFilter`), attributes: id, email, name. Not exactly one entry → fail.
4. Bind as that entry's DN with the password. Refused → fail.
4. Bind as that entry's DN with the password. Refused → fail. Any answer from the server is a
refusal (wrong password, locked, expired: 389 DS and FreeIPA answer a locked account with
19 or 53, not 49); only no answer at all is "unavailable". Else locking someone's directory
account would turn their sign-in into a 503.
5. `adminFilter` set → search `base` = that DN, scope `base`, filter `adminFilter`, with the
**service** connection: one entry → admin, none → user.
6. Unbind and close (one connection per sign-in, no pool: a few sign-ins a day).
@@ -356,7 +366,9 @@ accepted, so older scripts keep working).
("… signed in with the directory" for LDAP).
6. Anything else → 401 "Wrong email or password", as today (no hint whether the account exists).
The directory unreachable → 503 `directory_unavailable` "The directory can't be reached.
Passwords set in SelfPhoneNG still work." and a dashboard warning.
Passwords set in SelfPhoneNG still work." and a dashboard warning. The 503 is counted and
audited as a failed sign-in first: else, while the directory is down, a wrong local password
(503) and a right one (200) would be free unlimited guesses.
A user can have both a SelfPhoneNG password and a directory account: either password works.
@@ -364,14 +376,20 @@ A user can have both a SelfPhoneNG password and a directory account: either pass
1. A `user_identities` row (`ldap`, id) → its user. Their email here follows the directory's when
it changes (unless another user has it: kept, logged).
2. Else, a user with the directory email → connect it (add the row) and sign in, audit
`ldap_connected`. The directory is the company's own and the person just proved its password,
so its email is trusted, unlike an OpenID provider's (§3.3).
2. Else, a user with the directory email who is **not an admin** and has **no password or only the
temporary one** from Users (`must_change_password`) → connect it (add the row) and sign in,
audit `ldap_connected`. Anyone else connects on My settings → Sign-in → **Connect directory
account** (`POST /api/me/identities/ldap`, `{ login, password }`, checked by the directory and
counted by the login limiter; 409 `taken` when another user has it). Security audit,
2026-10-26: a directory where people can edit their own `mail` (common OpenLDAP access rules)
must not hand out the admin's account.
3. Else → 401 "Wrong email or password" (no account is made, §1), with `login_failed` audited as
"directory account … has no phone system account". The admin's Test shows the same answer.
4. No email on the entry → same 401, logged.
Users → a user shows the directory account and **Disconnect** (same rules as §3.3).
Users → a user shows the directory account and **Disconnect** (same rules as §3.3). Only an admin
disconnects a directory account (`DELETE /api/me/identities/ldap` → 403 `admin_only` for a user):
it's what the leaver check (§4.5) follows, so a leaver mustn't be able to drop it on the way out.
### 4.4 Ids
@@ -555,6 +573,32 @@ For every active user with a directory account, one connection per run:
gained a removed user disabled by the check. The OpenLDAP and FreeIPA presets are still
unchecked against a real server.
### 8.6 Security audit, 2026-10-26
Two separate reviews (one by an independent agent) of the whole branch and every API route. All
207 routes were called signed out and as a plain user: each is guarded as meant. Fixed, each with a
test:
- Login: a directory 503 is counted and audited as a failed sign-in (§4.2); a locked directory
account is a refused password, not "unavailable" (`passwordWorks`: any `ResultCodeError`).
- `next` after OpenID sign-in: no control character or backslash (`safeNext`): "/<tab>/evil" was
an open redirect, browsers drop the tab.
- Directory email matching only for non-admins without a password of their own (§4.3); others
connect on My settings (`POST /api/me/identities/ldap`). Only admins disconnect a directory
account (§4.3, §4.5).
- Session check: the refreshed ID token's claims under userinfo's (§3.5).
- Connect is `POST /api/auth/oidc/connect` (§3.2), not a GET.
- A stored bind password or client secret is only reused for the same URL and bind DN, or issuer
and client ID (400 "enter it again" otherwise, PATCH and Test); SMTP's password is dropped when
the host or username changes.
- `PATCH /api/users/:id` role → 409 `role_managed` when a provider sets it (§1); `UserDto.roleFrom`,
the Users drawer shows "From …" read-only.
- `@fastify/rate-limit` on every route (04 §2.2), stricter on Test buttons; a password asked again
shares the login limiter. `PATCH /api/me/contrack` with nothing to set: 400, not 500.
Left as is: directory usernames can be told apart by timing (one more bind when the entry exists);
the ID token goes to the provider as `id_token_hint` on sign-out (§1).
## 9. Decided
- Owner, 2026-10-25: a user connects their OpenID account to their account and signs in with
+2
View File
@@ -36,6 +36,8 @@ export type UserDto = {
createdAt: string;
extension: { id: string; number: string } | null;
signIn: SignInWays;
/** Who sets the role (12 §1): the provider's label, "the directory", or null (set here). */
roleFrom: string | null;
};
export type ListResponse<T> = { items: T[]; nextCursor: string | null };
+19
View File
@@ -44,6 +44,9 @@ importers:
'@fastify/cookie':
specifier: ^11.1.3
version: 11.1.3
'@fastify/rate-limit':
specifier: ^11.2.1
version: 11.2.1
'@fastify/static':
specifier: ^10.1.6
version: 10.1.6
@@ -757,6 +760,9 @@ packages:
'@fastify/proxy-addr@5.1.2':
resolution: {integrity: sha512-uqB4QsgVoro0XDC+DIftIMGXHdBPn+lK6Ek3xXAIxPMCJfqxnbtyEoMCm2KPUVOuIosd7Cyw9HawEqMFM/UA+g==}
'@fastify/rate-limit@11.2.1':
resolution: {integrity: sha512-uTQjii3tJUnb6ZyEMcunuvKE3FDKNZEaPpxdyWADDgthHnUV1swdvwuiZfi3DDDgxm1iBrhOkQ/w0yDBQ8qJtA==}
'@fastify/send@4.1.2':
resolution: {integrity: sha512-PitRBXg/N4IrE5ToLuP7lITI5Mz8MmP13dv2aMtMNfaGfc5W5hNOwHqcsdQdqu0Lwm5cpDiYLkiOsZTgmhyt2Q==}
@@ -1918,6 +1924,10 @@ packages:
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
ip-address@10.7.5:
resolution: {integrity: sha512-t2v0rkILYv9WXmSL7TpAaU3HXgaFYOPUcH3QHgwQ++gpJuH3bqyMy4Fvt/DJCTKwzH3Cf+bAp5W/mFL39KPLzA==}
engines: {node: '>= 12'}
ipaddr.js@2.5.0:
resolution: {integrity: sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==}
engines: {node: '>= 10'}
@@ -3164,6 +3174,13 @@ snapshots:
'@fastify/forwarded': 3.1.1
ipaddr.js: 2.5.0
'@fastify/rate-limit@11.2.1':
dependencies:
'@lukeed/ms': 2.0.2
fastify-plugin: 6.0.1
ip-address: 10.7.5
toad-cache: 3.7.4
'@fastify/send@4.1.2':
dependencies:
'@lukeed/ms': 2.0.2
@@ -4105,6 +4122,8 @@ snapshots:
inherits@2.0.4: {}
ip-address@10.7.5: {}
ipaddr.js@2.5.0: {}
is-extglob@2.1.1: {}