Autenticazione ed autorizzazione con Symfony e Jwt

Autenticazione ed autorizzazione con Symfony e Jwt

Symfony è ormai uno dei framework più diffusi per la realizzazione di applicazioni web. Si basa sul paradigma MVC e si avvale di tutte le nuove funzionalità introdotte da PHP 5 per offrire allo sviluppatore una via per realizzare in modo semplice ed efficiente applicazioni complesse. Uno dei punti chiave della sua architettura è il front-controller, unico punto di accesso centralizzato dell’applicazione. Questo raccoglie tutte le richieste per poi smistarle ai singoli controller, che avranno poi il compito di elaborarle e produrre la risposta relativa. Quest’approccio offre numerose opportunità, prima fra tutte la possibilità di introdurre dei filtri in grado di interporsi tra la richiesta e la sua elaborazione da parte del controller, aggiungendo una logica trasversale al flusso principale. Tra gli ambiti che traggono maggiore vantaggio da questa caratteristica c’è sicuramente quello della sicurezza, niente meglio del concetto di sicurezza si sposa con la possibilità di filtrare ogni richiesta entrante e decidere se far proseguire o meno il flusso. Di conseguenza le implementazioni di filtri per autenticazione, autorizzazione e firewall in symfony sono numerosissime e basate sui più svariati approcci. Di seguito ne verrà presentato uno con autenticazione basata sullo standard JWT ed autorizzazioni selettive per ruoli utente. Json Web Token (JWT) Json Web Token (o JWT) è uno standard che definisce una modalità semplice per trasferire informazioni tra le parti in sicurezza. Queste informazioni possono essere verificate in quanto firmate digitalmente con un algoritmo HMAC (es. HS256). Lo standard non si limita a definire una via per verificare l’identità del chiamante, ma permette di cifrare l’intero contenuto della comunicazione. Per quanto riguarda un’applicazione web, però, il contenuto cifrato sarà limitato all’id dell’utente chiamante ed eventualmente esteso a parti del suo profilo, mentre i dati di I/O viaggeranno comunque in chiaro nel payload. JWT - Composizione Volendo semplificare, il compito di JWT è quello di generare un token cifrato a partire da informazioni in formato JSON e di verificare un token ricevuto. La struttura di tale token è suddivisa in tre aree: • Header: Contiene informazioni riguardo l’algoritmo di cifratura. Si tratta di un oggetto JSON codificato Base 64 (all’occorrenza può essere ulteriormente cifrato per non permetterne la decodifica). • Payload: Rappresenta le informazioni vere e proprie da trasferire. Anch’esso è un oggetto JSON codificato in Base 64. Nell’ottica dell’autenticazione ad un’applicazione web, dovrà contenere la expire date, ovvero la data di scadenza del token. Anch’esso all’occorrenza potrà essere ulteriormente cifrato. • Checksum: Rappresenta la componente di sicurezza vera e propria del token. Questa parte è il risultato della cifratura HMAC dell’header e del payload utilizzando un segreto. Queste 3 componenti verranno separate nel token generato dal carattere punto, in modo da poter essere facilmente identificate e permettere, se voluto, la decodifica di header e payload anche non conoscendo il segreto. È importante sottolineare come questa funzionalità non introduce buchi nella sicurezza; pur riuscendo a decodificare queste 2 componenti, infatti, non sarà possibile generare un nuovo token valido senza il segreto, poichè la verifica della validità passerà per il confronto della checksum. JWT e Symfony - Autenticazione La soluzione proposta per l’autenticazione si basa sull’implementazione di un listener, ovvero di un filtro in grado di reagire ad un evento eseguendo la propria logica ed, eventualmente, interrompendo il flusso generando un errore. Per quanto riguarda la gestione del token JWT si farà riferimento alla libreria namshi/jose documentata sul sito http://jwt.io. Token Il primo step è la creazione di una classe che rappresenti il nostro token. Per garantire la corretta integrazione con i servizi di Symfony, tale classe dovrà estendere la classe AbstractToken e conseguentemente implementare una logica conforme ad essa. Per i nostri scopi in ogni caso non dovrà far altro che custodire la stringa JWT al suo interno. <?php namespace AppBundle\Security\Authentication\Token; use Symfony\Component\Security\Core\Authentication\Token\AbstractToken; class JwtToken extends AbstractToken { protected $tokenString; public function __construct(array $roles = array()){ parent::__construct($roles); $this->setAuthenticated(count($roles) > 0); } public function getCredentials(){ return ''; } public function getTokenString(){ return $this->tokenString; } public function setTokenString($tokenString){ $this->tokenString = $tokenString; } } Listener Molto più interessante è la creazione del listener. Come anticipato in precedenza, esso entrerà in funzione a seguito di un evento generato dal core di symfony ed avrà il compito di dare il via al processo di autenticazione. <?php namespace AppBundle\Security\Firewall; use AppBundle\Security\Authentication\Token\JwtToken; use Symfony\Component\HttpKernel\Event\GetResponseEvent; use Symfony\Component\Security\Core\Authentication\AuthenticationManagerInterface; use Symfony\Component\Security\Core\Authentication\AuthenticationProviderManager; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Security\Core\Exception\AuthenticationException; use Symfony\Component\Security\Core\Exception\TokenNotFoundException; use Symfony\Component\Security\Http\Firewall\ListenerInterface; class JwtListener implements ListenerInterface { /** * @var TokenStorageInterface */ protected $tokenStorage; /** * @var AuthenticationProviderManager */ protected $authenticationManager; public function __construct( TokenStorageInterface $tokenStorage, AuthenticationManagerInterface $authenticationManager ) { $this->tokenStorage = $tokenStorage; $this->authenticationManager = $authenticationManager; } public function handle(GetResponseEvent $event) { try{ $request = $event->getRequest(); //Token letto dagli header della richiesta $authorization = $request->headers->get('Authorization'); if ($authorization === null) { throw new TokenNotFoundException(); } $token = new JwtToken(); $token->setTokenString($authorization); //Autenticazione del token tramite authentication provider. //Solleverà eccezione in caso di mancata autenticazione $authToken = $this->authenticationManager->authenticate($token); //Imposto il token storage globale di symfony $this->tokenStorage->setToken($authToken); //Return esplicito per operazione conclusa con successo return; } catch (TokenNotFoundException $tnfe){ return $this->buildErrorResponse($event, $tnfe->getMessage()); } catch (AuthenticationException $ae){ return $this->buildErrorResponse($event, $ae->getMessage()); } } protected function buildErrorResponse(GetResponseEvent $event, $errorMessage){ //Flusso di gestione dell'errore return false; } } Si può notare come il listener non porti a termine in solitaria il processo di autenticazione, bensì si limiti a controllare la presenza di un token nella richiesta e demandi il compito di verifica ed autenticazione ad un AuthenticationProviderManager. AuthenticationProvider Per completare il processo è quindi necessario inserire la logica di autenticazione del token all’interno di una classe che estenda AuthenticationProviderInterface ed, infine, mettere tutti i pezzi insieme facendo si che listener e provider vengano riconosciuti all’interno del flusso applicativo. La verifica di un token JWT si compone di 3 fasi: • Verifica della checksum: Per prima cosa la stringa ricevuta nella richiesta deve essere verificata come valida. Di conseguenza la componente di checksum dovrà essere rigenerata a partire da header, payload e segreto e confrontata con quella presente nella stringa. • Verifica della data di scadenza: Una volta in possesso di un token valido deve essere controllata la data di scadenza contenuta nel payload. Se il token fosse scaduto l’autenticazione dovrà fallire. • Verifica della persistenza del token: In molti casi reali la sola scadenza del token non è sufficiente. Un caso classico è la volonta di invalidare il token a seguito di un’azione dell’utente, quale ad esempio il logout. Una stringa JWT risulta valida indipendentemente dal contesto di utilizzo e di conseguenza non è possibile invalidarla in alcun modo. Per questo motivo si ricorre spesso a meccanismi di persistenza del token che si basano sulla memorizzazione di una whitelist (ovvero di un elenco di token validi) in qualche sistema di persistenza (database, sessione, …). Se un token ha passato i primi 2 step di validazione ma non è presente nella whitelist allora l’autenticazione dovrà fallire. I metodi load ed isValid offerti dalla libreria JWT sono sufficienti a coprire i primi 2 punti, mentre il terzo punto è legato all’architettura dell’applicazione che stiamo realizzando. <?php namespace AppBundle\Security\Authentication\Provider; use AppBundle\Security\Authentication\Token\JwtToken; use Namshi\JOSE\SimpleJWS; use Symfony\Component\Security\Core\Authentication\Provider\AuthenticationProviderInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Exception\AuthenticationException; use Symfony\Component\Security\Core\Exception\TokenNotFoundException; class JwtAuthenticationProvider implements AuthenticationProviderInterface { protected $secret; public function __construct($secret) { $this->secret = $secret; } /** * Attempts to authenticate a TokenInterface object. * @param TokenInterface $token The TokenInterface instance to authenticate * @return TokenInterface An authenticated TokenInterface instance, never null * @throws AuthenticationException if the authentication fails */ public function authenticate(TokenInterface $token) { //Carica il token da string $jwt = SimpleJWS::load($token->getTokenString()); //Verifica validit√à e data $isValid = $jwt->isValid($this->secret, 'HS256'); if (!$isValid) { throw new AuthenticationException('Invalid token'); } //Controlla la persistenza if(!$this->checkTokenPersistence($token->getTokenString())){ throw new TokenNotFoundException('Invalid token'); } return $token; } protected function checkTokenPersistence($tokenString){ //Implementazione legata all'applicazione return true; } /** * Checks whether this provider supports the given token. * @param TokenInterface $token A TokenInterface instance * @return bool true if the implementation supports the Token, false otherwise */ public function supports(TokenInterface $token) { return $token instanceof JwtToken; } } Factory e configurazioni Ora le parti ci sono tutte: è stato definito un token per contenere la stringa JWT, un listener per interporsi nel flusso applicativo ed un provider per verificare la validità del token. Quello che resta da fare per completare il flusso è mettere insieme tutti i pezzi. Tale operazione si compone di due parti: • Definizione e configurazione dei servizi coinvolti • Creazione di una factory in grado di iniettare dinamicamente tali servizi nel flusso applicativo Configurazioni Per prima cosa è necessario salvare il segreto utilizzato da JWT per cifrare i token. Questa operazione può essere fatta introducendo un nuovo parametro nel file parameters.yml jwt_secret: secret Successivamente vanno definiti come servizi il listener ed il provider, in modo tale da poter sfruttare il concetto di dependency injection proposto da symfony per “iniettare” nel loro costruttore le classi necessarie al funzionamento. services: jwt.security.authentication.provider: class: AppBundle\Security\Authentication\Provider\JwtAuthenticationProvider arguments: ['%jwt_secret%'] public: false jwt.security.authentication.listener: class: AppBundle\Security\Firewall\JwtListener arguments: ['@security.token_storage', '@security.authentication.manager'] public: false Infine aggiungere un firewall che imponga a tutte le rotte il passaggio dal listener (eventualmente con alcune eccezioni, di seguito una rotta di login). security: firewalls: login: pattern: ^/login$ security: false jwt_secured: pattern: /.* stateless: true jwt: true Factory La factory non dovrà far altro che mettere tutto insieme, recuperando i servizi definiti e dichiarando il nostro “pacchetto di autenticazione” con un nome (jwt) ed una posizione (pre_auth) per far funzionare il tutto. <?php namespace AppBundle\DependencyInjection; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\DefinitionDecorator; use Symfony\Component\Config\Definition\Builder\NodeDefinition; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\SecurityFactoryInterface; class JwtFactory implements SecurityFactoryInterface { public function create(ContainerBuilder $container, $id, $config, $userProvider, $defaultEntryPoint) { $providerId = 'security.authentication.provider.jwt.'.$id; $container->setDefinition($providerId, new DefinitionDecorator('jwt.security.authentication.provider')); $listenerId = 'security.authentication.listener.jwt.'.$id; $listener = $container->setDefinition($listenerId, new DefinitionDecorator('jwt.security.authentication.listener')); return array($providerId, $listenerId, $defaultEntryPoint); } public function getPosition() { return 'pre_auth'; } public function getKey() { return 'jwt'; } public function addConfiguration(NodeDefinition $node) { } } Fine! Ora ogni chiamata, ad eccezione della login, dovrà avere un header Authorization che corrisponda ad un token JWT valido per poter raggiungere il relativo controller, in caso contrario l’applicazione risponderà con un accesso negato (403). JWT e Symfony - Autorizzazione Il processo di autorizzazione definisce se un utente, già precedentemente autenticato, abbia o meno la possibilità di ricevere una risposta alla richiesta che ha effettuato. Solitamente tale decisione viene presa sulla base di ruoli e/o privilegi associati all’utente. Avendo a disposizione un token JWT il cui payload è destinato a contenere informazioni legate all’utente, una scelta possibile è quella di inserire in tale payload anche i ruoli ad esso associati, in modo da poter sfruttare l’informazione in fase di autorizzazione. Di seguito un possibile payload JWT { “userId“: 10, “username“: “mario.rossi“, “roles“: [“ADMINISTRATION“, “FINANCIAL“] } I ruoli definiti nell’esempio sono più che altro privilegi, in quanto dichiarano puntualmente quali parti dell’applicazione debbano essere accessibili. Un’alternativa sarebbe quella di utilizzare ruoli che rappresentano la funzione dell’utente nell’applicazione (es. AMMINISTRATORE, GESTORE_FINANZE, …) e di conseguenza costituiscono un insieme di privilegi. Gli obiettivi del processo di autorizzazione sono sostanzialmente due: Definire la logica in un posto centralizzato, in modo che possa essere eseguita qualsiasi sia la richiesta. Permettere in fase di sviluppo di “dimenticarsi” della componente sicurezza, permettendo di indicare in modo semplice e distaccato le autorizzazioni ad una rotta. Annotations Le annotations sono classi destinate ad essere utilizzate in particolari tipi di commento, le annotazioni appunto. I commenti di tipo annotazione possono essere indicati in precedenza alla definizione di classi, proprietà o metodi e successivamente interpretati dal codice php. Dichiarare un’annotazione in symfony è molto semplice, e per rendere tale una classe viene sfruttata a sua volta una particolare annotazione. L’idea è di creare un’annotazione il cui compito sarà elencare tutti i ruoli ammessi per una tale rotta, in modo da poter essere interpretata durante il processo di autorizzazione. <?php namespace AppBundle\Annotation; /** * @Annotation * @Target({“CLASS“,“METHOD“}) * * Usage * * @Allowed(roles={“ADMINISTRATION“, “FINANCIAL“}) * */ final class Allowed { /** * @var array */ public $roles; } L’uso di questa annotazione è molto semplice ed è descritto nel commento del codice precedente. Supponiamo ora di avere una rotta /administration alla quale solo gli utenti con ruolo ADMINISTRATION possono accedere; l’annotazione appena definita potrà essere sfruttata nel seguente modo: /** * @Route(“/administration“) * @Method(“GET“) * @Allowed(roles={“ADMINISTRATION“}) * @param Request $request * @return Response */ public function administrationAction(Request $request){ //TODO } AuthorizationListener L’ultimo componente della guida è l’authorization listener, ovvero un listener che si pone dopo il processo di autenticazione e prima dell’intervento di un controller; esso, basandosi sul contenuto del token, deciderà se l’utente ha o meno i ruoli per ottenere una risposta alla sua richiesta. Per poter portare a termine il proprio incarico il listener dovrà essere in grado di interpretare l’annotation Allowed e, di conseguenza, poter conoscere quale sarà il controller incaricato di gestire la richiesta. Al tal fine sarà necessario far reagire il listener all’evento KernelEvent, il quale verrà scatenato a posteriori del processo di lookup route che definisce quale controller dovrà operare. Con queste informazioni si sfrutteranno le potenzialità della classe AnnotationReader, in grado di interpretare le annotazioni di classi, metodi e proprietà. Se tale listener dovesse rilevare un accesso non autorizzato, allora solleverà un’eccezione, altrimenti il flusso proseguirà normalmente. <?php namespace AppBundle\Security\EventListener; use Doctrine\Common\Annotations\AnnotationReader; use AppBundle\Annotation\Allowed; use Symfony\Component\HttpKernel\Event\FilterControllerEvent; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\Security\Core\Exception\AccessDeniedException; class AuthorizationListener { public function __construct() { } public function onKernelController(FilterControllerEvent $event){ try{ //Ottieni la classe controller $controller = $event->getController()[0]; //Ottieni il metodo action $actionMethod = $event->getController()[1]; $reflectionController = new \ReflectionClass($controller); //Nessun controllo sull'esistenza dell'header perchè altrimenti il flusso di autenticazione non avrebbe dato esito positivo $token = $event->getRequest()->headers->get('Authorization'); //Recupero i dati utente $jwt = SimpleJWS::load($token); $user = $jwt->getPayload(); //Preparo un AnnotationReader per l'annotation Allowed $annotationReader = new AnnotationReader(); $controllerAnnotation = $annotationReader->getClassAnnotation($reflectionController, 'AppBundle\Annotation\Allowed'); //Controllo l'annotazione sull'intero controller if($controllerAnnotation){ $this->checkAnnotation($user, $controllerAnnotation); } else{ //Controllo l'annotazione sulla singola action $reflectionMethod = $reflectionController->getMethod($actionMethod); $methodAnnotation = $annotationReader->getMethodAnnotation($reflectionMethod, 'AppBundle\Annotation\Allowed'); if($methodAnnotation){ $this->checkAnnotation($user, $methodAnnotation); } } } catch(AccessDeniedException $ade){ throw new AccessDeniedHttpException(“Access Denied“); } } private function checkAnnotation($user, Allowed $annotation){ //Se nessun ruolo utente è contenuto nell'annotazione, non ci sono le autorizzazioni if(count(array_intersect($user['roles'], $annotation->roles)) === 0){ throw new AccessDeniedException(); } } } Il flusso di autorizzazione è completo, ma, come fatto precedentemente per l’autenticazione, è necessario definire il listener come servizio per poterlo rendere funzionante. Differentemente dall’autenticazione, però, in questo caso non abbiamo necessità di una factory, perchè la classe è una sola e non c’è dependency injection necessaria. Sarà quindi sufficiente aggiungere il servizio nel file services.yml app.tokens.action_listener: class: AppBundle\Security\EventListener\AuthorizationListener arguments: [] tags: - { name: kernel.event_listener, event: kernel.controller, method: onKernelController } Conclusioni Sfruttando l’architettura di Symfony e lo standard JWT è stato possibile realizzare un servizio completamente centralizzato per autenticare ed autorizzare gli accessi alla propria applicazione. Ciascun attore coinvolto in questo processo vive nel suo spazio dedicato e comunica con gli altri tramite configurazioni ad hoc o dependency injection. Dal canto suo lo standard JWT permette, con pochissimo sforzo, di fare sicurezza a qualsiasi livello di granularità, senza appesantire l’applicazione con cookie e sessioni. Nell’ottica della realizzazione di servizi REST questo approccio assume ancora maggior valore perchè permette di mantenere il flusso assolutamente sessionless, garantendo massimo distaccamento tra la componente client e quella server. Infine l’approccio seguito permette di realizzare un pacchetto Security con la caratteristica di non dover essere più toccato durante tutto il flusso di sviluppo dell’applicazione e con ottime potenzialità di riusabilità cross-applicazione.

Realizziamo qualcosa di straordinario insieme!

Siamo consulenti prima che partner, scrivici per sapere quale soluzione si adatta meglio alle tue esigenze. Potremo trovare insieme la soluzione migliore per dare vita ai tuoi progetti.