mirror of
https://github.com/bitwarden/browser.git
synced 2025-01-03 18:28:13 +01:00
support for setup of multiple u2f keys
This commit is contained in:
parent
c6d6eecb43
commit
4aa75e9376
2
jslib
2
jslib
@ -1 +1 @@
|
|||||||
Subproject commit c3f67dbe26d7d5b30645a2857fd9f316fce7b6bc
|
Subproject commit 4b7962dc8fba73003be1ae651013f5f817496551
|
@ -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"
|
||||||
},
|
},
|
||||||
|
@ -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 {
|
||||||
|
@ -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">
|
||||||
<p>{{'twoFactorU2fAdd' | i18n}}:</p>
|
<li *ngFor="let k of keys; let i = index" #removeKeyBtn [appApiAction]="k.removePromise">
|
||||||
<ol>
|
<i class="fa-li fa fa-key"></i>
|
||||||
<li>{{'twoFactorU2fPlugIn' | i18n}}</li>
|
<strong *ngIf="!k.configured || !k.name">{{'u2fkeyX' | i18n : i + 1}}</strong>
|
||||||
<li>{{'twoFactorU2fTouchButton' | i18n}}</li>
|
<strong *ngIf="k.configured && k.name">{{k.name}}</strong>
|
||||||
</ol>
|
<i class="fa fa-fw" [ngClass]="{'fa-check text-success': !k.compromised, 'fa-exclamation-triangle text-warning': k.compromised}"
|
||||||
<hr>
|
*ngIf="k.configured && !removeKeyBtn.loading" title="{{(k.compromised ? 'keyCompromised' : 'enabled') | i18n}}"></i>
|
||||||
<div class="text-center">
|
<ng-container *ngIf="keysConfiguredCount > 1 && k.configured">
|
||||||
<ng-container *ngIf="u2fListening">
|
<i class="fa fa-spin fa-spinner text-muted fa-fw" title="{{'loading' | i18n}}" *ngIf="removeKeyBtn.loading"></i>
|
||||||
<p>
|
-
|
||||||
<i class="fa fa-spinner fa-spin fa-2x text-muted"></i>
|
<a href="#" appStopClick (click)="remove(k)">{{'remove' | i18n}}</a>
|
||||||
</p>
|
|
||||||
{{'twoFactorU2fWaiting' | i18n}}...
|
|
||||||
</ng-container>
|
|
||||||
<ng-container *ngIf="u2fResponse">
|
|
||||||
<p>
|
|
||||||
<i class="fa fa-check-circle fa-2x text-success"></i>
|
|
||||||
</p>
|
|
||||||
{{'twoFactorU2fClickEnable' | i18n}}
|
|
||||||
</ng-container>
|
|
||||||
<ng-container *ngIf="u2fError">
|
|
||||||
<p>
|
|
||||||
<i class="fa fa-warning fa-2x text-danger"></i>
|
|
||||||
</p>
|
|
||||||
{{'twoFactorU2fProblemReading' | i18n}}
|
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<hr>
|
||||||
|
<p>{{'twoFactorU2fAdd' | i18n}}:</p>
|
||||||
|
<ol>
|
||||||
|
<li>{{'twoFactorU2fGiveName' | i18n}}</li>
|
||||||
|
<li>{{'twoFactorU2fPlugInReadKey' | i18n}}</li>
|
||||||
|
<li>{{'twoFactorU2fTouchButton' | i18n}}</li>
|
||||||
|
<li>{{'twoFactorU2fSaveForm' | i18n}}</li>
|
||||||
|
</ol>
|
||||||
|
<div class="row">
|
||||||
|
<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>
|
||||||
|
</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">
|
||||||
|
<i class="fa fa-spinner fa-spin text-muted"></i>
|
||||||
|
{{'twoFactorU2fWaiting' | i18n}}...
|
||||||
|
</ng-container>
|
||||||
|
<ng-container *ngIf="u2fResponse">
|
||||||
|
<i class="fa fa-check-circle text-success"></i>
|
||||||
|
{{'twoFactorU2fClickSave' | i18n}}
|
||||||
|
</ng-container>
|
||||||
|
<ng-container *ngIf="u2fError">
|
||||||
|
<i class="fa fa-warning text-danger"></i>
|
||||||
|
{{'twoFactorU2fProblemReadingTryAgain' | i18n}}
|
||||||
|
</ng-container>
|
||||||
</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>
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(key: any) {
|
||||||
|
if (this.keysConfiguredCount <= 1 || key.removePromise != null) {
|
||||||
return;
|
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);
|
||||||
|
(window as any).u2f.register(u2fChallenge.appId, [{
|
||||||
|
version: u2fChallenge.version,
|
||||||
|
challenge: u2fChallenge.challenge,
|
||||||
|
}], [], (data: any) => {
|
||||||
|
this.ngZone.run(() => {
|
||||||
|
this.u2fListening = false;
|
||||||
|
if (data.errorCode) {
|
||||||
|
this.u2fError = true;
|
||||||
|
// tslint:disable-next-line
|
||||||
|
console.log('error: ' + data.errorCode);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.u2fResponse = JSON.stringify(data);
|
||||||
|
});
|
||||||
|
}, 15);
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetU2f(listening = false) {
|
||||||
this.u2fResponse = null;
|
this.u2fResponse = null;
|
||||||
this.u2fError = false;
|
this.u2fError = false;
|
||||||
this.u2fListening = true;
|
this.u2fListening = listening;
|
||||||
|
|
||||||
(window as any).u2f.register(this.u2fChallenge.appId, [{
|
|
||||||
version: this.u2fChallenge.version,
|
|
||||||
challenge: this.u2fChallenge.challenge,
|
|
||||||
}], [], (data: any) => {
|
|
||||||
this.u2fListening = false;
|
|
||||||
if (data.errorCode === 5) {
|
|
||||||
this.readDevice();
|
|
||||||
return;
|
|
||||||
} else if (data.errorCode) {
|
|
||||||
this.u2fError = true;
|
|
||||||
// tslint:disable-next-line
|
|
||||||
console.log('error: ' + data.errorCode);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.u2fResponse = JSON.stringify(data);
|
|
||||||
}, 10);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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"
|
||||||
|
Loading…
Reference in New Issue
Block a user