================================================================================ PROPOSTA DE ARQUITETURA E CÓDIGO FONTE - MICROSSERVIÇO LARAVEL 13 MULTITENANT ================================================================================ Recursos implementados: - Arquitetura Orientada a Serviços baseada em SOLID (Controllers, Services, Repositories, DTOs). - Multitenancy com banco de dados isolado por cliente (PostgreSQL + Codificação Latin-1/ISO-8859-1). - Autenticação via Laravel Sanctum customizada para a tabela legada 'configuracoes.db_usuarios'. - Suporte a Hashing Argon2id. - Remoção completa de rotas/opções de registro externo (Register). - Funcionalidade de recuperação de senha (Forgot/Reset Password). - Testes de integridade (Health Check e Ensure Tenant Database). ================================================================================ -------------------------------------------------------------------------------- 1. COMPOSER.JSON -------------------------------------------------------------------------------- { "name": "laravel/laravel", "type": "project", "description": "Microsserviço Multi-tenant Laravel 13 com Sanctum e PostgreSQL Latin-1", "keywords": ["framework", "laravel", "multitenant", "sanctum"], "license": "MIT", "require": { "php": "^8.3", "laravel/framework": "^13.0", "laravel/sanctum": "^4.0" }, "autoload": { "psr-4": { "App\\": "app/", "Database\\Factories\\": "database/factories/", "Database\\Seeders\\": "database/seeders/" } }, "minimum-stability": "stable", "prefer-stable": true } -------------------------------------------------------------------------------- 2. CONFIGURAÇÕES (config/database.php, config/hashing.php, config/sanctum.php, config/multitenancy.php) -------------------------------------------------------------------------------- --- [config/database.php] --- env('DB_CONNECTION', 'central'), 'connections' => [ 'central' => [ 'driver' => 'pgsql', 'host' => env('DB_CENTRAL_HOST', '127.0.0.1'), 'port' => env('DB_CENTRAL_PORT', '5432'), 'database' => env('DB_CENTRAL_DATABASE', 'central_db'), 'username' => env('DB_CENTRAL_USERNAME', 'postgres'), 'password' => env('DB_CENTRAL_PASSWORD', ''), 'charset' => 'latin1', 'prefix' => '', 'prefix_indexes' => true, 'search_path' => 'public', 'sslmode' => 'prefer', ], 'tenant' => [ 'driver' => 'pgsql', 'host' => env('DB_HOST', '127.0.0.1'), 'port' => env('DB_PORT', '5432'), 'database' => env('DB_DATABASE', 'forge'), 'username' => env('DB_USERNAME', 'forge'), 'password' => env('DB_PASSWORD', ''), 'charset' => 'latin1', // Forçando ISO-8859-1 (Latin-1) 'prefix' => '', 'prefix_indexes' => true, 'search_path' => 'public', 'sslmode' => 'prefer', ], ], ]; --- [config/hashing.php] --- 'argon2id', 'argon' => [ 'memory' => 65536, 'threads' => 2, 'time' => 4, ], ]; --- [config/sanctum.php] --- explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( '%s%s', 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1', Sanctum::currentApplicationUrlWithPort() ))), 'guard' => ['web'], 'expiration' => null, 'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''), 'middleware' => [ 'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class, 'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class, 'validate_csrf_tokens' => Illuminate\Foundation\Http\Middleware\ValidateCsrfTokens::class, ], ]; --- [config/multitenancy.php] --- env('DB_CENTRAL_CONNECTION', 'central'), 'tenant_connection' => env('DB_TENANT_CONNECTION', 'tenant'), 'identifier_headers' => [ 'tenant_id' => 'X-Tenant-ID', ], 'identifier_query_params' => [ 'tenant_id' => 'tenant', ], 'default_tenant_database_config' => [ 'driver' => 'pgsql', 'charset' => 'latin1', 'prefix' => '', 'prefix_indexes' => true, 'search_path' => 'public', 'sslmode' => 'prefer', ], ]; -------------------------------------------------------------------------------- 3. MODELS (app/Models/Central/Tenant.php, app/Models/Tenant/User.php) -------------------------------------------------------------------------------- --- [app/Models/Central/Tenant.php] --- senha; } } -------------------------------------------------------------------------------- 4. DTOS (app/DTOs/AuthLoginDTO.php, app/DTOs/PasswordResetDTO.php) -------------------------------------------------------------------------------- --- [app/DTOs/AuthLoginDTO.php] --- input('login'), senha: $request->input('senha') ); } } --- [app/DTOs/PasswordResetDTO.php] --- first(); } public function findByEmail(string $email): ?User { return User::where('email', $email)->first(); } public function findByToken(string $token): ?User { return User::where('datatoken', $token) ->where('dataexpira', '>', now()) ->first(); } public function updatePassword(User $user, string $novaSenhaHash): bool { $user->senha = $novaSenhaHash; $user->datatoken = null; $user->dataexpira = null; return $user->save(); } public function saveResetToken(User $user, string $token, string $expiresAt): bool { $user->datatoken = $token; $user->dataexpira = $expiresAt; return $user->save(); } } --- [app/Repositories/Contracts/TenantRepositoryInterface.php] --- where('id', $id)->first(); } } -------------------------------------------------------------------------------- 6. SERVICES E CONTRATOS -------------------------------------------------------------------------------- --- [app/Services/Contracts/AuthServiceContract.php] --- userRepository->findByLogin($dto->login); if (!$user || !Hash::check($dto->senha, $user->senha)) { throw ValidationException::withMessages([ 'login' => ['As credenciais fornecidas são inválidas.'], ]); } if ($user->usuarioativo != 1) { throw ValidationException::withMessages([ 'login' => ['Usuário inativo.'], ]); } return $user->createToken('api-token')->plainTextToken; } } --- [app/Services/Contracts/PasswordResetServiceContract.php] --- userRepository->findByEmail($email); if (!$user) { return false; } $token = Str::random(60); $expiresAt = now()->addHours(2)->toDateTimeString(); $this->userRepository->saveResetToken($user, $token, $expiresAt); return true; } public function reset(string $token, string $novaSenha): bool { $user = $this->userRepository->findByToken($token); if (!$user) { return false; } $senhaHash = Hash::make($novaSenha); return $this->userRepository->updatePassword($user, $senhaHash); } } --- [app/Services/Contracts/TenantServiceContract.php] --- tenantRepository->findById($tenantId); } } -------------------------------------------------------------------------------- 7. MIDDLEWARES (IdentifyTenant, EnsureTenantDatabase) -------------------------------------------------------------------------------- --- [app/Http/Middleware/IdentifyTenant.php] --- header('X-Tenant-ID') ?? $request->query('tenant'); if (!$tenantId) { return response()->json(['error' => 'Tenant não informado.'], 400); } $tenant = Tenant::on('central')->where('id', $tenantId)->first(); if (!$tenant) { return response()->json(['error' => 'Tenant inválido ou não encontrado.'], 404); } Config::set('database.connections.tenant', [ 'driver' => 'pgsql', 'host' => $tenant->db_host, 'port' => $tenant->db_port, 'database' => $tenant->db_database, 'username' => $tenant->db_username, 'password' => $tenant->db_password, 'charset' => 'latin1', 'prefix' => '', 'prefix_indexes' => true, 'search_path' => 'public', 'sslmode' => 'prefer', ]); DB::purge('tenant'); DB::reconnect('tenant'); return $next($request); } } --- [app/Http/Middleware/EnsureTenantDatabase.php] --- getPdo(); } catch (\Exception $e) { Log::error('Falha ao conectar no banco de dados do tenant: ' . $e->getMessage(), [ 'tenant_id' => $request->header('X-Tenant-ID') ?? $request->query('tenant'), 'url' => $request->fullUrl(), ]); return response()->json([ 'error' => 'Serviço indisponível para este cliente no momento.', 'message' => 'Não foi possível estabelecer conexão com o banco de dados do tenant.' ], 503); } return $next($request); } } -------------------------------------------------------------------------------- 8. REQUESTS E RESOURCES -------------------------------------------------------------------------------- --- [app/Http/Requests/LoginRequest.php] --- ['required', 'string'], 'senha' => ['required', 'string'], ]; } } --- [app/Http/Requests/ForgotPasswordRequest.php] --- ['required', 'email', 'max:255'], ]; } } --- [app/Http/Resources/UserResource.php] --- $this->id_usuario, 'nome' => $this->nome, 'login' => $this->login, 'email' => $this->email, 'usuarioativo' => (bool) $this->usuarioativo, 'usuext' => (bool) $this->usuext, 'administrador' => (bool) $this->administrador, 'dataexpira' => $this->dataexpira, ]; } } -------------------------------------------------------------------------------- 9. CONTROLLERS -------------------------------------------------------------------------------- --- [app/Http/Controllers/Api/V1/AuthController.php] --- authService->authenticate($dto); return response()->json([ 'message' => 'Login realizado com sucesso.', 'token' => $token, 'token_type' => 'Bearer' ], 200); } } --- [app/Http/Controllers/Api/V1/PasswordResetController.php] --- validate(['email' => 'required|email']); $this->passwordResetService->sendResetLink($request->email); return response()->json([ 'message' => 'Se o e-mail existir, as instruções foram enviadas.' ], 200); } public function reset(Request $request): JsonResponse { $request->validate([ 'token' => 'required|string', 'password' => 'required|string|min:6' ]); $success = $this->passwordResetService->reset($request->token, $request->password); if (!$success) { return response()->json(['error' => 'Token inválido ou expirado.'], 400); } return response()->json(['message' => 'Senha alterada com sucesso.'], 200); } } --- [app/Http/Controllers/Api/V1/HealthController.php] --- 'healthy', 'timestamp' => now()->toIso8601String(), 'services' => [ 'central_database' => $this->checkCentralDatabase(), ] ]; $tenantId = request()->header('X-Tenant-ID') ?? request()->query('tenant'); if ($tenantId) { $status['services']['tenant_database'] = $this->checkTenantDatabase(); } return response()->json($status, 200); } private function checkCentralDatabase(): string { try { DB::connection('central')->getPdo(); return 'connected'; } catch (\Exception $e) { return 'disconnected: ' . $e->getMessage(); } } private function checkTenantDatabase(): string { try { DB::connection('tenant')->getPdo(); return 'connected'; } catch (\Exception $e) { return 'disconnected: ' . $e->getMessage(); } } } -------------------------------------------------------------------------------- 10. CONFIGURAÇÕES DO SISTEMA (bootstrap/app.php, routes/api.php, routes/console.php) -------------------------------------------------------------------------------- --- [bootstrap/app.php] --- withRouting( web: __DIR__.'/../routes/web.php', api: __DIR__.'/../routes/api.php', commands: __DIR__.'/../routes/console.php', health: '/up', ) ->withMiddleware(function (Middleware $middleware) { $middleware->alias([ 'identify.tenant' => IdentifyTenant::class, 'ensure.tenant.db' => EnsureTenantDatabase::class, ]); }) ->withExceptions(function (Exceptions $exceptions) { // })->create(); --- [routes/api.php] --- group(function () { Route::middleware(['identify.tenant', 'ensure.tenant.db'])->group(function () { Route::post('/login', [AuthController::class, 'login']); Route::post('/password/forgot', [PasswordResetController::class, 'forgot']); Route::post('/password/reset', [PasswordResetController::class, 'reset']); }); }); --- [routes/console.php] --- all(); if ($tenants->isEmpty()) { $this->info('Nenhum tenant cadastrado.'); return; } foreach ($tenants as $tenant) { $this->info("Executando migrations para o tenant: {$tenant->id} ({$tenant->name})"); config([ 'database.connections.tenant.host' => $tenant->db_host, 'database.connections.tenant.port' => $tenant->db_port, 'database.connections.tenant.database' => $tenant->db_database, 'database.connections.tenant.username' => $tenant->db_username, 'database.connections.tenant.password' => $tenant->db_password, 'database.connections.tenant.charset' => 'latin1', ]); DB::purge('tenant'); DB::reconnect('tenant'); Artisan::call('migrate', [ '--database' => 'tenant', '--path' => 'database/tenant/migrations', '--force' => true, ]); $this->line(Artisan::output()); } $this->info('Migrações dos tenants concluídas com sucesso!'); })->purpose('Executa as migrations em todos os bancos de dados dos clientes'); -------------------------------------------------------------------------------- 11. MIGRATIONS -------------------------------------------------------------------------------- --- [database/central/migrations/2026_01_01_000000_create_tenants_table.php] --- create('tenants', function (Blueprint $table) { $table->string('id')->primary(); $table->string('name'); $table->string('domain')->unique()->nullable(); $table->string('db_database'); $table->string('db_username'); $table->string('db_password'); $table->string('db_host'); $table->integer('db_port')->default(5432); $table->timestamps(); }); } public function down(): void { Schema::connection('central')->dropIfExists('tenants'); } }; --- [database/tenant/migrations/2026_01_01_000001_create_usuarios_table.php] --- getConnection()->statement('CREATE SCHEMA IF NOT EXISTS configuracoes;'); Schema::connection('tenant')->create('configuracoes.db_usuarios', function (Blueprint $table) { $table->id('id_usuario'); $table->string('nome', 150); $table->string('login', 100)->unique(); $table->string('senha', 255); $table->smallInteger('usuarioativo')->default(1); $table->string('email', 150)->unique(); $table->smallInteger('usuext')->default(0); $table->smallInteger('administrador')->default(0); $table->timestamp('datatoken')->nullable(); $table->timestamp('dataexpira')->nullable(); }); } public function down(): void { Schema::connection('tenant')->dropIfExists('configuracoes.db_usuarios'); } }; -------------------------------------------------------------------------------- 12. .env.example -------------------------------------------------------------------------------- APP_NAME="Laravel Multitenant API" APP_ENV=local APP_KEY= APP_DEBUG=true APP_TIMEZONE=UTC APP_URL=http://localhost:8000 DB_CONNECTION=central DB_CENTRAL_HOST=127.0.0.1 DB_CENTRAL_PORT=5432 DB_CENTRAL_DATABASE=central_db DB_CENTRAL_USERNAME=postgres DB_CENTRAL_PASSWORD=secret DB_CONNECTION_TENANT=tenant DB_HOST=127.0.0.1 DB_PORT=5432 DB_DATABASE=tenant_db_placeholder DB_USERNAME=postgres DB_PASSWORD=secret SANCTUM_STATEFUL_DOMAINS=localhost:3000,127.0.0.1:8000