pgkiln 0.62.0 → 0.64.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 (61) hide show
  1. package/SECURITY.md +4 -0
  2. package/db/migrations/131_builder_sso.sql +69 -0
  3. package/db/migrations/132_app_user_mfa.sql +58 -0
  4. package/docs/apex-feature-parity.md +5 -2
  5. package/docs/guide/03-builder.md +46 -0
  6. package/docs/guide/08-security.md +48 -6
  7. package/docs/guide/09-reference.md +5 -1
  8. package/docs/guide/11-from-apex.md +2 -1
  9. package/docs/guide/12-development.md +9 -0
  10. package/docs/guide/18-cli.md +1 -0
  11. package/package.json +1 -1
  12. package/public/app.css +10 -0
  13. package/public/builder.css +4 -0
  14. package/src/account-mfa.ts +93 -0
  15. package/src/builder/advisor.ts +8 -0
  16. package/src/builder/builder-sso.ts +135 -0
  17. package/src/builder/i18n/de.ts +56 -0
  18. package/src/builder/i18n/en.ts +56 -0
  19. package/src/builder/i18n/es.ts +56 -0
  20. package/src/builder/i18n/fr.ts +56 -0
  21. package/src/builder/i18n/nl.ts +56 -0
  22. package/src/builder/i18n/pt.ts +56 -0
  23. package/src/builder/ldap.ts +2 -0
  24. package/src/builder/login-page.ts +26 -2
  25. package/src/builder/routes.ts +137 -21
  26. package/src/builder/security.ts +3 -1
  27. package/src/builder/ui.ts +2 -1
  28. package/src/builder/users.ts +36 -3
  29. package/src/builder/workspaces.ts +37 -3
  30. package/src/cli/main.ts +19 -5
  31. package/src/devsso.ts +149 -0
  32. package/src/i18n/ar.ts +34 -0
  33. package/src/i18n/cs.ts +34 -0
  34. package/src/i18n/da.ts +34 -0
  35. package/src/i18n/de.ts +34 -0
  36. package/src/i18n/el.ts +34 -0
  37. package/src/i18n/es.ts +34 -0
  38. package/src/i18n/fi.ts +34 -0
  39. package/src/i18n/fr.ts +34 -0
  40. package/src/i18n/he.ts +34 -0
  41. package/src/i18n/it.ts +34 -0
  42. package/src/i18n/ja.ts +34 -0
  43. package/src/i18n/ko.ts +34 -0
  44. package/src/i18n/nb.ts +34 -0
  45. package/src/i18n/pl.ts +34 -0
  46. package/src/i18n/pt.ts +34 -0
  47. package/src/i18n/ru.ts +34 -0
  48. package/src/i18n/sv.ts +34 -0
  49. package/src/i18n/tr.ts +34 -0
  50. package/src/i18n/uk.ts +34 -0
  51. package/src/i18n/zh.ts +34 -0
  52. package/src/i18n.ts +68 -0
  53. package/src/metadata.ts +4 -1
  54. package/src/mfa.ts +76 -52
  55. package/src/remember.ts +1 -1
  56. package/src/runtime/account.ts +4 -1
  57. package/src/runtime/routes.ts +24 -9
  58. package/src/runtime/two-step.ts +395 -0
  59. package/src/saml.ts +13 -7
  60. package/src/sharedlogin.ts +1 -1
  61. package/src/sso.ts +37 -7
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,13 @@ 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 { twoStepSection } from './two-step.ts';
15
16
  import { appWithLocale, loadContext, safeNext, txContext, type Req } from './routes.ts';
16
17
 
17
18
  // "My account": details, own password, and preferences (light/dark and
18
19
  // language). APEX has the APIs for this (APEX_UTIL.CHANGE_CURRENT_USER_PW,
19
- // theme style per user); pgkiln provides the page itself.
20
+ // theme style per user); pgkiln provides the page itself. (sprint 130)
21
+ // Two-step sign-in: authenticator apps and recovery codes (two-step.ts).
20
22
 
21
23
  /** Keep a signed-in user's style variant for this app (meta.account_style; '' = the base colours). */
22
24
  async function saveStyle(appId: number, username: string, style: string) {
@@ -115,6 +117,7 @@ async function accountPage(ctx: PageContext, reply: FastifyReply, error?: string
115
117
  <form method="post" action="${ctx.base}/account/devices">${csrf}<button class="btn">${t('account.forget_devices')}</button></form>
116
118
  </div></section>`
117
119
  : ''}
120
+ ${await twoStepSection(ctx)}
118
121
  ${await pushSection(ctx)}
119
122
  </div>
120
123
  </div>
@@ -16,7 +16,7 @@ import { accountRoles, loadApp, loadPage, type App, type Page, type Region } fro
16
16
  import { passwordDaysLeft, passwordProblem } from '../accounts.ts';
17
17
  import { english, type Translate } from '../i18n.ts';
18
18
  import { checksumValid, loginWindowMinutes, urlChecksum } from '../security.ts';
19
- import { enabledProviders, finishSignIn, loadProvider, ssoAccess, SsoError, startSignIn, type SsoResult } from '../sso.ts';
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
21
  import { checkPageAccess, computeVisibility, Forbidden, isAuthorized } from './authz.ts';
22
22
  import { bindValues, dbg, publicError, stripSemicolon, timed, toState, writeOut, type PageContext } from './context.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
  });
@@ -619,6 +622,7 @@ export async function runtimeRoutes(app: FastifyInstance) {
619
622
  fileRoutes(app, loadContext, txContext, forbidden);
620
623
  // (087) "Forgot password?"
621
624
  passwordResetRoutes(app);
625
+ twoStepRoutes(app);
622
626
  // (079) the developer toolbar's session state viewer and actions
623
627
  devbarRoutes(app, loadContext);
624
628
  // (084) users' feedback
@@ -1272,7 +1276,7 @@ export async function runtimeRoutes(app: FastifyInstance) {
1272
1276
  logActivity({ appId: a.id, username: account, event: 'login_failed', ip, detail: `ldap:${d.name}: no access` });
1273
1277
  return fail(locale.t('login.no_access', { user: account, app: a.name }), 403);
1274
1278
  }
1275
- return completeLogin(req, reply, a, session, account, {
1279
+ return passwordSignIn(req, reply, a, session, account, {
1276
1280
  next, remember, extraRoles: access.roles, groups: user.groups, method: `ldap:${d.name}`, detail: `ldap:${d.name}`,
1277
1281
  });
1278
1282
  }
@@ -1283,7 +1287,7 @@ export async function runtimeRoutes(app: FastifyInstance) {
1283
1287
  logActivity({ appId: a.id, username: r.username, event: 'password_expired', ip });
1284
1288
  return reply.type('text/html').send(expiredPage(a, locale, session, r.username, safeNext(a, next)));
1285
1289
  }
1286
- return completeLogin(req, reply, a, session, r.username, { next, remember });
1290
+ return passwordSignIn(req, reply, a, session, r.username, { next, remember });
1287
1291
  });
1288
1292
 
1289
1293
  app.post('/a/:alias/password', async (req: Req, reply) => {
@@ -1309,7 +1313,7 @@ export async function runtimeRoutes(app: FastifyInstance) {
1309
1313
  }
1310
1314
  logActivity({ appId: a.id, username, event: 'password_changed', ip });
1311
1315
  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 });
1316
+ return passwordSignIn(req, reply, a, session, canonical, { next });
1313
1317
  });
1314
1318
 
1315
1319
  // ---------------------------------------------------------------- single sign-on (OpenID Connect)
@@ -1350,8 +1354,19 @@ export async function runtimeRoutes(app: FastifyInstance) {
1350
1354
  });
1351
1355
 
1352
1356
  /** The end of a single sign-on (OpenID Connect or SAML): the app's access rules, then the session. */
1353
- const finishSso = (req: FastifyRequest, reply: FastifyReply, p: { name: string }, result: SsoResult, ip: string) =>
1354
- withAppDb(result.appId, () => finishSsoForApp(req, reply, p, result, ip));
1357
+ const finishSso = (req: FastifyRequest, reply: FastifyReply, p: Provider, result: SsoResult, ip: string) => {
1358
+ // (sprint 129) a sign-in to the builder
1359
+ if (result.appId === null) {
1360
+ const builder = builderSsoFinish();
1361
+ if (!builder) return simplePage(reply, 404, english('error.not_found'), english('login.method_unavailable'));
1362
+ return builder(req, reply, p, result, ip).catch((e) => {
1363
+ if (!(e instanceof SsoError)) throw e;
1364
+ logActivity({ username: result.link ?? result.username.slice(0, 100), event: 'login_failed', ip, detail: `builder sso:${p.name}: ${e.message}` });
1365
+ return simplePage(reply, 403, english('login.sso_failed'), e.message, result.link ? '/builder/security' : '/builder/login');
1366
+ });
1367
+ }
1368
+ return withAppDb(result.appId, () => finishSsoForApp(req, reply, p, result, ip));
1369
+ };
1355
1370
  const finishSsoForApp = async (req: FastifyRequest, reply: FastifyReply, p: { name: string }, result: SsoResult, ip: string) => {
1356
1371
  const alias = (await runtime.one<{ alias: string }>('select alias from meta.app where id = $1', [result.appId]))?.alias;
1357
1372
  const loaded = alias ? await appWithLocale(req, alias) : undefined;
@@ -1363,7 +1378,7 @@ export async function runtimeRoutes(app: FastifyInstance) {
1363
1378
  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);
1364
1379
  }
1365
1380
  const session = await getSession(req, reply, a.id, `/a/${a.alias}`);
1366
- return completeLogin(req, reply, a, session, result.username, { extraRoles: access.roles, next: result.next ?? undefined, detail: `sso:${p.name}` });
1381
+ return completeLogin(req, reply, a, session, result.username, { extraRoles: access.roles, next: result.next ?? undefined, method: `sso:${p.name}`, detail: `sso:${p.name}` });
1367
1382
  };
1368
1383
 
1369
1384
  // SAML: the IdP posts the response cross-site, where SameSite=Lax cookies aren't sent; post it on
@@ -0,0 +1,395 @@
1
+ import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
2
+ import {
3
+ accountCodesLeft, accountFactors, accountHasFactor, accountId, addAccountTotp, checkAccountCode, confirmAccountCodes, mfaOn, mfaRequired,
4
+ newAccountCodes, removeAccountFactor, secondStep, withSecondStep,
5
+ } from '../account-mfa.ts';
6
+ import { html, raw, type Raw } from '../html.ts';
7
+ import { documentShell } from '../layout.ts';
8
+ import { accountRoles, type App } from '../metadata.ts';
9
+ import { matchTotp, newTotpSecret, otpauthUri, unbase32 } from '../mfa.ts';
10
+ import { qrSvg } from '../qrcode.ts';
11
+ import { secretKeyConfigured } from '../secrets.ts';
12
+ import { loginWindowMinutes } from '../security.ts';
13
+ import { clientIp, getSession, loginThrottled, logActivity, saveState, type Session } from '../session.ts';
14
+ import type { PageContext } from './context.ts';
15
+ import type { Locale } from './locale.ts';
16
+ import { pwaHead } from './pwa.ts';
17
+ import { appWithLocale, completeLogin, loadContext, rootAttrs, safeNext, signIn, type Req } from './routes.ts';
18
+
19
+ // (sprint 130, SEC-3) The second step of signing in to an application, and "Two-step sign-in" on My account.
20
+ //
21
+ // A password or LDAP sign-in that the application asks a second step of (src/account-mfa.ts) doesn't get a session
22
+ // yet: what it would have become is kept in the signed-out session for five minutes (ten while adding an app), and
23
+ // /a/<alias>/login/2fa asks for a code from the authenticator app or a recovery code. When the application requires
24
+ // the step and the user has no app yet, the same page shows a QR code first, then the recovery codes once. Wrong
25
+ // codes count as failed sign-ins (the same lockout). The sign-in's method gets "+2fa", so a remembered or shared
26
+ // sign-in made from it may skip the step later.
27
+
28
+ const PENDING = '__MFA';
29
+ const STEP_SECONDS = 300;
30
+ const ENROL_SECONDS = 600;
31
+
32
+ interface Pending {
33
+ u: string;
34
+ next?: string;
35
+ remember?: boolean;
36
+ extraRoles?: string[];
37
+ groups?: string[];
38
+ method?: string;
39
+ detail?: string;
40
+ at: number;
41
+ enrol?: boolean;
42
+ /** while adding an app: the new secret, then (after its first code) the recovery codes to show once */
43
+ secret?: string;
44
+ codes?: string[];
45
+ }
46
+
47
+ function pendingOf(s: Session): Pending | null {
48
+ try {
49
+ const p = JSON.parse(String(s.state[PENDING] ?? '')) as Pending;
50
+ const limit = (p?.enrol ? ENROL_SECONDS : STEP_SECONDS) * 1000;
51
+ return p && typeof p.u === 'string' && Date.now() - Number(p.at) < limit ? p : null;
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+
57
+ interface PasswordSignIn {
58
+ next?: string;
59
+ remember?: boolean;
60
+ extraRoles?: string[];
61
+ groups?: string[];
62
+ method?: string;
63
+ detail?: string;
64
+ }
65
+
66
+ /**
67
+ * A password or LDAP sign-in that succeeded: signed in at once, or on to the second step when the application asks
68
+ * for one of this user.
69
+ */
70
+ export async function passwordSignIn(req: FastifyRequest, reply: FastifyReply, a: App, session: Session, username: string, opts: PasswordSignIn = {}) {
71
+ const roles = [...(await accountRoles(a.id, username)), ...(opts.extraRoles ?? []).map((r) => r.toLowerCase())];
72
+ const need = await secondStep(a, username, roles);
73
+ if (!need) return completeLogin(req, reply, a, session, username, opts);
74
+ const p: Pending = { u: username, ...opts, at: Date.now(), enrol: need === 'enrol' };
75
+ session.state[PENDING] = JSON.stringify(p);
76
+ await saveState(session);
77
+ return reply.redirect(`/a/${a.alias}/login/2fa`, 303);
78
+ }
79
+
80
+ function card(a: App, locale: Locale, title: string, body: Raw) {
81
+ return documentShell(
82
+ `${title} · ${a.name}`,
83
+ html`<main class="login">
84
+ <div class="card login-card">
85
+ <h1>${title}</h1>
86
+ ${body}
87
+ <a class="login-forgot" href="/a/${a.alias}/login">${locale.t('reset.back')}</a>
88
+ </div>
89
+ </main>`,
90
+ 'login-body',
91
+ {},
92
+ pwaHead(a),
93
+ rootAttrs(locale),
94
+ );
95
+ }
96
+
97
+ const csrfField = (s: Session) => html`<input type="hidden" name="__csrf" value="${s.csrf_token}">`;
98
+ const alert = (error?: string) => html`${error ? html`<div class="alert alert-error" role="alert">${error}</div>` : ''}`;
99
+ const grouped = (secret: string) => secret.replace(/(.{4})/g, '$1 ').trim();
100
+
101
+ function codesList(codes: string[]) {
102
+ return html`<ol class="mfa-codes">${codes.map((c) => html`<li><code>${c}</code></li>`)}</ol>`;
103
+ }
104
+
105
+ /** The second step's page: a code; or a new app (QR code, then its first code); or the recovery codes, once. */
106
+ function stepPage(a: App, locale: Locale, s: Session, p: Pending, error?: string) {
107
+ const t = locale.t;
108
+ const action = `/a/${a.alias}/login/2fa`;
109
+ if (p.codes)
110
+ return card(a, locale, t('mfa.codes_title'), html`<p>${t('mfa.codes_once')}</p>${codesList(p.codes)}
111
+ <form method="post" action="${action}/done" class="login-form">${csrfField(s)}
112
+ <label class="check"><input type="checkbox" name="saved" value="1" required> ${t('mfa.codes_saved')}</label>
113
+ <button class="btn btn-hot">${t('mfa.continue')}</button></form>`);
114
+ if (p.enrol && p.secret)
115
+ return card(a, locale, t('mfa.enrol_title'), html`<p class="muted">${t('mfa.enrol_intro')}</p>${alert(error)}
116
+ <ol class="mfa-steps">
117
+ <li>${t('mfa.scan')}</li>
118
+ <li>${t('mfa.or_enter')} <code class="mfa-secret">${grouped(p.secret)}</code></li>
119
+ <li>${t('mfa.enter_code')}</li>
120
+ </ol>
121
+ <div class="mfa-qr">${raw(qrSvg(otpauthUri(p.secret, p.u, a.name), { label: t('mfa.qr_label'), px: 4 }) ?? '')}</div>
122
+ <form method="post" action="${action}" class="login-form">${csrfField(s)}
123
+ <div class="field"><label class="label" for="code">${t('mfa.code')}</label><input id="code" name="code" required inputmode="numeric" autocomplete="one-time-code" maxlength="7" autofocus></div>
124
+ <button class="btn btn-hot">${t('mfa.verify')}</button></form>`);
125
+ return card(a, locale, t('mfa.step_title'), html`<p class="muted">${t('mfa.step_intro')}</p>${alert(error)}
126
+ <form method="post" action="${action}" class="login-form">${csrfField(s)}
127
+ <div class="field"><label class="label" for="code">${t('mfa.code')}</label><input id="code" name="code" required autocomplete="one-time-code" maxlength="20" spellcheck="false" autocapitalize="off" autofocus></div>
128
+ <button class="btn btn-hot">${t('mfa.verify')}</button></form>`);
129
+ }
130
+
131
+ /** The signed-out session's pending sign-in, or a page that says it expired. */
132
+ async function start(req: Req, reply: FastifyReply) {
133
+ const loaded = await appWithLocale(req, req.params.alias);
134
+ if (!loaded) return null;
135
+ const session = await getSession(req, reply, loaded.app.id, `/a/${loaded.app.alias}`);
136
+ const { app: a, locale } = (await appWithLocale(req, req.params.alias, session))!;
137
+ const p = session.username ? null : pendingOf(session);
138
+ return { a, locale, session, p };
139
+ }
140
+
141
+ const expired = async (reply: FastifyReply, a: App, locale: Locale, s: Session) => {
142
+ delete s.state[PENDING];
143
+ await saveState(s);
144
+ return reply.code(410).type('text/html').send(card(a, locale, locale.t('mfa.step_title'), alert(locale.t('mfa.expired'))));
145
+ };
146
+
147
+ /** Sign in with the pending sign-in's options, now with "+2fa" in its method. */
148
+ async function finish(req: Req, reply: FastifyReply, a: App, s: Session, p: Pending, detail: string, next?: string, flash?: string) {
149
+ const n = await signIn(req, reply, a, s, p.u, {
150
+ extraRoles: p.extraRoles, groups: p.groups, remember: p.remember, method: withSecondStep(p.method),
151
+ detail: `${p.detail ?? 'password'} ${detail}`,
152
+ });
153
+ if (flash) {
154
+ n.state.__FLASH = flash;
155
+ await saveState(n);
156
+ }
157
+ return reply.redirect(next ?? safeNext(a, p.next), 303);
158
+ }
159
+
160
+ export async function twoStepRoutes(app: FastifyInstance) {
161
+ app.get('/a/:alias/login/2fa', async (req: Req, reply) => {
162
+ const r = await start(req, reply);
163
+ if (!r) return reply.code(404).send('Not found');
164
+ const { a, locale, session: s, p } = r;
165
+ if (!p) return expired(reply, a, locale, s);
166
+ const id = await accountId(p.u);
167
+ if (id === null) return expired(reply, a, locale, s);
168
+ if (p.enrol && !p.codes && !(await accountHasFactor(id))) {
169
+ // fail closed: without the instance key there is nowhere safe to keep the app's secret
170
+ if (!secretKeyConfigured()) return reply.code(503).type('text/html').send(card(a, locale, locale.t('mfa.enrol_title'), alert(locale.t('mfa.no_key'))));
171
+ if (!p.secret) {
172
+ p.secret = newTotpSecret();
173
+ s.state[PENDING] = JSON.stringify(p);
174
+ }
175
+ }
176
+ await saveState(s);
177
+ return reply.type('text/html').send(stepPage(a, locale, s, p));
178
+ });
179
+
180
+ app.post('/a/:alias/login/2fa', async (req: Req, reply) => {
181
+ const r = await start(req, reply);
182
+ if (!r) return reply.code(404).send('Not found');
183
+ const { a, locale, session: s, p } = r;
184
+ const t = locale.t;
185
+ if (!p || req.body?.__csrf !== s.csrf_token) return expired(reply, a, locale, s);
186
+ const ip = clientIp(req);
187
+ const id = await accountId(p.u);
188
+ if (id === null) return expired(reply, a, locale, s);
189
+ if (await loginThrottled(a.id, p.u, ip)) {
190
+ logActivity({ appId: a.id, username: p.u, event: 'login_locked', ip, detail: '2fa' });
191
+ delete s.state[PENDING];
192
+ await saveState(s);
193
+ return reply.code(429).type('text/html').send(card(a, locale, t('mfa.step_title'), alert(t('login.throttled', { minutes: loginWindowMinutes() }))));
194
+ }
195
+ const code = String(req.body?.code ?? '').slice(0, 40);
196
+ // adding the first app while signing in: its first code proves it works
197
+ if (p.enrol && p.secret && !p.codes) {
198
+ const step = matchTotp(unbase32(p.secret), code.replace(/\s/g, ''), null);
199
+ if (step === null) {
200
+ await logActivity({ appId: a.id, username: p.u, event: 'login_failed', ip, detail: '2fa (new app)' });
201
+ return reply.code(401).type('text/html').send(stepPage(a, locale, s, p, t('mfa.wrong')));
202
+ }
203
+ await addAccountTotp(id, t('mfa.app_default'), p.secret, step);
204
+ await logActivity({ appId: a.id, username: p.u, event: '2fa_enrolled', ip, detail: 'authenticator app (sign-in)' });
205
+ p.codes = await newAccountCodes(id);
206
+ delete p.secret;
207
+ s.state[PENDING] = JSON.stringify(p);
208
+ await saveState(s);
209
+ return reply.type('text/html').send(stepPage(a, locale, s, p));
210
+ }
211
+ const ok = await checkAccountCode(id, code);
212
+ if (!ok) {
213
+ await logActivity({ appId: a.id, username: p.u, event: 'login_failed', ip, detail: '2fa' });
214
+ return reply.code(401).type('text/html').send(stepPage(a, locale, s, p, t('mfa.wrong')));
215
+ }
216
+ if (ok.kind === 'recovery') {
217
+ const left = await accountCodesLeft(id);
218
+ await logActivity({ appId: a.id, username: p.u, event: '2fa_recovery_used', ip, detail: `${left} left` });
219
+ // to My account: one code fewer, maybe time for new ones
220
+ return finish(req, reply, a, s, p, '2fa (recovery code)', `/a/${a.alias}/account#two-step`, t('mfa.recovery_used', { n: left }));
221
+ }
222
+ return finish(req, reply, a, s, p, '2fa');
223
+ });
224
+
225
+ // the recovery codes were shown once after adding the first app while signing in
226
+ app.post('/a/:alias/login/2fa/done', async (req: Req, reply) => {
227
+ const r = await start(req, reply);
228
+ if (!r) return reply.code(404).send('Not found');
229
+ const { a, locale, session: s, p } = r;
230
+ if (!p?.codes || req.body?.__csrf !== s.csrf_token) return expired(reply, a, locale, s);
231
+ if (req.body?.saved !== '1') return reply.code(422).type('text/html').send(stepPage(a, locale, s, p));
232
+ const id = await accountId(p.u);
233
+ if (id === null) return expired(reply, a, locale, s);
234
+ await confirmAccountCodes(id);
235
+ return finish(req, reply, a, s, p, '2fa (new app)');
236
+ });
237
+
238
+ // ---------------------------------------------------------------- My account → Two-step sign-in
239
+ const SECRET_MINUTES = 10;
240
+ const signedIn = async (req: Req, reply: FastifyReply) => {
241
+ const ctx = await loadContext(req, reply, { pageNo: 'home' });
242
+ if (!ctx) return null;
243
+ const id = await accountId(ctx.user);
244
+ if (req.body?.__csrf !== ctx.session.csrf_token || !mfaOn(ctx.app) || id === null) {
245
+ reply.redirect(`${ctx.base}/account`, 303);
246
+ return null;
247
+ }
248
+ return { ctx, id };
249
+ };
250
+ const done = async (reply: FastifyReply, ctx: PageContext, flash?: string, error?: boolean) => {
251
+ if (flash) ctx.session.state[error ? '__FLASH_ERROR' : '__FLASH'] = flash;
252
+ await saveState(ctx.session);
253
+ return reply.redirect(`${ctx.base}/account#two-step`, 303);
254
+ };
255
+
256
+ app.post('/a/:alias/account/2fa/start', async (req: Req, reply) => {
257
+ const r = await signedIn(req, reply);
258
+ if (!r) return;
259
+ if (secretKeyConfigured()) r.ctx.session.state.__TOTP_NEW = JSON.stringify({ secret: newTotpSecret(), at: Date.now() });
260
+ return done(reply, r.ctx);
261
+ });
262
+
263
+ app.post('/a/:alias/account/2fa/cancel', async (req: Req, reply) => {
264
+ const r = await signedIn(req, reply);
265
+ if (!r) return;
266
+ delete r.ctx.session.state.__TOTP_NEW;
267
+ return done(reply, r.ctx);
268
+ });
269
+
270
+ app.post('/a/:alias/account/2fa/confirm', async (req: Req, reply) => {
271
+ const r = await signedIn(req, reply);
272
+ if (!r) return;
273
+ const { ctx, id } = r;
274
+ const t = ctx.locale.t;
275
+ const pending = newSecret(ctx.session, SECRET_MINUTES);
276
+ if (!pending) {
277
+ delete ctx.session.state.__TOTP_NEW;
278
+ return done(reply, ctx, t('mfa.expired'), true);
279
+ }
280
+ const step = matchTotp(unbase32(pending), String(req.body?.code ?? '').replace(/\s/g, ''), null);
281
+ if (step === null) return done(reply, ctx, t('mfa.wrong'), true);
282
+ const first = !(await accountHasFactor(id));
283
+ await addAccountTotp(id, String(req.body?.name ?? '') || t('mfa.app_default'), pending, step);
284
+ delete ctx.session.state.__TOTP_NEW;
285
+ await logActivity({ appId: ctx.app.id, username: ctx.user, event: '2fa_enrolled', ip: ctx.ip, detail: 'authenticator app' });
286
+ if (first || !(await accountCodesLeft(id))) ctx.session.state.__CODES_NEW = JSON.stringify(await newAccountCodes(id));
287
+ return done(reply, ctx, t('mfa.added'));
288
+ });
289
+
290
+ app.post('/a/:alias/account/2fa/codes', async (req: Req, reply) => {
291
+ const r = await signedIn(req, reply);
292
+ if (!r) return;
293
+ if (!(await accountHasFactor(r.id))) return done(reply, r.ctx);
294
+ r.ctx.session.state.__CODES_NEW = JSON.stringify(await newAccountCodes(r.id));
295
+ await logActivity({ appId: r.ctx.app.id, username: r.ctx.user, event: '2fa_codes', ip: r.ctx.ip, detail: 'new recovery codes' });
296
+ return done(reply, r.ctx);
297
+ });
298
+
299
+ app.post('/a/:alias/account/2fa/codes/confirm', async (req: Req, reply) => {
300
+ const r = await signedIn(req, reply);
301
+ if (!r) return;
302
+ if (req.body?.saved !== '1') return done(reply, r.ctx);
303
+ await confirmAccountCodes(r.id);
304
+ delete r.ctx.session.state.__CODES_NEW;
305
+ return done(reply, r.ctx);
306
+ });
307
+
308
+ // removing an app asks for a code (from any app, or a recovery code): a session left open is not enough
309
+ app.post('/a/:alias/account/2fa/:id/delete', async (req: Req, reply) => {
310
+ const r = await signedIn(req, reply);
311
+ if (!r) return;
312
+ const { ctx, id } = r;
313
+ const t = ctx.locale.t;
314
+ if (await loginThrottled(ctx.app.id, ctx.user, ctx.ip)) {
315
+ logActivity({ appId: ctx.app.id, username: ctx.user, event: 'login_locked', ip: ctx.ip, detail: '2fa remove' });
316
+ return done(reply, ctx, t('login.throttled', { minutes: loginWindowMinutes() }), true);
317
+ }
318
+ const factors = await accountFactors(id);
319
+ const factorId = Number((req.params as { id?: string }).id);
320
+ if (!factors.some((f) => f.id === factorId)) return done(reply, ctx);
321
+ if (factors.length === 1 && mfaRequired(ctx.app, ctx.roles)) return done(reply, ctx, t('mfa.keep_one'), true);
322
+ if (!(await checkAccountCode(id, String(req.body?.code ?? '').slice(0, 40)))) {
323
+ await logActivity({ appId: ctx.app.id, username: ctx.user, event: 'login_failed', ip: ctx.ip, detail: '2fa remove' });
324
+ return done(reply, ctx, t('mfa.wrong'), true);
325
+ }
326
+ await removeAccountFactor(id, factorId);
327
+ await logActivity({ appId: ctx.app.id, username: ctx.user, event: '2fa_removed', ip: ctx.ip, detail: 'authenticator app' });
328
+ return done(reply, ctx, t('mfa.removed'));
329
+ });
330
+ }
331
+
332
+ /** The app secret being added on My account (kept in the session for some minutes), or null. */
333
+ function newSecret(s: Session, minutes: number): string | null {
334
+ try {
335
+ const p = JSON.parse(String(s.state.__TOTP_NEW ?? ''));
336
+ return p && typeof p.secret === 'string' && Date.now() - Number(p.at) < minutes * 60_000 ? p.secret : null;
337
+ } catch {
338
+ return null;
339
+ }
340
+ }
341
+
342
+ /** My account → Two-step sign-in (when the application has a second step). */
343
+ export async function twoStepSection(ctx: PageContext): Promise<Raw | ''> {
344
+ if (!mfaOn(ctx.app)) return '';
345
+ const id = await accountId(ctx.user);
346
+ if (id === null) return '';
347
+ const t = ctx.locale.t;
348
+ const [factors, left] = await Promise.all([accountFactors(id), accountCodesLeft(id)]);
349
+ const required = mfaRequired(ctx.app, ctx.roles);
350
+ const pending = newSecret(ctx.session, 10);
351
+ let shown: string[] | null = null;
352
+ try {
353
+ const c = JSON.parse(String(ctx.session.state.__CODES_NEW ?? ''));
354
+ shown = Array.isArray(c) ? c : null;
355
+ } catch {
356
+ shown = null;
357
+ }
358
+ const error = typeof ctx.session.state.__FLASH_ERROR === 'string' ? ctx.session.state.__FLASH_ERROR : '';
359
+ delete ctx.session.state.__FLASH_ERROR;
360
+ const base = `${ctx.base}/account/2fa`;
361
+ const csrf = html`<input type="hidden" name="__csrf" value="${ctx.session.csrf_token}">`;
362
+ const when = (v: string | null) => (v ? String(v).slice(0, 10) : '');
363
+ return html`<section class="region region-standard col-6" id="two-step"><header class="region-header"><h2>${t('mfa.step_title')}</h2></header><div class="region-body">
364
+ ${error ? html`<div class="alert alert-error" role="alert">${error}</div>` : ''}
365
+ <p class="muted u-mt0">${factors.length ? t('mfa.on') : required ? t('mfa.required_you') : t('mfa.off')}</p>
366
+ ${shown
367
+ ? html`<h3>${t('mfa.codes_title')}</h3><p>${t('mfa.codes_once')}</p>${codesList(shown)}
368
+ <form method="post" action="${base}/codes/confirm">${csrf}
369
+ <label class="check"><input type="checkbox" name="saved" value="1" required> ${t('mfa.codes_saved')}</label>
370
+ <div class="buttons"><button class="btn btn-hot">${t('mfa.continue')}</button></div></form>`
371
+ : ''}
372
+ ${factors.length
373
+ ? html`<ul class="mfa-factors">${factors.map((f) => html`<li><strong>${f.name}</strong> <span class="muted">${when(f.created_at)}</span>
374
+ <details><summary class="link-button">${t('mfa.remove')}</summary>
375
+ <form method="post" action="${base}/${f.id}/delete">${csrf}
376
+ <div class="field"><label class="label" for="rm_${f.id}">${t('mfa.remove_code')}</label><input id="rm_${f.id}" name="code" required autocomplete="one-time-code" maxlength="20"></div>
377
+ <div class="buttons"><button class="btn btn-danger">${t('mfa.remove')}</button></div></form></details></li>`)}</ul>
378
+ <p>${t('mfa.codes_left', { n: left })}</p>
379
+ <form method="post" action="${base}/codes">${csrf}<button class="btn">${t('mfa.codes_new')}</button></form>`
380
+ : ''}
381
+ ${pending
382
+ ? html`<h3>${t('mfa.add_app')}</h3>
383
+ <ol class="mfa-steps"><li>${t('mfa.scan')}</li><li>${t('mfa.or_enter')} <code class="mfa-secret">${grouped(pending)}</code></li><li>${t('mfa.enter_code')}</li></ol>
384
+ <div class="mfa-qr">${raw(qrSvg(otpauthUri(pending, ctx.user, ctx.app.name), { label: t('mfa.qr_label'), px: 4 }) ?? '')}</div>
385
+ <form method="post" action="${base}/confirm">${csrf}
386
+ <div class="field"><label class="label" for="mfa_name">${t('mfa.app_name')}</label><input id="mfa_name" name="name" value="${t('mfa.app_default')}" required maxlength="60"></div>
387
+ <div class="field"><label class="label" for="mfa_code">${t('mfa.code')}</label><input id="mfa_code" name="code" required inputmode="numeric" autocomplete="one-time-code" maxlength="7"></div>
388
+ <div class="buttons"><button class="btn btn-hot">${t('mfa.confirm_app')}</button></div></form>
389
+ <form method="post" action="${base}/cancel">${csrf}<button class="link-button">${t('mfa.cancel')}</button></form>`
390
+ : secretKeyConfigured()
391
+ ? html`<form method="post" action="${base}/start">${csrf}<div class="buttons"><button class="btn${factors.length ? '' : ' btn-hot'}">${t('mfa.add_app')}</button></div></form>`
392
+ : html`<p class="alert alert-info" role="status">${t('mfa.no_key')}</p>`}
393
+ </div></section>`;
394
+ }
395
+
package/src/saml.ts CHANGED
@@ -76,7 +76,7 @@ function client(p: Provider, cache: CacheProvider) {
76
76
  }
77
77
 
78
78
  /** Start: the IdP URL with the AuthnRequest, and the browser-binding cookie value. */
79
- export async function startSamlSignIn(p: Provider, appId: number, next: string | undefined) {
79
+ export async function startSamlSignIn(p: Provider, appId: number | null, next: string | undefined, link?: string) {
80
80
  const state = b64url(randomBytes(32));
81
81
  const browserKey = b64url(randomBytes(32));
82
82
  await owner.query(`delete from meta.sso_pending where created_at < now() - make_interval(mins => $1)`, [PENDING_MINUTES]);
@@ -86,9 +86,9 @@ export async function startSamlSignIn(p: Provider, appId: number, next: string |
86
86
  if (!requestId) throw new SsoError('The AuthnRequest has no ID.');
87
87
  // code_verifier is unused for SAML; nonce holds the AuthnRequest ID the response must answer
88
88
  await owner.query(
89
- `insert into meta.sso_pending (state, app_id, provider_id, code_verifier, nonce, browser_hash, next)
90
- values ($1, $2, $3, '', $4, $5, $6)`,
91
- [state, appId, p.id, requestId, sha256(browserKey), next ?? null],
89
+ `insert into meta.sso_pending (state, app_id, provider_id, code_verifier, nonce, browser_hash, next, link_username)
90
+ values ($1, $2, $3, '', $4, $5, $6, $7)`,
91
+ [state, appId, p.id, requestId, sha256(browserKey), next ?? null, link ?? null],
92
92
  );
93
93
  return { url, browserKey };
94
94
  }
@@ -99,7 +99,7 @@ const values = (v: unknown): string[] => (v === undefined || v === null ? [] : (
99
99
  export async function finishSamlSignIn(p: Provider, body: { SAMLResponse?: string; RelayState?: string }, browserKey: string | undefined): Promise<SsoResult> {
100
100
  const pending = await owner.one(
101
101
  `delete from meta.sso_pending where state = $1 and provider_id = $2 and created_at > now() - make_interval(mins => $3)
102
- returning app_id, nonce, browser_hash, next`,
102
+ returning app_id, nonce, browser_hash, next, link_username`,
103
103
  [body.RelayState ?? '', p.id, PENDING_MINUTES],
104
104
  );
105
105
  if (!pending) throw new SsoError('This sign-in has expired or was already used. Please try again.');
@@ -117,8 +117,14 @@ export async function finishSamlSignIn(p: Provider, body: { SAMLResponse?: strin
117
117
  if (!profile?.nameID) throw new SsoError('The SAML response names no user.');
118
118
  const username = p.username_claim === 'nameID' ? profile.nameID : values(profile[p.username_claim])[0];
119
119
  if (!usableUsername(username)) throw new SsoError(`The SAML response has no usable "${p.username_claim}" attribute.`);
120
- const account = await resolveAccount(p, { sub: profile.nameID, name: values(profile.displayName ?? profile.cn)[0], email: profile.email ?? profile.mail }, username);
121
- return { appId: pending.app_id, next: pending.next, username: account, groups: values(profile[p.groups_claim]) };
120
+ const name = values(profile.displayName ?? profile.cn)[0];
121
+ const email = values(profile.email ?? profile.mail)[0];
122
+ const groups = values(profile[p.groups_claim]);
123
+ if (pending.app_id === null)
124
+ // the builder: devsso.ts finds the developer (sprint 129)
125
+ return { appId: null, next: pending.next, username, groups, link: pending.link_username, identity: { subject: profile.nameID, name: name ?? null, email: email ?? null, mayLink: true } };
126
+ const account = await resolveAccount(p, { sub: profile.nameID, name, email: profile.email ?? profile.mail }, username);
127
+ return { appId: pending.app_id, next: pending.next, username: account, groups };
122
128
  }
123
129
 
124
130
  /** Service provider metadata (entity ID, ACS) to register pgkiln at the IdP. */
@@ -73,7 +73,7 @@ export async function useShared(req: FastifyRequest, reply: FastifyReply, a: Sha
73
73
  await owner.query('delete from meta.shared_login where token_hash = $1', [hash]);
74
74
  return clear(), null;
75
75
  }
76
- if (row.method === 'password' && (await passwordDaysLeft(row.username)) === 0) return null;
76
+ if (row.method.split('+')[0] === 'password' && (await passwordDaysLeft(row.username)) === 0) return null;
77
77
  const access = await ssoAccess(a.id, row.username, row.groups);
78
78
  if (!access.allowed) return null; // no access to this app: its own sign-in page (the shared sign-in stays for the others)
79
79
  return { username: row.username, roles: access.roles, groups: row.groups, method: row.method, hash };