Skip to content

Commit

Permalink
Feat/force password prompt (#1231)
Browse files Browse the repository at this point in the history
* Add new properties to VaultSettingsLocal

* VaultSettingsDialog: add new Biometric settings

* PasswordPrompt: check if password prompt is required before biometric unlock

implements #1201
  • Loading branch information
macno authored Nov 15, 2023
1 parent 30010d4 commit 3419634
Show file tree
Hide file tree
Showing 5 changed files with 180 additions and 27 deletions.
124 changes: 99 additions & 25 deletions source/renderer/components/PasswordPrompt.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,26 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useState as useHookState } from "@hookstate/core";
import { Button, Classes, Dialog, FormGroup, InputGroup, Intent, NonIdealState } from "@blueprintjs/core";
import {
Button,
Classes,
Dialog,
FormGroup,
InputGroup,
Intent,
NonIdealState
} from "@blueprintjs/core";
import { Layerr } from "layerr";
import { getPasswordEmitter } from "../services/password";
import { getBiometricSourcePassword } from "../services/biometrics";
import { PASSWORD_VIA_BIOMETRIC_SOURCE, SHOW_PROMPT } from "../state/password";
import { showError } from "../services/notifications";
import { t } from "../../shared/i18n/trans";
import { logErr } from "../library/log";
import { VaultSettingsLocal } from "../../shared/types";
import { naiveClone } from "../../shared/library/clone";
import { getVaultSettings, saveVaultSettings } from "../services/vaultSettings";

const ONE_DAY_IN_MS = 24 * 60 * 60 * 1000;

export function PasswordPrompt() {
const emitter = useMemo(getPasswordEmitter, []);
Expand All @@ -17,43 +30,107 @@ export function PasswordPrompt() {
const [biometricsPromptActive, setBiometricsPromptActive] = useState(false);
const [currentPassword, setCurrentPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [settings, _setSettings] = useState<VaultSettingsLocal>(null);
const saveAndReloadSettings = (sourceID, settings) => {
saveVaultSettings(sourceID, settings)
.then(() => {
_setSettings(settings);
})
.catch((err) => {
logErr("Failed saving vault settings", err);
});
}
const close = useCallback(() => {
setCurrentPassword(""); // clear
showPromptState.set(false);
emitter.emit("password", null);
setPromptedBiometrics(false);
}, [emitter]);
const submitAndClose = useCallback(password => {
setCurrentPassword(""); // clear
showPromptState.set(false);
emitter.emit("password", password);
setPromptedBiometrics(false);
}, [emitter]);
const handleKeyPress = useCallback(event => {
if (event.key === "Enter") {
submitAndClose(currentPassword);
}
}, [currentPassword]);
const submitAndClose = useCallback(
(password, isBioUnlock = false) => {
setCurrentPassword(""); // clear
showPromptState.set(false);
emitter.emit("password", password);
setPromptedBiometrics(false);
const sourceID = biometricSourceState.get();
if (isBioUnlock) {
const biometricUnlockCount = settings.biometricUnlockCount + 1;
const _settings = {
...naiveClone(settings),
biometricUnlockCount
};
saveAndReloadSettings(sourceID, _settings);
} else {
if (sourceID) {
const _settings = {
...naiveClone(settings),
biometricUnlockCount: 0,
biometricLastManualUnlock: Date.now()
};
saveAndReloadSettings(sourceID, _settings);
}
}
},
[emitter, settings]
);
const handleKeyPress = useCallback(
(event) => {
if (event.key === "Enter") {
submitAndClose(currentPassword);
}
},
[currentPassword]
);
useEffect(() => {
const sourceID = biometricSourceState.get();
if (!sourceID) return;
getVaultSettings(sourceID)
.then((settings) => {
_setSettings(naiveClone(settings));
})
.catch((err) => {
showError(t("notification.error.vault-settings-load"));
logErr("Failed loading vault settings", err);
});
}, [biometricSourceState.get()]);
useEffect(() => {
if (settings === null) return;
const showPrompt = showPromptState.get();
const sourceID = biometricSourceState.get();
if (!showPrompt || !sourceID || promptedBiometrics) return;
setPromptedBiometrics(true);
const biometricForcePasswordCount = Number(settings.biometricForcePasswordCount) || 0;
if (
biometricForcePasswordCount > 0 &&
biometricForcePasswordCount <= settings.biometricUnlockCount
) {
setBiometricsPromptActive(false);
return;
}
const biometricForcePasswordTimeout = Number(settings.biometricForcePasswordMaxInterval) || 0;
if (
biometricForcePasswordTimeout > 0 &&
Date.now() >
settings.biometricLastManualUnlock + ONE_DAY_IN_MS * biometricForcePasswordTimeout
) {
setBiometricsPromptActive(false);
return;
}
setBiometricsPromptActive(true);
setPromptedBiometrics(true);
getBiometricSourcePassword(sourceID)
.then(sourcePassword => {
.then((sourcePassword) => {
setBiometricsPromptActive(false);
if (!sourcePassword) return;
submitAndClose(sourcePassword);
submitAndClose(sourcePassword, true);
})
.catch(err => {
.catch((err) => {
setBiometricsPromptActive(false);
logErr(`Failed getting biometrics password for source: ${sourceID}`, err);
const errInfo = Layerr.info(err);
const message = errInfo?.i18n && t(errInfo.i18n) || err.message;
const message = (errInfo?.i18n && t(errInfo.i18n)) || err.message;
showError(message);
});
}, [showPromptState.get(), biometricSourceState.get(), promptedBiometrics]);
}, [showPromptState.get(), biometricSourceState.get(), promptedBiometrics, settings]);
return (
<Dialog isOpen={showPromptState.get()} onClose={close}>
<div className={Classes.DIALOG_HEADER}>{t("dialog.password-prompt.title")}</div>
Expand All @@ -68,7 +145,7 @@ export function PasswordPrompt() {
id="password"
placeholder={t("dialog.password-prompt.placeholder")}
type={showPassword ? "text" : "password"}
rightElement={(
rightElement={
<Button
icon={showPassword ? "unlock" : "lock"}
intent={Intent.NONE}
Expand All @@ -82,9 +159,9 @@ export function PasswordPrompt() {
active={showPassword}
style={{ outline: "none", userSelect: "none" }}
/>
)}
}
value={currentPassword}
onChange={evt => setCurrentPassword(evt.target.value)}
onChange={(evt) => setCurrentPassword(evt.target.value)}
onKeyDown={handleKeyPress}
autoFocus
/>
Expand All @@ -108,10 +185,7 @@ export function PasswordPrompt() {
>
{t("dialog.password-prompt.button-unlock")}
</Button>
<Button
onClick={close}
title={t("dialog.password-prompt.button-cancel-title")}
>
<Button onClick={close} title={t("dialog.password-prompt.button-cancel-title")}>
{t("dialog.password-prompt.button-cancel")}
</Button>
</div>
Expand Down
59 changes: 58 additions & 1 deletion source/renderer/components/VaultSettingsDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,21 @@
import React, { Fragment, useCallback, useEffect, useState } from "react";
import styled from "styled-components";
import { useState as useHookState } from "@hookstate/core";
import { Alignment, Button, ButtonGroup, Callout, Classes, Dialog, FormGroup, Icon, InputGroup, Intent, Switch, Text } from "@blueprintjs/core";
import {
Alignment,
Button,
ButtonGroup,
Callout,
Classes,
Dialog,
FormGroup,
Icon,
InputGroup,
Intent,
NumericInput,
Switch,
Text
} from "@blueprintjs/core";
import { VaultFormatID, VaultSourceStatus } from "buttercup";
import { ConfirmDialog } from "./prompt/ConfirmDialog";
import { t } from "../../shared/i18n/trans";
Expand All @@ -18,6 +32,7 @@ import { convertVaultToFormat } from "../actions/format";

const PAGE_BACKUP = "backup";
const PAGE_FORMAT = "format";
const PAGE_BIOMETRIC = "biometric";

const ContentNotice = styled.div`
margin-top: 12px;
Expand Down Expand Up @@ -206,6 +221,41 @@ export function VaultSettingsDialog() {
</FormGroup>
</>
);
const pageBiometric = () => (
<>
<Callout icon="info-sign">
<div dangerouslySetInnerHTML={{ __html: t("vault-settings.biometric.description") }} />
</Callout>
<FormGroup label={t("vault-settings.biometric.enable-password-prompt-timeout.label")} helperText={t("vault-settings.biometric.enable-password-prompt-timeout.helper")}>
<NumericInput
allowNumericCharactersOnly
min={0}
value={settings.biometricForcePasswordMaxInterval}
placeholder={t("vault-settings.biometric.enable-password-prompt-timeout.placeholder")}
onValueChange={(valueAsNumber: number, valueAsString: string) => {
setSettings({
...naiveClone(settings),
biometricForcePasswordMaxInterval: valueAsString
})
}}
/>
</FormGroup>
<FormGroup label={t("vault-settings.biometric.enable-password-prompt-count.label")} helperText={t("vault-settings.biometric.enable-password-prompt-count.helper")}>
<NumericInput
allowNumericCharactersOnly
min={0}
onValueChange={(valueAsNumber: number, valueAsString: string) => {
setSettings({
...naiveClone(settings),
biometricForcePasswordCount: valueAsString
})
}}
placeholder={t("vault-settings.biometric.enable-password-prompt-count.placeholder")}
value={settings.biometricForcePasswordCount}
/>
</FormGroup>
</>
)
return (
<Fragment>
<DialogFreeWidth isOpen={!!showSettingsState.get()} onClose={close}>
Expand All @@ -230,11 +280,18 @@ export function VaultSettingsDialog() {
onClick={() => setCurrentPage(PAGE_BACKUP)}
text={t("vault-settings.backup.title")}
/>
<Button
active={currentPage === PAGE_BIOMETRIC}
icon="desktop"
onClick={() => setCurrentPage(PAGE_BIOMETRIC)}
text={t("vault-settings.biometric.title")}
/>
</SettingsMenu>
</SettingsSidebar>
<PageContent>
{currentPage === PAGE_FORMAT && pageFormat()}
{currentPage === PAGE_BACKUP && pageBackup()}
{currentPage === PAGE_BIOMETRIC && pageBiometric()}
</PageContent>
</SettingsContent>
)}
Expand Down
14 changes: 14 additions & 0 deletions source/shared/i18n/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,20 @@
"title": "Format",
"upgrade-button": "Upgrade"
},
"biometric": {
"title": "Biometric",
"description": "Configure when requiring to type in password",
"enable-password-prompt-timeout": {
"label": "Maximum number of days before prompting to type in password",
"placeholder": "Number of days",
"helper": "Leave blank or set it to '0' to disable"
},
"enable-password-prompt-count": {
"label": "Maximum number of biometric unlocks before prompting to type in password",
"placeholder": "Number of biometric unlock",
"helper": "Leave blank or set it to '0' to disable"
}
},
"not-unlocked": "Vault must be unlocked to access this area.",
"title": "Vault Settings: {{title}}"
},
Expand Down
6 changes: 5 additions & 1 deletion source/shared/symbols.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,9 @@ export const PREFERENCES_DEFAULT: Preferences = {

export const VAULT_SETTINGS_DEFAULT: VaultSettingsLocal = {
localBackup: false,
localBackupLocation: null
localBackupLocation: null,
biometricForcePasswordMaxInterval: "",
biometricForcePasswordCount: "",
biometricLastManualUnlock: +Infinity,
biometricUnlockCount: 0
};
4 changes: 4 additions & 0 deletions source/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ export interface UpdateProgressInfo {
export interface VaultSettingsLocal {
localBackup: boolean;
localBackupLocation: null | string;
biometricForcePasswordMaxInterval: string;
biometricForcePasswordCount: string;
biometricLastManualUnlock: number;
biometricUnlockCount: number;
}

export interface VaultSourceDescription {
Expand Down

0 comments on commit 3419634

Please sign in to comment.