* SPDX-License-Identifier: Apache-2.0 * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * See the LICENSE file in the project root for the full license text. */ declare(strict_types=1); namespace App\Controllers; use App\Auth\BootstrapAdmin; use App\Auth\LocalAdmin; use App\Auth\OidcClaims; use App\Auth\OidcClient; use App\Auth\SessionGuard; use App\Http\Request; use App\Http\Response; use App\Http\View; use App\Repositories\AuthThrottleRepository; use App\Repositories\UserRepository; use App\Services\AuditLogger; use DateTimeImmutable; use DateTimeZone; use PDO; use Throwable; final class AuthController { public function __construct( private readonly PDO $pdo, private readonly UserRepository $users, private readonly AuditLogger $audit, private readonly View $view, private readonly AuthThrottleRepository $throttle, ) { } /** GET /auth/login — kick off the OIDC flow. */ public function login(Request $req): Response { SessionGuard::start(); if (!OidcClient::isConfigured()) { return Response::html( $this->configErrorPage(), 503 ); } try { $oidc = OidcClient::create(); // No 'code' in the query on this route → authenticate() builds the // Entra authorize URL and redirects via header() + exit. The line // below is only reached if the library ever changes that contract. $oidc->authenticate(); } catch (Throwable $e) { $this->logFailure($req, 'login_redirect_failed: ' . $e->getMessage()); return Response::redirect('/?auth_error=1'); } return Response::redirect('/'); } /** GET /auth/callback — exchange code for tokens, upsert user, start session. */ public function callback(Request $req): Response { SessionGuard::start(); // Entra can redirect back with an explicit error (e.g. user denied consent). if (isset($req->query['error'])) { $desc = (string) ($req->query['error_description'] ?? $req->query['error']); $this->logFailure($req, 'entra_error: ' . $desc); return Response::redirect('/?auth_error=1'); } try { $oidc = OidcClient::create(); $oidc->authenticate(); $claims = $oidc->getVerifiedClaims(); } catch (Throwable $e) { $this->logFailure($req, 'token_validation_failed: ' . $e->getMessage()); return Response::redirect('/?auth_error=1'); } $oid = (string) ($claims->oid ?? $claims->sub ?? ''); if ($oid === '') { $this->logFailure($req, 'missing_oid_or_sub_claim'); return Response::redirect('/?auth_error=1'); } // R01-N18: never fall back to `preferred_username` (user-controlled // in some Entra tenants) and reject `email` when the issuer marks // it unverified. Identity is still keyed by `oid`; this only // governs the human-readable label that ends up in the audit log. $email = OidcClaims::resolveEmail($claims, $oid); $name = (string) ($claims->name ?? $email); // R01-N03: explicit env-bootstrap. The OIDC path no longer auto- // promotes the first user to land — an attacker with a valid // tenant account could otherwise win the race to /auth/callback. // The signing principal must match BOOTSTRAP_ADMIN_OID or // BOOTSTRAP_ADMIN_EMAIL, AND no admin must yet exist. $shouldBootstrap = $this->users->countAdmins() === 0 && BootstrapAdmin::matches($oid, $email); $this->pdo->beginTransaction(); try { $result = $this->users->upsertFromOidc($oid, $email, $name, $shouldBootstrap); $user = $result['user']; $before = $result['before']?->toAuditSnapshot(); $action = $before === null ? 'CREATE' : 'UPDATE'; $this->audit->record( action: $action, entityType: 'user', entityId: $user->id, before: $before, after: $user->toAuditSnapshot(), userId: $user->id, userEmail: $user->email, ipAddress: $req->ip(), userAgent: $req->userAgent(), ); if ($shouldBootstrap) { $this->audit->record( action: 'BOOTSTRAP_ADMIN', entityType: 'user', entityId: $user->id, before: null, after: ['is_admin' => 1, 'via' => 'oidc'], userId: $user->id, userEmail: $user->email, ipAddress: $req->ip(), userAgent: $req->userAgent(), ); } $this->audit->record( action: 'LOGIN', entityType: 'user', entityId: $user->id, before: null, after: null, userId: $user->id, userEmail: $user->email, ipAddress: $req->ip(), userAgent: $req->userAgent(), ); $this->pdo->commit(); } catch (Throwable $e) { $this->pdo->rollBack(); $this->logFailure($req, 'user_upsert_failed: ' . $e->getMessage()); return Response::redirect('/?auth_error=1'); } SessionGuard::login($user); return Response::redirect('/'); } /** POST /auth/logout — end the session; CSRF-protected. */ public function logout(Request $req): Response { SessionGuard::start(); if (!SessionGuard::verifyCsrf($req)) { return Response::text('CSRF token invalid', 403); } $user = SessionGuard::currentUser($this->users); if ($user !== null) { $this->pdo->beginTransaction(); try { $this->audit->record( action: 'LOGOUT', entityType: 'user', entityId: $user->id, before: null, after: null, userId: $user->id, userEmail: $user->email, ipAddress: $req->ip(), userAgent: $req->userAgent(), ); $this->pdo->commit(); } catch (Throwable) { $this->pdo->rollBack(); // audit failure shouldn't block logout } } SessionGuard::logout(); return Response::redirect('/'); } /** GET /auth/local — render the local-admin login form. 404 when disabled. */ public function loginLocalForm(Request $req): Response { if (!LocalAdmin::isEnabled()) { return Response::text('Not Found', 404); } SessionGuard::start(); $error = $req->queryString('error') === '1'; $throttled = $req->queryString('throttled') === '1'; return Response::html($this->view->render('auth/local', [ 'title' => 'Local sign-in', 'currentUser' => null, 'csrfToken' => SessionGuard::csrfToken(), 'email' => LocalAdmin::email(), 'error' => $error, 'throttled' => $throttled, ])); } /** POST /auth/local — verify credentials, upsert user, start session. */ public function loginLocal(Request $req): Response { if (!LocalAdmin::isEnabled()) { return Response::text('Not Found', 404); } SessionGuard::start(); if (!SessionGuard::verifyCsrf($req)) { return Response::text('CSRF token invalid', 403); } $email = $req->postString('email'); $password = isset($req->post['password']) && is_scalar($req->post['password']) ? (string) $req->post['password'] : ''; $ip = $req->ip(); $now = new DateTimeImmutable('now', new DateTimeZone('UTC')); // R01-N06: refuse and audit while a lockout is active. We do this // BEFORE password verification so a slow `password_verify()` can't // be turned into an oracle by a still-locked attacker. $lockedUntil = $this->throttle->lockoutFor($ip, $email, $now); if ($lockedUntil !== null) { $this->logFailure( $req, 'local_admin_throttled_until_' . $lockedUntil->format('Y-m-d\TH:i:s\Z') ); return Response::redirect('/auth/local?throttled=1'); } if (!LocalAdmin::verify($email, $password)) { $this->throttle->recordFailure($ip, $email, $now); $this->logFailure($req, 'local_admin_credential_mismatch'); return Response::redirect('/auth/local?error=1'); } // Clear the (ip, email) bucket on success so the next bad password // doesn't lock out a legitimate operator who just signed in. $this->throttle->clear($ip, $email); $this->pdo->beginTransaction(); try { $isFirstUser = $this->users->count() === 0; $result = $this->users->upsertFromOidc( oid: LocalAdmin::oid(), email: LocalAdmin::email(), name: LocalAdmin::displayName(), promoteToAdmin: $isFirstUser, forceAdmin: true, ); $user = $result['user']; $before = $result['before']?->toAuditSnapshot(); $action = $before === null ? 'CREATE' : 'UPDATE'; $this->audit->record( action: $action, entityType: 'user', entityId: $user->id, before: $before, after: $user->toAuditSnapshot(), userId: $user->id, userEmail: $user->email, ipAddress: $req->ip(), userAgent: $req->userAgent(), ); if ($isFirstUser) { $this->audit->record( action: 'BOOTSTRAP_ADMIN', entityType: 'user', entityId: $user->id, before: null, after: ['is_admin' => 1, 'via' => 'local'], userId: $user->id, userEmail: $user->email, ipAddress: $req->ip(), userAgent: $req->userAgent(), ); } $this->audit->record( action: 'LOGIN', entityType: 'user', entityId: $user->id, before: null, after: ['via' => 'local'], userId: $user->id, userEmail: $user->email, ipAddress: $req->ip(), userAgent: $req->userAgent(), ); $this->pdo->commit(); } catch (Throwable $e) { $this->pdo->rollBack(); $this->logFailure($req, 'local_admin_upsert_failed: ' . $e->getMessage()); return Response::redirect('/auth/local?error=1'); } SessionGuard::login($user); return Response::redirect('/'); } /** Write a LOGIN_FAILED audit row in its own tx; never throws. */ private function logFailure(Request $req, string $reason): void { try { $this->pdo->beginTransaction(); $this->audit->record( action: 'LOGIN_FAILED', entityType: 'user', entityId: null, before: null, after: ['reason' => $reason], userId: null, userEmail: null, ipAddress: $req->ip(), userAgent: $req->userAgent(), ); $this->pdo->commit(); } catch (Throwable) { if ($this->pdo->inTransaction()) { $this->pdo->rollBack(); } } } private function configErrorPage(): string { return <<
Set ENTRA_TENANT_ID, ENTRA_CLIENT_ID,
ENTRA_CLIENT_SECRET and APP_BASE_URL in
.env and restart the container.