Skip to main content

Implementing authorization in Symfony with Cryptr

Cryptr - Symfony illustration

Symfony Authorization Getting Started: Secure an API

Symfony v4.23.54min

Learn how to use Cryptr to implement authorization in Symfony

In this tutorial, we are going to create an API that will allow you to read the data, i.e. see the list of courses in our example. Security will be enhanced, so that only Cryptr users can access the list of courses by checking if the token is valid by following the Cryptr security procedure.

Letโ€™s get started! ๐Ÿ˜‰

1. Configurationโ€‹

Create a Symfony applicationโ€‹

๐Ÿ› ๏ธ๏ธ First, open up your terminal and create this new symfony project by typing this command:

composer create-project symfony/skeleton cryptr-symfony-api-sample

This creates a new folder called cryptr-symfony-api-sample and configures your application in that folder

NOTE

Composer is a dependency manager in PHP. With it, you can declare the dependent libraries your project needs and they will be installed in your project by this tool. See Composer's getting started doc for more information.

๐Ÿ› ๏ธ๏ธ Once itโ€™s finished, go to your project with command cd cryptr-symfony-api-sample

You can start the application by running symfony server:start

2. Valid access tokensโ€‹

Install dependenciesโ€‹

๐Ÿ› ๏ธ๏ธ To begin, install these dependencies using composer:

composer require symfony/maker-bundle --dev
composer require doctrine/annotations
composer require symfony/debug-bundle --dev
composer require symfony/security-bundle
composer require firebase/php-jwt
composer require symfony/http-client
composer require nelmio/cors-bundle

Create sample resource controllerโ€‹

๐Ÿ›  First, create a CourseController. We'll use the symfony/maker bundle to generate it :

php bin/console make:controller CourseController
NOTE

The purpose of the controller is to receive a request (which has already been selected by a route) and to define the appropriate response.

๐Ÿ› ๏ธ๏ธ Now open up src/Controller/CourseController.php and replace its content with the following:

src/Controller/CourseController.php
<?php

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Annotation\Route;

class CourseController extends AbstractController
{
/**
* @Route("/api/v1/courses", name="courses")
*/
public function index(): JsonResponse
{
$courses = [array(
"id" => 1,
"user_id" =>
"eba25511-afce-4c8e-8cab-f82822434648",
"title" => "learn git",
"tags" => ["colaborate", "git" ,"cli", "commit", "versionning"],
"img" => "https://carlchenet.com/wp-content/uploads/2019/04/git-logo.png",
"desc" => "Learn how to create, manage, fork, and collaborate on a project. Git stays a major part of all companies projects. Learning git is learning how to make your project better everyday",
"date" => '5 Nov',
"timestamp" => 1604577600000,
"teacher" => array(
"name" => "Max",
"picture" => "https://images.unsplash.com/photo-1558531304-a4773b7e3a9c?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=634&q=80"
)
)];
return new JsonResponse($courses);
}
}

๐Ÿ› ๏ธ๏ธ Run the server with command symfony server:start and open insomnia or postman to make a GET on http://localhost:8000/api/v1/courses request which should end with 200:

Rendering

3. Application keysโ€‹

Create an application with Cryptrโ€‹

๐Ÿ› ๏ธ๏ธ In order to start, you need to create a free Cryptr account if you don't have one yet.

Rendering Rendering

๐Ÿ› ๏ธ๏ธ You can then create your application by following the onboarding steps.

Rendering

Once you've completed all the funnel, after the processing steps, you arrive on the homepage of the back-office where you have access to various guides that will help you. They will depend on the choices of technology you have made in the funnel.

Rendering

๐Ÿ› ๏ธ๏ธ You can get your environment configuration by first clicking on ยซ applications ยป in the left menu, then by clicking on the application you will have created. A modal will appear and this is where you may copy/paste your environment variables.

Rendering Rendering

Add your Cryptr credentialsโ€‹

๐Ÿ›  Complete the .env file with the variables that you obtained previously (you can retrieve them in the Cryptr back-office, in the modal that is displayed when you select your application.). Don't forget to replace YOUR_CLIENT_ID & YOUR_DOMAIN

CRYPTR_AUDIENCE=http://localhost:8081
CRYPTR_BASE_URL=https://auth.cryptr.eu
CRYPTR_TENANT_DOMAIN=YOUR_DOMAIN
CRYPTR_ALLOWED_ORIGINS=http://localhost:8081
CRYPTR_CLIENT_IDS=YOUR_CLIENT_ID
NOTE

If you are from the EU, you must add https://auth.cryptr.eu/ in the CRYPTR_BASE_URL variable, and if you are from the US, you must add https://auth.cryptr.us/ in the same variable.

Restart the terminal

Make sure to restart your shell/terminal when you modify the environment file

4. Protect API Endpointsโ€‹

Create User security entityโ€‹

We must create a User class or an entity in order to register or authenticate a user in our application.

๐Ÿ› ๏ธ๏ธ Head back to the terminal and create user with command php bin/console make:user

The command above will ask you several questions:

๐Ÿ› ๏ธ๏ธ Step 1: The name of the security user class - User

Rendering

๐Ÿ› ๏ธ๏ธ Step 2: Do you want to store user data in the database - no

Rendering

๐Ÿ› ๏ธ๏ธ Step 3: Enter a property name for the user - email

Rendering

๐Ÿ› ๏ธ๏ธ Step 4: Will this app need to hash/check user passwords? - no

Rendering

๐Ÿ› ๏ธ๏ธ Success Step:

Rendering

CryptrClaimsValidation and CryptrGuardโ€‹

๐Ÿ› ๏ธ๏ธ Create new files in src/Security folder, enter this command in your terminal:

touch src/Security/CryptrClaimsValidation.php src/Security/CryptrGuardAuthenticator.php

๐Ÿ› ๏ธ๏ธ Now open up the newly created src/Security/CryptrClaimsValidation.php file and paste in the following:

src/Security/CryptrClaimsValidation.php
<?php
namespace App\Security;

use DateTime;
use Exception;
use Psr\Log\LoggerInterface;


class CryptrClaimsValidation
{
public function __construct(LoggerInterface $logger)
{
$this->logger = $logger;
$this->cryptrBaseUrl = $_ENV['CRYPTR_BASE_URL'];
$this->cryptrTenantDomain = $_ENV['CRYPTR_TENANT_DOMAIN'];

# Audiences
$this->allowedOrigins = \explode(',', $_ENV['CRYPTR_ALLOWED_ORIGINS']);
$this->allowedClientIds = \explode(',', $_ENV['CRYPTR_CLIENT_IDS']);
}

public function issuer()
{
return "{$this->cryptrBaseUrl}/t/{$this->cryptrTenantDomain}";
}
public function jwksUri()
{
return "{$this->issuer()}/.well-known";
}

public function validateResourceOwner($decodedToken, $userId)
{
if ($decodedToken->sub != $userId) {
throw new Exception('The resource owner identifier (cryptr user id) of the JWT claim (sub) is not compliant');
}
return true;
}

public function validateScopes($decodedToken, $authorizedScopes)
{
if (array_intersect($decodedToken->scp, $authorizedScopes) != $decodedToken->scp){
throw new Exception('The scopes of the JWT claim (scp) resource are not compliants');
};
return true;
}

private function currentTime() {
return new DateTime();
}

public function validateExpiration($decodedToken) {
$expiration = DateTime::createFromFormat( 'U', $decodedToken->exp );

if ($expiration < $this->currentTime()){
throw new Exception('The expiration of the JWT claim (exp) should be greater than current time');
}

return true;
}

public function validateIssuedAt($decodedToken) {
$issuedAt = DateTime::createFromFormat( 'U', $decodedToken->iat );

if ($this->currentTime() < $issuedAt){
throw new Exception('The issuedAt of the JWT claim (iat) should be lower than current time');
};

return true;
}

public function validateNotBefore($decodedToken) {
if(isset($decodedToken->nbf)) {
$notBefore = DateTime::createFromFormat( 'U', $decodedToken->nbf );

if ($this->currentTime() < $notBefore){
throw new Exception('The notBefore of the JWT claim (iat) should be lower than current time');
};

return true;
} else {
$this->logger->info("'nbf key not present and not checked for now but will be in future");
return true;
}
}

public function validateIssuer($decodedToken) {
if ($decodedToken->iss != $this->issuer()){
throw new Exception('The JWT (iss) claim issuer must conform to issuer from config');
};

return true;
}

public function validateAudience($decodedToken) {
if (!in_array($decodedToken->aud, $this->allowedOrigins)){
throw new Exception('The JWT (aud) claim audience must conform to audience from config');
};

return true;
}

public function isValid($decodedToken)
{
// exp (Expiration Time)
return $this->validateExpiration($decodedToken) &&
// iat (Issued At)
$this->validateIssuedAt($decodedToken) &&
// nbf (Not before)
$this->validateNotBefore($decodedToken)&&
// iss (Issuer)
$this->validateIssuer($decodedToken) &&
// aud (Audience)
$this->validateAudience($decodedToken);
}
}

CryptrClaimsValidation allows you to validate the token (the user access token) before retrieving the response to the request.

๐Ÿ› ๏ธ๏ธ Next, open up src/Security/CryptrGuardAuthenticator.php and paste in the following:

src/Security/CryptrGuardAuthenticator.php
<?php
namespace App\Security;

use App\Security\User;
use App\Security\JwtClaimsValidation;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Guard\AbstractGuardAuthenticator;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use \Firebase\JWT\JWK;
use \Firebase\JWT\JWT;
use Symfony\Component\String\UnicodeString;
use Symfony\Component\Config\Definition\Exception\Exception;

class CryptrGuardAuthenticator extends AbstractGuardAuthenticator
{
private $logger;
private $client;
private $jwtClaims;

public function __construct(HttpClientInterface $client, LoggerInterface $logger){
$this->logger = $logger;
$this->client = $client;
$this->cryptrBaseUrl = $_ENV['CRYPTR_BASE_URL'];
$this->cryptrTenantDomain = $_ENV['CRYPTR_TENANT_DOMAIN'];
$this->jwtClaims = new JwtClaimsValidation($logger);
}

public function supports(Request $request): bool
{
return $request->getMethod() !== "OPTIONS";
}

public function getCredentials(Request $request)
{
$authHeader = $request->headers->get('Authorization', '');
$authParts = \explode(" ", $authHeader);
$prefix = new UnicodeString($authParts[0]);
if($prefix->lower() == 'bearer') {
return $authParts[1];
} else {
$exception = new Exception("Bearer token required");
throw new AuthenticationException($exception->getMessage(), $exception->getCode(), $exception);
}
}

private function issuer() {
return "{$this->cryptrBaseUrl}/t/{$this->cryptrTenantDomain}";
}
private function jwksUri() {
return "{$this->issuer()}/.well-known";
}

private function getJwks() {
$response = $this->client->request(
'GET',
$this->jwksUri()
);
$keys = $response->toArray()['keys'];
return ['keys' => $keys];
}

public function decodeJwt($jwt)
{
try {
$publicKeys = JWK::parseKeySet($this->getJwks());
$decodedJwt = JWT::decode($jwt, $publicKeys, array('RS256'));
$this->jwtClaims->isValid($decodedJwt);
} catch (\Throwable $exception) {
throw new AuthenticationException($exception->getMessage(), $exception->getCode(), $exception);
}
return false;
}

public function getUser($credentials, UserProviderInterface $userProvider)
{
if($credentials == null || empty($credentials)) {
return new User('unknown', null, ['IS_AUTHENTICATED_ANONYMOUSLY']);
}
return new User('unknown', null, ['IS_AUTHENTICATED_ANONYMOUSLY']);
}

public function checkCredentials($credentials, UserInterface $user): bool
{
try {
if($credentials) {
$this->decodeJwt($credentials);
}
return true;
} catch( Exception $exception) {
throw new AuthenticationException($exception->getMessage(), $exception->getCode(), $exception);
}
}

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

public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
$respBody = [
'message' => sprintf(
'Authentication failed: %s.',
rtrim($exception->getMessage(), '.')
)
];
return new JsonResponse($respBody, JsonResponse::HTTP_UNAUTHORIZED);
}

public function start(Request $request, AuthenticationException $authException = null): Response
{
$data = [
'message' => 'Authentication Required'
];

return new JsonResponse($data, Response::HTTP_UNAUTHORIZED);
}

public function supportsRememberMe()
{
return false;
}
}

How the CryptrGuardAuthenticator proceeds:

  1. Read the access token in the header
  2. Guard read public key from Cryptr to validate the access token. If it succeeds, the response will be 200, if not, it will be 401.
SUPPORTS

If some routes are public, developers can update CryptrGuardAuthenticator::supports methods to handle this, returning false will skip the authorization check

Use CryptrGuardAuthenticatorโ€‹

We are going to use the Symfony security component called guard for the authentication process.

๐Ÿ›  Add CryptrGuardAuthenticator as application firewall in config/packages/security.yaml:

config/packages/security.yaml
security:
# https://symfony.com/doc/current/security.html#where-do-users-come-from-user-providers
providers:
# used to reload user from session & other features (e.g. switch_user)
app_user_provider:
id: App\Security\UserProvider
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
anonymous: true
lazy: true
provider: app_user_provider
# Add CryptrGuardAuthenticator:
logout: ~
guard:
authenticators:
- App\Security\CryptrGuardAuthenticator
stateless: true

# activate different ways to authenticate
# https://symfony.com/doc/current/security.html#firewalls-authentication

# https://symfony.com/doc/current/security/impersonating_user.html
# switch_user: true

# Easy way to control access for large sections of your site
# Note: Only the *first* access control that matches will be used
access_control:
# - { path: ^/admin, roles: ROLE_ADMIN }
# - { path: ^/profile, roles: ROLE_USER }
NOTE

firewalls is used to define how users will be authenticated

Symfony and HTTPS

Symfony may run on HTTPS protocol, the client app should then target the related https url, if not, the browser will forbid the request due to CORS prohibiting redirections from http to https.

Test with a Cryptr Vue appโ€‹

Letโ€™s try this on an application. For this purpose, we have an example app on Vue.

๐Ÿ›  Run your code with symfony server:start

๐Ÿ›  Clone our cryptr-vue-sample:

git clone --branch 07-backend-courses-api https://github.com/cryptr-examples/cryptr-vue2-sample.git
cd cryptr-vue2-sample

๐Ÿ›  Install the Vue project dependencies with yarn

๐Ÿ› ๏ธ๏ธ Add .env.local file with your variables. Don't forget to replace YOUR_CLIENT_ID & YOUR_DOMAIN:

VUE_APP_AUDIENCE=http://localhost:8081
VUE_APP_CLIENT_ID=YOUR_CLIENT_ID
VUE_APP_DEFAULT_LOCALE=en
VUE_APP_DEFAULT_REDIRECT_URI=http://localhost:8081
VUE_APP_TENANT_DOMAIN=YOUR_DOMAIN
VUE_APP_CRYPTR_TELEMETRY=FALSE
VUE_APP_CRYPTR_REGION=eu

๐Ÿ› ๏ธ๏ธ Open up the Profile Component in src/views/Profile.vue and modify the url request:

src/views/Profile.vue
<script>
import { getCryptrClient } from "../CryptrPlugin";
export default {
data() {
return {
courses: [],
errors: [],
};
},
created() {
const client = getCryptrClient();
console.log("created");
client
.decoratedRequest({
method: "GET",
// url: "http://localhost/api/v1/courses",
// Replace localhost by 127.0.0.1 for Symfony:
url: "http://127.0.0.1:8000/api/v1/courses",
})
.then((data) => {
console.log(data);
this.courses = data.data;
})
.catch((error) => {
console.error(error);
this.errors = [error];
});
},
};
</script>

๐Ÿ› ๏ธ๏ธ Run vue server with yarn serve and try to connect. Your Vue application redirects you to your sign form page, where you can sign in or sign up with an email.

SANDBOX EMAIL

You can log in with a sandbox email and we send you a magic link which should directly arrive in your personal inbox. Your sandbox email is based on your account's email. The email's structure is as follows: my-user-to-test@sandbox.my-admin-name. For example testemail@sandbox.lucas

Once you're connected, click on "Protected route". You can now view the list of the courses.

Itโ€™s done, congratulations if you made it to the end!

I hope this was helpful, and thanks for following this tutorial! ๐Ÿ™‚