312 lines
9.7 KiB
PHP
312 lines
9.7 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Http\Controllers\Auth;
|
|
|
|
use App\Models\User;
|
|
use App\Services\Auth\PostLoginRedirectResolver;
|
|
use Illuminate\Http\RedirectResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Str;
|
|
|
|
class EntraController
|
|
{
|
|
public function redirect(Request $request): RedirectResponse
|
|
{
|
|
$clientId = (string) config('services.microsoft.client_id');
|
|
$clientSecret = (string) config('services.microsoft.client_secret');
|
|
$redirectUri = (string) config('services.microsoft.redirect');
|
|
$authorityTenant = (string) config('services.microsoft.tenant', 'organizations');
|
|
|
|
if ($clientId === '' || $clientSecret === '' || $redirectUri === '') {
|
|
return $this->failRedirect($request, reasonCode: 'oidc_provider_unavailable');
|
|
}
|
|
|
|
$state = (string) Str::uuid();
|
|
$request->session()->put('entra_state', $state);
|
|
|
|
$scopes = implode(' ', ['openid', 'profile', 'email']);
|
|
|
|
$url = sprintf(
|
|
'https://login.microsoftonline.com/%s/oauth2/v2.0/authorize?%s',
|
|
$authorityTenant,
|
|
http_build_query([
|
|
'client_id' => $clientId,
|
|
'response_type' => 'code',
|
|
'redirect_uri' => $redirectUri,
|
|
'response_mode' => 'query',
|
|
'scope' => $scopes,
|
|
'state' => $state,
|
|
])
|
|
);
|
|
|
|
return redirect()->away($url);
|
|
}
|
|
|
|
public function callback(Request $request): RedirectResponse
|
|
{
|
|
$expectedState = $request->session()->pull('entra_state');
|
|
|
|
if (! is_string($expectedState) || $expectedState === '') {
|
|
return $this->failRedirect($request, reasonCode: 'oidc_invalid_state');
|
|
}
|
|
|
|
if ($expectedState !== $request->string('state')->toString()) {
|
|
return $this->failRedirect($request, reasonCode: 'oidc_invalid_state');
|
|
}
|
|
|
|
if ($request->string('error')->toString() !== '') {
|
|
return $this->failRedirect($request, reasonCode: 'oidc_user_denied');
|
|
}
|
|
|
|
$code = $request->string('code')->toString();
|
|
|
|
if ($code === '') {
|
|
return $this->failRedirect($request, reasonCode: 'oidc_provider_unavailable');
|
|
}
|
|
|
|
$clientId = (string) config('services.microsoft.client_id');
|
|
$clientSecret = (string) config('services.microsoft.client_secret');
|
|
$redirectUri = (string) config('services.microsoft.redirect');
|
|
$authorityTenant = (string) config('services.microsoft.tenant', 'organizations');
|
|
|
|
if ($clientId === '' || $clientSecret === '' || $redirectUri === '') {
|
|
return $this->failRedirect($request, reasonCode: 'oidc_provider_unavailable');
|
|
}
|
|
|
|
$response = Http::asForm()->post(
|
|
sprintf('https://login.microsoftonline.com/%s/oauth2/v2.0/token', $authorityTenant),
|
|
[
|
|
'client_id' => $clientId,
|
|
'client_secret' => $clientSecret,
|
|
'code' => $code,
|
|
'grant_type' => 'authorization_code',
|
|
'redirect_uri' => $redirectUri,
|
|
]
|
|
);
|
|
|
|
if ($response->failed()) {
|
|
return $this->failRedirect($request, reasonCode: 'oidc_provider_unavailable');
|
|
}
|
|
|
|
$payload = $response->json() ?: [];
|
|
|
|
$idToken = $payload['id_token'] ?? null;
|
|
|
|
if (! is_string($idToken) || $idToken === '') {
|
|
return $this->failRedirect($request, reasonCode: 'oidc_missing_claims');
|
|
}
|
|
|
|
$claims = $this->decodeJwtClaims($idToken);
|
|
|
|
$entraTenantId = is_string($claims['tid'] ?? null) ? (string) $claims['tid'] : '';
|
|
$entraObjectId = is_string($claims['oid'] ?? null) ? (string) $claims['oid'] : '';
|
|
|
|
if ($entraTenantId === '' || $entraObjectId === '') {
|
|
return $this->failRedirect($request, reasonCode: 'oidc_missing_claims');
|
|
}
|
|
|
|
$email = $this->resolveEmailFromClaims($claims, $entraTenantId, $entraObjectId);
|
|
$name = $this->resolveNameFromClaims($claims, $email);
|
|
|
|
try {
|
|
$existingUser = User::withTrashed()
|
|
->where('entra_tenant_id', $entraTenantId)
|
|
->where('entra_object_id', $entraObjectId)
|
|
->first();
|
|
|
|
if ($existingUser?->trashed()) {
|
|
return $this->failRedirect(
|
|
$request,
|
|
reasonCode: 'user_disabled',
|
|
entraTenantId: $entraTenantId,
|
|
entraObjectId: $entraObjectId,
|
|
userId: (int) $existingUser->getKey(),
|
|
);
|
|
}
|
|
|
|
$isNewUser = $existingUser === null;
|
|
$user = $existingUser ?? new User;
|
|
$user->fill([
|
|
'entra_tenant_id' => $entraTenantId,
|
|
'entra_object_id' => $entraObjectId,
|
|
'email' => $email,
|
|
'name' => $name,
|
|
]);
|
|
|
|
if ($isNewUser) {
|
|
$user->password = Str::password(64);
|
|
}
|
|
|
|
$user->save();
|
|
} catch (\Throwable $exception) {
|
|
return $this->failRedirect(
|
|
$request,
|
|
reasonCode: 'oidc_user_upsert_failed',
|
|
entraTenantId: $entraTenantId,
|
|
entraObjectId: $entraObjectId,
|
|
);
|
|
}
|
|
|
|
Auth::login($user);
|
|
$request->session()->regenerate();
|
|
|
|
Log::info('auth.entra.login', $this->logContext($request, success: true, entraTenantId: $entraTenantId, entraObjectId: $entraObjectId, userId: (int) $user->getKey()));
|
|
|
|
$redirectTo = app(PostLoginRedirectResolver::class)->resolve($user);
|
|
|
|
return redirect()->to($redirectTo);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function decodeJwtClaims(string $jwt): array
|
|
{
|
|
$parts = explode('.', $jwt);
|
|
|
|
if (count($parts) < 2) {
|
|
return [];
|
|
}
|
|
|
|
$payload = $this->base64UrlDecode($parts[1]);
|
|
|
|
if ($payload === null) {
|
|
return [];
|
|
}
|
|
|
|
$decoded = json_decode($payload, true);
|
|
|
|
return is_array($decoded) ? $decoded : [];
|
|
}
|
|
|
|
private function base64UrlDecode(string $value): ?string
|
|
{
|
|
$value = str_replace(['-', '_'], ['+', '/'], $value);
|
|
$padding = strlen($value) % 4;
|
|
|
|
if ($padding > 0) {
|
|
$value .= str_repeat('=', 4 - $padding);
|
|
}
|
|
|
|
$decoded = base64_decode($value, true);
|
|
|
|
return $decoded === false ? null : $decoded;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $claims
|
|
*/
|
|
private function resolveEmailFromClaims(array $claims, string $entraTenantId, string $entraObjectId): string
|
|
{
|
|
$candidate = null;
|
|
|
|
foreach (['preferred_username', 'email', 'upn'] as $key) {
|
|
$value = $claims[$key] ?? null;
|
|
|
|
if (is_string($value) && $value !== '') {
|
|
$candidate = $value;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (! is_string($candidate) || $candidate === '') {
|
|
$candidate = sprintf('%s@%s.entra.invalid', $entraObjectId, $entraTenantId);
|
|
}
|
|
|
|
$candidate = strtolower(trim($candidate));
|
|
|
|
return Str::limit($candidate, 255, '');
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $claims
|
|
*/
|
|
private function resolveNameFromClaims(array $claims, string $email): string
|
|
{
|
|
$candidate = $claims['name'] ?? null;
|
|
|
|
if (is_string($candidate) && $candidate !== '') {
|
|
return Str::limit(trim($candidate), 255, '');
|
|
}
|
|
|
|
$given = $claims['given_name'] ?? null;
|
|
$family = $claims['family_name'] ?? null;
|
|
|
|
if (is_string($given) && is_string($family)) {
|
|
$full = trim($given.' '.$family);
|
|
|
|
if ($full !== '') {
|
|
return Str::limit($full, 255, '');
|
|
}
|
|
}
|
|
|
|
return Str::limit($email, 255, '');
|
|
}
|
|
|
|
private function failRedirect(
|
|
Request $request,
|
|
string $reasonCode,
|
|
?string $entraTenantId = null,
|
|
?string $entraObjectId = null,
|
|
?int $userId = null,
|
|
): RedirectResponse {
|
|
Log::warning('auth.entra.login', $this->logContext(
|
|
$request,
|
|
success: false,
|
|
reasonCode: $reasonCode,
|
|
entraTenantId: $entraTenantId,
|
|
entraObjectId: $entraObjectId,
|
|
userId: $userId,
|
|
));
|
|
|
|
return redirect()
|
|
->to('/admin/login')
|
|
->with('error', 'Authentication failed. Please try again.');
|
|
}
|
|
|
|
/**
|
|
* @return array{success:bool,reason_code?:string,user_id?:int,entra_tenant_id?:string,entra_object_id_hash?:string,correlation_id:string,timestamp:string}
|
|
*/
|
|
private function logContext(
|
|
Request $request,
|
|
bool $success,
|
|
?string $reasonCode = null,
|
|
?string $entraTenantId = null,
|
|
?string $entraObjectId = null,
|
|
?int $userId = null,
|
|
): array {
|
|
$correlationId = $request->header('X-Request-Id')
|
|
?: ($request->hasSession() ? $request->session()->getId() : null)
|
|
?: (string) Str::uuid();
|
|
|
|
$context = [
|
|
'success' => $success,
|
|
'correlation_id' => (string) $correlationId,
|
|
'timestamp' => now()->toISOString(),
|
|
];
|
|
|
|
if ($reasonCode !== null) {
|
|
$context['reason_code'] = $reasonCode;
|
|
}
|
|
|
|
if ($userId !== null) {
|
|
$context['user_id'] = $userId;
|
|
}
|
|
|
|
if ($entraTenantId !== null) {
|
|
$context['entra_tenant_id'] = $entraTenantId;
|
|
}
|
|
|
|
if ($entraObjectId !== null) {
|
|
$context['entra_object_id_hash'] = hash('sha256', $entraObjectId);
|
|
}
|
|
|
|
return $context;
|
|
}
|
|
}
|