177 lines
9.3 KiB
PHP
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);
|
|
}
|
|
}
|