Remove failed unlock notification, add new login notification

Also address the other PR requests and tweak the legacy UI tests
This commit is contained in:
Bruno Bernardino 2022-08-01 12:14:51 +01:00
parent 052477b6c9
commit 8d0a255ce7
No known key found for this signature in database
GPG Key ID: D1B0A69ADD114ECE
13 changed files with 383 additions and 73 deletions

275
assets/email/new-login.html Normal file
View File

@ -0,0 +1,275 @@
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>{{ title }}</title>
<style>
/* -------------------------------------
RESPONSIVE AND MOBILE FRIENDLY STYLES
------------------------------------- */
@media only screen and (max-width: 620px) {
table[class="body"] h1 {
font-size: 28px !important;
margin-bottom: 10px !important;
}
table[class="body"] p,
table[class="body"] ul,
table[class="body"] ol,
table[class="body"] td,
table[class="body"] span,
table[class="body"] a {
font-size: 16px !important;
}
table[class="body"] .wrapper,
table[class="body"] .article {
padding: 10px !important;
}
table[class="body"] .content {
padding: 0 !important;
}
table[class="body"] .container {
padding: 0 !important;
width: 100% !important;
}
table[class="body"] .main {
border-left-width: 0 !important;
border-radius: 0 !important;
border-right-width: 0 !important;
}
table[class="body"] .btn table {
width: 100% !important;
}
table[class="body"] .btn a {
width: 100% !important;
}
table[class="body"] .img-responsive {
height: auto !important;
max-width: 100% !important;
width: auto !important;
}
}
/* -------------------------------------
PRESERVE THESE STYLES IN THE HEAD
------------------------------------- */
@media all {
.ExternalClass {
width: 100%;
}
.ExternalClass,
.ExternalClass p,
.ExternalClass span,
.ExternalClass font,
.ExternalClass td,
.ExternalClass div {
line-height: 100%;
}
.apple-link a {
color: inherit !important;
font-family: inherit !important;
font-size: inherit !important;
font-weight: inherit !important;
line-height: inherit !important;
text-decoration: none !important;
}
.btn-primary table td:hover {
background-color: #3498db !important;
}
.btn-primary a:hover {
background-color: #3498db !important;
border-color: #3498db !important;
}
}
</style>
</head>
<body
class=""
style="
background-color: #f6f6f6;
font-family: sans-serif;
-webkit-font-smoothing: antialiased;
font-size: 14px;
line-height: 1.4;
margin: 0;
padding: 0;
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
"
>
<table
border="0"
cellpadding="0"
cellspacing="0"
class="body"
style="
border-collapse: separate;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
width: 100%;
background-color: #f6f6f6;
"
>
<tr>
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top">&nbsp;</td>
<td
class="container"
style="
font-family: sans-serif;
font-size: 14px;
vertical-align: top;
display: block;
margin: 0 auto;
max-width: 580px;
padding: 10px;
width: 580px;
"
>
<div
class="content"
style="box-sizing: border-box; display: block; margin: 0 auto; max-width: 580px; padding: 10px"
>
<!-- START CENTERED WHITE CONTAINER -->
<span
class="preheader"
style="
color: transparent;
display: none;
height: 0;
max-height: 0;
max-width: 0;
opacity: 0;
overflow: hidden;
mso-hide: all;
visibility: hidden;
width: 0;
"
></span>
<table
class="main"
style="
border-collapse: separate;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
width: 100%;
background: #ffffff;
border-radius: 5px;
"
>
<!-- START MAIN CONTENT AREA -->
<tr>
<td
class="wrapper"
style="
font-family: sans-serif;
font-size: 14px;
vertical-align: top;
box-sizing: border-box;
padding: 20px;
"
>
<table
border="0"
cellpadding="0"
cellspacing="0"
style="
border-collapse: separate;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
width: 100%;
"
>
<tr>
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top">
<p
style="
font-family: sans-serif;
font-size: 14px;
font-weight: normal;
margin: 0;
margin-bottom: 15px;
"
>
Hi there!
</p>
<p
style="
font-family: sans-serif;
font-size: 14px;
font-weight: normal;
margin: 0;
margin-bottom: 15px;
"
>
This is just an email to warn you that there was a
<strong>new successful login</strong> to your account in Padloc,
from {{ location }}.
</p>
<p
style="
font-family: sans-serif;
font-size: 14px;
font-weight: normal;
margin: 0;
margin-bottom: 15px;
"
>
If this was you, there's no action necessary, otherwise you might
want to make sure your trusted devices haven't been compromised, or
to remove potentially compromised devices from your trusted list
inside the app.
</p>
</td>
</tr>
</table>
</td>
</tr>
<!-- END MAIN CONTENT AREA -->
</table>
<!-- START FOOTER -->
<div class="footer" style="clear: both; margin-top: 10px; text-align: center; width: 100%">
<table
border="0"
cellpadding="0"
cellspacing="0"
style="
border-collapse: separate;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
width: 100%;
"
>
<tr>
<td
class="content-block"
style="
font-family: sans-serif;
vertical-align: top;
padding-bottom: 10px;
padding-top: 10px;
font-size: 12px;
color: #999999;
text-align: center;
"
>
<span
class="apple-link"
style="color: #999999; font-size: 12px; text-align: center"
>This email was sent to you by Padloc (https://padloc.app). If you have any
questions, please don't hesitate to contact us at support@padloc.app!</span
>
<!--<br> Don't like these emails? <a href="" style="text-decoration: underline; color: #999999; font-size: 12px; text-align: center;">Unsubscribe</a>.-->
</td>
</tr>
</table>
</div>
<!-- END FOOTER -->
<!-- END CENTERED WHITE CONTAINER -->
</div>
</td>
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top">&nbsp;</td>
</tr>
</table>
</body>
</html>

View File

@ -0,0 +1,7 @@
Hi there!
This is just an email to warn you that there was a new successful login to your account in Padloc, from {{ location }}.
If this was you, there's no action necessary, otherwise you might want to make sure your trusted devices haven't been compromised, or to remove potentially compromised devices from your trusted list inside the app.
This email was sent to you by Padloc (https://padloc.app). If you have any questions, please don't hesitate to contact us at support@padloc.app!

View File

@ -321,7 +321,7 @@ Cypress.Commands.add("v3_lock", () => {
// Click lock
cy.doWithin(["pl-app", "pl-menu"], () => cy.get("pl-icon[icon='lock'].tap").click({ force: true }));
cy.url().should("include", "/unlock");
cy.url({ timeout: 10000 }).should("include", "/unlock");
});
Cypress.Commands.add("v3_unlock", (email: string) => {
@ -337,5 +337,5 @@ Cypress.Commands.add("v3_unlock", (email: string) => {
cy.get("pl-loading-button#unlockButton").click({ force: true });
});
cy.url().should("include", "/items");
cy.url({ timeout: 10000 }).should("include", "/items");
});

View File

@ -153,9 +153,9 @@ export class Audit extends StateMixin(Routing(View)) {
}
if (
!app.account?.settings.securityReportWeak &&
!app.account?.settings.securityReportReused &&
!app.account?.settings.securityReportCompromised
!app.account?.settings.securityReport.weakPasswords &&
!app.account?.settings.securityReport.reusedPasswords &&
!app.account?.settings.securityReport.compromisedPaswords
) {
return html`
<div class="fullbleed centering double-padded text-centering vertical layout">
@ -175,11 +175,13 @@ export class Audit extends StateMixin(Routing(View)) {
const items = this._items;
return html`
<div class="counts">
${app.account?.settings.securityReportWeak ? this._renderSection(items, AuditType.WeakPassword) : ""}
${app.account?.settings.securityReportReused
${app.account?.settings.securityReport.weakPasswords
? this._renderSection(items, AuditType.WeakPassword)
: ""}
${app.account?.settings.securityReport.reusedPasswords
? this._renderSection(items, AuditType.ReusedPassword)
: ""}
${app.account?.settings.securityReportCompromised
${app.account?.settings.securityReport.compromisedPaswords
? this._renderSection(items, AuditType.CompromisedPassword)
: ""}
</div>

View File

@ -152,21 +152,23 @@ export class SettingsSecurity extends StateMixin(Routing(LitElement)) {
autoLock: (this.renderRoot.querySelector("#autoLockButton") as ToggleButton).active,
autoLockDelay: (this.renderRoot.querySelector("#autoLockDelaySlider") as Slider).value,
});
if (app.account) {
app.account.settings.securityReportWeak = (
app.updateAccount(async (account) => {
account.settings.securityReport.weakPasswords = (
this.renderRoot.querySelector("#securityReportWeakToggle") as ToggleButton
).active;
app.account.settings.securityReportReused = (
account.settings.securityReport.reusedPasswords = (
this.renderRoot.querySelector("#securityReportReusedToggle") as ToggleButton
).active;
app.account.settings.securityReportCompromised = (
account.settings.securityReport.compromisedPaswords = (
this.renderRoot.querySelector("#securityReportCompromisedToggle") as ToggleButton
).active;
app.account.settings.failedLoginAttemptNotifications = (
this.renderRoot.querySelector("#failedLoginAttemptNotificationsToggle") as ToggleButton
account.settings.notifications.failedLoginAttempts = (
this.renderRoot.querySelector("#failedLoginAttemptsNotificationsToggle") as ToggleButton
).active;
app.save();
}
account.settings.notifications.newLogins = (
this.renderRoot.querySelector("#newLoginsNotificationsToggle") as ToggleButton
).active;
});
auditVaults();
}
@ -268,7 +270,7 @@ export class SettingsSecurity extends StateMixin(Routing(LitElement)) {
) {
return;
}
await app.api.revokeSession({ id });
await app.api.revokeSession(id);
app.fetchAuthInfo();
}
@ -699,11 +701,11 @@ export class SettingsSecurity extends StateMixin(Routing(LitElement)) {
<pl-toggle-button
class="transparent"
id="securityReportWeakToggle"
.active=${app.account?.settings.securityReportWeak || false}
.active=${app.account?.settings.securityReport.weakPasswords || false}
.label=${html`<div class="horizontal center-aligning spacing layout">
<pl-icon icon="weak"></pl-icon>
<div>${$l("Weak Passwords")}</div>
</div>`}
</div>` as TemplateResult}
reverse
>
</pl-toggle-button>
@ -713,11 +715,11 @@ export class SettingsSecurity extends StateMixin(Routing(LitElement)) {
<pl-toggle-button
class="transparent"
id="securityReportReusedToggle"
.active=${app.account?.settings.securityReportReused || false}
.active=${app.account?.settings.securityReport.reusedPasswords || false}
.label=${html`<div class="horizontal center-aligning spacing layout">
<pl-icon icon="reused"></pl-icon>
<div>${$l("Reused Passwords")}</div>
</div>`}
</div>` as TemplateResult}
reverse
>
</pl-toggle-button>
@ -727,11 +729,11 @@ export class SettingsSecurity extends StateMixin(Routing(LitElement)) {
<pl-toggle-button
class="transparent"
id="securityReportCompromisedToggle"
.active=${app.account?.settings.securityReportCompromised || false}
.active=${app.account?.settings.securityReport.compromisedPaswords || false}
.label=${html`<div class="horizontal center-aligning spacing layout">
<pl-icon icon="compromised"></pl-icon>
<div>${$l("Compromised Passwords")}</div>
</div>`}
</div>` as TemplateResult}
reverse
>
</pl-toggle-button>
@ -748,12 +750,26 @@ export class SettingsSecurity extends StateMixin(Routing(LitElement)) {
<div>
<pl-toggle-button
class="transparent"
id="failedLoginAttemptNotificationsToggle"
.active=${app.account?.settings.failedLoginAttemptNotifications || false}
id="failedLoginAttemptsNotificationsToggle"
.active=${app.account?.settings.notifications.failedLoginAttempts || false}
.label=${html`<div class="horizontal center-aligning spacing layout">
<pl-icon icon="weak"></pl-icon>
<div>${$l("Failed Login Attempt")}</div>
</div>`}
<div>${$l("Failed Login Attempts")}</div>
</div>` as TemplateResult}
reverse
>
</pl-toggle-button>
</div>
<div class="border-top">
<pl-toggle-button
class="transparent"
id="newLoginsNotificationsToggle"
.active=${app.account?.settings.notifications.newLogins || false}
.label=${html`<div class="horizontal center-aligning spacing layout">
<pl-icon icon="weak"></pl-icon>
<div>${$l("New Logins (on new or untrusted devices)")}</div>
</div>` as TemplateResult}
reverse
>
</pl-toggle-button>

View File

@ -13,7 +13,7 @@ export class ToggleButton extends LitElement {
reverse: boolean = false;
@property()
label: string | TemplateResult | TemplateResult<1> = "";
label: string | TemplateResult = "";
@query("pl-toggle")
_toggle: Toggle;

View File

@ -215,8 +215,8 @@ export class Unlock extends StartForm {
if (e.code !== ErrorCode.DECRYPTION_FAILED) {
throw e;
}
if (app.account?.settings.failedLoginAttemptNotifications && this._failedCount > 3) {
await this.app.logout(true);
if (this._failedCount > 3) {
await this.app.logout();
router.go("login");
return;
}

View File

@ -150,7 +150,7 @@ export async function auditVaults(
reusedPasswordItemIds.add(item.id);
}
if (app.account?.settings.securityReportReused) {
if (app.account?.settings.securityReport.reusedPasswords) {
auditResults.push({
type: AuditType.ReusedPassword,
fieldIndex: passwordField.fieldIndex,
@ -160,7 +160,7 @@ export async function auditVaults(
vaultResultsFound = true;
}
if (app.account?.settings.securityReportWeak) {
if (app.account?.settings.securityReport.weakPasswords) {
// Perform weak audit
const isThisPasswordWeak = await isPasswordWeak(passwordField.field.value);
if (isThisPasswordWeak) {
@ -179,7 +179,7 @@ export async function auditVaults(
}
}
if (app.account?.settings.securityReportCompromised) {
if (app.account?.settings.securityReport.compromisedPaswords) {
// Perform compromised audit
const isPasswordCompromised = await hasPasswordBeenCompromised(passwordHash);
if (isPasswordCompromised) {

View File

@ -27,16 +27,23 @@ export class AccountSecrets extends Serializable {
favorites = new Set<VaultItemID>();
}
/** Various application settings */
export class SecurityReportSettings extends Serializable {
weakPasswords = true;
reusedPasswords = true;
compromisedPaswords = true;
}
export class NotificationSettings extends Serializable {
failedLoginAttempts = true;
newLogins = true;
}
export class AccountSettings extends Serializable {
/** Enable checking for weak passwords */
securityReportWeak = true;
/** Enable checking for reused passwords */
securityReportReused = true;
/** Enable checking for compromised passwords */
securityReportCompromised = true;
/** Enable emails for failed login attempts */
failedLoginAttemptNotifications = true;
@AsSerializable(SecurityReportSettings)
securityReport = new SecurityReportSettings();
@AsSerializable(NotificationSettings)
notifications = new NotificationSettings();
}
export const ACCOUNT_NAME_MAX_LENGTH = 100;

View File

@ -486,7 +486,7 @@ export class API {
* Revoke a [[Session]], effectively logging out any client authenticated with it
*/
@Handler(String, undefined)
revokeSession(_params: { id: SessionID; revokedForFailedAttempts?: boolean }): PromiseWithProgress<void> {
revokeSession(_id: SessionID): PromiseWithProgress<void> {
throw "Not implemented";
}

View File

@ -713,8 +713,8 @@ export class App {
/**
* Logs out user and clears all sensitive information
*/
async logout(revokedForFailedAttempts = false) {
await this._logout(revokedForFailedAttempts);
async logout() {
await this._logout();
this.publish();
}
@ -723,13 +723,13 @@ export class App {
await this._logout();
}
private async _logout(revokedForFailedAttempts = false) {
private async _logout() {
this._cachedStartCreateSessionResponses.clear();
// Revoke session
try {
await this.forgetMasterKey();
await this.api.revokeSession({ id: this.state.session!.id, revokedForFailedAttempts });
await this.api.revokeSession(this.state.session!.id);
} catch (e) {}
// Reset application state
@ -842,8 +842,8 @@ export class App {
/**
* Revokes the given [[Session]]
*/
async revokeSession({ id }: { id: SessionID }) {
await this.api.revokeSession({ id });
async revokeSession(id: SessionID) {
await this.api.revokeSession(id);
await this.fetchAccount();
}

View File

@ -69,6 +69,15 @@ export class FailedLoginAttemptMessage extends Message<{ location: string }> {
}
}
export class NewLoginMessage extends Message<{ location: string }> {
template = "new-login";
get title() {
const appName = process.env.PL_APP_NAME;
return `${appName ? appName + " " : ""}New Login from ${this.data.location})`;
}
}
export class PlainMessage extends Message<{ message: string }> {
template = "plain";

View File

@ -49,6 +49,7 @@ import {
JoinOrgInviteCompletedMessage,
JoinOrgInviteMessage,
FailedLoginAttemptMessage,
NewLoginMessage,
Messenger,
} from "./messenger";
import { Server as SRPServer, SRPSession } from "./srp";
@ -599,11 +600,10 @@ export class Controller extends API {
this.log("account.createSession", { success: false });
++srpState.failedAttempts;
await this.storage.save(auth);
if (srpState.failedAttempts > 5 && acc.settings.failedLoginAttemptNotifications) {
if (srpState.failedAttempts >= 5 && acc.settings.notifications.failedLoginAttempts) {
try {
const location = this._buildLocationAndDeviceString(this.context.location, this.context.device);
// Send invite link to invitees email address
await this.messenger.send(acc.email, new FailedLoginAttemptMessage({ location }));
// Remove trusted device, if it was (after email as this can throw if the device was already removed)
@ -632,13 +632,21 @@ export class Controller extends API {
// Persist changes
await Promise.all([this.storage.save(session), this.storage.save(acc)]);
// Add device to trusted devices
if (
this.context.device &&
!auth.trustedDevices.some(({ id }) => id === this.context.device!.id) &&
addTrustedDevice
) {
auth.trustedDevices.push(this.context.device);
// Check if device isn't trusted
if (this.context.device && !auth.trustedDevices.some(({ id }) => id === this.context.device!.id)) {
// Add to trusted devices
if (addTrustedDevice) {
auth.trustedDevices.push(this.context.device);
}
// Send new login notification (it's a new or untrusted device)
if (acc.settings.notifications.newLogins) {
try {
const location = this._buildLocationAndDeviceString(this.context.location, this.context.device);
await this.messenger.send(acc.email, new NewLoginMessage({ location }));
} catch (e) {}
}
}
await this.storage.save(auth);
@ -654,7 +662,7 @@ export class Controller extends API {
return session;
}
async revokeSession({ id, revokedForFailedAttempts }: { id: SessionID; revokedForFailedAttempts?: boolean }) {
async revokeSession(id: SessionID) {
const { account, auth } = this._requireAuth();
const session = await this.storage.get(Session, id);
@ -669,20 +677,6 @@ export class Controller extends API {
await Promise.all([this.storage.delete(session), this.storage.save(auth)]);
this.log("account.revokeSession", { revokedSession: { id, device: session.device } });
if (revokedForFailedAttempts && account.settings.failedLoginAttemptNotifications) {
try {
const location = this._buildLocationAndDeviceString(session.info.lastLocation, session.info.device);
// Send invite link to invitees email address
await this.messenger.send(account.email, new FailedLoginAttemptMessage({ location }));
// Remove trusted device, if it was (after email as this can throw if the device was already removed)
if (session.info.device) {
await this.removeTrustedDevice(session.info.device.id);
}
} catch (e) {}
}
}
async createAccount({