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.
cloudreve/pkg/recaptcha/recaptcha.go

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
}