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:
parent
b71da7dcc9
commit
7e305429b4
@ -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 = {
|
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()
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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',
|
||||||
|
@ -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
|
||||||
|
try {
|
||||||
|
const userRefreshJWT = jwt.verify(
|
||||||
|
refreshToken,
|
||||||
|
JWT_REFRESH_SECRET
|
||||||
|
) as UserRefreshJWT
|
||||||
const foundRefreshToken = await prisma.refreshToken.findFirst({
|
const foundRefreshToken = await prisma.refreshToken.findFirst({
|
||||||
where: { token: refreshToken }
|
where: { token: userRefreshJWT.tokenUUID }
|
||||||
})
|
})
|
||||||
if (foundRefreshToken == null) {
|
if (foundRefreshToken == null) {
|
||||||
throw fastify.httpErrors.forbidden()
|
throw fastify.httpErrors.forbidden()
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
const userJWT = jwt.verify(
|
|
||||||
foundRefreshToken.token,
|
|
||||||
JWT_REFRESH_SECRET
|
|
||||||
) as UserJWT
|
|
||||||
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 {
|
||||||
|
@ -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)
|
||||||
})
|
})
|
||||||
|
@ -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 (token == null) {
|
if (foundRefreshToken == null) {
|
||||||
throw fastify.httpErrors.notFound()
|
throw fastify.httpErrors.notFound()
|
||||||
}
|
}
|
||||||
await prisma.refreshToken.delete({
|
await prisma.refreshToken.delete({
|
||||||
where: {
|
where: {
|
||||||
id: token.id
|
id: foundRefreshToken.id
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
reply.statusCode = 200
|
reply.statusCode = 200
|
||||||
return {}
|
return {}
|
||||||
|
} catch {
|
||||||
|
throw fastify.httpErrors.notFound()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user