From c5e51bd7441cb686fcad451246b58b2521cbc61f Mon Sep 17 00:00:00 2001 From: DSO Date: Thu, 25 Sep 2025 10:25:49 -0700 Subject: [PATCH] ecer-4772 recaptcha refactor to cloudflare turnstiles --- .../Certifications/CertificationsEndpoints.cs | 10 +- .../ConfigurationEndpoints.cs | 34 +- .../Program.cs | 2 +- .../References/ReferencesEndpoints.cs | 34 +- .../Shared/Contract.cs | 2 +- .../appsettings.json | 8 +- .../src/api/configuration.ts | 6 +- .../src/api/reference.ts | 4 +- .../src/components/LookupCertification.vue | 28 +- .../components/inputs/EceCaptchaTurnstile.vue | 86 + .../src/components/inputs/EceRecaptcha.vue | 66 - .../src/components/reference/Invalid.vue | 2 +- .../src/components/reference/Reference.vue | 23 +- .../character-reference-decline-form.ts | 10 +- .../config/character-reference-review-form.ts | 10 +- ...erience-reference-400-hours-review-form.ts | 10 +- .../work-experience-reference-decline-form.ts | 11 +- .../work-experience-reference-review-form.ts | 10 +- .../src/store/captchaTurnstile.ts | 28 + .../src/store/wizard.ts | 6 +- .../src/types/global.d.ts | 21 +- .../src/types/input.d.ts | 8 +- .../src/types/openapi.d.ts | 2891 ++++++++--------- .../{Recaptcha => Captcha}/Contract.cs | 7 +- src/ECER.Managers.Registry/CaptchaHandlers.cs | 36 + src/ECER.Managers.Registry/Configurer.cs | 10 +- .../RecaptchaHandlers.cs | 36 - .../RegistryApi/ConfigurationTests.cs | 4 +- .../Integration/RegistryApi/ReferenceTests.cs | 6 +- tools/k6-load-testing/script.js | 7 +- 30 files changed, 1725 insertions(+), 1691 deletions(-) create mode 100644 src/ECER.Clients.RegistryPortal/ecer.clients.registryportal.client/src/components/inputs/EceCaptchaTurnstile.vue delete mode 100644 src/ECER.Clients.RegistryPortal/ecer.clients.registryportal.client/src/components/inputs/EceRecaptcha.vue create mode 100644 src/ECER.Clients.RegistryPortal/ecer.clients.registryportal.client/src/store/captchaTurnstile.ts rename src/ECER.Managers.Registry.Contract/{Recaptcha => Captcha}/Contract.cs (54%) create mode 100644 src/ECER.Managers.Registry/CaptchaHandlers.cs delete mode 100644 src/ECER.Managers.Registry/RecaptchaHandlers.cs diff --git a/src/ECER.Clients.RegistryPortal/ECER.Clients.RegistryPortal.Server/Certifications/CertificationsEndpoints.cs b/src/ECER.Clients.RegistryPortal/ECER.Clients.RegistryPortal.Server/Certifications/CertificationsEndpoints.cs index cb1119f85..e6b854cba 100644 --- a/src/ECER.Clients.RegistryPortal/ECER.Clients.RegistryPortal.Server/Certifications/CertificationsEndpoints.cs +++ b/src/ECER.Clients.RegistryPortal/ECER.Clients.RegistryPortal.Server/Certifications/CertificationsEndpoints.cs @@ -72,15 +72,15 @@ public void Register(IEndpointRouteBuilder endpointRouteBuilder) endpointRouteBuilder.MapPost("/api/certifications/lookup", async Task>, BadRequest, NotFound>> (CertificationLookupRequest request, HttpContext httpContext, CancellationToken ct, IMediator messageBus, IMapper mapper) => { - var recaptchaResult = await messageBus.Send(new Managers.Registry.Contract.Recaptcha.VerifyRecaptchaCommand(request.RecaptchaToken), ct); + var captchaResult = await messageBus.Send(new Managers.Registry.Contract.Captcha.VerifyCaptchaCommand(request.captchaToken), ct); - if (!recaptchaResult.Success) + if (!captchaResult.Success) { var problemDetails = new ProblemDetails { Status = StatusCodes.Status400BadRequest, - Detail = "Invalid recaptcha token", - Extensions = { ["errors"] = recaptchaResult.ErrorCodes } + Detail = "Invalid captcha token", + Extensions = { ["errors"] = captchaResult.ErrorCodes } }; return TypedResults.BadRequest(problemDetails); } @@ -131,7 +131,7 @@ public record CertificationLookupResponse(string Id) public IEnumerable CertificateConditions { get; set; } = Array.Empty(); } -public record CertificationLookupRequest(string RecaptchaToken) +public record CertificationLookupRequest(string captchaToken) { public string? FirstName { get; set; } public string? LastName { get; set; } diff --git a/src/ECER.Clients.RegistryPortal/ECER.Clients.RegistryPortal.Server/ConfigurationEndpoints.cs b/src/ECER.Clients.RegistryPortal/ECER.Clients.RegistryPortal.Server/ConfigurationEndpoints.cs index 8b5514eae..7536233ad 100644 --- a/src/ECER.Clients.RegistryPortal/ECER.Clients.RegistryPortal.Server/ConfigurationEndpoints.cs +++ b/src/ECER.Clients.RegistryPortal/ECER.Clients.RegistryPortal.Server/ConfigurationEndpoints.cs @@ -1,5 +1,4 @@ using AutoMapper; -using ECER.Clients.RegistryPortal.Server.Applications; using ECER.Clients.RegistryPortal.Server.Shared; using ECER.Managers.Admin.Contract.Metadatas; using ECER.Utilities.Hosting; @@ -28,7 +27,6 @@ public void Register(IEndpointRouteBuilder endpointRouteBuilder) .WithOpenApi("Handles province queries", string.Empty, "province_get") .CacheOutput(p => p.Expire(TimeSpan.FromMinutes(5))); - endpointRouteBuilder.MapGet("/api/defaultContents", async (HttpContext ctx, IMediator messageBus, IMapper mapper, CancellationToken ct) => { var results = await messageBus.Send(new DefaultContentsQuery(), ct); @@ -78,12 +76,12 @@ public void Register(IEndpointRouteBuilder endpointRouteBuilder) .WithOpenApi("Handles identification types queries", string.Empty, "identificationTypes_get") .CacheOutput(p => p.Expire(TimeSpan.FromMinutes(5))); - endpointRouteBuilder.MapGet("/api/recaptchaSiteKey", async (IOptions recaptchaSettings, CancellationToken ct) => + endpointRouteBuilder.MapGet("/api/captchaSiteKey", async (IOptions captchaSettings, CancellationToken ct) => { await Task.CompletedTask; - return TypedResults.Ok(recaptchaSettings.Value.SiteKey); + return TypedResults.Ok(captchaSettings.Value.SiteKey); }) - .WithOpenApi("Obtains site key for recaptcha", string.Empty, "recaptcha_site_key_get") + .WithOpenApi("Obtains site key for captcha", string.Empty, "captcha_site_key_get") .CacheOutput(p => p.Expire(TimeSpan.FromMinutes(5))); } } @@ -128,21 +126,21 @@ public record IdentificationTypesQuery public bool? ForPrimary { get; set; } public bool? ForSecondary { get; set; } } - public record OutOfProvinceCertificationType(string Id) - { - public string? CertificationType { get; set; } - } +public record OutOfProvinceCertificationType(string Id) +{ + public string? CertificationType { get; set; } +} - public record CertificationComparison(string Id) - { - public string? BcCertificate { get; set; } - } +public record CertificationComparison(string Id) +{ + public string? BcCertificate { get; set; } +} - public record ComparisonRecord() - { - public OutOfProvinceCertificationType? TransferringCertificate { get; set; } - public IEnumerable Options { get; set; } = Array.Empty(); - } +public record ComparisonRecord() +{ + public OutOfProvinceCertificationType? TransferringCertificate { get; set; } + public IEnumerable Options { get; set; } = Array.Empty(); +} public record DefaultContent { public string? Name { get; set; } diff --git a/src/ECER.Clients.RegistryPortal/ECER.Clients.RegistryPortal.Server/Program.cs b/src/ECER.Clients.RegistryPortal/ECER.Clients.RegistryPortal.Server/Program.cs index c6b326155..54e16f26e 100644 --- a/src/ECER.Clients.RegistryPortal/ECER.Clients.RegistryPortal.Server/Program.cs +++ b/src/ECER.Clients.RegistryPortal/ECER.Clients.RegistryPortal.Server/Program.cs @@ -63,7 +63,7 @@ private static async Task Main(string[] args) builder.Services.Configure(opts => opts.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter())); builder.Services.Configure(builder.Configuration.GetSection("Pagination")); builder.Services.Configure(builder.Configuration.GetSection("Uploader")); - builder.Services.Configure(builder.Configuration.GetSection("Recaptcha")); + builder.Services.Configure(builder.Configuration.GetSection("Captcha")); builder.Services.Configure(builder.Configuration.GetSection("Claims")); builder.Services diff --git a/src/ECER.Clients.RegistryPortal/ECER.Clients.RegistryPortal.Server/References/ReferencesEndpoints.cs b/src/ECER.Clients.RegistryPortal/ECER.Clients.RegistryPortal.Server/References/ReferencesEndpoints.cs index 1cf438c86..c9b612b7e 100644 --- a/src/ECER.Clients.RegistryPortal/ECER.Clients.RegistryPortal.Server/References/ReferencesEndpoints.cs +++ b/src/ECER.Clients.RegistryPortal/ECER.Clients.RegistryPortal.Server/References/ReferencesEndpoints.cs @@ -1,10 +1,10 @@ -using System.ComponentModel.DataAnnotations; -using AutoMapper; +using AutoMapper; using ECER.Clients.RegistryPortal.Server.Applications; using ECER.Utilities.Hosting; using MediatR; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; +using System.ComponentModel.DataAnnotations; namespace ECER.Clients.RegistryPortal.Server.References; @@ -16,15 +16,15 @@ public void Register(IEndpointRouteBuilder endpointRouteBuilder) { if (request.Token == null) return TypedResults.BadRequest(new ProblemDetails() { Detail = "Token is required" }); - var recaptchaResult = await messageBus.Send(new Managers.Registry.Contract.Recaptcha.VerifyRecaptchaCommand(request.RecaptchaToken), ct); + var captchaResult = await messageBus.Send(new Managers.Registry.Contract.Captcha.VerifyCaptchaCommand(request.CaptchaToken), ct); - if (!recaptchaResult.Success) + if (!captchaResult.Success) { var problemDetails = new ProblemDetails { Status = StatusCodes.Status400BadRequest, - Detail = "Invalid recaptcha token", - Extensions = { ["errors"] = recaptchaResult.ErrorCodes } + Detail = "Invalid captcha token", + Extensions = { ["errors"] = captchaResult.ErrorCodes } }; return TypedResults.BadRequest(problemDetails); } @@ -45,15 +45,15 @@ public void Register(IEndpointRouteBuilder endpointRouteBuilder) { if (request.Token == null) return TypedResults.BadRequest(new ProblemDetails { Detail = "Token is required" }); - var recaptchaResult = await messageBus.Send(new Managers.Registry.Contract.Recaptcha.VerifyRecaptchaCommand(request.RecaptchaToken), ct); + var captchaResult = await messageBus.Send(new Managers.Registry.Contract.Captcha.VerifyCaptchaCommand(request.CaptchaToken), ct); - if (!recaptchaResult.Success) + if (!captchaResult.Success) { var problemDetails = new ProblemDetails { Status = StatusCodes.Status400BadRequest, - Detail = "Invalid recaptcha token", - Extensions = { ["errors"] = recaptchaResult.ErrorCodes } + Detail = "Invalid captcha token", + Extensions = { ["errors"] = captchaResult.ErrorCodes } }; return TypedResults.BadRequest(problemDetails); } @@ -73,15 +73,15 @@ public void Register(IEndpointRouteBuilder endpointRouteBuilder) { if (request.Token == null) return TypedResults.BadRequest(new ProblemDetails { Detail = "Token is required" }); - var recaptchaResult = await messageBus.Send(new Managers.Registry.Contract.Recaptcha.VerifyRecaptchaCommand(request.RecaptchaToken), ct); + var captchaResult = await messageBus.Send(new Managers.Registry.Contract.Captcha.VerifyCaptchaCommand(request.CaptchaToken), ct); - if (!recaptchaResult.Success) + if (!captchaResult.Success) { var problemDetails = new ProblemDetails { Status = StatusCodes.Status400BadRequest, - Detail = "Invalid recaptcha token", - Extensions = { ["errors"] = recaptchaResult.ErrorCodes } + Detail = "Invalid captcha token", + Extensions = { ["errors"] = captchaResult.ErrorCodes } }; return TypedResults.BadRequest(problemDetails); } @@ -95,7 +95,7 @@ public void Register(IEndpointRouteBuilder endpointRouteBuilder) } } -public record CharacterReferenceSubmissionRequest(string Token, bool WillProvideReference, ReferenceContactInformation ReferenceContactInformation, CharacterReferenceEvaluation ReferenceEvaluation, bool ConfirmProvidedInformationIsRight, [Required] string RecaptchaToken); +public record CharacterReferenceSubmissionRequest(string Token, bool WillProvideReference, ReferenceContactInformation ReferenceContactInformation, CharacterReferenceEvaluation ReferenceEvaluation, bool ConfirmProvidedInformationIsRight, [Required] string CaptchaToken); public record ReferenceContactInformation([Required] string LastName, [Required] string Email, [Required] string PhoneNumber, string CertificateProvinceOther) { public string? FirstName { get; set; } @@ -104,7 +104,7 @@ public record ReferenceContactInformation([Required] string LastName, [Required] public DateTime? DateOfBirth { get; set; } } public record CharacterReferenceEvaluation([Required] ReferenceRelationship ReferenceRelationship, string ReferenceRelationshipOther, [Required] ReferenceKnownTime LengthOfAcquaintance, [Required] bool WorkedWithChildren, string ChildInteractionObservations, string ApplicantTemperamentAssessment); -public record OptOutReferenceRequest(string Token, [Required] UnabletoProvideReferenceReasons UnabletoProvideReferenceReasons, [Required] string RecaptchaToken); +public record OptOutReferenceRequest(string Token, [Required] UnabletoProvideReferenceReasons UnabletoProvideReferenceReasons, [Required] string CaptchaToken); public enum UnabletoProvideReferenceReasons { @@ -185,7 +185,7 @@ public record WorkExperienceReferenceCompetenciesAssessment() public LikertScale? FosteringPositiveRelationCoworker { get; set; } public string? FosteringPositiveRelationCoworkerReason { get; set; } } -public record WorkExperienceReferenceSubmissionRequest([Required] string Token, bool WillProvideReference, ReferenceContactInformation ReferenceContactInformation, WorkExperienceReferenceDetails WorkExperienceReferenceDetails, [RequiredWhenWorkExperienceType(WorkExperienceTypes.Is500Hours)] WorkExperienceReferenceCompetenciesAssessment? WorkExperienceReferenceCompetenciesAssessment, bool ConfirmProvidedInformationIsRight, [Required] string RecaptchaToken) +public record WorkExperienceReferenceSubmissionRequest([Required] string Token, bool WillProvideReference, ReferenceContactInformation ReferenceContactInformation, WorkExperienceReferenceDetails WorkExperienceReferenceDetails, [RequiredWhenWorkExperienceType(WorkExperienceTypes.Is500Hours)] WorkExperienceReferenceCompetenciesAssessment? WorkExperienceReferenceCompetenciesAssessment, bool ConfirmProvidedInformationIsRight, [Required] string CaptchaToken) { [Required] public WorkExperienceTypes? WorkExperienceType { get; set; } diff --git a/src/ECER.Clients.RegistryPortal/ECER.Clients.RegistryPortal.Server/Shared/Contract.cs b/src/ECER.Clients.RegistryPortal/ECER.Clients.RegistryPortal.Server/Shared/Contract.cs index 9d4b707f2..cb16e2a07 100644 --- a/src/ECER.Clients.RegistryPortal/ECER.Clients.RegistryPortal.Server/Shared/Contract.cs +++ b/src/ECER.Clients.RegistryPortal/ECER.Clients.RegistryPortal.Server/Shared/Contract.cs @@ -14,7 +14,7 @@ public record UploaderSettings public IEnumerable AllowedFileTypes { get; set; } = Array.Empty(); } -public record RecaptchaSettings +public record CaptchaSettings { public string SiteKey { get; set; } = string.Empty; } diff --git a/src/ECER.Clients.RegistryPortal/ECER.Clients.RegistryPortal.Server/appsettings.json b/src/ECER.Clients.RegistryPortal/ECER.Clients.RegistryPortal.Server/appsettings.json index 4d5cffe7b..a5a9332f5 100644 --- a/src/ECER.Clients.RegistryPortal/ECER.Clients.RegistryPortal.Server/appsettings.json +++ b/src/ECER.Clients.RegistryPortal/ECER.Clients.RegistryPortal.Server/appsettings.json @@ -65,16 +65,16 @@ "Schemes": { "kc": { "Authority": "https://loginproxy.gov.bc.ca/auth/realms/childcare-applications", - "ValidAudiences": [ "childcare-ecer" ], + "ValidAudiences": ["childcare-ecer"], "ValidIssuers": [ "https://loginproxy.gov.bc.ca/auth/realms/childcare-applications" ] } } }, - "Recaptcha": { - "Url": "https://www.google.com/recaptcha/api/siteverify", - "Secret": "[recaptcha secret]", + "Captcha": { + "Url": "https://challenges.cloudflare.com/turnstile/v0/siteverify", + "Secret": "[captcha secret]", "SiteKey": "[site key]" }, "fileScanner": { diff --git a/src/ECER.Clients.RegistryPortal/ecer.clients.registryportal.client/src/api/configuration.ts b/src/ECER.Clients.RegistryPortal/ecer.clients.registryportal.client/src/api/configuration.ts index 23e234aa4..c62280ab8 100644 --- a/src/ECER.Clients.RegistryPortal/ecer.clients.registryportal.client/src/api/configuration.ts +++ b/src/ECER.Clients.RegistryPortal/ecer.clients.registryportal.client/src/api/configuration.ts @@ -39,9 +39,9 @@ const getSystemMessages = async (): Promise => { +const getCaptchaSiteKey = async (): Promise => { const client = await getClient(false); - return (await client.recaptcha_site_key_get()).data; + return (await client.captcha_site_key_get()).data; }; const getIdentificationTypes = async (): Promise => { @@ -55,7 +55,7 @@ export { getCertificationComparisonList, getProvinceList, getCountryList, - getRecaptchaSiteKey, + getCaptchaSiteKey, getSystemMessages, getIdentificationTypes, getDefaultContent, diff --git a/src/ECER.Clients.RegistryPortal/ecer.clients.registryportal.client/src/api/reference.ts b/src/ECER.Clients.RegistryPortal/ecer.clients.registryportal.client/src/api/reference.ts index a36edbd4a..2310348f6 100644 --- a/src/ECER.Clients.RegistryPortal/ecer.clients.registryportal.client/src/api/reference.ts +++ b/src/ECER.Clients.RegistryPortal/ecer.clients.registryportal.client/src/api/reference.ts @@ -20,13 +20,13 @@ const getReference = async (token: string): Promise> => { const client = await getClient(); const body: Components.Schemas.OptOutReferenceRequest = { token: token, unabletoProvideReferenceReasons: optOutReason, - recaptchaToken: recaptchaToken, + captchaToken: captchaToken, }; return apiResultHandler.execute({ request: client.reference_optout(null, body), diff --git a/src/ECER.Clients.RegistryPortal/ecer.clients.registryportal.client/src/components/LookupCertification.vue b/src/ECER.Clients.RegistryPortal/ecer.clients.registryportal.client/src/components/LookupCertification.vue index 4f0a6560a..fe3e42b0c 100644 --- a/src/ECER.Clients.RegistryPortal/ecer.clients.registryportal.client/src/components/LookupCertification.vue +++ b/src/ECER.Clients.RegistryPortal/ecer.clients.registryportal.client/src/components/LookupCertification.vue @@ -44,12 +44,13 @@ - + captchaElementId="captchaTurnstile" + @update:model-value="(value: string) => (captchaToken = value)" + > @@ -121,12 +122,13 @@ import { isNumber } from "@/utils/formInput"; import { postLookupCertificate } from "@/api/certification"; import { useConfigStore } from "@/store/config"; import * as Rules from "../utils/formRules"; -import EceRecaptcha from "./inputs/EceRecaptcha.vue"; +import EceCaptchaTurnstile from "./inputs/EceCaptchaTurnstile.vue"; +import type { CaptchaTurnstile } from "@/components/inputs/EceCaptchaTurnstile.vue"; import type { Components } from "@/types/openapi"; import Alert from "@/components/Alert.vue"; interface LookupCertificationData { - recaptchaToken: string; + captchaToken: string; headers: ReadonlyHeaders; } @@ -134,7 +136,7 @@ type ReadonlyHeaders = VDataTable["$props"]["headers"]; export default defineComponent({ name: "LookupCertification", - components: { EceRecaptcha, EceTextField, Alert }, + components: { EceCaptchaTurnstile, EceTextField, Alert }, setup() { const alertStore = useAlertStore(); const lookupCertificationStore = useLookupCertificationStore(); @@ -147,7 +149,7 @@ export default defineComponent({ }, data(): LookupCertificationData { return { - recaptchaToken: "", + captchaToken: "", headers: [ { title: "Name", key: "name" }, { title: "Registration number", key: "registrationNumber" }, @@ -177,14 +179,14 @@ export default defineComponent({ firstName: this.lookupCertificationStore.firstName, lastName: this.lookupCertificationStore.lastName, registrationNumber: this.lookupCertificationStore.registrationNumber, - recaptchaToken: this.recaptchaToken, + captchaToken: this.captchaToken, }); this.lookupCertificationStore.setCertificationSearchResults(data); - //reset grecaptcha after success, token cannot be reused - this.recaptchaToken = ""; - window.grecaptcha.reset(); + //reset captcha after success, token cannot be reused + this.captchaToken = ""; + (this.$refs.captchaTurnstile as CaptchaTurnstile).reset(); await this.$nextTick(); (this.$refs.lookupForm as VForm).resetValidation(); } diff --git a/src/ECER.Clients.RegistryPortal/ecer.clients.registryportal.client/src/components/inputs/EceCaptchaTurnstile.vue b/src/ECER.Clients.RegistryPortal/ecer.clients.registryportal.client/src/components/inputs/EceCaptchaTurnstile.vue new file mode 100644 index 000000000..b9b203179 --- /dev/null +++ b/src/ECER.Clients.RegistryPortal/ecer.clients.registryportal.client/src/components/inputs/EceCaptchaTurnstile.vue @@ -0,0 +1,86 @@ + + diff --git a/src/ECER.Clients.RegistryPortal/ecer.clients.registryportal.client/src/components/inputs/EceRecaptcha.vue b/src/ECER.Clients.RegistryPortal/ecer.clients.registryportal.client/src/components/inputs/EceRecaptcha.vue deleted file mode 100644 index f2efbdfc6..000000000 --- a/src/ECER.Clients.RegistryPortal/ecer.clients.registryportal.client/src/components/inputs/EceRecaptcha.vue +++ /dev/null @@ -1,66 +0,0 @@ - - diff --git a/src/ECER.Clients.RegistryPortal/ecer.clients.registryportal.client/src/components/reference/Invalid.vue b/src/ECER.Clients.RegistryPortal/ecer.clients.registryportal.client/src/components/reference/Invalid.vue index 9a129d068..5f7a5c0c7 100644 --- a/src/ECER.Clients.RegistryPortal/ecer.clients.registryportal.client/src/components/reference/Invalid.vue +++ b/src/ECER.Clients.RegistryPortal/ecer.clients.registryportal.client/src/components/reference/Invalid.vue @@ -1,5 +1,5 @@