AuthController.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366
  1. <?php
  2. /*
  3. * Copyright 2026 Alessandro Chiapparini <sprint_planer_web@chiapparini.org>
  4. * SPDX-License-Identifier: Apache-2.0
  5. *
  6. * Licensed under the Apache License, Version 2.0 (the "License");
  7. * you may not use this file except in compliance with the License.
  8. * See the LICENSE file in the project root for the full license text.
  9. */
  10. declare(strict_types=1);
  11. namespace App\Controllers;
  12. use App\Auth\BootstrapAdmin;
  13. use App\Auth\LocalAdmin;
  14. use App\Auth\OidcClaims;
  15. use App\Auth\OidcClient;
  16. use App\Auth\SessionGuard;
  17. use App\Http\Request;
  18. use App\Http\Response;
  19. use App\Http\View;
  20. use App\Repositories\AuthThrottleRepository;
  21. use App\Repositories\UserRepository;
  22. use App\Services\AuditLogger;
  23. use DateTimeImmutable;
  24. use DateTimeZone;
  25. use PDO;
  26. use Throwable;
  27. final class AuthController
  28. {
  29. public function __construct(
  30. private readonly PDO $pdo,
  31. private readonly UserRepository $users,
  32. private readonly AuditLogger $audit,
  33. private readonly View $view,
  34. private readonly AuthThrottleRepository $throttle,
  35. ) {
  36. }
  37. /** GET /auth/login — kick off the OIDC flow. */
  38. public function login(Request $req): Response
  39. {
  40. SessionGuard::start();
  41. if (!OidcClient::isConfigured()) {
  42. return Response::html(
  43. $this->configErrorPage(),
  44. 503
  45. );
  46. }
  47. try {
  48. $oidc = OidcClient::create();
  49. // No 'code' in the query on this route → authenticate() builds the
  50. // Entra authorize URL and redirects via header() + exit. The line
  51. // below is only reached if the library ever changes that contract.
  52. $oidc->authenticate();
  53. } catch (Throwable $e) {
  54. $this->logFailure($req, 'login_redirect_failed: ' . $e->getMessage());
  55. return Response::redirect('/?auth_error=1');
  56. }
  57. return Response::redirect('/');
  58. }
  59. /** GET /auth/callback — exchange code for tokens, upsert user, start session. */
  60. public function callback(Request $req): Response
  61. {
  62. SessionGuard::start();
  63. // Entra can redirect back with an explicit error (e.g. user denied consent).
  64. if (isset($req->query['error'])) {
  65. $desc = (string) ($req->query['error_description'] ?? $req->query['error']);
  66. $this->logFailure($req, 'entra_error: ' . $desc);
  67. return Response::redirect('/?auth_error=1');
  68. }
  69. try {
  70. $oidc = OidcClient::create();
  71. $oidc->authenticate();
  72. $claims = $oidc->getVerifiedClaims();
  73. } catch (Throwable $e) {
  74. $this->logFailure($req, 'token_validation_failed: ' . $e->getMessage());
  75. return Response::redirect('/?auth_error=1');
  76. }
  77. $oid = (string) ($claims->oid ?? $claims->sub ?? '');
  78. if ($oid === '') {
  79. $this->logFailure($req, 'missing_oid_or_sub_claim');
  80. return Response::redirect('/?auth_error=1');
  81. }
  82. // R01-N18: never fall back to `preferred_username` (user-controlled
  83. // in some Entra tenants) and reject `email` when the issuer marks
  84. // it unverified. Identity is still keyed by `oid`; this only
  85. // governs the human-readable label that ends up in the audit log.
  86. $email = OidcClaims::resolveEmail($claims, $oid);
  87. $name = (string) ($claims->name ?? $email);
  88. // R01-N03: explicit env-bootstrap. The OIDC path no longer auto-
  89. // promotes the first user to land — an attacker with a valid
  90. // tenant account could otherwise win the race to /auth/callback.
  91. // The signing principal must match BOOTSTRAP_ADMIN_OID or
  92. // BOOTSTRAP_ADMIN_EMAIL, AND no admin must yet exist.
  93. $shouldBootstrap = $this->users->countAdmins() === 0
  94. && BootstrapAdmin::matches($oid, $email);
  95. $this->pdo->beginTransaction();
  96. try {
  97. $result = $this->users->upsertFromOidc($oid, $email, $name, $shouldBootstrap);
  98. $user = $result['user'];
  99. $before = $result['before']?->toAuditSnapshot();
  100. $action = $before === null ? 'CREATE' : 'UPDATE';
  101. $this->audit->record(
  102. action: $action,
  103. entityType: 'user',
  104. entityId: $user->id,
  105. before: $before,
  106. after: $user->toAuditSnapshot(),
  107. userId: $user->id,
  108. userEmail: $user->email,
  109. ipAddress: $req->ip(),
  110. userAgent: $req->userAgent(),
  111. );
  112. if ($shouldBootstrap) {
  113. $this->audit->record(
  114. action: 'BOOTSTRAP_ADMIN',
  115. entityType: 'user',
  116. entityId: $user->id,
  117. before: null,
  118. after: ['is_admin' => 1, 'via' => 'oidc'],
  119. userId: $user->id,
  120. userEmail: $user->email,
  121. ipAddress: $req->ip(),
  122. userAgent: $req->userAgent(),
  123. );
  124. }
  125. $this->audit->record(
  126. action: 'LOGIN',
  127. entityType: 'user',
  128. entityId: $user->id,
  129. before: null,
  130. after: null,
  131. userId: $user->id,
  132. userEmail: $user->email,
  133. ipAddress: $req->ip(),
  134. userAgent: $req->userAgent(),
  135. );
  136. $this->pdo->commit();
  137. } catch (Throwable $e) {
  138. $this->pdo->rollBack();
  139. $this->logFailure($req, 'user_upsert_failed: ' . $e->getMessage());
  140. return Response::redirect('/?auth_error=1');
  141. }
  142. SessionGuard::login($user);
  143. return Response::redirect('/');
  144. }
  145. /** POST /auth/logout — end the session; CSRF-protected. */
  146. public function logout(Request $req): Response
  147. {
  148. SessionGuard::start();
  149. if (!SessionGuard::verifyCsrf($req)) {
  150. return Response::text('CSRF token invalid', 403);
  151. }
  152. $user = SessionGuard::currentUser($this->users);
  153. if ($user !== null) {
  154. $this->pdo->beginTransaction();
  155. try {
  156. $this->audit->record(
  157. action: 'LOGOUT',
  158. entityType: 'user',
  159. entityId: $user->id,
  160. before: null,
  161. after: null,
  162. userId: $user->id,
  163. userEmail: $user->email,
  164. ipAddress: $req->ip(),
  165. userAgent: $req->userAgent(),
  166. );
  167. $this->pdo->commit();
  168. } catch (Throwable) {
  169. $this->pdo->rollBack();
  170. // audit failure shouldn't block logout
  171. }
  172. }
  173. SessionGuard::logout();
  174. return Response::redirect('/');
  175. }
  176. /** GET /auth/local — render the local-admin login form. 404 when disabled. */
  177. public function loginLocalForm(Request $req): Response
  178. {
  179. if (!LocalAdmin::isEnabled()) {
  180. return Response::text('Not Found', 404);
  181. }
  182. SessionGuard::start();
  183. $error = $req->queryString('error') === '1';
  184. $throttled = $req->queryString('throttled') === '1';
  185. return Response::html($this->view->render('auth/local', [
  186. 'title' => 'Local sign-in',
  187. 'currentUser' => null,
  188. 'csrfToken' => SessionGuard::csrfToken(),
  189. 'email' => LocalAdmin::email(),
  190. 'error' => $error,
  191. 'throttled' => $throttled,
  192. ]));
  193. }
  194. /** POST /auth/local — verify credentials, upsert user, start session. */
  195. public function loginLocal(Request $req): Response
  196. {
  197. if (!LocalAdmin::isEnabled()) {
  198. return Response::text('Not Found', 404);
  199. }
  200. SessionGuard::start();
  201. if (!SessionGuard::verifyCsrf($req)) {
  202. return Response::text('CSRF token invalid', 403);
  203. }
  204. $email = $req->postString('email');
  205. $password = isset($req->post['password']) && is_scalar($req->post['password'])
  206. ? (string) $req->post['password']
  207. : '';
  208. $ip = $req->ip();
  209. $now = new DateTimeImmutable('now', new DateTimeZone('UTC'));
  210. // R01-N06: refuse and audit while a lockout is active. We do this
  211. // BEFORE password verification so a slow `password_verify()` can't
  212. // be turned into an oracle by a still-locked attacker.
  213. $lockedUntil = $this->throttle->lockoutFor($ip, $email, $now);
  214. if ($lockedUntil !== null) {
  215. $this->logFailure(
  216. $req,
  217. 'local_admin_throttled_until_'
  218. . $lockedUntil->format('Y-m-d\TH:i:s\Z')
  219. );
  220. return Response::redirect('/auth/local?throttled=1');
  221. }
  222. if (!LocalAdmin::verify($email, $password)) {
  223. $this->throttle->recordFailure($ip, $email, $now);
  224. $this->logFailure($req, 'local_admin_credential_mismatch');
  225. return Response::redirect('/auth/local?error=1');
  226. }
  227. // Clear the (ip, email) bucket on success so the next bad password
  228. // doesn't lock out a legitimate operator who just signed in.
  229. $this->throttle->clear($ip, $email);
  230. $this->pdo->beginTransaction();
  231. try {
  232. $isFirstUser = $this->users->count() === 0;
  233. $result = $this->users->upsertFromOidc(
  234. oid: LocalAdmin::oid(),
  235. email: LocalAdmin::email(),
  236. name: LocalAdmin::displayName(),
  237. promoteToAdmin: $isFirstUser,
  238. forceAdmin: true,
  239. );
  240. $user = $result['user'];
  241. $before = $result['before']?->toAuditSnapshot();
  242. $action = $before === null ? 'CREATE' : 'UPDATE';
  243. $this->audit->record(
  244. action: $action,
  245. entityType: 'user',
  246. entityId: $user->id,
  247. before: $before,
  248. after: $user->toAuditSnapshot(),
  249. userId: $user->id,
  250. userEmail: $user->email,
  251. ipAddress: $req->ip(),
  252. userAgent: $req->userAgent(),
  253. );
  254. if ($isFirstUser) {
  255. $this->audit->record(
  256. action: 'BOOTSTRAP_ADMIN',
  257. entityType: 'user',
  258. entityId: $user->id,
  259. before: null,
  260. after: ['is_admin' => 1, 'via' => 'local'],
  261. userId: $user->id,
  262. userEmail: $user->email,
  263. ipAddress: $req->ip(),
  264. userAgent: $req->userAgent(),
  265. );
  266. }
  267. $this->audit->record(
  268. action: 'LOGIN',
  269. entityType: 'user',
  270. entityId: $user->id,
  271. before: null,
  272. after: ['via' => 'local'],
  273. userId: $user->id,
  274. userEmail: $user->email,
  275. ipAddress: $req->ip(),
  276. userAgent: $req->userAgent(),
  277. );
  278. $this->pdo->commit();
  279. } catch (Throwable $e) {
  280. $this->pdo->rollBack();
  281. $this->logFailure($req, 'local_admin_upsert_failed: ' . $e->getMessage());
  282. return Response::redirect('/auth/local?error=1');
  283. }
  284. SessionGuard::login($user);
  285. return Response::redirect('/');
  286. }
  287. /** Write a LOGIN_FAILED audit row in its own tx; never throws. */
  288. private function logFailure(Request $req, string $reason): void
  289. {
  290. try {
  291. $this->pdo->beginTransaction();
  292. $this->audit->record(
  293. action: 'LOGIN_FAILED',
  294. entityType: 'user',
  295. entityId: null,
  296. before: null,
  297. after: ['reason' => $reason],
  298. userId: null,
  299. userEmail: null,
  300. ipAddress: $req->ip(),
  301. userAgent: $req->userAgent(),
  302. );
  303. $this->pdo->commit();
  304. } catch (Throwable) {
  305. if ($this->pdo->inTransaction()) {
  306. $this->pdo->rollBack();
  307. }
  308. }
  309. }
  310. private function configErrorPage(): string
  311. {
  312. return <<<HTML
  313. <!doctype html><meta charset="utf-8">
  314. <title>OIDC not configured</title>
  315. <div style="font-family:system-ui;max-width:560px;margin:4rem auto;padding:1rem;border:1px solid #e2e8f0;border-radius:8px">
  316. <h1 style="margin:0 0 .5rem;font-size:1.1rem">Sign-in is not configured</h1>
  317. <p style="color:#475569;line-height:1.5">
  318. Set <code>ENTRA_TENANT_ID</code>, <code>ENTRA_CLIENT_ID</code>,
  319. <code>ENTRA_CLIENT_SECRET</code> and <code>APP_BASE_URL</code> in
  320. <code>.env</code> and restart the container.
  321. </p>
  322. </div>
  323. HTML;
  324. }
  325. }