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

288 lines
8.2 KiB
Go

/*
Copyright 2026 Arctel.net
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package cap
import (
"context"
"crypto/sha256"
"encoding/hex"
"strconv"
"strings"
"sync"
"time"
"github.com/Rain-kl/Wavelet/internal/config"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/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
}