Skip to content
Open
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
9 changes: 9 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { APP_GUARD } from '@nestjs/core';
import * as Joi from 'joi';
import { AuthModule } from './auth/auth.module';
import { JwtAuthModule } from './auth/jwt-auth.module';
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
import { AdminModule } from './admin/admin.module';
import { HealthModule } from './health/health.module';
import { KycModule } from './kyc/kyc.module';
Expand Down Expand Up @@ -73,10 +74,18 @@ import { KycModule } from './kyc/kyc.module';
KycModule,
],
providers: [
// APP_GUARDs fire in declared order. ThrottlerGuard runs first so
// every request counts toward rate limits (including 401s on
// unverified requests). JwtAuthGuard runs second and short-circuits
// for endpoints marked @Public().
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
],
})
export class AppModule {}
2 changes: 2 additions & 0 deletions src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@ import {
import { Response } from 'express';
import { AuthService } from './auth.service';
import { RegisterDto } from './dto/register.dto';
import { Public } from './decorators/public.decorator';
import { sendError, sendSuccess } from '../utils/response.util';

@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}

@Public()
@Post('register')
@HttpCode(HttpStatus.CREATED)
async register(@Body() dto: RegisterDto, @Res() res: Response): Promise<Response> {
Expand Down
12 changes: 9 additions & 3 deletions src/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,17 @@ import { PassportModule } from '@nestjs/passport';
import { ConfigService } from '@nestjs/config';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './jwt.strategy';
import { UsersModule } from '../users/users.module';
import { MailModule } from '../mail/mail.module';
import { JwtStrategy } from './strategies/jwt.strategy';

/**
* Owns the register/login/refresh/logout/forgot/verify endpoints and
* exposes a configured `JwtModule` so downstream modules can sign access
* tokens without re-registering @nestjs/jwt.
*
* Authentication side-effects (verifying incoming tokens) are delegated
* to `JwtAuthModule` which provides `JwtStrategy` and `JwtAuthGuard`.
*/
@Module({
imports: [
UsersModule,
Expand All @@ -26,7 +32,7 @@ import { JwtStrategy } from './strategies/jwt.strategy';
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
providers: [AuthService],
exports: [AuthService, JwtModule],
})
export class AuthModule {}
13 changes: 13 additions & 0 deletions src/auth/decorators/public.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { SetMetadata } from '@nestjs/common';

export const IS_PUBLIC_KEY = 'isPublic';

/**
* Mark a route or controller as not requiring authentication.
*
* Combine with the project's `JwtAuthGuard` (which checks for this
* metadata via `Reflector`) so endpoints like `/auth/login` and
* `/auth/register` stay reachable while private endpoints still get
* JWT verification.
*/
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
69 changes: 38 additions & 31 deletions src/auth/guards/jwt-auth.guard.ts
Original file line number Diff line number Diff line change
@@ -1,49 +1,56 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import {
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { ExecutionContext } from '@nestjs/common';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
canActivate(context: ExecutionContext) {
return super.canActivate(context);
}

handleRequest(err: any, user: any, info: any) {
if (err || !user) {
throw err || new UnauthorizedException('Invalid or missing authentication token');
}
return user;
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Request } from 'express';
import { Reflector } from '@nestjs/core';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
import { UserRole } from '../../users/schemas/user.schema';

/**
* Shape of the user object attached to the request by `JwtStrategy`.
* Endpoints that inject `@CurrentUser()` should rely on this type so
* consumers (controllers, guards) share a single contract regardless of
* which passport strategy populated `req.user`.
*/
export interface JwtPayload {
sub: string;
email: string;
role: UserRole;
}

/**
* Single, project-wide JWT authentication guard. Honours the `@Public()`
* metadata shortcut from `decorators/public.decorator.ts` so endpoint
* authors can opt out of auth on a per-route basis without redeclaring
* guards.
*/
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(private readonly jwtService: JwtService) {}
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private readonly reflector: Reflector) {
super();
}

canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<Request & { user: JwtPayload }>();
const token = this.extractToken(request);
if (!token) throw new UnauthorizedException('Missing access token');
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);

try {
request.user = this.jwtService.verify<JwtPayload>(token);
if (isPublic) {
return true;
} catch {
throw new UnauthorizedException('Invalid or expired access token');
}

return super.canActivate(context);
}

private extractToken(request: Request): string | null {
const auth = request.headers.authorization;
if (!auth?.startsWith('Bearer ')) return null;
return auth.slice(7);
handleRequest(err: unknown, user: unknown, _info: unknown) {
if (err || !user) {
throw (
(err as Error) ?? new UnauthorizedException('Invalid or expired token')
);
}
return user;
}
}
36 changes: 0 additions & 36 deletions src/auth/jwt-auth.guard.ts

This file was deleted.

2 changes: 1 addition & 1 deletion src/auth/jwt-auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { JwtStrategy } from './jwt.strategy';
import { JwtAuthGuard } from './jwt-auth.guard';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { UsersModule } from '../users/users.module';

@Module({
Expand Down
10 changes: 8 additions & 2 deletions src/auth/jwt.strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import { UsersService } from '../users/users.service';
import { UserRole } from '../users/schemas/user.schema';
import { JwtPayload } from './guards/jwt-auth.guard';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
Expand All @@ -17,11 +19,15 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
});
}

async validate(payload: { sub: string; email: string }) {
async validate(payload: { sub: string; email: string }): Promise<JwtPayload> {
const user = await this.usersService.findById(payload.sub);
if (!user) {
throw new UnauthorizedException('User not found');
}
return user;
return {
sub: payload.sub,
email: payload.email,
role: user.role ?? UserRole.USER,
};
}
}
27 changes: 0 additions & 27 deletions src/auth/strategies/jwt.strategy.ts

This file was deleted.

4 changes: 0 additions & 4 deletions src/decorators/public.decorator.ts

This file was deleted.

2 changes: 2 additions & 0 deletions src/health/health.controller.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { Controller, Get } from '@nestjs/common';
import { SkipThrottle } from '@nestjs/throttler';
import { Public } from '../auth/decorators/public.decorator';

@SkipThrottle()
@Public()
@Controller('health')
export class HealthController {
@Get()
Expand Down
15 changes: 5 additions & 10 deletions src/kyc/kyc.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import {
Controller,
Post,
UseGuards,
Request,
Body,
UseInterceptors,
UploadedFile,
Expand All @@ -12,7 +11,8 @@ import {
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { Response } from 'express';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { JwtAuthGuard, JwtPayload } from '../auth/guards/jwt-auth.guard';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { KycService } from './kyc.service';
import { SubmitKycDto } from './dto/submit-kyc.dto';
import { sendError, sendSuccess } from '../utils/response.util';
Expand Down Expand Up @@ -52,7 +52,7 @@ export class KycController {
}),
)
async submitKyc(
@Request() req,
@CurrentUser() currentUser: JwtPayload,
@UploadedFile() file: Express.Multer.File,
@Body() body: SubmitKycDto,
@Res() res: Response,
Expand All @@ -62,15 +62,10 @@ export class KycController {
return sendError(res, 'Document file is required', HttpStatus.BAD_REQUEST);
}

const userId = req.user._id?.toString() || req.user.id;
if (!userId) {
return sendError(res, 'User ID not found in token', HttpStatus.UNAUTHORIZED);
}

const documentUrl = `/uploads/kyc/${file.filename}`;

const kyc = await this.kycService.create(
userId,
currentUser.sub,
body.documentType,
documentUrl,
);
Expand Down
30 changes: 21 additions & 9 deletions src/kyc/kyc.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,12 @@ export class KycService {
submittedAt: new Date(),
});

// Update user's KYC status to pending
await this.usersService.update(userId, { kycStatus: KycStatus.PENDING });
// Update user's KYC status to pending and stamp submission date so
// GET /users/me/kyc can report it without a second query.
await this.usersService.update(userId, {
kycStatus: KycStatus.PENDING,
kycSubmissionDate: new Date(),
});

return kyc;
}
Expand All @@ -65,14 +69,22 @@ export class KycService {
const kyc = await this.kycModel.findByIdAndUpdate(id, updateData, { new: true }).exec();

if (kyc) {
// Update user's KYC status based on review result
const userKycStatus = status === KycReviewStatus.APPROVED
? KycStatus.APPROVED
: status === KycReviewStatus.REJECTED
? KycStatus.REJECTED
// Update user's KYC status based on review result and, when the
// reviewer left a note, surface it on the user so the GET
// /users/me/kyc response can include it.
const userKycStatus = status === KycReviewStatus.APPROVED
? KycStatus.APPROVED
: status === KycReviewStatus.REJECTED
? KycStatus.REJECTED
: KycStatus.PENDING;

await this.usersService.update(kyc.userId, { kycStatus: userKycStatus });

// Last-reviewer-wins: clearing reviewNote on subsequent status
// changes is intentional so GET /users/me/kyc reflects the
// most recent review verbatim.
await this.usersService.update(kyc.userId, {
kycStatus: userKycStatus,
kycReviewNotes: reviewNote ?? null,
});
}

return kyc;
Expand Down
Loading