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