Email Notifications: Failed Login Attempt

This is a new feature, related to #346 (half of it).

I'm unsure if we should implement a new server method to be called when the client is already authorized but fails to unlock (a trusted device has been compromised, for example), because that could be helpful but also easy to prevent from being called, so not entirely reliable.
This commit is contained in:
Bruno Bernardino 2022-07-21 12:36:31 +01:00
parent da7c8fdd55
commit d1f3295879
No known key found for this signature in database
GPG Key ID: D1B0A69ADD114ECE
7 changed files with 349 additions and 3 deletions

View File

@ -0,0 +1,274 @@
<!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>failed</strong> attempt to login to your account in Padloc.
</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 failed attempt to login to your account in Padloc.
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

@ -157,6 +157,12 @@ export class SettingsSecurity extends StateMixin(Routing(LitElement)) {
this.renderRoot.querySelector("#securityReportCompromisedToggle") as ToggleButton
).active,
});
app.account?.setSettings({
failedLoginAttemptNotifications: (
this.renderRoot.querySelector("#failedLoginAttemptNotificationsToggle") as ToggleButton
).active,
});
app.save();
auditVaults();
}
@ -680,7 +686,7 @@ export class SettingsSecurity extends StateMixin(Routing(LitElement)) {
`;
}
private _rendersecurityReport() {
private _renderSecurityReport() {
return html`
<div class="box">
<h2 class="padded uppercase bg-dark border-bottom semibold">${$l("Security Report")}</h2>
@ -730,6 +736,28 @@ export class SettingsSecurity extends StateMixin(Routing(LitElement)) {
`;
}
private _renderEmailNotifications() {
return html`
<div class="box">
<h2 class="padded uppercase bg-dark border-bottom semibold">${$l("Email Notifications")}</h2>
<div>
<pl-toggle-button
class="transparent"
id="failedLoginAttemptNotificationsToggle"
.active=${app.account?.settings.failedLoginAttemptNotifications}
.label=${html`<div class="horizontal center-aligning spacing layout">
<pl-icon icon="weak"></pl-icon>
<div>${$l("Failed Login Attempt")}</div>
</div>`}
reverse
>
</pl-toggle-button>
</div>
</div>
`;
}
render() {
return html`
<div class="fullbleed vertical layout stretch background">
@ -783,7 +811,8 @@ export class SettingsSecurity extends StateMixin(Routing(LitElement)) {
</div>
${this._renderBiometricUnlock()} ${this._renderMFA()} ${this._renderSessions()}
${this._renderTrustedDevices()} ${this._rendersecurityReport()}
${this._renderTrustedDevices()} ${this._renderSecurityReport()}
${this._renderEmailNotifications()}
</div>
</pl-scroller>
</div>

View File

@ -215,6 +215,10 @@ export class Unlock extends StartForm {
if (e.code !== ErrorCode.DECRYPTION_FAILED) {
throw e;
}
if (app.account?.settings.failedLoginAttemptNotifications) {
// TODO: Send email?
console.log("TODO: Sound send failed login attempt email?");
}
this._errorMessage = $l("Wrong password! Please try again.");
this.rumble();

View File

@ -1,4 +1,4 @@
import { stringToBytes, concatBytes, Serializable, AsBytes, AsDate, AsSet, Exclude } from "./encoding";
import { stringToBytes, concatBytes, Serializable, AsBytes, AsDate, AsSet, Exclude, AsSerializable } from "./encoding";
import { RSAPublicKey, RSAPrivateKey, RSAKeyParams, HMACKey, HMACParams, HMACKeyParams } from "./crypto";
import { getCryptoProvider as getProvider } from "./platform";
import { Err, ErrorCode } from "./error";
@ -27,6 +27,12 @@ export class AccountSecrets extends Serializable {
favorites = new Set<VaultItemID>();
}
/** Various application settings */
export class AccountSettings extends Serializable {
/** Enable emails for failed login attempts */
failedLoginAttemptNotifications = true;
}
export const ACCOUNT_NAME_MAX_LENGTH = 100;
export const ACCOUNT_EMAIL_MAX_LENGTH = 255;
@ -102,6 +108,10 @@ export class Account extends PBES2Container implements Storable {
@Exclude()
favorites = new Set<VaultItemID>();
/** Application Settings */
@AsSerializable(AccountSettings)
settings = new AccountSettings();
/**
* Whether or not this Account object is current "locked" or, in other words,
* whether the `privateKey` and `signingKey` properties have been decrypted.
@ -165,6 +175,12 @@ export class Account extends PBES2Container implements Storable {
this.favorites.clear();
}
/** Update account settings */
async setSettings(obj: Partial<AccountSettings>) {
Object.assign(this.settings, obj);
return this.settings;
}
clone() {
const clone = super.clone();
clone.copySecrets(this);

View File

@ -60,6 +60,15 @@ export class JoinOrgInviteCompletedMessage extends Message<{ orgName: string; op
}
}
export class FailedLoginAttemptMessage extends Message<{ srpId: string }> {
template = "failed-login-attempt";
get title() {
const appName = process.env.PL_APP_NAME;
return `${appName ? appName + " " : ""}Failed Login Attempt (SRP ID: ${this.data.srpId})`;
}
}
export class ErrorMessage extends Message<{ code: string; message: string; time: string; eventId: string }> {
template = "error";

View File

@ -48,6 +48,7 @@ import {
JoinOrgInviteAcceptedMessage,
JoinOrgInviteCompletedMessage,
JoinOrgInviteMessage,
FailedLoginAttemptMessage,
Messenger,
} from "./messenger";
import { Server as SRPServer, SRPSession } from "./srp";
@ -581,6 +582,12 @@ export class Controller extends API {
// authentication.
if (!(await getCryptoProvider().timingSafeEqual(M, srp.M1!))) {
this.log("account.createSession", { success: false });
if (acc.settings.failedLoginAttemptNotifications) {
try {
// Send invite link to invitees email address
await this.messenger.send(acc.email, new FailedLoginAttemptMessage({ srpId }));
} catch (e) {}
}
throw new Err(ErrorCode.INVALID_CREDENTIALS);
}