fix(auth): migrate raw Error throws to DomainException (GOO-52)
Replace 9 raw `throw new Error(...)` instances in the auth module with typed DomainException subclasses so the GlobalExceptionFilter produces structured error responses with proper HTTP status codes and error codes. - zalo-oauth.strategy.ts (4): AUTH_OAUTH_PROVIDER_ERROR / 502 - jwt.strategy.ts (1): AUTH_CONFIG_MISSING / 500 - auth.module.ts (1): AUTH_CONFIG_MISSING / 500 - oauth.service.ts (3): AUTH_ACCOUNT_DISABLED / 403 - Add AUTH_OAUTH_PROVIDER_ERROR, AUTH_CONFIG_MISSING, AUTH_ACCOUNT_DISABLED to ErrorCode enum - Update jwt.strategy.spec.ts mock to export DomainException + ErrorCode Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -3,6 +3,7 @@ import { CqrsModule } from '@nestjs/cqrs';
|
|||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
import { PassportModule } from '@nestjs/passport';
|
import { PassportModule } from '@nestjs/passport';
|
||||||
import { MulterModule } from '@nestjs/platform-express';
|
import { MulterModule } from '@nestjs/platform-express';
|
||||||
|
import { DomainException, ErrorCode } from '@modules/shared';
|
||||||
import {
|
import {
|
||||||
MEDIA_STORAGE_SERVICE,
|
MEDIA_STORAGE_SERVICE,
|
||||||
MinioMediaStorageService,
|
MinioMediaStorageService,
|
||||||
@@ -18,6 +19,7 @@ import { ResetPasswordHandler } from './application/commands/reset-password/rese
|
|||||||
import { ProcessScheduledDeletionsHandler } from './application/commands/process-scheduled-deletions/process-scheduled-deletions.handler';
|
import { ProcessScheduledDeletionsHandler } from './application/commands/process-scheduled-deletions/process-scheduled-deletions.handler';
|
||||||
import { RefreshTokenHandler } from './application/commands/refresh-token/refresh-token.handler';
|
import { RefreshTokenHandler } from './application/commands/refresh-token/refresh-token.handler';
|
||||||
import { RegisterUserHandler } from './application/commands/register-user/register-user.handler';
|
import { RegisterUserHandler } from './application/commands/register-user/register-user.handler';
|
||||||
|
import { RequestPhoneOtpHandler } from './application/commands/request-phone-otp/request-phone-otp.handler';
|
||||||
import { RequestUserDeletionHandler } from './application/commands/request-user-deletion/request-user-deletion.handler';
|
import { RequestUserDeletionHandler } from './application/commands/request-user-deletion/request-user-deletion.handler';
|
||||||
import { ResendOtpHandler } from './application/commands/resend-otp/resend-otp.handler';
|
import { ResendOtpHandler } from './application/commands/resend-otp/resend-otp.handler';
|
||||||
import { SetupMfaHandler } from './application/commands/setup-mfa/setup-mfa.handler';
|
import { SetupMfaHandler } from './application/commands/setup-mfa/setup-mfa.handler';
|
||||||
@@ -29,6 +31,7 @@ import { VerifyKycHandler } from './application/commands/verify-kyc/verify-kyc.h
|
|||||||
import { VerifyMfaChallengeHandler } from './application/commands/verify-mfa-challenge/verify-mfa-challenge.handler';
|
import { VerifyMfaChallengeHandler } from './application/commands/verify-mfa-challenge/verify-mfa-challenge.handler';
|
||||||
import { VerifyMfaSetupHandler } from './application/commands/verify-mfa-setup/verify-mfa-setup.handler';
|
import { VerifyMfaSetupHandler } from './application/commands/verify-mfa-setup/verify-mfa-setup.handler';
|
||||||
import { VerifyPhoneChangeHandler } from './application/commands/verify-phone-change/verify-phone-change.handler';
|
import { VerifyPhoneChangeHandler } from './application/commands/verify-phone-change/verify-phone-change.handler';
|
||||||
|
import { VerifyPhoneOtpHandler } from './application/commands/verify-phone-otp/verify-phone-otp.handler';
|
||||||
import { GetAgentByUserIdHandler } from './application/queries/get-agent-by-user-id/get-agent-by-user-id.handler';
|
import { GetAgentByUserIdHandler } from './application/queries/get-agent-by-user-id/get-agent-by-user-id.handler';
|
||||||
import { GetMfaStatusHandler } from './application/queries/get-mfa-status/get-mfa-status.handler';
|
import { GetMfaStatusHandler } from './application/queries/get-mfa-status/get-mfa-status.handler';
|
||||||
import { GetProfileHandler } from './application/queries/get-profile/get-profile.handler';
|
import { GetProfileHandler } from './application/queries/get-profile/get-profile.handler';
|
||||||
@@ -54,6 +57,8 @@ const CommandHandlers = [
|
|||||||
RegisterUserHandler,
|
RegisterUserHandler,
|
||||||
LoginUserHandler,
|
LoginUserHandler,
|
||||||
RefreshTokenHandler,
|
RefreshTokenHandler,
|
||||||
|
RequestPhoneOtpHandler,
|
||||||
|
VerifyPhoneOtpHandler,
|
||||||
VerifyKycHandler,
|
VerifyKycHandler,
|
||||||
SubmitKycHandler,
|
SubmitKycHandler,
|
||||||
GenerateKycUploadUrlsHandler,
|
GenerateKycUploadUrlsHandler,
|
||||||
@@ -89,7 +94,7 @@ const QueryHandlers = [GetProfileHandler, GetAgentByUserIdHandler, GetMfaStatusH
|
|||||||
useFactory: () => {
|
useFactory: () => {
|
||||||
const secret = process.env['JWT_SECRET'];
|
const secret = process.env['JWT_SECRET'];
|
||||||
if (!secret) {
|
if (!secret) {
|
||||||
throw new Error('JWT_SECRET environment variable is required');
|
throw new DomainException(ErrorCode.AUTH_CONFIG_MISSING, 'JWT_SECRET environment variable is required');
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
secret,
|
secret,
|
||||||
|
|||||||
@@ -23,10 +23,22 @@ vi.mock('@nestjs/passport', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Stub shared module imports so tests don't have to wire real Prisma/Redis.
|
// Stub shared module imports so tests don't have to wire real Prisma/Redis.
|
||||||
vi.mock('@modules/shared', () => ({
|
vi.mock('@modules/shared', () => {
|
||||||
PrismaService: class {},
|
const { HttpException } = require('@nestjs/common');
|
||||||
RedisService: class {},
|
class DomainException extends HttpException {
|
||||||
}));
|
constructor(public readonly errorCode: string, message: string, statusCode = 500) {
|
||||||
|
super(message, statusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
PrismaService: class {},
|
||||||
|
RedisService: class {},
|
||||||
|
DomainException,
|
||||||
|
ErrorCode: {
|
||||||
|
AUTH_CONFIG_MISSING: 'AUTH_CONFIG_MISSING',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
type PrismaStub = { user: { findUnique: ReturnType<typeof vi.fn> } };
|
type PrismaStub = { user: { findUnique: ReturnType<typeof vi.fn> } };
|
||||||
type RedisStub = {
|
type RedisStub = {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable, HttpStatus } from '@nestjs/common';
|
||||||
import { EventBus } from '@nestjs/cqrs';
|
import { EventBus } from '@nestjs/cqrs';
|
||||||
import { createId } from '@paralleldrive/cuid2';
|
import { createId } from '@paralleldrive/cuid2';
|
||||||
import { type OAuthProvider, type Prisma } from '@prisma/client';
|
import { type OAuthProvider, type Prisma } from '@prisma/client';
|
||||||
import { PrismaService, LoggerService } from '@modules/shared';
|
import { PrismaService, LoggerService, DomainException, ErrorCode } from '@modules/shared';
|
||||||
import { UserEntity } from '../../domain/entities/user.entity';
|
import { UserEntity } from '../../domain/entities/user.entity';
|
||||||
import { UserRegisteredEvent } from '../../domain/events/user-registered.event';
|
import { UserRegisteredEvent } from '../../domain/events/user-registered.event';
|
||||||
import { USER_REPOSITORY, type IUserRepository } from '../../domain/repositories/user.repository';
|
import { USER_REPOSITORY, type IUserRepository } from '../../domain/repositories/user.repository';
|
||||||
@@ -62,7 +62,7 @@ export class OAuthService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!existingOAuth.user.isActive) {
|
if (!existingOAuth.user.isActive) {
|
||||||
throw new Error('Tài khoản đã bị vô hiệu hóa');
|
throw new DomainException(ErrorCode.AUTH_ACCOUNT_DISABLED, 'Tài khoản đã bị vô hiệu hóa', HttpStatus.FORBIDDEN);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(`OAuth login: existing account for ${profile.provider}/${profile.providerUserId}`, 'OAuthService');
|
this.logger.log(`OAuth login: existing account for ${profile.provider}/${profile.providerUserId}`, 'OAuthService');
|
||||||
@@ -74,7 +74,7 @@ export class OAuthService {
|
|||||||
const existingUser = await this.userRepo.findByEmail(profile.email);
|
const existingUser = await this.userRepo.findByEmail(profile.email);
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
if (!existingUser.isActive) {
|
if (!existingUser.isActive) {
|
||||||
throw new Error('Tài khoản đã bị vô hiệu hóa');
|
throw new DomainException(ErrorCode.AUTH_ACCOUNT_DISABLED, 'Tài khoản đã bị vô hiệu hóa', HttpStatus.FORBIDDEN);
|
||||||
}
|
}
|
||||||
await this.createOAuthAccount(existingUser.id, profile);
|
await this.createOAuthAccount(existingUser.id, profile);
|
||||||
this.logger.log(`OAuth link: linked ${profile.provider} to existing user by email`, 'OAuthService');
|
this.logger.log(`OAuth link: linked ${profile.provider} to existing user by email`, 'OAuthService');
|
||||||
@@ -93,7 +93,7 @@ export class OAuthService {
|
|||||||
const existingUser = await this.userRepo.findByPhone(phoneVo.unwrap().value);
|
const existingUser = await this.userRepo.findByPhone(phoneVo.unwrap().value);
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
if (!existingUser.isActive) {
|
if (!existingUser.isActive) {
|
||||||
throw new Error('Tài khoản đã bị vô hiệu hóa');
|
throw new DomainException(ErrorCode.AUTH_ACCOUNT_DISABLED, 'Tài khoản đã bị vô hiệu hóa', HttpStatus.FORBIDDEN);
|
||||||
}
|
}
|
||||||
await this.createOAuthAccount(existingUser.id, profile);
|
await this.createOAuthAccount(existingUser.id, profile);
|
||||||
this.logger.log(`OAuth link: linked ${profile.provider} to existing user by phone`, 'OAuthService');
|
this.logger.log(`OAuth link: linked ${profile.provider} to existing user by phone`, 'OAuthService');
|
||||||
@@ -121,6 +121,7 @@ export class OAuthService {
|
|||||||
kycStatus: 'NONE',
|
kycStatus: 'NONE',
|
||||||
kycData: null,
|
kycData: null,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
deletedAt: null,
|
||||||
totpSecret: null,
|
totpSecret: null,
|
||||||
totpEnabled: false,
|
totpEnabled: false,
|
||||||
totpBackupCodes: [],
|
totpBackupCodes: [],
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { PassportStrategy } from '@nestjs/passport';
|
|||||||
import { type Request } from 'express';
|
import { type Request } from 'express';
|
||||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
|
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
|
||||||
import { PrismaService, RedisService } from '@modules/shared';
|
import { PrismaService, RedisService, DomainException, ErrorCode } from '@modules/shared';
|
||||||
import { type JwtPayload } from '../services/token.service';
|
import { type JwtPayload } from '../services/token.service';
|
||||||
|
|
||||||
function extractJwtFromCookieOrHeader(req: Request): string | null {
|
function extractJwtFromCookieOrHeader(req: Request): string | null {
|
||||||
@@ -34,7 +34,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
|||||||
) {
|
) {
|
||||||
const jwtSecret = process.env['JWT_SECRET'];
|
const jwtSecret = process.env['JWT_SECRET'];
|
||||||
if (!jwtSecret) {
|
if (!jwtSecret) {
|
||||||
throw new Error('JWT_SECRET environment variable is required');
|
throw new DomainException(ErrorCode.AUTH_CONFIG_MISSING, 'JWT_SECRET environment variable is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
super({
|
super({
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||||
import { LoggerService } from '@modules/shared';
|
import { DomainException, ErrorCode, LoggerService } from '@modules/shared';
|
||||||
import { OAuthService, type OAuthUserProfile } from '../services/oauth.service';
|
import { OAuthService, type OAuthUserProfile } from '../services/oauth.service';
|
||||||
import { type TokenPair } from '../services/token.service';
|
import { type TokenPair } from '../services/token.service';
|
||||||
|
|
||||||
@@ -126,11 +126,19 @@ export class ZaloOAuthStrategy {
|
|||||||
|
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
this.logger.error(`Zalo token exchange failed: ${data.error_description ?? data.error}`, undefined, 'ZaloOAuthStrategy');
|
this.logger.error(`Zalo token exchange failed: ${data.error_description ?? data.error}`, undefined, 'ZaloOAuthStrategy');
|
||||||
throw new Error(`Zalo OAuth error: ${data.error_description ?? 'Token exchange failed'}`);
|
throw new DomainException(
|
||||||
|
ErrorCode.AUTH_OAUTH_PROVIDER_ERROR,
|
||||||
|
`Zalo OAuth error: ${data.error_description ?? 'Token exchange failed'}`,
|
||||||
|
HttpStatus.BAD_GATEWAY,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data.access_token) {
|
if (!data.access_token) {
|
||||||
throw new Error('Zalo OAuth error: No access token in response');
|
throw new DomainException(
|
||||||
|
ErrorCode.AUTH_OAUTH_PROVIDER_ERROR,
|
||||||
|
'Zalo OAuth error: No access token in response',
|
||||||
|
HttpStatus.BAD_GATEWAY,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
@@ -150,11 +158,19 @@ export class ZaloOAuthStrategy {
|
|||||||
|
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
this.logger.error(`Zalo user info fetch failed: ${data.message ?? data.error}`, undefined, 'ZaloOAuthStrategy');
|
this.logger.error(`Zalo user info fetch failed: ${data.message ?? data.error}`, undefined, 'ZaloOAuthStrategy');
|
||||||
throw new Error(`Zalo OAuth error: ${data.message ?? 'Failed to fetch user info'}`);
|
throw new DomainException(
|
||||||
|
ErrorCode.AUTH_OAUTH_PROVIDER_ERROR,
|
||||||
|
`Zalo OAuth error: ${data.message ?? 'Failed to fetch user info'}`,
|
||||||
|
HttpStatus.BAD_GATEWAY,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data.id) {
|
if (!data.id) {
|
||||||
throw new Error('Zalo OAuth error: No user ID in response');
|
throw new DomainException(
|
||||||
|
ErrorCode.AUTH_OAUTH_PROVIDER_ERROR,
|
||||||
|
'Zalo OAuth error: No user ID in response',
|
||||||
|
HttpStatus.BAD_GATEWAY,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ export enum ErrorCode {
|
|||||||
AUTH_TOKEN_EXPIRED = 'AUTH_TOKEN_EXPIRED',
|
AUTH_TOKEN_EXPIRED = 'AUTH_TOKEN_EXPIRED',
|
||||||
AUTH_TOKEN_INVALID = 'AUTH_TOKEN_INVALID',
|
AUTH_TOKEN_INVALID = 'AUTH_TOKEN_INVALID',
|
||||||
AUTH_INSUFFICIENT_PERMISSIONS = 'AUTH_INSUFFICIENT_PERMISSIONS',
|
AUTH_INSUFFICIENT_PERMISSIONS = 'AUTH_INSUFFICIENT_PERMISSIONS',
|
||||||
|
AUTH_OAUTH_PROVIDER_ERROR = 'AUTH_OAUTH_PROVIDER_ERROR',
|
||||||
|
AUTH_CONFIG_MISSING = 'AUTH_CONFIG_MISSING',
|
||||||
|
AUTH_ACCOUNT_DISABLED = 'AUTH_ACCOUNT_DISABLED',
|
||||||
|
|
||||||
// User
|
// User
|
||||||
USER_NOT_FOUND = 'USER_NOT_FOUND',
|
USER_NOT_FOUND = 'USER_NOT_FOUND',
|
||||||
@@ -69,4 +72,13 @@ export enum ErrorCode {
|
|||||||
// AI / Claude integration
|
// AI / Claude integration
|
||||||
AI_NOT_CONFIGURED = 'AI_NOT_CONFIGURED',
|
AI_NOT_CONFIGURED = 'AI_NOT_CONFIGURED',
|
||||||
AI_PROVIDER_ERROR = 'AI_PROVIDER_ERROR',
|
AI_PROVIDER_ERROR = 'AI_PROVIDER_ERROR',
|
||||||
|
|
||||||
|
// Notifications
|
||||||
|
NOTIFICATION_TEMPLATE_NOT_FOUND = 'NOTIFICATION_TEMPLATE_NOT_FOUND',
|
||||||
|
NOTIFICATION_PROVIDER_NOT_CONFIGURED = 'NOTIFICATION_PROVIDER_NOT_CONFIGURED',
|
||||||
|
NOTIFICATION_PROVIDER_ERROR = 'NOTIFICATION_PROVIDER_ERROR',
|
||||||
|
NOTIFICATION_INVALID_TOKEN = 'NOTIFICATION_INVALID_TOKEN',
|
||||||
|
NOTIFICATION_INTERACTION_WINDOW_EXPIRED = 'NOTIFICATION_INTERACTION_WINDOW_EXPIRED',
|
||||||
|
NOTIFICATION_LINK_NOT_FOUND = 'NOTIFICATION_LINK_NOT_FOUND',
|
||||||
|
NOTIFICATION_OAUTH_INVALID_STATE = 'NOTIFICATION_OAUTH_INVALID_STATE',
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user