e9c10ae376
API CI/CD / Validate (composer + pint) (push) Failing after 1m11s
API CI/CD / Test (PHPUnit) (push) Failing after 2m30s
API CI/CD / Build frontend assets (push) Successful in 1m14s
API CI/CD / Security audit (push) Failing after 48s
API CI/CD / Deploy to shared hosting (PHP) (push) Has been skipped
219 lines
5.5 KiB
PHP
219 lines
5.5 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;
|
|
use Throwable;
|
|
|
|
/**
|
|
* 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 expired, begin a fresh failure window.
|
|
$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,
|
|
'blocked_until' => null,
|
|
]);
|
|
}
|
|
|
|
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;
|
|
|
|
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')
|
|
)
|
|
);
|
|
|
|
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 !== ''
|
|
? $schoolYear
|
|
: date('Y'),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Returns roles as an object map:
|
|
* {"RoleName": true}
|
|
*
|
|
* @param list<string> $roleNames
|
|
*/
|
|
public function rolesToObjectMap(array $roleNames): object
|
|
{
|
|
$rolesMap = [];
|
|
|
|
foreach ($roleNames as $roleName) {
|
|
$rolesMap[$roleName] = 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,
|
|
'token' => $jwtToken,
|
|
'access_token' => $jwtToken,
|
|
'token_type' => 'bearer',
|
|
'expires_in' => $this->jwtTtlSeconds(),
|
|
'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'],
|
|
],
|
|
];
|
|
}
|
|
|
|
private function jwtTtlSeconds(): int
|
|
{
|
|
try {
|
|
$ttl = config('jwt.ttl');
|
|
} catch (Throwable) {
|
|
$ttl = null;
|
|
}
|
|
|
|
return (int) ($ttl ?? 60) * 60;
|
|
}
|
|
} |