commit
44072ac899
@ -0,0 +1,23 @@ |
|||||||
|
import { AuthConfig } from 'angular-oauth2-oidc'; |
||||||
|
import { environment } from '@env'; |
||||||
|
|
||||||
|
/** |
||||||
|
* Configuration du serveur Keycloak. |
||||||
|
*/ |
||||||
|
export const authConfig: AuthConfig = { |
||||||
|
issuer: environment.keycloakConfig.issuer, |
||||||
|
clientId: environment.keycloakConfig.clientId,
|
||||||
|
dummyClientSecret: environment.keycloakConfig.dummyClientSecret, |
||||||
|
responseType: environment.keycloakConfig.responseType, |
||||||
|
redirectUri: environment.keycloakConfig.redirectUri, |
||||||
|
silentRefreshRedirectUri: environment.keycloakConfig.silentRefreshRedirectUri, |
||||||
|
scope: environment.keycloakConfig.scope,
|
||||||
|
useSilentRefresh: environment.keycloakConfig.useSilentRefresh,
|
||||||
|
silentRefreshTimeout: environment.keycloakConfig.silentRefreshTimeout, |
||||||
|
timeoutFactor: environment.keycloakConfig.timeoutFactor, |
||||||
|
sessionChecksEnabled: environment.keycloakConfig.sessionChecksEnabled, |
||||||
|
showDebugInformation: environment.keycloakConfig.showDebugInformation, |
||||||
|
clearHashAfterLogin: environment.keycloakConfig.clearHashAfterLogin,
|
||||||
|
nonceStateSeparator : environment.keycloakConfig.nonceStateSeparator
|
||||||
|
}; |
||||||
|
|
@ -0,0 +1,12 @@ |
|||||||
|
import { OAuthModuleConfig } from 'angular-oauth2-oidc'; |
||||||
|
|
||||||
|
/** |
||||||
|
* Liste des urls pour lesquelles les appels doivent être interceptés. |
||||||
|
* Si la propriété sendAccessToken est défini sur true, l'access_token est envoyé dans le header. |
||||||
|
*/ |
||||||
|
export const authModuleConfig: OAuthModuleConfig = { |
||||||
|
resourceServer: { |
||||||
|
allowedUrls: ['https://localhost:44393/api'], |
||||||
|
sendAccessToken: true, |
||||||
|
} |
||||||
|
}; |
@ -0,0 +1,28 @@ |
|||||||
|
import { Injectable } from '@angular/core'; |
||||||
|
import { ActivatedRouteSnapshot, CanActivate, RouterStateSnapshot } from '@angular/router'; |
||||||
|
import { Observable } from 'rxjs'; |
||||||
|
import { tap } from 'rxjs/operators'; |
||||||
|
|
||||||
|
import { AuthService } from './auth.service'; |
||||||
|
|
||||||
|
/** |
||||||
|
* Guard permettant de gérer les autorisations au niveau des routes. |
||||||
|
*/ |
||||||
|
@Injectable() |
||||||
|
export class AuthGuard implements CanActivate { |
||||||
|
constructor(private authService: AuthService) { } |
||||||
|
|
||||||
|
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> { |
||||||
|
return this.authService.canActivateProtectedRoutes$.pipe(tap(isLoggin => this.login(isLoggin))); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Affiche la page de connexion si l'utilisateur n'est pas connecté. |
||||||
|
* @param isLoggin Booléen permettant de savoir si l'utilisateur est connecté ou non |
||||||
|
*/ |
||||||
|
private login(isLoggin: boolean) { |
||||||
|
if (!isLoggin) { |
||||||
|
this.authService.login(); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,61 @@ |
|||||||
|
import { HttpClientModule } from '@angular/common/http'; |
||||||
|
import { ModuleWithProviders, NgModule, Optional, SkipSelf, APP_INITIALIZER } from '@angular/core'; |
||||||
|
import { AuthConfig, OAuthModule, OAuthModuleConfig, OAuthStorage } from 'angular-oauth2-oidc'; |
||||||
|
import { authConfig } from './auth-config'; |
||||||
|
import { AuthGuard } from './auth.guard'; |
||||||
|
import { authModuleConfig } from './auth-module-config'; |
||||||
|
import { AuthService } from './auth.service'; |
||||||
|
|
||||||
|
/** |
||||||
|
* Nous avons besoin d'une usine de stockage car le localStorage
|
||||||
|
* n'est pas disponible au moment de la compilation de l'AOT. (Ahead-of-time) |
||||||
|
*/ |
||||||
|
export function storageFactory(): OAuthStorage { |
||||||
|
return localStorage; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Exécute la méthode qui permet d'afficher la page de connexion. |
||||||
|
* @param authService Service d'authentification |
||||||
|
*/ |
||||||
|
export function init_app(authService: AuthService) { |
||||||
|
return () => authService.runInitialLoginSequence(); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Module d'authentification. |
||||||
|
*/ |
||||||
|
@NgModule({ |
||||||
|
imports: [ |
||||||
|
HttpClientModule, |
||||||
|
OAuthModule.forRoot(), |
||||||
|
], |
||||||
|
providers: [ |
||||||
|
AuthService, |
||||||
|
AuthGuard, |
||||||
|
], |
||||||
|
}) |
||||||
|
export class AuthModule { |
||||||
|
static forRoot(): ModuleWithProviders<AuthModule> { |
||||||
|
return { |
||||||
|
ngModule: AuthModule, |
||||||
|
providers: [ |
||||||
|
{ provide: AuthConfig, useValue: authConfig }, |
||||||
|
{ provide: OAuthModuleConfig, useValue: authModuleConfig }, |
||||||
|
{ provide: OAuthStorage, useFactory: storageFactory }, |
||||||
|
{ |
||||||
|
provide: APP_INITIALIZER, |
||||||
|
useFactory: init_app, // Affiche la page de connexion au démarrage de l'application
|
||||||
|
deps: [ AuthService ], |
||||||
|
multi: true |
||||||
|
} |
||||||
|
] |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
constructor (@Optional() @SkipSelf() parentModule: AuthModule) { |
||||||
|
if (parentModule) { |
||||||
|
throw new Error("AuthModule est déjà chargé. Importez-le dans uniquement l'AppModule."); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,6 @@ |
|||||||
|
|
||||||
|
describe('AuthService', () => { |
||||||
|
it('should pass', () => { |
||||||
|
expect(true).toBeTruthy(); |
||||||
|
}); |
||||||
|
}); |
@ -0,0 +1,263 @@ |
|||||||
|
import { Injectable } from '@angular/core'; |
||||||
|
import { Router } from '@angular/router'; |
||||||
|
import { OAuthErrorEvent, OAuthService } from 'angular-oauth2-oidc'; |
||||||
|
import { BehaviorSubject, combineLatest, Observable, ReplaySubject } from 'rxjs'; |
||||||
|
import { filter, map } from 'rxjs/operators'; |
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' }) |
||||||
|
export class AuthService { |
||||||
|
|
||||||
|
private isAuthenticatedSubject$ = new BehaviorSubject<boolean>(false); |
||||||
|
private isDoneLoadingSubject$ = new ReplaySubject<boolean>(); |
||||||
|
|
||||||
|
/** |
||||||
|
* Observable permettant de savoir si l'utilisateur est authentifié. |
||||||
|
*/ |
||||||
|
public isAuthenticated$ = this.isAuthenticatedSubject$.asObservable(); |
||||||
|
|
||||||
|
/** |
||||||
|
* Observable permettant de savoir si le chargement de la connexion initiale est terminé. |
||||||
|
*/ |
||||||
|
public isDoneLoading$ = this.isDoneLoadingSubject$.asObservable(); |
||||||
|
|
||||||
|
/** |
||||||
|
* Retourne "true" si et seulement si tous les appels asynchrones de la connexion initiale
|
||||||
|
* sont terminés (ou comportent une erreur), et si l'utilisateur |
||||||
|
* a fini par être authentifié. |
||||||
|
* |
||||||
|
* En résumé, il combine: |
||||||
|
* |
||||||
|
* - Le dernier état connu indiquant si l'utilisateur est autorisé |
||||||
|
* - Si les appels ajax pour la connexion initiale ont tous été effectués |
||||||
|
*/ |
||||||
|
public canActivateProtectedRoutes$: Observable<boolean> = combineLatest([ |
||||||
|
this.isAuthenticated$, this.isDoneLoading$ ]).pipe(map(values => values.every(b => b))); |
||||||
|
|
||||||
|
/** |
||||||
|
* Navigue vers la page de connexion. |
||||||
|
*/ |
||||||
|
private navigateToLoginPage() { |
||||||
|
// Pour naviguer vers un composant
|
||||||
|
//this.router.navigateByUrl('/should-login');
|
||||||
|
|
||||||
|
// Pour naviguer vers la page de connexion du server Keycloak
|
||||||
|
this.login(); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Constructeur du service d'authentification. |
||||||
|
* @param oauthService Service d'authentification de la librairie angular-oauth2-oidc |
||||||
|
* @param router Service permettant de naviguer et de manipuler les urls |
||||||
|
*/ |
||||||
|
constructor(private oauthService: OAuthService, private router: Router) { |
||||||
|
|
||||||
|
/* |
||||||
|
// Utile pour le débogage:
|
||||||
|
this.oauthService.events.subscribe(event => { |
||||||
|
if (event instanceof OAuthErrorEvent) { |
||||||
|
console.error('OAuthErrorEvent Object:', event); |
||||||
|
} else { |
||||||
|
console.warn('OAuthEvent Object:', event); |
||||||
|
} |
||||||
|
}); |
||||||
|
*/ |
||||||
|
|
||||||
|
// Ajout un évènement afin de gérer l'acces_token dans le cas où l'application est ouverte dans deux onglets différents.
|
||||||
|
// TODO: Pour améliorer cette configuration. Voir: https://github.com/jeroenheijmans/sample-angular-oauth2-oidc-with-auth-guards/issues/2
|
||||||
|
window.addEventListener('storage', (event) => { |
||||||
|
// La propriété "key" à la valeur "null" si l'événement a été causé par la méthode ".clear()".
|
||||||
|
if (event.key !== 'access_token' && event.key !== null) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
console.warn("Changements remarqués dans l'access_token (très probablement depuis un autre onglet). Mise à jour de l'observable isAuthenticated.") ; |
||||||
|
this.isAuthenticatedSubject$.next(this.oauthService.hasValidAccessToken()); |
||||||
|
|
||||||
|
// Si l'acces_token n'est pas valide, on redirige l'utilisateur vers la page de connexion
|
||||||
|
if (!this.oauthService.hasValidAccessToken()) { |
||||||
|
this.navigateToLoginPage(); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
// Met à jour l'observable lorsque l'access_token est valide
|
||||||
|
this.oauthService.events |
||||||
|
.subscribe(_ => { |
||||||
|
this.isAuthenticatedSubject$.next(this.oauthService.hasValidAccessToken()); |
||||||
|
}); |
||||||
|
|
||||||
|
// Charge le profil de l'utilisateur lorsque le token est reçu
|
||||||
|
this.oauthService.events |
||||||
|
.pipe(filter(e => ['token_received'].includes(e.type))) |
||||||
|
.subscribe(e => this.oauthService.loadUserProfile()); |
||||||
|
|
||||||
|
// Redirige l'utilisateur vers la page de connexion lorsque la session est terminée ou lorsque qu'il y a une erreur
|
||||||
|
this.oauthService.events |
||||||
|
.pipe(filter(e => ['session_terminated', 'session_error'].includes(e.type))) |
||||||
|
.subscribe(e => this.navigateToLoginPage()); |
||||||
|
|
||||||
|
// Mise en place d'un rafraîchissement silencieux lorsque le jeton est sur le point d'expirer
|
||||||
|
this.oauthService.setupAutomaticSilentRefresh(); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Lance la séquence de connexion initiale. |
||||||
|
*/ |
||||||
|
public runInitialLoginSequence(): Promise<void> { |
||||||
|
if (location.hash) { |
||||||
|
console.log("Fragment de hachage rencontré, tracé sous forme de tableau...") ; |
||||||
|
console.table(location.hash.substr(1).split('&').map(kvp => kvp.split('='))); |
||||||
|
} |
||||||
|
|
||||||
|
// 0. CONFIGURATION DU CHARGEMENT:
|
||||||
|
// Il faut d'abord vérifier comment le serveur Keycloak est actuellement configuré:
|
||||||
|
return this.oauthService.loadDiscoveryDocument() |
||||||
|
|
||||||
|
// 1. HASH LOGIN:
|
||||||
|
// Essaye de se connecter via le fragment de hachage
|
||||||
|
// après avoir été redirigé depuis le serveur Keycloak depuis initImplicitFlow :
|
||||||
|
.then(() => this.oauthService.tryLogin()) |
||||||
|
|
||||||
|
.then(() => { |
||||||
|
if (this.oauthService.hasValidAccessToken()) { |
||||||
|
return Promise.resolve(); |
||||||
|
} |
||||||
|
|
||||||
|
// 2. SILENT LOGIN:
|
||||||
|
// Effectue un rafraîchissement silencieux pour le flux implicite.
|
||||||
|
// Utilise cette méthode pour obtenir des nouveaux tokens quand/avant que les tokens existants expirent.
|
||||||
|
return this.oauthService.silentRefresh() |
||||||
|
.then(() => Promise.resolve()) |
||||||
|
.catch(result => { |
||||||
|
// Sous-ensemble de situations de https://openid.net/specs/openid-connect-core-1_0.html#AuthError
|
||||||
|
// Seulement celles où il est raisonnablement certain que l'envoi de l'utilisateur au serveur Keycloak est utile.
|
||||||
|
const errorResponsesRequiringUserInteraction = [ |
||||||
|
'interaction_required', |
||||||
|
'login_required', |
||||||
|
'account_selection_required', |
||||||
|
'consent_required', |
||||||
|
]; |
||||||
|
|
||||||
|
if (result |
||||||
|
&& result.reason |
||||||
|
&& errorResponsesRequiringUserInteraction.indexOf(result.reason.error) >= 0) { |
||||||
|
|
||||||
|
// 3. DEMANDE DE CONNEXION:
|
||||||
|
// A ce stade, nous savons avec certitude que nous devons demander à
|
||||||
|
// l'utilisateur de se connecter, nous le redirigeons donc vers la page de connexion.
|
||||||
|
|
||||||
|
// Activez cette option pour TOUJOURS forcer un utilisateur à se connecter.
|
||||||
|
this.login(); |
||||||
|
|
||||||
|
// Afficher un mesage d'avertissement:
|
||||||
|
//console.warn("Une interaction de l'utilisateur est nécessaire pour se connecter, nous allons attendre que l'utilisateur se connecte manuellement.");
|
||||||
|
|
||||||
|
return Promise.resolve(); |
||||||
|
} |
||||||
|
|
||||||
|
return Promise.reject(result); |
||||||
|
}); |
||||||
|
}) |
||||||
|
|
||||||
|
.then(() => { |
||||||
|
// Met à jour l'observable isDoneLoadingSubject
|
||||||
|
this.isDoneLoadingSubject$.next(true); |
||||||
|
|
||||||
|
// Vérifie les valeurs "undefined" et "null" pour être sûr.
|
||||||
|
// Notre login actuel ne devrait jamais avoir cela, mais au cas
|
||||||
|
// où quelqu'un appellerait la méthode initImplicitFlow(undefined|null), cela pourrait arriver.
|
||||||
|
if (this.oauthService.state && this.oauthService.state !== 'undefined' && this.oauthService.state !== 'null') { |
||||||
|
let stateUrl = this.oauthService.state; |
||||||
|
if (stateUrl.startsWith('/') === false) { |
||||||
|
stateUrl = decodeURIComponent(stateUrl); |
||||||
|
} |
||||||
|
console.log(`Il y a eu l'état de ${this.oauthService.state}, donc nous vous envoyons à ${stateUrl}`) ; |
||||||
|
this.router.navigateByUrl(stateUrl); |
||||||
|
} |
||||||
|
}) |
||||||
|
.catch(() => this.isDoneLoadingSubject$.next(true)); // Met à jour l'observable isDoneLoadingSubject
|
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Affiche la page de connexion. |
||||||
|
* @param targetUrl Url de destination |
||||||
|
*/ |
||||||
|
public login(targetUrl?: string) { |
||||||
|
// Note : avant la version 9.1.0 de la librairie, il fallait
|
||||||
|
// passer le composant encodageURIC dans les arguments de la méthode.
|
||||||
|
this.oauthService.initLoginFlow(targetUrl || this.router.url); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Supprime tous les tokens et déconnecte l'utilisateur. |
||||||
|
*/ |
||||||
|
public logout() { this.oauthService.logOut(); } |
||||||
|
|
||||||
|
/** |
||||||
|
* Permet d'obtenir des nouveaux tokens quand/avant que les tokens existants expirent. |
||||||
|
*/ |
||||||
|
public refresh() { this.oauthService.silentRefresh(); } |
||||||
|
|
||||||
|
/** |
||||||
|
* Vérifie si l'access_token est valide. |
||||||
|
*/ |
||||||
|
public hasValidToken() { return this.oauthService.hasValidAccessToken(); } |
||||||
|
|
||||||
|
/** |
||||||
|
* Premier rôle dans la liste des rôles de l'utilisateur. |
||||||
|
*/ |
||||||
|
public get firstRole() { |
||||||
|
if(this.identityClaims != null) |
||||||
|
return this.oauthService.getIdentityClaims()['roles'][0];
|
||||||
|
else |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Liste des rôles de l'utilisateur. |
||||||
|
*/ |
||||||
|
public get roles() {
|
||||||
|
if(this.identityClaims != null) |
||||||
|
return this.oauthService.getIdentityClaims()['roles'];
|
||||||
|
else |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Nom et prénom de l'utilisateur. |
||||||
|
*/ |
||||||
|
public get userInfo() {
|
||||||
|
if(this.identityClaims != null) |
||||||
|
return this.oauthService.getIdentityClaims()['name'];
|
||||||
|
else |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
// Normalement, un service comme celui-ci ne les expose pas,
|
||||||
|
// mais pour le débogage, c'est logique.
|
||||||
|
|
||||||
|
/** |
||||||
|
* Access_token actuel. |
||||||
|
*/ |
||||||
|
public get accessToken() { return this.oauthService.getAccessToken(); } |
||||||
|
|
||||||
|
/** |
||||||
|
* Refresh_Token actuel. |
||||||
|
*/ |
||||||
|
public get refreshToken() { return this.oauthService.getRefreshToken(); } |
||||||
|
|
||||||
|
/** |
||||||
|
* Claims concernant l'utilisateur. |
||||||
|
*/ |
||||||
|
public get identityClaims() { return this.oauthService.getIdentityClaims(); } |
||||||
|
|
||||||
|
/** |
||||||
|
* Id_token actuel. |
||||||
|
*/ |
||||||
|
public get idToken() { return this.oauthService.getIdToken(); } |
||||||
|
|
||||||
|
/** |
||||||
|
* Url de déconnexion. |
||||||
|
*/ |
||||||
|
public get logoutUrl() { return this.oauthService.logoutUrl; } |
||||||
|
} |
@ -1,40 +0,0 @@ |
|||||||
import { Injectable } from '@angular/core'; |
|
||||||
import { CanActivate, Router, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; |
|
||||||
import { KeycloakService, KeycloakAuthGuard } from 'keycloak-angular'; |
|
||||||
|
|
||||||
|
|
||||||
/** |
|
||||||
* KeycloakGuard est la classe qui va gérer l'authentification et les droits d'accès en fonction des rôles Keycloak. |
|
||||||
*/ |
|
||||||
@Injectable() |
|
||||||
export class KeycloakGuard extends KeycloakAuthGuard { |
|
||||||
constructor(protected router: Router, protected keycloakAngular: KeycloakService) { |
|
||||||
super(router, keycloakAngular); |
|
||||||
} |
|
||||||
|
|
||||||
isAccessAllowed(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Promise<boolean> { |
|
||||||
return new Promise(async (resolve, reject) => { |
|
||||||
if (!this.authenticated) { |
|
||||||
this.keycloakAngular.login(); |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
const requiredRoles = route.data.roles; |
|
||||||
if (!requiredRoles || requiredRoles.length === 0) { |
|
||||||
return resolve(true); |
|
||||||
} else { |
|
||||||
if (!this.roles || this.roles.length === 0) { |
|
||||||
resolve(false); |
|
||||||
} |
|
||||||
let granted: boolean = false; |
|
||||||
for (const requiredRole of requiredRoles) { |
|
||||||
if (this.roles.indexOf(requiredRole) > -1) { |
|
||||||
granted = true; |
|
||||||
break; |
|
||||||
} |
|
||||||
} |
|
||||||
resolve(granted); |
|
||||||
} |
|
||||||
}); |
|
||||||
} |
|
||||||
} |
|
@ -0,0 +1,31 @@ |
|||||||
|
<html> |
||||||
|
|
||||||
|
<body> |
||||||
|
<script> |
||||||
|
// Based on: https://manfredsteyer.github.io/angular-oauth2-oidc/docs/additional-documentation/silent-refresh.html |
||||||
|
|
||||||
|
const checks = [/[\?|&|#]code=/, /[\?|&|#]error=/, /[\?|&|#]token=/, /[\?|&|#]id_token=/]; |
||||||
|
|
||||||
|
function isResponse(str) { |
||||||
|
let count = 0; |
||||||
|
|
||||||
|
if (!str) { |
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
for (let i = 0; i < checks.length; i++) { |
||||||
|
if (str.match(checks[i])) return true; |
||||||
|
} |
||||||
|
|
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
let message = isResponse(location.hash) ? location.hash : '#' + location.search; |
||||||
|
|
||||||
|
console.log("Rafraîchissement silencieux de l'iframe affichée dans l'application parente, message: ", message); |
||||||
|
|
||||||
|
(window.opener || window.parent).postMessage(message, location.origin); |
||||||
|
</script> |
||||||
|
</body> |
||||||
|
|
||||||
|
</html> |
Loading…
Reference in new issue