mirror of
https://github.com/PrivateCaptcha/PrivateCaptcha.git
synced 2026-02-10 15:59:21 -06:00
Split reCAPTCHA compatibility and our own verify endpoint
This commit is contained in:
@@ -45,13 +45,15 @@ var (
|
||||
http.CanonicalHeaderKey(common.HeaderContentType): []string{common.ContentTypePlain},
|
||||
}
|
||||
verifyResultErrorTest = &puzzle.VerifyResult{
|
||||
Errors: []puzzle.VerifyError{puzzle.TestPropertyError},
|
||||
Error: puzzle.TestPropertyError,
|
||||
}
|
||||
verifyResultErrorParse = &puzzle.VerifyResult{
|
||||
Errors: []puzzle.VerifyError{puzzle.ParseResponseError},
|
||||
Error: puzzle.ParseResponseError,
|
||||
}
|
||||
)
|
||||
|
||||
type verifyFunc func(w http.ResponseWriter, r *http.Request) (*puzzle.VerifyResult, error)
|
||||
|
||||
type Server struct {
|
||||
Stage string
|
||||
BusinessDB db.Implementor
|
||||
@@ -105,12 +107,15 @@ func (a *apiKeyOwnerSource) OwnerID(ctx context.Context, tnow time.Time) (int32,
|
||||
}
|
||||
|
||||
type VerifyResponse struct {
|
||||
Success bool `json:"success"`
|
||||
ErrorCodes []string `json:"error-codes,omitempty"`
|
||||
Success bool `json:"success"`
|
||||
Origin string `json:"origin,omitempty"`
|
||||
Timestamp common.JSONTime `json:"timestamp,omitempty"`
|
||||
Error string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
type VerifyResponseRecaptchaV2 struct {
|
||||
VerifyResponse
|
||||
Success bool `json:"success"`
|
||||
ErrorCodes []string `json:"error-codes,omitempty"`
|
||||
ChallengeTS common.JSONTime `json:"challenge_ts"`
|
||||
Hostname string `json:"hostname"`
|
||||
}
|
||||
@@ -202,7 +207,10 @@ func (s *Server) setupWithPrefix(domain string, router *http.ServeMux, corsHandl
|
||||
apiRateLimiter := s.RateLimiter.RateLimitExFunc(apiKeyLeakyBucketCap, apiKeyLeakInterval)
|
||||
|
||||
verifyChain := publicChain.Append(apiRateLimiter, monitoring.Traced, common.TimeoutHandler(5*time.Second), s.Auth.APIKey)
|
||||
router.Handle(http.MethodPost+" "+prefix+common.VerifyEndpoint, verifyChain.Then(http.MaxBytesHandler(http.HandlerFunc(s.verifyHandler), maxSolutionsBodySize)))
|
||||
// reCAPTCHA compatibility
|
||||
router.Handle(http.MethodPost+" "+prefix+common.SiteVerifyEndpoint, verifyChain.Then(http.MaxBytesHandler(s.recaptchaResponseHandler(s.verifyHandler), maxSolutionsBodySize)))
|
||||
// Private Captcha format
|
||||
router.Handle(http.MethodPost+" "+prefix+common.VerifyEndpoint, verifyChain.Then(http.MaxBytesHandler(s.pcResponseHandler(s.verifyHandler), maxSolutionsBodySize)))
|
||||
|
||||
// "root" access
|
||||
router.Handle(prefix+"{$}", publicChain.Then(common.HttpStatus(http.StatusForbidden)))
|
||||
@@ -340,9 +348,9 @@ func (s *Server) Verify(ctx context.Context, data []byte, expectedOwner puzzle.O
|
||||
return verifyResultErrorParse, nil
|
||||
}
|
||||
|
||||
result := &puzzle.VerifyResult{Errors: make([]puzzle.VerifyError, 0, 1)}
|
||||
result := &puzzle.VerifyResult{}
|
||||
puzzleObject, property, perr := s.verifyPuzzleValid(ctx, verifyPayload, tnow)
|
||||
result.AddError(perr)
|
||||
result.SetError(perr)
|
||||
if puzzleObject != nil && !puzzleObject.IsZero() {
|
||||
result.CreatedAt = puzzleObject.Expiration.Add(-puzzle.DefaultValidityPeriod)
|
||||
}
|
||||
@@ -360,7 +368,7 @@ func (s *Server) Verify(ctx context.Context, data []byte, expectedOwner puzzle.O
|
||||
if (property.OrgOwnerID.Int32 != ownerID) && (property.CreatorID.Int32 != ownerID) {
|
||||
slog.WarnContext(ctx, "Org owner does not match expected owner", "expectedOwner", ownerID,
|
||||
"orgOwner", property.OrgOwnerID.Int32, "propertyCreator", property.CreatorID.Int32)
|
||||
result.AddError(puzzle.WrongOwnerError)
|
||||
result.SetError(puzzle.WrongOwnerError)
|
||||
return result, nil
|
||||
}
|
||||
} else {
|
||||
@@ -376,7 +384,7 @@ func (s *Server) Verify(ctx context.Context, data []byte, expectedOwner puzzle.O
|
||||
"propertyID", property.ID)
|
||||
|
||||
s.addVerifyRecord(ctx, puzzleObject, property, verr)
|
||||
result.AddError(verr)
|
||||
result.SetError(verr)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -391,14 +399,14 @@ func (s *Server) Verify(ctx context.Context, data []byte, expectedOwner puzzle.O
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Server) verifyHandler(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) verifyHandler(w http.ResponseWriter, r *http.Request) (*puzzle.VerifyResult, error) {
|
||||
ctx := r.Context()
|
||||
|
||||
data, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "Failed to read request body", common.ErrAttr(err))
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result, err := s.Verify(ctx, data, &apiKeyOwnerSource{Store: s.BusinessDB}, time.Now().UTC())
|
||||
@@ -411,7 +419,7 @@ func (s *Server) verifyHandler(w http.ResponseWriter, r *http.Request) {
|
||||
default:
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if apiKey, ok := ctx.Value(common.APIKeyContextKey).(*dbgen.APIKey); ok && (apiKey != nil) {
|
||||
@@ -421,25 +429,79 @@ func (s *Server) verifyHandler(w http.ResponseWriter, r *http.Request) {
|
||||
s.RateLimiter.UpdateRequestLimits(r, uint32(apiKey.RequestsBurst), time.Duration(interval))
|
||||
}
|
||||
|
||||
vr2 := &VerifyResponseRecaptchaV2{
|
||||
VerifyResponse: VerifyResponse{
|
||||
Success: result.Success(),
|
||||
ErrorCodes: result.ErrorsToStrings(),
|
||||
},
|
||||
ChallengeTS: common.JSONTime(result.CreatedAt),
|
||||
Hostname: result.Domain,
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
var response interface{} = vr2
|
||||
if recaptchaCompatVersion := r.Header.Get(common.HeaderCaptchaCompat); recaptchaCompatVersion == "rcV3" {
|
||||
response = &VerifyResponseRecaptchaV3{
|
||||
VerifyResponseRecaptchaV2: *vr2,
|
||||
Action: "",
|
||||
Score: 0.5,
|
||||
func (s *Server) recaptchaResponseHandler(vf verifyFunc) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
result, err := vf(w, r)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
common.SendJSONResponse(ctx, w, response, common.NoCacheHeaders)
|
||||
vr2 := &VerifyResponseRecaptchaV2{
|
||||
Success: result.Success(),
|
||||
ErrorCodes: result.ErrorsToStrings(),
|
||||
ChallengeTS: common.JSONTime(result.CreatedAt),
|
||||
Hostname: result.Domain,
|
||||
}
|
||||
|
||||
var response interface{} = vr2
|
||||
if recaptchaCompatVersion := r.Header.Get(common.HeaderCaptchaCompat); recaptchaCompatVersion == "rcV3" {
|
||||
response = &VerifyResponseRecaptchaV3{
|
||||
VerifyResponseRecaptchaV2: *vr2,
|
||||
Action: "",
|
||||
Score: 0.5,
|
||||
}
|
||||
}
|
||||
|
||||
common.SendJSONResponse(r.Context(), w, response, common.NoCacheHeaders)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) pcResponseHandler(vf verifyFunc) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
result, err := vf(w, r)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
response := &VerifyResponse{
|
||||
Success: result.Success(),
|
||||
Origin: result.Domain,
|
||||
Timestamp: common.JSONTime(result.CreatedAt),
|
||||
Error: result.ErrorString(),
|
||||
}
|
||||
|
||||
common.SendJSONResponse(r.Context(), w, response, common.NoCacheHeaders)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) privateCaptchaResponseHandler(vf verifyFunc) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
result, err := vf(w, r)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
vr2 := &VerifyResponseRecaptchaV2{
|
||||
Success: result.Success(),
|
||||
ErrorCodes: result.ErrorsToStrings(),
|
||||
ChallengeTS: common.JSONTime(result.CreatedAt),
|
||||
Hostname: result.Domain,
|
||||
}
|
||||
|
||||
var response interface{} = vr2
|
||||
if recaptchaCompatVersion := r.Header.Get(common.HeaderCaptchaCompat); recaptchaCompatVersion == "rcV3" {
|
||||
response = &VerifyResponseRecaptchaV3{
|
||||
VerifyResponseRecaptchaV2: *vr2,
|
||||
Action: "",
|
||||
Score: 0.5,
|
||||
}
|
||||
}
|
||||
|
||||
common.SendJSONResponse(r.Context(), w, response, common.NoCacheHeaders)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) addVerifyRecord(ctx context.Context, p *puzzle.Puzzle, property *dbgen.Property, verr puzzle.VerifyError) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -23,10 +24,8 @@ import (
|
||||
func TestSerializeResponse(t *testing.T) {
|
||||
v := VerifyResponseRecaptchaV3{
|
||||
VerifyResponseRecaptchaV2: VerifyResponseRecaptchaV2{
|
||||
VerifyResponse: VerifyResponse{
|
||||
Success: false,
|
||||
ErrorCodes: []string{puzzle.VerifyErrorOther.String()},
|
||||
},
|
||||
Success: false,
|
||||
ErrorCodes: []string{puzzle.VerifyErrorOther.String()},
|
||||
ChallengeTS: common.JSONTimeNow(),
|
||||
Hostname: "hostname.com",
|
||||
},
|
||||
@@ -40,13 +39,13 @@ func TestSerializeResponse(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func verifySuite(response, secret string) (*http.Response, error) {
|
||||
func verifySuite(response, secret, endpoint string) (*http.Response, error) {
|
||||
srv := http.NewServeMux()
|
||||
s.Setup(srv, "", true /*verbose*/, common.NoopMiddleware)
|
||||
|
||||
//srv.HandleFunc("/", catchAll)
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, "/"+common.VerifyEndpoint, strings.NewReader(response))
|
||||
req, err := http.NewRequest(http.MethodPost, "/"+endpoint, strings.NewReader(response))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -130,7 +129,7 @@ func TestVerifyPuzzle(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp, err := verifySuite(payload, apiKey)
|
||||
resp, err := verifySuite(payload, apiKey, common.VerifyEndpoint)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -140,13 +139,13 @@ func TestVerifyPuzzle(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func checkVerifyError(resp *http.Response, expected puzzle.VerifyError) error {
|
||||
func checkSiteVerifyError(resp *http.Response, expected puzzle.VerifyError) error {
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
response := &VerifyResponse{}
|
||||
response := &VerifyResponseRecaptchaV2{}
|
||||
err = json.Unmarshal(body, &response)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -154,15 +153,15 @@ func checkVerifyError(resp *http.Response, expected puzzle.VerifyError) error {
|
||||
|
||||
if expected == puzzle.VerifyNoError {
|
||||
if !response.Success {
|
||||
return fmt.Errorf("Expected successful verification")
|
||||
return errors.New("expected successful verification")
|
||||
}
|
||||
|
||||
if len(response.ErrorCodes) > 0 {
|
||||
return fmt.Errorf("Error codes present in response")
|
||||
return errors.New("error code present in response")
|
||||
}
|
||||
} else {
|
||||
if len(response.ErrorCodes) == 0 {
|
||||
return fmt.Errorf("No error codes in response")
|
||||
return errors.New("no error code in response")
|
||||
}
|
||||
|
||||
if response.ErrorCodes[0] != expected.String() {
|
||||
@@ -183,7 +182,7 @@ func TestVerifyPuzzleReplay(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp, err := verifySuite(payload, apiKey)
|
||||
resp, err := verifySuite(payload, apiKey, common.SiteVerifyEndpoint)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -193,12 +192,12 @@ func TestVerifyPuzzleReplay(t *testing.T) {
|
||||
}
|
||||
|
||||
// now second time the same
|
||||
resp, err = verifySuite(payload, apiKey)
|
||||
resp, err = verifySuite(payload, apiKey, common.SiteVerifyEndpoint)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := checkVerifyError(resp, puzzle.VerifiedBeforeError); err != nil {
|
||||
if err := checkSiteVerifyError(resp, puzzle.VerifiedBeforeError); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -220,7 +219,7 @@ func TestVerifyPuzzleAllowReplay(t *testing.T) {
|
||||
// this should be still cached so we don't need to actually update DB
|
||||
property.AllowReplay = true
|
||||
|
||||
resp, err := verifySuite(payload, apiKey)
|
||||
resp, err := verifySuite(payload, apiKey, common.SiteVerifyEndpoint)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -230,12 +229,12 @@ func TestVerifyPuzzleAllowReplay(t *testing.T) {
|
||||
}
|
||||
|
||||
// now second time the same
|
||||
resp, err = verifySuite(payload, apiKey)
|
||||
resp, err = verifySuite(payload, apiKey, common.SiteVerifyEndpoint)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := checkVerifyError(resp, puzzle.VerifyNoError); err != nil {
|
||||
if err := checkSiteVerifyError(resp, puzzle.VerifyNoError); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -276,7 +275,7 @@ func TestVerifyCachePriority(t *testing.T) {
|
||||
|
||||
cache.SetMissing(ctx, db.APIKeyCacheKey(secret))
|
||||
|
||||
resp, err := verifySuite(fmt.Sprintf("%s.%s", solutionsStr, puzzleStr), secret)
|
||||
resp, err := verifySuite(fmt.Sprintf("%s.%s", solutionsStr, puzzleStr), secret, common.SiteVerifyEndpoint)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -298,7 +297,7 @@ func TestVerifyInvalidKey(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp, err := verifySuite(payload, db.UUIDToSecret(*randomUUID()))
|
||||
resp, err := verifySuite(payload, db.UUIDToSecret(*randomUUID()), common.VerifyEndpoint)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -332,7 +331,7 @@ func TestVerifyExpiredKey(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp, err := verifySuite("a.b.c", db.UUIDToSecret(apikey.ExternalID))
|
||||
resp, err := verifySuite("a.b.c", db.UUIDToSecret(apikey.ExternalID), common.SiteVerifyEndpoint)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -361,7 +360,7 @@ func TestVerifyMaintenanceMode(t *testing.T) {
|
||||
store.UpdateConfig(true /*maintenance mode*/)
|
||||
defer store.UpdateConfig(false /*maintenance mode*/)
|
||||
|
||||
resp, err := verifySuite(payload, apiKey)
|
||||
resp, err := verifySuite(payload, apiKey, common.SiteVerifyEndpoint)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -370,7 +369,7 @@ func TestVerifyMaintenanceMode(t *testing.T) {
|
||||
t.Errorf("Unexpected submit status code %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
if err := checkVerifyError(resp, puzzle.MaintenanceModeError); err != nil {
|
||||
if err := checkSiteVerifyError(resp, puzzle.MaintenanceModeError); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -400,7 +399,7 @@ func TestVerifyTestProperty(t *testing.T) {
|
||||
|
||||
secret := db.UUIDToSecret(apikey.ExternalID)
|
||||
|
||||
resp, err := verifySuite(payload, secret)
|
||||
resp, err := verifySuite(payload, secret, common.SiteVerifyEndpoint)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -409,7 +408,7 @@ func TestVerifyTestProperty(t *testing.T) {
|
||||
t.Errorf("Unexpected verify status code %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
if err := checkVerifyError(resp, puzzle.TestPropertyError); err != nil {
|
||||
if err := checkSiteVerifyError(resp, puzzle.TestPropertyError); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ package common
|
||||
const (
|
||||
PuzzleEndpoint = "puzzle"
|
||||
EchoPuzzleEndpoint = "echopuzzle"
|
||||
VerifyEndpoint = "siteverify"
|
||||
SiteVerifyEndpoint = "siteverify"
|
||||
VerifyEndpoint = "verify"
|
||||
LoginEndpoint = "login"
|
||||
TwoFactorEndpoint = "2fa"
|
||||
ResendEndpoint = "resend"
|
||||
|
||||
@@ -63,7 +63,7 @@ func TestMain(m *testing.M) {
|
||||
CookieName: "pcsid",
|
||||
MaxLifetime: 1 * time.Minute,
|
||||
},
|
||||
PuzzleEngine: &fakePuzzleEngine{result: &puzzle.VerifyResult{Errors: []puzzle.VerifyError{puzzle.VerifyNoError}}},
|
||||
PuzzleEngine: &fakePuzzleEngine{result: &puzzle.VerifyResult{Error: puzzle.VerifyNoError}},
|
||||
PlanService: planService,
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ func TestMain(m *testing.M) {
|
||||
},
|
||||
Mailer: &email.StubMailer{},
|
||||
RateLimiter: &ratelimit.StubRateLimiter{},
|
||||
PuzzleEngine: &fakePuzzleEngine{result: &puzzle.VerifyResult{Errors: []puzzle.VerifyError{puzzle.VerifyNoError}}},
|
||||
PuzzleEngine: &fakePuzzleEngine{result: &puzzle.VerifyResult{Error: puzzle.VerifyNoError}},
|
||||
Metrics: monitoring.NewStub(),
|
||||
PlanService: planService,
|
||||
}
|
||||
|
||||
@@ -7,36 +7,38 @@ import (
|
||||
)
|
||||
|
||||
type VerifyResult struct {
|
||||
Errors []VerifyError
|
||||
Error VerifyError
|
||||
CreatedAt time.Time
|
||||
Domain string
|
||||
}
|
||||
|
||||
func (vr *VerifyResult) Success() bool {
|
||||
return (len(vr.Errors) == 0) ||
|
||||
((len(vr.Errors) == 1) &&
|
||||
(vr.Errors[0] == VerifyNoError) ||
|
||||
(vr.Errors[0] == MaintenanceModeError) ||
|
||||
(vr.Errors[0] == TestPropertyError))
|
||||
return (vr.Error == VerifyNoError) ||
|
||||
(vr.Error == MaintenanceModeError) ||
|
||||
(vr.Error == TestPropertyError)
|
||||
}
|
||||
|
||||
func (vr *VerifyResult) AddError(verr VerifyError) {
|
||||
if verr != VerifyNoError {
|
||||
vr.Errors = append(vr.Errors, verr)
|
||||
func (vr *VerifyResult) SetError(verr VerifyError) {
|
||||
vr.Error = verr
|
||||
}
|
||||
|
||||
func (vr *VerifyResult) ErrorString() string {
|
||||
if vr.Error == VerifyNoError {
|
||||
return ""
|
||||
}
|
||||
|
||||
return vr.Error.String()
|
||||
}
|
||||
|
||||
func (vr *VerifyResult) ErrorsToStrings() []string {
|
||||
if len(vr.Errors) == 0 {
|
||||
if vr.Error == VerifyNoError {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
result := make([]string, 0, len(vr.Errors))
|
||||
result := make([]string, 0, 1)
|
||||
|
||||
for _, err := range vr.Errors {
|
||||
if err != VerifyNoError {
|
||||
result = append(result, err.String())
|
||||
}
|
||||
if vr.Error != VerifyNoError {
|
||||
result = append(result, vr.Error.String())
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
Reference in New Issue
Block a user