我们如何为承包商构建身份验证:GPS 评分、循环 QR 和 Google Wallet 通行证
Source: Dev.to
Lynk ID 后端亮点
我们构建了 Lynk ID —— 一个承包商身份信任层,房主只需扫描二维码或轻触 NFC 徽章,即可瞬间看到门口是谁,无需任一方下载应用。
本文涵盖了四个有趣的后端实现:
- 复合信任评分算法(以及我们为何砍掉 NFC 硬门槛)
- 使用 RSA‑SHA256 在 Node.js 中为 Google Wallet 通行证签名
- B2B webhook 响应封装
- 用于防止重放的循环 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 + 设备 + 生物特征 | 100 | ✅ | high |
| 设备 + 生物特征(QR 路径) | 65 | ✅ | medium |
| 仅 NFC,无生物特征 | 55 | ❌ | low |
| 什么都没有 | 35 | ❌ | low |
GPS 惩罚是累加的——距离 600 m 会导致总计 –20 分,这可能会把本来处于边缘的中等可信度批准转为拒绝。GPS 由客户端上报(我们不在服务器端进行三角定位),因此它只是众多信号之一,而非硬性门槛。
2. 使用 RSA‑SHA256 为 Google Wallet 通行证签名
Google Wallet 通行证通过使用服务账号私钥对 JWT 进行签名来发行。google-auth-library 的 JWT 类负责 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 更新日志位于 。