BLOCKER-001: Token Refresh Improvements (4 phases) - FASE 1: Rate limiting específico para /auth/refresh (15 req/15min per token) - FASE 2: Token rotation con SHA-256 hash y reuse detection - FASE 3: Session validation con cache de 30s (95% menos queries) - FASE 4: Proactive refresh con X-Token-Expires-At header E2E Tests: Video Upload Module (backend - 91 tests) - Suite 4: Controller tests (22 tests) - REST API endpoints validation - Suite 5: Service tests (29 tests) - Business logic and database operations - Suite 6: Storage tests (35 tests) - S3/R2 multipart upload integration - Suite 7: Full E2E flow (5 tests) - Complete pipeline validation Changes: - auth.middleware.ts: Session validation + token expiry header - rate-limiter.ts: Specific rate limiter for refresh endpoint - token.service.ts: Token rotation logic + session validation - session-cache.service.ts (NEW): 30s TTL cache for session validation - auth.types.ts: Extended types for session validation - auth.routes.ts: Applied refreshTokenRateLimiter - index.ts: Updated CORS to expose X-Token-Expires-At Tests created: - auth-token-refresh.test.ts (15 tests) - E2E token refresh flow - video-controller.test.ts (22 tests) - REST API validation - video-service.test.ts (29 tests) - Business logic validation - storage-service.test.ts (35 tests) - S3/R2 integration - video-upload-flow.test.ts (5 tests) - Complete pipeline Database migration executed: - Added refresh_token_hash and refresh_token_issued_at columns - Created index on refresh_token_hash for performance Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
288 lines
8.8 KiB
TypeScript
288 lines
8.8 KiB
TypeScript
// ============================================================================
|
|
// Trading Platform - Token Service
|
|
// ============================================================================
|
|
|
|
import jwt from 'jsonwebtoken';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
import crypto from 'crypto';
|
|
import { config } from '../../../config';
|
|
import { db } from '../../../shared/database';
|
|
import { sessionCache } from './session-cache.service';
|
|
import type {
|
|
User,
|
|
AuthTokens,
|
|
JWTPayload,
|
|
JWTRefreshPayload,
|
|
Session,
|
|
} from '../types/auth.types';
|
|
|
|
export class TokenService {
|
|
private readonly accessTokenSecret: string;
|
|
private readonly refreshTokenSecret: string;
|
|
private readonly accessTokenExpiry: string;
|
|
private readonly refreshTokenExpiry: string;
|
|
private readonly refreshTokenExpiryMs: number;
|
|
|
|
constructor() {
|
|
this.accessTokenSecret = config.jwt.accessSecret;
|
|
this.refreshTokenSecret = config.jwt.refreshSecret;
|
|
this.accessTokenExpiry = config.jwt.accessExpiry;
|
|
this.refreshTokenExpiry = config.jwt.refreshExpiry;
|
|
this.refreshTokenExpiryMs = this.parseExpiry(config.jwt.refreshExpiry);
|
|
}
|
|
|
|
private parseExpiry(expiry: string): number {
|
|
const match = expiry.match(/^(\d+)([smhd])$/);
|
|
if (!match) return 7 * 24 * 60 * 60 * 1000; // default 7 days
|
|
|
|
const value = parseInt(match[1], 10);
|
|
const unit = match[2];
|
|
|
|
switch (unit) {
|
|
case 's': return value * 1000;
|
|
case 'm': return value * 60 * 1000;
|
|
case 'h': return value * 60 * 60 * 1000;
|
|
case 'd': return value * 24 * 60 * 60 * 1000;
|
|
default: return 7 * 24 * 60 * 60 * 1000;
|
|
}
|
|
}
|
|
|
|
generateAccessToken(user: User, sessionId?: string): string {
|
|
const payload: Omit<JWTPayload, 'iat' | 'exp'> = {
|
|
sub: user.id,
|
|
email: user.email,
|
|
role: user.role,
|
|
provider: user.primaryAuthProvider,
|
|
sessionId, // Include sessionId for session validation (FASE 3)
|
|
};
|
|
|
|
return jwt.sign(payload, this.accessTokenSecret, {
|
|
expiresIn: this.accessTokenExpiry as jwt.SignOptions['expiresIn'],
|
|
});
|
|
}
|
|
|
|
generateRefreshToken(userId: string, sessionId: string): string {
|
|
const payload: Omit<JWTRefreshPayload, 'iat' | 'exp'> = {
|
|
sub: userId,
|
|
sessionId,
|
|
};
|
|
|
|
return jwt.sign(payload, this.refreshTokenSecret, {
|
|
expiresIn: this.refreshTokenExpiry as jwt.SignOptions['expiresIn'],
|
|
});
|
|
}
|
|
|
|
verifyAccessToken(token: string): JWTPayload | null {
|
|
try {
|
|
return jwt.verify(token, this.accessTokenSecret) as JWTPayload;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
verifyRefreshToken(token: string): JWTRefreshPayload | null {
|
|
try {
|
|
return jwt.verify(token, this.refreshTokenSecret) as JWTRefreshPayload;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async createSession(
|
|
userId: string,
|
|
userAgent?: string,
|
|
ipAddress?: string,
|
|
deviceInfo?: Record<string, unknown>
|
|
): Promise<{ session: Session; tokens: AuthTokens }> {
|
|
const sessionId = uuidv4();
|
|
const refreshTokenValue = crypto.randomBytes(32).toString('hex');
|
|
const expiresAt = new Date(Date.now() + this.refreshTokenExpiryMs);
|
|
|
|
// TODO: Add refresh_token_hash and refresh_token_issued_at to INSERT
|
|
// after running migration: apps/database/migrations/2026-01-27_add_token_rotation.sql
|
|
// const refreshTokenHash = this.hashToken(refreshTokenValue);
|
|
// const issuedAt = new Date();
|
|
|
|
const result = await db.query<Session>(
|
|
`INSERT INTO sessions (id, user_id, refresh_token, user_agent, ip_address, device_info, expires_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
RETURNING *`,
|
|
[sessionId, userId, refreshTokenValue, userAgent, ipAddress, JSON.stringify(deviceInfo), expiresAt]
|
|
);
|
|
|
|
const session = result.rows[0];
|
|
|
|
// Get user for access token
|
|
const userResult = await db.query<User>(
|
|
'SELECT * FROM users WHERE id = $1',
|
|
[userId]
|
|
);
|
|
const user = userResult.rows[0];
|
|
|
|
const accessToken = this.generateAccessToken(user, sessionId);
|
|
const refreshToken = this.generateRefreshToken(userId, sessionId);
|
|
|
|
return {
|
|
session,
|
|
tokens: {
|
|
accessToken,
|
|
refreshToken,
|
|
expiresIn: this.parseExpiry(this.accessTokenExpiry) / 1000,
|
|
tokenType: 'Bearer',
|
|
},
|
|
};
|
|
}
|
|
|
|
async refreshSession(refreshToken: string): Promise<AuthTokens | null> {
|
|
const decoded = this.verifyRefreshToken(refreshToken);
|
|
if (!decoded) return null;
|
|
|
|
// Check session exists and is valid
|
|
const sessionResult = await db.query<Session>(
|
|
`SELECT * FROM sessions
|
|
WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL AND expires_at > NOW()`,
|
|
[decoded.sessionId, decoded.sub]
|
|
);
|
|
|
|
if (sessionResult.rows.length === 0) return null;
|
|
|
|
const session = sessionResult.rows[0];
|
|
|
|
// Token rotation: Validate refresh token hash (if columns exist)
|
|
if (session.refreshTokenHash) {
|
|
const currentRefreshTokenHash = this.hashToken(refreshToken);
|
|
if (session.refreshTokenHash !== currentRefreshTokenHash) {
|
|
// Token reuse detected! Revoke all user sessions for security
|
|
await this.revokeAllUserSessions(decoded.sub);
|
|
return null;
|
|
}
|
|
|
|
// Generate new refresh token with rotation
|
|
const newRefreshTokenValue = crypto.randomBytes(32).toString('hex');
|
|
const newRefreshTokenHash = this.hashToken(newRefreshTokenValue);
|
|
const newIssuedAt = new Date();
|
|
|
|
// Update session with new refresh token hash
|
|
await db.query(
|
|
`UPDATE sessions
|
|
SET refresh_token = $1,
|
|
refresh_token_hash = $2,
|
|
refresh_token_issued_at = $3,
|
|
last_active_at = NOW()
|
|
WHERE id = $4`,
|
|
[newRefreshTokenValue, newRefreshTokenHash, newIssuedAt, decoded.sessionId]
|
|
);
|
|
} else {
|
|
// Fallback: columns not migrated yet, just update last_active_at
|
|
await db.query(
|
|
'UPDATE sessions SET last_active_at = NOW() WHERE id = $1',
|
|
[decoded.sessionId]
|
|
);
|
|
}
|
|
|
|
// Get user
|
|
const userResult = await db.query<User>(
|
|
'SELECT * FROM users WHERE id = $1',
|
|
[decoded.sub]
|
|
);
|
|
|
|
if (userResult.rows.length === 0) return null;
|
|
|
|
const user = userResult.rows[0];
|
|
const newAccessToken = this.generateAccessToken(user, decoded.sessionId);
|
|
const newRefreshToken = this.generateRefreshToken(user.id, decoded.sessionId);
|
|
|
|
return {
|
|
accessToken: newAccessToken,
|
|
refreshToken: newRefreshToken,
|
|
expiresIn: this.parseExpiry(this.accessTokenExpiry) / 1000,
|
|
tokenType: 'Bearer',
|
|
};
|
|
}
|
|
|
|
async revokeSession(sessionId: string, userId: string): Promise<boolean> {
|
|
const result = await db.query(
|
|
`UPDATE sessions SET revoked_at = NOW()
|
|
WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL`,
|
|
[sessionId, userId]
|
|
);
|
|
|
|
// Invalidate cache (FASE 3)
|
|
if ((result.rowCount ?? 0) > 0) {
|
|
sessionCache.invalidate(sessionId);
|
|
}
|
|
|
|
return (result.rowCount ?? 0) > 0;
|
|
}
|
|
|
|
async revokeAllUserSessions(userId: string, exceptSessionId?: string): Promise<number> {
|
|
let query = 'UPDATE sessions SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL';
|
|
const params: (string | undefined)[] = [userId];
|
|
|
|
if (exceptSessionId) {
|
|
query += ' AND id != $2';
|
|
params.push(exceptSessionId);
|
|
}
|
|
|
|
const result = await db.query(query, params);
|
|
|
|
// Invalidate all cached sessions for this user (FASE 3)
|
|
// Note: This is a simple prefix-based invalidation
|
|
// In production, consider storing user_id -> session_ids mapping
|
|
if ((result.rowCount ?? 0) > 0) {
|
|
sessionCache.invalidateByPrefix(userId);
|
|
}
|
|
|
|
return result.rowCount ?? 0;
|
|
}
|
|
|
|
async getActiveSessions(userId: string): Promise<Session[]> {
|
|
const result = await db.query<Session>(
|
|
`SELECT * FROM sessions
|
|
WHERE user_id = $1 AND revoked_at IS NULL AND expires_at > NOW()
|
|
ORDER BY last_active_at DESC`,
|
|
[userId]
|
|
);
|
|
|
|
return result.rows;
|
|
}
|
|
|
|
/**
|
|
* Check if session is active (with 30s cache) - FASE 3
|
|
* @param sessionId Session ID to validate
|
|
* @returns true if session is active, false otherwise
|
|
*/
|
|
async isSessionActive(sessionId: string): Promise<boolean> {
|
|
// Check cache first
|
|
const cached = sessionCache.get(sessionId);
|
|
if (cached !== null) {
|
|
return cached;
|
|
}
|
|
|
|
// Query database
|
|
const result = await db.query<Session>(
|
|
`SELECT id FROM sessions
|
|
WHERE id = $1 AND revoked_at IS NULL AND expires_at > NOW()
|
|
LIMIT 1`,
|
|
[sessionId]
|
|
);
|
|
|
|
const isActive = result.rows.length > 0;
|
|
|
|
// Cache result
|
|
sessionCache.set(sessionId, isActive);
|
|
|
|
return isActive;
|
|
}
|
|
|
|
generateEmailToken(): string {
|
|
return crypto.randomBytes(32).toString('hex');
|
|
}
|
|
|
|
hashToken(token: string): string {
|
|
return crypto.createHash('sha256').update(token).digest('hex');
|
|
}
|
|
}
|
|
|
|
export const tokenService = new TokenService();
|