0587e040d9
- CaptchaService: stateless data-protected math captcha (no Google reCAPTCHA — blocked in Iran), TTL + Persian-digit tolerant; on PostJob + PostShift - SubmissionGuard: duplicate-position detection (facility+role+date/time for shifts, facility+role+title for jobs), spam/garbage screen on title/description, double-apply prevention - InterestService: Apply events deduped so an applicant can't apply to the same listing twice - Verified: wrong captcha rejected, correct publishes, duplicate + garbage blocked Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
59 lines
2.4 KiB
C#
59 lines
2.4 KiB
C#
using Microsoft.AspNetCore.DataProtection;
|
||
|
||
namespace JobsMedical.Web.Services;
|
||
|
||
/// <summary>
|
||
/// A built-in, stateless math CAPTCHA (no Google reCAPTCHA — it's blocked in Iran). It renders a
|
||
/// simple "a + b = ?" question plus a tamper-proof token (the answer + timestamp, data-protected).
|
||
/// Verification unprotects the token, checks it hasn't expired, and compares the answer. Because
|
||
/// the answer is encrypted in the token (not guessable/forgeable) and no server session is needed,
|
||
/// it works across the load-balanced/containerized deploy with zero extra storage.
|
||
/// </summary>
|
||
public class CaptchaService
|
||
{
|
||
private readonly IDataProtector _protector;
|
||
private static readonly TimeSpan Ttl = TimeSpan.FromMinutes(10);
|
||
|
||
public CaptchaService(IDataProtectionProvider dp) => _protector = dp.CreateProtector("hamkadr.captcha.v1");
|
||
|
||
/// <summary>Returns the question to show (e.g. "۳ + ۵") and an opaque token for a hidden field.</summary>
|
||
public (string Question, string Token) Create()
|
||
{
|
||
var a = Random.Shared.Next(1, 10);
|
||
var b = Random.Shared.Next(1, 10);
|
||
var token = _protector.Protect($"{a + b}|{DateTime.UtcNow.Ticks}");
|
||
return ($"{ToPersian(a)} + {ToPersian(b)}", token);
|
||
}
|
||
|
||
public bool Verify(string? token, string? answer)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(token) || string.IsNullOrWhiteSpace(answer)) return false;
|
||
try
|
||
{
|
||
var parts = _protector.Unprotect(token).Split('|');
|
||
if (parts.Length != 2) return false;
|
||
if (long.TryParse(parts[1], out var ticks))
|
||
{
|
||
var issued = new DateTime(ticks, DateTimeKind.Utc);
|
||
if (DateTime.UtcNow - issued > Ttl) return false; // expired
|
||
}
|
||
return parts[0] == ToLatin(answer).Trim();
|
||
}
|
||
catch { return false; } // tampered / undecryptable
|
||
}
|
||
|
||
private static string ToPersian(int n)
|
||
=> new string(n.ToString().Select(c => (char)('۰' + (c - '0'))).ToArray());
|
||
|
||
private static string ToLatin(string s)
|
||
{
|
||
var chars = s.ToCharArray();
|
||
for (var i = 0; i < chars.Length; i++)
|
||
{
|
||
if (chars[i] >= '۰' && chars[i] <= '۹') chars[i] = (char)('0' + (chars[i] - '۰'));
|
||
else if (chars[i] >= '٠' && chars[i] <= '٩') chars[i] = (char)('0' + (chars[i] - '٠'));
|
||
}
|
||
return new string(chars);
|
||
}
|
||
}
|