White-Label Phase 2: Login-Scoping je Host

- TenantAccessPolicy: Plattform-Host nur Plattform-/Reseller-Admins,
  Reseller-Host nur dessen Nutzer, Firmen-Host nur Firmen-Nutzer
  (+ zugehöriger Reseller-Admin); Plattform-Admin überall.
- LoginSuccessHandler prüft vor JWT-Ausstellung → 403 bei falschem Host.
- Login zeigt die 403-Hinweismeldung.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Thomas Peterson 2026-06-09 14:40:20 +02:00
parent b8f9a50731
commit bcd8ba969a
4 changed files with 107 additions and 3 deletions

View File

@ -21,7 +21,7 @@ security:
check_path: /api/login
username_path: email
password_path: password
success_handler: lexik_jwt_authentication.handler.authentication_success
success_handler: App\Security\LoginSuccessHandler
failure_handler: lexik_jwt_authentication.handler.authentication_failure
# Geschützte API: JWT im Authorization-Header

View File

@ -0,0 +1,45 @@
<?php
namespace App\Security;
use App\Entity\Employee;
use App\Service\ResolvedTenant;
use App\Service\TenantResolver;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface;
/**
* Prüft beim Login, ob der Nutzer auf dem aufgerufenen Host (White-Label-Tenant)
* überhaupt anmeldeberechtigt ist sonst 403. Danach erst stellt der Lexik-
* Handler das JWT aus. Wird in security.yaml als success_handler eingehängt.
*/
final class LoginSuccessHandler implements AuthenticationSuccessHandlerInterface
{
public function __construct(
#[Autowire(service: 'lexik_jwt_authentication.handler.authentication_success')]
private readonly AuthenticationSuccessHandlerInterface $inner,
private readonly TenantResolver $resolver,
private readonly TenantAccessPolicy $policy,
) {
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token): Response
{
$user = $token->getUser();
if ($user instanceof Employee) {
$tenant = $this->resolver->resolve($request->getHost()) ?? ResolvedTenant::platform();
if (!$this->policy->canLogin($user, $tenant)) {
return new JsonResponse(
['message' => 'Für diese Adresse besteht kein Zugang. Bitte die richtige Login-Adresse verwenden.'],
Response::HTTP_FORBIDDEN,
);
}
}
return $this->inner->onAuthenticationSuccess($request, $token);
}
}

View File

@ -0,0 +1,56 @@
<?php
namespace App\Security;
use App\Entity\Employee;
use App\Service\ResolvedTenant;
/**
* Entscheidet, ob ein Mitarbeiter sich auf dem Host eines bestimmten Tenants
* anmelden darf (White-Label-Scoping, KONZEPT §11):
* - Plattform-Host: nur Plattform- und Reseller-Admins (Firmen nutzen ihre Subdomain).
* - Reseller-Host: alle Nutzer dieses Resellers (inkl. seiner Firmen).
* - Firmen-Host: nur Nutzer dieser Firma (plus deren Reseller-Admin).
* Plattform-Admins dürfen überall (Support/Verwaltung).
*/
final class TenantAccessPolicy
{
public function canLogin(Employee $user, ResolvedTenant $tenant): bool
{
$roles = $user->getRoles();
$isPlatform = \in_array(Employee::ROLE_PLATFORM_ADMIN, $roles, true);
$isReseller = \in_array(Employee::ROLE_RESELLER_ADMIN, $roles, true);
if ($isPlatform) {
return true;
}
$userReseller = $user->getReseller();
$userCompany = $user->getCompany();
return match ($tenant->kind) {
ResolvedTenant::KIND_PLATFORM => $isReseller,
ResolvedTenant::KIND_RESELLER => null !== $userReseller
&& null !== $tenant->reseller
&& $userReseller->getId()->equals($tenant->reseller->getId()),
ResolvedTenant::KIND_COMPANY => $this->canLoginCompany($userCompany, $userReseller, $isReseller, $tenant),
default => false,
};
}
private function canLoginCompany(
?\App\Entity\Company $userCompany,
?\App\Entity\Reseller $userReseller,
bool $isReseller,
ResolvedTenant $tenant,
): bool {
if (null !== $userCompany && null !== $tenant->company
&& $userCompany->getId()->equals($tenant->company->getId())) {
return true;
}
// Reseller-Admin des zugehörigen Resellers darf den Firmen-Host ebenfalls nutzen
return $isReseller && null !== $userReseller && null !== $tenant->reseller
&& $userReseller->getId()->equals($tenant->reseller->getId());
}
}

View File

@ -20,8 +20,11 @@ async function submit() {
try {
await auth.login(email.value, password.value)
router.push((route.query.redirect as string) ?? '/app')
} catch {
error.value = 'Anmeldung fehlgeschlagen. Bitte E-Mail und Passwort prüfen.'
} catch (e: unknown) {
const err = e as { response?: { status?: number; data?: { message?: string } } }
error.value = err.response?.status === 403 && err.response.data?.message
? err.response.data.message
: 'Anmeldung fehlgeschlagen. Bitte E-Mail und Passwort prüfen.'
} finally {
loading.value = false
}