From b50b47e1b874273312a23f40757a59a486ec949e 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 +----- assets/css/imports/custom.css | 6 + .../env.conf | 1 + .../fixtures.sql | 36 ++++ .../index.cy.ts | 40 ++++ cypress/e2e/signin_with_right_acr/env.conf | 1 + cypress/e2e/signin_with_right_acr/index.cy.ts | 2 +- .../update_personal_information/fixtures.sql | 11 +- .../update_personal_information/index.cy.ts | 25 ++- docker-compose.yml | 4 +- ...9148263_create-verification-user-table.cjs | 18 ++ 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 ++ packages/identite/src/types/index.ts | 2 + .../identite/src/types/user-verification.ts | 18 ++ .../user/get-user-verification-link.test.ts | 61 +++++++ .../src/user/get-user-verification-link.ts | 25 +++ packages/identite/src/user/index.ts | 2 + packages/identite/src/user/update.ts | 9 +- .../user/upset-user-verification-link.test.ts | 97 ++++++++++ .../src/user/upset-user-verification-link.ts | 41 +++++ src/config/env.ts | 9 +- src/config/env.zod.ts | 35 ++++ src/config/notification-messages.ts | 5 + src/connectors/franceconnect.ts | 35 ++++ src/controllers/main.ts | 23 ++- .../user/certification-dirigeant.ts | 171 ++++++++++++++++++ .../user/update-personal-informations.ts | 13 +- src/index.ts | 5 + src/managers/session/authenticated.ts | 14 +- src/managers/session/certification.ts | 27 +++ src/managers/user.ts | 64 ++++++- src/middlewares/user.ts | 38 +++- src/repositories/user.ts | 10 + src/routers/user.ts | 44 +++++ src/types/express-session.d.ts | 11 +- src/views/personal-information.ejs | 18 +- .../user/certification-dirigeant-login-as.ejs | 25 +++ src/views/user/certification-dirigeant.ejs | 28 +++ src/views/user/personal-information.ejs | 24 ++- test/acr-checks.test.ts | 19 ++ test/env.zod.test.ts | 13 +- tsconfig.json | 5 +- 47 files changed, 1162 insertions(+), 112 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 migrations/1739189148263_create-verification-user-table.cjs 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 packages/identite/src/types/user-verification.ts create mode 100644 packages/identite/src/user/get-user-verification-link.test.ts create mode 100644 packages/identite/src/user/get-user-verification-link.ts create mode 100644 packages/identite/src/user/upset-user-verification-link.test.ts create mode 100644 packages/identite/src/user/upset-user-verification-link.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/assets/css/imports/custom.css b/assets/css/imports/custom.css index 69bcde21e..5d54be5fa 100644 --- a/assets/css/imports/custom.css +++ b/assets/css/imports/custom.css @@ -50,3 +50,9 @@ z-index: 1000; position: absolute; } + +/* Inspied by `.fr-input:disabled` from the dsfr */ +input[readonly] { + color: var(--text-disabled-grey); + box-shadow: inset 0 -2px 0 0 var(--border-disabled-grey); +} 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/env.conf b/cypress/e2e/signin_with_right_acr/env.conf index 7f468577c..580f2ba2c 100644 --- a/cypress/e2e/signin_with_right_acr/env.conf +++ b/cypress/e2e/signin_with_right_acr/env.conf @@ -1 +1,2 @@ DO_NOT_SEND_MAIL="True" +FEATURE_CONSIDER_ALL_USERS_AS_CERTIFIED="True" diff --git a/cypress/e2e/signin_with_right_acr/index.cy.ts b/cypress/e2e/signin_with_right_acr/index.cy.ts index ba6f0c8ab..3a6e250e8 100644 --- a/cypress/e2e/signin_with_right_acr/index.cy.ts +++ b/cypress/e2e/signin_with_right_acr/index.cy.ts @@ -154,7 +154,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/cypress/e2e/update_personal_information/fixtures.sql b/cypress/e2e/update_personal_information/fixtures.sql index 84b951df5..155a18d60 100644 --- a/cypress/e2e/update_personal_information/fixtures.sql +++ b/cypress/e2e/update_personal_information/fixtures.sql @@ -2,7 +2,8 @@ INSERT INTO users (id, email, email_verified, email_verified_at, encrypted_password, created_at, updated_at, given_name, family_name, phone_number, job) VALUES - (1, 'konrad.curze@nightlords.world', true, CURRENT_TIMESTAMP, '$2a$10$kzY3LINL6..50Fy9shWCcuNlRfYq0ft5lS.KCcJ5PzrhlWfKK4NIO', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'Konrad', 'Curze', '0404040404', 'Primarque'); + (1, 'god-emperor@mankind.world', true, CURRENT_TIMESTAMP, '$2a$10$kzY3LINL6..50Fy9shWCcuNlRfYq0ft5lS.KCcJ5PzrhlWfKK4NIO', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'God', 'Empire', '9999999999', 'God Empire'), + (2, 'konrad.curze@nightlords.world', true, CURRENT_TIMESTAMP, '$2a$10$kzY3LINL6..50Fy9shWCcuNlRfYq0ft5lS.KCcJ5PzrhlWfKK4NIO', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'Konrad', 'Curze', '0404040404', 'Primarque'); INSERT INTO organizations (id, siret, created_at, updated_at) @@ -12,4 +13,10 @@ VALUES INSERT INTO users_organizations (user_id, organization_id, is_external, verification_type, has_been_greeted) VALUES - (1, 1, false, 'verified_email_domain', true); + (1, 1, false, 'verified_email_domain', true), + (2, 1, false, 'verified_email_domain', true); + +INSERT INTO users_verification + (user_id, verification_type, verified_at) +VALUES + (1, 'franceconnect', CURRENT_TIMESTAMP); diff --git a/cypress/e2e/update_personal_information/index.cy.ts b/cypress/e2e/update_personal_information/index.cy.ts index 7a0bac763..f0337afb6 100644 --- a/cypress/e2e/update_personal_information/index.cy.ts +++ b/cypress/e2e/update_personal_information/index.cy.ts @@ -19,12 +19,12 @@ describe("Signup into new entreprise unipersonnelle", () => { "Mise à jour de vos données personnelles", ).then((email) => { cy.maildevVisitMessageById(email.id); + cy.maildevDeleteMessageById(email.id); cy.contains( "Nous vous informons que vos données personnelles ont été mises à jour avec succès.", ); cy.contains("Prénom : Night"); cy.contains("Nom de famille : Haunter"); - cy.maildevDeleteMessageById(email.id); }); }); @@ -43,4 +43,27 @@ describe("Signup into new entreprise unipersonnelle", () => { ); }); }); + + it("should no allow verified user to update given and family name", () => { + cy.visit("/personal-information"); + + cy.login("god-emperor@mankind.world"); + + ["given_name", "family_name"].forEach((inputName) => { + cy.get(`input[name="${inputName}"]`).should( + "have.attr", + "readonly", + "readonly", + ); + }); + + cy.contains("Issue de votre vérification par FranceConnect"); + cy.contains("Profession").click(); + cy.focused().clear().type("Guide GPS Warp"); + + cy.get('[type="submit"]').contains("Mettre à jour").click(); + + cy.contains("Vos informations ont été mises à jour."); + cy.contains("Guide GPS Warp"); + }); }); 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/migrations/1739189148263_create-verification-user-table.cjs b/migrations/1739189148263_create-verification-user-table.cjs new file mode 100644 index 000000000..a78fca3c9 --- /dev/null +++ b/migrations/1739189148263_create-verification-user-table.cjs @@ -0,0 +1,18 @@ +exports.shorthands = undefined; + +exports.up = async (pgm) => { + await pgm.db.query(` + CREATE TABLE users_verification ( + user_id INTEGER UNIQUE PRIMARY KEY REFERENCES users (id) ON DELETE CASCADE, + verification_type VARCHAR(255), + verified_at TIMESTAMP WITH TIME ZONE, + + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + `); +}; + +exports.down = async (pgm) => { + await pgm.db.query(`DROP TABLE users_verification;`); +}; 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/packages/identite/src/types/index.ts b/packages/identite/src/types/index.ts index 1c97d28bb..e0d97d23d 100644 --- a/packages/identite/src/types/index.ts +++ b/packages/identite/src/types/index.ts @@ -1,6 +1,8 @@ // export * from "./contexts.js"; +export * from "./franceconnect.schema.js"; export * from "./organization-info.js"; export * from "./organization.js"; +export * from "./user-verification.js"; export * from "./user.js"; diff --git a/packages/identite/src/types/user-verification.ts b/packages/identite/src/types/user-verification.ts new file mode 100644 index 000000000..f16cef21f --- /dev/null +++ b/packages/identite/src/types/user-verification.ts @@ -0,0 +1,18 @@ +// + +import { z } from "zod"; + +// + +export const UserVerificationTypeSchema = z.enum(["franceconnect"]); +export type UserVerificationType = z.output; + +// + +export interface UserVerification { + readonly created_at: Date; + readonly updated_at: Date; + readonly user_id: number; + verified_at: Date | null; + verification_type: UserVerificationType; +} diff --git a/packages/identite/src/user/get-user-verification-link.test.ts b/packages/identite/src/user/get-user-verification-link.test.ts new file mode 100644 index 000000000..2c9d9f3f9 --- /dev/null +++ b/packages/identite/src/user/get-user-verification-link.test.ts @@ -0,0 +1,61 @@ +// + +import { UserVerificationTypeSchema } from "#src/types"; +import { PGlite } from "@electric-sql/pglite"; +import { expect } from "chai"; +import { noop } from "lodash-es"; +import { before, describe, it } from "mocha"; +import { runner } from "node-pg-migrate"; +import { join } from "path"; +import { getUserVerificationLinkFactory } from "./get-user-verification-link.js"; + +// + +const pg = new PGlite(); +const getUserVerificationLink = getUserVerificationLinkFactory({ + pg: pg as any, +}); + +before(async function migrate() { + await runner({ + dbClient: pg as any, + dir: join(import.meta.dirname, "../../../../migrations"), + direction: "up", + migrationsTable: "pg-migrate", + log: noop, + }); +}); +afterEach(() => pg.sql`delete from users;`); + +describe("getUserVerificationLink", () => { + it.only("should get the link between a user verification data", async () => { + await pg.sql` + INSERT INTO users + (id, email, created_at, updated_at, given_name, family_name, phone_number, job) + VALUES + (1, 'lion.eljonson@darkangels.world', '4444-04-04', '4444-04-04', 'lion', 'el''jonson', 'i', 'primarque'), + (2, 'perturabo@ironwarriors.world', '4444-04-04', '4444-04-04', 'lion', 'el''jonson', 'iv', 'primarque') + ; + `; + await pg.sql` + INSERT INTO users_verification + (user_id, verification_type) + VALUES + (1, 'franceconnect') + ; + `; + + const user = await getUserVerificationLink(1); + + expect(user?.verified_at).to.be.null; + expect(user?.verification_type).to.equal( + UserVerificationTypeSchema.Enum.franceconnect, + ); + }); + + it("❎ fail to get an unknown user", async () => { + const user = await getUserVerificationLink(42); + + expect(user).to.be.undefined; + }); +}); diff --git a/packages/identite/src/user/get-user-verification-link.ts b/packages/identite/src/user/get-user-verification-link.ts new file mode 100644 index 000000000..7443a8029 --- /dev/null +++ b/packages/identite/src/user/get-user-verification-link.ts @@ -0,0 +1,25 @@ +// + +import type { DatabaseContext, UserVerification } from "#src/types"; +import { type QueryResult } from "pg"; + +// + +export function getUserVerificationLinkFactory({ pg }: DatabaseContext) { + return async function upsetUserVerificationLink(user_id: number) { + const { rows }: QueryResult = await pg.query( + ` + SELECT * + FROM users_verification + WHERE user_id = $1 + `, + [user_id], + ); + + return rows.shift(); + }; +} + +export type GetUserVerificationLinkHandler = ReturnType< + typeof getUserVerificationLinkFactory +>; diff --git a/packages/identite/src/user/index.ts b/packages/identite/src/user/index.ts index 466f4bcea..a44e06f0f 100644 --- a/packages/identite/src/user/index.ts +++ b/packages/identite/src/user/index.ts @@ -2,4 +2,6 @@ export * from "./create.js"; export * from "./find-by-email.js"; +export * from "./get-user-verification-link.js"; export * from "./update.js"; +export * from "./upset-user-verification-link.js"; diff --git a/packages/identite/src/user/update.ts b/packages/identite/src/user/update.ts index e1299fbe5..5a1793612 100644 --- a/packages/identite/src/user/update.ts +++ b/packages/identite/src/user/update.ts @@ -18,9 +18,12 @@ export function updateUserFactory({ pg }: DatabaseContext) { ); const { rows }: QueryResult = await pg.query( - `UPDATE users SET ${paramsString} = ${valuesString} WHERE id = $${ - values.length + 1 - } RETURNING *`, + ` + UPDATE users + SET ${paramsString} = ${valuesString} + WHERE id = $${values.length + 1} + RETURNING * + `, [...values, id], ); diff --git a/packages/identite/src/user/upset-user-verification-link.test.ts b/packages/identite/src/user/upset-user-verification-link.test.ts new file mode 100644 index 000000000..85bb9a485 --- /dev/null +++ b/packages/identite/src/user/upset-user-verification-link.test.ts @@ -0,0 +1,97 @@ +// + +import { UserVerificationTypeSchema } from "#src/types"; +import { PGlite } from "@electric-sql/pglite"; +import { expect } from "chai"; +import { noop } from "lodash-es"; +import { before, describe, it } from "mocha"; +import { runner } from "node-pg-migrate"; +import { join } from "path"; +import { upsetUserVerificationLinkFactory } from "./upset-user-verification-link.js"; + +// + +const pg = new PGlite(); +const upsetUserVerificationLink = upsetUserVerificationLinkFactory({ + pg: pg as any, +}); + +describe("upsetUserVerificationLink", () => { + before(async function migrate() { + await runner({ + dbClient: pg as any, + dir: join(import.meta.dirname, "../../../../migrations"), + direction: "up", + migrationsTable: "pg-migrate", + log: noop, + }); + }); + afterEach(() => pg.sql`delete from users;`); + + it("should insert a user Verification link", async () => { + await pg.sql` + INSERT INTO users + (id, email, created_at, updated_at, given_name, family_name, phone_number, job) + VALUES + (1, 'lion.eljonson@darkangels.world', '4444-04-04', '4444-04-04', 'lion', 'el''jonson', 'i', 'primarque'), + (2, 'perturabo@ironwarriors.world', '4444-04-04', '4444-04-04', 'lion', 'el''jonson', 'iv', 'primarque') + ; + `; + + const user = await upsetUserVerificationLink({ + created_at: new Date("4444-04-01"), + updated_at: new Date("4444-04-02"), + verified_at: new Date("4444-04-04"), + user_id: 1, + verification_type: UserVerificationTypeSchema.Enum.franceconnect, + }); + + expect(user.created_at).to.deep.equal(new Date("4444-04-01")); + expect(user.updated_at).to.not.deep.equal(new Date("4444-04-02")); + expect(user.user_id).to.deep.equal(1); + expect(user.verification_type).to.equal( + UserVerificationTypeSchema.Enum.franceconnect, + ); + expect(user.verified_at).to.deep.equal(new Date("4444-04-04")); + }); + + it("should update a user Verification link", async () => { + await pg.sql` + INSERT INTO users + (id, email, created_at, updated_at, given_name, family_name, phone_number, job) + VALUES + (1, 'lion.eljonson@darkangels.world', '4444-04-04', '4444-04-04', 'lion', 'el''jonson', 'i', 'primarque'), + (2, 'perturabo@ironwarriors.world', '4444-04-04', '4444-04-04', 'lion', 'el''jonson', 'iv', 'primarque') + ; + `; + await pg.sql` + INSERT INTO users_verification + (user_id, created_at, updated_at) + VALUES + (1, '4444-04-01', '4444-04-02') + ; + `; + + const user = await upsetUserVerificationLink({ + verified_at: new Date("4444-04-04"), + verification_type: UserVerificationTypeSchema.Enum.franceconnect, + user_id: 1, + }); + + expect(user.created_at).to.deep.equal(new Date("4444-04-01")); + expect(user.updated_at).to.not.deep.equal(new Date("4444-04-02")); + expect(user.user_id).to.deep.equal(1); + expect(user.verified_at).to.deep.equal(new Date("4444-04-04")); + }); + + it("❎ fail to update an unknown user", async () => { + await expect( + upsetUserVerificationLink({ + user_id: 42, + verification_type: UserVerificationTypeSchema.Enum.franceconnect, + }), + ).to.rejectedWith( + `insert or update on table "users_verification" violates foreign key constraint "users_verification_user_id_fkey"`, + ); + }); +}); diff --git a/packages/identite/src/user/upset-user-verification-link.ts b/packages/identite/src/user/upset-user-verification-link.ts new file mode 100644 index 000000000..2bffc6998 --- /dev/null +++ b/packages/identite/src/user/upset-user-verification-link.ts @@ -0,0 +1,41 @@ +// + +import { hashToPostgresParams } from "#src/services"; +import type { DatabaseContext, UserVerification } from "#src/types"; + +// + +export function upsetUserVerificationLinkFactory({ pg }: DatabaseContext) { + return async function upsetUserVerificationLink( + value: Pick & + Partial, + ) { + const fieldsWithTimestamps = { + ...value, + updated_at: new Date(), + }; + + const { paramsString, valuesString, values } = + hashToPostgresParams(fieldsWithTimestamps); + + const { rows } = await pg.query( + ` + INSERT INTO users_verification + ${paramsString} + VALUES + ${valuesString} + ON CONFLICT (user_id) + DO UPDATE + SET ${paramsString} = ${valuesString} + RETURNING * + `, + [...values], + ); + + return rows.shift()!; + }; +} + +export type UpsetUserVerificationLinkHandler = ReturnType< + typeof upsetUserVerificationLinkFactory +>; diff --git a/src/config/env.ts b/src/config/env.ts index fb73ace5f..f52b4c637 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -51,14 +51,21 @@ 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_CONSIDER_ALL_USERS_AS_CERTIFIED, 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, + FRANCECONNECT_VERIFICATION_MAX_AGE_IN_MINUTES, 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..1e86dbd93 100644 --- a/src/config/env.zod.ts +++ b/src/config/env.zod.ts @@ -15,6 +15,39 @@ 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(" "), + ), + FRANCECONNECT_VERIFICATION_MAX_AGE_IN_MINUTES: z.coerce + .number() + .int() + .nonnegative() + .default(3 * 30 * 24 * 60), // 3 months in minutes INSEE_CONSUMER_KEY: z.string(), INSEE_CONSUMER_SECRET: z.string(), REDIS_URL: z.string().url().default("redis://:@127.0.0.1:6379"), @@ -30,6 +63,8 @@ export const featureTogglesEnvSchema = z.object({ zodTrueFalseBoolean().default("False"), FEATURE_CONSIDER_ALL_EMAIL_DOMAINS_AS_NON_FREE: zodTrueFalseBoolean().default("True"), + FEATURE_CONSIDER_ALL_USERS_AS_CERTIFIED: + zodTrueFalseBoolean().default("False"), FEATURE_DISPLAY_TEST_ENV_WARNING: zodTrueFalseBoolean().default("False"), FEATURE_BYPASS_MODERATION: zodTrueFalseBoolean().default("False"), FEATURE_RATE_LIMIT: zodTrueFalseBoolean().default("False"), 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/main.ts b/src/controllers/main.ts index 2533b3082..c1984f7e4 100644 --- a/src/controllers/main.ts +++ b/src/controllers/main.ts @@ -16,6 +16,7 @@ import { } from "../managers/session/dirty-ds-redirect"; import { isAuthenticatorAppConfiguredForUser } from "../managers/totp"; import { + getUserVerificationLabel, sendUpdatePersonalInformationEmail, updatePersonalInformations, } from "../managers/user"; @@ -45,6 +46,8 @@ export const getPersonalInformationsController = async ( ) => { try { const user = getUserFromAuthenticatedSession(req); + const verifiedBy = await getUserVerificationLabel(user.id); + return res.render("personal-information", { pageTitle: "Informations personnelles", email: user.email, @@ -54,6 +57,7 @@ export const getPersonalInformationsController = async ( job: user.job, notifications: await getNotificationsFromRequest(req), csrfToken: csrfToken(req), + verifiedBy, }); } catch (error) { next(error); @@ -68,16 +72,15 @@ export const postPersonalInformationsController = async ( try { const { given_name, family_name, phone_number, job } = await getParamsForPostPersonalInformationsController(req); + const { id: userId } = getUserFromAuthenticatedSession(req); + const verifiedBy = await getUserVerificationLabel(userId); - const updatedUser = await updatePersonalInformations( - getUserFromAuthenticatedSession(req).id, - { - given_name, - family_name, - phone_number, - job, - }, - ); + const updatedUser = await updatePersonalInformations(userId, { + given_name, + family_name, + phone_number, + job, + }); await sendUpdatePersonalInformationEmail({ previousInformations: getUserFromAuthenticatedSession(req), @@ -85,7 +88,6 @@ export const postPersonalInformationsController = async ( }); updateUserInAuthenticatedSession(req, updatedUser); - return res.render("personal-information", { pageTitle: "Informations personnelles", email: updatedUser.email, @@ -97,6 +99,7 @@ export const postPersonalInformationsController = async ( notificationMessages["personal_information_update_success"], ], csrfToken: csrfToken(req), + verifiedBy, }); } catch (error) { if (error instanceof ZodError) { diff --git a/src/controllers/user/certification-dirigeant.ts b/src/controllers/user/certification-dirigeant.ts new file mode 100644 index 000000000..1ea2173a7 --- /dev/null +++ b/src/controllers/user/certification-dirigeant.ts @@ -0,0 +1,171 @@ +// + +import { createChecks } from "@gouvfr-lasuite/proconnect.identite/certification/executive"; +import { UserVerificationTypeSchema } from "@gouvfr-lasuite/proconnect.identite/types"; +import type { NextFunction, Request, Response } from "express"; +import HttpErrors from "http-errors"; +import { z } from "zod"; +import { FRANCECONNECT_CALLBACK_URL } from "../../config/env"; +import { OidcError } from "../../config/errors"; +import { + getFranceConnectRedirectUrl, + getFranceConnectUser, +} from "../../connectors/franceconnect"; +import { getUserFromAuthenticatedSession } from "../../managers/session/authenticated"; +import { FranceConnectOidcSessionSchema } from "../../managers/session/certification"; +import { verifyUserWith } from "../../managers/user"; +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 { family_name, given_name } = await getFranceConnectUser({ + code, + currentUrl: `${FRANCECONNECT_CALLBACK_URL}${req.url.substring(req.path.length)}`, + expectedNonce: nonce, + expectedState: state, + }); + + req.session.franceconnectUserInfo = { family_name, given_name }; + + // 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 { franceconnectUserInfo } = req.session; + if (!franceconnectUserInfo) { + return next(new HttpErrors.Unauthorized()); + } + 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", + ); + } + + const { id: user_id } = getUserFromAuthenticatedSession(req); + + await verifyUserWith( + UserVerificationTypeSchema.Enum.franceconnect, + user_id, + franceconnectUserInfo, + ); + + // ~~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/controllers/user/update-personal-informations.ts b/src/controllers/user/update-personal-informations.ts index 040a65ec0..52e7b3463 100644 --- a/src/controllers/user/update-personal-informations.ts +++ b/src/controllers/user/update-personal-informations.ts @@ -4,7 +4,10 @@ import { getUserFromAuthenticatedSession, updateUserInAuthenticatedSession, } from "../../managers/session/authenticated"; -import { updatePersonalInformations } from "../../managers/user"; +import { + getUserVerificationLabel, + updatePersonalInformations, +} from "../../managers/user"; import { csrfToken } from "../../middlewares/csrf-protection"; import { jobSchema, @@ -20,12 +23,14 @@ export const getPersonalInformationsController = async ( ) => { try { const { - given_name, family_name, - phone_number, + given_name, + id: userId, job, needs_inclusionconnect_onboarding_help, + phone_number, } = getUserFromAuthenticatedSession(req); + const verifiedBy = await getUserVerificationLabel(userId); return res.render("user/personal-information", { pageTitle: "Renseigner votre identité", given_name, @@ -35,11 +40,13 @@ export const getPersonalInformationsController = async ( needs_inclusionconnect_onboarding_help, notifications: await getNotificationsFromRequest(req), csrfToken: csrfToken(req), + verifiedBy, }); } catch (error) { next(error); } }; + export const getParamsForPostPersonalInformationsController = async ( req: Request, ) => { 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..1c6f4337f 100644 --- a/src/managers/session/authenticated.ts +++ b/src/managers/session/authenticated.ts @@ -54,12 +54,15 @@ 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, + franceconnectUserInfo, interactionId, mustReturnOneOrganizationInPayload, - twoFactorsAuthRequested, + nonce, referrerPath, - authForProconnectFederation, - certificationDirigeantRequested, + state, + twoFactorsAuthRequested, } = req.session; // as selected org is not stored in session, @@ -89,6 +92,9 @@ 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.franceconnectUserInfo = franceconnectUserInfo; req.session.amr = addAuthenticationMethodReference( req.session.amr, @@ -140,7 +146,7 @@ export const getUserFromAuthenticatedSession = (req: Request) => { ip_address: req.ip, username: `${req.session.user.given_name} ${req.session.user.family_name}`, }); - return req.session.user; + return req.session.user as User; }; export const updateUserInAuthenticatedSession = (req: Request, user: User) => { diff --git a/src/managers/session/certification.ts b/src/managers/session/certification.ts new file mode 100644 index 000000000..199a8a3ff --- /dev/null +++ b/src/managers/session/certification.ts @@ -0,0 +1,27 @@ +// + +import { FranceConnectUserInfoSchema } from "@gouvfr-lasuite/proconnect.identite/types"; +import { z } from "zod"; + +// + +export const CertificationSessionSchema = z.object({ + certificationDirigeantRequested: z.boolean().default(false), + // isUserCertified: z.boolean().default(false), + franceconnectUserInfo: FranceConnectUserInfoSchema.pick({ + given_name: true, + family_name: true, + }).optional(), +}); +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/managers/user.ts b/src/managers/user.ts index cc3406d48..c2c7faf58 100644 --- a/src/managers/user.ts +++ b/src/managers/user.ts @@ -18,9 +18,15 @@ import { UpdatePersonalDataMail, VerifyEmail, } from "@gouvfr-lasuite/proconnect.email"; -import type { User } from "@gouvfr-lasuite/proconnect.identite/types"; +import { + UserVerificationTypeSchema, + type User, + type UserVerificationType, +} from "@gouvfr-lasuite/proconnect.identite/types"; +import { to } from "await-to-js"; import { isEmpty } from "lodash-es"; import { + FRANCECONNECT_VERIFICATION_MAX_AGE_IN_MINUTES, HOST, MAGIC_LINK_TOKEN_EXPIRATION_DURATION_IN_MINUTES, MAX_DURATION_BETWEEN_TWO_EMAIL_ADDRESS_VERIFICATION_IN_MINUTES, @@ -41,7 +47,6 @@ import { } from "../config/errors"; import { isEmailSafeToSendTransactional } from "../connectors/debounce"; import { sendMail } from "../connectors/mail"; - import { hasPasswordBeenPwned } from "../connectors/pwnedpasswords"; import { create, @@ -49,7 +54,9 @@ import { findById, findByMagicLinkToken, findByResetPasswordToken, + getUserVerificationLink, update, + upsetUserVerificationLink, } from "../repositories/user"; import { isExpired } from "../services/is-expired"; import { isWebauthnConfiguredForUser } from "./webauthn"; @@ -581,10 +588,57 @@ export const updatePersonalInformations = async ( job, }: Pick, ): Promise => { - return await update(userId, { - given_name, - family_name, + const isUserVerified = await getUserVerificationLink(userId); + const names = isUserVerified ? {} : { given_name, family_name }; + + return update(userId, { + ...names, phone_number, job, }); }; + +export async function isUserVerifiedWith( + type: UserVerificationType, + user_id: number, +) { + const userFranceConnect = await getUserVerificationLink(user_id); + + if (isEmpty(userFranceConnect)) { + return false; + } + + const expirationDuration = { + [UserVerificationTypeSchema.Enum.franceconnect]: + FRANCECONNECT_VERIFICATION_MAX_AGE_IN_MINUTES, + }[type]; + + return !isExpired(userFranceConnect.verified_at, expirationDuration); +} + +export async function verifyUserWith( + type: UserVerificationType, + userId: number, + userInfo: { family_name: string; given_name: string }, +) { + await update(userId, userInfo); + await upsetUserVerificationLink({ + user_id: userId, + verification_type: type, + verified_at: new Date(), + }); +} + +export async function getUserVerificationLabel(userId: number) { + const [, { verification_type } = {}] = await to( + getUserVerificationLink(userId), + ); + + if (!verification_type) { + return undefined; + } + + return { + [UserVerificationTypeSchema.Enum.franceconnect]: "FranceConnect", + }[verification_type]; +} diff --git a/src/middlewares/user.ts b/src/middlewares/user.ts index 8a176a19f..186338180 100644 --- a/src/middlewares/user.ts +++ b/src/middlewares/user.ts @@ -1,8 +1,9 @@ import { getTrustedReferrerPath } from "@gouvfr-lasuite/proconnect.core/security"; +import { UserVerificationTypeSchema } from "@gouvfr-lasuite/proconnect.identite/types"; import type { NextFunction, Request, Response } from "express"; import HttpErrors from "http-errors"; import { isEmpty } from "lodash-es"; -import { HOST } from "../config/env"; +import { FEATURE_CONSIDER_ALL_USERS_AS_CERTIFIED, HOST } from "../config/env"; import { UserNotFoundError } from "../config/errors"; import { is2FACapable, shouldForce2faForUser } from "../managers/2fa"; import { isBrowserTrustedForUser } from "../managers/browser-authentication"; @@ -18,11 +19,15 @@ import { isWithinAuthenticatedSession, isWithinTwoFactorAuthenticatedSession, } from "../managers/session/authenticated"; +import { CertificationSessionSchema } from "../managers/session/certification"; import { getEmailFromUnauthenticatedSession, getPartialUserFromUnauthenticatedSession, } from "../managers/session/unauthenticated"; -import { needsEmailVerificationRenewal } from "../managers/user"; +import { + isUserVerifiedWith, + needsEmailVerificationRenewal, +} from "../managers/user"; import { getSelectedOrganizationId } from "../repositories/redis/selected-organization"; import { usesAuthHeaders } from "../services/uses-auth-headers"; @@ -227,12 +232,39 @@ 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); + if (FEATURE_CONSIDER_ALL_USERS_AS_CERTIFIED) return next(); + + const { certificationDirigeantRequested: isRequested } = + await CertificationSessionSchema.parseAsync(req.session); + if (!isRequested) return next(); + + const { id: user_id } = getUserFromAuthenticatedSession(req); + const isVerified = await isUserVerifiedWith( + UserVerificationTypeSchema.Enum.franceconnect, + user_id, + ); + if (isVerified) return next(); + + return res.redirect("/users/certification-dirigeant"); + } 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/repositories/user.ts b/src/repositories/user.ts index 75783d273..ed67ea174 100644 --- a/src/repositories/user.ts +++ b/src/repositories/user.ts @@ -2,7 +2,9 @@ import type { User } from "@gouvfr-lasuite/proconnect.identite/types"; import { createUserFactory, findByEmailFactory, + getUserVerificationLinkFactory, updateUserFactory, + upsetUserVerificationLinkFactory, } from "@gouvfr-lasuite/proconnect.identite/user"; import type { QueryResult } from "pg"; import { getDatabaseConnection } from "../connectors/postgres"; @@ -69,3 +71,11 @@ export const deleteUser = async (id: number) => { return rowCount > 0; }; + +export const getUserVerificationLink = getUserVerificationLinkFactory({ + pg: getDatabaseConnection(), +}); + +export const upsetUserVerificationLink = upsetUserVerificationLinkFactory({ + pg: getDatabaseConnection(), +}); 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/personal-information.ejs b/src/views/personal-information.ejs index d8663395e..9ef31c8f8 100644 --- a/src/views/personal-information.ejs +++ b/src/views/personal-information.ejs @@ -13,28 +13,38 @@
value="<%= given_name; %>" - <% } %> + <% if (given_name) { %> value="<%= given_name; %>" <% } %> + <% if (verifiedBy) { %> readonly="readonly" <% } %> >
value="<%= family_name; %>" - <% } %> + <% if (family_name) { %> value="<%= family_name; %>" <% } %> + <% if (verifiedBy) { %> readonly="readonly" <% } %> >
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/src/views/user/personal-information.ejs b/src/views/user/personal-information.ejs index 16010ad16..c6b68840d 100644 --- a/src/views/user/personal-information.ejs +++ b/src/views/user/personal-information.ejs @@ -23,28 +23,38 @@
value="<%= given_name; %>" - <% } %> + <% if (given_name) { %>value="<%= given_name; %>"<% } %> + <% if (verifiedBy) { %> disabled readonly="readonly" <% } %> >
value="<%= family_name; %>" - <% } %> + <% if (family_name) { %>value="<%= family_name; %>"<% } %> + <% if (verifiedBy) { %> disabled readonly="readonly" <% } %> >
@@ -60,8 +70,7 @@ id="phone_number" pattern="\+?(?:[0-9][ \-]?){6,14}[0-9]" title="un numéro de téléphone doit être écrit en chiffres, avec ou sans extension internationale (ex: France +33)" - <% if (phone_number) { %> value="<%= phone_number; %>" - <% } %> + <% if (phone_number) { %> value="<%= phone_number; %>"<% } %> > @@ -73,8 +82,7 @@ class="fr-input" required type="text" name="job" id="job" pattern="^(?![\d\s]+$).*" title="Merci d’inscrire une profession existante pour que votre compte soit validé" - <% if (job) { %> value="<%= job; %>" - <% } %> + <% if (job) { %> value="<%= job; %>"<% } %> > <%- include('../partials/submit-button.ejs', {label: 'Valider'}) %> 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..aa3cffc34 100644 --- a/test/env.zod.test.ts +++ b/test/env.zod.test.ts @@ -47,16 +47,27 @@ 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_CONSIDER_ALL_USERS_AS_CERTIFIED: false, 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", + ], + FRANCECONNECT_VERIFICATION_MAX_AGE_IN_MINUTES: 129600, HTTP_CLIENT_TIMEOUT: 55000, INSEE_CONSUMER_KEY: "fakesecret", INSEE_CONSUMER_SECRET: "fakesecret", diff --git a/tsconfig.json b/tsconfig.json index 88c76b091..41155ff18 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,10 +10,11 @@ "module": "Preserve", "moduleResolution": "Bundler", "outDir": "./build", - "resolveJsonModule": true + "resolveJsonModule": true, + "types": ["cypress"] }, "exclude": ["packages/*", "build/*", "dist/*"], - "include": ["src"], + "include": ["cypress.config.ts", "cypress/e2e", "cypress/support", "src"], "references": [ { "path": "./packages/core/tsconfig.lib.json" }, { "path": "./packages/email/tsconfig.lib.json" },