"""图形验证码 & 滑块拼图验证码""" import random import string import io import hashlib import hmac import time import base64 from typing import Tuple from PIL import Image, ImageDraw, ImageFont # ── HMAC 一次性 token ────────────────────────────────── _SECRET = hashlib.sha256(b"cma-captcha-secret-2024").digest() def sign_token(captcha_id: str, value: str) -> str: """签发一次性 token""" ts = str(int(time.time())) msg = f"{captcha_id}:{value}:{ts}".encode() sig = hmac.new(_SECRET, msg, "sha256").hexdigest()[:12] return f"{captcha_id}.{value}.{ts}.{sig}" def verify_token(token: str, expected_value: str, max_age: int = 300) -> bool: """验证一次性 token,防止重放""" try: parts = token.split(".") if len(parts) != 4: return False captcha_id, value, ts_str, sig = parts if value != expected_value: return False if int(time.time()) - int(ts_str) > max_age: return False expected_sig = hmac.new( _SECRET, f"{captcha_id}:{value}:{ts_str}".encode(), "sha256" ).hexdigest()[:12] if sig != expected_sig: return False return True except (ValueError, IndexError): return False # ── 字体 ──────────────────────────────────────────────── def _load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: """优先使用中文字体,回退默认""" for p in [ "/usr/share/fonts/opentype/noto/NotoSansCJKSC-VF.otf", "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", "/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc", "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", ]: try: return ImageFont.truetype(p, size) except (IOError, OSError): continue return ImageFont.load_default() # ── 图形验证码(4位字母数字) ──────────────────────────── def generate_image_captcha() -> Tuple[str, str, str]: """ 返回: (captcha_id, plain_text, base64_png) 仅需保持 captcha_id 与 plain_text 在 token 中绑定 """ chars = string.ascii_uppercase + string.digits text = "".join(random.choices(chars, k=4)) captcha_id = hashlib.md5(f"{time.time()}{random.random()}".encode()).hexdigest()[:16] w, h = 160, 60 img = Image.new("RGB", (w, h), (255, 255, 255)) draw = ImageDraw.Draw(img) font = _load_font(36) # 干扰线 for _ in range(5): x1, y1 = random.randint(0, w // 2), random.randint(0, h) x2, y2 = random.randint(w // 2, w), random.randint(0, h) draw.line( [(x1, y1), (x2, y2)], fill=(random.randint(100, 200), random.randint(100, 200), random.randint(100, 200)), width=2, ) # 噪点 for _ in range(80): draw.point( (random.randint(0, w), random.randint(0, h)), fill=(random.randint(0, 255), random.randint(0, 255), random.randint(0, 255)), ) # 文字 x_offset = 12 for ch in text: angle = random.randint(-25, 25) ch_img = Image.new("RGBA", (36, 48), (255, 255, 255, 0)) ch_draw = ImageDraw.Draw(ch_img) ch_draw.text((2, -2), ch, fill=(random.randint(0, 80), random.randint(0, 80), random.randint(0, 80)), font=font) rotated = ch_img.rotate(angle, expand=True, fillcolor=(255, 255, 255, 0)) img.paste(rotated, (x_offset, random.randint(5, 15)), rotated) x_offset += 34 buf = io.BytesIO() img.save(buf, format="PNG") b64 = base64.b64encode(buf.getvalue()).decode() return captcha_id, text, f"data:image/png;base64,{b64}" # ── 滑块拼图验证码 ────────────────────────────────────── def generate_slider_captcha() -> Tuple[str, str, dict]: """ 返回: (captcha_id, answer_xxx, { bg: base64 背景图, slice: base64 滑块拼图块, x: 缺口x坐标 (前端拼图用) }) """ captcha_id = hashlib.md5(f"{time.time()}{random.random()}".encode()).hexdigest()[:16] bg_w, bg_h = 280, 160 img = Image.new("RGB", (bg_w, bg_h), (240, 240, 240)) draw = ImageDraw.Draw(img) # 背景色块 for _ in range(3): r = random.randint(200, 255) g = random.randint(200, 255) b = random.randint(200, 255) x1, y1 = random.randint(0, bg_w - 60), random.randint(0, bg_h - 40) draw.rectangle([x1, y1, x1 + 60, y1 + 40], fill=(r, g, b)) # 随机干扰线 for _ in range(8): draw.line( [ (random.randint(0, bg_w), random.randint(0, bg_h)), (random.randint(0, bg_w), random.randint(0, bg_h)), ], fill=(random.randint(180, 220), random.randint(180, 220), random.randint(180, 220)), width=1, ) # 随机绘制文字(增加OCR难度) font_small = _load_font(14) for _ in range(12): x = random.randint(0, bg_w - 30) y = random.randint(0, bg_h - 20) c = random.choice(string.ascii_uppercase) draw.text((x, y), c, fill=(random.randint(150, 220), random.randint(150, 220), random.randint(150, 220)), font=font_small) # 缺口位置 gap_size = 40 gap_x = random.randint(20, bg_w - gap_size - 20) gap_y = random.randint(15, bg_h - gap_size - 15) # 在背景图上切出缺口(深色填充) draw.rectangle([gap_x, gap_y, gap_x + gap_size, gap_y + gap_size], fill=(80, 80, 80)) # 创建滑块拼图块(从另一位置裁取) slice_x = max(0, gap_x - 80) if slice_x + gap_size > bg_w: slice_x = bg_w - gap_size - 10 slice_img = img.crop((slice_x, gap_y, slice_x + gap_size, gap_y + gap_size)) # 给滑块块加白色边框 slice_with_border = Image.new("RGB", (gap_size + 4, gap_size + 4), (255, 255, 255)) slice_with_border.paste(slice_img, (2, 2)) buf_bg = io.BytesIO() img.save(buf_bg, format="PNG") bg_b64 = base64.b64encode(buf_bg.getvalue()).decode() buf_slice = io.BytesIO() slice_with_border.save(buf_slice, format="PNG") slice_b64 = base64.b64encode(buf_slice.getvalue()).decode() # answer 存 gap_x 的字符串形式 answer = hashlib.md5(str(gap_x).encode()).hexdigest()[:16] return captcha_id, f"ans_{answer}", { "bg": f"data:image/png;base64,{bg_b64}", "slice": f"data:image/png;base64,{slice_b64}", "gap_x": gap_x, }