Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
F
finwise-miniapp-be
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
ThinhNC
finwise-miniapp-be
Commits
5c0b7946
Commit
5c0b7946
authored
Aug 29, 2026
by
ThinhNC
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat(auth): support Zalo phoneToken decoding and validation
parent
e5fef1a7
Changes
4
Show whitespace changes
Inline
Side-by-side
Showing
4 changed files
with
98 additions
and
18 deletions
+98
-18
auth.dto.ts
src/modules/auth/auth.dto.ts
+10
-1
auth.service.ts
src/modules/auth/auth.service.ts
+60
-6
auth.validation.ts
src/modules/auth/auth.validation.ts
+13
-7
zalo-auth.test.ts
tests/zalo-auth.test.ts
+15
-4
No files found.
src/modules/auth/auth.dto.ts
View file @
5c0b7946
...
@@ -5,7 +5,16 @@ export interface LoginDto {
...
@@ -5,7 +5,16 @@ 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)
}
export
interface
ZaloPhoneResponse
{
data
?:
{
number
?:
string
;
};
error
?:
number
;
message
?:
string
;
}
}
export
interface
ZaloProfileResponse
{
export
interface
ZaloProfileResponse
{
...
...
src/modules/auth/auth.service.ts
View file @
5c0b7946
...
@@ -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,10 +446,10 @@ export class AuthService {
...
@@ -446,10 +446,10 @@ 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
;
const
appSecret
=
process
.
env
.
ZALO_APP_SECRET
||
''
;
// 1. Xác thực access_token với Zalo Graph API
// 1. Xác thực access_token với Zalo Graph API
const
appSecret
=
process
.
env
.
ZALO_APP_SECRET
||
''
;
const
appsecretProof
=
crypto
const
appsecretProof
=
crypto
.
createHmac
(
'sha256'
,
appSecret
)
.
createHmac
(
'sha256'
,
appSecret
)
.
update
(
accessToken
)
.
update
(
accessToken
)
...
@@ -467,8 +467,30 @@ export class AuthService {
...
@@ -467,8 +467,30 @@ export class AuthService {
const
{
id
:
zaloId
,
name
:
zaloName
,
picture
}
=
zaloProfile
;
const
{
id
:
zaloId
,
name
:
zaloName
,
picture
}
=
zaloProfile
;
const
zaloAvatarUrl
:
string
|
null
=
picture
?.
data
?.
url
||
null
;
const
zaloAvatarUrl
:
string
|
null
=
picture
?.
data
?.
url
||
null
;
// 2. Tìm hoặc tạo user theo SĐT
// 2. Lấy và chuẩn hóa số điện thoại (từ phoneToken hoặc phoneNumber)
let
user
=
await
this
.
repository
.
findByPhone
(
phoneNumber
);
let
resolvedPhone
=
dto
.
phoneNumber
;
if
(
dto
.
phoneToken
)
{
const
phoneResponse
=
await
this
.
fetchZaloPhoneNumber
(
accessToken
,
dto
.
phoneToken
,
appSecret
);
if
(
!
phoneResponse
||
phoneResponse
.
error
!==
0
||
!
phoneResponse
.
data
?.
number
)
{
throw
new
AppError
(
phoneResponse
?.
message
||
'Failed to decode phone number from Zalo token'
,
401
,
ERROR_CODE
.
INVALID_CREDENTIALS
,
);
}
resolvedPhone
=
phoneResponse
.
data
.
number
;
}
if
(
!
resolvedPhone
)
{
throw
new
AppError
(
'Phone number is required'
,
400
,
ERROR_CODE
.
VALIDATION_ERROR
);
}
// Chuẩn hóa số điện thoại: +84... hoặc 84... -> 0...
resolvedPhone
=
resolvedPhone
.
replace
(
/^
\+
84/
,
'0'
).
replace
(
/^84/
,
'0'
);
// 3. Tìm hoặc tạo user theo SĐT
let
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ó
// User đã tồn tại — liên kết Zalo ID nếu chưa có
...
@@ -485,7 +507,7 @@ export class AuthService {
...
@@ -485,7 +507,7 @@ export class AuthService {
user
=
await
this
.
repository
.
createSocialUser
({
user
=
await
this
.
repository
.
createSocialUser
({
fullName
:
zaloName
||
undefined
,
fullName
:
zaloName
||
undefined
,
avatarUrl
:
zaloAvatarUrl
||
undefined
,
avatarUrl
:
zaloAvatarUrl
||
undefined
,
phoneNumber
,
phoneNumber
:
resolvedPhone
,
roleId
:
role
.
id
,
roleId
:
role
.
id
,
provider
:
'zalo'
,
provider
:
'zalo'
,
providerUserId
:
zaloId
,
providerUserId
:
zaloId
,
...
@@ -582,4 +604,36 @@ export class AuthService {
...
@@ -582,4 +604,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
();
});
}
}
}
src/modules/auth/auth.validation.ts
View file @
5c0b7946
...
@@ -10,13 +10,19 @@ export const loginSchema = z.object({
...
@@ -10,13 +10,19 @@ 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
(),
});
})
.
refine
((
data
)
=>
data
.
phoneToken
||
data
.
phoneNumber
,
{
message
:
'Either phoneToken or phoneNumber must be provided'
,
path
:
[
'phoneToken'
],
});
export
const
refreshSchema
=
z
.
object
({
export
const
refreshSchema
=
z
.
object
({
refreshToken
:
z
.
string
().
optional
(),
refreshToken
:
z
.
string
().
optional
(),
...
...
tests/zalo-auth.test.ts
View file @
5c0b7946
...
@@ -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'
,
phone
Number
:
testPhone
,
phone
Token
:
'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'
,
phone
Number
:
testPhone
,
phone
Token
:
'valid_phone_token_abc'
,
});
});
expect
(
res
.
status
).
toBe
(
200
);
expect
(
res
.
status
).
toBe
(
200
);
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment