From d4d3fabda03fe24d401a9e17d5ac20aefc55ecef Mon Sep 17 00:00:00 2001 From: Douglas DUTEIL Date: Mon, 3 Feb 2025 21:05:46 +0100 Subject: [PATCH] feat(user): add certification routes --- .github/workflows/end-to-end.yml | 58 +------ .../env.conf | 1 + .../fixtures.sql | 36 ++++ .../index.cy.ts | 40 +++++ cypress/e2e/signin_with_right_acr/index.cy.ts | 8 +- docker-compose.yml | 4 +- package-lock.json | 48 +++++- package.json | 2 +- packages/identite/package.json | 1 + .../executive/get-franceconnect-user.ts | 109 ++++++++++++ .../src/certification/executive/index.ts | 3 + .../src/types/franceconnect.schema.ts | 18 ++ src/config/env.ts | 7 +- src/config/env.zod.ts | 28 ++++ src/config/notification-messages.ts | 5 + src/connectors/franceconnect.ts | 35 ++++ .../user/certification-dirigeant.ts | 158 ++++++++++++++++++ src/index.ts | 5 + src/managers/session/authenticated.ts | 10 +- src/managers/session/certification.ts | 22 +++ src/middlewares/user.ts | 27 ++- src/routers/user.ts | 44 +++++ src/types/express-session.d.ts | 11 +- .../user/certification-dirigeant-login-as.ejs | 25 +++ src/views/user/certification-dirigeant.ejs | 28 ++++ test/acr-checks.test.ts | 19 +++ test/env.zod.test.ts | 11 +- 27 files changed, 692 insertions(+), 71 deletions(-) create mode 100644 cypress/e2e/signin_with_certification_dirigeant/env.conf create mode 100644 cypress/e2e/signin_with_certification_dirigeant/fixtures.sql create mode 100644 cypress/e2e/signin_with_certification_dirigeant/index.cy.ts create mode 100644 packages/identite/src/certification/executive/get-franceconnect-user.ts create mode 100644 packages/identite/src/certification/executive/index.ts create mode 100644 packages/identite/src/types/franceconnect.schema.ts create mode 100644 src/connectors/franceconnect.ts create mode 100644 src/controllers/user/certification-dirigeant.ts create mode 100644 src/managers/session/certification.ts create mode 100644 src/views/user/certification-dirigeant-login-as.ejs create mode 100644 src/views/user/certification-dirigeant.ejs diff --git a/.github/workflows/end-to-end.yml b/.github/workflows/end-to-end.yml index dcfb4f801..8d753bed3 100644 --- a/.github/workflows/end-to-end.yml +++ b/.github/workflows/end-to-end.yml @@ -7,20 +7,11 @@ on: - "!master" env: - PGUSER: moncomptepro - PGPASSWORD: moncomptepro - PGDATABASE: moncomptepro - PGHOST: 127.0.0.1 - PGPORT: 5432 BREVO_API_KEY: ${{ secrets.BREVO_API_KEY }} - CYPRESS_BASE_URL: http://172.18.0.1:3000 - CYPRESS_MAILSLURP_API_KEY: ${{ secrets.MAILSLURP_API_KEY }} - DATABASE_URL: postgres://moncomptepro:moncomptepro@127.0.0.1:5432/moncomptepro + DATABASE_URL: postgres://moncomptepro:moncomptepro@localhost:5432/moncomptepro DEBOUNCE_API_KEY: ${{ secrets.DEBOUNCE_API_KEY }} - FEATURE_SEND_MAIL: "True" INSEE_CONSUMER_KEY: ${{ secrets.INSEE_CONSUMER_KEY }} INSEE_CONSUMER_SECRET: ${{ secrets.INSEE_CONSUMER_SECRET }} - HOST: http://172.18.0.1:3000 ZAMMAD_TOKEN: ${{ secrets.ZAMMAD_TOKEN }} jobs: test: @@ -47,6 +38,7 @@ jobs: - set_info_after_account_provisioning - signin_from_proconnect_federation_client - signin_from_standard_client + - signin_with_certification_dirigeant - signin_with_email_verification - signin_with_email_verification_renewal - signin_with_legacy_scope @@ -56,52 +48,10 @@ jobs: - signup_entreprise_unipersonnelle - update_personal_information runs-on: ubuntu-22.04 - services: - standard-client: - image: ghcr.io/numerique-gouv/proconnect-test-client - ports: - - 4000:3000 - env: - SITE_TITLE: standard-client - HOST: http://localhost:4000 - PC_CLIENT_ID: standard_client_id - PC_CLIENT_SECRET: standard_client_secret - PC_PROVIDER: ${{ env.HOST }} - PC_SCOPES: openid email profile organization - ACR_VALUE_FOR_2FA: https://proconnect.gouv.fr/assurance/consistency-checked-2fa - STYLESHEET_URL: "" - proconnect-federation-client: - image: ghcr.io/numerique-gouv/proconnect-test-client - ports: - - 4001:3000 - env: - SITE_TITLE: proconnect-federation-client - HOST: http://localhost:4001 - PC_CLIENT_ID: proconnect_federation_client_id - PC_CLIENT_SECRET: proconnect_federation_client_secret - PC_PROVIDER: ${{ env.HOST }} - PC_SCOPES: openid uid given_name usual_name email siren siret organizational_unit belonging_population phone chorusdt is_service_public is_public_service - PC_ID_TOKEN_SIGNED_RESPONSE_ALG: ES256 - PC_USERINFO_SIGNED_RESPONSE_ALG: ES256 - STYLESHEET_URL: "" - LOGIN_HINT: unused1@yopmail.com - ACR_VALUES: eidas1 - redis: - image: redis:7.2 - ports: - - 6379:6379 - postgres: - image: postgres:15.10 - env: - POSTGRES_USER: ${{ env.PGUSER }} - POSTGRES_PASSWORD: ${{ env.PGPASSWORD }} - POSTGRES_DB: ${{ env.PGDATABASE }} - ports: - - 5432:5432 steps: - uses: actions/checkout@v4 - - run: docker compose up --build --detach maildev + - run: docker compose up --build --detach - run: corepack enable - uses: actions/setup-node@v4 @@ -116,7 +66,7 @@ jobs: - name: Cypress run uses: cypress-io/github-action@v6.7.10 with: - wait-on: ${{ env.HOST }}/users/start-sign-in + wait-on: http://localhost:3000/users/start-sign-in build: npm run build:assets start: npx dotenvx run -f cypress/e2e/${{ matrix.e2e_test }}/env.conf --overload -- npm start install: false diff --git a/cypress/e2e/signin_with_certification_dirigeant/env.conf b/cypress/e2e/signin_with_certification_dirigeant/env.conf new file mode 100644 index 000000000..7f468577c --- /dev/null +++ b/cypress/e2e/signin_with_certification_dirigeant/env.conf @@ -0,0 +1 @@ +DO_NOT_SEND_MAIL="True" diff --git a/cypress/e2e/signin_with_certification_dirigeant/fixtures.sql b/cypress/e2e/signin_with_certification_dirigeant/fixtures.sql new file mode 100644 index 000000000..754099e9b --- /dev/null +++ b/cypress/e2e/signin_with_certification_dirigeant/fixtures.sql @@ -0,0 +1,36 @@ +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, 'certification-dirigeant@yopmail.com', true, CURRENT_TIMESTAMP, + '$2a$10$kzY3LINL6..50Fy9shWCcuNlRfYq0ft5lS.KCcJ5PzrhlWfKK4NIO', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, + 'Jean', 'Certification', '0123456789', 'Dirigeant', + 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); + +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', + 'standard_client_id', + 'standard_client_secret', + ARRAY [ + 'http://localhost:4000/login-callback' + ], + ARRAY []::varchar[], + 'openid email profile organization', + 'http://localhost:4000/', + 'ProConnect test client. More info: https://github.com/numerique-gouv/proconnect-test-client.', + null, null, null, null); diff --git a/cypress/e2e/signin_with_certification_dirigeant/index.cy.ts b/cypress/e2e/signin_with_certification_dirigeant/index.cy.ts new file mode 100644 index 000000000..9f4d223e0 --- /dev/null +++ b/cypress/e2e/signin_with_certification_dirigeant/index.cy.ts @@ -0,0 +1,40 @@ +describe("sign-in with a client requiring certification dirigeant", () => { + beforeEach(() => { + cy.visit("http://localhost:4000"); + cy.setRequestedAcrs([ + "https://proconnect.gouv.fr/assurance/certification-dirigeant", + ]); + }); + + it("should sign-in an return the right acr value", function () { + cy.get("button#custom-connection").click({ force: true }); + cy.login("certification-dirigeant@yopmail.com"); + + cy.contains("Authentifier votre statut"); + cy.contains("S’identifier avec").click(); + + cy.origin("https://fcp.integ01.dev-franceconnect.fr", () => { + cy.contains("FIP1-LOW - eIDAS LOW").click(); + }); + cy.origin("https://fip1-low.integ01.fcp.fournisseur-d-identite.fr", () => { + cy.contains("Mot de passe").click(); + cy.focused().type("123"); + cy.contains("Valider").click(); + }); + cy.origin("https://fcp.integ01.dev-franceconnect.fr", () => { + cy.contains("Continuer sur FSPublic").click(); + }); + + cy.contains("Vous allez vous connecter en tant que "); + cy.contains("Angela Claire Louise DUBOIS"); + + cy.contains( + "J'accepte que FranceConnect transmette mes données au service pour me connecter", + ).click(); + cy.contains("Continuer").click(); + + cy.contains( + '"acr": "https://proconnect.gouv.fr/assurance/certification-dirigeant"', + ); + }); +}); diff --git a/cypress/e2e/signin_with_right_acr/index.cy.ts b/cypress/e2e/signin_with_right_acr/index.cy.ts index ba6f0c8ab..e9ab1e38c 100644 --- a/cypress/e2e/signin_with_right_acr/index.cy.ts +++ b/cypress/e2e/signin_with_right_acr/index.cy.ts @@ -117,6 +117,12 @@ describe("sign-in with a client requiring certification dirigeant identity", () cy.get("button#custom-connection").click({ force: true }); cy.login("certification-dirigeant@yopmail.com"); + cy.contains("S’identifier avec").click(); + cy.contains( + "J'accepte que FranceConnect transmette mes données au service pour me connecter", + ).click(); + cy.contains("Continuer").click(); + cy.contains("Continuer").click(); cy.contains( '"acr": "https://proconnect.gouv.fr/assurance/certification-dirigeant"', @@ -154,7 +160,7 @@ describe("sign-in with a client requiring certification dirigeant and 2fa identi }); }); -describe("qign-in with a the requiring certification dirigeant and consistency-checked", () => { +describe("sign-in with a client requiring certification dirigeant and consistency-checked", () => { beforeEach(() => { cy.visit("http://localhost:4000"); cy.setRequestedAcrs([ diff --git a/docker-compose.yml b/docker-compose.yml index 16e2292b3..f9f59d051 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -57,10 +57,8 @@ services: network_mode: "host" maildev: - ports: - - "1080:1080" - - "1025:1025" image: soulteary/maildev + network_mode: "host" volumes: db-data: diff --git a/package-lock.json b/package-lock.json index d1f0f74b8..e0ed66e82 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,10 +9,10 @@ "version": "1.0.0", "license": "MIT", "workspaces": [ - "packages/devtools/typescript", "packages/core", "packages/crisp", "packages/debounce", + "packages/devtools/typescript", "packages/email", "packages/insee", "packages/identite" @@ -6275,9 +6275,9 @@ } }, "node_modules/jose": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/jose/-/jose-5.8.0.tgz", - "integrity": "sha512-E7CqYpL/t7MMnfGnK/eg416OsFCVUrU/Y3Vwe7QjKhu/BkS1Ms455+2xsqZQVN57/U2MHMBvEb5SrmAZWAIntA==", + "version": "5.9.6", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.9.6.tgz", + "integrity": "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -7286,6 +7286,15 @@ "node": ">=8" } }, + "node_modules/oauth4webapi": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.1.4.tgz", + "integrity": "sha512-eVfN3nZNbok2s/ROifO0UAc5G8nRoLSbrcKJ09OqmucgnhXEfdIQOR4gq1eJH1rN3gV7rNw62bDEgftsgFtBEg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/object-hash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", @@ -7431,6 +7440,19 @@ "resolved": "https://registry.npmjs.org/only/-/only-0.0.2.tgz", "integrity": "sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ==" }, + "node_modules/openid-client": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.1.7.tgz", + "integrity": "sha512-JfY/KvQgOutmG2P+oVNKInE7zIh+im1MQOaO7g5CtNnTWMociA563WweiEMKfR9ry9XG3K2HGvj9wEqhCQkPMg==", + "license": "MIT", + "dependencies": { + "jose": "^5.9.6", + "oauth4webapi": "^3.1.4" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/os-tmpdir": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", @@ -10683,11 +10705,29 @@ "vite": "^5.4.12" } }, + "packages/franceconnect": { + "name": "@gouvfr-lasuite/proconnect.franceconnect", + "version": "0.0.0", + "extraneous": true, + "license": "MIT", + "dependencies": { + "zod": "^3.24.1" + }, + "devDependencies": { + "@gouvfr-lasuite/proconnect.devtools.typescript": "0.0.0", + "@types/mocha": "^10.0.10", + "@types/node": "^22.10.2", + "chai": "^5.1.2", + "mocha": "^11.0.1", + "tsx": "^4.19.2" + } + }, "packages/identite": { "name": "@gouvfr-lasuite/proconnect.identite", "version": "0.3.1", "license": "MIT", "dependencies": { + "openid-client": "^6.1.7", "sql-template-tag": "^5.2.1" }, "devDependencies": { diff --git a/package.json b/package.json index 7b6e507fe..55d2aa520 100644 --- a/package.json +++ b/package.json @@ -9,10 +9,10 @@ }, "main": "src/index.js", "workspaces": [ - "packages/devtools/typescript", "packages/core", "packages/crisp", "packages/debounce", + "packages/devtools/typescript", "packages/email", "packages/insee", "packages/identite" diff --git a/packages/identite/package.json b/packages/identite/package.json index 3ddefa90b..caf68f3f5 100644 --- a/packages/identite/package.json +++ b/packages/identite/package.json @@ -46,6 +46,7 @@ "spec": "src/**/*.test.ts" }, "dependencies": { + "openid-client": "^6.1.7", "sql-template-tag": "^5.2.1" }, "devDependencies": { diff --git a/packages/identite/src/certification/executive/get-franceconnect-user.ts b/packages/identite/src/certification/executive/get-franceconnect-user.ts new file mode 100644 index 000000000..c13d785b2 --- /dev/null +++ b/packages/identite/src/certification/executive/get-franceconnect-user.ts @@ -0,0 +1,109 @@ +// + +import { + authorizationCodeGrant, + buildAuthorizationUrl, + ClientSecretPost, + Configuration, + fetchUserInfo, + randomNonce, + randomState, +} from "openid-client"; +import { z } from "zod"; +import { + FranceConnectUserInfoSchema, + type FranceConnectUserInfo, +} from "../../types/franceconnect.schema.js"; + +// + +export function getFranceConnectConfigurationFactory( + server: URL, + clientId: string, + clientSecret: string, +) { + return function getFranceConnectConfiguration() { + const serverUri = server.toString(); + return new Configuration( + { + authorization_endpoint: `${serverUri}/authorize`, + issuer: server.origin, + jwks_uri: `${serverUri}/jwks`, + token_endpoint: `${serverUri}/token`, + userinfo_endpoint: `${serverUri}/userinfo`, + token_endpoint_auth_method: "client_secret_basic", + }, + clientId, + { + id_token_signed_response_alg: "HS256", + }, + ClientSecretPost(clientSecret), + ); + }; +} +export type GetFranceConnectConfigurationHandler = ReturnType< + typeof getFranceConnectConfigurationFactory +>; + +export function createChecks() { + return { + state: randomState(), + nonce: randomNonce(), + }; +} + +export function getFranceConnectRedirectUrlFactory( + getConfiguration: GetFranceConnectConfigurationHandler, + parameters: { + callbackUrl: string; + scope: string; + }, +) { + const { callbackUrl, scope } = parameters; + return async function getFranceConnectUser(nonce: string, state: string) { + const config = getConfiguration(); + return buildAuthorizationUrl( + config, + new URLSearchParams({ + nonce, + redirect_uri: callbackUrl, + scope, + state, + }), + ); + }; +} + +export function getFranceConnectUserFactory( + getConfiguration: GetFranceConnectConfigurationHandler, +) { + return async function getFranceConnectUser(parameters: { + code: string; + currentUrl: string; + expectedNonce: string; + expectedState: string; + }) { + const { code, currentUrl, expectedNonce, expectedState } = parameters; + const config = getConfiguration(); + const tokens = await authorizationCodeGrant( + config, + new URL(currentUrl), + { + expectedNonce, + expectedState, + }, + { code }, + ); + const claims = tokens.claims(); + + const { sub } = await z + .object({ + sub: z.string(), + }) + .parseAsync(claims); + const userInfo = await fetchUserInfo(config, tokens.access_token, sub); + return FranceConnectUserInfoSchema.passthrough().parseAsync( + userInfo, + ) as Promise; + }; +} diff --git a/packages/identite/src/certification/executive/index.ts b/packages/identite/src/certification/executive/index.ts new file mode 100644 index 000000000..8de4cca22 --- /dev/null +++ b/packages/identite/src/certification/executive/index.ts @@ -0,0 +1,3 @@ +// + +export * from "./get-franceconnect-user.js"; diff --git a/packages/identite/src/types/franceconnect.schema.ts b/packages/identite/src/types/franceconnect.schema.ts new file mode 100644 index 000000000..f659d4aca --- /dev/null +++ b/packages/identite/src/types/franceconnect.schema.ts @@ -0,0 +1,18 @@ +// + +import { z } from "zod"; + +// + +/** + * @see https://docs.partenaires.franceconnect.gouv.fr/fs/fs-technique/fs-technique-scope-fc/#liste-des-claims + */ +export const FranceConnectUserInfoSchema = z.object({ + birthdate: z.string(), + birthplace: z.string(), + family_name: z.string(), + gender: z.string(), + given_name: z.string(), +}); + +export type FranceConnectUserInfo = z.infer; diff --git a/src/config/env.ts b/src/config/env.ts index fb73ace5f..fe04222c6 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -51,14 +51,19 @@ export const { FEATURE_AUTHENTICATE_BROWSER, FEATURE_CHECK_EMAIL_DELIVERABILITY, FEATURE_CONSIDER_ALL_EMAIL_DOMAINS_AS_FREE, + FEATURE_BYPASS_MODERATION, FEATURE_CONSIDER_ALL_EMAIL_DOMAINS_AS_NON_FREE, FEATURE_DISPLAY_TEST_ENV_WARNING, - FEATURE_BYPASS_MODERATION, FEATURE_RATE_LIMIT, FEATURE_SEND_MAIL, FEATURE_USE_ANNUAIRE_EMAILS, FEATURE_USE_SECURE_COOKIES, FEATURE_USE_SECURITY_RESPONSE_HEADERS, + FRANCECONNECT_CALLBACK_URL, + FRANCECONNECT_CLIENT_ID, + FRANCECONNECT_CLIENT_SECRET, + FRANCECONNECT_ISSUER, + FRANCECONNECT_SCOPES, HTTP_CLIENT_TIMEOUT, INSEE_CONSUMER_KEY, INSEE_CONSUMER_SECRET, diff --git a/src/config/env.zod.ts b/src/config/env.zod.ts index 23dfd1a81..55acac0ed 100644 --- a/src/config/env.zod.ts +++ b/src/config/env.zod.ts @@ -15,6 +15,34 @@ export const connectorEnvSchema = z.object({ CRISP_MODERATION_TAG: zCoerceArray(z.string()).default("identite,moderation"), DATABASE_URL: z.string().url(), DEBOUNCE_API_KEY: z.string().default(""), + FRANCECONNECT_CALLBACK_URL: z + .string() + .default("http://localhost:3000/login-callback"), + FRANCECONNECT_CLIENT_ID: z + .string() + .default( + "211286433e39cce01db448d80181bdfd005554b19cd51b3fe7943f6b3b86ab6e", + ), + FRANCECONNECT_CLIENT_SECRET: z + .string() + .default( + "2791a731e6a59f56b6b4dd0d08c9b1f593b5f3658b9fd731cb24248e2669af4b", + ), + FRANCECONNECT_ISSUER: z + .string() + .url() + .default("https://fcp.integ01.dev-franceconnect.fr/api/v1"), + FRANCECONNECT_SCOPES: zCoerceArray(z.string()).default( + [ + "birthplace", + "birthdate", + "family_name", + "gender", + "given_name", + "openid", + "preferred_username", + ].join(" "), + ), INSEE_CONSUMER_KEY: z.string(), INSEE_CONSUMER_SECRET: z.string(), REDIS_URL: z.string().url().default("redis://:@127.0.0.1:6379"), diff --git a/src/config/notification-messages.ts b/src/config/notification-messages.ts index e265ddbfd..1d5c62971 100644 --- a/src/config/notification-messages.ts +++ b/src/config/notification-messages.ts @@ -164,6 +164,11 @@ Si vous avez oublié votre mot de passe cliquez sur « Mot de passe oublié ? type: "success", description: "L’application d’authentification a bien été supprimée.", }, + certification_franceconnect_data_transmission_agreement_required: { + type: "error", + description: + "Erreur : vous devez accepter la transmission de vos données FranceConnect pour permettre la certification dirigeante.", + }, "2fa_successfully_enabled": { type: "success", description: "La double authentification a été activée sur tous les sites.", diff --git a/src/connectors/franceconnect.ts b/src/connectors/franceconnect.ts new file mode 100644 index 000000000..efd353c72 --- /dev/null +++ b/src/connectors/franceconnect.ts @@ -0,0 +1,35 @@ +// + +import { + getFranceConnectConfigurationFactory, + getFranceConnectRedirectUrlFactory, + getFranceConnectUserFactory, +} from "@gouvfr-lasuite/proconnect.identite/certification/executive"; +import { + FRANCECONNECT_CALLBACK_URL, + FRANCECONNECT_CLIENT_ID, + FRANCECONNECT_CLIENT_SECRET, + FRANCECONNECT_ISSUER, + FRANCECONNECT_SCOPES, +} from "../config/env"; + +// + +export const getFranceConnectConfiguration = + getFranceConnectConfigurationFactory( + new URL(FRANCECONNECT_ISSUER), + FRANCECONNECT_CLIENT_ID, + FRANCECONNECT_CLIENT_SECRET, + ); + +export const getFranceConnectRedirectUrl = getFranceConnectRedirectUrlFactory( + getFranceConnectConfiguration, + { + callbackUrl: FRANCECONNECT_CALLBACK_URL, + scope: FRANCECONNECT_SCOPES.join(" "), + }, +); + +export const getFranceConnectUser = getFranceConnectUserFactory( + getFranceConnectConfiguration, +); diff --git a/src/controllers/user/certification-dirigeant.ts b/src/controllers/user/certification-dirigeant.ts new file mode 100644 index 000000000..a0c9a414f --- /dev/null +++ b/src/controllers/user/certification-dirigeant.ts @@ -0,0 +1,158 @@ +// + +import { createChecks } from "@gouvfr-lasuite/proconnect.identite/certification/executive"; +import type { NextFunction, Request, Response } from "express"; +import { z } from "zod"; +import { FRANCECONNECT_CALLBACK_URL } from "../../config/env"; +import { OidcError } from "../../config/errors"; +import { + getFranceConnectRedirectUrl, + getFranceConnectUser, +} from "../../connectors/franceconnect"; +import { FranceConnectOidcSessionSchema } from "../../managers/session/certification"; +import { csrfToken } from "../../middlewares/csrf-protection"; +import getNotificationsFromRequest from "../../services/get-notifications-from-request"; + +// + +export async function getCertificationDirigeantController( + req: Request, + res: Response, + next: NextFunction, +) { + try { + return res.render("user/certification-dirigeant", { + csrfToken: csrfToken(req), + pageTitle: "Certification dirigeant", + }); + } catch (error) { + next(error); + } +} + +export async function postCertificationDirigeantController( + req: Request, + res: Response, + next: NextFunction, +) { + try { + const { nonce, state } = createChecks(); + req.session.nonce = nonce; + req.session.state = state; + + const url = await getFranceConnectRedirectUrl(nonce, state); + + return res.redirect(url.toString()); + } catch (error) { + next(error); + } +} + +// + +export async function getCertificationDirigeantLoginAsController( + req: Request, + res: Response, + next: NextFunction, +) { + try { + const errorQuery = await z + .object({ error: z.string(), error_description: z.string() }) + .safeParseAsync(req.query); + + if (errorQuery.success) { + const { error, error_description } = errorQuery.data; + throw new OidcError(error, error_description); + } + + const { code } = await z.object({ code: z.string() }).parseAsync(req.query); + + const { nonce, state } = await FranceConnectOidcSessionSchema.parseAsync( + req.session, + ); + const { given_name, family_name } = await getFranceConnectUser({ + code, + currentUrl: `${FRANCECONNECT_CALLBACK_URL}${req.url.substring(req.path.length)}`, + expectedNonce: nonce, + expectedState: state, + }); + + // TODO(douglasduteil): handle FC logout + // Should we directly logout from FC after this using the _idToken ? + + // TODO(douglasduteil): Redirect to another page to allow page reload / error notification + // As the user can be redirected to the certification-dirigeant page and the code is onetime use only, + // we should redirect to another page keeping the result of the FC userinfo request + // Should we store the FranceConnect data in the session (for how long)? + + return res.render("user/certification-dirigeant-login-as", { + csrfToken: csrfToken(req), + notifications: await getNotificationsFromRequest(req), + pageTitle: "Se connecter en tant que", + name: `${given_name} ${family_name}`, + }); + } catch (error) { + next(error); + } +} + +export async function postCertificationDirigeantLoginAsController( + req: Request, + res: Response, + next: NextFunction, +) { + try { + const schema = z.object({ + agreement: z.literal("on").optional(), + }); + + const { agreement } = await schema.parseAsync(req.body); + + if (agreement !== "on") { + return res.redirect( + "/users/certification-dirigeant/login-as?notification=certification_franceconnect_data_transmission_agreement_required", + ); + } + + // TODO(douglasduteil): get the FranceConnect data from the session + // Should we alter the the database with the FranceConnect data ? + // Should we store if the user already FranceConnected in the database ? + req.session.user_certified = true; + + // ~~Should we redirect to a "welcome" page for franceconnected users ?~~ + // Should we go the organization selection page ? + // return res.redirect("/users/sign-in"); + // return res.redirect("/users/sign-in"); + next(); + } catch (error) { + next(error); + } +} + +// + +export async function getCertificationDirigeantRepresentingController( + req: Request, + res: Response, + next: NextFunction, +) { + try { + const userOrganizations = [ + { + id: "1", + siret: "12345678901234", + cached_libelle: "Organisation 1", + cached_adresse: "123 rue de la paix", + cached_libelle_activite_principale: "Activité principale 1", + }, + ]; + return res.render("user/select-organization", { + csrfToken: csrfToken(req), + illustration: "illu-password.svg", + pageTitle: "Choisir une organisation", + userOrganizations, + }); + } catch (error) { + next(error); + } +} diff --git a/src/index.ts b/src/index.ts index 34c5afe0e..e7e22a4d8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -258,6 +258,11 @@ let server: Server; ejsLayoutMiddlewareFactory(app), interactionRouter(oidcProvider), ); + app.use("/login-callback", function franceConnectLoginCallback(req, res) { + return res.redirect( + `/users/certification-dirigeant/login-as${req.url.substring(req.path.length)}`, + ); + }); app.use("/users", ejsLayoutMiddlewareFactory(app), userRouter()); app.use("/api", apiRouter()); diff --git a/src/managers/session/authenticated.ts b/src/managers/session/authenticated.ts index cf50efe68..5f7d3269a 100644 --- a/src/managers/session/authenticated.ts +++ b/src/managers/session/authenticated.ts @@ -54,12 +54,14 @@ export const createAuthenticatedSession = async ( // email and needsInclusionconnectWelcomePage are not passed to the new session as it is not useful within logged session // csrfToken should not be passed to the new session for security reasons const { + authForProconnectFederation, + certificationDirigeantRequested, interactionId, mustReturnOneOrganizationInPayload, - twoFactorsAuthRequested, + nonce, referrerPath, - authForProconnectFederation, - certificationDirigeantRequested, + state, + twoFactorsAuthRequested, } = req.session; // as selected org is not stored in session, @@ -89,6 +91,8 @@ export const createAuthenticatedSession = async ( req.session.authForProconnectFederation = authForProconnectFederation; // new session reset amr req.session.amr = []; + req.session.nonce = nonce; + req.session.state = state; req.session.amr = addAuthenticationMethodReference( req.session.amr, diff --git a/src/managers/session/certification.ts b/src/managers/session/certification.ts new file mode 100644 index 000000000..14122c359 --- /dev/null +++ b/src/managers/session/certification.ts @@ -0,0 +1,22 @@ +// + +import { z } from "zod"; + +// + +export const CertificationSessionSchema = z.object({ + user_certified: z.boolean().default(false), + certificationDirigeantRequested: z.boolean().default(false), +}); +export type CertificationSession = z.infer; + +// + +export const FranceConnectOidcSessionSchema = z.object({ + nonce: z.string(), + state: z.string(), +}); + +export type FranceConnectOidcSession = z.infer< + typeof FranceConnectOidcSessionSchema +>; diff --git a/src/middlewares/user.ts b/src/middlewares/user.ts index 8a176a19f..e66b44ed4 100644 --- a/src/middlewares/user.ts +++ b/src/middlewares/user.ts @@ -18,6 +18,7 @@ import { isWithinAuthenticatedSession, isWithinTwoFactorAuthenticatedSession, } from "../managers/session/authenticated"; +import { CertificationSessionSchema } from "../managers/session/certification"; import { getEmailFromUnauthenticatedSession, getPartialUserFromUnauthenticatedSession, @@ -227,12 +228,36 @@ export const checkUserIsVerifiedMiddleware = ( } }); -export const checkUserHasPersonalInformationsMiddleware = ( +export const checkUserNeedCertificationDirigeantMiddleware = ( req: Request, res: Response, next: NextFunction, ) => checkUserIsVerifiedMiddleware(req, res, async (error) => { + try { + if (error) return next(error); + + const { + certificationDirigeantRequested: isRequested, + user_certified: isAlreadyCertified, + } = await CertificationSessionSchema.parseAsync(req.session); + + if (isAlreadyCertified) return next(); + + if (isRequested) return res.redirect("/users/certification-dirigeant"); + + return next(); + } catch (error) { + next(error); + } + }); + +export const checkUserHasPersonalInformationsMiddleware = ( + req: Request, + res: Response, + next: NextFunction, +) => + checkUserNeedCertificationDirigeantMiddleware(req, res, async (error) => { try { if (error) return next(error); diff --git a/src/routers/user.ts b/src/routers/user.ts index 611155423..1fc16a8dc 100644 --- a/src/routers/user.ts +++ b/src/routers/user.ts @@ -10,6 +10,13 @@ import { } from "../controllers/organization"; import { postSignInWithAuthenticatorAppController } from "../controllers/totp"; import { get2faSignInController } from "../controllers/user/2fa-sign-in"; +import { + getCertificationDirigeantController, + getCertificationDirigeantLoginAsController, + getCertificationDirigeantRepresentingController, + postCertificationDirigeantController, + postCertificationDirigeantLoginAsController, +} from "../controllers/user/certification-dirigeant"; import { postDeleteUserController } from "../controllers/user/delete"; import { postCancelModerationAndRedirectControllerFactory } from "../controllers/user/edit-moderation"; import { issueSessionOrRedirectController } from "../controllers/user/issue-session-or-redirect"; @@ -418,6 +425,43 @@ export const userRouter = () => { postDeleteUserController, ); + userRouter.get( + "/certification-dirigeant", + rateLimiterMiddleware, + csrfProtectionMiddleware, + getCertificationDirigeantController, + ); + + userRouter.post( + "/certification-dirigeant", + rateLimiterMiddleware, + csrfProtectionMiddleware, + postCertificationDirigeantController, + ); + + userRouter.get( + "/certification-dirigeant/login-as", + rateLimiterMiddleware, + csrfProtectionMiddleware, + getCertificationDirigeantLoginAsController, + ); + + userRouter.post( + "/certification-dirigeant/login-as", + rateLimiterMiddleware, + csrfProtectionMiddleware, + postCertificationDirigeantLoginAsController, + checkUserSignInRequirementsMiddleware, + issueSessionOrRedirectController, + ); + + userRouter.get( + "/certification-dirigeant/representing", + rateLimiterMiddleware, + csrfProtectionMiddleware, + getCertificationDirigeantRepresentingController, + ); + return userRouter; }; diff --git a/src/types/express-session.d.ts b/src/types/express-session.d.ts index a33bf7dbe..95839be65 100644 --- a/src/types/express-session.d.ts +++ b/src/types/express-session.d.ts @@ -1,3 +1,8 @@ +import type { + CertificationSession, + FranceConnectOidcSession, +} from "../managers/session/certification"; + export interface UnauthenticatedSessionData { email?: string; loginHint?: string; @@ -8,7 +13,6 @@ export interface UnauthenticatedSessionData { twoFactorsAuthRequested?: boolean; referrerPath?: string; authForProconnectFederation?: boolean; - certificationDirigeantRequested?: boolean; } export type AmrValue = @@ -29,7 +33,10 @@ export interface AuthenticatedSessionData { } declare module "express-session" { - export interface SessionData extends UnauthenticatedSessionData { + export interface SessionData + extends UnauthenticatedSessionData, + FranceConnectOidcSession, + CertificationSession { user?: User; temporaryEncryptedTotpKey?: string; amr?: AmrValue[]; diff --git a/src/views/user/certification-dirigeant-login-as.ejs b/src/views/user/certification-dirigeant-login-as.ejs new file mode 100644 index 000000000..1bd08ce5d --- /dev/null +++ b/src/views/user/certification-dirigeant-login-as.ejs @@ -0,0 +1,25 @@ +
+ <%- include('../partials/notifications.ejs', {notifications: notifications}) %> +

Vous allez vous connecter en tant que :

+
+

<%= name; %>

+
+ +
+ + +
+
+
+ + +
+
+
+ + +
+
diff --git a/src/views/user/certification-dirigeant.ejs b/src/views/user/certification-dirigeant.ejs new file mode 100644 index 000000000..ffd99a8b4 --- /dev/null +++ b/src/views/user/certification-dirigeant.ejs @@ -0,0 +1,28 @@ +
+

Authentifier votre statut

+ +

+ Vous pouvez authentifier instantanément votre statut de dirigeant grâce à + FranceConnect. +

+ +
+ + +
+ +

+ Qu’est-ce que FranceConnect ? +

+
+
+
diff --git a/test/acr-checks.test.ts b/test/acr-checks.test.ts index 7055f1c66..bb94e528e 100644 --- a/test/acr-checks.test.ts +++ b/test/acr-checks.test.ts @@ -283,4 +283,23 @@ describe("certificationDirigeantRequested", () => { assert.equal(certificationDirigeantRequested(prompt), false); }); + + it("should return false if non self asserted acr are requested", () => { + const prompt = { + details: { + acr: { + essential: true, + values: [ + "https://proconnect.gouv.fr/assurance/certification-dirigeant", + "https://proconnect.gouv.fr/assurance/consistency-checked", + "https://proconnect.gouv.fr/assurance/consistency-checked-2fa", + ], + }, + }, + name: "login", + reasons: ["essential_acrs"], + }; + + assert.equal(certificationDirigeantRequested(prompt), false); + }); }); diff --git a/test/env.zod.test.ts b/test/env.zod.test.ts index ec4a49b47..f32eae8cb 100644 --- a/test/env.zod.test.ts +++ b/test/env.zod.test.ts @@ -47,16 +47,25 @@ test("default sample env with configured INSEE secrets", () => { EMAIL_DELIVERABILITY_WHITELIST: [], FEATURE_ALWAYS_RETURN_EIDAS1_FOR_ACR: false, FEATURE_AUTHENTICATE_BROWSER: false, + FEATURE_BYPASS_MODERATION: false, FEATURE_CHECK_EMAIL_DELIVERABILITY: false, FEATURE_CONSIDER_ALL_EMAIL_DOMAINS_AS_FREE: false, FEATURE_CONSIDER_ALL_EMAIL_DOMAINS_AS_NON_FREE: true, FEATURE_DISPLAY_TEST_ENV_WARNING: false, - FEATURE_BYPASS_MODERATION: false, FEATURE_RATE_LIMIT: false, FEATURE_SEND_MAIL: false, FEATURE_USE_ANNUAIRE_EMAILS: false, FEATURE_USE_SECURE_COOKIES: false, FEATURE_USE_SECURITY_RESPONSE_HEADERS: false, + FRANCECONNECT_CALLBACK_URL: "http://localhost:3000/login-callback", + FRANCECONNECT_CLIENT_ID: + "211286433e39cce01db448d80181bdfd005554b19cd51b3fe7943f6b3b86ab6e", + FRANCECONNECT_CLIENT_SECRET: + "2791a731e6a59f56b6b4dd0d08c9b1f593b5f3658b9fd731cb24248e2669af4b", + FRANCECONNECT_ISSUER: "https://fcp.integ01.dev-franceconnect.fr/api/v1", + FRANCECONNECT_SCOPES: [ + "birthplace birthdate family_name gender given_name openid preferred_username", + ], HTTP_CLIENT_TIMEOUT: 55000, INSEE_CONSUMER_KEY: "fakesecret", INSEE_CONSUMER_SECRET: "fakesecret",