diff --git a/README.md b/README.md index d6003c0..f233e76 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,13 @@ Eine moderne Webanwendung zur Immobilienberechnung, entwickelt mit Symfony 7.3, ## Features - **REST-API**: Vollständige REST-API mit API Platform 4.2 +- **API-Key-Authentifizierung**: Sichere Authentifizierung über eindeutige API-Keys +- **Rollenbasierte Zugriffskontrolle**: USER, ADMIN, MODERATOR mit feingranularen Berechtigungen - **Swagger/OpenAPI**: Automatische interaktive API-Dokumentation (Swagger UI) - **Web-Interface**: Benutzerfreundliche Weboberfläche mit Twig-Templates -- **Immobilien-Management**: Vollständige CRUD-Operationen für Immobilien mit automatischen Berechnungen -- **User Management**: Benutzerverwaltung mit Rollenbasierter Zugriffskontrolle (USER, ADMIN, MODERATOR) +- **Immobilien-Management**: Vollständige CRUD-Operationen mit automatischen Berechnungen +- **Mandantenfähigkeit**: Jeder User sieht nur seine eigenen Immobilien (außer Admins) +- **Öffentliche Ressourcen**: Bundesländer und Heizungstypen ohne Authentifizierung abrufbar - **Docker-Setup**: Vollständig containerisierte Entwicklungsumgebung - **Datenbank-Migrationen**: Versionskontrollierte Datenbankschema-Verwaltung mit Doctrine - **CORS-Unterstützung**: Konfigurierbare CORS-Einstellungen für API-Zugriffe @@ -110,9 +113,22 @@ Wichtigste Endpunkte: | id | INT | Primärschlüssel (Auto-Increment) | | name | VARCHAR(255) | Benutzername (min. 2 Zeichen) | | email | VARCHAR(255) | E-Mail-Adresse (Unique) | -| role | ENUM | Benutzerrolle (user, admin, moderator) | +| role | ENUM | Benutzerrolle (user, admin, moderator, technical) | +| api_key | VARCHAR(64) | API-Key für Authentifizierung (Unique, automatisch generiert) | | created_at | DATETIME | Erstellungsdatum | +**Benutzerrollen:** +- `user` - Normaler Benutzer (sieht nur eigene Immobilien) +- `admin` - Administrator (uneingeschränkter Zugriff auf alle Ressourcen) +- `moderator` - Moderator (erweiterte Rechte) +- `technical` - Technischer User (kann Bundesländer und Heizungstypen verwalten) + +**API-Key:** +- Wird automatisch beim Erstellen eines Users generiert (SHA256-Hash) +- Ist eindeutig für jeden User +- Kann über die Methode `regenerateApiKey()` neu generiert werden +- Wird für die API-Authentifizierung verwendet + ### Bundesland-Tabelle | Feld | Typ | Beschreibung | @@ -393,6 +409,67 @@ services: Die Anwendung nutzt **API Platform** zur automatischen Generierung von OpenAPI/Swagger-Dokumentation. Die interaktive Dokumentation ermöglicht es, alle API-Endpunkte direkt im Browser zu testen. +### API-Authentifizierung + +Die API verwendet **API-Key-basierte Authentifizierung**. Jeder User erhält automatisch beim Erstellen einen eindeutigen API-Key. + +#### API-Key verwenden + +Fügen Sie den API-Key im Header `X-API-KEY` zu allen API-Anfragen hinzu: + +```bash +curl -H "X-API-KEY: your-api-key-here" http://localhost:8080/api/immobilies +``` + +#### API-Key erhalten + +Der API-Key wird automatisch generiert, wenn ein neuer User erstellt wird. Sie können ihn abrufen über: + +```bash +# User erstellen +curl -X POST http://localhost:8080/api/users \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Max Mustermann", + "email": "max@example.com", + "role": "user" + }' + +# Response enthält den API-Key +{ + "@context": "/api/contexts/User", + "@id": "/api/users/1", + "@type": "User", + "id": 1, + "name": "Max Mustermann", + "email": "max@example.com", + "role": "user", + "apiKey": "a1b2c3d4e5f6...", // Verwenden Sie diesen Key für die Authentifizierung + "createdAt": "2025-11-08T22:00:00+00:00" +} +``` + +### Zugriffsrechte + +Die API implementiert folgende Zugriffsrechte: + +| Ressource | Aktion | Berechtigung | +|-----------|--------|--------------| +| **Bundesländer** | GET (Lesen) | Öffentlich (keine Authentifizierung) | +| **Bundesländer** | POST, PUT, DELETE | ADMIN oder TECHNICAL | +| **Heizungstypen** | GET (Lesen) | Öffentlich (keine Authentifizierung) | +| **Heizungstypen** | POST, PUT, DELETE | ADMIN oder TECHNICAL | +| **Immobilien** | GET, POST, PATCH, DELETE | Authentifiziert | +| **Immobilien** | Sichtbarkeit | User sehen nur eigene Immobilien | +| **Immobilien** | Admin-Zugriff | ADMIN sieht alle Immobilien | +| **Users** | Alle Aktionen | Authentifiziert | + +**Wichtig:** +- Normale User sehen und verwalten nur ihre eigenen Immobilien +- Admins haben uneingeschränkten Zugriff auf alle Ressourcen +- Technische User können Bundesländer und Heizungstypen anlegen, ändern und löschen +- Bundesländer und Heizungstypen können ohne API-Key gelesen werden + ### Swagger UI (Interaktive Dokumentation) Die **Swagger UI** bietet eine vollständige, interaktive Dokumentation aller API-Endpunkte: @@ -494,32 +571,53 @@ curl -H "Accept: application/ld+json" http://localhost:8080/api/users ### Beispiele -#### Bundesland erstellen +#### Bundesländer abrufen (Öffentlich, kein API-Key benötigt) + +```bash +curl http://localhost:8080/api/bundeslands +``` + +#### Bundesland erstellen (Admin oder Technical User) ```bash curl -X POST http://localhost:8080/api/bundeslands \ -H "Content-Type: application/json" \ + -H "X-API-KEY: admin-or-technical-api-key" \ -d '{ "name": "Bayern", "grunderwerbsteuer": 3.5 }' ``` -#### Heizungstyp erstellen +#### Heizungstyp erstellen (Admin oder Technical User) ```bash curl -X POST http://localhost:8080/api/heizungstyps \ -H "Content-Type: application/json" \ + -H "X-API-KEY: admin-or-technical-api-key" \ -d '{ "name": "Wärmepumpe" }' ``` -#### Immobilie erstellen +#### Technischen User erstellen + +```bash +curl -X POST http://localhost:8080/api/users \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Technical User", + "email": "technical@example.com", + "role": "technical" + }' +``` + +#### Immobilie erstellen (Mit API-Key) ```bash curl -X POST http://localhost:8080/api/immobilies \ -H "Content-Type: application/json" \ + -H "X-API-KEY: your-api-key-here" \ -d '{ "verwalter": "/api/users/1", "adresse": "Hauptstraße 123, 12345 Musterstadt", @@ -549,21 +647,25 @@ curl -X POST http://localhost:8080/api/users \ }' ``` -#### Immobilien mit Filter abrufen (Pagination) +#### Eigene Immobilien abrufen (Mit API-Key) ```bash -# Erste Seite (Standard) -curl http://localhost:8080/api/immobilies +# Erste Seite (Standard) - User sieht nur eigene Immobilien +curl -H "X-API-KEY: your-api-key-here" http://localhost:8080/api/immobilies # Zweite Seite -curl http://localhost:8080/api/immobilies?page=2 +curl -H "X-API-KEY: your-api-key-here" http://localhost:8080/api/immobilies?page=2 + +# Als Admin: Sieht alle Immobilien +curl -H "X-API-KEY: admin-api-key-here" http://localhost:8080/api/immobilies ``` -#### Einzelne Immobilie aktualisieren (PATCH) +#### Einzelne Immobilie aktualisieren (PATCH, Mit API-Key) ```bash curl -X PATCH http://localhost:8080/api/immobilies/1 \ -H "Content-Type: application/merge-patch+json" \ + -H "X-API-KEY: your-api-key-here" \ -d '{ "wohnflaeche": 90, "kaufpreis": 360000, @@ -596,6 +698,7 @@ Die API verwendet folgende Enums: - `user` - Benutzer - `admin` - Administrator - `moderator` - Moderator +- `technical` - Technischer User ### Relationen diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 367af25..368e055 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -2,28 +2,53 @@ security: # https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords password_hashers: Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto' + # https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider providers: - users_in_memory: { memory: null } + app_user_provider: + entity: + class: App\Entity\User + property: email + firewalls: dev: pattern: ^/(_(profiler|wdt)|css|images|js)/ security: false + + # API-Dokumentation (öffentlich zugänglich) + api_docs: + pattern: ^/api/docs + stateless: true + security: false + + # Öffentliche Endpunkte (Bundesländer, Heizungstypen) + public_api: + pattern: ^/api/(bundeslands|heizungstyps) + stateless: true + security: false + + # API mit API-Key-Authentifizierung + api: + pattern: ^/api + stateless: true + custom_authenticators: + - App\Security\ApiKeyAuthenticator + main: lazy: true - provider: users_in_memory - - # activate different ways to authenticate - # https://symfony.com/doc/current/security.html#the-firewall - - # https://symfony.com/doc/current/security/impersonating_user.html - # switch_user: true + provider: app_user_provider # Easy way to control access for large sections of your site # Note: Only the *first* access control that matches will be used access_control: - # - { path: ^/admin, roles: ROLE_ADMIN } - # - { path: ^/profile, roles: ROLE_USER } + # API-Dokumentation ist öffentlich + - { path: ^/api/docs, roles: PUBLIC_ACCESS } + # Öffentlicher Zugriff auf Bundesländer und Heizungstypen (GET) + - { path: ^/api/(bundeslands|heizungstyps), methods: [GET], roles: PUBLIC_ACCESS } + # Admin und Technical User Zugriff für Änderungen an Bundesländern und Heizungstypen + - { path: ^/api/(bundeslands|heizungstyps), methods: [POST, PUT, DELETE, PATCH], roles: [ROLE_ADMIN, ROLE_TECHNICAL] } + # Alle anderen API-Endpunkte erfordern Authentifizierung + - { path: ^/api, roles: IS_AUTHENTICATED_FULLY } when@test: security: diff --git a/migrations/Version20251108215623.php b/migrations/Version20251108215623.php new file mode 100644 index 0000000..5d82eaf --- /dev/null +++ b/migrations/Version20251108215623.php @@ -0,0 +1,33 @@ +addSql('ALTER TABLE users ADD api_key VARCHAR(64) NOT NULL'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_1483A5E9C912ED9D ON users (api_key)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('DROP INDEX UNIQ_1483A5E9C912ED9D ON users'); + $this->addSql('ALTER TABLE users DROP api_key'); + } +} diff --git a/migrations/bundeslaender_data.sql b/migrations/bundeslaender_data.sql new file mode 100644 index 0000000..ddedcca --- /dev/null +++ b/migrations/bundeslaender_data.sql @@ -0,0 +1,22 @@ +-- Bundesländer mit Grunderwerbsteuer-Sätzen (Stand 2025) +-- Dieses Script wird nach der Migration automatisch ausgeführt + +INSERT INTO bundeslaender (name, grunderwerbsteuer) VALUES + ('Baden-Württemberg', 5.00), + ('Bayern', 3.50), + ('Berlin', 6.00), + ('Brandenburg', 6.50), + ('Bremen', 5.00), + ('Hamburg', 5.50), + ('Hessen', 6.00), + ('Mecklenburg-Vorpommern', 6.00), + ('Niedersachsen', 5.00), + ('Nordrhein-Westfalen', 6.50), + ('Rheinland-Pfalz', 5.00), + ('Saarland', 6.50), + ('Sachsen', 5.50), + ('Sachsen-Anhalt', 5.00), + ('Schleswig-Holstein', 6.50), + ('Thüringen', 5.00) +ON DUPLICATE KEY UPDATE + grunderwerbsteuer = VALUES(grunderwerbsteuer); diff --git a/src/Doctrine/CurrentUserExtension.php b/src/Doctrine/CurrentUserExtension.php new file mode 100644 index 0000000..a331a74 --- /dev/null +++ b/src/Doctrine/CurrentUserExtension.php @@ -0,0 +1,57 @@ +addWhere($queryBuilder, $resourceClass); + } + + public function applyToItem(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, array $identifiers, Operation $operation = null, array $context = []): void + { + $this->addWhere($queryBuilder, $resourceClass); + } + + private function addWhere(QueryBuilder $queryBuilder, string $resourceClass): void + { + // Nur für Immobilie-Entity + if (Immobilie::class !== $resourceClass) { + return; + } + + $user = $this->security->getUser(); + + // Wenn nicht eingeloggt, keine Ergebnisse + if (!$user instanceof User) { + $queryBuilder->andWhere('1 = 0'); + return; + } + + // Admin sieht alles + if ($user->getRole() === UserRole::ADMIN) { + return; + } + + // Normale User sehen nur eigene Immobilien + $rootAlias = $queryBuilder->getRootAliases()[0]; + $queryBuilder->andWhere(sprintf('%s.verwalter = :current_user', $rootAlias)) + ->setParameter('current_user', $user); + } +} diff --git a/src/Entity/Bundesland.php b/src/Entity/Bundesland.php index 08086c1..9cf670b 100644 --- a/src/Entity/Bundesland.php +++ b/src/Entity/Bundesland.php @@ -16,11 +16,11 @@ use Symfony\Component\Validator\Constraints as Assert; #[ORM\Table(name: 'bundeslaender')] #[ApiResource( operations: [ - new Get(), - new GetCollection(), - new Post(), - new Put(), - new Delete() + new Get(security: 'is_granted("PUBLIC_ACCESS")'), + new GetCollection(security: 'is_granted("PUBLIC_ACCESS")'), + new Post(security: 'is_granted("ROLE_ADMIN") or is_granted("ROLE_TECHNICAL")'), + new Put(security: 'is_granted("ROLE_ADMIN") or is_granted("ROLE_TECHNICAL")'), + new Delete(security: 'is_granted("ROLE_ADMIN") or is_granted("ROLE_TECHNICAL")') ] )] class Bundesland diff --git a/src/Entity/Heizungstyp.php b/src/Entity/Heizungstyp.php index f7e9c14..10f1051 100644 --- a/src/Entity/Heizungstyp.php +++ b/src/Entity/Heizungstyp.php @@ -16,11 +16,11 @@ use Symfony\Component\Validator\Constraints as Assert; #[ORM\Table(name: 'heizungstypen')] #[ApiResource( operations: [ - new Get(), - new GetCollection(), - new Post(), - new Put(), - new Delete() + new Get(security: 'is_granted("PUBLIC_ACCESS")'), + new GetCollection(security: 'is_granted("PUBLIC_ACCESS")'), + new Post(security: 'is_granted("ROLE_ADMIN") or is_granted("ROLE_TECHNICAL")'), + new Put(security: 'is_granted("ROLE_ADMIN") or is_granted("ROLE_TECHNICAL")'), + new Delete(security: 'is_granted("ROLE_ADMIN") or is_granted("ROLE_TECHNICAL")') ] )] class Heizungstyp diff --git a/src/Entity/Immobilie.php b/src/Entity/Immobilie.php index be15cd7..dc09b1e 100644 --- a/src/Entity/Immobilie.php +++ b/src/Entity/Immobilie.php @@ -3,6 +3,11 @@ namespace App\Entity; use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Delete; use App\Enum\ImmobilienTyp; use App\Repository\ImmobilieRepository; use Doctrine\ORM\Mapping as ORM; @@ -11,7 +16,16 @@ use Symfony\Component\Validator\Constraints as Assert; #[ORM\Entity(repositoryClass: ImmobilieRepository::class)] #[ORM\Table(name: 'immobilien')] #[ORM\HasLifecycleCallbacks] -#[ApiResource] +#[ApiResource( + operations: [ + new Get(security: 'is_granted("view", object)'), + new GetCollection(), + new Post(), + new Patch(security: 'is_granted("edit", object)'), + new Delete(security: 'is_granted("delete", object)') + ], + security: 'is_granted("ROLE_USER")' +)] class Immobilie { #[ORM\Id] @@ -298,7 +312,7 @@ class Immobilie */ public function getKaufnebenkosten(): array { - if (!$this->kaufpreis || !$this->bundesland) { + if (!$this->getKaufpreis() || !$this->bundesland) { return [ 'notar' => 0, 'grundbuch' => 0, @@ -308,14 +322,14 @@ class Immobilie } // Notarkosten: ca. 1,5% des Kaufpreises - $notar = (int) round($this->kaufpreis * 0.015); + $notar = $this->getKaufpreis() * 0.015; // Grundbuchkosten: ca. 0,5% des Kaufpreises - $grundbuch = (int) round($this->kaufpreis * 0.005); + $grundbuch = $this->getKaufpreis() * 0.005; // Grunderwerbsteuer: abhängig vom Bundesland $grunderwerbsteuerSatz = $this->bundesland->getGrunderwerbsteuer() / 100; - $grunderwerbsteuer = (int) round($this->kaufpreis * $grunderwerbsteuerSatz); + $grunderwerbsteuer = $this->getKaufpreis() * $grunderwerbsteuerSatz; $gesamt = $notar + $grundbuch + $grunderwerbsteuer; diff --git a/src/Entity/User.php b/src/Entity/User.php index aa9123d..07f6c21 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -13,6 +13,7 @@ use App\Repository\UserRepository; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Validator\Constraints as Assert; #[ORM\Entity(repositoryClass: UserRepository::class)] @@ -26,7 +27,7 @@ use Symfony\Component\Validator\Constraints as Assert; new Delete() ] )] -class User +class User implements UserInterface { #[ORM\Id] #[ORM\GeneratedValue] @@ -46,6 +47,9 @@ class User #[ORM\Column(type: 'string', enumType: UserRole::class)] private UserRole $role; + #[ORM\Column(type: 'string', length: 64, unique: true)] + private string $apiKey; + #[ORM\Column(type: 'datetime')] private \DateTimeInterface $createdAt; @@ -57,6 +61,15 @@ class User $this->createdAt = new \DateTime(); $this->role = UserRole::USER; $this->immobilien = new ArrayCollection(); + $this->apiKey = $this->generateApiKey(); + } + + /** + * Generiert einen eindeutigen API-Key + */ + private function generateApiKey(): string + { + return hash('sha256', random_bytes(32) . microtime(true)); } public function getId(): ?int @@ -131,4 +144,46 @@ class User $this->immobilien->removeElement($immobilie); return $this; } + + public function getApiKey(): string + { + return $this->apiKey; + } + + /** + * Generiert einen neuen API-Key + */ + public function regenerateApiKey(): self + { + $this->apiKey = $this->generateApiKey(); + return $this; + } + + /** + * UserInterface Methods + */ + public function getUserIdentifier(): string + { + return $this->email; + } + + public function getRoles(): array + { + $roles = ['ROLE_USER']; + + if ($this->role === UserRole::ADMIN) { + $roles[] = 'ROLE_ADMIN'; + } elseif ($this->role === UserRole::MODERATOR) { + $roles[] = 'ROLE_MODERATOR'; + } elseif ($this->role === UserRole::TECHNICAL) { + $roles[] = 'ROLE_TECHNICAL'; + } + + return array_unique($roles); + } + + public function eraseCredentials(): void + { + // Nothing to erase as we use API keys + } } diff --git a/src/Enum/UserRole.php b/src/Enum/UserRole.php index ba567c1..25623f3 100644 --- a/src/Enum/UserRole.php +++ b/src/Enum/UserRole.php @@ -7,6 +7,7 @@ enum UserRole: string case USER = 'user'; case ADMIN = 'admin'; case MODERATOR = 'moderator'; + case TECHNICAL = 'technical'; public function getLabel(): string { @@ -14,6 +15,7 @@ enum UserRole: string self::USER => 'Benutzer', self::ADMIN => 'Administrator', self::MODERATOR => 'Moderator', + self::TECHNICAL => 'Technischer User', }; } } diff --git a/src/Security/ApiKeyAuthenticator.php b/src/Security/ApiKeyAuthenticator.php new file mode 100644 index 0000000..a45f4c1 --- /dev/null +++ b/src/Security/ApiKeyAuthenticator.php @@ -0,0 +1,62 @@ +headers->has('X-API-KEY'); + } + + public function authenticate(Request $request): Passport + { + $apiKey = $request->headers->get('X-API-KEY'); + + if (null === $apiKey) { + throw new CustomUserMessageAuthenticationException('No API key provided'); + } + + return new SelfValidatingPassport( + new UserBadge($apiKey, function($apiKey) { + $user = $this->userRepository->findOneBy(['apiKey' => $apiKey]); + + if (!$user) { + throw new CustomUserMessageAuthenticationException('Invalid API key'); + } + + return $user; + }) + ); + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response + { + // On success, let the request continue + return null; + } + + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response + { + return new JsonResponse([ + 'message' => strtr($exception->getMessageKey(), $exception->getMessageData()) + ], Response::HTTP_UNAUTHORIZED); + } +} diff --git a/src/Security/ImmobilieVoter.php b/src/Security/ImmobilieVoter.php new file mode 100644 index 0000000..35cbf60 --- /dev/null +++ b/src/Security/ImmobilieVoter.php @@ -0,0 +1,67 @@ +getUser(); + + // User muss eingeloggt sein + if (!$user instanceof User) { + return false; + } + + /** @var Immobilie $immobilie */ + $immobilie = $subject; + + // Admin hat uneingeschränkten Zugriff + if ($user->getRole() === UserRole::ADMIN) { + return true; + } + + // Prüfe je nach Attribut + return match($attribute) { + self::VIEW => $this->canView($immobilie, $user), + self::EDIT => $this->canEdit($immobilie, $user), + self::DELETE => $this->canDelete($immobilie, $user), + default => false + }; + } + + private function canView(Immobilie $immobilie, User $user): bool + { + // User kann nur eigene Immobilien sehen + return $immobilie->getVerwalter() === $user; + } + + private function canEdit(Immobilie $immobilie, User $user): bool + { + // User kann nur eigene Immobilien bearbeiten + return $immobilie->getVerwalter() === $user; + } + + private function canDelete(Immobilie $immobilie, User $user): bool + { + // User kann nur eigene Immobilien löschen + return $immobilie->getVerwalter() === $user; + } +}