diff --git a/cypress/e2e/activate_totp/fixtures.sql b/cypress/e2e/activate_totp/fixtures.sql index 572ae188..b0014f48 100644 --- a/cypress/e2e/activate_totp/fixtures.sql +++ b/cypress/e2e/activate_totp/fixtures.sql @@ -1,7 +1,8 @@ INSERT INTO users (id, email, email_verified, email_verified_at, encrypted_password, created_at, updated_at, given_name, family_name, phone_number, job, force_2fa) VALUES - (1, 'lion.eljonson@darkangels.world', true, CURRENT_TIMESTAMP, '$2a$10$kzY3LINL6..50Fy9shWCcuNlRfYq0ft5lS.KCcJ5PzrhlWfKK4NIO', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'Lion', 'El''Jonson', 'I', 'Primarque', false); + (1, 'lion.eljonson@darkangels.world', true, CURRENT_TIMESTAMP, '$2a$10$kzY3LINL6..50Fy9shWCcuNlRfYq0ft5lS.KCcJ5PzrhlWfKK4NIO', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'Lion', 'El''Jonson', 'I', 'Primarque', false), + (2, 'unused1@yopmail.com', true, CURRENT_TIMESTAMP, '$2a$10$kzY3LINL6..50Fy9shWCcuNlRfYq0ft5lS.KCcJ5PzrhlWfKK4NIO', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'Raphapha', 'Dubibi', '0123456789', 'Sbire', false); INSERT INTO organizations (id, siret, created_at, updated_at) @@ -11,4 +12,5 @@ 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); diff --git a/cypress/e2e/activate_totp/index.cy.ts b/cypress/e2e/activate_totp/index.cy.ts index 577a4266..346d6a1f 100644 --- a/cypress/e2e/activate_totp/index.cy.ts +++ b/cypress/e2e/activate_totp/index.cy.ts @@ -12,6 +12,8 @@ describe("add 2fa authentication", () => { .contains("Configurer un code à usage unique") .click(); + cy.contains("Configurer une application d’authentification"); + // Extract the code from the front to generate the TOTP key cy.get("#humanReadableTotpKey") .invoke("text") @@ -36,4 +38,21 @@ describe("add 2fa authentication", () => { }, ); }); + + it("should see an help link on third failed attempt", function () { + cy.visit("/connection-and-account"); + + cy.login("unused1@yopmail.com"); + + cy.get('[href="/authenticator-app-configuration"]') + .contains("Configurer un code à usage unique") + .click(); + + cy.get("[name=totpToken]").type("123456"); + cy.get( + '[action="/authenticator-app-configuration"] [type="submit"]', + ).click(); + + cy.contains("Code invalide."); + }); }); diff --git a/cypress/e2e/signin_with_totp/fixtures.sql b/cypress/e2e/signin_with_totp/fixtures.sql index 6228c5f5..61e089e3 100644 --- a/cypress/e2e/signin_with_totp/fixtures.sql +++ b/cypress/e2e/signin_with_totp/fixtures.sql @@ -19,6 +19,12 @@ VALUES 'Jean', 'Jean', '0123456789', 'Sbire', 'kuOSXGk68H2B3pYnph0uyXAHrmpbWaWyX/iX49xVaUc=.VMPBZSO+eAng7mjS.cI2kRY9rwhXchcKiiaMZIg==', CURRENT_TIMESTAMP, true + ), + (4, 'unused4@yopmail.com', true, CURRENT_TIMESTAMP, + '$2a$10$kzY3LINL6..50Fy9shWCcuNlRfYq0ft5lS.KCcJ5PzrhlWfKK4NIO', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, + 'Jean', 'Jean', '0123456789', 'Sbire', + 'kuOSXGk68H2B3pYnph0uyXAHrmpbWaWyX/iX49xVaUc=.VMPBZSO+eAng7mjS.cI2kRY9rwhXchcKiiaMZIg==', + CURRENT_TIMESTAMP, true ); INSERT INTO organizations @@ -30,7 +36,9 @@ INSERT INTO users_organizations (user_id, organization_id, is_external, verification_type, has_been_greeted) VALUES (1, 1, false, 'domain', true), - (2, 1, false, 'domain', true); + (2, 1, false, 'domain', true), + (3, 1, false, 'domain', true), + (4, 1, false, 'domain', true); INSERT INTO oidc_clients (client_name, client_id, client_secret, redirect_uris, diff --git a/cypress/e2e/signin_with_totp/index.cy.ts b/cypress/e2e/signin_with_totp/index.cy.ts index 839ae94d..1e086a53 100644 --- a/cypress/e2e/signin_with_totp/index.cy.ts +++ b/cypress/e2e/signin_with_totp/index.cy.ts @@ -71,17 +71,28 @@ describe("sign-in with TOTP on untrusted browser", () => { cy.contains('"amr": [\n "pwd",\n "totp",\n "mfa"\n ],'); }); - it("should trigger totp rate limiting", function () { + it("should display error message", function () { cy.visit("/users/start-sign-in"); cy.login("unused3@yopmail.com"); + cy.get("[name=totpToken]").type("123456"); + cy.get( + '[action="/users/2fa-sign-in-with-authenticator-app"] [type="submit"]', + ).click(); + cy.contains("Code invalide."); + }); + + it("should trigger totp rate limiting", function () { + cy.visit("/users/start-sign-in"); + + cy.login("unused4@yopmail.com"); + for (let i = 0; i < 5; i++) { cy.get("[name=totpToken]").type("123456"); cy.get( '[action="/users/2fa-sign-in-with-authenticator-app"] [type="submit"]', ).click(); - cy.contains("le code que vous avez utilisé est invalide."); } cy.get("[name=totpToken]").type("123456"); diff --git a/src/controllers/totp.ts b/src/controllers/totp.ts index e8e313ff..b116cc9e 100644 --- a/src/controllers/totp.ts +++ b/src/controllers/totp.ts @@ -25,7 +25,9 @@ import { } from "../managers/user"; import { csrfToken } from "../middlewares/csrf-protection"; import { codeSchema } from "../services/custom-zod-schemas"; -import getNotificationsFromRequest from "../services/get-notifications-from-request"; +import getNotificationsFromRequest, { + getNotificationLabelFromRequest, +} from "../services/get-notifications-from-request"; export const getAuthenticatorAppConfigurationController = async ( req: Request, @@ -45,9 +47,13 @@ export const getAuthenticatorAppConfigurationController = async ( setTemporaryTotpKey(req, totpKey); + const notificationLabel = await getNotificationLabelFromRequest(req); + const hasCodeError = notificationLabel === "invalid_totp_token"; + return res.render("authenticator-app-configuration", { pageTitle: "Configuration TOTP", notifications: await getNotificationsFromRequest(req), + hasCodeError, csrfToken: csrfToken(req), isAuthenticatorAlreadyConfigured: await isAuthenticatorAppConfiguredForUser(user_id), diff --git a/src/controllers/user/2fa-sign-in.ts b/src/controllers/user/2fa-sign-in.ts index 986575f3..7225b080 100644 --- a/src/controllers/user/2fa-sign-in.ts +++ b/src/controllers/user/2fa-sign-in.ts @@ -6,7 +6,9 @@ import { import { isAuthenticatorAppConfiguredForUser } from "../../managers/totp"; import { isWebauthnConfiguredForUser } from "../../managers/webauthn"; import { csrfToken } from "../../middlewares/csrf-protection"; -import getNotificationsFromRequest from "../../services/get-notifications-from-request"; +import getNotificationsFromRequest, { + getNotificationLabelFromRequest, +} from "../../services/get-notifications-from-request"; export const get2faSignInController = async ( req: Request, @@ -17,6 +19,14 @@ export const get2faSignInController = async ( const { id, email } = getUserFromAuthenticatedSession(req); const showsTotpSection = await isAuthenticatorAppConfiguredForUser(id); + let hasCodeError = false; + if (showsTotpSection) { + const notificationLabel = await getNotificationLabelFromRequest(req); + hasCodeError = notificationLabel === "invalid_totp_token"; + } + const notifications = hasCodeError + ? [] + : await getNotificationsFromRequest(req); // If a passkey has already been used for authentication in this session, // we cannot use another passkey, or even the same one, for a second factor. @@ -28,7 +38,8 @@ export const get2faSignInController = async ( return res.render("user/2fa-sign-in", { pageTitle: "Se connecter en deux étapes", - notifications: await getNotificationsFromRequest(req), + notifications, + hasCodeError, csrfToken: csrfToken(req), email, showsTotpSection, diff --git a/src/managers/totp.ts b/src/managers/totp.ts index ca6f0bd4..48b6c852 100644 --- a/src/managers/totp.ts +++ b/src/managers/totp.ts @@ -57,7 +57,7 @@ export const confirmAuthenticatorAppRegistration = async ( throw new UserNotFoundError(); } - if (!temporaryTotpKey || !validateToken(temporaryTotpKey, totpToken)) { + if (!temporaryTotpKey || !validateToken(temporaryTotpKey, totpToken, 2)) { throw new InvalidTotpTokenError(); } diff --git a/src/views/authenticator-app-configuration.ejs b/src/views/authenticator-app-configuration.ejs index 2789912e..23d2a829 100644 --- a/src/views/authenticator-app-configuration.ejs +++ b/src/views/authenticator-app-configuration.ejs @@ -47,7 +47,7 @@
-
+
@@ -60,7 +60,23 @@ pattern="^(\s*\d){6}$" title="code composé de 6 chiffres" autocomplete="off" + <% if (locals.hasCodeError) { %> + autofocus + aria-describedby="email-error" + <% } %> > + <% if (locals.hasCodeError) { %> +

+ Code invalide.  + + Consultez notre page d’aide. + +

+ <% } %>
diff --git a/src/views/user/2fa-sign-in.ejs b/src/views/user/2fa-sign-in.ejs index 1c121a71..22f44974 100644 --- a/src/views/user/2fa-sign-in.ejs +++ b/src/views/user/2fa-sign-in.ejs @@ -29,7 +29,7 @@ -
+
@@ -42,7 +42,22 @@ title="code composé de 6 chiffres" autocomplete="off" autofocus + <% if (locals.hasCodeError) { %> + aria-describedby="email-error" + <% } %> > + <% if (locals.hasCodeError) { %> +

+ Code invalide.  + + Consultez notre page d’aide. + +

+ <% } %>
<% if (showsPasskeySection) { %>