From 630c00c5b044403e89cb4cca3c76eade9c53db2a Mon Sep 17 00:00:00 2001 From: sua yoo Date: Thu, 12 Oct 2023 19:36:59 -0700 Subject: [PATCH] Enforce strong passwords in UI (#1266) --- frontend/package.json | 3 + frontend/src/components/account-settings.ts | 51 +++++- frontend/src/components/index.ts | 3 + frontend/src/components/input/input.ts | 3 +- frontend/src/components/pw-strength-alert.ts | 160 +++++++++++++++++++ frontend/src/components/sign-up-form.ts | 69 ++++++-- frontend/src/pages/join.ts | 19 ++- frontend/src/pages/log-in.ts | 6 +- frontend/src/pages/reset-password.ts | 53 +++++- frontend/src/pages/sign-up.ts | 4 +- frontend/src/utils/PasswordService.ts | 62 +++++++ frontend/yarn.lock | 19 ++- 12 files changed, 420 insertions(+), 32 deletions(-) create mode 100644 frontend/src/components/pw-strength-alert.ts create mode 100644 frontend/src/utils/PasswordService.ts diff --git a/frontend/package.json b/frontend/package.json index 6f7213cc..9748d193 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/components/account-settings.ts b/frontend/src/components/account-settings.ts index 460ff707..424866ff 100644 --- a/frontend/src/components/account-settings.ts +++ b/frontend/src/components/account-settings.ts @@ -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 { @@ -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; @@ -110,6 +119,10 @@ export class AccountSettings extends LiteElement { } } + protected firstUpdated() { + PasswordService.setOptions(); + } + render() { if (!this.userInfo) return; return html` @@ -222,16 +235,26 @@ export class AccountSettings extends LiteElement { password-toggle minlength="8" required + @input=${this.onPasswordInput} > + + ${when(this.pwStrengthResults, this.renderPasswordStrength)}
+

+ ${msg( + str`Choose a strong password between ${PASSWORD_MINLENGTH}-${PASSWORD_MAXLENGTH} characters.` + )} +

${msg("Save")}
@@ -255,6 +278,32 @@ export class AccountSettings extends LiteElement { `; } + private renderPasswordStrength = () => html` +
+ + +
+ `; + + 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; diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index bd629d85..afa16078 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -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); }); diff --git a/frontend/src/components/input/input.ts b/frontend/src/components/input/input.ts index cc59b7ed..cc3343b1 100644 --- a/frontend/src/components/input/input.ts +++ b/frontend/src/components/input/input.ts @@ -23,7 +23,7 @@ export class Input extends LiteElement { label?: string; @property({ type: String }) - id: string = ""; + id: string = "customInput"; @property({ type: String }) name?: string; @@ -51,7 +51,6 @@ export class Input extends LiteElement { @state() isPasswordVisible: boolean = false; - render() { return html`
diff --git a/frontend/src/components/pw-strength-alert.ts b/frontend/src/components/pw-strength-alert.ts new file mode 100644 index 00000000..288d62bd --- /dev/null +++ b/frontend/src/components/pw-strength-alert.ts @@ -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 + * + * ``` + */ +@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` + +
+ +

${scoreProps.label}

+
+ + +
+ `; + } +} diff --git a/frontend/src/components/sign-up-form.ts b/frontend/src/components/sign-up-form.ts index 13e4ea60..d54e4a28 100644 --- a/frontend/src/components/sign-up-form.ts +++ b/frontend/src/components/sign-up-form.ts @@ -1,9 +1,17 @@ import { state, property } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.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 type { ZxcvbnResult } from "@zxcvbn-ts/core"; import LiteElement, { html } from "../utils/LiteElement"; import AuthService from "../utils/AuthService"; +import PasswordService from "../utils/PasswordService"; +import type { Input as BtrixInput } from "./input/input"; + +const { PASSWORD_MINLENGTH, PASSWORD_MAXLENGTH, PASSWORD_MIN_SCORE } = + PasswordService; /** * @event submit @@ -32,6 +40,13 @@ export class SignUpForm extends LiteElement { @state() private isSubmitting: boolean = false; + @state() + private pwStrengthResults: null | ZxcvbnResult = null; + + protected firstUpdated() { + PasswordService.setOptions(); + } + render() { let serverError; @@ -74,20 +89,23 @@ export class SignUpForm extends LiteElement { `}
-
+
-

- ${msg("Your name will be visible to organization collaborators.")} +

+ ${msg( + "Your full name, nickname, or another name that org collaborators can see." + )}

@@ -96,15 +114,19 @@ export class SignUpForm extends LiteElement { name="password" type="password" label="${msg("Password")}" - minlength="8" + minlength=${PASSWORD_MINLENGTH} autocomplete="new-password" passwordToggle required + @input=${this.onPasswordInput} > -

- ${msg("Choose a strong password between 8-64 characters.")} +

+ ${msg( + str`Choose a strong password between ${PASSWORD_MINLENGTH}–${PASSWORD_MAXLENGTH} characters.` + )}

+ ${when(this.pwStrengthResults, this.renderPasswordStrength)}
${serverError} @@ -113,6 +135,8 @@ export class SignUpForm extends LiteElement { class="w-full" variant="primary" ?loading=${this.isSubmitting} + ?disabled=${!this.pwStrengthResults || + this.pwStrengthResults.score < PASSWORD_MIN_SCORE} type="submit" >${msg("Sign up")} @@ -120,8 +144,33 @@ export class SignUpForm extends LiteElement { `; } + private renderPasswordStrength = () => html` +
+ + +
+ `; + + private onPasswordInput = debounce(150)(async (e: InputEvent) => { + const { value } = e.target as BtrixInput; + if (!value || value.length < 4) { + this.pwStrengthResults = null; + return; + } + const userInputs: string[] = []; + if (this.email) { + userInputs.push(this.email); + } + this.pwStrengthResults = await PasswordService.checkStrength( + value, + userInputs + ); + }) as any; + private async onSubmit(event: SubmitEvent) { - const form = event.target as HTMLFormElement; event.preventDefault(); event.stopPropagation(); this.dispatchEvent(new CustomEvent("submit")); @@ -129,7 +178,7 @@ export class SignUpForm extends LiteElement { this.serverError = undefined; this.isSubmitting = true; - const formData = new FormData(form); + const formData = new FormData(event.target as HTMLFormElement); const email = formData.get("email") as string; const password = formData.get("password") as string; const name = formData.get("name") as string; diff --git a/frontend/src/pages/join.ts b/frontend/src/pages/join.ts index 8ae6f71d..b3b80ced 100644 --- a/frontend/src/pages/join.ts +++ b/frontend/src/pages/join.ts @@ -56,26 +56,29 @@ export class Join extends LiteElement { return html`
-
- ${msg("Invited by ")} - ${this.inviteInfo.inviterName || - this.inviteInfo.inviterEmail || - placeholder} +
+ ${msg( + str`Invited by ${ + this.inviteInfo.inviterName || + this.inviteInfo.inviterEmail || + placeholder + }` + )}

${msg( - html`You've been invited to join + html`You’ve been invited to join ${hasInviteInfo ? this.inviteInfo.orgName || msg("Browsertrix Cloud") : placeholder}` + >.` )}

+
${successMessage} -
+
${form}
${link}
@@ -284,7 +284,7 @@ export class LogInPage extends LiteElement { ?loading=${this.formState.value === "signingIn"} ?disabled=${this.formState.value === "backendInitializing"} type="submit" - >${msg("Log in")}${msg("Log In")} ${this.formState.value === "backendInitializing" ? html`
diff --git a/frontend/src/pages/reset-password.ts b/frontend/src/pages/reset-password.ts index 875862ce..be8d8793 100644 --- a/frontend/src/pages/reset-password.ts +++ b/frontend/src/pages/reset-password.ts @@ -1,20 +1,35 @@ import { state, property } from "lit/decorators.js"; -import { msg, localized } from "@lit/localize"; +import { str, msg, localized } from "@lit/localize"; +import debounce from "lodash/fp/debounce"; +import { when } from "lit/directives/when.js"; +import type { ZxcvbnResult } from "@zxcvbn-ts/core"; import type { ViewState } from "../utils/APIRouter"; import LiteElement, { html } from "../utils/LiteElement"; +import PasswordService from "../utils/PasswordService"; +import type { Input as BtrixInput } from "../components/input/input"; + +const { PASSWORD_MINLENGTH, PASSWORD_MAXLENGTH, PASSWORD_MIN_SCORE } = + PasswordService; @localized() export class ResetPassword extends LiteElement { @property({ type: Object }) viewState!: ViewState; + @state() + private pwStrengthResults: null | ZxcvbnResult = null; + @state() private serverError?: string; @state() private isSubmitting: boolean = false; + protected firstUpdated() { + PasswordService.setOptions(); + } + render() { let formError; @@ -29,22 +44,29 @@ export class ResetPassword extends LiteElement { } return html` -
-
+
+
+

+ ${msg( + str`Choose a strong password between ${PASSWORD_MINLENGTH}-${PASSWORD_MAXLENGTH} characters.` + )} +

+ ${when(this.pwStrengthResults, this.renderPasswordStrength)}
${formError} @@ -53,8 +75,10 @@ export class ResetPassword extends LiteElement { class="w-full" variant="primary" ?loading=${this.isSubmitting} + ?disabled=${!this.pwStrengthResults || + this.pwStrengthResults.score < PASSWORD_MIN_SCORE} type="submit" - >${msg("Change password")}${msg("Change Password")}
@@ -71,6 +95,25 @@ export class ResetPassword extends LiteElement { `; } + private renderPasswordStrength = () => html` +
+ + +
+ `; + + private onPasswordInput = debounce(150)(async (e: InputEvent) => { + const { value } = e.target as BtrixInput; + if (!value || value.length < 4) { + this.pwStrengthResults = null; + return; + } + this.pwStrengthResults = await PasswordService.checkStrength(value); + }) as any; + async onSubmit(event: SubmitEvent) { event.preventDefault(); this.isSubmitting = true; diff --git a/frontend/src/pages/sign-up.ts b/frontend/src/pages/sign-up.ts index 3735d684..51870d43 100644 --- a/frontend/src/pages/sign-up.ts +++ b/frontend/src/pages/sign-up.ts @@ -15,8 +15,8 @@ export class SignUp extends LiteElement { render() { return html` -
-
+
+
${this.isSignedUpWithoutAuth ? html`
=> { + const zxcvbnCommonPackage = await import( + /* webpackChunkName: "zxcvbnCommonPackage" */ "@zxcvbn-ts/language-common" + ); + const zxcvbnEnPackage = await import( + /* webpackChunkName: "zxcvbnEnPackage" */ "@zxcvbn-ts/language-en" + ); + + return { + dictionary: { + ...zxcvbnCommonPackage.dictionary, + ...zxcvbnEnPackage.dictionary, + }, + graphs: zxcvbnCommonPackage.adjacencyGraphs, + translations: zxcvbnEnPackage.translations, + }; +}; + +/** + * Test and estimate password strength + */ +export default class PasswordService { + static readonly PASSWORD_MINLENGTH = 8; + static readonly PASSWORD_MAXLENGTH = 64; + static readonly PASSWORD_MIN_SCORE = 3; + + static options?: OptionsType; + + /** + * Update zxcvbn options asynchronously + * @TODO Localize by loading different translations + * @param opts See https://zxcvbn-ts.github.io/zxcvbn/guide/options/ + */ + static async setOptions(opts?: OptionsType) { + if (!PasswordService.options) { + PasswordService.options = await loadOptions(); + } + if (opts) { + zxcvbnOptions.setOptions({ + ...PasswordService.options, + ...opts, + }); + } else { + zxcvbnOptions.setOptions(PasswordService.options); + } + } + + /** + * @param password + * @param userInputs Array of personal data to check against + * @returns {ZxcvbnResult} See https://zxcvbn-ts.github.io/zxcvbn/guide/getting-started/#output + */ + static async checkStrength( + password: string, + // User input to check, e.g. emails + userInputs?: string[] + ) { + return zxcvbn(password, userInputs); + } +} diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 8bc2e477..a9e9b1ad 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -1679,6 +1679,23 @@ resolved "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz" integrity sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ== +"@zxcvbn-ts/core@^3.0.4": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@zxcvbn-ts/core/-/core-3.0.4.tgz#c5bde72235eb6c273cec78b672bb47c0d7045cad" + integrity sha512-aQeiT0F09FuJaAqNrxynlAwZ2mW/1MdXakKWNmGM1Qp/VaY6CnB/GfnMS2T8gB2231Esp1/maCWd8vTG4OuShw== + dependencies: + fastest-levenshtein "1.0.16" + +"@zxcvbn-ts/language-common@^3.0.4": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@zxcvbn-ts/language-common/-/language-common-3.0.4.tgz#fa1d2a42f8c8a589555859795da90d6b8027b7c4" + integrity sha512-viSNNnRYtc7ULXzxrQIVUNwHAPSXRtoIwy/Tq4XQQdIknBzw4vz36lQLF6mvhMlTIlpjoN/Z1GFu/fwiAlUSsw== + +"@zxcvbn-ts/language-en@^3.0.2": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@zxcvbn-ts/language-en/-/language-en-3.0.2.tgz#162ada6b2b556444efd5a7700e70845cfde6d6ec" + integrity sha512-Zp+zL+I6Un2Bj0tRXNs6VUBq3Djt+hwTwUz4dkt2qgsQz47U0/XthZ4ULrT/RxjwJRl5LwiaKOOZeOtmixHnjg== + accepts@^1.3.5, accepts@~1.3.4, accepts@~1.3.5, accepts@~1.3.8: version "1.3.8" resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" @@ -3401,7 +3418,7 @@ fast-levenshtein@^2.0.6: resolved "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz" integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== -fastest-levenshtein@^1.0.12: +fastest-levenshtein@1.0.16, fastest-levenshtein@^1.0.12: version "1.0.16" resolved "https://registry.yarnpkg.com/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz#210e61b6ff181de91ea9b3d1b84fdedd47e034e5" integrity sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==