final changes
This commit is contained in:
@@ -21,10 +21,6 @@ export class UserMapper {
|
||||
fname: user.fname,
|
||||
lname: user.lname,
|
||||
code: user.token,
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
type: user.type,
|
||||
>>>>>>> origin/main
|
||||
phone: user.phone,
|
||||
state: user.state,
|
||||
};
|
||||
|
||||
@@ -24,10 +24,6 @@ export interface DetailUserDto {
|
||||
fname: string;
|
||||
lname: string;
|
||||
code: string | null;
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
type: string;
|
||||
>>>>>>> origin/main
|
||||
phone: string | null;
|
||||
state: number;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
export interface UpdateDeckCommand {
|
||||
id: string;
|
||||
<<<<<<< HEAD
|
||||
userstate?: number;
|
||||
=======
|
||||
>>>>>>> origin/main
|
||||
name?: string;
|
||||
type?: number;
|
||||
userid?: string;
|
||||
|
||||
@@ -2,17 +2,13 @@ import { IDeckRepository } from '../../../Domain/IRepository/IDeckRepository';
|
||||
import { UpdateDeckCommand } from './UpdateDeckCommand';
|
||||
import { ShortDeckDto } from '../../DTOs/DeckDto';
|
||||
import { DeckMapper } from '../../DTOs/Mappers/DeckMapper';
|
||||
<<<<<<< HEAD
|
||||
import { DeckAggregate } from '../../../Domain/Deck/DeckAggregate';
|
||||
import { logError } from '../../Services/Logger';
|
||||
=======
|
||||
>>>>>>> origin/main
|
||||
|
||||
export class UpdateDeckCommandHandler {
|
||||
constructor(private readonly deckRepo: IDeckRepository) {}
|
||||
|
||||
async execute(cmd: UpdateDeckCommand): Promise<ShortDeckDto | null> {
|
||||
<<<<<<< HEAD
|
||||
if(cmd.state !== undefined && cmd.userstate!==1) {
|
||||
throw new Error('Only admin users can change deck state');
|
||||
}
|
||||
@@ -50,10 +46,5 @@ export class UpdateDeckCommandHandler {
|
||||
logError(`Error updating deck: ${cmd.id}`, error);
|
||||
throw error;
|
||||
}
|
||||
=======
|
||||
const updated = await this.deckRepo.update(cmd.id, { ...cmd });
|
||||
if (!updated) return null;
|
||||
return DeckMapper.toShortDto(updated);
|
||||
>>>>>>> origin/main
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,14 @@
|
||||
import { IDeckRepository } from '../../../Domain/IRepository/IDeckRepository';
|
||||
import { GetDeckByIdQuery } from './GetDeckByIdQuery';
|
||||
<<<<<<< HEAD
|
||||
import { DetailDeckDto } from '../../DTOs/DeckDto';
|
||||
=======
|
||||
import { ShortDeckDto } from '../../DTOs/DeckDto';
|
||||
>>>>>>> origin/main
|
||||
import { DeckMapper } from '../../DTOs/Mappers/DeckMapper';
|
||||
|
||||
export class GetDeckByIdQueryHandler {
|
||||
constructor(private readonly deckRepo: IDeckRepository) {}
|
||||
|
||||
<<<<<<< HEAD
|
||||
async execute(query: GetDeckByIdQuery): Promise<DetailDeckDto | null> {
|
||||
const deck = await this.deckRepo.findById(query.id);
|
||||
if (!deck) return null;
|
||||
return DeckMapper.toDetailDto(deck);
|
||||
=======
|
||||
async execute(query: GetDeckByIdQuery): Promise<ShortDeckDto | null> {
|
||||
const deck = await this.deckRepo.findById(query.id);
|
||||
if (!deck) return null;
|
||||
return DeckMapper.toShortDto(deck);
|
||||
>>>>>>> origin/main
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,6 +151,15 @@ export class JoinGameCommandHandler {
|
||||
isOnline: true
|
||||
};
|
||||
|
||||
// Check if player name is already in use by a different player
|
||||
const existingPlayerWithName = gameData.currentPlayers.find(
|
||||
p => p.playerName === command.playerName && p.playerId !== command.playerId
|
||||
);
|
||||
|
||||
if (existingPlayerWithName) {
|
||||
throw new Error(`Player name "${command.playerName}" is already in use in this game`);
|
||||
}
|
||||
|
||||
// Update players list (remove if exists, then add)
|
||||
gameData.currentPlayers = gameData.currentPlayers.filter(p => p.playerId !== command.playerId);
|
||||
gameData.currentPlayers.push(newPlayer);
|
||||
@@ -161,9 +170,6 @@ export class JoinGameCommandHandler {
|
||||
// Store updated data in Redis with TTL (24 hours)
|
||||
await this.redisService.setWithExpiry(redisKey, JSON.stringify(gameData), 24 * 60 * 60);
|
||||
|
||||
// Add player to active players set
|
||||
await this.redisService.setAdd(`active_players:${game.id}`, command.playerId);
|
||||
|
||||
logOther('Game data updated in Redis', {
|
||||
gameId: game.id,
|
||||
gameCode: game.gamecode,
|
||||
@@ -204,7 +210,6 @@ export class JoinGameCommandHandler {
|
||||
gameData.currentPlayers = gameData.currentPlayers.filter(p => p.playerId !== playerId);
|
||||
|
||||
await this.redisService.setWithExpiry(redisKey, JSON.stringify(gameData), 24 * 60 * 60);
|
||||
await this.redisService.setRemove(`active_players:${gameId}`, playerId);
|
||||
}
|
||||
} catch (error) {
|
||||
logError('Failed to remove player from Redis', error instanceof Error ? error : new Error(String(error)));
|
||||
|
||||
@@ -64,7 +64,7 @@ export class StartGameCommandHandler {
|
||||
gamecode,
|
||||
maxplayers: command.maxplayers,
|
||||
logintype: command.logintype,
|
||||
createdby: command.userid || null,
|
||||
createdby: command.userid!,
|
||||
orgid: command.orgid || null,
|
||||
gamedecks,
|
||||
players: [],
|
||||
|
||||
@@ -49,7 +49,6 @@ export class GeneralSearchService implements IGeneralSearchService {
|
||||
};
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
// Ensure limit is at least 1 to prevent database issues
|
||||
const effectiveLimit = Math.max(limit || 20, 1);
|
||||
const effectiveOffset = Math.max(offset || 0, 0);
|
||||
@@ -58,12 +57,6 @@ export class GeneralSearchService implements IGeneralSearchService {
|
||||
const { users, totalCount } = await this.userRepo.search(query.trim(), effectiveLimit, effectiveOffset);
|
||||
const results = users.map(user => UserMapper.toShortDto(user));
|
||||
const hasMore = (effectiveOffset + effectiveLimit) < totalCount;
|
||||
=======
|
||||
try {
|
||||
const { users, totalCount } = await this.userRepo.search(query.trim(), limit, offset);
|
||||
const results = users.map(user => UserMapper.toShortDto(user));
|
||||
const hasMore = (offset + limit) < totalCount;
|
||||
>>>>>>> origin/main
|
||||
|
||||
return {
|
||||
results,
|
||||
@@ -116,7 +109,6 @@ export class GeneralSearchService implements IGeneralSearchService {
|
||||
};
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
// Ensure limit is at least 1 to prevent database issues
|
||||
const effectiveLimit = Math.max(limit || 20, 1);
|
||||
const effectiveOffset = Math.max(offset || 0, 0);
|
||||
@@ -136,19 +128,6 @@ export class GeneralSearchService implements IGeneralSearchService {
|
||||
} catch (error) {
|
||||
throw new Error('Failed to search decks');
|
||||
}
|
||||
=======
|
||||
const { decks, totalCount } = await this.deckRepo.search(query.trim(), limit, offset);
|
||||
const results = decks.map(deck => DeckMapper.toShortDto(deck));
|
||||
const hasMore = (offset + limit) < totalCount;
|
||||
|
||||
return {
|
||||
results,
|
||||
totalCount,
|
||||
hasMore,
|
||||
searchQuery: query,
|
||||
searchType: 'decks'
|
||||
};
|
||||
>>>>>>> origin/main
|
||||
}
|
||||
|
||||
async searchByType(
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { JWTService } from './JWTService';
|
||||
<<<<<<< HEAD
|
||||
import { RedisService } from './RedisService';
|
||||
import { logAuth, logWarning } from './Logger';
|
||||
|
||||
@@ -80,7 +79,7 @@ export async function authRequired(req: Request, res: Response, next: NextFuncti
|
||||
orgId: payload.orgId
|
||||
}, req);
|
||||
|
||||
const refreshed = jwtService.refreshIfNeeded(payload, res);
|
||||
const refreshed = jwtService.refreshIfNeeded(payload, res, req);
|
||||
if (refreshed) {
|
||||
logAuth('Token refreshed', payload.userId, undefined, req);
|
||||
}
|
||||
@@ -133,7 +132,7 @@ export async function adminRequired(req: Request, res: Response, next: NextFunct
|
||||
orgId: payload.orgId
|
||||
}, req);
|
||||
|
||||
const refreshed = jwtService.refreshIfNeeded(payload, res);
|
||||
const refreshed = jwtService.refreshIfNeeded(payload, res, req);
|
||||
if (refreshed) {
|
||||
logAuth('Admin token refreshed', payload.userId, undefined, req);
|
||||
}
|
||||
@@ -144,60 +143,4 @@ export async function adminRequired(req: Request, res: Response, next: NextFunct
|
||||
logWarning('Admin authentication middleware error', { error: (error as Error).message }, req);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
=======
|
||||
import { logAuth, logWarning } from './Logger';
|
||||
|
||||
export const jwtService = new JWTService();
|
||||
|
||||
export function authRequired(req: Request, res: Response, next: NextFunction) {
|
||||
const payload = jwtService.verify(req);
|
||||
if (!payload) {
|
||||
logAuth('Authentication failed - No valid token', undefined, {
|
||||
ip: req.ip,
|
||||
userAgent: req.get ? req.get('User-Agent') : 'unknown',
|
||||
path: req.path
|
||||
}, req);
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
logAuth('Authentication successful', payload.userId, {
|
||||
authLevel: payload.authLevel,
|
||||
orgId: payload.orgId
|
||||
}, req);
|
||||
|
||||
const refreshed = jwtService.refreshIfNeeded(payload, res);
|
||||
if (refreshed) {
|
||||
logAuth('Token refreshed', payload.userId, undefined, req);
|
||||
}
|
||||
|
||||
(req as any).user = payload;
|
||||
next();
|
||||
}
|
||||
|
||||
export function adminRequired(req: Request, res: Response, next: NextFunction) {
|
||||
const payload = jwtService.verify(req);
|
||||
if (!payload || payload.authLevel !== 1) {
|
||||
logWarning('Admin access denied', {
|
||||
hasPayload: !!payload,
|
||||
authLevel: payload?.authLevel,
|
||||
userId: payload?.userId,
|
||||
ip: req.ip,
|
||||
path: req.path
|
||||
}, req);
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
|
||||
logAuth('Admin authentication successful', payload.userId, {
|
||||
authLevel: payload.authLevel,
|
||||
orgId: payload.orgId
|
||||
}, req);
|
||||
|
||||
const refreshed = jwtService.refreshIfNeeded(payload, res);
|
||||
if (refreshed) {
|
||||
logAuth('Admin token refreshed', payload.userId, undefined, req);
|
||||
}
|
||||
|
||||
(req as any).user = payload;
|
||||
next();
|
||||
>>>>>>> origin/main
|
||||
}
|
||||
@@ -25,7 +25,7 @@ export class EmailTemplateHelper {
|
||||
}
|
||||
|
||||
public static replaceTemplatePlaceholders(template: string, data: TemplateData): string {
|
||||
return template.replace(/\{\{(\w+)\}\}/g, (match, key) => {
|
||||
return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (match, key) => {
|
||||
return data[key] !== undefined ? String(data[key]) : match;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,8 +13,11 @@ import {
|
||||
GameActionData,
|
||||
PlayerPosition,
|
||||
GameStateUpdateData,
|
||||
FieldEffectRequest
|
||||
FieldEffectRequest,
|
||||
JoinGameData,
|
||||
LeaveGameData
|
||||
} from './Interfaces/GameInterfaces';
|
||||
import { json } from 'stream/consumers';
|
||||
|
||||
interface AuthenticatedSocket extends Socket {
|
||||
userId?: string;
|
||||
@@ -23,14 +26,6 @@ interface AuthenticatedSocket extends Socket {
|
||||
isAuthenticated?: boolean;
|
||||
}
|
||||
|
||||
interface JoinGameData {
|
||||
gameToken: string; // Required game session token
|
||||
}
|
||||
|
||||
interface LeaveGameData {
|
||||
gameCode: string;
|
||||
}
|
||||
|
||||
interface DiceRollData {
|
||||
gameCode: string;
|
||||
diceValue: number; // Value from frontend (1-6)
|
||||
@@ -91,7 +86,7 @@ export class GameWebSocketService {
|
||||
|
||||
private setupGameEventHandlers(socket: AuthenticatedSocket): void {
|
||||
// Join game room
|
||||
socket.on('game:join', async (data: JoinGameData) => {
|
||||
socket.on('game:join', async (data: any) => {
|
||||
await this.handleJoinGame(socket, data);
|
||||
});
|
||||
|
||||
@@ -141,11 +136,14 @@ export class GameWebSocketService {
|
||||
});
|
||||
}
|
||||
|
||||
private async handleJoinGame(socket: AuthenticatedSocket, data: JoinGameData): Promise<void> {
|
||||
private async handleJoinGame(socket: AuthenticatedSocket, data: any): Promise<void> {
|
||||
try {
|
||||
const { gameToken } = data;
|
||||
// Simple data extraction - let Socket.IO handle the parsing
|
||||
const jsdata = JSON.parse(data);
|
||||
const gameToken = jsdata?.gameToken;
|
||||
|
||||
if (!gameToken) {
|
||||
logError('Game join failed: No game token provided');
|
||||
socket.emit('game:error', { message: 'Game token is required' });
|
||||
return;
|
||||
}
|
||||
@@ -153,6 +151,7 @@ export class GameWebSocketService {
|
||||
// Verify the game token
|
||||
const gameTokenPayload = this.gameTokenService.verifyGameToken(gameToken);
|
||||
if (!gameTokenPayload) {
|
||||
logError('Game join failed: Invalid game token');
|
||||
socket.emit('game:error', { message: 'Invalid or expired game token' });
|
||||
return;
|
||||
}
|
||||
@@ -162,10 +161,19 @@ export class GameWebSocketService {
|
||||
// Validate game still exists
|
||||
const game = await this.gameRepository.findByGameCode(gameCode);
|
||||
if (!game || game.id !== gameId) {
|
||||
logError(`Game join failed: Game not found - Code: ${gameCode}`);
|
||||
socket.emit('game:error', { message: 'Game not found or token invalid' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if player name is already in use by checking connected players
|
||||
const connectedPlayers = await this.getConnectedPlayers(gameCode);
|
||||
if (connectedPlayers.includes(playerName)) {
|
||||
logOther(`Game join failed: Player name "${playerName}" already in use in game ${gameCode}`);
|
||||
socket.emit('game:error', { message: `Player name "${playerName}" is already in use in this game` });
|
||||
return;
|
||||
}
|
||||
|
||||
// Set socket properties from game token
|
||||
socket.gameCode = gameCode;
|
||||
socket.playerName = playerName;
|
||||
@@ -185,8 +193,6 @@ export class GameWebSocketService {
|
||||
// Add to pending players list and notify gamemaster
|
||||
await this.addToPendingPlayers(gameCode, playerName);
|
||||
|
||||
logOther(`Player ${playerName} requesting approval to join private game: ${gameRoomName}`);
|
||||
|
||||
// Send pending status to the requesting player
|
||||
socket.emit('game:pending-approval', {
|
||||
gameCode,
|
||||
@@ -210,7 +216,6 @@ export class GameWebSocketService {
|
||||
await socket.join(gameRoomName);
|
||||
await socket.join(playerRoomName);
|
||||
|
||||
logOther(`Player ${playerName} joined game room: ${gameRoomName} (${isAuthenticated ? 'authenticated' : 'public'}) ${isGamemaster ? '[GAMEMASTER]' : ''}`);
|
||||
|
||||
// Send success response to the joining player
|
||||
socket.emit('game:joined', {
|
||||
@@ -222,6 +227,7 @@ export class GameWebSocketService {
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
|
||||
// Notify other players in the game (broadcast)
|
||||
socket.to(gameRoomName).emit('game:player-joined', {
|
||||
playerName: playerName,
|
||||
@@ -230,40 +236,54 @@ export class GameWebSocketService {
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
|
||||
// Send current game state to the joining player
|
||||
const gameState = await this.getGameState(gameCode);
|
||||
socket.emit('game:state', gameState);
|
||||
|
||||
|
||||
// Update Redis with active player connection
|
||||
await this.updatePlayerConnection(gameCode, playerName, true);
|
||||
|
||||
} catch (error) {
|
||||
logError('Error joining game', error as Error);
|
||||
socket.emit('game:error', { message: 'Failed to join game' });
|
||||
socket.emit('game:error', {
|
||||
message: 'Failed to join game',
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async handleLeaveGame(socket: AuthenticatedSocket, data: LeaveGameData): Promise<void> {
|
||||
try {
|
||||
const { gameCode } = data;
|
||||
const { gameCode } = JSON.parse(data as any);
|
||||
const playerName = socket.playerName;
|
||||
|
||||
// Validate we have the required data
|
||||
if (!playerName) {
|
||||
logError('Cannot leave game: socket has no playerName');
|
||||
socket.emit('game:error', { message: 'Player has no name' });
|
||||
return;
|
||||
}
|
||||
|
||||
const gameRoomName = `game_${gameCode}`;
|
||||
const playerRoomName = `game_${gameCode}:${socket.playerName}`;
|
||||
const playerRoomName = `game_${gameCode}:${playerName}`;
|
||||
|
||||
// Leave both rooms
|
||||
await socket.leave(gameRoomName);
|
||||
await socket.leave(playerRoomName);
|
||||
|
||||
logOther(`Player ${socket.playerName} left game room: ${gameRoomName}`);
|
||||
logOther(`Player ${playerName} left game room: ${gameRoomName}`);
|
||||
|
||||
// Notify other players
|
||||
socket.to(gameRoomName).emit('game:player-left', {
|
||||
playerName: socket.playerName,
|
||||
playerName: playerName,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// Update Redis
|
||||
await this.updatePlayerConnection(gameCode, socket.playerName!, false);
|
||||
// Update Redis before clearing socket properties
|
||||
await this.updatePlayerConnection(gameCode, playerName, false);
|
||||
|
||||
// Clear socket properties
|
||||
socket.gameCode = undefined;
|
||||
socket.playerName = undefined;
|
||||
|
||||
@@ -275,7 +295,7 @@ export class GameWebSocketService {
|
||||
|
||||
private async handleGameAction(socket: AuthenticatedSocket, data: GameActionData): Promise<void> {
|
||||
try {
|
||||
const { gameCode, action, data: actionData } = data;
|
||||
const { gameCode, action, data: actionData } = JSON.parse(data as any);
|
||||
|
||||
if (!socket.gameCode || socket.gameCode !== gameCode) {
|
||||
socket.emit('game:error', { message: 'You must be in the game to perform actions' });
|
||||
@@ -317,7 +337,7 @@ export class GameWebSocketService {
|
||||
|
||||
private async handleGameChat(socket: AuthenticatedSocket, data: GameChatData): Promise<void> {
|
||||
try {
|
||||
const { gameCode, message } = data;
|
||||
const { gameCode, message } = JSON.parse(data as any);
|
||||
|
||||
if (!socket.gameCode || socket.gameCode !== gameCode) {
|
||||
socket.emit('game:error', { message: 'You must be in the game to chat' });
|
||||
@@ -343,7 +363,7 @@ export class GameWebSocketService {
|
||||
|
||||
private async handlePlayerReady(socket: AuthenticatedSocket, data: { gameCode: string; ready: boolean }): Promise<void> {
|
||||
try {
|
||||
const { gameCode, ready } = data;
|
||||
const { gameCode, ready } = JSON.parse(data as any);
|
||||
const gameRoomName = `game_${gameCode}`;
|
||||
|
||||
// Update player ready status in Redis
|
||||
@@ -373,7 +393,7 @@ export class GameWebSocketService {
|
||||
|
||||
private async handleApprovePlayer(socket: AuthenticatedSocket, data: { gameCode: string; playerName: string }): Promise<void> {
|
||||
try {
|
||||
const { gameCode, playerName } = data;
|
||||
const { gameCode, playerName } = JSON.parse(data as any);
|
||||
|
||||
// Verify that the requesting socket is the gamemaster
|
||||
const game = await this.gameRepository.findByGameCode(gameCode);
|
||||
@@ -434,7 +454,7 @@ export class GameWebSocketService {
|
||||
|
||||
private async handleRejectPlayer(socket: AuthenticatedSocket, data: { gameCode: string; playerName: string; reason?: string }): Promise<void> {
|
||||
try {
|
||||
const { gameCode, playerName, reason } = data;
|
||||
const { gameCode, playerName, reason } = JSON.parse(data as any);
|
||||
|
||||
// Verify that the requesting socket is the gamemaster
|
||||
const game = await this.gameRepository.findByGameCode(gameCode);
|
||||
@@ -482,7 +502,7 @@ export class GameWebSocketService {
|
||||
|
||||
private async handleJoinApproved(socket: AuthenticatedSocket, data: JoinGameData): Promise<void> {
|
||||
try {
|
||||
const { gameToken } = data;
|
||||
const { gameToken } = JSON.parse(data as any);
|
||||
|
||||
if (!gameToken) {
|
||||
socket.emit('game:error', { message: 'Game token is required' });
|
||||
@@ -560,7 +580,7 @@ export class GameWebSocketService {
|
||||
|
||||
private async handleDiceRoll(socket: AuthenticatedSocket, data: DiceRollData): Promise<void> {
|
||||
try {
|
||||
const { gameCode, diceValue } = data;
|
||||
const { gameCode, diceValue } = JSON.parse(data as any);
|
||||
|
||||
// Validate input
|
||||
if (!gameCode || !socket.gameCode || socket.gameCode !== gameCode) {
|
||||
@@ -772,14 +792,6 @@ export class GameWebSocketService {
|
||||
// Remove from pending players if they were pending
|
||||
await this.redisService.setRemove(`game_pending:${gameCode}`, playerName);
|
||||
|
||||
// If we have player ID, also clean up ID-based tracking
|
||||
if (playerId) {
|
||||
const game = await this.gameRepository.findByGameCode(gameCode);
|
||||
if (game?.id) {
|
||||
await this.redisService.setRemove(`active_players:${game.id}`, playerId);
|
||||
}
|
||||
}
|
||||
|
||||
logOther(`Cleaned up player data for ${playerName} in game ${gameCode}`);
|
||||
|
||||
} catch (error) {
|
||||
@@ -1276,7 +1288,6 @@ export class GameWebSocketService {
|
||||
if (gameId) {
|
||||
const gameIdKeys = [
|
||||
`game:${gameId}`, // Main game data
|
||||
`active_players:${gameId}`, // Active players set
|
||||
`game_turns:${gameId}` // Turn data by ID
|
||||
];
|
||||
|
||||
|
||||
@@ -7,60 +7,204 @@ export interface TokenPayload {
|
||||
authLevel: 0 | 1;
|
||||
userStatus: UserState;
|
||||
orgId: string;
|
||||
type?: 'access';
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
}
|
||||
|
||||
export interface RefreshTokenPayload {
|
||||
userId: string;
|
||||
type: 'refresh';
|
||||
orgId?: string;
|
||||
tokenId?: string; // For token rotation/revocation
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
}
|
||||
|
||||
export interface TokenPair {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
export class JWTService {
|
||||
private readonly secretKey: string;
|
||||
private readonly refreshSecretKey: string;
|
||||
private readonly tokenExpiry: number;
|
||||
private readonly refreshTokenExpiry: number;
|
||||
private readonly cookieName: string;
|
||||
private readonly refreshCookieName: string;
|
||||
|
||||
constructor() {
|
||||
this.secretKey = process.env.JWT_SECRET || 'your-secret-key';
|
||||
this.refreshSecretKey = process.env.JWT_REFRESH_SECRET || this.secretKey + '_refresh';
|
||||
|
||||
let expiry = 86400;
|
||||
|
||||
// Access token expiry (short-lived)
|
||||
let expiry = 1800; // Default 30 minutes for better security
|
||||
if (process.env.JWT_EXPIRY) {
|
||||
expiry = parseInt(process.env.JWT_EXPIRY);
|
||||
} else if (process.env.JWT_EXPIRATION) {
|
||||
expiry = this.parseDuration(process.env.JWT_EXPIRATION);
|
||||
}
|
||||
|
||||
// Refresh token expiry (long-lived)
|
||||
let refreshExpiry = 604800; // Default 7 days
|
||||
if (process.env.JWT_REFRESH_EXPIRATION) {
|
||||
refreshExpiry = this.parseDuration(process.env.JWT_REFRESH_EXPIRATION);
|
||||
}
|
||||
|
||||
this.tokenExpiry = expiry;
|
||||
this.refreshTokenExpiry = refreshExpiry;
|
||||
this.cookieName = 'auth_token';
|
||||
this.refreshCookieName = 'refresh_token';
|
||||
|
||||
if (process.env.NODE_ENV === 'production' && (!process.env.JWT_SECRET || process.env.JWT_SECRET === 'your-secret-key')) {
|
||||
throw new Error('JWT_SECRET environment variable must be set in production');
|
||||
}
|
||||
}
|
||||
|
||||
create(payload: TokenPayload, res: Response): string {
|
||||
/**
|
||||
* Create a pair of access and refresh tokens
|
||||
*/
|
||||
public createTokenPair(payload: Omit<TokenPayload, 'type' | 'iat' | 'exp'>): TokenPair {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const payloadWithTimestamps: TokenPayload = {
|
||||
// Create access token
|
||||
const accessTokenPayload: TokenPayload = {
|
||||
...payload,
|
||||
type: 'access',
|
||||
iat: now,
|
||||
exp: now + this.tokenExpiry
|
||||
};
|
||||
const accessToken = jwt.sign(accessTokenPayload, this.secretKey);
|
||||
|
||||
// Don't use expiresIn option since we're manually setting exp in payload
|
||||
const options: SignOptions = {};
|
||||
const token = jwt.sign(payloadWithTimestamps, this.secretKey, options);
|
||||
// Create refresh token
|
||||
const refreshTokenPayload: RefreshTokenPayload = {
|
||||
userId: payload.userId,
|
||||
type: 'refresh',
|
||||
orgId: payload.orgId,
|
||||
iat: now,
|
||||
exp: now + this.refreshTokenExpiry
|
||||
};
|
||||
const refreshToken = jwt.sign(refreshTokenPayload, this.refreshSecretKey);
|
||||
|
||||
res.cookie(this.cookieName, token, {
|
||||
return { accessToken, refreshToken };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create access and refresh tokens and set cookies (for cookie-based auth)
|
||||
*/
|
||||
create(payload: Omit<TokenPayload, 'type' | 'iat' | 'exp'>, res: Response): TokenPair {
|
||||
const tokenPair = this.createTokenPair(payload);
|
||||
this.setTokenCookies(res, tokenPair);
|
||||
return tokenPair;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the request is using Bearer token authentication
|
||||
*/
|
||||
private isUsingBearerAuth(req: Request): boolean {
|
||||
// No cookie but has Authorization header
|
||||
return !req.cookies?.[this.cookieName] &&
|
||||
!!req.headers.authorization &&
|
||||
req.headers.authorization.startsWith('Bearer ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a refresh token
|
||||
*/
|
||||
public verifyRefreshToken(token: string): RefreshTokenPayload | null {
|
||||
try {
|
||||
const decoded = jwt.verify(token, this.refreshSecretKey) as RefreshTokenPayload;
|
||||
if (decoded.type !== 'refresh') {
|
||||
return null;
|
||||
}
|
||||
return decoded;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to refresh tokens using refresh token from cookies or headers
|
||||
*/
|
||||
public attemptTokenRefresh(req: Request, res: Response): TokenPair | null {
|
||||
try {
|
||||
// Try to get refresh token from cookie first
|
||||
let refreshToken = req.cookies[this.refreshCookieName];
|
||||
|
||||
// If no cookie, try X-Refresh-Token header
|
||||
if (!refreshToken) {
|
||||
refreshToken = req.headers['x-refresh-token'] as string;
|
||||
}
|
||||
|
||||
if (!refreshToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const refreshPayload = this.verifyRefreshToken(refreshToken);
|
||||
if (!refreshPayload) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create new token pair
|
||||
const newTokenPair = this.createTokenPair({
|
||||
userId: refreshPayload.userId,
|
||||
authLevel: 0, // Default auth level, should be fetched from user data
|
||||
userStatus: UserState.VERIFIED_REGULAR, // Default status, should be fetched from user data
|
||||
orgId: refreshPayload.orgId || ''
|
||||
});
|
||||
|
||||
// Set new tokens based on authentication method
|
||||
if (req.cookies[this.cookieName] || req.cookies[this.refreshCookieName]) {
|
||||
// Cookie-based auth: set new cookies
|
||||
this.setTokenCookies(res, newTokenPair);
|
||||
} else {
|
||||
// Header-based auth: send tokens in response headers
|
||||
res.setHeader('X-New-Access-Token', newTokenPair.accessToken);
|
||||
res.setHeader('X-New-Refresh-Token', newTokenPair.refreshToken);
|
||||
res.setHeader('X-Token-Refreshed', 'true');
|
||||
}
|
||||
|
||||
return newTokenPair;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set token cookies for cookie-based authentication
|
||||
*/
|
||||
private setTokenCookies(res: Response, tokenPair: TokenPair): void {
|
||||
// Set access token cookie
|
||||
res.cookie(this.cookieName, tokenPair.accessToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'strict',
|
||||
maxAge: this.tokenExpiry * 1000, // Convert to milliseconds
|
||||
maxAge: this.tokenExpiry * 1000,
|
||||
});
|
||||
|
||||
return token;
|
||||
// Set refresh token cookie
|
||||
res.cookie(this.refreshCookieName, tokenPair.refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'strict',
|
||||
maxAge: this.refreshTokenExpiry * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
verify(req: Request): TokenPayload | null {
|
||||
try {
|
||||
const token = req.cookies[this.cookieName];
|
||||
// First try to get token from cookie
|
||||
let token = req.cookies[this.cookieName];
|
||||
|
||||
// If no cookie token, try Authorization header
|
||||
if (!token) {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
token = authHeader.substring(7);
|
||||
}
|
||||
}
|
||||
|
||||
if (!token) return null;
|
||||
|
||||
const decoded = jwt.verify(token, this.secretKey) as TokenPayload;
|
||||
@@ -70,6 +214,32 @@ export class JWTService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout user by clearing tokens
|
||||
*/
|
||||
public logout(req: Request, res: Response): void {
|
||||
// Clear cookies if they exist
|
||||
if (req.cookies[this.cookieName]) {
|
||||
res.clearCookie(this.cookieName, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'strict'
|
||||
});
|
||||
}
|
||||
|
||||
if (req.cookies[this.refreshCookieName]) {
|
||||
res.clearCookie(this.refreshCookieName, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'strict'
|
||||
});
|
||||
}
|
||||
|
||||
// For bearer token auth, set headers to indicate logout
|
||||
res.setHeader('X-Auth-Logout', 'true');
|
||||
res.setHeader('X-Clear-Tokens', 'true');
|
||||
}
|
||||
|
||||
// Check if token needs refresh (within 25% of expiry time)
|
||||
shouldRefreshToken(payload: TokenPayload): boolean {
|
||||
if (!payload.exp || !payload.iat) return false;
|
||||
@@ -83,16 +253,39 @@ export class JWTService {
|
||||
}
|
||||
|
||||
// Conditionally refresh token only if needed
|
||||
refreshIfNeeded(payload: TokenPayload, res: Response): boolean {
|
||||
refreshIfNeeded(payload: TokenPayload, res: Response, req?: Request): boolean {
|
||||
if (this.shouldRefreshToken(payload)) {
|
||||
// Create new token with fresh timestamps, but same user data
|
||||
const freshPayload: Omit<TokenPayload, 'iat' | 'exp'> = {
|
||||
if (req) {
|
||||
// Try to use the new refresh token system
|
||||
const newTokenPair = this.attemptTokenRefresh(req, res);
|
||||
if (newTokenPair) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: create new token pair
|
||||
const freshPayload: Omit<TokenPayload, 'iat' | 'exp' | 'type'> = {
|
||||
userId: payload.userId,
|
||||
authLevel: payload.authLevel,
|
||||
userStatus: payload.userStatus,
|
||||
orgId: payload.orgId
|
||||
};
|
||||
this.create(freshPayload, res);
|
||||
|
||||
// Check if using Bearer authentication
|
||||
if (req && this.isUsingBearerAuth(req)) {
|
||||
// For Bearer auth, create token pair and add to headers
|
||||
const newTokenPair = this.createTokenPair(freshPayload);
|
||||
res.setHeader('X-New-Access-Token', newTokenPair.accessToken);
|
||||
res.setHeader('X-New-Refresh-Token', newTokenPair.refreshToken);
|
||||
res.setHeader('X-Token-Refreshed', 'true');
|
||||
} else {
|
||||
// For cookie auth, create token pair and set cookies
|
||||
const newTokenPair = this.create(freshPayload, res);
|
||||
res.setHeader('X-New-Access-Token', newTokenPair.accessToken);
|
||||
res.setHeader('X-New-Refresh-Token', newTokenPair.refreshToken);
|
||||
res.setHeader('X-Token-Refreshed', 'true');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Response } from 'express';
|
||||
export interface LoginResponse {
|
||||
user: ShortUserDto;
|
||||
token: string;
|
||||
refreshToken: string;
|
||||
requiresOrgReauth?: boolean;
|
||||
orgLoginUrl?: string;
|
||||
organizationName?: string;
|
||||
@@ -111,7 +112,23 @@ export class LoginCommandHandler {
|
||||
try {
|
||||
// Use the real response object if provided, otherwise use mock
|
||||
const responseObj = res || mockRes;
|
||||
const token = this.jwtService.create(tokenPayload, responseObj);
|
||||
|
||||
// Check if client prefers Bearer token authentication
|
||||
const prefersBearerAuth = res && (
|
||||
res.req?.headers['authorization'] !== undefined ||
|
||||
res.req?.headers['x-auth-method'] === 'bearer' ||
|
||||
res.req?.headers['accept']?.includes('application/json')
|
||||
);
|
||||
|
||||
let tokenPair: any;
|
||||
|
||||
if (prefersBearerAuth && res) {
|
||||
// Create token pair for Bearer authentication (no cookies)
|
||||
tokenPair = this.jwtService.createTokenPair(tokenPayload);
|
||||
} else {
|
||||
// Cookie-based authentication (sets cookies automatically)
|
||||
tokenPair = this.jwtService.create(tokenPayload, responseObj);
|
||||
}
|
||||
|
||||
// Check if user belongs to an organization and needs reauthentication
|
||||
let requiresOrgReauth = false;
|
||||
@@ -154,7 +171,8 @@ export class LoginCommandHandler {
|
||||
|
||||
const response: LoginResponse = {
|
||||
user: UserMapper.toShortDto(user),
|
||||
token
|
||||
token: tokenPair.accessToken,
|
||||
refreshToken: tokenPair.refreshToken
|
||||
};
|
||||
|
||||
if (requiresOrgReauth) {
|
||||
|
||||
@@ -17,45 +17,63 @@ export class LogoutCommandHandler {
|
||||
try {
|
||||
logAuth('Logout process started', userId);
|
||||
|
||||
// 1. Get token from request to blacklist it
|
||||
let tokenToBlacklist: string | null = null;
|
||||
// 1. Get tokens from request to blacklist them
|
||||
let accessTokenToBlacklist: string | null = null;
|
||||
let refreshTokenToBlacklist: string | null = null;
|
||||
|
||||
if (req) {
|
||||
// Extract token from cookie
|
||||
tokenToBlacklist = req.cookies['auth_token'];
|
||||
|
||||
// Also check Authorization header as fallback
|
||||
if (!tokenToBlacklist && req.headers.authorization) {
|
||||
// Extract access token from cookie or Authorization header
|
||||
accessTokenToBlacklist = req.cookies['auth_token'];
|
||||
if (!accessTokenToBlacklist && req.headers.authorization) {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader.startsWith('Bearer ')) {
|
||||
tokenToBlacklist = authHeader.substring(7);
|
||||
accessTokenToBlacklist = authHeader.substring(7);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract refresh token from cookie or header
|
||||
refreshTokenToBlacklist = req.cookies['refresh_token'];
|
||||
if (!refreshTokenToBlacklist) {
|
||||
refreshTokenToBlacklist = req.headers['x-refresh-token'] as string;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Blacklist the current JWT token in Redis (if available)
|
||||
if (tokenToBlacklist && req) {
|
||||
// 2. Blacklist both access and refresh tokens in Redis
|
||||
if (accessTokenToBlacklist && req) {
|
||||
try {
|
||||
// Store token in blacklist with expiration matching token expiry
|
||||
const decoded = this.jwtService.verify(req);
|
||||
if (decoded && decoded.exp) {
|
||||
const ttl = decoded.exp - Math.floor(Date.now() / 1000);
|
||||
if (ttl > 0) {
|
||||
await this.redisService.setWithExpiry(`blacklist:${tokenToBlacklist}`, 'true', ttl);
|
||||
logAuth('JWT token blacklisted', userId, { tokenExpiry: ttl });
|
||||
await this.redisService.setWithExpiry(`blacklist:${accessTokenToBlacklist}`, 'true', ttl);
|
||||
logAuth('Access token blacklisted', userId, { tokenExpiry: ttl });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logWarning('Failed to blacklist token', { userId, error: (error as Error).message });
|
||||
logWarning('Failed to blacklist access token', { userId, error: (error as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Clear authentication cookie
|
||||
res.clearCookie('auth_token', {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'strict',
|
||||
path: '/'
|
||||
});
|
||||
// Blacklist refresh token if present
|
||||
if (refreshTokenToBlacklist) {
|
||||
try {
|
||||
const refreshDecoded = this.jwtService.verifyRefreshToken(refreshTokenToBlacklist);
|
||||
if (refreshDecoded && refreshDecoded.exp) {
|
||||
const ttl = refreshDecoded.exp - Math.floor(Date.now() / 1000);
|
||||
if (ttl > 0) {
|
||||
await this.redisService.setWithExpiry(`blacklist:${refreshTokenToBlacklist}`, 'true', ttl);
|
||||
logAuth('Refresh token blacklisted', userId, { tokenExpiry: ttl });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logWarning('Failed to blacklist refresh token', { userId, error: (error as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Use JWT service to clear cookies and set logout headers
|
||||
if (req) {
|
||||
this.jwtService.logout(req, res);
|
||||
}
|
||||
|
||||
// 4. Remove user from active sessions in Redis
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user