Files
EasyGoAdmin_GoFrame_AntdVue/utility/captcha/captcha.go
T
2026-10-20 19:07:36 +08:00

403 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package captcha
import (
"bytes"
"context"
"crypto/rand"
"encoding/base64"
"fmt"
"image"
"image/color"
"image/png"
"io"
"math"
"math/big"
"os"
"strings"
"sync"
"github.com/gogf/gf/v2/frame/g"
"golang.org/x/image/font"
"golang.org/x/image/font/opentype"
"golang.org/x/image/math/fixed"
"easygoadmin/internal/consts"
"easygoadmin/utility/cache"
)
// ============================== 图形验证码 ==============================
//
// 输出 PNG data URL,使用 TTF 字体渲染,可直接由 img 标签展示。
// 前端契约:GET /api/captcha 返回 data: {key, captcha}。
// 字体缺失或解析失败时降级为内置 SVG 矢量渲染,接口不失败。
// defaultChars 默认字符集,已排除易混淆字符(0/O、1/l/I)
// 可用配置 captcha.chars 与 captcha.length 覆盖
const defaultChars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
// SVG 降级画布尺寸
// 仅在字体路径缺失时使用
const svgWidth, svgHeight = 120, 40
var (
fontOnce sync.Once // 保证字体仅加载一次
parsedFont *opentype.Font // 已解析的字体实例
)
// loadFont 加载并缓存 TTF 字体
//
// 进程内只读取一次,失败后永久降级为 SVG 渲染
func loadFont(ctx context.Context, path string) *opentype.Font {
fontOnce.Do(func() {
data, err := os.ReadFile(path)
if err != nil {
g.Log().Warningf(ctx, "captcha: 字体文件不可读 %s: %v(降级 SVG 渲染)", path, err)
return
}
f, err := opentype.Parse(data)
if err != nil {
g.Log().Warningf(ctx, "captcha: 字体解析失败 %s: %v(降级 SVG 渲染)", path, err)
return
}
parsedFont = f
})
return parsedFont
}
// Generate 生成验证码
//
// 返回验证码标识与 data URL,标识写入缓存,TTL 取配置 captcha.expire
func Generate(ctx context.Context) (key, dataURL string, err error) {
length := g.Cfg().MustGet(ctx, "captcha.length").Int()
if length < 4 || length > 8 {
length = 4
}
code := randomCode(length)
key = randomKey()
expire := g.Cfg().MustGet(ctx, "captcha.expire").Int()
if expire <= 0 {
expire = 300
}
if err = cache.Set(ctx, consts.CacheKeyCaptcha(key), code, expire); err != nil {
return "", "", err
}
fontPath := g.Cfg().MustGet(ctx, "captcha.fontPath").String()
if fontPath == "" {
fontPath = "resource/static/fonts/Vera.ttf"
}
if f := loadFont(ctx, fontPath); f != nil {
if s, e := renderPNG(ctx, f, code); e == nil {
return key, s, nil
} else {
g.Log().Warningf(ctx, "captcha: PNG 渲染失败: %v(降级 SVG 渲染)", e)
}
}
return key, renderSVG(code), nil
}
// Verify 校验并消费验证码
//
// 验证码为一次性使用;标识为空时返回 true,走宽松模式
func Verify(ctx context.Context, key, code string) bool {
if key == "" {
return true
}
// 万能验证码(配置 captcha.bypassCode,仅开发调试用,生产留空)
if bypass := g.Cfg().MustGet(ctx, "captcha.bypassCode").String(); bypass != "" &&
strings.EqualFold(bypass, strings.TrimSpace(code)) {
ck := consts.CacheKeyCaptcha(key)
_ = cache.Del(ctx, ck)
return true
}
ck := consts.CacheKeyCaptcha(key)
expected, ok := cache.Get(ctx, ck)
_ = cache.Del(ctx, ck) // 无论成败均消费,防爆破重放
if !ok || expected == "" {
return false
}
return strings.EqualFold(expected, strings.TrimSpace(code))
}
// randomCode 生成指定长度的随机验证码
func randomCode(n int) string {
charset := g.Cfg().MustGet(context.Background(), "captcha.chars").String()
if charset == "" {
charset = defaultChars
}
var sb strings.Builder
for i := 0; i < n; i++ {
idx, _ := rand.Int(rand.Reader, big.NewInt(int64(len(charset))))
sb.WriteByte(charset[idx.Int64()])
}
return sb.String()
}
// randomKey 生成随机验证码标识
func randomKey() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
return fmt.Sprintf("%x", b)
}
// ----------------------------------------------------------------------------
// PNG 渲染(主路径)
// 使用 TTF 字体,配合随机旋转、颜色与位置,叠加干扰线与噪点。
// 相关配置:captcha.imageWidth、captcha.imageHeight、captcha.fontSize、
// captcha.noiseLines、captcha.noiseDots
// ----------------------------------------------------------------------------
// pngColors 字符颜色池(绿色系)
var pngColors = []color.RGBA{
{0, 128, 0, 255}, // 绿色
{0, 150, 0, 255}, // 稍亮绿
{34, 139, 34, 255}, // 森林绿
{50, 205, 50, 255}, // 酸橙绿
{0, 100, 0, 255}, // 深绿
}
// renderPNG 渲染验证码为 PNG data URL
func renderPNG(ctx context.Context, f *opentype.Font, code string) (string, error) {
w := cfgInt(ctx, "captcha.imageWidth", 160)
h := cfgInt(ctx, "captcha.imageHeight", 50)
fs := cfgInt(ctx, "captcha.fontSize", 28)
lines := cfgInt(ctx, "captcha.noiseLines", 8)
dots := cfgInt(ctx, "captcha.noiseDots", 500)
face, err := opentype.NewFace(f, &opentype.FaceOptions{Size: float64(fs), DPI: 72, Hinting: font.HintingFull})
if err != nil {
return "", err
}
defer func() { // opentype face 实现了 io.Closer,防御性断言
if c, ok := face.(io.Closer); ok {
_ = c.Close()
}
}()
// 字符总宽(含字距),画布不够时自动加宽——防长验证码右侧被裁剪
const gap = 4
total := 0
for _, ch := range code {
adv, _ := face.GlyphAdvance(ch)
total += adv.Ceil() + gap
}
const margin = 16
if need := total + margin*2; w < need {
w = need
}
img := image.NewRGBA(image.Rect(0, 0, w, h))
fillRGBA(img, color.RGBA{247, 249, 252, 255})
// 干扰线:深绿色
lineColor := color.RGBA{0, 100, 0, 255}
for i := 0; i < lines; i++ {
drawLine(img, randInt(w), randInt(h), randInt(w), randInt(h), lineColor)
}
// 字符(逐字独立小画布 → 旋转 → 混合到主画布)
cellH := fs * 2
x := (w - total) / 2
for _, ch := range code {
adv, _ := face.GlyphAdvance(ch)
cw := adv.Ceil() + 8
cell := image.NewRGBA(image.Rect(0, 0, cw, cellH))
col := pngColors[randInt(len(pngColors))]
d := font.Drawer{
Dst: cell,
Src: image.NewUniform(col),
Face: face,
Dot: fixed.P(4, cellH/2+fs/3),
}
d.DrawString(string(ch))
angle := float64(randInt(50)-25) * math.Pi / 180
dx := x - 4 + randInt(5) - 2
dy := h/2 - cellH/2 + randInt(5) - 2
rotateBlend(img, cell, dx, dy, cw/2, cellH/2, angle)
x += adv.Ceil() + gap
}
// 噪点:半透明绿色
dotColor := color.RGBA{0, 128, 0, 90}
for i := 0; i < dots; i++ {
blendPixel(img, randInt(w), randInt(h), dotColor)
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
return "", err
}
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes()), nil
}
// cfgInt 读取正整数配置
//
// 配置缺失或非正时返回默认值
func cfgInt(ctx context.Context, key string, def int) int {
if v := g.Cfg().MustGet(ctx, key).Int(); v > 0 {
return v
}
return def
}
// fillRGBA 用指定颜色填充整张图像
func fillRGBA(img *image.RGBA, c color.RGBA) {
for i := 0; i < len(img.Pix); i += 4 {
img.Pix[i] = c.R
img.Pix[i+1] = c.G
img.Pix[i+2] = c.B
img.Pix[i+3] = 255
}
}
// rotateBlend 旋转并混合
//
// 将 src(透明底)绕自身中心 (scx,scy) 旋转 angle 后,
// 放到 dst 的 (dx,dy) 处并做 alpha 混合
func rotateBlend(dst, src *image.RGBA, dx, dy, scx, scy int, angle float64) {
sin, cos := math.Sin(angle), math.Cos(angle)
sb := src.Bounds()
cx, cy := float64(dx+scx), float64(dy+scy)
for y := dy; y < dy+sb.Dy(); y++ {
for x := dx; x < dx+sb.Dx(); x++ {
// 逆旋转求源坐标
relX, relY := float64(x)-cx, float64(y)-cy
sx := int(relX*cos+relY*sin) + scx
sy := int(-relX*sin+relY*cos) + scy
if sx < 0 || sy < 0 || sx >= sb.Dx() || sy >= sb.Dy() {
continue
}
si := src.PixOffset(sx, sy)
a := src.Pix[si+3]
if a == 0 {
continue
}
di := imgOffset(dst, x, y)
if di < 0 || di+3 >= len(dst.Pix) {
continue
}
// src 为预乘 alpha(image.RGBA 约定):dst = src + dst*(1-a)
fa := 255 - uint32(a)
dst.Pix[di] = uint8(uint32(src.Pix[si]) + uint32(dst.Pix[di])*fa/255)
dst.Pix[di+1] = uint8(uint32(src.Pix[si+1]) + uint32(dst.Pix[di+1])*fa/255)
dst.Pix[di+2] = uint8(uint32(src.Pix[si+2]) + uint32(dst.Pix[di+2])*fa/255)
dst.Pix[di+3] = 255
}
}
}
// imgOffset 计算像素在图像数据中的偏移量
//
// 坐标越界时返回 -1
func imgOffset(img *image.RGBA, x, y int) int {
if !image.Pt(x, y).In(img.Bounds()) {
return -1
}
return (y-img.Rect.Min.Y)*img.Stride + (x-img.Rect.Min.X)*4
}
// drawLine 使用 Bresenham 算法绘制直线
//
// 不透明绘制,用于生成干扰线
func drawLine(img *image.RGBA, x0, y0, x1, y1 int, c color.RGBA) {
dx, dy := abs(x1-x0), -abs(y1-y0)
sx, sy := -1, -1
if x0 < x1 {
sx = 1
}
if y0 < y1 {
sy = 1
}
err := dx + dy
for {
if i := imgOffset(img, x0, y0); i >= 0 {
img.Pix[i] = c.R
img.Pix[i+1] = c.G
img.Pix[i+2] = c.B
img.Pix[i+3] = 255
}
if x0 == x1 && y0 == y1 {
break
}
e2 := 2 * err
if e2 >= dy {
err += dy
x0 += sx
}
if e2 <= dx {
err += dx
y0 += sy
}
}
}
// blendPixel 单像素 alpha 混合
//
// 用于绘制噪点,传入颜色为直 alpha,先转预乘再叠加
func blendPixel(img *image.RGBA, x, y int, c color.RGBA) {
i := imgOffset(img, x, y)
if i < 0 {
return
}
av := uint32(c.A)
pr, pg, pb := uint32(c.R)*av/255, uint32(c.G)*av/255, uint32(c.B)*av/255
fa := 255 - av
img.Pix[i] = uint8(pr + uint32(img.Pix[i])*fa/255)
img.Pix[i+1] = uint8(pg + uint32(img.Pix[i+1])*fa/255)
img.Pix[i+2] = uint8(pb + uint32(img.Pix[i+2])*fa/255)
}
// abs 计算整数绝对值
func abs(n int) int {
if n < 0 {
return -n
}
return n
}
// ----------------------------------------------------------------------------
// SVG 渲染(降级路径)
// 字体缺失时兜底,保持接口可用。
// ----------------------------------------------------------------------------
// renderSVG 渲染带干扰的 SVG 验证码
func renderSVG(code string) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 %d %d">`,
svgWidth, svgHeight, svgWidth, svgHeight))
sb.WriteString(`<rect width="100%" height="100%" fill="#f7f9fc"/>`)
// 干扰线
for i := 0; i < 4; i++ {
x1 := randInt(svgWidth)
y1 := randInt(svgHeight)
x2 := randInt(svgWidth)
y2 := randInt(svgHeight)
sb.WriteString(fmt.Sprintf(`<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="#c8d3e0" stroke-width="1"/>`, x1, y1, x2, y2))
}
// 字符
for i, ch := range code {
x := 14 + i*26 + randInt(6)
y := 27 + randInt(6)
rot := randInt(50) - 25
colorIdx := randInt(len(pngColors))
c := pngColors[colorIdx]
sb.WriteString(fmt.Sprintf(
`<text x="%d" y="%d" font-size="24" font-family="Arial, sans-serif" font-weight="bold" fill="#%02x%02x%02x" transform="rotate(%d %d %d)">%c</text>`,
x, y, c.R, c.G, c.B, rot, x, y, ch))
}
// 噪点
for i := 0; i < 18; i++ {
sb.WriteString(fmt.Sprintf(`<circle cx="%d" cy="%d" r="1" fill="#aab7c4"/>`, randInt(svgWidth), randInt(svgHeight)))
}
sb.WriteString(`</svg>`)
return "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte(sb.String()))
}
// randInt 生成指定上限内的随机整数
func randInt(n int) int {
if n <= 0 {
return 0
}
v, _ := rand.Int(rand.Reader, big.NewInt(int64(n)))
return int(v.Int64())
}