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
|
check_path: /api/login
|
||||||
username_path: email
|
username_path: email
|
||||||
password_path: password
|
password_path: password
|
||||||
success_handler: lexik_jwt_authentication.handler.authentication_success
|
success_handler: App\Security\LoginSuccessHandler
|
||||||
failure_handler: lexik_jwt_authentication.handler.authentication_failure
|
failure_handler: lexik_jwt_authentication.handler.authentication_failure
|
||||||
|
|
||||||
# Geschützte API: JWT im Authorization-Header
|
# 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 {
|
try {
|
||||||
await auth.login(email.value, password.value)
|
await auth.login(email.value, password.value)
|
||||||
router.push((route.query.redirect as string) ?? '/app')
|
router.push((route.query.redirect as string) ?? '/app')
|
||||||
} catch {
|
} catch (e: unknown) {
|
||||||
error.value = 'Anmeldung fehlgeschlagen. Bitte E-Mail und Passwort prüfen.'
|
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 {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user