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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user