pgkiln 0.63.0 → 0.65.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SECURITY.md +2 -0
- package/db/migrations/132_app_user_mfa.sql +58 -0
- package/db/migrations/133_app_user_passkeys_stepup.sql +54 -0
- package/docs/apex-feature-parity.md +6 -1
- package/docs/guide/08-security.md +81 -5
- package/docs/guide/09-reference.md +5 -1
- package/docs/guide/11-from-apex.md +2 -1
- package/docs/guide/12-development.md +13 -1
- package/docs/guide/13-rest-api.md +4 -0
- package/package.json +1 -1
- package/public/app.css +10 -0
- package/public/app.js +63 -0
- package/public/builder.js +1 -62
- package/src/account-mfa.ts +184 -0
- package/src/api.ts +9 -2
- package/src/builder/advisor.ts +8 -0
- package/src/builder/api.ts +3 -1
- package/src/builder/components.ts +1 -0
- package/src/builder/i18n/de.ts +6 -0
- package/src/builder/i18n/en.ts +6 -0
- package/src/builder/i18n/es.ts +6 -0
- package/src/builder/i18n/fr.ts +6 -0
- package/src/builder/i18n/nl.ts +6 -0
- package/src/builder/i18n/pt.ts +6 -0
- package/src/builder/login-page.ts +32 -3
- package/src/builder/rest-modules.ts +1 -1
- package/src/builder/security.ts +3 -19
- package/src/builder/shared.ts +5 -3
- package/src/builder/users.ts +34 -3
- package/src/i18n/ar.ts +65 -0
- package/src/i18n/cs.ts +65 -0
- package/src/i18n/da.ts +65 -0
- package/src/i18n/de.ts +65 -0
- package/src/i18n/el.ts +65 -0
- package/src/i18n/es.ts +65 -0
- package/src/i18n/fi.ts +65 -0
- package/src/i18n/fr.ts +65 -0
- package/src/i18n/he.ts +65 -0
- package/src/i18n/it.ts +65 -0
- package/src/i18n/ja.ts +65 -0
- package/src/i18n/ko.ts +65 -0
- package/src/i18n/nb.ts +65 -0
- package/src/i18n/pl.ts +65 -0
- package/src/i18n/pt.ts +65 -0
- package/src/i18n/ru.ts +65 -0
- package/src/i18n/sv.ts +65 -0
- package/src/i18n/tr.ts +65 -0
- package/src/i18n/uk.ts +65 -0
- package/src/i18n/zh.ts +65 -0
- package/src/i18n.ts +130 -0
- package/src/metadata.ts +11 -2
- package/src/mfa.ts +94 -65
- package/src/remember.ts +1 -1
- package/src/runtime/account.ts +7 -1
- package/src/runtime/authz.ts +23 -4
- package/src/runtime/routes.ts +21 -7
- package/src/runtime/two-step.ts +702 -0
- package/src/sharedlogin.ts +1 -1
- package/src/webauthn.ts +20 -0
|
@@ -0,0 +1,702 @@
|
|
|
1
|
+
import { isIP } from 'node:net';
|
|
2
|
+
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
|
3
|
+
import {
|
|
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,
|
|
8
|
+
} from '../account-mfa.ts';
|
|
9
|
+
import { issueApiToken, MAX_TOKEN_HOURS } from '../api.ts';
|
|
10
|
+
import { html, raw, type Raw } from '../html.ts';
|
|
11
|
+
import { documentShell } from '../layout.ts';
|
|
12
|
+
import { accountRoles, type App } from '../metadata.ts';
|
|
13
|
+
import { matchTotp, newTotpSecret, otpauthUri, unbase32 } from '../mfa.ts';
|
|
14
|
+
import { qrSvg } from '../qrcode.ts';
|
|
15
|
+
import { secretKeyConfigured } from '../secrets.ts';
|
|
16
|
+
import { loginWindowMinutes } from '../security.ts';
|
|
17
|
+
import { clientIp, getSession, loginThrottled, logActivity, saveState, type Session } from '../session.ts';
|
|
18
|
+
import { ALGORITHMS, giveChallenge, relyingParty, takeChallenge, verifyAssertion, verifyRegistration, WebAuthnError } from '../webauthn.ts';
|
|
19
|
+
import type { PageContext } from './context.ts';
|
|
20
|
+
import type { Locale } from './locale.ts';
|
|
21
|
+
import { pwaHead } from './pwa.ts';
|
|
22
|
+
import { appWithLocale, completeLogin, loadContext, rootAttrs, safeNext, signIn, type Req } from './routes.ts';
|
|
23
|
+
|
|
24
|
+
// (sprint 130, SEC-3) The second step of signing in to an application, and "Two-step sign-in" on My account.
|
|
25
|
+
//
|
|
26
|
+
// A password or LDAP sign-in that the application asks a second step of (src/account-mfa.ts) doesn't get a session
|
|
27
|
+
// yet: what it would have become is kept in the signed-out session for five minutes (ten while adding an app), and
|
|
28
|
+
// /a/<alias>/login/2fa asks for a code from the authenticator app or a recovery code. When the application requires
|
|
29
|
+
// the step and the user has no app yet, the same page shows a QR code first, then the recovery codes once. Wrong
|
|
30
|
+
// codes count as failed sign-ins (the same lockout). The sign-in's method gets "+2fa", so a remembered or shared
|
|
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.
|
|
37
|
+
|
|
38
|
+
const PENDING = '__MFA';
|
|
39
|
+
const STEP_SECONDS = 300;
|
|
40
|
+
const ENROL_SECONDS = 600;
|
|
41
|
+
const EMAILS_PER_STEP = 3;
|
|
42
|
+
|
|
43
|
+
interface Pending {
|
|
44
|
+
u: string;
|
|
45
|
+
next?: string;
|
|
46
|
+
remember?: boolean;
|
|
47
|
+
extraRoles?: string[];
|
|
48
|
+
groups?: string[];
|
|
49
|
+
method?: string;
|
|
50
|
+
detail?: string;
|
|
51
|
+
at: number;
|
|
52
|
+
enrol?: boolean;
|
|
53
|
+
/** (sprint 131) a signed-in user asked again (step-up): no new session, back to `next` */
|
|
54
|
+
stepup?: boolean;
|
|
55
|
+
/** while adding an app: the new secret, then (after its first code) the recovery codes to show once */
|
|
56
|
+
secret?: string;
|
|
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 };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function pendingOf(s: Session): Pending | null {
|
|
65
|
+
try {
|
|
66
|
+
const p = JSON.parse(String(s.state[PENDING] ?? '')) as Pending;
|
|
67
|
+
const limit = (p?.enrol ? ENROL_SECONDS : STEP_SECONDS) * 1000;
|
|
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;
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const keep = (s: Session, p: Pending) => { s.state[PENDING] = JSON.stringify(p); };
|
|
77
|
+
|
|
78
|
+
interface PasswordSignIn {
|
|
79
|
+
next?: string;
|
|
80
|
+
remember?: boolean;
|
|
81
|
+
extraRoles?: string[];
|
|
82
|
+
groups?: string[];
|
|
83
|
+
method?: string;
|
|
84
|
+
detail?: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* A password or LDAP sign-in that succeeded: signed in at once, or on to the second step when the application asks
|
|
89
|
+
* for one of this user.
|
|
90
|
+
*/
|
|
91
|
+
export async function passwordSignIn(req: FastifyRequest, reply: FastifyReply, a: App, session: Session, username: string, opts: PasswordSignIn = {}) {
|
|
92
|
+
const roles = [...(await accountRoles(a.id, username)), ...(opts.extraRoles ?? []).map((r) => r.toLowerCase())];
|
|
93
|
+
const need = await secondStep(a, username, roles);
|
|
94
|
+
if (!need) return completeLogin(req, reply, a, session, username, opts);
|
|
95
|
+
keep(session, { u: username, ...opts, at: Date.now(), enrol: need === 'enrol' });
|
|
96
|
+
await saveState(session);
|
|
97
|
+
return reply.redirect(`/a/${a.alias}/login/2fa`, 303);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function card(a: App, locale: Locale, title: string, body: Raw, back = `/a/${a.alias}/login`) {
|
|
101
|
+
return documentShell(
|
|
102
|
+
`${title} · ${a.name}`,
|
|
103
|
+
html`<main class="login">
|
|
104
|
+
<div class="card login-card">
|
|
105
|
+
<h1>${title}</h1>
|
|
106
|
+
${body}
|
|
107
|
+
<a class="login-forgot" href="${back}">${locale.t('reset.back')}</a>
|
|
108
|
+
</div>
|
|
109
|
+
</main>`,
|
|
110
|
+
'login-body',
|
|
111
|
+
{},
|
|
112
|
+
pwaHead(a),
|
|
113
|
+
rootAttrs(locale),
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const csrfField = (s: Session) => html`<input type="hidden" name="__csrf" value="${s.csrf_token}">`;
|
|
118
|
+
const alert = (error?: string, kind = 'error') => html`${error ? html`<div class="alert alert-${kind}" role="${kind === 'error' ? 'alert' : 'status'}">${error}</div>` : ''}`;
|
|
119
|
+
const grouped = (secret: string) => secret.replace(/(.{4})/g, '$1 ').trim();
|
|
120
|
+
|
|
121
|
+
function codesList(codes: string[]) {
|
|
122
|
+
return html`<ol class="mfa-codes">${codes.map((c) => html`<li><code>${c}</code></li>`)}</ol>`;
|
|
123
|
+
}
|
|
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
|
+
|
|
133
|
+
/** The second step's page: a code; or a new app (QR code, then its first code); or the recovery codes, once. */
|
|
134
|
+
function stepPage(a: App, locale: Locale, s: Session, p: Pending, offers: Offers, error?: string) {
|
|
135
|
+
const t = locale.t;
|
|
136
|
+
const action = `/a/${a.alias}/login/2fa`;
|
|
137
|
+
const back = p.stepup ? safeNext(a, p.next) : undefined;
|
|
138
|
+
if (p.codes)
|
|
139
|
+
return card(a, locale, t('mfa.codes_title'), html`<p>${t('mfa.codes_once')}</p>${codesList(p.codes)}
|
|
140
|
+
<form method="post" action="${action}/done" class="login-form">${csrfField(s)}
|
|
141
|
+
<label class="check"><input type="checkbox" name="saved" value="1" required> ${t('mfa.codes_saved')}</label>
|
|
142
|
+
<button class="btn btn-hot">${t('mfa.continue')}</button></form>`);
|
|
143
|
+
if (p.enrol && (p.secret || offers.newPasskey))
|
|
144
|
+
return card(a, locale, t('mfa.enrol_title'), html`<p class="muted">${t('mfa.enrol_intro')}</p>${alert(error)}
|
|
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)}
|
|
164
|
+
<form method="post" action="${action}" class="login-form">${csrfField(s)}
|
|
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>
|
|
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);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The signed-out session's pending sign-in (or a signed-in user's step-up), or a page that says it expired. */
|
|
178
|
+
async function start(req: Req, reply: FastifyReply) {
|
|
179
|
+
const loaded = await appWithLocale(req, req.params.alias);
|
|
180
|
+
if (!loaded) return null;
|
|
181
|
+
const session = await getSession(req, reply, loaded.app.id, `/a/${loaded.app.alias}`);
|
|
182
|
+
const { app: a, locale } = (await appWithLocale(req, req.params.alias, session))!;
|
|
183
|
+
return { a, locale, session, p: pendingOf(session) };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const expired = async (reply: FastifyReply, a: App, locale: Locale, s: Session) => {
|
|
187
|
+
const wasStepUp = !!s.username;
|
|
188
|
+
delete s.state[PENDING];
|
|
189
|
+
await saveState(s);
|
|
190
|
+
if (wasStepUp) return reply.redirect(`/a/${a.alias}/${a.home_page}`, 303);
|
|
191
|
+
return reply.code(410).type('text/html').send(card(a, locale, locale.t('mfa.step_title'), alert(locale.t('mfa.expired'))));
|
|
192
|
+
};
|
|
193
|
+
|
|
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. */
|
|
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
|
+
}
|
|
212
|
+
const n = await signIn(req, reply, a, s, p.u, {
|
|
213
|
+
extraRoles: p.extraRoles, groups: p.groups, remember: p.remember, method: withSecondStep(p.method),
|
|
214
|
+
detail: `${p.detail ?? 'password'} ${detail}`,
|
|
215
|
+
});
|
|
216
|
+
markSecondStep(n.state);
|
|
217
|
+
if (flash) n.state.__FLASH = flash;
|
|
218
|
+
await saveState(n);
|
|
219
|
+
return reply.redirect(next ?? safeNext(a, p.next), 303);
|
|
220
|
+
}
|
|
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
|
+
|
|
231
|
+
export async function twoStepRoutes(app: FastifyInstance) {
|
|
232
|
+
app.get('/a/:alias/login/2fa', async (req: Req, reply) => {
|
|
233
|
+
const r = await start(req, reply);
|
|
234
|
+
if (!r) return reply.code(404).send('Not found');
|
|
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
|
+
}
|
|
247
|
+
if (!p) return expired(reply, a, locale, s);
|
|
248
|
+
const id = await accountId(p.u);
|
|
249
|
+
if (id === null) return expired(reply, a, locale, s);
|
|
250
|
+
const offers = await offersFor(req, a, p, id);
|
|
251
|
+
if (p.enrol && !p.codes && !(await accountHasFactor(id))) {
|
|
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()) {
|
|
256
|
+
p.secret = newTotpSecret();
|
|
257
|
+
keep(s, p);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
await saveState(s);
|
|
261
|
+
return reply.header('cache-control', 'no-store').type('text/html').send(stepPage(a, locale, s, p, offers));
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
app.post('/a/:alias/login/2fa', async (req: Req, reply) => {
|
|
265
|
+
const r = await start(req, reply);
|
|
266
|
+
if (!r) return reply.code(404).send('Not found');
|
|
267
|
+
const { a, locale, session: s, p } = r;
|
|
268
|
+
const t = locale.t;
|
|
269
|
+
if (!p || req.body?.__csrf !== s.csrf_token) return expired(reply, a, locale, s);
|
|
270
|
+
const ip = clientIp(req);
|
|
271
|
+
const id = await accountId(p.u);
|
|
272
|
+
if (id === null) return expired(reply, a, locale, s);
|
|
273
|
+
const lock = await locked(reply, a, locale, s, p, ip, '2fa');
|
|
274
|
+
if (lock) return lock;
|
|
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
|
+
};
|
|
280
|
+
// adding the first app while signing in: its first code proves it works
|
|
281
|
+
if (p.enrol && p.secret && !p.codes) {
|
|
282
|
+
const step = matchTotp(unbase32(p.secret), code.replace(/\s/g, ''), null);
|
|
283
|
+
if (step === null) return wrong('2fa (new app)');
|
|
284
|
+
await addAccountTotp(id, t('mfa.app_default'), p.secret, step);
|
|
285
|
+
await logActivity({ appId: a.id, username: p.u, event: '2fa_enrolled', ip, detail: 'authenticator app (sign-in)' });
|
|
286
|
+
p.codes = await newAccountCodes(id);
|
|
287
|
+
p.added = 'app';
|
|
288
|
+
delete p.secret;
|
|
289
|
+
keep(s, p);
|
|
290
|
+
await saveState(s);
|
|
291
|
+
return reply.type('text/html').send(stepPage(a, locale, s, p, await offersFor(req, a, p, id)));
|
|
292
|
+
}
|
|
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)');
|
|
299
|
+
}
|
|
300
|
+
const ok = await checkAccountCode(id, code);
|
|
301
|
+
if (!ok) return wrong('2fa');
|
|
302
|
+
if (ok.kind === 'recovery') {
|
|
303
|
+
const left = await accountCodesLeft(id);
|
|
304
|
+
await logActivity({ appId: a.id, username: p.u, event: '2fa_recovery_used', ip, detail: `${left} left` });
|
|
305
|
+
// to My account: one code fewer, maybe time for new ones
|
|
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 }));
|
|
307
|
+
}
|
|
308
|
+
return finish(req, reply, a, s, p, '2fa');
|
|
309
|
+
});
|
|
310
|
+
|
|
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
|
|
420
|
+
app.post('/a/:alias/login/2fa/done', async (req: Req, reply) => {
|
|
421
|
+
const r = await start(req, reply);
|
|
422
|
+
if (!r) return reply.code(404).send('Not found');
|
|
423
|
+
const { a, locale, session: s, p } = r;
|
|
424
|
+
if (!p?.codes || req.body?.__csrf !== s.csrf_token) return expired(reply, a, locale, s);
|
|
425
|
+
const id = await accountId(p.u);
|
|
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)));
|
|
428
|
+
await confirmAccountCodes(id);
|
|
429
|
+
return finish(req, reply, a, s, p, `2fa (new ${p.added === 'passkey' ? 'passkey' : 'app'})`);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
// ---------------------------------------------------------------- My account → Two-step sign-in
|
|
433
|
+
const SECRET_MINUTES = 10;
|
|
434
|
+
const signedIn = async (req: Req, reply: FastifyReply) => {
|
|
435
|
+
const ctx = await loadContext(req, reply, { pageNo: 'home' });
|
|
436
|
+
if (!ctx) return null;
|
|
437
|
+
const id = await accountId(ctx.user);
|
|
438
|
+
if (req.body?.__csrf !== ctx.session.csrf_token || !mfaOn(ctx.app) || id === null) {
|
|
439
|
+
reply.redirect(`${ctx.base}/account`, 303);
|
|
440
|
+
return null;
|
|
441
|
+
}
|
|
442
|
+
return { ctx, id };
|
|
443
|
+
};
|
|
444
|
+
const done = async (reply: FastifyReply, ctx: PageContext, flash?: string, error?: boolean, anchor = 'two-step') => {
|
|
445
|
+
if (flash) ctx.session.state[error ? '__FLASH_ERROR' : '__FLASH'] = flash;
|
|
446
|
+
await saveState(ctx.session);
|
|
447
|
+
return reply.redirect(`${ctx.base}/account#${anchor}`, 303);
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
app.post('/a/:alias/account/2fa/start', async (req: Req, reply) => {
|
|
451
|
+
const r = await signedIn(req, reply);
|
|
452
|
+
if (!r) return;
|
|
453
|
+
if (secretKeyConfigured()) r.ctx.session.state.__TOTP_NEW = JSON.stringify({ secret: newTotpSecret(), at: Date.now() });
|
|
454
|
+
return done(reply, r.ctx);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
app.post('/a/:alias/account/2fa/cancel', async (req: Req, reply) => {
|
|
458
|
+
const r = await signedIn(req, reply);
|
|
459
|
+
if (!r) return;
|
|
460
|
+
delete r.ctx.session.state.__TOTP_NEW;
|
|
461
|
+
return done(reply, r.ctx);
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
app.post('/a/:alias/account/2fa/confirm', async (req: Req, reply) => {
|
|
465
|
+
const r = await signedIn(req, reply);
|
|
466
|
+
if (!r) return;
|
|
467
|
+
const { ctx, id } = r;
|
|
468
|
+
const t = ctx.locale.t;
|
|
469
|
+
const pending = newSecret(ctx.session, SECRET_MINUTES);
|
|
470
|
+
if (!pending) {
|
|
471
|
+
delete ctx.session.state.__TOTP_NEW;
|
|
472
|
+
return done(reply, ctx, t('mfa.expired'), true);
|
|
473
|
+
}
|
|
474
|
+
const step = matchTotp(unbase32(pending), String(req.body?.code ?? '').replace(/\s/g, ''), null);
|
|
475
|
+
if (step === null) return done(reply, ctx, t('mfa.wrong'), true);
|
|
476
|
+
const first = !(await accountHasFactor(id));
|
|
477
|
+
await addAccountTotp(id, String(req.body?.name ?? '') || t('mfa.app_default'), pending, step);
|
|
478
|
+
delete ctx.session.state.__TOTP_NEW;
|
|
479
|
+
await logActivity({ appId: ctx.app.id, username: ctx.user, event: '2fa_enrolled', ip: ctx.ip, detail: 'authenticator app' });
|
|
480
|
+
if (first || !(await accountCodesLeft(id))) ctx.session.state.__CODES_NEW = JSON.stringify(await newAccountCodes(id));
|
|
481
|
+
return done(reply, ctx, t('mfa.added'));
|
|
482
|
+
});
|
|
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
|
+
|
|
506
|
+
app.post('/a/:alias/account/2fa/codes', async (req: Req, reply) => {
|
|
507
|
+
const r = await signedIn(req, reply);
|
|
508
|
+
if (!r) return;
|
|
509
|
+
if (!(await accountHasFactor(r.id))) return done(reply, r.ctx);
|
|
510
|
+
r.ctx.session.state.__CODES_NEW = JSON.stringify(await newAccountCodes(r.id));
|
|
511
|
+
await logActivity({ appId: r.ctx.app.id, username: r.ctx.user, event: '2fa_codes', ip: r.ctx.ip, detail: 'new recovery codes' });
|
|
512
|
+
return done(reply, r.ctx);
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
app.post('/a/:alias/account/2fa/codes/confirm', async (req: Req, reply) => {
|
|
516
|
+
const r = await signedIn(req, reply);
|
|
517
|
+
if (!r) return;
|
|
518
|
+
if (req.body?.saved !== '1') return done(reply, r.ctx);
|
|
519
|
+
await confirmAccountCodes(r.id);
|
|
520
|
+
delete r.ctx.session.state.__CODES_NEW;
|
|
521
|
+
return done(reply, r.ctx);
|
|
522
|
+
});
|
|
523
|
+
|
|
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
|
|
525
|
+
app.post('/a/:alias/account/2fa/:id/delete', async (req: Req, reply) => {
|
|
526
|
+
const r = await signedIn(req, reply);
|
|
527
|
+
if (!r) return;
|
|
528
|
+
const { ctx, id } = r;
|
|
529
|
+
const t = ctx.locale.t;
|
|
530
|
+
if (await loginThrottled(ctx.app.id, ctx.user, ctx.ip)) {
|
|
531
|
+
logActivity({ appId: ctx.app.id, username: ctx.user, event: 'login_locked', ip: ctx.ip, detail: '2fa remove' });
|
|
532
|
+
return done(reply, ctx, t('login.throttled', { minutes: loginWindowMinutes() }), true);
|
|
533
|
+
}
|
|
534
|
+
const factors = await accountFactors(id);
|
|
535
|
+
const factorId = Number((req.params as { id?: string }).id);
|
|
536
|
+
const factor = factors.find((f) => f.id === factorId);
|
|
537
|
+
if (!factor) return done(reply, ctx);
|
|
538
|
+
if (factors.length === 1 && mfaRequired(ctx.app, ctx.roles)) return done(reply, ctx, t('mfa.keep_one'), true);
|
|
539
|
+
if (!(await checkAccountCode(id, String(req.body?.code ?? '').slice(0, 40)))) {
|
|
540
|
+
await logActivity({ appId: ctx.app.id, username: ctx.user, event: 'login_failed', ip: ctx.ip, detail: '2fa remove' });
|
|
541
|
+
return done(reply, ctx, t('mfa.wrong'), true);
|
|
542
|
+
}
|
|
543
|
+
await removeAccountFactor(id, factorId);
|
|
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'));
|
|
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;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/** The app secret being added on My account (kept in the session for some minutes), or null. */
|
|
606
|
+
function newSecret(s: Session, minutes: number): string | null {
|
|
607
|
+
try {
|
|
608
|
+
const p = JSON.parse(String(s.state.__TOTP_NEW ?? ''));
|
|
609
|
+
return p && typeof p.secret === 'string' && Date.now() - Number(p.at) < minutes * 60_000 ? p.secret : null;
|
|
610
|
+
} catch {
|
|
611
|
+
return null;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
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
|
+
|
|
625
|
+
/** My account → Two-step sign-in (when the application has a second step). */
|
|
626
|
+
export async function twoStepSection(ctx: PageContext, rpId = ''): Promise<Raw | ''> {
|
|
627
|
+
if (!mfaOn(ctx.app)) return '';
|
|
628
|
+
const id = await accountId(ctx.user);
|
|
629
|
+
if (id === null) return '';
|
|
630
|
+
const t = ctx.locale.t;
|
|
631
|
+
const [factors, left] = await Promise.all([accountFactors(id), accountCodesLeft(id)]);
|
|
632
|
+
const required = mfaRequired(ctx.app, ctx.roles);
|
|
633
|
+
const pending = newSecret(ctx.session, 10);
|
|
634
|
+
const shown = takeOnce<string[]>(ctx.session, '__CODES_NEW');
|
|
635
|
+
const error = typeof ctx.session.state.__FLASH_ERROR === 'string' ? ctx.session.state.__FLASH_ERROR : '';
|
|
636
|
+
delete ctx.session.state.__FLASH_ERROR;
|
|
637
|
+
const base = `${ctx.base}/account/2fa`;
|
|
638
|
+
const csrf = html`<input type="hidden" name="__csrf" value="${ctx.session.csrf_token}">`;
|
|
639
|
+
const when = (v: string | null) => (v ? String(v).slice(0, 10) : '');
|
|
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">
|
|
641
|
+
${error ? html`<div class="alert alert-error" role="alert">${error}</div>` : ''}
|
|
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)
|
|
644
|
+
? html`<h3>${t('mfa.codes_title')}</h3><p>${t('mfa.codes_once')}</p>${codesList(shown)}
|
|
645
|
+
<form method="post" action="${base}/codes/confirm">${csrf}
|
|
646
|
+
<label class="check"><input type="checkbox" name="saved" value="1" required> ${t('mfa.codes_saved')}</label>
|
|
647
|
+
<div class="buttons"><button class="btn btn-hot">${t('mfa.continue')}</button></div></form>`
|
|
648
|
+
: ''}
|
|
649
|
+
${factors.length
|
|
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>` : ''}
|
|
652
|
+
<details><summary class="link-button">${t('mfa.remove')}</summary>
|
|
653
|
+
<form method="post" action="${base}/${f.id}/delete">${csrf}
|
|
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>
|
|
655
|
+
<div class="buttons"><button class="btn btn-danger">${t('mfa.remove')}</button></div></form></details></li>`)}</ul>
|
|
656
|
+
<p>${t('mfa.codes_left', { n: left })}</p>
|
|
657
|
+
<form method="post" action="${base}/codes">${csrf}<button class="btn">${t('mfa.codes_new')}</button></form>`
|
|
658
|
+
: ''}
|
|
659
|
+
${pending
|
|
660
|
+
? html`<h3>${t('mfa.add_app')}</h3>
|
|
661
|
+
<ol class="mfa-steps"><li>${t('mfa.scan')}</li><li>${t('mfa.or_enter')} <code class="mfa-secret">${grouped(pending)}</code></li><li>${t('mfa.enter_code')}</li></ol>
|
|
662
|
+
<div class="mfa-qr">${raw(qrSvg(otpauthUri(pending, ctx.user, ctx.app.name), { label: t('mfa.qr_label'), px: 4 }) ?? '')}</div>
|
|
663
|
+
<form method="post" action="${base}/confirm">${csrf}
|
|
664
|
+
<div class="field"><label class="label" for="mfa_name">${t('mfa.app_name')}</label><input id="mfa_name" name="name" value="${t('mfa.app_default')}" required maxlength="60"></div>
|
|
665
|
+
<div class="field"><label class="label" for="mfa_code">${t('mfa.code')}</label><input id="mfa_code" name="code" required inputmode="numeric" autocomplete="one-time-code" maxlength="7"></div>
|
|
666
|
+
<div class="buttons"><button class="btn btn-hot">${t('mfa.confirm_app')}</button></div></form>
|
|
667
|
+
<form method="post" action="${base}/cancel">${csrf}<button class="link-button">${t('mfa.cancel')}</button></form>`
|
|
668
|
+
: secretKeyConfigured()
|
|
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>`
|
|
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>`}`}
|
|
681
|
+
</div></section>`;
|
|
682
|
+
}
|
|
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
|
+
}
|