Initial commit
This commit is contained in:
@@ -0,0 +1,522 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Security\Authentication;
|
||||
|
||||
use App\Models\Admin;
|
||||
use App\Models\Bank;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Direct Login Routes Security Tests
|
||||
*
|
||||
* Tests the security of direct login routes used in emails and external links:
|
||||
* - /user/{userId}/login
|
||||
* - /organization/{organizationId}/login
|
||||
* - /bank/{bankId}/login
|
||||
* - /admin/{adminId}/login
|
||||
*
|
||||
* Tests layered authentication, ownership verification, and intended URL handling.
|
||||
*
|
||||
* @group security
|
||||
* @group critical
|
||||
* @group authentication
|
||||
* @group direct-login
|
||||
*/
|
||||
class DirectLoginRoutesSecurityTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
// ==========================================
|
||||
// USER DIRECT LOGIN TESTS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Test user direct login requires authentication
|
||||
*/
|
||||
public function test_user_direct_login_requires_authentication()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->get(route('user.direct-login', ['userId' => $user->id]));
|
||||
|
||||
// Should redirect to login with intended URL (may be localized like /en/login)
|
||||
$response->assertRedirect();
|
||||
$redirectLocation = $response->headers->get('Location');
|
||||
$this->assertTrue(
|
||||
str_contains($redirectLocation, 'login') || str_contains($redirectLocation, '/en') || str_contains($redirectLocation, '/nl'),
|
||||
"Expected redirect to login page, got: {$redirectLocation}"
|
||||
);
|
||||
// Session url.intended may be set by controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Test user direct login validates ownership
|
||||
*/
|
||||
public function test_user_direct_login_validates_ownership()
|
||||
{
|
||||
$user1 = User::factory()->create();
|
||||
$user2 = User::factory()->create();
|
||||
|
||||
$this->actingAs($user1, 'web');
|
||||
|
||||
// Try to access user2's profile
|
||||
$response = $this->get(route('user.direct-login', ['userId' => $user2->id]));
|
||||
|
||||
$response->assertStatus(403);
|
||||
$response->assertSee('You do not have access to this profile');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test user direct login redirects to intended URL
|
||||
*/
|
||||
public function test_user_direct_login_redirects_to_intended_url()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
$intendedUrl = route('main');
|
||||
$response = $this->get(route('user.direct-login', [
|
||||
'userId' => $user->id,
|
||||
'intended' => $intendedUrl,
|
||||
]));
|
||||
|
||||
$response->assertRedirect($intendedUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test user direct login returns 404 for nonexistent user
|
||||
*/
|
||||
public function test_user_direct_login_returns_404_for_nonexistent_user()
|
||||
{
|
||||
$this->actingAs(User::factory()->create(), 'web');
|
||||
|
||||
$response = $this->get(route('user.direct-login', ['userId' => 99999]));
|
||||
|
||||
$response->assertStatus(404);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// ORGANIZATION DIRECT LOGIN TESTS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Test organization direct login requires user authentication first
|
||||
*/
|
||||
public function test_organization_direct_login_requires_user_authentication()
|
||||
{
|
||||
$organization = Organization::factory()->create();
|
||||
|
||||
$response = $this->get(route('organization.direct-login', ['organizationId' => $organization->id]));
|
||||
|
||||
// Should redirect to user login
|
||||
$response->assertRedirect();
|
||||
$this->assertStringContainsString('login', $response->headers->get('Location'));
|
||||
|
||||
// Should store intended URL in session
|
||||
$this->assertNotNull(session('url.intended'));
|
||||
$this->assertStringContainsString("/organization/{$organization->id}/login", session('url.intended'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test organization direct login validates ownership
|
||||
*/
|
||||
public function test_organization_direct_login_validates_ownership()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$ownedOrg = Organization::factory()->create();
|
||||
$unownedOrg = Organization::factory()->create();
|
||||
|
||||
$user->organizations()->attach($ownedOrg->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
// Try to access unowned organization
|
||||
$response = $this->get(route('organization.direct-login', ['organizationId' => $unownedOrg->id]));
|
||||
|
||||
$response->assertStatus(403);
|
||||
$response->assertSee('You do not have access to this organization');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test organization direct login switches guard directly (passwordless)
|
||||
*/
|
||||
public function test_organization_direct_login_switches_guard_passwordless()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create(['password' => Hash::make('password')]);
|
||||
$user->organizations()->attach($organization->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
$response = $this->get(route('organization.direct-login', ['organizationId' => $organization->id]));
|
||||
|
||||
// Should switch to organization guard immediately without password
|
||||
$this->assertTrue(Auth::guard('organization')->check());
|
||||
$this->assertEquals($organization->id, Auth::guard('organization')->id());
|
||||
|
||||
$response->assertRedirect(route('main'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test organization direct login redirects to intended URL
|
||||
*/
|
||||
public function test_organization_direct_login_redirects_to_intended_url()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create();
|
||||
$user->organizations()->attach($organization->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
$intendedUrl = '/some/deep/link';
|
||||
|
||||
$response = $this->get(route('organization.direct-login', [
|
||||
'organizationId' => $organization->id,
|
||||
'intended' => $intendedUrl,
|
||||
]));
|
||||
|
||||
$response->assertRedirect($intendedUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test organization direct login returns 404 for nonexistent organization
|
||||
*/
|
||||
public function test_organization_direct_login_returns_404_for_nonexistent_profile()
|
||||
{
|
||||
$this->actingAs(User::factory()->create(), 'web');
|
||||
|
||||
$response = $this->get(route('organization.direct-login', ['organizationId' => 99999]));
|
||||
|
||||
$response->assertStatus(404);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// BANK DIRECT LOGIN TESTS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Test bank direct login requires user authentication first
|
||||
*/
|
||||
public function test_bank_direct_login_requires_user_authentication()
|
||||
{
|
||||
$bank = Bank::factory()->create();
|
||||
|
||||
$response = $this->get(route('bank.direct-login', ['bankId' => $bank->id]));
|
||||
|
||||
// Should redirect to user login
|
||||
$response->assertRedirect();
|
||||
$this->assertStringContainsString('login', $response->headers->get('Location'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test bank direct login validates bank manager relationship
|
||||
*/
|
||||
public function test_bank_direct_login_validates_bank_manager_relationship()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$managedBank = Bank::factory()->create();
|
||||
$unmanagedBank = Bank::factory()->create();
|
||||
|
||||
$user->banksManaged()->attach($managedBank->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
// Try to access unmanaged bank
|
||||
$response = $this->get(route('bank.direct-login', ['bankId' => $unmanagedBank->id]));
|
||||
|
||||
$response->assertStatus(403);
|
||||
$response->assertSee('You do not have access to this bank');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test bank direct login requires password
|
||||
*/
|
||||
public function test_bank_direct_login_requires_password()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$bank = Bank::factory()->create(['password' => Hash::make('bank-password')]);
|
||||
$user->banksManaged()->attach($bank->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
$response = $this->get(route('bank.direct-login', ['bankId' => $bank->id]));
|
||||
|
||||
// Should redirect to bank login form (not switch immediately)
|
||||
$response->assertRedirect(route('bank.login'));
|
||||
|
||||
// Should NOT be authenticated on bank guard yet
|
||||
$this->assertFalse(Auth::guard('bank')->check());
|
||||
|
||||
// Should set session intent
|
||||
$this->assertEquals('Bank', session('intended_profile_switch_type'));
|
||||
$this->assertEquals($bank->id, session('intended_profile_switch_id'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test bank direct login with intended URL stores it in session
|
||||
*/
|
||||
public function test_bank_direct_login_stores_intended_url_in_session()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$bank = Bank::factory()->create();
|
||||
$user->banksManaged()->attach($bank->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
$intendedUrl = '/deep/link/to/transaction';
|
||||
|
||||
$this->get(route('bank.direct-login', [
|
||||
'bankId' => $bank->id,
|
||||
'intended' => $intendedUrl,
|
||||
]));
|
||||
|
||||
$this->assertEquals($intendedUrl, session('bank_login_intended_url'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test bank login fails with wrong password
|
||||
*/
|
||||
public function test_bank_direct_login_fails_with_wrong_password()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$bank = Bank::factory()->create(['password' => Hash::make('correct-password')]);
|
||||
$user->banksManaged()->attach($bank->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
$this->withoutMiddleware(\App\Http\Middleware\VerifyCsrfToken::class);
|
||||
|
||||
session([
|
||||
'intended_profile_switch_type' => 'Bank',
|
||||
'intended_profile_switch_id' => $bank->id,
|
||||
]);
|
||||
|
||||
$response = $this->post(route('bank.login.post'), [
|
||||
'password' => 'wrong-password',
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors(['password']);
|
||||
$this->assertFalse(Auth::guard('bank')->check());
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// ADMIN DIRECT LOGIN TESTS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Test admin direct login requires user authentication first
|
||||
*/
|
||||
public function test_admin_direct_login_requires_user_authentication()
|
||||
{
|
||||
$admin = Admin::factory()->create();
|
||||
|
||||
$response = $this->get(route('admin.direct-login', ['adminId' => $admin->id]));
|
||||
|
||||
// Should redirect to user login
|
||||
$response->assertRedirect();
|
||||
$this->assertStringContainsString('login', $response->headers->get('Location'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test admin direct login validates admin user relationship
|
||||
*/
|
||||
public function test_admin_direct_login_validates_admin_user_relationship()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$ownedAdmin = Admin::factory()->create();
|
||||
$unownedAdmin = Admin::factory()->create();
|
||||
|
||||
$user->admins()->attach($ownedAdmin->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
// Try to access unowned admin
|
||||
$response = $this->get(route('admin.direct-login', ['adminId' => $unownedAdmin->id]));
|
||||
|
||||
$response->assertStatus(403);
|
||||
$response->assertSee('You do not have access to this admin profile');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test admin direct login requires password
|
||||
*/
|
||||
public function test_admin_direct_login_requires_password()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$admin = Admin::factory()->create(['password' => Hash::make('admin-password')]);
|
||||
$user->admins()->attach($admin->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
$response = $this->get(route('admin.direct-login', ['adminId' => $admin->id]));
|
||||
|
||||
// Should redirect to admin login form
|
||||
$response->assertRedirect(route('admin.login'));
|
||||
|
||||
// Should NOT be authenticated on admin guard yet
|
||||
$this->assertFalse(Auth::guard('admin')->check());
|
||||
|
||||
// Should set session intent
|
||||
$this->assertEquals('Admin', session('intended_profile_switch_type'));
|
||||
$this->assertEquals($admin->id, session('intended_profile_switch_id'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test admin direct login fails for non-admin users
|
||||
*/
|
||||
public function test_admin_direct_login_fails_for_non_admin_users()
|
||||
{
|
||||
$regularUser = User::factory()->create();
|
||||
$admin = Admin::factory()->create();
|
||||
|
||||
// Regular user not linked to admin
|
||||
$this->actingAs($regularUser, 'web');
|
||||
|
||||
$response = $this->get(route('admin.direct-login', ['adminId' => $admin->id]));
|
||||
|
||||
$response->assertStatus(403);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// SESSION VARIABLE CLEANUP TESTS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Test that session variables are cleared after successful authentication
|
||||
*/
|
||||
public function test_direct_login_session_variables_cleared_after_completion()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$bank = Bank::factory()->create(['password' => Hash::make('password')]);
|
||||
$user->banksManaged()->attach($bank->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
$this->withoutMiddleware(\App\Http\Middleware\VerifyCsrfToken::class);
|
||||
|
||||
// Set up direct login
|
||||
$this->get(route('bank.direct-login', [
|
||||
'bankId' => $bank->id,
|
||||
'intended' => '/target/url',
|
||||
]));
|
||||
|
||||
$this->assertNotNull(session('intended_profile_switch_type'));
|
||||
$this->assertNotNull(session('intended_profile_switch_id'));
|
||||
$this->assertNotNull(session('bank_login_intended_url'));
|
||||
|
||||
// Complete authentication
|
||||
$this->post(route('bank.login.post'), [
|
||||
'password' => 'password',
|
||||
]);
|
||||
|
||||
// Session variables should be cleared
|
||||
$this->assertNull(session('intended_profile_switch_type'));
|
||||
$this->assertNull(session('intended_profile_switch_id'));
|
||||
$this->assertNull(session('bank_login_intended_url'));
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// INTENDED URL VALIDATION TESTS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Test that intended URL is properly encoded and decoded
|
||||
*/
|
||||
public function test_intended_url_properly_encoded_and_decoded()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create();
|
||||
$user->organizations()->attach($organization->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
$intendedUrl = '/path/with spaces/and?query=params';
|
||||
$encodedUrl = urlencode($intendedUrl);
|
||||
|
||||
$response = $this->get(route('organization.direct-login', [
|
||||
'organizationId' => $organization->id,
|
||||
'intended' => $intendedUrl,
|
||||
]));
|
||||
|
||||
// URL should be properly handled
|
||||
$response->assertRedirect($intendedUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that direct login handles missing intended URL gracefully
|
||||
*/
|
||||
public function test_direct_login_handles_missing_intended_url_gracefully()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create();
|
||||
$user->organizations()->attach($organization->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
$response = $this->get(route('organization.direct-login', [
|
||||
'organizationId' => $organization->id,
|
||||
// No 'intended' parameter
|
||||
]));
|
||||
|
||||
// Should redirect to default (main page)
|
||||
$response->assertRedirect(route('main'));
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// MULTI-LAYER AUTHENTICATION FLOW TESTS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Test complete flow: unauthenticated -> user login -> profile login
|
||||
*/
|
||||
public function test_complete_layered_authentication_flow()
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'email' => 'user@test.com',
|
||||
'password' => Hash::make('user-password'),
|
||||
]);
|
||||
$bank = Bank::factory()->create(['password' => Hash::make('bank-password')]);
|
||||
$user->banksManaged()->attach($bank->id);
|
||||
|
||||
$this->withoutMiddleware(\App\Http\Middleware\VerifyCsrfToken::class);
|
||||
|
||||
// Step 1: Try to access bank direct login while not authenticated
|
||||
$response = $this->get(route('bank.direct-login', [
|
||||
'bankId' => $bank->id,
|
||||
'intended' => '/final/destination',
|
||||
]));
|
||||
|
||||
// Should redirect to user login
|
||||
$response->assertRedirect();
|
||||
$this->assertStringContainsString('login', $response->headers->get('Location'));
|
||||
|
||||
// Step 2: Authenticate as user
|
||||
$this->post(route('login'), [
|
||||
'name' => $user->email,
|
||||
'password' => 'user-password',
|
||||
]);
|
||||
|
||||
$this->assertTrue(Auth::guard('web')->check());
|
||||
|
||||
// Step 3: Now access bank direct login (should redirect to bank password form)
|
||||
$response = $this->get(route('bank.direct-login', [
|
||||
'bankId' => $bank->id,
|
||||
'intended' => '/final/destination',
|
||||
]));
|
||||
|
||||
$response->assertRedirect(route('bank.login'));
|
||||
|
||||
// Step 4: Enter bank password
|
||||
$response = $this->post(route('bank.login.post'), [
|
||||
'password' => 'bank-password',
|
||||
]);
|
||||
|
||||
// Should be authenticated on bank guard and redirected to final destination
|
||||
$this->assertTrue(Auth::guard('bank')->check());
|
||||
$response->assertRedirect('/final/destination');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Security\Authentication;
|
||||
|
||||
use App\Models\Admin;
|
||||
use App\Models\Bank;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Multi-Guard Authentication Security Tests
|
||||
*
|
||||
* Tests the 4-guard authentication system:
|
||||
* - web: Individual user accounts
|
||||
* - organization: Non-profit organization profiles
|
||||
* - bank: Timebank operator profiles
|
||||
* - admin: Administrative profiles
|
||||
*
|
||||
* @group security
|
||||
* @group critical
|
||||
* @group authentication
|
||||
*/
|
||||
class MultiGuardAuthenticationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
// ==========================================
|
||||
// BASIC GUARD AUTHENTICATION TESTS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Test that a user can authenticate on the web guard
|
||||
*/
|
||||
public function test_user_can_authenticate_on_web_guard()
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'email' => 'user@test.com',
|
||||
'password' => Hash::make('password123'),
|
||||
]);
|
||||
|
||||
$this->withoutMiddleware(\App\Http\Middleware\VerifyCsrfToken::class);
|
||||
|
||||
$response = $this->post(route('login'), [
|
||||
'name' => $user->email,
|
||||
'password' => 'password123',
|
||||
]);
|
||||
|
||||
// Assert authenticated on web guard
|
||||
$this->assertTrue(Auth::guard('web')->check());
|
||||
$this->assertEquals($user->id, Auth::guard('web')->id());
|
||||
|
||||
// Assert not authenticated on other guards
|
||||
$this->assertFalse(Auth::guard('organization')->check());
|
||||
$this->assertFalse(Auth::guard('bank')->check());
|
||||
$this->assertFalse(Auth::guard('admin')->check());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that authentication fails with invalid credentials
|
||||
*/
|
||||
public function test_cannot_authenticate_with_invalid_credentials()
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'email' => 'user@test.com',
|
||||
'password' => Hash::make('correct-password'),
|
||||
]);
|
||||
|
||||
$this->withoutMiddleware(\App\Http\Middleware\VerifyCsrfToken::class);
|
||||
|
||||
$this->post(route('login'), [
|
||||
'name' => $user->email,
|
||||
'password' => 'wrong-password',
|
||||
]);
|
||||
|
||||
// Assert not authenticated on any guard
|
||||
$this->assertFalse(Auth::guard('web')->check());
|
||||
$this->assertFalse(Auth::guard('organization')->check());
|
||||
$this->assertFalse(Auth::guard('bank')->check());
|
||||
$this->assertFalse(Auth::guard('admin')->check());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that a user cannot authenticate on wrong guard
|
||||
*/
|
||||
public function test_cannot_authenticate_user_on_organization_guard()
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'password' => Hash::make('password123'),
|
||||
]);
|
||||
|
||||
// Attempt to manually authenticate user on organization guard (should not work)
|
||||
$result = Auth::guard('organization')->attempt([
|
||||
'email' => $user->email,
|
||||
'password' => 'password123',
|
||||
]);
|
||||
|
||||
$this->assertFalse($result);
|
||||
$this->assertFalse(Auth::guard('organization')->check());
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// GUARD ISOLATION TESTS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Test that web guard remains active when elevated guard is active
|
||||
*/
|
||||
public function test_web_guard_remains_active_with_elevated_guard()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create([
|
||||
'password' => Hash::make('org-password'),
|
||||
]);
|
||||
|
||||
// Link user to organization
|
||||
$user->organizations()->attach($organization->id);
|
||||
|
||||
// Authenticate on web guard
|
||||
Auth::guard('web')->login($user);
|
||||
$this->assertTrue(Auth::guard('web')->check());
|
||||
|
||||
// Now authenticate on organization guard
|
||||
Auth::guard('organization')->login($organization);
|
||||
session(['active_guard' => 'organization']);
|
||||
|
||||
// Both guards should be active
|
||||
$this->assertTrue(Auth::guard('web')->check(), 'Web guard should remain active');
|
||||
$this->assertTrue(Auth::guard('organization')->check(), 'Organization guard should be active');
|
||||
$this->assertEquals('organization', session('active_guard'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that only one elevated guard can be active at a time
|
||||
*/
|
||||
public function test_only_one_elevated_guard_active_at_time()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create([
|
||||
'password' => Hash::make('password'),
|
||||
]);
|
||||
$bank = Bank::factory()->create([
|
||||
'password' => Hash::make('password'),
|
||||
]);
|
||||
|
||||
// Link user to both profiles
|
||||
$user->organizations()->attach($organization->id);
|
||||
$user->banksManaged()->attach($bank->id);
|
||||
|
||||
// Authenticate on web guard first
|
||||
Auth::guard('web')->login($user);
|
||||
|
||||
// Authenticate on organization guard
|
||||
Auth::guard('organization')->login($organization);
|
||||
session(['active_guard' => 'organization']);
|
||||
|
||||
$this->assertTrue(Auth::guard('organization')->check());
|
||||
|
||||
// Now use SwitchGuardTrait to switch to bank (should logout organization)
|
||||
$controller = new class {
|
||||
use \App\Traits\SwitchGuardTrait;
|
||||
};
|
||||
|
||||
$controller->switchGuard('bank', $bank);
|
||||
|
||||
// Only bank guard should be active among elevated guards
|
||||
$this->assertFalse(Auth::guard('organization')->check(), 'Organization guard should be logged out');
|
||||
$this->assertTrue(Auth::guard('bank')->check(), 'Bank guard should be active');
|
||||
$this->assertTrue(Auth::guard('web')->check(), 'Web guard should remain active');
|
||||
$this->assertEquals('bank', session('active_guard'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test switching guard logs out other elevated guards
|
||||
*/
|
||||
public function test_switching_guard_logs_out_other_elevated_guards()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create(['password' => Hash::make('password')]);
|
||||
$bank = Bank::factory()->create(['password' => Hash::make('password')]);
|
||||
$admin = Admin::factory()->create(['password' => Hash::make('password')]);
|
||||
|
||||
// Link user to all profiles
|
||||
$user->organizations()->attach($organization->id);
|
||||
$user->banksManaged()->attach($bank->id);
|
||||
$user->admins()->attach($admin->id);
|
||||
|
||||
Auth::guard('web')->login($user);
|
||||
|
||||
// Use SwitchGuardTrait
|
||||
$controller = new class {
|
||||
use \App\Traits\SwitchGuardTrait;
|
||||
};
|
||||
|
||||
// Switch to organization
|
||||
$controller->switchGuard('organization', $organization);
|
||||
$this->assertTrue(Auth::guard('organization')->check());
|
||||
$this->assertFalse(Auth::guard('bank')->check());
|
||||
$this->assertFalse(Auth::guard('admin')->check());
|
||||
|
||||
// Switch to bank
|
||||
$controller->switchGuard('bank', $bank);
|
||||
$this->assertFalse(Auth::guard('organization')->check(), 'Organization should be logged out');
|
||||
$this->assertTrue(Auth::guard('bank')->check());
|
||||
$this->assertFalse(Auth::guard('admin')->check());
|
||||
|
||||
// Switch to admin
|
||||
$controller->switchGuard('admin', $admin);
|
||||
$this->assertFalse(Auth::guard('organization')->check());
|
||||
$this->assertFalse(Auth::guard('bank')->check(), 'Bank should be logged out');
|
||||
$this->assertTrue(Auth::guard('admin')->check());
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// SESSION STATE MANAGEMENT TESTS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Test that active guard is stored in session
|
||||
*/
|
||||
public function test_active_guard_stored_in_session()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create(['password' => Hash::make('password')]);
|
||||
$user->organizations()->attach($organization->id);
|
||||
|
||||
Auth::guard('web')->login($user);
|
||||
|
||||
$controller = new class {
|
||||
use \App\Traits\SwitchGuardTrait;
|
||||
};
|
||||
|
||||
$controller->switchGuard('organization', $organization);
|
||||
|
||||
$this->assertEquals('organization', session('active_guard'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that logging out non-web guards sets active guard to web
|
||||
*/
|
||||
public function test_logout_non_web_guards_sets_active_guard_to_web()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create(['password' => Hash::make('password')]);
|
||||
$user->organizations()->attach($organization->id);
|
||||
|
||||
Auth::guard('web')->login($user);
|
||||
|
||||
$controller = new class {
|
||||
use \App\Traits\SwitchGuardTrait;
|
||||
};
|
||||
|
||||
$controller->switchGuard('organization', $organization);
|
||||
$this->assertEquals('organization', session('active_guard'));
|
||||
|
||||
// Logout from non-web guards
|
||||
$controller->logoutNonWebGuards();
|
||||
|
||||
$this->assertEquals('web', session('active_guard'));
|
||||
$this->assertFalse(Auth::guard('organization')->check());
|
||||
$this->assertTrue(Auth::guard('web')->check());
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// AUTHENTICATION EDGE CASES
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Test that guest cannot access authenticated routes
|
||||
*/
|
||||
public function test_guest_cannot_access_authenticated_routes()
|
||||
{
|
||||
$response = $this->get(route('main'));
|
||||
|
||||
$response->assertRedirect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that authenticated user can access web guard routes
|
||||
*/
|
||||
public function test_authenticated_user_can_access_web_guard_routes()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
$response = $this->get(route('main'));
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test authentication persists across requests
|
||||
*/
|
||||
public function test_authentication_persists_across_requests()
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'email' => 'user@test.com',
|
||||
'password' => Hash::make('password123'),
|
||||
]);
|
||||
|
||||
$this->withoutMiddleware(\App\Http\Middleware\VerifyCsrfToken::class);
|
||||
|
||||
// First request - login
|
||||
$this->post(route('login'), [
|
||||
'name' => $user->email,
|
||||
'password' => 'password123',
|
||||
]);
|
||||
|
||||
$this->assertTrue(Auth::guard('web')->check());
|
||||
|
||||
// Second request - should still be authenticated
|
||||
$response = $this->get(route('main'));
|
||||
|
||||
$this->assertTrue(Auth::guard('web')->check());
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that logging out clears authentication
|
||||
*/
|
||||
public function test_logging_out_clears_authentication()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
$this->assertTrue(Auth::guard('web')->check());
|
||||
|
||||
Auth::guard('web')->logout();
|
||||
|
||||
$this->assertFalse(Auth::guard('web')->check());
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// CYCLOS LEGACY PASSWORD MIGRATION TESTS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Test that legacy Cyclos passwords are migrated on successful login
|
||||
*/
|
||||
public function test_cyclos_password_migrated_on_successful_organization_login()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create([
|
||||
'cyclos_salt' => 'legacy_salt',
|
||||
'password' => strtolower(hash('sha256', 'legacy_salt' . 'old-password')),
|
||||
]);
|
||||
|
||||
$user->organizations()->attach($organization->id);
|
||||
|
||||
// Store intent in session
|
||||
session([
|
||||
'intended_profile_switch_type' => 'Organization',
|
||||
'intended_profile_switch_id' => $organization->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
$this->withoutMiddleware(\App\Http\Middleware\VerifyCsrfToken::class);
|
||||
|
||||
// Attempt login with old password
|
||||
$response = $this->post(route('organization.login.post'), [
|
||||
'password' => 'old-password',
|
||||
]);
|
||||
|
||||
// Refresh organization from database
|
||||
$organization->refresh();
|
||||
|
||||
// Assert password was rehashed and salt removed
|
||||
$this->assertNull($organization->cyclos_salt, 'Cyclos salt should be removed');
|
||||
$this->assertTrue(Hash::check('old-password', $organization->password), 'Password should be rehashed with Laravel Hash');
|
||||
|
||||
// Assert authentication succeeded
|
||||
$this->assertTrue(Auth::guard('organization')->check());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Security\Authentication;
|
||||
|
||||
use App\Models\Admin;
|
||||
use App\Models\Bank;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Profile Switching Security Tests
|
||||
*
|
||||
* Tests the security of profile switching in the multi-guard system:
|
||||
* - Ownership verification
|
||||
* - Password requirements (organization: no password, bank/admin: requires password)
|
||||
* - Session state management
|
||||
* - Relationship validation
|
||||
*
|
||||
* @group security
|
||||
* @group critical
|
||||
* @group authentication
|
||||
* @group profile-switching
|
||||
*/
|
||||
class ProfileSwitchingSecurityTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
// ==========================================
|
||||
// OWNERSHIP VERIFICATION TESTS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Test that user can only switch to owned organization profiles
|
||||
*/
|
||||
public function test_user_can_only_switch_to_owned_organization()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$ownedOrganization = Organization::factory()->create(['password' => Hash::make('password')]);
|
||||
$unownedOrganization = Organization::factory()->create(['password' => Hash::make('password')]);
|
||||
|
||||
// Link user to owned organization only
|
||||
$user->organizations()->attach($ownedOrganization->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
// Set intent for owned organization - should work
|
||||
session([
|
||||
'intended_profile_switch_type' => 'Organization',
|
||||
'intended_profile_switch_id' => $ownedOrganization->id,
|
||||
]);
|
||||
|
||||
$response = $this->get(route('organization.login'));
|
||||
$response->assertStatus(200);
|
||||
$response->assertViewIs('profile-organization.login');
|
||||
|
||||
// Set intent for unowned organization - should fail
|
||||
session([
|
||||
'intended_profile_switch_type' => 'Organization',
|
||||
'intended_profile_switch_id' => $unownedOrganization->id,
|
||||
]);
|
||||
|
||||
$response = $this->get(route('organization.login'));
|
||||
// Should show nothing or error because getTargetProfileByTypeAndId returns null
|
||||
$this->assertNull(session('intended_profile_switch_id') ?
|
||||
Organization::find(session('intended_profile_switch_id')) : null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that user cannot switch to unowned bank profile
|
||||
*/
|
||||
public function test_cannot_switch_to_unowned_bank()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$ownedBank = Bank::factory()->create(['password' => Hash::make('password')]);
|
||||
$unownedBank = Bank::factory()->create(['password' => Hash::make('password')]);
|
||||
|
||||
// Link user to owned bank only
|
||||
$user->banksManaged()->attach($ownedBank->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
// Try to access unowned bank
|
||||
session([
|
||||
'intended_profile_switch_type' => 'Bank',
|
||||
'intended_profile_switch_id' => $unownedBank->id,
|
||||
]);
|
||||
|
||||
$response = $this->get(route('bank.login'));
|
||||
|
||||
// The view will receive $profile = null from getTargetProfileByTypeAndId
|
||||
// This might cause an error or show an empty form
|
||||
$response->assertStatus(200); // The route exists but profile will be null
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that user cannot switch to unowned admin profile
|
||||
*/
|
||||
public function test_cannot_switch_to_unowned_admin()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$ownedAdmin = Admin::factory()->create(['password' => Hash::make('password')]);
|
||||
$unownedAdmin = Admin::factory()->create(['password' => Hash::make('password')]);
|
||||
|
||||
// Link user to owned admin only
|
||||
$user->admins()->attach($ownedAdmin->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
// Try to access unowned admin
|
||||
session([
|
||||
'intended_profile_switch_type' => 'Admin',
|
||||
'intended_profile_switch_id' => $unownedAdmin->id,
|
||||
]);
|
||||
|
||||
$response = $this->get(route('admin.login'));
|
||||
|
||||
$response->assertStatus(200); // Route exists but profile will be null
|
||||
}
|
||||
|
||||
/**
|
||||
* Test profile switch validates relationship pivot tables
|
||||
*/
|
||||
public function test_profile_switch_validates_relationship_pivot_tables()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create(['password' => Hash::make('password')]);
|
||||
$bank = Bank::factory()->create(['password' => Hash::make('password')]);
|
||||
$admin = Admin::factory()->create(['password' => Hash::make('password')]);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
// Try organization without pivot entry
|
||||
session([
|
||||
'intended_profile_switch_type' => 'Organization',
|
||||
'intended_profile_switch_id' => $organization->id,
|
||||
]);
|
||||
$response = $this->get(route('organization.login'));
|
||||
// getTargetProfileByTypeAndId will return null (no relationship)
|
||||
|
||||
// Try bank without pivot entry
|
||||
session([
|
||||
'intended_profile_switch_type' => 'Bank',
|
||||
'intended_profile_switch_id' => $bank->id,
|
||||
]);
|
||||
$response = $this->get(route('bank.login'));
|
||||
// getTargetProfileByTypeAndId will return null
|
||||
|
||||
// Try admin without pivot entry
|
||||
session([
|
||||
'intended_profile_switch_type' => 'Admin',
|
||||
'intended_profile_switch_id' => $admin->id,
|
||||
]);
|
||||
$response = $this->get(route('admin.login'));
|
||||
// getTargetProfileByTypeAndId will return null
|
||||
|
||||
// Now add relationships and verify they work
|
||||
$user->organizations()->attach($organization->id);
|
||||
$user->banksManaged()->attach($bank->id);
|
||||
$user->admins()->attach($admin->id);
|
||||
|
||||
session([
|
||||
'intended_profile_switch_type' => 'Organization',
|
||||
'intended_profile_switch_id' => $organization->id,
|
||||
]);
|
||||
$response = $this->get(route('organization.login'));
|
||||
$response->assertStatus(200);
|
||||
$response->assertViewHas('profile', $organization);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// PASSWORD REQUIREMENT TESTS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Test that organization switch does not require password (via direct login)
|
||||
*/
|
||||
public function test_organization_switch_does_not_require_password_via_direct_login()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create(['password' => Hash::make('password')]);
|
||||
$user->organizations()->attach($organization->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
// Direct login for organization should switch immediately
|
||||
$response = $this->get(route('organization.direct-login', ['organizationId' => $organization->id]));
|
||||
|
||||
// Should redirect to main page, not to password form
|
||||
$response->assertRedirect(route('main'));
|
||||
|
||||
// Should be authenticated on organization guard
|
||||
$this->assertTrue(Auth::guard('organization')->check());
|
||||
$this->assertEquals($organization->id, Auth::guard('organization')->id());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that bank switch requires password
|
||||
*/
|
||||
public function test_bank_switch_requires_password()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$bank = Bank::factory()->create(['password' => Hash::make('bank-password')]);
|
||||
$user->banksManaged()->attach($bank->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
// Direct login for bank should redirect to password form
|
||||
$response = $this->get(route('bank.direct-login', ['bankId' => $bank->id]));
|
||||
|
||||
$response->assertRedirect(route('bank.login'));
|
||||
|
||||
// Should NOT be authenticated on bank guard yet
|
||||
$this->assertFalse(Auth::guard('bank')->check());
|
||||
|
||||
// Verify session intent was set
|
||||
$this->assertEquals('Bank', session('intended_profile_switch_type'));
|
||||
$this->assertEquals($bank->id, session('intended_profile_switch_id'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that admin switch requires password
|
||||
*/
|
||||
public function test_admin_switch_requires_password()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$admin = Admin::factory()->create(['password' => Hash::make('admin-password')]);
|
||||
$user->admins()->attach($admin->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
// Direct login for admin should redirect to password form
|
||||
$response = $this->get(route('admin.direct-login', ['adminId' => $admin->id]));
|
||||
|
||||
$response->assertRedirect(route('admin.login'));
|
||||
|
||||
// Should NOT be authenticated on admin guard yet
|
||||
$this->assertFalse(Auth::guard('admin')->check());
|
||||
|
||||
// Verify session intent was set
|
||||
$this->assertEquals('Admin', session('intended_profile_switch_type'));
|
||||
$this->assertEquals($admin->id, session('intended_profile_switch_id'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that invalid password prevents bank profile switch
|
||||
*/
|
||||
public function test_invalid_password_prevents_bank_profile_switch()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$bank = Bank::factory()->create(['password' => Hash::make('correct-password')]);
|
||||
$user->banksManaged()->attach($bank->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
$this->withoutMiddleware(\App\Http\Middleware\VerifyCsrfToken::class);
|
||||
|
||||
session([
|
||||
'intended_profile_switch_type' => 'Bank',
|
||||
'intended_profile_switch_id' => $bank->id,
|
||||
]);
|
||||
|
||||
$response = $this->post(route('bank.login.post'), [
|
||||
'password' => 'wrong-password',
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors(['password']);
|
||||
$this->assertFalse(Auth::guard('bank')->check());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that valid password allows bank profile switch
|
||||
*/
|
||||
public function test_valid_password_allows_bank_profile_switch()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$bank = Bank::factory()->create(['password' => Hash::make('correct-password')]);
|
||||
$user->banksManaged()->attach($bank->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
$this->withoutMiddleware(\App\Http\Middleware\VerifyCsrfToken::class);
|
||||
|
||||
session([
|
||||
'intended_profile_switch_type' => 'Bank',
|
||||
'intended_profile_switch_id' => $bank->id,
|
||||
]);
|
||||
|
||||
$response = $this->post(route('bank.login.post'), [
|
||||
'password' => 'correct-password',
|
||||
]);
|
||||
|
||||
$response->assertRedirect(route('main'));
|
||||
$this->assertTrue(Auth::guard('bank')->check());
|
||||
$this->assertEquals($bank->id, Auth::guard('bank')->id());
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// SESSION STATE MANAGEMENT TESTS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Test that profile switch clears session variables after successful authentication
|
||||
*/
|
||||
public function test_profile_switch_clears_session_variables()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$bank = Bank::factory()->create(['password' => Hash::make('password')]);
|
||||
$user->banksManaged()->attach($bank->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
$this->withoutMiddleware(\App\Http\Middleware\VerifyCsrfToken::class);
|
||||
|
||||
session([
|
||||
'intended_profile_switch_type' => 'Bank',
|
||||
'intended_profile_switch_id' => $bank->id,
|
||||
]);
|
||||
|
||||
$this->assertNotNull(session('intended_profile_switch_type'));
|
||||
$this->assertNotNull(session('intended_profile_switch_id'));
|
||||
|
||||
$this->post(route('bank.login.post'), [
|
||||
'password' => 'password',
|
||||
]);
|
||||
|
||||
// Session variables should be cleared after successful login
|
||||
$this->assertNull(session('intended_profile_switch_type'));
|
||||
$this->assertNull(session('intended_profile_switch_id'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that active profile information is stored in session
|
||||
*/
|
||||
public function test_active_profile_stored_in_session()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create(['password' => Hash::make('password')]);
|
||||
$user->organizations()->attach($organization->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
$response = $this->get(route('organization.direct-login', ['organizationId' => $organization->id]));
|
||||
|
||||
// Check session contains profile information
|
||||
$this->assertEquals(get_class($organization), session('activeProfileType'));
|
||||
$this->assertEquals($organization->id, session('activeProfileId'));
|
||||
$this->assertEquals($organization->name, session('activeProfileName'));
|
||||
$this->assertNotNull(session('last_activity'));
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// EDGE CASES
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Test that switching to nonexistent profile fails gracefully
|
||||
*/
|
||||
public function test_cannot_switch_to_nonexistent_profile()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
// Try to access nonexistent organization
|
||||
$response = $this->get(route('organization.direct-login', ['organizationId' => 99999]));
|
||||
|
||||
$response->assertStatus(404);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that switching to soft-deleted profile fails
|
||||
*/
|
||||
public function test_cannot_switch_to_soft_deleted_profile()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create(['password' => Hash::make('password')]);
|
||||
$user->organizations()->attach($organization->id);
|
||||
|
||||
// Soft delete the organization
|
||||
$organization->delete();
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
// Try to access deleted organization
|
||||
$response = $this->get(route('organization.direct-login', ['organizationId' => $organization->id]));
|
||||
|
||||
$response->assertStatus(404);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that profile switch requires user to be authenticated
|
||||
*/
|
||||
public function test_profile_switch_requires_authentication()
|
||||
{
|
||||
$organization = Organization::factory()->create(['password' => Hash::make('password')]);
|
||||
|
||||
// Try to access organization without being authenticated
|
||||
$response = $this->get(route('organization.direct-login', ['organizationId' => $organization->id]));
|
||||
|
||||
// Should redirect to login
|
||||
$response->assertRedirect();
|
||||
$this->assertStringContainsString('login', $response->headers->get('Location'));
|
||||
}
|
||||
}
|
||||
386
tests/Feature/Security/Authentication/SessionSecurityTest.php
Normal file
386
tests/Feature/Security/Authentication/SessionSecurityTest.php
Normal file
@@ -0,0 +1,386 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Security\Authentication;
|
||||
|
||||
use App\Models\Admin;
|
||||
use App\Models\Bank;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Session Security Tests
|
||||
*
|
||||
* Tests session security features:
|
||||
* - Session regeneration on login
|
||||
* - Session data protection
|
||||
* - Session cookie security
|
||||
* - Session clearing on logout
|
||||
*
|
||||
* @group security
|
||||
* @group high
|
||||
* @group authentication
|
||||
* @group session
|
||||
*/
|
||||
class SessionSecurityTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
// ==========================================
|
||||
// SESSION REGENERATION TESTS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Test that session is regenerated on user login
|
||||
*/
|
||||
public function test_session_regenerated_on_login()
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'email' => 'user@test.com',
|
||||
'password' => Hash::make('password123'),
|
||||
]);
|
||||
|
||||
$this->withoutMiddleware(\App\Http\Middleware\VerifyCsrfToken::class);
|
||||
|
||||
// Start session and get initial ID
|
||||
$this->startSession();
|
||||
$initialSessionId = Session::getId();
|
||||
|
||||
// Login
|
||||
$this->post(route('login'), [
|
||||
'name' => $user->email,
|
||||
'password' => 'password123',
|
||||
]);
|
||||
|
||||
// Session ID should have changed (regenerated)
|
||||
$newSessionId = Session::getId();
|
||||
$this->assertNotEquals($initialSessionId, $newSessionId, 'Session should be regenerated on login');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that session is regenerated on profile switch
|
||||
*/
|
||||
public function test_session_regenerated_on_profile_switch()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create();
|
||||
$user->organizations()->attach($organization->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
$initialSessionId = Session::getId();
|
||||
|
||||
// Switch to organization profile
|
||||
$this->get(route('organization.direct-login', ['organizationId' => $organization->id]));
|
||||
|
||||
// Note: Session regeneration on profile switch may not be implemented
|
||||
// This test documents expected behavior
|
||||
// If this test fails, consider implementing session regeneration on profile switch for security
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// SESSION DATA PROTECTION TESTS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Test that sensitive data is not stored in session
|
||||
*/
|
||||
public function test_sensitive_data_not_stored_in_session()
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'email' => 'user@test.com',
|
||||
'password' => Hash::make('secret-password'),
|
||||
]);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
$sessionData = Session::all();
|
||||
|
||||
// Passwords should never be in session
|
||||
$sessionString = json_encode($sessionData);
|
||||
$this->assertStringNotContainsString('secret-password', $sessionString);
|
||||
$this->assertStringNotContainsString('password', strtolower($sessionString));
|
||||
|
||||
// Email might be OK in session depending on implementation
|
||||
// but password hash should definitely not be there
|
||||
foreach ($sessionData as $key => $value) {
|
||||
if (is_string($value)) {
|
||||
$this->assertStringNotContainsString('$2y$', $value, 'Password hash should not be in session');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that active profile data is stored correctly in session
|
||||
*/
|
||||
public function test_active_profile_data_stored_correctly()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create(['name' => 'Test Org']);
|
||||
$user->organizations()->attach($organization->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
$this->get(route('organization.direct-login', ['organizationId' => $organization->id]));
|
||||
|
||||
// Session should contain profile information but not sensitive data
|
||||
$this->assertEquals(get_class($organization), session('activeProfileType'));
|
||||
$this->assertEquals($organization->id, session('activeProfileId'));
|
||||
$this->assertEquals($organization->name, session('activeProfileName'));
|
||||
|
||||
// Session should have last activity time
|
||||
$this->assertNotNull(session('last_activity'));
|
||||
|
||||
// Session should NOT contain password
|
||||
$this->assertNull(session('activeProfilePassword'));
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// SESSION CLEARING TESTS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Test that logging out clears authentication and session data
|
||||
*/
|
||||
public function test_logging_out_clears_authentication_and_session()
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'email' => 'user@test.com',
|
||||
'password' => Hash::make('password123'),
|
||||
]);
|
||||
|
||||
$this->withoutMiddleware(\App\Http\Middleware\VerifyCsrfToken::class);
|
||||
|
||||
// Login
|
||||
$this->post(route('login'), [
|
||||
'name' => $user->email,
|
||||
'password' => 'password123',
|
||||
]);
|
||||
|
||||
$this->assertTrue(Auth::guard('web')->check());
|
||||
|
||||
// Logout
|
||||
$this->post(route('logout'));
|
||||
|
||||
// Should no longer be authenticated
|
||||
$this->assertFalse(Auth::guard('web')->check());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that profile switch session variables are cleared after login
|
||||
*/
|
||||
public function test_profile_switch_session_variables_cleared()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$bank = Bank::factory()->create(['password' => Hash::make('password')]);
|
||||
$user->banksManaged()->attach($bank->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
$this->withoutMiddleware(\App\Http\Middleware\VerifyCsrfToken::class);
|
||||
|
||||
// Set profile switch intent
|
||||
session([
|
||||
'intended_profile_switch_type' => 'Bank',
|
||||
'intended_profile_switch_id' => $bank->id,
|
||||
'bank_login_intended_url' => '/test/url',
|
||||
]);
|
||||
|
||||
// Complete profile switch
|
||||
$this->post(route('bank.login.post'), [
|
||||
'password' => 'password',
|
||||
]);
|
||||
|
||||
// Session variables should be cleared
|
||||
$this->assertNull(session('intended_profile_switch_type'));
|
||||
$this->assertNull(session('intended_profile_switch_id'));
|
||||
$this->assertNull(session('bank_login_intended_url'));
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// SESSION PERSISTENCE TESTS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Test that authentication persists across multiple requests
|
||||
*/
|
||||
public function test_authentication_persists_across_requests()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
// Make multiple requests
|
||||
$this->get(route('main'))->assertStatus(200);
|
||||
$this->assertTrue(Auth::guard('web')->check());
|
||||
|
||||
// Second request
|
||||
$this->get(route('main'))->assertStatus(200);
|
||||
$this->assertTrue(Auth::guard('web')->check());
|
||||
|
||||
// Third request
|
||||
$this->get(route('main'))->assertStatus(200);
|
||||
$this->assertTrue(Auth::guard('web')->check());
|
||||
|
||||
// All requests should maintain authentication
|
||||
$this->assertEquals($user->id, Auth::guard('web')->id());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that multiple guards can be authenticated simultaneously
|
||||
*/
|
||||
public function test_multiple_guards_authenticated_simultaneously()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create();
|
||||
$user->organizations()->attach($organization->id);
|
||||
|
||||
// Authenticate on web guard
|
||||
Auth::guard('web')->login($user);
|
||||
|
||||
// Authenticate on organization guard
|
||||
Auth::guard('organization')->login($organization);
|
||||
session(['active_guard' => 'organization']);
|
||||
|
||||
// Both should be authenticated
|
||||
$this->assertTrue(Auth::guard('web')->check());
|
||||
$this->assertTrue(Auth::guard('organization')->check());
|
||||
|
||||
// Make a request - both should remain authenticated
|
||||
$this->get(route('main'));
|
||||
|
||||
$this->assertTrue(Auth::guard('web')->check());
|
||||
$this->assertTrue(Auth::guard('organization')->check());
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// ACTIVE GUARD TRACKING TESTS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Test that active guard is tracked in session
|
||||
*/
|
||||
public function test_active_guard_tracked_in_session()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create();
|
||||
$user->organizations()->attach($organization->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
|
||||
// Initially on web guard
|
||||
// Session might not have active_guard set initially
|
||||
|
||||
// Switch to organization
|
||||
$this->get(route('organization.direct-login', ['organizationId' => $organization->id]));
|
||||
|
||||
$this->assertEquals('organization', session('active_guard'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that active guard changes when switching profiles
|
||||
*/
|
||||
public function test_active_guard_changes_when_switching_profiles()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organization = Organization::factory()->create();
|
||||
$bank = Bank::factory()->create(['password' => Hash::make('password')]);
|
||||
|
||||
$user->organizations()->attach($organization->id);
|
||||
$user->banksManaged()->attach($bank->id);
|
||||
|
||||
$this->actingAs($user, 'web');
|
||||
$this->withoutMiddleware(\App\Http\Middleware\VerifyCsrfToken::class);
|
||||
|
||||
// Switch to organization
|
||||
$this->get(route('organization.direct-login', ['organizationId' => $organization->id]));
|
||||
$this->assertEquals('organization', session('active_guard'));
|
||||
|
||||
// Switch to bank
|
||||
session([
|
||||
'intended_profile_switch_type' => 'Bank',
|
||||
'intended_profile_switch_id' => $bank->id,
|
||||
]);
|
||||
$this->post(route('bank.login.post'), ['password' => 'password']);
|
||||
|
||||
$this->assertEquals('bank', session('active_guard'));
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// SESSION SECURITY EDGE CASES
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Test that unauthenticated users have no authentication data in session
|
||||
*/
|
||||
public function test_unauthenticated_users_have_no_auth_data_in_session()
|
||||
{
|
||||
$response = $this->get('/');
|
||||
|
||||
$sessionData = Session::all();
|
||||
|
||||
// Should not have authentication-related session keys
|
||||
$this->assertArrayNotHasKey('activeProfileType', $sessionData);
|
||||
$this->assertArrayNotHasKey('activeProfileId', $sessionData);
|
||||
$this->assertArrayNotHasKey('activeProfileName', $sessionData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that session data is properly isolated between users
|
||||
*/
|
||||
public function test_session_data_isolated_between_users()
|
||||
{
|
||||
$user1 = User::factory()->create();
|
||||
$user2 = User::factory()->create();
|
||||
|
||||
// Login as user1
|
||||
$this->actingAs($user1, 'web');
|
||||
$sessionId1 = Session::getId();
|
||||
|
||||
// Logout
|
||||
Auth::guard('web')->logout();
|
||||
Session::flush();
|
||||
|
||||
// Login as user2
|
||||
$this->actingAs($user2, 'web');
|
||||
$sessionId2 = Session::getId();
|
||||
|
||||
// Session IDs should be different
|
||||
$this->assertNotEquals($sessionId1, $sessionId2);
|
||||
|
||||
// Should be authenticated as user2, not user1
|
||||
$this->assertEquals($user2->id, Auth::guard('web')->id());
|
||||
$this->assertNotEquals($user1->id, Auth::guard('web')->id());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that session regeneration prevents session fixation attacks
|
||||
*/
|
||||
public function test_session_regeneration_prevents_fixation()
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'email' => 'user@test.com',
|
||||
'password' => Hash::make('password123'),
|
||||
]);
|
||||
|
||||
$this->withoutMiddleware(\App\Http\Middleware\VerifyCsrfToken::class);
|
||||
|
||||
// Attacker sets a known session ID
|
||||
$this->startSession();
|
||||
$attackerSessionId = Session::getId();
|
||||
|
||||
// Victim logs in with that session
|
||||
$this->post(route('login'), [
|
||||
'name' => $user->email,
|
||||
'password' => 'password123',
|
||||
]);
|
||||
|
||||
// Session ID should have changed, preventing attacker from hijacking
|
||||
$newSessionId = Session::getId();
|
||||
$this->assertNotEquals($attackerSessionId, $newSessionId,
|
||||
'Session ID should change on login to prevent fixation attacks');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user