523 lines
17 KiB
PHP
523 lines
17 KiB
PHP
<?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');
|
|
}
|
|
}
|