pgkiln 0.61.0 → 0.62.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/SECURITY.md CHANGED
@@ -114,6 +114,7 @@ first version (MVP). Every finding below has a regression test in
114
114
  | Builder texts in the browser (sprint 123) | The builder's JavaScript gets its texts as JSON in an attribute (`<div id="builder-texts" data-texts>`), escaped like any attribute value, so builder pages still have no inline script. The texts are the fixed message files, never data; scripts put them into the page with `textContent` and attributes, not as HTML. |
115
115
  | Two-factor sign-in for developers (0.60.0) | TOTP secrets (RFC 6238, 160 bits) are encrypted with `PGKILN_SECRET_KEY` and stored only after a code from the app confirms them; recovery codes (10 × 50 bits) are stored as SHA-256 and used up atomically; a code is accepted once (the last step is updated atomically) and only one 30-second step either side. The password step leaves only a pending sign-in in the session (5 minutes, no session for the developer yet); wrong codes count with wrong passwords for the per-user and per-IP lockout. Removing an app needs the password; another developer's factors can only be reset by an administrator, with a reason, which ends that developer's builder sessions. The require policy can only be turned on, and stays enforceable, with at least two administrators (or every one) holding an app; there is no setting, URL or variable that skips the second step: recovery is `pgkiln admin reset-2fa` / `set-2fa-policy off` on the server with the owner connection. Every enrolment, removal, reset, policy change and used recovery code is in the activity log; resets and policy changes are mailed to the administrators. The factor tables are closed to the runtime role. |
116
116
  | Passkeys and trusted browsers (0.61.0) | Passkeys (WebAuthn) are checked in `src/webauthn.ts` without a library: a 32-byte one-time challenge per purpose (registration or sign-in) kept in the session for five minutes and used up on the first answer, the `clientDataJSON` type, challenge and origin (`PUBLIC_URL`'s, else the browser's own), the SHA-256 of the relying party id in the authenticator data, user presence, an ES256, EdDSA or RS256 signature over the authenticator data and the client data hash, and a signature counter that must go up (updated atomically, so a replayed or cloned key is refused). The CBOR reader takes only definite lengths, at most 16 levels and 1,000 entries. Credentials are public keys (no secret to leak, independent of the instance key); attestation is *none*. `PGKILN_RP_ID` is only honoured when it is the host or a parent domain of it. The sign-in options (the developer's credential ids) are only given after the password step, with the CSRF token. Trusted browsers (off by default) are a random 256-bit cookie token (`HttpOnly`, `SameSite=Lax`, `Secure` with `COOKIE_SECURE`, path `/builder`) of which only the SHA-256 is stored; the cookie is only looked at after a correct password, for that developer, and ends with its days, a new password, removing a factor, a reset, *Forget trusted browsers* or turning trust off (which removes every trusted browser at once). |
117
+ | Passkey sign-in without a password, workspace rule (0.62.0) | *Sign in with a passkey* asks the browser for any passkey of this site (empty allow list) with `userVerification: required`, and the server refuses an answer whose authenticator data lacks the user-verified flag, so the passkey alone stands for possession plus a fingerprint, face or PIN. The credential id is looked up for this relying party only; a user handle, when the browser sends one, must be the developer's (a SHA-256-derived handle, not the user name). The same one-time challenge, origin, RP ID hash, signature and counter checks as at the second step apply; failures are logged as `login_failed` with the developer's name when the key is known and count for the per-user and per-IP lockout. Administrators turn it off in Instance settings. A workspace's rule (administrators and its own administrators only, CSRF-checked) can only add a requirement; `pgkiln admin set-2fa-policy off` clears every workspace's rule. While a second factor is required of a developer, a sign-in with the default password leads only to the password change. |
117
118
 
118
119
  ## Findings of the MVP review and their fixes
119
120
 
@@ -236,4 +237,4 @@ A second full review, mainly of what end users and API callers can reach. Regres
236
237
  8. With REST data sources: list only the hosts you need in `PGKILN_REST_ALLOWED_HOSTS` (avoid `*`), set a long random `PGKILN_SECRET_KEY` (32+ characters) kept outside the database and its backups, and use `PGKILN_REST_PRIVATE_HOSTS` only for internal services you mean to reach. Narrow it further per workspace under Administration → Network access (sprint 118).
237
238
  9. With header authentication: list only your proxy's addresses in `PGKILN_AUTH_HEADER_PROXIES`, make sure users can't reach pgkiln directly, and have the proxy remove any incoming copy of the header.
238
239
  10. With database-account authentication: list exact roles or use a dedicated membership role, and limit what those roles may connect to in `pg_hba.conf` (they can reach the database with their password outside pgkiln too).
239
- 11. Turn on two-factor sign-in for the builder (Instance settings → Two-factor sign-in, at least for administrators) once two administrators have a passkey or an authenticator app; set `PUBLIC_URL` to the address developers use (passkeys belong to its host name), keep *Trust a browser* short or at 0 on shared machines, keep `PGKILN_SECRET_KEY` backed up, and keep the recovery commands (`pgkiln admin reset-2fa`) for people with server access only.
240
+ 11. Turn on two-factor sign-in for the builder (Instance settings → Two-factor sign-in, at least for administrators) once two administrators have a passkey or an authenticator app; set `PUBLIC_URL` to the address developers use (passkeys belong to its host name), require it per workspace where the instance policy is too broad, keep *Trust a browser* short or at 0 on shared machines, keep `PGKILN_SECRET_KEY` backed up, and keep the recovery commands (`pgkiln admin reset-2fa`) for people with server access only.
@@ -0,0 +1,20 @@
1
+ -- ---------------------------------------------------------------------
2
+ -- 130: passkey sign-in and a workspace two-factor rule (sprint 128,
3
+ -- SEC-2 part 3; beyond APEX).
4
+ --
5
+ -- meta.workspace.mfa_required_since: when set, every developer of the
6
+ -- workspace needs a second factor, with the instance's grace period
7
+ -- (mfa_grace_days) counted from this moment; null = the instance policy
8
+ -- alone decides. Administrators of the instance are covered only where they
9
+ -- are members. `pgkiln admin set-2fa-policy off` clears it everywhere.
10
+ --
11
+ -- meta.setting mfa_passkey_signin ('on', the default, or 'off'): a
12
+ -- developer may sign in with a passkey alone, without the password, when
13
+ -- the authenticator verified them (fingerprint, face, PIN). It counts as
14
+ -- both factors.
15
+ -- ---------------------------------------------------------------------
16
+ alter table meta.workspace add column mfa_required_since timestamptz;
17
+
18
+ comment on column meta.workspace.mfa_required_since is 'Two-factor sign-in required for this workspace''s developers since (null = not by the workspace) (130).';
19
+
20
+ insert into meta.setting (name, value) values ('mfa_passkey_signin', 'on') on conflict (name) do nothing;
@@ -138,7 +138,7 @@ the 26.2 comparison.)
138
138
  | APEX accounts | ✅ | Instance-wide user directory, bcrypt, session rotation, idle and absolute timeouts |
139
139
  | Application Access Control (roles per app, any-user switch) | ✅ | Access control per application |
140
140
  | Social sign-in / OpenID Connect | ✅ | Any OIDC provider (Entra ID, Google, Okta, Keycloak, …): PKCE, group → role mapping, account linking, automatic accounts |
141
- | Multi-factor authentication | ✅ | As in APEX: through the identity provider (OpenID Connect, SAML). Beyond APEX (sprints 126–127): the builder itself has two-factor sign-in with passkeys (WebAuthn), authenticator apps (TOTP) and recovery codes, trusted browsers, a policy with a grace period and lockout recovery ([chapter 3](guide/03-builder.md#two-factor-sign-in)); two-factor for application users follows (SEC-3) |
141
+ | Multi-factor authentication | ✅ | As in APEX: through the identity provider (OpenID Connect, SAML). Beyond APEX (sprints 126–128): the builder itself has two-factor sign-in with passkeys (WebAuthn, also on their own without a password), authenticator apps (TOTP) and recovery codes, trusted browsers, an instance and per-workspace policy with a grace period and lockout recovery ([chapter 3](guide/03-builder.md#two-factor-sign-in)); two-factor for application users follows (SEC-3) |
142
142
  | Change own password | ✅ | A built-in *My account* page (APEX offers the API `CHANGE_CURRENT_USER_PW`) |
143
143
  | Password expiry, change on first use, admin reset, complexity | ✅ | Lifetime in days, expire/unexpire, admin reset, length and letters+digits rules, unlock |
144
144
  | Lockout after failed sign-ins | ✅ | Per user and per IP, time-based (APEX: until an administrator unlocks) |
@@ -296,8 +296,9 @@ Small but real differences, for teams comparing the two:
296
296
  - Responsive layouts verified automatically at four screen sizes.
297
297
  - The theme follows the device's light/dark setting automatically, and the choice is saved per user.
298
298
  - A built-in *My account* page (own password, theme, language).
299
- - (sprints 126–127) **Two-factor sign-in for the builder** without an identity provider: passkeys (WebAuthn), authenticator apps (TOTP),
300
- recovery codes, a policy for administrators or all developers with a grace period, and lockout recovery on the
299
+ - (sprints 126–128) **Two-factor sign-in for the builder** without an identity provider: passkeys (WebAuthn; sign-in with a
300
+ passkey alone), authenticator apps (TOTP), recovery codes, a policy for administrators, all developers or a
301
+ workspace's developers with a grace period, and lockout recovery on the
301
302
  command line ([chapter 3](guide/03-builder.md#two-factor-sign-in)). APEX relies on the identity provider.
302
303
  - One RLS policy set protects both the web UI and the REST API.
303
304
  - An import can be checked before it commits (`pgkiln import --check`): SQL that names a missing
@@ -68,6 +68,13 @@ one passkey on several sub-domains, set `PGKILN_RP_ID` to the parent domain (`ex
68
68
  instance key changes. pgkiln accepts any authenticator (attestation *none*), checks the signature, the address and
69
69
  the relying party on every sign-in, and refuses a key whose signature counter goes back (a copied key).
70
70
 
71
+ **A passkey alone** (0.62): the sign-in page has **Sign in with a passkey** next to the password form. The browser
72
+ offers the passkeys it holds for this site, and you choose one; no user name, no password. It only works when the
73
+ device verified you (fingerprint, face or PIN), so the passkey counts as both factors; a workspace typed in the form
74
+ still applies. Administrators can turn it off in Instance settings → *Two-factor sign-in* (*Developers may sign in
75
+ with a passkey alone*, on by default). A security key without a PIN, or a passkey made before 0.61 that the browser
76
+ doesn't offer, still works at the second step after the password.
77
+
71
78
  The app's secret is stored encrypted with the instance key (`PGKILN_SECRET_KEY`, [chapter 1](01-installation.md));
72
79
  without the key the page says so and you can't add an app. Recovery codes are stored as SHA-256 hashes, so they keep
73
80
  working if the key is lost. Wrong codes count with wrong passwords for the [sign-in lockout](08-security.md#what-sign-in-protects-against).
@@ -77,6 +84,15 @@ sign-in*: **Nobody** (optional, the default), **Administrators** or **All develo
77
84
  (default 7 days). During the grace period a developer without an app sees a reminder on every page; after it, they
78
85
  see only their Security page until they add one. The region lists the developers who have no app yet.
79
86
 
87
+ **Per workspace** (0.62): a workspace's page (Workspace utilities → Workspaces → the workspace, *Preferences*) has
88
+ *Require two-factor sign-in of this workspace's developers*, for administrators and the workspace's own
89
+ administrators. It adds to the instance's policy: the workspace's developers get the instance's grace period from the
90
+ moment it was turned on, then only their Security page. The page lists the members who have no factor yet.
91
+
92
+ **The default password** (0.62): a developer who signs in with the default password (`admin`, or their user name)
93
+ while two-factor sign-in is required of them sees only **Developers**, to change it, before anything else (then the
94
+ grace period or the Security page as above).
95
+
80
96
  **Trusted browsers** (0.61): *Trust a browser (days)* in the same region (0, the default: never) adds *Trust this
81
97
  browser for N days* to the second step. A trusted browser skips the second step for that developer until the days
82
98
  are over; only a hash of the cookie's random token is stored. The developer's Security page shows how many browsers
@@ -103,7 +119,7 @@ setting the days to 0 stops every trusted browser at once.
103
119
  | Lost phone, keys and codes | Ask another administrator to reset your two-factor sign-in on Developers |
104
120
  | A passkey no longer works after a move to another domain | Sign in with an app code or a recovery code and add a new passkey ([above](#two-factor-sign-in)) |
105
121
  | No administrator can sign in | On the server: `pgkiln admin reset-2fa <developer> --reason "…"` ([chapter 18](18-cli.md)), then sign in with the password |
106
- | The policy locks everyone out | On the server: `pgkiln admin set-2fa-policy off` |
122
+ | The policy locks everyone out | On the server: `pgkiln admin set-2fa-policy off` (also clears the workspaces' own rules) |
107
123
  | Codes are always wrong | Check the time on the phone and on the server (codes allow 30 seconds either way) |
108
124
  | The instance key was lost or changed | The apps' secrets can't be read: reset the developers (`pgkiln admin reset-2fa`); recovery codes still work |
109
125
 
@@ -53,7 +53,7 @@ any (see [what is not exported](03-builder.md#export-format)).
53
53
  | `pgkiln users add <username> [--developer] [--app <alias> --roles a,b] [--name …] [--email …]` | Adds an account (optionally with access to an application) or a builder developer |
54
54
  | `pgkiln users password <username> [--developer]` | Sets a password and ends that user's sessions |
55
55
  | `pgkiln admin reset-2fa <developer> [--reason <text>]` | (0.60) Removes a developer's authenticator apps and recovery codes and ends their builder sessions: [locked out?](03-builder.md#locked-out) |
56
- | `pgkiln admin set-2fa-policy off\|admins\|all [--grace <days>]` | (0.60) Sets the builder's [two-factor policy](03-builder.md#two-factor-sign-in); `off` always works, the others need two administrators with an app |
56
+ | `pgkiln admin set-2fa-policy off\|admins\|all [--grace <days>]` | (0.60) Sets the builder's [two-factor policy](03-builder.md#two-factor-sign-in); `off` always works (and, 0.62, clears every workspace's own rule), the others need two administrators with an app |
57
57
 
58
58
  `users` reads the password from standard input (the first line, e.g. from a secret store:
59
59
  `printf '%s\n' "$PW" | pgkiln users add ops --developer`) or asks for it twice on a terminal. It is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pgkiln",
3
- "version": "0.61.0",
3
+ "version": "0.62.0",
4
4
  "description": "Open source low-code, SQL-driven application builder for PostgreSQL, inspired by Oracle APEX",
5
5
  "keywords": [
6
6
  "postgresql",
@@ -675,6 +675,7 @@ kbd { display: inline-block; padding: 0 0.35em; border: 1px solid var(--border);
675
675
  .signin-reveal { flex: none; border: 0; background: none; color: var(--accent); cursor: pointer; font-size: 0.85rem; padding: 0.25rem; }
676
676
  .signin-remember { font-size: 0.9rem; }
677
677
  .signin-submit { padding: 0.75rem; font-size: 1rem; border-radius: var(--radius); }
678
+ [data-passkey-status]:empty { display: none; }
678
679
  .signin-hint { margin: 0; text-align: center; }
679
680
  .signin-actions { display: flex; flex-wrap: wrap; justify-content: center; gap: 0.5rem 1.5rem; margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--border); }
680
681
  .signin-actions a { display: inline-flex; align-items: center; gap: 0.4rem; color: var(--gold); text-decoration: none; }
package/public/builder.js CHANGED
@@ -1337,6 +1337,7 @@ document.querySelectorAll('input[data-filter-tree]').forEach((el) => {
1337
1337
  // (sprint 127) Passkeys: a [data-passkey] button asks the server (its URL) for WebAuthn options, calls
1338
1338
  // navigator.credentials.create() (Security page) or .get() (the second sign-in step) and posts the answer in
1339
1339
  // its own form, to data-passkey-submit when set, so the server answers with a redirect as for any form.
1340
+ // (sprint 128) On the sign-in page the list of allowed passkeys is empty: the browser offers the ones it holds.
1340
1341
  // Without WebAuthn or JavaScript the buttons stay hidden; codes keep working.
1341
1342
  (() => {
1342
1343
  'use strict';
@@ -1377,13 +1378,14 @@ document.querySelectorAll('input[data-filter-tree]').forEach((el) => {
1377
1378
  set('transports', (c.response.getTransports ? c.response.getTransports() : []).join(','));
1378
1379
  } else {
1379
1380
  const c = await navigator.credentials.get({ publicKey: {
1380
- challenge: bytes(o.challenge), rpId: o.rpId, userVerification: 'preferred', timeout: 120000,
1381
+ challenge: bytes(o.challenge), rpId: o.rpId, userVerification: o.uv || 'preferred', timeout: 120000,
1381
1382
  allowCredentials: o.allow.map((a) => ({ type: 'public-key', id: bytes(a.id), transports: a.transports })),
1382
1383
  } });
1383
1384
  set('credentialId', b64(c.rawId));
1384
1385
  set('clientDataJSON', b64(c.response.clientDataJSON));
1385
1386
  set('authenticatorData', b64(c.response.authenticatorData));
1386
1387
  set('signature', b64(c.response.signature));
1388
+ if (c.response.userHandle) set('userHandle', b64(c.response.userHandle));
1387
1389
  }
1388
1390
  if (btn.dataset.passkeySubmit) form.action = btn.dataset.passkeySubmit;
1389
1391
  form.submit();
@@ -1808,4 +1808,16 @@ export const de: BuilderMessages = {
1808
1808
  'mfa.trust_days': 'Browser vertrauen (Tage)',
1809
1809
  'mfa.trust_days_help': 'Nach dem zweiten Schritt darf ein Entwickler ihn in diesem Browser so viele Tage überspringen. 0: nie.',
1810
1810
  'mfa.passkey_needs_host': 'Passkeys brauchen einen Hostnamen, keine IP-Adresse ({host}): Öffnen Sie den Builder über localhost oder seinen Domainnamen.',
1811
+ 'signin.passkey': 'Mit einem Passkey anmelden',
1812
+ 'mfa.passkey_signin': 'Entwickler dürfen sich nur mit einem Passkey anmelden (ohne Passwort)',
1813
+ 'mfa.passkey_signin_help': 'Nur wenn das Gerät den Entwickler geprüft hat (Fingerabdruck, Gesicht oder PIN): Dann zählt der Passkey als beide Faktoren.',
1814
+ 'mfa.weak_required': 'Zwei-Faktor-Anmeldung ist erforderlich: Ändern Sie das Standardpasswort, bevor Sie fortfahren.',
1815
+ 'wsp.mfa': 'Zwei-Faktor-Anmeldung',
1816
+ 'wsp.mfa_required': 'Zwei-Faktor-Anmeldung für die Entwickler dieses Workspace verlangen',
1817
+ 'wsp.mfa_required_help': 'Zusätzlich zur Richtlinie der Instanz. Entwickler ohne Passkey oder Authenticator-App erhalten die Übergangsfrist der Instanz, danach nur ihre Sicherheitsseite.',
1818
+ 'wsp.mfa_since': 'Erforderlich seit {date}.',
1819
+ 'wsp.mfa_without': 'Noch nicht eingerichtet: {list}.',
1820
+ 'wsp.mfa_save': 'Zwei-Faktor-Regel speichern',
1821
+ 'wsp.mfa_on': 'Die Entwickler dieses Workspace brauchen die Zwei-Faktor-Anmeldung.',
1822
+ 'wsp.mfa_off': 'Der Workspace verlangt keine Zwei-Faktor-Anmeldung mehr (die Richtlinie der Instanz gilt weiter).',
1811
1823
  };
@@ -1811,4 +1811,16 @@ export const en = {
1811
1811
  'mfa.trust_days': 'Trust a browser (days)',
1812
1812
  'mfa.trust_days_help': 'After the second step a developer may skip it in that browser for this many days. 0: never.',
1813
1813
  'mfa.passkey_needs_host': 'Passkeys need a host name, not an IP address ({host}): open the builder at localhost or its domain name.',
1814
+ 'signin.passkey': 'Sign in with a passkey',
1815
+ 'mfa.passkey_signin': 'Developers may sign in with a passkey alone (no password)',
1816
+ 'mfa.passkey_signin_help': 'Only when the device checked the developer (fingerprint, face or PIN): the passkey then counts as both factors.',
1817
+ 'mfa.weak_required': 'Two-factor sign-in is required: change the default password before you continue.',
1818
+ 'wsp.mfa': 'Two-factor sign-in',
1819
+ 'wsp.mfa_required': 'Require two-factor sign-in of this workspace’s developers',
1820
+ 'wsp.mfa_required_help': 'On top of the instance’s policy. Developers without a passkey or authenticator app get the instance’s grace period, then only their Security page.',
1821
+ 'wsp.mfa_since': 'Required since {date}.',
1822
+ 'wsp.mfa_without': 'Not set up yet: {list}.',
1823
+ 'wsp.mfa_save': 'Save two-factor rule',
1824
+ 'wsp.mfa_on': 'This workspace’s developers need two-factor sign-in.',
1825
+ 'wsp.mfa_off': 'The workspace no longer requires two-factor sign-in (the instance’s policy still applies).',
1814
1826
  } as const;
@@ -1808,4 +1808,16 @@ export const es: BuilderMessages = {
1808
1808
  'mfa.trust_days': 'Confiar en un navegador (días)',
1809
1809
  'mfa.trust_days_help': 'Tras el segundo paso, un desarrollador puede omitirlo en ese navegador durante estos días. 0: nunca.',
1810
1810
  'mfa.passkey_needs_host': 'Las llaves de acceso necesitan un nombre de host, no una dirección IP ({host}): abra el constructor en localhost o en su nombre de dominio.',
1811
+ 'signin.passkey': 'Iniciar sesión con una llave de acceso',
1812
+ 'mfa.passkey_signin': 'Los desarrolladores pueden iniciar sesión solo con una llave de acceso (sin contraseña)',
1813
+ 'mfa.passkey_signin_help': 'Solo cuando el dispositivo verificó al desarrollador (huella, rostro o PIN): la llave cuenta entonces como ambos factores.',
1814
+ 'mfa.weak_required': 'El inicio de sesión en dos pasos es obligatorio: cambie la contraseña predeterminada antes de continuar.',
1815
+ 'wsp.mfa': 'Inicio de sesión en dos pasos',
1816
+ 'wsp.mfa_required': 'Exigir el inicio de sesión en dos pasos a los desarrolladores de este espacio de trabajo',
1817
+ 'wsp.mfa_required_help': 'Además de la política de la instancia. Los desarrolladores sin llave de acceso ni app de autenticación tienen el periodo de gracia de la instancia y después solo su página de seguridad.',
1818
+ 'wsp.mfa_since': 'Obligatorio desde {date}.',
1819
+ 'wsp.mfa_without': 'Aún sin configurar: {list}.',
1820
+ 'wsp.mfa_save': 'Guardar la regla de dos pasos',
1821
+ 'wsp.mfa_on': 'Los desarrolladores de este espacio de trabajo necesitan el inicio de sesión en dos pasos.',
1822
+ 'wsp.mfa_off': 'El espacio de trabajo ya no exige el inicio de sesión en dos pasos (la política de la instancia sigue vigente).',
1811
1823
  };
@@ -1808,4 +1808,16 @@ export const fr: BuilderMessages = {
1808
1808
  'mfa.trust_days': 'Faire confiance à un navigateur (jours)',
1809
1809
  'mfa.trust_days_help': 'Après la seconde étape, un développeur peut la passer dans ce navigateur pendant ce nombre de jours. 0 : jamais.',
1810
1810
  'mfa.passkey_needs_host': 'Les clés d’accès ont besoin d’un nom d’hôte, pas d’une adresse IP ({host}) : ouvrez le builder sur localhost ou son nom de domaine.',
1811
+ 'signin.passkey': 'Se connecter avec une clé d’accès',
1812
+ 'mfa.passkey_signin': 'Les développeurs peuvent se connecter avec une clé d’accès seule (sans mot de passe)',
1813
+ 'mfa.passkey_signin_help': 'Seulement quand l’appareil a vérifié le développeur (empreinte, visage ou code PIN) : la clé compte alors pour les deux facteurs.',
1814
+ 'mfa.weak_required': 'La connexion à deux facteurs est obligatoire : changez le mot de passe par défaut avant de continuer.',
1815
+ 'wsp.mfa': 'Connexion à deux facteurs',
1816
+ 'wsp.mfa_required': 'Exiger la connexion à deux facteurs des développeurs de cet espace de travail',
1817
+ 'wsp.mfa_required_help': 'En plus de la politique de l’instance. Les développeurs sans clé d’accès ni application d’authentification ont le délai de grâce de l’instance, puis seulement leur page Sécurité.',
1818
+ 'wsp.mfa_since': 'Obligatoire depuis le {date}.',
1819
+ 'wsp.mfa_without': 'Pas encore configuré : {list}.',
1820
+ 'wsp.mfa_save': 'Enregistrer la règle à deux facteurs',
1821
+ 'wsp.mfa_on': 'Les développeurs de cet espace de travail ont besoin de la connexion à deux facteurs.',
1822
+ 'wsp.mfa_off': 'L’espace de travail n’exige plus la connexion à deux facteurs (la politique de l’instance s’applique toujours).',
1811
1823
  };
@@ -1808,4 +1808,16 @@ export const nl: BuilderMessages = {
1808
1808
  'mfa.trust_days': 'Browser vertrouwen (dagen)',
1809
1809
  'mfa.trust_days_help': 'Na de tweede stap mag een ontwikkelaar die in die browser zoveel dagen overslaan. 0: nooit.',
1810
1810
  'mfa.passkey_needs_host': 'Passkeys hebben een hostnaam nodig, geen IP-adres ({host}): open de builder via localhost of de domeinnaam.',
1811
+ 'signin.passkey': 'Aanmelden met een passkey',
1812
+ 'mfa.passkey_signin': 'Ontwikkelaars mogen alleen met een passkey aanmelden (zonder wachtwoord)',
1813
+ 'mfa.passkey_signin_help': 'Alleen als het apparaat de ontwikkelaar heeft gecontroleerd (vingerafdruk, gezicht of pincode): de passkey telt dan als beide factoren.',
1814
+ 'mfa.weak_required': 'Tweestapsaanmelding is verplicht: wijzig het standaardwachtwoord voordat u verdergaat.',
1815
+ 'wsp.mfa': 'Tweestapsaanmelding',
1816
+ 'wsp.mfa_required': 'Tweestapsaanmelding verplichten voor de ontwikkelaars van deze werkruimte',
1817
+ 'wsp.mfa_required_help': 'Bovenop het beleid van de instantie. Ontwikkelaars zonder passkey of authenticator-app krijgen de overgangstermijn van de instantie, daarna alleen hun beveiligingspagina.',
1818
+ 'wsp.mfa_since': 'Verplicht sinds {date}.',
1819
+ 'wsp.mfa_without': 'Nog niet ingesteld: {list}.',
1820
+ 'wsp.mfa_save': 'Tweestapsregel opslaan',
1821
+ 'wsp.mfa_on': 'De ontwikkelaars van deze werkruimte hebben tweestapsaanmelding nodig.',
1822
+ 'wsp.mfa_off': 'De werkruimte verplicht geen tweestapsaanmelding meer (het beleid van de instantie blijft gelden).',
1811
1823
  };
@@ -1808,4 +1808,16 @@ export const pt: BuilderMessages = {
1808
1808
  'mfa.trust_days': 'Confiar em um navegador (dias)',
1809
1809
  'mfa.trust_days_help': 'Depois da segunda etapa, um desenvolvedor pode pulá-la naquele navegador por estes dias. 0: nunca.',
1810
1810
  'mfa.passkey_needs_host': 'Chaves de acesso precisam de um nome de host, não de um endereço IP ({host}): abra o construtor em localhost ou no nome de domínio.',
1811
+ 'signin.passkey': 'Entrar com uma chave de acesso',
1812
+ 'mfa.passkey_signin': 'Os desenvolvedores podem entrar só com uma chave de acesso (sem senha)',
1813
+ 'mfa.passkey_signin_help': 'Só quando o dispositivo verificou o desenvolvedor (impressão digital, rosto ou PIN): a chave conta então como os dois fatores.',
1814
+ 'mfa.weak_required': 'O login em duas etapas é obrigatório: altere a senha padrão antes de continuar.',
1815
+ 'wsp.mfa': 'Login em duas etapas',
1816
+ 'wsp.mfa_required': 'Exigir login em duas etapas dos desenvolvedores deste espaço de trabalho',
1817
+ 'wsp.mfa_required_help': 'Além da política da instância. Desenvolvedores sem chave de acesso ou app autenticador têm o período de carência da instância e depois só a página de segurança.',
1818
+ 'wsp.mfa_since': 'Obrigatório desde {date}.',
1819
+ 'wsp.mfa_without': 'Ainda não configurado: {list}.',
1820
+ 'wsp.mfa_save': 'Salvar a regra de duas etapas',
1821
+ 'wsp.mfa_on': 'Os desenvolvedores deste espaço de trabalho precisam do login em duas etapas.',
1822
+ 'wsp.mfa_off': 'O espaço de trabalho já não exige login em duas etapas (a política da instância continua valendo).',
1811
1823
  };
@@ -27,7 +27,8 @@ import { appFromFileRoutes } from './appfromfile.ts';
27
27
  import { clearDevbar } from '../runtime/devbar.ts';
28
28
  import { EMAIL } from './find-workspace.ts';
29
29
  import { cookieLanguage, LANG_COOKIE, tr, translatorFor, validBuilderLanguage } from './i18n.ts';
30
- import { checkSecondFactor, codesLeft, forgetBrowsers, hasFactor, isTrustedBrowser, leavesTooFewAdmins, passkeysOf, trustBrowser, trustDays, usePasskey } from '../mfa.ts';
30
+ import { checkSecondFactor, codesLeft, forgetBrowsers, hasFactor, isTrustedBrowser, leavesTooFewAdmins, passkeyByCredential, passkeySignin, passkeysOf, trustBrowser, trustDays, usePasskey, userHandle } from '../mfa.ts';
31
+ import { isIP } from 'node:net';
31
32
  import { giveChallenge, takeChallenge } from './security.ts';
32
33
  import { relyingParty, verifyAssertion, WebAuthnError } from '../webauthn.ts';
33
34
  import { chooseLanguage, langCookieOptions, languageRoutes, signInLanguageLinks } from './language.ts';
@@ -67,6 +68,8 @@ export async function builderRoutes(app: FastifyInstance) {
67
68
  await saveState(s);
68
69
  const r = remembered(req);
69
70
  const next = safeNext(req.query?.next);
71
+ // (sprint 128) a passkey alone, when the instance allows it and the address is a host name
72
+ const alone = (await passkeySignin()) && !isIP(relyingParty(req).rpId);
70
73
  const field = (name: string, label: string, ic: string, value: string, opts: { type?: string; required?: boolean; auto?: string; placeholder?: string; reveal?: boolean }) =>
71
74
  html`<div class="field signin-field"><label class="sr-only" for="f_${name}">${label}</label>
72
75
  <div class="signin-input">${icon(ic)}<input id="f_${name}" name="${name}" type="${opts.type ?? 'text'}" value="${value}" placeholder="${opts.placeholder ?? label}"${opts.required ? raw(' required') : ''}${opts.auto ? raw(` autocomplete="${opts.auto}"`) : ''}>
@@ -92,6 +95,9 @@ export async function builderRoutes(app: FastifyInstance) {
92
95
  ${field('password', tx('signin.password'), 'key', '', { type: 'password', required: true, auto: 'current-password', reveal: true })}
93
96
  <label class="check signin-remember"><input type="checkbox" name="remember" value="1"${r ? raw(' checked') : ''}> ${tx('signin.remember')}</label>
94
97
  <button class="btn btn-hot signin-submit">${tx('signin.submit')}</button>
98
+ ${alone ? html`<input type="hidden" name="credentialId"><input type="hidden" name="clientDataJSON"><input type="hidden" name="authenticatorData"><input type="hidden" name="signature"><input type="hidden" name="userHandle">
99
+ <button class="btn signin-submit signin-passkey" hidden data-passkey="${BASE}/login/passkey/options" data-passkey-submit="${BASE}/login/passkey" data-passkey-error="${tx('mfa.passkey_failed')}">${icon('fingerprint')} ${tx('signin.passkey')}</button>
100
+ <p class="small" role="status" data-passkey-status></p>` : ''}
95
101
  <p class="muted small signin-hint">${tx('signin.hint')}</p>
96
102
  <nav class="signin-actions" aria-label="${tx('signin.quick')}">
97
103
  <a href="${BASE}/find-workspace">${icon('search')} ${tx('signin.find_ws')}</a>
@@ -138,15 +144,7 @@ export async function builderRoutes(app: FastifyInstance) {
138
144
  [username, password],
139
145
  );
140
146
  // a workspace, when given, must be one of the developer's (administrators: any); the same answer otherwise
141
- const ws = dev && workspace
142
- ? await owner.one<{ id: number }>(
143
- `select w.id from meta.workspace w
144
- where lower(w.name) = lower($1)
145
- and (exists (select 1 from meta.developer d where d.username = $2 and d.is_admin)
146
- or exists (select 1 from meta.workspace_member m where m.workspace_id = w.id and m.username = $2))`,
147
- [workspace, dev.username],
148
- )
149
- : undefined;
147
+ const ws = dev && workspace ? await workspaceOf(workspace, dev.username) : undefined;
150
148
  if (!dev || (workspace && !ws)) {
151
149
  await logActivity({ username: username.slice(0, 100), event: 'login_failed', ip, detail: workspace ? 'builder (workspace)' : 'builder' });
152
150
  flash(s, tx(workspace ? 'signin.invalid_ws' : 'signin.invalid'), 'error');
@@ -162,6 +160,75 @@ export async function builderRoutes(app: FastifyInstance) {
162
160
  return finishSignIn(req, reply, s, { username: dev.username, language: dev.language }, { ws: ws ? String(ws.id) : null, workspace, next, remember: req.body?.remember === '1', weak: password === 'admin' || password === dev.username, picked, detail: 'builder' });
163
161
  });
164
162
 
163
+ /** A workspace by name, when it is one of the developer's (administrators: any). */
164
+ const workspaceOf = (workspace: string, username: string) =>
165
+ owner.one<{ id: number }>(
166
+ `select w.id from meta.workspace w
167
+ where lower(w.name) = lower($1)
168
+ and (exists (select 1 from meta.developer d where d.username = $2 and d.is_admin)
169
+ or exists (select 1 from meta.workspace_member m where m.workspace_id = w.id and m.username = $2))`,
170
+ [workspace, username],
171
+ );
172
+
173
+ // (sprint 128, SEC-2) signing in with a passkey alone: no user name, no password. The browser offers the
174
+ // passkeys it holds for this site (discoverable credentials); the authenticator must verify the developer
175
+ // (fingerprint, face, PIN), so the passkey counts as both factors. Failures count with wrong passwords.
176
+ app.post(`${BASE}/login/passkey/options`, async (req: Req, reply) => {
177
+ const s = await getSession(req, reply, null, BASE);
178
+ if (req.body?.__csrf !== s.csrf_token) return reply.code(403).send('Reload the page.');
179
+ if (!(await passkeySignin())) return reply.code(404).send('Passkey sign-in is off.');
180
+ const challenge = giveChallenge(s as Parameters<typeof giveChallenge>[0], 'get');
181
+ await saveState(s);
182
+ reply.header('cache-control', 'no-store');
183
+ return reply.send({ mode: 'get', challenge, rpId: relyingParty(req).rpId, allow: [], uv: 'required' });
184
+ });
185
+ app.post(`${BASE}/login/passkey`, async (req: Req, reply) => {
186
+ const s = await getSession(req, reply, null, BASE);
187
+ const ip = clientIp(req);
188
+ const picked = validBuilderLanguage(s.state.__BLANG);
189
+ const tx = translatorFor(picked ?? cookieLanguage(req));
190
+ const workspace = String(req.body?.workspace ?? '').trim().slice(0, 60);
191
+ const next = safeNext(req.body?.next);
192
+ const again = next === `${BASE}/home` ? `${BASE}/login` : `${BASE}/login?next=${encodeURIComponent(next)}`;
193
+ const challenge = takeChallenge(s as Parameters<typeof takeChallenge>[0], 'get');
194
+ if (req.body?.__csrf !== s.csrf_token || !(await passkeySignin())) {
195
+ flash(s, tx('signin.expired'), 'error');
196
+ return back(reply, s, again);
197
+ }
198
+ const rp = relyingParty(req);
199
+ const key = await passkeyByCredential(String(req.body?.credentialId ?? ''), rp.rpId);
200
+ const who = key?.username ?? '';
201
+ if (await loginThrottled(null, who, ip)) {
202
+ logActivity({ username: who, event: 'login_locked', ip, detail: 'builder passkey' });
203
+ flash(s, tx('signin.throttled'), 'error');
204
+ return back(reply, s, again);
205
+ }
206
+ let why = key ? '' : 'unknown passkey';
207
+ if (key && !challenge) why = 'no or an expired challenge';
208
+ else if (key && req.body?.userHandle && req.body.userHandle !== userHandle(key.username)) why = 'the user handle does not match';
209
+ else if (key && challenge) {
210
+ try {
211
+ const r = verifyAssertion(
212
+ { clientDataJSON: String(req.body?.clientDataJSON ?? ''), authenticatorData: String(req.body?.authenticatorData ?? ''), signature: String(req.body?.signature ?? '') },
213
+ { alg: key.alg, jwk: key.public_key, signCount: Number(key.sign_count) }, { challenge, ...rp });
214
+ if (!r.userVerified) why = 'the authenticator did not verify the user';
215
+ else if (!(await usePasskey(key.id, key.sign_count, r.signCount))) why = 'used at the same time elsewhere';
216
+ } catch (e) {
217
+ why = e instanceof WebAuthnError ? e.message : 'unreadable answer';
218
+ }
219
+ }
220
+ const ws = !why && workspace ? await workspaceOf(workspace, who) : undefined;
221
+ if (!why && workspace && !ws) why = 'not a workspace of the developer';
222
+ if (why) {
223
+ await logActivity({ username: who.slice(0, 100), event: 'login_failed', ip, detail: `builder passkey: ${why}` });
224
+ flash(s, tx(why === 'not a workspace of the developer' ? 'signin.invalid_ws' : 'mfa.passkey_failed'), 'error');
225
+ return back(reply, s, again);
226
+ }
227
+ const dev = await owner.one<{ username: string; language: string | null }>('select username, language from meta.developer where username = $1', [who]);
228
+ if (!dev) return back(reply, s, again);
229
+ return finishSignIn(req, reply, s, dev, { ws: ws ? String(ws.id) : null, workspace, next, remember: req.body?.remember === '1', weak: false, picked, detail: 'builder passkey' });
230
+ });
231
+
165
232
  /** The session after a successful sign-in (password, and the second factor when the developer has one). */
166
233
  async function finishSignIn(req: Req, reply: Parameters<typeof back>[0], s: Awaited<ReturnType<typeof getSession>>, dev: { username: string; language: string | null },
167
234
  o: { ws: string | null; workspace: string; next: string; remember: boolean; weak: boolean; picked: ReturnType<typeof validBuilderLanguage>; detail: string }) {
@@ -1,4 +1,3 @@
1
- import { createHash } from 'node:crypto';
2
1
  import { isIP } from 'node:net';
3
2
  import type { FastifyInstance } from 'fastify';
4
3
  import { owner } from '../db.ts';
@@ -6,13 +5,13 @@ import { html, raw, type Raw } from '../html.ts';
6
5
  import { icon } from '../icons.ts';
7
6
  import {
8
7
  addPasskey, addTotp, adminEmails, adminFactors, codesLeft, confirmRecoveryCodes, factorsOf, forgetBrowsers, hasFactor, matchTotp, MFA_POLICIES, mfaPolicy,
9
- newRecoveryCodes, newTotpSecret, notifyMfa, otpauthUri, passkeysOf, policyProblem, requiredFor, resetFactors, setPolicy, setTrustDays, trustDays,
10
- trustedBrowsers, unbase32, type MfaPolicy,
8
+ newRecoveryCodes, newTotpSecret, notifyMfa, otpauthUri, passkeysOf, policyProblem, requirement, resetFactors, setPolicy, setTrustDays, trustDays,
9
+ trustedBrowsers, unbase32, userHandle, passkeySignin, setPasskeySignin, type MfaPolicy,
11
10
  } from '../mfa.ts';
12
11
  import { qrSvg } from '../qrcode.ts';
13
12
  import { secretKeyConfigured } from '../secrets.ts';
14
13
  import { clientIp, logActivity, saveState, type Session } from '../session.ts';
15
- import { ALGORITHMS, b64url, newChallenge, relyingParty, verifyRegistration, WebAuthnError } from '../webauthn.ts';
14
+ import { ALGORITHMS, newChallenge, relyingParty, verifyRegistration, WebAuthnError } from '../webauthn.ts';
16
15
  import { tr } from './i18n.ts';
17
16
  import { isAdmin } from './locks.ts';
18
17
  import { back, BASE, csrf, developer, flash, input, region, send, shell, type Req } from './ui.ts';
@@ -59,9 +58,9 @@ export async function securityRoutes(app: FastifyInstance) {
59
58
  if (!s) return;
60
59
  const tx = tr(s);
61
60
  const me = s.username!;
62
- const [factors, left, pol, admin, days, trusted] = await Promise.all([factorsOf(me), codesLeft(me), mfaPolicy(), isAdmin(me), trustDays(), trustedBrowsers(me)]);
61
+ const [factors, left, admin, days, trusted] = await Promise.all([factorsOf(me), codesLeft(me), isAdmin(me), trustDays(), trustedBrowsers(me)]);
63
62
  const rp = relyingParty(req);
64
- const required = requiredFor(pol.policy, admin);
63
+ const { required } = await requirement(me, admin);
65
64
  const pending = (() => {
66
65
  try {
67
66
  const p = JSON.parse(String(s.state.__TOTP_NEW ?? ''));
@@ -222,7 +221,7 @@ export async function securityRoutes(app: FastifyInstance) {
222
221
  if (last) {
223
222
  const pol = await mfaPolicy();
224
223
  const admin = await isAdmin(me);
225
- if (requiredFor(pol.policy, admin)) {
224
+ if ((await requirement(me, admin)).required) {
226
225
  flash(s, tx('mfa.cannot_remove_required'), 'error');
227
226
  return back(reply, s, `${BASE}/security`);
228
227
  }
@@ -257,7 +256,7 @@ export async function securityRoutes(app: FastifyInstance) {
257
256
  return reply.send({
258
257
  mode: 'create', challenge, rp: { id: rp.rpId, name: 'pgkiln' }, algs: ALGORITHMS,
259
258
  // a stable handle that isn't the user name (the authenticator may show it, but it says nothing)
260
- user: { id: b64url(createHash('sha256').update(`pgkiln-developer:${me}`).digest().subarray(0, 16)), name: me, displayName: me },
259
+ user: { id: userHandle(me), name: me, displayName: me },
261
260
  exclude: (await passkeysOf(me, rp.rpId)).map((p) => p.credential_id),
262
261
  });
263
262
  });
@@ -341,9 +340,11 @@ export async function securityRoutes(app: FastifyInstance) {
341
340
  flash(s, tx('mfa.policy_needs_admins'), 'error');
342
341
  return back(reply, s, `${BASE}/instance#mfa`);
343
342
  }
343
+ const alone = req.body?.passkey_signin === '1';
344
344
  await setPolicy(policy, grace);
345
345
  await setTrustDays(trust);
346
- await logActivity({ username: s.username, event: '2fa_policy', ip: clientIp(req), detail: `${policy}, grace ${grace} days, trust browsers ${trust} days` });
346
+ await setPasskeySignin(alone);
347
+ await logActivity({ username: s.username, event: '2fa_policy', ip: clientIp(req), detail: `${policy}, grace ${grace} days, trust browsers ${trust} days, passkey sign-in ${alone ? 'on' : 'off'}` });
347
348
  await notifyMfa(await adminEmails(), `pgkiln: two-factor sign-in policy is now ${policy}`, `${s.username} set the two-factor policy to ${policy} (grace period ${grace} days).`);
348
349
  flash(s, tx('mfa.policy_saved'));
349
350
  return back(reply, s, `${BASE}/instance#mfa`);
@@ -353,8 +354,8 @@ export async function securityRoutes(app: FastifyInstance) {
353
354
  /** The Instance settings region for the policy (administrators). */
354
355
  export async function policyRegion(s: Session): Promise<Raw> {
355
356
  const tx = tr(s);
356
- const [pol, af, days, without] = await Promise.all([
357
- mfaPolicy(), adminFactors(), trustDays(),
357
+ const [pol, af, days, alone, without] = await Promise.all([
358
+ mfaPolicy(), adminFactors(), trustDays(), passkeySignin(),
358
359
  owner.query<{ username: string; is_admin: boolean }>('select username, is_admin from meta.developer d where not exists (select 1 from meta.developer_factor f where f.username = d.username) order by username'),
359
360
  ]);
360
361
  const opt = (v: MfaPolicy) => html`<option value="${v}"${pol.policy === v ? raw(' selected') : ''}>${tx(({ off: 'mfa.policy_off', admins: 'mfa.policy_admins', all: 'mfa.policy_all' } as const)[v])}</option>`;
@@ -364,6 +365,8 @@ export async function policyRegion(s: Session): Promise<Raw> {
364
365
  ${input('grace_days', tx('mfa.grace'), pol.graceDays, { type: 'number', help: tx('mfa.grace_help') })}
365
366
  ${input('trust_days', tx('mfa.trust_days'), days, { type: 'number', help: tx('mfa.trust_days_help') })}
366
367
  </div>
368
+ <div class="field"><label class="check"><input type="checkbox" name="passkey_signin" value="1"${alone ? raw(' checked') : ''}> ${tx('mfa.passkey_signin')}</label>
369
+ <small class="help">${tx('mfa.passkey_signin_help')}</small></div>
367
370
  <p class="muted small">${tx('mfa.policy_help', { enrolled: af.enrolled, admins: af.admins })}</p>
368
371
  <div class="buttons"><button class="btn btn-hot">${tx('mfa.policy_save')}</button></div></form>
369
372
  ${without.rows.length ? html`<p class="small">${tx('mfa.without', { list: without.rows.map((d) => `${d.username}${d.is_admin ? ' (admin)' : ''}`).join(', ') })}</p>` : ''}
package/src/builder/ui.ts CHANGED
@@ -12,7 +12,7 @@ import { ensureDevbar } from '../runtime/devbar.ts';
12
12
  import { noteChange } from './app-updates.ts';
13
13
  import { clientTexts, tr } from './i18n.ts';
14
14
  import { languageMenu, loadLanguage } from './language.ts';
15
- import { enrolmentDue } from '../mfa.ts';
15
+ import { enrolmentDue, requirement } from '../mfa.ts';
16
16
 
17
17
  // Shared building blocks of the builder UI.
18
18
 
@@ -323,9 +323,19 @@ export async function developer(req: Req, reply: FastifyReply) {
323
323
  if (s.state.__BLANG === undefined) await loadLanguage(s);
324
324
  // (sprint 126, SEC-2) the two-factor policy: a banner during the grace period, then only the Security page until they enrol
325
325
  delete s.state.__MFA_DUE;
326
- const due = await enrolmentDue(s.username, await isAdmin(s.username));
326
+ const admin = await isAdmin(s.username);
327
+ // (sprint 128) signed in with the default password while a second factor is required: change it first
328
+ // (on Developers), then the enrolment below
329
+ const weakFirst = !!s.state.__WEAK && (await requirement(s.username, admin)).required;
330
+ if (weakFirst && !/^\/builder\/(developers|logout|language|theme)(?:[/?#]|$)/.test(req.url)) {
331
+ flash(s, tr(s)('mfa.weak_required'), 'error');
332
+ await saveState(s);
333
+ reply.redirect(`${BASE}/developers`);
334
+ return null;
335
+ }
336
+ const due = await enrolmentDue(s.username, admin);
327
337
  if (due?.due === 'grace') s.state.__MFA_DUE = due.until.toISOString();
328
- else if (due && !/^\/builder\/(security|logout|language|theme)(?:[/?]|$)/.test(req.url)) {
338
+ else if (due && !weakFirst && !/^\/builder\/(security|logout|language|theme)(?:[/?]|$)/.test(req.url)) {
329
339
  reply.redirect(`${BASE}/security`);
330
340
  return null;
331
341
  }
@@ -8,6 +8,7 @@ import { html, raw } from '../html.ts';
8
8
  import { icon } from '../icons.ts';
9
9
  import { clientIp, logActivity, type BuilderWorkspace, type Session } from '../session.ts';
10
10
  import { isAdmin } from './locks.ts';
11
+ import { setWorkspaceRequired } from '../mfa.ts';
11
12
  import { back, BANNER_COLORS, BASE, csrf, developer, flash, input, region, select, send, shell, type Req } from './ui.ts';
12
13
 
13
14
  // Workspaces (064; APEX: workspaces). A workspace groups applications and the
@@ -215,7 +216,7 @@ function serviceRegion(s: Session, w: { id: number; message: string | null; bann
215
216
  }
216
217
 
217
218
  /** (093) Workspace preferences: the SQL Workshop and the applications' REST modules on or off. */
218
- function preferencesRegion(s: Session, w: { id: number; sql_workshop: boolean; rest_services: boolean; builder_home: string | null }) {
219
+ function preferencesRegion(s: Session, w: { id: number; sql_workshop: boolean; rest_services: boolean; builder_home: string | null; mfa_required_since: Date | null }, without: string[]) {
219
220
  const check = (name: string, on: boolean, label: string, help: string) =>
220
221
  html`<div class="field"><label class="check"><input type="checkbox" name="${name}" value="true"${on ? raw(' checked') : ''}> ${label}</label><small class="help">${help}</small></div>`;
221
222
  const tx = tr(s);
@@ -223,7 +224,12 @@ function preferencesRegion(s: Session, w: { id: number; sql_workshop: boolean; r
223
224
  ${check('sql_workshop', w.sql_workshop, tx('wsp.sql_on'), tx('wsp.sql_on_help'))}
224
225
  ${check('rest_services', w.rest_services, tx('wsp.rest_on'), tx('wsp.rest_on_help', { path: '/a/<alias>/rest/…' }))}
225
226
  ${select('builder_home', tx('wsp.builder_home'), w.builder_home === 'home' ? 'home' : 'apps', [['apps', tx('wsp.home.apps')], ['home', tx('wsp.home.home')]], tx('wsp.builder_home_help'))}
226
- <div class="buttons"><button class="btn">${tx('wsp.save_prefs')}</button></div></form>`, '', 'preferences');
227
+ <div class="buttons"><button class="btn">${tx('wsp.save_prefs')}</button></div></form>
228
+ <h3 id="mfa">${tx('wsp.mfa')}</h3>
229
+ <form method="post" action="${BASE}/workspaces/${w.id}/mfa">${csrf(s)}
230
+ ${check('required', !!w.mfa_required_since, tx('wsp.mfa_required'), tx('wsp.mfa_required_help'))}
231
+ ${w.mfa_required_since ? html`<p class="small">${tx('wsp.mfa_since', { date: new Date(w.mfa_required_since).toISOString().slice(0, 10) })}${without.length ? html` ${tx('wsp.mfa_without', { list: without.join(', ') })}` : ''}</p>` : ''}
232
+ <div class="buttons"><button class="btn">${tx('wsp.mfa_save')}</button></div></form>`, '', 'preferences');
227
233
  }
228
234
 
229
235
  /** (093) Extension links: links to other tools in the builder's Extensions menu. */
@@ -341,6 +347,10 @@ export async function workspaceRoutes(app: FastifyInstance) {
341
347
  ]);
342
348
  const roles = rolesQ;
343
349
  const links = (await owner.query('select id, label, url, description, position from meta.workspace_link where workspace_id = $1 order by position, lower(label)', [w.id])).rows;
350
+ // (sprint 128) the members still without a second factor
351
+ const without = (await owner.query<{ username: string }>(
352
+ `select m.username from meta.workspace_member m where m.workspace_id = $1
353
+ and not exists (select 1 from meta.developer_factor f where f.username = m.username) order by 1`, [w.id])).rows.map((r) => r.username);
344
354
  const main = html`
345
355
  <div class="title-row"><h1>${w.name}</h1></div>
346
356
  <div class="columns wide-left">
@@ -365,7 +375,7 @@ export async function workspaceRoutes(app: FastifyInstance) {
365
375
  </div>
366
376
  ${serviceRegion(s, w)}
367
377
  ${region(tx('wsp.mail_net'), html`<p class="u-mt0">${tx.rich('wsp.mail_net_text', { mail: html`<a href="${BASE}/workspaces/${w.id}/mail">${tx('wsp.mail_service')}</a>`, network: html`<a href="${BASE}/network?ws=${w.id}">${tx('wsp.network')}</a>`, queue: html`<a href="${BASE}/mail-queue${full ? `?ws=${w.id}` : ''}">${tx('wsp.queue')}</a>` })}</p>`, '', 'mail-network')}
368
- <div class="columns">${preferencesRegion(s, w)}${linksRegion(s, w, links)}</div>
378
+ <div class="columns">${preferencesRegion(s, w, without)}${linksRegion(s, w, links)}</div>
369
379
  ${full ? isolationRegion(s, w, roles.rows) : region(tx('wsp.isolation'), html`<p class="u-mt0">${w.isolated ? tx('wsp.iso_short_on') : tx('wsp.iso_short_off')} ${tx('wsp.admins_change')}</p>`)}
370
380
  ${!full ? '' : region(tx('wsp.applications'), html`<div class="table-wrap"><table class="report report-reflow">
371
381
  <thead><tr><th>${tx('wsp.application')}</th><th>${tx('wsp.move_to')}</th></tr></thead>
@@ -559,6 +569,17 @@ export async function workspaceRoutes(app: FastifyInstance) {
559
569
  return back(reply, s, `${BASE}/workspaces/${req.params.id}`);
560
570
  });
561
571
 
572
+ // (sprint 128, SEC-2) a workspace requires a second factor of its developers (the instance's grace period from now)
573
+ app.post(`${BASE}/workspaces/:id(^\\d{1,9}$)/mfa`, async (req: Req, reply) => {
574
+ const s = await developer(req, reply);
575
+ if (!s || !(await workspaceAdminOnly(s, reply, req.params.id))) return;
576
+ const on = req.body?.required === 'true';
577
+ if (!(await setWorkspaceRequired(Number(req.params.id), on))) return reply.code(404).send('Not found');
578
+ await logActivity({ appId: null, username: s.username, event: '2fa_policy', ip: clientIp(req), detail: `workspace ${req.params.id}: two-factor sign-in ${on ? 'required' : 'not required'}` });
579
+ flash(s, tr(s)(on ? 'wsp.mfa_on' : 'wsp.mfa_off'));
580
+ return back(reply, s, `${BASE}/workspaces/${req.params.id}#mfa`);
581
+ });
582
+
562
583
  // (093) extension links
563
584
  app.post(`${BASE}/workspaces/:id(^\\d{1,9}$)/links`, async (req: Req, reply) => {
564
585
  const s = await developer(req, reply);
package/src/cli/main.ts CHANGED
@@ -477,9 +477,9 @@ const COMMANDS: Record<string, Command> = {
477
477
  'pgkiln admin reset-2fa <developer> [--reason <text>]\n' +
478
478
  'pgkiln admin set-2fa-policy off|admins|all [--grace <days>]\n\n' +
479
479
  "reset-2fa removes the developer's authenticator apps and recovery codes and ends their builder\n" +
480
- 'sessions; they sign in with the password and set up an app again. set-2fa-policy off always works;\n' +
481
- 'admins and all need at least two administrators with two-factor sign-in (or every one, when there\n' +
482
- 'are fewer). Both are written to the activity log and mailed to the administrators.',
480
+ 'sessions; they sign in with the password and set up an app again. set-2fa-policy off always works\n' +
481
+ "(and clears the workspaces' own two-factor rules); admins and all need at least two administrators\n" +
482
+ 'with two-factor sign-in (or every one, when there are fewer). Both are written to the activity log and mailed to the administrators.',
483
483
  options: { reason: { type: 'string' }, grace: { type: 'string' } },
484
484
  optionHelp: [
485
485
  ['--reason <text>', 'reset-2fa: why (in the activity log and the mail)'],
@@ -509,9 +509,11 @@ const COMMANDS: Record<string, Command> = {
509
509
  const policy = arg as 'off' | 'admins' | 'all';
510
510
  if (await mfa.policyProblem(policy)) throw new Error('at least two administrators (or every one, when there are fewer) need two-factor sign-in first');
511
511
  await mfa.setPolicy(policy, grace);
512
- await logActivity({ event: '2fa_policy', ip: 'cli', detail: `${policy}, grace ${grace} days (${by})` });
512
+ // (sprint 128) off means off: no workspace requires a second factor either
513
+ const cleared = policy === 'off' ? await mfa.clearWorkspaceRequirements() : 0;
514
+ await logActivity({ event: '2fa_policy', ip: 'cli', detail: `${policy}, grace ${grace} days${cleared ? `, ${cleared} workspace rule(s) cleared` : ''} (${by})` });
513
515
  const sent = await mfa.notifyMfa(await mfa.adminEmails(), `pgkiln: two-factor sign-in policy is now ${policy}`, `${by} set the two-factor policy to ${policy} on the server.`);
514
- out(`Two-factor policy: ${policy}${policy === 'off' ? '' : `, grace ${grace} days`}.${sent ? ` ${sent} administrator(s) told by mail.` : ''}\n`);
516
+ out(`Two-factor policy: ${policy}${policy === 'off' ? '' : `, grace ${grace} days`}.${cleared ? ` ${cleared} workspace(s) no longer require it.` : ''}${sent ? ` ${sent} administrator(s) told by mail.` : ''}\n`);
515
517
  return EXIT.ok;
516
518
  }
517
519
  throw new UsageError(`unknown action ${action}: use reset-2fa or set-2fa-policy`);
package/src/mfa.ts CHANGED
@@ -11,6 +11,8 @@ import { decryptSecret, encryptSecret } from './secrets.ts';
11
11
  // factors and turns the policy off without signing in (src/cli/main.ts, `pgkiln admin`).
12
12
  // (sprint 127) Passkeys and security keys (WebAuthn, src/webauthn.ts) are the second kind of factor, and a
13
13
  // developer may trust a browser for a number of days (meta.setting mfa_trust_days; 0 = never).
14
+ // (sprint 128) A passkey the authenticator verified the developer with may stand in for both the password and
15
+ // the second factor (mfa_passkey_signin), and a workspace may require a second factor of its developers.
14
16
 
15
17
  export const TOTP_STEP = 30;
16
18
  export const TOTP_DIGITS = 6;
@@ -139,6 +141,26 @@ export async function passkeysOf(username: string, rpId: string): Promise<Passke
139
141
  where username = $1 and kind = 'passkey' and rp_id = $2 order by id`, [username, rpId])).rows;
140
142
  }
141
143
 
144
+ /** (sprint 128) The passkey with this credential id for this relying party, and whose it is (sign-in without a user name). */
145
+ export async function passkeyByCredential(credentialId: string, rpId: string): Promise<(Passkey & { username: string }) | null> {
146
+ if (!credentialId || credentialId.length > 1400) return null;
147
+ return (await owner.one<Passkey & { username: string }>(
148
+ `select id, username, name, credential_id, public_key, alg, sign_count, transports from meta.developer_factor
149
+ where kind = 'passkey' and credential_id = $1 and rp_id = $2`, [credentialId, rpId])) ?? null;
150
+ }
151
+
152
+ /** The user handle a developer's passkeys carry: stable, and not the user name. */
153
+ export const userHandle = (username: string) => createHash('sha256').update(`pgkiln-developer:${username}`).digest().subarray(0, 16).toString('base64url');
154
+
155
+ /** (sprint 128) Whether a passkey alone (user verified) may sign a developer in. */
156
+ export async function passkeySignin(): Promise<boolean> {
157
+ return (await owner.one<{ value: string }>(`select value from meta.setting where name = 'mfa_passkey_signin'`))?.value !== 'off';
158
+ }
159
+
160
+ export async function setPasskeySignin(on: boolean) {
161
+ await owner.query(`insert into meta.setting (name, value) values ('mfa_passkey_signin', $1) on conflict (name) do update set value = excluded.value`, [on ? 'on' : 'off']);
162
+ }
163
+
142
164
  /** Record a sign-in with a passkey; false when another sign-in moved the counter first (a replay or a clone). */
143
165
  export async function usePasskey(id: number, oldCount: string, newCount: number): Promise<boolean> {
144
166
  return !!(await owner.query(
@@ -284,14 +306,47 @@ export async function setPolicy(policy: MfaPolicy, graceDays: number) {
284
306
  });
285
307
  }
286
308
 
309
+ /**
310
+ * (sprint 128) Whether a second factor is required of this developer, by the instance's policy or by one of
311
+ * their workspaces, and since when (the grace period counts from the earliest).
312
+ */
313
+ export async function requirement(username: string, isAdmin: boolean): Promise<{ required: boolean; since: Date | null; graceDays: number; workspaces: string[] }> {
314
+ const [{ policy, since, graceDays }, ws] = await Promise.all([
315
+ mfaPolicy(),
316
+ owner.query<{ name: string; since: Date }>(
317
+ `select w.name, w.mfa_required_since as since from meta.workspace w join meta.workspace_member m on m.workspace_id = w.id
318
+ where m.username = $1 and w.mfa_required_since is not null order by w.mfa_required_since`, [username]),
319
+ ]);
320
+ const byInstance = requiredFor(policy, isAdmin);
321
+ const times = [...(byInstance ? [since ?? new Date()] : []), ...ws.rows.map((r) => new Date(r.since))];
322
+ return {
323
+ required: times.length > 0,
324
+ since: times.length ? new Date(Math.min(...times.map((t) => t.getTime()))) : null,
325
+ graceDays,
326
+ workspaces: ws.rows.map((r) => r.name),
327
+ };
328
+ }
329
+
330
+ /** (sprint 128) Require a second factor of a workspace's developers (from now), or stop requiring it. */
331
+ export async function setWorkspaceRequired(workspaceId: number, on: boolean): Promise<boolean> {
332
+ return !!(await owner.query(
333
+ `update meta.workspace set mfa_required_since = case when $2 then coalesce(mfa_required_since, now()) end where id = $1`,
334
+ [workspaceId, on])).rowCount;
335
+ }
336
+
337
+ /** The command line's "off": no workspace requires a second factor either. */
338
+ export async function clearWorkspaceRequirements(): Promise<number> {
339
+ return (await owner.query('update meta.workspace set mfa_required_since = null where mfa_required_since is not null')).rowCount ?? 0;
340
+ }
341
+
287
342
  /**
288
343
  * A developer without a factor while the policy requires one: 'grace' until the grace period ends (a
289
344
  * banner), then 'enrol' (only the Security page and signing out until they enrol); null otherwise.
290
345
  */
291
346
  export async function enrolmentDue(username: string, isAdmin: boolean): Promise<{ due: 'grace' | 'enrol'; until: Date } | null> {
292
- const { policy, since, graceDays } = await mfaPolicy();
293
- if (!requiredFor(policy, isAdmin) || (await hasFactor(username))) return null;
294
- const until = new Date((since ?? new Date()).getTime() + graceDays * 86_400_000);
347
+ const r = await requirement(username, isAdmin);
348
+ if (!r.required || (await hasFactor(username))) return null;
349
+ const until = new Date((r.since ?? new Date()).getTime() + r.graceDays * 86_400_000);
295
350
  return { due: Date.now() < until.getTime() ? 'grace' : 'enrol', until };
296
351
  }
297
352