Feature / Refactor to session based auth in backend #9

Merged
igorpropisnov merged 7 commits from feature/frontend-dashboard into main 2024-06-02 13:09:16 +02:00
6 changed files with 110 additions and 50 deletions
Showing only changes of commit a341196f37 - Show all commits

View File

@ -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;

View File

@ -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,
], ],

View File

@ -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;
}
}

View File

@ -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;
} }

View File

@ -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;
} }
} }

View File

@ -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 },