Implement session-based and OAuth 2 authentications

Relates #21. Please see acceptance docs for feature details.

Signed-off-by: Aaron Tan <jangsutsr@gmail.com>
This commit is contained in:
Aaron Tan
2017-11-02 17:18:27 -04:00
committed by adamscmRH
parent 2ebee58727
commit 1c2621cd60
37 changed files with 1712 additions and 144 deletions

View File

@@ -17,6 +17,9 @@ from django.core.exceptions import ObjectDoesNotExist
# Django REST Framework
from rest_framework.exceptions import ParseError, PermissionDenied, ValidationError
# Django OAuth Toolkit
from oauth2_provider.models import Application, AccessToken
# AWX
from awx.main.utils import (
get_object_or_400,
@@ -117,6 +120,8 @@ def check_user_access(user, model_class, action, *args, **kwargs):
Return True if user can perform action against model_class with the
provided parameters.
'''
if 'write' not in getattr(user, 'oauth_scopes', ['write']) and action != 'read':
return False
access_class = access_registry[model_class]
access_instance = access_class(user)
access_method = getattr(access_instance, 'can_%s' % action)
@@ -552,6 +557,73 @@ class UserAccess(BaseAccess):
return super(UserAccess, self).can_unattach(obj, sub_obj, relationship, *args, **kwargs)
class OauthApplicationAccess(BaseAccess):
'''
I can read, change or delete OAuth applications when:
- I am a superuser.
- I am the admin of the organization of the user of the application.
- I am the user of the application.
I can create OAuth applications when:
- I am a superuser.
- I am the admin of the organization of the user of the application.
'''
model = Application
select_related = ('user',)
def filtered_queryset(self):
accessible_users = User.objects.filter(
pk__in=self.user.admin_of_organizations.values('member_role__members')
) | User.objects.filter(pk=self.user.pk)
return self.model.objects.filter(user__in=accessible_users)
def can_change(self, obj, data):
return self.can_read(obj)
def can_delete(self, obj):
return self.can_read(obj)
def can_add(self, data):
if self.user.is_superuser:
return True
user = get_object_from_data('user', User, data)
if not user:
return False
return set(self.user.admin_of_organizations.all()) & set(user.organizations.all())
class OauthTokenAccess(BaseAccess):
'''
I can read, change or delete an OAuth token when:
- I am a superuser.
- I am the admin of the organization of the user of the token.
- I am the user of the token.
I can create an OAuth token when:
- I have the read permission of the related application.
'''
model = AccessToken
select_related = ('user', 'application')
def filtered_queryset(self):
accessible_users = User.objects.filter(
pk__in=self.user.admin_of_organizations.values('member_role__members')
) | User.objects.filter(pk=self.user.pk)
return self.model.objects.filter(user__in=accessible_users)
def can_change(self, obj, data):
return self.can_read(obj)
def can_delete(self, obj):
return self.can_read(obj)
def can_add(self, data):
app = get_object_from_data('application', Application, data)
if not app:
return False
return OauthApplicationAccess(self.user).can_read(app)
class OrganizationAccess(BaseAccess):
'''
I can see organizations when:

View File

@@ -1,17 +1,11 @@
import json
import logging
import urllib
from channels import Group, channel_layers
from channels.sessions import channel_session
from channels.handler import AsgiRequest
from channels import Group
from channels.auth import channel_session_user_from_http, channel_session_user
from django.conf import settings
from django.core.serializers.json import DjangoJSONEncoder
from django.contrib.auth.models import User
from awx.main.models.organization import AuthToken
logger = logging.getLogger('awx.main.consumers')
@@ -22,51 +16,29 @@ def discard_groups(message):
Group(group).discard(message.reply_channel)
@channel_session
@channel_session_user_from_http
def ws_connect(message):
message.reply_channel.send({"accept": True})
message.content['method'] = 'FAKE'
request = AsgiRequest(message)
token = request.COOKIES.get('token', None)
if token is not None:
token = urllib.unquote(token).strip('"')
try:
auth_token = AuthToken.objects.get(key=token)
if auth_token.in_valid_tokens:
message.channel_session['user_id'] = auth_token.user_id
message.reply_channel.send({"text": json.dumps({"accept": True, "user": auth_token.user_id})})
return None
except AuthToken.DoesNotExist:
logger.error("auth_token provided was invalid.")
message.reply_channel.send({"close": True})
if message.user.is_authenticated():
message.reply_channel.send(
{"text": json.dumps({"accept": True, "user": message.user.id})}
)
else:
logger.error("Request user is not authenticated to use websocket.")
message.reply_channel.send({"close": True})
return None
@channel_session
@channel_session_user
def ws_disconnect(message):
discard_groups(message)
@channel_session
@channel_session_user
def ws_receive(message):
from awx.main.access import consumer_access
channel_layer_settings = channel_layers.configs[message.channel_layer.alias]
max_retries = channel_layer_settings.get('RECEIVE_MAX_RETRY', settings.CHANNEL_LAYER_RECEIVE_MAX_RETRY)
user_id = message.channel_session.get('user_id', None)
if user_id is None:
retries = message.content.get('connect_retries', 0) + 1
message.content['connect_retries'] = retries
message.reply_channel.send({"text": json.dumps({"error": "no valid user"})})
retries_left = max_retries - retries
if retries_left > 0:
message.channel_layer.send(message.channel.name, message.content)
else:
logger.error("No valid user found for websocket.")
return None
user = User.objects.get(pk=user_id)
user = message.user
raw_data = message.content['text']
data = json.loads(raw_data)

View File

@@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.7 on 2017-11-09 21:54
from __future__ import unicode_literals
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('sessions', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('main', '0017_v330_move_deprecated_stdout'),
]
operations = [
migrations.CreateModel(
name='UserSessionMembership',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', models.DateTimeField(default=None, editable=False)),
('session', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='sessions.Session')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@@ -0,0 +1,29 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.7 on 2017-12-04 19:49
from __future__ import unicode_literals
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.OAUTH2_PROVIDER_APPLICATION_MODEL),
migrations.swappable_dependency(settings.OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL),
('main', '0018_v330_create_user_session_membership'),
]
operations = [
migrations.AddField(
model_name='activitystream',
name='access_token',
field=models.ManyToManyField(blank=True, to=settings.OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL),
),
migrations.AddField(
model_name='activitystream',
name='application',
field=models.ManyToManyField(blank=True, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL),
),
]

View File

@@ -24,6 +24,7 @@ from awx.main.models.fact import * # noqa
from awx.main.models.label import * # noqa
from awx.main.models.workflow import * # noqa
from awx.main.models.channels import * # noqa
from awx.api.versioning import reverse
# Monkeypatch Django serializer to ignore django-taggit fields (which break
# the dumpdata command; see https://github.com/alex/django-taggit/issues/155).
@@ -113,6 +114,24 @@ def user_is_in_enterprise_category(user, category):
User.add_to_class('is_in_enterprise_category', user_is_in_enterprise_category)
from oauth2_provider.models import Application, Grant, AccessToken, RefreshToken # noqa
def oauth_application_get_absolute_url(self, request=None):
return reverse('api:user_me_oauth_application_detail', kwargs={'pk': self.pk}, request=request)
Application.add_to_class('get_absolute_url', oauth_application_get_absolute_url)
def oauth_token_get_absolute_url(self, request=None):
return reverse('api:user_me_oauth_token_detail', kwargs={'pk': self.pk}, request=request)
AccessToken.add_to_class('get_absolute_url', oauth_token_get_absolute_url)
# Import signal handlers only after models have been defined.
import awx.main.signals # noqa
@@ -143,6 +162,8 @@ activity_stream_registrar.connect(User)
activity_stream_registrar.connect(WorkflowJobTemplate)
activity_stream_registrar.connect(WorkflowJobTemplateNode)
activity_stream_registrar.connect(WorkflowJob)
activity_stream_registrar.connect(Application)
activity_stream_registrar.connect(AccessToken)
# prevent API filtering on certain Django-supplied sensitive fields
prevent_search(User._meta.get_field('password'))

View File

@@ -66,6 +66,8 @@ class ActivityStream(models.Model):
label = models.ManyToManyField("Label", blank=True)
role = models.ManyToManyField("Role", blank=True)
instance_group = models.ManyToManyField("InstanceGroup", blank=True)
application = models.ManyToManyField("oauth2_provider.Application", blank=True)
access_token = models.ManyToManyField("oauth2_provider.AccessToken", blank=True)
setting = JSONField(blank=True)

View File

@@ -11,6 +11,7 @@ import uuid
from django.conf import settings
from django.db import models, connection
from django.contrib.auth.models import User
from django.contrib.sessions.models import Session
from django.utils.timezone import now as tz_now
from django.utils.translation import ugettext_lazy as _
@@ -26,7 +27,7 @@ from awx.main.models.rbac import (
)
from awx.main.models.mixins import ResourceMixin, CustomVirtualEnvMixin
__all__ = ['Organization', 'Team', 'Profile', 'AuthToken']
__all__ = ['Organization', 'Team', 'Profile', 'AuthToken', 'UserSessionMembership']
class Organization(CommonModel, NotificationFieldsModel, ResourceMixin, CustomVirtualEnvMixin):
@@ -269,6 +270,42 @@ class AuthToken(BaseModel):
return self.key
class UserSessionMembership(BaseModel):
'''
A lookup table for session membership given user.
'''
class Meta:
app_label = 'main'
user = models.ForeignKey(
'auth.User', related_name='+', blank=False, null=False, on_delete=models.CASCADE
)
session = models.OneToOneField(
Session, related_name='+', blank=False, null=False, on_delete=models.CASCADE
)
created = models.DateTimeField(default=None, editable=False)
@staticmethod
def get_memberships_over_limit(user, now=None):
if settings.SESSIONS_PER_USER == -1:
return []
if now is None:
now = tz_now()
query_set = UserSessionMembership.objects\
.select_related('session')\
.filter(user=user)\
.order_by('-created')
non_expire_memberships = [x for x in query_set if x.session.expire_date > now]
return non_expire_memberships[settings.SESSIONS_PER_USER:]
@staticmethod
def clear_session_for_user(user):
query_set = UserSessionMembership.objects.select_related('session').filter(user=user)
sessions_to_delete = [obj.session.pk for obj in query_set]
Session.objects.filter(pk__in=sessions_to_delete).delete()
# Add get_absolute_url method to User model if not present.
if not hasattr(User, 'get_absolute_url'):
def user_get_absolute_url(user, request=None):

View File

@@ -11,6 +11,9 @@ import json
from django.conf import settings
from django.db.models.signals import post_save, pre_delete, post_delete, m2m_changed
from django.dispatch import receiver
from django.contrib.auth import SESSION_KEY
from django.utils import timezone
from django.utils.translation import ugettext_lazy as _
# Django-CRUM
from crum import get_current_request, get_current_user
@@ -20,6 +23,7 @@ import six
# AWX
from awx.main.models import * # noqa
from django.contrib.sessions.models import Session
from awx.api.serializers import * # noqa
from awx.main.utils import model_instance_diff, model_to_dict, camelcase_to_underscore
from awx.main.utils import ignore_inventory_computed_fields, ignore_inventory_group_removal, _inventory_updates
@@ -581,3 +585,45 @@ def delete_inventory_for_org(sender, instance, **kwargs):
inventory.schedule_deletion(user_id=getattr(user, 'id', None))
except RuntimeError as e:
logger.debug(e)
@receiver(post_save, sender=Session)
def save_user_session_membership(sender, **kwargs):
session = kwargs.get('instance', None)
if not session:
return
user = session.get_decoded().get(SESSION_KEY, None)
if not user:
return
user = User.objects.get(pk=user)
if UserSessionMembership.objects.filter(user=user, session=session).exists():
return
UserSessionMembership.objects.create(user=user, session=session, created=timezone.now())
for membership in UserSessionMembership.get_memberships_over_limit(user):
emit_channel_notification(
'control-limit_reached',
dict(group_name='control',
reason=unicode(_('limit_reached')),
session_key=membership.session.session_key)
)
@receiver(post_save, sender=AccessToken)
def create_access_token_user_if_missing(sender, **kwargs):
obj = kwargs['instance']
if obj.application and obj.application.user:
obj.user = obj.application.user
post_save.disconnect(create_access_token_user_if_missing, sender=AccessToken)
obj.save()
post_save.connect(create_access_token_user_if_missing, sender=AccessToken)
@receiver(post_save, sender=User)
def create_default_oauth_app(sender, **kwargs):
if kwargs.get('created', False):
user = kwargs['instance']
Application.objects.create(
name='Default application for {}'.format(user.username),
user=user, client_type='confidential', redirect_uris='',
authorization_grant_type='password'
)

View File

@@ -199,6 +199,8 @@ def handle_setting_changes(self, setting_keys):
if key.startswith('LOG_AGGREGATOR_'):
restart_local_services(['uwsgi', 'celery', 'beat', 'callback'])
break
elif key == 'OAUTH2_PROVIDER':
restart_local_services(['uwsgi'])
@shared_task(bind=True, queue='tower_broadcast_all', base=LogErrorsTask)

View File

@@ -0,0 +1,135 @@
import pytest
from awx.api.versioning import reverse
from awx.main.models import (
Application,
AccessToken,
RefreshToken,
)
@pytest.mark.django_db
def test_oauth_application_create(admin, post):
response = post(
reverse('api:user_me_oauth_application_list'), {
'name': 'test app',
'user': admin.pk,
'client_type': 'confidential',
'authorization_grant_type': 'password',
}, admin, expect=201
)
assert 'modified' in response.data
assert 'updated' not in response.data
assert 'user' in response.data['related']
created_app = Application.objects.get(client_id=response.data['client_id'])
assert created_app.name == 'test app'
assert created_app.user == admin
assert created_app.skip_authorization is False
assert created_app.redirect_uris == ''
assert created_app.client_type == 'confidential'
assert created_app.authorization_grant_type == 'password'
@pytest.mark.django_db
def test_oauth_application_update(oauth_application, patch, admin, alice):
patch(
reverse('api:user_me_oauth_application_detail', kwargs={'pk': oauth_application.pk}), {
'name': 'Test app with immutable grant type and user',
'redirect_uris': 'http://localhost/api/',
'authorization_grant_type': 'implicit',
'skip_authorization': True,
'user': alice.pk,
}, admin, expect=200
)
updated_app = Application.objects.get(client_id=oauth_application.client_id)
assert updated_app.name == 'Test app with immutable grant type and user'
assert updated_app.redirect_uris == 'http://localhost/api/'
assert updated_app.skip_authorization is True
assert updated_app.authorization_grant_type == 'password'
assert updated_app.user == admin
@pytest.mark.django_db
def test_oauth_token_create(oauth_application, get, post, admin):
response = post(
reverse('api:user_me_oauth_application_token_list', kwargs={'pk': oauth_application.pk}),
{'scope': 'read'}, admin, expect=201
)
assert 'modified' in response.data
assert 'updated' not in response.data
token = AccessToken.objects.get(token=response.data['token'])
refresh_token = RefreshToken.objects.get(token=response.data['refresh_token'])
assert token.application == oauth_application
assert refresh_token.application == oauth_application
assert token.user == admin
assert refresh_token.user == admin
assert refresh_token.access_token == token
assert token.scope == 'read'
response = get(
reverse('api:user_me_oauth_application_token_list', kwargs={'pk': oauth_application.pk}),
admin, expect=200
)
assert response.data['count'] == 1
response = get(
reverse('api:user_me_oauth_application_detail', kwargs={'pk': oauth_application.pk}),
admin, expect=200
)
assert response.data['summary_fields']['tokens']['count'] == 1
assert response.data['summary_fields']['tokens']['results'][0] == {
'id': token.pk, 'token': token.token
}
@pytest.mark.django_db
def test_oauth_token_update(oauth_application, post, patch, admin):
response = post(
reverse('api:user_me_oauth_application_token_list', kwargs={'pk': oauth_application.pk}),
{'scope': 'read'}, admin, expect=201
)
token = AccessToken.objects.get(token=response.data['token'])
patch(
reverse('api:user_me_oauth_token_detail', kwargs={'pk': token.pk}),
{'scope': 'write'}, admin, expect=200
)
token = AccessToken.objects.get(token=token.token)
assert token.scope == 'write'
@pytest.mark.django_db
def test_oauth_token_delete(oauth_application, post, delete, get, admin):
response = post(
reverse('api:user_me_oauth_application_token_list', kwargs={'pk': oauth_application.pk}),
{'scope': 'read'}, admin, expect=201
)
token = AccessToken.objects.get(token=response.data['token'])
delete(
reverse('api:user_me_oauth_token_detail', kwargs={'pk': token.pk}),
admin, expect=204
)
assert AccessToken.objects.count() == 0
assert RefreshToken.objects.count() == 0
response = get(
reverse('api:user_me_oauth_application_token_list', kwargs={'pk': oauth_application.pk}),
admin, expect=200
)
assert response.data['count'] == 0
response = get(
reverse('api:user_me_oauth_application_detail', kwargs={'pk': oauth_application.pk}),
admin, expect=200
)
assert response.data['summary_fields']['tokens']['count'] == 0
@pytest.mark.django_db
def test_oauth_application_delete(oauth_application, post, delete, admin):
post(
reverse('api:user_me_oauth_application_token_list', kwargs={'pk': oauth_application.pk}),
{'scope': 'read'}, admin, expect=201
)
delete(
reverse('api:user_me_oauth_application_detail', kwargs={'pk': oauth_application.pk}),
admin, expect=204
)
assert Application.objects.filter(client_id=oauth_application.client_id).count() == 0
assert RefreshToken.objects.filter(application=oauth_application).count() == 0
assert AccessToken.objects.filter(application=oauth_application).count() == 0

View File

@@ -1,6 +1,7 @@
import pytest
from awx.api.versioning import reverse
from awx.main.models import User, Application
#
@@ -23,6 +24,11 @@ def test_user_create(post, admin):
assert response.status_code == 201
assert not response.data['is_superuser']
assert not response.data['is_system_auditor']
user = User.objects.get(username='affable')
assert Application.objects.filter(user=user).count() == 1
app = Application.objects.filter(user=user).first()
assert app.name == 'Default application for affable'
assert app.client_type == 'confidential'
@pytest.mark.django_db

View File

@@ -47,6 +47,7 @@ from awx.main.models.notifications import (
)
from awx.main.models.workflow import WorkflowJobTemplate
from awx.main.models.ad_hoc_commands import AdHocCommand
from awx.main.models import Application
__SWAGGER_REQUESTS__ = {}
@@ -535,6 +536,9 @@ def _request(verb):
view, view_args, view_kwargs = resolve(urlparse(url)[2])
request = getattr(APIRequestFactory(), verb)(url, **kwargs)
if isinstance(kwargs.get('cookies', None), dict):
for key, value in kwargs['cookies'].items():
request.COOKIES[key] = value
if middleware:
middleware.process_request(request)
if user:
@@ -545,7 +549,7 @@ def _request(verb):
middleware.process_response(request, response)
if expect:
if response.status_code != expect:
if response.data is not None:
if getattr(response, 'data', None):
try:
data_copy = response.data.copy()
# Make translated strings printable
@@ -558,7 +562,6 @@ def _request(verb):
response.data[key] = str(value)
except Exception:
response.data = data_copy
print(response.data)
assert response.status_code == expect
if hasattr(response, 'render'):
response.render()
@@ -727,3 +730,11 @@ def get_db_prep_save(self, value, connection, **kwargs):
@pytest.fixture
def monkeypatch_jsonbfield_get_db_prep_save(mocker):
JSONField.get_db_prep_save = get_db_prep_save
@pytest.fixture
def oauth_application(admin):
return Application.objects.create(
name='test app', user=admin, client_type='confidential',
authorization_grant_type='password'
)

View File

@@ -1,39 +0,0 @@
import pytest
from datetime import timedelta
from django.utils.timezone import now as tz_now
from django.test.utils import override_settings
from awx.main.models import AuthToken, User
@override_settings(AUTH_TOKEN_PER_USER=3)
@pytest.mark.django_db
def test_get_tokens_over_limit():
now = tz_now()
# Times are relative to now
# (key, created on in seconds , expiration in seconds)
test_data = [
# a is implicitly expired
("a", -1000, -10),
# b's are invalid due to session limit of 3
("b", -100, 60),
("bb", -100, 60),
("c", -90, 70),
("d", -80, 80),
("e", -70, 90),
]
user = User.objects.create_superuser('admin', 'foo@bar.com', 'password')
for key, t_create, t_expire in test_data:
AuthToken.objects.create(
user=user,
key=key,
request_hash='this_is_a_hash',
created=now + timedelta(seconds=t_create),
expires=now + timedelta(seconds=t_expire),
)
invalid_tokens = AuthToken.get_tokens_over_limit(user, now=now)
invalid_keys = [x.key for x in invalid_tokens]
assert len(invalid_keys) == 2
assert 'b' in invalid_keys
assert 'bb' in invalid_keys

View File

@@ -0,0 +1,114 @@
import pytest
from awx.main.access import (
OauthApplicationAccess,
OauthTokenAccess,
)
from awx.main.models import (
Application,
AccessToken,
)
from awx.api.versioning import reverse
@pytest.mark.django_db
class TestOAuthApplication:
@pytest.mark.parametrize("user_for_access, can_access_list", [
(0, [True, True, True, True]),
(1, [False, True, True, False]),
(2, [False, False, True, False]),
(3, [False, False, False, True]),
])
def test_can_read_change_delete(
self, admin, org_admin, org_member, alice, user_for_access, can_access_list
):
user_list = [admin, org_admin, org_member, alice]
access = OauthApplicationAccess(user_list[user_for_access])
for user, can_access in zip(user_list, can_access_list):
app = Application.objects.create(
name='test app for {}'.format(user.username), user=user,
client_type='confidential', authorization_grant_type='password'
)
assert access.can_read(app) is can_access
assert access.can_change(app, {}) is can_access
assert access.can_delete(app) is can_access
def test_superuser_can_always_create(self, admin, org_admin, org_member, alice):
access = OauthApplicationAccess(admin)
for user in [admin, org_admin, org_member, alice]:
assert access.can_add({
'name': 'test app', 'user': user.pk, 'client_type': 'confidential',
'authorization_grant_type': 'password'
})
def test_normal_user_cannot_create(self, admin, org_admin, org_member, alice):
for access_user in [org_member, alice]:
access = OauthApplicationAccess(access_user)
for user in [admin, org_admin, org_member, alice]:
assert not access.can_add({
'name': 'test app', 'user': user.pk, 'client_type': 'confidential',
'authorization_grant_type': 'password'
})
def test_org_admin_can_create_in_org(self, admin, org_admin, org_member, alice):
access = OauthApplicationAccess(org_admin)
for user in [admin, alice]:
assert not access.can_add({
'name': 'test app', 'user': user.pk, 'client_type': 'confidential',
'authorization_grant_type': 'password'
})
for user in [org_admin, org_member]:
assert access.can_add({
'name': 'test app', 'user': user.pk, 'client_type': 'confidential',
'authorization_grant_type': 'password'
})
@pytest.mark.django_db
class TestOAuthToken:
@pytest.mark.parametrize("user_for_access, can_access_list", [
(0, [True, True, True, True]),
(1, [False, True, True, False]),
(2, [False, False, True, False]),
(3, [False, False, False, True]),
])
def test_can_read_change_delete(
self, post, admin, org_admin, org_member, alice, user_for_access, can_access_list
):
user_list = [admin, org_admin, org_member, alice]
access = OauthTokenAccess(user_list[user_for_access])
for user, can_access in zip(user_list, can_access_list):
app = Application.objects.create(
name='test app for {}'.format(user.username), user=user,
client_type='confidential', authorization_grant_type='password'
)
response = post(
reverse('api:user_me_oauth_application_token_list', kwargs={'pk': app.pk}),
{'scope': 'read'}, admin, expect=201
)
token = AccessToken.objects.get(token=response.data['token'])
assert access.can_read(token) is can_access
assert access.can_change(token, {}) is can_access
assert access.can_delete(token) is can_access
@pytest.mark.parametrize("user_for_access, can_access_list", [
(0, [True, True, True, True]),
(1, [False, True, True, False]),
(2, [False, False, True, False]),
(3, [False, False, False, True]),
])
def test_can_create(
self, post, admin, org_admin, org_member, alice, user_for_access, can_access_list
):
user_list = [admin, org_admin, org_member, alice]
for user, can_access in zip(user_list, can_access_list):
app = Application.objects.create(
name='test app for {}'.format(user.username), user=user,
client_type='confidential', authorization_grant_type='password'
)
post(
reverse('api:user_me_oauth_application_token_list', kwargs={'pk': app.pk}),
{'scope': 'read'}, user_list[user_for_access], expect=201 if can_access else 403
)

View File

@@ -0,0 +1,100 @@
import pytest
from datetime import timedelta
import re
from django.utils.timezone import now as tz_now
from django.test.utils import override_settings
from django.contrib.sessions.middleware import SessionMiddleware
from django.contrib.sessions.models import Session
from django.contrib.auth import SESSION_KEY
from awx.main.models import UserSessionMembership
from awx.api.versioning import reverse
class AlwaysPassBackend(object):
user = None
def authenticate(self, **credentials):
return AlwaysPassBackend.user
@classmethod
def get_backend_path(cls):
return '{}.{}'.format(cls.__module__, cls.__name__)
@pytest.mark.django_db
def test_session_create_delete(admin, post, get):
AlwaysPassBackend.user = admin
with override_settings(
AUTHENTICATION_BACKENDS=(AlwaysPassBackend.get_backend_path(),),
SESSION_COOKIE_NAME='session_id'
):
response = post(
'/api/login/',
data={'username': admin.username, 'password': admin.password, 'next': '/api/'},
expect=302, middleware=SessionMiddleware(), format='multipart'
)
assert 'session_id' in response.cookies
session_key = re.findall(r'session_id=[a-zA-z0-9]+',
str(response.cookies['session_id']))[0][len('session_id=') :]
session = Session.objects.get(session_key=session_key)
assert int(session.get_decoded()[SESSION_KEY]) == admin.pk
response = get(
'/api/logout/', middleware=SessionMiddleware(),
cookies={'session_id': session_key}, expect=302
)
assert not Session.objects.filter(session_key=session_key).exists()
@pytest.mark.django_db
def test_session_overlimit(admin, post):
AlwaysPassBackend.user = admin
with override_settings(
AUTHENTICATION_BACKENDS=(AlwaysPassBackend.get_backend_path(),),
SESSION_COOKIE_NAME='session_id', SESSIONS_PER_USER=3
):
sessions_to_deprecate = []
for _ in range(5):
response = post(
'/api/login/',
data={'username': admin.username, 'password': admin.password, 'next': '/api/'},
expect=302, middleware=SessionMiddleware(), format='multipart'
)
session_key = re.findall(
r'session_id=[a-zA-z0-9]+',
str(response.cookies['session_id'])
)[0][len('session_id=') :]
sessions_to_deprecate.append(Session.objects.get(session_key=session_key))
sessions_to_deprecate[0].expire_date = tz_now() - timedelta(seconds=1000)
sessions_to_deprecate[0].save()
sessions_overlimit = [x.session for x in UserSessionMembership.get_memberships_over_limit(admin)]
assert sessions_to_deprecate[0] not in sessions_overlimit
assert sessions_to_deprecate[1] in sessions_overlimit
for session in sessions_to_deprecate[2 :]:
assert session not in sessions_overlimit
@pytest.mark.django_db
def test_password_update_clears_sessions(admin, alice, post, patch):
AlwaysPassBackend.user = alice
with override_settings(
AUTHENTICATION_BACKENDS=(AlwaysPassBackend.get_backend_path(),),
SESSION_COOKIE_NAME='session_id'
):
response = post(
'/api/login/',
data={'username': alice.username, 'password': alice.password, 'next': '/api/'},
expect=302, middleware=SessionMiddleware(), format='multipart'
)
session_key = re.findall(
r'session_id=[a-zA-z0-9]+',
str(response.cookies['session_id'])
)[0][len('session_id=') :]
assert Session.objects.filter(session_key=session_key).exists()
patch(
reverse('api:user_detail', kwargs={'pk': alice.pk}), admin,
data={'password': 'new_password'}, expect=200
)
assert not Session.objects.filter(session_key=session_key).exists()