137 lines
4.0 KiB
PHP
137 lines
4.0 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Auth;
|
|
|
|
use App\Models\IpAttempt;
|
|
use App\Models\LoginActivity;
|
|
use App\Models\Configuration;
|
|
use App\Models\User;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
/**
|
|
* Mirrors CodeIgniter `AuthController::apiLogin` security behavior:
|
|
* IP lockout (ip_attempts), failed_attempts / suspension after 3 failures, login_activity logging.
|
|
*/
|
|
class ApiLoginSecurityService
|
|
{
|
|
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 = utc_now();
|
|
|
|
$attempt = IpAttempt::query()->where('ip_address', $ip)->first();
|
|
if ($attempt) {
|
|
$attempts = ((int) $attempt->attempts) + 1;
|
|
$blockedUntil = null;
|
|
if ($attempts >= 10) {
|
|
$blockedUntil = date('Y-m-d H:i:s', strtotime('+24 hours'));
|
|
}
|
|
|
|
$attempt->update([
|
|
'attempts' => $attempts,
|
|
'last_attempt_at' => $now,
|
|
'blocked_until' => $blockedUntil,
|
|
]);
|
|
|
|
return;
|
|
}
|
|
|
|
IpAttempt::query()->create([
|
|
'ip_address' => $ip,
|
|
'attempts' => 1,
|
|
'last_attempt_at' => $now,
|
|
]);
|
|
}
|
|
|
|
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;
|
|
// CI sends reset email via View\UserController::sendResetEmail — wire Laravel mail here when parity is needed.
|
|
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',
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* CI 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 aligned with the CodeIgniter 4 `AuthController::apiLogin` success payload.
|
|
*
|
|
* @return array{status: true, token: string, user: array{id: int, name: string, roles: object}}
|
|
*/
|
|
public function buildLoginResponse(User $user, string $jwtToken): array
|
|
{
|
|
$roleNames = $user->roleNamesLikeCodeIgniter();
|
|
$fullName = trim(($user->firstname ?? '').' '.($user->lastname ?? ''));
|
|
|
|
return [
|
|
'status' => true,
|
|
'token' => $jwtToken,
|
|
'user' => [
|
|
'id' => (int) $user->id,
|
|
'name' => $fullName,
|
|
'roles' => $this->rolesToObjectMap($roleNames),
|
|
],
|
|
];
|
|
}
|
|
}
|