You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
183 lines
5.8 KiB
183 lines
5.8 KiB
package recaptcha
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io/ioutil"
|
|
"net/http"
|
|
"net/url"
|
|
"time"
|
|
)
|
|
|
|
const reCAPTCHALink = "https://www.recaptcha.net/recaptcha/api/siteverify"
|
|
|
|
// VERSION the recaptcha api version
|
|
type VERSION int8
|
|
|
|
const (
|
|
// V2 recaptcha api v2
|
|
V2 VERSION = iota
|
|
// V3 recaptcha api v3, more details can be found here : https://developers.google.com/recaptcha/docs/v3
|
|
V3
|
|
// DefaultTreshold Default minimin score when using V3 api
|
|
DefaultTreshold float32 = 0.5
|
|
)
|
|
|
|
type reCHAPTCHARequest struct {
|
|
Secret string `json:"secret"`
|
|
Response string `json:"response"`
|
|
RemoteIP string `json:"remoteip,omitempty"`
|
|
}
|
|
|
|
type reCHAPTCHAResponse struct {
|
|
Success bool `json:"success"`
|
|
ChallengeTS time.Time `json:"challenge_ts"`
|
|
Hostname string `json:"hostname,omitempty"`
|
|
ApkPackageName string `json:"apk_package_name,omitempty"`
|
|
Action string `json:"action,omitempty"`
|
|
Score float32 `json:"score,omitempty"`
|
|
ErrorCodes []string `json:"error-codes,omitempty"`
|
|
}
|
|
|
|
// custom client so we can mock in tests
|
|
type netClient interface {
|
|
PostForm(url string, formValues url.Values) (resp *http.Response, err error)
|
|
}
|
|
|
|
// custom clock so we can mock in tests
|
|
type clock interface {
|
|
Since(t time.Time) time.Duration
|
|
}
|
|
|
|
type realClock struct {
|
|
}
|
|
|
|
func (realClock) Since(t time.Time) time.Duration {
|
|
return time.Since(t)
|
|
}
|
|
|
|
// ReCAPTCHA recpatcha holder struct, make adding mocking code simpler
|
|
type ReCAPTCHA struct {
|
|
client netClient
|
|
Secret string
|
|
ReCAPTCHALink string
|
|
Version VERSION
|
|
Timeout time.Duration
|
|
horloge clock
|
|
}
|
|
|
|
// NewReCAPTCHA new ReCAPTCHA instance if version is set to V2 uses recatpcha v2 API, get your secret from https://www.google.com/recaptcha/admin
|
|
// if version is set to V2 uses recatpcha v2 API, get your secret from https://g.co/recaptcha/v3
|
|
func NewReCAPTCHA(ReCAPTCHASecret string, version VERSION, timeout time.Duration) (ReCAPTCHA, error) {
|
|
if ReCAPTCHASecret == "" {
|
|
return ReCAPTCHA{}, fmt.Errorf("recaptcha secret cannot be blank")
|
|
}
|
|
return ReCAPTCHA{
|
|
client: &http.Client{
|
|
Timeout: timeout,
|
|
},
|
|
horloge: &realClock{},
|
|
Secret: ReCAPTCHASecret,
|
|
ReCAPTCHALink: reCAPTCHALink,
|
|
Timeout: timeout,
|
|
Version: version,
|
|
}, nil
|
|
}
|
|
|
|
// Verify returns `nil` if no error and the client solved the challenge correctly
|
|
func (r *ReCAPTCHA) Verify(challengeResponse string) error {
|
|
body := reCHAPTCHARequest{Secret: r.Secret, Response: challengeResponse}
|
|
return r.confirm(body, VerifyOption{})
|
|
}
|
|
|
|
// VerifyOption verification options expected for the challenge
|
|
type VerifyOption struct {
|
|
Threshold float32 // ignored in v2 recaptcha
|
|
Action string // ignored in v2 recaptcha
|
|
Hostname string
|
|
ApkPackageName string
|
|
ResponseTime time.Duration
|
|
RemoteIP string
|
|
}
|
|
|
|
// VerifyWithOptions returns `nil` if no error and the client solved the challenge correctly and all options are natching
|
|
// `Threshold` and `Action` are ignored when using V2 version
|
|
func (r *ReCAPTCHA) VerifyWithOptions(challengeResponse string, options VerifyOption) error {
|
|
var body reCHAPTCHARequest
|
|
if options.RemoteIP == "" {
|
|
body = reCHAPTCHARequest{Secret: r.Secret, Response: challengeResponse}
|
|
} else {
|
|
body = reCHAPTCHARequest{Secret: r.Secret, Response: challengeResponse, RemoteIP: options.RemoteIP}
|
|
}
|
|
return r.confirm(body, options)
|
|
}
|
|
|
|
func (r *ReCAPTCHA) confirm(recaptcha reCHAPTCHARequest, options VerifyOption) (Err error) {
|
|
Err = nil
|
|
var formValues url.Values
|
|
if recaptcha.RemoteIP != "" {
|
|
formValues = url.Values{"secret": {recaptcha.Secret}, "remoteip": {recaptcha.RemoteIP}, "response": {recaptcha.Response}}
|
|
} else {
|
|
formValues = url.Values{"secret": {recaptcha.Secret}, "response": {recaptcha.Response}}
|
|
}
|
|
response, err := r.client.PostForm(r.ReCAPTCHALink, formValues)
|
|
if err != nil {
|
|
Err = fmt.Errorf("error posting to recaptcha endpoint: '%s'", err)
|
|
return
|
|
}
|
|
defer response.Body.Close()
|
|
resultBody, err := ioutil.ReadAll(response.Body)
|
|
if err != nil {
|
|
Err = fmt.Errorf("couldn't read response body: '%s'", err)
|
|
return
|
|
}
|
|
var result reCHAPTCHAResponse
|
|
err = json.Unmarshal(resultBody, &result)
|
|
if err != nil {
|
|
Err = fmt.Errorf("invalid response body json: '%s'", err)
|
|
return
|
|
}
|
|
|
|
if options.Hostname != "" && options.Hostname != result.Hostname {
|
|
Err = fmt.Errorf("invalid response hostname '%s', while expecting '%s'", result.Hostname, options.Hostname)
|
|
return
|
|
}
|
|
|
|
if options.ApkPackageName != "" && options.ApkPackageName != result.ApkPackageName {
|
|
Err = fmt.Errorf("invalid response ApkPackageName '%s', while expecting '%s'", result.ApkPackageName, options.ApkPackageName)
|
|
return
|
|
}
|
|
|
|
if options.ResponseTime != 0 {
|
|
duration := r.horloge.Since(result.ChallengeTS)
|
|
if options.ResponseTime < duration {
|
|
Err = fmt.Errorf("time spent in resolving challenge '%fs', while expecting maximum '%fs'", duration.Seconds(), options.ResponseTime.Seconds())
|
|
return
|
|
}
|
|
}
|
|
if r.Version == V3 {
|
|
if options.Action != "" && options.Action != result.Action {
|
|
Err = fmt.Errorf("invalid response action '%s', while expecting '%s'", result.Action, options.Action)
|
|
return
|
|
}
|
|
if options.Threshold != 0 && options.Threshold >= result.Score {
|
|
Err = fmt.Errorf("received score '%f', while expecting minimum '%f'", result.Score, options.Threshold)
|
|
return
|
|
}
|
|
if options.Threshold == 0 && DefaultTreshold >= result.Score {
|
|
Err = fmt.Errorf("received score '%f', while expecting minimum '%f'", result.Score, DefaultTreshold)
|
|
return
|
|
}
|
|
}
|
|
if result.ErrorCodes != nil {
|
|
Err = fmt.Errorf("remote error codes: %v", result.ErrorCodes)
|
|
return
|
|
}
|
|
if !result.Success && recaptcha.RemoteIP != "" {
|
|
Err = fmt.Errorf("invalid challenge solution or remote IP")
|
|
} else if !result.Success {
|
|
Err = fmt.Errorf("invalid challenge solution")
|
|
}
|
|
return
|
|
}
|