musikhood / auth-client-bundle
Symfony bundle for editor_v3 cookie-based auth (BEARER + refresh_token, JWT/JWKS validation, user mirror).
Package info
github.com/musikhood/auth-client-bundle
Type:symfony-bundle
pkg:composer/musikhood/auth-client-bundle
Requires
- php: >=8.2
- ext-openssl: *
- firebase/php-jwt: ^6.10 || ^7.0
- psr/cache: ^2.0 || ^3.0
- psr/log: ^2.0 || ^3.0
- ramsey/uuid: ^4.7
- symfony/cache-contracts: ^2.5 || ^3.0
- symfony/config: ^6.4 || ^7.0
- symfony/dependency-injection: ^6.4 || ^7.0
- symfony/event-dispatcher: ^6.4 || ^7.0
- symfony/framework-bundle: ^6.4 || ^7.0
- symfony/http-client: ^6.4 || ^7.0
- symfony/http-client-contracts: ^2.5 || ^3.0
- symfony/http-foundation: ^6.4 || ^7.0
- symfony/http-kernel: ^6.4 || ^7.0
- symfony/routing: ^6.4 || ^7.0
- symfony/security-bundle: ^6.4 || ^7.0
- symfony/security-core: ^6.4 || ^7.0
- symfony/security-http: ^6.4 || ^7.0
- symfony/yaml: ^6.4 || ^7.0
Requires (Dev)
- phpstan/phpstan: ^1.11
- phpstan/phpstan-symfony: ^1.4
README
Symfony bundle dla mikroserwisów, które delegują uwierzytelnianie do zewnętrznego auth servera (editor_v3) i wystawiają front-endowi spójny kontrakt oparty na ciasteczkach HttpOnly.
Bundle obsługuje całą warstwę HTTP — endpointy /api/login, /api/logout,
/api/token/refresh, /api/v1/user/me — walidację JWT/JWKS, kontrakt
ciasteczek (BEARER + refresh_token, oba HttpOnly), lokalną kopię
użytkownika z lazy upsert oraz listener z circuit breakerem, który co 30 s
weryfikuje sesję w auth serverze.
Front nigdy nie widzi JWT. Używa withCredentials: true plus interceptora
axiosa, który na 401 woła /api/token/refresh. Front napisany przeciw
samemu auth serverowi działa bez zmian z dowolnym mikroserwisem
korzystającym z tej paczki.
Wymagania
- PHP
>=8.2 - Symfony
^6.4 || ^7.0(security-bundle, framework-bundle, http-client) - ORM po stronie konsumenta (zalecany Doctrine) — paczka dostarcza tylko
interfejsy (
PanelUserInterface,PanelUserRepositoryInterface); konkretną encję i repozytorium tworzy konsument.
Instalacja
1. Dodaj endpoint Symfony Flex
Bundle dostarcza recipe Symfony Flex, które konfiguruje bundles.php,
config/packages/auth_client.yaml, import tras i zmienne środowiskowe.
Recipe siedzi w
musikhood/symfony-recipes.
Dodaj endpoint do composer.json w aplikacji konsumenta (jednorazowo):
{
"extra": {
"symfony": {
"endpoint": [
"https://api.github.com/repos/musikhood/symfony-recipes/contents/index.json",
"flex://defaults"
]
}
}
}
Zostaw flex://defaults po swoim endpoincie — bez tego nie będą działać
oficjalne recipes Symfony (Doctrine, Mailer itd.).
2. Zainstaluj paczkę
composer require musikhood/auth-client-bundle:^0.1
Flex zrobi automatycznie:
- zarejestruje
Musikhood\AuthClient\AuthClientBundlewconfig/bundles.php - utworzy
config/packages/auth_client.yamlz szablonem na zmienne środowiskowe - utworzy
config/routes/auth_client.yamlimportujący trasy paczki - doda klucze
AUTH_*do.env - wyświetli listę kroków, które musisz dokończyć ręcznie (sekcja 3 niżej)
Jeśli composer require nie pokaże komunikatu post-install, prawdopodobnie:
- Flex nie jest zainstalowany w konsumencie (
composer require symfony/flex) - brakuje konfiguracji endpointu z kroku 1
- paczka była już zainstalowana wcześniej — zrób
composer remove musikhood/auth-client-bundle && composer clear-cachei powtórzrequire
3. Kroki, które musisz wykonać ręcznie
Recipe robi tylko to, co bezpiecznie da się zautomatyzować. Pięć rzeczy wymaga jeszcze Twojej ręki — wszystkie dotykają miejsc specyficznych dla projektu, których recipe nie może zgadnąć.
3.1. Ustaw zmienne środowiskowe
W .env.local (albo Twoim secrets manager):
AUTH_BASE_URL=https://auth.twoja-domena.com AUTH_PANEL_ID=01234567-89ab-cdef-0123-456789abcdef AUTH_CLIENT_ID=<z panelu admin auth servera> AUTH_CLIENT_SECRET=<z panelu admin auth servera> AUTH_COOKIE_SECURE=1
AUTH_COOKIE_SECURE ustaw na 1 w produkcji (HTTPS), 0 tylko dla
lokalnego deva po HTTP.
3.2. Stwórz encję User
Paczka nigdy nie pisze do tabeli użytkowników — to robi konsument.
Zaimplementuj Musikhood\AuthClient\Contract\PanelUserInterface
(referencja: docs/example-entity.php):
// src/Entity/User.php namespace App\Entity; use Doctrine\ORM\Mapping as ORM; use Musikhood\AuthClient\Contract\PanelUserInterface; use Ramsey\Uuid\UuidInterface; #[ORM\Entity(repositoryClass: \App\Repository\UserRepository::class)] #[ORM\Table(name: 'users')] #[ORM\UniqueConstraint(name: 'uniq_users_email', columns: ['email'])] class User implements PanelUserInterface { #[ORM\Id] #[ORM\Column(type: 'uuid', unique: true)] private UuidInterface $id; #[ORM\Column(type: 'string', length: 180, unique: true)] private string $email; #[ORM\Column(type: 'string', length: 255, nullable: true)] private ?string $displayName = null; /** @var list<string> */ #[ORM\Column(type: 'json')] private array $rolesForPanel = []; #[ORM\Column(type: 'boolean', options: ['default' => false])] private bool $disabled = false; #[ORM\Column(type: 'datetime_immutable')] private \DateTimeImmutable $lastSyncedAt; private function __construct() {} /** @param list<string> $rolesForPanel */ public static function create( UuidInterface $id, string $email, ?string $displayName, array $rolesForPanel, ): self { $user = new self(); $user->id = $id; $user->email = $email; $user->displayName = $displayName; $user->rolesForPanel = array_values($rolesForPanel); $user->disabled = false; $user->lastSyncedAt = new \DateTimeImmutable(); return $user; } /** @param list<string> $rolesForPanel */ public function syncFromClaims(string $email, ?string $displayName, array $rolesForPanel): void { $this->email = $email; $this->displayName = $displayName; $this->rolesForPanel = array_values($rolesForPanel); $this->lastSyncedAt = new \DateTimeImmutable(); } public function markDisabled(bool $disabled): void { $this->disabled = $disabled; } public function getId(): UuidInterface { return $this->id; } public function getEmail(): string { return $this->email; } public function getDisplayName(): ?string { return $this->displayName; } /** @return list<string> */ public function getRolesForPanel(): array { return $this->rolesForPanel; } public function isDisabled(): bool { return $this->disabled; } /** @return list<string> */ public function getRoles(): array { $roles = ['ROLE_USER']; foreach ($this->rolesForPanel as $r) { $roles[] = 'ROLE_' . $r; } return array_values(array_unique($roles)); } public function getUserIdentifier(): string { return $this->email; } public function eraseCredentials(): void {} }
Jeśli używasz innej lokalizacji niż src/Entity/ (np. DDD ze strukturą
src/Domain/User/Entity/User.php), umieść klasę gdziekolwiek — paczka
patrzy tylko na kontrakt PanelUserInterface.
3.3. Stwórz UserRepository
Zaimplementuj Musikhood\AuthClient\Contract\PanelUserRepositoryInterface
i powiąż go z interfejsem przez atrybut #[AsAlias]. Dzięki temu nie
musisz nic dopisywać do services.yaml — Symfony sam podepnie repo pod
interfejs.
Referencja: docs/example-repository.php.
// src/Repository/UserRepository.php namespace App\Repository; use App\Entity\User; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; use Musikhood\AuthClient\Contract\PanelUserInterface; use Musikhood\AuthClient\Contract\PanelUserRepositoryInterface; use Ramsey\Uuid\UuidInterface; use Symfony\Component\DependencyInjection\Attribute\AsAlias; /** @extends ServiceEntityRepository<User> */ #[AsAlias(id: PanelUserRepositoryInterface::class)] class UserRepository extends ServiceEntityRepository implements PanelUserRepositoryInterface { public function __construct(ManagerRegistry $registry) { parent::__construct($registry, User::class); } public function findById(UuidInterface $id): ?PanelUserInterface { return $this->find($id); } public function findByEmail(string $email): ?PanelUserInterface { return $this->findOneBy(['email' => $email]); } public function save(PanelUserInterface $user): void { $this->getEntityManager()->persist($user); } public function flush(): void { $this->getEntityManager()->flush(); } public function createFromClaims( UuidInterface $id, string $email, ?string $displayName, array $rolesForPanel, ): PanelUserInterface { return User::create($id, $email, $displayName, $rolesForPanel); } }
Atrybut #[AsAlias] wymaga Symfony 6.1+. Jeśli z jakiegoś powodu wolisz
mapowanie w YAML-u, zamiast atrybutu dodaj do config/services.yaml:
services: Musikhood\AuthClient\Contract\PanelUserRepositoryInterface: alias: App\Repository\UserRepository
Paczka rozwiązuje PanelUserRepositoryInterface z kontenera DI — bez
jednego z tych dwóch wariantów authenticator i MeController wywalą się
przy starcie z błędem "Cannot autowire".
3.4. Skonfiguruj security
Recipe nie modyfikuje config/packages/security.yaml, bo większość
projektów ma już własny firewall i auto-merge byłby ryzykowny. Skopiuj ten
fragment ręcznie:
# config/packages/security.yaml security: providers: # Authenticator zwraca User-a wprost — Symfony nie ma skąd go # przeładować, więc in_memory provider jest poprawny. in_memory: memory: ~ firewalls: dev: pattern: ^/(_(profiler|wdt)|css|images|js)/ security: false login: # Login, logout i refresh muszą być publiczne — authenticator # nie może działać na /api/token/refresh, bo access_token jest # wtedy już nieważny. pattern: ^/api/(login|logout|token/(refresh|invalidate))$ stateless: true security: false api: pattern: ^/api stateless: true custom_authenticators: - Musikhood\AuthClient\Security\JwtCookieAuthenticator entry_point: Musikhood\AuthClient\Security\JwtCookieAuthenticator access_control: - { path: ^/api/(login|logout|token/(refresh|invalidate)), roles: PUBLIC_ACCESS } - { path: ^/api, roles: IS_AUTHENTICATED_FULLY } # Opcjonalnie: zdefiniuj hierarchię ról. Paczka nie narzuca żadnych # konkretnych ról — `panel_roles` z JWT są wystawiane wprost z # prefiksem ROLE_. role_hierarchy: ROLE_ADMIN: [ROLE_USER]
3.5. Stwórz tabelę w bazie
Albo skopiuj docs/example-migration.sql
prosto do swojego narzędzia migracji, albo wygeneruj migrację z encji:
php bin/console doctrine:migrations:diff php bin/console doctrine:migrations:migrate
Tabela potrzebuje kolumn: id (UUID), email (unique), display_name,
roles_for_panel (JSON), disabled (bool), last_synced_at. Pełny
mapping w docs/example-entity.php.
Synchronizacja z auth serverem
Auth server jest jedynym źródłem prawdy dla ról, displayName i flagi
disabled. Lokalna kopia użytkownika w mikroserwisie jest aktualizowana
w dwóch momentach:
- Pierwszy kontakt z userem (bootstrap) — gdy authenticator widzi zalogowanego usera, którego jeszcze nie ma w lokalnej tabeli, paczka tworzy lokalną kopię z claimów świeżego JWT (email, displayName, role per-panel). To jednorazowe — przy każdym kolejnym requeście tego usera authenticator NIE rusza już lokalnej kopii.
- Co ~30s na żądanie zalogowanego usera —
AuthValidationListenerwoła/api/v1/user/mena auth serverze i synchronizuje pełen payload (email, displayName, role per-panel, flagadisabled). To jest jedyna ścieżka aktualizacji istniejącej kopii. Krok pomijany jeśli wynik z poprzedniego wywołania jeszcze leży w cache (validation_cache_ttl, domyślnie 30s).
W szczególności paczka nie używa lokalnej flagi isDisabled() do
podejmowania decyzji o autoryzacji. Gating disabled userów leci
wyłącznie przez /me — co znaczy że:
- Po zablokowaniu konta w panelu admin auth servera użytkownik
zostanie wylogowany w czasie max.
validation_cache_ttlsekund. - Po odblokowaniu konta użytkownik znowu działa bez ponownego
logowania (jeśli JWT jest jeszcze ważny — w innym przypadku front
interceptor zrobi
/api/token/refreshi auth server wystawi nowy). - Po zmianie ról / displayName w panelu admin nowe wartości pojawią się
w lokalnej kopii w czasie max.
validation_cache_ttlsekund. Auth server NIE podbijatokenVersionprzy tych zmianach — istniejące JWT zostają ważne, propagacja idzie przez/me.
Lokalne pole disabled w encji konsumenta służy tylko do wyświetlenia
(np. w panelu zarządzania userami w mikroserwisie). Aktualizowane
automatycznie przez syncFromMe().
Kontrakt z front-endem
Front nigdy nie widzi JWT. Wymaga trzech rzeczy:
axios.defaults.withCredentials = true(lub równowartośćfetchzcredentials: 'include')- na
401z dowolnego/api/*:POST /api/token/refresh, potem retry - na
401z/api/token/refresh: czyść lokalny stan UI, redirect do logowania
To dokładnie ten sam kontrakt co przy gadaniu wprost z auth serverem, więc istniejący front przesiada się na inny backend bez zmian.
Pełna konfiguracja
Wszystkie klucze z domyślnymi wartościami — ustawiasz je w
config/packages/auth_client.yaml. Recipe wgrywa tylko klucze wymagane
(cztery AUTH_* env-y plus cookie.secure); reszta poniżej ma sensowne
defaulty.
auth_client: base_url: '%env(AUTH_BASE_URL)%' # wymagane panel_id: '%env(AUTH_PANEL_ID)%' # wymagane client_id: '%env(AUTH_CLIENT_ID)%' # wymagane client_secret: '%env(AUTH_CLIENT_SECRET)%' # wymagane jwks_cache_ttl: 3600 # TTL cache dokumentu JWKS (sekundy) validation_cache_ttl: 30 # TTL cache introspekcji /me per user (sekundy) cookie: access_name: BEARER refresh_name: refresh_token path: / secure: '%env(bool:default::AUTH_COOKIE_SECURE)%' http_only: true same_site: lax # lax | strict | none lifetime: 2592000 # 30 dni — powinno odpowiadać TTL refresh_token w auth serverze circuit_breaker: failure_threshold: 3 # ile kolejnych błędów /me otwiera breaker open_seconds: 60 # ile sekund breaker jest otwarty http: timeout: 5.0 max_duration: 10.0
Endpointy wystawiane przez paczkę
| Metoda | Ścieżka | Opis |
|---|---|---|
POST |
/api/login |
Wymienia {username, password} na ciasteczka BEARER + refresh_token. |
POST |
/api/logout (alias /api/token/invalidate) |
Czyści ciasteczka, unieważnia refresh token w auth serverze. |
POST |
/api/token/refresh |
Generuje nową parę ciasteczek z refresh tokena. |
GET |
/api/v1/user/me |
Zwraca dane zalogowanego użytkownika (id, email, displayName, roles, disabled). Czyta z lokalnej kopii — nie wymaga round-tripa do auth servera. |
Licencja
MIT — patrz LICENSE.