feat(api): require bearer authentication and unify response contracts
This commit is contained in:
1 parent
30e361cd10
commit
1b2b2b12ff
19 files changed
+1799
-314
No files matched your search
@@ -0,0 +1,125 @@
|
||||
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})
|
||||
}
|
||||
Reference in new issue
Block a user