support for setup of multiple u2f keys

This commit is contained in:
Kyle Spearrin 2018-10-08 14:23:30 -04:00
parent c6d6eecb43
commit 4aa75e9376
6 changed files with 184 additions and 74 deletions

2
jslib

@ -1 +1 @@
Subproject commit c3f67dbe26d7d5b30645a2857fd9f316fce7b6bc Subproject commit 4b7962dc8fba73003be1ae651013f5f817496551

View File

@ -18,6 +18,7 @@
"dist": "npm run build:prod && gulp postdist", "dist": "npm run build:prod && gulp postdist",
"dist:selfhost": "npm run build:selfhost:prod && gulp postdist", "dist:selfhost": "npm run build:selfhost:prod && gulp postdist",
"deploy": "npm run dist && gh-pages -d build", "deploy": "npm run dist && gh-pages -d build",
"deploy:dev": "npm run dist && gh-pages -d build -r git@github.com:kspearrin/bitwarden-web-dev.git",
"lint": "tslint src/**/*.ts || true", "lint": "tslint src/**/*.ts || true",
"lint:fix": "tslint src/**/*.ts --fix" "lint:fix": "tslint src/**/*.ts --fix"
}, },

View File

@ -127,7 +127,7 @@ containerService.attachToWindow(window);
export function initFactory(): Function { export function initFactory(): Function {
return async () => { return async () => {
await (storageService as HtmlStorageService).init(); await (storageService as HtmlStorageService).init();
const isDev = platformUtilsService.isDev(); const isDev = true;
if (!isDev && platformUtilsService.isSelfHost()) { if (!isDev && platformUtilsService.isSelfHost()) {
environmentService.baseUrl = window.location.origin; environmentService.baseUrl = window.location.origin;
} else { } else {

View File

@ -1,5 +1,5 @@
<div class="modal fade"> <div class="modal fade">
<div class="modal-dialog"> <div class="modal-dialog modal-lg">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h2 class="modal-title"> <h2 class="modal-title">
@ -23,43 +23,66 @@
<li>{{'twoFactorU2fSupportWeb' | i18n}}</li> <li>{{'twoFactorU2fSupportWeb' | i18n}}</li>
</ul> </ul>
</app-callout> </app-callout>
<ng-container *ngIf="!enabled">
<img src="../../images/two-factor/4.png" class="float-right ml-5" alt=""> <img src="../../images/two-factor/4.png" class="float-right ml-5" alt="">
<ul class="fa-ul">
<li *ngFor="let k of keys; let i = index" #removeKeyBtn [appApiAction]="k.removePromise">
<i class="fa-li fa fa-key"></i>
<strong *ngIf="!k.configured || !k.name">{{'u2fkeyX' | i18n : i + 1}}</strong>
<strong *ngIf="k.configured && k.name">{{k.name}}</strong>
<i class="fa fa-fw" [ngClass]="{'fa-check text-success': !k.compromised, 'fa-exclamation-triangle text-warning': k.compromised}"
*ngIf="k.configured && !removeKeyBtn.loading" title="{{(k.compromised ? 'keyCompromised' : 'enabled') | i18n}}"></i>
<ng-container *ngIf="keysConfiguredCount > 1 && k.configured">
<i class="fa fa-spin fa-spinner text-muted fa-fw" title="{{'loading' | i18n}}" *ngIf="removeKeyBtn.loading"></i>
-
<a href="#" appStopClick (click)="remove(k)">{{'remove' | i18n}}</a>
</ng-container>
</li>
</ul>
<hr>
<p>{{'twoFactorU2fAdd' | i18n}}:</p> <p>{{'twoFactorU2fAdd' | i18n}}:</p>
<ol> <ol>
<li>{{'twoFactorU2fPlugIn' | i18n}}</li> <li>{{'twoFactorU2fGiveName' | i18n}}</li>
<li>{{'twoFactorU2fPlugInReadKey' | i18n}}</li>
<li>{{'twoFactorU2fTouchButton' | i18n}}</li> <li>{{'twoFactorU2fTouchButton' | i18n}}</li>
<li>{{'twoFactorU2fSaveForm' | i18n}}</li>
</ol> </ol>
<hr> <div class="row">
<div class="text-center"> <div class="form-group col-6">
<label for="name">{{'name' | i18n}}</label>
<input id="name" type="text" name="Name" class="form-control" [(ngModel)]="name" [disabled]="!keyIdAvailable">
</div>
</div>
<button type="button" (click)="readKey()" class="btn btn-outline-secondary mr-2" [disabled]="readKeyBtn.loading || u2fListening || !keyIdAvailable"
#readKeyBtn [appApiAction]="challengePromise">
{{'readKey' | i18n}}
</button>
<ng-container *ngIf="readKeyBtn.loading">
<i class="fa fa-spinner fa-spin text-muted"></i>
</ng-container>
<ng-container *ngIf="!readKeyBtn.loading">
<ng-container *ngIf="u2fListening"> <ng-container *ngIf="u2fListening">
<p> <i class="fa fa-spinner fa-spin text-muted"></i>
<i class="fa fa-spinner fa-spin fa-2x text-muted"></i>
</p>
{{'twoFactorU2fWaiting' | i18n}}... {{'twoFactorU2fWaiting' | i18n}}...
</ng-container> </ng-container>
<ng-container *ngIf="u2fResponse"> <ng-container *ngIf="u2fResponse">
<p> <i class="fa fa-check-circle text-success"></i>
<i class="fa fa-check-circle fa-2x text-success"></i> {{'twoFactorU2fClickSave' | i18n}}
</p>
{{'twoFactorU2fClickEnable' | i18n}}
</ng-container> </ng-container>
<ng-container *ngIf="u2fError"> <ng-container *ngIf="u2fError">
<p> <i class="fa fa-warning text-danger"></i>
<i class="fa fa-warning fa-2x text-danger"></i> {{'twoFactorU2fProblemReadingTryAgain' | i18n}}
</p>
{{'twoFactorU2fProblemReading' | i18n}}
</ng-container> </ng-container>
</div>
</ng-container> </ng-container>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="submit" class="btn btn-primary" [disabled]="form.loading || (!enabled && !u2fResponse)"> <button type="submit" class="btn btn-primary" [disabled]="form.loading || !u2fResponse">
<i class="fa fa-spinner fa-spin" *ngIf="form.loading" title="{{'loading' | i18n}}"></i> <i class="fa fa-spinner fa-spin" *ngIf="form.loading" title="{{'loading' | i18n}}"></i>
<ng-container *ngIf="!form.loading"> <span *ngIf="!form.loading">{{'save' | i18n}}</span>
<span *ngIf="!enabled">{{'enable' | i18n}}</span> </button>
<span *ngIf="enabled">{{'disable' | i18n}}</span> <button #disableBtn type="button" class="btn btn-outline-secondary btn-submit" [appApiAction]="disablePromise"
</ng-container> [disabled]="disableBtn.loading" (click)="disable()" *ngIf="enabled">
<i class="fa fa-spinner fa-spin" title="{{'loading' | i18n}}"></i>
<span>{{'disableAllKeys' | i18n}}</span>
</button> </button>
<button type="button" class="btn btn-outline-secondary" data-dismiss="modal">{{'close' | i18n}}</button> <button type="button" class="btn btn-outline-secondary" data-dismiss="modal">{{'close' | i18n}}</button>
</div> </div>

View File

@ -1,5 +1,6 @@
import { import {
Component, Component,
NgZone,
OnDestroy, OnDestroy,
OnInit, OnInit,
} from '@angular/core'; } from '@angular/core';
@ -12,6 +13,9 @@ import { I18nService } from 'jslib/abstractions/i18n.service';
import { PlatformUtilsService } from 'jslib/abstractions/platformUtils.service'; import { PlatformUtilsService } from 'jslib/abstractions/platformUtils.service';
import { TwoFactorProviderType } from 'jslib/enums/twoFactorProviderType'; import { TwoFactorProviderType } from 'jslib/enums/twoFactorProviderType';
import { PasswordVerificationRequest } from 'jslib/models/request/passwordVerificationRequest';
import { UpdateTwoFactorU2fDeleteRequest } from 'jslib/models/request/updateTwoFactorU2fDeleteRequest';
import { UpdateTwoFactorU2fRequest } from 'jslib/models/request/updateTwoFactorU2fRequest'; import { UpdateTwoFactorU2fRequest } from 'jslib/models/request/updateTwoFactorU2fRequest';
import { import {
ChallengeResponse, ChallengeResponse,
@ -26,18 +30,21 @@ import { TwoFactorBaseComponent } from './two-factor-base.component';
}) })
export class TwoFactorU2fComponent extends TwoFactorBaseComponent implements OnInit, OnDestroy { export class TwoFactorU2fComponent extends TwoFactorBaseComponent implements OnInit, OnDestroy {
type = TwoFactorProviderType.U2f; type = TwoFactorProviderType.U2f;
u2fChallenge: ChallengeResponse; name: string;
keys: any[];
keyIdAvailable: number = null;
keysConfiguredCount = 0;
u2fError: boolean; u2fError: boolean;
u2fListening: boolean; u2fListening: boolean;
u2fResponse: string; u2fResponse: string;
challengePromise: Promise<ChallengeResponse>;
formPromise: Promise<any>; formPromise: Promise<any>;
private closed = false;
private u2fScript: HTMLScriptElement; private u2fScript: HTMLScriptElement;
constructor(apiService: ApiService, i18nService: I18nService, constructor(apiService: ApiService, i18nService: I18nService,
analytics: Angulartics2, toasterService: ToasterService, analytics: Angulartics2, toasterService: ToasterService,
platformUtilsService: PlatformUtilsService) { platformUtilsService: PlatformUtilsService, private ngZone: NgZone) {
super(apiService, i18nService, analytics, toasterService, platformUtilsService); super(apiService, i18nService, analytics, toasterService, platformUtilsService);
this.u2fScript = window.document.createElement('script'); this.u2fScript = window.document.createElement('script');
this.u2fScript.src = 'scripts/u2f.js'; this.u2fScript.src = 'scripts/u2f.js';
@ -49,28 +56,24 @@ export class TwoFactorU2fComponent extends TwoFactorBaseComponent implements OnI
} }
ngOnDestroy() { ngOnDestroy() {
this.closed = true;
window.document.body.removeChild(this.u2fScript); window.document.body.removeChild(this.u2fScript);
} }
auth(authResponse: any) { auth(authResponse: any) {
super.auth(authResponse); super.auth(authResponse);
this.processResponse(authResponse.response); this.processResponse(authResponse.response);
this.readDevice();
} }
submit() { submit() {
if (this.enabled) { if (this.u2fResponse == null || this.keyIdAvailable == null) {
return super.disable(this.formPromise); // Should never happen.
} else { return Promise.reject();
return this.enable();
} }
}
protected enable() {
const request = new UpdateTwoFactorU2fRequest(); const request = new UpdateTwoFactorU2fRequest();
request.masterPasswordHash = this.masterPasswordHash; request.masterPasswordHash = this.masterPasswordHash;
request.deviceResponse = this.u2fResponse; request.deviceResponse = this.u2fResponse;
request.id = this.keyIdAvailable;
request.name = this.name;
return super.enable(async () => { return super.enable(async () => {
this.formPromise = this.apiService.putTwoFactorU2f(request); this.formPromise = this.apiService.putTwoFactorU2f(request);
@ -79,38 +82,97 @@ export class TwoFactorU2fComponent extends TwoFactorBaseComponent implements OnI
}); });
} }
private readDevice() { disable() {
if (this.closed || this.enabled) { return super.disable(this.formPromise);
return;
} }
async remove(key: any) {
if (this.keysConfiguredCount <= 1 || key.removePromise != null) {
return;
}
const name = key.name != null ? key.name : this.i18nService.t('u2fkeyX', key.id);
const confirmed = await this.platformUtilsService.showDialog(
this.i18nService.t('removeU2fConfirmation'), name,
this.i18nService.t('yes'), this.i18nService.t('no'), 'warning');
if (!confirmed) {
return;
}
const request = new UpdateTwoFactorU2fDeleteRequest();
request.id = key.id;
request.masterPasswordHash = this.masterPasswordHash;
try {
key.removePromise = this.apiService.deleteTwoFactorU2f(request);
const response = await key.removePromise;
key.removePromise = null;
await this.processResponse(response);
} catch { }
}
async readKey() {
if (this.keyIdAvailable == null) {
return;
}
const request = new PasswordVerificationRequest();
request.masterPasswordHash = this.masterPasswordHash;
try {
this.challengePromise = this.apiService.getTwoFactorU2fChallenge(request);
const challenge = await this.challengePromise;
this.readDevice(challenge);
} catch { }
}
private readDevice(u2fChallenge: ChallengeResponse) {
// tslint:disable-next-line // tslint:disable-next-line
console.log('listening for key...'); console.log('listening for key...');
this.resetU2f(true);
this.u2fResponse = null; (window as any).u2f.register(u2fChallenge.appId, [{
this.u2fError = false; version: u2fChallenge.version,
this.u2fListening = true; challenge: u2fChallenge.challenge,
(window as any).u2f.register(this.u2fChallenge.appId, [{
version: this.u2fChallenge.version,
challenge: this.u2fChallenge.challenge,
}], [], (data: any) => { }], [], (data: any) => {
this.ngZone.run(() => {
this.u2fListening = false; this.u2fListening = false;
if (data.errorCode === 5) { if (data.errorCode) {
this.readDevice();
return;
} else if (data.errorCode) {
this.u2fError = true; this.u2fError = true;
// tslint:disable-next-line // tslint:disable-next-line
console.log('error: ' + data.errorCode); console.log('error: ' + data.errorCode);
return; return;
} }
this.u2fResponse = JSON.stringify(data); this.u2fResponse = JSON.stringify(data);
}, 10); });
}, 15);
}
private resetU2f(listening = false) {
this.u2fResponse = null;
this.u2fError = false;
this.u2fListening = listening;
} }
private processResponse(response: TwoFactorU2fResponse) { private processResponse(response: TwoFactorU2fResponse) {
this.u2fChallenge = response.challenge; this.resetU2f();
this.keys = [];
this.keyIdAvailable = null;
this.name = null;
this.keysConfiguredCount = 0;
for (let i = 1; i <= 5; i++) {
if (response.keys != null) {
const key = response.keys.filter((k) => k.id === i);
if (key.length > 0) {
this.keysConfiguredCount++;
this.keys.push({
id: i, name: key[0].name,
configured: true,
compromised: key[0].compromised,
removePromise: null,
});
continue;
}
}
this.keys.push({ id: i, name: null, configured: false, compromised: false, removePromise: null });
if (this.keyIdAvailable == null) {
this.keyIdAvailable = i;
}
}
this.enabled = response.enabled; this.enabled = response.enabled;
} }
} }

View File

@ -1145,7 +1145,7 @@
"message": "Add a new YubiKey to your account" "message": "Add a new YubiKey to your account"
}, },
"twoFactorYubikeyPlugIn": { "twoFactorYubikeyPlugIn": {
"message": "Plug the YubiKey (NEO or 4 series) into your computer's USB port." "message": "Plug the YubiKey into your computer's USB port."
}, },
"twoFactorYubikeySelectKey": { "twoFactorYubikeySelectKey": {
"message": "Select the first empty YubiKey input field below." "message": "Select the first empty YubiKey input field below."
@ -1174,6 +1174,15 @@
} }
} }
}, },
"u2fkeyX": {
"message": "U2F Key $INDEX$",
"placeholders": {
"index": {
"content": "$1",
"example": "2"
}
}
},
"nfcSupport": { "nfcSupport": {
"message": "NFC Support" "message": "NFC Support"
}, },
@ -1216,12 +1225,27 @@
"twoFactorU2fAdd": { "twoFactorU2fAdd": {
"message": "Add a FIDO U2F security key to your account" "message": "Add a FIDO U2F security key to your account"
}, },
"twoFactorU2fPlugIn": { "removeU2fConfirmation": {
"message": "Plug the security key into your computer's USB port." "message": "Are you sure you want to remove this security key?"
},
"readKey": {
"message": "Read Key"
},
"keyCompromised": {
"message": "Key is compromised."
},
"twoFactorU2fGiveName": {
"message": "Give the security key a friendly name to identify it."
},
"twoFactorU2fPlugInReadKey": {
"message": "Plug the security key into your computer's USB port and click the \"Read Key\" button."
}, },
"twoFactorU2fTouchButton": { "twoFactorU2fTouchButton": {
"message": "If the security key has a button, touch it." "message": "If the security key has a button, touch it."
}, },
"twoFactorU2fSaveForm": {
"message": "Save the form."
},
"twoFactorU2fWarning": { "twoFactorU2fWarning": {
"message": "Due to platform limitations, FIDO U2F cannot be used on all Bitwarden applications. You should enable another two-step login provider so that you can access your account when FIDO U2F cannot be used. Supported platforms:" "message": "Due to platform limitations, FIDO U2F cannot be used on all Bitwarden applications. You should enable another two-step login provider so that you can access your account when FIDO U2F cannot be used. Supported platforms:"
}, },
@ -1231,11 +1255,11 @@
"twoFactorU2fWaiting": { "twoFactorU2fWaiting": {
"message": "Waiting for you to touch the button on your security key" "message": "Waiting for you to touch the button on your security key"
}, },
"twoFactorU2fClickEnable": { "twoFactorU2fClickSave": {
"message": "Click the \"Enable\" button below to enable this security key for two-step login." "message": "Click the \"Save\" button below to enable this security key for two-step login."
}, },
"twoFactorU2fProblemReading": { "twoFactorU2fProblemReadingTryAgain": {
"message": "There was a problem reading the security key." "message": "There was a problem reading the security key. Try again."
}, },
"twoFactorRecoveryYourCode": { "twoFactorRecoveryYourCode": {
"message": "Your Bitwarden two-step login recovery code" "message": "Your Bitwarden two-step login recovery code"