singleloginsystem/app/Http/Controllers/EntraController.php
= 82d1cdb204 Refactor: Replace Outlook integration with Entra ID federation
- Removed legacy Outlook integration, including views, controllers, and models.
- Introduced Microsoft Entra ID OAuth flows with enhanced permission checks and token management.
- Migrated database structure: replaced `outlook_tokens` table with `oauth_tokens` table for multi-provider support.
- Added comprehensive tests for Entra ID federation, including connection flow, token refresh, and permission checks.
- Standardized OAuth implementation using `EntraController` and consolidated token models into `OauthToken`.
2026-06-10 13:16:34 +00:00

222 lines
7.7 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\OauthToken;
use Illuminate\Http\{RedirectResponse, Request};
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
class EntraController extends Controller
{
// ── OAuth config from .env ────────────────────────────────────────────────
private string $tenantId;
private string $clientId;
private string $clientSecret;
private string $redirectUri;
private const PROVIDER = 'microsoft';
private const SCOPES = 'openid profile email offline_access User.Read Mail.Read Mail.ReadWrite Mail.Send';
public function __construct()
{
$this->tenantId = config('services.microsoft.tenant_id');
$this->clientId = config('services.microsoft.client_id');
$this->clientSecret = config('services.microsoft.client_secret');
$this->redirectUri = route('entra.callback');
}
// ── Step 1: Redirect user to Microsoft login ──────────────────────────────
public function connect(): RedirectResponse
{
$user = auth()->user();
if (! $user) {
abort(403, 'Unauthorized');
}
$activeServices = app(\App\Services\UserAppAccessService::class)->getActiveServices($user);
$hasAzureFdAccess = $activeServices->contains(fn ($service) => 'azure-fd' === $service->provider_slug);
if (! $hasAzureFdAccess) {
abort(403, 'You do not have permission to access the required application.');
}
$state = Str::random(40);
session(['oauth_state' => $state]);
$query = http_build_query([
'client_id' => $this->clientId,
'response_type' => 'code',
'redirect_uri' => $this->redirectUri,
'response_mode' => 'query',
'scope' => self::SCOPES,
'state' => $state,
'prompt' => 'select_account',
]);
return redirect("https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/authorize?{$query}");
}
// ── Step 2: Handle callback, exchange code for tokens ────────────────────
public function callback(Request $request): RedirectResponse
{
$user = auth()->user();
if (! $user) {
abort(403, 'Unauthorized');
}
$activeServices = app(\App\Services\UserAppAccessService::class)->getActiveServices($user);
$hasAzureFdAccess = $activeServices->contains(fn ($service) => 'azure-fd' === $service->provider_slug);
if (! $hasAzureFdAccess) {
abort(403, 'You do not have permission to access the required application.');
}
// CSRF state check
if ($request->state !== session('oauth_state')) {
return redirect()->route('dashboard.user')->with('entra_error', 'Invalid OAuth state. Please try again.');
}
if ($request->has('error')) {
return redirect()->route('dashboard.user')->with('entra_error', $request->error_description ?? 'Microsoft login failed.');
}
// Exchange authorization code for tokens
$response = Http::asForm()->post(
"https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/token",
[
'client_id' => $this->clientId,
'client_secret' => $this->clientSecret,
'code' => $request->code,
'redirect_uri' => $this->redirectUri,
'grant_type' => 'authorization_code',
]
);
if ($response->failed()) {
return redirect()->route('dashboard.user')->with('entra_error', 'Failed to retrieve tokens from Microsoft.');
}
$data = $response->json();
// Persist token for this user (upsert so re-connecting just updates)
OauthToken::updateOrCreate(
[
'user_id' => auth()->id(),
'provider' => self::PROVIDER,
],
[
'access_token' => $data['access_token'],
'refresh_token' => $data['refresh_token'] ?? null,
'token_type' => $data['token_type'] ?? 'Bearer',
'scopes' => self::SCOPES,
'expires_at' => now()->addSeconds($data['expires_in'] ?? 3600),
]
);
return redirect()->route('dashboard.user')->with('entra_success', 'Microsoft connected successfully!');
}
// ── Open Entra (refresh token silently if expired) ───────────────────────
public function openEntra(): RedirectResponse
{
$user = auth()->user();
if (! $user) {
abort(403, 'Unauthorized');
}
$activeServices = app(\App\Services\UserAppAccessService::class)->getActiveServices($user);
$hasAzureFdAccess = $activeServices->contains(fn ($service) => 'azure-fd' === $service->provider_slug);
if (! $hasAzureFdAccess) {
abort(403, 'You do not have permission to access the required application.');
}
$token = $user->oauthToken(self::PROVIDER);
if (! $token) {
return redirect()->route('entra.connect');
}
// If token is expired, try to refresh silently
if ($token->isExpired()) {
$refreshed = $this->refreshToken($token);
if (! $refreshed) {
// Refresh failed — force re-login
$token->delete();
return redirect()->route('entra.connect');
}
}
// Redirect to Outlook Web — SSO session handles silent sign-in
return redirect('https://outlook.office.com/mail/');
}
// ── Disconnect ────────────────────────────────────────────────────────────
public function disconnect(): RedirectResponse
{
$user = auth()->user();
if (! $user) {
abort(403, 'Unauthorized');
}
$activeServices = app(\App\Services\UserAppAccessService::class)->getActiveServices($user);
$hasAzureFdAccess = $activeServices->contains(fn ($service) => 'azure-fd' === $service->provider_slug);
if (! $hasAzureFdAccess) {
abort(403, 'You do not have permission to access the required application.');
}
$user->oauthToken(self::PROVIDER)?->delete();
return redirect()->route('dashboard.user')->with('entra_success', 'Microsoft disconnected.');
}
// ── Internal: Refresh access token using refresh_token ───────────────────
private function refreshToken(OauthToken $token): bool
{
if (! $token->refresh_token) {
return false;
}
$response = Http::asForm()->post(
"https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/token",
[
'client_id' => $this->clientId,
'client_secret' => $this->clientSecret,
'refresh_token' => $token->refresh_token,
'grant_type' => 'refresh_token',
'scope' => self::SCOPES,
]
);
if ($response->failed()) {
return false;
}
$data = $response->json();
$token->update([
'access_token' => $data['access_token'],
'refresh_token' => $data['refresh_token'] ?? $token->refresh_token,
'expires_at' => now()->addSeconds($data['expires_in'] ?? 3600),
]);
return true;
}
}