diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php index 1c95544..75cc94f 100644 --- a/app/Http/Controllers/DashboardController.php +++ b/app/Http/Controllers/DashboardController.php @@ -36,15 +36,9 @@ public function launchSSO($id) return redirect()->route('dashboard')->with('error', 'Access denied. You do not have permission to access this service.'); } - // Check if it is a Microsoft service - $isMicrosoft = str_contains($app->url, 'microsoft.com') || - str_contains($app->url, 'office.com') || - str_contains($app->url, 'office365.com') || - str_contains($app->url, 'outlook.com') || - $app->icon_svg === 'outlook' || - $app->icon_svg === 'teams'; - - if ($isMicrosoft && $user->microsoft_id) { + // If user is logged in via Microsoft, route all service launches (including Keka) + // through the Microsoft SSO flow to ensure their browser has an active Microsoft session. + if ($user->microsoft_id) { // Save target app URL in session so callback knows where to redirect session(['sso_target_url' => $app->url]); diff --git a/app/Http/Controllers/LoginController.php b/app/Http/Controllers/LoginController.php index bb91cde..14373ff 100644 --- a/app/Http/Controllers/LoginController.php +++ b/app/Http/Controllers/LoginController.php @@ -114,6 +114,9 @@ public function handleMicrosoftCallback(Request $request) if ($user->is_blocked) { return redirect()->route('login')->with('error', 'Your account has been blocked. Please contact the administrator.'); } + if ($user->role === 'admin') { + return redirect()->route('admin.login')->with('error', 'Security protocol: Administrators are restricted to email and password authentication only.'); + } // Update existing user with Microsoft data if not already set $user->update([ 'microsoft_id' => $microsoftUser->getId(), @@ -155,7 +158,26 @@ public function logout(Request $request) $request->session()->invalidate(); $request->session()->regenerateToken(); - return redirect()->route('login')->with('success', 'Logged out successfully.'); + // Redirect to Microsoft's logout URL to log the user out of Microsoft as well + try { + $microsoftLogoutUrl = Socialite::driver('microsoft')->getLogoutUrl(route('login')); + return redirect($microsoftLogoutUrl); + } catch (\Exception $e) { + return redirect()->route('login')->with('success', 'Logged out successfully.'); + } + } + + /** + * Handle front-channel logout from Microsoft. + */ + public function frontChannelLogout(Request $request) + { + Auth::logout(); + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + // Microsoft renders this in an iframe. A simple 200 response is expected. + return response('Logged out from SingleLogin', 200); } /** diff --git a/public/Screenshot_3.png b/public/Screenshot_3.png new file mode 100644 index 0000000..e2b9b6d Binary files /dev/null and b/public/Screenshot_3.png differ diff --git a/public/Screenshot_4.png b/public/Screenshot_4.png new file mode 100644 index 0000000..42566e1 Binary files /dev/null and b/public/Screenshot_4.png differ diff --git a/resources/views/dashboard.blade.php b/resources/views/dashboard.blade.php index 5a2fbeb..4856abd 100644 --- a/resources/views/dashboard.blade.php +++ b/resources/views/dashboard.blade.php @@ -529,7 +529,15 @@
{{ $user->email }} • - {{ $user->role }} + + @if($user->isAdmin()) + Admin + @elseif($user->roles->isNotEmpty()) + {{ $user->roles->pluck('name')->implode(', ') }} + @else + {{ ucfirst($user->role) }} + @endif + @if($user->microsoft_id) Microsoft Linked @endif diff --git a/routes/web.php b/routes/web.php index 2383844..71513b7 100644 --- a/routes/web.php +++ b/routes/web.php @@ -16,6 +16,7 @@ // Microsoft OAuth endpoints Route::get('/auth/microsoft/redirect', [LoginController::class, 'redirectToMicrosoft'])->name('auth.microsoft.redirect'); Route::get('/auth/microsoft/callback', [LoginController::class, 'handleMicrosoftCallback'])->name('auth.microsoft.callback'); +Route::get('/auth/microsoft/logout', [LoginController::class, 'frontChannelLogout'])->name('auth.microsoft.logout'); // User Dashboard Portal (requires standard user role or admin access) Route::middleware(['auth', 'role:user'])->group(function () { diff --git a/tests/Feature/SecurityAndAdminTest.php b/tests/Feature/SecurityAndAdminTest.php index a95c804..383448f 100644 --- a/tests/Feature/SecurityAndAdminTest.php +++ b/tests/Feature/SecurityAndAdminTest.php @@ -188,4 +188,92 @@ public function test_admin_cannot_authenticate_via_microsoft_oauth_callback(): v $response->assertSessionHas('error', 'Security protocol: Administrators are restricted to email and password authentication only.'); $this->assertFalse(auth()->check()); } + + /** + * Test local logout redirects to Microsoft global logout endpoint. + */ + public function test_user_logout_redirects_to_microsoft_logout_page(): void + { + $user = User::create([ + 'name' => 'Standard User', + 'email' => 'user@sentientgeeks.com', + 'role' => 'user', + ]); + + $mockSocialite = \Mockery::mock('Laravel\Socialite\Contracts\Provider'); + $mockSocialite->shouldReceive('getLogoutUrl') + ->with(route('login')) + ->andReturn('https://login.microsoftonline.com/common/oauth2/v2.0/logout?post_logout_redirect_uri=' . urlencode(route('login'))); + + \Laravel\Socialite\Facades\Socialite::shouldReceive('driver')->with('microsoft')->andReturn($mockSocialite); + + $response = $this->actingAs($user) + ->get('/logout'); // supports GET for testing + + $response->assertRedirect('https://login.microsoftonline.com/common/oauth2/v2.0/logout?post_logout_redirect_uri=' . urlencode(route('login'))); + $this->assertFalse(auth()->check()); + } + + /** + * Test Microsoft Front-channel logout callback terminates local session. + */ + public function test_microsoft_front_channel_logout_clears_session(): void + { + $user = User::create([ + 'name' => 'Standard User', + 'email' => 'user@sentientgeeks.com', + 'role' => 'user', + ]); + + $this->actingAs($user); + $this->assertTrue(auth()->check()); + + $response = $this->get('/auth/microsoft/logout'); + + $response->assertStatus(200); + $response->assertSee('Logged out from SingleLogin'); + $this->assertFalse(auth()->check()); + } + + /** + * Test that launching any service (like Keka) routes through Microsoft SSO flow + * if the user is Microsoft-linked. + */ + public function test_sso_launch_redirects_to_microsoft_for_all_services_if_user_is_microsoft_linked(): void + { + $user = User::create([ + 'name' => 'Standard User', + 'email' => 'user@sentientgeeks.com', + 'role' => 'user', + 'microsoft_id' => 'user-microsoft-id-123', + ]); + + $app = App::create([ + 'name' => 'Keka HR', + 'url' => 'https://sentientgeeks.keka.com', + 'color' => '#f27059', + 'tag' => 'HR Portal' + ]); + + // Assign role with the app to user + $role = Role::create(['name' => 'Employee']); + $role->apps()->sync([$app->id]); + $user->roles()->sync([$role->id]); + + $mockSocialite = \Mockery::mock('Laravel\Socialite\Contracts\Provider'); + $mockSocialite->shouldReceive('redirectUrl')->with(route('sso.callback'))->andReturnSelf(); + $mockSocialite->shouldReceive('with')->with([ + 'login_hint' => $user->email, + 'domain_hint' => 'organizations' + ])->andReturnSelf(); + $mockSocialite->shouldReceive('redirect') + ->andReturn(redirect('https://login.microsoftonline.com/common/oauth2/v2.0/authorize')); + + \Laravel\Socialite\Facades\Socialite::shouldReceive('driver')->with('microsoft')->andReturn($mockSocialite); + + $response = $this->actingAs($user) + ->get(route('sso.launch', $app->id)); + + $response->assertRedirect('https://login.microsoftonline.com/common/oauth2/v2.0/authorize'); + } }