Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: HCaptcha Middleware #1071

Merged
merged 16 commits into from
Apr 24, 2024
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .github/workflows/test-hcaptcha.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
name: "Test hcaptcha"

on:
push:
branches:
- master
- main
paths:
- 'hcaptcha/**'
pull_request:
paths:
- 'hcaptcha/**'

jobs:
Tests:
runs-on: ubuntu-latest
strategy:
matrix:
go-version:
- 1.21.x
steps:
- name: Fetch Repository
uses: actions/checkout@v4
- name: Install Go
uses: actions/setup-go@v5
with:
go-version: '${{ matrix.go-version }}'
- name: Run Test
working-directory: ./hcaptcha
run: go test -v -race ./...
ReneWerner87 marked this conversation as resolved.
Show resolved Hide resolved
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,4 @@ Repository for third party middlewares with dependencies.
* [Socket.io](./socketio/README.md) <a href="https://github.com/gofiber/contrib/actions?query=workflow%3A%22Test+socketio%22"> <img src="https://img.shields.io/github/actions/workflow/status/gofiber/contrib/test-socketio.yml?branch=main&label=%F0%9F%A7%AA%20&style=flat&color=75C46B" /> </a>
* [Swagger](./swagger/README.md) <a href="https://github.com/gofiber/contrib/actions?query=workflow%3A%22Test+swagger%22"> <img src="https://img.shields.io/github/actions/workflow/status/gofiber/contrib/test-swagger.yml?branch=main&label=%F0%9F%A7%AA%20&style=flat&color=75C46B" /> </a>
* [Websocket](./websocket/README.md) <a href="https://github.com/gofiber/contrib/actions?query=workflow%3A%22Test+websocket%22"> <img src="https://img.shields.io/github/actions/workflow/status/gofiber/contrib/test-websocket.yml?branch=main&label=%F0%9F%A7%AA%20&style=flat&color=75C46B" /> </a>
* [HCaptcha](./hcaptcha/README.md) <a href="https://github.com/gofiber/contrib/actions?query=workflow%3A%22Test+websocket%22"> <img src="https://img.shields.io/github/actions/workflow/status/gofiber/contrib/test-hcaptcha.yml?branch=main&label=%F0%9F%A7%AA%20&style=flat&color=75C46B" /> </a>
ReneWerner87 marked this conversation as resolved.
Show resolved Hide resolved
76 changes: 76 additions & 0 deletions hcaptcha/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
---
id: hcaptcha
---

# HCaptcha
ReneWerner87 marked this conversation as resolved.
Show resolved Hide resolved


![Release](https://img.shields.io/github/v/tag/gofiber/contrib?filter=hcaptcha*)
[![Discord](https://img.shields.io/discord/704680098577514527?style=flat&label=%F0%9F%92%AC%20discord&color=00ACD7)](https://gofiber.io/discord)
![Test](https://github.com/gofiber/contrib/workflows/Tests/badge.svg)
![Security](https://github.com/gofiber/contrib/workflows/Security/badge.svg)
![Linter](https://github.com/gofiber/contrib/workflows/Linter/badge.svg)

A simple [HCaptcha](https://hcaptcha.com) middleware to prevent bot attacks.
ReneWerner87 marked this conversation as resolved.
Show resolved Hide resolved

**Note: Requires Go 1.21 and above**

## Install

This middleware only supports Fiber v3.


```
go get -u github.com/gofiber/fiber/v3
go get -u github.com/gofiber/contrib/hcaptcha
```

## Signature
```go
ReneWerner87 marked this conversation as resolved.
Show resolved Hide resolved
hcaptcha.New(config hcaptcha.Config) fiber.Handler
```

## Config

| Property | Type | Description | Default |
|:----------------|:----------------------------------|:----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:--------------------------------------|
| SecretKey | `string` | The secret key you got from HCaptcha admin panel. This field must not be empty. | `""` |
| ResponseKeyFunc | `func(fiber.Ctx) (string, error)` | ResponseKeyFunc should return the token that captcha provides upon successful solving. By default it gets the token from the body by parsing a JSON request and returns the `hcaptcha_token` field. | `hcaptcha.DefaultResponseKeyFunc` |
| SiteVerifyURL | `string` | It uses this API resource for token authentication. | `https://api.hcaptcha.com/siteverify` |

ReneWerner87 marked this conversation as resolved.
Show resolved Hide resolved
## Example

```go
package main

import (
"github.com/gofiber/contrib/hcaptcha"
"github.com/gofiber/fiber/v2"
"log"
)

const (
TestSecretKey = "0x0000000000000000000000000000000000000000"
TestSiteKey = "20000000-ffff-ffff-ffff-000000000002"
)

func main() {
app := fiber.New()
captcha := hcaptcha.New(hcaptcha.Config{
// Must set the secret key
SecretKey: TestSecretKey,
})

ReneWerner87 marked this conversation as resolved.
Show resolved Hide resolved
app.Get("/api/", func(c fiber.Ctx) error {
return c.JSON(fiber.Map{
"hcaptcha_site_key": TestSiteKey,
})
})

ReneWerner87 marked this conversation as resolved.
Show resolved Hide resolved
app.Post("/api/robots-excluded", func(c fiber.Ctx) error {
return c.SendString("You are not a robot")
}, captcha)

ReneWerner87 marked this conversation as resolved.
Show resolved Hide resolved
log.Fatal(app.Listen(":3000"))
}
ReneWerner87 marked this conversation as resolved.
Show resolved Hide resolved
```
ReneWerner87 marked this conversation as resolved.
Show resolved Hide resolved
34 changes: 34 additions & 0 deletions hcaptcha/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package hcaptcha

import (
"bytes"
"encoding/json"
"fmt"
"github.com/gofiber/fiber/v3"
)

const DefaultSiteVerifyURL = "https://api.hcaptcha.com/siteverify"

type Config struct {
// SecretKey is the secret key you get from HCaptcha when you create a new application
SecretKey string
// ResponseKeyFunc should return the generated pass UUID from the ctx, which will be validated
ResponseKeyFunc func(fiber.Ctx) (string, error)
// SiteVerifyURL is the endpoint URL where the program should verify the given token
// default value is: "https://api.hcaptcha.com/siteverify"
SiteVerifyURL string
}

func DefaultResponseKeyFunc(c fiber.Ctx) (string, error) {
data := struct {
HCaptchaToken string `json:"hcaptcha_token"`
}{}

err := json.NewDecoder(bytes.NewReader(c.Body())).Decode(&data)

if err != nil {
return "", fmt.Errorf("failed to decode HCaptcha token: %w", err)
}

return data.HCaptchaToken, nil
}
74 changes: 74 additions & 0 deletions hcaptcha/hcaptcha.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// Package hcaptcha is a simple middleware that checks for an HCaptcha UUID
// and then validates it. It returns an error if the UUID is not valid (the request may have been sent by a robot).
package hcaptcha

import (
"bytes"
"encoding/json"
"errors"
"fmt"
"github.com/gofiber/fiber/v3"
"github.com/valyala/fasthttp"
"net/url"
)

type HCaptcha struct {
Config
}

func New(config Config) fiber.Handler {
if config.SiteVerifyURL == "" {
config.SiteVerifyURL = DefaultSiteVerifyURL
}

if config.ResponseKeyFunc == nil {
config.ResponseKeyFunc = DefaultResponseKeyFunc
}

h := &HCaptcha{
config,
}
return h.Validate
}

func (h *HCaptcha) Validate(c fiber.Ctx) error {
token, err := h.ResponseKeyFunc(c)
if err != nil {
c.Status(fiber.StatusBadRequest)
return fmt.Errorf("error retrieving HCaptcha token: %w", err)
}

req := fasthttp.AcquireRequest()
defer fasthttp.ReleaseRequest(req)
req.SetBody([]byte(url.Values{
"secret": {h.SecretKey},
"response": {token},
}.Encode()))
req.Header.SetMethod("POST")
req.Header.SetContentType("application/x-www-form-urlencoded; charset=UTF-8")
req.Header.Set("Accept", "application/json")
req.SetRequestURI(h.SiteVerifyURL)
res := fasthttp.AcquireResponse()
defer fasthttp.ReleaseResponse(res)

if err = fasthttp.Do(req, res); err != nil {
c.Status(fiber.StatusBadRequest)
return fmt.Errorf("error sending request to HCaptcha API: %w", err)
}

o := struct {
Success bool `json:"success"`
}{}

if err = json.NewDecoder(bytes.NewReader(res.Body())).Decode(&o); err != nil {
c.Status(fiber.StatusInternalServerError)
return fmt.Errorf("error decoding HCaptcha API response: %w", err)
}

if !o.Success {
c.Status(fiber.StatusForbidden)
return errors.New("unable to check that you are not a robot")
}

return c.Next()
}
49 changes: 49 additions & 0 deletions hcaptcha/hcaptcha_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package hcaptcha

import (
"github.com/gofiber/fiber/v3"
"github.com/stretchr/testify/assert"
"io"
"net/http/httptest"
"testing"
)

const (
TestSecretKey = "0x0000000000000000000000000000000000000000"
TestResponseToken = "20000000-aaaa-bbbb-cccc-000000000002" // Got by using this site key: 20000000-ffff-ffff-ffff-000000000002
)

func TestHCaptcha(t *testing.T) {
app := fiber.New()

m := New(Config{
SecretKey: TestSecretKey,
ResponseKeyFunc: func(c fiber.Ctx) (string, error) {
return c.Query("token"), nil
},
})

app.Get("/hcaptcha", func(c fiber.Ctx) error {
return c.Status(200).SendString("ok")
}, m)

req := httptest.NewRequest("GET", "/hcaptcha?token="+TestResponseToken, nil)
req.Header.Set("Content-Type", "application/json")

res, err := app.Test(req, -1)
defer res.Body.Close()
Comment on lines +34 to +35
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ensure defer res.Body.Close() is called after checking for an error from app.Test(req, -1) to avoid potential nil pointer dereference.

- defer res.Body.Close()
  res, err := app.Test(req, -1)
+ if err != nil {
+     t.Fatal(err)
+ }
+ defer res.Body.Close()

Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation.

Suggested change
res, err := app.Test(req, -1)
defer res.Body.Close()
res, err := app.Test(req, -1)
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()


if err != nil {
t.Fatal(err)
}

assert.Equal(t, res.StatusCode, fiber.StatusOK, "Response status code")

body, err := io.ReadAll(res.Body)

if err != nil {
t.Fatal(err)
}

assert.Equal(t, "ok", string(body))
}