pgkiln 0.60.0 → 0.62.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 +3 -1
- package/db/migrations/129_developer_passkeys.sql +49 -0
- package/db/migrations/130_passkey_signin_workspace_mfa.sql +20 -0
- package/docs/apex-feature-parity.md +4 -3
- package/docs/guide/01-installation.md +2 -1
- package/docs/guide/03-builder.md +45 -8
- package/docs/guide/18-cli.md +1 -1
- package/package.json +1 -1
- package/public/builder.css +1 -0
- package/public/builder.js +63 -0
- package/src/builder/i18n/de.ts +45 -9
- package/src/builder/i18n/en.ts +45 -9
- package/src/builder/i18n/es.ts +45 -9
- package/src/builder/i18n/fr.ts +45 -9
- package/src/builder/i18n/nl.ts +45 -9
- package/src/builder/i18n/pt.ts +45 -9
- package/src/builder/routes.ts +158 -15
- package/src/builder/security.ts +130 -17
- package/src/builder/ui.ts +13 -3
- package/src/builder/workspaces.ts +24 -3
- package/src/cli/main.ts +7 -5
- package/src/instance.ts +2 -1
- package/src/mfa.ts +133 -5
- package/src/webauthn.ts +205 -0
package/src/builder/security.ts
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
|
+
import { isIP } from 'node:net';
|
|
1
2
|
import type { FastifyInstance } from 'fastify';
|
|
2
3
|
import { owner } from '../db.ts';
|
|
3
4
|
import { html, raw, type Raw } from '../html.ts';
|
|
4
5
|
import { icon } from '../icons.ts';
|
|
5
6
|
import {
|
|
6
|
-
addTotp, adminEmails, adminFactors, codesLeft, confirmRecoveryCodes, factorsOf, hasFactor, matchTotp, MFA_POLICIES, mfaPolicy,
|
|
7
|
-
notifyMfa, otpauthUri, policyProblem,
|
|
7
|
+
addPasskey, addTotp, adminEmails, adminFactors, codesLeft, confirmRecoveryCodes, factorsOf, forgetBrowsers, hasFactor, matchTotp, MFA_POLICIES, mfaPolicy,
|
|
8
|
+
newRecoveryCodes, newTotpSecret, notifyMfa, otpauthUri, passkeysOf, policyProblem, requirement, resetFactors, setPolicy, setTrustDays, trustDays,
|
|
9
|
+
trustedBrowsers, unbase32, userHandle, passkeySignin, setPasskeySignin, type MfaPolicy,
|
|
8
10
|
} from '../mfa.ts';
|
|
9
11
|
import { qrSvg } from '../qrcode.ts';
|
|
10
12
|
import { secretKeyConfigured } from '../secrets.ts';
|
|
11
|
-
import { clientIp, logActivity, type Session } from '../session.ts';
|
|
13
|
+
import { clientIp, logActivity, saveState, type Session } from '../session.ts';
|
|
14
|
+
import { ALGORITHMS, newChallenge, relyingParty, verifyRegistration, WebAuthnError } from '../webauthn.ts';
|
|
12
15
|
import { tr } from './i18n.ts';
|
|
13
16
|
import { isAdmin } from './locks.ts';
|
|
14
17
|
import { back, BASE, csrf, developer, flash, input, region, send, shell, type Req } from './ui.ts';
|
|
@@ -19,6 +22,29 @@ import { back, BASE, csrf, developer, flash, input, region, send, shell, type Re
|
|
|
19
22
|
// app confirms it, then stored encrypted with the instance key. Recovery codes are shown once; they count
|
|
20
23
|
// only after "I saved them". Removing a factor asks for the password, and is refused when it would leave a
|
|
21
24
|
// developer the policy covers without one, or the instance without enough administrators who have one.
|
|
25
|
+
// (sprint 127) Passkeys (WebAuthn): the browser asks the server for options (a one-time challenge kept in the
|
|
26
|
+
// session for five minutes), calls navigator.credentials.create() and posts the answer in a normal form
|
|
27
|
+
// (public/builder.js, [data-passkey]); without JavaScript the button stays hidden. Trusted browsers can be
|
|
28
|
+
// forgotten here; the instance decides how long a browser may be trusted (0 = never).
|
|
29
|
+
|
|
30
|
+
const CHALLENGE_SECONDS = 300;
|
|
31
|
+
|
|
32
|
+
/** A one-time WebAuthn challenge kept in the session (for this purpose only), or null when there is none or it expired. */
|
|
33
|
+
export function takeChallenge(s: Session, purpose: 'create' | 'get'): string | null {
|
|
34
|
+
try {
|
|
35
|
+
const p = JSON.parse(String(s.state.__PK ?? ''));
|
|
36
|
+
delete s.state.__PK;
|
|
37
|
+
return p && p.purpose === purpose && Date.now() - Number(p.at) < CHALLENGE_SECONDS * 1000 && typeof p.c === 'string' ? p.c : null;
|
|
38
|
+
} catch {
|
|
39
|
+
delete s.state.__PK;
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export function giveChallenge(s: Session, purpose: 'create' | 'get'): string {
|
|
44
|
+
const c = newChallenge();
|
|
45
|
+
s.state.__PK = JSON.stringify({ c, purpose, at: Date.now() });
|
|
46
|
+
return c;
|
|
47
|
+
}
|
|
22
48
|
|
|
23
49
|
const SECRET_MINUTES = 10;
|
|
24
50
|
|
|
@@ -32,8 +58,9 @@ export async function securityRoutes(app: FastifyInstance) {
|
|
|
32
58
|
if (!s) return;
|
|
33
59
|
const tx = tr(s);
|
|
34
60
|
const me = s.username!;
|
|
35
|
-
const [factors, left,
|
|
36
|
-
const
|
|
61
|
+
const [factors, left, admin, days, trusted] = await Promise.all([factorsOf(me), codesLeft(me), isAdmin(me), trustDays(), trustedBrowsers(me)]);
|
|
62
|
+
const rp = relyingParty(req);
|
|
63
|
+
const { required } = await requirement(me, admin);
|
|
37
64
|
const pending = (() => {
|
|
38
65
|
try {
|
|
39
66
|
const p = JSON.parse(String(s.state.__TOTP_NEW ?? ''));
|
|
@@ -80,18 +107,34 @@ export async function securityRoutes(app: FastifyInstance) {
|
|
|
80
107
|
<div class="buttons"><button class="btn btn-hot">${tx('mfa.codes_done')}</button></div></form>`, '', 'codes')
|
|
81
108
|
: ''}
|
|
82
109
|
<div class="columns">
|
|
83
|
-
${region(tx('mfa.
|
|
84
|
-
? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('mfa.app_name')}</th><th>${tx('mfa.added')}</th><th>${tx('mfa.last_used')}</th><th></th></tr></thead><tbody>
|
|
85
|
-
${factors.map((f) => html`<tr><td data-label="${tx('mfa.app_name')}">${icon('
|
|
110
|
+
${region(tx('mfa.factors'), html`${factors.length
|
|
111
|
+
? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('mfa.app_name')}</th><th>${tx('mfa.kind')}</th><th>${tx('mfa.added')}</th><th>${tx('mfa.last_used')}</th><th></th></tr></thead><tbody>
|
|
112
|
+
${factors.map((f) => html`<tr><td data-label="${tx('mfa.app_name')}">${icon(f.kind === 'passkey' ? 'fingerprint' : 'smartphone')} ${f.name}</td>
|
|
113
|
+
<td data-label="${tx('mfa.kind')}">${f.kind === 'passkey' ? tx('mfa.kind_passkey') : tx('mfa.kind_app')}${f.kind === 'passkey' && f.rp_id !== rp.rpId ? html` <span class="badge badge-warn" title="${tx('mfa.other_domain_help', { domain: f.rp_id ?? '' })}">${tx('mfa.other_domain', { domain: f.rp_id ?? '' })}</span>` : ''}</td>
|
|
114
|
+
<td data-label="${tx('mfa.added')}">${when(f.created_at)}</td><td data-label="${tx('mfa.last_used')}">${when(f.last_used_at)}</td>
|
|
86
115
|
<td><details class="menu"><summary class="btn btn-sm">${tx('mfa.remove')}</summary><div class="menu-panel"><form method="post" action="${BASE}/security/factor/${f.id}/delete" class="menu-section">${csrf(s)}
|
|
87
116
|
${input(`password_${f.id}`, tx('mfa.your_password'), '', { type: 'password', required: true, auto: 'current-password' })}
|
|
88
|
-
<button class="btn btn-sm btn-danger">${tx('mfa.remove_app')}</button></form></div></details></td></tr>`)}
|
|
117
|
+
<button class="btn btn-sm btn-danger">${tx(f.kind === 'passkey' ? 'mfa.remove_passkey' : 'mfa.remove_app')}</button></form></div></details></td></tr>`)}
|
|
89
118
|
</tbody></table></div>`
|
|
90
119
|
: html`<p class="muted">${tx('mfa.no_apps')}</p>`}
|
|
120
|
+
<h3>${tx('mfa.kind_passkey')}</h3>
|
|
121
|
+
<p class="muted small u-mt0">${tx('mfa.passkey_intro')}</p>
|
|
122
|
+
<form method="post" action="${BASE}/security/passkey">${csrf(s)}
|
|
123
|
+
<div class="form-grid">${input('pk_name', tx('mfa.app_name'), tx('mfa.passkey_default'), { required: true })}</div>
|
|
124
|
+
<input type="hidden" name="clientDataJSON"><input type="hidden" name="attestationObject"><input type="hidden" name="transports">
|
|
125
|
+
${isIP(rp.rpId) ? html`<p class="alert alert-info" role="status">${tx('mfa.passkey_needs_host', { host: rp.rpId })}</p>`
|
|
126
|
+
: html`<div class="buttons"><button class="btn${factors.length ? '' : ' btn-hot'}" hidden data-passkey="${BASE}/security/passkey/options" data-passkey-error="${tx('mfa.passkey_failed')}">${icon('fingerprint')} ${tx('mfa.add_passkey')}</button></div>`}
|
|
127
|
+
<p class="small" role="status" data-passkey-status></p>
|
|
128
|
+
<noscript><p class="muted small">${tx('mfa.passkey_needs_js')}</p></noscript>
|
|
129
|
+
</form>
|
|
130
|
+
<h3>${tx('mfa.kind_app')}</h3>
|
|
91
131
|
${setup}`)}
|
|
92
132
|
${region(tx('mfa.codes_title'), html`<p class="u-mt0">${factors.length || left ? tx('mfa.codes_left', { n: left }) : tx('mfa.codes_after')}</p>
|
|
93
133
|
${factors.length && left < 3 ? html`<p class="alert alert-warning" role="status">${tx('mfa.codes_low')}</p>` : ''}
|
|
94
|
-
${factors.length ? html`<form method="post" action="${BASE}/security/codes">${csrf(s)}<button class="btn" data-confirm="${tx('mfa.codes_new_confirm')}">${tx('mfa.codes_new')}</button></form>` : ''}
|
|
134
|
+
${factors.length ? html`<form method="post" action="${BASE}/security/codes">${csrf(s)}<button class="btn" data-confirm="${tx('mfa.codes_new_confirm')}">${tx('mfa.codes_new')}</button></form>` : ''}
|
|
135
|
+
${days || trusted ? html`<h3>${tx('mfa.trusted_title')}</h3>
|
|
136
|
+
<p class="u-mt0">${days ? tx('mfa.trusted_count', { n: trusted, days }) : tx('mfa.trusted_off')}</p>
|
|
137
|
+
${trusted ? html`<form method="post" action="${BASE}/security/forget-browsers">${csrf(s)}<button class="btn">${tx('mfa.forget_browsers')}</button></form>` : ''}` : ''}`)}
|
|
95
138
|
</div>`;
|
|
96
139
|
return send(reply, s, shell(s, tx('mfa.title'), [[tx('mfa.title')]], main, 'developers'));
|
|
97
140
|
});
|
|
@@ -167,7 +210,7 @@ export async function securityRoutes(app: FastifyInstance) {
|
|
|
167
210
|
const tx = tr(s);
|
|
168
211
|
const me = s.username!;
|
|
169
212
|
const id = Number(req.params.id);
|
|
170
|
-
const f = await owner.one('select id from meta.developer_factor where id = $1 and username = $2', [id, me]);
|
|
213
|
+
const f = await owner.one<{ kind: string }>('select id, kind from meta.developer_factor where id = $1 and username = $2', [id, me]);
|
|
171
214
|
if (!f) return reply.code(404).send('Not found');
|
|
172
215
|
if (!(await verifyPassword(me, String(req.body?.[`password_${id}`] ?? '')))) {
|
|
173
216
|
await logActivity({ username: me, event: 'login_failed', ip: clientIp(req), detail: 'builder (removing a second factor)' });
|
|
@@ -178,7 +221,7 @@ export async function securityRoutes(app: FastifyInstance) {
|
|
|
178
221
|
if (last) {
|
|
179
222
|
const pol = await mfaPolicy();
|
|
180
223
|
const admin = await isAdmin(me);
|
|
181
|
-
if (
|
|
224
|
+
if ((await requirement(me, admin)).required) {
|
|
182
225
|
flash(s, tx('mfa.cannot_remove_required'), 'error');
|
|
183
226
|
return back(reply, s, `${BASE}/security`);
|
|
184
227
|
}
|
|
@@ -193,11 +236,73 @@ export async function securityRoutes(app: FastifyInstance) {
|
|
|
193
236
|
}
|
|
194
237
|
await owner.query('delete from meta.developer_factor where id = $1 and username = $2', [id, me]);
|
|
195
238
|
if (last) await owner.query('delete from meta.developer_recovery_code where username = $1', [me]);
|
|
196
|
-
|
|
239
|
+
// a lost phone or key: browsers trusted with it must ask again
|
|
240
|
+
await forgetBrowsers(me);
|
|
241
|
+
const what = f.kind === 'passkey' ? 'a passkey' : 'an authenticator app';
|
|
242
|
+
await logActivity({ username: me, event: '2fa_removed', ip: clientIp(req), detail: last ? `${what}, the last factor (recovery codes removed)` : what });
|
|
197
243
|
flash(s, tx('mfa.removed'));
|
|
198
244
|
return back(reply, s, `${BASE}/security`);
|
|
199
245
|
});
|
|
200
246
|
|
|
247
|
+
// (sprint 127) a passkey: the options for navigator.credentials.create(), then the browser's answer
|
|
248
|
+
app.post(`${BASE}/security/passkey/options`, async (req: Req, reply) => {
|
|
249
|
+
const s = await developer(req, reply);
|
|
250
|
+
if (!s) return;
|
|
251
|
+
const me = s.username!;
|
|
252
|
+
const rp = relyingParty(req);
|
|
253
|
+
const challenge = giveChallenge(s, 'create');
|
|
254
|
+
await saveState(s);
|
|
255
|
+
reply.header('cache-control', 'no-store');
|
|
256
|
+
return reply.send({
|
|
257
|
+
mode: 'create', challenge, rp: { id: rp.rpId, name: 'pgkiln' }, algs: ALGORITHMS,
|
|
258
|
+
// a stable handle that isn't the user name (the authenticator may show it, but it says nothing)
|
|
259
|
+
user: { id: userHandle(me), name: me, displayName: me },
|
|
260
|
+
exclude: (await passkeysOf(me, rp.rpId)).map((p) => p.credential_id),
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
app.post(`${BASE}/security/passkey`, async (req: Req, reply) => {
|
|
265
|
+
const s = await developer(req, reply);
|
|
266
|
+
if (!s) return;
|
|
267
|
+
const tx = tr(s);
|
|
268
|
+
const me = s.username!;
|
|
269
|
+
const challenge = takeChallenge(s, 'create');
|
|
270
|
+
if (!challenge) {
|
|
271
|
+
flash(s, tx('mfa.setup_expired'), 'error');
|
|
272
|
+
return back(reply, s, `${BASE}/security`);
|
|
273
|
+
}
|
|
274
|
+
const rp = relyingParty(req);
|
|
275
|
+
let reg;
|
|
276
|
+
try {
|
|
277
|
+
reg = verifyRegistration({ clientDataJSON: String(req.body?.clientDataJSON ?? ''), attestationObject: String(req.body?.attestationObject ?? '') }, { challenge, ...rp });
|
|
278
|
+
} catch (e) {
|
|
279
|
+
await logActivity({ username: me, event: '2fa_refused', ip: clientIp(req), detail: `passkey: ${e instanceof WebAuthnError ? e.message : 'unreadable answer'}` });
|
|
280
|
+
flash(s, tx('mfa.passkey_failed'), 'error');
|
|
281
|
+
return back(reply, s, `${BASE}/security`);
|
|
282
|
+
}
|
|
283
|
+
const first = !(await hasFactor(me));
|
|
284
|
+
try {
|
|
285
|
+
await addPasskey(me, String(req.body?.pk_name ?? ''), { ...reg, rpId: rp.rpId, transports: String(req.body?.transports ?? '').split(',').filter(Boolean) });
|
|
286
|
+
} catch {
|
|
287
|
+
flash(s, tx('mfa.passkey_known'), 'error');
|
|
288
|
+
return back(reply, s, `${BASE}/security`);
|
|
289
|
+
}
|
|
290
|
+
delete s.state.__MFA_DUE;
|
|
291
|
+
await logActivity({ username: me, event: '2fa_enrolled', ip: clientIp(req), detail: `passkey (${rp.rpId})` });
|
|
292
|
+
if (first || !(await codesLeft(me))) s.state.__CODES_NEW = JSON.stringify(await newRecoveryCodes(me));
|
|
293
|
+
flash(s, tx('mfa.passkey_added'));
|
|
294
|
+
return back(reply, s, `${BASE}/security${s.state.__CODES_NEW ? '#codes' : ''}`);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
app.post(`${BASE}/security/forget-browsers`, async (req: Req, reply) => {
|
|
298
|
+
const s = await developer(req, reply);
|
|
299
|
+
if (!s) return;
|
|
300
|
+
await forgetBrowsers(s.username!);
|
|
301
|
+
await logActivity({ username: s.username, event: '2fa_forget_browsers', ip: clientIp(req) });
|
|
302
|
+
flash(s, tr(s)('mfa.browsers_forgotten'));
|
|
303
|
+
return back(reply, s, `${BASE}/security`);
|
|
304
|
+
});
|
|
305
|
+
|
|
201
306
|
// (SEC-6) an administrator resets another developer's second factors: logged, the developer and the administrators told
|
|
202
307
|
app.post(`${BASE}/developers/reset-2fa`, async (req: Req, reply) => {
|
|
203
308
|
const s = await developer(req, reply);
|
|
@@ -228,13 +333,18 @@ export async function securityRoutes(app: FastifyInstance) {
|
|
|
228
333
|
if (!(await isAdmin(s.username))) return reply.code(403).send('Only administrators change the policy.');
|
|
229
334
|
const policy = MFA_POLICIES.includes(req.body?.policy as MfaPolicy) ? (req.body!.policy as MfaPolicy) : null;
|
|
230
335
|
const grace = Number(req.body?.grace_days);
|
|
231
|
-
|
|
336
|
+
const trust = Number(req.body?.trust_days ?? 0);
|
|
337
|
+
if (!policy || !Number.isInteger(grace) || grace < 0 || grace > 90 || !Number.isInteger(trust) || trust < 0 || trust > 90)
|
|
338
|
+
return reply.code(400).send('policy is off, admins or all; grace_days and trust_days 0 to 90');
|
|
232
339
|
if (await policyProblem(policy)) {
|
|
233
340
|
flash(s, tx('mfa.policy_needs_admins'), 'error');
|
|
234
341
|
return back(reply, s, `${BASE}/instance#mfa`);
|
|
235
342
|
}
|
|
343
|
+
const alone = req.body?.passkey_signin === '1';
|
|
236
344
|
await setPolicy(policy, grace);
|
|
237
|
-
await
|
|
345
|
+
await setTrustDays(trust);
|
|
346
|
+
await setPasskeySignin(alone);
|
|
347
|
+
await logActivity({ username: s.username, event: '2fa_policy', ip: clientIp(req), detail: `${policy}, grace ${grace} days, trust browsers ${trust} days, passkey sign-in ${alone ? 'on' : 'off'}` });
|
|
238
348
|
await notifyMfa(await adminEmails(), `pgkiln: two-factor sign-in policy is now ${policy}`, `${s.username} set the two-factor policy to ${policy} (grace period ${grace} days).`);
|
|
239
349
|
flash(s, tx('mfa.policy_saved'));
|
|
240
350
|
return back(reply, s, `${BASE}/instance#mfa`);
|
|
@@ -244,8 +354,8 @@ export async function securityRoutes(app: FastifyInstance) {
|
|
|
244
354
|
/** The Instance settings region for the policy (administrators). */
|
|
245
355
|
export async function policyRegion(s: Session): Promise<Raw> {
|
|
246
356
|
const tx = tr(s);
|
|
247
|
-
const [pol, af, without] = await Promise.all([
|
|
248
|
-
mfaPolicy(), adminFactors(),
|
|
357
|
+
const [pol, af, days, alone, without] = await Promise.all([
|
|
358
|
+
mfaPolicy(), adminFactors(), trustDays(), passkeySignin(),
|
|
249
359
|
owner.query<{ username: string; is_admin: boolean }>('select username, is_admin from meta.developer d where not exists (select 1 from meta.developer_factor f where f.username = d.username) order by username'),
|
|
250
360
|
]);
|
|
251
361
|
const opt = (v: MfaPolicy) => html`<option value="${v}"${pol.policy === v ? raw(' selected') : ''}>${tx(({ off: 'mfa.policy_off', admins: 'mfa.policy_admins', all: 'mfa.policy_all' } as const)[v])}</option>`;
|
|
@@ -253,7 +363,10 @@ export async function policyRegion(s: Session): Promise<Raw> {
|
|
|
253
363
|
<div class="form-grid">
|
|
254
364
|
<div class="field"><label class="label" for="f_policy">${tx('mfa.policy_label')}</label><select id="f_policy" name="policy">${MFA_POLICIES.map(opt)}</select></div>
|
|
255
365
|
${input('grace_days', tx('mfa.grace'), pol.graceDays, { type: 'number', help: tx('mfa.grace_help') })}
|
|
366
|
+
${input('trust_days', tx('mfa.trust_days'), days, { type: 'number', help: tx('mfa.trust_days_help') })}
|
|
256
367
|
</div>
|
|
368
|
+
<div class="field"><label class="check"><input type="checkbox" name="passkey_signin" value="1"${alone ? raw(' checked') : ''}> ${tx('mfa.passkey_signin')}</label>
|
|
369
|
+
<small class="help">${tx('mfa.passkey_signin_help')}</small></div>
|
|
257
370
|
<p class="muted small">${tx('mfa.policy_help', { enrolled: af.enrolled, admins: af.admins })}</p>
|
|
258
371
|
<div class="buttons"><button class="btn btn-hot">${tx('mfa.policy_save')}</button></div></form>
|
|
259
372
|
${without.rows.length ? html`<p class="small">${tx('mfa.without', { list: without.rows.map((d) => `${d.username}${d.is_admin ? ' (admin)' : ''}`).join(', ') })}</p>` : ''}
|
package/src/builder/ui.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { ensureDevbar } from '../runtime/devbar.ts';
|
|
|
12
12
|
import { noteChange } from './app-updates.ts';
|
|
13
13
|
import { clientTexts, tr } from './i18n.ts';
|
|
14
14
|
import { languageMenu, loadLanguage } from './language.ts';
|
|
15
|
-
import { enrolmentDue } from '../mfa.ts';
|
|
15
|
+
import { enrolmentDue, requirement } from '../mfa.ts';
|
|
16
16
|
|
|
17
17
|
// Shared building blocks of the builder UI.
|
|
18
18
|
|
|
@@ -323,9 +323,19 @@ export async function developer(req: Req, reply: FastifyReply) {
|
|
|
323
323
|
if (s.state.__BLANG === undefined) await loadLanguage(s);
|
|
324
324
|
// (sprint 126, SEC-2) the two-factor policy: a banner during the grace period, then only the Security page until they enrol
|
|
325
325
|
delete s.state.__MFA_DUE;
|
|
326
|
-
const
|
|
326
|
+
const admin = await isAdmin(s.username);
|
|
327
|
+
// (sprint 128) signed in with the default password while a second factor is required: change it first
|
|
328
|
+
// (on Developers), then the enrolment below
|
|
329
|
+
const weakFirst = !!s.state.__WEAK && (await requirement(s.username, admin)).required;
|
|
330
|
+
if (weakFirst && !/^\/builder\/(developers|logout|language|theme)(?:[/?#]|$)/.test(req.url)) {
|
|
331
|
+
flash(s, tr(s)('mfa.weak_required'), 'error');
|
|
332
|
+
await saveState(s);
|
|
333
|
+
reply.redirect(`${BASE}/developers`);
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
const due = await enrolmentDue(s.username, admin);
|
|
327
337
|
if (due?.due === 'grace') s.state.__MFA_DUE = due.until.toISOString();
|
|
328
|
-
else if (due && !/^\/builder\/(security|logout|language|theme)(?:[/?]|$)/.test(req.url)) {
|
|
338
|
+
else if (due && !weakFirst && !/^\/builder\/(security|logout|language|theme)(?:[/?]|$)/.test(req.url)) {
|
|
329
339
|
reply.redirect(`${BASE}/security`);
|
|
330
340
|
return null;
|
|
331
341
|
}
|
|
@@ -8,6 +8,7 @@ import { html, raw } from '../html.ts';
|
|
|
8
8
|
import { icon } from '../icons.ts';
|
|
9
9
|
import { clientIp, logActivity, type BuilderWorkspace, type Session } from '../session.ts';
|
|
10
10
|
import { isAdmin } from './locks.ts';
|
|
11
|
+
import { setWorkspaceRequired } from '../mfa.ts';
|
|
11
12
|
import { back, BANNER_COLORS, BASE, csrf, developer, flash, input, region, select, send, shell, type Req } from './ui.ts';
|
|
12
13
|
|
|
13
14
|
// Workspaces (064; APEX: workspaces). A workspace groups applications and the
|
|
@@ -215,7 +216,7 @@ function serviceRegion(s: Session, w: { id: number; message: string | null; bann
|
|
|
215
216
|
}
|
|
216
217
|
|
|
217
218
|
/** (093) Workspace preferences: the SQL Workshop and the applications' REST modules on or off. */
|
|
218
|
-
function preferencesRegion(s: Session, w: { id: number; sql_workshop: boolean; rest_services: boolean; builder_home: string | null }) {
|
|
219
|
+
function preferencesRegion(s: Session, w: { id: number; sql_workshop: boolean; rest_services: boolean; builder_home: string | null; mfa_required_since: Date | null }, without: string[]) {
|
|
219
220
|
const check = (name: string, on: boolean, label: string, help: string) =>
|
|
220
221
|
html`<div class="field"><label class="check"><input type="checkbox" name="${name}" value="true"${on ? raw(' checked') : ''}> ${label}</label><small class="help">${help}</small></div>`;
|
|
221
222
|
const tx = tr(s);
|
|
@@ -223,7 +224,12 @@ function preferencesRegion(s: Session, w: { id: number; sql_workshop: boolean; r
|
|
|
223
224
|
${check('sql_workshop', w.sql_workshop, tx('wsp.sql_on'), tx('wsp.sql_on_help'))}
|
|
224
225
|
${check('rest_services', w.rest_services, tx('wsp.rest_on'), tx('wsp.rest_on_help', { path: '/a/<alias>/rest/…' }))}
|
|
225
226
|
${select('builder_home', tx('wsp.builder_home'), w.builder_home === 'home' ? 'home' : 'apps', [['apps', tx('wsp.home.apps')], ['home', tx('wsp.home.home')]], tx('wsp.builder_home_help'))}
|
|
226
|
-
<div class="buttons"><button class="btn">${tx('wsp.save_prefs')}</button></div></form
|
|
227
|
+
<div class="buttons"><button class="btn">${tx('wsp.save_prefs')}</button></div></form>
|
|
228
|
+
<h3 id="mfa">${tx('wsp.mfa')}</h3>
|
|
229
|
+
<form method="post" action="${BASE}/workspaces/${w.id}/mfa">${csrf(s)}
|
|
230
|
+
${check('required', !!w.mfa_required_since, tx('wsp.mfa_required'), tx('wsp.mfa_required_help'))}
|
|
231
|
+
${w.mfa_required_since ? html`<p class="small">${tx('wsp.mfa_since', { date: new Date(w.mfa_required_since).toISOString().slice(0, 10) })}${without.length ? html` ${tx('wsp.mfa_without', { list: without.join(', ') })}` : ''}</p>` : ''}
|
|
232
|
+
<div class="buttons"><button class="btn">${tx('wsp.mfa_save')}</button></div></form>`, '', 'preferences');
|
|
227
233
|
}
|
|
228
234
|
|
|
229
235
|
/** (093) Extension links: links to other tools in the builder's Extensions menu. */
|
|
@@ -341,6 +347,10 @@ export async function workspaceRoutes(app: FastifyInstance) {
|
|
|
341
347
|
]);
|
|
342
348
|
const roles = rolesQ;
|
|
343
349
|
const links = (await owner.query('select id, label, url, description, position from meta.workspace_link where workspace_id = $1 order by position, lower(label)', [w.id])).rows;
|
|
350
|
+
// (sprint 128) the members still without a second factor
|
|
351
|
+
const without = (await owner.query<{ username: string }>(
|
|
352
|
+
`select m.username from meta.workspace_member m where m.workspace_id = $1
|
|
353
|
+
and not exists (select 1 from meta.developer_factor f where f.username = m.username) order by 1`, [w.id])).rows.map((r) => r.username);
|
|
344
354
|
const main = html`
|
|
345
355
|
<div class="title-row"><h1>${w.name}</h1></div>
|
|
346
356
|
<div class="columns wide-left">
|
|
@@ -365,7 +375,7 @@ export async function workspaceRoutes(app: FastifyInstance) {
|
|
|
365
375
|
</div>
|
|
366
376
|
${serviceRegion(s, w)}
|
|
367
377
|
${region(tx('wsp.mail_net'), html`<p class="u-mt0">${tx.rich('wsp.mail_net_text', { mail: html`<a href="${BASE}/workspaces/${w.id}/mail">${tx('wsp.mail_service')}</a>`, network: html`<a href="${BASE}/network?ws=${w.id}">${tx('wsp.network')}</a>`, queue: html`<a href="${BASE}/mail-queue${full ? `?ws=${w.id}` : ''}">${tx('wsp.queue')}</a>` })}</p>`, '', 'mail-network')}
|
|
368
|
-
<div class="columns">${preferencesRegion(s, w)}${linksRegion(s, w, links)}</div>
|
|
378
|
+
<div class="columns">${preferencesRegion(s, w, without)}${linksRegion(s, w, links)}</div>
|
|
369
379
|
${full ? isolationRegion(s, w, roles.rows) : region(tx('wsp.isolation'), html`<p class="u-mt0">${w.isolated ? tx('wsp.iso_short_on') : tx('wsp.iso_short_off')} ${tx('wsp.admins_change')}</p>`)}
|
|
370
380
|
${!full ? '' : region(tx('wsp.applications'), html`<div class="table-wrap"><table class="report report-reflow">
|
|
371
381
|
<thead><tr><th>${tx('wsp.application')}</th><th>${tx('wsp.move_to')}</th></tr></thead>
|
|
@@ -559,6 +569,17 @@ export async function workspaceRoutes(app: FastifyInstance) {
|
|
|
559
569
|
return back(reply, s, `${BASE}/workspaces/${req.params.id}`);
|
|
560
570
|
});
|
|
561
571
|
|
|
572
|
+
// (sprint 128, SEC-2) a workspace requires a second factor of its developers (the instance's grace period from now)
|
|
573
|
+
app.post(`${BASE}/workspaces/:id(^\\d{1,9}$)/mfa`, async (req: Req, reply) => {
|
|
574
|
+
const s = await developer(req, reply);
|
|
575
|
+
if (!s || !(await workspaceAdminOnly(s, reply, req.params.id))) return;
|
|
576
|
+
const on = req.body?.required === 'true';
|
|
577
|
+
if (!(await setWorkspaceRequired(Number(req.params.id), on))) return reply.code(404).send('Not found');
|
|
578
|
+
await logActivity({ appId: null, username: s.username, event: '2fa_policy', ip: clientIp(req), detail: `workspace ${req.params.id}: two-factor sign-in ${on ? 'required' : 'not required'}` });
|
|
579
|
+
flash(s, tr(s)(on ? 'wsp.mfa_on' : 'wsp.mfa_off'));
|
|
580
|
+
return back(reply, s, `${BASE}/workspaces/${req.params.id}#mfa`);
|
|
581
|
+
});
|
|
582
|
+
|
|
562
583
|
// (093) extension links
|
|
563
584
|
app.post(`${BASE}/workspaces/:id(^\\d{1,9}$)/links`, async (req: Req, reply) => {
|
|
564
585
|
const s = await developer(req, reply);
|
package/src/cli/main.ts
CHANGED
|
@@ -477,9 +477,9 @@ const COMMANDS: Record<string, Command> = {
|
|
|
477
477
|
'pgkiln admin reset-2fa <developer> [--reason <text>]\n' +
|
|
478
478
|
'pgkiln admin set-2fa-policy off|admins|all [--grace <days>]\n\n' +
|
|
479
479
|
"reset-2fa removes the developer's authenticator apps and recovery codes and ends their builder\n" +
|
|
480
|
-
'sessions; they sign in with the password and set up an app again. set-2fa-policy off always works
|
|
481
|
-
'admins and all need at least two administrators
|
|
482
|
-
'are fewer). Both are written to the activity log and mailed to the administrators.',
|
|
480
|
+
'sessions; they sign in with the password and set up an app again. set-2fa-policy off always works\n' +
|
|
481
|
+
"(and clears the workspaces' own two-factor rules); admins and all need at least two administrators\n" +
|
|
482
|
+
'with two-factor sign-in (or every one, when there are fewer). Both are written to the activity log and mailed to the administrators.',
|
|
483
483
|
options: { reason: { type: 'string' }, grace: { type: 'string' } },
|
|
484
484
|
optionHelp: [
|
|
485
485
|
['--reason <text>', 'reset-2fa: why (in the activity log and the mail)'],
|
|
@@ -509,9 +509,11 @@ const COMMANDS: Record<string, Command> = {
|
|
|
509
509
|
const policy = arg as 'off' | 'admins' | 'all';
|
|
510
510
|
if (await mfa.policyProblem(policy)) throw new Error('at least two administrators (or every one, when there are fewer) need two-factor sign-in first');
|
|
511
511
|
await mfa.setPolicy(policy, grace);
|
|
512
|
-
|
|
512
|
+
// (sprint 128) off means off: no workspace requires a second factor either
|
|
513
|
+
const cleared = policy === 'off' ? await mfa.clearWorkspaceRequirements() : 0;
|
|
514
|
+
await logActivity({ event: '2fa_policy', ip: 'cli', detail: `${policy}, grace ${grace} days${cleared ? `, ${cleared} workspace rule(s) cleared` : ''} (${by})` });
|
|
513
515
|
const sent = await mfa.notifyMfa(await mfa.adminEmails(), `pgkiln: two-factor sign-in policy is now ${policy}`, `${by} set the two-factor policy to ${policy} on the server.`);
|
|
514
|
-
out(`Two-factor policy: ${policy}${policy === 'off' ? '' : `, grace ${grace} days`}.${sent ? ` ${sent} administrator(s) told by mail.` : ''}\n`);
|
|
516
|
+
out(`Two-factor policy: ${policy}${policy === 'off' ? '' : `, grace ${grace} days`}.${cleared ? ` ${cleared} workspace(s) no longer require it.` : ''}${sent ? ` ${sent} administrator(s) told by mail.` : ''}\n`);
|
|
515
517
|
return EXIT.ok;
|
|
516
518
|
}
|
|
517
519
|
throw new UsageError(`unknown action ${action}: use reset-2fa or set-2fa-policy`);
|
package/src/instance.ts
CHANGED
|
@@ -108,7 +108,8 @@ export async function saveInstanceSettings(values: Record<string, string | undef
|
|
|
108
108
|
export const CONFIGURATION: readonly { env: string; secret?: boolean; default: string; help: string }[] = [
|
|
109
109
|
{ env: 'PORT', default: '3100', help: 'HTTP port' },
|
|
110
110
|
{ env: 'HOST', default: '127.0.0.1', help: 'Address the server listens on' },
|
|
111
|
-
{ env: 'PUBLIC_URL', default: '(none)', help: 'The public address (single sign-on redirects, links)' },
|
|
111
|
+
{ env: 'PUBLIC_URL', default: '(none)', help: 'The public address (single sign-on redirects, links, passkeys)' },
|
|
112
|
+
{ env: 'PGKILN_RP_ID', default: '(the host of PUBLIC_URL)', help: 'A parent domain for developers\' passkeys' },
|
|
112
113
|
{ env: 'DATABASE_URL', secret: true, default: '(none)', help: 'Owner connection (migrations, builder, SQL Workshop)' },
|
|
113
114
|
{ env: 'RUNTIME_DATABASE_URL', secret: true, default: 'DATABASE_URL', help: 'Least-privilege connection that runs applications' },
|
|
114
115
|
{ env: 'COOKIE_SECURE', default: 'false', help: 'Cookies only over HTTPS' },
|
package/src/mfa.ts
CHANGED
|
@@ -9,6 +9,10 @@ import { decryptSecret, encryptSecret } from './secrets.ts';
|
|
|
9
9
|
// setting; it can only be required while two administrators (or every administrator, when there are fewer)
|
|
10
10
|
// have a working second factor, so it never locks the instance out. The command line resets a developer's
|
|
11
11
|
// factors and turns the policy off without signing in (src/cli/main.ts, `pgkiln admin`).
|
|
12
|
+
// (sprint 127) Passkeys and security keys (WebAuthn, src/webauthn.ts) are the second kind of factor, and a
|
|
13
|
+
// developer may trust a browser for a number of days (meta.setting mfa_trust_days; 0 = never).
|
|
14
|
+
// (sprint 128) A passkey the authenticator verified the developer with may stand in for both the password and
|
|
15
|
+
// the second factor (mfa_passkey_signin), and a workspace may require a second factor of its developers.
|
|
12
16
|
|
|
13
17
|
export const TOTP_STEP = 30;
|
|
14
18
|
export const TOTP_DIGITS = 6;
|
|
@@ -92,10 +96,10 @@ export const hashCode = (code: string) => createHash('sha256').update(normaliseC
|
|
|
92
96
|
|
|
93
97
|
// ---------------------------------------------------------------- the developer's factors
|
|
94
98
|
|
|
95
|
-
export interface Factor { id: number; kind: 'totp'; name: string; created_at: string; last_used_at: string | null }
|
|
99
|
+
export interface Factor { id: number; kind: 'totp' | 'passkey'; name: string; created_at: string; last_used_at: string | null; rp_id: string | null }
|
|
96
100
|
|
|
97
101
|
export async function factorsOf(username: string, db: Pick<pg.ClientBase, 'query'> = owner.pool): Promise<Factor[]> {
|
|
98
|
-
return (await db.query<Factor>('select id, kind, name, created_at, last_used_at from meta.developer_factor where username = $1 order by id', [username])).rows;
|
|
102
|
+
return (await db.query<Factor>('select id, kind, name, created_at, last_used_at, rp_id from meta.developer_factor where username = $1 order by kind desc, id', [username])).rows;
|
|
99
103
|
}
|
|
100
104
|
|
|
101
105
|
/** Recovery codes left (confirmed, unused). */
|
|
@@ -116,6 +120,96 @@ export async function addTotp(username: string, name: string, secret: string, st
|
|
|
116
120
|
);
|
|
117
121
|
}
|
|
118
122
|
|
|
123
|
+
// ---------------------------------------------------------------- passkeys (sprint 127)
|
|
124
|
+
|
|
125
|
+
export interface Passkey { id: number; name: string; credential_id: string; public_key: Record<string, string>; alg: number; sign_count: string; transports: string[] }
|
|
126
|
+
|
|
127
|
+
/** Add a passkey (after src/webauthn.ts checked the browser's answer). */
|
|
128
|
+
export async function addPasskey(username: string, name: string, p: { credentialId: string; jwk: Record<string, string>; alg: number; signCount: number; rpId: string; transports: string[] }) {
|
|
129
|
+
await owner.query(
|
|
130
|
+
`insert into meta.developer_factor (username, kind, name, credential_id, public_key, alg, sign_count, rp_id, transports, last_used_at)
|
|
131
|
+
values ($1, 'passkey', $2, $3, $4, $5, $6, $7, $8, now())`,
|
|
132
|
+
[username, name.trim().slice(0, 60) || 'Passkey', p.credentialId, JSON.stringify(p.jwk), p.alg, p.signCount, p.rpId,
|
|
133
|
+
p.transports.filter((t) => /^[a-z-]{1,20}$/.test(t)).slice(0, 8)],
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The developer's passkeys for this relying party (a passkey only works on the domain it was made for). */
|
|
138
|
+
export async function passkeysOf(username: string, rpId: string): Promise<Passkey[]> {
|
|
139
|
+
return (await owner.query<Passkey>(
|
|
140
|
+
`select id, name, credential_id, public_key, alg, sign_count, transports from meta.developer_factor
|
|
141
|
+
where username = $1 and kind = 'passkey' and rp_id = $2 order by id`, [username, rpId])).rows;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** (sprint 128) The passkey with this credential id for this relying party, and whose it is (sign-in without a user name). */
|
|
145
|
+
export async function passkeyByCredential(credentialId: string, rpId: string): Promise<(Passkey & { username: string }) | null> {
|
|
146
|
+
if (!credentialId || credentialId.length > 1400) return null;
|
|
147
|
+
return (await owner.one<Passkey & { username: string }>(
|
|
148
|
+
`select id, username, name, credential_id, public_key, alg, sign_count, transports from meta.developer_factor
|
|
149
|
+
where kind = 'passkey' and credential_id = $1 and rp_id = $2`, [credentialId, rpId])) ?? null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The user handle a developer's passkeys carry: stable, and not the user name. */
|
|
153
|
+
export const userHandle = (username: string) => createHash('sha256').update(`pgkiln-developer:${username}`).digest().subarray(0, 16).toString('base64url');
|
|
154
|
+
|
|
155
|
+
/** (sprint 128) Whether a passkey alone (user verified) may sign a developer in. */
|
|
156
|
+
export async function passkeySignin(): Promise<boolean> {
|
|
157
|
+
return (await owner.one<{ value: string }>(`select value from meta.setting where name = 'mfa_passkey_signin'`))?.value !== 'off';
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function setPasskeySignin(on: boolean) {
|
|
161
|
+
await owner.query(`insert into meta.setting (name, value) values ('mfa_passkey_signin', $1) on conflict (name) do update set value = excluded.value`, [on ? 'on' : 'off']);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Record a sign-in with a passkey; false when another sign-in moved the counter first (a replay or a clone). */
|
|
165
|
+
export async function usePasskey(id: number, oldCount: string, newCount: number): Promise<boolean> {
|
|
166
|
+
return !!(await owner.query(
|
|
167
|
+
'update meta.developer_factor set sign_count = $3, last_used_at = now() where id = $1 and sign_count = $2::bigint',
|
|
168
|
+
[id, oldCount, newCount])).rowCount;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ---------------------------------------------------------------- trusted browsers (sprint 127)
|
|
172
|
+
|
|
173
|
+
/** Days a browser may skip the second step after one (0 = never). */
|
|
174
|
+
export async function trustDays(): Promise<number> {
|
|
175
|
+
const v = Number((await owner.one<{ value: string }>(`select value from meta.setting where name = 'mfa_trust_days'`))?.value);
|
|
176
|
+
return Number.isInteger(v) && v >= 0 && v <= 90 ? v : 0;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export async function setTrustDays(days: number) {
|
|
180
|
+
await owner.query(`insert into meta.setting (name, value) values ('mfa_trust_days', $1) on conflict (name) do update set value = excluded.value`, [String(days)]);
|
|
181
|
+
if (days === 0) await owner.query('delete from meta.developer_trusted_browser');
|
|
182
|
+
else await owner.query(`delete from meta.developer_trusted_browser where expires_at > now() + make_interval(days => $1)`, [days]);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const tokenHash = (token: string) => createHash('sha256').update(token).digest('hex');
|
|
186
|
+
|
|
187
|
+
/** Trust this browser: a random token for the cookie (only its hash is stored). */
|
|
188
|
+
export async function trustBrowser(username: string, days: number, userAgent: string): Promise<string> {
|
|
189
|
+
const token = randomBytes(32).toString('base64url');
|
|
190
|
+
await owner.query(
|
|
191
|
+
`insert into meta.developer_trusted_browser (token_hash, username, expires_at, user_agent) values ($1, $2, now() + make_interval(days => $3), $4)`,
|
|
192
|
+
[tokenHash(token), username, days, userAgent.slice(0, 200)]);
|
|
193
|
+
return token;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Whether this browser's token is trusted for this developer (and trusting is still on). */
|
|
197
|
+
export async function isTrustedBrowser(username: string, token: string | undefined): Promise<boolean> {
|
|
198
|
+
if (!token || token.length > 100) return false;
|
|
199
|
+
return !!(await owner.one(
|
|
200
|
+
`select 1 as ok from meta.developer_trusted_browser t where token_hash = $1 and username = $2 and expires_at > now()
|
|
201
|
+
and exists (select 1 from meta.setting where name = 'mfa_trust_days' and value <> '0')`,
|
|
202
|
+
[tokenHash(token), username]));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export async function trustedBrowsers(username: string): Promise<number> {
|
|
206
|
+
return (await owner.one<{ n: number }>('select count(*)::int as n from meta.developer_trusted_browser where username = $1 and expires_at > now()', [username]))!.n;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export async function forgetBrowsers(username: string) {
|
|
210
|
+
await owner.query('delete from meta.developer_trusted_browser where username = $1', [username]);
|
|
211
|
+
}
|
|
212
|
+
|
|
119
213
|
/** New recovery codes (the old ones stop working); shown once, counted only when confirmed. */
|
|
120
214
|
export async function newRecoveryCodes(username: string): Promise<string[]> {
|
|
121
215
|
const codes = Array.from({ length: RECOVERY_CODES }, newRecoveryCode);
|
|
@@ -212,14 +306,47 @@ export async function setPolicy(policy: MfaPolicy, graceDays: number) {
|
|
|
212
306
|
});
|
|
213
307
|
}
|
|
214
308
|
|
|
309
|
+
/**
|
|
310
|
+
* (sprint 128) Whether a second factor is required of this developer, by the instance's policy or by one of
|
|
311
|
+
* their workspaces, and since when (the grace period counts from the earliest).
|
|
312
|
+
*/
|
|
313
|
+
export async function requirement(username: string, isAdmin: boolean): Promise<{ required: boolean; since: Date | null; graceDays: number; workspaces: string[] }> {
|
|
314
|
+
const [{ policy, since, graceDays }, ws] = await Promise.all([
|
|
315
|
+
mfaPolicy(),
|
|
316
|
+
owner.query<{ name: string; since: Date }>(
|
|
317
|
+
`select w.name, w.mfa_required_since as since from meta.workspace w join meta.workspace_member m on m.workspace_id = w.id
|
|
318
|
+
where m.username = $1 and w.mfa_required_since is not null order by w.mfa_required_since`, [username]),
|
|
319
|
+
]);
|
|
320
|
+
const byInstance = requiredFor(policy, isAdmin);
|
|
321
|
+
const times = [...(byInstance ? [since ?? new Date()] : []), ...ws.rows.map((r) => new Date(r.since))];
|
|
322
|
+
return {
|
|
323
|
+
required: times.length > 0,
|
|
324
|
+
since: times.length ? new Date(Math.min(...times.map((t) => t.getTime()))) : null,
|
|
325
|
+
graceDays,
|
|
326
|
+
workspaces: ws.rows.map((r) => r.name),
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** (sprint 128) Require a second factor of a workspace's developers (from now), or stop requiring it. */
|
|
331
|
+
export async function setWorkspaceRequired(workspaceId: number, on: boolean): Promise<boolean> {
|
|
332
|
+
return !!(await owner.query(
|
|
333
|
+
`update meta.workspace set mfa_required_since = case when $2 then coalesce(mfa_required_since, now()) end where id = $1`,
|
|
334
|
+
[workspaceId, on])).rowCount;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** The command line's "off": no workspace requires a second factor either. */
|
|
338
|
+
export async function clearWorkspaceRequirements(): Promise<number> {
|
|
339
|
+
return (await owner.query('update meta.workspace set mfa_required_since = null where mfa_required_since is not null')).rowCount ?? 0;
|
|
340
|
+
}
|
|
341
|
+
|
|
215
342
|
/**
|
|
216
343
|
* A developer without a factor while the policy requires one: 'grace' until the grace period ends (a
|
|
217
344
|
* banner), then 'enrol' (only the Security page and signing out until they enrol); null otherwise.
|
|
218
345
|
*/
|
|
219
346
|
export async function enrolmentDue(username: string, isAdmin: boolean): Promise<{ due: 'grace' | 'enrol'; until: Date } | null> {
|
|
220
|
-
const
|
|
221
|
-
if (!
|
|
222
|
-
const until = new Date((since ?? new Date()).getTime() + graceDays * 86_400_000);
|
|
347
|
+
const r = await requirement(username, isAdmin);
|
|
348
|
+
if (!r.required || (await hasFactor(username))) return null;
|
|
349
|
+
const until = new Date((r.since ?? new Date()).getTime() + r.graceDays * 86_400_000);
|
|
223
350
|
return { due: Date.now() < until.getTime() ? 'grace' : 'enrol', until };
|
|
224
351
|
}
|
|
225
352
|
|
|
@@ -230,6 +357,7 @@ export async function resetFactors(username: string): Promise<boolean> {
|
|
|
230
357
|
if (!known) return false;
|
|
231
358
|
await c.query('delete from meta.developer_factor where username = $1', [username]);
|
|
232
359
|
await c.query('delete from meta.developer_recovery_code where username = $1', [username]);
|
|
360
|
+
await c.query('delete from meta.developer_trusted_browser where username = $1', [username]);
|
|
233
361
|
await c.query('delete from meta.session where app_id is null and username = $1', [username]);
|
|
234
362
|
return true;
|
|
235
363
|
});
|