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.
@@ -19,6 +19,7 @@ import { appOr404 } from './forms.ts';
19
19
  import { docToFiles, filesToZip } from '../appfiles.ts';
20
20
  import { homeRoutes, rememberApp } from './home.ts';
21
21
  import { currentWorkspace, placeApp, workshopDb, workspaceRoutes } from './workspaces.ts';
22
+ import { securityRoutes } from './security.ts';
22
23
  import { instanceRoutes } from './instance.ts';
23
24
  import { saveTimeZoneSettings, timeZoneSettings } from './globalization.ts';
24
25
  import { WIZARD_KINDS, wizardRoutes, wizardTables } from './wizards.ts';
@@ -26,6 +27,9 @@ import { appFromFileRoutes } from './appfromfile.ts';
26
27
  import { clearDevbar } from '../runtime/devbar.ts';
27
28
  import { EMAIL } from './find-workspace.ts';
28
29
  import { cookieLanguage, LANG_COOKIE, tr, translatorFor, validBuilderLanguage } from './i18n.ts';
30
+ import { checkSecondFactor, codesLeft, forgetBrowsers, hasFactor, isTrustedBrowser, leavesTooFewAdmins, passkeysOf, trustBrowser, trustDays, usePasskey } from '../mfa.ts';
31
+ import { giveChallenge, takeChallenge } from './security.ts';
32
+ import { relyingParty, verifyAssertion, WebAuthnError } from '../webauthn.ts';
29
33
  import { chooseLanguage, langCookieOptions, languageRoutes, signInLanguageLinks } from './language.ts';
30
34
 
31
35
  // Builder pages: sign-in, workspace and app home, settings, activity and
@@ -148,27 +152,197 @@ export async function builderRoutes(app: FastifyInstance) {
148
152
  flash(s, tx(workspace ? 'signin.invalid_ws' : 'signin.invalid'), 'error');
149
153
  return back(reply, s, again);
150
154
  }
155
+ // (sprint 126, SEC-2) a developer with a second factor: the password was right, the session comes after the code;
156
+ // (sprint 127) unless this browser was trusted after a second factor, for the instance's number of days
157
+ if ((await hasFactor(dev.username)) && !(await isTrustedBrowser(dev.username, req.cookies?.[TRUST_COOKIE]))) {
158
+ s.state.__MFA = JSON.stringify({ u: dev.username, ws: ws ? String(ws.id) : null, w: workspace, next, remember: req.body?.remember === '1', weak: password === 'admin' || password === dev.username, at: Date.now() });
159
+ await saveState(s);
160
+ return reply.redirect(`${BASE}/login/2fa`, 303);
161
+ }
162
+ return finishSignIn(req, reply, s, { username: dev.username, language: dev.language }, { ws: ws ? String(ws.id) : null, workspace, next, remember: req.body?.remember === '1', weak: password === 'admin' || password === dev.username, picked, detail: 'builder' });
163
+ });
164
+
165
+ /** The session after a successful sign-in (password, and the second factor when the developer has one). */
166
+ async function finishSignIn(req: Req, reply: Parameters<typeof back>[0], s: Awaited<ReturnType<typeof getSession>>, dev: { username: string; language: string | null },
167
+ o: { ws: string | null; workspace: string; next: string; remember: boolean; weak: boolean; picked: ReturnType<typeof validBuilderLanguage>; detail: string }) {
168
+ const ip = clientIp(req);
169
+ const { picked, next, workspace } = o;
151
170
  await destroySession(reply, s, BASE);
152
171
  const ns = await createSession(reply, null, BASE, dev.username);
153
172
  // the builder theme chosen earlier on this device
154
173
  const theme = validTheme(req.cookies?.[THEME_COOKIE]);
155
174
  if (theme) ns.state.__BTHEME = theme;
156
- if (ws) ns.state.__WS = String(ws.id);
175
+ if (o.ws) ns.state.__WS = o.ws;
157
176
  // (sprint 121) the builder language: a link chosen on the sign-in page is stored for the developer; else theirs
158
177
  if (picked) await chooseLanguage(reply, ns, picked);
159
178
  else {
160
179
  ns.state.__BLANG = validBuilderLanguage(dev.language) ?? 'en';
161
180
  if (dev.language && ns.state.__BLANG !== 'en') reply.setCookie(LANG_COOKIE, ns.state.__BLANG, langCookieOptions());
162
181
  }
163
- if (password === 'admin' || password === dev.username) ns.state.__WEAK = '1';
182
+ if (o.weak) ns.state.__WEAK = '1';
164
183
  await saveState(ns);
165
- if (req.body?.remember === '1')
184
+ if (o.remember)
166
185
  reply.setCookie(REMEMBER_COOKIE, Buffer.from(JSON.stringify({ w: workspace, u: dev.username })).toString('base64url'), {
167
186
  path: BASE, httpOnly: true, sameSite: 'lax', maxAge: 365 * 24 * 3600, secure: process.env.COOKIE_SECURE === 'true',
168
187
  });
169
188
  else reply.clearCookie(REMEMBER_COOKIE, { path: BASE });
170
- logActivity({ username: dev.username, event: 'login', ip, detail: 'builder' });
189
+ logActivity({ username: dev.username, event: 'login', ip, detail: o.detail });
171
190
  return reply.redirect(next, 303);
191
+ }
192
+
193
+ // (sprint 126, SEC-2) the second step: a code from the authenticator app, or a recovery code;
194
+ // (sprint 127) or a passkey, and "trust this browser" when the instance allows it
195
+ const MFA_SECONDS = 300;
196
+ const TRUST_COOKIE = 'pgkiln_btrust';
197
+ const trustThisBrowser = async (req: Req, reply: Parameters<typeof back>[0], username: string) => {
198
+ const days = await trustDays();
199
+ if (!days || req.body?.trust !== '1') return;
200
+ const token = await trustBrowser(username, days, String(req.headers['user-agent'] ?? ''));
201
+ reply.setCookie(TRUST_COOKIE, token, { path: BASE, httpOnly: true, sameSite: 'lax', maxAge: days * 86400, secure: process.env.COOKIE_SECURE === 'true' });
202
+ logActivity({ username, event: '2fa_trusted_browser', ip: clientIp(req), detail: `${days} days` });
203
+ };
204
+ const pendingMfa = (s: { state: Record<string, any> }) => {
205
+ try {
206
+ const p = JSON.parse(String(s.state.__MFA ?? ''));
207
+ return p && typeof p.u === 'string' && Date.now() - Number(p.at) < MFA_SECONDS * 1000 ? p as { u: string; ws: string | null; w: string; next: string; remember: boolean; weak: boolean } : null;
208
+ } catch {
209
+ return null;
210
+ }
211
+ };
212
+ app.get(`${BASE}/login/2fa`, async (req: Req, reply) => {
213
+ const s = await getSession(req, reply, null, BASE);
214
+ if (s.username) return reply.redirect(`${BASE}/home`);
215
+ const p = pendingMfa(s);
216
+ const tx = translatorFor(validBuilderLanguage(s.state.__BLANG) ?? cookieLanguage(req));
217
+ if (!p) {
218
+ delete s.state.__MFA;
219
+ flash(s, tx('mfa.expired'), 'error');
220
+ return back(reply, s, `${BASE}/login`);
221
+ }
222
+ const error = takeFlash(s, '__ERROR');
223
+ await saveState(s);
224
+ const [keys, days] = await Promise.all([passkeysOf(p.u, relyingParty(req).rpId), trustDays()]);
225
+ return reply.type('text/html').send(documentShell(
226
+ `${tx('mfa.step_title')} · ${tx('shell.builder')}`,
227
+ html`<main class="signin">
228
+ <section class="signin-hero" aria-label="pgkiln">
229
+ <span class="signin-logo" aria-hidden="true">${brandMark()}</span>
230
+ <h1 class="signin-title">pgkiln</h1>
231
+ <p class="signin-tagline">${tx('signin.tagline')}</p>
232
+ </section>
233
+ <section class="signin-panel">
234
+ <form method="post" class="signin-form">
235
+ <h2>${tx('mfa.step_title')}</h2>
236
+ <p class="muted u-mt0">${tx('mfa.step_intro', { user: p.u })}</p>
237
+ ${error ? html`<div class="alert alert-error" role="alert">${error}</div>` : ''}
238
+ ${csrf(s)}
239
+ ${keys.length ? html`<input type="hidden" name="credentialId"><input type="hidden" name="clientDataJSON"><input type="hidden" name="authenticatorData"><input type="hidden" name="signature">
240
+ <button class="btn btn-hot signin-submit" hidden data-passkey="${BASE}/login/2fa/passkey/options" data-passkey-submit="${BASE}/login/2fa/passkey" data-passkey-error="${tx('mfa.passkey_failed')}">${icon('fingerprint')} ${tx('mfa.use_passkey')}</button>
241
+ <p class="small" role="status" data-passkey-status></p>` : ''}
242
+ <div class="field signin-field"><label class="label" for="f_code">${tx('mfa.code')}</label>
243
+ <div class="signin-input">${icon('key')}<input id="f_code" name="code" required autocomplete="one-time-code" autofocus maxlength="20" spellcheck="false" autocapitalize="off"></div>
244
+ <small class="help">${tx('mfa.code_help')}</small></div>
245
+ ${days ? html`<label class="check"><input type="checkbox" name="trust" value="1"> ${tx('mfa.trust_browser', { days })}</label>` : ''}
246
+ <button class="btn${keys.length ? '' : ' btn-hot'} signin-submit">${tx('mfa.verify')}</button>
247
+ <p class="muted small signin-hint"><a href="${BASE}/login">${tx('mfa.cancel')}</a> · ${tx('mfa.locked_out')}</p>
248
+ </form>
249
+ </section>
250
+ </main>`,
251
+ 'login-body ide-login signin-body', {}, builderHead(),
252
+ { theme: validTheme(req.cookies?.[THEME_COOKIE]) ?? 'dark', lang: tx.lang },
253
+ ));
254
+ });
255
+ app.post(`${BASE}/login/2fa`, async (req: Req, reply) => {
256
+ const s = await getSession(req, reply, null, BASE);
257
+ const ip = clientIp(req);
258
+ const picked = validBuilderLanguage(s.state.__BLANG);
259
+ const tx = translatorFor(picked ?? cookieLanguage(req));
260
+ const p = pendingMfa(s);
261
+ if (req.body?.__csrf !== s.csrf_token || !p) {
262
+ delete s.state.__MFA;
263
+ flash(s, tx(p ? 'signin.expired' : 'mfa.expired'), 'error');
264
+ return back(reply, s, `${BASE}/login`);
265
+ }
266
+ if (await loginThrottled(null, p.u, ip)) {
267
+ logActivity({ username: p.u, event: 'login_locked', ip, detail: 'builder 2fa' });
268
+ delete s.state.__MFA;
269
+ flash(s, tx('signin.throttled'), 'error');
270
+ return back(reply, s, `${BASE}/login`);
271
+ }
272
+ const ok = await checkSecondFactor(p.u, String(req.body?.code ?? '').slice(0, 40));
273
+ if (!ok) {
274
+ // counts with the failed passwords (lockout after the same number of attempts)
275
+ await logActivity({ username: p.u, event: 'login_failed', ip, detail: 'builder 2fa' });
276
+ flash(s, tx('mfa.wrong'), 'error');
277
+ return back(reply, s, `${BASE}/login/2fa`);
278
+ }
279
+ const dev = await owner.one<{ username: string; language: string | null }>('select username, language from meta.developer where username = $1', [p.u]);
280
+ if (!dev) return back(reply, s, `${BASE}/login`);
281
+ if (ok.kind === 'recovery') {
282
+ const left = await codesLeft(p.u);
283
+ await logActivity({ username: p.u, event: '2fa_recovery_used', ip, detail: `${left} left` });
284
+ // to the Security page: one code fewer, maybe time for new ones
285
+ await trustThisBrowser(req, reply, p.u);
286
+ return finishSignIn(req, reply, s, dev, { ws: p.ws, workspace: p.w, next: `${BASE}/security`, remember: p.remember, weak: p.weak, picked, detail: 'builder 2fa (recovery code)' });
287
+ }
288
+ await trustThisBrowser(req, reply, p.u);
289
+ return finishSignIn(req, reply, s, dev, { ws: p.ws, workspace: p.w, next: p.next, remember: p.remember, weak: p.weak, picked, detail: 'builder 2fa' });
290
+ });
291
+
292
+ // (sprint 127) the second step with a passkey: options for navigator.credentials.get(), then the browser's answer
293
+ app.post(`${BASE}/login/2fa/passkey/options`, async (req: Req, reply) => {
294
+ const s = await getSession(req, reply, null, BASE);
295
+ const p = pendingMfa(s);
296
+ if (req.body?.__csrf !== s.csrf_token || !p) return reply.code(403).send('Sign in again.');
297
+ const rp = relyingParty(req);
298
+ const keys = await passkeysOf(p.u, rp.rpId);
299
+ if (!keys.length) return reply.code(404).send('No passkey for this site.');
300
+ const challenge = giveChallenge(s as Parameters<typeof giveChallenge>[0], 'get');
301
+ await saveState(s);
302
+ reply.header('cache-control', 'no-store');
303
+ return reply.send({ mode: 'get', challenge, rpId: rp.rpId, allow: keys.map((k) => ({ id: k.credential_id, transports: k.transports })) });
304
+ });
305
+ app.post(`${BASE}/login/2fa/passkey`, async (req: Req, reply) => {
306
+ const s = await getSession(req, reply, null, BASE);
307
+ const ip = clientIp(req);
308
+ const picked = validBuilderLanguage(s.state.__BLANG);
309
+ const tx = translatorFor(picked ?? cookieLanguage(req));
310
+ const p = pendingMfa(s);
311
+ const challenge = takeChallenge(s as Parameters<typeof takeChallenge>[0], 'get');
312
+ if (req.body?.__csrf !== s.csrf_token || !p) {
313
+ delete s.state.__MFA;
314
+ flash(s, tx(p ? 'signin.expired' : 'mfa.expired'), 'error');
315
+ return back(reply, s, `${BASE}/login`);
316
+ }
317
+ if (await loginThrottled(null, p.u, ip)) {
318
+ logActivity({ username: p.u, event: 'login_locked', ip, detail: 'builder 2fa' });
319
+ delete s.state.__MFA;
320
+ flash(s, tx('signin.throttled'), 'error');
321
+ return back(reply, s, `${BASE}/login`);
322
+ }
323
+ const rp = relyingParty(req);
324
+ const key = (await passkeysOf(p.u, rp.rpId)).find((k) => k.credential_id === String(req.body?.credentialId ?? ''));
325
+ let why = 'unknown passkey';
326
+ if (key && challenge) {
327
+ try {
328
+ const r = verifyAssertion(
329
+ { clientDataJSON: String(req.body?.clientDataJSON ?? ''), authenticatorData: String(req.body?.authenticatorData ?? ''), signature: String(req.body?.signature ?? '') },
330
+ { alg: key.alg, jwk: key.public_key, signCount: Number(key.sign_count) }, { challenge, ...rp });
331
+ if (await usePasskey(key.id, key.sign_count, r.signCount)) why = '';
332
+ else why = 'used at the same time elsewhere';
333
+ } catch (e) {
334
+ why = e instanceof WebAuthnError ? e.message : 'unreadable answer';
335
+ }
336
+ } else if (!challenge) why = 'no or an expired challenge';
337
+ if (why) {
338
+ await logActivity({ username: p.u, event: 'login_failed', ip, detail: `builder 2fa passkey: ${why}` });
339
+ flash(s, tx('mfa.passkey_failed'), 'error');
340
+ return back(reply, s, `${BASE}/login/2fa`);
341
+ }
342
+ const dev = await owner.one<{ username: string; language: string | null }>('select username, language from meta.developer where username = $1', [p.u]);
343
+ if (!dev) return back(reply, s, `${BASE}/login`);
344
+ await trustThisBrowser(req, reply, p.u);
345
+ return finishSignIn(req, reply, s, dev, { ws: p.ws, workspace: p.w, next: p.next, remember: p.remember, weak: p.weak, picked, detail: 'builder 2fa (passkey)' });
172
346
  });
173
347
 
174
348
  app.post(`${BASE}/logout`, async (req: Req, reply) => {
@@ -205,6 +379,7 @@ export async function builderRoutes(app: FastifyInstance) {
205
379
  await languageRoutes(app);
206
380
  await workspaceRoutes(app);
207
381
  await instanceRoutes(app);
382
+ await securityRoutes(app);
208
383
  await wizardRoutes(app);
209
384
  await appFromFileRoutes(app);
210
385
 
@@ -686,7 +861,8 @@ export async function builderRoutes(app: FastifyInstance) {
686
861
  app.get(`${BASE}/developers`, async (req: Req, reply) => {
687
862
  const s = await developer(req, reply);
688
863
  if (!s) return;
689
- const devs = (await owner.query('select username, is_admin, email from meta.developer order by 1')).rows;
864
+ const devs = (await owner.query(`select username, is_admin, email, exists (select 1 from meta.developer_factor f where f.username = d.username) as mfa
865
+ from meta.developer d order by 1`)).rows;
690
866
  const admin = devs.some((d) => d.username === s.username && d.is_admin);
691
867
  const me = devs.find((d) => d.username === s.username);
692
868
  const tx = tr(s);
@@ -703,11 +879,14 @@ export async function builderRoutes(app: FastifyInstance) {
703
879
  <div class="buttons"><button class="btn">${tx('dev.save')}</button></div></form>`)}
704
880
  ${region(tx('dev.accounts'), html`
705
881
  <p class="muted u-mt0">${tx('dev.accounts_help')}</p>
706
- <div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('dev.username')}</th><th>${tx('dev.role')}</th><th>${tx('dev.email_col')}</th><th></th></tr></thead><tbody>
882
+ <div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('dev.username')}</th><th>${tx('dev.role')}</th><th>${tx('dev.email_col')}</th><th>${tx('mfa.column')}</th><th></th></tr></thead><tbody>
707
883
  ${devs.map((d) => html`<tr><td data-label="${tx('dev.username')}">${d.username}${d.username === s.username ? ` ${tx('dev.you')}` : ''}</td><td data-label="${tx('dev.role')}">${d.is_admin ? tx('dev.administrator') : tx('dev.developer')}</td>
708
884
  <td data-label="${tx('dev.email_col')}">${admin && d.username !== s.username
709
885
  ? html`<form method="post" action="${BASE}/developers/email" class="search u-m0 u-mwnone">${csrf(s)}<input type="hidden" name="username" value="${d.username}"><input name="email" type="email" value="${d.email ?? ''}" aria-label="${tx('dev.email_of', { user: d.username })}" placeholder="${tx('dev.none')}"><button class="btn">${tx('dev.save')}</button></form>`
710
- : html`<span class="wrap-anywhere">${d.email ?? ''}</span>`}</td><td>${d.username === s.username || !admin ? '' : html`
886
+ : html`<span class="wrap-anywhere">${d.email ?? ''}</span>`}</td>
887
+ <td data-label="${tx('mfa.column')}">${d.mfa ? html`${icon('lock')} ${tx('mfa.on')}${admin && d.username !== s.username ? html` <details class="menu u-inline"><summary class="link-button">${tx('mfa.reset')}</summary><div class="menu-panel"><form method="post" action="${BASE}/developers/reset-2fa" class="menu-section">${csrf(s)}<input type="hidden" name="username" value="${d.username}">
888
+ ${input(`reason_${d.username}`.replace(/[^\w]/g, '_'), tx('mfa.reason'), '', { required: true, help: tx('mfa.reset_help', { user: d.username }) })}
889
+ <button class="btn btn-sm btn-danger">${tx('mfa.reset_btn')}</button></form></div></details>` : ''}` : html`<span class="muted">${tx('mfa.off')}</span>`}</td><td>${d.username === s.username || !admin ? '' : html`
711
890
  <form method="post" action="${BASE}/developers/admin" class="u-inline">${csrf(s)}<input type="hidden" name="username" value="${d.username}"><input type="hidden" name="is_admin" value="${d.is_admin ? 'false' : 'true'}"><button class="link-button">${d.is_admin ? tx('dev.make_dev') : tx('dev.make_admin')}</button></form>
712
891
  · <form method="post" action="${BASE}/developers/delete" class="u-inline">${csrf(s)}<input type="hidden" name="username" value="${d.username}"><button class="link-button" data-confirm="${tx('dev.remove_confirm', { user: d.username })}">${tx('dev.remove')}</button></form>`}</td></tr>`)}
713
892
  </tbody></table></div>
@@ -761,8 +940,9 @@ export async function builderRoutes(app: FastifyInstance) {
761
940
  [s.username, req.body?.current ?? '', req.body?.password],
762
941
  );
763
942
  if (r.rowCount !== 1) throw new Error(tr(s)('dev.err.current'));
764
- // End all other builder sessions of this developer.
943
+ // End all other builder sessions of this developer, and (sprint 127) forget the browsers they trusted.
765
944
  await owner.query('delete from meta.session where app_id is null and username = $1 and id <> $2', [s.username, s.id]);
945
+ await forgetBrowsers(s.username!);
766
946
  delete s.state.__WEAK;
767
947
  flash(s, tr(s)('dev.pw_changed'));
768
948
  } catch (e) {
@@ -791,6 +971,10 @@ export async function builderRoutes(app: FastifyInstance) {
791
971
  if (!s) return;
792
972
  if (!(await isAdmin(s.username))) return reply.code(403).send('Only administrators change roles.');
793
973
  if (req.body?.username && req.body.username !== s.username) {
974
+ if (req.body.is_admin !== 'true' && (await leavesTooFewAdmins(req.body.username))) {
975
+ flash(s, tr(s)('mfa.keep_admins'), 'error');
976
+ return back(reply, s, `${BASE}/developers`);
977
+ }
794
978
  await owner.query('update meta.developer set is_admin = $2 where username = $1', [req.body.username, req.body.is_admin === 'true']);
795
979
  flash(s, tr(s)('dev.role_changed'));
796
980
  }
@@ -802,6 +986,10 @@ export async function builderRoutes(app: FastifyInstance) {
802
986
  if (!s) return;
803
987
  if (!(await isAdmin(s.username))) return reply.code(403).send('Only administrators remove developers.');
804
988
  if (req.body?.username && req.body.username !== s.username) {
989
+ if (await leavesTooFewAdmins(req.body.username)) {
990
+ flash(s, tr(s)('mfa.keep_admins'), 'error');
991
+ return back(reply, s, `${BASE}/developers`);
992
+ }
805
993
  await owner.query('delete from meta.developer where username = $1', [req.body.username]);
806
994
  await owner.query('delete from meta.session where app_id is null and username = $1', [req.body.username]);
807
995
  flash(s, tr(s)('dev.removed'));