Files
crewli/api/app/Services/PersonIdentityService.php
bert.hausmans 1028498705 security: round 1 — quick wins (rate limiting, headers, mass assignment, logging)
- 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>
2026-04-14 01:34:51 +02:00

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');
}
}