Create the concept of API keys to allow third party clients to skip the CAPTCHA check on spot submission

This commit is contained in:
Ian Renton
2026-09-27 16:12:48 +01:00
parent d79c89c74a
commit 5d8cd38351
14 changed files with 155 additions and 42 deletions
+48 -15
View File
@@ -1,4 +1,5 @@
import asyncio
import hmac
import logging
import re
import threading
@@ -11,7 +12,13 @@ from tornado.ioloop import IOLoop
from tornado.web import Application
from core.activity_utils import get_ref_regex_for_activity
from core.config import ALLOW_SPOTTING, ALLOW_UPSTREAM_SPOTTING, RECAPTCHA_SECRET_KEY
from core.config import (
ALLOW_SPOTTING,
ALLOW_UPSTREAM_SPOTTING,
API_KEYS,
PROTECT_SPOT_SUBMISSION,
RECAPTCHA_SECRET_KEY,
)
from core.constants import UNKNOWN_BAND
from core.utils import infer_band_from_freq, safe_json_dumps
from data.spot import Spot
@@ -84,17 +91,34 @@ class APISpotHandler(tornado.web.RequestHandler):
submit_upstream = len(upstream_provider_names) > 0
captcha_token = handling.get("captcha_token", None)
# Verify CAPTCHA if required
if RECAPTCHA_SECRET_KEY:
if not captcha_token:
self.set_status(422)
self.write(safe_json_dumps("Error - CAPTCHA token is required for spot submission."))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
if not await IOLoop.current().run_in_executor(None, self._verify_recaptcha, captcha_token):
self.set_status(422)
self.write(safe_json_dumps("Error - CAPTCHA verification failed."))
# If spot submission is protected, the client must either provide a valid API key in the request header, or
# a valid CAPTCHA token in the request body. API keys are how we allow trusted third-party clients to submit
# spots, as they can't solve a CAPTCHA.
if PROTECT_SPOT_SUBMISSION:
api_key = self.request.headers.get("X-API-Key", "")
if api_key:
if not self._is_valid_api_key(api_key):
self.set_status(401)
self.write(safe_json_dumps("Error - API key not recognised."))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
elif captcha_token and RECAPTCHA_SECRET_KEY:
if not await IOLoop.current().run_in_executor(None, self._verify_recaptcha, captcha_token):
self.set_status(422)
self.write(safe_json_dumps("Error - CAPTCHA verification failed."))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
else:
self.set_status(401)
if RECAPTCHA_SECRET_KEY:
message = (
"Error - this server requires either an API key or a CAPTCHA token for spot submission."
)
else:
message = "Error - this server requires an API key for spot submission."
self.write(safe_json_dumps(message))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
@@ -246,9 +270,7 @@ class APISpotHandler(tornado.web.RequestHandler):
# Submit spot to the upstream provider. Run in a separate thread otherwise this blocks the whole web server
# for everyone!
await IOLoop.current().run_in_executor(None, provider.submit_spot, spot, credentials)
logger.info(
f"Spot of {spot.dx_call} by {spot.de_call} submitted upstream to {upstream_provider_name}."
)
logger.info(f"Spot of {spot.dx_call} by {spot.de_call} submitted upstream to {upstream_provider_name}.")
# Trigger a re-poll after 3 second so the spot appears quickly. (Submitting to a cluster node is slower than
# this, but we get data as a live stream from cluster anyway, so force_poll does nothing in that case. This
# is really just for the HTTP providers when we submit a spot to them)
@@ -272,6 +294,17 @@ class APISpotHandler(tornado.web.RequestHandler):
return p
return None
@staticmethod
def _is_valid_api_key(api_key):
"""Check whether the supplied API key is one of the ones allowed in config."""
# HMAC Compare Digest is a recommended thing for security reasons. If you just compare strings
# then the comparison returns at the first non-matching character, which means in theory you can
# use the time it takes to compare strings to figure out how much of the string you've got right.
# With the digest approach it's not the real strings being compared but generated digests, so
# it will take a constant amount of time regardless of how well the actual strings match.
return any(hmac.compare_digest(api_key.encode(), k.encode()) for k in API_KEYS)
@staticmethod
def _verify_recaptcha(token):
"""Verify a Google reCAPTCHA v2 token. Returns True if valid."""