Backend Infrastructure: - Complete Express server setup with security middleware (helmet, CORS, rate limiting) - Comprehensive error handling and logging with Winston - Authentication system with JWT tokens and session management - Database models and migrations for Users, Documents, Feedback, and Processing Jobs - API routes structure for authentication and document management - Integration tests for all server components (86 tests passing) Frontend Infrastructure: - React application with TypeScript and Vite - Authentication UI with login form, protected routes, and logout functionality - Authentication context with proper async state management - Component tests with proper async handling (25 tests passing) - Tailwind CSS styling and responsive design Key Features: - User registration, login, and authentication - Protected routes with role-based access control - Comprehensive error handling and user feedback - Database schema with proper relationships - Security middleware and validation - Production-ready build configuration Test Coverage: 111/111 tests passing Tasks Completed: 1-5 (Project setup, Database, Auth system, Frontend UI, Backend infrastructure) Ready for Task 6: File upload backend infrastructure
313 lines
7.8 KiB
TypeScript
313 lines
7.8 KiB
TypeScript
import Redis from 'redis';
|
|
import { config } from '../config/env';
|
|
import logger from '../utils/logger';
|
|
|
|
export interface SessionData {
|
|
userId: string;
|
|
email: string;
|
|
role: string;
|
|
refreshToken: string;
|
|
lastActivity: number;
|
|
}
|
|
|
|
class SessionService {
|
|
private client: Redis.RedisClientType;
|
|
private isConnected: boolean = false;
|
|
|
|
constructor() {
|
|
this.client = Redis.createClient({
|
|
url: config.redis.url,
|
|
socket: {
|
|
host: config.redis.host,
|
|
port: config.redis.port,
|
|
reconnectStrategy: (retries) => {
|
|
if (retries > 10) {
|
|
logger.error('Redis connection failed after 10 retries');
|
|
return new Error('Redis connection failed');
|
|
}
|
|
return Math.min(retries * 100, 3000);
|
|
}
|
|
}
|
|
});
|
|
|
|
this.setupEventHandlers();
|
|
}
|
|
|
|
private setupEventHandlers(): void {
|
|
this.client.on('connect', () => {
|
|
logger.info('Connected to Redis');
|
|
this.isConnected = true;
|
|
});
|
|
|
|
this.client.on('ready', () => {
|
|
logger.info('Redis client ready');
|
|
});
|
|
|
|
this.client.on('error', (error) => {
|
|
logger.error('Redis client error:', error);
|
|
this.isConnected = false;
|
|
});
|
|
|
|
this.client.on('end', () => {
|
|
logger.info('Redis connection ended');
|
|
this.isConnected = false;
|
|
});
|
|
|
|
this.client.on('reconnecting', () => {
|
|
logger.info('Reconnecting to Redis...');
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Connect to Redis
|
|
*/
|
|
async connect(): Promise<void> {
|
|
if (this.isConnected) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await this.client.connect();
|
|
logger.info('Successfully connected to Redis');
|
|
} catch (error) {
|
|
logger.error('Failed to connect to Redis:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Disconnect from Redis
|
|
*/
|
|
async disconnect(): Promise<void> {
|
|
if (!this.isConnected) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await this.client.quit();
|
|
logger.info('Disconnected from Redis');
|
|
} catch (error) {
|
|
logger.error('Error disconnecting from Redis:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Store user session
|
|
*/
|
|
async storeSession(userId: string, sessionData: Omit<SessionData, 'lastActivity'>): Promise<void> {
|
|
try {
|
|
await this.connect();
|
|
|
|
const session: SessionData = {
|
|
...sessionData,
|
|
lastActivity: Date.now()
|
|
};
|
|
|
|
const key = `session:${userId}`;
|
|
const sessionTTL = parseInt(config.jwt.refreshExpiresIn.replace(/[^0-9]/g, '')) *
|
|
(config.jwt.refreshExpiresIn.includes('h') ? 3600 :
|
|
config.jwt.refreshExpiresIn.includes('d') ? 86400 : 60);
|
|
|
|
await this.client.setEx(key, sessionTTL, JSON.stringify(session));
|
|
logger.info(`Stored session for user: ${userId}`);
|
|
} catch (error) {
|
|
logger.error('Error storing session:', error);
|
|
throw new Error('Failed to store session');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get user session
|
|
*/
|
|
async getSession(userId: string): Promise<SessionData | null> {
|
|
try {
|
|
await this.connect();
|
|
|
|
const key = `session:${userId}`;
|
|
const sessionData = await this.client.get(key);
|
|
|
|
if (!sessionData) {
|
|
return null;
|
|
}
|
|
|
|
const session: SessionData = JSON.parse(sessionData);
|
|
|
|
// Update last activity
|
|
session.lastActivity = Date.now();
|
|
await this.updateSessionActivity(userId, session.lastActivity);
|
|
|
|
logger.info(`Retrieved session for user: ${userId}`);
|
|
return session;
|
|
} catch (error) {
|
|
logger.error('Error getting session:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update session activity timestamp
|
|
*/
|
|
async updateSessionActivity(userId: string, lastActivity: number): Promise<void> {
|
|
try {
|
|
await this.connect();
|
|
|
|
const key = `session:${userId}`;
|
|
const sessionData = await this.client.get(key);
|
|
|
|
if (sessionData) {
|
|
const session: SessionData = JSON.parse(sessionData);
|
|
session.lastActivity = lastActivity;
|
|
|
|
const sessionTTL = parseInt(config.jwt.refreshExpiresIn.replace(/[^0-9]/g, '')) *
|
|
(config.jwt.refreshExpiresIn.includes('h') ? 3600 :
|
|
config.jwt.refreshExpiresIn.includes('d') ? 86400 : 60);
|
|
|
|
await this.client.setEx(key, sessionTTL, JSON.stringify(session));
|
|
}
|
|
} catch (error) {
|
|
logger.error('Error updating session activity:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove user session
|
|
*/
|
|
async removeSession(userId: string): Promise<void> {
|
|
try {
|
|
await this.connect();
|
|
|
|
const key = `session:${userId}`;
|
|
await this.client.del(key);
|
|
|
|
logger.info(`Removed session for user: ${userId}`);
|
|
} catch (error) {
|
|
logger.error('Error removing session:', error);
|
|
throw new Error('Failed to remove session');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if session exists
|
|
*/
|
|
async sessionExists(userId: string): Promise<boolean> {
|
|
try {
|
|
await this.connect();
|
|
|
|
const key = `session:${userId}`;
|
|
const exists = await this.client.exists(key);
|
|
|
|
return exists === 1;
|
|
} catch (error) {
|
|
logger.error('Error checking session existence:', error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Store refresh token for blacklisting
|
|
*/
|
|
async blacklistToken(token: string, expiresIn: number): Promise<void> {
|
|
try {
|
|
await this.connect();
|
|
|
|
const key = `blacklist:${token}`;
|
|
await this.client.setEx(key, expiresIn, '1');
|
|
|
|
logger.info('Token blacklisted successfully');
|
|
} catch (error) {
|
|
logger.error('Error blacklisting token:', error);
|
|
throw new Error('Failed to blacklist token');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if token is blacklisted
|
|
*/
|
|
async isTokenBlacklisted(token: string): Promise<boolean> {
|
|
try {
|
|
await this.connect();
|
|
|
|
const key = `blacklist:${token}`;
|
|
const exists = await this.client.exists(key);
|
|
|
|
return exists === 1;
|
|
} catch (error) {
|
|
logger.error('Error checking token blacklist:', error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get all active sessions (for admin)
|
|
*/
|
|
async getAllSessions(): Promise<{ userId: string; session: SessionData }[]> {
|
|
try {
|
|
await this.connect();
|
|
|
|
const keys = await this.client.keys('session:*');
|
|
const sessions: { userId: string; session: SessionData }[] = [];
|
|
|
|
for (const key of keys) {
|
|
const userId = key.replace('session:', '');
|
|
const sessionData = await this.client.get(key);
|
|
|
|
if (sessionData) {
|
|
sessions.push({
|
|
userId,
|
|
session: JSON.parse(sessionData)
|
|
});
|
|
}
|
|
}
|
|
|
|
return sessions;
|
|
} catch (error) {
|
|
logger.error('Error getting all sessions:', error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clean up expired sessions
|
|
*/
|
|
async cleanupExpiredSessions(): Promise<number> {
|
|
try {
|
|
await this.connect();
|
|
|
|
const keys = await this.client.keys('session:*');
|
|
let cleanedCount = 0;
|
|
|
|
for (const key of keys) {
|
|
const sessionData = await this.client.get(key);
|
|
|
|
if (sessionData) {
|
|
const session: SessionData = JSON.parse(sessionData);
|
|
const now = Date.now();
|
|
const sessionTTL = parseInt(config.jwt.refreshExpiresIn.replace(/[^0-9]/g, '')) *
|
|
(config.jwt.refreshExpiresIn.includes('h') ? 3600 :
|
|
config.jwt.refreshExpiresIn.includes('d') ? 86400 : 60) * 1000;
|
|
|
|
if (now - session.lastActivity > sessionTTL) {
|
|
await this.client.del(key);
|
|
cleanedCount++;
|
|
}
|
|
}
|
|
}
|
|
|
|
logger.info(`Cleaned up ${cleanedCount} expired sessions`);
|
|
return cleanedCount;
|
|
} catch (error) {
|
|
logger.error('Error cleaning up expired sessions:', error);
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get Redis connection status
|
|
*/
|
|
getConnectionStatus(): boolean {
|
|
return this.isConnected;
|
|
}
|
|
}
|
|
|
|
// Export singleton instance
|
|
export const sessionService = new SessionService();
|