- Add throttle middleware to login (5/min), portal/token-auth (10/min), volunteer-register (5/min), and invitation routes (10/min) - Set Sanctum token expiration to 7 days - Remove billing_status from UpdateOrganisationRequest (super_admin only) - Revoke all Sanctum tokens on password reset - Strengthen password rules: min 8 chars, mixed case, numbers - Create SecurityHeaders middleware (X-Content-Type-Options, X-Frame-Options, HSTS, Referrer-Policy, Permissions-Policy) - Fix open redirect on all 3 login pages (validate ?to= starts with /) - Set APP_DEBUG=false in .env.example - Log failed login attempts with email, IP, user-agent - Log authorization failures (403) with user, IP, path, method - Harden mass assignment: remove user_id from Person, audit fields from ShiftAssignment, system fields from UserInvitation $fillable - Replace real DB records with factory make() in mail preview routes Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
224 lines
7.6 KiB
PHP
224 lines
7.6 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Enums\IdentityMatchConfidence;
|
|
use App\Enums\IdentityMatchMethod;
|
|
use App\Enums\IdentityMatchStatus;
|
|
use App\Models\Person;
|
|
use App\Models\PersonIdentityMatch;
|
|
use App\Models\User;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
final class PersonIdentityService
|
|
{
|
|
/**
|
|
* Detect if a person's email matches an existing user account.
|
|
* Called after a person is created. Does NOT auto-link.
|
|
*/
|
|
public function detectMatchForPerson(Person $person): ?PersonIdentityMatch
|
|
{
|
|
// Guard 1: Person already linked to a user
|
|
if ($person->user_id !== null) {
|
|
return null;
|
|
}
|
|
|
|
// Guard 2: Person has no email
|
|
if ($person->email === null || trim($person->email) === '') {
|
|
return null;
|
|
}
|
|
|
|
// Guard 3: Person is soft-deleted
|
|
if ($person->trashed()) {
|
|
return null;
|
|
}
|
|
|
|
// Guard 4: Find user with matching normalised email.
|
|
// StorePersonRequest validates 'email' as required + email format but does not
|
|
// enforce lowercase. User emails are also not guaranteed lowercase. We use
|
|
// LOWER() on both sides as a safety net for case-insensitive matching.
|
|
$normalised = strtolower(trim($person->email));
|
|
$user = User::whereRaw('LOWER(email) = ?', [$normalised])->first();
|
|
|
|
if ($user === null) {
|
|
return null;
|
|
}
|
|
|
|
// Guard 5: User already has a person record in the same event
|
|
// (would violate UNIQUE(event_id, user_id) WHERE user_id IS NOT NULL)
|
|
$alreadyLinkedInEvent = Person::where('event_id', $person->event_id)
|
|
->where('user_id', $user->id)
|
|
->exists();
|
|
|
|
if ($alreadyLinkedInEvent) {
|
|
return null;
|
|
}
|
|
|
|
// Guard 6: Match record already exists — return existing (idempotent)
|
|
$existing = PersonIdentityMatch::where('person_id', $person->id)
|
|
->where('matched_user_id', $user->id)
|
|
->first();
|
|
|
|
if ($existing !== null) {
|
|
return $existing;
|
|
}
|
|
|
|
$match = PersonIdentityMatch::create([
|
|
'person_id' => $person->id,
|
|
'matched_user_id' => $user->id,
|
|
'matched_on' => IdentityMatchMethod::EMAIL,
|
|
'confidence' => IdentityMatchConfidence::EXACT,
|
|
'status' => IdentityMatchStatus::PENDING,
|
|
]);
|
|
|
|
$activityLogger = activity('identity')
|
|
->performedOn($person)
|
|
->withProperties([
|
|
'matched_user_id' => $user->id,
|
|
'matched_on' => IdentityMatchMethod::EMAIL->value,
|
|
'confidence' => IdentityMatchConfidence::EXACT->value,
|
|
]);
|
|
|
|
if (auth()->user()) {
|
|
$activityLogger->causedBy(auth()->user());
|
|
}
|
|
|
|
$activityLogger->log('person.identity.match_detected');
|
|
|
|
return $match;
|
|
}
|
|
|
|
/**
|
|
* Detect all unlinked persons matching a user's email.
|
|
* Called after a user account is created. Creates pending matches.
|
|
* Returns the number of matches created.
|
|
*/
|
|
public function detectMatchesForUser(User $user): int
|
|
{
|
|
// 1. Fetch all matching unlinked persons
|
|
// Person uses SoftDeletes, so trashed records are automatically excluded by Eloquent.
|
|
$normalised = strtolower(trim($user->email));
|
|
$persons = Person::whereNull('user_id')
|
|
->whereRaw('LOWER(email) = ?', [$normalised])
|
|
->get();
|
|
|
|
if ($persons->isEmpty()) {
|
|
return 0;
|
|
}
|
|
|
|
// 2. Batch-check which events already have this user linked (no N+1)
|
|
$alreadyLinkedEventIds = Person::where('user_id', $user->id)
|
|
->whereIn('event_id', $persons->pluck('event_id'))
|
|
->pluck('event_id')
|
|
->toArray();
|
|
|
|
// 3. Batch-check existing match records (no N+1)
|
|
$existingMatchPersonIds = PersonIdentityMatch::where('matched_user_id', $user->id)
|
|
->whereIn('person_id', $persons->pluck('id'))
|
|
->pluck('person_id')
|
|
->toArray();
|
|
|
|
// 4. Filter — no queries inside this loop
|
|
$toCreate = $persons
|
|
->reject(fn (Person $p) => in_array($p->event_id, $alreadyLinkedEventIds))
|
|
->reject(fn (Person $p) => in_array($p->id, $existingMatchPersonIds));
|
|
|
|
// 5. Create matches
|
|
foreach ($toCreate as $person) {
|
|
PersonIdentityMatch::create([
|
|
'person_id' => $person->id,
|
|
'matched_user_id' => $user->id,
|
|
'matched_on' => IdentityMatchMethod::EMAIL,
|
|
'confidence' => IdentityMatchConfidence::EXACT,
|
|
'status' => IdentityMatchStatus::PENDING,
|
|
]);
|
|
|
|
$activityLogger = activity('identity')
|
|
->performedOn($person)
|
|
->withProperties([
|
|
'matched_user_id' => $user->id,
|
|
'matched_on' => IdentityMatchMethod::EMAIL->value,
|
|
'confidence' => IdentityMatchConfidence::EXACT->value,
|
|
]);
|
|
|
|
if (auth()->user()) {
|
|
$activityLogger->causedBy(auth()->user());
|
|
}
|
|
|
|
$activityLogger->log('person.identity.match_detected');
|
|
}
|
|
|
|
return $toCreate->count();
|
|
}
|
|
|
|
/**
|
|
* Confirm a match: link the person to the matched user.
|
|
*
|
|
* @throws \DomainException if match is not pending or would violate uniqueness
|
|
*/
|
|
public function confirmMatch(PersonIdentityMatch $match, User $resolvedBy): void
|
|
{
|
|
if ($match->status !== IdentityMatchStatus::PENDING) {
|
|
throw new \DomainException('Match is not pending and cannot be confirmed.');
|
|
}
|
|
|
|
// Safety check: no duplicate user_id in the same event
|
|
$person = $match->person;
|
|
$conflict = Person::where('event_id', $person->event_id)
|
|
->where('user_id', $match->matched_user_id)
|
|
->exists();
|
|
|
|
if ($conflict) {
|
|
throw new \DomainException('User already has a person record in this event.');
|
|
}
|
|
|
|
DB::transaction(function () use ($match, $person, $resolvedBy): void {
|
|
$match->update([
|
|
'status' => IdentityMatchStatus::CONFIRMED,
|
|
'resolved_by_user_id' => $resolvedBy->id,
|
|
'resolved_at' => now(),
|
|
]);
|
|
|
|
// Set user_id explicitly (not mass-assignable)
|
|
$person->user_id = $match->matched_user_id;
|
|
$person->save();
|
|
});
|
|
|
|
activity('identity')
|
|
->causedBy($resolvedBy)
|
|
->performedOn($person)
|
|
->withProperties([
|
|
'match_id' => $match->id,
|
|
'old' => ['user_id' => null],
|
|
'new' => ['user_id' => $match->matched_user_id],
|
|
])
|
|
->log('person.identity.match_confirmed');
|
|
}
|
|
|
|
/**
|
|
* Dismiss a match: mark as dismissed so it's not shown again.
|
|
*
|
|
* @throws \DomainException if match is not pending
|
|
*/
|
|
public function dismissMatch(PersonIdentityMatch $match, User $resolvedBy): void
|
|
{
|
|
if ($match->status !== IdentityMatchStatus::PENDING) {
|
|
throw new \DomainException('Match is not pending and cannot be dismissed.');
|
|
}
|
|
|
|
$match->update([
|
|
'status' => IdentityMatchStatus::DISMISSED,
|
|
'resolved_by_user_id' => $resolvedBy->id,
|
|
'resolved_at' => now(),
|
|
]);
|
|
|
|
activity('identity')
|
|
->causedBy($resolvedBy)
|
|
->performedOn($match->person)
|
|
->withProperties(['match_id' => $match->id])
|
|
->log('person.identity.match_dismissed');
|
|
}
|
|
}
|