Harden-biometric-verification
This commit is contained in:
@@ -164,6 +164,7 @@ Invoke-RestMethod -Method Post -Uri "https://api.telegram.org/bot$botToken/setWe
|
||||
## 说明
|
||||
|
||||
- 这里的生物识别使用的是 `Telegram.WebApp.BiometricManager`,不是 WebAuthn。
|
||||
- 首次使用生物识别时会通过 Telegram WebApp 在本机安全存储一个随机凭据;服务端只保存该凭据的哈希。
|
||||
- Telegram 小程序的 `initData` 会在服务端校验后,才接受验证结果。
|
||||
- 公共仓库使用者需要自己准备 Worker 域名、Turnstile、KV namespace 和 AI 凭据。
|
||||
|
||||
|
||||
387
src/index.ts
387
src/index.ts
@@ -45,7 +45,7 @@ interface TelegramApiResponse<T> {
|
||||
}
|
||||
|
||||
interface AiReview {
|
||||
decision: "approve" | "challenge";
|
||||
decision: "approve" | "challenge" | "reject";
|
||||
}
|
||||
|
||||
interface PendingJoinRecord {
|
||||
@@ -76,11 +76,25 @@ interface RequestContext {
|
||||
|
||||
const JOIN_PREFIX = "join:";
|
||||
const ACTIVE_PREFIX = "active:";
|
||||
const REJECT_PREFIX = "reject:";
|
||||
const BIOMETRIC_PREFIX = "bio:";
|
||||
const CHALLENGE_TTL_MS = 10 * 60 * 1000;
|
||||
const RECORD_TTL_SECONDS = 30 * 60;
|
||||
const REJECT_TTL_SECONDS = 60 * 60;
|
||||
const REJECT_BAN_THRESHOLD = 5;
|
||||
const BIOMETRIC_TOKEN_TTL_SECONDS = 90 * 24 * 60 * 60;
|
||||
const INIT_DATA_MAX_AGE_SECONDS = 10 * 60;
|
||||
type Locale = "zh" | "en";
|
||||
|
||||
interface BiometricCredentialRecord {
|
||||
userId: number;
|
||||
deviceId: string;
|
||||
tokenHash: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request, env, ctx): Promise<Response> {
|
||||
try {
|
||||
@@ -141,7 +155,8 @@ async function handleTelegramWebhook({ env, request }: RequestContext): Promise<
|
||||
}
|
||||
|
||||
const displayName = [join.from.first_name, join.from.last_name].filter(Boolean).join(" ").trim();
|
||||
const hasAvatar = await safelyFetchAvatarForUser(env, join.from.id);
|
||||
const avatar = await safelyFetchAvatarForUser(env, join.from.id);
|
||||
const hasAvatar = Boolean(avatar);
|
||||
const hasBio = Boolean(join.bio?.trim());
|
||||
const now = Date.now();
|
||||
const locale = detectLocale(join.from.language_code);
|
||||
@@ -190,6 +205,8 @@ async function handleTelegramWebhook({ env, request }: RequestContext): Promise<
|
||||
const aiReview = await safelyReviewWithAi(env, {
|
||||
displayName,
|
||||
bio: join.bio!,
|
||||
avatarBase64: avatar!.base64,
|
||||
avatarMimeType: avatar!.mimeType,
|
||||
});
|
||||
|
||||
if (!aiReview) {
|
||||
@@ -228,13 +245,20 @@ async function handleTelegramWebhook({ env, request }: RequestContext): Promise<
|
||||
return json({ ok: true, decision: "challenge", risk: "high" });
|
||||
}
|
||||
|
||||
const risk = decideRiskLevel(aiReview);
|
||||
if (risk === "low") {
|
||||
if (aiReview.decision === "approve") {
|
||||
await approveJoinRequest(env, join.chat.id, join.from.id);
|
||||
console.log("join request approved", { chatId: join.chat.id, userId: join.from.id, risk: "low" });
|
||||
return json({ ok: true, decision: "approved" });
|
||||
}
|
||||
|
||||
if (aiReview.decision === "reject") {
|
||||
await rejectOrBanJoinRequest(env, join.chat.id, join.from.id, "ai_reject");
|
||||
console.log("join request rejected", { chatId: join.chat.id, userId: join.from.id, source: "ai_reject" });
|
||||
return json({ ok: true, decision: "rejected" });
|
||||
}
|
||||
|
||||
const risk: RiskLevel = "high";
|
||||
|
||||
const token = crypto.randomUUID();
|
||||
const record: PendingJoinRecord = {
|
||||
token,
|
||||
@@ -288,7 +312,11 @@ async function handleVerifyPage({ env, request }: RequestContext): Promise<Respo
|
||||
}
|
||||
|
||||
async function handleTurnstileVerification({ env, request }: RequestContext): Promise<Response> {
|
||||
const body = (await request.json()) as { token?: string; turnstileToken?: string; initData?: string };
|
||||
const body = (await request.json()) as {
|
||||
token?: string;
|
||||
turnstileToken?: string;
|
||||
initData?: string;
|
||||
};
|
||||
if (!body.token || !body.turnstileToken || !body.initData) {
|
||||
return json({ ok: false, error: "missing_fields" }, 400);
|
||||
}
|
||||
@@ -311,8 +339,14 @@ async function handleTurnstileVerification({ env, request }: RequestContext): Pr
|
||||
}
|
||||
|
||||
async function handleBiometricVerification({ env, request }: RequestContext): Promise<Response> {
|
||||
const body = (await request.json()) as { token?: string; initData?: string };
|
||||
if (!body.token || !body.initData) {
|
||||
const body = (await request.json()) as {
|
||||
token?: string;
|
||||
initData?: string;
|
||||
biometricToken?: string;
|
||||
biometricDeviceId?: string;
|
||||
biometricMode?: "auth" | "enroll";
|
||||
};
|
||||
if (!body.token || !body.initData || !body.biometricToken || !body.biometricDeviceId || !body.biometricMode) {
|
||||
return json({ ok: false, error: "missing_fields" }, 400);
|
||||
}
|
||||
|
||||
@@ -326,6 +360,11 @@ async function handleBiometricVerification({ env, request }: RequestContext): Pr
|
||||
}
|
||||
|
||||
await verifyTelegramWebAppInitData(env, body.initData, record.userId);
|
||||
if (body.biometricMode === "auth") {
|
||||
await verifyBiometricCredential(env, record.userId, body.biometricDeviceId, body.biometricToken);
|
||||
} else {
|
||||
await storeBiometricCredential(env, record.userId, body.biometricDeviceId, body.biometricToken);
|
||||
}
|
||||
await finalizeVerification(env, record, "biometric");
|
||||
console.log("verification approved", { chatId: record.chatId, userId: record.userId, method: "biometric" });
|
||||
|
||||
@@ -393,20 +432,38 @@ async function verifyTelegramWebAppInitData(env: Env, initData: string, expected
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAvatarForUser(env: Env, userId: number): Promise<boolean> {
|
||||
const profilePhotos = await telegramCall<{ total_count: number }>(env, "getUserProfilePhotos", {
|
||||
async function fetchAvatarForUser(env: Env, userId: number): Promise<{ base64: string; mimeType: string } | null> {
|
||||
const profilePhotos = await telegramCall<{ total_count: number; photos: Array<Array<{ file_id: string }>> }>(env, "getUserProfilePhotos", {
|
||||
user_id: userId,
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
return profilePhotos.total_count > 0;
|
||||
if (!profilePhotos.total_count || !profilePhotos.photos.length || !profilePhotos.photos[0].length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sizes = profilePhotos.photos[0];
|
||||
const largest = sizes[sizes.length - 1];
|
||||
const file = await telegramCall<{ file_path: string }>(env, "getFile", { file_id: largest.file_id });
|
||||
const response = await fetch(`https://api.telegram.org/file/bot${env.BOT_TOKEN}/${file.file_path}`);
|
||||
if (!response.ok) {
|
||||
throw new HttpError(502, "failed_to_download_avatar");
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
const headerMimeType = response.headers.get("content-type") || "";
|
||||
const mimeType = inferImageMimeType(bytes) || (headerMimeType.startsWith("image/") ? headerMimeType : "image/jpeg");
|
||||
return {
|
||||
base64: bytesToBase64(bytes),
|
||||
mimeType,
|
||||
};
|
||||
}
|
||||
|
||||
async function safelyFetchAvatarForUser(env: Env, userId: number): Promise<boolean> {
|
||||
async function safelyFetchAvatarForUser(env: Env, userId: number): Promise<{ base64: string; mimeType: string } | null> {
|
||||
try {
|
||||
return await fetchAvatarForUser(env, userId);
|
||||
} catch {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -415,6 +472,8 @@ async function reviewWithAi(
|
||||
input: {
|
||||
displayName: string;
|
||||
bio: string;
|
||||
avatarBase64: string;
|
||||
avatarMimeType: string;
|
||||
},
|
||||
): Promise<AiReview> {
|
||||
const url = new URL("chat/completions", normalizeBaseUrl(env.AI_BASE_URL)).toString();
|
||||
@@ -427,27 +486,38 @@ async function reviewWithAi(
|
||||
body: JSON.stringify({
|
||||
model: env.AI_MODEL || "gpt-4.1-mini",
|
||||
temperature: 0,
|
||||
response_format: { type: "json_object" },
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content:
|
||||
"You review Telegram join requests for ad-spam risk using only display name and bio. Return strict JSON only with one field: decision. Decision must be exactly approve for normal genuine users and challenge for suspicious or uncertain users. If the profile looks like a real person, return approve.",
|
||||
"You review Telegram join requests for ad-spam risk using display name, bio, and avatar image. Return JSON only, with exactly one field: decision. The value must be exactly approve, challenge, or reject. Return approve for normal genuine users. Return challenge for uncertain users who should complete verification. Return reject only for obvious spam/ad accounts, including promotional avatars, QR codes, scam/crypto/forex/gambling ads, impersonation, or direct marketing profiles.",
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: JSON.stringify({
|
||||
display_name: input.displayName,
|
||||
bio: input.bio,
|
||||
task: "Evaluate whether this looks like a real personal profile instead of an ad or spam account.",
|
||||
}),
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
display_name: input.displayName,
|
||||
bio: input.bio,
|
||||
task: "Evaluate whether this looks like a real personal profile, an uncertain profile, or obvious spam/ad account. Pay attention to promotional content in the avatar image. Return only JSON like {\"decision\":\"approve\"}, {\"decision\":\"challenge\"}, or {\"decision\":\"reject\"}.",
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: `data:${input.avatarMimeType};base64,${input.avatarBase64}`,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new HttpError(502, `ai_request_failed:${response.status}`);
|
||||
const details = await response.text();
|
||||
throw new HttpError(502, `ai_request_failed:${response.status}:${details.slice(0, 500)}`);
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as {
|
||||
@@ -467,7 +537,7 @@ async function reviewWithAi(
|
||||
|
||||
const parsed = parseJsonFromText(text) as Partial<AiReview>;
|
||||
return {
|
||||
decision: parsed.decision === "approve" ? "approve" : "challenge",
|
||||
decision: parsed.decision === "approve" ? "approve" : parsed.decision === "reject" ? "reject" : "challenge",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -476,6 +546,8 @@ async function safelyReviewWithAi(
|
||||
input: {
|
||||
displayName: string;
|
||||
bio: string;
|
||||
avatarBase64: string;
|
||||
avatarMimeType: string;
|
||||
},
|
||||
): Promise<AiReview | null> {
|
||||
try {
|
||||
@@ -485,10 +557,6 @@ async function safelyReviewWithAi(
|
||||
}
|
||||
}
|
||||
|
||||
function decideRiskLevel(aiReview: AiReview): RiskLevel {
|
||||
return aiReview.decision === "approve" ? "low" : "high";
|
||||
}
|
||||
|
||||
async function persistPendingRecord(env: Env, record: PendingJoinRecord): Promise<void> {
|
||||
await env.PENDING_JOINS.put(`${JOIN_PREFIX}${record.token}`, JSON.stringify(record), { expirationTtl: RECORD_TTL_SECONDS });
|
||||
await env.PENDING_JOINS.put(activeJoinKey(record.chatId, record.userId), record.token, { expirationTtl: RECORD_TTL_SECONDS });
|
||||
@@ -539,6 +607,68 @@ async function finalizeVerification(env: Env, record: PendingJoinRecord, method:
|
||||
await env.PENDING_JOINS.delete(`${JOIN_PREFIX}${record.token}`);
|
||||
}
|
||||
|
||||
async function recordJoinRejection(env: Env, chatId: number, userId: number, source: string): Promise<number> {
|
||||
const key = rejectKey(chatId, userId);
|
||||
const current = Number((await env.PENDING_JOINS.get(key)) || "0");
|
||||
const next = Number.isFinite(current) ? current + 1 : 1;
|
||||
await env.PENDING_JOINS.put(key, String(next), { expirationTtl: REJECT_TTL_SECONDS });
|
||||
console.warn("join rejection recorded", {
|
||||
chatId,
|
||||
userId,
|
||||
source,
|
||||
rejections: next,
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
async function rejectOrBanJoinRequest(env: Env, chatId: number, userId: number, source: string): Promise<void> {
|
||||
const declined = await declineJoinRequest(env, chatId, userId);
|
||||
if (!declined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rejections = await recordJoinRejection(env, chatId, userId, source);
|
||||
if (rejections >= REJECT_BAN_THRESHOLD) {
|
||||
console.warn("banning requester after repeated join rejections", {
|
||||
chatId,
|
||||
userId,
|
||||
source,
|
||||
rejections,
|
||||
});
|
||||
|
||||
await banChatMember(env, chatId, userId);
|
||||
await env.PENDING_JOINS.delete(rejectKey(chatId, userId));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePendingJoinRejection(env: Env, record: PendingJoinRecord, source: string): Promise<void> {
|
||||
const declined = await declineJoinRequest(env, record.chatId, record.userId);
|
||||
if (!declined) {
|
||||
await deleteVerificationMessage(env, record);
|
||||
await env.PENDING_JOINS.delete(activeJoinKey(record.chatId, record.userId));
|
||||
await env.PENDING_JOINS.delete(`${JOIN_PREFIX}${record.token}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const rejections = await recordJoinRejection(env, record.chatId, record.userId, source);
|
||||
if (rejections >= REJECT_BAN_THRESHOLD) {
|
||||
console.warn("banning requester after repeated join rejections", {
|
||||
chatId: record.chatId,
|
||||
userId: record.userId,
|
||||
source,
|
||||
rejections,
|
||||
});
|
||||
|
||||
await banChatMember(env, record.chatId, record.userId);
|
||||
await env.PENDING_JOINS.delete(rejectKey(record.chatId, record.userId));
|
||||
}
|
||||
|
||||
await deleteVerificationMessage(env, record);
|
||||
await env.PENDING_JOINS.delete(activeJoinKey(record.chatId, record.userId));
|
||||
await env.PENDING_JOINS.delete(`${JOIN_PREFIX}${record.token}`);
|
||||
}
|
||||
|
||||
async function processExpiredRequests(env: Env, now: number): Promise<void> {
|
||||
let declinedCount = 0;
|
||||
let cursor: string | undefined;
|
||||
@@ -563,10 +693,14 @@ async function processExpiredRequests(env: Env, now: number): Promise<void> {
|
||||
continue;
|
||||
}
|
||||
|
||||
await declineJoinRequest(env, record.chatId, record.userId);
|
||||
await deleteVerificationMessage(env, record);
|
||||
await env.PENDING_JOINS.delete(activeJoinKey(record.chatId, record.userId));
|
||||
await env.PENDING_JOINS.delete(entry.name);
|
||||
console.log("declining expired join request", {
|
||||
chatId: record.chatId,
|
||||
userId: record.userId,
|
||||
token: record.token,
|
||||
createdAt: record.createdAt,
|
||||
expiresAt: record.expiresAt,
|
||||
});
|
||||
await handlePendingJoinRejection(env, record, "verification_timeout");
|
||||
declinedCount += 1;
|
||||
}
|
||||
} while (cursor);
|
||||
@@ -607,12 +741,86 @@ async function verifyTurnstile(env: Env, request: Request, token: string): Promi
|
||||
}
|
||||
}
|
||||
|
||||
async function approveJoinRequest(env: Env, chatId: number, userId: number): Promise<void> {
|
||||
await telegramCall(env, "approveChatJoinRequest", { chat_id: chatId, user_id: userId });
|
||||
async function storeBiometricCredential(env: Env, userId: number, deviceId: string, token: string): Promise<void> {
|
||||
if (!isValidBiometricDeviceId(deviceId) || !isValidBiometricToken(token)) {
|
||||
throw new HttpError(403, "invalid_biometric_token");
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const record: BiometricCredentialRecord = {
|
||||
userId,
|
||||
deviceId,
|
||||
tokenHash: await hashBiometricToken(env, userId, deviceId, token),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
expiresAt: now + BIOMETRIC_TOKEN_TTL_SECONDS * 1000,
|
||||
};
|
||||
|
||||
await env.PENDING_JOINS.put(await biometricCredentialKey(env, userId, deviceId), JSON.stringify(record), {
|
||||
expirationTtl: BIOMETRIC_TOKEN_TTL_SECONDS,
|
||||
});
|
||||
}
|
||||
|
||||
async function declineJoinRequest(env: Env, chatId: number, userId: number): Promise<void> {
|
||||
await telegramCall(env, "declineChatJoinRequest", { chat_id: chatId, user_id: userId });
|
||||
async function verifyBiometricCredential(
|
||||
env: Env,
|
||||
userId: number,
|
||||
deviceId: string,
|
||||
token: string,
|
||||
): Promise<void> {
|
||||
if (!isValidBiometricDeviceId(deviceId) || !isValidBiometricToken(token)) {
|
||||
throw new HttpError(403, "invalid_biometric_token");
|
||||
}
|
||||
|
||||
const raw = await env.PENDING_JOINS.get(await biometricCredentialKey(env, userId, deviceId));
|
||||
if (!raw) {
|
||||
throw new HttpError(403, "biometric_not_enrolled");
|
||||
}
|
||||
|
||||
const record = JSON.parse(raw) as BiometricCredentialRecord;
|
||||
if (record.userId !== userId || record.deviceId !== deviceId || record.expiresAt <= Date.now()) {
|
||||
throw new HttpError(403, "invalid_biometric_token");
|
||||
}
|
||||
|
||||
const tokenHash = await hashBiometricToken(env, userId, deviceId, token);
|
||||
if (!constantTimeEqual(tokenHash, record.tokenHash)) {
|
||||
throw new HttpError(403, "invalid_biometric_token");
|
||||
}
|
||||
|
||||
record.updatedAt = Date.now();
|
||||
await env.PENDING_JOINS.put(await biometricCredentialKey(env, userId, deviceId), JSON.stringify(record), {
|
||||
expirationTtl: BIOMETRIC_TOKEN_TTL_SECONDS,
|
||||
});
|
||||
}
|
||||
|
||||
async function approveJoinRequest(env: Env, chatId: number, userId: number): Promise<void> {
|
||||
try {
|
||||
await telegramCall(env, "approveChatJoinRequest", { chat_id: chatId, user_id: userId });
|
||||
} catch (error) {
|
||||
if (error instanceof HttpError && error.message.includes("USER_ALREADY_PARTICIPANT")) {
|
||||
console.warn("join request already approved", { chatId, userId });
|
||||
return;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function declineJoinRequest(env: Env, chatId: number, userId: number): Promise<boolean> {
|
||||
try {
|
||||
await telegramCall(env, "declineChatJoinRequest", { chat_id: chatId, user_id: userId });
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof HttpError && error.message.includes("HIDE_REQUESTER_MISSING")) {
|
||||
console.log("join request missing when declining", { chatId, userId });
|
||||
return false;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function banChatMember(env: Env, chatId: number, userId: number): Promise<void> {
|
||||
await telegramCall(env, "banChatMember", { chat_id: chatId, user_id: userId });
|
||||
}
|
||||
|
||||
async function telegramCall<T>(env: Env, method: string, payload: Record<string, unknown>): Promise<T> {
|
||||
@@ -690,6 +898,51 @@ var msg=document.getElementById("msg");
|
||||
try{if(tg){tg.ready();tg.expand()}}catch(e){}
|
||||
|
||||
function show(t,ok){msg.textContent=t;msg.className=ok?"ok":"err"}
|
||||
function bioDeviceId(){return bm&&bm.deviceId||""}
|
||||
|
||||
function newBioToken(){
|
||||
var bytes=new Uint8Array(32);
|
||||
crypto.getRandomValues(bytes);
|
||||
var text="";
|
||||
for(var i=0;i<bytes.length;i++)text+=String.fromCharCode(bytes[i]);
|
||||
return btoa(text).replace(/\\+/g,"-").replace(/\\//g,"_").replace(/=+$/,"");
|
||||
}
|
||||
|
||||
function postBio(mode,biometricToken){
|
||||
show(${JSON.stringify(copy.submittingVerification)},true);
|
||||
fetch(O+"/api/verify/biometric",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({token:T,initData:initData,biometricToken:biometricToken,biometricDeviceId:bioDeviceId(),biometricMode:mode})})
|
||||
.then(function(r){return r.json()})
|
||||
.then(function(d){if(d.ok){done()}else{show(d.error||${JSON.stringify(copy.verificationFailed)},false)}})
|
||||
.catch(function(e){show(${JSON.stringify(copy.networkErrorPrefix)}+e.message,false)});
|
||||
}
|
||||
|
||||
function withBioAccess(next){
|
||||
if(bm.isAccessGranted){next();return}
|
||||
bm.requestAccess({reason:${JSON.stringify(copy.biometricAccessReason)}},function(ok){
|
||||
if(ok){next()}else{show(${JSON.stringify(copy.biometricAccessDenied)},false)}
|
||||
});
|
||||
}
|
||||
|
||||
function enrollBio(){
|
||||
var biometricToken;
|
||||
try{biometricToken=newBioToken()}catch(e){show(${JSON.stringify(copy.biometricSetupFailed)},false);return}
|
||||
withBioAccess(function(){
|
||||
bm.updateBiometricToken(biometricToken,function(ok){
|
||||
if(!ok){show(${JSON.stringify(copy.biometricSetupFailed)},false);return}
|
||||
postBio("enroll",biometricToken);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function authBio(){
|
||||
withBioAccess(function(){
|
||||
bm.authenticate({reason:${JSON.stringify(copy.biometricAuthenticateReason)}},function(ok,biometricToken){
|
||||
if(!ok){show(${JSON.stringify(copy.biometricFailed)},false);return}
|
||||
if(!biometricToken){show(${JSON.stringify(copy.biometricTokenMissing)},false);return}
|
||||
postBio("auth",biometricToken);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function done(){
|
||||
show(${JSON.stringify("__SUCCESS__")},true);
|
||||
@@ -715,11 +968,6 @@ function initBio(){
|
||||
if(!bm.isBiometricAvailable){bioBtn.textContent=${JSON.stringify(copy.biometricUnavailable)};bioBtn.disabled=true;return}
|
||||
var label=bm.biometricType==="face"?${JSON.stringify(copy.faceId)}:bm.biometricType==="finger"?${JSON.stringify(copy.fingerprint)}:${JSON.stringify(copy.biometricLabel)};
|
||||
bioBtn.textContent=label;
|
||||
if(!bm.isAccessGranted){
|
||||
bm.requestAccess({reason:${JSON.stringify(copy.biometricAccessReason)}},function(ok){
|
||||
if(!ok){bioBtn.textContent=${JSON.stringify(copy.biometricAccessDenied)};bioBtn.disabled=true}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -728,14 +976,8 @@ initBio();
|
||||
document.getElementById("bio").addEventListener("click",function(){
|
||||
if(!initData){show(${JSON.stringify(copy.invalidEnvironment)},false);return}
|
||||
if(!bm||!bm.isInited||!bm.isBiometricAvailable){show(${JSON.stringify(copy.biometricUseTurnstile)},false);return}
|
||||
bm.authenticate({reason:${JSON.stringify(copy.biometricAuthenticateReason)}},function(ok){
|
||||
if(!ok){show(${JSON.stringify(copy.biometricFailed)},false);return}
|
||||
show(${JSON.stringify(copy.submittingVerification)},true);
|
||||
fetch(O+"/api/verify/biometric",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({token:T,initData:initData})})
|
||||
.then(function(r){return r.json()})
|
||||
.then(function(d){if(d.ok){done()}else{show(d.error||${JSON.stringify(copy.verificationFailed)},false)}})
|
||||
.catch(function(e){show(${JSON.stringify(copy.networkErrorPrefix)}+e.message,false)});
|
||||
});
|
||||
if(!bioDeviceId()){show(${JSON.stringify(copy.biometricUnavailable)},false);return}
|
||||
if(bm.isBiometricTokenSaved){authBio()}else{enrollBio()}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
@@ -763,7 +1005,7 @@ function getCopy(locale: Locale) {
|
||||
invalidLink: "链接无效或已失效。",
|
||||
expiredLink: "验证已超时,请重新申请入群。",
|
||||
openVerification: "打开验证中心",
|
||||
prompt: (chatTitle: string) => `你申请加入《${chatTitle}》需要做一次验证。\n请在 10 分钟内点击下方按钮,在 Telegram 小程序里任选一种方式完成:\n1. Cloudflare Turnstile\n2. 系统生物识别\n\n验证完成后会自动放行入群申请。`,
|
||||
prompt: (chatTitle: string) => `你申请加入《${chatTitle}》需要做一次验证。\n请在 10 分钟内点击下方按钮,在 Telegram 小程序里任选一种方式完成:\n1. Cloudflare Turnstile\n2. 系统生物识别\n\n首次使用生物识别时会先在本机保存一个验证凭据。验证完成后会自动放行入群申请。`,
|
||||
headerTitle: (displayName: string, chatTitle: string) => `${displayName} 申请加入 ${chatTitle}`,
|
||||
headerSubtitle: "请完成一次简短验证后继续",
|
||||
turnstileLabel: "方式一:Turnstile 人机验证",
|
||||
@@ -781,6 +1023,8 @@ function getCopy(locale: Locale) {
|
||||
fingerprint: "指纹验证",
|
||||
biometricAccessReason: "用于入群身份验证",
|
||||
biometricAccessDenied: "生物识别权限被拒绝",
|
||||
biometricSetupFailed: "生物识别凭据保存失败,请重试或使用 Turnstile",
|
||||
biometricTokenMissing: "未读取到已保存的生物验证凭据",
|
||||
biometricUseTurnstile: "当前环境不支持生物识别,请使用 Turnstile",
|
||||
biometricAuthenticateReason: "验证身份以加入群组",
|
||||
biometricFailed: "生物识别验证未通过",
|
||||
@@ -794,7 +1038,7 @@ function getCopy(locale: Locale) {
|
||||
invalidLink: "This link is invalid or has expired.",
|
||||
expiredLink: "Verification timed out. Please submit your join request again.",
|
||||
openVerification: "Open Verification",
|
||||
prompt: (chatTitle: string) => `Your request to join \"${chatTitle}\" requires verification.\nPlease tap the button below and complete one of these methods in Telegram within 10 minutes:\n1. Cloudflare Turnstile\n2. Biometric verification\n\nYour join request will be approved automatically after verification.`,
|
||||
prompt: (chatTitle: string) => `Your request to join \"${chatTitle}\" requires verification.\nPlease tap the button below and complete one method in Telegram within 10 minutes:\n1. Cloudflare Turnstile\n2. System biometric verification\n\nThe first biometric use saves a local credential on this device. Your join request will be approved automatically after verification.`,
|
||||
headerTitle: (displayName: string, chatTitle: string) => `${displayName} requested to join ${chatTitle}`,
|
||||
headerSubtitle: "Please complete a short verification to continue",
|
||||
turnstileLabel: "Option 1: Turnstile verification",
|
||||
@@ -812,6 +1056,8 @@ function getCopy(locale: Locale) {
|
||||
fingerprint: "Fingerprint Verification",
|
||||
biometricAccessReason: "Used to verify your identity for group access",
|
||||
biometricAccessDenied: "Biometric permission was denied",
|
||||
biometricSetupFailed: "Failed to save biometric credential. Please retry or use Turnstile.",
|
||||
biometricTokenMissing: "Saved biometric credential was not returned",
|
||||
biometricUseTurnstile: "Biometric verification is unavailable here. Please use Turnstile.",
|
||||
biometricAuthenticateReason: "Verify your identity to join the group",
|
||||
biometricFailed: "Biometric verification failed",
|
||||
@@ -827,10 +1073,46 @@ function activeJoinKey(chatId: number, userId: number): string {
|
||||
return `${ACTIVE_PREFIX}${chatId}:${userId}`;
|
||||
}
|
||||
|
||||
function rejectKey(chatId: number, userId: number): string {
|
||||
return `${REJECT_PREFIX}${chatId}:${userId}`;
|
||||
}
|
||||
|
||||
async function biometricCredentialKey(env: Env, userId: number, deviceId: string): Promise<string> {
|
||||
const deviceHash = bytesToHex(await hmacSha256(env.BOT_TOKEN, deviceId));
|
||||
return `${BIOMETRIC_PREFIX}${userId}:${deviceHash}`;
|
||||
}
|
||||
|
||||
async function hashBiometricToken(env: Env, userId: number, deviceId: string, token: string): Promise<string> {
|
||||
return bytesToHex(await hmacSha256(env.BOT_TOKEN, `${userId}\n${deviceId}\n${token}`));
|
||||
}
|
||||
|
||||
function isValidBiometricDeviceId(value: string | undefined): value is string {
|
||||
return typeof value === "string" && value.length > 0 && value.length <= 256;
|
||||
}
|
||||
|
||||
function isValidBiometricToken(value: string | undefined): value is string {
|
||||
return typeof value === "string" && value.length >= 16 && value.length <= 4096;
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(value: string): string {
|
||||
return value.endsWith("/") ? value : `${value}/`;
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
let text = "";
|
||||
for (const byte of bytes) {
|
||||
text += String.fromCharCode(byte);
|
||||
}
|
||||
return btoa(text);
|
||||
}
|
||||
|
||||
function inferImageMimeType(bytes: Uint8Array): string | null {
|
||||
if (bytes[0] === 0xff && bytes[1] === 0xd8) return "image/jpeg";
|
||||
if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) return "image/png";
|
||||
if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) return "image/gif";
|
||||
return null;
|
||||
}
|
||||
|
||||
async function hmacSha256(key: string | Uint8Array, value: string): Promise<Uint8Array> {
|
||||
const keyBytes = typeof key === "string" ? new TextEncoder().encode(key) : key;
|
||||
const keyBuffer = new ArrayBuffer(keyBytes.byteLength);
|
||||
@@ -850,6 +1132,15 @@ function bytesToHex(value: Uint8Array): string {
|
||||
return [...value].map((item) => item.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
function constantTimeEqual(left: string, right: string): boolean {
|
||||
let diff = left.length ^ right.length;
|
||||
const length = Math.max(left.length, right.length);
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
diff |= (left.charCodeAt(index) || 0) ^ (right.charCodeAt(index) || 0);
|
||||
}
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
|
||||
Reference in New Issue
Block a user