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  }