Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10b499e263 | ||
|
|
34f443385d | ||
|
|
f435b475ed | ||
|
|
1979a02827 | ||
|
|
08bc611dec | ||
|
|
63286a1954 | ||
|
|
6bd2aaec56 | ||
|
|
80e97b4118 | ||
|
|
0a18d5354d | ||
|
|
a0121d4975 | ||
|
|
678d45e4f9 | ||
|
|
60348cf0c5 | ||
|
|
958405651b | ||
|
|
db244b116a | ||
|
|
1db5aa22dc | ||
|
|
8a1f17099d | ||
|
|
a75113b6ac | ||
|
|
2bc7d5cde5 | ||
|
|
19ca4ffc91 | ||
|
|
1bc0fe8811 | ||
|
|
add90204c0 | ||
|
|
4369dd5ae1 | ||
|
|
3d0f091bba | ||
|
|
5db4914fa7 | ||
|
|
fd3f06c167 | ||
|
|
79bb1fa78b | ||
|
|
11b531d5b4 | ||
|
|
70083f8f83 | ||
|
|
6b0c5aa827 | ||
|
|
e05065599e | ||
|
|
49d529e185 | ||
|
|
70f14288ca | ||
|
|
6f87d6f663 | ||
|
|
a3dfcb6b14 | ||
|
|
dc5965617e | ||
|
|
a614319990 | ||
|
|
33d5f1a5f5 | ||
|
|
431ada51aa | ||
|
|
7da0611a98 | ||
|
|
0cdac0bcdc | ||
|
|
adb6d52ddf | ||
|
|
7722f33639 | ||
|
|
97e67e347d | ||
|
|
19610777a4 | ||
|
|
b06335586b | ||
|
|
73963ee3a3 | ||
|
|
06b3a73edc | ||
|
|
3a9423af1f | ||
|
|
ba293f8703 |
No files matched your search
@@ -39,3 +39,4 @@
|
||||
**/*.key
|
||||
**/*.log
|
||||
**/*.pid
|
||||
docs/voip.ms-api
|
||||
@@ -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.
|
||||
@@ -26,3 +27,6 @@ RTP_PORT_END=20000 # firewall and router. Two ports per call;
|
||||
#AI_TTS_URL=http://127.0.0.1:8000/v1
|
||||
#AI_TTS_MODEL=speaches-ai/Kokoro-82M-v1.0-ONNX
|
||||
#AI_TTS_VOICE=af_heart
|
||||
# A private CA your identity provider or directory uses (admin guide: Sign-in). Put the PEM file
|
||||
# in data/tls/; the api reads it at start, so restart the api after changing it.
|
||||
#NODE_EXTRA_CA_CERTS=/var/lib/selfphoneng/tls/idp-ca.pem
|
||||
@@ -6,3 +6,7 @@ data/
|
||||
coverage/
|
||||
.pnpm-store/
|
||||
*.pid
|
||||
# The mobile app is its own repo (13), checked out here beside the PBX.
|
||||
mobile/
|
||||
# voip.ms's API example bundle (06 §5.1): their copyright, kept locally for reference.
|
||||
docs/voip.ms-api/
|
||||
@@ -6,3 +6,4 @@ dist/
|
||||
AGENTS.md
|
||||
CLAUDE.md
|
||||
apps/api/src/db/migrations/
|
||||
mobile/
|
||||
@@ -28,6 +28,9 @@ write the software around it: database, config generator, API, web UI.
|
||||
| `docs/spec/08-security-ops.md` | Security, backups, monitoring, legal requirements | Before any release, and when touching auth or networking |
|
||||
| `docs/spec/09-roadmap.md` | Milestones, ordered tasks, acceptance criteria | Picking your next task |
|
||||
| `docs/spec/11-contacts.md` | Contacts: the builtin phonebook or Contrack, lookup, the Contrack timeline | Working on contacts or caller names |
|
||||
| `docs/spec/12-sso.md` | Single sign-on: OpenID Connect (Keycloak and others) and LDAP/LDAPS | Working on sign-in, users or roles |
|
||||
| `docs/spec/13-mobile.md` | The Android/iPhone app (`mobile` repo): pairing, device tokens, its tasks | Working on the mobile app or its API |
|
||||
| `docs/spec/14-spam.md` | Spam check: IPQualityScore and the FTC complaint list for unknown callers | Working on spam check or caller information |
|
||||
|
||||
## Hard rules
|
||||
|
||||
|
||||
+145
@@ -5,6 +5,151 @@ tag (08 §3.1): `selfphoneng-api`, `selfphoneng-asterisk` and `selfphoneng-probe
|
||||
the Gitea registry. From v0.0.3 the release also has the probe as downloads for machines without
|
||||
Docker.
|
||||
|
||||
## v0.0.13 — 2026-10-29
|
||||
|
||||
**Updating:** no database changes. Portainer: change both tags to `0.0.13` and redeploy. Compose:
|
||||
`sudo scripts/update.sh v0.0.13`.
|
||||
|
||||
Hardening after the attacks on FreePBX servers (spec 08 §8), where stolen trunk passwords were used
|
||||
for toll fraud:
|
||||
|
||||
- **Fraud alerts:** every admin gets an email, and the System page a warning, when the phone system
|
||||
calls an international or premium number (including Caribbean area codes, which are dialed like
|
||||
a US call but cost like abroad), when voip.ms bills calls on the trunk that this phone system
|
||||
didn't make, or when the voip.ms balance drops more than 10 in an hour. The last two need the
|
||||
voip.ms API login on the trunk. All three can be changed on Settings → Security → **Fraud
|
||||
alerts**.
|
||||
- **voip.ms IP restriction** (strongly recommended): the setup checklist on Trunks now says to
|
||||
turn it on for the sub-account, with only this server's public IP. A stolen trunk password is
|
||||
then useless anywhere else ([voip.ms](docs/admin-guide/voipms.md#1-in-the-voipms-portal)).
|
||||
- **Asterisk:** the api's manager login (AMI) can only do what SelfPhoneNG uses, and the image no
|
||||
longer has the modules that run commands on the server.
|
||||
|
||||
## v0.0.12 — 2026-10-28
|
||||
|
||||
**Before you update:** this version changes the database (spam reports); the api backs up first,
|
||||
by itself. Portainer: change both tags to `0.0.12` and redeploy. Compose:
|
||||
`sudo scripts/update.sh v0.0.12`. If you run the network test probe, update it too (see
|
||||
**Probe key** below).
|
||||
|
||||
- **My reports:** everyone with an extension gets a Reports tab: the calls that rang them, how
|
||||
many they answered and missed, talk time, calls and missed calls per day, and their busiest
|
||||
hours.
|
||||
- **Report spam:** on the Calls page, an incoming outside call has a **Report spam** button. When
|
||||
the spam check flagged the call, its finding is the reason; otherwise the person types one.
|
||||
Reports wait at the top of **Blocked numbers** for an admin to Block or Dismiss, and the
|
||||
Dashboard counts them.
|
||||
- **Second security review** (spec 08 §7), eight findings fixed, none critical:
|
||||
- Signing in by email (OpenID or directory) no longer matches admins or anyone who already has
|
||||
a connected sign-in, and connecting an OpenID account only finishes in the browser that
|
||||
started it.
|
||||
- **Recordings, transcripts and AI summaries** only go to whoever made or answered the call.
|
||||
Ring group and queue members whose phone only rang no longer get a coworker's recording, nor
|
||||
its summary in their call report email or on their Contrack timeline.
|
||||
- The `voicemail.new` webhook no longer carries the voicemail's transcript.
|
||||
- Changing the Contrack address asks for the company token again, and everyone reconnects
|
||||
their own Contrack token.
|
||||
- A directory account can only be swapped for another by an admin.
|
||||
- **Probe key** (strongly recommended): Settings → Network → **Generate key**, then set it as
|
||||
`PROBE_KEY` on the probe and restart it. The probe then only answers your server. Without a key
|
||||
it still works, and Diagnostics warns. Behind your own reverse proxy (`TRUST_PROXY=true`), the
|
||||
proxy must run on the probe's machine.
|
||||
- **Asterisk 24.0.0-rc3.** TLS trunks get a health check again, and Test checks them too (rc2
|
||||
couldn't check them over TLS). `pnpm test:asterisk-upgrade` now checks what SelfPhoneNG relies
|
||||
on Asterisk doing before any version change.
|
||||
- **Docs:** a CrowdSec rule for slow SIP scanners, and how to check that CrowdSec bans
|
||||
([network](docs/admin-guide/network.md#crowdsec)).
|
||||
|
||||
## v0.0.11 — 2026-10-28
|
||||
|
||||
**Updating:** no database changes. Portainer: change both tags to `0.0.11` and redeploy. Compose:
|
||||
`sudo scripts/update.sh v0.0.11`.
|
||||
|
||||
- **Phones:** on an iPhone a ringing call dimmed the screen but showed no Answer button (iOS
|
||||
zoomed into the small sign-in fields and stayed zoomed, so the call dialog was off screen).
|
||||
Fields are now 16px on touch screens and buttons at least 44px. A ringing call gets its own
|
||||
screen: full screen on a phone, with big round Decline and Answer buttons. My Phone's tabs fold
|
||||
into a menu button on narrow screens.
|
||||
- **New look:** colors from the logo (blue, and its navy for dark mode), cards on an off-white
|
||||
page, and text that is easy to read in both themes (all text at least WCAG AA). Icons
|
||||
throughout.
|
||||
- **Admin sidebar** grouped into Call routing, People, Features, History and System.
|
||||
- **My Phone Home** is a set of cards: My status, Missed calls (with call back) and New
|
||||
voicemails.
|
||||
- **Browser phone:** a status pill (Ready, Connecting…), phone-style call buttons with icons
|
||||
(Mute, Hold, Keypad, Transfer, Hang up) and a round keypad with the letters under the digits.
|
||||
|
||||
## v0.0.10 — 2026-10-27
|
||||
|
||||
**Before you update:** this version changes the database (spam check); the api backs up first,
|
||||
by itself. Portainer: change both tags to `0.0.10` and redeploy. Compose:
|
||||
`sudo scripts/update.sh v0.0.10`.
|
||||
|
||||
- **Spam check** (spec 14, off until you turn it on in Settings → Spam check): when an outside
|
||||
US caller isn't in the company's or your contacts, the number is looked up and what's known
|
||||
shows while it rings. The browser phone shows a **Likely spam** or **Possible spam** badge with
|
||||
a line such as "Fraud score 92 · spammer · VOIP"; desk phones show the caller's name as
|
||||
**SPAM LIKELY** or **Spam risk** (you can change these) when the answer is quick; call
|
||||
history keeps the badge. It's information only: calls are never blocked or delayed, and a
|
||||
failed lookup just shows nothing. Two sources, either or both:
|
||||
- **IPQualityScore** (1,000 lookups a month free): sends callers' numbers to IPQualityScore,
|
||||
so turning it on asks you to confirm. Answers are kept 14 days for flagged numbers and 3 for
|
||||
the rest. When the lookups run out the dashboard says so, checks fall back to the FTC list,
|
||||
and it carries on by itself when lookups are back.
|
||||
- **FTC complaint list** (free api.data.gov key): downloaded to the server, so nothing leaves
|
||||
it. The first download takes about 7 hours; then it fetches what's new every 24 hours.
|
||||
- See the [admin guide](docs/admin-guide/spam-check.md), including privacy and GDPR.
|
||||
- Database changes: `spam_lookups`, `ftc_complaints` (the api backs up first).
|
||||
|
||||
## v0.0.9 — 2026-10-27
|
||||
|
||||
**Before you update:** this version changes the database (paired phones); the api backs up first,
|
||||
by itself. Portainer: change both tags to `0.0.9` and redeploy. Compose:
|
||||
`sudo scripts/update.sh v0.0.9`.
|
||||
|
||||
- **Answer from the notification**: the softphone's incoming call notification has **Answer** and
|
||||
**Decline** buttons (Chrome and Edge; Firefox and Safari show it without buttons). It now shows
|
||||
whenever the softphone tab isn't in front, not only when it's hidden, stays up while the call
|
||||
rings and goes away once it stops. If notifications are blocked, the softphone panel says how
|
||||
to allow them.
|
||||
- **Pair the mobile app** (spec 13): My settings → Mobile app shows a one-time code (QR, or
|
||||
server + code, good for 10 minutes) that our own Android/iPhone app trades for a device token.
|
||||
A paired phone acts as its user, never as an admin, and is listed there to remove. Changing or
|
||||
resetting a password, or disabling a user, removes their paired phones. The app itself is
|
||||
still in testing and not in the app stores.
|
||||
- Database changes: `mobile_devices` (the api backs up first).
|
||||
|
||||
## v0.0.8 — 2026-10-26
|
||||
|
||||
**Before you update:** this version changes the database (sign-in); the api backs up first, by
|
||||
itself. Portainer: change both tags to `0.0.8` and redeploy. Compose:
|
||||
`sudo scripts/update.sh v0.0.8`. Sign-in works as before until you set up single sign-on in
|
||||
Settings → Sign-in (admin guide: [Sign-in](docs/admin-guide/sign-in.md)). Every route is now rate
|
||||
limited, 600 requests a minute per client IP by default (`RATE_LIMIT_PER_MINUTE`, 0 turns it off).
|
||||
|
||||
- **Sign in with OpenID Connect** (spec 12, admin guide: [Sign-in](docs/admin-guide/sign-in.md)):
|
||||
Keycloak (tested), and any provider that follows the standard: Authentik, Authelia, Zitadel,
|
||||
Entra ID and others are expected to work. People connect their
|
||||
account on My settings → Sign-in and then sign in with either it or their password. The admin
|
||||
picks passwords only, OpenID only, or both. Provider groups can decide who may sign in and who
|
||||
is an admin. Signing out also signs out at the provider, and someone disabled there loses their
|
||||
session here within 20 minutes. No accounts are made automatically: an admin still creates each
|
||||
user. Admins keep their password, so the phone system stays reachable if the provider is down.
|
||||
- **Sign in with the company directory** (LDAP / LDAPS): presets for Active Directory and Samba
|
||||
AD, OpenLDAP, FreeIPA and lldap (AD and lldap tested), and a Test button. People use their directory username or email
|
||||
and password. New users are matched by email; anyone who chose their own password, and every
|
||||
admin, connects once on My settings → Sign-in. An admin filter can make people admins (nested AD
|
||||
groups count).
|
||||
- **Leavers**: every hour, someone removed from the directory (or disabled there) is disabled
|
||||
here, password included. A dashboard warning shows when the directory can't be reached, or when
|
||||
a check would disable most people (it then disables nobody).
|
||||
- **Security** (a two-reviewer audit of the new sign-in and all 207 API routes): rate limits on
|
||||
every route, stricter on Test buttons; a password asked again (change password, two-step on and
|
||||
off) counts like a sign-in; a saved directory or provider password is never sent to a new
|
||||
address; and a role set by the provider is read-only on Users.
|
||||
- Database changes: `user_identities`; `sessions` records how each session was made; a user may
|
||||
have no password (the api backs up first).
|
||||
|
||||
## v0.0.7 — 2026-10-23
|
||||
|
||||
**Before you update:** this version changes the database (call reports, contacts); the api backs
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
A fully open source, self-hosted business phone system. Bring your own SIP trunk and numbers
|
||||
(reference provider: voip.ms). Built on Asterisk 24 + TypeScript + PostgreSQL 18, runs in Docker.
|
||||
|
||||
**Status:** pre-1.0 (v0.0.7), for testing. See [what's left for v1](docs/v1-checklist.md).
|
||||
**Status:** pre-1.0 (v0.0.13), for testing. See [what's left for v1](docs/v1-checklist.md).
|
||||
|
||||
## What it does today
|
||||
|
||||
@@ -19,6 +19,9 @@ A fully open source, self-hosted business phone system. Bring your own SIP trunk
|
||||
pickup, conference rooms, paging and intercom, music on hold.
|
||||
- **Voicemail** with email (and transcripts by local AI), **call history**, **recordings**, a **live dashboard** (listen,
|
||||
whisper, barge) and reports.
|
||||
- **Spam check** (off until you turn it on): outside callers who aren't contacts are looked up
|
||||
with IPQualityScore or the FTC complaint list, and flagged in the browser phone, on desk phones
|
||||
and in call history. Calls are never blocked.
|
||||
- **Running it:** updates (`scripts/update.sh` for compose installs; a backup before every
|
||||
database change), backups and restore, background health checks, webhooks and API tokens,
|
||||
two-step sign-in for admins, limits on outside calls at once.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@selfphoneng/api",
|
||||
"version": "0.0.7",
|
||||
"version": "0.0.13",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
@@ -12,13 +12,16 @@
|
||||
},
|
||||
"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",
|
||||
"@selfphoneng/shared": "workspace:*",
|
||||
"drizzle-orm": "^0.45.4",
|
||||
"fastify": "^5.12.7",
|
||||
"ldapts": "^9.2.0",
|
||||
"nodemailer": "10.0.11",
|
||||
"openid-client": "^6.8.8",
|
||||
"pg": "^8.23.1",
|
||||
"qrcode": "1.5.4",
|
||||
"zod": "^4.6.7"
|
||||
|
||||
+15
-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,9 +12,10 @@ 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';
|
||||
import { systemRoutes } from './routes/system.ts';
|
||||
import { extensionRoutes } from './routes/extensions.ts';
|
||||
import { trunkRoutes } from './routes/trunks.ts';
|
||||
@@ -21,6 +23,7 @@ import { didRoutes } from './routes/dids.ts';
|
||||
import { outboundRouteRoutes } from './routes/outbound.ts';
|
||||
import { liveRoutes } from './live/hub.ts';
|
||||
import { meRoutes } from './routes/me.ts';
|
||||
import { mobileRoutes } from './routes/mobile.ts';
|
||||
import { soundRoutes } from './routes/sounds.ts';
|
||||
import { ringGroupRoutes } from './routes/ringgroups.ts';
|
||||
import { queueRoutes } from './routes/queues.ts';
|
||||
@@ -91,6 +94,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.
|
||||
@@ -139,6 +151,7 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public'))
|
||||
|
||||
app.register(authRoutes);
|
||||
app.register(userRoutes);
|
||||
app.register(oidcRoutes);
|
||||
app.register(systemRoutes);
|
||||
app.register(extensionRoutes);
|
||||
app.register(trunkRoutes);
|
||||
@@ -147,6 +160,7 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public'))
|
||||
app.register(settingsRoutes);
|
||||
app.register(liveRoutes);
|
||||
app.register(meRoutes);
|
||||
app.register(mobileRoutes);
|
||||
app.register(contactRoutes);
|
||||
app.register(soundRoutes);
|
||||
app.register(ringGroupRoutes);
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Paired phones (13 §2): a one-time pairing code from the web, traded by the app for a device token
|
||||
// `Authorization: Bearer spm_<40 base62>`, stored as sha256, acting as the phone's user.
|
||||
import { randomInt } from 'node:crypto';
|
||||
import { and, eq, gt, isNull } from 'drizzle-orm';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { mobileDevices, users } from '../db/schema.ts';
|
||||
import { generateToken, hashToken } from './tokens.ts';
|
||||
|
||||
export const DEVICE_TOKEN_RE = /^spm_[A-Za-z0-9]{40}$/;
|
||||
/** No 0/O/1/I: people type it from a screen. */
|
||||
export const PAIR_CODE_CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
export const PAIR_CODE_TTL_MS = 10 * 60_000;
|
||||
|
||||
export const generateDeviceToken = () => `spm_${generateToken().slice(3)}`;
|
||||
|
||||
export function generatePairCode(): string {
|
||||
let s = '';
|
||||
for (let i = 0; i < 12; i++) s += PAIR_CODE_CHARS[randomInt(PAIR_CODE_CHARS.length)];
|
||||
return s;
|
||||
}
|
||||
|
||||
/** The device's (active) user, or null for an unknown, removed or malformed token. */
|
||||
export async function getDeviceUser(db: Db, token: string) {
|
||||
if (!DEVICE_TOKEN_RE.test(token)) return null;
|
||||
const [row] = await db
|
||||
.select({ id: mobileDevices.id, user: users })
|
||||
.from(mobileDevices)
|
||||
.innerJoin(users, eq(users.id, mobileDevices.userId))
|
||||
.where(and(eq(mobileDevices.tokenHash, hashToken(token)), eq(users.isActive, true)));
|
||||
if (!row) return null;
|
||||
await db
|
||||
.update(mobileDevices)
|
||||
.set({ lastUsedAt: new Date() })
|
||||
.where(eq(mobileDevices.id, row.id));
|
||||
return { user: row.user, deviceId: row.id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Trades a pairing code for a device token, once. Null for a code that's unknown, used, expired,
|
||||
* or whose user was disabled since.
|
||||
*/
|
||||
export async function pairDevice(
|
||||
db: Db,
|
||||
code: string,
|
||||
device: { name: string; platform: 'android' | 'ios' },
|
||||
) {
|
||||
const [row] = await db
|
||||
.select({ id: mobileDevices.id, user: users })
|
||||
.from(mobileDevices)
|
||||
.innerJoin(users, eq(users.id, mobileDevices.userId))
|
||||
.where(
|
||||
and(
|
||||
eq(mobileDevices.pairCodeHash, hashToken(code)),
|
||||
gt(mobileDevices.pairExpiresAt, new Date()),
|
||||
eq(users.isActive, true),
|
||||
),
|
||||
);
|
||||
if (!row) return null;
|
||||
const token = generateDeviceToken();
|
||||
// The code is cleared in the same statement: two apps racing with one code can't both win.
|
||||
const [won] = await db
|
||||
.update(mobileDevices)
|
||||
.set({
|
||||
...device,
|
||||
tokenPrefix: token.slice(0, 8),
|
||||
tokenHash: hashToken(token),
|
||||
pairCodeHash: null,
|
||||
pairExpiresAt: null,
|
||||
})
|
||||
.where(and(eq(mobileDevices.id, row.id), isNull(mobileDevices.tokenHash)))
|
||||
.returning({ id: mobileDevices.id });
|
||||
return won ? { token, deviceId: won.id, user: row.user } : null;
|
||||
}
|
||||
+23
-10
@@ -1,9 +1,11 @@
|
||||
// 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';
|
||||
import { COOKIE, getSessionUser, setSessionCookie } from './sessions.ts';
|
||||
import { COOKIE, getSessionUser, mustChange, setSessionCookie } from './sessions.ts';
|
||||
import { getDeviceUser } from './devices.ts';
|
||||
import { getTokenUser } from './tokens.ts';
|
||||
|
||||
export type CurrentUser = typeof users.$inferSelect;
|
||||
@@ -14,22 +16,28 @@ declare module 'fastify' {
|
||||
sessionId: string | null;
|
||||
/** Set when the request came with an API token instead of a session. */
|
||||
apiTokenId?: string;
|
||||
/** Set when the request came from a paired phone (13 §2). */
|
||||
mobileDeviceId?: string;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets req.user from an API token (Authorization: Bearer, 04 §2.3) or else the session cookie.
|
||||
* Enforces must_change_password outside /api/auth/*.
|
||||
* Sets req.user from a device token (`spm_`, 13 §2) or API token (Authorization: Bearer, 04 §2.3),
|
||||
* or else the session cookie. Enforces must_change_password outside /api/auth/*.
|
||||
*/
|
||||
export async function requireAuth(req: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
const auth = req.headers.authorization;
|
||||
if (auth?.startsWith('Bearer ')) {
|
||||
const t = await getTokenUser(req.server.db, auth.slice(7).trim());
|
||||
if (!t) throw unauthorized();
|
||||
req.user = t.user;
|
||||
const raw = auth.slice(7).trim();
|
||||
const d = raw.startsWith('spm_') ? await getDeviceUser(req.server.db, raw) : null;
|
||||
const t = d ? null : await getTokenUser(req.server.db, raw);
|
||||
if (!d && !t) throw unauthorized();
|
||||
// A phone is never an admin (13 §2): a lost one mustn't open the admin panel or recordings.
|
||||
req.user = d ? { ...d.user, role: 'user' } : t!.user;
|
||||
req.sessionId = null;
|
||||
req.apiTokenId = t.tokenId;
|
||||
if (t.user.mustChangePassword)
|
||||
if (d) req.mobileDeviceId = d.deviceId;
|
||||
else req.apiTokenId = t!.tokenId;
|
||||
if (req.user.mustChangePassword)
|
||||
throw forbidden('Change your password first', 'password_change_required');
|
||||
return;
|
||||
}
|
||||
@@ -39,7 +47,7 @@ export async function requireAuth(req: FastifyRequest, reply: FastifyReply): Pro
|
||||
if (found.extended) setSessionCookie(reply, token!);
|
||||
req.user = found.user;
|
||||
req.sessionId = found.sessionId;
|
||||
if (found.user.mustChangePassword && !req.url.startsWith('/api/auth/'))
|
||||
if (mustChange(found) && !req.url.startsWith('/api/auth/'))
|
||||
throw forbidden('Change your password first', 'password_change_required');
|
||||
}
|
||||
|
||||
@@ -47,3 +55,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 };
|
||||
@@ -15,21 +15,23 @@ function recent(key: string, windowMs: number, now: number): number[] {
|
||||
return list;
|
||||
}
|
||||
|
||||
export function isLoginBlocked(ip: string, email: string, now = Date.now()): boolean {
|
||||
/** `email` null: by IP only (an OpenID callback names no account, 12 §3.2). */
|
||||
export function isLoginBlocked(ip: string, email: string | null, now = Date.now()): boolean {
|
||||
return (
|
||||
recent(`ip:${ip}`, RULES.ip.windowMs, now).length >= RULES.ip.max ||
|
||||
recent(`email:${email.toLowerCase()}`, RULES.email.windowMs, now).length >= RULES.email.max
|
||||
(email !== null &&
|
||||
recent(`email:${email.toLowerCase()}`, RULES.email.windowMs, now).length >= RULES.email.max)
|
||||
);
|
||||
}
|
||||
|
||||
export function recordLoginFailure(ip: string, email: string, now = Date.now()): void {
|
||||
export function recordLoginFailure(ip: string, email: string | null, now = Date.now()): void {
|
||||
// Forget keys past every window, at most once a minute: emails tried once and never again
|
||||
// would otherwise stay until restart (08 §6).
|
||||
if (now - lastSweep > MIN) {
|
||||
lastSweep = now;
|
||||
for (const [key, list] of failures) if (now - list.at(-1)! >= LONGEST) failures.delete(key);
|
||||
}
|
||||
for (const key of [`ip:${ip}`, `email:${email.toLowerCase()}`]) {
|
||||
for (const key of [`ip:${ip}`, ...(email === null ? [] : [`email:${email.toLowerCase()}`])]) {
|
||||
failures.set(key, [...(failures.get(key) ?? []), now]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ import { createHash, randomBytes } from 'node:crypto';
|
||||
import { and, eq, gt, ne } from 'drizzle-orm';
|
||||
import type { FastifyReply } from 'fastify';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { sessions, users } from '../db/schema.ts';
|
||||
import { encrypt } from '../crypto/secrets.ts';
|
||||
import { mobileDevices, sessions, users } from '../db/schema.ts';
|
||||
|
||||
export const COOKIE = 'sp_session';
|
||||
const DAY = 86_400_000;
|
||||
@@ -32,6 +33,9 @@ export async function createSession(
|
||||
userId: string,
|
||||
ip: string | null,
|
||||
userAgent: string | null,
|
||||
method: 'password' | 'ldap' | 'oidc' = 'password',
|
||||
/** OpenID sessions (12 §3.5): kept encrypted for the leaver check and signing out there. */
|
||||
idp?: { refreshToken: string | null; idToken: string },
|
||||
): Promise<string> {
|
||||
const token = randomBytes(32).toString('base64url');
|
||||
await db.insert(sessions).values({
|
||||
@@ -40,6 +44,10 @@ export async function createSession(
|
||||
expiresAt: new Date(Date.now() + LIFETIME),
|
||||
ip,
|
||||
userAgent: userAgent?.slice(0, 500) ?? null,
|
||||
method,
|
||||
idpRefreshEnc: idp?.refreshToken ? encrypt(idp.refreshToken) : null,
|
||||
idpIdTokenEnc: idp ? encrypt(idp.idToken) : null,
|
||||
idpCheckedAt: idp ? new Date() : null,
|
||||
});
|
||||
return token;
|
||||
}
|
||||
@@ -51,7 +59,7 @@ export async function createSession(
|
||||
export async function getSessionUser(db: Db, token: string, slide = true) {
|
||||
const id = sha256(token);
|
||||
const [row] = await db
|
||||
.select({ expiresAt: sessions.expiresAt, user: users })
|
||||
.select({ expiresAt: sessions.expiresAt, method: sessions.method, user: users })
|
||||
.from(sessions)
|
||||
.innerJoin(users, eq(users.id, sessions.userId))
|
||||
.where(and(eq(sessions.id, id), gt(sessions.expiresAt, new Date()), eq(users.isActive, true)));
|
||||
@@ -64,15 +72,23 @@ export async function getSessionUser(db: Db, token: string, slide = true) {
|
||||
.where(eq(sessions.id, id));
|
||||
extended = true;
|
||||
}
|
||||
return { user: row.user, sessionId: id, extended };
|
||||
return { user: row.user, sessionId: id, method: row.method, extended };
|
||||
}
|
||||
|
||||
/** A password to change only blocks sessions made with it (OpenID sessions skip it, 12). */
|
||||
export const mustChange = (f: { user: { mustChangePassword: boolean }; method: string }) =>
|
||||
f.user.mustChangePassword && f.method === 'password';
|
||||
|
||||
export async function deleteSession(db: Db, token: string) {
|
||||
await db.delete(sessions).where(eq(sessions.id, sha256(token)));
|
||||
}
|
||||
|
||||
/** Logs a user out everywhere except (optionally) the current session. */
|
||||
/**
|
||||
* Logs a user out everywhere except (optionally) the current session, paired phones included (13 §2:
|
||||
* a password change, a reset, or disabling them).
|
||||
*/
|
||||
export async function deleteUserSessions(db: Db, userId: string, keepSessionId?: string) {
|
||||
await db.delete(mobileDevices).where(eq(mobileDevices.userId, userId));
|
||||
await db
|
||||
.delete(sessions)
|
||||
.where(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { todayIn } from '../generator/load.ts';
|
||||
import { getSetting } from '../services/settings.ts';
|
||||
import { ContrackError, contrackBook, contrackInteraction, contrackSettings } from './contrack.ts';
|
||||
import { ON_THE_CALL } from '../services/cdr.ts';
|
||||
|
||||
/** A recorded call waits this long for its summary before it's logged without one. */
|
||||
const SUMMARY_WAIT_MS = 30 * 60_000;
|
||||
@@ -141,11 +142,13 @@ export async function logCallsToContrack(d: {
|
||||
.limit(500);
|
||||
|
||||
// One entry per call and person (a person can be in a call as caller, rang and answered).
|
||||
const todo = new Map<string, (typeof rows)[number] & { out: boolean }>();
|
||||
// `heard`: made or answered it; a phone that only rang gets no recording or summary (08 §7 #3).
|
||||
const todo = new Map<string, (typeof rows)[number] & { out: boolean; heard: boolean }>();
|
||||
for (const r of rows) {
|
||||
const k = `${r.call.id}|${r.userId}`;
|
||||
const out = r.role === 'caller' || todo.get(k)?.out === true;
|
||||
todo.set(k, { ...r, out });
|
||||
const heard = ON_THE_CALL.includes(r.role) || todo.get(k)?.heard === true;
|
||||
todo.set(k, { ...r, out, heard });
|
||||
}
|
||||
const skipUser = new Set<string>(); // Contrack down or token refused: try again next run
|
||||
let logged = 0;
|
||||
@@ -169,7 +172,8 @@ export async function logCallsToContrack(d: {
|
||||
await record(null);
|
||||
continue;
|
||||
}
|
||||
const wantSummary = r.withSummary && summariesOn && r.call.recordingId !== null;
|
||||
const recorded = r.heard && r.call.recordingId !== null;
|
||||
const wantSummary = r.withSummary && summariesOn && recorded;
|
||||
if (wantSummary && !r.aiDoneAt && now.getTime() - r.call.endedAt.getTime() < SUMMARY_WAIT_MS)
|
||||
continue; // the transcript job hasn't summarized it yet
|
||||
const e = timelineEntry(
|
||||
@@ -180,7 +184,7 @@ export async function logCallsToContrack(d: {
|
||||
toNumber: r.call.toNumber,
|
||||
talkSeconds: r.call.talkSeconds,
|
||||
answeredBy: r.answeredById && r.answeredById !== r.extId ? r.answeredBy : null,
|
||||
recorded: r.call.recordingId !== null,
|
||||
recorded,
|
||||
},
|
||||
wantSummary ? r.summary : null,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
CREATE TYPE "public"."identity_kind" AS ENUM('oidc', 'ldap');--> statement-breakpoint
|
||||
CREATE TABLE "user_identities" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"kind" "identity_kind" NOT NULL,
|
||||
"subject" text NOT NULL,
|
||||
"email" text,
|
||||
"groups" text[] DEFAULT '{}' NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"last_used_at" timestamp with time zone,
|
||||
CONSTRAINT "user_identities_kind_subject_unique" UNIQUE("kind","subject"),
|
||||
CONSTRAINT "user_identities_userId_kind_unique" UNIQUE("user_id","kind")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "users" ALTER COLUMN "password_hash" DROP NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "sessions" ADD COLUMN "method" text DEFAULT 'password' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "sessions" ADD COLUMN "idp_refresh_enc" text;--> statement-breakpoint
|
||||
ALTER TABLE "sessions" ADD COLUMN "idp_id_token_enc" text;--> statement-breakpoint
|
||||
ALTER TABLE "sessions" ADD COLUMN "idp_checked_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "user_identities" ADD CONSTRAINT "user_identities_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_method_check" CHECK ("sessions"."method" in ('password', 'ldap', 'oidc'));
|
||||
@@ -0,0 +1,19 @@
|
||||
CREATE TABLE "mobile_devices" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"name" text,
|
||||
"platform" text,
|
||||
"token_prefix" text,
|
||||
"token_hash" text,
|
||||
"pair_code_hash" text,
|
||||
"pair_expires_at" timestamp with time zone,
|
||||
"last_used_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "mobile_devices_tokenHash_unique" UNIQUE("token_hash"),
|
||||
CONSTRAINT "mobile_devices_pairCodeHash_unique" UNIQUE("pair_code_hash"),
|
||||
CONSTRAINT "mobile_devices_platform_check" CHECK ("mobile_devices"."platform" in ('android', 'ios'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "mobile_devices" ADD CONSTRAINT "mobile_devices_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "mobile_devices_user_id_index" ON "mobile_devices" USING btree ("user_id");
|
||||
@@ -0,0 +1,25 @@
|
||||
CREATE TABLE "ftc_complaints" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"number" text NOT NULL,
|
||||
"reported_at" timestamp with time zone NOT NULL,
|
||||
"subject" text,
|
||||
"robocall" boolean,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "spam_lookups" (
|
||||
"number" text PRIMARY KEY NOT NULL,
|
||||
"provider" text NOT NULL,
|
||||
"risk" text NOT NULL,
|
||||
"info" jsonb NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "spam_lookups_risk_check" CHECK ("spam_lookups"."risk" in ('low', 'medium', 'high'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "calls" ADD COLUMN "spam_risk" text;--> statement-breakpoint
|
||||
ALTER TABLE "calls" ADD COLUMN "spam_info" jsonb;--> statement-breakpoint
|
||||
CREATE INDEX "ftc_complaints_number_index" ON "ftc_complaints" USING btree ("number");--> statement-breakpoint
|
||||
CREATE INDEX "ftc_complaints_reported_at_index" ON "ftc_complaints" USING btree ("reported_at");--> statement-breakpoint
|
||||
CREATE INDEX "spam_lookups_expires_at_index" ON "spam_lookups" USING btree ("expires_at");
|
||||
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE "spam_reports" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"number" text NOT NULL,
|
||||
"reason" text NOT NULL,
|
||||
"reported_by" uuid,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "spam_reports_number_reportedBy_unique" UNIQUE("number","reported_by")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "spam_reports" ADD CONSTRAINT "spam_reports_reported_by_users_id_fk" FOREIGN KEY ("reported_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -85,6 +85,34 @@
|
||||
"when": 1792788393381,
|
||||
"tag": "0011_contrack_call_log",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "7",
|
||||
"when": 1792951905226,
|
||||
"tag": "0012_sso",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "7",
|
||||
"when": 1793052019332,
|
||||
"tag": "0013_mobile_devices",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "7",
|
||||
"when": 1793127210386,
|
||||
"tag": "0014_spam_check",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "7",
|
||||
"when": 1793212840276,
|
||||
"tag": "0015_spam_reports",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+106
-2
@@ -19,6 +19,7 @@ import {
|
||||
smallint,
|
||||
text,
|
||||
timestamp,
|
||||
unique,
|
||||
uuid,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
|
||||
@@ -53,7 +54,8 @@ export const users = pgTable('users', {
|
||||
id: id(),
|
||||
email: citext().notNull().unique(),
|
||||
displayName: text().notNull(),
|
||||
passwordHash: text().notNull(),
|
||||
/** argon2id; null: no password, signs in with OpenID only (12 §3.3). */
|
||||
passwordHash: text(),
|
||||
role: userRole().notNull().default('user'),
|
||||
isActive: boolean().notNull().default(true),
|
||||
totpSecretEnc: text(),
|
||||
@@ -83,9 +85,40 @@ export const sessions = pgTable(
|
||||
expiresAt: tstz().notNull(),
|
||||
ip: inet(),
|
||||
userAgent: text(),
|
||||
/** How it was made (12 §2): password, ldap or oidc. */
|
||||
method: text().notNull().default('password'),
|
||||
/** OpenID sessions (12 §3.5): refresh token for the leaver check, ID token for signing out. */
|
||||
idpRefreshEnc: text(),
|
||||
idpIdTokenEnc: text(),
|
||||
idpCheckedAt: tstz(),
|
||||
...timestamps,
|
||||
},
|
||||
(t) => [index().on(t.userId)],
|
||||
(t) => [
|
||||
index().on(t.userId),
|
||||
check('sessions_method_check', sql`${t.method} in ('password', 'ldap', 'oidc')`),
|
||||
],
|
||||
);
|
||||
|
||||
// One more way to sign in to a user (12 §2): a connected OpenID account or directory entry.
|
||||
export const identityKind = pgEnum('identity_kind', ['oidc', 'ldap']);
|
||||
export const userIdentities = pgTable(
|
||||
'user_identities',
|
||||
{
|
||||
id: id(),
|
||||
userId: uuid()
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
kind: identityKind().notNull(),
|
||||
/** oidc: "<iss> <sub>"; ldap: the entry's UUID attribute. */
|
||||
subject: text().notNull(),
|
||||
/** As the provider last said. */
|
||||
email: text(),
|
||||
/** oidc: groupsClaim values at the last sign-in (12 §3.4). */
|
||||
groups: emptyTextArray(),
|
||||
createdAt: createdAt(),
|
||||
lastUsedAt: tstz(),
|
||||
},
|
||||
(t) => [unique().on(t.kind, t.subject), unique().on(t.userId, t.kind)],
|
||||
);
|
||||
|
||||
export const apiTokens = pgTable('api_tokens', {
|
||||
@@ -101,6 +134,29 @@ export const apiTokens = pgTable('api_tokens', {
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
/** Paired phones (13 §3): a row waits with a pairing code, then holds the device token. */
|
||||
export const mobileDevices = pgTable(
|
||||
'mobile_devices',
|
||||
{
|
||||
id: id(),
|
||||
userId: uuid()
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
name: text(),
|
||||
platform: text(),
|
||||
tokenPrefix: text(),
|
||||
tokenHash: text().unique(),
|
||||
pairCodeHash: text().unique(),
|
||||
pairExpiresAt: tstz(),
|
||||
lastUsedAt: tstz(),
|
||||
...timestamps,
|
||||
},
|
||||
(t) => [
|
||||
index().on(t.userId),
|
||||
check('mobile_devices_platform_check', sql`${t.platform} in ('android', 'ios')`),
|
||||
],
|
||||
);
|
||||
|
||||
// ───────────── extensions ─────────────
|
||||
export const outboundClass = pgEnum('outbound_class', [
|
||||
'internal',
|
||||
@@ -574,11 +630,59 @@ export const calls = pgTable(
|
||||
answeredByExtensionId: uuid().references(() => extensions.id, { onDelete: 'set null' }),
|
||||
talkSeconds: integer().notNull().default(0),
|
||||
recordingId: uuid().references(() => recordings.id, { onDelete: 'set null' }),
|
||||
/** The spam check of an incoming call's number (14 §6); null = not checked. */
|
||||
spamRisk: text(),
|
||||
spamInfo: jsonb(),
|
||||
...timestamps,
|
||||
},
|
||||
(t) => [index().on(t.startedAt.desc())],
|
||||
);
|
||||
|
||||
// ───────────── spam check (14 §7) ─────────────
|
||||
/** IPQualityScore answers, so a caller who rings again costs no credit (14 §4). */
|
||||
export const spamLookups = pgTable(
|
||||
'spam_lookups',
|
||||
{
|
||||
number: text().primaryKey(),
|
||||
provider: text().notNull(),
|
||||
risk: text().notNull(),
|
||||
info: jsonb().notNull(),
|
||||
expiresAt: tstz().notNull(),
|
||||
...timestamps,
|
||||
},
|
||||
(t) => [
|
||||
index().on(t.expiresAt),
|
||||
check('spam_lookups_risk_check', sql`${t.risk} in ('low', 'medium', 'high')`),
|
||||
],
|
||||
);
|
||||
|
||||
/** The local copy of the FTC's Do Not Call complaints (14 §2.2). */
|
||||
export const ftcComplaints = pgTable(
|
||||
'ftc_complaints',
|
||||
{
|
||||
id: text().primaryKey(),
|
||||
number: text().notNull(),
|
||||
reportedAt: tstz().notNull(),
|
||||
subject: text(),
|
||||
robocall: boolean(),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [index().on(t.number), index().on(t.reportedAt)],
|
||||
);
|
||||
|
||||
/** "Report spam" from a user's call history, waiting for an admin (05 §2 Blocked numbers). */
|
||||
export const spamReports = pgTable(
|
||||
'spam_reports',
|
||||
{
|
||||
id: id(),
|
||||
number: text().notNull(),
|
||||
reason: text().notNull(),
|
||||
reportedBy: uuid().references(() => users.id, { onDelete: 'set null' }),
|
||||
...timestamps,
|
||||
},
|
||||
(t) => [unique().on(t.number, t.reportedBy)],
|
||||
);
|
||||
|
||||
export const callExtensions = pgTable(
|
||||
'call_extensions',
|
||||
{
|
||||
|
||||
@@ -140,7 +140,7 @@ endpoint = trunk-a1b2c3d4-0000-4000-8000-000000000004
|
||||
[trunk-a1b2c3d4-0000-4000-8000-000000000004]
|
||||
type = aor
|
||||
contact = sip:chicago1.voip.ms:5061;transport=tls
|
||||
qualify_frequency = 0
|
||||
qualify_frequency = 60
|
||||
|
||||
[trunk-a1b2c3d4-0000-4000-8000-000000000004]
|
||||
type = endpoint
|
||||
|
||||
@@ -232,11 +232,9 @@ function trunkSections(t: SnapTrunk, snap: Snapshot): string {
|
||||
section(`trunk-${id}`, [
|
||||
'type = aor',
|
||||
`contact = sip:${target}`,
|
||||
// ponytail: Asterisk 24 checks a qualify's TLS certificate against the IP, not the name, so
|
||||
// OPTIONS to voip.ms fail verify_server and the contact goes Unavailable (calls and
|
||||
// REGISTER verify fine). No qualify over TLS: registration shows the trunk's health.
|
||||
// Turn it back on when a qualify to chicago1.voip.ms:5061 passes.
|
||||
`qualify_frequency = ${t.transport === 'tls' ? 0 : 60}`,
|
||||
// Over TLS too since 24.0.0-rc3, which checks a qualify's certificate against the host name
|
||||
// (rc2 used the IP and failed verify_server; upgrade-test.ts tlsQualifyByHostname).
|
||||
'qualify_frequency = 60',
|
||||
]),
|
||||
section(`trunk-${id}`, [
|
||||
'type = endpoint',
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
// voip.ms REST/JSON API (06 §5.1): GET rest.php?api_username&api_password&method&…, JSON back with
|
||||
// `status` = "success" or an error code. The password is in the query string, so the URL is never
|
||||
// logged or put in an error.
|
||||
// TODO(spec): response shapes and error codes are from the 06 spec, not a real account yet
|
||||
// (voip.ms's API docs need a login); check them with the owner's account and fix here.
|
||||
// TODO(spec): most response shapes and error codes are from the 06 spec, not a real account yet;
|
||||
// voip.ms's example bundle (docs/voip.ms-api) only shows getCDR's. Check with the owner's account.
|
||||
import { normalizeNumber } from '@selfphoneng/shared';
|
||||
|
||||
export type VoipmsCreds = { username: string; password: string };
|
||||
@@ -106,6 +106,59 @@ export async function getDids(baseUrl: string, creds: VoipmsCreds): Promise<Voip
|
||||
});
|
||||
}
|
||||
|
||||
export type VoipmsCall = {
|
||||
at: Date;
|
||||
destination: string;
|
||||
account: string;
|
||||
disposition: string;
|
||||
seconds: number;
|
||||
total: string;
|
||||
uniqueid: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* One sub-account's call records between two UTC dates (getCDR, 06 §5.1). `timezone=0` makes
|
||||
* `date` UTC. The request, `no_cdr` and most fields are in voip.ms's examples; `account`,
|
||||
* `disposition` and `uniqueid` aren't (verify), and come back '' if missing.
|
||||
*/
|
||||
export async function getCdr(
|
||||
baseUrl: string,
|
||||
creds: VoipmsCreds,
|
||||
q: { from: string; to: string; account: string },
|
||||
): Promise<VoipmsCall[]> {
|
||||
let r: Record<string, unknown>;
|
||||
try {
|
||||
r = await voipmsApi(baseUrl, creds, 'getCDR', {
|
||||
date_from: q.from,
|
||||
date_to: q.to,
|
||||
answered: '1',
|
||||
noanswer: '1',
|
||||
busy: '1',
|
||||
failed: '1',
|
||||
timezone: '0',
|
||||
account: q.account,
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof VoipmsError && e.status === 'no_cdr') return [];
|
||||
throw e;
|
||||
}
|
||||
return list(r.cdr).flatMap((c) => {
|
||||
const at = new Date(`${str(c.date).replace(' ', 'T')}Z`);
|
||||
if (Number.isNaN(at.getTime())) return [];
|
||||
return [
|
||||
{
|
||||
at,
|
||||
destination: str(c.destination),
|
||||
account: str(c.account),
|
||||
disposition: str(c.disposition),
|
||||
seconds: Number(str(c.seconds)) || 0,
|
||||
total: str(c.total),
|
||||
uniqueid: str(c.uniqueid),
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The account's balance (getBalance → balance.current_balance, in the account's currency).
|
||||
* (verify) against a real account: the shape is from voip.ms's API docs (06 §5.1).
|
||||
|
||||
+49
-3
@@ -10,6 +10,7 @@ import type { Db } from './db/client.ts';
|
||||
import {
|
||||
extensions,
|
||||
sessions,
|
||||
spamLookups,
|
||||
systemEvents,
|
||||
trunks,
|
||||
users,
|
||||
@@ -27,7 +28,12 @@ import { backupDue, runBackup } from './services/backup.ts';
|
||||
import { sendCallReports } from './services/callreport.ts';
|
||||
import { contrackBook, contrackSettings } from './contacts/contrack.ts';
|
||||
import { logCallsToContrack } from './contacts/timeline.ts';
|
||||
import { checkDirectory } from './sso/directory.ts';
|
||||
import { checkOidcSessions } from './sso/oidcCheck.ts';
|
||||
import { logEvent } from './services/events.ts';
|
||||
import { createFraudWatch } from './services/fraud.ts';
|
||||
import { spamQuotaCheck } from './spam/check.ts';
|
||||
import { ftcSync } from './spam/ftc.ts';
|
||||
import { deliverDue } from './services/webhooks.ts';
|
||||
import type { SendMail } from './services/mail.ts';
|
||||
import { getSetting, setSetting } from './services/settings.ts';
|
||||
@@ -40,7 +46,11 @@ export type JobDeps = {
|
||||
generator: Pick<Generator, 'apply'>;
|
||||
voicemail: { scan(ext: string): Promise<void> };
|
||||
send: SendMail;
|
||||
log: { info(o: object, m: string): void; error(o: object, m: string): void };
|
||||
log: {
|
||||
info(o: object, m: string): void;
|
||||
warn(o: object, m: string): void;
|
||||
error(o: object, m: string): void;
|
||||
};
|
||||
now?: () => Date;
|
||||
statfs?: typeof statfs;
|
||||
};
|
||||
@@ -68,7 +78,8 @@ export async function mailAdmins(d: JobDeps, subject: string, text: string) {
|
||||
await d.send({ to, subject, text }).catch((err: unknown) => d.log.error({ err }, subject));
|
||||
}
|
||||
|
||||
/** Recordings, voicemail, expired sessions and old webhook deliveries (04 §8 retentionCleanup). */
|
||||
/** Recordings, voicemail, expired sessions, old webhook deliveries and spam check answers
|
||||
* (04 §8 retentionCleanup, 14 §4). */
|
||||
export async function retentionCleanup(d: JobDeps) {
|
||||
const recordings = await cleanupRecordings(
|
||||
d.db,
|
||||
@@ -111,6 +122,7 @@ export async function retentionCleanup(d: JobDeps) {
|
||||
await d.db
|
||||
.delete(systemEvents)
|
||||
.where(lt(systemEvents.createdAt, sql`now() - interval '90 days'`));
|
||||
await d.db.delete(spamLookups).where(lt(spamLookups.expiresAt, sql`now()`));
|
||||
await cleanupContactShares(d.db);
|
||||
if (recordings || voicemail) d.log.info({ recordings, voicemail }, 'old messages deleted');
|
||||
}
|
||||
@@ -119,6 +131,13 @@ export async function retentionCleanup(d: JobDeps) {
|
||||
export function createJobs(d: JobDeps) {
|
||||
const now = d.now ?? (() => new Date());
|
||||
const tz = () => getSetting(d.db, 'general.timezone');
|
||||
const fraud = createFraudWatch({
|
||||
db: d.db,
|
||||
voipmsUrl: d.config.VOIPMS_API_URL,
|
||||
mail: (subject, text) => mailAdmins(d, subject, text),
|
||||
log: d.log,
|
||||
now,
|
||||
});
|
||||
|
||||
// Holidays roll over at midnight (03 §6.10): render again when the local date changes.
|
||||
// Retention runs then too (and at start), not at 02:00.
|
||||
@@ -184,7 +203,8 @@ export function createJobs(d: JobDeps) {
|
||||
`Public IP changed to ${ip}`,
|
||||
`The phone system's public IP changed from ${old ?? 'none'} to ${ip}.\n` +
|
||||
'Restart telephony so calls use the new address: Admin → System says how (it drops calls in progress).\n' +
|
||||
'Phones and the provider may need the new IP if they were set up with the old one.',
|
||||
'Phones and the provider may need the new IP if they were set up with the old one ' +
|
||||
'(voip.ms: the sub-account’s IP restriction or IP authentication, and the API whitelist).',
|
||||
);
|
||||
};
|
||||
|
||||
@@ -261,12 +281,32 @@ export function createJobs(d: JobDeps) {
|
||||
await logCallsToContrack({ db: d.db, log: d.log, now: now() });
|
||||
};
|
||||
|
||||
/** OpenID sessions refreshed at the provider; a leaver's end (12 §3.5). */
|
||||
const oidcCheck = async () => {
|
||||
await checkOidcSessions({ db: d.db, log: d.log, now: now() });
|
||||
};
|
||||
|
||||
/** Directory accounts re-read; a leaver disabled (12 §4.5). */
|
||||
const directoryCheck = async () => {
|
||||
await checkDirectory({ db: d.db, log: d.log });
|
||||
};
|
||||
|
||||
return {
|
||||
spamQuotaCheck: () => spamQuotaCheck(d.db),
|
||||
/** 14 §2.2: a failure is logged when it changes, and shown on Settings → Spam check. */
|
||||
spamFtcSync: async () => {
|
||||
const reason = await ftcSync(d.db, { now: now() });
|
||||
if (reason) d.log.warn({ provider: 'ftc', reason }, 'FTC complaint download failed');
|
||||
},
|
||||
oidcCheck,
|
||||
directoryCheck,
|
||||
daily,
|
||||
backup,
|
||||
publicIpWatch,
|
||||
trunkWatch,
|
||||
diskWatch,
|
||||
riskyCallWatch: fraud.riskyCallWatch,
|
||||
providerWatch: fraud.providerWatch,
|
||||
callReports,
|
||||
contactsWarm,
|
||||
contrackLog,
|
||||
@@ -292,9 +332,15 @@ export function startJobs(d: JobDeps): () => void {
|
||||
run('trunkWatch', j.trunkWatch);
|
||||
if (minute % 5 === 0) run('publicIpWatch', j.publicIpWatch);
|
||||
if (minute % 60 === 0) run('diskWatch', j.diskWatch);
|
||||
run('riskyCallWatch', j.riskyCallWatch);
|
||||
if (minute % 15 === 0) run('providerWatch', j.providerWatch);
|
||||
run('callReports', j.callReports);
|
||||
if (minute % 5 === 0) run('contactsWarm', j.contactsWarm);
|
||||
run('contrackLog', j.contrackLog);
|
||||
if (minute % 5 === 0) run('oidcCheck', j.oidcCheck);
|
||||
if (minute % 60 === 0) run('directoryCheck', j.directoryCheck);
|
||||
if (minute % 360 === 0) run('spamQuotaCheck', j.spamQuotaCheck);
|
||||
run('spamFtcSync', j.spamFtcSync);
|
||||
run('callTranscripts', async () => {
|
||||
await transcribeCalls({ db: d.db, dir: resolve(d.config.RECORDINGS_DIR), log: d.log });
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { FastifyInstance } from 'fastify';
|
||||
import type { WebSocket } from '@fastify/websocket';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { ExtensionLive, LiveMessage } from '@selfphoneng/shared';
|
||||
import { COOKIE, getSessionUser } from '../auth/sessions.ts';
|
||||
import { COOKIE, getSessionUser, mustChange } from '../auth/sessions.ts';
|
||||
import { extensions } from '../db/schema.ts';
|
||||
import type { LiveState } from './state.ts';
|
||||
|
||||
@@ -103,7 +103,7 @@ export async function liveRoutes(app: FastifyInstance) {
|
||||
for (const c of clients)
|
||||
void getSessionUser(app.db, c.token, false)
|
||||
.then((f) => {
|
||||
if (!f || f.user.mustChangePassword || (f.user.role === 'admin') !== c.admin)
|
||||
if (!f || mustChange(f) || (f.user.role === 'admin') !== c.admin)
|
||||
c.ws.close(4401, 'unauthorized');
|
||||
})
|
||||
.catch(() => {}); // database down: keep the socket, check again next time
|
||||
@@ -123,7 +123,7 @@ export async function liveRoutes(app: FastifyInstance) {
|
||||
const token = req.cookies[COOKIE];
|
||||
const found = token ? await getSessionUser(app.db, token) : null;
|
||||
// Browsers can't read an HTTP 401 on an upgrade, so open and close with 4401 (04 §5.2).
|
||||
if (!found || found.user.mustChangePassword) return ws.close(4401, 'unauthorized');
|
||||
if (!found || mustChange(found)) return ws.close(4401, 'unauthorized');
|
||||
const mine = await app.db
|
||||
.select({ n: extensions.number })
|
||||
.from(extensions)
|
||||
|
||||
@@ -213,12 +213,10 @@ export class LiveState extends EventEmitter<{ message: [LiveMessage] }> {
|
||||
/** POST /api/trunks/:id/test: qualify now, then read registration and reachability. */
|
||||
async testTrunk(
|
||||
id: string,
|
||||
{ waitMs = 1500, qualify = true } = {},
|
||||
{ waitMs = 1500 } = {},
|
||||
): Promise<{ live: TrunkLive; rttMs: number | null }> {
|
||||
if (qualify) {
|
||||
await this.ami.action({ Action: 'PJSIPQualify', Endpoint: `trunk-${id}` }).catch(() => {});
|
||||
await new Promise((r) => setTimeout(r, waitMs)); // the OPTIONS answer arrives asynchronously
|
||||
}
|
||||
await this.ami.action({ Action: 'PJSIPQualify', Endpoint: `trunk-${id}` }).catch(() => {});
|
||||
await new Promise((r) => setTimeout(r, waitMs)); // the OPTIONS answer arrives asynchronously
|
||||
await this.refreshRegistrations();
|
||||
const rttMs = await this.refreshReachable(id);
|
||||
const live = this.trunks.get(id);
|
||||
|
||||
@@ -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
|
||||
|
||||
+134
-22
@@ -1,14 +1,16 @@
|
||||
// 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,
|
||||
PasswordConfirmSchema,
|
||||
TotpEnableSchema,
|
||||
APP_NAME,
|
||||
type AuthMethods,
|
||||
type AuthUser,
|
||||
type SignInWays,
|
||||
type TotpSetup,
|
||||
type TotpStatus,
|
||||
} from '@selfphoneng/shared';
|
||||
@@ -22,7 +24,11 @@ import {
|
||||
otpauthUrl,
|
||||
} from '../auth/totp.ts';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { extensions, users } from '../db/schema.ts';
|
||||
import { extensions, sessions, userIdentities, users } from '../db/schema.ts';
|
||||
import { endSessionUrl, oidcError } from '../sso/oidc.ts';
|
||||
import { directorySignIn, directoryWarning } from '../sso/directory.ts';
|
||||
import { ldapAuthenticate } from '../sso/ldap.ts';
|
||||
import { getSetting } from '../services/settings.ts';
|
||||
import {
|
||||
unauthorized,
|
||||
rateLimited,
|
||||
@@ -49,11 +55,32 @@ import {
|
||||
setSessionCookie,
|
||||
} from '../auth/sessions.ts';
|
||||
|
||||
/** 12 §2: the user's password and connected accounts, from their user_identities rows. */
|
||||
export const signInWays = (
|
||||
u: { passwordHash: string | null },
|
||||
ids: { kind: 'oidc' | 'ldap'; email: string | null; groups: string[] }[],
|
||||
): SignInWays => {
|
||||
const oidc = ids.find((i) => i.kind === 'oidc');
|
||||
return {
|
||||
password: u.passwordHash !== null,
|
||||
oidc: oidc ? { email: oidc.email, groups: oidc.groups } : null,
|
||||
ldap: ids.some((i) => i.kind === 'ldap'),
|
||||
};
|
||||
};
|
||||
|
||||
export async function toAuthUser(db: Db, u: CurrentUser): Promise<AuthUser> {
|
||||
const [ext] = await db
|
||||
.select({ id: extensions.id, number: extensions.number, name: extensions.name })
|
||||
.from(extensions)
|
||||
.where(eq(extensions.userId, u.id));
|
||||
const ids = await db
|
||||
.select({
|
||||
kind: userIdentities.kind,
|
||||
email: userIdentities.email,
|
||||
groups: userIdentities.groups,
|
||||
})
|
||||
.from(userIdentities)
|
||||
.where(eq(userIdentities.userId, u.id));
|
||||
return {
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
@@ -61,6 +88,7 @@ export async function toAuthUser(db: Db, u: CurrentUser): Promise<AuthUser> {
|
||||
role: u.role,
|
||||
mustChangePassword: u.mustChangePassword,
|
||||
extension: ext ?? null,
|
||||
signIn: signInWays(u, ids),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -112,13 +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 (!(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.');
|
||||
@@ -139,15 +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 (!(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: [] })
|
||||
@@ -164,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));
|
||||
@@ -179,15 +218,61 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
return { recoveryCodes: codes };
|
||||
});
|
||||
|
||||
/** The login page's choices (12 §2). Public: says nothing about any user. */
|
||||
app.get('/api/auth/methods', async (): Promise<AuthMethods> => {
|
||||
const mode = await getSetting(db, 'auth.mode');
|
||||
const oidc = await getSetting(db, 'auth.oidc');
|
||||
return {
|
||||
mode,
|
||||
ldap: Boolean((await getSetting(db, 'auth.ldap'))?.enabled),
|
||||
oidc: oidc && mode !== 'password' ? { label: oidc.label } : null,
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/auth/login', async (req, reply) => {
|
||||
const body = LoginSchema.parse(req.body);
|
||||
if (isLoginBlocked(req.ip, body.email)) throw rateLimited();
|
||||
const [user] = await db.select().from(users).where(eq(users.email, body.email));
|
||||
const ok = user?.isActive
|
||||
const { login, ...body } = LoginSchema.parse(req.body);
|
||||
if (isLoginBlocked(req.ip, login)) throw rateLimited();
|
||||
let [user] = await db.select().from(users).where(eq(users.email, login));
|
||||
// No password (OpenID only, 12 §3.3) fails like a wrong one, after the same work.
|
||||
let ok = user?.passwordHash
|
||||
? await verifyPassword(user.passwordHash, body.password)
|
||||
: (await burnVerify(body.password), false);
|
||||
if (!user || !ok) {
|
||||
recordLoginFailure(req.ip, body.email);
|
||||
let method: 'password' | 'ldap' = 'password';
|
||||
// Not this password here: the directory's, when it's on (12 §4.2).
|
||||
const ldap = ok ? null : await getSetting(db, 'auth.ldap');
|
||||
if (ldap?.enabled) {
|
||||
let entry;
|
||||
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,
|
||||
`A directory sign-in failed: the directory couldn't be reached (${(err as Error).message}).`,
|
||||
);
|
||||
throw new HttpError(
|
||||
503,
|
||||
'directory_unavailable',
|
||||
'The directory can’t be reached. Passwords set in SelfPhoneNG still work.',
|
||||
);
|
||||
}
|
||||
const found = entry && (await directorySignIn(db, req.log, entry, req.ip));
|
||||
if (entry && !found)
|
||||
req.log.warn({ event: 'auth.failed', ip: req.ip }, 'directory account has no user here');
|
||||
if (found) [user, ok, method] = [found, true, 'ldap'];
|
||||
}
|
||||
if (!user || !ok || !user.isActive) {
|
||||
recordLoginFailure(req.ip, login);
|
||||
req.log.warn({ event: 'auth.failed', ip: req.ip }, 'login failed');
|
||||
await audit(db, {
|
||||
actorUserId: user?.id,
|
||||
@@ -195,16 +280,19 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
action: 'login_failed',
|
||||
entityType: 'user',
|
||||
entityId: user?.id,
|
||||
summary: `Failed login for ${body.email}`,
|
||||
summary: `Failed login for ${login}`,
|
||||
});
|
||||
throw unauthorized('Wrong email or password');
|
||||
}
|
||||
// OpenID only (12 §1.1): passwords are for admins, the way in when the provider is down.
|
||||
if (user.role !== 'admin' && (await getSetting(db, 'auth.mode')) === 'oidc')
|
||||
throw forbidden('Use the sign-in button instead', 'password_sign_in_off');
|
||||
// Two-step sign-in (08 §2.4): the password was right; now the code.
|
||||
if (user.totpSecretEnc) {
|
||||
if (!body.totp)
|
||||
throw new HttpError(401, 'totp_required', 'Enter the code from your authenticator app');
|
||||
if (!(await useSecondFactor(user, body.totp))) {
|
||||
recordLoginFailure(req.ip, body.email);
|
||||
recordLoginFailure(req.ip, login);
|
||||
req.log.warn({ event: 'auth.failed', ip: req.ip }, 'login failed: code');
|
||||
await audit(db, {
|
||||
actorUserId: user.id,
|
||||
@@ -212,12 +300,18 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
action: 'login_failed',
|
||||
entityType: 'user',
|
||||
entityId: user.id,
|
||||
summary: `Failed login for ${body.email} (wrong authenticator or recovery code)`,
|
||||
summary: `Failed login for ${login} (wrong authenticator or recovery code)`,
|
||||
});
|
||||
throw new HttpError(401, 'totp_invalid', 'That code is wrong or was used already');
|
||||
}
|
||||
}
|
||||
const token = await createSession(db, user.id, req.ip, req.headers['user-agent'] ?? null);
|
||||
const token = await createSession(
|
||||
db,
|
||||
user.id,
|
||||
req.ip,
|
||||
req.headers['user-agent'] ?? null,
|
||||
method,
|
||||
);
|
||||
setSessionCookie(reply, token);
|
||||
await db.update(users).set({ lastLoginAt: new Date() }).where(eq(users.id, user.id));
|
||||
await audit(db, {
|
||||
@@ -226,14 +320,30 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
action: 'login',
|
||||
entityType: 'user',
|
||||
entityId: user.id,
|
||||
summary: `${user.email} logged in`,
|
||||
summary: `${user.email} logged in${method === 'ldap' ? ' with the directory' : ''}`,
|
||||
});
|
||||
return { user: await toAuthUser(db, user) };
|
||||
});
|
||||
|
||||
app.post('/api/auth/logout', { preHandler: requireAuth }, async (req, reply) => {
|
||||
const [sess] = req.sessionId
|
||||
? await db.select().from(sessions).where(eq(sessions.id, req.sessionId))
|
||||
: [];
|
||||
await deleteSession(db, req.cookies[COOKIE]!);
|
||||
clearSessionCookie(reply);
|
||||
// Signed in with OpenID: sign out there too (12 §3.5). A provider problem doesn't stop ours.
|
||||
let redirect: string | null = null;
|
||||
const oidc = sess?.idpIdTokenEnc ? await getSetting(db, 'auth.oidc') : null;
|
||||
if (oidc)
|
||||
try {
|
||||
redirect = await endSessionUrl(
|
||||
oidc,
|
||||
app.config.PUBLIC_HOSTNAME,
|
||||
decrypt(sess!.idpIdTokenEnc!),
|
||||
);
|
||||
} catch (err) {
|
||||
req.log.warn({ event: 'auth.oidc', err: oidcError(err) }, 'no sign-out at the provider');
|
||||
}
|
||||
await audit(db, {
|
||||
actorUserId: req.user!.id,
|
||||
actorIp: req.ip,
|
||||
@@ -242,7 +352,7 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
entityId: req.user!.id,
|
||||
summary: `${req.user!.email} logged out`,
|
||||
});
|
||||
return { ok: true };
|
||||
return redirect ? { ok: true, redirect } : { ok: true };
|
||||
});
|
||||
|
||||
app.get('/api/auth/me', { preHandler: requireAuth }, async (req) => ({
|
||||
@@ -252,7 +362,9 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
app.post('/api/auth/change-password', { preHandler: requireAuth }, async (req) => {
|
||||
const body = ChangePasswordSchema.parse(req.body);
|
||||
const user = req.user!;
|
||||
if (!(await verifyPassword(user.passwordHash, body.currentPassword)))
|
||||
if (!user.passwordHash)
|
||||
throw badRequest('You have no password here. Ask an admin to reset it to get one.');
|
||||
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');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// /api/blocked-numbers (04 §7.3): PostgreSQL, then AstDB SP/BLOCK/<number>, which every trunk
|
||||
// entry checks (03 §6.4). Admin only.
|
||||
// entry checks (03 §6.4). /api/spam-reports: users' Report spam, to block or dismiss. Admin only.
|
||||
import { desc, eq } from 'drizzle-orm';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
@@ -9,9 +9,10 @@ import {
|
||||
formatNumber,
|
||||
type BlockedDto,
|
||||
type ListResponse,
|
||||
type SpamReportDto,
|
||||
} from '@selfphoneng/shared';
|
||||
import { requireAdmin } from '../auth/guards.ts';
|
||||
import { blockedNumbers } from '../db/schema.ts';
|
||||
import { blockedNumbers, spamReports, users } from '../db/schema.ts';
|
||||
import { conflict, notFound } from '../errors.ts';
|
||||
import { setBlock } from '../services/astdb.ts';
|
||||
import { audit } from '../services/audit.ts';
|
||||
@@ -52,6 +53,7 @@ export async function blockedRoutes(app: FastifyInstance) {
|
||||
throw e;
|
||||
}
|
||||
await mirror(b.number, true);
|
||||
await db.delete(spamReports).where(eq(spamReports.number, b.number));
|
||||
await audit(db, {
|
||||
actorUserId: req.user!.id,
|
||||
actorIp: req.ip,
|
||||
@@ -78,4 +80,28 @@ export async function blockedRoutes(app: FastifyInstance) {
|
||||
});
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.get('/api/spam-reports', async (): Promise<ListResponse<SpamReportDto>> => ({
|
||||
items: (
|
||||
await db
|
||||
.select({ r: spamReports, name: users.displayName })
|
||||
.from(spamReports)
|
||||
.leftJoin(users, eq(users.id, spamReports.reportedBy))
|
||||
.orderBy(desc(spamReports.createdAt))
|
||||
).map(({ r, name }) => ({
|
||||
id: r.id,
|
||||
number: r.number,
|
||||
reason: r.reason,
|
||||
reportedBy: name,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
})),
|
||||
nextCursor: null,
|
||||
}));
|
||||
|
||||
app.delete('/api/spam-reports/:id', async (req) => {
|
||||
const { id } = IdParams.parse(req.params);
|
||||
const [r] = await db.delete(spamReports).where(eq(spamReports.id, id)).returning();
|
||||
if (!r) throw notFound('Already handled');
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
@@ -1,20 +1,35 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// /api/calls (admin) and /api/me/calls (04 §7.3): call history, newest first, keyset paging.
|
||||
import { and, asc, desc, eq, ilike, inArray, or, sql, type SQL } from 'drizzle-orm';
|
||||
// /api/me/spam-reports: Report spam on a call that rang me (05 §4).
|
||||
import { and, asc, desc, eq, ilike, inArray, ne, or, sql, type SQL } from 'drizzle-orm';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { alias } from 'drizzle-orm/pg-core';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
CallFilterSchema,
|
||||
SpamReportCreateSchema,
|
||||
formatNumber,
|
||||
normalizeNumber,
|
||||
type CallDetailDto,
|
||||
type CallDto,
|
||||
type ListResponse,
|
||||
type MyCallDto,
|
||||
type SpamCheck,
|
||||
} from '@selfphoneng/shared';
|
||||
import { requireAdmin, requireAuth } from '../auth/guards.ts';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { callExtensions, calls, cdr, dids, extensions } from '../db/schema.ts';
|
||||
import { badRequest, notFound } from '../errors.ts';
|
||||
import {
|
||||
blockedNumbers,
|
||||
callExtensions,
|
||||
calls,
|
||||
cdr,
|
||||
dids,
|
||||
extensions,
|
||||
spamReports,
|
||||
} from '../db/schema.ts';
|
||||
import { contactNames } from '../contacts/book.ts';
|
||||
import { badRequest, conflict, notFound } from '../errors.ts';
|
||||
import { ON_THE_CALL } from '../services/cdr.ts';
|
||||
import { getSetting } from '../services/settings.ts';
|
||||
|
||||
const answerer = alias(extensions, 'answerer');
|
||||
@@ -65,7 +80,8 @@ async function conditions(db: Db, f: Filter): Promise<SQL[]> {
|
||||
return where;
|
||||
}
|
||||
|
||||
async function page(db: Db, where: SQL[], limit: number) {
|
||||
/** `viewer`: whose books hide the spam check for a number they name (14 §6). */
|
||||
async function page(db: Db, where: SQL[], limit: number, viewer: string) {
|
||||
const rows = await db
|
||||
.select({ c: calls, did: dids, a: answerer })
|
||||
.from(calls)
|
||||
@@ -76,6 +92,12 @@ async function page(db: Db, where: SQL[], limit: number) {
|
||||
.limit(limit + 1);
|
||||
const more = rows.length > limit;
|
||||
const items = rows.slice(0, limit);
|
||||
const known = await contactNames(
|
||||
db,
|
||||
viewer,
|
||||
items.filter(({ c }) => c.spamRisk).map(({ c }) => c.fromNumber),
|
||||
{ wait: false },
|
||||
).catch(() => null);
|
||||
return {
|
||||
items: items.map(({ c, did, a }): CallDto => ({
|
||||
id: c.id,
|
||||
@@ -91,6 +113,10 @@ async function page(db: Db, where: SQL[], limit: number) {
|
||||
answeredBy: a ? { id: a.id, number: a.number, name: a.name } : null,
|
||||
talkSeconds: c.talkSeconds,
|
||||
recordingId: c.recordingId,
|
||||
spam:
|
||||
c.spamRisk && known && !known.has(c.fromNumber ?? '')
|
||||
? ({ risk: c.spamRisk, ...(c.spamInfo as object) } as SpamCheck)
|
||||
: null,
|
||||
})),
|
||||
nextCursor: more ? encodeCursor(items.at(-1)!.c) : null,
|
||||
};
|
||||
@@ -111,7 +137,7 @@ export async function callRoutes(app: FastifyInstance) {
|
||||
{ preHandler: requireAdmin },
|
||||
async (req): Promise<ListResponse<CallDto>> => {
|
||||
const f = CallFilterSchema.parse(req.query);
|
||||
return page(db, await conditions(db, f), f.limit);
|
||||
return page(db, await conditions(db, f), f.limit, req.user!.id);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -123,7 +149,7 @@ export async function callRoutes(app: FastifyInstance) {
|
||||
'started,direction,status,from,from_name,to,phone_number,answered_by,talk_seconds',
|
||||
];
|
||||
// ponytail: one query capped at 50,000 rows; stream with a cursor if exports get bigger.
|
||||
const { items } = await page(db, where, 50_000);
|
||||
const { items } = await page(db, where, 50_000, req.user!.id);
|
||||
const fmt = new Intl.DateTimeFormat('sv-SE', {
|
||||
timeZone: tz,
|
||||
dateStyle: 'short',
|
||||
@@ -156,7 +182,7 @@ export async function callRoutes(app: FastifyInstance) {
|
||||
|
||||
app.get('/api/calls/:id', { preHandler: requireAdmin }, async (req): Promise<CallDetailDto> => {
|
||||
const { id } = z.object({ id: z.uuid() }).parse(req.params);
|
||||
const { items } = await page(db, [eq(calls.id, id)], 1);
|
||||
const { items } = await page(db, [eq(calls.id, id)], 1, req.user!.id);
|
||||
const [c] = await db.select({ linkedid: calls.linkedid }).from(calls).where(eq(calls.id, id));
|
||||
if (!items[0] || !c) throw notFound('Call not found');
|
||||
const legs = await db
|
||||
@@ -189,7 +215,7 @@ export async function callRoutes(app: FastifyInstance) {
|
||||
.where(eq(extensions.userId, req.user!.id));
|
||||
if (!ext) throw notFound("You don't have an extension yet. Ask your admin.");
|
||||
const where = await conditions(db, { ...f, extensionId: ext.id });
|
||||
const result = await page(db, where, f.limit);
|
||||
const result = await page(db, where, f.limit, req.user!.id);
|
||||
const ids = result.items.map((c) => c.id);
|
||||
const roles = ids.length
|
||||
? await db
|
||||
@@ -203,6 +229,8 @@ export async function callRoutes(app: FastifyInstance) {
|
||||
const out = mine.includes('caller');
|
||||
return {
|
||||
...c,
|
||||
// Only rang (a ring group): someone else's conversation (08 §7 #3).
|
||||
recordingId: mine.some((r) => ON_THE_CALL.includes(r)) ? c.recordingId : null,
|
||||
myRole: out ? 'out' : 'in',
|
||||
missedByMe: !out && (MISSED as readonly string[]).includes(c.status),
|
||||
};
|
||||
@@ -211,4 +239,42 @@ export async function callRoutes(app: FastifyInstance) {
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
/** The admins see it on Blocked numbers and block or dismiss it. */
|
||||
app.post('/api/me/spam-reports', { preHandler: requireAuth }, async (req, reply) => {
|
||||
const body = SpamReportCreateSchema.parse(req.body);
|
||||
const [c] = await db
|
||||
.select({ number: calls.fromNumber })
|
||||
.from(calls)
|
||||
.innerJoin(callExtensions, eq(callExtensions.callId, calls.id))
|
||||
.innerJoin(extensions, eq(extensions.id, callExtensions.extensionId))
|
||||
.where(
|
||||
and(
|
||||
eq(calls.id, body.callId),
|
||||
eq(calls.direction, 'inbound'),
|
||||
eq(extensions.userId, req.user!.id),
|
||||
ne(callExtensions.role, 'caller'),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!c) throw notFound('Not a call from outside that rang you');
|
||||
let number: string;
|
||||
try {
|
||||
number = normalizeNumber(c.number ?? '');
|
||||
} catch {
|
||||
throw badRequest("This caller's number was hidden, so it can't be blocked");
|
||||
}
|
||||
const [blocked] = await db
|
||||
.select({ id: blockedNumbers.id })
|
||||
.from(blockedNumbers)
|
||||
.where(eq(blockedNumbers.number, number));
|
||||
if (blocked) throw conflict(`${formatNumber(number)} is already blocked`);
|
||||
const [r] = await db
|
||||
.insert(spamReports)
|
||||
.values({ number, reason: body.reason, reportedBy: req.user!.id })
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
if (!r) throw conflict(`You already reported ${formatNumber(number)}`);
|
||||
return reply.code(201).send({ ok: true });
|
||||
});
|
||||
}
|
||||
@@ -15,8 +15,9 @@ import {
|
||||
type ContactImportResult,
|
||||
type ContactShareDto,
|
||||
type ListResponse,
|
||||
type SpamCheck,
|
||||
} from '@selfphoneng/shared';
|
||||
import { requireAdmin, requireAuth } from '../auth/guards.ts';
|
||||
import { NO_LIMIT, SLOW, requireAdmin, requireAuth } from '../auth/guards.ts';
|
||||
import {
|
||||
addToBook,
|
||||
assertBuiltin,
|
||||
@@ -37,6 +38,7 @@ import { getSetting } from '../services/settings.ts';
|
||||
import { decrypt } from '../crypto/secrets.ts';
|
||||
import { contrackCheck } from '../contacts/contrack.ts';
|
||||
import { callerNameToken, phoneSafeName, sameToken } from '../contacts/callername.ts';
|
||||
import { checkCaller, usNumber } from '../spam/check.ts';
|
||||
|
||||
const IdParams = z.object({ id: z.uuid() });
|
||||
const CommitQuery = z.object({ commit: z.enum(['0', '1']).default('0') });
|
||||
@@ -162,33 +164,79 @@ 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 owner = e?.userId ?? null;
|
||||
const names = await contactNames(db, owner, [q.data.number], {
|
||||
wait: false,
|
||||
}).catch(() => new Map<string, string>());
|
||||
let name = names.get(q.data.number) ?? '';
|
||||
// 14 §5.2: no contact → the spam check's label, if it answers in 1.2 s (of the 2 s).
|
||||
// app.log: this route's own log is silent (the token is in the query).
|
||||
// ponytail: the label replaces the carrier's caller ID name, which the dialplan doesn't
|
||||
// send; IPQualityScore's name for the line follows it when it has one.
|
||||
if (!name) {
|
||||
const c = await checkCaller({ db, log: app.log }, owner, usNumber(q.data.number), 1200);
|
||||
if (c && c.risk !== 'low') {
|
||||
const labels = (await getSetting(db, 'spam')).deskPhoneLabels;
|
||||
name = `${labels[c.risk]} ${c.provider === 'ipqs' ? (c.info.name ?? '') : ''}`;
|
||||
}
|
||||
}
|
||||
return reply.type('text/plain; charset=utf-8').send(phoneSafeName(name));
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* The softphone's spam check (14 §5.1): only for a caller ringing one of my extensions right
|
||||
* now, so nobody can spend the credits on numbers that never called.
|
||||
*/
|
||||
app.get(
|
||||
'/api/me/caller-check',
|
||||
{ preHandler: requireAuth },
|
||||
async (req): Promise<{ check: SpamCheck | null }> => {
|
||||
const { number } = z.object({ number: z.string().max(30) }).parse(req.query);
|
||||
const n = usNumber(number);
|
||||
const mine = (
|
||||
await db
|
||||
.select({ number: extensions.number })
|
||||
.from(extensions)
|
||||
.where(eq(extensions.userId, req.user!.id))
|
||||
).map((x) => x.number);
|
||||
const ringing = app.live.calls
|
||||
.list()
|
||||
.some(
|
||||
(c) =>
|
||||
c.direction === 'inbound' &&
|
||||
usNumber(c.from.number) === n &&
|
||||
c.extensions.some((x) => mine.includes(x)),
|
||||
);
|
||||
if (!n || !ringing) return { check: null };
|
||||
return { check: await checkCaller({ db, log: req.log }, req.user!.id, n, 5000) };
|
||||
},
|
||||
);
|
||||
|
||||
/** Copy the builtin book into Contrack (11 §5, C6): mine, or (admin) the company's. */
|
||||
app.post(
|
||||
@@ -214,19 +262,25 @@ 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');
|
||||
// The saved token only for the saved address: another one gets none (08 §7 #7).
|
||||
const same = saved?.url === b.url.replace(/\/+$/, '');
|
||||
const token =
|
||||
b.companyToken || (same && 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)
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Pairing the mobile app (13 §2, §4): a one-time code from My settings, traded by the app for a
|
||||
// device token; the person's paired phones, and removing one.
|
||||
import { and, eq, isNotNull, isNull } from 'drizzle-orm';
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import QRCode from 'qrcode';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
MobilePairSchema,
|
||||
type ListResponse,
|
||||
type MobileDeviceDto,
|
||||
type MobilePairCode,
|
||||
type MobilePairResult,
|
||||
} from '@selfphoneng/shared';
|
||||
import { PAIR_CODE_TTL_MS, generatePairCode, pairDevice } from '../auth/devices.ts';
|
||||
import { SLOW, requireAuth } from '../auth/guards.ts';
|
||||
import { hashToken } from '../auth/tokens.ts';
|
||||
import { mobileDevices } from '../db/schema.ts';
|
||||
import { forbidden, HttpError, notFound } from '../errors.ts';
|
||||
import { audit } from '../services/audit.ts';
|
||||
|
||||
const IdParams = z.object({ id: z.uuid() });
|
||||
|
||||
export async function mobileRoutes(app: FastifyInstance) {
|
||||
const db = app.db;
|
||||
const act = (req: FastifyRequest) => ({ actorUserId: req.user!.id, actorIp: req.ip });
|
||||
|
||||
app.post(
|
||||
'/api/me/mobile-devices/pair',
|
||||
{ preHandler: requireAuth, config: SLOW },
|
||||
async (req): Promise<MobilePairCode> => {
|
||||
// Only a person at the web page: no token (a stolen phone can't make itself a spare).
|
||||
if (!req.sessionId) throw forbidden('Pair a phone from the web page');
|
||||
const userId = req.user!.id;
|
||||
await db
|
||||
.delete(mobileDevices)
|
||||
.where(and(eq(mobileDevices.userId, userId), isNull(mobileDevices.tokenHash)));
|
||||
const code = generatePairCode();
|
||||
const expiresAt = new Date(Date.now() + PAIR_CODE_TTL_MS);
|
||||
const [d] = await db
|
||||
.insert(mobileDevices)
|
||||
.values({ userId, pairCodeHash: hashToken(code), pairExpiresAt: expiresAt })
|
||||
.returning({ id: mobileDevices.id });
|
||||
await audit(db, {
|
||||
...act(req),
|
||||
action: 'create',
|
||||
entityType: 'mobile_device',
|
||||
entityId: d!.id,
|
||||
summary: 'Made a code to pair a phone',
|
||||
});
|
||||
const server = `https://${app.config.PUBLIC_HOSTNAME}`;
|
||||
const link = `selfphoneng://pair?${new URLSearchParams({ server, code })}`;
|
||||
return {
|
||||
code,
|
||||
server,
|
||||
link,
|
||||
qr: await QRCode.toDataURL(link, { margin: 2, width: 280 }),
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
/** Public: the code is the credential. The only time the device token is shown. */
|
||||
app.post('/api/mobile/pair', { config: SLOW }, async (req): Promise<MobilePairResult> => {
|
||||
const { code, ...device } = MobilePairSchema.parse(req.body);
|
||||
const paired = await pairDevice(db, code, device);
|
||||
if (!paired)
|
||||
throw new HttpError(
|
||||
404,
|
||||
'pair_code_invalid',
|
||||
'That code is wrong or expired. Make a new one.',
|
||||
);
|
||||
await audit(db, {
|
||||
actorUserId: paired.user.id,
|
||||
actorIp: req.ip,
|
||||
action: 'create',
|
||||
entityType: 'mobile_device',
|
||||
entityId: paired.deviceId,
|
||||
summary: `Paired ${device.platform} phone ${device.name}`,
|
||||
});
|
||||
return {
|
||||
token: paired.token,
|
||||
user: { name: paired.user.displayName, email: paired.user.email },
|
||||
};
|
||||
});
|
||||
|
||||
app.get(
|
||||
'/api/me/mobile-devices',
|
||||
{ preHandler: requireAuth },
|
||||
async (req): Promise<ListResponse<MobileDeviceDto>> => {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(mobileDevices)
|
||||
.where(and(eq(mobileDevices.userId, req.user!.id), isNotNull(mobileDevices.tokenHash)))
|
||||
.orderBy(mobileDevices.createdAt);
|
||||
return {
|
||||
items: rows.map((d) => ({
|
||||
id: d.id,
|
||||
name: d.name!,
|
||||
platform: d.platform as MobileDeviceDto['platform'],
|
||||
tokenPrefix: d.tokenPrefix!,
|
||||
createdAt: d.createdAt.toISOString(),
|
||||
lastUsedAt: d.lastUsedAt?.toISOString() ?? null,
|
||||
current: d.id === req.mobileDeviceId,
|
||||
})),
|
||||
nextCursor: null,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
/** Removes one of my phones; the app's Sign out removes itself. */
|
||||
app.delete('/api/me/mobile-devices/:id', { preHandler: requireAuth }, async (req) => {
|
||||
const { id } = IdParams.parse(req.params);
|
||||
const [d] = await db
|
||||
.delete(mobileDevices)
|
||||
.where(and(eq(mobileDevices.id, id), eq(mobileDevices.userId, req.user!.id)))
|
||||
.returning();
|
||||
if (!d) throw notFound('Phone not found');
|
||||
await audit(db, {
|
||||
...act(req),
|
||||
action: 'delete',
|
||||
entityType: 'mobile_device',
|
||||
entityId: id,
|
||||
summary: `Removed phone ${d.name ?? '(not paired)'}`,
|
||||
});
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Sign in with OpenID Connect, connect and disconnect an OpenID account (12 §3.2, §3.3).
|
||||
import { and, eq, gt, isNull, ne, notExists } from 'drizzle-orm';
|
||||
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,
|
||||
badRequest,
|
||||
forbidden,
|
||||
notFound,
|
||||
rateLimited,
|
||||
unauthorized,
|
||||
} from '../errors.ts';
|
||||
import { requireAdmin, requireAuth } from '../auth/guards.ts';
|
||||
import { isLoginBlocked, recordLoginFailure } from '../auth/login-limiter.ts';
|
||||
import { COOKIE, createSession, getSessionUser, 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,
|
||||
oidcError,
|
||||
startSignIn,
|
||||
type OidcSettings,
|
||||
type PendingSignIn,
|
||||
} from '../sso/oidc.ts';
|
||||
|
||||
export const OIDC_COOKIE = 'sp_oidc';
|
||||
const COOKIE_PATH = '/api/auth/oidc';
|
||||
|
||||
/**
|
||||
* 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}`;
|
||||
|
||||
export async function oidcRoutes(app: FastifyInstance) {
|
||||
const { db } = app;
|
||||
const host = app.config.PUBLIC_HOSTNAME;
|
||||
|
||||
/** The provider's settings, when OpenID is on (12 §1.1: not in `password` mode). */
|
||||
async function enabled(): Promise<OidcSettings> {
|
||||
const s = await getSetting(db, 'auth.oidc');
|
||||
if (!s || (await getSetting(db, 'auth.mode')) === 'password') throw notFound();
|
||||
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() }).parse(req.query);
|
||||
try {
|
||||
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('/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`);
|
||||
}
|
||||
});
|
||||
|
||||
// Not logged by Fastify: the query holds the authorization code (12 §1).
|
||||
app.get('/api/auth/oidc/callback', { logLevel: 'silent' }, async (req, reply) => {
|
||||
const s = await enabled();
|
||||
let pending: PendingSignIn | null = null;
|
||||
try {
|
||||
pending = JSON.parse(decrypt(req.cookies[OIDC_COOKIE] ?? '')) as PendingSignIn;
|
||||
} catch {
|
||||
// missing or not ours: `expired` below
|
||||
}
|
||||
reply.clearCookie(OIDC_COOKIE, { path: COOKIE_PATH });
|
||||
const connecting = pending?.connectSessionId ?? null;
|
||||
|
||||
const fail = async (code: string, reason: string, userId?: string) => {
|
||||
if (!connecting) recordLoginFailure(req.ip, null);
|
||||
req.log.warn({ event: 'auth.failed', ip: req.ip, reason }, 'OpenID sign-in failed');
|
||||
await audit(db, {
|
||||
actorUserId: userId,
|
||||
actorIp: req.ip,
|
||||
action: 'login_failed',
|
||||
entityType: 'user',
|
||||
entityId: userId,
|
||||
summary: `Failed ${s.label} sign-in (${code}: ${reason.slice(0, 200)})`,
|
||||
});
|
||||
return reply.redirect(withParam(connecting ? pending!.next : '/login', 'sso_error', code));
|
||||
};
|
||||
|
||||
if (!connecting && isLoginBlocked(req.ip, null)) return fail('too_many', 'rate limited');
|
||||
if (!pending || pending.exp < Date.now()) return fail('expired', 'no sign-in in progress');
|
||||
const q = req.query as Record<string, unknown>;
|
||||
if (typeof q.error === 'string')
|
||||
return fail(q.error === 'access_denied' ? 'denied' : 'provider', `provider: ${q.error}`);
|
||||
|
||||
let r;
|
||||
try {
|
||||
r = await finishSignIn(s, new URL(`https://${host}${req.url}`), pending);
|
||||
} catch (err) {
|
||||
return fail('provider', oidcError(err));
|
||||
}
|
||||
const d = decide(s, r.claims);
|
||||
const email = typeof r.claims.email === 'string' ? r.claims.email : null;
|
||||
if (!d.allowed) return fail('not_allowed', `${email ?? r.subject} not in the allowed groups`);
|
||||
const [owner] = await db
|
||||
.select({ userId: userIdentities.userId })
|
||||
.from(userIdentities)
|
||||
.where(and(eq(userIdentities.kind, 'oidc'), eq(userIdentities.subject, r.subject)));
|
||||
|
||||
if (connecting) {
|
||||
// Connect (12 §3.3): to the user of the session that asked, if it's still signed in, and
|
||||
// only in that browser: a planted sp_oidc can't connect someone else's account (08 §7 #8).
|
||||
const here = req.cookies[COOKIE]
|
||||
? await getSessionUser(db, req.cookies[COOKIE], false)
|
||||
: null;
|
||||
if (here?.sessionId !== connecting)
|
||||
return fail('expired', 'connect finished in another browser than the one that asked');
|
||||
const [sess] = await db
|
||||
.select({ userId: sessions.userId, email: users.email })
|
||||
.from(sessions)
|
||||
.innerJoin(users, eq(users.id, sessions.userId))
|
||||
.where(
|
||||
and(
|
||||
eq(sessions.id, connecting),
|
||||
gt(sessions.expiresAt, new Date()),
|
||||
eq(users.isActive, true),
|
||||
),
|
||||
);
|
||||
if (!sess) return fail('expired', 'the session that asked has ended');
|
||||
if (owner && owner.userId !== sess.userId)
|
||||
return fail('taken', `${email ?? r.subject} belongs to another user`, sess.userId);
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.delete(userIdentities)
|
||||
.where(and(eq(userIdentities.userId, sess.userId), eq(userIdentities.kind, 'oidc')));
|
||||
await tx.insert(userIdentities).values({
|
||||
userId: sess.userId,
|
||||
kind: 'oidc',
|
||||
subject: r.subject,
|
||||
email,
|
||||
groups: d.groups,
|
||||
});
|
||||
});
|
||||
await audit(db, {
|
||||
actorUserId: sess.userId,
|
||||
actorIp: req.ip,
|
||||
action: 'sso_connected',
|
||||
entityType: 'user',
|
||||
entityId: sess.userId,
|
||||
summary: `${sess.email} connected ${s.label} account ${email ?? r.subject}`,
|
||||
});
|
||||
return reply.redirect(withParam(pending.next, 'sso', 'connected'));
|
||||
}
|
||||
|
||||
let userId = owner?.userId;
|
||||
if (!userId) {
|
||||
// A user without a password or any connected account yet, by verified email (12 §3.3
|
||||
// step 2). Never an admin: a provider account with their email isn't them (08 §7 #2).
|
||||
const v = verifiedEmail(r.claims, s.trustEmail);
|
||||
const [u] = v
|
||||
? await db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(
|
||||
and(
|
||||
eq(users.email, v),
|
||||
isNull(users.passwordHash),
|
||||
ne(users.role, 'admin'),
|
||||
notExists(
|
||||
db
|
||||
.select({ id: userIdentities.id })
|
||||
.from(userIdentities)
|
||||
.where(eq(userIdentities.userId, users.id)),
|
||||
),
|
||||
),
|
||||
)
|
||||
: [];
|
||||
if (!u) return fail('not_connected', `${email ?? r.subject} isn't connected to a user`);
|
||||
await db
|
||||
.insert(userIdentities)
|
||||
.values({ userId: u.id, kind: 'oidc', subject: r.subject, email, groups: d.groups });
|
||||
await audit(db, {
|
||||
actorUserId: u.id,
|
||||
actorIp: req.ip,
|
||||
action: 'sso_connected',
|
||||
entityType: 'user',
|
||||
entityId: u.id,
|
||||
summary: `${v} connected ${s.label} by verified email`,
|
||||
});
|
||||
userId = u.id;
|
||||
}
|
||||
const [user] = await db.select().from(users).where(eq(users.id, userId));
|
||||
if (!user!.isActive) return fail('disabled', `${user!.email} is disabled`, userId);
|
||||
|
||||
await applyClaims(db, req.log, s, userId, d, email, req.ip);
|
||||
await db
|
||||
.update(userIdentities)
|
||||
.set({ lastUsedAt: new Date() })
|
||||
.where(and(eq(userIdentities.userId, userId), eq(userIdentities.kind, 'oidc')));
|
||||
const token = await createSession(
|
||||
db,
|
||||
userId,
|
||||
req.ip,
|
||||
req.headers['user-agent'] ?? null,
|
||||
'oidc',
|
||||
{ refreshToken: r.refreshToken, idToken: r.idToken },
|
||||
);
|
||||
setSessionCookie(reply, token);
|
||||
await db.update(users).set({ lastLoginAt: new Date() }).where(eq(users.id, userId));
|
||||
await audit(db, {
|
||||
actorUserId: userId,
|
||||
actorIp: req.ip,
|
||||
action: 'login',
|
||||
entityType: 'user',
|
||||
entityId: userId,
|
||||
summary: `${user!.email} signed in with ${s.label}`,
|
||||
});
|
||||
req.log.info({ event: 'auth.oidc', userId }, 'OpenID sign-in');
|
||||
return reply.redirect(pending.next);
|
||||
});
|
||||
|
||||
/**
|
||||
* Disconnect an OpenID or directory account (12 §3.3, §4.3): refused when it would leave
|
||||
* them no way in under the current mode. Sessions made with it end.
|
||||
*/
|
||||
async function disconnect(
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
userId: string,
|
||||
kind: 'oidc' | 'ldap',
|
||||
) {
|
||||
const [u] = await db.select().from(users).where(eq(users.id, userId));
|
||||
if (!u) throw notFound('User not found');
|
||||
const ids = await db
|
||||
.select({ kind: userIdentities.kind })
|
||||
.from(userIdentities)
|
||||
.where(eq(userIdentities.userId, userId));
|
||||
if (!ids.some((i) => i.kind === kind)) throw notFound('No such account is connected');
|
||||
const left = ids.filter((i) => i.kind !== kind);
|
||||
const mode = await getSetting(db, 'auth.mode');
|
||||
const password = u.passwordHash !== null || left.some((i) => i.kind === 'ldap');
|
||||
const openid = mode !== 'password' && left.some((i) => i.kind === 'oidc');
|
||||
if (!openid && !(password && (mode !== 'oidc' || u.role === 'admin')))
|
||||
throw new HttpError(409, 'last_way_in', 'That would leave no way to sign in');
|
||||
await db
|
||||
.delete(userIdentities)
|
||||
.where(and(eq(userIdentities.userId, userId), eq(userIdentities.kind, kind)));
|
||||
await db.delete(sessions).where(and(eq(sessions.userId, userId), eq(sessions.method, kind)));
|
||||
const what = kind === 'oidc' ? 'OpenID' : 'directory';
|
||||
await audit(db, {
|
||||
actorUserId: req.user!.id,
|
||||
actorIp: req.ip,
|
||||
action: 'sso_disconnected',
|
||||
entityType: 'user',
|
||||
entityId: userId,
|
||||
summary: `Disconnected the ${what} account of ${u.email}`,
|
||||
});
|
||||
return reply.send({ ok: true });
|
||||
}
|
||||
|
||||
const Kind = z.object({ kind: z.enum(['oidc', 'ldap']) });
|
||||
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');
|
||||
}
|
||||
const r = await connectDirectory(db, req.log, req.user!.id, entry, req.ip);
|
||||
if (r === 'taken')
|
||||
throw new HttpError(409, 'taken', 'That directory account belongs to another user');
|
||||
if (r === 'connected_other')
|
||||
throw new HttpError(
|
||||
409,
|
||||
'connected_other',
|
||||
'Another directory account is connected. Ask an admin to disconnect it first',
|
||||
);
|
||||
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)
|
||||
|
||||
@@ -13,6 +13,7 @@ import { callExtensions, calls, extensions, recordings } from '../db/schema.ts';
|
||||
import { badRequest, forbidden, notFound } from '../errors.ts';
|
||||
import { audit } from '../services/audit.ts';
|
||||
import { sideFiles } from '../ai/calls.ts';
|
||||
import { ON_THE_CALL } from '../services/cdr.ts';
|
||||
|
||||
/** The recording and, when call transcripts left them, its two side files. */
|
||||
const removeFiles = async (dir: string, filePath: string) => {
|
||||
@@ -109,7 +110,10 @@ export async function recordingRoutes(app: FastifyInstance) {
|
||||
},
|
||||
);
|
||||
|
||||
/** Admins, or a participant: their extension is on the call this recording belongs to. */
|
||||
/**
|
||||
* Admins, or who was on the call: their extension made or answered it. Not a phone that only
|
||||
* rang (a ring group, a queue): that's a coworker's conversation (08 §7 #3).
|
||||
*/
|
||||
async function mayHear(user: { id: string; role: string }, recordingId: string) {
|
||||
if (user.role === 'admin') return;
|
||||
const [mine] = await db
|
||||
@@ -117,7 +121,13 @@ export async function recordingRoutes(app: FastifyInstance) {
|
||||
.from(calls)
|
||||
.innerJoin(callExtensions, eq(callExtensions.callId, calls.id))
|
||||
.innerJoin(extensions, eq(extensions.id, callExtensions.extensionId))
|
||||
.where(and(eq(calls.recordingId, recordingId), eq(extensions.userId, user.id)))
|
||||
.where(
|
||||
and(
|
||||
eq(calls.recordingId, recordingId),
|
||||
eq(extensions.userId, user.id),
|
||||
inArray(callExtensions.role, ON_THE_CALL),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!mine) throw forbidden('Not your call');
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// /api/reports (04 §7.3, 05 §2 Reports): calls per day, busiest hours, per-queue stats. Admin only.
|
||||
import { and, asc, eq, sql } from 'drizzle-orm';
|
||||
// /api/reports (04 §7.3, 05 §2 Reports): calls per day, busiest hours, per-queue stats. Admin only,
|
||||
// but /api/me/reports/summary: the same summary of the calls made to me.
|
||||
import { and, asc, eq, inArray, ne, sql } from 'drizzle-orm';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import {
|
||||
CALL_DIRECTIONS,
|
||||
@@ -10,12 +11,12 @@ import {
|
||||
type ProviderBalanceDto,
|
||||
type ReportSummary,
|
||||
} from '@selfphoneng/shared';
|
||||
import { requireAdmin } from '../auth/guards.ts';
|
||||
import { requireAdmin, requireAuth } from '../auth/guards.ts';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { decrypt } from '../crypto/secrets.ts';
|
||||
import { calls, queues, trunks } from '../db/schema.ts';
|
||||
import { callExtensions, calls, extensions, queues, trunks } from '../db/schema.ts';
|
||||
import { getBalance, VoipmsError } from '../integrations/voipms/client.ts';
|
||||
import { badRequest } from '../errors.ts';
|
||||
import { badRequest, notFound } from '../errors.ts';
|
||||
import { todayIn } from '../generator/load.ts';
|
||||
import { queueStats } from '../services/queuestats.ts';
|
||||
import { getSetting } from '../services/settings.ts';
|
||||
@@ -35,14 +36,28 @@ async function range(db: Db, query: unknown) {
|
||||
|
||||
export async function reportRoutes(app: FastifyInstance) {
|
||||
const { db } = app;
|
||||
app.addHook('preHandler', requireAdmin);
|
||||
const admin = { preHandler: requireAdmin };
|
||||
|
||||
app.get('/api/reports/summary', async (req): Promise<ReportSummary> => {
|
||||
const { tz, from, to } = await range(db, req.query);
|
||||
/** `toExt`: only the calls that rang or were answered by that extension, not the ones it made. */
|
||||
async function summary(query: unknown, toExt?: string): Promise<ReportSummary> {
|
||||
const { tz, from, to } = await range(db, query);
|
||||
const where = and(
|
||||
sql`${calls.startedAt} >= (${from}::date)::timestamp at time zone ${tz}`,
|
||||
sql`${calls.startedAt} < ((${to}::date + 1)::timestamp at time zone ${tz})`,
|
||||
toExt
|
||||
? inArray(
|
||||
calls.id,
|
||||
db
|
||||
.select({ id: callExtensions.callId })
|
||||
.from(callExtensions)
|
||||
.where(and(eq(callExtensions.extensionId, toExt), ne(callExtensions.role, 'caller'))),
|
||||
)
|
||||
: undefined,
|
||||
);
|
||||
// Mine: answered and talk time are the calls I picked up, not a coworker in my ring group.
|
||||
const answered = toExt
|
||||
? sql`${calls.answeredByExtensionId} = ${toExt}`
|
||||
: sql`${calls.status} = 'answered'`;
|
||||
const local = sql`(${calls.startedAt} at time zone ${tz})`;
|
||||
const missed = sql`${calls.status} in ('missed', 'voicemail', 'abandoned')`;
|
||||
// Outside line time: inbound from arrival (a receptionist or voicemail answers too),
|
||||
@@ -73,14 +88,14 @@ export async function reportRoutes(app: FastifyInstance) {
|
||||
db
|
||||
.select({
|
||||
calls: sql<number>`count(*)::int`,
|
||||
answered: sql<number>`count(*) filter (where ${calls.status} = 'answered')::int`,
|
||||
answered: sql<number>`count(*) filter (where ${answered})::int`,
|
||||
missed: sql<number>`count(*) filter (where ${missed})::int`,
|
||||
talk: sql<number>`coalesce(sum(${calls.talkSeconds}), 0)::int`,
|
||||
talk: sql<number>`coalesce(sum(${calls.talkSeconds}) filter (where ${toExt ? answered : sql`true`}), 0)::int`,
|
||||
lineIn: sql<number>`round(${lineIn})::int`,
|
||||
lineOut: sql<number>`${lineOut}::int`,
|
||||
avgTalk: sql<
|
||||
number | null
|
||||
>`round(avg(${calls.talkSeconds}) filter (where ${calls.status} = 'answered'))::int`,
|
||||
>`round(avg(${calls.talkSeconds}) filter (where ${answered}))::int`,
|
||||
})
|
||||
.from(calls)
|
||||
.where(where),
|
||||
@@ -113,10 +128,21 @@ export async function reportRoutes(app: FastifyInstance) {
|
||||
},
|
||||
hours,
|
||||
};
|
||||
}
|
||||
|
||||
app.get('/api/reports/summary', admin, (req) => summary(req.query));
|
||||
|
||||
app.get('/api/me/reports/summary', { preHandler: requireAuth }, async (req) => {
|
||||
const [ext] = await db
|
||||
.select({ id: extensions.id })
|
||||
.from(extensions)
|
||||
.where(eq(extensions.userId, req.user!.id));
|
||||
if (!ext) throw notFound("You don't have an extension yet. Ask your admin.");
|
||||
return summary(req.query, ext.id);
|
||||
});
|
||||
|
||||
/** Each voip.ms trunk's account balance (owner, 2026-10-21); a trunk without API login says so. */
|
||||
app.get('/api/reports/balance', async (): Promise<ListResponse<ProviderBalanceDto>> => {
|
||||
app.get('/api/reports/balance', admin, async (): Promise<ListResponse<ProviderBalanceDto>> => {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(trunks)
|
||||
@@ -150,7 +176,7 @@ export async function reportRoutes(app: FastifyInstance) {
|
||||
return { items, nextCursor: null };
|
||||
});
|
||||
|
||||
app.get('/api/reports/queues', async (req): Promise<ListResponse<QueueReportRow>> => {
|
||||
app.get('/api/reports/queues', admin, async (req): Promise<ListResponse<QueueReportRow>> => {
|
||||
const { from, to } = await range(db, req.query);
|
||||
const qs = await db.select().from(queues).orderBy(asc(queues.number));
|
||||
return {
|
||||
|
||||
+319
-11
@@ -1,25 +1,36 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// /api/settings (04 §7.3) and the dashboard warnings. Admin only.
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { eq, inArray, isNotNull } from 'drizzle-orm';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
AiEndpointSchema,
|
||||
AiTtsEndpointSchema,
|
||||
ContrackInputSchema,
|
||||
LdapInputSchema,
|
||||
OidcInputSchema,
|
||||
SettingsSchemas,
|
||||
SmtpInputSchema,
|
||||
SpamInputSchema,
|
||||
type AiTestResult,
|
||||
type AuthMode,
|
||||
type Destination,
|
||||
type LdapTestResult,
|
||||
type OidcInput,
|
||||
type OidcTestResult,
|
||||
type SettingKey,
|
||||
type SettingsDto,
|
||||
type SpamInput,
|
||||
type SpamSettings,
|
||||
type SpamTestResult,
|
||||
type Warning,
|
||||
} from '@selfphoneng/shared';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { dids, extensions, trunks } from '../db/schema.ts';
|
||||
import { dids, extensions, trunks, users } from '../db/schema.ts';
|
||||
import { HttpError, badRequest } from '../errors.ts';
|
||||
import { requireAdmin } from '../auth/guards.ts';
|
||||
import { encrypt } from '../crypto/secrets.ts';
|
||||
import { SLOW, requireAdmin } from '../auth/guards.ts';
|
||||
import { decrypt, encrypt } from '../crypto/secrets.ts';
|
||||
import { audit } from '../services/audit.ts';
|
||||
import { assertDestinationExists } from '../services/destinations.ts';
|
||||
import { getAllSettings, getSetting, setSetting } from '../services/settings.ts';
|
||||
@@ -30,6 +41,13 @@ import { detectPublicIp } from '../jobs.ts';
|
||||
import type { SendMail } from '../services/mail.ts';
|
||||
import { AiError, assertLocalUrl, testEndpoint } from '../ai/client.ts';
|
||||
import { forgetBook } from '../contacts/contrack.ts';
|
||||
import { assertLdap, testLdap, type LdapSettings } from '../sso/ldap.ts';
|
||||
import { directoryUserFor } from '../sso/directory.ts';
|
||||
import { assertIssuer, oidcConfig, oidcError, testOidc, type OidcSettings } from '../sso/oidc.ts';
|
||||
import { ftcTest } from '../spam/ftc.ts';
|
||||
import { SpamError } from '../spam/http.ts';
|
||||
import { ipqsCredits } from '../spam/ipqs.ts';
|
||||
import { patchSpamState, resumeIpqs } from '../spam/check.ts';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
@@ -42,14 +60,60 @@ declare module 'fastify' {
|
||||
const READ_ONLY = new Set<string>([
|
||||
'system.restartRequired',
|
||||
'system.transportHash',
|
||||
'system.directoryWarning',
|
||||
'spam.state',
|
||||
'sms.webhookToken',
|
||||
'setup.completed',
|
||||
'network.probeKeyEnc',
|
||||
]);
|
||||
|
||||
async function toDto(db: Db): Promise<SettingsDto> {
|
||||
const { smtp, 'contacts.contrack': ctk, ...rest } = await getAllSettings(db);
|
||||
const {
|
||||
smtp,
|
||||
'contacts.contrack': ctk,
|
||||
'auth.oidc': oidc,
|
||||
'auth.ldap': ldap,
|
||||
spam,
|
||||
'network.probeKeyEnc': probeKey,
|
||||
...rest
|
||||
} = await getAllSettings(db);
|
||||
return {
|
||||
...rest,
|
||||
'network.hasProbeKey': probeKey !== null,
|
||||
spam: {
|
||||
...spam,
|
||||
ipqs: spam.ipqs && { hasKey: true },
|
||||
ftc: spam.ftc && {
|
||||
hasKey: true,
|
||||
syncHours: spam.ftc.syncHours,
|
||||
windowDays: spam.ftc.windowDays,
|
||||
},
|
||||
},
|
||||
'auth.ldap': ldap && {
|
||||
enabled: ldap.enabled,
|
||||
preset: ldap.preset,
|
||||
url: ldap.url,
|
||||
startTls: ldap.startTls,
|
||||
bindDn: ldap.bindDn,
|
||||
baseDn: ldap.baseDn,
|
||||
userFilter: ldap.userFilter,
|
||||
idAttribute: ldap.idAttribute,
|
||||
emailAttribute: ldap.emailAttribute,
|
||||
nameAttribute: ldap.nameAttribute,
|
||||
adminFilter: ldap.adminFilter,
|
||||
hasBindPassword: ldap.bindPasswordEnc !== null,
|
||||
},
|
||||
'auth.oidc': oidc && {
|
||||
label: oidc.label,
|
||||
issuer: oidc.issuer,
|
||||
clientId: oidc.clientId,
|
||||
scopes: oidc.scopes,
|
||||
groupsClaim: oidc.groupsClaim,
|
||||
adminGroups: oidc.adminGroups,
|
||||
allowedGroups: oidc.allowedGroups,
|
||||
trustEmail: oidc.trustEmail,
|
||||
hasClientSecret: oidc.clientSecretEnc !== null,
|
||||
},
|
||||
'contacts.contrack': ctk && {
|
||||
url: ctk.url,
|
||||
publicUrl: ctk.publicUrl,
|
||||
@@ -100,6 +164,96 @@ async function checkRefs(db: Db, v: Record<string, unknown>) {
|
||||
|
||||
export async function settingsRoutes(app: FastifyInstance) {
|
||||
const { db } = app;
|
||||
|
||||
/** The stored form of `auth.ldap`: the bind password is write-only (empty keeps it). */
|
||||
async function ldapFromInput(raw: unknown): Promise<LdapSettings | null> {
|
||||
if (raw === null) return null;
|
||||
const v = LdapInputSchema.parse(raw);
|
||||
try {
|
||||
assertLdap(v);
|
||||
} catch (err) {
|
||||
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,
|
||||
startTls: v.startTls,
|
||||
bindDn: v.bindDn,
|
||||
bindPasswordEnc: v.bindPassword
|
||||
? encrypt(v.bindPassword)
|
||||
: v.clearBindPassword || !same
|
||||
? null
|
||||
: (old?.bindPasswordEnc ?? null),
|
||||
baseDn: v.baseDn,
|
||||
userFilter: v.userFilter,
|
||||
idAttribute: v.idAttribute,
|
||||
emailAttribute: v.emailAttribute,
|
||||
nameAttribute: v.nameAttribute,
|
||||
adminFilter: v.adminFilter,
|
||||
};
|
||||
}
|
||||
|
||||
/** The stored form of `auth.oidc`: the secret is write-only (empty keeps the stored one). */
|
||||
async function oidcFromInput(input: OidcInput | null): Promise<OidcSettings | null> {
|
||||
if (!input) return null;
|
||||
try {
|
||||
assertIssuer(input.issuer);
|
||||
} catch (err) {
|
||||
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,
|
||||
clientId: input.clientId,
|
||||
clientSecretEnc: input.clientSecret
|
||||
? encrypt(input.clientSecret)
|
||||
: input.clearClientSecret
|
||||
? null
|
||||
: same
|
||||
? (old.clientSecretEnc ?? null)
|
||||
: null,
|
||||
scopes: input.scopes,
|
||||
groupsClaim: input.groupsClaim,
|
||||
adminGroups: input.adminGroups,
|
||||
allowedGroups: input.allowedGroups,
|
||||
trustEmail: input.trustEmail,
|
||||
};
|
||||
}
|
||||
/** The stored form of `spam` (14 §7): keys are write-only (empty keeps the stored one). */
|
||||
async function spamFromInput(input: SpamInput): Promise<SpamSettings> {
|
||||
const v = SpamInputSchema.parse(input);
|
||||
const old = await getSetting(db, 'spam');
|
||||
const keyEnc = (typed: string | undefined, stored: string | undefined, name: string) => {
|
||||
if (typed) return encrypt(typed);
|
||||
if (stored) return stored;
|
||||
throw badRequest(`Enter the ${name} key`);
|
||||
};
|
||||
const ipqs = v.ipqs && {
|
||||
keyEnc: keyEnc(v.ipqs.key, old.ipqs?.keyEnc, 'IPQualityScore'),
|
||||
};
|
||||
const ftc = v.ftc && {
|
||||
keyEnc: keyEnc(v.ftc.key, old.ftc?.keyEnc, 'api.data.gov'),
|
||||
syncHours: v.ftc.syncHours,
|
||||
windowDays: v.ftc.windowDays,
|
||||
};
|
||||
if (v.enabled && !ipqs && !ftc)
|
||||
throw badRequest('Set up IPQualityScore or the FTC list to turn spam check on');
|
||||
return { ...v, ipqs, ftc };
|
||||
}
|
||||
|
||||
app.addHook('preHandler', requireAdmin);
|
||||
|
||||
app.get('/api/settings', () => toDto(db));
|
||||
@@ -107,6 +261,7 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
app.patch('/api/settings', async (req) => {
|
||||
const body = z.record(z.string(), z.unknown()).parse(req.body);
|
||||
const values: Record<string, unknown> = {};
|
||||
let contrackMoved = false;
|
||||
for (const [key, raw] of Object.entries(body)) {
|
||||
if (!(key in SettingsSchemas) || READ_ONLY.has(key))
|
||||
throw badRequest(`${key} can't be changed here`);
|
||||
@@ -120,7 +275,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).
|
||||
@@ -132,8 +292,13 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
} catch (err) {
|
||||
throw badRequest(`Contrack address: ${(err as Error).message}`);
|
||||
}
|
||||
// The saved tokens only go back to the address they were typed for (08 §7 #7).
|
||||
const url = input?.url.replace(/\/+$/, '');
|
||||
const moved = !!old && !!input && old.url !== url;
|
||||
if (moved && old.companyTokenEnc && !input.companyToken && !input.clearCompanyToken)
|
||||
throw badRequest('The Contrack address changed: enter the company token again');
|
||||
values[key] = input && {
|
||||
url: input.url.replace(/\/+$/, ''),
|
||||
url: url!,
|
||||
publicUrl: input.publicUrl,
|
||||
companyTokenEnc: input.companyToken
|
||||
? encrypt(input.companyToken)
|
||||
@@ -141,7 +306,14 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
? ''
|
||||
: (old?.companyTokenEnc ?? ''),
|
||||
};
|
||||
if (moved) contrackMoved = true;
|
||||
forgetBook(null);
|
||||
} else if (key === 'auth.ldap') {
|
||||
values[key] = await ldapFromInput(raw);
|
||||
} else if (key === 'spam') {
|
||||
values[key] = await spamFromInput(raw as SpamInput);
|
||||
} else if (key === 'auth.oidc') {
|
||||
values[key] = await oidcFromInput(raw === null ? null : OidcInputSchema.parse(raw));
|
||||
} else values[key] = SettingsSchemas[key as SettingKey].parse(raw);
|
||||
}
|
||||
await checkRefs(db, values);
|
||||
@@ -155,6 +327,21 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
}
|
||||
}
|
||||
const before = await getAllSettings(db);
|
||||
// 12 §1.1: mixed and oidc need a working OpenID Connect setup.
|
||||
if ('auth.mode' in values || 'auth.oidc' in values) {
|
||||
const mode = (values['auth.mode'] ?? before['auth.mode']) as AuthMode;
|
||||
const oidc = (
|
||||
'auth.oidc' in values ? values['auth.oidc'] : before['auth.oidc']
|
||||
) as OidcSettings | null;
|
||||
if (mode !== 'password') {
|
||||
if (!oidc) throw badRequest('Set up OpenID Connect first');
|
||||
try {
|
||||
await oidcConfig(oidc);
|
||||
} catch (err) {
|
||||
throw badRequest(`OpenID Connect doesn't work: ${oidcError(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
// SIP ports may not land on another listener (Asterisk would fail to start the transport).
|
||||
const c = app.config;
|
||||
const [sip, tls] = [
|
||||
@@ -173,8 +360,19 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
const cert = await readTlsCert(app.config.TLS_DIR);
|
||||
if (!cert.ok) throw badRequest(`Can't turn on TLS. ${cert.error}`);
|
||||
}
|
||||
// The probe's key was made for its address: another probe doesn't get it (08 §7 #9).
|
||||
if ('network.probeUrl' in values && values['network.probeUrl'] !== before['network.probeUrl'])
|
||||
values['network.probeKeyEnc'] = null;
|
||||
for (const [key, value] of Object.entries(values))
|
||||
await setSetting(db, key as SettingKey, value as never);
|
||||
// People's own Contrack tokens were for the old address: they connect again (08 §7 #7).
|
||||
if (contrackMoved)
|
||||
await db
|
||||
.update(users)
|
||||
.set({ contrackTokenEnc: null })
|
||||
.where(isNotNull(users.contrackTokenEnc));
|
||||
// A new IPQualityScore key may have credits: don't wait for spamQuotaCheck (14 §3).
|
||||
if ((body.spam as SpamInput | undefined)?.ipqs?.key) await resumeIpqs(db, 'a new key');
|
||||
const changed = Object.keys(values).filter(
|
||||
(k) => JSON.stringify(before[k as keyof typeof before]) !== JSON.stringify(values[k]),
|
||||
);
|
||||
@@ -195,7 +393,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({
|
||||
@@ -210,7 +408,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 },
|
||||
@@ -232,8 +430,91 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
return { notified };
|
||||
});
|
||||
|
||||
/**
|
||||
* 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',
|
||||
{ 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',
|
||||
{ config: SLOW },
|
||||
async (req): Promise<OidcTestResult> => {
|
||||
const s = await oidcFromInput(OidcInputSchema.parse(req.body));
|
||||
return testOidc(s!, app.config.PUBLIC_HOSTNAME);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Settings → Spam check → Test (14 §7.1), with the typed key or else the saved one. IPQualityScore
|
||||
* answers with its credits left, which spends none.
|
||||
*/
|
||||
for (const provider of ['ipqs', 'ftc'] as const)
|
||||
app.post(
|
||||
`/api/settings/spam/${provider}/test`,
|
||||
{ config: SLOW },
|
||||
async (req): Promise<SpamTestResult> => {
|
||||
const { key } = z
|
||||
.object({ key: z.string().trim().max(200).optional() })
|
||||
.parse(req.body ?? {});
|
||||
const keyEnc = (await getSetting(db, 'spam'))[provider]?.keyEnc;
|
||||
const k = key || (keyEnc && decrypt(keyEnc));
|
||||
if (!k) return { ok: false, error: 'Enter the key first' };
|
||||
try {
|
||||
if (provider === 'ftc') return { ok: true, detail: await ftcTest(k) };
|
||||
const credits = await ipqsCredits(k);
|
||||
return { ok: true, detail: `Works: ${credits} credits left this billing period.` };
|
||||
} catch (err) {
|
||||
if (err instanceof SpamError) return { ok: false, error: err.message };
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* A new key for the network test probe (08 §7 #9): shown this once, for its PROBE_KEY. Made for
|
||||
* the probe address set now; the old key stops working on the next test.
|
||||
*/
|
||||
app.post('/api/settings/network/probe-key', async (req) => {
|
||||
if (!(await getSetting(db, 'network.probeUrl')))
|
||||
throw badRequest('Set the probe address first');
|
||||
const key = randomBytes(32).toString('base64url');
|
||||
await setSetting(db, 'network.probeKeyEnc', encrypt(key));
|
||||
await audit(db, {
|
||||
actorUserId: req.user!.id,
|
||||
actorIp: req.ip,
|
||||
action: 'update',
|
||||
entityType: 'settings',
|
||||
summary: 'Made a new network test probe key',
|
||||
});
|
||||
return { key };
|
||||
});
|
||||
|
||||
/** 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.");
|
||||
@@ -255,7 +536,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,
|
||||
@@ -311,6 +592,20 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
message: "Today's backup hasn't been made. Check the api log, or press Back up now.",
|
||||
link: '/admin/settings/backups',
|
||||
});
|
||||
if (s['system.directoryWarning'])
|
||||
items.push({
|
||||
code: 'directory_check',
|
||||
message: s['system.directoryWarning'],
|
||||
link: '/admin/settings/sign-in',
|
||||
});
|
||||
const st = s['spam.state'];
|
||||
if (s.spam.enabled && s.spam.ipqs && st.ipqsPausedAt && !st.warningDismissedAt)
|
||||
items.push({
|
||||
code: 'spam_quota',
|
||||
message: `IPQualityScore's lookups are used up. Spam checks ${s.spam.ftc ? 'use the FTC list' : 'are off'} until they come back.`,
|
||||
link: '/admin/settings/spam',
|
||||
dismissable: true,
|
||||
});
|
||||
if (!s.smtp)
|
||||
items.push({
|
||||
code: 'smtp_missing',
|
||||
@@ -319,4 +614,17 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
});
|
||||
return { items };
|
||||
});
|
||||
|
||||
/** Settings → Spam check → Download now (14 §2.2): due at the next minute, unless under way. */
|
||||
app.post('/api/settings/spam/ftc/sync', async () => {
|
||||
if (!(await getSetting(db, 'spam')).ftc) throw badRequest('Set up the FTC list first');
|
||||
await patchSpamState(db, { ftcLastSyncAt: null });
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
/** Hidden until the next time the credits run out (14 §3). */
|
||||
app.post('/api/system/warnings/spam_quota/dismiss', async () => {
|
||||
await patchSpamState(db, { warningDismissedAt: new Date().toISOString() });
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
@@ -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,27 +202,24 @@ 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');
|
||||
if (!app.live.amiConnected)
|
||||
throw new HttpError(503, 'unavailable', 'Asterisk is not connected');
|
||||
// No OPTIONS over TLS (see the trunk's aor in generator/pjsip.ts).
|
||||
const { live, rttMs } = await app.live.testTrunk(id, { qualify: t.transport !== 'tls' });
|
||||
const { live, rttMs } = await app.live.testTrunk(id);
|
||||
const reg = t.authMode === 'registration';
|
||||
const detail =
|
||||
reg && live.registration === 'registered'
|
||||
? `Registered with ${t.serverHost}`
|
||||
: reg && live.registration === 'rejected'
|
||||
? `${t.serverHost} rejected the username or password`
|
||||
: !reg && t.transport === 'tls'
|
||||
? `Can't check a TLS trunk without registration from here: make a test call.`
|
||||
: !live.reachable
|
||||
? `${t.serverHost} doesn't answer. Check the server name and that this server can reach it on port ${t.serverPort}.`
|
||||
: reg
|
||||
? `${t.serverHost} answers but the registration hasn't completed yet. Try again in a minute.`
|
||||
: `${t.serverHost} answers (IP authentication: no registration)`;
|
||||
: !live.reachable
|
||||
? `${t.serverHost} doesn't answer. Check the server name and that this server can reach it on port ${t.serverPort}.`
|
||||
: reg
|
||||
? `${t.serverHost} answers but the registration hasn't completed yet. Try again in a minute.`
|
||||
: `${t.serverHost} answers (IP authentication: no registration)`;
|
||||
return { registration: reg ? live.registration : 'unknown', rttMs, detail };
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// /api/users (04 §7.3). Admin only.
|
||||
import { and, asc, count, eq, ne } from 'drizzle-orm';
|
||||
import { and, asc, count, eq, isNotNull, ne } from 'drizzle-orm';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
@@ -10,17 +10,30 @@ import {
|
||||
type UserDto,
|
||||
} from '@selfphoneng/shared';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { extensions, users } from '../db/schema.ts';
|
||||
import { badRequest, conflict, notFound } from '../errors.ts';
|
||||
import { extensions, userIdentities, users } from '../db/schema.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';
|
||||
import { randomAlnum } from '../crypto/secrets.ts';
|
||||
import { audit } from '../services/audit.ts';
|
||||
import { getSetting } from '../services/settings.ts';
|
||||
import { signInWays } from './auth.ts';
|
||||
|
||||
type UserRow = typeof users.$inferSelect;
|
||||
type IdentityRow = {
|
||||
userId: string;
|
||||
kind: 'oidc' | 'ldap';
|
||||
email: string | null;
|
||||
groups: string[];
|
||||
};
|
||||
|
||||
export const toUserDto = (u: UserRow, extension: UserDto['extension'] = null): UserDto => ({
|
||||
export const toUserDto = (
|
||||
u: UserRow,
|
||||
extension: UserDto['extension'] = null,
|
||||
ids: IdentityRow[] = [],
|
||||
from: RoleSources = { oidc: null, ldap: false },
|
||||
): UserDto => ({
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
displayName: u.displayName,
|
||||
@@ -31,8 +44,39 @@ export const toUserDto = (u: UserRow, extension: UserDto['extension'] = null): U
|
||||
lastLoginAt: u.lastLoginAt?.toISOString() ?? null,
|
||||
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
|
||||
.select({
|
||||
userId: userIdentities.userId,
|
||||
kind: userIdentities.kind,
|
||||
email: userIdentities.email,
|
||||
groups: userIdentities.groups,
|
||||
})
|
||||
.from(userIdentities)
|
||||
.where(userId ? eq(userIdentities.userId, userId) : undefined);
|
||||
|
||||
const extOf = async (db: Db, userId: string) => {
|
||||
const [e] = await db
|
||||
.select({ id: extensions.id, number: extensions.number })
|
||||
@@ -56,13 +100,27 @@ async function getUser(db: Db, id: string): Promise<UserRow> {
|
||||
return u;
|
||||
}
|
||||
|
||||
/** Throws if removing `id` from the active admins would leave none. */
|
||||
async function assertNotLastAdmin(db: Db, id: string) {
|
||||
/**
|
||||
* Removing `id` from the active admins would leave none with a password: the way in when the
|
||||
* identity provider is down (12 §1).
|
||||
*/
|
||||
export async function isLastAdmin(db: Db, id: string): Promise<boolean> {
|
||||
const [r] = await db
|
||||
.select({ n: count() })
|
||||
.from(users)
|
||||
.where(and(eq(users.role, 'admin'), eq(users.isActive, true), ne(users.id, id)));
|
||||
if (!r || r.n === 0) throw conflict('This is the last active admin');
|
||||
.where(
|
||||
and(
|
||||
eq(users.role, 'admin'),
|
||||
eq(users.isActive, true),
|
||||
isNotNull(users.passwordHash),
|
||||
ne(users.id, id),
|
||||
),
|
||||
);
|
||||
return !r || r.n === 0;
|
||||
}
|
||||
|
||||
async function assertNotLastAdmin(db: Db, id: string) {
|
||||
if (await isLastAdmin(db, id)) throw conflict('This is the last active admin with a password');
|
||||
}
|
||||
|
||||
export async function userRoutes(app: FastifyInstance) {
|
||||
@@ -76,9 +134,16 @@ export async function userRoutes(app: FastifyInstance) {
|
||||
.from(users)
|
||||
.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),
|
||||
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,
|
||||
};
|
||||
@@ -86,17 +151,27 @@ 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));
|
||||
return toUserDto(
|
||||
await getUser(db, id),
|
||||
await extOf(db, id),
|
||||
await identitiesOf(db, id),
|
||||
await roleSources(db),
|
||||
);
|
||||
});
|
||||
|
||||
app.post('/api/users', async (req, reply) => {
|
||||
const body = UserCreateSchema.parse(req.body);
|
||||
const password = temporaryPassword();
|
||||
// OpenID only (12 §3.3): no password; their first sign-in matches them by verified email.
|
||||
const password = (await getSetting(db, 'auth.mode')) === 'oidc' ? null : temporaryPassword();
|
||||
let created: UserRow;
|
||||
try {
|
||||
[created] = (await db
|
||||
.insert(users)
|
||||
.values({ ...body, passwordHash: await hashPassword(password), mustChangePassword: true })
|
||||
.values({
|
||||
...body,
|
||||
passwordHash: password && (await hashPassword(password)),
|
||||
mustChangePassword: password !== null,
|
||||
})
|
||||
.returning()) as [UserRow];
|
||||
} catch (e) {
|
||||
if (isUniqueViolation(e)) throw conflict('A user with this email already exists');
|
||||
@@ -120,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 &&
|
||||
@@ -150,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));
|
||||
return toUserDto(after, await extOf(db, id), ids, from);
|
||||
});
|
||||
|
||||
app.delete('/api/users/:id', async (req) => {
|
||||
|
||||
@@ -138,6 +138,9 @@ export async function sendCallReports(d: {
|
||||
aiDoneAt: recordings.aiDoneAt,
|
||||
out: sql<boolean>`exists (select 1 from ${callExtensions} where ${callExtensions.callId} = ${calls.id}
|
||||
and ${callExtensions.extensionId} = ${e.id} and ${callExtensions.role} = 'caller')`,
|
||||
// Made or answered it: only rang means a coworker's conversation (08 §7 #3).
|
||||
heard: sql<boolean>`exists (select 1 from ${callExtensions} where ${callExtensions.callId} = ${calls.id}
|
||||
and ${callExtensions.extensionId} = ${e.id} and ${callExtensions.role} in ('caller', 'answered'))`,
|
||||
})
|
||||
.from(calls)
|
||||
.leftJoin(answerer, eq(answerer.id, calls.answeredByExtensionId))
|
||||
@@ -177,9 +180,9 @@ export async function sendCallReports(d: {
|
||||
toNumber: r.c.toNumber,
|
||||
talkSeconds: r.c.talkSeconds,
|
||||
answeredBy: r.answeredById && r.answeredById !== e.id ? r.answeredBy : null,
|
||||
recorded: r.c.recordingId !== null,
|
||||
summary: r.summary,
|
||||
summaryPending: r.c.recordingId !== null && r.aiDoneAt === null,
|
||||
recorded: r.heard && r.c.recordingId !== null,
|
||||
summary: r.heard ? r.summary : null,
|
||||
summaryPending: r.heard && r.c.recordingId !== null && r.aiDoneAt === null,
|
||||
}));
|
||||
const withSummaries =
|
||||
e.callReportSummaries &&
|
||||
|
||||
@@ -9,10 +9,17 @@ import { join } from 'node:path';
|
||||
import { and, asc, eq, inArray, isNull, lt } from 'drizzle-orm';
|
||||
import { field, type AmiMessage } from '../ami/events.ts';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { checkNumber, usNumber, type SpamDeps } from '../spam/check.ts';
|
||||
import { callExtensions, calls, cdr, dids, extensions, recordings } from '../db/schema.ts';
|
||||
import { durationMs } from '../media/audio.ts';
|
||||
|
||||
type CdrRow = typeof cdr.$inferSelect;
|
||||
|
||||
/**
|
||||
* The call_extensions roles of someone who was on the call, not just rung: they may hear its
|
||||
* recording and read its transcript and summary (08 §7 #3).
|
||||
*/
|
||||
export const ON_THE_CALL = ['caller', 'answered'];
|
||||
type Tx = Parameters<Parameters<Db['transaction']>[0]>[0];
|
||||
|
||||
/** cdr_manager.conf has usegmtime = yes: "2026-10-18 19:28:37" is UTC. */
|
||||
@@ -160,7 +167,7 @@ async function linkRecording(db: Db | Tx, dir: string, s: CallSummary, linkedid:
|
||||
export async function aggregateCall(
|
||||
db: Db,
|
||||
linkedid: string,
|
||||
opts: { recordingsDir?: string } = {},
|
||||
opts: { recordingsDir?: string; log?: SpamDeps['log'] } = {},
|
||||
): Promise<void> {
|
||||
const rows = await db
|
||||
.select()
|
||||
@@ -180,6 +187,13 @@ export async function aggregateCall(
|
||||
const [d] = s.did
|
||||
? await db.select({ id: dids.id }).from(dids).where(eq(dids.number, s.did))
|
||||
: [];
|
||||
// 14 §6: what the spam check knows now (the cache, the local FTC copy), kept with the call.
|
||||
const spam =
|
||||
s.direction === 'inbound' && s.fromNumber
|
||||
? await checkNumber({ db, log: opts.log ?? { warn() {} } }, usNumber(s.fromNumber), {
|
||||
cachedOnly: true,
|
||||
})
|
||||
: null;
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.delete(calls).where(eq(calls.linkedid, linkedid));
|
||||
const [call] = await tx
|
||||
@@ -197,6 +211,8 @@ export async function aggregateCall(
|
||||
didId: d?.id ?? null,
|
||||
answeredByExtensionId: idOf(s.answeredBy),
|
||||
talkSeconds: s.talkSeconds,
|
||||
spamRisk: spam?.risk ?? null,
|
||||
spamInfo: spam && { provider: spam.provider, info: spam.info },
|
||||
recordingId: opts.recordingsDir
|
||||
? await linkRecording(tx, opts.recordingsDir, s, linkedid)
|
||||
: null,
|
||||
@@ -218,7 +234,7 @@ export async function aggregateCall(
|
||||
|
||||
export type CdrDeps = {
|
||||
db: Db;
|
||||
log: { error(o: object, msg: string): void };
|
||||
log: { error(o: object, msg: string): void; warn(o: object, msg: string): void };
|
||||
/** How long after a call's last Cdr event to aggregate it (04 §5.4: 5 s). */
|
||||
settleMs?: number;
|
||||
/** Where MixMonitor writes (RECORDINGS_DIR); recordings are linked to their calls. */
|
||||
@@ -233,7 +249,7 @@ export function watchCdr(d: CdrDeps, ami: Pick<EventEmitter, 'on'>) {
|
||||
linkedid,
|
||||
setTimeout(() => {
|
||||
timers.delete(linkedid);
|
||||
void aggregateCall(d.db, linkedid, { recordingsDir: d.recordingsDir }).catch(
|
||||
void aggregateCall(d.db, linkedid, { recordingsDir: d.recordingsDir, log: d.log }).catch(
|
||||
(err: unknown) => d.log.error({ err, linkedid }, 'call aggregation failed'),
|
||||
);
|
||||
}, d.settleMs ?? 5000),
|
||||
@@ -257,7 +273,7 @@ export function watchCdr(d: CdrDeps, ami: Pick<EventEmitter, 'on'>) {
|
||||
.leftJoin(calls, eq(calls.linkedid, cdr.linkedid))
|
||||
.where(and(isNull(calls.id), lt(cdr.createdAt, new Date(Date.now() - 60_000))));
|
||||
for (const o of orphans)
|
||||
await aggregateCall(d.db, o.linkedid, { recordingsDir: d.recordingsDir });
|
||||
await aggregateCall(d.db, o.linkedid, { recordingsDir: d.recordingsDir, log: d.log });
|
||||
},
|
||||
stop: () => timers.forEach(clearTimeout),
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ import { BlockList, isIPv4 } from 'node:net';
|
||||
import type { DiagnosticCheck, NetworkTestDto } from '@selfphoneng/shared';
|
||||
import type { AmiClient } from '../ami/client.ts';
|
||||
import type { Config } from '../config.ts';
|
||||
import { decrypt } from '../crypto/secrets.ts';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { detectPublicIp } from '../jobs.ts';
|
||||
import { getAllSettings } from './settings.ts';
|
||||
@@ -221,15 +222,23 @@ export async function insideChecks(d: DiagDeps): Promise<DiagnosticCheck[]> {
|
||||
);
|
||||
|
||||
out.push(
|
||||
s['network.probeUrl']
|
||||
? check('probe', 'Network test probe', 'ok', s['network.probeUrl'])
|
||||
: check(
|
||||
!s['network.probeUrl']
|
||||
? check(
|
||||
'probe',
|
||||
'Network test probe',
|
||||
'skip',
|
||||
'None set, so nothing is tested from outside your network.',
|
||||
'Run apps/probe on a machine outside this network (deploy/probe), then put its URL in Settings → Network.',
|
||||
),
|
||||
)
|
||||
: s['network.probeKeyEnc']
|
||||
? check('probe', 'Network test probe', 'ok', `${s['network.probeUrl']}, with its key`)
|
||||
: check(
|
||||
'probe',
|
||||
'Network test probe',
|
||||
'warn',
|
||||
`${s['network.probeUrl']} has no key: anyone who finds it can use it.`,
|
||||
'Settings → Network → Generate key, then set it as PROBE_KEY on the probe and restart it (strongly recommended).',
|
||||
),
|
||||
);
|
||||
return out;
|
||||
}
|
||||
@@ -289,14 +298,20 @@ export async function networkTest(
|
||||
let ip: string | null = null;
|
||||
let results: ProbeResult[] = [];
|
||||
let probeError: string | null = null;
|
||||
let keyRefused = false;
|
||||
const key = s['network.probeKeyEnc'];
|
||||
try {
|
||||
const r = await (d.fetchProbe ?? fetch)(new URL('/v1/check', probe), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
...(key && { authorization: `Bearer ${decrypt(key)}` }),
|
||||
},
|
||||
body: JSON.stringify({ host, tests }),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
const body = (await r.json()) as { ip?: string; results?: ProbeResult[]; error?: string };
|
||||
keyRefused = r.status === 401;
|
||||
if (!r.ok) probeError = body.error ?? `HTTP ${r.status}`;
|
||||
else ({ ip = null, results = [] } = body as { ip: string; results: ProbeResult[] });
|
||||
// Datagrams sent last arrive just after the answer.
|
||||
@@ -316,7 +331,9 @@ export async function networkTest(
|
||||
'Network test probe',
|
||||
'fail',
|
||||
`${probe} didn't answer: ${probeError}`,
|
||||
'Check the probe runs and is reachable from this server, then try again.',
|
||||
keyRefused
|
||||
? "The probe's PROBE_KEY isn't this server's key. Settings → Network → Generate key, set it as PROBE_KEY on the probe and restart it."
|
||||
: 'Check the probe runs and is reachable from this server, then try again.',
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Fraud alerts (08 §8.3): risky calls the PBX placed, voip.ms calls it didn't place, and a
|
||||
// balance that drops fast. Each is an email to every admin and a warning event.
|
||||
import { and, eq, gt, gte, isNotNull, like, lte } from 'drizzle-orm';
|
||||
import { formatNumber } from '@selfphoneng/shared';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { calls, cdr, dids, trunks } from '../db/schema.ts';
|
||||
import { decrypt } from '../crypto/secrets.ts';
|
||||
import {
|
||||
getBalance,
|
||||
getCdr,
|
||||
VoipmsError,
|
||||
type VoipmsCall,
|
||||
type VoipmsCreds,
|
||||
} from '../integrations/voipms/client.ts';
|
||||
import { logEvent } from './events.ts';
|
||||
import { getSetting } from './settings.ts';
|
||||
|
||||
/** NANP area codes outside the US and Canada: dialed like a home call, billed like abroad. (verify) */
|
||||
const ABROAD_NANP = new Set(
|
||||
'242 246 264 268 284 345 441 473 649 658 664 721 758 767 784 809 829 849 868 869 876'.split(' '),
|
||||
);
|
||||
|
||||
/** Why a dialed number is worth an email, or null. 10 digits count as 1 + them. */
|
||||
export function riskyDestination(dialed: string): string | null {
|
||||
const n = /^[2-9]\d{9}$/.test(dialed) ? `1${dialed}` : dialed;
|
||||
if (n.startsWith('011')) return 'international';
|
||||
const m = /^1(\d{3})(\d{3})\d{4}$/.exec(n);
|
||||
if (!m) return null;
|
||||
if (m[1] === '900' || m[2] === '976') return 'premium';
|
||||
if (ABROAD_NANP.has(m[1]!)) return 'international rates (outside the US and Canada)';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The number a trunk Dial sent (`PJSIP/<number>@trunk-<id>,120`), or null. */
|
||||
export const trunkDialNumber = (lastdata: string | null): string | null =>
|
||||
/^PJSIP\/([^@]+)@trunk-/.exec(lastdata ?? '')?.[1] ?? null;
|
||||
|
||||
const tail = (n: string) => n.replace(/\D/g, '').slice(-10);
|
||||
const MIN = 60_000;
|
||||
const day = (d: Date) => d.toISOString().slice(0, 10);
|
||||
|
||||
export type FraudDeps = {
|
||||
db: Db;
|
||||
voipmsUrl: string;
|
||||
mail: (subject: string, text: string) => Promise<void>;
|
||||
log: { warn(o: object, m: string): void };
|
||||
now: () => Date;
|
||||
};
|
||||
|
||||
export function createFraudWatch(d: FraudDeps) {
|
||||
const when = async (at: Date) =>
|
||||
new Intl.DateTimeFormat('en-US', {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'short',
|
||||
timeZone: await getSetting(d.db, 'general.timezone'),
|
||||
}).format(at);
|
||||
const warn = (code: string, source: string, message: string) =>
|
||||
logEvent(d.db, { severity: 'warning', code, source, message });
|
||||
|
||||
// Calls are read 10 s behind the clock, so a row whose insert is still committing isn't skipped.
|
||||
// ponytail: the cursor starts at api start; a call made while the api was down has no row anyway.
|
||||
let since = new Date(d.now().getTime() - 10_000);
|
||||
const riskyCallWatch = async () => {
|
||||
const until = new Date(d.now().getTime() - 10_000);
|
||||
const rows = await d.db
|
||||
.select()
|
||||
.from(calls)
|
||||
.where(
|
||||
and(
|
||||
eq(calls.direction, 'outbound'),
|
||||
gt(calls.createdAt, since),
|
||||
lte(calls.createdAt, until),
|
||||
),
|
||||
)
|
||||
.orderBy(calls.createdAt);
|
||||
since = until;
|
||||
if (!(await getSetting(d.db, 'alerts.riskyCalls'))) return;
|
||||
const lines: string[] = [];
|
||||
for (const c of rows) {
|
||||
const why = riskyDestination(c.toNumber ?? '');
|
||||
if (why)
|
||||
lines.push(
|
||||
`${await when(c.startedAt)} from ${c.fromNumber ?? '?'} to ${formatNumber(c.toNumber!)}: ` +
|
||||
`${why}, ${c.status}, ${c.talkSeconds} s talking`,
|
||||
);
|
||||
}
|
||||
if (!lines.length) return;
|
||||
await warn(
|
||||
'fraud.risky_call',
|
||||
'Fraud alerts',
|
||||
`${lines.length} call(s) to international or premium numbers`,
|
||||
);
|
||||
await d.mail(
|
||||
`Calls to risky numbers (${lines.length})`,
|
||||
'The phone system placed calls to numbers that can cost a lot:\n\n' +
|
||||
`${lines.join('\n')}\n\n` +
|
||||
"If you don't recognise them, someone may have a phone's password: change it " +
|
||||
'(Extensions → the extension → New SIP password) and check Outbound rules.\n' +
|
||||
'These emails can be turned off on Settings → Security → Fraud alerts.',
|
||||
);
|
||||
};
|
||||
|
||||
// voip.ms records already reported, for 3 hours (08 §8.3).
|
||||
const reported = new Map<string, number>();
|
||||
async function callCheck(t: { name: string; username: string }, creds: VoipmsCreds) {
|
||||
const now = d.now().getTime();
|
||||
for (const [id, at] of reported) if (at < now - 180 * MIN) reported.delete(id);
|
||||
const from = new Date(now - 120 * MIN);
|
||||
const to = new Date(now - 10 * MIN);
|
||||
// uniqueid isn't in voip.ms's examples (06 §5.1), so the time and number make the key too.
|
||||
const key = (r: VoipmsCall) => `${r.uniqueid}|${r.at.getTime()}|${r.destination}`;
|
||||
const records = (
|
||||
await getCdr(d.voipmsUrl, creds, { from: day(from), to: day(to), account: t.username })
|
||||
).filter((r) => r.at >= from && r.at <= to && !reported.has(key(r)));
|
||||
if (!records.length) return;
|
||||
const ours = new Set((await d.db.select({ n: dids.number }).from(dids)).map((x) => tail(x.n)));
|
||||
// Every call the PBX sent to a trunk, forwards and 911 included (03 §6.12, §6.14).
|
||||
const sent = (
|
||||
await d.db
|
||||
.select({ at: cdr.startAt, data: cdr.lastdata })
|
||||
.from(cdr)
|
||||
.where(
|
||||
and(
|
||||
like(cdr.lastdata, 'PJSIP/%@trunk-%'),
|
||||
gte(cdr.startAt, new Date(from.getTime() - 5 * MIN)),
|
||||
lte(cdr.startAt, new Date(to.getTime() + 5 * MIN)),
|
||||
),
|
||||
)
|
||||
).flatMap((r) => {
|
||||
const n = trunkDialNumber(r.data);
|
||||
return n && r.at ? [{ n: tail(n), at: r.at.getTime() }] : [];
|
||||
});
|
||||
const strange: VoipmsCall[] = [];
|
||||
for (const r of records) {
|
||||
reported.set(key(r), r.at.getTime());
|
||||
if (r.account && r.account !== t.username) continue;
|
||||
if (ours.has(tail(r.destination))) continue; // incoming, to one of our numbers
|
||||
const i = sent.findIndex(
|
||||
(s) => s.n === tail(r.destination) && Math.abs(s.at - r.at.getTime()) <= 5 * MIN,
|
||||
);
|
||||
if (i >= 0) sent.splice(i, 1);
|
||||
else strange.push(r);
|
||||
}
|
||||
if (!strange.length) return;
|
||||
const lines = await Promise.all(
|
||||
strange.map(
|
||||
async (r) =>
|
||||
`${await when(r.at)} to ${r.destination}: ${r.disposition.toLowerCase() || '?'}, ` +
|
||||
`${r.seconds} s, cost ${r.total}`,
|
||||
),
|
||||
);
|
||||
await warn(
|
||||
'fraud.provider_call',
|
||||
`Trunk ${t.name}`,
|
||||
`voip.ms has ${strange.length} call(s) on ${t.username} that this phone system didn't make`,
|
||||
);
|
||||
await d.mail(
|
||||
`voip.ms calls this phone system didn't make (${strange.length})`,
|
||||
`voip.ms billed these calls on sub-account ${t.username} (trunk ${t.name}), and this ` +
|
||||
'phone system has no record of making them:\n\n' +
|
||||
`${lines.join('\n')}\n\n` +
|
||||
'Someone else may have the sub-account password. At voip.ms: change the sub-account ' +
|
||||
"password (then paste it on Trunks), and turn on its IP restriction with this server's " +
|
||||
'public IP. Check the voip.ms call records too.\n' +
|
||||
'A call made while SelfPhoneNG was restarting, or by another device using the same ' +
|
||||
'sub-account, also shows up here.\n' +
|
||||
'These emails can be turned off on Settings → Security → Fraud alerts.',
|
||||
);
|
||||
}
|
||||
|
||||
// Balance samples per API login (one account can have several trunks), the last hour's.
|
||||
const balances = new Map<string, { at: number; v: number }[]>();
|
||||
const balanceMailed = new Map<string, number>();
|
||||
async function balanceCheck(creds: VoipmsCreds, limit: number) {
|
||||
const now = d.now().getTime();
|
||||
const v = await getBalance(d.voipmsUrl, creds);
|
||||
const samples = [
|
||||
...(balances.get(creds.username) ?? []).filter((s) => s.at >= now - 60 * MIN),
|
||||
{ at: now, v },
|
||||
];
|
||||
balances.set(creds.username, samples);
|
||||
const high = Math.max(...samples.map((s) => s.v));
|
||||
if (high - v <= limit || (balanceMailed.get(creds.username) ?? 0) > now - 60 * MIN) return;
|
||||
balanceMailed.set(creds.username, now);
|
||||
const drop = (high - v).toFixed(2);
|
||||
await warn(
|
||||
'fraud.balance_drop',
|
||||
'Fraud alerts',
|
||||
`The voip.ms balance dropped ${drop} in an hour`,
|
||||
);
|
||||
await d.mail(
|
||||
`voip.ms balance dropped ${drop} in an hour`,
|
||||
`The voip.ms balance of ${creds.username} went from ${high.toFixed(2)} to ${v.toFixed(2)} ` +
|
||||
`within the last hour, more than the ${limit} set on Settings → Security → Fraud alerts.\n\n` +
|
||||
"If that isn't your own calling, check the call records at voip.ms, change the " +
|
||||
'sub-account and API passwords, and turn on IP restriction on the sub-account.',
|
||||
);
|
||||
}
|
||||
|
||||
const providerWatch = async () => {
|
||||
const checkCalls = await getSetting(d.db, 'alerts.providerCalls');
|
||||
const limit = await getSetting(d.db, 'alerts.balanceDropPerHour');
|
||||
if (!checkCalls && !limit) return;
|
||||
const rows = await d.db
|
||||
.select({
|
||||
name: trunks.name,
|
||||
username: trunks.username,
|
||||
apiUser: trunks.voipmsApiUsername,
|
||||
apiPass: trunks.voipmsApiPasswordEnc,
|
||||
})
|
||||
.from(trunks)
|
||||
.where(
|
||||
and(
|
||||
eq(trunks.enabled, true),
|
||||
eq(trunks.provider, 'voipms'),
|
||||
isNotNull(trunks.voipmsApiUsername),
|
||||
isNotNull(trunks.voipmsApiPasswordEnc),
|
||||
),
|
||||
);
|
||||
const balanceAsked = new Set<string>();
|
||||
for (const t of rows) {
|
||||
const creds = { username: t.apiUser!, password: decrypt(t.apiPass!) };
|
||||
try {
|
||||
if (checkCalls && t.username)
|
||||
await callCheck({ name: t.name, username: t.username }, creds);
|
||||
if (limit && !balanceAsked.has(creds.username)) {
|
||||
balanceAsked.add(creds.username);
|
||||
await balanceCheck(creds, limit);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!(e instanceof VoipmsError)) throw e;
|
||||
d.log.warn({ trunk: t.name, status: e.status }, 'voip.ms fraud check failed');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return { riskyCallWatch, providerWatch };
|
||||
}
|
||||
@@ -121,11 +121,12 @@ async function transcribeMessage(d: VoicemailDeps, row: Row): Promise<string | n
|
||||
async function announce(d: VoicemailDeps, box: Box, found: Row) {
|
||||
const transcript = await transcribeMessage(d, found);
|
||||
const row = { ...found, transcript };
|
||||
// Every mailbox, with or without a user (the live message is only for the user's own).
|
||||
void enqueueWebhook(d.db, 'voicemail.new', {
|
||||
...toVoicemailDto(row),
|
||||
extension: box.number,
|
||||
}).catch((err: unknown) => d.log.error({ err }, 'webhook enqueue failed'));
|
||||
// Every mailbox, with or without a user (the live message is only for the user's own). Not
|
||||
// the transcript: webhooks go off this server, and transcripts never do (10 §1, 08 §7 #6).
|
||||
const { transcript: _words, ...hook } = toVoicemailDto(row);
|
||||
void enqueueWebhook(d.db, 'voicemail.new', { ...hook, extension: box.number }).catch(
|
||||
(err: unknown) => d.log.error({ err }, 'webhook enqueue failed'),
|
||||
);
|
||||
if (box.userId)
|
||||
d.live.emit('message', {
|
||||
type: 'voicemail.new',
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// The spam check (14 §5): the one way to ask what's known about a caller's number. Information
|
||||
// only: nothing here changes a call, and a failure only means there's no answer (and one warning
|
||||
// in the log). The contacts test (14 §1) is the caller's: it knows whose books to look in.
|
||||
import { and, eq, gt, isNull, sql, type SQL } from 'drizzle-orm';
|
||||
import {
|
||||
SpamStateSchema,
|
||||
normalizeNumber,
|
||||
type IpqsInfo,
|
||||
type SpamCheck,
|
||||
type SpamRisk,
|
||||
type SpamSettings,
|
||||
type SpamState,
|
||||
} from '@selfphoneng/shared';
|
||||
import { contactNames } from '../contacts/book.ts';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { settings, spamLookups } from '../db/schema.ts';
|
||||
import { decrypt } from '../crypto/secrets.ts';
|
||||
import { logEvent } from '../services/events.ts';
|
||||
import { getSetting } from '../services/settings.ts';
|
||||
import { ftcLookup } from './ftc.ts';
|
||||
import { SpamError } from './http.ts';
|
||||
import { ipqsCredits, ipqsLookup } from './ipqs.ts';
|
||||
|
||||
export type SpamDeps = { db: Db; log: { warn(o: object, msg: string): void } };
|
||||
|
||||
/** US numbers only (owner, 2026-10-27): 1NXXNXXXXXX after normalizeNumber (00 §8). */
|
||||
export const isCheckable = (n: string) => /^1[2-9]\d{2}[2-9]\d{6}$/.test(n);
|
||||
|
||||
/** `n` as 1NXXNXXXXXX-style digits (00 §8), or '' when it isn't a number. */
|
||||
export const usNumber = (n: string) => {
|
||||
try {
|
||||
return normalizeNumber(n);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
/** A check under way, by number: a ring group ringing five people costs one credit (14 §4). */
|
||||
const pending = new Map<string, Promise<SpamCheck | null>>();
|
||||
|
||||
function warn(d: SpamDeps, provider: string, err: unknown) {
|
||||
if (err instanceof SpamError) d.log.warn({ provider, reason: err.message }, 'spam check failed');
|
||||
else d.log.warn({ provider, err }, 'spam check failed');
|
||||
}
|
||||
|
||||
/**
|
||||
* IPQualityScore first when it's set up, then the FTC list (owner, 2026-10-27). `cachedOnly`:
|
||||
* the cache and the local FTC copy, never a request out (call history, 14 §6).
|
||||
*/
|
||||
export function checkNumber(
|
||||
d: SpamDeps,
|
||||
number: string,
|
||||
opts: { cachedOnly?: boolean } = {},
|
||||
): Promise<SpamCheck | null> {
|
||||
if (opts.cachedOnly) return check(d, number, true);
|
||||
// Shared from the start, before anything is awaited: else a check that finishes between
|
||||
// another's cache read and its look here would cost a second request.
|
||||
let p = pending.get(number);
|
||||
if (!p) {
|
||||
p = check(d, number, false).finally(() => pending.delete(number));
|
||||
pending.set(number, p);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
async function check(d: SpamDeps, number: string, cachedOnly: boolean): Promise<SpamCheck | null> {
|
||||
if (!isCheckable(number)) return null;
|
||||
const s = await getSetting(d.db, 'spam');
|
||||
if (!s.enabled) return null;
|
||||
if (s.ipqs) {
|
||||
const [hit] = await d.db
|
||||
.select()
|
||||
.from(spamLookups)
|
||||
.where(and(eq(spamLookups.number, number), gt(spamLookups.expiresAt, sql`now()`)));
|
||||
if (hit) return { risk: hit.risk as SpamRisk, provider: 'ipqs', info: hit.info as IpqsInfo };
|
||||
// Paused (14 §3): the cache still answers, IPQualityScore isn't asked.
|
||||
if (!cachedOnly && !(await getSetting(d.db, 'spam.state')).ipqsPausedAt) {
|
||||
const answer = await askIpqs(d, s, s.ipqs.keyEnc, number);
|
||||
if (answer) return answer;
|
||||
}
|
||||
}
|
||||
if (!s.ftc) return null;
|
||||
try {
|
||||
return { provider: 'ftc', ...(await ftcLookup(d.db, number, s.ftc.windowDays)) };
|
||||
} catch (err) {
|
||||
warn(d, 'ftc', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The check for a call ringing `userId` (14 §5): nothing for a number in their books (or when
|
||||
* the books can't be read), else the answer if it comes within `waitMs`. A later answer still
|
||||
* fills the cache.
|
||||
*/
|
||||
export async function checkCaller(
|
||||
d: SpamDeps,
|
||||
userId: string | null,
|
||||
number: string,
|
||||
waitMs: number,
|
||||
): Promise<SpamCheck | null> {
|
||||
if (!isCheckable(number)) return null;
|
||||
const known = await contactNames(d.db, userId, [number], { wait: false }).catch(() => null);
|
||||
if (!known || known.size) return null;
|
||||
const check = checkNumber(d, number).catch((err: unknown) => {
|
||||
warn(d, 'check', err);
|
||||
return null;
|
||||
});
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
const late = new Promise<null>((r) => (timer = setTimeout(r, waitMs, null)));
|
||||
try {
|
||||
return await Promise.race([check, late]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function askIpqs(
|
||||
d: SpamDeps,
|
||||
s: SpamSettings,
|
||||
keyEnc: string,
|
||||
number: string,
|
||||
): Promise<SpamCheck | null> {
|
||||
try {
|
||||
const a = await ipqsLookup(decrypt(keyEnc), number);
|
||||
if (a.kind === 'exhausted') {
|
||||
await pauseIpqs(d, s);
|
||||
return null;
|
||||
}
|
||||
const days = a.risk === 'low' ? s.cache.cleanDays : s.cache.flaggedDays;
|
||||
const expiresAt = sql`now() + make_interval(days => ${days})`;
|
||||
await d.db
|
||||
.insert(spamLookups)
|
||||
.values({ number, provider: 'ipqs', risk: a.risk, info: a.info, expiresAt })
|
||||
.onConflictDoUpdate({
|
||||
target: spamLookups.number,
|
||||
set: { risk: a.risk, info: a.info, expiresAt, updatedAt: new Date() },
|
||||
});
|
||||
return { risk: a.risk, provider: 'ipqs', info: a.info };
|
||||
} catch (err) {
|
||||
warn(d, 'ipqs', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges `patch` into spam.state in one statement, so the jobs and checks don't overwrite each
|
||||
* other's fields. `onlyIf`: the condition the stored state must meet; false when it didn't.
|
||||
*/
|
||||
export async function patchSpamState(
|
||||
db: Db,
|
||||
patch: Partial<SpamState>,
|
||||
onlyIf?: SQL,
|
||||
): Promise<boolean> {
|
||||
await db
|
||||
.insert(settings)
|
||||
.values({ key: 'spam.state', value: SpamStateSchema.parse({}) })
|
||||
.onConflictDoNothing();
|
||||
const rows = await db
|
||||
.update(settings)
|
||||
.set({
|
||||
value: sql`${settings.value} || ${JSON.stringify(patch)}::jsonb`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(settings.key, 'spam.state'), onlyIf))
|
||||
.returning({ key: settings.key });
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
const paused = sql`${settings.value}->>'ipqsPausedAt'`;
|
||||
|
||||
/** Out of credits (14 §3): paused, and the event, warning and log line once per time. */
|
||||
async function pauseIpqs(d: SpamDeps, s: SpamSettings) {
|
||||
const first = await patchSpamState(
|
||||
d.db,
|
||||
{ ipqsPausedAt: new Date().toISOString(), warningDismissedAt: null },
|
||||
isNull(paused),
|
||||
);
|
||||
if (!first) return;
|
||||
d.log.warn({ provider: 'ipqs' }, 'spam check: IPQualityScore credits are used up, paused');
|
||||
await logEvent(d.db, {
|
||||
severity: 'warning',
|
||||
code: 'spam.quotaExhausted',
|
||||
source: 'Spam check',
|
||||
message: `IPQualityScore's lookups are used up. ${s.ftc ? 'Spam checks use the FTC list' : 'Spam checks are off'} until they come back.`,
|
||||
});
|
||||
}
|
||||
|
||||
/** Unpaused, by the job or a new key (14 §3). */
|
||||
export async function resumeIpqs(db: Db, why: string) {
|
||||
const was = await patchSpamState(db, { ipqsPausedAt: null }, sql`${paused} is not null`);
|
||||
if (was)
|
||||
await logEvent(db, {
|
||||
severity: 'info',
|
||||
code: 'spam.quotaRestored',
|
||||
source: 'Spam check',
|
||||
message: `IPQualityScore lookups are back (${why}).`,
|
||||
});
|
||||
}
|
||||
|
||||
/** Job spamQuotaCheck (14 §3), every 6 hours: while paused, are credits back? A failure throws
|
||||
* (the job's log line) and the next run tries again. */
|
||||
export async function spamQuotaCheck(db: Db) {
|
||||
const s = await getSetting(db, 'spam');
|
||||
if (!s.ipqs || !(await getSetting(db, 'spam.state')).ipqsPausedAt) return;
|
||||
const credits = await ipqsCredits(decrypt(s.ipqs.keyEnc));
|
||||
if (credits > 0) await resumeIpqs(db, `${credits} credits left`);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// The FTC's Do Not Call complaints (14 §2.2): a check reads the local copy, so it sends nothing
|
||||
// out. spamFtcSync keeps the copy.
|
||||
import { lt, sql } from 'drizzle-orm';
|
||||
import type { FtcInfo, SpamRisk } from '@selfphoneng/shared';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { ftcComplaints } from '../db/schema.ts';
|
||||
import { decrypt } from '../crypto/secrets.ts';
|
||||
import { getSetting } from '../services/settings.ts';
|
||||
import { todayIn } from '../generator/load.ts';
|
||||
import { isCheckable, patchSpamState } from './check.ts';
|
||||
import { SpamError, getJson } from './http.ts';
|
||||
|
||||
export const FTC_API = 'https://api.ftc.gov/v0/dnc-complaints';
|
||||
|
||||
/** Risk (owner, 2026-10-27): 3 or more complaints in the window high, 1–2 medium. */
|
||||
export const ftcRisk = (complaints: number): SpamRisk =>
|
||||
complaints >= 3 ? 'high' : complaints >= 1 ? 'medium' : 'low';
|
||||
|
||||
/** The complaints about `number` (1NXXNXXXXXX) in the last `windowDays`. */
|
||||
export async function ftcLookup(
|
||||
db: Db,
|
||||
number: string,
|
||||
windowDays: number,
|
||||
): Promise<{ risk: SpamRisk; info: FtcInfo }> {
|
||||
const r = await db.execute<{
|
||||
complaints: number;
|
||||
robocalls: number;
|
||||
last: Date | string | null;
|
||||
subject: string | null;
|
||||
}>(sql`
|
||||
select count(*)::int as complaints,
|
||||
(count(*) filter (where robocall))::int as robocalls,
|
||||
max(reported_at) as last,
|
||||
mode() within group (order by subject) as subject
|
||||
from ftc_complaints
|
||||
where number = ${number}
|
||||
and reported_at > now() - make_interval(days => ${windowDays})`);
|
||||
const row = r.rows[0]!;
|
||||
const info: FtcInfo = {
|
||||
complaints: row.complaints,
|
||||
robocalls: row.robocalls,
|
||||
lastReportedAt: row.last === null ? null : new Date(row.last).toISOString(),
|
||||
subject: row.subject,
|
||||
};
|
||||
return { risk: ftcRisk(info.complaints), info };
|
||||
}
|
||||
|
||||
const OVER_LIMIT = 'api.data.gov says the key is over its hourly limit';
|
||||
|
||||
async function ftcGet(key: string, q: Record<string, string>) {
|
||||
try {
|
||||
return await getJson(`${FTC_API}?${new URLSearchParams(q)}`, { 'X-Api-Key': key });
|
||||
} catch (err) {
|
||||
const m = (err as Error).message;
|
||||
if (m === 'HTTP 403') throw new SpamError('api.data.gov refused the key');
|
||||
if (m === 'HTTP 429') throw new SpamError(OVER_LIMIT);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Settings → Spam check → Test: one request with the api.data.gov key. */
|
||||
export async function ftcTest(key: string): Promise<string> {
|
||||
await ftcGet(key, { items_per_page: '1' });
|
||||
return 'The FTC answered.';
|
||||
}
|
||||
|
||||
// ponytail: the FTC's dates have no zone; taken as Eastern (it's in Washington). Hours off at
|
||||
// worst, against a window of weeks.
|
||||
const FTC_TZ = 'America/New_York';
|
||||
const PAGE = 50;
|
||||
const DATE_TIME = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/;
|
||||
const nextDay = (day: string) =>
|
||||
new Date(Date.parse(`${day}T00:00:00Z`) + 86_400_000).toISOString().slice(0, 10);
|
||||
const daysBefore = (day: string, n: number) =>
|
||||
new Date(Date.parse(`${day}T00:00:00Z`) - n * 86_400_000).toISOString().slice(0, 10);
|
||||
|
||||
/** One complaint as stored, or null for one without a usable US number or date. */
|
||||
function complaint(c: unknown) {
|
||||
const { id, attributes: a } = (c ?? {}) as { id?: unknown; attributes?: Record<string, unknown> };
|
||||
const phone = `1${String(a?.['company-phone-number'] ?? '')}`;
|
||||
const at = a?.['created-date'];
|
||||
if (
|
||||
typeof id !== 'string' ||
|
||||
!isCheckable(phone) ||
|
||||
typeof at !== 'string' ||
|
||||
!DATE_TIME.test(at)
|
||||
)
|
||||
return null;
|
||||
const robo = a?.['recorded-message-or-robocall'];
|
||||
return {
|
||||
id,
|
||||
number: phone,
|
||||
reportedAt: sql`${at}::timestamp at time zone ${FTC_TZ}`,
|
||||
subject: typeof a?.subject === 'string' && a.subject ? a.subject : null,
|
||||
robocall: robo === 'Y' ? true : robo === 'N' ? false : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Job spamFtcSync (14 §2.2), every minute: when a sync is due or under way, read up to `budget`
|
||||
* pages (15 a minute is 900 an hour, under api.data.gov's 1,000) a day at a time, oldest first,
|
||||
* saving where it got to. A failure (a 429 among them) is saved for the Settings tab and the next
|
||||
* minute carries on from the same page. Returns the error, for the job's log line once.
|
||||
*/
|
||||
export async function ftcSync(
|
||||
db: Db,
|
||||
opts: { now?: Date; budget?: number } = {},
|
||||
): Promise<string | null> {
|
||||
const now = opts.now ?? new Date();
|
||||
const s = await getSetting(db, 'spam');
|
||||
if (!s.enabled || !s.ftc) return null;
|
||||
const st = await getSetting(db, 'spam.state');
|
||||
let cursor = st.ftcCursor;
|
||||
if (!cursor) {
|
||||
const due =
|
||||
!st.ftcLastSyncAt ||
|
||||
now.getTime() - Date.parse(st.ftcLastSyncAt) >= s.ftc.syncHours * 3_600_000;
|
||||
if (!due) return null;
|
||||
// From the newest complaint kept, less a day for ones posted late; at most the window.
|
||||
const r = await db.execute<{ day: string | null }>(
|
||||
sql`select to_char(max(reported_at) at time zone ${FTC_TZ}, 'YYYY-MM-DD') as day from ftc_complaints`,
|
||||
);
|
||||
const first = daysBefore(todayIn(FTC_TZ, now), s.ftc.windowDays);
|
||||
const last = r.rows[0]?.day ? daysBefore(r.rows[0].day, 1) : first;
|
||||
cursor = { day: last > first ? last : first, offset: 0 };
|
||||
}
|
||||
const key = decrypt(s.ftc.keyEnc);
|
||||
const today = todayIn(FTC_TZ, now);
|
||||
for (let n = 0; n < (opts.budget ?? 15); n++) {
|
||||
let page: unknown[];
|
||||
try {
|
||||
const j = await ftcGet(key, {
|
||||
created_date_from: `"${cursor.day} 00:00:00"`,
|
||||
created_date_to: `"${cursor.day} 23:59:59"`,
|
||||
sort_order: 'ASC',
|
||||
items_per_page: String(PAGE),
|
||||
offset: String(cursor.offset),
|
||||
});
|
||||
if (!Array.isArray(j.data)) throw new SpamError('the answer had no complaints list');
|
||||
page = j.data;
|
||||
} catch (err) {
|
||||
const error = err instanceof SpamError ? err.message : 'the sync failed';
|
||||
await patchSpamState(db, { ftcCursor: cursor, ftcLastError: error });
|
||||
if (!(err instanceof SpamError)) throw err;
|
||||
return error === st.ftcLastError ? null : error;
|
||||
}
|
||||
const rows = page.map(complaint).filter((c) => c !== null);
|
||||
if (rows.length) await db.insert(ftcComplaints).values(rows).onConflictDoNothing();
|
||||
if (page.length === PAGE) cursor = { day: cursor.day, offset: cursor.offset + PAGE };
|
||||
else if (cursor.day < today) cursor = { day: nextDay(cursor.day), offset: 0 };
|
||||
else {
|
||||
await db
|
||||
.delete(ftcComplaints)
|
||||
.where(
|
||||
lt(
|
||||
ftcComplaints.reportedAt,
|
||||
sql`${now.toISOString()}::timestamptz - make_interval(days => ${s.ftc.windowDays})`,
|
||||
),
|
||||
);
|
||||
await patchSpamState(db, {
|
||||
ftcCursor: null,
|
||||
ftcLastSyncAt: now.toISOString(),
|
||||
ftcLastError: null,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
await patchSpamState(db, { ftcCursor: cursor, ftcLastError: null });
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// The spam check's providers over HTTPS (14 §2). Errors carry only these texts: never the URL
|
||||
// or fetch's own error, which can quote it (IPQualityScore's key is in the path).
|
||||
|
||||
/** A failure whose message is safe to log and show. */
|
||||
export class SpamError extends Error {}
|
||||
|
||||
export async function getJson(
|
||||
url: string,
|
||||
headers: Record<string, string> = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
let r: Response;
|
||||
try {
|
||||
r = await fetch(url, { headers, signal: AbortSignal.timeout(4000) });
|
||||
} catch (err) {
|
||||
throw new SpamError(
|
||||
(err as Error).name === 'TimeoutError' ? 'no answer in 4 s' : "couldn't connect",
|
||||
);
|
||||
}
|
||||
if (!r.ok) throw new SpamError(`HTTP ${r.status}`);
|
||||
try {
|
||||
return (await r.json()) as Record<string, unknown>;
|
||||
} catch {
|
||||
throw new SpamError('the answer was not JSON');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// IPQualityScore (14 §2.1). Its key goes in the URL's path: see http.ts.
|
||||
import type { IpqsInfo, SpamRisk } from '@selfphoneng/shared';
|
||||
import { SpamError, getJson } from './http.ts';
|
||||
|
||||
const BASE = 'https://www.ipqualityscore.com/api/json';
|
||||
export type IpqsAnswer = { kind: 'ok'; risk: SpamRisk; info: IpqsInfo } | { kind: 'exhausted' };
|
||||
|
||||
const get = (path: string) => getJson(`${BASE}/${path}`);
|
||||
|
||||
const str = (v: unknown) => (typeof v === 'string' && v.trim() && v !== 'N/A' ? v.trim() : null);
|
||||
const used = (msg: unknown) => typeof msg === 'string' && /insufficient credits/i.test(msg);
|
||||
/** The provider's own message, cut short and without the key in case it quotes it. */
|
||||
const said = (msg: unknown, key: string) =>
|
||||
new SpamError(
|
||||
typeof msg === 'string' && msg
|
||||
? msg.replaceAll(key, '***').slice(0, 200)
|
||||
: 'the request failed',
|
||||
);
|
||||
|
||||
/** Risk (owner, 2026-10-27): IPQualityScore's own advice, 75 suspicious and 85 high risk. */
|
||||
export function ipqsRisk(a: {
|
||||
fraudScore: number;
|
||||
spammer: boolean;
|
||||
recentAbuse: boolean;
|
||||
risky: boolean;
|
||||
}): SpamRisk {
|
||||
if (a.spammer || a.fraudScore >= 85) return 'high';
|
||||
if (a.fraudScore >= 75 || a.recentAbuse || a.risky) return 'medium';
|
||||
return 'low';
|
||||
}
|
||||
|
||||
/** One phone lookup: `number` is 1NXXNXXXXXX. */
|
||||
export async function ipqsLookup(key: string, number: string): Promise<IpqsAnswer> {
|
||||
const j = await get(`phone/${encodeURIComponent(key)}/${number}?country[]=US`);
|
||||
if (j.success !== true) {
|
||||
if (used(j.message)) return { kind: 'exhausted' };
|
||||
throw said(j.message, key);
|
||||
}
|
||||
const info: IpqsInfo = {
|
||||
fraudScore: typeof j.fraud_score === 'number' ? j.fraud_score : 0,
|
||||
spammer: j.spammer === true,
|
||||
recentAbuse: j.recent_abuse === true,
|
||||
lineType: str(j.line_type),
|
||||
carrier: str(j.carrier),
|
||||
name: str(j.name),
|
||||
city: str(j.city),
|
||||
region: str(j.region),
|
||||
};
|
||||
return { kind: 'ok', risk: ipqsRisk({ ...info, risky: j.risky === true }), info };
|
||||
}
|
||||
|
||||
/** The Credit Usage API: credits left in this billing period (Test, and 14 §3). */
|
||||
export async function ipqsCredits(key: string): Promise<number> {
|
||||
const j = await get(`account/${encodeURIComponent(key)}`);
|
||||
if (j.success === false) throw said(j.message, key);
|
||||
const credits = Number(j.credits);
|
||||
if (!Number.isFinite(credits)) throw new SpamError('the answer had no credit count');
|
||||
return credits;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { claimValues, decide, verifiedEmail } from './claims.ts';
|
||||
|
||||
describe('OpenID claims (12 §3.4)', () => {
|
||||
const keycloak = {
|
||||
groups: ['/pbx-admins', 'pbx-users'],
|
||||
realm_access: { roles: ['offline_access', 'pbx-admin'] },
|
||||
resource_access: { selfphoneng: { roles: ['operator'] } },
|
||||
'urn:zitadel:iam:org:project:roles': { 'pbx-admin': { '123': 'example.com' } },
|
||||
hd: 'example.com',
|
||||
};
|
||||
|
||||
it('reads dotted paths, whole keys first', () => {
|
||||
expect(claimValues(keycloak, 'groups')).toEqual(['/pbx-admins', 'pbx-users']);
|
||||
expect(claimValues(keycloak, 'realm_access.roles')).toContain('pbx-admin');
|
||||
expect(claimValues(keycloak, 'resource_access.selfphoneng.roles')).toEqual(['operator']);
|
||||
expect(claimValues(keycloak, 'urn:zitadel:iam:org:project:roles')).toEqual(['pbx-admin']);
|
||||
expect(claimValues(keycloak, 'hd')).toEqual(['example.com']);
|
||||
expect(claimValues(keycloak, 'missing.path')).toEqual([]);
|
||||
expect(claimValues({ 'a.b': ['x'], a: { b: ['y'] } }, 'a.b')).toEqual(['x']);
|
||||
});
|
||||
|
||||
it('decides allowed and role, comparing names exactly', () => {
|
||||
const rules = { groupsClaim: 'groups', adminGroups: [], allowedGroups: [] };
|
||||
expect(decide(rules, keycloak)).toMatchObject({ allowed: true, admin: null });
|
||||
expect(decide({ ...rules, adminGroups: ['pbx-admins'] }, keycloak).admin).toBe(false);
|
||||
expect(decide({ ...rules, adminGroups: ['/pbx-admins'] }, keycloak).admin).toBe(true);
|
||||
expect(decide({ ...rules, allowedGroups: ['sales'] }, keycloak).allowed).toBe(false);
|
||||
expect(decide({ ...rules, allowedGroups: ['sales', 'pbx-users'] }, keycloak).allowed).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('takes an email only when verified, or trusted', () => {
|
||||
expect(verifiedEmail({ email: 'a@x.com', email_verified: true }, false)).toBe('a@x.com');
|
||||
expect(verifiedEmail({ email: 'a@x.com', email_verified: 'true' }, false)).toBeNull();
|
||||
expect(verifiedEmail({ email: 'a@x.com' }, false)).toBeNull();
|
||||
expect(verifiedEmail({ email: 'a@x.com' }, true)).toBe('a@x.com');
|
||||
expect(verifiedEmail({}, true)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// OpenID claims → groups, allowed, role (12 §3.4). Pure: no I/O.
|
||||
|
||||
type Claims = Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* The value at a dotted path. The whole remaining path is tried as one key first, so claim
|
||||
* names with dots or colons (Zitadel's "urn:zitadel:iam:org:project:roles") work, and
|
||||
* "resource_access.selfphoneng.roles" walks down.
|
||||
*/
|
||||
export function claimAt(claims: unknown, path: string): unknown {
|
||||
if (!claims || typeof claims !== 'object') return undefined;
|
||||
const o = claims as Claims;
|
||||
if (path in o) return o[path];
|
||||
const dot = path.indexOf('.');
|
||||
return dot < 0 ? undefined : claimAt(o[path.slice(0, dot)], path.slice(dot + 1));
|
||||
}
|
||||
|
||||
/** A string → [it]; an array → its strings; an object → its keys (Zitadel's roles). */
|
||||
export function claimValues(claims: unknown, path: string): string[] {
|
||||
const v = claimAt(claims, path);
|
||||
if (typeof v === 'string') return [v];
|
||||
if (Array.isArray(v)) return v.filter((x): x is string => typeof x === 'string');
|
||||
if (v && typeof v === 'object') return Object.keys(v);
|
||||
return [];
|
||||
}
|
||||
|
||||
export type OidcRules = { groupsClaim: string; adminGroups: string[]; allowedGroups: string[] };
|
||||
|
||||
export type ClaimsDecision = {
|
||||
groups: string[];
|
||||
allowed: boolean;
|
||||
/** null: the role is managed in SelfPhoneNG (no adminGroups). */
|
||||
admin: boolean | null;
|
||||
};
|
||||
|
||||
export function decide(rules: OidcRules, claims: Claims): ClaimsDecision {
|
||||
const groups = claimValues(claims, rules.groupsClaim);
|
||||
const any = (list: string[]) => list.some((g) => groups.includes(g));
|
||||
return {
|
||||
groups,
|
||||
allowed: rules.allowedGroups.length === 0 || any(rules.allowedGroups),
|
||||
admin: rules.adminGroups.length === 0 ? null : any(rules.adminGroups),
|
||||
};
|
||||
}
|
||||
|
||||
/** The email, when the provider says it's verified (or the admin trusts its emails, 12 §3.3). */
|
||||
export function verifiedEmail(claims: Claims, trustEmail: boolean): string | null {
|
||||
const email = typeof claims.email === 'string' ? claims.email.trim() : '';
|
||||
if (!email) return null;
|
||||
return claims.email_verified === true || trustEmail ? email : null;
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// A directory entry → its SelfPhoneNG user (12 §4.3): connected before, or by the directory's
|
||||
// email. Never a new user (owner, 2026-10-25).
|
||||
import { and, eq, notExists } from 'drizzle-orm';
|
||||
import { deleteUserSessions } from '../auth/sessions.ts';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { userIdentities, users } from '../db/schema.ts';
|
||||
import { isLastAdmin } from '../routes/users.ts';
|
||||
import { audit } from '../services/audit.ts';
|
||||
import { getSetting, setSetting } from '../services/settings.ts';
|
||||
import { lookupIds, type DirectoryEntry, type LdapSettings } from './ldap.ts';
|
||||
import { applyRole } from './role.ts';
|
||||
|
||||
type UserRow = typeof users.$inferSelect;
|
||||
|
||||
/** Who this entry is here, without changing anything (Settings → Test says it too). */
|
||||
export async function directoryUserFor(db: Db, e: DirectoryEntry): Promise<UserRow | null> {
|
||||
const [byId] = await db
|
||||
.select({ u: users })
|
||||
.from(userIdentities)
|
||||
.innerJoin(users, eq(users.id, userIdentities.userId))
|
||||
.where(and(eq(userIdentities.kind, 'ldap'), eq(userIdentities.subject, e.id)));
|
||||
if (byId) return byId.u;
|
||||
if (!e.email) return null;
|
||||
// Nobody with another way in either: an OpenID user's account is theirs to connect (08 §7 #1).
|
||||
const [byEmail] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(
|
||||
and(
|
||||
eq(users.email, e.email),
|
||||
notExists(
|
||||
db
|
||||
.select({ id: userIdentities.id })
|
||||
.from(userIdentities)
|
||||
.where(eq(userIdentities.userId, users.id)),
|
||||
),
|
||||
),
|
||||
);
|
||||
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). Not another one in place
|
||||
* of the one they have: the leaver check follows it, so swapping is an admin's job (08 §7 #5).
|
||||
*/
|
||||
export async function connectDirectory(
|
||||
db: Db,
|
||||
log: { info(o: object, m: string): void },
|
||||
userId: string,
|
||||
e: DirectoryEntry,
|
||||
ip: string,
|
||||
): Promise<'connected' | 'taken' | 'connected_other'> {
|
||||
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';
|
||||
const [mine] = await db
|
||||
.select({ subject: userIdentities.subject })
|
||||
.from(userIdentities)
|
||||
.where(and(eq(userIdentities.userId, userId), eq(userIdentities.kind, 'ldap')));
|
||||
if (mine && mine.subject !== e.id) return 'connected_other';
|
||||
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';
|
||||
}
|
||||
|
||||
/**
|
||||
* The user this directory sign-in is for, connected now if it was matched by email (the
|
||||
* directory is the company's own and the person just proved its password), with the role from
|
||||
* adminFilter. Null: nobody here has this directory account or email.
|
||||
*/
|
||||
export async function directorySignIn(
|
||||
db: Db,
|
||||
log: { info(o: object, m: string): void },
|
||||
e: DirectoryEntry,
|
||||
ip: string,
|
||||
): Promise<UserRow | null> {
|
||||
const u = await directoryUserFor(db, e);
|
||||
if (!u) return null;
|
||||
const [mine] = await db
|
||||
.select({ subject: userIdentities.subject })
|
||||
.from(userIdentities)
|
||||
.where(and(eq(userIdentities.userId, u.id), eq(userIdentities.kind, 'ldap')));
|
||||
if (!mine) {
|
||||
await db.insert(userIdentities).values({
|
||||
userId: u.id,
|
||||
kind: 'ldap',
|
||||
subject: e.id,
|
||||
email: e.email,
|
||||
lastUsedAt: new Date(),
|
||||
});
|
||||
await audit(db, {
|
||||
actorUserId: u.id,
|
||||
actorIp: ip,
|
||||
action: 'ldap_connected',
|
||||
entityType: 'user',
|
||||
entityId: u.id,
|
||||
summary: `${u.email} connected directory account ${e.dn}`,
|
||||
});
|
||||
} else
|
||||
// Found by this account's id (directoryUserFor never matches by email someone connected).
|
||||
await db
|
||||
.update(userIdentities)
|
||||
.set({ email: e.email, lastUsedAt: new Date() })
|
||||
.where(and(eq(userIdentities.userId, u.id), eq(userIdentities.kind, 'ldap')));
|
||||
await applyRole(db, log, u.id, e.admin, 'the directory', ip);
|
||||
const [after] = await db.select().from(users).where(eq(users.id, u.id));
|
||||
return after!;
|
||||
}
|
||||
|
||||
/** The dashboard's directory warning (12 §4.2, §4.5); null clears it. */
|
||||
export const directoryWarning = (db: Db, message: string | null) =>
|
||||
setSetting(db, 'system.directoryWarning', message);
|
||||
|
||||
type Log = { info(o: object, m: string): void; warn?(o: object, m: string): void };
|
||||
|
||||
/**
|
||||
* One run of the directoryCheck job (hourly, 12 §4.5): every active user with a directory
|
||||
* account still in it gets its email and role; one who isn't is disabled. Never re-enables.
|
||||
* `lookup` is for tests.
|
||||
*/
|
||||
export async function checkDirectory(d: {
|
||||
db: Db;
|
||||
log: Log;
|
||||
lookup?: (s: LdapSettings, ids: string[]) => Promise<Map<string, DirectoryEntry>>;
|
||||
}): Promise<void> {
|
||||
const { db, log } = d;
|
||||
const s = await getSetting(db, 'auth.ldap');
|
||||
const rows = s?.enabled
|
||||
? await db
|
||||
.select({ u: users, subject: userIdentities.subject })
|
||||
.from(userIdentities)
|
||||
.innerJoin(users, eq(users.id, userIdentities.userId))
|
||||
.where(and(eq(userIdentities.kind, 'ldap'), eq(users.isActive, true)))
|
||||
: [];
|
||||
if (!s || !rows.length) return directoryWarning(db, null);
|
||||
|
||||
let found: Map<string, DirectoryEntry>;
|
||||
try {
|
||||
found = await (d.lookup ?? lookupIds)(
|
||||
s,
|
||||
rows.map((r) => r.subject),
|
||||
);
|
||||
} catch (err) {
|
||||
log.warn?.({ err: (err as Error).message }, 'directory check skipped');
|
||||
return directoryWarning(
|
||||
db,
|
||||
`The directory check couldn't reach the directory (${(err as Error).message}), so leavers aren't being disabled.`,
|
||||
);
|
||||
}
|
||||
const gone = rows.filter((r) => !found.has(r.subject));
|
||||
// A broken filter or base DN is likelier than a mass layoff.
|
||||
if (gone.length >= 3 && gone.length * 2 > rows.length) {
|
||||
log.warn?.({ gone: gone.length, of: rows.length }, 'directory check would disable most users');
|
||||
return directoryWarning(
|
||||
db,
|
||||
`The directory check would disable ${gone.length} of ${rows.length} people, so it disabled nobody. Check the base DN and user filter.`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const { u, subject } of rows) {
|
||||
const e = found.get(subject);
|
||||
if (e) {
|
||||
await db
|
||||
.update(userIdentities)
|
||||
.set({ email: e.email })
|
||||
.where(and(eq(userIdentities.userId, u.id), eq(userIdentities.kind, 'ldap')));
|
||||
await applyRole(db, log, u.id, e.admin, 'the directory', null);
|
||||
continue;
|
||||
}
|
||||
if (u.role === 'admin' && u.passwordHash && (await isLastAdmin(db, u.id))) {
|
||||
log.warn?.(
|
||||
{ userId: u.id },
|
||||
'kept the last admin with a password, though the directory lost them',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
await db.update(users).set({ isActive: false }).where(eq(users.id, u.id));
|
||||
await deleteUserSessions(db, u.id);
|
||||
await audit(db, {
|
||||
action: 'directory_disabled',
|
||||
entityType: 'user',
|
||||
entityId: u.id,
|
||||
summary: `${u.email} is no longer in the directory`,
|
||||
diff: { before: { isActive: true }, after: { isActive: false } },
|
||||
});
|
||||
}
|
||||
await directoryWarning(db, null);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
import { BerWriter, Client, UnwillingToPerformError } from 'ldapts';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
anyLogin,
|
||||
assertLdap,
|
||||
byIdFilter,
|
||||
DirectoryUnavailable,
|
||||
ldapAuthenticate,
|
||||
withLogin,
|
||||
type LdapSettings,
|
||||
} from './ldap.ts';
|
||||
|
||||
describe('LDAP (12 §4)', () => {
|
||||
const f = '(&(objectClass=person)(|(uid={login})(mail={login})))';
|
||||
|
||||
it('escapes the login, so it can only ever be a value (RFC 4515)', () => {
|
||||
expect(withLogin(f, 'ann')).toBe('(&(objectClass=person)(|(uid=ann)(mail=ann)))');
|
||||
expect(withLogin(f, '*)(uid=*))(|(uid=*')).toBe(
|
||||
'(&(objectClass=person)(|(uid=\\2a\\29\\28uid=\\2a\\29\\29\\28|\\28uid=\\2a)(mail=\\2a\\29\\28uid=\\2a\\29\\29\\28|\\28uid=\\2a)))',
|
||||
);
|
||||
expect(withLogin('(uid={login})', 'a\\b\u0000')).toBe('(uid=a\\5cb\\00)');
|
||||
});
|
||||
|
||||
it('{login} → * keeps the other conditions, for the leaver check', () => {
|
||||
expect(anyLogin(f)).toBe('(&(objectClass=person)(|(uid=*)(mail=*)))');
|
||||
});
|
||||
|
||||
it('finds a leaver by id, keeping the other conditions of the filter (§4.5)', () => {
|
||||
expect(byIdFilter({ idAttribute: 'entryUUID', userFilter: f }, 'a*b').toString()).toBe(
|
||||
'(&(entryUUID=a\\2ab)(&(objectClass=person)(|(uid=*)(mail=*))))',
|
||||
);
|
||||
// AD: objectGUID is stored as hex and searched as its bytes (§4.4).
|
||||
const guid = byIdFilter({ idAttribute: 'objectGUID', userFilter: '(cn={login})' }, '00ff2a');
|
||||
expect(guid.toString()).toBe('(&(objectGUID=\\00\\ff\\2a)(cn=*))');
|
||||
// …and goes out as those three bytes, not U+00FF in UTF-8.
|
||||
const ber = new BerWriter();
|
||||
guid.write(ber);
|
||||
expect(ber.buffer.includes(Buffer.from([0x04, 0x03, 0x00, 0xff, 0x2a]))).toBe(true);
|
||||
});
|
||||
|
||||
it('never sends a password in clear text to another host', () => {
|
||||
const ok = { startTls: false, userFilter: f, adminFilter: null };
|
||||
expect(() => assertLdap({ ...ok, url: 'ldap://dc1.example.com' })).toThrow(/clear text/);
|
||||
expect(() =>
|
||||
assertLdap({ ...ok, url: 'ldap://dc1.example.com', startTls: true }),
|
||||
).not.toThrow();
|
||||
expect(() => assertLdap({ ...ok, url: 'ldaps://dc1.example.com' })).not.toThrow();
|
||||
expect(() => assertLdap({ ...ok, url: 'ldap://127.0.0.1:3890' })).not.toThrow();
|
||||
expect(() => assertLdap({ ...ok, url: 'ldaps://x', startTls: true })).toThrow(/StartTLS/);
|
||||
});
|
||||
|
||||
it('refuses filters that do not parse', () => {
|
||||
const base = { url: 'ldaps://x', startTls: false, userFilter: f, adminFilter: null };
|
||||
expect(() => assertLdap({ ...base, userFilter: '(uid={login}' })).toThrow(/User filter/);
|
||||
expect(() => assertLdap({ ...base, adminFilter: 'memberOf=x)' })).toThrow(/Admin filter/);
|
||||
});
|
||||
|
||||
it('an empty password fails before anything is sent (no "unauthenticated bind")', async () => {
|
||||
// A directory that doesn't exist: any attempt to connect would throw DirectoryUnavailable.
|
||||
const s = { url: 'ldaps://192.0.2.1', startTls: false, userFilter: f } as LdapSettings;
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,265 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// LDAP / LDAPS password check (12 §4) on ldapts: search the person with the service account,
|
||||
// bind as them with their password, read whether the admin filter matches them.
|
||||
import {
|
||||
AndFilter,
|
||||
Client,
|
||||
EqualityFilter,
|
||||
FilterParser,
|
||||
InvalidCredentialsError,
|
||||
NoSuchObjectError,
|
||||
ResultCodeError,
|
||||
SizeLimitExceededError,
|
||||
escapeFilter,
|
||||
type Entry,
|
||||
} from 'ldapts';
|
||||
import type { SettingValue } from '@selfphoneng/shared';
|
||||
import { decrypt } from '../crypto/secrets.ts';
|
||||
|
||||
export type LdapSettings = NonNullable<SettingValue<'auth.ldap'>>;
|
||||
|
||||
/** The directory can't be asked (down, TLS, a refused service account): not a wrong password. */
|
||||
export class DirectoryUnavailable extends Error {}
|
||||
|
||||
const LOCAL = new Set(['127.0.0.1', '::1', '[::1]', 'localhost']);
|
||||
|
||||
/** 12 §4.1: no password in clear text over the network, and filters that parse. */
|
||||
export function assertLdap(s: {
|
||||
url: string;
|
||||
startTls: boolean;
|
||||
userFilter: string;
|
||||
adminFilter: string | null;
|
||||
}): void {
|
||||
const u = new URL(s.url);
|
||||
if (u.protocol === 'ldaps:' && s.startTls) throw new Error('StartTLS is for ldap:// addresses');
|
||||
if (u.protocol === 'ldap:' && !s.startTls && !LOCAL.has(u.hostname))
|
||||
throw new Error('ldap:// sends passwords in clear text: use ldaps://, or turn on StartTLS');
|
||||
for (const [name, f] of [
|
||||
['User filter', s.userFilter],
|
||||
['Admin filter', s.adminFilter],
|
||||
] as const)
|
||||
try {
|
||||
if (f) FilterParser.parseString(f.replaceAll('{login}', 'x'));
|
||||
} catch (err) {
|
||||
throw new Error(`${name}: ${(err as Error).message}`, { cause: err });
|
||||
}
|
||||
}
|
||||
|
||||
/** Every {login} replaced by the RFC 4515-escaped value (ldapts escapeFilter). */
|
||||
export function withLogin(filter: string, login: string): string {
|
||||
const parts = filter.split('{login}');
|
||||
const strings = Object.assign([...parts], { raw: parts }) as unknown as TemplateStringsArray;
|
||||
return escapeFilter(strings, ...parts.slice(1).map(() => login));
|
||||
}
|
||||
|
||||
/** {login} → * : the filter's other conditions (group, disabled bit) without the login (§4.5). */
|
||||
export const anyLogin = (filter: string) => filter.replaceAll('{login}', '*');
|
||||
|
||||
const first = (v: Entry[string] | undefined) => (Array.isArray(v) ? v[0] : v);
|
||||
/** One attribute as text: binary ids (AD's objectGUID) as lowercase hex (12 §4.4). */
|
||||
const attr = (e: Entry, name: string): string | null => {
|
||||
const v = first(e[name]);
|
||||
if (v === undefined) return null;
|
||||
return (Buffer.isBuffer(v) ? v.toString('hex') : String(v)) || null;
|
||||
};
|
||||
|
||||
const newClient = (s: LdapSettings) =>
|
||||
new Client({ url: s.url, timeout: 5000, connectTimeout: 5000 });
|
||||
|
||||
async function startTls(c: Client, s: LdapSettings) {
|
||||
if (s.startTls) await c.startTLS({ servername: new URL(s.url).hostname });
|
||||
}
|
||||
|
||||
/** A connection bound as the service account (or anonymous). */
|
||||
async function connect(s: LdapSettings): Promise<Client> {
|
||||
const c = newClient(s);
|
||||
try {
|
||||
await startTls(c, s);
|
||||
if (s.bindDn) {
|
||||
// A DN with an empty password is an anonymous "unauthenticated bind" (RFC 4513 §5.1.2).
|
||||
if (!s.bindPasswordEnc) throw new Error('The service account has no password');
|
||||
await c.bind(s.bindDn, decrypt(s.bindPasswordEnc));
|
||||
}
|
||||
return c;
|
||||
} catch (err) {
|
||||
await c.unbind().catch(() => {});
|
||||
throw new DirectoryUnavailable(
|
||||
err instanceof InvalidCredentialsError
|
||||
? 'The directory refused the service account'
|
||||
: (err as Error).message,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const attributes = (s: LdapSettings) => [s.idAttribute, s.emailAttribute, s.nameAttribute, 'cn'];
|
||||
const buffers = (s: LdapSettings) => (s.idAttribute === 'objectGUID' ? ['objectGUID'] : []);
|
||||
|
||||
/** The entries `login` matches (at most 2: more than one is as bad as none). */
|
||||
async function findByLogin(c: Client, s: LdapSettings, login: string): Promise<Entry[]> {
|
||||
try {
|
||||
const r = await c.search(s.baseDn, {
|
||||
scope: 'sub',
|
||||
filter: withLogin(s.userFilter, login),
|
||||
attributes: attributes(s),
|
||||
explicitBufferAttributes: buffers(s),
|
||||
sizeLimit: 2,
|
||||
});
|
||||
return r.searchEntries;
|
||||
} catch (err) {
|
||||
if (err instanceof SizeLimitExceededError) return [{ dn: '' }, { dn: '' }];
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export type DirectoryEntry = {
|
||||
dn: string;
|
||||
/** The stable id attribute's value (12 §4.4). */
|
||||
id: string;
|
||||
email: string | null;
|
||||
name: string | null;
|
||||
/** adminFilter's answer; null when there is none (the role is managed here). */
|
||||
admin: boolean | null;
|
||||
};
|
||||
|
||||
async function toEntry(c: Client, s: LdapSettings, e: Entry): Promise<DirectoryEntry> {
|
||||
const id = attr(e, s.idAttribute);
|
||||
if (!id) throw new DirectoryUnavailable(`${e.dn} has no ${s.idAttribute}`);
|
||||
let admin: boolean | null = null;
|
||||
if (s.adminFilter)
|
||||
try {
|
||||
const r = await c.search(e.dn, { scope: 'base', filter: s.adminFilter, attributes: ['1.1'] });
|
||||
admin = r.searchEntries.length > 0;
|
||||
} catch (err) {
|
||||
// lldap answers "no such object" when the entry doesn't match a base search's filter.
|
||||
if (!(err instanceof NoSuchObjectError)) throw err;
|
||||
admin = false;
|
||||
}
|
||||
return {
|
||||
dn: e.dn,
|
||||
id,
|
||||
email: attr(e, s.emailAttribute),
|
||||
name: attr(e, s.nameAttribute) ?? attr(e, 'cn'),
|
||||
admin,
|
||||
};
|
||||
}
|
||||
|
||||
/** Is this the password of that entry? Its own connection, so the service one stays bound. */
|
||||
async function passwordWorks(s: LdapSettings, dn: string, password: string): Promise<boolean> {
|
||||
const u = newClient(s);
|
||||
try {
|
||||
await startTls(u, s);
|
||||
await u.bind(dn, password);
|
||||
return true;
|
||||
} catch (err) {
|
||||
// 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(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
const unavailable = (err: unknown) =>
|
||||
err instanceof DirectoryUnavailable ? err : new DirectoryUnavailable((err as Error).message);
|
||||
|
||||
/**
|
||||
* 12 §4.2 step 3: the directory entry for this login and password, or null when they don't
|
||||
* match exactly one entry with that password. Throws DirectoryUnavailable otherwise.
|
||||
*/
|
||||
export async function ldapAuthenticate(
|
||||
s: LdapSettings,
|
||||
login: string,
|
||||
password: string,
|
||||
): Promise<DirectoryEntry | null> {
|
||||
// Never bind with an empty password: many servers take it as anonymous success.
|
||||
if (!password) return null;
|
||||
const c = await connect(s);
|
||||
try {
|
||||
const found = await findByLogin(c, s, login);
|
||||
if (found.length !== 1) return null;
|
||||
if (!(await passwordWorks(s, found[0]!.dn, password))) return null;
|
||||
return await toEntry(c, s, found[0]!);
|
||||
} catch (err) {
|
||||
throw unavailable(err);
|
||||
} finally {
|
||||
await c.unbind().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/** `(&(<id attribute>=<id>)<userFilter with {login} → *>)`: still there, and still let in (§4.5). */
|
||||
export function byIdFilter(s: Pick<LdapSettings, 'idAttribute' | 'userFilter'>, id: string) {
|
||||
// An object, not a string: ldapts parses "\ff" in a string filter to U+00FF and sends it as
|
||||
// UTF-8 (two bytes), so a binary objectGUID would never match. A Buffer goes out as is.
|
||||
const value = s.idAttribute === 'objectGUID' ? Buffer.from(id, 'hex') : id;
|
||||
return new AndFilter({
|
||||
filters: [
|
||||
new EqualityFilter({ attribute: s.idAttribute, value }),
|
||||
FilterParser.parseString(anyLogin(s.userFilter)),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The leaver check (12 §4.5): each id → its entry, when the directory still has it and the user
|
||||
* filter still lets it in. One connection for all. Throws DirectoryUnavailable on any failure,
|
||||
* so a half-done run never reads as "gone".
|
||||
*/
|
||||
export async function lookupIds(
|
||||
s: LdapSettings,
|
||||
ids: string[],
|
||||
): Promise<Map<string, DirectoryEntry>> {
|
||||
const c = await connect(s);
|
||||
try {
|
||||
const found = new Map<string, DirectoryEntry>();
|
||||
for (const id of ids) {
|
||||
const r = await c.search(s.baseDn, {
|
||||
scope: 'sub',
|
||||
filter: byIdFilter(s, id),
|
||||
attributes: attributes(s),
|
||||
explicitBufferAttributes: buffers(s),
|
||||
sizeLimit: 2,
|
||||
});
|
||||
if (r.searchEntries.length === 1) found.set(id, await toEntry(c, s, r.searchEntries[0]!));
|
||||
}
|
||||
return found;
|
||||
} catch (err) {
|
||||
throw unavailable(err);
|
||||
} finally {
|
||||
await c.unbind().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/** Settings → Sign-in → Directory → Test (12 §4.1), step by step so it can say which failed. */
|
||||
export async function testLdap(
|
||||
s: LdapSettings,
|
||||
login?: string,
|
||||
password?: string,
|
||||
): Promise<{ matching: number; user?: DirectoryEntry }> {
|
||||
const c = await connect(s);
|
||||
try {
|
||||
let matching: number;
|
||||
try {
|
||||
const r = await c.search(s.baseDn, {
|
||||
scope: 'sub',
|
||||
filter: anyLogin(s.userFilter),
|
||||
attributes: ['1.1'],
|
||||
sizeLimit: 1000,
|
||||
});
|
||||
matching = r.searchEntries.length;
|
||||
} catch (err) {
|
||||
if (!(err instanceof SizeLimitExceededError)) throw err;
|
||||
matching = 1000;
|
||||
}
|
||||
if (!login) return { matching };
|
||||
const found = await findByLogin(c, s, login);
|
||||
if (found.length === 0) throw new Error(`No entry under ${s.baseDn} matches "${login}"`);
|
||||
if (found.length > 1) throw new Error(`More than one entry matches "${login}"`);
|
||||
if (!password || !(await passwordWorks(s, found[0]!.dn, password)))
|
||||
throw new Error(`The directory refused the password for ${found[0]!.dn}`);
|
||||
return { matching, user: await toEntry(c, s, found[0]!) };
|
||||
} finally {
|
||||
await c.unbind().catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// OpenID Connect relying party (12 §3) on openid-client: discovery, PKCE + state + nonce, the
|
||||
// code grant, userinfo, test, signing out at the provider. No protocol code of our own.
|
||||
import * as oidc from 'openid-client';
|
||||
import type { OidcTestResult, SettingValue } from '@selfphoneng/shared';
|
||||
import { decrypt } from '../crypto/secrets.ts';
|
||||
|
||||
export type OidcSettings = NonNullable<SettingValue<'auth.oidc'>>;
|
||||
|
||||
/** http:// only for the fake provider in tests; a real provider is always https (12 §3.1). */
|
||||
const insecureOk = (u: URL) =>
|
||||
process.env.NODE_ENV === 'test' && ['127.0.0.1', 'localhost'].includes(u.hostname);
|
||||
|
||||
export function assertIssuer(issuer: string): void {
|
||||
const u = new URL(issuer);
|
||||
if (u.protocol !== 'https:' && !insecureOk(u)) throw new Error('The issuer must be https://');
|
||||
}
|
||||
|
||||
export const redirectUri = (host: string) => `https://${host}/api/auth/oidc/callback`;
|
||||
export const postLogoutUri = (host: string) => `https://${host}/login`;
|
||||
|
||||
let cached: { key: string; config: oidc.Configuration } | null = null;
|
||||
|
||||
/**
|
||||
* Discovers the provider once per settings value (a change or a failure means the next call
|
||||
* discovers again) and picks client_secret_basic, else _post, else none for a public client.
|
||||
*/
|
||||
export async function oidcConfig(s: OidcSettings): Promise<oidc.Configuration> {
|
||||
const key = JSON.stringify(s);
|
||||
if (cached?.key === key) return cached.config;
|
||||
const issuer = new URL(s.issuer);
|
||||
assertIssuer(s.issuer);
|
||||
const insecure = insecureOk(issuer);
|
||||
const found = await oidc.discovery(issuer, s.clientId, undefined, oidc.None(), {
|
||||
timeout: 5,
|
||||
...(insecure && { execute: [oidc.allowInsecureRequests] }),
|
||||
});
|
||||
const meta = found.serverMetadata();
|
||||
const secret = s.clientSecretEnc ? decrypt(s.clientSecretEnc) : null;
|
||||
const methods = meta.token_endpoint_auth_methods_supported ?? ['client_secret_basic'];
|
||||
const auth = !secret
|
||||
? oidc.None()
|
||||
: methods.includes('client_secret_basic') || !methods.includes('client_secret_post')
|
||||
? oidc.ClientSecretBasic(secret)
|
||||
: oidc.ClientSecretPost(secret);
|
||||
const config = new oidc.Configuration(meta, s.clientId, undefined, auth);
|
||||
config.timeout = 5;
|
||||
// Check ID token signatures too (by default TLS to the token endpoint is trusted instead).
|
||||
oidc.enableNonRepudiationChecks(config);
|
||||
if (insecure) oidc.allowInsecureRequests(config);
|
||||
cached = { key, config };
|
||||
return config;
|
||||
}
|
||||
|
||||
/** What the sp_oidc cookie holds between start and callback (12 §3.2). */
|
||||
export type PendingSignIn = {
|
||||
state: string;
|
||||
nonce: string;
|
||||
verifier: string;
|
||||
next: string;
|
||||
/** Connecting from My account: the session that asked (12 §3.3). */
|
||||
connectSessionId: string | null;
|
||||
exp: number;
|
||||
};
|
||||
|
||||
export async function startSignIn(
|
||||
s: OidcSettings,
|
||||
host: string,
|
||||
next: string,
|
||||
connectSessionId: string | null,
|
||||
): Promise<{ url: URL; pending: PendingSignIn }> {
|
||||
const config = await oidcConfig(s);
|
||||
const pending: PendingSignIn = {
|
||||
state: oidc.randomState(),
|
||||
nonce: oidc.randomNonce(),
|
||||
verifier: oidc.randomPKCECodeVerifier(),
|
||||
next,
|
||||
connectSessionId,
|
||||
exp: Date.now() + 600_000,
|
||||
};
|
||||
const url = oidc.buildAuthorizationUrl(config, {
|
||||
redirect_uri: redirectUri(host),
|
||||
scope: s.scopes,
|
||||
state: pending.state,
|
||||
nonce: pending.nonce,
|
||||
code_challenge: await oidc.calculatePKCECodeChallenge(pending.verifier),
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
return { url, pending };
|
||||
}
|
||||
|
||||
export type SignInResult = {
|
||||
/** "<iss> <sub>" (12 §2). */
|
||||
subject: string;
|
||||
/** The ID token's claims, then userinfo's on top. */
|
||||
claims: Record<string, unknown>;
|
||||
idToken: string;
|
||||
refreshToken: string | null;
|
||||
};
|
||||
|
||||
/** Checks the provider's answer (state, PKCE, nonce, ID token) and reads userinfo. */
|
||||
export async function finishSignIn(
|
||||
s: OidcSettings,
|
||||
currentUrl: URL,
|
||||
pending: PendingSignIn,
|
||||
): Promise<SignInResult> {
|
||||
const config = await oidcConfig(s);
|
||||
const tokens = await oidc.authorizationCodeGrant(config, currentUrl, {
|
||||
pkceCodeVerifier: pending.verifier,
|
||||
expectedState: pending.state,
|
||||
expectedNonce: pending.nonce,
|
||||
idTokenExpected: true,
|
||||
});
|
||||
const id = tokens.claims()!;
|
||||
let claims: Record<string, unknown> = { ...id };
|
||||
// Several providers put email or groups only in userinfo (12 §3.2 step 4).
|
||||
if (config.serverMetadata().userinfo_endpoint)
|
||||
claims = { ...claims, ...(await oidc.fetchUserInfo(config, tokens.access_token, id.sub)) };
|
||||
return {
|
||||
subject: `${id.iss} ${id.sub}`,
|
||||
claims,
|
||||
idToken: tokens.id_token!,
|
||||
refreshToken: tokens.refresh_token ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** RP-initiated logout (12 §3.5): where to send the browser, or null when the provider can't. */
|
||||
export async function endSessionUrl(
|
||||
s: OidcSettings,
|
||||
host: string,
|
||||
idToken: string,
|
||||
): Promise<string | null> {
|
||||
const config = await oidcConfig(s);
|
||||
if (!config.serverMetadata().end_session_endpoint) return null;
|
||||
return oidc
|
||||
.buildEndSessionUrl(config, {
|
||||
id_token_hint: idToken,
|
||||
post_logout_redirect_uri: postLogoutUri(host),
|
||||
})
|
||||
.toString();
|
||||
}
|
||||
|
||||
/** A provider error as one line for the log and the admin (never a token). */
|
||||
export function oidcError(err: unknown): string {
|
||||
if (err instanceof oidc.ResponseBodyError)
|
||||
return `${err.error}${err.error_description ? `: ${err.error_description}` : ''}`;
|
||||
if (err instanceof oidc.AuthorizationResponseError)
|
||||
return `${err.error}${err.error_description ? `: ${err.error_description}` : ''}`;
|
||||
return (err as Error).message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings → Sign-in → Test (12 §3.1): discovery, then a code grant with a code that can't
|
||||
* exist: `invalid_grant` means the client id and secret were accepted.
|
||||
*/
|
||||
export async function testOidc(s: OidcSettings, host: string): Promise<OidcTestResult> {
|
||||
try {
|
||||
cached = null;
|
||||
const config = await oidcConfig(s);
|
||||
const meta = config.serverMetadata();
|
||||
try {
|
||||
await oidc.genericGrantRequest(config, 'authorization_code', {
|
||||
code: 'selfphoneng-test-not-a-code',
|
||||
redirect_uri: redirectUri(host),
|
||||
code_verifier: oidc.randomPKCECodeVerifier(),
|
||||
});
|
||||
} catch (err) {
|
||||
const code = err instanceof oidc.ResponseBodyError ? err.error : null;
|
||||
if (code === 'invalid_client' || code === 'unauthorized_client')
|
||||
return { ok: false, error: `The provider refused the client ID or secret (${code})` };
|
||||
if (code !== 'invalid_grant' && code !== 'invalid_request')
|
||||
return { ok: false, error: `The token endpoint answered: ${oidcError(err)}` };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
issuer: meta.issuer,
|
||||
algorithms: meta.id_token_signing_alg_values_supported ?? [],
|
||||
pkce: meta.code_challenge_methods_supported?.includes('S256') ?? false,
|
||||
refreshTokens: meta.grant_types_supported?.includes('refresh_token') ?? false,
|
||||
endSession: Boolean(meta.end_session_endpoint),
|
||||
redirectUri: redirectUri(host),
|
||||
postLogoutRedirectUri: postLogoutUri(host),
|
||||
};
|
||||
} catch (err) {
|
||||
return { ok: false, error: oidcError(err) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// What the provider says about a user, applied (12 §3.4), and the leaver check (12 §3.5): every
|
||||
// OpenID session is refreshed at the provider every 15 minutes, and ends when it says no.
|
||||
import { and, eq, gt, isNotNull, isNull, lt, or } from 'drizzle-orm';
|
||||
import * as oidc from 'openid-client';
|
||||
import type { Db } from '../db/client.ts';
|
||||
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 { claimAt, decide, type ClaimsDecision } from './claims.ts';
|
||||
import { applyRole } from './role.ts';
|
||||
import { oidcConfig, oidcError, type OidcSettings } from './oidc.ts';
|
||||
|
||||
type Log = { info(o: object, m: string): void; warn?(o: object, m: string): void };
|
||||
|
||||
/** The role from adminGroups (12 §3.4), and the groups and email on the identity. */
|
||||
export async function applyClaims(
|
||||
db: Db,
|
||||
log: Log,
|
||||
s: OidcSettings,
|
||||
userId: string,
|
||||
d: ClaimsDecision,
|
||||
email: string | null,
|
||||
actorIp: string | null,
|
||||
): Promise<void> {
|
||||
await applyRole(db, log, userId, d.admin, `${s.label}'s groups`, actorIp);
|
||||
await db
|
||||
.update(userIdentities)
|
||||
.set({ email, groups: d.groups })
|
||||
.where(and(eq(userIdentities.userId, userId), eq(userIdentities.kind, 'oidc')));
|
||||
}
|
||||
|
||||
const CHECK_EVERY_MS = 15 * 60_000;
|
||||
|
||||
/** One run of the oidcCheck job (every 5 minutes): sessions not checked for 15 minutes. */
|
||||
export async function checkOidcSessions(d: { db: Db; log: Log; now?: Date }): Promise<void> {
|
||||
const { db, log } = d;
|
||||
const now = d.now ?? new Date();
|
||||
const s = await getSetting(db, 'auth.oidc');
|
||||
if (!s) return;
|
||||
const due = await db
|
||||
.select({
|
||||
id: sessions.id,
|
||||
userId: sessions.userId,
|
||||
refreshEnc: sessions.idpRefreshEnc,
|
||||
email: users.email,
|
||||
subject: userIdentities.subject,
|
||||
})
|
||||
.from(sessions)
|
||||
.innerJoin(users, eq(users.id, sessions.userId))
|
||||
.leftJoin(
|
||||
userIdentities,
|
||||
and(eq(userIdentities.userId, sessions.userId), eq(userIdentities.kind, 'oidc')),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(sessions.method, 'oidc'),
|
||||
isNotNull(sessions.idpRefreshEnc),
|
||||
gt(sessions.expiresAt, now),
|
||||
or(
|
||||
isNull(sessions.idpCheckedAt),
|
||||
lt(sessions.idpCheckedAt, new Date(now.getTime() - CHECK_EVERY_MS)),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (!due.length) return;
|
||||
let config: oidc.Configuration;
|
||||
try {
|
||||
config = await oidcConfig(s);
|
||||
} catch (err) {
|
||||
log.warn?.({ err: oidcError(err) }, 'OpenID check skipped: the provider is unreachable');
|
||||
return;
|
||||
}
|
||||
|
||||
const end = async (row: (typeof due)[number], why: string) => {
|
||||
await db.delete(sessions).where(eq(sessions.id, row.id));
|
||||
await audit(db, {
|
||||
actorUserId: row.userId,
|
||||
action: 'logout',
|
||||
entityType: 'user',
|
||||
entityId: row.userId,
|
||||
summary: `${row.email} signed out: ${why}`,
|
||||
});
|
||||
};
|
||||
|
||||
for (const row of due) {
|
||||
// Disconnected since (Disconnect also ends these sessions; this is the belt to its braces).
|
||||
if (!row.subject) {
|
||||
await end(row, `no ${s.label} account connected any more`);
|
||||
continue;
|
||||
}
|
||||
let tokens: Awaited<ReturnType<typeof oidc.refreshTokenGrant>>;
|
||||
let claims: Record<string, unknown>;
|
||||
try {
|
||||
tokens = await oidc.refreshTokenGrant(config, decrypt(row.refreshEnc!));
|
||||
const sub = row.subject.slice(row.subject.indexOf(' ') + 1);
|
||||
// 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') {
|
||||
await end(row, `${s.label} ended the session (${oidcError(err)})`);
|
||||
continue;
|
||||
}
|
||||
// Down, 5xx, a bad client setup: keep the session and try again next run.
|
||||
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`);
|
||||
continue;
|
||||
}
|
||||
await db
|
||||
.update(sessions)
|
||||
.set({
|
||||
idpCheckedAt: now,
|
||||
...(tokens.refresh_token && { idpRefreshEnc: encrypt(tokens.refresh_token) }),
|
||||
...(tokens.id_token && { idpIdTokenEnc: encrypt(tokens.id_token) }),
|
||||
})
|
||||
.where(eq(sessions.id, row.id));
|
||||
const email = typeof claims.email === 'string' ? claims.email : null;
|
||||
await applyClaims(db, log, s, row.userId, verdict, email, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// The role a provider says (12 §1): OpenID adminGroups or the LDAP adminFilter.
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { users } from '../db/schema.ts';
|
||||
import { isLastAdmin } from '../routes/users.ts';
|
||||
import { audit } from '../services/audit.ts';
|
||||
|
||||
/** admin null: the role is managed here. Never demotes the last admin with a password. */
|
||||
export async function applyRole(
|
||||
db: Db,
|
||||
log: { info(o: object, m: string): void },
|
||||
userId: string,
|
||||
admin: boolean | null,
|
||||
source: string,
|
||||
actorIp: string | null,
|
||||
): Promise<void> {
|
||||
if (admin === null) return;
|
||||
const [user] = await db.select().from(users).where(eq(users.id, userId));
|
||||
const role = admin ? 'admin' : 'user';
|
||||
if (!user || role === user.role) return;
|
||||
if (role === 'user' && (await isLastAdmin(db, userId))) {
|
||||
log.info({ userId }, `kept the last admin with a password, though ${source} says user`);
|
||||
return;
|
||||
}
|
||||
await db.update(users).set({ role }).where(eq(users.id, userId));
|
||||
await audit(db, {
|
||||
actorIp: actorIp ?? undefined,
|
||||
action: 'update',
|
||||
entityType: 'user',
|
||||
entityId: userId,
|
||||
summary: `${user.email} is now ${role}, from ${source}`,
|
||||
diff: { before: { role: user.role }, after: { role } },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// 12 S5: the Active Directory preset against a real Samba AD DC: sign-in by sAMAccountName, UPN
|
||||
// and mail, admin through a nested group (LDAP_MATCHING_RULE_IN_CHAIN), the disabled bit,
|
||||
// objectGUID as hex, and directoryCheck disabling a deleted user. Needs AD_LIVE_URL (ldaps://…,
|
||||
// with NODE_EXTRA_CA_CERTS naming its CA) and AD_LIVE_ADMIN_PASS (the Administrator's).
|
||||
// Passed against Samba 4 (diegogslomp/samba-ad-dc), its smb.conf given a throwaway certificate
|
||||
// for 127.0.0.1 (`tls keyfile`, `tls certfile`, `tls cafile`):
|
||||
// docker run -d --privileged -p 127.0.0.1:11636:636 -e REALM=EXAMPLE.COM -e DOMAIN=EXAMPLE \
|
||||
// -e ADMIN_PASS=… -e DNS_FORWARDER=1.1.1.1 -e BIND_NETWORK_INTERFACES=false \
|
||||
// diegogslomp/samba-ad-dc
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { Attribute, Client } from 'ldapts';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { LDAP_PRESETS } from '@selfphoneng/shared';
|
||||
import { resetLoginLimiter } from '../src/auth/login-limiter.ts';
|
||||
import { encrypt } from '../src/crypto/secrets.ts';
|
||||
import { userIdentities, users } from '../src/db/schema.ts';
|
||||
import { setSetting } from '../src/services/settings.ts';
|
||||
import { checkDirectory } from '../src/sso/directory.ts';
|
||||
import { hasTestDb } from './db.ts';
|
||||
import { H, createTestApp } from './app.ts';
|
||||
|
||||
const URL_ = process.env.AD_LIVE_URL;
|
||||
const ADMIN_PASS = process.env.AD_LIVE_ADMIN_PASS ?? '';
|
||||
const DC = 'DC=example,DC=com';
|
||||
const BASE = `CN=Users,${DC}`;
|
||||
const ADMIN_DN = `CN=Administrator,${BASE}`;
|
||||
|
||||
describe.skipIf(!hasTestDb || !URL_)('Active Directory with a real Samba AD DC (12 S5)', () => {
|
||||
const tag = randomBytes(3).toString('hex');
|
||||
const [ann, bob, dan, cat] = ['ann', 'bob', 'dan', 'cat'].map((n) => `${n}${tag}`) as [
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
];
|
||||
const team = `CN=team-${tag},${BASE}`;
|
||||
const admins = `CN=pbx-admins-${tag},${BASE}`;
|
||||
const dn = (sam: string) => `CN=${sam},${BASE}`;
|
||||
let t: Awaited<ReturnType<typeof createTestApp>>;
|
||||
let ad: Client;
|
||||
|
||||
const login = (who: string, password: string) =>
|
||||
t.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/auth/login',
|
||||
headers: H,
|
||||
payload: { login: who, password },
|
||||
});
|
||||
const row = async (sam: string) =>
|
||||
(
|
||||
await t.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, `${sam}@example.com`))
|
||||
)[0]!;
|
||||
|
||||
beforeAll(async () => {
|
||||
ad = new Client({ url: URL_! });
|
||||
await ad.bind(ADMIN_DN, ADMIN_PASS);
|
||||
// userAccountControl 512: a normal account; 514: the same, disabled.
|
||||
for (const [sam, uac] of [
|
||||
[ann, '512'],
|
||||
[bob, '512'],
|
||||
[dan, '514'],
|
||||
[cat, '512'],
|
||||
] as const)
|
||||
await ad.add(dn(sam), [
|
||||
...Object.entries({
|
||||
objectClass: ['top', 'person', 'organizationalPerson', 'user'],
|
||||
sAMAccountName: [sam],
|
||||
userPrincipalName: [`${sam}@example.com`],
|
||||
mail: [`${sam}@example.com`],
|
||||
displayName: [sam],
|
||||
userAccountControl: [uac],
|
||||
}).map(([type, values]) => new Attribute({ type, values })),
|
||||
// AD's password: the quoted string in UTF-16LE, only over an encrypted connection.
|
||||
new Attribute({
|
||||
type: 'unicodePwd',
|
||||
values: [Buffer.from(`"${sam}-Directory-Pw1"`, 'utf16le')],
|
||||
}),
|
||||
]);
|
||||
// ann is in team, team is in pbx-admins: only the nested-group rule finds her admin.
|
||||
await ad.add(team, {
|
||||
objectClass: ['top', 'group'],
|
||||
sAMAccountName: `team-${tag}`,
|
||||
member: dn(ann),
|
||||
});
|
||||
await ad.add(admins, {
|
||||
objectClass: ['top', 'group'],
|
||||
sAMAccountName: `pbx-admins-${tag}`,
|
||||
member: team,
|
||||
});
|
||||
|
||||
t = await createTestApp();
|
||||
// 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',
|
||||
url: URL_!,
|
||||
startTls: false,
|
||||
bindDn: ADMIN_DN,
|
||||
bindPasswordEnc: encrypt(ADMIN_PASS),
|
||||
baseDn: BASE,
|
||||
userFilter: LDAP_PRESETS.ad.userFilter,
|
||||
idAttribute: LDAP_PRESETS.ad.idAttribute,
|
||||
emailAttribute: 'mail',
|
||||
nameAttribute: 'displayName',
|
||||
adminFilter: `(memberOf:1.2.840.113556.1.4.1941:=${admins})`,
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await t?.close();
|
||||
for (const d of [admins, team, ...[ann, bob, dan, cat].map(dn)])
|
||||
await ad?.del(d).catch(() => {});
|
||||
await ad?.unbind();
|
||||
});
|
||||
|
||||
it('signs in by sAMAccountName; a nested group makes an admin; objectGUID is stored as hex', async () => {
|
||||
resetLoginLimiter();
|
||||
const r = await login(ann, `${ann}-Directory-Pw1`);
|
||||
expect(r.statusCode).toBe(200);
|
||||
expect(r.json().user).toMatchObject({ role: 'admin', signIn: { ldap: true } });
|
||||
const [ident] = await t.db
|
||||
.select()
|
||||
.from(userIdentities)
|
||||
.where(eq(userIdentities.userId, (await row(ann)).id));
|
||||
expect(ident!.subject).toMatch(/^[0-9a-f]{32}$/);
|
||||
});
|
||||
|
||||
it('signs in by UPN and by mail; outside the group is a user', async () => {
|
||||
resetLoginLimiter();
|
||||
expect((await login(`${bob}@example.com`, `${bob}-Directory-Pw1`)).json().user.role).toBe(
|
||||
'user',
|
||||
);
|
||||
resetLoginLimiter();
|
||||
expect((await login(`${ann}@example.com`, `${ann}-Directory-Pw1`)).statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('a disabled account (userAccountControl bit 2) is refused', async () => {
|
||||
resetLoginLimiter();
|
||||
expect((await login(dan, `${dan}-Directory-Pw1`)).statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('directoryCheck disables a deleted user and keeps the rest', async () => {
|
||||
resetLoginLimiter();
|
||||
expect((await login(cat, `${cat}-Directory-Pw1`)).statusCode).toBe(200);
|
||||
await ad.del(dn(cat));
|
||||
await checkDirectory({ db: t.db, log: { info: () => {}, warn: () => {} } });
|
||||
expect((await row(cat)).isActive).toBe(false);
|
||||
expect(await row(ann)).toMatchObject({ isActive: true, role: 'admin' });
|
||||
expect((await row(bob)).isActive).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -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,28 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// A new Asterisk in asterisk/Dockerfile must go through the upgrade check first (verified.ts).
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { VERIFIED_ASTERISK, pinnedAsterisk } from './asterisk/verified.ts';
|
||||
|
||||
describe('the pinned Asterisk', () => {
|
||||
it('is the version the upgrade check last passed on', () => {
|
||||
expect(
|
||||
pinnedAsterisk(),
|
||||
`asterisk/Dockerfile pins ${pinnedAsterisk()}, the upgrade check last passed on ` +
|
||||
`${VERIFIED_ASTERISK}. Build the image, run \`pnpm test:asterisk-upgrade\` and ` +
|
||||
'`pnpm test:asterisk`, act on what moved, then set VERIFIED_ASTERISK in ' +
|
||||
'apps/api/test/asterisk/verified.ts',
|
||||
).toBe(VERIFIED_ASTERISK);
|
||||
});
|
||||
|
||||
it('is the one spec 01 §2 names, and its copy of the Dockerfile is the file', () => {
|
||||
const spec = readFileSync(
|
||||
new URL('../../../docs/spec/01-infrastructure.md', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
expect(spec).toContain(`**Pinned: \`${pinnedAsterisk()}\``);
|
||||
const copy = /^## 2\. [\s\S]*?^```dockerfile\n([\s\S]*?)^```$/m.exec(spec)?.[1];
|
||||
const file = readFileSync(new URL('../../../asterisk/Dockerfile', import.meta.url), 'utf8');
|
||||
expect(copy, 'spec 01 §2 has a copy of asterisk/Dockerfile: keep it the same').toBe(file);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import { SettingsSchemas, type SettingKey } from '@selfphoneng/shared';
|
||||
import { AmiClient } from '../../src/ami/client.ts';
|
||||
import { GENERATED_FILES, render } from '../../src/generator/index.ts';
|
||||
import type { Snapshot } from '../../src/generator/load.ts';
|
||||
import { testManagerConf } from './manager-conf.ts';
|
||||
|
||||
const IMAGE = process.env.ASTERISK_IMAGE ?? 'selfphoneng-asterisk';
|
||||
const CALL_SECONDS = Number(process.env.CALL_SECONDS ?? 30);
|
||||
@@ -126,11 +127,8 @@ async function main() {
|
||||
spawnSync('mkdir', ['-p', conf]);
|
||||
const files = render(snapshot());
|
||||
for (const f of GENERATED_FILES) writeFileSync(join(conf, f), files[f]);
|
||||
// Test-only manager.conf: reachable through the published port (the real one is 127.0.0.1-only).
|
||||
writeFileSync(
|
||||
join(dir, 'manager.conf.template'),
|
||||
'[general]\nenabled = yes\nport = 5038\nbindaddr = 0.0.0.0\n\n[selfphoneng]\nsecret = ${AMI_SECRET}\npermit = 0.0.0.0/0.0.0.0\nread = all\nwrite = all\n',
|
||||
);
|
||||
// The real manager.conf, reachable through the published port (test/asterisk/manager-conf.ts).
|
||||
writeFileSync(join(dir, 'manager.conf.template'), testManagerConf());
|
||||
phoneConfig(dir, '101', 5070, 440);
|
||||
phoneConfig(dir, '102', 5072, 880);
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import type { AmiMessage } from '../../src/ami/events.ts';
|
||||
import { GENERATED_FILES, render } from '../../src/generator/index.ts';
|
||||
import type { Snapshot, SnapExtension } from '../../src/generator/load.ts';
|
||||
import { providerDialplan, providerPjsip } from './provider.ts';
|
||||
import { testManagerConf } from './manager-conf.ts';
|
||||
|
||||
const IMAGE = process.env.ASTERISK_IMAGE ?? 'selfphoneng-asterisk';
|
||||
const OUT = fileURLToPath(new URL('../fixtures/cdr/', import.meta.url));
|
||||
@@ -243,10 +244,7 @@ async function main() {
|
||||
);
|
||||
}
|
||||
const runPhones = phoneFiles(dir);
|
||||
writeFileSync(
|
||||
join(dir, 'manager.conf.template'),
|
||||
'[general]\nenabled = yes\nport = 5038\nbindaddr = 0.0.0.0\n\n[selfphoneng]\nsecret = ${AMI_SECRET}\npermit = 0.0.0.0/0.0.0.0\nread = all\nwrite = all\n',
|
||||
);
|
||||
writeFileSync(join(dir, 'manager.conf.template'), testManagerConf());
|
||||
const ami = new AmiClient({
|
||||
host: '127.0.0.1',
|
||||
port: AMI_PORT,
|
||||
|
||||
@@ -17,6 +17,7 @@ import { AmiClient } from '../../src/ami/client.ts';
|
||||
import { GENERATED_FILES, render } from '../../src/generator/index.ts';
|
||||
import type { Snapshot } from '../../src/generator/load.ts';
|
||||
import { providerPjsip } from './provider.ts';
|
||||
import { testManagerConf } from './manager-conf.ts';
|
||||
|
||||
const IMAGE = process.env.ASTERISK_IMAGE ?? 'selfphoneng-asterisk';
|
||||
const NET = 'sp-cnam';
|
||||
@@ -223,10 +224,7 @@ async function main() {
|
||||
);
|
||||
}
|
||||
phoneFiles(dir);
|
||||
writeFileSync(
|
||||
join(dir, 'manager.conf.template'),
|
||||
'[general]\nenabled = yes\nport = 5038\nbindaddr = 0.0.0.0\n\n[selfphoneng]\nsecret = ${AMI_SECRET}\npermit = 0.0.0.0/0.0.0.0\nread = all\nwrite = all\n',
|
||||
);
|
||||
writeFileSync(join(dir, 'manager.conf.template'), testManagerConf());
|
||||
const problems: string[] = [];
|
||||
const ami = new AmiClient({
|
||||
host: '127.0.0.1',
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// The real manager.conf.template, read/write classes included, so a class the api needs and the
|
||||
// template lacks fails the tests. Only opened up for the host to reach AMI through the published
|
||||
// port: the real one is 127.0.0.1-only.
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
export function testManagerConf(): string {
|
||||
const real = readFileSync(
|
||||
new URL('../../../../asterisk/conf/manager.conf.template', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const open = real
|
||||
.replace(/^bindaddr = 127\.0\.0\.1$/m, 'bindaddr = 0.0.0.0')
|
||||
.replace(/^permit = 127\.0\.0\.1\/255\.255\.255\.255$/m, 'permit = 0.0.0.0/0.0.0.0');
|
||||
if (open.includes('127.0.0.1'))
|
||||
throw new Error('manager.conf.template changed: update manager-conf.ts');
|
||||
return open;
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { GENERATED_FILES, render } from '../../src/generator/index.ts';
|
||||
import type { Snapshot } from '../../src/generator/load.ts';
|
||||
|
||||
import { providerDialplan, providerPjsip } from './provider.ts';
|
||||
import { testManagerConf } from './manager-conf.ts';
|
||||
|
||||
const IMAGE = process.env.ASTERISK_IMAGE ?? 'selfphoneng-asterisk';
|
||||
const RECORD = process.argv.includes('--record')
|
||||
@@ -200,10 +201,7 @@ async function main() {
|
||||
);
|
||||
}
|
||||
phoneFiles(dir);
|
||||
writeFileSync(
|
||||
join(dir, 'manager.conf.template'),
|
||||
'[general]\nenabled = yes\nport = 5038\nbindaddr = 0.0.0.0\n\n[selfphoneng]\nsecret = ${AMI_SECRET}\npermit = 0.0.0.0/0.0.0.0\nread = all\nwrite = all\n',
|
||||
);
|
||||
writeFileSync(join(dir, 'manager.conf.template'), testManagerConf());
|
||||
|
||||
const problems: string[] = [];
|
||||
let recorder: Socket | undefined;
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Asterisk upgrade check (01 §2): boot the pinned image and measure each behaviour in verified.ts,
|
||||
// then list every one that moved and what depends on it. A second Asterisk plays a provider on
|
||||
// TLS with a certificate from our own CA for "provider.test", so the qualify check sees a real
|
||||
// host name. Needs Docker and the image built from asterisk/Dockerfile (ASTERISK_IMAGE, default
|
||||
// "selfphoneng-asterisk").
|
||||
// Usage: pnpm test:asterisk-upgrade
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { cpSync, mkdtempSync, readdirSync, rmSync, writeFileSync, appendFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { AmiClient } from '../../src/ami/client.ts';
|
||||
import { field, type AmiMessage } from '../../src/ami/events.ts';
|
||||
import { FACTS, VERIFIED_ASTERISK, pinnedAsterisk, type Fact } from './verified.ts';
|
||||
import { testManagerConf } from './manager-conf.ts';
|
||||
|
||||
const IMAGE = process.env.ASTERISK_IMAGE ?? 'selfphoneng-asterisk';
|
||||
const GOLDEN = fileURLToPath(
|
||||
new URL('../../src/generator/__golden__/one-user/expected', import.meta.url),
|
||||
);
|
||||
const NET = 'sp-upgrade';
|
||||
const PBX = 'sp-upgrade-pbx';
|
||||
const PROVIDER = 'sp-upgrade-provider';
|
||||
const HOST = 'provider.test';
|
||||
const AMI_PORT = 15040;
|
||||
const AMI_SECRET = 'upgrade-test-secret';
|
||||
const SUB = { user: 'upgrade', pass: 'UpgradeSecret42' };
|
||||
const RISKY = ['app_system', 'func_shell', 'app_disa', 'res_agi', 'app_externalivr'];
|
||||
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
const docker = (...args: string[]) => {
|
||||
const r = spawnSync('docker', args, { encoding: 'utf8' });
|
||||
return { ok: r.status === 0, out: `${r.stdout ?? ''}${r.stderr ?? ''}` };
|
||||
};
|
||||
const log = (...a: unknown[]) => console.log('[upgrade-test]', ...a);
|
||||
async function waitFor(what: string, fn: () => Promise<boolean>, seconds = 60) {
|
||||
for (let i = 0; i < seconds; i++) {
|
||||
if (await fn()) return;
|
||||
await sleep(1000);
|
||||
}
|
||||
throw new Error(`timed out waiting for ${what}`);
|
||||
}
|
||||
|
||||
/** A CA, and a certificate from it for HOST: what the PBX trusts and the provider shows. */
|
||||
function certificates(dir: string) {
|
||||
const r = docker(
|
||||
'run',
|
||||
'--rm',
|
||||
'-v',
|
||||
`${dir}:/out`,
|
||||
'--entrypoint',
|
||||
'sh',
|
||||
IMAGE,
|
||||
'-c',
|
||||
[
|
||||
'cd /out',
|
||||
'openssl req -x509 -newkey rsa:2048 -nodes -days 1 -subj /CN=sp-upgrade-ca -keyout ca.key -out ca.pem',
|
||||
`openssl req -newkey rsa:2048 -nodes -subj /CN=${HOST} -keyout privkey.pem -out host.csr`,
|
||||
`printf 'subjectAltName=DNS:${HOST}' > host.ext`,
|
||||
'openssl x509 -req -in host.csr -CA ca.pem -CAkey ca.key -CAcreateserial -days 1 -extfile host.ext -out fullchain.pem',
|
||||
'chmod 644 *',
|
||||
].join(' && '),
|
||||
);
|
||||
if (!r.ok) throw new Error(`certificates: ${r.out}`);
|
||||
}
|
||||
|
||||
const tlsTransport = (name: string, port: number, client: boolean) => `
|
||||
[${name}]
|
||||
type = transport
|
||||
protocol = tls
|
||||
bind = 0.0.0.0:${port}
|
||||
cert_file = /tls/fullchain.pem
|
||||
priv_key_file = /tls/privkey.pem
|
||||
method = tlsv1_2
|
||||
${client ? 'verify_server = yes\nca_list_file = /tls/ca.pem\n' : ''}`;
|
||||
|
||||
/** The provider: TLS only, one account that may register. */
|
||||
const providerPjsip = `${tlsTransport('transport-tls', 5061, false)}
|
||||
[${SUB.user}]
|
||||
type = endpoint
|
||||
context = default
|
||||
disallow = all
|
||||
allow = ulaw
|
||||
auth = ${SUB.user}-auth
|
||||
aors = ${SUB.user}
|
||||
|
||||
[${SUB.user}-auth]
|
||||
type = auth
|
||||
auth_type = userpass
|
||||
username = ${SUB.user}
|
||||
password = ${SUB.pass}
|
||||
|
||||
[${SUB.user}]
|
||||
type = aor
|
||||
max_contacts = 1
|
||||
remove_existing = yes
|
||||
`;
|
||||
|
||||
/**
|
||||
* Added to the one-user golden config: a trunk to the provider by name over TLS. The registration
|
||||
* is the control (REGISTER verifies the name on every version we know); the AOR's qualify is the
|
||||
* fact being measured.
|
||||
*/
|
||||
const pbxPjsip = `${tlsTransport('sp-upgrade-tls', 5062, true)}
|
||||
[sp-upgrade-auth]
|
||||
type = auth
|
||||
auth_type = userpass
|
||||
username = ${SUB.user}
|
||||
password = ${SUB.pass}
|
||||
|
||||
[sp-upgrade-reg]
|
||||
type = registration
|
||||
transport = sp-upgrade-tls
|
||||
outbound_auth = sp-upgrade-auth
|
||||
server_uri = sip:${HOST}:5061;transport=tls
|
||||
client_uri = sip:${SUB.user}@${HOST}:5061;transport=tls
|
||||
contact_user = ${SUB.user}
|
||||
retry_interval = 5
|
||||
|
||||
[sp-upgrade-trunk]
|
||||
type = aor
|
||||
contact = sip:${HOST}:5061;transport=tls
|
||||
qualify_frequency = 5
|
||||
|
||||
[sp-upgrade-trunk]
|
||||
type = endpoint
|
||||
transport = sp-upgrade-tls
|
||||
outbound_auth = sp-upgrade-auth
|
||||
aors = sp-upgrade-trunk
|
||||
context = sp-upgrade-test
|
||||
`;
|
||||
|
||||
const pbxDialplan = `
|
||||
[sp-upgrade-test]
|
||||
exten => s,1,Answer()
|
||||
same => n,Hangup()
|
||||
`;
|
||||
|
||||
async function main() {
|
||||
const pinned = pinnedAsterisk();
|
||||
const tls = mkdtempSync(join(tmpdir(), 'sp-upgrade-tls-'));
|
||||
const pbxConf = mkdtempSync(join(tmpdir(), 'sp-upgrade-pbx-'));
|
||||
const provConf = mkdtempSync(join(tmpdir(), 'sp-upgrade-prov-'));
|
||||
cpSync(GOLDEN, pbxConf, { recursive: true });
|
||||
appendFileSync(join(pbxConf, 'pjsip_selfphoneng.conf'), pbxPjsip);
|
||||
appendFileSync(join(pbxConf, 'extensions_selfphoneng.conf'), pbxDialplan);
|
||||
for (const f of readdirSync(GOLDEN))
|
||||
writeFileSync(join(provConf, f), f === 'pjsip_selfphoneng.conf' ? providerPjsip : '');
|
||||
writeFileSync(join(pbxConf, 'manager.conf.template'), testManagerConf());
|
||||
|
||||
const ami = new AmiClient({
|
||||
host: '127.0.0.1',
|
||||
port: AMI_PORT,
|
||||
username: 'selfphoneng',
|
||||
secret: AMI_SECRET,
|
||||
logger: { info() {}, warn() {}, error() {}, debug() {} },
|
||||
backoffMs: [500],
|
||||
});
|
||||
const asterisk = (name: string, conf: string, extra: string[]) =>
|
||||
docker(
|
||||
'run',
|
||||
'-d',
|
||||
'--rm',
|
||||
'--name',
|
||||
name,
|
||||
'--network',
|
||||
NET,
|
||||
...extra,
|
||||
'-e',
|
||||
`AMI_SECRET=${AMI_SECRET}`,
|
||||
'-v',
|
||||
`${conf}:/etc/asterisk/selfphoneng`,
|
||||
'-v',
|
||||
`${tls}:/tls:ro`,
|
||||
IMAGE,
|
||||
);
|
||||
const booted = (name: string) =>
|
||||
waitFor(
|
||||
`${name} to boot`,
|
||||
async () => docker('exec', name, 'asterisk', '-rx', 'core waitfullybooted').ok,
|
||||
);
|
||||
|
||||
const seen = {} as Record<Fact, boolean>;
|
||||
const problems: string[] = [];
|
||||
try {
|
||||
docker('rm', '-f', PBX, PROVIDER);
|
||||
docker('network', 'create', NET);
|
||||
certificates(tls);
|
||||
const prov = asterisk(PROVIDER, provConf, ['--network-alias', HOST]);
|
||||
if (!prov.ok) throw new Error(`provider: ${prov.out}`);
|
||||
await booted(PROVIDER);
|
||||
const pbx = asterisk(PBX, pbxConf, [
|
||||
'-p',
|
||||
`127.0.0.1:${AMI_PORT}:5038`,
|
||||
'-v',
|
||||
`${join(pbxConf, 'manager.conf.template')}:/etc/asterisk/manager.conf.template:ro`,
|
||||
]);
|
||||
if (!pbx.ok) throw new Error(`pbx: ${pbx.out}`);
|
||||
await booted(PBX);
|
||||
|
||||
const running = docker('exec', PBX, 'asterisk', '-V').out.trim();
|
||||
if (running !== `Asterisk ${pinned}`)
|
||||
throw new Error(
|
||||
`the image runs "${running}" but asterisk/Dockerfile pins ${pinned}: rebuild it ` +
|
||||
`(docker build -t ${IMAGE} asterisk)`,
|
||||
);
|
||||
log(`${running}, last verified on ${VERIFIED_ASTERISK}`);
|
||||
|
||||
ami.start();
|
||||
await new Promise((r) => ami.once('connected', r));
|
||||
const cli = (cmd: string) => ami.command(cmd);
|
||||
|
||||
const modules = await Promise.all(RISKY.map((m) => cli(`module show like ${m}`)));
|
||||
seen.riskyModulesGone = modules.every((o) => /\b0 modules loaded/.test(o));
|
||||
const ep101 = await cli('pjsip show endpoint 101');
|
||||
seen.mwiSubscribeReplaces = /mwi_subscribe_replaces_unsolicited\s*:\s*yes/.test(ep101);
|
||||
// set_var lines show as their own rows: " __SP_DIRECTION : internal".
|
||||
seen.setVarKeepsUnderscores = /^\s*__SP_DIRECTION\s*:\s*internal\s*$/m.test(ep101);
|
||||
const commands = await cli('manager show commands');
|
||||
seen.voicemailMoveRemove =
|
||||
/\bVoicemailMove\b/.test(commands) && /\bVoicemailRemove\b/.test(commands);
|
||||
|
||||
// CDR: one Local call through the test context.
|
||||
const cdr = new Promise<AmiMessage>((r) => ami.once('event:Cdr', r));
|
||||
await cli('channel originate Local/s@sp-upgrade-test application Wait 1');
|
||||
const m = await Promise.race([
|
||||
cdr,
|
||||
sleep(15_000).then(() => {
|
||||
throw new Error('no Cdr event within 15 s of a call');
|
||||
}),
|
||||
]);
|
||||
seen.cdrLinkedId = !!field(m, 'LinkedID');
|
||||
const start = field(m, 'StartTime') ?? '';
|
||||
seen.cdrTimesUtcNoZone =
|
||||
/^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d$/.test(start) &&
|
||||
Math.abs(Date.parse(`${start.replace(' ', 'T')}Z`) - Date.now()) < 120_000;
|
||||
|
||||
// TLS: the registration must work first, or the qualify answer says nothing.
|
||||
await waitFor(
|
||||
'the TLS registration to the provider (the control)',
|
||||
async () => /sp-upgrade-reg\S*\s.*Registered/.test(await cli('pjsip show registrations')),
|
||||
45,
|
||||
).catch(async (e: Error) => {
|
||||
throw new Error(`${e.message}:\n${await cli('pjsip show registrations')}`);
|
||||
});
|
||||
let contacts = '';
|
||||
for (let i = 0; i < 20; i++) {
|
||||
contacts = await cli('pjsip show contacts');
|
||||
if (/sp-upgrade-trunk\/\S+\s.*\b(Avail|Unavail)/.test(contacts)) break;
|
||||
await sleep(1000);
|
||||
}
|
||||
seen.tlsQualifyByHostname = /sp-upgrade-trunk\/\S+\s.*\bAvail\b/.test(contacts);
|
||||
if (!seen.tlsQualifyByHostname) {
|
||||
const why = docker('logs', PBX)
|
||||
// eslint-disable-next-line no-control-regex -- strips Asterisk's ANSI colors
|
||||
.out.replace(/\x1b\[[0-9;]*m/g, '')
|
||||
.split('\n');
|
||||
const line = contacts.split('\n').find((l) => l.includes('sp-upgrade-trunk'));
|
||||
log('qualify over TLS failed:', line);
|
||||
for (const l of why
|
||||
.filter((x) => /sp-upgrade-trunk|identity|EUNSUP|qualif/i.test(x))
|
||||
.slice(-4))
|
||||
log(l.trim());
|
||||
}
|
||||
|
||||
const listed = await ami.actionList({ Action: 'PJSIPShowContacts' });
|
||||
seen.staticContactsInShowContacts = listed.some((x) => x.Aor === 'sp-upgrade-trunk');
|
||||
const detail = await ami.actionList({
|
||||
Action: 'PJSIPShowEndpoint',
|
||||
Endpoint: 'sp-upgrade-trunk',
|
||||
});
|
||||
seen.endpointShowsContactStatus = detail.some(
|
||||
(x) => x.Event === 'ContactStatusDetail' && x.AOR === 'sp-upgrade-trunk',
|
||||
);
|
||||
} catch (e) {
|
||||
problems.push((e as Error).message);
|
||||
} finally {
|
||||
ami.stop();
|
||||
docker('rm', '-f', PBX, PROVIDER);
|
||||
docker('network', 'rm', NET);
|
||||
for (const d of [tls, pbxConf, provConf]) rmSync(d, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const moved = (Object.keys(FACTS) as Fact[]).filter(
|
||||
(k) => k in seen && seen[k] !== FACTS[k].expect,
|
||||
);
|
||||
for (const k of Object.keys(FACTS) as Fact[])
|
||||
console.log(
|
||||
`${!(k in seen) ? '? ' : moved.includes(k) ? 'MOVED' : 'ok '} ${k}: ${k in seen ? seen[k] : 'not measured'}`,
|
||||
);
|
||||
for (const k of moved)
|
||||
problems.push(
|
||||
`${k} was ${FACTS[k].expect} on ${VERIFIED_ASTERISK}, is ${seen[k]} on ${pinned}: ` +
|
||||
`${FACTS[k].what}. Depends on it: ${FACTS[k].where}`,
|
||||
);
|
||||
if (problems.length) {
|
||||
console.log('FAIL asterisk upgrade');
|
||||
for (const p of problems) console.log(` ${p}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(
|
||||
pinned === VERIFIED_ASTERISK
|
||||
? `ok asterisk ${pinned}: every fact as recorded`
|
||||
: `ok asterisk ${pinned}: nothing moved since ${VERIFIED_ASTERISK}; set VERIFIED_ASTERISK in verified.ts`,
|
||||
);
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -0,0 +1,68 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// What the code relies on Asterisk doing, as last measured by upgrade-test.ts, and on which
|
||||
// version. asterisk-version.test.ts fails when asterisk/Dockerfile pins another version: run
|
||||
// `pnpm test:asterisk-upgrade` on the new image, act on each change it lists, then update this.
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
export const VERIFIED_ASTERISK = '24.0.0-rc3';
|
||||
|
||||
/** The version asterisk/Dockerfile builds. */
|
||||
export function pinnedAsterisk(): string {
|
||||
const file = readFileSync(new URL('../../../../asterisk/Dockerfile', import.meta.url), 'utf8');
|
||||
const v = /^ARG ASTERISK_VERSION=(\S+)$/m.exec(file)?.[1];
|
||||
if (!v) throw new Error('asterisk/Dockerfile has no ARG ASTERISK_VERSION');
|
||||
return v;
|
||||
}
|
||||
|
||||
/** `expect`: the answer on VERIFIED_ASTERISK. `where`: what depends on it, to change if it moves. */
|
||||
export const FACTS = {
|
||||
riskyModulesGone: {
|
||||
expect: true,
|
||||
what: 'app_system, func_shell, app_disa, res_agi and app_externalivr are not loaded',
|
||||
where: 'asterisk/conf/modules.conf noload lines (08 §6): a renamed module would load again',
|
||||
},
|
||||
mwiSubscribeReplaces: {
|
||||
expect: true,
|
||||
what: 'endpoints take mwi_subscribe_replaces_unsolicited',
|
||||
where: 'generator/pjsip.ts: without it a phone that SUBSCRIBEs for MWI gets a 500 (03 §5.2)',
|
||||
},
|
||||
setVarKeepsUnderscores: {
|
||||
expect: true,
|
||||
what: 'set_var = __SP_DIRECTION=… loads as "__SP_DIRECTION"',
|
||||
where: 'generator/pjsip.ts set_var lines; dialplan reads SP_DIRECTION on dialed legs (03 §5.2)',
|
||||
},
|
||||
staticContactsInShowContacts: {
|
||||
expect: false,
|
||||
what: 'PJSIPShowContacts lists static (contact =) AOR contacts',
|
||||
where: 'live/state.ts reads trunk reachability from PJSIPShowEndpoint instead (04 §5.1)',
|
||||
},
|
||||
endpointShowsContactStatus: {
|
||||
expect: true,
|
||||
what: 'PJSIPShowEndpoint has a ContactStatusDetail for a static contact',
|
||||
where: 'live/state.ts trunk reachability (04 §5.1)',
|
||||
},
|
||||
voicemailMoveRemove: {
|
||||
expect: true,
|
||||
what: 'the AMI actions VoicemailMove and VoicemailRemove exist',
|
||||
where: 'services/voicemail.ts mark read and delete (04 §5.6)',
|
||||
},
|
||||
cdrLinkedId: {
|
||||
expect: true,
|
||||
what: 'Cdr AMI events carry LinkedID (cdr_manager.conf mapping)',
|
||||
where: 'services/cdr.ts groups a call by it (01 §3.15, 04 §5.4)',
|
||||
},
|
||||
cdrTimesUtcNoZone: {
|
||||
expect: true,
|
||||
what: 'Cdr StartTime is "YYYY-MM-DD HH:MM:SS" in UTC, no zone',
|
||||
where: 'services/cdr.ts utc() adds the Z itself',
|
||||
},
|
||||
tlsQualifyByHostname: {
|
||||
expect: true,
|
||||
what: 'a qualify OPTIONS over TLS checks the certificate against the host name, not the IP',
|
||||
where:
|
||||
'generator/pjsip.ts qualifies TLS trunks and Test sends PJSIPQualify (08 §2.1). False on ' +
|
||||
'24.0.0-rc2: a TLS trunk then showed as unreachable, so it had qualify_frequency = 0',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type Fact = keyof typeof FACTS;
|
||||
@@ -11,6 +11,7 @@ import { SettingsSchemas, type SettingKey } from '@selfphoneng/shared';
|
||||
import { AmiClient } from '../../src/ami/client.ts';
|
||||
import { GENERATED_FILES, render } from '../../src/generator/index.ts';
|
||||
import type { Snapshot } from '../../src/generator/load.ts';
|
||||
import { testManagerConf } from './manager-conf.ts';
|
||||
|
||||
const IMAGE = process.env.ASTERISK_IMAGE ?? 'selfphoneng-asterisk';
|
||||
const NET = 'sp-vm';
|
||||
@@ -111,10 +112,7 @@ async function main() {
|
||||
spawnSync('mkdir', ['-p', conf]);
|
||||
const files = render(snapshot());
|
||||
for (const f of GENERATED_FILES) writeFileSync(join(conf, f), files[f]);
|
||||
writeFileSync(
|
||||
join(dir, 'manager.conf.template'),
|
||||
'[general]\nenabled = yes\nport = 5038\nbindaddr = 0.0.0.0\n\n[selfphoneng]\nsecret = ${AMI_SECRET}\npermit = 0.0.0.0/0.0.0.0\nread = all\nwrite = all\n',
|
||||
);
|
||||
writeFileSync(join(dir, 'manager.conf.template'), testManagerConf());
|
||||
phoneConfig(dir, '101', 5070);
|
||||
phoneConfig(dir, '102', 5072);
|
||||
|
||||
|
||||
@@ -1,25 +1,32 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// M6-T8: /api/blocked-numbers and the AstDB mirror (04 §5.5).
|
||||
// M6-T8: /api/blocked-numbers and the AstDB mirror (04 §5.5); users' Report spam (05 §4).
|
||||
import type { LightMyRequestResponse } from 'fastify';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { callExtensions, calls, extensions } from '../src/db/schema.ts';
|
||||
import { encrypt } from '../src/crypto/secrets.ts';
|
||||
import { syncAstDb } from '../src/services/astdb.ts';
|
||||
import { hasTestDb } from './db.ts';
|
||||
import { ADMIN, H, createTestApp } from './app.ts';
|
||||
|
||||
describe.skipIf(!hasTestDb)('/api/blocked-numbers', () => {
|
||||
let t: Awaited<ReturnType<typeof createTestApp>>;
|
||||
let req: (method: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
|
||||
let req: (
|
||||
method: string,
|
||||
url: string,
|
||||
payload?: unknown,
|
||||
as?: string,
|
||||
) => Promise<LightMyRequestResponse>;
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestApp();
|
||||
await t.clearMustChange(ADMIN.email);
|
||||
const cookie = await t.login(ADMIN.email, ADMIN.password);
|
||||
req = (method, url, payload) =>
|
||||
req = (method, url, payload, as = cookie) =>
|
||||
t.app.inject({
|
||||
method: method as 'GET',
|
||||
url,
|
||||
payload: payload as object,
|
||||
headers: { ...H, cookie },
|
||||
headers: { ...H, cookie: as },
|
||||
});
|
||||
});
|
||||
afterAll(() => t?.close());
|
||||
@@ -59,4 +66,62 @@ describe.skipIf(!hasTestDb)('/api/blocked-numbers', () => {
|
||||
});
|
||||
expect((await req('GET', '/api/blocked-numbers')).json().items).toEqual([]);
|
||||
});
|
||||
|
||||
it('Report spam: only on an outside call that rang me; Block clears it, Dismiss drops it', async () => {
|
||||
const sam = await t.addUser('sam@example.com', 'sam-password-123');
|
||||
await t.addUser('pat@example.com', 'pat-password-123');
|
||||
const [ext] = await t.db
|
||||
.insert(extensions)
|
||||
.values({
|
||||
number: '103',
|
||||
name: 'Sam',
|
||||
sipPasswordEnc: encrypt('x'),
|
||||
voicemailPinEnc: encrypt('4826'),
|
||||
userId: sam.id,
|
||||
})
|
||||
.returning();
|
||||
const call = async (linkedid: string, from: string | null, role: string) => {
|
||||
const [c] = await t.db
|
||||
.insert(calls)
|
||||
.values({
|
||||
linkedid,
|
||||
direction: 'inbound',
|
||||
status: 'answered',
|
||||
fromNumber: from,
|
||||
startedAt: new Date(),
|
||||
endedAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
await t.db.insert(callExtensions).values({ callId: c!.id, extensionId: ext!.id, role });
|
||||
return c!.id;
|
||||
};
|
||||
const robo = await call('r1', '13125550142', 'rang');
|
||||
const hidden = await call('r2', null, 'answered');
|
||||
const samC = await t.login('sam@example.com', 'sam-password-123');
|
||||
const patC = await t.login('pat@example.com', 'pat-password-123');
|
||||
const report = (callId: string, as: string, reason = 'Car warranty robocall') =>
|
||||
req('POST', '/api/me/spam-reports', { callId, reason }, as);
|
||||
|
||||
expect((await report(robo, patC)).statusCode).toBe(404); // not a call that rang Pat
|
||||
expect((await report(robo, samC, ' ')).statusCode).toBe(400); // no reason
|
||||
expect((await report(hidden, samC)).statusCode).toBe(400); // no number to block
|
||||
expect((await report(robo, samC)).statusCode).toBe(201);
|
||||
expect((await report(robo, samC)).statusCode).toBe(409); // once each
|
||||
expect((await req('GET', '/api/spam-reports', undefined, samC)).statusCode).toBe(403);
|
||||
const [r] = (await req('GET', '/api/spam-reports')).json().items;
|
||||
expect(r).toMatchObject({
|
||||
number: '13125550142',
|
||||
reason: 'Car warranty robocall',
|
||||
reportedBy: 'sam@example.com',
|
||||
});
|
||||
|
||||
expect((await req('DELETE', `/api/spam-reports/${r.id}`)).statusCode).toBe(200);
|
||||
expect((await req('GET', '/api/spam-reports')).json().items).toEqual([]);
|
||||
|
||||
expect((await report(robo, samC)).statusCode).toBe(201); // dismissed: may report again
|
||||
const b = await req('POST', '/api/blocked-numbers', { number: r.number, reason: r.reason });
|
||||
expect(b.statusCode).toBe(201);
|
||||
expect((await req('GET', '/api/spam-reports')).json().items).toEqual([]);
|
||||
expect((await report(robo, samC)).statusCode).toBe(409); // already blocked
|
||||
});
|
||||
});
|
||||
@@ -181,4 +181,43 @@ describe.skipIf(!hasTestDb)('call report email', () => {
|
||||
await run('2026-10-23T13:06:00Z');
|
||||
expect(t.mails[1]!.text).not.toContain('quote');
|
||||
});
|
||||
|
||||
it('no recording or summary of a call that only rang my phone (08 §7 #3)', async () => {
|
||||
await dan('PATCH', '/api/me', { callReportSummaries: true });
|
||||
const [rec] = await t.db
|
||||
.insert(recordings)
|
||||
.values({
|
||||
linkedid: 'r4',
|
||||
filePath: '2026/10/24/r4.wav',
|
||||
startedAt: new Date('2026-10-24T15:00:00Z'),
|
||||
summary: 'Secret pricing for Acme',
|
||||
aiDoneAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
const [c] = await t.db
|
||||
.insert(calls)
|
||||
.values({
|
||||
linkedid: 'c4', // the ring group rang Dan; a coworker answered and talked
|
||||
direction: 'inbound',
|
||||
status: 'answered',
|
||||
startedAt: new Date('2026-10-24T15:00:00Z'),
|
||||
endedAt: new Date('2026-10-24T15:05:00Z'),
|
||||
fromNumber: '13125557777',
|
||||
talkSeconds: 300,
|
||||
recordingId: rec!.id,
|
||||
})
|
||||
.returning();
|
||||
await t.db.insert(callExtensions).values({ callId: c!.id, extensionId: extId, role: 'rang' });
|
||||
await t.db
|
||||
.update(extensions)
|
||||
.set({ callReportSentAt: new Date('2026-10-24T13:00:00Z') })
|
||||
.where(eq(extensions.id, extId));
|
||||
t.mails.length = 0;
|
||||
expect(await run('2026-10-25T13:06:00Z')).toBe(1);
|
||||
const text = t.mails[0]!.text;
|
||||
expect(text).toContain('Incoming from (312) 555-7777');
|
||||
expect(text).not.toContain('Secret pricing');
|
||||
expect(text).not.toContain('recorded');
|
||||
expect(text).not.toContain('Summary not ready');
|
||||
});
|
||||
});
|
||||
@@ -460,6 +460,19 @@ describe.skipIf(!hasTestDb)('contacts in Contrack', () => {
|
||||
fake.state.readOnly.clear();
|
||||
expect(await run()).toBe(0);
|
||||
expect(jane).toHaveLength(8);
|
||||
|
||||
// Only rang Dan (a coworker took it): on his timeline, but not its recording or summary.
|
||||
const [k12] = await t.db
|
||||
.insert(calls)
|
||||
.values(call('k12', 1.5, { recordingId: done!.id }))
|
||||
.returning();
|
||||
await t.db
|
||||
.insert(callExtensions)
|
||||
.values({ callId: k12!.id, extensionId: ext!.id, role: 'rang' });
|
||||
forgetBook(danId);
|
||||
expect(await run()).toBe(1);
|
||||
expect(jane.at(-1)!.content).not.toMatch(/recorded|Acme/);
|
||||
expect(jane.at(-1)).not.toHaveProperty('actionItem');
|
||||
});
|
||||
|
||||
it('contacts from before Contrack are copied once, the builtin ones kept (C6)', async () => {
|
||||
@@ -521,4 +534,35 @@ describe.skipIf(!hasTestDb)('contacts in Contrack', () => {
|
||||
]);
|
||||
expect((await dan('GET', '/api/me')).json().contrack).toBeNull();
|
||||
});
|
||||
|
||||
it('the saved tokens only go to the address they were saved for (08 §7 #7)', async () => {
|
||||
// Another machine on the network that answers like Contrack, and notes what it's sent.
|
||||
const heard: string[] = [];
|
||||
const other = createServer((req, res) => {
|
||||
heard.push(String(req.headers.authorization ?? ''));
|
||||
res.setHeader('content-type', 'application/json');
|
||||
res.end('[]');
|
||||
});
|
||||
await new Promise<void>((ok) => other.listen(0, '127.0.0.1', ok));
|
||||
const otherUrl = `http://127.0.0.1:${(other.address() as AddressInfo).port}`;
|
||||
try {
|
||||
await admin('POST', '/api/contacts/contrack/test', { url: otherUrl });
|
||||
expect(heard.join(' ')).not.toContain('ctk_company');
|
||||
// A new address needs its token typed again…
|
||||
const kept = await admin('PATCH', '/api/settings', {
|
||||
'contacts.contrack': { url: otherUrl },
|
||||
});
|
||||
expect(kept.statusCode).toBe(400);
|
||||
expect(kept.json().error.message).toMatch(/token again/);
|
||||
// …and people's own tokens are for the old one: they connect again.
|
||||
const moved = await admin('PATCH', '/api/settings', {
|
||||
'contacts.contrack': { url: otherUrl, companyToken: 'ctk_new_place' },
|
||||
});
|
||||
expect(moved.statusCode, moved.body).toBe(200);
|
||||
const [d] = await t.db.select().from(users).where(eq(users.id, danId));
|
||||
expect(d!.contrackTokenEnc).toBeNull();
|
||||
} finally {
|
||||
other.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -154,4 +154,55 @@ describe.skipIf(!hasTestDb)('diagnostics', () => {
|
||||
401,
|
||||
);
|
||||
});
|
||||
|
||||
it('the probe key: made here, shown once, sent to the probe, never in settings (08 §7 #9)', async () => {
|
||||
const cookie = await t.login(ADMIN.email, ADMIN.password);
|
||||
const as = (method: string, url: string, payload?: object) =>
|
||||
t.app.inject({ method: method as 'GET', url, payload, headers: { ...H, cookie } });
|
||||
await setSetting(t.db, 'network.probeUrl', 'https://probe.test');
|
||||
// No key yet: the inside checks say to make one.
|
||||
expect(byId(await insideChecks(deps)).probe).toMatchObject({ status: 'warn' });
|
||||
|
||||
const g = await as('POST', '/api/settings/network/probe-key');
|
||||
expect(g.statusCode, g.body).toBe(200);
|
||||
const { key } = g.json() as { key: string };
|
||||
expect(key).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
||||
const s = await as('GET', '/api/settings');
|
||||
expect(s.json()['network.hasProbeKey']).toBe(true);
|
||||
expect(s.body).not.toContain(key);
|
||||
expect(s.json()).not.toHaveProperty('network.probeKeyEnc');
|
||||
expect((await as('PATCH', '/api/settings', { 'network.probeKeyEnc': 'x' })).statusCode).toBe(
|
||||
400,
|
||||
);
|
||||
expect(byId(await insideChecks(deps)).probe).toMatchObject({ status: 'ok' });
|
||||
|
||||
let auth: string | null = null;
|
||||
await networkTest(
|
||||
{
|
||||
...deps,
|
||||
fetchProbe: (async (_u: URL, init: RequestInit) => {
|
||||
auth = new Headers(init.headers).get('authorization');
|
||||
return new Response(JSON.stringify({ ip: '203.0.113.10', results: [] }));
|
||||
}) as unknown as typeof fetch,
|
||||
},
|
||||
'quick',
|
||||
);
|
||||
expect(auth).toBe(`Bearer ${key}`);
|
||||
// The probe refusing it says what to do.
|
||||
const refused = await networkTest(
|
||||
{
|
||||
...deps,
|
||||
fetchProbe: (async () =>
|
||||
new Response(JSON.stringify({ error: 'a key is needed' }), {
|
||||
status: 401,
|
||||
})) as unknown as typeof fetch,
|
||||
},
|
||||
'quick',
|
||||
);
|
||||
expect(byId(refused.checks).probe!.fix).toContain('PROBE_KEY');
|
||||
|
||||
// Another address: the key was for the old probe, so it's gone.
|
||||
await as('PATCH', '/api/settings', { 'network.probeUrl': 'https://other-probe.test' });
|
||||
expect((await as('GET', '/api/settings')).json()['network.hasProbeKey']).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// 12 §4.5: the hourly directoryCheck, against a fake directory lookup.
|
||||
import { eq, ne } from 'drizzle-orm';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { auditLog, sessions, userIdentities, users } from '../src/db/schema.ts';
|
||||
import { getSetting, setSetting } from '../src/services/settings.ts';
|
||||
import { checkDirectory } from '../src/sso/directory.ts';
|
||||
import type { DirectoryEntry, LdapSettings } from '../src/sso/ldap.ts';
|
||||
import { hasTestDb } from './db.ts';
|
||||
import { ADMIN, H, createTestApp } from './app.ts';
|
||||
|
||||
const log = { info: () => {}, warn: () => {} };
|
||||
const LDAP: LdapSettings = {
|
||||
enabled: true,
|
||||
preset: 'lldap',
|
||||
url: 'ldaps://dir.example.com',
|
||||
startTls: false,
|
||||
bindDn: null,
|
||||
bindPasswordEnc: null,
|
||||
baseDn: 'ou=people,dc=example,dc=com',
|
||||
userFilter: '(uid={login})',
|
||||
idAttribute: 'entryUUID',
|
||||
emailAttribute: 'mail',
|
||||
nameAttribute: 'displayName',
|
||||
adminFilter: '(memberOf=cn=pbx-admins)',
|
||||
};
|
||||
|
||||
describe.skipIf(!hasTestDb)('directoryCheck (12 §4.5)', () => {
|
||||
let t: Awaited<ReturnType<typeof createTestApp>>;
|
||||
let n = 0;
|
||||
|
||||
/** A user here with a directory account `id`. */
|
||||
const member = async (role: 'admin' | 'user' = 'user') => {
|
||||
const u = await t.addUser(`dir${++n}@example.com`, 'local-password-1', role);
|
||||
const dirId = `uuid-${n}`;
|
||||
await t.db.insert(userIdentities).values({ userId: u.id, kind: 'ldap', subject: dirId });
|
||||
return { ...u, dirId };
|
||||
};
|
||||
const entry = (id: string, admin: boolean | null, email = `${id}@dir.example.com`) =>
|
||||
[id, { dn: `uid=${id}`, id, email, name: id, admin }] as [string, DirectoryEntry];
|
||||
const run = (found: [string, DirectoryEntry][] | Error) =>
|
||||
checkDirectory({
|
||||
db: t.db,
|
||||
log,
|
||||
lookup: () =>
|
||||
found instanceof Error ? Promise.reject(found) : Promise.resolve(new Map(found)),
|
||||
});
|
||||
const user = async (id: string) => (await t.db.select().from(users).where(eq(users.id, id)))[0]!;
|
||||
const warning = () => getSetting(t.db, 'system.directoryWarning');
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestApp();
|
||||
await t.clearMustChange(ADMIN.email);
|
||||
});
|
||||
afterAll(() => t?.close());
|
||||
beforeEach(async () => {
|
||||
await t.db.delete(userIdentities);
|
||||
await t.db.update(users).set({ role: 'user' }).where(ne(users.email, ADMIN.email));
|
||||
await setSetting(t.db, 'auth.ldap', LDAP);
|
||||
});
|
||||
|
||||
it('updates who is still there, and disables who is gone', async () => {
|
||||
const [stays, promoted, leaves] = [await member(), await member(), await member()];
|
||||
await t.login(leaves.email, 'local-password-1');
|
||||
await setSetting(t.db, 'system.directoryWarning', 'old');
|
||||
await run([entry(stays.dirId, false, 'new@dir.example.com'), entry(promoted.dirId, true)]);
|
||||
|
||||
expect(await user(stays.id)).toMatchObject({ isActive: true, role: 'user' });
|
||||
const [ident] = await t.db
|
||||
.select()
|
||||
.from(userIdentities)
|
||||
.where(eq(userIdentities.userId, stays.id));
|
||||
expect(ident!.email).toBe('new@dir.example.com');
|
||||
expect((await user(promoted.id)).role).toBe('admin');
|
||||
expect((await user(leaves.id)).isActive).toBe(false);
|
||||
expect(await t.db.select().from(sessions).where(eq(sessions.userId, leaves.id))).toEqual([]);
|
||||
const audits = await t.db.select().from(auditLog).where(eq(auditLog.entityId, leaves.id));
|
||||
expect(audits.map((a) => a.action)).toContain('directory_disabled');
|
||||
expect(await warning()).toBeNull();
|
||||
});
|
||||
|
||||
it('changes nothing and warns on the dashboard when the directory is down', async () => {
|
||||
const a = await member();
|
||||
await run(new Error('connect ECONNREFUSED'));
|
||||
expect((await user(a.id)).isActive).toBe(true);
|
||||
expect(await warning()).toMatch(/couldn't reach the directory \(connect ECONNREFUSED\)/);
|
||||
const admin = await t.login(ADMIN.email, ADMIN.password);
|
||||
const r = await t.app.inject({
|
||||
url: '/api/system/warnings',
|
||||
headers: { ...H, cookie: admin },
|
||||
});
|
||||
expect(r.json().items).toContainEqual(
|
||||
expect.objectContaining({ code: 'directory_check', link: '/admin/settings/sign-in' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('disables nobody when most people would go (a broken filter, not a layoff)', async () => {
|
||||
const all = [await member(), await member(), await member(), await member()];
|
||||
await run([entry(all[0]!.dirId, null)]);
|
||||
for (const u of all) expect((await user(u.id)).isActive).toBe(true);
|
||||
expect(await warning()).toMatch(/would disable 3 of 4 people/);
|
||||
});
|
||||
|
||||
it('two of four gone is not "most": they are disabled', async () => {
|
||||
const all = [await member(), await member(), await member(), await member()];
|
||||
await run([entry(all[0]!.dirId, null), entry(all[1]!.dirId, null)]);
|
||||
expect((await user(all[2]!.id)).isActive).toBe(false);
|
||||
expect((await user(all[3]!.id)).isActive).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the last active admin with a password', async () => {
|
||||
const [me] = await t.db.select().from(users).where(eq(users.email, ADMIN.email));
|
||||
await t.db.insert(userIdentities).values({ userId: me!.id, kind: 'ldap', subject: 'admin-id' });
|
||||
await run([]);
|
||||
expect((await user(me!.id)).isActive).toBe(true);
|
||||
await run([entry('admin-id', false)]);
|
||||
expect((await user(me!.id)).role).toBe('admin');
|
||||
});
|
||||
|
||||
it('does nothing with the directory off', async () => {
|
||||
const a = await member();
|
||||
await setSetting(t.db, 'auth.ldap', { ...LDAP, enabled: false });
|
||||
await setSetting(t.db, 'system.directoryWarning', 'old');
|
||||
await run([]);
|
||||
expect((await user(a.id)).isActive).toBe(true);
|
||||
expect(await warning()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
// 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). 08 §7: no OpenID user by email, no
|
||||
// swapping the connected account.
|
||||
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 { connectDirectory, 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('never connects by email someone who signs in with OpenID (08 §7 #1)', async () => {
|
||||
// A directory that lets people edit their own mail: Mallory's entry, the victim's email.
|
||||
const victim = await t.addUser('oidc-only@example.com', 'unused-pw-2');
|
||||
await t.db.update(users).set({ passwordHash: null }).where(eq(users.id, victim.id));
|
||||
await t.db
|
||||
.insert(userIdentities)
|
||||
.values({ userId: victim.id, kind: 'oidc', subject: 'https://idp victim-sub' });
|
||||
expect(await directorySignIn(t.db, log, entry(victim.email), '127.0.0.1')).toBeNull();
|
||||
expect(await connected(victim.id)).toBe(1); // the OpenID account only
|
||||
});
|
||||
|
||||
it('a connected directory account is only swapped by an admin (08 §7 #5)', async () => {
|
||||
const u = await t.addUser('swapper@example.com', 'swapper-password-1');
|
||||
expect(await connectDirectory(t.db, log, u.id, entry(u.email), '127.0.0.1')).toBe('connected');
|
||||
const [first] = await t.db.select().from(userIdentities).where(eq(userIdentities.userId, u.id));
|
||||
// The same account again is fine; another one (a shared mailbox) is not.
|
||||
expect(
|
||||
await connectDirectory(
|
||||
t.db,
|
||||
log,
|
||||
u.id,
|
||||
{ ...entry(u.email), id: first!.subject },
|
||||
'127.0.0.1',
|
||||
),
|
||||
).toBe('connected');
|
||||
expect(await connectDirectory(t.db, log, u.id, entry('shared@example.com'), '127.0.0.1')).toBe(
|
||||
'connected_other',
|
||||
);
|
||||
const now = await t.db.select().from(userIdentities).where(eq(userIdentities.userId, u.id));
|
||||
expect(now.map((i) => i.subject)).toEqual([first!.subject]);
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// A small OpenID provider for tests (12 §6): discovery, JWKS, authorize, token (code + refresh,
|
||||
// PKCE S256, client_secret_basic), userinfo, end session. `tamper` breaks one thing at a time.
|
||||
import { createHash, generateKeyPairSync, randomBytes, sign, type KeyObject } from 'node:crypto';
|
||||
import { createServer, type IncomingMessage } from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
|
||||
export type FakeUser = {
|
||||
sub: string;
|
||||
email?: string;
|
||||
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');
|
||||
|
||||
export async function startFakeOidc(alg: 'RS256' | 'ES256' = 'RS256') {
|
||||
const pair =
|
||||
alg === 'RS256'
|
||||
? generateKeyPairSync('rsa', { modulusLength: 2048 })
|
||||
: generateKeyPairSync('ec', { namedCurve: 'P-256' });
|
||||
const other = generateKeyPairSync('rsa', { modulusLength: 2048 }); // signs "bad signature" tokens
|
||||
const clientId = 'selfphoneng';
|
||||
const clientSecret = 'fake-secret';
|
||||
const st = {
|
||||
user: { sub: 'u-1', email: 'ann@example.com', email_verified: true } as FakeUser,
|
||||
tamper: {} as {
|
||||
nonce?: boolean;
|
||||
iss?: boolean;
|
||||
aud?: boolean;
|
||||
expired?: boolean;
|
||||
signature?: boolean;
|
||||
userinfoSub?: boolean;
|
||||
},
|
||||
/** false: refresh tokens are refused, as for a user disabled at the provider. */
|
||||
refreshOk: true,
|
||||
/** 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>();
|
||||
const refresh = new Map<string, FakeUser>();
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
const url = new URL(req.url!, issuer);
|
||||
const json = (code: number, body: unknown) => {
|
||||
res.writeHead(code, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify(body));
|
||||
};
|
||||
if (url.pathname === '/.well-known/openid-configuration')
|
||||
return json(200, {
|
||||
issuer,
|
||||
authorization_endpoint: `${issuer}/authorize`,
|
||||
token_endpoint: `${issuer}/token`,
|
||||
userinfo_endpoint: `${issuer}/userinfo`,
|
||||
jwks_uri: `${issuer}/jwks`,
|
||||
end_session_endpoint: `${issuer}/logout`,
|
||||
response_types_supported: ['code'],
|
||||
subject_types_supported: ['public'],
|
||||
id_token_signing_alg_values_supported: [alg],
|
||||
code_challenge_methods_supported: ['S256'],
|
||||
grant_types_supported: ['authorization_code', 'refresh_token'],
|
||||
token_endpoint_auth_methods_supported: ['client_secret_basic'],
|
||||
});
|
||||
if (url.pathname === '/jwks')
|
||||
return json(200, {
|
||||
keys: [{ ...pair.publicKey.export({ format: 'jwk' }), kid: 'k1', alg, use: 'sig' }],
|
||||
});
|
||||
if (url.pathname === '/authorize') {
|
||||
const p = url.searchParams;
|
||||
if (p.get('client_id') !== clientId || p.get('code_challenge_method') !== 'S256')
|
||||
return json(400, { error: 'invalid_request' });
|
||||
const code = b64u(randomBytes(16));
|
||||
codes.set(code, {
|
||||
nonce: p.get('nonce')!,
|
||||
challenge: p.get('code_challenge')!,
|
||||
user: { ...st.user },
|
||||
});
|
||||
const back = new URL(p.get('redirect_uri')!);
|
||||
back.searchParams.set('code', code);
|
||||
back.searchParams.set('state', p.get('state')!);
|
||||
res.writeHead(302, { location: back.href });
|
||||
return res.end();
|
||||
}
|
||||
if (url.pathname === '/token' && req.method === 'POST') {
|
||||
if (st.down) return json(503, { error: 'temporarily_unavailable' });
|
||||
// client_secret_basic: both parts form-encoded before base64 (RFC 6749 §2.3.1).
|
||||
const [id, secret] = Buffer.from(
|
||||
(req.headers.authorization ?? '').replace(/^Basic /, ''),
|
||||
'base64',
|
||||
)
|
||||
.toString()
|
||||
.split(':')
|
||||
.map((x) => decodeURIComponent(x.replace(/\+/g, ' ')));
|
||||
if (id !== clientId || secret !== clientSecret) return json(401, { error: 'invalid_client' });
|
||||
const form = new URLSearchParams(await body(req));
|
||||
if (form.get('grant_type') === 'refresh_token') {
|
||||
const user = refresh.get(form.get('refresh_token')!);
|
||||
st.refreshes++;
|
||||
if (!user || !st.refreshOk) return json(400, { error: 'invalid_grant' });
|
||||
// Today's claims, as a real provider gives on refresh.
|
||||
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') ?? '');
|
||||
const verifier = form.get('code_verifier') ?? '';
|
||||
if (!c || b64u(createHash('sha256').update(verifier).digest()) !== c.challenge)
|
||||
return json(400, { error: 'invalid_grant' });
|
||||
return json(200, tokens(c.user, c.nonce));
|
||||
}
|
||||
if (url.pathname === '/userinfo') {
|
||||
const user = access.get((req.headers.authorization ?? '').replace(/^Bearer /, ''));
|
||||
if (!user) return json(401, { error: 'invalid_token' });
|
||||
const { roles: _, ...info } = user;
|
||||
return json(200, { ...info, ...(st.tamper.userinfoSub && { sub: 'someone-else' }) });
|
||||
}
|
||||
if (url.pathname === '/logout') return json(200, {});
|
||||
json(404, {});
|
||||
});
|
||||
|
||||
function tokens(user: FakeUser, nonce: string | null) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const t = st.tamper;
|
||||
const claims = {
|
||||
iss: t.iss ? 'https://evil.example' : issuer,
|
||||
sub: user.sub,
|
||||
aud: t.aud ? 'another-client' : clientId,
|
||||
iat: now,
|
||||
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));
|
||||
access.set(at, user);
|
||||
refresh.set(rt, user);
|
||||
return {
|
||||
access_token: at,
|
||||
token_type: 'Bearer',
|
||||
expires_in: 300,
|
||||
refresh_token: rt,
|
||||
id_token: jwt(claims, t.signature ? other.privateKey : pair.privateKey),
|
||||
};
|
||||
}
|
||||
|
||||
function jwt(claims: object, key: KeyObject) {
|
||||
const data = `${b64u(JSON.stringify({ alg, kid: 'k1', typ: 'JWT' }))}.${b64u(JSON.stringify(claims))}`;
|
||||
const sig =
|
||||
alg === 'RS256'
|
||||
? sign('sha256', Buffer.from(data), key)
|
||||
: sign('sha256', Buffer.from(data), { key, dsaEncoding: 'ieee-p1363' });
|
||||
return `${data}.${b64u(sig)}`;
|
||||
}
|
||||
|
||||
await new Promise<void>((r) => server.listen(0, '127.0.0.1', r));
|
||||
const issuer = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
return {
|
||||
issuer,
|
||||
clientId,
|
||||
clientSecret,
|
||||
st,
|
||||
close: () => new Promise<void>((r) => server.close(() => r())),
|
||||
};
|
||||
}
|
||||
|
||||
async function body(req: IncomingMessage): Promise<string> {
|
||||
let s = '';
|
||||
for await (const chunk of req) s += chunk;
|
||||
return s;
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// M11-T10: fraud alerts (08 §8.3): risky calls, voip.ms calls the PBX didn't make, balance drop.
|
||||
import { createServer } from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import type { LightMyRequestResponse } from 'fastify';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { calls, cdr, dids } from '../src/db/schema.ts';
|
||||
import { createFraudWatch, riskyDestination, trunkDialNumber } from '../src/services/fraud.ts';
|
||||
import { setSetting } from '../src/services/settings.ts';
|
||||
import { hasTestDb } from './db.ts';
|
||||
import { ADMIN, H, createTestApp } from './app.ts';
|
||||
|
||||
describe('riskyDestination', () => {
|
||||
it('flags international, premium and NANP numbers billed like abroad', () => {
|
||||
expect(riskyDestination('01144207946095')).toBe('international');
|
||||
expect(riskyDestination('19005551234')).toBe('premium');
|
||||
expect(riskyDestination('13129765555')).toBe('premium'); // exchange 976
|
||||
expect(riskyDestination('18765551234')).toMatch(/international rates/); // Jamaica
|
||||
expect(riskyDestination('8095551234')).toMatch(/international rates/); // 10 digits
|
||||
expect(riskyDestination('13125551234')).toBeNull();
|
||||
expect(riskyDestination('18005551234')).toBeNull();
|
||||
expect(riskyDestination('101')).toBeNull();
|
||||
});
|
||||
|
||||
it('reads the number out of a trunk Dial', () => {
|
||||
expect(trunkDialNumber('PJSIP/13125550000@trunk-7d3e9a52,120')).toBe('13125550000');
|
||||
expect(trunkDialNumber('PJSIP/911@trunk-7d3e9a52')).toBe('911');
|
||||
expect(trunkDialNumber('PJSIP/101,30')).toBeNull();
|
||||
expect(trunkDialNumber(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!hasTestDb)('fraud watches', () => {
|
||||
const at = (iso: string) => new Date(iso);
|
||||
let clock = Date.parse('2026-10-29T12:00:00Z');
|
||||
let balance = '100.00';
|
||||
const cdrs: Record<string, unknown>[] = [];
|
||||
const seen: URLSearchParams[] = [];
|
||||
const fake = createServer((req, res) => {
|
||||
const q = new URL(req.url!, 'http://x').searchParams;
|
||||
seen.push(q);
|
||||
res.setHeader('content-type', 'application/json');
|
||||
const m = q.get('method');
|
||||
res.end(
|
||||
JSON.stringify(
|
||||
m === 'getBalance'
|
||||
? { status: 'success', balance: { current_balance: balance } }
|
||||
: m === 'getCDR'
|
||||
? cdrs.length
|
||||
? { status: 'success', cdr: cdrs }
|
||||
: { status: 'no_cdr' }
|
||||
: { status: 'invalid_method' },
|
||||
),
|
||||
);
|
||||
});
|
||||
let t: Awaited<ReturnType<typeof createTestApp>>;
|
||||
let req: (m: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
|
||||
let trunkId: string;
|
||||
let url: string;
|
||||
const mails: { subject: string; text: string }[] = [];
|
||||
const watch = () =>
|
||||
createFraudWatch({
|
||||
db: t.db,
|
||||
voipmsUrl: url,
|
||||
mail: async (subject, text) => void mails.push({ subject, text }),
|
||||
log: { warn() {} },
|
||||
now: () => new Date(clock),
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
await new Promise<void>((r) => fake.listen(0, '127.0.0.1', r));
|
||||
url = `http://127.0.0.1:${(fake.address() as AddressInfo).port}/api/v1/rest.php`;
|
||||
t = await createTestApp();
|
||||
await t.clearMustChange(ADMIN.email);
|
||||
const cookie = await t.login(ADMIN.email, ADMIN.password);
|
||||
req = (method, u, payload) =>
|
||||
t.app.inject({
|
||||
method: method as 'GET',
|
||||
url: u,
|
||||
payload: payload as object,
|
||||
headers: { ...H, cookie },
|
||||
});
|
||||
const r = await req('POST', '/api/trunks', {
|
||||
name: 'voip.ms',
|
||||
serverHost: '127.0.0.1',
|
||||
username: '123456_pbx',
|
||||
password: 'Sub-account pw!',
|
||||
});
|
||||
trunkId = r.json().id;
|
||||
await req('PATCH', `/api/trunks/${trunkId}`, {
|
||||
voipmsApiUsername: 'me@example.com',
|
||||
voipmsApiPassword: 'api-pw',
|
||||
});
|
||||
await t.db.insert(dids).values({
|
||||
number: '13125551000',
|
||||
trunkId,
|
||||
destination: { type: 'hangup' },
|
||||
} as typeof dids.$inferInsert);
|
||||
});
|
||||
afterAll(async () => {
|
||||
fake.close();
|
||||
await t.close();
|
||||
});
|
||||
|
||||
it('riskyCallWatch emails each risky outside call once, and not when turned off', async () => {
|
||||
const w = watch();
|
||||
mails.length = 0;
|
||||
const call = (linkedid: string, to: string, created: string) => ({
|
||||
linkedid,
|
||||
direction: 'outbound' as const,
|
||||
status: 'answered' as const,
|
||||
startedAt: at(created),
|
||||
endedAt: at(created),
|
||||
fromNumber: '101',
|
||||
toNumber: to,
|
||||
talkSeconds: 125,
|
||||
createdAt: at(created),
|
||||
});
|
||||
await t.db
|
||||
.insert(calls)
|
||||
.values([
|
||||
call('r1', '01144207946095', '2026-10-29T12:00:20Z'),
|
||||
call('r2', '18765551234', '2026-10-29T12:00:30Z'),
|
||||
call('r3', '13125551234', '2026-10-29T12:00:40Z'),
|
||||
]);
|
||||
clock = Date.parse('2026-10-29T12:01:00Z');
|
||||
await w.riskyCallWatch();
|
||||
expect(mails.map((m) => m.subject)).toEqual(['Calls to risky numbers (2)']);
|
||||
expect(mails[0]!.text).toMatch(/from 101 to 01144207946095: international, answered/);
|
||||
expect(mails[0]!.text).toMatch(/\(876\) 555-1234: international rates/);
|
||||
expect(mails[0]!.text).not.toMatch(/\(312\) 555-1234/);
|
||||
clock += 60_000;
|
||||
await w.riskyCallWatch(); // nothing new
|
||||
expect(mails).toHaveLength(1);
|
||||
|
||||
await setSetting(t.db, 'alerts.riskyCalls', false);
|
||||
await t.db.insert(calls).values(call('r4', '19005551234', '2026-10-29T12:01:30Z'));
|
||||
clock += 60_000;
|
||||
await w.riskyCallWatch();
|
||||
expect(mails).toHaveLength(1);
|
||||
await setSetting(t.db, 'alerts.riskyCalls', true);
|
||||
});
|
||||
|
||||
it('providerWatch reports voip.ms calls the PBX never sent to the trunk, once', async () => {
|
||||
await setSetting(t.db, 'alerts.balanceDropPerHour', 0);
|
||||
const w = watch();
|
||||
mails.length = 0;
|
||||
clock = Date.parse('2026-10-29T14:00:00Z');
|
||||
// The PBX sent one call to the trunk at 13:00 (a forward would look the same).
|
||||
await t.db.insert(cdr).values({
|
||||
uniqueid: 'u1',
|
||||
linkedid: 'u1',
|
||||
startAt: at('2026-10-29T13:00:05Z'),
|
||||
lastdata: `PJSIP/13125550000@trunk-${trunkId},120`,
|
||||
raw: {},
|
||||
});
|
||||
const rec = (uniqueid: string, date: string, destination: string) => ({
|
||||
date,
|
||||
callerid: '"Office" <3125551000>',
|
||||
destination,
|
||||
description: 'Outgoing',
|
||||
account: '123456_pbx',
|
||||
disposition: 'ANSWERED',
|
||||
duration: '00:03:00',
|
||||
seconds: '180',
|
||||
rate: '0.25',
|
||||
total: '0.7500',
|
||||
uniqueid,
|
||||
});
|
||||
cdrs.push(
|
||||
rec('v1', '2026-10-29 13:00:09', '3125550000'), // ours
|
||||
rec('v2', '2026-10-29 13:10:00', '3125551000'), // incoming, to our DID
|
||||
rec('v3', '2026-10-29 13:20:00', '01144207946095'), // not ours
|
||||
rec('v4', '2026-10-29 11:00:00', '01144207946096'), // before the window
|
||||
{ ...rec('v5', '2026-10-29 13:30:00', '01144207946097'), account: '123456_other' },
|
||||
);
|
||||
await w.providerWatch();
|
||||
const q = seen.findLast((s) => s.get('method') === 'getCDR')!;
|
||||
expect(
|
||||
Object.fromEntries(['date_from', 'date_to', 'timezone', 'account'].map((k) => [k, q.get(k)])),
|
||||
).toEqual({
|
||||
date_from: '2026-10-29',
|
||||
date_to: '2026-10-29',
|
||||
timezone: '0',
|
||||
account: '123456_pbx',
|
||||
});
|
||||
expect(mails.map((m) => m.subject)).toEqual([
|
||||
"voip.ms calls this phone system didn't make (1)",
|
||||
]);
|
||||
expect(mails[0]!.text).toMatch(/to 01144207946095: answered, 180 s, cost 0\.7500/);
|
||||
expect(mails[0]!.text).not.toMatch(/3125550000|3125551000|946096|946097/);
|
||||
clock += 15 * 60_000;
|
||||
await w.providerWatch(); // v3 again: already reported
|
||||
expect(mails).toHaveLength(1);
|
||||
|
||||
await setSetting(t.db, 'alerts.providerCalls', false);
|
||||
cdrs.push(rec('v6', '2026-10-29 14:00:00', '01144207946098'));
|
||||
clock += 15 * 60_000;
|
||||
await w.providerWatch();
|
||||
expect(mails).toHaveLength(1);
|
||||
await setSetting(t.db, 'alerts.providerCalls', true);
|
||||
cdrs.length = 0;
|
||||
});
|
||||
|
||||
it('balanceWatch emails once an hour when the balance drops more than the limit', async () => {
|
||||
await setSetting(t.db, 'alerts.providerCalls', false);
|
||||
await setSetting(t.db, 'alerts.balanceDropPerHour', 10);
|
||||
const w = watch();
|
||||
mails.length = 0;
|
||||
clock = Date.parse('2026-10-29T16:00:00Z');
|
||||
balance = '100.00';
|
||||
await w.providerWatch();
|
||||
clock += 15 * 60_000;
|
||||
balance = '95.00';
|
||||
await w.providerWatch();
|
||||
expect(mails).toEqual([]);
|
||||
clock += 15 * 60_000;
|
||||
balance = '85.00';
|
||||
await w.providerWatch();
|
||||
expect(mails.map((m) => m.subject)).toEqual(['voip.ms balance dropped 15.00 in an hour']);
|
||||
clock += 15 * 60_000;
|
||||
balance = '70.00';
|
||||
await w.providerWatch(); // within the hour of the last email
|
||||
expect(mails).toHaveLength(1);
|
||||
clock += 60 * 60_000;
|
||||
balance = '200.00'; // a deposit
|
||||
await w.providerWatch();
|
||||
expect(mails).toHaveLength(1);
|
||||
await setSetting(t.db, 'alerts.providerCalls', true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// SD3: the FTC complaint download (14 §2.2) against a fake api.ftc.gov with three days of data.
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { SPAM_DEFAULTS } from '@selfphoneng/shared';
|
||||
import { encrypt, setSecretKey } from '../src/crypto/secrets.ts';
|
||||
import type { Db } from '../src/db/client.ts';
|
||||
import { ftcComplaints, settings } from '../src/db/schema.ts';
|
||||
import { ftcSync } from '../src/spam/ftc.ts';
|
||||
import { getSetting, setSetting } from '../src/services/settings.ts';
|
||||
import { createTestDb, hasTestDb } from './db.ts';
|
||||
|
||||
const KEY = 'datagov-secret-key-456';
|
||||
const NOW = new Date('2026-10-27T18:00:00Z'); // 14:00 in Washington
|
||||
const hour = 3_600_000;
|
||||
|
||||
const at = (day: string, i: number) =>
|
||||
`${day} ${String(Math.floor(i / 60)).padStart(2, '0')}:${String(i % 60).padStart(2, '0')}:00`;
|
||||
const ftc = (id: string, phone: string, created: string, robo = 'Y') => ({
|
||||
type: 'dnc_complaint',
|
||||
id,
|
||||
attributes: {
|
||||
'company-phone-number': phone,
|
||||
'created-date': created,
|
||||
subject: 'Warranties',
|
||||
'recorded-message-or-robocall': robo,
|
||||
},
|
||||
});
|
||||
/** 120 complaints on the 25th (three pages), 3 on the 26th, 3 today (one usable). */
|
||||
const DATA = [
|
||||
...Array.from({ length: 120 }, (_, i) => ftc(`a${i}`, '3125550100', at('2026-10-25', i))),
|
||||
...[0, 1, 2].map((i) => ftc(`b${i}`, '3125550101', at('2026-10-26', i), 'N')),
|
||||
ftc('c0', '3125550102', '2026-10-27 09:00:00', ''),
|
||||
ftc('c1', '', '2026-10-27 09:01:00'), // no number
|
||||
ftc('c2', '3125550102', '2026-10-27'), // no time
|
||||
];
|
||||
|
||||
describe.skipIf(!hasTestDb)('FTC complaint download', () => {
|
||||
let db: Db;
|
||||
let drop: () => Promise<void>;
|
||||
/** Each request's day and offset. */
|
||||
const asked: string[] = [];
|
||||
let fail: { at: number; status: number } | null = null;
|
||||
|
||||
const turnOn = (keyEnc = encrypt(KEY)) =>
|
||||
setSetting(db, 'spam', {
|
||||
...SPAM_DEFAULTS,
|
||||
enabled: true,
|
||||
ftc: { keyEnc, syncHours: 24, windowDays: 7 },
|
||||
});
|
||||
const stored = async () => (await db.select().from(ftcComplaints)).length;
|
||||
const reset = async () => {
|
||||
await db.delete(ftcComplaints);
|
||||
await db.delete(settings).where(eq(settings.key, 'spam.state'));
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, drop } = await createTestDb());
|
||||
setSecretKey(randomBytes(32).toString('base64'));
|
||||
});
|
||||
afterAll(() => drop?.());
|
||||
|
||||
beforeEach(() => {
|
||||
asked.length = 0;
|
||||
fail = null;
|
||||
vi.stubGlobal('fetch', async (url: string, init?: RequestInit) => {
|
||||
const json = (j: unknown, status = 200) => new Response(JSON.stringify(j), { status });
|
||||
if ((init?.headers as Record<string, string>)['X-Api-Key'] !== KEY) return json({}, 403);
|
||||
const q = new URL(url).searchParams;
|
||||
const day = /^"(\d{4}-\d{2}-\d{2}) 00:00:00"$/.exec(q.get('created_date_from')!)![1]!;
|
||||
expect(q.get('created_date_to')).toBe(`"${day} 23:59:59"`);
|
||||
expect(q.get('sort_order')).toBe('ASC');
|
||||
const offset = Number(q.get('offset'));
|
||||
asked.push(`${day}+${offset}`);
|
||||
if (fail && asked.length === fail.at) return json({}, fail.status);
|
||||
const all = DATA.filter((c) => c.attributes['created-date'].startsWith(day));
|
||||
return json({ data: all.slice(offset, offset + 50), meta: { 'record-total': all.length } });
|
||||
});
|
||||
});
|
||||
afterEach(async () => {
|
||||
vi.unstubAllGlobals();
|
||||
await reset();
|
||||
});
|
||||
|
||||
it('downloads nothing while spam check or the FTC list is off', async () => {
|
||||
await setSetting(db, 'spam', SPAM_DEFAULTS);
|
||||
expect(await ftcSync(db, { now: NOW })).toBeNull();
|
||||
await setSetting(db, 'spam', { ...SPAM_DEFAULTS, enabled: true });
|
||||
expect(await ftcSync(db, { now: NOW })).toBeNull();
|
||||
expect(asked).toEqual([]);
|
||||
});
|
||||
|
||||
it('the first download fills the window a day at a time and drops old complaints', async () => {
|
||||
await turnOn();
|
||||
await db.insert(ftcComplaints).values({
|
||||
id: 'old',
|
||||
number: '13125550109',
|
||||
reportedAt: sql`${NOW.toISOString()}::timestamptz - interval '8 days'`,
|
||||
});
|
||||
expect(await ftcSync(db, { now: NOW, budget: 100 })).toBeNull();
|
||||
expect(asked).toEqual([
|
||||
'2026-10-20+0',
|
||||
'2026-10-21+0',
|
||||
'2026-10-22+0',
|
||||
'2026-10-23+0',
|
||||
'2026-10-24+0',
|
||||
'2026-10-25+0',
|
||||
'2026-10-25+50',
|
||||
'2026-10-25+100',
|
||||
'2026-10-26+0',
|
||||
'2026-10-27+0',
|
||||
]);
|
||||
expect(await stored()).toBe(124);
|
||||
const row = async (id: string) =>
|
||||
(await db.select().from(ftcComplaints).where(eq(ftcComplaints.id, id)))[0];
|
||||
expect(await row('b0')).toMatchObject({
|
||||
number: '13125550101',
|
||||
subject: 'Warranties',
|
||||
robocall: false,
|
||||
reportedAt: new Date('2026-10-26T04:00:00Z'), // midnight in Washington
|
||||
});
|
||||
expect((await row('c0'))?.robocall).toBeNull();
|
||||
expect(await getSetting(db, 'spam.state')).toMatchObject({
|
||||
ftcCursor: null,
|
||||
ftcLastSyncAt: NOW.toISOString(),
|
||||
ftcLastError: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('not again until syncHours; then from the newest day kept, less one', async () => {
|
||||
await turnOn();
|
||||
await ftcSync(db, { now: NOW, budget: 100 });
|
||||
asked.length = 0;
|
||||
await ftcSync(db, { now: new Date(NOW.getTime() + 23 * hour) });
|
||||
expect(asked).toEqual([]);
|
||||
await ftcSync(db, { now: new Date(NOW.getTime() + 24 * hour) });
|
||||
expect(asked).toEqual(['2026-10-26+0', '2026-10-27+0', '2026-10-28+0']);
|
||||
});
|
||||
|
||||
it('15 pages a minute, the place saved; a 429 stops there and the next minute carries on', async () => {
|
||||
await turnOn();
|
||||
await ftcSync(db, { now: NOW });
|
||||
expect(asked).toHaveLength(10);
|
||||
await reset();
|
||||
asked.length = 0;
|
||||
|
||||
await ftcSync(db, { now: NOW, budget: 2 });
|
||||
expect((await getSetting(db, 'spam.state')).ftcCursor).toEqual({
|
||||
day: '2026-10-22',
|
||||
offset: 0,
|
||||
});
|
||||
fail = { at: 7, status: 429 }; // the 25th's second page
|
||||
const said = 'api.data.gov says the key is over its hourly limit';
|
||||
expect(await ftcSync(db, { now: NOW })).toBe(said);
|
||||
expect(await getSetting(db, 'spam.state')).toMatchObject({
|
||||
ftcCursor: { day: '2026-10-25', offset: 50 },
|
||||
ftcLastError: said,
|
||||
ftcLastSyncAt: null,
|
||||
});
|
||||
fail = { at: 8, status: 429 }; // the same failure isn't returned (logged) twice
|
||||
expect(await ftcSync(db, { now: NOW })).toBeNull();
|
||||
fail = null;
|
||||
expect(await ftcSync(db, { now: NOW })).toBeNull();
|
||||
expect(await stored()).toBe(124);
|
||||
expect(await getSetting(db, 'spam.state')).toMatchObject({
|
||||
ftcCursor: null,
|
||||
ftcLastError: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('a refused key is saved for the Settings tab', async () => {
|
||||
await turnOn(encrypt('wrong'));
|
||||
expect(await ftcSync(db, { now: NOW })).toBe('api.data.gov refused the key');
|
||||
expect((await getSetting(db, 'spam.state')).ftcLastError).toBe('api.data.gov refused the key');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,252 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// 12 S4-S5 against a real lldap: sign in by uid and by email, connect by email, no user made for
|
||||
// a stranger, admin by memberOf, Test, LDAPS, directoryCheck disabling a removed user. Needs
|
||||
// LDAP_LIVE_URL (ldap://127.0.0.1:<port>) and LDAP_LIVE_HTTP (its web port, to make the users);
|
||||
// LDAP_LIVE_LDAPS_URL to try LDAPS too, with NODE_EXTRA_CA_CERTS naming the CA of its
|
||||
// certificate. Admin password: LDAP_LIVE_ADMIN_PASS.
|
||||
// Passed against lldap 0.6.3:
|
||||
// docker run -d -p 127.0.0.1:13890:3890 -p 127.0.0.1:16360:6360 -p 127.0.0.1:17170:17170 \
|
||||
// -e LLDAP_JWT_SECRET=… -e LLDAP_KEY_SEED=… -e LLDAP_LDAP_USER_PASS=admin-pass-123 \
|
||||
// -e LLDAP_LDAP_BASE_DN=dc=example,dc=com [-e LLDAP_LDAPS_OPTIONS__ENABLED=true …] \
|
||||
// lldap/lldap:v0.6.3-alpine
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { Client } from 'ldapts';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { encrypt } from '../src/crypto/secrets.ts';
|
||||
import { userIdentities, users } from '../src/db/schema.ts';
|
||||
import { resetLoginLimiter } from '../src/auth/login-limiter.ts';
|
||||
import { setSetting } from '../src/services/settings.ts';
|
||||
import { checkDirectory } from '../src/sso/directory.ts';
|
||||
import { hasTestDb } from './db.ts';
|
||||
import { ADMIN, H, createTestApp } from './app.ts';
|
||||
|
||||
const URL_ = process.env.LDAP_LIVE_URL;
|
||||
const HTTP = process.env.LDAP_LIVE_HTTP;
|
||||
const LDAPS = process.env.LDAP_LIVE_LDAPS_URL;
|
||||
const ADMIN_PASS = process.env.LDAP_LIVE_ADMIN_PASS ?? 'admin-pass-123';
|
||||
const BASE = 'dc=example,dc=com';
|
||||
|
||||
/** RFC 3062 password modify request: SEQUENCE { [0] user DN, [2] new password }. */
|
||||
function passwordModify(dn: string, password: string): Buffer {
|
||||
const tlv = (tag: number, v: Buffer) => Buffer.concat([Buffer.from([tag, v.length]), v]);
|
||||
return tlv(0x30, Buffer.concat([tlv(0x80, Buffer.from(dn)), tlv(0x82, Buffer.from(password))]));
|
||||
}
|
||||
|
||||
describe.skipIf(!hasTestDb || !URL_ || !HTTP)('LDAP with a real lldap (12 S4)', () => {
|
||||
const tag = randomBytes(3).toString('hex');
|
||||
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;
|
||||
let admin: string;
|
||||
|
||||
const gql = async (query: string) => {
|
||||
const r = await fetch(`${HTTP}/api/graphql`, {
|
||||
method: 'POST',
|
||||
headers: { authorization: `Bearer ${jwt}`, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ query }),
|
||||
});
|
||||
const j = (await r.json()) as { data: Record<string, unknown>; errors?: unknown };
|
||||
if (j.errors) throw new Error(JSON.stringify(j.errors));
|
||||
return j.data;
|
||||
};
|
||||
const settings = (url = URL_!) => ({
|
||||
enabled: true,
|
||||
preset: 'lldap' as const,
|
||||
url,
|
||||
startTls: false,
|
||||
bindDn: `uid=admin,ou=people,${BASE}`,
|
||||
bindPasswordEnc: encrypt(ADMIN_PASS),
|
||||
baseDn: `ou=people,${BASE}`,
|
||||
userFilter: '(&(objectClass=person)(|(uid={login})(mail={login})))',
|
||||
idAttribute: 'entryUUID',
|
||||
emailAttribute: 'mail',
|
||||
nameAttribute: 'displayName',
|
||||
adminFilter: `(memberOf=cn=${group},ou=groups,${BASE})`,
|
||||
});
|
||||
const login = (who: string, password: string) =>
|
||||
t.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/auth/login',
|
||||
headers: H,
|
||||
payload: { login: who, password },
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
const r = await fetch(`${HTTP}/auth/simple/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ username: 'admin', password: ADMIN_PASS }),
|
||||
});
|
||||
jwt = ((await r.json()) as { token: string }).token;
|
||||
for (const [id, name] of [
|
||||
[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 } }`,
|
||||
);
|
||||
const g = (await gql(`mutation { createGroup(name: "${group}") { id } }`)) as {
|
||||
createGroup: { id: number };
|
||||
};
|
||||
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, dee])
|
||||
await c.exop(
|
||||
'1.3.6.1.4.1.4203.1.11.1',
|
||||
passwordModify(`uid=${id},ou=people,${BASE}`, `${id}-directory-pw`),
|
||||
);
|
||||
await c.unbind();
|
||||
|
||||
t = await createTestApp();
|
||||
await t.clearMustChange(ADMIN.email);
|
||||
admin = await t.login(ADMIN.email, ADMIN.password);
|
||||
// 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, dee])
|
||||
await gql(`mutation { deleteUser(userId: "${id}") { ok } }`).catch(() => {});
|
||||
});
|
||||
|
||||
it('methods say the directory is on', async () => {
|
||||
expect((await t.app.inject({ url: '/api/auth/methods' })).json().ldap).toBe(true);
|
||||
});
|
||||
|
||||
it('signs in by uid, connecting the user by email, and memberOf makes an admin', async () => {
|
||||
resetLoginLimiter();
|
||||
const r = await login(ann, `${ann}-directory-pw`);
|
||||
expect(r.statusCode).toBe(200);
|
||||
expect(r.json().user).toMatchObject({
|
||||
email: `${ann}@example.com`,
|
||||
role: 'admin',
|
||||
signIn: { password: true, ldap: true },
|
||||
});
|
||||
const [u] = await t.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, `${ann}@example.com`));
|
||||
const ids = await t.db.select().from(userIdentities).where(eq(userIdentities.userId, u!.id));
|
||||
expect(ids).toMatchObject([{ kind: 'ldap', email: `${ann}@example.com` }]);
|
||||
expect(ids[0]!.subject).toMatch(/^[0-9a-f-]{36}$/); // entryUUID
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it('a wrong password, an empty one, and a stranger are refused alike', async () => {
|
||||
resetLoginLimiter();
|
||||
for (const [who, pw] of [
|
||||
[ann, 'wrong-password'],
|
||||
[ann, ''],
|
||||
[bob, `${bob}-directory-pw`], // in the directory, but nobody here: no user is made
|
||||
['*', `${ann}-directory-pw`],
|
||||
]) {
|
||||
const r = await login(who!, pw!);
|
||||
expect([400, 401]).toContain(r.statusCode);
|
||||
}
|
||||
const [b] = await t.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, `${bob}@example.com`));
|
||||
expect(b).toBeUndefined();
|
||||
});
|
||||
|
||||
it('a directory that is down is a 503, and local passwords still work', async () => {
|
||||
resetLoginLimiter();
|
||||
await setSetting(t.db, 'auth.ldap', settings('ldap://127.0.0.1:1'));
|
||||
try {
|
||||
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);
|
||||
} finally {
|
||||
await setSetting(t.db, 'auth.ldap', settings());
|
||||
}
|
||||
});
|
||||
|
||||
it('Test: counts people, and a test login says who it would be', async () => {
|
||||
const { bindPasswordEnc: _, ...form } = settings();
|
||||
const r = await t.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/settings/auth/ldap/test',
|
||||
headers: { ...H, cookie: admin },
|
||||
payload: { ...form, testLogin: ann, testPassword: `${ann}-directory-pw` },
|
||||
});
|
||||
expect(r.json()).toMatchObject({
|
||||
ok: true,
|
||||
user: { email: `${ann}@example.com`, admin: true, matches: `${ann}@example.com` },
|
||||
});
|
||||
expect(r.json().matching).toBeGreaterThanOrEqual(3);
|
||||
const bad = await t.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/settings/auth/ldap/test',
|
||||
headers: { ...H, cookie: admin },
|
||||
payload: { ...form, testLogin: ann, testPassword: 'nope' },
|
||||
});
|
||||
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
|
||||
await gql(`mutation { deleteUser(userId: "${cat}") { ok } }`);
|
||||
await checkDirectory({ db: t.db, log: { info: () => {}, warn: () => {} } });
|
||||
const active = async (who: string) =>
|
||||
(
|
||||
await t.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, `${who}@example.com`))
|
||||
)[0]!.isActive;
|
||||
expect(await active(cat)).toBe(false);
|
||||
expect(await active(ann)).toBe(true);
|
||||
});
|
||||
|
||||
it.skipIf(!LDAPS)('LDAPS with a private CA (NODE_EXTRA_CA_CERTS)', async () => {
|
||||
resetLoginLimiter();
|
||||
await setSetting(t.db, 'auth.ldap', settings(LDAPS));
|
||||
try {
|
||||
expect((await login(ann, `${ann}-directory-pw`)).statusCode).toBe(200);
|
||||
} finally {
|
||||
await setSetting(t.db, 'auth.ldap', settings());
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// MB1: pairing the mobile app and its device tokens (13 §2, §4).
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { LightMyRequestResponse } from 'fastify';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { auditLog, mobileDevices, users } from '../src/db/schema.ts';
|
||||
import { hasTestDb } from './db.ts';
|
||||
import { ADMIN, H, createTestApp } from './app.ts';
|
||||
|
||||
describe.skipIf(!hasTestDb)('mobile app pairing', () => {
|
||||
let t: Awaited<ReturnType<typeof createTestApp>>;
|
||||
type Req = (method: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
|
||||
let admin: Req;
|
||||
let dan: Req;
|
||||
let danId: string;
|
||||
const withHeaders =
|
||||
(headers: Record<string, string>): Req =>
|
||||
(method, url, payload) =>
|
||||
t.app.inject({ method: method as 'GET', url, payload: payload as object, headers });
|
||||
const bearer = (token: string) => withHeaders({ authorization: `Bearer ${token}` });
|
||||
// The app sends X-Requested-With on its one call without a token (CSRF hook, 04 §2.2).
|
||||
const anon = withHeaders(H);
|
||||
const pair = (code: string, name = 'Pixel 9') =>
|
||||
anon('POST', '/api/mobile/pair', { code, name, platform: 'android' });
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestApp();
|
||||
await t.clearMustChange(ADMIN.email);
|
||||
admin = withHeaders({ ...H, cookie: await t.login(ADMIN.email, ADMIN.password) });
|
||||
danId = (await t.addUser('dan@example.com', 'dan-password-123')).id;
|
||||
dan = withHeaders({ ...H, cookie: await t.login('dan@example.com', 'dan-password-123') });
|
||||
// GET /api/me needs an extension.
|
||||
for (const [number, userId] of [
|
||||
['101', danId],
|
||||
['102', (await t.db.select().from(users).where(eq(users.email, ADMIN.email)))[0]!.id],
|
||||
])
|
||||
await admin('POST', '/api/extensions', { number, name: `Ext ${number}`, userId });
|
||||
});
|
||||
afterAll(() => t?.close());
|
||||
|
||||
let token: string;
|
||||
let deviceId: string;
|
||||
|
||||
it('pairs a phone once with a code from the web page', async () => {
|
||||
const r = await dan('POST', '/api/me/mobile-devices/pair');
|
||||
expect(r.statusCode).toBe(200);
|
||||
const c = r.json();
|
||||
expect(c.code).toMatch(/^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{12}$/);
|
||||
expect(c.server).toBe('https://pbx.test');
|
||||
expect(c.link).toBe(
|
||||
`selfphoneng://pair?server=${encodeURIComponent('https://pbx.test')}&code=${c.code}`,
|
||||
);
|
||||
expect(c.qr).toMatch(/^data:image\/png;base64,/);
|
||||
expect(new Date(c.expiresAt).getTime() - Date.now()).toBeGreaterThan(9 * 60_000);
|
||||
|
||||
// Typed by hand: lower case and spaces are fine.
|
||||
const typed = `${c.code.slice(0, 4)} ${c.code.slice(4)}`.toLowerCase();
|
||||
const p = await pair(typed);
|
||||
expect(p.statusCode).toBe(200);
|
||||
expect(p.json().user).toEqual({ name: expect.any(String), email: 'dan@example.com' });
|
||||
token = p.json().token;
|
||||
expect(token).toMatch(/^spm_[A-Za-z0-9]{40}$/);
|
||||
|
||||
expect((await pair(c.code)).json().error.code).toBe('pair_code_invalid'); // used
|
||||
const rows = await t.db.select().from(mobileDevices);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]!.tokenHash).not.toContain(token.slice(4)); // stored hashed
|
||||
expect(rows[0]!.pairCodeHash).toBeNull();
|
||||
deviceId = rows[0]!.id;
|
||||
});
|
||||
|
||||
it('the device token acts as its user', async () => {
|
||||
const me = bearer(token);
|
||||
expect((await me('GET', '/api/me')).statusCode).toBe(200);
|
||||
// No X-Requested-With needed with a Bearer token.
|
||||
expect((await me('PATCH', '/api/me', { dnd: true })).statusCode).toBe(200);
|
||||
const list = (await me('GET', '/api/me/mobile-devices')).json().items;
|
||||
expect(list).toEqual([
|
||||
{
|
||||
id: deviceId,
|
||||
name: 'Pixel 9',
|
||||
platform: 'android',
|
||||
tokenPrefix: token.slice(0, 8),
|
||||
createdAt: expect.any(String),
|
||||
lastUsedAt: expect.any(String),
|
||||
current: true,
|
||||
},
|
||||
]);
|
||||
expect((await dan('GET', '/api/me/mobile-devices')).json().items[0].current).toBe(false);
|
||||
// A phone can't make itself a spare.
|
||||
expect((await me('POST', '/api/me/mobile-devices/pair')).statusCode).toBe(403);
|
||||
expect((await bearer(`spm_${'x'.repeat(40)}`)('GET', '/api/me')).statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it("an admin's phone is not an admin", async () => {
|
||||
const code = (await admin('POST', '/api/me/mobile-devices/pair')).json().code;
|
||||
const phone = bearer((await pair(code, 'iPhone')).json().token);
|
||||
expect((await phone('GET', '/api/me')).statusCode).toBe(200);
|
||||
expect((await phone('GET', '/api/users')).statusCode).toBe(403);
|
||||
expect((await phone('GET', '/api/api-tokens')).statusCode).toBe(403);
|
||||
expect((await admin('GET', '/api/users')).statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('refuses expired codes, bad input, and replaces an unused code', async () => {
|
||||
const first = (await dan('POST', '/api/me/mobile-devices/pair')).json().code;
|
||||
const second = (await dan('POST', '/api/me/mobile-devices/pair')).json().code;
|
||||
expect((await pair(first)).statusCode).toBe(404); // replaced
|
||||
await t.db
|
||||
.update(mobileDevices)
|
||||
.set({ pairExpiresAt: new Date(Date.now() - 1000) })
|
||||
.where(eq(mobileDevices.userId, danId));
|
||||
expect((await pair(second)).json().error.code).toBe('pair_code_invalid');
|
||||
const bad = await anon('POST', '/api/mobile/pair', {
|
||||
code: second,
|
||||
name: 'x',
|
||||
platform: 'windows',
|
||||
});
|
||||
expect(bad.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('a disabled user loses their phones and can’t pair one', async () => {
|
||||
const code = (await dan('POST', '/api/me/mobile-devices/pair')).json().code;
|
||||
await t.db.update(users).set({ isActive: false }).where(eq(users.id, danId));
|
||||
expect((await bearer(token)('GET', '/api/me')).statusCode).toBe(401);
|
||||
expect((await pair(code)).statusCode).toBe(404);
|
||||
await t.db.update(users).set({ isActive: true }).where(eq(users.id, danId));
|
||||
expect((await bearer(token)('GET', '/api/me')).statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('removing a device ends its token; only your own', async () => {
|
||||
const adminPhone = (await t.db.select().from(mobileDevices)).find((d) => d.name === 'iPhone')!;
|
||||
expect(
|
||||
(await bearer(token)('DELETE', `/api/me/mobile-devices/${adminPhone.id}`)).statusCode,
|
||||
).toBe(404);
|
||||
expect((await bearer(token)('DELETE', `/api/me/mobile-devices/${deviceId}`)).statusCode).toBe(
|
||||
200,
|
||||
);
|
||||
expect((await bearer(token)('GET', '/api/me')).statusCode).toBe(401);
|
||||
const actions = (await t.db.select().from(auditLog))
|
||||
.filter((a) => a.entityType === 'mobile_device')
|
||||
.map((a) => a.action);
|
||||
expect(actions).toContain('create');
|
||||
expect(actions).toContain('delete');
|
||||
});
|
||||
|
||||
it('changing the password removes every paired phone', async () => {
|
||||
const code = (await dan('POST', '/api/me/mobile-devices/pair')).json().code;
|
||||
const phone = bearer((await pair(code)).json().token);
|
||||
expect((await phone('GET', '/api/me')).statusCode).toBe(200);
|
||||
const r = await dan('POST', '/api/auth/change-password', {
|
||||
currentPassword: 'dan-password-123',
|
||||
newPassword: 'a-new-dan-password-456',
|
||||
});
|
||||
expect(r.statusCode).toBe(200);
|
||||
expect((await phone('GET', '/api/me')).statusCode).toBe(401);
|
||||
expect((await dan('GET', '/api/me/mobile-devices')).json().items).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,236 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// 12 S3 against a real Keycloak: connect, sign in through Keycloak's own login form, a group
|
||||
// makes an admin, signing out there too, and disabling the user there ending our session.
|
||||
// Needs KEYCLOAK_LIVE_URL (e.g. http://127.0.0.1:18080) and KEYCLOAK_LIVE_ADMIN ("user:password"
|
||||
// of the master realm); makes a throwaway realm and deletes it. Passed against 26.8.1:
|
||||
// docker run -d -p 127.0.0.1:18080:8080 -e KC_BOOTSTRAP_ADMIN_USERNAME=admin \
|
||||
// -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin quay.io/keycloak/keycloak:26.8.1 start-dev
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { encrypt } from '../src/crypto/secrets.ts';
|
||||
import { sessions, users } from '../src/db/schema.ts';
|
||||
import { setSetting } from '../src/services/settings.ts';
|
||||
import { checkOidcSessions } from '../src/sso/oidcCheck.ts';
|
||||
import { hasTestDb } from './db.ts';
|
||||
import { ADMIN, H, createTestApp } from './app.ts';
|
||||
|
||||
const KC = process.env.KEYCLOAK_LIVE_URL;
|
||||
const [KC_USER, KC_PASS] = (process.env.KEYCLOAK_LIVE_ADMIN ?? 'admin:admin').split(':');
|
||||
|
||||
/** A cookie jar for the browser's side of Keycloak. */
|
||||
class Browser {
|
||||
jar = new Map<string, string>();
|
||||
async get(url: string, init: RequestInit = {}) {
|
||||
const res = await fetch(url, {
|
||||
...init,
|
||||
redirect: 'manual',
|
||||
headers: { ...(init.headers as object), cookie: this.cookie() },
|
||||
});
|
||||
for (const c of res.headers.getSetCookie()) {
|
||||
const [kv] = c.split(';');
|
||||
const i = kv!.indexOf('=');
|
||||
this.jar.set(kv!.slice(0, i), kv!.slice(i + 1));
|
||||
}
|
||||
return res;
|
||||
}
|
||||
cookie = () => [...this.jar].map(([k, v]) => `${k}=${v}`).join('; ');
|
||||
}
|
||||
|
||||
describe.skipIf(!hasTestDb || !KC)('OpenID Connect with a real Keycloak (12 S3)', () => {
|
||||
const realm = `sp-test-${randomBytes(4).toString('hex')}`;
|
||||
const issuer = `${KC}/realms/${realm}`;
|
||||
let t: Awaited<ReturnType<typeof createTestApp>>;
|
||||
let token: string;
|
||||
let kcUserId: string;
|
||||
const browser = new Browser();
|
||||
|
||||
const kcAdmin = async (method: string, path: string, body?: unknown) => {
|
||||
const res = await fetch(`${KC}/admin/realms${path}`, {
|
||||
method,
|
||||
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) throw new Error(`${method} ${path}: ${res.status} ${await res.text()}`);
|
||||
return res;
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const tok = await fetch(`${KC}/realms/master/protocol/openid-connect/token`, {
|
||||
method: 'POST',
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'password',
|
||||
client_id: 'admin-cli',
|
||||
username: KC_USER!,
|
||||
password: KC_PASS!,
|
||||
}),
|
||||
});
|
||||
token = ((await tok.json()) as { access_token: string }).access_token;
|
||||
await kcAdmin('POST', '', { realm, enabled: true });
|
||||
await kcAdmin('POST', `/${realm}/clients`, {
|
||||
clientId: 'selfphoneng',
|
||||
protocol: 'openid-connect',
|
||||
publicClient: false,
|
||||
secret: 'live-secret-with-symbols-+/=',
|
||||
standardFlowEnabled: true,
|
||||
directAccessGrantsEnabled: false,
|
||||
redirectUris: ['https://pbx.test/api/auth/oidc/callback'],
|
||||
attributes: { 'post.logout.redirect.uris': 'https://pbx.test/login' },
|
||||
protocolMappers: [
|
||||
{
|
||||
name: 'groups',
|
||||
protocol: 'openid-connect',
|
||||
protocolMapper: 'oidc-group-membership-mapper',
|
||||
config: {
|
||||
'full.path': 'false',
|
||||
'id.token.claim': 'true',
|
||||
'access.token.claim': 'true',
|
||||
'userinfo.token.claim': 'true',
|
||||
'claim.name': 'groups',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
await kcAdmin('POST', `/${realm}/groups`, { name: 'pbx-admins' });
|
||||
const u = await kcAdmin('POST', `/${realm}/users`, {
|
||||
username: 'ann',
|
||||
email: 'ann@example.com',
|
||||
emailVerified: true,
|
||||
firstName: 'Ann',
|
||||
lastName: 'Lee',
|
||||
enabled: true,
|
||||
groups: ['/pbx-admins'],
|
||||
credentials: [{ type: 'password', value: 'ann-keycloak-pw', temporary: false }],
|
||||
});
|
||||
kcUserId = u.headers.get('location')!.split('/').at(-1)!;
|
||||
|
||||
t = await createTestApp();
|
||||
await t.clearMustChange(ADMIN.email);
|
||||
await t.addUser('ann@example.com', 'ann-password-12');
|
||||
await setSetting(t.db, 'auth.oidc', {
|
||||
label: 'Keycloak',
|
||||
issuer,
|
||||
clientId: 'selfphoneng',
|
||||
clientSecretEnc: encrypt('live-secret-with-symbols-+/='),
|
||||
scopes: 'openid email profile',
|
||||
groupsClaim: 'groups',
|
||||
adminGroups: ['pbx-admins'],
|
||||
allowedGroups: [],
|
||||
trustEmail: false,
|
||||
});
|
||||
await setSetting(t.db, 'auth.mode', 'mixed');
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await t?.close();
|
||||
if (token) await kcAdmin('DELETE', `/${realm}`).catch(() => {});
|
||||
});
|
||||
|
||||
/** start → Keycloak's login form, filled in like a person would → our callback. */
|
||||
async function flow(cookie?: string, query = '') {
|
||||
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(location as string);
|
||||
if (res.status === 200) {
|
||||
// Not signed in at Keycloak yet: post its login form.
|
||||
const html = await res.text();
|
||||
const action = /<form[^>]*id="kc-form-login"[^>]*action="([^"]+)"/.exec(html)![1]!;
|
||||
res = await browser.get(action.replaceAll('&', '&'), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ username: 'ann', password: 'ann-keycloak-pw' }).toString(),
|
||||
});
|
||||
}
|
||||
expect(res.status).toBe(302);
|
||||
const back = new URL(res.headers.get('location')!);
|
||||
expect(back.origin).toBe('https://pbx.test');
|
||||
return t.app.inject({
|
||||
url: back.pathname + back.search,
|
||||
headers: { cookie: [`sp_oidc=${pending.value}`, cookie].filter(Boolean).join('; ') },
|
||||
});
|
||||
}
|
||||
const session = (r: Awaited<ReturnType<typeof flow>>) => {
|
||||
const c = r.cookies.find((x) => x.name === 'sp_session');
|
||||
return c ? `sp_session=${c.value}` : null;
|
||||
};
|
||||
|
||||
it('Test sees the realm and accepts the client', async () => {
|
||||
const admin = await t.login(ADMIN.email, ADMIN.password);
|
||||
const r = await t.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/settings/auth/oidc/test',
|
||||
headers: { ...H, cookie: admin },
|
||||
payload: {
|
||||
label: 'Keycloak',
|
||||
issuer,
|
||||
clientId: 'selfphoneng',
|
||||
scopes: 'openid email profile',
|
||||
groupsClaim: 'groups',
|
||||
adminGroups: [],
|
||||
allowedGroups: [],
|
||||
trustEmail: false,
|
||||
},
|
||||
});
|
||||
expect(r.json()).toMatchObject({ ok: true, pkce: true, refreshTokens: true, endSession: true });
|
||||
});
|
||||
|
||||
it('connects, signs in through the login form, and the group makes an admin', async () => {
|
||||
const pw = await t.login('ann@example.com', 'ann-password-12');
|
||||
const c = await flow(pw, '?connect=1');
|
||||
expect(c.headers.location).toBe('/me/settings?sso=connected');
|
||||
const r = await flow();
|
||||
const cookie = session(r);
|
||||
expect(cookie).toBeTruthy();
|
||||
const me = (await t.app.inject({ url: '/api/auth/me', headers: { cookie: cookie! } })).json();
|
||||
expect(me.user).toMatchObject({ role: 'admin' });
|
||||
expect(me.user.signIn.oidc).toEqual({ email: 'ann@example.com', groups: ['pbx-admins'] });
|
||||
});
|
||||
|
||||
it('signing out signs out of Keycloak too', async () => {
|
||||
const r = await flow(); // already signed in at Keycloak: straight back
|
||||
const out = await t.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/auth/logout',
|
||||
headers: { ...H, cookie: session(r)! },
|
||||
});
|
||||
const to = out.json().redirect as string;
|
||||
expect(to.startsWith(`${issuer}/protocol/openid-connect/logout`)).toBe(true);
|
||||
const done = await browser.get(to);
|
||||
expect(done.status).toBe(302);
|
||||
expect(done.headers.get('location')).toBe('https://pbx.test/login');
|
||||
// Keycloak forgot us: the next sign-in shows its login form again (flow() fills it).
|
||||
expect(session(await flow())).toBeTruthy();
|
||||
});
|
||||
|
||||
it('disabling the user in Keycloak ends their session at the next check', async () => {
|
||||
const r = await flow();
|
||||
const cookie = session(r)!;
|
||||
const [ann] = await t.db.select().from(users).where(eq(users.email, 'ann@example.com'));
|
||||
const check = (min: number) =>
|
||||
checkOidcSessions({
|
||||
db: t.db,
|
||||
log: t.app.log,
|
||||
now: new Date(Date.now() + min * 60_000),
|
||||
});
|
||||
await check(16);
|
||||
expect((await t.app.inject({ url: '/api/auth/me', headers: { cookie } })).statusCode).toBe(200);
|
||||
await kcAdmin('PUT', `/${realm}/users/${kcUserId}`, { enabled: false });
|
||||
await check(32);
|
||||
const left = await t.db.select().from(sessions).where(eq(sessions.userId, ann!.id));
|
||||
expect(left.filter((s) => s.method === 'oidc')).toEqual([]);
|
||||
expect((await t.app.inject({ url: '/api/auth/me', headers: { cookie } })).statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,527 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// 12 S2: OpenID Connect sign-in, connect, disconnect, modes and signing out, against a fake
|
||||
// provider (test/fake-oidc.ts).
|
||||
import { and, desc, eq } from 'drizzle-orm';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
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';
|
||||
import type { OidcSettings } from '../src/sso/oidc.ts';
|
||||
import { checkOidcSessions } from '../src/sso/oidcCheck.ts';
|
||||
import { hasTestDb } from './db.ts';
|
||||
import { ADMIN, H, createTestApp } from './app.ts';
|
||||
import { startFakeOidc } from './fake-oidc.ts';
|
||||
|
||||
for (const alg of ['RS256', 'ES256'] as const)
|
||||
describe.skipIf(!hasTestDb)(`OpenID Connect (12 S2, ${alg})`, () => {
|
||||
let t: Awaited<ReturnType<typeof createTestApp>>;
|
||||
let idp: Awaited<ReturnType<typeof startFakeOidc>>;
|
||||
let admin: string;
|
||||
let oidcSettings: OidcSettings;
|
||||
|
||||
beforeAll(async () => {
|
||||
idp = await startFakeOidc(alg);
|
||||
t = await createTestApp();
|
||||
await t.clearMustChange(ADMIN.email);
|
||||
admin = await t.login(ADMIN.email, ADMIN.password);
|
||||
oidcSettings = {
|
||||
label: 'Keycloak',
|
||||
issuer: idp.issuer,
|
||||
clientId: idp.clientId,
|
||||
clientSecretEnc: encrypt(idp.clientSecret),
|
||||
scopes: 'openid email profile',
|
||||
groupsClaim: 'groups',
|
||||
adminGroups: [],
|
||||
allowedGroups: [],
|
||||
trustEmail: false,
|
||||
};
|
||||
await setSetting(t.db, 'auth.oidc', oidcSettings);
|
||||
await setSetting(t.db, 'auth.mode', 'mixed');
|
||||
});
|
||||
afterAll(async () => {
|
||||
await t?.close();
|
||||
await idp?.close();
|
||||
});
|
||||
beforeEach(() => {
|
||||
resetLoginLimiter();
|
||||
idp.st.tamper = {};
|
||||
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 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(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({
|
||||
url: back.pathname + back.search,
|
||||
headers: { cookie: [`sp_oidc=${pending.value}`, cookie].filter(Boolean).join('; ') },
|
||||
});
|
||||
return { start, callback };
|
||||
}
|
||||
const session = (r: { cookies: { name: string; value: string }[] }) => {
|
||||
const c = r.cookies.find((x) => x.name === 'sp_session');
|
||||
return c ? `sp_session=${c.value}` : null;
|
||||
};
|
||||
const errorOf = (r: { headers: Record<string, unknown> }) =>
|
||||
new URL(r.headers.location as string, 'https://pbx.test').searchParams.get('sso_error');
|
||||
|
||||
it('methods show the button in mixed mode', async () => {
|
||||
const r = await t.app.inject({ url: '/api/auth/methods' });
|
||||
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);
|
||||
expect(to.origin).toBe(idp.issuer);
|
||||
for (const p of ['state', 'nonce', 'code_challenge'])
|
||||
expect(to.searchParams.get(p)).toBeTruthy();
|
||||
expect(to.searchParams.get('code_challenge_method')).toBe('S256');
|
||||
expect(to.searchParams.get('redirect_uri')).toBe('https://pbx.test/api/auth/oidc/callback');
|
||||
expect(r.cookies.find((c) => c.name === 'sp_oidc')).toMatchObject({
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'Lax',
|
||||
path: '/api/auth/oidc',
|
||||
maxAge: 600,
|
||||
});
|
||||
});
|
||||
|
||||
it('an account nobody connected is refused, and no user is made', async () => {
|
||||
idp.st.user = { sub: 'stranger', email: 'stranger@example.com', email_verified: true };
|
||||
const { callback } = await flow();
|
||||
expect(errorOf(callback!)).toBe('not_connected');
|
||||
expect(session(callback!)).toBeNull();
|
||||
const [u] = await t.db.select().from(users).where(eq(users.email, 'stranger@example.com'));
|
||||
expect(u).toBeUndefined();
|
||||
});
|
||||
|
||||
it('a user with a password is never matched by email, even verified', async () => {
|
||||
await t.addUser('ann@example.com', 'ann-password-12');
|
||||
const { callback } = await flow();
|
||||
expect(errorOf(callback!)).toBe('not_connected');
|
||||
});
|
||||
|
||||
it('connects from My account, then signs in either way', async () => {
|
||||
const ann = await t.login('ann@example.com', 'ann-password-12');
|
||||
const { callback } = await flow(ann, '?connect=1');
|
||||
expect(callback!.headers.location).toBe('/me/settings?sso=connected');
|
||||
const me = await t.app.inject({ url: '/api/auth/me', headers: { cookie: ann } });
|
||||
expect(me.json().user.signIn).toEqual({
|
||||
password: true,
|
||||
oidc: { email: 'ann@example.com', groups: [] },
|
||||
ldap: false,
|
||||
});
|
||||
const signIn = await flow(undefined, '?next=/me/calls');
|
||||
expect(signIn.callback!.headers.location).toBe('/me/calls');
|
||||
const cookie = session(signIn.callback!);
|
||||
expect(cookie).toBeTruthy();
|
||||
const [s] = await t.db
|
||||
.select()
|
||||
.from(sessions)
|
||||
.where(eq(sessions.method, 'oidc'))
|
||||
.orderBy(sessions.createdAt);
|
||||
expect(s!.idpRefreshEnc).toBeTruthy();
|
||||
expect(s!.idpIdTokenEnc).toBeTruthy();
|
||||
// …and the password still works.
|
||||
expect(await t.login('ann@example.com', 'ann-password-12')).toBeTruthy();
|
||||
});
|
||||
|
||||
it("refuses to connect an account that's someone else's", async () => {
|
||||
await t.addUser('bob@example.com', 'bob-password-12');
|
||||
const bob = await t.login('bob@example.com', 'bob-password-12');
|
||||
const { callback } = await flow(bob, '?connect=1'); // the fake still signs in as u-1 (Ann)
|
||||
expect(errorOf(callback!)).toBe('taken');
|
||||
});
|
||||
|
||||
it('matches a user without a password once, by verified email', async () => {
|
||||
const [u] = await t.db
|
||||
.insert(users)
|
||||
.values({ email: 'cara@example.com', displayName: 'Cara', passwordHash: null })
|
||||
.returning();
|
||||
idp.st.user = { sub: 'u-cara', email: 'cara@example.com', email_verified: false };
|
||||
expect(errorOf((await flow()).callback!)).toBe('not_connected');
|
||||
idp.st.user.email_verified = true;
|
||||
const { callback } = await flow();
|
||||
expect(session(callback!)).toBeTruthy();
|
||||
const ids = await t.db.select().from(userIdentities).where(eq(userIdentities.userId, u!.id));
|
||||
expect(ids).toMatchObject([{ kind: 'oidc', subject: `${idp.issuer} u-cara` }]);
|
||||
});
|
||||
|
||||
it('never matches by email an admin, or someone with a directory account (08 §7 #2)', async () => {
|
||||
const [boss, dir] = await t.db
|
||||
.insert(users)
|
||||
.values([
|
||||
{ email: 'diradmin@example.com', displayName: 'Boss', passwordHash: null, role: 'admin' },
|
||||
{ email: 'diruser@example.com', displayName: 'Dir', passwordHash: null },
|
||||
])
|
||||
.returning();
|
||||
await t.db
|
||||
.insert(userIdentities)
|
||||
.values({ userId: dir!.id, kind: 'ldap', subject: 'dir-user-id' });
|
||||
for (const u of [boss!, dir!]) {
|
||||
idp.st.user = { sub: `u-${u.email}`, email: u.email, email_verified: true };
|
||||
const { callback } = await flow();
|
||||
expect(errorOf(callback!)).toBe('not_connected');
|
||||
expect(session(callback!)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('connect finishes only in the browser whose session asked (08 §7 #8)', async () => {
|
||||
// Mallory starts Connect, then plants her sp_oidc cookie in Vic's browser and sends him on.
|
||||
await t.addUser('mallory@example.com', 'mallory-password-1');
|
||||
const [m] = await t.db.select().from(users).where(eq(users.email, 'mallory@example.com'));
|
||||
await t.addUser('vic@example.com', 'vic-password-123');
|
||||
const mallory = await t.login('mallory@example.com', 'mallory-password-1');
|
||||
const vic = await t.login('vic@example.com', 'vic-password-123');
|
||||
idp.st.user = { sub: 'u-vic', email: 'vic@example.com', email_verified: true };
|
||||
for (const theirs of [vic, '']) {
|
||||
const start = await t.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/auth/oidc/connect',
|
||||
headers: { ...H, cookie: mallory },
|
||||
payload: {},
|
||||
});
|
||||
const pending = start.cookies.find((c) => c.name === 'sp_oidc')!;
|
||||
const atIdp = await fetch((start.json() as { url: string }).url, { redirect: 'manual' });
|
||||
const back = new URL(atIdp.headers.get('location')!);
|
||||
const callback = await t.app.inject({
|
||||
url: back.pathname + back.search,
|
||||
headers: { cookie: [`sp_oidc=${pending.value}`, theirs].filter(Boolean).join('; ') },
|
||||
});
|
||||
expect(errorOf(callback)).toBe('expired');
|
||||
const ids = await t.db
|
||||
.select()
|
||||
.from(userIdentities)
|
||||
.where(eq(userIdentities.userId, m!.id));
|
||||
expect(ids).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('trustEmail matches an unverified email', async () => {
|
||||
await t.db
|
||||
.insert(users)
|
||||
.values({ email: 'dee@example.com', displayName: 'Dee', passwordHash: null });
|
||||
idp.st.user = { sub: 'u-dee', email: 'dee@example.com' };
|
||||
await setSetting(t.db, 'auth.oidc', { ...oidcSettings, trustEmail: true });
|
||||
try {
|
||||
expect(session((await flow()).callback!)).toBeTruthy();
|
||||
} finally {
|
||||
await setSetting(t.db, 'auth.oidc', oidcSettings);
|
||||
}
|
||||
});
|
||||
|
||||
for (const [name, tamper] of [
|
||||
['a wrong nonce', { nonce: true }],
|
||||
['a wrong issuer', { iss: true }],
|
||||
['a wrong audience', { aud: true }],
|
||||
['an expired ID token', { expired: true }],
|
||||
['a bad signature', { signature: true }],
|
||||
['a userinfo subject that differs', { userinfoSub: true }],
|
||||
] as const)
|
||||
it(`refuses ${name}`, async () => {
|
||||
idp.st.tamper = tamper;
|
||||
const { callback } = await flow();
|
||||
expect(errorOf(callback!)).toBe('provider');
|
||||
expect(session(callback!)).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses a callback without our cookie, or with another state', async () => {
|
||||
const r = await t.app.inject({ url: '/api/auth/oidc/callback?code=x&state=y' });
|
||||
expect(errorOf(r)).toBe('expired');
|
||||
const start = await t.app.inject({ url: '/api/auth/oidc/start' });
|
||||
const pending = start.cookies.find((c) => c.name === 'sp_oidc')!;
|
||||
const atIdp = await fetch(start.headers.location as string, { redirect: 'manual' });
|
||||
const back = new URL(atIdp.headers.get('location')!);
|
||||
back.searchParams.set('state', 'forged');
|
||||
const forged = await t.app.inject({
|
||||
url: back.pathname + back.search,
|
||||
headers: { cookie: `sp_oidc=${pending.value}` },
|
||||
});
|
||||
expect(errorOf(forged)).toBe('provider');
|
||||
});
|
||||
|
||||
it('the provider saying access_denied is "denied"', async () => {
|
||||
const start = await t.app.inject({ url: '/api/auth/oidc/start' });
|
||||
const pending = start.cookies.find((c) => c.name === 'sp_oidc')!;
|
||||
const r = await t.app.inject({
|
||||
url: '/api/auth/oidc/callback?error=access_denied&state=x',
|
||||
headers: { cookie: `sp_oidc=${pending.value}` },
|
||||
});
|
||||
expect(errorOf(r)).toBe('denied');
|
||||
});
|
||||
|
||||
it('groups: allowedGroups refuses, adminGroups sets the role', async () => {
|
||||
idp.st.user = { sub: 'u-1', email: 'ann@example.com', groups: ['pbx-admins'] };
|
||||
await setSetting(t.db, 'auth.oidc', { ...oidcSettings, allowedGroups: ['pbx-users'] });
|
||||
expect(errorOf((await flow()).callback!)).toBe('not_allowed');
|
||||
await setSetting(t.db, 'auth.oidc', { ...oidcSettings, adminGroups: ['pbx-admins'] });
|
||||
try {
|
||||
const { callback } = await flow();
|
||||
const me = await t.app.inject({
|
||||
url: '/api/auth/me',
|
||||
headers: { cookie: session(callback!)! },
|
||||
});
|
||||
expect(me.json().user.role).toBe('admin');
|
||||
const [id] = await t.db
|
||||
.select()
|
||||
.from(userIdentities)
|
||||
.where(eq(userIdentities.subject, `${idp.issuer} u-1`));
|
||||
expect(id!.groups).toEqual(['pbx-admins']);
|
||||
idp.st.user.groups = [];
|
||||
await flow();
|
||||
const [ann] = await t.db.select().from(users).where(eq(users.email, 'ann@example.com'));
|
||||
expect(ann!.role).toBe('user');
|
||||
} finally {
|
||||
await setSetting(t.db, 'auth.oidc', oidcSettings);
|
||||
}
|
||||
});
|
||||
|
||||
it('signing out of an OpenID session sends the browser to the provider', async () => {
|
||||
const { callback } = await flow();
|
||||
const out = await t.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/auth/logout',
|
||||
headers: { ...H, cookie: session(callback!)! },
|
||||
});
|
||||
const to = new URL(out.json().redirect);
|
||||
expect(to.href.startsWith(`${idp.issuer}/logout`)).toBe(true);
|
||||
expect(to.searchParams.get('id_token_hint')).toBeTruthy();
|
||||
expect(to.searchParams.get('post_logout_redirect_uri')).toBe('https://pbx.test/login');
|
||||
// A password session signs out here only.
|
||||
const pw = await t.login('ann@example.com', 'ann-password-12');
|
||||
const out2 = await t.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/auth/logout',
|
||||
headers: { ...H, cookie: pw },
|
||||
});
|
||||
expect(out2.json()).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('OpenID-only mode: passwords for admins only; disconnect keeps a way in', async () => {
|
||||
await setSetting(t.db, 'auth.mode', 'oidc');
|
||||
try {
|
||||
const r = await t.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/auth/login',
|
||||
headers: H,
|
||||
payload: { login: 'ann@example.com', password: 'ann-password-12' },
|
||||
});
|
||||
expect(r.json().error.code).toBe('password_sign_in_off');
|
||||
const { callback } = await flow();
|
||||
const ann = session(callback!)!;
|
||||
const d = await t.app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/me/identities/oidc',
|
||||
headers: { ...H, cookie: ann },
|
||||
});
|
||||
expect(d.statusCode).toBe(409);
|
||||
expect(d.json().error.code).toBe('last_way_in');
|
||||
} finally {
|
||||
await setSetting(t.db, 'auth.mode', 'mixed');
|
||||
}
|
||||
// Mixed: Ann still has her password, so she may disconnect; OpenID sessions end.
|
||||
const { callback } = await flow();
|
||||
const ann = session(callback!)!;
|
||||
const [row] = await t.db.select().from(users).where(eq(users.email, 'ann@example.com'));
|
||||
const d = await t.app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/users/${row!.id}/identities/oidc`,
|
||||
headers: { ...H, cookie: admin },
|
||||
});
|
||||
expect(d.statusCode).toBe(200);
|
||||
const left = await t.db
|
||||
.select()
|
||||
.from(sessions)
|
||||
.where(and(eq(sessions.userId, row!.id), eq(sessions.method, 'oidc')));
|
||||
expect(left).toEqual([]);
|
||||
expect(
|
||||
(await t.app.inject({ url: '/api/auth/me', headers: { cookie: ann } })).statusCode,
|
||||
).toBe(401);
|
||||
});
|
||||
|
||||
it('settings: Test, the secret write-only, and the mode needing a working provider', async () => {
|
||||
const form = {
|
||||
label: 'Keycloak',
|
||||
issuer: idp.issuer,
|
||||
clientId: idp.clientId,
|
||||
scopes: 'openid email profile',
|
||||
groupsClaim: 'groups',
|
||||
adminGroups: [],
|
||||
allowedGroups: [],
|
||||
trustEmail: false,
|
||||
};
|
||||
const req = (method: 'GET' | 'PATCH' | 'POST', url: string, payload?: object) =>
|
||||
t.app.inject({ method, url, payload, headers: { ...H, cookie: admin } });
|
||||
const ok = await req('POST', '/api/settings/auth/oidc/test', form);
|
||||
expect(ok.json()).toMatchObject({
|
||||
ok: true,
|
||||
pkce: true,
|
||||
refreshTokens: true,
|
||||
endSession: true,
|
||||
});
|
||||
const bad = await req('POST', '/api/settings/auth/oidc/test', {
|
||||
...form,
|
||||
clientSecret: 'wrong',
|
||||
});
|
||||
expect(bad.json()).toMatchObject({ ok: false });
|
||||
expect((await req('PATCH', '/api/settings', { 'auth.oidc': form })).statusCode).toBe(200);
|
||||
const got = (await req('GET', '/api/settings')).json()['auth.oidc'];
|
||||
expect(got).toMatchObject({ hasClientSecret: true, label: 'Keycloak' });
|
||||
expect(JSON.stringify(got)).not.toContain('clientSecretEnc');
|
||||
const broken = await req('PATCH', '/api/settings', {
|
||||
'auth.oidc': { ...form, issuer: 'http://127.0.0.1:1' },
|
||||
});
|
||||
expect(broken.statusCode).toBe(400);
|
||||
const remote = await req('PATCH', '/api/settings', {
|
||||
'auth.oidc': { ...form, issuer: 'http://kc.example.com/realms/x' },
|
||||
});
|
||||
expect(remote.json().error.message).toContain('https');
|
||||
});
|
||||
|
||||
/** Ann's OpenID session, connected again (an earlier test disconnects her). */
|
||||
async function annSession() {
|
||||
const pw = await t.login('ann@example.com', 'ann-password-12');
|
||||
await flow(pw, '?connect=1');
|
||||
const { callback } = await flow();
|
||||
const cookie = session(callback!)!;
|
||||
const [row] = await t.db
|
||||
.select()
|
||||
.from(sessions)
|
||||
.where(eq(sessions.method, 'oidc'))
|
||||
.orderBy(desc(sessions.idpCheckedAt))
|
||||
.limit(1);
|
||||
return { cookie, id: row!.id };
|
||||
}
|
||||
const later = (min: number) => new Date(Date.now() + min * 60_000);
|
||||
const check = (min: number) => checkOidcSessions({ db: t.db, log: t.app.log, now: later(min) });
|
||||
const row = async (id: string) =>
|
||||
(await t.db.select().from(sessions).where(eq(sessions.id, id)))[0];
|
||||
|
||||
it('the session check: kept while the provider says yes, ended when it says no (§3.5)', async () => {
|
||||
const { cookie, id } = await annSession();
|
||||
const signedIn = (await row(id))!.idpCheckedAt!.getTime();
|
||||
await check(5); // checked at sign-in: not due yet
|
||||
expect((await row(id))!.idpCheckedAt!.getTime()).toBe(signedIn);
|
||||
await check(16);
|
||||
expect((await row(id))!.idpCheckedAt!.getTime()).toBeGreaterThan(signedIn);
|
||||
|
||||
idp.st.down = true; // an outage signs no one out
|
||||
await check(40);
|
||||
expect(await row(id)).toBeTruthy();
|
||||
idp.st.down = false;
|
||||
|
||||
idp.st.user = { ...idp.st.user, groups: ['pbx-admins'] };
|
||||
await setSetting(t.db, 'auth.oidc', { ...oidcSettings, adminGroups: ['pbx-admins'] });
|
||||
try {
|
||||
await check(56); // groups changed at the provider: the role follows
|
||||
const [ann] = await t.db.select().from(users).where(eq(users.email, 'ann@example.com'));
|
||||
expect(ann!.role).toBe('admin');
|
||||
await t.db.update(users).set({ role: 'user' }).where(eq(users.id, ann!.id));
|
||||
} finally {
|
||||
await setSetting(t.db, 'auth.oidc', oidcSettings);
|
||||
}
|
||||
|
||||
idp.st.refreshOk = false; // disabled at the provider
|
||||
await check(72);
|
||||
expect(await row(id)).toBeUndefined();
|
||||
expect((await t.app.inject({ url: '/api/auth/me', headers: { cookie } })).statusCode).toBe(
|
||||
401,
|
||||
);
|
||||
});
|
||||
|
||||
it('the session check ends a session no longer in the allowed groups', async () => {
|
||||
idp.st.user = { ...idp.st.user, groups: ['pbx-users'] };
|
||||
await setSetting(t.db, 'auth.oidc', { ...oidcSettings, allowedGroups: ['pbx-users'] });
|
||||
try {
|
||||
const { id } = await annSession();
|
||||
idp.st.user.groups = [];
|
||||
await check(20);
|
||||
expect(await row(id)).toBeUndefined();
|
||||
} finally {
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,13 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// M7-T3: a recorded call (sp_recording) is linked to its calls row and playable by admins and the
|
||||
// people on the call; delete and retention remove file and row.
|
||||
// people on the call (not a phone that only rang, 08 §7 #3); delete and retention remove file and row.
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { LightMyRequestResponse } from 'fastify';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import type { AmiMessage } from '../src/ami/events.ts';
|
||||
import { calls, cdr, extensions, recordings } from '../src/db/schema.ts';
|
||||
import { callExtensions, calls, cdr, extensions, recordings, users } from '../src/db/schema.ts';
|
||||
import { encrypt } from '../src/crypto/secrets.ts';
|
||||
import { cleanupRecordings } from '../src/routes/recordings.ts';
|
||||
import { aggregateCall, toCdrRow } from '../src/services/cdr.ts';
|
||||
@@ -56,6 +56,8 @@ describe.skipIf(!hasTestDb)('call recordings', () => {
|
||||
};
|
||||
});
|
||||
afterAll(() => t?.close());
|
||||
const bobId = async () =>
|
||||
(await t.db.select().from(users).where(eq(users.email, 'bob@example.com')))[0]!.id;
|
||||
|
||||
it('the recording is linked to its call, with size and length', async () => {
|
||||
const [c] = await t.db.select().from(calls);
|
||||
@@ -91,6 +93,26 @@ describe.skipIf(!hasTestDb)('call recordings', () => {
|
||||
expect((await as.alice('GET', '/api/recordings')).statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('a phone that only rang (ring group, queue) gets neither audio nor transcript (08 §7 #3)', async () => {
|
||||
const [bob] = await t.db
|
||||
.insert(extensions)
|
||||
.values({
|
||||
number: '102',
|
||||
name: 'Bob',
|
||||
userId: await bobId(),
|
||||
sipPasswordEnc: encrypt('x'),
|
||||
voicemailPinEnc: encrypt('4826'),
|
||||
})
|
||||
.returning();
|
||||
const [c] = await t.db.select().from(calls);
|
||||
await t.db.insert(callExtensions).values({ callId: c!.id, extensionId: bob!.id, role: 'rang' });
|
||||
expect((await as.bob('GET', `/api/recordings/${recId}/audio`)).statusCode).toBe(403);
|
||||
expect((await as.bob('GET', `/api/recordings/${recId}/transcript`)).statusCode).toBe(403);
|
||||
const mine = (await as.bob('GET', '/api/me/calls')).json();
|
||||
expect(mine.items).toEqual([expect.objectContaining({ id: c!.id, recordingId: null })]);
|
||||
expect((await as.alice('GET', `/api/recordings/${recId}/transcript`)).statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('retention deletes recordings past their days; delete removes file and link', async () => {
|
||||
expect(await cleanupRecordings(t.db, dir(), 3650)).toBe(0);
|
||||
expect((await as.admin('DELETE', `/api/recordings/${recId}`)).statusCode).toBe(200);
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// M11-T6: /api/reports/summary and /api/reports/queues (04 §7.3), days in the company time zone.
|
||||
// M11-T6: /api/reports/summary and /api/reports/queues (04 §7.3), days in the company time zone;
|
||||
// /api/me/reports/summary, the calls made to me.
|
||||
import type { LightMyRequestResponse } from 'fastify';
|
||||
import { inArray } from 'drizzle-orm';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { calls, queueCalls, queues } from '../src/db/schema.ts';
|
||||
import { callExtensions, calls, extensions, queueCalls, queues } from '../src/db/schema.ts';
|
||||
import { encrypt } from '../src/crypto/secrets.ts';
|
||||
import { setSetting } from '../src/services/settings.ts';
|
||||
import { hasTestDb } from './db.ts';
|
||||
import { ADMIN, H, createTestApp } from './app.ts';
|
||||
@@ -125,5 +128,74 @@ describe.skipIf(!hasTestDb)('/api/reports', () => {
|
||||
await t.addUser('pat@example.com', 'pat-password-123');
|
||||
const pat = await t.login('pat@example.com', 'pat-password-123');
|
||||
expect((await get('/api/reports/queues', pat)).statusCode).toBe(403);
|
||||
expect((await get('/api/me/reports/summary', pat)).statusCode).toBe(404); // no extension
|
||||
});
|
||||
|
||||
it('mine: only calls made to me; answered and talk time are the ones I picked up', async () => {
|
||||
const sam = await t.addUser('sam@example.com', 'sam-password-123');
|
||||
const [mine, other] = await t.db
|
||||
.insert(extensions)
|
||||
.values(
|
||||
(['103', '104'] as const).map((number) => ({
|
||||
number,
|
||||
name: number,
|
||||
sipPasswordEnc: encrypt('x'),
|
||||
voicemailPinEnc: encrypt('4826'),
|
||||
userId: number === '103' ? sam.id : null,
|
||||
})),
|
||||
)
|
||||
.returning();
|
||||
await t.db.insert(calls).values({
|
||||
linkedid: 'g',
|
||||
direction: 'internal',
|
||||
status: 'answered',
|
||||
startedAt: new Date('2026-05-05T15:00:00Z'), // Tue 10:00
|
||||
endedAt: new Date('2026-05-05T15:00:45Z'),
|
||||
talkSeconds: 40,
|
||||
answeredByExtensionId: mine!.id,
|
||||
});
|
||||
const ids = Object.fromEntries(
|
||||
(
|
||||
await t.db
|
||||
.select({ id: calls.id, linkedid: calls.linkedid })
|
||||
.from(calls)
|
||||
.where(inArray(calls.linkedid, ['a', 'b', 'c', 'g']))
|
||||
).map((c) => [c.linkedid, c.id]),
|
||||
);
|
||||
// a: rang me and a coworker, the coworker answered. b: rang me, missed. c: I made it. g: mine.
|
||||
await t.db
|
||||
.update(calls)
|
||||
.set({ answeredByExtensionId: other!.id })
|
||||
.where(inArray(calls.id, [ids.a!]));
|
||||
await t.db.insert(callExtensions).values([
|
||||
{ callId: ids.a!, extensionId: mine!.id, role: 'rang' },
|
||||
{ callId: ids.a!, extensionId: other!.id, role: 'answered' },
|
||||
{ callId: ids.b!, extensionId: mine!.id, role: 'rang' },
|
||||
{ callId: ids.c!, extensionId: mine!.id, role: 'caller' },
|
||||
{ callId: ids.g!, extensionId: mine!.id, role: 'rang' },
|
||||
{ callId: ids.g!, extensionId: mine!.id, role: 'answered' },
|
||||
]);
|
||||
const cookie = await t.login('sam@example.com', 'sam-password-123');
|
||||
const r = await get('/api/me/reports/summary?from=2026-05-04&to=2026-05-06', cookie);
|
||||
expect(r.statusCode, r.body).toBe(200);
|
||||
const s = r.json();
|
||||
expect(s.days).toEqual([
|
||||
{ date: '2026-05-04', inbound: 2, outbound: 0, internal: 0, missed: 1, outsideMinutes: 2 },
|
||||
{ date: '2026-05-05', inbound: 0, outbound: 0, internal: 1, missed: 0, outsideMinutes: 0 },
|
||||
{ date: '2026-05-06', inbound: 0, outbound: 0, internal: 0, missed: 0, outsideMinutes: 0 },
|
||||
]);
|
||||
expect(s.totals).toEqual({
|
||||
calls: 3,
|
||||
answered: 1,
|
||||
missed: 1,
|
||||
avgTalkSeconds: 40,
|
||||
talkSeconds: 40,
|
||||
outsideSeconds: { inbound: 95, outbound: 0 },
|
||||
});
|
||||
expect(s.hours.flat().reduce((a: number, b: number) => a + b)).toBe(3);
|
||||
// The company summary is unchanged by who rang.
|
||||
expect(
|
||||
(await get('/api/reports/summary?from=2026-05-04&to=2026-05-04')).json().totals.answered,
|
||||
).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,7 @@ const EXPECTED: Record<string, Record<string, string>> = {
|
||||
id: 'uuid|NO|gen_random_uuid()',
|
||||
email: 'USER-DEFINED:citext|NO|',
|
||||
display_name: 'text|NO|',
|
||||
password_hash: 'text|NO|',
|
||||
password_hash: 'text|YES|',
|
||||
role: "USER-DEFINED:user_role|NO|'user'::user_role",
|
||||
is_active: 'boolean|NO|true',
|
||||
totp_secret_enc: 'text|YES|',
|
||||
@@ -94,6 +94,8 @@ const EXPECTED: Record<string, Record<string, string>> = {
|
||||
answered_by_extension_id: 'uuid|YES|',
|
||||
talk_seconds: 'integer|NO|0',
|
||||
recording_id: 'uuid|YES|',
|
||||
spam_risk: 'text|YES|',
|
||||
spam_info: 'jsonb|YES|',
|
||||
...ts,
|
||||
},
|
||||
audit_log: {
|
||||
@@ -115,11 +117,11 @@ describe.skipIf(!hasTestDb)('schema matches spec 02', () => {
|
||||
beforeAll(async () => ({ db, drop } = await createTestDb()));
|
||||
afterAll(() => drop?.());
|
||||
|
||||
it('creates all 43 tables', async () => {
|
||||
it('creates all 48 tables', async () => {
|
||||
const r = await db.execute(
|
||||
sql`select count(*)::int as n from information_schema.tables where table_schema = 'public'`,
|
||||
);
|
||||
expect(r.rows[0]).toEqual({ n: 43 });
|
||||
expect(r.rows[0]).toEqual({ n: 48 });
|
||||
});
|
||||
|
||||
it.each(Object.keys(EXPECTED))('%s columns', async (table) => {
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// 12 S1: ways to sign in, the sign-in mode, and the admin with a password kept as the way in.
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { sessions, userIdentities, users } from '../src/db/schema.ts';
|
||||
import { resetLoginLimiter } from '../src/auth/login-limiter.ts';
|
||||
import { setSetting } from '../src/services/settings.ts';
|
||||
import { hasTestDb } from './db.ts';
|
||||
import { ADMIN, H, createTestApp } from './app.ts';
|
||||
|
||||
describe.skipIf(!hasTestDb)('sign-in (12 S1)', () => {
|
||||
let t: Awaited<ReturnType<typeof createTestApp>>;
|
||||
let admin: string;
|
||||
beforeAll(async () => {
|
||||
t = await createTestApp();
|
||||
await t.clearMustChange(ADMIN.email);
|
||||
admin = await t.login(ADMIN.email, ADMIN.password);
|
||||
});
|
||||
afterAll(() => t?.close());
|
||||
beforeEach(() => resetLoginLimiter());
|
||||
|
||||
const post = (url: string, payload: object, cookie?: string) =>
|
||||
t.app.inject({ method: 'POST', url, payload, headers: { ...H, ...(cookie && { cookie }) } });
|
||||
|
||||
it('GET /api/auth/methods is public and offers passwords only', async () => {
|
||||
const r = await t.app.inject({ url: '/api/auth/methods' });
|
||||
expect(r.statusCode).toBe(200);
|
||||
expect(r.json()).toEqual({ mode: 'password', ldap: false, oidc: null });
|
||||
});
|
||||
|
||||
it('signs in with `login`, and still with `email`; the session says how', async () => {
|
||||
const r = await post('/api/auth/login', { login: ADMIN.email, password: ADMIN.password });
|
||||
expect(r.statusCode).toBe(200);
|
||||
expect(r.json().user.signIn).toEqual({ password: true, oidc: null, ldap: false });
|
||||
const rows = await t.db.select({ method: sessions.method }).from(sessions);
|
||||
expect(rows.every((s) => s.method === 'password')).toBe(true);
|
||||
});
|
||||
|
||||
it('a user without a password fails like a wrong password, and has none to change', async () => {
|
||||
const u = await t.addUser('nopw@example.com', 'temporary-pass-1');
|
||||
const cookie = await t.login('nopw@example.com', 'temporary-pass-1');
|
||||
await t.db.update(users).set({ passwordHash: null }).where(eq(users.id, u.id));
|
||||
const r = await post('/api/auth/login', { login: 'nopw@example.com', password: 'anything' });
|
||||
expect(r.statusCode).toBe(401);
|
||||
expect(r.json().error.message).toBe('Wrong email or password');
|
||||
const c = await post(
|
||||
'/api/auth/change-password',
|
||||
{ currentPassword: 'x', newPassword: 'a-new-password-1' },
|
||||
cookie,
|
||||
);
|
||||
expect(c.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('users show their connected accounts', async () => {
|
||||
const u = await t.addUser('linked@example.com', 'linked-password-1');
|
||||
await t.db
|
||||
.insert(userIdentities)
|
||||
.values({ userId: u.id, kind: 'oidc', subject: 'https://kc/realms/x abc', email: 'l@kc' });
|
||||
const r = await t.app.inject({ url: `/api/users/${u.id}`, headers: { cookie: admin } });
|
||||
expect(r.json().signIn).toEqual({
|
||||
password: true,
|
||||
oidc: { email: 'l@kc', groups: [] },
|
||||
ldap: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('auth.mode can only be password until OpenID Connect is set up', async () => {
|
||||
const r = t.app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/settings',
|
||||
payload: { 'auth.mode': 'mixed' },
|
||||
headers: { ...H, cookie: admin },
|
||||
});
|
||||
expect((await r).statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('OpenID-only mode refuses passwords, except for admins', async () => {
|
||||
await t.addUser('plain@example.com', 'plain-password-1');
|
||||
await setSetting(t.db, 'auth.mode', 'oidc');
|
||||
try {
|
||||
const r = await post('/api/auth/login', {
|
||||
login: 'plain@example.com',
|
||||
password: 'plain-password-1',
|
||||
});
|
||||
expect(r.statusCode).toBe(403);
|
||||
expect(r.json().error.code).toBe('password_sign_in_off');
|
||||
const a = await post('/api/auth/login', { login: ADMIN.email, password: ADMIN.password });
|
||||
expect(a.statusCode).toBe(200);
|
||||
} finally {
|
||||
await setSetting(t.db, 'auth.mode', 'password');
|
||||
}
|
||||
});
|
||||
|
||||
it('the last active admin with a password stays, even with other admins', async () => {
|
||||
const [me] = await t.db.select().from(users).where(eq(users.email, ADMIN.email));
|
||||
const other = await t.addUser('sso-admin@example.com', 'sso-admin-pass-1', 'admin');
|
||||
await t.db.update(users).set({ passwordHash: null }).where(eq(users.id, other.id));
|
||||
const demote = await t.app.inject({
|
||||
method: 'PATCH',
|
||||
url: `/api/users/${me!.id}`,
|
||||
payload: { role: 'user' },
|
||||
headers: { ...H, cookie: admin },
|
||||
});
|
||||
expect(demote.statusCode).toBe(409);
|
||||
expect(demote.json().error.message).toBe('This is the last active admin with a password');
|
||||
// An admin without a password can go: the one with a password is still there.
|
||||
const del = await t.app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/users/${other.id}`,
|
||||
headers: { ...H, cookie: admin },
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,506 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// SD1, SD2, SD4, SD5: the spam check (14): IPQualityScore first, then the local FTC copy, against a fake fetch.
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { eq, like, sql } from 'drizzle-orm';
|
||||
import type { LightMyRequestResponse } from 'fastify';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { SPAM_DEFAULTS, type SpamSettings } from '@selfphoneng/shared';
|
||||
import { encrypt } from '../src/crypto/secrets.ts';
|
||||
import {
|
||||
auditLog,
|
||||
cdr,
|
||||
ftcComplaints,
|
||||
settings as settingsTable,
|
||||
spamLookups,
|
||||
systemEvents,
|
||||
users,
|
||||
} from '../src/db/schema.ts';
|
||||
import type { ActiveCall } from '@selfphoneng/shared';
|
||||
import type { AmiMessage } from '../src/ami/events.ts';
|
||||
import { callerNameToken } from '../src/contacts/callername.ts';
|
||||
import { aggregateCall, toCdrRow } from '../src/services/cdr.ts';
|
||||
import { checkNumber, spamQuotaCheck } from '../src/spam/check.ts';
|
||||
import { getSetting, setSetting } from '../src/services/settings.ts';
|
||||
import { hasTestDb } from './db.ts';
|
||||
import { ADMIN, H, createTestApp } from './app.ts';
|
||||
|
||||
const IPQS_KEY = 'ipqs-secret-key-123';
|
||||
const FTC_KEY = 'datagov-secret-key-456';
|
||||
const SPAMMER = '13125550001';
|
||||
const SHADY = '13125550002';
|
||||
const CLEAN = '13125550003';
|
||||
const REPORTED = '13125550004';
|
||||
|
||||
/** IPQualityScore's phone answers by number; anything else is a valid, clean line. */
|
||||
const ANSWERS: Record<string, Record<string, unknown>> = {
|
||||
[SPAMMER]: { fraud_score: 40, spammer: true, line_type: 'VOIP', carrier: 'Acme Tel' },
|
||||
[SHADY]: { fraud_score: 78, city: 'Austin', region: 'TX', name: 'N/A' },
|
||||
[CLEAN]: { fraud_score: 10, line_type: 'Wireless', carrier: 'Verizon' },
|
||||
};
|
||||
|
||||
describe.skipIf(!hasTestDb)('spam check', () => {
|
||||
let t: Awaited<ReturnType<typeof createTestApp>>;
|
||||
type Req = (method: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
|
||||
let admin: Req;
|
||||
const urls: string[] = [];
|
||||
/** What the fake IPQualityScore does: answer, fail, or say the credits are used up. */
|
||||
let ipqs: 'ok' | 'down' | 'exhausted' | 'slow' | 'late' = 'ok';
|
||||
let credits = 912;
|
||||
const log = { warn: vi.fn() };
|
||||
const d = () => ({ db: t.db, log });
|
||||
|
||||
const settings = (s: Partial<SpamSettings>) =>
|
||||
setSetting(t.db, 'spam', { ...SPAM_DEFAULTS, enabled: true, ...s });
|
||||
// Getters: the secret key is only set once createTestApp has run.
|
||||
const keys = {
|
||||
get withIpqs() {
|
||||
return { ipqs: { keyEnc: encrypt(IPQS_KEY) } };
|
||||
},
|
||||
get withFtc() {
|
||||
return { ftc: { keyEnc: encrypt(FTC_KEY), syncHours: 24, windowDays: 30 } };
|
||||
},
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestApp();
|
||||
await t.clearMustChange(ADMIN.email);
|
||||
const cookie = await t.login(ADMIN.email, ADMIN.password);
|
||||
admin = (method, url, payload) =>
|
||||
t.app.inject({
|
||||
method: method as 'GET',
|
||||
url,
|
||||
payload: payload as object,
|
||||
headers: { ...H, cookie },
|
||||
});
|
||||
const day = (n: number) => sql`now() - make_interval(days => ${n})`;
|
||||
await t.db.insert(ftcComplaints).values([
|
||||
{ id: 'a1', number: REPORTED, reportedAt: day(1), subject: 'Warranties', robocall: true },
|
||||
{ id: 'a2', number: REPORTED, reportedAt: day(2), subject: 'Warranties', robocall: true },
|
||||
{ id: 'a3', number: REPORTED, reportedAt: day(3), subject: 'Debt', robocall: false },
|
||||
{ id: 'a4', number: REPORTED, reportedAt: day(40), subject: 'Debt', robocall: true },
|
||||
{ id: 'b1', number: SHADY, reportedAt: day(5), subject: null, robocall: null },
|
||||
]);
|
||||
});
|
||||
afterAll(() => t?.close());
|
||||
|
||||
beforeEach(() => {
|
||||
urls.length = 0;
|
||||
log.warn.mockClear();
|
||||
ipqs = 'ok';
|
||||
credits = 912;
|
||||
vi.stubGlobal('fetch', async (url: string, init?: RequestInit) => {
|
||||
urls.push(url);
|
||||
const json = (j: unknown, status = 200) => new Response(JSON.stringify(j), { status });
|
||||
if (url.startsWith('https://api.ftc.gov/')) {
|
||||
const key = (init?.headers as Record<string, string>)['X-Api-Key'];
|
||||
return key === FTC_KEY ? json({ data: [] }) : json({ error: {} }, 403);
|
||||
}
|
||||
const account = /\/api\/json\/account\/([^/]+)$/.exec(url);
|
||||
if (account)
|
||||
return account[1] === IPQS_KEY
|
||||
? json({ success: true, credits, usage: 88 })
|
||||
: json({ success: false, message: `Invalid or unauthorized key: ${account[1]}` });
|
||||
const phone = /\/api\/json\/phone\/[^/]+\/(\d+)\?country\[\]=US$/.exec(url);
|
||||
if (!phone) return json({}, 404);
|
||||
if (ipqs === 'down') return json({}, 503);
|
||||
if (ipqs === 'slow') throw Object.assign(new Error('aborted'), { name: 'TimeoutError' });
|
||||
if (ipqs === 'exhausted')
|
||||
return json({
|
||||
success: false,
|
||||
message: 'You have insufficient credits to make this query.',
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, ipqs === 'late' ? 1500 : 20));
|
||||
return json({ success: true, valid: true, ...(ANSWERS[phone[1]!] ?? { fraud_score: 0 }) });
|
||||
});
|
||||
});
|
||||
afterEach(async () => {
|
||||
vi.unstubAllGlobals();
|
||||
await t.db.delete(spamLookups);
|
||||
await t.db.delete(settingsTable).where(eq(settingsTable.key, 'spam.state'));
|
||||
await t.db.delete(systemEvents).where(like(systemEvents.code, 'spam.%'));
|
||||
});
|
||||
|
||||
it('checks nothing while off, and only US numbers', async () => {
|
||||
await setSetting(t.db, 'spam', { ...SPAM_DEFAULTS, ...keys.withIpqs });
|
||||
expect(await checkNumber(d(), SPAMMER)).toBeNull();
|
||||
await settings(keys.withIpqs);
|
||||
for (const n of ['442079460958', '911', '101', '13125550', '10005550001'])
|
||||
expect(await checkNumber(d(), n)).toBeNull();
|
||||
expect(urls).toEqual([]);
|
||||
});
|
||||
|
||||
it('IPQualityScore: risk from its flags and score, only the kept fields', async () => {
|
||||
await settings(keys.withIpqs);
|
||||
expect(await checkNumber(d(), SPAMMER)).toEqual({
|
||||
risk: 'high',
|
||||
provider: 'ipqs',
|
||||
info: {
|
||||
fraudScore: 40,
|
||||
spammer: true,
|
||||
recentAbuse: false,
|
||||
lineType: 'VOIP',
|
||||
carrier: 'Acme Tel',
|
||||
name: null,
|
||||
city: null,
|
||||
region: null,
|
||||
},
|
||||
});
|
||||
expect((await checkNumber(d(), SHADY))?.risk).toBe('medium');
|
||||
expect(await checkNumber(d(), CLEAN)).toMatchObject({
|
||||
risk: 'low',
|
||||
info: { carrier: 'Verizon' },
|
||||
});
|
||||
expect(urls).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('caches answers: flagged for flaggedDays, the rest for cleanDays', async () => {
|
||||
await settings({ ...keys.withIpqs, cache: { flaggedDays: 14, cleanDays: 3 } });
|
||||
await checkNumber(d(), SPAMMER);
|
||||
await checkNumber(d(), CLEAN);
|
||||
await checkNumber(d(), SPAMMER);
|
||||
await checkNumber(d(), CLEAN);
|
||||
expect(urls).toHaveLength(2);
|
||||
const days = await t.db.execute<{ number: string; days: number }>(
|
||||
sql`select number, round(extract(epoch from expires_at - now()) / 86400)::int as days from spam_lookups order by number`,
|
||||
);
|
||||
expect(days.rows).toEqual([
|
||||
{ number: SPAMMER, days: 14 },
|
||||
{ number: CLEAN, days: 3 },
|
||||
]);
|
||||
// An expired answer is asked again.
|
||||
await t.db.update(spamLookups).set({ expiresAt: sql`now() - interval '1 second'` });
|
||||
await checkNumber(d(), CLEAN);
|
||||
expect(urls).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('a check under way is shared: five at once cost one request', async () => {
|
||||
await settings(keys.withIpqs);
|
||||
const all = await Promise.all([1, 2, 3, 4, 5].map(() => checkNumber(d(), SPAMMER)));
|
||||
expect(new Set(all.map((a) => a?.risk))).toEqual(new Set(['high']));
|
||||
expect(urls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('IPQualityScore failing: one warning without the key, nothing cached, the FTC answers', async () => {
|
||||
await settings({ ...keys.withIpqs, ...keys.withFtc });
|
||||
for (const mode of ['down', 'slow'] as const) {
|
||||
ipqs = mode;
|
||||
expect(await checkNumber(d(), REPORTED)).toEqual({
|
||||
risk: 'high',
|
||||
provider: 'ftc',
|
||||
info: {
|
||||
complaints: 3,
|
||||
robocalls: 2,
|
||||
lastReportedAt: expect.any(String),
|
||||
subject: 'Warranties',
|
||||
},
|
||||
});
|
||||
}
|
||||
expect(log.warn.mock.calls.map((c) => c[0])).toEqual([
|
||||
{ provider: 'ipqs', reason: 'HTTP 503' },
|
||||
{ provider: 'ipqs', reason: 'no answer in 4 s' },
|
||||
]);
|
||||
expect(JSON.stringify(log.warn.mock.calls)).not.toContain(IPQS_KEY);
|
||||
expect(await t.db.select().from(spamLookups)).toEqual([]);
|
||||
});
|
||||
|
||||
it('without IPQualityScore the FTC list answers; without either, nothing', async () => {
|
||||
ipqs = 'down';
|
||||
await settings(keys.withFtc);
|
||||
expect((await checkNumber(d(), SHADY))?.info).toEqual({
|
||||
complaints: 1,
|
||||
robocalls: 0,
|
||||
lastReportedAt: expect.any(String),
|
||||
subject: null,
|
||||
});
|
||||
expect((await checkNumber(d(), SHADY))?.risk).toBe('medium');
|
||||
expect(await checkNumber(d(), CLEAN)).toMatchObject({ risk: 'low', provider: 'ftc' });
|
||||
expect(urls).toEqual([]);
|
||||
await settings({});
|
||||
expect(await checkNumber(d(), REPORTED)).toBeNull();
|
||||
});
|
||||
|
||||
it('out of credits: paused once, the FTC answers, the warning, Dismiss, and back', async () => {
|
||||
await settings({ ...keys.withIpqs, ...keys.withFtc });
|
||||
const warning = async () =>
|
||||
(await admin('GET', '/api/system/warnings'))
|
||||
.json()
|
||||
.items.find((w: { code: string }) => w.code === 'spam_quota');
|
||||
const events = async () =>
|
||||
(
|
||||
await t.db
|
||||
.select({ code: systemEvents.code })
|
||||
.from(systemEvents)
|
||||
.where(like(systemEvents.code, 'spam.%'))
|
||||
.orderBy(systemEvents.createdAt)
|
||||
).map((e) => e.code);
|
||||
ipqs = 'exhausted';
|
||||
const both = await Promise.all([checkNumber(d(), REPORTED), checkNumber(d(), SHADY)]);
|
||||
expect(both.map((a) => a?.provider)).toEqual(['ftc', 'ftc']);
|
||||
expect((await getSetting(t.db, 'spam.state')).ipqsPausedAt).not.toBeNull();
|
||||
expect(log.warn).toHaveBeenCalledTimes(1);
|
||||
expect(await events()).toEqual(['spam.quotaExhausted']);
|
||||
expect(await warning()).toEqual({
|
||||
code: 'spam_quota',
|
||||
message:
|
||||
"IPQualityScore's lookups are used up. Spam checks use the FTC list until they come back.",
|
||||
link: '/admin/settings/spam',
|
||||
dismissable: true,
|
||||
});
|
||||
|
||||
// Paused: IPQualityScore isn't asked and nothing is logged.
|
||||
urls.length = 0;
|
||||
expect((await checkNumber(d(), CLEAN))?.provider).toBe('ftc');
|
||||
expect(urls).toEqual([]);
|
||||
expect(log.warn).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect((await admin('POST', '/api/system/warnings/spam_quota/dismiss')).statusCode).toBe(200);
|
||||
expect(await warning()).toBeUndefined();
|
||||
|
||||
credits = 0;
|
||||
await spamQuotaCheck(t.db);
|
||||
expect((await getSetting(t.db, 'spam.state')).ipqsPausedAt).not.toBeNull();
|
||||
credits = 5;
|
||||
await spamQuotaCheck(t.db);
|
||||
expect((await getSetting(t.db, 'spam.state')).ipqsPausedAt).toBeNull();
|
||||
expect(await events()).toEqual(['spam.quotaExhausted', 'spam.quotaRestored']);
|
||||
ipqs = 'ok';
|
||||
expect((await checkNumber(d(), SPAMMER))?.provider).toBe('ipqs');
|
||||
|
||||
// Out again: the warning comes back though it was dismissed; without the FTC list, "off".
|
||||
await settings(keys.withIpqs);
|
||||
ipqs = 'exhausted';
|
||||
expect(await checkNumber(d(), SHADY)).toBeNull();
|
||||
expect((await warning())?.message).toBe(
|
||||
"IPQualityScore's lookups are used up. Spam checks are off until they come back.",
|
||||
);
|
||||
|
||||
// A new key doesn't wait for the job.
|
||||
const r = await admin('PATCH', '/api/settings', {
|
||||
spam: {
|
||||
...SPAM_DEFAULTS,
|
||||
enabled: true,
|
||||
ipqs: { key: IPQS_KEY },
|
||||
},
|
||||
});
|
||||
expect(r.statusCode).toBe(200);
|
||||
expect((await getSetting(t.db, 'spam.state')).ipqsPausedAt).toBeNull();
|
||||
expect(await warning()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('spamQuotaCheck asks nothing unless paused, and a failure throws without the key', async () => {
|
||||
await settings(keys.withIpqs);
|
||||
await spamQuotaCheck(t.db);
|
||||
expect(urls).toEqual([]);
|
||||
await setSetting(t.db, 'spam.state', {
|
||||
...(await getSetting(t.db, 'spam.state')),
|
||||
ipqsPausedAt: new Date().toISOString(),
|
||||
});
|
||||
await settings({ ipqs: { keyEnc: encrypt('wrong-key') } });
|
||||
const err = await spamQuotaCheck(t.db).catch((e: unknown) => e as Error);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err!.message).toBe('Invalid or unauthorized key: ***');
|
||||
expect((await getSetting(t.db, 'spam.state')).ipqsPausedAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it('cachedOnly never asks IPQualityScore', async () => {
|
||||
await settings({ ...keys.withIpqs, ...keys.withFtc });
|
||||
expect((await checkNumber(d(), REPORTED, { cachedOnly: true }))?.provider).toBe('ftc');
|
||||
await checkNumber(d(), SPAMMER);
|
||||
expect((await checkNumber(d(), SPAMMER, { cachedOnly: true }))?.provider).toBe('ipqs');
|
||||
expect(urls).toHaveLength(1);
|
||||
});
|
||||
|
||||
describe('while it rings', () => {
|
||||
let ringing: ActiveCall[] = [];
|
||||
const call = (from: string, ext: string): ActiveCall => ({
|
||||
linkedid: `l-${from}`,
|
||||
startedAt: new Date().toISOString(),
|
||||
direction: 'inbound',
|
||||
from: { number: from },
|
||||
to: { number: '13125559999' },
|
||||
state: 'ringing',
|
||||
extensions: [ext],
|
||||
channels: [],
|
||||
recording: false,
|
||||
});
|
||||
const check = async (number: string) =>
|
||||
(await admin('GET', `/api/me/caller-check?number=${number}`)).json().check;
|
||||
const deskName = async (number: string, ext = '101') =>
|
||||
(
|
||||
await t.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/internal/caller-name?ext=${ext}&number=${number}&token=${callerNameToken(t.app.config.APP_SECRET)}`,
|
||||
})
|
||||
).body;
|
||||
|
||||
beforeAll(async () => {
|
||||
const [me] = await t.db.select().from(users).where(eq(users.email, ADMIN.email));
|
||||
expect(
|
||||
(await admin('POST', '/api/extensions', { number: '101', name: 'Admin', userId: me!.id }))
|
||||
.statusCode,
|
||||
).toBe(201);
|
||||
await admin('POST', '/api/me/contacts', {
|
||||
name: 'Pizza Place',
|
||||
numbers: [{ number: SHADY.slice(1) }],
|
||||
});
|
||||
t.app.live.calls.list = () => ringing;
|
||||
});
|
||||
beforeEach(() => settings({ ...keys.withIpqs, ...keys.withFtc }));
|
||||
|
||||
it('the softphone: only callers ringing me now, never my contacts', async () => {
|
||||
ringing = [call(SPAMMER.slice(1), '101'), call(CLEAN, '102'), call(SHADY, '101')];
|
||||
expect(await check(SPAMMER)).toMatchObject({ risk: 'high', provider: 'ipqs' });
|
||||
expect(await check(`+1 ${SPAMMER.slice(1)}`)).toMatchObject({ risk: 'high' });
|
||||
expect(await check(CLEAN)).toBeNull(); // ringing someone else
|
||||
expect(await check(REPORTED)).toBeNull(); // not ringing
|
||||
expect(await check(SHADY)).toBeNull(); // a contact
|
||||
expect(urls).toHaveLength(1);
|
||||
ringing = [call(CLEAN, '101')];
|
||||
expect(await check(CLEAN)).toMatchObject({ risk: 'low' }); // low: the softphone hides it
|
||||
expect((await admin('GET', '/api/me/caller-check?number=nonsense')).json().check).toBeNull();
|
||||
});
|
||||
|
||||
it('desk phones: the label in time, nothing for a contact, low risk or a late answer', async () => {
|
||||
expect(await deskName(SPAMMER)).toBe('SPAM LIKELY');
|
||||
expect(await deskName(SHADY)).toBe('Pizza Place');
|
||||
expect(await deskName(SHADY, '999')).toBe('Spam risk'); // nobody's contact there
|
||||
expect(await deskName(CLEAN)).toBe('');
|
||||
await t.db.delete(spamLookups);
|
||||
ipqs = 'late';
|
||||
expect(await deskName(SPAMMER)).toBe('');
|
||||
await vi.waitFor(async () => expect(await t.db.select().from(spamLookups)).toHaveLength(1));
|
||||
expect(await deskName(SPAMMER)).toBe('SPAM LIKELY'); // from the cache
|
||||
await settings({ ...keys.withFtc, deskPhoneLabels: { high: 'JUNK', medium: 'Maybe junk' } });
|
||||
expect(await deskName(REPORTED)).toBe('JUNK');
|
||||
});
|
||||
|
||||
it('call history keeps what the check knew, after the cache expires; contacts first', async () => {
|
||||
const fixture = readFileSync(
|
||||
new URL('./fixtures/cdr/inbound-user.json', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const callFrom = async (number: string) => {
|
||||
const events = (JSON.parse(fixture.replaceAll('13125559999', number)) as AmiMessage[]).map(
|
||||
(e) => ({ ...e, LinkedID: `${number}-${String(e.LinkedID)}` }),
|
||||
);
|
||||
for (const e of events) await t.db.insert(cdr).values(toCdrRow(e));
|
||||
await aggregateCall(t.db, String(events[0]!.LinkedID));
|
||||
};
|
||||
await checkNumber(d(), SPAMMER);
|
||||
urls.length = 0;
|
||||
for (const n of [SPAMMER, REPORTED, SHADY, CLEAN]) await callFrom(n);
|
||||
expect(urls).toEqual([]); // cachedOnly: CLEAN isn't asked
|
||||
await t.db.delete(spamLookups);
|
||||
const spamOf = async (n: string) =>
|
||||
(await admin('GET', '/api/calls?direction=inbound'))
|
||||
.json()
|
||||
.items.find((c: { fromNumber: string }) => c.fromNumber === n)?.spam;
|
||||
expect(await spamOf(SPAMMER)).toMatchObject({
|
||||
risk: 'high',
|
||||
provider: 'ipqs',
|
||||
info: { spammer: true },
|
||||
});
|
||||
expect(await spamOf(REPORTED)).toMatchObject({
|
||||
risk: 'high',
|
||||
provider: 'ftc',
|
||||
info: { complaints: 3 },
|
||||
});
|
||||
expect(await spamOf(SHADY)).toBeNull(); // the admin's contact
|
||||
// Not in the cache, never reported: low (the page shows nothing for it).
|
||||
expect(await spamOf(CLEAN)).toMatchObject({ risk: 'low', provider: 'ftc' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Settings → Spam check', () => {
|
||||
const body = (s: Record<string, unknown> = {}) => ({
|
||||
spam: {
|
||||
enabled: true,
|
||||
ipqs: { key: IPQS_KEY },
|
||||
ftc: null,
|
||||
cache: { flaggedDays: 14, cleanDays: 3 },
|
||||
deskPhoneLabels: { high: 'SPAM LIKELY', medium: 'Spam risk' },
|
||||
...s,
|
||||
},
|
||||
});
|
||||
|
||||
it('Download now: due at the next minute; refused without the FTC list', async () => {
|
||||
await settings(keys.withIpqs);
|
||||
const r = await admin('POST', '/api/settings/spam/ftc/sync');
|
||||
expect(r.statusCode).toBe(400);
|
||||
expect(r.body).toMatch('Set up the FTC list first');
|
||||
await settings(keys.withFtc);
|
||||
await setSetting(t.db, 'spam.state', {
|
||||
...(await getSetting(t.db, 'spam.state')),
|
||||
ftcLastSyncAt: new Date().toISOString(),
|
||||
});
|
||||
expect((await admin('POST', '/api/settings/spam/ftc/sync')).statusCode).toBe(200);
|
||||
expect((await getSetting(t.db, 'spam.state')).ftcLastSyncAt).toBeNull();
|
||||
});
|
||||
|
||||
it('keys are write-only, and empty keeps the saved one', async () => {
|
||||
await setSetting(t.db, 'spam', SPAM_DEFAULTS);
|
||||
const r = await admin('PATCH', '/api/settings', body());
|
||||
expect(r.statusCode).toBe(200);
|
||||
expect(r.json().spam).toEqual({
|
||||
...SPAM_DEFAULTS,
|
||||
enabled: true,
|
||||
ipqs: { hasKey: true },
|
||||
});
|
||||
expect(r.body).not.toContain(IPQS_KEY);
|
||||
const saved = (await getSetting(t.db, 'spam')).ipqs!.keyEnc;
|
||||
expect((await admin('PATCH', '/api/settings', body({ ipqs: {} }))).statusCode).toBe(200);
|
||||
expect((await getSetting(t.db, 'spam')).ipqs!.keyEnc).toBe(saved);
|
||||
const [a] = await t.db
|
||||
.select()
|
||||
.from(auditLog)
|
||||
.where(eq(auditLog.summary, 'Changed settings: spam'));
|
||||
expect(JSON.stringify(a!.diff)).not.toContain(saved);
|
||||
});
|
||||
|
||||
it('refuses a provider without a key, nothing to check with, and odd labels', async () => {
|
||||
await setSetting(t.db, 'spam', SPAM_DEFAULTS);
|
||||
const no = async (b: unknown, error: RegExp) => {
|
||||
const r = await admin('PATCH', '/api/settings', b);
|
||||
expect(r.statusCode).toBe(400);
|
||||
expect(r.body).toMatch(error);
|
||||
};
|
||||
await no(body({ ipqs: {} }), /Enter the IPQualityScore key/);
|
||||
await no(
|
||||
body({ ipqs: null, ftc: { syncHours: 24, windowDays: 30 } }),
|
||||
/Enter the api.data.gov key/,
|
||||
);
|
||||
await no(body({ ipqs: null }), /Set up IPQualityScore or the FTC list/);
|
||||
await no(body({ deskPhoneLabels: { high: 'Spam?', medium: 'Spam risk' } }), /Letters/);
|
||||
await no(
|
||||
body({ ftc: { key: FTC_KEY, syncHours: 0, windowDays: 30 } }),
|
||||
/syncHours|too small|>=1/i,
|
||||
);
|
||||
await no({ 'spam.state': { ipqsPausedAt: null } }, /can't be changed/);
|
||||
// Off with no provider is fine.
|
||||
expect(
|
||||
(await admin('PATCH', '/api/settings', body({ enabled: false, ipqs: null }))).statusCode,
|
||||
).toBe(200);
|
||||
});
|
||||
|
||||
it('Test uses the typed key, else the saved one, and never shows it', async () => {
|
||||
await setSetting(t.db, 'spam', SPAM_DEFAULTS);
|
||||
const test = async (provider: string, key?: string) =>
|
||||
(await admin('POST', `/api/settings/spam/${provider}/test`, { key })).json();
|
||||
expect(await test('ipqs')).toEqual({ ok: false, error: 'Enter the key first' });
|
||||
expect(await test('ipqs', IPQS_KEY)).toEqual({
|
||||
ok: true,
|
||||
detail: 'Works: 912 credits left this billing period.',
|
||||
});
|
||||
const bad = await test('ipqs', 'wrong-key-789');
|
||||
expect(bad.ok).toBe(false);
|
||||
expect(JSON.stringify(bad)).not.toContain('wrong-key-789');
|
||||
await settings({ ...keys.withIpqs, ...keys.withFtc });
|
||||
expect((await test('ipqs')).ok).toBe(true);
|
||||
expect(await test('ftc')).toEqual({ ok: true, detail: 'The FTC answered.' });
|
||||
expect(await test('ftc', 'nope')).toEqual({
|
||||
ok: false,
|
||||
error: 'api.data.gov refused the key',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -105,7 +105,7 @@ describe.skipIf(!hasTestDb || !hasOpenssl)('SIP TLS setting', () => {
|
||||
expect(await warningCodes()).not.toContain('tls_cert');
|
||||
});
|
||||
|
||||
it('a TLS trunk: no qualify, Test does not send OPTIONS, TLS stays on while it exists', async () => {
|
||||
it('a TLS trunk: qualified like the others (24.0.0-rc3), TLS stays on while it exists', async () => {
|
||||
const trunk = await req('POST', '/api/trunks', {
|
||||
name: 'voip.ms TLS',
|
||||
serverHost: '127.0.0.1',
|
||||
@@ -119,13 +119,15 @@ describe.skipIf(!hasTestDb || !hasOpenssl)('SIP TLS setting', () => {
|
||||
await t.app.generator.apply({ trigger: 'test' });
|
||||
const { readFile } = await import('node:fs/promises');
|
||||
const conf = await readFile(join(t.app.generator.dir, 'pjsip_selfphoneng.conf'), 'utf8');
|
||||
expect(conf).toMatch(/contact = sip:127\.0\.0\.1:5061;transport=tls\nqualify_frequency = 0\n/);
|
||||
expect(conf).toMatch(/contact = sip:127\.0\.0\.1:5061;transport=tls\nqualify_frequency = 60\n/);
|
||||
expect(conf).toMatch(/media_encryption = sdes/);
|
||||
expect(conf).toMatch(/verify_server = yes/);
|
||||
t.live.amiConnected = true;
|
||||
const test = await req('POST', `/api/trunks/${trunk.json().id}/test`);
|
||||
expect(test.statusCode).toBe(200);
|
||||
expect(t.ami.actions.filter((a) => a.Action === 'PJSIPQualify')).toEqual([]);
|
||||
expect(t.ami.actions.filter((a) => a.Action === 'PJSIPQualify')).toEqual([
|
||||
expect.objectContaining({ Endpoint: `trunk-${trunk.json().id}` }),
|
||||
]);
|
||||
const off = await req('PATCH', '/api/settings', { 'sip.tlsEnabled': false });
|
||||
expect(off.statusCode).toBe(400);
|
||||
expect(off.json().error.message).toMatch(/uses TLS/);
|
||||
|
||||
@@ -7,7 +7,7 @@ import { join } from 'node:path';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import type { LiveMessage } from '@selfphoneng/shared';
|
||||
import { extensions, voicemailMessages } from '../src/db/schema.ts';
|
||||
import { extensions, voicemailMessages, webhookDeliveries, webhooks } from '../src/db/schema.ts';
|
||||
import { parseCallerId, type watchVoicemail } from '../src/services/voicemail.ts';
|
||||
import { setSetting } from '../src/services/settings.ts';
|
||||
import { hasTestDb } from './db.ts';
|
||||
@@ -175,11 +175,21 @@ describe.skipIf(!hasTestDb)('voicemail watcher', () => {
|
||||
const url = `http://127.0.0.1:${(stt.address() as AddressInfo).port}/v1`;
|
||||
await setSetting(t.db, 'ai.stt', { url, model: 'whisper' });
|
||||
await setSetting(t.db, 'ai.voicemailTranscripts', true);
|
||||
await t.db
|
||||
.insert(webhooks)
|
||||
.values({ url: 'https://hooks.example.com/x', secretEnc: 'x', events: ['voicemail.new'] });
|
||||
try {
|
||||
leave('INBOX', 3, '1792300700-00000004', '<104>');
|
||||
await w.scan('101');
|
||||
const r = (await rows()).find((x) => x.msgId === '1792300700-00000004')!;
|
||||
expect(r.transcript).toBe('Please call me.');
|
||||
// A webhook goes off this server: the words stay here (10 §1, 08 §7 #6).
|
||||
const [hook] = await t.db
|
||||
.select()
|
||||
.from(webhookDeliveries)
|
||||
.where(eq(webhookDeliveries.event, 'voicemail.new'));
|
||||
expect((hook!.payload as { data: object }).data).toMatchObject({ id: r.id });
|
||||
expect((hook!.payload as { data: object }).data).not.toHaveProperty('transcript');
|
||||
expect(t.mails.at(-1)!.text).toContain(
|
||||
'What they said (automatic, may have mistakes):\nPlease call me.',
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@selfphoneng/probe",
|
||||
"version": "0.0.7",
|
||||
"version": "0.0.13",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
|
||||
+14
-3
@@ -1,10 +1,21 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// The network test probe (08 §2.6): run it outside the network SelfPhoneNG is in, and put its URL
|
||||
// in Settings → Network. `node src/index.ts` (Node 24 runs TypeScript as is).
|
||||
// PORT (default 8080), TRUST_PROXY=true behind a reverse proxy that sets X-Forwarded-For.
|
||||
// PORT (default 8080), TRUST_PROXY=true behind a reverse proxy on this machine that sets
|
||||
// X-Forwarded-For. HOST: where to listen; with TRUST_PROXY=true 127.0.0.1 (only the proxy may
|
||||
// reach it), else every address. PROBE_KEY: the key from SelfPhoneNG's Settings → Network;
|
||||
// without one anyone who finds the probe can use it.
|
||||
import { createProbe } from './server.ts';
|
||||
|
||||
const port = Number(process.env.PORT ?? 8080);
|
||||
const probe = createProbe({ trustProxy: process.env.TRUST_PROXY === 'true' });
|
||||
probe.listen(port, () => console.log(`selfphoneng-probe listening on ${port}`));
|
||||
const trustProxy = process.env.TRUST_PROXY === 'true';
|
||||
const host = process.env.HOST ?? (trustProxy ? '127.0.0.1' : '::');
|
||||
const key = process.env.PROBE_KEY || undefined;
|
||||
const probe = createProbe({ trustProxy, key });
|
||||
probe.listen(port, host, () => console.log(`selfphoneng-probe listening on ${host} ${port}`));
|
||||
if (!key)
|
||||
console.warn(
|
||||
'No PROBE_KEY: anyone who finds this probe can use it. Make one in SelfPhoneNG ' +
|
||||
'(Settings → Network → Generate key) and set PROBE_KEY.',
|
||||
);
|
||||
for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => probe.close(() => process.exit(0)));
|
||||
@@ -1,12 +1,18 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// The probe against stand-ins on 127.0.0.1: TCP, HTTP and a WebSocket upgrade, SIP over UDP and
|
||||
// TCP, raw UDP; and the server: it only tests the address that asked, validates, rate-limits.
|
||||
// TCP, raw UDP; and the server: it only tests the address that asked, validates, rate-limits,
|
||||
// believes X-Forwarded-For only from a proxy on this machine (08 §7 #4), and with PROBE_KEY only
|
||||
// answers who sends it (#9).
|
||||
import { createSocket, type Socket as UdpSocket } from 'node:dgram';
|
||||
import { createServer as httpServer, type Server as HttpServer } from 'node:http';
|
||||
import {
|
||||
createServer as httpServer,
|
||||
type IncomingMessage,
|
||||
type Server as HttpServer,
|
||||
} from 'node:http';
|
||||
import { createServer as tcpServer, type AddressInfo, type Server } from 'node:net';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { runTest } from './checks.ts';
|
||||
import { createProbe, parseCheck } from './server.ts';
|
||||
import { clientIp, createProbe, parseCheck } from './server.ts';
|
||||
|
||||
const port = (s: Server | HttpServer) => (s.address() as AddressInfo).port;
|
||||
const listen = <T extends Server | HttpServer>(s: T) =>
|
||||
@@ -165,3 +171,38 @@ describe('probe server', () => {
|
||||
target.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the probe key', () => {
|
||||
it('with a key, only a request that sends it gets tested (08 §7 #9)', async () => {
|
||||
const key = 'k3y'.repeat(15);
|
||||
const probe = await listen(createProbe({ key }));
|
||||
const post = (authorization?: string) =>
|
||||
fetch(`http://127.0.0.1:${port(probe)}/v1/check`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', ...(authorization && { authorization }) },
|
||||
body: JSON.stringify({ host: 'pbx.test', tests: [{ id: 'a', kind: 'tcp', port: 1 }] }),
|
||||
});
|
||||
expect((await post()).status).toBe(401);
|
||||
expect((await post('Bearer wrong')).status).toBe(401);
|
||||
expect((await post(`Bearer ${key}x`)).status).toBe(401);
|
||||
expect((await post(`Bearer ${key}`)).status).toBe(200);
|
||||
expect((await fetch(`http://127.0.0.1:${port(probe)}/healthz`)).status).toBe(200);
|
||||
probe.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the address that asked', () => {
|
||||
const req = (peer: string, xff?: string) =>
|
||||
({
|
||||
headers: xff ? { 'x-forwarded-for': xff } : {},
|
||||
socket: { remoteAddress: peer },
|
||||
}) as unknown as IncomingMessage;
|
||||
|
||||
it('takes X-Forwarded-For only from the local proxy (08 §7 #4)', () => {
|
||||
// Reached straight from the internet: the header is whatever the caller wrote.
|
||||
expect(clientIp(req('203.0.113.5', '198.51.100.9'), true)).toBe('203.0.113.5');
|
||||
for (const proxy of ['127.0.0.1', '::ffff:127.0.0.1', '::1'])
|
||||
expect(clientIp(req(proxy, '10.0.0.1, 198.51.100.9'), true)).toBe('198.51.100.9');
|
||||
expect(clientIp(req('::ffff:203.0.113.5', '198.51.100.9'), false)).toBe('203.0.113.5');
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,9 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// POST /v1/check {host, tests}: runs the tests against the address the request came from and
|
||||
// answers {ip, results}. GET /healthz. No other target can be given, which is what keeps the probe
|
||||
// from being aimed at someone else; each address gets a few checks per 10 minutes.
|
||||
// from being aimed at someone else; each address gets a few checks per 10 minutes. With a key
|
||||
// (PROBE_KEY, made in SelfPhoneNG's Settings → Network), only who sends it is tested (08 §7 #9).
|
||||
import { createHash, timingSafeEqual } from 'node:crypto';
|
||||
import { createServer, type IncomingMessage, type Server } from 'node:http';
|
||||
import { isIP } from 'node:net';
|
||||
import { runTest, type Result, type Test } from './checks.ts';
|
||||
@@ -9,6 +11,8 @@ import { runTest, type Result, type Test } from './checks.ts';
|
||||
export type ProbeOptions = {
|
||||
/** Behind a reverse proxy: the address is the last X-Forwarded-For entry (the proxy adds it). */
|
||||
trustProxy?: boolean;
|
||||
/** Only requests with `Authorization: Bearer <key>` are tested. None: anyone may ask. */
|
||||
key?: string;
|
||||
/** Checks per address per window. */
|
||||
maxChecks?: number;
|
||||
windowMs?: number;
|
||||
@@ -48,15 +52,27 @@ export function parseCheck(body: unknown): { host: string; tests: Test[] } | str
|
||||
return { host: b.host, tests };
|
||||
}
|
||||
|
||||
const clientIp = (req: IncomingMessage, trustProxy: boolean) => {
|
||||
/**
|
||||
* The address to test. X-Forwarded-For only counts when the request came from a proxy on this
|
||||
* machine: reached directly, the header is whatever the caller wrote (08 §7 #4).
|
||||
*/
|
||||
export const clientIp = (req: IncomingMessage, trustProxy: boolean) => {
|
||||
const peer = req.socket.remoteAddress!.replace(/^::ffff:/, '');
|
||||
const xff = req.headers['x-forwarded-for'];
|
||||
const raw =
|
||||
trustProxy && typeof xff === 'string'
|
||||
? xff.split(',').at(-1)!.trim()
|
||||
: req.socket.remoteAddress!;
|
||||
return raw.replace(/^::ffff:/, '');
|
||||
const local = peer === '127.0.0.1' || peer === '::1';
|
||||
return trustProxy && local && typeof xff === 'string'
|
||||
? xff
|
||||
.split(',')
|
||||
.at(-1)!
|
||||
.trim()
|
||||
.replace(/^::ffff:/, '')
|
||||
: peer;
|
||||
};
|
||||
|
||||
/** Equal, in the same time whatever was sent (hashes, so lengths don't show either). */
|
||||
const sameKey = (a: string, b: string) =>
|
||||
timingSafeEqual(createHash('sha256').update(a).digest(), createHash('sha256').update(b).digest());
|
||||
|
||||
function readJson(req: IncomingMessage): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let size = 0;
|
||||
@@ -78,7 +94,7 @@ function readJson(req: IncomingMessage): Promise<unknown> {
|
||||
}
|
||||
|
||||
export function createProbe(opts: ProbeOptions = {}): Server {
|
||||
const { trustProxy = false, maxChecks = 10, windowMs = 10 * 60_000 } = opts;
|
||||
const { trustProxy = false, key, maxChecks = 10, windowMs = 10 * 60_000 } = opts;
|
||||
// ponytail: in-process memory; addresses past the window are swept on each check.
|
||||
const seen = new Map<string, number[]>();
|
||||
const allow = (ip: string, now = Date.now()) => {
|
||||
@@ -95,6 +111,8 @@ export function createProbe(opts: ProbeOptions = {}): Server {
|
||||
if (req.method === 'GET' && req.url === '/healthz') return send(res, 200, { ok: true });
|
||||
if (req.method !== 'POST' || req.url !== '/v1/check')
|
||||
return send(res, 404, { error: 'not found' });
|
||||
if (key && !sameKey(req.headers.authorization ?? '', `Bearer ${key}`))
|
||||
return send(res, 401, { error: 'this probe needs its key' });
|
||||
const ip = clientIp(req, trustProxy);
|
||||
void readJson(req)
|
||||
.then(async (body) => {
|
||||
|
||||
Loaded 100 of 194 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user