457 lines
14 KiB
TypeScript
457 lines
14 KiB
TypeScript
import jwt, { SignOptions } from 'jsonwebtoken';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
import { Repository } from 'typeorm';
|
|
import { AppDataSource } from '../../../config/typeorm.js';
|
|
import { config } from '../../../config/index.js';
|
|
import { User, Session, SessionStatus } from '../entities/index.js';
|
|
import { blacklistToken, isTokenBlacklisted } from '../../../config/redis.js';
|
|
import { logger } from '../../../shared/utils/logger.js';
|
|
import { UnauthorizedError } from '../../../shared/types/index.js';
|
|
|
|
// ===== Interfaces =====
|
|
|
|
/**
|
|
* JWT Payload structure for access and refresh tokens
|
|
*/
|
|
export interface JwtPayload {
|
|
sub: string; // User ID
|
|
tid: string; // Tenant ID
|
|
email: string;
|
|
roles: string[];
|
|
jti: string; // JWT ID único
|
|
iat: number;
|
|
exp: number;
|
|
}
|
|
|
|
/**
|
|
* Token pair returned after authentication
|
|
*/
|
|
export interface TokenPair {
|
|
accessToken: string;
|
|
refreshToken: string;
|
|
accessTokenExpiresAt: Date;
|
|
refreshTokenExpiresAt: Date;
|
|
sessionId: string;
|
|
}
|
|
|
|
/**
|
|
* Request metadata for session tracking
|
|
*/
|
|
export interface RequestMetadata {
|
|
ipAddress: string;
|
|
userAgent: string;
|
|
}
|
|
|
|
// ===== TokenService Class =====
|
|
|
|
/**
|
|
* Service for managing JWT tokens with blacklist support via Redis
|
|
* and session tracking via TypeORM
|
|
*/
|
|
class TokenService {
|
|
private sessionRepository: Repository<Session>;
|
|
|
|
// Configuration constants
|
|
private readonly ACCESS_TOKEN_EXPIRY = '15m';
|
|
private readonly REFRESH_TOKEN_EXPIRY = '7d';
|
|
private readonly ALGORITHM = 'HS256' as const;
|
|
|
|
constructor() {
|
|
this.sessionRepository = AppDataSource.getRepository(Session);
|
|
}
|
|
|
|
/**
|
|
* Generates a new token pair (access + refresh) and creates a session
|
|
* @param user - User entity with roles loaded
|
|
* @param metadata - Request metadata (IP, user agent)
|
|
* @returns Promise<TokenPair> - Access and refresh tokens with expiration dates
|
|
*/
|
|
async generateTokenPair(user: User, metadata: RequestMetadata): Promise<TokenPair> {
|
|
try {
|
|
logger.debug('Generating token pair', { userId: user.id, tenantId: user.tenantId });
|
|
|
|
// Extract role codes from user roles
|
|
const roles = user.roles ? user.roles.map(role => role.code) : [];
|
|
|
|
// Calculate expiration dates
|
|
const accessTokenExpiresAt = this.calculateExpiration(this.ACCESS_TOKEN_EXPIRY);
|
|
const refreshTokenExpiresAt = this.calculateExpiration(this.REFRESH_TOKEN_EXPIRY);
|
|
|
|
// Generate unique JWT IDs
|
|
const accessJti = this.generateJti();
|
|
const refreshJti = this.generateJti();
|
|
|
|
// Generate access token
|
|
const accessToken = this.generateToken({
|
|
sub: user.id,
|
|
tid: user.tenantId,
|
|
email: user.email,
|
|
roles,
|
|
jti: accessJti,
|
|
}, this.ACCESS_TOKEN_EXPIRY);
|
|
|
|
// Generate refresh token
|
|
const refreshToken = this.generateToken({
|
|
sub: user.id,
|
|
tid: user.tenantId,
|
|
email: user.email,
|
|
roles,
|
|
jti: refreshJti,
|
|
}, this.REFRESH_TOKEN_EXPIRY);
|
|
|
|
// Create session record in database
|
|
const session = this.sessionRepository.create({
|
|
userId: user.id,
|
|
token: accessJti, // Store JTI instead of full token
|
|
refreshToken: refreshJti, // Store JTI instead of full token
|
|
status: SessionStatus.ACTIVE,
|
|
expiresAt: accessTokenExpiresAt,
|
|
refreshExpiresAt: refreshTokenExpiresAt,
|
|
ipAddress: metadata.ipAddress,
|
|
userAgent: metadata.userAgent,
|
|
});
|
|
|
|
await this.sessionRepository.save(session);
|
|
|
|
logger.info('Token pair generated successfully', {
|
|
userId: user.id,
|
|
sessionId: session.id,
|
|
tenantId: user.tenantId,
|
|
});
|
|
|
|
return {
|
|
accessToken,
|
|
refreshToken,
|
|
accessTokenExpiresAt,
|
|
refreshTokenExpiresAt,
|
|
sessionId: session.id,
|
|
};
|
|
} catch (error) {
|
|
logger.error('Error generating token pair', {
|
|
error: (error as Error).message,
|
|
userId: user.id,
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Refreshes an access token using a valid refresh token
|
|
* Implements token replay detection for enhanced security
|
|
* @param refreshToken - Valid refresh token
|
|
* @param metadata - Request metadata (IP, user agent)
|
|
* @returns Promise<TokenPair> - New access and refresh tokens
|
|
* @throws UnauthorizedError if token is invalid or replay detected
|
|
*/
|
|
async refreshTokens(refreshToken: string, metadata: RequestMetadata): Promise<TokenPair> {
|
|
try {
|
|
logger.debug('Refreshing tokens');
|
|
|
|
// Verify refresh token
|
|
const payload = this.verifyRefreshToken(refreshToken);
|
|
|
|
// Find active session with this refresh token JTI
|
|
const session = await this.sessionRepository.findOne({
|
|
where: {
|
|
refreshToken: payload.jti,
|
|
status: SessionStatus.ACTIVE,
|
|
},
|
|
relations: ['user', 'user.roles'],
|
|
});
|
|
|
|
if (!session) {
|
|
logger.warn('Refresh token not found or session inactive', {
|
|
jti: payload.jti,
|
|
});
|
|
throw new UnauthorizedError('Refresh token inválido o expirado');
|
|
}
|
|
|
|
// Check if session has already been used (token replay detection)
|
|
if (session.revokedAt !== null) {
|
|
logger.error('TOKEN REPLAY DETECTED - Session was already used', {
|
|
sessionId: session.id,
|
|
userId: session.userId,
|
|
jti: payload.jti,
|
|
});
|
|
|
|
// SECURITY: Revoke ALL user sessions on replay detection
|
|
const revokedCount = await this.revokeAllUserSessions(
|
|
session.userId,
|
|
'Token replay detected'
|
|
);
|
|
|
|
logger.error('All user sessions revoked due to token replay', {
|
|
userId: session.userId,
|
|
revokedCount,
|
|
});
|
|
|
|
throw new UnauthorizedError('Replay de token detectado. Todas las sesiones han sido revocadas por seguridad.');
|
|
}
|
|
|
|
// Verify session hasn't expired
|
|
if (session.refreshExpiresAt && new Date() > session.refreshExpiresAt) {
|
|
logger.warn('Refresh token expired', {
|
|
sessionId: session.id,
|
|
expiredAt: session.refreshExpiresAt,
|
|
});
|
|
|
|
await this.revokeSession(session.id, 'Token expired');
|
|
throw new UnauthorizedError('Refresh token expirado');
|
|
}
|
|
|
|
// Mark current session as used (revoke it)
|
|
session.status = SessionStatus.REVOKED;
|
|
session.revokedAt = new Date();
|
|
session.revokedReason = 'Used for refresh';
|
|
await this.sessionRepository.save(session);
|
|
|
|
// Generate new token pair
|
|
const newTokenPair = await this.generateTokenPair(session.user, metadata);
|
|
|
|
logger.info('Tokens refreshed successfully', {
|
|
userId: session.userId,
|
|
oldSessionId: session.id,
|
|
newSessionId: newTokenPair.sessionId,
|
|
});
|
|
|
|
return newTokenPair;
|
|
} catch (error) {
|
|
logger.error('Error refreshing tokens', {
|
|
error: (error as Error).message,
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Revokes a session and blacklists its access token
|
|
* @param sessionId - Session ID to revoke
|
|
* @param reason - Reason for revocation
|
|
*/
|
|
async revokeSession(sessionId: string, reason: string): Promise<void> {
|
|
try {
|
|
logger.debug('Revoking session', { sessionId, reason });
|
|
|
|
const session = await this.sessionRepository.findOne({
|
|
where: { id: sessionId },
|
|
});
|
|
|
|
if (!session) {
|
|
logger.warn('Session not found for revocation', { sessionId });
|
|
return;
|
|
}
|
|
|
|
// Mark session as revoked
|
|
session.status = SessionStatus.REVOKED;
|
|
session.revokedAt = new Date();
|
|
session.revokedReason = reason;
|
|
await this.sessionRepository.save(session);
|
|
|
|
// Blacklist the access token (JTI) in Redis
|
|
const remainingTTL = this.calculateRemainingTTL(session.expiresAt);
|
|
if (remainingTTL > 0) {
|
|
await this.blacklistAccessToken(session.token, remainingTTL);
|
|
}
|
|
|
|
logger.info('Session revoked successfully', { sessionId, reason });
|
|
} catch (error) {
|
|
logger.error('Error revoking session', {
|
|
error: (error as Error).message,
|
|
sessionId,
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Revokes all active sessions for a user
|
|
* Used for security events like password change or token replay detection
|
|
* @param userId - User ID whose sessions to revoke
|
|
* @param reason - Reason for revocation
|
|
* @returns Promise<number> - Number of sessions revoked
|
|
*/
|
|
async revokeAllUserSessions(userId: string, reason: string): Promise<number> {
|
|
try {
|
|
logger.debug('Revoking all user sessions', { userId, reason });
|
|
|
|
const sessions = await this.sessionRepository.find({
|
|
where: {
|
|
userId,
|
|
status: SessionStatus.ACTIVE,
|
|
},
|
|
});
|
|
|
|
if (sessions.length === 0) {
|
|
logger.debug('No active sessions found for user', { userId });
|
|
return 0;
|
|
}
|
|
|
|
// Revoke each session
|
|
for (const session of sessions) {
|
|
session.status = SessionStatus.REVOKED;
|
|
session.revokedAt = new Date();
|
|
session.revokedReason = reason;
|
|
|
|
// Blacklist access token
|
|
const remainingTTL = this.calculateRemainingTTL(session.expiresAt);
|
|
if (remainingTTL > 0) {
|
|
await this.blacklistAccessToken(session.token, remainingTTL);
|
|
}
|
|
}
|
|
|
|
await this.sessionRepository.save(sessions);
|
|
|
|
logger.info('All user sessions revoked', {
|
|
userId,
|
|
count: sessions.length,
|
|
reason,
|
|
});
|
|
|
|
return sessions.length;
|
|
} catch (error) {
|
|
logger.error('Error revoking all user sessions', {
|
|
error: (error as Error).message,
|
|
userId,
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Adds an access token to the Redis blacklist
|
|
* @param jti - JWT ID to blacklist
|
|
* @param expiresIn - TTL in seconds
|
|
*/
|
|
async blacklistAccessToken(jti: string, expiresIn: number): Promise<void> {
|
|
try {
|
|
await blacklistToken(jti, expiresIn);
|
|
logger.debug('Access token blacklisted', { jti, expiresIn });
|
|
} catch (error) {
|
|
logger.error('Error blacklisting access token', {
|
|
error: (error as Error).message,
|
|
jti,
|
|
});
|
|
// Don't throw - blacklist is optional (Redis might be unavailable)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks if an access token is blacklisted
|
|
* @param jti - JWT ID to check
|
|
* @returns Promise<boolean> - true if blacklisted
|
|
*/
|
|
async isAccessTokenBlacklisted(jti: string): Promise<boolean> {
|
|
try {
|
|
return await isTokenBlacklisted(jti);
|
|
} catch (error) {
|
|
logger.error('Error checking token blacklist', {
|
|
error: (error as Error).message,
|
|
jti,
|
|
});
|
|
// Return false on error - fail open
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// ===== Private Helper Methods =====
|
|
|
|
/**
|
|
* Generates a JWT token with the specified payload and expiry
|
|
* @param payload - Token payload (without iat/exp)
|
|
* @param expiresIn - Expiration time string (e.g., '15m', '7d')
|
|
* @returns string - Signed JWT token
|
|
*/
|
|
private generateToken(payload: Omit<JwtPayload, 'iat' | 'exp'>, expiresIn: string): string {
|
|
return jwt.sign(payload, config.jwt.secret, {
|
|
expiresIn: expiresIn as jwt.SignOptions['expiresIn'],
|
|
algorithm: this.ALGORITHM,
|
|
} as SignOptions);
|
|
}
|
|
|
|
/**
|
|
* Verifies an access token and returns its payload
|
|
* @param token - JWT access token
|
|
* @returns JwtPayload - Decoded payload
|
|
* @throws UnauthorizedError if token is invalid
|
|
*/
|
|
private verifyAccessToken(token: string): JwtPayload {
|
|
try {
|
|
return jwt.verify(token, config.jwt.secret, {
|
|
algorithms: [this.ALGORITHM],
|
|
}) as JwtPayload;
|
|
} catch (error) {
|
|
logger.warn('Invalid access token', {
|
|
error: (error as Error).message,
|
|
});
|
|
throw new UnauthorizedError('Access token inválido o expirado');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Verifies a refresh token and returns its payload
|
|
* @param token - JWT refresh token
|
|
* @returns JwtPayload - Decoded payload
|
|
* @throws UnauthorizedError if token is invalid
|
|
*/
|
|
private verifyRefreshToken(token: string): JwtPayload {
|
|
try {
|
|
return jwt.verify(token, config.jwt.secret, {
|
|
algorithms: [this.ALGORITHM],
|
|
}) as JwtPayload;
|
|
} catch (error) {
|
|
logger.warn('Invalid refresh token', {
|
|
error: (error as Error).message,
|
|
});
|
|
throw new UnauthorizedError('Refresh token inválido o expirado');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generates a unique JWT ID (JTI) using UUID v4
|
|
* @returns string - Unique identifier
|
|
*/
|
|
private generateJti(): string {
|
|
return uuidv4();
|
|
}
|
|
|
|
/**
|
|
* Calculates expiration date from a time string
|
|
* @param expiresIn - Time string (e.g., '15m', '7d')
|
|
* @returns Date - Expiration date
|
|
*/
|
|
private calculateExpiration(expiresIn: string): Date {
|
|
const unit = expiresIn.slice(-1);
|
|
const value = parseInt(expiresIn.slice(0, -1), 10);
|
|
|
|
const now = new Date();
|
|
|
|
switch (unit) {
|
|
case 's':
|
|
return new Date(now.getTime() + value * 1000);
|
|
case 'm':
|
|
return new Date(now.getTime() + value * 60 * 1000);
|
|
case 'h':
|
|
return new Date(now.getTime() + value * 60 * 60 * 1000);
|
|
case 'd':
|
|
return new Date(now.getTime() + value * 24 * 60 * 60 * 1000);
|
|
default:
|
|
throw new Error(`Invalid time unit: ${unit}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Calculates remaining TTL in seconds for a given expiration date
|
|
* @param expiresAt - Expiration date
|
|
* @returns number - Remaining seconds (0 if already expired)
|
|
*/
|
|
private calculateRemainingTTL(expiresAt: Date): number {
|
|
const now = new Date();
|
|
const remainingMs = expiresAt.getTime() - now.getTime();
|
|
return Math.max(0, Math.floor(remainingMs / 1000));
|
|
}
|
|
}
|
|
|
|
// ===== Export Singleton Instance =====
|
|
|
|
export const tokenService = new TokenService();
|