Password reprompt (#1784)

* Add support for password reprompt

* Rename passwordPrompt to reprompt.

* Move showPasswordDialog to paltformutils

* Fix swal2 validation error styling

* Group imports

* Update src/_locales/en/messages.json

Co-authored-by: Matt Gibson <mgibson@bitwarden.com>
This commit is contained in:
Oscar Hinton 2021-05-03 20:56:38 +02:00 committed by GitHub
parent 2c58dbb344
commit cdc71dd661
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 179 additions and 30 deletions

2
jslib

@ -1 +1 @@
Subproject commit b6f102938fe7c17631cb1b2e356438c5e4456529
Subproject commit a72c8a60c1b7a6980bceee456c53a9ea7b9b3451

View File

@ -1703,5 +1703,14 @@
},
"sendOptionsPolicyInEffect": {
"message": "One or more organization policies are affecting your Send options."
},
"passwordPrompt": {
"message": "Master password re-prompt"
},
"passwordConfirmation": {
"message": "Master password confirmation"
},
"passwordConfirmationDesc": {
"message": "This action is protected. To continue, please re-enter your master password to verify your identity."
}
}

View File

@ -1,4 +1,5 @@
import { CipherType } from 'jslib/enums';
import { CipherRepromptType } from 'jslib/enums/cipherRepromptType';
import {
ApiService,
@ -63,6 +64,7 @@ import { PolicyService as PolicyServiceAbstraction } from 'jslib/abstractions/po
import { SearchService as SearchServiceAbstraction } from 'jslib/abstractions/search.service';
import { SendService as SendServiceAbstraction } from 'jslib/abstractions/send.service';
import { SystemService as SystemServiceAbstraction } from 'jslib/abstractions/system.service';
import { AutofillService as AutofillServiceAbstraction } from '../services/abstractions/autofill.service';
import { Utils } from 'jslib/misc/utils';
@ -86,8 +88,6 @@ import BrowserStorageService from '../services/browserStorage.service';
import I18nService from '../services/i18n.service';
import VaultTimeoutService from '../services/vaultTimeout.service';
import { AutofillService as AutofillServiceAbstraction } from '../services/abstractions/autofill.service';
export default class MainBackground {
messagingService: MessagingServiceAbstraction;
storageService: StorageServiceAbstraction;
@ -583,7 +583,7 @@ export default class MainBackground {
}
private async loadLoginContextMenuOptions(cipher: any) {
if (cipher == null || cipher.type !== CipherType.Login) {
if (cipher == null || cipher.type !== CipherType.Login || cipher.reprompt !== CipherRepromptType.None) {
return;
}

View File

@ -33,6 +33,7 @@ import { StorageService } from 'jslib/abstractions/storage.service';
import { ConstantsService } from 'jslib/services/constants.service';
import BrowserPlatformUtilsService from 'src/services/browserPlatformUtils.service';
import { routerTransition } from './app-routing.animations';
@Component({
@ -105,6 +106,8 @@ export class AppComponent implements OnInit {
});
} else if (msg.command === 'showDialog') {
await this.showDialog(msg);
} else if (msg.command === 'showPasswordDialog') {
await this.showPasswordDialog(msg);
} else if (msg.command === 'showToast') {
this.ngZone.run(() => {
this.showToast(msg);
@ -248,4 +251,30 @@ export class AppComponent implements OnInit {
confirmed: confirmed.value,
});
}
private async showPasswordDialog(msg: any) {
const platformUtils = this.platformUtilsService as BrowserPlatformUtilsService;
const result = await Swal.fire({
heightAuto: false,
title: msg.title,
input: 'password',
text: msg.body,
confirmButtonText: this.i18nService.t('ok'),
showCancelButton: true,
cancelButtonText: this.i18nService.t('cancel'),
inputAttributes: {
autocapitalize: 'off',
autocorrect: 'off',
},
inputValidator: async (value: string): Promise<any> => {
if (await platformUtils.resolvePasswordDialogPromise(msg.dialogId, false, value)) {
return false;
}
return this.i18nService.t('invalidMasterPassword');
},
});
platformUtils.resolvePasswordDialogPromise(msg.dialogId, true, null);
}
}

View File

@ -14,12 +14,11 @@ import { CipherView } from 'jslib/models/view/cipherView';
import { EventService } from 'jslib/abstractions/event.service';
import { I18nService } from 'jslib/abstractions/i18n.service';
import { PasswordRepromptService } from 'jslib/abstractions/passwordReprompt.service';
import { PlatformUtilsService } from 'jslib/abstractions/platformUtils.service';
import { TotpService } from 'jslib/abstractions/totp.service';
import { UserService } from 'jslib/abstractions/user.service';
import { PopupUtilsService } from '../services/popup-utils.service';
@Component({
selector: 'app-action-buttons',
templateUrl: 'action-buttons.component.html',
@ -35,7 +34,8 @@ export class ActionButtonsComponent {
constructor(private toasterService: ToasterService, private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService, private eventService: EventService,
private totpService: TotpService, private userService: UserService) { }
private totpService: TotpService, private userService: UserService,
private passwordRepromptService: PasswordRepromptService) { }
async ngOnInit() {
this.userHasPremiumAccess = await this.userService.canAccessPremium();
@ -46,6 +46,10 @@ export class ActionButtonsComponent {
}
async copy(cipher: CipherView, value: string, typeI18nKey: string, aType: string) {
if (this.passwordRepromptService.protectedFields().includes(aType) && !await this.passwordRepromptService.showPasswordPrompt()) {
return;
}
if (value == null || aType === 'TOTP' && !this.displayTotpCopyButton(cipher)) {
return;
} else if (value === cipher.login.totp) {

View File

@ -211,6 +211,10 @@ $fa-font-path: "~font-awesome/fonts";
}
}
}
.swal2-validation-message {
margin-top: 20px;
}
}
date-input-polyfill {

View File

@ -16,6 +16,7 @@ import { MessagingService } from 'jslib/abstractions/messaging.service';
import { PlatformUtilsService } from 'jslib/abstractions/platformUtils.service';
import { PolicyService } from 'jslib/abstractions/policy.service';
import { SendService } from 'jslib/abstractions/send.service';
import { TokenService } from 'jslib/abstractions/token.service';
import { UserService } from 'jslib/abstractions/user.service';
import { PopupUtilsService } from '../services/popup-utils.service';

View File

@ -31,6 +31,7 @@ import { I18nService } from 'jslib/abstractions/i18n.service';
import { MessagingService } from 'jslib/abstractions/messaging.service';
import { NotificationsService } from 'jslib/abstractions/notifications.service';
import { PasswordGenerationService } from 'jslib/abstractions/passwordGeneration.service';
import { PasswordRepromptService as PasswordRepromptServiceAbstraction } from 'jslib/abstractions/passwordReprompt.service';
import { PlatformUtilsService } from 'jslib/abstractions/platformUtils.service';
import { PolicyService } from 'jslib/abstractions/policy.service';
import { SearchService as SearchServiceAbstraction } from 'jslib/abstractions/search.service';
@ -43,6 +44,7 @@ import { TokenService } from 'jslib/abstractions/token.service';
import { TotpService } from 'jslib/abstractions/totp.service';
import { UserService } from 'jslib/abstractions/user.service';
import { VaultTimeoutService } from 'jslib/abstractions/vaultTimeout.service';
import { PasswordRepromptService } from 'jslib/services/passwordReprompt.service';
import { AutofillService } from '../../services/abstractions/autofill.service';
import BrowserMessagingService from '../../services/browserMessaging.service';
@ -63,11 +65,13 @@ function getBgService<T>(service: string) {
};
}
export const stateService = new StateService();
export const messagingService = new BrowserMessagingService();
export const searchService = new PopupSearchService(getBgService<SearchService>('searchService')(),
const stateService = new StateService();
const messagingService = new BrowserMessagingService();
const searchService = new PopupSearchService(getBgService<SearchService>('searchService')(),
getBgService<CipherService>('cipherService')(), getBgService<ConsoleLogService>('consoleLogService')(),
getBgService<I18nService>('i18nService')());
const passwordRepromptService = new PasswordRepromptService(getBgService<I18nService>('i18nService')(),
getBgService<CryptoService>('cryptoService')(), getBgService<PlatformUtilsService>('platformUtilsService')());
export function initFactory(platformUtilsService: PlatformUtilsService, i18nService: I18nService, storageService: StorageService,
popupUtilsService: PopupUtilsService): Function {
@ -174,6 +178,7 @@ export function initFactory(platformUtilsService: PlatformUtilsService, i18nServ
useFactory: () => getBgService<I18nService>('i18nService')().translationLocale,
deps: [],
},
{ provide: PasswordRepromptServiceAbstraction, useValue: passwordRepromptService },
],
})
export class ServicesModule {

View File

@ -83,10 +83,19 @@
<input id="cardCardholderName" type="text" name="Card.CardCardholderName"
[(ngModel)]="cipher.card.cardholderName">
</div>
<div class="box-content-row" appBoxRow>
<label for="cardNumber">{{'number' | i18n}}</label>
<input id="cardNumber" type="text" name="Card.Number" [(ngModel)]="cipher.card.number"
appInputVerbatim>
<div class="box-content-row box-content-row-flex" appBoxRow>
<div class="row-main">
<label for="cardNumber">{{'number' | i18n}}</label>
<input id="cardNumber" class="monospaced" type="{{showCardNumber ? 'text' : 'password'}}"
name="Card.Number" [(ngModel)]="cipher.card.number" appInputVerbatim>
</div>
<div class="action-buttons">
<a class="row-btn" href="#" appStopClick appBlurClick
appA11yTitle="{{'toggleVisibility' | i18n}}" (click)="toggleCardNumber()">
<i class="fa fa-lg" aria-hidden="true"
[ngClass]="{'fa-eye': !showCardNumber, 'fa-eye-slash': showCardNumber}"></i>
</a>
</div>
</div>
<div class="box-content-row" appBoxRow>
<label for="cardBrand">{{'brand' | i18n}}</label>
@ -271,6 +280,11 @@
<label for="favorite">{{'favorite' | i18n}}</label>
<input id="favorite" type="checkbox" name="Favorite" [(ngModel)]="cipher.favorite">
</div>
<div class="box-content-row box-content-row-checkbox" appBoxRow>
<label for="passwordPrompt">{{'passwordPrompt' | i18n}}</label>
<input id="passwordPrompt" type="checkbox" name="PasswordPrompt" [ngModel]="reprompt"
(change)="repromptChanged()">
</div>
<a class="box-content-row box-content-row-flex text-default" href="#" appStopClick appBlurClick
(click)="attachments()" *ngIf="editMode && showAttachments && !cloneMode">
<div class="row-main">{{'attachments' | i18n}}</div>

View File

@ -14,12 +14,14 @@ import { BrowserApi } from '../../browser/browserApi';
import { BroadcasterService } from 'jslib/angular/services/broadcaster.service';
import { CipherRepromptType } from 'jslib/enums/cipherRepromptType';
import { CipherType } from 'jslib/enums/cipherType';
import { CipherView } from 'jslib/models/view/cipherView';
import { CipherService } from 'jslib/abstractions/cipher.service';
import { I18nService } from 'jslib/abstractions/i18n.service';
import { PasswordRepromptService } from 'jslib/abstractions/passwordReprompt.service';
import { PlatformUtilsService } from 'jslib/abstractions/platformUtils.service';
import { SearchService } from 'jslib/abstractions/search.service';
import { StorageService } from 'jslib/abstractions/storage.service';
@ -61,7 +63,8 @@ export class CurrentTabComponent implements OnInit, OnDestroy {
private toasterService: ToasterService, private i18nService: I18nService, private router: Router,
private ngZone: NgZone, private broadcasterService: BroadcasterService,
private changeDetectorRef: ChangeDetectorRef, private syncService: SyncService,
private searchService: SearchService, private storageService: StorageService) {
private searchService: SearchService, private storageService: StorageService,
private passwordRepromptService: PasswordRepromptService) {
}
async ngOnInit() {
@ -128,6 +131,10 @@ export class CurrentTabComponent implements OnInit, OnDestroy {
}
async fillCipher(cipher: CipherView) {
if (cipher.reprompt !== CipherRepromptType.None && !await this.passwordRepromptService.showPasswordPrompt()) {
return;
}
this.totpCode = null;
if (this.totpTimeout != null) {
window.clearTimeout(this.totpTimeout);

View File

@ -99,11 +99,17 @@
<div class="box-content-row box-content-row-flex" *ngIf="cipher.card.number">
<div class="row-main">
<span class="row-label">{{'number' | i18n}}</span>
{{cipher.card.number}}
<span [hidden]="showCardNumber" class="monospaced">{{cipher.card.maskedNumber}}</span>
<span [hidden]="!showCardNumber" class="monospaced">{{cipher.card.number}}</span>
</div>
<div class="action-buttons">
<a class="row-btn" href="#" appStopClick appA11yTitle="{{'toggleVisibility' | i18n}}"
(click)="toggleCardNumber()">
<i class="fa fa-lg" aria-hidden="true"
[ngClass]="{'fa-eye': !showCardNumber, 'fa-eye-slash': showCardNumber}"></i>
</a>
<a class="row-btn" href="#" appStopClick appA11yTitle="{{'copyNumber' | i18n}}"
(click)="copy(cipher.card.number, 'number', 'Number')">
(click)="copy(cipher.card.number, 'number', 'Card Number')">
<i class="fa fa-lg fa-clone" aria-hidden="true"></i>
</a>
</div>

View File

@ -16,6 +16,7 @@ import { CryptoService } from 'jslib/abstractions/crypto.service';
import { EventService } from 'jslib/abstractions/event.service';
import { I18nService } from 'jslib/abstractions/i18n.service';
import { MessagingService } from 'jslib/abstractions/messaging.service';
import { PasswordRepromptService } from 'jslib/abstractions/passwordReprompt.service';
import { PlatformUtilsService } from 'jslib/abstractions/platformUtils.service';
import { TokenService } from 'jslib/abstractions/token.service';
import { TotpService } from 'jslib/abstractions/totp.service';
@ -53,10 +54,12 @@ export class ViewComponent extends BaseViewComponent {
private router: Router, private location: Location,
broadcasterService: BroadcasterService, ngZone: NgZone,
changeDetectorRef: ChangeDetectorRef, userService: UserService,
eventService: EventService, private autofillService: AutofillService, apiService: ApiService,
private messagingService: MessagingService, private popupUtilsService: PopupUtilsService) {
eventService: EventService, private autofillService: AutofillService,
private messagingService: MessagingService, private popupUtilsService: PopupUtilsService,
apiService: ApiService, passwordRepromptService: PasswordRepromptService) {
super(cipherService, totpService, tokenService, i18nService, cryptoService, platformUtilsService,
auditService, window, broadcasterService, ngZone, changeDetectorRef, userService, eventService, apiService);
auditService, window, broadcasterService, ngZone, changeDetectorRef, userService, eventService,
apiService, passwordRepromptService);
}
ngOnInit() {
@ -112,32 +115,45 @@ export class ViewComponent extends BaseViewComponent {
await this.loadPageDetails();
}
edit() {
async edit() {
if (this.cipher.isDeleted) {
return false;
}
super.edit();
if (!await super.edit()) {
return false;
}
this.router.navigate(['/edit-cipher'], { queryParams: { cipherId: this.cipher.id } });
return true;
}
clone() {
async clone() {
if (this.cipher.isDeleted) {
return false;
}
super.clone();
if (!await super.clone()) {
return false;
}
this.router.navigate(['/clone-cipher'], {
queryParams: {
cloneMode: true,
cipherId: this.cipher.id,
},
});
return true;
}
share() {
super.share();
async share() {
if (!await super.share()) {
return false;
}
if (this.cipher.organizationId == null) {
this.router.navigate(['/share-cipher'], { replaceUrl: true, queryParams: { cipherId: this.cipher.id } });
}
return true;
}
async fillCipher() {
@ -220,6 +236,10 @@ export class ViewComponent extends BaseViewComponent {
}
private async doAutofill() {
if (!await this.promptPassword()) {
return false;
}
if (this.pageDetails == null || this.pageDetails.length === 0) {
this.platformUtilsService.showToast('error', null,
this.i18nService.t('autofillError'));

View File

@ -20,6 +20,7 @@ import {
} from 'jslib/abstractions';
import { EventService } from 'jslib/abstractions/event.service';
import { CipherRepromptType } from 'jslib/enums/cipherRepromptType';
import { EventType } from 'jslib/enums/eventType';
const CardAttributes: string[] = ['autoCompleteType', 'data-stripe', 'htmlName', 'htmlID', 'label-tag',
@ -254,6 +255,10 @@ export default class AutofillService implements AutofillServiceInterface {
}
}
if (cipher.reprompt !== CipherRepromptType.None) {
return;
}
const totpCode = await this.doAutoFill({
cipher: cipher,
pageDetails: pageDetails,

View File

@ -12,6 +12,7 @@ export default class BrowserPlatformUtilsService implements PlatformUtilsService
identityClientId: string = 'browser';
private showDialogResolves = new Map<number, { resolve: (value: boolean) => void, date: Date }>();
private passwordDialogResolves = new Map<number, { tryResolve: (canceled: boolean, password: string) => Promise<boolean>, date: Date }>();
private deviceCache: DeviceType = null;
private prefersColorSchemeDark = window.matchMedia('(prefers-color-scheme: dark)');
@ -149,6 +150,33 @@ export default class BrowserPlatformUtilsService implements PlatformUtilsService
});
}
async showPasswordDialog(title: string, body: string, passwordValidation: (value: string) => Promise<boolean>) {
const dialogId = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER);
this.messagingService.send('showPasswordDialog', {
title: title,
body: body,
dialogId: dialogId,
});
return new Promise<boolean>(resolve => {
this.passwordDialogResolves.set(dialogId, {
tryResolve: async (canceled: boolean, password: string) => {
if (canceled) {
resolve(false);
return false;
}
if (await passwordValidation(password)) {
resolve(true);
return true;
}
},
date: new Date(),
});
});
}
isDev(): boolean {
return process.env.ENV === 'development';
}
@ -256,16 +284,33 @@ export default class BrowserPlatformUtilsService implements PlatformUtilsService
}
// Clean up old promises
const deleteIds: number[] = [];
this.showDialogResolves.forEach((val, key) => {
const age = new Date().getTime() - val.date.getTime();
if (age > DialogPromiseExpiration) {
deleteIds.push(key);
this.showDialogResolves.delete(key);
}
});
deleteIds.forEach(id => {
this.showDialogResolves.delete(id);
}
async resolvePasswordDialogPromise(dialogId: number, canceled: boolean, password: string): Promise<boolean> {
let result = false;
if (this.passwordDialogResolves.has(dialogId)) {
const resolveObj = this.passwordDialogResolves.get(dialogId);
if (await resolveObj.tryResolve(canceled, password)) {
this.passwordDialogResolves.delete(dialogId);
result = true;
}
}
// Clean up old promises
this.passwordDialogResolves.forEach((val, key) => {
const age = new Date().getTime() - val.date.getTime();
if (age > DialogPromiseExpiration) {
this.passwordDialogResolves.delete(key);
}
});
return result;
}
async supportsBiometric() {