From fbe7ca4c9e3e2d1ee35bea8e2087ada99bd71cb8 Mon Sep 17 00:00:00 2001 From: Giancarlo Buomprisco Date: Mon, 17 Jun 2024 14:37:18 +0800 Subject: [PATCH] Refactor password validation and enhance localization (#35) * Refactor password validation and enhance localization A new PasswordSchema is introduced to handle the password validation in a centralized way and is used across all authentication schemas. The password requirements are also altered with additional special character, number, and uppercase letter checks. Error messages now utilize localization to provide dynamic error notifications. * Sign out before impersonating a user This update adds a call to sign out before impersonating a user. This is an additional measure to ensure the security of the system, accentuating the isolation of user sessions. * Refactor password validation and refine password schemas The password validation process has been restructured. The 'PasswordSchema' is now split into two separate schemas - 'PasswordSchema' and 'RefinedPasswordSchema'. The logic for validating repeating passwords has been moved into a separate function named 'refineRepeatPassword'. This streamlines the password validation process and ensures consistency across password checks. --- apps/web/public/locales/en/auth.json | 8 +- .../admin-impersonate-user-dialog.tsx | 2 + .../auth/src/schemas/password-reset.schema.ts | 11 ++- .../src/schemas/password-sign-in.schema.ts | 4 +- .../src/schemas/password-sign-up.schema.ts | 16 ++-- .../auth/src/schemas/password.schema.ts | 82 +++++++++++++++++++ packages/ui/src/shadcn/form.tsx | 3 +- 7 files changed, 105 insertions(+), 21 deletions(-) create mode 100644 packages/features/auth/src/schemas/password.schema.ts diff --git a/apps/web/public/locales/en/auth.json b/apps/web/public/locales/en/auth.json index a54f223bd..c1d9dd51e 100644 --- a/apps/web/public/locales/en/auth.json +++ b/apps/web/public/locales/en/auth.json @@ -26,7 +26,6 @@ "passwordHint": "Ensure it's at least 8 characters", "repeatPasswordHint": "Type your password again", "repeatPassword": "Repeat password", - "passwordsDoNotMatch": "The passwords do not match", "passwordForgottenQuestion": "Password forgotten?", "passwordResetLabel": "Reset Password", "passwordResetSubheading": "Enter your email address below. You will receive a link to reset your password.", @@ -69,6 +68,11 @@ "default": "We have encountered an error. Please ensure you have a working internet connection and try again", "generic": "Sorry, we weren't able to authenticate you. Please try again.", "link": "Sorry, we encountered an error while sending your link. Please try again.", - "codeVerifierMismatch": "It looks like you're trying to sign in using a different browser than the one you used to request the sign in link. Please try again using the same browser." + "codeVerifierMismatch": "It looks like you're trying to sign in using a different browser than the one you used to request the sign in link. Please try again using the same browser.", + "minPasswordLength": "Password must be at least 8 characters long", + "passwordsDoNotMatch": "The passwords do not match", + "minPasswordNumbers": "Password must contain at least one number", + "minPasswordSpecialChars": "Password must contain at least one special character", + "uppercasePassword": "Password must contain at least one uppercase letter" } } diff --git a/packages/features/admin/src/components/admin-impersonate-user-dialog.tsx b/packages/features/admin/src/components/admin-impersonate-user-dialog.tsx index 80786c91f..17447bf82 100644 --- a/packages/features/admin/src/components/admin-impersonate-user-dialog.tsx +++ b/packages/features/admin/src/components/admin-impersonate-user-dialog.tsx @@ -142,6 +142,8 @@ function useSetSession(tokens: { accessToken: string; refreshToken: string }) { queryKey: ['impersonate-user', tokens.accessToken, tokens.refreshToken], gcTime: 0, queryFn: async () => { + await supabase.auth.signOut(); + await supabase.auth.setSession({ refresh_token: tokens.refreshToken, access_token: tokens.accessToken, diff --git a/packages/features/auth/src/schemas/password-reset.schema.ts b/packages/features/auth/src/schemas/password-reset.schema.ts index 8c0c43fce..193edd600 100644 --- a/packages/features/auth/src/schemas/password-reset.schema.ts +++ b/packages/features/auth/src/schemas/password-reset.schema.ts @@ -1,11 +1,10 @@ import { z } from 'zod'; +import { RefinedPasswordSchema, refineRepeatPassword } from './password.schema'; + export const PasswordResetSchema = z .object({ - password: z.string().min(8).max(99), - repeatPassword: z.string().min(8).max(99), + password: RefinedPasswordSchema, + repeatPassword: RefinedPasswordSchema, }) - .refine((data) => data.password === data.repeatPassword, { - message: 'Passwords do not match', - path: ['repeatPassword'], - }); + .superRefine(refineRepeatPassword); diff --git a/packages/features/auth/src/schemas/password-sign-in.schema.ts b/packages/features/auth/src/schemas/password-sign-in.schema.ts index 3a2efec9f..823446c08 100644 --- a/packages/features/auth/src/schemas/password-sign-in.schema.ts +++ b/packages/features/auth/src/schemas/password-sign-in.schema.ts @@ -1,6 +1,8 @@ import { z } from 'zod'; +import { PasswordSchema } from './password.schema'; + export const PasswordSignInSchema = z.object({ email: z.string().email(), - password: z.string().min(8).max(99), + password: PasswordSchema, }); diff --git a/packages/features/auth/src/schemas/password-sign-up.schema.ts b/packages/features/auth/src/schemas/password-sign-up.schema.ts index 0f62e4ed6..828924d12 100644 --- a/packages/features/auth/src/schemas/password-sign-up.schema.ts +++ b/packages/features/auth/src/schemas/password-sign-up.schema.ts @@ -1,17 +1,11 @@ import { z } from 'zod'; +import { RefinedPasswordSchema, refineRepeatPassword } from './password.schema'; + export const PasswordSignUpSchema = z .object({ email: z.string().email(), - password: z.string().min(8).max(99), - repeatPassword: z.string().min(8).max(99), + password: RefinedPasswordSchema, + repeatPassword: RefinedPasswordSchema, }) - .refine( - (schema) => { - return schema.password === schema.repeatPassword; - }, - { - message: 'Passwords do not match', - path: ['repeatPassword'], - }, - ); + .superRefine(refineRepeatPassword); diff --git a/packages/features/auth/src/schemas/password.schema.ts b/packages/features/auth/src/schemas/password.schema.ts new file mode 100644 index 000000000..c31b697d5 --- /dev/null +++ b/packages/features/auth/src/schemas/password.schema.ts @@ -0,0 +1,82 @@ +import { z } from 'zod'; + +/** + * Password requirements + * These are the requirements for the password when signing up or changing the password + */ +const requirements = { + minLength: 8, + maxLength: 99, + specialChars: + process.env.NEXT_PUBLIC_PASSWORD_REQUIRE_SPECIAL_CHARS === 'true', + numbers: process.env.NEXT_PUBLIC_PASSWORD_REQUIRE_NUMBERS === 'true', + uppercase: process.env.NEXT_PUBLIC_PASSWORD_REQUIRE_UPPERCASE === 'true', +}; + +/** + * Password schema + * This is used to validate the password on sign in (for existing users when requirements are not enforced) + */ +export const PasswordSchema = z + .string() + .min(requirements.minLength) + .max(requirements.maxLength); + +/** + * Refined password schema with additional requirements + * This is required to validate the password requirements on sign up and password change + */ +export const RefinedPasswordSchema = PasswordSchema.superRefine((val, ctx) => + validatePassword(val, ctx), +); + +export function refineRepeatPassword( + data: { password: string; repeatPassword: string }, + ctx: z.RefinementCtx, +) { + if (data.password !== data.repeatPassword) { + ctx.addIssue({ + message: 'auth:errors.passwordsDoNotMatch', + path: ['repeatPassword'], + code: 'custom', + }); + } + + return true; +} + +function validatePassword(password: string, ctx: z.RefinementCtx) { + if (requirements.specialChars) { + const specialCharsCount = + password.match(/[!@#$%^&*(),.?":{}|<>]/g)?.length ?? 0; + + if (specialCharsCount < 1) { + ctx.addIssue({ + message: 'auth:errors.minPasswordSpecialChars', + code: 'custom', + }); + } + } + + if (requirements.numbers) { + const numbersCount = password.match(/\d/g)?.length ?? 0; + + if (numbersCount < 1) { + ctx.addIssue({ + message: 'auth:errors.minPasswordNumbers', + code: 'custom', + }); + } + } + + if (requirements.uppercase) { + if (!/[A-Z]/.test(password)) { + ctx.addIssue({ + message: 'auth:errors.uppercasePassword', + code: 'custom', + }); + } + } + + return true; +} diff --git a/packages/ui/src/shadcn/form.tsx b/packages/ui/src/shadcn/form.tsx index 939e7cc1b..298da78a2 100644 --- a/packages/ui/src/shadcn/form.tsx +++ b/packages/ui/src/shadcn/form.tsx @@ -9,6 +9,7 @@ import { Controller, FormProvider, useFormContext } from 'react-hook-form'; import { cn } from '../utils'; import { Label } from './label'; +import {Trans} from "../makerkit/trans"; const Form = FormProvider; @@ -156,7 +157,7 @@ const FormMessage = React.forwardRef< className={cn('text-[0.8rem] font-medium text-destructive', className)} {...props} > - {body} + {typeof body === 'string' ? : body}

); });