Commit 97360cba authored by ThinhNC's avatar ThinhNC

feat(auth): integrate Zalo login via phone number and HMAC-SHA256 verification

parent c9394e72
......@@ -61,3 +61,7 @@ AI_MAX_OUTPUT_TOKENS=2048
AI_MAX_CONTEXT_TRANSACTIONS=200
AI_RATE_LIMIT_MAX_REQUESTS=20
AI_RATE_LIMIT_WINDOW_MS=900000
# Zalo Mini App credentials (from developers.zalo.me)
ZALO_APP_ID=your_zalo_app_id
ZALO_APP_SECRET=your_zalo_app_secret_key
import { Request, Response, NextFunction } from 'express';
import { AuthService } from './auth.service';
import { LoginDto, RegisterDto, UpdateProfileDto, UpdateAvatarDto, UpdatePasswordDto, ForgotPasswordDto, ResetPasswordDto, ResendVerificationDto, SessionQueryDto } from './auth.dto';
import { LoginDto, ZaloLoginDto, RegisterDto, UpdateProfileDto, UpdateAvatarDto, UpdatePasswordDto, ForgotPasswordDto, ResetPasswordDto, ResendVerificationDto, SessionQueryDto } from './auth.dto';
import { AppError } from '../../common/errors/app-error';
import { ERROR_CODE } from '../../common/errors/error-code';
......@@ -289,4 +289,38 @@ export class AuthController {
next(error);
}
};
loginWithZalo = async (req: Request, res: Response, next: NextFunction) => {
try {
const body = req.body as ZaloLoginDto;
const userAgent = req.headers['user-agent'];
const ipAddress = req.ip;
const result = await this.service.loginWithZalo(body, { userAgent, ipAddress });
res.cookie('accessToken', result.accessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 24 * 60 * 60 * 1000,
});
res.cookie('refreshToken', result.refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000,
});
res.json({
success: true,
data: {
user: result.user,
accessToken: result.accessToken,
refreshToken: result.refreshToken,
},
});
} catch (error) {
next(error);
}
};
}
......@@ -3,6 +3,24 @@ export interface LoginDto {
password: string;
}
export interface ZaloLoginDto {
accessToken: string; // Zalo access_token từ getAccessToken() SDK
phoneNumber: string; // SĐT thực từ getPhoneNumber() SDK
}
export interface ZaloProfileResponse {
id: string;
name?: string;
picture?: {
data?: {
url?: string;
};
};
error?: number;
message?: string;
is_sensitive?: boolean;
}
export interface RegisterDto {
email: string;
password: string;
......
......@@ -75,6 +75,7 @@ export class AuthRepository {
async createSocialUser(data: {
fullName?: string;
avatarUrl?: string;
phoneNumber?: string;
roleId: string;
provider: string;
providerUserId: string;
......@@ -83,6 +84,7 @@ export class AuthRepository {
data: {
fullName: data.fullName,
avatarUrl: data.avatarUrl,
phoneNumber: data.phoneNumber,
roleId: data.roleId,
isActive: true, // Social users are active immediately
socialAccounts: {
......@@ -96,6 +98,12 @@ export class AuthRepository {
});
}
async linkSocialAccount(userId: string, provider: string, providerUserId: string) {
return prisma.userSocial.create({
data: { userId, provider, providerUserId },
});
}
async createVerificationToken(userId: string, token: string, expiresAt: Date) {
return prisma.verificationToken.create({
data: {
......
......@@ -2,7 +2,7 @@ import { Router } from 'express';
import { AuthController } from './auth.controller';
import { authMiddleware } from '../../middlewares/auth.middleware';
import { validate } from '../../middlewares/validate.middleware';
import { loginSchema, refreshSchema, logoutSchema, registerSchema, verifyEmailSchema, updateProfileSchema, updateAvatarSchema, updatePasswordSchema, forgotPasswordSchema, resetPasswordSchema, resendVerificationSchema, sessionParamsSchema, sessionQuerySchema, revokeOtherSessionsSchema } from './auth.validation';
import { loginSchema, zaloLoginSchema, refreshSchema, logoutSchema, registerSchema, verifyEmailSchema, updateProfileSchema, updateAvatarSchema, updatePasswordSchema, forgotPasswordSchema, resetPasswordSchema, resendVerificationSchema, sessionParamsSchema, sessionQuerySchema, revokeOtherSessionsSchema } from './auth.validation';
const router = Router();
const controller = new AuthController();
......@@ -10,6 +10,7 @@ const controller = new AuthController();
router.post('/register', validate(registerSchema), controller.register);
router.get('/verify-email', validate(verifyEmailSchema, 'query'), controller.verifyEmail);
router.post('/login', validate(loginSchema), controller.login);
router.post('/zalo-login', validate(zaloLoginSchema), controller.loginWithZalo);
router.get('/me', authMiddleware, controller.me);
router.post('/refresh', validate(refreshSchema), controller.refresh);
router.post('/logout', validate(logoutSchema), controller.logout);
......
......@@ -5,7 +5,8 @@ import { AuthRepository } from './auth.repository';
import { AppError } from '../../common/errors/app-error';
import { ERROR_CODE } from '../../common/errors/error-code';
import { jwtConfig } from '../../config/jwt.config';
import { LoginDto, LoginResponseDto, AuthTokensDto, MeDto, RegisterDto, UpdateProfileDto, UpdateAvatarDto, UpdatePasswordDto, ForgotPasswordDto, ResetPasswordDto, ResendVerificationDto, SessionQueryDto, SessionsResponseDto } from './auth.dto';
import { LoginDto, ZaloLoginDto, ZaloProfileResponse, LoginResponseDto, AuthTokensDto, MeDto, RegisterDto, UpdateProfileDto, UpdateAvatarDto, UpdatePasswordDto, ForgotPasswordDto, ResetPasswordDto, ResendVerificationDto, SessionQueryDto, SessionsResponseDto } from './auth.dto';
import https from 'https';
import { MailService } from '../../common/services/mail.service';
import { generateDeviceHash, parseUserAgent } from '../../common/helpers/user-agent.helper';
import { rbacService } from '../rbac/rbac.service';
......@@ -440,4 +441,145 @@ export class AuthService {
async revokeAllOtherSessions(userId: string, currentToken: string) {
await this.repository.deleteOtherSessions(userId, currentToken);
}
async loginWithZalo(
dto: ZaloLoginDto,
metadata?: { userAgent?: string; ipAddress?: string },
): Promise<LoginResponseDto> {
const { accessToken, phoneNumber } = dto;
// 1. Xác thực access_token với Zalo Graph API
const appSecret = process.env.ZALO_APP_SECRET || '';
const appsecretProof = crypto
.createHmac('sha256', appSecret)
.update(accessToken)
.digest('hex');
const zaloProfile = await this.fetchZaloProfile(accessToken, appsecretProof);
if (!zaloProfile || zaloProfile.error !== 0) {
throw new AppError(
'Invalid Zalo access token or Zalo API error',
401,
ERROR_CODE.INVALID_CREDENTIALS,
);
}
const { id: zaloId, name: zaloName, picture } = zaloProfile;
const zaloAvatarUrl: string | null = picture?.data?.url || null;
// 2. Tìm hoặc tạo user theo SĐT
let user = await this.repository.findByPhone(phoneNumber);
if (user) {
// User đã tồn tại — liên kết Zalo ID nếu chưa có
const existing = await this.repository.findBySocial('zalo', zaloId);
if (!existing) {
await this.repository.linkSocialAccount(user.id, 'zalo', zaloId);
}
} else {
// User chưa tồn tại — tạo mới từ Zalo profile
const role = await this.repository.findRoleByName(SYSTEM_ROLES.USER);
if (!role) {
throw new AppError('Default role not found', 500, ERROR_CODE.INTERNAL_SERVER_ERROR);
}
user = await this.repository.createSocialUser({
fullName: zaloName || undefined,
avatarUrl: zaloAvatarUrl || undefined,
phoneNumber,
roleId: role.id,
provider: 'zalo',
providerUserId: zaloId,
});
}
// Load role relation nếu chưa có (createSocialUser đã include)
const userWithRole = await this.repository.findById(user.id);
if (!userWithRole || !userWithRole.isActive) {
throw new AppError('User not found or inactive', 401, ERROR_CODE.USER_INACTIVE);
}
// 3. Tạo JWT tokens
const payload = { id: userWithRole.id, email: userWithRole.email, role: userWithRole.role.name };
const newAccessToken = jwt.sign(payload, jwtConfig.accessSecret, {
expiresIn: jwtConfig.accessExpiresIn as any,
});
const refreshToken = jwt.sign(
{ ...payload, jti: crypto.randomUUID() },
jwtConfig.refreshSecret,
{ expiresIn: jwtConfig.refreshExpiresIn as any },
);
const decoded = jwt.decode(refreshToken) as { exp: number };
const expiresAt = new Date(decoded.exp * 1000);
await this.repository.saveRefreshToken(
userWithRole.id,
refreshToken,
expiresAt,
metadata?.userAgent,
metadata?.ipAddress,
);
await this.repository.cleanupExpiredSessions(userWithRole.id);
await this.repository.enforceSessionLimit(userWithRole.id, 10);
const permissions = await rbacService.getUserPermissions(userWithRole.id);
return {
user: {
id: userWithRole.id,
email: userWithRole.email,
fullName: userWithRole.fullName,
avatarUrl: userWithRole.avatarUrl,
avatarPositionX: userWithRole.avatarPositionX,
avatarPositionY: userWithRole.avatarPositionY,
phoneNumber: userWithRole.phoneNumber,
roleId: userWithRole.roleId,
role: {
id: userWithRole.role.id,
name: userWithRole.role.name,
description: userWithRole.role.description,
isSystem: userWithRole.role.isSystem,
},
permissions,
isActive: userWithRole.isActive,
createdAt: userWithRole.createdAt,
updatedAt: userWithRole.updatedAt,
},
accessToken: newAccessToken,
refreshToken,
};
}
private fetchZaloProfile(
accessToken: string,
appsecretProof: string,
): Promise<ZaloProfileResponse> {
return new Promise((resolve, reject) => {
const options = {
hostname: 'graph.zalo.me',
path: '/v2.0/me?fields=id,name,picture',
method: 'GET',
headers: {
access_token: accessToken,
appsecret_proof: appsecretProof,
},
};
const req = https.request(options, (res) => {
let data = '';
res.on('data', (chunk) => (data += chunk));
res.on('end', () => {
try {
resolve(JSON.parse(data) as ZaloProfileResponse);
} catch {
reject(new Error('Failed to parse Zalo API response'));
}
});
});
req.on('error', reject);
req.end();
});
}
}
......@@ -10,6 +10,14 @@ export const loginSchema = z.object({
password: z.string().min(1, 'Password is required'),
});
export const zaloLoginSchema = z.object({
accessToken: z.string().min(1, 'Zalo access token is required'),
phoneNumber: z
.string()
.min(1, 'Phone number is required')
.regex(/^(0[3|5|7|8|9])+([0-9]{8})$/, 'Invalid Vietnamese phone number format'),
});
export const refreshSchema = z.object({
refreshToken: z.string().optional(),
});
......
import request from 'supertest';
import app from '../src/app';
import { prisma } from '../src/database/prisma.client';
import { AuthService } from '../src/modules/auth/auth.service';
describe('Zalo Auth Integration Tests', () => {
const testPhone = '0987654321';
const testZaloId = 'zalo_test_user_id_99999';
const testZaloName = 'Nguyễn Văn Zalo';
const testAvatarUrl = 'https://s120.zadn.vn/avatar_test.jpg';
afterAll(async () => {
// Cleanup test user
const user = await prisma.user.findFirst({
where: { phoneNumber: testPhone },
});
if (user) {
await prisma.userSocial.deleteMany({ where: { userId: user.id } });
await prisma.refreshToken.deleteMany({ where: { userId: user.id } });
await prisma.userDevice.deleteMany({ where: { userId: user.id } });
await prisma.user.delete({ where: { id: user.id } });
}
});
describe('Validation', () => {
it('should reject request when accessToken is missing', async () => {
const res = await request(app)
.post('/api/v1/auth/zalo-login')
.send({ phoneNumber: testPhone });
expect(res.status).toBe(422);
expect(res.body.success).toBe(false);
expect(res.body.code).toBe('VALIDATION_ERROR');
});
it('should reject request when phoneNumber is missing', async () => {
const res = await request(app)
.post('/api/v1/auth/zalo-login')
.send({ accessToken: 'some_access_token' });
expect(res.status).toBe(422);
expect(res.body.success).toBe(false);
expect(res.body.code).toBe('VALIDATION_ERROR');
});
it('should reject request when phoneNumber is invalid Vietnamese format', async () => {
const res = await request(app)
.post('/api/v1/auth/zalo-login')
.send({
accessToken: 'some_access_token',
phoneNumber: '1234567890', // Invalid prefix
});
expect(res.status).toBe(422);
expect(res.body.success).toBe(false);
expect(res.body.code).toBe('VALIDATION_ERROR');
});
});
describe('Invalid Zalo Token', () => {
it('should return 401 when Zalo access token is invalid', async () => {
const res = await request(app)
.post('/api/v1/auth/zalo-login')
.send({
accessToken: 'invalid_dummy_token',
phoneNumber: testPhone,
});
expect(res.status).toBe(401);
expect(res.body.success).toBe(false);
expect(res.body.code).toBe('INVALID_CREDENTIALS');
});
});
describe('Successful Zalo Login Flow', () => {
let mockFetchZalo: jest.SpyInstance;
beforeEach(() => {
// Mock fetchZaloProfile on AuthService prototype
mockFetchZalo = jest.spyOn(AuthService.prototype as any, 'fetchZaloProfile').mockResolvedValue({
id: testZaloId,
name: testZaloName,
error: 0,
message: 'Success',
picture: {
data: {
url: testAvatarUrl,
},
},
});
});
afterEach(() => {
mockFetchZalo.mockRestore();
});
it('should create new user and return tokens when phone does not exist yet', async () => {
const res = await request(app)
.post('/api/v1/auth/zalo-login')
.send({
accessToken: 'valid_mock_token_123',
phoneNumber: testPhone,
});
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.data).toHaveProperty('accessToken');
expect(res.body.data).toHaveProperty('refreshToken');
expect(res.body.data.user).toHaveProperty('phoneNumber', testPhone);
expect(res.body.data.user).toHaveProperty('fullName', testZaloName);
expect(res.body.data.user).toHaveProperty('avatarUrl', testAvatarUrl);
expect(res.body.data.user).toHaveProperty('isActive', true);
// Verify cookies are set
const cookies = res.headers['set-cookie'] as unknown as string[];
expect(cookies).toBeDefined();
expect(cookies.some((c: string) => c.includes('accessToken'))).toBe(true);
expect(cookies.some((c: string) => c.includes('refreshToken'))).toBe(true);
// Verify DB record
const dbUser = await prisma.user.findFirst({
where: { phoneNumber: testPhone },
include: { socialAccounts: true },
});
expect(dbUser).not.toBeNull();
expect(dbUser?.socialAccounts.length).toBe(1);
expect(dbUser?.socialAccounts[0].provider).toBe('zalo');
expect(dbUser?.socialAccounts[0].providerUserId).toBe(testZaloId);
});
it('should login existing user and return tokens without duplicate creation', async () => {
const res = await request(app)
.post('/api/v1/auth/zalo-login')
.send({
accessToken: 'valid_mock_token_123',
phoneNumber: testPhone,
});
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.data.user.phoneNumber).toBe(testPhone);
// Ensure no duplicate users were created
const count = await prisma.user.count({
where: { phoneNumber: testPhone },
});
expect(count).toBe(1);
});
});
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment