fix(auth): prevent login endpoint from returning 500 on invalid credentials

LocalStrategy.validate lacked a try-catch, so infrastructure errors
(DB timeouts, bcrypt failures, null/undefined phone) escaped as raw
Error instances. LocalAuthGuard.handleRequest blindly re-threw them,
causing GlobalExceptionFilter to map them to 500 Internal Server Error
instead of 401 Unauthorized.

Changes:
- Add null/falsy guard for phone and password in LocalStrategy.validate
- Wrap validate body in try-catch; re-throw DomainExceptions, wrap
  unexpected errors as UnauthorizedException (401)
- Add error type-checking in LocalAuthGuard.handleRequest: re-throw
  HttpException subclasses directly, wrap other errors as 401
- Add @IsNotEmpty() validators to LoginDto for Swagger accuracy
- Add 5 new test cases covering undefined/null/empty inputs, DB
  errors, and bcrypt failures
- Update guard tests for the new type-checking behaviour

Resolves TEC-1841

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-11 19:53:41 +07:00
parent 2da333a95b
commit 7008230424
5 changed files with 157 additions and 35 deletions

View File

@@ -1,6 +1,30 @@
import { describe, it, expect } from 'vitest';
import { HttpException, HttpStatus } from '@nestjs/common';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { LocalAuthGuard } from '../guards/local-auth.guard';
vi.mock('@nestjs/common', async (importOriginal) => {
const actual = (await importOriginal()) as any;
return {
...actual,
Logger: class MockLogger {
error = vi.fn();
warn = vi.fn();
log = vi.fn();
},
};
});
vi.mock('@modules/shared', async () => {
const { HttpException: HE, HttpStatus: HS } = await import('@nestjs/common');
class UnauthorizedException extends HE {
constructor(message: string) {
super(message, HS.UNAUTHORIZED);
this.name = 'UnauthorizedException';
}
}
return { UnauthorizedException };
});
describe('LocalAuthGuard', () => {
let guard: LocalAuthGuard;
@@ -14,9 +38,24 @@ describe('LocalAuthGuard', () => {
expect(result).toEqual(user);
});
it('throws error when error is provided', () => {
const error = new Error('Strategy error');
expect(() => guard.handleRequest(error, null, undefined, {} as any)).toThrow('Strategy error');
it('re-throws HttpException errors directly', () => {
const httpError = new HttpException('Bad Request', HttpStatus.BAD_REQUEST);
expect(() => guard.handleRequest(httpError, null, undefined, {} as any)).toThrow(HttpException);
expect(() => guard.handleRequest(httpError, null, undefined, {} as any)).toThrow('Bad Request');
});
it('wraps non-HttpException errors as UnauthorizedException', () => {
const dbError = new Error('DB connection refused');
expect(() => guard.handleRequest(dbError, null, undefined, {} as any)).toThrow(
'Số điện thoại hoặc mật khẩu không đúng',
);
});
it('wraps TypeError (e.g. from null/undefined) as UnauthorizedException', () => {
const typeError = new TypeError("Cannot read properties of undefined (reading 'replace')");
expect(() => guard.handleRequest(typeError, null, undefined, {} as any)).toThrow(
'Số điện thoại hoặc mật khẩu không đúng',
);
});
it('throws UnauthorizedException when no user and no error', () => {
@@ -24,9 +63,4 @@ describe('LocalAuthGuard', () => {
'Số điện thoại hoặc mật khẩu không đúng',
);
});
it('re-throws the original error type', () => {
const customError = new TypeError('Custom type error');
expect(() => guard.handleRequest(customError, null, undefined, {} as any)).toThrow(TypeError);
});
});

View File

@@ -1,12 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString } from 'class-validator';
import { IsNotEmpty, IsString } from 'class-validator';
export class LoginDto {
@ApiProperty({ example: '0901234567' })
@IsString()
@IsNotEmpty({ message: 'Số điện thoại không được để trống' })
phone!: string;
@ApiProperty({ example: 'P@ssw0rd!' })
@IsString()
@IsNotEmpty({ message: 'Mật khẩu không được để trống' })
password!: string;
}

View File

@@ -1,14 +1,25 @@
import { type ExecutionContext, Injectable } from '@nestjs/common';
import { type ExecutionContext, HttpException, Injectable, Logger } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { UnauthorizedException } from '@modules/shared';
@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {
private readonly logger = new Logger(LocalAuthGuard.name);
override handleRequest<T>(err: Error | null, user: T, _info: unknown, _context: ExecutionContext): T {
// If the strategy threw a DomainException (e.g. our custom UnauthorizedException),
// re-throw it directly so GlobalExceptionFilter produces the structured error format.
if (err) {
throw err;
// If the strategy threw a DomainException or HttpException,
// re-throw it directly so GlobalExceptionFilter produces the structured error format.
if (err instanceof HttpException) {
throw err;
}
// Unexpected infrastructure errors (e.g. DB failure, bcrypt crash) — log and
// wrap as 401 to avoid leaking a 500 on the login endpoint.
this.logger.error(
`Unexpected auth error: ${err.message}`,
err.stack,
);
throw new UnauthorizedException('Số điện thoại hoặc mật khẩu không đúng');
}
if (!user) {
throw new UnauthorizedException('Số điện thoại hoặc mật khẩu không đúng');