Files
2026-05-29 13:27:41 +08:00

188 lines
7.0 KiB
Python

"""极光一键登录验证 endpoint。
链路:
Android 客户端 SDK loginAuth → 拿到 loginToken
→ POST 本 endpoint
→ 调极光 REST /v1/web/loginTokenVerify(Basic Auth = AppKey:MasterSecret)
→ 极光返回 RSA 公钥加密的手机号(base64)
→ 用部署在服务器上的对应 RSA 私钥解密
→ 返回明文手机号给客户端
验通路阶段只返回手机号,不入库、不签 session、不做 CPS 归因。
跑通后再补持久化和绑定逻辑。
RSA 私钥位置:`$JG_PRIVATE_KEY_PATH` 环境变量,默认 /opt/duobibi-server/secrets/jverify_rsa_private.pem。
私钥不入 git,部署时单独 scp 到服务器。
"""
from __future__ import annotations
import base64
import json
import logging
import os
import time
import urllib.error
import urllib.request
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from app import db
logger = logging.getLogger("shagua.auth")
router = APIRouter(prefix="/api/v1/auth")
# 占坑期硬编码,跟 llm_client.py 的 key 风格一致(后续统一挪到 env)
JG_APP_KEY = "966b451a8d9cfe12d173ea9d"
JG_MASTER_SECRET = "dae6d856c7556ac1b8f2deda"
JG_VERIFY_ENDPOINT = "https://api.verification.jpush.cn/v1/web/loginTokenVerify"
JG_REQUEST_TIMEOUT_SEC = 15
JG_PRIVATE_KEY_PATH = os.environ.get(
"JG_PRIVATE_KEY_PATH",
"/opt/duobibi-server/secrets/jverify_rsa_private.pem",
)
_private_key_cache = None
def _get_private_key():
"""懒加载 RSA 私钥并缓存。首次调用时如果文件不存在会抛 FileNotFoundError,
让请求 500 + 日志,而不是 import 时炸进程。"""
global _private_key_cache
if _private_key_cache is None:
with open(JG_PRIVATE_KEY_PATH, "rb") as f:
_private_key_cache = serialization.load_pem_private_key(f.read(), password=None)
return _private_key_cache
class JverifyLoginRequest(BaseModel):
login_token: str = Field(..., description="客户端 loginAuth 拿到的 loginToken")
operator: str = Field(..., description="CM/CU/CT,客户端透传过来用于日志")
class JverifyLoginResponse(BaseModel):
phone: str
def _call_jg_login_token_verify(login_token: str) -> str:
"""调极光 REST,返回 RSA 加密后的 base64 手机号字符串。"""
body = json.dumps({"loginToken": login_token, "exID": ""}).encode("utf-8")
auth = base64.b64encode(f"{JG_APP_KEY}:{JG_MASTER_SECRET}".encode()).decode()
req = urllib.request.Request(
JG_VERIFY_ENDPOINT,
data=body,
method="POST",
headers={
"Content-Type": "application/json",
"Authorization": f"Basic {auth}",
},
)
try:
with urllib.request.urlopen(req, timeout=JG_REQUEST_TIMEOUT_SEC) as resp:
raw = resp.read()
except urllib.error.HTTPError as e:
body_text = ""
try:
body_text = e.read().decode("utf-8", errors="replace")
except Exception:
pass
logger.error("[JG] HTTP %s %s body=%s", e.code, e.reason, body_text[:500])
raise HTTPException(status_code=502, detail=f"jg http error: {e.code}")
data = json.loads(raw)
code = data.get("code")
if code != 8000:
logger.error("[JG] verify failed code=%s content=%s", code, data.get("content"))
raise HTTPException(status_code=400, detail=f"jg verify failed code={code}")
# JG 返回里加密手机号在 "phone" 字段(已验证)
encrypted = data.get("phone") or ""
if not encrypted:
logger.error("[JG] response missing phone field: %s", data)
raise HTTPException(status_code=500, detail="jg response missing phone")
return encrypted
def _decrypt_phone(encrypted_b64: str) -> str:
"""RSA 解密 base64 密文。
极光文档没明说 padding。实测 PKCS1v15 在 OAEP 密文上会"假成功"返回垃圾
(UnicodeDecodeError 0x8e),因此按 OAEP-SHA1 → OAEP-SHA256 → PKCS1v15 顺序
尝试,谁能解出 11 位纯数字手机号就用谁。
base64 padding 兼容:JG 各运营商可能返回不带 `=` 的 base64,先按 4 字节对齐补齐。
"""
private_key = _get_private_key()
padded_b64 = encrypted_b64 + "=" * (-len(encrypted_b64) % 4)
ciphertext = base64.b64decode(padded_b64)
paddings = [
("OAEP-SHA1", padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA1()),
algorithm=hashes.SHA1(),
label=None,
)),
("OAEP-SHA256", padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None,
)),
("PKCS1v15", padding.PKCS1v15()),
]
last_err: Exception | None = None
for name, pad in paddings:
try:
pt = private_key.decrypt(ciphertext, pad)
except Exception as e:
last_err = e
logger.debug("[JG] decrypt padding=%s failed: %s", name, e)
continue
try:
phone = pt.decode("utf-8")
except UnicodeDecodeError:
# decrypt 在 PKCS1v15 上对错误密文会"假成功"返回垃圾,跳到下一种 padding
logger.debug("[JG] decrypt padding=%s yielded non-UTF8 bytes head=%s", name, pt[:4].hex())
continue
# 验证是不是合法手机号(中国大陆 11 位纯数字 / 或带 + 前缀)
if phone.isdigit() and len(phone) == 11:
logger.info("[JG] decrypted with padding=%s", name)
return phone
# 不是 11 位纯数字但是 ASCII 可打印,也接受(留个口子,后续看实际格式)
if phone.isascii() and phone.isprintable():
logger.warning("[JG] decrypted with padding=%s, non-standard format: %r", name, phone)
return phone
logger.debug("[JG] decrypt padding=%s yielded non-phone string: %r", name, phone[:20])
raise RuntimeError(f"all RSA paddings failed; last error: {last_err}")
@router.post("/jverify-login", response_model=JverifyLoginResponse)
def jverify_login(req: JverifyLoginRequest) -> JverifyLoginResponse:
logger.info(
"jverify_login operator=%s token_len=%d",
req.operator,
len(req.login_token),
)
encrypted = _call_jg_login_token_verify(req.login_token)
try:
phone = _decrypt_phone(encrypted)
except Exception as e:
logger.error("[JG] decrypt failed: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="phone decrypt failed")
# upsert user(占坑期账号体系:phone 当主键)。失败不阻塞登录响应,只 log
try:
db.upsert_user(phone, int(time.time() * 1000))
except Exception as e:
logger.error("[JG] upsert_user failed (login still succeeds): %s", e)
# 日志里只打掩码,明文只返回客户端
masked = (phone[:3] + "****" + phone[-2:]) if len(phone) >= 5 else "***"
logger.info("jverify_login ok operator=%s phone=%s", req.operator, masked)
return JverifyLoginResponse(phone=phone)