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:
0
api/README.md
Normal file → Executable file
0
api/README.md
Normal file → Executable file
0
api/composer.json
Normal file → Executable file
0
api/composer.json
Normal file → Executable file
0
api/migrations/001_add_refresh_tokens.sql
Normal file → Executable file
0
api/migrations/001_add_refresh_tokens.sql
Normal file → Executable file
0
api/public/index.php
Normal file → Executable file
0
api/public/index.php
Normal file → Executable file
0
api/src/Config/Container.php
Normal file → Executable file
0
api/src/Config/Container.php
Normal file → Executable file
0
api/src/Controllers/AppointmentsController.php
Normal file → Executable file
0
api/src/Controllers/AppointmentsController.php
Normal file → Executable file
44
api/src/Controllers/AuthController.php
Normal file → Executable file
44
api/src/Controllers/AuthController.php
Normal file → Executable 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
0
api/src/Controllers/BootstrapController.php
Normal file → Executable file
0
api/src/Controllers/InstructorsController.php
Normal file → Executable file
0
api/src/Controllers/InstructorsController.php
Normal file → Executable file
0
api/src/Controllers/LessonTypesController.php
Normal file → Executable file
0
api/src/Controllers/LessonTypesController.php
Normal file → Executable file
0
api/src/Controllers/StudentsController.php
Normal file → Executable file
0
api/src/Controllers/StudentsController.php
Normal file → Executable file
0
api/src/Controllers/TemplatesController.php
Normal file → Executable file
0
api/src/Controllers/TemplatesController.php
Normal file → Executable file
36
api/src/Middleware/JwtMiddleware.php
Normal file → Executable file
36
api/src/Middleware/JwtMiddleware.php
Normal file → Executable 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
0
api/src/Models/Appointment.php
Normal file → Executable file
0
api/src/Models/Instructor.php
Normal file → Executable file
0
api/src/Models/Instructor.php
Normal file → Executable file
0
api/src/Models/LessonType.php
Normal file → Executable file
0
api/src/Models/LessonType.php
Normal file → Executable file
0
api/src/Models/LessonTypeTemplateVisibility.php
Normal file → Executable file
0
api/src/Models/LessonTypeTemplateVisibility.php
Normal file → Executable file
0
api/src/Models/LicenseClassTemplate.php
Normal file → Executable file
0
api/src/Models/LicenseClassTemplate.php
Normal file → Executable file
0
api/src/Models/LicenseTemplateRequirement.php
Normal file → Executable file
0
api/src/Models/LicenseTemplateRequirement.php
Normal file → Executable file
0
api/src/Models/RefreshToken.php
Normal file → Executable file
0
api/src/Models/RefreshToken.php
Normal file → Executable file
0
api/src/Models/Student.php
Normal file → Executable file
0
api/src/Models/Student.php
Normal file → Executable file
0
api/src/Models/Tenant.php
Normal file → Executable file
0
api/src/Models/Tenant.php
Normal file → Executable file
0
api/src/Models/User.php
Normal file → Executable file
0
api/src/Models/User.php
Normal file → Executable file
0
api/src/Routes/api.php
Normal file → Executable file
0
api/src/Routes/api.php
Normal file → Executable file
0
api/src/Services/InstructorAvailabilityService.php
Normal file → Executable file
0
api/src/Services/InstructorAvailabilityService.php
Normal file → Executable file
Reference in New Issue
Block a user