TenantAtlas/app/Http/Controllers/Auth/EntraController.php
ahmido c5fbcaa692 063-entra-signin (#76)
Key changes

Adds Entra OIDC redirect + callback endpoints under /auth/entra/* (token exchange only there).
Upserts tenant users keyed by (entra_tenant_id = tid, entra_object_id = oid); regenerates session; never stores tokens.
Blocks disabled / soft-deleted users with a generic error and safe logging.
Membership-based post-login routing:
0 memberships → /admin/no-access
1 membership → tenant dashboard (via Filament URL helpers)
>1 memberships → /admin/choose-tenant
Adds Filament pages:
/admin/choose-tenant (tenant selection + redirect)
/admin/no-access (tenantless-safe)
Both use simple layout to avoid tenant-required UI.
Guards / tests

Adds DbOnlyPagesDoNotMakeHttpRequestsTest to enforce DB-only render/hydration for:
/admin/login, /admin/no-access, /admin/choose-tenant
with Http::preventStrayRequests()
Adds session separation smoke coverage to ensure tenant session doesn’t access system and vice versa.
Runs: vendor/bin/sail artisan test --compact tests/Feature/Auth

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Reviewed-on: #76
2026-01-27 16:38:53 +00:00

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;
}
}