How to create an API with Symfony 4 and JWT

1. Docker

#docker-compose.yaml
version: "3.1"
volumes:
db-data:
services:
mysql:
image: mysql:5.6
container_name: ${PROJECT_NAME}-mysql
working_dir: /application
volumes:
- db-data:/application
environment:
- MYSQL_ROOT_PASSWORD=docker_root
- MYSQL_DATABASE=sf4_db
- MYSQL_USER=sf4_user
- MYSQL_PASSWORD=sf4_pw
ports:
- "8306:3306"
webserver:
image: nginx:alpine
container_name: ${PROJECT_NAME}-webserver
working_dir: /application
volumes:
- .:/application
- ./docker/nginx/nginx.conf:/etc/nginx/conf.d/default.conf
ports:
- "8000:80"
php-fpm:
build: docker/php-fpm
container_name: ${PROJECT_NAME}-php-fpm
working_dir: /application
volumes:
- .:/application
- ./docker/php-fpm/php-ini-overrides.ini:/etc/php/7.2/fpm/conf.d/99-overrides.ini
environment:
XDEBUG_CONFIG: "remote_host=${localIp}"
docker-compose build
docker-compose up -d

2. Creating a Symfony project

docker-compose exec php-fpm bash
#inside php-fpm bash
composer create-project symfony/website-skeleton symfony
#inside php-fpm bash
mv /application/symfony/* /application
mv /application/symfony/.* /application
rm -Rf /application/symfony

3. Mapping our User in the database

#inside php-fpm bash
composer require friendsofsymfony/user-bundle "~2.0"
"The child node "db_driver" at path "fos_user" must be configured." 

3.1 Configuriation

#config/services.yaml# FOS user config
fos_user:
db_driver: orm # other valid values are 'mongodb', 'couchdb' and 'propel'
firewall_name: main
user_class: App\Entity\User
from_email:
address: "no-reply@joeymasip.com"
sender_name: "Joey"
registration:
# form:
# type: AppBundle\Form\UserRegisterType
confirmation:
enabled: true
template: FOSUserBundle:Registration:email.txt.twig
from_email:
address: "no-reply@joeymasip.com"
sender_name: "No Reply Registration"
service:
mailer: fos_user.mailer.twig_swift
resetting:
email:
template: FOSUserBundle:Resetting:email.txt.twig

3.2 Creating the User class

namespace App\Entity;use FOS\UserBundle\Model\User as BaseUser;
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Entity
* @ORM\Table(name="fos_user")
*/
class User extends BaseUser
{
/**
* @ORM\Id
* @ORM\Column(type="integer")
* @ORM\GeneratedValue(strategy="AUTO")
*/
protected $id;
public function __construct()
{
parent::__construct();
// your own logic
}
}

3.3 Configuring main firewall

#config/packages/security.yaml
security:
encoders:
FOS\UserBundle\Model\UserInterface: bcrypt
Symfony\Component\Security\Core\User\User: plaintext
role_hierarchy:
ROLE_ADMIN: ROLE_USER
ROLE_SUPER_ADMIN: ROLE_ADMIN
providers:
chain_provider:
chain:
providers: [in_memory, fos_userbundle]
in_memory:
memory:
users:
superadmin:
password: 'superadminpw'
roles: ['ROLE_SUPER_ADMIN']
fos_userbundle:
id: fos_user.user_provider.username
access_control:
- { path: ^/login$, role: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/register, role: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/resetting, role: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/admin/, role: ROLE_ADMIN }
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
pattern: ^/
form_login:
provider: chain_provider
csrf_token_generator: security.csrf.token_manager
login_path: fos_user_security_login
check_path: fos_user_security_check
always_use_default_target_path: false
default_target_path: admin_admin_index
logout:
path: fos_user_security_logout
target: fos_user_security_login
anonymous: true

3.4 Creating the register API endpoint

#config/routes.yaml
api:
prefix: /api
resource: '../src/Controller/Api'
namespace App\Controller\Api;use FOS\UserBundle\Model\UserManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use App\Entity\User;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Validator\Validation;
use Symfony\Component\Validator\Constraints as Assert;
/**
* @Route("/auth")
*/
class ApiAuthController extends AbstractController
{
/**
* @Route("/register", name="api_auth_register", methods={"POST"})
* @param Request $request
* @param UserManagerInterface $userManager
* @return JsonResponse|\Symfony\Component\HttpFoundation\RedirectResponse
*/
public function register(Request $request, UserManagerInterface $userManager)
{
$data = json_decode(
$request->getContent(),
true
);
$validator = Validation::createValidator(); $constraint = new Assert\Collection(array(
// the keys correspond to the keys in the input array
'username' => new Assert\Length(array('min' => 1)),
'password' => new Assert\Length(array('min' => 1)),
'email' => new Assert\Email(),
));
$violations = $validator->validate($data, $constraint); if ($violations->count() > 0) {
return new JsonResponse(["error" => (string)$violations], 500);
}
$username = $data['username'];
$password = $data['password'];
$email = $data['email'];
$user = new User(); $user
->setUsername($username)
->setPlainPassword($password)
->setEmail($email)
->setEnabled(true)
->setRoles(['ROLE_USER'])
->setSuperAdmin(false)
;
try {
$userManager->updateUser($user, true);
} catch (\Exception $e) {
return new JsonResponse(["error" => $e->getMessage()], 500);
}
return new JsonResponse(["success" => $user->getUsername(). " has been registered!"], 200);
}
}
curl -X POST -H "Content-Type: application/json" http://localhost:8000/api/auth/register -d '{"username":"patata","password":"fregida", "email":"patatafregida@joeymasip.com"}'

4. LexikJWTAuthenticationBundle

#inside php-fpm bash
composer require lexik/jwt-authentication-bundle

4.1 Private and Public keys

#inside php-fpm bash
mkdir config/jwt
openssl genrsa -out config/jwt/private.pem -aes256 4096
openssl rsa -pubout -in config/jwt/private.pem -out config/jwt/public.pem

4.2 Configuration

#config/packages/lexik_jwt_authentication.yaml
lexik_jwt_authentication:
secret_key: '%env(resolve:JWT_SECRET_KEY)%'
public_key: '%env(resolve:JWT_PUBLIC_KEY)%'
pass_phrase: '%env(JWT_PASSPHRASE)%'
token_ttl: 3600
#.env
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
JWT_PASSPHRASE=sf4jwt

4.3 Routes

#config/routes.yaml
api_auth_login:
path: /api/auth/login
methods: [POST]
api:
prefix: /api
resource: '../src/Controller/Api'

4.4 Firewalls

#config/packages/security.yaml
security:
#...
firewalls:
dev:
#...
api_login:
pattern: ^/api/auth/login
stateless: true
anonymous: true
json_login:
provider: chain_provider
check_path: /api/auth/login
success_handler: lexik_jwt_authentication.handler.authentication_success
failure_handler: lexik_jwt_authentication.handler.authentication_failure
provider: chain_provider
main:
#...
curl -X POST -H "Content-Type: application/json" http://localhost:8000/api/auth/login -d '{"username":"patata","password":"fregida"}'
{"token":"eyJ0eXAiOiJKV1QiLCJhbGciOi..."}
#config/packages/security.yaml
security:
#...
firewalls:
dev:
#...
api_login:
#...
api:
pattern: ^/api
stateless: true
anonymous: false
provider: chain_provider
guard:
authenticators:
- lexik_jwt_authentication.jwt_token_authenticator
main:
#...
{
"code": 401,
"message": "JWT Token not found"
}
#config/packages/security.yaml
security:
#...
firewalls:
dev:
#...
api_login:
#...
api_register:
pattern: ^/api/auth/register
stateless: true
anonymous: true
api:
#...
main:
#...
Bearer eyJ0eXAiOiJKV1QiLCJhbGc...
#config/packages/security.yaml
security:
#...
firewalls:
dev:
#...
api_login:
#...
api_register:
#...
api:
#...
anonymous: true
#...
main:
#...
#config/packages/security.yaml
security:
#...
access_control:
#...
- { path: ^/api/auth/login, roles: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/api/auth/register, roles: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
namespace App\Controller\Api;/**
* @Route("/auth")
*/
class ApiAuthController extends AbstractController
{
/**
* @Route("/register", name="api_auth_register", methods={"POST"})
* @param Request $request
* @param UserManagerInterface $userManager
* @return JsonResponse|\Symfony\Component\HttpFoundation\RedirectResponse
*/
public function register(Request $request, UserManagerInterface $userManager)
{
#...
# Code 307 preserves the request method, while redirectToRoute() is a shortcut method.
return $this->redirectToRoute('api_auth_login', [
'username' => $data['username'],
'password' => $data['password']
], 307);
}
}

5. NelmioApiDocBundle

#inside php-fpm bash
composer require nelmio/api-doc-bundle

5.1 Configuration

#config/packages/nelmio_api_doc.yaml
nelmio_api_doc:
documentation:
# schemes: [http, https]
info:
title: Symfony JWT API
description: Symfony JWT API docs
version: 1.0.0
securityDefinitions:
Bearer:
type: apiKey
description: 'Authorization: Bearer {jwt}'
name: Authorization
in: header
security:
- Bearer: []
areas: # to filter documented areas
path_patterns:
- ^/api(?!/doc$) # Accepts routes under /api except /api/doc

5.2 Routing

#config/routes/nelmio_api_doc.yaml
app.swagger_ui:
path: /api/doc
methods: GET
defaults: { _controller: nelmio_api_doc.controller.swagger_ui }

5.3 ACL

#config/packages/security.yaml
security:
#...
access_control:
#...
- { path: ^/api/doc, roles: IS_AUTHENTICATED_ANONYMOUSLY }
#...
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }

6. NelmioCorsBundle

#inside php-fpm bash
composer req cors
#config/packages/nelmio_cors.yaml
nelmio_cors:
defaults:
origin_regex: true
allow_origin: ['%env(CORS_ALLOW_ORIGIN)%']
allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE']
allow_headers: ['Content-Type', 'Authorization']
expose_headers: ['Link']
max_age: 3600
paths:
'^/': ~
#config/packages/nelmio_cors.yaml
nelmio_cors:
defaults:
allow_credentials: false
allow_origin: []
allow_headers: []
allow_methods: []
expose_headers: []
max_age: 0
hosts: []
origin_regex: false
forced_allow_origin_value: ~
paths:
'^/api/':
allow_origin: ['*']
allow_headers: ['Content-Type', 'Authorization']
allow_methods: ['POST', 'PUT', 'GET', 'DELETE']
max_age: 3600
'^/':
origin_regex: true
allow_origin: ['^http://localhost:[0-9]+']
allow_headers: ['Content-Type', 'Authorization']
allow_methods: ['POST', 'PUT', 'GET', 'DELETE']
max_age: 3600
hosts: ['^api\.']

7. Creating an example API enpoint

namespace App\Controller\Api;use App\Entity\User;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Serializer\Encoder\XmlEncoder;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
/**
* @Route("/user")
*/
class ApiUserController extends AbstractController
{
/**
* @Route("/{id}", name="api_user_detail", methods={"GET"})
* @param User $user
* @return JsonResponse
*/
public function detail(User $user)
{
$this->denyAccessUnlessGranted('view', $user);
return new JsonResponse($this->serialize($user), 200);
}
protected function serialize(User $user)
{
$encoders = [new XmlEncoder(), new JsonEncoder()];
$normalizers = [new ObjectNormalizer()];
$serializer = new Serializer($normalizers, $encoders); $json = $serializer->serialize($user, 'json'); return $json;
}
}
namespace App\Security;use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface;
class UserVoter extends Voter
{
// these strings are just invented: you can use anything
const VIEW = 'view';
const EDIT = 'edit';
private $decisionManager; public function __construct(AccessDecisionManagerInterface $decisionManager)
{
$this->decisionManager = $decisionManager;
}
protected function supports($attribute, $subject)
{
// if the attribute isn't one we support, return false
if (!in_array($attribute, array(self::VIEW, self::EDIT))) {
return false;
}
// only vote on User objects inside this voter
if (!$subject instanceof User) {
return false;
}
return true;
}
protected function voteOnAttribute($attribute, $subject, TokenInterface $token)
{
$user = $token->getUser();
if (!$user instanceof User) {
// the user must be logged in; if not, deny access
return false;
}
// ROLE_SUPER_ADMIN can do anything! The power!
if ($this->decisionManager->decide($token, array('ROLE_ADMIN'))) {
return true;
}
// you know $subject is a User object, thanks to supports
/** @var User $userSubject */
$userSubject = $subject;
switch ($attribute) {
case self::VIEW:
return $this->canView($userSubject, $user);
case self::EDIT:
return $this->canEdit($userSubject, $user);
}
throw new \LogicException('This code should not be reached!');
}
private function canView(User $userSubject, User $user)
{
// if they can edit, they can view
if ($this->canEdit($userSubject, $user)) {
return true;
}
// the User object could have, for example, a method isPrivate()
// that checks a boolean $private property
return $user === $userSubject;
}
private function canEdit(User $userSubject, User $user)
{
// this assumes that the data object has a getOwner() method
// to get the entity of the user who owns this data object
return $user === $userSubject;
}
}
$tokenInterface->getUser()
curl -X GET -H "Content-Type: application/json" http://localhost:8000/api/user/1
{"code":401,"message":"JWT Token not found"}
curl -X GET -H "Content-Type: application/json" -H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpYXQiOjE1NDgyODI4NjUsImV4cCI6MTU0ODI4NjQ2NSwicm9sZXMiOlsiUk9MRV9VU0VSIl0sInVzZXJuYW1lIjoicGF0YXRhIn0.SLB40jBqtbY7Ql5DI4L4rZBEcH5hXNqn9u0eVYOCtlE_vqa_1RYQzHHVy7iMbmP4CMjSKUiBlGzuIBGApRmD36CFKgdMdfXuqrHeEFB-BUsd-HezdLg6U762GnLbe4g4vHzg9XKZVCzRtpboGUKgVycIaMdfiZ1FvUkJeZvdS_5HgHW43LrSqntPlbEaNaEYv7mrkzTQNi1WiKwJaDCW5M0JgRkfbHoUYXMadFUxR4KSnaXRQAwxZnqqPBW4dRs97ho5A15XKpuxmEWemvhMs0XL9E8KyPNG7ZipLis4JGs3X9Mn-ov4RDCqYbShSNbtj_F2gcakXL97FF3myLn1U2XfIzuwxq9ZIJnGLtemKgPlxSB1uxX5ep9aYxppuXpwxY0vGr9MsOgyL3kkuMqeXvFDN46bY-3P8TLOqEuPrrlKYuRfMQv6Wrhdq0orl3eo7t83YCb_Z-Mf7yeDDGeJsftaj4pALJUw4Ovo6Kv_4gNcG3VQpkJr4XtnULAcO9O_OJLgVOBXoOc7lUQmokdvAGeltEBmYIZD_2KtGrTwS4rL55LMn3MawL4dKVIAg8aaYbPDCxkk1t3LdZyI5zSUiJvLaCrMM8ZhJ7eJ0rKod2-d_dZcYPzQ5RF_wD8spuw1pkT6r4hMyTJvGQUUDjN-3E-MkNBHT8Ku8Z8I7a65x5M" http://localhost:8000/api/user/1

Conclusion

Resources

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store