pgkiln 0.59.0 → 0.61.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/README.md +3 -0
- package/SECURITY.md +3 -0
- package/db/migrations/128_developer_mfa.sql +48 -0
- package/db/migrations/129_developer_passkeys.sql +49 -0
- package/docs/apex-feature-parity.md +6 -3
- package/docs/guide/01-installation.md +2 -1
- package/docs/guide/03-builder.md +74 -0
- package/docs/guide/08-security.md +4 -0
- package/docs/guide/18-cli.md +9 -0
- package/docs/guide/20-ai-agents.md +18 -0
- package/package.json +1 -1
- package/public/builder.css +8 -0
- package/public/builder.js +61 -0
- package/src/builder/i18n/de.ts +97 -0
- package/src/builder/i18n/en.ts +97 -0
- package/src/builder/i18n/es.ts +97 -0
- package/src/builder/i18n/fr.ts +97 -0
- package/src/builder/i18n/nl.ts +97 -0
- package/src/builder/i18n/pt.ts +97 -0
- package/src/builder/instance.ts +2 -0
- package/src/builder/routes.ts +196 -8
- package/src/builder/security.ts +371 -0
- package/src/builder/ui.ts +13 -1
- package/src/cli/main.ts +51 -0
- package/src/instance.ts +2 -1
- package/src/mfa.ts +333 -0
- package/src/webauthn.ts +205 -0
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { isIP } from 'node:net';
|
|
3
|
+
import type { FastifyInstance } from 'fastify';
|
|
4
|
+
import { owner } from '../db.ts';
|
|
5
|
+
import { html, raw, type Raw } from '../html.ts';
|
|
6
|
+
import { icon } from '../icons.ts';
|
|
7
|
+
import {
|
|
8
|
+
addPasskey, addTotp, adminEmails, adminFactors, codesLeft, confirmRecoveryCodes, factorsOf, forgetBrowsers, hasFactor, matchTotp, MFA_POLICIES, mfaPolicy,
|
|
9
|
+
newRecoveryCodes, newTotpSecret, notifyMfa, otpauthUri, passkeysOf, policyProblem, requiredFor, resetFactors, setPolicy, setTrustDays, trustDays,
|
|
10
|
+
trustedBrowsers, unbase32, type MfaPolicy,
|
|
11
|
+
} from '../mfa.ts';
|
|
12
|
+
import { qrSvg } from '../qrcode.ts';
|
|
13
|
+
import { secretKeyConfigured } from '../secrets.ts';
|
|
14
|
+
import { clientIp, logActivity, saveState, type Session } from '../session.ts';
|
|
15
|
+
import { ALGORITHMS, b64url, newChallenge, relyingParty, verifyRegistration, WebAuthnError } from '../webauthn.ts';
|
|
16
|
+
import { tr } from './i18n.ts';
|
|
17
|
+
import { isAdmin } from './locks.ts';
|
|
18
|
+
import { back, BASE, csrf, developer, flash, input, region, send, shell, type Req } from './ui.ts';
|
|
19
|
+
|
|
20
|
+
// (sprint 126, SEC-2 and SEC-6) Developer › Security: a developer's second factors (authenticator apps, TOTP)
|
|
21
|
+
// and recovery codes; the instance's policy (Instance settings); an administrator's reset of another
|
|
22
|
+
// developer's factors (Developers). Adding an app: a new secret is kept in the session until a code from the
|
|
23
|
+
// app confirms it, then stored encrypted with the instance key. Recovery codes are shown once; they count
|
|
24
|
+
// only after "I saved them". Removing a factor asks for the password, and is refused when it would leave a
|
|
25
|
+
// developer the policy covers without one, or the instance without enough administrators who have one.
|
|
26
|
+
// (sprint 127) Passkeys (WebAuthn): the browser asks the server for options (a one-time challenge kept in the
|
|
27
|
+
// session for five minutes), calls navigator.credentials.create() and posts the answer in a normal form
|
|
28
|
+
// (public/builder.js, [data-passkey]); without JavaScript the button stays hidden. Trusted browsers can be
|
|
29
|
+
// forgotten here; the instance decides how long a browser may be trusted (0 = never).
|
|
30
|
+
|
|
31
|
+
const CHALLENGE_SECONDS = 300;
|
|
32
|
+
|
|
33
|
+
/** A one-time WebAuthn challenge kept in the session (for this purpose only), or null when there is none or it expired. */
|
|
34
|
+
export function takeChallenge(s: Session, purpose: 'create' | 'get'): string | null {
|
|
35
|
+
try {
|
|
36
|
+
const p = JSON.parse(String(s.state.__PK ?? ''));
|
|
37
|
+
delete s.state.__PK;
|
|
38
|
+
return p && p.purpose === purpose && Date.now() - Number(p.at) < CHALLENGE_SECONDS * 1000 && typeof p.c === 'string' ? p.c : null;
|
|
39
|
+
} catch {
|
|
40
|
+
delete s.state.__PK;
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export function giveChallenge(s: Session, purpose: 'create' | 'get'): string {
|
|
45
|
+
const c = newChallenge();
|
|
46
|
+
s.state.__PK = JSON.stringify({ c, purpose, at: Date.now() });
|
|
47
|
+
return c;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const SECRET_MINUTES = 10;
|
|
51
|
+
|
|
52
|
+
async function verifyPassword(username: string, password: string) {
|
|
53
|
+
return !!(await owner.one('select 1 as ok from meta.developer where username = $1 and crypt($2, password_hash) = password_hash', [username, password]));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function securityRoutes(app: FastifyInstance) {
|
|
57
|
+
app.get(`${BASE}/security`, async (req: Req, reply) => {
|
|
58
|
+
const s = await developer(req, reply);
|
|
59
|
+
if (!s) return;
|
|
60
|
+
const tx = tr(s);
|
|
61
|
+
const me = s.username!;
|
|
62
|
+
const [factors, left, pol, admin, days, trusted] = await Promise.all([factorsOf(me), codesLeft(me), mfaPolicy(), isAdmin(me), trustDays(), trustedBrowsers(me)]);
|
|
63
|
+
const rp = relyingParty(req);
|
|
64
|
+
const required = requiredFor(pol.policy, admin);
|
|
65
|
+
const pending = (() => {
|
|
66
|
+
try {
|
|
67
|
+
const p = JSON.parse(String(s.state.__TOTP_NEW ?? ''));
|
|
68
|
+
return p && Date.now() - p.at < SECRET_MINUTES * 60_000 ? (p as { secret: string; at: number }) : null;
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
})();
|
|
73
|
+
const shownCodes: string[] | null = (() => {
|
|
74
|
+
try {
|
|
75
|
+
const c = JSON.parse(String(s.state.__CODES_NEW ?? ''));
|
|
76
|
+
return Array.isArray(c) ? c : null;
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
})();
|
|
81
|
+
delete s.state.__MFA_DUE; // the page says it itself
|
|
82
|
+
const when = (v: string | null) => (v ? String(v).slice(0, 16).replace('T', ' ') : '—');
|
|
83
|
+
const setup = pending
|
|
84
|
+
? html`<form method="post" action="${BASE}/security/totp/confirm">${csrf(s)}
|
|
85
|
+
<ol class="mfa-steps">
|
|
86
|
+
<li>${tx('mfa.scan')}</li>
|
|
87
|
+
<li>${tx('mfa.or_enter')} <code class="mfa-secret">${pending.secret.replace(/(.{4})/g, '$1 ').trim()}</code></li>
|
|
88
|
+
<li>${tx('mfa.enter_code')}</li>
|
|
89
|
+
</ol>
|
|
90
|
+
<div class="mfa-qr">${raw(qrSvg(otpauthUri(pending.secret, me), { label: tx('mfa.qr_label'), px: 4 }) ?? '')}</div>
|
|
91
|
+
<div class="form-grid">
|
|
92
|
+
${input('name', tx('mfa.app_name'), tx('mfa.app_default'), { required: true })}
|
|
93
|
+
<div class="field"><label class="label" for="f_code">${tx('mfa.code')}</label><input id="f_code" name="code" required inputmode="numeric" pattern="[0-9 ]{6,7}" autocomplete="one-time-code" maxlength="7"></div>
|
|
94
|
+
</div>
|
|
95
|
+
<div class="buttons"><button class="btn btn-hot">${tx('mfa.confirm_app')}</button></div></form>
|
|
96
|
+
<form method="post" action="${BASE}/security/totp/cancel">${csrf(s)}<button class="link-button">${tx('mfa.cancel_setup')}</button></form>`
|
|
97
|
+
: html`<form method="post" action="${BASE}/security/totp/start">${csrf(s)}
|
|
98
|
+
<div class="buttons"><button class="btn${factors.length ? '' : ' btn-hot'}"${secretKeyConfigured() ? '' : raw(' disabled')}>${icon('plus')} ${tx('mfa.add_app')}</button></div>
|
|
99
|
+
${secretKeyConfigured() ? '' : html`<p class="alert alert-error" role="alert">${tx('mfa.no_key')}</p>`}</form>`;
|
|
100
|
+
const main = html`<h1 class="u-mb1">${tx('mfa.title')}</h1>
|
|
101
|
+
<p class="muted u-mt0">${tx('mfa.intro')}</p>
|
|
102
|
+
${required && !factors.length ? html`<div class="alert alert-warning" role="alert">${tx('mfa.required_you')}</div>` : ''}
|
|
103
|
+
${shownCodes
|
|
104
|
+
? region(tx('mfa.codes_title'), html`<p>${tx('mfa.codes_once')}</p>
|
|
105
|
+
<ol class="mfa-codes">${shownCodes.map((c) => html`<li><code>${c}</code></li>`)}</ol>
|
|
106
|
+
<form method="post" action="${BASE}/security/codes/confirm">${csrf(s)}
|
|
107
|
+
<label class="check"><input type="checkbox" name="saved" value="1" required> ${tx('mfa.codes_saved')}</label>
|
|
108
|
+
<div class="buttons"><button class="btn btn-hot">${tx('mfa.codes_done')}</button></div></form>`, '', 'codes')
|
|
109
|
+
: ''}
|
|
110
|
+
<div class="columns">
|
|
111
|
+
${region(tx('mfa.factors'), html`${factors.length
|
|
112
|
+
? 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>
|
|
113
|
+
${factors.map((f) => html`<tr><td data-label="${tx('mfa.app_name')}">${icon(f.kind === 'passkey' ? 'fingerprint' : 'smartphone')} ${f.name}</td>
|
|
114
|
+
<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>
|
|
115
|
+
<td data-label="${tx('mfa.added')}">${when(f.created_at)}</td><td data-label="${tx('mfa.last_used')}">${when(f.last_used_at)}</td>
|
|
116
|
+
<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)}
|
|
117
|
+
${input(`password_${f.id}`, tx('mfa.your_password'), '', { type: 'password', required: true, auto: 'current-password' })}
|
|
118
|
+
<button class="btn btn-sm btn-danger">${tx(f.kind === 'passkey' ? 'mfa.remove_passkey' : 'mfa.remove_app')}</button></form></div></details></td></tr>`)}
|
|
119
|
+
</tbody></table></div>`
|
|
120
|
+
: html`<p class="muted">${tx('mfa.no_apps')}</p>`}
|
|
121
|
+
<h3>${tx('mfa.kind_passkey')}</h3>
|
|
122
|
+
<p class="muted small u-mt0">${tx('mfa.passkey_intro')}</p>
|
|
123
|
+
<form method="post" action="${BASE}/security/passkey">${csrf(s)}
|
|
124
|
+
<div class="form-grid">${input('pk_name', tx('mfa.app_name'), tx('mfa.passkey_default'), { required: true })}</div>
|
|
125
|
+
<input type="hidden" name="clientDataJSON"><input type="hidden" name="attestationObject"><input type="hidden" name="transports">
|
|
126
|
+
${isIP(rp.rpId) ? html`<p class="alert alert-info" role="status">${tx('mfa.passkey_needs_host', { host: rp.rpId })}</p>`
|
|
127
|
+
: 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>`}
|
|
128
|
+
<p class="small" role="status" data-passkey-status></p>
|
|
129
|
+
<noscript><p class="muted small">${tx('mfa.passkey_needs_js')}</p></noscript>
|
|
130
|
+
</form>
|
|
131
|
+
<h3>${tx('mfa.kind_app')}</h3>
|
|
132
|
+
${setup}`)}
|
|
133
|
+
${region(tx('mfa.codes_title'), html`<p class="u-mt0">${factors.length || left ? tx('mfa.codes_left', { n: left }) : tx('mfa.codes_after')}</p>
|
|
134
|
+
${factors.length && left < 3 ? html`<p class="alert alert-warning" role="status">${tx('mfa.codes_low')}</p>` : ''}
|
|
135
|
+
${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>` : ''}
|
|
136
|
+
${days || trusted ? html`<h3>${tx('mfa.trusted_title')}</h3>
|
|
137
|
+
<p class="u-mt0">${days ? tx('mfa.trusted_count', { n: trusted, days }) : tx('mfa.trusted_off')}</p>
|
|
138
|
+
${trusted ? html`<form method="post" action="${BASE}/security/forget-browsers">${csrf(s)}<button class="btn">${tx('mfa.forget_browsers')}</button></form>` : ''}` : ''}`)}
|
|
139
|
+
</div>`;
|
|
140
|
+
return send(reply, s, shell(s, tx('mfa.title'), [[tx('mfa.title')]], main, 'developers'));
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
app.post(`${BASE}/security/totp/start`, async (req: Req, reply) => {
|
|
144
|
+
const s = await developer(req, reply);
|
|
145
|
+
if (!s) return;
|
|
146
|
+
if (!secretKeyConfigured()) return back(reply, s, `${BASE}/security`);
|
|
147
|
+
s.state.__TOTP_NEW = JSON.stringify({ secret: newTotpSecret(), at: Date.now() });
|
|
148
|
+
return back(reply, s, `${BASE}/security`);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
app.post(`${BASE}/security/totp/cancel`, async (req: Req, reply) => {
|
|
152
|
+
const s = await developer(req, reply);
|
|
153
|
+
if (!s) return;
|
|
154
|
+
delete s.state.__TOTP_NEW;
|
|
155
|
+
return back(reply, s, `${BASE}/security`);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
app.post(`${BASE}/security/totp/confirm`, async (req: Req, reply) => {
|
|
159
|
+
const s = await developer(req, reply);
|
|
160
|
+
if (!s) return;
|
|
161
|
+
const tx = tr(s);
|
|
162
|
+
let pending: { secret: string; at: number } | null = null;
|
|
163
|
+
try {
|
|
164
|
+
pending = JSON.parse(String(s.state.__TOTP_NEW ?? ''));
|
|
165
|
+
} catch {
|
|
166
|
+
pending = null;
|
|
167
|
+
}
|
|
168
|
+
if (!pending || Date.now() - pending.at > SECRET_MINUTES * 60_000) {
|
|
169
|
+
delete s.state.__TOTP_NEW;
|
|
170
|
+
flash(s, tx('mfa.setup_expired'), 'error');
|
|
171
|
+
return back(reply, s, `${BASE}/security`);
|
|
172
|
+
}
|
|
173
|
+
const step = matchTotp(unbase32(pending.secret), String(req.body?.code ?? '').replace(/\s/g, ''), null);
|
|
174
|
+
if (step === null) {
|
|
175
|
+
flash(s, tx('mfa.wrong_setup'), 'error');
|
|
176
|
+
return back(reply, s, `${BASE}/security`);
|
|
177
|
+
}
|
|
178
|
+
const first = !(await hasFactor(s.username!));
|
|
179
|
+
await addTotp(s.username!, String(req.body?.name ?? ''), pending.secret, step);
|
|
180
|
+
delete s.state.__TOTP_NEW;
|
|
181
|
+
delete s.state.__MFA_DUE;
|
|
182
|
+
await logActivity({ username: s.username, event: '2fa_enrolled', ip: clientIp(req), detail: 'authenticator app' });
|
|
183
|
+
// the first app: recovery codes at once (they count only once the developer saved them)
|
|
184
|
+
if (first || !(await codesLeft(s.username!))) s.state.__CODES_NEW = JSON.stringify(await newRecoveryCodes(s.username!));
|
|
185
|
+
flash(s, tx('mfa.app_added'));
|
|
186
|
+
return back(reply, s, `${BASE}/security#codes`);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
app.post(`${BASE}/security/codes`, async (req: Req, reply) => {
|
|
190
|
+
const s = await developer(req, reply);
|
|
191
|
+
if (!s) return;
|
|
192
|
+
if (!(await hasFactor(s.username!))) return back(reply, s, `${BASE}/security`);
|
|
193
|
+
s.state.__CODES_NEW = JSON.stringify(await newRecoveryCodes(s.username!));
|
|
194
|
+
await logActivity({ username: s.username, event: '2fa_codes', ip: clientIp(req), detail: 'new recovery codes' });
|
|
195
|
+
return back(reply, s, `${BASE}/security#codes`);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
app.post(`${BASE}/security/codes/confirm`, async (req: Req, reply) => {
|
|
199
|
+
const s = await developer(req, reply);
|
|
200
|
+
if (!s) return;
|
|
201
|
+
if (req.body?.saved !== '1') return back(reply, s, `${BASE}/security#codes`);
|
|
202
|
+
await confirmRecoveryCodes(s.username!);
|
|
203
|
+
delete s.state.__CODES_NEW;
|
|
204
|
+
flash(s, tr(s)('mfa.codes_kept'));
|
|
205
|
+
return back(reply, s, `${BASE}/security`);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
app.post(`${BASE}/security/factor/:id(^\\d{1,9}$)/delete`, async (req: Req, reply) => {
|
|
209
|
+
const s = await developer(req, reply);
|
|
210
|
+
if (!s) return;
|
|
211
|
+
const tx = tr(s);
|
|
212
|
+
const me = s.username!;
|
|
213
|
+
const id = Number(req.params.id);
|
|
214
|
+
const f = await owner.one<{ kind: string }>('select id, kind from meta.developer_factor where id = $1 and username = $2', [id, me]);
|
|
215
|
+
if (!f) return reply.code(404).send('Not found');
|
|
216
|
+
if (!(await verifyPassword(me, String(req.body?.[`password_${id}`] ?? '')))) {
|
|
217
|
+
await logActivity({ username: me, event: 'login_failed', ip: clientIp(req), detail: 'builder (removing a second factor)' });
|
|
218
|
+
flash(s, tx('mfa.wrong_password'), 'error');
|
|
219
|
+
return back(reply, s, `${BASE}/security`);
|
|
220
|
+
}
|
|
221
|
+
const last = (await factorsOf(me)).length === 1;
|
|
222
|
+
if (last) {
|
|
223
|
+
const pol = await mfaPolicy();
|
|
224
|
+
const admin = await isAdmin(me);
|
|
225
|
+
if (requiredFor(pol.policy, admin)) {
|
|
226
|
+
flash(s, tx('mfa.cannot_remove_required'), 'error');
|
|
227
|
+
return back(reply, s, `${BASE}/security`);
|
|
228
|
+
}
|
|
229
|
+
// (SEC-6) never fewer administrators with a factor than the policy needs
|
|
230
|
+
if (admin && pol.policy !== 'off') {
|
|
231
|
+
const { admins, enrolled } = await adminFactors();
|
|
232
|
+
if (enrolled - 1 < Math.min(2, admins)) {
|
|
233
|
+
flash(s, tx('mfa.cannot_remove_admin'), 'error');
|
|
234
|
+
return back(reply, s, `${BASE}/security`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
await owner.query('delete from meta.developer_factor where id = $1 and username = $2', [id, me]);
|
|
239
|
+
if (last) await owner.query('delete from meta.developer_recovery_code where username = $1', [me]);
|
|
240
|
+
// a lost phone or key: browsers trusted with it must ask again
|
|
241
|
+
await forgetBrowsers(me);
|
|
242
|
+
const what = f.kind === 'passkey' ? 'a passkey' : 'an authenticator app';
|
|
243
|
+
await logActivity({ username: me, event: '2fa_removed', ip: clientIp(req), detail: last ? `${what}, the last factor (recovery codes removed)` : what });
|
|
244
|
+
flash(s, tx('mfa.removed'));
|
|
245
|
+
return back(reply, s, `${BASE}/security`);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// (sprint 127) a passkey: the options for navigator.credentials.create(), then the browser's answer
|
|
249
|
+
app.post(`${BASE}/security/passkey/options`, async (req: Req, reply) => {
|
|
250
|
+
const s = await developer(req, reply);
|
|
251
|
+
if (!s) return;
|
|
252
|
+
const me = s.username!;
|
|
253
|
+
const rp = relyingParty(req);
|
|
254
|
+
const challenge = giveChallenge(s, 'create');
|
|
255
|
+
await saveState(s);
|
|
256
|
+
reply.header('cache-control', 'no-store');
|
|
257
|
+
return reply.send({
|
|
258
|
+
mode: 'create', challenge, rp: { id: rp.rpId, name: 'pgkiln' }, algs: ALGORITHMS,
|
|
259
|
+
// a stable handle that isn't the user name (the authenticator may show it, but it says nothing)
|
|
260
|
+
user: { id: b64url(createHash('sha256').update(`pgkiln-developer:${me}`).digest().subarray(0, 16)), name: me, displayName: me },
|
|
261
|
+
exclude: (await passkeysOf(me, rp.rpId)).map((p) => p.credential_id),
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
app.post(`${BASE}/security/passkey`, async (req: Req, reply) => {
|
|
266
|
+
const s = await developer(req, reply);
|
|
267
|
+
if (!s) return;
|
|
268
|
+
const tx = tr(s);
|
|
269
|
+
const me = s.username!;
|
|
270
|
+
const challenge = takeChallenge(s, 'create');
|
|
271
|
+
if (!challenge) {
|
|
272
|
+
flash(s, tx('mfa.setup_expired'), 'error');
|
|
273
|
+
return back(reply, s, `${BASE}/security`);
|
|
274
|
+
}
|
|
275
|
+
const rp = relyingParty(req);
|
|
276
|
+
let reg;
|
|
277
|
+
try {
|
|
278
|
+
reg = verifyRegistration({ clientDataJSON: String(req.body?.clientDataJSON ?? ''), attestationObject: String(req.body?.attestationObject ?? '') }, { challenge, ...rp });
|
|
279
|
+
} catch (e) {
|
|
280
|
+
await logActivity({ username: me, event: '2fa_refused', ip: clientIp(req), detail: `passkey: ${e instanceof WebAuthnError ? e.message : 'unreadable answer'}` });
|
|
281
|
+
flash(s, tx('mfa.passkey_failed'), 'error');
|
|
282
|
+
return back(reply, s, `${BASE}/security`);
|
|
283
|
+
}
|
|
284
|
+
const first = !(await hasFactor(me));
|
|
285
|
+
try {
|
|
286
|
+
await addPasskey(me, String(req.body?.pk_name ?? ''), { ...reg, rpId: rp.rpId, transports: String(req.body?.transports ?? '').split(',').filter(Boolean) });
|
|
287
|
+
} catch {
|
|
288
|
+
flash(s, tx('mfa.passkey_known'), 'error');
|
|
289
|
+
return back(reply, s, `${BASE}/security`);
|
|
290
|
+
}
|
|
291
|
+
delete s.state.__MFA_DUE;
|
|
292
|
+
await logActivity({ username: me, event: '2fa_enrolled', ip: clientIp(req), detail: `passkey (${rp.rpId})` });
|
|
293
|
+
if (first || !(await codesLeft(me))) s.state.__CODES_NEW = JSON.stringify(await newRecoveryCodes(me));
|
|
294
|
+
flash(s, tx('mfa.passkey_added'));
|
|
295
|
+
return back(reply, s, `${BASE}/security${s.state.__CODES_NEW ? '#codes' : ''}`);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
app.post(`${BASE}/security/forget-browsers`, async (req: Req, reply) => {
|
|
299
|
+
const s = await developer(req, reply);
|
|
300
|
+
if (!s) return;
|
|
301
|
+
await forgetBrowsers(s.username!);
|
|
302
|
+
await logActivity({ username: s.username, event: '2fa_forget_browsers', ip: clientIp(req) });
|
|
303
|
+
flash(s, tr(s)('mfa.browsers_forgotten'));
|
|
304
|
+
return back(reply, s, `${BASE}/security`);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
// (SEC-6) an administrator resets another developer's second factors: logged, the developer and the administrators told
|
|
308
|
+
app.post(`${BASE}/developers/reset-2fa`, async (req: Req, reply) => {
|
|
309
|
+
const s = await developer(req, reply);
|
|
310
|
+
if (!s) return;
|
|
311
|
+
const tx = tr(s);
|
|
312
|
+
if (!(await isAdmin(s.username))) return reply.code(403).send('Only administrators reset second factors.');
|
|
313
|
+
const who = String(req.body?.username ?? '');
|
|
314
|
+
const reason = String(req.body?.[`reason_${who.replace(/[^\w]/g, '_')}`] ?? req.body?.reason ?? '').trim().slice(0, 200);
|
|
315
|
+
if (who === s.username) return reply.code(400).send('Use your own Security page.');
|
|
316
|
+
if (!reason) {
|
|
317
|
+
flash(s, tx('mfa.reason_needed'), 'error');
|
|
318
|
+
return back(reply, s, `${BASE}/developers`);
|
|
319
|
+
}
|
|
320
|
+
if (!(await resetFactors(who))) return reply.code(404).send('Not found');
|
|
321
|
+
await logActivity({ username: s.username, event: '2fa_reset', ip: clientIp(req), detail: `${who}: ${reason}` });
|
|
322
|
+
const dev = await owner.one<{ email: string | null }>('select email from meta.developer where username = $1', [who]);
|
|
323
|
+
await notifyMfa([dev?.email ?? '', ...(await adminEmails())], `pgkiln: two-factor sign-in of ${who} reset`,
|
|
324
|
+
`${s.username} removed the second factors of developer ${who} (reason: ${reason}). ${who} signs in with the password only until they set up an authenticator app again.`);
|
|
325
|
+
flash(s, tx('mfa.reset_done', { user: who }));
|
|
326
|
+
return back(reply, s, `${BASE}/developers`);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// (SEC-2, SEC-6) the instance's policy: off, administrators or all developers, with a grace period
|
|
330
|
+
app.post(`${BASE}/instance/mfa`, async (req: Req, reply) => {
|
|
331
|
+
const s = await developer(req, reply);
|
|
332
|
+
if (!s) return;
|
|
333
|
+
const tx = tr(s);
|
|
334
|
+
if (!(await isAdmin(s.username))) return reply.code(403).send('Only administrators change the policy.');
|
|
335
|
+
const policy = MFA_POLICIES.includes(req.body?.policy as MfaPolicy) ? (req.body!.policy as MfaPolicy) : null;
|
|
336
|
+
const grace = Number(req.body?.grace_days);
|
|
337
|
+
const trust = Number(req.body?.trust_days ?? 0);
|
|
338
|
+
if (!policy || !Number.isInteger(grace) || grace < 0 || grace > 90 || !Number.isInteger(trust) || trust < 0 || trust > 90)
|
|
339
|
+
return reply.code(400).send('policy is off, admins or all; grace_days and trust_days 0 to 90');
|
|
340
|
+
if (await policyProblem(policy)) {
|
|
341
|
+
flash(s, tx('mfa.policy_needs_admins'), 'error');
|
|
342
|
+
return back(reply, s, `${BASE}/instance#mfa`);
|
|
343
|
+
}
|
|
344
|
+
await setPolicy(policy, grace);
|
|
345
|
+
await setTrustDays(trust);
|
|
346
|
+
await logActivity({ username: s.username, event: '2fa_policy', ip: clientIp(req), detail: `${policy}, grace ${grace} days, trust browsers ${trust} days` });
|
|
347
|
+
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).`);
|
|
348
|
+
flash(s, tx('mfa.policy_saved'));
|
|
349
|
+
return back(reply, s, `${BASE}/instance#mfa`);
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** The Instance settings region for the policy (administrators). */
|
|
354
|
+
export async function policyRegion(s: Session): Promise<Raw> {
|
|
355
|
+
const tx = tr(s);
|
|
356
|
+
const [pol, af, days, without] = await Promise.all([
|
|
357
|
+
mfaPolicy(), adminFactors(), trustDays(),
|
|
358
|
+
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'),
|
|
359
|
+
]);
|
|
360
|
+
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>`;
|
|
361
|
+
return region(tx('mfa.policy_title'), html`<form method="post" action="${BASE}/instance/mfa">${csrf(s)}
|
|
362
|
+
<div class="form-grid">
|
|
363
|
+
<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>
|
|
364
|
+
${input('grace_days', tx('mfa.grace'), pol.graceDays, { type: 'number', help: tx('mfa.grace_help') })}
|
|
365
|
+
${input('trust_days', tx('mfa.trust_days'), days, { type: 'number', help: tx('mfa.trust_days_help') })}
|
|
366
|
+
</div>
|
|
367
|
+
<p class="muted small">${tx('mfa.policy_help', { enrolled: af.enrolled, admins: af.admins })}</p>
|
|
368
|
+
<div class="buttons"><button class="btn btn-hot">${tx('mfa.policy_save')}</button></div></form>
|
|
369
|
+
${without.rows.length ? html`<p class="small">${tx('mfa.without', { list: without.rows.map((d) => `${d.username}${d.is_admin ? ' (admin)' : ''}`).join(', ') })}</p>` : ''}
|
|
370
|
+
<p class="muted small">${tx('mfa.cli_note')}</p>`, '', 'mfa');
|
|
371
|
+
}
|
package/src/builder/ui.ts
CHANGED
|
@@ -12,6 +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
16
|
|
|
16
17
|
// Shared building blocks of the builder UI.
|
|
17
18
|
|
|
@@ -112,6 +113,7 @@ export function shell(s: Session, title: string, crumbs: [string, string?][], ma
|
|
|
112
113
|
// (sprint 74) the default-password warning: on the Workspace home (sprint 109), the App Builder home and the Developers page only, until
|
|
113
114
|
// dismissed (for this sign-in); a dot on the avatar stays until the password is changed
|
|
114
115
|
const weak = !!s.state.__WEAK;
|
|
116
|
+
const mfaDue = typeof s.state.__MFA_DUE === 'string' ? s.state.__MFA_DUE : '';
|
|
115
117
|
const weakBanner = weak && !s.state.__WEAK_HIDDEN && crumbs.length === 1 && (crumbs[0][0] === 'Home' || crumbs[0][0] === 'App Builder' || crumbs[0][0] === 'Developers');
|
|
116
118
|
const trail = crumbs.map(([label, href]): [string, string?] => [tx.label(label), href]);
|
|
117
119
|
const back = [...trail].reverse().find(([, href], i) => i > 0 && href);
|
|
@@ -175,6 +177,7 @@ export function shell(s: Session, title: string, crumbs: [string, string?][], ma
|
|
|
175
177
|
</form></div>
|
|
176
178
|
<div class="menu-section menu-links">
|
|
177
179
|
<a href="${BASE}/developers">${icon('key')} ${tx('user.change_password')}</a>
|
|
180
|
+
<a href="${BASE}/security">${icon('lock')} ${tx('mfa.menu')}</a>
|
|
178
181
|
<form method="post" action="${BASE}/logout">${csrf(s)}<button>${icon('logout')} ${tx('user.sign_out')}</button></form>
|
|
179
182
|
</div>
|
|
180
183
|
</div></details></li>
|
|
@@ -191,7 +194,8 @@ export function shell(s: Session, title: string, crumbs: [string, string?][], ma
|
|
|
191
194
|
<div class="ide-tools">${opts.toolbar ?? ''}</div>
|
|
192
195
|
</header>
|
|
193
196
|
${s.workspace?.banner_label ? html`<div class="ide-banner ide-banner-${BANNER_COLORS.includes(s.workspace.banner_color ?? '') ? s.workspace.banner_color : 'blue'}" role="note" aria-label="${tx('bar.environment')}">${s.workspace.banner_label}</div>` : ''}
|
|
194
|
-
${weakBanner || ok || err ? html`<div class="ide-alerts">
|
|
197
|
+
${weakBanner || mfaDue || ok || err ? html`<div class="ide-alerts">
|
|
198
|
+
${mfaDue ? html`<div class="alert alert-warning" role="status">${tx.rich('mfa.banner', { date: mfaDue.slice(0, 10), link: html`<a href="${BASE}/security">${tx('mfa.set_up_now')}</a>` })}</div>` : ''}
|
|
195
199
|
${weakBanner ? html`<div class="alert alert-error alert-dismissible" role="alert"><span>${tx.rich('weak.banner', { link: html`<a href="${BASE}/developers">${tx('weak.change_now')}</a>` })}</span>
|
|
196
200
|
<form method="post" action="${BASE}/weak/dismiss">${csrf(s)}<button class="link-button" aria-label="${tx('weak.dismiss')}" title="${tx('weak.dismiss')}">${icon('close')}</button></form></div>` : ''}
|
|
197
201
|
${ok ? html`<div class="alert alert-success" role="status">${ok}</div>` : ''}
|
|
@@ -317,6 +321,14 @@ export async function developer(req: Req, reply: FastifyReply) {
|
|
|
317
321
|
}
|
|
318
322
|
// (sprint 121) a session from before the developer's language was kept in it: the stored choice (language.ts)
|
|
319
323
|
if (s.state.__BLANG === undefined) await loadLanguage(s);
|
|
324
|
+
// (sprint 126, SEC-2) the two-factor policy: a banner during the grace period, then only the Security page until they enrol
|
|
325
|
+
delete s.state.__MFA_DUE;
|
|
326
|
+
const due = await enrolmentDue(s.username, await isAdmin(s.username));
|
|
327
|
+
if (due?.due === 'grace') s.state.__MFA_DUE = due.until.toISOString();
|
|
328
|
+
else if (due && !/^\/builder\/(security|logout|language|theme)(?:[/?]|$)/.test(req.url)) {
|
|
329
|
+
reply.redirect(`${BASE}/security`);
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
320
332
|
// (079) the developer toolbar on the applications' pages in this browser
|
|
321
333
|
await ensureDevbar(req, reply, s);
|
|
322
334
|
// workspaces: an application outside the developer's workspaces doesn't exist for them
|
package/src/cli/main.ts
CHANGED
|
@@ -469,6 +469,57 @@ const COMMANDS: Record<string, Command> = {
|
|
|
469
469
|
});
|
|
470
470
|
},
|
|
471
471
|
},
|
|
472
|
+
// (sprint 126, SEC-6) lockout recovery on the server, when nobody can sign in to the builder
|
|
473
|
+
admin: {
|
|
474
|
+
usage: 'pgkiln admin reset-2fa <developer> | set-2fa-policy off|admins|all [options]',
|
|
475
|
+
summary: 'lockout recovery: reset a developer\'s two-factor sign-in, set the two-factor policy',
|
|
476
|
+
details:
|
|
477
|
+
'pgkiln admin reset-2fa <developer> [--reason <text>]\n' +
|
|
478
|
+
'pgkiln admin set-2fa-policy off|admins|all [--grace <days>]\n\n' +
|
|
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;\n' +
|
|
481
|
+
'admins and all need at least two administrators with two-factor sign-in (or every one, when there\n' +
|
|
482
|
+
'are fewer). Both are written to the activity log and mailed to the administrators.',
|
|
483
|
+
options: { reason: { type: 'string' }, grace: { type: 'string' } },
|
|
484
|
+
optionHelp: [
|
|
485
|
+
['--reason <text>', 'reset-2fa: why (in the activity log and the mail)'],
|
|
486
|
+
['--grace <days>', 'set-2fa-policy: days developers have to set up an app (0 to 90, default 7)'],
|
|
487
|
+
],
|
|
488
|
+
positionals: [2, 2],
|
|
489
|
+
async run(v, [action, arg]) {
|
|
490
|
+
process.env.RUNTIME_DATABASE_URL ??= process.env.DATABASE_URL; // the CLI runs as the owner
|
|
491
|
+
const mfa = await import('../mfa.ts');
|
|
492
|
+
const { logActivity } = await import('../session.ts');
|
|
493
|
+
const { closePools } = await import('../db.ts');
|
|
494
|
+
const by = `pgkiln admin (${userInfo().username})`;
|
|
495
|
+
try {
|
|
496
|
+
if (action === 'reset-2fa') {
|
|
497
|
+
const reason = String(v.reason ?? 'command line').slice(0, 200);
|
|
498
|
+
if (!(await mfa.resetFactors(arg))) throw new Error(`no developer ${arg}`);
|
|
499
|
+
await logActivity({ username: arg, event: '2fa_reset', ip: 'cli', detail: `${arg}: ${reason} (${by})` });
|
|
500
|
+
const sent = await mfa.notifyMfa(await mfa.adminEmails(), `pgkiln: two-factor sign-in of ${arg} reset`,
|
|
501
|
+
`${by} removed the second factors of developer ${arg} on the server (reason: ${reason}).`);
|
|
502
|
+
out(`Two-factor sign-in of ${arg} reset; their builder sessions ended.${sent ? ` ${sent} administrator(s) told by mail.` : ''}\n`);
|
|
503
|
+
return EXIT.ok;
|
|
504
|
+
}
|
|
505
|
+
if (action === 'set-2fa-policy') {
|
|
506
|
+
if (!mfa.MFA_POLICIES.includes(arg as never)) throw new UsageError('set-2fa-policy takes off, admins or all');
|
|
507
|
+
const grace = v.grace === undefined ? (await mfa.mfaPolicy()).graceDays : Number(v.grace);
|
|
508
|
+
if (!Number.isInteger(grace) || grace < 0 || grace > 90) throw new UsageError('--grace takes 0 to 90 days');
|
|
509
|
+
const policy = arg as 'off' | 'admins' | 'all';
|
|
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
|
+
await mfa.setPolicy(policy, grace);
|
|
512
|
+
await logActivity({ event: '2fa_policy', ip: 'cli', detail: `${policy}, grace ${grace} days (${by})` });
|
|
513
|
+
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`);
|
|
515
|
+
return EXIT.ok;
|
|
516
|
+
}
|
|
517
|
+
throw new UsageError(`unknown action ${action}: use reset-2fa or set-2fa-policy`);
|
|
518
|
+
} finally {
|
|
519
|
+
await closePools();
|
|
520
|
+
}
|
|
521
|
+
},
|
|
522
|
+
},
|
|
472
523
|
};
|
|
473
524
|
|
|
474
525
|
// ------------------------------------------------------------------ help and dispatch
|
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' },
|