diff --git a/JobFlow.API/Controllers/AuthController.cs b/JobFlow.API/Controllers/AuthController.cs index 7341444..9140a19 100644 --- a/JobFlow.API/Controllers/AuthController.cs +++ b/JobFlow.API/Controllers/AuthController.cs @@ -29,6 +29,7 @@ public AuthController( // ============================================================ [HttpPost] [Route("login-with-firebase")] + [AllowAnonymous] public async Task LoginWithFirebase([FromBody] TokenDto model) { try diff --git a/JobFlow.API/Controllers/EmailController.cs b/JobFlow.API/Controllers/EmailController.cs index d85170f..86da4df 100644 --- a/JobFlow.API/Controllers/EmailController.cs +++ b/JobFlow.API/Controllers/EmailController.cs @@ -1,11 +1,13 @@ using JobFlow.Business.Models; using JobFlow.Business.Services.ServiceInterfaces; -using JobFlow.Infrastructure.ExternalServices.ReCAPTCHA; +using JobFlow.Infrastructure.ExternalServices.Turnstile; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace JobFlow.API.Controllers; [ApiController] +[AllowAnonymous] [Route("api/email/")] public class EmailController : ControllerBase { @@ -14,10 +16,25 @@ public class EmailController : ControllerBase public async Task SubscribeToNewsletter( [FromBody] NewsletterSubscriptionRequest request, [FromServices] IBrevoService brevoService, - [FromServices] IReCAPTCHAService reCAPTCHAService) + [FromServices] ICaptchaVerificationService captchaService, + CancellationToken cancellationToken) { - var isHuman = await reCAPTCHAService.VerifyTokenAsync(request.CaptchaToken); - if (!isHuman) return BadRequest("reCaptcha validation failed."); + var remoteIp = HttpContext.Connection.RemoteIpAddress?.ToString(); + + var verification = await captchaService.VerifyAsync( + request.CaptchaToken, + "newsletter-subscribe", + remoteIp, + cancellationToken); + + if (!verification.IsValid) + { + return BadRequest(new + { + message = "Turnstile validation failed.", + errors = verification.ErrorCodes + }); + } var success = await brevoService.AddContactAsync(request.Email, request.ListId); return success @@ -30,10 +47,25 @@ public async Task SubscribeToNewsletter( public async Task SendContactForm( [FromBody] ContactFormRequest request, [FromServices] IBrevoService brevoService, - [FromServices] IReCAPTCHAService reCAPTCHAService) + [FromServices] ICaptchaVerificationService captchaService, + CancellationToken cancellationToken) { - var isHuman = await reCAPTCHAService.VerifyTokenAsync(request.CaptchaToken); - if (!isHuman) return BadRequest("reCaptcha validation failed."); + var remoteIp = HttpContext.Connection.RemoteIpAddress?.ToString(); + + var verification = await captchaService.VerifyAsync( + request.CaptchaToken, + "contact-sales", + remoteIp, + cancellationToken); + + if (!verification.IsValid) + { + return BadRequest(new + { + message = "Turnstile validation failed.", + errors = verification.ErrorCodes + }); + } var success = await brevoService.SendContactEmailAsync(request); return success ? Ok(new { message = "Contact form submitted." }) diff --git a/JobFlow.API/JobFlow.API.csproj b/JobFlow.API/JobFlow.API.csproj index 001b617..2661e8f 100644 --- a/JobFlow.API/JobFlow.API.csproj +++ b/JobFlow.API/JobFlow.API.csproj @@ -49,6 +49,9 @@ + + + diff --git a/JobFlow.API/JobFlow.Business/Services/EstimateService.cs b/JobFlow.API/JobFlow.Business/Services/EstimateService.cs new file mode 100644 index 0000000..e69de29 diff --git a/JobFlow.API/JobFlow.Infrastructure/ExternalServices/Turnstile/CaptchaVerificationResult.cs b/JobFlow.API/JobFlow.Infrastructure/ExternalServices/Turnstile/CaptchaVerificationResult.cs new file mode 100644 index 0000000..932445e --- /dev/null +++ b/JobFlow.API/JobFlow.Infrastructure/ExternalServices/Turnstile/CaptchaVerificationResult.cs @@ -0,0 +1,9 @@ +namespace JobFlow.Infrastructure.ExternalServices.Turnstile; + +public sealed class CaptchaVerificationResult +{ + public bool IsValid { get; init; } + public string[] ErrorCodes { get; init; } = Array.Empty(); + public string? Action { get; init; } + public string? Hostname { get; init; } +} diff --git a/JobFlow.API/JobFlow.Infrastructure/ExternalServices/Turnstile/ICaptchaVerificationService.cs b/JobFlow.API/JobFlow.Infrastructure/ExternalServices/Turnstile/ICaptchaVerificationService.cs new file mode 100644 index 0000000..9bad494 --- /dev/null +++ b/JobFlow.API/JobFlow.Infrastructure/ExternalServices/Turnstile/ICaptchaVerificationService.cs @@ -0,0 +1,10 @@ +namespace JobFlow.Infrastructure.ExternalServices.Turnstile; + +public interface ICaptchaVerificationService +{ + Task VerifyAsync( + string token, + string expectedAction, + string? remoteIp, + CancellationToken cancellationToken = default); +} diff --git a/JobFlow.API/JobFlow.Infrastructure/ExternalServices/Turnstile/TurnstileOptions.cs b/JobFlow.API/JobFlow.Infrastructure/ExternalServices/Turnstile/TurnstileOptions.cs new file mode 100644 index 0000000..1c19b13 --- /dev/null +++ b/JobFlow.API/JobFlow.Infrastructure/ExternalServices/Turnstile/TurnstileOptions.cs @@ -0,0 +1,7 @@ +namespace JobFlow.Infrastructure.ExternalServices.Turnstile; + +public sealed class TurnstileOptions +{ + public string SecretKey { get; set; } = string.Empty; + public string ExpectedHostname { get; set; } = string.Empty; +} diff --git a/JobFlow.API/JobFlow.Infrastructure/ExternalServices/Turnstile/TurnstileVerificationService.cs b/JobFlow.API/JobFlow.Infrastructure/ExternalServices/Turnstile/TurnstileVerificationService.cs new file mode 100644 index 0000000..b9a065c --- /dev/null +++ b/JobFlow.API/JobFlow.Infrastructure/ExternalServices/Turnstile/TurnstileVerificationService.cs @@ -0,0 +1,114 @@ +using System.Net.Http.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Options; + +namespace JobFlow.Infrastructure.ExternalServices.Turnstile; + +public sealed class TurnstileVerificationService : ICaptchaVerificationService +{ + private const string VerifyUrl = "https://challenges.cloudflare.com/turnstile/v0/siteverify"; + private readonly HttpClient _httpClient; + private readonly TurnstileOptions _options; + + public TurnstileVerificationService(HttpClient httpClient, IOptions options) + { + _httpClient = httpClient; + _options = options.Value; + } + + public async Task VerifyAsync( + string token, + string expectedAction, + string? remoteIp, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(token) || string.IsNullOrWhiteSpace(_options.SecretKey)) + { + return new CaptchaVerificationResult + { + IsValid = false, + ErrorCodes = ["missing-input"] + }; + } + + var form = new Dictionary + { + ["secret"] = _options.SecretKey, + ["response"] = token + }; + + if (!string.IsNullOrWhiteSpace(remoteIp)) + form["remoteip"] = remoteIp; + + using var content = new FormUrlEncodedContent(form); + using var response = await _httpClient.PostAsync(VerifyUrl, content, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + return new CaptchaVerificationResult + { + IsValid = false, + ErrorCodes = ["verification-http-failure"] + }; + } + + var payload = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); + + if (payload is null || !payload.Success) + { + return new CaptchaVerificationResult + { + IsValid = false, + ErrorCodes = payload?.ErrorCodes ?? Array.Empty(), + Action = payload?.Action, + Hostname = payload?.Hostname + }; + } + + if (!string.Equals(payload.Action, expectedAction, StringComparison.Ordinal)) + { + return new CaptchaVerificationResult + { + IsValid = false, + ErrorCodes = ["action-mismatch"], + Action = payload.Action, + Hostname = payload.Hostname + }; + } + + if (!string.IsNullOrWhiteSpace(_options.ExpectedHostname) && + !string.Equals(payload.Hostname, _options.ExpectedHostname, StringComparison.OrdinalIgnoreCase)) + { + return new CaptchaVerificationResult + { + IsValid = false, + ErrorCodes = ["hostname-mismatch"], + Action = payload.Action, + Hostname = payload.Hostname + }; + } + + return new CaptchaVerificationResult + { + IsValid = true, + ErrorCodes = payload.ErrorCodes ?? Array.Empty(), + Action = payload.Action, + Hostname = payload.Hostname + }; + } + + private sealed class TurnstileVerifyResponse + { + [JsonPropertyName("success")] + public bool Success { get; set; } + + [JsonPropertyName("error-codes")] + public string[]? ErrorCodes { get; set; } + + [JsonPropertyName("action")] + public string? Action { get; set; } + + [JsonPropertyName("hostname")] + public string? Hostname { get; set; } + } +} diff --git a/JobFlow.API/Program.cs b/JobFlow.API/Program.cs index 991b272..30e4a2b 100644 --- a/JobFlow.API/Program.cs +++ b/JobFlow.API/Program.cs @@ -270,10 +270,12 @@ options.ApiKey = builder.Configuration["BrevoSettings-ApiKey"] ?? ""; }); -builder.Services.Configure(options => -{ - options.SecretKey = builder.Configuration["reCAPTCHA-Api"] ?? ""; -}); +builder.Services.Configure( + builder.Configuration.GetSection("Turnstile")); + +builder.Services.AddHttpClient< + JobFlow.Infrastructure.ExternalServices.Turnstile.ICaptchaVerificationService, + JobFlow.Infrastructure.ExternalServices.Turnstile.TurnstileVerificationService>(); builder.Services.Configure(options => { @@ -291,7 +293,6 @@ builder.Services.AddSingleton(sp => sp.GetRequiredService>().Value); builder.Services.AddSingleton(sp => sp.GetRequiredService>().Value); builder.Services.AddSingleton(sp => sp.GetRequiredService>().Value); -builder.Services.AddSingleton(sp => sp.GetRequiredService>().Value); builder.Services.AddSingleton(sp => sp.GetRequiredService>().Value); // ============================================================ diff --git a/JobFlow.API/appsettings.Development.json b/JobFlow.API/appsettings.Development.json index 69f099b..86f227f 100644 --- a/JobFlow.API/appsettings.Development.json +++ b/JobFlow.API/appsettings.Development.json @@ -18,5 +18,9 @@ }, "Backend": { "BaseUrl": "https://localhost:44398" + }, + "Turnstile": { + "SecretKey": "YOUR_TURNSTILE_SECRET", + "ExpectedHostname": "localhost" } } diff --git a/JobFlow.API/appsettings.json b/JobFlow.API/appsettings.json index f558e70..a9c4094 100644 --- a/JobFlow.API/appsettings.json +++ b/JobFlow.API/appsettings.json @@ -13,6 +13,9 @@ }, "Backend": { "BaseUrl": "https://api.gojobflow.com" + }, + "Turnstile": { + "ExpectedHostname": "gojobflow.com" } } diff --git a/JobFlow.Business/Services/EstimateService.cs b/JobFlow.Business/Services/EstimateService.cs index 13f1558..7508443 100644 --- a/JobFlow.Business/Services/EstimateService.cs +++ b/JobFlow.Business/Services/EstimateService.cs @@ -16,15 +16,21 @@ public class EstimateService : IEstimateService private readonly IUnitOfWork unitOfWork; private readonly INotificationService notificationService; private readonly IPdfGenerator pdfGenerator; + private readonly IOrganizationClientPortalService clientPortalService; private readonly IRepository estimates; private readonly IRepository clients; - public EstimateService(IUnitOfWork unitOfWork, INotificationService notificationService, IPdfGenerator pdfGenerator) + public EstimateService( + IUnitOfWork unitOfWork, + INotificationService notificationService, + IPdfGenerator pdfGenerator, + IOrganizationClientPortalService clientPortalService) { this.unitOfWork = unitOfWork; this.notificationService = notificationService; this.pdfGenerator = pdfGenerator; + this.clientPortalService = clientPortalService; estimates = unitOfWork.RepositoryOf(); clients = unitOfWork.RepositoryOf(); @@ -173,7 +179,7 @@ public async Task> SendAsync(Guid id, SendEstimateRequest re estimates.Update(estimate); await unitOfWork.SaveChangesAsync(); - await notificationService.SendClientEstimateSentNotificationAsync(client, estimate); + await clientPortalService.SendMagicLinkAsync(estimate.OrganizationId, client.Id, client.EmailAddress ?? string.Empty); var full = await estimates.Query() .Include(x => x.LineItems) diff --git a/JobFlow.Infrastructure/Middleware/FirebaseAuthMiddleware.cs b/JobFlow.Infrastructure/Middleware/FirebaseAuthMiddleware.cs index 7ee2d4f..c4de7d2 100644 --- a/JobFlow.Infrastructure/Middleware/FirebaseAuthMiddleware.cs +++ b/JobFlow.Infrastructure/Middleware/FirebaseAuthMiddleware.cs @@ -1,6 +1,7 @@ using System.Security.Claims; using FirebaseAdmin.Auth; using JobFlow.Business.Services.ServiceInterfaces; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; namespace JobFlow.Infrastructure.Middleware; @@ -16,13 +17,22 @@ public FirebaseAuthMiddleware(RequestDelegate next) public async Task Invoke(HttpContext context, IUserService userService) { + var endpoint = context.GetEndpoint(); + if (endpoint?.Metadata.GetMetadata() is not null) + { + await _next(context); + return; + } + var path = context.Request.Path.Value?.ToLower(); - // Skip onboarding endpoints + // Skip endpoints that must be reachable without a Firebase bearer token. if (path != null && (path.StartsWith("/api/organizations/register") || path.StartsWith("/api/organizations/retrieve") || - path.StartsWith("/api/organization/types"))) + path.StartsWith("/api/organization/types") || + path.StartsWith("/api/auth/") || + path.StartsWith("/api/client-hub-auth"))) { await _next(context); return;