TEC-2722 — PATCH /api/v1/auth/profile now accepts phoneNumber alongside fullName, avatarUrl, and email. Phone changes are deferred until the user confirms the SMS OTP via POST /api/v1/auth/profile/verify-phone, mirroring the existing email-change OTP flow. - Add PhoneChangeRequestedEvent + user.phone_change_otp SMS template - Add VerifyPhoneChangeHandler with Redis-backed 10-minute OTP - Re-check phone uniqueness at verify time to catch races - Extend unit tests for UpdateProfileHandler + add VerifyPhoneChangeHandler spec Co-Authored-By: Paperclip <noreply@paperclip.ing>
346 lines
15 KiB
TypeScript
346 lines
15 KiB
TypeScript
import {
|
|
Body,
|
|
Controller,
|
|
Get,
|
|
Patch,
|
|
Post,
|
|
Req,
|
|
Res,
|
|
UseGuards,
|
|
} from '@nestjs/common';
|
|
import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
|
|
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiBody } from '@nestjs/swagger';
|
|
import { Throttle } from '@nestjs/throttler';
|
|
import { type Request, type Response } from 'express';
|
|
import {
|
|
EndpointRateLimit,
|
|
EndpointRateLimitGuard,
|
|
UnauthorizedException,
|
|
} from '@modules/shared';
|
|
import { GenerateKycUploadUrlsCommand } from '../../application/commands/generate-kyc-upload-urls/generate-kyc-upload-urls.command';
|
|
import { LoginUserCommand } from '../../application/commands/login-user/login-user.command';
|
|
import { type LoginResult } from '../../application/commands/login-user/login-user.handler';
|
|
import { RefreshTokenCommand } from '../../application/commands/refresh-token/refresh-token.command';
|
|
import { RegisterUserCommand } from '../../application/commands/register-user/register-user.command';
|
|
import { SubmitKycCommand } from '../../application/commands/submit-kyc/submit-kyc.command';
|
|
import { UpdateProfileCommand } from '../../application/commands/update-profile/update-profile.command';
|
|
import { type UpdateProfileResultDto } from '../../application/commands/update-profile/update-profile.handler';
|
|
import { VerifyEmailChangeCommand } from '../../application/commands/verify-email-change/verify-email-change.command';
|
|
import { type VerifyEmailChangeResultDto } from '../../application/commands/verify-email-change/verify-email-change.handler';
|
|
import { VerifyKycCommand } from '../../application/commands/verify-kyc/verify-kyc.command';
|
|
import { VerifyPhoneChangeCommand } from '../../application/commands/verify-phone-change/verify-phone-change.command';
|
|
import { type VerifyPhoneChangeResultDto } from '../../application/commands/verify-phone-change/verify-phone-change.handler';
|
|
import { type AgentDto } from '../../application/queries/get-agent-by-user-id/get-agent-by-user-id.handler';
|
|
import { GetAgentByUserIdQuery } from '../../application/queries/get-agent-by-user-id/get-agent-by-user-id.query';
|
|
import { type UserProfileDto } from '../../application/queries/get-profile/get-profile.handler';
|
|
import { GetProfileQuery } from '../../application/queries/get-profile/get-profile.query';
|
|
import { type TokenService, type JwtPayload, type TokenPair } from '../../infrastructure/services/token.service';
|
|
import { type LocalStrategyResult } from '../../infrastructure/strategies/local.strategy';
|
|
import { CurrentUser } from '../decorators/current-user.decorator';
|
|
import { Roles } from '../decorators/roles.decorator';
|
|
import { type GenerateKycUploadUrlsDto } from '../dto/generate-kyc-upload-urls.dto';
|
|
import { LoginDto } from '../dto/login.dto';
|
|
import { type RefreshTokenDto } from '../dto/refresh-token.dto';
|
|
import { type RegisterDto } from '../dto/register.dto';
|
|
import { type SubmitKycDto } from '../dto/submit-kyc.dto';
|
|
import { type UpdateProfileDto } from '../dto/update-profile.dto';
|
|
import { type VerifyEmailChangeDto } from '../dto/verify-email-change.dto';
|
|
import { type VerifyKycDto } from '../dto/verify-kyc.dto';
|
|
import { type VerifyPhoneChangeDto } from '../dto/verify-phone-change.dto';
|
|
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
|
import { LocalAuthGuard } from '../guards/local-auth.guard';
|
|
import { RolesGuard } from '../guards/roles.guard';
|
|
|
|
const IS_PRODUCTION = process.env['NODE_ENV'] === 'production';
|
|
const IS_TEST = process.env['NODE_ENV'] === 'test';
|
|
const AUTH_RATE_LIMIT = IS_TEST ? 10_000 : 5;
|
|
const ACCESS_TOKEN_MAX_AGE = 15 * 60 * 1000; // 15 minutes
|
|
const REFRESH_TOKEN_MAX_AGE = 30 * 24 * 60 * 60 * 1000; // 30 days
|
|
const AUTH_COOKIE_MAX_AGE = 30 * 24 * 60 * 60 * 1000; // 30 days
|
|
|
|
const SAME_SITE = IS_PRODUCTION ? 'strict' : 'lax';
|
|
|
|
function setAuthCookies(res: Response, tokens: TokenPair): void {
|
|
res.cookie('access_token', tokens.accessToken, {
|
|
httpOnly: true,
|
|
secure: IS_PRODUCTION,
|
|
sameSite: SAME_SITE,
|
|
path: '/',
|
|
maxAge: ACCESS_TOKEN_MAX_AGE,
|
|
});
|
|
res.cookie('refresh_token', tokens.refreshToken, {
|
|
httpOnly: true,
|
|
secure: IS_PRODUCTION,
|
|
sameSite: SAME_SITE,
|
|
path: '/',
|
|
maxAge: REFRESH_TOKEN_MAX_AGE,
|
|
});
|
|
res.cookie('goodgo_authenticated', '1', {
|
|
httpOnly: false,
|
|
secure: IS_PRODUCTION,
|
|
sameSite: 'lax',
|
|
path: '/',
|
|
maxAge: AUTH_COOKIE_MAX_AGE,
|
|
});
|
|
}
|
|
|
|
function clearAuthCookies(res: Response): void {
|
|
res.clearCookie('access_token', { path: '/' });
|
|
res.clearCookie('refresh_token', { path: '/' });
|
|
res.clearCookie('goodgo_authenticated', { path: '/' });
|
|
}
|
|
|
|
@ApiTags('auth')
|
|
@Controller('auth')
|
|
export class AuthController {
|
|
constructor(
|
|
private readonly commandBus: CommandBus,
|
|
private readonly queryBus: QueryBus,
|
|
private readonly tokenService: TokenService,
|
|
) {}
|
|
|
|
@Throttle({ default: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT }, auth: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT } })
|
|
@EndpointRateLimit({ limit: IS_TEST ? 10_000 : 3, windowSeconds: 60, keyStrategy: 'ip' })
|
|
@UseGuards(EndpointRateLimitGuard)
|
|
@Post('register')
|
|
@ApiOperation({ summary: 'Register a new user' })
|
|
@ApiResponse({ status: 201, description: 'User registered, auth cookies set' })
|
|
@ApiResponse({ status: 400, description: 'Validation error' })
|
|
@ApiResponse({ status: 409, description: 'Phone already registered' })
|
|
async register(
|
|
@Body() dto: RegisterDto,
|
|
@Res({ passthrough: true }) res: Response,
|
|
): Promise<{ message: string; accessToken: string; refreshToken: string }> {
|
|
const tokens: TokenPair = await this.commandBus.execute(
|
|
new RegisterUserCommand(dto.phone, dto.password, dto.fullName, dto.email),
|
|
);
|
|
setAuthCookies(res, tokens);
|
|
return {
|
|
message: 'Đăng ký thành công',
|
|
accessToken: tokens.accessToken,
|
|
refreshToken: tokens.refreshToken,
|
|
};
|
|
}
|
|
|
|
@Throttle({ default: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT }, auth: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT } })
|
|
@EndpointRateLimit({ limit: IS_TEST ? 10_000 : 5, windowSeconds: 60, keyStrategy: 'ip' })
|
|
@UseGuards(EndpointRateLimitGuard, LocalAuthGuard)
|
|
@Post('login')
|
|
@ApiOperation({ summary: 'Login with phone and password' })
|
|
@ApiBody({ type: LoginDto })
|
|
@ApiResponse({ status: 201, description: 'Login successful, auth cookies set (or MFA challenge returned)' })
|
|
@ApiResponse({ status: 401, description: 'Invalid credentials' })
|
|
async login(
|
|
@CurrentUser() user: LocalStrategyResult,
|
|
@Res({ passthrough: true }) res: Response,
|
|
): Promise<{ message: string; accessToken?: string; refreshToken?: string; requiresMfa?: boolean; challengeId?: string }> {
|
|
const result: LoginResult = await this.commandBus.execute(
|
|
new LoginUserCommand(user.id, user.phone, user.role, user.isMfaRequired),
|
|
);
|
|
|
|
if (result.requiresMfa) {
|
|
return {
|
|
message: 'Yêu cầu xác thực MFA',
|
|
requiresMfa: true,
|
|
challengeId: result.challengeId,
|
|
};
|
|
}
|
|
|
|
setAuthCookies(res, result.tokens!);
|
|
return {
|
|
message: 'Đăng nhập thành công',
|
|
accessToken: result.tokens!.accessToken,
|
|
refreshToken: result.tokens!.refreshToken,
|
|
};
|
|
}
|
|
|
|
@Throttle({ default: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT }, auth: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT } })
|
|
@Post('refresh')
|
|
@ApiOperation({ summary: 'Refresh access token using refresh cookie' })
|
|
@ApiResponse({ status: 201, description: 'New auth cookies set' })
|
|
@ApiResponse({ status: 401, description: 'Invalid or expired refresh token' })
|
|
async refresh(
|
|
@Req() req: Request,
|
|
@Res({ passthrough: true }) res: Response,
|
|
@Body() dto?: RefreshTokenDto,
|
|
): Promise<{ message: string; accessToken: string; refreshToken: string }> {
|
|
const refreshToken =
|
|
(req.cookies?.['refresh_token'] as string | undefined) ?? dto?.refreshToken;
|
|
if (!refreshToken) {
|
|
throw new UnauthorizedException('Refresh token not found');
|
|
}
|
|
const tokens: TokenPair = await this.commandBus.execute(
|
|
new RefreshTokenCommand(refreshToken),
|
|
);
|
|
setAuthCookies(res, tokens);
|
|
return {
|
|
message: 'Token refreshed',
|
|
accessToken: tokens.accessToken,
|
|
refreshToken: tokens.refreshToken,
|
|
};
|
|
}
|
|
|
|
@Post('logout')
|
|
@ApiOperation({ summary: 'Logout and clear auth cookies' })
|
|
@ApiResponse({ status: 200, description: 'Logged out' })
|
|
async logout(@Res({ passthrough: true }) res: Response): Promise<{ message: string }> {
|
|
clearAuthCookies(res);
|
|
return { message: 'Đã đăng xuất' };
|
|
}
|
|
|
|
@Post('exchange-token')
|
|
@ApiOperation({ summary: 'Exchange OAuth token pair for httpOnly cookies' })
|
|
@ApiResponse({ status: 201, description: 'Auth cookies set' })
|
|
@ApiResponse({ status: 401, description: 'Invalid access token' })
|
|
async exchangeToken(
|
|
@Body() body: { accessToken: string; refreshToken: string; expiresIn?: number },
|
|
@Res({ passthrough: true }) res: Response,
|
|
): Promise<{ message: string }> {
|
|
const payload = this.tokenService.verifyAccessToken(body.accessToken);
|
|
if (!payload) {
|
|
throw new UnauthorizedException('Invalid access token');
|
|
}
|
|
setAuthCookies(res, {
|
|
accessToken: body.accessToken,
|
|
refreshToken: body.refreshToken,
|
|
expiresIn: body.expiresIn ?? 900,
|
|
});
|
|
return { message: 'Auth cookies set' };
|
|
}
|
|
|
|
@UseGuards(JwtAuthGuard)
|
|
@Get('profile')
|
|
@ApiBearerAuth('JWT')
|
|
@ApiOperation({ summary: 'Get current user profile' })
|
|
@ApiResponse({ status: 200, description: 'User profile returned' })
|
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
async getProfile(@CurrentUser() user: JwtPayload): Promise<UserProfileDto> {
|
|
return this.queryBus.execute(new GetProfileQuery(user.sub));
|
|
}
|
|
|
|
@UseGuards(JwtAuthGuard)
|
|
@Patch('profile')
|
|
@ApiBearerAuth('JWT')
|
|
@ApiOperation({ summary: 'Update current user profile' })
|
|
@ApiResponse({ status: 200, description: 'Profile updated successfully' })
|
|
@ApiResponse({ status: 400, description: 'Validation error' })
|
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
@ApiResponse({ status: 409, description: 'Email already in use' })
|
|
async updateProfile(
|
|
@CurrentUser() user: JwtPayload,
|
|
@Body() dto: UpdateProfileDto,
|
|
): Promise<{ message: string; data: UpdateProfileResultDto }> {
|
|
const result: UpdateProfileResultDto = await this.commandBus.execute(
|
|
new UpdateProfileCommand(user.sub, dto.fullName, dto.avatarUrl, dto.email, dto.phoneNumber),
|
|
);
|
|
return { message: 'Cập nhật hồ sơ thành công', data: result };
|
|
}
|
|
|
|
@UseGuards(JwtAuthGuard)
|
|
@Post('profile/verify-phone')
|
|
@ApiBearerAuth('JWT')
|
|
@ApiOperation({ summary: 'Verify phone number change with SMS OTP code' })
|
|
@ApiResponse({ status: 201, description: 'Phone number changed successfully' })
|
|
@ApiResponse({ status: 400, description: 'Invalid or expired OTP code' })
|
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
@ApiResponse({ status: 409, description: 'Phone number already in use' })
|
|
async verifyPhoneChange(
|
|
@CurrentUser() user: JwtPayload,
|
|
@Body() dto: VerifyPhoneChangeDto,
|
|
): Promise<{ message: string; data: VerifyPhoneChangeResultDto }> {
|
|
const result: VerifyPhoneChangeResultDto = await this.commandBus.execute(
|
|
new VerifyPhoneChangeCommand(user.sub, dto.code),
|
|
);
|
|
return { message: 'Số điện thoại đã được cập nhật thành công', data: result };
|
|
}
|
|
|
|
@UseGuards(JwtAuthGuard)
|
|
@Post('profile/verify-email')
|
|
@ApiBearerAuth('JWT')
|
|
@ApiOperation({ summary: 'Verify email change with OTP code' })
|
|
@ApiResponse({ status: 201, description: 'Email changed successfully' })
|
|
@ApiResponse({ status: 400, description: 'Invalid or expired OTP code' })
|
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
@ApiResponse({ status: 409, description: 'Email already in use' })
|
|
async verifyEmailChange(
|
|
@CurrentUser() user: JwtPayload,
|
|
@Body() dto: VerifyEmailChangeDto,
|
|
): Promise<{ message: string; data: VerifyEmailChangeResultDto }> {
|
|
const result: VerifyEmailChangeResultDto = await this.commandBus.execute(
|
|
new VerifyEmailChangeCommand(user.sub, dto.code),
|
|
);
|
|
return { message: 'Email đã được cập nhật thành công', data: result };
|
|
}
|
|
|
|
@UseGuards(JwtAuthGuard)
|
|
@Get('profile/agent')
|
|
@ApiBearerAuth('JWT')
|
|
@ApiOperation({ summary: 'Get agent profile for current user' })
|
|
@ApiResponse({ status: 200, description: 'Agent profile returned (null if not an agent)' })
|
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
async getAgentProfile(@CurrentUser() user: JwtPayload): Promise<AgentDto | null> {
|
|
return this.queryBus.execute(new GetAgentByUserIdQuery(user.sub));
|
|
}
|
|
|
|
@UseGuards(JwtAuthGuard)
|
|
@Post('kyc/upload-urls')
|
|
@ApiBearerAuth('JWT')
|
|
@ApiOperation({ summary: 'Generate presigned upload URLs for KYC images' })
|
|
@ApiResponse({ status: 201, description: 'Presigned URLs generated' })
|
|
@ApiResponse({ status: 400, description: 'Validation error' })
|
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
async generateKycUploadUrls(
|
|
@Body() body: GenerateKycUploadUrlsDto,
|
|
@CurrentUser() user: JwtPayload,
|
|
): Promise<{ field: string; uploadUrl: string; publicUrl: string; objectKey: string }[]> {
|
|
return this.commandBus.execute(
|
|
new GenerateKycUploadUrlsCommand(user.sub, body.files),
|
|
);
|
|
}
|
|
|
|
@UseGuards(JwtAuthGuard)
|
|
@Post('kyc/submit')
|
|
@ApiBearerAuth('JWT')
|
|
@ApiOperation({ summary: 'Submit KYC documents with presigned image URLs' })
|
|
@ApiResponse({ status: 201, description: 'KYC documents submitted successfully' })
|
|
@ApiResponse({ status: 400, description: 'Validation error' })
|
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
async submitKyc(
|
|
@Body() body: SubmitKycDto,
|
|
@CurrentUser() user: JwtPayload,
|
|
): Promise<{ message: string }> {
|
|
return this.commandBus.execute(
|
|
new SubmitKycCommand(
|
|
user.sub,
|
|
body.documentType,
|
|
body.documentNumber,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
{
|
|
frontImageUrl: body.frontImageUrl,
|
|
backImageUrl: body.backImageUrl,
|
|
selfieUrl: body.selfieUrl,
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
|
@Roles('ADMIN')
|
|
@Patch('kyc')
|
|
@ApiBearerAuth('JWT')
|
|
@ApiOperation({ summary: 'Verify user KYC (admin only)' })
|
|
@ApiResponse({ status: 200, description: 'KYC status updated' })
|
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
@ApiResponse({ status: 403, description: 'Forbidden — admin only' })
|
|
async verifyKyc(
|
|
@Body() dto: VerifyKycDto & { userId: string },
|
|
): Promise<{ message: string }> {
|
|
await this.commandBus.execute(
|
|
new VerifyKycCommand(dto.userId, dto.kycStatus, dto.kycData),
|
|
);
|
|
return { message: 'KYC status đã được cập nhật' };
|
|
}
|
|
}
|