| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100 |
- """登录图片验证码:内存签发/校验 + PyMuPDF 渲染 PNG(安全需求4)。
- - 单实例部署(launcher 单进程)下内存存储足够;
- - 4 位字符(剔除易混淆 0O1I),120 秒过期;
- - 校验一次性消费(无论成败),防止同一验证码反复试密码;
- - 校验忽略大小写与首尾空白。
- """
- from __future__ import annotations
- import base64
- import secrets
- import threading
- import time
- import uuid
- import random
- from dms.common.errors import InvalidCaptchaError
- _CAPTCHA_CHARS = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
- _CODE_LENGTH = 4
- _EXPIRES_SECONDS = 120
- _MAX_ENTRIES = 1000
- _lock = threading.Lock()
- # captcha_id -> (code 大写归一, 过期时刻 monotonic)
- _entries: dict[str, tuple[str, float]] = {}
- def issue() -> dict[str, str]:
- """签发新验证码,返回 ``{"captchaId": ..., "image": "data:image/png;base64,..."}``。"""
- code = "".join(secrets.choice(_CAPTCHA_CHARS) for _ in range(_CODE_LENGTH))
- captcha_id = uuid.uuid4().hex
- now = time.monotonic()
- with _lock:
- _purge_expired(now)
- if len(_entries) >= _MAX_ENTRIES:
- # 上限保护:极端刷量时整体清空(存量本就即将过期,不影响正常用户)
- _entries.clear()
- _entries[captcha_id] = (code.upper(), now + _EXPIRES_SECONDS)
- return {
- "captchaId": captcha_id,
- "image": "data:image/png;base64,"
- + base64.b64encode(_render_png(code)).decode("ascii"),
- }
- def verify(captcha_id: object, code: object) -> None:
- """校验并消费验证码;缺失/错误/过期均抛 :class:`InvalidCaptchaError`。"""
- if not isinstance(captcha_id, str) or not captcha_id:
- raise InvalidCaptchaError()
- if not isinstance(code, str) or not code.strip():
- raise InvalidCaptchaError()
- with _lock:
- entry = _entries.pop(captcha_id, None)
- if entry is None or entry[1] <= time.monotonic():
- raise InvalidCaptchaError()
- if entry[0] != code.strip().upper():
- raise InvalidCaptchaError()
- def _purge_expired(now: float) -> None:
- for key in [k for k, (_, expires) in _entries.items() if expires <= now]:
- _entries.pop(key, None)
- def _render_png(code: str) -> bytes:
- """用 PyMuPDF(项目既有依赖)绘制验证码位图:随机颜色/基线/字号 + 干扰线。"""
- import fitz
- width, height = 132, 44
- rng = random.Random()
- doc = fitz.open()
- try:
- page = doc.new_page(width=width, height=height)
- page.draw_rect(page.rect, color=None, fill=(0.96, 0.98, 1.0))
- for _ in range(4):
- page.draw_line(
- fitz.Point(rng.uniform(0, width), rng.uniform(0, height)),
- fitz.Point(rng.uniform(0, width), rng.uniform(0, height)),
- color=(rng.random() * 0.6, rng.random() * 0.6, rng.random() * 0.6),
- width=1,
- )
- x = 14.0
- for ch in code:
- page.insert_text(
- fitz.Point(x, rng.uniform(28, 38)),
- ch,
- fontname="hebo",
- fontsize=rng.uniform(22, 26),
- color=(
- rng.uniform(0, 0.45),
- rng.uniform(0, 0.45),
- rng.uniform(0, 0.45),
- ),
- )
- x += 27
- return page.get_pixmap().tobytes("png")
- finally:
- doc.close()
|