diff options
author | Daniel Baumann <daniel@debian.org> | 2024-10-18 20:33:49 +0200 |
---|---|---|
committer | Daniel Baumann <daniel@debian.org> | 2024-12-12 23:57:56 +0100 |
commit | e68b9d00a6e05b3a941f63ffb696f91e554ac5ec (patch) | |
tree | 97775d6c13b0f416af55314eb6a89ef792474615 /modules/hcaptcha | |
parent | Initial commit. (diff) | |
download | forgejo-e68b9d00a6e05b3a941f63ffb696f91e554ac5ec.tar.xz forgejo-e68b9d00a6e05b3a941f63ffb696f91e554ac5ec.zip |
Adding upstream version 9.0.3.
Signed-off-by: Daniel Baumann <daniel@debian.org>
Diffstat (limited to 'modules/hcaptcha')
-rw-r--r-- | modules/hcaptcha/error.go | 47 | ||||
-rw-r--r-- | modules/hcaptcha/hcaptcha.go | 140 | ||||
-rw-r--r-- | modules/hcaptcha/hcaptcha_test.go | 106 |
3 files changed, 293 insertions, 0 deletions
diff --git a/modules/hcaptcha/error.go b/modules/hcaptcha/error.go new file mode 100644 index 0000000..7b68bf8 --- /dev/null +++ b/modules/hcaptcha/error.go @@ -0,0 +1,47 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package hcaptcha + +const ( + ErrMissingInputSecret ErrorCode = "missing-input-secret" + ErrInvalidInputSecret ErrorCode = "invalid-input-secret" + ErrMissingInputResponse ErrorCode = "missing-input-response" + ErrInvalidInputResponse ErrorCode = "invalid-input-response" + ErrBadRequest ErrorCode = "bad-request" + ErrInvalidOrAlreadySeenResponse ErrorCode = "invalid-or-already-seen-response" + ErrNotUsingDummyPasscode ErrorCode = "not-using-dummy-passcode" + ErrSitekeySecretMismatch ErrorCode = "sitekey-secret-mismatch" +) + +// ErrorCode is any possible error from hCaptcha +type ErrorCode string + +// String fulfills the Stringer interface +func (err ErrorCode) String() string { + switch err { + case ErrMissingInputSecret: + return "Your secret key is missing." + case ErrInvalidInputSecret: + return "Your secret key is invalid or malformed." + case ErrMissingInputResponse: + return "The response parameter (verification token) is missing." + case ErrInvalidInputResponse: + return "The response parameter (verification token) is invalid or malformed." + case ErrBadRequest: + return "The request is invalid or malformed." + case ErrInvalidOrAlreadySeenResponse: + return "The response parameter has already been checked, or has another issue." + case ErrNotUsingDummyPasscode: + return "You have used a testing sitekey but have not used its matching secret." + case ErrSitekeySecretMismatch: + return "The sitekey is not registered with the provided secret." + default: + return "" + } +} + +// Error fulfills the error interface +func (err ErrorCode) Error() string { + return err.String() +} diff --git a/modules/hcaptcha/hcaptcha.go b/modules/hcaptcha/hcaptcha.go new file mode 100644 index 0000000..b970d49 --- /dev/null +++ b/modules/hcaptcha/hcaptcha.go @@ -0,0 +1,140 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package hcaptcha + +import ( + "context" + "io" + "net/http" + "net/url" + "strings" + + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/setting" +) + +const verifyURL = "https://hcaptcha.com/siteverify" + +// Client is an hCaptcha client +type Client struct { + ctx context.Context + http *http.Client + + secret string +} + +// PostOptions are optional post form values +type PostOptions struct { + RemoteIP string + Sitekey string +} + +// ClientOption is a func to modify a new Client +type ClientOption func(*Client) + +// WithHTTP sets the http.Client of a Client +func WithHTTP(httpClient *http.Client) func(*Client) { + return func(hClient *Client) { + hClient.http = httpClient + } +} + +// WithContext sets the context.Context of a Client +func WithContext(ctx context.Context) func(*Client) { + return func(hClient *Client) { + hClient.ctx = ctx + } +} + +// New returns a new hCaptcha Client +func New(secret string, options ...ClientOption) (*Client, error) { + if strings.TrimSpace(secret) == "" { + return nil, ErrMissingInputSecret + } + + client := &Client{ + ctx: context.Background(), + http: http.DefaultClient, + secret: secret, + } + + for _, opt := range options { + opt(client) + } + + return client, nil +} + +// Response is an hCaptcha response +type Response struct { + Success bool `json:"success"` + ChallengeTS string `json:"challenge_ts"` + Hostname string `json:"hostname"` + Credit bool `json:"credit,omitempty"` + ErrorCodes []ErrorCode `json:"error-codes"` +} + +// Verify checks the response against the hCaptcha API +func (c *Client) Verify(token string, opts PostOptions) (*Response, error) { + if strings.TrimSpace(token) == "" { + return nil, ErrMissingInputResponse + } + + post := url.Values{ + "secret": []string{c.secret}, + "response": []string{token}, + } + if strings.TrimSpace(opts.RemoteIP) != "" { + post.Add("remoteip", opts.RemoteIP) + } + if strings.TrimSpace(opts.Sitekey) != "" { + post.Add("sitekey", opts.Sitekey) + } + + // Basically a copy of http.PostForm, but with a context + req, err := http.NewRequestWithContext(c.ctx, http.MethodPost, verifyURL, strings.NewReader(post.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := c.http.Do(req) + if err != nil { + return nil, err + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var response *Response + if err := json.Unmarshal(body, &response); err != nil { + return nil, err + } + + return response, nil +} + +// Verify calls hCaptcha API to verify token +func Verify(ctx context.Context, response string) (bool, error) { + client, err := New(setting.Service.HcaptchaSecret, WithContext(ctx)) + if err != nil { + return false, err + } + + resp, err := client.Verify(response, PostOptions{ + Sitekey: setting.Service.HcaptchaSitekey, + }) + if err != nil { + return false, err + } + + var respErr error + if len(resp.ErrorCodes) > 0 { + respErr = resp.ErrorCodes[0] + } + return resp.Success, respErr +} diff --git a/modules/hcaptcha/hcaptcha_test.go b/modules/hcaptcha/hcaptcha_test.go new file mode 100644 index 0000000..55e01ec --- /dev/null +++ b/modules/hcaptcha/hcaptcha_test.go @@ -0,0 +1,106 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package hcaptcha + +import ( + "net/http" + "os" + "strings" + "testing" + "time" +) + +const ( + dummySiteKey = "10000000-ffff-ffff-ffff-000000000001" + dummySecret = "0x0000000000000000000000000000000000000000" + dummyToken = "10000000-aaaa-bbbb-cccc-000000000001" +) + +func TestMain(m *testing.M) { + os.Exit(m.Run()) +} + +func TestCaptcha(t *testing.T) { + tt := []struct { + Name string + Secret string + Token string + Error ErrorCode + }{ + { + Name: "Success", + Secret: dummySecret, + Token: dummyToken, + }, + { + Name: "Missing Secret", + Token: dummyToken, + Error: ErrMissingInputSecret, + }, + { + Name: "Missing Token", + Secret: dummySecret, + Error: ErrMissingInputResponse, + }, + { + Name: "Invalid Token", + Secret: dummySecret, + Token: "test", + Error: ErrInvalidInputResponse, + }, + } + + for _, tc := range tt { + t.Run(tc.Name, func(t *testing.T) { + client, err := New(tc.Secret, WithHTTP(&http.Client{ + Timeout: time.Second * 5, + })) + if err != nil { + // The only error that can be returned from creating a client + if tc.Error == ErrMissingInputSecret && err == ErrMissingInputSecret { + return + } + t.Log(err) + t.FailNow() + } + + resp, err := client.Verify(tc.Token, PostOptions{ + Sitekey: dummySiteKey, + }) + if err != nil { + // The only error that can be returned prior to the request + if tc.Error == ErrMissingInputResponse && err == ErrMissingInputResponse { + return + } + t.Log(err) + t.FailNow() + } + + if tc.Error.String() != "" { + if resp.Success { + t.Log("Verification should fail.") + t.Fail() + } + if len(resp.ErrorCodes) == 0 { + t.Log("hCaptcha should have returned an error.") + t.Fail() + } + var hasErr bool + for _, err := range resp.ErrorCodes { + if strings.EqualFold(err.String(), tc.Error.String()) { + hasErr = true + break + } + } + if !hasErr { + t.Log("hCaptcha did not return the error being tested") + t.Fail() + } + } else if !resp.Success { + t.Log("Verification should succeed.") + t.Fail() + } + }) + } +} |