Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@
"moment": "^2.29.4",
"multer": "^2.0.2",
"nest-winston": "^1.10.2",
"nestjs-i18n": "^10.4.0",
"opossum": "^9.0.0",
"passport": "^0.7.0",
"passport-custom": "^1.1.1",
Expand Down Expand Up @@ -188,4 +189,4 @@
"url": "https://github.com/MettaChain/PropChain-BackEnd/issues"
},
"homepage": "https://github.com/MettaChain/PropChain-BackEnd#readme"
}
}
21 changes: 21 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ import { LoggingInterceptor } from './common/logging/logging.interceptor';
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
import { AllExceptionsFilter } from './common/errors/error.filter';

// I18n
import { I18nModule, AcceptLanguageResolver, QueryResolver, HeaderResolver } from 'nestjs-i18n';
import * as path from 'path';

// Redis
import { RedisModule } from './common/services/redis.module';
import { createRedisConfig } from './common/services/redis.config';
Expand Down Expand Up @@ -59,6 +63,23 @@ import { ObservabilityModule } from './observability/observability.module';
}),
ConfigurationModule,

// I18n
I18nModule.forRootAsync({
useFactory: (configService: ConfigService) => ({
fallbackLanguage: configService.getOrThrow('FALLBACK_LANGUAGE', 'en'),
loaderOptions: {
path: path.join(__dirname, '/i18n/'),
watch: true,
},
}),
resolvers: [
{ use: QueryResolver, options: ['lang'] },
AcceptLanguageResolver,
new HeaderResolver(['x-lang']),
],
inject: [ConfigService],
}),

// Caching
CacheModule,

Expand Down
31 changes: 13 additions & 18 deletions src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ import {
ResetPasswordDto,
VerifyEmailParamsDto,
} from './dto';
import { ErrorResponseDto } from '../common/errors/error.dto';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiParam } from '@nestjs/swagger';
import { Request } from 'express';
import { ApiStandardErrorResponse } from '../common/errors/api-standard-error-response.decorator';

/**
* AuthController
Expand All @@ -27,7 +27,7 @@ import { Request } from 'express';
@ApiTags('authentication')
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
constructor(private authService: AuthService) { }

/**
* Register a new user account
Expand Down Expand Up @@ -63,12 +63,7 @@ export class AuthController {
},
},
})
@ApiResponse({ status: 409, description: 'User with this email already exists.', type: ErrorResponseDto })
@ApiResponse({
status: 400,
description: 'Validation failed (weak password, invalid email, etc).',
type: ErrorResponseDto,
})
@ApiStandardErrorResponse([400, 409])
async register(@Body() createUserDto: CreateUserDto) {
return this.authService.register(createUserDto);
}
Expand Down Expand Up @@ -101,8 +96,7 @@ export class AuthController {
},
},
})
@ApiResponse({ status: 401, description: 'Invalid credentials or too many attempts.', type: ErrorResponseDto })
@ApiResponse({ status: 400, description: 'Validation failed.', type: ErrorResponseDto })
@ApiStandardErrorResponse([400, 401])
@HttpCode(HttpStatus.OK)
async login(@Body() loginDto: LoginDto, @Req() req: Request) {
return this.authService.login({
Expand Down Expand Up @@ -137,7 +131,7 @@ export class AuthController {
},
},
})
@ApiResponse({ status: 401, description: 'Invalid wallet address or signature.', type: ErrorResponseDto })
@ApiStandardErrorResponse([401])
@HttpCode(HttpStatus.OK)
async web3Login(@Body() loginDto: LoginWeb3Dto) {
return this.authService.login({
Expand Down Expand Up @@ -171,7 +165,7 @@ export class AuthController {
},
},
})
@ApiResponse({ status: 401, description: 'Invalid or expired refresh token.', type: ErrorResponseDto })
@ApiStandardErrorResponse([401])
@HttpCode(HttpStatus.OK)
async refreshToken(@Body() refreshTokenDto: RefreshTokenDto) {
return this.authService.refreshToken(refreshTokenDto.refreshToken);
Expand Down Expand Up @@ -202,7 +196,7 @@ export class AuthController {
},
},
})
@ApiResponse({ status: 401, description: 'Unauthorized - invalid or missing token.', type: ErrorResponseDto })
@ApiStandardErrorResponse([401])
@HttpCode(HttpStatus.OK)
async logout(@Req() req: Request) {
const user = req['user'] as any;
Expand Down Expand Up @@ -262,7 +256,7 @@ export class AuthController {
},
},
})
@ApiResponse({ status: 400, description: 'Invalid, expired, or already-used reset token.', type: ErrorResponseDto })
@ApiStandardErrorResponse([400])
@HttpCode(HttpStatus.OK)
async resetPassword(@Body() resetPasswordDto: ResetPasswordDto) {
return this.authService.resetPassword(resetPasswordDto.token, resetPasswordDto.newPassword);
Expand Down Expand Up @@ -296,6 +290,7 @@ export class AuthController {
description: 'Invalid, expired, or already-used verification token.',
type: ErrorResponseDto,
})
@ApiStandardErrorResponse([400])
async verifyEmail(@Param() params: VerifyEmailParamsDto) {
return this.authService.verifyEmail(params.token);
}
Expand Down Expand Up @@ -333,7 +328,7 @@ export class AuthController {
},
},
})
@ApiResponse({ status: 401, description: 'Unauthorized.', type: ErrorResponseDto })
@ApiStandardErrorResponse([401])
@HttpCode(HttpStatus.OK)
async getSessions(@Req() req: Request) {
const user = req['user'] as any;
Expand Down Expand Up @@ -366,7 +361,7 @@ export class AuthController {
},
},
})
@ApiResponse({ status: 401, description: 'Unauthorized.', type: ErrorResponseDto })
@ApiStandardErrorResponse([401])
@HttpCode(HttpStatus.OK)
async invalidateSession(@Req() req: Request, @Param('sessionId') sessionId: string) {
const user = req['user'] as any;
Expand Down Expand Up @@ -399,7 +394,7 @@ export class AuthController {
},
},
})
@ApiResponse({ status: 401, description: 'Unauthorized.', type: ErrorResponseDto })
@ApiStandardErrorResponse([401])
@HttpCode(HttpStatus.OK)
async invalidateAllSessions(@Req() req: Request) {
const user = req['user'] as any;
Expand Down
27 changes: 14 additions & 13 deletions src/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Injectable, UnauthorizedException, BadRequestException } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { UnauthorizedException, InvalidCredentialsException, TokenExpiredException, InvalidInputException, UserNotFoundException } from '../common/errors/custom.exceptions';
import { UserService } from '../users/user.service';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
Expand Down Expand Up @@ -64,7 +65,7 @@ export class AuthService {
} else if (credentials.walletAddress) {
user = await this.validateUserByWallet(credentials.walletAddress, credentials.signature);
} else {
throw new BadRequestException('Email/password or wallet address/signature required');
throw new InvalidInputException(undefined, 'Email/password or wallet address/signature required');
}

if (!user) {
Expand All @@ -75,7 +76,7 @@ export class AuthService {
const attempts = parseInt(existing || '0', 10) + 1;
await this.redisService.setex(attemptsKey, attemptWindow, attempts.toString());
}
throw new UnauthorizedException('Invalid credentials');
throw new InvalidCredentialsException();
}

// successful login, clear attempts
Expand All @@ -99,13 +100,13 @@ export class AuthService {

if (!user || !user.password) {
this.logger.warn('Email validation failed: User not found', { email });
throw new UnauthorizedException('Invalid credentials');
throw new InvalidCredentialsException();
}

const isPasswordValid = await bcrypt.compare(password, user.password);
if (!isPasswordValid) {
this.logger.warn('Email validation failed: Invalid password', { email });
throw new UnauthorizedException('Invalid credentials');
throw new InvalidCredentialsException();
}

const { password: _, ...result } = user as any;
Expand Down Expand Up @@ -141,22 +142,22 @@ export class AuthService {
this.logger.warn('Refresh token validation failed: User not found', {
userId: payload.sub,
});
throw new UnauthorizedException('User not found');
throw new UserNotFoundException(payload.sub);
}

const storedToken = await this.redisService.get(`refresh_token:${payload.sub}`);
if (storedToken !== refreshToken) {
this.logger.warn('Refresh token validation failed: Invalid token', {
userId: payload.sub,
});
throw new UnauthorizedException('Invalid refresh token');
throw new TokenExpiredException('Invalid refresh token');
}

this.logger.logAuth('Token refreshed successfully', { userId: user.id });
return this.generateTokens(user);
} catch (error) {
this.logger.error('Token refresh failed', error.stack);
throw new UnauthorizedException('Invalid refresh token');
throw new TokenExpiredException('Invalid refresh token');
}
}

Expand All @@ -177,7 +178,7 @@ export class AuthService {
}
}


// === REFRESH TOKEN REVOCATION ===
// Prevents token refresh even if JWT signature is still valid

Expand Down Expand Up @@ -212,15 +213,15 @@ export class AuthService {

if (!resetData) {
this.logger.warn('Invalid or expired password reset token received');
throw new BadRequestException('Invalid or expired reset token');
throw new InvalidInputException(undefined, 'Invalid or expired reset token');
}

const { userId, expiry } = JSON.parse(resetData);

if (Date.now() > expiry) {
await this.redisService.del(`password_reset:${resetToken}`);
this.logger.warn('Expired password reset token used', { userId });
throw new BadRequestException('Reset token has expired');
throw new InvalidInputException(undefined, 'Reset token has expired');
}

await this.userService.updatePassword(userId, newPassword);
Expand All @@ -235,7 +236,7 @@ export class AuthService {

if (!verificationData) {
this.logger.warn('Invalid or expired email verification token');
throw new BadRequestException('Invalid or expired verification token');
throw new InvalidInputException(undefined, 'Invalid or expired verification token');
}

const { userId } = JSON.parse(verificationData);
Expand Down Expand Up @@ -311,7 +312,7 @@ export class AuthService {
const payload = {
sub: user.id, // Subject (user ID)
email: user.email,
jti, // JWT ID for blacklisting
jti: jti // JWT ID for blacklisting
};

const accessToken = this.jwtService.sign(payload, {
Expand Down
17 changes: 17 additions & 0 deletions src/common/errors/api-standard-error-response.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { applyDecorators } from '@nestjs/common';
import { ApiResponse, getSchemaPath } from '@nestjs/swagger';
import { ErrorResponseDto } from './error.dto';

export const ApiStandardErrorResponse = (statusCodes: number[]) => {
const responses = statusCodes.map((status) =>
ApiResponse({
status,
description: `Standardized Error Response (${status})`,
schema: {
$ref: getSchemaPath(ErrorResponseDto),
},
}),
);

return applyDecorators(...responses);
};
6 changes: 6 additions & 0 deletions src/common/errors/custom.exceptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ export class InsufficientPermissionsException extends BaseCustomException {
}

// Resource Exceptions
export class NotFoundException extends BaseCustomException {
constructor(message?: string) {
super(ErrorCode.RESOURCE_NOT_FOUND, message || 'Resource not found', undefined, HttpStatus.NOT_FOUND);
}
}

export class ResourceNotFoundException extends BaseCustomException {
constructor(resourceType?: string, message?: string) {
const customMessage = message || (resourceType ? `${resourceType} not found` : undefined);
Expand Down
12 changes: 12 additions & 0 deletions src/common/errors/error.filter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ConfigService } from '@nestjs/config';
import { StructuredLoggerService } from '../../../src/common/logging/logger.service';
import { HttpException, HttpStatus, ArgumentsHost } from '@nestjs/common';
import { ErrorCode } from '../../../src/common/errors/error.codes';
import { I18nService } from 'nestjs-i18n';

describe('AppExceptionFilter', () => {
let filter: AppExceptionFilter;
Expand Down Expand Up @@ -44,6 +45,17 @@ describe('AppExceptionFilter', () => {
error: jest.fn(),
},
},
{
provide: I18nService,
useValue: {
translate: jest.fn().mockImplementation((key) => {
if (key === `errors.${ErrorCode.VALIDATION_ERROR}`) {
return 'The provided data is invalid';
}
return key;
}),
},
},
],
}).compile();

Expand Down
Loading
Loading