Add anti-abuse: built-in captcha + garbage/duplicate guard
CI/CD / CI · dotnet build (push) Successful in 1m22s
CI/CD / Deploy · hamkadr (push) Successful in 1m36s

- 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:
soroush.asadi
2026-06-04 06:35:17 +03:30
parent e2e26150cb
commit 0587e040d9
8 changed files with 210 additions and 12 deletions
@@ -77,6 +77,11 @@
<label>شرایط احراز</label>
<textarea name="Requirements" rows="2">@Model.Requirements</textarea>
</div>
<div class="filter-group">
<label>سؤال امنیتی: حاصل <strong>@Model.CaptchaQuestion</strong> چند می‌شود؟</label>
<input type="text" name="CaptchaAnswer" dir="ltr" inputmode="numeric" autocomplete="off" placeholder="پاسخ" />
<input type="hidden" name="CaptchaToken" value="@Model.CaptchaToken" />
</div>
<button type="submit" class="btn btn-accent btn-block btn-lg">انتشار موقعیت</button>
</form>
}
@@ -1,6 +1,7 @@
using System.Security.Claims;
using JobsMedical.Web.Data;
using JobsMedical.Web.Models;
using JobsMedical.Web.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
@@ -12,11 +13,20 @@ namespace JobsMedical.Web.Pages.Employer;
public class PostJobModel : PageModel
{
private readonly AppDbContext _db;
public PostJobModel(AppDbContext db) => _db = db;
private readonly CaptchaService _captcha;
private readonly SubmissionGuard _guard;
public PostJobModel(AppDbContext db, CaptchaService captcha, SubmissionGuard guard)
{
_db = db;
_captcha = captcha;
_guard = guard;
}
public List<Facility> MyFacilities { get; private set; } = new();
public List<Role> Roles { get; private set; } = new();
public string? Error { get; private set; }
public string CaptchaQuestion { get; private set; } = "";
[BindProperty] public int FacilityId { get; set; }
[BindProperty] public int RoleId { get; set; }
@@ -28,18 +38,30 @@ public class PostJobModel : PageModel
[BindProperty] public Gender GenderRequirement { get; set; }
[BindProperty] public string? Description { get; set; }
[BindProperty] public string? Requirements { get; set; }
[BindProperty] public string? CaptchaToken { get; set; }
[BindProperty] public string? CaptchaAnswer { get; set; }
public async Task OnGetAsync() => await LoadListsAsync();
public async Task OnGetAsync() { await LoadListsAsync(); NewCaptcha(); }
public async Task<IActionResult> OnPostAsync()
{
await LoadListsAsync();
// 1. Bot gate — built-in captcha.
if (!_captcha.Verify(CaptchaToken, CaptchaAnswer))
{ Error = "پاسخ سؤال امنیتی نادرست است."; NewCaptcha(); return Page(); }
if (!MyFacilities.Any(f => f.Id == FacilityId))
{
Error = "این مرکز متعلق به شما نیست.";
return Page();
}
if (string.IsNullOrWhiteSpace(Title)) { Error = "عنوان موقعیت الزامی است."; return Page(); }
{ Error = "این مرکز متعلق به شما نیست."; NewCaptcha(); return Page(); }
if (string.IsNullOrWhiteSpace(Title)) { Error = "عنوان موقعیت الزامی است."; NewCaptcha(); return Page(); }
// 2. Garbage screen.
if (SubmissionGuard.LooksLikeGarbage(Title, Description))
{ Error = "متن آگهی نامعتبر یا تبلیغاتی به‌نظر می‌رسد."; NewCaptcha(); return Page(); }
// 3. Duplicate position.
if (await _guard.DuplicateJobAsync(FacilityId, RoleId, Title))
{ Error = "این موقعیت استخدامی قبلاً برای این مرکز ثبت شده است."; NewCaptcha(); return Page(); }
_db.JobOpenings.Add(new JobOpening
{
@@ -66,4 +88,11 @@ public class PostJobModel : PageModel
.Where(f => f.OwnerUserId == userId).OrderBy(f => f.Name).ToListAsync();
Roles = await _db.Roles.Where(r => r.IsActive).OrderBy(r => r.SortOrder).ToListAsync();
}
private void NewCaptcha()
{
var (q, token) = _captcha.Create();
CaptchaQuestion = q;
CaptchaToken = token;
}
}
@@ -84,6 +84,11 @@
<label>توضیحات</label>
<textarea name="Description" rows="3">@Model.Description</textarea>
</div>
<div class="filter-group">
<label>سؤال امنیتی: حاصل <strong>@Model.CaptchaQuestion</strong> چند می‌شود؟</label>
<input type="text" name="CaptchaAnswer" dir="ltr" inputmode="numeric" autocomplete="off" placeholder="پاسخ" />
<input type="hidden" name="CaptchaToken" value="@Model.CaptchaToken" />
</div>
<button type="submit" class="btn btn-accent btn-block btn-lg">انتشار شیفت</button>
</form>
}
@@ -1,6 +1,7 @@
using System.Security.Claims;
using JobsMedical.Web.Data;
using JobsMedical.Web.Models;
using JobsMedical.Web.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
@@ -12,11 +13,22 @@ namespace JobsMedical.Web.Pages.Employer;
public class PostShiftModel : PageModel
{
private readonly AppDbContext _db;
public PostShiftModel(AppDbContext db) => _db = db;
private readonly CaptchaService _captcha;
private readonly SubmissionGuard _guard;
public PostShiftModel(AppDbContext db, CaptchaService captcha, SubmissionGuard guard)
{
_db = db;
_captcha = captcha;
_guard = guard;
}
public List<Facility> MyFacilities { get; private set; } = new();
public List<Role> Roles { get; private set; } = new();
public string? Error { get; private set; }
public string CaptchaQuestion { get; private set; } = "";
[BindProperty] public string? CaptchaToken { get; set; }
[BindProperty] public string? CaptchaAnswer { get; set; }
[BindProperty] public int FacilityId { get; set; }
[BindProperty] public int RoleId { get; set; }
@@ -36,16 +48,25 @@ public class PostShiftModel : PageModel
Date = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(1);
StartTime = new TimeOnly(8, 0);
EndTime = new TimeOnly(14, 0);
NewCaptcha();
}
public async Task<IActionResult> OnPostAsync()
{
await LoadListsAsync();
if (!_captcha.Verify(CaptchaToken, CaptchaAnswer))
{ Error = "پاسخ سؤال امنیتی نادرست است."; NewCaptcha(); return Page(); }
if (!MyFacilities.Any(f => f.Id == FacilityId))
{
Error = "این مرکز متعلق به شما نیست.";
return Page();
}
{ Error = "این مرکز متعلق به شما نیست."; NewCaptcha(); return Page(); }
if (SubmissionGuard.ContainsSpam(Description))
{ Error = "متن شیفت تبلیغاتی/نامعتبر به‌نظر می‌رسد."; NewCaptcha(); return Page(); }
if (await _guard.DuplicateShiftAsync(FacilityId, RoleId, Date, StartTime, ShiftType))
{ Error = "این شیفت (همان مرکز، نقش، تاریخ و ساعت) قبلاً ثبت شده است."; NewCaptcha(); return Page(); }
var role = await _db.Roles.FindAsync(RoleId);
_db.Shifts.Add(new Shift
{
@@ -76,4 +97,11 @@ public class PostShiftModel : PageModel
.Where(f => f.OwnerUserId == userId).OrderBy(f => f.Name).ToListAsync();
Roles = await _db.Roles.Where(r => r.IsActive).OrderBy(r => r.SortOrder).ToListAsync();
}
private void NewCaptcha()
{
var (q, token) = _captcha.Create();
CaptchaQuestion = q;
CaptchaToken = token;
}
}