From c6d502ce3d08a86270dab36b7ae26a69aa2f2afd Mon Sep 17 00:00:00 2001 From: DeVoresyah Arest Date: Tue, 2 Jan 2024 14:49:06 +0700 Subject: [PATCH] feat: add refresh token --- .../Http/v1/Auth/AuthsController.ts | 77 ++++++++++++++++++- app/Middleware/UserSessionMiddleware.ts | 18 ++++- .../v1/Auth/RefreshSessionValidator.ts | 14 ++++ routes/auth/v1/index.ts | 1 + 4 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 app/Validators/v1/Auth/RefreshSessionValidator.ts diff --git a/app/Controllers/Http/v1/Auth/AuthsController.ts b/app/Controllers/Http/v1/Auth/AuthsController.ts index 1826326..7b65a3b 100644 --- a/app/Controllers/Http/v1/Auth/AuthsController.ts +++ b/app/Controllers/Http/v1/Auth/AuthsController.ts @@ -19,6 +19,7 @@ import LoginWithOtpValidator from 'App/Validators/v1/Auth/LoginWithOtpValidator' import LogoutValidator from 'App/Validators/v1/Auth/LogoutValidator' import ResendValidator from 'App/Validators/v1/Auth/ResendValidator' import ForgotPasswordValidator from 'App/Validators/v1/Auth/ForgotPasswordValidator' +import RefreshSessionValidator from 'App/Validators/v1/Auth/RefreshSessionValidator' // Models import User from 'App/Models/User' @@ -404,7 +405,10 @@ export default class AuthsController { try { if (!sessions.currentSession) { - return response.api({ message: 'Invalid session.' }, StatusCodes.UNAUTHORIZED) + return response.api( + { message: 'Invalid session, please login again.' }, + StatusCodes.UNAUTHORIZED + ) } if (payload.scope === 'global') { @@ -478,4 +482,75 @@ export default class AuthsController { ) } } + + public async refreshSession({ request, response }: HttpContextContract) { + const payload = await request.validate(RefreshSessionValidator) + const userId = request.decoded!.user_id + const sessionId = request.decoded!.session_id + const ip = request.ips()[0] + + const session = await Session.query().where('user_id', userId).andWhere('id', sessionId).first() + + if (!session || session!.ip !== ip) { + return response.api( + { message: 'Invalid session, please login again.' }, + StatusCodes.UNAUTHORIZED + ) + } + + const refreshToken = await RefreshToken.query() + .where('user_id', userId) + .andWhere('session_id', sessionId) + .andWhere('token', payload.refresh_token) + .first() + + if (!refreshToken || refreshToken?.revoked) { + return response.api({ message: 'Invalid refresh token.' }, StatusCodes.FORBIDDEN) + } + + const refreshSession = await Database.transaction(async (trx) => { + refreshToken.useTransaction(trx) + + refreshToken.revoked = true + await refreshToken.save() + + if (session) { + session.useTransaction(trx) + session.refreshedAt = DateTime.now() + await session.save() + } + + const newRefreshToken = await RefreshToken.create( + { + userId: userId, + sessionId: sessionId, + parent: refreshToken.id, + revoked: false, + token: cuid(), + }, + { client: trx } + ) + + return { + newRefreshToken, + } + }) + + if (refreshSession.newRefreshToken) { + const accessToken = this.jwt.generate({ user_id: userId, session_id: sessionId }).make() + + return response.api( + { + access_token: accessToken, + refreshToken: refreshSession.newRefreshToken.token, + }, + StatusCodes.OK + ) + } else { + return response.api( + { message: 'Failed to refresh session.' }, + StatusCodes.INTERNAL_SERVER_ERROR + ) + } + } } diff --git a/app/Middleware/UserSessionMiddleware.ts b/app/Middleware/UserSessionMiddleware.ts index a1f5809..9d9d5c1 100644 --- a/app/Middleware/UserSessionMiddleware.ts +++ b/app/Middleware/UserSessionMiddleware.ts @@ -1,5 +1,9 @@ import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' import JwtService from 'App/Services/JwtService' +import { StatusCodes } from 'http-status-codes' + +// Models +import Session from 'App/Models/Session' export default class UserSessionMiddleware { public jwtService = new JwtService() @@ -12,12 +16,24 @@ export default class UserSessionMiddleware { // If token is not present, return unauthorized if (!token) { - return response.api({ message: 'Token cannot be empty' }, 401) + return response.api({ message: 'Token cannot be empty' }, StatusCodes.UNAUTHORIZED) } // Verify token const decoded = this.jwtService.verify(token).extract() + const session = await Session.query() + .where('user_id', decoded['user_id']) + .andWhere('id', decoded['session_id']) + .first() + + if (!session) { + return response.api( + { message: 'Invalid session, please login again.' }, + StatusCodes.UNAUTHORIZED + ) + } + request.decoded = { user_id: decoded['user_id'], session_id: decoded['session_id'], diff --git a/app/Validators/v1/Auth/RefreshSessionValidator.ts b/app/Validators/v1/Auth/RefreshSessionValidator.ts new file mode 100644 index 0000000..6a10d5f --- /dev/null +++ b/app/Validators/v1/Auth/RefreshSessionValidator.ts @@ -0,0 +1,14 @@ +import { schema, CustomMessages } from '@ioc:Adonis/Core/Validator' +import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' + +export default class RefreshSessionValidator { + constructor(protected ctx: HttpContextContract) {} + + public schema = schema.create({ + refresh_token: schema.string(), + }) + + public messages: CustomMessages = { + required: '{{ field }} cannot be empty.', + } +} diff --git a/routes/auth/v1/index.ts b/routes/auth/v1/index.ts index bc46fa0..8e6e4ec 100644 --- a/routes/auth/v1/index.ts +++ b/routes/auth/v1/index.ts @@ -6,6 +6,7 @@ Route.group(() => { Route.post('/login/otp', 'AuthsController.signInWithOtp') Route.post('/resend', 'AuthsController.resend') Route.post('/forgot-password', 'AuthsController.forgotPassword') + Route.post('/refresh', 'AuthsController.refreshSession').middleware('userSession') Route.delete('/logout', 'AuthsController.signOut').middleware('userSession') require('./verify') })