1788 lines
55 KiB
Markdown
1788 lines
55 KiB
Markdown
|
|
# SuperSeller3000 — Plan 3: Auth, ACL & Logging
|
||
|
|
|
||
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
|
|
||
|
|
**Goal:** Vollständige Auth-Infrastruktur (Browser-Login mit TOTP-2FA, API-Key-Authentifizierung), Permission-basiertes ACL, EasyAdmin-Adminpanel mit Artikel-Freigabe-Workflow, PostgreSQL-basiertes Logging mit Rotation und täglichem Backup-Command.
|
||
|
|
|
||
|
|
**Architecture:** Symfony Security mit zwei Firewalls (api / main). ApiKey-Authenticator liest `X-Api-Key`-Header, verifiziert gegen bcrypt-Hash nach Prefix-Lookup. PermissionVoter prüft User- und ApiKey-Permissions einheitlich. Monolog DatabaseLogHandler schreibt asynchron in `logs.log_entry`. EasyAdmin für Admin-UI.
|
||
|
|
|
||
|
|
**Tech Stack:** PHP 8.4, Symfony 7, scheb/2fa-bundle, scheb/2fa-totp, easycorp/easyadmin-bundle, Monolog, PHPStan Level 9
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Dateistruktur (gesamter Plan)
|
||
|
|
|
||
|
|
```
|
||
|
|
src/
|
||
|
|
Infrastructure/
|
||
|
|
Http/
|
||
|
|
Security/
|
||
|
|
ApiKeyAuthenticator.php
|
||
|
|
Controller/
|
||
|
|
SecurityController.php # Login, Logout, 2FA
|
||
|
|
TotpSetupController.php # TOTP einrichten
|
||
|
|
Admin/
|
||
|
|
DashboardController.php
|
||
|
|
ArticleCrudController.php # Freigabe-Workflow
|
||
|
|
ArticleTypeCrudController.php
|
||
|
|
PlatformCrudController.php
|
||
|
|
UserCrudController.php
|
||
|
|
AIPipelineJobCrudController.php
|
||
|
|
LogEntryCrudController.php
|
||
|
|
Logging/
|
||
|
|
DatabaseLogHandler.php
|
||
|
|
LogEntry.php # Doctrine entity → logs.log_entry
|
||
|
|
RotateLogsCommand.php
|
||
|
|
Security/
|
||
|
|
PermissionVoter.php
|
||
|
|
Command/
|
||
|
|
BackupCommand.php
|
||
|
|
config/
|
||
|
|
packages/
|
||
|
|
security.yaml
|
||
|
|
scheb_two_factor.yaml
|
||
|
|
monolog.yaml # ergänzen
|
||
|
|
routes/
|
||
|
|
security.yaml
|
||
|
|
admin.yaml
|
||
|
|
migrations/
|
||
|
|
Version20260513000003.php # ApiKey.key_prefix column
|
||
|
|
templates/
|
||
|
|
security/
|
||
|
|
login.html.twig
|
||
|
|
2fa.html.twig
|
||
|
|
totp/
|
||
|
|
setup.html.twig
|
||
|
|
tests/
|
||
|
|
Unit/
|
||
|
|
Infrastructure/
|
||
|
|
Security/
|
||
|
|
PermissionVoterTest.php
|
||
|
|
Logging/
|
||
|
|
DatabaseLogHandlerTest.php
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 1: Pakete installieren + Security-Grundkonfiguration
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `config/packages/security.yaml`
|
||
|
|
- Create: `config/routes/security.yaml`
|
||
|
|
- Create: `templates/security/login.html.twig`
|
||
|
|
- Create: `templates/security/2fa.html.twig`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Pakete installieren**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
docker compose run --rm app composer require \
|
||
|
|
scheb/2fa-bundle \
|
||
|
|
scheb/2fa-totp \
|
||
|
|
scheb/2fa-backup-code \
|
||
|
|
easycorp/easyadmin-bundle \
|
||
|
|
endroid/qr-code \
|
||
|
|
endroid/qr-code-bundle
|
||
|
|
|
||
|
|
docker compose run --rm app composer require --dev \
|
||
|
|
symfony/browser-kit \
|
||
|
|
symfony/css-selector
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: security.yaml schreiben**
|
||
|
|
|
||
|
|
```yaml
|
||
|
|
# config/packages/security.yaml
|
||
|
|
security:
|
||
|
|
password_hashers:
|
||
|
|
App\Domain\Auth\User:
|
||
|
|
algorithm: bcrypt
|
||
|
|
cost: 12
|
||
|
|
|
||
|
|
providers:
|
||
|
|
user_provider:
|
||
|
|
entity:
|
||
|
|
class: App\Domain\Auth\User
|
||
|
|
property: email
|
||
|
|
|
||
|
|
firewalls:
|
||
|
|
dev:
|
||
|
|
pattern: ^/(_(profiler|wdt)|css|images|js)/
|
||
|
|
security: false
|
||
|
|
|
||
|
|
api:
|
||
|
|
pattern: ^/api/
|
||
|
|
stateless: true
|
||
|
|
custom_authenticators:
|
||
|
|
- App\Infrastructure\Http\Security\ApiKeyAuthenticator
|
||
|
|
|
||
|
|
main:
|
||
|
|
lazy: true
|
||
|
|
provider: user_provider
|
||
|
|
form_login:
|
||
|
|
login_path: app_login
|
||
|
|
check_path: app_login
|
||
|
|
default_target_path: /admin
|
||
|
|
enable_csrf: true
|
||
|
|
logout:
|
||
|
|
path: app_logout
|
||
|
|
target: app_login
|
||
|
|
two_factor:
|
||
|
|
auth_form_path: 2fa_login
|
||
|
|
check_path: 2fa_login_check
|
||
|
|
|
||
|
|
access_control:
|
||
|
|
- { path: ^/login, roles: PUBLIC_ACCESS }
|
||
|
|
- { path: ^/2fa, roles: IS_AUTHENTICATED_2FA_IN_PROGRESS }
|
||
|
|
- { path: ^/api/, roles: IS_AUTHENTICATED_FULLY }
|
||
|
|
- { path: ^/admin, roles: ROLE_USER }
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: Scheb 2FA konfigurieren**
|
||
|
|
|
||
|
|
```yaml
|
||
|
|
# config/packages/scheb_two_factor.yaml
|
||
|
|
scheb_two_factor:
|
||
|
|
security_tokens:
|
||
|
|
- Symfony\Component\Security\Http\Authenticator\Token\PostAuthenticationToken
|
||
|
|
ip_whitelist: []
|
||
|
|
totp:
|
||
|
|
enabled: true
|
||
|
|
issuer: SuperSeller3000
|
||
|
|
digits: 6
|
||
|
|
period: 30
|
||
|
|
algorithm: sha1
|
||
|
|
backup_codes:
|
||
|
|
enabled: true
|
||
|
|
trusted_device:
|
||
|
|
enabled: false
|
||
|
|
two_factor_finish_url: /admin
|
||
|
|
login_check_path: app_login
|
||
|
|
auth_code_parameter_name: _auth_code
|
||
|
|
trusted_parameter_name: _trusted
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Route-Konfiguration**
|
||
|
|
|
||
|
|
```yaml
|
||
|
|
# config/routes/security.yaml
|
||
|
|
app_login:
|
||
|
|
path: /login
|
||
|
|
controller: App\Infrastructure\Http\Controller\SecurityController::login
|
||
|
|
|
||
|
|
app_logout:
|
||
|
|
path: /logout
|
||
|
|
methods: [POST]
|
||
|
|
|
||
|
|
2fa_login:
|
||
|
|
path: /2fa
|
||
|
|
controller: scheb_two_factor.form_renderer::renderForm
|
||
|
|
|
||
|
|
2fa_login_check:
|
||
|
|
path: /2fa_check
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 5: Login-Template**
|
||
|
|
|
||
|
|
```twig
|
||
|
|
{# templates/security/login.html.twig #}
|
||
|
|
<!DOCTYPE html>
|
||
|
|
<html lang="de">
|
||
|
|
<head>
|
||
|
|
<meta charset="UTF-8">
|
||
|
|
<title>SuperSeller3000 — Login</title>
|
||
|
|
<style>
|
||
|
|
body { font-family: system-ui, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #f5f5f7; }
|
||
|
|
.card { background: #fff; border-radius: 12px; padding: 2rem; width: 340px; box-shadow: 0 4px 24px rgba(0,0,0,.08); }
|
||
|
|
h1 { margin: 0 0 1.5rem; font-size: 1.25rem; }
|
||
|
|
input { width: 100%; box-sizing: border-box; padding: .625rem; border: 1px solid #d1d1d6; border-radius: 6px; margin-bottom: 1rem; font-size: 1rem; }
|
||
|
|
button { width: 100%; padding: .75rem; background: #0071e3; color: #fff; border: none; border-radius: 6px; font-size: 1rem; cursor: pointer; }
|
||
|
|
.error { color: #ff3b30; font-size: .85rem; margin-bottom: 1rem; }
|
||
|
|
</style>
|
||
|
|
</head>
|
||
|
|
<body>
|
||
|
|
<div class="card">
|
||
|
|
<h1>SuperSeller3000</h1>
|
||
|
|
{% if error %}
|
||
|
|
<div class="error">{{ error.messageKey|trans(error.messageData, 'security') }}</div>
|
||
|
|
{% endif %}
|
||
|
|
<form method="post" action="{{ path('app_login') }}">
|
||
|
|
<input type="email" name="_username" value="{{ last_username }}" placeholder="E-Mail" autofocus required>
|
||
|
|
<input type="password" name="_password" placeholder="Passwort" required>
|
||
|
|
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
|
||
|
|
<button type="submit">Anmelden</button>
|
||
|
|
</form>
|
||
|
|
</div>
|
||
|
|
</body>
|
||
|
|
</html>
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 6: 2FA-Template**
|
||
|
|
|
||
|
|
```twig
|
||
|
|
{# templates/security/2fa.html.twig #}
|
||
|
|
<!DOCTYPE html>
|
||
|
|
<html lang="de">
|
||
|
|
<head>
|
||
|
|
<meta charset="UTF-8">
|
||
|
|
<title>SuperSeller3000 — 2FA</title>
|
||
|
|
<style>
|
||
|
|
body { font-family: system-ui, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #f5f5f7; }
|
||
|
|
.card { background: #fff; border-radius: 12px; padding: 2rem; width: 340px; box-shadow: 0 4px 24px rgba(0,0,0,.08); }
|
||
|
|
h1 { margin: 0 0 .5rem; font-size: 1.25rem; }
|
||
|
|
p { color: #86868b; font-size: .9rem; margin: 0 0 1.5rem; }
|
||
|
|
input { width: 100%; box-sizing: border-box; padding: .625rem; border: 1px solid #d1d1d6; border-radius: 6px; margin-bottom: 1rem; font-size: 1.5rem; letter-spacing: .5rem; text-align: center; }
|
||
|
|
button { width: 100%; padding: .75rem; background: #0071e3; color: #fff; border: none; border-radius: 6px; font-size: 1rem; cursor: pointer; }
|
||
|
|
.error { color: #ff3b30; font-size: .85rem; margin-bottom: 1rem; }
|
||
|
|
</style>
|
||
|
|
</head>
|
||
|
|
<body>
|
||
|
|
<div class="card">
|
||
|
|
<h1>Zwei-Faktor-Auth</h1>
|
||
|
|
<p>Code aus deiner Authenticator-App eingeben.</p>
|
||
|
|
{% if authenticationError %}
|
||
|
|
<div class="error">{{ authenticationError.messageKey|trans(authenticationError.messageData, 'security') }}</div>
|
||
|
|
{% endif %}
|
||
|
|
<form method="post" action="{{ path('2fa_login_check') }}">
|
||
|
|
<input type="text" name="{{ twoFactorAuthCodeParameter }}" autocomplete="one-time-code" inputmode="numeric" maxlength="6" autofocus>
|
||
|
|
<button type="submit">Bestätigen</button>
|
||
|
|
</form>
|
||
|
|
</div>
|
||
|
|
</body>
|
||
|
|
</html>
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 7: SecurityController**
|
||
|
|
|
||
|
|
```php
|
||
|
|
<?php
|
||
|
|
// src/Infrastructure/Http/Controller/SecurityController.php
|
||
|
|
declare(strict_types=1);
|
||
|
|
|
||
|
|
namespace App\Infrastructure\Http\Controller;
|
||
|
|
|
||
|
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||
|
|
use Symfony\Component\HttpFoundation\Response;
|
||
|
|
use Symfony\Component\Routing\Attribute\Route;
|
||
|
|
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
|
||
|
|
|
||
|
|
final class SecurityController extends AbstractController
|
||
|
|
{
|
||
|
|
#[Route('/login', name: 'app_login', methods: ['GET', 'POST'])]
|
||
|
|
public function login(AuthenticationUtils $authUtils): Response
|
||
|
|
{
|
||
|
|
if ($this->getUser()) {
|
||
|
|
return $this->redirectToRoute('admin');
|
||
|
|
}
|
||
|
|
|
||
|
|
return $this->render('security/login.html.twig', [
|
||
|
|
'last_username' => $authUtils->getLastUsername(),
|
||
|
|
'error' => $authUtils->getLastAuthenticationError(),
|
||
|
|
]);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 8: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add config/packages/security.yaml config/packages/scheb_two_factor.yaml config/routes/security.yaml templates/security/ src/Infrastructure/Http/Controller/SecurityController.php composer.json composer.lock
|
||
|
|
git commit -m "feat: add Symfony Security, form login, 2FA config, login templates"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 2: ApiKey-Migration + Authenticator
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `migrations/Version20260513000003.php`
|
||
|
|
- Create: `src/Infrastructure/Http/Security/ApiKeyAuthenticator.php`
|
||
|
|
- Modify: `src/Domain/Auth/ApiKey.php`
|
||
|
|
|
||
|
|
- [ ] **Step 1: ApiKey-Entity um key_prefix erweitern**
|
||
|
|
|
||
|
|
Ergänze `src/Domain/Auth/ApiKey.php`:
|
||
|
|
|
||
|
|
```php
|
||
|
|
#[ORM\Column(type: 'string', length: 16)]
|
||
|
|
private string $keyPrefix;
|
||
|
|
|
||
|
|
// In __construct hinzufügen (nach $keyHash):
|
||
|
|
$this->keyPrefix = \substr($rawKey, 0, 16);
|
||
|
|
|
||
|
|
// Neuer Konstruktor-Parameter:
|
||
|
|
public function __construct(User $user, string $label, string $rawKey, string $keyHash)
|
||
|
|
{
|
||
|
|
$this->id = Uuid::v7();
|
||
|
|
$this->user = $user;
|
||
|
|
$this->label = $label;
|
||
|
|
$this->keyPrefix = \substr($rawKey, 0, 16);
|
||
|
|
$this->keyHash = $keyHash;
|
||
|
|
}
|
||
|
|
|
||
|
|
public function getKeyPrefix(): string { return $this->keyPrefix; }
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Migration schreiben**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
docker compose run --rm app php bin/console doctrine:migrations:generate
|
||
|
|
```
|
||
|
|
|
||
|
|
```php
|
||
|
|
<?php
|
||
|
|
// migrations/Version20260513000003.php
|
||
|
|
declare(strict_types=1);
|
||
|
|
|
||
|
|
namespace DoctrineMigrations;
|
||
|
|
|
||
|
|
use Doctrine\DBAL\Schema\Schema;
|
||
|
|
use Doctrine\Migrations\AbstractMigration;
|
||
|
|
|
||
|
|
final class Version20260513000003 extends AbstractMigration
|
||
|
|
{
|
||
|
|
public function getDescription(): string
|
||
|
|
{
|
||
|
|
return 'Add key_prefix column to api_keys for fast lookup';
|
||
|
|
}
|
||
|
|
|
||
|
|
public function up(Schema $schema): void
|
||
|
|
{
|
||
|
|
$this->addSql("ALTER TABLE app.api_keys ADD COLUMN key_prefix VARCHAR(16) NOT NULL DEFAULT ''");
|
||
|
|
$this->addSql('CREATE INDEX idx_api_keys_prefix ON app.api_keys (key_prefix)');
|
||
|
|
}
|
||
|
|
|
||
|
|
public function down(Schema $schema): void
|
||
|
|
{
|
||
|
|
$this->addSql('DROP INDEX idx_api_keys_prefix');
|
||
|
|
$this->addSql('ALTER TABLE app.api_keys DROP COLUMN key_prefix');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
```bash
|
||
|
|
docker compose run --rm app php bin/console doctrine:migrations:migrate --no-interaction
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: ApiKey-Repository-Interface erweitern**
|
||
|
|
|
||
|
|
Erstelle `src/Domain/Auth/Repository/ApiKeyRepositoryInterface.php`:
|
||
|
|
|
||
|
|
```php
|
||
|
|
<?php
|
||
|
|
// src/Domain/Auth/Repository/ApiKeyRepositoryInterface.php
|
||
|
|
declare(strict_types=1);
|
||
|
|
|
||
|
|
namespace App\Domain\Auth\Repository;
|
||
|
|
|
||
|
|
use App\Domain\Auth\ApiKey;
|
||
|
|
use Symfony\Component\Uid\Uuid;
|
||
|
|
|
||
|
|
interface ApiKeyRepositoryInterface
|
||
|
|
{
|
||
|
|
/** @return list<ApiKey> active keys with this prefix */
|
||
|
|
public function findActiveByPrefix(string $prefix): array;
|
||
|
|
|
||
|
|
public function findById(Uuid $id): ?ApiKey;
|
||
|
|
|
||
|
|
public function save(ApiKey $apiKey): void;
|
||
|
|
|
||
|
|
public function remove(ApiKey $apiKey): void;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Erstelle `src/Infrastructure/Persistence/Repository/DoctrineApiKeyRepository.php`:
|
||
|
|
|
||
|
|
```php
|
||
|
|
<?php
|
||
|
|
// src/Infrastructure/Persistence/Repository/DoctrineApiKeyRepository.php
|
||
|
|
declare(strict_types=1);
|
||
|
|
|
||
|
|
namespace App\Infrastructure\Persistence\Repository;
|
||
|
|
|
||
|
|
use App\Domain\Auth\ApiKey;
|
||
|
|
use App\Domain\Auth\Repository\ApiKeyRepositoryInterface;
|
||
|
|
use Doctrine\ORM\EntityManagerInterface;
|
||
|
|
use Symfony\Component\Uid\Uuid;
|
||
|
|
|
||
|
|
final class DoctrineApiKeyRepository implements ApiKeyRepositoryInterface
|
||
|
|
{
|
||
|
|
public function __construct(private readonly EntityManagerInterface $em) {}
|
||
|
|
|
||
|
|
/** @return list<ApiKey> */
|
||
|
|
public function findActiveByPrefix(string $prefix): array
|
||
|
|
{
|
||
|
|
/** @var list<ApiKey> */
|
||
|
|
return $this->em->getRepository(ApiKey::class)
|
||
|
|
->createQueryBuilder('k')
|
||
|
|
->where('k.keyPrefix = :prefix')
|
||
|
|
->andWhere('k.isActive = :active')
|
||
|
|
->setParameter('prefix', $prefix)
|
||
|
|
->setParameter('active', true)
|
||
|
|
->getQuery()
|
||
|
|
->getResult();
|
||
|
|
}
|
||
|
|
|
||
|
|
public function findById(Uuid $id): ?ApiKey
|
||
|
|
{
|
||
|
|
return $this->em->find(ApiKey::class, $id);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function save(ApiKey $apiKey): void
|
||
|
|
{
|
||
|
|
$this->em->persist($apiKey);
|
||
|
|
$this->em->flush();
|
||
|
|
}
|
||
|
|
|
||
|
|
public function remove(ApiKey $apiKey): void
|
||
|
|
{
|
||
|
|
$this->em->remove($apiKey);
|
||
|
|
$this->em->flush();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Ergänze `config/services.yaml`:
|
||
|
|
|
||
|
|
```yaml
|
||
|
|
App\Domain\Auth\Repository\ApiKeyRepositoryInterface:
|
||
|
|
alias: App\Infrastructure\Persistence\Repository\DoctrineApiKeyRepository
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: ApiKeyAuthenticator implementieren**
|
||
|
|
|
||
|
|
```php
|
||
|
|
<?php
|
||
|
|
// src/Infrastructure/Http/Security/ApiKeyAuthenticator.php
|
||
|
|
declare(strict_types=1);
|
||
|
|
|
||
|
|
namespace App\Infrastructure\Http\Security;
|
||
|
|
|
||
|
|
use App\Domain\Auth\Repository\ApiKeyRepositoryInterface;
|
||
|
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||
|
|
use Symfony\Component\HttpFoundation\Request;
|
||
|
|
use Symfony\Component\HttpFoundation\Response;
|
||
|
|
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||
|
|
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||
|
|
use Symfony\Component\Security\Core\Exception\AuthenticationException;
|
||
|
|
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
|
||
|
|
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
|
||
|
|
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
|
||
|
|
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
|
||
|
|
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
|
||
|
|
|
||
|
|
final class ApiKeyAuthenticator extends AbstractAuthenticator
|
||
|
|
{
|
||
|
|
public function __construct(
|
||
|
|
private readonly ApiKeyRepositoryInterface $apiKeyRepository,
|
||
|
|
) {}
|
||
|
|
|
||
|
|
public function supports(Request $request): ?bool
|
||
|
|
{
|
||
|
|
return $request->headers->has('X-Api-Key');
|
||
|
|
}
|
||
|
|
|
||
|
|
public function authenticate(Request $request): Passport
|
||
|
|
{
|
||
|
|
$rawKey = $request->headers->get('X-Api-Key', '');
|
||
|
|
if (\strlen($rawKey) < 16) {
|
||
|
|
throw new CustomUserMessageAuthenticationException('Invalid API key format');
|
||
|
|
}
|
||
|
|
|
||
|
|
$prefix = \substr($rawKey, 0, 16);
|
||
|
|
$candidates = $this->apiKeyRepository->findActiveByPrefix($prefix);
|
||
|
|
|
||
|
|
foreach ($candidates as $apiKey) {
|
||
|
|
if (\password_verify($rawKey, $apiKey->getKeyHash())) {
|
||
|
|
if ($apiKey->isExpired()) {
|
||
|
|
throw new CustomUserMessageAuthenticationException('API key has expired');
|
||
|
|
}
|
||
|
|
$apiKey->markUsed();
|
||
|
|
$this->apiKeyRepository->save($apiKey);
|
||
|
|
|
||
|
|
return new SelfValidatingPassport(
|
||
|
|
new UserBadge($apiKey->getUser()->getUserIdentifier()),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
throw new CustomUserMessageAuthenticationException('Invalid API key');
|
||
|
|
}
|
||
|
|
|
||
|
|
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
|
||
|
|
{
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
|
||
|
|
{
|
||
|
|
return new JsonResponse(['error' => $exception->getMessageKey()], Response::HTTP_UNAUTHORIZED);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 5: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add migrations/ src/Domain/Auth/ src/Infrastructure/Http/Security/ src/Infrastructure/Persistence/Repository/DoctrineApiKeyRepository.php config/services.yaml
|
||
|
|
git commit -m "feat: add ApiKey prefix-based authenticator with bcrypt verification"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 3: TOTP 2FA Setup
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `src/Infrastructure/Http/Controller/TotpSetupController.php`
|
||
|
|
- Create: `templates/totp/setup.html.twig`
|
||
|
|
|
||
|
|
- [ ] **Step 1: User für 2FA konfigurieren**
|
||
|
|
|
||
|
|
Die `User`-Entity muss `TwoFactorInterface` implementieren. Ergänze `src/Domain/Auth/User.php`:
|
||
|
|
|
||
|
|
```php
|
||
|
|
use Scheb\TwoFactorBundle\Model\Totp\TotpConfiguration;
|
||
|
|
use Scheb\TwoFactorBundle\Model\Totp\TotpConfigurationInterface;
|
||
|
|
use Scheb\TwoFactorBundle\Model\Totp\TwoFactorInterface;
|
||
|
|
|
||
|
|
// Implements-Liste um TwoFactorInterface erweitern:
|
||
|
|
class User implements UserInterface, PasswordAuthenticatedUserInterface, TwoFactorInterface
|
||
|
|
{
|
||
|
|
// Neue Methoden:
|
||
|
|
public function isTotpAuthenticationEnabled(): bool
|
||
|
|
{
|
||
|
|
return null !== $this->totpSecret;
|
||
|
|
}
|
||
|
|
|
||
|
|
public function getTotpAuthenticationUsername(): string
|
||
|
|
{
|
||
|
|
return $this->email;
|
||
|
|
}
|
||
|
|
|
||
|
|
public function getTotpAuthenticationConfiguration(): ?TotpConfigurationInterface
|
||
|
|
{
|
||
|
|
if (null === $this->totpSecret) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
return new TotpConfiguration($this->totpSecret, TotpConfiguration::ALGORITHM_SHA1, 30, 6);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: TotpSetupController**
|
||
|
|
|
||
|
|
```php
|
||
|
|
<?php
|
||
|
|
// src/Infrastructure/Http/Controller/TotpSetupController.php
|
||
|
|
declare(strict_types=1);
|
||
|
|
|
||
|
|
namespace App\Infrastructure\Http\Controller;
|
||
|
|
|
||
|
|
use App\Domain\Auth\Repository\UserRepositoryInterface;
|
||
|
|
use App\Domain\Auth\User;
|
||
|
|
use Endroid\QrCode\Builder\Builder;
|
||
|
|
use Endroid\QrCode\Encoding\Encoding;
|
||
|
|
use Endroid\QrCode\ErrorCorrectionLevel;
|
||
|
|
use Endroid\QrCode\Writer\PngWriter;
|
||
|
|
use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\Totp\TotpAuthenticatorInterface;
|
||
|
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||
|
|
use Symfony\Component\HttpFoundation\Request;
|
||
|
|
use Symfony\Component\HttpFoundation\Response;
|
||
|
|
use Symfony\Component\Routing\Attribute\Route;
|
||
|
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||
|
|
|
||
|
|
#[Route('/totp', name: 'totp_')]
|
||
|
|
#[IsGranted('ROLE_USER')]
|
||
|
|
final class TotpSetupController extends AbstractController
|
||
|
|
{
|
||
|
|
public function __construct(
|
||
|
|
private readonly TotpAuthenticatorInterface $totpAuthenticator,
|
||
|
|
private readonly UserRepositoryInterface $userRepository,
|
||
|
|
) {}
|
||
|
|
|
||
|
|
#[Route('/setup', name: 'setup', methods: ['GET'])]
|
||
|
|
public function setup(): Response
|
||
|
|
{
|
||
|
|
/** @var User $user */
|
||
|
|
$user = $this->getUser();
|
||
|
|
|
||
|
|
if (null === $user->getTotpSecret()) {
|
||
|
|
$secret = $this->totpAuthenticator->generateSecret();
|
||
|
|
$user->setTotpSecret($secret);
|
||
|
|
$this->userRepository->save($user);
|
||
|
|
}
|
||
|
|
|
||
|
|
$qrContent = $this->totpAuthenticator->getQRContent($user);
|
||
|
|
$qrCode = Builder::create()
|
||
|
|
->writer(new PngWriter())
|
||
|
|
->data($qrContent)
|
||
|
|
->encoding(new Encoding('UTF-8'))
|
||
|
|
->errorCorrectionLevel(ErrorCorrectionLevel::High)
|
||
|
|
->size(200)
|
||
|
|
->margin(10)
|
||
|
|
->build();
|
||
|
|
|
||
|
|
return $this->render('totp/setup.html.twig', [
|
||
|
|
'qrCodeDataUri' => $qrCode->getDataUri(),
|
||
|
|
'secret' => $user->getTotpSecret(),
|
||
|
|
'enabled' => $user->isTotpAuthenticationEnabled(),
|
||
|
|
]);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[Route('/disable', name: 'disable', methods: ['POST'])]
|
||
|
|
public function disable(Request $request): Response
|
||
|
|
{
|
||
|
|
if (!$this->isCsrfTokenValid('totp_disable', $request->request->getString('_token'))) {
|
||
|
|
throw $this->createAccessDeniedException('Invalid CSRF token');
|
||
|
|
}
|
||
|
|
|
||
|
|
/** @var User $user */
|
||
|
|
$user = $this->getUser();
|
||
|
|
$user->setTotpSecret(null);
|
||
|
|
$this->userRepository->save($user);
|
||
|
|
|
||
|
|
$this->addFlash('success', '2FA deaktiviert.');
|
||
|
|
|
||
|
|
return $this->redirectToRoute('totp_setup');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: UserRepositoryInterface ergänzen**
|
||
|
|
|
||
|
|
Erstelle `src/Domain/Auth/Repository/UserRepositoryInterface.php`:
|
||
|
|
|
||
|
|
```php
|
||
|
|
<?php
|
||
|
|
// src/Domain/Auth/Repository/UserRepositoryInterface.php
|
||
|
|
declare(strict_types=1);
|
||
|
|
|
||
|
|
namespace App\Domain\Auth\Repository;
|
||
|
|
|
||
|
|
use App\Domain\Auth\User;
|
||
|
|
use Symfony\Component\Uid\Uuid;
|
||
|
|
|
||
|
|
interface UserRepositoryInterface
|
||
|
|
{
|
||
|
|
public function findById(Uuid $id): ?User;
|
||
|
|
|
||
|
|
public function findByEmail(string $email): ?User;
|
||
|
|
|
||
|
|
/** @return list<User> */
|
||
|
|
public function findAll(): array;
|
||
|
|
|
||
|
|
public function save(User $user): void;
|
||
|
|
|
||
|
|
public function remove(User $user): void;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Erstelle `src/Infrastructure/Persistence/Repository/DoctrineUserRepository.php`:
|
||
|
|
|
||
|
|
```php
|
||
|
|
<?php
|
||
|
|
// src/Infrastructure/Persistence/Repository/DoctrineUserRepository.php
|
||
|
|
declare(strict_types=1);
|
||
|
|
|
||
|
|
namespace App\Infrastructure\Persistence\Repository;
|
||
|
|
|
||
|
|
use App\Domain\Auth\Repository\UserRepositoryInterface;
|
||
|
|
use App\Domain\Auth\User;
|
||
|
|
use Doctrine\ORM\EntityManagerInterface;
|
||
|
|
use Symfony\Component\Uid\Uuid;
|
||
|
|
|
||
|
|
final class DoctrineUserRepository implements UserRepositoryInterface
|
||
|
|
{
|
||
|
|
public function __construct(private readonly EntityManagerInterface $em) {}
|
||
|
|
|
||
|
|
public function findById(Uuid $id): ?User
|
||
|
|
{
|
||
|
|
return $this->em->find(User::class, $id);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function findByEmail(string $email): ?User
|
||
|
|
{
|
||
|
|
return $this->em->getRepository(User::class)->findOneBy(['email' => $email]);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** @return list<User> */
|
||
|
|
public function findAll(): array
|
||
|
|
{
|
||
|
|
/** @var list<User> */
|
||
|
|
return $this->em->getRepository(User::class)->findAll();
|
||
|
|
}
|
||
|
|
|
||
|
|
public function save(User $user): void
|
||
|
|
{
|
||
|
|
$this->em->persist($user);
|
||
|
|
$this->em->flush();
|
||
|
|
}
|
||
|
|
|
||
|
|
public function remove(User $user): void
|
||
|
|
{
|
||
|
|
$this->em->remove($user);
|
||
|
|
$this->em->flush();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Ergänze `config/services.yaml`:
|
||
|
|
|
||
|
|
```yaml
|
||
|
|
App\Domain\Auth\Repository\UserRepositoryInterface:
|
||
|
|
alias: App\Infrastructure\Persistence\Repository\DoctrineUserRepository
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: TOTP-Setup-Template**
|
||
|
|
|
||
|
|
```twig
|
||
|
|
{# templates/totp/setup.html.twig #}
|
||
|
|
<!DOCTYPE html>
|
||
|
|
<html lang="de">
|
||
|
|
<head><meta charset="UTF-8"><title>2FA Setup</title>
|
||
|
|
<style>
|
||
|
|
body { font-family: system-ui, sans-serif; max-width: 480px; margin: 3rem auto; padding: 0 1rem; }
|
||
|
|
.card { background: #fff; border: 1px solid #d1d1d6; border-radius: 12px; padding: 1.5rem; }
|
||
|
|
h1 { font-size: 1.25rem; margin: 0 0 1rem; }
|
||
|
|
img { display: block; margin: 1rem auto; }
|
||
|
|
code { display: block; text-align: center; font-size: 0.85rem; color: #86868b; margin: 0.5rem 0 1.5rem; }
|
||
|
|
button { background: #ff3b30; color: #fff; border: none; padding: .5rem 1rem; border-radius: 6px; cursor: pointer; }
|
||
|
|
.enabled { color: #34c759; font-weight: 600; }
|
||
|
|
.disabled { color: #86868b; }
|
||
|
|
</style>
|
||
|
|
</head>
|
||
|
|
<body>
|
||
|
|
<div class="card">
|
||
|
|
<h1>Zwei-Faktor-Authentifizierung</h1>
|
||
|
|
{% if enabled %}
|
||
|
|
<p class="enabled">✓ 2FA ist aktiv.</p>
|
||
|
|
<form method="post" action="{{ path('totp_disable') }}">
|
||
|
|
<input type="hidden" name="_token" value="{{ csrf_token('totp_disable') }}">
|
||
|
|
<button type="submit">2FA deaktivieren</button>
|
||
|
|
</form>
|
||
|
|
{% else %}
|
||
|
|
<p class="disabled">2FA ist noch nicht eingerichtet.</p>
|
||
|
|
<p>Scanne den QR-Code mit deiner Authenticator-App (Google Authenticator, Authy, …):</p>
|
||
|
|
<img src="{{ qrCodeDataUri }}" alt="QR Code" width="200" height="200">
|
||
|
|
<code>Manueller Code: {{ secret }}</code>
|
||
|
|
<p>Nach dem Scannen: beim nächsten Login wirst du nach dem Code gefragt.</p>
|
||
|
|
{% endif %}
|
||
|
|
</div>
|
||
|
|
</body>
|
||
|
|
</html>
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 5: Route ergänzen**
|
||
|
|
|
||
|
|
```yaml
|
||
|
|
# config/routes/security.yaml (ergänzen)
|
||
|
|
totp_setup:
|
||
|
|
path: /totp/setup
|
||
|
|
controller: App\Infrastructure\Http\Controller\TotpSetupController::setup
|
||
|
|
|
||
|
|
totp_disable:
|
||
|
|
path: /totp/disable
|
||
|
|
controller: App\Infrastructure\Http\Controller\TotpSetupController::disable
|
||
|
|
methods: [POST]
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 6: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add src/Domain/Auth/ src/Infrastructure/Http/Controller/TotpSetupController.php src/Infrastructure/Persistence/Repository/DoctrineUserRepository.php templates/totp/ config/routes/security.yaml config/services.yaml
|
||
|
|
git commit -m "feat: add TOTP 2FA setup, UserRepository"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 4: PermissionVoter
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `src/Infrastructure/Security/PermissionVoter.php`
|
||
|
|
- Test: `tests/Unit/Infrastructure/Security/PermissionVoterTest.php`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Failing-Test schreiben**
|
||
|
|
|
||
|
|
```php
|
||
|
|
<?php
|
||
|
|
// tests/Unit/Infrastructure/Security/PermissionVoterTest.php
|
||
|
|
declare(strict_types=1);
|
||
|
|
|
||
|
|
namespace App\Tests\Unit\Infrastructure\Security;
|
||
|
|
|
||
|
|
use App\Domain\Auth\User;
|
||
|
|
use App\Infrastructure\Security\PermissionVoter;
|
||
|
|
use PHPUnit\Framework\TestCase;
|
||
|
|
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
|
||
|
|
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
|
||
|
|
|
||
|
|
final class PermissionVoterTest extends TestCase
|
||
|
|
{
|
||
|
|
private PermissionVoter $voter;
|
||
|
|
|
||
|
|
protected function setUp(): void
|
||
|
|
{
|
||
|
|
$this->voter = new PermissionVoter();
|
||
|
|
}
|
||
|
|
|
||
|
|
private function tokenFor(User $user): UsernamePasswordToken
|
||
|
|
{
|
||
|
|
return new UsernamePasswordToken($user, 'main', $user->getRoles());
|
||
|
|
}
|
||
|
|
|
||
|
|
public function test_grants_permission_to_user_with_it(): void
|
||
|
|
{
|
||
|
|
$user = new User('test@example.com', 'hash');
|
||
|
|
$user->grantPermission('article:view');
|
||
|
|
|
||
|
|
$result = $this->voter->vote($this->tokenFor($user), null, ['PERMISSION_article:view']);
|
||
|
|
|
||
|
|
$this->assertSame(VoterInterface::ACCESS_GRANTED, $result);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function test_denies_permission_user_lacks(): void
|
||
|
|
{
|
||
|
|
$user = new User('test@example.com', 'hash');
|
||
|
|
|
||
|
|
$result = $this->voter->vote($this->tokenFor($user), null, ['PERMISSION_order:delete']);
|
||
|
|
|
||
|
|
$this->assertSame(VoterInterface::ACCESS_DENIED, $result);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function test_abstains_for_non_permission_attribute(): void
|
||
|
|
{
|
||
|
|
$user = new User('test@example.com', 'hash');
|
||
|
|
|
||
|
|
$result = $this->voter->vote($this->tokenFor($user), null, ['ROLE_ADMIN']);
|
||
|
|
|
||
|
|
$this->assertSame(VoterInterface::ACCESS_ABSTAIN, $result);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Test ausführen — muss fehlschlagen**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
docker compose run --rm app ./vendor/bin/pest tests/Unit/Infrastructure/Security/PermissionVoterTest.php
|
||
|
|
# Expected: FAIL
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: PermissionVoter implementieren**
|
||
|
|
|
||
|
|
```php
|
||
|
|
<?php
|
||
|
|
// src/Infrastructure/Security/PermissionVoter.php
|
||
|
|
declare(strict_types=1);
|
||
|
|
|
||
|
|
namespace App\Infrastructure\Security;
|
||
|
|
|
||
|
|
use App\Domain\Auth\User;
|
||
|
|
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||
|
|
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Grants access when the authenticated User has the given permission.
|
||
|
|
* Usage: $this->denyAccessUnlessGranted('PERMISSION_article:view')
|
||
|
|
*
|
||
|
|
* @extends Voter<string, mixed>
|
||
|
|
*/
|
||
|
|
final class PermissionVoter extends Voter
|
||
|
|
{
|
||
|
|
private const PREFIX = 'PERMISSION_';
|
||
|
|
|
||
|
|
protected function supports(string $attribute, mixed $subject): bool
|
||
|
|
{
|
||
|
|
return \str_starts_with($attribute, self::PREFIX);
|
||
|
|
}
|
||
|
|
|
||
|
|
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
|
||
|
|
{
|
||
|
|
$user = $token->getUser();
|
||
|
|
if (!$user instanceof User) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
$permission = \substr($attribute, \strlen(self::PREFIX));
|
||
|
|
|
||
|
|
return $user->hasPermission($permission);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Test ausführen**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
docker compose run --rm app ./vendor/bin/pest tests/Unit/Infrastructure/Security/PermissionVoterTest.php
|
||
|
|
# Expected: PASS (3 tests)
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 5: PHPStan + Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
docker compose run --rm app ./vendor/bin/phpstan analyse src/Infrastructure/Security/ --no-progress
|
||
|
|
|
||
|
|
git add src/Infrastructure/Security/PermissionVoter.php tests/Unit/Infrastructure/Security/PermissionVoterTest.php
|
||
|
|
git commit -m "feat: add PermissionVoter (PERMISSION_* attribute → User.hasPermission)"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 5: EasyAdmin Setup
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `src/Infrastructure/Http/Admin/DashboardController.php`
|
||
|
|
- Create: `src/Infrastructure/Http/Admin/ArticleCrudController.php`
|
||
|
|
- Create: `src/Infrastructure/Http/Admin/ArticleTypeCrudController.php`
|
||
|
|
- Create: `src/Infrastructure/Http/Admin/UserCrudController.php`
|
||
|
|
- Create: `src/Infrastructure/Http/Admin/AIPipelineJobCrudController.php`
|
||
|
|
- Create: `config/routes/admin.yaml`
|
||
|
|
|
||
|
|
- [ ] **Step 1: DashboardController**
|
||
|
|
|
||
|
|
```php
|
||
|
|
<?php
|
||
|
|
// src/Infrastructure/Http/Admin/DashboardController.php
|
||
|
|
declare(strict_types=1);
|
||
|
|
|
||
|
|
namespace App\Infrastructure\Http\Admin;
|
||
|
|
|
||
|
|
use EasyCorp\Bundle\EasyAdminBundle\Attribute\AdminDashboard;
|
||
|
|
use EasyCorp\Bundle\EasyAdminBundle\Config\Dashboard;
|
||
|
|
use EasyCorp\Bundle\EasyAdminBundle\Config\MenuItem;
|
||
|
|
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractDashboardController;
|
||
|
|
use Symfony\Component\HttpFoundation\Response;
|
||
|
|
|
||
|
|
#[AdminDashboard(routePath: '/admin', routeName: 'admin')]
|
||
|
|
final class DashboardController extends AbstractDashboardController
|
||
|
|
{
|
||
|
|
public function index(): Response
|
||
|
|
{
|
||
|
|
return $this->render('@EasyAdmin/page/content.html.twig');
|
||
|
|
}
|
||
|
|
|
||
|
|
public function configureDashboard(): Dashboard
|
||
|
|
{
|
||
|
|
return Dashboard::new()->setTitle('SuperSeller3000');
|
||
|
|
}
|
||
|
|
|
||
|
|
public function configureMenuItems(): iterable
|
||
|
|
{
|
||
|
|
yield MenuItem::linkToDashboard('Dashboard', 'fa fa-home');
|
||
|
|
yield MenuItem::section('Artikel');
|
||
|
|
yield MenuItem::linkToCrud('Artikel', 'fa fa-box', \App\Domain\Article\Article::class);
|
||
|
|
yield MenuItem::linkToCrud('Artikel-Typen', 'fa fa-tags', \App\Domain\Article\ArticleType::class);
|
||
|
|
yield MenuItem::section('Plattformen');
|
||
|
|
yield MenuItem::linkToCrud('Plattformen', 'fa fa-plug', \App\Domain\Channel\Platform::class);
|
||
|
|
yield MenuItem::section('Monitoring');
|
||
|
|
yield MenuItem::linkToCrud('KI-Jobs', 'fa fa-robot', \App\Domain\Pipeline\AIPipelineJob::class);
|
||
|
|
yield MenuItem::linkToCrud('Logs', 'fa fa-list', \App\Infrastructure\Logging\LogEntry::class);
|
||
|
|
yield MenuItem::section('Administration');
|
||
|
|
yield MenuItem::linkToCrud('Benutzer', 'fa fa-users', \App\Domain\Auth\User::class);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: ArticleCrudController (Freigabe-Workflow)**
|
||
|
|
|
||
|
|
```php
|
||
|
|
<?php
|
||
|
|
// src/Infrastructure/Http/Admin/ArticleCrudController.php
|
||
|
|
declare(strict_types=1);
|
||
|
|
|
||
|
|
namespace App\Infrastructure\Http\Admin;
|
||
|
|
|
||
|
|
use App\Application\Article\ArticleService;
|
||
|
|
use App\Domain\Article\Article;
|
||
|
|
use App\Domain\Article\ArticleStatus;
|
||
|
|
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
|
||
|
|
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
|
||
|
|
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
|
||
|
|
use EasyCorp\Bundle\EasyAdminBundle\Config\Filters;
|
||
|
|
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
|
||
|
|
use EasyCorp\Bundle\EasyAdminBundle\Field\ChoiceField;
|
||
|
|
use EasyCorp\Bundle\EasyAdminBundle\Field\DateTimeField;
|
||
|
|
use EasyCorp\Bundle\EasyAdminBundle\Field\MoneyField;
|
||
|
|
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
|
||
|
|
use EasyCorp\Bundle\EasyAdminBundle\Filter\ChoiceFilter;
|
||
|
|
use EasyCorp\Bundle\EasyAdminBundle\Router\AdminUrlGenerator;
|
||
|
|
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||
|
|
use Symfony\Component\HttpFoundation\Request;
|
||
|
|
use Symfony\Component\Routing\Attribute\Route;
|
||
|
|
|
||
|
|
final class ArticleCrudController extends AbstractCrudController
|
||
|
|
{
|
||
|
|
public function __construct(
|
||
|
|
private readonly ArticleService $articleService,
|
||
|
|
private readonly AdminUrlGenerator $adminUrlGenerator,
|
||
|
|
) {}
|
||
|
|
|
||
|
|
public static function getEntityFqcn(): string
|
||
|
|
{
|
||
|
|
return Article::class;
|
||
|
|
}
|
||
|
|
|
||
|
|
public function configureCrud(Crud $crud): Crud
|
||
|
|
{
|
||
|
|
return $crud
|
||
|
|
->setEntityLabelInSingular('Artikel')
|
||
|
|
->setEntityLabelInPlural('Artikel')
|
||
|
|
->setDefaultSort(['status' => 'ASC']);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function configureFields(string $pageName): iterable
|
||
|
|
{
|
||
|
|
yield TextField::new('sku', 'SKU');
|
||
|
|
yield TextField::new('inventoryNumber', 'Inventar-Nr.');
|
||
|
|
yield ChoiceField::new('status', 'Status')->setChoices(ArticleStatus::class);
|
||
|
|
yield TextField::new('articleType.name', 'Typ');
|
||
|
|
yield ChoiceField::new('condition', 'Zustand');
|
||
|
|
yield MoneyField::new('listingPrice', 'Preis')->setCurrency('EUR');
|
||
|
|
yield DateTimeField::new('createdAt', 'Erstellt')->onlyOnIndex();
|
||
|
|
}
|
||
|
|
|
||
|
|
public function configureFilters(Filters $filters): Filters
|
||
|
|
{
|
||
|
|
return $filters->add(ChoiceFilter::new('status')->setChoices(\array_column(ArticleStatus::cases(), 'value', 'value')));
|
||
|
|
}
|
||
|
|
|
||
|
|
public function configureActions(Actions $actions): Actions
|
||
|
|
{
|
||
|
|
$activate = Action::new('activate', 'Freigeben', 'fa fa-check')
|
||
|
|
->linkToRoute('admin_article_activate', fn (Article $a) => ['id' => $a->getId()->toRfc4122()])
|
||
|
|
->displayIf(static fn (Article $a) => $a->getStatus() === ArticleStatus::Draft);
|
||
|
|
|
||
|
|
return $actions
|
||
|
|
->add(Crud::PAGE_INDEX, $activate)
|
||
|
|
->disable(Action::DELETE);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[Route('/admin/articles/{id}/activate', name: 'admin_article_activate')]
|
||
|
|
public function activateAction(string $id, Request $request): RedirectResponse
|
||
|
|
{
|
||
|
|
$result = $this->articleService->activate(\Symfony\Component\Uid\Uuid::fromString($id));
|
||
|
|
|
||
|
|
if ([] !== $result['missing']) {
|
||
|
|
$this->addFlash('danger', 'Fehlende Pflichtfelder: '.\implode(', ', $result['missing']));
|
||
|
|
} else {
|
||
|
|
$this->addFlash('success', 'Artikel freigegeben.');
|
||
|
|
}
|
||
|
|
|
||
|
|
$url = $this->adminUrlGenerator->setController(self::class)->setAction(Action::INDEX)->generateUrl();
|
||
|
|
|
||
|
|
return $this->redirect($url);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: Weitere CRUDs (ArticleType, User, AIPipelineJob)**
|
||
|
|
|
||
|
|
```php
|
||
|
|
<?php
|
||
|
|
// src/Infrastructure/Http/Admin/ArticleTypeCrudController.php
|
||
|
|
declare(strict_types=1);
|
||
|
|
|
||
|
|
namespace App\Infrastructure\Http\Admin;
|
||
|
|
|
||
|
|
use App\Domain\Article\ArticleType;
|
||
|
|
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
|
||
|
|
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
|
||
|
|
|
||
|
|
final class ArticleTypeCrudController extends AbstractCrudController
|
||
|
|
{
|
||
|
|
public static function getEntityFqcn(): string { return ArticleType::class; }
|
||
|
|
|
||
|
|
public function configureFields(string $pageName): iterable
|
||
|
|
{
|
||
|
|
yield TextField::new('name', 'Name');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
```php
|
||
|
|
<?php
|
||
|
|
// src/Infrastructure/Http/Admin/UserCrudController.php
|
||
|
|
declare(strict_types=1);
|
||
|
|
|
||
|
|
namespace App\Infrastructure\Http\Admin;
|
||
|
|
|
||
|
|
use App\Domain\Auth\User;
|
||
|
|
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
|
||
|
|
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
|
||
|
|
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
|
||
|
|
use EasyCorp\Bundle\EasyAdminBundle\Field\BooleanField;
|
||
|
|
use EasyCorp\Bundle\EasyAdminBundle\Field\EmailField;
|
||
|
|
|
||
|
|
final class UserCrudController extends AbstractCrudController
|
||
|
|
{
|
||
|
|
public static function getEntityFqcn(): string { return User::class; }
|
||
|
|
|
||
|
|
public function configureFields(string $pageName): iterable
|
||
|
|
{
|
||
|
|
yield EmailField::new('email', 'E-Mail');
|
||
|
|
yield BooleanField::new('isActive', 'Aktiv');
|
||
|
|
}
|
||
|
|
|
||
|
|
public function configureActions(Actions $actions): Actions
|
||
|
|
{
|
||
|
|
return $actions->disable(Action::DELETE);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
```php
|
||
|
|
<?php
|
||
|
|
// src/Infrastructure/Http/Admin/AIPipelineJobCrudController.php
|
||
|
|
declare(strict_types=1);
|
||
|
|
|
||
|
|
namespace App\Infrastructure\Http\Admin;
|
||
|
|
|
||
|
|
use App\Domain\Pipeline\AIPipelineJob;
|
||
|
|
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
|
||
|
|
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
|
||
|
|
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
|
||
|
|
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
|
||
|
|
use EasyCorp\Bundle\EasyAdminBundle\Field\ChoiceField;
|
||
|
|
use EasyCorp\Bundle\EasyAdminBundle\Field\DateTimeField;
|
||
|
|
use EasyCorp\Bundle\EasyAdminBundle\Field\IntegerField;
|
||
|
|
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
|
||
|
|
|
||
|
|
final class AIPipelineJobCrudController extends AbstractCrudController
|
||
|
|
{
|
||
|
|
public static function getEntityFqcn(): string { return AIPipelineJob::class; }
|
||
|
|
|
||
|
|
public function configureCrud(Crud $crud): Crud
|
||
|
|
{
|
||
|
|
return $crud->setDefaultSort(['createdAt' => 'DESC']);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function configureFields(string $pageName): iterable
|
||
|
|
{
|
||
|
|
yield ChoiceField::new('type', 'Typ');
|
||
|
|
yield ChoiceField::new('status', 'Status');
|
||
|
|
yield IntegerField::new('attemptCount', 'Versuche');
|
||
|
|
yield TextField::new('errorMessage', 'Fehler')->onlyOnDetail();
|
||
|
|
yield DateTimeField::new('createdAt', 'Erstellt');
|
||
|
|
yield DateTimeField::new('completedAt', 'Abgeschlossen');
|
||
|
|
}
|
||
|
|
|
||
|
|
public function configureActions(Actions $actions): Actions
|
||
|
|
{
|
||
|
|
return $actions->disable(Action::NEW, Action::EDIT, Action::DELETE);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: PHPStan + Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
docker compose run --rm app ./vendor/bin/phpstan analyse src/Infrastructure/Http/Admin/ --no-progress
|
||
|
|
docker compose run --rm app ./vendor/bin/php-cs-fixer fix src/Infrastructure/Http/Admin/ --dry-run --diff
|
||
|
|
|
||
|
|
git add src/Infrastructure/Http/Admin/ config/routes/
|
||
|
|
git commit -m "feat: add EasyAdmin dashboard with Article approval workflow, ArticleType, User, AIPipelineJob CRUDs"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 6: DatabaseLogHandler + LogEntry
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `src/Infrastructure/Logging/LogEntry.php`
|
||
|
|
- Create: `src/Infrastructure/Logging/DatabaseLogHandler.php`
|
||
|
|
- Test: `tests/Unit/Logging/DatabaseLogHandlerTest.php`
|
||
|
|
|
||
|
|
- [ ] **Step 1: LogEntry-Entity erstellen**
|
||
|
|
|
||
|
|
Plan 1 hat die Tabellen `logs.log_entry` und `logs_archive.log_entry` bereits angelegt. Jetzt erstellen wir die Doctrine-Entity für die aktive Tabelle:
|
||
|
|
|
||
|
|
```php
|
||
|
|
<?php
|
||
|
|
// src/Infrastructure/Logging/LogEntry.php
|
||
|
|
declare(strict_types=1);
|
||
|
|
|
||
|
|
namespace App\Infrastructure\Logging;
|
||
|
|
|
||
|
|
use Doctrine\ORM\Mapping as ORM;
|
||
|
|
use Symfony\Component\Uid\Uuid;
|
||
|
|
|
||
|
|
#[ORM\Entity]
|
||
|
|
#[ORM\Table(name: 'log_entry', schema: 'logs')]
|
||
|
|
class LogEntry
|
||
|
|
{
|
||
|
|
#[ORM\Id]
|
||
|
|
#[ORM\Column(type: 'uuid')]
|
||
|
|
private Uuid $id;
|
||
|
|
|
||
|
|
#[ORM\Column(type: 'string', length: 20)]
|
||
|
|
private string $level;
|
||
|
|
|
||
|
|
#[ORM\Column(type: 'string', length: 100)]
|
||
|
|
private string $channel;
|
||
|
|
|
||
|
|
#[ORM\Column(type: 'text')]
|
||
|
|
private string $message;
|
||
|
|
|
||
|
|
/** @var array<string, mixed> */
|
||
|
|
#[ORM\Column(type: 'json')]
|
||
|
|
private array $context = [];
|
||
|
|
|
||
|
|
// message_search is a GENERATED ALWAYS column — never written from PHP
|
||
|
|
#[ORM\Column(type: 'string', insertable: false, updatable: false, nullable: true)]
|
||
|
|
private ?string $messageSearch = null;
|
||
|
|
|
||
|
|
#[ORM\Column(type: 'datetime_immutable')]
|
||
|
|
private \DateTimeImmutable $createdAt;
|
||
|
|
|
||
|
|
public function __construct(string $level, string $channel, string $message, array $context = [])
|
||
|
|
{
|
||
|
|
$this->id = Uuid::v7();
|
||
|
|
$this->level = $level;
|
||
|
|
$this->channel = $channel;
|
||
|
|
$this->message = $message;
|
||
|
|
$this->context = $context;
|
||
|
|
$this->createdAt = new \DateTimeImmutable();
|
||
|
|
}
|
||
|
|
|
||
|
|
public function getId(): Uuid { return $this->id; }
|
||
|
|
public function getLevel(): string { return $this->level; }
|
||
|
|
public function getChannel(): string { return $this->channel; }
|
||
|
|
public function getMessage(): string { return $this->message; }
|
||
|
|
/** @return array<string, mixed> */
|
||
|
|
public function getContext(): array { return $this->context; }
|
||
|
|
public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; }
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Failing-Test schreiben**
|
||
|
|
|
||
|
|
```php
|
||
|
|
<?php
|
||
|
|
// tests/Unit/Logging/DatabaseLogHandlerTest.php
|
||
|
|
declare(strict_types=1);
|
||
|
|
|
||
|
|
namespace App\Tests\Unit\Logging;
|
||
|
|
|
||
|
|
use App\Infrastructure\Logging\DatabaseLogHandler;
|
||
|
|
use Doctrine\DBAL\Connection;
|
||
|
|
use Monolog\Level;
|
||
|
|
use Monolog\LogRecord;
|
||
|
|
use PHPUnit\Framework\MockObject\MockObject;
|
||
|
|
use PHPUnit\Framework\TestCase;
|
||
|
|
|
||
|
|
final class DatabaseLogHandlerTest extends TestCase
|
||
|
|
{
|
||
|
|
private Connection&MockObject $connection;
|
||
|
|
private DatabaseLogHandler $handler;
|
||
|
|
|
||
|
|
protected function setUp(): void
|
||
|
|
{
|
||
|
|
$this->connection = $this->createMock(Connection::class);
|
||
|
|
$this->handler = new DatabaseLogHandler($this->connection);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function test_writes_record_to_database(): void
|
||
|
|
{
|
||
|
|
$this->connection->expects($this->once())
|
||
|
|
->method('insert')
|
||
|
|
->with(
|
||
|
|
'logs.log_entry',
|
||
|
|
$this->callback(static fn (array $data) =>
|
||
|
|
$data['level'] === 'ERROR' &&
|
||
|
|
$data['channel'] === 'app' &&
|
||
|
|
$data['message'] === 'Something went wrong' &&
|
||
|
|
isset($data['id']) &&
|
||
|
|
isset($data['created_at'])
|
||
|
|
),
|
||
|
|
);
|
||
|
|
|
||
|
|
$record = new LogRecord(
|
||
|
|
datetime: new \DateTimeImmutable(),
|
||
|
|
channel: 'app',
|
||
|
|
level: Level::Error,
|
||
|
|
message: 'Something went wrong',
|
||
|
|
context: [],
|
||
|
|
extra: [],
|
||
|
|
);
|
||
|
|
|
||
|
|
$this->handler->handle($record);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function test_handles_below_min_level_by_ignoring(): void
|
||
|
|
{
|
||
|
|
$this->connection->expects($this->never())->method('insert');
|
||
|
|
|
||
|
|
$record = new LogRecord(
|
||
|
|
datetime: new \DateTimeImmutable(),
|
||
|
|
channel: 'app',
|
||
|
|
level: Level::Debug,
|
||
|
|
message: 'Debug message',
|
||
|
|
context: [],
|
||
|
|
extra: [],
|
||
|
|
);
|
||
|
|
|
||
|
|
$this->handler->handle($record);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: Test ausführen — muss fehlschlagen**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
docker compose run --rm app ./vendor/bin/pest tests/Unit/Logging/DatabaseLogHandlerTest.php
|
||
|
|
# Expected: FAIL
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: DatabaseLogHandler implementieren**
|
||
|
|
|
||
|
|
```php
|
||
|
|
<?php
|
||
|
|
// src/Infrastructure/Logging/DatabaseLogHandler.php
|
||
|
|
declare(strict_types=1);
|
||
|
|
|
||
|
|
namespace App\Infrastructure\Logging;
|
||
|
|
|
||
|
|
use Doctrine\DBAL\Connection;
|
||
|
|
use Monolog\Handler\AbstractProcessingHandler;
|
||
|
|
use Monolog\Level;
|
||
|
|
use Monolog\LogRecord;
|
||
|
|
use Symfony\Component\Uid\Uuid;
|
||
|
|
|
||
|
|
final class DatabaseLogHandler extends AbstractProcessingHandler
|
||
|
|
{
|
||
|
|
public function __construct(
|
||
|
|
private readonly Connection $connection,
|
||
|
|
Level $level = Level::Info,
|
||
|
|
) {
|
||
|
|
parent::__construct($level);
|
||
|
|
}
|
||
|
|
|
||
|
|
protected function write(LogRecord $record): void
|
||
|
|
{
|
||
|
|
try {
|
||
|
|
$this->connection->insert('logs.log_entry', [
|
||
|
|
'id' => Uuid::v7()->toRfc4122(),
|
||
|
|
'level' => $record->level->name,
|
||
|
|
'channel' => $record->channel,
|
||
|
|
'message' => $record->message,
|
||
|
|
'context' => \json_encode($record->context, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
|
||
|
|
'created_at' => $record->datetime->format('Y-m-d H:i:s.u'),
|
||
|
|
]);
|
||
|
|
} catch (\Throwable) {
|
||
|
|
// Never let logging break the application
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 5: Monolog konfigurieren**
|
||
|
|
|
||
|
|
```yaml
|
||
|
|
# config/packages/monolog.yaml (ergänzen, unter handlers:)
|
||
|
|
database:
|
||
|
|
type: service
|
||
|
|
id: App\Infrastructure\Logging\DatabaseLogHandler
|
||
|
|
```
|
||
|
|
|
||
|
|
Für Prod (`config/packages/prod/monolog.yaml`):
|
||
|
|
|
||
|
|
```yaml
|
||
|
|
monolog:
|
||
|
|
handlers:
|
||
|
|
main:
|
||
|
|
type: fingers_crossed
|
||
|
|
action_level: error
|
||
|
|
handler: nested
|
||
|
|
excluded_http_codes: [404, 405]
|
||
|
|
nested:
|
||
|
|
type: rotating_file
|
||
|
|
path: '%kernel.logs_dir%/%kernel.environment%.log'
|
||
|
|
level: debug
|
||
|
|
max_files: 7
|
||
|
|
database:
|
||
|
|
type: service
|
||
|
|
id: App\Infrastructure\Logging\DatabaseLogHandler
|
||
|
|
level: info
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 6: Test ausführen**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
docker compose run --rm app ./vendor/bin/pest tests/Unit/Logging/DatabaseLogHandlerTest.php
|
||
|
|
# Expected: PASS (2 tests)
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 7: EasyAdmin LogEntry CRUD ergänzen**
|
||
|
|
|
||
|
|
```php
|
||
|
|
<?php
|
||
|
|
// src/Infrastructure/Http/Admin/LogEntryCrudController.php
|
||
|
|
declare(strict_types=1);
|
||
|
|
|
||
|
|
namespace App\Infrastructure\Http\Admin;
|
||
|
|
|
||
|
|
use App\Infrastructure\Logging\LogEntry;
|
||
|
|
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
|
||
|
|
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
|
||
|
|
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
|
||
|
|
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
|
||
|
|
use EasyCorp\Bundle\EasyAdminBundle\Field\ChoiceField;
|
||
|
|
use EasyCorp\Bundle\EasyAdminBundle\Field\DateTimeField;
|
||
|
|
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
|
||
|
|
|
||
|
|
final class LogEntryCrudController extends AbstractCrudController
|
||
|
|
{
|
||
|
|
public static function getEntityFqcn(): string { return LogEntry::class; }
|
||
|
|
|
||
|
|
public function configureCrud(Crud $crud): Crud
|
||
|
|
{
|
||
|
|
return $crud->setDefaultSort(['createdAt' => 'DESC']);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function configureFields(string $pageName): iterable
|
||
|
|
{
|
||
|
|
yield DateTimeField::new('createdAt', 'Zeit');
|
||
|
|
yield ChoiceField::new('level', 'Level')->setChoices(['DEBUG' => 'DEBUG', 'INFO' => 'INFO', 'WARNING' => 'WARNING', 'ERROR' => 'ERROR', 'CRITICAL' => 'CRITICAL']);
|
||
|
|
yield TextField::new('channel', 'Channel');
|
||
|
|
yield TextField::new('message', 'Meldung');
|
||
|
|
}
|
||
|
|
|
||
|
|
public function configureActions(Actions $actions): Actions
|
||
|
|
{
|
||
|
|
return $actions->disable(Action::NEW, Action::EDIT);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 8: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add src/Infrastructure/Logging/ tests/Unit/Logging/ config/packages/monolog.yaml
|
||
|
|
git commit -m "feat: add DatabaseLogHandler, LogEntry entity, EasyAdmin log viewer"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 7: Log-Rotations-Command
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `src/Infrastructure/Logging/RotateLogsCommand.php`
|
||
|
|
|
||
|
|
- [ ] **Step 1: RotateLogsCommand implementieren**
|
||
|
|
|
||
|
|
```php
|
||
|
|
<?php
|
||
|
|
// src/Infrastructure/Logging/RotateLogsCommand.php
|
||
|
|
declare(strict_types=1);
|
||
|
|
|
||
|
|
namespace App\Infrastructure\Logging;
|
||
|
|
|
||
|
|
use Doctrine\DBAL\Connection;
|
||
|
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||
|
|
use Symfony\Component\Console\Command\Command;
|
||
|
|
use Symfony\Component\Console\Input\InputInterface;
|
||
|
|
use Symfony\Component\Console\Output\OutputInterface;
|
||
|
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||
|
|
|
||
|
|
#[AsCommand(name: 'app:logs:rotate', description: 'Archiviert Logs älter als 90 Tage (level > DEBUG), löscht alle alten Einträge')]
|
||
|
|
final class RotateLogsCommand extends Command
|
||
|
|
{
|
||
|
|
public function __construct(private readonly Connection $connection)
|
||
|
|
{
|
||
|
|
parent::__construct();
|
||
|
|
}
|
||
|
|
|
||
|
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||
|
|
{
|
||
|
|
$io = new SymfonyStyle($input, $output);
|
||
|
|
$cutoff = (new \DateTimeImmutable('-90 days'))->format('Y-m-d H:i:s');
|
||
|
|
|
||
|
|
// Archive non-DEBUG entries older than 90 days
|
||
|
|
$archived = $this->connection->executeStatement(
|
||
|
|
"INSERT INTO logs_archive.log_entry (id, level, channel, message, context, created_at)
|
||
|
|
SELECT id, level, channel, message, context, created_at
|
||
|
|
FROM logs.log_entry
|
||
|
|
WHERE created_at < :cutoff AND level != 'DEBUG'
|
||
|
|
ON CONFLICT (id) DO NOTHING",
|
||
|
|
['cutoff' => $cutoff],
|
||
|
|
);
|
||
|
|
|
||
|
|
// Delete all entries older than 90 days from active log
|
||
|
|
$deleted = $this->connection->executeStatement(
|
||
|
|
'DELETE FROM logs.log_entry WHERE created_at < :cutoff',
|
||
|
|
['cutoff' => $cutoff],
|
||
|
|
);
|
||
|
|
|
||
|
|
$io->success("Archiviert: {$archived} Einträge. Gelöscht: {$deleted} Einträge.");
|
||
|
|
|
||
|
|
return Command::SUCCESS;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Cron-Eintrag im Docker-Container sicherstellen**
|
||
|
|
|
||
|
|
In `docker-compose.yml` wird der `cron`-Container mit diesem Befehl konfiguriert. Stelle sicher, dass er vorhanden ist und der Job eingetragen ist:
|
||
|
|
|
||
|
|
```yaml
|
||
|
|
cron:
|
||
|
|
build: docker/app
|
||
|
|
volumes:
|
||
|
|
- .:/var/www
|
||
|
|
command: >
|
||
|
|
sh -c "echo '0 2 * * * cd /var/www && php bin/console app:logs:rotate >> /proc/1/fd/1 2>&1' | crontab - && crond -f -l 2"
|
||
|
|
env_file: .env.local
|
||
|
|
depends_on:
|
||
|
|
- postgres
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: Command testen**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
docker compose run --rm app php bin/console app:logs:rotate
|
||
|
|
# Expected: "[OK] Archiviert: 0 Einträge. Gelöscht: 0 Einträge." (leere DB)
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add src/Infrastructure/Logging/RotateLogsCommand.php docker-compose.yml
|
||
|
|
git commit -m "feat: add log rotation command (archive >90d non-DEBUG, delete all >90d)"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 8: Backup-Command
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `src/Infrastructure/Command/BackupCommand.php`
|
||
|
|
|
||
|
|
- [ ] **Step 1: BackupCommand implementieren**
|
||
|
|
|
||
|
|
```php
|
||
|
|
<?php
|
||
|
|
// src/Infrastructure/Command/BackupCommand.php
|
||
|
|
declare(strict_types=1);
|
||
|
|
|
||
|
|
namespace App\Infrastructure\Command;
|
||
|
|
|
||
|
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||
|
|
use Symfony\Component\Console\Command\Command;
|
||
|
|
use Symfony\Component\Console\Input\InputInterface;
|
||
|
|
use Symfony\Component\Console\Output\OutputInterface;
|
||
|
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||
|
|
|
||
|
|
#[AsCommand(name: 'app:backup:run', description: 'Erstellt pg_dump und Gitea-Backup')]
|
||
|
|
final class BackupCommand extends Command
|
||
|
|
{
|
||
|
|
public function __construct(
|
||
|
|
private readonly string $backupDir,
|
||
|
|
private readonly string $pgDumpDsn,
|
||
|
|
private readonly string $giteaDataDir,
|
||
|
|
) {
|
||
|
|
parent::__construct();
|
||
|
|
}
|
||
|
|
|
||
|
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||
|
|
{
|
||
|
|
$io = new SymfonyStyle($input, $output);
|
||
|
|
$date = \date('Y-m-d');
|
||
|
|
|
||
|
|
if (!\is_dir($this->backupDir)) {
|
||
|
|
\mkdir($this->backupDir, 0750, true);
|
||
|
|
}
|
||
|
|
|
||
|
|
// PostgreSQL dump
|
||
|
|
$pgFile = "{$this->backupDir}/postgres-{$date}.sql.gz";
|
||
|
|
$pgCmd = "pg_dump \"{$this->pgDumpDsn}\" | gzip > \"{$pgFile}\"";
|
||
|
|
\exec($pgCmd, result_code: $pgCode);
|
||
|
|
|
||
|
|
if ($pgCode !== 0) {
|
||
|
|
$io->error("pg_dump fehlgeschlagen (exit code: {$pgCode})");
|
||
|
|
|
||
|
|
return Command::FAILURE;
|
||
|
|
}
|
||
|
|
|
||
|
|
$io->success("PostgreSQL-Backup: {$pgFile}");
|
||
|
|
|
||
|
|
// Gitea dump (nur wenn gitea CLI verfügbar)
|
||
|
|
$giteaFile = "{$this->backupDir}/gitea-{$date}.zip";
|
||
|
|
$giteaCmd = "gitea dump -c /etc/gitea/app.ini --file \"{$giteaFile}\" 2>&1";
|
||
|
|
\exec($giteaCmd, result_code: $giteaCode);
|
||
|
|
|
||
|
|
if ($giteaCode === 0) {
|
||
|
|
$io->success("Gitea-Backup: {$giteaFile}");
|
||
|
|
} else {
|
||
|
|
$io->warning('Gitea-Backup übersprungen (gitea CLI nicht verfügbar oder Fehler).');
|
||
|
|
}
|
||
|
|
|
||
|
|
// Lösche Backups älter als 14 Tage
|
||
|
|
$cutoffTime = \time() - (14 * 86400);
|
||
|
|
foreach (\glob("{$this->backupDir}/*.gz") ?: [] as $file) {
|
||
|
|
if (\filemtime($file) < $cutoffTime) {
|
||
|
|
\unlink($file);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
foreach (\glob("{$this->backupDir}/*.zip") ?: [] as $file) {
|
||
|
|
if (\filemtime($file) < $cutoffTime) {
|
||
|
|
\unlink($file);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return Command::SUCCESS;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Service in services.yaml binden**
|
||
|
|
|
||
|
|
```yaml
|
||
|
|
# config/services.yaml (ergänzen)
|
||
|
|
App\Infrastructure\Command\BackupCommand:
|
||
|
|
arguments:
|
||
|
|
$backupDir: '%env(BACKUP_DIR)%'
|
||
|
|
$pgDumpDsn: '%env(DATABASE_URL)%'
|
||
|
|
$giteaDataDir: '%env(GITEA_DATA_DIR)%'
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: .env ergänzen**
|
||
|
|
|
||
|
|
```ini
|
||
|
|
# .env (Defaults)
|
||
|
|
BACKUP_DIR=/var/backups/superseller
|
||
|
|
GITEA_DATA_DIR=/var/lib/gitea
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Cron ergänzen**
|
||
|
|
|
||
|
|
Im `cron`-Container-Command (docker-compose.yml) den Backup-Job hinzufügen:
|
||
|
|
|
||
|
|
```yaml
|
||
|
|
command: >
|
||
|
|
sh -c "
|
||
|
|
echo '0 2 * * * cd /var/www && php bin/console app:logs:rotate >> /proc/1/fd/1 2>&1' >> /tmp/crontab
|
||
|
|
echo '0 3 * * * cd /var/www && php bin/console app:backup:run >> /proc/1/fd/1 2>&1' >> /tmp/crontab
|
||
|
|
crontab /tmp/crontab && crond -f -l 2
|
||
|
|
"
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 5: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add src/Infrastructure/Command/BackupCommand.php config/services.yaml .env docker-compose.yml
|
||
|
|
git commit -m "feat: add backup command (pg_dump + gitea dump, 14-day retention)"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 9: Ersten Admin-User anlegen (Console Command)
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `src/Infrastructure/Command/CreateUserCommand.php`
|
||
|
|
|
||
|
|
- [ ] **Step 1: CreateUserCommand implementieren**
|
||
|
|
|
||
|
|
```php
|
||
|
|
<?php
|
||
|
|
// src/Infrastructure/Command/CreateUserCommand.php
|
||
|
|
declare(strict_types=1);
|
||
|
|
|
||
|
|
namespace App\Infrastructure\Command;
|
||
|
|
|
||
|
|
use App\Domain\Auth\Repository\UserRepositoryInterface;
|
||
|
|
use App\Domain\Auth\User;
|
||
|
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||
|
|
use Symfony\Component\Console\Command\Command;
|
||
|
|
use Symfony\Component\Console\Input\InputArgument;
|
||
|
|
use Symfony\Component\Console\Input\InputInterface;
|
||
|
|
use Symfony\Component\Console\Output\OutputInterface;
|
||
|
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||
|
|
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||
|
|
|
||
|
|
#[AsCommand(name: 'app:user:create', description: 'Erstellt einen neuen Admin-Benutzer')]
|
||
|
|
final class CreateUserCommand extends Command
|
||
|
|
{
|
||
|
|
public function __construct(
|
||
|
|
private readonly UserRepositoryInterface $userRepository,
|
||
|
|
private readonly UserPasswordHasherInterface $hasher,
|
||
|
|
) {
|
||
|
|
parent::__construct();
|
||
|
|
}
|
||
|
|
|
||
|
|
protected function configure(): void
|
||
|
|
{
|
||
|
|
$this->addArgument('email', InputArgument::REQUIRED, 'E-Mail-Adresse');
|
||
|
|
}
|
||
|
|
|
||
|
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||
|
|
{
|
||
|
|
$io = new SymfonyStyle($input, $output);
|
||
|
|
|
||
|
|
$email = $input->getArgument('email');
|
||
|
|
\assert(\is_string($email));
|
||
|
|
|
||
|
|
$password = $io->askHidden('Passwort (wird nicht angezeigt)');
|
||
|
|
if (!\is_string($password) || \strlen($password) < 12) {
|
||
|
|
$io->error('Passwort muss mindestens 12 Zeichen haben.');
|
||
|
|
|
||
|
|
return Command::FAILURE;
|
||
|
|
}
|
||
|
|
|
||
|
|
$user = new User($email, 'placeholder');
|
||
|
|
$hash = $this->hasher->hashPassword($user, $password);
|
||
|
|
$user2 = new User($email, $hash);
|
||
|
|
$user2->grantPermission('article:view');
|
||
|
|
$user2->grantPermission('article:edit');
|
||
|
|
$user2->grantPermission('order:view');
|
||
|
|
$user2->grantPermission('log:view');
|
||
|
|
|
||
|
|
$this->userRepository->save($user2);
|
||
|
|
|
||
|
|
$io->success("Benutzer {$email} erstellt. Bitte 2FA unter /totp/setup einrichten.");
|
||
|
|
|
||
|
|
return Command::SUCCESS;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: User anlegen**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
docker compose run --rm app php bin/console app:user:create admin@superseller.local
|
||
|
|
# Passwort eingeben, mindestens 12 Zeichen
|
||
|
|
# Expected: "[OK] Benutzer admin@superseller.local erstellt."
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: Login testen**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
# Browser öffnen: http://localhost/login
|
||
|
|
# E-Mail + Passwort eingeben → sollte zu /admin weiterleiten
|
||
|
|
# (2FA übersprungen, da noch nicht eingerichtet)
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add src/Infrastructure/Command/CreateUserCommand.php
|
||
|
|
git commit -m "feat: add app:user:create console command for initial admin setup"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Selbstreview
|
||
|
|
|
||
|
|
**Spec-Abdeckung:**
|
||
|
|
- Browser-Login mit Form + CSRF ✓ (Task 1)
|
||
|
|
- TOTP 2FA ✓ (Task 3)
|
||
|
|
- API-Key-Auth mit bcrypt + Prefix-Lookup ✓ (Task 2)
|
||
|
|
- PermissionVoter ✓ (Task 4)
|
||
|
|
- EasyAdmin mit Artikel-Freigabe-Workflow ✓ (Task 5)
|
||
|
|
- DatabaseLogHandler → PostgreSQL ✓ (Task 6)
|
||
|
|
- Log-Rotation (90 Tage, DEBUG bleibt nicht im Archiv) ✓ (Task 7)
|
||
|
|
- Backups (pg_dump, Gitea, 14-Tage-Retention) ✓ (Task 8)
|
||
|
|
- Erster Admin-User anlegen ✓ (Task 9)
|
||
|
|
|
||
|
|
**Noch offen:**
|
||
|
|
- Log-Admin-Panel-Suche (tsvector-Fulltext via EasyAdmin custom filter — späterer Enhancement)
|
||
|
|
- API-Endpoints mit `#[IsGranted]` absichern → kann in diesem Plan oder Plan 4/5 ergänzt werden
|