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

Add ODKToken Authentication Method #1705

Merged
merged 47 commits into from
Oct 25, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
58155ad
Add ODKToken Model
DavisRayM Oct 4, 2019
199d0d0
Add odk_token action in ConnectViewSet
DavisRayM Oct 4, 2019
7135c77
Fix failing test
DavisRayM Oct 7, 2019
730cedf
Add a post save hook on ODKToken Model to create partial digests
DavisRayM Oct 9, 2019
330f077
Make the ODK Token Key length configurable through settings variable …
DavisRayM Oct 9, 2019
f9d6cd2
Add Test for DigestAuthentication using email and ODK Token
DavisRayM Oct 9, 2019
379175d
Add test for ODK Token Model
DavisRayM Oct 9, 2019
66807db
Fix inaccurate test
DavisRayM Oct 9, 2019
62bd0e6
Minor code changes
DavisRayM Oct 18, 2019
8f380d4
Modify ODKToken Model
DavisRayM Oct 18, 2019
fa9cf29
Update TestODKToken tests
DavisRayM Oct 18, 2019
72a93f6
Modify test_authenticate_odk_token_email test
DavisRayM Oct 18, 2019
9abddb8
Store key as string not byte
DavisRayM Oct 18, 2019
105548d
Change odk_token endpoint
DavisRayM Oct 18, 2019
3fd3673
Add status field to ODKToken model
DavisRayM Oct 18, 2019
86838d7
Set default status for an ODKToken to Active
DavisRayM Oct 18, 2019
79157f6
Update travis test settings
DavisRayM Oct 18, 2019
0ad21c5
Revert Travis Test Settings
DavisRayM Oct 18, 2019
310f5ce
Use ugettext_lazy
DavisRayM Oct 18, 2019
cfc49ae
Modify ODK Token Module Docstring
DavisRayM Oct 22, 2019
7c8d44a
Create DigestAccountStorage
DavisRayM Oct 22, 2019
c0f2576
Use DigestAccountStorage as default Account Storage for Digest Authen…
DavisRayM Oct 22, 2019
48548e2
Fix Failing Test
DavisRayM Oct 22, 2019
8ed16ad
Modify Connect Viewset ODK Token Endpoint
DavisRayM Oct 22, 2019
2a630ae
Install Cryptography package
DavisRayM Oct 22, 2019
f6d87d7
Minor Code Cleanup
DavisRayM Oct 22, 2019
164dc89
Add ODK_TOKEN_FERNET_KEY setting to travis_test settings
DavisRayM Oct 22, 2019
2e2f8ba
Modify TestDigestAuthentication
DavisRayM Oct 22, 2019
2622e17
Modify odk_token endpont
DavisRayM Oct 22, 2019
27a1581
Call correct functions
DavisRayM Oct 22, 2019
1a3ee93
Add status_display field
DavisRayM Oct 22, 2019
2102220
Add new test to TestConnectViewSet
DavisRayM Oct 22, 2019
f3fcfb6
Silence Pylint warning
DavisRayM Oct 22, 2019
87ba7e0
Remove unused import
DavisRayM Oct 22, 2019
49f5d06
Silence pylint 'no-self-use' warning
DavisRayM Oct 22, 2019
277b03e
Fix Failing Tests
DavisRayM Oct 22, 2019
dd20472
Remove strict setting of digest authentication account storage
DavisRayM Oct 23, 2019
f395a33
Fix issue on checking for odk_token_expiry
DavisRayM Oct 23, 2019
5847d74
Fix an error raised when an ODK Token has expired
DavisRayM Oct 23, 2019
84a8605
Fix failing test
DavisRayM Oct 23, 2019
150685e
Remove get_user() method from ODKTokenAccountStorage
DavisRayM Oct 25, 2019
61d32ac
Update last_login of a User on successful DigestAuthentication
DavisRayM Oct 25, 2019
fba6dd6
Add new ODKToken model properties; expires and raw_key
DavisRayM Oct 25, 2019
48a7b32
Remove ability to change ODKToken Status
DavisRayM Oct 25, 2019
e35c0c8
Remove _check_odk_token_expiry funtion
DavisRayM Oct 25, 2019
bed57c2
Change ODKToken user field to a ForeignKey Field
DavisRayM Oct 25, 2019
ba95f94
Test that old ODKToken objects are set to Inactive after regeneration
DavisRayM Oct 25, 2019
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
29 changes: 29 additions & 0 deletions onadata/apps/api/migrations/0005_auto_20191018_0735.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Generated by Django 2.2.1 on 2019-10-18 11:35

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('api', '0004_auto_20190125_0517'),
]

operations = [
migrations.AlterModelOptions(
name='team',
options={},
),
migrations.CreateModel(
name='ODKToken',
fields=[
('key', models.CharField(max_length=150, primary_key=True, serialize=False)),
('status', models.CharField(choices=[('1', 'Active'), ('2', 'Inactive')], default='1', max_length=1, verbose_name='Status')),
('created', models.DateTimeField(auto_now_add=True)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='odk_token', to=settings.AUTH_USER_MODEL)),
],
),
]
20 changes: 20 additions & 0 deletions onadata/apps/api/migrations/0006_auto_20191025_0730.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Generated by Django 2.1.7 on 2019-10-25 11:30

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
('api', '0005_auto_20191018_0735'),
]

operations = [
migrations.AlterField(
model_name='odktoken',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
),
]
103 changes: 103 additions & 0 deletions onadata/apps/api/models/odk_token.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""
ODK token model module
"""
import binascii
import os
from datetime import timedelta

from django.conf import settings
from django.db import models
from django.db.models.signals import post_save
from django.utils.translation import ugettext_lazy as _

from cryptography.fernet import Fernet
from django_digest.models import (_persist_partial_digests,
_prepare_partial_digests)

AUTH_USER_MODEL = getattr(settings, 'AUTH_USER_MODEL', 'auth.User')
ODK_TOKEN_LENGTH = getattr(settings, 'ODK_TOKEN_LENGTH', 7)
ODK_TOKEN_FERNET_KEY = getattr(settings, 'ODK_TOKEN_FERNET_KEY')
ODK_TOKEN_LIFETIME = getattr(settings, "ODK_KEY_LIFETIME", 7)


class ODKToken(models.Model):
"""
ODK Token class
"""
ACTIVE = '1'
INACTIVE = '2'
STATUS_CHOICES = (
(ACTIVE, _('Active')),
(INACTIVE, _('Inactive'))
)

key = models.CharField(max_length=150, primary_key=True)
user = models.ForeignKey(
AUTH_USER_MODEL, on_delete=models.CASCADE)
status = models.CharField(
'Status',
choices=STATUS_CHOICES,
default=ACTIVE,
max_length=1)
created = models.DateTimeField(auto_now_add=True)
DavisRayM marked this conversation as resolved.
Show resolved Hide resolved

class Meta:
app_label = 'api'

def _generate_partial_digest(self, raw_key):
"""
Generates the partial digests for ODK Authentication
"""
_prepare_partial_digests(self.user, raw_key)

def check_key(self, key):
"""
Check that the passed in key matches the stored hashed key
"""
return self.raw_key == key

def save(self, *args, **kwargs): # pylint: disable=arguments-differ
if not self.key:
self.key = self.generate_key()
return super(ODKToken, self).save(*args, **kwargs)

def generate_key(self):
key = binascii.hexlify(os.urandom(ODK_TOKEN_LENGTH)).decode()
self._generate_partial_digest(key)
return _encrypt_key(key)

def __str__(self):
return self.key

@property
def expires(self):
"""
This property holds the datetime of when the Token expires
"""
return self.created + timedelta(days=ODK_TOKEN_LIFETIME)

@property
def raw_key(self):
"""
Decrypts the key and returns it in its Raw Form
"""
fernet = Fernet(ODK_TOKEN_FERNET_KEY)
return fernet.decrypt(self.key.encode('utf-8'))


def _encrypt_key(raw_key):
"""
Encrypts the ODK Token using the ODK_TOKEN_SECRET through
the fernet cryptography scheme
"""
fernet = Fernet(ODK_TOKEN_FERNET_KEY)
return fernet.encrypt(raw_key.encode('utf-8')).decode('utf-8')


def _post_save_persist_partial_digests(sender, instance=None, **kwargs):
if instance:
_persist_partial_digests(instance.user)


post_save.connect(
_post_save_persist_partial_digests, sender=ODKToken)
65 changes: 65 additions & 0 deletions onadata/apps/api/storage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""
Backends module for th API app
"""
import logging

from django.conf import settings
from django.db import connection
from django.utils import timezone

from django_digest.backend.storage import AccountStorage

from onadata.apps.api.models.odk_token import ODKToken

_l = logging.getLogger(__name__)
_l.setLevel(logging.DEBUG)

ODK_KEY_LIFETIME_IN_SEC = getattr(settings, 'ODK_KEY_LIFETIME', 7) * 86400


class ODKTokenAccountStorage(AccountStorage):
"""
Digest Account Backend class

In order to utilize this storage as the default account storage for
Digest Authentication set the DIGEST_ACCOUNT_BACKEND variable in
your local_settings to 'onadata.apps.api.storage.ODKTokenAccountStorage'
"""
GET_PARTIAL_DIGEST_QUERY = f"""
SELECT django_digest_partialdigest.login,
django_digest_partialdigest.partial_digest
FROM django_digest_partialdigest
INNER JOIN auth_user ON
auth_user.id = django_digest_partialdigest.user_id
INNER JOIN api_odktoken ON
api_odktoken.user_id = django_digest_partialdigest.user_id
WHERE django_digest_partialdigest.login = %s
AND django_digest_partialdigest.confirmed
AND auth_user.is_active
AND api_odktoken.status='{ODKToken.ACTIVE}'
"""

def get_partial_digest(self, username):
"""
Checks that the returned partial digest is associated with a
Token that isn't past it's expire date.

Sets an ODK Token to Inactive if the associate token has passed
its expiry date
"""
cursor = connection.cursor()
cursor.execute(self.GET_PARTIAL_DIGEST_QUERY, [username])
# In MySQL, string comparison is case-insensitive by default.
# Therefore a second round of filtering is required.
partial_digest = [(row[1]) for row in cursor.fetchall()
if row[0] == username]
if not partial_digest:
return None

token = ODKToken.objects.get(user__username=username)

if timezone.now() > token.expires:
token.status = ODKToken.INACTIVE
return None

return partial_digest
19 changes: 19 additions & 0 deletions onadata/apps/api/tests/models/test_odk_token.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"""
Test ODK Token module
"""
from onadata.apps.api.models.odk_token import ODKToken
from onadata.apps.api.tests.models.test_abstract_models import \
TestAbstractModels


class TestODKToken(TestAbstractModels):
def test_create_odk_token(self):
"""
Test that ODK Tokens can be created
"""
self._create_user_and_login()
initial_count = ODKToken.objects.count()

ODKToken.objects.create(user=self.user)

self.assertEqual(initial_count + 1, ODKToken.objects.count())
60 changes: 60 additions & 0 deletions onadata/apps/api/tests/viewsets/test_connect_viewset.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from rest_framework.authtoken.models import Token

from onadata.apps.api.models.temp_token import TempToken
from onadata.apps.api.models.odk_token import ODKToken
from onadata.apps.api.tests.viewsets.test_abstract_viewset import \
TestAbstractViewSet
from onadata.apps.api.viewsets.connect_viewset import ConnectViewSet
Expand Down Expand Up @@ -480,3 +481,62 @@ def test_login_attempts(self, send_account_lockout_email):
# clear cache
cache.delete(safe_key("login_attempts-bob"))
cache.delete(safe_key("lockout_user-bob"))

def test_generate_odk_token(self):
"""
Test that ODK Tokens can be created
"""
view = ConnectViewSet.as_view({'post': 'odk_token'})
request = self.factory.post('/', **self.extra)
request.session = self.client.session
response = view(request)
self.assertEqual(response.status_code, 201)

def test_regenerate_odk_token(self):
"""
Test that ODK Tokens can be regenerated and old tokens
are set to Inactive after regeneration
"""
view = ConnectViewSet.as_view({'post': 'odk_token'})
request = self.factory.post('/', **self.extra)
request.session = self.client.session
response = view(request)
self.assertEqual(response.status_code, 201)
old_token = response.data['odk_token']

with self.assertRaises(ODKToken.DoesNotExist):
ODKToken.objects.get(
user=self.user, status=ODKToken.INACTIVE)

request = self.factory.post('/', **self.extra)
request.session = self.client.session
response = view(request)
self.assertEqual(response.status_code, 201)
self.assertNotEqual(response.data['odk_token'], old_token)

# Test that the previous token was set to inactive
inactive_token = ODKToken.objects.get(
user=self.user, status=ODKToken.INACTIVE)
self.assertEqual(inactive_token.raw_key, old_token)

def test_retrieve_odk_token(self):
"""
Test that ODK Tokens can be retrieved
"""
view = ConnectViewSet.as_view({
'post': 'odk_token',
'get': 'odk_token'
})
request = self.factory.post('/', **self.extra)
request.session = self.client.session
response = view(request)
self.assertEqual(response.status_code, 201)
odk_token = response.data['odk_token']
expires = response.data['expires']

request = self.factory.get('/', **self.extra)
request.session = self.client.session
response = view(request)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['odk_token'], odk_token)
self.assertEqual(response.data['expires'], expires)
34 changes: 34 additions & 0 deletions onadata/apps/api/viewsets/connect_viewset.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from django.utils import timezone
from django.utils.decorators import classonlymethod
from django.utils.translation import ugettext as _
from django.views.decorators.cache import never_cache
Expand All @@ -8,6 +9,7 @@
from rest_framework.response import Response
from rest_framework import mixins

from onadata.apps.api.models.odk_token import ODKToken
from onadata.apps.api.models.temp_token import TempToken
from onadata.apps.api.permissions import ConnectViewsetPermissions
from onadata.apps.main.models.user_profile import UserProfile
Expand Down Expand Up @@ -114,6 +116,38 @@ def regenerate_auth_token(self, request, *args, **kwargs):

return Response(data=new_token.key, status=status.HTTP_201_CREATED)

@action(methods=['GET', 'POST'], detail=False)
def odk_token(self, request, *args, **kwargs):
user = request.user

if request.method == 'GET':
token, created = ODKToken.objects.get_or_create(
user=user, status=ODKToken.ACTIVE)

if not created and timezone.now() > token.expires:
token.status = ODKToken.INACTIVE
token.save()
token = ODKToken.objects.create(user=user)

return Response(data={
'odk_token': token.raw_key,
'expires': token.expires}, status=status.HTTP_200_OK)
elif request.method == 'POST':
# Regenerates the ODK Token if one is already existant
try:
old_token = ODKToken.objects.get(user=user)
old_token.status = ODKToken.INACTIVE
old_token.save()
except ODKToken.DoesNotExist:
pass

token = ODKToken.objects.create(user=user)

return Response(data={
'odk_token': token.raw_key,
'expires': token.expires
}, status=status.HTTP_201_CREATED)

@classonlymethod
def as_view(cls, actions=None, **initkwargs):
view = super(ConnectViewSet, cls).as_view(actions, **initkwargs)
Expand Down
Loading