Commit 56c0f194 authored by Ryan Diehl's avatar Ryan Diehl

feat(security): cross cutting security features

exposed class interfaces for auth and token services
parent ab21d3d9
Pipeline #109470 passed with stages
in 5 minutes and 7 seconds
export { REQUIRE_AUTH_HEADER } from './lib/constants';
export { AlreadyLoggedInGuard } from './lib/already-logged-in.guard';
export { AuthInterceptor } from './lib/auth.interceptor';
export { AuthService } from './lib/auth.service';
export { HTTP_AUTHORIZATION_HEADER, REQUIRE_AUTH_HEADER } from './lib/constants';
export { provideAuthService, provideTokenService } from './lib/providers';
export { TokenService } from './lib/token.service';
export { UseridRequestTracingInterceptor } from './lib/userid-request-tracing-interceptor';
import { TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { Mock } from 'ts-mocks';
import { AlreadyLoggedInGuard } from './already-logged-in.guard';
import { AuthService } from './auth.service';
describe('AlreadyLoggedInGuard', () => {
let guard: AlreadyLoggedInGuard;
let router: Mock<Router>;
let authService: Mock<AuthService>;
let isLoggedIn: boolean;
let result: boolean;
let complete: boolean;
let loadResult: boolean;
let loadComplete: boolean;
const snapshot = {
data: {
targetRoute: ['test', 'route'],
queryParams: {
a: 'b'
}
}
};
beforeEach(() => {
result = complete = undefined;
  • Remove this useless assignment to variable "result". 📘 Remove this useless assignment to variable "complete". 📘

Please register or sign in to reply
loadResult = loadComplete = undefined;
  • Remove this useless assignment to variable "loadResult". 📘 Remove this useless assignment to variable "loadComplete". 📘

Please register or sign in to reply
isLoggedIn = undefined;
router = new Mock<Router>({
navigate: () => ({} as any)
});
authService = new Mock<AuthService>({
isLoggedIn: () => isLoggedIn
});
TestBed.configureTestingModule({
providers: [
{ provide: Router, useValue: router.Object },
{ provide: AuthService, useValue: authService.Object },
AlreadyLoggedInGuard
]
});
guard = TestBed.get(AlreadyLoggedInGuard);
});
describe('when not logged in', () => {
beforeEach(() => {
isLoggedIn = false;
});
it('should emit true and not redirect', () => {
expect(guard.canActivate(snapshot as any)).toBe(true);
expect(guard.canLoad({ data: { targetRoute: ['lazy'] } })).toBe(true);
expect(router.Object.navigate).not.toHaveBeenCalled();
});
});
describe('when logged in', () => {
beforeEach(() => {
isLoggedIn = true;
});
it('should emit false and redirect to dashboard', () => {
expect(guard.canActivate(snapshot as any)).toBe(false);
expect(guard.canLoad({ data: { targetRoute: ['lazy'] } })).toBe(false);
expect(router.Object.navigate).toHaveBeenCalledWith(['test', 'route'], {
queryParams: { a: 'b' }
});
expect(router.Object.navigate).toHaveBeenCalledWith(['lazy'], {
queryParams: {}
});
});
});
});
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivate, CanLoad, Route, Router } from '@angular/router';
import { Observable, of } from 'rxjs';
  • 🔽 Remove this unused import of 'Observable'. 📘 🔽 Remove this unused import of 'of'. 📘

Please register or sign in to reply
import { map, take, tap } from 'rxjs/operators';
  • 🔽 Remove this unused import of 'map'. 📘 🔽 Remove this unused import of 'take'. 📘 🔽 Remove this unused import of 'tap'. 📘

Please register or sign in to reply
import { AuthService } from './auth.service';
@Injectable({
providedIn: 'root'
})
export class AlreadyLoggedInGuard implements CanActivate, CanLoad {
constructor(private authService: AuthService, private router: Router) {}
public canActivate(route: ActivatedRouteSnapshot): boolean {
return this.isLoggedIn(route.data);
}
public canLoad(route: Route): boolean {
return this.isLoggedIn(route.data);
}
private isLoggedIn(routeData: any): boolean {
const loggedIn = !!this.authService.isLoggedIn();
if (loggedIn && routeData && routeData.targetRoute) {
this.router.navigate(routeData.targetRoute, {
queryParams: routeData.queryParams || {}
});
}
return !loggedIn;
}
}
import { HttpClient, HttpHeaders, HttpRequest, HTTP_INTERCEPTORS } from '@angular/common/http';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { Injectable } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Observable, Subject } from 'rxjs';
import { Mock } from 'ts-mocks';
import { AuthInterceptor } from './auth.interceptor';
import { REQUIRE_AUTH_HEADER } from './constants';
import { TokenService } from './token.service';
@Injectable()
class TestService {
constructor(private httpClient: HttpClient) {}
public getUnprotected(): Observable<boolean> {
return this.httpClient.get<boolean>('unprotected');
}
public getProtected(): Observable<boolean> {
return this.httpClient.get<boolean>('api', {
headers: new HttpHeaders().append(REQUIRE_AUTH_HEADER, 'true').append('Another', 'abc')
});
}
public getBasicAuth(): Observable<boolean> {
return this.httpClient.get<boolean>('api/1', {
headers: {
Authorization: 'Basic test',
[REQUIRE_AUTH_HEADER]: 'true'
}
});
}
}
describe('AuthInterceptor', () => {
let service: TestService;
let backend: HttpTestingController;
let tokenService: Mock<TokenService>;
let tokenCanAuth: boolean;
let acquireTokenResult$: Subject<string>;
beforeEach(() => {
tokenCanAuth = undefined;
acquireTokenResult$ = new Subject();
tokenService = new Mock<TokenService>({
requiresAuth: () => tokenCanAuth,
acquireToken: (req: HttpRequest<any>) => acquireTokenResult$.asObservable(),
onUnauthorized: () => {}
});
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [
{ provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true },
TestService,
{ provide: TokenService, useValue: tokenService.Object }
]
});
service = TestBed.get(TestService);
backend = TestBed.get(HttpTestingController);
});
afterEach(() => {
backend.verify();
});
describe('intercept', () => {
it('should not add an Authorization header when request url is unprotected', () => {
service.getUnprotected().subscribe();
const req = backend.expectOne('unprotected');
expect(req.request.headers.has('Authorization')).toBe(false);
expect(req.request.headers.has(REQUIRE_AUTH_HEADER)).toBe(false);
req.flush({});
expect(tokenService.Object.onUnauthorized).not.toHaveBeenCalled();
});
it('should not add an Authorization header when token service cannot handle the request', () => {
tokenCanAuth = false;
service.getProtected().subscribe();
const req = backend.expectOne('api');
expect(req.request.headers.has('Authorization')).toBe(false);
expect(req.request.headers.has(REQUIRE_AUTH_HEADER)).toBe(false);
req.flush({});
expect(tokenService.Object.onUnauthorized).not.toHaveBeenCalled();
});
it('should add Authorization header when token service can handle the request', () => {
tokenCanAuth = true;
service.getProtected().subscribe();
acquireTokenResult$.next('tok');
const req = backend.expectOne('api');
expect(req.request.headers.get('Authorization')).toEqual('Bearer tok');
expect(req.request.headers.has(REQUIRE_AUTH_HEADER)).toBe(false);
expect(req.request.headers.get('Another')).toBe('abc');
req.flush({});
expect(tokenService.Object.onUnauthorized).not.toHaveBeenCalled();
});
it('should not modify existing Authorization header when request already has an authorization header', () => {
tokenCanAuth = true;
service.getBasicAuth().subscribe();
const req = backend.expectOne('api/1');
expect(req.request.headers.get('Authorization')).toEqual('Basic test');
expect(req.request.headers.has(REQUIRE_AUTH_HEADER)).toBe(false);
req.flush({});
expect(tokenService.Object.onUnauthorized).not.toHaveBeenCalled();
});
it('should not make request if token service throws an error', () => {
tokenCanAuth = true;
let error;
service.getProtected().subscribe({
next: () => fail('Expected error to be thrown'),
error: e => (error = e)
});
acquireTokenResult$.error('token error');
backend.expectNone('api/1');
expect(error).toEqual('token error');
expect(tokenService.Object.onUnauthorized).not.toHaveBeenCalled();
});
it('should call onUnauthorized if server errors with 401', () => {
tokenCanAuth = true;
service.getProtected().subscribe();
acquireTokenResult$.next('tok');
const req = backend.expectOne('api');
req.flush('', { status: 401, statusText: 'Unauthorized' });
expect(tokenService.Object.onUnauthorized).toHaveBeenCalledWith(
jasmine.objectContaining({ status: 401, statusText: 'Unauthorized' }),
'tok'
);
});
});
});
import { HttpErrorResponse, HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable, throwError } from 'rxjs';
import { catchError, mergeMap } from 'rxjs/operators';
import { HTTP_AUTHORIZATION_HEADER, REQUIRE_AUTH_HEADER } from './constants';
import { TokenService } from './token.service';
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
constructor(private tokenService: TokenService) {}
public intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
// figure out if the request requires authentication.
const shouldAuth = this.requiresAuth(req);
// always remove the custom x-auth header (cases where token handler cannot handle a request)
const headers = req.headers.delete(REQUIRE_AUTH_HEADER);
const newReq = req.clone({ headers });
if (shouldAuth) {
return this.tokenService.acquireToken(newReq).pipe(
mergeMap(token =>
next
.handle(
newReq.clone({
setHeaders: {
[HTTP_AUTHORIZATION_HEADER]: `Bearer ${token}`
}
})
)
.pipe(
catchError(err => {
if (err instanceof HttpErrorResponse && err.status === 401 && this.tokenService.onUnauthorized) {
this.tokenService.onUnauthorized(err, token);
}
return throwError(err);
})
)
)
);
} else {
return next.handle(newReq);
}
}
private requiresAuth(req: HttpRequest<any>): boolean {
// 1. Does request have our custom X-Auth header?
// 2. Does request NOT already have an Authorization header?
// 3. Does token handler know how to handle the request?
// If all 3 are true, we can handle this request
return (
req.headers.has(REQUIRE_AUTH_HEADER) &&
!req.headers.has(HTTP_AUTHORIZATION_HEADER) &&
this.tokenService.requiresAuth(req)
);
}
}
import { Injectable } from '@angular/core';
import { AuthService } from './auth.service';
@Injectable()
class MockAuthService extends AuthService {
getUserName = () => 'mock';
}
@Injectable()
class MockAnonymousService extends AuthService {
getUserName = () => undefined;
}
describe('AuthService', () => {
let service: MockAuthService;
let anonymousService: MockAnonymousService;
beforeEach(() => {
service = new MockAuthService();
anonymousService = new MockAnonymousService();
});
it('should create', () => {
expect(service).toBeDefined();
expect(anonymousService).toBeDefined();
});
it('should return mock username', () => {
expect(service.getUserName()).toEqual('mock');
expect(anonymousService.getUserName()).toBeUndefined();
});
it('isLoggedIn should be true', () => {
expect(service.isLoggedIn()).toEqual(true);
});
it('anonymous service isLoggedIn should be false', () => {
expect(anonymousService.isLoggedIn()).toEqual(false);
});
});
/**
* This is a "class interface" for an authentication service.
*
* It exists to allow us to easily decouple the auth implementation from cross-cutting concerns
* that only need to be able to do things like get the current username.
*/
export abstract class AuthService {
/**
* Get the userName of the currently logged in user.
*/
getUserName: () => string;
/**
* Trigger a login request to the auth provider.
*
* @param targetUrl the destination URL after logging in
*/
login: (targetUrl: string) => any;
/**
* Determine if there is a logged in user.
*/
public isLoggedIn(): boolean {
return !!this.getUserName();
}
}
describe('Need to have at least one test', () => {
it('should pass', () => {
expect(1).toEqual(1);
import { HTTP_AUTHORIZATION_HEADER, REQUIRE_AUTH_HEADER } from './constants';
describe('security constants', () => {
it('REQUIRE_AUTH_HEADER should be X-Auth', () => {
expect(REQUIRE_AUTH_HEADER).toEqual('X-Auth');
});
it('HTTP_AUTHORIZATION_HEADER should be Authorization', () => {
expect(HTTP_AUTHORIZATION_HEADER).toEqual('Authorization');
});
});
export const REQUIRE_AUTH_HEADER = 'X-Auth';
export const HTTP_AUTHORIZATION_HEADER = 'Authorization';
import { Provider, Type } from '@angular/core';
import { AuthService } from './auth.service';
import { TokenService } from './token.service';
export function provideAuthService(impl: Type<AuthService>): Provider {
return { provide: AuthService, useExisting: impl };
}
export function provideTokenService(impl: Type<TokenService>): Provider {
return { provide: TokenService, useExisting: impl };
}
import { HttpErrorResponse, HttpRequest } from '@angular/common/http';
import { Observable } from 'rxjs';
/**
* This is a "class interface" for a token service.
*
* It exists to allow us to easily decouple the token implementation from the
* HTTP interceptor that needs to acquire the token.
*/
export abstract class TokenService {
/**
* Determine whether this token handler is able to acquire a token for a given request.
* This method is called after initial checks have already passed, so implementations only
* need to worry about checking scopes, etc.
*
* @returns true if this token handler is able to acquire a token for the given request, false otherwise
*/
requiresAuth: (req: HttpRequest<any>) => boolean;
/**
* Acquire a token for the given request.
*
* @returns an Observable of a bearer token
*/
acquireToken: (req: HttpRequest<any>) => Observable<string>;
/**
* Handle HTTP 401 Unauthorized responses from the server. This function is optional.
* The auth provider may want to clear token caches, etc.
*/
onUnauthorized?: (err: HttpErrorResponse, token: string) => void;
}
import { HttpClient, HttpHeaders, HTTP_INTERCEPTORS } from '@angular/common/http';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { Injectable } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { REQUEST_TRACING_CONFIG, TRACE_HEADER } from '@psu/utils/browser';
import { Observable } from 'rxjs';
import { Mock } from 'ts-mocks';
import { AuthService } from './auth.service';
import { UseridRequestTracingInterceptor } from './userid-request-tracing-interceptor';
@Injectable()
class TestService {
constructor(private httpClient: HttpClient) {}
public getStuff(trace = true): Observable<boolean> {
let headers = new HttpHeaders({
something: 'else'
});
if (trace) {
headers = headers.append('X-Trace', 'true');
}
return this.httpClient.get<boolean>('http://stuff', {
headers
});
}
}
describe('UseridRequestTracingInterceptor', () => {
let backend: HttpTestingController;
let service: TestService;
let authService: Mock<AuthService>;
let mockUserName: string;
beforeEach(() => {
mockUserName = undefined;
authService = new Mock<AuthService>({
getUserName: () => mockUserName
});
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [
{
provide: HTTP_INTERCEPTORS,
useClass: UseridRequestTracingInterceptor,
multi: true
},
{
provide: REQUEST_TRACING_CONFIG,
useValue: { applicationId: 'my-app' }
},
{ provide: AuthService, useValue: authService.Object },
TestService
]
});
service = TestBed.get(TestService);
backend = TestBed.get(HttpTestingController);
});
afterEach(() => {
backend.verify();
});
describe('intercept', () => {
describe('when there is no trace header', () => {
beforeEach(() => {
mockUserName = 'junk';
service.getStuff(false).subscribe();
});
it('should not add request id header', () => {
const req = backend.expectOne('http://stuff');
expect(req.request.headers.has('something')).toBe(true);
expect(req.request.headers.has('x-request-id')).toBe(false);
expect(req.request.headers.has(TRACE_HEADER)).toBe(false);
});
});
describe('when there is no user name', () => {
beforeEach(() => {
mockUserName = undefined;
service.getStuff().subscribe();
});
it('should add default request id header', () => {
const req = backend.expectOne('http://stuff');
expect(req.request.headers.has('something')).toBe(true);
expect(req.request.headers.get('x-request-id')).toMatch(/^my-app_\w+$/);
expect(req.request.headers.has(TRACE_HEADER)).toBe(false);
});
});
describe('when there is a user name', () => {
beforeEach(() => {
mockUserName = 'me123';
service.getStuff().subscribe();
});
it('should add request id header with username', () => {
const req = backend.expectOne('http://stuff');
expect(req.request.headers.has('something')).toBe(true);
expect(req.request.headers.get('x-request-id')).toMatch(/^my-app_me123_\w+$/);
expect(req.request.headers.has(TRACE_HEADER)).toBe(false);
});
});
});
});
import { Inject, Injectable } from '@angular/core';
import { RequestTracingConfig, RequestTracingInterceptor, REQUEST_TRACING_CONFIG } from '@psu/utils/browser';
import { AuthService } from './auth.service';
@Injectable()
export class UseridRequestTracingInterceptor extends RequestTracingInterceptor {
constructor(
@Inject(REQUEST_TRACING_CONFIG) protected config: RequestTracingConfig,
private authService: AuthService
) {
super(config);
}
protected generateRequestId(): string {