diff --git a/api/app/Http/Middleware/PortalTokenMiddleware.php b/api/app/Http/Middleware/PortalTokenMiddleware.php
index 7c671403..2d36f3dd 100644
--- a/api/app/Http/Middleware/PortalTokenMiddleware.php
+++ b/api/app/Http/Middleware/PortalTokenMiddleware.php
@@ -33,11 +33,9 @@ final class PortalTokenMiddleware
return response()->json(['message' => 'Portal token required.'], 401);
}
- $request->merge([
- 'portal_context' => 'artist',
- 'portal_person' => $artist,
- 'portal_event' => $event,
- ]);
+ $request->attributes->set('portal_context', 'artist');
+ $request->attributes->set('portal_person', $artist);
+ $request->attributes->set('portal_event', $event);
return $next($request);
}
@@ -53,11 +51,9 @@ final class PortalTokenMiddleware
return response()->json(['message' => 'Portal token required.'], 401);
}
- $request->merge([
- 'portal_context' => 'supplier',
- 'portal_person' => $productionRequest,
- 'portal_event' => $event,
- ]);
+ $request->attributes->set('portal_context', 'supplier');
+ $request->attributes->set('portal_person', $productionRequest);
+ $request->attributes->set('portal_event', $event);
return $next($request);
}
diff --git a/api/tests/Feature/Security/AuthenticationSecurityTest.php b/api/tests/Feature/Security/AuthenticationSecurityTest.php
new file mode 100644
index 00000000..1340a698
--- /dev/null
+++ b/api/tests/Feature/Security/AuthenticationSecurityTest.php
@@ -0,0 +1,242 @@
+seed(RoleSeeder::class);
+ }
+
+ // --- Rate Limiting ---
+
+ public function test_login_rate_limited_after_5_attempts(): void
+ {
+ $user = User::factory()->create();
+
+ for ($i = 0; $i < 5; $i++) {
+ $this->postJson('/api/v1/auth/login', [
+ 'email' => $user->email,
+ 'password' => 'WrongPassword1',
+ ]);
+ }
+
+ $response = $this->postJson('/api/v1/auth/login', [
+ 'email' => $user->email,
+ 'password' => 'WrongPassword1',
+ ]);
+
+ $response->assertStatus(429);
+ }
+
+ public function test_portal_token_auth_rate_limited(): void
+ {
+ for ($i = 0; $i < 10; $i++) {
+ $this->postJson('/api/v1/portal/token-auth', [
+ 'token' => 'invalid-token-' . $i,
+ ]);
+ }
+
+ $response = $this->postJson('/api/v1/portal/token-auth', [
+ 'token' => 'invalid-token-final',
+ ]);
+
+ $response->assertStatus(429);
+ }
+
+ public function test_invitation_show_rate_limited(): void
+ {
+ for ($i = 0; $i < 10; $i++) {
+ $this->getJson('/api/v1/invitations/fake-token-' . $i);
+ }
+
+ $response = $this->getJson('/api/v1/invitations/fake-token-final');
+
+ $response->assertStatus(429);
+ }
+
+ // --- Account Enumeration Prevention ---
+
+ public function test_failed_login_returns_generic_message(): void
+ {
+ $response = $this->postJson('/api/v1/auth/login', [
+ 'email' => 'nonexistent@example.com',
+ 'password' => 'SomePassword1',
+ ]);
+
+ $response->assertUnauthorized();
+ // Should NOT say "User not found" or "Email does not exist"
+ $response->assertJson(['message' => 'Invalid credentials']);
+ }
+
+ public function test_failed_login_same_message_for_wrong_password(): void
+ {
+ $user = User::factory()->create();
+
+ $response = $this->postJson('/api/v1/auth/login', [
+ 'email' => $user->email,
+ 'password' => 'WrongPassword1',
+ ]);
+
+ $response->assertUnauthorized();
+ $response->assertJson(['message' => 'Invalid credentials']);
+ }
+
+ public function test_password_reset_returns_success_for_unknown_email(): void
+ {
+ $response = $this->postJson('/api/v1/auth/forgot-password', [
+ 'email' => 'nonexistent@example.com',
+ ]);
+
+ // Must return 200 regardless — don't leak whether email exists
+ $response->assertOk();
+ }
+
+ public function test_password_reset_returns_success_for_known_email(): void
+ {
+ $user = User::factory()->create();
+
+ $response = $this->postJson('/api/v1/auth/forgot-password', [
+ 'email' => $user->email,
+ ]);
+
+ $response->assertOk();
+ }
+
+ // --- Token Lifecycle ---
+
+ public function test_logout_revokes_current_token(): void
+ {
+ $user = User::factory()->create();
+ $token = $user->createToken('auth-token')->plainTextToken;
+
+ // Use token for logout
+ $this->withHeaders(['Authorization' => "Bearer {$token}"])
+ ->postJson('/api/v1/auth/logout')
+ ->assertOk();
+
+ // Verify the token record is deleted from the database
+ $this->assertDatabaseCount('personal_access_tokens', 0);
+ }
+
+ public function test_password_reset_revokes_all_tokens(): void
+ {
+ $user = User::factory()->create(['email' => 'tokens@test.nl']);
+
+ // Create two tokens
+ $user->createToken('device-1');
+ $user->createToken('device-2');
+ $this->assertDatabaseCount('personal_access_tokens', 2);
+
+ // Reset password
+ $resetToken = Password::createToken($user);
+
+ $this->postJson('/api/v1/auth/reset-password', [
+ 'token' => $resetToken,
+ 'email' => 'tokens@test.nl',
+ 'password' => 'NewPassword123',
+ 'password_confirmation' => 'NewPassword123',
+ ])->assertOk();
+
+ // All tokens should be deleted from the database
+ $this->assertDatabaseCount('personal_access_tokens', 0);
+ }
+
+ public function test_token_expiration_is_configured(): void
+ {
+ // Verify Sanctum expiration is set to 7 days (10080 minutes)
+ $expiration = config('sanctum.expiration');
+ $this->assertEquals(60 * 24 * 7, $expiration);
+
+ // Create a token and backdate its created_at to 8 days ago
+ $user = User::factory()->create();
+ $accessToken = $user->createToken('auth-token');
+
+ // Sanctum checks created_at against the expiration window
+ $accessToken->accessToken->forceFill([
+ 'created_at' => now()->subDays(8),
+ ])->save();
+
+ // Token created 8 days ago should be expired (7-day expiry)
+ $this->withHeaders(['Authorization' => "Bearer {$accessToken->plainTextToken}"])
+ ->getJson('/api/v1/auth/me')
+ ->assertUnauthorized();
+ }
+
+ // --- Password Strength ---
+
+ public function test_weak_password_rejected_on_registration(): void
+ {
+ $response = $this->postJson('/api/v1/auth/reset-password', [
+ 'token' => 'fake',
+ 'email' => 'test@test.nl',
+ 'password' => 'weakpass',
+ 'password_confirmation' => 'weakpass',
+ ]);
+
+ $response->assertUnprocessable();
+ $response->assertJsonValidationErrors('password');
+ }
+
+ public function test_password_without_uppercase_rejected(): void
+ {
+ $response = $this->postJson('/api/v1/auth/reset-password', [
+ 'token' => 'fake',
+ 'email' => 'test@test.nl',
+ 'password' => 'lowercase1',
+ 'password_confirmation' => 'lowercase1',
+ ]);
+
+ $response->assertUnprocessable();
+ $response->assertJsonValidationErrors('password');
+ }
+
+ public function test_password_without_numbers_rejected(): void
+ {
+ $response = $this->postJson('/api/v1/auth/reset-password', [
+ 'token' => 'fake',
+ 'email' => 'test@test.nl',
+ 'password' => 'NoNumbersHere',
+ 'password_confirmation' => 'NoNumbersHere',
+ ]);
+
+ $response->assertUnprocessable();
+ $response->assertJsonValidationErrors('password');
+ }
+
+ // --- Security Headers ---
+
+ public function test_security_headers_present(): void
+ {
+ $response = $this->getJson('/api/v1/');
+
+ $response->assertHeader('X-Content-Type-Options', 'nosniff');
+ $response->assertHeader('X-Frame-Options', 'DENY');
+ $response->assertHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
+ $response->assertHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
+ }
+
+ // --- Protected Routes ---
+
+ public function test_protected_routes_require_authentication(): void
+ {
+ $this->getJson('/api/v1/auth/me')->assertUnauthorized();
+ $this->postJson('/api/v1/auth/logout')->assertUnauthorized();
+ $this->getJson('/api/v1/organisations')->assertUnauthorized();
+ $this->getJson('/api/v1/portal/me?event_id=fake')->assertUnauthorized();
+ }
+}
diff --git a/api/tests/Feature/Security/InputValidationSecurityTest.php b/api/tests/Feature/Security/InputValidationSecurityTest.php
new file mode 100644
index 00000000..1c61aa99
--- /dev/null
+++ b/api/tests/Feature/Security/InputValidationSecurityTest.php
@@ -0,0 +1,367 @@
+seed(RoleSeeder::class);
+
+ $this->organisation = Organisation::factory()->create();
+ $this->admin = User::factory()->create();
+ $this->organisation->users()->attach($this->admin, ['role' => 'org_admin']);
+ $this->crowdType = CrowdType::factory()->systemType('VOLUNTEER')->create([
+ 'organisation_id' => $this->organisation->id,
+ ]);
+ $this->event = Event::factory()->create([
+ 'organisation_id' => $this->organisation->id,
+ 'status' => 'registration_open',
+ ]);
+ }
+
+ // --- XSS Payload Storage ---
+
+ public function test_xss_in_person_name_is_stored_safely(): void
+ {
+ Sanctum::actingAs($this->admin);
+
+ $xssPayload = '';
+
+ $response = $this->postJson("/api/v1/events/{$this->event->id}/persons", [
+ 'crowd_type_id' => $this->crowdType->id,
+ 'first_name' => $xssPayload,
+ 'last_name' => 'Normal',
+ 'email' => 'xss-test@example.com',
+ ]);
+
+ $response->assertCreated();
+
+ // Value is stored as-is (Laravel escapes on output via {{ }})
+ $this->assertDatabaseHas('persons', [
+ 'first_name' => $xssPayload,
+ 'email' => 'xss-test@example.com',
+ ]);
+
+ // API resource should return the raw string (Vue's {{ }} escapes it)
+ $response->assertJsonPath('data.first_name', $xssPayload);
+ }
+
+ public function test_xss_in_event_name_is_stored_safely(): void
+ {
+ Sanctum::actingAs($this->admin);
+
+ $xssPayload = '">
';
+
+ $response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/events", [
+ 'name' => $xssPayload,
+ 'slug' => 'xss-test-event',
+ 'start_date' => '2026-07-01',
+ 'end_date' => '2026-07-03',
+ ]);
+
+ $response->assertCreated();
+ $response->assertJsonPath('data.name', $xssPayload);
+ }
+
+ public function test_xss_in_section_name_is_stored_safely(): void
+ {
+ Sanctum::actingAs($this->admin);
+
+ $xssPayload = '