Autenticazione delle api con jwt e symfony (Italian)

43
Autenticazione sessionless delle API con JWT e Symfony

Transcript of Autenticazione delle api con jwt e symfony (Italian)

Autenticazione sessionless delle API con JWT e Symfony

Chi sono

Marco Albarelli

Freelance

Full stack developer web e mobile

Sysadmin

PHP, javascript, Android, Go, java

https://www.adhocmobile.it

https://github.com/marcoalbarelli

Scenario

Un mondo fatto di API

Ci sono più microservizi usati da client di ogni genere:

JS, mobile, server, cli

Forniti da server di ogni genere:

nodejs, php, .Net, Java, ESB

Potenzialmente identità multiple

Condividere fra tutti la sessione serverside diventa impraticabile

Un mondo fatto di API

Un nuovo standard

Per permettere a sistemi diversi di interagire serviva un nuovo standard:

OpenID Connect

google lo usa in produzione https://developers.google.com/identity/protocols/OpenIDConnect

Un mondo fatto di API

Un nuovo standard

Obiettivo:

Passare da

Cookie: PHPSESSID

a

Authorization: Bearer mqZSaG...

Un mondo fatto di API

Un nuovo standard:OpenID Connect

Si basa su Oauth2.0

Usa dei token particolari: JWT

Interoperabile

Molto più semplice di SAML

Cos’è un JSON WEB TOKEN

JWT, cos’è

Una convenzione: RFC 7519

Una stringa: 3 blocchi di testo Base64 encoded uniti da un punto

Supporta JOSE: Json Object Signing and Encryption

Composto di header, corpo e firma

Supporta “claims”

JWT, cos’è

Un esempio: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.

eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.

TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ

http://jwt.io

JWT, cos’è

Che equivale a:

{ "alg": "HS256", "typ": "JWT" }

{ "sub": "1234567890", "name": "John Doe", "admin": true }

hash_hmac( base64($header) . ”.” . base64($body) , ”secret” )

“sub” “name” e “admin” sono claim

JWT, cos’èStandard claims

iss: issuer

aud: audience

nbf: not before

exp: expiration

sub: subject

iat: issued at

jti: jwt id

typ: type

Cosa non vedremo oggi

Troppo poco tempo per:

il flusso OpenID completo

Usare lo stack completo di autenticazione di Symfony

Validare e usare token JWT cifrati

Cosa vedremo oggi

Abbastanza tempo per:

Una ricetta quasi “standard” dal cookbook di symfony

Ingredienti:

SimplePreAuthenticatorInterface

"firebase/php-jwt": "^3.0"

La ricetta

La ricetta

Installiamo symfony come da manuale

Aggiungiamo al composer.json:

"friendsofsymfony/user-bundle": "2.0.x-dev",

"firebase/php-jwt": "^3.0"

composer update

La ricettaTestiamo due scenari:

Richiesta con token non valido e ci aspettiamo un codice 401

Richiesta con token valido e ci aspettiamo un codice 200

Cosa stiamo per fare

Scriviamo il primo test: public function testApiEndpointsAreInaccessibleWithAnInvalidJWTAuthorizationHeader($method,$route,$params){ $this->setupMocksWithoutExpectations(); $router = $this->container->get('router'); $uri = $router->generate($route,$params);

$this->client->setServerParameter('HTTP_Authorization','Bearer'.$this->createInvalidJWT($this->container->getParameter('secret')));

$this->client->request($method,$uri,$params); $this->assertEquals(401,$this->client->getResponse()->getStatusCode()); }

Testiamo che la chiamata ottenga risposta (401)

Il token non è valido

Scriviamo il primo test: public function testApiEndpointsAreInaccessibleWithAnInvalidJWTAuthorizationHeader($method,$route,$params){ $this->setupMocksWithoutExpectations(); $router = $this->container->get('router'); $uri = $router->generate($route,$params);

$this->client->setServerParameter('HTTP_Authorization','Bearer'.$this->createInvalidJWT($this->container->getParameter('secret')));

$this->client->request($method,$uri,$params); $this->assertEquals(401,$this->client->getResponse()->getStatusCode()); }

Testiamo che la chiamata ottenga risposta (401)

Il token non è valido

$this->client->setServerParameter('HTTP_Authorization','Bearer'.$this->createInvalidJWT($this->container->getParameter('secret')));

JWT Test doubles public function createValidJWT($key,$role = 'ROLE_USER',$apiKey = null) { $now = new \DateTime('now'); $role = 'ROLE_USER'; if($apiKey == null){ $apiKey = md5(rand(0,10)); } $token = array( "iss" => "http://example.org", "aud" => "http://example.com", "iat" => $now->getTimestamp(),

"nbf" => $now->sub(new \DateInterval('P1D'))->getTimestamp(), "role" => $role, Constants::JWT_APIKEY_PARAMETER_NAME => $apiKey ); return JWT::encode($token,$key); }

public function createInvalidJWT($key,$role = 'ROLE_USER') { $now = new \DateTime('now'); $role = 'ROLE_USER'; //Missing apikey and valid since tomorrow $token = array( "iss" => "http://example.org", "aud" => "http://example.com", "iat" => $now->getTimestamp(),

"nbf" => $now->add(new \DateInterval('P1D'))->getTimestamp(), "role" => $role ); return JWT::encode($token,$key); }

JWT Test doubles public function createValidJWT($key,$role = 'ROLE_USER',$apiKey = null) { $now = new \DateTime('now'); $role = 'ROLE_USER'; if($apiKey == null){ $apiKey = md5(rand(0,10)); } $token = array( "iss" => "http://example.org", "aud" => "http://example.com", "iat" => $now->getTimestamp(),

"nbf" => $now->sub(new \DateInterval('P1D'))->getTimestamp(), "role" => $role, Constants::JWT_APIKEY_PARAMETER_NAME => $apiKey ); return JWT::encode($token,$key); }

public function createInvalidJWT($key,$role = 'ROLE_USER') { $now = new \DateTime('now'); $role = 'ROLE_USER'; //Missing apikey and valid since tomorrow $token = array( "iss" => "http://example.org", "aud" => "http://example.com", "iat" => $now->getTimestamp(),

"nbf" => $now->add(new \DateInterval('P1D'))->getTimestamp(), "role" => $role ); return JWT::encode($token,$key); }

"nbf" => $now->add(new \DateInterval('P1D'))->getTimestamp());

Scriviamo il primo test:CONTROLLER

/** * @Route("/hello/{name}", name="api_hello") */ public function indexAction($name) { return new Response(json_encode(array('hello'=>$name))); }

Come si vede per usare un autenticatore custom non dobbiamo modificare il controller

Scriviamo il primo test:Lanciamo i test adesso e siamo in profondo rosso: dobbiamo configurare un bel po’ di cose:

app/config/security.ymlsecurity:... firewalls:... api_area: pattern: ^/api/ provider: api_chain_provider stateless: true

entry_point: marcoalbarelli.api_user_auth_entrypoint anonymous: ~ simple_preauth:

authenticator: marcoalbarelli.api_user_authenticator access_control: - { path: ^/api/status, roles: IS_AUTHENTICATED_ANONYMOUSLY }

Entry point

Authenticator vero e proprio

Due cose principali:

Scriviamo il primo test:Lanciamo i test adesso e siamo in profondo rosso: dobbiamo configurare un bel po’ di cose:

app/config/security.ymlsecurity:... firewalls:... api_area: pattern: ^/api/ provider: api_chain_provider stateless: true

entry_point: marcoalbarelli.api_user_auth_entrypoint anonymous: ~ simple_preauth:

authenticator: marcoalbarelli.api_user_authenticator access_control: - { path: ^/api/status, roles: IS_AUTHENTICATED_ANONYMOUSLY }

Entry point

Authenticator vero e proprio

Due cose principali:

authenticator: marcoalbarelli.api_user_authenticator... marcoalbarelli.api_user_authenticator: class: Marcoalbarelli\APIBundle\Service\APIUserAuthenticator arguments: - @marcoalbarelli.api_user_provider - @marcoalbarelli.jwt_checker

Entry point:public function testAuthEntrypointGives401ErrorForMissingJWT(){ $authException = new AuthenticationException("missing JWT"); $request = new Request(); $service = $this->container->get('marcoalbarelli.api_user_auth_entrypoint'); $response = $service->start($request,$authException); $this->assertTrue($response instanceof Response); $this->assertEquals('application/json',$response->headers->get('Content-Type')); $this->assertEquals('OpenID realm="api_area"',$response->headers->get('WWW-Authenticate')); }

/** * Starts the authentication scheme. * * @param Request $request The request that resulted in an AuthenticationException * @param AuthenticationException $authException The exception that started the authentication process * * @return Response */ public function start(Request $request, AuthenticationException $authException = null) { $content = array('success'=>false); $response = new Response(json_encode($content),401); $response->headers->set('Content-Type','application/json'); $response->headers->set('WWW-Authenticate','OpenID realm="api_area"'); //TODO: retrieve the firewall name dynamically return $response; }

SimplePreAuthenticatorInterface:/** * @expectedException \Exception */ public function testAuthenticatorThrowsExceptionIfRequestIsInvalid(){ $jwt = $this->createInvalidJWT($this->container->getParameter('secret')); $request = new Request(); $request->headers->add(array('Authorization'=> Constants::JWT_BEARER_PREFIX .$jwt)); $service = $this->container->get('marcoalbarelli.api_user_authenticator'); $service->createToken($request,'pippo'); }

public function createToken(Request $request, $providerKey) { $authorizationHeader = $request->headers->get('Authorization'); … $encodedJWT = $authorizationHeader[1];

try { $jwt = $this->jwtCheckerService->decodeToken($encodedJWT); } catch (\Exception $exception){ throw new AuthenticationException($exception->getMessage()); }

$user = $this->userProvider->findUserByAPIKey($jwt->$apiKeyName); ... $token = new PreAuthenticatedToken($user,$encodedJWT,$providerKey); return $token; }

SimplePreAuthenticatorInterface:/** * @expectedException \Exception */ public function testAuthenticatorThrowsExceptionIfRequestIsInvalid(){ $jwt = $this->createInvalidJWT($this->container->getParameter('secret')); $request = new Request(); $request->headers->add(array('Authorization'=> Constants::JWT_BEARER_PREFIX .$jwt)); $service = $this->container->get('marcoalbarelli.api_user_authenticator'); $service->createToken($request,'pippo'); }

public function createToken(Request $request, $providerKey) { $authorizationHeader = $request->headers->get('Authorization'); … $encodedJWT = $authorizationHeader[1];

try { $jwt = $this->jwtCheckerService->decodeToken($encodedJWT); } catch (\Exception $exception){ throw new AuthenticationException($exception->getMessage()); }

$user = $this->userProvider->findUserByAPIKey($jwt->$apiKeyName); ... $token = new PreAuthenticatedToken($user,$encodedJWT,$providerKey); return $token; }

Il fulcro di tutto:$jwt = $this->jwtCheckerService->decodeToken($encodedJWT);

JWT Checker /** * @expectedException Exception */ public function testServiceThrowsExceptionForInvalidJWTToken(){ $key = $key = $this->container->getParameter('secret'); $token = $this->createInvalidJWT($key); $service = $this->container->get('marcoalbarelli.jwt_checker'); $service->decodeToken($token); }

//TODO: creare un dataprovider che copra esplicitamente tutti i casi di invalidità

/** * @var string $secret The secret for this deployment (from parameters.yml) */ private $secret; /** * @var array $algs The algs for JWT signing (from parameters.yml) */ private $algs; public function __construct($secret, $algs) { $this->secret = $secret; $this->algs = $algs; } public function decodeToken($token) {

return JWT::decode($token, $this->secret, $this->algs); }

JWT Checker /** * @expectedException Exception */ public function testServiceThrowsExceptionForInvalidJWTToken(){ $key = $key = $this->container->getParameter('secret'); $token = $this->createInvalidJWT($key); $service = $this->container->get('marcoalbarelli.jwt_checker'); $service->decodeToken($token); }

//TODO: creare un dataprovider che copra esplicitamente tutti i casi di invalidità

/** * @var string $secret The secret for this deployment (from parameters.yml) */ private $secret; /** * @var array $algs The algs for JWT signing (from parameters.yml) */ private $algs; public function __construct($secret, $algs) { $this->secret = $secret; $this->algs = $algs; } public function decodeToken($token) {

return JWT::decode($token, $this->secret, $this->algs); }

La prima implementazione: solo correttezza formalereturn JWT::decode($token, $this->secret, $this->algs);

Scriviamo il secondo test: public function testApiEndpointsAreAccessibleWithAValidJWTAuthorizationHeader($method,$route,$params){ $this->setupMocks(); $router = $this->container->get('router'); $uri = $router->generate($route,$params);

$this->client->setServerParameter('HTTP_Authorization','Bearer '.$this->createValidJWT($this->container->getParameter('secret')));

$this->client->request($method,$uri,$params); $this->assertEquals(200,$this->client->getResponse()->getStatusCode()); }

Testiamo che la chiamata ottenga risposta

Praticamente identico al precedente, tranne che per il token (stavolta valido)

SimplePreAuthenticatorInterface:public function testAuthenticatorCreatesValidTokenIfRequestIsValidAnUserIsPresent(){ $jwt = $this->createValidJWT($this->container->getParameter('secret')); $request = new Request(); $request->headers->add(array('Authorization'=> Constants::JWT_BEARER_PREFIX .$jwt)); $this->container->set('marcoalbarelli.api_user_provider',$this->getMockedUserProvider()); $service = $this->container->get('marcoalbarelli.api_user_authenticator'); $preauthenticatedToken = $service->createToken($request,'pippo'); $this->assertNotNull($preauthenticatedToken); $this->assertEquals($preauthenticatedToken->getCredentials(),$jwt); }

public function createToken(Request $request, $providerKey) { ….

//Tutto ok $token = new PreAuthenticatedToken($user,$encodedJWT,$providerKey); return $token; }

Il cuore dell’autenticazione try { $jwt = $this->jwtCheckerService->decodeToken($encodedJWT); } catch (\Exception $exception){ throw new AuthenticationException($exception->getMessage()); }

if( !isset($jwt->$apiKeyName)){ throw new BadCredentialsException('Invalid JWT'); } $user = $this->userProvider->findUserByAPIKey($jwt->$apiKeyName); if($user == null){ throw new UsernameNotFoundException("Invalid User"); }… $token = new PreAuthenticatedToken($user,$encodedJWT,$providerKey); return $token;

Qui possiamo aggiungere una miriade di controlli sia sul nostro sistema che su altri (grazie all’interoperabilità offerta da JWT)

Prossimi passi?

Prossimi passi

Implementazione di tutto il flusso OpenID Connect

Implementazione dei token JWT cifrati

Implementazione di un AP con symfony (soon on a github near you)

Conclusioni

ConclusioniSymfony + JWT

Autenticazione API

Abbiamo visto rapidamente come creare un autenticatore per delle API che non si appoggia ai cookie di sessione

Abbiamo visto come sia semplice farlo con approccio TDD, essenziale in ambito di sicurezza

Abbiamo iniziato ad usare un nuovo standard che ci renderà più facile integrarci con OpenID Connect

ConclusioniSymfony + JWT

Autenticazione API

Domande?

Grazie