Files
Investidor-V2/tools/portal-qa-auth-trace.php
nala 287e991486
tests / ci (push) Waiting to run
oportunidades 7
2026-10-13 11:40:29 +02:00

177 lines
9.3 KiB
PHP

<?php
// Loaded exclusively by the guarded QA router. Never log request bodies or cookie values.
use App\Http\Middleware\HandleInertiaRequests;
use App\Models\User;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Http\Request;
use Illuminate\Session\FileSessionHandler;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use PragmaRX\Google2FA\Google2FA;
use Symfony\Component\HttpFoundation\Response;
class PortalQaAuthTrace
{
public static function pseudonym(?string $value): ?string
{
if (! $value) {
return null;
}
$file = storage_path('app/private/investmoz-v2/portal-audit/auth-trace-salt-private');
if (! is_file($file)) {
$handle = fopen($file, 'x');
if ($handle) {
fwrite($handle, random_bytes(32));
fclose($handle);
}
}
return substr(hash_hmac('sha256', $value, file_get_contents($file)), 0, 24);
}
public static function destination(?string $url): ?array
{
if (! $url) {
return null;
}
$parts = parse_url($url);
return ['origin' => isset($parts['host']) ? ($parts['scheme'] ?? 'http').'://'.$parts['host'].(isset($parts['port']) ? ':'.$parts['port'] : '') : null,
'path' => $parts['path'] ?? '/']; // Query strings may contain tokens: excluded.
}
public static function state(Request $request): ?array
{
if (! $request->hasSession()) {
return null;
}
$session = $request->session();
$guard = Auth::guard(config('fortify.guard'));
return ['session' => self::pseudonym($session->getId()),
'accepted_cookie_session' => self::pseudonym($request->cookies->get(config('session.cookie'))),
'pending_mfa_user' => $session->get('login.id'),
'stored_web_user' => $session->get($guard->getName()), 'authenticated_user' => $guard->id(),
'intended' => self::destination($session->get('url.intended')),
'handler' => get_class($session->getHandler())];
}
public function handle(Request $request, Closure $next): Response
{
if (! app()->environment('testing') || (int) $request->getPort() !== 18097) {
return $next($request);
}
$trace = bin2hex(random_bytes(8));
$request->attributes->set('qa_auth_trace', $trace);
$names = [];
foreach (explode(';', $request->headers->get('Cookie', '')) as $cookie) {
$name = trim(explode('=', $cookie, 2)[0]);
if ($name === 'XSRF-TOKEN' || str_ends_with($name, '_qa') || str_starts_with($name, 'investmoz_qa_')) {
$names[$name] = ($names[$name] ?? 0) + 1;
}
}
$response = $next($request);
$state = self::state($request);
$loginInputCheck = null;
if ($request->isMethod('POST') && $request->is('login')) {
$email = $request->input('email');
$password = $request->input('password');
$actor = User::find(18);
$emailMatches = is_string($email) && $actor && $email === $actor->email;
$loginInputCheck = ['expected_qa_account' => 18, 'email_exact_matches_qa18' => (bool) $emailMatches,
'password_matches_qa18' => $emailMatches && is_string($password)
? Hash::check($password, $actor->password) : null];
unset($email, $password, $actor);
}
$persisted = null;
if ($request->hasSession()) {
$raw = $request->session()->getHandler()->read($request->session()->getId());
$data = $raw ? json_decode($raw, true) : null;
$guardKey = Auth::guard(config('fortify.guard'))->getName();
$persisted = ['readable' => is_array($data), 'stored_web_user' => is_array($data) ? ($data[$guardKey] ?? null) : null];
}
$cookies = [];
foreach ($response->headers->getCookies() as $cookie) {
$cookies[] = ['name' => $cookie->getName(), 'domain' => $cookie->getDomain(), 'path' => $cookie->getPath(),
'secure' => $cookie->isSecure(), 'http_only' => $cookie->isHttpOnly(), 'same_site' => $cookie->getSameSite()];
}
$middleware = $request->route() ? app('router')->gatherRouteMiddleware($request->route()) : [];
$handlerDirectory = null;
if ($request->hasSession() && $request->session()->getHandler() instanceof FileSessionHandler) {
$property = new ReflectionProperty(FileSessionHandler::class, 'path');
$handlerDirectory = $property->getValue($request->session()->getHandler());
}
$entry = ['at_utc' => gmdate('c'), 'trace' => $trace, 'method' => $request->method(), 'path' => '/'.$request->path(),
'origin' => $request->getSchemeAndHttpHost(), 'cookie_name_counts' => $names,
'inertia' => $request->headers->has('X-Inertia'), 'json_expected' => $request->expectsJson(),
'inertia_version_matches' => $request->headers->has('X-Inertia-Version')
? hash_equals((string) app(HandleInertiaRequests::class)->version($request), (string) $request->headers->get('X-Inertia-Version')) : null,
'qa_restored_account_rejected' => $request->attributes->get('qa_restored_account_rejected', false),
'login_input_check' => $loginInputCheck, // Booleans only: no submitted values or password fingerprints.
'mfa_input_check' => $request->attributes->get('qa_mfa_input_check'),
'loaded' => $request->attributes->get('qa_auth_loaded'), 'after' => $state, 'persisted' => $persisted,
'status' => $response->getStatusCode(), 'redirect' => self::destination($response->headers->get('Location') ?? $response->headers->get('X-Inertia-Location')),
'set_cookie_attributes' => $cookies, 'default_guard' => config('auth.defaults.guard'), 'fortify_guard' => config('fortify.guard'),
'session_driver' => config('session.driver'), 'lifetime_minutes' => config('session.lifetime'),
'handler_directory_matches_config' => $handlerDirectory === config('session.files'),
'middleware' => $middleware,
'key_fingerprint' => hash('sha256', app('encrypter')->getKey()),
'qa_target' => config('database.connections.pgsql.database').':'.config('database.connections.pgsql.port'),
'config_cached' => app()->configurationIsCached()];
$file = storage_path('app/private/investmoz-v2/portal-audit/auth-session-trace-qa.jsonl');
// Preserve the original trace. A full file must not silently stop QA diagnostics.
$segment = 0;
while (is_file($file) && filesize($file) >= 5 * 1024 * 1024) {
$segment++;
$file = storage_path('app/private/investmoz-v2/portal-audit/auth-session-trace-qa-'.gmdate('Ymd').'-'.$segment.'.jsonl');
}
file_put_contents($file, json_encode($entry, JSON_THROW_ON_ERROR).PHP_EOL, FILE_APPEND | LOCK_EX);
$response->headers->set('X-Investmoz-QA-Auth-Trace', $trace);
return $response;
}
}
class PortalQaSessionTrace
{
public function handle(Request $request, Closure $next): Response
{
if ($request->attributes->has('qa_auth_trace')) {
$request->attributes->set('qa_auth_loaded', PortalQaAuthTrace::state($request));
if ($request->isMethod('POST') && $request->is('two-factor-challenge') && (int) $request->session()->get('login.id') === 18) {
// Diagnostic only: never consume a code, extend acceptance windows or authenticate.
$code = $request->input('code');
$check = ['account_id' => 18, 'six_digits' => is_string($code) && preg_match('/^[0-9]{6}$/D', $code) === 1];
try {
$actor = User::findOrFail(18);
$secret = app('encrypter')->decrypt($actor->two_factor_secret);
$check['secret_decrypts'] = is_string($secret);
if ($check['six_digits'] && $check['secret_decrypts']) {
$engine = new Google2FA;
$window = config('fortify-options.two-factor-authentication.window');
if (is_int($window)) {
$engine->setWindow($window);
}
$previous = app('cache.store')->get('fortify.2fa_codes.'.md5($code));
$check['matches_configured_time_window'] = $engine->verifyKey($secret, $code);
$check['previously_used_in_cache'] = $previous !== null;
$check['accepted_by_replay_check'] = $engine->verifyKeyNewer($secret, $code, $previous) !== false;
$engine->setWindow(12);
$check['matches_wider_diagnostic_window_only'] = ! $check['matches_configured_time_window'] && $engine->verifyKey($secret, $code);
}
} catch (DecryptException) {
$check['secret_decrypts'] = false;
} catch (Throwable) {
$check['diagnostic_unavailable'] = true;
} finally {
unset($code, $actor, $secret, $engine, $previous);
}
$request->attributes->set('qa_mfa_input_check', $check);
}
}
return $next($request);
}
}