Tích hợp ZaloPay chuẩn Enterprise: Giải mã chữ ký MAC và bẫy bảo mật Webhook
Tích hợp cổng thanh toán (Payment Gateway) như ZaloPay là một "cú vấp" kinh điển của rất nhiều lập trình viên. Cái khó của ZaloPay không nằm ở việc gọi API, mà nằm ở Cơ chế băm chữ ký (MAC - Message Authentication Code) và Luồng bất đồng bộ (Webhook/Callback). Rất nhiều người làm xong phần tạo link thanh toán, nhưng đến lúc ZaloPay trả kết quả về (Callback) thì lại xử lý sai bét, dẫn đến việc hacker có thể giả mạo request để "hack" trạng thái đơn hàng thành "Đã thanh toán".
Hôm nay, anh em mình sẽ xây dựng một bộ code tích hợp ZaloPay hoàn toàn mới mẻ, chuẩn chỉ từ khâu sinh chữ ký cho đến khâu bảo mật Webhook. Bắt đầu thôi!
Lời mở đầu: Vì sao tích hợp ví điện tử lại khó?
Giao tiếp với ZaloPay là giao tiếp giữa 2 Server (Server của bạn và Server của ZaloPay). Trong môi trường Internet đầy rẫy hacker, làm sao ZaloPay biết request tạo đơn hàng là do chính server của bạn gửi? Và ngược lại, làm sao bạn biết request thông báo "Đã nhận tiền" thực sự đến từ ZaloPay chứ không phải do một gã hacker nào đó dùng Postman bắn vào?
Câu trả lời là Khóa bí mật (Key1, Key2) và Thuật toán băm mã (HMAC-SHA256).
Bài viết này sẽ hướng dẫn anh em đóng gói toàn bộ sự phức tạp đó vào một Service độc lập, giữ cho Controller luôn "sạch sẽ".
Bước 1: Khởi tạo dự án và Cấu hình thông tin (Sandbox)
Tạo một project mới tinh để thực hành:
laravel new zalopay-demo
cd zalopay-demo
ZaloPay cung cấp sẵn môi trường Sandbox (thử nghiệm) với các Key công khai. Hãy mở file .env và thêm các cấu hình này vào cuối file:
# ZALOPAY SANDBOX CONFIG
ZALOPAY_APP_ID=2553
ZALOPAY_KEY1=PcY4iZIKFCIdgZvA6ueMcMHHUbRLYjPL
ZALOPAY_KEY2=kLtgPl8YESDmyABkQgeZByOUJsbcpNI2
ZALOPAY_ENDPOINT=https://sb-openapi.zalopay.vn/v2/create
Tạo một file config để Laravel dễ dàng nạp các biến này ở bất cứ đâu. Tạo file config/zalopay.php:
<?php
return [
'app_id' => env('ZALOPAY_APP_ID'),
'key1' => env('ZALOPAY_KEY1'),
'key2' => env('ZALOPAY_KEY2'),
'endpoint' => env('ZALOPAY_ENDPOINT'),
];
Bước 2: Tạo ZaloPayService - Trái tim của luồng thanh toán
Quy tắc của hệ thống lớn: Tuyệt đối không nhét logic nối chuỗi, băm MAC vào Controller. Hãy tạo một Service chuyên lo việc nói chuyện với ZaloPay.
mkdir app/Services
Tạo file app/Services/ZaloPayService.php:
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class ZaloPayService
{
/**
* Hàm sinh mã đơn hàng yêu cầu gửi sang ZaloPay
*/
public function createOrder(string $orderId, int $amount, string $description): array
{
$app_id = config('zalopay.app_id');
$key1 = config('zalopay.key1');
$endpoint = config('zalopay.endpoint');
// ZaloPay yêu cầu mã giao dịch (app_trans_id) phải có format: yymmdd_xxxx
$app_trans_id = date("ymd") . "_" . $orderId;
// Cấu trúc dữ liệu ép buộc từ ZaloPay
$order = [
"app_id" => $app_id,
"app_time" => round(microtime(true) * 1000), // Thời gian tính bằng mili-giây
"app_trans_id" => $app_trans_id,
"app_user" => "demo_user",
"item" => json_encode([["item_name" => "Thanh toán đơn hàng", "item_price" => $amount, "item_quantity" => 1]]),
"embed_data" => json_encode(["redirecturl" => "http://localhost:8000/success"]),
"amount" => $amount,
"description" => $description,
"bank_code" => "", // Để rỗng thì ZaloPay sẽ hiện màn hình chọn phương thức (Thẻ/Ví)
];
// 1. Công thức tạo chữ ký (MAC) theo đúng chuẩn tài liệu ZaloPay
$data = $order["app_id"] . "|" . $order["app_trans_id"] . "|" . $order["app_user"] . "|" . $order["amount"] . "|" . $order["app_time"] . "|" . $order["embed_data"] . "|" . $order["item"];
$order["mac"] = hash_hmac("sha256", $data, $key1);
// 2. Gửi HTTP Request sang Server ZaloPay
try {
$response = Http::asForm()->post($endpoint, $order);
$result = $response->json();
if ($result['return_code'] == 1) {
return [
'success' => true,
'payment_url' => $result['order_url'], // URL để chuyển hướng user sang màn hình ZaloPay
'app_trans_id' => $app_trans_id
];
}
return ['success' => false, 'message' => $result['return_message']];
} catch (\Exception $e) {
Log::error("ZaloPay Create Order Error: " . $e->getMessage());
return ['success' => false, 'message' => 'Lỗi kết nối đến cổng thanh toán.'];
}
}
/**
* Hàm xác thực dữ liệu từ Callback của ZaloPay (Bảo mật sinh tử)
*/
public function verifyCallback(array $callbackData): bool
{
$key2 = config('zalopay.key2'); // Callback dùng KEY2 để verify
$mac = hash_hmac("sha256", $callbackData['data'], $key2);
// Kiểm tra xem chữ ký server băm ra có khớp với chữ ký ZaloPay gửi sang không
return strcmp($mac, $callbackData['mac']) === 0;
}
}
Bước 3: Controller mỏng dính & Luồng Webhook (Callback)
Controller của chúng ta sẽ chia làm 2 API:
createPayment: Frontend gọi API này để lấy cái Link thanh toán.callback: ZaloPay sẽ tự động gọi API này (chạy ngầm Server-to-Server) khi khách hàng thanh toán xong.
php artisan make:controller Api/PaymentController
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Services\ZaloPayService;
use Illuminate\Support\Facades\Log;
class PaymentController extends Controller
{
protected ZaloPayService $zaloPayService;
public function __construct(ZaloPayService $zaloPayService)
{
$this->zaloPayService = $zaloPayService;
}
public function createPayment(Request $request)
{
// Giả lập tạo một mã đơn hàng ngẫu nhiên (Thực tế bạn lấy từ Database)
$orderId = rand(100000, 999999);
$amount = 50000; // 50.000 VNĐ
$description = "Thanh toan don hang #{$orderId}";
$result = $this->zaloPayService->createOrder($orderId, $amount, $description);
if ($result['success']) {
return response()->json([
'success' => true,
'message' => 'Tạo link thanh toán thành công',
'payment_url' => $result['payment_url']
]);
}
return response()->json(['success' => false, 'message' => $result['message']], 400);
}
/**
* API này do ZaloPay gọi (Webhook)
*/
public function callback(Request $request)
{
$result = [];
$callbackData = $request->all();
try {
// 1. Kiểm tra chữ ký bảo mật (Ngăn chặn Hacker giả mạo)
$isValid = $this->zaloPayService->verifyCallback($callbackData);
if ($isValid) {
// 2. Chữ ký chuẩn -> Parse dữ liệu đơn hàng ra
$data = json_decode($callbackData['data'], true);
$app_trans_id = $data['app_trans_id'];
Log::info("Thanh toán thành công cho đơn hàng: " . $app_trans_id);
// THỰC TẾ Ở ĐÂY: Update trạng thái đơn hàng trong Database thành 'PAID'
// Order::where('trans_id', $app_trans_id)->update(['status' => 'paid']);
$result['return_code'] = 1;
$result['return_message'] = 'success';
} else {
// Chữ ký sai -> Từ chối
Log::warning("Callback ZaloPay sai chữ ký (MAC Mismatch)");
$result['return_code'] = -1;
$result['return_message'] = 'mac not equal';
}
} catch (\Exception $e) {
$result['return_code'] = 0; // ZaloPay sẽ hiểu là lỗi hệ thống và gửi lại Callback sau
$result['return_message'] = $e->getMessage();
}
// BẮT BUỘC phải trả về JSON format này cho ZaloPay
return response()->json($result);
}
}
Mở routes/api.php đăng ký Route:
use App\Http\Controllers\Api\PaymentController;
Route::post('/zalopay/create', [PaymentController::class, 'createPayment']);
Route::post('/zalopay/callback', [PaymentController::class, 'callback']);
Bước 4: Thử lửa với Postman
Hãy mở Terminal, chạy php artisan serve.
Kịch bản 1: Gọi API tạo đơn hàng (Frontend gọi)
- Method:
POST - URL:
[http://127.0.0.1:8000/api/zalopay/create](http://127.0.0.1:8000/api/zalopay/create) - Headers:
Accept:application/json
Kết quả nhận được (Response):
{
"success": true,
"message": "Tạo link thanh toán thành công",
"payment_url": "https://sb-openapi.zalopay.vn/v2/pay?order=eUa... (link rất dài)"
}
(Thực tế: Frontend sẽ lấy cái payment_url này, mở một tab mới hoặc WebView để khách hàng vào quét mã ZaloPay).
Kịch bản 2: Giả lập ZaloPay gửi Callback (Hacker gọi thử)
Vì server của bạn đang chạy ở localhost, ZaloPay thật không thể bắn request vào máy bạn được (trừ khi dùng ngrok). Ta sẽ dùng Postman đóng vai Hacker bắn request vào API Callback.
- Method:
POST - URL:
[http://127.0.0.1:8000/api/zalopay/callback](http://127.0.0.1:8000/api/zalopay/callback) - Body (raw - JSON): (Gửi bừa data)
{
"data": "{\"app_trans_id\":\"260504_123456\",\"amount\":50000}",
"mac": "chu_ky_gia_mao_cua_hacker"
}
Kết quả (Response):
{
"return_code": -1,
"return_message": "mac not equal"
}
Lớp khiên bảo mật bằng KEY2 hoạt động hoàn hảo! Nếu không có cơ chế verifyCallback() ở Service, Hacker đã lừa được hệ thống cập nhật đơn hàng thành "Đã thanh toán".
Tóm lại
Làm việc với Payment Gateway không chỉ là chuyện "gọi API nhét tham số là xong". Nó là bài toán về Mật mã học (HMAC) và Kiến trúc hướng sự kiện (Webhook).
- Tách biệt việc tạo chữ ký (MAC) vào tầng Service.
- Tuyệt đối không tin tưởng bất kỳ ai gọi vào API Webhook/Callback nếu chưa verify chữ ký (bằng Key2).
All rights reserved