export const AUTH_TAC_SCENE = Object.freeze({ SMS_LOGIN: "APP_SMS_LOGIN", REGISTER: "APP_REGISTER", FORGOT_PASSWORD: "APP_FORGOT_PASSWORD", }); const SUPPORTED_TAC_TYPES = new Set([ "SLIDER", "ROTATE", "CONCAT", "WORD_IMAGE_CLICK", ]); const PHONE_PATTERN = /^1[3-9]\d{9}$/; export const isAuthPhone = (value) => typeof value === "string" && PHONE_PATTERN.test(value); const contractError = (message, code) => { const error = new Error(message); error.code = code; return error; }; const requireText = (value, label) => { if (typeof value !== "string" || !value.trim()) { throw contractError(`${label}不能为空`, "AUTH_TAC_CONTRACT_INVALID"); } return value.trim(); }; export const assertSmsCode = (value) => { if (typeof value !== "string" || !/^\d{4}$/.test(value)) { throw contractError("请输入 4 位短信验证码", "SMS_CODE_INVALID"); } return value; }; export const normalizeCaptchaRequirement = (value, expectedSceneCode) => { if (!value || typeof value !== "object" || Array.isArray(value)) { throw contractError("行为验证策略响应无效", "AUTH_TAC_REQUIREMENT_INVALID"); } const sceneCode = requireText(value.sceneCode, "行为验证场景"); if (sceneCode !== expectedSceneCode) { throw contractError("行为验证场景与当前操作不匹配", "AUTH_TAC_SCENE_MISMATCH"); } // 短信接口把 validToken 定义为必填,因此 required=false 不能在客户端被解释成 // “跳过验证”。后端必须为短信场景启用 TAC,或另行签发可消费的免验证票据。 if (value.required !== true) { throw contractError( "服务端未要求行为验证,无法取得发送短信所需票据", "AUTH_TAC_POLICY_INCOMPLETE", ); } const providerCode = requireText(value.providerCode, "行为验证服务商").toUpperCase(); if (providerCode !== "TIANAI") { throw contractError("当前仅支持 TIANAI 行为验证服务", "AUTH_TAC_PROVIDER_UNSUPPORTED"); } const captchaType = requireText(value.captchaType, "行为验证码类型").toUpperCase(); if (!SUPPORTED_TAC_TYPES.has(captchaType)) { throw contractError("服务端返回了客户端不支持的验证码类型", "AUTH_TAC_TYPE_UNSUPPORTED"); } const ttlSeconds = Number(value.ttlSeconds); if (!Number.isInteger(ttlSeconds) || ttlSeconds < 1) { throw contractError("行为验证策略缺少有效期", "AUTH_TAC_TTL_INVALID"); } return { required: true, providerCode, captchaType, sceneCode, ttlSeconds }; }; export const createTacRenderContext = ({ requestId, baseUrl, clientId, tenantId, sceneCode, subject, requirement, }) => { const normalizedRequirement = normalizeCaptchaRequirement(requirement, sceneCode); const normalizedBaseUrl = requireText(baseUrl, "后端地址").replace(/\/+$/, ""); if (!/^https:\/\/[^/]+/i.test(normalizedBaseUrl)) { throw contractError("行为验证只允许使用 HTTPS 后端地址", "AUTH_TAC_HTTPS_REQUIRED"); } const normalizedSubject = requireText(subject, "手机号"); if (!isAuthPhone(normalizedSubject)) { throw contractError("请输入正确的手机号", "AUTH_PHONE_INVALID"); } return { requestId: requireText(requestId, "验证请求标识"), baseUrl: normalizedBaseUrl, challengeUrl: `${normalizedBaseUrl}/captcha/challenge`, verifyUrl: `${normalizedBaseUrl}/captcha/verify`, clientId: requireText(clientId, "客户端标识"), tenantId: requireText(tenantId, "租户标识"), sceneCode: normalizedRequirement.sceneCode, subject: normalizedSubject, providerCode: normalizedRequirement.providerCode, captchaType: normalizedRequirement.captchaType, }; }; export const normalizeTacSuccess = (value, expectedRequestId) => { if (!value || typeof value !== "object" || value.requestId !== expectedRequestId) { throw contractError("行为验证结果已过期或与当前请求不匹配", "AUTH_TAC_RESULT_STALE"); } const validToken = requireText(value.validToken, "行为验证票据"); const expireSeconds = Number(value.expireSeconds); if (!Number.isInteger(expireSeconds) || expireSeconds < 1) { throw contractError("行为验证票据缺少有效期", "AUTH_TAC_RESULT_INVALID"); } return { requestId: value.requestId, validToken, expireSeconds }; }; export const isSmsDeliveryOutcomeUnknown = (error) => { if (!error || typeof error !== "object") return false; if ( error.code === "REQUEST_TIMEOUT" || error.code === "NETWORK_ERROR" || error.code === "RESPONSE_INVALID" || error.code === "REQUEST_CANCELLED" ) { return true; } if (error.code === "HTTP_ERROR" || error.code === "BUSINESS_ERROR") { const status = Number( error.code === "HTTP_ERROR" ? error.httpStatus : error.businessCode, ); if (!Number.isInteger(status)) return true; return !(status >= 400 && status < 500 && status !== 408); } return false; };