248 lines
8.4 KiB
PHP
248 lines
8.4 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
use App\Models\OauthToken;
|
|
use App\Services\UserAppAccessService;
|
|
use Illuminate\Http\{RedirectResponse, Request};
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Str;
|
|
|
|
class EntraController extends Controller
|
|
{
|
|
private const PROVIDER = 'microsoft';
|
|
|
|
private const SCOPES = 'openid profile email offline_access User.Read Mail.Read Mail.ReadWrite Mail.Send';
|
|
|
|
private string $tenantId;
|
|
private string $clientId;
|
|
private string $clientSecret;
|
|
private string $redirectUri;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->tenantId = (string) config('services.microsoft.tenant_id');
|
|
$this->clientId = (string) config('services.microsoft.client_id');
|
|
$this->clientSecret = (string) 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');
|
|
}
|
|
|
|
if (! $this->hasAzureAccess($user)) {
|
|
abort(403, 'You do not have permission to access the required application.');
|
|
}
|
|
|
|
// Already connected and token still valid → open Outlook directly
|
|
$token = $user->oauthToken(self::PROVIDER);
|
|
|
|
if ($token && $token->expires_at->gt(now()->addMinutes(5))) {
|
|
return redirect('https://outlook.office.com/mail/');
|
|
}
|
|
|
|
// Token exists but expired → try silent refresh first
|
|
if ($token && $token->refresh_token) {
|
|
if ($this->refreshToken($token)) {
|
|
return redirect('https://outlook.office.com/mail/');
|
|
}
|
|
// Refresh failed → delete and fall through to full login
|
|
$token->delete();
|
|
}
|
|
|
|
// No token or refresh failed → full Microsoft login
|
|
$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');
|
|
}
|
|
|
|
if (! $this->hasAzureAccess($user)) {
|
|
abort(403, 'You do not have permission to access the required application.');
|
|
}
|
|
|
|
// CSRF state check
|
|
if (! hash_equals((string) session('oauth_state'), (string) $request->state)) {
|
|
session()->forget('oauth_state');
|
|
return redirect()->route('dashboard.user')
|
|
->with('entra_error', 'Invalid OAuth state. Please try again.');
|
|
}
|
|
|
|
session()->forget('oauth_state');
|
|
|
|
if ($request->has('error')) {
|
|
return redirect()->route('dashboard.user')
|
|
->with('entra_error', $request->error_description ?? 'Microsoft login failed.');
|
|
}
|
|
|
|
if (! $request->filled('code')) {
|
|
return redirect()->route('dashboard.user')
|
|
->with('entra_error', 'No authorization code received from Microsoft.');
|
|
}
|
|
|
|
// 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',
|
|
'scope' => self::SCOPES,
|
|
]
|
|
);
|
|
|
|
if ($response->failed()) {
|
|
return redirect()->route('dashboard.user')
|
|
->with('entra_error', $response->json('error_description', 'Failed to retrieve tokens from Microsoft.'));
|
|
}
|
|
|
|
$data = $response->json();
|
|
|
|
OauthToken::updateOrCreate(
|
|
[
|
|
'user_id' => $user->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((int) ($data['expires_in'] ?? 3600)),
|
|
]
|
|
);
|
|
|
|
return redirect()->route('dashboard.user')
|
|
->with('entra_success', 'Microsoft connected successfully!');
|
|
}
|
|
|
|
// ── Open Outlook (silent refresh if expired) ──────────────────────────────
|
|
|
|
public function openEntra(): RedirectResponse
|
|
{
|
|
$user = auth()->user();
|
|
|
|
if (! $user) {
|
|
abort(403, 'Unauthorized');
|
|
}
|
|
|
|
if (! $this->hasAzureAccess($user)) {
|
|
abort(403, 'You do not have permission to access the required application.');
|
|
}
|
|
|
|
$token = $user->oauthToken(self::PROVIDER);
|
|
|
|
if (! $token) {
|
|
return redirect()->route('entra.connect');
|
|
}
|
|
|
|
// Token still valid (5-min buffer)
|
|
if ($token->expires_at->gt(now()->addMinutes(5))) {
|
|
return redirect('https://outlook.office.com/mail/');
|
|
}
|
|
|
|
// Token expired — try silent refresh
|
|
if ($this->refreshToken($token)) {
|
|
return redirect('https://outlook.office.com/mail/');
|
|
}
|
|
|
|
// Refresh failed — force full re-login
|
|
$token->delete();
|
|
|
|
return redirect()->route('entra.connect');
|
|
}
|
|
|
|
// ── Disconnect ────────────────────────────────────────────────────────────
|
|
|
|
public function disconnect(): RedirectResponse
|
|
{
|
|
$user = auth()->user();
|
|
|
|
if (! $user) {
|
|
abort(403, 'Unauthorized');
|
|
}
|
|
|
|
if (! $this->hasAzureAccess($user)) {
|
|
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: silent refresh ──────────────────────────────────────────────
|
|
|
|
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((int) ($data['expires_in'] ?? 3600)),
|
|
]);
|
|
|
|
return true;
|
|
}
|
|
|
|
// ── Internal: permission check (DRY) ─────────────────────────────────────
|
|
|
|
private function hasAzureAccess(mixed $user): bool
|
|
{
|
|
$activeServices = app(UserAppAccessService::class)->getActiveServices($user);
|
|
|
|
return $activeServices->contains(fn ($service) => 'azure-fd' === $service->provider_slug);
|
|
}
|
|
} |