diff --git a/src/app.module.ts b/src/app.module.ts index 72ccce4..33523cf 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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'; @@ -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 {} diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 2767f86..964f9a2 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -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 { diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 3c15b14..2a2fb21 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -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, @@ -26,7 +32,7 @@ import { JwtStrategy } from './strategies/jwt.strategy'; }), ], controllers: [AuthController], - providers: [AuthService, JwtStrategy], + providers: [AuthService], exports: [AuthService, JwtModule], }) export class AuthModule {} diff --git a/src/auth/decorators/public.decorator.ts b/src/auth/decorators/public.decorator.ts new file mode 100644 index 0000000..e787729 --- /dev/null +++ b/src/auth/decorators/public.decorator.ts @@ -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); diff --git a/src/auth/guards/jwt-auth.guard.ts b/src/auth/guards/jwt-auth.guard.ts index b711bc7..996050c 100644 --- a/src/auth/guards/jwt-auth.guard.ts +++ b/src/auth/guards/jwt-auth.guard.ts @@ -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(); - const token = this.extractToken(request); - if (!token) throw new UnauthorizedException('Missing access token'); + canActivate(context: ExecutionContext) { + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); - try { - request.user = this.jwtService.verify(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; } } diff --git a/src/auth/jwt-auth.guard.ts b/src/auth/jwt-auth.guard.ts deleted file mode 100644 index 963b063..0000000 --- a/src/auth/jwt-auth.guard.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Injectable, ExecutionContext, UnauthorizedException } from '@nestjs/common'; -import { AuthGuard } from '@nestjs/passport'; -import { Reflector } from '@nestjs/core'; -import { IS_PUBLIC_KEY } from '../decorators/public.decorator'; - -@Injectable() -export class JwtAuthGuard extends AuthGuard('jwt') { - constructor(private reflector: Reflector) { - super(); - } - - canActivate(context: ExecutionContext) { - const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ - context.getHandler(), - context.getClass(), - ]); - - if (isPublic) { - return true; - } - - return super.canActivate(context); - } - - handleRequest(err: any, user: any, info: any) { - if (err || !user) { - throw err || new UnauthorizedException('Invalid or expired token'); - } - return user; - } -} -import { Injectable } from '@nestjs/common'; -import { AuthGuard } from '@nestjs/passport'; - -@Injectable() -export class JwtAuthGuard extends AuthGuard('jwt') {} diff --git a/src/auth/jwt-auth.module.ts b/src/auth/jwt-auth.module.ts index 260909c..22db3a1 100644 --- a/src/auth/jwt-auth.module.ts +++ b/src/auth/jwt-auth.module.ts @@ -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({ diff --git a/src/auth/jwt.strategy.ts b/src/auth/jwt.strategy.ts index 0b5c348..89041b4 100644 --- a/src/auth/jwt.strategy.ts +++ b/src/auth/jwt.strategy.ts @@ -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) { @@ -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 { 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, + }; } } diff --git a/src/auth/strategies/jwt.strategy.ts b/src/auth/strategies/jwt.strategy.ts deleted file mode 100644 index 221efca..0000000 --- a/src/auth/strategies/jwt.strategy.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Injectable, UnauthorizedException } from '@nestjs/common'; -import { PassportStrategy } from '@nestjs/passport'; -import { ExtractJwt, Strategy } from 'passport-jwt'; -import { ConfigService } from '@nestjs/config'; -import { UsersService } from '../../users/users.service'; - -@Injectable() -export class JwtStrategy extends PassportStrategy(Strategy) { - constructor( - private readonly configService: ConfigService, - private readonly usersService: UsersService, - ) { - super({ - jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), - ignoreExpiration: false, - secretOrKey: configService.get('JWT_SECRET'), - }); - } - - async validate(payload: any) { - const user = await this.usersService.findById(payload.sub); - if (!user) { - throw new UnauthorizedException('User not found'); - } - return user; - } -} diff --git a/src/decorators/public.decorator.ts b/src/decorators/public.decorator.ts deleted file mode 100644 index b3845e1..0000000 --- a/src/decorators/public.decorator.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { SetMetadata } from '@nestjs/common'; - -export const IS_PUBLIC_KEY = 'isPublic'; -export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); diff --git a/src/health/health.controller.ts b/src/health/health.controller.ts index 9c2ce81..a8fb36a 100644 --- a/src/health/health.controller.ts +++ b/src/health/health.controller.ts @@ -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() diff --git a/src/kyc/kyc.controller.ts b/src/kyc/kyc.controller.ts index f5d7e06..b3538d4 100644 --- a/src/kyc/kyc.controller.ts +++ b/src/kyc/kyc.controller.ts @@ -2,7 +2,6 @@ import { Controller, Post, UseGuards, - Request, Body, UseInterceptors, UploadedFile, @@ -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'; @@ -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, @@ -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, ); diff --git a/src/kyc/kyc.service.ts b/src/kyc/kyc.service.ts index 4f27f4e..719785a 100644 --- a/src/kyc/kyc.service.ts +++ b/src/kyc/kyc.service.ts @@ -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; } @@ -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; diff --git a/src/users/kyc.service.ts b/src/users/kyc.service.ts deleted file mode 100644 index 2018b88..0000000 --- a/src/users/kyc.service.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { Injectable, ConflictException, NotFoundException } from '@nestjs/common'; -import { InjectModel } from '@nestjs/mongoose'; -import { Model } from 'mongoose'; -import { Kyc, KycDocument, KycReviewStatus } from './schemas/kyc.schema'; -import { User, UserDocument, KycStatus } from './schemas/user.schema'; - -@Injectable() -export class KycService { - constructor( - @InjectModel(Kyc.name) private readonly kycModel: Model, - @InjectModel(User.name) private readonly userModel: Model, - ) {} - - async findByUserId(userId: string): Promise { - return this.kycModel.findOne({ userId }).exec(); - } - - async create(userId: string, documentType: string, documentUrl: string): Promise { - // Check if user exists - const user = await this.userModel.findById(userId); - if (!user) { - throw new NotFoundException('User not found'); - } - - // Check if user already has a pending or approved KYC submission - const existingKyc = await this.findByUserId(userId); - if (existingKyc && (existingKyc.status === KycReviewStatus.PENDING || existingKyc.status === KycReviewStatus.APPROVED)) { - throw new ConflictException('KYC document already submitted or approved'); - } - - // Create new KYC submission - const kyc = await this.kycModel.create({ - userId, - documentType, - documentUrl, - status: KycReviewStatus.PENDING, - }); - - // Update user's kycStatus to pending - await this.userModel.findByIdAndUpdate(userId, { kycStatus: KycStatus.PENDING }); - - return kyc; - } -} diff --git a/src/users/schemas/kyc.schema.ts b/src/users/schemas/kyc.schema.ts deleted file mode 100644 index 7392d1e..0000000 --- a/src/users/schemas/kyc.schema.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; -import { Document, Types } from 'mongoose'; - -export type KycDocument = Kyc & Document; - -export enum KycDocumentType { - PASSPORT = 'passport', - NATIONAL_ID = 'national_id', - DRIVERS_LICENSE = 'drivers_license', -} - -export enum KycReviewStatus { - PENDING = 'pending', - APPROVED = 'approved', - REJECTED = 'rejected', -} - -@Schema({ - collection: 'kyc', - timestamps: true, - toJSON: { - virtuals: true, - transform: (_doc, ret: Record) => { - const idSource = ret._id as { toString(): string } | undefined; - ret.id = idSource?.toString(); - delete ret._id; - delete ret.__v; - return ret; - }, - }, -}) -export class Kyc { - @Prop({ type: Schema.Types.ObjectId, required: true, ref: 'User' }) - userId!: Types.ObjectId; - - @Prop({ type: String, enum: Object.values(KycDocumentType), required: true }) - documentType!: KycDocumentType; - - @Prop({ type: String, required: true }) - documentUrl!: string; - - @Prop({ type: String, enum: Object.values(KycReviewStatus), default: KycReviewStatus.PENDING }) - status!: KycReviewStatus; - - @Prop({ type: Date, default: null }) - reviewedAt!: Date | null; - - @Prop({ type: String, default: null }) - reviewNote!: string | null; - - @Prop({ type: Date }) - createdAt!: Date; - - @Prop({ type: Date }) - updatedAt!: Date; -} - -export const KycSchema = SchemaFactory.createForClass(Kyc); - -KycSchema.index({ userId: 1 }); diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index 3e86bc5..43a807a 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -1,23 +1,15 @@ import { Controller, Get, - Post, UseGuards, - Request, - UploadedFile, - UseInterceptors, - BadRequestException, Res, - HttpCode, HttpStatus, } from '@nestjs/common'; -import { FileInterceptor } from '@nestjs/platform-express'; import { Response } from 'express'; -import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { JwtAuthGuard, JwtPayload } from '../auth/guards/jwt-auth.guard'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { UsersService } from './users.service'; -import { KycService } from './kyc.service'; -import { KycDocumentType } from './schemas/kyc.schema'; import { sendSuccess, sendError } from '../utils/response.util'; @Controller('users') @@ -25,16 +17,18 @@ import { sendSuccess, sendError } from '../utils/response.util'; export class UsersController { constructor( private readonly usersService: UsersService, - private readonly kycService: KycService, ) {} @Get('me/kyc') async getKycStatus( - @Request() req: any, + @CurrentUser() currentUser: JwtPayload, @Res() res: Response, ): Promise { try { - const user = req.user; + const user = await this.usersService.findById(currentUser.sub); + if (!user) { + return sendError(res, 'User not found', HttpStatus.NOT_FOUND); + } if (!user.kycSubmissionDate) { return sendError(res, 'No KYC submission found', HttpStatus.NOT_FOUND); @@ -57,87 +51,4 @@ export class UsersController { ); } } - - @Post('me/kyc') - @HttpCode(HttpStatus.CREATED) - @UseInterceptors( - FileInterceptor('document', { - fileFilter: (req, file, callback) => { - const allowedMimes = [ - 'application/pdf', - 'image/jpeg', - 'image/jpg', - 'image/png', - ]; - - if (allowedMimes.includes(file.mimetype)) { - callback(null, true); - } else { - callback( - new BadRequestException( - 'Only PDF, JPG, and PNG files are allowed', - ), - false, - ); - } - }, - limits: { - fileSize: 5 * 1024 * 1024, - }, - }), - ) - async submitKyc( - @Request() req: any, - @UploadedFile() file: Express.Multer.File, - @Res() res: Response, - ): Promise { - try { - if (!file) { - return sendError( - res, - 'Document file is required', - HttpStatus.BAD_REQUEST, - ); - } - - const documentType = req.body.documentType; - - if ( - !documentType || - !Object.values(KycDocumentType).includes(documentType) - ) { - return sendError( - res, - 'Invalid document type. Must be one of: passport, national_id, drivers_license', - HttpStatus.BAD_REQUEST, - ); - } - - const documentUrl = file.path; - - const kyc = await this.kycService.create( - req.user._id.toString(), - documentType, - documentUrl, - ); - - return sendSuccess( - res, - { - id: kyc._id?.toString(), - documentType: kyc.documentType, - status: kyc.status, - submittedAt: kyc.createdAt, - }, - 'KYC document submitted successfully', - HttpStatus.CREATED, - ); - } catch (err) { - if (err instanceof BadRequestException) { - return sendError(res, err.message, HttpStatus.BAD_REQUEST); - } - - throw err; - } - } -} \ No newline at end of file +} diff --git a/src/users/users.module.ts b/src/users/users.module.ts index 82ab7bf..8dda21c 100644 --- a/src/users/users.module.ts +++ b/src/users/users.module.ts @@ -2,23 +2,18 @@ import { Module } from '@nestjs/common'; import { MongooseModule } from '@nestjs/mongoose'; import { User, UserSchema } from './schemas/user.schema'; -import { Kyc, KycSchema } from './schemas/kyc.schema'; import { UsersService } from './users.service'; -import { KycService } from './kyc.service'; import { UsersController } from './users.controller'; -import { JwtAuthModule } from '../auth/jwt-auth.module'; @Module({ imports: [ - JwtAuthModule, MongooseModule.forFeature([ { name: User.name, schema: UserSchema }, - { name: Kyc.name, schema: KycSchema }, ]), ], controllers: [UsersController], - providers: [UsersService, KycService], - exports: [UsersService, KycService, MongooseModule], + providers: [UsersService], + exports: [UsersService, MongooseModule], }) -export class UsersModule {} \ No newline at end of file +export class UsersModule {}