4 changed files with 411 additions and 1 deletions
@ -0,0 +1,152 @@ |
|||
package files |
|||
|
|||
import ( |
|||
"crypto/rand" |
|||
"encoding/hex" |
|||
"encoding/json" |
|||
"errors" |
|||
"fmt" |
|||
"math/big" |
|||
"net/http" |
|||
"strconv" |
|||
"sync" |
|||
"time" |
|||
|
|||
"github.com/golang-jwt/jwt/v5" |
|||
) |
|||
|
|||
const captchaTTL = 10 * time.Minute |
|||
|
|||
type captchaClaims struct { |
|||
Expected int `json:"ans"` |
|||
jwt.RegisteredClaims |
|||
} |
|||
|
|||
// jtiStore tracks used token IDs to make each captcha token single-use.
|
|||
type jtiStore struct { |
|||
mu sync.Mutex |
|||
used map[string]time.Time // jti → token expiry
|
|||
} |
|||
|
|||
var usedJTIs = &jtiStore{used: make(map[string]time.Time)} |
|||
|
|||
func init() { |
|||
// Periodically remove expired JTIs so the map doesn't grow forever.
|
|||
go func() { |
|||
ticker := time.NewTicker(captchaTTL) |
|||
defer ticker.Stop() |
|||
for range ticker.C { |
|||
usedJTIs.mu.Lock() |
|||
now := time.Now() |
|||
for jti, exp := range usedJTIs.used { |
|||
if now.After(exp) { |
|||
delete(usedJTIs.used, jti) |
|||
} |
|||
} |
|||
usedJTIs.mu.Unlock() |
|||
} |
|||
}() |
|||
} |
|||
|
|||
// claim marks a JTI as used. Returns false if it was already used.
|
|||
func (s *jtiStore) claim(jti string, exp time.Time) bool { |
|||
s.mu.Lock() |
|||
defer s.mu.Unlock() |
|||
if _, exists := s.used[jti]; exists { |
|||
return false |
|||
} |
|||
s.used[jti] = exp |
|||
return true |
|||
} |
|||
|
|||
func randInt(min, max int) int { |
|||
n, _ := rand.Int(rand.Reader, big.NewInt(int64(max-min+1))) |
|||
return int(n.Int64()) + min |
|||
} |
|||
|
|||
func randJTI() string { |
|||
b := make([]byte, 16) |
|||
rand.Read(b) //nolint:errcheck
|
|||
return hex.EncodeToString(b) |
|||
} |
|||
|
|||
// CaptchaHandler generates a math question and returns it with a signed token
|
|||
// that embeds the expected answer. The token is single-use and expires in 10 minutes.
|
|||
func CaptchaHandler(jwtSecret string) http.HandlerFunc { |
|||
type response struct { |
|||
Question string `json:"question"` |
|||
Token string `json:"token"` |
|||
} |
|||
|
|||
return func(w http.ResponseWriter, r *http.Request) { |
|||
ops := []string{"+", "-", "×"} |
|||
op := ops[randInt(0, 2)] |
|||
|
|||
var a, b, expected int |
|||
switch op { |
|||
case "+": |
|||
a, b = randInt(2, 20), randInt(1, 15) |
|||
expected = a + b |
|||
case "-": |
|||
a, b = randInt(5, 20), randInt(1, 10) |
|||
expected = a - b |
|||
case "×": |
|||
a, b = randInt(1, 10), randInt(1, 10) |
|||
expected = a * b |
|||
} |
|||
|
|||
exp := time.Now().Add(captchaTTL) |
|||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, captchaClaims{ |
|||
Expected: expected, |
|||
RegisteredClaims: jwt.RegisteredClaims{ |
|||
ID: randJTI(), |
|||
ExpiresAt: jwt.NewNumericDate(exp), |
|||
}, |
|||
}) |
|||
signed, err := token.SignedString([]byte(jwtSecret)) |
|||
if err != nil { |
|||
http.Error(w, "internal error", http.StatusInternalServerError) |
|||
return |
|||
} |
|||
|
|||
w.Header().Set("Content-Type", "application/json") |
|||
json.NewEncoder(w).Encode(response{ //nolint:errcheck
|
|||
Question: fmt.Sprintf("%d %s %d", a, op, b), |
|||
Token: signed, |
|||
}) |
|||
} |
|||
} |
|||
|
|||
// verifyCaptcha validates the signed token and checks that answerStr matches
|
|||
// the embedded expected answer. Each token can only be used once.
|
|||
func verifyCaptcha(tokenStr, answerStr, jwtSecret string) error { |
|||
answer, err := strconv.Atoi(answerStr) |
|||
if err != nil { |
|||
return errors.New("invalid answer") |
|||
} |
|||
|
|||
parsed, err := jwt.ParseWithClaims(tokenStr, &captchaClaims{}, func(t *jwt.Token) (any, error) { |
|||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { |
|||
return nil, errors.New("unexpected signing method") |
|||
} |
|||
return []byte(jwtSecret), nil |
|||
}) |
|||
if err != nil || !parsed.Valid { |
|||
return errors.New("invalid or expired token") |
|||
} |
|||
|
|||
claims, ok := parsed.Claims.(*captchaClaims) |
|||
if !ok || claims.ID == "" { |
|||
return errors.New("malformed token") |
|||
} |
|||
|
|||
if claims.Expected != answer { |
|||
return errors.New("wrong answer") |
|||
} |
|||
|
|||
if !usedJTIs.claim(claims.ID, claims.ExpiresAt.Time) { |
|||
return errors.New("token already used") |
|||
} |
|||
|
|||
return nil |
|||
} |
|||
Loading…
Reference in new issue