Photo by Priscilla Du Preez on Unsplash
Oggi costruire un grande sistema composto da più endpoint API è sempre più una pratica comune, anche grazie alla diffusione del pattern a microservizi.
Adottando questo tipo di architettura hai spesso più API che offrono servizi diversi per lo stesso utente, accessibili tramite un unico frontend, come una SPA, un'app mobile, una soluzione desktop, ecc… In questo modo puoi avere, per esempio:
- un'API per l'autenticazione e la gestione del profilo utente.
- più API per ogni servizio.
- un frontend che comunica con ogni API (o anche più frontend).
In questo articolo costruiremo due semplici API basate su Laravel e JWT.
Come funzionerà?
JWT sta per JSON Web Token e, se non sai cos'è: è uno standard aperto per trasmettere informazioni in JSON tramite token firmati, puoi leggere di più sullo standard qui.
JWT Logo, Source: https://jwt.io/
JWT gestirà i dati utente per l'autenticazione (ID interno, email, permessi, IP, tutto ciò che ritieni importante) firmandoli tramite un algoritmo asimmetrico come RS256 (firma RSA con SHA-256) che useremo per verificare l'autenticazione dell'utente tra tutti i nostri servizi.
Useremo il framework Laravel per servire API che genereranno i token e forniranno diverse funzionalità da vari endpoint dove gli utenti sono autenticati tramite lo stesso JWT, gestito dal package tymondesigns/jwt-auth.
Setup dei progetti
Per prima cosa vogliamo creare due progetti Laravel, io userò composer:
> composer create-project --prefer-dist laravel/laravel api-authentication
> composer create-project --prefer-dist laravel/laravel api-service1
Avremo due cartelle con due progetti Laravel appena creati. Entreremo in ogni cartella e installeremo il package jwt in questo modo:
> composer require tymon/jwt-auth
Questo modificherà il composer.json aggiungendo il package jwt-auth; mentre scrivo, sto usando la versione 1.1 del package. Dopo l'installazione del package vogliamo pubblicare la configurazione di default del package in questo modo:
> php artisan vendor:publish --provider="Tymon\JWTAuth\Providers\LaravelServiceProvider"
Questo creerà un file config/jwt.php che contiene la configurazione JWT che modificheremo più avanti.
Se vuoi costruire solo API, puoi commentare/rimuovere le web routes nel boot di app/Providers/RouteServiceProvider.php:
public function boot() {
$this->configureRateLimiting();
$this->routes(function () {
Route::prefix('api')
->middleware('api')
->group(base_path('routes/api.php'));
/*
Route::middleware('web')
->namespace($this->namespace)
->group(base_path('routes/web.php'));
*/
});
}
Vogliamo anche creare una cartella per le chiavi che useremo per la firma (chiave privata e pubblica) nella cartella storage (o dove preferisci) storage/jwt. Per motivi di sicurezza creeremo al suo interno un file .gitignore che ci aiuterà a non pushare la chiave privata e pubblica nel nostro repository; questo conterrà semplicemente una riga:*.pem.
Generazione delle chiavi
Genereremo le chiavi una sola volta, nella nostra api-authentication, e le copieremo negli altri progetti API.
Per la coppia di chiave pubblica e privata dobbiamo avere una passphrase robusta che non condivideremo. In questo esempio userò questa (la chiave che segue è solo a scopo dimostrativo, cambiala per il tuo sistema):
sO9sH6qT8jA0wV5gE5eT3kY2
Il comando per generare una chiave privata RSA di 4096 bit cifrata tramite AES256 in storage/jwt/private.pem è:
openssl genrsa -passout pass:sO9sH6qT8jA0wV5gE5eT3kY2 -out storage/jwt/private.pem -aes256 4096
Per generare la chiave pubblica dalla nostra chiave privata:
openssl rsa -passin pass:sO9sH6qT8jA0wV5gE5eT3kY2 -pubout -in storage/jwt/private.pem -out storage/jwt/public.pem
Ora abbiamo public.pem e private.pem nella nostra cartella storage/jwt.
Configurazione JWT
Ora aggiungeremo al file .env la nostra configurazione JWT relativa all'algoritmo usato e alle nostre chiavi:
JWT_ALGO=RS256
JWT_PUBLIC_KEY=jwt/public.pem
JWT_PRIVATE_KEY=jwt/private.pem
JWT_PASSPHRASE=sO9sH6qT8jA0wV5gE5eT3kY2
E sistemeremo il file config/jwt.php facendo caricare a Laravel la chiave privata e pubblica dallo storage:
'public' => 'file://'.storage_path(env('JWT_PUBLIC_KEY')),
'private' => 'file://'.storage_path(env('JWT_PRIVATE_KEY')),Costruire l'API di autenticazione
Questa sezione riguarderà solo il progetto api-authentication.
Costruire questo tipo di autenticazione non differisce dalla documentazione di default di tymonjwt, ma costruiremo comunque l'autenticazione, per avere una visione su ogni passaggio del progetto.
Per prima cosa il tuo modello authenticatable deve implementare JWTSubject (l'intero codice non verrà riportato qui, solo le parti più rilevanti):
<?php
namespace App\Models;
use Tymon\JWTAuth\Contracts\JWTSubject;
class User extends Authenticatable implements JWTSubject
{
public function getJWTIdentifier()
{
return $this->id;
}
public function getJWTCustomClaims()
{
return [
// Here you will put claims for your JWT: ip, device, permissions
];
}
}
Dopo questo, vogliamo che JWT sia il nostro Guard di autenticazione principale modificando config/auth.php:
'defaults' => [
'guard' => 'api',
'passwords' => 'users',
],
'guards' => [
'api' => [
'driver' => 'jwt',
'provider' => 'users',
],
],
Poi costruiremo un nuovo controller php artisan make:controller AuthController con un metodo login:
public function login(Request $request)
{
$credentials = $request->only('email', 'password');
if (!$token = auth()->attempt($credentials)) {
abort(406);
}
return response()->json([
'success' => true,
'data' => [
'token' => $token,
'token_type' => 'bearer',
]
]);
}
Infine aggiungiamo il binding alla route in routes/api.php:
Route::post('/login', [\App\Http\Controllers\AuthController::class, 'login']);
Funziona già? Testiamolo.
Abbiamo già migration, model e factory dell'utente in una build standard di Laravel: adattali al tuo scopo e crea un nuovo test php artisan make:test AuthTest, aprilo e crea un testLogin:
public function testLogin()
{
$user = User::factory()->createOne();
$response = $this->post(
'/api/v1/login',
[
'email' => $user->email,
'password' => 'password',
]
);
$response->assertStatus(200);
$response->assertJsonStructure([
'success',
'data' => [
'token',
'token_type',
]
]);
\JWTAuth::setToken($response->json('data.token'))->checkOrFail();
}
Ora puoi eseguire tranquillamente php artisan test e, se tutto è andato bene, otterrai un risultato come questo:
PASS Tests\Feature\AuthTest
✓ login
Tests: 1 passed
Time: 0.39sCostruire l'autenticazione delle altre API
Ora che l'autenticazione è pronta, possiamo passare al progetto api-service1.
Idealmente questo altro servizio API non avrà accesso al database della prima API, quindi costruiremo un'autenticazione senza database tramite un model Laravel, che eventualmente puoi modificare per risolvere l'utente nel model che preferisci. Perché questa scelta? Usare più endpoint API per servizi/funzionalità diversi generalmente significa che i tuoi servizi sono completamente separati tra loro e non vuoi problemi di accoppiamento dovuti alla replica dei dati, quindi riferirsi a un utente con i soli dati minimi non memorizzati in un DB aiuterà il tuo sistema a rimanere coerente.
Per far funzionare la nostra API cloneremo la configurazione dalla fase di config, copiando le nostre chiavi e modificando il config/jwt.php. Metteremo anche le nostre variabili nel file .env.
Per sovrascrivere l'autenticazione di default costruiremo un Guard personalizzato passo dopo passo, crea una nuova classe app/Guard/JWTGuard.php:
<?php
namespace App\Guard;
use App\Models\User;
use Illuminate\Auth\GuardHelpers;
use Illuminate\Contracts\Auth\Guard;
use Illuminate\Http\Request;
use Tymon\JWTAuth\JWT;
class JWTGuard implements Guard
{
use GuardHelpers;
/**
* @var JWT $jwt
*/
protected JWT $jwt;
/**
* @var Request $request
*/
protected Request $request;
/**
* JWTGuard constructor.
* @param JWT $jwt
* @param Request $request
*/
public function __construct(JWT $jwt, Request $request) {
$this->jwt = $jwt;
$this->request = $request;
}
public function user() {
if (! is_null($this->user)) {
return $this->user;
}
if ($this->jwt->setRequest($this->request)->getToken() && $this->jwt->check()) {
$id = $this->jwt->payload()->get('sub');
$this->user = new User();
$this->user->id = $id;
// Set data from custom claims
return $this->user;
}
return null;
}
public function validate(array $credentials = []) {
}
}
Cosa significa? Questa è un'implementazione personalizzata di Guard: Guard richiede l'implementazione di diversi metodi, usando GuardHelpers salteremo quei metodi e ci concentreremo sulla logica di cui abbiamo bisogno. I metodi ancora da implementare sono: user() e validate(). Non ci serve validate(), quindi lo lasceremo vuoto e ci concentreremo su user().
Il nostro token di autenticazione arriverà dalla request e verrà parsato tramite l'istanza JWT nelle proprietà che verranno iniettate. Se la validazione del token va a buon fine, creeremo semplicemente un'istanza del model User, imposteremo l'id (e gli altri dati che abbiamo messo nei custom claims) ottenuto dal payload tramite array access e lo restituiremo senza eseguire alcuna operazione sul database.
Dobbiamo anche apportare alcune modifiche al nostro model:
<?php
namespace App\Models;
use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
use Illuminate\Database\Eloquent\Model;
class User extends Model implements AuthenticatableContract
{
public function getAuthIdentifierName()
{
return 'id';
}
public function getAuthIdentifier()
{
return $this->id;
}
public function getAuthPassword()
{
return null;
}
public function getRememberToken()
{
return null;
}
public function setRememberToken($value) {}
public function getRememberTokenName() {}
}
Rimuoveremo la nostra implementazione Authenticatable di default per implementare il contract Authenticatable e i suoi metodi (getAuthIdentifierName, getAuthIdentifier, getAuthPassword, getRememberToken, setRememberToken, getRememberTokenName).
Ora dobbiamo dire a Laravel che il nostro guard esiste nel metodo boot dell'AuthServiceProvider:
public function boot()
{
$this->registerPolicies();
$this->app['auth']->extend(
'jwt-auth',
function ($app, $name, array $config) {
$guard = new JWTGuard(
$app['tymon.jwt'],
$app['request']
);
$app->refresh('request', $guard, 'setRequest');
return $guard;
}
);
}
Una volta definito il Guard ed estesa l'autenticazione, dobbiamo registrarlo come nostro guard di autenticazione nel config/auth.php:
<?php
return [
'defaults' => [
'guard' => 'jwt',
'passwords' => 'users',
],
'guards' => [
// ...
'jwt' => [
'driver' => 'jwt-auth',
'provider' => 'users'
],
],
// ...
];
Per fare i test imposta una route di base, come quella già pronta nell'api.php:
Route::middleware('auth:jwt')->get('/user', function () {
return Auth::user();
});L'API di servizio saprà chi sono?
Per testare come funziona, dobbiamo prima emettere un token dall'API di autenticazione, il mio era questo:
eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwOlwvXC9sb2NhbGhvc3QiLCJpYXQiOjE2MDY0MDU1MDMsImV4cCI6MTYwNjQwOTEwMywibmJmIjoxNjA2NDA1NTAzLCJqdGkiOiJjUlI5Q1BmbWtxME9sWHN6Iiwic3ViIjoxLCJwcnYiOiIyM2JkNWM4OTQ5ZjYwMGFkYjM5ZTcwMWM0MDA4NzJkYjdhNTk3NmY3In0.B_RrqoCtN6k1vWUFgtAQjb4sTSZUDOhClwMXxHFfvtI8WFOJAbPCc2k_jPz1OMTJRsirko9X_fmQS2JSy0_pURNt45Ezn-JsJgN85rWKpK3x5VbFXudf3Ngh1L8kz1kAK928PIbuTVQqCmO9edDVN8LvQ9-klf5NN4JUpZcjbO3Qoobqko0Yuc6khUP-tbIASijVgaO8E2ehOdCppga4wldbHACLmks2Xe2YYN-lIdljvT3m2hKxAvX2LnT7NilVM7sstJydvhTk507-LhMfO8q71RUAF9pjTZ5gXDdUCGhw5VJjT7aUGNjMe96anuLA6fr1PtAtLlu2Jv6Qx2ijJNxWVV9wLo6ovnT2e9bl56f_rqXLMz1qFq_3xYA2ziQlpQtoPSa7HYxpqaxhFnw5ji6-iqQmwgkvDBISi0zXE9Z48X-7LvUO4Y341iFBKqpFgA5agDgmo-Y0hyg7aCUt9nvzSOyz77afKgaF5AedKEIE0fgrgFPkZcni5gUw1OZRfCEMhzDZj_zKOrMCzQZhTMYTtyz7xzqwTdV8JKk2GJ5qH27JlNhm0uA2TEGBB-KYvABRO7OL2fOARCCMo6LGC_SRoZB2LnbBbT6ZXGavjbegFaPAYdGowWwKhccTDuJQoeOWkIAQ1P4b6CS_FBxvQ7IXlUix9f164G0Yiavab_U
Poi avvia il server di sviluppo di Laravel php artisan serve ed esegui una richiesta GET a /api/user?token={TOKEN_HERE} oppure puoi eseguire una richiesta GET a /api/user con l'header HTTP Authorization: Bearer {TOKEN_HERE}.
Se tutto è andato bene, la tua risposta dovrebbe essere:
{
"id": 1
}
Ed eccolo! Il tuo utente è autenticato nell'API di servizio.
Come posso verificare manualmente la firma del JWT?
Bene, prendi il token emesso prima:
eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwOlwvXC9sb2NhbGhvc3QiLCJpYXQiOjE2MDY0MDU1MDMsImV4cCI6MTYwNjQwOTEwMywibmJmIjoxNjA2NDA1NTAzLCJqdGkiOiJjUlI5Q1BmbWtxME9sWHN6Iiwic3ViIjoxLCJwcnYiOiIyM2JkNWM4OTQ5ZjYwMGFkYjM5ZTcwMWM0MDA4NzJkYjdhNTk3NmY3In0.B_RrqoCtN6k1vWUFgtAQjb4sTSZUDOhClwMXxHFfvtI8WFOJAbPCc2k_jPz1OMTJRsirko9X_fmQS2JSy0_pURNt45Ezn-JsJgN85rWKpK3x5VbFXudf3Ngh1L8kz1kAK928PIbuTVQqCmO9edDVN8LvQ9-klf5NN4JUpZcjbO3Qoobqko0Yuc6khUP-tbIASijVgaO8E2ehOdCppga4wldbHACLmks2Xe2YYN-lIdljvT3m2hKxAvX2LnT7NilVM7sstJydvhTk507-LhMfO8q71RUAF9pjTZ5gXDdUCGhw5VJjT7aUGNjMe96anuLA6fr1PtAtLlu2Jv6Qx2ijJNxWVV9wLo6ovnT2e9bl56f_rqXLMz1qFq_3xYA2ziQlpQtoPSa7HYxpqaxhFnw5ji6-iqQmwgkvDBISi0zXE9Z48X-7LvUO4Y341iFBKqpFgA5agDgmo-Y0hyg7aCUt9nvzSOyz77afKgaF5AedKEIE0fgrgFPkZcni5gUw1OZRfCEMhzDZj_zKOrMCzQZhTMYTtyz7xzqwTdV8JKk2GJ5qH27JlNhm0uA2TEGBB-KYvABRO7OL2fOARCCMo6LGC_SRoZB2LnbBbT6ZXGavjbegFaPAYdGowWwKhccTDuJQoeOWkIAQ1P4b6CS_FBxvQ7IXlUix9f164G0Yiavab_U
Se apri jwt.io, puoi usare il debugger client-side integrato per testare la validità e il contenuto del tuo token. Per questo token il contenuto è:
Debugging del token JWT: Contenuto
Puoi leggere facilmente il contenuto del campo sub che indica il nostro user ID, ma in questo modo non puoi sapere se il token è valido. Per verificare la validità devi incollare la tua chiave pubblica nel campo "Verify Signature". La mia chiave pubblica era:
-----BEGIN PUBLIC KEY-----
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA7F7BG64826DJ6COaE41D
9oK6nSm33RZeovt4AzbGhYjyezl51rqPqm09p9F7UU5UMbx9JCLsjA835CYOU77L
jsMBkus+B88vi4Z+szCHqGQXqD6FRBNhid9Si7uY3cydnJEnboEAP/RgLTUrNjE/
4L4oq/Sev0WJ+oQyTOAX+z9QuUbwblWlecdQnSMQNjRWkjTHOjYVrChcKP4hR0O7
ZsGGzO6Cdcst4g3tywKIK2fuQetzXhecvrC75AOJsBwAga086RNmSFW076CzeUIx
8d+KvsMLUZKKCmT6QrC2J5DJNwT2JJUcMfryB4DuOZ+3VUsS8jgdBiYZvoBEsX/Y
pJ/vb0h9jFbcWDER4VcFZfXolyOO3i1JPGK8k8QiwGAoGpxjWipXmvXZLAPAahIe
baOCW7cYrLxC2/ICP8n9pVueeyUXh+geiU7bKDH53Cy5s2rVW/fah3cZQo5D0Oym
bbO0MmqP4QA81VX/Cfl2FPclIv9wRP78kCiy6YFk1wao4P0mtGQsVSWkgWi78/KS
goWgXOdYZ2r9zUEvc+6BVAKwYc2iyTt/zuffkypiyIoDLqSoQz1/JZ8PpX4wXSWE
VKXk8/3oBknl9aCpjCYD4VSZPIGMSB5rMaCKlUvdpg4e2lwiXWmbfJb/c4WOu5v8
k7rer4iBpbCqoROuQWDg+b8CAwEAAQ==
-----END PUBLIC KEY-----
Quindi se incolli questa in quel campo otterrai una verifica concreta:
Debugging del token JWT: Token verificato