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..6aa7559 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -1,21 +1,29 @@ import { + BadRequestException, Body, ConflictException, Controller, HttpCode, HttpStatus, + Param, + Patch, Post, Res, + UseGuards, } from '@nestjs/common'; import { Response } from 'express'; import { AuthService } from './auth.service'; import { RegisterDto } from './dto/register.dto'; +import { ResetPasswordDto } from './dto/reset-password.dto'; +import { Public } from './decorators/public.decorator'; +import { AuthThrottlerGuard } from '../throttler/auth-throttler.guard'; 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 { @@ -38,4 +46,43 @@ export class AuthController { throw err; } } + + /** + * Issue #369 — `PATCH /auth/reset-password/:token`. + * + * Completes the password-reset flow that began when the user clicked + * the link in their "forgot password" email. The token arrives in the + * URL; the new password arrives in the body. + * + * Marked `@Public()` because the user has no access token at this + * point (the whole point of the flow is to recover one). + * + * `AuthThrottlerGuard` is layered on top of the global `ThrottlerGuard` + * to give this brute-force-prone endpoint an extra IP-budget cap + * (10 reqs / 15 min) in addition to whatever the global guard allows. + */ + @Public() + @UseGuards(AuthThrottlerGuard) + @Patch('reset-password/:token') + @HttpCode(HttpStatus.OK) + async resetPassword( + @Param('token') token: string, + @Body() dto: ResetPasswordDto, + @Res() res: Response, + ): Promise { + try { + await this.authService.resetPassword(token, dto.password); + return sendSuccess( + res, + undefined, + 'Password reset successfully. Please log in with your new password.', + HttpStatus.OK, + ); + } catch (err) { + if (err instanceof BadRequestException) { + return sendError(res, err.message, HttpStatus.BAD_REQUEST); + } + throw err; + } + } } 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/auth.service.spec.ts b/src/auth/auth.service.spec.ts index d8d1db3..4fe39e8 100644 --- a/src/auth/auth.service.spec.ts +++ b/src/auth/auth.service.spec.ts @@ -1,6 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ConfigService } from '@nestjs/config'; -import { ConflictException } from '@nestjs/common'; +import { BadRequestException, ConflictException } from '@nestjs/common'; +import * as crypto from 'crypto'; import { AuthService } from './auth.service'; import { UsersService } from '../users/users.service'; import { MailService } from '../mail/mail.service'; @@ -133,3 +134,117 @@ describe('AuthService.register', () => { expect(result.email).toBe('bob@example.com'); }); }); + +describe('AuthService.resetPassword', () => { + let auth: AuthService; + let users: jest.Mocked< + Pick + >; + + const RAW_TOKEN = 'a'.repeat(64); + const HASHED_TOKEN = crypto.createHash('sha256').update(RAW_TOKEN).digest('hex'); + + function fauxUserWithActiveReset(_id: string) { + return { + _id: { toString: () => _id }, + password: 'old-hash', + passwordResetExpires: new Date(Date.now() + 60_000), + }; + } + + beforeEach(async () => { + users = { + findByPasswordResetToken: jest.fn(), + update: jest.fn(), + }; + + const moduleRef: TestingModule = await Test.createTestingModule({ + providers: [ + AuthService, + { provide: UsersService, useValue: users }, + { provide: MailService, useValue: {} }, + { provide: ConfigService, useValue: { get: jest.fn() } }, + ], + }).compile(); + + auth = moduleRef.get(AuthService); + }); + + it('hashes the incoming token, hashes the new password, and clears reset+refresh state', async () => { + users.findByPasswordResetToken.mockResolvedValue( + fauxUserWithActiveReset('user-1') as unknown as Awaited< + ReturnType + >, + ); + users.update.mockResolvedValue( + fauxUserWithActiveReset('user-1') as unknown as Awaited< + ReturnType + >, + ); + + await auth.resetPassword(RAW_TOKEN, 'completelyNewPass1!'); + + // The lookup must use the SHA-256 digest, never the raw token. + expect(users.findByPasswordResetToken).toHaveBeenCalledTimes(1); + expect(users.findByPasswordResetToken).toHaveBeenCalledWith(HASHED_TOKEN); + + // The update must persist the bcrypt-hashed new password and clear + // every token-bearing field so previously active sessions die. + expect(users.update).toHaveBeenCalledTimes(1); + const [updateId, updateArg] = users.update.mock.calls[0] as [ + string, + Record, + ]; + expect(updateId).toBe('user-1'); + expect(updateArg.password).not.toBe('completelyNewPass1!'); + expect(typeof updateArg.password).toBe('string'); + expect((updateArg.password as string).startsWith('$2')).toBe(true); + expect(updateArg.passwordResetToken).toBeNull(); + expect(updateArg.passwordResetExpires).toBeNull(); + expect(updateArg.refreshTokenHash).toBeNull(); + expect(updateArg.refreshTokenExpires).toBeNull(); + }); + + it('rejects an unknown token without touching the user', async () => { + users.findByPasswordResetToken.mockResolvedValue(null); + + await expect( + auth.resetPassword(RAW_TOKEN, 'completelyNewPass1!'), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(users.update).not.toHaveBeenCalled(); + }); + + it('rejects an expired token without touching the user', async () => { + users.findByPasswordResetToken.mockResolvedValue({ + _id: { toString: () => 'user-2' }, + password: 'old-hash', + passwordResetExpires: new Date(Date.now() - 1), + } as unknown as Awaited>); + + await expect( + auth.resetPassword(RAW_TOKEN, 'completelyNewPass1!'), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(users.update).not.toHaveBeenCalled(); + }); + + it('emits the same generic message for invalid and expired tokens', async () => { + users.findByPasswordResetToken.mockResolvedValue(null); + const unknownMessage = await auth + .resetPassword(RAW_TOKEN, 'completelyNewPass1!') + .catch((err: Error) => err.message); + + users.findByPasswordResetToken.mockResolvedValue({ + _id: { toString: () => 'user-3' }, + password: 'old-hash', + passwordResetExpires: new Date(Date.now() - 1000), + } as unknown as Awaited>); + const expiredMessage = await auth + .resetPassword(RAW_TOKEN, 'completelyNewPass1!') + .catch((err: Error) => err.message); + + expect(unknownMessage).toBe(expiredMessage); + }); + +}); diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 3b24896..689c2b8 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -1,8 +1,14 @@ -import { ConflictException, Injectable, Logger } from '@nestjs/common'; +import { + BadRequestException, + ConflictException, + Injectable, + Logger, +} from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import * as crypto from 'crypto'; import { UsersService } from '../users/users.service'; import { MailService } from '../mail/mail.service'; +import { hashUserPassword } from '../users/schemas/user.schema'; export interface RegisterInput { fullName: string; @@ -68,4 +74,52 @@ export class AuthService { isVerified: user.isVerified, }; } + + /** + * Reset a user's password using the one-time token delivered to their + * inbox (issue #369). + * + * The token is matched against `User.passwordResetToken`, which stores + * a SHA-256 hash of the original token rather than the raw value — this + * way a leaked DB snapshot cannot be used to compromise any pending + * reset. Expiry is enforced against `User.passwordResetExpires`. + * + * On success the new password is bcrypt-hashed and the reset token, + * reset expiry, refresh token hash, and refresh token expiry are all + * cleared so any previously active session can no longer be refreshed. + * + * @throws BadRequestException when the token is unknown, malformed, or + * expired. The same message is used in all three cases so the endpoint + * does not leak which condition failed. + */ + async resetPassword(token: string, newPassword: string): Promise { + const hashedToken = hashResetToken(token); + const user = await this.usersService.findByPasswordResetToken(hashedToken); + + if ( + !user || + !user.passwordResetExpires || + user.passwordResetExpires.getTime() < Date.now() + ) { + throw new BadRequestException('Invalid or expired reset token'); + } + + const hashedPassword = await hashUserPassword(newPassword); + + await this.usersService.update(user._id?.toString() ?? '', { + password: hashedPassword, + passwordResetToken: null, + passwordResetExpires: null, + refreshTokenHash: null, + refreshTokenExpires: null, + }); + } +} + +/** + * SHA-256 hex digest of a raw reset token. The raw token is what gets + * delivered to the user; only the digest lives in the database. + */ +export function hashResetToken(token: string): string { + return crypto.createHash('sha256').update(token).digest('hex'); } 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/dto/reset-password.dto.ts b/src/auth/dto/reset-password.dto.ts new file mode 100644 index 0000000..fa0cc19 --- /dev/null +++ b/src/auth/dto/reset-password.dto.ts @@ -0,0 +1,14 @@ +import { IsString, MaxLength, MinLength } from 'class-validator'; + +/** + * Body for `PATCH /auth/reset-password/:token`. + * + * Carries only the new password — the reset token itself lives in the URL + * so an attacker who only sees the body cannot replay it. + */ +export class ResetPasswordDto { + @IsString({ message: 'password must be a string' }) + @MinLength(8, { message: 'password must be at least 8 characters' }) + @MaxLength(128, { message: 'password must be at most 128 characters' }) + password!: string; +} 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 {} diff --git a/src/users/users.service.ts b/src/users/users.service.ts index a810539..586687f 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -32,6 +32,13 @@ export class UsersService { return this.userModel.findOne({ emailVerificationToken: token }).exec(); } + /** + * Look up a user by their active password-reset token digest. + * + * Callers MUST pass the hashed token (see `hashResetToken` in + * `auth.service.ts`) rather than the raw token, so a leaked DB cannot + * be used to complete a reset. Expiry is enforced by the caller. + */ async findByPasswordResetToken(token: string): Promise { return this.userModel.findOne({ passwordResetToken: token }).exec(); }