diff --git a/pkg/captcha/local.go b/pkg/captcha/local.go new file mode 100644 index 0000000..ba6b917 --- /dev/null +++ b/pkg/captcha/local.go @@ -0,0 +1,98 @@ +package captcha + +import ( + "context" + "fmt" + "time" + + "github.com/mojocn/base64Captcha" + "github.com/redis/go-redis/v9" +) + +type localService struct { + redis *redis.Client + driver base64Captcha.Driver +} + +func newLocalService(redisClient *redis.Client) Service { + // Configure captcha driver + driver := base64Captcha.NewDriverDigit(80, 240, 5, 0.7, 80) + return &localService{ + redis: redisClient, + driver: driver, + } +} + +func (s *localService) Generate(ctx context.Context) (id string, image string, err error) { + // Generate captcha + captcha := base64Captcha.NewCaptcha(s.driver, &redisStore{ + redis: s.redis, + ctx: ctx, + }) + + id, b64s, answer, err := captcha.Generate() + if err != nil { + return "", "", err + } + + // Store answer in Redis with 5 minute expiration + key := fmt.Sprintf("captcha:%s", id) + err = s.redis.Set(ctx, key, answer, 5*time.Minute).Err() + if err != nil { + return "", "", err + } + + return id, b64s, nil +} + +func (s *localService) Verify(ctx context.Context, id string, code string, ip string) (bool, error) { + if id == "" || code == "" { + return false, nil + } + + key := fmt.Sprintf("captcha:%s", id) + + // Get answer from Redis + answer, err := s.redis.Get(ctx, key).Result() + if err != nil { + return false, err + } + + // Delete captcha after verification (one-time use) + s.redis.Del(ctx, key) + + // Verify code + return answer == code, nil +} + +func (s *localService) GetType() CaptchaType { + return CaptchaTypeLocal +} + +// redisStore implements base64Captcha.Store interface +type redisStore struct { + redis *redis.Client + ctx context.Context +} + +func (r *redisStore) Set(id string, value string) error { + key := fmt.Sprintf("captcha:%s", id) + return r.redis.Set(r.ctx, key, value, 5*time.Minute).Err() +} + +func (r *redisStore) Get(id string, clear bool) string { + key := fmt.Sprintf("captcha:%s", id) + val, err := r.redis.Get(r.ctx, key).Result() + if err != nil { + return "" + } + if clear { + r.redis.Del(r.ctx, key) + } + return val +} + +func (r *redisStore) Verify(id, answer string, clear bool) bool { + v := r.Get(id, clear) + return v == answer +} diff --git a/pkg/captcha/service.go b/pkg/captcha/service.go new file mode 100644 index 0000000..4536227 --- /dev/null +++ b/pkg/captcha/service.go @@ -0,0 +1,49 @@ +package captcha + +import ( + "context" + + "github.com/redis/go-redis/v9" +) + +type CaptchaType string + +const ( + CaptchaTypeLocal CaptchaType = "local" + CaptchaTypeTurnstile CaptchaType = "turnstile" +) + +// Service defines the captcha service interface +type Service interface { + // Generate generates a new captcha + // For local captcha: returns id and base64 image + // For turnstile: returns empty strings + Generate(ctx context.Context) (id string, image string, err error) + + // Verify verifies the captcha + // For local captcha: token is captcha id, code is user input + // For turnstile: token is cf-turnstile-response, code is ignored + Verify(ctx context.Context, token string, code string, ip string) (bool, error) + + // GetType returns the captcha type + GetType() CaptchaType +} + +// Config holds the configuration for captcha service +type Config struct { + Type CaptchaType + RedisClient *redis.Client + TurnstileSecret string +} + +// NewService creates a new captcha service based on the config +func NewService(config Config) Service { + switch config.Type { + case CaptchaTypeTurnstile: + return newTurnstileService(config.TurnstileSecret) + case CaptchaTypeLocal: + fallthrough + default: + return newLocalService(config.RedisClient) + } +} diff --git a/pkg/captcha/turnstile.go b/pkg/captcha/turnstile.go new file mode 100644 index 0000000..52e5bca --- /dev/null +++ b/pkg/captcha/turnstile.go @@ -0,0 +1,37 @@ +package captcha + +import ( + "context" + + "github.com/perfect-panel/server/pkg/turnstile" +) + +type turnstileService struct { + service turnstile.Service +} + +func newTurnstileService(secret string) Service { + return &turnstileService{ + service: turnstile.New(turnstile.Config{ + Secret: secret, + }), + } +} + +func (s *turnstileService) Generate(ctx context.Context) (id string, image string, err error) { + // Turnstile doesn't need server-side generation + return "", "", nil +} + +func (s *turnstileService) Verify(ctx context.Context, token string, code string, ip string) (bool, error) { + if token == "" { + return false, nil + } + + // Verify with Cloudflare Turnstile + return s.service.Verify(ctx, token, ip) +} + +func (s *turnstileService) GetType() CaptchaType { + return CaptchaTypeTurnstile +}