feat(auth): implement Auth module with register, login, JWT, guards, and CQRS
- Add RefreshToken and OAuthAccount models to Prisma schema - Implement clean architecture: domain (entities, VOs, events, repo interfaces), infrastructure (Prisma repos, Passport strategies, token service), application (CQRS command/query handlers), presentation (controller, guards, DTOs) - Endpoints: POST /auth/register, /auth/login, /auth/refresh, GET /auth/profile, GET /auth/profile/agent, PATCH /auth/kyc - JWT access + refresh token rotation with family-based revocation - Role-based guards (BUYER, SELLER, AGENT, ADMIN) - 16 unit tests (value objects, entity) + integration test suite - All 80 tests passing, clean TypeScript build Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
export class LoginUserCommand {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly phone: string,
|
||||
public readonly role: string,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { LoginUserCommand } from './login-user.command';
|
||||
import { TokenService, type TokenPair } from '../../../infrastructure/services/token.service';
|
||||
|
||||
@CommandHandler(LoginUserCommand)
|
||||
export class LoginUserHandler implements ICommandHandler<LoginUserCommand> {
|
||||
constructor(private readonly tokenService: TokenService) {}
|
||||
|
||||
async execute(command: LoginUserCommand): Promise<TokenPair> {
|
||||
return this.tokenService.generateTokenPair({
|
||||
sub: command.userId,
|
||||
phone: command.phone,
|
||||
role: command.role,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export class RefreshTokenCommand {
|
||||
constructor(public readonly refreshToken: string) {}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { UnauthorizedException, Inject } from '@nestjs/common';
|
||||
import { RefreshTokenCommand } from './refresh-token.command';
|
||||
import { TokenService, type TokenPair } from '../../../infrastructure/services/token.service';
|
||||
import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositories/user.repository';
|
||||
|
||||
@CommandHandler(RefreshTokenCommand)
|
||||
export class RefreshTokenHandler implements ICommandHandler<RefreshTokenCommand> {
|
||||
constructor(
|
||||
private readonly tokenService: TokenService,
|
||||
@Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository,
|
||||
) {}
|
||||
|
||||
async execute(command: RefreshTokenCommand): Promise<TokenPair> {
|
||||
const rotated = await this.tokenService.rotateRefreshToken(command.refreshToken);
|
||||
if (!rotated) {
|
||||
throw new UnauthorizedException('Refresh token không hợp lệ hoặc đã hết hạn');
|
||||
}
|
||||
|
||||
const user = await this.userRepo.findById(rotated.userId);
|
||||
if (!user || !user.isActive) {
|
||||
throw new UnauthorizedException('Tài khoản không tồn tại hoặc đã bị vô hiệu hóa');
|
||||
}
|
||||
|
||||
const accessToken = this.tokenService.generateAccessToken({
|
||||
sub: user.id,
|
||||
phone: user.phone.value,
|
||||
role: user.role,
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken: rotated.refreshToken,
|
||||
expiresIn: 900,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export class RegisterUserCommand {
|
||||
constructor(
|
||||
public readonly phone: string,
|
||||
public readonly password: string,
|
||||
public readonly fullName: string,
|
||||
public readonly email?: string,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { ConflictException, BadRequestException } from '@nestjs/common';
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { createId } from '@paralleldrive/cuid2';
|
||||
import { RegisterUserCommand } from './register-user.command';
|
||||
import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositories/user.repository';
|
||||
import { UserEntity } from '../../../domain/entities/user.entity';
|
||||
import { Phone } from '../../../domain/value-objects/phone.vo';
|
||||
import { Email } from '../../../domain/value-objects/email.vo';
|
||||
import { HashedPassword } from '../../../domain/value-objects/hashed-password.vo';
|
||||
import { TokenService, type TokenPair } from '../../../infrastructure/services/token.service';
|
||||
|
||||
@CommandHandler(RegisterUserCommand)
|
||||
export class RegisterUserHandler implements ICommandHandler<RegisterUserCommand> {
|
||||
constructor(
|
||||
@Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository,
|
||||
private readonly tokenService: TokenService,
|
||||
private readonly eventBus: EventBus,
|
||||
) {}
|
||||
|
||||
async execute(command: RegisterUserCommand): Promise<TokenPair> {
|
||||
// Validate phone
|
||||
const phoneResult = Phone.create(command.phone);
|
||||
if (phoneResult.isErr) {
|
||||
throw new BadRequestException(phoneResult.unwrapErr());
|
||||
}
|
||||
const phone = phoneResult.unwrap();
|
||||
|
||||
// Check duplicate phone
|
||||
const existingByPhone = await this.userRepo.findByPhone(phone.value);
|
||||
if (existingByPhone) {
|
||||
throw new ConflictException('Số điện thoại đã được đăng ký');
|
||||
}
|
||||
|
||||
// Validate email if provided
|
||||
let email: Email | undefined;
|
||||
if (command.email) {
|
||||
const emailResult = Email.create(command.email);
|
||||
if (emailResult.isErr) {
|
||||
throw new BadRequestException(emailResult.unwrapErr());
|
||||
}
|
||||
email = emailResult.unwrap();
|
||||
|
||||
const existingByEmail = await this.userRepo.findByEmail(email.value);
|
||||
if (existingByEmail) {
|
||||
throw new ConflictException('Email đã được đăng ký');
|
||||
}
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const passwordResult = await HashedPassword.fromPlain(command.password);
|
||||
if (passwordResult.isErr) {
|
||||
throw new BadRequestException(passwordResult.unwrapErr());
|
||||
}
|
||||
const passwordHash = passwordResult.unwrap();
|
||||
|
||||
// Create user entity
|
||||
const userId = createId();
|
||||
const user = UserEntity.createNew(userId, phone, command.fullName, passwordHash, email);
|
||||
|
||||
// Persist
|
||||
await this.userRepo.save(user);
|
||||
|
||||
// Publish domain events
|
||||
const events = user.clearDomainEvents();
|
||||
for (const event of events) {
|
||||
this.eventBus.publish(event);
|
||||
}
|
||||
|
||||
// Generate tokens
|
||||
return this.tokenService.generateTokenPair({
|
||||
sub: user.id,
|
||||
phone: user.phone.value,
|
||||
role: user.role,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { type KYCStatus } from '@prisma/client';
|
||||
|
||||
export class VerifyKycCommand {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly kycStatus: KYCStatus,
|
||||
public readonly kycData?: Record<string, unknown>,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { Inject, NotFoundException } from '@nestjs/common';
|
||||
import { VerifyKycCommand } from './verify-kyc.command';
|
||||
import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositories/user.repository';
|
||||
|
||||
@CommandHandler(VerifyKycCommand)
|
||||
export class VerifyKycHandler implements ICommandHandler<VerifyKycCommand> {
|
||||
constructor(
|
||||
@Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository,
|
||||
) {}
|
||||
|
||||
async execute(command: VerifyKycCommand): Promise<void> {
|
||||
const user = await this.userRepo.findById(command.userId);
|
||||
if (!user) {
|
||||
throw new NotFoundException('Người dùng không tồn tại');
|
||||
}
|
||||
|
||||
user.updateKycStatus(command.kycStatus, command.kycData);
|
||||
await this.userRepo.update(user);
|
||||
}
|
||||
}
|
||||
12
apps/api/src/modules/auth/application/index.ts
Normal file
12
apps/api/src/modules/auth/application/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export { RegisterUserCommand } from './commands/register-user/register-user.command';
|
||||
export { RegisterUserHandler } from './commands/register-user/register-user.handler';
|
||||
export { LoginUserCommand } from './commands/login-user/login-user.command';
|
||||
export { LoginUserHandler } from './commands/login-user/login-user.handler';
|
||||
export { RefreshTokenCommand } from './commands/refresh-token/refresh-token.command';
|
||||
export { RefreshTokenHandler } from './commands/refresh-token/refresh-token.handler';
|
||||
export { VerifyKycCommand } from './commands/verify-kyc/verify-kyc.command';
|
||||
export { VerifyKycHandler } from './commands/verify-kyc/verify-kyc.handler';
|
||||
export { GetProfileQuery } from './queries/get-profile/get-profile.query';
|
||||
export { GetProfileHandler, type UserProfileDto } from './queries/get-profile/get-profile.handler';
|
||||
export { GetAgentByUserIdQuery } from './queries/get-agent-by-user-id/get-agent-by-user-id.query';
|
||||
export { GetAgentByUserIdHandler, type AgentDto } from './queries/get-agent-by-user-id/get-agent-by-user-id.handler';
|
||||
@@ -0,0 +1,46 @@
|
||||
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '@modules/shared/infrastructure/prisma.service';
|
||||
import { GetAgentByUserIdQuery } from './get-agent-by-user-id.query';
|
||||
|
||||
export interface AgentDto {
|
||||
id: string;
|
||||
userId: string;
|
||||
licenseNumber: string | null;
|
||||
agency: string | null;
|
||||
qualityScore: number;
|
||||
totalDeals: number;
|
||||
responseTimeAvg: number | null;
|
||||
bio: string | null;
|
||||
serviceAreas: unknown;
|
||||
isVerified: boolean;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@QueryHandler(GetAgentByUserIdQuery)
|
||||
export class GetAgentByUserIdHandler implements IQueryHandler<GetAgentByUserIdQuery> {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async execute(query: GetAgentByUserIdQuery): Promise<AgentDto | null> {
|
||||
const agent = await this.prisma.agent.findUnique({
|
||||
where: { userId: query.userId },
|
||||
});
|
||||
|
||||
if (!agent) return null;
|
||||
|
||||
return {
|
||||
id: agent.id,
|
||||
userId: agent.userId,
|
||||
licenseNumber: agent.licenseNumber,
|
||||
agency: agent.agency,
|
||||
qualityScore: agent.qualityScore,
|
||||
totalDeals: agent.totalDeals,
|
||||
responseTimeAvg: agent.responseTimeAvg,
|
||||
bio: agent.bio,
|
||||
serviceAreas: agent.serviceAreas,
|
||||
isVerified: agent.isVerified,
|
||||
createdAt: agent.createdAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export class GetAgentByUserIdQuery {
|
||||
constructor(public readonly userId: string) {}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import { Inject, NotFoundException } from '@nestjs/common';
|
||||
import { GetProfileQuery } from './get-profile.query';
|
||||
import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositories/user.repository';
|
||||
|
||||
export interface UserProfileDto {
|
||||
id: string;
|
||||
email: string | null;
|
||||
phone: string;
|
||||
fullName: string;
|
||||
avatarUrl: string | null;
|
||||
role: string;
|
||||
kycStatus: string;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
@QueryHandler(GetProfileQuery)
|
||||
export class GetProfileHandler implements IQueryHandler<GetProfileQuery> {
|
||||
constructor(
|
||||
@Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository,
|
||||
) {}
|
||||
|
||||
async execute(query: GetProfileQuery): Promise<UserProfileDto> {
|
||||
const user = await this.userRepo.findById(query.userId);
|
||||
if (!user) {
|
||||
throw new NotFoundException('Người dùng không tồn tại');
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email?.value ?? null,
|
||||
phone: user.phone.value,
|
||||
fullName: user.fullName,
|
||||
avatarUrl: user.avatarUrl,
|
||||
role: user.role,
|
||||
kycStatus: user.kycStatus,
|
||||
isActive: user.isActive,
|
||||
createdAt: user.createdAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export class GetProfileQuery {
|
||||
constructor(public readonly userId: string) {}
|
||||
}
|
||||
Reference in New Issue
Block a user