From 21c581bc61bd6bdd08baa71c5c13608ac075d3eb Mon Sep 17 00:00:00 2001 From: Dan Percic Date: Tue, 26 Jul 2022 18:40:41 +0300 Subject: [PATCH] add auth module --- src/lib/auth/auth.guard.ts | 31 +++++++++ src/lib/auth/auth.module.ts | 76 +++++++++++++++++++++++ src/lib/auth/base-user.service.ts | 8 +-- src/lib/auth/default-user.service.ts | 3 +- src/lib/auth/index.ts | 5 ++ src/lib/auth/role.guard.ts | 29 +++++++++ src/lib/auth/types/auth-module-options.ts | 4 +- src/lib/common-ui.module.ts | 27 ++++---- src/lib/utils/index.ts | 1 + src/lib/utils/module-with-options.ts | 14 +++++ src/lib/utils/types/common-ui-options.ts | 2 +- 11 files changed, 176 insertions(+), 24 deletions(-) create mode 100644 src/lib/auth/auth.guard.ts create mode 100644 src/lib/auth/auth.module.ts create mode 100644 src/lib/auth/role.guard.ts create mode 100644 src/lib/utils/module-with-options.ts diff --git a/src/lib/auth/auth.guard.ts b/src/lib/auth/auth.guard.ts new file mode 100644 index 0000000..17eb476 --- /dev/null +++ b/src/lib/auth/auth.guard.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, Router } from '@angular/router'; +import { KeycloakAuthGuard, KeycloakService } from 'keycloak-angular'; +import { BaseConfigService } from '../services'; +import { BaseUserService } from './base-user.service'; + +@Injectable() +export class AuthGuard extends KeycloakAuthGuard { + constructor( + protected readonly _router: Router, + protected readonly _keycloak: KeycloakService, + private readonly _configService: BaseConfigService, + private readonly _userService: BaseUserService, + ) { + super(_router, _keycloak); + } + + async isAccessAllowed(route: ActivatedRouteSnapshot): Promise { + if (!this.authenticated) { + const kcIdpHint = route.queryParamMap.get('kc_idp_hint'); + await this._keycloak.login({ + idpHint: kcIdpHint ?? this._configService.values.OAUTH_IDP_HINT, + redirectUri: window.location.href, + }); + return false; + } + + await this._userService.loadCurrentUser(); + return true; + } +} diff --git a/src/lib/auth/auth.module.ts b/src/lib/auth/auth.module.ts new file mode 100644 index 0000000..2bc42f9 --- /dev/null +++ b/src/lib/auth/auth.module.ts @@ -0,0 +1,76 @@ +import { APP_INITIALIZER, ModuleWithProviders, NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { HttpClientModule } from '@angular/common/http'; + +import { KeycloakAngularModule, KeycloakOptions, KeycloakService } from 'keycloak-angular'; +import { DefaultUserService } from './default-user.service'; +import { IIqserUser } from './types/user.response'; +import { BaseUserService } from './base-user.service'; +import { BASE_HREF, ModuleWithOptions } from '../utils'; +import { AuthModuleOptions } from './types/auth-module-options'; +import { IqserUser } from './user.model'; +import { RoleGuard } from './role.guard'; +import { AuthGuard } from './auth.guard'; +import { BaseConfigService } from '../services'; + +function getKeycloakOptions(baseUrl: string, configService: BaseConfigService): KeycloakOptions { + let url: string = configService.values.OAUTH_URL; + url = url.replace(/\/$/, ''); // remove trailing slash + const realm = url.substring(url.lastIndexOf('/') + 1, url.length); + url = url.substring(0, url.lastIndexOf('/realms')); + return { + config: { + url: url, + realm: realm, + clientId: configService.values.OAUTH_CLIENT_ID, + }, + initOptions: { + checkLoginIframe: false, + onLoad: 'check-sso', + silentCheckSsoRedirectUri: window.location.origin + baseUrl + '/assets/oauth/silent-refresh.html', + flow: 'standard', + }, + enableBearerInterceptor: true, + }; +} + +function configureAutomaticRedirectToLoginScreen(keyCloakService: KeycloakService) { + keyCloakService.getKeycloakInstance().onAuthRefreshError = () => { + void keyCloakService.logout().then(); + }; +} + +export function keycloakInitializer( + keycloakService: KeycloakService, + configService: BaseConfigService, + baseUrl: string, +): () => Promise { + const x = keycloakService.init(getKeycloakOptions(baseUrl, configService)); + return () => x.then(() => configureAutomaticRedirectToLoginScreen(keycloakService)); +} + +@NgModule({ + imports: [CommonModule, HttpClientModule, KeycloakAngularModule], + providers: [ + AuthGuard, + RoleGuard, + { + provide: APP_INITIALIZER, + useFactory: keycloakInitializer, + multi: true, + deps: [KeycloakService, BaseConfigService, BASE_HREF], + }, + ], +}) +export class AuthModule extends ModuleWithOptions { + static forRoot>( + options: AuthModuleOptions, + ): ModuleWithProviders { + const userService = this._getService(BaseUserService, DefaultUserService, options.existingUserService); + + return { + ngModule: AuthModule, + providers: [userService], + }; + } +} diff --git a/src/lib/auth/base-user.service.ts b/src/lib/auth/base-user.service.ts index f5acb54..4a3d806 100644 --- a/src/lib/auth/base-user.service.ts +++ b/src/lib/auth/base-user.service.ts @@ -16,10 +16,10 @@ import { IProfileUpdateRequest } from './types/profile-update.request'; import { KeycloakProfile } from 'keycloak-js'; @Injectable() -export abstract class BaseUserService extends EntitiesService< - Interface, - Class -> { +export abstract class BaseUserService< + Interface extends IIqserUser = IIqserUser, + Class extends IqserUser & Interface = IqserUser & Interface, +> extends EntitiesService { readonly currentUser$: Observable; protected abstract readonly _defaultModelPath: string; protected abstract readonly _entityClass: new (entityInterface: Interface | KeycloakProfile, ...args: unknown[]) => Class; diff --git a/src/lib/auth/default-user.service.ts b/src/lib/auth/default-user.service.ts index 83a4d8c..4b0025a 100644 --- a/src/lib/auth/default-user.service.ts +++ b/src/lib/auth/default-user.service.ts @@ -1,10 +1,9 @@ import { Injectable } from '@angular/core'; import { IqserUser } from './user.model'; -import { IIqserUser } from './types/user.response'; import { BaseUserService } from './base-user.service'; @Injectable() -export class DefaultUserService extends BaseUserService { +export class DefaultUserService extends BaseUserService { protected readonly _defaultModelPath = 'user'; protected readonly _entityClass = IqserUser; } diff --git a/src/lib/auth/index.ts b/src/lib/auth/index.ts index 5e9e86f..273ba7a 100644 --- a/src/lib/auth/index.ts +++ b/src/lib/auth/index.ts @@ -3,5 +3,10 @@ export * from './types/create-user.request'; export * from './types/reset-password.request'; export * from './types/my-profile-update.request'; export * from './types/profile-update.request'; +export * from './types/auth-module-options'; export * from './user.model'; export * from './base-user.service'; +export * from './default-user.service'; +export * from './auth.module'; +export * from './auth.guard'; +export * from './role.guard'; diff --git a/src/lib/auth/role.guard.ts b/src/lib/auth/role.guard.ts new file mode 100644 index 0000000..90ba6f3 --- /dev/null +++ b/src/lib/auth/role.guard.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, CanActivate, Router } from '@angular/router'; +import { LoadingService } from '../loading'; +import { BaseUserService } from './base-user.service'; + +@Injectable() +export class RoleGuard implements CanActivate { + constructor( + private readonly _router: Router, + private readonly _loadingService: LoadingService, + private readonly _userService: BaseUserService, + ) {} + + async canActivate(route: ActivatedRouteSnapshot): Promise { + const currentUser = this._userService.currentUser; + if (!currentUser || !currentUser.hasAnyRole) { + await this._router.navigate(['/auth-error']); + this._loadingService.stop(); + return false; + } + + const requiredRoles = route.data['requiredRoles'] as string[]; + if (requiredRoles) { + return currentUser.hasAny(requiredRoles); + } + + return true; + } +} diff --git a/src/lib/auth/types/auth-module-options.ts b/src/lib/auth/types/auth-module-options.ts index 4174072..60ae6e3 100644 --- a/src/lib/auth/types/auth-module-options.ts +++ b/src/lib/auth/types/auth-module-options.ts @@ -1,8 +1,8 @@ -import { Type } from '@angular/core'; import { BaseUserService } from '../base-user.service'; import { IIqserUser } from './user.response'; import { IqserUser } from '../user.model'; +import { Type } from '@angular/core'; export interface AuthModuleOptions = BaseUserService> { - existingUserService: Type; + existingUserService?: Type; } diff --git a/src/lib/common-ui.module.ts b/src/lib/common-ui.module.ts index adbbb00..d2c177f 100644 --- a/src/lib/common-ui.module.ts +++ b/src/lib/common-ui.module.ts @@ -1,4 +1,4 @@ -import { ModuleWithProviders, NgModule, Provider, Type } from '@angular/core'; +import { ModuleWithProviders, NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; import { MatIconModule } from '@angular/material/icon'; import { TranslateModule } from '@ngx-translate/core'; @@ -38,6 +38,7 @@ import { CommonUiOptions } from './utils/types/common-ui-options'; import { BaseUserPreferenceService } from './services'; import { BaseConfigService } from './services/base-config.service'; import { DefaultUserPreferenceService } from './services/default-user-preference.service'; +import { ModuleWithOptions } from './utils/module-with-options'; const matModules = [MatIconModule, MatProgressSpinnerModule, MatButtonModule, MatDialogModule, MatCheckboxModule, MatTooltipModule]; const modules = [ @@ -75,29 +76,25 @@ const pipes = [SortByPipe, HumanizePipe, CapitalizePipe]; imports: [CommonModule, ...matModules, ...modules, FormsModule, ReactiveFormsModule, KeycloakAngularModule, MatProgressBarModule], exports: [...components, ...pipes, ...modules], }) -export class CommonUiModule { +export class CommonUiModule extends ModuleWithOptions { static forRoot( - options?: CommonUiOptions, + options: CommonUiOptions, ): ModuleWithProviders { const userPreferenceService = this._getService( BaseUserPreferenceService, DefaultUserPreferenceService, - options?.existingUserPreferenceService, + options.existingUserPreferenceService, ); return { ngModule: CommonUiModule, - providers: [userPreferenceService], + providers: [ + userPreferenceService, + { + provide: BaseConfigService, + useFactory: options.configServiceFactory, + }, + ], }; } - - private static _getService(base: B, _default: Type, existing?: E): Provider { - if (existing) { - return { - provide: base, - useExisting: existing, - }; - } - return { provide: base, useClass: _default }; - } } diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts index 3d537e6..9cb13af 100644 --- a/src/lib/utils/index.ts +++ b/src/lib/utils/index.ts @@ -18,5 +18,6 @@ export * from './custom-route-reuse.strategy'; export * from './headers-configuration'; export * from './context.component'; export * from './tokens'; +export * from './module-with-options'; export * from './base-app-config'; export * from './types/common-ui-options'; diff --git a/src/lib/utils/module-with-options.ts b/src/lib/utils/module-with-options.ts new file mode 100644 index 0000000..1f800a8 --- /dev/null +++ b/src/lib/utils/module-with-options.ts @@ -0,0 +1,14 @@ +import { Provider, Type } from '@angular/core'; + +export class ModuleWithOptions { + protected static _getService(base: B, _default: Type, existing?: E): Provider { + if (existing) { + return { + provide: base, + useExisting: existing, + }; + } + + return { provide: base, useClass: _default }; + } +} diff --git a/src/lib/utils/types/common-ui-options.ts b/src/lib/utils/types/common-ui-options.ts index 98bcc6a..e781a65 100644 --- a/src/lib/utils/types/common-ui-options.ts +++ b/src/lib/utils/types/common-ui-options.ts @@ -2,6 +2,6 @@ import { BaseConfigService, BaseUserPreferenceService } from '../../services'; import { Type } from '@angular/core'; export interface CommonUiOptions { - existingUserPreferenceService: Type; + existingUserPreferenceService?: Type; configServiceFactory: () => C; }