- 登录链路改为用户名 + 密码 + 短信验证码 + DCAuth v2 设备签名 - 新增 login_sms_codes / user_devices / device_challenges 三张表,迁移 0063/0064 - 设备绑定只比对 deviceId + publicKey,每账号一个 active 设备,管理员可重置 - 保留未绑定手机号账号的密码登录兜底,绑定后强制安全登录 - 登录页与用户管理页支持验证码、DCAuth 轮询、手机号绑定与设备重置
334 lines
10 KiB
Python
334 lines
10 KiB
Python
"""登录短信验证码:发送渠道、频率限制与校验。
|
||
|
||
渠道通过环境变量切换(沿用订单后台命名):
|
||
- ``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()
|