| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366 |
- <?php
- /*
- * Copyright 2026 Alessandro Chiapparini <sprint_planer_web@chiapparini.org>
- * 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 <<<HTML
- <!doctype html><meta charset="utf-8">
- <title>OIDC not configured</title>
- <div style="font-family:system-ui;max-width:560px;margin:4rem auto;padding:1rem;border:1px solid #e2e8f0;border-radius:8px">
- <h1 style="margin:0 0 .5rem;font-size:1.1rem">Sign-in is not configured</h1>
- <p style="color:#475569;line-height:1.5">
- Set <code>ENTRA_TENANT_ID</code>, <code>ENTRA_CLIENT_ID</code>,
- <code>ENTRA_CLIENT_SECRET</code> and <code>APP_BASE_URL</code> in
- <code>.env</code> and restart the container.
- </p>
- </div>
- HTML;
- }
- }
|