187 lines
6.6 KiB
Python
187 lines
6.6 KiB
Python
"""图形验证码 & 滑块拼图验证码"""
|
|
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,
|
|
}
|