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>
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,10 @@ public class InterestService
|
||||
public async Task LogAsync(InterestEventType type, int shiftId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(VisitorId)) return;
|
||||
// Don't record the same applicant applying to the same shift twice (garbage prevention).
|
||||
if (type == InterestEventType.Apply && await _db.InterestEvents.AnyAsync(e =>
|
||||
e.VisitorId == VisitorId && e.ShiftId == shiftId && e.EventType == InterestEventType.Apply))
|
||||
return;
|
||||
await EnsureVisitorAsync();
|
||||
_db.InterestEvents.Add(new InterestEvent
|
||||
{
|
||||
@@ -54,6 +58,9 @@ public class InterestService
|
||||
public async Task LogJobAsync(InterestEventType type, int jobOpeningId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(VisitorId)) return;
|
||||
if (type == InterestEventType.Apply && await _db.InterestEvents.AnyAsync(e =>
|
||||
e.VisitorId == VisitorId && e.JobOpeningId == jobOpeningId && e.EventType == InterestEventType.Apply))
|
||||
return;
|
||||
await EnsureVisitorAsync();
|
||||
_db.InterestEvents.Add(new InterestEvent
|
||||
{
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using JobsMedical.Web.Data;
|
||||
using JobsMedical.Web.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace JobsMedical.Web.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Anti-garbage gate for user-submitted listings: blocks duplicate positions, screens obvious
|
||||
/// spam/garbage text, and prevents the same applicant applying to a listing twice.
|
||||
/// </summary>
|
||||
public class SubmissionGuard
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
public SubmissionGuard(AppDbContext db) => _db = db;
|
||||
|
||||
private static readonly string[] SpamMarkers =
|
||||
{
|
||||
"سرمایه گذاری", "سرمایهگذاری", "وام", "ارز دیجیتال", "رمز ارز", "فروش فالوور",
|
||||
"بک لینک", "قرعه کشی", "کازینو", "شرط بندی", "بیت کوین", "http://", "https://", "www."
|
||||
};
|
||||
|
||||
/// <summary>Spam/scam markers, mashed-keyboard, or stray links in free text (any field).</summary>
|
||||
public static bool ContainsSpam(string? text)
|
||||
{
|
||||
var t = (text ?? "").Trim();
|
||||
if (t.Length == 0) return false;
|
||||
if (SpamMarkers.Any(m => t.Contains(m))) return true;
|
||||
if (Regex.IsMatch(t, @"(.)\1{6,}")) return true; // aaaaaaa / کککککککک
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>For jobs: a real title is required, plus the spam screen on title+description.</summary>
|
||||
public static bool LooksLikeGarbage(string? title, string? description)
|
||||
{
|
||||
if (Normalize(title).Length < 3) return true; // no real title
|
||||
return ContainsSpam(title) || ContainsSpam(description);
|
||||
}
|
||||
|
||||
/// <summary>A near-identical OPEN shift already exists at this facility?</summary>
|
||||
public Task<bool> DuplicateShiftAsync(int facilityId, int roleId, DateOnly date,
|
||||
TimeOnly start, ShiftType type) =>
|
||||
_db.Shifts.AnyAsync(s => s.Status == ShiftStatus.Open && s.FacilityId == facilityId
|
||||
&& s.RoleId == roleId && s.Date == date && s.StartTime == start && s.ShiftType == type);
|
||||
|
||||
/// <summary>A near-identical OPEN job already exists at this facility (same role + title)?</summary>
|
||||
public async Task<bool> DuplicateJobAsync(int facilityId, int roleId, string title)
|
||||
{
|
||||
var t = Normalize(title);
|
||||
var candidates = await _db.JobOpenings
|
||||
.Where(j => j.Status == ShiftStatus.Open && j.FacilityId == facilityId && j.RoleId == roleId)
|
||||
.Select(j => j.Title).ToListAsync();
|
||||
return candidates.Any(c => Normalize(c) == t);
|
||||
}
|
||||
|
||||
/// <summary>Has this visitor already applied (Apply event) to this shift/job?</summary>
|
||||
public Task<bool> AlreadyAppliedAsync(string visitorId, int? shiftId, int? jobId) =>
|
||||
_db.InterestEvents.AnyAsync(e => e.VisitorId == visitorId
|
||||
&& e.EventType == InterestEventType.Apply
|
||||
&& ((shiftId != null && e.ShiftId == shiftId) || (jobId != null && e.JobOpeningId == jobId)));
|
||||
|
||||
private static string Normalize(string? s) => Regex.Replace((s ?? "").Trim(), @"\s+", " ")
|
||||
.Replace('ي', 'ی').Replace('ك', 'ک').ToLowerInvariant();
|
||||
}
|
||||
Reference in New Issue
Block a user