Password reprompt (#343)

Add support for password reprompt on cipher items
This commit is contained in:
Oscar Hinton 2021-04-15 16:14:33 +02:00 committed by GitHub
parent 66eec2b022
commit 372e139810
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 166 additions and 15 deletions

9
package-lock.json generated
View File

@ -1163,7 +1163,8 @@
}, },
"kind-of": { "kind-of": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz",
"integrity": "sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==",
"dev": true "dev": true
} }
} }
@ -3765,7 +3766,8 @@
}, },
"kind-of": { "kind-of": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz",
"integrity": "sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==",
"dev": true "dev": true
} }
} }
@ -8330,7 +8332,8 @@
}, },
"kind-of": { "kind-of": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz",
"integrity": "sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==",
"dev": true "dev": true
} }
} }

View File

@ -0,0 +1,4 @@
export abstract class PasswordRepromptService {
protectedFields: () => string[];
showPasswordPrompt: () => Promise<boolean>;
}

View File

@ -26,6 +26,7 @@ export abstract class PlatformUtilsService {
options?: any) => void; options?: any) => void;
showDialog: (body: string, title?: string, confirmText?: string, cancelText?: string, showDialog: (body: string, title?: string, confirmText?: string, cancelText?: string,
type?: string, bodyIsHtml?: boolean) => Promise<boolean>; type?: string, bodyIsHtml?: boolean) => Promise<boolean>;
showPasswordDialog: (title: string, body: string, passwordValidation: (value: string) => Promise<boolean>) => Promise<boolean>;
isDev: () => boolean; isDev: () => boolean;
isSelfHost: () => boolean; isSelfHost: () => boolean;
copyToClipboard: (text: string, options?: any) => void; copyToClipboard: (text: string, options?: any) => void;

View File

@ -42,6 +42,7 @@ import { LoginUriView } from '../../models/view/loginUriView';
import { LoginView } from '../../models/view/loginView'; import { LoginView } from '../../models/view/loginView';
import { SecureNoteView } from '../../models/view/secureNoteView'; import { SecureNoteView } from '../../models/view/secureNoteView';
import { CipherRepromptType } from '../../enums/cipherRepromptType';
import { Utils } from '../../misc/utils'; import { Utils } from '../../misc/utils';
@Directive() @Directive()
@ -71,6 +72,7 @@ export class AddEditComponent implements OnInit {
restorePromise: Promise<any>; restorePromise: Promise<any>;
checkPasswordPromise: Promise<number>; checkPasswordPromise: Promise<number>;
showPassword: boolean = false; showPassword: boolean = false;
showCardNumber: boolean = false;
showCardCode: boolean = false; showCardCode: boolean = false;
cipherType = CipherType; cipherType = CipherType;
fieldType = FieldType; fieldType = FieldType;
@ -84,6 +86,7 @@ export class AddEditComponent implements OnInit {
ownershipOptions: any[] = []; ownershipOptions: any[] = [];
currentDate = new Date(); currentDate = new Date();
allowPersonal = true; allowPersonal = true;
reprompt: boolean = false;
protected writeableCollections: CollectionView[]; protected writeableCollections: CollectionView[];
private previousCipherId: string; private previousCipherId: string;
@ -245,6 +248,7 @@ export class AddEditComponent implements OnInit {
this.eventService.collect(EventType.Cipher_ClientViewed, this.cipherId); this.eventService.collect(EventType.Cipher_ClientViewed, this.cipherId);
} }
this.previousCipherId = this.cipherId; this.previousCipherId = this.cipherId;
this.reprompt = this.cipher.reprompt !== CipherRepromptType.None;
} }
async submit(): Promise<boolean> { async submit(): Promise<boolean> {
@ -422,6 +426,13 @@ export class AddEditComponent implements OnInit {
} }
} }
async toggleCardNumber() {
this.showCardNumber = !this.showCardNumber;
if (this.showCardNumber) {
this.eventService.collect(EventType.Cipher_ClientToggledCardCodeVisible, this.cipherId);
}
}
toggleCardCode() { toggleCardCode() {
this.showCardCode = !this.showCardCode; this.showCardCode = !this.showCardCode;
document.getElementById('cardCode').focus(); document.getElementById('cardCode').focus();
@ -488,6 +499,15 @@ export class AddEditComponent implements OnInit {
} }
} }
repromptChanged() {
this.reprompt = !this.reprompt;
if (this.reprompt) {
this.cipher.reprompt = CipherRepromptType.Password;
} else {
this.cipher.reprompt = CipherRepromptType.None;
}
}
protected async loadCollections() { protected async loadCollections() {
const allCollections = await this.collectionService.getAllDecrypted(); const allCollections = await this.collectionService.getAllDecrypted();
return allCollections.filter(c => !c.readOnly); return allCollections.filter(c => !c.readOnly);

View File

@ -23,6 +23,8 @@ import { TokenService } from '../../abstractions/token.service';
import { TotpService } from '../../abstractions/totp.service'; import { TotpService } from '../../abstractions/totp.service';
import { UserService } from '../../abstractions/user.service'; import { UserService } from '../../abstractions/user.service';
import { PasswordRepromptService } from '../../abstractions/passwordReprompt.service';
import { CipherRepromptType } from '../../enums/cipherRepromptType';
import { AttachmentView } from '../../models/view/attachmentView'; import { AttachmentView } from '../../models/view/attachmentView';
import { CipherView } from '../../models/view/cipherView'; import { CipherView } from '../../models/view/cipherView';
import { FieldView } from '../../models/view/fieldView'; import { FieldView } from '../../models/view/fieldView';
@ -42,6 +44,7 @@ export class ViewComponent implements OnDestroy, OnInit {
cipher: CipherView; cipher: CipherView;
showPassword: boolean; showPassword: boolean;
showCardNumber: boolean;
showCardCode: boolean; showCardCode: boolean;
canAccessPremium: boolean; canAccessPremium: boolean;
totpCode: string; totpCode: string;
@ -54,6 +57,7 @@ export class ViewComponent implements OnDestroy, OnInit {
private totpInterval: any; private totpInterval: any;
private previousCipherId: string; private previousCipherId: string;
private passwordReprompted: boolean = false;
constructor(protected cipherService: CipherService, protected totpService: TotpService, constructor(protected cipherService: CipherService, protected totpService: TotpService,
protected tokenService: TokenService, protected i18nService: I18nService, protected tokenService: TokenService, protected i18nService: I18nService,
@ -61,7 +65,7 @@ export class ViewComponent implements OnDestroy, OnInit {
protected auditService: AuditService, protected win: Window, protected auditService: AuditService, protected win: Window,
protected broadcasterService: BroadcasterService, protected ngZone: NgZone, protected broadcasterService: BroadcasterService, protected ngZone: NgZone,
protected changeDetectorRef: ChangeDetectorRef, protected userService: UserService, protected changeDetectorRef: ChangeDetectorRef, protected userService: UserService,
protected eventService: EventService) { } protected eventService: EventService, protected passwordRepromptService: PasswordRepromptService) { }
ngOnInit() { ngOnInit() {
this.broadcasterService.subscribe(BroadcasterSubscriptionId, (message: any) => { this.broadcasterService.subscribe(BroadcasterSubscriptionId, (message: any) => {
@ -107,19 +111,38 @@ export class ViewComponent implements OnDestroy, OnInit {
this.previousCipherId = this.cipherId; this.previousCipherId = this.cipherId;
} }
edit() { async edit() {
this.onEditCipher.emit(this.cipher); if (await this.promptPassword()) {
this.onEditCipher.emit(this.cipher);
return true;
}
return false;
} }
clone() { async clone() {
this.onCloneCipher.emit(this.cipher); if (await this.promptPassword()) {
this.onCloneCipher.emit(this.cipher);
return true;
}
return false;
} }
share() { async share() {
this.onShareCipher.emit(this.cipher); if (await this.promptPassword()) {
this.onShareCipher.emit(this.cipher);
return true;
}
return false;
} }
async delete(): Promise<boolean> { async delete(): Promise<boolean> {
if (!await this.promptPassword()) {
return;
}
const confirmed = await this.platformUtilsService.showDialog( const confirmed = await this.platformUtilsService.showDialog(
this.i18nService.t(this.cipher.isDeleted ? 'permanentlyDeleteItemConfirmation' : 'deleteItemConfirmation'), this.i18nService.t(this.cipher.isDeleted ? 'permanentlyDeleteItemConfirmation' : 'deleteItemConfirmation'),
this.i18nService.t('deleteItem'), this.i18nService.t('yes'), this.i18nService.t('no'), 'warning'); this.i18nService.t('deleteItem'), this.i18nService.t('yes'), this.i18nService.t('no'), 'warning');
@ -158,14 +181,33 @@ export class ViewComponent implements OnDestroy, OnInit {
return true; return true;
} }
togglePassword() { async togglePassword() {
if (!await this.promptPassword()) {
return;
}
this.showPassword = !this.showPassword; this.showPassword = !this.showPassword;
if (this.showPassword) { if (this.showPassword) {
this.eventService.collect(EventType.Cipher_ClientToggledPasswordVisible, this.cipherId); this.eventService.collect(EventType.Cipher_ClientToggledPasswordVisible, this.cipherId);
} }
} }
toggleCardCode() { async toggleCardNumber() {
if (!await this.promptPassword()) {
return;
}
this.showCardNumber = !this.showCardNumber;
if (this.showCardNumber) {
this.eventService.collect(EventType.Cipher_ClientToggledCardCodeVisible, this.cipherId);
}
}
async toggleCardCode() {
if (!await this.promptPassword()) {
return;
}
this.showCardCode = !this.showCardCode; this.showCardCode = !this.showCardCode;
if (this.showCardCode) { if (this.showCardCode) {
this.eventService.collect(EventType.Cipher_ClientToggledCardCodeVisible, this.cipherId); this.eventService.collect(EventType.Cipher_ClientToggledCardCodeVisible, this.cipherId);
@ -188,7 +230,11 @@ export class ViewComponent implements OnDestroy, OnInit {
} }
} }
toggleFieldValue(field: FieldView) { async toggleFieldValue(field: FieldView) {
if (!await this.promptPassword()) {
return;
}
const f = (field as any); const f = (field as any);
f.showValue = !f.showValue; f.showValue = !f.showValue;
if (f.showValue) { if (f.showValue) {
@ -208,11 +254,15 @@ export class ViewComponent implements OnDestroy, OnInit {
this.platformUtilsService.launchUri(uri.launchUri); this.platformUtilsService.launchUri(uri.launchUri);
} }
copy(value: string, typeI18nKey: string, aType: string) { async copy(value: string, typeI18nKey: string, aType: string) {
if (value == null) { if (value == null) {
return; return;
} }
if (this.passwordRepromptService.protectedFields().includes(aType) && !await this.promptPassword()) {
return;
}
const copyOptions = this.win != null ? { window: this.win } : null; const copyOptions = this.win != null ? { window: this.win } : null;
this.platformUtilsService.copyToClipboard(value, copyOptions); this.platformUtilsService.copyToClipboard(value, copyOptions);
this.platformUtilsService.showToast('info', null, this.platformUtilsService.showToast('info', null,
@ -273,6 +323,14 @@ export class ViewComponent implements OnDestroy, OnInit {
return this.cipherService.restoreWithServer(this.cipher.id); return this.cipherService.restoreWithServer(this.cipher.id);
} }
protected async promptPassword() {
if (this.cipher.reprompt === CipherRepromptType.None || this.passwordReprompted) {
return true;
}
return this.passwordReprompted = await this.passwordRepromptService.showPasswordPrompt();
}
private cleanUp() { private cleanUp() {
this.totpCode = null; this.totpCode = null;
this.cipher = null; this.cipher = null;

View File

@ -114,6 +114,11 @@ export class CliPlatformUtilsService implements PlatformUtilsService {
throw new Error('Not implemented.'); throw new Error('Not implemented.');
} }
showPasswordDialog(title: string, body: string, passwordValidation: (value: string) => Promise<boolean>):
Promise<boolean> {
throw new Error('Not implemented.');
}
isDev(): boolean { isDev(): boolean {
return process.env.BWCLI_ENV === 'development'; return process.env.BWCLI_ENV === 'development';
} }

View File

@ -23,7 +23,7 @@ export class ElectronPlatformUtilsService implements PlatformUtilsService {
private deviceCache: DeviceType = null; private deviceCache: DeviceType = null;
constructor(private i18nService: I18nService, private messagingService: MessagingService, constructor(protected i18nService: I18nService, private messagingService: MessagingService,
private isDesktopApp: boolean, private storageService: StorageService) { private isDesktopApp: boolean, private storageService: StorageService) {
this.identityClientId = isDesktopApp ? 'desktop' : 'connector'; this.identityClientId = isDesktopApp ? 'desktop' : 'connector';
} }
@ -148,6 +148,11 @@ export class ElectronPlatformUtilsService implements PlatformUtilsService {
return Promise.resolve(result.response === 0); return Promise.resolve(result.response === 0);
} }
async showPasswordDialog(title: string, body: string, passwordValidation: (value: string) => Promise<boolean>):
Promise<boolean> {
throw new Error('Not implemented.');
}
isDev(): boolean { isDev(): boolean {
return isDev(); return isDev();
} }

View File

@ -0,0 +1,4 @@
export enum CipherRepromptType {
None = 0,
Password = 1,
}

View File

@ -25,6 +25,7 @@ export enum EventType {
Cipher_ClientAutofilled = 1114, Cipher_ClientAutofilled = 1114,
Cipher_SoftDeleted = 1115, Cipher_SoftDeleted = 1115,
Cipher_Restored = 1116, Cipher_Restored = 1116,
Cipher_ClientToggledCardNumberVisible = 1117,
Collection_Created = 1300, Collection_Created = 1300,
Collection_Updated = 1301, Collection_Updated = 1301,

View File

@ -1,3 +1,4 @@
import { CipherRepromptType } from '../../enums/cipherRepromptType';
import { CipherType } from '../../enums/cipherType'; import { CipherType } from '../../enums/cipherType';
import { AttachmentData } from './attachmentData'; import { AttachmentData } from './attachmentData';
@ -33,6 +34,7 @@ export class CipherData {
passwordHistory?: PasswordHistoryData[]; passwordHistory?: PasswordHistoryData[];
collectionIds?: string[]; collectionIds?: string[];
deletedDate: string; deletedDate: string;
reprompt: CipherRepromptType;
constructor(response?: CipherResponse, userId?: string, collectionIds?: string[]) { constructor(response?: CipherResponse, userId?: string, collectionIds?: string[]) {
if (response == null) { if (response == null) {
@ -53,6 +55,7 @@ export class CipherData {
this.notes = response.notes; this.notes = response.notes;
this.collectionIds = collectionIds != null ? collectionIds : response.collectionIds; this.collectionIds = collectionIds != null ? collectionIds : response.collectionIds;
this.deletedDate = response.deletedDate; this.deletedDate = response.deletedDate;
this.reprompt = response.reprompt;
switch (this.type) { switch (this.type) {
case CipherType.Login: case CipherType.Login:

View File

@ -1,3 +1,4 @@
import { CipherRepromptType } from '../../enums/cipherRepromptType';
import { CipherType } from '../../enums/cipherType'; import { CipherType } from '../../enums/cipherType';
import { CipherData } from '../data/cipherData'; import { CipherData } from '../data/cipherData';
@ -37,6 +38,7 @@ export class Cipher extends Domain {
passwordHistory: Password[]; passwordHistory: Password[];
collectionIds: string[]; collectionIds: string[];
deletedDate: Date; deletedDate: Date;
reprompt: CipherRepromptType;
constructor(obj?: CipherData, alreadyEncrypted: boolean = false, localData: any = null) { constructor(obj?: CipherData, alreadyEncrypted: boolean = false, localData: any = null) {
super(); super();
@ -66,6 +68,7 @@ export class Cipher extends Domain {
this.collectionIds = obj.collectionIds; this.collectionIds = obj.collectionIds;
this.localData = localData; this.localData = localData;
this.deletedDate = obj.deletedDate != null ? new Date(obj.deletedDate) : null; this.deletedDate = obj.deletedDate != null ? new Date(obj.deletedDate) : null;
this.reprompt = obj.reprompt;
switch (this.type) { switch (this.type) {
case CipherType.Login: case CipherType.Login:
@ -183,6 +186,7 @@ export class Cipher extends Domain {
c.type = this.type; c.type = this.type;
c.collectionIds = this.collectionIds; c.collectionIds = this.collectionIds;
c.deletedDate = this.deletedDate != null ? this.deletedDate.toISOString() : null; c.deletedDate = this.deletedDate != null ? this.deletedDate.toISOString() : null;
c.reprompt = this.reprompt;
this.buildDataModel(this, c, { this.buildDataModel(this, c, {
name: null, name: null,

View File

@ -1,3 +1,4 @@
import { CipherRepromptType } from '../../enums/cipherRepromptType';
import { CipherType } from '../../enums/cipherType'; import { CipherType } from '../../enums/cipherType';
import { Cipher } from '../domain/cipher'; import { Cipher } from '../domain/cipher';
@ -29,6 +30,7 @@ export class CipherRequest {
attachments: { [id: string]: string; }; attachments: { [id: string]: string; };
attachments2: { [id: string]: AttachmentRequest; }; attachments2: { [id: string]: AttachmentRequest; };
lastKnownRevisionDate: Date; lastKnownRevisionDate: Date;
reprompt: CipherRepromptType;
constructor(cipher: Cipher) { constructor(cipher: Cipher) {
this.type = cipher.type; this.type = cipher.type;
@ -38,6 +40,7 @@ export class CipherRequest {
this.notes = cipher.notes ? cipher.notes.encryptedString : null; this.notes = cipher.notes ? cipher.notes.encryptedString : null;
this.favorite = cipher.favorite; this.favorite = cipher.favorite;
this.lastKnownRevisionDate = cipher.revisionDate; this.lastKnownRevisionDate = cipher.revisionDate;
this.reprompt = cipher.reprompt;
switch (this.type) { switch (this.type) {
case CipherType.Login: case CipherType.Login:

View File

@ -2,6 +2,7 @@ import { AttachmentResponse } from './attachmentResponse';
import { BaseResponse } from './baseResponse'; import { BaseResponse } from './baseResponse';
import { PasswordHistoryResponse } from './passwordHistoryResponse'; import { PasswordHistoryResponse } from './passwordHistoryResponse';
import { CipherRepromptType } from '../../enums/cipherRepromptType';
import { CardApi } from '../api/cardApi'; import { CardApi } from '../api/cardApi';
import { FieldApi } from '../api/fieldApi'; import { FieldApi } from '../api/fieldApi';
import { IdentityApi } from '../api/identityApi'; import { IdentityApi } from '../api/identityApi';
@ -29,6 +30,7 @@ export class CipherResponse extends BaseResponse {
passwordHistory: PasswordHistoryResponse[]; passwordHistory: PasswordHistoryResponse[];
collectionIds: string[]; collectionIds: string[];
deletedDate: string; deletedDate: string;
reprompt: CipherRepromptType;
constructor(response: any) { constructor(response: any) {
super(response); super(response);
@ -84,5 +86,7 @@ export class CipherResponse extends BaseResponse {
if (passwordHistory != null) { if (passwordHistory != null) {
this.passwordHistory = passwordHistory.map((h: any) => new PasswordHistoryResponse(h)); this.passwordHistory = passwordHistory.map((h: any) => new PasswordHistoryResponse(h));
} }
this.reprompt = this.getResponseProperty('Reprompt') || CipherRepromptType.None;
} }
} }

View File

@ -22,6 +22,10 @@ export class CardView implements View {
return this.code != null ? '•'.repeat(this.code.length) : null; return this.code != null ? '•'.repeat(this.code.length) : null;
} }
get maskedNumber(): string {
return this.number != null ? '•'.repeat(this.number.length) : null;
}
get brand(): string { get brand(): string {
return this._brand; return this._brand;
} }

View File

@ -1,3 +1,4 @@
import { CipherRepromptType } from '../../enums/cipherRepromptType';
import { CipherType } from '../../enums/cipherType'; import { CipherType } from '../../enums/cipherType';
import { Cipher } from '../domain/cipher'; import { Cipher } from '../domain/cipher';
@ -33,6 +34,7 @@ export class CipherView implements View {
collectionIds: string[] = null; collectionIds: string[] = null;
revisionDate: Date = null; revisionDate: Date = null;
deletedDate: Date = null; deletedDate: Date = null;
reprompt: CipherRepromptType = null;
constructor(c?: Cipher) { constructor(c?: Cipher) {
if (!c) { if (!c) {
@ -51,6 +53,7 @@ export class CipherView implements View {
this.collectionIds = c.collectionIds; this.collectionIds = c.collectionIds;
this.revisionDate = c.revisionDate; this.revisionDate = c.revisionDate;
this.deletedDate = c.deletedDate; this.deletedDate = c.deletedDate;
this.reprompt = c.reprompt;
} }
get subTitle(): string { get subTitle(): string {

View File

@ -147,6 +147,7 @@ export class CipherService implements CipherServiceAbstraction {
cipher.type = model.type; cipher.type = model.type;
cipher.collectionIds = model.collectionIds; cipher.collectionIds = model.collectionIds;
cipher.revisionDate = model.revisionDate; cipher.revisionDate = model.revisionDate;
cipher.reprompt = model.reprompt;
if (key == null && cipher.organizationId != null) { if (key == null && cipher.organizationId != null) {
key = await this.cryptoService.getOrgKey(cipher.organizationId); key = await this.cryptoService.getOrgKey(cipher.organizationId);

View File

@ -0,0 +1,28 @@
import { PlatformUtilsService } from '../abstractions';
import { CryptoService } from '../abstractions/crypto.service';
import { I18nService } from '../abstractions/i18n.service';
import { PasswordRepromptService as PasswordRepromptServiceAbstraction } from '../abstractions/passwordReprompt.service';
export class PasswordRepromptService implements PasswordRepromptServiceAbstraction {
constructor(private i18nService: I18nService, private cryptoService: CryptoService,
private platformUtilService: PlatformUtilsService) { }
protectedFields() {
return ['TOTP', 'Password', 'H_Field', 'Card Number', 'Security Code'];
}
async showPasswordPrompt() {
const passwordValidator = async (value: string) => {
const keyHash = await this.cryptoService.hashPassword(value, null);
const storedKeyHash = await this.cryptoService.getKeyHash();
if (storedKeyHash == null || keyHash == null || storedKeyHash !== keyHash) {
return false;
}
return true;
};
return this.platformUtilService.showPasswordDialog(this.i18nService.t('passwordConfirmation'), this.i18nService.t('passwordConfirmationDesc'), passwordValidator);
}
}