Feat: ReCaptcha support (#292)
* Add custom mysql database port. * Modify: add cloudreve bin file to .gitignore * Feat:增加后端对ReCaptcha的支持 P.S.必须要执行迁移pull/310/head
parent
fa900b166a
commit
e58fb82463
@ -1 +1 @@
|
||||
Subproject commit 43c9ce1d266050637a247113db54883ce2218291
|
||||
Subproject commit f544486b6ae2440df197630601b1827ed6977c0b
|
@ -0,0 +1,182 @@
|
||||
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
|
||||
}
|
Loading…
Reference in new issue