feat(auth): 新增手机号与 DCAuth 设备认证登录
- 登录链路改为用户名 + 密码 + 短信验证码 + DCAuth v2 设备签名 - 新增 login_sms_codes / user_devices / device_challenges 三张表,迁移 0063/0064 - 设备绑定只比对 deviceId + publicKey,每账号一个 active 设备,管理员可重置 - 保留未绑定手机号账号的密码登录兜底,绑定后强制安全登录 - 登录页与用户管理页支持验证码、DCAuth 轮询、手机号绑定与设备重置
This commit is contained in:
@@ -0,0 +1,517 @@
|
||||
"""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
|
||||
@@ -0,0 +1,333 @@
|
||||
"""登录短信验证码:发送渠道、频率限制与校验。
|
||||
|
||||
渠道通过环境变量切换(沿用订单后台命名):
|
||||
- ``WORKER_SMS_PROVIDER=mock``(默认):只打日志,不做每日限额;
|
||||
- ``WORKER_SMS_PROVIDER=aliyun``:阿里云 Dysmsapi RPC 签名调用。
|
||||
|
||||
验证码只存 sha256,5 分钟有效、60 秒冷却、同一码错 5 次作废、成功即消费。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta, timezone
|
||||
from urllib.parse import quote
|
||||
from uuid import uuid4
|
||||
|
||||
import requests
|
||||
from loguru import logger
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..errors import ApiError
|
||||
from ..models import LoginSmsCode, User
|
||||
|
||||
SMS_PURPOSE = "login"
|
||||
MAX_VERIFY_ATTEMPTS = 5
|
||||
_CODE_RE = re.compile(r"^\d{6}$")
|
||||
_PHONE_RE = re.compile(r"^1[3-9]\d{9}$")
|
||||
_CHINA_TZ = timezone(timedelta(hours=8))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SmsConfig:
|
||||
provider: str
|
||||
access_key_id: str
|
||||
access_key_secret: str
|
||||
sign_name: str
|
||||
template_code: str
|
||||
code_ttl_seconds: int
|
||||
cooldown_seconds: int
|
||||
daily_limit: int
|
||||
|
||||
|
||||
def _env_int(name: str, default: int) -> int:
|
||||
try:
|
||||
return int(str(os.getenv(name, "")).strip())
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def get_sms_config() -> SmsConfig:
|
||||
return SmsConfig(
|
||||
provider=str(os.getenv("WORKER_SMS_PROVIDER", "mock")).strip().lower()
|
||||
or "mock",
|
||||
access_key_id=str(os.getenv("WORKER_SMS_ACCESS_KEY_ID", "")).strip(),
|
||||
access_key_secret=str(os.getenv("WORKER_SMS_ACCESS_KEY_SECRET", "")).strip(),
|
||||
sign_name=str(os.getenv("WORKER_SMS_SIGN_NAME", "")).strip(),
|
||||
template_code=str(os.getenv("WORKER_SMS_TEMPLATE_CODE", "")).strip(),
|
||||
code_ttl_seconds=max(60, _env_int("WORKER_SMS_CODE_TTL_SECONDS", 300)),
|
||||
cooldown_seconds=max(10, _env_int("WORKER_SMS_COOLDOWN_SECONDS", 60)),
|
||||
daily_limit=max(1, _env_int("WORKER_SMS_DAILY_LIMIT", 10)),
|
||||
)
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
def normalize_phone(value: str | None) -> str:
|
||||
phone = str(value or "").strip()
|
||||
if not phone:
|
||||
return ""
|
||||
if not _PHONE_RE.fullmatch(phone):
|
||||
raise ApiError(400, "手机号格式不正确", "phone_invalid")
|
||||
return phone
|
||||
|
||||
|
||||
def mask_phone(value: str | None) -> str:
|
||||
phone = str(value or "").strip()
|
||||
if not _PHONE_RE.fullmatch(phone):
|
||||
return phone
|
||||
return f"{phone[:3]}****{phone[-4:]}"
|
||||
|
||||
|
||||
def hash_sms_code(code: str) -> str:
|
||||
return hashlib.sha256(str(code or "").encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def generate_sms_code() -> str:
|
||||
return f"{secrets.randbelow(1_000_000):06d}"
|
||||
|
||||
|
||||
def should_enforce_login_sms_daily_limit(provider: str | None) -> bool:
|
||||
return str(provider or "mock").strip().lower() != "mock"
|
||||
|
||||
|
||||
def china_day_range(now: datetime | None = None) -> tuple[datetime, datetime]:
|
||||
current = (now or utcnow()).astimezone(_CHINA_TZ)
|
||||
start_local = current.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
return (
|
||||
start_local.astimezone(UTC),
|
||||
(start_local + timedelta(days=1)).astimezone(UTC),
|
||||
)
|
||||
|
||||
|
||||
def _dispatch_sms(config: SmsConfig, phone: str, code: str, ttl_seconds: int) -> None:
|
||||
if config.provider != "mock":
|
||||
_send_via_aliyun(config, phone, code)
|
||||
return
|
||||
logger.info(
|
||||
"[mock短信] 向 {} 发送登录验证码:{}({} 秒内有效,不真实发送)",
|
||||
mask_phone(phone),
|
||||
code,
|
||||
ttl_seconds,
|
||||
)
|
||||
|
||||
|
||||
def _percent_encode(value: str) -> str:
|
||||
return quote(str(value), safe="~")
|
||||
|
||||
|
||||
def _aliyun_signature(params: dict[str, str], secret: str) -> str:
|
||||
canonical = "&".join(
|
||||
f"{_percent_encode(key)}={_percent_encode(value)}"
|
||||
for key, value in sorted(params.items())
|
||||
)
|
||||
string_to_sign = f"GET&%2F&{_percent_encode(canonical)}"
|
||||
digest = hmac.new(
|
||||
f"{secret}&".encode(),
|
||||
string_to_sign.encode("utf-8"),
|
||||
hashlib.sha1,
|
||||
).digest()
|
||||
return base64.b64encode(digest).decode("utf-8")
|
||||
|
||||
|
||||
def _send_via_aliyun(config: SmsConfig, phone: str, code: str) -> None:
|
||||
if not (
|
||||
config.access_key_id
|
||||
and config.access_key_secret
|
||||
and config.sign_name
|
||||
and config.template_code
|
||||
):
|
||||
raise ApiError(
|
||||
503,
|
||||
"短信渠道未配置,请联系管理员",
|
||||
"sms_not_configured",
|
||||
)
|
||||
|
||||
params = {
|
||||
"AccessKeyId": config.access_key_id,
|
||||
"Action": "SendSms",
|
||||
"Format": "JSON",
|
||||
"PhoneNumbers": phone,
|
||||
"RegionId": "cn-hangzhou",
|
||||
"SignName": config.sign_name,
|
||||
"SignatureMethod": "HMAC-SHA1",
|
||||
"SignatureNonce": str(uuid4()),
|
||||
"SignatureVersion": "1.0",
|
||||
"TemplateCode": config.template_code,
|
||||
"TemplateParam": json.dumps(
|
||||
{"code": code}, separators=(",", ":"), ensure_ascii=False
|
||||
),
|
||||
"Timestamp": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"Version": "2017-05-25",
|
||||
}
|
||||
params["Signature"] = _aliyun_signature(params, config.access_key_secret)
|
||||
|
||||
try:
|
||||
response = requests.get(
|
||||
"https://dysmsapi.aliyuncs.com/",
|
||||
params=params,
|
||||
timeout=10,
|
||||
)
|
||||
payload = response.json()
|
||||
except Exception as exc: # 外部渠道统一兜底
|
||||
logger.exception("阿里云短信发送异常: {}", exc)
|
||||
raise ApiError(502, "短信发送失败,请稍后再试", "sms_send_failed") from exc
|
||||
|
||||
if response.status_code != 200 or str(payload.get("Code", "")) != "OK":
|
||||
logger.error(
|
||||
"阿里云短信发送失败: status={} code={} message={}",
|
||||
response.status_code,
|
||||
payload.get("Code"),
|
||||
payload.get("Message"),
|
||||
)
|
||||
raise ApiError(502, "短信发送失败,请稍后再试", "sms_send_failed")
|
||||
|
||||
|
||||
def _latest_code(db: Session, user_id: int) -> LoginSmsCode | None:
|
||||
return (
|
||||
db.query(LoginSmsCode)
|
||||
.filter(
|
||||
LoginSmsCode.user_id == user_id,
|
||||
LoginSmsCode.purpose == SMS_PURPOSE,
|
||||
)
|
||||
.order_by(LoginSmsCode.id.desc())
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def send_login_sms_code(
|
||||
db: Session,
|
||||
user: User,
|
||||
phone: str,
|
||||
request_ip: str = "",
|
||||
) -> dict:
|
||||
config = get_sms_config()
|
||||
now = utcnow()
|
||||
|
||||
latest = _latest_code(db, user.id)
|
||||
if (
|
||||
latest is not None
|
||||
and latest.consumed_at is None
|
||||
and latest.created_at > now - timedelta(seconds=config.cooldown_seconds)
|
||||
):
|
||||
raise ApiError(429, "验证码发送过于频繁,请稍后再试", "sms_cooldown")
|
||||
|
||||
if should_enforce_login_sms_daily_limit(config.provider):
|
||||
start, end = china_day_range(now)
|
||||
sent_today = (
|
||||
db.query(LoginSmsCode)
|
||||
.filter(
|
||||
LoginSmsCode.user_id == user.id,
|
||||
LoginSmsCode.purpose == SMS_PURPOSE,
|
||||
LoginSmsCode.created_at >= start,
|
||||
LoginSmsCode.created_at < end,
|
||||
)
|
||||
.count()
|
||||
)
|
||||
if sent_today >= config.daily_limit:
|
||||
raise ApiError(
|
||||
429,
|
||||
"今日验证码发送次数已达上限,请明天再试",
|
||||
"sms_daily_limit",
|
||||
)
|
||||
|
||||
code = generate_sms_code()
|
||||
_dispatch_sms(config, phone, code, config.code_ttl_seconds)
|
||||
|
||||
row = LoginSmsCode(
|
||||
user_id=user.id,
|
||||
phone=phone,
|
||||
purpose=SMS_PURPOSE,
|
||||
code_hash=hash_sms_code(code),
|
||||
expires_at=now + timedelta(seconds=config.code_ttl_seconds),
|
||||
failed_attempt_count=0,
|
||||
request_ip=str(request_ip or "").strip()[:64],
|
||||
created_at=now,
|
||||
)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"sent": True,
|
||||
"phoneMasked": mask_phone(phone),
|
||||
"expiresInSeconds": config.code_ttl_seconds,
|
||||
"provider": config.provider,
|
||||
}
|
||||
|
||||
|
||||
def verify_login_sms_code(db: Session, user_id: int, code_value: str | None) -> str:
|
||||
"""校验并消费验证码;返回验证码绑定的手机号。"""
|
||||
code = str(code_value or "").strip()
|
||||
if not code:
|
||||
raise ApiError(400, "请输入短信验证码", "sms_code_required")
|
||||
if not _CODE_RE.fullmatch(code):
|
||||
raise ApiError(400, "短信验证码错误或已过期", "sms_code_invalid")
|
||||
|
||||
now = utcnow()
|
||||
latest = _latest_code(db, user_id)
|
||||
if latest is None or latest.consumed_at is not None or latest.expires_at <= now:
|
||||
raise ApiError(400, "短信验证码错误或已过期", "sms_code_invalid")
|
||||
|
||||
if latest.failed_attempt_count >= MAX_VERIFY_ATTEMPTS:
|
||||
_mark_code_consumed(db, latest.id, now)
|
||||
raise ApiError(
|
||||
400,
|
||||
"验证码错误次数过多,请重新获取验证码",
|
||||
"sms_code_locked",
|
||||
)
|
||||
|
||||
if not hmac.compare_digest(str(latest.code_hash or ""), hash_sms_code(code)):
|
||||
db.query(LoginSmsCode).filter(
|
||||
LoginSmsCode.id == latest.id,
|
||||
LoginSmsCode.consumed_at.is_(None),
|
||||
).update(
|
||||
{LoginSmsCode.failed_attempt_count: LoginSmsCode.failed_attempt_count + 1},
|
||||
synchronize_session=False,
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(latest)
|
||||
if latest.failed_attempt_count >= MAX_VERIFY_ATTEMPTS:
|
||||
_mark_code_consumed(db, latest.id, now)
|
||||
raise ApiError(
|
||||
400,
|
||||
"验证码错误次数过多,请重新获取验证码",
|
||||
"sms_code_locked",
|
||||
)
|
||||
raise ApiError(400, "短信验证码错误或已过期", "sms_code_invalid")
|
||||
|
||||
consumed = (
|
||||
db.query(LoginSmsCode)
|
||||
.filter(
|
||||
LoginSmsCode.id == latest.id,
|
||||
LoginSmsCode.consumed_at.is_(None),
|
||||
LoginSmsCode.failed_attempt_count < MAX_VERIFY_ATTEMPTS,
|
||||
)
|
||||
.update(
|
||||
{LoginSmsCode.consumed_at: now},
|
||||
synchronize_session=False,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
if consumed != 1:
|
||||
raise ApiError(400, "验证码已失效,请重新获取", "sms_code_invalid")
|
||||
return str(latest.phone or "")
|
||||
|
||||
|
||||
def _mark_code_consumed(db: Session, code_id: int, now: datetime) -> None:
|
||||
db.query(LoginSmsCode).filter(
|
||||
LoginSmsCode.id == code_id,
|
||||
LoginSmsCode.consumed_at.is_(None),
|
||||
).update(
|
||||
{LoginSmsCode.consumed_at: now},
|
||||
synchronize_session=False,
|
||||
)
|
||||
db.commit()
|
||||
@@ -0,0 +1,30 @@
|
||||
"""用户信息序列化(列表 / 创建 / 编辑 / 管理端接口共用)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ..models import User, UserDevice
|
||||
from ..permissions import get_user_permissions
|
||||
from ..schemas import UserInfo
|
||||
from .dc_auth_service import mask_device_id
|
||||
from .login_sms_service import mask_phone
|
||||
|
||||
|
||||
def to_user_info(user: User, device: UserDevice | None = None) -> UserInfo:
|
||||
device_id = str(device.device_id or "") if device is not None else ""
|
||||
return UserInfo(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
role=user.role,
|
||||
is_active=user.is_active,
|
||||
remark=user.remark or "",
|
||||
created_at=user.created_at,
|
||||
permissions=get_user_permissions(user),
|
||||
custom_permissions=user.custom_permissions,
|
||||
phone=str(user.phone or ""),
|
||||
phone_masked=mask_phone(user.phone),
|
||||
phone_bound_at=user.phone_bound_at,
|
||||
device_id=device_id,
|
||||
device_id_masked=mask_device_id(device_id),
|
||||
device_bound_at=device.bound_at if device is not None else None,
|
||||
device_last_used_at=device.last_used_at if device is not None else None,
|
||||
)
|
||||
Reference in New Issue
Block a user