Skip to content

Commit 6ce7a4d

Browse files
committed
WIP for #884
1 parent 856276f commit 6ce7a4d

22 files changed

Lines changed: 240 additions & 54 deletions

account-gui/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
"test:watch": "npm run test -- --watch"
3939
},
4040
"dependencies": {
41+
"@friendlycaptcha/sdk": "^0.1.24",
4142
"@github/webauthn-json": "^2.1.1",
4243
"dompurify": "^3.2.6",
4344
"i18n-js": "^4.5.1",

account-gui/src/api/index.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,8 @@ function postPutJson(path, body, method) {
4141
}
4242

4343
//Base
44-
export function generateCodeNewUser(email, givenName, familyName, authenticationRequestId) {
45-
const body = {user: {email, givenName, familyName}, authenticationRequestId};
44+
export function generateCodeNewUser(email, givenName, familyName, authenticationRequestId, captchaResponse) {
45+
const body = {user: {email, givenName, familyName}, authenticationRequestId, captchaResponse};
4646
return postPutJson("/myconext/api/idp/generate_code_request", body, "POST");
4747
}
4848

account-gui/src/locale/en.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
const en = {
1+
const en = {
22
login: {
33
requestEduId: "No eduID?",
44
requestEduId2: "Create one!",
@@ -429,6 +429,9 @@ const en = {
429429
studielink: "Studielink",
430430
servicedesk: "Service Desk"
431431
}
432+
},
433+
captcha: {
434+
proveNotRobot: "Please tick the 'I am human' checkbox"
432435
}
433436
};
434437
export default en;

account-gui/src/locale/nl.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,9 @@ const nl = {
428428
studielink: "Studielink",
429429
servicedesk: "Service Desk"
430430
}
431+
},
432+
captcha: {
433+
proveNotRobot: "Check de 'I am human' checkbox"
431434
}
432435
};
433436
export default nl;

account-gui/src/routes/Request.svelte

Lines changed: 62 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import I18n from "../locale/I18n";
77
import critical from "../icons/critical.svg?raw";
88
import attention from "../icons/alert-circle.svg?raw";
9-
9+
import {FriendlyCaptchaSDK} from "@friendlycaptcha/sdk"
1010
import {fetchServiceName, generateCodeNewUser} from "../api/index";
1111
import CheckBox from "../components/CheckBox.svelte";
1212
import Spinner from "../components/Spinner.svelte";
@@ -15,22 +15,39 @@
1515
import {domains} from "../stores/domains";
1616
import Cookies from "js-cookie";
1717
import {cookieNames} from "../constants/cookieNames";
18+
import {isEmpty} from "../utils/utils.js";
1819
1920
export let id;
21+
2022
let emailInUse = false;
2123
let emailForbidden = false;
2224
let initial = true;
2325
let showSpinner = true;
2426
let serviceName = "";
25-
2627
let agreedWithTerms = false;
28+
let captchaShowWarning = false;
2729
2830
onMount(() => {
2931
$links.displayBackArrow = true;
3032
$user.givenName = "";
3133
$user.familyName = "";
3234
$user.knowUser = null;
3335
Cookies.remove(cookieNames.USERNAME);
36+
if ($conf.captchaEnabled) {
37+
const mount = document.querySelector("#captcha");
38+
mount.addEventListener("frc:widget.complete", function(event) {
39+
captchaShowWarning = false;
40+
});
41+
42+
const sdk = new FriendlyCaptchaSDK();
43+
sdk.createWidget({
44+
element: mount,
45+
sitekey: $conf.captchaSiteKey,
46+
startMode: "auto",
47+
language: I18n.locale,
48+
});
49+
}
50+
3451
fetchServiceName(id).then(res => {
3552
serviceName = res.name;
3653
showSpinner = false;
@@ -39,8 +56,13 @@
3956
4057
const handleNext = () => {
4158
if (allowedNext($user.email, $user.givenName, $user.familyName, agreedWithTerms)) {
59+
const captchaResponse = document.querySelector("[name='frc-captcha-response']").value;
60+
if ($conf.captchaEnabled && isEmpty(captchaResponse) || captchaResponse === ".ACTIVATED") {
61+
captchaShowWarning = true;
62+
return;
63+
}
4264
showSpinner = true;
43-
generateCodeNewUser($user.email, $user.givenName, $user.familyName, id)
65+
generateCodeNewUser($user.email, $user.givenName, $user.familyName, id, captchaResponse)
4466
.then(res => {
4567
const url = res.stepup ? `/stepup/${id}?name=${encodeURIComponent(serviceName)}&explanation=${res.explanation}` :
4668
`/code/${id}?name=${encodeURIComponent(serviceName)}&modus=cr`;
@@ -72,7 +94,7 @@
7294
}
7395
7496
const allowedNext = (email, familyName, givenName, agreedWithTerms) => {
75-
return validEmail(email) && familyName && givenName && agreedWithTerms && !$domains.allowedDomainNamesError
97+
return validEmail(email) && familyName && givenName && agreedWithTerms && !$domains.allowedDomainNamesError && !captchaShowWarning
7698
};
7799
78100
const handleEmailBlur = e => {
@@ -186,11 +208,28 @@
186208
font-weight: 600;
187209
}
188210
211+
.controls {
212+
margin: 10px 0 10px 0;
213+
display: flex;
214+
flex-direction: column;
215+
gap: 10px
216+
}
217+
218+
#captcha {
219+
width: 335px !important;
220+
margin-top: 15px;
221+
}
222+
223+
:global(.frc-banner) {
224+
display: none;
225+
}
226+
227+
189228
</style>
190229
{#if showSpinner}
191230
<Spinner/>
192231
{/if}
193-
232+
<form on:keydown={(e) => e.key === 'Enter' && e.preventDefault()}>
194233
<h2 class="header">{I18n.t("LinkFromInstitution.RequestEduIdButton.COPY")}</h2>
195234
{#if serviceName}
196235
<h2 class="top">{I18n.t("Login.HeaderSubTitle.COPY")}<span>{serviceName}</span></h2>
@@ -207,7 +246,8 @@
207246
value={$user.email}
208247
on:blur={handleEmailBlur}>
209248
{#if !initial && !validEmail($user.email)}
210-
<div class="error"><span class="svg">{@html critical}</span><span>{I18n.t("LinkFromInstitution.InvalidEmail.COPY")}</span></div>
249+
<div class="error"><span
250+
class="svg">{@html critical}</span><span>{I18n.t("LinkFromInstitution.InvalidEmail.COPY")}</span></div>
211251
{/if}
212252
{#if emailInUse}
213253
<div class="error">
@@ -274,12 +314,19 @@
274314
{#if !initial && !$user.familyName}
275315
<span class="error">{I18n.t("Login.RequiredAttribute.COPY", {attr: I18n.t("Profile.LastName.COPY")})}</span>
276316
{/if}
277-
<CheckBox value={agreedWithTerms}
278-
className="light"
279-
label={I18n.t("LinkFromInstitution.AgreeWithTerms.COPY")}
280-
onChange={val => agreedWithTerms = val}/>
281-
<Button disabled={showSpinner || !allowedNext($user.email, $user.familyName, $user.givenName, agreedWithTerms)}
282-
href="/magic"
283-
className="full"
284-
label={I18n.t("LinkFromInstitution.RequestEduIdButton.COPY")}
285-
onClick={handleNext}/>
317+
<div class="controls">
318+
<CheckBox value={agreedWithTerms}
319+
className="light"
320+
label={I18n.t("LinkFromInstitution.AgreeWithTerms.COPY")}
321+
onChange={val => agreedWithTerms = val}/>
322+
<div id="captcha"></div>
323+
{#if captchaShowWarning}
324+
<span class="captcha error">{I18n.t("captcha.proveNotRobot")}</span>
325+
{/if}
326+
<Button disabled={showSpinner || !allowedNext($user.email, $user.familyName, $user.givenName, agreedWithTerms)}
327+
href="/magic"
328+
className="full"
329+
label={I18n.t("LinkFromInstitution.RequestEduIdButton.COPY")}
330+
onClick={handleNext}/>
331+
</div>
332+
</form>

account-gui/src/stores/conf.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ export const conf = writable({
1313
featureIdVerify: false,
1414
featureServiceDeskActive: false,
1515
emailSpamThresholdSeconds: 5,
16-
mobileAppRedirect: ""
16+
mobileAppRedirect: "",
17+
captchaEnabled: false,
18+
captchaSiteKey: null
1719
});
1820

1921
export const links = writable({

account-gui/yarn.lock

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1034,6 +1034,11 @@
10341034
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.5.tgz#7fc114af5f6563f19f73324b5d5ff36ece0803d1"
10351035
integrity sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==
10361036

1037+
"@friendlycaptcha/sdk@^0.1.24":
1038+
version "0.1.24"
1039+
resolved "https://registry.yarnpkg.com/@friendlycaptcha/sdk/-/sdk-0.1.24.tgz#b252e00f38488847a2f81c94d8294809e0fcc577"
1040+
integrity sha512-cGsY/Q6dl9pDQVRv1iSZmAOAls0mVcN6YETRx0Y+w/uUD9sOlPNg+oMFzgzkkVvH6+Sc4Oradv10UVr8ftNBtQ==
1041+
10371042
"@github/webauthn-json@^2.1.1":
10381043
version "2.1.1"
10391044
resolved "https://registry.yarnpkg.com/@github/webauthn-json/-/webauthn-json-2.1.1.tgz#648e63fc28050917d2882cc2b27817a88cb420fc"

myconext-server/src/main/java/myconext/api/LoginController.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ public LoginController(UserRepository userRepository,
6767
@Value("${oidc-token-api.enabled}") boolean featureOidcTokenAPI,
6868
@Value("${feature.create_eduid_institution_enabled}") boolean createEduIDInstitutionEnabled,
6969
@Value("${feature.create_eduid_institution_landing}") boolean createEduIDInstitutionLanding,
70+
@Value("${feature.captcha_enabled}") boolean captchaEnabled,
71+
@Value("${captcha.sitekey}") String captchaSiteKey,
7072
@Value("${email_spam_threshold_seconds}") long emailSpamThresholdSeconds,
7173
@Value("${linked_accounts.expiry-duration-days-non-validated}") long expirationNonValidatedDurationDays,
7274
@Value("${mobile_app_redirect}") String mobileAppRedirect,
@@ -97,6 +99,8 @@ public LoginController(UserRepository userRepository,
9799
this.config.put("mobileAppRedirect", mobileAppRedirect);
98100
this.config.put("featureIdVerify", idVerify);
99101
this.config.put("featureServiceDeskActive", serviceDeskActive);
102+
this.config.put("captchaEnabled", captchaEnabled);
103+
this.config.put("captchaSiteKey", captchaSiteKey);
100104
this.secureCookie = secureCookie;
101105
this.userRepository = userRepository;
102106
this.authenticationRequestRepository = authenticationRequestRepository;

myconext-server/src/main/java/myconext/api/UserController.java

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import jakarta.validation.Valid;
2424
import lombok.Getter;
2525
import lombok.SneakyThrows;
26+
import myconext.captcha.CaptchaVerifier;
2627
import myconext.cron.DisposableEmailProviders;
2728
import myconext.exceptions.*;
2829
import myconext.mail.MailBox;
@@ -104,7 +105,7 @@ public class UserController implements UserAuthentication {
104105
private final boolean featureDefaultRememberMe;
105106
private final boolean sendJsExceptions;
106107
private final ServicesConfiguration servicesConfiguration;
107-
108+
private final CaptchaVerifier captchaVerifier;
108109
private final List<VerifyIssuer> issuers;
109110
//For now, hardcode the not known issuers from test
110111
private final List<String> unknownIssuers = List.of("CURRNL2A");
@@ -122,6 +123,7 @@ public UserController(UserRepository userRepository,
122123
EmailDomainGuard emailDomainGuard,
123124
DisposableEmailProviders disposableEmailProviders,
124125
RegistrationRepository registrationRepository,
126+
CaptchaVerifier captchaVerifier,
125127
@Qualifier("jsonMapper") ObjectMapper objectMapper,
126128
@Value("${email.magic-link-url}") String magicLinkUrl,
127129
@Value("${schac_home_organization}") String schacHomeOrganization,
@@ -148,6 +150,7 @@ public UserController(UserRepository userRepository,
148150
this.disposableEmailProviders = disposableEmailProviders;
149151
this.registrationRepository = registrationRepository;
150152
this.objectMapper = objectMapper;
153+
this.captchaVerifier = captchaVerifier;
151154
this.magicLinkUrl = magicLinkUrl;
152155
this.schacHomeOrganization = schacHomeOrganization;
153156
this.idpBaseUrl = idpBaseUrl;
@@ -282,8 +285,12 @@ public ResponseEntity generateCodeRequestNewUser(@Valid @RequestBody ClientAuthe
282285
.orElseThrow(() -> new ExpiredAuthenticationException("Expired authentication request"));
283286

284287
User user = clientAuthenticationRequest.getUser();
285-
286288
String email = user.getEmail();
289+
290+
boolean success = this.captchaVerifier.verify(clientAuthenticationRequest.getCaptchaResponse());
291+
if (!success) {
292+
throw new CaptchInvalidException("Invalid captcha for user: " + email);
293+
}
287294
verifyEmails(email, true);
288295

289296
Optional<User> optionalUser = userRepository.findUserByEmail(emailGuessingPreventor.sanitizeEmail(email));
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package myconext.captcha;
2+
3+
import org.springframework.http.HttpHeaders;
4+
import org.springframework.http.HttpRequest;
5+
import org.springframework.http.client.ClientHttpRequestExecution;
6+
import org.springframework.http.client.ClientHttpRequestInterceptor;
7+
import org.springframework.http.client.ClientHttpResponse;
8+
9+
import java.io.IOException;
10+
11+
public class ApiHeaderInterceptor implements ClientHttpRequestInterceptor {
12+
13+
private final String apiKey;
14+
15+
public ApiHeaderInterceptor(String apiKey) {
16+
this.apiKey = apiKey;
17+
}
18+
19+
@Override
20+
public ClientHttpResponse intercept(HttpRequest request,
21+
byte[] body,
22+
ClientHttpRequestExecution execution) throws IOException {
23+
24+
HttpHeaders headers = request.getHeaders();
25+
headers.set("X-API-Key", this.apiKey);
26+
return execution.execute(request, body);
27+
}
28+
}

0 commit comments

Comments
 (0)