[MNT-24614] Fixed APS basic auth login issue with ADF (#10364)

* [MNT-24614] Fixed APS basic auth login issue with ADF

* [MNT-24614] Addressed code review findings - Using includes api, and removed unneeded functions. Added missing return type to functions

* [MNT-24614] Added unit tests

* [MNT-24614] Added unit tests

* [MNT-24614] Fixed casing of unit test titles
This commit is contained in:
swapnil-verma-gl
2024-11-08 14:06:40 +05:30
committed by GitHub
parent 3ec3e732c0
commit 35c8093706
4 changed files with 180 additions and 89 deletions

View File

@@ -387,4 +387,36 @@ describe('AdfHttpClient', () => {
req.flush(null, { status: 200, statusText: 'Ok' }); req.flush(null, { status: 200, statusText: 'Ok' });
}); });
it('should set X-CSRF-TOKEN header if CSRF is enabled', () => {
const options: RequestOptions = {
path: '',
httpMethod: 'GET'
};
angularHttpClient.disableCsrf = false;
angularHttpClient.request('http://example.com', options, securityOptions, emitters).catch((error) => fail(error));
const req = controller.expectOne('http://example.com');
expect(req.request.headers.get('X-CSRF-TOKEN')).toBeDefined();
req.flush(null, { status: 200, statusText: 'Ok' });
});
it('should not set X-CSRF-TOKEN header if CSRF is disabled', () => {
const options: RequestOptions = {
path: '',
httpMethod: 'GET'
};
angularHttpClient.disableCsrf = true;
angularHttpClient.request('http://example.com', options, securityOptions, emitters).catch((error) => fail(error));
const req = controller.expectOne('http://example.com');
expect(req.request.headers.get('X-CSRF-TOKEN')).toBeNull();
req.flush(null, { status: 200, statusText: 'Ok' });
});
}); });

View File

@@ -17,15 +17,7 @@
import { SHOULD_ADD_AUTH_TOKEN } from '@alfresco/adf-core/auth'; import { SHOULD_ADD_AUTH_TOKEN } from '@alfresco/adf-core/auth';
import { Emitters as JsApiEmitters, HttpClient as JsApiHttpClient } from '@alfresco/js-api'; import { Emitters as JsApiEmitters, HttpClient as JsApiHttpClient } from '@alfresco/js-api';
import { import { HttpClient, HttpContext, HttpErrorResponse, HttpEvent, HttpHeaders, HttpParams, HttpResponse } from '@angular/common/http';
HttpClient,
HttpContext,
HttpErrorResponse,
HttpEvent,
HttpHeaders,
HttpParams,
HttpResponse
} from '@angular/common/http';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Observable, of, Subject, throwError } from 'rxjs'; import { Observable, of, Subject, throwError } from 'rxjs';
import { catchError, map, takeUntil } from 'rxjs/operators'; import { catchError, map, takeUntil } from 'rxjs/operators';
@@ -52,8 +44,7 @@ export interface Emitters {
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
}) })
export class AdfHttpClient implements ee.Emitter,JsApiHttpClient { export class AdfHttpClient implements ee.Emitter, JsApiHttpClient {
on: ee.EmitterMethod; on: ee.EmitterMethod;
off: ee.EmitterMethod; off: ee.EmitterMethod;
once: ee.EmitterMethod; once: ee.EmitterMethod;
@@ -107,47 +98,43 @@ export class AdfHttpClient implements ee.Emitter,JsApiHttpClient {
const params = getQueryParamsWithCustomEncoder(options.queryParams, new AlfrescoApiParamEncoder()); const params = getQueryParamsWithCustomEncoder(options.queryParams, new AlfrescoApiParamEncoder());
const responseType = AdfHttpClient.getResponseType(options); const responseType = AdfHttpClient.getResponseType(options);
const context = new HttpContext().set(SHOULD_ADD_AUTH_TOKEN, true); const context = new HttpContext().set(SHOULD_ADD_AUTH_TOKEN, true);
const security: SecurityOptions = {...this.defaultSecurityOptions, ...sc}; const security: SecurityOptions = { ...this.defaultSecurityOptions, ...sc };
const headers = this.getHeaders(options); const headers = this.getHeaders(options);
if (!emitters) { if (!emitters) {
emitters = this.getEventEmitters(); emitters = this.getEventEmitters();
} }
const request = this.httpClient.request( const request = this.httpClient.request(options.httpMethod, url, {
options.httpMethod, context,
url, ...(body && { body }),
{ ...(responseType && { responseType }),
context, ...security,
...(body && {body}), ...(params && { params }),
...(responseType && {responseType}), headers,
...security, observe: 'events',
...(params && {params}), reportProgress: true
headers, });
observe: 'events',
reportProgress: true
}
);
return this.requestWithLegacyEventEmitters<T>(request, emitters, options.returnType); return this.requestWithLegacyEventEmitters<T>(request, emitters, options.returnType);
} }
post<T = any>(url: string, options?: RequestOptions, sc?: SecurityOptions, emitters?: JsApiEmitters): Promise<T> { post<T = any>(url: string, options?: RequestOptions, sc?: SecurityOptions, emitters?: JsApiEmitters): Promise<T> {
return this.request<T>(url, {...options, httpMethod: 'POST'}, sc, emitters); return this.request<T>(url, { ...options, httpMethod: 'POST' }, sc, emitters);
} }
put<T = any>(url: string, options?: RequestOptions, sc?: SecurityOptions, emitters?: JsApiEmitters): Promise<T> { put<T = any>(url: string, options?: RequestOptions, sc?: SecurityOptions, emitters?: JsApiEmitters): Promise<T> {
return this.request<T>(url, {...options, httpMethod: 'PUT'}, sc, emitters); return this.request<T>(url, { ...options, httpMethod: 'PUT' }, sc, emitters);
} }
get<T = any>(url: string, options?: RequestOptions, sc?: SecurityOptions, emitters?: JsApiEmitters): Promise<T> { get<T = any>(url: string, options?: RequestOptions, sc?: SecurityOptions, emitters?: JsApiEmitters): Promise<T> {
return this.request<T>(url, {...options, httpMethod: 'GET'}, sc, emitters); return this.request<T>(url, { ...options, httpMethod: 'GET' }, sc, emitters);
} }
delete<T = void>(url: string, options?: RequestOptions, sc?: SecurityOptions, emitters?: JsApiEmitters): Promise<T> { delete<T = void>(url: string, options?: RequestOptions, sc?: SecurityOptions, emitters?: JsApiEmitters): Promise<T> {
return this.request<T>(url, {...options, httpMethod: 'DELETE'}, sc, emitters); return this.request<T>(url, { ...options, httpMethod: 'DELETE' }, sc, emitters);
} }
private addPromiseListeners<T = any>(promise: Promise<T>, eventEmitter: any) { private addPromiseListeners<T = any>(promise: Promise<T>, eventEmitter: any) {
const eventPromise = Object.assign(promise, { const eventPromise = Object.assign(promise, {
on() { on() {
// eslint-disable-next-line prefer-spread, prefer-rest-params // eslint-disable-next-line prefer-spread, prefer-rest-params
@@ -189,58 +176,59 @@ export class AdfHttpClient implements ee.Emitter,JsApiHttpClient {
} }
private requestWithLegacyEventEmitters<T = any>(request$: Observable<HttpEvent<T>>, emitters: JsApiEmitters, returnType: any): Promise<T> { private requestWithLegacyEventEmitters<T = any>(request$: Observable<HttpEvent<T>>, emitters: JsApiEmitters, returnType: any): Promise<T> {
const abort$ = new Subject<void>(); const abort$ = new Subject<void>();
const {eventEmitter, apiClientEmitter} = emitters; const { eventEmitter, apiClientEmitter } = emitters;
const promise = request$.pipe( const promise = request$
map((res) => { .pipe(
if (isHttpUploadProgressEvent(res)) { map((res) => {
const percent = Math.round((res.loaded / res.total) * 100); if (isHttpUploadProgressEvent(res)) {
eventEmitter.emit('progress', {loaded: res.loaded, total: res.total, percent}); const percent = Math.round((res.loaded / res.total) * 100);
} eventEmitter.emit('progress', { loaded: res.loaded, total: res.total, percent });
}
if (isHttpResponseEvent(res)) { if (isHttpResponseEvent(res)) {
eventEmitter.emit('success', res.body); eventEmitter.emit('success', res.body);
return AdfHttpClient.deserialize(res, returnType); return AdfHttpClient.deserialize(res, returnType);
} }
}), }),
catchError((err: HttpErrorResponse): Observable<AlfrescoApiResponseError> => { catchError((err: HttpErrorResponse): Observable<AlfrescoApiResponseError> => {
// since we can't always determinate ahead of time if the response is going to be xml or plain text response
// we need to handle false positive cases here.
// since we can't always determinate ahead of time if the response is going to be xml or plain text response if (err.status === 200) {
// we need to handle false positive cases here. eventEmitter.emit('success', err.error.text);
return of(err.error.text);
}
if (err.status === 200) { eventEmitter.emit('error', err);
eventEmitter.emit('success', err.error.text); apiClientEmitter.emit('error', { ...err, response: { req: err } });
return of(err.error.text);
}
eventEmitter.emit('error', err); if (err.status === 401) {
apiClientEmitter.emit('error', { ...err, response: { req: err } }); eventEmitter.emit('unauthorized');
apiClientEmitter.emit('unauthorized');
}
if (err.status === 401) { // for backwards compatibility we need to convert it to error class as the HttpErrorResponse only implements Error interface, not extending it,
eventEmitter.emit('unauthorized'); // and we need to be able to correctly pass instanceof Error conditions used inside repository
apiClientEmitter.emit('unauthorized'); // we also need to pass error as Stringify string as we are detecting statusCodes using JSON.parse(error.message) in some places
} const msg = typeof err.error === 'string' ? err.error : JSON.stringify(err.error);
// for backwards compatibility we need to convert it to error class as the HttpErrorResponse only implements Error interface, not extending it, // for backwards compatibility to handle cases in code where we try read response.error.response.body;
// and we need to be able to correctly pass instanceof Error conditions used inside repository
// we also need to pass error as Stringify string as we are detecting statusCodes using JSON.parse(error.message) in some places
const msg = typeof err.error === 'string' ? err.error : JSON.stringify(err.error);
// for backwards compatibility to handle cases in code where we try read response.error.response.body; const error = {
...err,
body: err.error
};
const error = { const alfrescoApiError = new AlfrescoApiResponseError(msg, err.status, error);
...err, body: err.error return throwError(alfrescoApiError);
}; }),
takeUntil(abort$)
)
.toPromise();
const alfrescoApiError = new AlfrescoApiResponseError(msg, err.status, error); (promise as any).abort = function () {
return throwError(alfrescoApiError);
}),
takeUntil(abort$)
).toPromise();
(promise as any).abort = function() {
eventEmitter.emit('abort'); eventEmitter.emit('abort');
abort$.next(); abort$.next();
abort$.complete(); abort$.complete();
@@ -261,7 +249,7 @@ export class AdfHttpClient implements ee.Emitter,JsApiHttpClient {
} }
if (isFormUrlEncoded) { if (isFormUrlEncoded) {
return new HttpParams({fromObject: removeNilValues(options.formParams)}); return new HttpParams({ fromObject: removeNilValues(options.formParams) });
} }
return body; return body;
@@ -273,8 +261,8 @@ export class AdfHttpClient implements ee.Emitter,JsApiHttpClient {
const optionsHeaders = { const optionsHeaders = {
...options.headerParams, ...options.headerParams,
...(accept && {Accept: accept}), ...(accept && { Accept: accept }),
...((contentType) && {'Content-Type': contentType}) ...(contentType && { 'Content-Type': contentType })
}; };
if (!this.disableCsrf) { if (!this.disableCsrf) {
@@ -319,7 +307,6 @@ export class AdfHttpClient implements ee.Emitter,JsApiHttpClient {
return Boolean(contentType?.match(/^application\/json(;.*)?$/i)); return Boolean(contentType?.match(/^application\/json(;.*)?$/i));
} }
private setCsrfToken(optionsHeaders: any) { private setCsrfToken(optionsHeaders: any) {
const token = this.createCSRFToken(); const token = this.createCSRFToken();
optionsHeaders['X-CSRF-TOKEN'] = token; optionsHeaders['X-CSRF-TOKEN'] = token;
@@ -332,12 +319,16 @@ export class AdfHttpClient implements ee.Emitter,JsApiHttpClient {
} }
private createCSRFToken(a?: any): string { private createCSRFToken(a?: any): string {
const randomValue = window.crypto.getRandomValues(new Uint32Array(1))[0]; const randomValue = AdfHttpClient.getSecureRandomValue();
return a ? (a ^ ((randomValue * 16) >> (a / 4))).toString(16) : ([1e16] + (1e16).toString()).replace(/[01]/g, this.createCSRFToken); return a ? (a ^ ((randomValue * 16) >> (a / 4))).toString(16) : ([1e16] + (1e16).toString()).replace(/[01]/g, this.createCSRFToken);
} }
private static getResponseType(options: RequestOptions): 'blob' | 'json' | 'text' { private static getSecureRandomValue(): number {
const max = Math.pow(2, 32);
return window.crypto.getRandomValues(new Uint32Array(1))[0] / max;
}
private static getResponseType(options: RequestOptions): 'blob' | 'json' | 'text' {
const isBlobType = options.returnType?.toString().toLowerCase() === 'blob' || options.responseType?.toString().toLowerCase() === 'blob'; const isBlobType = options.returnType?.toString().toLowerCase() === 'blob' || options.responseType?.toString().toLowerCase() === 'blob';
if (isBlobType) { if (isBlobType) {
@@ -359,7 +350,6 @@ export class AdfHttpClient implements ee.Emitter,JsApiHttpClient {
* @returns deserialized object * @returns deserialized object
*/ */
private static deserialize<T>(response: HttpResponse<T>, returnType?: Constructor<unknown> | 'blob'): any { private static deserialize<T>(response: HttpResponse<T>, returnType?: Constructor<unknown> | 'blob'): any {
if (response === null) { if (response === null) {
return null; return null;
} }
@@ -390,9 +380,7 @@ export class AdfHttpClient implements ee.Emitter,JsApiHttpClient {
return new returnType(body); return new returnType(body);
} }
private static deserializeBlobResponse(response: HttpResponse<Blob>) { private static deserializeBlobResponse(response: HttpResponse<Blob>) {
return new Blob([response.body], {type: response.headers.get('Content-Type')}); return new Blob([response.body], { type: response.headers.get('Content-Type') });
} }
} }

View File

@@ -0,0 +1,62 @@
/*!
* @license
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* 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 { BasicAlfrescoAuthService } from './basic-alfresco-auth.service';
import { AppConfigService, AppConfigValues } from '../../app-config/app-config.service';
import { ProcessAuth } from './process-auth';
import { ContentAuth } from './content-auth';
import { HttpClientTestingModule } from '@angular/common/http/testing';
describe('BasicAlfrescoAuthService', () => {
let basicAlfrescoAuthService: BasicAlfrescoAuthService;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [BasicAlfrescoAuthService]
});
basicAlfrescoAuthService = TestBed.inject(BasicAlfrescoAuthService);
spyOn(TestBed.inject(ProcessAuth), 'getToken').and.returnValue('Mock Process Auth ticket');
spyOn(TestBed.inject(ContentAuth), 'getToken').and.returnValue('Mock Content Auth ticket');
const appConfigSpy = spyOn(TestBed.inject(AppConfigService), 'get');
appConfigSpy.withArgs(AppConfigValues.CONTEXTROOTBPM).and.returnValue('activiti-app');
appConfigSpy.withArgs(AppConfigValues.CONTEXTROOTECM).and.returnValue('alfresco');
});
it('should return content services ticket when requestUrl contains ECM context root', () => {
const ticket = basicAlfrescoAuthService.getTicketEcmBase64('http://www.exmple.com/alfresco/mock-api-url');
const base64Segment = ticket.split('Basic ')[1];
expect(atob(base64Segment)).toEqual('Mock Content Auth ticket');
});
it('should return process services ticket when requestUrl contains ECM context root', () => {
const ticket = basicAlfrescoAuthService.getTicketEcmBase64('http://www.example.com/activiti-app/mock-api-url');
expect(ticket).toEqual('Basic Mock Process Auth ticket');
});
it('should return content services ticket when requestUrl contains both ECM and BPM context root, but ECM context root comes before', () => {
const ticket = basicAlfrescoAuthService.getTicketEcmBase64('http://www.exmple.com/alfresco/activiti-app/mock-api-url');
const base64Segment = ticket.split('Basic ')[1];
expect(atob(base64Segment)).toEqual('Mock Content Auth ticket');
});
it('should return process services ticket when requestUrl contains both ECM and BPM context root, but BPM context root comes before', () => {
const ticket = basicAlfrescoAuthService.getTicketEcmBase64('http://www.example.com/activiti-app/alfresco/mock-api-url');
expect(ticket).toEqual('Basic Mock Process Auth ticket');
});
});

View File

@@ -373,15 +373,24 @@ export class BasicAlfrescoAuthService extends BaseAuthenticationService {
getTicketEcmBase64(requestUrl: string): string | null { getTicketEcmBase64(requestUrl: string): string | null {
let ticket = null; let ticket = null;
const contextRootBpm = this.appConfig.get<string>(AppConfigValues.CONTEXTROOTBPM) || 'activiti-app'; const bpmRoot = `/${this.appConfig.get<string>(AppConfigValues.CONTEXTROOTBPM) || 'activiti-app'}/`;
const contextRoot = this.appConfig.get<string>(AppConfigValues.CONTEXTROOTECM) || 'alfresco'; const ecmRoot = `/${this.appConfig.get<string>(AppConfigValues.CONTEXTROOTECM) || 'alfresco'}/`;
if (contextRoot && requestUrl.indexOf(contextRoot) !== -1) { if (requestUrl?.includes(ecmRoot) && !requestUrl.includes(bpmRoot)) {
ticket = 'Basic ' + btoa(this.contentAuth.getToken()); ticket = this.getContentServicesTicket();
} else if (contextRootBpm && requestUrl.indexOf(contextRootBpm) !== -1) { } else if (requestUrl?.includes(bpmRoot) && !requestUrl.includes(ecmRoot)) {
ticket = 'Basic ' + this.processAuth.getToken(); ticket = this.getProcessServicesTicket();
} else if (requestUrl?.includes(ecmRoot) && requestUrl.includes(bpmRoot)) {
ticket = requestUrl.indexOf(ecmRoot) < requestUrl.indexOf(bpmRoot) ? this.getContentServicesTicket() : this.getProcessServicesTicket();
} }
return ticket; return ticket;
} }
private getProcessServicesTicket(): string {
return this.processAuth.getToken()?.startsWith('Basic ') ? this.processAuth.getToken() : 'Basic ' + this.processAuth.getToken();
}
private getContentServicesTicket(): string {
return 'Basic ' + btoa(this.contentAuth.getToken());
}
} }