58.【.NET8 实战--孢子记账--从单体到微服务--转向微服务】--新增功能--图形验证码

图形验证码一般用于限制某项操作的频率以及防止恶意程序进行自动化操作。它通过要求用户输入图片中显示的文字或数字组合,来验证操作者是否为真实的人类用户。这种机制可以有效地阻止批量注册、暴力破解密码等自动化攻击行为,同时也能减轻服务器负载压力,保护系统安全。在用户体验方面,图形验证码的难度要适中,既要确保机器无法轻易识别,又要让正常用户能够方便快捷地完成验证。

一、图形验证码的实现

1.1 Redis服务增加计数自增

Redis服务中的IncrementAsync方法是一个通用的计数自增方法,可用于多种场景下的计数统计。在验证码场景中,我们使用它来统计生成次数并进行频率限制。该方法利用Redis的原子操作特性,确保在并发环境下计数的准确性。通过设置过期时间,可以灵活控制各类操作的频率限制。代码如下:

csharp 复制代码
//==========IRedisService接口=============
/// <summary>
/// 计数自增(不存在则创建并设置过期时间)
/// </summary>
/// <param name="key">键</param>
/// <param name="expirySeconds">过期时间(秒)</param>
/// <returns>自增后的值</returns>
Task<long> IncrementAsync(string key, int expirySeconds);

//==========RedisService类=============
/// <summary>
/// 自增计数(若键不存在则设置为1并附带过期时间)
/// </summary>
/// <param name="key">键</param>
/// <param name="expirySeconds">过期时间(秒)</param>
/// <returns>自增后的值</returns>
public async Task<long> IncrementAsync(string key, int expirySeconds)
{
    try
    {
        // 使用 Lua 脚本保证原子性:不存在则设置为1并设置过期;存在则INCR
        const string script = @"local exists = redis.call('EXISTS', KEYS[1])
                                if exists == 1 then
                                    return redis.call('INCR', KEYS[1])
                                else
                                    redis.call('SET', KEYS[1], 1, 'EX', ARGV[1])
                                    return 1
                                end";

        var result = (long)await Database.ScriptEvaluateAsync(script, new RedisKey[] { key },
            new RedisValue[] { expirySeconds });
        return result;
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, "Redis自增失败,Key: {Key}", key);
        return -1;
    }
}

上述代码实现了一个基于Redis的计数自增功能,这是图形验证码系统中的一个重要组成部分。这段代码主要包含了接口定义和具体实现两个部分,让我们深入了解其工作原理。

在接口定义中,IncrementAsync方法接收两个参数:键名和过期时间(以秒为单位)。这个方法的设计目的是为了实现一个原子性的计数器,可以用于限制用户在特定时间窗口内的操作次数。实现部分使用了Redis的Lua脚本来确保操作的原子性。这一点非常重要,因为在高并发环境下,我们需要确保计数操作的准确性。Lua脚本的逻辑可以分为两个分支:如果键已存在,直接进行自增操作;如果键不存在,则创建该键并设置初始值为1,同时设置过期时间。在错误处理方面,代码使用了try-catch块来捕获可能发生的异常,并通过注入的ILogger接口记录错误信息。当发生错误时,方法会返回-1作为错误标识,这让调用方能够方便地判断操作是否成功。

通过设置过期时间,可以确保计数器在指定时间后自动失效,这对于实现时间窗口内的访问频率限制特别有用。比如,可以用它来限制同一IP地址在一分钟内获取验证码的次数,防止恶意用户进行大量请求。

1.2 图形验证码服务接口

在这一小节我们实现Service层的代码,在SP.IdentityService微服务中新建图形验证码服务接口ICaptchaService以及它的实现类CaptchaServiceImpl,它包含生成图形验证码CreateAsync方法以及校验图形验证码VerifyAsync方法。我们先来看一下代码:

csharp 复制代码
using SP.IdentityService.Models.Response;

namespace SP.IdentityService.Services;

/// <summary>
/// 图形验证码服务
/// </summary>
public interface ICaptchaService
{
    /// <summary>
    /// 生成图形验证码(字母数字)并缓存到 Redis
    /// </summary>
    /// <param name="ip">请求方IP(可选用于限流)</param>
    /// <returns>验证码图片与令牌</returns>
    Task<CaptchaCreateResponse> CreateAsync(string? ip = null);

    /// <summary>
    /// 校验图形验证码
    /// </summary>
    /// <param name="token">验证码令牌</param>
    /// <param name="code">用户输入验证码</param>
    /// <param name="removeOnSuccess">成功后是否删除</param>
    /// <returns>是否通过</returns>
    Task<bool> VerifyAsync(string token, string code, bool removeOnSuccess = true);
}

上面的代码中,CreateAsync方法用于生成图形验证码并缓存到 Redis 中,VerifyAsync方法用于校验用户输入的验证码是否正确。下面是CaptchaServiceImpl的代码:

csharp 复制代码
using System.Text;
using SixLabors.Fonts;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Drawing;
using SixLabors.ImageSharp.Drawing.Processing;
using SixLabors.ImageSharp.Formats.Png;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
using SP.Common;
using SP.Common.ConfigService;
using SP.Common.ExceptionHandling.Exceptions;
using SP.Common.Redis;
using SP.IdentityService.Models.Response;
using SP.IdentityService.Services;

namespace SP.IdentityService.Service.Impl;

/// <summary>
/// 图形验证码服务实现
/// </summary>
public class CaptchaServiceImpl : ICaptchaService
{
    private readonly IRedisService _redis;
    private readonly ILogger<CaptchaServiceImpl> _logger;
    private readonly IConfiguration _configuration;

    private static readonly char[] CaptchaChars =
        "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz".ToCharArray();

    /// <summary>
    /// 构建key
    /// </summary>
    /// <param name="token"></param>
    /// <returns></returns>
    private static string BuildKey(string token) => $"captcha:{token}";

    public CaptchaServiceImpl(IRedisService redis, ILogger<CaptchaServiceImpl> logger, IConfiguration configuration)
    {
        _redis = redis;
        _logger = logger;
        _configuration = configuration;
    }

    /// <summary>
    /// 生成图形验证码(字母数字)并缓存到 Redis
    /// </summary>
    /// <param name="ip">请求方IP(可选用于限流)</param>
    /// <returns>验证码图片与令牌</returns>
    public async Task<CaptchaCreateResponse> CreateAsync(string? ip = null)
    {
        // 按 IP 限流(基于配置开关)
        bool rateLimitEnabled = _configuration.GetValue(SPConfigKey.CaptchaRateLimitEnabled, true);
        if (rateLimitEnabled && !string.IsNullOrWhiteSpace(ip))
        {
            int windowSeconds = _configuration.GetValue(SPConfigKey.CaptchaRateLimitWindowSeconds, 60);
            int maxRequests = _configuration.GetValue(SPConfigKey.CaptchaRateLimitMaxRequests, 10);
            string rateKey = string.Format(SPRedisKey.CaptchaRateLimit, ip);
            long count = await _redis.IncrementAsync(rateKey, windowSeconds);
            if (count > 0 && count > maxRequests)
            {
                throw new BusinessException(
                    $"请求过于频繁,请在{windowSeconds}秒后再试",
                    System.Net.HttpStatusCode.TooManyRequests);
            }
        }

        int width = _configuration.GetValue(SPConfigKey.CaptchaWidth, 120);
        int height = _configuration.GetValue(SPConfigKey.CaptchaHeight, 40);
        int length = _configuration.GetValue(SPConfigKey.CaptchaLength, 4);
        int expiresSeconds = _configuration.GetValue(SPConfigKey.CaptchaExpirySeconds, 60);

        string code = GenerateCode(length);
        string token = Snow.GetId().ToString();

        using var image = new Image<Rgba32>(width, height, Color.White);
        image.Mutate<Rgba32>(ctx =>
        {
            ctx.Fill(Color.White);
            var random = Random.Shared;

            // 干扰线(使用 Pen + PathBuilder 以提升兼容性)
            for (int i = 0; i < 8; i++)
            {
                var color = Color.FromRgb((byte)random.Next(256), (byte)random.Next(256), (byte)random.Next(256));
                var p1 = new PointF(random.Next(width), random.Next(height));
                var p2 = new PointF(random.Next(width), random.Next(height));
                var pen = Pens.Solid(color, 1);
                var pb = new PathBuilder();
                pb.AddLine(p1, p2);
                ctx.Draw(pen, pb.Build());
            }

            // 文本:使用默认系统字体(第一个可用的字体)
            var fontFamily = SystemFonts.Families.First();
            var font = new Font(fontFamily, height * 0.6f, FontStyle.Bold);
            var textOptions = new RichTextOptions(font)
            {
                HorizontalAlignment = HorizontalAlignment.Center,
                VerticalAlignment = VerticalAlignment.Center,
                Origin = new PointF(width / 2f, height / 2f)
            };
            ctx.DrawText(textOptions, code, Color.Black);

            // 轻微扭曲/噪点
            for (int i = 0; i < width * height / 30; i++)
            {
                image[Random.Shared.Next(width), Random.Shared.Next(height)] = Color.FromRgb(
                    (byte)random.Next(256), (byte)random.Next(256), (byte)random.Next(256));
            }
        });

        // 保存到 Redis(小写存储方便大小写不敏感)
        await _redis.SetStringAsync(BuildKey(token), code.ToLowerInvariant(), expiresSeconds);

        // 输出为Base64
        using var buffer = new MemoryStream();
        await image.SaveAsync(buffer, new PngEncoder());
        string base64 = Convert.ToBase64String(buffer.ToArray());
        return new CaptchaCreateResponse
        {
            Token = token,
            ImageBase64 = $"data:image/png;base64,{base64}",
            ExpiresInSeconds = expiresSeconds
        };
    }

    /// <summary>
    /// 校验图形验证码
    /// </summary>
    /// <param name="token">验证码令牌</param>
    /// <param name="code">用户输入验证码</param>
    /// <param name="removeOnSuccess">成功后是否删除</param>
    /// <returns>是否通过</returns>
    public async Task<bool> VerifyAsync(string token, string code, bool removeOnSuccess = true)
    {
        if (string.IsNullOrWhiteSpace(token) || string.IsNullOrWhiteSpace(code)) return false;
        string? cached = await _redis.GetStringAsync(BuildKey(token));
        if (string.IsNullOrEmpty(cached)) return false;
        bool ok = string.Equals(cached, code.Trim().ToLowerInvariant(), StringComparison.Ordinal);
        if (ok && removeOnSuccess)
        {
            await _redis.RemoveAsync(BuildKey(token));
        }

        return ok;
    }


    /// <summary>
    /// 生成验证码
    /// </summary>
    /// <param name="length"></param>
    /// <returns></returns>
    private static string GenerateCode(int length)
    {
        var sb = new StringBuilder(length);
        for (int i = 0; i < length; i++)
        {
            sb.Append(CaptchaChars[Random.Shared.Next(CaptchaChars.Length)]);
        }

        return sb.ToString();
    }
}

在代码中,生成验证码方法CreateAsync首先实现了基于IP的请求频率限制功能。通过配置系统,可以灵活控制是否启用限流、限流时间窗口大小以及最大请求次数。当启用限流时,使用Redis的计数器来追踪每个IP的请求次数,超出限制则抛出业务异常。

验证码图片的生成使用了SixLabors.ImageSharp库。首先创建指定尺寸的白色背景图片,然后通过多个步骤增加干扰元素来提高验证码的安全性:绘制随机颜色的干扰线、将验证码文字绘制到图片中心位置、添加随机噪点。验证码文字使用系统默认字体,并应用了加粗效果以提升可读性。生成的验证码图片被转换为Base64格式,便于在前端直接显示。同时,验证码内容会以小写形式存储在Redis中,配合一个唯一的token作为键,并设置过期时间。为了保证验证码的随机性,使用了一个包含数字和大小写字母的字符集。验证码生成过程使用Random.Shared来获取随机字符,这是一个线程安全的随机数生成器。最后将随机token、Base64图片和有效期返回给调用方。

验证方法VerifyAsync的实现相对简单。它接收用户提供的token和验证码,将用户输入转换为小写后与Redis中存储的值进行比对。验证通过后,默认会删除Redis中的验证码记录,防止重复使用。

Tip:这里只展示了核心代码,非核心代码请查看项目的GitHub。

1.3 控制器实现

在Controllers文件夹下新建一个CaptchaController类,用于处理验证码相关的请求。代码如下:

csharp 复制代码
using Microsoft.AspNetCore.Mvc;
using SP.IdentityService.Models.Request;
using SP.IdentityService.Services;

namespace SP.IdentityService.Controllers;

/// <summary>
/// 图形验证码
/// </summary>
[ApiController]
[Route("api/captcha")] 
public class CaptchaController : ControllerBase
{
    private readonly ICaptchaService _captchaService;

    public CaptchaController(ICaptchaService captchaService)
    {
        _captchaService = captchaService;
    }

    /// <summary>
    /// 生成验证码图片(Base64)
    /// </summary>
    [HttpGet("create")]
    public async Task<IActionResult> Create()
    {
        string? ip = HttpContext.Connection.RemoteIpAddress?.ToString();
        var result = await _captchaService.CreateAsync(ip);
        return Ok(result);
    }

    /// <summary>
    /// 校验验证码
    /// </summary>
    [HttpPost("verify")]
    public async Task<IActionResult> Verify([FromBody] CaptchaVerifyRequest request)
    {
        bool ok = await _captchaService.VerifyAsync(request.Token, request.Code, true);
        return Ok(new { success = ok });
    }
}

代码中提供了两个主要的API端点用于验证码的生成和校验。Create方法通过HTTP GET请求提供验证码生成功能。它首先获取请求方的IP地址,这个IP地址将用于实现请求频率限制。方法调用captchaServiceCreateAsync方法生成验证码,并返回包含验证码图片(Base64格式)、令牌和过期时间的响应。Verify方法则通过HTTP POST请求处理验证码的校验。它接收一个包含token和用户输入验证码的请求体,通过调用服务层的VerifyAsync方法进行验证,并返回一个包含验证结果的简单JSON对象。

二、总结

本文详细介绍了在微服务中实现图形验证码功能的完整过程。通过Redis实现了高效的验证码存储和频率限制机制,使用SixLabors.ImageSharp库生成具有干扰线、随机噪点等反爬虫特性的验证码图片。整个实现采用了分层架构,从Redis基础服务、验证码业务服务到API控制器,每一层都进行了合理的职责划分和异常处理。系统不仅实现了基本的验证码生成和验证功能,还包含了可配置的IP限流机制,确保了系统的安全性和可用性。

相关推荐
Lee川7 小时前
深度拆解:基于面向对象思维的“就地编辑”组件全模块解析
javascript·架构
勤劳打代码7 小时前
Flutter 架构日记 — 状态管理
flutter·架构·前端框架
子兮曰12 小时前
后端字段又改了?我撸了一个 BFF 数据适配器,从此再也不怕接口“屎山”!
前端·javascript·架构
卓卓不是桌桌15 小时前
如何优雅地处理 iframe 跨域通信?这是我的开源方案
javascript·架构
Qlly15 小时前
DDD 架构为什么适合 MCP Server 开发?
人工智能·后端·架构
用户881586910911 天前
AI Agent 协作系统架构设计与实践
架构
鹏北海1 天前
Qiankun 微前端实战踩坑历程
前端·架构
货拉拉技术1 天前
货拉拉海豚平台-大模型推理加速工程化实践
人工智能·后端·架构
RoyLin2 天前
libkrun 深度解析:架构设计、模块实现与 Windows WHPX 后端
架构