feat: add a dev-only login and seed command
This commit is contained in:
1 parent
8ae303d1bc
commit
ca57df7437
8 files changed
+325
-26
No files matched your search
@@ -1,39 +1,37 @@
|
||||
security:
|
||||
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
|
||||
password_hashers:
|
||||
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
|
||||
# No password_hashers: there are no passwords here. Authentication is OAuth2 against myep
|
||||
# (plan step 6); until that lands there is no way to log in outside dev.
|
||||
|
||||
# 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:
|
||||
# Ensure dev tools and static assets are always allowed
|
||||
pattern: ^/(_profiler|_wdt|assets|build)/
|
||||
pattern: ^/(_profiler|_wdt|assets)/
|
||||
security: false
|
||||
main:
|
||||
lazy: true
|
||||
provider: users_in_memory
|
||||
provider: app_user_provider
|
||||
logout:
|
||||
path: app_logout
|
||||
|
||||
# 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
|
||||
|
||||
# Note: Only the *first* matching rule is applied
|
||||
access_control:
|
||||
# - { path: ^/admin, roles: ROLE_ADMIN }
|
||||
# - { path: ^/profile, roles: ROLE_USER }
|
||||
# ROLE_EMPLOYEE is the gate, not ROLE_USER: myep authenticates more people than are staff,
|
||||
# and only staff may book a desk here.
|
||||
- { path: ^/dev/, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/, roles: ROLE_EMPLOYEE }
|
||||
|
||||
when@test:
|
||||
when@dev:
|
||||
security:
|
||||
password_hashers:
|
||||
# Password hashers are resource-intensive by design to ensure security.
|
||||
# In tests, it's safe to reduce their cost to improve performance.
|
||||
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
|
||||
algorithm: auto
|
||||
cost: 4 # Lowest possible value for bcrypt
|
||||
time_cost: 3 # Lowest possible value for argon
|
||||
memory_cost: 10 # Lowest possible value for argon
|
||||
firewalls:
|
||||
main:
|
||||
# Real login is blocked on OAuth2 client registration on myep. Until then
|
||||
# /dev/login lists the seeded users and logs one in without credentials.
|
||||
# The entry point, the route and the controller are all dev-only, so this
|
||||
# cannot leak into another environment by accident.
|
||||
entry_point: App\Security\DevLoginEntryPoint
|
||||
custom_authenticators:
|
||||
- App\Security\DevLoginAuthenticator
|
||||
@@ -0,0 +1,7 @@
|
||||
# Dev-only stand-in for the OAuth2 login. The controller carries no #[Route] attribute, so
|
||||
# outside dev this route simply does not exist.
|
||||
when@dev:
|
||||
app_dev_login:
|
||||
path: /dev/login
|
||||
controller: App\Controller\DevLoginController::login
|
||||
methods: [GET, POST]
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Entity\Resource;
|
||||
use App\Entity\ResourceType;
|
||||
use App\Entity\User;
|
||||
use App\Repository\ResourceRepository;
|
||||
use App\Repository\UserRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
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;
|
||||
use Symfony\Component\DependencyInjection\Attribute\When;
|
||||
|
||||
/**
|
||||
* Seeds the desks, parking spots and a handful of employees.
|
||||
*
|
||||
* Dev only, and idempotent: it matches on the natural key (resource label, user email) so it can
|
||||
* be re-run after a migration without duplicating anything. The resource svgElementId values are
|
||||
* the contract with templates/dashboard/_floorplan.html.twig -- change one and the other stops
|
||||
* drawing that resource.
|
||||
*/
|
||||
#[When('dev')]
|
||||
#[AsCommand(name: 'app:seed', description: 'Seeds desks, parking spots and demo employees (dev only)')]
|
||||
final class SeedCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly ResourceRepository $resources,
|
||||
private readonly UserRepository $users,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$created = 0;
|
||||
|
||||
for ($i = 1; $i <= 12; ++$i) {
|
||||
$created += $this->ensureResource(ResourceType::DESK, \sprintf('Schreibtisch %02d', $i), \sprintf('desk-%02d', $i));
|
||||
}
|
||||
|
||||
for ($i = 1; $i <= 6; ++$i) {
|
||||
$created += $this->ensureResource(ResourceType::PARKING, \sprintf('Parkplatz %02d', $i), \sprintf('parking-%02d', $i));
|
||||
}
|
||||
|
||||
foreach ([
|
||||
['[email protected]', 'Anna Berger'],
|
||||
['[email protected]', 'Ben Kuhn'],
|
||||
['[email protected]', 'Clara Dietz'],
|
||||
['[email protected]', 'David Roth'],
|
||||
['[email protected]', 'Elif Yılmaz'],
|
||||
] as [$email, $name]) {
|
||||
$created += $this->ensureUser($email, $name);
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
$io->success(\sprintf('%d neue Datensätze angelegt.', $created));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function ensureResource(ResourceType $type, string $label, string $svgElementId): int
|
||||
{
|
||||
if (null !== $this->resources->findOneBy(['type' => $type, 'label' => $label])) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->em->persist(new Resource($type, $label, $svgElementId));
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
private function ensureUser(string $email, string $name): int
|
||||
{
|
||||
if (null !== $this->users->findOneBy(['email' => $email])) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$user = new User($email, $name);
|
||||
// Mirrors what the OAuth2 authenticator will do: the remote roles claim is filtered
|
||||
// through a local whitelist, and ROLE_EMPLOYEE is what the access_control gate wants.
|
||||
$user->setRoles(['ROLE_EMPLOYEE']);
|
||||
$this->em->persist($user);
|
||||
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Repository\UserRepository;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\DependencyInjection\Attribute\When;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Dev-only "log in as" picker, standing in for OAuth2 against myep until the client is
|
||||
* registered there (plan step 6).
|
||||
*
|
||||
* Deliberately carries no #[Route] attribute: the routes are declared in
|
||||
* config/routes/dev_login.yaml under `when@dev`, so this cannot be reached in any other
|
||||
* environment even if the class were somehow registered.
|
||||
*/
|
||||
#[When('dev')]
|
||||
final class DevLoginController extends AbstractController
|
||||
{
|
||||
public function __construct(private readonly UserRepository $users)
|
||||
{
|
||||
}
|
||||
|
||||
public function login(): Response
|
||||
{
|
||||
if ($this->getUser()) {
|
||||
return $this->redirectToRoute('app_dashboard');
|
||||
}
|
||||
|
||||
// POST never reaches here -- DevLoginAuthenticator intercepts it on the firewall.
|
||||
return $this->render('dev/login.html.twig', [
|
||||
'users' => $this->users->findBy([], ['name' => 'ASC']),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
/**
|
||||
* Gains /auth/init and /auth/check when OAuth2 against myep lands (plan step 6).
|
||||
*/
|
||||
final class SecurityController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* Never executed: the firewall's logout listener intercepts this route.
|
||||
*/
|
||||
#[Route('/logout', name: 'app_logout', methods: ['GET'])]
|
||||
public function logout(): never
|
||||
{
|
||||
throw new \LogicException('Intercepted by the logout key on the main firewall.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Security;
|
||||
|
||||
use App\Repository\UserRepository;
|
||||
use Symfony\Component\DependencyInjection\Attribute\When;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Logs in whoever was picked on /dev/login, with no credentials at all.
|
||||
*
|
||||
* Dev only. It deliberately has the same shape the real OAuth2 authenticator will take in plan
|
||||
* step 6 -- supports() matches one route, authenticate() returns a SelfValidatingPassport -- so
|
||||
* swapping it out is a deletion rather than a rewrite.
|
||||
*/
|
||||
#[When('dev')]
|
||||
final class DevLoginAuthenticator extends AbstractAuthenticator
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserRepository $users,
|
||||
private readonly UrlGeneratorInterface $urlGenerator,
|
||||
) {
|
||||
}
|
||||
|
||||
public function supports(Request $request): bool
|
||||
{
|
||||
return 'app_dev_login' === $request->attributes->get('_route') && $request->isMethod('POST');
|
||||
}
|
||||
|
||||
public function authenticate(Request $request): Passport
|
||||
{
|
||||
$id = $request->request->getInt('user');
|
||||
|
||||
return new SelfValidatingPassport(new UserBadge((string) $id, function (string $identifier) {
|
||||
$user = $this->users->find((int) $identifier);
|
||||
|
||||
if (null === $user) {
|
||||
throw new CustomUserMessageAuthenticationException('Unbekannter Benutzer.');
|
||||
}
|
||||
|
||||
return $user;
|
||||
}));
|
||||
}
|
||||
|
||||
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
|
||||
{
|
||||
return new RedirectResponse($this->urlGenerator->generate('app_dashboard'));
|
||||
}
|
||||
|
||||
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
|
||||
{
|
||||
$request->getSession()->getFlashBag()->add('error', $exception->getMessageKey());
|
||||
|
||||
return new RedirectResponse($this->urlGenerator->generate('app_dev_login'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Security;
|
||||
|
||||
use Symfony\Component\DependencyInjection\Attribute\When;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
use Symfony\Component\Security\Core\Exception\AuthenticationException;
|
||||
use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface;
|
||||
|
||||
/**
|
||||
* Sends an unauthenticated visitor to the dev login picker.
|
||||
*
|
||||
* Dev only -- it exists so UI work is not gated on the OAuth2 client registration on myep.
|
||||
* When the real authenticator lands (plan step 6) the entry point for every environment becomes
|
||||
* the OAuth2 init route and this class goes away.
|
||||
*/
|
||||
#[When('dev')]
|
||||
final readonly class DevLoginEntryPoint implements AuthenticationEntryPointInterface
|
||||
{
|
||||
public function __construct(private UrlGeneratorInterface $urlGenerator)
|
||||
{
|
||||
}
|
||||
|
||||
public function start(Request $request, ?AuthenticationException $authException = null): Response
|
||||
{
|
||||
return new RedirectResponse($this->urlGenerator->generate('app_dev_login'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
|
||||
{% block title %}Anmelden · ephub{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="min-h-screen flex items-center justify-center px-4">
|
||||
<div class="w-full max-w-md">
|
||||
<img class="h-8 w-auto mx-auto mb-8" src="{{ asset('images/logo_ep.svg') }}" alt="ephub">
|
||||
|
||||
<div class="rounded-md border border-amber-300 bg-amber-50 px-4 py-3 text-sm mb-6">
|
||||
<strong>Nur für die Entwicklung.</strong>
|
||||
Die echte Anmeldung läuft über myep (OAuth2) und wartet auf die Client-Registrierung dort.
|
||||
</div>
|
||||
|
||||
{% if users is empty %}
|
||||
<p class="text-sm">
|
||||
Keine Benutzer vorhanden. <code class="font-mono">bin/console app:seed</code> ausführen.
|
||||
</p>
|
||||
{% else %}
|
||||
<form method="post" class="space-y-2">
|
||||
{% for user in users %}
|
||||
<button type="submit" name="user" value="{{ user.id }}"
|
||||
class="w-full text-left rounded-md border border-gray-200 px-4 py-3 hover:bg-gray-50">
|
||||
<span class="block font-semibold">{{ user.name }}</span>
|
||||
<span class="block text-sm text-gray-500">{{ user.email }}</span>
|
||||
</button>
|
||||
{% endfor %}
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<div class="fixed bottom-4 right-4 z-50 space-y-2">
|
||||
{% include '_partials/_flashes.html.twig' %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in new issue
Block a user