From 115d50c51589bbc3a2f4c67ec3cca0ef0caa9ee5 Mon Sep 17 00:00:00 2001 From: Douglas DUTEIL Date: Thu, 26 Jun 2025 12:30:55 +0200 Subject: [PATCH 1/2] feat: whitelist siret domain pair --- .../join_with_free_email_domain/fixtures.sql | 10 +- .../join_with_free_email_domain/index.cy.ts | 15 +++ package-lock.json | 2 +- .../data/organization/domains-whitelist.ts | 8 ++ .../identite/src/data/organization/index.ts | 3 + .../organization/mark-domain-as-verified.ts | 8 +- .../src/services/organization/index.ts | 3 + .../is-domain-allowed-for-organization.ts | 10 ++ packages/identite/src/types/email-domain.ts | 28 +++-- .../etablissements/11000201100044.json | 100 ++++++++++++++++++ src/config/errors.ts | 10 ++ src/controllers/organization.ts | 40 +++++++ src/managers/organization/join.ts | 10 +- src/routers/user.ts | 8 ++ .../user/access-restricted-to-domains.ejs | 15 +++ 15 files changed, 255 insertions(+), 15 deletions(-) create mode 100644 packages/identite/src/data/organization/domains-whitelist.ts create mode 100644 packages/identite/src/data/organization/index.ts create mode 100644 packages/identite/src/services/organization/is-domain-allowed-for-organization.ts create mode 100644 packages/testing/src/api/routes/entreprise.api.gouv.fr/etablissements/11000201100044.json create mode 100644 src/views/user/access-restricted-to-domains.ejs diff --git a/cypress/e2e/join_with_free_email_domain/fixtures.sql b/cypress/e2e/join_with_free_email_domain/fixtures.sql index d799300fc..581116f42 100644 --- a/cypress/e2e/join_with_free_email_domain/fixtures.sql +++ b/cypress/e2e/join_with_free_email_domain/fixtures.sql @@ -7,9 +7,17 @@ VALUES INSERT INTO organizations (id, siret, created_at, updated_at) VALUES - (1, '19750663700010', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP); + (1, '19750663700010', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (2, '11000201100044', 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 email_domains + (organization_id, domain, verification_type, verified_at) +VALUES + (2, 'finances.gouv.fr', 'verified', CURRENT_TIMESTAMP), + (2, 'ext.finances.gouv.fr', 'external', CURRENT_TIMESTAMP) +; diff --git a/cypress/e2e/join_with_free_email_domain/index.cy.ts b/cypress/e2e/join_with_free_email_domain/index.cy.ts index 01792238f..a48214493 100644 --- a/cypress/e2e/join_with_free_email_domain/index.cy.ts +++ b/cypress/e2e/join_with_free_email_domain/index.cy.ts @@ -154,4 +154,19 @@ describe("restrict access for", () => { "L’accès à ce site est limité aux agentes et agents possédant une adresse email d’une administration publique.", ); }); + + it("ChorusPro", function () { + cy.focused().clear().type("11000201100044"); + + cy.contains("Enregistrer").click(); + + cy.title().should("include", "Domains restreintes dans l'organisation -"); + cy.contains("Accès restreint"); + cy.contains( + "Seules les adresses finances.gouv.fr peuvent rejoindre l’organisation « Services de l'etat pour la facturation electronique - Destination etat via chorus pro ».", + ); + cy.contains( + "Soyez sûrs d’utiliser le SIRET de l’organisation pour laquelle vous travaillez.", + ); + }); }); diff --git a/package-lock.json b/package-lock.json index 1f234f3f7..fe034a6a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,8 +15,8 @@ "packages/devtools/typescript", "packages/email", "packages/entreprise", - "packages/identite", "packages/insee", + "packages/identite", "packages/testing" ], "dependencies": { diff --git a/packages/identite/src/data/organization/domains-whitelist.ts b/packages/identite/src/data/organization/domains-whitelist.ts new file mode 100644 index 000000000..f37482a4b --- /dev/null +++ b/packages/identite/src/data/organization/domains-whitelist.ts @@ -0,0 +1,8 @@ +// + +export const DOMAINS_WHITELIST = new Map([ + [ + "11000201100044", // ChorusPro siret + ["finances.gouv.fr"], // domains to whitelist + ], +]); diff --git a/packages/identite/src/data/organization/index.ts b/packages/identite/src/data/organization/index.ts new file mode 100644 index 000000000..f8318a46f --- /dev/null +++ b/packages/identite/src/data/organization/index.ts @@ -0,0 +1,3 @@ +// + +export * from "./domains-whitelist.js"; diff --git a/packages/identite/src/managers/organization/mark-domain-as-verified.ts b/packages/identite/src/managers/organization/mark-domain-as-verified.ts index 90a65e61f..a9b26106d 100644 --- a/packages/identite/src/managers/organization/mark-domain-as-verified.ts +++ b/packages/identite/src/managers/organization/mark-domain-as-verified.ts @@ -58,7 +58,7 @@ export function markDomainAsVerifiedFactory({ return match(domain_verification_type) .with( - ...EMAIL_DOMAIN_APPROVED_VERIFICATION_TYPES, + ...EMAIL_DOMAIN_APPROVED_VERIFICATION_TYPES.options, async (approved_verification_type) => { await assignUserVerificationTypeToDomain(organization_id, domain); return markDomainAsApproved({ @@ -69,7 +69,7 @@ export function markDomainAsVerifiedFactory({ }, ) .with( - ...EMAIL_DOMAIN_REJECTED_VERIFICATION_TYPES, + ...EMAIL_DOMAIN_REJECTED_VERIFICATION_TYPES.options, (rejected_verification_type) => markDomainAsRejected({ organization_id, @@ -93,7 +93,7 @@ export function markDomainAsVerifiedFactory({ organization_id, domain, domain_verification_types: [ - ...EMAIL_DOMAIN_APPROVED_VERIFICATION_TYPES, + ...EMAIL_DOMAIN_APPROVED_VERIFICATION_TYPES.options, null, ], }); @@ -118,7 +118,7 @@ export function markDomainAsVerifiedFactory({ domain, domain_verification_types: [ null, - ...EMAIL_DOMAIN_REJECTED_VERIFICATION_TYPES, + ...EMAIL_DOMAIN_REJECTED_VERIFICATION_TYPES.options, ], }); return addDomain({ diff --git a/packages/identite/src/services/organization/index.ts b/packages/identite/src/services/organization/index.ts index bfe894df8..8722fb400 100644 --- a/packages/identite/src/services/organization/index.ts +++ b/packages/identite/src/services/organization/index.ts @@ -1 +1,4 @@ +// + +export * from "./is-domain-allowed-for-organization.js"; export * from "./is-entreprise-unipersonnelle.js"; diff --git a/packages/identite/src/services/organization/is-domain-allowed-for-organization.ts b/packages/identite/src/services/organization/is-domain-allowed-for-organization.ts new file mode 100644 index 000000000..f36faf589 --- /dev/null +++ b/packages/identite/src/services/organization/is-domain-allowed-for-organization.ts @@ -0,0 +1,10 @@ +import { DOMAINS_WHITELIST } from "@gouvfr-lasuite/proconnect.identite/data/organization"; + +export function isDomainAllowedForOrganization(siret: string, domain: string) { + const whitelist = DOMAINS_WHITELIST.get(siret); + + // Allow unknown siret to ignore whitelisting + if (!whitelist) return true; + + return whitelist.includes(domain); +} diff --git a/packages/identite/src/types/email-domain.ts b/packages/identite/src/types/email-domain.ts index b9da2034d..637d0fbc8 100644 --- a/packages/identite/src/types/email-domain.ts +++ b/packages/identite/src/types/email-domain.ts @@ -1,20 +1,32 @@ -export const EMAIL_DOMAIN_APPROVED_VERIFICATION_TYPES = [ +// + +import { z } from "zod"; + +// + +export const EMAIL_DOMAIN_APPROVED_VERIFICATION_TYPES = z.enum([ "official_contact", "trackdechets_postal_mail", "verified", -] as const; +]); + +export type EmailDomainApprovedVerificationType = z.output< + typeof EMAIL_DOMAIN_APPROVED_VERIFICATION_TYPES +>; + +// -export const EMAIL_DOMAIN_REJECTED_VERIFICATION_TYPES = [ +export const EMAIL_DOMAIN_REJECTED_VERIFICATION_TYPES = z.enum([ "blacklisted", // unused "external", // domain used by external employees (eg. ext.numerique.gouv.fr) "refused", -] as const; +]); -export type EmailDomainApprovedVerificationType = - (typeof EMAIL_DOMAIN_APPROVED_VERIFICATION_TYPES)[number]; +export type EmailDomainRejectedVerificationType = z.output< + typeof EMAIL_DOMAIN_REJECTED_VERIFICATION_TYPES +>; -export type EmailDomainRejectedVerificationType = - (typeof EMAIL_DOMAIN_REJECTED_VERIFICATION_TYPES)[number]; +// export type EmailDomainVerificationType = | EmailDomainApprovedVerificationType diff --git a/packages/testing/src/api/routes/entreprise.api.gouv.fr/etablissements/11000201100044.json b/packages/testing/src/api/routes/entreprise.api.gouv.fr/etablissements/11000201100044.json new file mode 100644 index 000000000..c85a66c44 --- /dev/null +++ b/packages/testing/src/api/routes/entreprise.api.gouv.fr/etablissements/11000201100044.json @@ -0,0 +1,100 @@ +{ + "data": { + "siret": "11000201100044", + "siege_social": true, + "etat_administratif": "A", + "date_fermeture": null, + "enseigne": "DESTINATION ETAT VIA CHORUS PRO", + "activite_principale": { + "code": "84.11Z", + "nomenclature": "NAFRev2", + "libelle": "Administration publique générale" + }, + "tranche_effectif_salarie": { + "de": 50, + "a": 99, + "code": "21", + "date_reference": "2022", + "intitule": "50 à 99 salariés" + }, + "diffusable_commercialement": true, + "status_diffusion": "diffusible", + "date_creation": 815785200, + "unite_legale": { + "siren": "110002011", + "rna": null, + "siret_siege_social": "11000201100044", + "type": "personne_morale", + "personne_morale_attributs": { + "raison_sociale": "SERVICES DE L'ETAT POUR LA FACTURATION ELECTRONIQUE", + "sigle": null + }, + "personne_physique_attributs": { + "pseudonyme": null, + "prenom_usuel": null, + "prenom_1": null, + "prenom_2": null, + "prenom_3": null, + "prenom_4": null, + "nom_usage": null, + "nom_naissance": null, + "sexe": null + }, + "categorie_entreprise": "PME", + "status_diffusion": "diffusible", + "diffusable_commercialement": true, + "forme_juridique": { + "code": "7120", + "libelle": "Service central d'un ministère" + }, + "activite_principale": { + "code": "84.11Z", + "nomenclature": "NAFRev2", + "libelle": "Administration publique générale" + }, + "tranche_effectif_salarie": { + "de": 50, + "a": 99, + "code": "21", + "date_reference": "2022", + "intitule": "50 à 99 salariés" + }, + "economie_sociale_et_solidaire": false, + "date_creation": 362095200, + "etat_administratif": "A" + }, + "adresse": { + "status_diffusion": "diffusible", + "complement_adresse": null, + "numero_voie": "139", + "indice_repetition_voie": null, + "type_voie": "RUE", + "libelle_voie": "DE BERCY", + "code_postal": "75012", + "libelle_commune": "PARIS", + "libelle_commune_etranger": null, + "distribution_speciale": null, + "code_commune": "75112", + "code_cedex": null, + "libelle_cedex": null, + "code_pays_etranger": null, + "libelle_pays_etranger": null, + "acheminement_postal": { + "l1": "SERVICES DE L'ETAT POUR LA FACTURATION ELECTRONIQUE", + "l2": "", + "l3": "", + "l4": "139 RUE DE BERCY", + "l5": "", + "l6": "75012 PARIS", + "l7": "FRANCE" + } + } + }, + "links": { + "unite_legale": "https://entreprise.api.gouv.fr/v3/insee/sirene/unites_legales/110002011" + }, + "meta": { + "date_derniere_mise_a_jour": 1737241200, + "redirect_from_siret": null + } +} diff --git a/src/config/errors.ts b/src/config/errors.ts index 45e8c34d1..5839a1b65 100644 --- a/src/config/errors.ts +++ b/src/config/errors.ts @@ -37,6 +37,16 @@ export class AccessRestrictedToPublicServiceEmailError extends Error { } } +export class DomainRestrictedError extends Error { + constructor( + public organizationId: number, + options?: ErrorOptions, + ) { + super("", options); + this.name = "DomainRestrictedError"; + } +} + export class InvalidCredentialsError extends Error {} export class EmailUnavailableError extends Error {} diff --git a/src/controllers/organization.ts b/src/controllers/organization.ts index d757afa5b..00caa5dff 100644 --- a/src/controllers/organization.ts +++ b/src/controllers/organization.ts @@ -1,5 +1,6 @@ import { getEmailDomain } from "@gouvfr-lasuite/proconnect.core/services/email"; import { EntrepriseApiError } from "@gouvfr-lasuite/proconnect.entreprise/types"; +import { DOMAINS_WHITELIST } from "@gouvfr-lasuite/proconnect.identite/data/organization"; import { InvalidCertificationError, InvalidSiretError, @@ -12,6 +13,7 @@ import { isEmpty } from "lodash-es"; import { z, ZodError } from "zod"; import { AccessRestrictedToPublicServiceEmailError, + DomainRestrictedError, UnableToAutoJoinOrganizationError, UserAlreadyAskedToJoinOrganizationError, UserInOrganizationAlreadyError, @@ -144,6 +146,12 @@ export const postJoinOrganizationMiddleware = async ( return res.redirect(`/users/access-restricted-to-public-sector-email`); } + if (error instanceof DomainRestrictedError) { + return res.redirect( + `/users/domains-restricted-in-organization?organization_id=${error.organizationId}`, + ); + } + if ( error instanceof InvalidSiretError || error instanceof OrganizationNotActiveError || @@ -176,6 +184,38 @@ export const postJoinOrganizationMiddleware = async ( } }; +export const getDomainsRestrictedInOrganizationController = async ( + req: Request, + res: Response, + next: NextFunction, +) => { + try { + const schema = z.object({ + organization_id: idSchema(), + }); + + const { organization_id } = await schema.parseAsync(req.query); + + const organization = await getOrganizationById(organization_id); + if (isEmpty(organization)) { + return next(new HttpErrors.NotFound()); + } + const whitelist = DOMAINS_WHITELIST.get(organization.siret); + if (!whitelist) { + return next(new HttpErrors.NotFound()); + } + + return res.render("user/access-restricted-to-domains", { + pageTitle: "Domains restreintes dans l'organisation", + csrfToken: csrfToken(req), + organization_label: organization.cached_libelle, + organization_domains: whitelist.join(", "), + }); + } catch (error) { + next(error); + } +}; + export const getJoinOrganizationConfirmController = async ( req: Request, res: Response, diff --git a/src/managers/organization/join.ts b/src/managers/organization/join.ts index ac6eb5b8c..076c57e5c 100644 --- a/src/managers/organization/join.ts +++ b/src/managers/organization/join.ts @@ -9,7 +9,10 @@ import { OrganizationNotFoundError, } from "@gouvfr-lasuite/proconnect.identite/errors"; import { forceJoinOrganizationFactory } from "@gouvfr-lasuite/proconnect.identite/managers/organization"; -import { isEntrepriseUnipersonnelle } from "@gouvfr-lasuite/proconnect.identite/services/organization"; +import { + isDomainAllowedForOrganization, + isEntrepriseUnipersonnelle, +} from "@gouvfr-lasuite/proconnect.identite/services/organization"; import type { Organization, OrganizationInfo, @@ -26,6 +29,7 @@ import { } from "../../config/env"; import { AccessRestrictedToPublicServiceEmailError, + DomainRestrictedError, UnableToAutoJoinOrganizationError, UserAlreadyAskedToJoinOrganizationError, UserInOrganizationAlreadyError, @@ -176,6 +180,10 @@ export const joinOrganization = async ({ const organizationEmailDomains = await findEmailDomainsByOrganizationId(organization_id); + if (!isDomainAllowedForOrganization(siret, domain)) { + throw new DomainRestrictedError(organization_id); + } + if (certificationRequested) { const isDirigeant = await isOrganizationDirigeant(siret, user_id); diff --git a/src/routers/user.ts b/src/routers/user.ts index 978d03a17..d31931100 100644 --- a/src/routers/user.ts +++ b/src/routers/user.ts @@ -3,6 +3,7 @@ import nocache from "nocache"; import { HOST } from "../config/env"; import { getAccessRestrictedToPublicSectorEmailController, + getDomainsRestrictedInOrganizationController, getJoinOrganizationConfirmController, getJoinOrganizationController, getOrganizationSuggestionsController, @@ -402,6 +403,13 @@ export const userRouter = () => { getJoinOrganizationConfirmController, ); + userRouter.get( + "/domains-restricted-in-organization", + checkUserHasPersonalInformationsMiddleware, + csrfProtectionMiddleware, + getDomainsRestrictedInOrganizationController, + ); + userRouter.get( "/unable-to-auto-join-organization", checkUserHasPersonalInformationsMiddleware, diff --git a/src/views/user/access-restricted-to-domains.ejs b/src/views/user/access-restricted-to-domains.ejs new file mode 100644 index 000000000..07dce5380 --- /dev/null +++ b/src/views/user/access-restricted-to-domains.ejs @@ -0,0 +1,15 @@ +
+

Accès restreint

+
+

+ Seules les adresses <%= organization_domains; %> peuvent rejoindre l’organisation « <%= organization_label; %> ». + Soyez sûrs d’utiliser le SIRET de l’organisation pour laquelle vous travaillez. +

+
+ +

+ Vous pouvez également nous signaler cette erreur par mail à + support+identite@proconnect.gouv.fr. +

+ Retour à l’accueil +
From 7a27c4e655172381e32bf077d701b813627cdbd7 Mon Sep 17 00:00:00 2001 From: Douglas DUTEIL Date: Tue, 1 Jul 2025 12:20:47 +0200 Subject: [PATCH 2/2] chore(error): little homogenization --- src/config/errors.ts | 47 ++++++++++++++++++--------- src/controllers/interaction.ts | 8 +++++ src/controllers/user/franceconnect.ts | 4 ++- src/managers/organization/join.ts | 9 ++++- src/managers/user.ts | 9 ++++- 5 files changed, 58 insertions(+), 19 deletions(-) diff --git a/src/config/errors.ts b/src/config/errors.ts index 5839a1b65..a6b8b5865 100644 --- a/src/config/errors.ts +++ b/src/config/errors.ts @@ -1,32 +1,47 @@ export class InvalidEmailError extends Error { - constructor(public didYouMean: string) { - super(); - this.didYouMean = didYouMean; + constructor( + public didYouMean: string, + options?: ErrorOptions, + ) { + super(`Did you mean "${didYouMean}" ?`, options); + this.name = "InvalidEmailError"; } } export class ForbiddenError extends Error {} export class UnableToAutoJoinOrganizationError extends Error { - constructor(public moderationId: number) { - super(); - this.moderationId = moderationId; + constructor( + public moderationId: number, + options?: ErrorOptions, + ) { + super(`Linked to moderation ${moderationId}`, options); + this.name = "UnableToAutoJoinOrganizationError"; } } export class UserInOrganizationAlreadyError extends Error {} export class UserAlreadyAskedToJoinOrganizationError extends Error { - constructor(public moderationId: number) { - super(); - this.moderationId = moderationId; + constructor( + public moderationId: number, + options: ErrorOptions, + ) { + super( + `Moderation ${moderationId} already asked to join organization`, + options, + ); + this.name = "UserAlreadyAskedToJoinOrganizationError"; } } export class UserMustConfirmToJoinOrganizationError extends Error { - constructor(public organizationId: number) { - super(); - this.organizationId = organizationId; + constructor( + public organizationId: number, + options?: ErrorOptions, + ) { + super(`Organization ${organizationId} confirmation is required`, options); + this.name = "UserMustConfirmToJoinOrganizationError"; } } @@ -42,7 +57,7 @@ export class DomainRestrictedError extends Error { public organizationId: number, options?: ErrorOptions, ) { - super("", options); + super(`Organization ${organizationId} is domain restricted`, options); this.name = "DomainRestrictedError"; } } @@ -109,10 +124,10 @@ export class OidcError extends Error { constructor( public error: string, public error_description?: string, + options?: ErrorOptions, ) { - super(); - this.error = error; - this.error_description = error_description; + super(`${error}: ${error_description}`, options); + this.name = "OidcError"; } } diff --git a/src/controllers/interaction.ts b/src/controllers/interaction.ts index 92304c901..807b4e7d0 100644 --- a/src/controllers/interaction.ts +++ b/src/controllers/interaction.ts @@ -1,4 +1,5 @@ import type { NextFunction, Request, Response } from "express"; +import { AssertionError } from "node:assert"; import Provider, { errors } from "oidc-provider"; import { z } from "zod"; import { FEATURE_ALWAYS_RETURN_EIDAS1_FOR_ACR } from "../config/env"; @@ -131,6 +132,13 @@ export const interactionEndControllerFactory = new OidcError( "access_denied", "none of the requested ACRs could be obtained", + { + cause: new AssertionError({ + expected: prompt, + actual: currentAcr, + operator: "isAcrSatisfied", + }), + }, ), ); } diff --git a/src/controllers/user/franceconnect.ts b/src/controllers/user/franceconnect.ts index 4ae3c624b..f889505c0 100644 --- a/src/controllers/user/franceconnect.ts +++ b/src/controllers/user/franceconnect.ts @@ -40,7 +40,9 @@ export async function getFranceConnectLoginCallbackMiddleware( if (errorQuery.success) { const { error, error_description } = errorQuery.data; - throw new OidcError(error, error_description); + throw new OidcError(error, error_description, { + cause: errorQuery.error, + }); } const { code } = await z.object({ code: z.string() }).parseAsync(req.query); diff --git a/src/managers/organization/join.ts b/src/managers/organization/join.ts index 076c57e5c..82c39509d 100644 --- a/src/managers/organization/join.ts +++ b/src/managers/organization/join.ts @@ -20,6 +20,7 @@ import type { } from "@gouvfr-lasuite/proconnect.identite/types"; import * as Sentry from "@sentry/node"; import { isEmpty, some } from "lodash-es"; +import { AssertionError } from "node:assert"; import { inspect } from "node:util"; import { CRISP_WEBSITE_ID, @@ -171,7 +172,13 @@ export const joinOrganization = async ({ }); if (!isEmpty(pendingModeration)) { const { id: moderation_id } = pendingModeration; - throw new UserAlreadyAskedToJoinOrganizationError(moderation_id); + throw new UserAlreadyAskedToJoinOrganizationError(moderation_id, { + cause: new AssertionError({ + expected: undefined, + actual: pendingModeration, + operator: "findPendingModeration", + }), + }); } const { id: organization_id, cached_libelle } = organization; diff --git a/src/managers/user.ts b/src/managers/user.ts index 8f06a6ca3..f61f3c917 100644 --- a/src/managers/user.ts +++ b/src/managers/user.ts @@ -28,6 +28,7 @@ import { } from "@gouvfr-lasuite/proconnect.identite/types"; import { to } from "await-to-js"; import { isEmpty } from "lodash-es"; +import { AssertionError } from "node:assert"; import { FRANCECONNECT_VERIFICATION_MAX_AGE_IN_MINUTES, HOST, @@ -93,7 +94,13 @@ export const startLogin = async ( didYouMean = getDidYouMeanSuggestion(email); } - throw new InvalidEmailError(didYouMean); + throw new InvalidEmailError(didYouMean, { + cause: new AssertionError({ + actual: email, + expected: didYouMean, + operator: "isEmailSafeToSendTransactional", + }), + }); } return {