feat(auth): 新增手机号与 DCAuth 设备认证登录

- 登录链路改为用户名 + 密码 + 短信验证码 + DCAuth v2 设备签名
- 新增 login_sms_codes / user_devices / device_challenges 三张表,迁移 0063/0064
- 设备绑定只比对 deviceId + publicKey,每账号一个 active 设备,管理员可重置
- 保留未绑定手机号账号的密码登录兜底,绑定后强制安全登录
- 登录页与用户管理页支持验证码、DCAuth 轮询、手机号绑定与设备重置
This commit is contained in:
yml2213
2026-10-21 22:00:03 +08:00
parent f21362dfe3
commit 2f5a63ae27
24 changed files with 3049 additions and 130 deletions
+517
View File
@@ -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
+333
View File
@@ -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,
)