Implementing authorization in Laravel with Cryptr
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 Laravel applicationโ
๐ ๏ธ๏ธ To create your laravel application, type this command in your terminal:
composer create-project laravel/laravel cryptr-laravel-api-sample
This creates a new folder called cryptr-laravel-api-sample
and configures your application in that folder
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-laravel-api-sample
.
You can start the application by running php artisan serve
2. 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.
๐ ๏ธ๏ธ You can then create your application by following the onboarding steps.
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.
๐ ๏ธ๏ธ 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.
Add your Cryptr credentialsโ
๐ Complete the environment file with the variables that you get 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_BASE_URL=https://auth.cryptr.eu
TENANT_DOMAIN=YOUR_DOMAIN
DEFAULT_REDIRECT_URI=http://localhost:8080
CLIENT_ID=YOUR_CLIENT_ID
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.
Make sure to restart your shell/terminal when you modify the environment file
3. Valid access tokensโ
Install dependenciesโ
๐ Update composer setup by adding PHP-JWT
to encode and decode JSON Web Tokens (JWT)
in PHP:
composer require firebase/php-jwt
๐ Open up composer.json
and add Cryptr\\": "cryptr/
in autoloader psr-4:
"autoload": {
"psr-4": {
"App\\": "app/",
// Add here
"Cryptr\\": "cryptr/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
}
},
With the autoloader, we can define a namespace prefix and the directory mapped to that prefix. Everything in the cryptr folder is a namespace. Anything the composer asks for that has a namespace starting with "Cryptr" can be found in the "cryptr" directory.
๐ Donโt forget to run composer dump-autoload
Create a sample resource modelโ
๐ First, create a basic model in order to have a data structure:
php artisan make:model Course
๐ Next, add protected $fillable = ['title', 'date', 'desc', 'img'];
before use HasFactory
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Course extends Model
{
// Add protected $fillable here
protected $fillable = ['title', 'date', 'desc', 'img'];
use HasFactory;
}
Create a sample resource controllerโ
๐ First, create an API resource controller which does not include the edit creation methods by adding --api
and add --model
to use a model instance:
php artisan make:controller CourseController --api --model=Course
The purpose of the controller is to receive a request (which has already been selected by a route) and to define the appropriate response.
๐ Next, create an Api folder and move generated file in it with this command:
mkdir app/Http/Controllers/Api && mv app/Http/Controllers/CourseController.php "$_"
๐ ๏ธ๏ธ Open up app/Http/Controllers/Api/CourseController.php
and replace the contents with this:
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Course;
use Illuminate\Http\Request;
class CourseController extends Controller
{
/**
* Display a listing of the resource.
*
* @return \Illuminate\Http\Response
*/
public function index()
{
return Course::all()->toJson();
}
/**
* Store a newly created resource in storage.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function store(Request $request)
{
$course = Course::create($request->all());
if ($course) {
return response() -> json([
'data' => $course
], 200);
} else {
return response() -> json([
'error' => 'unprocessable course'
], 422);
}
}
/**
* Display the specified resource.
*
* @param int $id
* @return \Illuminate\Http\Response
*/
public function show($id)
{
if (Course::where('id', $id)->exists()) {
return Course::find($id)->toJson();
} else {
return response()->json([
"error" => "course not found"
], 404);
}
}
/**
* Update the specified resource in storage.
*
* @param \Illuminate\Http\Request $request
* @param int $id
* @return \Illuminate\Http\Response
*/
public function update(Request $request, $id)
{
if (Course::where('id', $id)->exists()) {
$course = Course::find($id);
$course->date = is_null($request->date) ? $course->date : $request->date;
$course->desc = is_null($request->desc) ? $course->desc : $request->desc;
$course->img = is_null($request->img) ? $course->img : $request->img;
$course->title = is_null($request->title) ? $course->title : $request->title;
$course->save();
return response()->json([
'data' => $course
], 200);
} else {
return response()->json([
"error" => "course not found"
], 404);
}
}
/**
* Remove the specified resource from storage.
*
* @param int $id
* @return \Illuminate\Http\Response
*/
public function destroy($id)
{
if(Course::where('id', $id)->exists()) {
$course = Course::find($id);
$course->delete();
return response()->json([
"message" => "records deleted"
], 202);
} else {
return response()->json([
"error" => "course not found"
], 404);
}
}
}
Link the route and the controllerโ
๐ ๏ธ๏ธ Open up routes/api.php
and add use app\Http\Controllers\Api\CourseController;
<?php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
// 1. Add this line:
use App\Http\Controllers\Api\CourseController;
/*
|--------------------------------------------------------------------------
| API Routes
|--------------------------------------------------------------------------
|
| Here is where you can register API routes for your application. These
| routes are loaded by the RouteServiceProvider within a group which
| is assigned the "api" middleware group. Enjoy building your API!
|
*/
Route::middleware('auth:api')->get('/user', function (Request $request) {
return $request->user();
});
๐ ๏ธ๏ธ Next, remove the base route and add this code instead: Route::apiResource('courses', CourseController::class);
<?php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\Api\CourseController;
/*
|--------------------------------------------------------------------------
| API Routes
|--------------------------------------------------------------------------
|
| Here is where you can register API routes for your application. These
| routes are loaded by the RouteServiceProvider within a group which
| is assigned the "api" middleware group. Enjoy building your API!
|
*/
// 2. Modify the route:
Route::apiResource('courses', CourseController::class);
๐ ๏ธ๏ธ Next, add /v1
to api in app/Providers/RouteServiceProvider.php
// ...
public function boot()
{
$this->configureRateLimiting();
$this->routes(function () {
// Add /v1 to api
Route::prefix('api/v1')
->middleware('api')
->namespace($this->namespace)
->group(base_path('routes/api.php'));
Route::middleware('web')
->namespace($this->namespace)
->group(base_path('routes/web.php'));
});
}
// ...
๐ ๏ธ๏ธ Next, update course controller in app/Http/Controllers/Api/CourseController.php
:
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Course;
use Illuminate\Http\Request;
class CourseController extends Controller
{
/**
* Display a listing of the resource.
*
* @return \Illuminate\Http\Response
*/
public function index()
{
// Update here:
return response()->json(
[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"
)
)], 200);
}
๐ ๏ธ๏ธ Run the server with command php artisan serve
and open insomnia or postman to make a GET
request which should end with 200
:
JWT validationโ
๐ ๏ธ๏ธ Create cryptr folder and go to the folder:
mkdir cryptr && cd cryptr
๐ ๏ธ๏ธ Create CryptrJwtVerifier.php
with command touch CryptrJwtVerifier.php
and copy paste this code:
<?php
namespace Cryptr;
use \Firebase\JWT\JWK;
use \Firebase\JWT\JWT;
use \Illuminate\Support\Facades\Http;
use DateTime;
use Exception;
use Cryptr\JwtClaimsValidation;
class CryptrJwtVerifier
{
public function __construct($cryptrBaseUrl, $domain, $allowedOrigins, $allowedCLientIds)
{
$this->wellknownEndpoint = "$cryptrBaseUrl/t/$domain/.well-known";
# JWT
$this->jwks = NULL;
# Issuer
$this->tenantDomain = $domain;
$this->issuer = "$cryptrBaseUrl/t/$domain";
# Audiences
$this->allowedOrigins = $allowedOrigins;
$this->allowedClientIds = $allowedCLientIds;
}
public function getJwks()
{
try {
$keys = Http::get($this->wellknownEndpoint)['keys'];
$jwks = ['keys' => $keys];
$this->jwks = $jwks;
return $jwks;
} catch (Exception $e) {
echo 'Can not fetch JWKS : ', $e->getMessage(), "\n";
return [];
}
}
public function decode($jwt, $jwks)
{
$publicKeys = JWK::parseKeySet($jwks);
return JWT::decode($jwt, $publicKeys, array('RS256'));
}
public function validate($jwt, $jwks = NULL)
{
// 0. Prepare public keys
$jwks = $jwks ? $jwks : $this->jwks;
$publicKeys = JWK::parseKeySet($jwks);
// 1. Decode token
$decodedJwt = $this->decode($jwt, $jwks);
// 2. Assert claims
$jwtClaims = new JwtClaimsValidation($this->tenantDomain, $this->issuer, $this->allowedOrigins, $this->allowedClientIds);
$jwtClaims->isValid($decodedJwt);
return $decodedJwt;
}
}
๐ ๏ธ๏ธ Create JwtClaimsValidation.php
with command touch JwtClaimsValidation.php
and copy paste this code:
<?php
namespace Cryptr;
use DateTime;
use Exception;
class JwtClaimsValidation
{
public function __construct($tenantDomain, $issuer, $allowedOrigins, $allowedClientIds)
{
# Issuer
$this->tenantDomain = $tenantDomain;
$this->issuer = $issuer;
# Audiences
$this->allowedOrigins = $allowedOrigins;
$this->allowedClientIds = $allowedClientIds;
}
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) {
$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;
}
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);
}
}
These are tools that will make it possible to retrieve the token (token of the user session) before retrieving the response to the request, and verify the token thanks to the validations claims.
4. Protect API Endpointsโ
CryptrGuard middlewareโ
๐ ๏ธ๏ธ Return in root folder and create CryptrGuard
middleware:
php artisan make:middleware CryptrGuard
Middleware is responsible for filtering HTTP requests that arrive in the application, as well as those that leave it. In our case, it is verifying the authentication of a user so they can access certain resources.
๐ ๏ธ๏ธ Open up app/Http/Middleware/CryptrGuard.php
and replace it with the following code:
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Cryptr\CryptrJwtVerifier;
use Illuminate\Support\Facades\Log;
class CryptrGuard
{
// To handle Public Keys in cache to avoid HTTP requests :
//
// const JWKS_KEY = "cryptr_jwks";
// const JWKS_TTL_SECONDS = 900;
public function handle(Request $request, Closure $next)
{
$requestOrigin = $request->headers->get('origin');
// 0. Config
$cryptrBaseUrl = env('CRYPTR_BASE_URL');
$domain = env('TENANT_DOMAIN');
$allowedOrigins = explode(',',env('DEFAULT_REDIRECT_URI'));
$allowedClientIds = explode(',',env('CLIENT_ID'));
// 1. Handle CORS
$allowOriginHeader = in_array($requestOrigin, $allowedOrigins) ? $requestOrigin : array_values($allowedOrigins)[0];
$headers = [
'Access-Control-Allow-Origin' => $allowOriginHeader,
'Access-Control-Allow-Methods'=> 'POST, GET, OPTIONS, PUT, DELETE',
'Access-Control-Allow-Headers'=> 'Content-Type, Authorization, Origin'
];
if($request->getMethod() == "OPTIONS") {
// The client-side application can set only headers allowed in Access-Control-Allow-Headers
return Response::make('OK', 200, $headers);
}
// 2. JWT Validation
$cryptrJwtVerifier = new CryptrJwtVerifier($cryptrBaseUrl, $domain, $allowedOrigins, $allowedClientIds);
try {
// 2.1 Fetch public keys set to validate token
//
// To handle Public Keys in cache to avoid HTTP requests, uncomment the following lines :
//
// $jwks = Cache::get(JWKS_KEY);
//
// if (!$jwks) {
// $jwks = $cryptrJwtVerifier->getJwks();
// Cache::add(JWKS_KEY, $jwks, JWKS_TTL_SECONDS);
// }
// ...
$jwks = $cryptrJwtVerifier->getJwks();
// 2.2 Validate token with
$decoded = $cryptrJwtVerifier->validate($request->bearerToken(), $jwks);
// 1.bis Add headers to response
$response = $next($request);
foreach($headers as $key => $value){
$response->header($key, $value);
}
return $response;
// ...
} catch (\Exception $exception) {
$errMsg = $exception->getMessage();
$errLine = $exception->getLine();
error_log("Can not handle: $errMsg:$errLine");
return response('Unauthorized', 401)->header('Content-Type', 'text/plain');
}
}
}
In this middleware:
- Check the header, if it does not contain
http://localhost:4000
the request is blocked - Retrieve the client's key to validate the token of the request
- Accept request and process it, otherwise return
401
Configure the middlewareโ
๐ ๏ธ๏ธ Open up app/Http/Kernel.php
and register cryptr-guard middleware 'cryptr-guard' => [\App\Http\Middleware\CryptrGuard::class]
to Kernel.php in $middlewareGroups
// ...
protected $middlewareGroups = [
'web' => [
\App\Http\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
// \Illuminate\Session\Middleware\AuthenticateSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\App\Http\Middleware\VerifyCsrfToken::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
'api' => [
'throttle:api',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
// Add cryptr guard:
'cryptr-guard' => [\App\Http\Middleware\CryptrGuard::class]
];
// ...
๐ ๏ธ๏ธ Open up routes/api.php
to protect individual API endpoints by applying the jwt
middleware, wrap route with Route::prefix('/')->middleware('cryptr-guard')->group(function () {
<?php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\Api\CourseController;
/*
|--------------------------------------------------------------------------
| API Routes
|--------------------------------------------------------------------------
|
| Here is where you can register API routes for your application. These
| routes are loaded by the RouteServiceProvider within a group which
| is assigned the "api" middleware group. Enjoy building your API!
|
*/
// Apply cryptr-guard middleware:
Route::prefix('/')->middleware('cryptr-guard')->group(function () {
Route::apiResource('courses', CourseController::class);
});
For all actions, the routes are secured by respecting the criteria of Cryptr
It is now time to try this on an application. For this purpose, we have an example app on Vue.
Test with a Cryptr Vue appโ
๐ Run your code with php artisan serve
๐ ๏ธ๏ธ 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:8080
VUE_APP_CLIENT_ID=YOUR_CLIENT_ID
VUE_APP_CRYPTR_BASE_URL=YOUR_BASE_URL
VUE_APP_DEFAULT_LOCALE=fr
VUE_APP_DEFAULT_REDIRECT_URI=http://localhost:8080
VUE_APP_TENANT_DOMAIN=YOUR_DOMAIN
VUE_APP_CRYPTR_TELEMETRY=FALSE
๐ ๏ธ๏ธ 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.
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.
Congratulations if you made it to the end!
I hope this was helpful, and thanks for reading! ๐