pgkiln 0.63.0 → 0.65.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.
Files changed (59) hide show
  1. package/SECURITY.md +2 -0
  2. package/db/migrations/132_app_user_mfa.sql +58 -0
  3. package/db/migrations/133_app_user_passkeys_stepup.sql +54 -0
  4. package/docs/apex-feature-parity.md +6 -1
  5. package/docs/guide/08-security.md +81 -5
  6. package/docs/guide/09-reference.md +5 -1
  7. package/docs/guide/11-from-apex.md +2 -1
  8. package/docs/guide/12-development.md +13 -1
  9. package/docs/guide/13-rest-api.md +4 -0
  10. package/package.json +1 -1
  11. package/public/app.css +10 -0
  12. package/public/app.js +63 -0
  13. package/public/builder.js +1 -62
  14. package/src/account-mfa.ts +184 -0
  15. package/src/api.ts +9 -2
  16. package/src/builder/advisor.ts +8 -0
  17. package/src/builder/api.ts +3 -1
  18. package/src/builder/components.ts +1 -0
  19. package/src/builder/i18n/de.ts +6 -0
  20. package/src/builder/i18n/en.ts +6 -0
  21. package/src/builder/i18n/es.ts +6 -0
  22. package/src/builder/i18n/fr.ts +6 -0
  23. package/src/builder/i18n/nl.ts +6 -0
  24. package/src/builder/i18n/pt.ts +6 -0
  25. package/src/builder/login-page.ts +32 -3
  26. package/src/builder/rest-modules.ts +1 -1
  27. package/src/builder/security.ts +3 -19
  28. package/src/builder/shared.ts +5 -3
  29. package/src/builder/users.ts +34 -3
  30. package/src/i18n/ar.ts +65 -0
  31. package/src/i18n/cs.ts +65 -0
  32. package/src/i18n/da.ts +65 -0
  33. package/src/i18n/de.ts +65 -0
  34. package/src/i18n/el.ts +65 -0
  35. package/src/i18n/es.ts +65 -0
  36. package/src/i18n/fi.ts +65 -0
  37. package/src/i18n/fr.ts +65 -0
  38. package/src/i18n/he.ts +65 -0
  39. package/src/i18n/it.ts +65 -0
  40. package/src/i18n/ja.ts +65 -0
  41. package/src/i18n/ko.ts +65 -0
  42. package/src/i18n/nb.ts +65 -0
  43. package/src/i18n/pl.ts +65 -0
  44. package/src/i18n/pt.ts +65 -0
  45. package/src/i18n/ru.ts +65 -0
  46. package/src/i18n/sv.ts +65 -0
  47. package/src/i18n/tr.ts +65 -0
  48. package/src/i18n/uk.ts +65 -0
  49. package/src/i18n/zh.ts +65 -0
  50. package/src/i18n.ts +130 -0
  51. package/src/metadata.ts +11 -2
  52. package/src/mfa.ts +94 -65
  53. package/src/remember.ts +1 -1
  54. package/src/runtime/account.ts +7 -1
  55. package/src/runtime/authz.ts +23 -4
  56. package/src/runtime/routes.ts +21 -7
  57. package/src/runtime/two-step.ts +702 -0
  58. package/src/sharedlogin.ts +1 -1
  59. package/src/webauthn.ts +20 -0
package/src/mfa.ts CHANGED
@@ -94,6 +94,75 @@ export function newRecoveryCode(): string {
94
94
  export const normaliseCode = (code: string) => code.toLowerCase().replace(/[^a-z0-9]/g, '');
95
95
  export const hashCode = (code: string) => createHash('sha256').update(normaliseCode(code)).digest('hex');
96
96
 
97
+ // ---------------------------------------------------------------- where factors are kept
98
+
99
+ /**
100
+ * (sprint 130, SEC-3) The tables of one kind of user: developers (128, by user name) or application accounts
101
+ * (132, by account id). The code checks, recovery codes and the "each step once" rule are the same for both.
102
+ */
103
+ export interface FactorStore { factors: string; codes: string; key: string }
104
+ export const DEVELOPERS: FactorStore = { factors: 'meta.developer_factor', codes: 'meta.developer_recovery_code', key: 'username' };
105
+ export const ACCOUNTS: FactorStore = { factors: 'meta.account_factor', codes: 'meta.account_recovery_code', key: 'account_id' };
106
+ type Who = string | number;
107
+
108
+ export async function storeCodesLeft(st: FactorStore, who: Who): Promise<number> {
109
+ return (await owner.one<{ n: number }>(`select count(*)::int as n from ${st.codes} where ${st.key} = $1 and confirmed and used_at is null`, [who]))!.n;
110
+ }
111
+
112
+ export async function storeHasFactor(st: FactorStore, who: Who): Promise<boolean> {
113
+ return !!(await owner.one(`select 1 as ok from ${st.factors} where ${st.key} = $1`, [who]));
114
+ }
115
+
116
+ export async function storeAddTotp(st: FactorStore, who: Who, name: string, secret: string, step: number) {
117
+ await owner.query(
118
+ `insert into ${st.factors} (${st.key}, kind, name, secret_enc, last_step, last_used_at) values ($1, 'totp', $2, $3, $4, now())`,
119
+ [who, name.trim().slice(0, 60) || 'Authenticator', encryptSecret(secret), step],
120
+ );
121
+ }
122
+
123
+ export async function storeNewCodes(st: FactorStore, who: Who): Promise<string[]> {
124
+ const codes = Array.from({ length: RECOVERY_CODES }, newRecoveryCode);
125
+ await owner.tx(async (c) => {
126
+ await c.query(`delete from ${st.codes} where ${st.key} = $1`, [who]);
127
+ for (const code of codes) await c.query(`insert into ${st.codes} (${st.key}, code_hash) values ($1, $2)`, [who, hashCode(code)]);
128
+ });
129
+ return codes;
130
+ }
131
+
132
+ export async function storeConfirmCodes(st: FactorStore, who: Who) {
133
+ await owner.query(`update ${st.codes} set confirmed = true where ${st.key} = $1 and used_at is null`, [who]);
134
+ }
135
+
136
+ /**
137
+ * Check a second-factor code: an authenticator code (each step once) or a confirmed, unused recovery code
138
+ * (then used up). Returns what matched, or null.
139
+ */
140
+ export async function storeCheck(st: FactorStore, who: Who, code: string): Promise<{ kind: 'totp' | 'recovery'; name?: string } | null> {
141
+ const digits = code.replace(/\s/g, '');
142
+ if (/^\d{6}$/.test(digits)) {
143
+ const rows = (await owner.query<{ id: number; name: string; secret_enc: string; last_step: string | null }>(
144
+ `select id, name, secret_enc, last_step from ${st.factors} where ${st.key} = $1 and kind = 'totp'`, [who])).rows;
145
+ for (const f of rows) {
146
+ let secret: Buffer;
147
+ try {
148
+ secret = unbase32(decryptSecret(f.secret_enc));
149
+ } catch {
150
+ continue; // the instance key changed: recovery codes still work
151
+ }
152
+ const step = matchTotp(secret, digits, f.last_step === null ? null : Number(f.last_step));
153
+ // the update only wins once per step, also with two requests at the same time
154
+ if (step !== null && (await owner.query(`update ${st.factors} set last_step = $2, last_used_at = now() where id = $1 and (last_step is null or last_step < $2)`, [f.id, step])).rowCount)
155
+ return { kind: 'totp', name: f.name };
156
+ }
157
+ return null;
158
+ }
159
+ if (normaliseCode(code).length !== 10) return null;
160
+ const used = await owner.query(
161
+ `update ${st.codes} set used_at = now() where ${st.key} = $1 and code_hash = $2 and confirmed and used_at is null`,
162
+ [who, hashCode(code)]);
163
+ return used.rowCount ? { kind: 'recovery' } : null;
164
+ }
165
+
97
166
  // ---------------------------------------------------------------- the developer's factors
98
167
 
99
168
  export interface Factor { id: number; kind: 'totp' | 'passkey'; name: string; created_at: string; last_used_at: string | null; rp_id: string | null }
@@ -103,44 +172,45 @@ export async function factorsOf(username: string, db: Pick<pg.ClientBase, 'query
103
172
  }
104
173
 
105
174
  /** Recovery codes left (confirmed, unused). */
106
- export async function codesLeft(username: string): Promise<number> {
107
- return (await owner.one<{ n: number }>('select count(*)::int as n from meta.developer_recovery_code where username = $1 and confirmed and used_at is null', [username]))!.n;
108
- }
175
+ export const codesLeft = (username: string) => storeCodesLeft(DEVELOPERS, username);
109
176
 
110
177
  /** Whether the developer has a working second factor (an authenticator app; recovery codes back it up). */
111
- export async function hasFactor(username: string): Promise<boolean> {
112
- return !!(await owner.one('select 1 as ok from meta.developer_factor where username = $1', [username]));
113
- }
178
+ export const hasFactor = (username: string) => storeHasFactor(DEVELOPERS, username);
114
179
 
115
180
  /** Add an authenticator app (after the developer confirmed a code from it). */
116
- export async function addTotp(username: string, name: string, secret: string, step: number) {
117
- await owner.query(
118
- `insert into meta.developer_factor (username, kind, name, secret_enc, last_step, last_used_at) values ($1, 'totp', $2, $3, $4, now())`,
119
- [username, name.trim().slice(0, 60) || 'Authenticator', encryptSecret(secret), step],
120
- );
121
- }
181
+ export const addTotp = (username: string, name: string, secret: string, step: number) => storeAddTotp(DEVELOPERS, username, name, secret, step);
122
182
 
123
183
  // ---------------------------------------------------------------- passkeys (sprint 127)
124
184
 
125
185
  export interface Passkey { id: number; name: string; credential_id: string; public_key: Record<string, string>; alg: number; sign_count: string; transports: string[] }
126
186
 
127
187
  /** Add a passkey (after src/webauthn.ts checked the browser's answer). */
128
- export async function addPasskey(username: string, name: string, p: { credentialId: string; jwk: Record<string, string>; alg: number; signCount: number; rpId: string; transports: string[] }) {
188
+ export async function storeAddPasskey(st: FactorStore, who: Who, name: string, p: { credentialId: string; jwk: Record<string, string>; alg: number; signCount: number; rpId: string; transports: string[] }) {
129
189
  await owner.query(
130
- `insert into meta.developer_factor (username, kind, name, credential_id, public_key, alg, sign_count, rp_id, transports, last_used_at)
190
+ `insert into ${st.factors} (${st.key}, kind, name, credential_id, public_key, alg, sign_count, rp_id, transports, last_used_at)
131
191
  values ($1, 'passkey', $2, $3, $4, $5, $6, $7, $8, now())`,
132
- [username, name.trim().slice(0, 60) || 'Passkey', p.credentialId, JSON.stringify(p.jwk), p.alg, p.signCount, p.rpId,
192
+ [who, name.trim().slice(0, 60) || 'Passkey', p.credentialId, JSON.stringify(p.jwk), p.alg, p.signCount, p.rpId,
133
193
  p.transports.filter((t) => /^[a-z-]{1,20}$/.test(t)).slice(0, 8)],
134
194
  );
135
195
  }
136
196
 
137
- /** The developer's passkeys for this relying party (a passkey only works on the domain it was made for). */
138
- export async function passkeysOf(username: string, rpId: string): Promise<Passkey[]> {
197
+ /** The passkeys for this relying party (a passkey only works on the domain it was made for). */
198
+ export async function storePasskeys(st: FactorStore, who: Who, rpId: string): Promise<Passkey[]> {
139
199
  return (await owner.query<Passkey>(
140
- `select id, name, credential_id, public_key, alg, sign_count, transports from meta.developer_factor
141
- where username = $1 and kind = 'passkey' and rp_id = $2 order by id`, [username, rpId])).rows;
200
+ `select id, name, credential_id, public_key, alg, sign_count, transports from ${st.factors}
201
+ where ${st.key} = $1 and kind = 'passkey' and rp_id = $2 order by id`, [who, rpId])).rows;
142
202
  }
143
203
 
204
+ /** Record a sign-in with a passkey; false when another sign-in moved the counter first (a replay or a clone). */
205
+ export async function storeUsePasskey(st: FactorStore, id: number, oldCount: string, newCount: number): Promise<boolean> {
206
+ return !!(await owner.query(
207
+ `update ${st.factors} set sign_count = $3, last_used_at = now() where id = $1 and sign_count = $2::bigint`,
208
+ [id, oldCount, newCount])).rowCount;
209
+ }
210
+
211
+ export const addPasskey = (username: string, name: string, p: Parameters<typeof storeAddPasskey>[3]) => storeAddPasskey(DEVELOPERS, username, name, p);
212
+ export const passkeysOf = (username: string, rpId: string) => storePasskeys(DEVELOPERS, username, rpId);
213
+
144
214
  /** (sprint 128) The passkey with this credential id for this relying party, and whose it is (sign-in without a user name). */
145
215
  export async function passkeyByCredential(credentialId: string, rpId: string): Promise<(Passkey & { username: string }) | null> {
146
216
  if (!credentialId || credentialId.length > 1400) return null;
@@ -161,12 +231,7 @@ export async function setPasskeySignin(on: boolean) {
161
231
  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
232
  }
163
233
 
164
- /** Record a sign-in with a passkey; false when another sign-in moved the counter first (a replay or a clone). */
165
- export async function usePasskey(id: number, oldCount: string, newCount: number): Promise<boolean> {
166
- return !!(await owner.query(
167
- 'update meta.developer_factor set sign_count = $3, last_used_at = now() where id = $1 and sign_count = $2::bigint',
168
- [id, oldCount, newCount])).rowCount;
169
- }
234
+ export const usePasskey = (id: number, oldCount: string, newCount: number) => storeUsePasskey(DEVELOPERS, id, oldCount, newCount);
170
235
 
171
236
  // ---------------------------------------------------------------- trusted browsers (sprint 127)
172
237
 
@@ -211,48 +276,12 @@ export async function forgetBrowsers(username: string) {
211
276
  }
212
277
 
213
278
  /** New recovery codes (the old ones stop working); shown once, counted only when confirmed. */
214
- export async function newRecoveryCodes(username: string): Promise<string[]> {
215
- const codes = Array.from({ length: RECOVERY_CODES }, newRecoveryCode);
216
- await owner.tx(async (c) => {
217
- await c.query('delete from meta.developer_recovery_code where username = $1', [username]);
218
- for (const code of codes) await c.query('insert into meta.developer_recovery_code (username, code_hash) values ($1, $2)', [username, hashCode(code)]);
219
- });
220
- return codes;
221
- }
279
+ export const newRecoveryCodes = (username: string) => storeNewCodes(DEVELOPERS, username);
222
280
 
223
- export async function confirmRecoveryCodes(username: string) {
224
- await owner.query('update meta.developer_recovery_code set confirmed = true where username = $1 and used_at is null', [username]);
225
- }
281
+ export const confirmRecoveryCodes = (username: string) => storeConfirmCodes(DEVELOPERS, username);
226
282
 
227
- /**
228
- * Check a second-factor code at sign-in: an authenticator code (each step once) or a confirmed, unused
229
- * recovery code (then used up). Returns what matched, or null.
230
- */
231
- export async function checkSecondFactor(username: string, code: string): Promise<{ kind: 'totp' | 'recovery'; name?: string } | null> {
232
- const digits = code.replace(/\s/g, '');
233
- if (/^\d{6}$/.test(digits)) {
234
- const rows = (await owner.query<{ id: number; name: string; secret_enc: string; last_step: string | null }>(
235
- 'select id, name, secret_enc, last_step from meta.developer_factor where username = $1 and kind = $2', [username, 'totp'])).rows;
236
- for (const f of rows) {
237
- let secret: Buffer;
238
- try {
239
- secret = unbase32(decryptSecret(f.secret_enc));
240
- } catch {
241
- continue; // the instance key changed: recovery codes still work
242
- }
243
- const step = matchTotp(secret, digits, f.last_step === null ? null : Number(f.last_step));
244
- // the update only wins once per step, also with two requests at the same time
245
- if (step !== null && (await owner.query('update meta.developer_factor set last_step = $2, last_used_at = now() where id = $1 and (last_step is null or last_step < $2)', [f.id, step])).rowCount)
246
- return { kind: 'totp', name: f.name };
247
- }
248
- return null;
249
- }
250
- if (normaliseCode(code).length !== 10) return null;
251
- const used = await owner.query(
252
- 'update meta.developer_recovery_code set used_at = now() where username = $1 and code_hash = $2 and confirmed and used_at is null',
253
- [username, hashCode(code)]);
254
- return used.rowCount ? { kind: 'recovery' } : null;
255
- }
283
+ /** Check a developer's second-factor code at sign-in (see storeCheck). */
284
+ export const checkSecondFactor = (username: string, code: string) => storeCheck(DEVELOPERS, username, code);
256
285
 
257
286
  // ---------------------------------------------------------------- policy
258
287
 
package/src/remember.ts CHANGED
@@ -56,7 +56,7 @@ export async function useRemember(req: FastifyRequest, reply: FastifyReply, a: R
56
56
  );
57
57
  if (!row || !row.active) return clear(), null;
58
58
  // an expired password needs the password form; access may have been withdrawn
59
- if (row.method === 'password' && (await passwordDaysLeft(row.username)) === 0) return clear(), null;
59
+ if (row.method.split('+')[0] === 'password' && (await passwordDaysLeft(row.username)) === 0) return clear(), null;
60
60
  const access = await ssoAccess(a.id, row.username, row.groups);
61
61
  if (!access.allowed) return clear(), null;
62
62
  return { username: row.username, roles: access.roles, groups: row.groups, method: row.method, expiresAt: new Date(row.expires_at) };
@@ -12,11 +12,15 @@ import { chrome, clientTexts } from './render.ts';
12
12
  import { appStyles, choosable, chosenStyleName, styleChoice } from './styles.ts';
13
13
  import { loadApp } from '../metadata.ts';
14
14
  import { pushSection } from './push.ts';
15
+ import { relyingParty } from '../webauthn.ts';
16
+ import { tokenSection, twoStepSection } from './two-step.ts';
15
17
  import { appWithLocale, loadContext, safeNext, txContext, type Req } from './routes.ts';
16
18
 
17
19
  // "My account": details, own password, and preferences (light/dark and
18
20
  // language). APEX has the APIs for this (APEX_UTIL.CHANGE_CURRENT_USER_PW,
19
- // theme style per user); pgkiln provides the page itself.
21
+ // theme style per user); pgkiln provides the page itself. (sprint 130)
22
+ // Two-step sign-in: authenticator apps and recovery codes (two-step.ts). (sprint 131) Passkeys, and a REST API
23
+ // token the user creates (when the application allows it).
20
24
 
21
25
  /** Keep a signed-in user's style variant for this app (meta.account_style; '' = the base colours). */
22
26
  async function saveStyle(appId: number, username: string, style: string) {
@@ -115,6 +119,8 @@ async function accountPage(ctx: PageContext, reply: FastifyReply, error?: string
115
119
  <form method="post" action="${ctx.base}/account/devices">${csrf}<button class="btn">${t('account.forget_devices')}</button></form>
116
120
  </div></section>`
117
121
  : ''}
122
+ ${await twoStepSection(ctx, relyingParty(reply.request).rpId)}
123
+ ${await tokenSection(ctx)}
118
124
  ${await pushSection(ctx)}
119
125
  </div>
120
126
  </div>
@@ -1,10 +1,14 @@
1
+ import { secondStepWithin } from '../account-mfa.ts';
1
2
  import { applyBinds } from '../binds.ts';
3
+ import type { AuthzScheme } from '../metadata.ts';
2
4
  import { savepoint } from '../db.ts';
3
5
  import { bindValues, publicError, stripSemicolon, type PageContext, type Visibility } from './context.ts';
4
6
  import { saveButton, saveRequest } from './grid.ts';
5
7
  import { selectionOf } from './report.ts';
6
8
 
7
9
  export class Forbidden extends Error {}
10
+ /** (sprint 131) The page needs a second step more recent than the session's: ask for one, then come back. */
11
+ export class StepUp extends Forbidden {}
8
12
 
9
13
  /**
10
14
  * Evaluate an authorization reference ("ADMIN", "!ADMIN", or the built-in
@@ -20,15 +24,28 @@ export async function isAuthorized(ctx: PageContext, ref: string | null | undefi
20
24
  if (name === 'MUST_NOT_BE_PUBLIC_USER') ok = ctx.user !== 'nobody';
21
25
  else {
22
26
  const scheme = ctx.app.authz_schemes.find((s) => s.name === name);
23
- if (!scheme) ok = false;
24
- else if (scheme.type === 'role') ok = ctx.roles.includes(scheme.value.toLowerCase());
25
- else ok = await sqlTrue(ctx, scheme.value, `authorization ${name}`);
27
+ ok = !!scheme && (await schemePasses(ctx, scheme)) && (!scheme.mfa_minutes || secondStepWithin(ctx.session?.state ?? {}, scheme.mfa_minutes));
26
28
  }
27
29
  ctx.authzCache.set(name, ok);
28
30
  }
29
31
  return negate ? !ok : ok;
30
32
  }
31
33
 
34
+ async function schemePasses(ctx: PageContext, scheme: AuthzScheme): Promise<boolean> {
35
+ if (scheme.type === 'role') return ctx.roles.includes(scheme.value.toLowerCase());
36
+ return sqlTrue(ctx, scheme.value, `authorization ${scheme.name}`);
37
+ }
38
+
39
+ /**
40
+ * (sprint 131) Whether a reference fails only for its step-up: a scheme with mfa_minutes that the user passes, in a
41
+ * session whose last second step is too old (or never happened).
42
+ */
43
+ export async function onlyStepUpMissing(ctx: PageContext, ref: string | null | undefined): Promise<boolean> {
44
+ if (!ref || ref.startsWith('!') || ctx.user === 'nobody') return false;
45
+ const scheme = ctx.app.authz_schemes.find((s) => s.name === ref.trim().toUpperCase());
46
+ return !!scheme?.mfa_minutes && !secondStepWithin(ctx.session?.state ?? {}, scheme.mfa_minutes) && (await schemePasses(ctx, scheme));
47
+ }
48
+
32
49
  /**
33
50
  * Evaluate a SQL boolean expression with binds. Errors yield `onError`
34
51
  * (false by default: a broken condition hides a region or button).
@@ -51,7 +68,9 @@ export function authzMessage(ctx: PageContext, ref: string | null) {
51
68
  }
52
69
 
53
70
  export async function checkPageAccess(ctx: PageContext) {
54
- if (!(await isAuthorized(ctx, ctx.page.authz))) throw new Forbidden(authzMessage(ctx, ctx.page.authz));
71
+ if (await isAuthorized(ctx, ctx.page.authz)) return;
72
+ if (await onlyStepUpMissing(ctx, ctx.page.authz)) throw new StepUp(authzMessage(ctx, ctx.page.authz));
73
+ throw new Forbidden(authzMessage(ctx, ctx.page.authz));
55
74
  }
56
75
 
57
76
  /** Can the current user open this page? Used to hide links and menu entries. */
@@ -18,7 +18,7 @@ import { english, type Translate } from '../i18n.ts';
18
18
  import { checksumValid, loginWindowMinutes, urlChecksum } from '../security.ts';
19
19
  import { builderSsoFinish, enabledProviders, finishSignIn, loadProvider, ssoAccess, SsoError, startSignIn, type Provider, type SsoResult } from '../sso.ts';
20
20
  import { sessionLimits, clientIp, createSession, destroySession, getSession, loginThrottled, logActivity, saveState, takeFlash, type Session } from '../session.ts';
21
- import { checkPageAccess, computeVisibility, Forbidden, isAuthorized } from './authz.ts';
21
+ import { checkPageAccess, computeVisibility, Forbidden, isAuthorized, StepUp } from './authz.ts';
22
22
  import { bindValues, dbg, publicError, stripSemicolon, timed, toState, writeOut, type PageContext } from './context.ts';
23
23
  import { startDebug } from '../debug.ts';
24
24
  import { assignable, clearPageItems, fetchForms, processConditionHolds, ProcessFailed, runAppProcesses, runProcesses, runSql, validate, ValidationFailed } from './engine.ts';
@@ -48,6 +48,8 @@ import { resolveLocale, THEME_COOKIE, themeCookieOptions, translateApp, translat
48
48
  import { chrome, dialogClosePage, languagePicker, renderPage } from './render.ts';
49
49
  import { LOGIN_ERROR, LOGIN_USER, safeNext, signInForm } from './signin.ts';
50
50
  import { passwordResetRoutes } from './password-reset.ts';
51
+ import { passwordSignIn, twoStepRoutes } from './two-step.ts';
52
+ import { carriesSecondStep } from '../account-mfa.ts';
51
53
  import { devbarOf, devbarRoutes, sessionDebugLevel } from './devbar.ts';
52
54
  import { availabilityRefusal } from './availability.ts';
53
55
  import { feedbackRoutes } from './feedback.ts';
@@ -229,7 +231,8 @@ export async function loadContext(req: Req, reply: FastifyReply, { json = false,
229
231
  // the session ended, but the browser was remembered: sign in again silently (a new token each time)
230
232
  if (app.authentication === 'app_users' && !session.username) {
231
233
  const remembered = await useRemember(req, reply, app);
232
- if (remembered)
234
+ // (sprint 130) one made before the application asked this user for a second step doesn't stand in for it
235
+ if (remembered && (await carriesSecondStep(app, remembered.username, remembered.roles, remembered.method)))
233
236
  session = await signIn(req, reply, app, session, remembered.username, {
234
237
  extraRoles: remembered.roles, groups: remembered.groups, method: remembered.method,
235
238
  remember: true, rememberUntil: remembered.expiresAt, detail: 'remember me',
@@ -238,7 +241,7 @@ export async function loadContext(req: Req, reply: FastifyReply, { json = false,
238
241
  // (067) another application of the session sharing group was signed in to: sign in here too
239
242
  if (!session.username && sharingGroup(app)) {
240
243
  const shared = await useShared(req, reply, app);
241
- if (shared)
244
+ if (shared && (await carriesSecondStep(app, shared.username, shared.roles, shared.method)))
242
245
  session = await signIn(req, reply, app, session, shared.username, {
243
246
  extraRoles: shared.roles, groups: shared.groups, method: shared.method, shared: shared.hash, detail: 'shared sign-in',
244
247
  });
@@ -313,6 +316,13 @@ export async function loadContext(req: Req, reply: FastifyReply, { json = false,
313
316
  };
314
317
  }
315
318
 
319
+ /** (sprint 131) Step-up: the page's authorization needs a recent second step; ask for it, then come back. */
320
+ async function stepUp(ctx: PageContext, reply: FastifyReply, next: string) {
321
+ dbg(ctx, 2, 'access', `step-up: a second step is needed for page ${ctx.page.page_no}`);
322
+ await saveState(ctx.session);
323
+ return reply.redirect(`${ctx.base}/login/2fa?next=${encodeURIComponent(next)}`, 303);
324
+ }
325
+
316
326
  async function forbidden(ctx: PageContext, reply: FastifyReply, message: string, detail: string) {
317
327
  dbg(ctx, 2, 'access', `refused (403): ${detail}`);
318
328
  logActivity({ appId: ctx.app.id, pageNo: ctx.page.page_no, username: ctx.user, event: 'forbidden', ip: ctx.ip, detail });
@@ -484,6 +494,7 @@ export async function runtimeRoutes(app: FastifyInstance) {
484
494
  return { html: await timed(ctx, 4, 'page', 'render page', () => renderPage(ctx)) };
485
495
  });
486
496
  } catch (e) {
497
+ if (e instanceof StepUp) return stepUp(ctx, reply, req.url);
487
498
  if (e instanceof Forbidden) return forbidden(ctx, reply, e.message, `page ${ctx.page.page_no}`);
488
499
  throw e;
489
500
  }
@@ -580,6 +591,8 @@ export async function runtimeRoutes(app: FastifyInstance) {
580
591
  return pressed;
581
592
  });
582
593
  } catch (e) {
594
+ // the second step expired while the page was open: ask again, then back to the page (the input is lost)
595
+ if (e instanceof StepUp) return stepUp(ctx, reply, `${ctx.base}/${ctx.page.page_no}`);
583
596
  if (e instanceof Forbidden) return forbidden(ctx, reply, e.message, `forged or unavailable request "${body.__request}" on page ${ctx.page.page_no}`);
584
597
  if (e instanceof ValidationFailed) {
585
598
  ctx.errors = e.errors;
@@ -619,6 +632,7 @@ export async function runtimeRoutes(app: FastifyInstance) {
619
632
  fileRoutes(app, loadContext, txContext, forbidden);
620
633
  // (087) "Forgot password?"
621
634
  passwordResetRoutes(app);
635
+ twoStepRoutes(app);
622
636
  // (079) the developer toolbar's session state viewer and actions
623
637
  devbarRoutes(app, loadContext);
624
638
  // (084) users' feedback
@@ -1272,7 +1286,7 @@ export async function runtimeRoutes(app: FastifyInstance) {
1272
1286
  logActivity({ appId: a.id, username: account, event: 'login_failed', ip, detail: `ldap:${d.name}: no access` });
1273
1287
  return fail(locale.t('login.no_access', { user: account, app: a.name }), 403);
1274
1288
  }
1275
- return completeLogin(req, reply, a, session, account, {
1289
+ return passwordSignIn(req, reply, a, session, account, {
1276
1290
  next, remember, extraRoles: access.roles, groups: user.groups, method: `ldap:${d.name}`, detail: `ldap:${d.name}`,
1277
1291
  });
1278
1292
  }
@@ -1283,7 +1297,7 @@ export async function runtimeRoutes(app: FastifyInstance) {
1283
1297
  logActivity({ appId: a.id, username: r.username, event: 'password_expired', ip });
1284
1298
  return reply.type('text/html').send(expiredPage(a, locale, session, r.username, safeNext(a, next)));
1285
1299
  }
1286
- return completeLogin(req, reply, a, session, r.username, { next, remember });
1300
+ return passwordSignIn(req, reply, a, session, r.username, { next, remember });
1287
1301
  });
1288
1302
 
1289
1303
  app.post('/a/:alias/password', async (req: Req, reply) => {
@@ -1309,7 +1323,7 @@ export async function runtimeRoutes(app: FastifyInstance) {
1309
1323
  }
1310
1324
  logActivity({ appId: a.id, username, event: 'password_changed', ip });
1311
1325
  const canonical = (await runtime.one<{ username: string }>('select username from meta.account where lower(username) = lower($1)', [username]))!.username;
1312
- return completeLogin(req, reply, a, session, canonical, { next });
1326
+ return passwordSignIn(req, reply, a, session, canonical, { next });
1313
1327
  });
1314
1328
 
1315
1329
  // ---------------------------------------------------------------- single sign-on (OpenID Connect)
@@ -1374,7 +1388,7 @@ export async function runtimeRoutes(app: FastifyInstance) {
1374
1388
  return simplePage(reply, 403, locale.t('login.no_access_title'), locale.t('login.no_access', { user: result.username, app: a.name }), `/a/${a.alias}/login`, locale);
1375
1389
  }
1376
1390
  const session = await getSession(req, reply, a.id, `/a/${a.alias}`);
1377
- return completeLogin(req, reply, a, session, result.username, { extraRoles: access.roles, next: result.next ?? undefined, detail: `sso:${p.name}` });
1391
+ return completeLogin(req, reply, a, session, result.username, { extraRoles: access.roles, next: result.next ?? undefined, method: `sso:${p.name}`, detail: `sso:${p.name}` });
1378
1392
  };
1379
1393
 
1380
1394
  // SAML: the IdP posts the response cross-site, where SameSite=Lax cookies aren't sent; post it on