fix first production issues
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
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
This commit is contained in:
@@ -22,7 +22,10 @@ class ApiLoginSecurityService
|
||||
|
||||
public function isIpBlocked(string $ip): bool
|
||||
{
|
||||
$row = IpAttempt::query()->where('ip_address', $ip)->first();
|
||||
$row = IpAttempt::query()
|
||||
->where('ip_address', $ip)
|
||||
->first();
|
||||
|
||||
if (! $row || ! $row->blocked_until) {
|
||||
return false;
|
||||
}
|
||||
@@ -35,14 +38,16 @@ class ApiLoginSecurityService
|
||||
$now = CarbonImmutable::now('UTC');
|
||||
$nowStr = $now->toDateTimeString();
|
||||
|
||||
$attempt = IpAttempt::query()->where('ip_address', $ip)->first();
|
||||
$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.
|
||||
// If the previous block has expired, begin a fresh failure window.
|
||||
$attempts = $previouslyBlocked
|
||||
? ((int) $attempt->attempts) + 1
|
||||
: 1;
|
||||
@@ -64,14 +69,19 @@ class ApiLoginSecurityService
|
||||
'ip_address' => $ip,
|
||||
'attempts' => 1,
|
||||
'last_attempt_at' => $nowStr,
|
||||
'blocked_until' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function handleFailedLogin(User $user, string $email, string $ip): void
|
||||
{
|
||||
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(),
|
||||
@@ -79,8 +89,14 @@ class ApiLoginSecurityService
|
||||
|
||||
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]);
|
||||
|
||||
Log::notice(
|
||||
'api_login: account suspended after failed attempts',
|
||||
[
|
||||
'email' => $email,
|
||||
'user_id' => $user->id,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
$user->fill($data);
|
||||
@@ -96,10 +112,16 @@ class ApiLoginSecurityService
|
||||
$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'));
|
||||
public function logSuccessfulLogin(
|
||||
User $user,
|
||||
Request $request
|
||||
): void {
|
||||
$schoolYear = trim(
|
||||
(string) (
|
||||
Configuration::getConfig('school_year')
|
||||
?? date('Y')
|
||||
)
|
||||
);
|
||||
|
||||
LoginActivity::query()->create([
|
||||
'user_id' => $user->id,
|
||||
@@ -107,21 +129,24 @@ class ApiLoginSecurityService
|
||||
'login_time' => utc_now(),
|
||||
'ip_address' => $request->ip(),
|
||||
'user_agent' => (string) ($request->userAgent() ?? ''),
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester !== '' ? $semester : 'Fall',
|
||||
'school_year' => $schoolYear !== ''
|
||||
? $schoolYear
|
||||
: date('Y'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns roles as an object map {"RoleName": true}.
|
||||
* Returns roles as an object map:
|
||||
* {"RoleName": true}
|
||||
*
|
||||
* @param list<string> $roleNames
|
||||
* @param list<string> $roleNames
|
||||
*/
|
||||
public function rolesToObjectMap(array $roleNames): object
|
||||
{
|
||||
$rolesMap = [];
|
||||
foreach ($roleNames as $r) {
|
||||
$rolesMap[$r] = true;
|
||||
|
||||
foreach ($roleNames as $roleName) {
|
||||
$rolesMap[$roleName] = true;
|
||||
}
|
||||
|
||||
return (object) $rolesMap;
|
||||
@@ -130,19 +155,40 @@ class ApiLoginSecurityService
|
||||
/**
|
||||
* 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}}
|
||||
* @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
|
||||
{
|
||||
public function buildLoginResponse(
|
||||
User $user,
|
||||
string $jwtToken
|
||||
): array {
|
||||
$roleNames = $user->roleNames();
|
||||
$fullName = trim(($user->firstname ?? '').' '.($user->lastname ?? ''));
|
||||
|
||||
$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',
|
||||
@@ -170,4 +216,4 @@ class ApiLoginSecurityService
|
||||
|
||||
return (int) ($ttl ?? 60) * 60;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user