pgkiln 0.60.0 → 0.61.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SECURITY.md +2 -1
- package/db/migrations/129_developer_passkeys.sql +49 -0
- package/docs/apex-feature-parity.md +2 -2
- package/docs/guide/01-installation.md +2 -1
- package/docs/guide/03-builder.md +28 -7
- package/package.json +1 -1
- package/public/builder.js +61 -0
- package/src/builder/i18n/de.ts +33 -9
- package/src/builder/i18n/en.ts +33 -9
- package/src/builder/i18n/es.ts +33 -9
- package/src/builder/i18n/fr.ts +33 -9
- package/src/builder/i18n/nl.ts +33 -9
- package/src/builder/i18n/pt.ts +33 -9
- package/src/builder/routes.ts +82 -6
- package/src/builder/security.ts +125 -15
- package/src/instance.ts +2 -1
- package/src/mfa.ts +75 -2
- package/src/webauthn.ts +205 -0
package/src/builder/i18n/nl.ts
CHANGED
|
@@ -1714,16 +1714,16 @@ export const nl: BuilderMessages = {
|
|
|
1714
1714
|
// ------------------------------------------------------------ Security: two-factor sign-in (sprint 126)
|
|
1715
1715
|
'mfa.menu': 'Aanmelden in twee stappen',
|
|
1716
1716
|
'mfa.title': 'Beveiliging',
|
|
1717
|
-
'mfa.intro': '
|
|
1718
|
-
'mfa.required_you': 'Uw beheerder vereist aanmelden in twee stappen: voeg een authenticator-app toe.',
|
|
1717
|
+
'mfa.intro': 'Bevestig na uw wachtwoord dat u het bent met een passkey (vingerafdruk, gezicht, beveiligingssleutel) of een code uit een authenticator-app. Met herstelcodes komt u binnen als u die kwijtraakt.',
|
|
1718
|
+
'mfa.required_you': 'Uw beheerder vereist aanmelden in twee stappen: voeg een passkey of een authenticator-app toe.',
|
|
1719
1719
|
'mfa.banner': 'Aanmelden in twee stappen is verplicht. Stel het in vóór {date}: {link}',
|
|
1720
1720
|
'mfa.set_up_now': 'nu instellen',
|
|
1721
|
-
'mfa.
|
|
1721
|
+
'mfa.factors': 'Passkeys en authenticator-apps',
|
|
1722
1722
|
'mfa.app_name': 'Naam',
|
|
1723
1723
|
'mfa.app_default': 'Telefoon',
|
|
1724
1724
|
'mfa.added': 'Toegevoegd',
|
|
1725
1725
|
'mfa.last_used': 'Laatst gebruikt',
|
|
1726
|
-
'mfa.no_apps': 'Nog geen
|
|
1726
|
+
'mfa.no_apps': 'Nog geen tweede factor: u meldt zich alleen met uw wachtwoord aan.',
|
|
1727
1727
|
'mfa.add_app': 'Authenticator-app toevoegen',
|
|
1728
1728
|
'mfa.no_key': 'Aanmelden in twee stappen heeft de instantiesleutel (PGKILN_SECRET_KEY) nodig om het geheim van de app op te slaan.',
|
|
1729
1729
|
'mfa.scan': 'Scan de QR-code met een authenticator-app.',
|
|
@@ -1739,28 +1739,28 @@ export const nl: BuilderMessages = {
|
|
|
1739
1739
|
'mfa.remove_app': 'Deze app verwijderen',
|
|
1740
1740
|
'mfa.your_password': 'Uw wachtwoord',
|
|
1741
1741
|
'mfa.wrong_password': 'Onjuist wachtwoord.',
|
|
1742
|
-
'mfa.cannot_remove_required': 'Aanmelden in twee stappen is voor u verplicht: voeg eerst een andere app toe.',
|
|
1742
|
+
'mfa.cannot_remove_required': 'Aanmelden in twee stappen is voor u verplicht: voeg eerst een andere passkey of app toe.',
|
|
1743
1743
|
'mfa.cannot_remove_admin': 'Minstens twee beheerders (of alle, als het er minder zijn) moeten aanmelden in twee stappen houden zolang het beleid aan staat.',
|
|
1744
1744
|
'mfa.keep_admins': 'Nu niet: het beleid vereist minstens twee beheerders met aanmelden in twee stappen (of alle, als het er minder zijn).',
|
|
1745
|
-
'mfa.removed': '
|
|
1745
|
+
'mfa.removed': 'Verwijderd.',
|
|
1746
1746
|
'mfa.codes_title': 'Herstelcodes',
|
|
1747
1747
|
'mfa.codes_once': 'Bewaar deze codes op een veilige plek, niet bij uw telefoon. Elke code vervangt één keer een app-code. Ze worden alleen nu getoond.',
|
|
1748
1748
|
'mfa.codes_saved': 'Ik heb deze codes bewaard',
|
|
1749
1749
|
'mfa.codes_done': 'Klaar',
|
|
1750
1750
|
'mfa.codes_kept': 'Herstelcodes bewaard.',
|
|
1751
1751
|
'mfa.codes_left': 'Ongebruikte herstelcodes: {n}.',
|
|
1752
|
-
'mfa.codes_after': 'U krijgt herstelcodes zodra u een authenticator-app toevoegt.',
|
|
1752
|
+
'mfa.codes_after': 'U krijgt herstelcodes zodra u een passkey of authenticator-app toevoegt.',
|
|
1753
1753
|
'mfa.codes_low': 'Nog maar weinig herstelcodes: maak nieuwe.',
|
|
1754
1754
|
'mfa.codes_new': 'Nieuwe herstelcodes',
|
|
1755
1755
|
'mfa.codes_new_confirm': 'Nieuwe herstelcodes maken? De oude werken dan niet meer.',
|
|
1756
1756
|
'mfa.expired': 'Het aanmelden duurde te lang: meld u opnieuw aan.',
|
|
1757
1757
|
'mfa.step_title': 'Aanmelden in twee stappen',
|
|
1758
|
-
'mfa.step_intro': '
|
|
1758
|
+
'mfa.step_intro': 'Bevestig dat u het bent, {user}: gebruik een passkey of voer een code uit uw authenticator-app in.',
|
|
1759
1759
|
'mfa.code': 'Code',
|
|
1760
1760
|
'mfa.code_help': '6 cijfers uit de app, of een van uw herstelcodes.',
|
|
1761
1761
|
'mfa.verify': 'Controleren',
|
|
1762
1762
|
'mfa.cancel': 'Terug naar aanmelden',
|
|
1763
|
-
'mfa.locked_out': 'Telefoon en codes kwijt? Vraag een beheerder uw aanmelden in twee stappen te resetten.',
|
|
1763
|
+
'mfa.locked_out': 'Telefoon, sleutel en codes kwijt? Vraag een beheerder uw aanmelden in twee stappen te resetten.',
|
|
1764
1764
|
'mfa.wrong': 'Die code klopt niet of is al gebruikt.',
|
|
1765
1765
|
'mfa.column': 'Twee stappen',
|
|
1766
1766
|
'mfa.on': 'aan',
|
|
@@ -1784,4 +1784,28 @@ export const nl: BuilderMessages = {
|
|
|
1784
1784
|
'mfa.policy_needs_admins': 'Eerst moeten minstens twee beheerders (of alle, als het er minder zijn) aanmelden in twee stappen instellen, zodat niemand alleen buitengesloten raakt.',
|
|
1785
1785
|
'mfa.without': 'Zonder aanmelden in twee stappen: {list}.',
|
|
1786
1786
|
'mfa.cli_note': 'Buitengesloten? Op de server: pgkiln admin reset-2fa <developer> of pgkiln admin set-2fa-policy off.',
|
|
1787
|
+
// ------------------------------------------------------------ Security: passkeys and trusted browsers (sprint 127)
|
|
1788
|
+
'mfa.kind': 'Soort',
|
|
1789
|
+
'mfa.kind_passkey': 'Passkeys',
|
|
1790
|
+
'mfa.kind_app': 'Authenticator-apps',
|
|
1791
|
+
'mfa.other_domain': 'voor {domain}',
|
|
1792
|
+
'mfa.other_domain_help': 'Gemaakt voor {domain}: werkt niet op dit adres. Voeg hier een nieuwe passkey toe.',
|
|
1793
|
+
'mfa.passkey_intro': 'Meld u aan met uw vingerafdruk, gezicht, schermvergrendeling of een beveiligingssleutel. Een passkey werkt alleen op deze site.',
|
|
1794
|
+
'mfa.passkey_default': 'Passkey',
|
|
1795
|
+
'mfa.add_passkey': 'Passkey toevoegen',
|
|
1796
|
+
'mfa.passkey_needs_js': 'Een passkey toevoegen vraagt JavaScript; een authenticator-app werkt zonder.',
|
|
1797
|
+
'mfa.passkey_failed': 'De passkey werkte niet. Probeer het opnieuw of gebruik een code.',
|
|
1798
|
+
'mfa.passkey_known': 'Deze passkey is al toegevoegd.',
|
|
1799
|
+
'mfa.passkey_added': 'Passkey toegevoegd: voortaan kunt u zich ermee aanmelden.',
|
|
1800
|
+
'mfa.remove_passkey': 'Deze passkey verwijderen',
|
|
1801
|
+
'mfa.use_passkey': 'Passkey gebruiken',
|
|
1802
|
+
'mfa.trust_browser': 'Deze browser {days} dagen vertrouwen',
|
|
1803
|
+
'mfa.trusted_title': 'Vertrouwde browsers',
|
|
1804
|
+
'mfa.trusted_count': 'Browsers die de tweede stap overslaan: {n} (hoogstens {days} dagen).',
|
|
1805
|
+
'mfa.trusted_off': 'Browsers vertrouwen staat uit.',
|
|
1806
|
+
'mfa.forget_browsers': 'Vertrouwde browsers vergeten',
|
|
1807
|
+
'mfa.browsers_forgotten': 'Elke browser vraagt weer om de tweede stap.',
|
|
1808
|
+
'mfa.trust_days': 'Browser vertrouwen (dagen)',
|
|
1809
|
+
'mfa.trust_days_help': 'Na de tweede stap mag een ontwikkelaar die in die browser zoveel dagen overslaan. 0: nooit.',
|
|
1810
|
+
'mfa.passkey_needs_host': 'Passkeys hebben een hostnaam nodig, geen IP-adres ({host}): open de builder via localhost of de domeinnaam.',
|
|
1787
1811
|
};
|
package/src/builder/i18n/pt.ts
CHANGED
|
@@ -1714,16 +1714,16 @@ export const pt: BuilderMessages = {
|
|
|
1714
1714
|
// ------------------------------------------------------------ Security: two-factor sign-in (sprint 126)
|
|
1715
1715
|
'mfa.menu': 'Login em duas etapas',
|
|
1716
1716
|
'mfa.title': 'Segurança',
|
|
1717
|
-
'mfa.intro': '
|
|
1718
|
-
'mfa.required_you': 'Seu administrador exige login em duas etapas: adicione um aplicativo autenticador.',
|
|
1717
|
+
'mfa.intro': 'Depois da senha, confirme que é você com uma chave de acesso (digital, rosto, chave de segurança) ou um código de um aplicativo autenticador. Os códigos de recuperação permitem entrar se você os perder.',
|
|
1718
|
+
'mfa.required_you': 'Seu administrador exige login em duas etapas: adicione uma chave de acesso ou um aplicativo autenticador.',
|
|
1719
1719
|
'mfa.banner': 'O login em duas etapas é obrigatório. Configure-o antes de {date}: {link}',
|
|
1720
1720
|
'mfa.set_up_now': 'configurar agora',
|
|
1721
|
-
'mfa.
|
|
1721
|
+
'mfa.factors': 'Chaves de acesso e aplicativos autenticadores',
|
|
1722
1722
|
'mfa.app_name': 'Nome',
|
|
1723
1723
|
'mfa.app_default': 'Telefone',
|
|
1724
1724
|
'mfa.added': 'Adicionado',
|
|
1725
1725
|
'mfa.last_used': 'Último uso',
|
|
1726
|
-
'mfa.no_apps': 'Nenhum
|
|
1726
|
+
'mfa.no_apps': 'Nenhum segundo fator ainda: você entra apenas com sua senha.',
|
|
1727
1727
|
'mfa.add_app': 'Adicionar um aplicativo autenticador',
|
|
1728
1728
|
'mfa.no_key': 'O login em duas etapas precisa da chave da instância (PGKILN_SECRET_KEY) para guardar o segredo do aplicativo.',
|
|
1729
1729
|
'mfa.scan': 'Leia o código QR com um aplicativo autenticador.',
|
|
@@ -1739,28 +1739,28 @@ export const pt: BuilderMessages = {
|
|
|
1739
1739
|
'mfa.remove_app': 'Remover este aplicativo',
|
|
1740
1740
|
'mfa.your_password': 'Sua senha',
|
|
1741
1741
|
'mfa.wrong_password': 'Senha incorreta.',
|
|
1742
|
-
'mfa.cannot_remove_required': 'O login em duas etapas é obrigatório para você: adicione
|
|
1742
|
+
'mfa.cannot_remove_required': 'O login em duas etapas é obrigatório para você: adicione outra chave ou aplicativo antes de remover este.',
|
|
1743
1743
|
'mfa.cannot_remove_admin': 'Pelo menos dois administradores (ou todos, se forem menos) devem manter o login em duas etapas enquanto a política estiver ativa.',
|
|
1744
1744
|
'mfa.keep_admins': 'Agora não: a política exige pelo menos dois administradores com login em duas etapas (ou todos, se forem menos).',
|
|
1745
|
-
'mfa.removed': '
|
|
1745
|
+
'mfa.removed': 'Removido.',
|
|
1746
1746
|
'mfa.codes_title': 'Códigos de recuperação',
|
|
1747
1747
|
'mfa.codes_once': 'Guarde estes códigos em local seguro, longe do telefone. Cada um substitui uma vez um código do aplicativo. Eles são mostrados só agora.',
|
|
1748
1748
|
'mfa.codes_saved': 'Guardei estes códigos',
|
|
1749
1749
|
'mfa.codes_done': 'Concluído',
|
|
1750
1750
|
'mfa.codes_kept': 'Códigos de recuperação guardados.',
|
|
1751
1751
|
'mfa.codes_left': 'Códigos de recuperação não usados: {n}.',
|
|
1752
|
-
'mfa.codes_after': 'Você recebe códigos de recuperação ao adicionar um aplicativo autenticador.',
|
|
1752
|
+
'mfa.codes_after': 'Você recebe códigos de recuperação ao adicionar uma chave de acesso ou um aplicativo autenticador.',
|
|
1753
1753
|
'mfa.codes_low': 'Restam poucos códigos de recuperação: gere novos.',
|
|
1754
1754
|
'mfa.codes_new': 'Novos códigos de recuperação',
|
|
1755
1755
|
'mfa.codes_new_confirm': 'Gerar novos códigos de recuperação? Os antigos deixam de funcionar.',
|
|
1756
1756
|
'mfa.expired': 'O login demorou demais: entre novamente.',
|
|
1757
1757
|
'mfa.step_title': 'Login em duas etapas',
|
|
1758
|
-
'mfa.step_intro': '
|
|
1758
|
+
'mfa.step_intro': 'Confirme que é você, {user}: use uma chave de acesso ou digite um código do seu aplicativo autenticador.',
|
|
1759
1759
|
'mfa.code': 'Código',
|
|
1760
1760
|
'mfa.code_help': '6 dígitos do aplicativo ou um dos seus códigos de recuperação.',
|
|
1761
1761
|
'mfa.verify': 'Verificar',
|
|
1762
1762
|
'mfa.cancel': 'Voltar ao login',
|
|
1763
|
-
'mfa.locked_out': 'Perdeu o telefone e os códigos? Peça a um administrador para redefinir seu login em duas etapas.',
|
|
1763
|
+
'mfa.locked_out': 'Perdeu o telefone, a chave e os códigos? Peça a um administrador para redefinir seu login em duas etapas.',
|
|
1764
1764
|
'mfa.wrong': 'Esse código não está certo ou já foi usado.',
|
|
1765
1765
|
'mfa.column': 'Duas etapas',
|
|
1766
1766
|
'mfa.on': 'ativado',
|
|
@@ -1784,4 +1784,28 @@ export const pt: BuilderMessages = {
|
|
|
1784
1784
|
'mfa.policy_needs_admins': 'Primeiro, pelo menos dois administradores (ou todos, se forem menos) configuram o login em duas etapas, para ninguém ficar bloqueado sozinho.',
|
|
1785
1785
|
'mfa.without': 'Sem login em duas etapas: {list}.',
|
|
1786
1786
|
'mfa.cli_note': 'Bloqueado? No servidor: pgkiln admin reset-2fa <developer> ou pgkiln admin set-2fa-policy off.',
|
|
1787
|
+
// ------------------------------------------------------------ Security: passkeys and trusted browsers (sprint 127)
|
|
1788
|
+
'mfa.kind': 'Tipo',
|
|
1789
|
+
'mfa.kind_passkey': 'Chaves de acesso',
|
|
1790
|
+
'mfa.kind_app': 'Aplicativos autenticadores',
|
|
1791
|
+
'mfa.other_domain': 'para {domain}',
|
|
1792
|
+
'mfa.other_domain_help': 'Criada para {domain}: não funciona neste endereço. Adicione aqui uma nova chave.',
|
|
1793
|
+
'mfa.passkey_intro': 'Entre com sua digital, rosto, bloqueio de tela ou uma chave de segurança. Uma chave de acesso só funciona neste site.',
|
|
1794
|
+
'mfa.passkey_default': 'Chave de acesso',
|
|
1795
|
+
'mfa.add_passkey': 'Adicionar uma chave de acesso',
|
|
1796
|
+
'mfa.passkey_needs_js': 'Adicionar uma chave de acesso exige JavaScript; um aplicativo autenticador funciona sem ele.',
|
|
1797
|
+
'mfa.passkey_failed': 'A chave de acesso não funcionou. Tente de novo ou use um código.',
|
|
1798
|
+
'mfa.passkey_known': 'Esta chave de acesso já foi adicionada.',
|
|
1799
|
+
'mfa.passkey_added': 'Chave de acesso adicionada: a partir de agora você pode entrar com ela.',
|
|
1800
|
+
'mfa.remove_passkey': 'Remover esta chave de acesso',
|
|
1801
|
+
'mfa.use_passkey': 'Usar uma chave de acesso',
|
|
1802
|
+
'mfa.trust_browser': 'Confiar neste navegador por {days} dias',
|
|
1803
|
+
'mfa.trusted_title': 'Navegadores confiáveis',
|
|
1804
|
+
'mfa.trusted_count': 'Navegadores que pulam a segunda etapa: {n} (até {days} dias).',
|
|
1805
|
+
'mfa.trusted_off': 'A confiança em navegadores está desativada.',
|
|
1806
|
+
'mfa.forget_browsers': 'Esquecer os navegadores confiáveis',
|
|
1807
|
+
'mfa.browsers_forgotten': 'Todos os navegadores voltam a pedir a segunda etapa.',
|
|
1808
|
+
'mfa.trust_days': 'Confiar em um navegador (dias)',
|
|
1809
|
+
'mfa.trust_days_help': 'Depois da segunda etapa, um desenvolvedor pode pulá-la naquele navegador por estes dias. 0: nunca.',
|
|
1810
|
+
'mfa.passkey_needs_host': 'Chaves de acesso precisam de um nome de host, não de um endereço IP ({host}): abra o construtor em localhost ou no nome de domínio.',
|
|
1787
1811
|
};
|
package/src/builder/routes.ts
CHANGED
|
@@ -27,7 +27,9 @@ 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, hasFactor, leavesTooFewAdmins } from '../mfa.ts';
|
|
30
|
+
import { checkSecondFactor, codesLeft, forgetBrowsers, hasFactor, isTrustedBrowser, leavesTooFewAdmins, passkeysOf, trustBrowser, trustDays, usePasskey } from '../mfa.ts';
|
|
31
|
+
import { giveChallenge, takeChallenge } from './security.ts';
|
|
32
|
+
import { relyingParty, verifyAssertion, WebAuthnError } from '../webauthn.ts';
|
|
31
33
|
import { chooseLanguage, langCookieOptions, languageRoutes, signInLanguageLinks } from './language.ts';
|
|
32
34
|
|
|
33
35
|
// Builder pages: sign-in, workspace and app home, settings, activity and
|
|
@@ -150,8 +152,9 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
150
152
|
flash(s, tx(workspace ? 'signin.invalid_ws' : 'signin.invalid'), 'error');
|
|
151
153
|
return back(reply, s, again);
|
|
152
154
|
}
|
|
153
|
-
// (sprint 126, SEC-2) a developer with a second factor: the password was right, the session comes after the code
|
|
154
|
-
|
|
155
|
+
// (sprint 126, SEC-2) a developer with a second factor: the password was right, the session comes after the code;
|
|
156
|
+
// (sprint 127) unless this browser was trusted after a second factor, for the instance's number of days
|
|
157
|
+
if ((await hasFactor(dev.username)) && !(await isTrustedBrowser(dev.username, req.cookies?.[TRUST_COOKIE]))) {
|
|
155
158
|
s.state.__MFA = JSON.stringify({ u: dev.username, ws: ws ? String(ws.id) : null, w: workspace, next, remember: req.body?.remember === '1', weak: password === 'admin' || password === dev.username, at: Date.now() });
|
|
156
159
|
await saveState(s);
|
|
157
160
|
return reply.redirect(`${BASE}/login/2fa`, 303);
|
|
@@ -187,8 +190,17 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
187
190
|
return reply.redirect(next, 303);
|
|
188
191
|
}
|
|
189
192
|
|
|
190
|
-
// (sprint 126, SEC-2) the second step: a code from the authenticator app, or a recovery code
|
|
193
|
+
// (sprint 126, SEC-2) the second step: a code from the authenticator app, or a recovery code;
|
|
194
|
+
// (sprint 127) or a passkey, and "trust this browser" when the instance allows it
|
|
191
195
|
const MFA_SECONDS = 300;
|
|
196
|
+
const TRUST_COOKIE = 'pgkiln_btrust';
|
|
197
|
+
const trustThisBrowser = async (req: Req, reply: Parameters<typeof back>[0], username: string) => {
|
|
198
|
+
const days = await trustDays();
|
|
199
|
+
if (!days || req.body?.trust !== '1') return;
|
|
200
|
+
const token = await trustBrowser(username, days, String(req.headers['user-agent'] ?? ''));
|
|
201
|
+
reply.setCookie(TRUST_COOKIE, token, { path: BASE, httpOnly: true, sameSite: 'lax', maxAge: days * 86400, secure: process.env.COOKIE_SECURE === 'true' });
|
|
202
|
+
logActivity({ username, event: '2fa_trusted_browser', ip: clientIp(req), detail: `${days} days` });
|
|
203
|
+
};
|
|
192
204
|
const pendingMfa = (s: { state: Record<string, any> }) => {
|
|
193
205
|
try {
|
|
194
206
|
const p = JSON.parse(String(s.state.__MFA ?? ''));
|
|
@@ -209,6 +221,7 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
209
221
|
}
|
|
210
222
|
const error = takeFlash(s, '__ERROR');
|
|
211
223
|
await saveState(s);
|
|
224
|
+
const [keys, days] = await Promise.all([passkeysOf(p.u, relyingParty(req).rpId), trustDays()]);
|
|
212
225
|
return reply.type('text/html').send(documentShell(
|
|
213
226
|
`${tx('mfa.step_title')} · ${tx('shell.builder')}`,
|
|
214
227
|
html`<main class="signin">
|
|
@@ -223,10 +236,14 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
223
236
|
<p class="muted u-mt0">${tx('mfa.step_intro', { user: p.u })}</p>
|
|
224
237
|
${error ? html`<div class="alert alert-error" role="alert">${error}</div>` : ''}
|
|
225
238
|
${csrf(s)}
|
|
239
|
+
${keys.length ? html`<input type="hidden" name="credentialId"><input type="hidden" name="clientDataJSON"><input type="hidden" name="authenticatorData"><input type="hidden" name="signature">
|
|
240
|
+
<button class="btn btn-hot signin-submit" hidden data-passkey="${BASE}/login/2fa/passkey/options" data-passkey-submit="${BASE}/login/2fa/passkey" data-passkey-error="${tx('mfa.passkey_failed')}">${icon('fingerprint')} ${tx('mfa.use_passkey')}</button>
|
|
241
|
+
<p class="small" role="status" data-passkey-status></p>` : ''}
|
|
226
242
|
<div class="field signin-field"><label class="label" for="f_code">${tx('mfa.code')}</label>
|
|
227
243
|
<div class="signin-input">${icon('key')}<input id="f_code" name="code" required autocomplete="one-time-code" autofocus maxlength="20" spellcheck="false" autocapitalize="off"></div>
|
|
228
244
|
<small class="help">${tx('mfa.code_help')}</small></div>
|
|
229
|
-
|
|
245
|
+
${days ? html`<label class="check"><input type="checkbox" name="trust" value="1"> ${tx('mfa.trust_browser', { days })}</label>` : ''}
|
|
246
|
+
<button class="btn${keys.length ? '' : ' btn-hot'} signin-submit">${tx('mfa.verify')}</button>
|
|
230
247
|
<p class="muted small signin-hint"><a href="${BASE}/login">${tx('mfa.cancel')}</a> · ${tx('mfa.locked_out')}</p>
|
|
231
248
|
</form>
|
|
232
249
|
</section>
|
|
@@ -265,11 +282,69 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
265
282
|
const left = await codesLeft(p.u);
|
|
266
283
|
await logActivity({ username: p.u, event: '2fa_recovery_used', ip, detail: `${left} left` });
|
|
267
284
|
// to the Security page: one code fewer, maybe time for new ones
|
|
285
|
+
await trustThisBrowser(req, reply, p.u);
|
|
268
286
|
return finishSignIn(req, reply, s, dev, { ws: p.ws, workspace: p.w, next: `${BASE}/security`, remember: p.remember, weak: p.weak, picked, detail: 'builder 2fa (recovery code)' });
|
|
269
287
|
}
|
|
288
|
+
await trustThisBrowser(req, reply, p.u);
|
|
270
289
|
return finishSignIn(req, reply, s, dev, { ws: p.ws, workspace: p.w, next: p.next, remember: p.remember, weak: p.weak, picked, detail: 'builder 2fa' });
|
|
271
290
|
});
|
|
272
291
|
|
|
292
|
+
// (sprint 127) the second step with a passkey: options for navigator.credentials.get(), then the browser's answer
|
|
293
|
+
app.post(`${BASE}/login/2fa/passkey/options`, async (req: Req, reply) => {
|
|
294
|
+
const s = await getSession(req, reply, null, BASE);
|
|
295
|
+
const p = pendingMfa(s);
|
|
296
|
+
if (req.body?.__csrf !== s.csrf_token || !p) return reply.code(403).send('Sign in again.');
|
|
297
|
+
const rp = relyingParty(req);
|
|
298
|
+
const keys = await passkeysOf(p.u, rp.rpId);
|
|
299
|
+
if (!keys.length) return reply.code(404).send('No passkey for this site.');
|
|
300
|
+
const challenge = giveChallenge(s as Parameters<typeof giveChallenge>[0], 'get');
|
|
301
|
+
await saveState(s);
|
|
302
|
+
reply.header('cache-control', 'no-store');
|
|
303
|
+
return reply.send({ mode: 'get', challenge, rpId: rp.rpId, allow: keys.map((k) => ({ id: k.credential_id, transports: k.transports })) });
|
|
304
|
+
});
|
|
305
|
+
app.post(`${BASE}/login/2fa/passkey`, async (req: Req, reply) => {
|
|
306
|
+
const s = await getSession(req, reply, null, BASE);
|
|
307
|
+
const ip = clientIp(req);
|
|
308
|
+
const picked = validBuilderLanguage(s.state.__BLANG);
|
|
309
|
+
const tx = translatorFor(picked ?? cookieLanguage(req));
|
|
310
|
+
const p = pendingMfa(s);
|
|
311
|
+
const challenge = takeChallenge(s as Parameters<typeof takeChallenge>[0], 'get');
|
|
312
|
+
if (req.body?.__csrf !== s.csrf_token || !p) {
|
|
313
|
+
delete s.state.__MFA;
|
|
314
|
+
flash(s, tx(p ? 'signin.expired' : 'mfa.expired'), 'error');
|
|
315
|
+
return back(reply, s, `${BASE}/login`);
|
|
316
|
+
}
|
|
317
|
+
if (await loginThrottled(null, p.u, ip)) {
|
|
318
|
+
logActivity({ username: p.u, event: 'login_locked', ip, detail: 'builder 2fa' });
|
|
319
|
+
delete s.state.__MFA;
|
|
320
|
+
flash(s, tx('signin.throttled'), 'error');
|
|
321
|
+
return back(reply, s, `${BASE}/login`);
|
|
322
|
+
}
|
|
323
|
+
const rp = relyingParty(req);
|
|
324
|
+
const key = (await passkeysOf(p.u, rp.rpId)).find((k) => k.credential_id === String(req.body?.credentialId ?? ''));
|
|
325
|
+
let why = 'unknown passkey';
|
|
326
|
+
if (key && challenge) {
|
|
327
|
+
try {
|
|
328
|
+
const r = verifyAssertion(
|
|
329
|
+
{ clientDataJSON: String(req.body?.clientDataJSON ?? ''), authenticatorData: String(req.body?.authenticatorData ?? ''), signature: String(req.body?.signature ?? '') },
|
|
330
|
+
{ alg: key.alg, jwk: key.public_key, signCount: Number(key.sign_count) }, { challenge, ...rp });
|
|
331
|
+
if (await usePasskey(key.id, key.sign_count, r.signCount)) why = '';
|
|
332
|
+
else why = 'used at the same time elsewhere';
|
|
333
|
+
} catch (e) {
|
|
334
|
+
why = e instanceof WebAuthnError ? e.message : 'unreadable answer';
|
|
335
|
+
}
|
|
336
|
+
} else if (!challenge) why = 'no or an expired challenge';
|
|
337
|
+
if (why) {
|
|
338
|
+
await logActivity({ username: p.u, event: 'login_failed', ip, detail: `builder 2fa passkey: ${why}` });
|
|
339
|
+
flash(s, tx('mfa.passkey_failed'), 'error');
|
|
340
|
+
return back(reply, s, `${BASE}/login/2fa`);
|
|
341
|
+
}
|
|
342
|
+
const dev = await owner.one<{ username: string; language: string | null }>('select username, language from meta.developer where username = $1', [p.u]);
|
|
343
|
+
if (!dev) return back(reply, s, `${BASE}/login`);
|
|
344
|
+
await trustThisBrowser(req, reply, p.u);
|
|
345
|
+
return finishSignIn(req, reply, s, dev, { ws: p.ws, workspace: p.w, next: p.next, remember: p.remember, weak: p.weak, picked, detail: 'builder 2fa (passkey)' });
|
|
346
|
+
});
|
|
347
|
+
|
|
273
348
|
app.post(`${BASE}/logout`, async (req: Req, reply) => {
|
|
274
349
|
const s = await getSession(req, reply, null, BASE);
|
|
275
350
|
if (req.body?.__csrf === s.csrf_token) {
|
|
@@ -865,8 +940,9 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
865
940
|
[s.username, req.body?.current ?? '', req.body?.password],
|
|
866
941
|
);
|
|
867
942
|
if (r.rowCount !== 1) throw new Error(tr(s)('dev.err.current'));
|
|
868
|
-
// End all other builder sessions of this developer.
|
|
943
|
+
// End all other builder sessions of this developer, and (sprint 127) forget the browsers they trusted.
|
|
869
944
|
await owner.query('delete from meta.session where app_id is null and username = $1 and id <> $2', [s.username, s.id]);
|
|
945
|
+
await forgetBrowsers(s.username!);
|
|
870
946
|
delete s.state.__WEAK;
|
|
871
947
|
flash(s, tr(s)('dev.pw_changed'));
|
|
872
948
|
} catch (e) {
|
package/src/builder/security.ts
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { isIP } from 'node:net';
|
|
1
3
|
import type { FastifyInstance } from 'fastify';
|
|
2
4
|
import { owner } from '../db.ts';
|
|
3
5
|
import { html, raw, type Raw } from '../html.ts';
|
|
4
6
|
import { icon } from '../icons.ts';
|
|
5
7
|
import {
|
|
6
|
-
addTotp, adminEmails, adminFactors, codesLeft, confirmRecoveryCodes, factorsOf, hasFactor, matchTotp, MFA_POLICIES, mfaPolicy,
|
|
7
|
-
notifyMfa, otpauthUri, policyProblem, requiredFor, resetFactors, setPolicy,
|
|
8
|
+
addPasskey, addTotp, adminEmails, adminFactors, codesLeft, confirmRecoveryCodes, factorsOf, forgetBrowsers, hasFactor, matchTotp, MFA_POLICIES, mfaPolicy,
|
|
9
|
+
newRecoveryCodes, newTotpSecret, notifyMfa, otpauthUri, passkeysOf, policyProblem, requiredFor, resetFactors, setPolicy, setTrustDays, trustDays,
|
|
10
|
+
trustedBrowsers, unbase32, type MfaPolicy,
|
|
8
11
|
} from '../mfa.ts';
|
|
9
12
|
import { qrSvg } from '../qrcode.ts';
|
|
10
13
|
import { secretKeyConfigured } from '../secrets.ts';
|
|
11
|
-
import { clientIp, logActivity, type Session } from '../session.ts';
|
|
14
|
+
import { clientIp, logActivity, saveState, type Session } from '../session.ts';
|
|
15
|
+
import { ALGORITHMS, b64url, newChallenge, relyingParty, verifyRegistration, WebAuthnError } from '../webauthn.ts';
|
|
12
16
|
import { tr } from './i18n.ts';
|
|
13
17
|
import { isAdmin } from './locks.ts';
|
|
14
18
|
import { back, BASE, csrf, developer, flash, input, region, send, shell, type Req } from './ui.ts';
|
|
@@ -19,6 +23,29 @@ import { back, BASE, csrf, developer, flash, input, region, send, shell, type Re
|
|
|
19
23
|
// app confirms it, then stored encrypted with the instance key. Recovery codes are shown once; they count
|
|
20
24
|
// only after "I saved them". Removing a factor asks for the password, and is refused when it would leave a
|
|
21
25
|
// developer the policy covers without one, or the instance without enough administrators who have one.
|
|
26
|
+
// (sprint 127) Passkeys (WebAuthn): the browser asks the server for options (a one-time challenge kept in the
|
|
27
|
+
// session for five minutes), calls navigator.credentials.create() and posts the answer in a normal form
|
|
28
|
+
// (public/builder.js, [data-passkey]); without JavaScript the button stays hidden. Trusted browsers can be
|
|
29
|
+
// forgotten here; the instance decides how long a browser may be trusted (0 = never).
|
|
30
|
+
|
|
31
|
+
const CHALLENGE_SECONDS = 300;
|
|
32
|
+
|
|
33
|
+
/** A one-time WebAuthn challenge kept in the session (for this purpose only), or null when there is none or it expired. */
|
|
34
|
+
export function takeChallenge(s: Session, purpose: 'create' | 'get'): string | null {
|
|
35
|
+
try {
|
|
36
|
+
const p = JSON.parse(String(s.state.__PK ?? ''));
|
|
37
|
+
delete s.state.__PK;
|
|
38
|
+
return p && p.purpose === purpose && Date.now() - Number(p.at) < CHALLENGE_SECONDS * 1000 && typeof p.c === 'string' ? p.c : null;
|
|
39
|
+
} catch {
|
|
40
|
+
delete s.state.__PK;
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export function giveChallenge(s: Session, purpose: 'create' | 'get'): string {
|
|
45
|
+
const c = newChallenge();
|
|
46
|
+
s.state.__PK = JSON.stringify({ c, purpose, at: Date.now() });
|
|
47
|
+
return c;
|
|
48
|
+
}
|
|
22
49
|
|
|
23
50
|
const SECRET_MINUTES = 10;
|
|
24
51
|
|
|
@@ -32,7 +59,8 @@ export async function securityRoutes(app: FastifyInstance) {
|
|
|
32
59
|
if (!s) return;
|
|
33
60
|
const tx = tr(s);
|
|
34
61
|
const me = s.username!;
|
|
35
|
-
const [factors, left, pol, admin] = await Promise.all([factorsOf(me), codesLeft(me), mfaPolicy(), isAdmin(me)]);
|
|
62
|
+
const [factors, left, pol, admin, days, trusted] = await Promise.all([factorsOf(me), codesLeft(me), mfaPolicy(), isAdmin(me), trustDays(), trustedBrowsers(me)]);
|
|
63
|
+
const rp = relyingParty(req);
|
|
36
64
|
const required = requiredFor(pol.policy, admin);
|
|
37
65
|
const pending = (() => {
|
|
38
66
|
try {
|
|
@@ -80,18 +108,34 @@ export async function securityRoutes(app: FastifyInstance) {
|
|
|
80
108
|
<div class="buttons"><button class="btn btn-hot">${tx('mfa.codes_done')}</button></div></form>`, '', 'codes')
|
|
81
109
|
: ''}
|
|
82
110
|
<div class="columns">
|
|
83
|
-
${region(tx('mfa.
|
|
84
|
-
? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('mfa.app_name')}</th><th>${tx('mfa.added')}</th><th>${tx('mfa.last_used')}</th><th></th></tr></thead><tbody>
|
|
85
|
-
${factors.map((f) => html`<tr><td data-label="${tx('mfa.app_name')}">${icon('
|
|
111
|
+
${region(tx('mfa.factors'), html`${factors.length
|
|
112
|
+
? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('mfa.app_name')}</th><th>${tx('mfa.kind')}</th><th>${tx('mfa.added')}</th><th>${tx('mfa.last_used')}</th><th></th></tr></thead><tbody>
|
|
113
|
+
${factors.map((f) => html`<tr><td data-label="${tx('mfa.app_name')}">${icon(f.kind === 'passkey' ? 'fingerprint' : 'smartphone')} ${f.name}</td>
|
|
114
|
+
<td data-label="${tx('mfa.kind')}">${f.kind === 'passkey' ? tx('mfa.kind_passkey') : tx('mfa.kind_app')}${f.kind === 'passkey' && f.rp_id !== rp.rpId ? html` <span class="badge badge-warn" title="${tx('mfa.other_domain_help', { domain: f.rp_id ?? '' })}">${tx('mfa.other_domain', { domain: f.rp_id ?? '' })}</span>` : ''}</td>
|
|
115
|
+
<td data-label="${tx('mfa.added')}">${when(f.created_at)}</td><td data-label="${tx('mfa.last_used')}">${when(f.last_used_at)}</td>
|
|
86
116
|
<td><details class="menu"><summary class="btn btn-sm">${tx('mfa.remove')}</summary><div class="menu-panel"><form method="post" action="${BASE}/security/factor/${f.id}/delete" class="menu-section">${csrf(s)}
|
|
87
117
|
${input(`password_${f.id}`, tx('mfa.your_password'), '', { type: 'password', required: true, auto: 'current-password' })}
|
|
88
|
-
<button class="btn btn-sm btn-danger">${tx('mfa.remove_app')}</button></form></div></details></td></tr>`)}
|
|
118
|
+
<button class="btn btn-sm btn-danger">${tx(f.kind === 'passkey' ? 'mfa.remove_passkey' : 'mfa.remove_app')}</button></form></div></details></td></tr>`)}
|
|
89
119
|
</tbody></table></div>`
|
|
90
120
|
: html`<p class="muted">${tx('mfa.no_apps')}</p>`}
|
|
121
|
+
<h3>${tx('mfa.kind_passkey')}</h3>
|
|
122
|
+
<p class="muted small u-mt0">${tx('mfa.passkey_intro')}</p>
|
|
123
|
+
<form method="post" action="${BASE}/security/passkey">${csrf(s)}
|
|
124
|
+
<div class="form-grid">${input('pk_name', tx('mfa.app_name'), tx('mfa.passkey_default'), { required: true })}</div>
|
|
125
|
+
<input type="hidden" name="clientDataJSON"><input type="hidden" name="attestationObject"><input type="hidden" name="transports">
|
|
126
|
+
${isIP(rp.rpId) ? html`<p class="alert alert-info" role="status">${tx('mfa.passkey_needs_host', { host: rp.rpId })}</p>`
|
|
127
|
+
: html`<div class="buttons"><button class="btn${factors.length ? '' : ' btn-hot'}" hidden data-passkey="${BASE}/security/passkey/options" data-passkey-error="${tx('mfa.passkey_failed')}">${icon('fingerprint')} ${tx('mfa.add_passkey')}</button></div>`}
|
|
128
|
+
<p class="small" role="status" data-passkey-status></p>
|
|
129
|
+
<noscript><p class="muted small">${tx('mfa.passkey_needs_js')}</p></noscript>
|
|
130
|
+
</form>
|
|
131
|
+
<h3>${tx('mfa.kind_app')}</h3>
|
|
91
132
|
${setup}`)}
|
|
92
133
|
${region(tx('mfa.codes_title'), html`<p class="u-mt0">${factors.length || left ? tx('mfa.codes_left', { n: left }) : tx('mfa.codes_after')}</p>
|
|
93
134
|
${factors.length && left < 3 ? html`<p class="alert alert-warning" role="status">${tx('mfa.codes_low')}</p>` : ''}
|
|
94
|
-
${factors.length ? html`<form method="post" action="${BASE}/security/codes">${csrf(s)}<button class="btn" data-confirm="${tx('mfa.codes_new_confirm')}">${tx('mfa.codes_new')}</button></form>` : ''}
|
|
135
|
+
${factors.length ? html`<form method="post" action="${BASE}/security/codes">${csrf(s)}<button class="btn" data-confirm="${tx('mfa.codes_new_confirm')}">${tx('mfa.codes_new')}</button></form>` : ''}
|
|
136
|
+
${days || trusted ? html`<h3>${tx('mfa.trusted_title')}</h3>
|
|
137
|
+
<p class="u-mt0">${days ? tx('mfa.trusted_count', { n: trusted, days }) : tx('mfa.trusted_off')}</p>
|
|
138
|
+
${trusted ? html`<form method="post" action="${BASE}/security/forget-browsers">${csrf(s)}<button class="btn">${tx('mfa.forget_browsers')}</button></form>` : ''}` : ''}`)}
|
|
95
139
|
</div>`;
|
|
96
140
|
return send(reply, s, shell(s, tx('mfa.title'), [[tx('mfa.title')]], main, 'developers'));
|
|
97
141
|
});
|
|
@@ -167,7 +211,7 @@ export async function securityRoutes(app: FastifyInstance) {
|
|
|
167
211
|
const tx = tr(s);
|
|
168
212
|
const me = s.username!;
|
|
169
213
|
const id = Number(req.params.id);
|
|
170
|
-
const f = await owner.one('select id from meta.developer_factor where id = $1 and username = $2', [id, me]);
|
|
214
|
+
const f = await owner.one<{ kind: string }>('select id, kind from meta.developer_factor where id = $1 and username = $2', [id, me]);
|
|
171
215
|
if (!f) return reply.code(404).send('Not found');
|
|
172
216
|
if (!(await verifyPassword(me, String(req.body?.[`password_${id}`] ?? '')))) {
|
|
173
217
|
await logActivity({ username: me, event: 'login_failed', ip: clientIp(req), detail: 'builder (removing a second factor)' });
|
|
@@ -193,11 +237,73 @@ export async function securityRoutes(app: FastifyInstance) {
|
|
|
193
237
|
}
|
|
194
238
|
await owner.query('delete from meta.developer_factor where id = $1 and username = $2', [id, me]);
|
|
195
239
|
if (last) await owner.query('delete from meta.developer_recovery_code where username = $1', [me]);
|
|
196
|
-
|
|
240
|
+
// a lost phone or key: browsers trusted with it must ask again
|
|
241
|
+
await forgetBrowsers(me);
|
|
242
|
+
const what = f.kind === 'passkey' ? 'a passkey' : 'an authenticator app';
|
|
243
|
+
await logActivity({ username: me, event: '2fa_removed', ip: clientIp(req), detail: last ? `${what}, the last factor (recovery codes removed)` : what });
|
|
197
244
|
flash(s, tx('mfa.removed'));
|
|
198
245
|
return back(reply, s, `${BASE}/security`);
|
|
199
246
|
});
|
|
200
247
|
|
|
248
|
+
// (sprint 127) a passkey: the options for navigator.credentials.create(), then the browser's answer
|
|
249
|
+
app.post(`${BASE}/security/passkey/options`, async (req: Req, reply) => {
|
|
250
|
+
const s = await developer(req, reply);
|
|
251
|
+
if (!s) return;
|
|
252
|
+
const me = s.username!;
|
|
253
|
+
const rp = relyingParty(req);
|
|
254
|
+
const challenge = giveChallenge(s, 'create');
|
|
255
|
+
await saveState(s);
|
|
256
|
+
reply.header('cache-control', 'no-store');
|
|
257
|
+
return reply.send({
|
|
258
|
+
mode: 'create', challenge, rp: { id: rp.rpId, name: 'pgkiln' }, algs: ALGORITHMS,
|
|
259
|
+
// a stable handle that isn't the user name (the authenticator may show it, but it says nothing)
|
|
260
|
+
user: { id: b64url(createHash('sha256').update(`pgkiln-developer:${me}`).digest().subarray(0, 16)), name: me, displayName: me },
|
|
261
|
+
exclude: (await passkeysOf(me, rp.rpId)).map((p) => p.credential_id),
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
app.post(`${BASE}/security/passkey`, async (req: Req, reply) => {
|
|
266
|
+
const s = await developer(req, reply);
|
|
267
|
+
if (!s) return;
|
|
268
|
+
const tx = tr(s);
|
|
269
|
+
const me = s.username!;
|
|
270
|
+
const challenge = takeChallenge(s, 'create');
|
|
271
|
+
if (!challenge) {
|
|
272
|
+
flash(s, tx('mfa.setup_expired'), 'error');
|
|
273
|
+
return back(reply, s, `${BASE}/security`);
|
|
274
|
+
}
|
|
275
|
+
const rp = relyingParty(req);
|
|
276
|
+
let reg;
|
|
277
|
+
try {
|
|
278
|
+
reg = verifyRegistration({ clientDataJSON: String(req.body?.clientDataJSON ?? ''), attestationObject: String(req.body?.attestationObject ?? '') }, { challenge, ...rp });
|
|
279
|
+
} catch (e) {
|
|
280
|
+
await logActivity({ username: me, event: '2fa_refused', ip: clientIp(req), detail: `passkey: ${e instanceof WebAuthnError ? e.message : 'unreadable answer'}` });
|
|
281
|
+
flash(s, tx('mfa.passkey_failed'), 'error');
|
|
282
|
+
return back(reply, s, `${BASE}/security`);
|
|
283
|
+
}
|
|
284
|
+
const first = !(await hasFactor(me));
|
|
285
|
+
try {
|
|
286
|
+
await addPasskey(me, String(req.body?.pk_name ?? ''), { ...reg, rpId: rp.rpId, transports: String(req.body?.transports ?? '').split(',').filter(Boolean) });
|
|
287
|
+
} catch {
|
|
288
|
+
flash(s, tx('mfa.passkey_known'), 'error');
|
|
289
|
+
return back(reply, s, `${BASE}/security`);
|
|
290
|
+
}
|
|
291
|
+
delete s.state.__MFA_DUE;
|
|
292
|
+
await logActivity({ username: me, event: '2fa_enrolled', ip: clientIp(req), detail: `passkey (${rp.rpId})` });
|
|
293
|
+
if (first || !(await codesLeft(me))) s.state.__CODES_NEW = JSON.stringify(await newRecoveryCodes(me));
|
|
294
|
+
flash(s, tx('mfa.passkey_added'));
|
|
295
|
+
return back(reply, s, `${BASE}/security${s.state.__CODES_NEW ? '#codes' : ''}`);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
app.post(`${BASE}/security/forget-browsers`, async (req: Req, reply) => {
|
|
299
|
+
const s = await developer(req, reply);
|
|
300
|
+
if (!s) return;
|
|
301
|
+
await forgetBrowsers(s.username!);
|
|
302
|
+
await logActivity({ username: s.username, event: '2fa_forget_browsers', ip: clientIp(req) });
|
|
303
|
+
flash(s, tr(s)('mfa.browsers_forgotten'));
|
|
304
|
+
return back(reply, s, `${BASE}/security`);
|
|
305
|
+
});
|
|
306
|
+
|
|
201
307
|
// (SEC-6) an administrator resets another developer's second factors: logged, the developer and the administrators told
|
|
202
308
|
app.post(`${BASE}/developers/reset-2fa`, async (req: Req, reply) => {
|
|
203
309
|
const s = await developer(req, reply);
|
|
@@ -228,13 +334,16 @@ export async function securityRoutes(app: FastifyInstance) {
|
|
|
228
334
|
if (!(await isAdmin(s.username))) return reply.code(403).send('Only administrators change the policy.');
|
|
229
335
|
const policy = MFA_POLICIES.includes(req.body?.policy as MfaPolicy) ? (req.body!.policy as MfaPolicy) : null;
|
|
230
336
|
const grace = Number(req.body?.grace_days);
|
|
231
|
-
|
|
337
|
+
const trust = Number(req.body?.trust_days ?? 0);
|
|
338
|
+
if (!policy || !Number.isInteger(grace) || grace < 0 || grace > 90 || !Number.isInteger(trust) || trust < 0 || trust > 90)
|
|
339
|
+
return reply.code(400).send('policy is off, admins or all; grace_days and trust_days 0 to 90');
|
|
232
340
|
if (await policyProblem(policy)) {
|
|
233
341
|
flash(s, tx('mfa.policy_needs_admins'), 'error');
|
|
234
342
|
return back(reply, s, `${BASE}/instance#mfa`);
|
|
235
343
|
}
|
|
236
344
|
await setPolicy(policy, grace);
|
|
237
|
-
await
|
|
345
|
+
await setTrustDays(trust);
|
|
346
|
+
await logActivity({ username: s.username, event: '2fa_policy', ip: clientIp(req), detail: `${policy}, grace ${grace} days, trust browsers ${trust} days` });
|
|
238
347
|
await notifyMfa(await adminEmails(), `pgkiln: two-factor sign-in policy is now ${policy}`, `${s.username} set the two-factor policy to ${policy} (grace period ${grace} days).`);
|
|
239
348
|
flash(s, tx('mfa.policy_saved'));
|
|
240
349
|
return back(reply, s, `${BASE}/instance#mfa`);
|
|
@@ -244,8 +353,8 @@ export async function securityRoutes(app: FastifyInstance) {
|
|
|
244
353
|
/** The Instance settings region for the policy (administrators). */
|
|
245
354
|
export async function policyRegion(s: Session): Promise<Raw> {
|
|
246
355
|
const tx = tr(s);
|
|
247
|
-
const [pol, af, without] = await Promise.all([
|
|
248
|
-
mfaPolicy(), adminFactors(),
|
|
356
|
+
const [pol, af, days, without] = await Promise.all([
|
|
357
|
+
mfaPolicy(), adminFactors(), trustDays(),
|
|
249
358
|
owner.query<{ username: string; is_admin: boolean }>('select username, is_admin from meta.developer d where not exists (select 1 from meta.developer_factor f where f.username = d.username) order by username'),
|
|
250
359
|
]);
|
|
251
360
|
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>`;
|
|
@@ -253,6 +362,7 @@ export async function policyRegion(s: Session): Promise<Raw> {
|
|
|
253
362
|
<div class="form-grid">
|
|
254
363
|
<div class="field"><label class="label" for="f_policy">${tx('mfa.policy_label')}</label><select id="f_policy" name="policy">${MFA_POLICIES.map(opt)}</select></div>
|
|
255
364
|
${input('grace_days', tx('mfa.grace'), pol.graceDays, { type: 'number', help: tx('mfa.grace_help') })}
|
|
365
|
+
${input('trust_days', tx('mfa.trust_days'), days, { type: 'number', help: tx('mfa.trust_days_help') })}
|
|
256
366
|
</div>
|
|
257
367
|
<p class="muted small">${tx('mfa.policy_help', { enrolled: af.enrolled, admins: af.admins })}</p>
|
|
258
368
|
<div class="buttons"><button class="btn btn-hot">${tx('mfa.policy_save')}</button></div></form>
|
package/src/instance.ts
CHANGED
|
@@ -108,7 +108,8 @@ export async function saveInstanceSettings(values: Record<string, string | undef
|
|
|
108
108
|
export const CONFIGURATION: readonly { env: string; secret?: boolean; default: string; help: string }[] = [
|
|
109
109
|
{ env: 'PORT', default: '3100', help: 'HTTP port' },
|
|
110
110
|
{ env: 'HOST', default: '127.0.0.1', help: 'Address the server listens on' },
|
|
111
|
-
{ env: 'PUBLIC_URL', default: '(none)', help: 'The public address (single sign-on redirects, links)' },
|
|
111
|
+
{ env: 'PUBLIC_URL', default: '(none)', help: 'The public address (single sign-on redirects, links, passkeys)' },
|
|
112
|
+
{ env: 'PGKILN_RP_ID', default: '(the host of PUBLIC_URL)', help: 'A parent domain for developers\' passkeys' },
|
|
112
113
|
{ env: 'DATABASE_URL', secret: true, default: '(none)', help: 'Owner connection (migrations, builder, SQL Workshop)' },
|
|
113
114
|
{ env: 'RUNTIME_DATABASE_URL', secret: true, default: 'DATABASE_URL', help: 'Least-privilege connection that runs applications' },
|
|
114
115
|
{ env: 'COOKIE_SECURE', default: 'false', help: 'Cookies only over HTTPS' },
|