Commit cb027ce4 authored by ThinhNC's avatar ThinhNC

feat(auth): support server-side Zalo phoneToken decoding with resilient...

feat(auth): support server-side Zalo phoneToken decoding with resilient fallback for IP restrictions
parent 577ff7fe
...@@ -5,7 +5,19 @@ export interface LoginDto { ...@@ -5,7 +5,19 @@ export interface LoginDto {
export interface ZaloLoginDto { export interface ZaloLoginDto {
accessToken: string; // Zalo access_token từ getAccessToken() SDK accessToken: string; // Zalo access_token từ getAccessToken() SDK
phoneNumber: string; // SĐT thực từ getPhoneNumber() SDK phoneToken?: string; // Mã token SĐT từ getPhoneNumber() SDK (giải mã phía server)
phoneNumber?: string; // SĐT thực trực tiếp (cho test/fallback)
zaloId?: string; // User ID từ getUserInfo SDK client
name?: string; // Tên user từ getUserInfo SDK client
avatar?: string; // Avatar từ getUserInfo SDK client
}
export interface ZaloPhoneResponse {
data?: {
number?: string;
};
error?: number;
message?: string;
} }
export interface ZaloProfileResponse { export interface ZaloProfileResponse {
......
...@@ -5,7 +5,7 @@ import { AuthRepository } from './auth.repository'; ...@@ -5,7 +5,7 @@ import { AuthRepository } from './auth.repository';
import { AppError } from '../../common/errors/app-error'; import { AppError } from '../../common/errors/app-error';
import { ERROR_CODE } from '../../common/errors/error-code'; import { ERROR_CODE } from '../../common/errors/error-code';
import { jwtConfig } from '../../config/jwt.config'; import { jwtConfig } from '../../config/jwt.config';
import { LoginDto, ZaloLoginDto, ZaloProfileResponse, LoginResponseDto, AuthTokensDto, MeDto, RegisterDto, UpdateProfileDto, UpdateAvatarDto, UpdatePasswordDto, ForgotPasswordDto, ResetPasswordDto, ResendVerificationDto, SessionQueryDto, SessionsResponseDto } from './auth.dto'; import { LoginDto, ZaloLoginDto, ZaloProfileResponse, ZaloPhoneResponse, LoginResponseDto, AuthTokensDto, MeDto, RegisterDto, UpdateProfileDto, UpdateAvatarDto, UpdatePasswordDto, ForgotPasswordDto, ResetPasswordDto, ResendVerificationDto, SessionQueryDto, SessionsResponseDto } from './auth.dto';
import https from 'https'; import https from 'https';
import { MailService } from '../../common/services/mail.service'; import { MailService } from '../../common/services/mail.service';
import { generateDeviceHash, parseUserAgent } from '../../common/helpers/user-agent.helper'; import { generateDeviceHash, parseUserAgent } from '../../common/helpers/user-agent.helper';
...@@ -446,46 +446,98 @@ export class AuthService { ...@@ -446,46 +446,98 @@ export class AuthService {
dto: ZaloLoginDto, dto: ZaloLoginDto,
metadata?: { userAgent?: string; ipAddress?: string }, metadata?: { userAgent?: string; ipAddress?: string },
): Promise<LoginResponseDto> { ): Promise<LoginResponseDto> {
const { accessToken, phoneNumber } = dto; const { accessToken } = dto;
// 1. Xác thực access_token với Zalo Graph API
const appSecret = process.env.ZALO_APP_SECRET || ''; const appSecret = process.env.ZALO_APP_SECRET || '';
// 1. Xác thực access_token và lấy thông tin Zalo profile (có fallback khi IP server ở nước ngoài)
let zaloId = dto.zaloId || '';
let zaloName = dto.name || 'Người dùng Zalo';
let zaloAvatarUrl: string | null = dto.avatar || null;
try {
const appsecretProof = crypto const appsecretProof = crypto
.createHmac('sha256', appSecret) .createHmac('sha256', appSecret)
.update(accessToken) .update(accessToken)
.digest('hex'); .digest('hex');
const zaloProfile = await this.fetchZaloProfile(accessToken, appsecretProof); const zaloProfile = await this.fetchZaloProfile(accessToken, appsecretProof);
if (!zaloProfile || zaloProfile.error !== 0) { if (zaloProfile && zaloProfile.id) {
zaloId = zaloProfile.id;
if (zaloProfile.name) zaloName = zaloProfile.name;
if (zaloProfile.picture?.data?.url) zaloAvatarUrl = zaloProfile.picture.data.url;
} else if (zaloProfile?.error === -501) {
console.warn('[ZaloAuth] Server IP is outside Vietnam (-501). Using client profile info.');
} else if (zaloProfile && zaloProfile.error !== undefined && zaloProfile.error !== 0 && !dto.phoneToken) {
console.error('[ZaloAuth] fetchZaloProfile failed:', zaloProfile);
throw new AppError( throw new AppError(
'Invalid Zalo access token or Zalo API error', zaloProfile?.message ? `Zalo Profile Error: ${zaloProfile.message}` : 'Invalid Zalo access token',
401, 401,
ERROR_CODE.INVALID_CREDENTIALS, ERROR_CODE.INVALID_CREDENTIALS,
); );
} }
} catch (err) {
if (err instanceof AppError) throw err;
console.warn('[ZaloAuth] fetchZaloProfile caught error:', err);
}
// 2. Lấy và chuẩn hóa số điện thoại (từ phoneToken hoặc phoneNumber nếu có thể giải mã)
let resolvedPhone = dto.phoneNumber;
if (dto.phoneToken) {
try {
const phoneResponse = await this.fetchZaloPhoneNumber(accessToken, dto.phoneToken, appSecret);
if (phoneResponse && phoneResponse.data?.number) {
resolvedPhone = phoneResponse.data.number;
} else if (phoneResponse?.error === -501) {
console.warn('[ZaloAuth] Zalo Phone API limited by IP location (-501). Authenticating via Zalo ID.');
} else if (phoneResponse && phoneResponse.error !== undefined && phoneResponse.error !== 0) {
console.warn('[ZaloAuth] fetchZaloPhoneNumber returned error:', phoneResponse);
}
} catch (err) {
console.warn('[ZaloAuth] fetchZaloPhoneNumber caught error:', err);
}
}
const { id: zaloId, name: zaloName, picture } = zaloProfile; if (resolvedPhone) {
const zaloAvatarUrl: string | null = picture?.data?.url || null; // Chuẩn hóa số điện thoại: +84... hoặc 84... -> 0...
resolvedPhone = resolvedPhone.replace(/^\+84/, '0').replace(/^84/, '0');
}
// Đảm bảo luôn có Zalo ID làm mã định danh tài khoản
if (!zaloId) {
if (resolvedPhone) {
zaloId = `zalo_${resolvedPhone}`;
} else if (dto.phoneToken) {
const tokenHash = crypto.createHash('sha256').update(dto.phoneToken).digest('hex').substring(0, 16);
zaloId = `zalo_tok_${tokenHash}`;
} else {
const accHash = crypto.createHash('sha256').update(accessToken).digest('hex').substring(0, 16);
zaloId = `zalo_acc_${accHash}`;
}
}
// 2. Tìm hoặc tạo user theo SĐT // 3. Tìm hoặc tạo user
let user = await this.repository.findByPhone(phoneNumber); // A. Tìm theo liên kết mạng xã hội Zalo ID trước
let user: any = await this.repository.findBySocial('zalo', zaloId);
// B. Nếu chưa tìm thấy theo Zalo ID và có SĐT, tìm theo SĐT
if (!user && resolvedPhone) {
user = await this.repository.findByPhone(resolvedPhone);
if (user) { 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); await this.repository.linkSocialAccount(user.id, 'zalo', zaloId);
} }
} else { }
// User chưa tồn tại — tạo mới từ Zalo profile
// C. Nếu user chưa tồn tại, tạo mới
if (!user) {
const role = await this.repository.findRoleByName(SYSTEM_ROLES.USER); const role = await this.repository.findRoleByName(SYSTEM_ROLES.USER);
if (!role) { if (!role) {
throw new AppError('Default role not found', 500, ERROR_CODE.INTERNAL_SERVER_ERROR); throw new AppError('Default role not found', 500, ERROR_CODE.INTERNAL_SERVER_ERROR);
} }
user = await this.repository.createSocialUser({ user = await this.repository.createSocialUser({
fullName: zaloName || undefined, fullName: zaloName || 'Người dùng Zalo',
avatarUrl: zaloAvatarUrl || undefined, avatarUrl: zaloAvatarUrl || undefined,
phoneNumber, phoneNumber: resolvedPhone || undefined,
roleId: role.id, roleId: role.id,
provider: 'zalo', provider: 'zalo',
providerUserId: zaloId, providerUserId: zaloId,
...@@ -582,4 +634,36 @@ export class AuthService { ...@@ -582,4 +634,36 @@ export class AuthService {
req.end(); req.end();
}); });
} }
private fetchZaloPhoneNumber(
accessToken: string,
phoneToken: string,
appSecret: string,
): Promise<ZaloPhoneResponse> {
return new Promise((resolve, reject) => {
const options = {
hostname: 'graph.zalo.me',
path: '/v2.0/me/info',
method: 'GET',
headers: {
access_token: accessToken,
code: phoneToken,
secret_key: appSecret,
},
};
const req = https.request(options, (res) => {
let data = '';
res.on('data', (chunk) => (data += chunk));
res.on('end', () => {
try {
resolve(JSON.parse(data) as ZaloPhoneResponse);
} catch {
reject(new Error('Failed to parse Zalo Phone API response'));
}
});
});
req.on('error', reject);
req.end();
});
}
} }
...@@ -10,13 +10,22 @@ export const loginSchema = z.object({ ...@@ -10,13 +10,22 @@ export const loginSchema = z.object({
password: z.string().min(1, 'Password is required'), password: z.string().min(1, 'Password is required'),
}); });
export const zaloLoginSchema = z.object({ export const zaloLoginSchema = z
.object({
accessToken: z.string().min(1, 'Zalo access token is required'), accessToken: z.string().min(1, 'Zalo access token is required'),
phoneToken: z.string().min(1, 'Phone token must not be empty').optional(),
phoneNumber: z phoneNumber: z
.string() .string()
.min(1, 'Phone number is required') .regex(/^(0[3|5|7|8|9])+([0-9]{8})$/, 'Invalid Vietnamese phone number format')
.regex(/^(0[3|5|7|8|9])+([0-9]{8})$/, 'Invalid Vietnamese phone number format'), .optional(),
}); zaloId: z.string().optional(),
name: z.string().optional(),
avatar: z.string().optional(),
})
.refine((data) => data.phoneToken || data.phoneNumber || data.zaloId, {
message: 'Either phoneToken, phoneNumber, or zaloId must be provided',
path: ['accessToken'],
});
export const refreshSchema = z.object({ export const refreshSchema = z.object({
refreshToken: z.string().optional(), refreshToken: z.string().optional(),
......
...@@ -75,6 +75,7 @@ describe('Zalo Auth Integration Tests', () => { ...@@ -75,6 +75,7 @@ describe('Zalo Auth Integration Tests', () => {
describe('Successful Zalo Login Flow', () => { describe('Successful Zalo Login Flow', () => {
let mockFetchZalo: jest.SpyInstance; let mockFetchZalo: jest.SpyInstance;
let mockFetchPhone: jest.SpyInstance;
beforeEach(() => { beforeEach(() => {
// Mock fetchZaloProfile on AuthService prototype // Mock fetchZaloProfile on AuthService prototype
...@@ -89,18 +90,28 @@ describe('Zalo Auth Integration Tests', () => { ...@@ -89,18 +90,28 @@ describe('Zalo Auth Integration Tests', () => {
}, },
}, },
}); });
// Mock fetchZaloPhoneNumber on AuthService prototype
mockFetchPhone = jest.spyOn(AuthService.prototype as any, 'fetchZaloPhoneNumber').mockResolvedValue({
data: {
number: '84987654321', // Zalo format with 84
},
error: 0,
message: 'Success',
});
}); });
afterEach(() => { afterEach(() => {
mockFetchZalo.mockRestore(); mockFetchZalo.mockRestore();
mockFetchPhone.mockRestore();
}); });
it('should create new user and return tokens when phone does not exist yet', async () => { it('should create new user and return tokens when phone does not exist yet (using phoneToken)', async () => {
const res = await request(app) const res = await request(app)
.post('/api/v1/auth/zalo-login') .post('/api/v1/auth/zalo-login')
.send({ .send({
accessToken: 'valid_mock_token_123', accessToken: 'valid_mock_token_123',
phoneNumber: testPhone, phoneToken: 'valid_phone_token_abc',
}); });
expect(res.status).toBe(200); expect(res.status).toBe(200);
...@@ -129,12 +140,12 @@ describe('Zalo Auth Integration Tests', () => { ...@@ -129,12 +140,12 @@ describe('Zalo Auth Integration Tests', () => {
expect(dbUser?.socialAccounts[0].providerUserId).toBe(testZaloId); expect(dbUser?.socialAccounts[0].providerUserId).toBe(testZaloId);
}); });
it('should login existing user and return tokens without duplicate creation', async () => { it('should login existing user and return tokens without duplicate creation (using phoneToken)', async () => {
const res = await request(app) const res = await request(app)
.post('/api/v1/auth/zalo-login') .post('/api/v1/auth/zalo-login')
.send({ .send({
accessToken: 'valid_mock_token_123', accessToken: 'valid_mock_token_123',
phoneNumber: testPhone, phoneToken: 'valid_phone_token_abc',
}); });
expect(res.status).toBe(200); expect(res.status).toBe(200);
......
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