feat: make JWT refreshTokens more secure
Don't store the token itself in the database, store a UUID, and when refreshing the accessToken, verify the token and verify that in the payload there is a corresponding UUID stored in the database
This commit is contained in:
		| @@ -1 +1,7 @@ | ||||
| { "extends": ["@commitlint/config-conventional"] } | ||||
| { | ||||
|   "extends": ["@commitlint/config-conventional"], | ||||
|   "rules": { | ||||
|     "body-max-length": [0, "always"], | ||||
|     "body-max-line-length": [0, "always"] | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -6,7 +6,7 @@ import { date, id } from './utils.js' | ||||
|  | ||||
| export const refreshTokensSchema = { | ||||
|   id, | ||||
|   token: Type.String(), | ||||
|   token: Type.String({ format: 'uuid' }), | ||||
|   createdAt: date.createdAt, | ||||
|   updatedAt: date.updatedAt, | ||||
|   userId: id | ||||
| @@ -15,7 +15,7 @@ export const refreshTokensSchema = { | ||||
| export const refreshTokenExample: RefreshToken = { | ||||
|   id: 1, | ||||
|   userId: userExample.id, | ||||
|   token: 'sometoken', | ||||
|   token: 'sometokenUUID', | ||||
|   createdAt: new Date(), | ||||
|   updatedAt: new Date() | ||||
| } | ||||
|   | ||||
| @@ -10,6 +10,10 @@ export interface UserJWT { | ||||
|   currentStrategy: AuthenticationStrategy | ||||
| } | ||||
|  | ||||
| export interface UserRefreshJWT extends UserJWT { | ||||
|   tokenUUID: string | ||||
| } | ||||
|  | ||||
| export interface UserRequest { | ||||
|   current: User | ||||
|   currentStrategy: AuthenticationStrategy | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| import tap from 'tap' | ||||
| import sinon from 'sinon' | ||||
| import jwt from 'jsonwebtoken' | ||||
|  | ||||
| import { application } from '../../../../application.js' | ||||
| import { authenticateUserTest } from '../../../../__test__/utils/authenticateUserTest.js' | ||||
| @@ -13,8 +14,7 @@ await tap.test('POST /users/refresh-token', async (t) => { | ||||
|   }) | ||||
|  | ||||
|   await t.test('succeeds', async (t) => { | ||||
|     const { accessToken, refreshToken, refreshTokenStubValue } = | ||||
|       await authenticateUserTest() | ||||
|     const { refreshToken, refreshTokenStubValue } = await authenticateUserTest() | ||||
|     sinon.stub(prisma, 'refreshToken').value({ | ||||
|       ...refreshTokenStubValue, | ||||
|       findFirst: async () => { | ||||
| @@ -28,9 +28,6 @@ await tap.test('POST /users/refresh-token', async (t) => { | ||||
|     const response = await application.inject({ | ||||
|       method: 'POST', | ||||
|       url: '/users/refresh-token', | ||||
|       headers: { | ||||
|         authorization: `Bearer ${accessToken}` | ||||
|       }, | ||||
|       payload: { refreshToken } | ||||
|     }) | ||||
|     const responseJson = response.json() | ||||
| @@ -62,6 +59,9 @@ await tap.test('POST /users/refresh-token', async (t) => { | ||||
|         return refreshTokenExample | ||||
|       } | ||||
|     }) | ||||
|     sinon.stub(jwt, 'verify').value(() => { | ||||
|       throw new Error('Invalid token') | ||||
|     }) | ||||
|     const response = await application.inject({ | ||||
|       method: 'POST', | ||||
|       url: '/users/refresh-token', | ||||
|   | ||||
| @@ -9,7 +9,7 @@ import { | ||||
|   jwtSchema, | ||||
|   expiresIn | ||||
| } from '../../../tools/utils/jwtToken.js' | ||||
| import { UserJWT } from '../../../models/User.js' | ||||
| import { UserRefreshJWT } from '../../../models/User.js' | ||||
| import { JWT_REFRESH_SECRET } from '../../../tools/configurations/index.js' | ||||
|  | ||||
| const bodyPostRefreshTokenSchema = Type.Object({ | ||||
| @@ -43,20 +43,20 @@ export const postRefreshTokenUser: FastifyPluginAsync = async (fastify) => { | ||||
|     schema: postRefreshTokenSchema, | ||||
|     handler: async (request, reply) => { | ||||
|       const { refreshToken } = request.body | ||||
|       const foundRefreshToken = await prisma.refreshToken.findFirst({ | ||||
|         where: { token: refreshToken } | ||||
|       }) | ||||
|       if (foundRefreshToken == null) { | ||||
|         throw fastify.httpErrors.forbidden() | ||||
|       } | ||||
|       try { | ||||
|         const userJWT = jwt.verify( | ||||
|           foundRefreshToken.token, | ||||
|         const userRefreshJWT = jwt.verify( | ||||
|           refreshToken, | ||||
|           JWT_REFRESH_SECRET | ||||
|         ) as UserJWT | ||||
|         ) as UserRefreshJWT | ||||
|         const foundRefreshToken = await prisma.refreshToken.findFirst({ | ||||
|           where: { token: userRefreshJWT.tokenUUID } | ||||
|         }) | ||||
|         if (foundRefreshToken == null) { | ||||
|           throw fastify.httpErrors.forbidden() | ||||
|         } | ||||
|         const accessToken = generateAccessToken({ | ||||
|           id: userJWT.id, | ||||
|           currentStrategy: userJWT.currentStrategy | ||||
|           id: userRefreshJWT.id, | ||||
|           currentStrategy: userRefreshJWT.currentStrategy | ||||
|         }) | ||||
|         reply.statusCode = 200 | ||||
|         return { | ||||
|   | ||||
| @@ -1,9 +1,11 @@ | ||||
| import tap from 'tap' | ||||
| import sinon from 'sinon' | ||||
| import jwt from 'jsonwebtoken' | ||||
|  | ||||
| import { application } from '../../../../application.js' | ||||
| import prisma from '../../../../tools/database/prisma.js' | ||||
| import { refreshTokenExample } from '../../../../models/RefreshToken.js' | ||||
| import { UserRefreshJWT } from '../../../../models/User.js' | ||||
|  | ||||
| await tap.test('POST /users/signout', async (t) => { | ||||
|   t.afterEach(() => { | ||||
| @@ -17,10 +19,18 @@ await tap.test('POST /users/signout', async (t) => { | ||||
|       }, | ||||
|       delete: async () => {} | ||||
|     }) | ||||
|     sinon.stub(jwt, 'verify').value(() => { | ||||
|       const value: UserRefreshJWT = { | ||||
|         id: 1, | ||||
|         tokenUUID: refreshTokenExample.token, | ||||
|         currentStrategy: 'Local' | ||||
|       } | ||||
|       return value | ||||
|     }) | ||||
|     const response = await application.inject({ | ||||
|       method: 'POST', | ||||
|       url: '/users/signout', | ||||
|       payload: { refreshToken: refreshTokenExample.token } | ||||
|       payload: { refreshToken: 'jwt token' } | ||||
|     }) | ||||
|     t.equal(response.statusCode, 200) | ||||
|   }) | ||||
|   | ||||
| @@ -1,12 +1,15 @@ | ||||
| import { Static, Type } from '@sinclair/typebox' | ||||
| import { FastifyPluginAsync, FastifySchema } from 'fastify' | ||||
| import jwt from 'jsonwebtoken' | ||||
|  | ||||
| import prisma from '../../../tools/database/prisma.js' | ||||
| import { fastifyErrors } from '../../../models/utils.js' | ||||
| import { refreshTokensSchema } from '../../../models/RefreshToken.js' | ||||
| import { JWT_REFRESH_SECRET } from '../../../tools/configurations/index.js' | ||||
| import { UserRefreshJWT } from '../../../models/User.js' | ||||
| import { jwtSchema } from '../../../tools/utils/jwtToken.js' | ||||
|  | ||||
| const bodyPostSignoutSchema = Type.Object({ | ||||
|   refreshToken: refreshTokensSchema.token | ||||
|   refreshToken: jwtSchema.refreshToken | ||||
| }) | ||||
|  | ||||
| type BodyPostSignoutSchemaType = Static<typeof bodyPostSignoutSchema> | ||||
| @@ -32,21 +35,27 @@ export const postSignoutUser: FastifyPluginAsync = async (fastify) => { | ||||
|     schema: postSignoutSchema, | ||||
|     handler: async (request, reply) => { | ||||
|       const { refreshToken } = request.body | ||||
|       const token = await prisma.refreshToken.findFirst({ | ||||
|         where: { | ||||
|           token: refreshToken | ||||
|       try { | ||||
|         const userRefreshJWT = jwt.verify( | ||||
|           refreshToken, | ||||
|           JWT_REFRESH_SECRET | ||||
|         ) as UserRefreshJWT | ||||
|         const foundRefreshToken = await prisma.refreshToken.findFirst({ | ||||
|           where: { token: userRefreshJWT.tokenUUID } | ||||
|         }) | ||||
|         if (foundRefreshToken == null) { | ||||
|           throw fastify.httpErrors.notFound() | ||||
|         } | ||||
|       }) | ||||
|       if (token == null) { | ||||
|         await prisma.refreshToken.delete({ | ||||
|           where: { | ||||
|             id: foundRefreshToken.id | ||||
|           } | ||||
|         }) | ||||
|         reply.statusCode = 200 | ||||
|         return {} | ||||
|       } catch { | ||||
|         throw fastify.httpErrors.notFound() | ||||
|       } | ||||
|       await prisma.refreshToken.delete({ | ||||
|         where: { | ||||
|           id: token.id | ||||
|         } | ||||
|       }) | ||||
|       reply.statusCode = 200 | ||||
|       return {} | ||||
|     } | ||||
|   }) | ||||
| } | ||||
|   | ||||
| @@ -1,3 +1,5 @@ | ||||
| import { randomUUID } from 'node:crypto' | ||||
|  | ||||
| import { Type } from '@sinclair/typebox' | ||||
| import jwt from 'jsonwebtoken' | ||||
| import ms from 'ms' | ||||
| @@ -34,9 +36,16 @@ export const generateAccessToken = (user: UserJWT): string => { | ||||
| } | ||||
|  | ||||
| export const generateRefreshToken = async (user: UserJWT): Promise<string> => { | ||||
|   const refreshToken = jwt.sign(user, JWT_REFRESH_SECRET) | ||||
|   const tokenUUID = randomUUID() | ||||
|   const refreshToken = jwt.sign( | ||||
|     { | ||||
|       ...user, | ||||
|       tokenUUID | ||||
|     }, | ||||
|     JWT_REFRESH_SECRET | ||||
|   ) | ||||
|   await prisma.refreshToken.create({ | ||||
|     data: { token: refreshToken, userId: user.id } | ||||
|     data: { token: tokenUUID, userId: user.id } | ||||
|   }) | ||||
|   return refreshToken | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user