"""DCAuth v2 设备认证(无机器码)。 协议要点: - 客户端 POST {origin}/api/dcauth/authorize; - 签名原文固定为 ``dcauth.v2\\n{app}\\n{challengeId}\\n{challenge}\\n{deviceId}\\n{timestamp}``; - Ed25519 验签,公钥 32 字节、签名 64 字节,base64url 无填充; - challenge 120 秒有效,状态 pending → authorized → ticket_issued → consumed, 每次推进都用带 status/expires_at 条件的原子 UPDATE,保证只能消费一次。 """ from __future__ import annotations import base64 import binascii import hashlib import hmac import re import secrets import threading import time from collections import deque from datetime import UTC, datetime, timedelta from urllib.parse import quote, urlencode, urlsplit from uuid import uuid4 from cryptography.exceptions import InvalidSignature from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from ..errors import ApiError from ..models import DeviceChallenge, User, UserDevice APP_ID = "live_hub" CHALLENGE_PURPOSE = "login" CHALLENGE_TTL_SECONDS = 120 MAX_CLOCK_SKEW_SECONDS = 300 MAX_AUTHORIZE_PER_MINUTE = 30 _DEVICE_ID_RE = re.compile(r"^D1-[A-Z0-9-]{8,64}$") _BASE64URL_RE = re.compile(r"^[A-Za-z0-9_-]+$") _rate_lock = threading.Lock() _rate_buckets: dict[str, deque[float]] = {} def utcnow() -> datetime: return datetime.now(UTC) def build_device_canonical( app: str, challenge_id: str, challenge: str, device_id: str, timestamp: int, ) -> str: """DCAuth v2 签名原文(顺序、分隔符都不能变)。""" return "\n".join( [ "dcauth.v2", app, challenge_id, challenge, device_id, str(timestamp), ] ) def hash_token(value: str) -> str: return hashlib.sha256(str(value or "").encode("utf-8")).hexdigest() def mask_device_id(value: str | None) -> str: device_id = str(value or "").strip().upper() if len(device_id) <= 8: return device_id return f"{device_id[:6]}-****-{device_id[-4:]}" def parse_client_major(client_version: str | None) -> int | None: major = str(client_version or "").strip().split(".", 1)[0] if not major.isdigit(): return None return int(major) def ensure_supported_client_version(client_version: str | None) -> None: major = parse_client_major(client_version) if major is None or major < 2: raise ApiError( 426, "DCAuth 客户端版本过低,请升级到 v2.0.0 或更高版本", "client_outdated", ) def decode_base64url(value: str) -> bytes: """严格解码 base64url(不接受 padding、空格或标准 base64 字符)。""" if not value or not _BASE64URL_RE.fullmatch(value): raise ApiError( 400, "设备公钥或签名编码无效", "signature_invalid", ) padding = "=" * (-len(value) % 4) try: return base64.urlsafe_b64decode(value + padding) except (binascii.Error, ValueError) as exc: raise ApiError( 400, "设备公钥或签名编码无效", "signature_invalid", ) from exc def verify_device_signature( public_key: str, signature: str, canonical: str, ) -> None: public_key_bytes = decode_base64url(public_key) signature_bytes = decode_base64url(signature) if len(public_key_bytes) != 32 or len(signature_bytes) != 64: raise ApiError( 400, "设备公钥或签名格式不正确", "signature_invalid", ) try: key = Ed25519PublicKey.from_public_bytes(public_key_bytes) key.verify(signature_bytes, canonical.encode("utf-8")) except (InvalidSignature, ValueError) as exc: raise ApiError(401, "设备签名校验失败", "signature_invalid") from exc def normalize_api_base(value: str | None) -> str: """apiBase 必须是 scheme://host,不能带路径、查询串或用户信息。""" raw = str(value or "").strip() if not raw: raise ApiError(400, "缺少设备认证 API 地址", "api_base_invalid") candidate = raw.rstrip("/") parts = urlsplit(candidate) if parts.scheme.lower() not in ("http", "https") or not parts.netloc: raise ApiError(400, "设备认证 API 地址必须是 http(s) 地址", "api_base_invalid") if parts.username or parts.password or parts.path or parts.query or parts.fragment: raise ApiError( 400, "设备认证 API 地址只能包含 scheme://host,不能带路径或查询参数", "api_base_invalid", ) return f"{parts.scheme.lower()}://{parts.netloc.lower()}" def build_launch_url( api_base: str, challenge_id: str, challenge: str, ) -> str: query = urlencode( { "app": APP_ID, "challenge_id": challenge_id, "challenge": challenge, "api": api_base, }, quote_via=quote, ) return f"dcauth://authorize?{query}" def check_authorize_rate_limit(challenge_id: str) -> None: """按 challengeId 限流(内存实现,单进程 30 次/分钟)。""" now = time.monotonic() cutoff = now - 60.0 with _rate_lock: bucket = _rate_buckets.setdefault(challenge_id, deque()) while bucket and bucket[0] <= cutoff: bucket.popleft() if len(bucket) >= MAX_AUTHORIZE_PER_MINUTE: raise ApiError(429, "设备认证请求过于频繁,请稍后再试", "rate_limited") bucket.append(now) if len(_rate_buckets) > 5000: stale = [ key for key, hits in _rate_buckets.items() if not hits or hits[-1] <= cutoff ] for key in stale: _rate_buckets.pop(key, None) def create_login_challenge(db: Session, user_id: int, api_base: str) -> dict: now = utcnow() expires_at = now + timedelta(seconds=CHALLENGE_TTL_SECONDS) challenge_id = str(uuid4()) challenge_value = secrets.token_urlsafe(32) poll_token = secrets.token_urlsafe(32) row = DeviceChallenge( challenge_id=challenge_id, app=APP_ID, purpose=CHALLENGE_PURPOSE, challenge=challenge_value, poll_token_hash=hash_token(poll_token), status="pending", user_id=user_id, expires_at=expires_at, created_at=now, updated_at=now, ) db.add(row) db.commit() return { "challengeId": challenge_id, "challenge": challenge_value, "expiresAt": expires_at.isoformat(), "pollToken": poll_token, "launchUrl": build_launch_url(api_base, challenge_id, challenge_value), "app": APP_ID, } def get_challenge(db: Session, challenge_id: str) -> DeviceChallenge | None: return ( db.query(DeviceChallenge) .filter(DeviceChallenge.challenge_id == challenge_id) .first() ) def _parse_timestamp(value: object) -> int: if isinstance(value, bool): raise ApiError(400, "设备时间戳格式不正确", "clock_invalid") if isinstance(value, int): return value text = str(value or "").strip() if not text or not re.fullmatch(r"-?\d+", text): raise ApiError(400, "设备时间戳格式不正确", "clock_invalid") return int(text) def authorize_device(db: Session, payload: dict) -> dict: """校验并原子推进 challenge:pending → authorized。""" app = str(payload.get("app") or "").strip() challenge_id = str(payload.get("challengeId") or "").strip() challenge_value = str(payload.get("challenge") or "").strip() device_id = str(payload.get("deviceId") or "").strip().upper() public_key = str(payload.get("publicKey") or "").strip() signature = str(payload.get("signature") or "").strip() client_version = str(payload.get("clientVersion") or "").strip()[:32] raw_timestamp = payload.get("timestamp") if ( not challenge_id or not challenge_value or not device_id or not public_key or not signature ): raise ApiError(400, "设备认证参数不完整", "payload_invalid") if raw_timestamp is None or raw_timestamp == "": raise ApiError(400, "设备认证参数不完整", "payload_invalid") check_authorize_rate_limit(challenge_id) if app != APP_ID: raise ApiError(400, "设备认证应用不匹配", "app_mismatch") ensure_supported_client_version(client_version) if not _DEVICE_ID_RE.fullmatch(device_id): raise ApiError(400, "设备码格式不正确", "device_id_invalid") timestamp = _parse_timestamp(raw_timestamp) now = utcnow() if abs(now.timestamp() - timestamp) > MAX_CLOCK_SKEW_SECONDS: raise ApiError( 400, "设备时间与服务器偏差过大,请校准系统时间", "clock_invalid", ) challenge = get_challenge(db, challenge_id) if ( not challenge or challenge.app != APP_ID or challenge.purpose != CHALLENGE_PURPOSE ): raise ApiError(404, "设备认证会话不存在", "challenge_not_found") if not hmac.compare_digest(challenge.challenge, challenge_value): raise ApiError(400, "设备认证 challenge 不匹配", "challenge_mismatch") if challenge.status != "pending" or challenge.expires_at <= now: raise ApiError( 409, "设备认证会话已失效,请重新登录", "challenge_expired", ) canonical = build_device_canonical( app=APP_ID, challenge_id=challenge_id, challenge=challenge_value, device_id=device_id, timestamp=timestamp, ) verify_device_signature(public_key, signature, canonical) updated = ( db.query(DeviceChallenge) .filter( DeviceChallenge.challenge_id == challenge_id, DeviceChallenge.app == APP_ID, DeviceChallenge.status == "pending", DeviceChallenge.expires_at > now, ) .update( { DeviceChallenge.status: "authorized", DeviceChallenge.device_id: device_id, DeviceChallenge.public_key: public_key, DeviceChallenge.client_version: client_version, DeviceChallenge.authorized_at: now, DeviceChallenge.updated_at: now, }, synchronize_session=False, ) ) if updated != 1: db.rollback() raise ApiError( 409, "设备认证会话已被使用,请重新登录", "challenge_used", ) db.commit() return {"status": "authorized", "deviceId": device_id} def get_challenge_status( db: Session, challenge_id: str, poll_token: str, ) -> dict: challenge_id = str(challenge_id or "").strip() poll_token = str(poll_token or "").strip() if not challenge_id or not poll_token: raise ApiError(400, "缺少设备认证参数", "poll_invalid") challenge = get_challenge(db, challenge_id) if not challenge: return {"status": "rejected", "message": "设备认证会话不存在"} if not hmac.compare_digest(challenge.poll_token_hash, hash_token(poll_token)): raise ApiError(403, "设备认证轮询密钥无效", "poll_forbidden") now = utcnow() if challenge.expires_at <= now and challenge.status != "consumed": return {"status": "expired", "message": "设备认证会话已过期"} if challenge.status == "authorized": ticket = secrets.token_urlsafe(32) issued = ( db.query(DeviceChallenge) .filter( DeviceChallenge.challenge_id == challenge_id, DeviceChallenge.status == "authorized", DeviceChallenge.expires_at > now, ) .update( { DeviceChallenge.status: "ticket_issued", DeviceChallenge.device_ticket_hash: hash_token(ticket), DeviceChallenge.ticket_issued_at: now, DeviceChallenge.updated_at: now, }, synchronize_session=False, ) ) if issued == 1: db.commit() return { "status": "authorized", "deviceTicket": ticket, "deviceId": challenge.device_id, } db.rollback() db.expire_all() latest = get_challenge(db, challenge_id) return {"status": latest.status if latest else "pending"} return { "status": challenge.status, "deviceId": challenge.device_id or "", } def consume_device_ticket( db: Session, challenge_id: str, poll_token: str, device_ticket: str, ) -> DeviceChallenge: challenge_id = str(challenge_id or "").strip() poll_token = str(poll_token or "").strip() device_ticket = str(device_ticket or "").strip() if not challenge_id or not poll_token or not device_ticket: raise ApiError(400, "缺少设备认证票据", "ticket_required") now = utcnow() updated = ( db.query(DeviceChallenge) .filter( DeviceChallenge.challenge_id == challenge_id, DeviceChallenge.poll_token_hash == hash_token(poll_token), DeviceChallenge.device_ticket_hash == hash_token(device_ticket), DeviceChallenge.status == "ticket_issued", DeviceChallenge.expires_at > now, ) .update( { DeviceChallenge.status: "consumed", DeviceChallenge.consumed_at: now, DeviceChallenge.updated_at: now, }, synchronize_session=False, ) ) if updated != 1: db.rollback() raise ApiError( 401, "设备认证票据无效或已使用,请重新登录", "ticket_invalid", ) db.commit() consumed = get_challenge(db, challenge_id) if consumed is None: raise ApiError( 401, "设备认证票据无效或已使用,请重新登录", "ticket_invalid", ) return consumed def _active_device(db: Session, user_id: int) -> UserDevice | None: return ( db.query(UserDevice) .filter(UserDevice.user_id == user_id, UserDevice.status == "active") .order_by(UserDevice.id.desc()) .first() ) def _device_mismatch() -> ApiError: return ApiError( 401, "当前设备与账号绑定设备不一致,请联系管理员重置设备", "device_mismatch", ) def ensure_device_binding( db: Session, user: User, challenge: DeviceChallenge, ) -> UserDevice: """首台设备自动绑定;已绑定设备必须 device_id + public_key 同时一致。""" now = utcnow() current = _active_device(db, user.id) if current is not None: if not hmac.compare_digest( str(current.device_id or ""), str(challenge.device_id or "") ) or not hmac.compare_digest( str(current.public_key or ""), str(challenge.public_key or "") ): raise _device_mismatch() current.last_used_at = now current.updated_at = now db.commit() return current bound = UserDevice( user_id=user.id, device_id=challenge.device_id, public_key=challenge.public_key, client_version=challenge.client_version, status="active", bound_at=now, last_used_at=now, created_at=now, updated_at=now, ) db.add(bound) try: db.commit() except IntegrityError: # 并发首绑:唯一索引只允许一个 active 设备,回查后按同一规则比较。 db.rollback() concurrent = _active_device(db, user.id) if ( concurrent is None or not hmac.compare_digest( str(concurrent.device_id or ""), str(challenge.device_id or "") ) or not hmac.compare_digest( str(concurrent.public_key or ""), str(challenge.public_key or "") ) ): raise _device_mismatch() concurrent.last_used_at = now concurrent.updated_at = now db.commit() return concurrent db.refresh(bound) return bound