This commit is contained in:
Igor Hrenowitsch Propisnov 2024-06-03 09:06:55 +02:00
parent 7ef46af4f3
commit 9993c63a56
14 changed files with 145 additions and 264 deletions

View File

@ -10,7 +10,6 @@ import { CspMiddleware } from './middleware/csp-middleware/csp.middleware';
import { HttpsRedirectMiddleware } from './middleware/https-middlware/https-redirect.middleware'; import { HttpsRedirectMiddleware } from './middleware/https-middlware/https-redirect.middleware';
import { SecurityHeadersMiddleware } from './middleware/security-middleware/security.middleware'; import { SecurityHeadersMiddleware } from './middleware/security-middleware/security.middleware';
import { AuthModule } from './modules/auth-module/auth.module'; import { AuthModule } from './modules/auth-module/auth.module';
import { AccessTokenGuard } from './modules/auth-module/common/guards';
import { DatabaseModule } from './modules/database-module/database.module'; import { DatabaseModule } from './modules/database-module/database.module';
import { SendgridModule } from './modules/sendgrid-module/sendgrid.module'; import { SendgridModule } from './modules/sendgrid-module/sendgrid.module';
import { UserModule } from './modules/user-module/user.module'; import { UserModule } from './modules/user-module/user.module';
@ -29,11 +28,7 @@ import { VerifyModule } from './modules/verify-module/verify.module';
VerifyModule, VerifyModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [ providers: [AppService, ClearExpiredSessionsCron],
AppService,
{ provide: 'APP_GUARD', useClass: AccessTokenGuard },
ClearExpiredSessionsCron,
],
}) })
export class AppModule { export class AppModule {
public configure(consumer: MiddlewareConsumer): void { public configure(consumer: MiddlewareConsumer): void {

View File

@ -13,7 +13,6 @@ import { UserCredentialsRepository } from './repositories/user-credentials.repos
import { AuthService } from './services/auth.service'; import { AuthService } from './services/auth.service';
import { SessionService } from './services/session.service'; import { SessionService } from './services/session.service';
import { TokenManagementService } from './services/token-management.service'; import { TokenManagementService } from './services/token-management.service';
import { AccessTokenStrategy, RefreshTokenStrategy } from './strategies';
@Module({ @Module({
imports: [ imports: [
@ -29,8 +28,6 @@ import { AccessTokenStrategy, RefreshTokenStrategy } from './strategies';
TokenManagementService, TokenManagementService,
UserCredentialsRepository, UserCredentialsRepository,
SessionRepository, SessionRepository,
AccessTokenStrategy,
RefreshTokenStrategy,
], ],
controllers: [AuthController], controllers: [AuthController],
exports: [SessionService], exports: [SessionService],

View File

@ -1,11 +0,0 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { JwtPayload } from 'src/modules/auth-module/models/types';
export const GetCurrentUserId = createParamDecorator(
(_: undefined, context: ExecutionContext): number => {
const request = context.switchToHttp().getRequest();
const user = request.user as JwtPayload;
return user.sub;
}
);

View File

@ -1,13 +0,0 @@
//import { JwtPayloadWithRefreshToken } from 'src/modules/auth-module/models/types';
// export const GetCurrentUser = createParamDecorator(
// (
// data: keyof JwtPayloadWithRefreshToken | undefined,
// context: ExecutionContext
// ) => {
// const request = context.switchToHttp().getRequest();
// if (!data) return request.user;
// return request.user[data];
// }
// );

View File

@ -1,2 +0,0 @@
export * from './get-user-id.decorator';
// export * from './get-user.decorator';

View File

@ -1,2 +0,0 @@
export * from './access-token.guard';
export * from './refresh-token.guard';

View File

@ -1,7 +0,0 @@
import { AuthGuard } from '@nestjs/passport';
export class RefreshTokenGuard extends AuthGuard('jwt-refresh-token') {
public constructor() {
super();
}
}

View File

@ -1,22 +1,8 @@
import { import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common';
Controller,
Post,
Body,
HttpCode,
HttpStatus,
Res,
Req,
} from '@nestjs/common';
import { ApiCreatedResponse, ApiTags } from '@nestjs/swagger'; import { ApiCreatedResponse, ApiTags } from '@nestjs/swagger';
import { Response, Request } from 'express';
import { Public } from 'src/shared/decorator'; import { Public } from 'src/shared/decorator';
import { GetCurrentUserId } from '../common/decorators'; import { LoginResponseDto, UserCredentialsDto } from '../models/dto';
import {
AccessTokenDto,
LoginResponseDto,
UserCredentialsDto,
} from '../models/dto';
import { AuthService } from '../services/auth.service'; import { AuthService } from '../services/auth.service';
@ApiTags('Authentication') @ApiTags('Authentication')
@ -37,39 +23,39 @@ export class AuthController {
return this.authService.signup(userCredentials); return this.authService.signup(userCredentials);
} }
@ApiCreatedResponse({ // @ApiCreatedResponse({
description: 'User signin successfully', // description: 'User signin successfully',
type: LoginResponseDto, // type: LoginResponseDto,
}) // })
@HttpCode(HttpStatus.OK) // @HttpCode(HttpStatus.OK)
@Public() // @Public()
@Post('signin') // @Post('signin')
public async signin( // public async signin(
@Res({ passthrough: true }) response: Response, // @Res({ passthrough: true }) response: Response,
@Req() request: Request, // @Req() request: Request,
@Body() userCredentials: UserCredentialsDto // @Body() userCredentials: UserCredentialsDto
): Promise<LoginResponseDto> { // ): Promise<LoginResponseDto> {
return await this.authService.signin(userCredentials, response, request); // return await this.authService.signin(userCredentials, response, request);
} // }
@ApiCreatedResponse({ // @ApiCreatedResponse({
description: 'User tokens refreshed successfully', // description: 'User tokens refreshed successfully',
type: AccessTokenDto, // type: AccessTokenDto,
}) // })
@HttpCode(HttpStatus.OK) // @HttpCode(HttpStatus.OK)
@Public() // @Public()
@Post('refresh') // @Post('refresh')
public async refreshToken(@Req() request: Request): Promise<AccessTokenDto> { // public async refreshToken(@Req() request: Request): Promise<AccessTokenDto> {
return await this.authService.refresh(request); // return await this.authService.refresh(request);
} // }
@ApiCreatedResponse({ // @ApiCreatedResponse({
description: 'User signed out successfully', // description: 'User signed out successfully',
type: Boolean, // type: Boolean,
}) // })
@HttpCode(HttpStatus.OK) // @HttpCode(HttpStatus.OK)
@Post('logout') // @Post('logout')
public async logout(@GetCurrentUserId() userId: string): Promise<boolean> { // public async logout(@GetCurrentUserId() userId: string): Promise<boolean> {
return this.authService.logout(userId); // return this.authService.logout(userId);
} // }
} }

View File

@ -35,6 +35,15 @@ export class SessionRepository {
}); });
} }
public findSessionByUserId(userId: string): Promise<Session[]> {
return this.sessionRepository
.createQueryBuilder('session')
.where('session.userCredentialsId = :userId', {
userId,
})
.getMany();
}
public attachSessionToResponse(response: Response, sessionId: string): void { public attachSessionToResponse(response: Response, sessionId: string): void {
response.cookie('session_id', sessionId, { response.cookie('session_id', sessionId, {
httpOnly: true, httpOnly: true,

View File

@ -1,17 +1,10 @@
import { ForbiddenException, Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Response, Request } from 'express';
import { Session } from 'src/entities';
import { EncryptionService } from 'src/shared'; import { EncryptionService } from 'src/shared';
import { PasswordConfirmationMailService } from '../../sendgrid-module/services/password-confirmation.mail.service'; import { PasswordConfirmationMailService } from '../../sendgrid-module/services/password-confirmation.mail.service';
import { UserDataRepository } from '../../user-module/repositories/user-data.repository'; import { UserDataRepository } from '../../user-module/repositories/user-data.repository';
import { EmailVerificationService } from '../../verify-module/services/email-verification.service'; import { EmailVerificationService } from '../../verify-module/services/email-verification.service';
import { import { LoginResponseDto, UserCredentialsDto } from '../models/dto';
AccessTokenDto,
LoginResponseDto,
UserCredentialsDto,
} from '../models/dto';
import { TokenPayload } from '../models/types';
import { UserCredentialsRepository } from '../repositories/user-credentials.repository'; import { UserCredentialsRepository } from '../repositories/user-credentials.repository';
import { SessionService } from './session.service'; import { SessionService } from './session.service';
@ -52,129 +45,129 @@ export class AuthService {
token token
); );
return this.generateAndPersistTokens(user.id, user.email); //return this.generateAndPersistTokens(user.id, user.email);
} }
public async signin( // public async signin(
userCredentials: UserCredentialsDto, // userCredentials: UserCredentialsDto,
response: Response, // response: Response,
request: Request // request: Request
): Promise<LoginResponseDto> { // ): Promise<LoginResponseDto> {
const user = await this.userCredentialsRepository.findUserByEmail( // const user = await this.userCredentialsRepository.findUserByEmail(
userCredentials.email // userCredentials.email
); // );
if (!user) { // if (!user) {
throw new ForbiddenException('Access Denied'); // throw new ForbiddenException('Access Denied');
} // }
const passwordMatch = await EncryptionService.compareHash( // const passwordMatch = await EncryptionService.compareHash(
userCredentials.password, // userCredentials.password,
user.hash // user.hash
); // );
if (!passwordMatch) { // if (!passwordMatch) {
throw new ForbiddenException('Access Denied'); // throw new ForbiddenException('Access Denied');
} // }
await this.sessionService.checkSessionLimit(user.id); // 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']
); // );
this.sessionService.attachSessionToResponse(response, sesseionId.sessionId); // this.sessionService.attachSessionToResponse(response, sesseionId.sessionId);
return this.generateAndPersistTokens(user.id, user.email, true); // return this.generateAndPersistTokens(user.id, user.email, true);
} // }
public async logout(userId: string): Promise<boolean> { // public async logout(userId: string): Promise<boolean> {
const affected = // const affected =
await this.userCredentialsRepository.updateUserRefreshToken(userId, null); // await this.userCredentialsRepository.updateUserRefreshToken(userId, null);
await this.sessionService.invalidateAllSessionsForUser(userId); // await this.sessionService.invalidateAllSessionsForUser(userId);
return affected > 0; // return affected > 0;
} // }
public async refresh(request: Request): Promise<AccessTokenDto> { // public async refresh(request: Request): Promise<AccessTokenDto> {
const sessionId = request.cookies['session_id']; // const sessionId = request.cookies['session_id'];
if (!sessionId) { // if (!sessionId) {
throw new ForbiddenException('Session ID missing'); // throw new ForbiddenException('Session ID missing');
} // }
const session: Session = // const session: Session =
await this.sessionService.findSessionBySessionId(sessionId); // await this.sessionService.findSessionBySessionId(sessionId);
if (!session) { // if (!session) {
throw new ForbiddenException('Invalid session'); // throw new ForbiddenException('Invalid session');
} // }
const isUserAgentValid = await this.sessionService.validateSessionUserAgent( // const isUserAgentValid = await this.sessionService.validateSessionUserAgent(
sessionId, // sessionId,
request.headers['user-agent'] // request.headers['user-agent']
); // );
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); // 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 // false
); // );
return { access_token: newTokens.access_token }; // return { access_token: newTokens.access_token };
} // }
private async generateAndPersistTokens( // private async generateAndPersistTokens(
userId: string, // userId: string,
email: string, // email: string,
updateRefreshToken: boolean = false // updateRefreshToken: boolean = false
): Promise<LoginResponseDto> { // ): Promise<LoginResponseDto> {
const tokens = await this.tokenManagementService.generateTokens( // const tokens = await this.tokenManagementService.generateTokens(
userId, // userId,
email // email
); // );
if (updateRefreshToken) { // if (updateRefreshToken) {
await this.userCredentialsRepository.updateUserRefreshToken( // await this.userCredentialsRepository.updateUserRefreshToken(
userId, // userId,
tokens.refresh_token // 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.refreshToken) { // 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.refreshToken // user.refreshToken
); // );
if (decodedToken.exp < Date.now() / 1000) { // if (decodedToken.exp < Date.now() / 1000) {
throw new Error('Token expired'); // throw new Error('Token expired');
} // }
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

@ -43,6 +43,10 @@ export class SessionService {
return this.sessionRepository.findSessionBySessionId(sessionId); return this.sessionRepository.findSessionBySessionId(sessionId);
} }
public findSessionByUserId(userId: string): Promise<Session[]> {
return this.sessionRepository.findSessionByUserId(userId);
}
public attachSessionToResponse(response: Response, sessionId: string): void { public attachSessionToResponse(response: Response, sessionId: string): void {
this.sessionRepository.attachSessionToResponse(response, sessionId); this.sessionRepository.attachSessionToResponse(response, sessionId);
} }

View File

@ -1,27 +0,0 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, ExtractJwt } from 'passport-jwt';
import { JwtPayload } from '../models/types';
@Injectable()
export class AccessTokenStrategy extends PassportStrategy(
Strategy,
'jwt-access-token'
) {
public constructor(private readonly configService: ConfigService) {
super(AccessTokenStrategy.getJwtConfig(configService));
}
private static getJwtConfig(configService: ConfigService): any {
return {
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: configService.get<string>('JWT_SECRET_AT'),
};
}
public async validate(payload: JwtPayload): Promise<JwtPayload> {
return payload;
}
}

View File

@ -1,2 +0,0 @@
export * from './access-token.strategie';
export * from './refresh-token.strategie';

View File

@ -1,39 +0,0 @@
import { Injectable, ForbiddenException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { Request } from 'express';
import { Strategy, ExtractJwt } from 'passport-jwt';
@Injectable()
export class RefreshTokenStrategy extends PassportStrategy(
Strategy,
'jwt-refresh-token'
) {
public constructor(private readonly configService: ConfigService) {
super(RefreshTokenStrategy.createJwtStrategyOptions(configService));
}
private static createJwtStrategyOptions(configService: ConfigService): any {
return {
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: configService.get<string>('JWT_SECRET_RT'),
passReqToCallback: true,
};
}
public async validate(req: Request, payload: any) {
const refresh_token: string = req
?.get('authorization')
?.replace('Bearer', '')
.trim();
if (!refresh_token) {
throw new ForbiddenException('Refresh token malformed');
}
return {
...payload,
refresh_token,
};
}
}