Commit 37a0fc7d authored by David Burke's avatar David Burke

Merge branch '10-mfa-design' into 'dev'

MFA Design

See merge request !220
parents 5171bf18 2321a776
Pipeline #63158747 passed with stages
in 7 minutes and 57 seconds
......@@ -10,7 +10,7 @@ import { ManageBackupCodeContainer } from "./manage-backup-code/manage-backup-co
import { DeleteContainer } from "./delete/delete.container";
import { ResetPasswordVerifyContainer } from "./reset-password/reset-password-verify/reset-password-verify.container";
import { SetPasswordContainer } from "./reset-password/set-password/set-password.container";
// import { ManageMfaContainer } from "./manage-mfa/manage-mfa.container";
import { ManageMfaContainer } from "./manage-mfa/manage-mfa.container";
export const appRoutes: Routes = [
...routes,
......@@ -53,15 +53,15 @@ export const appRoutes: Routes = [
title: "Set Account Password",
showNavBar: false
}
},
{
path: "manage-mfa",
component: ManageMfaContainer,
canActivate: [LoggedInGuard],
data: {
title: "Manage MFA",
}
}
// {
// path: "manage-mfa",
// component: ManageMfaContainer,
// canActivate: [LoggedInGuard],
// data: {
// title: "Manage MFA",
// }
// }
];
@NgModule({
......
......@@ -12,10 +12,7 @@
<!-- <li class="account__list-item">
<h3 class="account__item-heading">
<app-text-link
[link]="['./manage-mfa']"
caret="right"
>
<app-text-link [link]="['./manage-mfa']" caret="right">
Manage Two-Factor Authentication
</app-text-link>
</h3>
......
......@@ -51,6 +51,8 @@ import { DeleteEffects } from "./delete/delete.effects";
import { AccountRoutingModule } from "./account-routing.module";
import { ManageMfaContainer } from "./manage-mfa/manage-mfa.container";
import { ManageMfaComponent } from "./manage-mfa/manage-mfa.component";
import { SplitMfaLinkPipe } from "./manage-mfa/split-mfa-link.pipe";
import { ManageMFAEffects } from "./manage-mfa/manage-mfa.effects";
export const COMPONENTS = [
RegisterComponent,
......@@ -78,7 +80,8 @@ export const COMPONENTS = [
ForgotLearnMoreContainer,
ForgotLearnMoreComponent,
ManageMfaContainer,
ManageMfaComponent
ManageMfaComponent,
SplitMfaLinkPipe
];
export const SERVICES = [BackupCodePdfService, UserService, ConfirmEmailGuard];
......@@ -103,7 +106,8 @@ export const SERVICES = [BackupCodePdfService, UserService, ConfirmEmailGuard];
SetPasswordEffects,
ManageBackupCodeEffects,
ChangePasswordEffects,
DeleteEffects
DeleteEffects,
ManageMFAEffects
]),
AccountRoutingModule
],
......
......@@ -12,6 +12,7 @@ import * as fromResetPassword from "./reset-password/reset-password.reducer";
import * as fromResetPasswordVerify from "./reset-password/reset-password-verify/reset-password-verify.reducer";
import * as fromSetPassword from "./reset-password/set-password/set-password.reducer";
import * as fromManageBackupCode from "./manage-backup-code/manage-backup-code.reducer";
import * as fromManageMfa from "./manage-mfa/manage-mfa.reducer";
import * as fromRoot from "../app.reducers";
export interface IAccountState {
......@@ -24,6 +25,7 @@ export interface IAccountState {
resetPasswordVerify: fromResetPasswordVerify.IResetPasswordVerifyState;
manageBackupCode: fromManageBackupCode.IState;
setPassword: fromSetPassword.ISetPasswordState;
enableMfa: fromManageMfa.IEnbleMfaState;
}
export const initialState: IAccountState = {
......@@ -35,7 +37,8 @@ export const initialState: IAccountState = {
resetPassword: fromResetPassword.initialState,
resetPasswordVerify: fromResetPasswordVerify.initialState,
manageBackupCode: fromManageBackupCode.initialState,
setPassword: fromSetPassword.initialState
setPassword: fromSetPassword.initialState,
enableMfa: fromManageMfa.initialState
};
export const reducers: ActionReducerMap<IAccountState> = {
......@@ -47,7 +50,8 @@ export const reducers: ActionReducerMap<IAccountState> = {
resetPassword: fromResetPassword.reducer,
resetPasswordVerify: fromResetPasswordVerify.reducer,
setPassword: fromSetPassword.reducer,
manageBackupCode: fromManageBackupCode.reducer
manageBackupCode: fromManageBackupCode.reducer,
enableMfa: fromManageMfa.reducer
};
export interface IState extends fromRoot.IState {
......@@ -296,3 +300,32 @@ export const getSetPasswordBackupCode = createSelector(
selectSetPasswordState,
fromSetPassword.getBackupCode
);
export const selectManageMfaState = createSelector(
selectAccountState,
(state: IAccountState) => state.enableMfa
);
export const getEnableMfaForm = createSelector(
selectManageMfaState,
fromManageMfa.getForm
);
export const getMfaEnabled = createSelector(
selectManageMfaState,
fromManageMfa.getMfaEnabled
);
export const getMfaStep = createSelector(
selectManageMfaState,
fromManageMfa.getStep
);
export const getMfaErrorMessage = createSelector(
selectManageMfaState,
fromManageMfa.getErrorMessage
);
export const getMfaProvisioningURI = createSelector(
selectManageMfaState,
fromManageMfa.getProvisioningURI
);
export const getMfaId = createSelector(
selectManageMfaState,
fromManageMfa.getMFAId
);
import { Action } from "@ngrx/store";
import { IGeneratedMFA } from "./manage-mfa.interfaces";
export enum ManageMfaActionTypes {
FORWARD_STEP = "[ENABLE MFA] Forward Step",
ENABLE_MFA = "[ENABLE MFA] Enable",
ENABLE_MFA_SUCCESS = "[ENABLE MFA] Enable Success",
ENABLE_MFA_FAILURE = "[ENABLE MFA] Enable Failure",
ACTIVATE_MFA = "[ENABLE MFA] Activate",
ACTIVATE_MFA_SUCCESS = "[ENABLE MFA] Activate Success",
ACTIVATE_MFA_FAILURE = "[ENABLE MFA] Activate Failure",
RESET_FORM = "[Reset] = Reset Form"
}
export class ForwardStep implements Action {
readonly type = ManageMfaActionTypes.FORWARD_STEP;
}
export class EnableMfa implements Action {
readonly type = ManageMfaActionTypes.ENABLE_MFA;
}
export class EnableMfaSuccess implements Action {
readonly type = ManageMfaActionTypes.ENABLE_MFA_SUCCESS;
constructor(public payload: IGeneratedMFA) {}
}
export class EnableMfaFailure implements Action {
readonly type = ManageMfaActionTypes.ENABLE_MFA_FAILURE;
constructor(public payload: string[]) {}
}
export class ActivateMfa implements Action {
readonly type = ManageMfaActionTypes.ACTIVATE_MFA;
}
export class ActivateMfaSuccess implements Action {
readonly type = ManageMfaActionTypes.ACTIVATE_MFA_SUCCESS;
}
export class ActivateMfaFailure implements Action {
readonly type = ManageMfaActionTypes.ACTIVATE_MFA_FAILURE;
}
export class ResetForm implements Action {
readonly type = ManageMfaActionTypes.RESET_FORM;
}
export type ManageMfaActions =
| ForwardStep
| EnableMfa
| EnableMfaSuccess
| EnableMfaFailure
| ActivateMfa
| ActivateMfaSuccess
| ActivateMfaFailure
| ResetForm;
<p>
<a [href]="uri">{{ uri }}</a>
<img [src]="qrCode" />
<button (click)="generateMfa.emit()">Generate MFA</button>
</p>
<div class="account l-container-narrow u-margin-tb-50">
<app-text-link
class="heading-back-link"
caret="left"
[routerLink]="['/account']"
>Account Management</app-text-link
>
<h2 class="heading-main heading-main--pad-bottom">
Two-Factor Authentication
</h2>
<div *ngIf="!mfaEnabled" class="register-step__feedback">
<div class="u-margin-b-15">
Make your account more secure by requiring a code after you log in.
</div>
<div class="u-margin-b-20">
If you ever lose access to your codes, you can recover your account with
your
<app-text-link [link]="['/account/change-backup-code']"
>backup code</app-text-link
>.
</div>
</div>
<div *ngIf="mfaEnabled" class="register-step__feedback">
<div class="u-margin-b-15">
Two-factor authentication is currently enabled on your account.
</div>
<div class="u-margin-b-15">
If you no longer wish to use two-factor authentication, enter your current
password to disable your account.
</div>
</div>
<form *ngIf="mfaEnabled" (sumbit)="disableMfa()">
<div class="form-field form-field--large">
<app-form-label [isLarge]="true">
Password
</app-form-label>
<div class="form-field form-field--large">
<input type="password" class="form-field__input" />
</div>
<button class="button button--primary" (click)="disableMfa()">
Disable Two-Factor Authentication
</button>
</div>
</form>
<form *ngIf="!mfaEnabled" [ngrxFormState]="form" (submit)="verifyMfa.emit()">
<div *ngIf="step === 0" class="manage-backup-code__actions">
<button class="button button--primary" (click)="forwardStep.emit()">
Get Started
</button>
</div>
<div class="form-field form-field--large">
<app-form-label
[isLarge]="true"
[isComplete]="step > 1"
[isInactive]="step < 1"
>
Download Authenticator App
</app-form-label>
<div *ngIf="step === 1">
<div class="text--large u-margin-tb-20">
Download
<app-text-link
isExternal="true"
target="_blank"
link="https://authy.com/download/"
>Authy</app-text-link
>,&nbsp;
<app-text-link
isExternal="true"
target="_blank"
link="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2&hl=en_US"
>Google Authenticator</app-text-link
>,
<app-text-link
isExternal="true"
target="_blank"
link="https://www.microsoft.com/en-us/p/microsoft-authenticator/9nblgggzmcj6?activetab=pivot:overviewtab"
>Windows Authenticator</app-text-link
>, or your favorite two-factor app on you mobile device.
</div>
<div class="text--large text--less-bottom-margin">
Once you have the app running, proceed to the next step.
</div>
<div class="manage-backup-code__actions">
<button class="button button--primary" (click)="generateMfa.emit()">
Next
</button>
</div>
</div>
</div>
<div class="form-field form-field--large">
<app-form-label
[isLarge]="true"
[isComplete]="step > 2"
[isInactive]="step < 2"
>
Scan QR Code
</app-form-label>
<div *ngIf="step === 2">
<div class="text--large text--no-bottom-margin u-margin-t-20">
Scan the QR code in your authenticator app or enter the code below.
</div>
<div>
<img style="margin-left: -14px" *ngIf="qrCode" [src]="qrCode" />
</div>
<div class="code-spacing">
<a [href]="getURI()" *ngIf="uri">
{{ uri | splitMfaLink }}
</a>
</div>
<div class="manage-backup-code__actions">
<button class="button button--primary" (click)="forwardStep.emit()">
Next
</button>
</div>
</div>
</div>
<div class="form-field form-field--large">
<app-form-label
[isLarge]="true"
[isComplete]="step > 3"
[isInactive]="step < 3"
[class.u-margin-b-20]="step === 3"
>
Enter Code
</app-form-label>
<div *ngIf="step === 3">
<app-text-field
[isFormSubmitted]="form.isSubmitted"
[ngrxFormControl]="form.controls.verificationCode"
></app-text-field>
<ul *ngIf="errors" class="form-field__error-list">
<li *ngFor="let error of errors" class="form-field__error">
{{ error }}
</li>
</ul>
<div class="form-field form-field--large">
<div class="register-step__feedback">
<span class="register-step__feedback__static-element">
Enter the six digit code generated in your authenticator app, and
submit this form to enable two-factor authentication.
</span>
</div>
</div>
<button class="button button--primary" (click)="onSubmit()">
Enable Two-Factor Authentication
</button>
</div>
</div>
</form>
</div>
.code-spacing {
margin-bottom: 15px;
margin-top: -10px;
}
\ No newline at end of file
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { Component, Input, Output, EventEmitter } from "@angular/core";
import * as QRCode from "qrcode";
import { FormGroupState } from "ngrx-forms";
import { DomSanitizer, SafeUrl } from "@angular/platform-browser";
import { ActivateMFAStep } from "./manage-mfa.interfaces";
import { IEnableMfaForm } from "./manage-mfa.reducer";
@Component({
selector: 'app-manage-mfa',
templateUrl: './manage-mfa.component.html',
styleUrls: ['./manage-mfa.component.css']
selector: "app-manage-mfa",
templateUrl: "./manage-mfa.component.html",
styleUrls: [
"./manage-mfa.component.scss",
"../manage-backup-code/manage-backup-code.component.scss",
"../account.component.scss",
"../../../styles/_utility.scss"
]
})
export class ManageMfaComponent {
@Input() uri: string;
@Input() qrCode: string;
private _uri: string;
@Input() step: ActivateMFAStep;
@Input() form: FormGroupState<IEnableMfaForm>;
@Input() errors: string[] | null;
@Input()
set uri(value: string) {
this._uri = value;
if (value) {
QRCode.toDataURL(value).then(dataUrl => (this.qrCode = dataUrl));
} else {
this.qrCode = null;
}
}
get uri() {
return this._uri;
}
qrCode: string | null;
mfaEnabled = false; // TODO remove this
@Output() generateMfa = new EventEmitter();
@Output() verifyMfa = new EventEmitter();
@Output() forwardStep = new EventEmitter();
constructor(private _sanitizer: DomSanitizer) {}
getURI(): SafeUrl {
return this._sanitizer.bypassSecurityTrustUrl(this._uri);
}
}
import { Component } from '@angular/core';
import * as QRCode from "qrcode";
import { UserService } from '../user';
import { Component } from "@angular/core";
import { Store, select } from "@ngrx/store";
import { IState } from "../../app.reducers";
import { EnableMfa, ForwardStep, ActivateMfa } from "./manage-mfa.actions";
import {
getMfaProvisioningURI,
getMfaStep,
getEnableMfaForm,
getMfaErrorMessage
} from "../account.reducer";
@Component({
template: `
<app-manage-mfa
[uri]="uri"
[qrCode]="qrCode"
(generateMfa)="generateMfa()"
></app-manage-mfa>
`,
<app-manage-mfa
[uri]="uri$ | async"
[step]="step$ | async"
[form]="form$ | async"
[errors]="errors$ | async"
(forwardStep)="forwardStep()"
(generateMfa)="generateMfa()"
(verifyMfa)="activateMfa()"
></app-manage-mfa>
`
})
export class ManageMfaContainer {
uri: string;
qrCode: string;
constructor(private service: UserService) { }
uri$ = this.store.pipe(select(getMfaProvisioningURI));
step$ = this.store.pipe(select(getMfaStep));
form$ = this.store.pipe(select(getEnableMfaForm));
errors$ = this.store.pipe(select(getMfaErrorMessage));
constructor(private store: Store<IState>) {}
forwardStep() {
this.store.dispatch(new ForwardStep());
}
generateMfa() {
this.service.generateMfa().toPromise().then(resp => {
this.uri = resp.provisioning_uri;
QRCode.toDataURL(this.uri).then(dataUrl => this.qrCode = dataUrl);
});
this.store.dispatch(new EnableMfa());
}
activateMfa() {
this.store.dispatch(new ActivateMfa());
}
}
import { Injectable } from "@angular/core";
import { Effect, Actions, ofType } from "@ngrx/effects";
import { UserService } from "../user";
import {
ManageMfaActionTypes,
EnableMfaSuccess,
EnableMfaFailure,
ActivateMfaFailure,
ActivateMfaSuccess
} from "./manage-mfa.actions";
import { exhaustMap, map, catchError, withLatestFrom } from "rxjs/operators";
import { of } from "rxjs";
import { Store, select } from "@ngrx/store";
import { IState } from "../../app.reducers";
import { getEnableMfaForm, getMfaId } from "../account.reducer";
@Injectable()
export class ManageMFAEffects {
@Effect()
generateMfa$ = this.actions$.pipe(
ofType(ManageMfaActionTypes.ENABLE_MFA),
exhaustMap(action =>
this.service.generateMfa().pipe(
map(resp => new EnableMfaSuccess(resp)),
catchError(resp => of(new EnableMfaFailure(resp)))
)
)
);
@Effect()
activateMfa$ = this.actions$.pipe(
ofType(ManageMfaActionTypes.ACTIVATE_MFA),
withLatestFrom(
this.store.pipe(select(getEnableMfaForm)),
this.store.pipe(select(getMfaId))
),
exhaustMap(([action, form, id]) => {
if (form.isValid && form.value.verificationCode && id) {
return this.service.activateMfa(form.value.verificationCode, id).pipe(
map(resp => new ActivateMfaSuccess()),
catchError(resp => of(new ActivateMfaFailure()))
);
}
return of(new ActivateMfaFailure());
})
);
constructor(
private actions$: Actions,
private store: Store<IState>,
private service: UserService
) {}
}
export enum ActivateMFAStep {
Start = 0,
DownloadApp = 1,
ScanQR = 2,
EnterCode = 3
}
export interface IGeneratedMFA {
provisioning_uri: string;
id: number;
}
import {
FormGroupState,
validate,
updateGroup,
createFormGroupState,
createFormStateReducerWithUpdate
} from "ngrx-forms";
import { required, minLength } from "ngrx-forms/validation";
import { ManageMfaActions, ManageMfaActionTypes } from "./manage-mfa.actions";
import { ActivateMFAStep, IGeneratedMFA } from "./manage-mfa.interfaces";
export const FORM_ID = "Enable MFA Form";
export interface IEnableMfaForm {
verificationCode: string | null;
}
export interface IEnbleMfaState {
form: FormGroupState<IEnableMfaForm>;
generatedMFA: IGeneratedMFA | null;
mfaEnabled: boolean;
step: ActivateMFAStep;
errorMessage: string[] | null;
}
const validateAndUpdateFormState = updateGroup<IEnableMfaForm>({
verificationCode: validate(required, minLength(6))
});
export const initialFormState = validateAndUpdateFormState(
createFormGroupState<IEnableMfaForm>(FORM_ID, {
verificationCode: ""
})
);
export const initialState: IEnbleMfaState = {
form: initialFormState,
generatedMFA: null,
step: ActivateMFAStep.Start,
errorMessage: null,
mfaEnabled: false
};
export const formReducer = createFormStateReducerWithUpdate<IEnableMfaForm>(
validateAndUpdateFormState
);
export function reducer(
state = initialState,
action: ManageMfaActions
): IEnbleMfaState {
const form = formReducer(state.form, action);
state = { ...state, form };
switch (action.type) {
case ManageMfaActionTypes.ENABLE_MFA:
case ManageMfaActionTypes.FORWARD_STEP:
return {
...state,
step: state.step + 1
};
case ManageMfaActionTypes.ENABLE_MFA_SUCCESS:
return {
...state,
generatedMFA: action.payload,
errorMessage: null,
mfaEnabled: true
};
case ManageMfaActionTypes.ENABLE_MFA_FAILURE:
return {
...state,
errorMessage: action.payload
};
case ManageMfaActionTypes.ACTIVATE_MFA_SUCCESS:
return {
...state,
errorMessage: null
};
case ManageMfaActionTypes.ACTIVATE_MFA_FAILURE:
return {
...state,
errorMessage: ["Unable to verify code."]
};
case ManageMfaActionTypes.RESET_FORM:
return initialState;
}
return state;
}
export const getForm = (state: IEnbleMfaState) => state.form;
export const getMfaEnabled = (state: IEnbleMfaState) => state.mfaEnabled;
export const getStep = (state: IEnbleMfaState) => state.step;
export const getErrorMessage = (state: IEnbleMfaState) => state.errorMessage;
export const getProvisioningURI = (state: IEnbleMfaState) =>
state.generatedMFA ? state.generatedMFA.provisioning_uri : null;
export const getMFAId = (state: IEnbleMfaState) =>
state.generatedMFA ? state.generatedMFA.id : null;
import { Pipe, PipeTransform } from "@angular/core";
@Pipe({
name: "splitMfaLink"
})
export class SplitMfaLinkPipe implements PipeTransform {
transform(uri: string, args?: any): string {
const splitUriBefore = uri.split("secret=", 2);
const splitUriAfter = splitUriBefore[1].split("&issuer", 2);
return splitUriAfter[0];
}
}
......@@ -20,6 +20,7 @@ import { SetUrlAction } from "../account.actions";
import { IAuthStore, IResetPasswordVerifyResponse } from "./user.interfaces";
import { checkRespForErrors } from "~/app/shared/utils";
import { IState, selectAuthState } from "../../app.reducers";
import { IGeneratedMFA } from "../manage-mfa/manage-mfa.interfaces";
@Injectable()
export class UserService {
......@@ -425,9 +426,17 @@ export class UserService {
/** Generate new MFA code, returns the uri that can be imported into MFA apps like Google Authenticator */
generateMfa() {
const url = this.sdk.formUrl("generate-mfa/");
return this.http.post<{ provisioning_uri: string }>(url, "");
return this.http.post<IGeneratedMFA>(url, "");
}
/** Activate a generated MFA code */
activateMfa(otp: string, id: number) {
const url = this.sdk.formUrl("activate-mfa/");
const data = { otp, id };
return this.http.post(url, data);
}
/** Verify a OTP, used for login */
verifyMfa(otp: string) {
const url = this.sdk.formUrl("verify-mfa/");
const data = { otp };
......