Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions JobFlow.API/Controllers/AuthController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public AuthController(
// ============================================================
[HttpPost]
[Route("login-with-firebase")]
[AllowAnonymous]
public async Task<IActionResult> LoginWithFirebase([FromBody] TokenDto model)
{
try
Expand Down
46 changes: 39 additions & 7 deletions JobFlow.API/Controllers/EmailController.cs
Original file line number Diff line number Diff line change
@@ -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
{
Expand All @@ -14,10 +16,25 @@ public class EmailController : ControllerBase
public async Task<IActionResult> 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
Expand All @@ -30,10 +47,25 @@ public async Task<IActionResult> SubscribeToNewsletter(
public async Task<IActionResult> 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." })
Expand Down
3 changes: 3 additions & 0 deletions JobFlow.API/JobFlow.API.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@

<ItemGroup>
<Folder Include=".github\workflows\" />
<Folder Include="JobFlow.Infrastructure\ConfigurationInterfaces\" />
<Folder Include="JobFlow.Infrastructure\ConfigurationModels\" />
<Folder Include="JobFlow.Infrastructure\ExternalServices\ReCAPTCHA\" />
</ItemGroup>

</Project>
Empty file.
Original file line number Diff line number Diff line change
@@ -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<string>();
public string? Action { get; init; }
public string? Hostname { get; init; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace JobFlow.Infrastructure.ExternalServices.Turnstile;

public interface ICaptchaVerificationService
{
Task<CaptchaVerificationResult> VerifyAsync(
string token,
string expectedAction,
string? remoteIp,
CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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<TurnstileOptions> options)
{
_httpClient = httpClient;
_options = options.Value;
}

public async Task<CaptchaVerificationResult> 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<string, string>
{
["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<TurnstileVerifyResponse>(cancellationToken: cancellationToken);

if (payload is null || !payload.Success)
{
return new CaptchaVerificationResult
{
IsValid = false,
ErrorCodes = payload?.ErrorCodes ?? Array.Empty<string>(),
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<string>(),
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; }
}
}
11 changes: 6 additions & 5 deletions JobFlow.API/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -270,10 +270,12 @@
options.ApiKey = builder.Configuration["BrevoSettings-ApiKey"] ?? "";
});

builder.Services.Configure<ReCAPTCHASettings>(options =>
{
options.SecretKey = builder.Configuration["reCAPTCHA-Api"] ?? "";
});
builder.Services.Configure<JobFlow.Infrastructure.ExternalServices.Turnstile.TurnstileOptions>(
builder.Configuration.GetSection("Turnstile"));

builder.Services.AddHttpClient<
JobFlow.Infrastructure.ExternalServices.Turnstile.ICaptchaVerificationService,
JobFlow.Infrastructure.ExternalServices.Turnstile.TurnstileVerificationService>();

builder.Services.Configure<SquareSettings>(options =>
{
Expand All @@ -291,7 +293,6 @@
builder.Services.AddSingleton<ITwilioSettings>(sp => sp.GetRequiredService<IOptions<TwilioSettings>>().Value);
builder.Services.AddSingleton<IStripeSettings>(sp => sp.GetRequiredService<IOptions<StripeSettings>>().Value);
builder.Services.AddSingleton<IBrevoSettings>(sp => sp.GetRequiredService<IOptions<BrevoSettings>>().Value);
builder.Services.AddSingleton<IReCAPTCHASettings>(sp => sp.GetRequiredService<IOptions<ReCAPTCHASettings>>().Value);
builder.Services.AddSingleton<ISquareSettings>(sp => sp.GetRequiredService<IOptions<SquareSettings>>().Value);

// ============================================================
Expand Down
4 changes: 4 additions & 0 deletions JobFlow.API/appsettings.Development.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,9 @@
},
"Backend": {
"BaseUrl": "https://localhost:44398"
},
"Turnstile": {
"SecretKey": "YOUR_TURNSTILE_SECRET",
"ExpectedHostname": "localhost"
}
}
3 changes: 3 additions & 0 deletions JobFlow.API/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
},
"Backend": {
"BaseUrl": "https://api.gojobflow.com"
},
"Turnstile": {
"ExpectedHostname": "gojobflow.com"
}
}

10 changes: 8 additions & 2 deletions JobFlow.Business/Services/EstimateService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Estimate> estimates;
private readonly IRepository<OrganizationClient> 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<Estimate>();
clients = unitOfWork.RepositoryOf<OrganizationClient>();
Expand Down Expand Up @@ -173,7 +179,7 @@ public async Task<Result<EstimateDto>> 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)
Expand Down
14 changes: 12 additions & 2 deletions JobFlow.Infrastructure/Middleware/FirebaseAuthMiddleware.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -16,13 +17,22 @@ public FirebaseAuthMiddleware(RequestDelegate next)

public async Task Invoke(HttpContext context, IUserService userService)
{
var endpoint = context.GetEndpoint();
if (endpoint?.Metadata.GetMetadata<IAllowAnonymous>() 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;
Expand Down
Loading