error-handling (#18)

Reviewed-on: #18
Co-authored-by: Igor Propisnov <info@igor-propisnov.com>
Co-committed-by: Igor Propisnov <info@igor-propisnov.com>
This commit is contained in:
Igor Hrenowitsch Propisnov 2024-09-08 18:01:17 +02:00 committed by Igor Hrenowitsch Propisnov
parent 61dcf21730
commit 8e82733dd0
13 changed files with 266 additions and 74 deletions

View File

@ -8,6 +8,8 @@ import * as cookieParser from 'cookie-parser';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { SessionInitService } from './modules/session/services'; import { SessionInitService } from './modules/session/services';
import { HttpExceptionFilter } from './shared/filters';
import { ErrorHandlingInterceptor } from './shared/interceptors';
async function setupSwagger(app: INestApplication): Promise<void> { async function setupSwagger(app: INestApplication): Promise<void> {
const config = new DocumentBuilder() const config = new DocumentBuilder()
@ -50,12 +52,22 @@ async function setupClassValidator(app: INestApplication): Promise<void> {
app.useGlobalPipes(new ValidationPipe()); app.useGlobalPipes(new ValidationPipe());
} }
async function setupGlobalFilters(app: INestApplication): Promise<void> {
app.useGlobalFilters(new HttpExceptionFilter());
}
async function setupGlobalInterceptors(app: INestApplication): Promise<void> {
app.useGlobalInterceptors(new ErrorHandlingInterceptor());
}
async function bootstrap(): Promise<void> { async function bootstrap(): Promise<void> {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
await setupCookieParser(app); await setupCookieParser(app);
await setupSwagger(app); await setupSwagger(app);
await setupPrefix(app); await setupPrefix(app);
await setupGlobalFilters(app);
await setupGlobalInterceptors(app);
await setupClassValidator(app); await setupClassValidator(app);
await setupSessions(app); await setupSessions(app);
await app.listen(3000); await app.listen(3000);

View File

@ -1,13 +1,12 @@
import { import { Injectable } from '@nestjs/common';
ConflictException,
ForbiddenException,
HttpException,
HttpStatus,
Injectable,
} from '@nestjs/common';
import { UserCredentials } from 'src/entities'; import { UserCredentials } from 'src/entities';
import { SessionService } from 'src/modules/session/services/session.service'; import { SessionService } from 'src/modules/session/services/session.service';
import { EncryptionService, SuccessDto } from 'src/shared'; import { EncryptionService, SuccessDto } from 'src/shared';
import {
ConflictException,
ForbiddenException,
InternalServerErrorException,
} from 'src/shared/exceptions';
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';
@ -34,7 +33,7 @@ export class AuthService {
); );
if (existingUser) { if (existingUser) {
throw new ConflictException('User already exists'); throw new ConflictException('USER_ALREADY_EXISTS');
} }
const passwordHashed = await EncryptionService.hashData( const passwordHashed = await EncryptionService.hashData(
@ -63,14 +62,11 @@ export class AuthService {
}; };
} catch (error) { } catch (error) {
if (error instanceof ConflictException) { if (error instanceof ConflictException) {
throw new ConflictException( throw error;
'User already exists. Please try to login instead.'
);
} else { } else {
throw new HttpException( throw new InternalServerErrorException('SIGNUP_ERROR', {
'Error while signing up', cause: error,
HttpStatus.INTERNAL_SERVER_ERROR });
);
} }
} }
} }
@ -83,7 +79,7 @@ export class AuthService {
const user = await this.userCredentialsRepository.findUserByEmail(email); const user = await this.userCredentialsRepository.findUserByEmail(email);
if (!user) { if (!user) {
throw new ForbiddenException('Access Denied'); throw new ForbiddenException('INVALID_CREDENTIALS');
} }
const passwordMatch = await EncryptionService.compareHash( const passwordMatch = await EncryptionService.compareHash(
@ -92,60 +88,61 @@ export class AuthService {
); );
if (!passwordMatch) { if (!passwordMatch) {
throw new ForbiddenException('Access Denied'); throw new ForbiddenException('INVALID_CREDENTIALS');
} }
return user; return user;
} catch (error) { } catch (error) {
if (error instanceof ForbiddenException) { if (error instanceof ForbiddenException) {
throw new ForbiddenException( throw error;
'E-Mail address or password is incorrect. Please try again.'
);
} else { } else {
throw new HttpException( throw new InternalServerErrorException('VALIDATION_ERROR', {
'Error while validating user credentials', cause: error,
HttpStatus.INTERNAL_SERVER_ERROR });
);
} }
} }
} }
public async signout(sessionId: string): Promise<{ success: boolean }> { public async signout(sessionId: string): Promise<SuccessDto> {
try { try {
this.sessionService.deleteSessionBySessionId(sessionId); await this.sessionService.deleteSessionBySessionId(sessionId);
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
throw new HttpException( throw new InternalServerErrorException('SIGNOUT_ERROR', {
'Fehler beim Logout', message:
HttpStatus.INTERNAL_SERVER_ERROR 'An error occurred during the sign out process. Please try again later.',
); });
} }
} }
public async checkAuthStatus( public async checkAuthStatus(
sessionId: string, sessionId: string,
userAgend: string userAgent: string
): Promise<SuccessDto> { ): Promise<SuccessDto> {
try { try {
const session = const session =
await this.sessionService.findSessionBySessionId(sessionId); await this.sessionService.findSessionBySessionId(sessionId);
if (!session) { if (!session) {
throw new ForbiddenException('Session not found'); throw new ForbiddenException('SESSION_NOT_FOUND');
} }
const userAgendFromSession = JSON.parse(session.json).passport.user const userAgentFromSession = JSON.parse(session.json).passport.user
.userAgent; .userAgent;
if (userAgendFromSession !== userAgend) { if (userAgentFromSession !== userAgent) {
throw new ForbiddenException('User-Agent does not match'); throw new ForbiddenException('USER_AGENT_MISMATCH');
} }
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
throw new HttpException( if (error instanceof ForbiddenException) {
'Error while checking auth status', throw error;
HttpStatus.INTERNAL_SERVER_ERROR } else {
); throw new InternalServerErrorException('AUTH_STATUS_CHECK_ERROR', {
cause: error,
});
}
} }
} }

View File

@ -1,10 +1,10 @@
import { randomBytes } from 'crypto'; import { randomBytes } from 'crypto';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { EmailVerification } from 'src/entities'; import { EmailVerification } from 'src/entities';
import { SessionService } from 'src/modules/session/services/session.service'; import { SessionService } from 'src/modules/session/services/session.service';
import { UriEncoderService } from 'src/shared'; import { UriEncoderService } from 'src/shared';
import { InternalServerErrorException } from 'src/shared/exceptions';
import { UserDataRepository } from '../../user-module/repositories/user-data.repository'; import { UserDataRepository } from '../../user-module/repositories/user-data.repository';
import { EmailVerifyRepository } from '../repositories'; import { EmailVerifyRepository } from '../repositories';
@ -14,63 +14,81 @@ export class EmailVerificationService {
public constructor( public constructor(
private readonly emailVerifyRepository: EmailVerifyRepository, private readonly emailVerifyRepository: EmailVerifyRepository,
private readonly userDataRepository: UserDataRepository, private readonly userDataRepository: UserDataRepository,
private readonly sessionService: SessionService, private readonly sessionService: SessionService
private readonly configService: ConfigService
) {} ) {}
public async generateEmailVerificationToken(userId: string): Promise<string> { public async generateEmailVerificationToken(userId: string): Promise<string> {
const verificationToken = await this.createVerificationToken(); try {
const verificationToken = await this.createVerificationToken();
const expiration = new Date(Date.now() + 24 * 60 * 60 * 1000);
// TODO Check users local time zone and set expiration time accordingly await this.emailVerifyRepository.createEmailVerification(
const expiration = new Date(Date.now() + 24 * 60 * 60 * 1000); verificationToken,
expiration,
userId
);
this.emailVerifyRepository.createEmailVerification( return verificationToken;
verificationToken, } catch (error) {
expiration, throw new InternalServerErrorException(
userId 'EMAIL_VERIFICATION_TOKEN_GENERATION_ERROR',
); {
message:
return verificationToken; 'An error occurred while generating the email verification token.',
}
);
}
} }
public async verifyEmail(tokenToVerify: string): Promise<boolean> { public async verifyEmail(tokenToVerify: string): Promise<boolean> {
const isTokenVerified = try {
await this.emailVerifyRepository.findEmailVerificationByToken(
tokenToVerify
);
if (isTokenVerified) {
const emailVerification = const emailVerification =
await this.emailVerifyRepository.findEmailVerificationByToken(
tokenToVerify
);
if (!emailVerification) {
return false;
}
const deletedVerification =
await this.deleteEmailVerificationToken(tokenToVerify); await this.deleteEmailVerificationToken(tokenToVerify);
if (emailVerification && emailVerification.user) { if (deletedVerification && deletedVerification.user) {
const isStatusUpdated = const isStatusUpdated =
await this.userDataRepository.updateEmailVerificationStatus( await this.userDataRepository.updateEmailVerificationStatus(
emailVerification.user.id deletedVerification.user.id
); );
return isStatusUpdated; return isStatusUpdated;
} }
}
return false; return false;
} catch (error) {
throw new InternalServerErrorException('EMAIL_VERIFICATION_ERROR', {
message: 'An error occurred while verifying the email.',
});
}
} }
public async isEmailVerified(sessionID: string): Promise<boolean> { public async isEmailVerified(sessionID: string): Promise<boolean> {
const userId = await this.sessionService.getUserIdBySessionId(sessionID); try {
const userId = await this.sessionService.getUserIdBySessionId(sessionID);
if (!userId) { if (!userId) {
return false; return false;
}
const isVerified =
await this.userDataRepository.isEmailConfirmedByUserId(userId);
return isVerified;
} catch (error) {
throw new InternalServerErrorException('EMAIL_VERIFICATION_CHECK_ERROR', {
message:
'An error occurred while checking the email verification status.',
});
} }
const isVerfiied =
await this.userDataRepository.isEmailConfirmedByUserId(userId);
if (isVerfiied) {
return true;
}
return false;
} }
private async createVerificationToken(): Promise<string> { private async createVerificationToken(): Promise<string> {

View File

@ -0,0 +1,11 @@
export class BaseException extends Error {
public constructor(
public readonly message: string,
public readonly status: number,
public readonly error: string,
public readonly details?: unknown
) {
super(message);
this.name = this.constructor.name;
}
}

View File

@ -0,0 +1,9 @@
import { HttpStatus } from '@nestjs/common';
import { BaseException } from './base.exception';
export class ConflictException extends BaseException {
public constructor(errorCode: string, details?: unknown) {
super('Conflict Error', HttpStatus.CONFLICT, errorCode, details);
}
}

View File

@ -0,0 +1,9 @@
import { HttpStatus } from '@nestjs/common';
import { BaseException } from './base.exception';
export class ForbiddenException extends BaseException {
public constructor(errorCode: string, details?: unknown) {
super('Forbidden Error', HttpStatus.FORBIDDEN, errorCode, details);
}
}

View File

@ -0,0 +1,4 @@
export * from './conflict.exception';
export * from './forbidden.exception';
export * from './internal-server-error.exception';
export * from './not-found.exception';

View File

@ -0,0 +1,14 @@
import { HttpStatus } from '@nestjs/common';
import { BaseException } from './base.exception';
export class InternalServerErrorException extends BaseException {
public constructor(errorCode: string, details?: unknown) {
super(
'Internal Server Error',
HttpStatus.INTERNAL_SERVER_ERROR,
errorCode,
details
);
}
}

View File

@ -0,0 +1,9 @@
import { HttpStatus } from '@nestjs/common';
import { BaseException } from './base.exception';
export class NotFoundException extends BaseException {
public constructor(errorCode: string, details?: unknown) {
super('Not Found Error', HttpStatus.NOT_FOUND, errorCode, details);
}
}

View File

@ -0,0 +1,70 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { Response } from 'express';
import { BaseException } from '../exceptions/base.exception';
@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
private readonly logger: Logger = new Logger(HttpExceptionFilter.name);
public catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
let status: HttpStatus = HttpStatus.INTERNAL_SERVER_ERROR;
let message: string = 'Internal server error';
let error: string = 'INTERNAL_SERVER_ERROR';
let details: unknown = undefined;
if (exception instanceof BaseException) {
status = exception.status;
message = exception.message;
error = exception.error;
details = exception.details;
} else if (exception instanceof HttpException) {
status = exception.getStatus();
message = exception.message;
}
// Logging
this.logger.error(`${error}: ${message}`, exception);
// TODO: Error reporting (mock implementation)
this.reportError(error, message, details);
response.status(status).json({
status: status,
message,
error,
details: this.sanitizeErrorDetails(details),
timestamp: new Date().toISOString(),
});
}
private sanitizeErrorDetails(details: any): any {
if (details && typeof details === 'object') {
const sanitized = { ...details };
// TODO: Remove sensitive data
// delete sanitized.password;
// delete sanitized.creditCard;
return sanitized;
}
return details;
}
private reportError(errorCode: string, message: string, details: any): void {
console.log(`Error reported: ${errorCode} - ${message}`);
// TODO: Implement error reporting (i. e. Sentry)
// Example Sentry.captureException(new Error(`${errorCode}: ${message}`), { extra: details });
}
}

View File

@ -0,0 +1 @@
export * from './http-exception.filter';

View File

@ -0,0 +1,37 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
HttpException,
InternalServerErrorException,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { BaseException } from '../exceptions/base.exception';
@Injectable()
export class ErrorHandlingInterceptor implements NestInterceptor {
public intercept(
context: ExecutionContext,
next: CallHandler
): Observable<any> {
return next.handle().pipe(
catchError((error) => {
if (error instanceof BaseException) {
throw error;
} else if (error instanceof HttpException) {
throw error;
} else {
throw new InternalServerErrorException({
message: 'An unexpected error occurred',
errorCode: 'UNEXPECTED_ERROR',
details: { originalError: error.message },
});
}
})
);
}
}

View File

@ -0,0 +1 @@
export * from './error-handling.interceptor';