0

Thực chiến Device Authentication Bài 3: Thực hành Backend: Viết API cấp phép và xác thực thiết bị bằng Golang/Node.js + Redis.

Lý thuyết đã nắm vững rồi, ở Bài 3 này chúng ta sẽ xắn tay áo lên và đi vào thực chiến.

Để xử lý hàng ngàn request đồng thời từ các thiết bị trạm (như hệ thống AFC) mà không bị nghẽn cổ chai, Golang kết hợp cùng Redis là một sự lựa chọn kiến trúc hoàn hảo. Chúng ta sẽ viết một Middleware trong Go để chặn đứng mọi request không hợp lệ ngay từ cửa ngõ.


1. Quy ước giao tiếp (API Contract)

Trước khi code, chúng ta cần thống nhất với team làm firmware/phần cứng dưới thiết bị về cách gửi dữ liệu. Mọi API call từ thiết bị lên Backend phải tuân thủ chuẩn sau:

  • HTTP Method: POST
  • Body: Chứa JSON dữ liệu nghiệp vụ.
  • Headers bắt buộc:
    • X-Device-Id: Định danh phần cứng (ví dụ: AFC-GATE-01).
    • X-Timestamp: Thời gian hiện tại của thiết bị (Unix epoch, tính bằng giây).
    • X-Nonce: Chuỗi ngẫu nhiên duy nhất cho request này (UUID).
    • X-Signature: Chữ ký điện tử (Base64) được mã hóa bằng Private Key của thiết bị.

Quy tắc ký (Signing Rule): Chữ ký được tạo ra bằng cách lấy chuỗi nội dung Body + Timestamp + Nonce, băm bằng SHA-256 rồi ký bằng thuật toán RSA.


2. Bước đệm: Khởi tạo dữ liệu (Provisioning)

Giả sử thiết bị đã gửi Public Key lên server trong lần đầu tiên lắp đặt. Backend lưu Public Key này vào Database (PostgreSQL/MongoDB). Trong code ví dụ, chúng ta sẽ mock một function lấy khóa này ra.

// Giả lập truy vấn Database lấy Public Key theo DeviceID
func getDevicePublicKey(deviceID string) (*rsa.PublicKey, error) {
    // Trong thực tế, bạn query DB ở đây.
    // Dưới đây là Public Key mẫu (PEM format)
    pubPEM := `-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
-----END PUBLIC KEY-----`
    
    block, _ := pem.Decode([]byte(pubPEM))
    pub, err := x509.ParsePKIXPublicKey(block.Bytes)
    return pub.(*rsa.PublicKey), err
}

3. Trái tim của hệ thống: Device Authentication Middleware

Đây là chốt chặn an ninh quan trọng nhất. Code này sẽ thực thi chính xác 4 bước kiểm tra mà chúng ta đã vạch ra ở Bài 2.

package main

import (
	"bytes"
	"crypto"
	"crypto/rsa"
	"crypto/sha256"
	"encoding/base64"
	"fmt"
	"io"
	"net/http"
	"strconv"
	"time"

	"github.com/gin-gonic/gin"
	"github.com/go-redis/redis/v8"
)

var rdb *redis.Client // Redis client instance

// Middleware xác thực thiết bị
func DeviceAuthMiddleware() gin.HandlerFunc {
	return func(c *gin.Context) {
		// 1. Lấy dữ liệu từ Header
		deviceID := c.GetHeader("X-Device-Id")
		timestampStr := c.GetHeader("X-Timestamp")
		nonce := c.GetHeader("X-Nonce")
		signatureBase64 := c.GetHeader("X-Signature")

		if deviceID == "" || timestampStr == "" || nonce == "" || signatureBase64 == "" {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Missing required headers"})
			return
		}

		// 2. Kiểm tra Timestamp chống gói tin quá hạn
		reqTime, err := strconv.ParseInt(timestampStr, 10, 64)
		if err != nil {
			c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid timestamp format"})
			return
		}

		now := time.Now().Unix()
		// Cho phép độ trễ mạng tối đa 30 giây
		if now-reqTime > 30 || reqTime-now > 30 {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Request expired"})
			return
		}

		// 3. Kiểm tra Nonce chống Replay Attack bằng Redis SETNX
		// SETNX đảm bảo tính atomic: Nếu key đã tồn tại, trả về false ngay.
		// Set TTL bằng đúng 30 giây để Redis tự dọn rác.
		nonceKey := fmt.Sprintf("device_nonce:%s:%s", deviceID, nonce)
		isNew, err := rdb.SetNX(c.Request.Context(), nonceKey, "1", 30*time.Second).Result()
		if err != nil || !isNew {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Replay attack detected (duplicate nonce)"})
			return
		}

		// 4. Đọc Body để xác minh chữ ký
		bodyBytes, _ := io.ReadAll(c.Request.Body)
		// Khôi phục lại body vào request để các handler phía sau đọc được
		c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))

		// Tạo lại chuỗi gốc để verify: Body + Timestamp + Nonce
		originalData := string(bodyBytes) + timestampStr + nonce

		// 5. Xác minh chữ ký RSA
		pubKey, err := getDevicePublicKey(deviceID)
		if err != nil {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Device not provisioned"})
			return
		}

		sigBytes, err := base64.StdEncoding.DecodeString(signatureBase64)
		if err != nil {
			c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid signature format"})
			return
		}

		hashed := sha256.Sum256([]byte(originalData))
		err = rsa.VerifyPKCS1v15(pubKey, crypto.SHA256, hashed[:], sigBytes)
		if err != nil {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid signature"})
			return
		}

		// Nếu mọi thứ đều ổn, chuyển request tới tầng xử lý nghiệp vụ
		c.Next()
	}
}

4. Tích hợp vào Route và Xử lý

Bây giờ, chỉ cần gắn middleware này vào các API mà thiết bị gọi lên. Ví dụ với API báo cáo trừ tiền vé:

func main() {
	// Khởi tạo Redis (giản lược code connect)
	rdb = redis.NewClient(&redis.Options{Addr: "localhost:6379"})

	router := gin.Default()

	// Bất cứ request nào gọi vào nhóm /api/v1/device đều bị kiểm tra nghiêm ngặt
	deviceRoutes := router.Group("/api/v1/device")
	deviceRoutes.Use(DeviceAuthMiddleware())
	{
		deviceRoutes.POST("/deduct-balance", func(c *gin.Context) {
			// Lấy DeviceID từ header để biết thiết bị nào đang gọi
			deviceID := c.GetHeader("X-Device-Id")
			
			// Xử lý logic trừ tiền an toàn ở đây...
			
			c.JSON(http.StatusOK, gin.H{"status": "success", "message": "Transaction verified and processed"})
		})
	}

	router.Run(":8080")
}

💡 Bí mật của tốc độ

Nếu để ý, trong bước 3, chúng ta sử dụng hàm SetNX (Set if Not eXists) của Redis. Đây là một thao tác Atomic (nguyên tử). Nó gộp bước "kiểm tra xem Nonce có chưa" và "lưu Nonce vào" thành một chu kỳ CPU duy nhất của Redis.

Điều này triệt tiêu hoàn toàn lỗi Race Condition (khi hacker bắn 2 request có cùng Nonce lên server ở đúng một phần nghìn giây - nếu dùng hàm Get rồi Set riêng biệt, cả 2 request đều có thể lọt qua).


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí