How to Prevent Multiple Logins in NestJS using Redis cache
Enhancing Security and Session Management by Preventing Multiple Logins in NestJS Using Redis
Table of contents
Preventing multiple logins for a single user can be essential for security and maintaining a smooth user experience, especially in applications where session management and user accountability are critical. In this blog, we'll explore how to prevent multiple logins in a NestJS application by invalidating previous sessions when a new login occurs.
Why Prevent Multiple Logins?
Allowing multiple logins can lead to several issues:
Security risks: A user can share their login credentials with others or have their account accessed from multiple locations without their knowledge.
Session hijacking: If a user's session is stolen, attackers can maintain access without the user knowing.
User experience: In some applications (e.g., banking or sensitive accounts), it’s beneficial to limit access to one session at a time.
By implementing a mechanism to prevent multiple simultaneous logins, we can enhance the security and usability of our applications.
Steps to Implement Multiple Login Prevention in NestJS
Here’s a step-by-step guide to implementing a solution that prevents multiple logins using NestJS, JWT (JSON Web Token), and a session invalidation strategy.
Prerequisites
Basic knowledge of NestJS and JWT authentication.
A NestJS project with user authentication already set up using JWT.
Step 1: Store Active User Sessions
We need to store and manage user sessions, typically in a Redis store or a database, to track which users are logged in. When a user logs in, we store the token, and if another login attempt occurs, we invalidate the previous session.
Create a Session Service
We'll create a SessionService
to manage user sessions in Redis (or any in-memory data store).
npm install redis @nestjs/redis
import { Injectable } from '@nestjs/common';
import { RedisService } from 'nestjs-redis';
import { InjectRedis } from '@nestjs-modules/ioredis';
@Injectable()
export class SessionService {
constructor(@InjectRedis() private readonly redisClient) {}
async setSession(userId: string, token: string): Promise<void> {
await this.redisClient.set(userId, token);
}
async getSession(userId: string): Promise<string> {
return this.redisClient.get(userId);
}
async invalidateSession(userId: string): Promise<void> {
await this.redisClient.del(userId);
}
}
Here, we store the JWT token for each user in Redis. When a user logs in, we call setSession()
, and when they log out or log in again, we invalidate the previous session using invalidateSession()
.
Step 2: Invalidate Previous Sessions on New Login
To ensure that only one session is active at a time, we need to invalidate the previous session when a new login happens. In your AuthService
(or wherever you handle login), you can modify the login logic like this:
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { UsersService } from '../users/users.service';
import { SessionService } from '../session/session.service';
@Injectable()
export class AuthService {
constructor(
private readonly usersService: UsersService,
private readonly jwtService: JwtService,
private readonly sessionService: SessionService,
) {}
async login(userId: string): Promise<{ access_token: string }> {
// Generate a new JWT token
const payload = { sub: userId };
const token = this.jwtService.sign(payload);
// Invalidate previous session
const currentSession = await this.sessionService.getSession(userId);
if (currentSession) {
await this.sessionService.invalidateSession(userId);
}
// Store new session
await this.sessionService.setSession(userId, token);
return { access_token: token };
}
}
Step 3: Verify Session Validity on Each Request
To prevent access using an old token, you must validate the active session during each request. When a request comes in with a JWT token, you can check the stored session in Redis to ensure the token is still valid.
In your JWT validation guard (or middleware), check if the token in the request matches the token stored in the session:
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { SessionService } from '../session/session.service';
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(
private readonly jwtService: JwtService,
private readonly sessionService: SessionService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = request.headers.authorization?.split(' ')[1];
if (!token) {
throw new UnauthorizedException('Token is missing');
}
// Decode token to get the userId
const decoded = this.jwtService.decode(token) as { sub: string };
const userId = decoded.sub;
// Get the current session from Redis
const currentSession = await this.sessionService.getSession(userId);
// Check if the provided token matches the active session
if (token !== currentSession) {
throw new UnauthorizedException('Session invalidated, please log in again');
}
return true;
}
}
This guard will reject requests with an invalidated token and force the user to log in again.
Step 4: Handle Logout
When a user logs out, it’s important to also invalidate their session. This prevents the token from being used after logout:
async logout(userId: string): Promise<void> {
await this.sessionService.invalidateSession(userId);
}
Step 5: Additional Considerations
Token Expiry: Ensure that JWT tokens have an expiration time (
exp
). This will automatically invalidate tokens after a certain period.Refresh Tokens: You can implement refresh tokens if you want to allow users to remain logged in for long periods, but still limit them to one active session at a time.
Session Management in Database: If Redis isn't available, you can use a database to store the active sessions instead. Just make sure that queries to check the active session are optimized for performance.
Conclusion
Happy Coding!