Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/backend-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ jobs:
- uses: ramsey/composer-install@v3
with:
working-directory: backend
- run: vendor/bin/phpstan analyse --no-progress
- run: vendor/bin/phpstan analyse --no-progress --memory-limit=512M

pest:
name: Pest
Expand Down
16 changes: 16 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,19 @@ AWS_USE_PATH_STYLE_ENDPOINT=false

# Nome exposto ao front-end via Vite (build de assets do backend, se houver).
VITE_APP_NAME="${APP_NAME}"

# Origens com permissão de CORS (separadas por vírgula). Nunca "*" fora de local.
CORS_ALLOWED_ORIGINS=http://localhost:3000

# Limites de rate limit por rota sensível — tentativas por janela de decaimento em minutos.
RATE_LIMIT_LOGIN_ATTEMPTS=5
RATE_LIMIT_LOGIN_DECAY_MINUTES=1
RATE_LIMIT_PASSWORD_RESET_ATTEMPTS=5
RATE_LIMIT_PASSWORD_RESET_DECAY_MINUTES=1
RATE_LIMIT_CHECKOUT_ATTEMPTS=10
RATE_LIMIT_CHECKOUT_DECAY_MINUTES=1
RATE_LIMIT_WEBHOOKS_ATTEMPTS=60
RATE_LIMIT_WEBHOOKS_DECAY_MINUTES=1

# DSN do Sentry. Vazio = integração desligada (padrão).
SENTRY_LARAVEL_DSN=
4 changes: 4 additions & 0 deletions backend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ Ver `docs/produto.md` na raiz do monorepo para a responsabilidade de cada um.
- Toda mutação de Model com regra de negócio por trás passa por uma `Action`, nunca `->save()`/`->update()` direto de um Controller.
- Toda entidade de edição carrega `edition_id`; usuários, perfis de palestrante e fornecedores são globais.
- Sem Filament — o admin é o Nuxt em `../front-admin`, consumindo esta API.
- Validação sempre por `FormRequest` ou `Data` object — nunca `$request->all()` em um controller. `tests/Arch/NoRawRequestAllTest.php` garante isso.
- Rota sensível (login, recuperação de senha, checkout, webhook) usa `throttle:<nome>` com um dos limiters nomeados em `App\Providers\SecurityServiceProvider` (`login`, `password-reset`, `checkout`, `webhooks`), configurável via `.env` (`RATE_LIMIT_*`).
- `Log::info/error/...` nunca precisa esconder segredo na mão — `App\Logging\RedactSensitiveDataProcessor` redige `password`/`token`/`api_key`/`secret`/`authorization`/cartão automaticamente em todo log (JSON estruturado, ver `config/logging.php`).
- Segredo nunca vai pro repositório — ver `docs/adr/0006-politica-de-segredos.md`.

## Comandos

Expand Down
45 changes: 45 additions & 0 deletions backend/app/Http/Controllers/HealthController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use Closure;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis;
use Throwable;

class HealthController extends Controller
{
public function __invoke(): JsonResponse
{
$checks = [
'database' => $this->probe(function (): bool {
DB::connection()->getPdo();

return true;
}),
'redis' => $this->probe(fn (): bool => Redis::connection()->ping() !== false),
];

$healthy = !in_array(false, $checks, true);

return response()->json([
'status' => $healthy ? 'ok' : 'degraded',
'checks' => $checks,
], $healthy ? 200 : 503);
}

/**
* @param Closure(): bool $probe
*/
private function probe(Closure $probe): bool
{
try {
return $probe();
} catch (Throwable) {
return false;
}
}
}
45 changes: 45 additions & 0 deletions backend/app/Http/Middleware/SecurityHeaders.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

declare(strict_types=1);

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class SecurityHeaders
{
/**
* @param Closure(Request): Response $next
*/
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);

$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
$response->headers->set('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=()');
$response->headers->set('Content-Security-Policy', $this->contentSecurityPolicy($request));

if ($request->secure()) {
$response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}

return $response;
}

private function contentSecurityPolicy(Request $request): string
{
// A UI do Scramble (/docs/*) carrega Stoplight Elements do unpkg e
// usa script/style inline — precisa de uma CSP mais frouxa que o
// resto da aplicação, que não serve HTML nenhum.
if ($request->is('docs/*')) {
return "default-src 'self'; script-src 'self' 'unsafe-inline' https://unpkg.com; "
."style-src 'self' 'unsafe-inline' https://unpkg.com; img-src 'self' data: https:; "
."connect-src 'self'; frame-ancestors 'none'";
}

return "default-src 'self'; frame-ancestors 'none'; base-uri 'self'";
}
}
33 changes: 33 additions & 0 deletions backend/app/Logging/JsonFormatterTap.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace App\Logging;

use Illuminate\Log\Logger;
use Monolog\Formatter\JsonFormatter;
use Monolog\Handler\FormattableHandlerInterface;
use Monolog\Handler\ProcessableHandlerInterface;
use Monolog\Logger as MonologLogger;

class JsonFormatterTap
{
public function __invoke(Logger $logger): void
{
$monolog = $logger->getLogger();

if (!$monolog instanceof MonologLogger) {
return;
}

foreach ($monolog->getHandlers() as $handler) {
if ($handler instanceof FormattableHandlerInterface) {
$handler->setFormatter(new JsonFormatter);
}

if ($handler instanceof ProcessableHandlerInterface) {
$handler->pushProcessor(new RedactSensitiveDataProcessor);
}
}
}
}
41 changes: 41 additions & 0 deletions backend/app/Logging/RedactSensitiveDataProcessor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

declare(strict_types=1);

namespace App\Logging;

use Monolog\LogRecord;
use Monolog\Processor\ProcessorInterface;

class RedactSensitiveDataProcessor implements ProcessorInterface
{
private const array SENSITIVE_KEYS = [
'password', 'password_confirmation', 'current_password',
'token', 'access_token', 'refresh_token', 'api_key', 'secret',
'authorization', 'card_number', 'cvv', 'cvc',
];

public function __invoke(LogRecord $record): LogRecord
{
return $record->with(context: $this->redact($record->context));
}

/**
* @param array<array-key, mixed> $data
* @return array<array-key, mixed>
*/
private function redact(array $data): array
{
$redacted = [];

foreach ($data as $key => $value) {
$redacted[$key] = match (true) {
is_string($key) && in_array(mb_strtolower($key), self::SENSITIVE_KEYS, true) => '[REDACTED]',
is_array($value) => $this->redact($value),
default => $value,
};
}

return $redacted;
}
}
43 changes: 43 additions & 0 deletions backend/app/Providers/SecurityServiceProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

declare(strict_types=1);

namespace App\Providers;

use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;

class SecurityServiceProvider extends ServiceProvider
{
public function register(): void {}

public function boot(): void
{
$this->registerRateLimiters();
}

private function registerRateLimiters(): void
{
$this->registerLimiter('login', fn (Request $request): string => $request->ip().'|'.$request->input('email'));
$this->registerLimiter('password-reset', fn (Request $request): string => $request->ip().'|'.$request->input('email'));
$this->registerLimiter('checkout', fn (Request $request): string => (string) $request->ip());
$this->registerLimiter('webhooks', fn (Request $request): string => (string) $request->ip());
}

/**
* @param callable(Request): string $signatureFor
*/
private function registerLimiter(string $name, callable $signatureFor): void
{
$key = str_replace('-', '_', $name);
$config = config("security.rate_limits.{$key}");

RateLimiter::for(
$name,
fn (Request $request) => Limit::perMinutes($config['decay_minutes'], $config['attempts'])
->by($signatureFor($request)),
);
}
}
4 changes: 3 additions & 1 deletion backend/bootstrap/app.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use App\Http\Middleware\AssignRequestId;
use App\Http\Middleware\EnsureIdempotencyKey;
use App\Http\Middleware\SecurityHeaders;
use App\Support\Api\ApiError;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
Expand All @@ -15,13 +16,14 @@
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
then: function (): void {
require __DIR__.'/../routes/api.php';
require __DIR__.'/../routes/health.php';
},
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->append(AssignRequestId::class);
$middleware->append(SecurityHeaders::class);

$middleware->alias([
'idempotent' => EnsureIdempotencyKey::class,
Expand Down
2 changes: 2 additions & 0 deletions backend/bootstrap/providers.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@

use App\Providers\AppServiceProvider;
use App\Providers\HorizonServiceProvider;
use App\Providers\SecurityServiceProvider;

return [
AppServiceProvider::class,
HorizonServiceProvider::class,
SecurityServiceProvider::class,
];
5 changes: 3 additions & 2 deletions backend/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@
"phpeste/suppliers": "^1.0.0",
"phpeste/tasks": "^1.0.0",
"phpeste/ticketing": "^1.0.0",
"predis/predis": "^3.0"
"predis/predis": "^3.0",
"sentry/sentry-laravel": "^4.29"
},
"require-dev": {
"driftingly/rector-laravel": "^2.6",
Expand Down Expand Up @@ -81,7 +82,7 @@
"./vendor/bin/pint"
],
"phpstan": [
"./vendor/bin/phpstan analyse"
"./vendor/bin/phpstan analyse --memory-limit=512M"
],
"rector": [
"./vendor/bin/rector process --dry-run"
Expand Down
Loading
Loading