diff --git a/app/client/ui/errorPages.ts b/app/client/ui/errorPages.ts index b21ac10661..3b27b07de4 100644 --- a/app/client/ui/errorPages.ts +++ b/app/client/ui/errorPages.ts @@ -21,6 +21,7 @@ export function createErrPage(appModel: AppModel) { errPage === 'not-found' ? createNotFoundPage(appModel, errMessage) : errPage === 'access-denied' ? createForbiddenPage(appModel, errMessage) : errPage === 'account-deleted' ? createAccountDeletedPage(appModel) : + errPage === 'mfa-not-enabled' ? createMfaNotEnabledErrorPage(appModel) : createOtherErrorPage(appModel, errMessage); } @@ -81,6 +82,32 @@ export function createAccountDeletedPage(appModel: AppModel) { ]); } +/** + * Creates a page that show the user's account does not have multifactor authentication enabled, despite being needed. + */ +export function createMfaNotEnabledErrorPage(appModel: AppModel) { + document.title = t("Multi-factor authentication required{{suffix}}", { + suffix: getPageTitleSuffix(getGristConfig()) + }); + + const searchParams = new URL(location.href).searchParams; + + return pagePanelsError(appModel, t("Multi-factor authentication required{{suffix}}", {suffix: ''}), [ + cssErrorText(t("Multi-factor-authentication is required for accessing this site, but it is not set up on your \ +account. Please enable it and try again.")), + cssButtonWrap(bigPrimaryButtonLink( + t("Set up Multi-factor authentication"), + {href: getGristConfig().mfaSettingsUrl, target: '_blank'}, + testId('error-setup-mfa') + )), + cssButtonWrap(bigBasicButtonLink( + t("Try again"), + {href: getSignupUrl({ nextUrl: searchParams.get("next") || "" })}, + testId('error-signin') + )) + ]); +} + /** * Creates a "Page not found" page. */ diff --git a/app/common/gristUrls.ts b/app/common/gristUrls.ts index 8779d23a9b..f3d669581b 100644 --- a/app/common/gristUrls.ts +++ b/app/common/gristUrls.ts @@ -793,6 +793,9 @@ export interface GristLoadConfig { // If backend has an email service for sending notifications. notifierEnabled?: boolean; + + // The URL to the external IDP, where the user can set up Multi-factor authentication + mfaSettingsUrl?: string; } export const Features = StringUnion( diff --git a/app/server/lib/FlexServer.ts b/app/server/lib/FlexServer.ts index 17ac46a3a2..27f4f7b3b9 100644 --- a/app/server/lib/FlexServer.ts +++ b/app/server/lib/FlexServer.ts @@ -1273,6 +1273,11 @@ export class FlexServer implements GristServer { this.app.get('/signed-out', expressWrap((req, resp) => this._sendAppPage(req, resp, {path: 'error.html', status: 200, config: {errPage: 'signed-out'}}))); + // Add a static "mfa-not-enabled" page. This is where logout typically lands when GRIST_OIDC_SP_FORCE_MFA is true + // but the user hasn't configured multi-factor authentication. + this.app.get('/login/error/mfa-not-enabled', expressWrap((req, resp) => + this._sendAppPage(req, resp, {path: 'error.html', status: 401, config: {errPage: 'mfa-not-enabled'}}))); + const comment = await this._loginMiddleware.addEndpoints(this.app); this.info.push(['loginMiddlewareComment', comment]); diff --git a/app/server/lib/OIDCConfig.ts b/app/server/lib/OIDCConfig.ts index 86f78bce20..0340bb87f1 100644 --- a/app/server/lib/OIDCConfig.ts +++ b/app/server/lib/OIDCConfig.ts @@ -35,6 +35,14 @@ * env GRIST_OIDC_SP_IGNORE_EMAIL_VERIFIED * If set to "true", the user will be allowed to login even if the email is not verified by the IDP. * Defaults to false. + * env GRIST_OIDC_SP_FORCE_MFA + * If set to "true", the user will be forced to have multi-factor authentication enabled. The state of MFA will + * be determined by OIDC's amr claim: It must include "mfa". Make sure that the IDP returns the amr claim + * correctly, otherwise authentication will fail. + * env GRIST_OIDC_SP_MFA_SETTINGS_URL + * This is needed when GRIST_OIDC_SP_FORCE_MFA is set to true. Enter the URL where the user will be able to + * configure Multi-factor authentication on their account. This will be shown in the UI if the user does not have + * MFA enabled. * * This version of OIDCConfig has been tested with Keycloak OIDC IdP following the instructions * at: @@ -69,6 +77,7 @@ export class OIDCConfig { private _endSessionEndpoint: string; private _skipEndSessionEndpoint: boolean; private _ignoreEmailVerified: boolean; + private _forceMfa: boolean; public constructor() { } @@ -113,6 +122,11 @@ export class OIDCConfig { defaultValue: false, })!; + this._forceMfa = section.flag('forceMfa').readBool({ + envVar: 'GRIST_OIDC_SP_FORCE_MFA', + defaultValue: false, + })!; + const issuer = await Issuer.discover(issuerUrl); this._redirectUrl = new URL(CALLBACK_URL, spHost).href; this._client = new issuer.Client({ @@ -159,6 +173,26 @@ export class OIDCConfig { throw new Error(`OIDCConfig: email not verified for ${userInfo.email}`); } + const amr = tokenSet.claims().amr; + if (this._forceMfa) { + if (!amr) { + throw new Error('OIDCConfig: could not verify mfa status due to missing amr claim.'); + } else if (!amr.includes("mfa")) { + log.error(`OIDCConfig: multi-factor-authentication is not enabled for ${userInfo.email}.`); + delete mreq.session.oidc; + + // Convert absolute URL into relative, since it will be prefixed further down the line + const targetURL = new URL(targetUrl as string); + let targetUrlRelative = targetURL.pathname; + if (targetURL.searchParams.toString()) { + targetUrlRelative += "?" + targetURL.searchParams.toString(); + } + + res.redirect(`/login/error/mfa-not-enabled?next=${targetUrlRelative}`); + return; + } + } + const profile = this._makeUserProfileFromUserInfo(userInfo); log.info(`OIDCConfig: got OIDC response for ${profile.email} (${profile.name}) redirecting to ${targetUrl}`); diff --git a/app/server/lib/sendAppPage.ts b/app/server/lib/sendAppPage.ts index 8c5ebf92c7..ade3abc849 100644 --- a/app/server/lib/sendAppPage.ts +++ b/app/server/lib/sendAppPage.ts @@ -24,6 +24,7 @@ import * as handlebars from 'handlebars'; import jsesc from 'jsesc'; import * as path from 'path'; import difference = require('lodash/difference'); +import * as process from "node:process"; const translate = (req: express.Request, key: string, args?: any) => req.t(`sendAppPage.${key}`, args); @@ -98,6 +99,7 @@ export function makeGristConfig(options: MakeGristConfigOptions): GristLoadConfi canCloseAccount: isAffirmative(process.env.GRIST_ACCOUNT_CLOSE), experimentalPlugins: isAffirmative(process.env.GRIST_EXPERIMENTAL_PLUGINS), notifierEnabled: server?.hasNotifier(), + mfaSettingsUrl: process.env.GRIST_OIDC_SP_MFA_SETTINGS_URL, ...extra, }; } diff --git a/static/locales/de.client.json b/static/locales/de.client.json index 99331f9576..6b461b8960 100644 --- a/static/locales/de.client.json +++ b/static/locales/de.client.json @@ -948,7 +948,11 @@ "An unknown error occurred.": "Ein unbekannter Fehler ist aufgetreten.", "Powered by": "Angetrieben durch", "Build your own form": "Erstellen Sie Ihr eigenes Formular", - "Form not found": "Formular nicht gefunden" + "Form not found": "Formular nicht gefunden", + "Multi-factor authentication required{{suffix}}": "Multi-Faktor-Authentifizierung nötig{{suffix}}", + "Multi-factor-authentication is required for accessing this site, but it is not set up on your account. Please enable it and try again.": "Für den Zugriff auf diese Website ist Multi-Faktor-Authentifizierung erforderlich. Diese ist nicht in Ihrem Konto eingerichtet. Bitte richten Sie sie ein und versuchen Sie es erneut.", + "Set up Multi-factor authentication": "Multi-Faktor-Authentifizierung einrichten", + "Try again": "Erneut versuchen" }, "menus": { "* Workspaces are available on team plans. ": "* Arbeitsbereiche sind in Teamplänen verfügbar. ", diff --git a/static/locales/en.client.json b/static/locales/en.client.json index c26932fce4..a322fba041 100644 --- a/static/locales/en.client.json +++ b/static/locales/en.client.json @@ -888,7 +888,11 @@ "An unknown error occurred.": "An unknown error occurred.", "Build your own form": "Build your own form", "Form not found": "Form not found", - "Powered by": "Powered by" + "Powered by": "Powered by", + "Multi-factor authentication required{{suffix}}": "Multi-factor authentication required{{suffix}}", + "Multi-factor-authentication is required for accessing this site, but it is not set up on your account. Please enable it and try again.": "Multi-factor-authentication is required for accessing this site, but it is not set up on your account. Please enable it and try again.", + "Set up Multi-factor authentication": "Set up Multi-factor authentication", + "Try again": "Try again" }, "menus": { "* Workspaces are available on team plans. ": "* Workspaces are available on team plans. ",