pgkiln 0.63.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 (47) hide show
  1. package/SECURITY.md +2 -0
  2. package/db/migrations/132_app_user_mfa.sql +58 -0
  3. package/docs/apex-feature-parity.md +4 -1
  4. package/docs/guide/08-security.md +46 -5
  5. package/docs/guide/09-reference.md +5 -1
  6. package/docs/guide/11-from-apex.md +2 -1
  7. package/docs/guide/12-development.md +6 -0
  8. package/package.json +1 -1
  9. package/public/app.css +10 -0
  10. package/src/account-mfa.ts +93 -0
  11. package/src/builder/advisor.ts +8 -0
  12. package/src/builder/i18n/de.ts +6 -0
  13. package/src/builder/i18n/en.ts +6 -0
  14. package/src/builder/i18n/es.ts +6 -0
  15. package/src/builder/i18n/fr.ts +6 -0
  16. package/src/builder/i18n/nl.ts +6 -0
  17. package/src/builder/i18n/pt.ts +6 -0
  18. package/src/builder/login-page.ts +26 -2
  19. package/src/builder/users.ts +34 -3
  20. package/src/i18n/ar.ts +34 -0
  21. package/src/i18n/cs.ts +34 -0
  22. package/src/i18n/da.ts +34 -0
  23. package/src/i18n/de.ts +34 -0
  24. package/src/i18n/el.ts +34 -0
  25. package/src/i18n/es.ts +34 -0
  26. package/src/i18n/fi.ts +34 -0
  27. package/src/i18n/fr.ts +34 -0
  28. package/src/i18n/he.ts +34 -0
  29. package/src/i18n/it.ts +34 -0
  30. package/src/i18n/ja.ts +34 -0
  31. package/src/i18n/ko.ts +34 -0
  32. package/src/i18n/nb.ts +34 -0
  33. package/src/i18n/pl.ts +34 -0
  34. package/src/i18n/pt.ts +34 -0
  35. package/src/i18n/ru.ts +34 -0
  36. package/src/i18n/sv.ts +34 -0
  37. package/src/i18n/tr.ts +34 -0
  38. package/src/i18n/uk.ts +34 -0
  39. package/src/i18n/zh.ts +34 -0
  40. package/src/i18n.ts +68 -0
  41. package/src/metadata.ts +4 -1
  42. package/src/mfa.ts +76 -52
  43. package/src/remember.ts +1 -1
  44. package/src/runtime/account.ts +4 -1
  45. package/src/runtime/routes.ts +10 -6
  46. package/src/runtime/two-step.ts +395 -0
  47. package/src/sharedlogin.ts +1 -1
@@ -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
+
@@ -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 };