Files
alrahma_sunday_school_api/app/Services/Auth/ApiLoginSecurityService.php
T
2026-06-08 23:30:22 -04:00

162 lines
5.2 KiB
PHP

<?php
namespace App\Services\Auth;
use App\Models\Configuration;
use App\Models\IpAttempt;
use App\Models\LoginActivity;
use App\Models\User;
use Carbon\CarbonImmutable;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
/**
* Centralizes API login lockout, suspension, and audit logging behavior.
*/
class ApiLoginSecurityService
{
private const MAX_ATTEMPTS = 10;
private const BLOCK_HOURS = 24;
public function isIpBlocked(string $ip): bool
{
$row = IpAttempt::query()->where('ip_address', $ip)->first();
if (! $row || ! $row->blocked_until) {
return false;
}
return $row->blocked_until->isFuture();
}
public function logIpAttempt(string $ip): void
{
$now = CarbonImmutable::now('UTC');
$nowStr = $now->toDateTimeString();
$attempt = IpAttempt::query()->where('ip_address', $ip)->first();
if ($attempt) {
$previouslyBlocked = $attempt->blocked_until
? CarbonImmutable::parse($attempt->blocked_until, 'UTC')->isFuture()
: false;
// If the previous block has already expired, start a fresh window
// so we don't perma-ban an IP after its first 10 lifetime failures.
$attempts = $previouslyBlocked
? ((int) $attempt->attempts) + 1
: 1;
$blockedUntil = $attempts >= self::MAX_ATTEMPTS
? $now->addHours(self::BLOCK_HOURS)->toDateTimeString()
: null;
$attempt->update([
'attempts' => $attempts,
'last_attempt_at' => $nowStr,
'blocked_until' => $blockedUntil,
]);
return;
}
IpAttempt::query()->create([
'ip_address' => $ip,
'attempts' => 1,
'last_attempt_at' => $nowStr,
]);
}
public function handleFailedLogin(User $user, string $email, string $ip): void
{
$user->refresh();
$failedAttempts = ((int) ($user->failed_attempts ?? 0)) + 1;
$data = [
'failed_attempts' => $failedAttempts,
'last_failed_at' => utc_now(),
];
if ($failedAttempts >= 3) {
$data['is_suspended'] = true;
// Password-reset delivery can be wired here when the suspension workflow is finalized.
Log::notice('api_login: account suspended after failed attempts', ['email' => $email, 'user_id' => $user->id]);
}
$user->fill($data);
$user->save();
$this->logIpAttempt($ip);
}
public function resetFailedAttempts(User $user): void
{
$user->failed_attempts = 0;
$user->last_failed_at = null;
$user->save();
}
public function logSuccessfulLogin(User $user, Request $request): void
{
$schoolYear = trim((string) (Configuration::getConfig('school_year') ?? date('Y')));
$semester = trim((string) (Configuration::getConfig('semester') ?? 'Fall'));
LoginActivity::query()->create([
'user_id' => $user->id,
'email' => $user->email,
'login_time' => utc_now(),
'ip_address' => $request->ip(),
'user_agent' => (string) ($request->userAgent() ?? ''),
'school_year' => $schoolYear,
'semester' => $semester !== '' ? $semester : 'Fall',
]);
}
/**
* Returns roles as an object map {"RoleName": true}.
*
* @param list<string> $roleNames
*/
public function rolesToObjectMap(array $roleNames): object
{
$rolesMap = [];
foreach ($roleNames as $r) {
$rolesMap[$r] = true;
}
return (object) $rolesMap;
}
/**
* Top-level JSON body for API login success responses.
*
* @return array{status: true, token: string, access_token: string, token_type: string, expires_in: int, user: array{id: int, name: string, firstname: ?string, lastname: ?string, email: ?string, roles: object, class_section_id: ?int, class_section_name: ?string}}
*/
public function buildLoginResponse(User $user, string $jwtToken): array
{
$roleNames = $user->roleNames();
$fullName = trim(($user->firstname ?? '').' '.($user->lastname ?? ''));
$teacherContext = $user->teacherSessionContext();
return [
'status' => true,
// Keep both names. Older clients read `token`; JWT-aware clients often read
// `access_token`. Removing either one just makes login fail in a new and
// irritatingly avoidable way.
'token' => $jwtToken,
'access_token' => $jwtToken,
'token_type' => 'bearer',
'expires_in' => (int) config('jwt.ttl') * 60,
'user' => [
'id' => (int) $user->id,
'name' => $fullName,
'firstname' => $user->firstname,
'lastname' => $user->lastname,
'email' => $user->email,
'roles' => $this->rolesToObjectMap($roleNames),
'class_section_id' => $teacherContext['class_section_id'],
'class_section_name' => $teacherContext['class_section_name'],
],
];
}
}