Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Multiple API Keys per User #27679

Merged
merged 30 commits into from
Jun 24, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
48571d2
Add HQApiKey model
proteusvacuum May 25, 2020
f1a260d
Add HQApiKeyAuthentication to match against multiple keys
proteusvacuum May 25, 2020
862c2bd
Add ApiKey to Users page in Django Admin
proteusvacuum May 25, 2020
8023ac1
Add new API key view
proteusvacuum May 25, 2020
ccea403
Update doc with correct icon class
proteusvacuum May 25, 2020
9cb9df8
Use custom auth to check IP whitelisting
proteusvacuum May 25, 2020
b18a492
Update import
proteusvacuum Jun 1, 2020
f42412f
Remove old API key button
proteusvacuum Jun 1, 2020
cd7a17c
Make things look a bit nicer
proteusvacuum Jun 1, 2020
d089d2d
Merge remote-tracking branch 'origin/master' into fr/multiple-api-keys
proteusvacuum Jun 5, 2020
2aaa58f
Update tests to use HQApiKey
proteusvacuum Jun 5, 2020
78fea79
Fix multiple import
proteusvacuum Jun 5, 2020
1b117bd
Generate key on create
proteusvacuum Jun 5, 2020
1afc316
ApiKeyFallbackBackend fetch from HQApiKey model
proteusvacuum Jun 5, 2020
6522319
Ooops });
proteusvacuum Jun 5, 2020
541595b
Merge remote-tracking branch 'origin/ce/web-apps-permission' into fr/…
proteusvacuum Jun 8, 2020
764f819
Add merge migration
proteusvacuum Jun 8, 2020
aed49f3
Merge branch 'master' into fr/multiple-api-keys
orangejenny Jun 12, 2020
143d9af
Renamed migration
orangejenny Jun 12, 2020
73e0ace
Revert "Renamed migration"
orangejenny Jun 12, 2020
b540436
Merge branch 'fr/multiple-api-keys+ce/web-apps-permission' into fr/mu…
orangejenny Jun 12, 2020
302de3c
Skip migration on fresh install and add noop down to webappspermissio…
proteusvacuum Jun 17, 2020
46258a0
whitelist -> allowlist
proteusvacuum Jun 17, 2020
27f8309
Rate limit api key usage for each domain
proteusvacuum Jun 17, 2020
816f039
Rate limit per domain per user
proteusvacuum Jun 17, 2020
96abb1f
Merge remote-tracking branch 'origin/master' into fr/multiple-api-keys
proteusvacuum Jun 19, 2020
ad73366
Whitelist -> Allowlist
proteusvacuum Jun 19, 2020
32bfd28
Disallow api keys to have the same name
proteusvacuum Jun 19, 2020
ecc0dd1
Merge remote-tracking branch 'origin/master' into fr/multiple-api-keys
proteusvacuum Jun 22, 2020
64df72d
Merge pull request #27842 from dimagi/fr/rate-limit-domain
proteusvacuum Jun 22, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions corehq/apps/api/odata/tests/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
from django.urls import reverse

from corehq.apps.domain.utils import clear_domain_names
from tastypie.models import ApiKey

from corehq.apps.accounting.models import (
BillingAccount,
Expand All @@ -21,7 +20,7 @@
FormExportInstance,
CaseExportInstance,
ExportColumn, TableConfiguration)
from corehq.apps.users.models import WebUser
from corehq.apps.users.models import HQApiKey, WebUser
from corehq.pillows.mappings.case_mapping import CASE_INDEX_INFO
from corehq.pillows.mappings.xform_mapping import XFORM_INDEX_INFO
from corehq.util.elastic import ensure_index_deleted, reset_es_index
Expand Down Expand Up @@ -149,7 +148,7 @@ def get_instance(cls, domain_name):


def generate_api_key_from_web_user(web_user):
api_key = ApiKey.objects.get_or_create(user=web_user.get_django_user())[0]
api_key = HQApiKey.objects.get_or_create(user=web_user.get_django_user())[0]
api_key.key = api_key.generate_key()
api_key.save()
return api_key
Expand Down
2 changes: 1 addition & 1 deletion corehq/apps/api/resources/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ def dummy(request, domain, **kwargs):
return response

def get_identifier(self, request):
return request.couch_user.username
return f"{request.domain}_{request.couch_user.username}"


class RequirePermissionAuthentication(LoginAndDomainAuthentication):
Expand Down
10 changes: 5 additions & 5 deletions corehq/apps/api/resources/v0_5.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

from memoized import memoized_property
from tastypie import fields, http
from tastypie.authentication import ApiKeyAuthentication
from corehq.apps.domain.auth import HQApiKeyAuthentication
from tastypie.authorization import ReadOnlyAuthorization
from tastypie.bundle import Bundle
from tastypie.exceptions import BadRequest, ImmediateHttpResponse, NotFound
Expand Down Expand Up @@ -813,7 +813,7 @@ class UserDomainsResource(Resource):

class Meta(object):
resource_name = 'user_domains'
authentication = ApiKeyAuthentication()
authentication = HQApiKeyAuthentication()
object_class = UserDomain
include_resource_uri = False

Expand Down Expand Up @@ -860,7 +860,7 @@ class DomainForms(Resource):

class Meta(object):
resource_name = 'domain_forms'
authentication = ApiKeyAuthentication()
authentication = HQApiKeyAuthentication()
object_class = Form
include_resource_uri = False
allowed_methods = ['get']
Expand Down Expand Up @@ -908,7 +908,7 @@ class DomainCases(Resource):

class Meta(object):
resource_name = 'domain_cases'
authentication = ApiKeyAuthentication()
authentication = HQApiKeyAuthentication()
object_class = CaseType
include_resource_uri = False
allowed_methods = ['get']
Expand Down Expand Up @@ -941,7 +941,7 @@ class DomainUsernames(Resource):

class Meta(object):
resource_name = 'domain_usernames'
authentication = ApiKeyAuthentication()
authentication = HQApiKeyAuthentication()
object_class = User
include_resource_uri = False
allowed_methods = ['get']
Expand Down
5 changes: 2 additions & 3 deletions corehq/apps/api/tests/core_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
from django.utils.http import urlencode

from tastypie import fields
from tastypie.models import ApiKey
from tastypie.resources import Resource

from corehq.apps.accounting.models import (
Expand All @@ -22,7 +21,7 @@
from corehq.apps.api.resources import v0_4, v0_5
from corehq.apps.api.util import get_obj
from corehq.apps.domain.models import Domain
from corehq.apps.users.models import CommCareUser, WebUser
from corehq.apps.users.models import CommCareUser, HQApiKey, WebUser

from .utils import APIResourceTest, FakeXFormES

Expand Down Expand Up @@ -476,7 +475,7 @@ def test_wrong_user_api_key(self):
other_user.save()
self.addCleanup(other_user.delete)
django_user = WebUser.get_django_user(other_user)
other_api_key, _ = ApiKey.objects.get_or_create(user=django_user)
other_api_key, _ = HQApiKey.objects.get_or_create(user=django_user)
self.addCleanup(other_api_key.delete)

endpoint = "%s?%s" % (self.single_endpoint(self.user._id),
Expand Down
7 changes: 3 additions & 4 deletions corehq/apps/api/tests/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
from django.utils.http import urlencode

from django_prbac.models import Role
from tastypie.models import ApiKey

from corehq.apps.accounting.models import (
BillingAccount,
Expand All @@ -15,7 +14,7 @@
)
from corehq.apps.api.util import object_does_not_exist
from corehq.apps.domain.models import Domain
from corehq.apps.users.models import WebUser
from corehq.apps.users.models import HQApiKey, WebUser
from corehq.util.test_utils import PatchMeta, flag_enabled


Expand Down Expand Up @@ -88,7 +87,7 @@ def setUpClass(cls):
cls.subscription.is_active = True
cls.subscription.save()

cls.api_key, _ = ApiKey.objects.get_or_create(user=WebUser.get_django_user(cls.user))
cls.api_key, _ = HQApiKey.objects.get_or_create(user=WebUser.get_django_user(cls.user))

@classmethod
def _get_list_endpoint(cls):
Expand Down Expand Up @@ -122,7 +121,7 @@ def _api_url(self, url, username=None):
api_key = self.api_key.key
if username != self.username:
web_user = WebUser.get_by_username(username)
api_key, _ = ApiKey.objects.get_or_create(user=WebUser.get_django_user(web_user))
api_key, _ = HQApiKey.objects.get_or_create(user=WebUser.get_django_user(web_user))
api_key = api_key.key

api_params = urlencode({'username': username, 'api_key': api_key})
Expand Down
60 changes: 56 additions & 4 deletions corehq/apps/domain/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@

from tastypie.authentication import ApiKeyAuthentication

from corehq.toggles import TWO_STAGE_USER_PROVISIONING
from dimagi.utils.django.request import mutable_querydict
from dimagi.utils.web import get_ip

from corehq.apps.receiverwrapper.util import DEMO_SUBMIT_MODE
from corehq.apps.users.models import CouchUser, WebUser, CommCareUser
from corehq.apps.users.models import CouchUser, HQApiKey
from corehq.toggles import TWO_STAGE_USER_PROVISIONING
from corehq.util.hmac_request import validate_request_hmac
from no_exceptions.exceptions import Http400
from python_digest import parse_digest_credentials
Expand All @@ -32,7 +33,7 @@
def _is_api_key_authentication(request):
authorization_header = request.META.get('HTTP_AUTHORIZATION', '')

api_key_authentication = ApiKeyAuthentication()
api_key_authentication = HQApiKeyAuthentication()
try:
username, api_key = api_key_authentication.extract_credentials(request)
except ValueError:
Expand Down Expand Up @@ -205,7 +206,7 @@ def authenticate(self, request, username, password):
return None

try:
user = User.objects.get(username=username, api_key__key=password)
user = User.objects.get(username=username, api_keys__key=password)
except (User.DoesNotExist, User.MultipleObjectsReturned):
return None
else:
Expand Down Expand Up @@ -233,3 +234,54 @@ def get_active_users_by_email(email):
# intentionally excluded:
# - WebUsers who have changed their email address from their login (though could revisit this)
# - CommCareUsers not belonging to domains with TWO_STAGE_USER_PROVISIONING enabled


class HQApiKeyAuthentication(ApiKeyAuthentication):
def is_authenticated(self, request):
"""Follows what tastypie does, then tests for IP whitelisting
"""
try:
username, api_key = self.extract_credentials(request)
except ValueError:
return self._unauthorized()

if not username or not api_key:
return self._unauthorized()

User = get_user_model()

lookup_kwargs = {User.USERNAME_FIELD: username}
try:
user = User.objects.prefetch_related("api_keys").get(**lookup_kwargs)
except (User.DoesNotExist, User.MultipleObjectsReturned):
return self._unauthorized()

if not self.check_active(user):
return False

# ensure API Key exists
try:
key = user.api_keys.get(key=api_key)
except HQApiKey.DoesNotExist:
return self._unauthorized()

# ensure the IP address is in the allowlist, if that exists
if key.ip_allowlist and (get_ip(request) not in key.ip_allowlist):
return self._unauthorized()

request.user = user

return True

def get_identifier(self, request):
"""Returns {domain}_{api_key} for use in rate limiting api key.

Each api key can currently be used on multiple domains, and rates
are domain specific.

"""
try:
api_key = self.extract_credentials(request)[1]
except ValueError:
api_key = ''
return f"{request.domain}_{api_key}"
14 changes: 2 additions & 12 deletions corehq/apps/domain/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,8 @@

from django_otp import match_token
from django_prbac.utils import has_privilege
from tastypie.authentication import ApiKeyAuthentication
from corehq.apps.domain.auth import HQApiKeyAuthentication
from tastypie.http import HttpUnauthorized
from tastypie.models import ApiKey

from dimagi.utils.web import get_ip

from dimagi.utils.django.request import mutable_querydict
from dimagi.utils.web import json_response
Expand All @@ -44,7 +41,6 @@
)
from corehq.apps.domain.models import Domain, DomainAuditRecordEntry
from corehq.apps.domain.utils import normalize_domain_name
from corehq.apps.hqwebapp.models import ApiKeySettings
from corehq.apps.hqwebapp.signals import clear_login_attempts
from corehq.apps.users.models import CouchUser
from corehq.toggles import (
Expand Down Expand Up @@ -179,20 +175,14 @@ def dispatch(self, *args, **kwargs):


def api_key():
api_auth_class = ApiKeyAuthentication()
api_auth_class = HQApiKeyAuthentication()

def real_decorator(view):
def wrapper(request, *args, **kwargs):
auth = api_auth_class.is_authenticated(request)
if auth:
if isinstance(auth, HttpUnauthorized):
return auth
try:
allowed_ips = request.user.api_key.apikeysettings.ip_whitelist
except (ApiKey.DoesNotExist, ApiKeySettings.DoesNotExist):
allowed_ips = []
if allowed_ips and get_ip(request) not in allowed_ips:
return HttpUnauthorized()
return view(request, *args, **kwargs)

response = HttpUnauthorized()
Expand Down
21 changes: 0 additions & 21 deletions corehq/apps/hqwebapp/admin.py

This file was deleted.

5 changes: 2 additions & 3 deletions corehq/apps/linked_domain/tests/test_linked_case_claim.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import json

from mock import patch
from tastypie.models import ApiKey

from corehq.apps.case_search.models import (
CaseSearchConfig,
Expand All @@ -13,7 +12,7 @@
from corehq.apps.linked_domain.decorators import REMOTE_REQUESTER_HEADER
from corehq.apps.linked_domain.tests.test_linked_apps import BaseLinkedAppsTest
from corehq.apps.linked_domain.updates import update_case_search_config
from corehq.apps.users.models import WebUser
from corehq.apps.users.models import HQApiKey, WebUser
from corehq.util import reverse


Expand Down Expand Up @@ -82,7 +81,7 @@ def setUpClass(cls):
cls.domain_obj = create_domain(cls.domain)
cls.couch_user = WebUser.create(cls.domain, "test", "foobar", None, None)
cls.django_user = cls.couch_user.get_django_user()
cls.api_key, _ = ApiKey.objects.get_or_create(user=cls.django_user)
cls.api_key, _ = HQApiKey.objects.get_or_create(user=cls.django_user)
cls.auth_headers = {'HTTP_AUTHORIZATION': 'apikey test:%s' % cls.api_key.key}
cls.domain_link.save()

Expand Down
5 changes: 2 additions & 3 deletions corehq/apps/linked_domain/tests/test_linked_userreports.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import json

from mock import patch
from tastypie.models import ApiKey

from dimagi.utils.couch.undo import is_deleted, soft_delete

Expand All @@ -21,7 +20,7 @@
get_sample_report_config,
)
from corehq.apps.users.dbaccessors.all_commcare_users import delete_all_users
from corehq.apps.users.models import WebUser
from corehq.apps.users.models import HQApiKey, WebUser
from corehq.util import reverse


Expand Down Expand Up @@ -140,7 +139,7 @@ def test_remote_link_ucr(self, fake_ucr_getter):
django_user = couch_user.get_django_user()
self.addCleanup(delete_all_users)

api_key, _ = ApiKey.objects.get_or_create(user=django_user)
api_key, _ = HQApiKey.objects.get_or_create(user=django_user)
auth_headers = {'HTTP_AUTHORIZATION': 'apikey test:%s' % api_key.key}
self.domain_link.save()

Expand Down
5 changes: 2 additions & 3 deletions corehq/apps/linked_domain/tests/test_remote_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,11 @@

from django.test import TestCase

from tastypie.models import ApiKey

from corehq.apps.domain.shortcuts import create_domain
from corehq.apps.linked_domain.decorators import REMOTE_REQUESTER_HEADER
from corehq.apps.linked_domain.models import DomainLink
from corehq.apps.users.models import WebUser
from corehq.apps.users.models import HQApiKey, WebUser
from corehq.util import reverse
from corehq.util.view_utils import absolute_reverse

Expand All @@ -24,7 +23,7 @@ def setUpClass(cls):
cls.domain = create_domain(cls.master_domain)
cls.couch_user = WebUser.create(cls.master_domain, "test", "foobar", None, None)
cls.django_user = cls.couch_user.get_django_user()
cls.api_key, _ = ApiKey.objects.get_or_create(user=cls.django_user)
cls.api_key, _ = HQApiKey.objects.get_or_create(user=cls.django_user)

cls.auth_headers = {'HTTP_AUTHORIZATION': 'apikey test:%s' % cls.api_key.key}

Expand Down
2 changes: 2 additions & 0 deletions corehq/apps/settings/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
class DuplicateApiKeyName(Exception):
pass
Loading