feat: protect auth with Turnstile
This commit is contained in:
@@ -16,6 +16,10 @@ JOBTRACKER_CONNECTION_STRING=
|
|||||||
AUTH_JWT_KEY=CHANGE_ME_LONG_RANDOM_SECRET
|
AUTH_JWT_KEY=CHANGE_ME_LONG_RANDOM_SECRET
|
||||||
AUTH_ADMIN_EMAIL=admin@example.com
|
AUTH_ADMIN_EMAIL=admin@example.com
|
||||||
AUTH_ADMIN_PASSWORD=CHANGE_ME_STRONG_PASSWORD
|
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
|
AUTH_GOOGLE_CLIENT_ID=CHANGE_ME_GOOGLE_CLIENT_ID
|
||||||
# Optional: enables the "Continue with Microsoft" sign-in tab (separate from the
|
# 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).
|
# MICROSOFT_CLIENT_ID below, which is for Outlook mail linking, not sign-in).
|
||||||
|
|||||||
@@ -724,6 +724,24 @@ public sealed class AuthAndSystemControllerTests
|
|||||||
summarizer.Verify(x => x.RunProbeAsync(It.IsAny<CancellationToken>()), Times.Once);
|
summarizer.Verify(x => x.RunProbeAsync(It.IsAny<CancellationToken>()), Times.Once);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Login_rejects_missing_turnstile_token_when_configured()
|
||||||
|
{
|
||||||
|
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["Turnstile:SiteKey"] = "site-key",
|
||||||
|
["Turnstile:SecretKey"] = "secret-key",
|
||||||
|
}).Build();
|
||||||
|
var users = CreateUserManager();
|
||||||
|
var controller = new AuthController(config, users.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb(), httpClients: Mock.Of<IHttpClientFactory>());
|
||||||
|
|
||||||
|
var result = await controller.Login(new AuthController.LoginRequest("person@example.com", "password"), CancellationToken.None);
|
||||||
|
|
||||||
|
var badRequest = Assert.IsType<BadRequestObjectResult>(result);
|
||||||
|
Assert.Equal("Security verification failed. Please try again.", badRequest.Value);
|
||||||
|
users.Verify(x => x.FindByEmailAsync(It.IsAny<string>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
private static IConfiguration BuildConfig()
|
private static IConfiguration BuildConfig()
|
||||||
{
|
{
|
||||||
return new ConfigurationBuilder()
|
return new ConfigurationBuilder()
|
||||||
|
|||||||
@@ -25,8 +25,9 @@ public sealed class AuthController : ControllerBase
|
|||||||
private readonly ITwoFactorPendingTokenService _twoFactorPending;
|
private readonly ITwoFactorPendingTokenService _twoFactorPending;
|
||||||
private readonly JobTrackerContext _db;
|
private readonly JobTrackerContext _db;
|
||||||
private readonly string _avatarDataRoot;
|
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;
|
_cfg = cfg;
|
||||||
_users = users;
|
_users = users;
|
||||||
@@ -37,6 +38,7 @@ public sealed class AuthController : ControllerBase
|
|||||||
_logger = logger;
|
_logger = logger;
|
||||||
_twoFactorPending = twoFactorPending;
|
_twoFactorPending = twoFactorPending;
|
||||||
_db = db;
|
_db = db;
|
||||||
|
_httpClients = httpClients;
|
||||||
_avatarDataRoot = Path.GetFullPath((_cfg["Data:Root"] ?? environment?.ContentRootPath ?? AppContext.BaseDirectory).Trim());
|
_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 microsoftEnabled = !string.IsNullOrWhiteSpace((_cfg["Auth:MicrosoftClientId"] ?? string.Empty).Trim());
|
||||||
var allowRegistration = _cfg.GetValue("Auth:AllowRegistration", false);
|
var allowRegistration = _cfg.GetValue("Auth:AllowRegistration", false);
|
||||||
var requireEmailVerification = _cfg.GetValue("Auth:RequireEmailVerification", 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
|
return Ok(new
|
||||||
{
|
{
|
||||||
@@ -58,15 +62,18 @@ public sealed class AuthController : ControllerBase
|
|||||||
localEnabled = true,
|
localEnabled = true,
|
||||||
allowRegistration,
|
allowRegistration,
|
||||||
requireEmailVerification,
|
requireEmailVerification,
|
||||||
|
turnstileEnabled,
|
||||||
|
turnstileSiteKey = turnstileEnabled ? turnstileSiteKey : null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record LoginRequest(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);
|
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 AuthSessionResult(bool Authenticated, string Provider);
|
||||||
public sealed record TwoFactorRequiredResult(bool RequiresTwoFactor, string PendingToken);
|
public sealed record TwoFactorRequiredResult(bool RequiresTwoFactor, string PendingToken);
|
||||||
public sealed record GoogleLinkDto(bool Linked, string? Email, DateTimeOffset? LinkedAt);
|
public sealed record GoogleLinkDto(bool Linked, string? Email, DateTimeOffset? LinkedAt);
|
||||||
public sealed record MicrosoftLinkDto(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(
|
public sealed record MeResult(
|
||||||
string Provider,
|
string Provider,
|
||||||
string? Id,
|
string? Id,
|
||||||
@@ -79,6 +86,8 @@ public sealed class AuthController : ControllerBase
|
|||||||
string? ProfileCvStructureJson,
|
string? ProfileCvStructureJson,
|
||||||
string? AvatarImageDataUrl,
|
string? AvatarImageDataUrl,
|
||||||
IList<string> Roles,
|
IList<string> Roles,
|
||||||
|
string Plan,
|
||||||
|
EntitlementsDto Entitlements,
|
||||||
GoogleLinkDto? GoogleLink,
|
GoogleLinkDto? GoogleLink,
|
||||||
MicrosoftLinkDto? MicrosoftLink);
|
MicrosoftLinkDto? MicrosoftLink);
|
||||||
private const int MaxAvatarBytes = 1_000_000;
|
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 (email.Length == 0) return BadRequest("Email is required.");
|
||||||
if (password.Length == 0) return BadRequest("Password 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);
|
var user = await _users.FindByEmailAsync(email) ?? await _users.FindByNameAsync(email);
|
||||||
if (user is null) return Unauthorized();
|
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 (email.Length == 0) return BadRequest("Email is required.");
|
||||||
if (password.Length == 0) return BadRequest("Password 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);
|
var existing = await _users.FindByEmailAsync(email);
|
||||||
if (existing is not null) return BadRequest("User already exists.");
|
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);
|
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")]
|
[HttpPost("google/exchange")]
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
[EnableRateLimiting("auth-login")]
|
[EnableRateLimiting("auth-login")]
|
||||||
@@ -355,6 +396,8 @@ public sealed class AuthController : ControllerBase
|
|||||||
ProfileCvStructureJson: null,
|
ProfileCvStructureJson: null,
|
||||||
AvatarImageDataUrl: null,
|
AvatarImageDataUrl: null,
|
||||||
Roles: Array.Empty<string>(),
|
Roles: Array.Empty<string>(),
|
||||||
|
Plan: "free",
|
||||||
|
Entitlements: BuildEntitlements(Array.Empty<string>()),
|
||||||
GoogleLink: provider == "google" ? new GoogleLinkDto(false, email, null) : null,
|
GoogleLink: provider == "google" ? new GoogleLinkDto(false, email, null) : null,
|
||||||
MicrosoftLink: provider == "microsoft" ? new MicrosoftLinkDto(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();
|
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)
|
private static MeResult ToMeResult(ApplicationUser user, IList<string> roles)
|
||||||
{
|
{
|
||||||
|
var entitlements = BuildEntitlements(roles);
|
||||||
return new MeResult(
|
return new MeResult(
|
||||||
Provider: "local",
|
Provider: "local",
|
||||||
Id: user.Id,
|
Id: user.Id,
|
||||||
@@ -881,6 +931,8 @@ public sealed class AuthController : ControllerBase
|
|||||||
ProfileCvStructureJson: user.ProfileCvStructureJson,
|
ProfileCvStructureJson: user.ProfileCvStructureJson,
|
||||||
AvatarImageDataUrl: AvatarStorage.Resolve(user.AvatarImageDataUrl),
|
AvatarImageDataUrl: AvatarStorage.Resolve(user.AvatarImageDataUrl),
|
||||||
Roles: roles,
|
Roles: roles,
|
||||||
|
Plan: entitlements.AdvancedAi ? "premium" : "free",
|
||||||
|
Entitlements: entitlements,
|
||||||
GoogleLink: new GoogleLinkDto(
|
GoogleLink: new GoogleLinkDto(
|
||||||
Linked: !string.IsNullOrWhiteSpace(user.GoogleSubject),
|
Linked: !string.IsNullOrWhiteSpace(user.GoogleSubject),
|
||||||
Email: user.GoogleEmail,
|
Email: user.GoogleEmail,
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ services:
|
|||||||
- Auth__JwtKey=${AUTH_JWT_KEY}
|
- Auth__JwtKey=${AUTH_JWT_KEY}
|
||||||
- Auth__AdminEmail=${AUTH_ADMIN_EMAIL}
|
- Auth__AdminEmail=${AUTH_ADMIN_EMAIL}
|
||||||
- Auth__AdminPassword=${AUTH_ADMIN_PASSWORD}
|
- 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)
|
# Optional: allow Google / Microsoft ID-token bearer auth (sign-in, not mail access)
|
||||||
- Auth__GoogleClientId=${AUTH_GOOGLE_CLIENT_ID}
|
- Auth__GoogleClientId=${AUTH_GOOGLE_CLIENT_ID}
|
||||||
- Auth__MicrosoftClientId=${AUTH_MICROSOFT_CLIENT_ID}
|
- Auth__MicrosoftClientId=${AUTH_MICROSOFT_CLIENT_ID}
|
||||||
|
|||||||
@@ -189,8 +189,8 @@ Goal: commercialise. Last, per the guide's "do not over-engineer before needed.
|
|||||||
|
|
||||||
| # | Task | Priority | Difficulty | Dependencies | Expected value |
|
| # | 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.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 | **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.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.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.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. |
|
| 7.5 | **Stripe billing** | **P3** | **L** | 7.2 | Still blocked on **Stripe keys** — the only remaining hard blocker. Tiers are now decided. |
|
||||||
|
|||||||
@@ -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<HTMLDivElement>(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<HTMLScriptElement>('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 <Box><Typography variant="caption" color="text.secondary">Security check</Typography><Box ref={ref} sx={{ minHeight: 65, mt: 0.5 }} /></Box>;
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import { getRememberMePref, setAuthPersistencePreference } from "../auth";
|
|||||||
import GoogleAuthCard from "../components/GoogleAuthCard";
|
import GoogleAuthCard from "../components/GoogleAuthCard";
|
||||||
import MicrosoftAuthCard from "../components/MicrosoftAuthCard";
|
import MicrosoftAuthCard from "../components/MicrosoftAuthCard";
|
||||||
import TwoFactorChallenge from "../components/TwoFactorChallenge";
|
import TwoFactorChallenge from "../components/TwoFactorChallenge";
|
||||||
|
import TurnstileWidget from "../components/TurnstileWidget";
|
||||||
import { useToast } from "../toast";
|
import { useToast } from "../toast";
|
||||||
import { useI18n } from "../i18n/I18nProvider";
|
import { useI18n } from "../i18n/I18nProvider";
|
||||||
|
|
||||||
@@ -19,6 +20,8 @@ type AuthConfig = {
|
|||||||
localEnabled: boolean;
|
localEnabled: boolean;
|
||||||
allowRegistration: boolean;
|
allowRegistration: boolean;
|
||||||
requireEmailVerification: boolean;
|
requireEmailVerification: boolean;
|
||||||
|
turnstileEnabled?: boolean;
|
||||||
|
turnstileSiteKey?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function LoginPage({ initialMode = "login" }: { initialMode?: "login" | "register" }) {
|
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 [verificationResent, setVerificationResent] = useState(false);
|
||||||
const [fieldErrors, setFieldErrors] = useState<{ email?: string; password?: string; confirmPassword?: string }>({});
|
const [fieldErrors, setFieldErrors] = useState<{ email?: string; password?: string; confirmPassword?: string }>({});
|
||||||
const [registerMode, setRegisterMode] = useState(initialMode === "register");
|
const [registerMode, setRegisterMode] = useState(initialMode === "register");
|
||||||
|
const [turnstileToken, setTurnstileToken] = useState("");
|
||||||
|
|
||||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
@@ -83,7 +87,8 @@ export default function LoginPage({ initialMode = "login" }: { initialMode?: "lo
|
|||||||
setVerificationResent(false);
|
setVerificationResent(false);
|
||||||
try {
|
try {
|
||||||
const url = mode === "register" ? "/auth/register" : "/auth/login";
|
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) {
|
if (res.data?.requiresTwoFactor && res.data.pendingToken) {
|
||||||
setPendingToken(res.data.pendingToken);
|
setPendingToken(res.data.pendingToken);
|
||||||
return;
|
return;
|
||||||
@@ -230,6 +235,10 @@ export default function LoginPage({ initialMode = "login" }: { initialMode?: "lo
|
|||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{cfg?.turnstileEnabled && cfg.turnstileSiteKey ? (
|
||||||
|
<TurnstileWidget key={registerMode ? "register" : "login"} siteKey={cfg.turnstileSiteKey} action={registerMode ? "register" : "login"} onToken={setTurnstileToken} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 1, mt: 1, flexWrap: "wrap" }}>
|
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 1, mt: 1, flexWrap: "wrap" }}>
|
||||||
{(allowReg || initialMode === "register") && (
|
{(allowReg || initialMode === "register") && (
|
||||||
<Button
|
<Button
|
||||||
@@ -244,7 +253,7 @@ export default function LoginPage({ initialMode = "login" }: { initialMode?: "lo
|
|||||||
{registerMode ? t("backToLogin") : t("createAccount")}
|
{registerMode ? t("backToLogin") : t("createAccount")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button type="submit" variant="contained" disableRipple disabled={loading || (registerMode && cfg !== null && !allowReg)} sx={{ ml: "auto" }}>
|
<Button type="submit" variant="contained" disableRipple disabled={loading || (registerMode && cfg !== null && !allowReg) || Boolean(cfg?.turnstileEnabled && !turnstileToken)} sx={{ ml: "auto" }}>
|
||||||
{registerMode ? t("createAccount") : t("signInTitle")}
|
{registerMode ? t("createAccount") : t("signInTitle")}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
Reference in New Issue
Block a user