From 71e28643033c26d413d661554db006f288c37cbb Mon Sep 17 00:00:00 2001 From: Mikolaj Serwicki Date: Wed, 21 Sep 2022 22:55:50 +0000 Subject: [PATCH] feat: add auth module --- ...-service-with-angular-based-http-client.ts | 38 +++ .../alfresco-api-v2-loader.service.ts | 62 ++++ .../src/lib/app-config/app-config.loader.ts | 25 ++ .../src/lib/app-config/app-config.service.ts | 3 +- .../src/lib/auth/auth-config.service.spec.ts | 40 +++ lib/core/src/lib/auth/auth-config.service.ts | 82 +++++ lib/core/src/lib/auth/auth-config.ts | 24 ++ lib/core/src/lib/auth/auth-routing.module.ts | 30 ++ lib/core/src/lib/auth/auth.module.ts | 74 ++++ lib/core/src/lib/auth/auth.service.ts | 60 ++++ lib/core/src/lib/auth/index.ts | 23 ++ lib/core/src/lib/auth/oidc-auth.guard.spec.ts | 36 ++ lib/core/src/lib/auth/oidc-auth.guard.ts | 52 +++ .../lib/auth/oidc-authentication.service.ts | 125 +++++++ .../src/lib/auth/redirect-auth.service.ts | 167 +++++++++ ...hentication-confirmation.component.spec.ts | 51 +++ .../authentication-confirmation.component.ts | 41 +++ lib/core/src/lib/core.module.ts | 39 ++- lib/core/src/lib/i18n/en.json | 1 + lib/core/src/lib/models/oauth-config.model.ts | 1 + .../src/lib/services/alfresco-api.service.ts | 28 +- .../lib/services/auth-bearer.interceptor.ts | 5 +- .../lib/services/authentication.service.ts | 323 +++--------------- .../src/lib/services/automation.service.ts | 14 +- .../services/base-authentication.service.ts | 285 ++++++++++++++++ .../lib/services/startup-service-factory.ts | 11 +- .../lib/settings/host-settings.component.html | 6 + .../lib/settings/host-settings.component.ts | 23 +- lib/core/src/public-api.ts | 1 + 29 files changed, 1344 insertions(+), 326 deletions(-) create mode 100644 lib/core/src/lib/api-factories/alfresco-api-service-with-angular-based-http-client.ts create mode 100644 lib/core/src/lib/api-factories/alfresco-api-v2-loader.service.ts create mode 100644 lib/core/src/lib/app-config/app-config.loader.ts create mode 100644 lib/core/src/lib/auth/auth-config.service.spec.ts create mode 100644 lib/core/src/lib/auth/auth-config.service.ts create mode 100644 lib/core/src/lib/auth/auth-config.ts create mode 100644 lib/core/src/lib/auth/auth-routing.module.ts create mode 100644 lib/core/src/lib/auth/auth.module.ts create mode 100644 lib/core/src/lib/auth/auth.service.ts create mode 100644 lib/core/src/lib/auth/index.ts create mode 100644 lib/core/src/lib/auth/oidc-auth.guard.spec.ts create mode 100644 lib/core/src/lib/auth/oidc-auth.guard.ts create mode 100644 lib/core/src/lib/auth/oidc-authentication.service.ts create mode 100644 lib/core/src/lib/auth/redirect-auth.service.ts create mode 100644 lib/core/src/lib/auth/view/authentication-confirmation/authentication-confirmation.component.spec.ts create mode 100644 lib/core/src/lib/auth/view/authentication-confirmation/authentication-confirmation.component.ts create mode 100644 lib/core/src/lib/services/base-authentication.service.ts diff --git a/lib/core/src/lib/api-factories/alfresco-api-service-with-angular-based-http-client.ts b/lib/core/src/lib/api-factories/alfresco-api-service-with-angular-based-http-client.ts new file mode 100644 index 0000000000..c7afee3cee --- /dev/null +++ b/lib/core/src/lib/api-factories/alfresco-api-service-with-angular-based-http-client.ts @@ -0,0 +1,38 @@ +/*! + * @license + * Copyright 2019 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AlfrescoApiHttpClient } from '@alfresco/adf-core/api'; +import { StorageService } from '../services/storage.service'; +import { AlfrescoApi, AlfrescoApiConfig } from '@alfresco/js-api'; +import { Injectable } from '@angular/core'; +import { AppConfigService } from '../app-config'; +import { AlfrescoApiService } from '../services/alfresco-api.service'; + +@Injectable() +export class AlfrescoApiServiceWithAngularBasedHttpClient extends AlfrescoApiService { + constructor( + storage: StorageService, + appConfig: AppConfigService, + private readonly alfrescoApiHttpClient: AlfrescoApiHttpClient + ) { + super(appConfig, storage); + } + + override createInstance(config: AlfrescoApiConfig) { + return new AlfrescoApi(config, this.alfrescoApiHttpClient); + } +} diff --git a/lib/core/src/lib/api-factories/alfresco-api-v2-loader.service.ts b/lib/core/src/lib/api-factories/alfresco-api-v2-loader.service.ts new file mode 100644 index 0000000000..89169f30b3 --- /dev/null +++ b/lib/core/src/lib/api-factories/alfresco-api-v2-loader.service.ts @@ -0,0 +1,62 @@ +/*! + * @license + * Copyright 2019 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AlfrescoApiConfig } from '@alfresco/js-api'; +import { Injectable } from '@angular/core'; +import { AppConfigService, AppConfigValues } from '../app-config/app-config.service'; +import { OauthConfigModel } from '../models/oauth-config.model'; +import { AlfrescoApiService } from '../services/alfresco-api.service'; + +export function createAlfrescoApiInstance(angularAlfrescoApiService: AlfrescoApiLoaderService) { + return () => angularAlfrescoApiService.init(); +} + +@Injectable({ + providedIn: 'root' +}) +export class AlfrescoApiLoaderService { + constructor(private readonly appConfig: AppConfigService, private readonly apiService: AlfrescoApiService) {} + + async init(): Promise { + await this.appConfig.load(); + return this.initAngularAlfrescoApi(); + } + + private initAngularAlfrescoApi() { + const oauth: OauthConfigModel = Object.assign({}, this.appConfig.get(AppConfigValues.OAUTHCONFIG, null)); + + if (oauth) { + oauth.redirectUri = window.location.origin + window.location.pathname; + oauth.redirectUriLogout = window.location.origin + window.location.pathname; + } + + const config = new AlfrescoApiConfig({ + provider: this.appConfig.get(AppConfigValues.PROVIDERS), + hostEcm: this.appConfig.get(AppConfigValues.ECMHOST), + hostBpm: this.appConfig.get(AppConfigValues.BPMHOST), + authType: this.appConfig.get(AppConfigValues.AUTHTYPE, 'BASIC'), + contextRootBpm: this.appConfig.get(AppConfigValues.CONTEXTROOTBPM), + contextRoot: this.appConfig.get(AppConfigValues.CONTEXTROOTECM), + disableCsrf: this.appConfig.get(AppConfigValues.DISABLECSRF), + withCredentials: this.appConfig.get(AppConfigValues.AUTH_WITH_CREDENTIALS, false), + domainPrefix: this.appConfig.get(AppConfigValues.STORAGE_PREFIX), + oauth2: oauth + }); + + this.apiService.load(config); + } +} diff --git a/lib/core/src/lib/app-config/app-config.loader.ts b/lib/core/src/lib/app-config/app-config.loader.ts new file mode 100644 index 0000000000..0c2d21a80e --- /dev/null +++ b/lib/core/src/lib/app-config/app-config.loader.ts @@ -0,0 +1,25 @@ +/*! + * @license + * Copyright 2019 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AppConfigService, AppConfigValues } from './app-config.service'; +import { StorageService } from '../services/storage.service'; + +export function loadAppConfig(appConfigService: AppConfigService, storageService: StorageService) { + return () => appConfigService.load().then(() => { + storageService.prefix = appConfigService.get(AppConfigValues.STORAGE_PREFIX, ''); + }); +} diff --git a/lib/core/src/lib/app-config/app-config.service.ts b/lib/core/src/lib/app-config/app-config.service.ts index ecd66373cc..7353c8ef2d 100644 --- a/lib/core/src/lib/app-config/app-config.service.ts +++ b/lib/core/src/lib/app-config/app-config.service.ts @@ -192,8 +192,9 @@ export class AppConfigService { this.http.get(configUrl).subscribe( (data: any) => { this.status = Status.LOADED; - this.onDataLoaded(data); resolve(this.config); + // WARNING: Risky change! Despite the fact that this would be the right order, this is a breaking change... + this.onDataLoaded(data); }, () => { resolve(this.config); diff --git a/lib/core/src/lib/auth/auth-config.service.spec.ts b/lib/core/src/lib/auth/auth-config.service.spec.ts new file mode 100644 index 0000000000..b4e190b3b5 --- /dev/null +++ b/lib/core/src/lib/auth/auth-config.service.spec.ts @@ -0,0 +1,40 @@ +/*! + * @license + * Copyright 2019 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { AUTH_MODULE_CONFIG } from './auth-config'; + +import { AuthConfigService } from './auth-config.service'; + +describe('AuthConfigService', () => { + let service: AuthConfigService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + { provide: AUTH_MODULE_CONFIG, useValue: { useHash: true } } + ] + }); + service = TestBed.inject(AuthConfigService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/lib/core/src/lib/auth/auth-config.service.ts b/lib/core/src/lib/auth/auth-config.service.ts new file mode 100644 index 0000000000..c8cf81ff26 --- /dev/null +++ b/lib/core/src/lib/auth/auth-config.service.ts @@ -0,0 +1,82 @@ +/*! + * @license + * Copyright 2019 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Inject, Injectable } from '@angular/core'; +import { AuthConfig } from 'angular-oauth2-oidc'; +import { take } from 'rxjs/operators'; +import { AppConfigService, AppConfigValues } from '../app-config/app-config.service'; +import { OauthConfigModel } from '../models/oauth-config.model'; +import { AuthModuleConfig, AUTH_MODULE_CONFIG } from './auth-config'; + +export function authConfigFactory(authConfigService: AuthConfigService): Promise { + return authConfigService.loadConfig(); +} + +@Injectable({ + providedIn: 'root' +}) +export class AuthConfigService { + constructor( + private appConfigService: AppConfigService, + @Inject(AUTH_MODULE_CONFIG) private readonly authModuleConfig: AuthModuleConfig + ) {} + + private _authConfig!: AuthConfig; + get authConfig(): AuthConfig { + return this._authConfig; + } + + loadConfig(): Promise { + return this.appConfigService.onLoad.pipe(take(1)).toPromise().then(this.loadAppConfig.bind(this)); + } + + loadAppConfig(): AuthConfig { + const oauth2: OauthConfigModel = Object.assign({}, this.appConfigService.get(AppConfigValues.OAUTHCONFIG, null)); + const origin = window.location.origin; + const redirectUri = this.getRedirectUri(); + + const authConfig: AuthConfig = { + oidc: oauth2.implicitFlow || oauth2.codeFlow || false, + issuer: oauth2.host, + redirectUri, + silentRefreshRedirectUri: `${origin}/silent-refresh.html`, + postLogoutRedirectUri: `${origin}/${oauth2.redirectUriLogout}`, + clientId: oauth2.clientId, + scope: oauth2.scope, + dummyClientSecret: oauth2.secret || '', + ...(oauth2.codeFlow && { responseType: 'code' }) + }; + + return authConfig; + } + + getRedirectUri(): string { + // required for this package as we handle the returned token on this view, with is provided by the AuthModule + const viewUrl = `view/authentication-confirmation`; + const useHash = this.authModuleConfig.useHash; + + const redirectUri = useHash + ? `${window.location.origin}/#/${viewUrl}` + : `${window.location.origin}/${viewUrl}`; + + const oauth2: OauthConfigModel = Object.assign({}, this.appConfigService.get(AppConfigValues.OAUTHCONFIG, null)); + + // handle issue from the OIDC library with hashStrategy and implicitFlow, with would append &state to the url with would lead to error + // `cannot match any routes`, and displaying the wildcard ** error page + return oauth2.implicitFlow && useHash ? `${redirectUri}/?` : redirectUri; + } +} diff --git a/lib/core/src/lib/auth/auth-config.ts b/lib/core/src/lib/auth/auth-config.ts new file mode 100644 index 0000000000..b58d70413c --- /dev/null +++ b/lib/core/src/lib/auth/auth-config.ts @@ -0,0 +1,24 @@ +/*! + * @license + * Copyright 2019 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { InjectionToken } from '@angular/core'; + +export interface AuthModuleConfig { + readonly useHash: boolean; +} + +export const AUTH_MODULE_CONFIG = new InjectionToken('AUTH_MODULE_CONFIG'); diff --git a/lib/core/src/lib/auth/auth-routing.module.ts b/lib/core/src/lib/auth/auth-routing.module.ts new file mode 100644 index 0000000000..bd16903c31 --- /dev/null +++ b/lib/core/src/lib/auth/auth-routing.module.ts @@ -0,0 +1,30 @@ +/*! + * @license + * Copyright 2019 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { AuthenticationConfirmationComponent } from './view/authentication-confirmation/authentication-confirmation.component'; + +const routes: Routes = [ + { path: 'view/authentication-confirmation', component: AuthenticationConfirmationComponent } +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class AuthRoutingModule {} diff --git a/lib/core/src/lib/auth/auth.module.ts b/lib/core/src/lib/auth/auth.module.ts new file mode 100644 index 0000000000..dce5e6b176 --- /dev/null +++ b/lib/core/src/lib/auth/auth.module.ts @@ -0,0 +1,74 @@ +/*! + * @license + * Copyright 2019 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { APP_INITIALIZER, ModuleWithProviders, NgModule } from '@angular/core'; +import { AuthConfig, AUTH_CONFIG, OAuthModule, OAuthService, OAuthStorage } from 'angular-oauth2-oidc'; +import { AlfrescoApiServiceWithAngularBasedHttpClient } from '../api-factories/alfresco-api-service-with-angular-based-http-client'; +import { AlfrescoApiService } from '../services/alfresco-api.service'; +import { AuthGuardBpm } from '../services/auth-guard-bpm.service'; +import { AuthGuardEcm } from '../services/auth-guard-ecm.service'; +import { AuthGuard } from '../services/auth-guard.service'; +import { AuthenticationService } from '../services/authentication.service'; +import { StorageService } from '../services/storage.service'; +import { AuthModuleConfig, AUTH_MODULE_CONFIG } from './auth-config'; +import { authConfigFactory, AuthConfigService } from './auth-config.service'; +import { AuthRoutingModule } from './auth-routing.module'; +import { AuthService } from './auth.service'; +import { OidcAuthGuard } from './oidc-auth.guard'; +import { OIDCAuthenticationService } from './oidc-authentication.service'; +import { RedirectAuthService } from './redirect-auth.service'; +import { AuthenticationConfirmationComponent } from './view/authentication-confirmation/authentication-confirmation.component'; + + +export function loginFactory(oAuthService: OAuthService, storage: OAuthStorage, config: AuthConfig) { + const service = new RedirectAuthService(oAuthService, storage, config); + return () => service.init(); +} + +@NgModule({ + declarations: [AuthenticationConfirmationComponent], + imports: [AuthRoutingModule, OAuthModule.forRoot()], + providers: [ + { provide: OAuthStorage, useExisting: StorageService }, + { provide: AuthGuard, useClass: OidcAuthGuard }, + { provide: AuthGuardEcm, useClass: OidcAuthGuard }, + { provide: AuthGuardBpm, useClass: OidcAuthGuard }, + { provide: AuthenticationService, useClass: OIDCAuthenticationService }, + { provide: AlfrescoApiService, useClass: AlfrescoApiServiceWithAngularBasedHttpClient }, + { + provide: AUTH_CONFIG, + useFactory: authConfigFactory, + deps: [AuthConfigService] + }, + RedirectAuthService, + { provide: AuthService, useExisting: RedirectAuthService }, + { + provide: APP_INITIALIZER, + useFactory: loginFactory, + deps: [OAuthService, OAuthStorage, AUTH_CONFIG], + multi: true + } + ] +}) +export class AuthModule { + static forRoot(config: AuthModuleConfig = { useHash: false }): ModuleWithProviders { + return { + ngModule: AuthModule, + providers: [{ provide: AUTH_MODULE_CONFIG, useValue: config }] + }; + } +} diff --git a/lib/core/src/lib/auth/auth.service.ts b/lib/core/src/lib/auth/auth.service.ts new file mode 100644 index 0000000000..258cbab1df --- /dev/null +++ b/lib/core/src/lib/auth/auth.service.ts @@ -0,0 +1,60 @@ +/*! + * @license + * Copyright 2019 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TokenResponse } from 'angular-oauth2-oidc'; +import { Observable } from 'rxjs'; + +/** + * Provide authentication/authorization through OAuth2/OIDC protocol. + */ +export abstract class AuthService { + /** Subscribe to whether the user has valid Id/Access tokens. */ + abstract authenticated$: Observable; + + /** Get whether the user has valid Id/Access tokens. */ + abstract authenticated: boolean; + + /** Subscribe to errors reaching the IdP. */ + abstract idpUnreachable$: Observable; + + /** Get user profile, if authenticated. */ + abstract getUserProfile(): Promise; + + /** + * Initiate the IdP login flow. + */ + abstract login(currentUrl?: string): Promise | void; + + abstract baseAuthLogin(username: string, password: string): Observable ; + + /** + * Disconnect from IdP. + * + * @returns Promise may be returned depending on implementation + */ + abstract logout(): Promise | void; + + /** + * Complete the login flow. + * + * In browsers, checks URL for auth and stored state. Call this once the application returns from IdP. + * + * @returns Promise, resolve with stored state, reject if unable to reach IdP + */ + abstract loginCallback(): Promise; + abstract updateIDPConfiguration(...args: any[]): void; +} diff --git a/lib/core/src/lib/auth/index.ts b/lib/core/src/lib/auth/index.ts new file mode 100644 index 0000000000..fbc734587d --- /dev/null +++ b/lib/core/src/lib/auth/index.ts @@ -0,0 +1,23 @@ +/*! + * @license + * Copyright 2019 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './auth-routing.module'; +export * from './auth.module'; +export * from './auth.service'; +export * from './oidc-auth.guard'; +export * from './redirect-auth.service'; +export * from './view/authentication-confirmation/authentication-confirmation.component'; diff --git a/lib/core/src/lib/auth/oidc-auth.guard.spec.ts b/lib/core/src/lib/auth/oidc-auth.guard.spec.ts new file mode 100644 index 0000000000..4321ac6d50 --- /dev/null +++ b/lib/core/src/lib/auth/oidc-auth.guard.spec.ts @@ -0,0 +1,36 @@ +/*! + * @license + * Copyright 2019 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TestBed } from '@angular/core/testing'; +import { MockProvider } from 'ng-mocks'; +import { AuthService } from './auth.service'; +import { OidcAuthGuard } from './oidc-auth.guard'; + +describe('OidcAuthGuard', () => { + let guard: OidcAuthGuard; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [OidcAuthGuard, MockProvider(AuthService)] + }); + guard = TestBed.inject(OidcAuthGuard); + }); + + it('should be created', () => { + expect(guard).toBeTruthy(); + }); +}); diff --git a/lib/core/src/lib/auth/oidc-auth.guard.ts b/lib/core/src/lib/auth/oidc-auth.guard.ts new file mode 100644 index 0000000000..914a051aaa --- /dev/null +++ b/lib/core/src/lib/auth/oidc-auth.guard.ts @@ -0,0 +1,52 @@ +/*! + * @license + * Copyright 2019 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, CanActivate, RouterStateSnapshot, UrlTree } from '@angular/router'; +import { Observable } from 'rxjs'; +import { AuthService } from './auth.service'; + +@Injectable() +export class OidcAuthGuard implements CanActivate { + constructor(private auth: AuthService) {} + + canActivate( + _route: ActivatedRouteSnapshot, + state: RouterStateSnapshot + ): Observable | Promise | boolean | UrlTree { + return this._isAuthenticated(state); + } + + canActivateChild(_route: ActivatedRouteSnapshot, state: RouterStateSnapshot) { + return this._isAuthenticated(state); + } + + private _isAuthenticated(state: RouterStateSnapshot) { + if (this.auth.authenticated) { + return true; + } + + const loginResult = this.auth.login(state.url); + + if (loginResult instanceof Promise) { + return loginResult.then(() => true).catch(() => false); + } + + return false; + } + +} diff --git a/lib/core/src/lib/auth/oidc-authentication.service.ts b/lib/core/src/lib/auth/oidc-authentication.service.ts new file mode 100644 index 0000000000..1ae92aa8d5 --- /dev/null +++ b/lib/core/src/lib/auth/oidc-authentication.service.ts @@ -0,0 +1,125 @@ +/*! + * @license + * Copyright 2019 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Injectable } from '@angular/core'; +import { OAuthService, OAuthStorage } from 'angular-oauth2-oidc'; +import { EMPTY, Observable } from 'rxjs'; +import { catchError, map } from 'rxjs/operators'; +import { AppConfigService, AppConfigValues } from '../app-config/app-config.service'; +import { OauthConfigModel } from '../models/oauth-config.model'; +import { AlfrescoApiService } from '../services/alfresco-api.service'; +import { BaseAuthenticationService } from '../services/base-authentication.service'; +import { CookieService } from '../services/cookie.service'; +import { JwtHelperService } from '../services/jwt-helper.service'; +import { LogService } from '../services/log.service'; +import { AuthConfigService } from './auth-config.service'; +import { AuthService } from './auth.service'; + +@Injectable({ + providedIn: 'root' +}) +export class OIDCAuthenticationService extends BaseAuthenticationService { + readonly supportCodeFlow = true; + + constructor( + alfrescoApi: AlfrescoApiService, + appConfig: AppConfigService, + cookie: CookieService, + logService: LogService, + private authStorage: OAuthStorage, + private oauthService: OAuthService, + private readonly authConfig: AuthConfigService, + private readonly auth: AuthService + ) { + super(alfrescoApi, appConfig, cookie, logService); + } + + isEcmLoggedIn(): boolean { + return this.isLoggedIn(); + } + + isBpmLoggedIn(): boolean { + return this.isLoggedIn(); + } + + isLoggedIn(): boolean { + return this.oauthService.hasValidAccessToken() && this.oauthService.hasValidIdToken(); + } + + isLoggedInWith(_provider?: string): boolean { + return this.isLoggedIn(); + } + + isOauth(): boolean { + return this.appConfig.get(AppConfigValues.AUTHTYPE) === 'OAUTH'; + } + + isImplicitFlow() { + const oauth2: OauthConfigModel = Object.assign({}, this.appConfig.get(AppConfigValues.OAUTHCONFIG, null)); + return !!oauth2?.implicitFlow; + } + + isAuthCodeFlow() { + const oauth2: OauthConfigModel = Object.assign({}, this.appConfig.get(AppConfigValues.OAUTHCONFIG, null)); + return !!oauth2?.codeFlow; + } + + login(username: string, password: string, rememberMe: boolean = false): Observable<{ type: string; ticket: any }> { + return this.auth.baseAuthLogin(username, password).pipe( + map((response) => { + this.saveRememberMeCookie(rememberMe); + this.onLogin.next(response); + return { + type: this.appConfig.get(AppConfigValues.PROVIDERS), + ticket: response + }; + }), + catchError((err) => this.handleError(err)) + ); + } + + ssoImplicitLogin() { + this.oauthService.initLoginFlow(); + } + + ssoCodeFlowLogin() { + this.oauthService.initCodeFlow(); + } + + isRememberMeSet(): boolean { + return true; + } + + logout() { + this.oauthService.logOut(); + return EMPTY; + } + + getToken(): string { + return this.authStorage.getItem(JwtHelperService.USER_ACCESS_TOKEN); + } + + reset(): void { + const config = this.authConfig.loadAppConfig(); + this.auth.updateIDPConfiguration(config); + const oauth2: OauthConfigModel = Object.assign({}, this.appConfig.get(AppConfigValues.OAUTHCONFIG, null)); + + if (config.oidc && oauth2.silentLogin) { + this.auth.login(); + } + } +} diff --git a/lib/core/src/lib/auth/redirect-auth.service.ts b/lib/core/src/lib/auth/redirect-auth.service.ts new file mode 100644 index 0000000000..b1162a8e94 --- /dev/null +++ b/lib/core/src/lib/auth/redirect-auth.service.ts @@ -0,0 +1,167 @@ +/*! + * @license + * Copyright 2019 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Inject, Injectable } from '@angular/core'; +import { AuthConfig, AUTH_CONFIG, OAuthErrorEvent, OAuthService, OAuthStorage, TokenResponse } from 'angular-oauth2-oidc'; +import { JwksValidationHandler } from 'angular-oauth2-oidc-jwks'; +import { from, Observable } from 'rxjs'; +import { distinctUntilChanged, filter, map, shareReplay, startWith } from 'rxjs/operators'; +import { AuthService } from './auth.service'; + +const isPromise = (value: T | Promise): value is Promise => value && typeof (value as Promise).then === 'function'; + +@Injectable() +export class RedirectAuthService extends AuthService { + private _loadDiscoveryDocumentPromise = Promise.resolve(false); + + /** Subscribe to whether the user has valid Id/Access tokens. */ + authenticated$!: Observable; + + /** Subscribe to errors reaching the IdP. */ + idpUnreachable$!: Observable; + + /** Get whether the user has valid Id/Access tokens. */ + get authenticated(): boolean { + return this.oauthService.hasValidIdToken() && this.oauthService.hasValidAccessToken(); + } + + private authConfig!: AuthConfig | Promise; + + constructor( + private oauthService: OAuthService, + private _oauthStorage: OAuthStorage, + @Inject(AUTH_CONFIG) authConfig: AuthConfig + ) { + super(); + this.authConfig = authConfig; + } + + init() { + this.oauthService.clearHashAfterLogin = true; + + this.authenticated$ = this.oauthService.events.pipe( + startWith(undefined), + map(() => this.authenticated), + distinctUntilChanged(), + shareReplay(1) + ); + + this.idpUnreachable$ = this.oauthService.events.pipe( + filter((event): event is OAuthErrorEvent => event.type === 'discovery_document_load_error'), + map((event) => event.reason as Error) + ); + + if (isPromise(this.authConfig)) { + return this.authConfig.then((config) => this.configureAuth(config)); + } + + return this.configureAuth(this.authConfig); + + } + + logout() { + this.oauthService.logOut(); + } + + async getUserProfile(): Promise { + await this.ensureDiscoveryDocument(); + const userProfile = await this.oauthService.loadUserProfile(); + return (userProfile as any).info; + } + + ensureDiscoveryDocument(): Promise { + this._loadDiscoveryDocumentPromise = this._loadDiscoveryDocumentPromise + .catch(() => false) + .then((loaded) => { + if (!loaded) { + return this.oauthService.loadDiscoveryDocument().then(() => true); + } + return true; + }); + return this._loadDiscoveryDocumentPromise; + } + + + login(currentUrl?: string): void { + let stateKey: string | undefined; + + if (currentUrl) { + stateKey = `auth_state_${Math.random()}${Date.now()}`; + this._oauthStorage.setItem(stateKey, JSON.stringify(currentUrl || {})); + } + + // initLoginFlow will initialize the login flow in either code or implicit depending on the configuration + this.ensureDiscoveryDocument().then(() => void this.oauthService.initLoginFlow(stateKey)); + } + + baseAuthLogin(username: string, password: string): Observable { + this.oauthService.useHttpBasicAuth = true; + + return from(this.oauthService.fetchTokenUsingPasswordFlow(username, password)).pipe( + map((response) => { + const props = new Map(); + props.set('id_token', response.id_token); + // for backward compatibility we need to set the response in our storage + this.oauthService['storeAccessTokenResponse'](response.access_token, response.refresh_token, response.expires_in, response.scope, props); + return response; + }) + ); + } + + async loginCallback(): Promise { + return this.ensureDiscoveryDocument() + .then(() => this.oauthService.tryLogin({ preventClearHashAfterLogin: false })) + .then(() => this._getRedirectUrl()); + } + + private _getRedirectUrl() { + const DEFAULT_REDIRECT = '/'; + const stateKey = this.oauthService.state; + + if (stateKey) { + const stateStringified = this._oauthStorage.getItem(stateKey); + if (stateStringified) { + // cleanup state from storage + this._oauthStorage.removeItem(stateKey); + return JSON.parse(stateStringified); + } + } + + return DEFAULT_REDIRECT; + } + + private configureAuth(config: AuthConfig) { + this.oauthService.configure(config); + this.oauthService.tokenValidationHandler = new JwksValidationHandler(); + + if (config.sessionChecksEnabled) { + this.oauthService.events.pipe(filter((event) => event.type === 'session_terminated')).subscribe(() => { + this.oauthService.logOut(); + }); + } + + return this.ensureDiscoveryDocument().then(() => + void this.oauthService.setupAutomaticSilentRefresh() + ).catch(() => { + // catch error to prevent the app from crashing when trying to access unprotected routes + }); + } + + updateIDPConfiguration(config: AuthConfig) { + this.oauthService.configure(config); + } +} diff --git a/lib/core/src/lib/auth/view/authentication-confirmation/authentication-confirmation.component.spec.ts b/lib/core/src/lib/auth/view/authentication-confirmation/authentication-confirmation.component.spec.ts new file mode 100644 index 0000000000..c49b65d798 --- /dev/null +++ b/lib/core/src/lib/auth/view/authentication-confirmation/authentication-confirmation.component.spec.ts @@ -0,0 +1,51 @@ +/*! + * @license + * Copyright 2019 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { MockProvider } from 'ng-mocks'; +import { AuthService } from '../../auth.service'; +import { AuthenticationConfirmationComponent } from './authentication-confirmation.component'; + +describe('AuthenticationConfirmationComponent', () => { + let component: AuthenticationConfirmationComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [AuthenticationConfirmationComponent], + providers: [ + MockProvider(AuthService, { + loginCallback() { + return Promise.resolve(undefined); + } + }) + ], + imports: [RouterTestingModule] + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(AuthenticationConfirmationComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/lib/core/src/lib/auth/view/authentication-confirmation/authentication-confirmation.component.ts b/lib/core/src/lib/auth/view/authentication-confirmation/authentication-confirmation.component.ts new file mode 100644 index 0000000000..a054aa800e --- /dev/null +++ b/lib/core/src/lib/auth/view/authentication-confirmation/authentication-confirmation.component.ts @@ -0,0 +1,41 @@ +/*! + * @license + * Copyright 2019 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { Router } from '@angular/router'; +import { from, of } from 'rxjs'; +import { catchError, first, map } from 'rxjs/operators'; +import { AuthService } from '../../auth.service'; + +const ROUTE_DEFAULT = '/'; + +@Component({ + template: '', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class AuthenticationConfirmationComponent { + constructor(private auth: AuthService, private _router: Router) { + const routeStored$ = from(this.auth.loginCallback()).pipe( + map((route) => route || ROUTE_DEFAULT), + catchError(() => of(ROUTE_DEFAULT)) + ); + + routeStored$.pipe(first()).subscribe((route) => { + this._router.navigateByUrl(route, { replaceUrl: true }); + }); + } +} diff --git a/lib/core/src/lib/core.module.ts b/lib/core/src/lib/core.module.ts index 02535a3751..33acb8f468 100644 --- a/lib/core/src/lib/core.module.ts +++ b/lib/core/src/lib/core.module.ts @@ -50,7 +50,6 @@ import { PipeModule } from './pipes/pipe.module'; import { AlfrescoApiService } from './services/alfresco-api.service'; import { TranslationService } from './services/translation.service'; -import { startupServiceFactory } from './services/startup-service-factory'; import { SortingPickerModule } from './sorting-picker/sorting-picker.module'; import { IconModule } from './icon/icon.module'; import { TranslateLoaderService } from './services/translate-loader.service'; @@ -66,6 +65,17 @@ import { LegacyApiClientModule } from './api-factories/legacy-api-client.module' import { RichTextEditorModule } from './rich-text-editor/rich-text-editor.module'; import { HttpClientModule, HttpClientXsrfModule, HTTP_INTERCEPTORS } from '@angular/common/http'; import { AuthenticationService } from './services/authentication.service'; +import { loadAppConfig } from './app-config/app-config.loader'; +import { AppConfigService } from './app-config/app-config.service'; +import { StorageService } from './services/storage.service'; +import { AlfrescoApiLoaderService, createAlfrescoApiInstance } from './api-factories/alfresco-api-v2-loader.service'; +import { AlfrescoApiServiceWithAngularBasedHttpClient } from './api-factories/alfresco-api-service-with-angular-based-http-client'; + +interface Config { + readonly useAngularBasedHttpClientInAlfrescoJs: boolean; +} + +const defaultConfig: Config = { useAngularBasedHttpClientInAlfrescoJs: false }; @NgModule({ imports: [ @@ -149,7 +159,7 @@ import { AuthenticationService } from './services/authentication.service'; ] }) export class CoreModule { - static forRoot(): ModuleWithProviders { + static forRoot(config: Config = defaultConfig): ModuleWithProviders { return { ngModule: CoreModule, providers: [ @@ -158,11 +168,8 @@ export class CoreModule { { provide: TranslateLoader, useClass: TranslateLoaderService }, { provide: APP_INITIALIZER, - useFactory: startupServiceFactory, - deps: [ - AlfrescoApiService - ], - multi: true + useFactory: loadAppConfig, + deps: [ AppConfigService, StorageService ], multi: true }, { provide: APP_INITIALIZER, @@ -176,8 +183,24 @@ export class CoreModule { deps: [VersionCompatibilityService], multi: true }, + { + provide: APP_INITIALIZER, + useFactory: createAlfrescoApiInstance, + deps: [ AlfrescoApiLoaderService ], + multi: true + }, + { + provide: APP_INITIALIZER, + useFactory: createAlfrescoApiInstance, + deps: [ AlfrescoApiLoaderService ], + multi: true + }, { provide: HTTP_INTERCEPTORS, useClass: AuthenticationInterceptor, multi: true }, - { provide: Authentication, useClass: AuthenticationService } + { provide: Authentication, useClass: AuthenticationService }, + ...(config.useAngularBasedHttpClientInAlfrescoJs + ? [{ provide: AlfrescoApiService, useClass: AlfrescoApiServiceWithAngularBasedHttpClient }] + : [] + ) ] }; } diff --git a/lib/core/src/lib/i18n/en.json b/lib/core/src/lib/i18n/en.json index 947bee586c..714b5bc09a 100644 --- a/lib/core/src/lib/i18n/en.json +++ b/lib/core/src/lib/i18n/en.json @@ -172,6 +172,7 @@ "BASIC": "Basic Authentication", "SSO": "SSO", "IMPLICIT-FLOW": "Implicit Flow", + "CODE-FLOW": "Code Flow", "PROVIDER": "Provider", "REQUIRED": "This field is required", "CS_URL_ERROR": "Content Services address doesn't match the URL format", diff --git a/lib/core/src/lib/models/oauth-config.model.ts b/lib/core/src/lib/models/oauth-config.model.ts index 505d80e9b4..be4cc6a1c9 100644 --- a/lib/core/src/lib/models/oauth-config.model.ts +++ b/lib/core/src/lib/models/oauth-config.model.ts @@ -20,6 +20,7 @@ export interface OauthConfigModel { clientId: string; scope: string; implicitFlow: boolean; + codeFlow?: boolean; redirectUri: string; silentLogin?: boolean; secret?: string; diff --git a/lib/core/src/lib/services/alfresco-api.service.ts b/lib/core/src/lib/services/alfresco-api.service.ts index 4c02de5585..5f344de606 100644 --- a/lib/core/src/lib/services/alfresco-api.service.ts +++ b/lib/core/src/lib/services/alfresco-api.service.ts @@ -47,23 +47,13 @@ export class AlfrescoApiService { return this.alfrescoApi; } - constructor( - protected appConfig: AppConfigService, - protected storageService: StorageService) { - } + constructor(protected appConfig: AppConfigService, protected storageService: StorageService) {} - async load() { - try { - await this.appConfig.load(); - this.storageService.prefix = this.appConfig.get(AppConfigValues.STORAGE_PREFIX, ''); - this.getCurrentAppConfig(); + async load(config: AlfrescoApiConfig): Promise { + this.currentAppConfig = config; - if (this.currentAppConfig.authType === 'OAUTH') { - this.idpConfig = await this.appConfig.loadWellKnown(this.currentAppConfig.oauth2.host); + if (config.authType === 'OAUTH') { this.mapAlfrescoApiOpenIdConfig(); - } - } catch { - throw new Error('Something wrong happened when calling the app.config.json'); } this.initAlfrescoApiWithConfig(); @@ -73,7 +63,6 @@ export class AlfrescoApiService { async reset() { this.getCurrentAppConfig(); if (this.currentAppConfig.authType === 'OAUTH') { - this.idpConfig = await this.appConfig.loadWellKnown(this.currentAppConfig.oauth2.host); this.mapAlfrescoApiOpenIdConfig(); } this.initAlfrescoApiWithConfig(); @@ -88,7 +77,8 @@ export class AlfrescoApiService { return oauth; } - private mapAlfrescoApiOpenIdConfig() { + private async mapAlfrescoApiOpenIdConfig() { + this.idpConfig = await this.appConfig.loadWellKnown(this.currentAppConfig.oauth2.host); this.currentAppConfig.oauth2.tokenUrl = this.idpConfig.token_endpoint; this.currentAppConfig.oauth2.authorizationUrl = this.idpConfig.authorization_endpoint; this.currentAppConfig.oauth2.logoutUrl = this.idpConfig.end_session_endpoint; @@ -121,11 +111,15 @@ export class AlfrescoApiService { if (this.alfrescoApi && this.isDifferentConfig(this.lastConfig, this.currentAppConfig)) { this.alfrescoApi.setConfig(this.currentAppConfig); } else { - this.alfrescoApi = new AlfrescoApi(this.currentAppConfig); + this.alfrescoApi = this.createInstance(this.currentAppConfig); } this.lastConfig = this.currentAppConfig; } + createInstance(config: AlfrescoApiConfig): AlfrescoApi { + return (this.alfrescoApi = new AlfrescoApi(config)); + } + isDifferentConfig(lastConfig: AlfrescoApiConfig, newConfig: AlfrescoApiConfig) { return JSON.stringify(lastConfig) !== JSON.stringify(newConfig); } diff --git a/lib/core/src/lib/services/auth-bearer.interceptor.ts b/lib/core/src/lib/services/auth-bearer.interceptor.ts index 3a763cea9c..19020131e9 100644 --- a/lib/core/src/lib/services/auth-bearer.interceptor.ts +++ b/lib/core/src/lib/services/auth-bearer.interceptor.ts @@ -31,9 +31,8 @@ export class AuthBearerInterceptor implements HttpInterceptor { constructor(private injector: Injector, private authService: AuthenticationService) { } private loadExcludedUrlsRegex() { - const excludedUrls: string[] = this.authService.getBearerExcludedUrls(); - - this.excludedUrlsRegex = [...excludedUrls].map((urlPattern) => new RegExp(urlPattern, 'i')) || []; + const excludedUrls = this.authService.getBearerExcludedUrls(); + this.excludedUrlsRegex = excludedUrls.map((urlPattern) => new RegExp(urlPattern, 'i')) || []; } intercept(req: HttpRequest, next: HttpHandler): diff --git a/lib/core/src/lib/services/authentication.service.ts b/lib/core/src/lib/services/authentication.service.ts index a33cdb24e9..9ced9a0a40 100644 --- a/lib/core/src/lib/services/authentication.service.ts +++ b/lib/core/src/lib/services/authentication.service.ts @@ -15,59 +15,32 @@ * limitations under the License. */ -import { Authentication } from '@alfresco/adf-core/auth'; import { Injectable } from '@angular/core'; -import { Observable, from, throwError, Observer, ReplaySubject, forkJoin } from 'rxjs'; -import { AlfrescoApiService } from './alfresco-api.service'; -import { CookieService } from './cookie.service'; -import { LogService } from './log.service'; -import { RedirectionModel } from '../models/redirection.model'; +import { forkJoin, from, Observable } from 'rxjs'; +import { catchError, map, tap } from 'rxjs/operators'; import { AppConfigService, AppConfigValues } from '../app-config/app-config.service'; -import { PeopleApi, UserProfileApi, UserRepresentation } from '@alfresco/js-api'; -import { map, catchError, tap } from 'rxjs/operators'; -import { HttpHeaders } from '@angular/common/http'; +import { OauthConfigModel } from '../models/oauth-config.model'; +import { AlfrescoApiService } from './alfresco-api.service'; +import { BaseAuthenticationService } from './base-authentication.service'; +import { CookieService } from './cookie.service'; import { JwtHelperService } from './jwt-helper.service'; +import { LogService } from './log.service'; import { StorageService } from './storage.service'; -const REMEMBER_ME_COOKIE_KEY = 'ALFRESCO_REMEMBER_ME'; -const REMEMBER_ME_UNTIL = 1000 * 60 * 60 * 24 * 30; - @Injectable({ providedIn: 'root' }) -export class AuthenticationService extends Authentication { - private redirectUrl: RedirectionModel = null; - - private bearerExcludedUrls: string[] = ['auth/realms', 'resources/', 'assets/']; - /** - * Emits login event - */ - onLogin: ReplaySubject = new ReplaySubject(1); - - /** - * Emits logout event - */ - onLogout: ReplaySubject = new ReplaySubject(1); - - _peopleApi: PeopleApi; - get peopleApi(): PeopleApi { - this._peopleApi = this._peopleApi ?? new PeopleApi(this.alfrescoApi.getInstance()); - return this._peopleApi; - } - - _profileApi: UserProfileApi; - get profileApi(): UserProfileApi { - this._profileApi = this._profileApi ?? new UserProfileApi(this.alfrescoApi.getInstance()); - return this._profileApi; - } +export class AuthenticationService extends BaseAuthenticationService { + readonly supportCodeFlow = false; constructor( - private appConfig: AppConfigService, - private storageService: StorageService, - private alfrescoApi: AlfrescoApiService, - private cookie: CookieService, - private logService: LogService) { - super(); + alfrescoApi: AlfrescoApiService, + appConfig: AppConfigService, + cookie: CookieService, + logService: LogService, + private storageService: StorageService + ) { + super(alfrescoApi, appConfig, cookie, logService); this.alfrescoApi.alfrescoApiInitialized.subscribe(() => { this.alfrescoApi.getInstance().reply('logged-in', () => { this.onLogin.next(); @@ -114,15 +87,6 @@ export class AuthenticationService extends Authentication { } } - /** - * Does kerberos enabled? - * - * @returns True if enabled, false otherwise - */ - isKerberosEnabled(): boolean { - return this.appConfig.get(AppConfigValues.AUTH_WITH_CREDENTIALS, false); - } - /** * Does the provider support OAuth? * @@ -132,37 +96,6 @@ export class AuthenticationService extends Authentication { return this.alfrescoApi.getInstance().isOauthConfiguration(); } - isPublicUrl(): boolean { - return this.alfrescoApi.getInstance().isPublicUrl(); - } - - /** - * Does the provider support ECM? - * - * @returns True if supported, false otherwise - */ - isECMProvider(): boolean { - return this.alfrescoApi.getInstance().isEcmConfiguration(); - } - - /** - * Does the provider support BPM? - * - * @returns True if supported, false otherwise - */ - isBPMProvider(): boolean { - return this.alfrescoApi.getInstance().isBpmConfiguration(); - } - - /** - * Does the provider support both ECM and BPM? - * - * @returns True if both are supported, false otherwise - */ - isALLProvider(): boolean { - return this.alfrescoApi.getInstance().isEcmBpmConfiguration(); - } - /** * Logs the user in. * @@ -172,18 +105,17 @@ export class AuthenticationService extends Authentication { * @returns Object with auth type ("ECM", "BPM" or "ALL") and auth ticket */ login(username: string, password: string, rememberMe: boolean = false): Observable<{ type: string; ticket: any }> { - return from(this.alfrescoApi.getInstance().login(username, password)) - .pipe( - map((response: any) => { - this.saveRememberMeCookie(rememberMe); - this.onLogin.next(response); - return { - type: this.appConfig.get(AppConfigValues.PROVIDERS), - ticket: response - }; - }), - catchError((err) => this.handleError(err)) - ); + return from(this.alfrescoApi.getInstance().login(username, password)).pipe( + map((response: any) => { + this.saveRememberMeCookie(rememberMe); + this.onLogin.next(response); + return { + type: this.appConfig.get(AppConfigValues.PROVIDERS), + ticket: response + }; + }), + catchError((err) => this.handleError(err)) + ); } /** @@ -193,31 +125,6 @@ export class AuthenticationService extends Authentication { this.alfrescoApi.getInstance().implicitLogin(); } - /** - * Saves the "remember me" cookie as either a long-life cookie or a session cookie. - * - * @param rememberMe Enables a long-life cookie - */ - private saveRememberMeCookie(rememberMe: boolean): void { - let expiration = null; - - if (rememberMe) { - expiration = new Date(); - const time = expiration.getTime(); - const expireTime = time + REMEMBER_ME_UNTIL; - expiration.setTime(expireTime); - } - this.cookie.setItem(REMEMBER_ME_COOKIE_KEY, '1', expiration, null); - } - - /** - * Checks whether the "remember me" cookie was set or not. - * - * @returns True if set, false otherwise - */ - isRememberMeSet(): boolean { - return (this.cookie.getItem(REMEMBER_ME_COOKIE_KEY) !== null); - } /** * Logs the user out. @@ -225,14 +132,13 @@ export class AuthenticationService extends Authentication { * @returns Response event called when logout is complete */ logout() { - return from(this.callApiLogout()) - .pipe( - tap((response) => { - this.onLogout.next(response); - return response; - }), - catchError((err) => this.handleError(err)) - ); + return from(this.callApiLogout()).pipe( + tap((response) => { + this.onLogout.next(response); + return response; + }), + catchError((err) => this.handleError(err)) + ); } private callApiLogout(): Promise { @@ -242,37 +148,6 @@ export class AuthenticationService extends Authentication { return Promise.resolve(); } - /** - * Gets the ECM ticket stored in the Storage. - * - * @returns The ticket or `null` if none was found - */ - getTicketEcm(): string | null { - return this.alfrescoApi.getInstance()?.getTicketEcm(); - } - - /** - * Gets the BPM ticket stored in the Storage. - * - * @returns The ticket or `null` if none was found - */ - getTicketBpm(): string | null { - return this.alfrescoApi.getInstance()?.getTicketBpm(); - } - - /** - * Gets the BPM ticket from the Storage in Base 64 format. - * - * @returns The ticket or `null` if none was found - */ - getTicketEcmBase64(): string | null { - const ticket = this.alfrescoApi.getInstance()?.getTicketEcm(); - if (ticket) { - return 'Basic ' + btoa(ticket); - } - return null; - } - /** * Checks if the user is logged in on an ECM provider. * @@ -303,76 +178,13 @@ export class AuthenticationService extends Authentication { return false; } - /** - * Gets the ECM username. - * - * @returns The ECM username - */ - getEcmUsername(): string { - return this.alfrescoApi.getInstance().getEcmUsername(); + isImplicitFlow(): boolean { + const oauth2: OauthConfigModel = Object.assign({}, this.appConfig.get(AppConfigValues.OAUTHCONFIG, null)); + return !!oauth2?.implicitFlow; } - /** - * Gets the BPM username - * - * @returns The BPM username - */ - getBpmUsername(): string { - return this.alfrescoApi.getInstance().getBpmUsername(); - } - - /** Sets the URL to redirect to after login. - * - * @param url URL to redirect to - */ - setRedirect(url: RedirectionModel) { - this.redirectUrl = url; - } - - /** Gets the URL to redirect to after login. - * - * @returns The redirect URL - */ - getRedirect(): string { - const provider = this.appConfig.get(AppConfigValues.PROVIDERS); - return this.hasValidRedirection(provider) ? this.redirectUrl.url : null; - } - - /** - * Gets information about the user currently logged into APS. - * - * @returns User information - */ - getBpmLoggedUser(): Observable { - return from(this.profileApi.getProfile()); - } - - private hasValidRedirection(provider: string): boolean { - return this.redirectUrl && (this.redirectUrl.provider === provider || this.hasSelectedProviderAll(provider)); - } - - private hasSelectedProviderAll(provider: string): boolean { - return this.redirectUrl && (this.redirectUrl.provider === 'ALL' || provider === 'ALL'); - } - - /** - * Prints an error message in the console browser - * - * @param error Error message - * @returns Object representing the error message - */ - handleError(error: any): Observable { - this.logService.error('Error when logging in', error); - return throwError(error || 'Server error'); - } - - /** - * Gets the set of URLs that the token bearer is excluded from. - * - * @returns Array of URL strings - */ - getBearerExcludedUrls(): string[] { - return this.bearerExcludedUrls; + isAuthCodeFlow(): boolean { + return false; } /** @@ -384,60 +196,5 @@ export class AuthenticationService extends Authentication { return this.storageService.getItem(JwtHelperService.USER_ACCESS_TOKEN); } - /** - * Adds the auth token to an HTTP header using the 'bearer' scheme. - * - * @param headersArg Header that will receive the token - * @returns The new header with the token added - */ - addTokenToHeader(headersArg?: HttpHeaders): Observable { - return new Observable((observer: Observer) => { - let headers = headersArg; - if (!headers) { - headers = new HttpHeaders(); - } - try { - const header = this.getAuthHeaders(headers); - - observer.next(header); - observer.complete(); - } catch (error) { - observer.error(error); - } - }); - } - - private getAuthHeaders(header: HttpHeaders): HttpHeaders { - const authType = this.appConfig.get(AppConfigValues.AUTHTYPE, 'BASIC'); - - switch (authType) { - case 'OAUTH': - return this.addBearerToken(header); - case 'BASIC': - return this.addBasicAuth(header); - default: - return header; - } - } - - private addBearerToken(header: HttpHeaders): HttpHeaders { - const token: string = this.getToken(); - - if (!token) { - return header; - } - - return header.set('Authorization', 'bearer ' + token); - } - - private addBasicAuth(header: HttpHeaders): HttpHeaders { - const ticket: string = this.getTicketEcmBase64(); - - if (!ticket) { - return header; - } - - return header.set('Authorization', ticket); - } - + reset() {} } diff --git a/lib/core/src/lib/services/automation.service.ts b/lib/core/src/lib/services/automation.service.ts index 739f822c21..89645bc51c 100644 --- a/lib/core/src/lib/services/automation.service.ts +++ b/lib/core/src/lib/services/automation.service.ts @@ -21,6 +21,7 @@ import { AlfrescoApiService } from './alfresco-api.service'; import { StorageService } from './storage.service'; import { UserPreferencesService } from './user-preferences.service'; import { DemoForm } from '../mock/form/demo-form.mock'; +import { AuthenticationService } from './authentication.service'; @Injectable({ providedIn: 'root' @@ -29,11 +30,13 @@ export class CoreAutomationService { public forms = new DemoForm(); - constructor(private appConfigService: AppConfigService, - private alfrescoApiService: AlfrescoApiService, - private userPreferencesService: UserPreferencesService, - private storageService: StorageService) { - } + constructor( + private appConfigService: AppConfigService, + private alfrescoApiService: AlfrescoApiService, + private userPreferencesService: UserPreferencesService, + private storageService: StorageService, + private auth: AuthenticationService + ) {} setup() { const adfProxy = window['adf'] || {}; @@ -72,6 +75,7 @@ export class CoreAutomationService { adfProxy.apiReset = () => { this.alfrescoApiService.reset(); + this.auth.reset(); }; window['adf'] = adfProxy; diff --git a/lib/core/src/lib/services/base-authentication.service.ts b/lib/core/src/lib/services/base-authentication.service.ts new file mode 100644 index 0000000000..6c3c5d04e7 --- /dev/null +++ b/lib/core/src/lib/services/base-authentication.service.ts @@ -0,0 +1,285 @@ +/*! + * @license + * Copyright 2019 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { PeopleApi, UserProfileApi, UserRepresentation } from '@alfresco/js-api'; +import { HttpHeaders } from '@angular/common/http'; +import { from, Observable, Observer, ReplaySubject, throwError } from 'rxjs'; +import { AppConfigService, AppConfigValues } from '../app-config/app-config.service'; +import { RedirectionModel } from '../models/redirection.model'; +import { AlfrescoApiService } from './alfresco-api.service'; +import { CookieService } from './cookie.service'; +import { LogService } from './log.service'; + +const REMEMBER_ME_COOKIE_KEY = 'ALFRESCO_REMEMBER_ME'; +const REMEMBER_ME_UNTIL = 1000 * 60 * 60 * 24 * 30; + +export abstract class BaseAuthenticationService { + protected bearerExcludedUrls: readonly string[] = ['resources/', 'assets/', 'auth/realms', 'idp/']; + protected redirectUrl: RedirectionModel = null; + + onLogin = new ReplaySubject(1); + onLogout = new ReplaySubject(1); + + _peopleApi: PeopleApi; + get peopleApi(): PeopleApi { + this._peopleApi = this._peopleApi ?? new PeopleApi(this.alfrescoApi.getInstance()); + return this._peopleApi; + } + + _profileApi: UserProfileApi; + get profileApi(): UserProfileApi { + this._profileApi = this._profileApi ?? new UserProfileApi(this.alfrescoApi.getInstance()); + return this._profileApi; + } + + constructor( + protected alfrescoApi: AlfrescoApiService, + protected appConfig: AppConfigService, + protected cookie: CookieService, + private logService: LogService + ) {} + + abstract readonly supportCodeFlow: boolean; + abstract getToken(): string; + abstract isLoggedIn(): boolean; + abstract isLoggedInWith(provider: string): boolean; + abstract isOauth(): boolean; + abstract isImplicitFlow(): boolean; + abstract isAuthCodeFlow(): boolean; + abstract login(username: string, password: string, rememberMe?: boolean): Observable<{ type: string; ticket: any }>; + abstract ssoImplicitLogin(): void; + abstract logout(): Observable; + abstract isEcmLoggedIn(): boolean; + abstract isBpmLoggedIn(): boolean; + abstract reset(): void; + + getBearerExcludedUrls(): readonly string[] { + return this.bearerExcludedUrls; + } + + /** + * Adds the auth token to an HTTP header using the 'bearer' scheme. + * + * @param headersArg Header that will receive the token + * @returns The new header with the token added + */ + addTokenToHeader(headersArg?: HttpHeaders): Observable { + return new Observable((observer: Observer) => { + let headers = headersArg; + if (!headers) { + headers = new HttpHeaders(); + } + try { + const header = this.getAuthHeaders(headers); + + observer.next(header); + observer.complete(); + } catch (error) { + observer.error(error); + } + }); + } + + private getAuthHeaders(header: HttpHeaders): HttpHeaders { + const authType = this.appConfig.get(AppConfigValues.AUTHTYPE, 'BASIC'); + + switch (authType) { + case 'OAUTH': + return this.addBearerToken(header); + case 'BASIC': + return this.addBasicAuth(header); + default: + return header; + } + } + + private addBearerToken(header: HttpHeaders): HttpHeaders { + const token: string = this.getToken(); + + if (!token) { + return header; + } + + return header.set('Authorization', 'bearer ' + token); + } + + private addBasicAuth(header: HttpHeaders): HttpHeaders { + const ticket: string = this.getTicketEcmBase64(); + + if (!ticket) { + return header; + } + + return header.set('Authorization', ticket); + } + + /** + * Gets the ECM username. + * + * @returns The ECM username + */ + getEcmUsername(): string { + return this.alfrescoApi.getInstance().getEcmUsername(); + } + + /** + * Gets the BPM username + * + * @returns The BPM username + */ + getBpmUsername(): string { + return this.alfrescoApi.getInstance().getBpmUsername(); + } + + isPublicUrl(): boolean { + return this.alfrescoApi.getInstance().isPublicUrl(); + } + + /** + * Does the provider support ECM? + * + * @returns True if supported, false otherwise + */ + isECMProvider(): boolean { + return this.alfrescoApi.getInstance().isEcmConfiguration(); + } + + /** + * Does the provider support BPM? + * + * @returns True if supported, false otherwise + */ + isBPMProvider(): boolean { + return this.alfrescoApi.getInstance().isBpmConfiguration(); + } + + /** + * Does the provider support both ECM and BPM? + * + * @returns True if both are supported, false otherwise + */ + isALLProvider(): boolean { + return this.alfrescoApi.getInstance().isEcmBpmConfiguration(); + } + + /** + * Gets the ECM ticket stored in the Storage. + * + * @returns The ticket or `null` if none was found + */ + getTicketEcm(): string | null { + return this.alfrescoApi.getInstance()?.getTicketEcm(); + } + + /** + * Gets the BPM ticket stored in the Storage. + * + * @returns The ticket or `null` if none was found + */ + getTicketBpm(): string | null { + return this.alfrescoApi.getInstance()?.getTicketBpm(); + } + + /** + * Gets the BPM ticket from the Storage in Base 64 format. + * + * @returns The ticket or `null` if none was found + */ + getTicketEcmBase64(): string | null { + const ticket = this.alfrescoApi.getInstance()?.getTicketEcm(); + if (ticket) { + return 'Basic ' + btoa(ticket); + } + return null; + } + + /** + * Gets information about the user currently logged into APS. + * + * @returns User information + */ + getBpmLoggedUser(): Observable { + return from(this.profileApi.getProfile()); + } + + /** + * Prints an error message in the console browser + * + * @param error Error message + * @returns Object representing the error message + */ + handleError(error: any): Observable { + this.logService.error('Error when logging in', error); + return throwError(error || 'Server error'); + } + + /** + * Does kerberos enabled? + * + * @returns True if enabled, false otherwise + */ + isKerberosEnabled(): boolean { + return this.appConfig.get(AppConfigValues.AUTH_WITH_CREDENTIALS, false); + } + + /** + * Saves the "remember me" cookie as either a long-life cookie or a session cookie. + * + * @param rememberMe Enables a long-life cookie + */ + saveRememberMeCookie(rememberMe: boolean): void { + let expiration = null; + + if (rememberMe) { + expiration = new Date(); + const time = expiration.getTime(); + const expireTime = time + REMEMBER_ME_UNTIL; + expiration.setTime(expireTime); + } + this.cookie.setItem(REMEMBER_ME_COOKIE_KEY, '1', expiration, null); + } + + /** + * Checks whether the "remember me" cookie was set or not. + * + * @returns True if set, false otherwise + */ + isRememberMeSet(): boolean { + return this.cookie.getItem(REMEMBER_ME_COOKIE_KEY) !== null; + } + + setRedirect(url?: RedirectionModel) { + this.redirectUrl = url; + } + + /** Gets the URL to redirect to after login. + * + * @returns The redirect URL + */ + getRedirect(): string { + const provider = this.appConfig.get(AppConfigValues.PROVIDERS); + return this.hasValidRedirection(provider) ? this.redirectUrl.url : null; + } + + private hasValidRedirection(provider: string): boolean { + return this.redirectUrl && (this.redirectUrl.provider === provider || this.hasSelectedProviderAll(provider)); + } + + private hasSelectedProviderAll(provider: string): boolean { + return this.redirectUrl && (this.redirectUrl.provider === 'ALL' || provider === 'ALL'); + } +} diff --git a/lib/core/src/lib/services/startup-service-factory.ts b/lib/core/src/lib/services/startup-service-factory.ts index 45feb9d406..cbfdb09ba0 100644 --- a/lib/core/src/lib/services/startup-service-factory.ts +++ b/lib/core/src/lib/services/startup-service-factory.ts @@ -15,9 +15,12 @@ * limitations under the License. */ -import { AlfrescoApiService } from './alfresco-api.service'; +import { AppConfigService, AppConfigValues } from '../app-config/app-config.service'; +import { StorageService } from './storage.service'; -// eslint-disable-next-line prefer-arrow/prefer-arrow-functions -export function startupServiceFactory(alfrescoApiService: AlfrescoApiService) { - return () => alfrescoApiService.load(); +export function loadAppConfig(appConfigService: AppConfigService, storageService: StorageService) { + return () => + appConfigService.load().then(() => { + storageService.prefix = appConfigService.get(AppConfigValues.STORAGE_PREFIX, ''); + }); } diff --git a/lib/core/src/lib/settings/host-settings.component.html b/lib/core/src/lib/settings/host-settings.component.html index 17275eda5b..ccc22def0e 100644 --- a/lib/core/src/lib/settings/host-settings.component.html +++ b/lib/core/src/lib/settings/host-settings.component.html @@ -122,6 +122,12 @@ formControlName="implicitFlow"> + + + + + {{ 'CORE.HOST_SETTINGS.REDIRECT'| translate }} diff --git a/lib/core/src/lib/settings/host-settings.component.ts b/lib/core/src/lib/settings/host-settings.component.ts index b988c3334d..5a4f79c8f3 100644 --- a/lib/core/src/lib/settings/host-settings.component.ts +++ b/lib/core/src/lib/settings/host-settings.component.ts @@ -22,6 +22,7 @@ import { StorageService } from '../services/storage.service'; import { AlfrescoApiService } from '../services/alfresco-api.service'; import { OauthConfigModel } from '../models/oauth-config.model'; import { ENTER } from '@angular/cdk/keycodes'; +import { AuthenticationService } from '../services/authentication.service'; export const HOST_REGEX = '^(http|https):\/\/.*[^/]$'; @@ -60,11 +61,13 @@ export class HostSettingsComponent implements OnInit { @Output() success = new EventEmitter(); - constructor(private formBuilder: UntypedFormBuilder, - private storageService: StorageService, - private alfrescoApiService: AlfrescoApiService, - private appConfig: AppConfigService) { - } + constructor( + private formBuilder: UntypedFormBuilder, + private storageService: StorageService, + private alfrescoApiService: AlfrescoApiService, + private appConfig: AppConfigService, + private auth: AuthenticationService + ) {} ngOnInit() { if (this.providers.length === 1) { @@ -149,6 +152,7 @@ export class HostSettingsComponent implements OnInit { secret: oauth.secret, silentLogin: oauth.silentLogin, implicitFlow: oauth.implicitFlow, + codeFlow: oauth.codeFlow, publicUrls: [oauth.publicUrls] }); } @@ -188,6 +192,7 @@ export class HostSettingsComponent implements OnInit { this.storageService.setItem(AppConfigValues.AUTHTYPE, values.authType); this.alfrescoApiService.reset(); + this.auth.reset(); this.alfrescoApiService.getInstance().invalidateSession(); this.success.emit(true); } @@ -231,6 +236,10 @@ export class HostSettingsComponent implements OnInit { return this.form.get('authType').value === 'OAUTH'; } + get supportsCodeFlow(): boolean { + return this.auth.supportCodeFlow; + } + get providersControl(): UntypedFormControl { return this.form.get('providersControl') as UntypedFormControl; } @@ -267,6 +276,10 @@ export class HostSettingsComponent implements OnInit { return this.oauthConfig.get('implicitFlow') as UntypedFormControl; } + get codeFlow(): UntypedFormControl { + return this.oauthConfig.get('codeFlow') as UntypedFormControl; + } + get silentLogin(): UntypedFormControl { return this.oauthConfig.get('silentLogin') as UntypedFormControl; } diff --git a/lib/core/src/public-api.ts b/lib/core/src/public-api.ts index ba914a6e76..9e5b1bd268 100644 --- a/lib/core/src/public-api.ts +++ b/lib/core/src/public-api.ts @@ -55,3 +55,4 @@ export * from './lib/testing'; export * from './lib/material.module'; export * from './lib/core.module'; +export { AuthModule } from './lib/auth/auth.module';