Files
live-hub-py/web/backend/services/dc_auth_service.py
T
yml2213 2f5a63ae27 feat(auth): 新增手机号与 DCAuth 设备认证登录
- 登录链路改为用户名 + 密码 + 短信验证码 + DCAuth v2 设备签名
- 新增 login_sms_codes / user_devices / device_challenges 三张表,迁移 0063/0064
- 设备绑定只比对 deviceId + publicKey,每账号一个 active 设备,管理员可重置
- 保留未绑定手机号账号的密码登录兜底,绑定后强制安全登录
- 登录页与用户管理页支持验证码、DCAuth 轮询、手机号绑定与设备重置
2026-10-21 22:00:03 +08:00

518 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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