diff --git a/app/routes/_auth+/onboarding.tsx b/app/routes/_auth+/onboarding.tsx index 4c90e76f2..f75a8e9dc 100644 --- a/app/routes/_auth+/onboarding.tsx +++ b/app/routes/_auth+/onboarding.tsx @@ -7,7 +7,12 @@ import { z } from 'zod' import { CheckboxField, ErrorList, Field } from '#app/components/forms.tsx' import { Spacer } from '#app/components/spacer.tsx' import { StatusButton } from '#app/components/ui/status-button.tsx' -import { requireAnonymous, sessionKey, signup } from '#app/utils/auth.server.ts' +import { + checkCommonPassword, + requireAnonymous, + sessionKey, + signup, +} from '#app/utils/auth.server.ts' import { prisma } from '#app/utils/db.server.ts' import { checkHoneypot } from '#app/utils/honeypot.server.ts' import { useIsPending } from '#app/utils/misc.tsx' @@ -72,6 +77,14 @@ export async function action({ request }: Route.ActionArgs) { }) return } + const isCommonPassword = await checkCommonPassword(data.password) + if (isCommonPassword) { + ctx.addIssue({ + path: ['password'], + code: 'custom', + message: 'Password is too common', + }) + } }).transform(async (data) => { if (intent !== null) return { ...data, session: null } diff --git a/app/routes/_auth+/reset-password.tsx b/app/routes/_auth+/reset-password.tsx index b8581a781..581612313 100644 --- a/app/routes/_auth+/reset-password.tsx +++ b/app/routes/_auth+/reset-password.tsx @@ -5,7 +5,11 @@ import { data, redirect, Form } from 'react-router' import { GeneralErrorBoundary } from '#app/components/error-boundary.tsx' import { ErrorList, Field } from '#app/components/forms.tsx' import { StatusButton } from '#app/components/ui/status-button.tsx' -import { requireAnonymous, resetUserPassword } from '#app/utils/auth.server.ts' +import { + checkCommonPassword, + requireAnonymous, + resetUserPassword, +} from '#app/utils/auth.server.ts' import { useIsPending } from '#app/utils/misc.tsx' import { PasswordAndConfirmPasswordSchema } from '#app/utils/user-validation.ts' import { verifySessionStorage } from '#app/utils/verification.server.ts' @@ -41,8 +45,18 @@ export async function loader({ request }: Route.LoaderArgs) { export async function action({ request }: Route.ActionArgs) { const resetPasswordUsername = await requireResetPasswordUsername(request) const formData = await request.formData() - const submission = parseWithZod(formData, { - schema: ResetPasswordSchema, + const submission = await parseWithZod(formData, { + schema: ResetPasswordSchema.superRefine(async ({ password }, ctx) => { + const isCommonPassword = await checkCommonPassword(password) + if (isCommonPassword) { + ctx.addIssue({ + path: ['password'], + code: 'custom', + message: 'Password is too common', + }) + } + }), + async: true, }) if (submission.status !== 'success') { return data( diff --git a/app/utils/auth.server.ts b/app/utils/auth.server.ts index 2d369b9a4..3b55b28ca 100644 --- a/app/utils/auth.server.ts +++ b/app/utils/auth.server.ts @@ -1,3 +1,4 @@ +import crypto from 'node:crypto' import { type Connection, type Password, type User } from '@prisma/client' import bcrypt from 'bcryptjs' import { redirect } from 'react-router' @@ -255,3 +256,34 @@ export async function verifyUserPassword( return { id: userWithPassword.id } } + +export async function checkCommonPassword(password: string) { + const hash = crypto + .createHash('sha1') + .update(password, 'utf8') + .digest('hex') + .toUpperCase() + + const [prefix, suffix] = [hash.slice(0, 5), hash.slice(5)] + + const controller = new AbortController() + + try { + const timeoutId = setTimeout(() => controller.abort(), 1000) + + const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`) + + clearTimeout(timeoutId) + + if (!res.ok) false + + const data = await res.text() + return data.split('/\r?\n/').some((line) => line.includes(suffix)) + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + console.warn('Password check timed out') + } + console.warn('unknow error during password check', error) + return false + } +} diff --git a/tests/mocks/index.ts b/tests/mocks/index.ts index bb5da828d..260123fe6 100644 --- a/tests/mocks/index.ts +++ b/tests/mocks/index.ts @@ -1,6 +1,7 @@ import closeWithGrace from 'close-with-grace' import { setupServer } from 'msw/node' import { handlers as githubHandlers } from './github.ts' +import { handlers as pwnedPasswordApiHandlers } from './pwnedpasswords.ts' import { handlers as resendHandlers } from './resend.ts' import { handlers as tigrisHandlers } from './tigris.ts' @@ -8,6 +9,7 @@ export const server = setupServer( ...resendHandlers, ...githubHandlers, ...tigrisHandlers, + ...pwnedPasswordApiHandlers, ) server.listen({ diff --git a/tests/mocks/pwnedpasswords.ts b/tests/mocks/pwnedpasswords.ts new file mode 100644 index 000000000..298ae128b --- /dev/null +++ b/tests/mocks/pwnedpasswords.ts @@ -0,0 +1,7 @@ +import { http, HttpResponse } from 'msw' + +export const handlers = [ + http.get('https://api.pwnedpasswords.com/range/:prefix', () => { + return new HttpResponse('', { status: 200 }) + }), +]