Skip to content

Commit 384b5d3

Browse files
authored
hcaptcha: initial implementation (#1)
1 parent d7d8cca commit 384b5d3

8 files changed

Lines changed: 332 additions & 13 deletions

File tree

README.md

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,65 @@ The minimum requirement of Go is **1.16**.
1515

1616
## Getting started
1717

18-
TBD
18+
```html
19+
<!-- templates/home.tmpl -->
20+
<html>
21+
<head>
22+
<script src="https://hcaptcha.com/1/api.js"></script>
23+
</head>
24+
<body>
25+
<form method="POST">
26+
<div class="h-captcha" data-sitekey="{{.SiteKey}}"></div>
27+
<input type="submit" name="button" value="Submit">
28+
</form>
29+
</body>
30+
</html>
31+
```
32+
33+
```go
34+
package main
35+
36+
import (
37+
"fmt"
38+
"net/http"
39+
40+
"github.com/flamego/flamego"
41+
"github.com/flamego/hcaptcha"
42+
"github.com/flamego/template"
43+
)
44+
45+
func main() {
46+
f := flamego.Classic()
47+
f.Use(template.Templater())
48+
f.Use(hcaptcha.Captcha(
49+
hcaptcha.Options{
50+
Secret: "<SECRET>",
51+
},
52+
))
53+
f.Get("/", func(t template.Template, data template.Data) {
54+
data["SiteKey"] = "<SITE KEY>"
55+
t.HTML(http.StatusOK, "home")
56+
})
57+
58+
f.Post("/", func(w http.ResponseWriter, r *http.Request, h hcaptcha.HCaptcha) {
59+
token := r.PostFormValue("h-captcha-response")
60+
resp, err := h.Verify(token)
61+
if err != nil {
62+
w.WriteHeader(http.StatusBadRequest)
63+
_, _ = w.Write([]byte(err.Error()))
64+
return
65+
} else if !resp.Success {
66+
w.WriteHeader(http.StatusBadRequest)
67+
_, _ = w.Write([]byte(fmt.Sprintf("Verification failed, error codes %v", resp.ErrorCodes)))
68+
return
69+
}
70+
w.WriteHeader(http.StatusOK)
71+
_, _ = w.Write([]byte("Verified!"))
72+
})
73+
74+
f.Run()
75+
}
76+
```
1977

2078
## Getting help
2179

example/README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
You need a [hCaptcha](https://www.hcaptcha.com/) account to run this example.
2+
3+
```shell
4+
$ go run main.go -site-key=<SITE KEY> -secret=<SECRET>
5+
[Flamego] Listening on 0.0.0.0:2830 (development)
6+
...
7+
```
8+
9+
Then, visit http://localhost:2830 to test different behaviors with and without completing the hCaptcha challenge.

example/main.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// Copyright 2022 Flamego. All rights reserved.
2+
// Use of this source code is governed by a MIT-style
3+
// license that can be found in the LICENSE file.
4+
5+
package main
6+
7+
import (
8+
"flag"
9+
"fmt"
10+
"net/http"
11+
12+
"github.com/flamego/flamego"
13+
14+
"github.com/flamego/hcaptcha"
15+
)
16+
17+
func main() {
18+
siteKey := flag.String("site-key", "", "The hCaptcha site key")
19+
secret := flag.String("secret", "", "The hCaptcha account secret")
20+
flag.Parse()
21+
22+
f := flamego.Classic()
23+
f.Use(hcaptcha.Captcha(
24+
hcaptcha.Options{
25+
Secret: *secret,
26+
},
27+
))
28+
f.Get("/", func(w http.ResponseWriter) {
29+
w.Header().Set("Content-Type", "text/html; charset=UTF-8")
30+
_, _ = w.Write([]byte(fmt.Sprintf(`
31+
<html>
32+
<head>
33+
<script src="https://hcaptcha.com/1/api.js"></script>
34+
</head>
35+
<body>
36+
<form method="POST">
37+
<div class="h-captcha" data-sitekey="%s"></div>
38+
<input type="submit" name="button" value="Submit">
39+
</form>
40+
</body>
41+
</html>
42+
`, *siteKey)))
43+
})
44+
45+
f.Post("/", func(w http.ResponseWriter, r *http.Request, h hcaptcha.HCaptcha) {
46+
token := r.PostFormValue("h-captcha-response")
47+
resp, err := h.Verify(token)
48+
if err != nil {
49+
w.WriteHeader(http.StatusBadRequest)
50+
_, _ = w.Write([]byte(err.Error()))
51+
return
52+
} else if !resp.Success {
53+
w.WriteHeader(http.StatusBadRequest)
54+
_, _ = w.Write([]byte(fmt.Sprintf("Verification failed, error codes %v", resp.ErrorCodes)))
55+
return
56+
}
57+
w.WriteHeader(http.StatusOK)
58+
_, _ = w.Write([]byte("Verified!"))
59+
})
60+
61+
f.Run()
62+
}

go.mod

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,8 @@ module github.com/flamego/hcaptcha
22

33
go 1.16
44

5-
require github.com/flamego/flamego v1.1.0
5+
require (
6+
github.com/flamego/flamego v1.1.0
7+
github.com/pkg/errors v0.9.1
8+
github.com/stretchr/testify v1.7.0
9+
)

go.sum

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7w
2626
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
2727
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c h1:F1jZWGFhYfh0Ci55sIpILtKKK8p3i2/krTr0H1rg74I=
2828
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
29+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
2930
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
3031
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
3132
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=

hcaptcha.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// Copyright 2022 Flamego. All rights reserved.
2+
// Use of this source code is governed by a MIT-style
3+
// license that can be found in the LICENSE file.
4+
5+
package hcaptcha
6+
7+
import (
8+
"encoding/json"
9+
"net/http"
10+
"net/url"
11+
"time"
12+
13+
"github.com/pkg/errors"
14+
15+
"github.com/flamego/flamego"
16+
)
17+
18+
// Options contains options for both hcaptcha.Captcha middleware.
19+
type Options struct {
20+
// Client the HTTP client to make requests. The default is http.DefaultClient.
21+
Client *http.Client
22+
// Secret is the secret key to check user captcha codes. This field is required.
23+
Secret string
24+
}
25+
26+
// Captcha returns a middleware handler that injects hcaptcha.HCaptcha into the
27+
// request context, which is used for verifying hCaptcha requests.
28+
func Captcha(opts Options) flamego.Handler {
29+
if opts.Client == nil {
30+
opts.Client = http.DefaultClient
31+
}
32+
33+
if opts.Secret == "" {
34+
panic("hcaptcha: empty secret")
35+
}
36+
37+
return flamego.ContextInvoker(func(c flamego.Context) {
38+
hcaptcha := &hCaptcha{
39+
client: opts.Client,
40+
secret: opts.Secret,
41+
}
42+
c.MapTo(hcaptcha, (*HCaptcha)(nil))
43+
})
44+
}
45+
46+
// HCaptcha is a hCaptcha verify service.
47+
type HCaptcha interface {
48+
// Verify verifies the given token. An optional remote IP of the user may be
49+
// passed as extra security criteria.
50+
Verify(token string, remoteIP ...string) (*Response, error)
51+
}
52+
53+
// Response is the response struct which hCaptcha sends back.
54+
type Response struct {
55+
// Success indicates whether the passcode valid, and does it meet security
56+
// criteria you specified.
57+
Success bool `json:"success"`
58+
// ChallengeTS is the timestamp of the challenge (ISO format
59+
// yyyy-MM-dd'T'HH:mm:ssZZ).
60+
ChallengeTS time.Time `json:"challenge_ts"`
61+
// Hostname is the hostname of the site where the challenge was solved.
62+
Hostname string `json:"hostname"`
63+
// Credit indicates whether the response will be credited.
64+
Credit bool `json:"credit"`
65+
// ErrorCodes contains the error codes when verify failed.
66+
ErrorCodes []string `json:"error-codes"`
67+
}
68+
69+
var _ HCaptcha = (*hCaptcha)(nil)
70+
71+
type hCaptcha struct {
72+
client *http.Client
73+
secret string
74+
}
75+
76+
func (h *hCaptcha) Verify(token string, remoteIP ...string) (*Response, error) {
77+
if token == "" {
78+
return nil, errors.New("empty token")
79+
}
80+
81+
data := url.Values{
82+
"secret": {h.secret},
83+
"response": {token},
84+
}
85+
if len(remoteIP) > 0 {
86+
data.Add("remoteip", remoteIP[0])
87+
}
88+
89+
resp, err := h.client.PostForm("https://hcaptcha.com/siteverify", data)
90+
if err != nil {
91+
return nil, errors.Wrap(err, "request hCaptcha server")
92+
}
93+
defer func() { _ = resp.Body.Close() }()
94+
95+
var response Response
96+
err = json.NewDecoder(resp.Body).Decode(&response)
97+
if err != nil {
98+
return nil, errors.Wrap(err, "decode response body")
99+
}
100+
return &response, nil
101+
}

hcaptcha_test.go

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
// Copyright 2022 Flamego. All rights reserved.
2+
// Use of this source code is governed by a MIT-style
3+
// license that can be found in the LICENSE file.
4+
5+
package hcaptcha
6+
7+
import (
8+
"bytes"
9+
"io"
10+
"net/http"
11+
"net/http/httptest"
12+
"net/url"
13+
"strings"
14+
"testing"
15+
16+
"github.com/flamego/flamego"
17+
"github.com/stretchr/testify/assert"
18+
"github.com/stretchr/testify/require"
19+
)
20+
21+
type mockRoundTripper struct {
22+
roundTrip func(*http.Request) (*http.Response, error)
23+
}
24+
25+
func (m *mockRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) {
26+
return m.roundTrip(r)
27+
}
28+
29+
func TestCaptcha(t *testing.T) {
30+
tests := []struct {
31+
name string
32+
wantSecret string
33+
wantToken string
34+
wantRemoteIP string
35+
}{
36+
{
37+
name: "normal",
38+
wantSecret: "test-secret",
39+
wantToken: "valid-token",
40+
wantRemoteIP: "",
41+
},
42+
{
43+
name: "normal",
44+
wantSecret: "test-secret",
45+
wantToken: "valid-token",
46+
wantRemoteIP: "127.0.0.1",
47+
},
48+
}
49+
for _, test := range tests {
50+
t.Run(test.name, func(t *testing.T) {
51+
client := &http.Client{
52+
Transport: &mockRoundTripper{
53+
roundTrip: func(r *http.Request) (*http.Response, error) {
54+
assert.Equal(t, test.wantSecret, r.PostFormValue("secret"))
55+
assert.Equal(t, test.wantToken, r.PostFormValue("response"))
56+
assert.Equal(t, test.wantRemoteIP, r.PostFormValue("remoteip"))
57+
return &http.Response{
58+
StatusCode: http.StatusOK,
59+
Body: io.NopCloser(strings.NewReader(`{"success": true}`)),
60+
Request: r,
61+
}, nil
62+
},
63+
},
64+
}
65+
66+
f := flamego.NewWithLogger(&bytes.Buffer{})
67+
f.Use(Captcha(Options{
68+
Client: client,
69+
Secret: test.wantSecret,
70+
}))
71+
f.Post("/", func(r *http.Request, h HCaptcha) {
72+
token := r.PostFormValue("h-captcha-response")
73+
74+
var err error
75+
var resp *Response
76+
if test.wantRemoteIP != "" {
77+
resp, err = h.Verify(token, test.wantRemoteIP)
78+
} else {
79+
resp, err = h.Verify(token)
80+
}
81+
require.NoError(t, err)
82+
assert.True(t, resp.Success)
83+
})
84+
85+
resp := httptest.NewRecorder()
86+
req, err := http.NewRequest(http.MethodPost, "/", nil)
87+
require.NoError(t, err)
88+
89+
req.PostForm = url.Values{
90+
"h-captcha-response": {test.wantToken},
91+
}
92+
f.ServeHTTP(resp, req)
93+
})
94+
}
95+
}

main.go

Lines changed: 0 additions & 11 deletions
This file was deleted.

0 commit comments

Comments
 (0)