Skip to content

Commit

Permalink
feat(user): add certification routes
Browse files Browse the repository at this point in the history
  • Loading branch information
douglasduteil committed Feb 6, 2025
1 parent aad8a75 commit 6d3dc41
Show file tree
Hide file tree
Showing 15 changed files with 437 additions and 4 deletions.
1 change: 1 addition & 0 deletions cypress/e2e/signin_with_certification_dirigeant/env.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DO_NOT_SEND_MAIL="True"
36 changes: 36 additions & 0 deletions cypress/e2e/signin_with_certification_dirigeant/fixtures.sql
Original file line number Diff line number Diff line change
@@ -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, '[email protected]', 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);
40 changes: 40 additions & 0 deletions cypress/e2e/signin_with_certification_dirigeant/index.cy.ts
Original file line number Diff line number Diff line change
@@ -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("[email protected]");

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"',
);
});
});
8 changes: 7 additions & 1 deletion cypress/e2e/signin_with_right_acr/index.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,12 @@ describe("sign-in with a client requiring certification dirigeant identity", ()
cy.get("button#custom-connection").click({ force: true });

cy.login("[email protected]");
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"',
Expand Down Expand Up @@ -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([
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ services:
PC_CLIENT_SECRET: standard_client_secret
PC_PROVIDER: http://localhost:3000
PC_SCOPES: openid email profile organization
ACR_VALUE_FOR_2FA: "https://proconnect.gouv.fr/assurance/consistency-checked-2fa"
ACR_VALUE_FOR_2FA: "https://proconnect.gouv.fr/assurance/certification-dirigeant"
STYLESHEET_URL:
network_mode: "host"

Expand Down
5 changes: 5 additions & 0 deletions src/config/notification-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,11 @@ Si vous avez oublié votre mot de passe cliquez sur « Mot de passe oublié ?
type: "success",
description: "L’application d’authentification a été modifié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 validation en deux étapes a bien été activée.",
Expand Down
189 changes: 189 additions & 0 deletions src/controllers/user/certification-dirigeant.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
//

import type { NextFunction, Request, Response } from "express";
import crypto from "node:crypto";
import { z } from "zod";
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 {
// return res.redirect("/users/certification-dirigeant/login-as");
const url = new URL(
"https://fcp.integ01.dev-franceconnect.fr/api/v1/authorize",
);
url.search = new URLSearchParams({
scope: [
"openid",
"given_name",
"family_name",
"gender",
"preferred_username",
"birthdate",
].join(" "),
redirect_uri: `http://localhost:3000/login-callback`,
response_type: "code",
client_id:
"211286433e39cce01db448d80181bdfd005554b19cd51b3fe7943f6b3b86ab6e",
state: `state${crypto.randomBytes(32).toString("hex")}`,
nonce: `nonce${crypto.randomBytes(32).toString("hex")}`,
}).toString();

return res.redirect(url.toString());
} catch (error) {
next(error);
}
}

//

export async function getCertificationDirigeantLoginAsController(
req: Request,
res: Response,
next: NextFunction,
) {
try {
const body = await z.object({ code: z.string() }).parseAsync(req.query);
const data = new URLSearchParams({
grant_type: "authorization_code",
redirect_uri: `http://localhost:3000/login-callback`,
client_id:
"211286433e39cce01db448d80181bdfd005554b19cd51b3fe7943f6b3b86ab6e",
client_secret:
"2791a731e6a59f56b6b4dd0d08c9b1f593b5f3658b9fd731cb24248e2669af4b",
code: body.code,
}).toString();
const responseToken = await fetch(
"https://fcp.integ01.dev-franceconnect.fr/api/v1/token",
{
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: data,
},
);

const { access_token: accessToken, id_token: _idToken } = await z
.object({
access_token: z.string(),
id_token: z.string(),
})
.parseAsync(await responseToken.json());

const responseUserInfo = await fetch(
"https://fcp.integ01.dev-franceconnect.fr/api/v1/userinfo",
{
method: "GET",
headers: { Authorization: `Bearer ${accessToken}` },
},
);

const { given_name, family_name } = await z
.object({
birthdate: z.string(),
family_name: z.string(),
gender: z.string(),
given_name: z.string(),
sub: z.string(),
})
.parseAsync(await responseUserInfo.json());

// 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);
}
}
5 changes: 5 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());

Expand Down
10 changes: 9 additions & 1 deletion src/managers/session/authenticated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,5 +244,13 @@ export const isIdentityConsistencyChecked = async (req: Request) => {
throw new Error("link should be set");
}

return link?.verification_type !== null;
return [
"code_sent_to_official_contact_email",
"domain",
"imported_from_inclusion_connect",
"imported_from_coop_mediation_numerique",
"in_liste_dirigeants_rna",
"official_contact_email",
"bypassed",
].includes(link?.verification_type ?? "");
};
28 changes: 27 additions & 1 deletion src/middlewares/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,12 +227,38 @@ 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 isRequested = req.session.certificationDirigeantRequested;
const isAlreadyCertified = req.session.__user_certified;

console.log("Loooooooooool");
console.log({ isRequested, isAlreadyCertified });
console.trace();

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);

Expand Down
Loading

0 comments on commit 6d3dc41

Please sign in to comment.