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