Skip to content

Commit c0d920a

Browse files
authored
OpenConceptLab/ocl_online#51 | Block Anonymous API usage except from approved clients (#845)
* OpenConceptLab/ocl_online#51 | Block Anonymouse API usage except from approved clients * OpenConceptLab/ocl_online#51 | feedback review
1 parent e332131 commit c0d920a

3 files changed

Lines changed: 288 additions & 2 deletions

File tree

core/middlewares/middlewares.py

Lines changed: 105 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
import time
33

44
import requests
5+
from django.conf import settings
56
from django.http import HttpResponseNotFound, HttpResponse
7+
from django.http.response import JsonResponse
68
from django.utils.deprecation import MiddlewareMixin
79
from request_logging.middleware import LoggingMiddleware
810
from rest_framework.views import APIView
@@ -52,7 +54,6 @@ class ResponseHeadersMiddleware(BaseMiddleware):
5254
def __call__(self, request):
5355
start_time = time.time()
5456
response = self.get_response(request)
55-
from django.conf import settings
5657
response[VERSION_HEADER] = settings.VERSION
5758
try:
5859
response[REQUEST_USER_HEADER] = str(getattr(request, 'user', None))
@@ -89,6 +90,109 @@ def __call__(self, request):
8990
return response
9091

9192

93+
class RequireAuthenticationMiddleware(BaseMiddleware):
94+
"""Block anonymous API access unless the request matches an approved bypass."""
95+
96+
exempt_path_prefixes = (
97+
'/healthcheck/',
98+
'/users/api-token/',
99+
'/users/login/',
100+
'/users/logout/',
101+
'/users/signup/',
102+
'/users/password/reset/',
103+
'/users/oidc/',
104+
'/oidc/',
105+
'/fhir/',
106+
'/swagger',
107+
'/redoc/',
108+
'/admin/',
109+
)
110+
exempt_exact_paths = {
111+
'/',
112+
'/version',
113+
'/changelog',
114+
'/feedback',
115+
'/toggles',
116+
'/locales',
117+
'/events',
118+
}
119+
forbidden_response = {
120+
'detail': 'Authentication required. Anonymous API access is disabled.',
121+
'upgrade_url': 'https://app.openconceptlab.org/pricing',
122+
}
123+
124+
def __call__(self, request):
125+
"""Allow exempt and approved anonymous traffic, otherwise return 403."""
126+
if self.is_request_allowed(request):
127+
return self.get_response(request)
128+
129+
return JsonResponse(self.forbidden_response, status=403)
130+
131+
def is_request_allowed(self, request):
132+
"""Return whether the current request can bypass authentication enforcement."""
133+
if request.method == 'OPTIONS' or request.META.get('HTTP_USER_AGENT', '').startswith('ELB-HealthChecker'):
134+
return True
135+
136+
user = getattr(request, 'user', None)
137+
return getattr(user, 'is_authenticated', False) or self.is_exempt_path(request.path) or \
138+
self.has_approved_client_header(request) or \
139+
self.has_approved_api_key(request) or self.has_approved_ip(request)
140+
141+
@classmethod
142+
def is_exempt_path(cls, path):
143+
"""Return whether a request path must remain accessible to anonymous users."""
144+
normalized_path = path.rstrip('/') or '/'
145+
if normalized_path in cls.exempt_exact_paths:
146+
return True
147+
148+
if any(path.startswith(prefix) for prefix in cls.exempt_path_prefixes):
149+
return True
150+
151+
return (
152+
path.startswith('/users/')
153+
and (
154+
'/verify/' in path
155+
or path.endswith('/sso-migrate/')
156+
or path.endswith('/following/')
157+
)
158+
)
159+
160+
@staticmethod
161+
def has_approved_client_header(request):
162+
"""Match the configured anonymous allowlist against the X-OCL-CLIENT header."""
163+
client_name = request.META.get('HTTP_X_OCL_CLIENT', '').strip()
164+
return bool(client_name and client_name in settings.APPROVED_ANONYMOUS_CLIENTS)
165+
166+
@staticmethod
167+
def has_approved_api_key(request):
168+
"""Match allowlisted anonymous API keys from common request locations."""
169+
approved_keys = settings.APPROVED_ANONYMOUS_API_KEYS
170+
if not approved_keys:
171+
return False
172+
173+
authorization = request.META.get('HTTP_AUTHORIZATION', '').strip()
174+
x_api_key = request.META.get('HTTP_X_API_KEY', '').strip()
175+
tokens = [authorization, x_api_key]
176+
bearer_token = authorization.split(None, 1)[1].strip() if ' ' in authorization else ''
177+
if bearer_token:
178+
tokens.append(bearer_token)
179+
180+
return any(token and token in approved_keys for token in tokens)
181+
182+
@staticmethod
183+
def has_approved_ip(request):
184+
"""Match source IPs using the socket address only."""
185+
approved_ips = settings.APPROVED_ANONYMOUS_IPS
186+
if not approved_ips:
187+
return False
188+
189+
remote_addr = request.META.get('REMOTE_ADDR', '').strip()
190+
if not remote_addr:
191+
return False
192+
193+
return remote_addr in approved_ips
194+
195+
92196
class FhirMiddleware(BaseMiddleware):
93197
"""
94198
It is used to expose FHIR endpoints under FHIR subdomain only and convert content from xml to json.
@@ -98,7 +202,6 @@ class FhirMiddleware(BaseMiddleware):
98202
def __call__(self, request):
99203
absolute_uri = request.build_absolute_uri()
100204

101-
from django.conf import settings
102205
if settings.FHIR_SUBDOMAIN:
103206
uri = absolute_uri.split('/')
104207
domain = uri[2] if len(uri) > 2 else ''

core/middlewares/tests.py

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import json
2+
3+
from django.conf import settings
4+
from django.contrib.auth.models import AnonymousUser
5+
from django.http import HttpResponse
6+
from django.test import RequestFactory, SimpleTestCase, override_settings
7+
8+
from core.middlewares.middlewares import RequireAuthenticationMiddleware
9+
10+
11+
@override_settings(
12+
REQUIRE_AUTHENTICATION=True,
13+
APPROVED_ANONYMOUS_CLIENTS=['test-client'],
14+
APPROVED_ANONYMOUS_API_KEYS=['test-api-key'],
15+
APPROVED_ANONYMOUS_IPS=['10.0.0.1'],
16+
)
17+
class RequireAuthenticationMiddlewareTest(SimpleTestCase):
18+
"""Verify anonymous authentication enforcement and approved bypasses."""
19+
20+
def setUp(self):
21+
"""Create a request factory and middleware instance for each test."""
22+
self.factory = RequestFactory()
23+
self.middleware = RequireAuthenticationMiddleware(lambda request: HttpResponse('ok'))
24+
25+
def make_request(self, path='/orgs/', method='get', user=None, **meta):
26+
"""Build a request object with a controllable authenticated user state."""
27+
request_method = getattr(self.factory, method.lower())
28+
request = request_method(path, **meta)
29+
request.user = user or AnonymousUser()
30+
return request
31+
32+
def test_allows_authenticated_request(self):
33+
"""Authenticated requests should bypass the anonymous access gate."""
34+
user = type('AuthenticatedUser', (), {'is_authenticated': True})()
35+
36+
response = self.middleware(self.make_request(user=user))
37+
38+
self.assertEqual(response.status_code, 200)
39+
40+
def test_blocks_anonymous_request_for_protected_path(self):
41+
"""Anonymous traffic to protected API paths should receive a 403 response."""
42+
response = self.middleware(self.make_request('/orgs/OCL/'))
43+
44+
self.assertEqual(response.status_code, 403)
45+
self.assertEqual(
46+
json.loads(response.content),
47+
{
48+
'detail': 'Authentication required. Anonymous API access is disabled.',
49+
'upgrade_url': 'https://app.openconceptlab.org/pricing',
50+
}
51+
)
52+
53+
def test_allows_anonymous_request_for_approved_client_header(self):
54+
"""Approved X-OCL-CLIENT values should retain anonymous access."""
55+
response = self.middleware(self.make_request('/orgs/OCL/', HTTP_X_OCL_CLIENT='test-client'))
56+
57+
self.assertEqual(response.status_code, 200)
58+
59+
def test_blocks_anonymous_request_for_unapproved_client_header(self):
60+
"""Unknown X-OCL-CLIENT values should still be rejected."""
61+
response = self.middleware(self.make_request('/orgs/OCL/', HTTP_X_OCL_CLIENT='unknown-client'))
62+
63+
self.assertEqual(response.status_code, 403)
64+
65+
def test_blocks_anonymous_request_for_whitespace_client_header(self):
66+
"""Whitespace-only client header values should be rejected after normalization."""
67+
response = self.middleware(self.make_request('/orgs/OCL/', HTTP_X_OCL_CLIENT=' '))
68+
69+
self.assertEqual(response.status_code, 403)
70+
71+
def test_allows_anonymous_request_for_approved_api_key_header(self):
72+
"""Allowlisted anonymous API keys should bypass the gate."""
73+
response = self.middleware(self.make_request('/orgs/OCL/', HTTP_X_API_KEY='test-api-key'))
74+
75+
self.assertEqual(response.status_code, 200)
76+
77+
def test_allows_anonymous_request_for_approved_authorization_token(self):
78+
"""Allowlisted bearer or token credentials should bypass the gate."""
79+
response = self.middleware(self.make_request('/orgs/OCL/', HTTP_AUTHORIZATION='Token test-api-key'))
80+
81+
self.assertEqual(response.status_code, 200)
82+
83+
def test_blocks_anonymous_request_for_query_string_api_key(self):
84+
"""Query string API keys should not bypass the authentication gate."""
85+
response = self.middleware(self.make_request('/orgs/OCL/?api_key=test-api-key'))
86+
87+
self.assertEqual(response.status_code, 403)
88+
89+
def test_allows_anonymous_request_for_approved_ip(self):
90+
"""Allowlisted source IPs should keep anonymous access."""
91+
response = self.middleware(self.make_request('/orgs/OCL/', REMOTE_ADDR='10.0.0.1'))
92+
93+
self.assertEqual(response.status_code, 200)
94+
95+
def test_blocks_anonymous_request_for_forwarded_ip_only(self):
96+
"""Forwarded IP headers alone should not bypass the authentication gate."""
97+
response = self.middleware(
98+
self.make_request('/orgs/OCL/', HTTP_X_FORWARDED_FOR='10.0.0.1', REMOTE_ADDR='203.0.113.5')
99+
)
100+
101+
self.assertEqual(response.status_code, 403)
102+
103+
def test_allows_options_request(self):
104+
"""CORS preflight requests should not be blocked."""
105+
response = self.middleware(self.make_request('/orgs/OCL/', method='options'))
106+
107+
self.assertEqual(response.status_code, 200)
108+
109+
def test_allows_elb_health_checker_request(self):
110+
"""Infrastructure health checks should bypass the gate."""
111+
response = self.middleware(
112+
self.make_request('/orgs/OCL/', HTTP_USER_AGENT='ELB-HealthChecker/2.0')
113+
)
114+
115+
self.assertEqual(response.status_code, 200)
116+
117+
def test_allows_exempt_exact_paths(self):
118+
"""Public root-level utility endpoints should remain anonymous."""
119+
for path in ['/', '/version/', '/changelog/', '/feedback/', '/toggles/', '/locales/', '/events/']:
120+
with self.subTest(path=path):
121+
response = self.middleware(self.make_request(path))
122+
self.assertEqual(response.status_code, 200)
123+
124+
def test_allows_exempt_path_prefixes(self):
125+
"""Auth, docs, admin, and FHIR prefixes should remain anonymous."""
126+
paths = [
127+
'/healthcheck/',
128+
'/users/api-token/',
129+
'/users/login/',
130+
'/users/logout/',
131+
'/users/signup/',
132+
'/users/password/reset/',
133+
'/users/oidc/code-exchange/',
134+
'/oidc/authenticate/',
135+
'/fhir/',
136+
'/swagger/',
137+
'/swagger.json',
138+
'/redoc/',
139+
'/admin/login/',
140+
]
141+
142+
for path in paths:
143+
with self.subTest(path=path):
144+
response = self.middleware(self.make_request(path))
145+
self.assertEqual(response.status_code, 200)
146+
147+
def test_allows_exempt_dynamic_user_paths(self):
148+
"""Public user verification and following endpoints should remain anonymous."""
149+
paths = [
150+
'/users/alice/verify/token-123/',
151+
'/users/alice/sso-migrate/',
152+
'/users/alice/following/',
153+
]
154+
155+
for path in paths:
156+
with self.subTest(path=path):
157+
response = self.middleware(self.make_request(path))
158+
self.assertEqual(response.status_code, 200)
159+
160+
161+
@override_settings(REQUIRE_AUTHENTICATION=False)
162+
class RequireAuthenticationSettingsTest(SimpleTestCase):
163+
"""Verify auth-enforcement middleware configuration toggles cleanly."""
164+
165+
def test_authentication_middleware_not_inserted_when_disabled(self):
166+
"""RequireAuthenticationMiddleware should be absent when the feature is disabled."""
167+
self.assertNotIn('core.middlewares.middlewares.RequireAuthenticationMiddleware', settings.MIDDLEWARE)

core/settings.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,17 @@
4949
else:
5050
ENABLE_THROTTLING = os.environ.get('ENABLE_THROTTLING', False) in ['true', 'True', 'TRUE', True]
5151

52+
53+
def get_set_from_env(name):
54+
"""Return a trimmed set for comma-separated environment variables."""
55+
return {value.strip() for value in os.environ.get(name, '').split(',') if value.strip()}
56+
57+
58+
REQUIRE_AUTHENTICATION = os.environ.get('REQUIRE_AUTHENTICATION', 'false').lower() in ['true', '1']
59+
APPROVED_ANONYMOUS_CLIENTS = get_set_from_env('APPROVED_ANONYMOUS_CLIENTS')
60+
APPROVED_ANONYMOUS_API_KEYS = get_set_from_env('APPROVED_ANONYMOUS_API_KEYS')
61+
APPROVED_ANONYMOUS_IPS = get_set_from_env('APPROVED_ANONYMOUS_IPS')
62+
5263
ALLOWED_HOSTS = ['*']
5364

5465
CORS_ALLOW_HEADERS = default_headers + (
@@ -199,6 +210,11 @@
199210
}
200211
MIDDLEWARE = [*MIDDLEWARE, 'core.middlewares.middlewares.ThrottleHeadersMiddleware']
201212

213+
if REQUIRE_AUTHENTICATION:
214+
auth_middleware = 'django.contrib.auth.middleware.AuthenticationMiddleware'
215+
auth_index = MIDDLEWARE.index(auth_middleware)
216+
MIDDLEWARE.insert(auth_index + 1, 'core.middlewares.middlewares.RequireAuthenticationMiddleware')
217+
202218

203219
ROOT_URLCONF = 'core.urls'
204220

0 commit comments

Comments
 (0)