Skip to content

Commit d1d81fb

Browse files
committed
feat: display errored input when totp code is invalid
1 parent 1fc18ea commit d1d81fb

File tree

8 files changed

+105
-11
lines changed

8 files changed

+105
-11
lines changed
Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
INSERT INTO users
22
(id, email, email_verified, email_verified_at, encrypted_password, created_at, updated_at, given_name, family_name, phone_number, job, force_2fa)
33
VALUES
4-
(1, '64d9024b-d389-4b9d-948d-a504082c14fa@mailslurp.com', true, CURRENT_TIMESTAMP, '$2a$10$kzY3LINL6..50Fy9shWCcuNlRfYq0ft5lS.KCcJ5PzrhlWfKK4NIO', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'Rebibi', 'Dumama', '0123456789', 'Sbirette', false);
4+
(1, '64d9024b-d389-4b9d-948d-a504082c14fa@mailslurp.com', true, CURRENT_TIMESTAMP, '$2a$10$kzY3LINL6..50Fy9shWCcuNlRfYq0ft5lS.KCcJ5PzrhlWfKK4NIO', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'Rebibi', 'Dumama', '0123456789', 'Sbirette', false),
5+
(2, 'unused1@yopmail.com', true, CURRENT_TIMESTAMP, '$2a$10$kzY3LINL6..50Fy9shWCcuNlRfYq0ft5lS.KCcJ5PzrhlWfKK4NIO', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'Raphapha', 'Dubibi', '0123456789', 'Sbire', false);
56

67
INSERT INTO organizations
78
(id, siret, created_at, updated_at)
@@ -11,4 +12,5 @@ VALUES
1112
INSERT INTO users_organizations
1213
(user_id, organization_id, is_external, verification_type, has_been_greeted)
1314
VALUES
14-
(1, 1, false, 'verified_email_domain', true);
15+
(1, 1, false, 'verified_email_domain', true),
16+
(2, 1, false, 'verified_email_domain', true);

cypress/e2e/activate_totp/index.cy.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ describe("add 2fa authentication", () => {
2020
.contains("Configurer un code à usage unique")
2121
.click();
2222

23+
cy.contains("Configurer une application d’authentification");
24+
2325
// Extract the code from the front to generate the TOTP key
2426
cy.get("#humanReadableTotpKey")
2527
.invoke("text")
@@ -48,4 +50,21 @@ describe("add 2fa authentication", () => {
4850
expect(email.subject).to.include("Validation en deux étapes activée");
4951
});
5052
});
53+
54+
it("should see an help link on third failed attempt", function () {
55+
cy.visit("/connection-and-account");
56+
57+
cy.login("unused1@yopmail.com");
58+
59+
cy.get('[href="/authenticator-app-configuration"]')
60+
.contains("Configurer un code à usage unique")
61+
.click();
62+
63+
cy.get("[name=totpToken]").type("123456");
64+
cy.get(
65+
'[action="/authenticator-app-configuration"] [type="submit"]',
66+
).click();
67+
68+
cy.contains("Code invalide.");
69+
});
5170
});

cypress/e2e/signin_with_totp/fixtures.sql

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,18 @@ VALUES
1313
'Jean', 'Jean', '0123456789', 'Sbire',
1414
'kuOSXGk68H2B3pYnph0uyXAHrmpbWaWyX/iX49xVaUc=.VMPBZSO+eAng7mjS.cI2kRY9rwhXchcKiiaMZIg==',
1515
CURRENT_TIMESTAMP, false
16+
),
17+
(3, 'unused3@yopmail.com', true, CURRENT_TIMESTAMP,
18+
'$2a$10$kzY3LINL6..50Fy9shWCcuNlRfYq0ft5lS.KCcJ5PzrhlWfKK4NIO', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP,
19+
'Jean', 'Jean', '0123456789', 'Sbire',
20+
'kuOSXGk68H2B3pYnph0uyXAHrmpbWaWyX/iX49xVaUc=.VMPBZSO+eAng7mjS.cI2kRY9rwhXchcKiiaMZIg==',
21+
CURRENT_TIMESTAMP, true
22+
),
23+
(4, 'unused4@yopmail.com', true, CURRENT_TIMESTAMP,
24+
'$2a$10$kzY3LINL6..50Fy9shWCcuNlRfYq0ft5lS.KCcJ5PzrhlWfKK4NIO', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP,
25+
'Jean', 'Jean', '0123456789', 'Sbire',
26+
'kuOSXGk68H2B3pYnph0uyXAHrmpbWaWyX/iX49xVaUc=.VMPBZSO+eAng7mjS.cI2kRY9rwhXchcKiiaMZIg==',
27+
CURRENT_TIMESTAMP, true
1628
);
1729

1830
INSERT INTO organizations
@@ -24,7 +36,9 @@ INSERT INTO users_organizations
2436
(user_id, organization_id, is_external, verification_type, has_been_greeted)
2537
VALUES
2638
(1, 1, false, 'domain', true),
27-
(2, 1, false, 'domain', true);
39+
(2, 1, false, 'domain', true),
40+
(3, 1, false, 'domain', true),
41+
(4, 1, false, 'domain', true);
2842

2943
INSERT INTO oidc_clients
3044
(client_name, client_id, client_secret, redirect_uris,

cypress/e2e/signin_with_totp/index.cy.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,17 +26,28 @@ describe("sign-in with TOTP on untrusted browser", () => {
2626
cy.contains('"amr": [\n "pwd",\n "totp",\n "mfa"\n ],');
2727
});
2828

29+
it("should display error message", function () {
30+
cy.visit("/users/start-sign-in");
31+
32+
cy.login("unused3@yopmail.com");
33+
34+
cy.get("[name=totpToken]").type("123456");
35+
cy.get(
36+
'[action="/users/2fa-sign-in-with-authenticator-app"] [type="submit"]',
37+
).click();
38+
cy.contains("Code invalide.");
39+
});
40+
2941
it("should trigger totp rate limiting", function () {
3042
cy.visit("/users/start-sign-in");
3143

32-
cy.login("unused1@yopmail.com");
44+
cy.login("unused4@yopmail.com");
3345

34-
for (let i = 0; i < 4; i++) {
46+
for (let i = 0; i < 5; i++) {
3547
cy.get("[name=totpToken]").type("123456");
3648
cy.get(
3749
'[action="/users/2fa-sign-in-with-authenticator-app"] [type="submit"]',
3850
).click();
39-
cy.contains("le code que vous avez utilisé est invalide.");
4051
}
4152

4253
cy.get("[name=totpToken]").type("123456");

src/controllers/totp.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@ import {
2525
} from "../managers/user";
2626
import { csrfToken } from "../middlewares/csrf-protection";
2727
import { codeSchema } from "../services/custom-zod-schemas";
28-
import getNotificationsFromRequest from "../services/get-notifications-from-request";
28+
import getNotificationsFromRequest, {
29+
getNotificationLabelFromRequest,
30+
} from "../services/get-notifications-from-request";
2931

3032
export const getAuthenticatorAppConfigurationController = async (
3133
req: Request,
@@ -45,9 +47,13 @@ export const getAuthenticatorAppConfigurationController = async (
4547

4648
setTemporaryTotpKey(req, totpKey);
4749

50+
const notificationLabel = await getNotificationLabelFromRequest(req);
51+
const hasCodeError = notificationLabel === "invalid_totp_token";
52+
4853
return res.render("authenticator-app-configuration", {
4954
pageTitle: "Configuration TOTP",
5055
notifications: await getNotificationsFromRequest(req),
56+
hasCodeError,
5157
csrfToken: csrfToken(req),
5258
isAuthenticatorAlreadyConfigured:
5359
await isAuthenticatorAppConfiguredForUser(user_id),

src/controllers/user/2fa-sign-in.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ import {
66
import { isAuthenticatorAppConfiguredForUser } from "../../managers/totp";
77
import { isWebauthnConfiguredForUser } from "../../managers/webauthn";
88
import { csrfToken } from "../../middlewares/csrf-protection";
9-
import getNotificationsFromRequest from "../../services/get-notifications-from-request";
9+
import getNotificationsFromRequest, {
10+
getNotificationLabelFromRequest,
11+
} from "../../services/get-notifications-from-request";
1012

1113
export const get2faSignInController = async (
1214
req: Request,
@@ -17,6 +19,14 @@ export const get2faSignInController = async (
1719
const { id, email } = getUserFromAuthenticatedSession(req);
1820

1921
const showsTotpSection = await isAuthenticatorAppConfiguredForUser(id);
22+
let hasCodeError = false;
23+
if (showsTotpSection) {
24+
const notificationLabel = await getNotificationLabelFromRequest(req);
25+
hasCodeError = notificationLabel === "invalid_totp_token";
26+
}
27+
const notifications = hasCodeError
28+
? []
29+
: await getNotificationsFromRequest(req);
2030

2131
// If a passkey has already been used for authentication in this session,
2232
// we cannot use another passkey, or even the same one, for a second factor.
@@ -28,7 +38,8 @@ export const get2faSignInController = async (
2838

2939
return res.render("user/2fa-sign-in", {
3040
pageTitle: "Se connecter en deux étapes",
31-
notifications: await getNotificationsFromRequest(req),
41+
notifications,
42+
hasCodeError,
3243
csrfToken: csrfToken(req),
3344
email,
3445
showsTotpSection,

src/views/authenticator-app-configuration.ejs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
<form action="/authenticator-app-configuration" method="post" class="fr-mb-6w">
4848
<input type="hidden" name="_csrf" value="<%= csrfToken; %>" autocomplete="off">
4949

50-
<div class="fr-input-group">
50+
<div class="fr-input-group<% if (locals.hasCodeError) { %> fr-input-group--error<% } %>">
5151
<label class="fr-label" for="totpToken">
5252
Saisissez le code à six chiffres affiché dans l’application
5353
</label>
@@ -60,7 +60,23 @@
6060
pattern="^(\s*\d){6}$"
6161
title="code composé de 6 chiffres"
6262
autocomplete="off"
63+
<% if (locals.hasCodeError) { %>
64+
autofocus
65+
aria-describedby="email-error"
66+
<% } %>
6367
>
68+
<% if (locals.hasCodeError) { %>
69+
<p class="fr-error-text" id="email-error">
70+
Code invalide. 
71+
<a href="#"
72+
target="_blank"
73+
rel="noopener noreferrer"
74+
aria-label="Page d'aide"
75+
>
76+
Aide
77+
</a>
78+
</p>
79+
<% } %>
6480
</div>
6581

6682

src/views/user/2fa-sign-in.ejs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
<form action="/users/2fa-sign-in-with-authenticator-app" method="post" class="fr-mb-5w">
3030
<input type="hidden" name="_csrf" value="<%= csrfToken; %>" autocomplete="off" />
3131
32-
<div class="fr-input-group">
32+
<div class="fr-input-group<% if (locals.hasCodeError) { %> fr-input-group--error<% } %>">
3333
<label class="fr-label" for="totpToken">
3434
Obtenir un code à usage unique depuis votre application mobile.
3535
</label>
@@ -42,7 +42,22 @@
4242
title="code composé de 6 chiffres"
4343
autocomplete="off"
4444
autofocus
45+
<% if (locals.hasCodeError) { %>
46+
aria-describedby="email-error"
47+
<% } %>
4548
>
49+
<% if (locals.hasCodeError) { %>
50+
<p class="fr-error-text" id="email-error">
51+
Code invalide. 
52+
<a href="#"
53+
target="_blank"
54+
rel="noopener noreferrer"
55+
aria-label="Page d'aide"
56+
>
57+
Aide
58+
</a>
59+
</p>
60+
<% } %>
4661
</div>
4762
<% if (showsPasskeySection) { %>
4863
<button class="fr-btn btn--fullwidth" type="submit">

0 commit comments

Comments
 (0)