store verification token in redis with exp and destroy after use

This commit is contained in:
Hongbo Wu 2024-08-22 18:04:18 +08:00
parent 96c118f163
commit 5c9816b5b8
5 changed files with 58 additions and 35 deletions

View file

@ -24,6 +24,7 @@ export interface Claims {
exp?: number
email?: string
system?: boolean
destroyAfterUse?: boolean
}
export type ClaimsToSet = {

View file

@ -29,12 +29,13 @@ import {
import { analytics } from '../../utils/analytics'
import {
comparePassword,
getClaimsByToken,
hashPassword,
setAuthInCookie,
verifyToken,
} from '../../utils/auth'
import { corsConfig } from '../../utils/corsConfig'
import { logger } from '../../utils/logger'
import { DEFAULT_HOME_PATH } from '../../utils/navigation'
import { hourlyLimiter } from '../../utils/rate_limit'
import { verifyChallengeRecaptcha } from '../../utils/recaptcha'
import { createSsoToken, ssoRedirectURL } from '../../utils/sso'
@ -48,7 +49,6 @@ import {
} from './google_auth'
import { createWebAuthToken } from './jwt_helpers'
import { createMobileAccountCreationResponse } from './mobile/account_creation'
import { DEFAULT_HOME_PATH } from '../../utils/navigation'
export interface SignupRequest {
email: string
@ -582,13 +582,7 @@ export function authRouter() {
try {
// verify token
const claims = await getClaimsByToken(token)
if (!claims) {
return res.redirect(
`${env.client.url}/auth/confirm-email?errorCodes=INVALID_TOKEN`
)
}
const claims = await verifyToken(token)
const user = await getRepository(User).findOneBy({ id: claims.uid })
if (!user) {
return res.redirect(
@ -710,20 +704,14 @@ export function authRouter() {
const { token, password } = req.body
try {
// verify token
const claims = await getClaimsByToken(token)
if (!claims) {
return res.redirect(
`${env.client.url}/auth/reset-password/${token}?errorCodes=INVALID_TOKEN`
)
}
if (!password || password.length < 8) {
return res.redirect(
`${env.client.url}/auth/reset-password/${token}?errorCodes=INVALID_PASSWORD`
)
}
// verify token
const claims = await verifyToken(token)
const user = await getRepository(User).findOneBy({
id: claims.uid,
})

View file

@ -10,7 +10,7 @@ export const sendNewAccountVerificationEmail = async (user: {
email: string
}): Promise<boolean> => {
// generate confirmation link
const token = generateVerificationToken({ id: user.id })
const token = await generateVerificationToken({ id: user.id })
const link = `${env.client.url}/auth/confirm-email/${token}`
// send email
const dynamicTemplateData = {
@ -71,7 +71,10 @@ export const sendAccountChangeEmail = async (user: {
email: string
}): Promise<boolean> => {
// generate verification link
const token = generateVerificationToken({ id: user.id, email: user.email })
const token = await generateVerificationToken({
id: user.id,
email: user.email,
})
const link = `${env.client.url}/auth/reset-password/${token}`
// send email
const dynamicTemplateData = {
@ -94,7 +97,7 @@ export const sendPasswordResetEmail = async (user: {
email: string
}): Promise<boolean> => {
// generate link
const token = generateVerificationToken({ id: user.id })
const token = await generateVerificationToken({ id: user.id })
const link = `${env.client.url}/auth/reset-password/${token}`
// send email
const dynamicTemplateData = {

View file

@ -6,6 +6,7 @@ import { promisify } from 'util'
import { v4 as uuidv4 } from 'uuid'
import { ApiKey } from '../entity/api_key'
import { env } from '../env'
import { redisDataSource } from '../redis_data_source'
import { getRepository } from '../repository'
import { Claims, ClaimsToSet } from '../resolvers/types'
import { logger } from './logger'
@ -89,22 +90,52 @@ export const getClaimsByToken = async (
}
}
export const generateVerificationToken = (
const verificationTokenKey = (token: string) => `verification:${token}`
export const verifyToken = async (token: string): Promise<Claims> => {
const redisClient = redisDataSource.redisClient
const key = verificationTokenKey(token)
if (redisClient) {
const cachedToken = await redisClient.get(key)
if (!cachedToken) {
throw new Error('Token not found')
}
}
const claims = jwt.verify(token, env.server.jwtSecret) as Claims
if (claims.destroyAfterUse) {
await redisClient?.del(key)
}
return claims
}
export const generateVerificationToken = async (
user: {
id: string
email?: string
},
expireInSeconds = 60 * 60 * 24 // 1 day
): string => {
expireInSeconds = 60, // 1 minute
destroyAfterUse = true
): Promise<string> => {
const iat = Math.floor(Date.now() / 1000)
const exp = Math.floor(
new Date(Date.now() + expireInSeconds * 1000).getTime() / 1000
)
return jwt.sign(
{ uid: user.id, iat, exp, email: user.email },
const token = jwt.sign(
{ uid: user.id, iat, exp, email: user.email, destroyAfterUse },
env.server.jwtSecret
)
await redisDataSource.redisClient?.set(
verificationTokenKey(token),
user.id,
'EX',
expireInSeconds
)
return token
}
export const setAuthInCookie = async (

View file

@ -258,8 +258,8 @@ describe('auth router', () => {
})
context('when token is valid', () => {
before(() => {
token = generateVerificationToken({ id: user.id })
before(async () => {
token = await generateVerificationToken({ id: user.id })
})
it('set auth token in cookie', async () => {
@ -292,8 +292,8 @@ describe('auth router', () => {
})
context('when token is expired', () => {
before(() => {
token = generateVerificationToken({ id: user.id }, -1)
before(async () => {
token = await generateVerificationToken({ id: user.id }, -1)
})
it('redirects to confirm-email page with error code TokenExpired', async () => {
@ -305,9 +305,9 @@ describe('auth router', () => {
})
context('when user is not found', () => {
before(() => {
before(async () => {
const nonExistsUserId = generateFakeUuid()
token = generateVerificationToken({ id: nonExistsUserId })
token = await generateVerificationToken({ id: nonExistsUserId })
})
it('redirects to confirm-email page with error code UserNotFound', async () => {
@ -419,8 +419,8 @@ describe('auth router', () => {
})
context('when token is valid', () => {
before(() => {
token = generateVerificationToken({ id: user.id })
before(async () => {
token = await generateVerificationToken({ id: user.id })
})
context('when password is not empty', () => {
@ -464,8 +464,8 @@ describe('auth router', () => {
})
context('when token is expired', () => {
before(() => {
token = generateVerificationToken({ id: user.id }, -1)
before(async () => {
token = await generateVerificationToken({ id: user.id }, -1)
})
it('redirects to reset-password page with error code ExpiredToken', async () => {