pgkiln 0.64.0 → 0.66.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/db/migrations/133_app_user_passkeys_stepup.sql +54 -0
- package/db/migrations/134_idp_mfa.sql +46 -0
- package/docs/apex-feature-parity.md +9 -3
- package/docs/guide/03-builder.md +5 -1
- package/docs/guide/08-security.md +81 -8
- package/docs/guide/12-development.md +10 -4
- package/docs/guide/13-rest-api.md +4 -0
- package/package.json +1 -1
- package/public/app.js +63 -0
- package/public/builder.css +6 -0
- package/public/builder.js +1 -62
- package/src/account-mfa.ts +94 -3
- package/src/api.ts +9 -2
- package/src/builder/api.ts +3 -1
- package/src/builder/components.ts +1 -0
- package/src/builder/i18n/de.ts +13 -0
- package/src/builder/i18n/en.ts +13 -0
- package/src/builder/i18n/es.ts +13 -0
- package/src/builder/i18n/fr.ts +13 -0
- package/src/builder/i18n/nl.ts +13 -0
- package/src/builder/i18n/pt.ts +13 -0
- package/src/builder/login-page.ts +20 -17
- package/src/builder/rest-modules.ts +1 -1
- package/src/builder/routes.ts +24 -8
- package/src/builder/security.ts +3 -19
- package/src/builder/shared.ts +5 -3
- package/src/builder/users.ts +24 -5
- package/src/i18n/ar.ts +31 -0
- package/src/i18n/cs.ts +31 -0
- package/src/i18n/da.ts +31 -0
- package/src/i18n/de.ts +31 -0
- package/src/i18n/el.ts +31 -0
- package/src/i18n/es.ts +31 -0
- package/src/i18n/fi.ts +31 -0
- package/src/i18n/fr.ts +31 -0
- package/src/i18n/he.ts +31 -0
- package/src/i18n/it.ts +31 -0
- package/src/i18n/ja.ts +31 -0
- package/src/i18n/ko.ts +31 -0
- package/src/i18n/nb.ts +31 -0
- package/src/i18n/pl.ts +31 -0
- package/src/i18n/pt.ts +31 -0
- package/src/i18n/ru.ts +31 -0
- package/src/i18n/sv.ts +31 -0
- package/src/i18n/tr.ts +31 -0
- package/src/i18n/uk.ts +31 -0
- package/src/i18n/zh.ts +31 -0
- package/src/i18n.ts +62 -0
- package/src/metadata.ts +8 -2
- package/src/mfa.ts +18 -13
- package/src/runtime/account.ts +6 -3
- package/src/runtime/authz.ts +23 -4
- package/src/runtime/routes.ts +50 -4
- package/src/runtime/two-step.ts +400 -73
- package/src/saml.ts +34 -11
- package/src/sso.ts +72 -11
- package/src/webauthn.ts +20 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
-- ---------------------------------------------------------------------
|
|
2
|
+
-- 133: passkeys, e-mail codes and step-up for application users (sprint
|
|
3
|
+
-- 131, SEC-3 part 2; beyond APEX).
|
|
4
|
+
--
|
|
5
|
+
-- meta.account_factor gets a second kind, 'passkey' (WebAuthn), with the
|
|
6
|
+
-- same columns as a developer's (129): the credential id (base64url, unique),
|
|
7
|
+
-- its public key as a JWK, the COSE algorithm, the signature counter, the
|
|
8
|
+
-- relying party id (host name) it was made for and the transports. A passkey
|
|
9
|
+
-- keeps working without the instance key (it is a public key).
|
|
10
|
+
--
|
|
11
|
+
-- meta.app.mfa_email: at the second step, a user may ask for a one-time code
|
|
12
|
+
-- by e-mail (through the application's mail service) instead of opening their
|
|
13
|
+
-- authenticator app. Only users who have an authenticator app or a passkey
|
|
14
|
+
-- are offered it: an e-mail code is never someone's only factor (SEC-6).
|
|
15
|
+
--
|
|
16
|
+
-- meta.authz_scheme.mfa_minutes (step-up): the scheme passes only when the
|
|
17
|
+
-- session's last second step is at most this many minutes old; a page that
|
|
18
|
+
-- needs it asks for a code (or a passkey) again before it opens. null: no
|
|
19
|
+
-- step-up (the default). Exported with the application.
|
|
20
|
+
-- ---------------------------------------------------------------------
|
|
21
|
+
alter table meta.account_factor drop constraint account_factor_kind_check;
|
|
22
|
+
alter table meta.account_factor
|
|
23
|
+
alter column secret_enc drop not null,
|
|
24
|
+
add column credential_id text unique,
|
|
25
|
+
add column public_key jsonb,
|
|
26
|
+
add column alg int,
|
|
27
|
+
add column sign_count bigint not null default 0,
|
|
28
|
+
add column rp_id text,
|
|
29
|
+
add column transports text[] not null default '{}',
|
|
30
|
+
add constraint account_factor_kind_check check (kind in ('totp', 'passkey')),
|
|
31
|
+
add constraint account_factor_kind_fields check (
|
|
32
|
+
case kind
|
|
33
|
+
when 'totp' then secret_enc is not null
|
|
34
|
+
when 'passkey' then credential_id is not null and public_key is not null and alg is not null and rp_id is not null
|
|
35
|
+
end);
|
|
36
|
+
comment on table meta.account_factor is
|
|
37
|
+
'An application account''s second factors: authenticator apps (TOTP, 132; the secret encrypted with the instance key) and passkeys (WebAuthn, 133; a public key).';
|
|
38
|
+
|
|
39
|
+
alter table meta.app add column mfa_email boolean not null default false;
|
|
40
|
+
comment on column meta.app.mfa_email is
|
|
41
|
+
'Two-step sign-in (133): users with an authenticator app or a passkey may get a one-time code by e-mail instead.';
|
|
42
|
+
|
|
43
|
+
alter table meta.authz_scheme
|
|
44
|
+
add column mfa_minutes int check (mfa_minutes between 1 and 1440);
|
|
45
|
+
comment on column meta.authz_scheme.mfa_minutes is
|
|
46
|
+
'Step-up (133): the scheme passes only when the session''s last second step is at most this many minutes old (null: no step-up).';
|
|
47
|
+
|
|
48
|
+
-- meta.app.api_self_tokens: signed-in users create their own REST API token
|
|
49
|
+
-- on My account. A user the application requires a second step of gets one
|
|
50
|
+
-- only after a second step at most 15 minutes old; the builder's "Issue a
|
|
51
|
+
-- token" refuses such accounts (SEC-3: no token around two-step sign-in).
|
|
52
|
+
alter table meta.app add column api_self_tokens boolean not null default false;
|
|
53
|
+
comment on column meta.app.api_self_tokens is
|
|
54
|
+
'Users create their own REST API token on My account (133); after a recent second step when the app requires one of them.';
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
-- ---------------------------------------------------------------------
|
|
2
|
+
-- 134: make sure an identity provider really did multi-factor sign-in
|
|
3
|
+
-- (sprint 132, SEC-4; beyond APEX, which trusts the IdP's sign-on policy).
|
|
4
|
+
--
|
|
5
|
+
-- meta.auth_provider:
|
|
6
|
+
-- mfa_required refuse a sign-in (to applications and to the builder)
|
|
7
|
+
-- unless the provider says it asked for a second factor:
|
|
8
|
+
-- OpenID Connect through the ID token's amr or acr claim,
|
|
9
|
+
-- SAML through the assertion's AuthnContextClassRef.
|
|
10
|
+
-- mfa_request what to ask the provider for, space separated: OpenID
|
|
11
|
+
-- Connect acr_values, or SAML AuthnContextClassRef values
|
|
12
|
+
-- (a RequestedAuthnContext). null: ask for nothing.
|
|
13
|
+
-- mfa_values the amr / acr / AuthnContextClassRef values that count
|
|
14
|
+
-- as a second factor (any one of them is enough).
|
|
15
|
+
-- local_second_step after a sign-in through this provider, pgkiln asks its
|
|
16
|
+
-- own second step as after a password (the application's
|
|
17
|
+
-- two-step rule; the developer's factor for the builder),
|
|
18
|
+
-- for providers that cannot enforce MFA themselves.
|
|
19
|
+
--
|
|
20
|
+
-- meta.sso_pending.stepup_session: a step-up through the provider (an
|
|
21
|
+
-- authorization scheme with mfa_minutes, 133): the signed-in session the
|
|
22
|
+
-- provider's fresh multi-factor sign-in confirms. The provider is asked to
|
|
23
|
+
-- sign the user in again (prompt=login / max_age=0, SAML ForceAuthn).
|
|
24
|
+
-- ---------------------------------------------------------------------
|
|
25
|
+
alter table meta.auth_provider
|
|
26
|
+
add column mfa_required boolean not null default false,
|
|
27
|
+
add column mfa_request text check (length(mfa_request) <= 500),
|
|
28
|
+
add column mfa_values text[] not null default array[
|
|
29
|
+
'mfa',
|
|
30
|
+
'http://schemas.microsoft.com/claims/multipleauthn',
|
|
31
|
+
'urn:oasis:names:tc:SAML:2.0:ac:classes:MobileTwoFactorContract',
|
|
32
|
+
'urn:oasis:names:tc:SAML:2.0:ac:classes:MobileTwoFactorUnregistered'],
|
|
33
|
+
add column local_second_step boolean not null default false;
|
|
34
|
+
comment on column meta.auth_provider.mfa_required is
|
|
35
|
+
'Refuse sign-ins the provider does not mark as multi-factor (amr / acr, SAML AuthnContextClassRef; 134).';
|
|
36
|
+
comment on column meta.auth_provider.mfa_request is
|
|
37
|
+
'acr_values (OpenID Connect) or AuthnContextClassRef values (SAML) to request, space separated (134).';
|
|
38
|
+
comment on column meta.auth_provider.mfa_values is
|
|
39
|
+
'amr / acr / AuthnContextClassRef values that count as a second factor (134).';
|
|
40
|
+
comment on column meta.auth_provider.local_second_step is
|
|
41
|
+
'After a sign-in through this provider, pgkiln asks its own second step too (134).';
|
|
42
|
+
|
|
43
|
+
alter table meta.sso_pending
|
|
44
|
+
add column stepup_session uuid references meta.session (id) on delete cascade;
|
|
45
|
+
comment on column meta.sso_pending.stepup_session is
|
|
46
|
+
'A step-up through the provider: the signed-in session it confirms (134).';
|
|
@@ -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–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)). Sprint 130: application users too, per application (off, optional, required, required for roles) with authenticator apps and recovery codes, added while signing in or on My account, reset from Users ([chapter 8](guide/08-security.md#two-factor-sign-in)) |
|
|
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)). Sprint 130: application users too, per application (off, optional, required, required for roles) with authenticator apps and recovery codes, added while signing in or on My account, reset from Users; sprint 131: passkeys, e-mail codes as a fallback, step-up per authorization scheme and REST API tokens only after a second step ([chapter 8](guide/08-security.md#two-factor-sign-in)). Sprint 132: pgkiln checks that the identity provider did multi-factor sign-in (`amr` / `acr`, SAML `AuthnContextClassRef`), requests it, steps up through the provider, or asks its own second step after it ([chapter 8](guide/08-security.md#multi-factor-sign-in-at-the-identity-provider)) |
|
|
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) |
|
|
@@ -300,9 +300,15 @@ Small but real differences, for teams comparing the two:
|
|
|
300
300
|
passkey alone), authenticator apps (TOTP), recovery codes, a policy for administrators, all developers or a
|
|
301
301
|
workspace's developers with a grace period, and lockout recovery on the
|
|
302
302
|
command line ([chapter 3](guide/03-builder.md#two-factor-sign-in)). APEX relies on the identity provider.
|
|
303
|
-
- (
|
|
304
|
-
and recovery codes, added while signing in when required
|
|
303
|
+
- (sprints 130–131) **Two-factor sign-in for application users**: per application and per role, with authenticator
|
|
304
|
+
apps, passkeys and recovery codes, added while signing in when required, a code by e-mail as a fallback, step-up
|
|
305
|
+
before sensitive pages (an authorization scheme's *Second step within*) and REST API tokens only after a second step
|
|
306
|
+
([chapter 8](guide/08-security.md#two-factor-sign-in)).
|
|
305
307
|
APEX's application authentication schemes have no second factor.
|
|
308
|
+
- (sprint 132) **Proof that the identity provider did multi-factor sign-in**: a provider can be required to say so in
|
|
309
|
+
the ID token's `amr` / `acr` or the SAML `AuthnContextClassRef`, step-up goes back to it for a fresh multi-factor
|
|
310
|
+
sign-in, and pgkiln's own second step can follow a provider that cannot enforce MFA
|
|
311
|
+
([chapter 8](guide/08-security.md#multi-factor-sign-in-at-the-identity-provider)). APEX trusts the provider's policy.
|
|
306
312
|
- One RLS policy set protects both the web UI and the REST API.
|
|
307
313
|
- An import can be checked before it commits (`pgkiln import --check`): SQL that names a missing
|
|
308
314
|
table or column refuses the deployment instead of failing at run time.
|
package/docs/guide/03-builder.md
CHANGED
|
@@ -138,7 +138,11 @@ started it (in the same browser). The region lists linked identities, with **Unl
|
|
|
138
138
|
developers linked to it; administrators unlink any of them.
|
|
139
139
|
|
|
140
140
|
Signing in through a provider skips pgkiln's own second step and the enrolment on Security: the provider decides on
|
|
141
|
-
the second factor.
|
|
141
|
+
the second factor. (0.66) The provider's *Require multi-factor sign-in at this provider* makes pgkiln check that it
|
|
142
|
+
did (the ID token's `amr` / `acr`, SAML's `AuthnContextClassRef`) and refuse the sign-in otherwise; *Ask pgkiln's own
|
|
143
|
+
second step after this provider* makes a developer with a second factor give it after the provider too
|
|
144
|
+
([chapter 8](08-security.md#multi-factor-sign-in-at-the-identity-provider)). A directory checks a password, so
|
|
145
|
+
pgkiln's second step follows as with the pgkiln password.
|
|
142
146
|
|
|
143
147
|
**Requiring it per workspace.** A workspace's page (*Preferences* → *Single sign-on*) has *This workspace's
|
|
144
148
|
developers must sign in through an identity provider* (or a directory). Its developers' pgkiln password and a passkey
|
|
@@ -244,6 +244,40 @@ psql "$DATABASE_URL" -f examples/keycloak-saml.sql # reads Keycloak's signing
|
|
|
244
244
|
|
|
245
245
|
The HR login page then offers *Sign in with Keycloak (SAML)*, with the same Keycloak users.
|
|
246
246
|
|
|
247
|
+
### Multi-factor sign-in at the identity provider
|
|
248
|
+
|
|
249
|
+
With single sign-on the provider asks for the second factor, in its own sign-on policy (Entra ID Conditional Access,
|
|
250
|
+
Okta and Keycloak authentication flows, OCI IAM sign-on policies). APEX trusts that policy blindly; pgkiln (0.66) can
|
|
251
|
+
check that the provider really did it. On the provider (**Users → Identity providers**), under *Multi-factor sign-in*:
|
|
252
|
+
|
|
253
|
+
| Field | Meaning |
|
|
254
|
+
|---|---|
|
|
255
|
+
| Require multi-factor sign-in at this provider | Refuse every sign-in through this provider, to applications and to the builder, that the provider does not mark as multi-factor. The user sees *The identity provider did not confirm a second factor*; the Activity monitor logs it as a failed sign-in |
|
|
256
|
+
| Ask the provider for | Sent with every sign-in: OpenID Connect `acr_values`, or SAML `AuthnContextClassRef` values (a `RequestedAuthnContext`, comparison *exact*), separated by spaces. Empty: nothing is asked for |
|
|
257
|
+
| Values that mean multi-factor | The values that count, separated by spaces; any one is enough. OpenID Connect: an entry of the ID token's `amr` claim (RFC 8176) or its `acr`. SAML: the assertion's `AuthnContextClassRef` |
|
|
258
|
+
| Ask pgkiln's own second step after this provider | For providers that cannot require MFA themselves: see below |
|
|
259
|
+
|
|
260
|
+
The default values are `mfa` (Entra ID, Okta and Auth0 put it in `amr` after a second factor), Entra ID's SAML
|
|
261
|
+
`http://schemas.microsoft.com/claims/multipleauthn`, and SAML's `MobileTwoFactorContract` and
|
|
262
|
+
`MobileTwoFactorUnregistered`. For Keycloak, name the level of your step-up flow (for example request and accept
|
|
263
|
+
`gold`, or `2` with the default level-of-authentication mapping); for a SAML IdP, the class it sends after a second
|
|
264
|
+
factor. Without *Require*, a provider that does say `mfa` still counts: the session then has a recent second step for
|
|
265
|
+
[step-up](#asking-again-before-a-sensitive-page-step-up), and the Activity monitor shows `sso:<provider> (mfa)`.
|
|
266
|
+
|
|
267
|
+
**Step-up through the provider.** A user who signed in through a provider and opens a page behind an authorization
|
|
268
|
+
scheme with *Second step within (minutes)* goes back to that provider, which is asked to sign them in again
|
|
269
|
+
(OpenID Connect `prompt=login` and `max_age=0`; SAML `ForceAuthn`) with the requested context. The answer must carry
|
|
270
|
+
one of the multi-factor values, a sign-in time (`auth_time`, or the SAML `AuthnInstant`) of the last ten minutes,
|
|
271
|
+
and the identity already linked to the signed-in account; nothing is created or linked on the way. The page then
|
|
272
|
+
opens.
|
|
273
|
+
|
|
274
|
+
**pgkiln's own second step after the provider.** With *Ask pgkiln's own second step after this provider* on, a sign-in
|
|
275
|
+
through the provider is followed by the application's [two-factor rule](#two-factor-sign-in) exactly as after a
|
|
276
|
+
password (users add an app or a passkey on *My account*, or while signing in when the application requires one), and
|
|
277
|
+
step-up asks for that factor instead of going back to the provider. For the builder, a developer with a second factor
|
|
278
|
+
gives it after the provider; one without is treated like a password sign-in, so a two-factor rule of the instance or
|
|
279
|
+
workspace makes them add one.
|
|
280
|
+
|
|
247
281
|
### LDAP and Active Directory
|
|
248
282
|
|
|
249
283
|
Applications can check passwords against **LDAP directories** (OpenLDAP, Active Directory,
|
|
@@ -419,14 +453,15 @@ sign-on policy asks for one, or a hand-written TOTP check in a custom authentica
|
|
|
419
453
|
| Setting | Who gives a code after the password |
|
|
420
454
|
|---|---|
|
|
421
455
|
| **Off** (the default) | Nobody |
|
|
422
|
-
| **Optional** | Users who added an authenticator app on *My account* |
|
|
423
|
-
| **Required** | Everybody. A user without
|
|
456
|
+
| **Optional** | Users who added an authenticator app or a passkey on *My account* |
|
|
457
|
+
| **Required** | Everybody. A user without one adds an app (a QR code, then its first code) or a passkey while signing in, then sees ten recovery codes once |
|
|
424
458
|
| **Required for some roles** | Users with one of the roles in *Required for roles* (for example `admin, manager`); others as with *Optional* |
|
|
425
459
|
|
|
426
460
|
How it works:
|
|
427
461
|
|
|
428
462
|
- The second step follows the user directory's **password** and **LDAP** directories. An identity provider
|
|
429
|
-
([OpenID Connect](#single-sign-on-openid-connect), [SAML](#single-sign-on-saml-20)) asks for its own second factor
|
|
463
|
+
([OpenID Connect](#single-sign-on-openid-connect), [SAML](#single-sign-on-saml-20)) asks for its own second factor,
|
|
464
|
+
which pgkiln can [check](#multi-factor-sign-in-at-the-identity-provider), or leaves it to pgkiln's second step;
|
|
430
465
|
database accounts, custom authentication and HTTP header authentication are not covered.
|
|
431
466
|
- The code comes from any authenticator app (TOTP, RFC 6238: Google Authenticator, Microsoft Authenticator,
|
|
432
467
|
1Password …), each code once, one 30-second step either side for clock drift. A **recovery code** (ten per account,
|
|
@@ -447,8 +482,43 @@ How it works:
|
|
|
447
482
|
the step. Recovery codes are stored as SHA-256 hashes.
|
|
448
483
|
- The **Advisor** warns when users with the role `admin` sign in with only a password.
|
|
449
484
|
|
|
450
|
-
Passkeys
|
|
451
|
-
|
|
485
|
+
#### Passkeys
|
|
486
|
+
|
|
487
|
+
Next to authenticator apps, users add **passkeys** (WebAuthn: a fingerprint, face or screen lock on the device, or a
|
|
488
|
+
security key) on *My account*, or while signing in when the application requires a second step and they have none
|
|
489
|
+
yet (the page offers an app and a passkey). At the second step **Use a passkey** replaces the code. pgkiln checks the
|
|
490
|
+
answer itself (challenge, origin, relying party, signature, counter), as for [developers](03-builder.md#two-factor-sign-in).
|
|
491
|
+
A passkey works only at the host name it was made for (`PUBLIC_URL`'s host, or `PGKILN_RP_ID`), not at an IP address,
|
|
492
|
+
and needs JavaScript; codes keep working without either. Passkeys are public keys: they don't need the instance key.
|
|
493
|
+
The first factor a user adds, app or passkey, brings ten recovery codes.
|
|
494
|
+
|
|
495
|
+
#### A code by e-mail
|
|
496
|
+
|
|
497
|
+
**Offer a code by e-mail** (same settings) adds *E-mail me a code instead* to the second step, for users who have an
|
|
498
|
+
authenticator app or a passkey and an e-mail address in the user directory. The code (six digits, valid 10 minutes,
|
|
499
|
+
once) goes through the application's mail service; at most three per sign-in and five per account per hour. It is
|
|
500
|
+
**never someone's only factor**: it is not offered while a user adds their first one, so a lost mailbox or a mail
|
|
501
|
+
outage never locks anyone out and a stolen mailbox alone never signs anyone in.
|
|
502
|
+
|
|
503
|
+
#### Asking again before a sensitive page (step-up)
|
|
504
|
+
|
|
505
|
+
An authorization scheme's **Second step within (minutes)** makes it pass only when the session's last second step
|
|
506
|
+
(code, passkey, e-mail code or recovery code) is at most that old. A page behind such a scheme sends a user who passes
|
|
507
|
+
the scheme otherwise to *Confirm it's you* (the second-step page) and back to the page afterwards; regions, buttons and
|
|
508
|
+
processes behind it stay hidden or skipped until then. A user without a factor is told to add one on *My account*.
|
|
509
|
+
Typical: `SALARY_EDIT` (role `hr_manager`, second step within 15 minutes) on the page that changes salaries. A
|
|
510
|
+
*Keep me signed in* or shared session has no recent second step of pgkiln's own, so it is asked too; a single sign-on
|
|
511
|
+
session goes back to its [identity provider](#multi-factor-sign-in-at-the-identity-provider) for a new multi-factor
|
|
512
|
+
sign-in (or to pgkiln's own second step, when the provider leaves it to pgkiln).
|
|
513
|
+
A form posted after the time ran out is not processed: the user confirms, comes back to the page and submits again.
|
|
514
|
+
|
|
515
|
+
#### REST API tokens
|
|
516
|
+
|
|
517
|
+
A developer's **REST API → Issue a token** acts as an account without its sign-in, so it is refused for accounts the
|
|
518
|
+
application requires a second step of. Turn on **REST API → Users create their own tokens** and those users (and
|
|
519
|
+
everyone else) create a token on *My account → REST API token* instead; one who must use two-factor sign-in gives a
|
|
520
|
+
second step first when their last one is older than 15 minutes. The token is shown once. MCP has no tokens for
|
|
521
|
+
application users, so nothing to cover there.
|
|
452
522
|
|
|
453
523
|
### What sign-in protects against
|
|
454
524
|
|
|
@@ -458,9 +528,10 @@ two-factor sign-in come in a later release.
|
|
|
458
528
|
time.
|
|
459
529
|
- **Session fixation**: the session token is replaced at sign-in.
|
|
460
530
|
- **Stale sessions**: 60 minutes idle or 8 hours total (configurable).
|
|
461
|
-
- **Stolen passwords**: [two-factor sign-in](#two-factor-sign-in) asks for a code from an authenticator app
|
|
462
|
-
user or for some roles; with an identity provider ([OpenID Connect](#single-sign-on-openid-connect),
|
|
463
|
-
[SAML](#single-sign-on-saml-20)) the provider asks for its own
|
|
531
|
+
- **Stolen passwords**: [two-factor sign-in](#two-factor-sign-in) asks for a code from an authenticator app or a
|
|
532
|
+
passkey, for every user or for some roles, and again before sensitive pages ([step-up](#asking-again-before-a-sensitive-page-step-up)); with an identity provider ([OpenID Connect](#single-sign-on-openid-connect),
|
|
533
|
+
[SAML](#single-sign-on-saml-20)) the provider asks for its own, and pgkiln
|
|
534
|
+
[checks that it did](#multi-factor-sign-in-at-the-identity-provider). Developers have
|
|
464
535
|
[two-factor sign-in](03-builder.md#two-factor-sign-in) for the builder too.
|
|
465
536
|
|
|
466
537
|
All sign-ins, failures and lockouts appear in the **Activity** monitor.
|
|
@@ -480,6 +551,8 @@ Plus the built-in `MUST_NOT_BE_PUBLIC_USER` (signed in). Reference a scheme by n
|
|
|
480
551
|
`authz` property of a page, region, item, button, process, dynamic action or navigation entry;
|
|
481
552
|
prefix it with `!` to negate (`!ADMIN`). Unknown schemes **fail closed** (deny). Each scheme is
|
|
482
553
|
evaluated at most once per request, and its `error_message` is shown when a page is refused.
|
|
554
|
+
With **Second step within (minutes)** it also needs a recent second factor
|
|
555
|
+
([step-up](#asking-again-before-a-sensitive-page-step-up)).
|
|
483
556
|
|
|
484
557
|
What happens when a scheme fails:
|
|
485
558
|
|
|
@@ -63,9 +63,12 @@ src/
|
|
|
63
63
|
customauth.ts custom authentication: the app's function or PL/pgSQL body (a pg_temp function) and post-authentication code, as the app's role
|
|
64
64
|
remember.ts "Keep me signed in": rotating persistent sign-in tokens
|
|
65
65
|
mfa.ts second factors: TOTP, recovery codes, a FactorStore per kind of user (developers, accounts);
|
|
66
|
-
the developers' policy, passkeys, trusted browsers (webauthn.ts: WebAuthn checks
|
|
66
|
+
the developers' policy, passkeys, trusted browsers (webauthn.ts: WebAuthn checks and the
|
|
67
|
+
session's one-time challenge)
|
|
67
68
|
account-mfa.ts (sprint 130) two-factor sign-in for application accounts: the application's rule (off, optional,
|
|
68
|
-
required, roles), secondStep, carriesSecondStep (remembered/shared sign-ins), resets
|
|
69
|
+
required, roles), secondStep, carriesSecondStep (remembered/shared sign-ins), resets;
|
|
70
|
+
(sprint 131) account passkeys, e-mail codes (emailCodeTarget, queueEmailCode), the session's
|
|
71
|
+
last second step (__MFA_AT, secondStepWithin) for step-up, accountMustUseMfa (API tokens)
|
|
69
72
|
workflow.ts workflows: step checks, the runner with parallel branches (NOTIFY + polling), invoke_api steps
|
|
70
73
|
(the call between two transactions, with a lease), Advisor references, the diagram;
|
|
71
74
|
(sprint 114) send_mail and draft steps, switch cases on task outcomes, the diagram as a standalone SVG
|
|
@@ -176,9 +179,12 @@ src/
|
|
|
176
179
|
signin.ts the sign-in form (built-in page and the login region of an application's own login page), safeNext, passwordResetAvailable
|
|
177
180
|
password-reset.ts Forgot password: request (same answer either way), the link (token into the session), the new password
|
|
178
181
|
two-step.ts (sprint 130) the second step of signing in (/login/2fa: a code, or the first app and its recovery
|
|
179
|
-
codes), passwordSignIn, and My account → Two-factor sign-in
|
|
182
|
+
codes), passwordSignIn, and My account → Two-factor sign-in; (sprint 131) passkeys at the
|
|
183
|
+
second step and as the first factor, e-mail codes, step-up (?next=, a signed-in user), My
|
|
184
|
+
account → REST API token (tokenSection)
|
|
180
185
|
context.ts PageContext, dbg()/timed() debug helpers, bind values, substitutions, public error messages, writeOut (streamed responses with back pressure)
|
|
181
|
-
authz.ts authorization schemes, conditions, visibility (menu requests count as buttons)
|
|
186
|
+
authz.ts authorization schemes, conditions, visibility (menu requests count as buttons); (sprint 131)
|
|
187
|
+
step-up (mfa_minutes; StepUp sends the page's user to /login/2fa?next=)
|
|
182
188
|
engine.ts form fetch, validations, processes (conditions, execution chains, queueing background chains; web requests queued by an sql process are made right after it), application processes
|
|
183
189
|
processes.ts download (file or zip from a query, safe headers), workflow processes, configuration checks of chains
|
|
184
190
|
logic.ts computations (the page's and (102) the application's), branches (page, URL, function returning a URL, another application) and their conditions
|
|
@@ -362,6 +362,10 @@ curl https://api.example.com/employees -H "Authorization: Bearer eyJ…"
|
|
|
362
362
|
Under **REST API → Issue a token**, pick an account and a lifetime (1 hour to 30 days). The token
|
|
363
363
|
is shown once and not stored. Issuing is logged in the activity log (`api_token`).
|
|
364
364
|
|
|
365
|
+
An account the application requires two-factor sign-in of gets no token here: with **Users create their
|
|
366
|
+
own tokens** on, it creates one on *My account → REST API token* after a recent second step
|
|
367
|
+
([chapter 8](08-security.md#rest-api-tokens)).
|
|
368
|
+
|
|
365
369
|
Such a token contains `role`, `app` and `app_user`, but **no roles**. `meta.has_role()` reads the
|
|
366
370
|
account's roles in the application at every request, and `meta.api_check()` rejects the token as
|
|
367
371
|
soon as the account is deactivated or loses access. Changing `API_JWT_SECRET` (in pgkiln and
|
package/package.json
CHANGED
package/public/app.js
CHANGED
|
@@ -4299,3 +4299,66 @@ document.addEventListener('change', (e) => {
|
|
|
4299
4299
|
}
|
|
4300
4300
|
});
|
|
4301
4301
|
})();
|
|
4302
|
+
|
|
4303
|
+
// (sprint 127, moved here from builder.js in sprint 131) Passkeys, for developers (the builder loads app.js too) and
|
|
4304
|
+
// application users: a [data-passkey] button asks the server (its URL) for WebAuthn options, calls
|
|
4305
|
+
// navigator.credentials.create() (Security, My account, adding one while signing in) or .get() (the second step, a
|
|
4306
|
+
// passkey alone on the builder's sign-in page) and posts the answer in its own form, to data-passkey-submit when set.
|
|
4307
|
+
// Without WebAuthn or JavaScript the buttons stay hidden; codes keep working.
|
|
4308
|
+
(() => {
|
|
4309
|
+
'use strict';
|
|
4310
|
+
if (!window.PublicKeyCredential || !navigator.credentials) return;
|
|
4311
|
+
const b64 = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
4312
|
+
const bytes = (s) => Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/') + '==='.slice((s.length + 3) % 4)), (c) => c.charCodeAt(0));
|
|
4313
|
+
for (const btn of document.querySelectorAll('[data-passkey]')) {
|
|
4314
|
+
const form = btn.closest('form');
|
|
4315
|
+
if (!form) continue;
|
|
4316
|
+
btn.hidden = false;
|
|
4317
|
+
btn.addEventListener('click', async (e) => {
|
|
4318
|
+
e.preventDefault();
|
|
4319
|
+
const status = form.querySelector('[data-passkey-status]');
|
|
4320
|
+
const set = (name, value) => {
|
|
4321
|
+
const field = form.querySelector(`[name="${name}"]`);
|
|
4322
|
+
if (field) field.value = value;
|
|
4323
|
+
};
|
|
4324
|
+
btn.disabled = true;
|
|
4325
|
+
if (status) status.textContent = '';
|
|
4326
|
+
try {
|
|
4327
|
+
const res = await fetch(btn.dataset.passkey, {
|
|
4328
|
+
method: 'POST', credentials: 'same-origin', headers: { accept: 'application/json' },
|
|
4329
|
+
body: new URLSearchParams({ __csrf: form.querySelector('[name="__csrf"]').value }),
|
|
4330
|
+
});
|
|
4331
|
+
if (!res.ok) throw new Error(String(res.status));
|
|
4332
|
+
const o = await res.json();
|
|
4333
|
+
if (o.mode === 'create') {
|
|
4334
|
+
const c = await navigator.credentials.create({ publicKey: {
|
|
4335
|
+
challenge: bytes(o.challenge), rp: o.rp,
|
|
4336
|
+
user: { id: bytes(o.user.id), name: o.user.name, displayName: o.user.displayName },
|
|
4337
|
+
pubKeyCredParams: o.algs.map((alg) => ({ type: 'public-key', alg })),
|
|
4338
|
+
excludeCredentials: o.exclude.map((id) => ({ type: 'public-key', id: bytes(id) })),
|
|
4339
|
+
authenticatorSelection: { residentKey: 'preferred', userVerification: 'preferred' },
|
|
4340
|
+
attestation: 'none', timeout: 120000,
|
|
4341
|
+
} });
|
|
4342
|
+
set('clientDataJSON', b64(c.response.clientDataJSON));
|
|
4343
|
+
set('attestationObject', b64(c.response.attestationObject));
|
|
4344
|
+
set('transports', (c.response.getTransports ? c.response.getTransports() : []).join(','));
|
|
4345
|
+
} else {
|
|
4346
|
+
const c = await navigator.credentials.get({ publicKey: {
|
|
4347
|
+
challenge: bytes(o.challenge), rpId: o.rpId, userVerification: o.uv || 'preferred', timeout: 120000,
|
|
4348
|
+
allowCredentials: o.allow.map((a) => ({ type: 'public-key', id: bytes(a.id), transports: a.transports })),
|
|
4349
|
+
} });
|
|
4350
|
+
set('credentialId', b64(c.rawId));
|
|
4351
|
+
set('clientDataJSON', b64(c.response.clientDataJSON));
|
|
4352
|
+
set('authenticatorData', b64(c.response.authenticatorData));
|
|
4353
|
+
set('signature', b64(c.response.signature));
|
|
4354
|
+
if (c.response.userHandle) set('userHandle', b64(c.response.userHandle));
|
|
4355
|
+
}
|
|
4356
|
+
if (btn.dataset.passkeySubmit) form.action = btn.dataset.passkeySubmit;
|
|
4357
|
+
form.submit();
|
|
4358
|
+
} catch (err) {
|
|
4359
|
+
btn.disabled = false;
|
|
4360
|
+
if (status) status.textContent = btn.dataset.passkeyError || String(err);
|
|
4361
|
+
}
|
|
4362
|
+
});
|
|
4363
|
+
}
|
|
4364
|
+
})();
|
package/public/builder.css
CHANGED
|
@@ -1576,3 +1576,9 @@ pre.wrap-pre { white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
|
1576
1576
|
.mfa-steps { padding-left: 1.25rem; }
|
|
1577
1577
|
.mfa-codes { columns: 2; column-gap: 2rem; padding-left: 1.5rem; font-size: 1.05em; }
|
|
1578
1578
|
@media (max-width: 480px) { .mfa-codes { columns: 1; } }
|
|
1579
|
+
|
|
1580
|
+
/* Settings → Sign-in: a checkbox under a grid of fields gets room, and long help stays readable */
|
|
1581
|
+
.settings-panel[data-tab="signin"] .form-grid + .field { margin-top: 0.75rem; }
|
|
1582
|
+
.settings-panel[data-tab="signin"] .field > .help,
|
|
1583
|
+
.settings-panel[data-tab="signin"] p.small { max-width: 75ch; }
|
|
1584
|
+
.settings-panel[data-tab="signin"] h3:not(:first-child) { margin-top: 1.5rem; }
|
package/public/builder.js
CHANGED
|
@@ -1334,65 +1334,4 @@ document.querySelectorAll('input[data-filter-tree]').forEach((el) => {
|
|
|
1334
1334
|
}
|
|
1335
1335
|
})();
|
|
1336
1336
|
|
|
1337
|
-
// (sprint 127) Passkeys:
|
|
1338
|
-
// navigator.credentials.create() (Security page) or .get() (the second sign-in step) and posts the answer in
|
|
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.
|
|
1341
|
-
// Without WebAuthn or JavaScript the buttons stay hidden; codes keep working.
|
|
1342
|
-
(() => {
|
|
1343
|
-
'use strict';
|
|
1344
|
-
if (!window.PublicKeyCredential || !navigator.credentials) return;
|
|
1345
|
-
const b64 = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
1346
|
-
const bytes = (s) => Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/') + '==='.slice((s.length + 3) % 4)), (c) => c.charCodeAt(0));
|
|
1347
|
-
for (const btn of document.querySelectorAll('[data-passkey]')) {
|
|
1348
|
-
const form = btn.closest('form');
|
|
1349
|
-
if (!form) continue;
|
|
1350
|
-
btn.hidden = false;
|
|
1351
|
-
btn.addEventListener('click', async (e) => {
|
|
1352
|
-
e.preventDefault();
|
|
1353
|
-
const status = form.querySelector('[data-passkey-status]');
|
|
1354
|
-
const set = (name, value) => {
|
|
1355
|
-
const field = form.querySelector(`[name="${name}"]`);
|
|
1356
|
-
if (field) field.value = value;
|
|
1357
|
-
};
|
|
1358
|
-
btn.disabled = true;
|
|
1359
|
-
if (status) status.textContent = '';
|
|
1360
|
-
try {
|
|
1361
|
-
const res = await fetch(btn.dataset.passkey, {
|
|
1362
|
-
method: 'POST', credentials: 'same-origin', headers: { accept: 'application/json' },
|
|
1363
|
-
body: new URLSearchParams({ __csrf: form.querySelector('[name="__csrf"]').value }),
|
|
1364
|
-
});
|
|
1365
|
-
if (!res.ok) throw new Error(String(res.status));
|
|
1366
|
-
const o = await res.json();
|
|
1367
|
-
if (o.mode === 'create') {
|
|
1368
|
-
const c = await navigator.credentials.create({ publicKey: {
|
|
1369
|
-
challenge: bytes(o.challenge), rp: o.rp,
|
|
1370
|
-
user: { id: bytes(o.user.id), name: o.user.name, displayName: o.user.displayName },
|
|
1371
|
-
pubKeyCredParams: o.algs.map((alg) => ({ type: 'public-key', alg })),
|
|
1372
|
-
excludeCredentials: o.exclude.map((id) => ({ type: 'public-key', id: bytes(id) })),
|
|
1373
|
-
authenticatorSelection: { residentKey: 'preferred', userVerification: 'preferred' },
|
|
1374
|
-
attestation: 'none', timeout: 120000,
|
|
1375
|
-
} });
|
|
1376
|
-
set('clientDataJSON', b64(c.response.clientDataJSON));
|
|
1377
|
-
set('attestationObject', b64(c.response.attestationObject));
|
|
1378
|
-
set('transports', (c.response.getTransports ? c.response.getTransports() : []).join(','));
|
|
1379
|
-
} else {
|
|
1380
|
-
const c = await navigator.credentials.get({ publicKey: {
|
|
1381
|
-
challenge: bytes(o.challenge), rpId: o.rpId, userVerification: o.uv || 'preferred', timeout: 120000,
|
|
1382
|
-
allowCredentials: o.allow.map((a) => ({ type: 'public-key', id: bytes(a.id), transports: a.transports })),
|
|
1383
|
-
} });
|
|
1384
|
-
set('credentialId', b64(c.rawId));
|
|
1385
|
-
set('clientDataJSON', b64(c.response.clientDataJSON));
|
|
1386
|
-
set('authenticatorData', b64(c.response.authenticatorData));
|
|
1387
|
-
set('signature', b64(c.response.signature));
|
|
1388
|
-
if (c.response.userHandle) set('userHandle', b64(c.response.userHandle));
|
|
1389
|
-
}
|
|
1390
|
-
if (btn.dataset.passkeySubmit) form.action = btn.dataset.passkeySubmit;
|
|
1391
|
-
form.submit();
|
|
1392
|
-
} catch (err) {
|
|
1393
|
-
btn.disabled = false;
|
|
1394
|
-
if (status) status.textContent = btn.dataset.passkeyError || String(err);
|
|
1395
|
-
}
|
|
1396
|
-
});
|
|
1397
|
-
}
|
|
1398
|
-
})();
|
|
1337
|
+
// (sprint 127) Passkeys: the [data-passkey] buttons are handled by app.js (sprint 131), which every builder page loads too.
|
package/src/account-mfa.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
import { createHash, randomInt, timingSafeEqual } from 'node:crypto';
|
|
1
2
|
import { owner } from './db.ts';
|
|
2
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
ACCOUNTS, storeAddPasskey, storeAddTotp, storeCheck, storeCodesLeft, storeConfirmCodes, storeHasFactor, storeNewCodes, storePasskeys,
|
|
5
|
+
storeUsePasskey,
|
|
6
|
+
} from './mfa.ts';
|
|
3
7
|
|
|
4
8
|
// (sprint 130, SEC-3) Two-step sign-in for application users, beyond APEX (whose application authentication has no
|
|
5
9
|
// second factor). An account's authenticator apps and recovery codes (meta.account_factor, account_recovery_code,
|
|
@@ -7,6 +11,9 @@ import { ACCOUNTS, storeAddTotp, storeCheck, storeCodesLeft, storeConfirmCodes,
|
|
|
7
11
|
// off, optional (users who added an app give a code), required, or required for some roles. The second step follows
|
|
8
12
|
// the user directory's passwords and LDAP directories; an identity provider decides on its own second factor (SEC-4).
|
|
9
13
|
// A remembered or shared sign-in carries its method: only one that ends in "+2fa" stands in for the second step.
|
|
14
|
+
// (sprint 131, SEC-3 part 2) Passkeys next to authenticator apps; a code by e-mail for users who have one of those
|
|
15
|
+
// (never someone's only factor, SEC-6); step-up: an authorization scheme with mfa_minutes passes only when the session's
|
|
16
|
+
// last second step (session state __MFA_AT) is that recent; and REST API tokens a user creates on My account.
|
|
10
17
|
|
|
11
18
|
export type AppMfa = 'off' | 'optional' | 'required' | 'roles';
|
|
12
19
|
export const APP_MFA: AppMfa[] = ['off', 'optional', 'required', 'roles'];
|
|
@@ -55,10 +62,11 @@ export async function carriesSecondStep(a: MfaApp, username: string, roles: stri
|
|
|
55
62
|
return (await secondStep(a, username, roles)) === null;
|
|
56
63
|
}
|
|
57
64
|
|
|
58
|
-
export interface AccountFactor { id: number; name: string; created_at: string; last_used_at: string | null }
|
|
65
|
+
export interface AccountFactor { id: number; kind: 'totp' | 'passkey'; name: string; created_at: string; last_used_at: string | null; rp_id: string | null }
|
|
59
66
|
|
|
60
67
|
export async function accountFactors(id: number): Promise<AccountFactor[]> {
|
|
61
|
-
return (await owner.query<AccountFactor>(
|
|
68
|
+
return (await owner.query<AccountFactor>(
|
|
69
|
+
'select id, kind, name, created_at, last_used_at, rp_id from meta.account_factor where account_id = $1 order by kind desc, id', [id])).rows;
|
|
62
70
|
}
|
|
63
71
|
|
|
64
72
|
export const accountHasFactor = (id: number) => storeHasFactor(ACCOUNTS, id);
|
|
@@ -67,6 +75,89 @@ export const addAccountTotp = (id: number, name: string, secret: string, step: n
|
|
|
67
75
|
export const newAccountCodes = (id: number) => storeNewCodes(ACCOUNTS, id);
|
|
68
76
|
export const confirmAccountCodes = (id: number) => storeConfirmCodes(ACCOUNTS, id);
|
|
69
77
|
export const checkAccountCode = (id: number, code: string) => storeCheck(ACCOUNTS, id, code);
|
|
78
|
+
export const addAccountPasskey = (id: number, name: string, p: Parameters<typeof storeAddPasskey>[3]) => storeAddPasskey(ACCOUNTS, id, name, p);
|
|
79
|
+
export const accountPasskeys = (id: number, rpId: string) => storePasskeys(ACCOUNTS, id, rpId);
|
|
80
|
+
export const useAccountPasskey = (factorId: number, oldCount: string, newCount: number) => storeUsePasskey(ACCOUNTS, factorId, oldCount, newCount);
|
|
81
|
+
/** The user handle an account's passkeys carry: stable, and not the user name. */
|
|
82
|
+
export const accountUserHandle = (id: number) => createHash('sha256').update(`pgkiln-account:${id}`).digest().subarray(0, 16).toString('base64url');
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------- step-up (sprint 131)
|
|
85
|
+
|
|
86
|
+
/** Session state: when (ms) the session last had a second step (signing in with one, or asked again). */
|
|
87
|
+
export const MFA_AT = '__MFA_AT';
|
|
88
|
+
export const markSecondStep = (state: Record<string, unknown>) => { state[MFA_AT] = Date.now(); };
|
|
89
|
+
/** Whether the session had a second step at most `minutes` ago. */
|
|
90
|
+
export function secondStepWithin(state: Record<string, unknown>, minutes: number): boolean {
|
|
91
|
+
const at = Number(state[MFA_AT]);
|
|
92
|
+
return Number.isFinite(at) && at > 0 && Date.now() - at <= minutes * 60_000;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------- e-mail codes (sprint 131)
|
|
96
|
+
|
|
97
|
+
export const EMAIL_CODE_MINUTES = 10;
|
|
98
|
+
/** Codes one account may get by e-mail per hour (every application together). */
|
|
99
|
+
export const EMAIL_CODES_PER_HOUR = 5;
|
|
100
|
+
export const newEmailCode = () => String(randomInt(0, 1_000_000)).padStart(6, '0');
|
|
101
|
+
export const emailCodeHash = (code: string) => createHash('sha256').update(`pgkiln-mail-code:${code}`).digest('hex');
|
|
102
|
+
export function emailCodeMatches(hash: string | undefined, code: string): boolean {
|
|
103
|
+
const digits = code.replace(/\s/g, '');
|
|
104
|
+
if (!hash || !/^\d{6}$/.test(digits)) return false;
|
|
105
|
+
return timingSafeEqual(Buffer.from(emailCodeHash(digits)), Buffer.from(hash));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Where an e-mail code would go for this account in this application: the account's address and the application's
|
|
110
|
+
* mail service, or null (not offered). Only for an application that allows it and an account with another factor.
|
|
111
|
+
*/
|
|
112
|
+
export async function emailCodeTarget(a: { id: number; mfa_email?: boolean | null }, accountIdValue: number): Promise<{ to: string; from: string } | null> {
|
|
113
|
+
if (!a.mfa_email || !(await storeHasFactor(ACCOUNTS, accountIdValue))) return null;
|
|
114
|
+
const r = await owner.one<{ email: string | null; from_address: string | null; ok: boolean }>(
|
|
115
|
+
`select ac.email, ams.from_address, coalesce(s.enabled and meta.mail_sender_allowed(s.senders, ams.from_address), false) as ok
|
|
116
|
+
from meta.account ac
|
|
117
|
+
left join meta.app_mail_service ams on ams.app_id = $2
|
|
118
|
+
left join meta.mail_service s on s.id = ams.service_id
|
|
119
|
+
where ac.id = $1 and ac.active`, [accountIdValue, a.id]);
|
|
120
|
+
const to = r?.email?.trim();
|
|
121
|
+
return r?.ok && to && r.from_address?.trim() ? { to, from: r.from_address.trim() } : null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Queue a sign-in code mail (the application's mail service, its daily limit). Returns false when the account had
|
|
126
|
+
* too many this hour or the limit is reached; the caller says nothing more specific (no hint about the address).
|
|
127
|
+
*/
|
|
128
|
+
export async function queueEmailCode(a: { id: number }, username: string, target: { to: string; from: string }, subject: string, text: string): Promise<boolean> {
|
|
129
|
+
return owner.tx(async (c) => {
|
|
130
|
+
const recent = (await c.query<{ n: number }>(
|
|
131
|
+
`select count(*)::int as n from meta.mail_queue where template = 'SIGNIN_CODE' and lower(username) = lower($1) and created_at > now() - interval '1 hour'`,
|
|
132
|
+
[username])).rows[0].n;
|
|
133
|
+
if (recent >= EMAIL_CODES_PER_HOUR) return false;
|
|
134
|
+
const over = (await c.query<{ over: boolean }>(
|
|
135
|
+
`select ams.max_per_day is not null and (select count(*) from meta.mail_queue q where q.app_id = $1
|
|
136
|
+
and q.created_at >= date_trunc('day', now() at time zone 'UTC') at time zone 'UTC' and q.status <> 'cancelled') >= ams.max_per_day as over
|
|
137
|
+
from meta.app_mail_service ams where ams.app_id = $1`, [a.id])).rows[0]?.over;
|
|
138
|
+
if (over) return false;
|
|
139
|
+
const id = (await c.query<{ id: string }>(
|
|
140
|
+
`insert into meta.mail_queue (app_id, username, from_address, to_addresses, subject, body_text, template)
|
|
141
|
+
values ($1, $2, $3, array[$4], left($5, 998), $6, 'SIGNIN_CODE') returning id`,
|
|
142
|
+
[a.id, username, target.from, target.to, subject.replace(/[\r\n]+/g, ' '), text])).rows[0].id;
|
|
143
|
+
await c.query(`select pg_notify('pgkiln_mail', $1)`, [String(id)]);
|
|
144
|
+
return true;
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ---------------------------------------------------------------- REST API tokens (sprint 131)
|
|
149
|
+
|
|
150
|
+
/** Minutes a second step may be old for a user to create their own API token (when the app requires one of them). */
|
|
151
|
+
export const TOKEN_STEP_MINUTES = 15;
|
|
152
|
+
|
|
153
|
+
/** Whether the application requires a second step of this account (by its roles in the application). */
|
|
154
|
+
export async function accountMustUseMfa(a: MfaApp & { id: number }, username: string): Promise<boolean> {
|
|
155
|
+
if (!mfaOn(a)) return false;
|
|
156
|
+
const roles = (await owner.query<{ roles: string[] }>(
|
|
157
|
+
`select aa.roles from meta.app_access aa join meta.account ac on ac.id = aa.account_id where aa.app_id = $1 and lower(ac.username) = lower($2)`,
|
|
158
|
+
[a.id, username])).rows[0]?.roles ?? [];
|
|
159
|
+
return mfaRequired(a, roles);
|
|
160
|
+
}
|
|
70
161
|
|
|
71
162
|
/** Remove one of the account's authenticator apps (and the recovery codes with the last one). */
|
|
72
163
|
export async function removeAccountFactor(id: number, factorId: number): Promise<boolean> {
|