Skip to content

Commit

Permalink
Enforce strong passwords in UI (#1266)
Browse files Browse the repository at this point in the history
  • Loading branch information
SuaYoo authored and tw4l committed Oct 18, 2023
1 parent b4214bb commit 792f2dd
Show file tree
Hide file tree
Showing 12 changed files with 420 additions and 32 deletions.
3 changes: 3 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
"@typescript-eslint/parser": "^5.4.0",
"@wysimark/standalone": "2.2.15",
"@xstate/fsm": "^1.6.2",
"@zxcvbn-ts/core": "^3.0.4",
"@zxcvbn-ts/language-common": "^3.0.4",
"@zxcvbn-ts/language-en": "^3.0.2",
"autoprefixer": "^10.4.2",
"axios": "^0.22.0",
"broadcastchannel-polyfill": "^1.0.1",
Expand Down
51 changes: 50 additions & 1 deletion frontend/src/components/account-settings.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
import { LitElement } from "lit";
import { state, queryAsync, property } from "lit/decorators.js";
import { msg, localized } from "@lit/localize";
import { msg, str, localized } from "@lit/localize";
import debounce from "lodash/fp/debounce";
import { when } from "lit/directives/when.js";
import { serialize } from "@shoelace-style/shoelace/dist/utilities/form.js";
import type { SlInput } from "@shoelace-style/shoelace";
import type { ZxcvbnResult } from "@zxcvbn-ts/core";

import type { CurrentUser } from "../types/user";
import LiteElement, { html } from "../utils/LiteElement";
import { needLogin } from "../utils/auth";
import type { AuthState, Auth } from "../utils/AuthService";
import AuthService from "../utils/AuthService";
import PasswordService from "../utils/PasswordService";

const { PASSWORD_MINLENGTH, PASSWORD_MAXLENGTH, PASSWORD_MIN_SCORE } =
PasswordService;

@localized()
class RequestVerify extends LitElement {
Expand Down Expand Up @@ -98,6 +104,9 @@ export class AccountSettings extends LiteElement {
@state()
private isChangingPassword = false;

@state()
private pwStrengthResults: null | ZxcvbnResult = null;

@queryAsync('sl-input[name="password"]')
private passwordInput?: Promise<SlInput | null>;

Expand All @@ -110,6 +119,10 @@ export class AccountSettings extends LiteElement {
}
}

protected firstUpdated() {
PasswordService.setOptions();
}

render() {
if (!this.userInfo) return;
return html`
Expand Down Expand Up @@ -222,16 +235,26 @@ export class AccountSettings extends LiteElement {
password-toggle
minlength="8"
required
@input=${this.onPasswordInput}
></sl-input>
${when(this.pwStrengthResults, this.renderPasswordStrength)}
</div>
<footer
class="flex items-center justify-end border-t px-4 py-3"
>
<p class="mr-auto text-gray-500">
${msg(
str`Choose a strong password between ${PASSWORD_MINLENGTH}-${PASSWORD_MAXLENGTH} characters.`
)}
</p>
<sl-button
type="submit"
size="small"
variant="primary"
?loading=${this.sectionSubmitting === "password"}
?disabled=${!this.pwStrengthResults ||
this.pwStrengthResults.score < PASSWORD_MIN_SCORE}
>${msg("Save")}</sl-button
>
</footer>
Expand All @@ -255,6 +278,32 @@ export class AccountSettings extends LiteElement {
`;
}

private renderPasswordStrength = () => html`
<div class="mt-4">
<btrix-pw-strength-alert
.result=${this.pwStrengthResults}
min=${PASSWORD_MIN_SCORE}
>
</btrix-pw-strength-alert>
</div>
`;

private onPasswordInput = debounce(150)(async (e: InputEvent) => {
const { value } = e.target as SlInput;
if (!value || value.length < 4) {
this.pwStrengthResults = null;
return;
}
const userInputs: string[] = [];
if (this.userInfo) {
userInputs.push(this.userInfo.name, this.userInfo.email);
}
this.pwStrengthResults = await PasswordService.checkStrength(
value,
userInputs
);
}) as any;

private async onSubmitName(e: SubmitEvent) {
if (!this.userInfo || !this.authState) return;
const form = e.target as HTMLFormElement;
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,9 @@ import("./collections-add").then(({ CollectionsAdd }) => {
import("./code").then(({ Code }) => {
customElements.define("btrix-code", Code);
});
import("./pw-strength-alert").then(({ PasswordStrengthAlert }) => {
customElements.define("btrix-pw-strength-alert", PasswordStrengthAlert);
});
import("./search-combobox").then(({ SearchCombobox }) => {
customElements.define("btrix-search-combobox", SearchCombobox);
});
Expand Down
3 changes: 1 addition & 2 deletions frontend/src/components/input/input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export class Input extends LiteElement {
label?: string;

@property({ type: String })
id: string = "";
id: string = "customInput";

@property({ type: String })
name?: string;
Expand Down Expand Up @@ -51,7 +51,6 @@ export class Input extends LiteElement {

@state()
isPasswordVisible: boolean = false;

render() {
return html`
<div class="sl-label">
Expand Down
160 changes: 160 additions & 0 deletions frontend/src/components/pw-strength-alert.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { LitElement, html, css } from "lit";
import { msg, localized } from "@lit/localize";
import { property } from "lit/decorators.js";
import { when } from "lit/directives/when.js";
import type { ZxcvbnResult } from "@zxcvbn-ts/core";

/**
* Show results of password strength estimate
*
* Usage example:
* ```ts
* <btrix-pw-strength-alert .result=${this.zxcvbnResult}></btrix-pw-strength-alert>
* ```
*/
@localized()
export class PasswordStrengthAlert extends LitElement {
@property({ type: String })
result?: ZxcvbnResult;

/** Minimum acceptable score */
@property({ type: String })
min = 1;

/** Optimal score */
@property({ type: String })
optimal = 4;

static styles = css`
sl-alert::part(message) {
/* Decrease padding size: */
--sl-spacing-large: var(--sl-spacing-small);
}
sl-alert[variant="danger"] .icon {
color: var(--sl-color-danger-600);
}
sl-alert[variant="warning"] .icon {
color: var(--sl-color-warning-600);
}
sl-alert[variant="primary"] .icon {
color: var(--sl-color-primary-600);
}
sl-alert[variant="success"] .icon {
color: var(--sl-color-success-600);
}
p,
ul {
margin: 0;
padding: 0;
}
ul {
list-style-position: inside;
}
.score {
display: flex;
gap: var(--sl-spacing-x-small);
align-items: center;
}
.icon {
font-size: var(--sl-font-size-large);
}
.label {
color: var(--sl-color-neutral-900);
font-weight: var(--sl-font-weight-semibold);
}
.feedback {
color: var(--sl-color-neutral-700);
margin-left: var(--sl-spacing-x-large);
}
.text {
margin-top: var(--sl-spacing-small);
}
`;

render() {
if (!this.result) return;

const { score, feedback } = this.result;
let scoreProps = {
icon: "exclamation-triangle",
label: msg("Very weak password"),
variant: "danger",
};
switch (score) {
case 2:
scoreProps = {
icon: "exclamation-circle",
label: msg("Weak password"),
variant: "warning",
};
break;
case 3:
scoreProps = {
icon: "shield-check",
label: msg("Acceptably strong password"),
variant: "primary",
};
break;
case 4:
scoreProps = {
icon: "shield-fill-check",
label: msg("Very strong password"),
variant: "success",
};
break;
default:
break;
}
if (score < this.min) {
scoreProps.label = msg("Please choose a stronger password");
}
return html`
<sl-alert variant=${scoreProps.variant as any} open>
<div class="score">
<sl-icon class="icon" name=${scoreProps.icon}></sl-icon>
<p class="label">${scoreProps.label}</p>
</div>
<div class="feedback">
${when(
feedback.warning,
() => html` <p class="text">${feedback.warning}</p> `
)}
${when(feedback.suggestions.length, () =>
feedback.suggestions.length === 1
? html`<p class="text">
${msg("Suggestion:")} ${feedback.suggestions[0]}
</p>`
: html`<p class="text">${msg("Suggestions:")}</p>
<ul>
${feedback.suggestions.map(
(text) => html`<li>${text}</li>`
)}
</ul>`
)}
${when(
score >= this.min && score < this.optimal,
() => html`
<p class="text">
${msg(
"Tip: To generate very strong passwords, consider using a password manager."
)}
</p>
`
)}
</div>
</sl-alert>
`;
}
}
Loading

0 comments on commit 792f2dd

Please sign in to comment.