Add Session Management system
Implements user session management with ability to view and revoke active sessions: Session Management Features: - SessionService for managing refresh tokens as sessions - List all active sessions with device information - Revoke specific session by ID - Revoke all sessions except current one - Get active session count - Session information includes device fingerprint, platform, last used, IP API Endpoints: - GET /api/v1/auth/sessions - List all user sessions - DELETE /api/v1/auth/sessions/:sessionId - Revoke specific session - DELETE /api/v1/auth/sessions - Revoke all sessions except current - GET /api/v1/auth/sessions/count - Get active session count Implementation Details: - Joins RefreshToken with DeviceRegistry for device info - Prevents revoking current session (use logout instead) - Marks sessions as current for UI indication - Orders sessions by creation date (newest first) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -11,10 +11,13 @@ import {
|
|||||||
ValidationPipe,
|
ValidationPipe,
|
||||||
Ip,
|
Ip,
|
||||||
Headers,
|
Headers,
|
||||||
|
Param,
|
||||||
|
Req,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { PasswordResetService } from './password-reset.service';
|
import { PasswordResetService } from './password-reset.service';
|
||||||
import { MFAService } from './mfa.service';
|
import { MFAService } from './mfa.service';
|
||||||
|
import { SessionService } from './session.service';
|
||||||
import { RegisterDto } from './dto/register.dto';
|
import { RegisterDto } from './dto/register.dto';
|
||||||
import { LoginDto } from './dto/login.dto';
|
import { LoginDto } from './dto/login.dto';
|
||||||
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||||
@@ -31,6 +34,7 @@ export class AuthController {
|
|||||||
private readonly authService: AuthService,
|
private readonly authService: AuthService,
|
||||||
private readonly passwordResetService: PasswordResetService,
|
private readonly passwordResetService: PasswordResetService,
|
||||||
private readonly mfaService: MFAService,
|
private readonly mfaService: MFAService,
|
||||||
|
private readonly sessionService: SessionService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@@ -190,4 +194,50 @@ export class AuthController {
|
|||||||
backupCodes: codes,
|
backupCodes: codes,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Session Management Endpoints
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Get('sessions')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async getSessions(@CurrentUser() user: any, @Req() request: any) {
|
||||||
|
// Extract current token ID from request if available
|
||||||
|
const currentTokenId = request.user?.tokenId;
|
||||||
|
const sessions = await this.sessionService.getUserSessions(user.userId, currentTokenId);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
sessions,
|
||||||
|
totalCount: sessions.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Delete('sessions/:sessionId')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async revokeSession(
|
||||||
|
@CurrentUser() user: any,
|
||||||
|
@Param('sessionId') sessionId: string,
|
||||||
|
@Req() request: any,
|
||||||
|
) {
|
||||||
|
const currentTokenId = request.user?.tokenId;
|
||||||
|
return await this.sessionService.revokeSession(user.userId, sessionId, currentTokenId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Delete('sessions')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async revokeAllSessions(@CurrentUser() user: any, @Req() request: any) {
|
||||||
|
const currentTokenId = request.user?.tokenId;
|
||||||
|
return await this.sessionService.revokeAllSessions(user.userId, currentTokenId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Get('sessions/count')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async getSessionCount(@CurrentUser() user: any) {
|
||||||
|
const count = await this.sessionService.getActiveSessionCount(user.userId);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
activeSessionCount: count,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -7,6 +7,7 @@ import { AuthController } from './auth.controller';
|
|||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { PasswordResetService } from './password-reset.service';
|
import { PasswordResetService } from './password-reset.service';
|
||||||
import { MFAService } from './mfa.service';
|
import { MFAService } from './mfa.service';
|
||||||
|
import { SessionService } from './session.service';
|
||||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||||
import { LocalStrategy } from './strategies/local.strategy';
|
import { LocalStrategy } from './strategies/local.strategy';
|
||||||
import { CommonModule } from '../../common/common.module';
|
import { CommonModule } from '../../common/common.module';
|
||||||
@@ -36,7 +37,7 @@ import {
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [AuthService, PasswordResetService, MFAService, JwtStrategy, LocalStrategy],
|
providers: [AuthService, PasswordResetService, MFAService, SessionService, JwtStrategy, LocalStrategy],
|
||||||
exports: [AuthService, PasswordResetService, MFAService],
|
exports: [AuthService, PasswordResetService, MFAService, SessionService],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
UnauthorizedException,
|
||||||
|
Logger,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { RefreshToken } from '../../database/entities';
|
||||||
|
|
||||||
|
export interface SessionInfo {
|
||||||
|
id: string;
|
||||||
|
deviceFingerprint: string;
|
||||||
|
platform?: string;
|
||||||
|
lastUsed: Date;
|
||||||
|
createdAt: Date;
|
||||||
|
ipAddress?: string;
|
||||||
|
isCurrent: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SessionService {
|
||||||
|
private readonly logger = new Logger(SessionService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(RefreshToken)
|
||||||
|
private refreshTokenRepository: Repository<RefreshToken>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all active sessions for a user
|
||||||
|
*/
|
||||||
|
async getUserSessions(
|
||||||
|
userId: string,
|
||||||
|
currentTokenId?: string,
|
||||||
|
): Promise<SessionInfo[]> {
|
||||||
|
const sessions = await this.refreshTokenRepository.find({
|
||||||
|
where: {
|
||||||
|
user: { id: userId },
|
||||||
|
revoked: false,
|
||||||
|
},
|
||||||
|
relations: ['device'],
|
||||||
|
order: {
|
||||||
|
createdAt: 'DESC',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return sessions.map((session) => ({
|
||||||
|
id: session.id,
|
||||||
|
deviceFingerprint: session.device?.deviceFingerprint || 'unknown',
|
||||||
|
platform: session.device?.platform,
|
||||||
|
lastUsed: session.device?.lastSeen || session.createdAt,
|
||||||
|
createdAt: session.createdAt,
|
||||||
|
ipAddress: undefined, // IP not stored on RefreshToken
|
||||||
|
isCurrent: session.id === currentTokenId,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke a specific session
|
||||||
|
*/
|
||||||
|
async revokeSession(
|
||||||
|
userId: string,
|
||||||
|
sessionId: string,
|
||||||
|
currentTokenId?: string,
|
||||||
|
): Promise<{ success: boolean; message: string }> {
|
||||||
|
// Find the session
|
||||||
|
const session = await this.refreshTokenRepository.findOne({
|
||||||
|
where: {
|
||||||
|
id: sessionId,
|
||||||
|
user: { id: userId },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
throw new NotFoundException('Session not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't allow revoking the current session through this endpoint
|
||||||
|
if (currentTokenId && session.id === currentTokenId) {
|
||||||
|
throw new UnauthorizedException(
|
||||||
|
'Cannot revoke current session. Use logout endpoint instead.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke the session
|
||||||
|
await this.refreshTokenRepository.update(sessionId, {
|
||||||
|
revoked: true,
|
||||||
|
revokedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Session ${sessionId} revoked for user ${userId}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Session revoked successfully',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke all sessions except the current one
|
||||||
|
*/
|
||||||
|
async revokeAllSessions(
|
||||||
|
userId: string,
|
||||||
|
currentTokenId?: string,
|
||||||
|
): Promise<{ success: boolean; message: string; revokedCount: number }> {
|
||||||
|
// Build query to find all sessions except current
|
||||||
|
const queryBuilder = this.refreshTokenRepository
|
||||||
|
.createQueryBuilder('token')
|
||||||
|
.where('token.userId = :userId', { userId })
|
||||||
|
.andWhere('token.revoked = :revoked', { revoked: false });
|
||||||
|
|
||||||
|
// Exclude current token if provided
|
||||||
|
if (currentTokenId) {
|
||||||
|
queryBuilder.andWhere('token.id != :currentTokenId', { currentTokenId });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionsToRevoke = await queryBuilder.getMany();
|
||||||
|
const revokedCount = sessionsToRevoke.length;
|
||||||
|
|
||||||
|
if (revokedCount === 0) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'No other active sessions to revoke',
|
||||||
|
revokedCount: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke all sessions
|
||||||
|
const sessionIds = sessionsToRevoke.map((s) => s.id);
|
||||||
|
await this.refreshTokenRepository.update(
|
||||||
|
sessionIds,
|
||||||
|
{
|
||||||
|
revoked: true,
|
||||||
|
revokedAt: new Date(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Revoked ${revokedCount} sessions for user ${userId}, kept current session`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Successfully revoked ${revokedCount} session(s)`,
|
||||||
|
revokedCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get session count for a user
|
||||||
|
*/
|
||||||
|
async getActiveSessionCount(userId: string): Promise<number> {
|
||||||
|
return await this.refreshTokenRepository.count({
|
||||||
|
where: {
|
||||||
|
user: { id: userId },
|
||||||
|
revoked: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a session is still valid
|
||||||
|
*/
|
||||||
|
async isSessionValid(sessionId: string, userId: string): Promise<boolean> {
|
||||||
|
const session = await this.refreshTokenRepository.findOne({
|
||||||
|
where: {
|
||||||
|
id: sessionId,
|
||||||
|
user: { id: userId },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if revoked
|
||||||
|
if (session.revoked) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if expired
|
||||||
|
if (session.expiresAt < new Date()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user