|
@@ -0,0 +1,212 @@
|
|
|
|
|
+<?php
|
|
|
|
|
+
|
|
|
|
|
+declare(strict_types=1);
|
|
|
|
|
+
|
|
|
|
|
+namespace App\Controllers;
|
|
|
|
|
+
|
|
|
|
|
+use App\Auth\OidcClient;
|
|
|
|
|
+use App\Auth\SessionGuard;
|
|
|
|
|
+use App\Http\Request;
|
|
|
|
|
+use App\Http\Response;
|
|
|
|
|
+use App\Repositories\UserRepository;
|
|
|
|
|
+use App\Services\AuditLogger;
|
|
|
|
|
+use PDO;
|
|
|
|
|
+use Throwable;
|
|
|
|
|
+
|
|
|
|
|
+final class AuthController
|
|
|
|
|
+{
|
|
|
|
|
+ public function __construct(
|
|
|
|
|
+ private readonly PDO $pdo,
|
|
|
|
|
+ private readonly UserRepository $users,
|
|
|
|
|
+ private readonly AuditLogger $audit,
|
|
|
|
|
+ ) {
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 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 ?? '');
|
|
|
|
|
+ $email = (string) ($claims->email ?? $claims->preferred_username ?? '');
|
|
|
|
|
+ $name = (string) ($claims->name ?? $email ?? 'user');
|
|
|
|
|
+
|
|
|
|
|
+ if ($oid === '') {
|
|
|
|
|
+ $this->logFailure($req, 'missing_oid_or_sub_claim');
|
|
|
|
|
+ return Response::redirect('/?auth_error=1');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $this->pdo->beginTransaction();
|
|
|
|
|
+ try {
|
|
|
|
|
+ $isFirstUser = $this->users->count() === 0;
|
|
|
|
|
+ $result = $this->users->upsertFromOidc($oid, $email, $name, $isFirstUser);
|
|
|
|
|
+ $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],
|
|
|
|
|
+ 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('/');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 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;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|