pgkiln 0.58.0 → 0.60.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,7 @@ 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, hasFactor, leavesTooFewAdmins } from '../mfa.ts';
29
31
  import { chooseLanguage, langCookieOptions, languageRoutes, signInLanguageLinks } from './language.ts';
30
32
 
31
33
  // Builder pages: sign-in, workspace and app home, settings, activity and
@@ -148,27 +150,124 @@ export async function builderRoutes(app: FastifyInstance) {
148
150
  flash(s, tx(workspace ? 'signin.invalid_ws' : 'signin.invalid'), 'error');
149
151
  return back(reply, s, again);
150
152
  }
153
+ // (sprint 126, SEC-2) a developer with a second factor: the password was right, the session comes after the code
154
+ if (await hasFactor(dev.username)) {
155
+ 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() });
156
+ await saveState(s);
157
+ return reply.redirect(`${BASE}/login/2fa`, 303);
158
+ }
159
+ 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' });
160
+ });
161
+
162
+ /** The session after a successful sign-in (password, and the second factor when the developer has one). */
163
+ async function finishSignIn(req: Req, reply: Parameters<typeof back>[0], s: Awaited<ReturnType<typeof getSession>>, dev: { username: string; language: string | null },
164
+ o: { ws: string | null; workspace: string; next: string; remember: boolean; weak: boolean; picked: ReturnType<typeof validBuilderLanguage>; detail: string }) {
165
+ const ip = clientIp(req);
166
+ const { picked, next, workspace } = o;
151
167
  await destroySession(reply, s, BASE);
152
168
  const ns = await createSession(reply, null, BASE, dev.username);
153
169
  // the builder theme chosen earlier on this device
154
170
  const theme = validTheme(req.cookies?.[THEME_COOKIE]);
155
171
  if (theme) ns.state.__BTHEME = theme;
156
- if (ws) ns.state.__WS = String(ws.id);
172
+ if (o.ws) ns.state.__WS = o.ws;
157
173
  // (sprint 121) the builder language: a link chosen on the sign-in page is stored for the developer; else theirs
158
174
  if (picked) await chooseLanguage(reply, ns, picked);
159
175
  else {
160
176
  ns.state.__BLANG = validBuilderLanguage(dev.language) ?? 'en';
161
177
  if (dev.language && ns.state.__BLANG !== 'en') reply.setCookie(LANG_COOKIE, ns.state.__BLANG, langCookieOptions());
162
178
  }
163
- if (password === 'admin' || password === dev.username) ns.state.__WEAK = '1';
179
+ if (o.weak) ns.state.__WEAK = '1';
164
180
  await saveState(ns);
165
- if (req.body?.remember === '1')
181
+ if (o.remember)
166
182
  reply.setCookie(REMEMBER_COOKIE, Buffer.from(JSON.stringify({ w: workspace, u: dev.username })).toString('base64url'), {
167
183
  path: BASE, httpOnly: true, sameSite: 'lax', maxAge: 365 * 24 * 3600, secure: process.env.COOKIE_SECURE === 'true',
168
184
  });
169
185
  else reply.clearCookie(REMEMBER_COOKIE, { path: BASE });
170
- logActivity({ username: dev.username, event: 'login', ip, detail: 'builder' });
186
+ logActivity({ username: dev.username, event: 'login', ip, detail: o.detail });
171
187
  return reply.redirect(next, 303);
188
+ }
189
+
190
+ // (sprint 126, SEC-2) the second step: a code from the authenticator app, or a recovery code
191
+ const MFA_SECONDS = 300;
192
+ const pendingMfa = (s: { state: Record<string, any> }) => {
193
+ try {
194
+ const p = JSON.parse(String(s.state.__MFA ?? ''));
195
+ 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;
196
+ } catch {
197
+ return null;
198
+ }
199
+ };
200
+ app.get(`${BASE}/login/2fa`, async (req: Req, reply) => {
201
+ const s = await getSession(req, reply, null, BASE);
202
+ if (s.username) return reply.redirect(`${BASE}/home`);
203
+ const p = pendingMfa(s);
204
+ const tx = translatorFor(validBuilderLanguage(s.state.__BLANG) ?? cookieLanguage(req));
205
+ if (!p) {
206
+ delete s.state.__MFA;
207
+ flash(s, tx('mfa.expired'), 'error');
208
+ return back(reply, s, `${BASE}/login`);
209
+ }
210
+ const error = takeFlash(s, '__ERROR');
211
+ await saveState(s);
212
+ return reply.type('text/html').send(documentShell(
213
+ `${tx('mfa.step_title')} · ${tx('shell.builder')}`,
214
+ html`<main class="signin">
215
+ <section class="signin-hero" aria-label="pgkiln">
216
+ <span class="signin-logo" aria-hidden="true">${brandMark()}</span>
217
+ <h1 class="signin-title">pgkiln</h1>
218
+ <p class="signin-tagline">${tx('signin.tagline')}</p>
219
+ </section>
220
+ <section class="signin-panel">
221
+ <form method="post" class="signin-form">
222
+ <h2>${tx('mfa.step_title')}</h2>
223
+ <p class="muted u-mt0">${tx('mfa.step_intro', { user: p.u })}</p>
224
+ ${error ? html`<div class="alert alert-error" role="alert">${error}</div>` : ''}
225
+ ${csrf(s)}
226
+ <div class="field signin-field"><label class="label" for="f_code">${tx('mfa.code')}</label>
227
+ <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>
228
+ <small class="help">${tx('mfa.code_help')}</small></div>
229
+ <button class="btn btn-hot signin-submit">${tx('mfa.verify')}</button>
230
+ <p class="muted small signin-hint"><a href="${BASE}/login">${tx('mfa.cancel')}</a> · ${tx('mfa.locked_out')}</p>
231
+ </form>
232
+ </section>
233
+ </main>`,
234
+ 'login-body ide-login signin-body', {}, builderHead(),
235
+ { theme: validTheme(req.cookies?.[THEME_COOKIE]) ?? 'dark', lang: tx.lang },
236
+ ));
237
+ });
238
+ app.post(`${BASE}/login/2fa`, async (req: Req, reply) => {
239
+ const s = await getSession(req, reply, null, BASE);
240
+ const ip = clientIp(req);
241
+ const picked = validBuilderLanguage(s.state.__BLANG);
242
+ const tx = translatorFor(picked ?? cookieLanguage(req));
243
+ const p = pendingMfa(s);
244
+ if (req.body?.__csrf !== s.csrf_token || !p) {
245
+ delete s.state.__MFA;
246
+ flash(s, tx(p ? 'signin.expired' : 'mfa.expired'), 'error');
247
+ return back(reply, s, `${BASE}/login`);
248
+ }
249
+ if (await loginThrottled(null, p.u, ip)) {
250
+ logActivity({ username: p.u, event: 'login_locked', ip, detail: 'builder 2fa' });
251
+ delete s.state.__MFA;
252
+ flash(s, tx('signin.throttled'), 'error');
253
+ return back(reply, s, `${BASE}/login`);
254
+ }
255
+ const ok = await checkSecondFactor(p.u, String(req.body?.code ?? '').slice(0, 40));
256
+ if (!ok) {
257
+ // counts with the failed passwords (lockout after the same number of attempts)
258
+ await logActivity({ username: p.u, event: 'login_failed', ip, detail: 'builder 2fa' });
259
+ flash(s, tx('mfa.wrong'), 'error');
260
+ return back(reply, s, `${BASE}/login/2fa`);
261
+ }
262
+ const dev = await owner.one<{ username: string; language: string | null }>('select username, language from meta.developer where username = $1', [p.u]);
263
+ if (!dev) return back(reply, s, `${BASE}/login`);
264
+ if (ok.kind === 'recovery') {
265
+ const left = await codesLeft(p.u);
266
+ await logActivity({ username: p.u, event: '2fa_recovery_used', ip, detail: `${left} left` });
267
+ // to the Security page: one code fewer, maybe time for new ones
268
+ 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)' });
269
+ }
270
+ 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' });
172
271
  });
173
272
 
174
273
  app.post(`${BASE}/logout`, async (req: Req, reply) => {
@@ -205,6 +304,7 @@ export async function builderRoutes(app: FastifyInstance) {
205
304
  await languageRoutes(app);
206
305
  await workspaceRoutes(app);
207
306
  await instanceRoutes(app);
307
+ await securityRoutes(app);
208
308
  await wizardRoutes(app);
209
309
  await appFromFileRoutes(app);
210
310
 
@@ -686,40 +786,45 @@ export async function builderRoutes(app: FastifyInstance) {
686
786
  app.get(`${BASE}/developers`, async (req: Req, reply) => {
687
787
  const s = await developer(req, reply);
688
788
  if (!s) return;
689
- const devs = (await owner.query('select username, is_admin, email from meta.developer order by 1')).rows;
789
+ 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
790
+ from meta.developer d order by 1`)).rows;
690
791
  const admin = devs.some((d) => d.username === s.username && d.is_admin);
691
792
  const me = devs.find((d) => d.username === s.username);
692
- const main = html`<h1 class="u-mb1">Developers</h1>
793
+ const tx = tr(s);
794
+ const main = html`<h1 class="u-mb1">${tx('dev.title')}</h1>
693
795
  <div class="columns">
694
- ${region('Change your password', html`<form method="post" action="${BASE}/developers/password">${csrf(s)}
796
+ ${region(tx('dev.change_pw'), html`<form method="post" action="${BASE}/developers/password">${csrf(s)}
695
797
  <div class="form-grid">
696
- ${input('current', 'Current password', '', { type: 'password', required: true, auto: 'current-password' })}
697
- ${input('password', 'New password', '', { type: 'password', required: true, auto: 'new-password', help: 'At least 8 characters.' })}
798
+ ${input('current', tx('dev.current'), '', { type: 'password', required: true, auto: 'current-password' })}
799
+ ${input('password', tx('dev.new'), '', { type: 'password', required: true, auto: 'new-password', help: tx('dev.min8') })}
698
800
  </div>
699
- <div class="buttons"><button class="btn btn-hot">Change password</button></div></form>`)}
700
- ${region('Your e-mail address', html`<form method="post" action="${BASE}/developers/email">${csrf(s)}<input type="hidden" name="username" value="${s.username}">
701
- <div class="form-grid">${input('email', 'E-mail address', me?.email ?? '', { type: 'email', auto: 'email', help: '"Find my workspace" on the sign-in page sends your workspaces to it. Empty: none.' })}</div>
702
- <div class="buttons"><button class="btn">Save</button></div></form>`)}
703
- ${region('Developer accounts', html`
704
- <p class="muted u-mt0">Administrators add and remove developers and can break other developers' page and application locks.</p>
705
- <div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Username</th><th>Role</th><th>E-mail</th><th></th></tr></thead><tbody>
706
- ${devs.map((d) => html`<tr><td data-label="Username">${d.username}${d.username === s.username ? ' (you)' : ''}</td><td data-label="Role">${d.is_admin ? 'Administrator' : 'Developer'}</td>
707
- <td data-label="E-mail">${admin && d.username !== s.username
708
- ? 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="E-mail address of ${d.username}" placeholder="none"><button class="btn">Save</button></form>`
709
- : html`<span class="wrap-anywhere">${d.email ?? ''}</span>`}</td><td>${d.username === s.username || !admin ? '' : html`
710
- <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 ? 'Make developer' : 'Make administrator'}</button></form>
711
- · <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="Remove developer ${d.username}?">Remove</button></form>`}</td></tr>`)}
801
+ <div class="buttons"><button class="btn btn-hot">${tx('dev.change_btn')}</button></div></form>`)}
802
+ ${region(tx('dev.your_email'), html`<form method="post" action="${BASE}/developers/email">${csrf(s)}<input type="hidden" name="username" value="${s.username}">
803
+ <div class="form-grid">${input('email', tx('dev.email'), me?.email ?? '', { type: 'email', auto: 'email', help: tx('dev.email_help') })}</div>
804
+ <div class="buttons"><button class="btn">${tx('dev.save')}</button></div></form>`)}
805
+ ${region(tx('dev.accounts'), html`
806
+ <p class="muted u-mt0">${tx('dev.accounts_help')}</p>
807
+ <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>
808
+ ${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>
809
+ <td data-label="${tx('dev.email_col')}">${admin && d.username !== s.username
810
+ ? 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>`
811
+ : html`<span class="wrap-anywhere">${d.email ?? ''}</span>`}</td>
812
+ <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}">
813
+ ${input(`reason_${d.username}`.replace(/[^\w]/g, '_'), tx('mfa.reason'), '', { required: true, help: tx('mfa.reset_help', { user: d.username }) })}
814
+ <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`
815
+ <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>
816
+ · <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>`)}
712
817
  </tbody></table></div>
713
818
  ${admin
714
- ? html`<h3>Add developer</h3>
819
+ ? html`<h3>${tx('dev.add')}</h3>
715
820
  <form method="post" action="${BASE}/developers">${csrf(s)}
716
- <div class="form-grid">${input('username', 'Username', '', { required: true })}${input('password', 'Password', '', { type: 'password', required: true, auto: 'new-password' })}${input('email', 'E-mail address', '', { type: 'email', help: 'Optional: for "Find my workspace".' })}</div>
717
- <div class="field"><label class="check"><input type="checkbox" name="is_admin" value="true"> Administrator</label></div>
718
- <div class="buttons"><button class="btn btn-hot">Add developer</button></div>
821
+ <div class="form-grid">${input('username', tx('dev.username'), '', { required: true })}${input('password', tx('dev.password'), '', { type: 'password', required: true, auto: 'new-password' })}${input('email', tx('dev.email'), '', { type: 'email', help: tx('dev.optional_email') })}</div>
822
+ <div class="field"><label class="check"><input type="checkbox" name="is_admin" value="true"> ${tx('dev.administrator')}</label></div>
823
+ <div class="buttons"><button class="btn btn-hot">${tx('dev.add')}</button></div>
719
824
  </form>`
720
- : html`<p class="muted">Only administrators add and remove developers.</p>`}`)}
825
+ : html`<p class="muted">${tx('dev.admins_only')}</p>`}`)}
721
826
  </div>`;
722
- return send(reply, s, shell(s, 'Developers', [['Developers']], main, 'developers'));
827
+ return send(reply, s, shell(s, tx('dev.title'), [['Developers']], main, 'developers'));
723
828
  });
724
829
 
725
830
  app.post(`${BASE}/developers`, async (req: Req, reply) => {
@@ -732,7 +837,7 @@ export async function builderRoutes(app: FastifyInstance) {
732
837
  const ws = currentWorkspace(s);
733
838
  await owner.tx(async (c) => {
734
839
  const email = String(req.body?.email ?? '').trim();
735
- if (email && !EMAIL.test(email)) throw new Error('The e-mail address is not valid.');
840
+ if (email && !EMAIL.test(email)) throw new Error(tr(s)('dev.err.email'));
736
841
  await c.query('insert into meta.developer (username, password_hash, is_admin, email) values ($1, meta.hash_password($2), $3, $4)', [req.body?.username?.trim(), req.body?.password, req.body?.is_admin === 'true', email || null]);
737
842
  // (064) a new developer works in the current workspace; more on Workspace utilities → Workspaces
738
843
  // (the insert trigger put them in Default)
@@ -741,7 +846,7 @@ export async function builderRoutes(app: FastifyInstance) {
741
846
  await c.query('insert into meta.workspace_member (workspace_id, username) values ($1, $2)', [ws, req.body?.username?.trim()]);
742
847
  }
743
848
  });
744
- flash(s, ws > 0 ? `Developer added to workspace ${s.workspace!.name}.` : 'Developer added.');
849
+ flash(s, ws > 0 ? tr(s)('dev.added_ws', { name: s.workspace!.name }) : tr(s)('dev.added'));
745
850
  } catch (e) {
746
851
  flash(s, (e as Error).message, 'error');
747
852
  }
@@ -759,11 +864,11 @@ export async function builderRoutes(app: FastifyInstance) {
759
864
  where username = $1 and password_hash = crypt($2, password_hash)`,
760
865
  [s.username, req.body?.current ?? '', req.body?.password],
761
866
  );
762
- if (r.rowCount !== 1) throw new Error('The current password is not correct.');
867
+ if (r.rowCount !== 1) throw new Error(tr(s)('dev.err.current'));
763
868
  // End all other builder sessions of this developer.
764
869
  await owner.query('delete from meta.session where app_id is null and username = $1 and id <> $2', [s.username, s.id]);
765
870
  delete s.state.__WEAK;
766
- flash(s, 'Password changed.');
871
+ flash(s, tr(s)('dev.pw_changed'));
767
872
  } catch (e) {
768
873
  flash(s, (e as Error).message, 'error');
769
874
  }
@@ -777,10 +882,10 @@ export async function builderRoutes(app: FastifyInstance) {
777
882
  const who = String(req.body?.username ?? s.username);
778
883
  if (who !== s.username && !(await isAdmin(s.username))) return reply.code(403).send('Only administrators change other developers\' addresses.');
779
884
  const email = String(req.body?.email ?? '').trim();
780
- if (email && (!EMAIL.test(email) || email.length > 254)) flash(s, 'The e-mail address is not valid.', 'error');
885
+ if (email && (!EMAIL.test(email) || email.length > 254)) flash(s, tr(s)('dev.err.email'), 'error');
781
886
  else {
782
887
  const r = await owner.query('update meta.developer set email = $2 where username = $1', [who, email || null]);
783
- flash(s, r.rowCount ? 'E-mail address saved.' : 'Not found.', r.rowCount ? 'ok' : 'error');
888
+ flash(s, tr(s)(r.rowCount ? 'dev.email_saved' : 'dev.not_found'), r.rowCount ? 'ok' : 'error');
784
889
  }
785
890
  return back(reply, s, `${BASE}/developers`);
786
891
  });
@@ -790,8 +895,12 @@ export async function builderRoutes(app: FastifyInstance) {
790
895
  if (!s) return;
791
896
  if (!(await isAdmin(s.username))) return reply.code(403).send('Only administrators change roles.');
792
897
  if (req.body?.username && req.body.username !== s.username) {
898
+ if (req.body.is_admin !== 'true' && (await leavesTooFewAdmins(req.body.username))) {
899
+ flash(s, tr(s)('mfa.keep_admins'), 'error');
900
+ return back(reply, s, `${BASE}/developers`);
901
+ }
793
902
  await owner.query('update meta.developer set is_admin = $2 where username = $1', [req.body.username, req.body.is_admin === 'true']);
794
- flash(s, 'Role changed.');
903
+ flash(s, tr(s)('dev.role_changed'));
795
904
  }
796
905
  return back(reply, s, `${BASE}/developers`);
797
906
  });
@@ -801,9 +910,13 @@ export async function builderRoutes(app: FastifyInstance) {
801
910
  if (!s) return;
802
911
  if (!(await isAdmin(s.username))) return reply.code(403).send('Only administrators remove developers.');
803
912
  if (req.body?.username && req.body.username !== s.username) {
913
+ if (await leavesTooFewAdmins(req.body.username)) {
914
+ flash(s, tr(s)('mfa.keep_admins'), 'error');
915
+ return back(reply, s, `${BASE}/developers`);
916
+ }
804
917
  await owner.query('delete from meta.developer where username = $1', [req.body.username]);
805
918
  await owner.query('delete from meta.session where app_id is null and username = $1', [req.body.username]);
806
- flash(s, 'Developer removed.');
919
+ flash(s, tr(s)('dev.removed'));
807
920
  }
808
921
  return back(reply, s, `${BASE}/developers`);
809
922
  });
@@ -0,0 +1,261 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import { owner } from '../db.ts';
3
+ import { html, raw, type Raw } from '../html.ts';
4
+ import { icon } from '../icons.ts';
5
+ import {
6
+ addTotp, adminEmails, adminFactors, codesLeft, confirmRecoveryCodes, factorsOf, hasFactor, matchTotp, MFA_POLICIES, mfaPolicy, newRecoveryCodes, newTotpSecret,
7
+ notifyMfa, otpauthUri, policyProblem, requiredFor, resetFactors, setPolicy, unbase32, type MfaPolicy,
8
+ } from '../mfa.ts';
9
+ import { qrSvg } from '../qrcode.ts';
10
+ import { secretKeyConfigured } from '../secrets.ts';
11
+ import { clientIp, logActivity, type Session } from '../session.ts';
12
+ import { tr } from './i18n.ts';
13
+ import { isAdmin } from './locks.ts';
14
+ import { back, BASE, csrf, developer, flash, input, region, send, shell, type Req } from './ui.ts';
15
+
16
+ // (sprint 126, SEC-2 and SEC-6) Developer › Security: a developer's second factors (authenticator apps, TOTP)
17
+ // and recovery codes; the instance's policy (Instance settings); an administrator's reset of another
18
+ // developer's factors (Developers). Adding an app: a new secret is kept in the session until a code from the
19
+ // app confirms it, then stored encrypted with the instance key. Recovery codes are shown once; they count
20
+ // only after "I saved them". Removing a factor asks for the password, and is refused when it would leave a
21
+ // developer the policy covers without one, or the instance without enough administrators who have one.
22
+
23
+ const SECRET_MINUTES = 10;
24
+
25
+ async function verifyPassword(username: string, password: string) {
26
+ return !!(await owner.one('select 1 as ok from meta.developer where username = $1 and crypt($2, password_hash) = password_hash', [username, password]));
27
+ }
28
+
29
+ export async function securityRoutes(app: FastifyInstance) {
30
+ app.get(`${BASE}/security`, async (req: Req, reply) => {
31
+ const s = await developer(req, reply);
32
+ if (!s) return;
33
+ const tx = tr(s);
34
+ const me = s.username!;
35
+ const [factors, left, pol, admin] = await Promise.all([factorsOf(me), codesLeft(me), mfaPolicy(), isAdmin(me)]);
36
+ const required = requiredFor(pol.policy, admin);
37
+ const pending = (() => {
38
+ try {
39
+ const p = JSON.parse(String(s.state.__TOTP_NEW ?? ''));
40
+ return p && Date.now() - p.at < SECRET_MINUTES * 60_000 ? (p as { secret: string; at: number }) : null;
41
+ } catch {
42
+ return null;
43
+ }
44
+ })();
45
+ const shownCodes: string[] | null = (() => {
46
+ try {
47
+ const c = JSON.parse(String(s.state.__CODES_NEW ?? ''));
48
+ return Array.isArray(c) ? c : null;
49
+ } catch {
50
+ return null;
51
+ }
52
+ })();
53
+ delete s.state.__MFA_DUE; // the page says it itself
54
+ const when = (v: string | null) => (v ? String(v).slice(0, 16).replace('T', ' ') : '—');
55
+ const setup = pending
56
+ ? html`<form method="post" action="${BASE}/security/totp/confirm">${csrf(s)}
57
+ <ol class="mfa-steps">
58
+ <li>${tx('mfa.scan')}</li>
59
+ <li>${tx('mfa.or_enter')} <code class="mfa-secret">${pending.secret.replace(/(.{4})/g, '$1 ').trim()}</code></li>
60
+ <li>${tx('mfa.enter_code')}</li>
61
+ </ol>
62
+ <div class="mfa-qr">${raw(qrSvg(otpauthUri(pending.secret, me), { label: tx('mfa.qr_label'), px: 4 }) ?? '')}</div>
63
+ <div class="form-grid">
64
+ ${input('name', tx('mfa.app_name'), tx('mfa.app_default'), { required: true })}
65
+ <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>
66
+ </div>
67
+ <div class="buttons"><button class="btn btn-hot">${tx('mfa.confirm_app')}</button></div></form>
68
+ <form method="post" action="${BASE}/security/totp/cancel">${csrf(s)}<button class="link-button">${tx('mfa.cancel_setup')}</button></form>`
69
+ : html`<form method="post" action="${BASE}/security/totp/start">${csrf(s)}
70
+ <div class="buttons"><button class="btn${factors.length ? '' : ' btn-hot'}"${secretKeyConfigured() ? '' : raw(' disabled')}>${icon('plus')} ${tx('mfa.add_app')}</button></div>
71
+ ${secretKeyConfigured() ? '' : html`<p class="alert alert-error" role="alert">${tx('mfa.no_key')}</p>`}</form>`;
72
+ const main = html`<h1 class="u-mb1">${tx('mfa.title')}</h1>
73
+ <p class="muted u-mt0">${tx('mfa.intro')}</p>
74
+ ${required && !factors.length ? html`<div class="alert alert-warning" role="alert">${tx('mfa.required_you')}</div>` : ''}
75
+ ${shownCodes
76
+ ? region(tx('mfa.codes_title'), html`<p>${tx('mfa.codes_once')}</p>
77
+ <ol class="mfa-codes">${shownCodes.map((c) => html`<li><code>${c}</code></li>`)}</ol>
78
+ <form method="post" action="${BASE}/security/codes/confirm">${csrf(s)}
79
+ <label class="check"><input type="checkbox" name="saved" value="1" required> ${tx('mfa.codes_saved')}</label>
80
+ <div class="buttons"><button class="btn btn-hot">${tx('mfa.codes_done')}</button></div></form>`, '', 'codes')
81
+ : ''}
82
+ <div class="columns">
83
+ ${region(tx('mfa.apps'), html`${factors.length
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('key')} ${f.name}</td><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
+ <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
+ ${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>`)}
89
+ </tbody></table></div>`
90
+ : html`<p class="muted">${tx('mfa.no_apps')}</p>`}
91
+ ${setup}`)}
92
+ ${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
+ ${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>` : ''}`)}
95
+ </div>`;
96
+ return send(reply, s, shell(s, tx('mfa.title'), [[tx('mfa.title')]], main, 'developers'));
97
+ });
98
+
99
+ app.post(`${BASE}/security/totp/start`, async (req: Req, reply) => {
100
+ const s = await developer(req, reply);
101
+ if (!s) return;
102
+ if (!secretKeyConfigured()) return back(reply, s, `${BASE}/security`);
103
+ s.state.__TOTP_NEW = JSON.stringify({ secret: newTotpSecret(), at: Date.now() });
104
+ return back(reply, s, `${BASE}/security`);
105
+ });
106
+
107
+ app.post(`${BASE}/security/totp/cancel`, async (req: Req, reply) => {
108
+ const s = await developer(req, reply);
109
+ if (!s) return;
110
+ delete s.state.__TOTP_NEW;
111
+ return back(reply, s, `${BASE}/security`);
112
+ });
113
+
114
+ app.post(`${BASE}/security/totp/confirm`, async (req: Req, reply) => {
115
+ const s = await developer(req, reply);
116
+ if (!s) return;
117
+ const tx = tr(s);
118
+ let pending: { secret: string; at: number } | null = null;
119
+ try {
120
+ pending = JSON.parse(String(s.state.__TOTP_NEW ?? ''));
121
+ } catch {
122
+ pending = null;
123
+ }
124
+ if (!pending || Date.now() - pending.at > SECRET_MINUTES * 60_000) {
125
+ delete s.state.__TOTP_NEW;
126
+ flash(s, tx('mfa.setup_expired'), 'error');
127
+ return back(reply, s, `${BASE}/security`);
128
+ }
129
+ const step = matchTotp(unbase32(pending.secret), String(req.body?.code ?? '').replace(/\s/g, ''), null);
130
+ if (step === null) {
131
+ flash(s, tx('mfa.wrong_setup'), 'error');
132
+ return back(reply, s, `${BASE}/security`);
133
+ }
134
+ const first = !(await hasFactor(s.username!));
135
+ await addTotp(s.username!, String(req.body?.name ?? ''), pending.secret, step);
136
+ delete s.state.__TOTP_NEW;
137
+ delete s.state.__MFA_DUE;
138
+ await logActivity({ username: s.username, event: '2fa_enrolled', ip: clientIp(req), detail: 'authenticator app' });
139
+ // the first app: recovery codes at once (they count only once the developer saved them)
140
+ if (first || !(await codesLeft(s.username!))) s.state.__CODES_NEW = JSON.stringify(await newRecoveryCodes(s.username!));
141
+ flash(s, tx('mfa.app_added'));
142
+ return back(reply, s, `${BASE}/security#codes`);
143
+ });
144
+
145
+ app.post(`${BASE}/security/codes`, async (req: Req, reply) => {
146
+ const s = await developer(req, reply);
147
+ if (!s) return;
148
+ if (!(await hasFactor(s.username!))) return back(reply, s, `${BASE}/security`);
149
+ s.state.__CODES_NEW = JSON.stringify(await newRecoveryCodes(s.username!));
150
+ await logActivity({ username: s.username, event: '2fa_codes', ip: clientIp(req), detail: 'new recovery codes' });
151
+ return back(reply, s, `${BASE}/security#codes`);
152
+ });
153
+
154
+ app.post(`${BASE}/security/codes/confirm`, async (req: Req, reply) => {
155
+ const s = await developer(req, reply);
156
+ if (!s) return;
157
+ if (req.body?.saved !== '1') return back(reply, s, `${BASE}/security#codes`);
158
+ await confirmRecoveryCodes(s.username!);
159
+ delete s.state.__CODES_NEW;
160
+ flash(s, tr(s)('mfa.codes_kept'));
161
+ return back(reply, s, `${BASE}/security`);
162
+ });
163
+
164
+ app.post(`${BASE}/security/factor/:id(^\\d{1,9}$)/delete`, async (req: Req, reply) => {
165
+ const s = await developer(req, reply);
166
+ if (!s) return;
167
+ const tx = tr(s);
168
+ const me = s.username!;
169
+ 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]);
171
+ if (!f) return reply.code(404).send('Not found');
172
+ if (!(await verifyPassword(me, String(req.body?.[`password_${id}`] ?? '')))) {
173
+ await logActivity({ username: me, event: 'login_failed', ip: clientIp(req), detail: 'builder (removing a second factor)' });
174
+ flash(s, tx('mfa.wrong_password'), 'error');
175
+ return back(reply, s, `${BASE}/security`);
176
+ }
177
+ const last = (await factorsOf(me)).length === 1;
178
+ if (last) {
179
+ const pol = await mfaPolicy();
180
+ const admin = await isAdmin(me);
181
+ if (requiredFor(pol.policy, admin)) {
182
+ flash(s, tx('mfa.cannot_remove_required'), 'error');
183
+ return back(reply, s, `${BASE}/security`);
184
+ }
185
+ // (SEC-6) never fewer administrators with a factor than the policy needs
186
+ if (admin && pol.policy !== 'off') {
187
+ const { admins, enrolled } = await adminFactors();
188
+ if (enrolled - 1 < Math.min(2, admins)) {
189
+ flash(s, tx('mfa.cannot_remove_admin'), 'error');
190
+ return back(reply, s, `${BASE}/security`);
191
+ }
192
+ }
193
+ }
194
+ await owner.query('delete from meta.developer_factor where id = $1 and username = $2', [id, me]);
195
+ if (last) await owner.query('delete from meta.developer_recovery_code where username = $1', [me]);
196
+ await logActivity({ username: me, event: '2fa_removed', ip: clientIp(req), detail: last ? 'the last authenticator app (recovery codes removed)' : 'an authenticator app' });
197
+ flash(s, tx('mfa.removed'));
198
+ return back(reply, s, `${BASE}/security`);
199
+ });
200
+
201
+ // (SEC-6) an administrator resets another developer's second factors: logged, the developer and the administrators told
202
+ app.post(`${BASE}/developers/reset-2fa`, async (req: Req, reply) => {
203
+ const s = await developer(req, reply);
204
+ if (!s) return;
205
+ const tx = tr(s);
206
+ if (!(await isAdmin(s.username))) return reply.code(403).send('Only administrators reset second factors.');
207
+ const who = String(req.body?.username ?? '');
208
+ const reason = String(req.body?.[`reason_${who.replace(/[^\w]/g, '_')}`] ?? req.body?.reason ?? '').trim().slice(0, 200);
209
+ if (who === s.username) return reply.code(400).send('Use your own Security page.');
210
+ if (!reason) {
211
+ flash(s, tx('mfa.reason_needed'), 'error');
212
+ return back(reply, s, `${BASE}/developers`);
213
+ }
214
+ if (!(await resetFactors(who))) return reply.code(404).send('Not found');
215
+ await logActivity({ username: s.username, event: '2fa_reset', ip: clientIp(req), detail: `${who}: ${reason}` });
216
+ const dev = await owner.one<{ email: string | null }>('select email from meta.developer where username = $1', [who]);
217
+ await notifyMfa([dev?.email ?? '', ...(await adminEmails())], `pgkiln: two-factor sign-in of ${who} reset`,
218
+ `${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.`);
219
+ flash(s, tx('mfa.reset_done', { user: who }));
220
+ return back(reply, s, `${BASE}/developers`);
221
+ });
222
+
223
+ // (SEC-2, SEC-6) the instance's policy: off, administrators or all developers, with a grace period
224
+ app.post(`${BASE}/instance/mfa`, async (req: Req, reply) => {
225
+ const s = await developer(req, reply);
226
+ if (!s) return;
227
+ const tx = tr(s);
228
+ if (!(await isAdmin(s.username))) return reply.code(403).send('Only administrators change the policy.');
229
+ const policy = MFA_POLICIES.includes(req.body?.policy as MfaPolicy) ? (req.body!.policy as MfaPolicy) : null;
230
+ const grace = Number(req.body?.grace_days);
231
+ if (!policy || !Number.isInteger(grace) || grace < 0 || grace > 90) return reply.code(400).send('policy is off, admins or all; grace_days 0 to 90');
232
+ if (await policyProblem(policy)) {
233
+ flash(s, tx('mfa.policy_needs_admins'), 'error');
234
+ return back(reply, s, `${BASE}/instance#mfa`);
235
+ }
236
+ await setPolicy(policy, grace);
237
+ await logActivity({ username: s.username, event: '2fa_policy', ip: clientIp(req), detail: `${policy}, grace ${grace} days` });
238
+ 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
+ flash(s, tx('mfa.policy_saved'));
240
+ return back(reply, s, `${BASE}/instance#mfa`);
241
+ });
242
+ }
243
+
244
+ /** The Instance settings region for the policy (administrators). */
245
+ export async function policyRegion(s: Session): Promise<Raw> {
246
+ const tx = tr(s);
247
+ const [pol, af, without] = await Promise.all([
248
+ mfaPolicy(), adminFactors(),
249
+ 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
+ ]);
251
+ 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>`;
252
+ return region(tx('mfa.policy_title'), html`<form method="post" action="${BASE}/instance/mfa">${csrf(s)}
253
+ <div class="form-grid">
254
+ <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
+ ${input('grace_days', tx('mfa.grace'), pol.graceDays, { type: 'number', help: tx('mfa.grace_help') })}
256
+ </div>
257
+ <p class="muted small">${tx('mfa.policy_help', { enrolled: af.enrolled, admins: af.admins })}</p>
258
+ <div class="buttons"><button class="btn btn-hot">${tx('mfa.policy_save')}</button></div></form>
259
+ ${without.rows.length ? html`<p class="small">${tx('mfa.without', { list: without.rows.map((d) => `${d.username}${d.is_admin ? ' (admin)' : ''}`).join(', ') })}</p>` : ''}
260
+ <p class="muted small">${tx('mfa.cli_note')}</p>`, '', 'mfa');
261
+ }
@@ -253,7 +253,7 @@ export async function sharedRoutes(app: FastifyInstance) {
253
253
  <td data-label="${tx('sc.account')}"><a href="${BASE}/users/${u.id}">${u.username}</a>${u.display_name ? html` <span class="muted">${u.display_name}</span>` : ''}${u.active ? '' : html` <b>${tx('sc.inactive')}</b>`}</td>
254
254
  <td data-label="${tx('sc.roles')}"><form method="post" action="${BASE}/apps/${a.id}/access/${u.id}" class="search roles-form u-m0 u-mwnone">${csrf(s)}
255
255
  <input name="roles" value="${u.roles.join(', ')}" aria-label="${tx('sc.roles_of', { user: u.username })}" placeholder="${tx('sc.no_roles')}"><button class="btn">${tx('sc.save')}</button>
256
- ${roleHintsHtml(appHints, 'Add')}</form></td>
256
+ ${roleHintsHtml(appHints, tx('us.hints_add'), tx)}</form></td>
257
257
  <td data-label="${tx('sc.last_signin')}">${u.last_login_at ? String(u.last_login_at).slice(0, 16) : '—'}</td>
258
258
  <td data-label=""><form method="post" action="${BASE}/apps/${a.id}/access/${u.id}/revoke">${csrf(s)}<button class="link-button" data-confirm="${tx('sc.revoke_confirm', { user: u.username, app: a.name })}">${tx('sc.revoke')}</button></form></td>
259
259
  </tr>`)
@@ -269,7 +269,7 @@ export async function sharedRoutes(app: FastifyInstance) {
269
269
  <small class="help">${tx('sc.existing')} <a href="${BASE}/users">${tx('sc.create_in_users')}</a></small></div>
270
270
  <div class="field"><label class="label" for="f_roles">${tx('sc.roles')}</label>
271
271
  <input id="f_roles" name="roles" placeholder="${tx('sc.roles_ph')}">
272
- ${roleHintsHtml(appHints)}</div>
272
+ ${roleHintsHtml(appHints, undefined, tx)}</div>
273
273
  </div>
274
274
  <div class="buttons"><button class="btn btn-hot">${tx('sc.grant')}</button></div>
275
275
  </form>`);