/apiplatform-users-auth

This a very small & simple implementation for API Platform with LexikJWTAuthenticationBundle to make user API authentications.

Primary LanguageJavaScript

Simple JWT auth with API Platform

Introduction

As the title says we will create together so simple JWT authentication using API Platform and LexikJWTAuthenticationBundle. Using of course our lovely Doctrine User Provider.


Motivation

There too many tutorials online about symfony with JWT, and also some about the api-platform. But most of them are really not helpful or they are very short, and most of them that I found was missing many things. They even don't say that you need to know some concepts first and at the end you find so many confusing developers because of it.

Or they just mentioning the APi Platform name and you enter and nothing mostly it is about symfony. to be honest it make sense as the APi Platform community is too small and as it is really based on symfony so most of issues already asked and solved by symfony community so we are lucky it is so big community :).

I hope this will be diffrent and if you have any concerns or updates will be much appreciated. Also any questions will try to answer all of them.


Requirements

  • PHP >= 7.0 knowledge
  • symfony knowledge (Autowiring, Dependency Injection)
  • Docker knowledge
  • REST APIs knowledge
  • postgresql knowledge
  • Ubuntu or MacOs (Sorry for windows users :)

API Platform installation

The best way for me to install it is using the git repository, or download the API Platform as .zip file from Github.

$ git clone https://github.com/api-platform/api-platform.git apiplatform-user-auth

$ cd apiplatform-user-auth
Make sure nothing on the required ports:

Now, first of all the whole api platform runs on specific ports, so you need to make sure that this ports is free and nothing is listening to it.

  • How i can know this ports?
    • You can find them in the docker-compose.yml file in the project root directory, and they always be like [80, 81, 8080, 8081, 3000, 5432, 1337, 8443, 8444, 443, 444]
  • How to know this?
    • Run this command
      $ sudo lsof -nP | grep LISTEN
  • What to do know?
    • Kill any process listening on any of the above ports.
      $ sudo kill -9 $PROCESS_NUMBER

Installation:
  • Pull the required packages and everything needed.
docker-compose pull
  • Bring the application up and running.
docker-compose up -d
  • You may face some issue here so better to bring all containers down and run the command again like this
  $ docker-compose down
  $ COMPOSE_HTTP_TIMEOUT=120 docker-compose up -d
  • Now the application should be running and everything in place:
docker ps

CONTAINER ID        IMAGE                            COMMAND                  CREATED              STATUS              PORTS                                                                    NAMES
6389d8efb6a0        apiplatform-user-auth_h2-proxy   "nginx -g 'daemon of…"   About a minute ago   Up About a minute   0.0.0.0:443-444->443-444/tcp, 80/tcp, 0.0.0.0:8443-8444->8443-8444/tcp   apiplatform-user-auth_h2-proxy_1_a012bc894b6c
a12ff2759ca4        quay.io/api-platform/varnish     "docker-varnish-entr…"   2 minutes ago        Up 2 minutes        0.0.0.0:8081->80/tcp                                                     apiplatform-user-auth_cache-proxy_1_32d747ba8877
6c1d29d1cbdd        quay.io/api-platform/nginx       "nginx -g 'daemon of…"   2 minutes ago        Up 2 minutes        0.0.0.0:8080->80/tcp                                                     apiplatform-user-auth_api_1_725cd9549081
62f69838dacb        quay.io/api-platform/php         "docker-entrypoint p…"   2 minutes ago        Up 2 minutes        9000/tcp                                                                 apiplatform-user-auth_php_1_cf09d32c3120
381384222af5        dunglas/mercure                  "./mercure"              2 minutes ago        Up 2 minutes        443/tcp, 0.0.0.0:1337->80/tcp                                            apiplatform-user-auth_mercure_1_54363c253a34
783565efb2eb        postgres:10-alpine               "docker-entrypoint.s…"   2 minutes ago        Up 2 minutes        0.0.0.0:5432->5432/tcp                                                   apiplatform-user-auth_db_1_8da243ca2865
1bc8e386bf02        quay.io/api-platform/client      "/bin/sh -c 'yarn st…"   2 minutes ago        Up About a minute   0.0.0.0:80->3000/tcp                                                     apiplatform-user-auth_client_1_1c413b4e4a5e
c22bef7a0b3f        quay.io/api-platform/admin       "/bin/sh -c 'yarn st…"   2 minutes ago        Up About a minute   0.0.0.0:81->3000/tcp                                                     apiplatform-user-auth_admin_1_cfecc5c6b442
  • Now, if you go to localhost:8080 you will see their some simple apis listed their, it is the example entity that comes with the project.

Create the User entity based on Doctrine User Provider

  • Install the doctrine maker package to help us make it quick :)
$ docker-compose exec php composer require doctrine maker
  • Create our User entity
$ docker-compose exec php bin/console make:user

 The name of the security user class (e.g. User) [User]:
 > Users

 Do you want to store user data in the database (via Doctrine)? (yes/no) [yes]:
 >

 Enter a property name that will be the unique "display" name for the user (e.g. email, username, uuid) [email]:
 > email

 Will this app need to hash/check user passwords? Choose No if passwords are not needed or will be checked/hashed by some other system (e.g. a single sign-on server).

 Does this app need to hash/check user passwords? (yes/no) [yes]:
 >

The newer Argon2i password hasher requires PHP 7.2, libsodium or paragonie/sodium_compat. Your system DOES support this algorithm.
You should use Argon2i unless your production system will not support it.

 Use Argon2i as your password hasher (bcrypt will be used otherwise)? (yes/no) [yes]:
 >

 created: src/Entity/Users.php
 created: src/Repository/UsersRepository.php
 updated: src/Entity/Users.php
 updated: config/packages/security.yaml


  Success!


 Next Steps:
   - Review your new App\Entity\Users class.
   - Use make:entity to add more fields to your Users entity and then run make:migration.
   - Create a way to authenticate! See https://symfony.com/doc/current/security.html
  • If you go now to "api/src/Entity" you will find your entity there. If you scroll down a little bit to the getEmail & getPassword functions you will see something like this, which means this two properties will be used as the User identifier in the authentication. (Will not use the ROLES in this example as it is so simple one)
# api/src/Entity/Users.php

/**
* @see UserInterface
*/
  • As you know the latest versions of symfony using the autowiring feature so you can see that this entity is already wired and injected with teh repository called "api/src/Repository/UsersReporitory"
# api/src/Entity/Users.php

/**
 * @ORM\Entity(repositoryClass="App\Repository\UsersRepository")
 */
class Users implements UserInterface
{
    ...
}

  • You can see clearly in this repository some pre-implemented functions like findbyId(), but now let us create another function that helps us to create a new user.

    • To add user into the Db will need to define an entity manager like the following:
    # api/src/Repository/UsersRepository.php
    class UsersRepository extends ServiceEntityRepository
    {
      /** EntityManager $manager */
      private $manager;
    ....
    }

    and initialize it in the constructor like the following:

    # api/src/Repository/UsersRepository.php
    
    /**
    * UsersRepository constructor.
    * @param RegistryInterface $registry
    */
    public function __construct(RegistryInterface $registry)
    {
      parent::__construct($registry, Users::class);
    
      $this->manager = $registry->getEntityManager();
    }
    • Now, let us create our function like the followings:
    # api/src/Repository/UsersRepository.php
    
    /**
     * Create a new user
     * @param $data
     * @return Users
     * @throws \Doctrine\ORM\ORMException
     * @throws \Doctrine\ORM\OptimisticLockException
    */
    public function createNewUser($data)
    {
        $user = new Users();
        $user->setEmail($data['email'])
            ->setPassword($data['password']);
    
        $this->manager->persist($user);
        $this->manager->flush();
    
        return $user;
    }

  • Let us create our controller to consume that repository, will call it "AuthController".
$ docker-compose exec php bin/console make:controller

 Choose a name for your controller class (e.g. TinyJellybeanController):
 > AuthController

 created: src/Controller/AuthController.php
 created: templates/auth/index.html.twig


  Success!


 Next: Open your new controller class and add some pages!

  • Now, lets consume this createNewUser function. If you see your controller you will find it is only contains the index function, but we need to create another one will call it "register".
    • We need the UsersRepository right so will create it is object first.
    # api/src/Controller/AuthController.php
    
    use App\Repository\UsersRepository;
    
    class AuthController extends AbstractController
    {
        /** @var UsersRepository $userRepository */
        private $usersRepository;
    
        /**
         * AuthController Constructor
         *
         * @param UsersRepository $usersRepository
         */
        public function __construct(UsersRepository $usersRepository)
        {
            $this->usersRepository = $usersRepository;
        }
        .......
    }
    • Now, we need to make this controller know about the User repository, so we will inject it as a service.
    # api/config/services.yaml
    
    services:
        ......
      # Repositories
      app.user.repository:
          class: App\Repository\UsersRepository
          arguments:
              - Symfony\Bridge\Doctrine\RegistryInterface
      
      # Controllers
      app.auth.controller:
          class: App\Controller\AuthController
          arguments:
              - '@app.user.repository'
    • Now, it is time to implement our new endpoint to register a new account.
    # api/src/Controller/AuthController.php
    
    # Import those
    use Symfony\Component\HttpFoundation\Request;
    use Symfony\Component\HttpFoundation\Response;
    
    # Then add this to the class
    /**
     * Register new user
     * @param Request $request
     *
     * @return Response
     */
    public function register(Request $request)
    {
        $newUserData['email']    = $request->get('email');
        $newUserData['password'] = $request->get('password');
    
        $user = $this->usersRepository->createNewUser($newUserData);
    
        return new Response(sprintf('User %s successfully created', $user->getUsername()));
    }
    • Now, we need to let the framework to know about this new endpoint by adding it to our routes file
    # src/config/routes.yaml
    
    # Register api
    register:
        path: /register
        controller: App\Controller\AuthController::register
        methods: ['POST']

Testing this new API:

  • Make the migration and update the DB first:
$ docker-compose exec php bin/console make:migration

$ docker-compose exec php bin/console doctrine:migrations:migrate

  WARNING! You are about to execute a database migration that could result in schema changes and data loss. Are you sure you wish to continue? (y/n) y
  • Now, from Postman or any other client you use. Here am using CURL
$ curl -X POST -H "Content-Type: application/json" "http://localhost:8080/register?email=test1@mail.com&password=test1"
User test1@mail.com successfully created
  • You want to see this data in the DB:
$ docker-compose exec db psql -U api-platform api
psql (10.8)
Type "help" for help.

$ api=# select * from users;
 id |     email      | roles | password
----+----------------+-------+----------
  6 | test1@mail.com | []    | test1
(1 row)

  • Oooooh, woow the password is not encrypted what should we do!!!
    • So, as i said before this project is built on Symfony that is why i said you need to have knowledge about symfony. So will use the Password encoder class.
    # api/src/Repository/UsersRepository.php
    
    use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
    
    class UsersRepository extends ServiceEntityRepository
    {
        .......
    
      /** UserPasswordEncoderInterface $encoder */
      private $encoder;
        
      /**
       * UserRepository constructor.
       * @param RegistryInterface $registry
       * @param UserPasswordEncoderInterface $encoder
       */
      public function __construct(RegistryInterface $registry, UserPasswordEncoderInterface $encoder)
      {
          parent::__construct($registry, Users::class);
    
          $this->manager = $registry->getEntityManager();
          $this->encoder = $encoder;
      }
    }
    • As always we need to inject it to the repository:
    # api/config/services.yaml
    
    services:
      .......
      # Repositories
      app.user.repository:
          class: App\Repository\UsersRepository
          arguments:
              - Symfony\Bridge\Doctrine\RegistryInterface
              - Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface
    • Update the create user function:
    # api/src/Repository/UsersRepository.php
    
    public function createNewUser($data)
    {
        $user = new Users();
        $user->setEmail($data['email'])
            ->setPassword($this->encoder->encodePassword($user, $data['password']));
        .......
    }
    • Now, try the register call again, rembmer with diffrent email as we defined the email as Unique:
    $ curl -X POST -H "Content-Type: application/json" "http://localhost:8080/register?email=test2@mail.com&password=test2"
    User test2@mail.com successfully created
    • check now again:
    $ api=# select * from users;
     id |     email      | roles |                                            password
    ----+----------------+-------+-------------------------------------------------------------------------------------------------
      6 | test1@mail.com | []    | test1
      7 | test2@mail.com | []    | $argon2i$v=19$m=1024,t=2,p=2$VW9tYXEzZHp5U0RMSE5ydA$bo+V1X6rgYZ4ebN/bs1cpz+sf+DQdx3Duu3hvFUII8M
    (2 rows)

Install LexikJWTAuthenticationBundle

  • Install the bundle and generate the secrets:
$ docker-compose exec php composer require jwt-auth

Create our authentication

  • (Additional) Before anything if you tried this call for now you will get this result:
$ curl -X GET -H "Content-Type: application/json" "http://localhost:8080/greetings"
{
    "@context": "/contexts/Greeting",
    "@id": "/greetings",
    "@type": "hydra:Collection",
    "hydra:member": [],
    "hydra:totalItems": 0
}
  • Let us keep going for now, create a new & so simple endpoint that we will use it in our testing now will call it "/api"
# api/src/Controller/AuthController.php

/**
* api route redirects
* @return Response
*/
public function api()
{
    return new Response(sprintf("Logged in as %s", $this->getUser()->getUsername()));
}
  • Add it to our Routes
# api/config/routes.yaml

api:
    path: /api
    controller: App\Controller\AuthController::api
    methods: ['POST']

  • Now, we need to make some configurations in our security config file:

    • First of all this our provider to our authentication or anything related to users in the application. It is already predefined if you wanna change it you can change the user provider you can do it here.
    # api/config/packages/security.yaml
    
    app_user_provider:
        entity:
            class: App\Entity\Users
            property: email
    • Lets make some configs for our "/register" api as we want this api to be public for anyone:
    # api/config/packages/security
    
    register:
        pattern:  ^/register
        stateless: true
        anonymous: true
    • Now, let us assume that we need everything generated by the api-platform to not work without JWT token, meaning without authenticated user the api shouldn't return anything. So will update the "main" part configs to be like this:
    # api/config/packages/security.yaml
    
    main:
        anonymous: false
        stateless: true
        provider: app_user_provider
        json_login:
            check_path: /login
            username_path: email
            password_path: password
            success_handler: lexik_jwt_authentication.handler.authentication_success
            failure_handler: lexik_jwt_authentication.handler.authentication_failure
        guard:
            authenticators:
                - lexik_jwt_authentication.jwt_token_authenticator
    • Also, add some configs for our simple endpoint /api.
    # api/config/packages/security.yaml
    
    api:
        pattern: ^/api
        stateless: true
        anonymous: false
        provider: app_user_provider
        guard:
            authenticators:
                - lexik_jwt_authentication.jwt_token_authenticator
    • As you see in the above configs we set the anonymous to false we don't want anyone to access this two APIs now, also we telling the framework the provider for you is the user provider that we defined before, and at the end we telling it which authenticator you will use and the authentication success/faliure messages.

    • Now, if you tried the call we tried it in the Additional part for the /greetings api

    $ curl -X GET -H "Content-Type: application/json" "http://localhost:8080/greetings"
      {
          "code": 401,
          "message": "JWT Token not found"
      }
    • The same with the our simple endpoint /api that we created:
    $ curl -X POST -H "Content-Type: application/json" "http://localhost:8080/api" 
      {
        "code": 401,
        "message": "JWT Token not found"
      }
    • As you see it asks you to login :D, there is no JWT token specified so we will create a very simple API that used by the lexik jwt to authenticate the users, and generate their tokens, remember that the login check path should be the same as the check_path under json_login in the security file:
    # api/config/packages/security.yaml
    ....
    json_login:
            check_path: /login
    
    # api/config/routes.yaml
    
    # Login check to log the user and generate JWT token
    api_login_check:
          path: /login
          methods: ['POST']
    • Now, Lets try it out and see if it will generate a token for us or what!
    $ curl -X POST -H "Content-Type: application/json" http://localhost:8080/login -d '{"email":"test2@mail.com","password":"test2"}'
      {"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpYXQiOjE1NTg2OTg4MTIsImV4cCI6MTU1ODcwMjQxMiwicm9sZXMiOlsiUk9MRV9VU0VSIl0sInVzZXJuYW1lIjoidGVzdDJAbWFpbC5jb20ifQ.nzd5FVhcyrfjYyN8jRgYFp3VOB2QytnPPRGNyp4ZtfLx6IRwg0TWZJPu5OFtOKPkdLO8DQAr_4Fpq_G6oPjzoxmGOASNuRoQonik9FCCq6oAIW3k5utzQecXDVE_ImnfgByc6WYW6a-aWLnsq1qtvxy274ojqdR0rWLePwSWX5K5-t08zDBgavO_87dVpYd0DLwhHIS7F10lNscET7bfWS-ioPDTv-G74OvkcpbcjgwHhXlO7TYubnrES-FsvAw7kezQe4BPxdbXr1w-XBZuqTNEU4MyrBuadSLgjoe_gievNBtkVhKErIkEQZVjeJIQ4xaKaxwmPxZcP9jYkE47myRdbMsL9XHSd0XmGq0bPuGjOJ2KLTmUb5oeuRnY-e9Q_V9BbouEGw0sjw2meo6Jot2MZyv5ZnLci_GwpRtWqmV7ZLw5jNyiLDFXR1rz70NcJh7EXqu9o4nno3oc68zokfDQvGkJJJZMtBrLCK5pKGMh0a1elIz41LRLZvpLYCrOZ2f4wCkGRD_U92iILD6w8EdVWGoO1wTn5Z2k8-GS1-QH9f-4KkOpaYGPCwwdrY7yioSt2oVbEj2FOb1jULteeP_Cpu44HyJktPLPW_wrN2OtZlUFr4Vz_owDSIvNESYk1JBQ_Fjlv9QGmUs9itzaDExjfB4QYoGkvpfNymtw2PI"}

    As you see it created JWT token for me, so I can use it to call any api in the application. If it show some exception like Unable to generate token for the specified configurations, please check this step here. First open you .env file we will need the JWT_PASSPHRASE so keep it opened

    $ mkdir -p api/config/jwt
    $ openssl genrsa -out api/config/jwt/private.pem -aes256 4096 # this will ask you for the JWT_PASSPHRASE
    $ openssl rsa -pubout -in api/config/jwt/private.pem -out api/config/jwt/public.pem # will confirm the JWT_PASSPHRASE again
    • Lets try it out to call /api or /greetings endpoints with this token now:
    $ curl -X GET -H "Content-Type: application/json" -H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpYXQiOjE1NTg2OTg4MTIsImV4cCI6MTU1ODcwMjQxMiwicm9sZXMiOlsiUk9MRV9VU0VSIl0sInVzZXJuYW1lIjoidGVzdDJAbWFpbC5jb20ifQ.nzd5FVhcyrfjYyN8jRgYFp3VOB2QytnPPRGNyp4ZtfLx6IRwg0TWZJPu5OFtOKPkdLO8DQAr_4Fpq_G6oPjzoxmGOASNuRoQonik9FCCq6oAIW3k5utzQecXDVE_ImnfgByc6WYW6a-aWLnsq1qtvxy274ojqdR0rWLePwSWX5K5-t08zDBgavO_87dVpYd0DLwhHIS7F10lNscET7bfWS-ioPDTv-G74OvkcpbcjgwHhXlO7TYubnrES-FsvAw7kezQe4BPxdbXr1w-XBZuqTNEU4MyrBuadSLgjoe_gievNBtkVhKErIkEQZVjeJIQ4xaKaxwmPxZcP9jYkE47myRdbMsL9XHSd0XmGq0bPuGjOJ2KLTmUb5oeuRnY-e9Q_V9BbouEGw0sjw2meo6Jot2MZyv5ZnLci_GwpRtWqmV7ZLw5jNyiLDFXR1rz70NcJh7EXqu9o4nno3oc68zokfDQvGkJJJZMtBrLCK5pKGMh0a1elIz41LRLZvpLYCrOZ2f4wCkGRD_U92iILD6w8EdVWGoO1wTn5Z2k8-GS1-QH9f-4KkOpaYGPCwwdrY7yioSt2oVbEj2FOb1jULteeP_Cpu44HyJktPLPW_wrN2OtZlUFr4Vz_owDSIvNESYk1JBQ_Fjlv9QGmUs9itzaDExjfB4QYoGkvpfNymtw2PI" "http://localhost:8080/greetings"
    {
        "@context": "/contexts/Greeting",
        "@id": "/greetings",
        "@type": "hydra:Collection",
        "hydra:member": [],
        "hydra:totalItems": 0
    }
    $ curl -X GET -H "Content-Type: application/json" "http://localhost:8080/greetings"
      {
          "code": 401,
          "message": "JWT Token not found"
      }

    I guess now you see the diffrence.

    • what about the /api endpoint let us try it out:
    $ curl -X POST -H "Content-Type: application/json" -H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpYXQiOjE1NTg2OTg4MTIsImV4cCI6MTU1ODcwMjQxMiwicm9sZXMiOlsiUk9MRV9VU0VSIl0sInVzZXJuYW1lIjoidGVzdDJAbWFpbC5jb20ifQ.nzd5FVhcyrfjYyN8jRgYFp3VOB2QytnPPRGNyp4ZtfLx6IRwg0TWZJPu5OFtOKPkdLO8DQAr_4Fpq_G6oPjzoxmGOASNuRoQonik9FCCq6oAIW3k5utzQecXDVE_ImnfgByc6WYW6a-aWLnsq1qtvxy274ojqdR0rWLePwSWX5K5-t08zDBgavO_87dVpYd0DLwhHIS7F10lNscET7bfWS-ioPDTv-G74OvkcpbcjgwHhXlO7TYubnrES-FsvAw7kezQe4BPxdbXr1w-XBZuqTNEU4MyrBuadSLgjoe_gievNBtkVhKErIkEQZVjeJIQ4xaKaxwmPxZcP9jYkE47myRdbMsL9XHSd0XmGq0bPuGjOJ2KLTmUb5oeuRnY-e9Q_V9BbouEGw0sjw2meo6Jot2MZyv5ZnLci_GwpRtWqmV7ZLw5jNyiLDFXR1rz70NcJh7EXqu9o4nno3oc68zokfDQvGkJJJZMtBrLCK5pKGMh0a1elIz41LRLZvpLYCrOZ2f4wCkGRD_U92iILD6w8EdVWGoO1wTn5Z2k8-GS1-QH9f-4KkOpaYGPCwwdrY7yioSt2oVbEj2FOb1jULteeP_Cpu44HyJktPLPW_wrN2OtZlUFr4Vz_owDSIvNESYk1JBQ_Fjlv9QGmUs9itzaDExjfB4QYoGkvpfNymtw2PI" "http://localhost:8080/api"
    Logged in as test2@mail.com

    As you can see only from the JWT token you can know exactly whos is logged in, and you can improve this by implmenting additonal User properties like isActive or userRoles...etc.


Thank you for coming so far in this tutorials, I hope that you learned something new.
Thank you so much and if you have any questions please don't hesitate to ask.