pgkiln 0.61.0 → 0.63.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 +4 -1
- package/db/migrations/130_passkey_signin_workspace_mfa.sql +20 -0
- package/db/migrations/131_builder_sso.sql +69 -0
- package/docs/apex-feature-parity.md +5 -4
- package/docs/guide/03-builder.md +63 -1
- package/docs/guide/08-security.md +2 -1
- package/docs/guide/12-development.md +3 -0
- package/docs/guide/18-cli.md +2 -1
- package/package.json +1 -1
- package/public/builder.css +5 -0
- package/public/builder.js +3 -1
- package/src/builder/builder-sso.ts +135 -0
- package/src/builder/i18n/de.ts +62 -0
- package/src/builder/i18n/en.ts +62 -0
- package/src/builder/i18n/es.ts +62 -0
- package/src/builder/i18n/fr.ts +62 -0
- package/src/builder/i18n/nl.ts +62 -0
- package/src/builder/i18n/pt.ts +62 -0
- package/src/builder/ldap.ts +2 -0
- package/src/builder/routes.ts +213 -30
- package/src/builder/security.ts +17 -12
- package/src/builder/ui.ts +14 -3
- package/src/builder/users.ts +2 -0
- package/src/builder/workspaces.ts +58 -3
- package/src/cli/main.ts +25 -9
- package/src/devsso.ts +149 -0
- package/src/mfa.ts +58 -3
- package/src/runtime/routes.ts +14 -3
- package/src/saml.ts +13 -7
- package/src/sso.ts +37 -7
package/src/builder/routes.ts
CHANGED
|
@@ -27,10 +27,16 @@ import { appFromFileRoutes } from './appfromfile.ts';
|
|
|
27
27
|
import { clearDevbar } from '../runtime/devbar.ts';
|
|
28
28
|
import { EMAIL } from './find-workspace.ts';
|
|
29
29
|
import { cookieLanguage, LANG_COOKIE, tr, translatorFor, validBuilderLanguage } from './i18n.ts';
|
|
30
|
-
import { checkSecondFactor, codesLeft, forgetBrowsers, hasFactor, isTrustedBrowser, leavesTooFewAdmins, passkeysOf, trustBrowser, trustDays, usePasskey } from '../mfa.ts';
|
|
30
|
+
import { checkSecondFactor, codesLeft, forgetBrowsers, hasFactor, isTrustedBrowser, leavesTooFewAdmins, passkeyByCredential, passkeySignin, passkeysOf, trustBrowser, trustDays, usePasskey, userHandle } from '../mfa.ts';
|
|
31
|
+
import { isIP } from 'node:net';
|
|
31
32
|
import { giveChallenge, takeChallenge } from './security.ts';
|
|
32
33
|
import { relyingParty, verifyAssertion, WebAuthnError } from '../webauthn.ts';
|
|
33
34
|
import { chooseLanguage, langCookieOptions, languageRoutes, signInLanguageLinks } from './language.ts';
|
|
35
|
+
import { builderSsoRoutes, leavesNoBreakGlass } from './builder-sso.ts';
|
|
36
|
+
import { builderDirectory, builderProvider, builderSources, linkDeveloper, resolveDeveloper, ssoRequired } from '../devsso.ts';
|
|
37
|
+
import { ldapAuthenticate, LdapError } from '../ldap.ts';
|
|
38
|
+
import { onBuilderSso, SsoError, startSignIn } from '../sso.ts';
|
|
39
|
+
import { startSamlSignIn } from '../saml.ts';
|
|
34
40
|
|
|
35
41
|
// Builder pages: sign-in, workspace and app home, settings, activity and
|
|
36
42
|
// developers. Shared Components, the page designer and the SQL Workshop
|
|
@@ -67,6 +73,10 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
67
73
|
await saveState(s);
|
|
68
74
|
const r = remembered(req);
|
|
69
75
|
const next = safeNext(req.query?.next);
|
|
76
|
+
// (sprint 128) a passkey alone, when the instance allows it and the address is a host name
|
|
77
|
+
const alone = (await passkeySignin()) && !isIP(relyingParty(req).rpId);
|
|
78
|
+
// (sprint 129, SEC-1) identity providers and directories that sign developers in
|
|
79
|
+
const sources = await builderSources();
|
|
70
80
|
const field = (name: string, label: string, ic: string, value: string, opts: { type?: string; required?: boolean; auto?: string; placeholder?: string; reveal?: boolean }) =>
|
|
71
81
|
html`<div class="field signin-field"><label class="sr-only" for="f_${name}">${label}</label>
|
|
72
82
|
<div class="signin-input">${icon(ic)}<input id="f_${name}" name="${name}" type="${opts.type ?? 'text'}" value="${value}" placeholder="${opts.placeholder ?? label}"${opts.required ? raw(' required') : ''}${opts.auto ? raw(` autocomplete="${opts.auto}"`) : ''}>
|
|
@@ -88,10 +98,18 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
88
98
|
${error ? html`<div class="alert alert-error" role="alert">${error}</div>` : ''}
|
|
89
99
|
${csrf(s)}<input type="hidden" name="next" value="${next}">
|
|
90
100
|
${field('workspace', tx('signin.workspace'), 'database', r?.w ?? '', { placeholder: tx('signin.workspace_placeholder'), auto: 'organization' })}
|
|
101
|
+
${sources.directories.length ? html`<div class="field signin-field"><label class="sr-only" for="f_method">${tx('signin.method')}</label>
|
|
102
|
+
<div class="signin-input">${icon('server')}<select id="f_method" name="method" aria-label="${tx('signin.method')}">
|
|
103
|
+
<option value="">${tx('signin.method_pgkiln')}</option>
|
|
104
|
+
${sources.directories.map((d) => html`<option value="ldap:${d.name}">${d.display_name}</option>`)}</select></div></div>` : ''}
|
|
91
105
|
${field('username', tx('signin.username'), 'user', r?.u ?? '', { required: true, auto: 'username' })}
|
|
92
106
|
${field('password', tx('signin.password'), 'key', '', { type: 'password', required: true, auto: 'current-password', reveal: true })}
|
|
93
107
|
<label class="check signin-remember"><input type="checkbox" name="remember" value="1"${r ? raw(' checked') : ''}> ${tx('signin.remember')}</label>
|
|
94
108
|
<button class="btn btn-hot signin-submit">${tx('signin.submit')}</button>
|
|
109
|
+
${alone ? html`<input type="hidden" name="credentialId"><input type="hidden" name="clientDataJSON"><input type="hidden" name="authenticatorData"><input type="hidden" name="signature"><input type="hidden" name="userHandle">
|
|
110
|
+
<button class="btn signin-submit signin-passkey" hidden data-passkey="${BASE}/login/passkey/options" data-passkey-submit="${BASE}/login/passkey" data-passkey-error="${tx('mfa.passkey_failed')}">${icon('fingerprint')} ${tx('signin.passkey')}</button>
|
|
111
|
+
<p class="small" role="status" data-passkey-status></p>` : ''}
|
|
112
|
+
${sources.providers.map((p) => html`<a class="btn signin-submit signin-idp" href="${BASE}/sso/${p.name}${next === `${BASE}/home` ? '' : `?next=${encodeURIComponent(next)}`}">${icon('log-in')} ${tx('signin.with', { provider: p.display_name })}</a>`)}
|
|
95
113
|
<p class="muted small signin-hint">${tx('signin.hint')}</p>
|
|
96
114
|
<nav class="signin-actions" aria-label="${tx('signin.quick')}">
|
|
97
115
|
<a href="${BASE}/find-workspace">${icon('search')} ${tx('signin.find_ws')}</a>
|
|
@@ -129,45 +147,196 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
129
147
|
flash(s, tx('signin.throttled'), 'error');
|
|
130
148
|
return back(reply, s, again);
|
|
131
149
|
}
|
|
132
|
-
//
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
150
|
+
// (sprint 129, SEC-1) an LDAP directory checks the password; the developer is the one linked to the entry
|
|
151
|
+
const method = String(req.body?.method ?? '');
|
|
152
|
+
const directory = method.startsWith('ldap:') ? await builderDirectory(method.slice(5)) : undefined;
|
|
153
|
+
if (method && !directory) {
|
|
154
|
+
flash(s, tx('signin.expired'), 'error');
|
|
155
|
+
return back(reply, s, again);
|
|
156
|
+
}
|
|
157
|
+
let dev: { username: string; language: string | null } | undefined;
|
|
158
|
+
if (directory) {
|
|
159
|
+
let why = 'invalid';
|
|
160
|
+
try {
|
|
161
|
+
const u = await ldapAuthenticate(directory, username, password);
|
|
162
|
+
if (u) {
|
|
163
|
+
const name = await resolveDeveloper({ kind: 'directory', ...directory }, { subject: u.subject, username: u.username, name: u.displayName, email: u.email, mayLink: true, groups: u.groups });
|
|
164
|
+
dev = await owner.one('select username, language from meta.developer where username = $1', [name]);
|
|
165
|
+
}
|
|
166
|
+
} catch (e) {
|
|
167
|
+
if (!(e instanceof SsoError)) req.log.warn({ err: e, directory: directory.name }, 'builder ldap sign-in failed');
|
|
168
|
+
why = e instanceof SsoError ? e.message : e instanceof LdapError ? e.message : 'unreachable';
|
|
169
|
+
}
|
|
170
|
+
if (!dev) {
|
|
171
|
+
await logActivity({ username: username.slice(0, 100), event: 'login_failed', ip, detail: `builder ldap:${directory.name}: ${why}` });
|
|
172
|
+
flash(s, why === 'invalid' ? tx('signin.invalid') : why === 'unreachable' ? tx('signin.ldap_unavailable', { directory: directory.display_name }) : why, 'error');
|
|
173
|
+
return back(reply, s, again);
|
|
174
|
+
}
|
|
175
|
+
} else
|
|
176
|
+
// bcrypt always runs (dummy salt for unknown users) so timing does not reveal accounts
|
|
177
|
+
dev = await owner.one(
|
|
178
|
+
`select d.username, d.language
|
|
179
|
+
from (select $1::text as u) x
|
|
180
|
+
left join meta.developer d on d.username = x.u
|
|
181
|
+
where crypt($2, coalesce(d.password_hash, gen_salt('bf', 10))) = d.password_hash`,
|
|
182
|
+
[username, password],
|
|
183
|
+
);
|
|
140
184
|
// a workspace, when given, must be one of the developer's (administrators: any); the same answer otherwise
|
|
141
|
-
const ws = dev && workspace
|
|
142
|
-
? await owner.one<{ id: number }>(
|
|
143
|
-
`select w.id from meta.workspace w
|
|
144
|
-
where lower(w.name) = lower($1)
|
|
145
|
-
and (exists (select 1 from meta.developer d where d.username = $2 and d.is_admin)
|
|
146
|
-
or exists (select 1 from meta.workspace_member m where m.workspace_id = w.id and m.username = $2))`,
|
|
147
|
-
[workspace, dev.username],
|
|
148
|
-
)
|
|
149
|
-
: undefined;
|
|
185
|
+
const ws = dev && workspace ? await workspaceOf(workspace, dev.username) : undefined;
|
|
150
186
|
if (!dev || (workspace && !ws)) {
|
|
151
|
-
await logActivity({ username: username.slice(0, 100), event: 'login_failed', ip, detail: workspace ? 'builder (workspace)' : 'builder' });
|
|
187
|
+
await logActivity({ username: username.slice(0, 100), event: 'login_failed', ip, detail: directory ? `builder ldap:${directory.name} (workspace)` : workspace ? 'builder (workspace)' : 'builder' });
|
|
152
188
|
flash(s, tx(workspace ? 'signin.invalid_ws' : 'signin.invalid'), 'error');
|
|
153
189
|
return back(reply, s, again);
|
|
154
190
|
}
|
|
191
|
+
// (sprint 129) a workspace of the developer requires single sign-on: not the pgkiln password (break-glass excepted)
|
|
192
|
+
if (!directory && (await ssoRequired(dev.username))) {
|
|
193
|
+
await logActivity({ username: dev.username, event: 'login_failed', ip, detail: 'builder: single sign-on required' });
|
|
194
|
+
flash(s, tx('signin.sso_required'), 'error');
|
|
195
|
+
return back(reply, s, again);
|
|
196
|
+
}
|
|
197
|
+
const weak = !directory && (password === 'admin' || password === dev.username);
|
|
198
|
+
const detail = directory ? `builder ldap:${directory.name}` : 'builder';
|
|
155
199
|
// (sprint 126, SEC-2) a developer with a second factor: the password was right, the session comes after the code;
|
|
156
200
|
// (sprint 127) unless this browser was trusted after a second factor, for the instance's number of days
|
|
157
201
|
if ((await hasFactor(dev.username)) && !(await isTrustedBrowser(dev.username, req.cookies?.[TRUST_COOKIE]))) {
|
|
158
|
-
s.state.__MFA = JSON.stringify({ u: dev.username, ws: ws ? String(ws.id) : null, w: workspace, next, remember: req.body?.remember === '1', weak
|
|
202
|
+
s.state.__MFA = JSON.stringify({ u: dev.username, ws: ws ? String(ws.id) : null, w: workspace, next, remember: req.body?.remember === '1', weak, at: Date.now() });
|
|
159
203
|
await saveState(s);
|
|
160
204
|
return reply.redirect(`${BASE}/login/2fa`, 303);
|
|
161
205
|
}
|
|
162
|
-
return finishSignIn(req, reply, s, { username: dev.username, language: dev.language }, { ws: ws ? String(ws.id) : null, workspace, next, remember: req.body?.remember === '1', weak
|
|
206
|
+
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, picked, detail });
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
/** A workspace by name, when it is one of the developer's (administrators: any). */
|
|
210
|
+
const workspaceOf = (workspace: string, username: string) =>
|
|
211
|
+
owner.one<{ id: number }>(
|
|
212
|
+
`select w.id from meta.workspace w
|
|
213
|
+
where lower(w.name) = lower($1)
|
|
214
|
+
and (exists (select 1 from meta.developer d where d.username = $2 and d.is_admin)
|
|
215
|
+
or exists (select 1 from meta.workspace_member m where m.workspace_id = w.id and m.username = $2))`,
|
|
216
|
+
[workspace, username],
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
// (sprint 129, SEC-1) signing in through an identity provider (OpenID Connect or SAML; devsso.ts). The
|
|
220
|
+
// provider returns to the runtime's callback (/sso/…, registered at the provider for applications too),
|
|
221
|
+
// which hands sign-ins without an application to the function below.
|
|
222
|
+
const SSO_COOKIE = 'pgkiln_sso';
|
|
223
|
+
app.get(`${BASE}/sso/:provider`, async (req: Req, reply) => {
|
|
224
|
+
const p = await builderProvider(String(req.params.provider));
|
|
225
|
+
if (!p) return reply.code(404).send('Not found');
|
|
226
|
+
try {
|
|
227
|
+
const next = safeNext(req.query?.next);
|
|
228
|
+
const { url, browserKey } = p.protocol === 'saml' ? await startSamlSignIn(p, null, next) : await startSignIn(p, null, next);
|
|
229
|
+
reply.setCookie(SSO_COOKIE, browserKey, { path: '/sso', httpOnly: true, sameSite: 'lax', secure: process.env.COOKIE_SECURE === 'true', maxAge: 600 });
|
|
230
|
+
return reply.redirect(url);
|
|
231
|
+
} catch (e) {
|
|
232
|
+
req.log.warn({ err: e }, 'builder sso start failed');
|
|
233
|
+
const s = await getSession(req, reply, null, BASE);
|
|
234
|
+
flash(s, translatorFor(validBuilderLanguage(s.state.__BLANG) ?? cookieLanguage(req))('signin.sso_unavailable', { provider: p.display_name }), 'error');
|
|
235
|
+
return back(reply, s, `${BASE}/login`);
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
/** Linking an identity from the Security page (security.ts): the signed-in developer starts it. */
|
|
239
|
+
app.post(`${BASE}/security/link/:provider`, async (req: Req, reply) => {
|
|
240
|
+
const s = await developer(req, reply);
|
|
241
|
+
if (!s) return;
|
|
242
|
+
const p = await builderProvider(String(req.params.provider));
|
|
243
|
+
if (!p) return reply.code(404).send('Not found');
|
|
244
|
+
try {
|
|
245
|
+
const { url, browserKey } = p.protocol === 'saml' ? await startSamlSignIn(p, null, `${BASE}/security`, s.username!) : await startSignIn(p, null, `${BASE}/security`, s.username!);
|
|
246
|
+
reply.setCookie(SSO_COOKIE, browserKey, { path: '/sso', httpOnly: true, sameSite: 'lax', secure: process.env.COOKIE_SECURE === 'true', maxAge: 600 });
|
|
247
|
+
return reply.redirect(url, 303);
|
|
248
|
+
} catch (e) {
|
|
249
|
+
req.log.warn({ err: e }, 'builder sso link failed');
|
|
250
|
+
flash(s, tr(s)('signin.sso_unavailable', { provider: p.display_name }), 'error');
|
|
251
|
+
return back(reply, s, `${BASE}/security`);
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
// the end of both (the runtime's callback; SsoError → an error page there)
|
|
255
|
+
onBuilderSso(async (req: Req, reply, p, result, ip) => {
|
|
256
|
+
if (!p.builder || !result.identity) throw new SsoError('This identity provider does not sign developers in.');
|
|
257
|
+
const src = { kind: 'provider' as const, ...p };
|
|
258
|
+
if (result.link) {
|
|
259
|
+
// the browser that started this was signed in as result.link (the start needs the session and its CSRF token)
|
|
260
|
+
await linkDeveloper(src, result.identity.subject, result.link);
|
|
261
|
+
logActivity({ username: result.link, event: 'sso_linked', ip, detail: p.name });
|
|
262
|
+
return reply.redirect(`${BASE}/security?linked=${encodeURIComponent(p.name)}`, 303);
|
|
263
|
+
}
|
|
264
|
+
const username = await resolveDeveloper(src, { ...result.identity, username: result.username, groups: result.groups });
|
|
265
|
+
const dev = await owner.one<{ username: string; language: string | null }>('select username, language from meta.developer where username = $1', [username]);
|
|
266
|
+
if (!dev) throw new SsoError('The developer was removed.');
|
|
267
|
+
return finishSignIn(req, reply, null, dev, { ws: null, workspace: '', next: safeNext(result.next), remember: false, weak: false, picked: null, detail: `builder sso:${p.name}`, sso: p.name });
|
|
163
268
|
});
|
|
164
269
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
270
|
+
// (sprint 128, SEC-2) signing in with a passkey alone: no user name, no password. The browser offers the
|
|
271
|
+
// passkeys it holds for this site (discoverable credentials); the authenticator must verify the developer
|
|
272
|
+
// (fingerprint, face, PIN), so the passkey counts as both factors. Failures count with wrong passwords.
|
|
273
|
+
app.post(`${BASE}/login/passkey/options`, async (req: Req, reply) => {
|
|
274
|
+
const s = await getSession(req, reply, null, BASE);
|
|
275
|
+
if (req.body?.__csrf !== s.csrf_token) return reply.code(403).send('Reload the page.');
|
|
276
|
+
if (!(await passkeySignin())) return reply.code(404).send('Passkey sign-in is off.');
|
|
277
|
+
const challenge = giveChallenge(s as Parameters<typeof giveChallenge>[0], 'get');
|
|
278
|
+
await saveState(s);
|
|
279
|
+
reply.header('cache-control', 'no-store');
|
|
280
|
+
return reply.send({ mode: 'get', challenge, rpId: relyingParty(req).rpId, allow: [], uv: 'required' });
|
|
281
|
+
});
|
|
282
|
+
app.post(`${BASE}/login/passkey`, async (req: Req, reply) => {
|
|
283
|
+
const s = await getSession(req, reply, null, BASE);
|
|
284
|
+
const ip = clientIp(req);
|
|
285
|
+
const picked = validBuilderLanguage(s.state.__BLANG);
|
|
286
|
+
const tx = translatorFor(picked ?? cookieLanguage(req));
|
|
287
|
+
const workspace = String(req.body?.workspace ?? '').trim().slice(0, 60);
|
|
288
|
+
const next = safeNext(req.body?.next);
|
|
289
|
+
const again = next === `${BASE}/home` ? `${BASE}/login` : `${BASE}/login?next=${encodeURIComponent(next)}`;
|
|
290
|
+
const challenge = takeChallenge(s as Parameters<typeof takeChallenge>[0], 'get');
|
|
291
|
+
if (req.body?.__csrf !== s.csrf_token || !(await passkeySignin())) {
|
|
292
|
+
flash(s, tx('signin.expired'), 'error');
|
|
293
|
+
return back(reply, s, again);
|
|
294
|
+
}
|
|
295
|
+
const rp = relyingParty(req);
|
|
296
|
+
const key = await passkeyByCredential(String(req.body?.credentialId ?? ''), rp.rpId);
|
|
297
|
+
const who = key?.username ?? '';
|
|
298
|
+
if (await loginThrottled(null, who, ip)) {
|
|
299
|
+
logActivity({ username: who, event: 'login_locked', ip, detail: 'builder passkey' });
|
|
300
|
+
flash(s, tx('signin.throttled'), 'error');
|
|
301
|
+
return back(reply, s, again);
|
|
302
|
+
}
|
|
303
|
+
let why = key ? '' : 'unknown passkey';
|
|
304
|
+
if (key && !challenge) why = 'no or an expired challenge';
|
|
305
|
+
else if (key && req.body?.userHandle && req.body.userHandle !== userHandle(key.username)) why = 'the user handle does not match';
|
|
306
|
+
else if (key && challenge) {
|
|
307
|
+
try {
|
|
308
|
+
const r = verifyAssertion(
|
|
309
|
+
{ clientDataJSON: String(req.body?.clientDataJSON ?? ''), authenticatorData: String(req.body?.authenticatorData ?? ''), signature: String(req.body?.signature ?? '') },
|
|
310
|
+
{ alg: key.alg, jwk: key.public_key, signCount: Number(key.sign_count) }, { challenge, ...rp });
|
|
311
|
+
if (!r.userVerified) why = 'the authenticator did not verify the user';
|
|
312
|
+
else if (!(await usePasskey(key.id, key.sign_count, r.signCount))) why = 'used at the same time elsewhere';
|
|
313
|
+
} catch (e) {
|
|
314
|
+
why = e instanceof WebAuthnError ? e.message : 'unreadable answer';
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
const ws = !why && workspace ? await workspaceOf(workspace, who) : undefined;
|
|
318
|
+
if (!why && workspace && !ws) why = 'not a workspace of the developer';
|
|
319
|
+
// (sprint 129) a passkey alone is not single sign-on
|
|
320
|
+
if (!why && (await ssoRequired(who))) why = 'single sign-on required';
|
|
321
|
+
if (why) {
|
|
322
|
+
await logActivity({ username: who.slice(0, 100), event: 'login_failed', ip, detail: `builder passkey: ${why}` });
|
|
323
|
+
flash(s, tx(why === 'not a workspace of the developer' ? 'signin.invalid_ws' : why === 'single sign-on required' ? 'signin.sso_required' : 'mfa.passkey_failed'), 'error');
|
|
324
|
+
return back(reply, s, again);
|
|
325
|
+
}
|
|
326
|
+
const dev = await owner.one<{ username: string; language: string | null }>('select username, language from meta.developer where username = $1', [who]);
|
|
327
|
+
if (!dev) return back(reply, s, again);
|
|
328
|
+
return finishSignIn(req, reply, s, dev, { ws: ws ? String(ws.id) : null, workspace, next, remember: req.body?.remember === '1', weak: false, picked, detail: 'builder passkey' });
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* The session after a successful sign-in (password, and the second factor when the developer has one;
|
|
333
|
+
* or an identity provider, sso: its name; s null: the sign-in page's session is not at hand there).
|
|
334
|
+
*/
|
|
335
|
+
async function finishSignIn(req: Req, reply: Parameters<typeof back>[0], s: Awaited<ReturnType<typeof getSession>> | null, dev: { username: string; language: string | null },
|
|
336
|
+
o: { ws: string | null; workspace: string; next: string; remember: boolean; weak: boolean; picked: ReturnType<typeof validBuilderLanguage>; detail: string; sso?: string }) {
|
|
168
337
|
const ip = clientIp(req);
|
|
169
338
|
const { picked, next, workspace } = o;
|
|
170
|
-
await destroySession(reply, s, BASE);
|
|
339
|
+
if (s) await destroySession(reply, s, BASE);
|
|
171
340
|
const ns = await createSession(reply, null, BASE, dev.username);
|
|
172
341
|
// the builder theme chosen earlier on this device
|
|
173
342
|
const theme = validTheme(req.cookies?.[THEME_COOKIE]);
|
|
@@ -180,8 +349,12 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
180
349
|
if (dev.language && ns.state.__BLANG !== 'en') reply.setCookie(LANG_COOKIE, ns.state.__BLANG, langCookieOptions());
|
|
181
350
|
}
|
|
182
351
|
if (o.weak) ns.state.__WEAK = '1';
|
|
352
|
+
// (sprint 129) the identity provider decided on the second factor: no enrolment in pgkiln (ui.ts)
|
|
353
|
+
if (o.sso) ns.state.__SSO = o.sso;
|
|
183
354
|
await saveState(ns);
|
|
184
|
-
if (o.
|
|
355
|
+
if (o.sso) {
|
|
356
|
+
// the sign-in page's "remember" stays as it was
|
|
357
|
+
} else if (o.remember)
|
|
185
358
|
reply.setCookie(REMEMBER_COOKIE, Buffer.from(JSON.stringify({ w: workspace, u: dev.username })).toString('base64url'), {
|
|
186
359
|
path: BASE, httpOnly: true, sameSite: 'lax', maxAge: 365 * 24 * 3600, secure: process.env.COOKIE_SECURE === 'true',
|
|
187
360
|
});
|
|
@@ -380,6 +553,7 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
380
553
|
await workspaceRoutes(app);
|
|
381
554
|
await instanceRoutes(app);
|
|
382
555
|
await securityRoutes(app);
|
|
556
|
+
await builderSsoRoutes(app);
|
|
383
557
|
await wizardRoutes(app);
|
|
384
558
|
await appFromFileRoutes(app);
|
|
385
559
|
|
|
@@ -861,7 +1035,10 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
861
1035
|
app.get(`${BASE}/developers`, async (req: Req, reply) => {
|
|
862
1036
|
const s = await developer(req, reply);
|
|
863
1037
|
if (!s) return;
|
|
864
|
-
const devs = (await owner.query(`select username, is_admin, email, exists (select 1 from meta.developer_factor f where f.username = d.username) as mfa
|
|
1038
|
+
const devs = (await owner.query(`select username, is_admin, email, break_glass, exists (select 1 from meta.developer_factor f where f.username = d.username) as mfa,
|
|
1039
|
+
(select string_agg(coalesce(p.display_name, l.display_name), ', ' order by 1) from meta.developer_identity i
|
|
1040
|
+
left join meta.auth_provider p on p.id = i.provider_id left join meta.ldap_directory l on l.id = i.directory_id
|
|
1041
|
+
where i.username = d.username) as identities
|
|
865
1042
|
from meta.developer d order by 1`)).rows;
|
|
866
1043
|
const admin = devs.some((d) => d.username === s.username && d.is_admin);
|
|
867
1044
|
const me = devs.find((d) => d.username === s.username);
|
|
@@ -880,7 +1057,7 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
880
1057
|
${region(tx('dev.accounts'), html`
|
|
881
1058
|
<p class="muted u-mt0">${tx('dev.accounts_help')}</p>
|
|
882
1059
|
<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>
|
|
883
|
-
${devs.map((d) => html`<tr><td data-label="${tx('dev.username')}">${d.username}${d.username === s.username ? ` ${tx('dev.you')}` : ''}</td><td data-label="${tx('dev.role')}">${d.is_admin ? tx('dev.administrator') : tx('dev.developer')}</td>
|
|
1060
|
+
${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')}${d.break_glass ? html` <span class="badge badge-warn" title="${tx('bsso.break_glass_help')}">${tx('bsso.break_glass')}</span>` : ''}${d.identities ? html`<br><span class="muted small">${icon('log-in')} ${d.identities}</span>` : ''}</td>
|
|
884
1061
|
<td data-label="${tx('dev.email_col')}">${admin && d.username !== s.username
|
|
885
1062
|
? 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>`
|
|
886
1063
|
: html`<span class="wrap-anywhere">${d.email ?? ''}</span>`}</td>
|
|
@@ -888,6 +1065,7 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
888
1065
|
${input(`reason_${d.username}`.replace(/[^\w]/g, '_'), tx('mfa.reason'), '', { required: true, help: tx('mfa.reset_help', { user: d.username }) })}
|
|
889
1066
|
<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`
|
|
890
1067
|
<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>
|
|
1068
|
+
${d.is_admin ? html`· <form method="post" action="${BASE}/developers/break-glass" class="u-inline">${csrf(s)}<input type="hidden" name="username" value="${d.username}"><input type="hidden" name="break_glass" value="${d.break_glass ? 'false' : 'true'}"><button class="link-button" title="${tx('bsso.break_glass_help')}">${d.break_glass ? tx('bsso.break_glass_remove') : tx('bsso.break_glass_make')}</button></form>` : ''}
|
|
891
1069
|
· <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>`)}
|
|
892
1070
|
</tbody></table></div>
|
|
893
1071
|
${admin
|
|
@@ -975,7 +1153,12 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
975
1153
|
flash(s, tr(s)('mfa.keep_admins'), 'error');
|
|
976
1154
|
return back(reply, s, `${BASE}/developers`);
|
|
977
1155
|
}
|
|
978
|
-
|
|
1156
|
+
// (sprint 129) break-glass is for administrators: a developer loses it, unless they are the last one needed
|
|
1157
|
+
if (req.body.is_admin !== 'true' && (await leavesNoBreakGlass(req.body.username))) {
|
|
1158
|
+
flash(s, tr(s)('bsso.keep_break_glass'), 'error');
|
|
1159
|
+
return back(reply, s, `${BASE}/developers`);
|
|
1160
|
+
}
|
|
1161
|
+
await owner.query('update meta.developer set is_admin = $2, break_glass = break_glass and $2 where username = $1', [req.body.username, req.body.is_admin === 'true']);
|
|
979
1162
|
flash(s, tr(s)('dev.role_changed'));
|
|
980
1163
|
}
|
|
981
1164
|
return back(reply, s, `${BASE}/developers`);
|
|
@@ -986,7 +1169,7 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
986
1169
|
if (!s) return;
|
|
987
1170
|
if (!(await isAdmin(s.username))) return reply.code(403).send('Only administrators remove developers.');
|
|
988
1171
|
if (req.body?.username && req.body.username !== s.username) {
|
|
989
|
-
if (await leavesTooFewAdmins(req.body.username)) {
|
|
1172
|
+
if ((await leavesTooFewAdmins(req.body.username)) || (await leavesNoBreakGlass(req.body.username))) {
|
|
990
1173
|
flash(s, tr(s)('mfa.keep_admins'), 'error');
|
|
991
1174
|
return back(reply, s, `${BASE}/developers`);
|
|
992
1175
|
}
|
package/src/builder/security.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto';
|
|
2
1
|
import { isIP } from 'node:net';
|
|
3
2
|
import type { FastifyInstance } from 'fastify';
|
|
4
3
|
import { owner } from '../db.ts';
|
|
@@ -6,15 +5,16 @@ import { html, raw, type Raw } from '../html.ts';
|
|
|
6
5
|
import { icon } from '../icons.ts';
|
|
7
6
|
import {
|
|
8
7
|
addPasskey, addTotp, adminEmails, adminFactors, codesLeft, confirmRecoveryCodes, factorsOf, forgetBrowsers, hasFactor, matchTotp, MFA_POLICIES, mfaPolicy,
|
|
9
|
-
newRecoveryCodes, newTotpSecret, notifyMfa, otpauthUri, passkeysOf, policyProblem,
|
|
10
|
-
trustedBrowsers, unbase32, type MfaPolicy,
|
|
8
|
+
newRecoveryCodes, newTotpSecret, notifyMfa, otpauthUri, passkeysOf, policyProblem, requirement, resetFactors, setPolicy, setTrustDays, trustDays,
|
|
9
|
+
trustedBrowsers, unbase32, userHandle, passkeySignin, setPasskeySignin, type MfaPolicy,
|
|
11
10
|
} from '../mfa.ts';
|
|
12
11
|
import { qrSvg } from '../qrcode.ts';
|
|
13
12
|
import { secretKeyConfigured } from '../secrets.ts';
|
|
14
13
|
import { clientIp, logActivity, saveState, type Session } from '../session.ts';
|
|
15
|
-
import { ALGORITHMS,
|
|
14
|
+
import { ALGORITHMS, newChallenge, relyingParty, verifyRegistration, WebAuthnError } from '../webauthn.ts';
|
|
16
15
|
import { tr } from './i18n.ts';
|
|
17
16
|
import { isAdmin } from './locks.ts';
|
|
17
|
+
import { identitiesRegion } from './builder-sso.ts';
|
|
18
18
|
import { back, BASE, csrf, developer, flash, input, region, send, shell, type Req } from './ui.ts';
|
|
19
19
|
|
|
20
20
|
// (sprint 126, SEC-2 and SEC-6) Developer › Security: a developer's second factors (authenticator apps, TOTP)
|
|
@@ -59,9 +59,9 @@ export async function securityRoutes(app: FastifyInstance) {
|
|
|
59
59
|
if (!s) return;
|
|
60
60
|
const tx = tr(s);
|
|
61
61
|
const me = s.username!;
|
|
62
|
-
const [factors, left,
|
|
62
|
+
const [factors, left, admin, days, trusted] = await Promise.all([factorsOf(me), codesLeft(me), isAdmin(me), trustDays(), trustedBrowsers(me)]);
|
|
63
63
|
const rp = relyingParty(req);
|
|
64
|
-
const required =
|
|
64
|
+
const { required } = await requirement(me, admin);
|
|
65
65
|
const pending = (() => {
|
|
66
66
|
try {
|
|
67
67
|
const p = JSON.parse(String(s.state.__TOTP_NEW ?? ''));
|
|
@@ -136,7 +136,8 @@ export async function securityRoutes(app: FastifyInstance) {
|
|
|
136
136
|
${days || trusted ? html`<h3>${tx('mfa.trusted_title')}</h3>
|
|
137
137
|
<p class="u-mt0">${days ? tx('mfa.trusted_count', { n: trusted, days }) : tx('mfa.trusted_off')}</p>
|
|
138
138
|
${trusted ? html`<form method="post" action="${BASE}/security/forget-browsers">${csrf(s)}<button class="btn">${tx('mfa.forget_browsers')}</button></form>` : ''}` : ''}`)}
|
|
139
|
-
</div
|
|
139
|
+
</div>
|
|
140
|
+
${await identitiesRegion(s, typeof req.query?.linked === 'string' ? req.query.linked : undefined)}`;
|
|
140
141
|
return send(reply, s, shell(s, tx('mfa.title'), [[tx('mfa.title')]], main, 'developers'));
|
|
141
142
|
});
|
|
142
143
|
|
|
@@ -222,7 +223,7 @@ export async function securityRoutes(app: FastifyInstance) {
|
|
|
222
223
|
if (last) {
|
|
223
224
|
const pol = await mfaPolicy();
|
|
224
225
|
const admin = await isAdmin(me);
|
|
225
|
-
if (
|
|
226
|
+
if ((await requirement(me, admin)).required) {
|
|
226
227
|
flash(s, tx('mfa.cannot_remove_required'), 'error');
|
|
227
228
|
return back(reply, s, `${BASE}/security`);
|
|
228
229
|
}
|
|
@@ -257,7 +258,7 @@ export async function securityRoutes(app: FastifyInstance) {
|
|
|
257
258
|
return reply.send({
|
|
258
259
|
mode: 'create', challenge, rp: { id: rp.rpId, name: 'pgkiln' }, algs: ALGORITHMS,
|
|
259
260
|
// a stable handle that isn't the user name (the authenticator may show it, but it says nothing)
|
|
260
|
-
user: { id:
|
|
261
|
+
user: { id: userHandle(me), name: me, displayName: me },
|
|
261
262
|
exclude: (await passkeysOf(me, rp.rpId)).map((p) => p.credential_id),
|
|
262
263
|
});
|
|
263
264
|
});
|
|
@@ -341,9 +342,11 @@ export async function securityRoutes(app: FastifyInstance) {
|
|
|
341
342
|
flash(s, tx('mfa.policy_needs_admins'), 'error');
|
|
342
343
|
return back(reply, s, `${BASE}/instance#mfa`);
|
|
343
344
|
}
|
|
345
|
+
const alone = req.body?.passkey_signin === '1';
|
|
344
346
|
await setPolicy(policy, grace);
|
|
345
347
|
await setTrustDays(trust);
|
|
346
|
-
await
|
|
348
|
+
await setPasskeySignin(alone);
|
|
349
|
+
await logActivity({ username: s.username, event: '2fa_policy', ip: clientIp(req), detail: `${policy}, grace ${grace} days, trust browsers ${trust} days, passkey sign-in ${alone ? 'on' : 'off'}` });
|
|
347
350
|
await notifyMfa(await adminEmails(), `pgkiln: two-factor sign-in policy is now ${policy}`, `${s.username} set the two-factor policy to ${policy} (grace period ${grace} days).`);
|
|
348
351
|
flash(s, tx('mfa.policy_saved'));
|
|
349
352
|
return back(reply, s, `${BASE}/instance#mfa`);
|
|
@@ -353,8 +356,8 @@ export async function securityRoutes(app: FastifyInstance) {
|
|
|
353
356
|
/** The Instance settings region for the policy (administrators). */
|
|
354
357
|
export async function policyRegion(s: Session): Promise<Raw> {
|
|
355
358
|
const tx = tr(s);
|
|
356
|
-
const [pol, af, days, without] = await Promise.all([
|
|
357
|
-
mfaPolicy(), adminFactors(), trustDays(),
|
|
359
|
+
const [pol, af, days, alone, without] = await Promise.all([
|
|
360
|
+
mfaPolicy(), adminFactors(), trustDays(), passkeySignin(),
|
|
358
361
|
owner.query<{ username: string; is_admin: boolean }>('select username, is_admin from meta.developer d where not exists (select 1 from meta.developer_factor f where f.username = d.username) order by username'),
|
|
359
362
|
]);
|
|
360
363
|
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>`;
|
|
@@ -364,6 +367,8 @@ export async function policyRegion(s: Session): Promise<Raw> {
|
|
|
364
367
|
${input('grace_days', tx('mfa.grace'), pol.graceDays, { type: 'number', help: tx('mfa.grace_help') })}
|
|
365
368
|
${input('trust_days', tx('mfa.trust_days'), days, { type: 'number', help: tx('mfa.trust_days_help') })}
|
|
366
369
|
</div>
|
|
370
|
+
<div class="field"><label class="check"><input type="checkbox" name="passkey_signin" value="1"${alone ? raw(' checked') : ''}> ${tx('mfa.passkey_signin')}</label>
|
|
371
|
+
<small class="help">${tx('mfa.passkey_signin_help')}</small></div>
|
|
367
372
|
<p class="muted small">${tx('mfa.policy_help', { enrolled: af.enrolled, admins: af.admins })}</p>
|
|
368
373
|
<div class="buttons"><button class="btn btn-hot">${tx('mfa.policy_save')}</button></div></form>
|
|
369
374
|
${without.rows.length ? html`<p class="small">${tx('mfa.without', { list: without.rows.map((d) => `${d.username}${d.is_admin ? ' (admin)' : ''}`).join(', ') })}</p>` : ''}
|
package/src/builder/ui.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { ensureDevbar } from '../runtime/devbar.ts';
|
|
|
12
12
|
import { noteChange } from './app-updates.ts';
|
|
13
13
|
import { clientTexts, tr } from './i18n.ts';
|
|
14
14
|
import { languageMenu, loadLanguage } from './language.ts';
|
|
15
|
-
import { enrolmentDue } from '../mfa.ts';
|
|
15
|
+
import { enrolmentDue, requirement } from '../mfa.ts';
|
|
16
16
|
|
|
17
17
|
// Shared building blocks of the builder UI.
|
|
18
18
|
|
|
@@ -323,9 +323,20 @@ export async function developer(req: Req, reply: FastifyReply) {
|
|
|
323
323
|
if (s.state.__BLANG === undefined) await loadLanguage(s);
|
|
324
324
|
// (sprint 126, SEC-2) the two-factor policy: a banner during the grace period, then only the Security page until they enrol
|
|
325
325
|
delete s.state.__MFA_DUE;
|
|
326
|
-
const
|
|
326
|
+
const admin = await isAdmin(s.username);
|
|
327
|
+
// (sprint 128) signed in with the default password while a second factor is required: change it first
|
|
328
|
+
// (on Developers), then the enrolment below
|
|
329
|
+
const weakFirst = !!s.state.__WEAK && (await requirement(s.username, admin)).required;
|
|
330
|
+
if (weakFirst && !/^\/builder\/(developers|logout|language|theme)(?:[/?#]|$)/.test(req.url)) {
|
|
331
|
+
flash(s, tr(s)('mfa.weak_required'), 'error');
|
|
332
|
+
await saveState(s);
|
|
333
|
+
reply.redirect(`${BASE}/developers`);
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
// (sprint 129) signed in through an identity provider: it decides on the second factor
|
|
337
|
+
const due = s.state.__SSO ? null : await enrolmentDue(s.username, admin);
|
|
327
338
|
if (due?.due === 'grace') s.state.__MFA_DUE = due.until.toISOString();
|
|
328
|
-
else if (due && !/^\/builder\/(security|logout|language|theme)(?:[/?]|$)/.test(req.url)) {
|
|
339
|
+
else if (due && !weakFirst && !/^\/builder\/(security|logout|language|theme)(?:[/?]|$)/.test(req.url)) {
|
|
329
340
|
reply.redirect(`${BASE}/security`);
|
|
330
341
|
return null;
|
|
331
342
|
}
|
package/src/builder/users.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { accountSettings, clearAccountSettings, passwordProblem } from '../accou
|
|
|
7
7
|
import { loginMaxFailuresPerUser, loginWindowMinutes } from '../security.ts';
|
|
8
8
|
import { acsUrl, metadataUrl, spEntityId } from '../saml.ts';
|
|
9
9
|
import { discover, publicUrl, redirectUri } from '../sso.ts';
|
|
10
|
+
import { builderSignInRegion } from './builder-sso.ts';
|
|
10
11
|
import { administrator, back, BASE, csrf, developer, flash, input, region, select, send, shell, type Req } from './ui.ts';
|
|
11
12
|
|
|
12
13
|
// The workspace user directory (like APEX's workspace users with
|
|
@@ -476,6 +477,7 @@ export async function usersRoutes(app: FastifyInstance) {
|
|
|
476
477
|
<p class="muted">${tx.rich('idp.public_url', { env: html`<code>PUBLIC_URL</code>` })}</p>
|
|
477
478
|
<p>${tx.rich('idp.grant', { type: html`<b>${tx('idp.auth_code')}</b>`, scopes: html`<code>${pr.scopes}</code>`, claim: html`<code>${pr.groups_claim}</code>` })}</p>
|
|
478
479
|
<form method="post" action="${BASE}/users/providers/${pr.id}/test">${csrf(s)}<button class="btn">${tx('idp.test')}</button></form>`)}
|
|
480
|
+
${await builderSignInRegion(s, 'provider', pr)}
|
|
479
481
|
</div>`;
|
|
480
482
|
return send(reply, s, shell(s, pr.display_name, [['Users', `${BASE}/users`], [tx('idp.title'), `${BASE}/users/providers`], [pr.display_name]], main, 'users'));
|
|
481
483
|
});
|
|
@@ -8,6 +8,8 @@ import { html, raw } from '../html.ts';
|
|
|
8
8
|
import { icon } from '../icons.ts';
|
|
9
9
|
import { clientIp, logActivity, type BuilderWorkspace, type Session } from '../session.ts';
|
|
10
10
|
import { isAdmin } from './locks.ts';
|
|
11
|
+
import { setWorkspaceRequired } from '../mfa.ts';
|
|
12
|
+
import { breakGlassAdmins } from '../devsso.ts';
|
|
11
13
|
import { back, BANNER_COLORS, BASE, csrf, developer, flash, input, region, select, send, shell, type Req } from './ui.ts';
|
|
12
14
|
|
|
13
15
|
// Workspaces (064; APEX: workspaces). A workspace groups applications and the
|
|
@@ -215,7 +217,7 @@ function serviceRegion(s: Session, w: { id: number; message: string | null; bann
|
|
|
215
217
|
}
|
|
216
218
|
|
|
217
219
|
/** (093) Workspace preferences: the SQL Workshop and the applications' REST modules on or off. */
|
|
218
|
-
function preferencesRegion(s: Session, w: { id: number; sql_workshop: boolean; rest_services: boolean; builder_home: string | null }) {
|
|
220
|
+
function preferencesRegion(s: Session, w: { id: number; sql_workshop: boolean; rest_services: boolean; builder_home: string | null; mfa_required_since: Date | null; sso_only: boolean }, without: string[], unlinked: string[]) {
|
|
219
221
|
const check = (name: string, on: boolean, label: string, help: string) =>
|
|
220
222
|
html`<div class="field"><label class="check"><input type="checkbox" name="${name}" value="true"${on ? raw(' checked') : ''}> ${label}</label><small class="help">${help}</small></div>`;
|
|
221
223
|
const tx = tr(s);
|
|
@@ -223,7 +225,17 @@ function preferencesRegion(s: Session, w: { id: number; sql_workshop: boolean; r
|
|
|
223
225
|
${check('sql_workshop', w.sql_workshop, tx('wsp.sql_on'), tx('wsp.sql_on_help'))}
|
|
224
226
|
${check('rest_services', w.rest_services, tx('wsp.rest_on'), tx('wsp.rest_on_help', { path: '/a/<alias>/rest/…' }))}
|
|
225
227
|
${select('builder_home', tx('wsp.builder_home'), w.builder_home === 'home' ? 'home' : 'apps', [['apps', tx('wsp.home.apps')], ['home', tx('wsp.home.home')]], tx('wsp.builder_home_help'))}
|
|
226
|
-
<div class="buttons"><button class="btn">${tx('wsp.save_prefs')}</button></div></form
|
|
228
|
+
<div class="buttons"><button class="btn">${tx('wsp.save_prefs')}</button></div></form>
|
|
229
|
+
<h3 id="mfa">${tx('wsp.mfa')}</h3>
|
|
230
|
+
<form method="post" action="${BASE}/workspaces/${w.id}/mfa">${csrf(s)}
|
|
231
|
+
${check('required', !!w.mfa_required_since, tx('wsp.mfa_required'), tx('wsp.mfa_required_help'))}
|
|
232
|
+
${w.mfa_required_since ? html`<p class="small">${tx('wsp.mfa_since', { date: new Date(w.mfa_required_since).toISOString().slice(0, 10) })}${without.length ? html` ${tx('wsp.mfa_without', { list: without.join(', ') })}` : ''}</p>` : ''}
|
|
233
|
+
<div class="buttons"><button class="btn">${tx('wsp.mfa_save')}</button></div></form>
|
|
234
|
+
<h3 id="sso">${tx('wsp.sso')}</h3>
|
|
235
|
+
<form method="post" action="${BASE}/workspaces/${w.id}/sso">${csrf(s)}
|
|
236
|
+
${check('sso_only', w.sso_only, tx('wsp.sso_only'), tx('wsp.sso_only_help'))}
|
|
237
|
+
${w.sso_only && unlinked.length ? html`<p class="small">${tx('wsp.sso_unlinked', { list: unlinked.join(', ') })}</p>` : ''}
|
|
238
|
+
<div class="buttons"><button class="btn">${tx('wsp.sso_save')}</button></div></form>`, '', 'preferences');
|
|
227
239
|
}
|
|
228
240
|
|
|
229
241
|
/** (093) Extension links: links to other tools in the builder's Extensions menu. */
|
|
@@ -341,6 +353,15 @@ export async function workspaceRoutes(app: FastifyInstance) {
|
|
|
341
353
|
]);
|
|
342
354
|
const roles = rolesQ;
|
|
343
355
|
const links = (await owner.query('select id, label, url, description, position from meta.workspace_link where workspace_id = $1 order by position, lower(label)', [w.id])).rows;
|
|
356
|
+
// (sprint 128) the members still without a second factor
|
|
357
|
+
const without = (await owner.query<{ username: string }>(
|
|
358
|
+
`select m.username from meta.workspace_member m where m.workspace_id = $1
|
|
359
|
+
and not exists (select 1 from meta.developer_factor f where f.username = m.username) order by 1`, [w.id])).rows.map((r) => r.username);
|
|
360
|
+
// (sprint 129) the members who could not sign in while single sign-on is required: no linked identity, not break-glass
|
|
361
|
+
const unlinked = (await owner.query<{ username: string }>(
|
|
362
|
+
`select m.username from meta.workspace_member m join meta.developer d on d.username = m.username
|
|
363
|
+
where m.workspace_id = $1 and not d.break_glass
|
|
364
|
+
and not exists (select 1 from meta.developer_identity i where i.username = m.username) order by 1`, [w.id])).rows.map((r) => r.username);
|
|
344
365
|
const main = html`
|
|
345
366
|
<div class="title-row"><h1>${w.name}</h1></div>
|
|
346
367
|
<div class="columns wide-left">
|
|
@@ -365,7 +386,7 @@ export async function workspaceRoutes(app: FastifyInstance) {
|
|
|
365
386
|
</div>
|
|
366
387
|
${serviceRegion(s, w)}
|
|
367
388
|
${region(tx('wsp.mail_net'), html`<p class="u-mt0">${tx.rich('wsp.mail_net_text', { mail: html`<a href="${BASE}/workspaces/${w.id}/mail">${tx('wsp.mail_service')}</a>`, network: html`<a href="${BASE}/network?ws=${w.id}">${tx('wsp.network')}</a>`, queue: html`<a href="${BASE}/mail-queue${full ? `?ws=${w.id}` : ''}">${tx('wsp.queue')}</a>` })}</p>`, '', 'mail-network')}
|
|
368
|
-
<div class="columns">${preferencesRegion(s, w)}${linksRegion(s, w, links)}</div>
|
|
389
|
+
<div class="columns">${preferencesRegion(s, w, without, unlinked)}${linksRegion(s, w, links)}</div>
|
|
369
390
|
${full ? isolationRegion(s, w, roles.rows) : region(tx('wsp.isolation'), html`<p class="u-mt0">${w.isolated ? tx('wsp.iso_short_on') : tx('wsp.iso_short_off')} ${tx('wsp.admins_change')}</p>`)}
|
|
370
391
|
${!full ? '' : region(tx('wsp.applications'), html`<div class="table-wrap"><table class="report report-reflow">
|
|
371
392
|
<thead><tr><th>${tx('wsp.application')}</th><th>${tx('wsp.move_to')}</th></tr></thead>
|
|
@@ -559,6 +580,40 @@ export async function workspaceRoutes(app: FastifyInstance) {
|
|
|
559
580
|
return back(reply, s, `${BASE}/workspaces/${req.params.id}`);
|
|
560
581
|
});
|
|
561
582
|
|
|
583
|
+
// (sprint 128, SEC-2) a workspace requires a second factor of its developers (the instance's grace period from now)
|
|
584
|
+
app.post(`${BASE}/workspaces/:id(^\\d{1,9}$)/mfa`, async (req: Req, reply) => {
|
|
585
|
+
const s = await developer(req, reply);
|
|
586
|
+
if (!s || !(await workspaceAdminOnly(s, reply, req.params.id))) return;
|
|
587
|
+
const on = req.body?.required === 'true';
|
|
588
|
+
if (!(await setWorkspaceRequired(Number(req.params.id), on))) return reply.code(404).send('Not found');
|
|
589
|
+
await logActivity({ appId: null, username: s.username, event: '2fa_policy', ip: clientIp(req), detail: `workspace ${req.params.id}: two-factor sign-in ${on ? 'required' : 'not required'}` });
|
|
590
|
+
flash(s, tr(s)(on ? 'wsp.mfa_on' : 'wsp.mfa_off'));
|
|
591
|
+
return back(reply, s, `${BASE}/workspaces/${req.params.id}#mfa`);
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
// (sprint 129, SEC-1) a workspace requires single sign-on of its developers: not while no break-glass
|
|
595
|
+
// administrator could sign in with the password, and not by someone who would lock themselves out
|
|
596
|
+
app.post(`${BASE}/workspaces/:id(^\\d{1,9}$)/sso`, async (req: Req, reply) => {
|
|
597
|
+
const s = await developer(req, reply);
|
|
598
|
+
if (!s || !(await workspaceAdminOnly(s, reply, req.params.id))) return;
|
|
599
|
+
const tx = tr(s);
|
|
600
|
+
const on = req.body?.sso_only === 'true';
|
|
601
|
+
if (on && !(await breakGlassAdmins()).length) {
|
|
602
|
+
flash(s, tx('wsp.sso_no_break_glass'), 'error');
|
|
603
|
+
return back(reply, s, `${BASE}/workspaces/${req.params.id}#sso`);
|
|
604
|
+
}
|
|
605
|
+
if (on && !(await owner.one(`select 1 as ok from meta.developer d where d.username = $1
|
|
606
|
+
and (d.break_glass or exists (select 1 from meta.developer_identity i where i.username = d.username))`, [s.username]))) {
|
|
607
|
+
flash(s, tx('wsp.sso_link_first'), 'error');
|
|
608
|
+
return back(reply, s, `${BASE}/workspaces/${req.params.id}#sso`);
|
|
609
|
+
}
|
|
610
|
+
const r = await owner.query('update meta.workspace set sso_only = $2 where id = $1', [req.params.id, on]);
|
|
611
|
+
if (!r.rowCount) return reply.code(404).send('Not found');
|
|
612
|
+
await logActivity({ appId: null, username: s.username, event: 'sso_policy', ip: clientIp(req), detail: `workspace ${req.params.id}: single sign-on ${on ? 'required' : 'not required'}` });
|
|
613
|
+
flash(s, tx(on ? 'wsp.sso_on' : 'wsp.sso_off'));
|
|
614
|
+
return back(reply, s, `${BASE}/workspaces/${req.params.id}#sso`);
|
|
615
|
+
});
|
|
616
|
+
|
|
562
617
|
// (093) extension links
|
|
563
618
|
app.post(`${BASE}/workspaces/:id(^\\d{1,9}$)/links`, async (req: Req, reply) => {
|
|
564
619
|
const s = await developer(req, reply);
|