From eae2a44f469eeadfbcd942019504de196a38a7e2 Mon Sep 17 00:00:00 2001 From: joaomiranda Date: Tue, 4 Aug 2026 20:24:18 +0100 Subject: [PATCH] feat: harden identity and recovery operations --- .env.example | 3 + CHANGELOG.md | 11 +- README.md | 3 +- .../MicrosoftAuthenticationException.php | 7 + .../Identity/Models/ExternalIdentity.php | 45 +++ .../Services/MicrosoftIdentityService.php | 165 +++++++++++ .../Auth/MicrosoftAuthController.php | 96 ++++++ app/Http/Controllers/HealthController.php | 43 +++ .../Settings/MicrosoftIdentityController.php | 36 +++ .../Settings/SecurityController.php | 13 +- .../Microsoft/MicrosoftOidcClient.php | 271 +++++++++++++++++ app/Models/User.php | 7 + app/Providers/FortifyServiceProvider.php | 2 + compose.yaml | 6 + composer.json | 1 + composer.lock | 68 ++++- config/services.php | 3 + docs/ARCHITECTURE.md | 13 + docs/DECISIONS.md | 18 ++ docs/PRODUCTION_CHECKLIST.md | 79 +++++ docs/PROJECT_STATUS.md | 31 +- docs/RUNBOOK.md | 40 +++ ops/backup-postgres.sh | 33 +++ ops/test-restore-postgres.sh | 51 ++++ resources/js/pages/auth/login.tsx | 47 ++- resources/js/pages/settings/security.tsx | 49 ++++ routes/settings.php | 9 + routes/web.php | 11 + tests/Feature/HealthCheckTest.php | 15 + tests/Feature/MicrosoftAuthenticationTest.php | 273 ++++++++++++++++++ 30 files changed, 1428 insertions(+), 21 deletions(-) create mode 100644 app/Domain/Identity/Exceptions/MicrosoftAuthenticationException.php create mode 100644 app/Domain/Identity/Models/ExternalIdentity.php create mode 100644 app/Domain/Identity/Services/MicrosoftIdentityService.php create mode 100644 app/Http/Controllers/Auth/MicrosoftAuthController.php create mode 100644 app/Http/Controllers/HealthController.php create mode 100644 app/Http/Controllers/Settings/MicrosoftIdentityController.php create mode 100644 app/Integrations/Microsoft/MicrosoftOidcClient.php create mode 100644 docs/PRODUCTION_CHECKLIST.md create mode 100755 ops/backup-postgres.sh create mode 100755 ops/test-restore-postgres.sh create mode 100644 tests/Feature/HealthCheckTest.php create mode 100644 tests/Feature/MicrosoftAuthenticationTest.php diff --git a/.env.example b/.env.example index d09a5ad..c083c4a 100644 --- a/.env.example +++ b/.env.example @@ -75,7 +75,10 @@ SNIPE_IT_PAGE_SIZE=100 SNIPE_IT_RETRIES=3 SNIPE_IT_RETRY_DELAY_MS=250 +MICROSOFT_ENABLED=false MICROSOFT_CLIENT_ID= MICROSOFT_CLIENT_SECRET= MICROSOFT_TENANT_ID= MICROSOFT_REDIRECT_URI="${APP_URL}/auth/microsoft/callback" +MICROSOFT_TIMEOUT=10 +MICROSOFT_METADATA_CACHE_SECONDS=3600 diff --git a/CHANGELOG.md b/CHANGELOG.md index eed2e15..4b080d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,11 @@ Todas as alterações materiais são registadas neste ficheiro. O formato segue - Relatórios operacionais, métricas por dimensão e exportação CSV detalhada. - Consulta de auditoria filtrável e paginada para administradores e auditores. - Modelos editáveis para email e notificações internas com variáveis funcionais. +- Login Microsoft Entra/OIDC de tenant único com descoberta dinâmica, PKCE, `state`, `nonce` e validação RS256 do `id_token`. +- Associação e remoção explícitas da identidade Microsoft nas definições de segurança, com histórico de login e auditoria. +- Endpoints públicos de liveness/readiness sem divulgação de detalhes internos e health check do Nginx. +- Scripts operacionais para backup PostgreSQL custom, checksum e teste de restauro numa base temporária isolada. +- Checklist versionado de entrada em produção com evidências e critérios de go/no-go. ### Alterado @@ -59,6 +64,8 @@ Todas as alterações materiais são registadas neste ficheiro. O formato segue - Registo público desativado. - Variáveis para tokens Snipe-IT separados e credenciais Entra ID documentadas sem valores. +- A associação Microsoft exige autenticação local e confirmação recente da palavra-passe; coincidências de email nunca associam contas automaticamente. +- Tokens OIDC não são persistidos ou registados e o redirect URI tem de usar HTTPS em produção. - Rotas operacionais protegidas por Gate e verificação de perfil também no serviço de domínio; escritas externas não fazem retry automático. - Exportação CSV neutraliza células iniciadas por caracteres de fórmula e limita cada relatório a 366 dias. - Gestão administrativa impede auto-desativação e garante pelo menos um administrador ativo. @@ -66,7 +73,7 @@ Todas as alterações materiais são registadas neste ficheiro. O formato segue ### Verificado - Migrations e seeders executados em PostgreSQL 17 real. -- Suite backend: Pint em 111 ficheiros, PHPStan nível 7 em 84 ficheiros e 70 testes Pest (340 asserções). +- Suite backend: Pint em 141 ficheiros, PHPStan nível 7 em 107 ficheiros e 103 testes Pest (585 asserções; 2 testes de registo deliberadamente ignorados). - Frontend: TypeScript, ESLint, Prettier e build Vite de produção. - Stack Docker operacional e endpoint de login acessível através do Nginx. - Renderização SSR real do login validada, incluindo o conteúdo “Entrar no Portal de Reservas”. @@ -77,3 +84,5 @@ Todas as alterações materiais são registadas neste ficheiro. O formato segue - Migration da caixa interna de notificações aplicada em PostgreSQL 17. - Fluxo operacional: autorização, checkout/check-in, entrega parcial, auditoria, incidentes e bloqueios validados com API Snipe-IT simulada. - Administração: utilizadores, políticas, buffers, calendário global, relatórios, CSV, auditoria e modelos de notificação validados por testes funcionais. +- Autenticação Microsoft simulada criptograficamente: associação explícita, login, `state`, PKCE, tenant e recusa de autoassociação validados. +- Backup real criado e restaurado em PostgreSQL 17 temporário, com 12 migrations confirmadas; liveness/readiness validados através do Nginx. diff --git a/README.md b/README.md index eeca940..6666c1b 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Portal interno para consultar, requisitar, aprovar, entregar e devolver equipame ## Estado atual -O projeto concluiu as **Fases 1 a 6**: fundação técnica, integração Snipe-IT, reservas, aprovações, operações físicas e administração/relatórios. Inclui gestão de utilizadores e perfis, regras editáveis com margens garantidas em PostgreSQL, calendário global, métricas, exportação CSV, auditoria e modelos de email. A próxima entrega é o hardening e a entrada em produção. +O projeto concluiu as **Fases 1 a 6** e já inclui a primeira entrega técnica da **Fase 7**: autenticação Microsoft Entra/OIDC com associação explícita, health checks e procedimentos executáveis de backup/restauro. A configuração contra serviços institucionais, observabilidade externa e piloto permanecem tarefas de entrada em produção. Consulte sempre [docs/PROJECT_STATUS.md](docs/PROJECT_STATUS.md) antes de alterar o projeto. Esse documento contém o ponto de situação, riscos e próxima tarefa recomendada. @@ -61,6 +61,7 @@ docker compose exec app php artisan migrate:fresh --seed - [Requisitos e âmbito](docs/REQUIREMENTS.md) - [Decisões técnicas](docs/DECISIONS.md) - [Operação local](docs/RUNBOOK.md) +- [Checklist de produção](docs/PRODUCTION_CHECKLIST.md) - [Registo de alterações](CHANGELOG.md) - [Regras para agentes](AGENTS.md) diff --git a/app/Domain/Identity/Exceptions/MicrosoftAuthenticationException.php b/app/Domain/Identity/Exceptions/MicrosoftAuthenticationException.php new file mode 100644 index 0000000..de41892 --- /dev/null +++ b/app/Domain/Identity/Exceptions/MicrosoftAuthenticationException.php @@ -0,0 +1,7 @@ +|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 */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + protected function casts(): array + { + return [ + 'claims' => 'array', + 'last_login_at' => 'datetime', + ]; + } +} diff --git a/app/Domain/Identity/Services/MicrosoftIdentityService.php b/app/Domain/Identity/Services/MicrosoftIdentityService.php new file mode 100644 index 0000000..dd26fba --- /dev/null +++ b/app/Domain/Identity/Services/MicrosoftIdentityService.php @@ -0,0 +1,165 @@ +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 + */ + private function storedClaims(array $claims): array + { + return [ + 'oid' => $claims['oid'], + 'name' => $claims['name'], + 'preferred_username' => $claims['preferred_username'], + ]; + } +} diff --git a/app/Http/Controllers/Auth/MicrosoftAuthController.php b/app/Http/Controllers/Auth/MicrosoftAuthController.php new file mode 100644 index 0000000..9f15b96 --- /dev/null +++ b/app/Http/Controllers/Auth/MicrosoftAuthController.php @@ -0,0 +1,96 @@ +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); + } +} diff --git a/app/Http/Controllers/HealthController.php b/app/Http/Controllers/HealthController.php new file mode 100644 index 0000000..9e53500 --- /dev/null +++ b/app/Http/Controllers/HealthController.php @@ -0,0 +1,43 @@ +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'); + } +} diff --git a/app/Http/Controllers/Settings/MicrosoftIdentityController.php b/app/Http/Controllers/Settings/MicrosoftIdentityController.php new file mode 100644 index 0000000..0126bb2 --- /dev/null +++ b/app/Http/Controllers/Settings/MicrosoftIdentityController.php @@ -0,0 +1,36 @@ +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.'); + } +} diff --git a/app/Http/Controllers/Settings/SecurityController.php b/app/Http/Controllers/Settings/SecurityController.php index d2800f8..8238d51 100644 --- a/app/Http/Controllers/Settings/SecurityController.php +++ b/app/Http/Controllers/Settings/SecurityController.php @@ -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()) { diff --git a/app/Integrations/Microsoft/MicrosoftOidcClient.php b/app/Integrations/Microsoft/MicrosoftOidcClient.php new file mode 100644 index 0000000..6875bdb --- /dev/null +++ b/app/Integrations/Microsoft/MicrosoftOidcClient.php @@ -0,0 +1,271 @@ +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 $jwks + * @return array + */ + 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 $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 */ + private function jwks(string $url): array + { + /** @var array */ + return Cache::remember( + $this->jwksCacheKey(), + max(60, (int) config('services.microsoft.metadata_cache_seconds', 3600)), + fn (): array => $this->getJson($url), + ); + } + + /** @return array */ + 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)); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 981d7d4..1c7cefd 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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 */ + public function externalIdentities(): HasMany + { + return $this->hasMany(ExternalIdentity::class); + } + public function hasRole(RoleName|string $role): bool { $name = $role instanceof RoleName ? $role->value : $role; diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index 0215f0c..2b4af52 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -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', [ diff --git a/compose.yaml b/compose.yaml index 6f6ce77..349fe8f 100644 --- a/compose.yaml +++ b/compose.yaml @@ -39,6 +39,12 @@ services: - ./docker/nginx.conf:/etc/nginx/conf.d/default.conf:ro depends_on: - app + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1/health/ready"] + interval: 15s + timeout: 5s + retries: 3 + start_period: 20s restart: unless-stopped node: diff --git a/composer.json b/composer.json index c830a07..853a9d1 100644 --- a/composer.json +++ b/composer.json @@ -10,6 +10,7 @@ "license": "MIT", "require": { "php": "^8.3", + "firebase/php-jwt": "^7.1", "inertiajs/inertia-laravel": "^3.0", "laravel/chisel": "^0.1.0", "laravel/fortify": "^1.37.2", diff --git a/composer.lock b/composer.lock index a807098..fb71b25 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "b7da95ffef0fa06cb5811e3351ad4dc0", + "content-hash": "e4137d1b368159a614bd2a68d5492ddd", "packages": [ { "name": "bacon/bacon-qr-code", @@ -660,6 +660,72 @@ ], "time": "2025-03-06T22:45:56+00:00" }, + { + "name": "firebase/php-jwt", + "version": "v7.1.0", + "source": { + "type": "git", + "url": "https://github.com/googleapis/php-jwt.git", + "reference": "b374a5d1a4f1f67fadc2165cdb284645945e2fc0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/googleapis/php-jwt/zipball/b374a5d1a4f1f67fadc2165cdb284645945e2fc0", + "reference": "b374a5d1a4f1f67fadc2165cdb284645945e2fc0", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.4", + "phpfastcache/phpfastcache": "^9.2", + "phpseclib/phpseclib": "~3.0", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psr/cache": "^2.0||^3.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0" + }, + "suggest": { + "ext-sodium": "Support EdDSA (Ed25519) signatures", + "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present", + "phpseclib/phpseclib": "Support PS256 (RSASSA-PSS) signatures" + }, + "type": "library", + "autoload": { + "psr-4": { + "Firebase\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Neuman Vong", + "email": "neuman+pear@twilio.com", + "role": "Developer" + }, + { + "name": "Anant Narayanan", + "email": "anant@php.net", + "role": "Developer" + } + ], + "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", + "homepage": "https://github.com/googleapis/php-jwt", + "keywords": [ + "jwt", + "php" + ], + "support": { + "issues": "https://github.com/googleapis/php-jwt/issues", + "source": "https://github.com/googleapis/php-jwt/tree/v7.1.0" + }, + "time": "2026-06-11T17:54:14+00:00" + }, { "name": "fruitcake/php-cors", "version": "v1.4.0", diff --git a/config/services.php b/config/services.php index 1c4ba44..c9e7742 100644 --- a/config/services.php +++ b/config/services.php @@ -46,10 +46,13 @@ return [ ], 'microsoft' => [ + 'enabled' => (bool) env('MICROSOFT_ENABLED', false), 'client_id' => env('MICROSOFT_CLIENT_ID'), 'client_secret' => env('MICROSOFT_CLIENT_SECRET'), 'tenant_id' => env('MICROSOFT_TENANT_ID'), 'redirect' => env('MICROSOFT_REDIRECT_URI'), + 'timeout' => (int) env('MICROSOFT_TIMEOUT', 10), + 'metadata_cache_seconds' => (int) env('MICROSOFT_METADATA_CACHE_SECONDS', 3600), ], ]; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4d729c1..0c8561f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -37,6 +37,13 @@ Nginx ──► Laravel + Inertia/React - `app/Domain/Operations`: entrega, devolução, faltas, atrasos e incidentes. - `app/Domain/Audit`: logs imutáveis pela interface. - `app/Integrations/SnipeIt`: cliente HTTP, DTOs, mapper e exceções da API Snipe-IT. +- `app/Integrations/Microsoft`: cliente OIDC de tenant único, descoberta e validação de tokens. + +## Identidade institucional + +O login Microsoft usa Authorization Code Flow com OIDC e PKCE. O portal descobre `authorization_endpoint`, `token_endpoint`, emissor e `jwks_uri` no documento `.well-known` específico do tenant. O `id_token` só é aceite após validação RS256, expiração, emissor, audiência, tenant e `nonce`; `state` é consumido uma única vez e o fluxo expira ao fim de dez minutos. A rotação de chaves provoca uma atualização imediata do JWKS em cache e uma segunda validação. + +A identidade externa é associada explicitamente a uma conta local já autenticada, após confirmação recente da palavra-passe. Uma coincidência de email nunca cria nem associa contas automaticamente. O portal persiste apenas `sub`, tenant, email apresentado e um subconjunto de claims de perfil; códigos e tokens não são guardados. Utilizadores inativos continuam impedidos de entrar. O login local permanece disponível como contingência operacional. ## Consistência temporal @@ -89,3 +96,9 @@ Referências oficiais usadas para o contrato: [autenticação da API](https://sn - Dois tokens Snipe-IT com menor privilégio. - MFA obrigatório a definir para perfis privilegiados antes do piloto. - Logs de auditoria não expostos a operações de update/delete. + +## Saúde e recuperação + +`/health/live` confirma que o processo HTTP responde. `/health/ready` confirma acesso à base de dados e um ciclo de escrita/leitura no cache, devolvendo apenas o estado global e nunca detalhes de infraestrutura. O health check do Nginx usa readiness. + +Os scripts em `ops/` produzem um `pg_dump` custom com permissões restritas, validam a estrutura e permitem restaurar para uma base temporária descartável. Encriptação, retenção, cópia externa e agendamento pertencem à plataforma de produção e são acompanhados pelo checklist operacional. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 72ec4db..d028af6 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -92,3 +92,21 @@ Guardar parâmetros funcionais em `portal_settings`, mantendo defaults seguros n **Estado:** aceite Separar Gates para calendário global, relatórios e auditoria. Administradores têm acesso integral; auditores consultam relatórios/auditoria sem mutações; aprovadores e operadores consultam o calendário global. Relatórios são limitados a 366 dias, exportados por streaming e neutralizam possíveis fórmulas em CSV. Modelos de notificação são conteúdo funcional persistente, mas credenciais SMTP e tokens continuam exclusivamente no ambiente. + +## ADR-014 — Entra ID de tenant único com associação explícita + +**Data:** 2026-08-04 +**Estado:** aceite + +Implementar diretamente o Authorization Code Flow OIDC com PKCE, usando descoberta específica do tenant e `firebase/php-jwt` 7 para validação criptográfica RS256. Validar `state`, `nonce`, expiração, emissor, audiência e `tid`; nunca persistir tokens. A versão 6 da biblioteca foi rejeitada por existir um aviso de segurança ativo e não foi adicionada à lista de exceções do Composer. + +Por defeito, uma identidade Microsoft só pode ser associada a partir de uma conta local autenticada e depois de confirmação recente da palavra-passe. Não existe associação ou provisionamento automático por email. Esta escolha evita apropriação de conta por claims mutáveis ou endereços reutilizados. O login local permanece como recuperação controlada e o tenant institucional é obrigatório e expresso por GUID. + +## ADR-015 — Health checks mínimos e restauro verificável + +**Data:** 2026-08-04 +**Estado:** aceite + +Separar liveness, que prova apenas resposta do processo, de readiness, que confirma PostgreSQL e cache. As respostas públicas contêm apenas o estado agregado. O Nginx usa readiness no Docker Compose. + +Disponibilizar scripts independentes para `pg_dump` custom e restauro numa base temporária validada. Os scripts usam nomes resolvidos do contentor, restringem os identificadores aceites, geram checksum e nunca substituem a base principal. Encriptação, armazenamento externo, retenção e alertas permanecem responsabilidades explícitas da plataforma de produção. diff --git a/docs/PRODUCTION_CHECKLIST.md b/docs/PRODUCTION_CHECKLIST.md new file mode 100644 index 0000000..ddd294a --- /dev/null +++ b/docs/PRODUCTION_CHECKLIST.md @@ -0,0 +1,79 @@ +# Checklist de entrada em produção + +Este documento é o registo de prontidão. Cada item deve ter responsável, data e ligação para a evidência no sistema institucional de operação. Nunca colocar segredos, tokens ou dados pessoais neste ficheiro. + +## 1. Microsoft Entra ID + +- [ ] Registar uma aplicação de tenant único do tipo Web. +- [ ] Configurar exatamente `https:///auth/microsoft/callback` como redirect URI. +- [ ] Criar credencial com validade e rotação definidas; preferir mecanismo externo de segredos. +- [ ] Definir `MICROSOFT_ENABLED=true`, client ID, tenant GUID, secret e redirect URI fora do repositório. +- [ ] Confirmar que uma conta local consegue associar e remover a identidade em Segurança. +- [ ] Confirmar login, logout, utilizador desativado, tenant incorreto e rotação da chave de assinatura. +- [ ] Aplicar Conditional Access/MFA institucional aos perfis privilegiados. +- [ ] Documentar o procedimento de recuperação através de login local administrado. + +Referências oficiais: [Authorization Code Flow com PKCE](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow), [tokens e validação de claims](https://learn.microsoft.com/en-us/entra/identity-platform/security-tokens) e [validação de assinatura/emissor](https://learn.microsoft.com/en-us/entra/identity-platform/access-tokens). + +## 2. Snipe-IT + +- [ ] Confirmar URL e versão da instância institucional. +- [ ] Criar conta/token de leitura com menor privilégio. +- [ ] Criar conta/token operacional separado, apenas com checkout/check-in necessários. +- [ ] Executar sincronização integral e comparar amostra de etiquetas, categorias, estados e localizações. +- [ ] Exercitar checkout/check-in com equipamentos de teste e reconciliar `handover_records`. +- [ ] Confirmar resposta a indisponibilidade, timeout e falha parcial sem retries de escrita. + +## 3. Email, domínio e TLS + +- [ ] Configurar SMTP institucional e remetente autorizado. +- [ ] Validar submissão, esclarecimento, aprovação, rejeição e substituição em destinatários reais de teste. +- [ ] Publicar domínio e certificado TLS com renovação automática. +- [ ] Definir `APP_ENV=production`, `APP_DEBUG=false`, `APP_URL=https://...`, cookies seguros e proxy confiável. +- [ ] Remover exposição pública de PostgreSQL, Redis, Vite e Mailpit. + +## 4. Segredos e infraestrutura + +- [ ] Montar `APP_KEY`, credenciais PostgreSQL/Redis/SMTP, tokens Snipe-IT e Entra através do gestor externo. +- [ ] Confirmar que logs, dumps, imagens e artefactos de CI não contêm segredos. +- [ ] Construir imagem imutável, executar migrations com etapa controlada e reiniciar workers após deploy. +- [ ] Definir limites de CPU/memória, rotação de logs e política de atualização de imagens/dependências. + +## 5. Saúde, monitorização e alertas + +- [ ] Monitorizar `GET /health/live` para disponibilidade do processo. +- [ ] Usar `GET /health/ready` para retirar instâncias sem PostgreSQL/cache do balanceamento. +- [ ] Alertar para HTTP 5xx, latência, filas acumuladas, jobs falhados, scheduler parado e disco baixo. +- [ ] Alertar para sincronização Snipe-IT falhada/antiga e erros de autenticação anormais. +- [ ] Definir contacto, severidade, escalonamento e janela de manutenção. + +## 6. Backup e recuperação + +- [ ] Agendar `ops/backup-postgres.sh ` com utilizador restrito e armazenamento cifrado. +- [ ] Copiar backups e respetivos `.sha256` para uma localização externa com retenção diária/semanal/mensal. +- [ ] Monitorizar idade, tamanho e resultado de cada backup. +- [ ] Executar `ops/test-restore-postgres.sh ` pelo menos mensalmente em ambiente isolado. +- [ ] Registar RPO, RTO, responsável, duração e resultado do ensaio. +- [ ] Ensaiar recuperação da configuração e segredos por procedimento separado. + +## 7. Segurança, desempenho e piloto + +- [ ] Executar análise de dependências, análise estática e testes completos no artefacto de release. +- [ ] Testar autorização horizontal/vertical para todos os perfis e exportações. +- [ ] Testar concorrência na aprovação/reserva contra PostgreSQL real. +- [ ] Medir catálogo, calendário global, relatórios e exportações com volume representativo. +- [ ] Executar E2E com equipamentos reais, scanner, entrega parcial, incidente e reconciliação. +- [ ] Importar utilizadores/perfis iniciais e rever a matriz de aprovadores. +- [ ] Formar requisitantes, aprovadores, operadores e administradores com runbook aplicável. +- [ ] Realizar piloto limitado, registar incidentes e obter decisão formal de go/no-go. + +## Registo de evidências + +| Área | Responsável | Data | Resultado | Evidência externa | +|---|---|---|---|---| +| Entra ID | Por atribuir | — | Pendente | — | +| Snipe-IT | Por atribuir | — | Pendente | — | +| SMTP/TLS | Por atribuir | — | Pendente | — | +| Monitorização | Por atribuir | — | Pendente | — | +| Restauro | Por atribuir | — | Pendente | — | +| Piloto | Por atribuir | — | Pendente | — | diff --git a/docs/PROJECT_STATUS.md b/docs/PROJECT_STATUS.md index 0907685..ca6bb8a 100644 --- a/docs/PROJECT_STATUS.md +++ b/docs/PROJECT_STATUS.md @@ -1,7 +1,7 @@ # Estado do projeto e handoff **Atualizado em:** 4 de agosto de 2026 -**Fase:** Fases 1 a 6 concluídas; próxima entrega: Fase 7 +**Fase:** Fases 1 a 6 concluídas; Fase 7 em curso (base técnica de identidade, saúde e recuperação concluída) **Último agente:** Codex ## Resultado disponível @@ -49,11 +49,18 @@ - Exportação CSV protegida contra formula injection e limitada a períodos de 366 dias. - Consulta paginada e filtrável de `audit_logs`, apenas para administradores e auditores. - Modelos editáveis de assunto/mensagem para os seis eventos de notificação, com variáveis validadas e auditoria das alterações. +- Autenticação Microsoft Entra/OIDC de tenant único com descoberta `.well-known`, Authorization Code Flow, PKCE, `state`, `nonce` e validação criptográfica RS256. +- Associação Microsoft explícita após login local e confirmação recente da palavra-passe; nenhuma conta é associada automaticamente por email. +- Login Microsoft para identidades previamente associadas, bloqueio de contas inativas e eventos de associação/login/remoção em auditoria. +- Endpoints `/health/live` e `/health/ready`, sendo readiness dependente de PostgreSQL e cache sem divulgar detalhes internos. +- Health check Docker do Nginx sobre readiness. +- Scripts operacionais de backup PostgreSQL custom com checksum e restauro de teste numa base temporária isolada. +- Checklist versionado de produção, incluindo Entra, Snipe-IT, SMTP/TLS, segredos, monitorização, recuperação, segurança e piloto. ## Limites conhecidos - A integração está implementada e testada contra respostas simuladas; falta configurar e validar URL, versão e token da instância institucional. Até lá, o catálogo utiliza dados fictícios do seeder. -- Microsoft Entra ID ainda não está implementado; as variáveis de ambiente já estão definidas. +- Microsoft Entra ID está implementado e testado com tokens assinados simulados; falta registar/configurar a aplicação no tenant institucional e validar Conditional Access/MFA real. - As notificações estão implementadas com Mailpit em desenvolvimento; falta validar SMTP institucional antes do piloto. - O calendário atual é a vista mensal pessoal do MVP; vistas diária, semanal, global e por equipamento/categoria/localização ficam para as áreas operacional e administrativa. - O leitor operacional aceita scanners QR/código de barras que funcionem como teclado. A leitura direta pela câmara do navegador ainda não foi adicionada. @@ -64,10 +71,10 @@ Implementar a **Fase 7 — hardening e entrada em produção**: -1. implementar Microsoft Entra ID/OIDC e associação segura a contas locais; +1. configurar e validar Microsoft Entra no tenant institucional, incluindo Conditional Access/MFA; 2. validar a integração contra o Snipe-IT institucional e configurar tokens reais de menor privilégio; 3. configurar SMTP institucional, domínio, HTTPS e gestão externa de segredos; -4. adicionar health checks, monitorização, alertas, backups e teste de restauro; +4. ligar os health checks a monitorização/alertas e agendar backups cifrados externos; 5. executar testes de segurança, concorrência, desempenho e end-to-end com equipamentos reais; 6. preparar formação, dados iniciais e piloto com um grupo reduzido; 7. corrigir resultados do piloto e preparar a disponibilização geral. @@ -77,9 +84,9 @@ Implementar a **Fase 7 — hardening e entrada em produção**: Validação concluída em 4 de agosto de 2026: - `docker compose exec -T app composer test`: passou; -- Laravel Pint: 132 ficheiros sem desvios; -- PHPStan/Larastan nível 7: 100 ficheiros, zero erros; -- Pest: 92 testes passaram, 522 asserções e 2 testes de registo ignorados por a funcionalidade estar deliberadamente desativada; +- Laravel Pint: 141 ficheiros sem desvios; +- PHPStan/Larastan nível 7: 107 ficheiros, zero erros; +- Pest: 103 testes passaram, 585 asserções e 2 testes de registo ignorados por a funcionalidade estar deliberadamente desativada; - TypeScript (`tsc --noEmit`): passou; - ESLint: passou; - Prettier: passou; @@ -94,6 +101,12 @@ Validação concluída em 4 de agosto de 2026: - Vite em Docker: URL pública, HMR e CORS verificados em `localhost:5173`; corrigida a página em branco causada pela publicação anterior de `0.0.0.0:5173`; - Inertia SSR: renderização do login validada através do serviço `node`; corrigido o componente de mensagens flash que usava `usePage()` fora do contexto Inertia; - verificação visual no browser integrado: a inspeção controlada de `localhost` continua impedida por uma política administrativa temporariamente indisponível; aprovações, esclarecimentos e notificações foram validados por testes Inertia, TypeScript e builds cliente/SSR. +- testes focados de Microsoft Entra e health checks: 9 passaram, com 57 asserções; +- PHPStan/Larastan depois da integração OIDC: 107 ficheiros, zero erros; +- TypeScript, ESLint e Prettier depois das alterações de login/segurança: passaram; +- backup PostgreSQL real em formato custom criado com checksum e validado por `pg_restore --list`; +- restauro real numa base temporária: passou e confirmou 12 migrations antes da remoção automática; +- smoke tests via Nginx: `/health/live` devolveu `ok` e `/health/ready` devolveu `ready`. ## Decisões pendentes do contexto institucional @@ -106,3 +119,7 @@ Estas decisões não bloqueiam o desenvolvimento com defaults seguros, mas devem - duração máxima, antecedência e margens por categoria; - domínio institucional e método de emissão TLS; - SMTP institucional. + +## Próximo ficheiro a abrir + +Começar em `docs/PRODUCTION_CHECKLIST.md`. A próxima alteração de código pode preparar testes de concorrência/desempenho e observabilidade local; as validações Entra, Snipe-IT e SMTP só ficam concluídas quando existirem serviços institucionais concretos, sem guardar os respetivos segredos no repositório. diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md index 5c7fa37..db0a87f 100644 --- a/docs/RUNBOOK.md +++ b/docs/RUNBOOK.md @@ -19,8 +19,12 @@ docker compose exec app php artisan about docker compose exec app php artisan migrate:status docker compose exec postgres pg_isready -U reserva -d reserva docker compose exec redis redis-cli ping +curl --fail http://localhost:8000/health/live +curl --fail http://localhost:8000/health/ready ``` +`/health/live` confirma apenas resposta HTTP. `/health/ready` verifica PostgreSQL e cache e deve ser usado pelo balanceador. Ambos omitem detalhes de serviços. Se readiness falhar, consultar os logs da aplicação e testar PostgreSQL/Redis separadamente com os comandos anteriores. + ### Página em branco no desenvolvimento Se o HTML responder mas a interface React não aparecer, confirmar que `public/hot` contém `http://localhost:5173` e não `http://0.0.0.0:5173`. Depois validar os módulos: @@ -83,6 +87,30 @@ docker compose exec app php artisan schedule:list Administradores podem testar a ligação, iniciar uma execução e consultar as últimas dez sincronizações em `http://localhost:8000/administracao/snipe-it`. Uma execução falhada mantém o último catálogo válido e não desativa registos ausentes. Consultar `sync_runs`, os logs do worker e o painel antes de repetir. +## Microsoft Entra ID + +Registar uma aplicação **single tenant** do tipo Web. O redirect URI tem de corresponder exatamente ao domínio do portal e ao caminho `/auth/microsoft/callback`. Em produção é obrigatório HTTPS. + +```dotenv +MICROSOFT_ENABLED=true +MICROSOFT_CLIENT_ID= +MICROSOFT_CLIENT_SECRET= +MICROSOFT_TENANT_ID= +MICROSOFT_REDIRECT_URI=https://reservas.exemplo.pt/auth/microsoft/callback +MICROSOFT_TIMEOUT=10 +MICROSOFT_METADATA_CACHE_SECONDS=3600 +``` + +Depois de aplicar a configuração e limpar a cache (`php artisan optimize:clear`): + +1. entrar com a conta local; +2. abrir `Definições > Segurança` e confirmar novamente a palavra-passe; +3. escolher **Associar conta Microsoft** e concluir a autenticação institucional; +4. terminar sessão e validar **Entrar com a conta Microsoft** no login; +5. confirmar os eventos `identity.microsoft_linked` e `identity.microsoft_login` em auditoria. + +O portal não associa contas por email. Uma identidade não associada recebe uma instrução para entrar localmente e fazer a associação explícita. Remover a associação também exige confirmação recente da palavra-passe e cria `identity.microsoft_unlinked`. Nunca copiar códigos ou tokens para tickets/logs. + ## Entregas, devoluções e reconciliação Utilizadores com perfil `inventory_operator` ou `administrator` acedem a `http://localhost:8000/operacoes`. A fila inclui as reservas do dia e todos os processos entregues, atrasados, devolvidos ou com incidente ainda pendente. O campo inicial aceita referência, nome, email, etiqueta, ID Snipe-IT ou a saída de um leitor QR/código de barras configurado como teclado. @@ -157,3 +185,15 @@ Antes de disponibilizar: ## Backups Salvaguardar diariamente PostgreSQL e uploads. Manter retenção semanal/mensal e uma cópia fora do servidor. Segredos são salvaguardados através de procedimento separado. Executar um teste de restauro periódico e registar o resultado. + +```bash +# Cria dump custom, valida-o e acrescenta checksum SHA-256 +ops/backup-postgres.sh /caminho/cifrado/backups + +# Restaura numa base temporária isolada e elimina-a no fim +ops/test-restore-postgres.sh /caminho/cifrado/backups/reserva-AAAAMMDDTHHMMSSZ.dump +``` + +Os scripts obtêm o nome da base e utilizador diretamente do contentor PostgreSQL, recusam identificadores inesperados e nunca restauram sobre a base principal. O destino tem de estar cifrado e deve ser replicado para fora do servidor. Monitorizar a execução e a idade do último ficheiro; um dump sem ensaio de restauro não conta como recuperação validada. + +Usar [docs/PRODUCTION_CHECKLIST.md](PRODUCTION_CHECKLIST.md) para registar responsáveis e evidências antes do piloto. diff --git a/ops/backup-postgres.sh b/ops/backup-postgres.sh new file mode 100755 index 0000000..5056dc4 --- /dev/null +++ b/ops/backup-postgres.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash + +set -euo pipefail + +umask 077 + +backup_directory="${1:-storage/app/backups}" +mkdir -p "$backup_directory" + +database_name="$(docker compose exec -T postgres printenv POSTGRES_DB)" +database_user="$(docker compose exec -T postgres printenv POSTGRES_USER)" + +if [[ ! "$database_name" =~ ^[A-Za-z0-9_]+$ ]] || [[ ! "$database_user" =~ ^[A-Za-z0-9_]+$ ]]; then + echo "Nome de base de dados ou utilizador inválido." >&2 + exit 1 +fi + +timestamp="$(date -u +%Y%m%dT%H%M%SZ)" +backup_path="${backup_directory%/}/reserva-${timestamp}.dump" +temporary_path="${backup_path}.partial" + +docker compose exec -T postgres pg_dump \ + --username="$database_user" \ + --dbname="$database_name" \ + --format=custom \ + --no-owner \ + --no-privileges > "$temporary_path" + +docker compose exec -T postgres pg_restore --list < "$temporary_path" > /dev/null +mv "$temporary_path" "$backup_path" +shasum -a 256 "$backup_path" > "${backup_path}.sha256" + +echo "$backup_path" diff --git a/ops/test-restore-postgres.sh b/ops/test-restore-postgres.sh new file mode 100755 index 0000000..259fb8f --- /dev/null +++ b/ops/test-restore-postgres.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash + +set -euo pipefail + +backup_path="${1:-}" + +if [[ -z "$backup_path" ]] || [[ ! -f "$backup_path" ]]; then + echo "Utilização: $0 " >&2 + exit 1 +fi + +database_user="$(docker compose exec -T postgres printenv POSTGRES_USER)" +restore_database="reserva_restore_$(date -u +%Y%m%d%H%M%S)_$$" + +if [[ ! "$database_user" =~ ^[A-Za-z0-9_]+$ ]] || [[ ! "$restore_database" =~ ^reserva_restore_[0-9_]+$ ]]; then + echo "Identificador de restauro inválido." >&2 + exit 1 +fi + +cleanup() { + docker compose exec -T postgres dropdb \ + --username="$database_user" \ + --if-exists \ + "$restore_database" > /dev/null +} + +trap cleanup EXIT + +docker compose exec -T postgres createdb \ + --username="$database_user" \ + "$restore_database" + +docker compose exec -T postgres pg_restore \ + --username="$database_user" \ + --dbname="$restore_database" \ + --no-owner \ + --no-privileges < "$backup_path" + +migration_count="$(docker compose exec -T postgres psql \ + --username="$database_user" \ + --dbname="$restore_database" \ + --tuples-only \ + --no-align \ + --command='select count(*) from migrations')" + +if [[ ! "$migration_count" =~ ^[1-9][0-9]*$ ]]; then + echo "O restauro não contém o histórico de migrations esperado." >&2 + exit 1 +fi + +echo "Restauro validado em base temporária (${migration_count} migrations)." diff --git a/resources/js/pages/auth/login.tsx b/resources/js/pages/auth/login.tsx index bcfcb2c..4ed0e1a 100644 --- a/resources/js/pages/auth/login.tsx +++ b/resources/js/pages/auth/login.tsx @@ -14,14 +14,39 @@ import { request } from '@/routes/password'; type Props = { status?: string; canResetPassword: boolean; + microsoftEnabled: boolean; }; -export default function Login({ status, canResetPassword }: Props) { +export default function Login({ + status, + canResetPassword, + microsoftEnabled, +}: Props) { return ( <> - + - + + + {microsoftEnabled && ( + <> + +
+ + ou utilize a conta local + + +
+ + )}
- +
- + {canResetPassword && ( - Forgot your password? + Recuperar palavra-passe )}
@@ -65,7 +92,7 @@ export default function Login({ status, canResetPassword }: Props) { required tabIndex={2} autoComplete="current-password" - placeholder="Password" + placeholder="Palavra-passe" />
@@ -76,7 +103,7 @@ export default function Login({ status, canResetPassword }: Props) { name="remember" tabIndex={3} /> - +
diff --git a/resources/js/pages/settings/security.tsx b/resources/js/pages/settings/security.tsx index 1b8340e..48d26d3 100644 --- a/resources/js/pages/settings/security.tsx +++ b/resources/js/pages/settings/security.tsx @@ -14,6 +14,12 @@ import { edit } from '@/routes/security'; type Props = { passwordRules: string; + microsoft: { + enabled: boolean; + linked: boolean; + email: string | null; + lastLoginAt: string | null; + }; } & ManagePasskeysProps & ManageTwoFactorProps; @@ -133,6 +139,49 @@ export default function Security(props: Props) { canManagePasskeys={props.canManagePasskeys} passkeys={props.passkeys} /> + + {props.microsoft.enabled && ( +
+ + + {props.microsoft.linked ? ( +
+
+

+ Identidade associada +

+

+ {props.microsoft.email} +

+
+ + {({ processing }) => ( + + )} + +
+ ) : ( + + )} +
+ )} ); } diff --git a/routes/settings.php b/routes/settings.php index d74f967..c0faef7 100644 --- a/routes/settings.php +++ b/routes/settings.php @@ -1,5 +1,6 @@ group(function () { ->middleware('throttle:6,1') ->name('user-password.update'); + Route::delete('settings/microsoft-identity', [MicrosoftIdentityController::class, 'destroy']) + ->middleware(RequirePassword::class) + ->name('identity.microsoft.destroy'); + + Route::get('settings/microsoft-identity/redirect', [MicrosoftIdentityController::class, 'redirect']) + ->middleware([RequirePassword::class, 'throttle:10,1']) + ->name('identity.microsoft.redirect'); + Route::inertia('settings/appearance', 'settings/appearance')->name('appearance.edit'); }); diff --git a/routes/web.php b/routes/web.php index 86ba416..00a2686 100644 --- a/routes/web.php +++ b/routes/web.php @@ -7,8 +7,10 @@ use App\Http\Controllers\Admin\UserController; use App\Http\Controllers\ApprovalController; use App\Http\Controllers\AssetController; use App\Http\Controllers\AuditController; +use App\Http\Controllers\Auth\MicrosoftAuthController; use App\Http\Controllers\DashboardController; use App\Http\Controllers\GlobalCalendarController; +use App\Http\Controllers\HealthController; use App\Http\Controllers\NotificationController; use App\Http\Controllers\OperationController; use App\Http\Controllers\ReportController; @@ -16,6 +18,15 @@ use App\Http\Controllers\ReservationController; use Illuminate\Support\Facades\Route; Route::get('/', fn () => redirect()->route(auth()->check() ? 'dashboard' : 'login'))->name('home'); +Route::get('health/live', [HealthController::class, 'live'])->name('health.live'); +Route::get('health/ready', [HealthController::class, 'ready'])->name('health.ready'); + +Route::middleware('throttle:10,1')->group(function () { + Route::get('auth/microsoft/redirect', [MicrosoftAuthController::class, 'redirect']) + ->middleware('guest') + ->name('auth.microsoft.redirect'); + Route::get('auth/microsoft/callback', [MicrosoftAuthController::class, 'callback'])->name('auth.microsoft.callback'); +}); Route::middleware(['auth', 'verified'])->group(function () { Route::get('dashboard', DashboardController::class)->name('dashboard'); diff --git a/tests/Feature/HealthCheckTest.php b/tests/Feature/HealthCheckTest.php new file mode 100644 index 0000000..c43f6ec --- /dev/null +++ b/tests/Feature/HealthCheckTest.php @@ -0,0 +1,15 @@ +get(route('health.live')) + ->assertOk() + ->assertHeader('Cache-Control', 'no-store, private') + ->assertExactJson(['status' => 'ok']); +}); + +test('readiness endpoint checks the database and cache', function () { + $this->get(route('health.ready')) + ->assertOk() + ->assertJson(['status' => 'ready']) + ->assertJsonMissingPath('services'); +}); diff --git a/tests/Feature/MicrosoftAuthenticationTest.php b/tests/Feature/MicrosoftAuthenticationTest.php new file mode 100644 index 0000000..e7fd995 --- /dev/null +++ b/tests/Feature/MicrosoftAuthenticationTest.php @@ -0,0 +1,273 @@ +set('services.microsoft.enabled', false); + + $this->get(route('login')) + ->assertOk() + ->assertInertia(fn ($page) => $page->where('microsoftEnabled', false)); +}); + +test('authorization uses tenant-specific oidc discovery state nonce and pkce', function () { + fakeMicrosoftMetadata(); + + $response = $this->get(route('auth.microsoft.redirect')); + + $response->assertRedirectContains('/oauth2/v2.0/authorize?'); + $query = parse_url((string) $response->headers->get('Location'), PHP_URL_QUERY); + parse_str((string) $query, $parameters); + + expect($parameters) + ->toMatchArray([ + 'client_id' => microsoftClientId(), + 'response_type' => 'code', + 'scope' => 'openid profile email', + 'code_challenge_method' => 'S256', + ]) + ->and($parameters['state'])->toHaveLength(64) + ->and($parameters['nonce'])->toHaveLength(64) + ->and($parameters['code_challenge'])->not->toBeEmpty(); + $response->assertSessionHas('microsoft_oidc.intent', 'login'); + $response->assertSessionHas('microsoft_oidc.state', $parameters['state']); +}); + +test('an authenticated user can explicitly link a microsoft identity', function () { + $user = User::factory()->create(); + $flow = microsoftFlow(); + fakeMicrosoftAuthentication($flow, ['email' => $user->email]); + + $response = $this->actingAs($user) + ->withSession([ + 'auth.password_confirmed_at' => time(), + 'microsoft_oidc' => [...$flow, 'intent' => 'link', 'user_id' => $user->id], + ]) + ->get(route('auth.microsoft.callback', ['state' => $flow['state'], 'code' => 'valid-code'])); + + $response->assertRedirect(route('security.edit')); + $this->assertAuthenticatedAs($user); + $this->assertDatabaseHas('external_identities', [ + 'user_id' => $user->id, + 'provider' => 'microsoft', + 'provider_subject' => 'subject-123', + 'tenant_id' => microsoftTenantId(), + 'provider_email' => Str::lower($user->email), + ]); + expect(AuditLog::query()->where('event', 'identity.microsoft_linked')->count())->toBe(1); +}); + +test('starting an identity link requires recent password confirmation', function () { + fakeMicrosoftMetadata(); + $user = User::factory()->create(); + + $this->actingAs($user) + ->get(route('identity.microsoft.redirect')) + ->assertRedirect(route('password.confirm')); + + Http::assertNothingSent(); +}); + +test('a linked active user can sign in with microsoft', function () { + $user = User::factory()->create(); + ExternalIdentity::query()->create([ + 'user_id' => $user->id, + 'provider' => 'microsoft', + 'provider_subject' => 'subject-123', + 'tenant_id' => microsoftTenantId(), + 'provider_email' => $user->email, + ]); + $flow = microsoftFlow(); + fakeMicrosoftAuthentication($flow, ['email' => $user->email]); + + $response = $this->withSession([ + 'microsoft_oidc' => [...$flow, 'intent' => 'login', 'user_id' => null], + ])->get(route('auth.microsoft.callback', ['state' => $flow['state'], 'code' => 'valid-code'])); + + $response->assertRedirect(route('dashboard', absolute: false)); + $this->assertAuthenticatedAs($user); + expect($user->fresh()->last_login_at)->not->toBeNull() + ->and(AuditLog::query()->where('event', 'identity.microsoft_login')->count())->toBe(1); +}); + +test('a linked inactive user cannot sign in with microsoft', function () { + $user = User::factory()->create(['active' => false]); + ExternalIdentity::query()->create([ + 'user_id' => $user->id, + 'provider' => 'microsoft', + 'provider_subject' => 'subject-123', + 'tenant_id' => microsoftTenantId(), + 'provider_email' => $user->email, + ]); + $flow = microsoftFlow(); + fakeMicrosoftAuthentication($flow, ['email' => $user->email]); + + $this->withSession([ + 'microsoft_oidc' => [...$flow, 'intent' => 'login', 'user_id' => null], + ])->get(route('auth.microsoft.callback', ['state' => $flow['state'], 'code' => 'valid-code'])) + ->assertRedirect(route('login')); + + $this->assertGuest(); +}); + +test('email coincidence never links a microsoft identity automatically', function () { + $user = User::factory()->create(); + $flow = microsoftFlow(); + fakeMicrosoftAuthentication($flow, ['email' => $user->email]); + + $response = $this->withSession([ + 'microsoft_oidc' => [...$flow, 'intent' => 'login', 'user_id' => null], + ])->get(route('auth.microsoft.callback', ['state' => $flow['state'], 'code' => 'valid-code'])); + + $response->assertRedirect(route('login')); + $response->assertSessionHas('error'); + $this->assertGuest(); + $this->assertDatabaseCount('external_identities', 0); +}); + +test('callback rejects state mismatch before exchanging a code', function () { + Http::fake(); + $flow = microsoftFlow(); + + $response = $this->withSession([ + 'microsoft_oidc' => [...$flow, 'intent' => 'login', 'user_id' => null], + ])->get(route('auth.microsoft.callback', ['state' => 'wrong-state', 'code' => 'valid-code'])); + + $response->assertRedirect(route('login')); + $this->assertGuest(); + Http::assertNothingSent(); +}); + +test('callback rejects an id token from another tenant', function () { + $flow = microsoftFlow(); + fakeMicrosoftAuthentication($flow, [ + 'tid' => '22222222-2222-4222-8222-222222222222', + 'iss' => 'https://login.microsoftonline.com/22222222-2222-4222-8222-222222222222/v2.0', + ]); + + $response = $this->withSession([ + 'microsoft_oidc' => [...$flow, 'intent' => 'login', 'user_id' => null], + ])->get(route('auth.microsoft.callback', ['state' => $flow['state'], 'code' => 'valid-code'])); + + $response->assertRedirect(route('login')); + $this->assertGuest(); +}); + +function configureMicrosoft(): void +{ + config()->set('services.microsoft', [ + 'enabled' => true, + 'client_id' => microsoftClientId(), + 'client_secret' => 'test-client-secret', + 'tenant_id' => microsoftTenantId(), + 'redirect' => 'http://localhost/auth/microsoft/callback', + 'timeout' => 2, + 'metadata_cache_seconds' => 60, + ]); +} + +function microsoftTenantId(): string +{ + return '11111111-1111-4111-8111-111111111111'; +} + +function microsoftClientId(): string +{ + return 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; +} + +/** @return array{state:string,nonce:string,code_verifier:string,created_at:int} */ +function microsoftFlow(): array +{ + return [ + 'state' => Str::random(64), + 'nonce' => Str::random(64), + 'code_verifier' => Str::random(96), + 'created_at' => time(), + ]; +} + +function fakeMicrosoftMetadata(): void +{ + $authority = 'https://login.microsoftonline.com/'.microsoftTenantId(); + Http::fake([ + $authority.'/v2.0/.well-known/openid-configuration' => Http::response([ + 'issuer' => $authority.'/v2.0', + 'authorization_endpoint' => $authority.'/oauth2/v2.0/authorize', + 'token_endpoint' => $authority.'/oauth2/v2.0/token', + 'jwks_uri' => $authority.'/discovery/v2.0/keys', + ]), + ]); +} + +/** @param array $overrides */ +function fakeMicrosoftAuthentication(array $flow, array $overrides = []): void +{ + $authority = 'https://login.microsoftonline.com/'.microsoftTenantId(); + $key = openssl_pkey_new([ + 'private_key_bits' => 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]); + if ($key === false) { + throw new RuntimeException('Could not generate a test RSA key.'); + } + + openssl_pkey_export($key, $privateKey); + $details = openssl_pkey_get_details($key); + if (! is_array($details) || ! is_array($details['rsa'] ?? null)) { + throw new RuntimeException('Could not read the test RSA key.'); + } + + $claims = array_merge([ + 'iss' => $authority.'/v2.0', + 'aud' => microsoftClientId(), + 'iat' => time() - 10, + 'nbf' => time() - 10, + 'exp' => time() + 300, + 'nonce' => $flow['nonce'], + 'ver' => '2.0', + 'tid' => microsoftTenantId(), + 'sub' => 'subject-123', + 'oid' => 'object-123', + 'name' => 'Pessoa Institucional', + 'email' => 'pessoa@escola.pt', + 'preferred_username' => 'pessoa@escola.pt', + ], $overrides); + $idToken = JWT::encode($claims, $privateKey, 'RS256', 'test-key'); + + Http::fake([ + $authority.'/v2.0/.well-known/openid-configuration' => Http::response([ + 'issuer' => $authority.'/v2.0', + 'authorization_endpoint' => $authority.'/oauth2/v2.0/authorize', + 'token_endpoint' => $authority.'/oauth2/v2.0/token', + 'jwks_uri' => $authority.'/discovery/v2.0/keys', + ]), + $authority.'/oauth2/v2.0/token' => Http::response(['id_token' => $idToken]), + $authority.'/discovery/v2.0/keys' => Http::response([ + 'keys' => [[ + 'kty' => 'RSA', + 'use' => 'sig', + 'kid' => 'test-key', + 'alg' => 'RS256', + 'n' => base64UrlEncode($details['rsa']['n']), + 'e' => base64UrlEncode($details['rsa']['e']), + ]], + ]), + ]); +} + +function base64UrlEncode(string $value): string +{ + return rtrim(strtr(base64_encode($value), '+/', '-_'), '='); +}