Skip to content

Commit

Permalink
remove verification via username (including forgot password, login an…
Browse files Browse the repository at this point in the history
…d any other route)
  • Loading branch information
itsmegood committed Feb 2, 2024
1 parent 60ee504 commit 0b683a8
Show file tree
Hide file tree
Showing 11 changed files with 205 additions and 77 deletions.
2 changes: 2 additions & 0 deletions app/routes/_auth+/auth.$provider.callback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ test('gives an error if the account is already connected to another user', async
await prisma.user.create({
data: {
...createUser(),
platformStatusKey: 'ACTIVE',
connections: {
create: {
providerName: GITHUB_PROVIDER_NAME,
Expand Down Expand Up @@ -246,6 +247,7 @@ async function setupUser(userData = createUser()) {
user: {
create: {
...userData,
platformStatusKey: 'active',
},
},
},
Expand Down
29 changes: 14 additions & 15 deletions app/routes/_auth+/forgot-password.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,30 +16,28 @@ import { StatusButton } from '#app/components/ui/status-button.tsx'
import { prisma } from '#app/utils/db.server.ts'
import { sendEmail } from '#app/utils/email.server.ts'
import { checkHoneypot } from '#app/utils/honeypot.server.ts'
import { EmailSchema, UsernameSchema } from '#app/utils/user-validation.ts'
import { EmailSchema } from '#app/utils/user-validation.ts'
import { prepareVerification } from './verify.tsx'

const ForgotPasswordSchema = z.object({
usernameOrEmail: z.union([EmailSchema, UsernameSchema]),
email: EmailSchema,
})

export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData()
checkHoneypot(formData)

const submission = await parseWithZod(formData, {
schema: ForgotPasswordSchema.superRefine(async (data, ctx) => {
const user = await prisma.user.findFirst({
where: {
OR: [
{ email: data.usernameOrEmail },
{ username: data.usernameOrEmail },
],
email: data.email,
},
select: { id: true },
})
if (!user) {
ctx.addIssue({
path: ['usernameOrEmail'],
path: ['email'],
code: z.ZodIssueCode.custom,
message: 'No user exists with this username or email',
})
Expand All @@ -48,29 +46,30 @@ export async function action({ request }: ActionFunctionArgs) {
}),
async: true,
})

if (submission.status !== 'success') {
return json(
{ result: submission.reply() },
{ status: submission.status === 'error' ? 400 : 200 },
)
}
const { usernameOrEmail } = submission.value
const { email } = submission.value

const user = await prisma.user.findFirstOrThrow({
where: { OR: [{ email: usernameOrEmail }, { username: usernameOrEmail }] },
select: { email: true, username: true },
where: { email },
select: { email: true },
})

const { verifyUrl, redirectTo, otp } = await prepareVerification({
period: 10 * 60,
request,
type: 'reset-password',
target: usernameOrEmail,
target: email,
})

const response = await sendEmail({
to: user.email,
subject: `Epic Notes Password Reset`,
subject: `BookBreeze Password Reset`,
react: (
<ForgotPasswordEmail onboardingUrl={verifyUrl.toString()} otp={otp} />
),
Expand Down Expand Up @@ -145,14 +144,14 @@ export default function ForgotPasswordRoute() {
<div>
<Field
labelProps={{
htmlFor: fields.usernameOrEmail.id,
htmlFor: fields.email.id,
children: 'Username or Email',
}}
inputProps={{
autoFocus: true,
...getInputProps(fields.usernameOrEmail, { type: 'text' }),
...getInputProps(fields.email, { type: 'text' }),
}}
errors={fields.usernameOrEmail.errors}
errors={fields.email.errors}
/>
</div>
<ErrorList errors={form.errors} id={form.errorId} />
Expand Down
17 changes: 11 additions & 6 deletions app/routes/_auth+/login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,11 @@ import { checkHoneypot } from '#app/utils/honeypot.server.ts'
import { combineResponseInits, useIsPending } from '#app/utils/misc.tsx'
import { authSessionStorage } from '#app/utils/session.server.ts'
import { redirectWithToast } from '#app/utils/toast.server.ts'
import { PasswordSchema, UsernameSchema } from '#app/utils/user-validation.ts'
import {
EmailSchema,
PasswordSchema,
// UsernameSchema,
} from '#app/utils/user-validation.ts'
import { verifySessionStorage } from '#app/utils/verification.server.ts'
import { getRedirectToUrl, type VerifyFunctionArgs } from './verify.tsx'

Expand Down Expand Up @@ -184,7 +188,8 @@ export async function shouldRequestTwoFA(request: Request) {
}

const LoginFormSchema = z.object({
username: UsernameSchema,
// username: UsernameSchema,
email: EmailSchema,
password: PasswordSchema,
redirectTo: z.string().optional(),
remember: z.boolean().optional(),
Expand Down Expand Up @@ -268,14 +273,14 @@ export default function LoginPage() {
<Form method="POST" {...getFormProps(form)}>
<HoneypotInputs />
<Field
labelProps={{ children: 'Username' }}
labelProps={{ children: 'Email' }}
inputProps={{
...getInputProps(fields.username, { type: 'text' }),
...getInputProps(fields.email, { type: 'email' }),
autoFocus: true,
className: 'lowercase',
autoComplete: 'username',
autoComplete: 'email',
}}
errors={fields.username.errors}
errors={fields.email.errors}
/>

<Field
Expand Down
31 changes: 15 additions & 16 deletions app/routes/_auth+/reset-password.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,14 @@ import { Form, useActionData, useLoaderData } from '@remix-run/react'
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 { requireAnonymous, resetEmailPassword } from '#app/utils/auth.server.ts'
import { prisma } from '#app/utils/db.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'
import { type VerifyFunctionArgs } from './verify.tsx'

const resetPasswordUsernameSessionKey = 'resetPasswordUsername'
const resetPasswordEmailSessionKey = 'resetPasswordEmail'

export async function handleVerification({ submission }: VerifyFunctionArgs) {
invariant(
Expand All @@ -28,8 +28,9 @@ export async function handleVerification({ submission }: VerifyFunctionArgs) {
)
const target = submission.value.target
const user = await prisma.user.findFirst({
where: { OR: [{ email: target }, { username: target }] },
select: { email: true, username: true },
// where: { OR: [{ email: target }, { username: target }] },
where: { email: target },
select: { email: true },
})
// we don't want to say the user is not found if the email is not found
// because that would allow an attacker to check if an email is registered
Expand All @@ -45,7 +46,7 @@ export async function handleVerification({ submission }: VerifyFunctionArgs) {
}

const verifySession = await verifySessionStorage.getSession()
verifySession.set(resetPasswordUsernameSessionKey, user.username)
verifySession.set(resetPasswordEmailSessionKey, user.email)
return redirect('/reset-password', {
headers: {
'set-cookie': await verifySessionStorage.commitSession(verifySession),
Expand All @@ -55,27 +56,25 @@ export async function handleVerification({ submission }: VerifyFunctionArgs) {

const ResetPasswordSchema = PasswordAndConfirmPasswordSchema

async function requireResetPasswordUsername(request: Request) {
async function requireResetPasswordEmail(request: Request) {
await requireAnonymous(request)
const verifySession = await verifySessionStorage.getSession(
request.headers.get('cookie'),
)
const resetPasswordUsername = verifySession.get(
resetPasswordUsernameSessionKey,
)
if (typeof resetPasswordUsername !== 'string' || !resetPasswordUsername) {
const resetPasswordEmail = verifySession.get(resetPasswordEmailSessionKey)
if (typeof resetPasswordEmail !== 'string' || !resetPasswordEmail) {
throw redirect('/login')
}
return resetPasswordUsername
return resetPasswordEmail
}

export async function loader({ request }: LoaderFunctionArgs) {
const resetPasswordUsername = await requireResetPasswordUsername(request)
return json({ resetPasswordUsername })
const resetPasswordEmail = await requireResetPasswordEmail(request)
return json({ resetPasswordEmail })
}

export async function action({ request }: ActionFunctionArgs) {
const resetPasswordUsername = await requireResetPasswordUsername(request)
const resetPasswordEmail = await requireResetPasswordEmail(request)
const formData = await request.formData()
const submission = parseWithZod(formData, {
schema: ResetPasswordSchema,
Expand All @@ -88,7 +87,7 @@ export async function action({ request }: ActionFunctionArgs) {
}
const { password } = submission.value

await resetUserPassword({ username: resetPasswordUsername, password })
await resetEmailPassword({ email: resetPasswordEmail, password })
const verifySession = await verifySessionStorage.getSession()
return redirect('/login', {
headers: {
Expand Down Expand Up @@ -121,7 +120,7 @@ export default function ResetPasswordPage() {
<div className="text-center">
<h1 className="text-h1">Password Reset</h1>
<p className="mt-3 text-body-md text-muted-foreground">
Hi, {data.resetPasswordUsername}. No worries. It happens all the time.
Hi, {data.resetPasswordEmail}. No worries. It happens all the time.
</p>
</div>
<div className="mx-auto mt-16 min-w-full max-w-sm sm:min-w-[368px]">
Expand Down
12 changes: 10 additions & 2 deletions app/routes/users+/$username.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@ test('The user profile when not logged in as self', async () => {
userImages[faker.number.int({ min: 0, max: userImages.length - 1 })]
const user = await prisma.user.create({
select: { id: true, username: true, name: true },
data: { ...createUser(), image: { create: userImage } },
data: {
...createUser(),
image: { create: userImage },
platformStatusKey: 'ACTIVE',
},
})
const App = createRemixStub([
{
Expand All @@ -43,7 +47,11 @@ test('The user profile when logged in as self', async () => {
userImages[faker.number.int({ min: 0, max: userImages.length - 1 })]
const user = await prisma.user.create({
select: { id: true, username: true, name: true },
data: { ...createUser(), image: { create: userImage } },
data: {
...createUser(),
image: { create: userImage },
platformStatusKey: 'ACTIVE',
},
})
const session = await prisma.session.create({
select: { id: true },
Expand Down
40 changes: 32 additions & 8 deletions app/utils/auth.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import bcrypt from 'bcryptjs'
import { Authenticator } from 'remix-auth'
import { safeRedirect } from 'remix-utils/safe-redirect'
import { connectionSessionStorage, providers } from './connections.server.ts'
import { PLATFORM_STATUS } from './constants/platform-status.ts'
import { prisma } from './db.server.ts'
import { combineHeaders, downloadFile } from './misc.tsx'
import { type ProviderUser } from './providers/provider.ts'
Expand Down Expand Up @@ -83,13 +84,14 @@ export async function requireAnonymous(request: Request) {
}

export async function login({
username,
// username,
email,
password,
}: {
username: User['username']
email: User['email']
password: string
}) {
const user = await verifyUserPassword({ username }, password)
const user = await verifyUserPassword({ email }, password)
if (!user) return null
const session = await prisma.session.create({
select: { id: true, expirationDate: true, userId: true },
Expand All @@ -101,16 +103,36 @@ export async function login({
return session
}

export async function resetUserPassword({
username,
// export async function resetUserPassword({
// username,
// password,
// }: {
// username: User['username']
// password: string
// }) {
// const hashedPassword = await getPasswordHash(password)
// return prisma.user.update({
// where: { username },
// data: {
// password: {
// update: {
// hash: hashedPassword,
// },
// },
// },
// })
// }

export async function resetEmailPassword({
email,
password,
}: {
username: User['username']
email: User['email']
password: string
}) {
const hashedPassword = await getPasswordHash(password)
return prisma.user.update({
where: { username },
where: { email },
data: {
password: {
update: {
Expand Down Expand Up @@ -143,6 +165,7 @@ export async function signup({
username: username.toLowerCase(),
name,
roles: { connect: { name: 'user' } },
platformStatusKey: PLATFORM_STATUS.ACTIVE.KEY,
password: {
create: {
hash: hashedPassword,
Expand Down Expand Up @@ -181,6 +204,7 @@ export async function signupWithConnection({
username: username.toLowerCase(),
name,
roles: { connect: { name: 'user' } },
platformStatusKey: PLATFORM_STATUS.ACTIVE.KEY,
connections: { create: { providerId, providerName } },
image: imageUrl
? { create: await downloadFile(imageUrl) }
Expand Down Expand Up @@ -230,7 +254,7 @@ export async function getPasswordHash(password: string) {
}

export async function verifyUserPassword(
where: Pick<User, 'username'> | Pick<User, 'id'>,
where: Pick<User, 'email'> | Pick<User, 'id'>,
password: Password['hash'],
) {
const userWithPassword = await prisma.user.findUnique({
Expand Down
6 changes: 6 additions & 0 deletions app/utils/constants/platform-status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export const PLATFORM_STATUS = {
ACTIVE: { KEY: 'ACTIVE', LABEL: 'Active', COLOR: 'green' },
IN_REVIEW: { KEY: 'IN_REVIEW', LABEL: 'In Review', COLOR: 'orange' },
SUSPENDED: { KEY: 'SUSPENDED', LABEL: 'Suspended', COLOR: 'red' },
DELETED: { KEY: 'DELETED', LABEL: 'Deleted', COLOR: 'red' },
}
Loading

0 comments on commit 0b683a8

Please sign in to comment.