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:
parent
b8f9a50731
commit
bcd8ba969a
@ -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
|
||||
|
||||
45
backend/src/Security/LoginSuccessHandler.php
Normal file
45
backend/src/Security/LoginSuccessHandler.php
Normal 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);
|
||||
}
|
||||
}
|
||||
56
backend/src/Security/TenantAccessPolicy.php
Normal file
56
backend/src/Security/TenantAccessPolicy.php
Normal 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());
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user