auth: security audit fixes, rate limits on every route
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:
1 parent
19610777a4
commit
97e67e347d
39 files changed
+1059
-186
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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 };
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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');
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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(
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(() => {});
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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-')),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 }),
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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)**:
|
||||
|
||||
|
||||
@@ -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)
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
Generated
+19
@@ -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: {}
|
||||
|
||||
Reference in new issue
Block a user