import base64 import io import secrets from PIL import Image, ImageDraw, ImageFont from app.services.redis_client import get_redis CAPTCHA_TTL_SEC = 120 CAPTCHA_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" def _key(captcha_id: str) -> str: return f"gsfdc:captcha:{captcha_id}" def _new_code() -> str: return "".join(secrets.choice(CAPTCHA_ALPHABET) for _ in range(4)) def _image(code: str) -> str: width, height = 132, 48 image = Image.new("RGB", (width, height), (246, 248, 251)) draw = ImageDraw.Draw(image) font = ImageFont.load_default(size=26) for _ in range(5): draw.line( ( secrets.randbelow(width), secrets.randbelow(height), secrets.randbelow(width), secrets.randbelow(height), ), fill=(180, 190, 200), width=1, ) for index, char in enumerate(code): color = (secrets.randbelow(80), secrets.randbelow(80), 40 + secrets.randbelow(80)) draw.text((12 + index * 30, 8 + secrets.randbelow(8)), char, font=font, fill=color) buffer = io.BytesIO() image.save(buffer, format="PNG") encoded = base64.b64encode(buffer.getvalue()).decode("ascii") return f"data:image/png;base64,{encoded}" def issue_captcha() -> dict: captcha_id = secrets.token_hex(16) code = _new_code() get_redis().set(_key(captcha_id), code.lower(), ex=CAPTCHA_TTL_SEC) return {"captchaId": captcha_id, "image": _image(code)} def consume_captcha(captcha_id: str, code: str) -> bool: if not isinstance(captcha_id, str) or not isinstance(code, str): return False captcha_id = captcha_id.strip() code = code.strip() if not captcha_id or not code: return False stored = get_redis().getdel(_key(captcha_id)) if stored is None: return False if isinstance(stored, bytes): stored = stored.decode("utf-8", errors="replace") if not isinstance(stored, str): return False return stored.lower() == code.lower()