pgkiln 0.63.0 → 0.65.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/SECURITY.md +2 -0
  2. package/db/migrations/132_app_user_mfa.sql +58 -0
  3. package/db/migrations/133_app_user_passkeys_stepup.sql +54 -0
  4. package/docs/apex-feature-parity.md +6 -1
  5. package/docs/guide/08-security.md +81 -5
  6. package/docs/guide/09-reference.md +5 -1
  7. package/docs/guide/11-from-apex.md +2 -1
  8. package/docs/guide/12-development.md +13 -1
  9. package/docs/guide/13-rest-api.md +4 -0
  10. package/package.json +1 -1
  11. package/public/app.css +10 -0
  12. package/public/app.js +63 -0
  13. package/public/builder.js +1 -62
  14. package/src/account-mfa.ts +184 -0
  15. package/src/api.ts +9 -2
  16. package/src/builder/advisor.ts +8 -0
  17. package/src/builder/api.ts +3 -1
  18. package/src/builder/components.ts +1 -0
  19. package/src/builder/i18n/de.ts +6 -0
  20. package/src/builder/i18n/en.ts +6 -0
  21. package/src/builder/i18n/es.ts +6 -0
  22. package/src/builder/i18n/fr.ts +6 -0
  23. package/src/builder/i18n/nl.ts +6 -0
  24. package/src/builder/i18n/pt.ts +6 -0
  25. package/src/builder/login-page.ts +32 -3
  26. package/src/builder/rest-modules.ts +1 -1
  27. package/src/builder/security.ts +3 -19
  28. package/src/builder/shared.ts +5 -3
  29. package/src/builder/users.ts +34 -3
  30. package/src/i18n/ar.ts +65 -0
  31. package/src/i18n/cs.ts +65 -0
  32. package/src/i18n/da.ts +65 -0
  33. package/src/i18n/de.ts +65 -0
  34. package/src/i18n/el.ts +65 -0
  35. package/src/i18n/es.ts +65 -0
  36. package/src/i18n/fi.ts +65 -0
  37. package/src/i18n/fr.ts +65 -0
  38. package/src/i18n/he.ts +65 -0
  39. package/src/i18n/it.ts +65 -0
  40. package/src/i18n/ja.ts +65 -0
  41. package/src/i18n/ko.ts +65 -0
  42. package/src/i18n/nb.ts +65 -0
  43. package/src/i18n/pl.ts +65 -0
  44. package/src/i18n/pt.ts +65 -0
  45. package/src/i18n/ru.ts +65 -0
  46. package/src/i18n/sv.ts +65 -0
  47. package/src/i18n/tr.ts +65 -0
  48. package/src/i18n/uk.ts +65 -0
  49. package/src/i18n/zh.ts +65 -0
  50. package/src/i18n.ts +130 -0
  51. package/src/metadata.ts +11 -2
  52. package/src/mfa.ts +94 -65
  53. package/src/remember.ts +1 -1
  54. package/src/runtime/account.ts +7 -1
  55. package/src/runtime/authz.ts +23 -4
  56. package/src/runtime/routes.ts +21 -7
  57. package/src/runtime/two-step.ts +702 -0
  58. package/src/sharedlogin.ts +1 -1
  59. package/src/webauthn.ts +20 -0
@@ -0,0 +1,184 @@
1
+ import { createHash, randomInt, timingSafeEqual } from 'node:crypto';
2
+ import { owner } from './db.ts';
3
+ import {
4
+ ACCOUNTS, storeAddPasskey, storeAddTotp, storeCheck, storeCodesLeft, storeConfirmCodes, storeHasFactor, storeNewCodes, storePasskeys,
5
+ storeUsePasskey,
6
+ } from './mfa.ts';
7
+
8
+ // (sprint 130, SEC-3) Two-step sign-in for application users, beyond APEX (whose application authentication has no
9
+ // second factor). An account's authenticator apps and recovery codes (meta.account_factor, account_recovery_code,
10
+ // migration 132) serve every application it may use; each application decides whether it asks for them (meta.app.mfa):
11
+ // off, optional (users who added an app give a code), required, or required for some roles. The second step follows
12
+ // the user directory's passwords and LDAP directories; an identity provider decides on its own second factor (SEC-4).
13
+ // A remembered or shared sign-in carries its method: only one that ends in "+2fa" stands in for the second step.
14
+ // (sprint 131, SEC-3 part 2) Passkeys next to authenticator apps; a code by e-mail for users who have one of those
15
+ // (never someone's only factor, SEC-6); step-up: an authorization scheme with mfa_minutes passes only when the session's
16
+ // last second step (session state __MFA_AT) is that recent; and REST API tokens a user creates on My account.
17
+
18
+ export type AppMfa = 'off' | 'optional' | 'required' | 'roles';
19
+ export const APP_MFA: AppMfa[] = ['off', 'optional', 'required', 'roles'];
20
+
21
+ export interface MfaApp { authentication: string; mfa?: AppMfa | null; mfa_roles?: string[] | null }
22
+
23
+ /** Whether the application has a second step at all (its own accounts, and not "off"). */
24
+ export const mfaOn = (a: MfaApp) => a.authentication === 'app_users' && !!a.mfa && a.mfa !== 'off';
25
+
26
+ /** Whether the application requires a second step of a user with these roles. */
27
+ export function mfaRequired(a: MfaApp, roles: string[]): boolean {
28
+ if (!mfaOn(a)) return false;
29
+ if (a.mfa === 'required') return true;
30
+ const need = new Set((a.mfa_roles ?? []).map((r) => r.toLowerCase()));
31
+ return a.mfa === 'roles' && roles.some((r) => need.has(r.toLowerCase()));
32
+ }
33
+
34
+ /** The sign-in methods a second step follows (the directory's password, an LDAP directory). */
35
+ export const knowsPassword = (method: string | undefined) => !method || method.startsWith('password') || method.startsWith('ldap:');
36
+ export const MFA_SUFFIX = '+2fa';
37
+ export const withSecondStep = (method: string | undefined) => `${method ?? 'password'}${MFA_SUFFIX}`;
38
+
39
+ /** An active account's id by user name (accounts are matched without regard to case). */
40
+ export async function accountId(username: string): Promise<number | null> {
41
+ return (await owner.one<{ id: number }>('select id from meta.account where lower(username) = lower($1) and active', [username]))?.id ?? null;
42
+ }
43
+
44
+ /**
45
+ * What a password (or LDAP) sign-in to this application still needs: 'code' (the user has an authenticator app),
46
+ * 'enrol' (required, and the user has none yet) or null (no second step).
47
+ */
48
+ export async function secondStep(a: MfaApp, username: string, roles: string[]): Promise<'code' | 'enrol' | null> {
49
+ if (!mfaOn(a)) return null;
50
+ const id = await accountId(username);
51
+ if (id === null) return null;
52
+ if (await storeHasFactor(ACCOUNTS, id)) return 'code';
53
+ return mfaRequired(a, roles) ? 'enrol' : null;
54
+ }
55
+
56
+ /**
57
+ * Whether a remembered or shared sign-in may sign the user in here without a second step: one made with an identity
58
+ * provider, one that had a second step, or a user this application asks nothing of.
59
+ */
60
+ export async function carriesSecondStep(a: MfaApp, username: string, roles: string[], method: string | undefined): Promise<boolean> {
61
+ if (!knowsPassword(method) || method?.endsWith(MFA_SUFFIX)) return true;
62
+ return (await secondStep(a, username, roles)) === null;
63
+ }
64
+
65
+ export interface AccountFactor { id: number; kind: 'totp' | 'passkey'; name: string; created_at: string; last_used_at: string | null; rp_id: string | null }
66
+
67
+ export async function accountFactors(id: number): Promise<AccountFactor[]> {
68
+ return (await owner.query<AccountFactor>(
69
+ 'select id, kind, name, created_at, last_used_at, rp_id from meta.account_factor where account_id = $1 order by kind desc, id', [id])).rows;
70
+ }
71
+
72
+ export const accountHasFactor = (id: number) => storeHasFactor(ACCOUNTS, id);
73
+ export const accountCodesLeft = (id: number) => storeCodesLeft(ACCOUNTS, id);
74
+ export const addAccountTotp = (id: number, name: string, secret: string, step: number) => storeAddTotp(ACCOUNTS, id, name, secret, step);
75
+ export const newAccountCodes = (id: number) => storeNewCodes(ACCOUNTS, id);
76
+ export const confirmAccountCodes = (id: number) => storeConfirmCodes(ACCOUNTS, id);
77
+ export const checkAccountCode = (id: number, code: string) => storeCheck(ACCOUNTS, id, code);
78
+ export const addAccountPasskey = (id: number, name: string, p: Parameters<typeof storeAddPasskey>[3]) => storeAddPasskey(ACCOUNTS, id, name, p);
79
+ export const accountPasskeys = (id: number, rpId: string) => storePasskeys(ACCOUNTS, id, rpId);
80
+ export const useAccountPasskey = (factorId: number, oldCount: string, newCount: number) => storeUsePasskey(ACCOUNTS, factorId, oldCount, newCount);
81
+ /** The user handle an account's passkeys carry: stable, and not the user name. */
82
+ export const accountUserHandle = (id: number) => createHash('sha256').update(`pgkiln-account:${id}`).digest().subarray(0, 16).toString('base64url');
83
+
84
+ // ---------------------------------------------------------------- step-up (sprint 131)
85
+
86
+ /** Session state: when (ms) the session last had a second step (signing in with one, or asked again). */
87
+ export const MFA_AT = '__MFA_AT';
88
+ export const markSecondStep = (state: Record<string, unknown>) => { state[MFA_AT] = Date.now(); };
89
+ /** Whether the session had a second step at most `minutes` ago. */
90
+ export function secondStepWithin(state: Record<string, unknown>, minutes: number): boolean {
91
+ const at = Number(state[MFA_AT]);
92
+ return Number.isFinite(at) && at > 0 && Date.now() - at <= minutes * 60_000;
93
+ }
94
+
95
+ // ---------------------------------------------------------------- e-mail codes (sprint 131)
96
+
97
+ export const EMAIL_CODE_MINUTES = 10;
98
+ /** Codes one account may get by e-mail per hour (every application together). */
99
+ export const EMAIL_CODES_PER_HOUR = 5;
100
+ export const newEmailCode = () => String(randomInt(0, 1_000_000)).padStart(6, '0');
101
+ export const emailCodeHash = (code: string) => createHash('sha256').update(`pgkiln-mail-code:${code}`).digest('hex');
102
+ export function emailCodeMatches(hash: string | undefined, code: string): boolean {
103
+ const digits = code.replace(/\s/g, '');
104
+ if (!hash || !/^\d{6}$/.test(digits)) return false;
105
+ return timingSafeEqual(Buffer.from(emailCodeHash(digits)), Buffer.from(hash));
106
+ }
107
+
108
+ /**
109
+ * Where an e-mail code would go for this account in this application: the account's address and the application's
110
+ * mail service, or null (not offered). Only for an application that allows it and an account with another factor.
111
+ */
112
+ export async function emailCodeTarget(a: { id: number; mfa_email?: boolean | null }, accountIdValue: number): Promise<{ to: string; from: string } | null> {
113
+ if (!a.mfa_email || !(await storeHasFactor(ACCOUNTS, accountIdValue))) return null;
114
+ const r = await owner.one<{ email: string | null; from_address: string | null; ok: boolean }>(
115
+ `select ac.email, ams.from_address, coalesce(s.enabled and meta.mail_sender_allowed(s.senders, ams.from_address), false) as ok
116
+ from meta.account ac
117
+ left join meta.app_mail_service ams on ams.app_id = $2
118
+ left join meta.mail_service s on s.id = ams.service_id
119
+ where ac.id = $1 and ac.active`, [accountIdValue, a.id]);
120
+ const to = r?.email?.trim();
121
+ return r?.ok && to && r.from_address?.trim() ? { to, from: r.from_address.trim() } : null;
122
+ }
123
+
124
+ /**
125
+ * Queue a sign-in code mail (the application's mail service, its daily limit). Returns false when the account had
126
+ * too many this hour or the limit is reached; the caller says nothing more specific (no hint about the address).
127
+ */
128
+ export async function queueEmailCode(a: { id: number }, username: string, target: { to: string; from: string }, subject: string, text: string): Promise<boolean> {
129
+ return owner.tx(async (c) => {
130
+ const recent = (await c.query<{ n: number }>(
131
+ `select count(*)::int as n from meta.mail_queue where template = 'SIGNIN_CODE' and lower(username) = lower($1) and created_at > now() - interval '1 hour'`,
132
+ [username])).rows[0].n;
133
+ if (recent >= EMAIL_CODES_PER_HOUR) return false;
134
+ const over = (await c.query<{ over: boolean }>(
135
+ `select ams.max_per_day is not null and (select count(*) from meta.mail_queue q where q.app_id = $1
136
+ and q.created_at >= date_trunc('day', now() at time zone 'UTC') at time zone 'UTC' and q.status <> 'cancelled') >= ams.max_per_day as over
137
+ from meta.app_mail_service ams where ams.app_id = $1`, [a.id])).rows[0]?.over;
138
+ if (over) return false;
139
+ const id = (await c.query<{ id: string }>(
140
+ `insert into meta.mail_queue (app_id, username, from_address, to_addresses, subject, body_text, template)
141
+ values ($1, $2, $3, array[$4], left($5, 998), $6, 'SIGNIN_CODE') returning id`,
142
+ [a.id, username, target.from, target.to, subject.replace(/[\r\n]+/g, ' '), text])).rows[0].id;
143
+ await c.query(`select pg_notify('pgkiln_mail', $1)`, [String(id)]);
144
+ return true;
145
+ });
146
+ }
147
+
148
+ // ---------------------------------------------------------------- REST API tokens (sprint 131)
149
+
150
+ /** Minutes a second step may be old for a user to create their own API token (when the app requires one of them). */
151
+ export const TOKEN_STEP_MINUTES = 15;
152
+
153
+ /** Whether the application requires a second step of this account (by its roles in the application). */
154
+ export async function accountMustUseMfa(a: MfaApp & { id: number }, username: string): Promise<boolean> {
155
+ if (!mfaOn(a)) return false;
156
+ const roles = (await owner.query<{ roles: string[] }>(
157
+ `select aa.roles from meta.app_access aa join meta.account ac on ac.id = aa.account_id where aa.app_id = $1 and lower(ac.username) = lower($2)`,
158
+ [a.id, username])).rows[0]?.roles ?? [];
159
+ return mfaRequired(a, roles);
160
+ }
161
+
162
+ /** Remove one of the account's authenticator apps (and the recovery codes with the last one). */
163
+ export async function removeAccountFactor(id: number, factorId: number): Promise<boolean> {
164
+ return owner.tx(async (c) => {
165
+ const gone = (await c.query('delete from meta.account_factor where id = $1 and account_id = $2', [factorId, id])).rowCount;
166
+ if (gone && !(await c.query('select 1 from meta.account_factor where account_id = $1', [id])).rowCount)
167
+ await c.query('delete from meta.account_recovery_code where account_id = $1', [id]);
168
+ return !!gone;
169
+ });
170
+ }
171
+
172
+ /** (Manage users) Remove every second factor of an account and end its sessions and remembered sign-ins. */
173
+ export async function resetAccountFactors(id: number): Promise<boolean> {
174
+ return owner.tx(async (c) => {
175
+ const acc = (await c.query<{ username: string }>('select username from meta.account where id = $1', [id])).rows[0];
176
+ if (!acc) return false;
177
+ await c.query('delete from meta.account_factor where account_id = $1', [id]);
178
+ await c.query('delete from meta.account_recovery_code where account_id = $1', [id]);
179
+ await c.query('delete from meta.persistent_login where account_id = $1', [id]);
180
+ await c.query('delete from meta.shared_login where account_id = $1', [id]);
181
+ await c.query('delete from meta.session where app_id is not null and lower(username) = lower($1)', [acc.username]);
182
+ return true;
183
+ });
184
+ }
package/src/api.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { SignJWT } from 'jose';
2
+ import { accountMustUseMfa } from './account-mfa.ts';
2
3
  import { owner } from './db.ts';
3
4
 
4
5
  // Tokens for the REST API that PostgREST serves next to pgkiln. They're
@@ -47,9 +48,11 @@ export async function apiStatus(): Promise<{ ok: boolean; detail: string }> {
47
48
  * active and have access to the app. Roles are not put in the token:
48
49
  * meta.has_role() reads them from meta.app_access at each request, and
49
50
  * meta.api_check() rejects the token once the account loses access.
51
+ * (sprint 131) `self`: the user asked for it on My account (two-step.ts checked the second step); `internal`: the
52
+ * token never leaves the server (the builder's Try it).
50
53
  */
51
- export async function issueApiToken(appId: number, username: string, hours: number) {
52
- const app = await owner.one('select alias, api_role, access_control from meta.app where id = $1', [appId]);
54
+ export async function issueApiToken(appId: number, username: string, hours: number, opts: { self?: boolean; internal?: boolean } = {}) {
55
+ const app = await owner.one('select id, alias, api_role, access_control, authentication, mfa, mfa_roles from meta.app where id = $1', [appId]);
53
56
  if (!app) throw new Error('No such application.');
54
57
  // without an API role the token is for pgkiln's own REST modules only (they run as the app's role)
55
58
  const problem = app.api_role ? await apiRoleProblem(app.api_role) : null;
@@ -63,6 +66,10 @@ export async function issueApiToken(appId: number, username: string, hours: numb
63
66
  if (!acc) throw new Error(`There is no account "${username}".`);
64
67
  if (!acc.active) throw new Error('This account is inactive.');
65
68
  if (!acc.has_access && app.access_control !== 'any_user') throw new Error('This account has no access to the application.');
69
+ // (sprint 131, SEC-3) a user who must sign in in two steps gets a token only from their own sign-in, after a second
70
+ // step (My account, src/runtime/two-step.ts), never one a developer issues for them
71
+ if (!opts.self && !opts.internal && (await accountMustUseMfa(app, acc.username)))
72
+ throw new Error(`The application requires two-factor sign-in of "${acc.username}": they create their own on My account, after a second step (REST API → Users create their own tokens).`);
66
73
  const ttl = Math.max(1, Math.min(MAX_TOKEN_HOURS, Math.round(hours)));
67
74
  const token = await new SignJWT({ ...(app.api_role ? { role: app.api_role } : {}), app_user: acc.username, app: app.alias })
68
75
  .setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
@@ -187,6 +187,14 @@ export async function advise(appId: number, tx?: pg.ClientBase, opts: { db?: Db
187
187
  else if (!all.some((x) => x.kind === 'region' && x.row.type === 'login' && Number(x.row.page_no) === Number(app.login_page)))
188
188
  findings.push({ severity: 'error', entry: null, field, message: `Page ${app.login_page} has no Login region: nobody can sign in.` });
189
189
  }
190
+ // (sprint 130, SEC-3) administrators of the application who sign in with only a password
191
+ if (app.authentication === 'app_users' && (app.local_login || (app.ldap_directories ?? []).length)) {
192
+ const covered = app.mfa === 'required' || (app.mfa === 'roles' && (app.mfa_roles ?? []).includes('admin'));
193
+ const admins = covered ? 0 : (await one(`select count(*)::int as n from meta.app_access where app_id = $1 and 'admin' = any(roles)`, [appId])).n;
194
+ if (admins)
195
+ findings.push({ severity: 'warning', entry: null, field: 'Application settings → Two-factor sign-in',
196
+ message: `${admins === 1 ? 'A user' : `${admins} users`} with the admin role sign${admins === 1 ? 's' : ''} in with only a password: require two-factor sign-in (Required, or Required for roles: admin).` });
197
+ }
190
198
  // (089) a page's group exists
191
199
  for (const p of (await query(`select p.page_no, p.page_group from meta.page p where p.app_id = $1 and p.page_group is not null
192
200
  and not exists (select 1 from meta.page_group g where g.app_id = p.app_id and g.name = p.page_group) order by 1`, [appId])).rows)
@@ -123,6 +123,8 @@ async function apiPage(s: Session, a: any, issued?: { token: string; username: s
123
123
  <form method="post" action="${BASE}/apps/${a.id}/api">${csrf(s)}
124
124
  <div class="form-grid">
125
125
  ${input('api_role', 'API database role', role, { placeholder: 'e.g. myapp_api', help: 'For PostgREST: grant it only the api schema’s views and functions, and grant it to pgkiln_authenticator. Empty: tokens work for the REST modules pgkiln serves (Shared Components → REST modules) only.' })}
126
+ <div class="field"><span class="label" aria-hidden="true"></span><label class="check"><input type="checkbox" name="api_self_tokens" value="true"${a.api_self_tokens ? raw(' checked') : ''}> Users create their own tokens</label>
127
+ <small class="help">On <em>My account</em>, signed-in users create a token that acts as them. A user the application requires two-factor sign-in of gives a second step first (if their last one is older than 15 minutes); for such users this is the only way to get a token.</small></div>
126
128
  </div>
127
129
  <div class="buttons"><button class="btn btn-hot">Save</button></div>
128
130
  </form>
@@ -171,7 +173,7 @@ export async function apiRoutes(app: FastifyInstance) {
171
173
  try {
172
174
  const problem = role ? await apiRoleProblem(role) : null;
173
175
  if (problem) throw new Error(problem);
174
- await owner.query('update meta.app set api_role = $2, updated_at = now() where id = $1', [req.params.id, role]);
176
+ await owner.query('update meta.app set api_role = $2, api_self_tokens = $3, updated_at = now() where id = $1', [req.params.id, role, req.body?.api_self_tokens === 'true']);
175
177
  flash(s, role ? `API role set to ${role}.` : 'API role removed; new tokens work for the REST modules pgkiln serves only.');
176
178
  } catch (e) {
177
179
  flash(s, (e as Error).message, 'error');
@@ -420,6 +420,7 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
420
420
  { name: 'type', label: 'Type', kind: 'select', options: ['role', 'sql'] },
421
421
  { name: 'value', label: 'Role name / SQL expression', kind: 'code', wide: true, help: "role: e.g. admin · sql: e.g. meta.has_role('manager') or :APP_USER = 'boss'" },
422
422
  { name: 'error_message', label: 'Error message', kind: 'text', wide: true },
423
+ { name: 'mfa_minutes', label: 'Second step within (minutes)', kind: 'int', help: 'Step-up: the scheme passes only when the user gave a second factor (code or passkey) at most this many minutes ago; a page that needs it asks again, then opens. Users without one are refused. Empty: no step-up.' },
423
424
  ],
424
425
  },
425
426
  lov: {
@@ -1870,4 +1870,10 @@ export const de: BuilderMessages = {
1870
1870
  'wsp.sso_off': 'Die Entwickler des Workspace dürfen wieder das pgkiln-Kennwort verwenden.',
1871
1871
  'wsp.sso_no_break_glass': 'Geben Sie zuerst einem Administrator Notfallzugang (Entwickler), damit sich jemand anmelden kann, wenn der Identitätsanbieter ausfällt.',
1872
1872
  'wsp.sso_link_first': 'Verknüpfen Sie zuerst auf Ihrer Sicherheitsseite eine Identität, sonst könnten Sie sich selbst nicht anmelden.',
1873
+ 'us.mfa_title': 'Zwei-Faktor-Anmeldung',
1874
+ 'us.mfa_status': 'Authenticator-Apps: {n}. Verbleibende Wiederherstellungscodes: {codes}.',
1875
+ 'us.mfa_none': 'Noch keine Authenticator-App.',
1876
+ 'us.mfa_reset_help': 'Entfernt die Authenticator-Apps und Wiederherstellungscodes von {user} und meldet das Konto überall ab. Wo eine Anwendung die Zwei-Faktor-Anmeldung verlangt, wird bei der nächsten Anmeldung eine neue App hinzugefügt. Wird protokolliert.',
1877
+ 'us.mfa_reset_done': 'Die Zwei-Faktor-Anmeldung von {user} wurde zurückgesetzt.',
1878
+ 'us.mfa_reason_needed': 'Geben Sie einen Grund für das Zurücksetzen an.',
1873
1879
  };
@@ -1873,4 +1873,10 @@ export const en = {
1873
1873
  'wsp.sso_off': 'The workspace’s developers may use the pgkiln password again.',
1874
1874
  'wsp.sso_no_break_glass': 'Make an administrator break-glass first (Developers), so someone can still sign in when the identity provider fails.',
1875
1875
  'wsp.sso_link_first': 'Link an identity on your Security page first, or you could not sign in yourself.',
1876
+ 'us.mfa_title': 'Two-factor sign-in',
1877
+ 'us.mfa_status': 'Authenticator apps: {n}. Recovery codes left: {codes}.',
1878
+ 'us.mfa_none': 'No authenticator app yet.',
1879
+ 'us.mfa_reset_help': 'Removes the authenticator apps and recovery codes of {user} and signs them out everywhere. Where an application requires two-factor sign-in, they add a new app at their next sign-in. Logged.',
1880
+ 'us.mfa_reset_done': 'Two-factor sign-in of {user} was reset.',
1881
+ 'us.mfa_reason_needed': 'Give a reason for the reset.',
1876
1882
  } as const;
@@ -1870,4 +1870,10 @@ export const es: BuilderMessages = {
1870
1870
  'wsp.sso_off': 'Los desarrolladores del espacio de trabajo pueden volver a usar la contraseña de pgkiln.',
1871
1871
  'wsp.sso_no_break_glass': 'Primero dé acceso de emergencia a un administrador (Desarrolladores), para que alguien pueda entrar si falla el proveedor de identidad.',
1872
1872
  'wsp.sso_link_first': 'Vincule primero una identidad en su página de seguridad, o usted mismo no podría iniciar sesión.',
1873
+ 'us.mfa_title': 'Inicio de sesión en dos pasos',
1874
+ 'us.mfa_status': 'Aplicaciones de autenticación: {n}. Códigos de recuperación restantes: {codes}.',
1875
+ 'us.mfa_none': 'Aún no hay ninguna aplicación de autenticación.',
1876
+ 'us.mfa_reset_help': 'Quita las aplicaciones de autenticación y los códigos de recuperación de {user} y cierra todas sus sesiones. Donde una aplicación exija el inicio de sesión en dos pasos, añadirá una aplicación nueva en su próximo inicio de sesión. Queda registrado.',
1877
+ 'us.mfa_reset_done': 'Se restableció el inicio de sesión en dos pasos de {user}.',
1878
+ 'us.mfa_reason_needed': 'Indique un motivo para el restablecimiento.',
1873
1879
  };
@@ -1870,4 +1870,10 @@ export const fr: BuilderMessages = {
1870
1870
  'wsp.sso_off': 'Les développeurs de l’espace de travail peuvent de nouveau utiliser le mot de passe pgkiln.',
1871
1871
  'wsp.sso_no_break_glass': 'Donnez d’abord l’accès de secours à un administrateur (Développeurs), pour que quelqu’un puisse se connecter si le fournisseur d’identité tombe en panne.',
1872
1872
  'wsp.sso_link_first': 'Liez d’abord une identité sur votre page Sécurité, sinon vous ne pourriez plus vous connecter.',
1873
+ 'us.mfa_title': 'Connexion à deux facteurs',
1874
+ 'us.mfa_status': 'Applications d’authentification : {n}. Codes de récupération restants : {codes}.',
1875
+ 'us.mfa_none': 'Aucune application d’authentification pour l’instant.',
1876
+ 'us.mfa_reset_help': 'Supprime les applications d’authentification et les codes de récupération de {user} et le déconnecte partout. Là où une application exige la connexion à deux facteurs, une nouvelle application est ajoutée à la prochaine connexion. Journalisé.',
1877
+ 'us.mfa_reset_done': 'La connexion à deux facteurs de {user} a été réinitialisée.',
1878
+ 'us.mfa_reason_needed': 'Indiquez la raison de la réinitialisation.',
1873
1879
  };
@@ -1870,4 +1870,10 @@ export const nl: BuilderMessages = {
1870
1870
  'wsp.sso_off': 'De ontwikkelaars van de werkruimte mogen weer het pgkiln-wachtwoord gebruiken.',
1871
1871
  'wsp.sso_no_break_glass': 'Geef eerst een beheerder noodtoegang (Ontwikkelaars), zodat iemand kan aanmelden als de identiteitsprovider uitvalt.',
1872
1872
  'wsp.sso_link_first': 'Koppel eerst een identiteit op uw beveiligingspagina, anders kunt u zelf niet aanmelden.',
1873
+ 'us.mfa_title': 'Aanmelden in twee stappen',
1874
+ 'us.mfa_status': 'Authenticator-apps: {n}. Resterende herstelcodes: {codes}.',
1875
+ 'us.mfa_none': 'Nog geen authenticator-app.',
1876
+ 'us.mfa_reset_help': 'Verwijdert de authenticator-apps en herstelcodes van {user} en meldt het account overal af. Waar een applicatie aanmelden in twee stappen vereist, voegt de gebruiker bij de volgende aanmelding een nieuwe app toe. Wordt gelogd.',
1877
+ 'us.mfa_reset_done': 'Aanmelden in twee stappen van {user} is gereset.',
1878
+ 'us.mfa_reason_needed': 'Geef een reden voor de reset.',
1873
1879
  };
@@ -1870,4 +1870,10 @@ export const pt: BuilderMessages = {
1870
1870
  'wsp.sso_off': 'Os desenvolvedores do espaço de trabalho podem voltar a usar a senha do pgkiln.',
1871
1871
  'wsp.sso_no_break_glass': 'Primeiro dê acesso de emergência a um administrador (Desenvolvedores), para que alguém possa entrar se o provedor de identidade falhar.',
1872
1872
  'wsp.sso_link_first': 'Vincule primeiro uma identidade na sua página de segurança, ou você mesmo não conseguiria entrar.',
1873
+ 'us.mfa_title': 'Login em duas etapas',
1874
+ 'us.mfa_status': 'Aplicativos autenticadores: {n}. Códigos de recuperação restantes: {codes}.',
1875
+ 'us.mfa_none': 'Nenhum aplicativo autenticador ainda.',
1876
+ 'us.mfa_reset_help': 'Remove os aplicativos autenticadores e os códigos de recuperação de {user} e encerra todas as sessões. Onde um aplicativo exigir login em duas etapas, um novo aplicativo será adicionado no próximo login. Registrado.',
1877
+ 'us.mfa_reset_done': 'O login em duas etapas de {user} foi redefinido.',
1878
+ 'us.mfa_reason_needed': 'Informe um motivo para a redefinição.',
1873
1879
  };
@@ -1,12 +1,15 @@
1
1
  import type { FastifyInstance } from 'fastify';
2
2
  import { owner } from '../db.ts';
3
+ import { APP_MFA, EMAIL_CODE_MINUTES, EMAIL_CODES_PER_HOUR, type AppMfa } from '../account-mfa.ts';
3
4
  import { html, raw } from '../html.ts';
5
+ import { secretKeyConfigured } from '../secrets.ts';
4
6
  import { back, BASE, developer, flash, type Req } from './ui.ts';
5
7
 
6
8
  // Application settings → Sign-in methods (migration 087): the application's
7
9
  // own login page (APEX: the login page, usually 9999) and "Forgot password?".
8
10
  // "Create login page" adds a minimal public page with a login region and
9
- // makes it the login page.
11
+ // makes it the login page. (sprint 130, SEC-3) Two-factor sign-in for the
12
+ // application's users: off, optional, required, or required for some roles. (sprint 131) A code by e-mail.
10
13
 
11
14
  interface AppRow {
12
15
  id: number;
@@ -15,6 +18,9 @@ interface AppRow {
15
18
  local_login: boolean;
16
19
  login_page: number | null;
17
20
  password_reset: boolean;
21
+ mfa: AppMfa;
22
+ mfa_roles: string[];
23
+ mfa_email: boolean;
18
24
  }
19
25
 
20
26
  /** The fields in the settings form. */
@@ -41,14 +47,37 @@ export async function loginSettingsFields(a: AppRow) {
41
47
  ? html`Sent through mail service ${mail!.service} as ${mail!.from_address}.`
42
48
  : html`<strong>Not offered yet:</strong> the application needs an enabled mail service with a From address (Workspace utilities → Mail services).`}
43
49
  Only for the user directory's own passwords: not for LDAP, single sign-on, database accounts or custom authentication.</small></div>
44
- </div>`;
50
+ </div>
51
+ <h3 id="two-factor">Two-factor sign-in</h3>
52
+ <div class="form-grid">
53
+ <div class="field"><label class="label" for="f_mfa">Second step</label>
54
+ <select id="f_mfa" name="mfa" aria-describedby="f_mfa_help">${(APP_MFA as string[]).map((m) => opt(m, MFA_LABELS[m as AppMfa], a.mfa))}</select>
55
+ <small class="help" id="f_mfa_help">After the password (or an LDAP directory), users give a code from an authenticator app or a recovery code. They add the app on <em>My account</em>, or while signing in when it is required. An identity provider asks for its own second factor. Accounts share their apps across applications.
56
+ ${secretKeyConfigured() ? '' : html`<strong>Not available yet:</strong> the server needs <code>PGKILN_SECRET_KEY</code> to keep the apps' secrets.`}</small></div>
57
+ <div class="field"><label class="label" for="f_mfa_roles">Required for roles</label>
58
+ <input id="f_mfa_roles" name="mfa_roles" value="${(a.mfa_roles ?? []).join(', ')}" placeholder="admin, manager" aria-describedby="f_mfa_roles_help">
59
+ <small class="help" id="f_mfa_roles_help">With <em>Required for some roles</em>: users with one of these roles must use it, others may.</small></div>
60
+ <div class="field"><span class="label" aria-hidden="true"></span><label class="check"><input type="checkbox" name="mfa_email" value="true"${a.mfa_email ? raw(' checked') : ''}> Offer a code by e-mail</label>
61
+ <small class="help">At the second step, users who have an authenticator app or a passkey may ask for a one-time code by e-mail instead (valid ${EMAIL_CODE_MINUTES} minutes, at most ${EMAIL_CODES_PER_HOUR} an hour). It is never someone's only factor: a user who must add one still adds an app or a passkey.
62
+ ${mailOk ? '' : html`<strong>Not offered yet:</strong> the application needs an enabled mail service with a From address.`}</small></div>
63
+ </div>
64
+ <p class="muted small">Users also add <strong>passkeys</strong> (fingerprint, face, PIN or a security key) on <em>My account</em> or while signing in; they need the application at a host name, not an IP address. To ask for the second step again before a page opens (step-up), give its authorization scheme <em>Second step within (minutes)</em> in Shared Components.</p>`;
45
65
  }
46
66
 
67
+ const MFA_LABELS: Record<AppMfa, string> = { off: 'Off', optional: 'Optional (users may add an app)', required: 'Required', roles: 'Required for some roles' };
68
+
47
69
  /** Save them (with the rest of the settings form). */
48
70
  export async function saveLoginSettings(appId: string, b: Record<string, string | undefined>) {
49
71
  const page = b.login_page?.trim() ? Number(b.login_page) : null;
50
72
  if (page !== null && !(await owner.one('select 1 from meta.page where app_id = $1 and page_no = $2', [appId, page]))) throw new Error(`Login page: there is no page ${b.login_page}.`);
51
- await owner.query('update meta.app set login_page = $2, password_reset = $3 where id = $1', [appId, page, b.password_reset === 'true']);
73
+ const mfa = (APP_MFA as string[]).includes(b.mfa ?? '') ? (b.mfa as AppMfa) : 'off';
74
+ const roles = [...new Set((b.mfa_roles ?? '').split(',').map((r) => r.trim().toLowerCase()).filter(Boolean))];
75
+ if (roles.some((r) => r.length > 100)) throw new Error('Two-factor sign-in: a role name is too long.');
76
+ if (mfa === 'roles' && !roles.length) throw new Error('Two-factor sign-in: name the roles that must use it.');
77
+ // without the instance key, users could not add an app: requiring one would lock them out
78
+ if ((mfa === 'required' || mfa === 'roles') && !secretKeyConfigured()) throw new Error('Two-factor sign-in: the server needs PGKILN_SECRET_KEY before it can be required.');
79
+ await owner.query('update meta.app set login_page = $2, password_reset = $3, mfa = $4, mfa_roles = $5, mfa_email = $6 where id = $1',
80
+ [appId, page, b.password_reset === 'true', mfa, roles, b.mfa_email === 'true']);
52
81
  }
53
82
 
54
83
  export async function loginPageRoutes(app: FastifyInstance) {
@@ -599,7 +599,7 @@ export async function restModuleRoutes(app: FastifyInstance) {
599
599
  const headers: Record<string, string> = {};
600
600
  if (as) {
601
601
  if (!(await tryAccounts(m.app_id)).includes(as)) throw new Error('Choose an account with access to the application.');
602
- headers.authorization = `Bearer ${(await issueApiToken(m.app_id, as, 1)).token}`;
602
+ headers.authorization = `Bearer ${(await issueApiToken(m.app_id, as, 1, { internal: true })).token}`;
603
603
  }
604
604
  const raw = h.method === 'GET' || h.method === 'DELETE' ? '' : String(b.body ?? '').trim();
605
605
  if (raw) {
@@ -11,7 +11,7 @@ import {
11
11
  import { qrSvg } from '../qrcode.ts';
12
12
  import { secretKeyConfigured } from '../secrets.ts';
13
13
  import { clientIp, logActivity, saveState, type Session } from '../session.ts';
14
- import { ALGORITHMS, newChallenge, relyingParty, verifyRegistration, WebAuthnError } from '../webauthn.ts';
14
+ import { ALGORITHMS, giveChallenge, relyingParty, takeChallenge, verifyRegistration, WebAuthnError } from '../webauthn.ts';
15
15
  import { tr } from './i18n.ts';
16
16
  import { isAdmin } from './locks.ts';
17
17
  import { identitiesRegion } from './builder-sso.ts';
@@ -28,24 +28,8 @@ import { back, BASE, csrf, developer, flash, input, region, send, shell, type Re
28
28
  // (public/builder.js, [data-passkey]); without JavaScript the button stays hidden. Trusted browsers can be
29
29
  // forgotten here; the instance decides how long a browser may be trusted (0 = never).
30
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
- }
31
+ // (sprint 131) the challenge helpers moved to src/webauthn.ts: application users have passkeys too
32
+ export { giveChallenge, takeChallenge };
49
33
 
50
34
  const SECRET_MINUTES = 10;
51
35
 
@@ -156,7 +156,8 @@ export async function sharedRoutes(app: FastifyInstance) {
156
156
  rows[kind] = (await owner.query(`select * from ${COMPONENTS[kind].table} where app_id = $1 order by ${ORDER[kind] ?? 'name'}`, [a.id])).rows;
157
157
  const users = (
158
158
  await owner.query(
159
- `select ac.id, ac.username, ac.display_name, ac.active, ac.last_login_at, aa.roles
159
+ `select ac.id, ac.username, ac.display_name, ac.active, ac.last_login_at, aa.roles,
160
+ exists (select 1 from meta.account_factor f where f.account_id = ac.id) as mfa
160
161
  from meta.app_access aa join meta.account ac on ac.id = aa.account_id
161
162
  where aa.app_id = $1 order by lower(ac.username)`,
162
163
  [a.id],
@@ -247,7 +248,7 @@ export async function sharedRoutes(app: FastifyInstance) {
247
248
  <button class="btn u-selfend">${tx('sc.save')}</button>
248
249
  </form>
249
250
  <p class="muted">${tx.rich('sc.access_help', { directory: html`<a href="${BASE}/users">${tx('sc.directory')}</a>`, app: html`<b>${a.name}</b>`, fn: html`<code>meta.has_role()</code>` })}</p>
250
- <div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('sc.account')}</th><th>${tx('sc.roles_in_app')}</th><th>${tx('sc.last_signin')}</th><th></th></tr></thead><tbody>
251
+ <div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('sc.account')}</th><th>${tx('sc.roles_in_app')}</th><th>${tx('sc.last_signin')}</th><th>${tx('mfa.column')}</th><th></th></tr></thead><tbody>
251
252
  ${users.length
252
253
  ? users.map((u) => html`<tr>
253
254
  <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>
@@ -255,9 +256,10 @@ export async function sharedRoutes(app: FastifyInstance) {
255
256
  <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
257
  ${roleHintsHtml(appHints, tx('us.hints_add'), tx)}</form></td>
257
258
  <td data-label="${tx('sc.last_signin')}">${u.last_login_at ? String(u.last_login_at).slice(0, 16) : '—'}</td>
259
+ <td data-label="${tx('mfa.column')}">${u.mfa ? html`${icon('lock')} ${tx('mfa.on')}` : html`<span class="muted">${tx('mfa.off')}</span>`}</td>
258
260
  <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
261
  </tr>`)
260
- : html`<tr><td colspan="4" class="empty">${tx('sc.no_access')}</td></tr>`}
262
+ : html`<tr><td colspan="5" class="empty">${tx('sc.no_access')}</td></tr>`}
261
263
  </tbody></table></div>
262
264
  ${groupMap}
263
265
  <h3>${tx('sc.grant')}</h3>
@@ -4,6 +4,9 @@ import { owner } from '../db.ts';
4
4
  import { appAllowed } from './workspaces.ts';
5
5
  import { html, raw } from '../html.ts';
6
6
  import { accountSettings, clearAccountSettings, passwordProblem } from '../accounts.ts';
7
+ import { accountCodesLeft, accountFactors, resetAccountFactors } from '../account-mfa.ts';
8
+ import { icon } from '../icons.ts';
9
+ import { clientIp, logActivity } from '../session.ts';
7
10
  import { loginMaxFailuresPerUser, loginWindowMinutes } from '../security.ts';
8
11
  import { acsUrl, metadataUrl, spEntityId } from '../saml.ts';
9
12
  import { discover, publicUrl, redirectUri } from '../sso.ts';
@@ -107,7 +110,8 @@ export async function usersRoutes(app: FastifyInstance) {
107
110
  const users = (
108
111
  await owner.query(
109
112
  `select a.*, coalesce((select jsonb_agg(jsonb_build_object('alias', p.alias, 'roles', aa.roles) order by p.alias)
110
- from meta.app_access aa join meta.app p on p.id = aa.app_id where aa.account_id = a.id), '[]') as apps
113
+ from meta.app_access aa join meta.app p on p.id = aa.app_id where aa.account_id = a.id), '[]') as apps,
114
+ exists (select 1 from meta.account_factor f where f.account_id = a.id) as mfa
111
115
  from meta.account a
112
116
  where $1 = '' or a.username ilike '%' || $1 || '%' or a.display_name ilike '%' || $1 || '%' or a.email ilike '%' || $1 || '%'
113
117
  order by lower(a.username)
@@ -125,16 +129,17 @@ export async function usersRoutes(app: FastifyInstance) {
125
129
  <input type="search" name="q" value="${q}" placeholder="${tx('us.search_ph')}" aria-label="${tx('us.search_label')}"><button class="btn">${tx('us.search')}</button>
126
130
  </form>
127
131
  <div class="table-wrap"><table class="report report-reflow">
128
- <thead><tr><th>${tx('us.username')}</th><th>${tx('us.name')}</th><th>${tx('us.apps_roles')}</th><th>${tx('us.signin')}</th><th>${tx('us.status')}</th></tr></thead>
132
+ <thead><tr><th>${tx('us.username')}</th><th>${tx('us.name')}</th><th>${tx('us.apps_roles')}</th><th>${tx('us.signin')}</th><th>${tx('mfa.column')}</th><th>${tx('us.status')}</th></tr></thead>
129
133
  <tbody>${users.length
130
134
  ? users.map((u) => html`<tr>
131
135
  <td data-label="${tx('us.username')}"><a href="${BASE}/users/${u.id}">${u.username}</a></td>
132
136
  <td data-label="${tx('us.name')}">${u.display_name ?? ''}${u.email ? html`<div class="muted">${u.email}</div>` : ''}</td>
133
137
  <td data-label="${tx('us.applications')}">${u.apps.length ? u.apps.map((x: any) => html`<span class="chip">${x.alias}${x.roles.length ? `: ${x.roles.join(', ')}` : ''}</span> `) : html`<span class="muted">${tx('us.none')}</span>`}</td>
134
138
  <td data-label="${tx('us.signin')}">${u.password_hash ? tx('us.password') : html`<span class="muted">${tx('us.no_password')}</span>`}</td>
139
+ <td data-label="${tx('mfa.column')}">${u.mfa ? html`${icon('lock')} ${tx('mfa.on')}` : html`<span class="muted">${tx('mfa.off')}</span>`}</td>
135
140
  <td data-label="${tx('us.status')}">${u.active ? tx('us.active') : html`<b>${tx('us.inactive')}</b>`}${u.last_login_at ? html`<div class="muted">${tx('us.last', { at: String(u.last_login_at).slice(0, 16) })}</div>` : ''}</td>
136
141
  </tr>`)
137
- : html`<tr><td colspan="5" class="empty">${tx('us.no_accounts')}</td></tr>`}</tbody>
142
+ : html`<tr><td colspan="6" class="empty">${tx('us.no_accounts')}</td></tr>`}</tbody>
138
143
  </table></div>`)}
139
144
  ${region(tx('us.create'), html`
140
145
  <form method="post" action="${BASE}/users">${csrf(s)}
@@ -215,6 +220,7 @@ export async function usersRoutes(app: FastifyInstance) {
215
220
  owner.query(`select id, name, alias from meta.app where id not in (select app_id from meta.app_access where account_id = $1) order by name`, [u.id]),
216
221
  ]);
217
222
  const hints = await roleHints([...access.rows.map((a) => a.app_id), ...apps.rows.map((a) => a.id)]);
223
+ const [factors, codes] = await Promise.all([accountFactors(u.id), accountCodesLeft(u.id)]);
218
224
  const main = html`
219
225
  <div class="title-row"><h1>${u.username}</h1></div>
220
226
  <div class="columns">
@@ -244,6 +250,13 @@ export async function usersRoutes(app: FastifyInstance) {
244
250
  <div class="buttons"><button class="btn">${tx('us.set_password')}</button>
245
251
  ${u.password_hash ? html`<button class="btn" name="remove" value="1" data-confirm="${tx('us.remove_confirm')}">${tx('us.remove_password')}</button>` : ''}</div>
246
252
  </form>
253
+ <h3 id="two-factor">${tx('us.mfa_title')}</h3>
254
+ <p>${factors.length ? tx('us.mfa_status', { n: factors.length, codes }) : tx('us.mfa_none')}</p>
255
+ ${factors.length
256
+ ? html`<form method="post" action="${BASE}/users/${u.id}/reset-2fa" class="danger-zone">${csrf(s)}
257
+ <div class="form-grid">${input('reason', tx('mfa.reason'), '', { required: true, help: tx('us.mfa_reset_help', { user: u.username }) })}</div>
258
+ <div class="buttons"><button class="btn btn-danger">${tx('mfa.reset_btn')}</button></div></form>`
259
+ : ''}
247
260
  <form method="post" action="${BASE}/users/${u.id}/delete" class="danger-zone">${csrf(s)}
248
261
  <button class="btn btn-danger" data-confirm="${tx('us.delete_confirm', { user: u.username })}">${tx('us.delete')}</button>
249
262
  </form>`)}
@@ -326,6 +339,24 @@ export async function usersRoutes(app: FastifyInstance) {
326
339
  return back(reply, s, `${BASE}/users/${req.params.id}`);
327
340
  });
328
341
 
342
+ // (sprint 130, SEC-3) remove an account's authenticator apps and recovery codes (a lost phone), with a reason
343
+ app.post(`${BASE}/users/:id(^\\d+$)/reset-2fa`, async (req: Req, reply) => {
344
+ const s = await developer(req, reply);
345
+ if (!s) return;
346
+ const tx = tr(s);
347
+ const reason = String(req.body?.reason ?? '').trim().slice(0, 200);
348
+ const acc = await owner.one<{ id: number; username: string }>('select id, username from meta.account where id = $1', [req.params.id]);
349
+ if (!acc) return reply.code(404).send('Not found');
350
+ if (!reason) {
351
+ flash(s, tx('us.mfa_reason_needed'), 'error');
352
+ return back(reply, s, `${BASE}/users/${acc.id}#two-factor`);
353
+ }
354
+ await resetAccountFactors(acc.id);
355
+ await logActivity({ username: acc.username, event: '2fa_reset', ip: clientIp(req), detail: `by ${s.username}: ${reason}` });
356
+ flash(s, tx('us.mfa_reset_done', { user: acc.username }));
357
+ return back(reply, s, `${BASE}/users/${acc.id}`);
358
+ });
359
+
329
360
  app.post(`${BASE}/users/:id(^\\d+$)/delete`, async (req: Req, reply) => {
330
361
  const s = await developer(req, reply);
331
362
  if (!s) return;