github.com/cloudreve/Cloudreve/v3@v3.0.0-20240224133659-3edb00a6484c/pkg/recaptcha/recaptcha.go (about) 1 package recaptcha 2 3 import ( 4 "encoding/json" 5 "fmt" 6 "io/ioutil" 7 "net/http" 8 "net/url" 9 "time" 10 ) 11 12 const reCAPTCHALink = "https://www.recaptcha.net/recaptcha/api/siteverify" 13 14 // VERSION the recaptcha api version 15 type VERSION int8 16 17 const ( 18 // V2 recaptcha api v2 19 V2 VERSION = iota 20 // V3 recaptcha api v3, more details can be found here : https://developers.google.com/recaptcha/docs/v3 21 V3 22 // DefaultTreshold Default minimin score when using V3 api 23 DefaultTreshold float32 = 0.5 24 ) 25 26 type reCHAPTCHARequest struct { 27 Secret string `json:"secret"` 28 Response string `json:"response"` 29 RemoteIP string `json:"remoteip,omitempty"` 30 } 31 32 type reCHAPTCHAResponse struct { 33 Success bool `json:"success"` 34 ChallengeTS time.Time `json:"challenge_ts"` 35 Hostname string `json:"hostname,omitempty"` 36 ApkPackageName string `json:"apk_package_name,omitempty"` 37 Action string `json:"action,omitempty"` 38 Score float32 `json:"score,omitempty"` 39 ErrorCodes []string `json:"error-codes,omitempty"` 40 } 41 42 // custom client so we can mock in tests 43 type netClient interface { 44 PostForm(url string, formValues url.Values) (resp *http.Response, err error) 45 } 46 47 // custom clock so we can mock in tests 48 type clock interface { 49 Since(t time.Time) time.Duration 50 } 51 52 type realClock struct { 53 } 54 55 func (realClock) Since(t time.Time) time.Duration { 56 return time.Since(t) 57 } 58 59 // ReCAPTCHA recpatcha holder struct, make adding mocking code simpler 60 type ReCAPTCHA struct { 61 client netClient 62 Secret string 63 ReCAPTCHALink string 64 Version VERSION 65 Timeout time.Duration 66 horloge clock 67 } 68 69 // NewReCAPTCHA new ReCAPTCHA instance if version is set to V2 uses recatpcha v2 API, get your secret from https://www.google.com/recaptcha/admin 70 // if version is set to V2 uses recatpcha v2 API, get your secret from https://g.co/recaptcha/v3 71 func NewReCAPTCHA(ReCAPTCHASecret string, version VERSION, timeout time.Duration) (ReCAPTCHA, error) { 72 if ReCAPTCHASecret == "" { 73 return ReCAPTCHA{}, fmt.Errorf("recaptcha secret cannot be blank") 74 } 75 return ReCAPTCHA{ 76 client: &http.Client{ 77 Timeout: timeout, 78 }, 79 horloge: &realClock{}, 80 Secret: ReCAPTCHASecret, 81 ReCAPTCHALink: reCAPTCHALink, 82 Timeout: timeout, 83 Version: version, 84 }, nil 85 } 86 87 // Verify returns `nil` if no error and the client solved the challenge correctly 88 func (r *ReCAPTCHA) Verify(challengeResponse string) error { 89 body := reCHAPTCHARequest{Secret: r.Secret, Response: challengeResponse} 90 return r.confirm(body, VerifyOption{}) 91 } 92 93 // VerifyOption verification options expected for the challenge 94 type VerifyOption struct { 95 Threshold float32 // ignored in v2 recaptcha 96 Action string // ignored in v2 recaptcha 97 Hostname string 98 ApkPackageName string 99 ResponseTime time.Duration 100 RemoteIP string 101 } 102 103 // VerifyWithOptions returns `nil` if no error and the client solved the challenge correctly and all options are natching 104 // `Threshold` and `Action` are ignored when using V2 version 105 func (r *ReCAPTCHA) VerifyWithOptions(challengeResponse string, options VerifyOption) error { 106 var body reCHAPTCHARequest 107 if options.RemoteIP == "" { 108 body = reCHAPTCHARequest{Secret: r.Secret, Response: challengeResponse} 109 } else { 110 body = reCHAPTCHARequest{Secret: r.Secret, Response: challengeResponse, RemoteIP: options.RemoteIP} 111 } 112 return r.confirm(body, options) 113 } 114 115 func (r *ReCAPTCHA) confirm(recaptcha reCHAPTCHARequest, options VerifyOption) (Err error) { 116 Err = nil 117 var formValues url.Values 118 if recaptcha.RemoteIP != "" { 119 formValues = url.Values{"secret": {recaptcha.Secret}, "remoteip": {recaptcha.RemoteIP}, "response": {recaptcha.Response}} 120 } else { 121 formValues = url.Values{"secret": {recaptcha.Secret}, "response": {recaptcha.Response}} 122 } 123 response, err := r.client.PostForm(r.ReCAPTCHALink, formValues) 124 if err != nil { 125 Err = fmt.Errorf("error posting to recaptcha endpoint: '%s'", err) 126 return 127 } 128 defer response.Body.Close() 129 resultBody, err := ioutil.ReadAll(response.Body) 130 if err != nil { 131 Err = fmt.Errorf("couldn't read response body: '%s'", err) 132 return 133 } 134 var result reCHAPTCHAResponse 135 err = json.Unmarshal(resultBody, &result) 136 if err != nil { 137 Err = fmt.Errorf("invalid response body json: '%s'", err) 138 return 139 } 140 141 if options.Hostname != "" && options.Hostname != result.Hostname { 142 Err = fmt.Errorf("invalid response hostname '%s', while expecting '%s'", result.Hostname, options.Hostname) 143 return 144 } 145 146 if options.ApkPackageName != "" && options.ApkPackageName != result.ApkPackageName { 147 Err = fmt.Errorf("invalid response ApkPackageName '%s', while expecting '%s'", result.ApkPackageName, options.ApkPackageName) 148 return 149 } 150 151 if options.ResponseTime != 0 { 152 duration := r.horloge.Since(result.ChallengeTS) 153 if options.ResponseTime < duration { 154 Err = fmt.Errorf("time spent in resolving challenge '%fs', while expecting maximum '%fs'", duration.Seconds(), options.ResponseTime.Seconds()) 155 return 156 } 157 } 158 if r.Version == V3 { 159 if options.Action != "" && options.Action != result.Action { 160 Err = fmt.Errorf("invalid response action '%s', while expecting '%s'", result.Action, options.Action) 161 return 162 } 163 if options.Threshold != 0 && options.Threshold >= result.Score { 164 Err = fmt.Errorf("received score '%f', while expecting minimum '%f'", result.Score, options.Threshold) 165 return 166 } 167 if options.Threshold == 0 && DefaultTreshold >= result.Score { 168 Err = fmt.Errorf("received score '%f', while expecting minimum '%f'", result.Score, DefaultTreshold) 169 return 170 } 171 } 172 if result.ErrorCodes != nil { 173 Err = fmt.Errorf("remote error codes: %v", result.ErrorCodes) 174 return 175 } 176 if !result.Success && recaptcha.RemoteIP != "" { 177 Err = fmt.Errorf("invalid challenge solution or remote IP") 178 } else if !result.Success { 179 Err = fmt.Errorf("invalid challenge solution") 180 } 181 return 182 }