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,7 @@
<?php
namespace App\Domain\Identity\Exceptions;
use RuntimeException;
final class MicrosoftAuthenticationException extends RuntimeException {}

View 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',
];
}
}

View 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'],
];
}
}

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

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

View File

@@ -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.');
}
}

View File

@@ -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()) {

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

View File

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

View File

@@ -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', [