Angular et SSO (Single Sign On), côté client.
L’authentification
Lorsque vous créez un site WEB complexe reposant sur une base de données, la question de l’authentification est très vite abordée et il devient rapidement nécessaire d’identifier les utilisateurs de votre application afin de leur accorder des droits.
Dans cette optique, je vais vous faire part de mon expérience avec l’authentification SSO (Single Sign On) et les réseaux sociaux, que j’ai implémenté en Javacript et le framework AngularJS, je ne cherche en aucun cas à vous instruire l’utilisation de ces différents outils.
Se connecter sur votre site via les réseaux sociaux
Tout d’abord, pourquoi ne pas développer son propre mécanisme d’authentification ?
-
Le fonctionnement est complexe et doit garantir une sécurité optimale. Se pencher sur le sujet prend beaucoup de temps.
-
Les visiteurs de votre site seront plus méfiants à l’idée de se créer un nouveau compte, ils seront plus à l’aise s’ils peuvent se connecter via un compte qu’ils possèdent déjà tels que Facebook, Google, etc…
Si vous l’avez déjà expérimenté, vous avez peut être remarqué que l’utilisation d’une application tierce pour se faciliter la connexion est une pratique assez répandue dans le domaine des applications mobiles, plus particulièrement dans les jeux, mais elle s’applique aussi très bien dans le secteur du WEB.
Tout nous amène à croire que cette solution est le remède miracle contre la difficulté de la gestion de la connexion de l’utilisateur.
Néanmoins, il est important de noter que s’authentifier via une application tierce comporte tout de même des risques à ne pas négliger.
Si l’application sur laquelle votre site repose ne fonctionne plus (Facebook, Google, LinkedIn, ect…), l’utilisateur ne pourra plus se connecter, et vous ne pourrez malheureusement rien y faire puisque vous n’y avez aucun contrôle. (Croyez-moi, cela arrive beaucoup plus de fois qu’on pourrait le penser). Ce fût par exemple le cas avec Twitter qui a décidé de supprimer l’API Javascript qu’il mettait à disposition lors de son passage de la version 1.0 à la version 1.1 (pour des raisons de sécurité), forçant tous les utilisateurs de l’API à mettre à jour leurs sites.
Mon expérience et besoin
Dans le cadre de mon expérience, j’ai eu à traiter les problèmes suivants :
-
Authentification côté client en Javascript via Facebook, Google et LinkedIn
-
Personnaliser les boutons Facebook, Google et LinkedIn
-
Sauvegarde des utilisateurs de mon site dans une base
Dans cet exemple, j’ai utilisé du Javascript avec le framework AngularJS fonctionnant côté client. Le serveur ne sera que très peu évoqué par la suite.
Je vais ainsi utiliser les APIs Javascript proposées par ces trois réseaux sociaux.
Voici les spécifications du fonctionnement de mon application “News’ Innov” (pour Facebook) :
Un peu de théorie…
Afin de faciliter la maintenabilité et la réutilisation du code, il est important de bien structurer son application. Si vous cherchez plus d’informations concernant l’authentification via Facebook, Google et LinkedIn, vous vous apercevrez qu’il existe bien des manières de la réaliser. Notre but ici est d’homogénéiser ces trois APIs différentes, afin de rendre leur utilisation plus intuitive aux yeux du programmeur.
On distingue 2 phases :
-
Chargement des APIs
-
Utilisation des APIs
Voici l’architecture gérant l’authentification côté client que j’ai utilisé en considérant les points suivants :
-
Application single page, la page est partiellement rafraîchie lorsque l’utilisateur navigue entre les différentes fonctionnalités
-
Développement côté client sur AngularJS
-
Peu de traitements effectués sur le serveur (uniquement le stockage des informations utilisateurs, et la gestion des tokens)
Vous noterez que quelque soit notre réseau social, le fonctionnement est le même. Ainsi, il devient simple d’ajouter, si vous en avez le besoin, une nouvelle API.
J’ai choisi d’architecturer ce module d’authentification conformément au modèle MVC (modèle-vue-contrôleur).
Passons sans plus tarder à la pratique, je vous expliquerai en détail le fonctionnement de chacune de ces parties.
Chargement des APIs
Avant de se lancer, nous devons récupérer les clés d’API nous permettant d’utiliser les services proposés par Facebook, Google et LinkedIn.
Je vous laisse le soin d’effectuer les recherches nécessaires afin d’obtenir ces clés, vous trouverez quelques informations ici :
Facebook : https://ftutorials.com/facebook-api-key/
Google : https://developers.google.com/maps/documentation/javascript/tutorial?hl=fr#api_key
LinkedIn : https://developer.linkedin.com/documents/authentication
La 1ère étape consiste à charger les APIs proposées par nos différents réseaux sociaux.
Nous allons les récupérer de manière asynchrone afin de ne pas bloquer le chargement de la page.
En s’inspirant de la documentation :
// Charge le SDK de manière asynchrone
(function(){
// Si le SDK est déjà installé, c’est bon !
if(document.getElementById(‘facebook-jssdk‘)){return;}
// Création de la racine Facebook ‘fb-root’ dans le DOM
var fbroot = document.getElementById(‘fb-root‘);
if(!fbroot){
fbroot = document.createElement(‘div’);
fbroot.id=‘fb-root‘;
document.body.insertBefore(fbroot, document.body.childNodes[0]);
}
// On récupère la première balise <script>
var firstScriptElement = document.getElementsByTagName(‘script’)[0];
// Création du <script> Facebook
var facebookJS = document.createElement(‘script’);
facebookJS.id=‘facebook-jssdk‘;
// Source du Facebook JS SDK
facebookJS.src =‘//connect.facebook.net/fr_FR/all.js’;
// Insertion du Facebook JS SDK dans le DOM
firstScriptElement.parentNode.insertBefore(facebookJS, firstScriptElement);
}());
// Charge le SDK de manière asynchrone
(function(){
// On récupère la première balise <script>
var firstScriptElement = document.getElementsByTagName(‘script’)[0];
// Création du <script> Google
var googleJS = document.createElement(‘script’);
googleJS.type=‘text/javascript’;
googleJS.async=true;
// Source du Google JS SDK
googleJS.src=‘https://apis.google.com/js/client:plusone.js?onload=googlePlusAsyncInit‘;
// Insertion du Google JS SDK dans le DOM
firstScriptElement.parentNode.insertBefore(googleJS, firstScriptElement);
}());
// Charge le SDK de manière asynchrone
(function(){
// On récupère la première balise <script>
var firstScriptElement = document.getElementsByTagName(‘script’)[0];
// Création du <script> LinkedIn
var linkedInJS = document.createElement(‘script’);
linkedInJS.type=‘text/javascript’;
linkedInJS.async=true;
// Source du LinkedIn JS SDK
linkedInJS.src=‘https://platform.linkedin.com/in.js‘;
// Ajout du paramètre onLoad
var keys = document.createTextNode(
« n« +
« onLoad: linkedInAsyncInit »+
« n«
);
linkedInJS.appendChild(keys);
// Insertion du LinkedIn JS SDK dans le DOM
firstScriptElement.parentNode.insertBefore(linkedInJS, firstScriptElement);
}());
Si vous utilisez AngularJS, vous pouvez inclure la définition des scripts dans une directive :
angular.module(‘GooglePlus’,[]).
directive(‘googlePlus’,function(){
return{
restrict:‘A’,
scope:true,
controller:function($scope, $attrs){
// Ajouter le code ci-dessus
}
}
});
N’oubliez pas d’inclure les scripts et d’injecter les différents modules créés dans votre application.
Les codes ci-dessus vont créer les balises <script> permettant de récupérer le contenu des scripts chez nos fournisseurs. Une fois ces scripts chargés, chacun va faire appel à une fonction callback, qui va nous avertir.
Dans notre cas, voici les fonctions qui seront appelées à la fin du chargement de chaque script :
Facebook : Par défaut, la fonction fbAsyncInit
Google : googlePlusAsyncInit – défini en paramètre ?onload=googlePlusAsyncInit
LinkedIn : linkedInAsyncInit – défini dans le corps du script onLoad: linkedInAsyncInit
J’ai rencontré un bug : la callback “linkedInAsyncInit” n’est pas appelée sur Internet Explorer, si vous rencontrez le même problème, vous devrez ajouter ce bout de code dans le corps de la fonction asynchrone.
if(linkedInJS.readyState){
linkedInJS.onreadystatechange=function(){
if(linkedInJS.readyState==« loaded »||
linkedInJS.readyState==« complete »){
linkedInJS.onreadystatechange=null;
linkedInAsyncInit(); // On lance manuellement notre callback
}
};
}
linkedInJS.readyState nous permet de tester “manuellement” si nous sommes sur Internet Explorer, si c’est le cas, on implémente un listener qui appellera la callback une fois le script chargé.
Le corps de ces callbacks sera défini plus tard.
Nous voilà enfin prêts à utiliser les objets FB, gapi et IN, qui nous autorisent à communiquer avec nos fournisseurs !
Création de l’utilisateur
Nous avons besoin de stocker toutes les informations relatives à l’utilisateur dans un objet. Celui-ci devra être disponible sur toutes les pages de notre site, de ce fait, nous pouvons le stocker sous la forme d’un service AngularJS (il est aussi possible d’utiliser une factory AngularJS).
A vous de choisir quelles informations utilisateur stocker. Dans mon cas, je dois récupérer un token qui me permettra de maintenir et de sécuriser ma connexion avec mon fournisseur. Je sauvegarde aussi mes données dans un cookie qui sera réutilisable lors de la prochaine visite de l’utilisateur sur mon site. La validité du cookie sera assurée par le token.
angular.module(‘myApp’)
.service(‘UserService’,function($cookieStore){
var user ={
accessToken:« »,
id:« »,
isLogged:false,
firstName:« »,
lastName:« »,
email:« »,
socialNetwork:« »,
image:« »
};
// Fonctions utilisateurs
this.setUser=function(userData){
user.isLogged=true;
// … Affecter les différents champs de notre utilisateur
$cookieStore.put(‘user’, user);// Ajouter dans les cookies
sendToServer();
}
// Ajoutez d’autres fonctions …
this.sendToServer =function(userData){
// …
}
this.logout=function(){
// Vider tous les champs
user.isLogged=false;
$cookieStore.remove(‘user’);
}
});
Afin d’utiliser le service $cookieStore d’AngularJS, n’oubliez pas d’injecter le module “ng-cookies” dans votre application.
Définition des services Facebook, Google et LinkedIn
Écriture de la structure de nos services
J’ai créé 3 services de type factory AngularJS correspondant à chaque fournisseur.
Chacun de ces services implémentera une fonction d’initialisation de l’API, une fonction de connexion et une fonction de déconnexion.
angular.module(‘Authentication’)
.factory(‘GooglePlusService’,function(UserService){
return{
init:function(clientId){
// Code d’initialisation
},
login:function(){
// Code de connexion
},
logout:function(){
// Code de déconnexion
}
};
});
Initialisation des APIs
Nous avons précédemment défini comment charger nos différentes API, passons à leur initialisation.
Notre fonction d’initialisation prend en paramètre la clé d’API récupérée auprès de notre fournisseur qui sera nécessaire afin d’autoriser une connexion. Elle contiendra la callback qui sera appelée lorsque le script sera complètement chargé. Vous pouvez paramétrer les options d’initialisation comme vous le désirez, celles-ci sont décrits dans la documentation de chacune des APIs.
Le comportement des APIs est assez différent, je vais alors vous les décrire au cas par cas.
init:function(apiKey){
// Cette fonction est appelée lorsque le script sera chargé
window.fbAsyncInit=function(){
FB.init({
appId: apiKey, // La clé d’API Facebook
cookie:true,
status:true,
xfbml:true
});
}
}
Souvenez-vous, la callback appelée par Facebook est “fbAsyncInit”.
init:function(clientId){
window.googlePlusAsyncInit=function(){
// Cette fonction permet de contourner les bloqueurs de popup
window.setTimeout(gapi.auth.init,1);
// On stocke les paramètres de la connexion dans une variable
// Ces paramètres seront utilisés
params ={
client_id: clientId +« .apps.googleusercontent.com », // La clé d’API Google
immediate:false,
scope:« https://www.googleapis.com/auth/plus.login https://www.googleapis.com/auth/userinfo.email » // Les permissions
};
}
}
La callback appelée par Google est “googlePlusAsyncInit”, le nom a été défini arbitrairement auparavant.
init:function(apiKey){
window.linkedInAsyncInit=function(){
IN.init({
onLoad:« loadLinkedIn »,
api_key: apiKey, // La clé d’API LinkedIn
authorize:true,
credentials_cookie:true,
lang:« fr_FR »
});
};
}
La callback appelée par LinkedIn est “linkedInPlusAsyncInit”, le nom a été défini arbitrairement auparavant.
Le fonctionnement de LinkedIn est particulier car implémente le mécanisme des événements (listeners). De ce fait, les événements doivent être chargés qu’une seule fois. Je les ai définis dans la callback “loadLinkedIn”.
window.loadLinkedIn=function(){
// Evénement qui sera lancé lorsque l’utilisateur sera authentifié
IN.Event.on(IN,« auth »,function(){
login(); // On récupère les données utilisateur.
});
}
Pourquoi tant de callbacks ?
Vous vous en êtes probablement rendu compte, l’utilisation de callbacks n’est pas très élégant car les fonctions sont définies globalement dans toute l’application. Pour Facebook et Google, il est possible de fournir en paramètre du “onLoad” une fonction, mais ce n’est pas le cas avec LinkedIn, qui prend en paramètre le nom de la fonction. Ainsi, LinkedIn recherche une fonction définie globalement dans le window.
Récupération des données utilisateur
Nous voici dans la phase la plus importante de notre module d’authentification.
Lorsque l’utilisateur va cliquer sur notre bouton, il faudra alors faire appel à l’API correspondante afin d’ouvrir une popup de connexion. Rassurez-vous, cette popup est entièrement gérée par le fournisseur, vous n’avez juste qu’à faire appel à la fonction permettant de l’afficher et récupérer les informations utilisateur.
login:function(){
FB.getLoginStatus(function(response){
if(response.status===‘connected’){
// On récupère le token temporaire qu’on stocke dans une variable
accessToken = response.authResponse.accessToken;
// Si l’utilisateur est déjà connecté à Facebook, on récupère son profil
getProfile();
}else{
// Si l’utilisateur n’est déjà connecté à Facebook, on ouvre la popup
openPopup();
}
});
}
// Fonction d’ouverture de la popup
function openPopup(){
// Ouverture de la popup
FB.login(function(response){
// Teste si l’utilisateur est désormais connecté
if(response.status==‘connected’){
// On récupère le token temporaire qu’on stocke dans une variable
accessToken = response.authResponse.accessToken;
// On récupère le profil utilisateur
getProfile();
}elseif(angular.isDefined(response.error)){
// Une erreur s’est produite
}
},{scope:’email’}); // Paramétrage pour récupérer l’email
}
// Fonction de récupération du profil
function getProfile(){
FB.api(‘/me’,function(response){
if(angular.isUndefined(response.error)){
// On remplit notre objet utilisateur
UserService.set(response, accessToken /* Ajoutez les champs nécessaires */ );
}else{
// Une erreur s’est produite
}
});
}
login:function(){
// Ouvre la popup
// Prend en paramètre les paramètres utilisés lors de l’initialisation
// et la callback qui va traiter la réponse envoyée par Google
gapi.auth.authorize(params, loginFinishedCallback);
}
function loginFinishedCallback(authResult){
if(authResult[‘access_token’]&& authResult[‘g-oauth-window’]&&
angular.isUndefined(authResult[‘error’])){
// On affecte le token à notre variable gapi
// (Utile pour récupérer l’adresse email)
gapi.auth.setToken(authResult);
// On récupère le token temporaire
var accessToken = authResult[‘access_token’];
// On récupère les informations utilisateur
gapi.client.load(‘plus’,‘v1’,function(){
var request = gapi.client.plus.people.get({‘userId’:‘me’});
request.execute(
function(profile){
// On remplit notre objet utilisateur
UserService.set(profile, accessToken /* Ajoutez les champs nécessaires */ );
}
);
});
}elseif(authResult[‘error’]){
// Une erreur s’est produite
}else{
// authResult est vide, l’utilisateur a abandonné la connexion
}
}
// Si on désire récupérer l’adresse mail, il faut réaliser cette requête supplémentaire
// gapi.auth.setToken doit avoir été appelé auparavant
gapi.client.load(‘oauth2’,‘v2’,function(){
var request = gapi.client.oauth2.userinfo.get();
request.execute(
function(profile){
email = profile.email;// On récupère l’email
// …
}
);
});
login:function(){
// On affiche la popup
var isAlreadyLogged = IN.User.authorize();
// Si l’utilisateur s’est connecté, on récupère son profil
if(isAlreadyLogged){
getProfile();
}else{
// L’utilisateur a abandonné la connexion
}
}
function getProfile(){
IN.API.Profile(« me »).fields([« id »,« firstName »,« lastName »,« pictureUrl »,« publicProfileUrl »,« emailAddress »])/* Ajoutez les champs désirés */
.result(function(result){
var profile ={};
// Le résultat envoyé par LinkedIn est très complet, nous récupérons seulement le profil
angular.copy(result.values[0], profile);
// Récupération du token temporaire
var accessToken = IN.ENV.auth.oauth_token;
UserService.set(profile, accessToken /* Ajoutez les champs nécessaires */);
}).error(function(err){
// Une erreur s’est produite
});
}
Dans le cas de LinkedIn, il se peut que l’utilisateur n’ait pas défini de photo. Le champ “pictureUrl” est alors “undefined”. Si vous voulez récupérer la photo par défaut de LinkedIn, vous la trouverez via ce lien : https://s.c.lnkd.licdn.com/scds/common/u/images/themes/katy/ghosts/person/ghost_person_200x200_v1.png
La déconnexion
Dans mon cas, lorsque l’utilisateur cliquera sur le bouton “Se déconnecter”, il se déconnectera uniquement de mon application et non du réseau social.
Dans les trois cas, ma fonction de déconnexion est la suivante
logout:function(){
UserService.logout();
}
Si on veut fermer toute session de Facebook, par exemple, on peut ajouter:
FB.logout();
Service d’authentification global
Toutes nos fonctions sont désormais implémentées, il ne nous reste plus qu’à les appeler !
Nous allons réunir tous nos appels dans un service:
angular.module(‘Authentication’)
.factory(‘AuthenticationService’,
function(UserService, FacebookService, GooglePlusService, LinkedInService){
var FACEBOOK_API_KEY =« Votre_cle_Facebook »;
var GOOGLE_CLIENT_ID =« Votre_cle_Google »;
var LINKED_IN_API_KEY =« Votre_cle_LinkedIn »;
return{
// Pour récupérer les données de l’utilisateur
getUser:function(){
return UserService.getUser();
},
// Fonctions d’initialisation
initFacebook:function(){
FacebookService.init(FACEBOOK_API_KEY);
},
initGooglePlus:function(){
GooglePlusService.init(GOOGLE_CLIENT_ID);
},
initLinkedIn:function(){
LinkedInService.init(LINKED_IN_API_KEY);
},
// Fonctions de connexion
connectFacebook:function(){
FacebookService.login();
},
connectGooglePlus:function(){
GooglePlusService.login();
},
connectLinkedIn:function(){
LinkedInService.login();
},
// Fonctions de déconnexion
disconnectFacebook:function(){
FacebookService.logout();
},
disconnectGooglePlus:function(){
GooglePlusService.logout();
},
disconnectLinkedIn:function(){
LinkedInService.logout();
}
}
});
Contrôleur
Le contrôleur joue le rôle de médiateur entre le service et la vue.
On va initialiser toutes nos APIs, puis demander la connexion/déconnexion lorsque l’utilisateur va cliquer sur un bouton.
angular.module(‘Authentication’)
.controller(‘AuthenticationCtrl’,function($scope, $location, SourcesCache, AuthenticationService){
// On initialise toutes les APIs
AuthenticationService.initFacebook();
AuthenticationService.initLinkedIn();
AuthenticationService.initGooglePlus();
// On récupère notre utilisateur, pour par exemple l’afficher sur notre vue
$scope.user= AuthenticationService.getUser();
// Les fonctions de connexion, lancées par un clic
$scope.connectFacebook=function(){
AuthenticationService.connectFacebook();
};
$scope.connectTwitter=function(){
AuthenticationService.connectTwitter();
};
$scope.connectGooglePlus=function(){
AuthenticationService.connectGooglePlus();
};
$scope.connectLinkedIn=function(){
AuthenticationService.connectLinkedIn();
};
// La déconnexion est la même pour chaque réseau social dans mon cas
// J’ai affecté un champ « socialNetwork » qui renseigne quel réseau social
// a été utilisé
$scope.disconnectUser=function(){
switch($scope.user.socialNetwork){
case(« facebook »):
AuthenticationService.disconnectFacebook();
break;
case(« google »):
AuthenticationService.disconnectGooglePlus();
break;
case(« linkedin »):
AuthenticationService.disconnectLinkedIn();
break;
}
};
});
Création de la vue
Passons dorénavant au HTML. Nous devons ajouter nos 3 boutons de connexion :
<a facebookclass=« facebook »ng-click=« connectFacebook() »>Se connecter avec Facebook</a>
<a google-plusclass=« google »ng-click=« connectGooglePlus() »>Se connecter avec Google</a>
<a linkedinclass=« linkedin »ng-click=« connectLinkedIn() »>Se connecter avec LinkedIn</a>
et notre bouton de déconnexion :
<a ng-click=« disconnectUser() »>Se déconnecter</a>
Vous pouvez ajouter le style que vous souhaitez grâce au CSS.
Avec AngularJS, on fait appel aux directives en les ajoutant en attribut ou en balise (cela dépend de la façon dont vous avez défini votre directive).
Si vous n’utilisez pas AngularJS, vous pouvez remplacer ng-click par on-click.
Petit point sur les tokens et validation par le serveur
Jusqu’ici, les tokens récupérés sont des tokens ayant une durée de vie limitée. Il faut alors se procurer des tokens validés.
Dans mon cas, je n’utilise uniquement les réseaux sociaux pour l’authentification. Le client va envoyer le token temporaire au serveur, et ce serveur va communiquer une seconde fois avec le fournisseur afin de valider ce token (Rappelons-le, le Javascript n’est pas réputé pour sa sécurité). Si le token est bien valide, l’application est autorisée à utiliser les fonctionnalités du site bloquées.
Pour plus d’informations sur le mécanisme de tokens, je vous invite à vous rendre sur le site de Facebook Developers qui vous propose plusieurs architectures selon vos besoins :
https://developers.facebook.com/docs/facebook-login/access-tokens/#architecture
Pour conclure
Dans cette longue étude, j’ai passé beaucoup de temps à sélectionner l’architecture qui me semblait la plus adaptée pour mon type d’application. Si vous cherchez à réaliser une application aux fonctionnalités similaires, il vous sera utile de passer du temps sur les différentes documentations des APIs afin de connaître les paramétrages spécifiques à utiliser. Vous trouverez sans doute une autre solution plus adaptée à votre cas.
En développement collaboratif, il est primordial d’être clair sur le code afin de faire gagner beaucoup de temps aux autres à la lecture de votre code.
Comme l’a si bien dit Alain Rémond, “Partir, c’est ranger un peu”…
Ce sujet vous intéresse ?