Files
live-hub-py/web/backend/services/login_sms_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

334 lines
10 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.
"""登录短信验证码:发送渠道、频率限制与校验。
渠道通过环境变量切换(沿用订单后台命名):
- ``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()