Skip to content

Commit

Permalink
WIP: feat: acr can be used to force identity with IAL=2
Browse files Browse the repository at this point in the history
  • Loading branch information
rdubigny committed Oct 18, 2024
1 parent bc4c527 commit 7c54f5c
Show file tree
Hide file tree
Showing 18 changed files with 398 additions and 42 deletions.
1 change: 1 addition & 0 deletions .github/workflows/end-to-end.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ jobs:
- signin_from_standard_client
- signin_with_email_verification_renewal
- signin_with_magic_link
- signin_with_right_acr
- signin_with_totp
- signup_entreprise_unipersonnelle
- update_personal_information
Expand Down
13 changes: 10 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,14 +155,21 @@ d’usurpations d’identités liés aux attaques par _phishing_ par exemple.

Vous pouvez tester la cinématique via le lien suivant : https://test.moncomptepro.beta.gouv.fr/#force-2fa

Pour ce faire, vous devez passer les paramètres `claims={"id_token":{"acr":{"essential":true,value:"https://refeds.org/profile/mfa"}}}` comme suit :
Pour ce faire, vous devez passer les paramètres `claims={"id_token":{"acr":{"essential":true,value:"urn:dinum:ac:classes:consistency-checked-2fa"}}}` comme suit :

https://app-sandbox.moncomptepro.beta.gouv.fr/oauth/authorize?client_id=client_id&scope=openid%20email%20profile%20organization&response_type=code&redirect_uri=https%3A%2F%2Ftest.moncomptepro.beta.gouv.fr%2Flogin-callback&claims=%7B%22id_token%22%3A%7B%22acr%22%3A%7B%22essential%22%3Atrue%2C%22value%22%3A%22https%3A%2F%2Frefeds.org%2Fprofile%2Fmfa%22%7D%7D%7D

Les valeurs `acr` utilisées par ProConnect Identité sont les suivantes :

- `eidas1` authentification simple facteur avec une identité de niveau faible.
- `https://refeds.org/profile/mfa` authentification par double facteur sans preuve d’identité particulière.
- `eidas1` authentification simple facteur avec une identité de niveau faible ;
- `urn:dinum:ac:classes:self-asserted` : identité déclarative ;
- `urn:dinum:ac:classes:self-asserted-2fa` : identité déclarative ;
- `urn:dinum:ac:classes:consistency-checked` : identité déclarative + un des tests de cohérence suivant :
- contrôle du référencement du nom de domaine
- code à usage unique envoyé par courrier postal au siège social
- code à usage unique envoyé par email à l'adresse de contact référencée dans un annuaire de référence
- identité du dirigeant d'association conforme
- `urn:dinum:ac:classes:consistency-checked-2fa` : `urn:dinum:ac:classes:consistency-checked` + authentification à double facteur

## 3. 👋 Contribuer à ProConnect Identité

Expand Down
1 change: 1 addition & 0 deletions cypress/e2e/signin_with_right_acr/env.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DO_NOT_SEND_MAIL="True"
57 changes: 57 additions & 0 deletions cypress/e2e/signin_with_right_acr/fixtures.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
INSERT INTO users
(id, email, email_verified, email_verified_at, encrypted_password, created_at, updated_at,
given_name, family_name, phone_number, job, encrypted_totp_key, totp_key_verified_at, force_2fa)
VALUES
(1, '[email protected]', true, CURRENT_TIMESTAMP,
'$2a$10$kzY3LINL6..50Fy9shWCcuNlRfYq0ft5lS.KCcJ5PzrhlWfKK4NIO', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP,
'Jean', 'IAL2 AAL2', '0123456789', 'Sbire',
'kuOSXGk68H2B3pYnph0uyXAHrmpbWaWyX/iX49xVaUc=.VMPBZSO+eAng7mjS.cI2kRY9rwhXchcKiiaMZIg==',
CURRENT_TIMESTAMP, false
),
(2, '[email protected]', true, CURRENT_TIMESTAMP,
'$2a$10$kzY3LINL6..50Fy9shWCcuNlRfYq0ft5lS.KCcJ5PzrhlWfKK4NIO', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP,
'Jean', 'IAL1 AAL2', '0123456789', 'Sbire',
'kuOSXGk68H2B3pYnph0uyXAHrmpbWaWyX/iX49xVaUc=.VMPBZSO+eAng7mjS.cI2kRY9rwhXchcKiiaMZIg==',
CURRENT_TIMESTAMP, false
),
(3, '[email protected]', true, CURRENT_TIMESTAMP,
'$2a$10$kzY3LINL6..50Fy9shWCcuNlRfYq0ft5lS.KCcJ5PzrhlWfKK4NIO', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP,
'Jean', 'IAL2 AAL1', '0123456789', 'Sbire',
null, null, false),
(4, '[email protected]', true, CURRENT_TIMESTAMP,
'$2a$10$kzY3LINL6..50Fy9shWCcuNlRfYq0ft5lS.KCcJ5PzrhlWfKK4NIO', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP,
'Jean', 'IAL1 AAL1', '0123456789', 'Sbire',
null, null, false);

INSERT INTO organizations
(id, siret, created_at, updated_at)
VALUES
(1, '21340126800130', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);

INSERT INTO users_organizations
(user_id, organization_id, is_external, verification_type, has_been_greeted)
VALUES
(1, 1, false, 'domain', true),
(2, 1, false, null, true),
(3, 1, false, 'domain', true),
(4, 1, false, null, true);

INSERT INTO oidc_clients
(client_name, client_id, client_secret, redirect_uris,
post_logout_redirect_uris, scope, client_uri, client_description,
userinfo_signed_response_alg, id_token_signed_response_alg,
authorization_signed_response_alg, introspection_signed_response_alg)
VALUES
('Oidc Test Client',
'acr_client_id',
'acr_client_secret',
ARRAY [
'http://localhost:4003/login-callback'
],
ARRAY [
'http://localhost:4003/'
],
'openid email profile organization',
'http://localhost:4003/',
'MonComptePro test client. More info: https://github.com/numerique-gouv/moncomptepro-test-client.',
null, 'RS256', null, null);
31 changes: 31 additions & 0 deletions cypress/e2e/signin_with_right_acr/index.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
//

describe("sign-in with a client requiring consistency-checked identity", () => {
it("should sign-in an return the right acr value", function () {
cy.visit("http://localhost:4003");
cy.get("button#force-2fa").click();

cy.login("[email protected]");

cy.contains('"acr": "urn:dinum:ac:classes:consistency-checked"');
});
it("should return an error with ial1", function () {
cy.visit("http://localhost:4003");
cy.get("button#force-2fa").click();

cy.login("[email protected]");

cy.contains("access_denied (none of the requested ACRs could be obtained)");
});

// TODO add tests:
// - log with a client requiring consistency-checked and consistency-checked-mfa
// - with a consistency checked user and MFA => see the right acr returned
// - with a self-asserted user and MFA => see an error
// - log with a client not requiring any acr
// - with a self-asserted user => see acr self-asserted
// - with a consistency checked user => see acr consistency-checked
// - log with acr_values=eidas1 and ENABLE_FIXED_ACR=True
// - with all type of acr => see the right acr
// these tests required the mcp-test-client to be modifiable like fc-mock
});
23 changes: 22 additions & 1 deletion cypress/e2e/signin_with_totp/index.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ describe("sign-in with TOTP on untrusted browser", () => {

cy.login("[email protected]");

cy.contains("Vérifier votre email");
// TODO get browser enrollment code

cy.contains("moncomptepro-standard-client");
});

it("should sign-in with password and TOTP when forced by SP", function () {
Expand All @@ -26,6 +28,25 @@ describe("sign-in with TOTP on untrusted browser", () => {
cy.contains('"amr": [\n "pwd",\n "totp",\n "mfa"\n ],');
});

it("should only show totp step when already logged", function () {
cy.visit("http://localhost:4000");
cy.get("button.proconnect-button").click();

cy.login("[email protected]");

// TODO get browser enrollment code

cy.contains("moncomptepro-standard-client");

cy.get("button#force-2fa").click();

cy.contains("merci de valider votre deuxième étape de connexion");

cy.fillTotpFields();

cy.contains('"amr": [\n "pwd",\n "totp",\n "mfa"\n ],');
});

it("should trigger totp rate limiting", function () {
cy.visit("/users/start-sign-in");

Expand Down
18 changes: 16 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
services:
db:
image: postgres:15.8
image: postgres:14.1
ports:
- "5432:5432"
environment:
Expand All @@ -11,7 +11,7 @@ services:
- db-data:/var/lib/postgresql/data

redis:
image: redis:7.2
image: redis:7.0
ports:
- "6379:6379"

Expand Down Expand Up @@ -67,6 +67,20 @@ services:
STYLESHEET_URL:
network_mode: "host"

moncomptepro-acr-client:
image: ghcr.io/numerique-gouv/moncomptepro-test-client
environment:
PORT: 4003
SITE_TITLE: moncomptepro-acr-client
HOST: http://localhost:4003
MCP_CLIENT_ID: acr_client_id
MCP_CLIENT_SECRET: acr_client_secret
MCP_PROVIDER: http://localhost:3000
MCP_SCOPES: openid email profile organization
ACR_VALUE_FOR_2FA: urn:dinum:ac:classes:consistency-checked
STYLESHEET_URL:
network_mode: "host"

maildev:
image: soulteary/maildev
network_mode: "host"
Expand Down
4 changes: 4 additions & 0 deletions src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ export const {
ACCESS_LOG_PATH,
API_AUTH_PASSWORD,
API_AUTH_USERNAME,
ACR_VALUE_FOR_IAL1_AAL1,
ACR_VALUE_FOR_IAL1_AAL2,
ACR_VALUE_FOR_IAL2_AAL1,
ACR_VALUE_FOR_IAL2_AAL2,
BREVO_API_KEY,
CRISP_BASE_URL,
CRISP_IDENTIFIER,
Expand Down
12 changes: 12 additions & 0 deletions src/config/env.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,18 @@ export const secretEnvSchema = z.object({

export const paramsEnvSchema = z.object({
ACCESS_LOG_PATH: z.string().optional(),
ACR_VALUE_FOR_IAL1_AAL1: z
.string()
.default("urn:dinum:ac:classes:self-asserted"),
ACR_VALUE_FOR_IAL1_AAL2: z
.string()
.default("urn:dinum:ac:classes:self-asserted-2fa"),
ACR_VALUE_FOR_IAL2_AAL1: z
.string()
.default("urn:dinum:ac:classes:consistency-checked"),
ACR_VALUE_FOR_IAL2_AAL2: z
.string()
.default("urn:dinum:ac:classes:consistency-checked-2fa"),
DEPLOY_ENV: z.enum(["preview", "production", "sandbox"]).default("preview"),
DIRTY_DS_REDIRECTION_URL: z
.string()
Expand Down
14 changes: 13 additions & 1 deletion src/config/oidc-provider-configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ import epochTime from "../services/epoch-time";
import { findAccount } from "../services/oidc-account-adapter";
import policy from "../services/oidc-policy";
import { renderWithEjsLayout } from "../services/renderer";
import {
ACR_VALUE_FOR_IAL1_AAL1,
ACR_VALUE_FOR_IAL1_AAL2,
ACR_VALUE_FOR_IAL2_AAL1,
ACR_VALUE_FOR_IAL2_AAL2,
} from "./env";

//

Expand All @@ -13,7 +19,13 @@ export const oidcProviderConfiguration = ({
shortTokenTtlInSeconds = 10 * 60,
tokenTtlInSeconds = 60 * 60,
}): Configuration => ({
acrValues: ["eidas1", "https://refeds.org/profile/mfa"],
acrValues: [
"eidas1",
ACR_VALUE_FOR_IAL1_AAL1,
ACR_VALUE_FOR_IAL1_AAL2,
ACR_VALUE_FOR_IAL2_AAL1,
ACR_VALUE_FOR_IAL2_AAL2,
],
claims: {
amr: null,
// claims definitions can be found here: https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims
Expand Down
89 changes: 73 additions & 16 deletions src/controllers/interaction.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import type { NextFunction, Request, Response } from "express";
import { isEmpty } from "lodash-es";
import Provider, { errors } from "oidc-provider";
import { FEATURE_ALWAYS_RETURN_EIDAS1_FOR_ACR } from "../config/env";
import {
ACR_VALUE_FOR_IAL1_AAL1,
ACR_VALUE_FOR_IAL1_AAL2,
ACR_VALUE_FOR_IAL2_AAL1,
ACR_VALUE_FOR_IAL2_AAL2,
FEATURE_ALWAYS_RETURN_EIDAS1_FOR_ACR,
} from "../config/env";
import {
getSessionStandardizedAuthenticationMethodsReferences,
getUserFromAuthenticatedSession,
Expand All @@ -9,9 +16,15 @@ import {
} from "../managers/session/authenticated";
import { setLoginHintInUnauthenticatedSession } from "../managers/session/unauthenticated";
import { findByClientId } from "../repositories/oidc-client";
import { getUserOrganizationLink } from "../repositories/organization/getters";
import { getSelectedOrganizationId } from "../repositories/redis/selected-organization";
import epochTime from "../services/epoch-time";
import { mustReturnOneOrganizationInPayload } from "../services/must-return-one-organization-in-payload";
import { shouldTrigger2fa } from "../services/should-trigger-2fa";
import {
isAcrSatisfied,
isThereAnyRequestedAcrOtherThanEidas1,
twoFactorsAuthRequested,
} from "../services/should-trigger-2fa";

export const interactionStartControllerFactory =
(oidcProvider: any) =>
Expand All @@ -26,9 +39,7 @@ export const interactionStartControllerFactory =
req.session.interactionId = interactionId;
req.session.mustReturnOneOrganizationInPayload =
mustReturnOneOrganizationInPayload(scope);
if (shouldTrigger2fa(prompt)) {
req.session.mustUse2FA = true;
}
req.session.twoFactorsAuthRequested = twoFactorsAuthRequested(prompt);

const oidcClient = await findByClientId(client_id);
req.session.authForProconnectFederation =
Expand Down Expand Up @@ -74,29 +85,68 @@ export const interactionEndControllerFactory =
try {
const user = getUserFromAuthenticatedSession(req);

const acr =
(!FEATURE_ALWAYS_RETURN_EIDAS1_FOR_ACR &&
isWithinTwoFactorAuthenticatedSession(req)) ||
(FEATURE_ALWAYS_RETURN_EIDAS1_FOR_ACR && req.session.mustUse2FA)
? "https://refeds.org/profile/mfa"
: "eidas1";
let isIdentityChecked = false;

// TODO this check could be made in the user middleware after the user as selected an org
// an error could be thrown and the redirect to sp link could end the interaction in a separate interaction controller
if (req.session.mustReturnOneOrganizationInPayload) {
// TODO move this in a separate function: isIdentityChecked
const selectedOrganizationId = await getSelectedOrganizationId(user.id);

if (selectedOrganizationId === null) {
const error = Error("selectedOrganizationId should be set");

return next(error);
}

const link = await getUserOrganizationLink(
selectedOrganizationId,
user.id,
);

if (isEmpty(link)) {
const error = Error("link should be set");

return next(error);
}

if (link?.verification_type !== null) {
isIdentityChecked = true;
}
}

let currentAcr = isWithinTwoFactorAuthenticatedSession(req)
? isIdentityChecked
? ACR_VALUE_FOR_IAL2_AAL2
: ACR_VALUE_FOR_IAL1_AAL2
: isIdentityChecked
? ACR_VALUE_FOR_IAL2_AAL1
: ACR_VALUE_FOR_IAL1_AAL1;

const amr = getSessionStandardizedAuthenticationMethodsReferences(req);
const ts = user.last_sign_in_at
? epochTime(user.last_sign_in_at)
: undefined;

const result: OidcInteractionResults = {
const { prompt } = await oidcProvider.interactionDetails(req, res);

if (
FEATURE_ALWAYS_RETURN_EIDAS1_FOR_ACR &&
!isThereAnyRequestedAcrOtherThanEidas1(prompt)
) {
currentAcr = "eidas1";
}

let result: OidcInteractionResults = {
login: {
accountId: user.id.toString(),
acr,
acr: currentAcr,
amr,
ts,
},
select_organization: false,
update_userinfo: false,
};

const { prompt } = await oidcProvider.interactionDetails(req, res);
if (prompt.name === "select_organization") {
result.select_organization = true;
}
Expand All @@ -105,9 +155,16 @@ export const interactionEndControllerFactory =
result.update_userinfo = true;
}

if (!isAcrSatisfied(prompt, currentAcr)) {
result = {
error: "access_denied",
error_description: "none of the requested ACRs could be obtained",
};
}

req.session.interactionId = undefined;
req.session.mustReturnOneOrganizationInPayload = undefined;
req.session.mustUse2FA = undefined;
req.session.twoFactorsAuthRequested = undefined;
req.session.authForProconnectFederation = undefined;

await oidcProvider.interactionFinished(req, res, result);
Expand Down
Loading

0 comments on commit 7c54f5c

Please sign in to comment.