Files
OpenFlare/internal/util/cap/manager.go
T
2026-06-08 20:37:47 +08:00

272 lines
7.6 KiB
Go

package cap
import (
"context"
"crypto/sha256"
"encoding/hex"
"strconv"
"strings"
"sync"
"time"
"github.com/linux-do/credit/internal/config"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/model"
)
// Config holds settings for the CAPTCHA manager
type Config struct {
Secret []byte // HMAC signing key
ChallengeCount int // Number of PoW puzzles
ChallengeSize int // Size of the salt string
ChallengeDifficulty int // Length of difficulty target prefix
ChallengeTTL time.Duration // Lifespan of the challenge JWT
TokenTTL time.Duration // Lifespan of the redeem token
}
// Manager orchestrates challenge generation and solution validation
type Manager struct {
conf Config
store Store
}
// NewManager creates a new CAPTCHA Manager
func NewManager(conf Config, store Store) *Manager {
if conf.ChallengeCount <= 0 {
conf.ChallengeCount = 1
}
if conf.ChallengeSize <= 0 {
conf.ChallengeSize = 32
}
if conf.ChallengeDifficulty <= 0 {
conf.ChallengeDifficulty = 4
}
if conf.ChallengeTTL <= 0 {
conf.ChallengeTTL = 10 * time.Minute
}
if conf.TokenTTL <= 0 {
conf.TokenTTL = 20 * time.Minute
}
return &Manager{
conf: conf,
store: store,
}
}
// Generate creates a challenge response
func (m *Manager) Generate(ctx context.Context, scope string) (*ChallengeResponse, error) {
c := ChallengeConfig{
Count: m.getChallengeCount(ctx),
Size: m.getChallengeSize(ctx),
Difficulty: m.getChallengeDifficulty(ctx),
ExpiresMs: m.getChallengeTTL(ctx),
}
return GenerateChallenge(m.conf.Secret, c, scope)
}
// Redeem verifies PoW solutions and returns a one-time redeem token
func (m *Manager) Redeem(ctx context.Context, token string, solutions []int, scope string) (*RedeemResponse, error) {
sigHex := jwtSigHex(token)
if sigHex == "" {
return &RedeemResponse{Success: false, Error: "invalid_token"}, nil
}
nonceKey := "cap:nonce:" + sigHex
// Atomically claim the nonce slot BEFORE verifying solutions.
// SetNX returns true only when the key did not previously exist, so two
// concurrent requests carrying the same JWT can never both succeed here.
// TTL is set to the challenge's remaining lifetime so the slot auto-expires.
payload, err := VerifyChallengeSolutions(token, solutions, m.conf.Secret, scope)
if err != nil {
return &RedeemResponse{Success: false, Error: err.Error()}, nil
}
// Calculate remaining lifetime of the challenge JWT for the nonce TTL.
now := time.Now().UnixNano() / int64(time.Millisecond)
nonceTTL := time.Duration(payload.Expires-now) * time.Millisecond
if nonceTTL < time.Second {
nonceTTL = time.Second
}
// Atomic claim: if another goroutine already redeemed this JWT the SetNX
// will return false and we reject the request without issuing a token.
set, err := m.store.SetNX(ctx, nonceKey, "1", nonceTTL)
if err != nil {
return &RedeemResponse{Success: false, Error: "nonce_store_error"}, err
}
if !set {
return &RedeemResponse{Success: false, Error: "already_redeemed"}, nil
}
// Generate a redeem token formatted as "id:verToken"
id := randomHex(8)
verToken := randomHex(15)
verHashBytes := sha256.Sum256([]byte(verToken))
verHashHex := hex.EncodeToString(verHashBytes[:])
tokenKey := "cap:token:" + id + ":" + verHashHex
tokenTTL := m.getTokenTTL(ctx)
tokenExpires := time.Now().Add(tokenTTL)
// Value stored is "expiresNano|scope"
storeVal := strconv.FormatInt(tokenExpires.UnixNano(), 10) + "|" + scope
if err := m.store.Set(ctx, tokenKey, storeVal, tokenTTL); err != nil {
return &RedeemResponse{Success: false, Error: "token_store_error"}, err
}
return &RedeemResponse{
Success: true,
Token: id + ":" + verToken,
Expires: tokenExpires.UnixNano() / int64(time.Millisecond),
}, nil
}
// VerifyToken validates and consumes the redeem token (single-use).
// GetAndDelete is used so that retrieval and removal happen atomically:
// two concurrent requests carrying the same token can never both see a value.
func (m *Manager) VerifyToken(ctx context.Context, token string, expectedScope string) (bool, error) {
if token == "" {
return false, nil
}
parts := strings.Split(token, ":")
if len(parts) != 2 {
return false, nil
}
id := parts[0]
verToken := parts[1]
verHashBytes := sha256.Sum256([]byte(verToken))
verHashHex := hex.EncodeToString(verHashBytes[:])
tokenKey := "cap:token:" + id + ":" + verHashHex
// Atomically retrieve-and-delete: the first caller gets the value, any
// subsequent caller (even concurrent) receives (false, nil) immediately.
val, exists, err := sGetAndDelete(ctx, m.store, tokenKey)
if err != nil {
return false, err
}
if !exists {
return false, nil
}
valParts := strings.Split(val, "|")
if len(valParts) != 2 {
return false, nil
}
expNano, err := strconv.ParseInt(valParts[0], 10, 64)
if err != nil {
return false, nil
}
tokenScope := valParts[1]
if expectedScope != "" && tokenScope != expectedScope {
return false, nil
}
if time.Now().UnixNano() > expNano {
return false, nil // Expired
}
return true, nil
}
// sGet safely calls store.Get, treating a nil store as a miss.
func sGet(ctx context.Context, store Store, key string) (string, bool, error) {
if store == nil {
return "", false, nil
}
return store.Get(ctx, key)
}
// sGetAndDelete safely calls store.GetAndDelete, treating a nil store as a miss.
func sGetAndDelete(ctx context.Context, store Store, key string) (string, bool, error) {
if store == nil {
return "", false, nil
}
return store.GetAndDelete(ctx, key)
}
func (m *Manager) getChallengeCount(ctx context.Context) int {
val, err := model.GetIntByKey(ctx, model.ConfigKeyCapChallengeCount)
if err != nil || val <= 0 {
return m.conf.ChallengeCount
}
return val
}
func (m *Manager) getChallengeSize(ctx context.Context) int {
val, err := model.GetIntByKey(ctx, model.ConfigKeyCapChallengeSize)
if err != nil || val <= 0 {
return m.conf.ChallengeSize
}
return val
}
func (m *Manager) getChallengeDifficulty(ctx context.Context) int {
val, err := model.GetIntByKey(ctx, model.ConfigKeyCapChallengeDifficulty)
if err != nil || val <= 0 {
return m.conf.ChallengeDifficulty
}
return val
}
func (m *Manager) getChallengeTTL(ctx context.Context) time.Duration {
val, err := model.GetIntByKey(ctx, model.ConfigKeyCapChallengeTTL)
if err != nil || val <= 0 {
return m.conf.ChallengeTTL
}
return time.Duration(val) * time.Second
}
func (m *Manager) getTokenTTL(ctx context.Context) time.Duration {
val, err := model.GetIntByKey(ctx, model.ConfigKeyCapTokenTTL)
if err != nil || val <= 0 {
return m.conf.TokenTTL
}
return time.Duration(val) * time.Second
}
var (
defaultManager *Manager
once sync.Once
)
// GetDefaultManager yields the global singleton CAPTCHA manager
func GetDefaultManager() *Manager {
once.Do(func() {
var secret []byte
if config.Config != nil && config.Config.App.SessionSecret != "" {
secret = []byte(config.Config.App.SessionSecret)
} else {
secret = []byte("default-captcha-secret-key-at-least-16-bytes")
}
challengeCount := 1
challengeSize := 32
challengeDifficulty := 4
challengeTTL := 10 * time.Minute
tokenTTL := 20 * time.Minute
var store Store
if config.Config != nil && config.Config.Redis.Enabled && db.Redis != nil {
store = NewRedisStore(db.Redis)
} else {
store = NewMemoryStore(1 * time.Minute)
}
defaultManager = NewManager(Config{
Secret: secret,
ChallengeCount: challengeCount,
ChallengeSize: challengeSize,
ChallengeDifficulty: challengeDifficulty,
ChallengeTTL: challengeTTL,
TokenTTL: tokenTTL,
}, store)
})
return defaultManager
}