Add HttpOnly cookie auth for cross-domain SPA login

Problem: Frontend JS bundle used credentials:'same-origin' which
dropped cookies on cross-domain requests. Login worked (200) but
the subsequent /auth/me check returned 401, leaving the user stuck
on the login screen.

Fix:
- AuthController now sets a dtp_jwt HttpOnly cookie on login/refresh
- Cookie uses Domain=.fahrschultermin.de (shared between frontend
  and api subdomain), Secure, SameSite=Lax, Max-Age=8h
- JwtMiddleware reads JWT from Authorization header OR cookie
- Added AuthController::me() endpoint (was missing, caused 500)
- Logout endpoint clears the cookie
- Frontend index.php patches fetch() to use credentials:'include'
  for all /api/v1/* calls
This commit is contained in:
Hermes Agent
2026-06-04 20:33:31 +02:00
parent 5f753c15df
commit 5d87e4975c
1040 changed files with 95 additions and 13 deletions

0
api/src/Config/Container.php Normal file → Executable file
View File

0
api/src/Controllers/AppointmentsController.php Normal file → Executable file
View File

44
api/src/Controllers/AuthController.php Normal file → Executable file
View File

@@ -41,6 +41,12 @@ final class AuthController
// Generate refresh token
$refreshToken = $this->generateRefreshToken($user->id);
// Set JWT in HttpOnly cookie for cross-domain SPA auth.
// Domain=.fahrschultermin.de makes the cookie available to BOTH
// fahrschultermin.de and api.fahrschultermin.de, so the browser
// sends it automatically with credentials:'include' requests.
$response = $this->setAuthCookie($response, $jwt);
$userData = $user->toArray();
unset($userData['password_hash']);
@@ -82,6 +88,9 @@ final class AuthController
$tokenRecord->revoke();
$newRefreshToken = $this->generateRefreshToken($user->id);
// Update auth cookie with new JWT
$response = $this->setAuthCookie($response, $jwt);
$userData = $user->toArray();
unset($userData['password_hash']);
@@ -93,6 +102,19 @@ final class AuthController
]);
}
/**
* Return the current authenticated user. Used by the frontend to
* restore a session from the dtp_jwt cookie on page reload.
*/
public function me(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$user = $request->getAttribute('user');
if (!$user) {
return $this->json($response, ['message' => 'Not authenticated'], 401);
}
return $this->json($response, $user);
}
public function logout(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$body = json_decode($request->getBody()->getContents(), true);
@@ -105,9 +127,31 @@ final class AuthController
}
}
// Clear auth cookie
$response = $this->clearAuthCookie($response);
return $this->json($response, ['message' => 'Logged out']);
}
private function setAuthCookie(ResponseInterface $response, string $jwt): ResponseInterface
{
// SameSite=Lax works for same-site requests (fahrschultermin.de → api.fahrschultermin.de
// is technically cross-origin but the registrable domain matches, so Lax is enough
// and is more browser-friendly than None). Secure is required for cross-site cookies.
$cookie = sprintf(
'dtp_jwt=%s; Path=/; Domain=.fahrschultermin.de; Max-Age=%d; Secure; HttpOnly; SameSite=Lax',
rawurlencode($jwt),
self::JWT_EXPIRY
);
return $response->withAddedHeader('Set-Cookie', $cookie);
}
private function clearAuthCookie(ResponseInterface $response): ResponseInterface
{
$cookie = 'dtp_jwt=; Path=/; Domain=.fahrschultermin.de; Max-Age=0; Secure; HttpOnly; SameSite=Lax';
return $response->withAddedHeader('Set-Cookie', $cookie);
}
private function generateJwt(User $user): string
{
$now = time();

0
api/src/Controllers/BootstrapController.php Normal file → Executable file
View File

0
api/src/Controllers/InstructorsController.php Normal file → Executable file
View File

0
api/src/Controllers/LessonTypesController.php Normal file → Executable file
View File

0
api/src/Controllers/StudentsController.php Normal file → Executable file
View File

0
api/src/Controllers/TemplatesController.php Normal file → Executable file
View File

36
api/src/Middleware/JwtMiddleware.php Normal file → Executable file
View File

@@ -15,17 +15,15 @@ final class JwtMiddleware implements \Psr\Http\Server\MiddlewareInterface
{
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$authHeader = $request->getHeaderLine('Authorization') ?? '';
$token = $this->extractToken($request);
if (!str_starts_with($authHeader, 'Bearer ')) {
if ($token === null) {
$body = json_encode(['message' => 'Missing or invalid Authorization header']);
return new \Slim\Psr7\Response(401)
->withHeader('Content-Type', 'application/json')
->withBody((new \Slim\Psr7\Factory\StreamFactory())->createStream($body));
}
$token = substr($authHeader, 7);
try {
$secret = $_ENV['JWT_SECRET'] ?? 'this-is-a-32-character-secret-key-for-testing';
$decoded = JWT::decode($token, new Key($secret, 'HS256'));
@@ -59,4 +57,34 @@ final class JwtMiddleware implements \Psr\Http\Server\MiddlewareInterface
return $handler->handle($request);
}
/**
* Extract JWT from Authorization header (Bearer) or from `dtp_jwt` cookie.
* Cookie is used for cross-domain SPA auth when the browser auto-sends it.
*/
private function extractToken(ServerRequestInterface $request): ?string
{
// 1. Try Authorization header first
$authHeader = $request->getHeaderLine('Authorization');
if (str_starts_with($authHeader, 'Bearer ')) {
return substr($authHeader, 7);
}
// 2. Fallback: read from cookie
$cookieHeader = $request->getHeaderLine('Cookie');
if (!empty($cookieHeader)) {
$cookies = [];
foreach (explode(';', $cookieHeader) as $pair) {
$parts = explode('=', trim($pair), 2);
if (count($parts) === 2) {
$cookies[$parts[0]] = urldecode($parts[1]);
}
}
if (isset($cookies['dtp_jwt']) && !empty($cookies['dtp_jwt'])) {
return $cookies['dtp_jwt'];
}
}
return null;
}
}

0
api/src/Models/Appointment.php Normal file → Executable file
View File

0
api/src/Models/Instructor.php Normal file → Executable file
View File

0
api/src/Models/LessonType.php Normal file → Executable file
View File

0
api/src/Models/LessonTypeTemplateVisibility.php Normal file → Executable file
View File

0
api/src/Models/LicenseClassTemplate.php Normal file → Executable file
View File

0
api/src/Models/LicenseTemplateRequirement.php Normal file → Executable file
View File

0
api/src/Models/RefreshToken.php Normal file → Executable file
View File

0
api/src/Models/Student.php Normal file → Executable file
View File

0
api/src/Models/Tenant.php Normal file → Executable file
View File

0
api/src/Models/User.php Normal file → Executable file
View File

0
api/src/Routes/api.php Normal file → Executable file
View File

0
api/src/Services/InstructorAvailabilityService.php Normal file → Executable file
View File