Skip to content

Commit

Permalink
feat: access token rotation
Browse files Browse the repository at this point in the history
  • Loading branch information
aseerkt committed Jul 26, 2024
1 parent 1ca6dd3 commit f6e7837
Show file tree
Hide file tree
Showing 34 changed files with 453 additions and 190 deletions.
48 changes: 48 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"@socket.io/redis-streams-adapter": "^0.2.2",
"argon2": "^0.40.3",
"colors": "^1.4.0",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"drizzle-orm": "^0.31.2",
Expand All @@ -41,6 +42,7 @@
"devDependencies": {
"@eslint/js": "^9.6.0",
"@faker-js/faker": "^8.4.1",
"@types/cookie-parser": "^1.4.7",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.6",
Expand Down
69 changes: 69 additions & 0 deletions server/src/common/controllers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { config } from '@/config'
import { db } from '@/database'
import { getRedisClient } from '@/redis'
import { invalidateRefreshToken, isRefreshTokenValid } from '@/redis/handlers'
import { notAuthorized } from '@/utils/api'
import {
clearRefreshTokenCookie,
signTokens,
verifyRefreshToken,
} from '@/utils/jwt'
import { sql } from 'drizzle-orm'
import { RequestHandler } from 'express'

export const welcome: RequestHandler = (_, res) => {
res.send(`<h1>Welcome to ${config.appName} API</h1>`)
}

export const healthCheck: RequestHandler = async (_req, res, next) => {
try {
await db.execute(sql`SELECT 1`)
const redisClient = getRedisClient()
const redisResult = await redisClient.ping()

res.json({ postgres: 'Ok', redis: redisResult })
} catch (error) {
next(error)
}
}

export const recreateAccessToken: RequestHandler = async (req, res) => {
const refreshToken = req.cookies[config.jwtKey]

if (!refreshToken) {
return notAuthorized(res)
}

try {
const refreshPayload = verifyRefreshToken(refreshToken) as UserPayload

const isValid = await isRefreshTokenValid(refreshPayload.id, refreshToken)

if (!isValid) {
return notAuthorized(res)
}

const payload: UserPayload = {
id: refreshPayload.id,
fullName: refreshPayload.fullName,
username: refreshPayload.username,
}

// invalidate the old refresh token
await invalidateRefreshToken(refreshPayload.id, refreshToken)
// sign new tokens (access, refresh)
const token = await signTokens(res, payload)
res.json({ token, user: payload })
} catch (error) {
return notAuthorized(res)
}
}

export const logout: RequestHandler = async (req, res, next) => {
try {
await invalidateRefreshToken(req.user!.id, req.cookies[config.jwtKey])
clearRefreshTokenCookie(res)
} catch (error) {
next(error)
}
}
9 changes: 8 additions & 1 deletion server/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import 'dotenv/config'

export const config = {
appName: 'mChat',
port: process.env.PORT || 5000,
jwtSecret: process.env.JWT_SECRET || 'verycomplexsecret',
jwtKey: 'jwt',
accessTokenExpiry: '15m',
refreshTokenExpiry: '7d',
refreshTokenMaxAge: 7 * 24 * 60 * 60 * 1000,
accessTokenSecret: process.env.ACCESS_TOKEN_SECRET || 'verycomplexsecret',
refreshTokenSecret:
process.env.REFRESH_TOKEN_SECRET || 'eventmorecomplexsecret',
dbUrl: process.env.DB_URL!,
corsOrigin: process.env.CORS_ORIGIN || 'http://localhost:3000',
isProd: process.env.NODE_ENV === 'production',
Expand Down
11 changes: 6 additions & 5 deletions server/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createAdapter } from '@socket.io/redis-streams-adapter'
import 'colors'
import cookieParser from 'cookie-parser'
import cors from 'cors'
import express from 'express'
import helmet from 'helmet'
Expand Down Expand Up @@ -30,11 +31,13 @@ const createApp = async () => {
const app = express()

app.use(
cors({ origin: config.corsOrigin }),
helmet(),
express.urlencoded({ extended: true }),
express.json(),
cors({ origin: config.corsOrigin, credentials: true }),
helmet(),
morgan(config.isProd ? 'combined' : 'dev'),
)
app.use(cookieParser())

const server = createServer(app)

Expand All @@ -44,7 +47,7 @@ const createApp = async () => {
InterServerEvents,
SocketData
>(server, {
cors: { origin: config.corsOrigin },
cors: { origin: config.corsOrigin, credentials: true },
adapter: createAdapter(redisClient),
})

Expand All @@ -54,8 +57,6 @@ const createApp = async () => {

app.set('io', io)



app.use(rootRouter)

app.use('/api-docs', swaggerUi.serve)
Expand Down
4 changes: 2 additions & 2 deletions server/src/middlewares.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { config } from './config'
import { MemberRole } from './modules/members/members.schema'
import { checkPermission } from './modules/members/members.service'
import { badRequest, notAuthenticated, notAuthorized } from './utils/api'
import { verifyToken } from './utils/jwt'
import { verifyAccessToken } from './utils/jwt'

// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const errorHandler: ErrorRequestHandler = (err, _req, res, _next) => {
Expand All @@ -22,7 +22,7 @@ export const auth: RequestHandler = (req, res, next) => {
return notAuthenticated(res)
}

const payload = verifyToken(token) as UserPayload
const payload = verifyAccessToken(token) as UserPayload

req.user = payload

Expand Down
14 changes: 9 additions & 5 deletions server/src/modules/users/users.controller.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { db } from '@/database'
import { notFound } from '@/utils/api'
import { signToken } from '@/utils/jwt'
import { signTokens } from '@/utils/jwt'
import { removeAttrFromObject } from '@/utils/object'
import { hash, verify } from 'argon2'
import { and, eq, getTableColumns, like, ne } from 'drizzle-orm'
Expand All @@ -10,6 +10,7 @@ import { users } from './users.schema'
export const signUpUser: RequestHandler = async (req, res, next) => {
try {
const { username, password, fullName } = req.body
console.log('req.body', req.body)
const rows = await db
.select({ username: users.username })
.from(users)
Expand All @@ -32,13 +33,13 @@ export const signUpUser: RequestHandler = async (req, res, next) => {
createdAt: users.createdAt,
})

const token = signToken({
const accessToken = await signTokens(res, {
id: user.id,
username: user.username,
fullName: user.fullName,
})

res.status(201).json({ user: user, token })
res.status(201).json({ user: user, token: accessToken })
} catch (error) {
next(error)
}
Expand All @@ -63,13 +64,16 @@ export const loginUser: RequestHandler = async (req, res, next) => {
return res.status(400).json({ message: 'Invalid username/password' })
}

const token = signToken({
const accessToken = await signTokens(res, {
id: user.id,
username: user.username,
fullName: user.fullName,
})

res.json({ user: removeAttrFromObject(user, 'password'), token })
res.json({
user: removeAttrFromObject(user, 'password'),
token: accessToken,
})
} catch (error) {
next(error)
}
Expand Down
16 changes: 16 additions & 0 deletions server/src/redis/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,22 @@ export const redisKeys = {
TYPING_USERS: (chatId: number, mode: ChatMode) =>
`typing_users:${mode}:${chatId}:`,
MEMBER_ROLES: (groupId: number) => `group:${groupId}:member_roles`,
USER_TOKEN: (userId: number) => `user:${userId}:tokens`,
}

// USER REFRESH TOKENS

export const addRefreshToken = (userId: number, token: string) => {
return redisClient.sadd(redisKeys.USER_TOKEN(userId), token)
}

export const invalidateRefreshToken = (userId: number, token: string) => {
return redisClient.srem(redisKeys.USER_TOKEN(userId), token)
}

export const isRefreshTokenValid = async (userId: number, token: string) => {
const value = await redisClient.sismember(redisKeys.USER_TOKEN(userId), token)
return value == 1
}

// MEMBER ROLES
Expand Down
2 changes: 1 addition & 1 deletion server/src/redis/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export function getRedisClient() {
process.env.DEBUG = config.isProd ? '' : 'ioredis:*'
redisClient = new Redis({
host: config.redisHost,
port: Number(config.redisPort),
port: +config.redisPort,
})

redisClient.on('connect', () => {
Expand Down
14 changes: 10 additions & 4 deletions server/src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,20 @@ import { router as groupRoutes } from '@/modules/groups/groups.routes'
import { router as memberRoutes } from '@/modules/members/members.routes'
import { router as messageRoutes } from '@/modules/messages/messages.routes'
import { router as userRoutes } from '@/modules/users/users.routes'
import { healthCheck } from './utils/api'
import {
healthCheck,
logout,
recreateAccessToken,
welcome,
} from './common/controllers'

const rootRouter = Router()

rootRouter.get('/api', (_, res) => {
res.send('<h1>Welcome to mChat API</h1>')
})
rootRouter.get('/api', welcome)
rootRouter.get('/api/health', healthCheck)
rootRouter.post('/api/refresh', recreateAccessToken)
rootRouter.delete('/api/logout', auth, logout)

rootRouter.use('/api/users', userRoutes)
rootRouter.use('/api/groups', auth, groupRoutes)
rootRouter.use('/api/members', auth, memberRoutes)
Expand Down
Loading

0 comments on commit f6e7837

Please sign in to comment.