Skip to content

Commit 344e467

Browse files
authored
fix: password validation rules (@fehmer) (monkeytypegame#6967)
fixes monkeytypegame#6966 , monkeytypegame#6965
1 parent 8810b1c commit 344e467

File tree

5 files changed

+122
-97
lines changed

5 files changed

+122
-97
lines changed

frontend/src/ts/elements/input-validation.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,9 @@ export type ValidationOptions<T> = (T extends string
143143
callback?: (result: ValidationResult) => void;
144144
};
145145

146+
export type ValidatedHtmlInputElement = HTMLInputElement & {
147+
isValid: () => boolean | undefined;
148+
};
146149
/**
147150
* adds an 'InputIndicator` to the given `inputElement` and updates its status depending on the given validation
148151
* @param inputElement
@@ -151,7 +154,7 @@ export type ValidationOptions<T> = (T extends string
151154
export function validateWithIndicator<T>(
152155
inputElement: HTMLInputElement,
153156
options: ValidationOptions<T>
154-
): void {
157+
): ValidatedHtmlInputElement {
155158
//use indicator
156159
const indicator = new InputIndicator(inputElement, {
157160
success: {
@@ -172,7 +175,10 @@ export function validateWithIndicator<T>(
172175
level: 0,
173176
},
174177
});
178+
179+
let isValid: boolean | undefined = undefined;
175180
const callback = (result: ValidationResult): void => {
181+
isValid = result.status === "success" || result.status === "warning";
176182
if (result.status === "failed" || result.status === "warning") {
177183
indicator.show(result.status, result.errorMessage);
178184
} else {
@@ -188,6 +194,11 @@ export function validateWithIndicator<T>(
188194
);
189195

190196
inputElement.addEventListener("input", handler);
197+
198+
const result = inputElement as ValidatedHtmlInputElement;
199+
result.isValid = () => isValid;
200+
201+
return result;
191202
}
192203

193204
export type ConfigInputOptions<K extends ConfigKey, T = ConfigType[K]> = {

frontend/src/ts/modals/simple-modals.ts

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ import {
2323
import {
2424
createErrorMessage,
2525
isDevEnvironment,
26-
isPasswordStrong,
2726
reloadAfter,
2827
} from "../utils/misc";
2928
import * as CustomTextState from "../states/custom-text-name";
@@ -38,9 +37,14 @@ import {
3837
} from "../utils/simple-modal";
3938
import { ShowOptions } from "../utils/animated-modal";
4039
import { GenerateDataRequest } from "@monkeytype/contracts/dev";
41-
import { UserEmailSchema, UserNameSchema } from "@monkeytype/schemas/users";
40+
import {
41+
PasswordSchema,
42+
UserEmailSchema,
43+
UserNameSchema,
44+
} from "@monkeytype/schemas/users";
4245
import { goToPage } from "../pages/leaderboards";
4346
import FileStorage from "../utils/file-storage";
47+
import { z } from "zod";
4448

4549
type PopupKey =
4650
| "updateEmail"
@@ -551,6 +555,9 @@ list.updatePassword = new SimpleModal({
551555
placeholder: "new password",
552556
type: "password",
553557
initVal: "",
558+
validation: {
559+
schema: isDevEnvironment() ? z.string().min(6) : PasswordSchema,
560+
},
554561
},
555562
{
556563
placeholder: "confirm new password",
@@ -580,14 +587,6 @@ list.updatePassword = new SimpleModal({
580587
};
581588
}
582589

583-
if (!isDevEnvironment() && !isPasswordStrong(newPassword)) {
584-
return {
585-
status: 0,
586-
message:
587-
"New password must contain at least one capital letter, number, a special character and must be between 8 and 64 characters long",
588-
};
589-
}
590-
591590
const reauth = await reauthenticate({ password: previousPass });
592591
if (reauth.status !== 1) {
593592
return {

frontend/src/ts/pages/login.ts

Lines changed: 89 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import Ape from "../ape";
22
import Page from "./page";
33
import * as Skeleton from "../utils/skeleton";
4-
import * as Misc from "../utils/misc";
54
import TypoList from "../utils/typo-list";
6-
import { UserEmailSchema, UserNameSchema } from "@monkeytype/schemas/users";
5+
import {
6+
PasswordSchema,
7+
UserEmailSchema,
8+
UserNameSchema,
9+
} from "@monkeytype/schemas/users";
710
import { validateWithIndicator } from "../elements/input-validation";
11+
import { isDevEnvironment } from "../utils/misc";
812
import { z } from "zod";
913

1014
let registerForm: {
@@ -39,11 +43,19 @@ export function hidePreloader(): void {
3943
$(".pageLogin .preloader").addClass("hidden");
4044
}
4145

46+
function isFormComplete(): boolean {
47+
return (
48+
registerForm.name !== undefined &&
49+
registerForm.email !== undefined &&
50+
registerForm.password !== undefined
51+
);
52+
}
53+
4254
export const updateSignupButton = (): void => {
43-
if (Object.values(registerForm).some((it) => it === undefined)) {
44-
disableSignUpButton();
45-
} else {
55+
if (isFormComplete()) {
4656
enableSignUpButton();
57+
} else {
58+
disableSignUpButton();
4759
}
4860
};
4961

@@ -53,9 +65,7 @@ type SignupData = {
5365
password: string;
5466
};
5567
export function getSignupData(): SignupData | false {
56-
return Object.values(registerForm).some((it) => it === undefined)
57-
? false
58-
: (registerForm as SignupData);
68+
return isFormComplete() ? (registerForm as SignupData) : false;
5969
}
6070

6171
const nameInputEl = document.querySelector(
@@ -84,9 +94,58 @@ let disposableEmailModule: typeof import("disposable-email-domains-js") | null =
8494
null;
8595
let moduleLoadAttempted = false;
8696

87-
const emailInputEl = document.querySelector(
88-
".page.pageLogin .register.side input.emailInput"
89-
) as HTMLInputElement;
97+
const emailInputEl = validateWithIndicator(
98+
document.querySelector(
99+
".page.pageLogin .register.side input.emailInput"
100+
) as HTMLInputElement,
101+
{
102+
schema: UserEmailSchema,
103+
isValid: async (email: string) => {
104+
const educationRegex =
105+
/@.*(student|education|school|\.edu$|\.edu\.|\.ac\.|\.sch\.)/i;
106+
if (educationRegex.test(email)) {
107+
return {
108+
warning:
109+
"Some education emails will fail to receive our messages, or disable the account as soon as you graduate. Consider using a personal email address.",
110+
};
111+
}
112+
113+
const emailHasTypo = TypoList.some((typo) => {
114+
return email.endsWith(typo);
115+
});
116+
if (emailHasTypo) {
117+
return {
118+
warning: "Please check your email address, it may contain a typo.",
119+
};
120+
}
121+
122+
if (
123+
disposableEmailModule &&
124+
disposableEmailModule.isDisposableEmail !== undefined
125+
) {
126+
try {
127+
if (disposableEmailModule.isDisposableEmail(email)) {
128+
return {
129+
warning:
130+
"Using a temporary email may cause issues with logging in, password resets and support. Consider using a permanent email address. Don't worry, we don't send spam.",
131+
};
132+
}
133+
} catch (e) {
134+
// Silent failure
135+
}
136+
}
137+
138+
return true;
139+
},
140+
debounceDelay: 0,
141+
callback: (result) => {
142+
if (result.status === "success") {
143+
//re-validate the verify email
144+
emailVerifyInputEl.dispatchEvent(new Event("input"));
145+
}
146+
},
147+
}
148+
);
90149

91150
emailInputEl.addEventListener("focus", async () => {
92151
if (!moduleLoadAttempted) {
@@ -99,54 +158,6 @@ emailInputEl.addEventListener("focus", async () => {
99158
}
100159
});
101160

102-
validateWithIndicator(emailInputEl, {
103-
schema: UserEmailSchema,
104-
isValid: async (email: string) => {
105-
const educationRegex =
106-
/@.*(student|education|school|\.edu$|\.edu\.|\.ac\.|\.sch\.)/i;
107-
if (educationRegex.test(email)) {
108-
return {
109-
warning:
110-
"Some education emails will fail to receive our messages, or disable the account as soon as you graduate. Consider using a personal email address.",
111-
};
112-
}
113-
114-
const emailHasTypo = TypoList.some((typo) => {
115-
return email.endsWith(typo);
116-
});
117-
if (emailHasTypo) {
118-
return {
119-
warning: "Please check your email address, it may contain a typo.",
120-
};
121-
}
122-
123-
if (
124-
disposableEmailModule &&
125-
disposableEmailModule.isDisposableEmail !== undefined
126-
) {
127-
try {
128-
if (disposableEmailModule.isDisposableEmail(email)) {
129-
return {
130-
warning:
131-
"Using a temporary email may cause issues with logging in, password resets and support. Consider using a permanent email address. Don't worry, we don't send spam.",
132-
};
133-
}
134-
} catch (e) {
135-
// Silent failure
136-
}
137-
}
138-
139-
return true;
140-
},
141-
debounceDelay: 0,
142-
callback: (result) => {
143-
if (result.status === "success") {
144-
//re-validate the verify email
145-
emailVerifyInputEl.dispatchEvent(new Event("input"));
146-
}
147-
},
148-
});
149-
150161
const emailVerifyInputEl = document.querySelector(
151162
".page.pageLogin .register.side input.verifyEmailInput"
152163
) as HTMLInputElement;
@@ -159,36 +170,27 @@ validateWithIndicator(emailVerifyInputEl, {
159170
debounceDelay: 0,
160171
callback: (result) => {
161172
registerForm.email =
162-
result.status === "success" ? emailInputEl.value : undefined;
173+
emailInputEl.isValid() && result.status === "success"
174+
? emailInputEl.value
175+
: undefined;
163176
updateSignupButton();
164177
},
165178
});
166179

167-
const passwordInputEl = document.querySelector(
168-
".page.pageLogin .register.side .passwordInput"
169-
) as HTMLInputElement;
170-
validateWithIndicator(passwordInputEl, {
171-
schema: z.string().min(6), //firebase requires min 6 chars, we apply stricter rules on prod
172-
isValid: async (password: string) => {
173-
if (!Misc.isDevEnvironment() && !Misc.isPasswordStrong(password)) {
174-
if (password.length < 8) {
175-
return "Password must be at least 8 characters";
176-
} else if (password.length > 64) {
177-
return "Password must be at most 64 characters";
178-
} else {
179-
return "Password must contain at least one capital letter, number, and special character";
180+
const passwordInputEl = validateWithIndicator(
181+
document.querySelector(
182+
".page.pageLogin .register.side .passwordInput"
183+
) as HTMLInputElement,
184+
{
185+
schema: isDevEnvironment() ? z.string().min(6) : PasswordSchema,
186+
callback: (result) => {
187+
if (result.status === "success") {
188+
//re-validate the verify password
189+
passwordVerifyInputEl.dispatchEvent(new Event("input"));
180190
}
181-
}
182-
return true;
183-
},
184-
debounceDelay: 0,
185-
callback: (result) => {
186-
if (result.status === "success") {
187-
//re-validate the verify password
188-
passwordVerifyInputEl.dispatchEvent(new Event("input"));
189-
}
190-
},
191-
});
191+
},
192+
}
193+
);
192194

193195
const passwordVerifyInputEl = document.querySelector(
194196
".page.pageLogin .register.side .verifyPasswordInput"
@@ -202,7 +204,9 @@ validateWithIndicator(passwordVerifyInputEl, {
202204
debounceDelay: 0,
203205
callback: (result) => {
204206
registerForm.password =
205-
result.status === "success" ? passwordInputEl.value : undefined;
207+
passwordInputEl.isValid() && result.status === "success"
208+
? passwordInputEl.value
209+
: undefined;
206210
updateSignupButton();
207211
},
208212
});

packages/contracts/src/users.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -439,7 +439,7 @@ export const usersContract = c.router(
439439
},
440440
updatePassword: {
441441
summary: "update password",
442-
description: "Updates a user's email",
442+
description: "Updates a user's password",
443443
method: "PATCH",
444444
path: "/password",
445445
body: UpdatePasswordRequestSchema.strict(),

packages/schemas/src/users.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,3 +371,14 @@ export const ReportUserReasonSchema = z.enum([
371371
"Suspected cheating",
372372
]);
373373
export type ReportUserReason = z.infer<typeof ReportUserReasonSchema>;
374+
375+
export const PasswordSchema = z
376+
.string()
377+
.min(8, { message: "must be at least 8 characters" })
378+
.max(64, { message: "must be at most 64 characters" })
379+
.regex(/[A-Z]/, { message: "must contain at least one capital letter" })
380+
.regex(/[\d]/, { message: "must contain at least one number" })
381+
.regex(/[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/, {
382+
message: "must contain at least one special character",
383+
});
384+
export type Password = z.infer<typeof PasswordSchema>;

0 commit comments

Comments
 (0)