Commit 4cb08256 authored by Ryan Diehl's avatar Ryan Diehl

Merge branch 'feature/security' into 'develop'

feat(security): cross cutting security features

See merge request !37
parents a41d2835 49e4dccc
Pipeline #109708 passed with stages
in 4 minutes and 32 seconds
......@@ -476,6 +476,34 @@
"styleext": "scss"
}
}
},
"utils-security-ngrx": {
"projectType": "library",
"root": "libs/utils/security-ngrx",
"sourceRoot": "libs/utils/security-ngrx/src",
"prefix": "ut",
"architect": {
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"options": {
"tsConfig": ["libs/utils/security-ngrx/tsconfig.lib.json", "libs/utils/security-ngrx/tsconfig.spec.json"],
"exclude": ["**/node_modules/**", "!libs/utils/security-ngrx/**"]
}
},
"test": {
"builder": "@nrwl/jest:jest",
"options": {
"jestConfig": "libs/utils/security-ngrx/jest.config.js",
"tsConfig": "libs/utils/security-ngrx/tsconfig.spec.json",
"setupFile": "libs/utils/security-ngrx/src/test-setup.ts"
}
}
},
"schematics": {
"@nrwl/angular:component": {
"styleext": "scss"
}
}
}
},
"cli": {
......
# @psu/utils/security-ngrx
NgRx extensions for @psu/utils/security.
## Class Interfaces
This library makes use of class interfaces for dependency injection of vendor-specific authentication functionality.
External libraries such as @psu/msal-oidc will provide implementation of these interfaces.
### SecurityEventListener
`SecurityEventListener` defines an interface for a service that handles authentication related events that should
trigger NgRx actions.
### Providers
This library exports a helper function to allow you to provide implementations for these class interfaces -
`provideSecurityEventListener()`.
```
providers: [
...,
VendorSecurityEventListener,
provideSecurityEventListener(VendorSecurityEventListener),
]
```
## Facade
The `SecurityFacade` abstracts away most details of the NgRx implementation and provides helper functions and properties
for apps to use. It injects the `SecurityEventListener` and listens for login success, failure, etc. events, and
then dispatches corresponding actions against the store.
module.exports = {
name: 'utils-security-ngrx',
preset: '../../../jest.config.js',
coverageDirectory: '../../../coverage/libs/utils/security-ngrx',
snapshotSerializers: [
'jest-preset-angular/AngularSnapshotSerializer.js',
'jest-preset-angular/HTMLCommentSerializer.js'
]
};
{
"lib": {
"entryFile": "src/index.ts"
}
}
export * from './lib/providers';
export * from './lib/security-event.listener';
export * from './lib/security-ngrx.module';
export * from './lib/security.actions';
export * from './lib/security.facade';
export * from './lib/security.selectors';
import { Provider, Type } from '@angular/core';
import { SecurityEventListener } from './security-event.listener';
export function provideSecurityEventListener(impl: Type<SecurityEventListener>): Provider {
return { provide: SecurityEventListener, useExisting: impl };
}
import { User } from '@psu/utils/security';
import { Observable } from 'rxjs';
/**
* This is a "class interface" for a security event service.
*
* This allows our authentication implementation (oauth2-server, azure, hydra, etc) to be
* decoupled from the internal logic in this library.
*/
export abstract class SecurityEventListener {
/**
* An observable of type User that should emit whenever a user has succesfully logged in.
*/
readonly loginSuccessful$: Observable<User>;
/**
* An observable of type string that should emit whenever a user has failed to log in.
*/
readonly loginFailure$: Observable<string>;
}
import { async, TestBed } from '@angular/core/testing';
import { SecurityNgrxModule } from './security-ngrx.module';
describe('SecurityNgrxModule', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [SecurityNgrxModule]
}).compileComponents();
}));
it('should create', () => {
expect(SecurityNgrxModule).toBeDefined();
});
});
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { EffectsModule } from '@ngrx/effects';
import { StoreModule } from '@ngrx/store';
import { SecurityEffects } from './security.effects';
import { securityReducer, securitySliceName } from './security.reducer';
@NgModule({
imports: [
CommonModule,
StoreModule.forFeature(securitySliceName, securityReducer),
EffectsModule.forFeature([SecurityEffects])
]
})
export class SecurityNgrxModule {}
import { createAction, props } from '@ngrx/store';
import { User } from '@psu/utils/security';
const PREFIX = '@psu/utils/security';
export const login = createAction(`${PREFIX} Login`, props<{ targetUrl: string }>());
export const loginSuccessful = createAction(`${PREFIX} Login Successful`, props<{ user: User }>());
export const loginFailure = createAction(`${PREFIX} Login Failure`, props<{ message?: string }>());
import { TestBed } from '@angular/core/testing';
import { provideMockActions } from '@ngrx/effects/testing';
import { Action } from '@ngrx/store';
import { AuthService } from '@psu/utils/security';
import { ReplaySubject } from 'rxjs';
import { Mock } from 'ts-mocks';
import { login } from './security.actions';
import { SecurityEffects } from './security.effects';
describe('SecurityEffects', () => {
let effects: SecurityEffects;
let actions$: ReplaySubject<Action>;
let authService: Mock<AuthService>;
beforeEach(() => {
actions$ = new ReplaySubject(1);
authService = new Mock<AuthService>({
login: () => {}
});
TestBed.configureTestingModule({
providers: [SecurityEffects, provideMockActions(actions$), { provide: AuthService, useValue: authService.Object }]
});
effects = TestBed.get(SecurityEffects);
});
it('should create', () => {
expect(effects).toBeDefined();
});
it('login action should call auth service login method', () => {
let result: Action;
effects.login$.subscribe(r => (result = r));
actions$.next(login({ targetUrl: '/authed' }));
expect(result).toEqual({ type: 'NO_ACTION' });
expect(authService.Object.login).toHaveBeenCalledWith('/authed');
});
});
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { AuthService } from '@psu/utils/security';
import { map, tap } from 'rxjs/operators';
import { login } from './security.actions';
@Injectable({ providedIn: 'root' })
export class SecurityEffects {
public login$ = createEffect(
() =>
this.actions$.pipe(
ofType(login),
map(action => action.targetUrl),
tap(targetUrl => this.authService.login(targetUrl)),
map(() => ({ type: 'NO_ACTION' }))
),
{ dispatch: false }
);
constructor(private actions$: Actions, private authService: AuthService) {}
}
import { TestBed } from '@angular/core/testing';
import { Store } from '@ngrx/store';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { User } from '@psu/utils/security';
import { Subject } from 'rxjs';
import { Mock } from 'ts-mocks';
import { SecurityEventListener } from './security-event.listener';
import { login, loginFailure, loginSuccessful } from './security.actions';
import { SecurityFacade } from './security.facade';
import { initialSecurityState, SecurityPartialState, securitySliceName } from './security.reducer';
describe('SecurityFacade', () => {
let facade: SecurityFacade;
let store: MockStore<SecurityPartialState>;
let listener: Mock<SecurityEventListener>;
let loginSuccess$: Subject<User>;
let loginFailed$: Subject<string>;
beforeEach(() => {
loginSuccess$ = new Subject();
loginFailed$ = new Subject();
listener = new Mock<SecurityEventListener>({
loginSuccessful$: loginSuccess$.asObservable(),
loginFailure$: loginFailed$.asObservable()
});
TestBed.configureTestingModule({
providers: [
SecurityFacade,
provideMockStore({ initialState: { [securitySliceName]: initialSecurityState } }),
{ provide: SecurityEventListener, useValue: listener.Object }
]
});
facade = TestBed.get(SecurityFacade);
store = TestBed.get(Store);
spyOn(store, 'dispatch').and.callThrough();
});
it('should create', () => {
expect(facade).toBeDefined();
});
it('login should dispatch login action', () => {
facade.login('/app');
expect(store.dispatch).toHaveBeenCalledWith(login({ targetUrl: '/app' }));
});
it('listener loginSuccessful should dispatch loginSuccessful action', () => {
const user = {
userName: 'me',
other: 'stuff',
identityClaims: {}
};
loginSuccess$.next(user);
expect(store.dispatch).toHaveBeenCalledWith(loginSuccessful({ user }));
});
it('listener loginFailure should dispatch loginFailure action', () => {
loginFailed$.next('no');
expect(store.dispatch).toHaveBeenCalledWith(loginFailure({ message: 'no' }));
loginFailed$.next();
expect(store.dispatch).toHaveBeenCalledWith(loginFailure({}));
});
});
import { Injectable } from '@angular/core';
import { select, Store } from '@ngrx/store';
import { User } from '@psu/utils/security';
import { SecurityEventListener } from './security-event.listener';
import { login, loginFailure, loginSuccessful } from './security.actions';
import { SecurityPartialState } from './security.reducer';
import {
selectSecurityCurrentUserName,
selectSecurityIsLoggedIn,
selectSecurityLoginPending
} from './security.selectors';
@Injectable({ providedIn: 'root' })
export class SecurityFacade {
public readonly isLoggedIn$ = this.store.pipe(select(selectSecurityIsLoggedIn));
public readonly isLoggingIn$ = this.store.pipe(select(selectSecurityLoginPending));
public readonly userName$ = this.store.pipe(select(selectSecurityCurrentUserName));
constructor(private store: Store<SecurityPartialState>, private listener: SecurityEventListener) {
this.listener.loginFailure$.subscribe(message => this.loginFailure(message));
this.listener.loginSuccessful$.subscribe(user => this.loginSuccessful(user));
}
public login(targetUrl: string): void {
this.store.dispatch(login({ targetUrl }));
}
public loginSuccessful(user: User): void {
this.store.dispatch(loginSuccessful({ user }));
}
public loginFailure(message?: string): void {
this.store.dispatch(loginFailure({ message }));
}
}
import { login, loginFailure, loginSuccessful } from './security.actions';
import { initialSecurityState, securityReducer } from './security.reducer';
describe('securityReducer', () => {
it('login should set loggingIn to true', () => {
expect(securityReducer(initialSecurityState, login({ targetUrl: 'any' }))).toEqual({
...initialSecurityState,
loggingIn: true
});
});
it('loginSuccessful should set user and loggingIn to false', () => {
const user = {
userName: 'me',
other: 'stuff'
};
expect(securityReducer({ ...initialSecurityState, loggingIn: true }, loginSuccessful({ user }))).toEqual({
...initialSecurityState,
loggingIn: false,
user
});
});
it('loginFailure should clear loggingIn', () => {
expect(securityReducer({ ...initialSecurityState, loggingIn: true }, loginFailure({}))).toEqual({
...initialSecurityState,
loggingIn: false
});
});
});
import { Action, createReducer, on } from '@ngrx/store';
import { User } from '@psu/utils/security';
import { login, loginFailure, loginSuccessful } from './security.actions';
export interface SecurityState {
user: User;
loggingIn: boolean;
}
export const securitySliceName = 'utils-security';
export interface SecurityPartialState {
[securitySliceName]: SecurityState;
}
export const initialSecurityState: SecurityState = {
user: undefined,
loggingIn: false
};
const reducer = createReducer(
initialSecurityState,
on(login, state => ({ ...state, loggingIn: true })),
on(loginSuccessful, (state, { user }) => ({
...state,
loggingIn: false,
user
})),
on(loginFailure, state => ({ ...state, loggingIn: false }))
);
export function securityReducer(state: SecurityState | undefined, action: Action): SecurityState {
return reducer(state, action);
}
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { securitySliceName, SecurityState } from './security.reducer';
const securityFeatureSelector = createFeatureSelector<SecurityState>(securitySliceName);
export const selectSecurityIsLoggedIn = createSelector(
securityFeatureSelector,
s => !!s.user
);
export const selectSecurityLoginPending = createSelector(
securityFeatureSelector,
s => s.loggingIn
);
export const selectSecurityCurrentUser = createSelector(
securityFeatureSelector,
s => s.user
);
export const selectSecurityCurrentUserName = createSelector(
selectSecurityCurrentUser,
s => (s ? s.userName : undefined)
);
import 'jest-preset-angular';
import '../../../../jestGlobalMocks';
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"types": ["node", "jest"]
},
"include": ["**/*.ts"]
}
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"target": "es2015",
"declaration": true,
"inlineSources": true,
"types": [],
"lib": ["dom", "es2018"]
},
"angularCompilerOptions": {
"annotateForClosureCompiler": true,
"skipTemplateCodegen": true,
"strictMetadataEmit": true,
"fullTemplateTypeCheck": true,
"strictInjectionParameters": true,
"enableResourceInlining": true
},
"exclude": ["src/test-setup.ts", "**/*.spec.ts"]
}
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"module": "commonjs",
"types": ["jest", "node"]
},
"files": ["src/test-setup.ts"],
"include": ["**/*.spec.ts", "**/*.d.ts"]
}
{
"extends": "../../../tslint.json",
"rules": {
"directive-selector": [true, "attribute", "ut", "camelCase"],
"component-selector": [true, "element", "ut", "kebab-case"]
}
}
# Security Utils
# @psu/utils/security
This will eventually be the home of the refactored and de-spaghettified security utils, but for now it just has a single string constant for the `REQUIRE_AUTH_HEADER` since I needed somewhere to put it and didn't want to import the entire security library just for one constant.
This entrypoint contains cross-cutting security functionality that is independent of any actual auth
implementation. Where we need to interact with a specific auth implementation (@psu/security, @psu/msal-oidc, etc.),
this entrypoint exposes class interfaces and dependency injection helpers for the implementor.
## Class Interfaces
This library makes use of class interfaces for dependency injection of vendor-specific authentication functionality.
External libraries such as @psu/msal-oidc will provide implementation of these interfaces.
### AuthService
`AuthService` defines methods to log a user into a system and get user information such as userName (ideally from
something like an ID token using OIDC).
### TokenService
`TokenService` defines methods to acquire tokens during HTTP flows.
### Providers
This library exports a couple of helper functions to allow you to provide implementations for these class interfaces -
`provideAuthService()` and `provideTokenService()`.
```
providers: [
...,
VendorAuthService,
provideAuthService(VendorAuthService),
VendorTokenService,
provideVendorTokenService(VendorTokenService),
]
```
## Interceptors
### AuthInterceptor
`AuthInterceptor` provides an implementation-agnostic way to attach `Bearer` tokens to outgoing HTTP requests.
It leverages the existing `REQUIRE_AUTH_HEADER` constant that our apis library already uses. It should be a drop-in
replacement for the implementation from @psu/security, with the exception that we no longer support the legacy
`protectedUrls` configuration parameter (though it could be re-added if needed).
The interceptor injects `TokenService`, which is a class interface. This library does not provide an implementation
for this service, instead it should be implemented by vendor-specific libraries like @psu/msal-oidc. The TokenService
is used to acquire a token.
### UseridRequestTracingInterceptor
`UseridRequestTracingInterceptor` provides an implementation-agnostic way to set custom `x-request-id` headers that
include the username of the currently logged in user. It is a drop-in replacement for the version in @psu/security.
The interceptor injects `AuthService`, which is a class interface. This library does not provide an implementation
for this service, instead it should be implemented by vendor-specific libraries like @psu/msal-oidc. The AuthService
is used to determine if a user is currently logged in.
## Models
The `User` interface defines an authenticated user. Its only field is a `userName` at this point. Your implementation
will likely have additional fields such as identity claims.
## Guards
### AlreadyLoggedInGuard
The `AlreadyLoggedInGuard` can be used to redirect users away from anonymous resources like a login screen, if they are
currently authenticated. It injects `AuthService`, see above.
export { REQUIRE_AUTH_HEADER } from './lib/constants';
export { AlreadyLoggedInGuard } from './lib/already-logged-in.guard';
export { AuthInterceptor } from './lib/auth.interceptor';
export { User } from './lib/auth.model';
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: {