Those who don't want to use third party authentication bundles such as Lexik can use this example to implement JWT authentication in their APIs.


Improvement



Endpoints


$ bin/console debug:router
----------------------- -------- -------- ------ -------------------
Name Method Scheme Host Path
----------------------- -------- -------- ------ -------------------
app_country_getall GET ANY ANY /api/v1/countries # SECURED
app_country_createone POST ANY ANY /api/v1/countries # SECURED
home GET ANY ANY / # UNSECURED
login POST ANY ANY /login # UNSECURED
----------------------- -------- -------- ------ -------------------

Prerequisites


Install


Apart from other common packages you need to install annotations, sensio/framework-extra-bundle, symfony/security, symfony/expression-language, firebase/php-jwt and ramsey/uuid packages.


Create OpenSSL RS256 keys


$ ssh-keygen -t rsa -b 4096 -f jwt/rsa_256 # Hit enter for all questions
$ openssl rsa -in jwt/rsa_256 -pubout -outform PEM -out jwt/rsa_256.pub

Just remember not to commit these two files for security reasons.


Config files


config/packages/sensio_framework_extra.yaml


sensio_framework_extra:
router:
annotations: false
security:
annotations: true

config/routes.yaml


home:
path: /
methods: [GET]
controller: App\Controller\HomeController::home

login:
path: /login
methods: [POST]
controller: App\Controller\LoginController::login

config/routes/annotations.yaml


controllers:
resource: ../../src/Controller/
type: annotation
prefix: /api/v1

config/packages/security.yaml


security:

encoders:
App\Entity\User:
algorithm: bcrypt
cost: 13

role_hierarchy:
ROLE_USER: ROLE_USER
ROLE_ADMIN: ROLE_USER

providers:
jwt_user_provider:
id: App\Security\JwtUserProvider

firewalls:
jwt:
pattern: ^/api
stateless: true
simple_preauth:
authenticator: App\Security\JwtUserAuthenticator
provider: jwt_user_provider

config/services.yaml


imports:
- { resource: services/* }

parameters:
jwt_ttl: '+1 hour'
jwt_algorithm: 'RS256'
jwt_private_key: '%kernel.project_dir%/jwt/rsa_256'
jwt_public_key: '%kernel.project_dir%/jwt/rsa_256.pub'

services:
_defaults:
autowire: true
autoconfigure: true
public: false

App\:
resource: '../src/*'
exclude: '../src/{Entity,Command,Controller,Repository,Security,Service,Util,Kernel.php}'

Controller


Controller/HomeController.php


declare(strict_types=1);

namespace App\Controller;

use Symfony\Component\HttpFoundation\Response;

class HomeController
{
public function home(): Response
{
return new Response('Welcome to the API application.');
}
}

Controller/LoginController.php


I am doing everything in this controller just to keep the example as short as possible for now but you should not do that because it is a "bad practise". As you can see I don't even validate the request at all. I would suggest you to move part of the logic to a service class and a factory class.


declare(strict_types=1);

namespace App\Controller;

use App\Entity\Token;
use App\Entity\User;
use App\Repository\UserRepositoryInterface;
use App\Util\JwtUtilInterface;
use DateTime;
use Doctrine\ORM\EntityManagerInterface;
use Ramsey\Uuid\Uuid;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;

class LoginController
{
private $userRepository;
private $entityManager;
private $userPasswordEncoder;
private $jwtUtil;
private $jwtTtl;

public function __construct(
UserRepositoryInterface $userRepository,
EntityManagerInterface $entityManager,
UserPasswordEncoderInterface $userPasswordEncoder,
JwtUtilInterface $jwtUtil,
string $jwtTtl
) {
$this->userRepository = $userRepository;
$this->entityManager = $entityManager;
$this->userPasswordEncoder = $userPasswordEncoder;
$this->jwtUtil = $jwtUtil;
$this->jwtTtl = $jwtTtl;
}

public function login(Request $request): Response
{
$data = json_decode($request->getContent(), true);

$user = $this->userRepository->findOneActiveByUsername($data['username']);
if (
!$user instanceof User ||
!$this->userPasswordEncoder->isPasswordValid($user, $data['password'])
) {
throw new UnauthorizedHttpException('Basic realm="API Login"', 'Invalid credentials.');
}

$id = Uuid::uuid4()->toString();
$createdAt = (new DateTime())->format(DATE_ISO8601);
$expiresAt = (new DateTime())->modify($this->jwtTtl)->format(DATE_ISO8601);

$tokenData = [
'id' => $id,
'created_at' => $createdAt,
'expires_at' => $expiresAt,
'user' => [
'id' => $user->getId(),
'roles' => $user->getRoles(),
],
];

$token = new Token();
$token->setId($id);
$token->setCreatedAt($createdAt);
$token->setExpiresAt($expiresAt);
$token->setUser($user);
$token->setData($this->jwtUtil->encode($tokenData));

$this->entityManager->persist($token);
$this->entityManager->flush();

return new Response($token->getData(), Response::HTTP_CREATED);
}
}

Controller/CountryController.php


declare(strict_types=1);

namespace App\Controller;

use App\Security\JwtUserInterface;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

/**
* @Route("/countries")
*/
class CountryController
{
private $jwtUser;

public function __construct(JwtUserInterface $jwtUser)
{
$this->jwtUser = $jwtUser;
}

/**
* @Route("", methods="GET")
* @Security("has_role('ROLE_USER')")
*/
public function getAll(): Response
{
return new Response($this->getUserData());
}

/**
* @Route("", methods="POST")
* @Security("has_role('ROLE_ADMIN')")
*/
public function createOne(): Response
{
return new Response($this->getUserData());
}

private function getUserData(): string
{
return json_encode([
'user_id' => $this->jwtUser->get()->getId(),
'user_roles' => $this->jwtUser->get()->getRoles(),
]);
}
}

# config/services/controllers.yaml

services:
_defaults:
autowire: true
autoconfigure: true
public: true

App\Controller\:
resource: '../../src/Controller'
tags: ['controller.service_arguments']

App\Controller\LoginController:
arguments:
$jwtTtl: '%jwt_ttl%'

Entity classes


One user can have many tokens.


User


declare(strict_types=1);

namespace App\Entity;

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Ramsey\Uuid\Uuid;
use Symfony\Component\Security\Core\User\UserInterface;

/**
* @ORM\Entity
* @ORM\Table(name="users")
*/
class User implements UserInterface
{
/**
* @ORM\Id
* @ORM\Column(name="id", type="string", length=36, nullable=false)
* @ORM\GeneratedValue(strategy="NONE")
*/
private $id;

/**
* @ORM\Column(name="username", type="string", length=100, nullable=false, unique=true)
*/
private $username;

/**
* @ORM\Column(name="password", type="string", length=100, nullable=false)
*/
private $password;

/**
* @ORM\Column(name="email", type="string", length=100, nullable=false, unique=true)
*/
private $email;

/**
* @ORM\Column(name="roles", type="array")
*/
private $roles;

/**
* @ORM\Column(name="is_active", type="boolean", nullable=false)
*/
private $isActive = true;

/**
* @ORM\OneToMany(targetEntity="Token", mappedBy="user", cascade={"persist", "remove"})
*/
private $tokens;

public function __construct()
{
$this->id = Uuid::uuid4()->toString();
$this->tokens = new ArrayCollection();
}

public function getId(): string
{
return $this->id;
}

public function getUsername()
{
return $this->username;
}

public function setUsername(string $username): self
{
$this->username = $username;

return $this;
}

public function getPassword()
{
return $this->password;
}

public function setPassword(string $password): self
{
$this->password = $password;

return $this;
}

public function getEmail(): string
{
return $this->email;
}

public function setEmail(string $email): self
{
$this->email = $email;

return $this;
}

public function getIsActive(): bool
{
return $this->isActive;
}

public function setIsActive(bool $isActive): self
{
$this->isActive = $isActive;

return $this;
}

public function getRoles()
{
return $this->roles;
}

public function setRoles(iterable $roles): self
{
$this->roles = $roles;

return $this;
}

public function getSalt()
{
}

public function eraseCredentials()
{
}

public function addToken(Token $token): self
{
$this->tokens[] = $token;

return $this;
}

public function removeToken(Token $token): bool
{
return $this->tokens->removeElement($token);
}

public function getTokens(): Collection
{
return $this->tokens;
}
}

Token


declare(strict_types=1);

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
* @ORM\Entity
* @ORM\Table(name="tokens")
*/
class Token
{
/**
* @ORM\Id
* @ORM\Column(name="id", type="string", length=36, nullable=false)
* @ORM\GeneratedValue(strategy="NONE")
*/
private $id;

/**
* @ORM\Column(name="data", type="text", nullable=false)
*/
private $data;

/**
* @ORM\Column(name="created_at", type="string", length=30, nullable=false)
*/
private $createdAt;

/**
* @ORM\Column(name="expires_at", type="string", length=30, nullable=false)
*/
private $expiresAt;

/**
* @ORM\ManyToOne(targetEntity="User", inversedBy="tokens", cascade={"persist"})
* @ORM\JoinColumn(name="user_id", referencedColumnName="id", nullable=true)
*/
private $user;

public function setId(string $id): self
{
$this->id = $id;

return $this;
}

public function getId(): string
{
return $this->id;
}

public function setData(string $data): self
{
$this->data = $data;

return $this;
}

public function getData(): string
{
return $this->data;
}

public function setCreatedAt(string $createdAt): self
{
$this->createdAt = $createdAt;

return $this;
}

public function getCreatedAt(): string
{
return $this->createdAt;
}

public function setExpiresAt(string $expiresAt): self
{
$this->expiresAt = $expiresAt;

return $this;
}

public function getExpiresAt(): string
{
return $this->expiresAt;
}

public function setUser(User $user): self
{
$this->user = $user;

return $this;
}

public function getUser(): User
{
return $this->user;
}
}

Repository class


Repository/UserRepository.php


declare(strict_types=1);

namespace App\Repository;

use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Query;

class UserRepository implements UserRepositoryInterface
{
private $entityManager;
private $entityRepository;

public function __construct(
EntityManagerInterface $entityManager,
EntityRepository $entityRepository
) {
$this->entityManager = $entityManager;
$this->entityRepository = $entityRepository;
}

public function findOneActiveById(string $id): ?User
{
return $this->entityRepository
->createQueryBuilder('u')
->where('u.id = :id')
->andWhere('u.isActive = true')
->setParameter('id', $id)
->getQuery()
->getOneOrNullResult(Query::HYDRATE_SIMPLEOBJECT);
}

public function findOneActiveByUsername(string $username): ?User
{
return $this->entityRepository
->createQueryBuilder('u')
->where('u.username = :username')
->andWhere('u.isActive = true')
->setParameter('username', $username)
->getQuery()
->setHint(Query::HINT_FORCE_PARTIAL_LOAD, true)
->getOneOrNullResult(Query::HYDRATE_SIMPLEOBJECT);
}
}

declare(strict_types=1);

namespace App\Repository;

use App\Entity\User;

interface UserRepositoryInterface
{
public function findOneActiveById(string $id): ?User;

public function findOneActiveByUsername(string $username): ?User;
}

# config/services/repositories.yaml

services:
_defaults:
autowire: true
autoconfigure: true
public: false

App\Repository\:
resource: '../../src/Repository'

app.entity_repository.user:
class: Doctrine\ORM\EntityRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
arguments:
- App\Entity\User

App\Repository\UserRepository:
arguments:
$entityRepository: '@app.entity_repository.user'

Command class


Command/CreateUserCommand.php


declare(strict_types=1);

namespace App\Command;

use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;

class CreateUserCommand extends Command
{
private $entityManager;
private $userPasswordEncoder;

public function __construct(
EntityManagerInterface $entityManager,
UserPasswordEncoderInterface $userPasswordEncoder
) {
parent::__construct();

$this->entityManager = $entityManager;
$this->userPasswordEncoder = $userPasswordEncoder;
}

protected function configure()
{
$this
->setName('app:create-user')
->setDescription('Creates a user entry in database.')
->addOption(
'username',
null,
InputOption::VALUE_REQUIRED,
'The username of the user.'
)
->addOption(
'email',
null,
InputOption::VALUE_REQUIRED,
'The email of the user.'
)
->addOption(
'roles',
null,
InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
'The roles of the user.'
);
}

protected function execute(InputInterface $input, OutputInterface $output)
{
$password = bin2hex(random_bytes(16));

$this->createUser($input, $password);
$this->outputCredentials($output, $input->getOption('username'), $password);
}

private function createUser(InputInterface $input, $password)
{
$user = new User();
$user->setUsername($input->getOption('username'));
$user->setEmail($input->getOption('email'));
$user->setRoles($input->getOption('roles'));
$password = $this->userPasswordEncoder->encodePassword($user, $password);
$user->setPassword($password);

$this->entityManager->persist($user);
$this->entityManager->flush();
}

private function outputCredentials(OutputInterface $output, $username, $password)
{
$output->writeln(PHP_EOL.'CREDENTIALS');
$output->writeln('-----------');
$output->writeln('Username: '.$username);
$output->writeln('Password: '.$password.PHP_EOL);
}
}

# config/services/commands.yaml

services:
_defaults:
autowire: true
autoconfigure: true
public: false

App\Command\:
resource: '../../src/Command'

app.command.create_user:
class: App\Command\CreateUserCommand
tags:
- { name: console.command }

Create users


$ bin/console app:create-user --username="basic" --email="basic@domain.com" --roles="ROLE_USER"

CREDENTIALS
-----------
Username: basic
Password: 5b18898f343e6a763105a2fab26b9d5b

$ bin/console app:create-user --username="admin" --email="admin@domain.com" --roles="ROLE_USER" --roles="ROLE_ADMIN"

CREDENTIALS
-----------
Username: admin
Password: b5e71d52c22c80d25dee18ad1d7fd441

When a user wants to obtain a authentication token by calling /login endpoint, they will POST credentials above.


Security classes


Security/JwtUserAuthenticator.php


declare(strict_types=1);

namespace App\Security;

use App\Entity\User;
use App\Util\JwtUtilInterface;
use DateTime;
use Exception;
use InvalidArgumentException;
use stdClass;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\PreAuthenticatedToken;
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\Core\User\UserProviderInterface;
use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface;
use Symfony\Component\Security\Http\Authentication\SimplePreAuthenticatorInterface;

class JwtUserAuthenticator implements SimplePreAuthenticatorInterface, AuthenticationFailureHandlerInterface
{
private $jwtUtil;

public function __construct(JwtUtilInterface $jwtUtil)
{
$this->jwtUtil = $jwtUtil;
}

public function createToken(Request $request, $providerKey)
{
$token = $request->headers->get('Authorization');
if (!$token) {
throw new CustomUserMessageAuthenticationException('Missing token.');
}

return new PreAuthenticatedToken('anon.', $token, $providerKey);
}

public function supportsToken(TokenInterface $token, $providerKey)
{
return $token instanceof PreAuthenticatedToken && $token->getProviderKey() === $providerKey;
}

public function authenticateToken(TokenInterface $token, UserProviderInterface $userProvider, $providerKey)
{
if (!$userProvider instanceof JwtUserProvider) {
throw new InvalidArgumentException('Invalid provider.');
}

$tokenData = $this->validateToken($token);

$user = $userProvider->loadUserByUsername($tokenData->user->id);
if (!$user instanceof User) {
throw new CustomUserMessageAuthenticationException('User not found.');
}

return new PreAuthenticatedToken($user, $user->getUsername(), $providerKey, $user->getRoles());
}

public function onAuthenticationFailure(Request $request, AuthenticationException $exception)
{
return new Response($exception->getMessageKey(), Response::HTTP_UNAUTHORIZED);
}

public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey)
{
return null;
}

private function validateToken(TokenInterface $token): stdClass
{
preg_match('/^(Bearer )(.*)/', $token->getCredentials(), $matches);
if (!$matches) {
throw new CustomUserMessageAuthenticationException('Invalid token.');
}

try {
$tokenData = $this->jwtUtil->decode($matches[2]);
} catch (Exception $e) {
throw new CustomUserMessageAuthenticationException('Invalid token.');
}

$expiresAt = new DateTime($tokenData->expires_at);
if ($expiresAt < new DateTime()) {
throw new CustomUserMessageAuthenticationException('Token expired.');
}

return $tokenData;
}
}

Security/JwtUserProvider.php


declare(strict_types=1);

namespace App\Security;

use App\Entity\User;
use App\Repository\UserRepositoryInterface;
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 $userRepository;

public function __construct(UserRepositoryInterface $userRepository)
{
$this->userRepository = $userRepository;
}

public function loadUserByUsername($id)
{
return $this->userRepository->findOneActiveById($id);
}

public function refreshUser(UserInterface $user)
{
throw new UnsupportedUserException();
}

public function supportsClass($class)
{
return User::class === $class;
}
}

Security/JwtUserInterface.php


declare(strict_types=1);

namespace App\Security;

use App\Entity\User;

interface JwtUserInterface
{
public function get(): User;
}

Security/JwtUser.php


declare(strict_types=1);

namespace App\Security;

use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Exception\TokenNotFoundException;

class JwtUser implements JwtUserInterface
{
private $tokenStorage;

public function __construct(
TokenStorageInterface $tokenStorage
) {
$this->tokenStorage = $tokenStorage;
}

public function get(): User
{
$user = $this->tokenStorage->getToken()->getUser();
if (!$user instanceof User) {
throw new TokenNotFoundException('User not found.');
}

return $user;
}
}

# config/services/securities.yaml

services:
_defaults:
autowire: true
autoconfigure: true
public: false

App\Security\:
resource: '../../src/Security'

Util class


Util/JwtUtil.php


declare(strict_types=1);

namespace App\Util;

use Firebase\JWT\JWT;
use stdClass;

class JwtUtil implements JwtUtilInterface
{
private $jwtAlgorithm;
private $jwtPrivateKey;
private $jwtPublicKey;

public function __construct(
string $jwtAlgorithm,
string $jwtPrivateKey,
string $jwtPublicKey
) {
$this->jwtAlgorithm = $jwtAlgorithm;
$this->jwtPrivateKey = $jwtPrivateKey;
$this->jwtPublicKey = $jwtPublicKey;
}

public function encode(iterable $tokenData): string
{
return JWT::encode($tokenData, file_get_contents($this->jwtPrivateKey), $this->jwtAlgorithm);
}

public function decode(string $tokenString): stdClass
{
return JWT::decode($tokenString, file_get_contents($this->jwtPublicKey), [$this->jwtAlgorithm]);
}
}

declare(strict_types=1);

namespace App\Util;

use stdClass;

interface JwtUtilInterface
{
public function encode(iterable $tokenData): string;

public function decode(string $tokenString): stdClass;
}

# config/services/utils.yaml

services:
_defaults:
autowire: true
autoconfigure: true
public: false

App\Util\:
resource: '../../src/Util'

App\Util\JwtUtil:
arguments:
$jwtAlgorithm: '%jwt_algorithm%'
$jwtPrivateKey: '%jwt_private_key%'
$jwtPublicKey: '%jwt_public_key%'

Users in Database


mysql> SELECT * FROM users;
+--------------------------------------+----------+--------------------------------------------------------------+------------------+--------------------------------------------------+-----------+
| id | username | password | email | roles | is_active |
+--------------------------------------+----------+--------------------------------------------------------------+------------------+--------------------------------------------------+-----------+
| cdac445a-27cf-4a3c-aa5d-2fcff32ad135 | basic | $2y$13$OsqUKZBUEl9995BjQp4spux00TXvdnJHss4QQricpUCZh2NhIO0S. | basic@domain.com | a:1:{i:0;s:9:"ROLE_USER";} | 1 |
| f53bf3d5-f3e6-4c18-b9a4-4b45921b7b94 | admin | $2y$13$X73aR26ctEUQn5byVtiheeMfGyYhoERN1cjY5Dtt3847.KjffQwHa | admin@domain.com | a:2:{i:0;s:9:"ROLE_USER";i:1;s:10:"ROLE_ADMIN";} | 1 |
+--------------------------------------+----------+--------------------------------------------------------------+------------------+--------------------------------------------------+-----------+
2 rows in set (0.00 sec)

Tests


Obtain token


$ curl -i -X POST http://192.168.99.20:81/login
-H 'Content-Type: application/json'
-d '{"username": "basic","password":"5b18898f343e6a763105a2fab26b9d5b"}'

HTTP/1.1 201 Created
Server: nginx/1.6.2
Content-Type: text/html; charset=UTF-8
Transfer-Encoding: chunked
Connection: keep-alive
Cache-Control: no-cache, private
Date: Sat, 18 Aug 2018 20:56:32 GMT

eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpZCI6ImZlODAzO...........

If you wish, you can use https://jwt.io/ to manually verify your JWT token.


Consume API


$ curl -i -X GET http://192.168.99.20:81/api/v1/countries
-H 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpZCI6ImZlODAzO...........'

HTTP/1.1 200 OK
Server: nginx/1.6.2
Content-Type: text/html; charset=UTF-8
Transfer-Encoding: chunked
Connection: keep-alive
Cache-Control: no-cache, private
Date: Sat, 18 Aug 2018 20:58:58 GMT

{"user_id":"cdac445a-27cf-4a3c-aa5d-2fcff32ad135","user_roles":["ROLE_USER"]}