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
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
|
|
}
|
|
|