我们如何为承包商构建身份验证:GPS 评分、循环 QR 和 Google Wallet 通行证

发布: (2026年3月8日 GMT+8 12:45)
9 分钟阅读
原文: Dev.to

Source: Dev.to

Lynk ID 后端亮点

我们构建了 Lynk ID —— 一个承包商身份信任层,房主只需扫描二维码或轻触 NFC 徽章,即可瞬间看到门口是谁,无需任一方下载应用。

本文涵盖了四个有趣的后端实现:

  1. 复合信任评分算法(以及我们为何砍掉 NFC 硬门槛)
  2. 使用 RSA‑SHA256 在 Node.js 中为 Google Wallet 通行证签名
  3. B2B webhook 响应封装
  4. 用于防止重放的循环 QR 令牌

1. 复合信任评分 — GPS、NFC、生物特征

早期的 /api/v1/verify 使用了一个天真的硬门槛:

// old — broken for QR‑only partners
const approved = score >= 70 && nfcTap && deviceMatch;

任何 发送 NFC 轻触的平台都会被自动拒绝,即使设备指纹和生物特征匹配度很高。
解决方案是改为 软惩罚 模型,并提供两条有效的批准路径。

// src/app/api/v1/verify/route.ts

type VerifyRequestBody = {
  endpointId?: string;
  referenceId?: string;
  subjectId?: string;
  signals?: {
    nfcTap?: boolean;
    deviceMatch?: boolean;
    biometricMatch?: boolean;
    gpsDistanceMeters?: number;
  };
};

function calculateVerification(body: VerifyRequestBody) {
  const nfcTap            = body.signals?.nfcTap === true;
  const deviceMatch       = body.signals?.deviceMatch === true;
  const biometricMatch   = body.signals?.biometricMatch === true;
  const gpsDistanceMeters = Math.max(0, Number(body.signals?.gpsDistanceMeters ?? 0));

  let score = 100;
  if (!nfcTap)            score -= 30; // NFC not required, but rewarded
  if (!deviceMatch)       score -= 20;
  if (!biometricMatch)    score -= 15;
  if (gpsDistanceMeters > 100)  score -= 10; // out of proximity tier 1
  if (gpsDistanceMeters > 500)  score -= 10; // out of proximity tier 2

  score = Math.max(0, Math.min(100, score));

  const nfcPath = nfcTap;
  const qrPath  = deviceMatch && biometricMatch;

  // Approved on either path — NFC gives higher score/confidence
  const approved = score >= 60 && (nfcPath || qrPath);

  const confidence =
    nfcTap && deviceMatch && biometricMatch ? "high" :
    approved                               ? "medium" : "low";

  const riskLevel = score >= 85 ? "low" : score >= 65 ? "medium" : "high";

  return {
    approved,
    score,
    confidence,
    riskLevel,
    reasonCodes: [
      !nfcTap && !qrPath          ? "missing_nfc_tap"       : null,
      !deviceMatch               ? "device_mismatch"       : null,
      !biometricMatch            ? "biometric_unverified"  : null,
      gpsDistanceMeters > 100    ? "distance_out_of_range" : null,
    ].filter(Boolean),
  };
}

按场景划分的得分细则

存在的信号得分是否批准可信度
NFC + 设备 + 生物特征100high
设备 + 生物特征(QR 路径)65medium
仅 NFC,无生物特征55low
什么都没有35low

GPS 惩罚是累加的——距离 600 m 会导致总计 –20 分,这可能会把本来处于边缘的中等可信度批准转为拒绝。GPS 由客户端上报(我们不在服务器端进行三角定位),因此它只是众多信号之一,而非硬性门槛。

2. 使用 RSA‑SHA256 为 Google Wallet 通行证签名

Google Wallet 通行证通过使用服务账号私钥对 JWT 进行签名来发行。google-auth-libraryJWT 类负责 OAuth 令牌,但对于 “保存到钱包” 链接,你直接签名——无需 OAuth 循环。

// src/lib/wallet/google.ts

import { createSign, randomUUID } from "crypto";

function base64UrlEncode(input: Buffer | string) {
  const buffer = Buffer.isBuffer(input) ? input : Buffer.from(input);
  return buffer
    .toString("base64")
    .replace(/\+/

```typescript
g, "-")
    .replace(/\//g, "_")
    .replace(/=+$/g, "");
}

function signRs256Jwt(
  header: Record<string, unknown>,
  payload: Record<string, unknown>,
  privateKey: string
) {
  const encodedHeader  = base64UrlEncode(JSON.stringify(header));
  const encodedPayload = base64UrlEncode(JSON.stringify(payload));
  const signingInput   = `${encodedHeader}.${encodedPayload}`;

  const signer = createSign("RSA-SHA256");
  signer.update(signingInput);
  signer.end();

  const signature        = signer.sign(privateKey);
  const encodedSignature = base64UrlEncode(signature);

  return `${signingInput}.${encodedSignature}`;
}

export function createGoogleWalletAddLink(input: {
  subjectId?: string;
  displayName: string;
  subtitle?: string;
  qrValue?: string;
}) {
  const { issuerId, serviceAccountEmail, privateKey, baseUrl } = getGoogleWalletConfig();

  const classId       = `${issuerId}.lynk_verified_v1`;
  const objectSuffix  = sanitizeId(input.subjectId || randomUUID());
  const objectId      = `${issuerId}.${objectSuffix}`;
  const qrValue       = input.qrValue ??
    `${baseUrl}/scan/${encodeURIComponent(input.subjectId || objectSuffix)}`;

  const claims = {
    iss: serviceAccountEmail,
    aud: "google",
    typ: "savetowallet",
    origins: [baseUrl],
    payload: {
      genericObjects: [
        {
          id: objectId,
          classId,
          state: "ACTIVE",
          cardTitle:  { defaultValue: { language: "en-US", value: input.displayName } },
          header:     { defaultValue: { language: "en-US", value: "Verified Identity" } },
          subheader:  { defaultValue: { language: "en-US", value: input.subtitle || "Contractor Trust Pass" } },
          barcode: {
            type:  "QR_CODE",
            value: qrValue,
          },
        },
      ],
    },
  };

  const token = signRs256Jwt({ alg: "RS256", typ: "JWT" }, claims, privateKey);

  return {
    addToWalletUrl: `https://pay.google.com/gp/v/save/${token}`,
    classId,
    objectId,
  };
}

关键点: 环境必须以 PEM 格式公开 私钥;建议从密钥管理服务或环境变量中加载,以避免意外提交。

3. B2B webhook 响应封装

(内容已省略——原文包括 JSON 封装模式、版本控制策略以及错误处理约定。)

4. 轮转 QR 令牌以防重放

(内容已省略——原文说明了如何在 QR 负载中嵌入短期有效的 HMAC 签名令牌,服务器如何验证该令牌,以及轮转机制如何减轻重放攻击。)

PEM 密钥标准化

环境变量经常会从 .env 文件、Vercel 仪表盘或 Docker secret 中带有转义的换行符 (\\n)。我们在将密钥传递给 createSign 之前进行标准化:

function normalizePrivateKey(rawKey: string) {
  let k = (rawKey || "").trim();

  // Strip surrounding quotes
  if (
    (k.startsWith('"') && k.endsWith('"')) ||
    (k.startsWith("'") && k.endsWith("'"))
  ) {
    k = k.slice(1, -1);
  }

  return k
    .replace(/\\r\\n/g, "\n")
    .replace(/\\n/g, "\n")
    .replace(/\r\n/g, "\n")
    .trim();
}

我们还会在任何签名尝试之前验证 PEM 边界——否则,格式错误的密钥会在 Vercel 边缘日志深处触发难以理解的 OpenSSL 错误:

if (
  !privateKey.includes("BEGIN PRIVATE KEY") ||
  !privateKey.includes("END PRIVATE KEY")
) {
  throw new Error(
    "Invalid GOOGLE_WALLET_SERVICE_ACCOUNT_PRIVATE_KEY: include full PEM boundaries."
  );
}

B2B Webhook / Verify Response Envelope

当网约车平台或企业合作伙伴 POST/api/v1/verify 时,响应封装如下所示:

{
  "verificationId": "a3c82f1e-9b47-4d2e-bf03-1234abcd5678",
  "apiVersion": "1.0.0",
  "endpointId": "lyft-dfw-staging",
  "referenceId": "trip_abc123",
  "subjectId": "usr_ryan_sideris",
  "result": {
    "approved": true,
    "score": 65,
    "confidence": "medium",
    "riskLevel": "medium",
    "reasonCodes": []
  },
  "billing": {
    "billable": true,
    "unit": "scan",
    "quantity": 1
  },
  "processedAt": "2026-03-07T14:22:01.003Z"
}

每个事件也会通过 REST API 异步持久化到 Firestore 的 api_verifications 集合(在 Node.js edge 函数中无需导入 SDK):

async function persistVerificationEvent(event: Record<string, unknown>) {
  const url = `https://firestore.googleapis.com/v1/projects/${projectId}/databases/(default)/documents/api_verifications?key=${apiKey}`;

  await fetch(url, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ fields: toFirestoreFields(event) })
  });
}

持久化失败会被捕获并记录日志,但不会阻塞响应——慢速的 Firestore 写入不应给合作伙伴的实时司机接入流程增加延迟。

限流在进程内运行(内存 Map),键为 API 密钥的截断 SHA‑256 哈希。这可以在日志中隐藏密钥本身,同时仍然支持基于每个密钥的窗口限流:

function hashApiKey(rawKey: string): string {
  return createHash("sha256").update(rawKey).digest("hex").slice(0, 12);
}

循环 QR — 无需后端往返的重放防护

静态 QR URL 就是一次截图攻击的潜在风险。我们的 QR 值每 60 秒轮换一次,使用 Math.floor(Date.now() / 60000) 作为附加在 URL 后的时间窗口令牌。扫描端会验证该令牌不超过 1 个窗口。

// src/app/dashboard/badges/page.tsx
const [timeWindow, setTimeWindow] = useState(() => Math.floor(Date.now() / 60000));
const [countdown, setCountdown] = useState(() => 60 - (Math.floor(Date.now() / 1000) % 60));

// 1‑second tick — updates token and countdown display simultaneously
useEffect(() => {
  const tick = setInterval(() => {
    const now = Date.now();
    setTimeWindow(Math.floor(now / 60000));
    setCountdown(60 - (Math.floor(now / 1000) % 60));
  }, 1000);
  return () => clearInterval(tick);
}, []);

实时 QR 显示

{/* visual indicator */}
<div>
  Refreshes in {countdown}s
</div>

?t= 参数是 Unix 分钟索引 —— Math.floor(epoch_ms / 60000)。扫描端接受 ± 1 分钟的窗口,以容纳承包商手机与服务器之间的时钟偏差,使实际的重放窗口为 60–120 秒,而不是无限。

为什么不使用服务器签发的 nonce?
nonce 需要承包商手机在显示 QR 之前进行一次网络往返,这会破坏离线可用、即时展示的用户体验。时间窗口方案在零延迟的前提下提供了大部分的重放防护。

接下来

  • Apple Wallet pkpass 签名(PassKit + WWDR 证书链)
  • 服务器端 ?t= 验证在 /verify/[tagUid] 扫描路由上
  • 背景检查状态嵌入在 wallet‑pass 子标题中
  • 用于合作伙伴事件投递的 Webhook HMAC 签名

如果这些内容有用,API 文档位于 ,V2 更新日志位于 。

0 浏览
Back to Blog

相关文章

阅读更多 »