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 $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, user: array{id: int, name: 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, 'user' => [ 'id' => (int) $user->id, 'name' => $fullName, 'roles' => $this->rolesToObjectMap($roleNames), 'class_section_id' => $teacherContext['class_section_id'], 'class_section_name' => $teacherContext['class_section_name'], ], ]; } }