padloc/packages/app/src/elements/settings-security.ts

735 lines
33 KiB
TypeScript

import "./drawer";
import "./popover";
import "./list";
import "./button";
import "./scroller";
import { html, LitElement, TemplateResult } from "lit";
import { StateMixin } from "../mixins/state";
import {
authenticate,
registerAuthenticator,
DeviceInfo,
supportsPlatformAuthenticator,
getPlatform,
} from "@padloc/core/src/platform";
import { app, router } from "../globals";
import { prompt, alert, confirm, choose } from "../lib/dialog";
import { translate as $l } from "@padloc/locale/src/translate";
import { live } from "lit/directives/live.js";
import { ToggleButton } from "./toggle-button";
import { customElement, query } from "lit/decorators.js";
import { shared } from "../styles";
import { Slider } from "./slider";
import { UpdateAuthParams } from "@padloc/core/src/api";
import { Routing } from "../mixins/routing";
import { AuthPurpose, AuthType, AuthenticatorInfo, AuthenticatorStatus } from "@padloc/core/src/auth";
import { formatDate, formatDateFromNow, passwordStrength } from "../lib/util";
import { until } from "lit/directives/until.js";
import { Button } from "./button";
import { SessionInfo } from "@padloc/core/src/session";
import { KeyStoreEntryInfo } from "@padloc/core/src/key-store";
import { Toggle } from "./toggle";
import { alertDisabledFeature } from "../lib/provisioning";
@customElement("pl-settings-security")
export class SettingsSecurity extends StateMixin(Routing(LitElement)) {
readonly routePattern = /^settings\/security/;
@query("#addMFAButton")
private _addMFAButton: Button;
connectedCallback() {
super.connectedCallback();
this.addEventListener("change", () => this._updateSettings());
}
//* Opens the change password dialog and resets the corresponding input elements
private async _changePassword(askForExisting = true): Promise<void> {
const success =
!askForExisting ||
(await prompt($l("Please enter your current password!"), {
title: $l("Change Master Password"),
label: $l("Enter Current Password"),
type: "password",
validate: async (pwd) => {
try {
await app.account!.unlock(pwd);
} catch (e) {
throw $l("Wrong password! Please try again!");
}
return pwd;
},
}));
if (!success) {
return;
}
const newPwd = await prompt($l("Now choose a new master password!"), {
title: $l("Change Master Password"),
label: $l("Enter New Password"),
type: "password",
validate: async (val: string) => {
if (val === "") {
throw $l("Please enter a password!");
}
return val;
},
});
const strength = await passwordStrength(newPwd);
if (strength.score < 2) {
const choice = await choose(
$l(
"The password you entered is weak which makes it easier for attackers to break " +
"the encryption used to protect your data. Try to use a longer password or include a " +
"variation of uppercase, lowercase and special characters as well as numbers!"
),
[$l("Choose Different Password"), $l("Use Anyway")],
{
type: "warning",
title: $l("WARNING: Weak Password"),
icon: null,
preventDismiss: true,
}
);
if (choice === 0) {
return this._changePassword(false);
}
}
if (newPwd === null) {
return;
}
const confirmed = await prompt($l("Please confirm your new password!"), {
title: $l("Change Master Password"),
label: $l("Repeat New Password"),
type: "password",
validate: async (pwd) => {
if (pwd !== newPwd) {
throw "Wrong password! Please try again!";
}
return pwd;
},
});
if (!confirmed) {
return;
}
await app.changePassword(newPwd);
alert($l("Master password changed successfully."), { type: "success" });
}
private async _toggleBiometricUnlock(e: Event) {
// e.stopPropagation();
const toggle = e.target as ToggleButton;
if (toggle.active) {
this.dispatchEvent(new CustomEvent("enable-biometric-auth", { bubbles: true, composed: true }));
} else {
const confirmed = await confirm(
$l("Are you sure you want to disable biometric unlock for this device?"),
$l("Disable"),
$l("Cancel"),
{ title: $l("Disable Biometric Unlock") }
);
if (confirmed) {
await app.forgetMasterKey();
} else {
toggle.active = true;
}
}
}
private _updateSettings() {
app.setSettings({
autoLock: (this.renderRoot.querySelector("#autoLockButton") as ToggleButton).active,
autoLockDelay: (this.renderRoot.querySelector("#autoLockDelaySlider") as Slider).value,
});
}
private async _addAuthenticator() {
const feature = app.getAccountFeatures().manageAuthenticators;
if (feature.disabled) {
await alertDisabledFeature(feature);
return;
}
const choices: {
type?: AuthType;
label: TemplateResult | string;
}[] = [];
const supportedAuthTypes = getPlatform().supportedAuthTypes;
if (supportedAuthTypes.includes(AuthType.WebAuthnPortable)) {
choices.push({
type: AuthType.WebAuthnPortable,
label: html`
<pl-icon icon="usb" class="large horizontally-half-margined"></pl-icon>
<div class="left-padded text-left-aligning stretch">
<div>Hardware Key</div>
<div class="small subtle">Yubikey, Google Titan etc.</div>
</div>
`,
});
}
// choices.push(
// html`
// <pl-icon icon="mail" class="large horizontally-half-margined"></pl-icon>
// <div class="left-padded text-left-aligning stretch">
// <div>OTP via Email</div>
// <div class="small subtle">We'll send a code to your email.</div>
// </div>
// `
// );
if (supportedAuthTypes.includes(AuthType.Totp)) {
choices.push({
type: AuthType.Totp,
label: html`
<pl-icon icon="totp" class="large horizontally-half-margined"></pl-icon>
<div class="left-padded text-left-aligning stretch">
<div>TOTP</div>
<div class="small subtle">Google Authenticator, Authy etc.</div>
</div>
`,
});
}
choices.push({ label: $l("Cancel") });
const typeIndex = await choose(
$l("What kind of multi-factor authenticator would you like to add?"),
choices.map((c) => c.label),
{
title: "New MFA-Method",
icon: "key",
}
);
const type = choices[typeIndex].type;
if (!type) {
return;
}
this._addMFAButton.start();
try {
await registerAuthenticator({
purposes: [AuthPurpose.Login],
type,
});
app.fetchAuthInfo();
} catch (e) {
alert(e.message, { type: "warning", title: $l("Failed to add authenticator") });
}
this._addMFAButton.stop();
}
private async _deleteAuthenticator({ id }: AuthenticatorInfo) {
if (
!(await confirm($l("Are you sure you want to delete this authenticator?"), $l("Delete"), $l("Cancel"), {
type: "destructive",
title: $l("Delete Authenticator"),
}))
) {
return;
}
await app.api.deleteAuthenticator(id);
app.fetchAuthInfo();
}
private async _revokeSession({ id }: SessionInfo) {
if (
!(await confirm($l("Are you sure you want to revoke this session?"), $l("Revoke"), $l("Cancel"), {
type: "destructive",
title: $l("Revoke Session"),
}))
) {
return;
}
await app.api.revokeSession(id);
app.fetchAuthInfo();
}
private async _removeTrustedDevice({ id }: DeviceInfo) {
if (
!(await confirm(
$l("Are you sure you want to remove this device from your trusted devices?"),
$l("Remove"),
$l("Cancel"),
{
type: "destructive",
title: $l("Remove Trusted Device"),
}
))
) {
return;
}
await app.api.removeTrustedDevice(id);
app.fetchAuthInfo();
}
private async _moveAuthenticator(authenticator: AuthenticatorInfo, direction: "up" | "down") {
const authenticators = await this._getLoginAuthenticators();
const i = authenticators.indexOf(authenticator);
authenticators.splice(i, 1);
authenticators.splice(direction === "up" ? i - 1 : i + 1, 0, authenticator);
await app.api.updateAuth(
new UpdateAuthParams({
mfaOrder: authenticators.map((a) => a.id),
})
);
app.fetchAuthInfo();
}
private _getLoginAuthenticators() {
if (!app.authInfo) {
return [];
}
const { authenticators, mfaOrder } = app.authInfo;
return authenticators
.filter((a) => a.purposes.includes(AuthPurpose.Login))
.sort((a, b) => mfaOrder.indexOf(a.id) - mfaOrder.indexOf(b.id));
}
private async _testMFAuthenticator(authenticator: AuthenticatorInfo) {
try {
const token = await authenticate({
authenticatorId: authenticator.id,
purpose: AuthPurpose.TestAuthenticator,
});
if (token) {
alert($l("The test was successfull!"), { title: $l("Test Authenticator"), icon: "test" });
} else {
alert($l("Test failed!"), { title: $l("Test Authenticator") });
}
} catch (e) {
alert($l("Test failed! Error: {0}", e.message), { title: $l("Test Authenticator") });
}
}
private async _revokeBiometricUnlock(keyStore: KeyStoreEntryInfo, device?: DeviceInfo, e?: Event) {
const toggle = e && (e.target as Toggle);
if (
!(await confirm(
$l(
'Are you sure you want to revoke biometric unlock for the device "{0}"?',
device?.description || $l("Unknown Device")
),
$l("Revoke"),
$l("Cancel"),
{ title: $l("Revoke Biometric Unlock") }
))
) {
if (toggle) {
toggle.active = true;
toggle.requestUpdate("active");
}
return;
}
await app.api.deleteKeyStoreEntry(keyStore.id);
await app.api.deleteAuthenticator(keyStore.authenticatorId);
await app.fetchAuthInfo();
}
static styles = [shared];
private _renderMFA() {
if (app.getAccountFeatures().manageAuthenticators.hidden) {
return;
}
const authenticators = this._getLoginAuthenticators();
return html`
<div class="box">
<h2 class="padded uppercase bg-dark border-bottom semibold">${$l("Multi-Factor Authentication")}</h2>
<pl-list>
${authenticators.map(
(a, i) => html`
<div class="padded list-item center-aligning horizontal layout">
<pl-icon
icon="${a.type === AuthType.Email
? "mail"
: a.type === AuthType.Totp
? "time"
: "usb"}"
class="large"
></pl-icon>
<div class="stretch collapse horizontally-padded left-margined">
<div class="ellipsis">${a.description}</div>
<div class="tiny wrapping tags top-margined">
${a.status === AuthenticatorStatus.Registering
? html`<div class="tag warning">${$l("not activated")}</div>`
: a.status === AuthenticatorStatus.Revoked
? html`<div class="tag warning">${$l("revoked")}</div>`
: html`
<div
class="tag"
title="Last Used: ${a.lastUsed
? formatDate(a.lastUsed)
: $l("never")}"
>
<pl-icon icon="time" class="inline"></pl-icon> ${a.lastUsed
? until(formatDateFromNow(a.lastUsed), "")
: $l("never")}
</div>
`}
</div>
</div>
<pl-button class="slim transparent reveal-on-parent-hover">
<pl-icon icon="more"></pl-icon>
</pl-button>
<pl-popover hide-on-click>
<pl-list>
<div
class="small double-padded horizontal spacing center-aligning layout list-item hover click"
@click=${() => this._testMFAuthenticator(a)}
>
<pl-icon icon="test"></pl-icon>
<div class="ellipsis">${$l("Test")}</div>
</div>
<div
class="small double-padded horizontal spacing center-aligning layout list-item hover click"
@click=${() => this._deleteAuthenticator(a)}
>
<pl-icon icon="delete"></pl-icon>
<div class="ellipsis">${$l("Remove")}</div>
</div>
</pl-list>
</pl-popover>
<div
class="vertical layout reveal-on-parent-hover"
?hidden=${authenticators.length < 2}
>
<pl-button
class="transparent"
style="display: flex; --button-padding: 0 0.3em;"
?hidden=${i === 0}
@click=${() => this._moveAuthenticator(a, "up")}
>
<pl-icon icon="dropup"></pl-icon>
</pl-button>
<pl-button
class="transparent"
style="display: flex; --button-padding: 0 0.3em;"
?disabled=${i === authenticators.length - 1}
@click=${() => this._moveAuthenticator(a, "down")}
>
<pl-icon icon="dropdown"></pl-icon>
</pl-button>
</div>
</div>
`
)}
</pl-list>
<div class="list-item">
<pl-button id="addMFAButton" class="transparent" @click=${this._addAuthenticator}>
<pl-icon icon="add" class="right-margined"></pl-icon>
<div>${$l("Add MFA Method")}</div>
</pl-button>
</div>
</div>
`;
}
private _renderSessions() {
if (!app.authInfo || !app.session || app.getAccountFeatures().manageSessions.hidden) {
return;
}
const { sessions } = app.authInfo;
sessions.sort((a, b) => Number(b.lastUsed) - Number(a.lastUsed));
return html`
<div class="box">
<h2 class="padded uppercase bg-dark border-bottom semibold">${$l("Active Sessions")}</h2>
<pl-list>
${sessions.map((session) => {
const lastKnownLocation = !session.lastLocation
? $l("Unknown")
: `${session.lastLocation.city || $l("Unknown City")}, ${
session.lastLocation.country || $l("Unknown Country")
}`;
return html`
<div class="padded list-item center-aligning horizontal layout">
<pl-icon
icon="${["ios", "android"].includes(session.device?.platform.toLowerCase() || "")
? "mobile"
: "desktop"}"
class="large"
></pl-icon>
<div class="stretch collapse horizontally-padded left-margined">
<div class="ellipsis">${session.device?.description || $l("Unknown Device")}</div>
<div class="tiny tags top-margined">
${session.id === app.session!.id
? html` <div class="tag highlight">
<strong>${$l("Current Session")}</strong>
</div>`
: ""}
<div class="tag" title="Last Active: ${formatDate(session.lastUsed)}">
<pl-icon icon="time" class="inline"></pl-icon> ${session.lastUsed
? until(formatDateFromNow(session.lastUsed), "")
: $l("never")}
</div>
<div class="tag" title="Last Known Location: ${formatDate(session.lastUsed)}">
<pl-icon icon="location" class="inline"></pl-icon> ${lastKnownLocation}
</div>
</div>
</div>
<pl-button
class="slim transparent reveal-on-parent-hover"
@click=${() => this._revokeSession(session)}
?disabled=${session.id === app.session!.id}
>
<pl-icon icon="delete"></pl-icon>
</pl-button>
</div>
`;
})}
</pl-list>
</div>
`;
}
private _renderTrustedDevices() {
if (!app.authInfo || app.getAccountFeatures().manageDevices.hidden) {
return;
}
const { trustedDevices, sessions } = app.authInfo;
return html`
<div class="box">
<h2 class="padded uppercase bg-dark border-bottom semibold">${$l("Trusted Devices")}</h2>
<pl-list>
${trustedDevices.map((device) => {
const latestSession = sessions
.filter((s) => s.device?.id === device.id)
.sort((a, b) => Number(b.lastUsed) - Number(a.lastUsed))[0];
const lastKnownLocation = !latestSession?.lastLocation
? $l("Unknown")
: `${latestSession.lastLocation.city || $l("Unknown City")}, ${
latestSession.lastLocation.country || $l("Unknown Country")
}`;
return html`
<div class="padded list-item center-aligning horizontal layout">
<pl-icon
icon="${["ios", "android"].includes(device.platform.toLowerCase() || "")
? "mobile"
: "desktop"}"
class="large"
></pl-icon>
<div class="stretch collapse horizontally-padded left-margined">
<div class="ellipsis">${device.description || $l("Unknown Device")}</div>
<div class="tiny wrapping tags top-margined">
${device.id === app.state.device.id
? html` <div class="tag highlight">
<strong>${$l("Current Device")}</strong>
</div>`
: ""}
${latestSession
? html`
<div
class="tag"
title="Last Login: ${formatDate(latestSession.created)}"
>
<pl-icon icon="time" class="inline"></pl-icon>
${latestSession.created
? until(formatDateFromNow(latestSession.created), "")
: $l("never")}
</div>
<div class="tag" title="Last Known Location: ${lastKnownLocation}">
<pl-icon icon="location" class="inline"></pl-icon>
${lastKnownLocation}
</div>
`
: ""}
</div>
</div>
<pl-button
class="slim transparent reveal-on-parent-hover"
@click=${() => this._removeTrustedDevice(device)}
>
<pl-icon icon="delete"></pl-icon>
</pl-button>
</div>
`;
})}
</pl-list>
</div>
`;
}
private async _renderBiometricUnlockCurrentDevice(
currentDevice: DeviceInfo,
currentAuthenticator?: AuthenticatorInfo
) {
const supportsPlatformAuth = await supportsPlatformAuthenticator();
return html`
<div class="padded list-item center-aligning horizontal layout" ?disabled=${!supportsPlatformAuth}>
<pl-icon
icon="${["ios", "android"].includes(currentDevice.platform.toLowerCase() || "")
? "mobile"
: "desktop"}"
class="large"
></pl-icon>
<div class="stretch collapse horizontally-padded left-margined">
<div class="ellipsis">${currentDevice.description || $l("Unknown Device")}</div>
<div class="tiny wrapping tags top-margined">
<div class="tag highlight">
<strong>${$l("Current Device")}</strong>
</div>
${currentAuthenticator
? html`
<div
class="tag"
title="Last Used: ${currentAuthenticator.lastUsed
? formatDate(currentAuthenticator.lastUsed)
: $l("never")}"
>
<pl-icon icon="time" class="inline"></pl-icon> ${currentAuthenticator.lastUsed
? until(formatDateFromNow(currentAuthenticator.lastUsed), "")
: $l("never")}
</div>
`
: ""}
</div>
</div>
<pl-toggle
.active=${live(app.remembersMasterKey)}
class="click"
@change=${(e: Event) => this._toggleBiometricUnlock(e)}
></pl-toggle>
</div>
`;
}
private _renderBiometricUnlock() {
if (!app.authInfo || app.getAccountFeatures().quickUnlock.hidden) {
return;
}
const { keyStoreEntries, authenticators } = app.authInfo;
const currentDevice = app.state.device;
const currentAuthenticator = authenticators.find((a) => a.device?.id === currentDevice.id);
return html`
<div class="box">
<h2 class="padded uppercase bg-dark border-bottom semibold">${$l("Biometric Unlock")}</h2>
<pl-list>
${until(this._renderBiometricUnlockCurrentDevice(currentDevice, currentAuthenticator), "")}
${keyStoreEntries.map((entry) => {
const authenticator = authenticators.find((a) => a.id === entry.authenticatorId);
const device = authenticator?.device;
if (device?.id === app.state.device.id) {
return;
}
return html`
<div class="padded list-item box center-aligning horizontal layout">
<pl-icon
icon="${["ios", "android"].includes(device?.platform.toLowerCase() || "")
? "mobile"
: "desktop"}"
class="large"
></pl-icon>
<div class="stretch collapse horizontally-padded left-margined">
<div class="ellipsis">${device?.description || $l("Unknown Device")}</div>
<div class="tiny wrapping tags top-margined">
${authenticator
? html`
<div
class="tag"
title="Last Used: ${authenticator.lastUsed
? formatDate(authenticator.lastUsed)
: $l("never")}"
>
<pl-icon icon="time" class="inline"></pl-icon>
${authenticator.lastUsed
? until(formatDateFromNow(authenticator.lastUsed), "")
: $l("never")}
</div>
`
: ""}
</div>
</div>
<pl-toggle
.active=${true}
class="click"
@change=${(e: Event) => this._revokeBiometricUnlock(entry, device, e)}
></pl-toggle>
<!-- <pl-button
class="slim transparent reveal-on-parent-hover"
@click=${() => this._revokeBiometricUnlock(entry)}
>
<pl-icon icon="delete"></pl-icon>
</pl-button> -->
</div>
`;
})}
</pl-list>
</div>
`;
}
render() {
return html`
<div class="fullbleed vertical layout stretch background">
<header class="padded center-aligning horizontal layout">
<pl-button class="transparent slim back-button" @click=${() => router.go("settings")}>
<pl-icon icon="backward"></pl-icon>
</pl-button>
<pl-icon icon="lock" class="left-margined vertically-padded wide-only"></pl-icon>
<div class="padded stretch ellipsis">${$l("Security")}</div>
</header>
<pl-scroller class="stretch">
<div class="wrapper double-padded double-spacing vertical layout">
<div class="box">
<h2 class="padded uppercase bg-dark border-bottom semibold">${$l("Master Password")}</h2>
<pl-button class="transparent" @click=${() => this._changePassword()}>
${$l("Change Master Password")}
</pl-button>
</div>
<div class="box">
<h2 class="padded uppercase bg-dark border-bottom semibold">${$l("Auto Lock")}</h2>
<div>
<pl-toggle-button
class="transparent"
id="autoLockButton"
.active=${app.settings.autoLock}
.label=${$l("Lock Automatically")}
reverse
>
</pl-toggle-button>
</div>
<pl-drawer .collapsed=${!app.settings.autoLock}>
<div class="half-padded border-top">
<pl-slider
id="autoLockDelaySlider"
min="1"
max="10"
step="1"
.value=${app.settings.autoLockDelay}
.unit=${$l(" min")}
.label=${$l("After")}
class="item"
>
</pl-slider>
</div>
</pl-drawer>
</div>
${this._renderBiometricUnlock()} ${this._renderMFA()} ${this._renderSessions()}
${this._renderTrustedDevices()}
</div>
</pl-scroller>
</div>
`;
}
}