mirror of
https://github.com/PrivateCaptcha/PrivateCaptcha.git
synced 2026-02-09 23:38:42 -06:00
384 lines
13 KiB
Go
384 lines
13 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/PrivateCaptcha/PrivateCaptcha/pkg/common"
|
|
"github.com/PrivateCaptcha/PrivateCaptcha/pkg/db"
|
|
dbgen "github.com/PrivateCaptcha/PrivateCaptcha/pkg/db/generated"
|
|
"github.com/PrivateCaptcha/PrivateCaptcha/pkg/difficulty"
|
|
"github.com/PrivateCaptcha/PrivateCaptcha/pkg/monitoring"
|
|
"github.com/PrivateCaptcha/PrivateCaptcha/pkg/puzzle"
|
|
"github.com/PrivateCaptcha/PrivateCaptcha/pkg/ratelimit"
|
|
"github.com/justinas/alice"
|
|
"github.com/rs/cors"
|
|
)
|
|
|
|
const (
|
|
maxSolutionsBodySize = 256 * 1024
|
|
VerifyBatchSize = 100
|
|
PropertyBucketSize = 5 * time.Minute
|
|
updateLimitsBatchSize = 100
|
|
maxVerifyBatchSize = 100_000
|
|
)
|
|
|
|
var (
|
|
errAPIKeyNotSet = errors.New("API key is not set in context")
|
|
errInvalidAPIKey = errors.New("API key is not valid")
|
|
errPuzzleOwner = errors.New("error fetching puzzle owner")
|
|
errInvalidArg = errors.New("invalid arguments")
|
|
errTestSolutions = errors.New("invalid test solutions")
|
|
headersAnyOrigin = map[string][]string{
|
|
http.CanonicalHeaderKey(common.HeaderAccessControlOrigin): []string{"*"},
|
|
http.CanonicalHeaderKey(common.HeaderAccessControlAge): []string{"86400"},
|
|
}
|
|
headersContentPlain = map[string][]string{
|
|
http.CanonicalHeaderKey(common.HeaderContentType): []string{common.ContentTypePlain},
|
|
}
|
|
)
|
|
|
|
type Server struct {
|
|
Stage string
|
|
BusinessDB db.Implementor
|
|
TimeSeries common.TimeSeriesStore
|
|
Levels *difficulty.Levels
|
|
Auth *AuthMiddleware
|
|
VerifyLogChan chan *common.VerifyRecord
|
|
VerifyLogCancel context.CancelFunc
|
|
Cors *cors.Cors
|
|
Metrics common.APIMetrics
|
|
Mailer common.Mailer
|
|
RateLimiter ratelimit.HTTPRateLimiter
|
|
Verifier *Verifier
|
|
}
|
|
|
|
type apiKeyOwnerSource struct {
|
|
Store db.Implementor
|
|
cachedKey *dbgen.APIKey
|
|
}
|
|
|
|
var _ puzzle.OwnerIDSource = (*apiKeyOwnerSource)(nil)
|
|
|
|
func (a *apiKeyOwnerSource) apiKey(ctx context.Context) (*dbgen.APIKey, error) {
|
|
if apiKey, ok := ctx.Value(common.APIKeyContextKey).(*dbgen.APIKey); ok && (apiKey != nil) {
|
|
a.cachedKey = apiKey
|
|
return apiKey, nil
|
|
}
|
|
|
|
if secret, ok := ctx.Value(common.SecretContextKey).(string); ok && (len(secret) > 0) {
|
|
// this is the "postponed" DB access mentioned in APIKey() middleware
|
|
// NOTE: here we do NOT verify user's subscription validity, it's done only in middleware
|
|
key, err := a.Store.Impl().RetrieveAPIKey(ctx, secret)
|
|
if err != nil {
|
|
a.cachedKey = key
|
|
}
|
|
return key, err
|
|
}
|
|
|
|
return nil, errAPIKeyNotSet
|
|
}
|
|
|
|
func (a *apiKeyOwnerSource) OwnerID(ctx context.Context, tnow time.Time) (int32, error) {
|
|
apiKey, err := a.apiKey(ctx)
|
|
if err != nil {
|
|
return -1, err
|
|
}
|
|
|
|
if !isAPIKeyValid(ctx, apiKey, tnow) {
|
|
return -1, errInvalidAPIKey
|
|
}
|
|
|
|
return apiKey.UserID.Int32, nil
|
|
}
|
|
|
|
type VerificationResponse struct {
|
|
Success bool `json:"success"`
|
|
Code puzzle.VerifyError `json:"code"`
|
|
Origin string `json:"origin,omitempty"`
|
|
Timestamp common.JSONTime `json:"timestamp,omitempty"`
|
|
}
|
|
|
|
type VerifyResponseRecaptchaV2 struct {
|
|
Success bool `json:"success"`
|
|
ErrorCodes []string `json:"error-codes,omitempty"`
|
|
ChallengeTS common.JSONTime `json:"challenge_ts"`
|
|
Hostname string `json:"hostname"`
|
|
}
|
|
|
|
type VerifyResponseRecaptchaV3 struct {
|
|
VerifyResponseRecaptchaV2
|
|
Score float64 `json:"score"`
|
|
Action string `json:"action"`
|
|
}
|
|
|
|
func (s *Server) Init(ctx context.Context, verifyFlushInterval, authBackfillDelay time.Duration) error {
|
|
if err := s.Verifier.Update(ctx); err != nil {
|
|
slog.ErrorContext(ctx, "Failed to update puzzle verifier", common.ErrAttr(err))
|
|
return err
|
|
}
|
|
|
|
s.Levels.Init(2*time.Second /*access log interval*/, PropertyBucketSize /*backfill interval*/)
|
|
s.Auth.StartBackfill(authBackfillDelay)
|
|
|
|
var cancelVerifyCtx context.Context
|
|
cancelVerifyCtx, s.VerifyLogCancel = context.WithCancel(
|
|
context.WithValue(context.Background(), common.TraceIDContextKey, "flush_verify_log"))
|
|
|
|
go common.ProcessBatchArray(cancelVerifyCtx, s.VerifyLogChan, verifyFlushInterval, VerifyBatchSize, maxVerifyBatchSize, s.TimeSeries.WriteVerifyLogBatch)
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) Setup(router *http.ServeMux, domain string, verbose bool, security alice.Constructor) {
|
|
corsOpts := cors.Options{
|
|
// NOTE: due to the implementation of rs/cors, we need not to set "*" as AllowOrigin as this will ruin the response
|
|
// (in case of "*" allowed origin, response contains the same, while we want to restrict the response to domain)
|
|
AllowOriginVaryRequestFunc: s.Auth.originAllowed,
|
|
AllowedHeaders: []string{common.HeaderCaptchaVersion, "accept", "content-type", "x-requested-with"},
|
|
AllowedMethods: []string{http.MethodGet},
|
|
AllowPrivateNetwork: true,
|
|
OptionsPassthrough: true,
|
|
Debug: verbose,
|
|
MaxAge: 60 * 60, /*seconds*/
|
|
}
|
|
|
|
if corsOpts.Debug {
|
|
corsOpts.Logger = &common.FmtLogger{Ctx: common.TraceContext(context.TODO(), "cors"), Level: common.LevelTrace}
|
|
}
|
|
|
|
s.Cors = cors.New(corsOpts)
|
|
|
|
s.setupWithPrefix(domain, router, s.Cors.Handler, security)
|
|
}
|
|
|
|
func (s *Server) Shutdown() {
|
|
s.Levels.Shutdown()
|
|
s.Auth.Shutdown()
|
|
|
|
slog.Debug("Shutting down API server routines")
|
|
s.VerifyLogCancel()
|
|
close(s.VerifyLogChan)
|
|
}
|
|
|
|
func (s *Server) setupWithPrefix(domain string, router *http.ServeMux, corsHandler, security alice.Constructor) {
|
|
prefix := domain + "/"
|
|
slog.Debug("Setting up the API routes", "prefix", prefix)
|
|
svc := common.ServiceMiddleware("api")
|
|
publicChain := alice.New(svc, common.Recovered, security, s.Metrics.Handler)
|
|
// NOTE: auth middleware provides rate limiting internally
|
|
puzzleChain := publicChain.Append(s.RateLimiter.RateLimit, monitoring.Traced, common.TimeoutHandler(1*time.Second))
|
|
router.Handle(http.MethodGet+" "+prefix+common.PuzzleEndpoint, puzzleChain.Append(corsHandler, s.Auth.Sitekey).ThenFunc(s.puzzleHandler))
|
|
router.Handle(http.MethodOptions+" "+prefix+common.PuzzleEndpoint, puzzleChain.Append(common.Cached, corsHandler, s.Auth.SitekeyOptions).ThenFunc(s.puzzlePreFlight))
|
|
|
|
const (
|
|
// NOTE: these defaults will be adjusted per API key quota almost immediately after verifying API key
|
|
// requests burst
|
|
apiKeyLeakyBucketCap = 10
|
|
// effective 0.5 rps
|
|
apiKeyLeakInterval = 2 * time.Second
|
|
)
|
|
apiRateLimiter := s.RateLimiter.RateLimitExFunc(apiKeyLeakyBucketCap, apiKeyLeakInterval)
|
|
|
|
verifyChain := publicChain.Append(apiRateLimiter, monitoring.Traced, common.TimeoutHandler(5*time.Second))
|
|
// reCAPTCHA compatibility
|
|
// the difference from our side is _when_ we fetch API key: for reCAPTCHA it comes in form field "secret" and
|
|
// we want to put it _behind_ the MaxBytesHandler, while for Private Captcha format (header) it can be before
|
|
formAPIAuth := s.Auth.APIKey(formSecretAPIKey)
|
|
router.Handle(http.MethodPost+" "+prefix+common.SiteVerifyEndpoint, verifyChain.Then(http.MaxBytesHandler(formAPIAuth(http.HandlerFunc(s.recaptchaVerifyHandler)), maxSolutionsBodySize)))
|
|
// Private Captcha format
|
|
router.Handle(http.MethodPost+" "+prefix+common.VerifyEndpoint, verifyChain.Append(s.Auth.APIKey(headerAPIKey)).Then(http.MaxBytesHandler(http.HandlerFunc(s.pcVerifyHandler), maxSolutionsBodySize)))
|
|
|
|
// "root" access
|
|
router.Handle(prefix+"{$}", publicChain.Then(common.HttpStatus(http.StatusForbidden)))
|
|
}
|
|
|
|
func (s *Server) puzzlePreFlight(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
|
|
// the reason for this is that we intend to cache test property responses
|
|
if sitekey, ok := ctx.Value(common.SitekeyContextKey).(string); ok && (sitekey == db.TestPropertySitekey) {
|
|
common.WriteHeaders(w, headersAnyOrigin)
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (s *Server) puzzleHandler(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
puzzle, property, err := s.Verifier.PuzzleForRequest(r, s.Levels)
|
|
if err != nil {
|
|
if err == db.ErrTestProperty {
|
|
common.WriteHeaders(w, common.CachedHeaders)
|
|
// we cache test property responses, can as well allow them anywhere
|
|
common.WriteHeaders(w, headersAnyOrigin)
|
|
common.WriteHeaders(w, headersContentPlain)
|
|
_ = s.Verifier.WriteTestPuzzle(w)
|
|
return
|
|
}
|
|
|
|
status := http.StatusInternalServerError
|
|
if err == errInvalidArg {
|
|
status = http.StatusBadRequest
|
|
} else {
|
|
slog.ErrorContext(ctx, "Failed to create puzzle", common.ErrAttr(err))
|
|
}
|
|
|
|
http.Error(w, "", status)
|
|
return
|
|
}
|
|
|
|
var extraSalt []byte
|
|
var userID int32 = -1
|
|
if property != nil {
|
|
userID = property.OrgOwnerID.Int32
|
|
extraSalt = property.Salt
|
|
}
|
|
|
|
if err := s.Verifier.Write(ctx, puzzle, extraSalt, w); err != nil {
|
|
slog.ErrorContext(ctx, "Failed to write puzzle", common.ErrAttr(err))
|
|
}
|
|
|
|
s.Metrics.ObservePuzzleCreated(userID)
|
|
}
|
|
|
|
// reCAPTCHA format: puzzle response is in form field "response", API key is in form field "secret"
|
|
// https://developers.google.com/recaptcha/docs/verify
|
|
func (s *Server) recaptchaVerifyHandler(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
|
|
if err := r.ParseForm(); err != nil {
|
|
slog.ErrorContext(ctx, "Failed to read request form", common.ErrAttr(err))
|
|
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
data := r.FormValue(common.ParamResponse)
|
|
if len(data) == 0 {
|
|
slog.ErrorContext(ctx, "Empty captcha response")
|
|
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
payload, err := s.Verifier.ParseSolutionPayload(ctx, []byte(data))
|
|
if err != nil {
|
|
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
ownerSource := &apiKeyOwnerSource{Store: s.BusinessDB}
|
|
result, err := s.Verifier.Verify(ctx, payload, ownerSource, time.Now().UTC())
|
|
if err != nil {
|
|
switch err {
|
|
case errPuzzleOwner:
|
|
// "late" auth check (we postpone API key check in case it's not cached in Auth)
|
|
// in this case we also automatically set "API key" (or whatever is passed) as missing in cache
|
|
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
|
|
default:
|
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
}
|
|
return
|
|
}
|
|
|
|
if result.Valid() {
|
|
s.addVerifyRecord(ctx, result)
|
|
}
|
|
|
|
if apiKey := ownerSource.cachedKey; apiKey != nil {
|
|
// if we are not cached, then we will recheck via "delayed" mechanism of OwnerIDSource
|
|
// when rate limiting is cleaned up (due to inactivity) we should still be able to access on defaults
|
|
interval := float64(time.Second) / apiKey.RequestsPerSecond
|
|
s.RateLimiter.UpdateRequestLimits(r, uint32(apiKey.RequestsBurst), time.Duration(interval))
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// Private Captcha format: puzzle response is the whole body, API key is in header
|
|
func (s *Server) pcVerifyHandler(w http.ResponseWriter, r *http.Request) {
|
|
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
|
|
}
|
|
|
|
payload, err := s.Verifier.ParseSolutionPayload(ctx, data)
|
|
if err != nil {
|
|
slog.Log(ctx, common.LevelTrace, "Failed to parse solution payload", common.ErrAttr(err))
|
|
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
ownerSource := &apiKeyOwnerSource{Store: s.BusinessDB}
|
|
result, err := s.Verifier.Verify(ctx, payload, ownerSource, time.Now().UTC())
|
|
if err != nil {
|
|
switch err {
|
|
case errPuzzleOwner:
|
|
// "late" auth check (we postpone API key check in case it's not cached in Auth)
|
|
// in this case we also automatically set "API key" (or whatever is passed) as missing in cache
|
|
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
|
|
default:
|
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
}
|
|
return
|
|
}
|
|
|
|
if result.Valid() {
|
|
s.addVerifyRecord(ctx, result)
|
|
}
|
|
|
|
if apiKey := ownerSource.cachedKey; apiKey != nil {
|
|
// if we are not cached, then we will recheck via "delayed" mechanism of OwnerIDSource
|
|
// when rate limiting is cleaned up (due to inactivity) we should still be able to access on defaults
|
|
interval := float64(time.Second) / apiKey.RequestsPerSecond
|
|
s.RateLimiter.UpdateRequestLimits(r, uint32(apiKey.RequestsBurst), time.Duration(interval))
|
|
}
|
|
|
|
response := &VerificationResponse{
|
|
Success: result.Success(),
|
|
Code: result.Error,
|
|
Origin: result.Domain,
|
|
Timestamp: common.JSONTime(result.CreatedAt),
|
|
}
|
|
|
|
common.SendJSONResponse(r.Context(), w, response, common.NoCacheHeaders)
|
|
}
|
|
|
|
func (s *Server) addVerifyRecord(ctx context.Context, result *puzzle.VerifyResult) {
|
|
vr := &common.VerifyRecord{
|
|
UserID: result.UserID,
|
|
OrgID: result.OrgID,
|
|
PropertyID: result.PropertyID,
|
|
PuzzleID: result.PuzzleID,
|
|
Timestamp: time.Now().UTC(),
|
|
Status: int8(result.Error),
|
|
}
|
|
|
|
s.VerifyLogChan <- vr
|
|
|
|
s.Metrics.ObservePuzzleVerified(vr.UserID, result.Error.String(), (result.PuzzleID == 0) /*is stub*/)
|
|
}
|