diff --git a/.gitignore b/.gitignore index ee4a77a..83206bb 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,7 @@ lerna-debug.log* docker-compose.yml # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json - +.agent # Runtime data pids *.pid diff --git a/src/config/config.ts b/src/config/config.ts index c00d705..33ace71 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -19,7 +19,8 @@ const getEnvironment = (): Environment => { interface Config { PORT: number; ENVIRONMENT: Environment, - JWT_SECRET: string; + JWT_REFRESH_SECRET: string; + REFRESH_TOKEN_HASH_SECRET: string; SESSION_SECRET: string; OTP_SECRET_KEY: string; REDIS_URL: string; @@ -58,7 +59,8 @@ interface Config { const config: Config = { PORT: parseInt(process.env.PORT || '3000'), ENVIRONMENT: getEnvironment(), - JWT_SECRET: process.env.JWT_SECRET!, + JWT_REFRESH_SECRET: process.env.JWT_REFRESH_SECRET!, + REFRESH_TOKEN_HASH_SECRET: process.env.REFRESH_TOKEN_HASH_SECRET!, SESSION_SECRET: process.env.SESSION_SECRET!, OTP_SECRET_KEY: process.env.OTP_SECRET_KEY!, REDIS_URL: process.env.REDIS_URL || 'redis://localhost:6379', diff --git a/src/controllers/auth.controller.ts b/src/controllers/auth.controller.ts new file mode 100644 index 0000000..e8a3691 --- /dev/null +++ b/src/controllers/auth.controller.ts @@ -0,0 +1,143 @@ +import type { Context } from "hono"; +import res from "@/utils/response"; +import authService from "@/services/auth.service"; +import type { InferSchemaType } from "@/utils/validation"; +import authDtoValidation from "@/validation/auth.validation"; + +/** + * POST /auth/refresh + * Refresh access token using refresh token + */ +const refreshToken = async (c: Context) => { + try { + type RefreshTokenBody = InferSchemaType; + const { refresh_token } = c.get('validated') as RefreshTokenBody; + + // Get metadata + const ip = c.req.header("x-forwarded-for") || c.req.header("x-real-ip") || c.env?.ip; + const userAgent = c.req.header("user-agent"); + + // Rotate the refresh token + const tokenPair = await authService.rotateRefreshToken(refresh_token, { ip, userAgent }); + + return res.SuccessResponse(c, 200, { + message: "Token refreshed successfully", + data: { + access_token: tokenPair.accessToken, + refresh_token: tokenPair.refreshToken, + expires_at: tokenPair.accessTokenExpiresAt, + refresh_expires_at: tokenPair.refreshTokenExpiresAt, + }, + }); + } catch (error) { + console.error("Refresh token error:", error); + return res.FailureResponse(c, 401, { + message: error instanceof Error ? error.message : "Invalid or expired refresh token", + }); + } +}; + +/** + * POST /auth/revoke + * Revoke a specific refresh token (logout from single device) + */ +const revokeToken = async (c: Context) => { + try { + type RevokeTokenBody = InferSchemaType; + const { refresh_token } = c.get('validated') as RevokeTokenBody; + + const success = await authService.revokeRefreshToken(refresh_token); + + if (!success) { + return res.FailureResponse(c, 404, { + message: "Refresh token not found", + }); + } + + return res.SuccessResponse(c, 200, { + message: "Refresh token revoked successfully", + data: {}, + }); + } catch (error) { + console.error("Revoke token error:", error); + return res.FailureResponse(c, 500, { + message: "Failed to revoke token", + }); + } +}; + +/** + * POST /auth/revoke-all + * Revoke all refresh tokens for the authenticated user (logout from all devices) + */ +const revokeAllTokens = async (c: Context) => { + try { + const user = c.get('user'); + if (!user) { + return res.FailureResponse(c, 401, { + message: "User not authenticated.", + }); + } + + const count = await authService.revokeAllUserTokens(user.id); + + return res.SuccessResponse(c, 200, { + message: "All refresh tokens revoked successfully", + data: { + revoked_count: count, + }, + }); + } catch (error) { + return res.FailureResponse(c, 500, { + message: "Failed to revoke all tokens", + }); + } +}; + +/** + * GET /auth/sessions + * Get all active refresh tokens (sessions) for the authenticated user + */ +const getActiveSessions = async (c: Context) => { + try { + const user = c.get('user'); + if (!user) { + return res.FailureResponse(c, 401, { + message: "User not authenticated.", + }); + } + + const sessions = await authService.getActiveRefreshTokens(user.id); + + // Map to user-friendly format (don't expose token hashes) + const sessionData = sessions.map(session => ({ + id: session.id, + refresh_token: session.token_hash, + created_at: session.created_at, + expires_at: session.expires_at, + last_used_at: session.last_used_at, + ip: session.ip, + user_agent: session.user_agent, + })); + + return res.SuccessResponse(c, 200, { + message: "Active sessions retrieved successfully", + data: { + sessions: sessionData, + total: sessionData.length, + }, + }); + } catch (error) { + console.error("Get active sessions error:", error); + return res.FailureResponse(c, 500, { + message: "Failed to retrieve active sessions", + }); + } +}; + +export default { + refreshToken, + revokeToken, + revokeAllTokens, + getActiveSessions, +}; diff --git a/src/controllers/user.controller.ts b/src/controllers/user.controller.ts index 75cbcd2..0e296bb 100644 --- a/src/controllers/user.controller.ts +++ b/src/controllers/user.controller.ts @@ -5,7 +5,7 @@ import userDtoValidation from "@/validation/user.validation"; import userRepository from "@/repository/user.repository"; import userService from "@/services/user.service"; import type { IUserAttributes } from "@/models/User.model"; -import { getSessionData, getValidPinSession, setSessionData, type PinSessionData } from "@/core/session"; +import { getValidPinSession, setSessionData, type PinSessionData } from "@/core/session"; /** @@ -49,26 +49,39 @@ const Login = async (c: Context) => { try { type LoginBody = InferSchemaType; const { email, password } = c.get('validated') as LoginBody; - // Check if user exists and account status + + // Check if user exists const user = await userRepository.findUserByEmail(email); if (!user) { return res.FailureResponse(c, 400, { message: "User not found", }); } - // Check if password is correct - const isPasswordCorrect = await userService.verifyPassword(password, user.password_hash); + // Verify password + const isPasswordCorrect = await userService.verifyPassword(password, user.password_hash); if (!isPasswordCorrect) { return res.FailureResponse(c, 400, { message: "Invalid password", }); } - // Generate session - const session = await userService.generateSession(user); + + // Get metadata for token generation + const ip = c.req.header("x-forwarded-for") || c.req.header("x-real-ip") || c.env?.ip; + const userAgent = c.req.header("user-agent"); + + // Generate session with refresh token + const session = await userService.generateSession(user, { ip, userAgent }); + return res.SuccessResponse(c, 200, { message: "Login successful", - data: { jwt: session, user: user }, + data: { + access_token: session.jwt_token, + refresh_token: session.refresh_token, + expires_at: session.expiresAt, + refresh_expires_at: session.refreshExpiresAt, + user: user + }, }); } catch (error) { diff --git a/src/core/session.ts b/src/core/session.ts index 2c97e72..e07e4fb 100644 --- a/src/core/session.ts +++ b/src/core/session.ts @@ -16,7 +16,6 @@ import constants from '@/global/constants'; * Cons: Limited by cookie size (~4KB), all data sent with every request * */ - const cookieOptions: SessionOptions['cookieOptions'] = { // domain: config.ENVIRONMENT === 'development' ? 'localhost' : undefined, path: '/', diff --git a/src/global/constants.ts b/src/global/constants.ts index 26f4bdb..1769700 100644 --- a/src/global/constants.ts +++ b/src/global/constants.ts @@ -1,9 +1,9 @@ - const OTP_VALID_DURATION = 10 * 60 * 1000; // 10 MINS in milliseconds const RESEND_COOLDOWN_MINUTES = 1; // one minuite -const TOKEN_EXPIRY_TIME = 360 * 24 * 60 * 60; // 365 DAYS in seconds +const ACCESS_TOKEN_EXPIRY_TIME = 15 * 60; // 15 MINUTES in seconds +const REFRESH_TOKEN_EXPIRY_TIME = 30 * 24 * 60 * 60; // 30 DAYS in seconds const INVITATION_EXPIRY_TIME = 30 * 24 * 60 * 60; // 30 DAYS in seconds const VERIFY_EMAIL_EXPIRY_TIME = 7 * 24 * 60 * 60; // 7 DAYS in seconds const SALT_ROUNDS = 10; @@ -26,7 +26,8 @@ export default { SALT_ROUNDS, MAX_FILE_UPLOAD, MAX_CPU_USAGE, - TOKEN_EXPIRY_TIME, + ACCESS_TOKEN_EXPIRY_TIME, + REFRESH_TOKEN_EXPIRY_TIME, VERIFY_EMAIL_EXPIRY_TIME, OTP_VALID_DURATION, MAX_PAGINATION_LIMIT, @@ -38,3 +39,4 @@ export default { SHARE_EXPIRY_TIME, PIN_SESSION_DURATION_MS, } + diff --git a/src/global/redis-constants.ts b/src/global/redis-constants.ts index 338ab2e..4a906e0 100644 --- a/src/global/redis-constants.ts +++ b/src/global/redis-constants.ts @@ -1,5 +1,7 @@ -const USER_SESSION_PREFIX = 'session:user:'; +// const USER_SESSION_PREFIX = 'session:user:'; +const ACCESS_TOKEN_PREFIX = 'access:token:'; export default { - USER_SESSION_PREFIX + // USER_SESSION_PREFIX, + ACCESS_TOKEN_PREFIX } \ No newline at end of file diff --git a/src/middleware/auth.middleware.ts b/src/middleware/auth.middleware.ts index d8f0f18..66b75fd 100644 --- a/src/middleware/auth.middleware.ts +++ b/src/middleware/auth.middleware.ts @@ -5,8 +5,6 @@ import jwt from "@/utils/jwt-token"; import db from "@/config/database"; import redisConn from "@/config/redis.config"; import redisConstants from "@/global/redis-constants"; -import { type ISessionData } from "@/types/hono"; -import authService from "@/services/user.service"; import { getValidPinSession } from "@/core/session"; import crypto from "crypto"; @@ -16,25 +14,21 @@ const verifyBearerToken = async (c: Context, token: string, next: Next) => { try { const jwt_decode = jwt.verifyJwtToken(token); - // 2️⃣ Check Redis session + // Check if access token exists in Redis (fast validation) const client = redisConn.getClient(); - const sessionKey = `${redisConstants.USER_SESSION_PREFIX}${jwt_decode.id}:${token}`; - const sessionData = await client.get(sessionKey); + const redisKey = `${redisConstants.ACCESS_TOKEN_PREFIX}${jwt_decode.id}:${token}`; + const tokenData = await client.get(redisKey); - if (!sessionData) { + if (!tokenData) { return res.FailureResponse(c, 401, { message: "Session expired or invalid." }); } - const { login_details } = authService.parseJson(sessionData); - - // Ensure the token matches the stored session - if (login_details.session_token !== token || !login_details.is_active) { - return res.FailureResponse(c, 401, { message: "Invalid or inactive session." }); - } + // Verify user exists in database const find_user = await db.User.findOne({ where: { id: jwt_decode.id }, raw: true }); if (!find_user) { return res.FailureResponse(c, 401, { message: "Login expired." }); } + // Attach the user object to the context for downstream handlers c.set("user", find_user); await next(); diff --git a/src/models/RefreshToken.model.ts b/src/models/RefreshToken.model.ts new file mode 100644 index 0000000..464f6a5 --- /dev/null +++ b/src/models/RefreshToken.model.ts @@ -0,0 +1,104 @@ +import { DataTypes, Model, type Optional, Sequelize } from 'sequelize'; + +// Interface for RefreshToken attributes +export interface IRefreshTokenAttributes { + id?: string; + user_id: string; + token_hash: string; + revoked: boolean; + expires_at: Date; + created_at?: Date; + replaced_by?: string | null; + ip?: string | null; + user_agent?: string | null; + last_used_at?: Date | null; +} + +// Optional attributes for creation +type IRefreshTokenCreationAttributes = Optional; + +// Sequelize Model class for RefreshToken +export class RefreshToken extends Model implements IRefreshTokenAttributes { + declare id: string; + declare user_id: string; + declare token_hash: string; + declare revoked: boolean; + declare expires_at: Date; + declare created_at: Date; + declare replaced_by: string | null; + declare ip: string | null; + declare user_agent: string | null; + declare last_used_at: Date | null; +} + +// Model initialization function +export const RefreshTokenModel = (sequelize: Sequelize): typeof RefreshToken => { + RefreshToken.init({ + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + user_id: { + type: DataTypes.UUID, + allowNull: false, + references: { + model: 'users', + key: 'id', + }, + }, + token_hash: { + type: DataTypes.STRING(512), + allowNull: false, + }, + revoked: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + expires_at: { + type: DataTypes.DATE, + allowNull: false, + }, + replaced_by: { + type: DataTypes.UUID, + allowNull: true, + }, + ip: { + type: DataTypes.STRING(50), + allowNull: true, + }, + user_agent: { + type: DataTypes.STRING(255), + allowNull: true, + }, + last_used_at: { + type: DataTypes.DATE, + allowNull: true, + }, + }, { + sequelize, + tableName: "refresh_tokens", + freezeTableName: true, + timestamps: true, + underscored: true, + createdAt: 'created_at', + updatedAt: false, + indexes: [ + { + fields: ['user_id'], + name: 'idx_refresh_tokens_user_id', + }, + { + fields: ['token_hash'], + name: 'idx_refresh_tokens_token_hash', + }, + { + fields: ['expires_at'], + name: 'idx_refresh_tokens_expires_at', + }, + ], + }); + + return RefreshToken; +}; diff --git a/src/models/index.ts b/src/models/index.ts index f83d95f..5d175fe 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -11,6 +11,7 @@ export * from './Favorite.model'; export * from './Notification.model'; export * from './UserSession.model'; export * from './ApiToken.model'; +export * from './RefreshToken.model'; // Import all model functions import { UserModel } from './User.model'; @@ -23,6 +24,7 @@ import { FavoriteModel } from './Favorite.model'; import { NotificationModel } from './Notification.model'; import { UserSessionModel } from './UserSession.model'; import { ApiTokenModel } from './ApiToken.model'; +import { RefreshTokenModel } from './RefreshToken.model'; // Function to initialize all models with associations export const initializeModels = (sequelize: Sequelize) => { @@ -37,6 +39,7 @@ export const initializeModels = (sequelize: Sequelize) => { const Notification = NotificationModel(sequelize); const UserSession = UserSessionModel(sequelize); const ApiToken = ApiTokenModel(sequelize); + const RefreshToken = RefreshTokenModel(sequelize); // Define associations // User associations @@ -52,6 +55,7 @@ export const initializeModels = (sequelize: Sequelize) => { User.hasMany(Notification, { foreignKey: 'related_user_id', as: 'relatedNotifications' }); User.hasMany(UserSession, { foreignKey: 'user_id', as: 'sessions' }); User.hasMany(ApiToken, { foreignKey: 'user_id', as: 'apiTokens' }); + User.hasMany(RefreshToken, { foreignKey: 'user_id', as: 'refreshTokens' }); // File associations File.belongsTo(User, { foreignKey: 'owner_id', as: 'owner' }); @@ -92,6 +96,9 @@ export const initializeModels = (sequelize: Sequelize) => { // ApiToken associations ApiToken.belongsTo(User, { foreignKey: 'user_id', as: 'user' }); + // RefreshToken associations + RefreshToken.belongsTo(User, { foreignKey: 'user_id', as: 'user' }); + return { User, File, @@ -103,6 +110,7 @@ export const initializeModels = (sequelize: Sequelize) => { Notification, UserSession, ApiToken, + RefreshToken, }; }; diff --git a/src/repository/refreshToken.repository.ts b/src/repository/refreshToken.repository.ts new file mode 100644 index 0000000..ff38da4 --- /dev/null +++ b/src/repository/refreshToken.repository.ts @@ -0,0 +1,117 @@ +import db from "@/config/database"; +import { type IRefreshTokenAttributes } from "@/models/RefreshToken.model"; +import { Op } from "sequelize"; + +/** + * Create a new refresh token record + */ +const createRefreshToken = async (tokenData: IRefreshTokenAttributes) => { + return await db.RefreshToken.create(tokenData); +}; + +/** + * Find a refresh token by its hash + */ +const findRefreshTokenByHash = async (tokenHash: string) => { + return await db.RefreshToken.findOne({ + where: { token_hash: tokenHash }, + raw: true + }); +}; + +/** + * Find a refresh token by its ID + */ +const findRefreshTokenById = async (id: string) => { + return await db.RefreshToken.findOne({ + where: { id }, + raw: true + }); +}; + +/** + * Update a refresh token (mark as revoked, set replaced_by, etc.) + */ +const updateRefreshToken = async (id: string, updates: Partial) => { + const [affectedRows] = await db.RefreshToken.update(updates, { + where: { id } + }); + return affectedRows > 0; +}; + +/** + * Revoke a specific refresh token by ID + */ +const revokeRefreshTokenById = async (id: string) => { + return await updateRefreshToken(id, { + revoked: true, + last_used_at: new Date() + }); +}; + +/** + * Revoke all refresh tokens for a user + */ +const revokeAllRefreshTokensForUser = async (userId: string) => { + const [affectedRows] = await db.RefreshToken.update( + { revoked: true }, + { where: { user_id: userId } } + ); + return affectedRows; +}; + +/** + * Find all active (non-revoked, non-expired) refresh tokens for a user + */ +const findActiveRefreshTokensForUser = async (userId: string) => { + return await db.RefreshToken.findAll({ + where: { + user_id: userId, + revoked: false, + expires_at: { [Op.gt]: new Date() } + }, + raw: true, + order: [["created_at", "DESC"]] + }); +}; + +/** + * Delete expired refresh tokens (cleanup job) + */ +const deleteExpiredRefreshTokens = async () => { + const result = await db.RefreshToken.destroy({ + where: { + expires_at: { [Op.lt]: new Date() } + } + }); + return result; +}; + +/** + * Delete old revoked tokens (cleanup job) + * Deletes revoked tokens older than specified days + */ +const deleteOldRevokedTokens = async (daysOld: number = 30) => { + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - daysOld); + + const result = await db.RefreshToken.destroy({ + where: { + revoked: true, + created_at: { [Op.lt]: cutoffDate } + } + }); + return result; +}; + +export default { + createRefreshToken, + findRefreshTokenByHash, + findRefreshTokenById, + updateRefreshToken, + revokeRefreshTokenById, + revokeAllRefreshTokensForUser, + findActiveRefreshTokensForUser, + deleteExpiredRefreshTokens, + deleteOldRevokedTokens +}; diff --git a/src/repository/user.repository.ts b/src/repository/user.repository.ts index aaf6a40..e651d71 100644 --- a/src/repository/user.repository.ts +++ b/src/repository/user.repository.ts @@ -12,7 +12,7 @@ interface GetAllUsersParams { const saveSession = async (login_details: IUserSessionAttributes) => { await db.User.update({ last_login: new Date() }, { where: { id: login_details.user_id } }); - return await db.UserSession.create(login_details); + // return await db.UserSession.create(login_details); } const deleteSessionByToken = async (session_token: string) => { diff --git a/src/routes/user.routes.ts b/src/routes/user.routes.ts index 77c6070..246c05d 100644 --- a/src/routes/user.routes.ts +++ b/src/routes/user.routes.ts @@ -1,8 +1,10 @@ import { Hono } from "hono"; import AuthMiddleware from "@/middleware/auth.middleware"; import AuthController from "@/controllers/user.controller"; +import TokenAuthController from "@/controllers/auth.controller"; import { validateBody, validateQuery } from '@/utils/validation'; import userDtoValidation from '@/validation/user.validation'; +import authDtoValidation from '@/validation/auth.validation'; export class AuthRouter { /** Each router owns its own Hono instance */ @@ -11,6 +13,7 @@ export class AuthRouter { constructor() { this.router = new Hono(); this.initializeRoutes(); + this.initializeAuthTokenRoutes(); } private initializeRoutes() { @@ -22,7 +25,25 @@ export class AuthRouter { this.router.post('/user/verify-pin', AuthMiddleware.authMiddleware, validateBody(userDtoValidation.verifyPinValidation), AuthController.verifyPin); this.router.post('/user/set-pin', AuthMiddleware.authMiddleware, validateBody(userDtoValidation.setPinValidation), AuthController.setPin); this.router.put('/user/change-pin', AuthMiddleware.authMiddleware, validateBody(userDtoValidation.changePinValidation), AuthController.changePin); - this.router.get('/user/get-session',AuthMiddleware.authMiddleware, AuthMiddleware.pinSessionMiddleware, AuthController.getSession); + this.router.get('/user/get-session', AuthMiddleware.authMiddleware, AuthMiddleware.pinSessionMiddleware, AuthController.getSession); + } + + /** + * Initialize authentication token management routes + * Handles refresh tokens, token revocation, and session management + */ + private initializeAuthTokenRoutes() { + // POST /auth/refresh - Refresh access token using refresh token + this.router.post('/refresh', validateBody(authDtoValidation.refreshTokenValidation), TokenAuthController.refreshToken); + + // POST /auth/revoke - Revoke a specific refresh token (logout from single device) + this.router.post('/revoke', validateBody(authDtoValidation.revokeTokenValidation), TokenAuthController.revokeToken); + + // POST /auth/revoke-all - Revoke all refresh tokens (logout from all devices) + this.router.post('/revoke-all', AuthMiddleware.authMiddleware, TokenAuthController.revokeAllTokens); + + // GET /auth/sessions - Get all active sessions for the authenticated user + this.router.get('/sessions', AuthMiddleware.authMiddleware, TokenAuthController.getActiveSessions); } public getRouter() { diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts new file mode 100644 index 0000000..0a8db84 --- /dev/null +++ b/src/services/auth.service.ts @@ -0,0 +1,203 @@ +import constants from "@/global/constants"; +import jwt from "@/utils/jwt-token"; +import { hashRefreshToken } from "@/utils/token-hash"; +import refreshTokenRepository from "@/repository/refreshToken.repository"; +import redisConn from "@/config/redis.config"; +import redisConstants from "@/global/redis-constants"; +import { type IUserAttributes } from "@/models/User.model"; + +interface RefreshTokenMetadata { + ip?: string; + userAgent?: string; +} + +interface TokenPair { + accessToken: string; + refreshToken: string; + accessTokenExpiresAt: number; + refreshTokenExpiresAt: number; +} + +/** + * Generate both access token and refresh token for a user + * Stores refresh token in database (hashed) and Redis + */ +async function generateTokenPair(user: IUserAttributes, metadata?: RefreshTokenMetadata): Promise { + const issuedAt = Math.floor(Date.now() / 1000); + const accessTokenExpiresIn = constants.ACCESS_TOKEN_EXPIRY_TIME; + const refreshTokenExpiresIn = constants.REFRESH_TOKEN_EXPIRY_TIME; + + const accessTokenExpiresAt = issuedAt + accessTokenExpiresIn; + const refreshTokenExpiresAt = issuedAt + refreshTokenExpiresIn; + + const payload = { + id: user.id, + email: user.email, + role: user.role, + }; + + // Generate tokens + const accessToken = await jwt.generateJwtToken(payload, accessTokenExpiresIn); + const rawRefreshToken = await jwt.generateRefreshToken(payload, refreshTokenExpiresIn); + + // Hash the refresh token for storage + const tokenHash = hashRefreshToken(rawRefreshToken); + + // Save refresh token to database + const refreshTokenRecord = await refreshTokenRepository.createRefreshToken({ + user_id: user.id, + token_hash: tokenHash, + expires_at: new Date(refreshTokenExpiresAt * 1000), + revoked: false, + ip: metadata?.ip, + user_agent: metadata?.userAgent, + }); + + // Store access token in Redis with TTL for fast validation + const client = redisConn.getClient(); + const redisKey = `${redisConstants.ACCESS_TOKEN_PREFIX}${user.id}:${accessToken}`; + await client.set( + redisKey, + JSON.stringify({ + userId: user.id, + email: user.email, + role: user.role, + createdAt: new Date().toISOString(), + expiresAt: new Date(accessTokenExpiresAt * 1000).toISOString(), + }), + 'EX', + accessTokenExpiresIn + ); + + return { + accessToken, + refreshToken: rawRefreshToken, + accessTokenExpiresAt, + refreshTokenExpiresAt, + }; +} + +/** + * Rotate refresh token - validates old token and issues new token pair + * Implements token rotation and reuse detection + */ +async function rotateRefreshToken(oldRefreshToken: string, metadata?: RefreshTokenMetadata): Promise { + try { + // 1. Verify JWT signature and expiry + const payload = jwt.verifyRefreshToken(oldRefreshToken); + const userId = payload.id; + + // 2. Hash the token and find it in database + const tokenHash = hashRefreshToken(oldRefreshToken); + const savedToken = await refreshTokenRepository.findRefreshTokenByHash(tokenHash); + + // 3. Token not found - possible reuse attack + if (!savedToken) { + // Revoke all tokens for this user as a security measure + await revokeAllUserTokens(userId); + throw new Error('Refresh token not found - possible reuse detected. All tokens revoked.'); + } + + // 4. Token already revoked - reuse detected + if (savedToken.revoked) { + // Revoke all tokens for this user + await revokeAllUserTokens(userId); + throw new Error('Refresh token has been revoked - possible token reuse. All tokens revoked.'); + } + + // 5. Token expired + if (savedToken.expires_at.getTime() < Date.now()) { + throw new Error('Refresh token expired'); + } + + // 6. Generate new token pair + const user: IUserAttributes = { + id: userId, + email: payload.email, + role: payload.role, + } as IUserAttributes; + + const newTokenPair = await generateTokenPair(user, metadata); + + // 7. Mark old token as revoked and link to new token + // We need to get the new token's ID from the hash + const newTokenHash = hashRefreshToken(newTokenPair.refreshToken); + const newTokenRecord = await refreshTokenRepository.findRefreshTokenByHash(newTokenHash); + + await refreshTokenRepository.updateRefreshToken(savedToken.id!, { + revoked: true, + replaced_by: newTokenRecord?.id, + last_used_at: new Date(), + }); + + // Note: Access tokens in Redis will expire automatically via TTL + // No need to manually delete them during refresh token rotation + + return newTokenPair; + } catch (err) { + // Re-throw with context + throw err; + } +} + +/** + * Revoke a specific refresh token + */ +async function revokeRefreshToken(refreshToken: string): Promise { + try { + // const tokenHash = hashRefreshToken(refreshToken); + const savedToken = await refreshTokenRepository.findRefreshTokenByHash(refreshToken); + + if (!savedToken) { + return false; + } + + // Mark as revoked in database + await refreshTokenRepository.revokeRefreshTokenById(savedToken.id!); + + // Note: Access tokens in Redis will expire automatically via TTL + // Refresh tokens are only stored in database, not in Redis + + return true; + } catch (err) { + throw err; + } +} + +/** + * Revoke all refresh tokens for a user + */ +async function revokeAllUserTokens(userId: string): Promise { + // Revoke in database + const count = await refreshTokenRepository.revokeAllRefreshTokensForUser(userId); + return count; +} + +/** + * Get all active refresh tokens for a user + */ +async function getActiveRefreshTokens(userId: string) { + return await refreshTokenRepository.findActiveRefreshTokensForUser(userId); +} + +/** + * Cleanup expired and old revoked tokens (for cron job) + */ +async function cleanupTokens() { + const expiredCount = await refreshTokenRepository.deleteExpiredRefreshTokens(); + const oldRevokedCount = await refreshTokenRepository.deleteOldRevokedTokens(30); + + return { + expiredDeleted: expiredCount, + oldRevokedDeleted: oldRevokedCount, + }; +} + +export default { + generateTokenPair, + rotateRefreshToken, + revokeRefreshToken, + revokeAllUserTokens, + getActiveRefreshTokens, + cleanupTokens, +}; diff --git a/src/services/socket.service.ts b/src/services/socket.service.ts index a4f1ca1..a8b0882 100644 --- a/src/services/socket.service.ts +++ b/src/services/socket.service.ts @@ -41,7 +41,7 @@ class SocketService { const decoded = jwt.verifyJwtToken(token); const client = redisConn.getClient(); - const sessionKey = `${redisConstants.USER_SESSION_PREFIX}${decoded.id}:${token}`; + const sessionKey = `${redisConstants.ACCESS_TOKEN_PREFIX}${decoded.id}:${token}`; const sessionData = await client.get(sessionKey); if (!sessionData) { diff --git a/src/services/user.service.ts b/src/services/user.service.ts index c000793..3822509 100644 --- a/src/services/user.service.ts +++ b/src/services/user.service.ts @@ -6,22 +6,18 @@ import { type IUserAttributes } from "@/models/User.model"; import redisConn from "@/config/redis.config"; import redisConstants from "@/global/redis-constants"; import bcryptjs from "bcryptjs"; +import authService from "@/services/auth.service"; -async function generateSession(user: IUserAttributes, metadata?: Record): Promise<{ jwt_token: string; expiresAt: number }> { - const issuedAt = Math.floor(Date.now() / 1000); // seconds - const expiresIn = constants.TOKEN_EXPIRY_TIME; // e.g. 3600 for 1 hour - const expiresAt = issuedAt + expiresIn; +async function generateSession(user: IUserAttributes, metadata?: Record): Promise<{ jwt_token: string; refresh_token: string; expiresAt: number; refreshExpiresAt: number }> { + // Use the new auth service to generate token pair + const tokenPair = await authService.generateTokenPair(user, metadata); - const payload = { - id: user.id, - email: user.email, - }; - const jwt_token = await jwt.generateJwtToken(payload, expiresIn); + // Still save the access token session for backward compatibility const login_details: IUserSessionAttributes = { - session_token: jwt_token, - user_id: payload.id, - expires_at: new Date(expiresAt * 1000), + session_token: tokenPair.accessToken, + user_id: user.id, + expires_at: new Date(tokenPair.accessTokenExpiresAt * 1000), is_active: true, device_token: null, metadata: metadata ? metadata : null, @@ -30,19 +26,25 @@ async function generateSession(user: IUserAttributes, metadata?: Record { // 1. Delete session from database completely - await userRepository.deleteSessionByToken(sessionToken); + // await userRepository.deleteSessionByToken(sessionToken); // 2. Delete session from Redis const client = redisConn.getClient(); - const sessionKey = `${redisConstants.USER_SESSION_PREFIX}${userId}:${sessionToken}`; + const sessionKey = `${redisConstants.ACCESS_TOKEN_PREFIX}${userId}:${sessionToken}`; await client.del(sessionKey); return true; @@ -51,20 +53,23 @@ async function logout(userId: string, sessionToken: string): Promise { async function logoutAllSessions(userId: string): Promise { // 1. Deactivate all active sessions for the user in database - const activeSessions = await userRepository.findActiveSessionsByUserId(userId); + // const activeSessions = await userRepository.findActiveSessionsByUserId(userId); - for (const session of activeSessions) { - await userRepository.deactivateSessionByToken(session.session_token); - } + // for (const session of activeSessions) { + // await userRepository.deactivateSessionByToken(session.session_token); + // } // 2. Delete session from Redis const client = redisConn.getClient(); - const sessionPattern = `${redisConstants.USER_SESSION_PREFIX}${userId}:*`; + const sessionPattern = `${redisConstants.ACCESS_TOKEN_PREFIX}${userId}:*`; const keys = await client.keys(sessionPattern); if (keys.length > 0) { await client.del(...keys); // delete all at once } + // 3. Also revoke all refresh tokens + await authService.revokeAllUserTokens(userId); + return true; } diff --git a/src/utils/jwt-token.ts b/src/utils/jwt-token.ts index bbf5b86..4c22c02 100644 --- a/src/utils/jwt-token.ts +++ b/src/utils/jwt-token.ts @@ -5,11 +5,12 @@ import config from "@/config/config"; export interface TokenAttributes { id: string; email: string; + role: string; } async function generateEncryptedPayload(payload: TokenAttributes): Promise { // Hash the secret key to ensure it is 32 bytes long - const secretKey = crypto.createHash('sha256').update(config.JWT_SECRET as string).digest(); + const secretKey = crypto.createHash('sha256').update(config.JWT_REFRESH_SECRET as string).digest(); const iv = crypto.randomBytes(16); // Generate a random IV const cipher = crypto.createCipheriv('aes-256-cbc', secretKey, iv); @@ -31,7 +32,7 @@ function decryptToken(decoded: { encryptedData: string }): TokenAttributes { } const iv = Buffer.from(ivString, 'base64'); // Hash the secret key to ensure it is 32 bytes long - const secretKey = crypto.createHash('sha256').update(config.JWT_SECRET as string).digest(); + const secretKey = crypto.createHash('sha256').update(config.JWT_REFRESH_SECRET as string).digest(); // Create the decipher object using the same algorithm and secret key const decipher = crypto.createDecipheriv('aes-256-cbc', secretKey, iv); // Decrypt the payload @@ -48,16 +49,45 @@ function decryptToken(decoded: { encryptedData: string }): TokenAttributes { const generateJwtToken = async (payload: TokenAttributes, expiryTime: number) => { // Create JWT with the encrypted payload const combinedPayload: string = await generateEncryptedPayload(payload) - const token: string = jwt.sign({ encryptedData: combinedPayload }, config.JWT_SECRET as string, { expiresIn: expiryTime }); + const token: string = jwt.sign({ encryptedData: combinedPayload }, config.JWT_REFRESH_SECRET as string, { expiresIn: expiryTime }); return token; }; const verifyJwtToken = (token: string): TokenAttributes => { - const decoded = jwt.verify(token, config.JWT_SECRET as string) as { encryptedData: string }; + const decoded = jwt.verify(token, config.JWT_REFRESH_SECRET as string) as { encryptedData: string }; return decryptToken(decoded) } +/** + * Generate a refresh token (JWT with user payload) + * @param payload - User data to include in the token + * @param expiryTime - Expiry time in seconds + * @returns Signed refresh token + */ +const generateRefreshToken = async (payload: TokenAttributes, expiryTime: number): Promise => { + const combinedPayload: string = await generateEncryptedPayload(payload); + const token: string = jwt.sign( + { encryptedData: combinedPayload }, + config.JWT_REFRESH_SECRET as string, + { expiresIn: expiryTime } + ); + return token; +}; + +/** + * Verify a refresh token and return the decrypted payload + * @param token - The refresh token to verify + * @returns Decrypted token payload + */ +const verifyRefreshToken = (token: string): TokenAttributes => { + const decoded = jwt.verify(token, config.JWT_REFRESH_SECRET as string) as { encryptedData: string }; + return decryptToken(decoded); +}; + export default { generateJwtToken, - verifyJwtToken + verifyJwtToken, + generateRefreshToken, + verifyRefreshToken }; + diff --git a/src/utils/token-hash.ts b/src/utils/token-hash.ts new file mode 100644 index 0000000..0adb570 --- /dev/null +++ b/src/utils/token-hash.ts @@ -0,0 +1,28 @@ +import crypto from 'crypto'; +import config from '@/config/config'; + +/** + * Hash a refresh token using HMAC-SHA256 + * This is used to securely store refresh tokens in the database + * @param token - The raw refresh token to hash + * @returns The hashed token as a hex string + */ +export function hashRefreshToken(token: string): string { + return crypto.createHmac('sha256', config.REFRESH_TOKEN_HASH_SECRET).update(token).digest('hex'); +} + +/** + * Verify if a raw token matches a stored hash + * @param token - The raw token to verify + * @param hash - The stored hash to compare against + * @returns True if the token matches the hash + */ +export function verifyRefreshTokenHash(hash: string, token: string): boolean { + const computedHash = hashRefreshToken(token); + return crypto.timingSafeEqual(Buffer.from(computedHash, 'hex'), Buffer.from(hash, 'hex')); +} + +export default { + hashRefreshToken, + verifyRefreshTokenHash +}; diff --git a/src/utils/validation.ts b/src/utils/validation.ts index c0ce599..bd7a779 100644 --- a/src/utils/validation.ts +++ b/src/utils/validation.ts @@ -10,8 +10,14 @@ export function validateBody(schema: T) { const body = await c.req.json(); const { value: cleaned, error } = schema.validate(body, { abortEarly: false, stripUnknown: true }); if (error) { + const formattedErrors: Record = {}; + + error.details.forEach((err) => { + const key = err.path.join('.') || 'unknown'; + formattedErrors[key] = err.message; + }); return res.FailureResponse(c, 422, { - errors: error.details.map((d) => d.message), + errors: formattedErrors, message: 'Validation failed', }) } diff --git a/src/validation/auth.validation.ts b/src/validation/auth.validation.ts new file mode 100644 index 0000000..5fe8c74 --- /dev/null +++ b/src/validation/auth.validation.ts @@ -0,0 +1,32 @@ +import Joi from "joi"; + +/** + * Validation schema for refresh token endpoint + */ +const refreshTokenValidation = Joi.object({ + refresh_token: Joi.string() + .required() + .messages({ + "string.base": "Refresh token must be a text value", + "string.empty": "Refresh token is required", + "any.required": "Refresh token is required" + }), +}); + +/** + * Validation schema for revoking a specific token + */ +const revokeTokenValidation = Joi.object({ + refresh_token: Joi.string() + .required() + .messages({ + "string.base": "Refresh token must be a text value", + "string.empty": "Refresh token is required", + "any.required": "Refresh token is required" + }), +}); + +export default { + refreshTokenValidation, + revokeTokenValidation, +};