diff --git a/README.md b/README.md
index 0c95c7d..84be506 100644
--- a/README.md
+++ b/README.md
@@ -15,25 +15,67 @@ The minimum requirement of Go is **1.16**.
## Getting started
+```html
+
+
+
+
+
+
+
+
+
+
+```
+
```go
package main
import (
+ "fmt"
+ "net/http"
+
"github.com/flamego/flamego"
- "github.com/flamego/recaptcha"
+ "github.com/flamego/hcaptcha"
+ "github.com/flamego/template"
)
func main() {
f := flamego.Classic()
- f.Use(recaptcha.V2(recaptcha.Options{
- Secret: "",
- VerifyURL: recaptcha.VerifyURLGlobal,
- }))
- f.Get("/verify", func(c flamego.Context, r recaptcha.RecaptchaV2) {
- response, err := r.Verify(input)
- if response.Success{
- //...
+ f.Use(template.Templater())
+ f.Use(recaptcha.V3(
+ recaptcha.Options{
+ Secret: "",
+ VerifyURL: recaptcha.VerifyURLGoogle,
+ },
+ ))
+ f.Get("/", func(t template.Template, data template.Data) {
+ data["SiteKey"] = ""
+ t.HTML(http.StatusOK, "home")
+ })
+ f.Post("/", func(w http.ResponseWriter, r *http.Request, re recaptcha.RecaptchaV3) {
+ token := r.PostFormValue("g-recaptcha-response")
+ resp, err := re.Verify(token)
+ if err != nil {
+ w.WriteHeader(http.StatusBadRequest)
+ _, _ = w.Write([]byte(err.Error()))
+ return
+ } else if !resp.Success {
+ w.WriteHeader(http.StatusBadRequest)
+ _, _ = w.Write([]byte(fmt.Sprintf("Verification failed, error codes %v", resp.ErrorCodes)))
+ return
}
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte("Verified!"))
})
f.Run()
}
diff --git a/example/README.md b/example/README.md
new file mode 100644
index 0000000..b0e14e5
--- /dev/null
+++ b/example/README.md
@@ -0,0 +1,9 @@
+You need a [reCAPTCHA](https://www.google.com/recaptcha/about/) account to run this example.
+
+```shell
+$ go run main.go -site-key= -secret-key=
+[Flamego] Listening on 0.0.0.0:2830 (development)
+...
+```
+
+Then, visit http://localhost:2830 to test the challenge.
diff --git a/example/main.go b/example/main.go
new file mode 100644
index 0000000..0c22dd1
--- /dev/null
+++ b/example/main.go
@@ -0,0 +1,72 @@
+// Copyright 2022 Flamego. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package main
+
+import (
+ "flag"
+ "fmt"
+ "net/http"
+
+ "github.com/flamego/flamego"
+
+ "github.com/flamego/recaptcha"
+)
+
+func main() {
+ siteKey := flag.String("site-key", "", "The reCAPTCHA site key")
+ secretKey := flag.String("secret-key", "", "The reCAPTCHA secret key")
+ flag.Parse()
+
+ f := flamego.Classic()
+ f.Use(recaptcha.V3(
+ recaptcha.Options{
+ Secret: *secretKey,
+ VerifyURL: recaptcha.VerifyURLGoogle,
+ },
+ ))
+
+ f.Get("/", func(w http.ResponseWriter) {
+ w.Header().Set("Content-Type", "text/html; charset=UTF-8")
+ _, _ = w.Write([]byte(fmt.Sprintf(`
+
+
+
+
+
+
+
+
+
+`, *siteKey)))
+ })
+
+ f.Post("/", func(w http.ResponseWriter, r *http.Request, re recaptcha.RecaptchaV3) {
+ token := r.PostFormValue("g-recaptcha-response")
+ fmt.Println("token", token)
+ resp, err := re.Verify(token)
+ if err != nil {
+ w.WriteHeader(http.StatusBadRequest)
+ _, _ = w.Write([]byte(err.Error()))
+ return
+ } else if !resp.Success {
+ w.WriteHeader(http.StatusBadRequest)
+ _, _ = w.Write([]byte(fmt.Sprintf("Verification failed, error codes %v", resp.ErrorCodes)))
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte("Verified!"))
+ })
+
+ f.Run()
+}
diff --git a/recaptcha.go b/recaptcha.go
index 9938d8c..30c2a80 100644
--- a/recaptcha.go
+++ b/recaptcha.go
@@ -5,9 +5,9 @@
package recaptcha
import (
- "fmt"
"io"
"net/http"
+ "net/url"
"github.com/pkg/errors"
@@ -27,7 +27,9 @@ const (
// Options contains options for both recaptcha.RecaptchaV2 and recaptcha.RecaptchaV3 middleware.
type Options struct {
- // Secret is the shared key between your site and reCAPTCHA. This field is required.
+ // Client the HTTP client to make requests. The default is http.DefaultClient.
+ Client *http.Client
+ // Secret is the secret key to check user captcha codes. This field is required.
Secret string
VerifyURL
@@ -36,6 +38,10 @@ type Options struct {
// V2 returns a middleware handler that injects recaptcha.RecaptchaV2
// into the request context, which is used for verifying reCAPTCHA V2 requests.
func V2(opts Options) flamego.Handler {
+ if opts.Client == nil {
+ opts.Client = http.DefaultClient
+ }
+
if opts.Secret == "" {
panic("recaptcha: empty secret")
}
@@ -46,6 +52,7 @@ func V2(opts Options) flamego.Handler {
return flamego.ContextInvoker(func(c flamego.Context) {
client := &recaptchaV2{
+ client: opts.Client,
secret: opts.Secret,
verifyURL: string(opts.VerifyURL),
}
@@ -66,6 +73,7 @@ func V3(opts Options) flamego.Handler {
return flamego.ContextInvoker(func(c flamego.Context) {
var client RecaptchaV3 = &recaptchaV3{
+ client: opts.Client,
secret: opts.Secret,
verifyURL: string(opts.VerifyURL),
}
@@ -74,16 +82,19 @@ func V3(opts Options) flamego.Handler {
})
}
-// request requests specific url and returns response.
-func request(url, secret, response, remoteIP string) ([]byte, error) {
- url = fmt.Sprintf("%s?secret=%s&response=%s&remoteIP=%s", url, secret, response, remoteIP)
- res, err := http.Get(url)
+func request(client *http.Client, endpoint, secret, response, remoteIP string) ([]byte, error) {
+ data := url.Values{
+ "secret": {secret},
+ "response": {response},
+ "remoteip": {remoteIP},
+ }
+ resp, err := client.PostForm(endpoint, data)
if err != nil {
- return nil, errors.Wrapf(err, "request %q", url)
+ return nil, errors.Wrapf(err, "POST %q", endpoint)
}
- defer func() { _ = res.Body.Close() }()
+ defer func() { _ = resp.Body.Close() }()
- body, err := io.ReadAll(res.Body)
+ body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, errors.Wrap(err, "read response body")
}
diff --git a/recaptcha_test.go b/recaptcha_test.go
index d391574..6680403 100644
--- a/recaptcha_test.go
+++ b/recaptcha_test.go
@@ -6,34 +6,158 @@ package recaptcha
import (
"bytes"
+ "io"
"net/http"
"net/http/httptest"
+ "net/url"
"strings"
"testing"
- "github.com/stretchr/testify/assert"
-
"github.com/flamego/flamego"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
+type mockRoundTripper struct {
+ roundTrip func(*http.Request) (*http.Response, error)
+}
+
+func (m *mockRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) {
+ return m.roundTrip(r)
+}
+
func TestV2(t *testing.T) {
- f := flamego.NewWithLogger(&bytes.Buffer{})
- f.Use(V2(Options{
- Secret: "6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe",
- VerifyURL: VerifyURLGlobal,
- }))
- f.Post("/", func(c flamego.Context, r RecaptchaV2) bool {
- response, err := c.Request().Body().String()
- assert.Nil(t, err)
-
- responseV2, err := r.Verify(response)
- assert.Nil(t, err)
- return responseV2.Success
- })
-
- resp := httptest.NewRecorder()
- req, err := http.NewRequest(http.MethodPost, "/", strings.NewReader("some response"))
- assert.Nil(t, err)
-
- f.ServeHTTP(resp, req)
+ tests := []struct {
+ name string
+ wantSecret string
+ wantToken string
+ wantRemoteIP string
+ }{
+ {
+ name: "normal",
+ wantSecret: "test-secret",
+ wantToken: "valid-token",
+ wantRemoteIP: "",
+ },
+ {
+ name: "remoteip",
+ wantSecret: "test-secret",
+ wantToken: "valid-token",
+ wantRemoteIP: "127.0.0.1",
+ },
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ client := &http.Client{
+ Transport: &mockRoundTripper{
+ roundTrip: func(r *http.Request) (*http.Response, error) {
+ assert.Equal(t, test.wantSecret, r.PostFormValue("secret"))
+ assert.Equal(t, test.wantToken, r.PostFormValue("response"))
+ assert.Equal(t, test.wantRemoteIP, r.PostFormValue("remoteip"))
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(`{"success": true}`)),
+ Request: r,
+ }, nil
+ },
+ },
+ }
+
+ f := flamego.NewWithLogger(&bytes.Buffer{})
+ f.Use(V2(Options{
+ Client: client,
+ Secret: test.wantSecret,
+ }))
+ f.Post("/", func(r *http.Request, re RecaptchaV2) {
+ token := r.PostFormValue("g-recaptcha-response")
+
+ var err error
+ var resp *ResponseV2
+ if test.wantRemoteIP != "" {
+ resp, err = re.Verify(token, test.wantRemoteIP)
+ } else {
+ resp, err = re.Verify(token)
+ }
+ require.NoError(t, err)
+ assert.True(t, resp.Success)
+ })
+
+ resp := httptest.NewRecorder()
+ req, err := http.NewRequest(http.MethodPost, "/", nil)
+ require.NoError(t, err)
+
+ req.PostForm = url.Values{
+ "g-recaptcha-response": {test.wantToken},
+ }
+ f.ServeHTTP(resp, req)
+ })
+ }
+}
+
+func TestV3(t *testing.T) {
+ tests := []struct {
+ name string
+ wantSecret string
+ wantToken string
+ wantRemoteIP string
+ }{
+ {
+ name: "normal",
+ wantSecret: "test-secret",
+ wantToken: "valid-token",
+ wantRemoteIP: "",
+ },
+ {
+ name: "remoteip",
+ wantSecret: "test-secret",
+ wantToken: "valid-token",
+ wantRemoteIP: "127.0.0.1",
+ },
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ client := &http.Client{
+ Transport: &mockRoundTripper{
+ roundTrip: func(r *http.Request) (*http.Response, error) {
+ assert.Equal(t, test.wantSecret, r.PostFormValue("secret"))
+ assert.Equal(t, test.wantToken, r.PostFormValue("response"))
+ assert.Equal(t, test.wantRemoteIP, r.PostFormValue("remoteip"))
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(`{"success": true}`)),
+ Request: r,
+ }, nil
+ },
+ },
+ }
+
+ f := flamego.NewWithLogger(&bytes.Buffer{})
+ f.Use(V3(Options{
+ Client: client,
+ Secret: test.wantSecret,
+ }))
+ f.Post("/", func(r *http.Request, re RecaptchaV3) {
+ token := r.PostFormValue("g-recaptcha-response")
+
+ var err error
+ var resp *ResponseV3
+ if test.wantRemoteIP != "" {
+ resp, err = re.Verify(token, test.wantRemoteIP)
+ } else {
+ resp, err = re.Verify(token)
+ }
+ require.NoError(t, err)
+ assert.True(t, resp.Success)
+ })
+
+ resp := httptest.NewRecorder()
+ req, err := http.NewRequest(http.MethodPost, "/", nil)
+ require.NoError(t, err)
+
+ req.PostForm = url.Values{
+ "g-recaptcha-response": {test.wantToken},
+ }
+ f.ServeHTTP(resp, req)
+ })
+ }
}
diff --git a/v2.go b/v2.go
index 9bc6694..c2b2905 100644
--- a/v2.go
+++ b/v2.go
@@ -6,6 +6,7 @@ package recaptcha
import (
"encoding/json"
+ "net/http"
"time"
"github.com/pkg/errors"
@@ -13,45 +14,44 @@ import (
// RecaptchaV2 is a reCAPTCHA V2 verify interface.
type RecaptchaV2 interface {
- // Verify verifies user's response and send result back to client.
+ // Verify verifies the given token. An optional remote IP of the user may be
+ // passed as extra security criteria.
Verify(token string, remoteIP ...string) (*ResponseV2, error)
}
var _ RecaptchaV2 = (*recaptchaV2)(nil)
type recaptchaV2 struct {
- // secret is the shared key between your site and reCAPTCHA. [Required]
- secret string
- // response is the user response token provided by the reCAPTCHA client-side integration on your site. [Required]
- response string
- // remoteIP is the user's IP address. [Optional]
- remoteIP string
- // verifyURL is the reCAPTCHA backend service URL.
+ client *http.Client
+ secret string
verifyURL string
}
// ResponseV2 is the response struct which Google send back to the client.
type ResponseV2 struct {
- // Success shows whether the user is a real human.
+ // Success indicates whether the passcode valid, and does it meet security
+ // criteria you specified.
Success bool `json:"success"`
- // ChallengeTS is the timestamp of the challenge load (ISO format yyyy-MM-dd'T'HH:mm:ssZZ).
+ // ChallengeTS is the timestamp of the challenge load (ISO format
+ // yyyy-MM-dd'T'HH:mm:ssZZ).
ChallengeTS time.Time `json:"challenge_ts"`
- // Hostname is the hostname of the site where the reCAPTCHA was solved.
+ // Hostname is the hostname of the site where the challenge was solved.
Hostname string `json:"hostname"`
- // ErrorCodes returns the error codes when verify failed.
+ // ErrorCodes contains the error codes when verify failed.
ErrorCodes []string `json:"error-codes"`
}
-// Verify verifies user's response and send result back to client.
func (r *recaptchaV2) Verify(token string, remoteIP ...string) (*ResponseV2, error) {
if token == "" {
return nil, errors.New("empty token")
}
+
+ var ip string
if len(remoteIP) > 0 {
- r.remoteIP = remoteIP[0]
+ ip = remoteIP[0]
}
- resp, err := request(r.verifyURL, r.secret, r.response, r.remoteIP)
+ resp, err := request(r.client, r.verifyURL, r.secret, token, ip)
if err != nil {
return nil, errors.Wrap(err, "request reCAPTCHA server")
}
@@ -59,8 +59,7 @@ func (r *recaptchaV2) Verify(token string, remoteIP ...string) (*ResponseV2, err
var response ResponseV2
err = json.Unmarshal(resp, &response)
if err != nil {
- return nil, errors.Wrapf(err, "unmarshal reCAPTCHA response JSON: %v", string(resp))
+ return nil, errors.Wrap(err, "unmarshal response body")
}
-
return &response, nil
}
diff --git a/v3.go b/v3.go
index 58aadfb..ddb6e31 100644
--- a/v3.go
+++ b/v3.go
@@ -6,6 +6,7 @@ package recaptcha
import (
"encoding/json"
+ "net/http"
"time"
"github.com/pkg/errors"
@@ -13,46 +14,48 @@ import (
// RecaptchaV3 is a reCAPTCHA V3 verify interface.
type RecaptchaV3 interface {
- // Verify verifies user's response and send result back to client.
- // It returns a score (1.0 is very likely a good interaction, 0.0 is very likely a bot).
- // Based on the score, you can take variable action in the context of your site.
+ // Verify verifies the given token. An optional remote IP of the user may be
+ // passed as extra security criteria.
Verify(token string, remoteIP ...string) (*ResponseV3, error)
}
+var _ RecaptchaV3 = (*recaptchaV3)(nil)
+
type recaptchaV3 struct {
- // secret is the shared key between your site and reCAPTCHA. [Required]
- secret string
- // response is the user response token provided by the reCAPTCHA client-side integration on your site. [Required]
- response string
- // remoteIP is the user's IP address. [Optional]
- remoteIP string
- // verifyURL is the reCAPTCHA backend service URL.
+ client *http.Client
+ secret string
verifyURL string
}
-var _ RecaptchaV3 = (*recaptchaV3)(nil)
-
// ResponseV3 is the response struct which Google send back to the client.
type ResponseV3 struct {
- Success bool `json:"success"` // whether this request was a valid reCAPTCHA token for your site
- Score float64 `json:"score"` // the score for this request (0.0 - 1.0)
- Action string `json:"action"` // the action name for this request (important to verify)
- ChallengeTS time.Time `json:"challenge_ts"` // timestamp of the challenge load (ISO format yyyy-MM-dd'T'HH:mm:ssZZ)
- Hostname string `json:"hostname"` // the hostname of the site where the reCAPTCHA was solved
- ErrorCodes []string `json:"error-codes"` // optional
+ // Success indicates whether the passcode valid, and does it meet security
+ // criteria you specified.
+ Success bool `json:"success"`
+ // ChallengeTS is the timestamp of the challenge load (ISO format
+ // yyyy-MM-dd'T'HH:mm:ssZZ).
+ ChallengeTS time.Time `json:"challenge_ts"`
+ // Hostname is the hostname of the site where the challenge was solved.
+ Hostname string `json:"hostname"`
+ // ErrorCodes contains the error codes when verify failed.
+ ErrorCodes []string `json:"error-codes"`
+ // Score indicates the score of the request (0.0 - 1.0).
+ Score float64 `json:"score"`
+ // Action is the action name of the request.
+ Action string `json:"action"`
}
-// Verify verifies user's response and send result back to client.
func (r *recaptchaV3) Verify(token string, remoteIP ...string) (*ResponseV3, error) {
if token == "" {
return nil, errors.New("empty token")
}
+ var ip string
if len(remoteIP) > 0 {
- r.remoteIP = remoteIP[0]
+ ip = remoteIP[0]
}
- resp, err := request(r.verifyURL, r.secret, r.response, r.remoteIP)
+ resp, err := request(r.client, r.verifyURL, r.secret, token, ip)
if err != nil {
return nil, errors.Wrap(err, "request reCAPTCHA server")
}
@@ -60,8 +63,7 @@ func (r *recaptchaV3) Verify(token string, remoteIP ...string) (*ResponseV3, err
var response ResponseV3
err = json.Unmarshal(resp, &response)
if err != nil {
- return nil, errors.Wrapf(err, "unmarshal reCAPTCHA response JSON: %v", string(resp))
+ return nil, errors.Wrap(err, "unmarshal response body")
}
-
return &response, nil
}