From 9cd2e5c2e37a122dafea5895750e04e71e6ab5d3 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Thu, 30 Jul 2026 23:08:36 +0200 Subject: [PATCH] feat: protect auth with Turnstile --- .env.example | 4 ++ .../AuthAndSystemControllerTests.cs | 18 ++++++ JobTrackerApi/Controllers/AuthController.cs | 58 ++++++++++++++++++- docker-compose.yml | 3 + docs/implementation-roadmap.md | 4 +- .../src/components/TurnstileWidget.tsx | 34 +++++++++++ job-tracker-ui/src/views/LoginPage.tsx | 13 ++++- 7 files changed, 127 insertions(+), 7 deletions(-) create mode 100644 job-tracker-ui/src/components/TurnstileWidget.tsx diff --git a/.env.example b/.env.example index 48e9e92..a83279b 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,10 @@ JOBTRACKER_CONNECTION_STRING= AUTH_JWT_KEY=CHANGE_ME_LONG_RANDOM_SECRET AUTH_ADMIN_EMAIL=admin@example.com AUTH_ADMIN_PASSWORD=CHANGE_ME_STRONG_PASSWORD +# Public signup remains closed until explicitly enabled. Configure both Turnstile keys first. +AUTH_ALLOW_REGISTRATION=false +TURNSTILE_SITE_KEY= +TURNSTILE_SECRET_KEY= AUTH_GOOGLE_CLIENT_ID=CHANGE_ME_GOOGLE_CLIENT_ID # Optional: enables the "Continue with Microsoft" sign-in tab (separate from the # MICROSOFT_CLIENT_ID below, which is for Outlook mail linking, not sign-in). diff --git a/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs b/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs index c31a04a..2141d57 100644 --- a/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs +++ b/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs @@ -724,6 +724,24 @@ public sealed class AuthAndSystemControllerTests summarizer.Verify(x => x.RunProbeAsync(It.IsAny()), Times.Once); } + [Fact] + public async Task Login_rejects_missing_turnstile_token_when_configured() + { + var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["Turnstile:SiteKey"] = "site-key", + ["Turnstile:SecretKey"] = "secret-key", + }).Build(); + var users = CreateUserManager(); + var controller = new AuthController(config, users.Object, Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), NullLogger.Instance, Mock.Of(), TestHostFactory.CreateInMemoryDb(), httpClients: Mock.Of()); + + var result = await controller.Login(new AuthController.LoginRequest("person@example.com", "password"), CancellationToken.None); + + var badRequest = Assert.IsType(result); + Assert.Equal("Security verification failed. Please try again.", badRequest.Value); + users.Verify(x => x.FindByEmailAsync(It.IsAny()), Times.Never); + } + private static IConfiguration BuildConfig() { return new ConfigurationBuilder() diff --git a/JobTrackerApi/Controllers/AuthController.cs b/JobTrackerApi/Controllers/AuthController.cs index 0965ab9..3d3e58f 100644 --- a/JobTrackerApi/Controllers/AuthController.cs +++ b/JobTrackerApi/Controllers/AuthController.cs @@ -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 users, ITokenService tokens, IAppEmailSender email, IGoogleTokenValidator googleTokens, IMicrosoftTokenValidator microsoftTokens, ILogger logger, ITwoFactorPendingTokenService twoFactorPending, JobTrackerContext db, IHostEnvironment? environment = null) + public AuthController(IConfiguration cfg, UserManager users, ITokenService tokens, IAppEmailSender email, IGoogleTokenValidator googleTokens, IMicrosoftTokenValidator microsoftTokens, ILogger 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 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 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 + { + ["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(), + Plan: "free", + Entitlements: BuildEntitlements(Array.Empty()), 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 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 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, diff --git a/docker-compose.yml b/docker-compose.yml index 1c4a51f..956613a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,9 @@ services: - Auth__JwtKey=${AUTH_JWT_KEY} - Auth__AdminEmail=${AUTH_ADMIN_EMAIL} - Auth__AdminPassword=${AUTH_ADMIN_PASSWORD} + - Auth__AllowRegistration=${AUTH_ALLOW_REGISTRATION:-false} + - Turnstile__SiteKey=${TURNSTILE_SITE_KEY} + - Turnstile__SecretKey=${TURNSTILE_SECRET_KEY} # Optional: allow Google / Microsoft ID-token bearer auth (sign-in, not mail access) - Auth__GoogleClientId=${AUTH_GOOGLE_CLIENT_ID} - Auth__MicrosoftClientId=${AUTH_MICROSOFT_CLIENT_ID} diff --git a/docs/implementation-roadmap.md b/docs/implementation-roadmap.md index 5a2d376..9388073 100644 --- a/docs/implementation-roadmap.md +++ b/docs/implementation-roadmap.md @@ -189,8 +189,8 @@ Goal: commercialise. Last, per the guide's "do not over-engineer before needed. | # | Task | Priority | Difficulty | Dependencies | Expected value | |---|---|---|---|---|---| -| 7.1 | **Open registration + CAPTCHA** | **P2** | **M** | 2.4, 7.3 | Registration is 403 by default; **no CAPTCHA exists** (verified). Rate limiting alone is not enough for public signup. | -| 7.2 | **Plan / tier / entitlement model — capability flags (`advancedAi`, `premiumThemes`, `automation`, `analytics`, `storageBytes`), not counters. | **P3** | **M** | none | No concept of a plan exists anywhere. Shape it around the decided split so the free tier stays genuinely useful. | +| 7.1 | **IMPLEMENTED; configuration required** — password signup and sign-in use Cloudflare Turnstile with mandatory server-side Siteverify validation when keys are configured. Registration remains closed until `AUTH_ALLOW_REGISTRATION=true` and production widget keys are supplied. | **P2** | **M** | 2.4, 7.3 | Safe code path is ready without silently opening public registration. | +| 7.2 | **DONE (2026-07-30)** — existing Identity roles are the plan model: `Premium` (and `Admin`) receives `advancedAi`, `premiumThemes`, `automation`, `analytics`, and 5 GB storage capabilities; free accounts receive core features and 250 MB. `/auth/me` exposes plan and entitlements. | **P3** | **M** | none | Reuses the existing role system and avoids a second billing-state table before Stripe exists. | | 7.3 | **Usage quotas — AI + storage only** | **P3** | **M** | 5.2, 7.2 | **Do not open registration before this lands.** AI and storage are unmetered and unbounded; these are real cost, so they are legitimate limits. Job/CV counts are not. | | 7.4 | **Storage limits + attachment caps** | **P3** | **S** | 7.2 | The "more storage" premium lever. | | 7.5 | **Stripe billing** | **P3** | **L** | 7.2 | Still blocked on **Stripe keys** — the only remaining hard blocker. Tiers are now decided. | diff --git a/job-tracker-ui/src/components/TurnstileWidget.tsx b/job-tracker-ui/src/components/TurnstileWidget.tsx new file mode 100644 index 0000000..2f74360 --- /dev/null +++ b/job-tracker-ui/src/components/TurnstileWidget.tsx @@ -0,0 +1,34 @@ +import { useEffect, useRef } from "react"; +import { Box, Typography } from "@mui/material"; + +type Props = { siteKey: string; action: "login" | "register"; onToken: (token: string) => void; }; + +export default function TurnstileWidget({ siteKey, action, onToken }: Props) { + const ref = useRef(null); + useEffect(() => { + let widgetId: string | undefined; + let cancelled = false; + const render = () => { + if (cancelled || !ref.current || !(window as any).turnstile) return; + widgetId = (window as any).turnstile.render(ref.current, { + sitekey: siteKey, action, theme: "auto", size: "flexible", + callback: onToken, + "expired-callback": () => onToken(""), + "error-callback": () => onToken(""), + }); + }; + const existing = document.querySelector('script[data-turnstile]'); + if ((window as any).turnstile) render(); + else if (existing) existing.addEventListener("load", render, { once: true }); + else { + const script = document.createElement("script"); + script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"; + script.async = true; script.defer = true; script.dataset.turnstile = "true"; + script.addEventListener("load", render, { once: true }); + document.head.appendChild(script); + } + return () => { cancelled = true; if (widgetId && (window as any).turnstile) (window as any).turnstile.remove(widgetId); }; + }, [action, onToken, siteKey]); + + return Security check; +} diff --git a/job-tracker-ui/src/views/LoginPage.tsx b/job-tracker-ui/src/views/LoginPage.tsx index 4073370..97b3a8c 100644 --- a/job-tracker-ui/src/views/LoginPage.tsx +++ b/job-tracker-ui/src/views/LoginPage.tsx @@ -9,6 +9,7 @@ import { getRememberMePref, setAuthPersistencePreference } from "../auth"; import GoogleAuthCard from "../components/GoogleAuthCard"; import MicrosoftAuthCard from "../components/MicrosoftAuthCard"; import TwoFactorChallenge from "../components/TwoFactorChallenge"; +import TurnstileWidget from "../components/TurnstileWidget"; import { useToast } from "../toast"; import { useI18n } from "../i18n/I18nProvider"; @@ -19,6 +20,8 @@ type AuthConfig = { localEnabled: boolean; allowRegistration: boolean; requireEmailVerification: boolean; + turnstileEnabled?: boolean; + turnstileSiteKey?: string; }; export default function LoginPage({ initialMode = "login" }: { initialMode?: "login" | "register" }) { @@ -41,6 +44,7 @@ export default function LoginPage({ initialMode = "login" }: { initialMode?: "lo const [verificationResent, setVerificationResent] = useState(false); const [fieldErrors, setFieldErrors] = useState<{ email?: string; password?: string; confirmPassword?: string }>({}); const [registerMode, setRegisterMode] = useState(initialMode === "register"); + const [turnstileToken, setTurnstileToken] = useState(""); const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; @@ -83,7 +87,8 @@ export default function LoginPage({ initialMode = "login" }: { initialMode?: "lo setVerificationResent(false); try { const url = mode === "register" ? "/auth/register" : "/auth/login"; - const res = await api.post<{ requiresTwoFactor?: boolean; pendingToken?: string }>(url, { email, password, rememberMe }); + const payload = { email, password, rememberMe, ...(cfg?.turnstileEnabled ? { turnstileToken } : {}) }; + const res = await api.post<{ requiresTwoFactor?: boolean; pendingToken?: string }>(url, payload); if (res.data?.requiresTwoFactor && res.data.pendingToken) { setPendingToken(res.data.pendingToken); return; @@ -230,6 +235,10 @@ export default function LoginPage({ initialMode = "login" }: { initialMode?: "lo )} + {cfg?.turnstileEnabled && cfg.turnstileSiteKey ? ( + + ) : null} + {(allowReg || initialMode === "register") && (