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
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 {}
47 changes: 47 additions & 0 deletions src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -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<Response> {
Expand All @@ -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<Response> {
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;
}
}
}
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 {}
117 changes: 116 additions & 1 deletion src/auth/auth.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<UsersService, 'findByPasswordResetToken' | 'update'>
>;

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<UsersService['findByPasswordResetToken']>
>,
);
users.update.mockResolvedValue(
fauxUserWithActiveReset('user-1') as unknown as Awaited<
ReturnType<UsersService['update']>
>,
);

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<string, unknown>,
];
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<ReturnType<UsersService['findByPasswordResetToken']>>);

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<ReturnType<UsersService['findByPasswordResetToken']>>);
const expiredMessage = await auth
.resetPassword(RAW_TOKEN, 'completelyNewPass1!')
.catch((err: Error) => err.message);

expect(unknownMessage).toBe(expiredMessage);
});

});
56 changes: 55 additions & 1 deletion src/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<void> {
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');
}
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);
14 changes: 14 additions & 0 deletions src/auth/dto/reset-password.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading