erp-core-backend-v2/src/modules/auth/services/token.service.ts

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();