feat: harden identity and recovery operations

This commit is contained in:
2026-08-04 20:24:18 +01:00
parent 7a4ac48aaf
commit eae2a44f46
30 changed files with 1428 additions and 21 deletions

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