Enforce strong passwords in UI (#1266)

This commit is contained in:
sua yoo 2023-10-12 19:36:59 -07:00 committed by GitHub
parent 834fa72baf
commit 630c00c5b0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 420 additions and 32 deletions

View File

@ -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",

View File

@ -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<SlInput | null>;
@ -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}
></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>
@ -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;

View File

@ -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);
});

View File

@ -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`
<div class="sl-label">

View File

@ -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>
`;
}
}

View File

@ -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 {
</btrix-input>
`}
</div>
<div class="mb-5">
<div class="mb-5 list-">
<btrix-input
id="name"
name="name"
label=${msg("Your name")}
placeholder=${msg("Lisa Simpson", {
desc: "Example user's name",
desc: "Example users name",
})}
autocomplete="nickname"
minlength="2"
required
>
</btrix-input>
<p class="mt-2 text-sm text-gray-500">
${msg("Your name will be visible to organization collaborators.")}
<p class="mt-2 text-gray-500">
${msg(
"Your full name, nickname, or another name that org collaborators can see."
)}
</p>
</div>
<div class="mb-5">
@ -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}
>
</btrix-input>
<p class="mt-2 text-sm text-gray-500">
${msg("Choose a strong password between 8-64 characters.")}
<p class="mt-2 text-gray-500">
${msg(
str`Choose a strong password between ${PASSWORD_MINLENGTH}${PASSWORD_MAXLENGTH} characters.`
)}
</p>
${when(this.pwStrengthResults, this.renderPasswordStrength)}
</div>
${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")}</sl-button
>
@ -120,8 +144,33 @@ export class SignUpForm extends LiteElement {
`;
}
private renderPasswordStrength = () => html`
<div class="my-3">
<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 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;

View File

@ -56,26 +56,29 @@ export class Join extends LiteElement {
return html`
<article class="w-full p-5 flex flex-col md:flex-row justify-center">
<div class="max-w-sm md:mt-12 md:mr-12">
<div class="mb-3 text-sm text-gray-400">
${msg("Invited by ")}
${this.inviteInfo.inviterName ||
this.inviteInfo.inviterEmail ||
placeholder}
<div class="mb-3 text-gray-500">
${msg(
str`Invited by ${
this.inviteInfo.inviterName ||
this.inviteInfo.inviterEmail ||
placeholder
}`
)}
</div>
<p class="text-xl md:text-2xl font-semibold mb-5">
${msg(
html`You've been invited to join
html`Youve been invited to join
<span class="text-primary break-words"
>${hasInviteInfo
? this.inviteInfo.orgName || msg("Browsertrix Cloud")
: placeholder}</span
>`
>.`
)}
</p>
</div>
<main
class="max-w-md md:bg-white md:shadow-xl md:rounded-lg md:px-12 md:py-12"
class="max-w-md md:bg-white md:border md:shadow-lg md:rounded-lg md:p-10"
>
<btrix-sign-up-form
email=${this.email!}

View File

@ -216,10 +216,10 @@ export class LogInPage extends LiteElement {
}
return html`
<article class="w-full max-w-sm grid gap-5">
<article class="w-full max-w-md grid gap-5">
${successMessage}
<main class="md:bg-white md:shadow-xl md:rounded-lg p-12">
<main class="md:bg-white md:border md:shadow-lg md:rounded-lg p-10">
<div>${form}</div>
</main>
<footer class="text-center">${link}</footer>
@ -284,7 +284,7 @@ export class LogInPage extends LiteElement {
?loading=${this.formState.value === "signingIn"}
?disabled=${this.formState.value === "backendInitializing"}
type="submit"
>${msg("Log in")}</sl-button
>${msg("Log In")}</sl-button
>
${this.formState.value === "backendInitializing"
? html` <div class="mt-3">

View File

@ -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`
<div class="w-full max-w-sm grid gap-5">
<div class="md:bg-white md:shadow-xl md:rounded-lg md:px-12 md:py-12">
<div class="w-full max-w-md grid gap-5">
<div class="md:bg-white md:border md:shadow-lg md:rounded-lg md:p-10">
<form @submit=${this.onSubmit} aria-describedby="formError">
<div class="mb-5">
<btrix-input
id="password"
name="password"
type="password"
label="${msg("New password")}"
label="${msg("Enter new password")}"
help-text=${msg("Must be between 8-64 characters")}
minlength="8"
autocomplete="new-password"
passwordToggle
required
@input=${this.onPasswordInput}
>
</btrix-input>
<p class="mt-2 text-gray-500">
${msg(
str`Choose a strong password between ${PASSWORD_MINLENGTH}-${PASSWORD_MAXLENGTH} characters.`
)}
</p>
${when(this.pwStrengthResults, this.renderPasswordStrength)}
</div>
${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")}</sl-button
>${msg("Change Password")}</sl-button
>
</form>
</div>
@ -71,6 +95,25 @@ export class ResetPassword extends LiteElement {
`;
}
private renderPasswordStrength = () => html`
<div class="my-3">
<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 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;

View File

@ -15,8 +15,8 @@ export class SignUp extends LiteElement {
render() {
return html`
<article class="w-full max-w-sm grid gap-5">
<main class="md:bg-white md:shadow-xl md:rounded-lg p-12">
<article class="w-full max-w-md grid gap-5">
<main class="md:bg-white md:border md:shadow-lg md:rounded-lg p-10">
${this.isSignedUpWithoutAuth
? html`
<div

View File

@ -0,0 +1,62 @@
import { zxcvbn, zxcvbnOptions, OptionsType } from "@zxcvbn-ts/core";
const loadOptions = async (): Promise<OptionsType> => {
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);
}
}

View File

@ -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==