From 205762526f5f1a8818152ea48e6238ee3902d0f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Dubigny?= Date: Mon, 14 Oct 2024 19:06:45 +0200 Subject: [PATCH] feat: acr can be used to force identity with IAL=2 --- .github/workflows/end-to-end.yml | 15 ++ README.md | 13 +- cypress/e2e/signin_with_right_acr/env.conf | 1 + .../e2e/signin_with_right_acr/fixtures.sql | 57 +++++++ cypress/e2e/signin_with_right_acr/index.cy.ts | 31 ++++ cypress/e2e/signin_with_totp/env.conf | 1 - cypress/e2e/signin_with_totp/fixtures.sql | 8 +- cypress/e2e/signin_with_totp/index.cy.ts | 70 ++++++++- docker-compose.yml | 15 ++ src/config/env.ts | 4 + src/config/env.zod.ts | 12 ++ src/config/oidc-provider-configuration.ts | 14 +- src/controllers/interaction.ts | 90 +++++++++-- src/index.ts | 2 + src/managers/session/authenticated.ts | 4 +- src/middlewares/user.ts | 6 +- src/services/should-trigger-2fa.ts | 74 ++++++++- src/types/express-session.d.ts | 2 +- test/env.zod.test.ts | 4 + test/should-trigger-2fa.test.ts | 142 +++++++++++++++++- 20 files changed, 519 insertions(+), 46 deletions(-) create mode 100644 cypress/e2e/signin_with_right_acr/env.conf create mode 100644 cypress/e2e/signin_with_right_acr/fixtures.sql create mode 100644 cypress/e2e/signin_with_right_acr/index.cy.ts diff --git a/.github/workflows/end-to-end.yml b/.github/workflows/end-to-end.yml index 8b6f78f7..2c63208e 100644 --- a/.github/workflows/end-to-end.yml +++ b/.github/workflows/end-to-end.yml @@ -50,6 +50,7 @@ jobs: - signin_from_standard_client - signin_with_email_verification_renewal - signin_with_magic_link + - signin_with_right_acr - signin_with_totp - signup_entreprise_unipersonnelle - update_personal_information @@ -67,6 +68,7 @@ jobs: MCP_CLIENT_SECRET: standard_client_secret MCP_PROVIDER: ${{ env.MONCOMPTEPRO_HOST }} MCP_SCOPES: openid email profile organization + ACR_VALUE_FOR_2FA: urn:dinum:ac:classes:consistency-checked-2fa STYLESHEET_URL: "" moncomptepro-proconnect-federation-client: image: ghcr.io/numerique-gouv/moncomptepro-test-client @@ -95,6 +97,19 @@ jobs: MCP_PROVIDER: ${{ env.MONCOMPTEPRO_HOST }} MCP_SCOPES: openid email profile phone organizations STYLESHEET_URL: "" + moncomptepro-acr-client: + image: ghcr.io/numerique-gouv/moncomptepro-test-client + ports: + - 4003:3000 + env: + SITE_TITLE: moncomptepro-acr-client + HOST: http://localhost:4003 + MCP_CLIENT_ID: acr_client_id + MCP_CLIENT_SECRET: acr_client_secret + MCP_PROVIDER: ${{ env.MONCOMPTEPRO_HOST }} + MCP_SCOPES: openid email profile organization + ACR_VALUE_FOR_2FA: urn:dinum:ac:classes:consistency-checked + STYLESHEET_URL: "" redis: image: redis:7.2 ports: diff --git a/README.md b/README.md index 4284c642..add75c1c 100644 --- a/README.md +++ b/README.md @@ -155,14 +155,21 @@ d’usurpations d’identités liés aux attaques par _phishing_ par exemple. Vous pouvez tester la cinématique via le lien suivant : https://test.moncomptepro.beta.gouv.fr/#force-2fa -Pour ce faire, vous devez passer les paramètres `claims={"id_token":{"acr":{"essential":true,value:"https://refeds.org/profile/mfa"}}}` comme suit : +Pour ce faire, vous devez passer les paramètres `claims={"id_token":{"acr":{"essential":true,value:"urn:dinum:ac:classes:consistency-checked-2fa"}}}` comme suit : https://app-sandbox.moncomptepro.beta.gouv.fr/oauth/authorize?client_id=client_id&scope=openid%20email%20profile%20organization&response_type=code&redirect_uri=https%3A%2F%2Ftest.moncomptepro.beta.gouv.fr%2Flogin-callback&claims=%7B%22id_token%22%3A%7B%22acr%22%3A%7B%22essential%22%3Atrue%2C%22value%22%3A%22https%3A%2F%2Frefeds.org%2Fprofile%2Fmfa%22%7D%7D%7D Les valeurs `acr` utilisées par ProConnect Identité sont les suivantes : -- `eidas1` authentification simple facteur avec une identité de niveau faible. -- `https://refeds.org/profile/mfa` authentification par double facteur sans preuve d’identité particulière. +- `eidas1` authentification simple facteur avec une identité de niveau faible ; +- `urn:dinum:ac:classes:self-asserted` : identité déclarative ; +- `urn:dinum:ac:classes:self-asserted-2fa` : identité déclarative ; +- `urn:dinum:ac:classes:consistency-checked` : identité déclarative + un des tests de cohérence suivant : + - contrôle du référencement du nom de domaine + - code à usage unique envoyé par courrier postal au siège social + - code à usage unique envoyé par email à l'adresse de contact référencée dans un annuaire de référence + - identité du dirigeant d'association conforme +- `urn:dinum:ac:classes:consistency-checked-2fa` : `urn:dinum:ac:classes:consistency-checked` + authentification à double facteur ## 3. 👋 Contribuer à ProConnect Identité diff --git a/cypress/e2e/signin_with_right_acr/env.conf b/cypress/e2e/signin_with_right_acr/env.conf new file mode 100644 index 00000000..7f468577 --- /dev/null +++ b/cypress/e2e/signin_with_right_acr/env.conf @@ -0,0 +1 @@ +DO_NOT_SEND_MAIL="True" diff --git a/cypress/e2e/signin_with_right_acr/fixtures.sql b/cypress/e2e/signin_with_right_acr/fixtures.sql new file mode 100644 index 00000000..2bf5d9f0 --- /dev/null +++ b/cypress/e2e/signin_with_right_acr/fixtures.sql @@ -0,0 +1,57 @@ +INSERT INTO users +(id, email, email_verified, email_verified_at, encrypted_password, created_at, updated_at, + given_name, family_name, phone_number, job, encrypted_totp_key, totp_key_verified_at, force_2fa) +VALUES + (1, 'ial2-aal2@yopmail.com', true, CURRENT_TIMESTAMP, + '$2a$10$kzY3LINL6..50Fy9shWCcuNlRfYq0ft5lS.KCcJ5PzrhlWfKK4NIO', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, + 'Jean', 'IAL2 AAL2', '0123456789', 'Sbire', + 'kuOSXGk68H2B3pYnph0uyXAHrmpbWaWyX/iX49xVaUc=.VMPBZSO+eAng7mjS.cI2kRY9rwhXchcKiiaMZIg==', + CURRENT_TIMESTAMP, false + ), + (2, 'ial1-aal2@yopmail.com', true, CURRENT_TIMESTAMP, + '$2a$10$kzY3LINL6..50Fy9shWCcuNlRfYq0ft5lS.KCcJ5PzrhlWfKK4NIO', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, + 'Jean', 'IAL1 AAL2', '0123456789', 'Sbire', + 'kuOSXGk68H2B3pYnph0uyXAHrmpbWaWyX/iX49xVaUc=.VMPBZSO+eAng7mjS.cI2kRY9rwhXchcKiiaMZIg==', + CURRENT_TIMESTAMP, false + ), + (3, 'ial2-aal1@yopmail.com', true, CURRENT_TIMESTAMP, + '$2a$10$kzY3LINL6..50Fy9shWCcuNlRfYq0ft5lS.KCcJ5PzrhlWfKK4NIO', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, + 'Jean', 'IAL2 AAL1', '0123456789', 'Sbire', + null, null, false), + (4, 'ial1-aal1@yopmail.com', true, CURRENT_TIMESTAMP, + '$2a$10$kzY3LINL6..50Fy9shWCcuNlRfYq0ft5lS.KCcJ5PzrhlWfKK4NIO', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, + 'Jean', 'IAL1 AAL1', '0123456789', 'Sbire', + null, null, false); + +INSERT INTO organizations + (id, siret, created_at, updated_at) +VALUES + (1, '21340126800130', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP); + +INSERT INTO users_organizations + (user_id, organization_id, is_external, verification_type, has_been_greeted) +VALUES + (1, 1, false, 'domain', true), + (2, 1, false, null, true), + (3, 1, false, 'domain', true), + (4, 1, false, null, true); + +INSERT INTO oidc_clients + (client_name, client_id, client_secret, redirect_uris, + post_logout_redirect_uris, scope, client_uri, client_description, + userinfo_signed_response_alg, id_token_signed_response_alg, + authorization_signed_response_alg, introspection_signed_response_alg) +VALUES + ('Oidc Test Client', + 'acr_client_id', + 'acr_client_secret', + ARRAY [ + 'http://localhost:4003/login-callback' + ], + ARRAY [ + 'http://localhost:4003/' + ], + 'openid email profile organization', + 'http://localhost:4003/', + 'MonComptePro test client. More info: https://github.com/numerique-gouv/moncomptepro-test-client.', + null, 'RS256', null, null); diff --git a/cypress/e2e/signin_with_right_acr/index.cy.ts b/cypress/e2e/signin_with_right_acr/index.cy.ts new file mode 100644 index 00000000..f3210822 --- /dev/null +++ b/cypress/e2e/signin_with_right_acr/index.cy.ts @@ -0,0 +1,31 @@ +// + +describe("sign-in with a client requiring consistency-checked identity", () => { + it("should sign-in an return the right acr value", function () { + cy.visit("http://localhost:4003"); + cy.get("button#force-2fa").click(); + + cy.login("ial2-aal1@yopmail.com"); + + cy.contains('"acr": "urn:dinum:ac:classes:consistency-checked"'); + }); + it("should return an error with ial1", function () { + cy.visit("http://localhost:4003"); + cy.get("button#force-2fa").click(); + + cy.login("ial1-aal1@yopmail.com"); + + cy.contains("access_denied (none of the requested ACRs could be obtained)"); + }); + + // TODO add tests: + // - log with a client requiring consistency-checked and consistency-checked-mfa + // - with a consistency checked user and MFA => see the right acr returned + // - with a self-asserted user and MFA => see an error + // - log with a client not requiring any acr + // - with a self-asserted user => see acr self-asserted + // - with a consistency checked user => see acr consistency-checked + // - log with acr_values=eidas1 and ENABLE_FIXED_ACR=True + // - with all type of acr => see the right acr + // these tests required the mcp-test-client to be modifiable like fc-mock +}); diff --git a/cypress/e2e/signin_with_totp/env.conf b/cypress/e2e/signin_with_totp/env.conf index d9f7f763..2edba900 100644 --- a/cypress/e2e/signin_with_totp/env.conf +++ b/cypress/e2e/signin_with_totp/env.conf @@ -1,3 +1,2 @@ FEATURE_AUTHENTICATE_BROWSER=True -FEATURE_SEND_MAIL=False FEATURE_RATE_LIMIT=True diff --git a/cypress/e2e/signin_with_totp/fixtures.sql b/cypress/e2e/signin_with_totp/fixtures.sql index 8acb7255..71d60271 100644 --- a/cypress/e2e/signin_with_totp/fixtures.sql +++ b/cypress/e2e/signin_with_totp/fixtures.sql @@ -8,11 +8,17 @@ VALUES 'kuOSXGk68H2B3pYnph0uyXAHrmpbWaWyX/iX49xVaUc=.VMPBZSO+eAng7mjS.cI2kRY9rwhXchcKiiaMZIg==', CURRENT_TIMESTAMP, true ), - (2, 'unused2@yopmail.com', true, CURRENT_TIMESTAMP, + (2, '181eb568-ca3d-4995-8b06-a717a83421fd@mailslurp.com', true, CURRENT_TIMESTAMP, '$2a$10$kzY3LINL6..50Fy9shWCcuNlRfYq0ft5lS.KCcJ5PzrhlWfKK4NIO', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'Jean', 'Jean', '0123456789', 'Sbire', 'kuOSXGk68H2B3pYnph0uyXAHrmpbWaWyX/iX49xVaUc=.VMPBZSO+eAng7mjS.cI2kRY9rwhXchcKiiaMZIg==', CURRENT_TIMESTAMP, false + ), + (3, 'unused3@yopmail.com', true, CURRENT_TIMESTAMP, + '$2a$10$kzY3LINL6..50Fy9shWCcuNlRfYq0ft5lS.KCcJ5PzrhlWfKK4NIO', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, + 'Jean', 'Jean', '0123456789', 'Sbire', + 'kuOSXGk68H2B3pYnph0uyXAHrmpbWaWyX/iX49xVaUc=.VMPBZSO+eAng7mjS.cI2kRY9rwhXchcKiiaMZIg==', + CURRENT_TIMESTAMP, true ); INSERT INTO organizations diff --git a/cypress/e2e/signin_with_totp/index.cy.ts b/cypress/e2e/signin_with_totp/index.cy.ts index be62a872..77a6f868 100644 --- a/cypress/e2e/signin_with_totp/index.cy.ts +++ b/cypress/e2e/signin_with_totp/index.cy.ts @@ -1,4 +1,13 @@ +import { getVerificationCodeFromEmail } from "#cypress/support/get-from-email"; + describe("sign-in with TOTP on untrusted browser", () => { + beforeEach(() => { + cy.mailslurp().then((mailslurp) => + mailslurp.inboxController.deleteAllInboxEmails({ + inboxId: "181eb568-ca3d-4995-8b06-a717a83421fd", + }), + ); + }); it("should sign-in with password and TOTP", function () { cy.visit("http://localhost:4000"); cy.get("button.proconnect-button").click(); @@ -12,16 +21,67 @@ describe("sign-in with TOTP on untrusted browser", () => { cy.visit("http://localhost:4000"); cy.get("button.proconnect-button").click(); - cy.login("unused2@yopmail.com"); + cy.login("181eb568-ca3d-4995-8b06-a717a83421fd@mailslurp.com"); + + cy.contains( + "Information : pour garantir la sécurité de votre compte, nous avons besoin d’authentifier votre navigateur.", + ); - cy.contains("Vérifier votre email"); + cy.mailslurp() + .then((mailslurp) => + mailslurp.waitForLatestEmail( + "181eb568-ca3d-4995-8b06-a717a83421fd", + 60000, + true, + ), + ) + .then(getVerificationCodeFromEmail) + // fill out the verification form and submit + .then((code) => { + cy.get('[name="verify_email_token"]').type(code); + cy.get('[type="submit"]').click(); + }); + + cy.contains("moncomptepro-standard-client"); }); it("should sign-in with password and TOTP when forced by SP", function () { cy.visit("http://localhost:4000"); cy.get("button#force-2fa").click(); - cy.mfaLogin("unused2@yopmail.com"); + cy.mfaLogin("181eb568-ca3d-4995-8b06-a717a83421fd@mailslurp.com"); + + cy.contains('"amr": [\n "pwd",\n "totp",\n "mfa"\n ],'); + }); + + it("should only show totp step when already logged", function () { + cy.visit("http://localhost:4000"); + cy.get("button.proconnect-button").click(); + + cy.login("181eb568-ca3d-4995-8b06-a717a83421fd@mailslurp.com"); + + cy.mailslurp() + .then((mailslurp) => + mailslurp.waitForLatestEmail( + "181eb568-ca3d-4995-8b06-a717a83421fd", + 60000, + true, + ), + ) + .then(getVerificationCodeFromEmail) + // fill out the verification form and submit + .then((code) => { + cy.get('[name="verify_email_token"]').type(code); + cy.get('[type="submit"]').click(); + }); + + cy.contains("moncomptepro-standard-client"); + + cy.get("button#force-2fa").click(); + + cy.contains("Valider en deux étapes"); + + cy.fillTotpFields(); cy.contains('"amr": [\n "pwd",\n "totp",\n "mfa"\n ],'); }); @@ -29,9 +89,9 @@ describe("sign-in with TOTP on untrusted browser", () => { it("should trigger totp rate limiting", function () { cy.visit("/users/start-sign-in"); - cy.login("unused1@yopmail.com"); + cy.login("unused3@yopmail.com"); - for (let i = 0; i < 4; i++) { + for (let i = 0; i < 5; i++) { cy.get("[name=totpToken]").type("123456"); cy.get( '[action="/users/2fa-sign-in-with-authenticator-app"] [type="submit"]', diff --git a/docker-compose.yml b/docker-compose.yml index d2d1179f..36423f86 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,6 +35,7 @@ services: MCP_CLIENT_SECRET: standard_client_secret MCP_PROVIDER: http://localhost:3000 MCP_SCOPES: openid email profile organization + ACR_VALUE_FOR_2FA: "urn:dinum:ac:classes:consistency-checked-2fa" STYLESHEET_URL: network_mode: "host" @@ -67,6 +68,20 @@ services: STYLESHEET_URL: network_mode: "host" + moncomptepro-acr-client: + image: ghcr.io/numerique-gouv/moncomptepro-test-client + environment: + PORT: 4003 + SITE_TITLE: moncomptepro-acr-client + HOST: http://localhost:4003 + MCP_CLIENT_ID: acr_client_id + MCP_CLIENT_SECRET: acr_client_secret + MCP_PROVIDER: http://localhost:3000 + MCP_SCOPES: openid email profile organization + ACR_VALUE_FOR_2FA: urn:dinum:ac:classes:consistency-checked + STYLESHEET_URL: + network_mode: "host" + maildev: image: soulteary/maildev network_mode: "host" diff --git a/src/config/env.ts b/src/config/env.ts index 973376aa..8d838c16 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -28,6 +28,10 @@ export const { ACCESS_LOG_PATH, API_AUTH_PASSWORD, API_AUTH_USERNAME, + ACR_VALUE_FOR_IAL1_AAL1, + ACR_VALUE_FOR_IAL1_AAL2, + ACR_VALUE_FOR_IAL2_AAL1, + ACR_VALUE_FOR_IAL2_AAL2, BREVO_API_KEY, CRISP_BASE_URL, CRISP_IDENTIFIER, diff --git a/src/config/env.zod.ts b/src/config/env.zod.ts index 7da09100..56f06739 100644 --- a/src/config/env.zod.ts +++ b/src/config/env.zod.ts @@ -52,6 +52,18 @@ export const secretEnvSchema = z.object({ export const paramsEnvSchema = z.object({ ACCESS_LOG_PATH: z.string().optional(), + ACR_VALUE_FOR_IAL1_AAL1: z + .string() + .default("urn:dinum:ac:classes:self-asserted"), + ACR_VALUE_FOR_IAL1_AAL2: z + .string() + .default("urn:dinum:ac:classes:self-asserted-2fa"), + ACR_VALUE_FOR_IAL2_AAL1: z + .string() + .default("urn:dinum:ac:classes:consistency-checked"), + ACR_VALUE_FOR_IAL2_AAL2: z + .string() + .default("urn:dinum:ac:classes:consistency-checked-2fa"), DEPLOY_ENV: z.enum(["preview", "production", "sandbox"]).default("preview"), DIRTY_DS_REDIRECTION_URL: z .string() diff --git a/src/config/oidc-provider-configuration.ts b/src/config/oidc-provider-configuration.ts index 8c32f22d..61e600e2 100644 --- a/src/config/oidc-provider-configuration.ts +++ b/src/config/oidc-provider-configuration.ts @@ -5,6 +5,12 @@ import epochTime from "../services/epoch-time"; import { findAccount } from "../services/oidc-account-adapter"; import policy from "../services/oidc-policy"; import { renderWithEjsLayout } from "../services/renderer"; +import { + ACR_VALUE_FOR_IAL1_AAL1, + ACR_VALUE_FOR_IAL1_AAL2, + ACR_VALUE_FOR_IAL2_AAL1, + ACR_VALUE_FOR_IAL2_AAL2, +} from "./env"; // @@ -13,7 +19,13 @@ export const oidcProviderConfiguration = ({ shortTokenTtlInSeconds = 10 * 60, tokenTtlInSeconds = 60 * 60, }): Configuration => ({ - acrValues: ["eidas1", "https://refeds.org/profile/mfa"], + acrValues: [ + "eidas1", + ACR_VALUE_FOR_IAL1_AAL1, + ACR_VALUE_FOR_IAL1_AAL2, + ACR_VALUE_FOR_IAL2_AAL1, + ACR_VALUE_FOR_IAL2_AAL2, + ], claims: { amr: null, // claims definitions can be found here: https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims diff --git a/src/controllers/interaction.ts b/src/controllers/interaction.ts index d645106d..5804242c 100644 --- a/src/controllers/interaction.ts +++ b/src/controllers/interaction.ts @@ -1,6 +1,13 @@ import type { NextFunction, Request, Response } from "express"; +import { isEmpty } from "lodash-es"; import Provider, { errors } from "oidc-provider"; -import { FEATURE_ALWAYS_RETURN_EIDAS1_FOR_ACR } from "../config/env"; +import { + ACR_VALUE_FOR_IAL1_AAL1, + ACR_VALUE_FOR_IAL1_AAL2, + ACR_VALUE_FOR_IAL2_AAL1, + ACR_VALUE_FOR_IAL2_AAL2, + FEATURE_ALWAYS_RETURN_EIDAS1_FOR_ACR, +} from "../config/env"; import { getSessionStandardizedAuthenticationMethodsReferences, getUserFromAuthenticatedSession, @@ -9,9 +16,15 @@ import { } from "../managers/session/authenticated"; import { setLoginHintInUnauthenticatedSession } from "../managers/session/unauthenticated"; import { findByClientId } from "../repositories/oidc-client"; +import { getUserOrganizationLink } from "../repositories/organization/getters"; +import { getSelectedOrganizationId } from "../repositories/redis/selected-organization"; import epochTime from "../services/epoch-time"; import { mustReturnOneOrganizationInPayload } from "../services/must-return-one-organization-in-payload"; -import { shouldTrigger2fa } from "../services/should-trigger-2fa"; +import { + isAcrSatisfied, + isThereAnyRequestedAcrOtherThanEidas1, + twoFactorsAuthRequested, +} from "../services/should-trigger-2fa"; export const interactionStartControllerFactory = (oidcProvider: any) => @@ -26,9 +39,7 @@ export const interactionStartControllerFactory = req.session.interactionId = interactionId; req.session.mustReturnOneOrganizationInPayload = mustReturnOneOrganizationInPayload(scope); - if (shouldTrigger2fa(prompt)) { - req.session.mustUse2FA = true; - } + req.session.twoFactorsAuthRequested = twoFactorsAuthRequested(prompt); const oidcClient = await findByClientId(client_id); req.session.authForProconnectFederation = @@ -74,29 +85,68 @@ export const interactionEndControllerFactory = try { const user = getUserFromAuthenticatedSession(req); - const acr = - (!FEATURE_ALWAYS_RETURN_EIDAS1_FOR_ACR && - isWithinTwoFactorAuthenticatedSession(req)) || - (FEATURE_ALWAYS_RETURN_EIDAS1_FOR_ACR && req.session.mustUse2FA) - ? "https://refeds.org/profile/mfa" - : "eidas1"; + let isIdentityChecked = false; + + // TODO this check could be made in the user middleware after the user as selected an org + // an error could be thrown and the redirect to sp link could end the interaction in a separate interaction controller + if (req.session.mustReturnOneOrganizationInPayload) { + // TODO move this in a separate function: isIdentityChecked + const selectedOrganizationId = await getSelectedOrganizationId(user.id); + + if (selectedOrganizationId === null) { + const error = Error("selectedOrganizationId should be set"); + + return next(error); + } + + const link = await getUserOrganizationLink( + selectedOrganizationId, + user.id, + ); + + if (isEmpty(link)) { + const error = Error("link should be set"); + + return next(error); + } + + if (link?.verification_type !== null) { + isIdentityChecked = true; + } + } + + let currentAcr = isWithinTwoFactorAuthenticatedSession(req) + ? isIdentityChecked + ? ACR_VALUE_FOR_IAL2_AAL2 + : ACR_VALUE_FOR_IAL1_AAL2 + : isIdentityChecked + ? ACR_VALUE_FOR_IAL2_AAL1 + : ACR_VALUE_FOR_IAL1_AAL1; + const amr = getSessionStandardizedAuthenticationMethodsReferences(req); const ts = user.last_sign_in_at ? epochTime(user.last_sign_in_at) : undefined; - const result: OidcInteractionResults = { + const { prompt } = await oidcProvider.interactionDetails(req, res); + + if ( + FEATURE_ALWAYS_RETURN_EIDAS1_FOR_ACR && + !isThereAnyRequestedAcrOtherThanEidas1(prompt) + ) { + currentAcr = "eidas1"; + } + + let result: OidcInteractionResults = { login: { accountId: user.id.toString(), - acr, + acr: currentAcr, amr, ts, }, select_organization: false, update_userinfo: false, }; - - const { prompt } = await oidcProvider.interactionDetails(req, res); if (prompt.name === "select_organization") { result.select_organization = true; } @@ -105,9 +155,17 @@ export const interactionEndControllerFactory = result.update_userinfo = true; } + if (!isAcrSatisfied(prompt, currentAcr)) { + // TODO log the error like other 400 errors from oidc-provider + result = { + error: "access_denied", + error_description: "none of the requested ACRs could be obtained", + }; + } + req.session.interactionId = undefined; req.session.mustReturnOneOrganizationInPayload = undefined; - req.session.mustUse2FA = undefined; + req.session.twoFactorsAuthRequested = undefined; req.session.authForProconnectFederation = undefined; await oidcProvider.interactionFinished(req, res, result); diff --git a/src/index.ts b/src/index.ts index bc01b1f1..31d13a58 100644 --- a/src/index.ts +++ b/src/index.ts @@ -286,6 +286,8 @@ let server: Server; ) => { logger.error(err); + // TODO if req.session.interactionId is set, we should put a redirectToSpUri in the call-to-action button + if (err instanceof HttpErrors.HttpError) { if (err.statusCode === 404) { return res.status(404).render("not-found-error", { diff --git a/src/managers/session/authenticated.ts b/src/managers/session/authenticated.ts index defe9df2..76896c59 100644 --- a/src/managers/session/authenticated.ts +++ b/src/managers/session/authenticated.ts @@ -51,7 +51,7 @@ export const createAuthenticatedSession = async ( const { interactionId, mustReturnOneOrganizationInPayload, - mustUse2FA, + twoFactorsAuthRequested, referrerPath, authForProconnectFederation, } = req.session; @@ -76,7 +76,7 @@ export const createAuthenticatedSession = async ( req.session.interactionId = interactionId; req.session.mustReturnOneOrganizationInPayload = mustReturnOneOrganizationInPayload; - req.session.mustUse2FA = mustUse2FA; + req.session.twoFactorsAuthRequested = twoFactorsAuthRequested; req.session.referrerPath = referrerPath; req.session.authForProconnectFederation = authForProconnectFederation; // new session reset amr diff --git a/src/middlewares/user.ts b/src/middlewares/user.ts index af28d923..2f040f24 100644 --- a/src/middlewares/user.ts +++ b/src/middlewares/user.ts @@ -150,7 +150,8 @@ export const checkUserTwoFactorAuthMiddleware = async ( const { id: user_id } = getUserFromAuthenticatedSession(req); if ( - ((await shouldForce2faForUser(user_id)) || req.session.mustUse2FA) && + ((await shouldForce2faForUser(user_id)) || + req.session.twoFactorsAuthRequested) && !isWithinTwoFactorAuthenticatedSession(req) ) { if (!(await is2FACapable(user_id))) { @@ -158,7 +159,8 @@ export const checkUserTwoFactorAuthMiddleware = async ( req.session.interactionId = undefined; req.session.mustReturnOneOrganizationInPayload = undefined; - req.session.mustUse2FA = undefined; + req.session.twoFactorsAuthRequested = undefined; + req.session.authForProconnectFederation = undefined; return res.redirect( "/connection-and-account?notification=2fa_not_configured", ); diff --git a/src/services/should-trigger-2fa.ts b/src/services/should-trigger-2fa.ts index 841e569f..468e9ef7 100644 --- a/src/services/should-trigger-2fa.ts +++ b/src/services/should-trigger-2fa.ts @@ -1,6 +1,13 @@ -import { get, isArray } from "lodash-es"; +import { get, intersection, isArray, isEmpty } from "lodash-es"; import type { UnknownObject } from "oidc-provider"; +import { + ACR_VALUE_FOR_IAL1_AAL1, + ACR_VALUE_FOR_IAL1_AAL2, + ACR_VALUE_FOR_IAL2_AAL1, + ACR_VALUE_FOR_IAL2_AAL2, +} from "../config/env"; +// TODO rename this file interface EssentialAcrPromptDetail { name: "login" | "consent" | string; reasons: string[]; @@ -11,15 +18,24 @@ interface EssentialAcrPromptDetail { | UnknownObject; } -export const shouldTrigger2fa = (prompt: EssentialAcrPromptDetail) => { - const value = get(prompt.details, "acr.value") as string | undefined; - const values = get(prompt.details, "acr.values") as string[] | undefined; +const areAcrsRequestedInPrompt = ({ + prompt, + acrs, +}: { + prompt: EssentialAcrPromptDetail; + acrs: string[]; +}) => { + const requestedAcr = get(prompt.details, "acr.value") as string | undefined; + const requestedAcrs = get(prompt.details, "acr.values") as + | string[] + | undefined; const essential = get(prompt.details, "acr.essential") as boolean | undefined; + if ( prompt.name === "login" && prompt.reasons.includes("essential_acr") && essential && - value === "https://refeds.org/profile/mfa" + acrs.includes(requestedAcr || "") ) { return true; } @@ -28,10 +44,54 @@ export const shouldTrigger2fa = (prompt: EssentialAcrPromptDetail) => { prompt.name === "login" && prompt.reasons.includes("essential_acrs") && essential && - isArray(values) && - values.includes("https://refeds.org/profile/mfa") + isArray(requestedAcrs) && + !isEmpty(intersection(acrs, requestedAcrs)) ) { return true; } + return false; }; + +export const twoFactorsAuthRequested = (prompt: EssentialAcrPromptDetail) => { + return areAcrsRequestedInPrompt({ + prompt: prompt, + acrs: [ACR_VALUE_FOR_IAL1_AAL2, ACR_VALUE_FOR_IAL2_AAL2], + }); +}; + +export const isThereAnyRequestedAcrOtherThanEidas1 = ( + prompt: EssentialAcrPromptDetail, +) => { + return areAcrsRequestedInPrompt({ + prompt: prompt, + acrs: [ + ACR_VALUE_FOR_IAL1_AAL1, + ACR_VALUE_FOR_IAL1_AAL2, + ACR_VALUE_FOR_IAL2_AAL1, + ACR_VALUE_FOR_IAL2_AAL2, + ], + }); +}; + +export const isAcrSatisfied = ( + prompt: EssentialAcrPromptDetail, + currentAcr: string, +) => { + // if no acr is required it is satisfied + if ( + !( + prompt.name === "login" && + (prompt.reasons.includes("essential_acr") || + prompt.reasons.includes("essential_acrs")) + ) + ) { + return true; + } + + // if current acr is requested in prompt it is satisfied + return areAcrsRequestedInPrompt({ + prompt: prompt, + acrs: [currentAcr], + }); +}; diff --git a/src/types/express-session.d.ts b/src/types/express-session.d.ts index bd56656c..c73f169c 100644 --- a/src/types/express-session.d.ts +++ b/src/types/express-session.d.ts @@ -5,7 +5,7 @@ export interface UnauthenticatedSessionData { hasWebauthnConfigured?: boolean; interactionId?: string; mustReturnOneOrganizationInPayload?: boolean; - mustUse2FA?: boolean; + twoFactorsAuthRequested?: boolean; referrerPath?: string; authForProconnectFederation?: boolean; } diff --git a/test/env.zod.test.ts b/test/env.zod.test.ts index a45c4eba..386a46e4 100644 --- a/test/env.zod.test.ts +++ b/test/env.zod.test.ts @@ -18,6 +18,10 @@ test("default sample env with configured INSEE secrets", () => { const env = envSchema.parse(sample_env); expect(env).to.deep.equal({ + ACR_VALUE_FOR_IAL1_AAL1: "urn:dinum:ac:classes:self-asserted", + ACR_VALUE_FOR_IAL1_AAL2: "urn:dinum:ac:classes:self-asserted-2fa", + ACR_VALUE_FOR_IAL2_AAL1: "urn:dinum:ac:classes:consistency-checked", + ACR_VALUE_FOR_IAL2_AAL2: "urn:dinum:ac:classes:consistency-checked-2fa", API_AUTH_PASSWORD: "admin", API_AUTH_USERNAME: "admin", CRISP_BASE_URL: "https://api.crisp.chat", diff --git a/test/should-trigger-2fa.test.ts b/test/should-trigger-2fa.test.ts index c9e92346..11c1b04e 100644 --- a/test/should-trigger-2fa.test.ts +++ b/test/should-trigger-2fa.test.ts @@ -1,7 +1,12 @@ import { assert } from "chai"; -import { shouldTrigger2fa } from "../src/services/should-trigger-2fa"; +import { + isAcrSatisfied, + isThereAnyRequestedAcrOtherThanEidas1, + twoFactorsAuthRequested, +} from "../src/services/should-trigger-2fa"; -describe("shouldTrigger2fa", () => { +// TODO rename this file +describe("twoFactorsAuthRequested", () => { it("should return false for random prompt", () => { const prompt = { name: "random", @@ -9,7 +14,13 @@ describe("shouldTrigger2fa", () => { details: { random: "random" }, }; - assert.equal(shouldTrigger2fa(prompt), false); + assert.equal(twoFactorsAuthRequested(prompt), false); + }); + + it("should return false for prompt with no acr required", () => { + const prompt = { name: "login", reasons: ["no_session"], details: {} }; + + assert.equal(twoFactorsAuthRequested(prompt), false); }); it("should return true for new session", () => { @@ -17,11 +28,29 @@ describe("shouldTrigger2fa", () => { name: "login", reasons: ["no_session", "essential_acr"], details: { - acr: { essential: true, value: "https://refeds.org/profile/mfa" }, + acr: { + essential: true, + value: "urn:dinum:ac:classes:consistency-checked-2fa", + }, }, }; - assert.equal(shouldTrigger2fa(prompt), true); + assert.equal(twoFactorsAuthRequested(prompt), true); + }); + + it("should return true for self asserted identity", () => { + const prompt = { + name: "login", + reasons: ["essential_acr"], + details: { + acr: { + essential: true, + value: "urn:dinum:ac:classes:self-asserted-2fa", + }, + }, + }; + + assert.equal(twoFactorsAuthRequested(prompt), true); }); it("should return true for existing session", () => { @@ -31,11 +60,110 @@ describe("shouldTrigger2fa", () => { details: { acr: { essential: true, - values: ["eidas2", "https://refeds.org/profile/mfa"], + values: [ + "eidas2", + "urn:dinum:ac:classes:self-asserted-2fa", + "urn:dinum:ac:classes:consistency-checked-2fa", + ], + }, + }, + }; + + assert.equal(twoFactorsAuthRequested(prompt), true); + }); +}); + +describe("isAcrSatisfied", () => { + it("should return true for acr non-related prompt", () => { + const prompt = { + name: "random", + reasons: ["random"], + details: { random: "random" }, + }; + + assert.equal( + isAcrSatisfied(prompt, "urn:dinum:ac:classes:self-asserted"), + true, + ); + }); + + it("should return true for prompt with no acr required", () => { + const prompt = { name: "login", reasons: ["no_session"], details: {} }; + + assert.equal( + isAcrSatisfied(prompt, "urn:dinum:ac:classes:self-asserted"), + true, + ); + }); + + it("should return true for consistency checked identity", () => { + const prompt = { + name: "login", + reasons: ["essential_acr"], + details: { + acr: { + essential: true, + value: "urn:dinum:ac:classes:consistency-checked", + }, + }, + }; + + assert.equal( + isAcrSatisfied(prompt, "urn:dinum:ac:classes:consistency-checked"), + true, + ); + }); + it("should return false for self-asserted identity", () => { + const prompt = { + name: "login", + reasons: ["essential_acr"], + details: { + acr: { + essential: true, + value: "urn:dinum:ac:classes:consistency-checked", + }, + }, + }; + + assert.equal( + isAcrSatisfied(prompt, "urn:dinum:ac:classes:self-asserted"), + false, + ); + }); +}); + +describe("isThereAnyRequestedAcrOtherThanEidas1", () => { + it("should return false for acr non-related prompt", () => { + const prompt = { + name: "random", + reasons: ["random"], + details: { random: "random" }, + }; + + assert.equal(isThereAnyRequestedAcrOtherThanEidas1(prompt), false); + }); + + it("should return true for prompt with no acr required", () => { + const prompt = { name: "login", reasons: ["no_session"], details: {} }; + + assert.equal(isThereAnyRequestedAcrOtherThanEidas1(prompt), false); + }); + + it("should return true for mfa requested identity", () => { + const prompt = { + name: "login", + reasons: ["essential_acrs"], + details: { + acr: { + essential: true, + values: [ + "urn:dinum:ac:classes:self-asserted-2fa", + "urn:dinum:ac:classes:consistency-checked-2fa", + ], }, }, }; - assert.equal(shouldTrigger2fa(prompt), true); + assert.equal(isThereAnyRequestedAcrOtherThanEidas1(prompt), true); }); });