Anti-abuse hardening: hourly posting rate limit + captcha on facility registration
- SubmissionGuard.PostingRateExceededAsync: max 20 new listings (shifts+jobs) per account per rolling hour, enforced in PostJob + PostShift - Captcha + spam-name screen added to /Employer/RegisterFacility Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -63,6 +63,11 @@ public class PostJobModel : PageModel
|
|||||||
if (await _guard.DuplicateJobAsync(FacilityId, RoleId, Title))
|
if (await _guard.DuplicateJobAsync(FacilityId, RoleId, Title))
|
||||||
{ Error = "این موقعیت استخدامی قبلاً برای این مرکز ثبت شده است."; NewCaptcha(); return Page(); }
|
{ Error = "این موقعیت استخدامی قبلاً برای این مرکز ثبت شده است."; NewCaptcha(); return Page(); }
|
||||||
|
|
||||||
|
// 4. Flood protection — hourly posting cap.
|
||||||
|
var uid = int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||||
|
if (await _guard.PostingRateExceededAsync(uid))
|
||||||
|
{ Error = $"در یک ساعت اخیر بیش از حد مجاز ({SubmissionGuard.MaxListingsPerHour}) آگهی ثبت کردهاید. بعداً تلاش کنید."; NewCaptcha(); return Page(); }
|
||||||
|
|
||||||
_db.JobOpenings.Add(new JobOpening
|
_db.JobOpenings.Add(new JobOpening
|
||||||
{
|
{
|
||||||
FacilityId = FacilityId,
|
FacilityId = FacilityId,
|
||||||
|
|||||||
@@ -67,6 +67,10 @@ public class PostShiftModel : PageModel
|
|||||||
if (await _guard.DuplicateShiftAsync(FacilityId, RoleId, Date, StartTime, ShiftType))
|
if (await _guard.DuplicateShiftAsync(FacilityId, RoleId, Date, StartTime, ShiftType))
|
||||||
{ Error = "این شیفت (همان مرکز، نقش، تاریخ و ساعت) قبلاً ثبت شده است."; NewCaptcha(); return Page(); }
|
{ Error = "این شیفت (همان مرکز، نقش، تاریخ و ساعت) قبلاً ثبت شده است."; NewCaptcha(); return Page(); }
|
||||||
|
|
||||||
|
var uid = int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||||
|
if (await _guard.PostingRateExceededAsync(uid))
|
||||||
|
{ Error = $"در یک ساعت اخیر بیش از حد مجاز ({SubmissionGuard.MaxListingsPerHour}) آگهی ثبت کردهاید. بعداً تلاش کنید."; NewCaptcha(); return Page(); }
|
||||||
|
|
||||||
var role = await _db.Roles.FindAsync(RoleId);
|
var role = await _db.Roles.FindAsync(RoleId);
|
||||||
_db.Shifts.Add(new Shift
|
_db.Shifts.Add(new Shift
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -62,6 +62,11 @@
|
|||||||
<div style="flex:1;"><label>طول جغرافیایی (اختیاری)</label><input type="number" step="any" name="Lng" value="@Model.Lng" dir="ltr" /></div>
|
<div style="flex:1;"><label>طول جغرافیایی (اختیاری)</label><input type="number" step="any" name="Lng" value="@Model.Lng" dir="ltr" /></div>
|
||||||
</div>
|
</div>
|
||||||
<p class="muted" style="font-size:12px;">مختصات برای نمایش در فیلتر «نزدیک من» استفاده میشود.</p>
|
<p class="muted" style="font-size:12px;">مختصات برای نمایش در فیلتر «نزدیک من» استفاده میشود.</p>
|
||||||
|
<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>
|
<button type="submit" class="btn btn-accent btn-block btn-lg">ثبت مرکز و ورود به پنل</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,10 +15,18 @@ namespace JobsMedical.Web.Pages.Employer;
|
|||||||
public class RegisterFacilityModel : PageModel
|
public class RegisterFacilityModel : PageModel
|
||||||
{
|
{
|
||||||
private readonly AppDbContext _db;
|
private readonly AppDbContext _db;
|
||||||
public RegisterFacilityModel(AppDbContext db) => _db = db;
|
private readonly CaptchaService _captcha;
|
||||||
|
public RegisterFacilityModel(AppDbContext db, CaptchaService captcha)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_captcha = captcha;
|
||||||
|
}
|
||||||
|
|
||||||
public List<City> Cities { get; private set; } = new();
|
public List<City> Cities { get; private set; } = new();
|
||||||
public List<District> Districts { get; private set; } = new();
|
public List<District> Districts { get; private set; } = new();
|
||||||
|
public string CaptchaQuestion { get; private set; } = "";
|
||||||
|
[BindProperty] public string? CaptchaToken { get; set; }
|
||||||
|
[BindProperty] public string? CaptchaAnswer { get; set; }
|
||||||
|
|
||||||
[BindProperty] public string Name { get; set; } = "";
|
[BindProperty] public string Name { get; set; } = "";
|
||||||
[BindProperty] public FacilityType Type { get; set; }
|
[BindProperty] public FacilityType Type { get; set; }
|
||||||
@@ -31,16 +39,21 @@ public class RegisterFacilityModel : PageModel
|
|||||||
[BindProperty] public double? Lng { get; set; }
|
[BindProperty] public double? Lng { get; set; }
|
||||||
public string? Error { get; private set; }
|
public string? Error { get; private set; }
|
||||||
|
|
||||||
public async Task OnGetAsync() => await LoadListsAsync();
|
public async Task OnGetAsync() { await LoadListsAsync(); NewCaptcha(); }
|
||||||
|
|
||||||
public async Task<IActionResult> OnPostAsync()
|
public async Task<IActionResult> OnPostAsync()
|
||||||
{
|
{
|
||||||
await LoadListsAsync();
|
await LoadListsAsync();
|
||||||
|
if (!_captcha.Verify(CaptchaToken, CaptchaAnswer))
|
||||||
|
{ Error = "پاسخ سؤال امنیتی نادرست است."; NewCaptcha(); return Page(); }
|
||||||
if (string.IsNullOrWhiteSpace(Name) || CityId == 0)
|
if (string.IsNullOrWhiteSpace(Name) || CityId == 0)
|
||||||
{
|
{
|
||||||
Error = "نام مرکز و شهر الزامی است.";
|
Error = "نام مرکز و شهر الزامی است.";
|
||||||
|
NewCaptcha();
|
||||||
return Page();
|
return Page();
|
||||||
}
|
}
|
||||||
|
if (SubmissionGuard.ContainsSpam(Name))
|
||||||
|
{ Error = "نام مرکز نامعتبر بهنظر میرسد."; NewCaptcha(); return Page(); }
|
||||||
|
|
||||||
var userId = int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
var userId = int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||||
var facility = new Facility
|
var facility = new Facility
|
||||||
@@ -81,4 +94,11 @@ public class RegisterFacilityModel : PageModel
|
|||||||
Cities = await _db.Cities.OrderByDescending(c => c.IsActive).ThenBy(c => c.Name).ToListAsync();
|
Cities = await _db.Cities.OrderByDescending(c => c.IsActive).ThenBy(c => c.Name).ToListAsync();
|
||||||
Districts = await _db.Districts.Where(d => d.IsActive).OrderBy(d => d.Name).ToListAsync();
|
Districts = await _db.Districts.Where(d => d.IsActive).OrderBy(d => d.Name).ToListAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void NewCaptcha()
|
||||||
|
{
|
||||||
|
var (q, token) = _captcha.Create();
|
||||||
|
CaptchaQuestion = q;
|
||||||
|
CaptchaToken = token;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,18 @@ public class SubmissionGuard
|
|||||||
return candidates.Any(c => Normalize(c) == t);
|
return candidates.Any(c => Normalize(c) == t);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Max new listings (shifts + jobs) one account may post per rolling hour.</summary>
|
||||||
|
public const int MaxListingsPerHour = 20;
|
||||||
|
|
||||||
|
/// <summary>True if this owner has hit the hourly posting cap (flood protection).</summary>
|
||||||
|
public async Task<bool> PostingRateExceededAsync(int ownerUserId)
|
||||||
|
{
|
||||||
|
var since = DateTime.UtcNow.AddHours(-1);
|
||||||
|
var shifts = await _db.Shifts.CountAsync(s => s.Facility.OwnerUserId == ownerUserId && s.CreatedAt >= since);
|
||||||
|
var jobs = await _db.JobOpenings.CountAsync(j => j.Facility.OwnerUserId == ownerUserId && j.CreatedAt >= since);
|
||||||
|
return shifts + jobs >= MaxListingsPerHour;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Has this visitor already applied (Apply event) to this shift/job?</summary>
|
/// <summary>Has this visitor already applied (Apply event) to this shift/job?</summary>
|
||||||
public Task<bool> AlreadyAppliedAsync(string visitorId, int? shiftId, int? jobId) =>
|
public Task<bool> AlreadyAppliedAsync(string visitorId, int? shiftId, int? jobId) =>
|
||||||
_db.InterestEvents.AnyAsync(e => e.VisitorId == visitorId
|
_db.InterestEvents.AnyAsync(e => e.VisitorId == visitorId
|
||||||
|
|||||||
Reference in New Issue
Block a user