final changes

This commit is contained in:
2025-09-22 11:14:32 +02:00
parent cf157643d7
commit bf9ae5f01f
509 changed files with 920 additions and 64152 deletions
@@ -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 {