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:
@@ -1,11 +1,13 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Strategy } from 'passport-local';
|
||||
import { normalizeVietnamPhone, UnauthorizedException } from '@modules/shared';
|
||||
import { DomainException, normalizeVietnamPhone, UnauthorizedException } from '@modules/shared';
|
||||
import { USER_REPOSITORY, type IUserRepository } from '../../domain/repositories/user.repository';
|
||||
|
||||
@Injectable()
|
||||
export class LocalStrategy extends PassportStrategy(Strategy) {
|
||||
private readonly logger = new Logger(LocalStrategy.name);
|
||||
|
||||
constructor(
|
||||
@Inject(USER_REPOSITORY)
|
||||
private readonly userRepo: IUserRepository,
|
||||
@@ -14,25 +16,38 @@ export class LocalStrategy extends PassportStrategy(Strategy) {
|
||||
}
|
||||
|
||||
async validate(phone: string, password: string): Promise<{ id: string; phone: string; role: string }> {
|
||||
const normalizedPhone = normalizeVietnamPhone(phone);
|
||||
if (!normalizedPhone) {
|
||||
throw new UnauthorizedException('Số điện thoại không hợp lệ');
|
||||
}
|
||||
const user = await this.userRepo.findByPhone(normalizedPhone);
|
||||
try {
|
||||
if (!phone || !password) {
|
||||
throw new UnauthorizedException('Số điện thoại hoặc mật khẩu không đúng');
|
||||
}
|
||||
|
||||
if (!user || !user.passwordHash) {
|
||||
const normalizedPhone = normalizeVietnamPhone(phone);
|
||||
if (!normalizedPhone) {
|
||||
throw new UnauthorizedException('Số điện thoại không hợp lệ');
|
||||
}
|
||||
const user = await this.userRepo.findByPhone(normalizedPhone);
|
||||
|
||||
if (!user || !user.passwordHash) {
|
||||
throw new UnauthorizedException('Số điện thoại hoặc mật khẩu không đúng');
|
||||
}
|
||||
|
||||
if (!user.isActive) {
|
||||
throw new UnauthorizedException('Tài khoản đã bị vô hiệu hóa');
|
||||
}
|
||||
|
||||
const isValid = await user.passwordHash.compare(password);
|
||||
if (!isValid) {
|
||||
throw new UnauthorizedException('Số điện thoại hoặc mật khẩu không đúng');
|
||||
}
|
||||
|
||||
return { id: user.id, phone: user.phone.value, role: user.role };
|
||||
} catch (error) {
|
||||
if (error instanceof DomainException) throw error;
|
||||
this.logger.error(
|
||||
`Authentication failed: ${error instanceof Error ? error.message : error}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
);
|
||||
throw new UnauthorizedException('Số điện thoại hoặc mật khẩu không đúng');
|
||||
}
|
||||
|
||||
if (!user.isActive) {
|
||||
throw new UnauthorizedException('Tài khoản đã bị vô hiệu hóa');
|
||||
}
|
||||
|
||||
const isValid = await user.passwordHash.compare(password);
|
||||
if (!isValid) {
|
||||
throw new UnauthorizedException('Số điện thoại hoặc mật khẩu không đúng');
|
||||
}
|
||||
|
||||
return { id: user.id, phone: user.phone.value, role: user.role };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user