You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

291 lines
7.4 KiB

package authcaptcha
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"fmt"
"image"
"image/color"
"image/png"
"io"
"strings"
"sync"
"time"
)
const (
defaultTTL = 2 * time.Minute
defaultCapacity = 256
maxPerSubject = 4
answerLength = 8
)
var digitGlyphs = map[byte][7]string{
'2': {"11111", "00001", "00001", "11111", "10000", "10000", "11111"},
'3': {"11111", "00001", "00001", "01111", "00001", "00001", "11111"},
'4': {"10001", "10001", "10001", "11111", "00001", "00001", "00001"},
'5': {"11111", "10000", "10000", "11111", "00001", "00001", "11111"},
'6': {"11111", "10000", "10000", "11111", "10001", "10001", "11111"},
'7': {"11111", "00001", "00010", "00100", "01000", "01000", "01000"},
'8': {"11111", "10001", "10001", "11111", "10001", "10001", "11111"},
'9': {"11111", "10001", "10001", "11111", "00001", "00001", "11111"},
}
type Challenge struct {
ChallengeID string `json:"challenge_id"`
ImageDataURL string `json:"image_data_url"`
ExpiresAt time.Time `json:"expires_at"`
}
type Service interface {
Create(subject string) (Challenge, error)
Verify(subject, challengeID, answer string) bool
}
type Options struct {
Clock func() time.Time
Random io.Reader
TTL time.Duration
Capacity int
}
type entry struct {
subject string
digest [sha256.Size]byte
createdAt time.Time
expiresAt time.Time
}
type Manager struct {
mu sync.Mutex
clock func() time.Time
random io.Reader
ttl time.Duration
capacity int
entries map[string]entry
}
func New(options Options) *Manager {
clock := options.Clock
if clock == nil {
clock = time.Now
}
randomSource := options.Random
if randomSource == nil {
randomSource = rand.Reader
}
ttl := options.TTL
if ttl <= 0 {
ttl = defaultTTL
}
capacity := options.Capacity
if capacity <= 0 {
capacity = defaultCapacity
}
return &Manager{clock: clock, random: randomSource, ttl: ttl, capacity: capacity, entries: make(map[string]entry)}
}
func (m *Manager) Create(subject string) (Challenge, error) {
idBytes := make([]byte, 18)
answerBytes := make([]byte, answerLength)
noiseBytes := make([]byte, 256)
if _, err := io.ReadFull(m.random, idBytes); err != nil {
return Challenge{}, fmt.Errorf("generate captcha id: %w", err)
}
if _, err := io.ReadFull(m.random, answerBytes); err != nil {
return Challenge{}, fmt.Errorf("generate captcha answer: %w", err)
}
if _, err := io.ReadFull(m.random, noiseBytes); err != nil {
return Challenge{}, fmt.Errorf("generate captcha image noise: %w", err)
}
const alphabet = "23456789"
for index := range answerBytes {
answerBytes[index] = alphabet[int(answerBytes[index])%len(alphabet)]
}
id := base64.RawURLEncoding.EncodeToString(idBytes)
answer := string(answerBytes)
now := m.clock().UTC()
expiresAt := now.Add(m.ttl)
imageDataURL, err := renderPNG(answer, noiseBytes)
if err != nil {
return Challenge{}, err
}
m.mu.Lock()
defer m.mu.Unlock()
m.pruneLocked(now)
m.evictSubjectOverflowLocked(strings.TrimSpace(subject))
for len(m.entries) >= m.capacity {
m.evictOldestLocked()
}
m.entries[id] = entry{
subject: strings.TrimSpace(subject),
digest: challengeDigest(subject, id, answer),
createdAt: now,
expiresAt: expiresAt,
}
return Challenge{ChallengeID: id, ImageDataURL: imageDataURL, ExpiresAt: expiresAt}, nil
}
func (m *Manager) evictSubjectOverflowLocked(subject string) {
count := 0
oldestID := ""
var oldest time.Time
for id, item := range m.entries {
if item.subject != subject {
continue
}
count++
if oldestID == "" || item.createdAt.Before(oldest) {
oldestID, oldest = id, item.createdAt
}
}
if count >= maxPerSubject && oldestID != "" {
delete(m.entries, oldestID)
}
}
func (m *Manager) Verify(subject, challengeID, answer string) bool {
id := strings.TrimSpace(challengeID)
if id == "" || strings.TrimSpace(answer) == "" {
return false
}
now := m.clock().UTC()
m.mu.Lock()
defer m.mu.Unlock()
m.pruneLocked(now)
stored, ok := m.entries[id]
delete(m.entries, id)
if !ok || stored.subject != strings.TrimSpace(subject) || !now.Before(stored.expiresAt) {
return false
}
actual := challengeDigest(subject, id, answer)
return subtle.ConstantTimeCompare(actual[:], stored.digest[:]) == 1
}
func (m *Manager) pruneLocked(now time.Time) {
for id, item := range m.entries {
if !now.Before(item.expiresAt) {
delete(m.entries, id)
}
}
}
func (m *Manager) evictOldestLocked() {
oldestID := ""
var oldest time.Time
for id, item := range m.entries {
if oldestID == "" || item.createdAt.Before(oldest) {
oldestID, oldest = id, item.createdAt
}
}
if oldestID != "" {
delete(m.entries, oldestID)
}
}
func challengeDigest(subject, id, answer string) [sha256.Size]byte {
normalized := strings.ToUpper(strings.TrimSpace(answer))
return sha256.Sum256([]byte(strings.TrimSpace(subject) + "\x00" + id + "\x00" + normalized))
}
func renderPNG(answer string, noise []byte) (string, error) {
const width, height = 184, 52
canvas := image.NewRGBA(image.Rect(0, 0, width, height))
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
shade := uint8(238 + int(noise[(x+y*width)%len(noise)])%15)
canvas.SetRGBA(x, y, color.RGBA{R: shade, G: shade, B: uint8(minInt(255, int(shade)+4)), A: 255})
}
}
for index := 0; index < 18; index++ {
offset := index * 4
x0 := int(noise[offset]) % width
y0 := int(noise[offset+1]) % height
x1 := int(noise[offset+2]) % width
y1 := int(noise[offset+3]) % height
drawLine(canvas, x0, y0, x1, y1, color.RGBA{R: 120, G: 145, B: 158, A: 130})
}
for index := 0; index < 120; index++ {
x := int(noise[(index*2)%len(noise)]) % width
y := int(noise[(index*2+1)%len(noise)]) % height
canvas.SetRGBA(x, y, color.RGBA{R: 70, G: 104, B: 120, A: 255})
}
for index := 0; index < len(answer); index++ {
glyph, ok := digitGlyphs[answer[index]]
if !ok {
return "", fmt.Errorf("captcha renderer received unsupported glyph")
}
x := 8 + index*22
y := 9 + int(noise[96+index])%5
drawGlyph(canvas, glyph, x, y, 4, color.RGBA{R: uint8(18 + noise[112+index]%45), G: 48, B: 66, A: 255})
}
var encoded strings.Builder
writer := base64.NewEncoder(base64.StdEncoding, &encoded)
if err := png.Encode(writer, canvas); err != nil {
_ = writer.Close()
return "", fmt.Errorf("encode captcha image: %w", err)
}
if err := writer.Close(); err != nil {
return "", fmt.Errorf("encode captcha image: %w", err)
}
return "data:image/png;base64," + encoded.String(), nil
}
func drawGlyph(canvas *image.RGBA, glyph [7]string, originX, originY, scale int, ink color.RGBA) {
for row, pattern := range glyph {
for column := 0; column < len(pattern); column++ {
if pattern[column] != '1' {
continue
}
for dy := 0; dy < scale; dy++ {
for dx := 0; dx < scale; dx++ {
canvas.SetRGBA(originX+column*scale+dx, originY+row*scale+dy, ink)
}
}
}
}
}
func drawLine(canvas *image.RGBA, x0, y0, x1, y1 int, ink color.RGBA) {
dx, sx := absInt(x1-x0), -1
if x0 < x1 {
sx = 1
}
dy, sy := -absInt(y1-y0), -1
if y0 < y1 {
sy = 1
}
err := dx + dy
for {
canvas.SetRGBA(x0, y0, ink)
if x0 == x1 && y0 == y1 {
return
}
twice := 2 * err
if twice >= dy {
err += dy
x0 += sx
}
if twice <= dx {
err += dx
y0 += sy
}
}
}
func absInt(value int) int {
if value < 0 {
return -value
}
return value
}
func minInt(left, right int) int {
if left < right {
return left
}
return right
}