Skip to content

Commit 6b305bb

Browse files
authored
Merge pull request #36044 from dimagi/es/ip-blocking
Restrict project access by IP address
2 parents 66547a0 + d28b6c1 commit 6b305bb

30 files changed

+677
-24
lines changed

corehq/apps/domain/deletion.py

+1
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,7 @@ def _delete_demo_user_restores(domain_name):
409409
ModelDeletion('integration', 'SimprintsIntegration', 'domain'),
410410
ModelDeletion('integration', 'KycConfig', 'domain'),
411411
ModelDeletion('integration', 'MoMoConfig', 'domain'),
412+
ModelDeletion('ip_access', 'IPAccessConfig', 'domain'),
412413
ModelDeletion('linked_domain', 'DomainLink', 'linked_domain', ['DomainLinkHistory']),
413414
CustomDeletion('scheduling', _delete_sms_content_events_schedules, [
414415
'SMSContent', 'EmailContent', 'SMSSurveyContent',

corehq/apps/domain/forms.py

+87
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import datetime
22
import io
3+
import ipaddress
34
import json
45
import logging
56
import uuid
@@ -222,6 +223,92 @@ def save(self, user, domain):
222223
return True
223224

224225

226+
class IPAccessConfigForm(forms.Form):
227+
"""
228+
Form for updating a project's IP Access Configuration
229+
"""
230+
country_allowlist = forms.MultipleChoiceField(
231+
label=_("Allowed Countries"),
232+
choices=sorted(list(COUNTRIES.items()), key=lambda x: x[1]),
233+
required=False,
234+
)
235+
236+
ip_allowlist = forms.CharField(
237+
label=_("Allowed IPs"),
238+
required=False,
239+
help_text='IPs that will be allowed access to your project, regardless of country of origin. '
240+
'Please configure your list to be comma and space separated, '
241+
'e.g. 192.168.0.1, 192.168.1.1, 192.168.2.1',
242+
)
243+
244+
ip_denylist = forms.CharField(
245+
label=_("Denied IPs"),
246+
required=False,
247+
help_text='IPs that will be denied access to your project, regardless of country of origin.',
248+
)
249+
250+
comment = forms.CharField(
251+
label=_("Additional Notes"),
252+
widget=forms.Textarea(attrs={"class": "vertical-resize"}),
253+
required=False
254+
)
255+
256+
def __init__(self, *args, **kwargs):
257+
self.current_ip = kwargs.pop('current_ip', None)
258+
self.current_country = kwargs.pop('current_country', None)
259+
super(IPAccessConfigForm, self).__init__(*args, **kwargs)
260+
self.helper = hqcrispy.HQFormHelper(self)
261+
self.helper.form_id = 'ip-access-config-form'
262+
self.helper.layout = crispy.Layout(
263+
crispy.Fieldset(
264+
_("Edit IP Access Config"),
265+
"country_allowlist",
266+
"ip_allowlist",
267+
"ip_denylist",
268+
"comment"
269+
),
270+
hqcrispy.FormActions(
271+
StrictButton(
272+
_("Update IP Access Config"),
273+
type="submit",
274+
css_class='btn-primary',
275+
)
276+
)
277+
)
278+
279+
def clean(self):
280+
allow_list = self.cleaned_data['ip_allowlist'].split(", ") if self.cleaned_data['ip_allowlist'] else []
281+
deny_list = self.cleaned_data['ip_denylist'].split(", ") if self.cleaned_data['ip_denylist'] else []
282+
283+
# Ensure an IP isn't in both lists
284+
if (allow_list and deny_list) and set(allow_list).intersection(set(deny_list)):
285+
self.add_error('ip_allowlist', _("There are IP addresses in both the Allowed and Denied lists. "
286+
"Please ensure an IP address is only in one list at a time."))
287+
288+
# Ensure inputs are valid IPs, checks both IPv4 and IPv6
289+
for ip in allow_list + deny_list:
290+
try:
291+
ipaddress.ip_address(ip)
292+
except ValueError as e:
293+
raise ValidationError(e)
294+
295+
self.cleaned_data['ip_allowlist'] = allow_list
296+
self.cleaned_data['ip_denylist'] = deny_list
297+
298+
# Additional validation
299+
if self.cleaned_data['country_allowlist']:
300+
if not settings.MAXMIND_LICENSE_KEY:
301+
self.add_error('country_allowlist', _("The Allowed Countries field cannot be saved because "
302+
"MaxMind is not configured for your environment"))
303+
elif (self.current_country and self.current_country not in self.cleaned_data['country_allowlist']
304+
and self.current_ip not in self.cleaned_data['ip_allowlist']):
305+
self.add_error('country_allowlist', _("Please add your own country or IP to the Allowed IPs field "
306+
"to avoid being locked out."))
307+
if self.current_ip in self.cleaned_data['ip_denylist']:
308+
self.add_error('ip_denylist', _("You cannot put your current IP address in the Denied IPs field"))
309+
return self.cleaned_data
310+
311+
225312
class TransferDomainFormErrors(object):
226313
USER_DNE = gettext_lazy('The user being transferred to does not exist')
227314
DOMAIN_MISMATCH = gettext_lazy('Mismatch in domains when confirming')
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import "hqwebapp/js/htmx_and_alpine";
2+
import $ from 'jquery';
3+
import 'select2/dist/js/select2.full.min';
4+
5+
$(function () {
6+
$("#id_country_allowlist").select2();
7+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{% extends "hqwebapp/bootstrap5/base_section.html" %}
2+
{% load hq_shared_tags %}
3+
{% load crispy_forms_tags %}
4+
{% load i18n %}
5+
6+
{% js_entry "domain/js/ip_access_config" %}
7+
8+
{% block page_content %}
9+
{% crispy ip_access_config_form %}
10+
{% endblock %}

corehq/apps/domain/urls.py

+2
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767
DefaultProjectSettingsView,
6868
EditBasicProjectInfoView,
6969
EditDomainAlertView,
70+
EditIPAccessConfigView,
7071
EditMyProjectSettingsView,
7172
EditPrivacySecurityView,
7273
FeaturePreviewsView,
@@ -142,6 +143,7 @@
142143
url(r'^call_center_owner_options/', CallCenterOwnerOptionsView.as_view(),
143144
name=CallCenterOwnerOptionsView.url_name),
144145
url(r'^privacy/$', EditPrivacySecurityView.as_view(), name=EditPrivacySecurityView.urlname),
146+
url(r'^ip_access/$', EditIPAccessConfigView.as_view(), name=EditIPAccessConfigView.urlname),
145147
url(r'^subscription/change/$', SelectPlanView.as_view(), name=SelectPlanView.urlname),
146148
url(r'^subscription/change/confirm/$', ConfirmSelectedPlanView.as_view(),
147149
name=ConfirmSelectedPlanView.urlname),

corehq/apps/domain/views/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
EditBasicProjectInfoView,
5757
EditMyProjectSettingsView,
5858
EditPrivacySecurityView,
59+
EditIPAccessConfigView,
5960
FeaturePreviewsView,
6061
CustomPasswordResetView,
6162
RecoveryMeasuresHistory,

corehq/apps/domain/views/settings.py

+74-1
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424

2525
from corehq.apps.accounting.decorators import always_allow_project_access
2626
from corehq.apps.enterprise.mixins import ManageMobileWorkersMixin
27-
from dimagi.utils.web import json_response
27+
from dimagi.utils.web import json_response, get_ip
2828

2929
from corehq import feature_previews, privileges, toggles
3030
from corehq.apps.app_manager.dbaccessors import get_apps_in_domain
@@ -50,13 +50,15 @@
5050
DomainMetadataForm,
5151
PrivacySecurityForm,
5252
ProjectSettingsForm,
53+
IPAccessConfigForm,
5354
clean_password
5455
)
5556
from corehq.apps.domain.models import Domain
5657
from corehq.apps.domain.views.base import BaseDomainView
5758
from corehq.apps.hqwebapp.decorators import use_bootstrap5
5859
from corehq.apps.hqwebapp.models import Alert
5960
from corehq.apps.hqwebapp.signals import clear_login_attempts
61+
from corehq.apps.ip_access.models import IPAccessConfig, get_ip_country
6062
from corehq.apps.locations.permissions import location_safe
6163
from corehq.apps.ota.models import MobileRecoveryMeasure
6264
from corehq.apps.users.decorators import require_can_manage_domain_alerts
@@ -271,6 +273,77 @@ def post(self, request, *args, **kwargs):
271273
return self.get(request, *args, **kwargs)
272274

273275

276+
@method_decorator([
277+
domain_admin_required,
278+
use_bootstrap5,
279+
toggles.IP_ACCESS_CONTROLS.required_decorator(),
280+
], name='dispatch')
281+
class EditIPAccessConfigView(BaseProjectSettingsView):
282+
template_name = 'domain/admin/ip_access_config.html'
283+
urlname = 'ip_access_config'
284+
page_title = gettext_lazy("IP Access")
285+
286+
@property
287+
@memoized
288+
def form(self):
289+
initial = {}
290+
domain_config = self.ip_access_config
291+
if domain_config:
292+
display_spacer = ", "
293+
initial.update({
294+
'country_allowlist': domain_config.country_allowlist,
295+
'ip_allowlist': display_spacer.join(domain_config.ip_allowlist),
296+
'ip_denylist': display_spacer.join(domain_config.ip_denylist),
297+
'comment': domain_config.comment
298+
})
299+
if self.request.method == 'POST':
300+
return IPAccessConfigForm(self.request.POST, initial=initial,
301+
current_ip=self.current_ip, current_country=self.ip_country)
302+
return IPAccessConfigForm(initial=initial)
303+
304+
@cached_property
305+
def ip_access_config(self):
306+
try:
307+
return IPAccessConfig.objects.get(domain=self.domain)
308+
except IPAccessConfig.DoesNotExist:
309+
return None
310+
311+
@cached_property
312+
def ip_country(self):
313+
if settings.MAXMIND_LICENSE_KEY:
314+
return get_ip_country(self.current_ip)
315+
return None
316+
317+
@property
318+
def current_ip(self):
319+
return get_ip(self.request)
320+
321+
@property
322+
def page_context(self):
323+
return {
324+
'ip_access_config_form': self.form,
325+
}
326+
327+
def post(self, request, *args, **kwargs):
328+
if self.form.is_valid():
329+
domain_config = self.ip_access_config
330+
should_save = True
331+
if not domain_config:
332+
should_save = False
333+
domain_config = IPAccessConfig()
334+
domain_config.domain = self.domain
335+
for attr, value in self.form.cleaned_data.items():
336+
current_value = getattr(domain_config, attr)
337+
if value != current_value:
338+
should_save = True
339+
setattr(domain_config, attr, value)
340+
if should_save:
341+
domain_config.save()
342+
messages.success(request, _("Your IP Access settings have been saved!"))
343+
return HttpResponseRedirect(reverse(self.urlname, args=[self.domain]))
344+
return self.get(request, *args, **kwargs)
345+
346+
274347
@location_safe
275348
def logo(request, domain):
276349
logo = Domain.get_by_name(domain).get_custom_logo()

corehq/apps/dump_reload/sql/dump.py

+1
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@
157157
FilteredModelIteratorBuilder('integration.SimprintsIntegration', SimpleFilter('domain')),
158158
FilteredModelIteratorBuilder('integration.KycConfig', SimpleFilter('domain')),
159159
FilteredModelIteratorBuilder('integration.MoMoConfig', SimpleFilter('domain')),
160+
FilteredModelIteratorBuilder('ip_access.IPAccessConfig', SimpleFilter('domain')),
160161
FilteredModelIteratorBuilder('phonelog.DeviceReportEntry', SimpleFilter('domain')),
161162
FilteredModelIteratorBuilder('phonelog.ForceCloseEntry', SimpleFilter('domain')),
162163
FilteredModelIteratorBuilder('phonelog.UserErrorEntry', SimpleFilter('domain')),

corehq/apps/hqwebapp/views.py

+19-13
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,12 @@
100100
from corehq.toggles import CLOUDCARE_LATEST_BUILD
101101
from corehq.util.context_processors import commcare_hq_names
102102
from corehq.util.email_event_utils import handle_email_sns_event
103-
from corehq.util.metrics import create_metrics_event, metrics_counter, metrics_gauge
103+
from corehq.util.metrics import (
104+
create_metrics_event,
105+
limit_domains,
106+
metrics_counter,
107+
metrics_gauge,
108+
)
104109
from corehq.util.metrics.const import TAG_UNKNOWN, MPM_MAX
105110
from corehq.util.metrics.utils import sanitize_url
106111
from corehq.util.public_only_requests.public_only_requests import get_public_only_session
@@ -114,7 +119,6 @@
114119
from dimagi.utils.django.request import mutable_querydict
115120
from dimagi.utils.logging import notify_exception, notify_error
116121
from dimagi.utils.web import get_url_base
117-
from no_exceptions.exceptions import Http403
118122
from soil import DownloadBase
119123
from soil import views as soil_views
120124

@@ -332,8 +336,8 @@ def server_up(req):
332336

333337

334338
@use_bootstrap5
335-
def _no_permissions_message(request, template_name="403.html", message=None):
336-
t = loader.get_template(template_name)
339+
def _no_permissions_message(request, message=None):
340+
t = loader.get_template("403.html")
337341
return t.render(
338342
context={
339343
'MEDIA_URL': settings.MEDIA_URL,
@@ -345,16 +349,18 @@ def _no_permissions_message(request, template_name="403.html", message=None):
345349

346350

347351
@use_bootstrap5
348-
def no_permissions(request, redirect_to=None, template_name="403.html", message=None, exception=None):
349-
"""
350-
403 error handler.
351-
"""
352-
return HttpResponseForbidden(_no_permissions_message(request, template_name, message))
352+
def no_permissions(request, redirect_to=None, message=None, exception=None):
353+
"""403 error handler. Called automatically by Django on PermissionDenied
353354
354-
355-
@use_bootstrap5
356-
def no_permissions_exception(request, template_name="403.html", message=None):
357-
return Http403(_no_permissions_message(request, template_name, message))
355+
:param exception: Instance of PermissionDenied or subclass. Class name will
356+
be reported to datadog.
357+
"""
358+
metrics_counter('commcare.no_permissions.count', tags={
359+
'domain': limit_domains(getattr(request, 'domain', '__other__')),
360+
'exception_type': exception.__class__.__name__ if exception else 'Other',
361+
})
362+
message = message or getattr(exception, 'user_facing_message', None)
363+
return HttpResponseForbidden(_no_permissions_message(request, message))
358364

359365

360366
@use_bootstrap5

corehq/apps/ip_access/__init__.py

Whitespace-only changes.

corehq/apps/ip_access/admin.py

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from django.contrib import admin
2+
3+
from .models import IPAccessConfig
4+
5+
admin.site.register(IPAccessConfig)

corehq/apps/ip_access/middleware.py

+52
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
from django.core.exceptions import PermissionDenied
2+
from django.utils.deprecation import MiddlewareMixin
3+
from django.utils.translation import gettext_lazy as _
4+
5+
from dimagi.utils.web import get_ip
6+
7+
from corehq.toggles import IP_ACCESS_CONTROLS
8+
9+
from .models import IPAccessConfig
10+
11+
12+
class IPPermissionDenied(PermissionDenied):
13+
user_facing_message = _(
14+
"You cannot access this page from your current IP address"
15+
)
16+
17+
18+
class IPAccessMiddleware(MiddlewareMixin):
19+
20+
def process_view(self, request, view_func, view_args, view_kwargs):
21+
if not request.user.is_authenticated or 'domain' not in view_kwargs:
22+
return
23+
24+
domain = view_kwargs['domain']
25+
if IP_ACCESS_CONTROLS.enabled(domain) and not is_valid_ip(request, domain):
26+
raise IPPermissionDenied()
27+
28+
29+
def is_valid_ip(request, domain):
30+
ip = get_ip(request)
31+
block_key = f"hq_session_blocked_ips-{domain}"
32+
allow_key = f"hq_session_ips-{domain}"
33+
if block_key not in request.session:
34+
request.session[block_key] = []
35+
if allow_key not in request.session:
36+
request.session[allow_key] = []
37+
38+
if ip in request.session[block_key]:
39+
return False
40+
if ip in request.session[allow_key]:
41+
return True
42+
43+
try:
44+
config = IPAccessConfig.objects.get(domain=domain)
45+
except IPAccessConfig.DoesNotExist:
46+
config = None
47+
if not config or config.is_allowed(ip):
48+
request.session[allow_key].append(ip)
49+
return True
50+
else:
51+
request.session[block_key].append(ip)
52+
return False

0 commit comments

Comments
 (0)