dimanche 6 janvier 2013

AngularJS + Play! Framework: Authentification





En tant que newbie sur Angular, je me suis posé la question de l'authentification dans ce type d'applications.

Pour ceux qui veulent aller directement à la présentation du code, c'est
.



Depuis longtemps je suis habitué au développement de web apps dont les pages sont générées dynamiquement sur le serveur (JSP, JSF, PHP, etc.): cette problématique est donc comblée dans la plupart des cas par une redirection vers un formulaire d'authentification. Or avec un framework du type AngularJS, les pages sont générées dynamiquement sur le client à partir de templates nourries de données obtenues du serveur collectées grâce à du code client (Javasript).

D'un point de vue technique cette approche a évidemment énormément de sens, car:





  • Toutes les ressources clientes sont donc "cachables": templates, Javascript

  • A l'heure du Cloud (un buzzword, un), le coût d'hébergement est proportionnel à la sollicitation, il est tout naturel de transférer sur la machine du client (gratuite) toute charge éligilble

  • L'état du client est géré... sur le client (un truc de malade), permettant de réduire la pression sur la mémoire de la partie serveur (au point précédent on a économisé de la CPU, là on s'occupe de la RAM!). Sans compter que si la partie serveur ne maintient plus d'état, n'importe quelle instance pourra servir n'importe quelle requête... Nous parlons donc de conception Stateless (buzzword #2), permettant de déployer les noeuds en ayant aucun autre souci que la répartition de charge (pas de synchronisation entre les noeuds), m'amenant tout doucement vers le 3ème buzzword, la scalabilité horizontale.



En réfléchissant, si la traditionnelle redirection vers le formulaire de login ne semble pas être une option adaptée, que reste-t-il? Facile: le protocole HTTP prévoit au moins deux codes de retour liés aux problèmes de contrôles d'accès: 401, authentification requise, et 403, authentification refusée. Parfait, le premier m'explique que je dois présenter mes papiers et le second me signale que l'authentification fournie ne comprend pas l'accréditation nécessaire pour accéder à la ressource demandée.



Donc me voilà parti sur Google avec les mots clefs "Angular authentication 401" et je tombe sur un article fort intéressant:
http://www.espeo.pl/2012/02/26/authentication-in-angularjs-application . La démonstration est complète: toutes les requêtes tenues en échec suite à une erreur 401 sont bufferisées, en attente de la connexion de l'utilisateur et retentées le cas échéant. Et si on veut quelque chose de plus basique? Un peu plus newbie? Genre une redirection vers un formulaire de login à la première erreur 401 et une redirection a la racine suite à authentification? C'est sûrement pas aussi complet, mais l'aspect naïf permet de monter tranquillement en compétence sur AngularJS.



Avant toute chose, il nous faut un backend, mon choix c'est porté sur Play 2.1-RC1 car:



  • Il est stateless par essence

  • C'est hype, j'en avais envie et c'est moi le chef du blog (#noTroll)

Point de départ standard avec une application Play vide, ensuite on récupère une partie de l'authentification du sample
zentask en modifiant quelques aspects:



  • Tout d'abord, en cas de nécessité d'authentification, pas de redirection => 401:


  private def onUnauthorized(request: RequestHeader) = Results.Redirect(routes.Application.login)
devient:


  private def onUnauthorized(request: RequestHeader) = Results.Unauthorized





  • La démonstration nécessite une ressource protégée:




  def protectedResource = IsAuthenticated{
    username => _ =>
    Ok(Json.obj("test"->"1234"))
  }






  • Comme on veut faire une application cliente qui se connecte à un backend, pas d'utilisation des templates Play et modification de la route pour que la racine pointe sur une ressource statique:

GET     /                           controllers.Assets.at(path="/public", file="index.html")



Le code est disponible
ici.



Vérifications:





  • Pas autorisé:




$ curl http://localhost:9000/protectedResource -v
* About to connect() to localhost port 9000 (#0)
*   Trying ::1...
* connected
* Connected to localhost (::1) port 9000 (#0)
> GET /protectedResource HTTP/1.1
> User-Agent: curl/7.24.0 (x86_64-apple-darwin12.0) libcurl/7.24.0 OpenSSL/0.9.8r zlib/1.2.5
> Host: localhost:9000
> Accept: */*
>
< HTTP/1.1 401 Unauthorized
< Content-Length: 0
<
* Connection #0 to host localhost left intact
* Closing connection #0


L'application réfute bien l'accès avec un code 401.





  • Authentification:




$ curl http://localhost:9000/login -d 'mail=tony@stark.com&password=ironman' -v
* About to connect() to localhost port 9000 (#0)
*   Trying ::1...
* connected
* Connected to localhost (::1) port 9000 (#0)
> POST /login HTTP/1.1
> User-Agent: curl/7.24.0 (x86_64-apple-darwin12.0) libcurl/7.24.0 OpenSSL/0.9.8r zlib/1.2.5
> Host: localhost:9000
> Accept: */*
> Content-Length: 36
> Content-Type: application/x-www-form-urlencoded
>
* upload completely sent off: 36 out of 36 bytes
< HTTP/1.1 200 OK
< Set-Cookie: PLAY_SESSION=1701c43b7f845bdde0e38c0f43705d54b6815977-mail%3Atony%40stark.com; Path=/; HTTPOnly
< Content-Length: 0
<
* Connection #0 to host localhost left intact
* Closing connection #0

Code 200, un cookie signé de session en retour… tout va bien, donc on retente l'accès à la ressource protégée en présentant le sésame:




$ curl http://localhost:9000/protectedResource -b "PLAY_SESSION=1701c43b7f845bdde0e38c0f43705d54b6815977-mail%3Atony%40stark.com" -v
* About to connect() to localhost port 9000 (#0)
*   Trying ::1...
* connected
* Connected to localhost (::1) port 9000 (#0)
> GET /protectedResource HTTP/1.1
> User-Agent: curl/7.24.0 (x86_64-apple-darwin12.0) libcurl/7.24.0 OpenSSL/0.9.8r zlib/1.2.5
> Host: localhost:9000
> Accept: */*
> Cookie: PLAY_SESSION=1701c43b7f845bdde0e38c0f43705d54b6815977-mail%3Atony%40stark.com
>
< HTTP/1.1 200 OK
< Content-Type: application/json; charset=utf-8
< Content-Length: 15
<
* Connection #0 to host localhost left intact
{"test":"1234"}* Closing connection #0


On obtient donnée attendue, authentification réussie!



Maintenant je m'attaque au client:




<html ng-app="angularAuth" authenticator>
<head>
  <title>Angular authent</title>
    <script type="text/javascript" src="/public/javascripts/angular/angular.min.js"></script>
    <script type="text/javascript" src="/public/javascripts/angular/angular-resource.min.js"></script>
    <script type="text/javascript" src="/public/javascripts/app.js"></script>
    <script type="text/javascript" src="/public/javascripts/controllers.js"></script>
</head>
<body>
<h1>Simple authentication example</h1>
<div ng-view></div>
</body>
</html>


Mon module AngularJS est sommaire:




angular.module("angularAuth",['authServiceProvider']).
    config(['$routeProvider',function($routeProvider){
        $routeProvider.
            when("/", {templateUrl:"public/partials/protectedContent.html", controller:ProtectedCtrl}).
            when("/login",{templateUrl:"public/partials/login.html", controller:LoginCtrl}).
            otherwise({redirectTo:"/"})
    }]).
    directive('authenticator',function($location){
        return function(scope, elem, attrs){
            scope.$on('event:auth-loginRequired',function(){
                $location.path("/login")
            })
        }
    })  ;



En gros, quand "/" est demandé, affichage du template tirant la ressource protégée et lorsque "/login" est demandé, affichage du formulaire de login et redirection dans tous les autres cas.

Ensuite un listener va être positionné afin de réagir à l'évènement de demande d'authentification. L'utilisation de la directive permet de le placer au niveau du module, soit une fois pour toutes. Ne pas oublier de mentionner la directive dans le fichier html (moi, j'ai cherché un moment au début :-D).



Le concept présenté par Witold Szczerba est de tirer parti de la possibilité de poser des intercepteurs sur le service $http, c'est ce que je vais faire mais de façon plus naïve dans le module "authServiceProvider" injecté dans "angularAuth":




angular.module('authServiceProvider', []).
config(['$httpProvider', function($httpProvider) {

$httpProvider.responseInterceptors.push(function($q,$rootScope,$log){
function success(response) {
// $log.info(response)
return response
}

function error(response) {
if (response.status === 401) {
$log.error("401!!!!")
$rootScope.$broadcast('event:auth-loginRequired')
}
return $q.reject(response)
}

return function(promise) {
return promise.then(success, error)
}

})

}])



La fonction: si une requête retourne une erreur 401, alors l'évènement de demande d'authentification est propagé et stimule ainsi la directive et le formulaire de login apparaît. Je vous épargne un couplet sur l'API Promise d'AngularJS et cous encourage à aller consulter le Reference Guide.



Les contrôleurs du
formulaire d'authentification, de récupération de la ressource protégée et les
templates associés sont spartiates et consultables sur GitHub.



En introduction, j'ai présenté les arguments liés à la rationalisation de l'infrastructure d'hébergement, en revanche à l'heure actuelle il est clair que la charge de développement est supérieure comparée à une application dans laquelle les vues sont générées par le serveur: on y déclare simplement l'emplacement du formulaire de login.



La démo est testable sur
Heroku.