Feature / Refactor to session based auth in backend #9
|
@ -18,7 +18,7 @@ export class UserCredentials {
|
||||||
public hash: string;
|
public hash: string;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
public hashedRt?: string;
|
public refreshToken?: string;
|
||||||
|
|
||||||
@CreateDateColumn()
|
@CreateDateColumn()
|
||||||
public createdAt: Date;
|
public createdAt: Date;
|
||||||
|
|
|
@ -8,6 +8,7 @@ import { UserModule } from '../user-module/user.module';
|
||||||
import { VerifyModule } from '../verify-module/verify.module';
|
import { VerifyModule } from '../verify-module/verify.module';
|
||||||
|
|
||||||
import { AuthController } from './controller/auth.controller';
|
import { AuthController } from './controller/auth.controller';
|
||||||
|
import { SessionRepository } from './repositories/session.repository';
|
||||||
import { UserCredentialsRepository } from './repositories/user-credentials.repository';
|
import { UserCredentialsRepository } from './repositories/user-credentials.repository';
|
||||||
import { AuthService } from './services/auth.service';
|
import { AuthService } from './services/auth.service';
|
||||||
import { SessionService } from './services/session.service';
|
import { SessionService } from './services/session.service';
|
||||||
|
@ -27,6 +28,7 @@ import { AccessTokenStrategy, RefreshTokenStrategy } from './strategies';
|
||||||
SessionService,
|
SessionService,
|
||||||
TokenManagementService,
|
TokenManagementService,
|
||||||
UserCredentialsRepository,
|
UserCredentialsRepository,
|
||||||
|
SessionRepository,
|
||||||
AccessTokenStrategy,
|
AccessTokenStrategy,
|
||||||
RefreshTokenStrategy,
|
RefreshTokenStrategy,
|
||||||
],
|
],
|
||||||
|
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Session } from 'src/entities';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SessionRepository {
|
||||||
|
public constructor(
|
||||||
|
@InjectRepository(Session)
|
||||||
|
private sessionRepository: Repository<Session>
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async createSession(
|
||||||
|
userId: string,
|
||||||
|
userAgent: string
|
||||||
|
): Promise<Session> {
|
||||||
|
const sessionId = uuidv4();
|
||||||
|
const expirationDate = new Date();
|
||||||
|
|
||||||
|
expirationDate.setHours(expirationDate.getHours() + 1);
|
||||||
|
const session = this.sessionRepository.create({
|
||||||
|
userCredentials: userId,
|
||||||
|
sessionId,
|
||||||
|
expiresAt: expirationDate,
|
||||||
|
userAgent,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.sessionRepository.save(session);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async validateSessionUserAgent(
|
||||||
|
sessionId: string,
|
||||||
|
currentUserAgent: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const session = await this.sessionRepository.findOne({
|
||||||
|
where: { sessionId: sessionId },
|
||||||
|
select: ['userAgent'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return session.userAgent === currentUserAgent;
|
||||||
|
}
|
||||||
|
}
|
|
@ -31,11 +31,11 @@ export class UserCredentialsRepository {
|
||||||
return this.repository.findOne({ where: { id: userId } });
|
return this.repository.findOne({ where: { id: userId } });
|
||||||
}
|
}
|
||||||
|
|
||||||
public async updateUserTokenHash(
|
public async updateUserRefreshToken(
|
||||||
userId: string,
|
userId: string,
|
||||||
hashedRt: string | null
|
refreshToken: string | null
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const result = await this.repository.update(userId, { hashedRt });
|
const result = await this.repository.update(userId, { refreshToken });
|
||||||
|
|
||||||
return result.affected ?? 0;
|
return result.affected ?? 0;
|
||||||
}
|
}
|
||||||
|
|
|
@ -79,6 +79,8 @@ export class AuthService {
|
||||||
throw new ForbiddenException('Access Denied');
|
throw new ForbiddenException('Access Denied');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.sessionService.checkSessionLimit(user.id);
|
||||||
|
|
||||||
const sesseionId = await this.sessionService.createSession(
|
const sesseionId = await this.sessionService.createSession(
|
||||||
user.id,
|
user.id,
|
||||||
request.headers['user-agent']
|
request.headers['user-agent']
|
||||||
|
@ -90,11 +92,10 @@ export class AuthService {
|
||||||
}
|
}
|
||||||
|
|
||||||
public async logout(userId: string): Promise<boolean> {
|
public async logout(userId: string): Promise<boolean> {
|
||||||
// TODO Check if the user is logged out already
|
const affected =
|
||||||
const affected = await this.userCredentialsRepository.updateUserTokenHash(
|
await this.userCredentialsRepository.updateUserRefreshToken(userId, null);
|
||||||
userId,
|
|
||||||
null
|
await this.sessionService.invalidateAllSessionsForUser(userId);
|
||||||
);
|
|
||||||
|
|
||||||
return affected > 0;
|
return affected > 0;
|
||||||
}
|
}
|
||||||
|
@ -118,19 +119,20 @@ export class AuthService {
|
||||||
request.headers['user-agent']
|
request.headers['user-agent']
|
||||||
);
|
);
|
||||||
|
|
||||||
// TODO expand session expiration
|
|
||||||
|
|
||||||
if (!isUserAgentValid) {
|
if (!isUserAgentValid) {
|
||||||
throw new ForbiddenException('Invalid session - User agent mismatch');
|
throw new ForbiddenException('Invalid session - User agent mismatch');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.sessionService.extendSessionExpiration(sessionId);
|
||||||
|
|
||||||
const decodedToken: TokenPayload = await this.validateRefreshToken(
|
const decodedToken: TokenPayload = await this.validateRefreshToken(
|
||||||
session.userCredentials['id']
|
session.userCredentials['id']
|
||||||
);
|
);
|
||||||
|
|
||||||
const newTokens = await this.generateAndPersistTokens(
|
const newTokens = await this.generateAndPersistTokens(
|
||||||
decodedToken.sub,
|
decodedToken.sub,
|
||||||
decodedToken.email
|
decodedToken.email,
|
||||||
|
false
|
||||||
);
|
);
|
||||||
|
|
||||||
return { access_token: newTokens.access_token };
|
return { access_token: newTokens.access_token };
|
||||||
|
@ -138,29 +140,33 @@ export class AuthService {
|
||||||
|
|
||||||
private async generateAndPersistTokens(
|
private async generateAndPersistTokens(
|
||||||
userId: string,
|
userId: string,
|
||||||
email: string
|
email: string,
|
||||||
|
updateRefreshToken: boolean = false
|
||||||
): Promise<LoginResponseDto> {
|
): Promise<LoginResponseDto> {
|
||||||
const tokens = await this.tokenManagementService.generateTokens(
|
const tokens = await this.tokenManagementService.generateTokens(
|
||||||
userId,
|
userId,
|
||||||
email
|
email
|
||||||
);
|
);
|
||||||
|
|
||||||
await this.userCredentialsRepository.updateUserTokenHash(
|
if (updateRefreshToken) {
|
||||||
userId,
|
await this.userCredentialsRepository.updateUserRefreshToken(
|
||||||
tokens.refresh_token
|
userId,
|
||||||
);
|
tokens.refresh_token
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return { access_token: tokens.access_token, email: email, userId: userId };
|
return { access_token: tokens.access_token, email: email, userId: userId };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async validateRefreshToken(userId: string): Promise<TokenPayload> {
|
private async validateRefreshToken(userId: string): Promise<TokenPayload> {
|
||||||
const user = await this.userCredentialsRepository.findUserById(userId);
|
const user = await this.userCredentialsRepository.findUserById(userId);
|
||||||
|
|
||||||
if (!user || !user.hashedRt) {
|
if (!user || !user.refreshToken) {
|
||||||
throw new Error('No refresh token found');
|
throw new Error('No refresh token found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const decodedToken = await this.tokenManagementService.verifyRefreshToken(
|
const decodedToken = await this.tokenManagementService.verifyRefreshToken(
|
||||||
user.hashedRt
|
user.refreshToken
|
||||||
);
|
);
|
||||||
|
|
||||||
if (decodedToken.exp < Date.now() / 1000) {
|
if (decodedToken.exp < Date.now() / 1000) {
|
||||||
|
@ -170,6 +176,7 @@ export class AuthService {
|
||||||
if (decodedToken.sub !== user.id) {
|
if (decodedToken.sub !== user.id) {
|
||||||
throw new Error('Token subject mismatch');
|
throw new Error('Token subject mismatch');
|
||||||
}
|
}
|
||||||
|
|
||||||
return decodedToken;
|
return decodedToken;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,68 +3,71 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import { Session } from 'src/entities';
|
import { Session } from 'src/entities';
|
||||||
import { LessThan, Repository } from 'typeorm';
|
import { LessThan, Repository } from 'typeorm';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
|
||||||
|
import { SessionRepository } from '../repositories/session.repository';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SessionService {
|
export class SessionService {
|
||||||
public constructor(
|
public constructor(
|
||||||
@InjectRepository(Session)
|
@InjectRepository(Session)
|
||||||
private sessionRepository: Repository<Session>
|
private sessionRepository: Repository<Session>,
|
||||||
|
private readonly sessionRepository2: SessionRepository
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async createSession(
|
public async createSession(
|
||||||
userId: string,
|
userId: string,
|
||||||
userAgent: string
|
userAgent: string
|
||||||
): Promise<Session> {
|
): Promise<Session> {
|
||||||
const sessionId = uuidv4();
|
return this.sessionRepository2.createSession(userId, userAgent);
|
||||||
const expirationDate = new Date();
|
|
||||||
|
|
||||||
expirationDate.setHours(expirationDate.getHours() + 1);
|
|
||||||
|
|
||||||
const session = this.sessionRepository.create({
|
|
||||||
userCredentials: userId,
|
|
||||||
sessionId: sessionId,
|
|
||||||
expiresAt: expirationDate,
|
|
||||||
userAgent: userAgent,
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.sessionRepository.save(session);
|
|
||||||
return session;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async validateSessionUserAgent(
|
public async validateSessionUserAgent(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
currentUserAgent: string
|
currentUserAgent: string
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const session = await this.sessionRepository.findOne({
|
return this.sessionRepository2.validateSessionUserAgent(
|
||||||
where: { sessionId: sessionId },
|
sessionId,
|
||||||
select: ['userAgent'],
|
currentUserAgent
|
||||||
});
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!session) {
|
public async checkSessionLimit(userId: string): Promise<void> {
|
||||||
return false;
|
const userSessions = await this.sessionRepository
|
||||||
|
.createQueryBuilder('session')
|
||||||
|
.leftJoinAndSelect('session.userCredentials', 'userCredentials')
|
||||||
|
.where('userCredentials.id = :userId', { userId })
|
||||||
|
.orderBy('session.expiresAt', 'ASC')
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
if (userSessions.length >= 5) {
|
||||||
|
await this.sessionRepository.delete(userSessions[0].id);
|
||||||
}
|
}
|
||||||
|
|
||||||
return session.userAgent === currentUserAgent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO use method to invalidate session
|
|
||||||
public async invalidateSession(sessionId: string): Promise<void> {
|
|
||||||
await this.sessionRepository.delete({ sessionId: sessionId });
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO use method to invalidate all sessions for user
|
|
||||||
public async invalidateAllSessionsForUser(userId: string): Promise<void> {
|
public async invalidateAllSessionsForUser(userId: string): Promise<void> {
|
||||||
await this.sessionRepository.delete({ userCredentials: userId });
|
await this.sessionRepository.delete({ userCredentials: userId });
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO use method to clear expired sessions
|
// TODO Add cron job to clear expired sessions
|
||||||
public async clearExpiredSessions(): Promise<void> {
|
public async clearExpiredSessions(): Promise<void> {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
await this.sessionRepository.delete({ expiresAt: LessThan(now) });
|
await this.sessionRepository.delete({ expiresAt: LessThan(now) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async extendSessionExpiration(sessionId: string): Promise<void> {
|
||||||
|
const session = await this.sessionRepository.findOne({
|
||||||
|
where: { sessionId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (session) {
|
||||||
|
session.expiresAt = new Date(
|
||||||
|
session.expiresAt.setMinutes(session.expiresAt.getMinutes() + 30)
|
||||||
|
);
|
||||||
|
await this.sessionRepository.save(session);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async findSessionBySessionId(sessionId: string): Promise<Session> {
|
public async findSessionBySessionId(sessionId: string): Promise<Session> {
|
||||||
return this.sessionRepository.findOne({
|
return this.sessionRepository.findOne({
|
||||||
where: { sessionId: sessionId },
|
where: { sessionId: sessionId },
|
||||||
|
|
Loading…
Reference in New Issue