Search code examples
phpsymfonyapi-platform.com

How to make a Symfony apps use the authentication and authorization from other Symfony apps?


Is there any way to authenticate and authorize user in symfony from another symfony apps on different domain?

So here is my scenario, I have a symfony apps mainly used as user related service (user, roles, student name, etc) on domain example1.dev, this site provide api endpoint to login with JWTLexicBundle (on example1.dev/api/authentication), and provide endpoint to get student data (roles, etc) on eample1.dev/api/whoami by sending that endpoint a valid JWT Token Header. The JWT token from example1.dev is being used as an authentication token on various front end site (example3.dev, example4.dev which built on top react)

Then, I have the second symfony apps used for class with no user/authentication method yet on example2.dev.

How can I use example1.dev user data and roles to be implemented at example2.dev? I mean the authentication and authorization on example2.dev using example1.dev api service, like checking whether a request provide a JWT Token, if so, check to example1.dev whether the token is valid, if it's valid, get the user data from example1.dev. Is that possible?


Solution

  • It's definitely possible. Let's take a look at a possible implementation, hope it would be a good starting point to design a solution, tailored for your requirements. In the first Symfony app (let's call it User Service), we would have login functionality to exchange credentials to JWT token, refresh JWT token, etc. After obtaining a JWT token, a user is able to call other services and sign requests with a JWT token. On other services, we need to decode the JWT token (it would check if it's valid and not expired). In order to do so, we should have LexikJWTAuthenticationBundle dependency in all services, but with a different configuration. For User Service, we would have both public and secret keys in order to generate a JWT token and validate it, while other services require only a public key in order to validate a JWT token and decode it to read a payload.

    User Service config.yml configuration.

    # JWT Configuration
    lexik_jwt_authentication:
        secret_key:          '%jwt_private_key%'
        public_key:          '%jwt_public_key%'
        pass_phrase:         '%jwt_key_pass_phrase%'
        token_ttl:           '%jwt_token_ttl%'
        user_identity_field: email
    

    Other service config.yml configuration.

    # JWT Configuration
    lexik_jwt_authentication:
        public_key:          '%jwt_public_key%'
        token_ttl:           '%jwt_token_ttl%'
        user_identity_field: email
    

    After that, we may want to create a small shared library to share possible roles. Or just duplicate roles for all services. Roles are just strings, so whatever approach would work. We also may want to have a shared user provider and UserInterface implementation, but it's completely optional. Inside of JWT token payload, we may pass available roles of a user, which would be filled by User Service when a user would be authenticated and a JWT token would be generated. This approach enables other services to read a JWT token payload and get user roles to check user authorization against requested resources.

    Sample security.yml User Service configuration.

    security:
        encoders:
            SharedAuthLibrary\Security\User:
                algorithm: bcrypt
            App\Entity\User:
                algorithm: bcrypt
    
        role_hierarchy:
            ROLE_ADMIN: ROLE_USER
    
        providers:
            service:
                id: shared_auth_library_jwt_user_provider
            login:
                id: app.user_provider
    
        firewalls:
            dev:
                pattern: ^/(_(profiler|wdt)|css|images|js)/
                security: false
    
            refresh:
                pattern:  ^/api/token/refresh
                stateless: true
                anonymous: true
    
            login:
                pattern:  ^/api/login$
                stateless: true
                anonymous: true
                provider: login
                json_login:
                    check_path: /api/login
                    username_path: email
                    password_path: password
                    success_handler: lexik_jwt_authentication.handler.authentication_success
                    failure_handler: lexik_jwt_authentication.handler.authentication_failure
    
            api:
                pattern: ^/api
                stateless: true
                anonymous: true
                provider: service
                guard:
                  entry_point: lexik_jwt_authentication.jwt_token_authenticator
                  authenticators:
                    - lexik_jwt_authentication.jwt_token_authenticator
    
        access_control:
            - { path: ^/api/login, roles: IS_AUTHENTICATED_ANONYMOUSLY }
            - { path: ^/api/token/refresh, roles: IS_AUTHENTICATED_ANONYMOUSLY }
            - { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
    

    Sample security.yml oconfiguration on other services.

    security:
        encoders:
            SharedAuthLibrary\Security\User:
                algorithm: bcrypt
    
        role_hierarchy:
            ROLE_ADMIN: ROLE_USER
    
        providers:
            service:
                id: shared_auth_library_jwt_user_provider
    
        firewalls:
            dev:
                pattern: ^/(_(profiler|wdt)|css|images|js)/
                security: false
    
            api:
                pattern: ^/api
                stateless: true
                anonymous: true
                provider: service
                guard:
                  entry_point: lexik_jwt_authentication.jwt_token_authenticator
                  authenticators:
                    - lexik_jwt_authentication.jwt_token_authenticator
    
        access_control:
            - { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
    

    In order to have required data for authorisation on other services, we need to enrich JWT payload with id, email, and roles. Let's create a JWT created event listener in our User Service.

    <?php
    
    declare(strict_types=1);
    
    namespace App\EventListener;
    
    use SharedAuthLibrary\Security\User;
    use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTCreatedEvent;
    use Lexik\Bundle\JWTAuthenticationBundle\Events;
    use Symfony\Component\EventDispatcher\EventSubscriberInterface;
    
    class SecurityEventSubscriber implements EventSubscriberInterface
    {
        public static function getSubscribedEvents()
        {
            return [
                Events::JWT_CREATED => 'onJwtCreated',
            ];
        }
    
        public function onJwtCreated(JWTCreatedEvent $event): void
        {
            /** @var User $user */
            $user = $event->getUser();
    
            $payload          = $event->getData();
            $payload['id']    = $user->getId();
            $payload['roles'] = $user->getRoles();
            $payload['email'] = $user->getUsername();
            $payload['exp']   = (new \DateTimeImmutable())->getTimestamp() + 86400;
    
            $event->setData($payload);
        }
    }
    

    Let's take a look at shared library. We need a payload container to pass payload to our user provider in order to create a authenticated User with all fields from payload we need to check authorization to resources and things like that.

    <?php
    
    declare(strict_types=1);
    
    namespace SharedAuthLibrary\Security;
    
    class JwtPayloadContainer
    {
        private array $payload = [];
    
        public function setPayload(array $payload): void
        {
            if (empty($this->payload)) {
                $this->payload = $payload;
            }
        }
    
        public function getPayload(): array
        {
            return $this->payload;
        }
    }
    

    And a listener to actually use the payload container.

    <?php
    
    declare(strict_types=1);
    
    namespace SharedAuthLibrary\Listener;
    
    use SharedAuthLibrary\Security\JwtPayloadContainer;
    use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTDecodedEvent;
    
    class JwtPayloadListener
    {
        private JwtPayloadContainer $jwtPayloadContainer;
    
        public function __construct(JwtPayloadContainer $jwtPayloadContainer)
        {
            $this->jwtPayloadContainer = $jwtPayloadContainer;
        }
    
        public function onJWTDecoded(JWTDecodedEvent $event): void
        {
            $payload = $event->getPayload();
            $this->jwtPayloadContainer->setPayload($payload);
        }
    }
    

    Our user provider may look something like this.

    <?php
    
    declare(strict_types=1);
    
    namespace SharedAuthLibrary\Security;
    
    use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
    use Symfony\Component\Security\Core\User\UserInterface;
    use Symfony\Component\Security\Core\User\UserProviderInterface;
    
    class JwtUserProvider implements UserProviderInterface
    {
        private JwtPayloadContainer $jwtPayloadContainer;
    
        public function __construct(JwtPayloadContainer $jwtPayloadContainer)
        {
            $this->jwtPayloadContainer = $jwtPayloadContainer;
        }
    
        public function loadUserByUsername($username): User
        {
            $payload = $this->jwtPayloadContainer->getPayload();
    
            return new User($payload['id'], $payload['email'], $payload['roles']);
        }
    
        public function refreshUser(UserInterface $user): User
        {
            if (!$user instanceof User) {
                throw new UnsupportedUserException(
                    sprintf('Instances of "%s" are not supported.', \get_class($user))
                );
            }
    
            return $this->loadUserByUsername($user->getUsername());
        }
    
        public function supportsClass($class): bool
        {
            return User::class === $class;
        }
    }
    

    Sample shared User model.

    <?php
    
    declare(strict_types=1);
    
    namespace SharedAuthLibrary\Security;
    
    use Symfony\Component\Security\Core\User\UserInterface;
    
    class User implements UserInterface
    {
        private string $id;
    
        private string $username;
    
        private array $roles;
    
        private string $password;
    
        private string $salt;
    
        public function __construct(
            string $id,
            string $email,
            array $roles,
            string $password = '',
            string $salt = '',
        ) {
            $this->id         = $id;
            $this->roles      = $roles;
            $this->username   = $email;
            $this->password   = $password;
            $this->salt       = $salt;
        }
    
        public function getId(): string
        {
            return $this->id;
        }
    
        public function getUsername(): string
        {
            return $this->username;
        }
    
        public function getRoles(): array
        {
            return $this->roles;
        }
    
        public function getPassword(): string
        {
            return $this->password;
        }
    
        public function getSalt(): string
        {
            return $this->salt;
        }
    
        public function eraseCredentials()
        {
            // TODO: Implement eraseCredentials() method.
        }
    }
    

    Finally, register the subsriber and listener to service.yml:

        shared_auth_library_jwt_user_provider:
            class: App\SharedAuthLibrary\Security\JwtUserProvider
    
        App\EventListener\SecurityEventSubscriber:
            tags:
                - { name: kernel.event_listener, event: lexik_jwt_authentication.on_jwt_created, method: onJWTCreated}
    
        App\SharedAuthLibrary\Listener\JwtPayloadListener:
            tags:
                - { name: kernel.event_listener, event: lexik_jwt_authentication.on_jwt_decoded, method: onJWTDecoded}