first release
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
// Package captcha 生成登录页用的图形验证码。
|
||||
//
|
||||
// 刻意不引第三方库、也不依赖字体文件:字形是内置的 5×7 点阵,放大后逐个加抖动,
|
||||
// 再叠干扰线与噪点。服务端只存答案(不存图片),答案一次性使用并带过期时间。
|
||||
package captcha
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
"image/png"
|
||||
"math/big"
|
||||
mrand "math/rand"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// alphabet 是取码字符集:去掉 0/O、1/I/L 这些互相像的,省得用户反复认错。
|
||||
alphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
|
||||
// CodeLen 是验证码长度。
|
||||
CodeLen = 4
|
||||
|
||||
glyphW, glyphH = 5, 7
|
||||
scale = 4
|
||||
padX, padY = 10, 6
|
||||
// stepX 要给抖动留余量:字符宽 20,抖动 ±2,步长 26 时最小间距只剩 2px,
|
||||
// 相邻字符的笔画会串在一起(人眼也一样难看)。30 保证最小间距仍有 6px。
|
||||
stepX = 30
|
||||
// Width/Height 是出图尺寸。
|
||||
Width = padX*2 + (CodeLen-1)*stepX + glyphW*scale // 130
|
||||
Height = padY*2 + glyphH*scale // 40
|
||||
|
||||
defaultTTL = 10 * time.Minute
|
||||
maxItems = 4096
|
||||
)
|
||||
|
||||
// Store 保存待验证的答案。站点是单进程部署,放内存即可;
|
||||
// 重启会让已发出的验证码失效,用户重新获取一次就行。
|
||||
type Store struct {
|
||||
mu sync.Mutex
|
||||
items map[string]item
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
type item struct {
|
||||
code string
|
||||
exp time.Time
|
||||
}
|
||||
|
||||
// NewStore 建一个验证码池;ttl <= 0 时用 10 分钟。
|
||||
func NewStore(ttl time.Duration) *Store {
|
||||
if ttl <= 0 {
|
||||
ttl = defaultTTL
|
||||
}
|
||||
return &Store{items: map[string]item{}, ttl: ttl}
|
||||
}
|
||||
|
||||
// Issue 生成一张新验证码,返回用于对答的 id 与 PNG 字节。
|
||||
func (s *Store) Issue() (string, []byte, error) {
|
||||
code, err := randomCode()
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
id, err := randomID()
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.pruneLocked()
|
||||
s.items[id] = item{code: code, exp: time.Now().Add(s.ttl)}
|
||||
s.mu.Unlock()
|
||||
|
||||
png, err := render(code)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return id, png, nil
|
||||
}
|
||||
|
||||
// Verify 校验答案。无论对错都作废该 id——一次一用,避免拿同一张图反复试。
|
||||
func (s *Store) Verify(id, answer string) bool {
|
||||
s.mu.Lock()
|
||||
it, ok := s.items[id]
|
||||
delete(s.items, id)
|
||||
s.mu.Unlock()
|
||||
if !ok || time.Now().After(it.exp) {
|
||||
return false
|
||||
}
|
||||
got := strings.ToUpper(strings.TrimSpace(answer))
|
||||
return got == it.code
|
||||
}
|
||||
|
||||
// Answer 返回某个 id 当前对应的答案,不作废它。
|
||||
// 只给测试与本地排查用:答案本身不会经任何接口下发到客户端。
|
||||
func (s *Store) Answer(id string) (string, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
it, ok := s.items[id]
|
||||
return it.code, ok
|
||||
}
|
||||
|
||||
// pruneLocked 清掉过期项;仍然超过上限时随便丢一些,防止有人只取图不对答把内存撑大。
|
||||
// 调用方需持有 s.mu。
|
||||
func (s *Store) pruneLocked() {
|
||||
now := time.Now()
|
||||
for k, v := range s.items {
|
||||
if now.After(v.exp) {
|
||||
delete(s.items, k)
|
||||
}
|
||||
}
|
||||
for k := range s.items {
|
||||
if len(s.items) < maxItems {
|
||||
break
|
||||
}
|
||||
delete(s.items, k)
|
||||
}
|
||||
}
|
||||
|
||||
func randomCode() (string, error) {
|
||||
var b strings.Builder
|
||||
for i := 0; i < CodeLen; i++ {
|
||||
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(alphabet))))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b.WriteByte(alphabet[n.Int64()])
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
func randomID() (string, error) {
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b[:]), nil
|
||||
}
|
||||
|
||||
// render 把验证码画成 PNG:底 → 干扰线 → 字符(各自抖动)→ 噪点。
|
||||
func render(code string) ([]byte, error) {
|
||||
img := image.NewRGBA(image.Rect(0, 0, Width, Height))
|
||||
draw.Draw(img, img.Bounds(), &image.Uniform{color.RGBA{248, 250, 251, 255}}, image.Point{}, draw.Src)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
drawLine(img, mrand.Intn(Width), mrand.Intn(Height), mrand.Intn(Width), mrand.Intn(Height), rgb(160, 215))
|
||||
}
|
||||
for i, ch := range code {
|
||||
g, ok := glyphs[ch]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// 每个字符独立抖动,位置与颜色都不同——不抖的话点阵字太规整,等于白送
|
||||
ox := padX + i*stepX + mrand.Intn(5) - 2
|
||||
oy := padY + mrand.Intn(5) - 2
|
||||
col := rgb(0, 110)
|
||||
for row := 0; row < glyphH; row++ {
|
||||
for c := 0; c < glyphW; c++ {
|
||||
if g[row]&(1<<uint(glyphW-1-c)) == 0 {
|
||||
continue
|
||||
}
|
||||
for dy := 0; dy < scale; dy++ {
|
||||
for dx := 0; dx < scale; dx++ {
|
||||
img.Set(ox+c*scale+dx, oy+row*scale+dy, col)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 压在最上面的噪点少一些,别把字盖住
|
||||
for i := 0; i < 60; i++ {
|
||||
img.Set(mrand.Intn(Width), mrand.Intn(Height), rgb(120, 200))
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// rgb 返回每个通道取 [lo, hi) 的随机颜色。
|
||||
func rgb(lo, hi int) color.RGBA {
|
||||
f := func() uint8 { return uint8(lo + mrand.Intn(hi-lo)) }
|
||||
return color.RGBA{f(), f(), f(), 255}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
img.Set(x0, y0, c)
|
||||
if x0 == x1 && y0 == y1 {
|
||||
return
|
||||
}
|
||||
e2 := 2 * err
|
||||
if e2 >= dy {
|
||||
err += dy
|
||||
x0 += sx
|
||||
}
|
||||
if e2 <= dx {
|
||||
err += dx
|
||||
y0 += sy
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func abs(v int) int {
|
||||
if v < 0 {
|
||||
return -v
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// glyphs 是 5×7 点阵字形:每行低 5 位有效,最高位在最左边。
|
||||
var glyphs = map[rune][glyphH]byte{
|
||||
'0': {0b01110, 0b10001, 0b10011, 0b10101, 0b11001, 0b10001, 0b01110},
|
||||
'1': {0b00100, 0b01100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110},
|
||||
'2': {0b01110, 0b10001, 0b00001, 0b00010, 0b00100, 0b01000, 0b11111},
|
||||
'3': {0b11111, 0b00010, 0b00100, 0b00010, 0b00001, 0b10001, 0b01110},
|
||||
'4': {0b00010, 0b00110, 0b01010, 0b10010, 0b11111, 0b00010, 0b00010},
|
||||
'5': {0b11111, 0b10000, 0b11110, 0b00001, 0b00001, 0b10001, 0b01110},
|
||||
'6': {0b00110, 0b01000, 0b10000, 0b11110, 0b10001, 0b10001, 0b01110},
|
||||
'7': {0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b01000, 0b01000},
|
||||
'8': {0b01110, 0b10001, 0b10001, 0b01110, 0b10001, 0b10001, 0b01110},
|
||||
'9': {0b01110, 0b10001, 0b10001, 0b01111, 0b00001, 0b00010, 0b01100},
|
||||
'A': {0b01110, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001},
|
||||
'B': {0b11110, 0b10001, 0b10001, 0b11110, 0b10001, 0b10001, 0b11110},
|
||||
'C': {0b01110, 0b10001, 0b10000, 0b10000, 0b10000, 0b10001, 0b01110},
|
||||
'D': {0b11110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b11110},
|
||||
'E': {0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b11111},
|
||||
'F': {0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b10000},
|
||||
'G': {0b01110, 0b10001, 0b10000, 0b10111, 0b10001, 0b10001, 0b01111},
|
||||
'H': {0b10001, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001},
|
||||
'I': {0b01110, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110},
|
||||
'J': {0b00111, 0b00010, 0b00010, 0b00010, 0b00010, 0b10010, 0b01100},
|
||||
'K': {0b10001, 0b10010, 0b10100, 0b11000, 0b10100, 0b10010, 0b10001},
|
||||
'L': {0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b11111},
|
||||
'M': {0b10001, 0b11011, 0b10101, 0b10101, 0b10001, 0b10001, 0b10001},
|
||||
'N': {0b10001, 0b11001, 0b10101, 0b10011, 0b10001, 0b10001, 0b10001},
|
||||
'O': {0b01110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110},
|
||||
'P': {0b11110, 0b10001, 0b10001, 0b11110, 0b10000, 0b10000, 0b10000},
|
||||
'Q': {0b01110, 0b10001, 0b10001, 0b10001, 0b10101, 0b10010, 0b01101},
|
||||
'R': {0b11110, 0b10001, 0b10001, 0b11110, 0b10100, 0b10010, 0b10001},
|
||||
'S': {0b01111, 0b10000, 0b10000, 0b01110, 0b00001, 0b00001, 0b11110},
|
||||
'T': {0b11111, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100},
|
||||
'U': {0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110},
|
||||
'V': {0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01010, 0b00100},
|
||||
'W': {0b10001, 0b10001, 0b10001, 0b10101, 0b10101, 0b11011, 0b10001},
|
||||
'X': {0b10001, 0b10001, 0b01010, 0b00100, 0b01010, 0b10001, 0b10001},
|
||||
'Y': {0b10001, 0b10001, 0b01010, 0b00100, 0b00100, 0b00100, 0b00100},
|
||||
'Z': {0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b10000, 0b11111},
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package captcha
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/png"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestIssueAndVerify(t *testing.T) {
|
||||
s := NewStore(time.Minute)
|
||||
id, pngBytes, err := s.Issue()
|
||||
if err != nil {
|
||||
t.Fatalf("出图失败: %v", err)
|
||||
}
|
||||
if id == "" || len(pngBytes) == 0 {
|
||||
t.Fatal("id 或图片为空")
|
||||
}
|
||||
code, ok := s.Answer(id)
|
||||
if !ok || len([]rune(code)) != CodeLen {
|
||||
t.Fatalf("答案不对: %q ok=%v", code, ok)
|
||||
}
|
||||
for _, r := range code {
|
||||
if !strings.ContainsRune(alphabet, r) {
|
||||
t.Errorf("答案含取码字符集之外的字符 %q", r)
|
||||
}
|
||||
}
|
||||
|
||||
// 大小写与首尾空格都该容忍(用户手打容易带上)
|
||||
if !s.Verify(id, " "+strings.ToLower(code)+" ") {
|
||||
t.Error("正确验证码应通过(应容忍大小写与空格)")
|
||||
}
|
||||
// 一次性:同一张图不能再用
|
||||
if s.Verify(id, code) {
|
||||
t.Error("同一张验证码不应能被验证两次")
|
||||
}
|
||||
|
||||
// 错误答案
|
||||
id2, _, err := s.Issue()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Verify(id2, "XXXX") {
|
||||
t.Error("错误答案不应通过")
|
||||
}
|
||||
if s.Verify(id2, "XXXX") {
|
||||
t.Error("失败后该验证码也应作废")
|
||||
}
|
||||
|
||||
// 未发出的 id
|
||||
if s.Verify("nope", "ABCD") {
|
||||
t.Error("未知 id 不应通过")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpiry(t *testing.T) {
|
||||
s := NewStore(time.Millisecond)
|
||||
id, _, err := s.Issue()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
code, _ := s.Answer(id)
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
if s.Verify(id, code) {
|
||||
t.Error("过期后不应通过")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrune(t *testing.T) {
|
||||
s := NewStore(time.Millisecond)
|
||||
for i := 0; i < 5; i++ {
|
||||
if _, _, err := s.Issue(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
// 下一次 Issue 会顺手清掉过期的,池子不该越攒越大
|
||||
if _, _, err := s.Issue(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := len(s.items); n > 1 {
|
||||
t.Errorf("过期项应被清掉,实际还剩 %d 条", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestImageShowsAnswer 确认图里画的确实是答案:按已知点阵模板逐位回读。
|
||||
// 若哪天改了绘制偏移、抖动范围或字形表却忘了同步,用户会连自己也登不进后台,
|
||||
// 这条测试会立刻失败——它同时就是「图还认得出」的最低保证。
|
||||
func TestImageShowsAnswer(t *testing.T) {
|
||||
s := NewStore(time.Minute)
|
||||
for n := 0; n < 8; n++ {
|
||||
id, pngBytes, err := s.Issue()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want, _ := s.Answer(id)
|
||||
img, err := png.Decode(bytes.NewReader(pngBytes))
|
||||
if err != nil {
|
||||
t.Fatalf("图片无法解码: %v", err)
|
||||
}
|
||||
if b := img.Bounds(); b.Dx() != Width || b.Dy() != Height {
|
||||
t.Fatalf("图片尺寸应为 %dx%d,实际 %dx%d", Width, Height, b.Dx(), b.Dy())
|
||||
}
|
||||
if got := readCode(t, img); got != want {
|
||||
t.Fatalf("图里读出 %q,答案却是 %q", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// darkGrid 把图片预先取成「哪些像素属于字符笔画」的网格:
|
||||
// 字形用 0~110 的深色,干扰线与噪点都在 120 以上。逐像素调 img.At 太慢,先摊平。
|
||||
func darkGrid(img image.Image) [][]bool {
|
||||
grid := make([][]bool, Height)
|
||||
for y := 0; y < Height; y++ {
|
||||
row := make([]bool, Width)
|
||||
for x := 0; x < Width; x++ {
|
||||
r, g, b, _ := img.At(x, y).RGBA()
|
||||
row[x] = r>>8 < 120 && g>>8 < 120 && b>>8 < 120
|
||||
}
|
||||
grid[y] = row
|
||||
}
|
||||
return grid
|
||||
}
|
||||
|
||||
// readCode 逐位回读验证码:每个字符的位置只在基准点 ±2 内抖动,
|
||||
// 于是在这个小邻域里让每个字形去套,取重合度最高的那个。
|
||||
func readCode(t *testing.T, img image.Image) string {
|
||||
t.Helper()
|
||||
grid := darkGrid(img)
|
||||
var out strings.Builder
|
||||
// 基准位置与 render 保持一致
|
||||
for i := 0; i < CodeLen; i++ {
|
||||
baseX := padX + i*stepX
|
||||
best, bestGlyph := -1, '?'
|
||||
for ch, g := range glyphs {
|
||||
if !strings.ContainsRune(alphabet, ch) {
|
||||
continue
|
||||
}
|
||||
for dx := -3; dx <= 3; dx++ {
|
||||
for dy := -3; dy <= 3; dy++ {
|
||||
score := 0
|
||||
for row := 0; row < glyphH; row++ {
|
||||
for c := 0; c < glyphW; c++ {
|
||||
on := g[row]&(1<<uint(glyphW-1-c)) != 0
|
||||
if !on {
|
||||
continue
|
||||
}
|
||||
// 只看笔画中心,避开边缘被抖动与噪点糊掉的部分
|
||||
x := baseX + dx + c*scale + scale/2
|
||||
y := padY + dy + row*scale + scale/2
|
||||
if x < 0 || y < 0 || x >= Width || y >= Height {
|
||||
continue
|
||||
}
|
||||
// 命中加分、误命中扣分:只看命中数的话,笔画多的字形
|
||||
// (比如 B 比 C 多一竖)光靠瞎撞也能赢
|
||||
if grid[y][x] {
|
||||
score++
|
||||
} else {
|
||||
score--
|
||||
}
|
||||
}
|
||||
}
|
||||
if score > best {
|
||||
best, bestGlyph = score, ch
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 一个小字形最多 (5*7*scale*scale) 个像素,笔画中心数远小于它;
|
||||
// 命中太少说明这个位置没有字符,读数就不可信了
|
||||
if best < 8 {
|
||||
t.Fatalf("第 %d 个字符位置没读到笔画(最高分 %d)", i, best)
|
||||
}
|
||||
out.WriteRune(bestGlyph)
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
Reference in New Issue
Block a user