summaryrefslogtreecommitdiffstats
path: root/modules/hcaptcha/hcaptcha.go
blob: b970d491c578f5da7f27a742921c97bddaef7946 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
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
}