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

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

View File

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

View File

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

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

View File

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

View File

@@ -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",

68
composer.lock generated
View File

@@ -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",

View File

@@ -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),
],
];

View File

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

View File

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

View File

@@ -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://<domínio>/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 <destino>` 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 <ficheiro.dump>` 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 | — |

View File

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

View File

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

33
ops/backup-postgres.sh Executable file
View File

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

51
ops/test-restore-postgres.sh Executable file
View File

@@ -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 <ficheiro.dump>" >&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)."

View File

@@ -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 (
<>
<Head title="Log in" />
<Head title="Entrar" />
<PasskeyVerify />
<PasskeyVerify
label="Entrar com chave de acesso"
loadingLabel="A autenticar..."
separator="Ou continuar com email"
/>
{microsoftEnabled && (
<>
<Button variant="outline" className="w-full" asChild>
<a href="/auth/microsoft/redirect">
Entrar com a conta Microsoft
</a>
</Button>
<div className="relative text-center text-sm">
<span className="relative z-10 bg-background px-2 text-muted-foreground">
ou utilize a conta local
</span>
<span className="absolute inset-x-0 top-1/2 border-t" />
</div>
</>
)}
<Form
{...store.form()}
@@ -32,7 +57,7 @@ export default function Login({ status, canResetPassword }: Props) {
<>
<div className="grid gap-6">
<div className="grid gap-2">
<Label htmlFor="email">Email address</Label>
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
@@ -41,21 +66,23 @@ export default function Login({ status, canResetPassword }: Props) {
autoFocus
tabIndex={1}
autoComplete="email"
placeholder="email@example.com"
placeholder="nome@escola.pt"
/>
<InputError message={errors.email} />
</div>
<div className="grid gap-2">
<div className="flex items-center">
<Label htmlFor="password">Password</Label>
<Label htmlFor="password">
Palavra-passe
</Label>
{canResetPassword && (
<TextLink
href={request()}
className="ml-auto text-sm"
tabIndex={5}
>
Forgot your password?
Recuperar palavra-passe
</TextLink>
)}
</div>
@@ -65,7 +92,7 @@ export default function Login({ status, canResetPassword }: Props) {
required
tabIndex={2}
autoComplete="current-password"
placeholder="Password"
placeholder="Palavra-passe"
/>
<InputError message={errors.password} />
</div>
@@ -76,7 +103,7 @@ export default function Login({ status, canResetPassword }: Props) {
name="remember"
tabIndex={3}
/>
<Label htmlFor="remember">Remember me</Label>
<Label htmlFor="remember">Manter sessão</Label>
</div>
<Button
@@ -87,7 +114,7 @@ export default function Login({ status, canResetPassword }: Props) {
data-test="login-button"
>
{processing && <Spinner />}
Log in
Entrar
</Button>
</div>

View File

@@ -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 && (
<div className="space-y-6">
<Heading
variant="small"
title="Conta institucional Microsoft"
description="Associe explicitamente a identidade institucional para poder iniciar sessão pelo Microsoft Entra."
/>
{props.microsoft.linked ? (
<div className="space-y-4 rounded-lg border p-4">
<div>
<p className="font-medium">
Identidade associada
</p>
<p className="text-sm text-muted-foreground">
{props.microsoft.email}
</p>
</div>
<Form
method="delete"
action="/settings/microsoft-identity"
>
{({ processing }) => (
<Button
type="submit"
variant="destructive"
disabled={processing}
>
Remover associação
</Button>
)}
</Form>
</div>
) : (
<Button variant="outline" asChild>
<a href="/settings/microsoft-identity/redirect">
Associar conta Microsoft
</a>
</Button>
)}
</div>
)}
</>
);
}

View File

@@ -1,5 +1,6 @@
<?php
use App\Http\Controllers\Settings\MicrosoftIdentityController;
use App\Http\Controllers\Settings\ProfileController;
use App\Http\Controllers\Settings\SecurityController;
use Illuminate\Auth\Middleware\RequirePassword;
@@ -23,6 +24,14 @@ Route::middleware(['auth', 'verified'])->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');
});

View File

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

View File

@@ -0,0 +1,15 @@
<?php
test('liveness endpoint is public and does not expose internals', function () {
$this->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');
});

View File

@@ -0,0 +1,273 @@
<?php
use App\Domain\Audit\Models\AuditLog;
use App\Domain\Identity\Models\ExternalIdentity;
use App\Models\User;
use Firebase\JWT\JWT;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
beforeEach(function () {
Cache::clear();
configureMicrosoft();
});
test('microsoft login is hidden until the integration is fully configured', function () {
config()->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<string, mixed> $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), '+/', '-_'), '=');
}