pgkiln 0.64.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.
- package/db/migrations/133_app_user_passkeys_stepup.sql +54 -0
- package/docs/apex-feature-parity.md +5 -3
- package/docs/guide/08-security.md +41 -6
- package/docs/guide/12-development.md +10 -4
- package/docs/guide/13-rest-api.md +4 -0
- package/package.json +1 -1
- package/public/app.js +63 -0
- package/public/builder.js +1 -62
- package/src/account-mfa.ts +94 -3
- package/src/api.ts +9 -2
- package/src/builder/api.ts +3 -1
- package/src/builder/components.ts +1 -0
- package/src/builder/login-page.ts +10 -5
- package/src/builder/rest-modules.ts +1 -1
- package/src/builder/security.ts +3 -19
- package/src/builder/shared.ts +5 -3
- package/src/i18n/ar.ts +31 -0
- package/src/i18n/cs.ts +31 -0
- package/src/i18n/da.ts +31 -0
- package/src/i18n/de.ts +31 -0
- package/src/i18n/el.ts +31 -0
- package/src/i18n/es.ts +31 -0
- package/src/i18n/fi.ts +31 -0
- package/src/i18n/fr.ts +31 -0
- package/src/i18n/he.ts +31 -0
- package/src/i18n/it.ts +31 -0
- package/src/i18n/ja.ts +31 -0
- package/src/i18n/ko.ts +31 -0
- package/src/i18n/nb.ts +31 -0
- package/src/i18n/pl.ts +31 -0
- package/src/i18n/pt.ts +31 -0
- package/src/i18n/ru.ts +31 -0
- package/src/i18n/sv.ts +31 -0
- package/src/i18n/tr.ts +31 -0
- package/src/i18n/uk.ts +31 -0
- package/src/i18n/zh.ts +31 -0
- package/src/i18n.ts +62 -0
- package/src/metadata.ts +8 -2
- package/src/mfa.ts +18 -13
- package/src/runtime/account.ts +6 -3
- package/src/runtime/authz.ts +23 -4
- package/src/runtime/routes.ts +11 -1
- package/src/runtime/two-step.ts +379 -72
- package/src/webauthn.ts +20 -0
package/src/runtime/two-step.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
|
+
import { isIP } from 'node:net';
|
|
1
2
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
|
2
3
|
import {
|
|
3
|
-
accountCodesLeft, accountFactors, accountHasFactor, accountId,
|
|
4
|
-
|
|
4
|
+
accountCodesLeft, accountFactors, accountHasFactor, accountId, accountMustUseMfa, accountPasskeys, accountUserHandle, addAccountPasskey,
|
|
5
|
+
addAccountTotp, checkAccountCode, confirmAccountCodes, EMAIL_CODE_MINUTES, emailCodeHash, emailCodeMatches, emailCodeTarget, markSecondStep,
|
|
6
|
+
mfaOn, mfaRequired, newAccountCodes, newEmailCode, queueEmailCode, removeAccountFactor, secondStep, secondStepWithin, TOKEN_STEP_MINUTES,
|
|
7
|
+
useAccountPasskey, withSecondStep,
|
|
5
8
|
} from '../account-mfa.ts';
|
|
9
|
+
import { issueApiToken, MAX_TOKEN_HOURS } from '../api.ts';
|
|
6
10
|
import { html, raw, type Raw } from '../html.ts';
|
|
7
11
|
import { documentShell } from '../layout.ts';
|
|
8
12
|
import { accountRoles, type App } from '../metadata.ts';
|
|
@@ -11,6 +15,7 @@ import { qrSvg } from '../qrcode.ts';
|
|
|
11
15
|
import { secretKeyConfigured } from '../secrets.ts';
|
|
12
16
|
import { loginWindowMinutes } from '../security.ts';
|
|
13
17
|
import { clientIp, getSession, loginThrottled, logActivity, saveState, type Session } from '../session.ts';
|
|
18
|
+
import { ALGORITHMS, giveChallenge, relyingParty, takeChallenge, verifyAssertion, verifyRegistration, WebAuthnError } from '../webauthn.ts';
|
|
14
19
|
import type { PageContext } from './context.ts';
|
|
15
20
|
import type { Locale } from './locale.ts';
|
|
16
21
|
import { pwaHead } from './pwa.ts';
|
|
@@ -24,10 +29,16 @@ import { appWithLocale, completeLogin, loadContext, rootAttrs, safeNext, signIn,
|
|
|
24
29
|
// the step and the user has no app yet, the same page shows a QR code first, then the recovery codes once. Wrong
|
|
25
30
|
// codes count as failed sign-ins (the same lockout). The sign-in's method gets "+2fa", so a remembered or shared
|
|
26
31
|
// sign-in made from it may skip the step later.
|
|
32
|
+
//
|
|
33
|
+
// (sprint 131, SEC-3 part 2) Passkeys (WebAuthn, src/webauthn.ts; public/app.js [data-passkey]) at the second step
|
|
34
|
+
// and on My account, also as the first factor a user adds while signing in; a code by e-mail when the application
|
|
35
|
+
// allows it, for users who have another factor; step-up: a signed-in user whose page needs a more recent second step
|
|
36
|
+
// comes here with ?next= and goes back after it (the session's __MFA_AT); and My account → REST API token.
|
|
27
37
|
|
|
28
38
|
const PENDING = '__MFA';
|
|
29
39
|
const STEP_SECONDS = 300;
|
|
30
40
|
const ENROL_SECONDS = 600;
|
|
41
|
+
const EMAILS_PER_STEP = 3;
|
|
31
42
|
|
|
32
43
|
interface Pending {
|
|
33
44
|
u: string;
|
|
@@ -39,21 +50,31 @@ interface Pending {
|
|
|
39
50
|
detail?: string;
|
|
40
51
|
at: number;
|
|
41
52
|
enrol?: boolean;
|
|
53
|
+
/** (sprint 131) a signed-in user asked again (step-up): no new session, back to `next` */
|
|
54
|
+
stepup?: boolean;
|
|
42
55
|
/** while adding an app: the new secret, then (after its first code) the recovery codes to show once */
|
|
43
56
|
secret?: string;
|
|
44
57
|
codes?: string[];
|
|
58
|
+
/** (sprint 131) what was added while signing in, for the activity log */
|
|
59
|
+
added?: 'app' | 'passkey';
|
|
60
|
+
/** (sprint 131) a code sent by e-mail: its hash, when, and how many were sent in this step */
|
|
61
|
+
mail?: { h: string; at: number; n: number };
|
|
45
62
|
}
|
|
46
63
|
|
|
47
64
|
function pendingOf(s: Session): Pending | null {
|
|
48
65
|
try {
|
|
49
66
|
const p = JSON.parse(String(s.state[PENDING] ?? '')) as Pending;
|
|
50
67
|
const limit = (p?.enrol ? ENROL_SECONDS : STEP_SECONDS) * 1000;
|
|
51
|
-
|
|
68
|
+
if (!p || typeof p.u !== 'string' || Date.now() - Number(p.at) >= limit) return null;
|
|
69
|
+
// a step-up belongs to the signed-in user; a sign-in's step to a signed-out session
|
|
70
|
+
return !!p.stepup === !!s.username && (!p.stepup || p.u === s.username) ? p : null;
|
|
52
71
|
} catch {
|
|
53
72
|
return null;
|
|
54
73
|
}
|
|
55
74
|
}
|
|
56
75
|
|
|
76
|
+
const keep = (s: Session, p: Pending) => { s.state[PENDING] = JSON.stringify(p); };
|
|
77
|
+
|
|
57
78
|
interface PasswordSignIn {
|
|
58
79
|
next?: string;
|
|
59
80
|
remember?: boolean;
|
|
@@ -71,20 +92,19 @@ export async function passwordSignIn(req: FastifyRequest, reply: FastifyReply, a
|
|
|
71
92
|
const roles = [...(await accountRoles(a.id, username)), ...(opts.extraRoles ?? []).map((r) => r.toLowerCase())];
|
|
72
93
|
const need = await secondStep(a, username, roles);
|
|
73
94
|
if (!need) return completeLogin(req, reply, a, session, username, opts);
|
|
74
|
-
|
|
75
|
-
session.state[PENDING] = JSON.stringify(p);
|
|
95
|
+
keep(session, { u: username, ...opts, at: Date.now(), enrol: need === 'enrol' });
|
|
76
96
|
await saveState(session);
|
|
77
97
|
return reply.redirect(`/a/${a.alias}/login/2fa`, 303);
|
|
78
98
|
}
|
|
79
99
|
|
|
80
|
-
function card(a: App, locale: Locale, title: string, body: Raw) {
|
|
100
|
+
function card(a: App, locale: Locale, title: string, body: Raw, back = `/a/${a.alias}/login`) {
|
|
81
101
|
return documentShell(
|
|
82
102
|
`${title} · ${a.name}`,
|
|
83
103
|
html`<main class="login">
|
|
84
104
|
<div class="card login-card">
|
|
85
105
|
<h1>${title}</h1>
|
|
86
106
|
${body}
|
|
87
|
-
<a class="login-forgot" href="
|
|
107
|
+
<a class="login-forgot" href="${back}">${locale.t('reset.back')}</a>
|
|
88
108
|
</div>
|
|
89
109
|
</main>`,
|
|
90
110
|
'login-body',
|
|
@@ -95,86 +115,150 @@ function card(a: App, locale: Locale, title: string, body: Raw) {
|
|
|
95
115
|
}
|
|
96
116
|
|
|
97
117
|
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
|
|
118
|
+
const alert = (error?: string, kind = 'error') => html`${error ? html`<div class="alert alert-${kind}" role="${kind === 'error' ? 'alert' : 'status'}">${error}</div>` : ''}`;
|
|
99
119
|
const grouped = (secret: string) => secret.replace(/(.{4})/g, '$1 ').trim();
|
|
100
120
|
|
|
101
121
|
function codesList(codes: string[]) {
|
|
102
122
|
return html`<ol class="mfa-codes">${codes.map((c) => html`<li><code>${c}</code></li>`)}</ol>`;
|
|
103
123
|
}
|
|
104
124
|
|
|
125
|
+
/** The hidden fields public/app.js fills with the browser's WebAuthn answer. */
|
|
126
|
+
const passkeyFields = (names: string[]) => html`${names.map((n) => html`<input type="hidden" name="${n}">`)}`;
|
|
127
|
+
const ASSERTION = ['credentialId', 'clientDataJSON', 'authenticatorData', 'signature', 'userHandle'];
|
|
128
|
+
const ATTESTATION = ['clientDataJSON', 'attestationObject', 'transports'];
|
|
129
|
+
|
|
130
|
+
/** What the second step's page offers besides a code. */
|
|
131
|
+
interface Offers { passkey: boolean; mail: boolean; newPasskey: boolean; notice?: string }
|
|
132
|
+
|
|
105
133
|
/** 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) {
|
|
134
|
+
function stepPage(a: App, locale: Locale, s: Session, p: Pending, offers: Offers, error?: string) {
|
|
107
135
|
const t = locale.t;
|
|
108
136
|
const action = `/a/${a.alias}/login/2fa`;
|
|
137
|
+
const back = p.stepup ? safeNext(a, p.next) : undefined;
|
|
109
138
|
if (p.codes)
|
|
110
139
|
return card(a, locale, t('mfa.codes_title'), html`<p>${t('mfa.codes_once')}</p>${codesList(p.codes)}
|
|
111
140
|
<form method="post" action="${action}/done" class="login-form">${csrfField(s)}
|
|
112
141
|
<label class="check"><input type="checkbox" name="saved" value="1" required> ${t('mfa.codes_saved')}</label>
|
|
113
142
|
<button class="btn btn-hot">${t('mfa.continue')}</button></form>`);
|
|
114
|
-
if (p.enrol && p.secret)
|
|
143
|
+
if (p.enrol && (p.secret || offers.newPasskey))
|
|
115
144
|
return card(a, locale, t('mfa.enrol_title'), html`<p class="muted">${t('mfa.enrol_intro')}</p>${alert(error)}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
145
|
+
${p.secret
|
|
146
|
+
? html`<ol class="mfa-steps">
|
|
147
|
+
<li>${t('mfa.scan')}</li>
|
|
148
|
+
<li>${t('mfa.or_enter')} <code class="mfa-secret">${grouped(p.secret)}</code></li>
|
|
149
|
+
<li>${t('mfa.enter_code')}</li>
|
|
150
|
+
</ol>
|
|
151
|
+
<div class="mfa-qr">${raw(qrSvg(otpauthUri(p.secret, p.u, a.name), { label: t('mfa.qr_label'), px: 4 }) ?? '')}</div>
|
|
152
|
+
<form method="post" action="${action}" class="login-form">${csrfField(s)}
|
|
153
|
+
<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>
|
|
154
|
+
<button class="btn btn-hot">${t('mfa.verify')}</button></form>`
|
|
155
|
+
: ''}
|
|
156
|
+
${offers.newPasskey
|
|
157
|
+
? html`<form method="post" action="${action}/passkey/new" class="login-form mfa-passkey">${csrfField(s)}${passkeyFields(ATTESTATION)}
|
|
158
|
+
<p class="muted">${p.secret ? t('mfa.or_passkey') : t('mfa.passkey_intro')}</p>
|
|
159
|
+
<button class="btn${p.secret ? '' : ' btn-hot'}" hidden data-passkey="${action}/passkey/new/options" data-passkey-error="${t('mfa.passkey_failed')}">${t('mfa.add_passkey')}</button>
|
|
160
|
+
<p class="small" role="status" data-passkey-status></p></form>`
|
|
161
|
+
: ''}`);
|
|
162
|
+
return card(a, locale, p.stepup ? t('mfa.stepup_title') : t('mfa.step_title'), html`<p class="muted">${p.stepup ? t('mfa.stepup_intro') : t('mfa.step_intro')}</p>
|
|
163
|
+
${alert(offers.notice, 'success')}${alert(error)}
|
|
126
164
|
<form method="post" action="${action}" class="login-form">${csrfField(s)}
|
|
127
165
|
<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
|
|
166
|
+
<button class="btn btn-hot">${t('mfa.verify')}</button></form>
|
|
167
|
+
${offers.passkey
|
|
168
|
+
? html`<form method="post" action="${action}/passkey" class="login-form mfa-passkey">${csrfField(s)}${passkeyFields(ASSERTION)}
|
|
169
|
+
<button class="btn" hidden data-passkey="${action}/passkey/options" data-passkey-error="${t('mfa.passkey_failed')}">${t('mfa.use_passkey')}</button>
|
|
170
|
+
<p class="small" role="status" data-passkey-status></p></form>`
|
|
171
|
+
: ''}
|
|
172
|
+
${offers.mail
|
|
173
|
+
? html`<form method="post" action="${action}/email" class="login-form">${csrfField(s)}<button class="link-button">${t('mfa.email_send')}</button></form>`
|
|
174
|
+
: ''}`, back);
|
|
129
175
|
}
|
|
130
176
|
|
|
131
|
-
/** The signed-out session's pending sign-in, or a page that says it expired. */
|
|
177
|
+
/** The signed-out session's pending sign-in (or a signed-in user's step-up), or a page that says it expired. */
|
|
132
178
|
async function start(req: Req, reply: FastifyReply) {
|
|
133
179
|
const loaded = await appWithLocale(req, req.params.alias);
|
|
134
180
|
if (!loaded) return null;
|
|
135
181
|
const session = await getSession(req, reply, loaded.app.id, `/a/${loaded.app.alias}`);
|
|
136
182
|
const { app: a, locale } = (await appWithLocale(req, req.params.alias, session))!;
|
|
137
|
-
|
|
138
|
-
return { a, locale, session, p };
|
|
183
|
+
return { a, locale, session, p: pendingOf(session) };
|
|
139
184
|
}
|
|
140
185
|
|
|
141
186
|
const expired = async (reply: FastifyReply, a: App, locale: Locale, s: Session) => {
|
|
187
|
+
const wasStepUp = !!s.username;
|
|
142
188
|
delete s.state[PENDING];
|
|
143
189
|
await saveState(s);
|
|
190
|
+
if (wasStepUp) return reply.redirect(`/a/${a.alias}/${a.home_page}`, 303);
|
|
144
191
|
return reply.code(410).type('text/html').send(card(a, locale, locale.t('mfa.step_title'), alert(locale.t('mfa.expired'))));
|
|
145
192
|
};
|
|
146
193
|
|
|
147
|
-
/**
|
|
194
|
+
/** What the page offers this user here: their passkeys for this site, an e-mail code, a new passkey while enrolling. */
|
|
195
|
+
async function offersFor(req: FastifyRequest, a: App, p: Pending, id: number, notice?: string): Promise<Offers> {
|
|
196
|
+
const rp = relyingParty(req);
|
|
197
|
+
const host = !isIP(rp.rpId);
|
|
198
|
+
const [keys, mail] = await Promise.all([accountPasskeys(id, rp.rpId), p.enrol ? null : emailCodeTarget(a, id)]);
|
|
199
|
+
return { passkey: host && keys.length > 0, mail: !!mail && (p.mail?.n ?? 0) < EMAILS_PER_STEP, newPasskey: host && !!p.enrol && !p.codes, notice };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Signed in (or, for a step-up, confirmed) with the pending sign-in's options, now with "+2fa" in its method. */
|
|
148
203
|
async function finish(req: Req, reply: FastifyReply, a: App, s: Session, p: Pending, detail: string, next?: string, flash?: string) {
|
|
204
|
+
if (p.stepup) {
|
|
205
|
+
delete s.state[PENDING];
|
|
206
|
+
markSecondStep(s.state);
|
|
207
|
+
if (flash) s.state.__FLASH = flash;
|
|
208
|
+
await saveState(s);
|
|
209
|
+
await logActivity({ appId: a.id, username: p.u, event: '2fa_stepup', ip: clientIp(req), detail });
|
|
210
|
+
return reply.redirect(safeNext(a, p.next), 303);
|
|
211
|
+
}
|
|
149
212
|
const n = await signIn(req, reply, a, s, p.u, {
|
|
150
213
|
extraRoles: p.extraRoles, groups: p.groups, remember: p.remember, method: withSecondStep(p.method),
|
|
151
214
|
detail: `${p.detail ?? 'password'} ${detail}`,
|
|
152
215
|
});
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
}
|
|
216
|
+
markSecondStep(n.state);
|
|
217
|
+
if (flash) n.state.__FLASH = flash;
|
|
218
|
+
await saveState(n);
|
|
157
219
|
return reply.redirect(next ?? safeNext(a, p.next), 303);
|
|
158
220
|
}
|
|
159
221
|
|
|
222
|
+
/** Too many wrong codes: the same lockout as wrong passwords. Returns a reply when locked. */
|
|
223
|
+
async function locked(reply: FastifyReply, a: App, locale: Locale, s: Session, p: Pending, ip: string, what: string) {
|
|
224
|
+
if (!(await loginThrottled(a.id, p.u, ip))) return null;
|
|
225
|
+
logActivity({ appId: a.id, username: p.u, event: 'login_locked', ip, detail: what });
|
|
226
|
+
delete s.state[PENDING];
|
|
227
|
+
await saveState(s);
|
|
228
|
+
return reply.code(429).type('text/html').send(card(a, locale, locale.t('mfa.step_title'), alert(locale.t('login.throttled', { minutes: loginWindowMinutes() }))));
|
|
229
|
+
}
|
|
230
|
+
|
|
160
231
|
export async function twoStepRoutes(app: FastifyInstance) {
|
|
161
232
|
app.get('/a/:alias/login/2fa', async (req: Req, reply) => {
|
|
162
233
|
const r = await start(req, reply);
|
|
163
234
|
if (!r) return reply.code(404).send('Not found');
|
|
164
|
-
const { a, locale, session: s
|
|
235
|
+
const { a, locale, session: s } = r;
|
|
236
|
+
let p = r.p;
|
|
237
|
+
// (sprint 131) step-up: a signed-in user sent here by a page that needs a more recent second step
|
|
238
|
+
const next = typeof (req.query as { next?: unknown }).next === 'string' ? String((req.query as { next: string }).next).slice(0, 2000) : undefined;
|
|
239
|
+
if (s.username && (!p || (next && p.next !== next))) {
|
|
240
|
+
const id = await accountId(s.username);
|
|
241
|
+
if (id === null || !(await accountHasFactor(id)))
|
|
242
|
+
return reply.code(403).type('text/html').send(card(a, locale, locale.t('mfa.stepup_title'),
|
|
243
|
+
html`${alert(locale.t('mfa.stepup_none'))}<p><a class="btn" href="/a/${a.alias}/account#two-step">${locale.t('mfa.my_account')}</a></p>`, `/a/${a.alias}/${a.home_page}`));
|
|
244
|
+
p = { u: s.username, next, at: Date.now(), stepup: true };
|
|
245
|
+
keep(s, p);
|
|
246
|
+
}
|
|
165
247
|
if (!p) return expired(reply, a, locale, s);
|
|
166
248
|
const id = await accountId(p.u);
|
|
167
249
|
if (id === null) return expired(reply, a, locale, s);
|
|
250
|
+
const offers = await offersFor(req, a, p, id);
|
|
168
251
|
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()
|
|
171
|
-
|
|
252
|
+
// fail closed: without the instance key there is nowhere safe to keep the app's secret (a passkey needs none)
|
|
253
|
+
if (!secretKeyConfigured() && !offers.newPasskey)
|
|
254
|
+
return reply.code(503).type('text/html').send(card(a, locale, locale.t('mfa.enrol_title'), alert(locale.t('mfa.no_key'))));
|
|
255
|
+
if (!p.secret && secretKeyConfigured()) {
|
|
172
256
|
p.secret = newTotpSecret();
|
|
173
|
-
s
|
|
257
|
+
keep(s, p);
|
|
174
258
|
}
|
|
175
259
|
}
|
|
176
260
|
await saveState(s);
|
|
177
|
-
return reply.type('text/html').send(stepPage(a, locale, s, p));
|
|
261
|
+
return reply.header('cache-control', 'no-store').type('text/html').send(stepPage(a, locale, s, p, offers));
|
|
178
262
|
});
|
|
179
263
|
|
|
180
264
|
app.post('/a/:alias/login/2fa', async (req: Req, reply) => {
|
|
@@ -186,53 +270,163 @@ export async function twoStepRoutes(app: FastifyInstance) {
|
|
|
186
270
|
const ip = clientIp(req);
|
|
187
271
|
const id = await accountId(p.u);
|
|
188
272
|
if (id === null) return expired(reply, a, locale, s);
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
}
|
|
273
|
+
const lock = await locked(reply, a, locale, s, p, ip, '2fa');
|
|
274
|
+
if (lock) return lock;
|
|
195
275
|
const code = String(req.body?.code ?? '').slice(0, 40);
|
|
276
|
+
const wrong = async (detail: string) => {
|
|
277
|
+
await logActivity({ appId: a.id, username: p.u, event: 'login_failed', ip, detail });
|
|
278
|
+
return reply.code(401).type('text/html').send(stepPage(a, locale, s, p, await offersFor(req, a, p, id), t('mfa.wrong')));
|
|
279
|
+
};
|
|
196
280
|
// adding the first app while signing in: its first code proves it works
|
|
197
281
|
if (p.enrol && p.secret && !p.codes) {
|
|
198
282
|
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
|
-
}
|
|
283
|
+
if (step === null) return wrong('2fa (new app)');
|
|
203
284
|
await addAccountTotp(id, t('mfa.app_default'), p.secret, step);
|
|
204
285
|
await logActivity({ appId: a.id, username: p.u, event: '2fa_enrolled', ip, detail: 'authenticator app (sign-in)' });
|
|
205
286
|
p.codes = await newAccountCodes(id);
|
|
287
|
+
p.added = 'app';
|
|
206
288
|
delete p.secret;
|
|
207
|
-
s
|
|
289
|
+
keep(s, p);
|
|
208
290
|
await saveState(s);
|
|
209
|
-
return reply.type('text/html').send(stepPage(a, locale, s, p));
|
|
291
|
+
return reply.type('text/html').send(stepPage(a, locale, s, p, await offersFor(req, a, p, id)));
|
|
210
292
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
293
|
+
if (p.enrol) return expired(reply, a, locale, s);
|
|
294
|
+
// (sprint 131) the code sent by e-mail, valid for some minutes and once
|
|
295
|
+
if (p.mail && Date.now() - p.mail.at < EMAIL_CODE_MINUTES * 60_000 && emailCodeMatches(p.mail.h, code)) {
|
|
296
|
+
delete p.mail;
|
|
297
|
+
keep(s, p);
|
|
298
|
+
return finish(req, reply, a, s, p, '2fa (e-mail code)');
|
|
215
299
|
}
|
|
300
|
+
const ok = await checkAccountCode(id, code);
|
|
301
|
+
if (!ok) return wrong('2fa');
|
|
216
302
|
if (ok.kind === 'recovery') {
|
|
217
303
|
const left = await accountCodesLeft(id);
|
|
218
304
|
await logActivity({ appId: a.id, username: p.u, event: '2fa_recovery_used', ip, detail: `${left} left` });
|
|
219
305
|
// 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 }));
|
|
306
|
+
return finish(req, reply, a, s, p, '2fa (recovery code)', p.stepup ? undefined : `/a/${a.alias}/account#two-step`, t('mfa.recovery_used', { n: left }));
|
|
221
307
|
}
|
|
222
308
|
return finish(req, reply, a, s, p, '2fa');
|
|
223
309
|
});
|
|
224
310
|
|
|
225
|
-
//
|
|
311
|
+
// (sprint 131) a code by e-mail, for a user who has an authenticator app or a passkey (never the only factor)
|
|
312
|
+
app.post('/a/:alias/login/2fa/email', async (req: Req, reply) => {
|
|
313
|
+
const r = await start(req, reply);
|
|
314
|
+
if (!r) return reply.code(404).send('Not found');
|
|
315
|
+
const { a, locale, session: s, p } = r;
|
|
316
|
+
const t = locale.t;
|
|
317
|
+
if (!p || p.enrol || req.body?.__csrf !== s.csrf_token) return expired(reply, a, locale, s);
|
|
318
|
+
const ip = clientIp(req);
|
|
319
|
+
const id = await accountId(p.u);
|
|
320
|
+
if (id === null) return expired(reply, a, locale, s);
|
|
321
|
+
const lock = await locked(reply, a, locale, s, p, ip, '2fa e-mail');
|
|
322
|
+
if (lock) return lock;
|
|
323
|
+
const target = (p.mail?.n ?? 0) < EMAILS_PER_STEP ? await emailCodeTarget(a, id) : null;
|
|
324
|
+
const code = newEmailCode();
|
|
325
|
+
const sent = !!target && (await queueEmailCode(a, p.u, target,
|
|
326
|
+
t('mfa.email_subject', { app: a.name }), t('mfa.email_body', { app: a.name, code, minutes: EMAIL_CODE_MINUTES })));
|
|
327
|
+
if (sent) {
|
|
328
|
+
p.mail = { h: emailCodeHash(code), at: Date.now(), n: (p.mail?.n ?? 0) + 1 };
|
|
329
|
+
keep(s, p);
|
|
330
|
+
await logActivity({ appId: a.id, username: p.u, event: '2fa_email', ip, detail: 'code sent' });
|
|
331
|
+
}
|
|
332
|
+
await saveState(s);
|
|
333
|
+
const offers = await offersFor(req, a, p, id, sent ? t('mfa.email_sent', { minutes: EMAIL_CODE_MINUTES }) : undefined);
|
|
334
|
+
return reply.code(sent ? 200 : 429).type('text/html').send(stepPage(a, locale, s, p, offers, sent ? undefined : t('mfa.email_failed')));
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
// (sprint 131) the second step with a passkey: options for navigator.credentials.get(), then the browser's answer
|
|
338
|
+
app.post('/a/:alias/login/2fa/passkey/options', async (req: Req, reply) => {
|
|
339
|
+
const r = await start(req, reply);
|
|
340
|
+
if (!r) return reply.code(404).send('Not found');
|
|
341
|
+
const { session: s, p } = r;
|
|
342
|
+
if (!p || p.enrol || req.body?.__csrf !== s.csrf_token) return reply.code(403).send('Sign in again.');
|
|
343
|
+
const id = await accountId(p.u);
|
|
344
|
+
const rp = relyingParty(req);
|
|
345
|
+
const keys = id === null ? [] : await accountPasskeys(id, rp.rpId);
|
|
346
|
+
if (!keys.length) return reply.code(404).send('No passkey for this site.');
|
|
347
|
+
const challenge = giveChallenge(s, 'get');
|
|
348
|
+
await saveState(s);
|
|
349
|
+
return reply.header('cache-control', 'no-store').send({ mode: 'get', challenge, rpId: rp.rpId, allow: keys.map((k) => ({ id: k.credential_id, transports: k.transports })) });
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
app.post('/a/:alias/login/2fa/passkey', async (req: Req, reply) => {
|
|
353
|
+
const r = await start(req, reply);
|
|
354
|
+
if (!r) return reply.code(404).send('Not found');
|
|
355
|
+
const { a, locale, session: s, p } = r;
|
|
356
|
+
const challenge = takeChallenge(s, 'get');
|
|
357
|
+
if (!p || p.enrol || req.body?.__csrf !== s.csrf_token) return expired(reply, a, locale, s);
|
|
358
|
+
const ip = clientIp(req);
|
|
359
|
+
const id = await accountId(p.u);
|
|
360
|
+
if (id === null) return expired(reply, a, locale, s);
|
|
361
|
+
const lock = await locked(reply, a, locale, s, p, ip, '2fa passkey');
|
|
362
|
+
if (lock) return lock;
|
|
363
|
+
const rp = relyingParty(req);
|
|
364
|
+
const key = (await accountPasskeys(id, rp.rpId)).find((k) => k.credential_id === String(req.body?.credentialId ?? ''));
|
|
365
|
+
let why = key ? '' : 'unknown passkey';
|
|
366
|
+
if (key && !challenge) why = 'no or an expired challenge';
|
|
367
|
+
if (key && challenge) {
|
|
368
|
+
try {
|
|
369
|
+
const v = verifyAssertion(
|
|
370
|
+
{ clientDataJSON: String(req.body?.clientDataJSON ?? ''), authenticatorData: String(req.body?.authenticatorData ?? ''), signature: String(req.body?.signature ?? '') },
|
|
371
|
+
{ alg: key.alg, jwk: key.public_key, signCount: Number(key.sign_count) }, { challenge, ...rp });
|
|
372
|
+
if (!(await useAccountPasskey(key.id, key.sign_count, v.signCount))) why = 'used at the same time elsewhere';
|
|
373
|
+
} catch (e) {
|
|
374
|
+
why = e instanceof WebAuthnError ? e.message : 'unreadable answer';
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
if (why) {
|
|
378
|
+
await logActivity({ appId: a.id, username: p.u, event: 'login_failed', ip, detail: `2fa passkey: ${why}` });
|
|
379
|
+
await saveState(s);
|
|
380
|
+
return reply.code(401).type('text/html').send(stepPage(a, locale, s, p, await offersFor(req, a, p, id), locale.t('mfa.passkey_failed')));
|
|
381
|
+
}
|
|
382
|
+
return finish(req, reply, a, s, p, '2fa (passkey)');
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
// (sprint 131) adding a passkey as the first factor while signing in (the application requires one)
|
|
386
|
+
app.post('/a/:alias/login/2fa/passkey/new/options', async (req: Req, reply) => {
|
|
387
|
+
const r = await start(req, reply);
|
|
388
|
+
if (!r) return reply.code(404).send('Not found');
|
|
389
|
+
const { a, session: s, p } = r;
|
|
390
|
+
if (!p?.enrol || p.codes || req.body?.__csrf !== s.csrf_token) return reply.code(403).send('Sign in again.');
|
|
391
|
+
const id = await accountId(p.u);
|
|
392
|
+
if (id === null) return reply.code(403).send('Sign in again.');
|
|
393
|
+
return reply.header('cache-control', 'no-store').send(await createOptions(req, s, a, id, p.u));
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
app.post('/a/:alias/login/2fa/passkey/new', async (req: Req, reply) => {
|
|
397
|
+
const r = await start(req, reply);
|
|
398
|
+
if (!r) return reply.code(404).send('Not found');
|
|
399
|
+
const { a, locale, session: s, p } = r;
|
|
400
|
+
const t = locale.t;
|
|
401
|
+
const challenge = takeChallenge(s, 'create');
|
|
402
|
+
if (!p?.enrol || p.codes || req.body?.__csrf !== s.csrf_token) return expired(reply, a, locale, s);
|
|
403
|
+
const id = await accountId(p.u);
|
|
404
|
+
if (id === null) return expired(reply, a, locale, s);
|
|
405
|
+
const ip = clientIp(req);
|
|
406
|
+
const added = challenge ? await registerPasskey(req, a, id, p.u, challenge, t('mfa.passkey_default'), ip, 'sign-in') : 'expired';
|
|
407
|
+
if (added !== 'ok') {
|
|
408
|
+
await saveState(s);
|
|
409
|
+
return reply.code(422).type('text/html').send(stepPage(a, locale, s, p, await offersFor(req, a, p, id), t(added === 'known' ? 'mfa.passkey_known' : 'mfa.passkey_failed')));
|
|
410
|
+
}
|
|
411
|
+
p.codes = await newAccountCodes(id);
|
|
412
|
+
p.added = 'passkey';
|
|
413
|
+
delete p.secret;
|
|
414
|
+
keep(s, p);
|
|
415
|
+
await saveState(s);
|
|
416
|
+
return reply.type('text/html').send(stepPage(a, locale, s, p, await offersFor(req, a, p, id)));
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
// the recovery codes were shown once after adding the first app or passkey while signing in
|
|
226
420
|
app.post('/a/:alias/login/2fa/done', async (req: Req, reply) => {
|
|
227
421
|
const r = await start(req, reply);
|
|
228
422
|
if (!r) return reply.code(404).send('Not found');
|
|
229
423
|
const { a, locale, session: s, p } = r;
|
|
230
424
|
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
425
|
const id = await accountId(p.u);
|
|
233
426
|
if (id === null) return expired(reply, a, locale, s);
|
|
427
|
+
if (req.body?.saved !== '1') return reply.code(422).type('text/html').send(stepPage(a, locale, s, p, await offersFor(req, a, p, id)));
|
|
234
428
|
await confirmAccountCodes(id);
|
|
235
|
-
return finish(req, reply, a, s, p,
|
|
429
|
+
return finish(req, reply, a, s, p, `2fa (new ${p.added === 'passkey' ? 'passkey' : 'app'})`);
|
|
236
430
|
});
|
|
237
431
|
|
|
238
432
|
// ---------------------------------------------------------------- My account → Two-step sign-in
|
|
@@ -247,10 +441,10 @@ export async function twoStepRoutes(app: FastifyInstance) {
|
|
|
247
441
|
}
|
|
248
442
|
return { ctx, id };
|
|
249
443
|
};
|
|
250
|
-
const done = async (reply: FastifyReply, ctx: PageContext, flash?: string, error?: boolean) => {
|
|
444
|
+
const done = async (reply: FastifyReply, ctx: PageContext, flash?: string, error?: boolean, anchor = 'two-step') => {
|
|
251
445
|
if (flash) ctx.session.state[error ? '__FLASH_ERROR' : '__FLASH'] = flash;
|
|
252
446
|
await saveState(ctx.session);
|
|
253
|
-
return reply.redirect(`${ctx.base}/account
|
|
447
|
+
return reply.redirect(`${ctx.base}/account#${anchor}`, 303);
|
|
254
448
|
};
|
|
255
449
|
|
|
256
450
|
app.post('/a/:alias/account/2fa/start', async (req: Req, reply) => {
|
|
@@ -287,6 +481,28 @@ export async function twoStepRoutes(app: FastifyInstance) {
|
|
|
287
481
|
return done(reply, ctx, t('mfa.added'));
|
|
288
482
|
});
|
|
289
483
|
|
|
484
|
+
// (sprint 131) a passkey on My account: the options for navigator.credentials.create(), then the browser's answer
|
|
485
|
+
app.post('/a/:alias/account/2fa/passkey/options', async (req: Req, reply) => {
|
|
486
|
+
const r = await signedIn(req, reply);
|
|
487
|
+
if (!r) return;
|
|
488
|
+
await saveState(r.ctx.session);
|
|
489
|
+
return reply.header('cache-control', 'no-store').send(await createOptions(req, r.ctx.session, r.ctx.app, r.id, r.ctx.user));
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
app.post('/a/:alias/account/2fa/passkey', async (req: Req, reply) => {
|
|
493
|
+
const r = await signedIn(req, reply);
|
|
494
|
+
if (!r) return;
|
|
495
|
+
const { ctx, id } = r;
|
|
496
|
+
const t = ctx.locale.t;
|
|
497
|
+
const challenge = takeChallenge(ctx.session, 'create');
|
|
498
|
+
if (!challenge) return done(reply, ctx, t('mfa.expired'), true);
|
|
499
|
+
const first = !(await accountHasFactor(id));
|
|
500
|
+
const added = await registerPasskey(req, ctx.app, id, ctx.user, challenge, String(req.body?.pk_name ?? '') || t('mfa.passkey_default'), ctx.ip, 'My account');
|
|
501
|
+
if (added !== 'ok') return done(reply, ctx, t(added === 'known' ? 'mfa.passkey_known' : 'mfa.passkey_failed'), true);
|
|
502
|
+
if (first || !(await accountCodesLeft(id))) ctx.session.state.__CODES_NEW = JSON.stringify(await newAccountCodes(id));
|
|
503
|
+
return done(reply, ctx, t('mfa.passkey_added'));
|
|
504
|
+
});
|
|
505
|
+
|
|
290
506
|
app.post('/a/:alias/account/2fa/codes', async (req: Req, reply) => {
|
|
291
507
|
const r = await signedIn(req, reply);
|
|
292
508
|
if (!r) return;
|
|
@@ -305,7 +521,7 @@ export async function twoStepRoutes(app: FastifyInstance) {
|
|
|
305
521
|
return done(reply, r.ctx);
|
|
306
522
|
});
|
|
307
523
|
|
|
308
|
-
// removing an app asks for a code (from any app, or a recovery code): a session left open is not enough
|
|
524
|
+
// removing an app or a passkey asks for a code (from any app, or a recovery code): a session left open is not enough
|
|
309
525
|
app.post('/a/:alias/account/2fa/:id/delete', async (req: Req, reply) => {
|
|
310
526
|
const r = await signedIn(req, reply);
|
|
311
527
|
if (!r) return;
|
|
@@ -317,16 +533,73 @@ export async function twoStepRoutes(app: FastifyInstance) {
|
|
|
317
533
|
}
|
|
318
534
|
const factors = await accountFactors(id);
|
|
319
535
|
const factorId = Number((req.params as { id?: string }).id);
|
|
320
|
-
|
|
536
|
+
const factor = factors.find((f) => f.id === factorId);
|
|
537
|
+
if (!factor) return done(reply, ctx);
|
|
321
538
|
if (factors.length === 1 && mfaRequired(ctx.app, ctx.roles)) return done(reply, ctx, t('mfa.keep_one'), true);
|
|
322
539
|
if (!(await checkAccountCode(id, String(req.body?.code ?? '').slice(0, 40)))) {
|
|
323
540
|
await logActivity({ appId: ctx.app.id, username: ctx.user, event: 'login_failed', ip: ctx.ip, detail: '2fa remove' });
|
|
324
541
|
return done(reply, ctx, t('mfa.wrong'), true);
|
|
325
542
|
}
|
|
326
543
|
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'));
|
|
544
|
+
await logActivity({ appId: ctx.app.id, username: ctx.user, event: '2fa_removed', ip: ctx.ip, detail: factor.kind === 'passkey' ? 'passkey' : 'authenticator app' });
|
|
545
|
+
return done(reply, ctx, t(factor.kind === 'passkey' ? 'mfa.passkey_removed' : 'mfa.removed'));
|
|
329
546
|
});
|
|
547
|
+
|
|
548
|
+
// ---------------------------------------------------------------- My account → REST API token (sprint 131)
|
|
549
|
+
// Only when the application lets users create their own (meta.app.api_self_tokens). A user the application
|
|
550
|
+
// requires a second step of gives one first, unless the session had one in the last 15 minutes. The token is
|
|
551
|
+
// kept in the session until the page has shown it once.
|
|
552
|
+
app.post('/a/:alias/account/token', async (req: Req, reply) => {
|
|
553
|
+
const ctx = await loadContext(req, reply, { pageNo: 'home' });
|
|
554
|
+
if (!ctx) return;
|
|
555
|
+
const t = ctx.locale.t;
|
|
556
|
+
if (req.body?.__csrf !== ctx.session.csrf_token || !ctx.app.api_self_tokens || ctx.user === 'nobody') return reply.redirect(`${ctx.base}/account`, 303);
|
|
557
|
+
if ((await accountMustUseMfa(ctx.app, ctx.user)) && !secondStepWithin(ctx.session.state, TOKEN_STEP_MINUTES)) {
|
|
558
|
+
ctx.session.state.__FLASH = t('token.step_first');
|
|
559
|
+
await saveState(ctx.session);
|
|
560
|
+
return reply.redirect(`${ctx.base}/login/2fa?next=${encodeURIComponent(`${ctx.base}/account#api-token`)}`, 303);
|
|
561
|
+
}
|
|
562
|
+
try {
|
|
563
|
+
const issued = await issueApiToken(ctx.app.id, ctx.user, Number(req.body?.hours) || 8, { self: true });
|
|
564
|
+
ctx.session.state.__TOKEN_NEW = JSON.stringify({ token: issued.token, hours: issued.expiresInHours });
|
|
565
|
+
await logActivity({ appId: ctx.app.id, username: issued.username, event: 'api_token', ip: ctx.ip, detail: `by the user on My account, ${issued.expiresInHours} h` });
|
|
566
|
+
return done(reply, ctx, undefined, false, 'api-token');
|
|
567
|
+
} catch (e) {
|
|
568
|
+
return done(reply, ctx, (e as Error).message, true, 'api-token');
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/** WebAuthn options for adding a passkey to an account (a one-time challenge kept in the session). */
|
|
574
|
+
async function createOptions(req: FastifyRequest, s: Session, a: App, id: number, username: string) {
|
|
575
|
+
const rp = relyingParty(req);
|
|
576
|
+
const challenge = giveChallenge(s, 'create');
|
|
577
|
+
await saveState(s);
|
|
578
|
+
return {
|
|
579
|
+
mode: 'create', challenge, rp: { id: rp.rpId, name: a.name }, algs: ALGORITHMS,
|
|
580
|
+
// a stable handle that isn't the user name (the authenticator may show it, but it says nothing)
|
|
581
|
+
user: { id: accountUserHandle(id), name: username, displayName: username },
|
|
582
|
+
exclude: (await accountPasskeys(id, rp.rpId)).map((k) => k.credential_id),
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** Check the browser's answer and keep the passkey: 'ok', 'known' (added already) or 'failed'. */
|
|
587
|
+
async function registerPasskey(req: Req, a: App, id: number, username: string, challenge: string, name: string, ip: string, where: string) {
|
|
588
|
+
const rp = relyingParty(req);
|
|
589
|
+
let reg;
|
|
590
|
+
try {
|
|
591
|
+
reg = verifyRegistration({ clientDataJSON: String(req.body?.clientDataJSON ?? ''), attestationObject: String(req.body?.attestationObject ?? '') }, { challenge, ...rp });
|
|
592
|
+
} catch (e) {
|
|
593
|
+
await logActivity({ appId: a.id, username, event: '2fa_refused', ip, detail: `passkey: ${e instanceof WebAuthnError ? e.message : 'unreadable answer'}` });
|
|
594
|
+
return 'failed' as const;
|
|
595
|
+
}
|
|
596
|
+
try {
|
|
597
|
+
await addAccountPasskey(id, name, { ...reg, rpId: rp.rpId, transports: String(req.body?.transports ?? '').split(',').filter(Boolean) });
|
|
598
|
+
} catch {
|
|
599
|
+
return 'known' as const;
|
|
600
|
+
}
|
|
601
|
+
await logActivity({ appId: a.id, username, event: '2fa_enrolled', ip, detail: `passkey (${rp.rpId}, ${where})` });
|
|
602
|
+
return 'ok' as const;
|
|
330
603
|
}
|
|
331
604
|
|
|
332
605
|
/** The app secret being added on My account (kept in the session for some minutes), or null. */
|
|
@@ -339,8 +612,18 @@ function newSecret(s: Session, minutes: number): string | null {
|
|
|
339
612
|
}
|
|
340
613
|
}
|
|
341
614
|
|
|
615
|
+
/** Something kept in the session to show once (recovery codes, a token), taken out. */
|
|
616
|
+
function takeOnce<T>(s: Session, key: string): T | null {
|
|
617
|
+
try {
|
|
618
|
+
const v = JSON.parse(String(s.state[key] ?? ''));
|
|
619
|
+
return v ?? null;
|
|
620
|
+
} catch {
|
|
621
|
+
return null;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
342
625
|
/** My account → Two-step sign-in (when the application has a second step). */
|
|
343
|
-
export async function twoStepSection(ctx: PageContext): Promise<Raw | ''> {
|
|
626
|
+
export async function twoStepSection(ctx: PageContext, rpId = ''): Promise<Raw | ''> {
|
|
344
627
|
if (!mfaOn(ctx.app)) return '';
|
|
345
628
|
const id = await accountId(ctx.user);
|
|
346
629
|
if (id === null) return '';
|
|
@@ -348,13 +631,7 @@ export async function twoStepSection(ctx: PageContext): Promise<Raw | ''> {
|
|
|
348
631
|
const [factors, left] = await Promise.all([accountFactors(id), accountCodesLeft(id)]);
|
|
349
632
|
const required = mfaRequired(ctx.app, ctx.roles);
|
|
350
633
|
const pending = newSecret(ctx.session, 10);
|
|
351
|
-
|
|
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
|
-
}
|
|
634
|
+
const shown = takeOnce<string[]>(ctx.session, '__CODES_NEW');
|
|
358
635
|
const error = typeof ctx.session.state.__FLASH_ERROR === 'string' ? ctx.session.state.__FLASH_ERROR : '';
|
|
359
636
|
delete ctx.session.state.__FLASH_ERROR;
|
|
360
637
|
const base = `${ctx.base}/account/2fa`;
|
|
@@ -362,15 +639,16 @@ export async function twoStepSection(ctx: PageContext): Promise<Raw | ''> {
|
|
|
362
639
|
const when = (v: string | null) => (v ? String(v).slice(0, 10) : '');
|
|
363
640
|
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
641
|
${error ? html`<div class="alert alert-error" role="alert">${error}</div>` : ''}
|
|
365
|
-
<p class="muted u-mt0">${factors.length ? t('mfa.
|
|
366
|
-
${shown
|
|
642
|
+
<p class="muted u-mt0">${factors.length ? t('mfa.on_any') : required ? t('mfa.required_you') : t('mfa.off')}</p>
|
|
643
|
+
${Array.isArray(shown)
|
|
367
644
|
? html`<h3>${t('mfa.codes_title')}</h3><p>${t('mfa.codes_once')}</p>${codesList(shown)}
|
|
368
645
|
<form method="post" action="${base}/codes/confirm">${csrf}
|
|
369
646
|
<label class="check"><input type="checkbox" name="saved" value="1" required> ${t('mfa.codes_saved')}</label>
|
|
370
647
|
<div class="buttons"><button class="btn btn-hot">${t('mfa.continue')}</button></div></form>`
|
|
371
648
|
: ''}
|
|
372
649
|
${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>
|
|
650
|
+
? html`<ul class="mfa-factors">${factors.map((f) => html`<li><strong>${f.name}</strong> <span class="muted">${f.kind === 'passkey' ? t('mfa.kind_passkey') : t('mfa.kind_app')} · ${when(f.created_at)}</span>
|
|
651
|
+
${f.kind === 'passkey' && rpId && f.rp_id !== rpId ? html` <span class="badge">${f.rp_id}</span>` : ''}
|
|
374
652
|
<details><summary class="link-button">${t('mfa.remove')}</summary>
|
|
375
653
|
<form method="post" action="${base}/${f.id}/delete">${csrf}
|
|
376
654
|
<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>
|
|
@@ -390,6 +668,35 @@ export async function twoStepSection(ctx: PageContext): Promise<Raw | ''> {
|
|
|
390
668
|
: secretKeyConfigured()
|
|
391
669
|
? 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
670
|
: html`<p class="alert alert-info" role="status">${t('mfa.no_key')}</p>`}
|
|
671
|
+
${pending
|
|
672
|
+
? ''
|
|
673
|
+
: html`<h3>${t('mfa.kind_passkey')}</h3><p class="muted u-mt0">${t('mfa.passkey_intro')}</p>
|
|
674
|
+
${rpId && isIP(rpId)
|
|
675
|
+
? html`<p class="alert alert-info" role="status">${t('mfa.passkey_needs_host')}</p>`
|
|
676
|
+
: html`<form method="post" action="${base}/passkey" class="mfa-passkey">${csrf}${passkeyFields(ATTESTATION)}
|
|
677
|
+
<div class="field"><label class="label" for="pk_name">${t('mfa.app_name')}</label><input id="pk_name" name="pk_name" value="${t('mfa.passkey_default')}" required maxlength="60"></div>
|
|
678
|
+
<div class="buttons"><button class="btn" hidden data-passkey="${base}/passkey/options" data-passkey-error="${t('mfa.passkey_failed')}">${t('mfa.add_passkey')}</button></div>
|
|
679
|
+
<p class="small" role="status" data-passkey-status></p>
|
|
680
|
+
<noscript><p class="muted small">${t('mfa.passkey_needs_js')}</p></noscript></form>`}`}
|
|
393
681
|
</div></section>`;
|
|
394
682
|
}
|
|
395
683
|
|
|
684
|
+
/** (sprint 131) My account → REST API token, when the application lets users create their own. */
|
|
685
|
+
export async function tokenSection(ctx: PageContext): Promise<Raw | ''> {
|
|
686
|
+
if (!ctx.app.api_self_tokens || (await accountId(ctx.user)) === null) return '';
|
|
687
|
+
const t = ctx.locale.t;
|
|
688
|
+
const issued = takeOnce<{ token: string; hours: number }>(ctx.session, '__TOKEN_NEW');
|
|
689
|
+
delete ctx.session.state.__TOKEN_NEW;
|
|
690
|
+
const needsStep = await accountMustUseMfa(ctx.app, ctx.user);
|
|
691
|
+
const csrf = html`<input type="hidden" name="__csrf" value="${ctx.session.csrf_token}">`;
|
|
692
|
+
return html`<section class="region region-standard col-6" id="api-token"><header class="region-header"><h2>${t('token.title')}</h2></header><div class="region-body">
|
|
693
|
+
<p class="muted u-mt0">${t('token.intro')}${needsStep ? html` ${t('token.needs_step')}` : ''}</p>
|
|
694
|
+
${issued?.token
|
|
695
|
+
? html`<div class="field"><label class="label" for="api_token">${t('token.shown_once', { hours: issued.hours })}</label>
|
|
696
|
+
<textarea id="api_token" rows="4" readonly spellcheck="false">${issued.token}</textarea></div>`
|
|
697
|
+
: ''}
|
|
698
|
+
<form method="post" action="${ctx.base}/account/token">${csrf}
|
|
699
|
+
<div class="field"><label class="label" for="token_hours">${t('token.hours')}</label><input id="token_hours" name="hours" type="number" min="1" max="${MAX_TOKEN_HOURS}" value="8" required></div>
|
|
700
|
+
<div class="buttons"><button class="btn">${t('token.create')}</button></div></form>
|
|
701
|
+
</div></section>`;
|
|
702
|
+
}
|