From eafa63da50f578b2ebad8f2dbbd204f659b2f247 Mon Sep 17 00:00:00 2001 From: "Gerasimos (Makis) Maropoulos" Date: Tue, 14 Apr 2020 02:22:57 +0300 Subject: [PATCH] add hCaptcha middleware and example and memstore json struct tags Former-commit-id: 31e4f68429e354c9130ebcf3829683a6d52e4492 --- _examples/miscellaneous/hcaptcha/hosts | 3 + _examples/miscellaneous/hcaptcha/main.go | 46 +++++ .../hcaptcha/templates/register_form.html | 18 ++ core/memstore/memstore.go | 6 +- core/memstore/memstore_test.go | 76 ++++++-- middleware/README.md | 1 + middleware/hcaptcha/hcaptcha.go | 162 ++++++++++++++++++ 7 files changed, 294 insertions(+), 18 deletions(-) create mode 100644 _examples/miscellaneous/hcaptcha/hosts create mode 100644 _examples/miscellaneous/hcaptcha/main.go create mode 100644 _examples/miscellaneous/hcaptcha/templates/register_form.html create mode 100644 middleware/hcaptcha/hcaptcha.go diff --git a/_examples/miscellaneous/hcaptcha/hosts b/_examples/miscellaneous/hcaptcha/hosts new file mode 100644 index 00000000..d11c711b --- /dev/null +++ b/_examples/miscellaneous/hcaptcha/hosts @@ -0,0 +1,3 @@ +# https://docs.hcaptcha.com/#localdev +# Add to the end of your hosts file, e.g. on windows: C:/windows/system32/drivers/etc/hosts +127.0.0.1 yourdomain.com diff --git a/_examples/miscellaneous/hcaptcha/main.go b/_examples/miscellaneous/hcaptcha/main.go new file mode 100644 index 00000000..87e917ac --- /dev/null +++ b/_examples/miscellaneous/hcaptcha/main.go @@ -0,0 +1,46 @@ +package main + +import ( + "os" + + "github.com/kataras/iris/v12" + "github.com/kataras/iris/v12/middleware/hcaptcha" +) + +// Get the following values from: https://dashboard.hcaptcha.com +// Also, check: https://docs.hcaptcha.com/#localdev to test on local environment. +var ( + siteKey = os.Getenv("HCAPTCHA-SITE-KEY") + secretKey = os.Getenv("HCAPTCHA-SECRET-KEY") +) + +func main() { + app := iris.New() + app.RegisterView(iris.HTML("./templates", ".html")) + + hCaptcha := hcaptcha.New(secretKey) + app.Get("/register", registerForm) + app.Post("/register", hCaptcha, register) // See `hcaptcha.SiteVerify` for manual validation too. + + app.Logger().Infof("SiteKey = %s\tSecretKey = %s", + siteKey, secretKey) + + // GET: http://yourdomain.com/register + app.Listen(":80") +} + +func register(ctx iris.Context) { + hcaptchaResp, ok := hcaptcha.Get(ctx) + if !ok { + ctx.StatusCode(iris.StatusUnauthorized) + ctx.WriteString("Are you a bot?") + return + } + + ctx.Writef("Register action here...action was asked by a Human.\nResponse value is: %#+v", hcaptchaResp) +} + +func registerForm(ctx iris.Context) { + ctx.ViewData("SiteKey", siteKey) + ctx.View("register_form.html") +} diff --git a/_examples/miscellaneous/hcaptcha/templates/register_form.html b/_examples/miscellaneous/hcaptcha/templates/register_form.html new file mode 100644 index 00000000..26a0cdc0 --- /dev/null +++ b/_examples/miscellaneous/hcaptcha/templates/register_form.html @@ -0,0 +1,18 @@ + + + + hCaptcha Demo + + + + +
+ + +
+
+ +
+ + + \ No newline at end of file diff --git a/core/memstore/memstore.go b/core/memstore/memstore.go index e5ee0e82..56be5a1f 100644 --- a/core/memstore/memstore.go +++ b/core/memstore/memstore.go @@ -21,9 +21,9 @@ type ( } // Entry is the entry of the context storage Store - .Values() Entry struct { - Key string - ValueRaw interface{} - immutable bool // if true then it can't change by its caller. + Key string `json:"key" msgpack:"key" yaml:"Key" toml:"Value"` + ValueRaw interface{} `json:"value" msgpack:"value" yaml:"Value" toml:"Value"` + immutable bool // if true then it can't change by its caller. } // Store is a collection of key-value entries with immutability capabilities. diff --git a/core/memstore/memstore_test.go b/core/memstore/memstore_test.go index 91233c05..686d48e4 100644 --- a/core/memstore/memstore_test.go +++ b/core/memstore/memstore_test.go @@ -2,11 +2,14 @@ package memstore import ( + "bytes" + "encoding/json" + "fmt" "testing" ) type myTestObject struct { - name string + Name string `json:"name"` } func TestMuttable(t *testing.T) { @@ -15,10 +18,10 @@ func TestMuttable(t *testing.T) { // slice p.Set("slice", []myTestObject{{"value 1"}, {"value 2"}}) v := p.Get("slice").([]myTestObject) - v[0].name = "modified" + v[0].Name = "modified" vv := p.Get("slice").([]myTestObject) - if vv[0].name != "modified" { + if vv[0].Name != "modified" { t.Fatalf("expected slice to be muttable but caller was not able to change its value") } @@ -29,7 +32,7 @@ func TestMuttable(t *testing.T) { vMap["key 1"] = myTestObject{"modified"} vvMap := p.Get("map").(map[string]myTestObject) - if vvMap["key 1"].name != "modified" { + if vvMap["key 1"].Name != "modified" { t.Fatalf("expected map to be muttable but caller was not able to change its value") } @@ -38,10 +41,10 @@ func TestMuttable(t *testing.T) { // we expect pointer here, as we set it. vObjP := p.Get("objp").(*myTestObject) - vObjP.name = "modified" + vObjP.Name = "modified" vvObjP := p.Get("objp").(*myTestObject) - if vvObjP.name != "modified" { + if vvObjP.Name != "modified" { t.Fatalf("expected objp to be muttable but caller was able to change its value") } } @@ -52,10 +55,10 @@ func TestImmutable(t *testing.T) { // slice p.SetImmutable("slice", []myTestObject{{"value 1"}, {"value 2"}}) v := p.Get("slice").([]myTestObject) - v[0].name = "modified" + v[0].Name = "modified" vv := p.Get("slice").([]myTestObject) - if vv[0].name == "modified" { + if vv[0].Name == "modified" { t.Fatalf("expected slice to be immutable but caller was able to change its value") } @@ -65,17 +68,17 @@ func TestImmutable(t *testing.T) { vMap["key 1"] = myTestObject{"modified"} vvMap := p.Get("map").(map[string]myTestObject) - if vvMap["key 1"].name == "modified" { + if vvMap["key 1"].Name == "modified" { t.Fatalf("expected map to be immutable but caller was able to change its value") } // object value, it's immutable at all cases. p.SetImmutable("obj", myTestObject{"value"}) vObj := p.Get("obj").(myTestObject) - vObj.name = "modified" + vObj.Name = "modified" vvObj := p.Get("obj").(myTestObject) - if vvObj.name == "modified" { + if vvObj.Name == "modified" { t.Fatalf("expected obj to be immutable but caller was able to change its value") } @@ -85,10 +88,10 @@ func TestImmutable(t *testing.T) { // so it can't be changed by-design vObjP := p.Get("objp").(myTestObject) - vObjP.name = "modified" + vObjP.Name = "modified" vvObjP := p.Get("objp").(myTestObject) - if vvObjP.name == "modified" { + if vvObjP.Name == "modified" { t.Fatalf("expected objp to be immutable but caller was able to change its value") } } @@ -100,13 +103,13 @@ func TestImmutableSetOnlyWithSetImmutable(t *testing.T) { p.Set("objp", &myTestObject{"modified"}) vObjP := p.Get("objp").(myTestObject) - if vObjP.name == "modified" { + if vObjP.Name == "modified" { t.Fatalf("caller should not be able to change the immutable entry with a simple `Set`") } p.SetImmutable("objp", &myTestObject{"value with SetImmutable"}) vvObjP := p.Get("objp").(myTestObject) - if vvObjP.name != "value with SetImmutable" { + if vvObjP.Name != "value with SetImmutable" { t.Fatalf("caller should be able to change the immutable entry with a `SetImmutable`") } } @@ -119,3 +122,46 @@ func TestGetInt64Default(t *testing.T) { t.Fatalf("unexpected value of %d", v) } } + +func TestJSON(t *testing.T) { + var p Store + + p.Set("key1", "value1") + p.Set("key2", 2) + p.Set("key3", myTestObject{Name: "makis"}) + + b, err := json.Marshal(p) + if err != nil { + t.Fatal(err) + } + + expectedJSON := []byte(`[{"key":"key1","value":"value1"},{"key":"key2","value":2},{"key":"key3","value":{"name":"makis"}}]`) + + if !bytes.Equal(b, expectedJSON) { + t.Fatalf("expected: %s but got: %s", string(expectedJSON), string(b)) + } + + var newStore Store + if err = json.Unmarshal(b, &newStore); err != nil { + t.Fatal(err) + } + + for i, v := range newStore { + expected, got := p.Get(v.Key), v.ValueRaw + + if ex, g := fmt.Sprintf("%v", expected), fmt.Sprintf("%v", got); ex != g { + if _, isMap := got.(map[string]interface{}); isMap { + // was struct but converted into map (as expected). + b1, _ := json.Marshal(expected) + b2, _ := json.Marshal(got) + + if !bytes.Equal(b1, b2) { + t.Fatalf("[%d] JSON expected: %s but got: %s", i, string(b1), string(b2)) + } + + continue + } + t.Fatalf("[%d] expected: %s but got: %s", i, ex, g) + } + } +} diff --git a/middleware/README.md b/middleware/README.md index 8a2ff130..c8dfb4f2 100644 --- a/middleware/README.md +++ b/middleware/README.md @@ -8,6 +8,7 @@ Builtin Handlers | [HTTP method override](methodoverride) | [iris/middleware/methodoverride/methodoverride_test.go](https://github.com/kataras/iris/blob/master/middleware/methodoverride/methodoverride_test.go) | | [profiling (pprof)](pprof) | [iris/_examples/miscellaneous/pprof](https://github.com/kataras/iris/tree/master/_examples/miscellaneous/pprof) | | [Google reCAPTCHA](recaptcha) | [iris/_examples/miscellaneous/recaptcha](https://github.com/kataras/iris/tree/master/_examples/miscellaneous/recaptcha) | +| [hCaptcha](hcaptcha) | [iris/_examples/miscellaneous/recaptcha](https://github.com/kataras/iris/tree/master/_examples/miscellaneous/hcaptcha) | | [recovery](recover) | [iris/_examples/miscellaneous/recover](https://github.com/kataras/iris/tree/master/_examples/miscellaneous/recover) | Community made diff --git a/middleware/hcaptcha/hcaptcha.go b/middleware/hcaptcha/hcaptcha.go new file mode 100644 index 00000000..fc7ecc3f --- /dev/null +++ b/middleware/hcaptcha/hcaptcha.go @@ -0,0 +1,162 @@ +package hcaptcha + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/url" + + "github.com/kataras/iris/v12/context" +) + +var ( + // ResponseContextKey is the default request's context key that response of a hcaptcha request is kept. + ResponseContextKey string = "iris.hcaptcha" + // DefaultFailureHandler is the default HTTP handler that is fired on hcaptcha failures. + // See `Client.FailureHandler`. + DefaultFailureHandler = func(ctx context.Context) { + ctx.StopWithStatus(http.StatusTooManyRequests) + } +) + +// Client represents the hcaptcha client. +type Client struct { + // FailureHandler if specified, fired when user does not complete hcaptcha successfully. + // Failure and error codes information are kept as `Response` type + // at the Request's Context key of "hcaptcha". + // + // Defaults to a handler that writes a status code of 429 (Too Many Requests) + // and without additional information. + FailureHandler context.Handler + + secret string +} + +// Option declares an option for the hcaptcha client. +// See `New` package-level function. +type Option func(*Client) + +// Response is the hcaptcha JSON response. +type Response struct { + ChallengeTS string `json:"challenge_ts"` + Hostname string `json:"hostname"` + ErrorCodes []string `json:"error-codes,omitempty"` + Success bool `json:"success"` + Credit bool `json:"credit,omitempty"` +} + +// New accepts a hpcatcha secret key and returns a new hcaptcha HTTP Client. +// +// Instructions at: https://docs.hcaptcha.com/. +// +// See its `Handler` and `SiteVerify` for details. +func New(secret string, options ...Option) context.Handler { + c := &Client{ + FailureHandler: DefaultFailureHandler, + secret: secret, + } + + for _, opt := range options { + opt(c) + } + + return c.Handler +} + +// Handler is the HTTP route middleware featured hcaptcha validation. +// It calls the `SiteVerify` method and fires the "next" when user completed the hcaptcha successfully, +// otherwise it calls the Client's `FailureHandler`. +// The hcaptcha's `Response` (which contains any `ErrorCodes`) +// is saved on the Request's Context (see `GetResponseFromContext`). +func (c *Client) Handler(ctx context.Context) { + v := SiteVerify(ctx, c.secret) + ctx.Values().Set(ResponseContextKey, v) + if v.Success { + ctx.Next() + return + } + + if c.FailureHandler != nil { + c.FailureHandler(ctx) + } +} + +// responseFormValue = "h-captcha-response" +const apiURL = "https://hcaptcha.com/siteverify" + +// SiteVerify accepts an Iris Context and a secret key (https://dashboard.hcaptcha.com/settings). +// It returns the hcaptcha's `Response`. +// The `response.Success` reports whether the validation passed. +// Any errors are passed through the `response.ErrorCodes` field. +func SiteVerify(ctx context.Context, secret string) (response Response) { + generatedResponseID := ctx.FormValue("h-captcha-response") + + if generatedResponseID == "" { + response.ErrorCodes = append(response.ErrorCodes, + "form[h-captcha-response] is empty") + return + } + + resp, err := http.DefaultClient.PostForm(apiURL, + url.Values{ + "secret": {secret}, + "response": {generatedResponseID}, + }, + ) + if err != nil { + response.ErrorCodes = append(response.ErrorCodes, err.Error()) + return + } + + body, err := ioutil.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + response.ErrorCodes = append(response.ErrorCodes, err.Error()) + return + } + + err = json.Unmarshal(body, &response) + if err != nil { + response.ErrorCodes = append(response.ErrorCodes, err.Error()) + return + } + + return +} + +// Get returns the hcaptcha `Response` of the current request and reports whether was found or not. +func Get(ctx context.Context) (Response, bool) { + v := ctx.Values().Get(ResponseContextKey) + if v != nil { + if response, ok := v.(Response); ok { + return response, true + } + } + + return Response{}, false +} + +// Script is the hCaptcha's javascript source file that should be incldued in the HTML head or body. +const Script = "https://hcaptcha.com/1/api.js" + +// HTMLForm is the default HTML form for clients. +// It's totally optional, use your own code for the best possible result depending on your web application. +// See `ParseForm` and `RenderForm` for more. +var HTMLForm = `
+ +
+ +
` + +// ParseForm parses the `HTMLForm` with the necessary parameters and returns +// its result for render. +func ParseForm(dataSiteKey, postActionRelativePath string) string { + return fmt.Sprintf(HTMLForm, postActionRelativePath, Script, dataSiteKey) +} + +// RenderForm writes the `HTMLForm` to "w" response writer. +// See `_examples/basic/register_form.html` example for a custom form instead. +func RenderForm(ctx context.Context, dataSiteKey, postActionRelativePath string) (int, error) { + return ctx.HTML(ParseForm(dataSiteKey, postActionRelativePath)) +}