chore: 基线提交(重构前代码快照)
- git 初始化 + .gitignore 补全(根 .env / logs / instance / __pycache__ 等) - 修复 utils/.gitignore 误将整个 utils/ 源码层忽略的问题,补入全部工具源码(含 ip2region.xdb) - 含后端全部代码:application / extensions / utils / config / migrations - 含前端 ui/(后续整体迁走) - 不含根 .env(含密钥,本地保留) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
# +======================================================================
|
||||
# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ]
|
||||
# +----------------------------------------------------------------------
|
||||
# | Copyright (c) 2021-2026 一米阳光团队 版权所有
|
||||
# +----------------------------------------------------------------------
|
||||
# | 开源协议: Apache-2.0
|
||||
# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。
|
||||
# +----------------------------------------------------------------------
|
||||
# | 官方网站: https://www.djangoadmin.cn
|
||||
# +----------------------------------------------------------------------
|
||||
# | 研发团队: 一米阳光团队 荣誉出品
|
||||
# +======================================================================
|
||||
|
||||
"""
|
||||
验证码生成与验证模块
|
||||
=============================================================
|
||||
|
||||
功能说明:
|
||||
1. 生成带干扰的验证码图片,返回 Base64 格式数据
|
||||
2. 验证用户输入的验证码是否正确
|
||||
3. 防刷机制:限制同一 IP 获取验证码的频率
|
||||
|
||||
技术特点:
|
||||
- 使用 secrets 模块生成安全随机数,替代 random 模块
|
||||
- 字符集去除 0/O/1/I/l/5/S/2/Z 等易混淆字符,提升用户体验
|
||||
- 验证码图片包含干扰线和噪点,防止 OCR 识别
|
||||
- 验证码存储于 Redis,支持过期自动失效
|
||||
- 验证成功后自动删除,确保一次性使用,防止重放攻击
|
||||
- 大小写不敏感比较,提升用户体验
|
||||
|
||||
配置来源:
|
||||
所有配置参数从 config 模块导入,由 .env 文件控制
|
||||
包括:验证码长度、过期时间、防刷间隔、字符集、字体、图片尺寸等
|
||||
|
||||
依赖:
|
||||
- utils.redis_client: Redis 客户端,用于存储验证码
|
||||
- config: 验证码配置参数(从 .env 读取)
|
||||
- PIL: 图片生成与处理
|
||||
|
||||
接口说明:
|
||||
generate_captcha() -> dict
|
||||
生成验证码,返回 {"idKey": "唯一标识", "data": "base64图片数据"}
|
||||
调用频率受 CAPTCHA_RATE_LIMIT 限制
|
||||
|
||||
verify_captcha(id_key, user_input) -> bool
|
||||
验证用户输入的验证码是否正确
|
||||
验证成功后自动删除 Redis 中的记录
|
||||
"""
|
||||
|
||||
import secrets
|
||||
from io import BytesIO
|
||||
|
||||
from PIL import Image, ImageFont, ImageDraw, ImageFilter
|
||||
import base64
|
||||
|
||||
from utils.redis_client import redis
|
||||
from config import (
|
||||
CAPTCHA_LENGTH,
|
||||
CAPTCHA_EXPIRE,
|
||||
CAPTCHA_RATE_LIMIT,
|
||||
CAPTCHA_RATE_WINDOW,
|
||||
CAPTCHA_CHARS,
|
||||
CAPTCHA_FONT_PATH,
|
||||
CAPTCHA_IMAGE_WIDTH,
|
||||
CAPTCHA_IMAGE_HEIGHT,
|
||||
CAPTCHA_FONT_SIZE,
|
||||
CAPTCHA_NOISE_LINES,
|
||||
CAPTCHA_NOISE_DOTS,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 内部函数
|
||||
# ============================================================
|
||||
|
||||
def get_random_color():
|
||||
"""获取随机颜色RGB(使用secrets更安全)"""
|
||||
return (
|
||||
secrets.randbelow(256),
|
||||
secrets.randbelow(256),
|
||||
secrets.randbelow(256)
|
||||
)
|
||||
|
||||
|
||||
def generate_image(length):
|
||||
"""生成验证码图片
|
||||
|
||||
Args:
|
||||
length: 验证码长度
|
||||
|
||||
Returns:
|
||||
tuple: (PIL.Image对象, 验证码字符串)
|
||||
"""
|
||||
# 从字符集随机抽取
|
||||
code = ''.join(secrets.choice(CAPTCHA_CHARS) for _ in range(length))
|
||||
|
||||
# 创建画布(浅灰色背景,减少对比度干扰)
|
||||
image = Image.new('RGB', (CAPTCHA_IMAGE_WIDTH, CAPTCHA_IMAGE_HEIGHT), color=(240, 240, 240))
|
||||
|
||||
# 加载字体
|
||||
try:
|
||||
font = ImageFont.truetype(CAPTCHA_FONT_PATH, size=CAPTCHA_FONT_SIZE)
|
||||
except Exception:
|
||||
font = ImageFont.load_default()
|
||||
|
||||
draw = ImageDraw.Draw(image)
|
||||
|
||||
# 绘制验证码字符(随机偏移,防OCR识别)
|
||||
char_width = (CAPTCHA_IMAGE_WIDTH - 20) // length
|
||||
for i, c in enumerate(code):
|
||||
x = 10 + i * char_width + secrets.randbelow(8)
|
||||
y = secrets.randbelow(10) + 2
|
||||
draw.text(
|
||||
(x, y),
|
||||
text=c,
|
||||
fill=get_random_color(),
|
||||
font=font
|
||||
)
|
||||
|
||||
# 绘制干扰线
|
||||
for _ in range(CAPTCHA_NOISE_LINES):
|
||||
x1 = secrets.randbelow(CAPTCHA_IMAGE_WIDTH)
|
||||
y1 = secrets.randbelow(CAPTCHA_IMAGE_HEIGHT // 2)
|
||||
x2 = secrets.randbelow(CAPTCHA_IMAGE_WIDTH)
|
||||
y2 = secrets.randbelow(CAPTCHA_IMAGE_HEIGHT // 2) + CAPTCHA_IMAGE_HEIGHT // 2
|
||||
draw.line(((x1, y1), (x2, y2)), fill=get_random_color(), width=1)
|
||||
|
||||
# 添加噪点
|
||||
for _ in range(CAPTCHA_NOISE_DOTS):
|
||||
x = secrets.randbelow(CAPTCHA_IMAGE_WIDTH)
|
||||
y = secrets.randbelow(CAPTCHA_IMAGE_HEIGHT)
|
||||
draw.point((x, y), fill=get_random_color())
|
||||
|
||||
# 滤镜增强边缘
|
||||
image = image.filter(ImageFilter.EDGE_ENHANCE)
|
||||
|
||||
return image, code
|
||||
|
||||
|
||||
def get_client_ip():
|
||||
"""获取客户端真实IP(支持代理)"""
|
||||
from flask import request
|
||||
if request.headers.get('X-Forwarded-For'):
|
||||
return request.headers.get('X-Forwarded-For').split(',')[0].strip()
|
||||
return request.remote_addr
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 对外接口
|
||||
# ============================================================
|
||||
|
||||
def generate_captcha():
|
||||
"""生成验证码实例
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
"idKey": str, # 验证码唯一标识
|
||||
"data": str # base64图片数据
|
||||
}
|
||||
"""
|
||||
from flask import request
|
||||
|
||||
# ============================================================
|
||||
# 防刷:滑动窗口计数,限制同一IP在窗口期内的请求次数
|
||||
# ============================================================
|
||||
client_ip = get_client_ip()
|
||||
rate_key = f"captcha:rate:{client_ip}"
|
||||
count = redis.get(rate_key)
|
||||
|
||||
if count and int(count) >= CAPTCHA_RATE_LIMIT:
|
||||
ttl = redis.ttl(rate_key)
|
||||
remaining = ttl if ttl > 0 else CAPTCHA_RATE_WINDOW
|
||||
raise Exception(f"请求过于频繁,请等待 {remaining} 秒后再试")
|
||||
|
||||
# ============================================================
|
||||
# 生成验证码
|
||||
# ============================================================
|
||||
image, code = generate_image(CAPTCHA_LENGTH)
|
||||
|
||||
# 转换为base64
|
||||
buffer = BytesIO()
|
||||
image.save(buffer, format='PNG')
|
||||
buf_bytes = buffer.getvalue()
|
||||
data = base64.b64encode(buf_bytes).decode('ascii')
|
||||
|
||||
# 生成唯一ID(使用secrets更安全)
|
||||
id_key = secrets.token_urlsafe(24)
|
||||
|
||||
# 存储验证码到Redis
|
||||
redis.set(id_key, code, expire=CAPTCHA_EXPIRE)
|
||||
|
||||
# 递增计数器,首次时设置过期时间(滑动窗口)
|
||||
if count:
|
||||
redis.incr(rate_key)
|
||||
else:
|
||||
redis.set(rate_key, 1, expire=CAPTCHA_RATE_WINDOW)
|
||||
|
||||
return {
|
||||
"idKey": id_key,
|
||||
"data": data
|
||||
}
|
||||
|
||||
|
||||
def verify_captcha(id_key, user_input):
|
||||
"""验证验证码(验证成功后自动删除,一次性使用)
|
||||
|
||||
Args:
|
||||
id_key: 验证码ID
|
||||
user_input: 用户输入的验证码
|
||||
|
||||
Returns:
|
||||
bool: 验证是否通过
|
||||
"""
|
||||
if not id_key or not user_input:
|
||||
return False
|
||||
|
||||
# 从Redis获取验证码
|
||||
stored_code = redis.get(id_key)
|
||||
|
||||
if not stored_code:
|
||||
return False
|
||||
|
||||
# 转为大写比较(大小写不敏感)
|
||||
stored_code = str(stored_code).upper().strip()
|
||||
user_input = str(user_input).upper().strip()
|
||||
|
||||
# 验证通过后删除(一次性使用,防止重放攻击)
|
||||
if stored_code == user_input:
|
||||
redis.delete(id_key)
|
||||
return True
|
||||
|
||||
return False
|
||||
Reference in New Issue
Block a user