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 $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; } }