Files
hamkadr/src/JobsMedical.Web/Services/CaptchaService.cs
T
soroush.asadi 0587e040d9
CI/CD / CI · dotnet build (push) Successful in 1m22s
CI/CD / Deploy · hamkadr (push) Successful in 1m36s
Add anti-abuse: built-in captcha + garbage/duplicate guard
- 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>
2026-06-04 06:35:17 +03:30

59 lines
2.4 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}