Skip to content

Commit

Permalink
Move two-factor auth to APIv2.
Browse files Browse the repository at this point in the history
  • Loading branch information
pattisdr committed Jul 19, 2018
1 parent 8075baa commit fd84cd2
Show file tree
Hide file tree
Showing 5 changed files with 249 additions and 2 deletions.
71 changes: 71 additions & 0 deletions api/users/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

from django.utils import timezone
from rest_framework import serializers as ser
from rest_framework import exceptions

from addons.twofactor.models import UserSettings as TwoFactorUserSettings
from api.base.exceptions import InvalidModelValueError
from api.base.serializers import (
BaseAPISerializer, JSONAPISerializer, JSONAPIRelationshipSerializer,
Expand Down Expand Up @@ -252,3 +254,72 @@ def get_absolute_url(self, obj):

class Meta:
type_ = 'institutions'


class UserSettingsSerializer(JSONAPISerializer):
id = IDField(source='_id', read_only=True)
type = TypeField()
two_factor_enabled = ser.SerializerMethodField()

links = LinksField({
'self': 'get_absolute_url'
})

def get_absolute_url(self, obj):
return absolute_reverse(
'users:user-settings',
kwargs={
'user_id': obj._id,
'version': self.context['request'].parser_context['kwargs']['version']
}
)

def get_two_factor_enabled(self, obj):
try:
two_factor = TwoFactorUserSettings.objects.get(owner_id=obj.id)
return not two_factor.deleted
except TwoFactorUserSettings.DoesNotExist:
return False

class Meta:
type_ = 'user-settings'


class UserSettingsUpdateSerializer(UserSettingsSerializer):
id = IDField(source='_id', required=True)
two_factor_enabled = ser.BooleanField(write_only=True, required=False)
two_factor_verification = ser.IntegerField(write_only=True, required=False)

def to_representation(self, instance):
"""
Overriding to_representation allows using different serializers for the request and response.
"""
context = self.context
return UserSettingsSerializer(instance=instance, context=context).data

def update(self, instance, validated_data):
save_user_addon = False
auth = get_user_auth(self.context['request'])
for attr, value in validated_data.items():
user_addon = None
if 'two_factor_enabled' == attr:
if value:
if not instance.has_addon('twofactor'):
user_addon = instance.get_or_add_addon('twofactor')
save_user_addon = True
else:
instance.delete_addon('twofactor', auth=auth)
elif 'two_factor_verification' == attr:
user_addon = user_addon or instance.get_addon('twofactor')
if not user_addon:
raise exceptions.ValidationError(detail='Two-factor authentication is not enabled.')
if user_addon.verify_code(value):
user_addon.is_confirmed = True
save_user_addon = True
else:
raise exceptions.PermissionDenied(detail='The two-factor verification code you provided is invalid.')

if save_user_addon:
user_addon.save()

return instance
1 change: 1 addition & 0 deletions api/users/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
url(r'^(?P<user_id>\w+)/nodes/$', views.UserNodes.as_view(), name=views.UserNodes.view_name),
url(r'^(?P<user_id>\w+)/preprints/$', views.UserPreprints.as_view(), name=views.UserPreprints.view_name),
url(r'^(?P<user_id>\w+)/registrations/$', views.UserRegistrations.as_view(), name=views.UserRegistrations.view_name),
url(r'^(?P<user_id>\w+)/settings/$', views.UserSettings.as_view(), name=views.UserSettings.view_name),
url(r'^(?P<user_id>\w+)/quickfiles/$', views.UserQuickFiles.as_view(), name=views.UserQuickFiles.view_name),
url(r'^(?P<user_id>\w+)/relationships/institutions/$', views.UserInstitutionsRelationship.as_view(), name=views.UserInstitutionsRelationship.view_name),
]
30 changes: 30 additions & 0 deletions api/users/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
UserDetailSerializer,
UserInstitutionsRelationshipSerializer,
UserSerializer,
UserSettingsSerializer,
UserSettingsUpdateSerializer,
UserQuickFilesSerializer,
ReadEmailUserDetailSerializer,)
from django.contrib.auth.models import AnonymousUser
Expand Down Expand Up @@ -436,3 +438,31 @@ def perform_destroy(self, instance):
if val['id'] in current_institutions:
user.remove_institution(val['id'])
user.save()


class UserSettings(JSONAPIBaseView, generics.RetrieveUpdateAPIView, UserMixin):
permission_classes = (
drf_permissions.IsAuthenticatedOrReadOnly,
base_permissions.TokenHasScope,
CurrentUser,
)

required_read_scopes = [CoreScopes.USER_SETTINGS_READ]
required_write_scopes = [CoreScopes.USER_SETTINGS_WRITE]

view_category = 'users'
view_name = 'user-settings'

serializer_class = UserSettingsSerializer
# overrides RetrieveUpdateAPIView
def get_serializer_class(self):
"""
Use NodeDetailSerializer which requires 'id'
"""
if self.request.method in ('PUT', 'PATCH'):
return UserSettingsUpdateSerializer
return UserSettingsSerializer

# overrides RetrieveUpdateAPIView
def get_object(self):
return self.get_user()
142 changes: 142 additions & 0 deletions api_tests/users/views/test_user_settings_detail.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
# -*- coding: utf-8 -*-
import pytest
from api.base.settings.defaults import API_BASE
from osf_tests.factories import (
AuthUserFactory,
)
from addons.twofactor.tests.utils import _valid_code


@pytest.fixture()
def user_one():
return AuthUserFactory()

@pytest.fixture()
def user_two():
return AuthUserFactory()


@pytest.mark.django_db
class TestUserSettingsGet:

@pytest.fixture()
def url(self, user_one):
return '/{}users/{}/settings/'.format(API_BASE, user_one._id)

def test_get(self, app, user_one, user_two, url):
# User unauthenticated
res = app.get(url, expect_errors=True)
assert res.status_code == 401

# User accessing another user's settings
res = app.get(url, auth=user_two.auth, expect_errors=True)
assert res.status_code == 403

# User authenticated
res = app.get(url, auth=user_one.auth)
assert res.status_code == 200


@pytest.mark.django_db
class TestUserSettingsUpdate:

@pytest.fixture()
def url(self, user_one):
return '/{}users/{}/settings/'.format(API_BASE, user_one._id)

@pytest.fixture()
def payload(self, user_one):
return {
'data': {
'type': 'user-settings',
'id': user_one._id,
'attributes': {}
}
}

def test_user_settings_type(self, app, user_one, url, payload):
payload['data']['type'] = 'Invalid type'
res = app.patch_json_api(url, payload, auth=user_one.auth, expect_errors=True)
assert res.status_code == 409

def test_update_two_factor_permissions(self, app, user_one, user_two, url, payload):
payload['data']['attributes']['two_factor_enabled'] = False
# Unauthenticated
res = app.patch_json_api(url, payload, expect_errors=True)
assert res.status_code == 401
# User modifying someone else's settings
res = app.patch_json_api(url, payload, auth=user_two.auth, expect_errors=True)
assert res.status_code == 403

def test_update_two_factor_enabled(self, app, user_one, url, payload):
# Invalid data type
payload['data']['attributes']['two_factor_enabled'] = 'Yes'
res = app.patch_json_api(url, payload, auth=user_one.auth, expect_errors=True)
assert res.status_code == 400
assert res.json['errors'][0]['detail'] == '"Yes" is not a valid boolean.'

# Already disabled - nothing happens, still disabled
payload['data']['attributes']['two_factor_enabled'] = False
res = app.patch_json_api(url, payload, auth=user_one.auth, expect_errors=True)
assert res.status_code == 200
assert res.json['data']['attributes']['two_factor_enabled'] is False

# Test enabling two factor
payload['data']['attributes']['two_factor_enabled'] = True
res = app.patch_json_api(url, payload, auth=user_one.auth, expect_errors=True)
assert res.status_code == 200
assert res.json['data']['attributes']['two_factor_enabled'] is True
user_one.reload()
addon = user_one.get_addon('twofactor')
assert addon.deleted is False
assert addon.is_confirmed is False

# Test already enabled - nothing happens, still enabled
res = app.patch_json_api(url, payload, auth=user_one.auth, expect_errors=True)
assert res.status_code == 200
assert res.json['data']['attributes']['two_factor_enabled'] is True

# Test disabling two factor
payload['data']['attributes']['two_factor_enabled'] = False
res = app.patch_json_api(url, payload, auth=user_one.auth, expect_errors=True)
assert res.status_code == 200
assert res.json['data']['attributes']['two_factor_enabled'] is False
user_one.reload()
addon = user_one.get_addon('twofactor')
assert addon is None

def test_update_two_factor_verification(self, app, user_one, url, payload):
TOTP_SECRET = 'b8f85986068f8079aa9d'
# Two factor not enabled
payload['data']['attributes']['two_factor_verification'] = 123456
res = app.patch_json_api(url, payload, auth=user_one.auth, expect_errors=True)
assert res.status_code == 400
assert res.json['errors'][0]['detail'] == 'Two-factor authentication is not enabled.'

# Two factor invalid code
payload['data']['attributes']['two_factor_enabled'] = True
payload['data']['attributes']['two_factor_verification'] = 123456
res = app.patch_json_api(url, payload, auth=user_one.auth, expect_errors=True)
assert res.status_code == 403
assert res.json['errors'][0]['detail'] == 'The two-factor verification code you provided is invalid.'

# Test invalid data type
payload['data']['attributes']['two_factor_verification'] = 'abcd123'
res = app.patch_json_api(url, payload, auth=user_one.auth, expect_errors=True)
assert res.status_code == 400
assert res.json['errors'][0]['detail'] == 'A valid integer is required.'

# Test two factor valid code
del payload['data']['attributes']['two_factor_verification']
res = app.patch_json_api(url, payload, auth=user_one.auth, expect_errors=True)
addon = user_one.get_addon('twofactor')
addon.totp_secret = TOTP_SECRET
addon.save()
payload['data']['attributes']['two_factor_verification'] = _valid_code(TOTP_SECRET)
res = app.patch_json_api(url, payload, auth=user_one.auth, expect_errors=True)
assert res.json['data']['attributes']['two_factor_enabled'] is True
assert res.status_code == 200
user_one.reload()
addon = user_one.get_addon('twofactor')
assert addon.deleted is False
assert addon.is_confirmed is True
7 changes: 5 additions & 2 deletions framework/auth/oauth_scopes.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ class CoreScopes(object):
USERS_WRITE = 'users_write'
USERS_CREATE = 'users_create'

USER_SETTINGS_READ = 'user.settings_read'
USER_SETTINGS_WRITE = 'user.settings_write'

USER_EMAIL_READ = 'users.email_read'

USER_ADDON_READ = 'users.addon_read'
Expand Down Expand Up @@ -147,8 +150,8 @@ class ComposedScopes(object):
# All views should be based on selections from CoreScopes, above

# Users collection
USERS_READ = (CoreScopes.USERS_READ, CoreScopes.SUBSCRIPTIONS_READ, CoreScopes.ALERTS_READ)
USERS_WRITE = USERS_READ + (CoreScopes.USERS_WRITE, CoreScopes.SUBSCRIPTIONS_WRITE, CoreScopes.ALERTS_WRITE)
USERS_READ = (CoreScopes.USERS_READ, CoreScopes.SUBSCRIPTIONS_READ, CoreScopes.ALERTS_READ, CoreScopes.USER_SETTINGS_READ)
USERS_WRITE = USERS_READ + (CoreScopes.USERS_WRITE, CoreScopes.SUBSCRIPTIONS_WRITE, CoreScopes.ALERTS_WRITE, CoreScopes.USER_SETTINGS_WRITE)
USERS_CREATE = USERS_READ + (CoreScopes.USERS_CREATE, )

# User extensions
Expand Down

0 comments on commit fd84cd2

Please sign in to comment.