Marco Albarelli
Freelance
Full stack developer web e mobile
Sysadmin
PHP, javascript, Android, Go, java
https://www.adhocmobile.it
https://github.com/marcoalbarelli
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
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
Troppo poco tempo per:
il flusso OpenID completo
Usare lo stack completo di autenticazione di Symfony
Validare e usare token JWT cifrati
Abbastanza tempo per:
Una ricetta quasi “standard” dal cookbook di symfony
Ingredienti:
SimplePreAuthenticatorInterface
"firebase/php-jwt": "^3.0"
La ricettaRepository WIP:
https://github.com/
marcoalbarelli/symfony2notes
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
Implementazione di tutto il flusso OpenID Connect
Implementazione dei token JWT cifrati
Implementazione di un AP con symfony (soon on a github near you)
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