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:
Divlo 2022-08-29 17:26:43 +00:00
parent b71da7dcc9
commit 7e305429b4
No known key found for this signature in database
GPG Key ID: 8F9478F220CE65E9
8 changed files with 75 additions and 37 deletions

View File

@ -1 +1,7 @@
{ "extends": ["@commitlint/config-conventional"] } {
"extends": ["@commitlint/config-conventional"],
"rules": {
"body-max-length": [0, "always"],
"body-max-line-length": [0, "always"]
}
}

View File

@ -6,7 +6,7 @@ import { date, id } from './utils.js'
export const refreshTokensSchema = { export const refreshTokensSchema = {
id, id,
token: Type.String(), token: Type.String({ format: 'uuid' }),
createdAt: date.createdAt, createdAt: date.createdAt,
updatedAt: date.updatedAt, updatedAt: date.updatedAt,
userId: id userId: id
@ -15,7 +15,7 @@ export const refreshTokensSchema = {
export const refreshTokenExample: RefreshToken = { export const refreshTokenExample: RefreshToken = {
id: 1, id: 1,
userId: userExample.id, userId: userExample.id,
token: 'sometoken', token: 'sometokenUUID',
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date() updatedAt: new Date()
} }

View File

@ -10,6 +10,10 @@ export interface UserJWT {
currentStrategy: AuthenticationStrategy currentStrategy: AuthenticationStrategy
} }
export interface UserRefreshJWT extends UserJWT {
tokenUUID: string
}
export interface UserRequest { export interface UserRequest {
current: User current: User
currentStrategy: AuthenticationStrategy currentStrategy: AuthenticationStrategy

View File

@ -1,5 +1,6 @@
import tap from 'tap' import tap from 'tap'
import sinon from 'sinon' import sinon from 'sinon'
import jwt from 'jsonwebtoken'
import { application } from '../../../../application.js' import { application } from '../../../../application.js'
import { authenticateUserTest } from '../../../../__test__/utils/authenticateUserTest.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) => { await t.test('succeeds', async (t) => {
const { accessToken, refreshToken, refreshTokenStubValue } = const { refreshToken, refreshTokenStubValue } = await authenticateUserTest()
await authenticateUserTest()
sinon.stub(prisma, 'refreshToken').value({ sinon.stub(prisma, 'refreshToken').value({
...refreshTokenStubValue, ...refreshTokenStubValue,
findFirst: async () => { findFirst: async () => {
@ -28,9 +28,6 @@ await tap.test('POST /users/refresh-token', async (t) => {
const response = await application.inject({ const response = await application.inject({
method: 'POST', method: 'POST',
url: '/users/refresh-token', url: '/users/refresh-token',
headers: {
authorization: `Bearer ${accessToken}`
},
payload: { refreshToken } payload: { refreshToken }
}) })
const responseJson = response.json() const responseJson = response.json()
@ -62,6 +59,9 @@ await tap.test('POST /users/refresh-token', async (t) => {
return refreshTokenExample return refreshTokenExample
} }
}) })
sinon.stub(jwt, 'verify').value(() => {
throw new Error('Invalid token')
})
const response = await application.inject({ const response = await application.inject({
method: 'POST', method: 'POST',
url: '/users/refresh-token', url: '/users/refresh-token',

View File

@ -9,7 +9,7 @@ import {
jwtSchema, jwtSchema,
expiresIn expiresIn
} from '../../../tools/utils/jwtToken.js' } 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' import { JWT_REFRESH_SECRET } from '../../../tools/configurations/index.js'
const bodyPostRefreshTokenSchema = Type.Object({ const bodyPostRefreshTokenSchema = Type.Object({
@ -43,20 +43,20 @@ export const postRefreshTokenUser: FastifyPluginAsync = async (fastify) => {
schema: postRefreshTokenSchema, schema: postRefreshTokenSchema,
handler: async (request, reply) => { handler: async (request, reply) => {
const { refreshToken } = request.body const { refreshToken } = request.body
const foundRefreshToken = await prisma.refreshToken.findFirst({
where: { token: refreshToken }
})
if (foundRefreshToken == null) {
throw fastify.httpErrors.forbidden()
}
try { try {
const userJWT = jwt.verify( const userRefreshJWT = jwt.verify(
foundRefreshToken.token, refreshToken,
JWT_REFRESH_SECRET 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({ const accessToken = generateAccessToken({
id: userJWT.id, id: userRefreshJWT.id,
currentStrategy: userJWT.currentStrategy currentStrategy: userRefreshJWT.currentStrategy
}) })
reply.statusCode = 200 reply.statusCode = 200
return { return {

View File

@ -1,9 +1,11 @@
import tap from 'tap' import tap from 'tap'
import sinon from 'sinon' import sinon from 'sinon'
import jwt from 'jsonwebtoken'
import { application } from '../../../../application.js' import { application } from '../../../../application.js'
import prisma from '../../../../tools/database/prisma.js' import prisma from '../../../../tools/database/prisma.js'
import { refreshTokenExample } from '../../../../models/RefreshToken.js' import { refreshTokenExample } from '../../../../models/RefreshToken.js'
import { UserRefreshJWT } from '../../../../models/User.js'
await tap.test('POST /users/signout', async (t) => { await tap.test('POST /users/signout', async (t) => {
t.afterEach(() => { t.afterEach(() => {
@ -17,10 +19,18 @@ await tap.test('POST /users/signout', async (t) => {
}, },
delete: async () => {} delete: async () => {}
}) })
sinon.stub(jwt, 'verify').value(() => {
const value: UserRefreshJWT = {
id: 1,
tokenUUID: refreshTokenExample.token,
currentStrategy: 'Local'
}
return value
})
const response = await application.inject({ const response = await application.inject({
method: 'POST', method: 'POST',
url: '/users/signout', url: '/users/signout',
payload: { refreshToken: refreshTokenExample.token } payload: { refreshToken: 'jwt token' }
}) })
t.equal(response.statusCode, 200) t.equal(response.statusCode, 200)
}) })

View File

@ -1,12 +1,15 @@
import { Static, Type } from '@sinclair/typebox' import { Static, Type } from '@sinclair/typebox'
import { FastifyPluginAsync, FastifySchema } from 'fastify' import { FastifyPluginAsync, FastifySchema } from 'fastify'
import jwt from 'jsonwebtoken'
import prisma from '../../../tools/database/prisma.js' import prisma from '../../../tools/database/prisma.js'
import { fastifyErrors } from '../../../models/utils.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({ const bodyPostSignoutSchema = Type.Object({
refreshToken: refreshTokensSchema.token refreshToken: jwtSchema.refreshToken
}) })
type BodyPostSignoutSchemaType = Static<typeof bodyPostSignoutSchema> type BodyPostSignoutSchemaType = Static<typeof bodyPostSignoutSchema>
@ -32,21 +35,27 @@ export const postSignoutUser: FastifyPluginAsync = async (fastify) => {
schema: postSignoutSchema, schema: postSignoutSchema,
handler: async (request, reply) => { handler: async (request, reply) => {
const { refreshToken } = request.body const { refreshToken } = request.body
const token = await prisma.refreshToken.findFirst({ try {
where: { const userRefreshJWT = jwt.verify(
token: refreshToken refreshToken,
JWT_REFRESH_SECRET
) as UserRefreshJWT
const foundRefreshToken = await prisma.refreshToken.findFirst({
where: { token: userRefreshJWT.tokenUUID }
})
if (foundRefreshToken == null) {
throw fastify.httpErrors.notFound()
} }
}) await prisma.refreshToken.delete({
if (token == null) { where: {
id: foundRefreshToken.id
}
})
reply.statusCode = 200
return {}
} catch {
throw fastify.httpErrors.notFound() throw fastify.httpErrors.notFound()
} }
await prisma.refreshToken.delete({
where: {
id: token.id
}
})
reply.statusCode = 200
return {}
} }
}) })
} }

View File

@ -1,3 +1,5 @@
import { randomUUID } from 'node:crypto'
import { Type } from '@sinclair/typebox' import { Type } from '@sinclair/typebox'
import jwt from 'jsonwebtoken' import jwt from 'jsonwebtoken'
import ms from 'ms' import ms from 'ms'
@ -34,9 +36,16 @@ export const generateAccessToken = (user: UserJWT): string => {
} }
export const generateRefreshToken = async (user: UserJWT): Promise<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({ await prisma.refreshToken.create({
data: { token: refreshToken, userId: user.id } data: { token: tokenUUID, userId: user.id }
}) })
return refreshToken return refreshToken
} }