126 lines
2.9 KiB
Go
126 lines
2.9 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/rand"
|
|
"crypto/subtle"
|
|
"encoding/base64"
|
|
"errors"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/dchest/captcha"
|
|
"rawmark/internal/auth"
|
|
)
|
|
|
|
var (
|
|
errCaptchaInvalid = errors.New("验证码错误或已使用,请刷新后重试")
|
|
errCaptchaExpired = errors.New("验证码已过期,请刷新后重试")
|
|
)
|
|
|
|
type loginChallenge struct {
|
|
digits []byte
|
|
source string
|
|
expires time.Time
|
|
}
|
|
type captchaManager struct {
|
|
mu sync.Mutex
|
|
challenges map[string]loginChallenge
|
|
issued map[string][]time.Time
|
|
now func() time.Time
|
|
}
|
|
|
|
func newCaptchas() *captchaManager {
|
|
return &captchaManager{challenges: map[string]loginChallenge{}, issued: map[string][]time.Time{}, now: time.Now}
|
|
}
|
|
|
|
func opaqueToken() string {
|
|
b := make([]byte, 32)
|
|
rand.Read(b)
|
|
return base64.RawURLEncoding.EncodeToString(b)
|
|
}
|
|
|
|
func (c *captchaManager) create(source, replace string) (string, loginChallenge, error) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
now := c.now()
|
|
for id, item := range c.challenges {
|
|
if !now.Before(item.expires) {
|
|
delete(c.challenges, id)
|
|
}
|
|
}
|
|
for ip, times := range c.issued {
|
|
recent := times[:0]
|
|
for _, t := range times {
|
|
if now.Sub(t) < time.Minute {
|
|
recent = append(recent, t)
|
|
}
|
|
}
|
|
if len(recent) == 0 {
|
|
delete(c.issued, ip)
|
|
} else {
|
|
c.issued[ip] = recent
|
|
}
|
|
}
|
|
if len(c.issued[source]) >= 60 || len(c.challenges) >= 10000 {
|
|
return "", loginChallenge{}, auth.ErrRateLimited
|
|
}
|
|
if previous, ok := c.challenges[replace]; ok && previous.source == source {
|
|
delete(c.challenges, replace)
|
|
}
|
|
id := opaqueToken()
|
|
item := loginChallenge{digits: captcha.RandomDigits(6), source: source, expires: now.Add(5 * time.Minute)}
|
|
c.challenges[id] = item
|
|
c.issued[source] = append(c.issued[source], now)
|
|
return id, item, nil
|
|
}
|
|
|
|
func (c *captchaManager) consume(source, id, code string) error {
|
|
c.mu.Lock()
|
|
item, ok := c.challenges[id]
|
|
delete(c.challenges, id)
|
|
now := c.now()
|
|
c.mu.Unlock()
|
|
if !ok || item.source != source {
|
|
return errCaptchaInvalid
|
|
}
|
|
if !now.Before(item.expires) {
|
|
return errCaptchaExpired
|
|
}
|
|
if len(code) != 6 {
|
|
return errCaptchaInvalid
|
|
}
|
|
digits := []byte(code)
|
|
for i, v := range digits {
|
|
if v < '0' || v > '9' {
|
|
return errCaptchaInvalid
|
|
}
|
|
digits[i] = v - '0'
|
|
}
|
|
if subtle.ConstantTimeCompare(digits, item.digits) != 1 {
|
|
return errCaptchaInvalid
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) createCaptcha(w http.ResponseWriter, r *http.Request) {
|
|
var body struct {
|
|
ReplaceID string `json:"replaceId"`
|
|
}
|
|
if !readJSON(w, r, &body) {
|
|
return
|
|
}
|
|
id, item, err := s.captchas.create(clientIP(r), body.ReplaceID)
|
|
if err != nil {
|
|
writeError(w, 429, err.Error())
|
|
return
|
|
}
|
|
var png bytes.Buffer
|
|
if _, err = captcha.NewImage(id, item.digits, 240, 80).WriteTo(&png); err != nil {
|
|
internalError(w, r, err)
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"captchaId": id, "imageDataUrl": "data:image/png;base64," + base64.StdEncoding.EncodeToString(png.Bytes()), "expiresAt": item.expires})
|
|
}
|