From 1ff2634cdf163430aa51f4370153aa738e790d01 Mon Sep 17 00:00:00 2001 From: Joe Chen Date: Tue, 8 Mar 2022 22:07:25 +0800 Subject: [PATCH] all: make the code example work (#11) --- README.md | 60 ++++++++++++++--- example/README.md | 9 +++ example/main.go | 72 ++++++++++++++++++++ recaptcha.go | 29 +++++--- recaptcha_test.go | 166 ++++++++++++++++++++++++++++++++++++++++------ v2.go | 33 +++++---- v3.go | 48 +++++++------- 7 files changed, 338 insertions(+), 79 deletions(-) create mode 100644 example/README.md create mode 100644 example/main.go 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 }