最近开发的一个网站采用的是固定图片验证码,结果被人恶意灌流+恶意注册,导致了一些不必要的消耗,决定稍稍升级一下验证码,给“君子”们加点难度(开源OCR是真的便宜,普通验证码根本挡不住!)

引言

传统的验证码方法包括

  1. 图片固定验证码

  2. GIF固定验证码

  3. 拖动验证码

  4. 行为验证码

前两个方法虽然能够在一定程度上防止自动化攻击,但随着OCR技术的发展,这些传统方法的安全性越来越受到挑战。而后两个方法开源的都有破解方案,商业公司的又太贵,与这些方法相比,GIF运算验证码就颇具性价比,通过动态展示公式,大大增加了自动化解析的难度,从而提升了验证码的安全性。

环境准备和导入必要的库

要开始我们的项目,首先需要准备Python环境,并导入几个关键的库:

  • PIL:Python Imaging Library,提供了强大的绘图以及图像处理能力。

  • io.BytesIO:用于在内存中读写bytes,我们将利用它来生成和存储GIF。

  • random:用来生成随机的数学公式和噪声效果。

介绍核心实现流程

实现GIF运算验证码的过程可以分为以下几个关键步骤:

  1. 输出公式的确定: 该步骤通过随机方法生成简单的数学公式,如加法和乘法,确保生成的每个验证码都是独一无二的。

  2. 创建带随机背景的画布: 利用PIL库创建一个指定尺寸的空白图像,并填充上随机选择的颜色作为背景。

  3. 录入文字和噪声效果: 在已创建的画布上,利用PIL库的绘图工具将数学公式的各个部分以及噪点、干扰线和干扰圈绘制上去。

  4. 图片合并和GIF生成: 最后,将所有帧合并成一个GIF文件,每帧间设置适当的停顿时间,生成最终的运算验证码。

现在我们来基于以上流程来实现一下最小功能

Step 1: 输出公式的确定

首先,我们需要生成一个简单的数学公式。这个步骤的目的是确保我们生成的验证码是独一无二的,为此我们将使用Python的`random`库。

import random

def generate_math_expression():
    a, b = random.randint(1, 9), random.randint(1, 9)
    operation = random.choice(['+', '*'])  # 选择加法或乘法
    if operation == '+':
        result = a + b
    elif operation == '*':
        result = a * b
    expression = f"{a} {operation} {b} = ?"
    return expression, result

这个函数将随机生成两个数字和一个运算符(加法或乘法),然后计算结果,并返回一个字符串形式的公式及其结果。

Step 2: 创建带随机背景的画布

接下来,我们将使用PIL(Pillow)库来创建一个带有随机背景色的图像。首先,你需要安装Pillow库。

pip install Pillow

然后,你可以按照以下方法创建一个带有随机背景色的画布:

from PIL import Image, ImageDraw

import random

def create_canvas(width, height):
    # 生成随机背景色
    background_color = (random.randint(0, 255), random.randint(0, 255), random.randint(0, 255))
    # 创建画布
    image = Image.new('RGB', (width, height), color=background_color)

    return image

Step 3: 录入文字和噪声效果

有了画布,我们现在可以开始在上面绘制数学公式和噪声效果了。

from PIL import ImageFont

def draw_text_and_noise(image, text):
    draw = ImageDraw.Draw(image)
    width, height = image.size
    font = ImageFont.load_default()  # 加载默认字体

    # 在中间绘制文本
    text_width, text_height = draw.textsize(text, font=font)
    draw.text(((width - text_width) / 2, (height - text_height) / 2), text, fill=(255, 255, 255), font=font)

    # 添加噪点
    for _ in range(100):
        x, y = random.randint(0, width), random.randint(0, height)
        draw.point((x, y), fill=(random.randint(0, 255), random.randint(0, 255), random.randint(0, 255)))

    # 添加干扰线
    for _ in range(5):
        start_point = (random.randint(0, width), random.randint(0, height))
        end_point = (random.randint(0, width), random.randint(0, height))
        draw.line([start_point, end_point], fill=(random.randint(0, 255), random.randint(0, 255), random.randint(0, 255)))

Step 4: 图片合并和GIF生成**

现在,我们需要组合之前的步骤,创建多帧图片并将它们合并为一个GIF。首先,我们将生成多帧图片,每一帧显示数学表达式的一个元素(数字或运算符)。最后,所有帧将合并为最终的GIF。

from PIL import Image, ImageDraw, ImageFont, ImageSequence, GifImagePlugin
from io import BytesIO

def create_frames(expression):
    frames = []
    for char in expression:
        # 创建画布
        image = create_canvas(100, 50)
        # 添加文本和噪声
        draw_text_and_noise(image, char)
        frames.append(image)
    return frames

def create_gif(animation_frames, duration=500):
    with BytesIO() as output:
        animation_frames[0].save(output, format="GIF", save_all=True, 
                                 append_images=animation_frames[1:], 
                                 optimize=False, duration=duration, loop=0)
        return output.getvalue()

# 实际使用步骤
expression, result = generate_math_expression()  # Step 1
frames = create_frames(expression)               # Step 2 - Step 3 创建每个字符的帧
captcha_gif = create_gif(frames)                 # Step 4 合并创建GIF

# 将GIF写入文件或返回web请求
with open("captcha.gif", "wb") as f:
    f.write(captcha_gif)

代码封装与提取逻辑

在完成上述功能的实现后,我们将其中的关键部分提取并封装成独立的函数,以便提高代码的重用性和可读性。如此一来,我们可以轻松地在不同的项目中应用GIF运算验证码,或根据需要对其进行扩展。

异步实现的封装代码

class GenerateGIFExpressionCaptcha:
    def __init__(
        self,
        font_path: str | None = None,
        font_size: int = 40,
        width: int = 100,
        height: int = 50,
        duration: int = 700,
    ) -> None:
        """
        Initialize the GIF expression captcha generator.

        Args:
            font_path (str | None): Path to the font file, default is None. If None, use the default font.
            font_size (int): Font size.
            width (int): GIF image width.
            height (int): GIF image height.
            duration (int): GIF animation duration in milliseconds.
        """
        self.font = ImageFont.truetype(font_path, font_size) if font_path else ImageFont.load_default()
        self.width = width
        self.height = height
        self.duration = duration

    async def generate_math_expression(self) -> tuple[str, int]:
        """
        Generate a random math expression consisting of two random single-digit numbers, an operator (+ or *), and the result.

        Returns:
            tuple[str, int]: A tuple containing the math expression as a string and the result as an integer.
        """
        a, b = random.randint(1, 9), random.randint(1, 9)
        a, b = max(a, b), min(a, b)
        op = random.choice(['+', '*'])
        result = a + b if op == '+' else a * b
        return f"{a} {op} {b} =", result

    async def draw_noise_line(self, draw: ImageDraw.ImageDraw) -> None:
        """
        Draw a random line as noise on the given ImageDraw object.

        Parameters:
            draw (ImageDraw.ImageDraw): The ImageDraw object on which to draw the line.
        """
        width, height = draw._image.size
        start_x, start_y = random.randint(0, width - 1), random.randint(0, height - 1)
        end_x, end_y = min(random.randint(0, width // 2), width - 1), min(random.randint(0, height // 2), height - 1)
        color = tuple(random.randint(0, 255) for _ in range(3))
        draw.line([(start_x, start_y), (end_x, end_y)], fill=color)

    async def draw_noise_circle(self, draw: ImageDraw.ImageDraw) -> None:
        """
        Draw a random circle as noise on the given ImageDraw object.

        Parameters:
            draw (ImageDraw.ImageDraw): The ImageDraw object on which to draw the circle.
        """
        width, height = draw._image.size
        radius = random.randint(1, height // 4)
        x, y = random.randint(radius, width - radius), random.randint(radius, height - radius)
        outline_color = tuple(random.randint(0, 255) for _ in range(3))
        draw.ellipse([(x - radius, y - radius), (x + radius, y + radius)], outline=outline_color)

    async def draw_noise_dot(self, draw: ImageDraw.ImageDraw) -> None:
        """
        Draw a random dot as noise on the given ImageDraw object.

        Args:
            draw (ImageDraw.ImageDraw): The ImageDraw object on which to draw the dot.
        """
        width, height = draw._image.size
        x, y = random.randint(0, width - 1), random.randint(0, height - 1)
        color = tuple(random.randint(0, 255) for _ in range(3))
        draw.point((x, y), fill=color)

    async def create_frame(
        self,
        text: str,
        index: int,
        total: int,
        width: int = 100,
        height: int = 50,
    ) -> Image.Image:
        """Create a frame for the GIF expression captcha.

        Args:
            text (str): The text to be displayed on the frame.
            index (int): The index of the text in the expression.
            total (int): The total number of texts in the expression.
            width (int, optional): The width of the frame. Defaults to 100.
            height (int, optional): The height of the frame. Defaults to 50.

        Returns:
            Image.Image: The created frame.
        """
        background_color = tuple(random.randint(0, 255) for _ in range(3))
        image = Image.new('RGB', (width, height), color=background_color)
        draw = ImageDraw.Draw(image)
        font = ImageFont.load_default()

        bbox = draw.textbbox((0, 0), text, font=font, align='center')
        text_width, text_height = bbox[2] - bbox[0], bbox[3] - bbox[1]

        for _ in range(6):
            await self.draw_noise_line(draw)
        for _ in range(int(self.width * self.height * 0.15)):
            await self.draw_noise_dot(draw)
        for _ in range(3):
            await self.draw_noise_circle(draw)

        code_color = tuple(255 - c for c in background_color)
        text_top, text_left = (height - text_height - 20) // 2, (width - text_width * total) // 2 + index * text_width
        draw.text((text_left, text_top), text, font=font, fill=code_color)
        return image

    async def create_gif(self, expression) -> bytes:
        """
        Create a GIF image from the given expression.

        Args:
            expression (str): The expression to be used for creating the GIF image.

        Returns:
            bytes: The GIF image bytes.
        """
        frames = []
        expression_list = expression.split()
        for index, part in enumerate(expression_list):
            frame = await self.create_frame(
                text=part,
                index=index,
                total=len(expression_list),
                width=self.width,
                height=self.height,
            )
            frames.append(frame)

        bytes_io = BytesIO()
        frames[0].save(
            bytes_io,
            format='GIF',
            append_images=frames[1:] if frames else [],
            save_all=True,
            duration=self.duration,
            loop=0,
        )
        bytes_io.seek(0)
        return bytes_io.getvalue()

总结

还有些可以优化的地方,比如可控的噪点与干扰线,GIF的大小和加载速度,但是对于小网站来说够了。

附录

更多关于PIL、io.BytesIO和random库的文档和资源,可以访问以下链接:

- PIL官方文档:[https://pillow.readthedocs.io/en/stable/](https://pillow.readthedocs.io/en/stable/)

- Python官方文档:[https://docs.python.org/3/](https://docs.python.org/3/)