diff --git a/api/.env.example b/api/.env.example index 27ad2637..48255335 100644 --- a/api/.env.example +++ b/api/.env.example @@ -30,7 +30,8 @@ SESSION_DRIVER=database SESSION_LIFETIME=120 SESSION_ENCRYPT=false SESSION_PATH=/ -SESSION_DOMAIN=null +# In production, use: SESSION_DOMAIN=.crewli.app +SESSION_DOMAIN=localhost BROADCAST_CONNECTION=log FILESYSTEM_DISK=local diff --git a/api/app/Http/Controllers/Api/V1/AuthRefreshController.php b/api/app/Http/Controllers/Api/V1/AuthRefreshController.php new file mode 100644 index 00000000..70cd6d11 --- /dev/null +++ b/api/app/Http/Controllers/Api/V1/AuthRefreshController.php @@ -0,0 +1,39 @@ +user(); + + // Revoke the current token + $request->user()->currentAccessToken()->delete(); + + // Create a new token + $newToken = $user->createToken('auth-token')->plainTextToken; + $cookieName = $this->resolveCookieName($request); + + $user->load(['organisations', 'roles', 'permissions']); + + Log::info('Auth token refreshed', [ + 'user_id' => $user->id, + 'ip' => $request->ip(), + ]); + + return $this->success(new MeResource($user), 'Token refreshed') + ->withCookie($this->makeAuthCookie($cookieName, $newToken)); + } +} diff --git a/api/app/Http/Controllers/Api/V1/InvitationController.php b/api/app/Http/Controllers/Api/V1/InvitationController.php index 88217c11..e0eff145 100644 --- a/api/app/Http/Controllers/Api/V1/InvitationController.php +++ b/api/app/Http/Controllers/Api/V1/InvitationController.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Http\Controllers\Api\V1; +use App\Http\Controllers\Api\V1\Traits\SetAuthCookie; use App\Http\Controllers\Controller; use App\Http\Requests\Api\V1\AcceptInvitationRequest; use App\Http\Requests\Api\V1\StoreInvitationRequest; @@ -16,6 +17,7 @@ use Illuminate\Support\Facades\Gate; final class InvitationController extends Controller { + use SetAuthCookie; public function __construct( private readonly InvitationService $invitationService, ) {} @@ -63,6 +65,7 @@ final class InvitationController extends Controller ); $sanctumToken = $user->createToken('auth-token')->plainTextToken; + $cookieName = $this->resolveCookieName($request); return $this->success([ 'user' => [ @@ -72,8 +75,8 @@ final class InvitationController extends Controller 'full_name' => $user->full_name, 'email' => $user->email, ], - 'token' => $sanctumToken, - ], 'Uitnodiging geaccepteerd'); + ], 'Uitnodiging geaccepteerd') + ->withCookie($this->makeAuthCookie($cookieName, $sanctumToken)); } public function revoke(Organisation $organisation, UserInvitation $invitation): JsonResponse diff --git a/api/app/Http/Controllers/Api/V1/LoginController.php b/api/app/Http/Controllers/Api/V1/LoginController.php index 2a9ce889..2f493edc 100644 --- a/api/app/Http/Controllers/Api/V1/LoginController.php +++ b/api/app/Http/Controllers/Api/V1/LoginController.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Http\Controllers\Api\V1; +use App\Http\Controllers\Api\V1\Traits\SetAuthCookie; use App\Http\Controllers\Controller; use App\Http\Requests\Api\V1\LoginRequest; use App\Http\Resources\Api\V1\UserResource; @@ -13,6 +14,8 @@ use Illuminate\Support\Facades\Log; final class LoginController extends Controller { + use SetAuthCookie; + public function __invoke(LoginRequest $request): JsonResponse { if (!Auth::attempt($request->only('email', 'password'))) { @@ -27,10 +30,11 @@ final class LoginController extends Controller $user = Auth::user()->load(['organisations', 'roles']); $token = $user->createToken('auth-token')->plainTextToken; + $cookieName = $this->resolveCookieName($request); return $this->success([ 'user' => new UserResource($user), - 'token' => $token, - ], 'Login successful'); + ], 'Login successful') + ->withCookie($this->makeAuthCookie($cookieName, $token)); } } diff --git a/api/app/Http/Controllers/Api/V1/LogoutController.php b/api/app/Http/Controllers/Api/V1/LogoutController.php index 98f31a63..73889296 100644 --- a/api/app/Http/Controllers/Api/V1/LogoutController.php +++ b/api/app/Http/Controllers/Api/V1/LogoutController.php @@ -4,16 +4,22 @@ declare(strict_types=1); namespace App\Http\Controllers\Api\V1; +use App\Http\Controllers\Api\V1\Traits\SetAuthCookie; use App\Http\Controllers\Controller; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; final class LogoutController extends Controller { + use SetAuthCookie; + public function __invoke(Request $request): JsonResponse { $request->user()->currentAccessToken()->delete(); - return $this->success(null, 'Logged out successfully'); + $cookieName = $this->resolveCookieName($request); + + return $this->success(null, 'Logged out successfully') + ->withCookie($this->forgetAuthCookie($cookieName)); } } diff --git a/api/app/Http/Controllers/Api/V1/Traits/SetAuthCookie.php b/api/app/Http/Controllers/Api/V1/Traits/SetAuthCookie.php new file mode 100644 index 00000000..17be41dd --- /dev/null +++ b/api/app/Http/Controllers/Api/V1/Traits/SetAuthCookie.php @@ -0,0 +1,87 @@ + 'crewli_admin_token', + 'app' => 'crewli_app_token', + 'portal' => 'crewli_portal_token', + ]; + + private const COOKIE_TTL_MINUTES = 60 * 24 * 7; // 7 days + + protected function resolveCookieName(Request $request): string + { + $origin = $request->headers->get('Origin') + ?? $request->headers->get('Referer') + ?? ''; + + $adminUrl = config('app.frontend_admin_url', 'http://localhost:5173'); + $appUrl = config('app.frontend_app_url', 'http://localhost:5174'); + $portalUrl = config('app.frontend_portal_url', 'http://localhost:5175'); + + if ($this->originMatches($origin, $adminUrl)) { + return self::COOKIE_MAP['admin']; + } + + if ($this->originMatches($origin, $appUrl)) { + return self::COOKIE_MAP['app']; + } + + if ($this->originMatches($origin, $portalUrl)) { + return self::COOKIE_MAP['portal']; + } + + return self::COOKIE_MAP['app']; + } + + protected function makeAuthCookie(string $cookieName, string $token): Cookie + { + return new Cookie( + name: $cookieName, + value: $token, + expire: now()->addMinutes(self::COOKIE_TTL_MINUTES), + path: '/', + domain: config('session.domain'), + secure: config('app.env') === 'production', + httpOnly: true, + sameSite: 'Strict', + ); + } + + protected function forgetAuthCookie(string $cookieName): Cookie + { + return new Cookie( + name: $cookieName, + value: '', + expire: now()->subMinute(), + path: '/', + domain: config('session.domain'), + secure: config('app.env') === 'production', + httpOnly: true, + sameSite: 'Strict', + ); + } + + private function originMatches(string $origin, string $configuredUrl): bool + { + if ($origin === '' || $configuredUrl === '') { + return false; + } + + // Parse to compare host+port, ignoring trailing slashes and paths + $originHost = parse_url($origin, PHP_URL_HOST); + $originPort = parse_url($origin, PHP_URL_PORT); + $configHost = parse_url($configuredUrl, PHP_URL_HOST); + $configPort = parse_url($configuredUrl, PHP_URL_PORT); + + return $originHost === $configHost && $originPort === $configPort; + } +} diff --git a/api/app/Http/Middleware/CookieBearerToken.php b/api/app/Http/Middleware/CookieBearerToken.php new file mode 100644 index 00000000..e81f7a35 --- /dev/null +++ b/api/app/Http/Middleware/CookieBearerToken.php @@ -0,0 +1,37 @@ +hasHeader('Authorization')) { + return $next($request); + } + + foreach (self::COOKIE_NAMES as $cookieName) { + $token = $request->cookie($cookieName); + + if ($token) { + $request->headers->set('Authorization', 'Bearer ' . $token); + break; + } + } + + return $next($request); + } +} diff --git a/api/bootstrap/app.php b/api/bootstrap/app.php index e28fbad2..6421d902 100644 --- a/api/bootstrap/app.php +++ b/api/bootstrap/app.php @@ -27,6 +27,11 @@ return Application::configure(basePath: dirname(__DIR__)) $middleware->append(\App\Http\Middleware\SecurityHeaders::class); + // Read httpOnly auth cookie and inject as Authorization header (before Sanctum) + $middleware->api(prepend: [ + \App\Http\Middleware\CookieBearerToken::class, + ]); + $middleware->alias([ 'portal.token' => \App\Http\Middleware\PortalTokenMiddleware::class, ]); diff --git a/api/routes/api.php b/api/routes/api.php index 71572858..19d384df 100644 --- a/api/routes/api.php +++ b/api/routes/api.php @@ -30,6 +30,7 @@ use App\Http\Controllers\Api\V1\VolunteerRegistrationController; use App\Http\Controllers\Api\V1\PublicRegistrationDataController; use App\Http\Controllers\Api\V1\PortalTokenController; use App\Http\Controllers\Api\V1\AccountController; +use App\Http\Controllers\Api\V1\AuthRefreshController; use App\Http\Controllers\Api\V1\EmailChangeController; use App\Http\Controllers\Api\V1\PasswordResetController; use App\Http\Controllers\Api\V1\PortalMeController; @@ -82,6 +83,7 @@ Route::middleware('auth:sanctum')->group(function () { // Auth Route::get('auth/me', MeController::class); Route::post('auth/logout', LogoutController::class); + Route::post('auth/refresh', AuthRefreshController::class); // Account management (self-service) Route::post('me/change-password', [AccountController::class, 'changePassword']); diff --git a/api/tests/Feature/Auth/LoginTest.php b/api/tests/Feature/Auth/LoginTest.php index 114ddc8c..5878c66b 100644 --- a/api/tests/Feature/Auth/LoginTest.php +++ b/api/tests/Feature/Auth/LoginTest.php @@ -24,10 +24,13 @@ class LoginTest extends TestCase $response->assertOk() ->assertJsonStructure([ 'success', - 'data' => ['user' => ['id', 'first_name', 'last_name', 'full_name', 'email'], 'token'], + 'data' => ['user' => ['id', 'first_name', 'last_name', 'full_name', 'email']], 'message', ]) ->assertJson(['success' => true]); + + // Token must NOT be in response body (set via httpOnly cookie) + $this->assertArrayNotHasKey('token', $response->json('data')); } public function test_login_fails_with_invalid_credentials(): void diff --git a/api/tests/Feature/Invitation/InvitationTest.php b/api/tests/Feature/Invitation/InvitationTest.php index 00c68d2a..a5bd30be 100644 --- a/api/tests/Feature/Invitation/InvitationTest.php +++ b/api/tests/Feature/Invitation/InvitationTest.php @@ -159,7 +159,9 @@ class InvitationTest extends TestCase ]); $response->assertOk(); - $response->assertJsonStructure(['data' => ['user' => ['id', 'first_name', 'last_name', 'full_name', 'email'], 'token']]); + $response->assertJsonStructure(['data' => ['user' => ['id', 'first_name', 'last_name', 'full_name', 'email']]]); + // Token must NOT be in response body (set via httpOnly cookie) + $this->assertArrayNotHasKey('token', $response->json('data')); $this->assertDatabaseHas('users', ['email' => 'newuser@test.nl']); $this->assertDatabaseHas('organisation_user', [ @@ -187,7 +189,9 @@ class InvitationTest extends TestCase $response = $this->postJson("/api/v1/invitations/{$invitation->plainToken}/accept"); $response->assertOk(); - $response->assertJsonStructure(['data' => ['user', 'token']]); + $response->assertJsonStructure(['data' => ['user']]); + // Token must NOT be in response body (set via httpOnly cookie) + $this->assertArrayNotHasKey('token', $response->json('data')); $this->assertDatabaseHas('organisation_user', [ 'user_id' => $existingUser->id, diff --git a/api/tests/Feature/Security/HttpOnlyCookieAuthTest.php b/api/tests/Feature/Security/HttpOnlyCookieAuthTest.php new file mode 100644 index 00000000..f7aaa2ce --- /dev/null +++ b/api/tests/Feature/Security/HttpOnlyCookieAuthTest.php @@ -0,0 +1,207 @@ +seed(RoleSeeder::class); + // Enable cookies for JSON requests (required for cookie-based auth testing) + $this->withCredentials(); + } + + // --- Login Cookie Tests --- + + public function test_login_response_does_not_contain_token_in_json_body(): void + { + $user = User::factory()->create(); + + $response = $this->postJson('/api/v1/auth/login', [ + 'email' => $user->email, + 'password' => 'password', + ]); + + $response->assertOk(); + $response->assertJsonMissing(['token']); + $this->assertArrayNotHasKey('token', $response->json('data')); + } + + public function test_login_response_sets_httponly_cookie(): void + { + $user = User::factory()->create(); + + $response = $this->postJson('/api/v1/auth/login', [ + 'email' => $user->email, + 'password' => 'password', + ], ['Origin' => 'http://localhost:5174']); + + $response->assertOk(); + $response->assertCookie('crewli_app_token'); + } + + public function test_login_cookie_has_httponly_flag(): void + { + $user = User::factory()->create(); + + $response = $this->postJson('/api/v1/auth/login', [ + 'email' => $user->email, + 'password' => 'password', + ], ['Origin' => 'http://localhost:5174']); + + $cookie = $this->findCookie($response, 'crewli_app_token'); + $this->assertNotNull($cookie, 'Cookie crewli_app_token not found'); + $this->assertTrue($cookie->isHttpOnly(), 'Cookie must be httpOnly'); + } + + public function test_login_cookie_has_samesite_strict(): void + { + $user = User::factory()->create(); + + $response = $this->postJson('/api/v1/auth/login', [ + 'email' => $user->email, + 'password' => 'password', + ], ['Origin' => 'http://localhost:5174']); + + $cookie = $this->findCookie($response, 'crewli_app_token'); + $this->assertNotNull($cookie); + $this->assertEquals('strict', strtolower($cookie->getSameSite())); + } + + public function test_login_sets_admin_cookie_for_admin_origin(): void + { + $user = User::factory()->create(); + + $response = $this->postJson('/api/v1/auth/login', [ + 'email' => $user->email, + 'password' => 'password', + ], ['Origin' => 'http://localhost:5173']); + + $response->assertOk(); + $response->assertCookie('crewli_admin_token'); + } + + public function test_login_sets_portal_cookie_for_portal_origin(): void + { + $user = User::factory()->create(); + + $response = $this->postJson('/api/v1/auth/login', [ + 'email' => $user->email, + 'password' => 'password', + ], ['Origin' => 'http://localhost:5175']); + + $response->assertOk(); + $response->assertCookie('crewli_portal_token'); + } + + // --- Middleware Tests --- + + public function test_request_with_auth_cookie_is_authenticated(): void + { + $user = User::factory()->create(); + $token = $user->createToken('auth-token')->plainTextToken; + + $response = $this->withUnencryptedCookie('crewli_app_token', $token) + ->getJson('/api/v1/auth/me'); + + $response->assertOk(); + $response->assertJsonPath('data.id', $user->id); + } + + public function test_request_with_invalid_cookie_returns_401(): void + { + $response = $this->withUnencryptedCookie('crewli_app_token', 'invalid-token-value') + ->getJson('/api/v1/auth/me'); + + $response->assertUnauthorized(); + } + + public function test_request_without_cookie_or_header_returns_401(): void + { + $response = $this->getJson('/api/v1/auth/me'); + + $response->assertUnauthorized(); + } + + // --- Logout Tests --- + + public function test_logout_expires_auth_cookie(): void + { + $user = User::factory()->create(); + $token = $user->createToken('auth-token')->plainTextToken; + + $response = $this->withUnencryptedCookie('crewli_app_token', $token) + ->postJson('/api/v1/auth/logout', [], ['Origin' => 'http://localhost:5174']); + + $response->assertOk(); + + $cookie = $this->findCookie($response, 'crewli_app_token'); + $this->assertNotNull($cookie, 'Cookie crewli_app_token not found in logout response'); + // Expired cookie has a past expiry time + $this->assertTrue($cookie->getExpiresTime() < time(), 'Logout cookie must be expired'); + } + + // --- Refresh Tests --- + + public function test_refresh_revokes_old_token_and_sets_new_cookie(): void + { + $user = User::factory()->create(); + $accessToken = $user->createToken('auth-token'); + $token = $accessToken->plainTextToken; + + $response = $this->withUnencryptedCookie('crewli_app_token', $token) + ->postJson('/api/v1/auth/refresh', [], ['Origin' => 'http://localhost:5174']); + + $response->assertOk(); + $response->assertCookie('crewli_app_token'); + + // New cookie should contain a different token + $newCookie = $this->findCookie($response, 'crewli_app_token'); + $this->assertNotNull($newCookie); + $this->assertNotEquals($token, $newCookie->getValue(), 'New token must differ from old token'); + + // Old token should be revoked in the database + $this->assertNull( + \Laravel\Sanctum\PersonalAccessToken::findToken($token), + 'Old token must be deleted from database', + ); + + // New token should be valid in the database + $this->assertNotNull( + \Laravel\Sanctum\PersonalAccessToken::findToken($newCookie->getValue()), + 'New token must exist in database', + ); + } + + public function test_refresh_with_expired_token_returns_401(): void + { + $response = $this->withUnencryptedCookie('crewli_app_token', 'expired-or-invalid-token') + ->postJson('/api/v1/auth/refresh'); + + $response->assertUnauthorized(); + } + + // --- Helper --- + + private function findCookie($response, string $name): ?\Symfony\Component\HttpFoundation\Cookie + { + foreach ($response->headers->getCookies() as $cookie) { + if ($cookie->getName() === $name) { + return $cookie; + } + } + + return null; + } +} diff --git a/apps/admin/src/composables/useOrganisationContext.ts b/apps/admin/src/composables/useOrganisationContext.ts index bfe5db6d..c75592ca 100644 --- a/apps/admin/src/composables/useOrganisationContext.ts +++ b/apps/admin/src/composables/useOrganisationContext.ts @@ -1,4 +1,4 @@ -import { useCookie } from '@core/composable/useCookie' +import { useAuthStore } from '@/stores/useAuthStore' import { computed } from 'vue' export interface AuthOrganisationSummary { @@ -17,12 +17,12 @@ export interface AuthUserCookie { } /** - * First organisation from the session cookie (set at login). Super-admins still need an organisation context for nested event routes. + * First organisation from the auth store (set at login). Super-admins still need an organisation context for nested event routes. */ export function useCurrentOrganisationId() { - const userData = useCookie('userData') + const authStore = useAuthStore() - const organisationId = computed(() => userData.value?.organisations?.[0]?.id ?? null) + const organisationId = computed(() => authStore.user?.organisations?.[0]?.id ?? null) return { organisationId } } diff --git a/apps/admin/src/layouts/components/UserProfile.vue b/apps/admin/src/layouts/components/UserProfile.vue index f140ffe1..c3a32978 100644 --- a/apps/admin/src/layouts/components/UserProfile.vue +++ b/apps/admin/src/layouts/components/UserProfile.vue @@ -1,37 +1,21 @@