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.
- package/SECURITY.md +2 -0
- package/db/migrations/132_app_user_mfa.sql +58 -0
- package/db/migrations/133_app_user_passkeys_stepup.sql +54 -0
- package/docs/apex-feature-parity.md +6 -1
- package/docs/guide/08-security.md +81 -5
- package/docs/guide/09-reference.md +5 -1
- package/docs/guide/11-from-apex.md +2 -1
- package/docs/guide/12-development.md +13 -1
- package/docs/guide/13-rest-api.md +4 -0
- package/package.json +1 -1
- package/public/app.css +10 -0
- package/public/app.js +63 -0
- package/public/builder.js +1 -62
- package/src/account-mfa.ts +184 -0
- package/src/api.ts +9 -2
- package/src/builder/advisor.ts +8 -0
- package/src/builder/api.ts +3 -1
- package/src/builder/components.ts +1 -0
- package/src/builder/i18n/de.ts +6 -0
- package/src/builder/i18n/en.ts +6 -0
- package/src/builder/i18n/es.ts +6 -0
- package/src/builder/i18n/fr.ts +6 -0
- package/src/builder/i18n/nl.ts +6 -0
- package/src/builder/i18n/pt.ts +6 -0
- package/src/builder/login-page.ts +32 -3
- package/src/builder/rest-modules.ts +1 -1
- package/src/builder/security.ts +3 -19
- package/src/builder/shared.ts +5 -3
- package/src/builder/users.ts +34 -3
- package/src/i18n/ar.ts +65 -0
- package/src/i18n/cs.ts +65 -0
- package/src/i18n/da.ts +65 -0
- package/src/i18n/de.ts +65 -0
- package/src/i18n/el.ts +65 -0
- package/src/i18n/es.ts +65 -0
- package/src/i18n/fi.ts +65 -0
- package/src/i18n/fr.ts +65 -0
- package/src/i18n/he.ts +65 -0
- package/src/i18n/it.ts +65 -0
- package/src/i18n/ja.ts +65 -0
- package/src/i18n/ko.ts +65 -0
- package/src/i18n/nb.ts +65 -0
- package/src/i18n/pl.ts +65 -0
- package/src/i18n/pt.ts +65 -0
- package/src/i18n/ru.ts +65 -0
- package/src/i18n/sv.ts +65 -0
- package/src/i18n/tr.ts +65 -0
- package/src/i18n/uk.ts +65 -0
- package/src/i18n/zh.ts +65 -0
- package/src/i18n.ts +130 -0
- package/src/metadata.ts +11 -2
- package/src/mfa.ts +94 -65
- package/src/remember.ts +1 -1
- package/src/runtime/account.ts +7 -1
- package/src/runtime/authz.ts +23 -4
- package/src/runtime/routes.ts +21 -7
- package/src/runtime/two-step.ts +702 -0
- package/src/sharedlogin.ts +1 -1
- package/src/webauthn.ts +20 -0
package/src/mfa.ts
CHANGED
|
@@ -94,6 +94,75 @@ export function newRecoveryCode(): string {
|
|
|
94
94
|
export const normaliseCode = (code: string) => code.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
95
95
|
export const hashCode = (code: string) => createHash('sha256').update(normaliseCode(code)).digest('hex');
|
|
96
96
|
|
|
97
|
+
// ---------------------------------------------------------------- where factors are kept
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* (sprint 130, SEC-3) The tables of one kind of user: developers (128, by user name) or application accounts
|
|
101
|
+
* (132, by account id). The code checks, recovery codes and the "each step once" rule are the same for both.
|
|
102
|
+
*/
|
|
103
|
+
export interface FactorStore { factors: string; codes: string; key: string }
|
|
104
|
+
export const DEVELOPERS: FactorStore = { factors: 'meta.developer_factor', codes: 'meta.developer_recovery_code', key: 'username' };
|
|
105
|
+
export const ACCOUNTS: FactorStore = { factors: 'meta.account_factor', codes: 'meta.account_recovery_code', key: 'account_id' };
|
|
106
|
+
type Who = string | number;
|
|
107
|
+
|
|
108
|
+
export async function storeCodesLeft(st: FactorStore, who: Who): Promise<number> {
|
|
109
|
+
return (await owner.one<{ n: number }>(`select count(*)::int as n from ${st.codes} where ${st.key} = $1 and confirmed and used_at is null`, [who]))!.n;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function storeHasFactor(st: FactorStore, who: Who): Promise<boolean> {
|
|
113
|
+
return !!(await owner.one(`select 1 as ok from ${st.factors} where ${st.key} = $1`, [who]));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function storeAddTotp(st: FactorStore, who: Who, name: string, secret: string, step: number) {
|
|
117
|
+
await owner.query(
|
|
118
|
+
`insert into ${st.factors} (${st.key}, kind, name, secret_enc, last_step, last_used_at) values ($1, 'totp', $2, $3, $4, now())`,
|
|
119
|
+
[who, name.trim().slice(0, 60) || 'Authenticator', encryptSecret(secret), step],
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function storeNewCodes(st: FactorStore, who: Who): Promise<string[]> {
|
|
124
|
+
const codes = Array.from({ length: RECOVERY_CODES }, newRecoveryCode);
|
|
125
|
+
await owner.tx(async (c) => {
|
|
126
|
+
await c.query(`delete from ${st.codes} where ${st.key} = $1`, [who]);
|
|
127
|
+
for (const code of codes) await c.query(`insert into ${st.codes} (${st.key}, code_hash) values ($1, $2)`, [who, hashCode(code)]);
|
|
128
|
+
});
|
|
129
|
+
return codes;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function storeConfirmCodes(st: FactorStore, who: Who) {
|
|
133
|
+
await owner.query(`update ${st.codes} set confirmed = true where ${st.key} = $1 and used_at is null`, [who]);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Check a second-factor code: an authenticator code (each step once) or a confirmed, unused recovery code
|
|
138
|
+
* (then used up). Returns what matched, or null.
|
|
139
|
+
*/
|
|
140
|
+
export async function storeCheck(st: FactorStore, who: Who, code: string): Promise<{ kind: 'totp' | 'recovery'; name?: string } | null> {
|
|
141
|
+
const digits = code.replace(/\s/g, '');
|
|
142
|
+
if (/^\d{6}$/.test(digits)) {
|
|
143
|
+
const rows = (await owner.query<{ id: number; name: string; secret_enc: string; last_step: string | null }>(
|
|
144
|
+
`select id, name, secret_enc, last_step from ${st.factors} where ${st.key} = $1 and kind = 'totp'`, [who])).rows;
|
|
145
|
+
for (const f of rows) {
|
|
146
|
+
let secret: Buffer;
|
|
147
|
+
try {
|
|
148
|
+
secret = unbase32(decryptSecret(f.secret_enc));
|
|
149
|
+
} catch {
|
|
150
|
+
continue; // the instance key changed: recovery codes still work
|
|
151
|
+
}
|
|
152
|
+
const step = matchTotp(secret, digits, f.last_step === null ? null : Number(f.last_step));
|
|
153
|
+
// the update only wins once per step, also with two requests at the same time
|
|
154
|
+
if (step !== null && (await owner.query(`update ${st.factors} set last_step = $2, last_used_at = now() where id = $1 and (last_step is null or last_step < $2)`, [f.id, step])).rowCount)
|
|
155
|
+
return { kind: 'totp', name: f.name };
|
|
156
|
+
}
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
if (normaliseCode(code).length !== 10) return null;
|
|
160
|
+
const used = await owner.query(
|
|
161
|
+
`update ${st.codes} set used_at = now() where ${st.key} = $1 and code_hash = $2 and confirmed and used_at is null`,
|
|
162
|
+
[who, hashCode(code)]);
|
|
163
|
+
return used.rowCount ? { kind: 'recovery' } : null;
|
|
164
|
+
}
|
|
165
|
+
|
|
97
166
|
// ---------------------------------------------------------------- the developer's factors
|
|
98
167
|
|
|
99
168
|
export interface Factor { id: number; kind: 'totp' | 'passkey'; name: string; created_at: string; last_used_at: string | null; rp_id: string | null }
|
|
@@ -103,44 +172,45 @@ export async function factorsOf(username: string, db: Pick<pg.ClientBase, 'query
|
|
|
103
172
|
}
|
|
104
173
|
|
|
105
174
|
/** Recovery codes left (confirmed, unused). */
|
|
106
|
-
export
|
|
107
|
-
return (await owner.one<{ n: number }>('select count(*)::int as n from meta.developer_recovery_code where username = $1 and confirmed and used_at is null', [username]))!.n;
|
|
108
|
-
}
|
|
175
|
+
export const codesLeft = (username: string) => storeCodesLeft(DEVELOPERS, username);
|
|
109
176
|
|
|
110
177
|
/** Whether the developer has a working second factor (an authenticator app; recovery codes back it up). */
|
|
111
|
-
export
|
|
112
|
-
return !!(await owner.one('select 1 as ok from meta.developer_factor where username = $1', [username]));
|
|
113
|
-
}
|
|
178
|
+
export const hasFactor = (username: string) => storeHasFactor(DEVELOPERS, username);
|
|
114
179
|
|
|
115
180
|
/** Add an authenticator app (after the developer confirmed a code from it). */
|
|
116
|
-
export
|
|
117
|
-
await owner.query(
|
|
118
|
-
`insert into meta.developer_factor (username, kind, name, secret_enc, last_step, last_used_at) values ($1, 'totp', $2, $3, $4, now())`,
|
|
119
|
-
[username, name.trim().slice(0, 60) || 'Authenticator', encryptSecret(secret), step],
|
|
120
|
-
);
|
|
121
|
-
}
|
|
181
|
+
export const addTotp = (username: string, name: string, secret: string, step: number) => storeAddTotp(DEVELOPERS, username, name, secret, step);
|
|
122
182
|
|
|
123
183
|
// ---------------------------------------------------------------- passkeys (sprint 127)
|
|
124
184
|
|
|
125
185
|
export interface Passkey { id: number; name: string; credential_id: string; public_key: Record<string, string>; alg: number; sign_count: string; transports: string[] }
|
|
126
186
|
|
|
127
187
|
/** Add a passkey (after src/webauthn.ts checked the browser's answer). */
|
|
128
|
-
export async function
|
|
188
|
+
export async function storeAddPasskey(st: FactorStore, who: Who, name: string, p: { credentialId: string; jwk: Record<string, string>; alg: number; signCount: number; rpId: string; transports: string[] }) {
|
|
129
189
|
await owner.query(
|
|
130
|
-
`insert into
|
|
190
|
+
`insert into ${st.factors} (${st.key}, kind, name, credential_id, public_key, alg, sign_count, rp_id, transports, last_used_at)
|
|
131
191
|
values ($1, 'passkey', $2, $3, $4, $5, $6, $7, $8, now())`,
|
|
132
|
-
[
|
|
192
|
+
[who, name.trim().slice(0, 60) || 'Passkey', p.credentialId, JSON.stringify(p.jwk), p.alg, p.signCount, p.rpId,
|
|
133
193
|
p.transports.filter((t) => /^[a-z-]{1,20}$/.test(t)).slice(0, 8)],
|
|
134
194
|
);
|
|
135
195
|
}
|
|
136
196
|
|
|
137
|
-
/** The
|
|
138
|
-
export async function
|
|
197
|
+
/** The passkeys for this relying party (a passkey only works on the domain it was made for). */
|
|
198
|
+
export async function storePasskeys(st: FactorStore, who: Who, rpId: string): Promise<Passkey[]> {
|
|
139
199
|
return (await owner.query<Passkey>(
|
|
140
|
-
`select id, name, credential_id, public_key, alg, sign_count, transports from
|
|
141
|
-
where
|
|
200
|
+
`select id, name, credential_id, public_key, alg, sign_count, transports from ${st.factors}
|
|
201
|
+
where ${st.key} = $1 and kind = 'passkey' and rp_id = $2 order by id`, [who, rpId])).rows;
|
|
142
202
|
}
|
|
143
203
|
|
|
204
|
+
/** Record a sign-in with a passkey; false when another sign-in moved the counter first (a replay or a clone). */
|
|
205
|
+
export async function storeUsePasskey(st: FactorStore, id: number, oldCount: string, newCount: number): Promise<boolean> {
|
|
206
|
+
return !!(await owner.query(
|
|
207
|
+
`update ${st.factors} set sign_count = $3, last_used_at = now() where id = $1 and sign_count = $2::bigint`,
|
|
208
|
+
[id, oldCount, newCount])).rowCount;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export const addPasskey = (username: string, name: string, p: Parameters<typeof storeAddPasskey>[3]) => storeAddPasskey(DEVELOPERS, username, name, p);
|
|
212
|
+
export const passkeysOf = (username: string, rpId: string) => storePasskeys(DEVELOPERS, username, rpId);
|
|
213
|
+
|
|
144
214
|
/** (sprint 128) The passkey with this credential id for this relying party, and whose it is (sign-in without a user name). */
|
|
145
215
|
export async function passkeyByCredential(credentialId: string, rpId: string): Promise<(Passkey & { username: string }) | null> {
|
|
146
216
|
if (!credentialId || credentialId.length > 1400) return null;
|
|
@@ -161,12 +231,7 @@ export async function setPasskeySignin(on: boolean) {
|
|
|
161
231
|
await owner.query(`insert into meta.setting (name, value) values ('mfa_passkey_signin', $1) on conflict (name) do update set value = excluded.value`, [on ? 'on' : 'off']);
|
|
162
232
|
}
|
|
163
233
|
|
|
164
|
-
|
|
165
|
-
export async function usePasskey(id: number, oldCount: string, newCount: number): Promise<boolean> {
|
|
166
|
-
return !!(await owner.query(
|
|
167
|
-
'update meta.developer_factor set sign_count = $3, last_used_at = now() where id = $1 and sign_count = $2::bigint',
|
|
168
|
-
[id, oldCount, newCount])).rowCount;
|
|
169
|
-
}
|
|
234
|
+
export const usePasskey = (id: number, oldCount: string, newCount: number) => storeUsePasskey(DEVELOPERS, id, oldCount, newCount);
|
|
170
235
|
|
|
171
236
|
// ---------------------------------------------------------------- trusted browsers (sprint 127)
|
|
172
237
|
|
|
@@ -211,48 +276,12 @@ export async function forgetBrowsers(username: string) {
|
|
|
211
276
|
}
|
|
212
277
|
|
|
213
278
|
/** New recovery codes (the old ones stop working); shown once, counted only when confirmed. */
|
|
214
|
-
export
|
|
215
|
-
const codes = Array.from({ length: RECOVERY_CODES }, newRecoveryCode);
|
|
216
|
-
await owner.tx(async (c) => {
|
|
217
|
-
await c.query('delete from meta.developer_recovery_code where username = $1', [username]);
|
|
218
|
-
for (const code of codes) await c.query('insert into meta.developer_recovery_code (username, code_hash) values ($1, $2)', [username, hashCode(code)]);
|
|
219
|
-
});
|
|
220
|
-
return codes;
|
|
221
|
-
}
|
|
279
|
+
export const newRecoveryCodes = (username: string) => storeNewCodes(DEVELOPERS, username);
|
|
222
280
|
|
|
223
|
-
export
|
|
224
|
-
await owner.query('update meta.developer_recovery_code set confirmed = true where username = $1 and used_at is null', [username]);
|
|
225
|
-
}
|
|
281
|
+
export const confirmRecoveryCodes = (username: string) => storeConfirmCodes(DEVELOPERS, username);
|
|
226
282
|
|
|
227
|
-
/**
|
|
228
|
-
|
|
229
|
-
* recovery code (then used up). Returns what matched, or null.
|
|
230
|
-
*/
|
|
231
|
-
export async function checkSecondFactor(username: string, code: string): Promise<{ kind: 'totp' | 'recovery'; name?: string } | null> {
|
|
232
|
-
const digits = code.replace(/\s/g, '');
|
|
233
|
-
if (/^\d{6}$/.test(digits)) {
|
|
234
|
-
const rows = (await owner.query<{ id: number; name: string; secret_enc: string; last_step: string | null }>(
|
|
235
|
-
'select id, name, secret_enc, last_step from meta.developer_factor where username = $1 and kind = $2', [username, 'totp'])).rows;
|
|
236
|
-
for (const f of rows) {
|
|
237
|
-
let secret: Buffer;
|
|
238
|
-
try {
|
|
239
|
-
secret = unbase32(decryptSecret(f.secret_enc));
|
|
240
|
-
} catch {
|
|
241
|
-
continue; // the instance key changed: recovery codes still work
|
|
242
|
-
}
|
|
243
|
-
const step = matchTotp(secret, digits, f.last_step === null ? null : Number(f.last_step));
|
|
244
|
-
// the update only wins once per step, also with two requests at the same time
|
|
245
|
-
if (step !== null && (await owner.query('update meta.developer_factor set last_step = $2, last_used_at = now() where id = $1 and (last_step is null or last_step < $2)', [f.id, step])).rowCount)
|
|
246
|
-
return { kind: 'totp', name: f.name };
|
|
247
|
-
}
|
|
248
|
-
return null;
|
|
249
|
-
}
|
|
250
|
-
if (normaliseCode(code).length !== 10) return null;
|
|
251
|
-
const used = await owner.query(
|
|
252
|
-
'update meta.developer_recovery_code set used_at = now() where username = $1 and code_hash = $2 and confirmed and used_at is null',
|
|
253
|
-
[username, hashCode(code)]);
|
|
254
|
-
return used.rowCount ? { kind: 'recovery' } : null;
|
|
255
|
-
}
|
|
283
|
+
/** Check a developer's second-factor code at sign-in (see storeCheck). */
|
|
284
|
+
export const checkSecondFactor = (username: string, code: string) => storeCheck(DEVELOPERS, username, code);
|
|
256
285
|
|
|
257
286
|
// ---------------------------------------------------------------- policy
|
|
258
287
|
|
package/src/remember.ts
CHANGED
|
@@ -56,7 +56,7 @@ export async function useRemember(req: FastifyRequest, reply: FastifyReply, a: R
|
|
|
56
56
|
);
|
|
57
57
|
if (!row || !row.active) return clear(), null;
|
|
58
58
|
// an expired password needs the password form; access may have been withdrawn
|
|
59
|
-
if (row.method === 'password' && (await passwordDaysLeft(row.username)) === 0) return clear(), null;
|
|
59
|
+
if (row.method.split('+')[0] === 'password' && (await passwordDaysLeft(row.username)) === 0) return clear(), null;
|
|
60
60
|
const access = await ssoAccess(a.id, row.username, row.groups);
|
|
61
61
|
if (!access.allowed) return clear(), null;
|
|
62
62
|
return { username: row.username, roles: access.roles, groups: row.groups, method: row.method, expiresAt: new Date(row.expires_at) };
|
package/src/runtime/account.ts
CHANGED
|
@@ -12,11 +12,15 @@ import { chrome, clientTexts } from './render.ts';
|
|
|
12
12
|
import { appStyles, choosable, chosenStyleName, styleChoice } from './styles.ts';
|
|
13
13
|
import { loadApp } from '../metadata.ts';
|
|
14
14
|
import { pushSection } from './push.ts';
|
|
15
|
+
import { relyingParty } from '../webauthn.ts';
|
|
16
|
+
import { tokenSection, twoStepSection } from './two-step.ts';
|
|
15
17
|
import { appWithLocale, loadContext, safeNext, txContext, type Req } from './routes.ts';
|
|
16
18
|
|
|
17
19
|
// "My account": details, own password, and preferences (light/dark and
|
|
18
20
|
// language). APEX has the APIs for this (APEX_UTIL.CHANGE_CURRENT_USER_PW,
|
|
19
|
-
// theme style per user); pgkiln provides the page itself.
|
|
21
|
+
// theme style per user); pgkiln provides the page itself. (sprint 130)
|
|
22
|
+
// Two-step sign-in: authenticator apps and recovery codes (two-step.ts). (sprint 131) Passkeys, and a REST API
|
|
23
|
+
// token the user creates (when the application allows it).
|
|
20
24
|
|
|
21
25
|
/** Keep a signed-in user's style variant for this app (meta.account_style; '' = the base colours). */
|
|
22
26
|
async function saveStyle(appId: number, username: string, style: string) {
|
|
@@ -115,6 +119,8 @@ async function accountPage(ctx: PageContext, reply: FastifyReply, error?: string
|
|
|
115
119
|
<form method="post" action="${ctx.base}/account/devices">${csrf}<button class="btn">${t('account.forget_devices')}</button></form>
|
|
116
120
|
</div></section>`
|
|
117
121
|
: ''}
|
|
122
|
+
${await twoStepSection(ctx, relyingParty(reply.request).rpId)}
|
|
123
|
+
${await tokenSection(ctx)}
|
|
118
124
|
${await pushSection(ctx)}
|
|
119
125
|
</div>
|
|
120
126
|
</div>
|
package/src/runtime/authz.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
|
+
import { secondStepWithin } from '../account-mfa.ts';
|
|
1
2
|
import { applyBinds } from '../binds.ts';
|
|
3
|
+
import type { AuthzScheme } from '../metadata.ts';
|
|
2
4
|
import { savepoint } from '../db.ts';
|
|
3
5
|
import { bindValues, publicError, stripSemicolon, type PageContext, type Visibility } from './context.ts';
|
|
4
6
|
import { saveButton, saveRequest } from './grid.ts';
|
|
5
7
|
import { selectionOf } from './report.ts';
|
|
6
8
|
|
|
7
9
|
export class Forbidden extends Error {}
|
|
10
|
+
/** (sprint 131) The page needs a second step more recent than the session's: ask for one, then come back. */
|
|
11
|
+
export class StepUp extends Forbidden {}
|
|
8
12
|
|
|
9
13
|
/**
|
|
10
14
|
* Evaluate an authorization reference ("ADMIN", "!ADMIN", or the built-in
|
|
@@ -20,15 +24,28 @@ export async function isAuthorized(ctx: PageContext, ref: string | null | undefi
|
|
|
20
24
|
if (name === 'MUST_NOT_BE_PUBLIC_USER') ok = ctx.user !== 'nobody';
|
|
21
25
|
else {
|
|
22
26
|
const scheme = ctx.app.authz_schemes.find((s) => s.name === name);
|
|
23
|
-
|
|
24
|
-
else if (scheme.type === 'role') ok = ctx.roles.includes(scheme.value.toLowerCase());
|
|
25
|
-
else ok = await sqlTrue(ctx, scheme.value, `authorization ${name}`);
|
|
27
|
+
ok = !!scheme && (await schemePasses(ctx, scheme)) && (!scheme.mfa_minutes || secondStepWithin(ctx.session?.state ?? {}, scheme.mfa_minutes));
|
|
26
28
|
}
|
|
27
29
|
ctx.authzCache.set(name, ok);
|
|
28
30
|
}
|
|
29
31
|
return negate ? !ok : ok;
|
|
30
32
|
}
|
|
31
33
|
|
|
34
|
+
async function schemePasses(ctx: PageContext, scheme: AuthzScheme): Promise<boolean> {
|
|
35
|
+
if (scheme.type === 'role') return ctx.roles.includes(scheme.value.toLowerCase());
|
|
36
|
+
return sqlTrue(ctx, scheme.value, `authorization ${scheme.name}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* (sprint 131) Whether a reference fails only for its step-up: a scheme with mfa_minutes that the user passes, in a
|
|
41
|
+
* session whose last second step is too old (or never happened).
|
|
42
|
+
*/
|
|
43
|
+
export async function onlyStepUpMissing(ctx: PageContext, ref: string | null | undefined): Promise<boolean> {
|
|
44
|
+
if (!ref || ref.startsWith('!') || ctx.user === 'nobody') return false;
|
|
45
|
+
const scheme = ctx.app.authz_schemes.find((s) => s.name === ref.trim().toUpperCase());
|
|
46
|
+
return !!scheme?.mfa_minutes && !secondStepWithin(ctx.session?.state ?? {}, scheme.mfa_minutes) && (await schemePasses(ctx, scheme));
|
|
47
|
+
}
|
|
48
|
+
|
|
32
49
|
/**
|
|
33
50
|
* Evaluate a SQL boolean expression with binds. Errors yield `onError`
|
|
34
51
|
* (false by default: a broken condition hides a region or button).
|
|
@@ -51,7 +68,9 @@ export function authzMessage(ctx: PageContext, ref: string | null) {
|
|
|
51
68
|
}
|
|
52
69
|
|
|
53
70
|
export async function checkPageAccess(ctx: PageContext) {
|
|
54
|
-
if (
|
|
71
|
+
if (await isAuthorized(ctx, ctx.page.authz)) return;
|
|
72
|
+
if (await onlyStepUpMissing(ctx, ctx.page.authz)) throw new StepUp(authzMessage(ctx, ctx.page.authz));
|
|
73
|
+
throw new Forbidden(authzMessage(ctx, ctx.page.authz));
|
|
55
74
|
}
|
|
56
75
|
|
|
57
76
|
/** Can the current user open this page? Used to hide links and menu entries. */
|
package/src/runtime/routes.ts
CHANGED
|
@@ -18,7 +18,7 @@ import { english, type Translate } from '../i18n.ts';
|
|
|
18
18
|
import { checksumValid, loginWindowMinutes, urlChecksum } from '../security.ts';
|
|
19
19
|
import { builderSsoFinish, enabledProviders, finishSignIn, loadProvider, ssoAccess, SsoError, startSignIn, type Provider, type SsoResult } from '../sso.ts';
|
|
20
20
|
import { sessionLimits, clientIp, createSession, destroySession, getSession, loginThrottled, logActivity, saveState, takeFlash, type Session } from '../session.ts';
|
|
21
|
-
import { checkPageAccess, computeVisibility, Forbidden, isAuthorized } from './authz.ts';
|
|
21
|
+
import { checkPageAccess, computeVisibility, Forbidden, isAuthorized, StepUp } from './authz.ts';
|
|
22
22
|
import { bindValues, dbg, publicError, stripSemicolon, timed, toState, writeOut, type PageContext } from './context.ts';
|
|
23
23
|
import { startDebug } from '../debug.ts';
|
|
24
24
|
import { assignable, clearPageItems, fetchForms, processConditionHolds, ProcessFailed, runAppProcesses, runProcesses, runSql, validate, ValidationFailed } from './engine.ts';
|
|
@@ -48,6 +48,8 @@ import { resolveLocale, THEME_COOKIE, themeCookieOptions, translateApp, translat
|
|
|
48
48
|
import { chrome, dialogClosePage, languagePicker, renderPage } from './render.ts';
|
|
49
49
|
import { LOGIN_ERROR, LOGIN_USER, safeNext, signInForm } from './signin.ts';
|
|
50
50
|
import { passwordResetRoutes } from './password-reset.ts';
|
|
51
|
+
import { passwordSignIn, twoStepRoutes } from './two-step.ts';
|
|
52
|
+
import { carriesSecondStep } from '../account-mfa.ts';
|
|
51
53
|
import { devbarOf, devbarRoutes, sessionDebugLevel } from './devbar.ts';
|
|
52
54
|
import { availabilityRefusal } from './availability.ts';
|
|
53
55
|
import { feedbackRoutes } from './feedback.ts';
|
|
@@ -229,7 +231,8 @@ export async function loadContext(req: Req, reply: FastifyReply, { json = false,
|
|
|
229
231
|
// the session ended, but the browser was remembered: sign in again silently (a new token each time)
|
|
230
232
|
if (app.authentication === 'app_users' && !session.username) {
|
|
231
233
|
const remembered = await useRemember(req, reply, app);
|
|
232
|
-
|
|
234
|
+
// (sprint 130) one made before the application asked this user for a second step doesn't stand in for it
|
|
235
|
+
if (remembered && (await carriesSecondStep(app, remembered.username, remembered.roles, remembered.method)))
|
|
233
236
|
session = await signIn(req, reply, app, session, remembered.username, {
|
|
234
237
|
extraRoles: remembered.roles, groups: remembered.groups, method: remembered.method,
|
|
235
238
|
remember: true, rememberUntil: remembered.expiresAt, detail: 'remember me',
|
|
@@ -238,7 +241,7 @@ export async function loadContext(req: Req, reply: FastifyReply, { json = false,
|
|
|
238
241
|
// (067) another application of the session sharing group was signed in to: sign in here too
|
|
239
242
|
if (!session.username && sharingGroup(app)) {
|
|
240
243
|
const shared = await useShared(req, reply, app);
|
|
241
|
-
if (shared)
|
|
244
|
+
if (shared && (await carriesSecondStep(app, shared.username, shared.roles, shared.method)))
|
|
242
245
|
session = await signIn(req, reply, app, session, shared.username, {
|
|
243
246
|
extraRoles: shared.roles, groups: shared.groups, method: shared.method, shared: shared.hash, detail: 'shared sign-in',
|
|
244
247
|
});
|
|
@@ -313,6 +316,13 @@ export async function loadContext(req: Req, reply: FastifyReply, { json = false,
|
|
|
313
316
|
};
|
|
314
317
|
}
|
|
315
318
|
|
|
319
|
+
/** (sprint 131) Step-up: the page's authorization needs a recent second step; ask for it, then come back. */
|
|
320
|
+
async function stepUp(ctx: PageContext, reply: FastifyReply, next: string) {
|
|
321
|
+
dbg(ctx, 2, 'access', `step-up: a second step is needed for page ${ctx.page.page_no}`);
|
|
322
|
+
await saveState(ctx.session);
|
|
323
|
+
return reply.redirect(`${ctx.base}/login/2fa?next=${encodeURIComponent(next)}`, 303);
|
|
324
|
+
}
|
|
325
|
+
|
|
316
326
|
async function forbidden(ctx: PageContext, reply: FastifyReply, message: string, detail: string) {
|
|
317
327
|
dbg(ctx, 2, 'access', `refused (403): ${detail}`);
|
|
318
328
|
logActivity({ appId: ctx.app.id, pageNo: ctx.page.page_no, username: ctx.user, event: 'forbidden', ip: ctx.ip, detail });
|
|
@@ -484,6 +494,7 @@ export async function runtimeRoutes(app: FastifyInstance) {
|
|
|
484
494
|
return { html: await timed(ctx, 4, 'page', 'render page', () => renderPage(ctx)) };
|
|
485
495
|
});
|
|
486
496
|
} catch (e) {
|
|
497
|
+
if (e instanceof StepUp) return stepUp(ctx, reply, req.url);
|
|
487
498
|
if (e instanceof Forbidden) return forbidden(ctx, reply, e.message, `page ${ctx.page.page_no}`);
|
|
488
499
|
throw e;
|
|
489
500
|
}
|
|
@@ -580,6 +591,8 @@ export async function runtimeRoutes(app: FastifyInstance) {
|
|
|
580
591
|
return pressed;
|
|
581
592
|
});
|
|
582
593
|
} catch (e) {
|
|
594
|
+
// the second step expired while the page was open: ask again, then back to the page (the input is lost)
|
|
595
|
+
if (e instanceof StepUp) return stepUp(ctx, reply, `${ctx.base}/${ctx.page.page_no}`);
|
|
583
596
|
if (e instanceof Forbidden) return forbidden(ctx, reply, e.message, `forged or unavailable request "${body.__request}" on page ${ctx.page.page_no}`);
|
|
584
597
|
if (e instanceof ValidationFailed) {
|
|
585
598
|
ctx.errors = e.errors;
|
|
@@ -619,6 +632,7 @@ export async function runtimeRoutes(app: FastifyInstance) {
|
|
|
619
632
|
fileRoutes(app, loadContext, txContext, forbidden);
|
|
620
633
|
// (087) "Forgot password?"
|
|
621
634
|
passwordResetRoutes(app);
|
|
635
|
+
twoStepRoutes(app);
|
|
622
636
|
// (079) the developer toolbar's session state viewer and actions
|
|
623
637
|
devbarRoutes(app, loadContext);
|
|
624
638
|
// (084) users' feedback
|
|
@@ -1272,7 +1286,7 @@ export async function runtimeRoutes(app: FastifyInstance) {
|
|
|
1272
1286
|
logActivity({ appId: a.id, username: account, event: 'login_failed', ip, detail: `ldap:${d.name}: no access` });
|
|
1273
1287
|
return fail(locale.t('login.no_access', { user: account, app: a.name }), 403);
|
|
1274
1288
|
}
|
|
1275
|
-
return
|
|
1289
|
+
return passwordSignIn(req, reply, a, session, account, {
|
|
1276
1290
|
next, remember, extraRoles: access.roles, groups: user.groups, method: `ldap:${d.name}`, detail: `ldap:${d.name}`,
|
|
1277
1291
|
});
|
|
1278
1292
|
}
|
|
@@ -1283,7 +1297,7 @@ export async function runtimeRoutes(app: FastifyInstance) {
|
|
|
1283
1297
|
logActivity({ appId: a.id, username: r.username, event: 'password_expired', ip });
|
|
1284
1298
|
return reply.type('text/html').send(expiredPage(a, locale, session, r.username, safeNext(a, next)));
|
|
1285
1299
|
}
|
|
1286
|
-
return
|
|
1300
|
+
return passwordSignIn(req, reply, a, session, r.username, { next, remember });
|
|
1287
1301
|
});
|
|
1288
1302
|
|
|
1289
1303
|
app.post('/a/:alias/password', async (req: Req, reply) => {
|
|
@@ -1309,7 +1323,7 @@ export async function runtimeRoutes(app: FastifyInstance) {
|
|
|
1309
1323
|
}
|
|
1310
1324
|
logActivity({ appId: a.id, username, event: 'password_changed', ip });
|
|
1311
1325
|
const canonical = (await runtime.one<{ username: string }>('select username from meta.account where lower(username) = lower($1)', [username]))!.username;
|
|
1312
|
-
return
|
|
1326
|
+
return passwordSignIn(req, reply, a, session, canonical, { next });
|
|
1313
1327
|
});
|
|
1314
1328
|
|
|
1315
1329
|
// ---------------------------------------------------------------- single sign-on (OpenID Connect)
|
|
@@ -1374,7 +1388,7 @@ export async function runtimeRoutes(app: FastifyInstance) {
|
|
|
1374
1388
|
return simplePage(reply, 403, locale.t('login.no_access_title'), locale.t('login.no_access', { user: result.username, app: a.name }), `/a/${a.alias}/login`, locale);
|
|
1375
1389
|
}
|
|
1376
1390
|
const session = await getSession(req, reply, a.id, `/a/${a.alias}`);
|
|
1377
|
-
return completeLogin(req, reply, a, session, result.username, { extraRoles: access.roles, next: result.next ?? undefined, detail: `sso:${p.name}` });
|
|
1391
|
+
return completeLogin(req, reply, a, session, result.username, { extraRoles: access.roles, next: result.next ?? undefined, method: `sso:${p.name}`, detail: `sso:${p.name}` });
|
|
1378
1392
|
};
|
|
1379
1393
|
|
|
1380
1394
|
// SAML: the IdP posts the response cross-site, where SameSite=Lax cookies aren't sent; post it on
|