diff --git a/.github/workflows/deploy_qa.yml b/.github/workflows/deploy_qa.yml index bcab832f05..30cc2b46df 100644 --- a/.github/workflows/deploy_qa.yml +++ b/.github/workflows/deploy_qa.yml @@ -2,8 +2,7 @@ name: "Deploy Update to QA Server" on: push: - branches: - - master + branches: [master] permissions: contents: read diff --git a/.vscode/settings.json b/.vscode/settings.json index 73188d834c..885cd7e751 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -73,7 +73,6 @@ "piptools", "Prenoun", "Preverb", - "recaptcha", "reportgenerator", "sched", "signup", diff --git a/Backend.Tests/Controllers/UserControllerTests.cs b/Backend.Tests/Controllers/UserControllerTests.cs index 530325fe31..4906558a86 100644 --- a/Backend.Tests/Controllers/UserControllerTests.cs +++ b/Backend.Tests/Controllers/UserControllerTests.cs @@ -35,7 +35,7 @@ public void Setup() _userRepo = new UserRepositoryMock(); _permissionService = new PermissionServiceMock(_userRepo); _userController = new UserController(_userRepo, _permissionService, - new EmailServiceMock(), new PasswordResetServiceMock()); + new CaptchaServiceMock(), new EmailServiceMock(), new PasswordResetServiceMock()); } private static User RandomUser() diff --git a/Backend.Tests/Mocks/CaptchaServiceMock.cs b/Backend.Tests/Mocks/CaptchaServiceMock.cs new file mode 100644 index 0000000000..06095f2814 --- /dev/null +++ b/Backend.Tests/Mocks/CaptchaServiceMock.cs @@ -0,0 +1,13 @@ +using System.Threading.Tasks; +using BackendFramework.Interfaces; + +namespace Backend.Tests.Mocks +{ + sealed internal class CaptchaServiceMock : ICaptchaService + { + public Task VerifyToken(string token) + { + return Task.FromResult(true); + } + } +} diff --git a/Backend/Contexts/CaptchaContext.cs b/Backend/Contexts/CaptchaContext.cs new file mode 100644 index 0000000000..f262e30000 --- /dev/null +++ b/Backend/Contexts/CaptchaContext.cs @@ -0,0 +1,21 @@ +using System.Diagnostics.CodeAnalysis; +using BackendFramework.Interfaces; +using Microsoft.Extensions.Options; + +namespace BackendFramework.Contexts +{ + [ExcludeFromCodeCoverage] + public class CaptchaContext : ICaptchaContext + { + public bool CaptchaEnabled { get; } + public string? CaptchaSecretKey { get; } + public string? CaptchaVerifyUrl { get; } + + public CaptchaContext(IOptions options) + { + CaptchaEnabled = options.Value.CaptchaEnabled; + CaptchaSecretKey = options.Value.CaptchaSecretKey; + CaptchaVerifyUrl = options.Value.CaptchaVerifyUrl; + } + } +} diff --git a/Backend/Contexts/EmailContext.cs b/Backend/Contexts/EmailContext.cs index 57a6f24f0d..fe252d7517 100644 --- a/Backend/Contexts/EmailContext.cs +++ b/Backend/Contexts/EmailContext.cs @@ -7,6 +7,7 @@ namespace BackendFramework.Contexts [ExcludeFromCodeCoverage] public class EmailContext : IEmailContext { + public bool EmailEnabled { get; } public string? SmtpServer { get; } public int SmtpPort { get; } public string? SmtpUsername { get; } @@ -16,6 +17,7 @@ public class EmailContext : IEmailContext public EmailContext(IOptions options) { + EmailEnabled = options.Value.EmailEnabled; SmtpServer = options.Value.SmtpServer; SmtpPort = options.Value.SmtpPort ?? IEmailContext.InvalidPort; SmtpUsername = options.Value.SmtpUsername; diff --git a/Backend/Controllers/UserController.cs b/Backend/Controllers/UserController.cs index cbf0fa3175..32c9c3ce47 100644 --- a/Backend/Controllers/UserController.cs +++ b/Backend/Controllers/UserController.cs @@ -18,19 +18,30 @@ namespace BackendFramework.Controllers public class UserController : Controller { private readonly IUserRepository _userRepo; + private readonly ICaptchaService _captchaService; private readonly IEmailService _emailService; private readonly IPasswordResetService _passwordResetService; private readonly IPermissionService _permissionService; public UserController(IUserRepository userRepo, IPermissionService permissionService, - IEmailService emailService, IPasswordResetService passwordResetService) + ICaptchaService captchaService, IEmailService emailService, IPasswordResetService passwordResetService) { _userRepo = userRepo; + _captchaService = captchaService; _emailService = emailService; _passwordResetService = passwordResetService; _permissionService = permissionService; } + /// Verifies a CAPTCHA token + [AllowAnonymous] + [HttpGet("captcha/{token}", Name = "VerifyCaptchaToken")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task VerifyCaptchaToken(string token) + { + return await _captchaService.VerifyToken(token) ? Ok() : BadRequest(); + } + /// Sends a password reset request [AllowAnonymous] [HttpPost("forgot", Name = "ResetPasswordRequest")] diff --git a/Backend/Interfaces/ICaptchaContext.cs b/Backend/Interfaces/ICaptchaContext.cs new file mode 100644 index 0000000000..742f8c123a --- /dev/null +++ b/Backend/Interfaces/ICaptchaContext.cs @@ -0,0 +1,9 @@ +namespace BackendFramework.Interfaces +{ + public interface ICaptchaContext + { + bool CaptchaEnabled { get; } + string? CaptchaSecretKey { get; } + string? CaptchaVerifyUrl { get; } + } +} diff --git a/Backend/Interfaces/ICaptchaService.cs b/Backend/Interfaces/ICaptchaService.cs new file mode 100644 index 0000000000..efcb7bdc5a --- /dev/null +++ b/Backend/Interfaces/ICaptchaService.cs @@ -0,0 +1,9 @@ +using System.Threading.Tasks; + +namespace BackendFramework.Interfaces +{ + public interface ICaptchaService + { + Task VerifyToken(string token); + } +} diff --git a/Backend/Interfaces/IEmailContext.cs b/Backend/Interfaces/IEmailContext.cs index cae139443c..13892f9df8 100644 --- a/Backend/Interfaces/IEmailContext.cs +++ b/Backend/Interfaces/IEmailContext.cs @@ -6,6 +6,7 @@ public interface IEmailContext /// This is value is set if the user does not supply an SMTP port number. public const int InvalidPort = -1; + bool EmailEnabled { get; } string? SmtpServer { get; } int SmtpPort { get; } string? SmtpUsername { get; } diff --git a/Backend/Properties/launchSettings.json b/Backend/Properties/launchSettings.json index 00e83c8bed..3fad483db3 100644 --- a/Backend/Properties/launchSettings.json +++ b/Backend/Properties/launchSettings.json @@ -14,7 +14,11 @@ "launchBrowser": true, "launchUrl": "v1", "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" + "ASPNETCORE_ENVIRONMENT": "Development", + "COMBINE_CAPTCHA_REQUIRED": "true", + "COMBINE_CAPTCHA_SECRET_KEY": "1x0000000000000000000000000000000AA", + "COMBINE_CAPTCHA_VERIFY_URL": "https://challenges.cloudflare.com/turnstile/v0/siteverify", + "COMBINE_JWT_SECRET_KEY": "0123456789abcdefghijklmnopqrstuvwxyz" } }, "BackendFramework": { @@ -23,7 +27,11 @@ "launchUrl": "v1/", "environmentVariables": { "Key": "Value", - "ASPNETCORE_ENVIRONMENT": "Development" + "ASPNETCORE_ENVIRONMENT": "Development", + "COMBINE_CAPTCHA_REQUIRED": "true", + "COMBINE_CAPTCHA_SECRET_KEY": "1x0000000000000000000000000000000AA", + "COMBINE_CAPTCHA_VERIFY_URL": "https://challenges.cloudflare.com/turnstile/v0/siteverify", + "COMBINE_JWT_SECRET_KEY": "0123456789abcdefghijklmnopqrstuvwxyz" }, "applicationUrl": "http://localhost:5000", "hotReloadProfile": "aspnetcore" diff --git a/Backend/Services/CaptchaService.cs b/Backend/Services/CaptchaService.cs new file mode 100644 index 0000000000..ef3c361eb0 --- /dev/null +++ b/Backend/Services/CaptchaService.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Net.Http; +using System.Threading.Tasks; +using BackendFramework.Interfaces; + +namespace BackendFramework.Services +{ + [ExcludeFromCodeCoverage] + public class CaptchaService : ICaptchaService + { + private readonly ICaptchaContext _captchaContext; + + public CaptchaService(ICaptchaContext captchaContext) + { + _captchaContext = captchaContext; + } + + public async Task VerifyToken(string token) + { + if (!_captchaContext.CaptchaEnabled) + { + throw new CaptchaNotEnabledException(); + } + + var secret = _captchaContext.CaptchaSecretKey; + var verifyUrl = _captchaContext.CaptchaVerifyUrl; + if (string.IsNullOrEmpty(secret) || string.IsNullOrEmpty(verifyUrl)) + { + return false; + } + var httpContent = new FormUrlEncodedContent(new Dictionary{ + {"response", token}, + {"secret", secret}, + }); + using var result = await new HttpClient().PostAsync(verifyUrl, httpContent); + var contentString = await result.Content.ReadAsStringAsync(); + return contentString.Contains("\"success\":true"); + } + + private sealed class CaptchaNotEnabledException : Exception { } + } +} diff --git a/Backend/Services/EmailService.cs b/Backend/Services/EmailService.cs index e4ba7d0014..38604021d1 100644 --- a/Backend/Services/EmailService.cs +++ b/Backend/Services/EmailService.cs @@ -1,6 +1,7 @@ +using System; using System.Diagnostics.CodeAnalysis; -using BackendFramework.Interfaces; using System.Threading.Tasks; +using BackendFramework.Interfaces; using MimeKit; namespace BackendFramework.Services @@ -17,6 +18,11 @@ public EmailService(IEmailContext emailContext) public async Task SendEmail(MimeMessage message) { + if (!_emailContext.EmailEnabled) + { + throw new EmailNotEnabledException(); + } + using var client = new MailKit.Net.Smtp.SmtpClient(); await client.ConnectAsync(_emailContext.SmtpServer, _emailContext.SmtpPort); @@ -33,5 +39,7 @@ public async Task SendEmail(MimeMessage message) await client.DisconnectAsync(true); return true; } + + private sealed class EmailNotEnabledException : Exception { } } } diff --git a/Backend/Startup.cs b/Backend/Startup.cs index 84a0fc2b08..8bdad6da05 100644 --- a/Backend/Startup.cs +++ b/Backend/Startup.cs @@ -39,8 +39,12 @@ public class Settings { public const int DefaultPasswordResetExpireTime = 15; + public bool CaptchaEnabled { get; set; } + public string? CaptchaSecretKey { get; set; } + public string? CaptchaVerifyUrl { get; set; } public string ConnectionString { get; set; } public string CombineDatabase { get; set; } + public bool EmailEnabled { get; set; } public string? SmtpServer { get; set; } public int? SmtpPort { get; set; } public string? SmtpUsername { get; set; } @@ -51,15 +55,17 @@ public class Settings public Settings() { + CaptchaEnabled = true; ConnectionString = ""; CombineDatabase = ""; + EmailEnabled = false; PassResetExpireTime = DefaultPasswordResetExpireTime; } } private sealed class EnvironmentNotConfiguredException : Exception { } - private string? CheckedEnvironmentVariable(string name, string? defaultValue, string error = "") + private string? CheckedEnvironmentVariable(string name, string? defaultValue, string error = "", bool info = false) { var contents = Environment.GetEnvironmentVariable(name); if (contents is not null) @@ -67,7 +73,14 @@ private sealed class EnvironmentNotConfiguredException : Exception { } return contents; } - _logger.LogError("Environment variable: {Name} is not defined. {Error}", name, error); + if (info) + { + _logger.LogInformation("Environment variable: {Name} is not defined. {Error}", name, error); + } + else + { + _logger.LogError("Environment variable: {Name} is not defined. {Error}", name, error); + } return defaultValue; } @@ -153,31 +166,56 @@ public void ConfigureServices(IServiceCollection services) options.CombineDatabase = Configuration["MongoDB:CombineDatabase"] ?? throw new EnvironmentNotConfiguredException(); + options.CaptchaEnabled = bool.Parse(CheckedEnvironmentVariable( + "COMBINE_CAPTCHA_REQUIRED", + "true", + "CAPTCHA should be explicitly required or not required.")!); + if (options.CaptchaEnabled) + { + options.CaptchaSecretKey = CheckedEnvironmentVariable( + "COMBINE_CAPTCHA_SECRET_KEY", + null, + "CAPTCHA secret key required."); + options.CaptchaVerifyUrl = CheckedEnvironmentVariable( + "COMBINE_CAPTCHA_VERIFY_URL", + null, + "CAPTCHA verification URL required."); + } + const string emailServiceFailureMessage = "Email services will not work."; - options.SmtpServer = CheckedEnvironmentVariable( - "COMBINE_SMTP_SERVER", - null, - emailServiceFailureMessage); - options.SmtpPort = int.Parse(CheckedEnvironmentVariable( - "COMBINE_SMTP_PORT", - IEmailContext.InvalidPort.ToString(), - emailServiceFailureMessage)!); - options.SmtpUsername = CheckedEnvironmentVariable( - "COMBINE_SMTP_USERNAME", - null, - emailServiceFailureMessage); - options.SmtpPassword = CheckedEnvironmentVariable( - "COMBINE_SMTP_PASSWORD", - null, - emailServiceFailureMessage); - options.SmtpAddress = CheckedEnvironmentVariable( - "COMBINE_SMTP_ADDRESS", - null, - emailServiceFailureMessage); - options.SmtpFrom = CheckedEnvironmentVariable( - "COMBINE_SMTP_FROM", - null, - emailServiceFailureMessage); + options.EmailEnabled = bool.Parse(CheckedEnvironmentVariable( + "COMBINE_EMAIL_ENABLED", + "false", + emailServiceFailureMessage, + true)!); + if (options.EmailEnabled) + { + options.SmtpServer = CheckedEnvironmentVariable( + "COMBINE_SMTP_SERVER", + null, + emailServiceFailureMessage); + options.SmtpPort = int.Parse(CheckedEnvironmentVariable( + "COMBINE_SMTP_PORT", + IEmailContext.InvalidPort.ToString(), + emailServiceFailureMessage)!); + options.SmtpUsername = CheckedEnvironmentVariable( + "COMBINE_SMTP_USERNAME", + null, + emailServiceFailureMessage); + options.SmtpPassword = CheckedEnvironmentVariable( + "COMBINE_SMTP_PASSWORD", + null, + emailServiceFailureMessage); + options.SmtpAddress = CheckedEnvironmentVariable( + "COMBINE_SMTP_ADDRESS", + null, + emailServiceFailureMessage); + options.SmtpFrom = CheckedEnvironmentVariable( + "COMBINE_SMTP_FROM", + null, + emailServiceFailureMessage); + } + options.PassResetExpireTime = int.Parse(CheckedEnvironmentVariable( "COMBINE_PASSWORD_RESET_EXPIRE_TIME", Settings.DefaultPasswordResetExpireTime.ToString(), @@ -190,6 +228,10 @@ public void ConfigureServices(IServiceCollection services) services.AddTransient(); services.AddTransient(); + // CAPTCHA types + services.AddTransient(); + services.AddTransient(); + // Email types services.AddTransient(); services.AddTransient(); diff --git a/README.md b/README.md index 7aff0f3790..707af1ee32 100644 --- a/README.md +++ b/README.md @@ -142,12 +142,11 @@ A rapid word collection tool. See the [User Guide](https://sillsdev.github.io/Th ### Prepare the Environment -1. Set the environment variable `COMBINE_JWT_SECRET_KEY` to a string **containing at least 32 characters**, such as - _This is a secret key that is longer_. Set it in your `.profile` (Linux or Mac 10.14-), your `.zprofile` (Mac - 10.15+), or the _System_ app (Windows). -2. If you want the email services to work you will need to set the following environment variables. These values must be - kept secret, so ask your email administrator to supply them. +1. _(Optional)_ If you want the email services to work you will need to set the following environment variables. These + `COMBINE_SMTP_` values must be kept secret, so ask your email administrator to supply them. Set them in your + `.profile` (Linux or Mac 10.14-), your `.zprofile` (Mac 10.15+), or the _System_ app (Windows). + - `COMBINE_EMAIL_ENABLED=true` - `COMBINE_SMTP_SERVER` - `COMBINE_SMTP_PORT` - `COMBINE_SMTP_USERNAME` @@ -155,16 +154,16 @@ A rapid word collection tool. See the [User Guide](https://sillsdev.github.io/Th - `COMBINE_SMTP_ADDRESS` - `COMBINE_SMTP_FROM` -3. _(Optional)_ To opt in to segment.com analytics to test the analytics during development: +2. _(Optional)_ To opt in to segment.com analytics to test the analytics during development: ```bash # For Windows, use `copy`. cp .env.local.template .env.local ``` -4. Run `npm start` from the project directory to install dependencies and start the project. +3. Run `npm start` from the project directory to install dependencies and start the project. -5. Consult our [C#](docs/style_guide/c_sharp_style_guide.md) and [TypeScript](docs/style_guide/ts_style_guide.md) style +4. Consult our [C#](docs/style_guide/c_sharp_style_guide.md) and [TypeScript](docs/style_guide/ts_style_guide.md) style guides for best coding practices in this project. [chocolatey]: https://chocolatey.org/ @@ -687,7 +686,12 @@ Notes: ### Setup Environment Variables -_Note: This is optional for Development Environments._ +Before installing _The Combine_ in Kubernetes, you need to set the following environment variables: +`COMBINE_CAPTCHA_SECRET_KEY`, `COMBINE_JWT_SECRET_KEY`. For development environments, you can use the values defined in +`Backend/Properties/launchSettings.json`. Set them in your `.profile` (Linux or Mac 10.14-), your `.zprofile` (Mac +10.15+), or the _System_ app (Windows). + +_Note: The following is optional for Development Environments._ In addition to the environment variables defined in [Prepare the Environment](#prepare-the-environment), you may setup the following environment variables: @@ -697,15 +701,13 @@ the following environment variables: - `AWS_ACCESS_KEY_ID` - `AWS_SECRET_ACCESS_KEY` -These variables will allow the Combine to: +These variables will allow _The Combine_ to: - pull released and QA software images from AWS Elastic Container Registry (ECR); - create backups and push them to AWS S3 storage; and - restore _The Combine's_ database and backend files from a backup stored in AWS S3 storage. -The Combine application will function in a local cluster without these variables set. - -These can be set in your `.profile` (Linux or Mac 10.14-), your `.zprofile` (Mac 10.15+), or the _System_ app (Windows). +The Combine application will function in a local cluster without these `AWS_` variables set. ### Install/Update _The Combine_ diff --git a/deploy/helm/thecombine/charts/backend/templates/backend-config-map.yaml b/deploy/helm/thecombine/charts/backend/templates/backend-config-map.yaml index 1802815869..0a7f5a803c 100644 --- a/deploy/helm/thecombine/charts/backend/templates/backend-config-map.yaml +++ b/deploy/helm/thecombine/charts/backend/templates/backend-config-map.yaml @@ -5,6 +5,9 @@ metadata: name: env-backend namespace: {{ .Release.Namespace }} data: + COMBINE_CAPTCHA_REQUIRED: {{ .Values.global.captchaRequired | quote | lower }} + COMBINE_CAPTCHA_VERIFY_URL: {{ .Values.captchaVerifyUrl | quote }} + COMBINE_EMAIL_ENABLED: {{ .Values.global.emailEnabled | quote | lower }} COMBINE_PASSWORD_RESET_EXPIRE_TIME: {{ .Values.combinePasswordResetTime | quote }} COMBINE_SMTP_ADDRESS: {{ .Values.combineSmtpAddress | quote }} diff --git a/deploy/helm/thecombine/charts/backend/templates/backend-secrets.yaml b/deploy/helm/thecombine/charts/backend/templates/backend-secrets.yaml index 60a8eb8f2e..44ad6bc87b 100644 --- a/deploy/helm/thecombine/charts/backend/templates/backend-secrets.yaml +++ b/deploy/helm/thecombine/charts/backend/templates/backend-secrets.yaml @@ -6,6 +6,7 @@ metadata: namespace: {{ .Release.Namespace }} type: Opaque data: + COMBINE_CAPTCHA_SECRET_KEY: {{ .Values.global.captchaSecretKey | b64enc }} COMBINE_JWT_SECRET_KEY: {{ .Values.global.combineJwtSecretKey | b64enc }} COMBINE_SMTP_USERNAME: {{ .Values.global.combineSmtpUsername | b64enc }} COMBINE_SMTP_PASSWORD: {{ .Values.global.combineSmtpPassword | b64enc }} diff --git a/deploy/helm/thecombine/charts/backend/templates/deployment-backend.yaml b/deploy/helm/thecombine/charts/backend/templates/deployment-backend.yaml index 2d4043a1e9..fbdf68418b 100644 --- a/deploy/helm/thecombine/charts/backend/templates/deployment-backend.yaml +++ b/deploy/helm/thecombine/charts/backend/templates/deployment-backend.yaml @@ -29,6 +29,26 @@ spec: image: {{ include "backend.containerImage" . }} imagePullPolicy: {{ .Values.global.imagePullPolicy }} env: + - name: COMBINE_CAPTCHA_REQUIRED + valueFrom: + configMapKeyRef: + key: COMBINE_CAPTCHA_REQUIRED + name: env-backend + - name: COMBINE_CAPTCHA_SECRET_KEY + valueFrom: + secretKeyRef: + key: COMBINE_CAPTCHA_SECRET_KEY + name: env-backend-secrets + - name: COMBINE_CAPTCHA_VERIFY_URL + valueFrom: + configMapKeyRef: + key: COMBINE_CAPTCHA_VERIFY_URL + name: env-backend + - name: COMBINE_EMAIL_ENABLED + valueFrom: + configMapKeyRef: + key: COMBINE_EMAIL_ENABLED + name: env-backend - name: COMBINE_JWT_SECRET_KEY valueFrom: secretKeyRef: diff --git a/deploy/helm/thecombine/charts/backend/values.yaml b/deploy/helm/thecombine/charts/backend/values.yaml index 3bc97872dc..6bd9d71a1a 100644 --- a/deploy/helm/thecombine/charts/backend/values.yaml +++ b/deploy/helm/thecombine/charts/backend/values.yaml @@ -12,20 +12,27 @@ global: # Update strategy should be "Recreate" or "Rolling Update" updateStrategy: Recreate + # AWS Credentials + awsAccount: "Override" + awsDefaultRegion: "Override" + # Combine variables adminUsername: "Override" adminPassword: "Override" adminEmail: "Override" - awsAccount: "Override" - awsDefaultRegion: "Override" - pullSecretName: "None" + captchaRequired: false + captchaSecretKey: "Override" combineJwtSecretKey: "Override" combineSmtpUsername: "Override" combineSmtpPassword: "Override" + emailEnabled: false # Values for pulling container image from image registry imagePullPolicy: "Override" - imageTag: "latest" # Define the image registry to use (may be blank for local images) imageRegistry: "" + imageTag: "latest" + pullSecretName: "None" + + includeResourceLimits: false persistentVolumeSize: 32Gi combinePasswordResetTime: 15 @@ -34,3 +41,4 @@ combineSmtpFrom: "The Combine" combineSmtpPort: 587 combineSmtpServer: "email-smtp.us-east-1.amazonaws.com" imageName: combine_backend +captchaVerifyUrl: "https://challenges.cloudflare.com/turnstile/v0/siteverify" diff --git a/deploy/helm/thecombine/charts/frontend/templates/deployment-frontend.yaml b/deploy/helm/thecombine/charts/frontend/templates/deployment-frontend.yaml index 1f350c41a3..739efab059 100644 --- a/deploy/helm/thecombine/charts/frontend/templates/deployment-frontend.yaml +++ b/deploy/helm/thecombine/charts/frontend/templates/deployment-frontend.yaml @@ -34,10 +34,10 @@ spec: configMapKeyRef: key: CERT_ADDL_DOMAINS name: env-frontend - - name: CONFIG_CAPTCHA_REQD + - name: CONFIG_CAPTCHA_REQUIRED valueFrom: configMapKeyRef: - key: CONFIG_CAPTCHA_REQD + key: CONFIG_CAPTCHA_REQUIRED name: env-frontend - name: CONFIG_CAPTCHA_SITE_KEY valueFrom: diff --git a/deploy/helm/thecombine/charts/frontend/templates/env-frontend-configmap.yaml b/deploy/helm/thecombine/charts/frontend/templates/env-frontend-configmap.yaml index 5b9036f554..7069bc3c6e 100644 --- a/deploy/helm/thecombine/charts/frontend/templates/env-frontend-configmap.yaml +++ b/deploy/helm/thecombine/charts/frontend/templates/env-frontend-configmap.yaml @@ -8,11 +8,11 @@ data: SERVER_NAME: {{ .Values.global.serverName }} CERT_ADDL_DOMAINS: {{ .Values.combineAddlDomainList | quote }} CONFIG_USE_CONNECTION_URL: "true" - CONFIG_CAPTCHA_REQD: {{ .Values.configCaptchaRequired | quote }} + CONFIG_CAPTCHA_REQUIRED: {{ .Values.global.captchaRequired | quote | lower }} CONFIG_CAPTCHA_SITE_KEY: {{ .Values.configCaptchaSiteKey | quote }} - CONFIG_OFFLINE: {{ .Values.configOffline | quote }} - CONFIG_EMAIL_ENABLED: {{ and .Values.configEmailEnabled (empty .Values.global.combineSmtpUsername | not) | quote }} - CONFIG_SHOW_CERT_EXPIRATION: {{ .Values.configShowCertExpiration | quote }} + CONFIG_OFFLINE: {{ .Values.configOffline | quote | lower }} + CONFIG_EMAIL_ENABLED: {{ .Values.global.emailEnabled | quote | lower }} + CONFIG_SHOW_CERT_EXPIRATION: {{ .Values.configShowCertExpiration | quote | lower }} {{- if .Values.configAnalyticsWriteKey }} CONFIG_ANALYTICS_WRITE_KEY: {{ .Values.configAnalyticsWriteKey | quote }} {{- end }} diff --git a/deploy/helm/thecombine/charts/frontend/values.yaml b/deploy/helm/thecombine/charts/frontend/values.yaml index 145e78a047..42240c00fb 100644 --- a/deploy/helm/thecombine/charts/frontend/values.yaml +++ b/deploy/helm/thecombine/charts/frontend/values.yaml @@ -3,24 +3,26 @@ # Declare variables to be passed into your templates. global: - combineSmtpUsername: "Override" serverName: localhost - pullSecretName: aws-login-credentials + # Combine variables + captchaRequired: false + emailEnabled: false # Update strategy should be "Recreate" or "Rolling Update" updateStrategy: Recreate # Values for pulling container image from image registry imagePullPolicy: "Override" - imageTag: "latest" # Define the image registry to use (may be blank for local images) imageRegistry: "" + imageTag: "latest" + pullSecretName: "None" + + includeResourceLimits: false imageName: combine_frontend # The additional domain list is a space-separated string list of domains combineAddlDomainList: "" -configCaptchaRequired: "false" -configCaptchaSiteKey: "None - from frontend chart" -configOffline: "false" -configEmailEnabled: "true" -configShowCertExpiration: "false" configAnalyticsWriteKey: "" +configCaptchaSiteKey: "None - defined in profiles" +configOffline: false +configShowCertExpiration: false diff --git a/deploy/helm/thecombine/values.yaml b/deploy/helm/thecombine/values.yaml index 49a47078a1..7f4ad5b282 100644 --- a/deploy/helm/thecombine/values.yaml +++ b/deploy/helm/thecombine/values.yaml @@ -23,10 +23,12 @@ global: adminUsername: "Override" adminPassword: "Override" adminEmail: "Override" + captchaRequired: false + captchaSecretKey: "Override" combineJwtSecretKey: "Override" combineSmtpUsername: "Override" combineSmtpPassword: "Override" - offline: false + emailEnabled: false # Local Storage for fonts fontStorageAccessMode: "ReadWriteOnce" fontStorageSize: 1Gi @@ -35,7 +37,7 @@ global: # Define the image registry to use (may be blank for local images) imageRegistry: awsEcr imageTag: "latest" - pullSecretName: aws-login-credentials + pullSecretName: "None" # Update strategy should be "Recreate" or "Rolling Update" updateStrategy: Recreate @@ -51,12 +53,6 @@ certManager: enabled: false certIssuer: letsencrypt-prod -frontend: - configShowCertExpiration: false - configAnalyticsWriteKey: "" - configCaptchaRequired: false - configCaptchaSiteKey: "None" - # Maintenance configuration items maintenance: backupSchedule: "" diff --git a/deploy/scripts/install-combine.sh b/deploy/scripts/install-combine.sh index b32cb18d66..55aed50a26 100755 --- a/deploy/scripts/install-combine.sh +++ b/deploy/scripts/install-combine.sh @@ -17,7 +17,6 @@ set-combine-env () { export AWS_DEFAULT_REGION="us-east-1" export AWS_ACCESS_KEY_ID="${AWS_ACCESS_KEY_ID}" export AWS_SECRET_ACCESS_KEY="${AWS_SECRET_ACCESS_KEY}" - export COMBINE_SMTP_USERNAME="nobody" .EOF chmod 600 ${CONFIG_DIR}/env fi diff --git a/deploy/scripts/setup_files/combine_config.yaml b/deploy/scripts/setup_files/combine_config.yaml index 3ce315b835..ca9030bbbe 100644 --- a/deploy/scripts/setup_files/combine_config.yaml +++ b/deploy/scripts/setup_files/combine_config.yaml @@ -79,9 +79,6 @@ targets: thecombine: global: serverName: qa-kube.thecombine.app - frontend: - configCaptchaRequired: "true" - configCaptchaSiteKey: "6LdZIlkaAAAAAES4FZ5d01Shj5G4X0e2CHYg0D5t" prod: profile: prod env_vars_required: true @@ -90,9 +87,6 @@ targets: thecombine: global: serverName: thecombine.app - frontend: - configCaptchaRequired: "true" - configCaptchaSiteKey: "6LdZIlkaAAAAAES4FZ5d01Shj5G4X0e2CHYg0D5t" # Set of profiles # Each key of 'profiles' defines one of the profiles used by the set of targets. @@ -150,6 +144,8 @@ charts: env_var: AWS_ACCESS_KEY_ID - config_item: awsSecretAccessKey env_var: AWS_SECRET_ACCESS_KEY + - config_item: captchaSecretKey + env_var: COMBINE_CAPTCHA_SECRET_KEY - config_item: combineJwtSecretKey env_var: COMBINE_JWT_SECRET_KEY - config_item: combineSmtpUsername diff --git a/deploy/scripts/setup_files/profiles/desktop.yaml b/deploy/scripts/setup_files/profiles/desktop.yaml index 7a30eaab80..4d827ee28e 100644 --- a/deploy/scripts/setup_files/profiles/desktop.yaml +++ b/deploy/scripts/setup_files/profiles/desktop.yaml @@ -2,7 +2,7 @@ ################################################ # Profile specific configuration items # -# Profile: nuc +# Profile: desktop ################################################ charts: @@ -13,12 +13,12 @@ charts: enabled: false global: awsS3Location: local.thecombine.app - combineSmtpUsername: "" + captchaRequired: false + emailEnabled: false imagePullPolicy: IfNotPresent pullSecretName: None frontend: configOffline: true - configEmailEnabled: false maintenance: localLangList: - "ar" diff --git a/deploy/scripts/setup_files/profiles/dev.yaml b/deploy/scripts/setup_files/profiles/dev.yaml index 9f1e3feb88..ae4b78a03f 100644 --- a/deploy/scripts/setup_files/profiles/dev.yaml +++ b/deploy/scripts/setup_files/profiles/dev.yaml @@ -11,14 +11,18 @@ charts: enabled: false frontend: - configCaptchaRequired: "true" - configCaptchaSiteKey: "6Le6BL0UAAAAAMjSs1nINeB5hqDZ4m3mMg3k67x3" + # https://developers.cloudflare.com/turnstile/troubleshooting/testing/ + # has dummy secret keys for development and testing; options are + # invisible pass, invisible fail, visible pass, visible fail, forced interaction + configCaptchaSiteKey: "1x00000000000000000000AA" # visible pass global: imageRegistry: "" imagePullPolicy: Never includeResourceLimits: false awsS3Location: dev.thecombine.app + captchaRequired: true + emailEnabled: true ingressClass: nginx imagePullPolicy: Never diff --git a/deploy/scripts/setup_files/profiles/nuc.yaml b/deploy/scripts/setup_files/profiles/nuc.yaml index 9eb2f9de62..6a9bec25ca 100644 --- a/deploy/scripts/setup_files/profiles/nuc.yaml +++ b/deploy/scripts/setup_files/profiles/nuc.yaml @@ -13,12 +13,12 @@ charts: enabled: false global: awsS3Location: prod.thecombine.app - combineSmtpUsername: "" + captchaRequired: false + emailEnabled: false imagePullPolicy: IfNotPresent pullSecretName: None frontend: configOffline: true - configEmailEnabled: false maintenance: localLangList: - "ar" diff --git a/deploy/scripts/setup_files/profiles/nuc_qa.yaml b/deploy/scripts/setup_files/profiles/nuc_qa.yaml index a3c8d8e106..6f2efa29c5 100644 --- a/deploy/scripts/setup_files/profiles/nuc_qa.yaml +++ b/deploy/scripts/setup_files/profiles/nuc_qa.yaml @@ -2,7 +2,7 @@ ################################################ # Profile specific configuration items # -# Profile: nuc +# Profile: nuc_qa ################################################ charts: @@ -11,11 +11,11 @@ charts: enabled: true global: awsS3Location: prod.thecombine.app - combineSmtpUsername: "" + captchaRequired: false + emailEnabled: false imagePullPolicy: Always frontend: configOffline: true - configEmailEnabled: false maintenance: localLangList: - "ar" diff --git a/deploy/scripts/setup_files/profiles/prod.yaml b/deploy/scripts/setup_files/profiles/prod.yaml index 2e45809325..f293e076d6 100644 --- a/deploy/scripts/setup_files/profiles/prod.yaml +++ b/deploy/scripts/setup_files/profiles/prod.yaml @@ -13,8 +13,9 @@ charts: enabled: false # Frontend configuration items: frontend: - configShowCertExpiration: false configAnalyticsWriteKey: "j9EeK4oURluRSIKbaXCBKBxGCnT2WahB" + configCaptchaSiteKey: "0x4AAAAAAAe9zmM2ysXGSJk1" # Turnstile site key + configShowCertExpiration: false # Maintenance configuration items maintenance: ####################################### @@ -29,6 +30,8 @@ charts: updateFontsSchedule: "15 02 * * 0" global: awsS3Location: prod.thecombine.app + captchaRequired: true + emailEnabled: true fontStorageAccessMode: ReadWriteMany imagePullPolicy: Always pullSecretName: None diff --git a/deploy/scripts/setup_files/profiles/staging.yaml b/deploy/scripts/setup_files/profiles/staging.yaml index 6c86d40934..0243891114 100644 --- a/deploy/scripts/setup_files/profiles/staging.yaml +++ b/deploy/scripts/setup_files/profiles/staging.yaml @@ -2,17 +2,20 @@ ################################################ # Profile specific configuration items # -# Profile: prod +# Profile: staging ################################################ charts: thecombine: # Frontend configuration items: frontend: - configShowCertExpiration: false configAnalyticsWriteKey: "AoebaDJNjSlOMRUH87EaNjvwkQpfLoyy" + configCaptchaSiteKey: "0x4AAAAAAAe9zmM2ysXGSJk1" # Turnstile site key + configShowCertExpiration: false global: awsS3Location: prod.thecombine.app + captchaRequired: true + emailEnabled: true fontStorageAccessMode: ReadWriteMany imagePullPolicy: Always includeResourceLimits: true diff --git a/docs/deploy/README.md b/docs/deploy/README.md index b9ee813630..bff173ffeb 100644 --- a/docs/deploy/README.md +++ b/docs/deploy/README.md @@ -285,6 +285,7 @@ The setup scripts require the following environment variables to be set: - AWS_DEFAULT_REGION - AWS_ACCESS_KEY_ID - AWS_SECRET_ACCESS_KEY +- COMBINE_CAPTCHA_SECRET_KEY - COMBINE_JWT_SECRET_KEY - COMBINE_SMTP_USERNAME - COMBINE_SMTP_PASSWORD diff --git a/docs/user_guide/assets/licenses/frontend_licenses.txt b/docs/user_guide/assets/licenses/frontend_licenses.txt index 34ebc6a2ab..55537ee183 100644 --- a/docs/user_guide/assets/licenses/frontend_licenses.txt +++ b/docs/user_guide/assets/licenses/frontend_licenses.txt @@ -972,29 +972,8 @@ The above copyright notice and this permission notice shall be included in all c THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -@matt-block/react-recaptcha-v2 2.0.1 +@marsidev/react-turnstile 0.7.2 MIT -MIT License - -Copyright (c) 2018-present Matei Bogdan Radu - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. @microsoft/signalr 8.0.0 @@ -43055,30 +43034,6 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -nanoid 3.3.7 -MIT -The MIT License (MIT) - -Copyright 2017 Andrey Sitnik - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - new-date 1.0.3 null Copyright (c) 2016 Segment.io, Inc. diff --git a/nginx/init/25-combine-runtime-config.sh b/nginx/init/25-combine-runtime-config.sh index 0ccf48111e..62fa83e8e1 100755 --- a/nginx/init/25-combine-runtime-config.sh +++ b/nginx/init/25-combine-runtime-config.sh @@ -49,7 +49,7 @@ OUTFILE=${FRONTEND_HOST_DIR}/scripts/config.js declare -A env_map env_map=( ["CONFIG_USE_CONNECTION_URL"]="useConnectionBaseUrlForApi" - ["CONFIG_CAPTCHA_REQD"]="captchaRequired" + ["CONFIG_CAPTCHA_REQUIRED"]="captchaRequired" ["CONFIG_CAPTCHA_SITE_KEY"]="captchaSiteKey" ["CONFIG_ANALYTICS_WRITE_KEY"]="analyticsWriteKey" ["CONFIG_OFFLINE"]="offline" diff --git a/package-lock.json b/package-lock.json index ddf7e4f281..f0f734f1ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", "@loadable/component": "^5.16.3", - "@matt-block/react-recaptcha-v2": "^2.0.1", + "@marsidev/react-turnstile": "^0.7.2", "@microsoft/signalr": "^8.0.0", "@mui/icons-material": "^5.15.7", "@mui/material": "^5.14.16", @@ -4330,15 +4330,13 @@ "node": ">=8" } }, - "node_modules/@matt-block/react-recaptcha-v2": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@matt-block/react-recaptcha-v2/-/react-recaptcha-v2-2.0.1.tgz", - "integrity": "sha512-nQ1DjdjmfeG5dcKwqprfgBMdBO1MYlFcB4LtfMDsw8kmuxVuRsiVlAHsmARirmGutJ9zKQpvcYZqy2HbIoAH5w==", - "dependencies": { - "nanoid": "^3.3.4" - }, + "node_modules/@marsidev/react-turnstile": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@marsidev/react-turnstile/-/react-turnstile-0.7.2.tgz", + "integrity": "sha512-0jwLvAUkcLkaYaS6jBOZB3zzUiKi5dU3kZtlaeBX6yV7Y4CbFEtfHCY352ovphNz1v0ZjpOj6+3QUczJvD56VA==", "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + "react": ">=16.8.0", + "react-dom": ">=16.8.0" } }, "node_modules/@microsoft/signalr": { @@ -22035,6 +22033,7 @@ "version": "3.3.7", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "dev": true, "funding": [ { "type": "github", diff --git a/package.json b/package.json index 453c8d0ae5..3fde36ede4 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", "@loadable/component": "^5.16.3", - "@matt-block/react-recaptcha-v2": "^2.0.1", + "@marsidev/react-turnstile": "^0.7.2", "@microsoft/signalr": "^8.0.0", "@mui/icons-material": "^5.15.7", "@mui/material": "^5.14.16", diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 69b9b69a0f..bbdd646f39 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -75,6 +75,9 @@ "resetDone": "If you have correctly entered your email or username, a password reset link has been sent to your email address.", "backToLogin": "Back To Login" }, + "captcha": { + "error": "Page verification failed or expired. Please refresh the page." + }, "speakerMenu": { "none": "No speakers in the project. To attach a speaker to audio recordings, please talk to a project administrator.", "other": "[None of the above]" diff --git a/src/api/api/user-api.ts b/src/api/api/user-api.ts index 611cb22647..684e936992 100644 --- a/src/api/api/user-api.ts +++ b/src/api/api/user-api.ts @@ -601,6 +601,51 @@ export const UserApiAxiosParamCreator = function ( ...options.headers, }; + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {string} token + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + verifyCaptchaToken: async ( + token: string, + options: any = {} + ): Promise => { + // verify required parameter 'token' is not null or undefined + assertParamExists("verifyCaptchaToken", "token", token); + const localVarPath = `/v1/users/captcha/{token}`.replace( + `{${"token"}}`, + encodeURIComponent(String(token)) + ); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { + method: "GET", + ...baseOptions, + ...options, + }; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + }; + return { url: toPathString(localVarUrlObj), options: localVarRequestOptions, @@ -884,6 +929,27 @@ export const UserApiFp = function (configuration?: Configuration) { configuration ); }, + /** + * + * @param {string} token + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async verifyCaptchaToken( + token: string, + options?: any + ): Promise< + (axios?: AxiosInstance, basePath?: string) => AxiosPromise + > { + const localVarAxiosArgs = + await localVarAxiosParamCreator.verifyCaptchaToken(token, options); + return createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration + ); + }, }; }; @@ -1039,6 +1105,17 @@ export const UserApiFactory = function ( .validateResetToken(token, options) .then((request) => request(axios, basePath)); }, + /** + * + * @param {string} token + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + verifyCaptchaToken(token: string, options?: any): AxiosPromise { + return localVarFp + .verifyCaptchaToken(token, options) + .then((request) => request(axios, basePath)); + }, }; }; @@ -1189,6 +1266,20 @@ export interface UserApiValidateResetTokenRequest { readonly token: string; } +/** + * Request parameters for verifyCaptchaToken operation in UserApi. + * @export + * @interface UserApiVerifyCaptchaTokenRequest + */ +export interface UserApiVerifyCaptchaTokenRequest { + /** + * + * @type {string} + * @memberof UserApiVerifyCaptchaToken + */ + readonly token: string; +} + /** * UserApi - object-oriented interface * @export @@ -1376,4 +1467,20 @@ export class UserApi extends BaseAPI { .validateResetToken(requestParameters.token, options) .then((request) => request(this.axios, this.basePath)); } + + /** + * + * @param {UserApiVerifyCaptchaTokenRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof UserApi + */ + public verifyCaptchaToken( + requestParameters: UserApiVerifyCaptchaTokenRequest, + options?: any + ) { + return UserApiFp(this.configuration) + .verifyCaptchaToken(requestParameters.token, options) + .then((request) => request(this.axios, this.basePath)); + } } diff --git a/src/backend/index.ts b/src/backend/index.ts index ce63fe05fc..539a97d096 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -45,6 +45,7 @@ const config = new Api.Configuration(config_parameters); * and the blanket error pop ups should be suppressed.*/ const whiteListedErrorUrls = [ "users/authenticate", + "users/captcha", "/speakers/create/", "/speakers/update/", ]; @@ -611,6 +612,13 @@ export async function getProgressEstimationLineChartRoot( /* UserController.cs */ +export async function verifyCaptchaToken(token: string): Promise { + return await userApi + .verifyCaptchaToken({ token }) + .then(() => true) + .catch(() => false); +} + export async function resetPasswordRequest( emailOrUsername: string ): Promise { diff --git a/src/components/App/App.test.tsx b/src/components/App/App.test.tsx index b584a1bdb5..52b24c6cca 100644 --- a/src/components/App/App.test.tsx +++ b/src/components/App/App.test.tsx @@ -7,13 +7,8 @@ import thunk from "redux-thunk"; import { defaultState } from "components/App/DefaultState"; import App from "components/App/component"; -jest.mock( - "@matt-block/react-recaptcha-v2", - () => - function MockRecaptcha() { - return
Recaptcha
; - } -); +jest.mock("react-router-dom"); + jest.mock("components/AnnouncementBanner/AnnouncementBanner", () => "div"); const createMockStore = configureMockStore([thunk]); diff --git a/src/components/Login/Captcha.tsx b/src/components/Login/Captcha.tsx index d50912cf72..8f3de5f5ed 100644 --- a/src/components/Login/Captcha.tsx +++ b/src/components/Login/Captcha.tsx @@ -1,27 +1,56 @@ -import ReCaptcha from "@matt-block/react-recaptcha-v2"; -import { Fragment, ReactElement } from "react"; +import { Turnstile } from "@marsidev/react-turnstile"; +import { Fragment, type ReactElement, useEffect, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "react-toastify"; +import { verifyCaptchaToken } from "backend"; +import i18n from "i18n"; import { RuntimeConfig } from "types/runtimeConfig"; -interface CaptchaProps { - onExpire: () => void; - onSuccess: () => void; +export interface CaptchaProps { + /** Parent function to call when CAPTCHA succeeds or fails. */ + setSuccess: (success: boolean) => void; } +/** Component wrapper for CAPTCHA implementation. */ export default function Captcha(props: CaptchaProps): ReactElement { - return RuntimeConfig.getInstance().captchaRequired() ? ( -
- - console.error("Something went wrong; check your connection.") - } - onExpire={props.onExpire} - onSuccess={props.onSuccess} - siteKey={RuntimeConfig.getInstance().captchaSiteKey()} - size="normal" - theme="light" - /> -
+ const setSuccess = props.setSuccess; + const isRequired = useRef(RuntimeConfig.getInstance().captchaRequired()); + const { t } = useTranslation(); + + useEffect(() => { + setSuccess(!isRequired.current); + }, [isRequired, setSuccess]); + + const siteKey = + process.env.NODE_ENV === "production" + ? RuntimeConfig.getInstance().captchaSiteKey() + : // https://developers.cloudflare.com/turnstile/troubleshooting/testing/ + // has dummy site keys for development and testing; options are + // invisible pass, invisible fail, visible pass, visible fail, forced interaction + "1x00000000000000000000AA"; // visible pass + + const fail = (): void => { + setSuccess(false); + toast.error(t("captcha.error")); + }; + const succeed = (): void => { + setSuccess(true); + }; + const verify = (token: string): void => { + verifyCaptchaToken(token).then((isVerified) => + isVerified ? succeed() : fail() + ); + }; + + return isRequired.current ? ( + ) : ( ); diff --git a/src/components/Login/Login.tsx b/src/components/Login/Login.tsx index a185fa7393..f093471b52 100644 --- a/src/components/Login/Login.tsx +++ b/src/components/Login/Login.tsx @@ -51,9 +51,7 @@ export default function Login(): ReactElement { ); const [banner, setBanner] = useState(""); - const [isVerified, setIsVerified] = useState( - !RuntimeConfig.getInstance().captchaRequired() - ); + const [isVerified, setIsVerified] = useState(false); const [password, setPassword] = useState(""); const [passwordError, setPasswordError] = useState(false); const [username, setUsername] = useState(""); @@ -153,10 +151,7 @@ export default function Login(): ReactElement { )} - setIsVerified(false)} - onSuccess={() => setIsVerified(true)} - /> + {/* User Guide, Sign Up, and Log In buttons */} diff --git a/src/components/Login/Signup.tsx b/src/components/Login/Signup.tsx index fb8bd44333..b25c6cc015 100644 --- a/src/components/Login/Signup.tsx +++ b/src/components/Login/Signup.tsx @@ -25,7 +25,6 @@ import { useAppDispatch, useAppSelector } from "rootRedux/hooks"; import { type StoreState } from "rootRedux/types"; import router from "router/browserRouter"; import { Path } from "types/path"; -import { RuntimeConfig } from "types/runtimeConfig"; import { meetsPasswordRequirements, meetsUsernameRequirements, @@ -87,9 +86,7 @@ export default function Signup(props: SignupProps): ReactElement { const [fieldError, setFieldError] = useState(defaultSignupError); const [fieldText, setFieldText] = useState(defaultSignupText); - const [isVerified, setIsVerified] = useState( - !RuntimeConfig.getInstance().captchaRequired() - ); + const [isVerified, setIsVerified] = useState(false); const { t } = useTranslation(); @@ -257,10 +254,7 @@ export default function Signup(props: SignupProps): ReactElement { )} - setIsVerified(false)} - onSuccess={() => setIsVerified(true)} - /> + {/* Sign Up and Log In buttons */} diff --git a/src/components/Login/tests/Login.test.tsx b/src/components/Login/tests/Login.test.tsx index 8f14957ee0..03c89992fb 100644 --- a/src/components/Login/tests/Login.test.tsx +++ b/src/components/Login/tests/Login.test.tsx @@ -10,20 +10,13 @@ import configureMockStore from "redux-mock-store"; import Login, { LoginId } from "components/Login/Login"; import { defaultState as loginState } from "components/Login/Redux/LoginReduxTypes"; -jest.mock( - "@matt-block/react-recaptcha-v2", - () => - function MockRecaptcha() { - return
Recaptcha
; - } -); jest.mock("backend", () => ({ getBannerText: () => Promise.resolve(""), })); +jest.mock("components/Login/Captcha", () => "div"); jest.mock("components/Login/Redux/LoginActions", () => ({ asyncLogIn: (...args: any[]) => mockAsyncLogIn(...args), })); -jest.mock("router/browserRouter"); jest.mock("rootRedux/hooks", () => { return { ...jest.requireActual("rootRedux/hooks"), diff --git a/src/components/Login/tests/MockCaptcha.tsx b/src/components/Login/tests/MockCaptcha.tsx new file mode 100644 index 0000000000..aa3dbc89e8 --- /dev/null +++ b/src/components/Login/tests/MockCaptcha.tsx @@ -0,0 +1,11 @@ +import { type ReactElement, useEffect } from "react"; + +import { type CaptchaProps } from "components/Login/Captcha"; + +/** Mock CAPTCHA component that automatically succeeds. */ +export default function MockCaptcha(props: CaptchaProps): ReactElement { + useEffect(() => { + props.setSuccess(true); + }, [props]); + return
; +} diff --git a/src/components/Login/tests/Signup.test.tsx b/src/components/Login/tests/Signup.test.tsx index 15aa9ec0b6..45d53bada6 100644 --- a/src/components/Login/tests/Signup.test.tsx +++ b/src/components/Login/tests/Signup.test.tsx @@ -11,20 +11,13 @@ import configureMockStore from "redux-mock-store"; import { defaultState as loginState } from "components/Login/Redux/LoginReduxTypes"; import Signup, { SignupId } from "components/Login/Signup"; -jest.mock( - "@matt-block/react-recaptcha-v2", - () => - function MockRecaptcha() { - return
Recaptcha
; - } -); jest.mock("backend", () => ({ getBannerText: () => Promise.resolve(""), })); +jest.mock("components/Login/Captcha", () => "div"); jest.mock("components/Login/Redux/LoginActions", () => ({ asyncSignUp: (...args: any[]) => mockAsyncSignUp(...args), })); -jest.mock("router/browserRouter"); jest.mock("rootRedux/hooks", () => { return { ...jest.requireActual("rootRedux/hooks"), diff --git a/src/components/PasswordReset/Request.tsx b/src/components/PasswordReset/Request.tsx index ed571db5cc..763b0eb7f0 100644 --- a/src/components/PasswordReset/Request.tsx +++ b/src/components/PasswordReset/Request.tsx @@ -5,6 +5,7 @@ import { useNavigate } from "react-router-dom"; import { resetPasswordRequest } from "backend"; import { LoadingDoneButton } from "components/Buttons"; +import Captcha from "components/Login/Captcha"; import { Path } from "types/path"; export enum PasswordRequestIds { @@ -18,6 +19,7 @@ export default function ResetRequest(): ReactElement { const [isDone, setIsDone] = useState(false); const [isError, setIsError] = useState(false); const [isLoading, setIsLoading] = useState(false); + const [isVerified, setIsVerified] = useState(false); const { t } = useTranslation(); const navigate = useNavigate(); @@ -80,6 +82,9 @@ export default function ResetRequest(): ReactElement { variant="outlined" /> + + + onSubmit, variant: "contained", }} - disabled={!emailOrUsername} + disabled={!emailOrUsername || !isVerified} loading={isLoading} > {t("passwordReset.submit")} diff --git a/src/components/PasswordReset/tests/Request.test.tsx b/src/components/PasswordReset/tests/Request.test.tsx index 693ac4e883..894b2bccca 100644 --- a/src/components/PasswordReset/tests/Request.test.tsx +++ b/src/components/PasswordReset/tests/Request.test.tsx @@ -4,6 +4,7 @@ import userEvent from "@testing-library/user-event"; import { act } from "react"; import MockBypassLoadableButton from "components/Buttons/LoadingDoneButton"; +import MockCaptcha from "components/Login/tests/MockCaptcha"; import ResetRequest, { PasswordRequestIds, } from "components/PasswordReset/Request"; @@ -18,6 +19,7 @@ jest.mock("backend", () => ({ jest.mock("components/Buttons", () => ({ LoadingDoneButton: MockBypassLoadableButton, })); +jest.mock("components/Login/Captcha", () => MockCaptcha); const mockResetPasswordRequest = jest.fn(); diff --git a/src/goals/CharacterInventory/Redux/tests/CharacterInventoryActions.test.tsx b/src/goals/CharacterInventory/Redux/tests/CharacterInventoryActions.test.tsx index e2e323e03c..9e59de3ea9 100644 --- a/src/goals/CharacterInventory/Redux/tests/CharacterInventoryActions.test.tsx +++ b/src/goals/CharacterInventory/Redux/tests/CharacterInventoryActions.test.tsx @@ -39,7 +39,6 @@ jest.mock("goals/Redux/GoalActions", () => ({ addCharInvChangesToGoal: (changes: CharInvChanges) => mockAddCharInvChangesToGoal(changes), })); -jest.mock("router/browserRouter"); const mockAddCharInvChangesToGoal = jest.fn(); const mockAsyncUpdateCurrentProject = jest.fn(); diff --git a/src/setupTests.js b/src/setupTests.js index 4c546243b2..4acd208210 100644 --- a/src/setupTests.js +++ b/src/setupTests.js @@ -1,6 +1,6 @@ import "tests/reactI18nextMock"; -// Force tests to fail on console.error and console.warn. +// Force tests to fail on console.error and console.warn global.console.error = (message) => { throw message; }; @@ -13,3 +13,6 @@ jest .spyOn(window.HTMLMediaElement.prototype, "pause") .mockImplementation(() => {}); jest.mock("components/Pronunciations/Recorder"); + +// Mock the router to short circuit a circular dependency +jest.mock("router/browserRouter", () => ({ navigate: jest.fn() })); diff --git a/src/types/runtimeConfig.ts b/src/types/runtimeConfig.ts index 562b5bce17..27369c2b6e 100644 --- a/src/types/runtimeConfig.ts +++ b/src/types/runtimeConfig.ts @@ -17,7 +17,7 @@ declare global { const defaultConfig: RuntimeConfigItems = { baseUrl: "http://localhost:5000", captchaRequired: true, - captchaSiteKey: "6Le6BL0UAAAAAMjSs1nINeB5hqDZ4m3mMg3k67x3", + captchaSiteKey: "0x4AAAAAAAe9zmM2ysXGSJk1", offline: false, emailServicesEnabled: true, showCertExpiration: true, @@ -56,13 +56,6 @@ export class RuntimeConfig { return "v0.0.0-default.0"; } - public captchaSiteKey(): string { - if (window.runtimeConfig.hasOwnProperty("captchaSiteKey")) { - return window.runtimeConfig.captchaSiteKey; - } - return defaultConfig.captchaSiteKey; - } - public captchaRequired(): boolean { if (window.runtimeConfig.hasOwnProperty("captchaRequired")) { return window.runtimeConfig.captchaRequired; @@ -70,6 +63,13 @@ export class RuntimeConfig { return defaultConfig.captchaRequired; } + public captchaSiteKey(): string { + if (window.runtimeConfig.hasOwnProperty("captchaSiteKey")) { + return window.runtimeConfig.captchaSiteKey; + } + return defaultConfig.captchaSiteKey; + } + public emailServicesEnabled(): boolean { if (RuntimeConfig._instance.isOffline()) { return false;