pgkiln 0.64.0 → 0.66.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/db/migrations/133_app_user_passkeys_stepup.sql +54 -0
- package/db/migrations/134_idp_mfa.sql +46 -0
- package/docs/apex-feature-parity.md +9 -3
- package/docs/guide/03-builder.md +5 -1
- package/docs/guide/08-security.md +81 -8
- package/docs/guide/12-development.md +10 -4
- package/docs/guide/13-rest-api.md +4 -0
- package/package.json +1 -1
- package/public/app.js +63 -0
- package/public/builder.css +6 -0
- package/public/builder.js +1 -62
- package/src/account-mfa.ts +94 -3
- package/src/api.ts +9 -2
- package/src/builder/api.ts +3 -1
- package/src/builder/components.ts +1 -0
- package/src/builder/i18n/de.ts +13 -0
- package/src/builder/i18n/en.ts +13 -0
- package/src/builder/i18n/es.ts +13 -0
- package/src/builder/i18n/fr.ts +13 -0
- package/src/builder/i18n/nl.ts +13 -0
- package/src/builder/i18n/pt.ts +13 -0
- package/src/builder/login-page.ts +20 -17
- package/src/builder/rest-modules.ts +1 -1
- package/src/builder/routes.ts +24 -8
- package/src/builder/security.ts +3 -19
- package/src/builder/shared.ts +5 -3
- package/src/builder/users.ts +24 -5
- package/src/i18n/ar.ts +31 -0
- package/src/i18n/cs.ts +31 -0
- package/src/i18n/da.ts +31 -0
- package/src/i18n/de.ts +31 -0
- package/src/i18n/el.ts +31 -0
- package/src/i18n/es.ts +31 -0
- package/src/i18n/fi.ts +31 -0
- package/src/i18n/fr.ts +31 -0
- package/src/i18n/he.ts +31 -0
- package/src/i18n/it.ts +31 -0
- package/src/i18n/ja.ts +31 -0
- package/src/i18n/ko.ts +31 -0
- package/src/i18n/nb.ts +31 -0
- package/src/i18n/pl.ts +31 -0
- package/src/i18n/pt.ts +31 -0
- package/src/i18n/ru.ts +31 -0
- package/src/i18n/sv.ts +31 -0
- package/src/i18n/tr.ts +31 -0
- package/src/i18n/uk.ts +31 -0
- package/src/i18n/zh.ts +31 -0
- package/src/i18n.ts +62 -0
- package/src/metadata.ts +8 -2
- package/src/mfa.ts +18 -13
- package/src/runtime/account.ts +6 -3
- package/src/runtime/authz.ts +23 -4
- package/src/runtime/routes.ts +50 -4
- package/src/runtime/two-step.ts +400 -73
- package/src/saml.ts +34 -11
- package/src/sso.ts +72 -11
- package/src/webauthn.ts +20 -0
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' })
|
package/src/builder/api.ts
CHANGED
|
@@ -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: {
|
package/src/builder/i18n/de.ts
CHANGED
|
@@ -1295,6 +1295,9 @@ export const de: BuilderMessages = {
|
|
|
1295
1295
|
'st.custom_post_help': 'Optionales PL/pgSQL nach einer erfolgreichen Prüfung, mit p_username (und meta.app_user()), z. B. um die letzte Anmeldung zu speichern. Eine Exception lehnt die Anmeldung ab. Anwendungsprozesse „after login“ laufen danach wie gewohnt.',
|
|
1296
1296
|
'st.signin': 'Anmeldung',
|
|
1297
1297
|
'st.methods': 'Anmeldemethoden',
|
|
1298
|
+
'st.signin_page': 'Anmeldeseite',
|
|
1299
|
+
'st.staying': 'Angemeldet bleiben',
|
|
1300
|
+
'st.two_factor': 'Zwei-Faktor-Anmeldung',
|
|
1298
1301
|
'st.local': 'Benutzername und Passwort',
|
|
1299
1302
|
'st.ldap': 'Passwörter aus LDAP: {name}',
|
|
1300
1303
|
'st.disabled': '(deaktiviert)',
|
|
@@ -1509,6 +1512,16 @@ export const de: BuilderMessages = {
|
|
|
1509
1512
|
'idp.disabled_s': 'deaktiviert',
|
|
1510
1513
|
'idp.auto_s': 'automatisch anlegen',
|
|
1511
1514
|
'idp.links_s': 'verknüpft vorhandene Konten',
|
|
1515
|
+
'idp.mfa_title': 'Mehrstufige Anmeldung',
|
|
1516
|
+
'idp.mfa_required': 'Mehrstufige Anmeldung bei diesem Anbieter verlangen',
|
|
1517
|
+
'idp.mfa_required_help': 'Anmeldungen (bei Anwendungen und im Builder) ablehnen, die der Anbieter nicht als mehrstufig kennzeichnet: bei OpenID Connect über den amr- oder acr-Claim des ID-Tokens, bei SAML über die AuthnContextClassRef.',
|
|
1518
|
+
'idp.mfa_request': 'Beim Anbieter anfordern',
|
|
1519
|
+
'idp.mfa_request_help': 'OpenID-Connect-acr_values oder SAML-AuthnContextClassRef-Werte, durch Leerzeichen getrennt. Leer: nichts anfordern.',
|
|
1520
|
+
'idp.mfa_values': 'Werte, die mehrstufig bedeuten',
|
|
1521
|
+
'idp.mfa_values_help': 'amr-, acr- oder AuthnContextClassRef-Werte, durch Leerzeichen getrennt; einer davon genügt. Entra ID und Okta setzen „mfa“ in amr.',
|
|
1522
|
+
'idp.local_step': 'Nach diesem Anbieter den eigenen zweiten Schritt von pgkiln verlangen',
|
|
1523
|
+
'idp.local_step_help': 'Für Anbieter, die keine mehrstufige Anmeldung erzwingen können: Die Zwei-Schritt-Regel der Anwendung gilt wie nach einem Passwort, und Entwickler mit einem zweiten Faktor geben ihn ebenfalls an. Eine erneute Bestätigung fragt dann nach dem Faktor in pgkiln, statt sich erneut beim Anbieter anzumelden.',
|
|
1524
|
+
'idp.mfa_s': 'verlangt MFA',
|
|
1512
1525
|
'idp.none': 'Noch keine Identitätsanbieter.',
|
|
1513
1526
|
'idp.settings': 'Einstellungen',
|
|
1514
1527
|
'idp.delete_confirm': '{name} löschen? Verknüpfte Identitäten werden entfernt; die Konten bleiben.',
|
package/src/builder/i18n/en.ts
CHANGED
|
@@ -1298,6 +1298,9 @@ export const en = {
|
|
|
1298
1298
|
'st.custom_post_help': 'Optional PL/pgSQL run after a successful check, with p_username (and meta.app_user()), e.g. to record the last sign-in. Raising an exception refuses the sign-in. Application processes "after login" run afterwards as usual.',
|
|
1299
1299
|
'st.signin': 'Sign-in',
|
|
1300
1300
|
'st.methods': 'Sign-in methods',
|
|
1301
|
+
'st.signin_page': 'Sign-in page',
|
|
1302
|
+
'st.staying': 'Staying signed in',
|
|
1303
|
+
'st.two_factor': 'Two-factor sign-in',
|
|
1301
1304
|
'st.local': 'Username and password',
|
|
1302
1305
|
'st.ldap': 'Passwords from LDAP: {name}',
|
|
1303
1306
|
'st.disabled': '(disabled)',
|
|
@@ -1512,6 +1515,16 @@ export const en = {
|
|
|
1512
1515
|
'idp.disabled_s': 'disabled',
|
|
1513
1516
|
'idp.auto_s': 'auto-create',
|
|
1514
1517
|
'idp.links_s': 'links existing accounts',
|
|
1518
|
+
'idp.mfa_title': 'Multi-factor sign-in',
|
|
1519
|
+
'idp.mfa_required': 'Require multi-factor sign-in at this provider',
|
|
1520
|
+
'idp.mfa_required_help': "Refuse sign-ins (to applications and to the builder) that the provider does not mark as multi-factor: OpenID Connect through the ID token's amr or acr claim, SAML through the AuthnContextClassRef.",
|
|
1521
|
+
'idp.mfa_request': 'Ask the provider for',
|
|
1522
|
+
'idp.mfa_request_help': 'OpenID Connect acr_values or SAML AuthnContextClassRef values, separated by spaces. Empty: ask for nothing.',
|
|
1523
|
+
'idp.mfa_values': 'Values that mean multi-factor',
|
|
1524
|
+
'idp.mfa_values_help': 'amr, acr or AuthnContextClassRef values, separated by spaces; any one of them is enough. Entra ID and Okta put "mfa" in amr.',
|
|
1525
|
+
'idp.local_step': "Ask pgkiln's own second step after this provider",
|
|
1526
|
+
'idp.local_step_help': "For providers that cannot require multi-factor sign-in: the application's two-step rule applies as after a password, and developers with a second factor give it too. Step-up then asks for pgkiln's factor instead of signing in at the provider again.",
|
|
1527
|
+
'idp.mfa_s': 'requires MFA',
|
|
1515
1528
|
'idp.none': 'No identity providers yet.',
|
|
1516
1529
|
'idp.settings': 'Settings',
|
|
1517
1530
|
'idp.delete_confirm': 'Delete {name}? Linked identities are removed; accounts stay.',
|
package/src/builder/i18n/es.ts
CHANGED
|
@@ -1295,6 +1295,9 @@ export const es: BuilderMessages = {
|
|
|
1295
1295
|
'st.custom_post_help': 'PL/pgSQL opcional que se ejecuta tras una comprobación correcta, con p_username (y meta.app_user()), p. ej. para registrar el último inicio de sesión. Lanzar una excepción rechaza el inicio de sesión. Los procesos de aplicación «after login» se ejecutan después como siempre.',
|
|
1296
1296
|
'st.signin': 'Inicio de sesión',
|
|
1297
1297
|
'st.methods': 'Métodos de inicio de sesión',
|
|
1298
|
+
'st.signin_page': 'Página de inicio de sesión',
|
|
1299
|
+
'st.staying': 'Mantener la sesión',
|
|
1300
|
+
'st.two_factor': 'Inicio de sesión en dos pasos',
|
|
1298
1301
|
'st.local': 'Nombre de usuario y contraseña',
|
|
1299
1302
|
'st.ldap': 'Contraseñas de LDAP: {name}',
|
|
1300
1303
|
'st.disabled': '(desactivado)',
|
|
@@ -1509,6 +1512,16 @@ export const es: BuilderMessages = {
|
|
|
1509
1512
|
'idp.disabled_s': 'desactivado',
|
|
1510
1513
|
'idp.auto_s': 'creación automática',
|
|
1511
1514
|
'idp.links_s': 'vincula cuentas existentes',
|
|
1515
|
+
'idp.mfa_title': 'Inicio de sesión multifactor',
|
|
1516
|
+
'idp.mfa_required': 'Exigir inicio de sesión multifactor en este proveedor',
|
|
1517
|
+
'idp.mfa_required_help': 'Rechaza los inicios de sesión (en aplicaciones y en el builder) que el proveedor no marca como multifactor: en OpenID Connect por el claim amr o acr del token de ID, en SAML por el AuthnContextClassRef.',
|
|
1518
|
+
'idp.mfa_request': 'Solicitar al proveedor',
|
|
1519
|
+
'idp.mfa_request_help': 'Valores acr_values de OpenID Connect o AuthnContextClassRef de SAML, separados por espacios. Vacío: no solicitar nada.',
|
|
1520
|
+
'idp.mfa_values': 'Valores que significan multifactor',
|
|
1521
|
+
'idp.mfa_values_help': 'Valores amr, acr o AuthnContextClassRef, separados por espacios; basta con uno. Entra ID y Okta ponen «mfa» en amr.',
|
|
1522
|
+
'idp.local_step': 'Pedir el segundo paso propio de pgkiln después de este proveedor',
|
|
1523
|
+
'idp.local_step_help': 'Para proveedores que no pueden exigir multifactor: la regla de dos pasos de la aplicación se aplica como después de una contraseña, y los desarrolladores con un segundo factor también lo indican. La nueva confirmación pide entonces el factor de pgkiln en lugar de volver a iniciar sesión en el proveedor.',
|
|
1524
|
+
'idp.mfa_s': 'exige MFA',
|
|
1512
1525
|
'idp.none': 'Todavía no hay proveedores de identidad.',
|
|
1513
1526
|
'idp.settings': 'Ajustes',
|
|
1514
1527
|
'idp.delete_confirm': '¿Eliminar {name}? Se quitan las identidades vinculadas; las cuentas se mantienen.',
|
package/src/builder/i18n/fr.ts
CHANGED
|
@@ -1295,6 +1295,9 @@ export const fr: BuilderMessages = {
|
|
|
1295
1295
|
'st.custom_post_help': 'PL/pgSQL facultatif exécuté après une vérification réussie, avec p_username (et meta.app_user()), p. ex. pour enregistrer la dernière connexion. Lever une exception refuse la connexion. Les processus d’application « after login » s’exécutent ensuite comme d’habitude.',
|
|
1296
1296
|
'st.signin': 'Connexion',
|
|
1297
1297
|
'st.methods': 'Méthodes de connexion',
|
|
1298
|
+
'st.signin_page': 'Page de connexion',
|
|
1299
|
+
'st.staying': 'Rester connecté',
|
|
1300
|
+
'st.two_factor': 'Connexion en deux étapes',
|
|
1298
1301
|
'st.local': 'Nom d’utilisateur et mot de passe',
|
|
1299
1302
|
'st.ldap': 'Mots de passe LDAP : {name}',
|
|
1300
1303
|
'st.disabled': '(désactivé)',
|
|
@@ -1509,6 +1512,16 @@ export const fr: BuilderMessages = {
|
|
|
1509
1512
|
'idp.disabled_s': 'désactivé',
|
|
1510
1513
|
'idp.auto_s': 'création automatique',
|
|
1511
1514
|
'idp.links_s': 'lie les comptes existants',
|
|
1515
|
+
'idp.mfa_title': 'Connexion multifacteur',
|
|
1516
|
+
'idp.mfa_required': 'Exiger une connexion multifacteur chez ce fournisseur',
|
|
1517
|
+
'idp.mfa_required_help': 'Refuser les connexions (aux applications et au builder) que le fournisseur ne marque pas comme multifacteur : en OpenID Connect par le claim amr ou acr du jeton d’identité, en SAML par l’AuthnContextClassRef.',
|
|
1518
|
+
'idp.mfa_request': 'Demander au fournisseur',
|
|
1519
|
+
'idp.mfa_request_help': 'Valeurs acr_values (OpenID Connect) ou AuthnContextClassRef (SAML), séparées par des espaces. Vide : ne rien demander.',
|
|
1520
|
+
'idp.mfa_values': 'Valeurs qui signifient multifacteur',
|
|
1521
|
+
'idp.mfa_values_help': 'Valeurs amr, acr ou AuthnContextClassRef, séparées par des espaces ; une seule suffit. Entra ID et Okta mettent « mfa » dans amr.',
|
|
1522
|
+
'idp.local_step': 'Demander la deuxième étape de pgkiln après ce fournisseur',
|
|
1523
|
+
'idp.local_step_help': 'Pour les fournisseurs qui ne peuvent pas exiger le multifacteur : la règle de connexion en deux étapes de l’application s’applique comme après un mot de passe, et les développeurs ayant un second facteur le donnent aussi. Une nouvelle confirmation demande alors le facteur de pgkiln au lieu de se reconnecter chez le fournisseur.',
|
|
1524
|
+
'idp.mfa_s': 'exige la MFA',
|
|
1512
1525
|
'idp.none': 'Pas encore de fournisseurs d’identité.',
|
|
1513
1526
|
'idp.settings': 'Paramètres',
|
|
1514
1527
|
'idp.delete_confirm': 'Supprimer {name} ? Les identités liées sont retirées ; les comptes restent.',
|
package/src/builder/i18n/nl.ts
CHANGED
|
@@ -1295,6 +1295,9 @@ export const nl: BuilderMessages = {
|
|
|
1295
1295
|
'st.custom_post_help': 'Optionele PL/pgSQL die draait na een geslaagde controle, met p_username (en meta.app_user()), bijv. om de laatste aanmelding vast te leggen. Een exception weigert de aanmelding. Applicatieprocessen “after login” draaien daarna zoals gewoonlijk.',
|
|
1296
1296
|
'st.signin': 'Aanmelden',
|
|
1297
1297
|
'st.methods': 'Aanmeldmethoden',
|
|
1298
|
+
'st.signin_page': 'Aanmeldpagina',
|
|
1299
|
+
'st.staying': 'Aangemeld blijven',
|
|
1300
|
+
'st.two_factor': 'Aanmelden in twee stappen',
|
|
1298
1301
|
'st.local': 'Gebruikersnaam en wachtwoord',
|
|
1299
1302
|
'st.ldap': 'Wachtwoorden uit LDAP: {name}',
|
|
1300
1303
|
'st.disabled': '(uitgeschakeld)',
|
|
@@ -1509,6 +1512,16 @@ export const nl: BuilderMessages = {
|
|
|
1509
1512
|
'idp.disabled_s': 'uitgeschakeld',
|
|
1510
1513
|
'idp.auto_s': 'automatisch aanmaken',
|
|
1511
1514
|
'idp.links_s': 'koppelt bestaande accounts',
|
|
1515
|
+
'idp.mfa_title': 'Meervoudige aanmelding',
|
|
1516
|
+
'idp.mfa_required': 'Meervoudige aanmelding bij deze provider vereisen',
|
|
1517
|
+
'idp.mfa_required_help': 'Aanmeldingen (bij applicaties en in de builder) weigeren die de provider niet als meervoudig aanmerkt: bij OpenID Connect via de amr- of acr-claim van het ID-token, bij SAML via de AuthnContextClassRef.',
|
|
1518
|
+
'idp.mfa_request': 'Vragen aan de provider',
|
|
1519
|
+
'idp.mfa_request_help': 'OpenID Connect-acr_values of SAML-AuthnContextClassRef-waarden, gescheiden door spaties. Leeg: niets vragen.',
|
|
1520
|
+
'idp.mfa_values': 'Waarden die meervoudig betekenen',
|
|
1521
|
+
'idp.mfa_values_help': 'amr-, acr- of AuthnContextClassRef-waarden, gescheiden door spaties; één ervan is genoeg. Entra ID en Okta zetten "mfa" in amr.',
|
|
1522
|
+
'idp.local_step': 'Na deze provider ook de eigen tweede stap van pgkiln vragen',
|
|
1523
|
+
'idp.local_step_help': 'Voor providers die geen meervoudige aanmelding kunnen afdwingen: de tweestapsregel van de applicatie geldt zoals na een wachtwoord, en ontwikkelaars met een tweede factor geven die ook. Opnieuw bevestigen vraagt dan de factor in pgkiln in plaats van opnieuw aanmelden bij de provider.',
|
|
1524
|
+
'idp.mfa_s': 'vereist MFA',
|
|
1512
1525
|
'idp.none': 'Nog geen identiteitsproviders.',
|
|
1513
1526
|
'idp.settings': 'Instellingen',
|
|
1514
1527
|
'idp.delete_confirm': '{name} verwijderen? Gekoppelde identiteiten worden verwijderd; de accounts blijven.',
|
package/src/builder/i18n/pt.ts
CHANGED
|
@@ -1295,6 +1295,9 @@ export const pt: BuilderMessages = {
|
|
|
1295
1295
|
'st.custom_post_help': 'PL/pgSQL opcional executado após uma verificação bem-sucedida, com p_username (e meta.app_user()), por exemplo para registrar o último login. Lançar uma exceção recusa o login. Processos de aplicação “after login” são executados depois, como de costume.',
|
|
1296
1296
|
'st.signin': 'Login',
|
|
1297
1297
|
'st.methods': 'Métodos de login',
|
|
1298
|
+
'st.signin_page': 'Página de acesso',
|
|
1299
|
+
'st.staying': 'Manter o acesso',
|
|
1300
|
+
'st.two_factor': 'Acesso em duas etapas',
|
|
1298
1301
|
'st.local': 'Nome de usuário e senha',
|
|
1299
1302
|
'st.ldap': 'Senhas do LDAP: {name}',
|
|
1300
1303
|
'st.disabled': '(desativado)',
|
|
@@ -1509,6 +1512,16 @@ export const pt: BuilderMessages = {
|
|
|
1509
1512
|
'idp.disabled_s': 'desativado',
|
|
1510
1513
|
'idp.auto_s': 'criação automática',
|
|
1511
1514
|
'idp.links_s': 'vincula contas existentes',
|
|
1515
|
+
'idp.mfa_title': 'Acesso multifator',
|
|
1516
|
+
'idp.mfa_required': 'Exigir acesso multifator neste provedor',
|
|
1517
|
+
'idp.mfa_required_help': 'Recusar acessos (às aplicações e ao builder) que o provedor não marca como multifator: no OpenID Connect pelo claim amr ou acr do token de ID, no SAML pelo AuthnContextClassRef.',
|
|
1518
|
+
'idp.mfa_request': 'Pedir ao provedor',
|
|
1519
|
+
'idp.mfa_request_help': 'Valores acr_values do OpenID Connect ou AuthnContextClassRef do SAML, separados por espaços. Vazio: não pedir nada.',
|
|
1520
|
+
'idp.mfa_values': 'Valores que significam multifator',
|
|
1521
|
+
'idp.mfa_values_help': 'Valores amr, acr ou AuthnContextClassRef, separados por espaços; basta um deles. Entra ID e Okta colocam "mfa" em amr.',
|
|
1522
|
+
'idp.local_step': 'Pedir a segunda etapa do próprio pgkiln após este provedor',
|
|
1523
|
+
'idp.local_step_help': 'Para provedores que não conseguem exigir multifator: a regra de duas etapas da aplicação vale como após uma senha, e desenvolvedores com um segundo fator também o informam. A nova confirmação pede então o fator do pgkiln em vez de entrar de novo no provedor.',
|
|
1524
|
+
'idp.mfa_s': 'exige MFA',
|
|
1512
1525
|
'idp.none': 'Ainda não há provedores de identidade.',
|
|
1513
1526
|
'idp.settings': 'Configurações',
|
|
1514
1527
|
'idp.delete_confirm': 'Excluir {name}? As identidades vinculadas são removidas; as contas ficam.',
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { FastifyInstance } from 'fastify';
|
|
2
2
|
import { owner } from '../db.ts';
|
|
3
|
-
import { APP_MFA, type AppMfa } from '../account-mfa.ts';
|
|
3
|
+
import { APP_MFA, EMAIL_CODE_MINUTES, EMAIL_CODES_PER_HOUR, type AppMfa } from '../account-mfa.ts';
|
|
4
4
|
import { html, raw } from '../html.ts';
|
|
5
5
|
import { secretKeyConfigured } from '../secrets.ts';
|
|
6
6
|
import { back, BASE, developer, flash, type Req } from './ui.ts';
|
|
@@ -9,7 +9,7 @@ import { back, BASE, developer, flash, type Req } from './ui.ts';
|
|
|
9
9
|
// own login page (APEX: the login page, usually 9999) and "Forgot password?".
|
|
10
10
|
// "Create login page" adds a minimal public page with a login region and
|
|
11
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.
|
|
12
|
+
// application's users: off, optional, required, or required for some roles. (sprint 131) A code by e-mail.
|
|
13
13
|
|
|
14
14
|
interface AppRow {
|
|
15
15
|
id: number;
|
|
@@ -20,9 +20,10 @@ interface AppRow {
|
|
|
20
20
|
password_reset: boolean;
|
|
21
21
|
mfa: AppMfa;
|
|
22
22
|
mfa_roles: string[];
|
|
23
|
+
mfa_email: boolean;
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
/** The fields in the settings form. */
|
|
26
|
+
/** The fields in the settings form: the sign-in page, and two-factor sign-in (each placed by the settings page). */
|
|
26
27
|
export async function loginSettingsFields(a: AppRow) {
|
|
27
28
|
const pages = (await owner.query<{ page_no: number; name: string; requires_auth: boolean }>(
|
|
28
29
|
'select page_no, name, requires_auth from meta.page where app_id = $1 order by page_no', [a.id],
|
|
@@ -32,31 +33,33 @@ export async function loginSettingsFields(a: AppRow) {
|
|
|
32
33
|
from meta.app_mail_service ams join meta.mail_service s on s.id = ams.service_id where ams.app_id = $1`, [a.id],
|
|
33
34
|
);
|
|
34
35
|
const mailOk = !!(mail?.enabled && mail.from_address?.trim());
|
|
36
|
+
const noMail = html`<strong>Not offered yet:</strong> the application needs an enabled mail service with a From address (Workspace utilities → Mail services).`;
|
|
35
37
|
const opt = (v: string, label: string, cur: string) => html`<option value="${v}"${v === cur ? raw(' selected') : ''}>${label}</option>`;
|
|
36
38
|
const current = a.login_page ? String(a.login_page) : '';
|
|
37
|
-
|
|
39
|
+
const page = html`<div class="form-grid">
|
|
38
40
|
<div class="field"><label class="label" for="f_login_page">Login page</label>
|
|
39
41
|
<select id="f_login_page" name="login_page">${opt('', '- the built-in sign-in page -', current)}${pages.map((p) =>
|
|
40
42
|
opt(String(p.page_no), `${p.page_no} ${p.name}${p.requires_auth ? ' (requires sign-in: not usable)' : ''}`, current))}</select>
|
|
41
|
-
<small class="help">A page
|
|
43
|
+
<small class="help">A public page with a <em>Login</em> region (APEX: the login page); page template <em>Minimal</em> hides the header and navigation.
|
|
42
44
|
<button class="link-button" formaction="${BASE}/apps/${a.id}/login-page">Create a login page</button></small></div>
|
|
43
|
-
<div class="field"><span class="label" aria-hidden="true"></span><label class="check"><input type="checkbox" name="password_reset" value="true"${a.password_reset ? raw(' checked') : ''}> Offer "Forgot password?"</label>
|
|
44
|
-
<small class="help">Users with a password and an e-mail address in the user directory get a link to choose a new password (single-use, valid for 30 minutes).
|
|
45
|
-
${mailOk
|
|
46
|
-
? html`Sent through mail service ${mail!.service} as ${mail!.from_address}.`
|
|
47
|
-
: html`<strong>Not offered yet:</strong> the application needs an enabled mail service with a From address (Workspace utilities → Mail services).`}
|
|
48
|
-
Only for the user directory's own passwords: not for LDAP, single sign-on, database accounts or custom authentication.</small></div>
|
|
49
45
|
</div>
|
|
50
|
-
<
|
|
51
|
-
|
|
46
|
+
<div class="field"><label class="check"><input type="checkbox" name="password_reset" value="true"${a.password_reset ? raw(' checked') : ''}> Offer "Forgot password?"</label>
|
|
47
|
+
<small class="help">Users with a password and an e-mail address in the user directory get a single-use link, valid for 30 minutes, to choose a new password. Only for the user directory's own passwords.
|
|
48
|
+
${mailOk ? html`Sent through mail service ${mail!.service} as ${mail!.from_address}.` : noMail}</small></div>`;
|
|
49
|
+
const twoFactor = html`<div class="form-grid">
|
|
52
50
|
<div class="field"><label class="label" for="f_mfa">Second step</label>
|
|
53
51
|
<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>
|
|
54
|
-
<small class="help" id="f_mfa_help">After the password
|
|
52
|
+
<small class="help" id="f_mfa_help">After the password or an LDAP directory: a code from an authenticator app, a passkey or a recovery code, added on <em>My account</em> or while signing in.
|
|
55
53
|
${secretKeyConfigured() ? '' : html`<strong>Not available yet:</strong> the server needs <code>PGKILN_SECRET_KEY</code> to keep the apps' secrets.`}</small></div>
|
|
56
54
|
<div class="field"><label class="label" for="f_mfa_roles">Required for roles</label>
|
|
57
55
|
<input id="f_mfa_roles" name="mfa_roles" value="${(a.mfa_roles ?? []).join(', ')}" placeholder="admin, manager" aria-describedby="f_mfa_roles_help">
|
|
58
56
|
<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>
|
|
59
|
-
</div
|
|
57
|
+
</div>
|
|
58
|
+
<div class="field"><label class="check"><input type="checkbox" name="mfa_email" value="true"${a.mfa_email ? raw(' checked') : ''}> Offer a code by e-mail</label>
|
|
59
|
+
<small class="help">Users who already have an app or a passkey may get a one-time code by e-mail instead (valid ${EMAIL_CODE_MINUTES} minutes, at most ${EMAIL_CODES_PER_HOUR} an hour); never as their only factor.
|
|
60
|
+
${mailOk ? '' : noMail}</small></div>
|
|
61
|
+
<p class="muted small">Passkeys need the application at a host name, not an IP address. Identity providers ask for their own second factor (see the provider's <em>Multi-factor sign-in</em>). To ask again before a page opens (step-up), give its authorization scheme <em>Second step within (minutes)</em> in Shared Components.</p>`;
|
|
62
|
+
return { page, twoFactor };
|
|
60
63
|
}
|
|
61
64
|
|
|
62
65
|
const MFA_LABELS: Record<AppMfa, string> = { off: 'Off', optional: 'Optional (users may add an app)', required: 'Required', roles: 'Required for some roles' };
|
|
@@ -71,8 +74,8 @@ export async function saveLoginSettings(appId: string, b: Record<string, string
|
|
|
71
74
|
if (mfa === 'roles' && !roles.length) throw new Error('Two-factor sign-in: name the roles that must use it.');
|
|
72
75
|
// without the instance key, users could not add an app: requiring one would lock them out
|
|
73
76
|
if ((mfa === 'required' || mfa === 'roles') && !secretKeyConfigured()) throw new Error('Two-factor sign-in: the server needs PGKILN_SECRET_KEY before it can be required.');
|
|
74
|
-
await owner.query('update meta.app set login_page = $2, password_reset = $3, mfa = $4, mfa_roles = $5 where id = $1',
|
|
75
|
-
[appId, page, b.password_reset === 'true', mfa, roles]);
|
|
77
|
+
await owner.query('update meta.app set login_page = $2, password_reset = $3, mfa = $4, mfa_roles = $5, mfa_email = $6 where id = $1',
|
|
78
|
+
[appId, page, b.password_reset === 'true', mfa, roles, b.mfa_email === 'true']);
|
|
76
79
|
}
|
|
77
80
|
|
|
78
81
|
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) {
|
package/src/builder/routes.ts
CHANGED
|
@@ -264,7 +264,16 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
264
264
|
const username = await resolveDeveloper(src, { ...result.identity, username: result.username, groups: result.groups });
|
|
265
265
|
const dev = await owner.one<{ username: string; language: string | null }>('select username, language from meta.developer where username = $1', [username]);
|
|
266
266
|
if (!dev) throw new SsoError('The developer was removed.');
|
|
267
|
-
|
|
267
|
+
// (sprint 132, SEC-4) the provider leaves the second step to pgkiln: as after a password (the trusted-browser
|
|
268
|
+
// cookie belongs to /builder and doesn't reach this callback, so the step is always asked); a developer without a
|
|
269
|
+
// factor signs in without __SSO, so a 2FA rule makes them add one (ui.ts)
|
|
270
|
+
if (p.local_second_step && (await hasFactor(dev.username))) {
|
|
271
|
+
const ns = await createSession(reply, null, BASE);
|
|
272
|
+
ns.state.__MFA = JSON.stringify({ u: dev.username, ws: null, w: '', next: safeNext(result.next), remember: false, weak: false, at: Date.now() });
|
|
273
|
+
await saveState(ns);
|
|
274
|
+
return reply.redirect(`${BASE}/login/2fa`, 303);
|
|
275
|
+
}
|
|
276
|
+
return finishSignIn(req, reply, null, dev, { ws: null, workspace: '', next: safeNext(result.next), remember: false, weak: false, picked: null, detail: `builder sso:${p.name}${result.mfa ? ' (mfa)' : ''}`, sso: p.local_second_step ? undefined : p.name });
|
|
268
277
|
});
|
|
269
278
|
|
|
270
279
|
// (sprint 128, SEC-2) signing in with a passkey alone: no user name, no password. The browser offers the
|
|
@@ -762,6 +771,7 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
762
771
|
if (!a) return reply.code(404).send('Not found');
|
|
763
772
|
const providers = (await owner.query('select name, display_name, enabled from meta.auth_provider order by display_name')).rows;
|
|
764
773
|
const directories = (await owner.query('select name, display_name, enabled from meta.ldap_directory order by display_name')).rows;
|
|
774
|
+
const signinFields = await loginSettingsFields(a);
|
|
765
775
|
const lists = (await owner.query('select name from meta.list where app_id = $1 order by name', [a.id])).rows.map((r) => r.name as string);
|
|
766
776
|
const listChoices = (v: string | null, none: string): [string, string][] => [['', none], ...lists.map((n): [string, string] => [n, n]), ...(v && !lists.includes(v) ? [[v, tx('st.missing', { name: v })] as [string, string]] : [])];
|
|
767
777
|
// (sprint 74) tabs like APEX's Edit Application Definition and Security Attributes: every tab of the
|
|
@@ -822,15 +832,21 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
822
832
|
<h3>${tx('st.methods')}</h3>
|
|
823
833
|
<div class="field"><label class="check"><input type="checkbox" name="local_login" value="true"${a.local_login ? raw(' checked') : ''}> ${tx('st.local')}</label></div>
|
|
824
834
|
${directories.length
|
|
825
|
-
? directories.map((d) => html`<div class="field"><label class="check"><input type="checkbox" name="ldap_directories" value="${d.name}"${a.ldap_directories.includes(d.name) ? raw(' checked') : ''}> ${tx('st.ldap', { name: d.display_name })}${d.enabled ? '' : ` ${tx('st.disabled')}`}</label></div>`)
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
<div class="form-grid">${input('session_group', tx('st.session_group'), a.session_group ?? '', { placeholder: tx('st.session_group_ph'), help: tx('st.session_group_help') })}</div>
|
|
829
|
-
${await loginSettingsFields(a)}
|
|
830
|
-
<div class="form-grid">${input('remember_me_days', tx('st.remember'), a.remember_me_days ?? '', { type: 'number', placeholder: tx('st.remember_ph'), help: tx('st.remember_help') })}</div>
|
|
835
|
+
? html`${directories.map((d) => html`<div class="field"><label class="check"><input type="checkbox" name="ldap_directories" value="${d.name}"${a.ldap_directories.includes(d.name) ? raw(' checked') : ''}> ${tx('st.ldap', { name: d.display_name })}${d.enabled ? '' : ` ${tx('st.disabled')}`}</label></div>`)}
|
|
836
|
+
<small class="help">${tx('st.ldap_help')}</small>`
|
|
837
|
+
: html`<p class="muted small">${tx.rich('st.no_ldap', { add: html`<a href="${BASE}/users/directories">${tx('st.add_one')}</a>` })}</p>`}
|
|
831
838
|
${providers.length
|
|
832
839
|
? providers.map((pr) => html`<div class="field"><label class="check"><input type="checkbox" name="sso_providers" value="${pr.name}"${a.sso_providers.includes(pr.name) ? raw(' checked') : ''}> ${tx('st.sso', { name: pr.display_name })}${pr.enabled ? '' : ` ${tx('st.disabled')}`}</label></div>`)
|
|
833
|
-
: html`<p class="muted">${tx.rich('st.no_sso', { add: html`<a href="${BASE}/users/providers">${tx('st.add_one')}</a>` })}</p>`}
|
|
840
|
+
: html`<p class="muted small">${tx.rich('st.no_sso', { add: html`<a href="${BASE}/users/providers">${tx('st.add_one')}</a>` })}</p>`}
|
|
841
|
+
<h3>${tx('st.signin_page')}</h3>
|
|
842
|
+
${signinFields.page}
|
|
843
|
+
<h3>${tx('st.staying')}</h3>
|
|
844
|
+
<div class="form-grid">
|
|
845
|
+
${input('remember_me_days', tx('st.remember'), a.remember_me_days ?? '', { type: 'number', placeholder: tx('st.remember_ph'), help: tx('st.remember_help') })}
|
|
846
|
+
${input('session_group', tx('st.session_group'), a.session_group ?? '', { placeholder: tx('st.session_group_ph'), help: tx('st.session_group_help') })}
|
|
847
|
+
</div>
|
|
848
|
+
<h3 id="two-factor">${tx('st.two_factor')}</h3>
|
|
849
|
+
${signinFields.twoFactor}
|
|
834
850
|
`)}${end}
|
|
835
851
|
${panel('ui')}${region(tx('st.tab.ui'), html`
|
|
836
852
|
<h3>${tx('st.theme')}</h3>
|
package/src/builder/security.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
|
package/src/builder/shared.ts
CHANGED
|
@@ -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="
|
|
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>
|
package/src/builder/users.ts
CHANGED
|
@@ -398,6 +398,9 @@ export async function usersRoutes(app: FastifyInstance) {
|
|
|
398
398
|
});
|
|
399
399
|
|
|
400
400
|
// ---------------------------------------------------------------- identity providers (OpenID Connect)
|
|
401
|
+
/** (134) the values a new provider counts as a second factor: amr "mfa" (Entra ID, Okta), Entra ID's and SAML's own */
|
|
402
|
+
const DEFAULT_MFA_VALUES = ['mfa', 'http://schemas.microsoft.com/claims/multipleauthn',
|
|
403
|
+
'urn:oasis:names:tc:SAML:2.0:ac:classes:MobileTwoFactorContract', 'urn:oasis:names:tc:SAML:2.0:ac:classes:MobileTwoFactorUnregistered'];
|
|
401
404
|
const providerForm = (pr: any, action: string, csrfField: ReturnType<typeof csrf>, isNew: boolean, tx: BuilderTranslate) => html`
|
|
402
405
|
<form method="post" action="${action}">${csrfField}
|
|
403
406
|
<div class="form-grid">
|
|
@@ -419,6 +422,16 @@ export async function usersRoutes(app: FastifyInstance) {
|
|
|
419
422
|
<small class="help">${tx('idp.auto_create_help')}</small></div>
|
|
420
423
|
<div class="field"><label class="check"><input type="checkbox" name="link_existing" value="true"${pr.link_existing ? raw(' checked') : ''}> ${tx('idp.link')}</label>
|
|
421
424
|
<small class="help">${tx('idp.link_help')}</small></div>
|
|
425
|
+
<fieldset class="prop-group u-mt075"><legend>${tx('idp.mfa_title')}</legend>
|
|
426
|
+
<div class="field"><label class="check"><input type="checkbox" name="mfa_required" value="true"${pr.mfa_required ? raw(' checked') : ''}> ${tx('idp.mfa_required')}</label>
|
|
427
|
+
<small class="help">${tx('idp.mfa_required_help')}</small></div>
|
|
428
|
+
<div class="form-grid">
|
|
429
|
+
${input('mfa_request', tx('idp.mfa_request'), pr.mfa_request ?? '', { help: tx('idp.mfa_request_help') })}
|
|
430
|
+
${input('mfa_values', tx('idp.mfa_values'), (pr.mfa_values ?? DEFAULT_MFA_VALUES).join(' '), { help: tx('idp.mfa_values_help') })}
|
|
431
|
+
</div>
|
|
432
|
+
<div class="field"><label class="check"><input type="checkbox" name="local_second_step" value="true"${pr.local_second_step ? raw(' checked') : ''}> ${tx('idp.local_step')}</label>
|
|
433
|
+
<small class="help">${tx('idp.local_step_help')}</small></div>
|
|
434
|
+
</fieldset>
|
|
422
435
|
<div class="field"><label class="check"><input type="checkbox" name="enabled" value="true"${pr.enabled !== false ? raw(' checked') : ''}> ${tx('idp.enabled')}</label></div>
|
|
423
436
|
${!isNew && pr.has_secret ? html`<div class="field"><label class="check"><input type="checkbox" name="remove_secret" value="true"> ${tx('idp.remove_secret')}</label></div>` : ''}
|
|
424
437
|
<div class="buttons"><button class="btn btn-hot">${isNew ? tx('idp.add') : tx('us.save')}</button></div>
|
|
@@ -439,13 +452,17 @@ export async function usersRoutes(app: FastifyInstance) {
|
|
|
439
452
|
b.auto_create === 'true', b.enabled === 'true',
|
|
440
453
|
protocol === 'saml' ? b.idp_sso_url?.trim() || null : null, protocol === 'saml' ? pem(b.idp_cert) : null,
|
|
441
454
|
b.link_existing === 'true',
|
|
455
|
+
// (sprint 132, SEC-4) multi-factor sign-in at the provider
|
|
456
|
+
b.mfa_required === 'true', b.mfa_request?.trim().replace(/\s+/g, ' ').slice(0, 500) || null,
|
|
457
|
+
words(b.mfa_values).length ? words(b.mfa_values) : DEFAULT_MFA_VALUES, b.local_second_step === 'true',
|
|
442
458
|
];
|
|
459
|
+
const words = (v: string | undefined) => [...new Set((v ?? '').split(/\s+/).filter(Boolean))].slice(0, 20);
|
|
443
460
|
|
|
444
461
|
app.get(`${BASE}/users/providers`, async (req: Req, reply) => {
|
|
445
462
|
const s = await administrator(req, reply, 'Identity providers');
|
|
446
463
|
if (!s) return;
|
|
447
464
|
const tx = tr(s);
|
|
448
|
-
const rows = (await owner.query(`select id, name, display_name, issuer, enabled, auto_create, link_existing,
|
|
465
|
+
const rows = (await owner.query(`select id, name, display_name, issuer, enabled, auto_create, link_existing, mfa_required,
|
|
449
466
|
(select count(*) from meta.account_identity i where i.provider_id = p.id)::int as linked,
|
|
450
467
|
(select string_agg(a.alias, ', ' order by a.alias) from meta.app a where p.name = any(a.sso_providers)) as apps
|
|
451
468
|
from meta.auth_provider p order by display_name`)).rows;
|
|
@@ -460,7 +477,7 @@ export async function usersRoutes(app: FastifyInstance) {
|
|
|
460
477
|
<td data-label="${tx('idp.issuer_col')}">${r.issuer}</td>
|
|
461
478
|
<td data-label="${tx('idp.used_by')}">${r.apps ?? html`<span class="muted">${tx('idp.no_apps')}</span>`}</td>
|
|
462
479
|
<td data-label="${tx('idp.linked')}">${r.linked}</td>
|
|
463
|
-
<td data-label="${tx('us.status')}">${r.enabled ? tx('idp.enabled_s') : html`<b>${tx('idp.disabled_s')}</b>`}${r.auto_create ? ` · ${tx('idp.auto_s')}` : ''}${r.link_existing ? ` · ${tx('idp.links_s')}` : ''}</td>
|
|
480
|
+
<td data-label="${tx('us.status')}">${r.enabled ? tx('idp.enabled_s') : html`<b>${tx('idp.disabled_s')}</b>`}${r.auto_create ? ` · ${tx('idp.auto_s')}` : ''}${r.link_existing ? ` · ${tx('idp.links_s')}` : ''}${r.mfa_required ? ` · ${tx('idp.mfa_s')}` : ''}</td>
|
|
464
481
|
</tr>`) : html`<tr><td colspan="5" class="empty">${tx('idp.none')}</td></tr>`}</tbody></table></div>`)}
|
|
465
482
|
${region(tx('idp.add'), providerForm({}, `${BASE}/users/providers`, csrf(s), true, tx))}
|
|
466
483
|
</div>`;
|
|
@@ -473,8 +490,9 @@ export async function usersRoutes(app: FastifyInstance) {
|
|
|
473
490
|
const b = req.body ?? {};
|
|
474
491
|
try {
|
|
475
492
|
const r = await owner.one(
|
|
476
|
-
`insert into meta.auth_provider (name, display_name, issuer, client_id, scopes, username_claim, groups_claim, auto_create, enabled, idp_sso_url, idp_cert, link_existing,
|
|
477
|
-
|
|
493
|
+
`insert into meta.auth_provider (name, display_name, issuer, client_id, scopes, username_claim, groups_claim, auto_create, enabled, idp_sso_url, idp_cert, link_existing,
|
|
494
|
+
mfa_required, mfa_request, mfa_values, local_second_step, protocol, client_secret)
|
|
495
|
+
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18) returning id`,
|
|
478
496
|
[b.name?.trim().toLowerCase(), ...providerValues(b, protocolOf(b.protocol), b.name?.trim().toLowerCase() ?? ''), protocolOf(b.protocol), b.client_secret || null],
|
|
479
497
|
);
|
|
480
498
|
flash(s, tr(s)('idp.added'));
|
|
@@ -523,7 +541,8 @@ export async function usersRoutes(app: FastifyInstance) {
|
|
|
523
541
|
await owner.query(
|
|
524
542
|
`update meta.auth_provider set display_name = $2, issuer = $3, client_id = $4, scopes = $5, username_claim = $6,
|
|
525
543
|
groups_claim = $7, auto_create = $8, enabled = $9, idp_sso_url = $10, idp_cert = $11, link_existing = $12,
|
|
526
|
-
|
|
544
|
+
mfa_required = $13, mfa_request = $14, mfa_values = $15, local_second_step = $16,
|
|
545
|
+
client_secret = case when $18 then null when $17::text is null then client_secret else $17 end
|
|
527
546
|
where id = $1`,
|
|
528
547
|
[req.params.id, ...providerValues(b, current.protocol, current.name), b.client_secret || null, b.remove_secret === 'true'],
|
|
529
548
|
);
|
package/src/i18n/ar.ts
CHANGED
|
@@ -839,4 +839,35 @@ export const ar: Record<MessageKey, string> = {
|
|
|
839
839
|
"mfa.codes_left": "رموز الاسترداد المتبقية: {n}",
|
|
840
840
|
"mfa.codes_new": "رموز استرداد جديدة",
|
|
841
841
|
"mfa.recovery_used": "سجّلت الدخول باستخدام رمز استرداد. رموز الاسترداد المتبقية: {n}.",
|
|
842
|
+
// (sprint 131) passkeys, e-mail codes, step-up, API tokens
|
|
843
|
+
"mfa.on_any": "عند تسجيل الدخول، تُدخل أيضًا رمزًا أو تستخدم مفتاح مرور.",
|
|
844
|
+
"mfa.use_passkey": "استخدام مفتاح مرور",
|
|
845
|
+
"mfa.passkey_failed": "لم يعمل مفتاح المرور. حاول مرة أخرى أو أدخل رمزًا.",
|
|
846
|
+
"mfa.email_send": "أرسل لي رمزًا بالبريد الإلكتروني بدلًا من ذلك",
|
|
847
|
+
"mfa.email_sent": "أرسلنا رمزًا إلى عنوان بريدك الإلكتروني. وهو صالح لمدة {minutes} دقيقة.",
|
|
848
|
+
"mfa.email_failed": "تعذّر إرسال رمز الآن. استخدم تطبيق المصادقة أو مفتاح مرور أو رمز استرداد.",
|
|
849
|
+
"mfa.email_subject": "رمز تسجيل الدخول إلى {app}",
|
|
850
|
+
"mfa.email_body": "رمز تسجيل الدخول إلى {app} هو {code}. وهو صالح لمدة {minutes} دقيقة.\n\nإذا لم تحاول تسجيل الدخول، فغيّر كلمة المرور.",
|
|
851
|
+
"mfa.stepup_title": "أكّد أنك أنت",
|
|
852
|
+
"mfa.stepup_intro": "تطلب هذه الصفحة خطوتك الثانية مرة أخرى. أدخل رمزًا من تطبيق المصادقة أو رمز استرداد، أو استخدم مفتاح مرور.",
|
|
853
|
+
"mfa.stepup_none": "تتطلب هذه الصفحة تسجيل الدخول بخطوتين. أضف أولًا تطبيق مصادقة أو مفتاح مرور في حسابي.",
|
|
854
|
+
"mfa.my_account": "حسابي",
|
|
855
|
+
"mfa.passkey_default": "مفتاح مرور",
|
|
856
|
+
"mfa.add_passkey": "إضافة مفتاح مرور",
|
|
857
|
+
"mfa.passkey_intro": "يتيح لك مفتاح المرور تسجيل الدخول ببصمة الإصبع أو الوجه أو قفل الشاشة أو مفتاح أمان، دون كتابة رمز.",
|
|
858
|
+
"mfa.passkey_added": "تمت إضافة مفتاح المرور.",
|
|
859
|
+
"mfa.passkey_removed": "تمت إزالة مفتاح المرور.",
|
|
860
|
+
"mfa.passkey_known": "تمت إضافة مفتاح المرور هذا من قبل.",
|
|
861
|
+
"mfa.passkey_needs_host": "تتطلب مفاتيح المرور أن يكون عنوان التطبيق اسم مضيف، لا عنوان IP.",
|
|
862
|
+
"mfa.passkey_needs_js": "تتطلب إضافة مفتاح مرور JavaScript.",
|
|
863
|
+
"mfa.or_passkey": "أو أضف مفتاح مرور بدلًا من ذلك:",
|
|
864
|
+
"mfa.kind_passkey": "مفتاح مرور",
|
|
865
|
+
"mfa.kind_app": "تطبيق المصادقة",
|
|
866
|
+
"token.title": "رمز REST API المميز",
|
|
867
|
+
"token.intro": "يتيح الرمز المميز لبرنامج نصي استخدام REST API لهذا التطبيق باسمك حتى تنتهي صلاحيته. يُعرض مرة واحدة فقط.",
|
|
868
|
+
"token.needs_step": "تُقدّم خطوتك الثانية مرة أخرى أولًا.",
|
|
869
|
+
"token.hours": "صالح لمدة (ساعات)",
|
|
870
|
+
"token.create": "إنشاء رمز مميز",
|
|
871
|
+
"token.shown_once": "رمزك المميز (يُعرض مرة واحدة، صالح {hours} ساعة):",
|
|
872
|
+
"token.step_first": "أكّد أنك أنت قبل إنشاء رمز مميز.",
|
|
842
873
|
};
|