bodyAll(); $apiTokenEndpointService = app(ApiTokenEndpointService::class); // ---- Login-specific rate limits (stricter than the general API rate limit in ApiBootstrap) ---- $rateLimiter = (app(SecurityServicesFactory::class))->createRateLimiterService(); $ip = $requestRuntime->ip(); $dummyPasswordHash = '$2y$12$cPhOFmglmOpMfs28m7KwMuM58w1W7STb2xtVDsj8.WZCmOCuEtN4a'; $ipResult = $rateLimiter->isBlocked('api.auth.login.ip', $ip); if (!($ipResult['allowed'] ?? true)) { ApiResponse::tooManyRequests((int) ($ipResult['retry_after'] ?? 60)); } // ---- Input ---- $email = strtolower(trim((string) ($input['email'] ?? ''))); $password = (string) ($input['password'] ?? ''); $tokenName = trim((string) ($input['token_name'] ?? '')) ?: 'Login'; $tenantUuid = trim((string) ($input['tenant_uuid'] ?? '')); $validationErrors = formErrors(); if ($email === '') { $validationErrors->add('email', 'required'); } if ($password === '') { $validationErrors->add('password', 'required'); } if ($validationErrors->hasAny()) { ApiResponse::validationFromFormErrors($validationErrors); } // ---- Email+IP rate limit ---- $emailKey = $email . '|' . $ip; $emailResult = $rateLimiter->isBlocked('api.auth.login.email_ip', $emailKey); if (!($emailResult['allowed'] ?? true)) { ApiResponse::tooManyRequests((int) ($emailResult['retry_after'] ?? 60)); } // ---- User lookup + credential verification ---- $userReadRepository = (app(UserServicesFactory::class))->createUserReadRepository(); $user = $userReadRepository->findByEmail($email); // Use a fixed bcrypt hash when the account does not exist to keep verification timing closer. $userPasswordHash = $user ? (string) ($user['password'] ?? '') : $dummyPasswordHash; $passwordMatches = password_verify($password, $userPasswordHash); if ( !$user || !$passwordMatches || !(int) ($user['active'] ?? 0) || empty($user['email_verified_at']) ) { $rateLimiter->registerFailure('api.auth.login.ip', $ip, 10, 600, 300); $rateLimiter->registerFailure('api.auth.login.email_ip', $emailKey, 5, 900, 900); ApiResponse::error('invalid_credentials', 401); } // ---- 2FA stub ---- // When TOTP is implemented, intercept here if totp_secret is non-empty: // return a short-lived challenge token instead of issuing a full API token. // Example: ApiResponse::success(['requires_2fa' => true, 'challenge_token' => '...'], 200); // $totpSecret = trim((string) ($user['totp_secret'] ?? '')); // if ($totpSecret !== '') { /* TODO: 2FA challenge flow */ } // ---- Successful login: reset email+IP rate limit ---- $rateLimiter->reset('api.auth.login.email_ip', $emailKey); // ---- Optional tenant scope ---- $tenantId = null; if ($tenantUuid !== '') { $tenantResolution = $apiTokenEndpointService->resolveLoginTenant((int) ($user['id'] ?? 0), $tenantUuid); if (!($tenantResolution['ok'] ?? false)) { ApiResponse::error( (string) ($tenantResolution['error'] ?? 'invalid_tenant_uuid'), (int) ($tenantResolution['status'] ?? 422) ); } $tenantId = array_key_exists('tenant_id', $tenantResolution) ? (int) ($tenantResolution['tenant_id'] ?? 0) : null; } // ---- Create token ---- $userId = (int) ($user['id'] ?? 0); $result = (app(AuthServicesFactory::class))->createApiTokenService()->create( $userId, $tokenName, $tenantId, null, // expiresAt null → uses getApiTokenDefaultTtlDays() from settings $userId // createdBy = self-issued ); if (!($result['ok'] ?? false)) { $error = (string) ($result['error'] ?? 'create_failed'); if ($error === 'max_tokens_reached') { ApiResponse::error($error, 409); } ApiResponse::error($error, 422); } ApiResponse::success([ 'data' => [ 'token' => (string) ($result['token'] ?? ''), 'token_uuid' => (string) ($result['uuid'] ?? ''), 'expires_at' => $result['expires_at'] ?? null, ], ], 201);