Skip to content

Commit

Permalink
feat: increase entropy of email login OTP (#230)
Browse files Browse the repository at this point in the history
* feat: move lib/auth.ts to its own directory, add constants

* feat: add email auth schema to share between client and server

* feat: use shared zod schema in email auth procedures

* feat: use shared zod schema in client

* fix: move lib/auth/session.ts into server folder

since it uses some env vars, incorrect to put it in lib

* feat: simplify login email state

use one object (since they are always updated at the same time) instead of multiple different strings

* feat: increase entropy of login OTP

* feat: add handling for new alphanum OTP

* feat: make BAD_REQUEST not retryable

* feat: reset OTP field on resend OTP

* fix: update email otp tests

* feat: update otp story

* fix: correct success test return

* fix: test again
  • Loading branch information
karrui authored Dec 1, 2023
1 parent abf6dc5 commit 1c6114e
Show file tree
Hide file tree
Showing 17 changed files with 203 additions and 100 deletions.
14 changes: 3 additions & 11 deletions src/features/sign-in/components/Emailnput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,16 @@ import { FormControl, FormLabel, Input, Wrap } from '@chakra-ui/react'
import { Button, FormErrorMessage } from '@opengovsg/design-system-react'
import { useRouter } from 'next/router'
import { useEffect } from 'react'
import { z } from 'zod'
import { useZodForm } from '~/lib/form'
import { trpc } from '~/utils/trpc'
import { SgidLoginButton } from './SgidLoginButton'
import { emailSignInSchema } from '~/schemas/auth/email/sign-in'
import { type VfnStepData } from './SignInContext'

interface EmailInputProps {
onSuccess: (email: string) => void
onSuccess: (props: VfnStepData) => void
}

export const emailSignInSchema = z.object({
email: z
.string()
.trim()
.toLowerCase()
.min(1, 'Please enter an email address.')
.email({ message: 'Please enter a valid email address.' }),
})

export const EmailInput: React.FC<EmailInputProps> = ({ onSuccess }) => {
const {
register,
Expand Down
20 changes: 10 additions & 10 deletions src/features/sign-in/components/SignInContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,8 @@ import {
type SignInStates = {
timer: number
setTimer: Dispatch<SetStateAction<number>>
email: string
setEmail: Dispatch<SetStateAction<string>>
showVerificationStep: boolean
setShowVerificationStep: Dispatch<SetStateAction<boolean>>
vfnStepData: VfnStepData | undefined
setVfnStepData: Dispatch<SetStateAction<VfnStepData | undefined>>
delayForResendSeconds: number
}

Expand All @@ -39,21 +37,23 @@ interface SignInContextProviderProps {
delayForResendSeconds?: number
}

export type VfnStepData = {
email: string
otpPrefix: string
}

export const SignInContextProvider = ({
children,
delayForResendSeconds = 60,
}: PropsWithChildren<SignInContextProviderProps>) => {
const [email, setEmail] = useState('')
const [showVerificationStep, setShowVerificationStep] = useState(false)
const [vfnStepData, setVfnStepData] = useState<VfnStepData>()
const [timer, setTimer] = useState(delayForResendSeconds)

return (
<SignInContext.Provider
value={{
email,
setEmail,
showVerificationStep,
setShowVerificationStep,
vfnStepData,
setVfnStepData,
timer,
setTimer,
delayForResendSeconds,
Expand Down
14 changes: 6 additions & 8 deletions src/features/sign-in/components/SignInForm.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,19 @@
import { useCallback } from 'react'
import { useSignInContext } from './SignInContext'
import { type VfnStepData, useSignInContext } from './SignInContext'
import { EmailInput } from './Emailnput'
import { VerificationInput } from './VerificationInput'

export const SignInForm = () => {
const { showVerificationStep, setEmail, setShowVerificationStep } =
useSignInContext()
const { setVfnStepData, vfnStepData } = useSignInContext()

const handleOnSuccessEmail = useCallback(
(email: string) => {
setEmail(email)
setShowVerificationStep(true)
({ email, otpPrefix }: VfnStepData) => {
setVfnStepData({ email, otpPrefix })
},
[setEmail, setShowVerificationStep]
[setVfnStepData]
)

if (showVerificationStep) {
if (!!vfnStepData?.email) {
return <VerificationInput />
}

Expand Down
105 changes: 68 additions & 37 deletions src/features/sign-in/components/VerificationInput.tsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,36 @@
import { FormControl, FormLabel, Stack } from '@chakra-ui/react'
import {
FormControl,
FormLabel,
InputGroup,
InputLeftAddon,
Stack,
} from '@chakra-ui/react'
import { Button, FormErrorMessage, Input } from '@opengovsg/design-system-react'
import { useRouter } from 'next/router'
import { useInterval } from 'usehooks-ts'
import { z } from 'zod'
import { CALLBACK_URL_KEY } from '~/constants/params'
import { useZodForm } from '~/lib/form'
import { HOME } from '~/lib/routes'
import { trpc } from '~/utils/trpc'
import { useSignInContext } from './SignInContext'
import { emailSignInSchema } from './Emailnput'
import { ResendOtpButton } from './ResendOtpButton'
import { useLoginState } from '~/features/auth'
import { emailVerifyOtpSchema } from '~/schemas/auth/email/sign-in'
import { Controller } from 'react-hook-form'
import { OTP_LENGTH } from '~/lib/auth'

const emailVerificationSchema = emailSignInSchema.extend({
token: z
.string()
.trim()
.min(1, 'OTP is required.')
.length(6, 'Please enter a 6 character OTP.'),
})

export const VerificationInput = (): JSX.Element => {
export const VerificationInput = (): JSX.Element | null => {
const { setHasLoginStateFlag } = useLoginState()
const router = useRouter()
const utils = trpc.useContext()

const { email, timer, setTimer, delayForResendSeconds } = useSignInContext()
const {
vfnStepData,
timer,
setTimer,
setVfnStepData,
delayForResendSeconds,
} = useSignInContext()

useInterval(
() => setTimer(timer - 1),
Expand All @@ -34,18 +39,28 @@ export const VerificationInput = (): JSX.Element => {
)

const {
register,
control,
handleSubmit,
formState: { errors },
resetField,
setFocus,
setError,
} = useZodForm({
schema: emailVerificationSchema,
schema: emailVerifyOtpSchema,
defaultValues: {
email,
email: vfnStepData?.email,
token: '',
},
})

const verifyOtpMutation = trpc.auth.email.verifyOtp.useMutation({
onSuccess: async () => {
setHasLoginStateFlag()
await utils.me.get.invalidate()
// accessing router.query values returns decoded URI params automatically,
// so there's no need to call decodeURIComponent manually when accessing the callback url.
await router.push(String(router.query[CALLBACK_URL_KEY] ?? HOME))
},
onError: (error) => {
setError('token', { message: error.message })
},
Expand All @@ -56,41 +71,57 @@ export const VerificationInput = (): JSX.Element => {
})

const handleVerifyOtp = handleSubmit(({ email, token }) => {
return verifyOtpMutation.mutate(
{
email,
otp: token,
},
{
onSuccess: async () => {
await utils.me.get.invalidate()
setHasLoginStateFlag()
// accessing router.query values returns decoded URI params automatically,
// so there's no need to call decodeURIComponent manually when accessing the callback url.
await router.push(String(router.query[CALLBACK_URL_KEY] ?? HOME))
},
}
)
return verifyOtpMutation.mutate({ email, token })
})

const handleResendOtp = () => {
if (timer > 0) return
if (timer > 0 || !vfnStepData?.email) return
return resendOtpMutation.mutate(
{ email },
// On success, restart the timer before this can be called again.
{ onSuccess: () => setTimer(delayForResendSeconds) }
{ email: vfnStepData.email },
{
onSuccess: ({ email, otpPrefix }) => {
setVfnStepData({ email, otpPrefix })
resetField('token')
setFocus('token')
// On success, restart the timer before this can be called again.
setTimer(delayForResendSeconds)
},
}
)
}

if (!vfnStepData) return null

return (
<form onSubmit={handleVerifyOtp}>
<FormControl
id="email"
isInvalid={!!errors.token}
isReadOnly={verifyOtpMutation.isLoading}
>
<FormLabel htmlFor="email">Enter OTP sent to {email}</FormLabel>
<Input autoFocus maxLength={6} {...register('token')} />
<FormLabel htmlFor="email">
Enter OTP sent to {vfnStepData.email}
</FormLabel>
<Controller
control={control}
name="token"
render={({ field: { onChange, value, ...field } }) => (
<InputGroup>
<InputLeftAddon>{vfnStepData?.otpPrefix}-</InputLeftAddon>
<Input
autoFocus
autoCapitalize="true"
autoCorrect="false"
autoComplete="one-time-code"
placeholder="ABC123"
maxLength={OTP_LENGTH}
{...field}
value={value}
onChange={(e) => onChange(e.target.value.toUpperCase())}
/>
</InputGroup>
)}
/>
<FormErrorMessage>{errors.token?.message}</FormErrorMessage>
</FormControl>
<Stack direction="row" mt={4}>
Expand Down
8 changes: 8 additions & 0 deletions src/lib/auth/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export const MAX_VFN_ATTEMPTS = 5

export const OTP_LENGTH = 6
export const OTP_PREFIX_LENGTH = 3

// Alphabet space with ambiguous characters removed.
export const OTP_ALPHABET = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ'
export const OTP_PREFIX_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ'
1 change: 1 addition & 0 deletions src/lib/auth/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './constants'
15 changes: 15 additions & 0 deletions src/schemas/auth/email/sign-in.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { z } from 'zod'
import { OTP_LENGTH } from '~/lib/auth'
import { normaliseEmail } from '~/utils/zod'

export const emailSignInSchema = z.object({
email: normaliseEmail,
})

export const emailVerifyOtpSchema = emailSignInSchema.extend({
token: z
.string()
.trim()
.min(1, 'OTP is required.')
.length(OTP_LENGTH, `Please enter a ${OTP_LENGTH} character OTP.`),
})
2 changes: 1 addition & 1 deletion src/server/context.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type * as trpc from '@trpc/server'
import type { CreateNextContextOptions } from '@trpc/server/adapters/next'
import { getIronSession, type IronSession } from 'iron-session'
import { sessionOptions } from '~/lib/auth'
import { prisma } from './prisma'
import { sessionOptions } from './modules/auth/session'

interface CreateContextOptions {
session?: IronSession
Expand Down
18 changes: 14 additions & 4 deletions src/server/modules/auth/auth.util.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
import { scryptSync, randomInt, timingSafeEqual } from 'node:crypto'
import { customAlphabet } from 'nanoid'
import { scryptSync, timingSafeEqual } from 'node:crypto'
import {
OTP_ALPHABET,
OTP_LENGTH,
OTP_PREFIX_ALPHABET,
OTP_PREFIX_LENGTH,
} from '~/lib/auth'

export const createVfnToken = () => {
return randomInt(0, 1000000).toString().padStart(6, '0')
}
export const createVfnToken = customAlphabet(OTP_ALPHABET, OTP_LENGTH)

export const createVfnPrefix = customAlphabet(
OTP_PREFIX_ALPHABET,
OTP_PREFIX_LENGTH
)

export const createTokenHash = (token: string, email: string) => {
return scryptSync(token, email, 64).toString('base64')
Expand Down
Loading

1 comment on commit 1c6114e

@vercel
Copy link

@vercel vercel bot commented on 1c6114e Dec 1, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.