- 登录链路改为用户名 + 密码 + 短信验证码 + DCAuth v2 设备签名 - 新增 login_sms_codes / user_devices / device_challenges 三张表,迁移 0063/0064 - 设备绑定只比对 deviceId + publicKey,每账号一个 active 设备,管理员可重置 - 保留未绑定手机号账号的密码登录兜底,绑定后强制安全登录 - 登录页与用户管理页支持验证码、DCAuth 轮询、手机号绑定与设备重置
518 lines
16 KiB
Python
518 lines
16 KiB
Python
"""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
|