feat: protect auth with Turnstile
This commit is contained in:
@@ -25,8 +25,9 @@ public sealed class AuthController : ControllerBase
|
||||
private readonly ITwoFactorPendingTokenService _twoFactorPending;
|
||||
private readonly JobTrackerContext _db;
|
||||
private readonly string _avatarDataRoot;
|
||||
private readonly IHttpClientFactory? _httpClients;
|
||||
|
||||
public AuthController(IConfiguration cfg, UserManager<ApplicationUser> users, ITokenService tokens, IAppEmailSender email, IGoogleTokenValidator googleTokens, IMicrosoftTokenValidator microsoftTokens, ILogger<AuthController> logger, ITwoFactorPendingTokenService twoFactorPending, JobTrackerContext db, IHostEnvironment? environment = null)
|
||||
public AuthController(IConfiguration cfg, UserManager<ApplicationUser> users, ITokenService tokens, IAppEmailSender email, IGoogleTokenValidator googleTokens, IMicrosoftTokenValidator microsoftTokens, ILogger<AuthController> logger, ITwoFactorPendingTokenService twoFactorPending, JobTrackerContext db, IHostEnvironment? environment = null, IHttpClientFactory? httpClients = null)
|
||||
{
|
||||
_cfg = cfg;
|
||||
_users = users;
|
||||
@@ -37,6 +38,7 @@ public sealed class AuthController : ControllerBase
|
||||
_logger = logger;
|
||||
_twoFactorPending = twoFactorPending;
|
||||
_db = db;
|
||||
_httpClients = httpClients;
|
||||
_avatarDataRoot = Path.GetFullPath((_cfg["Data:Root"] ?? environment?.ContentRootPath ?? AppContext.BaseDirectory).Trim());
|
||||
}
|
||||
|
||||
@@ -49,6 +51,8 @@ public sealed class AuthController : ControllerBase
|
||||
var microsoftEnabled = !string.IsNullOrWhiteSpace((_cfg["Auth:MicrosoftClientId"] ?? string.Empty).Trim());
|
||||
var allowRegistration = _cfg.GetValue("Auth:AllowRegistration", false);
|
||||
var requireEmailVerification = _cfg.GetValue("Auth:RequireEmailVerification", false);
|
||||
var turnstileSiteKey = (_cfg["Turnstile:SiteKey"] ?? string.Empty).Trim();
|
||||
var turnstileEnabled = turnstileSiteKey.Length > 0 && !string.IsNullOrWhiteSpace(_cfg["Turnstile:SecretKey"]);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
@@ -58,15 +62,18 @@ public sealed class AuthController : ControllerBase
|
||||
localEnabled = true,
|
||||
allowRegistration,
|
||||
requireEmailVerification,
|
||||
turnstileEnabled,
|
||||
turnstileSiteKey = turnstileEnabled ? turnstileSiteKey : null,
|
||||
});
|
||||
}
|
||||
|
||||
public sealed record LoginRequest(string Email, string Password, bool RememberMe = true);
|
||||
public sealed record RegisterRequest(string Email, string Password, bool RememberMe = true);
|
||||
public sealed record LoginRequest(string Email, string Password, bool RememberMe = true, string? TurnstileToken = null);
|
||||
public sealed record RegisterRequest(string Email, string Password, bool RememberMe = true, string? TurnstileToken = null);
|
||||
public sealed record AuthSessionResult(bool Authenticated, string Provider);
|
||||
public sealed record TwoFactorRequiredResult(bool RequiresTwoFactor, string PendingToken);
|
||||
public sealed record GoogleLinkDto(bool Linked, string? Email, DateTimeOffset? LinkedAt);
|
||||
public sealed record MicrosoftLinkDto(bool Linked, string? Email, DateTimeOffset? LinkedAt);
|
||||
public sealed record EntitlementsDto(bool AdvancedAi, bool PremiumThemes, bool Automation, bool Analytics, long StorageBytes);
|
||||
public sealed record MeResult(
|
||||
string Provider,
|
||||
string? Id,
|
||||
@@ -79,6 +86,8 @@ public sealed class AuthController : ControllerBase
|
||||
string? ProfileCvStructureJson,
|
||||
string? AvatarImageDataUrl,
|
||||
IList<string> Roles,
|
||||
string Plan,
|
||||
EntitlementsDto Entitlements,
|
||||
GoogleLinkDto? GoogleLink,
|
||||
MicrosoftLinkDto? MicrosoftLink);
|
||||
private const int MaxAvatarBytes = 1_000_000;
|
||||
@@ -100,6 +109,7 @@ public sealed class AuthController : ControllerBase
|
||||
|
||||
if (email.Length == 0) return BadRequest("Email is required.");
|
||||
if (password.Length == 0) return BadRequest("Password is required.");
|
||||
if (!await VerifyTurnstileAsync(request.TurnstileToken, "login", cancellationToken)) return BadRequest("Security verification failed. Please try again.");
|
||||
|
||||
var user = await _users.FindByEmailAsync(email) ?? await _users.FindByNameAsync(email);
|
||||
if (user is null) return Unauthorized();
|
||||
@@ -141,6 +151,7 @@ public sealed class AuthController : ControllerBase
|
||||
|
||||
if (email.Length == 0) return BadRequest("Email is required.");
|
||||
if (password.Length == 0) return BadRequest("Password is required.");
|
||||
if (!await VerifyTurnstileAsync(request.TurnstileToken, "register", cancellationToken)) return BadRequest("Security verification failed. Please try again.");
|
||||
|
||||
var existing = await _users.FindByEmailAsync(email);
|
||||
if (existing is not null) return BadRequest("User already exists.");
|
||||
@@ -170,6 +181,36 @@ public sealed class AuthController : ControllerBase
|
||||
return await CompleteSignInAsync(user, request.RememberMe, "local", cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<bool> VerifyTurnstileAsync(string? token, string expectedAction, CancellationToken cancellationToken)
|
||||
{
|
||||
var secret = (_cfg["Turnstile:SecretKey"] ?? string.Empty).Trim();
|
||||
var siteKey = (_cfg["Turnstile:SiteKey"] ?? string.Empty).Trim();
|
||||
if (secret.Length == 0 && siteKey.Length == 0) return true;
|
||||
if (secret.Length == 0 || siteKey.Length == 0) return false;
|
||||
if (string.IsNullOrWhiteSpace(token) || token.Length > 2048 || _httpClients is null) return false;
|
||||
|
||||
try
|
||||
{
|
||||
using var content = new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["secret"] = secret,
|
||||
["response"] = token.Trim(),
|
||||
["remoteip"] = HttpContext.Connection.RemoteIpAddress?.ToString() ?? string.Empty,
|
||||
});
|
||||
using var response = await _httpClients.CreateClient().PostAsync("https://challenges.cloudflare.com/turnstile/v0/siteverify", content, cancellationToken);
|
||||
if (!response.IsSuccessStatusCode) return false;
|
||||
using var json = JsonDocument.Parse(await response.Content.ReadAsStreamAsync(cancellationToken));
|
||||
return json.RootElement.TryGetProperty("success", out var success) && success.GetBoolean()
|
||||
&& json.RootElement.TryGetProperty("action", out var action)
|
||||
&& string.Equals(action.GetString(), expectedAction, StringComparison.Ordinal);
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or JsonException or TaskCanceledException)
|
||||
{
|
||||
_logger.LogWarning(ex, "Turnstile verification failed");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("google/exchange")]
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting("auth-login")]
|
||||
@@ -355,6 +396,8 @@ public sealed class AuthController : ControllerBase
|
||||
ProfileCvStructureJson: null,
|
||||
AvatarImageDataUrl: null,
|
||||
Roles: Array.Empty<string>(),
|
||||
Plan: "free",
|
||||
Entitlements: BuildEntitlements(Array.Empty<string>()),
|
||||
GoogleLink: provider == "google" ? new GoogleLinkDto(false, email, null) : null,
|
||||
MicrosoftLink: provider == "microsoft" ? new MicrosoftLinkDto(false, email, null) : null));
|
||||
}
|
||||
@@ -867,8 +910,15 @@ public sealed class AuthController : ControllerBase
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
private static EntitlementsDto BuildEntitlements(IList<string> roles)
|
||||
{
|
||||
var premium = roles.Contains("Premium", StringComparer.OrdinalIgnoreCase) || roles.Contains("Admin", StringComparer.OrdinalIgnoreCase);
|
||||
return new EntitlementsDto(premium, premium, premium, premium, premium ? 5_000_000_000 : 250_000_000);
|
||||
}
|
||||
|
||||
private static MeResult ToMeResult(ApplicationUser user, IList<string> roles)
|
||||
{
|
||||
var entitlements = BuildEntitlements(roles);
|
||||
return new MeResult(
|
||||
Provider: "local",
|
||||
Id: user.Id,
|
||||
@@ -881,6 +931,8 @@ public sealed class AuthController : ControllerBase
|
||||
ProfileCvStructureJson: user.ProfileCvStructureJson,
|
||||
AvatarImageDataUrl: AvatarStorage.Resolve(user.AvatarImageDataUrl),
|
||||
Roles: roles,
|
||||
Plan: entitlements.AdvancedAi ? "premium" : "free",
|
||||
Entitlements: entitlements,
|
||||
GoogleLink: new GoogleLinkDto(
|
||||
Linked: !string.IsNullOrWhiteSpace(user.GoogleSubject),
|
||||
Email: user.GoogleEmail,
|
||||
|
||||
Reference in New Issue
Block a user