feat: harden identity and recovery operations
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Identity\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class MicrosoftAuthenticationException extends RuntimeException {}
|
||||
45
app/Domain/Identity/Models/ExternalIdentity.php
Normal file
45
app/Domain/Identity/Models/ExternalIdentity.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Identity\Models;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $user_id
|
||||
* @property string $provider
|
||||
* @property string $provider_subject
|
||||
* @property string|null $tenant_id
|
||||
* @property string $provider_email
|
||||
* @property array<string, mixed>|null $claims
|
||||
* @property Carbon|null $last_login_at
|
||||
*/
|
||||
class ExternalIdentity extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'provider',
|
||||
'provider_subject',
|
||||
'tenant_id',
|
||||
'provider_email',
|
||||
'claims',
|
||||
'last_login_at',
|
||||
];
|
||||
|
||||
/** @return BelongsTo<User, $this> */
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'claims' => 'array',
|
||||
'last_login_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
}
|
||||
165
app/Domain/Identity/Services/MicrosoftIdentityService.php
Normal file
165
app/Domain/Identity/Services/MicrosoftIdentityService.php
Normal file
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Identity\Services;
|
||||
|
||||
use App\Domain\Audit\Models\AuditLog;
|
||||
use App\Domain\Identity\Exceptions\MicrosoftAuthenticationException;
|
||||
use App\Domain\Identity\Models\ExternalIdentity;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class MicrosoftIdentityService
|
||||
{
|
||||
/** @param array{sub:string,oid:?string,tid:string,email:string,name:string,preferred_username:?string} $claims */
|
||||
public function link(User $user, array $claims, Request $request): ExternalIdentity
|
||||
{
|
||||
if (! $user->active) {
|
||||
throw new MicrosoftAuthenticationException('A conta local está desativada.');
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($user, $claims, $request): ExternalIdentity {
|
||||
$subjectIdentity = ExternalIdentity::query()
|
||||
->where('provider', 'microsoft')
|
||||
->where('provider_subject', $claims['sub'])
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if ($subjectIdentity !== null && $subjectIdentity->user_id !== $user->id) {
|
||||
throw new MicrosoftAuthenticationException('Esta identidade Microsoft já está associada a outra conta.');
|
||||
}
|
||||
|
||||
$userIdentity = ExternalIdentity::query()
|
||||
->where('provider', 'microsoft')
|
||||
->where('user_id', $user->id)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if ($userIdentity !== null && $userIdentity->provider_subject !== $claims['sub']) {
|
||||
throw new MicrosoftAuthenticationException('A conta local já tem outra identidade Microsoft associada.');
|
||||
}
|
||||
|
||||
$identity = $subjectIdentity ?? $userIdentity ?? new ExternalIdentity([
|
||||
'provider' => 'microsoft',
|
||||
'provider_subject' => $claims['sub'],
|
||||
]);
|
||||
$identity->fill([
|
||||
'user_id' => $user->id,
|
||||
'tenant_id' => $claims['tid'],
|
||||
'provider_email' => $claims['email'],
|
||||
'claims' => $this->storedClaims($claims),
|
||||
'last_login_at' => now(),
|
||||
]);
|
||||
$identity->save();
|
||||
|
||||
AuditLog::create([
|
||||
'actor_id' => $user->id,
|
||||
'event' => 'identity.microsoft_linked',
|
||||
'auditable_type' => $user->getMorphClass(),
|
||||
'auditable_id' => $user->id,
|
||||
'ip_address' => $request->ip(),
|
||||
'user_agent' => $request->userAgent(),
|
||||
'after' => [
|
||||
'provider' => 'microsoft',
|
||||
'tenant_id' => $claims['tid'],
|
||||
'provider_email' => $claims['email'],
|
||||
],
|
||||
]);
|
||||
|
||||
return $identity;
|
||||
}, 3);
|
||||
}
|
||||
|
||||
/** @param array{sub:string,oid:?string,tid:string,email:string,name:string,preferred_username:?string} $claims */
|
||||
public function userForLogin(array $claims, Request $request): User
|
||||
{
|
||||
return DB::transaction(function () use ($claims, $request): User {
|
||||
$identity = ExternalIdentity::query()
|
||||
->where('provider', 'microsoft')
|
||||
->where('provider_subject', $claims['sub'])
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if ($identity === null) {
|
||||
throw new MicrosoftAuthenticationException(
|
||||
'Esta identidade ainda não está associada. Entre com a conta local e associe-a em Segurança.',
|
||||
);
|
||||
}
|
||||
|
||||
$user = User::query()->whereKey($identity->user_id)->lockForUpdate()->first();
|
||||
if ($user === null || ! $user->active) {
|
||||
throw new MicrosoftAuthenticationException('A conta local associada está desativada.');
|
||||
}
|
||||
|
||||
if (! hash_equals(strtolower((string) $identity->tenant_id), strtolower($claims['tid']))) {
|
||||
throw new MicrosoftAuthenticationException('A identidade não pertence ao tenant institucional associado.');
|
||||
}
|
||||
|
||||
$identity->update([
|
||||
'provider_email' => $claims['email'],
|
||||
'claims' => $this->storedClaims($claims),
|
||||
'last_login_at' => now(),
|
||||
]);
|
||||
$user->update(['last_login_at' => now()]);
|
||||
|
||||
AuditLog::create([
|
||||
'actor_id' => $user->id,
|
||||
'event' => 'identity.microsoft_login',
|
||||
'auditable_type' => $user->getMorphClass(),
|
||||
'auditable_id' => $user->id,
|
||||
'ip_address' => $request->ip(),
|
||||
'user_agent' => $request->userAgent(),
|
||||
'metadata' => [
|
||||
'provider' => 'microsoft',
|
||||
'tenant_id' => $claims['tid'],
|
||||
],
|
||||
]);
|
||||
|
||||
return $user;
|
||||
}, 3);
|
||||
}
|
||||
|
||||
public function unlink(User $user, Request $request): void
|
||||
{
|
||||
DB::transaction(function () use ($user, $request): void {
|
||||
$identity = ExternalIdentity::query()
|
||||
->where('provider', 'microsoft')
|
||||
->where('user_id', $user->id)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if ($identity === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$before = [
|
||||
'provider' => 'microsoft',
|
||||
'tenant_id' => $identity->tenant_id,
|
||||
'provider_email' => $identity->provider_email,
|
||||
];
|
||||
$identity->delete();
|
||||
|
||||
AuditLog::create([
|
||||
'actor_id' => $user->id,
|
||||
'event' => 'identity.microsoft_unlinked',
|
||||
'auditable_type' => $user->getMorphClass(),
|
||||
'auditable_id' => $user->id,
|
||||
'ip_address' => $request->ip(),
|
||||
'user_agent' => $request->userAgent(),
|
||||
'before' => $before,
|
||||
]);
|
||||
}, 3);
|
||||
}
|
||||
|
||||
/** @param array{sub:string,oid:?string,tid:string,email:string,name:string,preferred_username:?string} $claims
|
||||
* @return array<string, string|null>
|
||||
*/
|
||||
private function storedClaims(array $claims): array
|
||||
{
|
||||
return [
|
||||
'oid' => $claims['oid'],
|
||||
'name' => $claims['name'],
|
||||
'preferred_username' => $claims['preferred_username'],
|
||||
];
|
||||
}
|
||||
}
|
||||
96
app/Http/Controllers/Auth/MicrosoftAuthController.php
Normal file
96
app/Http/Controllers/Auth/MicrosoftAuthController.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Domain\Identity\Exceptions\MicrosoftAuthenticationException;
|
||||
use App\Domain\Identity\Services\MicrosoftIdentityService;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Integrations\Microsoft\MicrosoftOidcClient;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
final class MicrosoftAuthController extends Controller
|
||||
{
|
||||
public function redirect(Request $request, MicrosoftOidcClient $client): RedirectResponse
|
||||
{
|
||||
try {
|
||||
$authorization = $client->authorizationRequest();
|
||||
} catch (MicrosoftAuthenticationException $exception) {
|
||||
return $this->failure($request, $exception->getMessage());
|
||||
}
|
||||
|
||||
$request->session()->put('microsoft_oidc', [
|
||||
...$authorization['flow'],
|
||||
'intent' => 'login',
|
||||
'user_id' => null,
|
||||
]);
|
||||
|
||||
return redirect()->away($authorization['url']);
|
||||
}
|
||||
|
||||
public function callback(
|
||||
Request $request,
|
||||
MicrosoftOidcClient $client,
|
||||
MicrosoftIdentityService $identities,
|
||||
): RedirectResponse {
|
||||
$flow = $request->session()->pull('microsoft_oidc');
|
||||
|
||||
if (! is_array($flow)
|
||||
|| ! is_string($flow['state'] ?? null)
|
||||
|| ! is_string($flow['nonce'] ?? null)
|
||||
|| ! is_string($flow['code_verifier'] ?? null)
|
||||
|| ! in_array($flow['intent'] ?? null, ['login', 'link'], true)
|
||||
|| ! is_string($request->query('state'))
|
||||
|| ! hash_equals($flow['state'], $request->query('state'))
|
||||
|| ! is_int($flow['created_at'] ?? null)
|
||||
|| $flow['created_at'] < time() - 600) {
|
||||
return $this->failure($request, 'O pedido de autenticação expirou ou não é válido.');
|
||||
}
|
||||
|
||||
if ($request->filled('error')) {
|
||||
return $this->failure($request, 'A autenticação Microsoft foi cancelada ou recusada.');
|
||||
}
|
||||
|
||||
$code = $request->query('code');
|
||||
if (! is_string($code) || $code === '') {
|
||||
return $this->failure($request, 'O Microsoft Entra não devolveu o código de autenticação.');
|
||||
}
|
||||
|
||||
try {
|
||||
$claims = $client->authenticate($code, (string) $flow['code_verifier'], (string) $flow['nonce']);
|
||||
|
||||
if ($flow['intent'] === 'link') {
|
||||
$user = $request->user();
|
||||
if ($user === null || $user->id !== ($flow['user_id'] ?? null)) {
|
||||
throw new MicrosoftAuthenticationException('A sessão local mudou durante a associação da identidade.');
|
||||
}
|
||||
|
||||
$identities->link($user, $claims, $request);
|
||||
|
||||
return redirect()->route('security.edit')->with('success', 'Identidade Microsoft associada com sucesso.');
|
||||
}
|
||||
|
||||
$user = $identities->userForLogin($claims, $request);
|
||||
Auth::login($user);
|
||||
$request->session()->regenerate();
|
||||
|
||||
return redirect()->intended(route('dashboard', absolute: false));
|
||||
} catch (MicrosoftAuthenticationException $exception) {
|
||||
return $this->failure($request, $exception->getMessage());
|
||||
} catch (Throwable $exception) {
|
||||
Log::warning('Falha inesperada na autenticação Microsoft.', [
|
||||
'exception' => $exception::class,
|
||||
]);
|
||||
|
||||
return $this->failure($request, 'Não foi possível concluir a autenticação Microsoft.');
|
||||
}
|
||||
}
|
||||
|
||||
private function failure(Request $request, string $message): RedirectResponse
|
||||
{
|
||||
return redirect()->route($request->user() === null ? 'login' : 'security.edit')->with('error', $message);
|
||||
}
|
||||
}
|
||||
43
app/Http/Controllers/HealthController.php
Normal file
43
app/Http/Controllers/HealthController.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Throwable;
|
||||
|
||||
final class HealthController extends Controller
|
||||
{
|
||||
public function live(): JsonResponse
|
||||
{
|
||||
return response()
|
||||
->json(['status' => 'ok'])
|
||||
->header('Cache-Control', 'no-store');
|
||||
}
|
||||
|
||||
public function ready(): JsonResponse
|
||||
{
|
||||
try {
|
||||
DB::select('select 1');
|
||||
|
||||
$key = 'health:'.bin2hex(random_bytes(12));
|
||||
Cache::put($key, 'ok', 5);
|
||||
$cacheReady = Cache::pull($key) === 'ok';
|
||||
|
||||
if (! $cacheReady) {
|
||||
throw new \RuntimeException('Cache read/write check failed.');
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
|
||||
return response()
|
||||
->json(['status' => 'not_ready'], 503)
|
||||
->header('Cache-Control', 'no-store');
|
||||
}
|
||||
|
||||
return response()
|
||||
->json(['status' => 'ready'])
|
||||
->header('Cache-Control', 'no-store');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Settings;
|
||||
|
||||
use App\Domain\Identity\Exceptions\MicrosoftAuthenticationException;
|
||||
use App\Domain\Identity\Services\MicrosoftIdentityService;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Integrations\Microsoft\MicrosoftOidcClient;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class MicrosoftIdentityController extends Controller
|
||||
{
|
||||
public function redirect(Request $request, MicrosoftOidcClient $client): RedirectResponse
|
||||
{
|
||||
try {
|
||||
$authorization = $client->authorizationRequest();
|
||||
} catch (MicrosoftAuthenticationException $exception) {
|
||||
return back()->with('error', $exception->getMessage());
|
||||
}
|
||||
$request->session()->put('microsoft_oidc', [
|
||||
...$authorization['flow'],
|
||||
'intent' => 'link',
|
||||
'user_id' => $request->user()->id,
|
||||
]);
|
||||
|
||||
return redirect()->away($authorization['url']);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, MicrosoftIdentityService $identities): RedirectResponse
|
||||
{
|
||||
$identities->unlink($request->user(), $request);
|
||||
|
||||
return back()->with('success', 'Associação à identidade Microsoft removida.');
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Settings;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Settings\PasswordUpdateRequest;
|
||||
use App\Http\Requests\Settings\TwoFactorAuthenticationRequest;
|
||||
use App\Integrations\Microsoft\MicrosoftOidcClient;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Inertia\Inertia;
|
||||
@@ -16,8 +17,12 @@ class SecurityController extends Controller
|
||||
/**
|
||||
* Show the user's security settings page.
|
||||
*/
|
||||
public function edit(TwoFactorAuthenticationRequest $request): Response
|
||||
public function edit(TwoFactorAuthenticationRequest $request, MicrosoftOidcClient $microsoft): Response
|
||||
{
|
||||
$microsoftIdentity = $request->user()
|
||||
->externalIdentities()
|
||||
->where('provider', 'microsoft')
|
||||
->first();
|
||||
$props = [
|
||||
'canManageTwoFactor' => Features::canManageTwoFactorAuthentication(),
|
||||
'canManagePasskeys' => Features::canManagePasskeys(),
|
||||
@@ -38,6 +43,12 @@ class SecurityController extends Controller
|
||||
->all()
|
||||
: [],
|
||||
'passwordRules' => Password::defaults()->toPasswordRulesString(),
|
||||
'microsoft' => [
|
||||
'enabled' => $microsoft->configured(),
|
||||
'linked' => $microsoftIdentity !== null,
|
||||
'email' => $microsoftIdentity?->provider_email,
|
||||
'lastLoginAt' => $microsoftIdentity?->last_login_at?->toIso8601String(),
|
||||
],
|
||||
];
|
||||
|
||||
if (Features::canManageTwoFactorAuthentication()) {
|
||||
|
||||
271
app/Integrations/Microsoft/MicrosoftOidcClient.php
Normal file
271
app/Integrations/Microsoft/MicrosoftOidcClient.php
Normal file
@@ -0,0 +1,271 @@
|
||||
<?php
|
||||
|
||||
namespace App\Integrations\Microsoft;
|
||||
|
||||
use App\Domain\Identity\Exceptions\MicrosoftAuthenticationException;
|
||||
use Firebase\JWT\JWK;
|
||||
use Firebase\JWT\JWT;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use Throwable;
|
||||
|
||||
final class MicrosoftOidcClient
|
||||
{
|
||||
/** @return array{url:string,flow:array{state:string,nonce:string,code_verifier:string,created_at:int}} */
|
||||
public function authorizationRequest(): array
|
||||
{
|
||||
$this->ensureConfigured();
|
||||
$metadata = $this->metadata();
|
||||
$state = Str::random(64);
|
||||
$nonce = Str::random(64);
|
||||
$codeVerifier = Str::random(96);
|
||||
$challenge = rtrim(strtr(base64_encode(hash('sha256', $codeVerifier, true)), '+/', '-_'), '=');
|
||||
|
||||
return [
|
||||
'url' => $metadata['authorization_endpoint'].'?'.http_build_query([
|
||||
'client_id' => $this->clientId(),
|
||||
'response_type' => 'code',
|
||||
'redirect_uri' => $this->redirectUri(),
|
||||
'response_mode' => 'query',
|
||||
'scope' => 'openid profile email',
|
||||
'state' => $state,
|
||||
'nonce' => $nonce,
|
||||
'code_challenge' => $challenge,
|
||||
'code_challenge_method' => 'S256',
|
||||
], '', '&', PHP_QUERY_RFC3986),
|
||||
'flow' => [
|
||||
'state' => $state,
|
||||
'nonce' => $nonce,
|
||||
'code_verifier' => $codeVerifier,
|
||||
'created_at' => time(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array{sub:string,oid:?string,tid:string,email:string,name:string,preferred_username:?string} */
|
||||
public function authenticate(string $code, string $codeVerifier, string $nonce): array
|
||||
{
|
||||
$this->ensureConfigured();
|
||||
$metadata = $this->metadata();
|
||||
|
||||
try {
|
||||
$response = Http::asForm()
|
||||
->acceptJson()
|
||||
->timeout($this->timeout())
|
||||
->post($metadata['token_endpoint'], [
|
||||
'client_id' => $this->clientId(),
|
||||
'client_secret' => $this->clientSecret(),
|
||||
'grant_type' => 'authorization_code',
|
||||
'code' => $code,
|
||||
'redirect_uri' => $this->redirectUri(),
|
||||
'code_verifier' => $codeVerifier,
|
||||
'scope' => 'openid profile email',
|
||||
]);
|
||||
} catch (ConnectionException) {
|
||||
throw new MicrosoftAuthenticationException('O serviço Microsoft Entra não respondeu a tempo.');
|
||||
}
|
||||
|
||||
$idToken = $response->successful() ? $response->json('id_token') : null;
|
||||
if (! is_string($idToken) || $idToken === '') {
|
||||
throw new MicrosoftAuthenticationException('O Microsoft Entra não devolveu uma identidade válida.');
|
||||
}
|
||||
|
||||
return $this->validateIdToken($idToken, $nonce, $metadata);
|
||||
}
|
||||
|
||||
public function configured(): bool
|
||||
{
|
||||
return (bool) config('services.microsoft.enabled')
|
||||
&& preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', $this->tenantId()) === 1
|
||||
&& $this->clientId() !== ''
|
||||
&& $this->clientSecret() !== ''
|
||||
&& filter_var($this->redirectUri(), FILTER_VALIDATE_URL) !== false
|
||||
&& (! app()->isProduction() || parse_url($this->redirectUri(), PHP_URL_SCHEME) === 'https');
|
||||
}
|
||||
|
||||
/** @param array{issuer:string,jwks_uri:string,authorization_endpoint:string,token_endpoint:string} $metadata
|
||||
* @return array{sub:string,oid:?string,tid:string,email:string,name:string,preferred_username:?string}
|
||||
*/
|
||||
private function validateIdToken(string $idToken, string $nonce, array $metadata): array
|
||||
{
|
||||
try {
|
||||
$claims = $this->decode($idToken, $this->jwks($metadata['jwks_uri']));
|
||||
} catch (Throwable) {
|
||||
Cache::forget($this->jwksCacheKey());
|
||||
|
||||
try {
|
||||
$claims = $this->decode($idToken, $this->jwks($metadata['jwks_uri']));
|
||||
} catch (Throwable) {
|
||||
throw new MicrosoftAuthenticationException('Não foi possível validar a assinatura da identidade Microsoft.');
|
||||
}
|
||||
}
|
||||
|
||||
$expected = [
|
||||
'iss' => [$metadata['issuer'], false],
|
||||
'aud' => [$this->clientId(), false],
|
||||
'tid' => [strtolower($this->tenantId()), true],
|
||||
'nonce' => [$nonce, false],
|
||||
];
|
||||
foreach ($expected as $claim => [$value, $caseInsensitive]) {
|
||||
$actual = $claims[$claim] ?? null;
|
||||
if (! is_string($actual)
|
||||
|| ! hash_equals(
|
||||
$caseInsensitive ? strtolower($value) : $value,
|
||||
$caseInsensitive ? strtolower($actual) : $actual,
|
||||
)) {
|
||||
throw new MicrosoftAuthenticationException('A identidade Microsoft não corresponde à configuração institucional.');
|
||||
}
|
||||
}
|
||||
|
||||
if (($claims['ver'] ?? null) !== '2.0'
|
||||
|| ! is_numeric($claims['iat'] ?? null)
|
||||
|| ! is_numeric($claims['exp'] ?? null)
|
||||
|| ! is_string($claims['sub'] ?? null)
|
||||
|| $claims['sub'] === ''
|
||||
|| strlen($claims['sub']) > 255) {
|
||||
throw new MicrosoftAuthenticationException('A identidade Microsoft não contém os identificadores obrigatórios.');
|
||||
}
|
||||
|
||||
$email = $claims['email'] ?? $claims['preferred_username'] ?? null;
|
||||
if (! is_string($email) || strlen($email) > 255 || filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
|
||||
throw new MicrosoftAuthenticationException('A conta Microsoft não disponibilizou um endereço de email válido.');
|
||||
}
|
||||
|
||||
return [
|
||||
'sub' => $claims['sub'],
|
||||
'oid' => is_string($claims['oid'] ?? null) ? $claims['oid'] : null,
|
||||
'tid' => strtolower($claims['tid']),
|
||||
'email' => Str::lower($email),
|
||||
'name' => is_string($claims['name'] ?? null) ? $claims['name'] : $email,
|
||||
'preferred_username' => is_string($claims['preferred_username'] ?? null)
|
||||
? $claims['preferred_username']
|
||||
: null,
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $jwks
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function decode(string $idToken, array $jwks): array
|
||||
{
|
||||
$keys = JWK::parseKeySet($jwks, 'RS256');
|
||||
$previousLeeway = JWT::$leeway;
|
||||
JWT::$leeway = 60;
|
||||
|
||||
try {
|
||||
return (array) JWT::decode($idToken, $keys);
|
||||
} finally {
|
||||
JWT::$leeway = $previousLeeway;
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array{issuer:string,jwks_uri:string,authorization_endpoint:string,token_endpoint:string} */
|
||||
private function metadata(): array
|
||||
{
|
||||
/** @var array<string, mixed> $metadata */
|
||||
$metadata = Cache::remember(
|
||||
$this->metadataCacheKey(),
|
||||
max(60, (int) config('services.microsoft.metadata_cache_seconds', 3600)),
|
||||
fn (): array => $this->getJson($this->authority().'/v2.0/.well-known/openid-configuration'),
|
||||
);
|
||||
|
||||
$expectedIssuer = $this->authority().'/v2.0';
|
||||
$result = [];
|
||||
foreach (['issuer', 'jwks_uri', 'authorization_endpoint', 'token_endpoint'] as $key) {
|
||||
if (! is_string($metadata[$key] ?? null) || ! $this->trustedMicrosoftUrl($metadata[$key])) {
|
||||
throw new MicrosoftAuthenticationException('A configuração OIDC do Microsoft Entra é inválida.');
|
||||
}
|
||||
$result[$key] = $metadata[$key];
|
||||
}
|
||||
|
||||
if (! hash_equals($expectedIssuer, $result['issuer'])) {
|
||||
throw new MicrosoftAuthenticationException('O emissor OIDC não corresponde ao tenant configurado.');
|
||||
}
|
||||
|
||||
/** @var array{issuer:string,jwks_uri:string,authorization_endpoint:string,token_endpoint:string} $result */
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function jwks(string $url): array
|
||||
{
|
||||
/** @var array<string, mixed> */
|
||||
return Cache::remember(
|
||||
$this->jwksCacheKey(),
|
||||
max(60, (int) config('services.microsoft.metadata_cache_seconds', 3600)),
|
||||
fn (): array => $this->getJson($url),
|
||||
);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function getJson(string $url): array
|
||||
{
|
||||
try {
|
||||
$response = Http::acceptJson()->timeout($this->timeout())->get($url);
|
||||
} catch (ConnectionException) {
|
||||
throw new MicrosoftAuthenticationException('O serviço de identidade Microsoft está indisponível.');
|
||||
}
|
||||
|
||||
$json = $response->successful() ? $response->json() : null;
|
||||
if (! is_array($json)) {
|
||||
throw new MicrosoftAuthenticationException('O serviço de identidade Microsoft devolveu uma resposta inválida.');
|
||||
}
|
||||
|
||||
return $json;
|
||||
}
|
||||
|
||||
private function ensureConfigured(): void
|
||||
{
|
||||
if (! $this->configured()) {
|
||||
throw new MicrosoftAuthenticationException('A autenticação Microsoft ainda não está configurada.');
|
||||
}
|
||||
}
|
||||
|
||||
private function trustedMicrosoftUrl(string $url): bool
|
||||
{
|
||||
return parse_url($url, PHP_URL_SCHEME) === 'https'
|
||||
&& strtolower((string) parse_url($url, PHP_URL_HOST)) === 'login.microsoftonline.com';
|
||||
}
|
||||
|
||||
private function authority(): string
|
||||
{
|
||||
return 'https://login.microsoftonline.com/'.strtolower($this->tenantId());
|
||||
}
|
||||
|
||||
private function metadataCacheKey(): string
|
||||
{
|
||||
return 'microsoft.oidc.metadata.'.hash('sha256', strtolower($this->tenantId()));
|
||||
}
|
||||
|
||||
private function jwksCacheKey(): string
|
||||
{
|
||||
return 'microsoft.oidc.jwks.'.hash('sha256', strtolower($this->tenantId()));
|
||||
}
|
||||
|
||||
private function clientId(): string
|
||||
{
|
||||
return trim((string) config('services.microsoft.client_id'));
|
||||
}
|
||||
|
||||
private function clientSecret(): string
|
||||
{
|
||||
return (string) config('services.microsoft.client_secret');
|
||||
}
|
||||
|
||||
private function tenantId(): string
|
||||
{
|
||||
return trim((string) config('services.microsoft.tenant_id'));
|
||||
}
|
||||
|
||||
private function redirectUri(): string
|
||||
{
|
||||
return trim((string) config('services.microsoft.redirect'));
|
||||
}
|
||||
|
||||
private function timeout(): int
|
||||
{
|
||||
return max(1, (int) config('services.microsoft.timeout', 10));
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use App\Domain\Identity\Enums\RoleName;
|
||||
use App\Domain\Identity\Models\ExternalIdentity;
|
||||
use App\Domain\Identity\Models\Role;
|
||||
use App\Domain\Reservations\Models\Reservation;
|
||||
use Database\Factories\UserFactory;
|
||||
@@ -58,6 +59,12 @@ class User extends Authenticatable implements PasskeyUser
|
||||
return $this->hasMany(Reservation::class, 'requester_id');
|
||||
}
|
||||
|
||||
/** @return HasMany<ExternalIdentity, $this> */
|
||||
public function externalIdentities(): HasMany
|
||||
{
|
||||
return $this->hasMany(ExternalIdentity::class);
|
||||
}
|
||||
|
||||
public function hasRole(RoleName|string $role): bool
|
||||
{
|
||||
$name = $role instanceof RoleName ? $role->value : $role;
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Providers;
|
||||
|
||||
use App\Actions\Fortify\CreateNewUser;
|
||||
use App\Actions\Fortify\ResetUserPassword;
|
||||
use App\Integrations\Microsoft\MicrosoftOidcClient;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
@@ -51,6 +52,7 @@ class FortifyServiceProvider extends ServiceProvider
|
||||
Fortify::loginView(fn (Request $request) => Inertia::render('auth/login', [
|
||||
'canResetPassword' => Features::enabled(Features::resetPasswords()),
|
||||
'status' => $request->session()->get('status'),
|
||||
'microsoftEnabled' => app(MicrosoftOidcClient::class)->configured(),
|
||||
]));
|
||||
|
||||
Fortify::resetPasswordView(fn (Request $request) => Inertia::render('auth/reset-password', [
|
||||
|
||||
Reference in New Issue
Block a user