Contexte :

Dans les épisodes précédents, vous avez sécurisé votre API avec Keycloak, et ça c’est bien. Mais consommer votre API c’est tout aussi indispensable.
Dans cet article, nous allons voir comment s’authentifier auprès du serveur Keycloak pour qu’il nous fournisse un token qui va nous permettre d’effectuer nos appels vers l’API depuis notre application Angular grâce à Keycloak.js.

Pour le bon déroulement de cet article, les parties #1, #2 et #3 doivent être terminées. Le serveur keycloak doit être démarré.

Vous devez avoir NodeJS en version 6+ et le Angular CLI d’installés.

Le code source présenté est disponible sur le repository Github d’Ineat.

Prérequis

Keycloak : 4.1.0 FINAL

Angular CLI : 6+

Angular : 6+

Configuration du client keycloak

Le client web sur le serveur keycloak doit être configuré sous peine d’obtenir une boucle infinie lors de la connexion.

Il faut renseigner :

  • Root Url: http://localhost:4200
  • Valid Redirect URI’s: http://localhost:4200/*
  • Web Origins= http://localhost:4200

Initialisation du projet Angular

Pour démarrer un nouveau projet Angular nous allons utiliser le CLI :

ng new ng-keycloak

Nous avons notre squelette d’application Angular. Nous allons créer un nouveau composant Dashboard qui va s’afficher au démarrage de notre application.

Ce composant affichera le nom de l’utilisateur connecté et effectuera une requête sur l’API sécurisée.

cd ng-keycloak
ng generate component components/dashboard

Nous allons modifier le fichier app.component.ts afin qu’il affiche le nouveau composant.

@Component({
  selector: 'root',
  template: `
    <app-dashboard></app-dashboard>
  `
})

Nous pouvons vérifier si notre projet fonctionne en démarrant le serveur web intégré :

ng serve

Installation du connecteur javascript Keycloak

Pour installer le connecteur javascript Keycloak, nous allons utiliser le gestionnaire de dépendances NPM.

npm install --save keycloak-js@latest

On ajoute le paramètre ‘@latest’ pour récupérer la dernière version du connecteur.

Il faut ensuite déclarer son utilisation dans l’application Angular. Pour celà on ouvre le fichier angular.json.

On ajoute la ligne suivante au niveau de la déclaration des scripts :

"projects": [
  ...
  "scripts": [ 
    "node_modules/keycloak-js/dist/keycloak.js"
  ],
],

La librairie est désormais accessible au sein de l’application Angular.

Création du service Keycloak

Pour communiquer avec la librairie Keycloak.js, nous allons créer un nouveau service.

ng generate service services/keycloak/keycloak

Ce service va nous permettre d’initialiser l’object de configuration de Keycloak et va contenir les différentes fonctions comme celles pour la déconnexion, le getToken…

On importe la librairie keycloak-js et le fichier d’environnement au niveau des imports :

import * as Keycloak from 'keycloak-js';
import { environment } from '../../../environments/environment';

On déclare ensuite un object static auth dans la classe KeycloakService :

@Injectable()
export class KeycloakService {
  static auth: any = {};
}

Ce dernier va recevoir toutes les informations comme par exemple : “l’utilisateur est-il loggé ?”.

Création de la fonction d’initialisation

static init(): Promise<any> {
  /**
   * init KeycloakService with client-id
   * @type {Keycloak.KeycloakInstance}
   */
  const keycloakAuth: Keycloak.KeycloakInstance = Keycloak({
    url: environment.keycloak.url,
    realm: environment.keycloak.realm,
    clientId: environment.keycloak.clientId,
    'ssl-required': 'external',
    'public-client': true
  });
  KeycloakService.auth.loggedIn = false;
  return new Promise((resolve, reject) => {
    keycloakAuth.init({ onLoad: 'check-sso', checkLoginIframe: false })
      .success(() => {
        KeycloakService.auth.loggedIn = false;
        KeycloakService.auth.authz = keycloakAuth;
        console.log(KeycloakService.auth.authz.tokenParsed);
        resolve();
      })
      .error(() => {
        reject();
      });
  });
}

Cette fonction contient l’object de configuration Keycloak avec les différents paramètres comme l’url, le royaume ou encore le clientId. Pour définir les paramètres de configuration nous utilisons le fichier environment d’Angular.

export const environment = {
  production: false,
  apiUrl:'URL_DE_API',
  keycloak: {
    url: 'http://localhost:8080/auth',
    realm: 'NOM_DU_ROYAUME',
    clientId: 'CLIENT_ID',
  }
};

Nous allons ensuite créer 4 autres fonctions :

  • getToken, qui va permettre de récupérer le token une fois l’authentitifaction effectuée.
  • getFullName, qui renverra le nom et prénom de l’utilisateur connecté.
  • login, qui permettra de nous logger sur l’application.
  • logout, qui permettra de nous dé-logger de l’application.
  • getKeycloakAuth, qui nous donne accès à plusieurs éléments d’authentification.

La fonction getToken

Cette fonction qui retourne une Promise va nous permettre à la fois de récupérer le token afin de le transmettre sur chaque appel vers l’API mais également d’effectuer un update de ce token via le RefreshToken OAuth.

getToken(): Promise<string> {
    return new Promise<string>((resolve, reject) => {
      if (KeycloakService.auth.authz.token) {
        KeycloakService.auth.authz
          .updateToken(5)
          .success(() => {
            resolve(<string>KeycloakService.auth.authz.token);
          })
          .error(() => {
            reject('Failed to refresh token');
          });
      } else {
        reject('Not logged in');
      }
    });
  }

La fonction getFullName

Cette fonction nous renverra le nom et prénom de l’utilisateur connecté.

getFullName(): string {
    return KeycloakService.auth.authz.tokenParsed.name;
}

La fonction login

Cette fonction nous enverra vers la page de login du serveur Keycloak puis une fois authentifié nous redirigera vers la home de l’application Web.

login(): void {
    KeycloakService.auth.authz.login().success(
      () => {
        KeycloakService.auth.loggedIn = true;
      }
    );
  }

La fonction logout

Cette fonction nous permettra de nous déconnecter de l’application et nous redirigera vers la page de login.

logout(): void {
  KeycloakService.auth.authz.logout({redirectUri : document.baseURI}).success(() => {
    KeycloakService.auth.loggedIn = false;
    KeycloakService.auth.authz = null;
  });
}

La fonction getKeycloakAuth

Cette fonction nous renvoie l’object authz de Keycloak et nous donne accès à plusieurs éléments dont la variable qui nous renvoie si l’utilisateur est connecté ou non.

getKeycloakAuth() {
    return KeycloakService.auth.authz;
  }

Le fichier final ressemble à :

import { Injectable } from '@angular/core';
import { environment } from '../../../environments/environment';
import * as Keycloak from 'keycloak-js';

@Injectable()
export class KeycloakService {

  static auth: any = {};

  static init(): Promise<any> {
    /**
     * init KeycloakService with client-id
     * @type {Keycloak.KeycloakInstance}
     */
    const keycloakAuth: Keycloak.KeycloakInstance = Keycloak({
      url: environment.keycloak.url,
      realm: environment.keycloak.realm,
      clientId: environment.keycloak.clientId,
      'ssl-required': 'external',
      'public-client': true
    });
    KeycloakService.auth.loggedIn = false;
    return new Promise((resolve, reject) => {
      keycloakAuth.init({ onLoad: 'check-sso', checkLoginIframe: false })
        .success(() => {
          KeycloakService.auth.loggedIn = false;
          KeycloakService.auth.authz = keycloakAuth;

          console.log(KeycloakService.auth.authz.tokenParsed);
          resolve();
        })
        .error(() => {
          reject();
        });
    });
  }

  constructor() { }

  login(): void {
    KeycloakService.auth.authz.login().success(
      () => {
        KeycloakService.auth.loggedIn = true;
      }
    );
  }

  getToken(): Promise<string> {
    return new Promise<string>((resolve, reject) => {
      if (KeycloakService.auth.authz.token) {
        KeycloakService.auth.authz
          .updateToken(5)
          .success(() => {
            resolve(<string>KeycloakService.auth.authz.token);
          })
          .error(() => {
            reject('Failed to refresh token');
          });
      } else {
        reject('Not logged in');
      }
    });
  }

  isLoggedIn(): boolean {
    return KeycloakService.auth.authz.authenticated;
  }

  getFullName(): string {
    return KeycloakService.auth.authz.tokenParsed.name;
  }

  getKeycloakAuth() {
    return KeycloakService.auth.authz;
  }

  logout(): void {
    KeycloakService.auth.authz.logout({redirectUri : document.baseURI}).success(() => {
      KeycloakService.auth.loggedIn = false;
      KeycloakService.auth.authz = null;
    });
  }

}

Voilà c’est a peu prêt tout pour notre service Keycloak. On peu facilement ajouter d’autres fonctions à notre service pour nous permettre de récupérer d’autres informations à partir du token.

Création de la couche service pour effectuer les appels vers l’API

Pour effectuer nos appels vers l’api nous allons créer un nouveau service.

ng generate service services/data/data

Dans le fichier data.service.ts, nous utilisons le client http d’Angular et les variables d’environnement pour effectuer nos appels vers l’API.

import { Injectable } from '@angular/core';
import { environment } from '../../../environments/environment';
import { HttpClient } from '@angular/common/http';

@Injectable()
export class DataService {
  private apiUrl = environment.apiUrl;

  constructor(
    private http: HttpClient
  ) { }


}

Le service va contenir 3 fonctions :

  • la fonction getUnsecureData pour récupérer les données non sécurisées.
  • la fonction getUserData pour récupérer les données accessible uniquement pour un utilisateur étant identifié.
  • la fonction getAdminData pour récupérer les données accessible uniquement pour un administrateur.
getUnsecureData() {
  return this.http.get(this.apiUrl + '/', {responseType: 'text'});
}

getUserData() {
  return this.http.get(this.apiUrl + '/user', {responseType: 'text'});
}

getAdminData() {
  return this.http.get(this.apiUrl + '/admin', {responseType: 'text'});
}

Création de l’intercepteur HTTP Keycloak

Pour passer des appels sécurisés vers l’API, il faut ajouter le token au niveau de chaque requête qui lui seront envoyées. Pour cela nous allons créer un intercepteur HTTP en utilisant le client HTTP d’Angular (HttpClient).

ATTENTION :

Depuis la version 4 d’Angular, il existe un nouveau client HTTP. En effet dans la version 4, on retrouve l’ancien client nomme ‘Http’ qui est déprécié et le nouveau client ‘HttpClient’. Dans la dernière version d’Angular, seul le client ‘HttpClient’ est présent.

Nous allons donc créer un intercepteur HTTP pour ajouter dans le header de chaque requête le champ ‘Authorization’.

Créer le fichier keycloak.interceptor.service.ts dans la répertoire ‘/services/keycloak’ :

@Injectable()
export class KeycloakInterceptorService implements HttpInterceptor {
  constructor(
  ) {}

  intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
  }
}

Nous allons utiliser le service keycloak pour récupérer le token grâce à la méthode getToken.

getUserToken() {
  const tokenPromise: Promise<string> = this.keycloakService.getToken();
  const tokenObservable: Observable<string> = from(tokenPromise);
  return tokenObservable;
}

La méthode getToken retourne une Promise que nous allons transformer en Observable afin de pouvoir l’injecter dans notre méthode intercept.

Dans la méthode intercept nous allons dans un premier temps vérifier si l’utilisateur est connecté ou non afin de laisser passer les appels api sans token (routes publiques de l’api) et utiliser RxJs et l’opérateur mergeMap pour utiliser l’observable retourné par la méthode getUserToken.

intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
  if (this.keycloakService.isLoggedIn()) {
    return this.getUserToken().pipe(
      mergeMap((token) => {
        if (token) {
          request = request.clone({
            setHeaders: {
              Authorization: `Bearer ${token}`
            }
          });
        }
        return next.handle(request);
      }));
  }
  return next.handle(request);
}

Une fois le token récupéré, nous clonons la requête et nous lui ajoutons un nouveau paramètre Authorization: Bearer +Token puis nous retournons la requête contenant le nouveau Header.

Si l’utilisateur n’est pas connecté et n’a par conséquent pas de token, nous retournons la requête.

Le fichier final ressemble au suivant :

import { Injectable } from '@angular/core';
import { HttpInterceptor, HttpRequest, HttpHandler, HttpEvent } from '@angular/common/http';
import { Observable, from } from 'rxjs';
import { mergeMap } from 'rxjs/operators';
import { KeycloakService } from './keycloak.service';

@Injectable()
export class KeycloakInterceptorService implements HttpInterceptor {
  constructor(
    private keycloakService: KeycloakService
  ) {}

  intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    if (this.keycloakService.isLoggedIn()) {
      return this.getUserToken().pipe(
        mergeMap((token) => {
          if (token) {
            request = request.clone({
              setHeaders: {
                Authorization: `Bearer ${token}`
              }
            });
          }
          return next.handle(request);
        }));
    }
    return next.handle(request);
  }

  getUserToken() {
    const tokenPromise: Promise<string> = this.keycloakService.getToken();
    const tokenObservable: Observable<string> = from(tokenPromise);
    return tokenObservable;
  }
}

Il faut maintenant déclarer notre intercepteur au niveau de notre application dans la fichier app.module.ts.

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';

import { AppComponent } from './app.component';
import { DashboardComponent } from './components/dashboard/dashboard.component';
import { DataService } from './services/data/data.service';
import { KeycloakService } from './services/keycloak/keycloak.service';
import { KeycloakInterceptorService } from './services/keycloak/keycloak.interceptor.service';


@NgModule({
  declarations: [
    AppComponent,
    DashboardComponent
  ],
  imports: [
    HttpClientModule,
    BrowserModule
  ],
  providers: [
    DataService,
    {
      provide: HTTP_INTERCEPTORS,
      useClass: KeycloakInterceptorService,
      multi: true
    },
    KeycloakService
  ],
  bootstrap: [AppComponent]
})
export class AppModule { }

NB: ne pas oublier d’importer le KeycloakService, KeycloakInterceptorService et HTTP_INTERCEPTORS.

Voilà c’est tout pour l’intercepteur Http. Si nous effectuons des requêtes vers l’API, on voit bien l’ajout du nouveau paramètre dans les ‘Headers‘ des requêtes.

Chargement de la configuration Keycloak au lancement de l’application

Pour pouvoir utiliser keycloak, il faut charger la configuration au lancement de l’application Angular. Pour cela, on va charger la configuration directement dans le fichier main.ts avant même de “bootstraper” l’application :

KeycloakService.init()
  .then(() => platformBrowserDynamic().bootstrapModule(AppModule))
  .catch(e => window.location.reload());

Vérification du fonctionnement “In App” de Keycloak

Il nous reste plus qu’a tester notre implémentation Keycloak. Revenons sur notre composant Dashboard.

Nous allons modifier le template de notre dashboard : app/components/dashboard/dashboard.component.html

<div class="container">
  <div class="row">
    <div class="column">
      <nav class="navbar fixed-top navbar-dark bg-primary">
        <a class="navbar-brand" href="#">
          <img src="https://ineat-group.com/assets/images/poulpies/ineat-logo-full.svg" height="30" class="d-inline-block align-top" alt="">
          <span  *ngIf="keycloakAuth.authenticated">Bonjour {{ displayUserInfo() }}</span>
        </a>
        <button class="btn btn-outline-warning my-2 my-sm-0" (click)="logout()">Logout</button>
        <button class="btn btn-outline-warning my-2 my-sm-0" (click)="login()">Login</button>
      </nav>
      <div class="jumbotron">
        <h1 class="display-4">Hello, world!</h1>
        <p class="lead">Securisez vos APIs Spring avec Keycloak : #4 – Utilisation du connecteur Keycloak.js avec Angular 4+.</p>
        <hr class="my-4">
        <p>Récupération de la donnée du endpoint non sécurisé</p>
        <div class="lead">
          <a class="btn btn-primary btn-lg" (click)="getUnsecuredData()" role="button">Data non sécurisé</a>
          <div class="card" *ngIf="unsecuredLoaded">
            <div class="card-body">
              <h5 class="card-title">Response</h5>
              <p class="card-text" *ngIf="!unsecuredError">{{ unsecureData }}</p>
              <p class="card-text" *ngIf="unsecuredError">Code retour: {{ unsecuredErrorResponse.status }} - message: {{ unsecuredErrorResponse.message }}</p>
            </div>
          </div>
        </div>
        <hr class="my-4">
        <p>Récupération de la donnée du endpoint user sécurisé</p>
        <div class="lead">
          <a class="btn btn-primary btn-lg" (click)="getUserData()" role="button">Data User</a>
          <div class="card" *ngIf="userLoaded">
            <div class="card-body">
              <h5 class="card-title">Response</h5>
              <p class="card-text" *ngIf="!userError">{{ userData }}</p>
              <p class="card-text" *ngIf="userError">Code retour: {{ userErrorResponse.status }} - message: {{ userErrorResponse.message }}</p>
            </div>
          </div>
        </div>
        <hr class="my-4">
        <p>Récupération de la donnée du endpoint admin sécurisé</p>
        <div class="lead">
          <a class="btn btn-primary btn-lg" (click)="getAdminData()" role="button">Data Admin</a>
          <div class="card" *ngIf="adminLoaded">
            <div class="card-body">
              <h5 class="card-title">Response</h5>
              <p class="card-text" *ngIf="!adminError">{{ adminData }}</p>
              <p class="card-text" *ngIf="adminError">Code retour: {{ adminErrorResponse.status }} - message: {{ adminErrorResponse.message }}</p>
            </div>
          </div>
        </div>
    </div>
  </div>
</div>

On y ajoute un peu de style:

On ajoute l’appel au cdn bootstrap dans le fichier index.html juste avant la fermeture de la balise HEAD

<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css" integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossorigin="anonymous">

app/components/dashboard/dashboard.component.css

.title {
    text-align: center;
    padding: 10px;
}

.card {
    margin-top: 10px;
}

On va ensuite ajouter les différentes fonctions du composant dashboard.
On déclare nos variable qui vont nous servir a animer un peu le template : app/components/dashboard/dashboard.component.ts

public unsecureData: string;
public userData: string;
public adminData: string;

public unsecuredError: boolean;
public userError: boolean;
public adminError: boolean;

public unsecuredLoaded: boolean;
public userLoaded: boolean;
public adminLoaded: boolean;

public unsecuredErrorResponse: any;
public userErrorResponse: any;
public adminErrorResponse: any;

public keycloakAuth: KeycloakInstance;

Dans le constructeur on déclare le dataService et le KeycloakService :

constructor(
    private data: DataService,
    private keycloak: KeycloakService
) { }

et dans la fonction ngOnInit on déclare la variable keycloakAuth :

ngOnInit() {
    this.keycloakAuth = this.keycloak.getKeycloakAuth();
  }

On va également avoir les fonctions de login, logout et displayUserInfo :

login() {
  this.keycloak.login();
}

logout() {
  this.keycloak.logout();
}

displayUserInfo() {
  return this.keycloak.getFullName();
}

private isJsonString = (str) => {
  console.log(JSON.parse(str));
  try {
    JSON.parse(str);
  } catch (e) {
    console.log('test');
    return false;
  }
  return true;
}

La fonction isJsonString est utilisée pour récupérer et interpréter les réponses en erreurs.

Il nous reste à implémenter 3 fonctions qui vont nous permettre d’effectuer des appels vers l’API suite à des actions utilisateurs (action sur les boutons) :

La fonction getUnsecuredData

getUnsecuredData() {
  this.data.getUnsecureData().subscribe(
    data => {
      this.unsecuredLoaded = true;
      this.unsecureData = data;
    },
    error => {
      this.unsecuredLoaded = true;
      this.unsecuredError = true;
      this.unsecuredErrorResponse = {
        status: error.status,
        message: error.message
      };
    }
  );
}

La fonction getUserData

getUserData() {
  this.data.getUserData().subscribe(
    data => {
      this.userLoaded = true;
      this.userData = data;
    },
    error => {
      this.userLoaded = true;
      this.userError = true;
      this.userErrorResponse = {
        status: error.status,
        message: error.message && this.isJsonString(error.error) ? JSON.parse(error.error).message : error.message
      };
    }
  );
}

Et la fonction getAdminData

getAdminData() {
  this.data.getAdminData().subscribe(
    data => {
      this.adminLoaded = true;
      this.adminData = data;
    },
    error => {
      console.log(error.error);
      this.adminLoaded = true;
      this.adminError = true;
      this.adminErrorResponse = {
        status: error.status,
        message: error.message && this.isJsonString(error.error) ? JSON.parse(error.error).message : error.message
      };
    }
  );
}

Le fichier final app/components/dashboard/dashboard.component.ts ressemble à :

import { Component, OnInit } from '@angular/core';
import { DataService } from '../../services/data/data.service';
import { KeycloakService } from '../../services/keycloak/keycloak.service';
import { KeycloakInstance } from 'keycloak-js';

@Component({
  selector: 'app-dashboard',
  templateUrl: './dashboard.component.html',
  styleUrls: ['./dashboard.component.css']
})
export class DashboardComponent implements OnInit {
  public unsecureData: string;
  public userData: string;
  public adminData: string;

  public unsecuredError: boolean;
  public userError: boolean;
  public adminError: boolean;

  public unsecuredLoaded: boolean;
  public userLoaded: boolean;
  public adminLoaded: boolean;

  public unsecuredErrorResponse: any;
  public userErrorResponse: any;
  public adminErrorResponse: any;

  public keycloakAuth: KeycloakInstance;

  constructor(
    private data: DataService,
    private keycloak: KeycloakService
  ) { }

  ngOnInit() {
    this.keycloakAuth = this.keycloak.getKeycloakAuth();
  }

  getUnsecuredData() {
    this.data.getUnsecureData().subscribe(
      data => {
        this.unsecuredLoaded = true;
        this.unsecureData = data;
      },
      error => {
        this.unsecuredLoaded = true;
        this.unsecuredError = true;
        this.unsecuredErrorResponse = {
          status: error.status,
          message: error.message
        };
      }
    );
  }

  getUserData() {
    this.data.getUserData().subscribe(
      data => {
        this.userLoaded = true;
        this.userData = data;
      },
      error => {
        this.userLoaded = true;
        this.userError = true;
        this.userErrorResponse = {
          status: error.status,
          message: error.message && this.isJsonString(error.error) ? JSON.parse(error.error).message : error.message
        };
      }
    );
  }

  getAdminData() {
    this.data.getAdminData().subscribe(
      data => {
        this.adminLoaded = true;
        this.adminData = data;
      },
      error => {
        console.log(error.error);
        this.adminLoaded = true;
        this.adminError = true;
        this.adminErrorResponse = {
          status: error.status,
          message: error.message && this.isJsonString(error.error) ? JSON.parse(error.error).message : error.message
        };
      }
    );
  }

  login() {
    this.keycloak.login();
  }

  logout() {
    this.keycloak.logout();
  }

  displayUserInfo() {
    return this.keycloak.getFullName();
  }

  private isJsonString = (str) => {
    console.log(JSON.parse(str));
    try {
      JSON.parse(str);
    } catch (e) {
      console.log('test');
      return false;
    }
    return true;
  }

}

NB: attention à ne pas oublier les imports des classes keycloakInstance, DataService, KeycloakService.

Voilà notre application Angular est prête à être lancée.

ng serve

Si tout se passe correctement on arrive sur la page suivante :

Il nous reste alors à tester l’accès aux différentes ressources de l’API.

Et voilà, c’est terminé pour cette intégration de Keycloak dans vos applications Angular.
N’hésitez pas à nous laisser vos commentaires.

Série d’articles Keycloak :