khgasd652k
khgasd652k

Reputation: 301

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?

Upvotes: 2

Views: 2970

Answers (2)

jim smith
jim smith

Reputation: 2454

Sharing jwt tokens across different apps:

  1. Set up the lexikjwt bundle in your auth app using the default documentation (I use the standard doctrine user entity config)

  2. Install the lexikjwt bundle in your app that accepts the token

  3. Ensure that the public key is the same as the one in your auth app

  4. In security.yaml add the provider

    providers:
        users:
            lexik_jwt: ~
    

The jwt bundle will simply decode the token using the pub key and all the claims like (username and roles) are there

If you need custom claims like IP address add them as follows:

https://symfony.com/bundles/LexikJWTAuthenticationBundle/current/2-data-customization.html#adding-custom-data-or-headers-to-the-jwt

Upvotes: 0

Mikhail Prosalov
Mikhail Prosalov

Reputation: 4363

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}

Upvotes: 4

Related Questions