mirror of
https://github.com/ansible/awx.git
synced 2026-05-14 21:07:39 -02:30
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:
@@ -16,6 +16,9 @@ from rest_framework import authentication
|
|||||||
from rest_framework import exceptions
|
from rest_framework import exceptions
|
||||||
from rest_framework import HTTP_HEADER_ENCODING
|
from rest_framework import HTTP_HEADER_ENCODING
|
||||||
|
|
||||||
|
# Django OAuth Toolkit
|
||||||
|
from oauth2_provider.contrib.rest_framework import OAuth2Authentication
|
||||||
|
|
||||||
# AWX
|
# AWX
|
||||||
from awx.main.models import AuthToken
|
from awx.main.models import AuthToken
|
||||||
|
|
||||||
@@ -137,3 +140,28 @@ class LoggedBasicAuthentication(authentication.BasicAuthentication):
|
|||||||
if not settings.AUTH_BASIC_ENABLED:
|
if not settings.AUTH_BASIC_ENABLED:
|
||||||
return
|
return
|
||||||
return super(LoggedBasicAuthentication, self).authenticate_header(request)
|
return super(LoggedBasicAuthentication, self).authenticate_header(request)
|
||||||
|
|
||||||
|
|
||||||
|
class SessionAuthentication(authentication.SessionAuthentication):
|
||||||
|
|
||||||
|
def authenticate_header(self, request):
|
||||||
|
return 'Session'
|
||||||
|
|
||||||
|
def enforce_csrf(self, request):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class LoggedOAuth2Authentication(OAuth2Authentication):
|
||||||
|
|
||||||
|
def authenticate(self, request):
|
||||||
|
ret = super(LoggedOAuth2Authentication, self).authenticate(request)
|
||||||
|
if ret:
|
||||||
|
user, token = ret
|
||||||
|
username = user.username if user else '<none>'
|
||||||
|
logger.debug(smart_text(
|
||||||
|
u"User {} performed a {} to {} through the API using OAuth token {}".format(
|
||||||
|
username, request.method, request.path, user
|
||||||
|
)
|
||||||
|
))
|
||||||
|
setattr(user, 'oauth_scopes', [x for x in token.scope.split() if x])
|
||||||
|
return ret
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
# Django
|
# Django
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
# Tower
|
# AWX
|
||||||
from awx.conf import fields, register
|
from awx.conf import fields, register
|
||||||
|
from awx.api.fields import OAuth2ProviderField
|
||||||
|
|
||||||
|
|
||||||
register(
|
register(
|
||||||
@@ -24,7 +25,24 @@ register(
|
|||||||
category=_('Authentication'),
|
category=_('Authentication'),
|
||||||
category_slug='authentication',
|
category_slug='authentication',
|
||||||
)
|
)
|
||||||
|
register(
|
||||||
|
'SESSION_COOKIE_AGE',
|
||||||
|
field_class=fields.IntegerField,
|
||||||
|
min_value=60,
|
||||||
|
label=_('Idle Time Force Log Out'),
|
||||||
|
help_text=_('Number of seconds that a user is inactive before they will need to login again.'),
|
||||||
|
category=_('Authentication'),
|
||||||
|
category_slug='authentication',
|
||||||
|
)
|
||||||
|
register(
|
||||||
|
'SESSIONS_PER_USER',
|
||||||
|
field_class=fields.IntegerField,
|
||||||
|
min_value=-1,
|
||||||
|
label=_('Maximum number of simultaneous logged in sessions'),
|
||||||
|
help_text=_('Maximum number of simultaneous logged in sessions a user may have. To disable enter -1.'),
|
||||||
|
category=_('Authentication'),
|
||||||
|
category_slug='authentication',
|
||||||
|
)
|
||||||
register(
|
register(
|
||||||
'AUTH_BASIC_ENABLED',
|
'AUTH_BASIC_ENABLED',
|
||||||
field_class=fields.BooleanField,
|
field_class=fields.BooleanField,
|
||||||
@@ -33,3 +51,15 @@ register(
|
|||||||
category=_('Authentication'),
|
category=_('Authentication'),
|
||||||
category_slug='authentication',
|
category_slug='authentication',
|
||||||
)
|
)
|
||||||
|
register(
|
||||||
|
'OAUTH2_PROVIDER',
|
||||||
|
field_class=OAuth2ProviderField,
|
||||||
|
default={'ACCESS_TOKEN_EXPIRE_SECONDS': 36000, 'AUTHORIZATION_CODE_EXPIRE_SECONDS': 60},
|
||||||
|
label=_('OAuth 2 Timeout Settings'),
|
||||||
|
help_text=_('Dictionary for customizing OAuth 2 timeouts, available items are '
|
||||||
|
'`ACCESS_TOKEN_EXPIRE_SECONDS`, the duration of access tokens in the number '
|
||||||
|
'of seconds, and `AUTHORIZATION_CODE_EXPIRE_SECONDS`, the duration of '
|
||||||
|
'authorization grants in the number of seconds.'),
|
||||||
|
category=_('Authentication'),
|
||||||
|
category_slug='authentication',
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
# Copyright (c) 2016 Ansible, Inc.
|
# Copyright (c) 2016 Ansible, Inc.
|
||||||
# All Rights Reserved.
|
# All Rights Reserved.
|
||||||
|
|
||||||
|
# Django
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
# Django REST Framework
|
# Django REST Framework
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
# AWX
|
||||||
|
from awx.conf import fields
|
||||||
|
|
||||||
__all__ = ['BooleanNullField', 'CharNullField', 'ChoiceNullField', 'VerbatimField']
|
__all__ = ['BooleanNullField', 'CharNullField', 'ChoiceNullField', 'VerbatimField']
|
||||||
|
|
||||||
|
|
||||||
@@ -66,3 +71,19 @@ class VerbatimField(serializers.Field):
|
|||||||
|
|
||||||
def to_representation(self, value):
|
def to_representation(self, value):
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class OAuth2ProviderField(fields.DictField):
|
||||||
|
|
||||||
|
default_error_messages = {
|
||||||
|
'invalid_key_names': _('Invalid key names: {invalid_key_names}'),
|
||||||
|
}
|
||||||
|
valid_key_names = {'ACCESS_TOKEN_EXPIRE_SECONDS', 'AUTHORIZATION_CODE_EXPIRE_SECONDS'}
|
||||||
|
child = fields.IntegerField(min_value=1)
|
||||||
|
|
||||||
|
def to_internal_value(self, data):
|
||||||
|
data = super(OAuth2ProviderField, self).to_internal_value(data)
|
||||||
|
invalid_flags = (set(data.keys()) - self.valid_key_names)
|
||||||
|
if invalid_flags:
|
||||||
|
self.fail('invalid_key_names', invalid_key_names=', '.join(list(invalid_flags)))
|
||||||
|
return data
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from django.utils.encoding import smart_text
|
|||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
from django.contrib.auth import views as auth_views
|
||||||
|
|
||||||
# Django REST Framework
|
# Django REST Framework
|
||||||
from rest_framework.authentication import get_authorization_header
|
from rest_framework.authentication import get_authorization_header
|
||||||
@@ -59,6 +60,29 @@ logger = logging.getLogger('awx.api.generics')
|
|||||||
analytics_logger = logging.getLogger('awx.analytics.performance')
|
analytics_logger = logging.getLogger('awx.analytics.performance')
|
||||||
|
|
||||||
|
|
||||||
|
class LoggedLoginView(auth_views.LoginView):
|
||||||
|
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
original_user = getattr(request, 'user', None)
|
||||||
|
ret = super(LoggedLoginView, self).post(request, *args, **kwargs)
|
||||||
|
current_user = getattr(request, 'user', None)
|
||||||
|
if current_user and getattr(current_user, 'pk', None) and current_user != original_user:
|
||||||
|
logger.info("User {} logged in.".format(current_user.username))
|
||||||
|
return ret
|
||||||
|
|
||||||
|
|
||||||
|
class LoggedLogoutView(auth_views.LogoutView):
|
||||||
|
|
||||||
|
def dispatch(self, request, *args, **kwargs):
|
||||||
|
original_user = getattr(request, 'user', None)
|
||||||
|
ret = super(LoggedLogoutView, self).dispatch(request, *args, **kwargs)
|
||||||
|
current_user = getattr(request, 'user', None)
|
||||||
|
if (not current_user or not getattr(current_user, 'pk', True)) \
|
||||||
|
and current_user != original_user:
|
||||||
|
logger.info("User {} logged out.".format(original_user.username))
|
||||||
|
return ret
|
||||||
|
|
||||||
|
|
||||||
def get_view_name(cls, suffix=None):
|
def get_view_name(cls, suffix=None):
|
||||||
'''
|
'''
|
||||||
Wrapper around REST framework get_view_name() to support get_name() method
|
Wrapper around REST framework get_view_name() to support get_name() method
|
||||||
|
|||||||
@@ -103,7 +103,8 @@ class ModelAccessPermission(permissions.BasePermission):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
# Always allow superusers
|
# Always allow superusers
|
||||||
if getattr(view, 'always_allow_superuser', True) and request.user.is_superuser:
|
if getattr(view, 'always_allow_superuser', True) and request.user.is_superuser \
|
||||||
|
and not hasattr(request.user, 'oauth_scopes'):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Check if view supports the request method before checking permission
|
# Check if view supports the request method before checking permission
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ import re
|
|||||||
import six
|
import six
|
||||||
import urllib
|
import urllib
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
|
from dateutil import rrule
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
# OAuth
|
||||||
|
from oauthlib.common import generate_token
|
||||||
|
from oauth2_provider.settings import oauth2_settings
|
||||||
|
|
||||||
# Django
|
# Django
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -67,6 +73,7 @@ DEFAULT_SUMMARY_FIELDS = ('id', 'name', 'description')# , 'created_by', 'modifie
|
|||||||
SUMMARIZABLE_FK_FIELDS = {
|
SUMMARIZABLE_FK_FIELDS = {
|
||||||
'organization': DEFAULT_SUMMARY_FIELDS,
|
'organization': DEFAULT_SUMMARY_FIELDS,
|
||||||
'user': ('id', 'username', 'first_name', 'last_name'),
|
'user': ('id', 'username', 'first_name', 'last_name'),
|
||||||
|
'application': ('id', 'name', 'client_id'),
|
||||||
'team': DEFAULT_SUMMARY_FIELDS,
|
'team': DEFAULT_SUMMARY_FIELDS,
|
||||||
'inventory': DEFAULT_SUMMARY_FIELDS + ('has_active_failures',
|
'inventory': DEFAULT_SUMMARY_FIELDS + ('has_active_failures',
|
||||||
'total_hosts',
|
'total_hosts',
|
||||||
@@ -428,6 +435,16 @@ class BaseSerializer(serializers.ModelSerializer):
|
|||||||
return obj.modified
|
return obj.modified
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def get_extra_kwargs(self):
|
||||||
|
extra_kwargs = super(BaseSerializer, self).get_extra_kwargs()
|
||||||
|
if self.instance:
|
||||||
|
read_only_on_update_fields = getattr(self.Meta, 'read_only_on_update_fields', tuple())
|
||||||
|
for field_name in read_only_on_update_fields:
|
||||||
|
kwargs = extra_kwargs.get(field_name, {})
|
||||||
|
kwargs['read_only'] = True
|
||||||
|
extra_kwargs[field_name] = kwargs
|
||||||
|
return extra_kwargs
|
||||||
|
|
||||||
def build_standard_field(self, field_name, model_field):
|
def build_standard_field(self, field_name, model_field):
|
||||||
# DRF 3.3 serializers.py::build_standard_field() -> utils/field_mapping.py::get_field_kwargs() short circuits
|
# DRF 3.3 serializers.py::build_standard_field() -> utils/field_mapping.py::get_field_kwargs() short circuits
|
||||||
# when a Model's editable field is set to False. The short circuit skips choice rendering.
|
# when a Model's editable field is set to False. The short circuit skips choice rendering.
|
||||||
@@ -825,6 +842,7 @@ class UserSerializer(BaseSerializer):
|
|||||||
if new_password:
|
if new_password:
|
||||||
obj.set_password(new_password)
|
obj.set_password(new_password)
|
||||||
obj.save(update_fields=['password'])
|
obj.save(update_fields=['password'])
|
||||||
|
UserSessionMembership.clear_session_for_user(obj)
|
||||||
elif not obj.password:
|
elif not obj.password:
|
||||||
obj.set_unusable_password()
|
obj.set_unusable_password()
|
||||||
obj.save(update_fields=['password'])
|
obj.save(update_fields=['password'])
|
||||||
@@ -906,6 +924,113 @@ class UserSerializer(BaseSerializer):
|
|||||||
return self._validate_ldap_managed_field(value, 'is_superuser')
|
return self._validate_ldap_managed_field(value, 'is_superuser')
|
||||||
|
|
||||||
|
|
||||||
|
class OauthApplicationSerializer(BaseSerializer):
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Application
|
||||||
|
fields = (
|
||||||
|
'*', '-description', 'user', 'client_id', 'client_secret', 'client_type',
|
||||||
|
'redirect_uris', 'authorization_grant_type', 'skip_authorization',
|
||||||
|
)
|
||||||
|
read_only_fields = ('client_id', 'client_secret')
|
||||||
|
read_only_on_update_fields = ('user', 'authorization_grant_type')
|
||||||
|
extra_kwargs = {
|
||||||
|
'user': {'allow_null': False, 'required': True},
|
||||||
|
'authorization_grant_type': {'allow_null': False}
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_modified(self, obj):
|
||||||
|
if obj is None:
|
||||||
|
return None
|
||||||
|
return obj.updated
|
||||||
|
|
||||||
|
def get_related(self, obj):
|
||||||
|
ret = super(OauthApplicationSerializer, self).get_related(obj)
|
||||||
|
if obj.user:
|
||||||
|
ret['user'] = self.reverse('api:user_detail', kwargs={'pk': obj.user.pk})
|
||||||
|
ret['tokens'] = self.reverse(
|
||||||
|
'api:user_me_oauth_application_token_list', kwargs={'pk': obj.pk}
|
||||||
|
)
|
||||||
|
ret['activity_stream'] = self.reverse(
|
||||||
|
'api:user_me_oauth_application_activity_stream_list', kwargs={'pk': obj.pk}
|
||||||
|
)
|
||||||
|
return ret
|
||||||
|
|
||||||
|
def _summary_field_tokens(self, obj):
|
||||||
|
token_list = [{'id': x.pk, 'token': x.token} for x in obj.accesstoken_set.all()[:10]]
|
||||||
|
if has_model_field_prefetched(obj, 'accesstoken_set'):
|
||||||
|
token_count = len(obj.accesstoken_set.all())
|
||||||
|
else:
|
||||||
|
if len(token_list) < 10:
|
||||||
|
token_count = len(token_list)
|
||||||
|
else:
|
||||||
|
token_count = obj.accesstoken_set.count()
|
||||||
|
return {'count': token_count, 'results': token_list}
|
||||||
|
|
||||||
|
def get_summary_fields(self, obj):
|
||||||
|
ret = super(OauthApplicationSerializer, self).get_summary_fields(obj)
|
||||||
|
ret['tokens'] = self._summary_field_tokens(obj)
|
||||||
|
return ret
|
||||||
|
|
||||||
|
|
||||||
|
class OauthTokenSerializer(BaseSerializer):
|
||||||
|
|
||||||
|
refresh_token = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = AccessToken
|
||||||
|
fields = (
|
||||||
|
'*', '-name', '-description', 'user', 'token', 'refresh_token',
|
||||||
|
'application', 'expires', 'scope',
|
||||||
|
)
|
||||||
|
read_only_fields = ('user', 'token', 'expires')
|
||||||
|
read_only_on_update_fields = ('application',)
|
||||||
|
extra_kwargs = {
|
||||||
|
'application': {'allow_null': False}
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_modified(self, obj):
|
||||||
|
if obj is None:
|
||||||
|
return None
|
||||||
|
return obj.updated
|
||||||
|
|
||||||
|
def get_related(self, obj):
|
||||||
|
ret = super(OauthTokenSerializer, self).get_related(obj)
|
||||||
|
if obj.user:
|
||||||
|
ret['user'] = self.reverse('api:user_detail', kwargs={'pk': obj.user.pk})
|
||||||
|
if obj.application:
|
||||||
|
ret['application'] = self.reverse(
|
||||||
|
'api:user_me_oauth_application_detail', kwargs={'pk': obj.application.pk}
|
||||||
|
)
|
||||||
|
ret['activity_stream'] = self.reverse(
|
||||||
|
'api:user_me_oauth_token_activity_stream_list', kwargs={'pk': obj.pk}
|
||||||
|
)
|
||||||
|
return ret
|
||||||
|
|
||||||
|
def get_refresh_token(self, obj):
|
||||||
|
try:
|
||||||
|
return getattr(obj.refresh_token, 'token', '')
|
||||||
|
except ObjectDoesNotExist:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
validated_data['token'] = generate_token()
|
||||||
|
validated_data['expires'] = now() + timedelta(
|
||||||
|
seconds=oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS
|
||||||
|
)
|
||||||
|
obj = super(OauthTokenSerializer, self).create(validated_data)
|
||||||
|
if obj.application and obj.application.user:
|
||||||
|
obj.user = obj.application.user
|
||||||
|
obj.save()
|
||||||
|
RefreshToken.objects.create(
|
||||||
|
user=obj.application.user if obj.application and obj.application.user else None,
|
||||||
|
token=generate_token(),
|
||||||
|
application=obj.application if obj.application else None,
|
||||||
|
access_token=obj
|
||||||
|
)
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
class OrganizationSerializer(BaseSerializer):
|
class OrganizationSerializer(BaseSerializer):
|
||||||
show_capabilities = ['edit', 'delete']
|
show_capabilities = ['edit', 'delete']
|
||||||
|
|
||||||
@@ -4219,7 +4344,8 @@ class ActivityStreamSerializer(BaseSerializer):
|
|||||||
field_list += [
|
field_list += [
|
||||||
('workflow_job_template_node', ('id', 'unified_job_template_id')),
|
('workflow_job_template_node', ('id', 'unified_job_template_id')),
|
||||||
('label', ('id', 'name', 'organization_id')),
|
('label', ('id', 'name', 'organization_id')),
|
||||||
('notification', ('id', 'status', 'notification_type', 'notification_template_id'))
|
('notification', ('id', 'status', 'notification_type', 'notification_template_id')),
|
||||||
|
('access_token', ('id', 'token'))
|
||||||
]
|
]
|
||||||
return field_list
|
return field_list
|
||||||
|
|
||||||
@@ -4276,6 +4402,14 @@ class ActivityStreamSerializer(BaseSerializer):
|
|||||||
id_list.append(getattr(thisItem, 'id', None))
|
id_list.append(getattr(thisItem, 'id', None))
|
||||||
if fk == 'custom_inventory_script':
|
if fk == 'custom_inventory_script':
|
||||||
rel[fk].append(self.reverse('api:inventory_script_detail', kwargs={'pk': thisItem.id}))
|
rel[fk].append(self.reverse('api:inventory_script_detail', kwargs={'pk': thisItem.id}))
|
||||||
|
elif fk == 'application':
|
||||||
|
rel[fk].append(self.reverse(
|
||||||
|
'api:user_me_oauth_application_detail', kwargs={'pk': thisItem.pk}
|
||||||
|
))
|
||||||
|
elif fk == 'access_token':
|
||||||
|
rel[fk].append(self.reverse(
|
||||||
|
'api:user_me_oauth_token_detail', kwargs={'pk': thisItem.pk}
|
||||||
|
))
|
||||||
else:
|
else:
|
||||||
rel[fk].append(self.reverse('api:' + fk + '_detail', kwargs={'pk': thisItem.id}))
|
rel[fk].append(self.reverse('api:' + fk + '_detail', kwargs={'pk': thisItem.id}))
|
||||||
|
|
||||||
|
|||||||
311
awx/api/templates/api/api_o_auth_authorization_root_view.md
Normal file
311
awx/api/templates/api/api_o_auth_authorization_root_view.md
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
This page lists OAuth utility endpoints used for authorization, token refresh and revoke.
|
||||||
|
Note endpoints other than `/api/o/authorize/` are not meant to be used in browsers and do not
|
||||||
|
support HTTP GET. The endpoints here strictly follow
|
||||||
|
[RFC specs for OAuth2](https://tools.ietf.org/html/rfc6749), so please use that for detailed
|
||||||
|
reference. Here we give some examples to demonstrate the typical usage of these endpoints in
|
||||||
|
AWX context (Note AWX net location default to `http://localhost:8013` in examples):
|
||||||
|
|
||||||
|
## Authorization using application of grant type `implicit`
|
||||||
|
Suppose we have an application `admin's app` of grant type `implicit`:
|
||||||
|
```text
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"type": "application",
|
||||||
|
"related": {
|
||||||
|
...
|
||||||
|
"name": "admin's app",
|
||||||
|
"user": 1,
|
||||||
|
"client_id": "L0uQQWW8pKX51hoqIRQGsuqmIdPi2AcXZ9EJRGmj",
|
||||||
|
"client_secret": "9Wp4dUrUsigI8J15fQYJ3jn0MJHLkAjyw7ikBsABeWTNJbZwy7eB2Xro9ykYuuygerTPQ2gIF2DCTtN3kurkt0Me3AhanEw6peRNvNLs1NNfI4f53mhX8zo5JQX0BKy5",
|
||||||
|
"client_type": "confidential",
|
||||||
|
"redirect_uris": "http://localhost:8013/api/",
|
||||||
|
"authorization_grant_type": "implicit",
|
||||||
|
"skip_authorization": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In API browser, first make sure the user is logged in via session auth, then visit authorization
|
||||||
|
endpoint with given parameters:
|
||||||
|
```text
|
||||||
|
http://localhost:8013/api/o/authorize/?response_type=token&client_id=L0uQQWW8pKX51hoqIRQGsuqmIdPi2AcXZ9EJRGmj&scope=read
|
||||||
|
```
|
||||||
|
Here the value of `client_id` should be the same as that of `client_id` field of underlying application.
|
||||||
|
On success, an authorization page should be displayed asking logged in user to grant/deny access token.
|
||||||
|
Once user click on 'grant', API browser will try POSTing to the same endpoint with the same parameters
|
||||||
|
in POST body, on success a 302 redirect will be returned:
|
||||||
|
```text
|
||||||
|
HTTP/1.1 302 Found
|
||||||
|
Connection:keep-alive
|
||||||
|
Content-Language:en
|
||||||
|
Content-Length:0
|
||||||
|
Content-Type:text/html; charset=utf-8
|
||||||
|
Date:Tue, 05 Dec 2017 20:36:19 GMT
|
||||||
|
Location:http://localhost:8013/api/#access_token=0lVJJkolFTwYawHyGkk7NTmSKdzBen&token_type=Bearer&state=&expires_in=36000&scope=read
|
||||||
|
Server:nginx/1.12.2
|
||||||
|
Strict-Transport-Security:max-age=15768000
|
||||||
|
Vary:Accept-Language, Cookie
|
||||||
|
|
||||||
|
```
|
||||||
|
By inspecting the fragment part of redirect URL given by `Location` header, we can get access token
|
||||||
|
(given by `access_token` key) as well as other standard fields specified in OAuth spec. Internally
|
||||||
|
an OAuth token is created under the given application. Verify by
|
||||||
|
`GET /api/v2/me/oauth/tokens/?token=0lVJJkolFTwYawHyGkk7NTmSKdzBen`
|
||||||
|
```text
|
||||||
|
HTTP 200 OK
|
||||||
|
Allow: GET, POST, HEAD, OPTIONS
|
||||||
|
Content-Type: application/json
|
||||||
|
...
|
||||||
|
|
||||||
|
{
|
||||||
|
"count": 1,
|
||||||
|
"next": null,
|
||||||
|
"previous": null,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"type": "access_token",
|
||||||
|
...
|
||||||
|
"user": 1,
|
||||||
|
"token": "0lVJJkolFTwYawHyGkk7NTmSKdzBen",
|
||||||
|
"refresh_token": "",
|
||||||
|
"application": 1,
|
||||||
|
"expires": "2017-12-06T06:36:19.743062Z",
|
||||||
|
"scope": "read"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Authorization using application of grant type `password`
|
||||||
|
Suppose we have an application `curl for admin` with grant type `password`:
|
||||||
|
```text
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"type": "application",
|
||||||
|
...
|
||||||
|
"name": "curl for admin",
|
||||||
|
"user": 1,
|
||||||
|
"client_id": "gwSPoasWSdNkMDtBN3Hu2WYQpPWCO9SwUEsKK22l",
|
||||||
|
"client_secret": "fI6ZpfocHYBGfm1tP92r0yIgCyfRdDQt0Tos9L8a4fNsJjQQMwp9569eIaUBsaVDgt2eiwOGe0bg5m5vCSstClZmtdy359RVx2rQK5YlIWyPlrolpt2LEpVeKXWaiybo",
|
||||||
|
"client_type": "confidential",
|
||||||
|
"redirect_uris": "",
|
||||||
|
"authorization_grant_type": "password",
|
||||||
|
"skip_authorization": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Log in is not required for `password` grant type, so we can simply use `curl` to acquire access token
|
||||||
|
via `/api/o/token/`:
|
||||||
|
```bash
|
||||||
|
curl -X POST \
|
||||||
|
-d "grant_type=password&username=<username>&password=<password>&scope=read" \
|
||||||
|
-u "gwSPoasWSdNkMDtBN3Hu2WYQpPWCO9SwUEsKK22l:fI6ZpfocHYBGfm1tP92r0yIgCyfRdDQt0Tos9L8a4fNsJjQQMwp9569e
|
||||||
|
IaUBsaVDgt2eiwOGe0bg5m5vCSstClZmtdy359RVx2rQK5YlIWyPlrolpt2LEpVeKXWaiybo" \
|
||||||
|
http://localhost:8013/api/o/token/ -i
|
||||||
|
```
|
||||||
|
In the above post request, parameters `username` and `password` are username and password of the related
|
||||||
|
AWX user of the underlying application, and the authentication information is of format
|
||||||
|
`<client_id>:<client_secret>`, where `client_id` and `client_secret` are the corresponding fields of
|
||||||
|
underlying application.
|
||||||
|
|
||||||
|
Upon success, access token, refresh token and other information are given in the response body in JSON
|
||||||
|
format:
|
||||||
|
```text
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
Server: nginx/1.12.2
|
||||||
|
Date: Tue, 05 Dec 2017 16:48:09 GMT
|
||||||
|
Content-Type: application/json
|
||||||
|
Content-Length: 163
|
||||||
|
Connection: keep-alive
|
||||||
|
Content-Language: en
|
||||||
|
Vary: Accept-Language, Cookie
|
||||||
|
Pragma: no-cache
|
||||||
|
Cache-Control: no-store
|
||||||
|
Strict-Transport-Security: max-age=15768000
|
||||||
|
|
||||||
|
{"access_token": "9epHOqHhnXUcgYK8QanOmUQPSgX92g", "token_type": "Bearer", "expires_in": 36000, "refresh_token": "jMRX6QvzOTf046KHee3TU5mT3nyXsz", "scope": "read"}
|
||||||
|
```
|
||||||
|
Verify by searching created token:
|
||||||
|
```text
|
||||||
|
GET /api/v2/me/oauth/tokens/?token=9epHOqHhnXUcgYK8QanOmUQPSgX92g
|
||||||
|
|
||||||
|
HTTP 200 OK
|
||||||
|
Allow: GET, POST, HEAD, OPTIONS
|
||||||
|
Content-Type: application/json
|
||||||
|
...
|
||||||
|
|
||||||
|
{
|
||||||
|
"count": 1,
|
||||||
|
"next": null,
|
||||||
|
"previous": null,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": 26,
|
||||||
|
"type": "access_token",
|
||||||
|
...
|
||||||
|
"user": 1,
|
||||||
|
"token": "9epHOqHhnXUcgYK8QanOmUQPSgX92g",
|
||||||
|
"refresh_token": "jMRX6QvzOTf046KHee3TU5mT3nyXsz",
|
||||||
|
"application": 6,
|
||||||
|
"expires": "2017-12-06T02:48:09.812720Z",
|
||||||
|
"scope": "read"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Refresh an existing access token
|
||||||
|
Suppose we have an existing access token with refresh token provided:
|
||||||
|
```text
|
||||||
|
{
|
||||||
|
"id": 35,
|
||||||
|
"type": "access_token",
|
||||||
|
...
|
||||||
|
"user": 1,
|
||||||
|
"token": "omMFLk7UKpB36WN2Qma9H3gbwEBSOc",
|
||||||
|
"refresh_token": "AL0NK9TTpv0qp54dGbC4VUZtsZ9r8z",
|
||||||
|
"application": 6,
|
||||||
|
"expires": "2017-12-06T03:46:17.087022Z",
|
||||||
|
"scope": "read write"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
`/api/o/token/` endpoint is used for refreshing access token:
|
||||||
|
```bash
|
||||||
|
curl -X POST \
|
||||||
|
-d "grant_type=refresh_token&refresh_token=AL0NK9TTpv0qp54dGbC4VUZtsZ9r8z" \
|
||||||
|
-u "gwSPoasWSdNkMDtBN3Hu2WYQpPWCO9SwUEsKK22l:fI6ZpfocHYBGfm1tP92r0yIgCyfRdDQt0Tos9L8a4fNsJjQQMwp9569eIaUBsaVDgt2eiwOGe0bg5m5vCSstClZmtdy359RVx2rQK5YlIWyPlrolpt2LEpVeKXWaiybo" \
|
||||||
|
http://localhost:8013/api/o/token/ -i
|
||||||
|
```
|
||||||
|
In the above post request, `refresh_token` is provided by `refresh_token` field of the access token
|
||||||
|
above. The authentication information is of format `<client_id>:<client_secret>`, where `client_id`
|
||||||
|
and `client_secret` are the corresponding fields of underlying related application of the access token.
|
||||||
|
|
||||||
|
Upon success, the new (refreshed) access token with the same scope information as the previous one is
|
||||||
|
given in the response body in JSON format:
|
||||||
|
```text
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
Server: nginx/1.12.2
|
||||||
|
Date: Tue, 05 Dec 2017 17:54:06 GMT
|
||||||
|
Content-Type: application/json
|
||||||
|
Content-Length: 169
|
||||||
|
Connection: keep-alive
|
||||||
|
Content-Language: en
|
||||||
|
Vary: Accept-Language, Cookie
|
||||||
|
Pragma: no-cache
|
||||||
|
Cache-Control: no-store
|
||||||
|
Strict-Transport-Security: max-age=15768000
|
||||||
|
|
||||||
|
{"access_token": "NDInWxGJI4iZgqpsreujjbvzCfJqgR", "token_type": "Bearer", "expires_in": 36000, "refresh_token": "DqOrmz8bx3srlHkZNKmDpqA86bnQkT", "scope": "read write"}
|
||||||
|
```
|
||||||
|
Internally, the refresh operation deletes the existing token and a new token is created immediately
|
||||||
|
after, with information like scope and related application identical to the original one. We can
|
||||||
|
verify by checking the new token is present
|
||||||
|
```text
|
||||||
|
GET /api/v2/me/oauth/tokens/?token=NDInWxGJI4iZgqpsreujjbvzCfJqgR
|
||||||
|
|
||||||
|
HTTP 200 OK
|
||||||
|
Allow: GET, POST, HEAD, OPTIONS
|
||||||
|
Content-Type: application/json
|
||||||
|
Vary: Accept
|
||||||
|
X-API-Node: awx
|
||||||
|
X-API-Query-Count: 4
|
||||||
|
X-API-Query-Time: 0.004s
|
||||||
|
X-API-Time: 0.021s
|
||||||
|
|
||||||
|
{
|
||||||
|
"count": 1,
|
||||||
|
"next": null,
|
||||||
|
"previous": null,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": 36,
|
||||||
|
"type": "access_token",
|
||||||
|
...
|
||||||
|
"user": 1,
|
||||||
|
"token": "NDInWxGJI4iZgqpsreujjbvzCfJqgR",
|
||||||
|
"refresh_token": "DqOrmz8bx3srlHkZNKmDpqA86bnQkT",
|
||||||
|
"application": 6,
|
||||||
|
"expires": "2017-12-06T03:54:06.181917Z",
|
||||||
|
"scope": "read write"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
and the old token is deleted.
|
||||||
|
```text
|
||||||
|
GET /api/v2/me/oauth/tokens/?token=omMFLk7UKpB36WN2Qma9H3gbwEBSOc
|
||||||
|
|
||||||
|
HTTP 200 OK
|
||||||
|
Allow: GET, POST, HEAD, OPTIONS
|
||||||
|
Content-Type: application/json
|
||||||
|
Vary: Accept
|
||||||
|
X-API-Node: awx
|
||||||
|
X-API-Query-Count: 2
|
||||||
|
X-API-Query-Time: 0.003s
|
||||||
|
X-API-Time: 0.018s
|
||||||
|
|
||||||
|
{
|
||||||
|
"count": 0,
|
||||||
|
"next": null,
|
||||||
|
"previous": null,
|
||||||
|
"results": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Revoke an access token
|
||||||
|
Revoking an access token is the same as deleting the token resource object. Suppose we have
|
||||||
|
an existing token to revoke:
|
||||||
|
```text
|
||||||
|
{
|
||||||
|
"id": 30,
|
||||||
|
"type": "access_token",
|
||||||
|
"url": "/api/v2/me/oauth/tokens/30/",
|
||||||
|
...
|
||||||
|
"user": null,
|
||||||
|
"token": "rQONsve372fQwuc2pn76k3IHDCYpi7",
|
||||||
|
"refresh_token": "",
|
||||||
|
"application": 6,
|
||||||
|
"expires": "2017-12-06T03:24:25.614523Z",
|
||||||
|
"scope": "read"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Revoking is conducted by POSTing to `/api/o/revoke_token/` with the token to revoke as parameter:
|
||||||
|
```bash
|
||||||
|
curl -X POST -d "token=rQONsve372fQwuc2pn76k3IHDCYpi7" \
|
||||||
|
-u "gwSPoasWSdNkMDtBN3Hu2WYQpPWCO9SwUEsKK22l:fI6ZpfocHYBGfm1tP92r0yIgCyfRdDQt0Tos9L8a4fNsJjQQMwp9569eIaUBsaVDgt2eiwOGe0bg5m5vCSstClZmtdy359RVx2rQK5YlIWyPlrolpt2LEpVeKXWaiybo" \
|
||||||
|
http://localhost:8013/api/o/revoke_token/ -i
|
||||||
|
```
|
||||||
|
`200 OK` means a successful delete.
|
||||||
|
```text
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
Server: nginx/1.12.2
|
||||||
|
Date: Tue, 05 Dec 2017 18:05:18 GMT
|
||||||
|
Content-Type: text/html; charset=utf-8
|
||||||
|
Content-Length: 0
|
||||||
|
Connection: keep-alive
|
||||||
|
Vary: Accept-Language, Cookie
|
||||||
|
Content-Language: en
|
||||||
|
Strict-Transport-Security: max-age=15768000
|
||||||
|
|
||||||
|
```
|
||||||
|
We can verify the effect by checking if the token is no longer present.
|
||||||
|
```text
|
||||||
|
GET /api/v2/me/oauth/tokens/?token=rQONsve372fQwuc2pn76k3IHDCYpi7
|
||||||
|
|
||||||
|
HTTP 200 OK
|
||||||
|
Allow: GET, POST, HEAD, OPTIONS
|
||||||
|
Content-Type: application/json
|
||||||
|
Vary: Accept
|
||||||
|
X-API-Node: awx
|
||||||
|
X-API-Query-Count: 3
|
||||||
|
X-API-Query-Time: 0.003s
|
||||||
|
X-API-Time: 0.098s
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{
|
||||||
|
"count": 0,
|
||||||
|
"next": null,
|
||||||
|
"previous": null,
|
||||||
|
"results": []
|
||||||
|
}
|
||||||
|
```
|
||||||
18
awx/api/urls/oauth.py
Normal file
18
awx/api/urls/oauth.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Copyright (c) 2017 Ansible, Inc.
|
||||||
|
# All Rights Reserved.
|
||||||
|
|
||||||
|
from django.conf.urls import url
|
||||||
|
|
||||||
|
from oauth2_provider.urls import base_urlpatterns
|
||||||
|
|
||||||
|
from awx.api.views import (
|
||||||
|
ApiOAuthAuthorizationRootView,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
urls = [
|
||||||
|
url(r'^$', ApiOAuthAuthorizationRootView.as_view(), name='oauth_authorization_root_view'),
|
||||||
|
] + base_urlpatterns
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ['urls']
|
||||||
@@ -5,6 +5,10 @@ from __future__ import absolute_import, unicode_literals
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.conf.urls import include, url
|
from django.conf.urls import include, url
|
||||||
|
|
||||||
|
from awx.api.generics import (
|
||||||
|
LoggedLoginView,
|
||||||
|
LoggedLogoutView,
|
||||||
|
)
|
||||||
from awx.api.views import (
|
from awx.api.views import (
|
||||||
ApiRootView,
|
ApiRootView,
|
||||||
ApiV1RootView,
|
ApiV1RootView,
|
||||||
@@ -60,6 +64,8 @@ from .schedule import urls as schedule_urls
|
|||||||
from .activity_stream import urls as activity_stream_urls
|
from .activity_stream import urls as activity_stream_urls
|
||||||
from .instance import urls as instance_urls
|
from .instance import urls as instance_urls
|
||||||
from .instance_group import urls as instance_group_urls
|
from .instance_group import urls as instance_group_urls
|
||||||
|
from .user_oauth import urls as user_oauth_urls
|
||||||
|
from .oauth import urls as oauth_urls
|
||||||
|
|
||||||
|
|
||||||
v1_urls = [
|
v1_urls = [
|
||||||
@@ -116,6 +122,7 @@ v2_urls = [
|
|||||||
url(r'^jobs/(?P<pk>[0-9]+)/credentials/$', JobCredentialsList.as_view(), name='job_credentials_list'),
|
url(r'^jobs/(?P<pk>[0-9]+)/credentials/$', JobCredentialsList.as_view(), name='job_credentials_list'),
|
||||||
url(r'^job_templates/(?P<pk>[0-9]+)/extra_credentials/$', JobTemplateExtraCredentialsList.as_view(), name='job_template_extra_credentials_list'),
|
url(r'^job_templates/(?P<pk>[0-9]+)/extra_credentials/$', JobTemplateExtraCredentialsList.as_view(), name='job_template_extra_credentials_list'),
|
||||||
url(r'^job_templates/(?P<pk>[0-9]+)/credentials/$', JobTemplateCredentialsList.as_view(), name='job_template_credentials_list'),
|
url(r'^job_templates/(?P<pk>[0-9]+)/credentials/$', JobTemplateCredentialsList.as_view(), name='job_template_credentials_list'),
|
||||||
|
url(r'^me/oauth/', include(user_oauth_urls))
|
||||||
url(r'^schedules/preview/$', SchedulePreview.as_view(), name='schedule_rrule'),
|
url(r'^schedules/preview/$', SchedulePreview.as_view(), name='schedule_rrule'),
|
||||||
url(r'^schedules/zoneinfo/$', ScheduleZoneInfo.as_view(), name='schedule_zoneinfo'),
|
url(r'^schedules/zoneinfo/$', ScheduleZoneInfo.as_view(), name='schedule_zoneinfo'),
|
||||||
]
|
]
|
||||||
@@ -125,6 +132,14 @@ urlpatterns = [
|
|||||||
url(r'^$', ApiRootView.as_view(), name='api_root_view'),
|
url(r'^$', ApiRootView.as_view(), name='api_root_view'),
|
||||||
url(r'^(?P<version>(v2))/', include(v2_urls)),
|
url(r'^(?P<version>(v2))/', include(v2_urls)),
|
||||||
url(r'^(?P<version>(v1|v2))/', include(v1_urls)),
|
url(r'^(?P<version>(v1|v2))/', include(v1_urls)),
|
||||||
|
url(r'^login/$', LoggedLoginView.as_view(
|
||||||
|
template_name='rest_framework/login.html',
|
||||||
|
extra_context={'inside_login_context': True}
|
||||||
|
), name='login'),
|
||||||
|
url(r'^logout/$', LoggedLogoutView.as_view(
|
||||||
|
next_page='/api/', redirect_field_name='next'
|
||||||
|
), name='logout'),
|
||||||
|
url(r'^o/', include(oauth_urls))
|
||||||
]
|
]
|
||||||
if settings.SETTINGS_MODULE == 'awx.settings.development':
|
if settings.SETTINGS_MODULE == 'awx.settings.development':
|
||||||
from awx.api.swagger import SwaggerSchemaView
|
from awx.api.swagger import SwaggerSchemaView
|
||||||
|
|||||||
49
awx/api/urls/user_oauth.py
Normal file
49
awx/api/urls/user_oauth.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# Copyright (c) 2017 Ansible, Inc.
|
||||||
|
# All Rights Reserved.
|
||||||
|
|
||||||
|
from django.conf.urls import url
|
||||||
|
|
||||||
|
from awx.api.views import (
|
||||||
|
UserMeOauthRootView,
|
||||||
|
UserMeOauthApplicationList,
|
||||||
|
UserMeOauthApplicationDetail,
|
||||||
|
UserMeOauthApplicationTokenList,
|
||||||
|
UserMeOauthApplicationActivityStreamList,
|
||||||
|
UserMeOauthTokenList,
|
||||||
|
UserMeOauthTokenDetail,
|
||||||
|
UserMeOauthTokenActivityStreamList
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
urls = [
|
||||||
|
url(r'^$', UserMeOauthRootView.as_view(), name='user_me_oauth_root_view'),
|
||||||
|
url(r'^applications/$', UserMeOauthApplicationList.as_view(), name='user_me_oauth_application_list'),
|
||||||
|
url(
|
||||||
|
r'^applications/(?P<pk>[0-9]+)/$',
|
||||||
|
UserMeOauthApplicationDetail.as_view(),
|
||||||
|
name='user_me_oauth_application_detail'
|
||||||
|
),
|
||||||
|
url(
|
||||||
|
r'^applications/(?P<pk>[0-9]+)/tokens/$',
|
||||||
|
UserMeOauthApplicationTokenList.as_view(),
|
||||||
|
name='user_me_oauth_application_token_list'
|
||||||
|
),
|
||||||
|
url(
|
||||||
|
r'^applications/(?P<pk>[0-9]+)/activity_stream/$',
|
||||||
|
UserMeOauthApplicationActivityStreamList.as_view(),
|
||||||
|
name='user_me_oauth_application_activity_stream_list'
|
||||||
|
),
|
||||||
|
url(r'^tokens/$', UserMeOauthTokenList.as_view(), name='user_me_oauth_token_list'),
|
||||||
|
url(
|
||||||
|
r'^tokens/(?P<pk>[0-9]+)/$',
|
||||||
|
UserMeOauthTokenDetail.as_view(),
|
||||||
|
name='user_me_oauth_token_detail'
|
||||||
|
),
|
||||||
|
url(
|
||||||
|
r'^tokens/(?P<pk>[0-9]+)/activity_stream/$',
|
||||||
|
UserMeOauthTokenActivityStreamList.as_view(),
|
||||||
|
name='user_me_oauth_token_activity_stream_list'
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
__all__ = ['urls']
|
||||||
@@ -64,7 +64,7 @@ from awx.api.authentication import TokenGetAuthentication
|
|||||||
from awx.api.filters import V1CredentialFilterBackend
|
from awx.api.filters import V1CredentialFilterBackend
|
||||||
from awx.api.generics import get_view_name
|
from awx.api.generics import get_view_name
|
||||||
from awx.api.generics import * # noqa
|
from awx.api.generics import * # noqa
|
||||||
from awx.api.versioning import reverse, get_request_version
|
from awx.api.versioning import reverse, get_request_version, drf_reverse
|
||||||
from awx.conf.license import get_license, feature_enabled, feature_exists, LicenseForbids
|
from awx.conf.license import get_license, feature_enabled, feature_exists, LicenseForbids
|
||||||
from awx.main.models import * # noqa
|
from awx.main.models import * # noqa
|
||||||
from awx.main.utils import * # noqa
|
from awx.main.utils import * # noqa
|
||||||
@@ -204,6 +204,22 @@ class ApiRootView(APIView):
|
|||||||
if feature_enabled('rebranding'):
|
if feature_enabled('rebranding'):
|
||||||
data['custom_logo'] = settings.CUSTOM_LOGO
|
data['custom_logo'] = settings.CUSTOM_LOGO
|
||||||
data['custom_login_info'] = settings.CUSTOM_LOGIN_INFO
|
data['custom_login_info'] = settings.CUSTOM_LOGIN_INFO
|
||||||
|
data['oauth'] = drf_reverse('api:oauth_authorization_root_view')
|
||||||
|
return Response(data)
|
||||||
|
|
||||||
|
|
||||||
|
class ApiOAuthAuthorizationRootView(APIView):
|
||||||
|
|
||||||
|
authentication_classes = []
|
||||||
|
permission_classes = (AllowAny,)
|
||||||
|
view_name = _("API OAuth Authorization Root")
|
||||||
|
versioning_class = None
|
||||||
|
|
||||||
|
def get(self, request, format=None):
|
||||||
|
data = OrderedDict()
|
||||||
|
data['authorize'] = drf_reverse('api:authorize')
|
||||||
|
data['token'] = drf_reverse('api:token')
|
||||||
|
data['revoke_token'] = drf_reverse('api:revoke-token')
|
||||||
return Response(data)
|
return Response(data)
|
||||||
|
|
||||||
|
|
||||||
@@ -223,6 +239,8 @@ class ApiVersionRootView(APIView):
|
|||||||
data['config'] = reverse('api:api_v1_config_view', request=request)
|
data['config'] = reverse('api:api_v1_config_view', request=request)
|
||||||
data['settings'] = reverse('api:setting_category_list', request=request)
|
data['settings'] = reverse('api:setting_category_list', request=request)
|
||||||
data['me'] = reverse('api:user_me_list', request=request)
|
data['me'] = reverse('api:user_me_list', request=request)
|
||||||
|
if get_request_version(request) > 1:
|
||||||
|
data['oauth'] = reverse('api:user_me_oauth_root_view', request=request)
|
||||||
data['dashboard'] = reverse('api:dashboard_view', request=request)
|
data['dashboard'] = reverse('api:dashboard_view', request=request)
|
||||||
data['organizations'] = reverse('api:organization_list', request=request)
|
data['organizations'] = reverse('api:organization_list', request=request)
|
||||||
data['users'] = reverse('api:user_list', request=request)
|
data['users'] = reverse('api:user_list', request=request)
|
||||||
@@ -1554,6 +1572,76 @@ class UserMeList(ListAPIView):
|
|||||||
return self.model.objects.filter(pk=self.request.user.pk)
|
return self.model.objects.filter(pk=self.request.user.pk)
|
||||||
|
|
||||||
|
|
||||||
|
class UserMeOauthRootView(APIView):
|
||||||
|
|
||||||
|
view_name = _("OAuth Root")
|
||||||
|
|
||||||
|
def get(self, request, format=None):
|
||||||
|
data = OrderedDict()
|
||||||
|
data['applications'] = reverse('api:user_me_oauth_application_list', request=request)
|
||||||
|
data['tokens'] = reverse('api:user_me_oauth_token_list', request=request)
|
||||||
|
return Response(data)
|
||||||
|
|
||||||
|
|
||||||
|
class UserMeOauthApplicationList(ListCreateAPIView):
|
||||||
|
|
||||||
|
view_name = _("OAuth Applications")
|
||||||
|
|
||||||
|
model = Application
|
||||||
|
serializer_class = OauthApplicationSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class UserMeOauthApplicationDetail(RetrieveUpdateDestroyAPIView):
|
||||||
|
|
||||||
|
view_name = _("OAuth Application Detail")
|
||||||
|
|
||||||
|
model = Application
|
||||||
|
serializer_class = OauthApplicationSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class UserMeOauthApplicationTokenList(SubListCreateAPIView):
|
||||||
|
|
||||||
|
view_name = _("OAuth Application Tokens")
|
||||||
|
|
||||||
|
model = AccessToken
|
||||||
|
serializer_class = OauthTokenSerializer
|
||||||
|
parent_model = Application
|
||||||
|
relationship = 'accesstoken_set'
|
||||||
|
parent_key = 'application'
|
||||||
|
|
||||||
|
|
||||||
|
class UserMeOauthApplicationActivityStreamList(ActivityStreamEnforcementMixin, SubListAPIView):
|
||||||
|
|
||||||
|
model = ActivityStream
|
||||||
|
serializer_class = ActivityStreamSerializer
|
||||||
|
parent_model = Application
|
||||||
|
relationship = 'activitystream_set'
|
||||||
|
|
||||||
|
|
||||||
|
class UserMeOauthTokenList(ListCreateAPIView):
|
||||||
|
|
||||||
|
view_name = _("OAuth Tokens")
|
||||||
|
|
||||||
|
model = AccessToken
|
||||||
|
serializer_class = OauthTokenSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class UserMeOauthTokenDetail(RetrieveUpdateDestroyAPIView):
|
||||||
|
|
||||||
|
view_name = _("OAuth Token Detail")
|
||||||
|
|
||||||
|
model = AccessToken
|
||||||
|
serializer_class = OauthTokenSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class UserMeOauthTokenActivityStreamList(ActivityStreamEnforcementMixin, SubListAPIView):
|
||||||
|
|
||||||
|
model = ActivityStream
|
||||||
|
serializer_class = ActivityStreamSerializer
|
||||||
|
parent_model = AccessToken
|
||||||
|
relationship = 'activitystream_set'
|
||||||
|
|
||||||
|
|
||||||
class UserTeamsList(ListAPIView):
|
class UserTeamsList(ListAPIView):
|
||||||
|
|
||||||
model = User
|
model = User
|
||||||
|
|||||||
@@ -11,8 +11,16 @@ class ConfConfig(AppConfig):
|
|||||||
name = 'awx.conf'
|
name = 'awx.conf'
|
||||||
verbose_name = _('Configuration')
|
verbose_name = _('Configuration')
|
||||||
|
|
||||||
|
def configure_oauth2_provider(self, settings):
|
||||||
|
from oauth2_provider import settings as o_settings
|
||||||
|
o_settings.oauth2_settings = o_settings.OAuth2ProviderSettings(
|
||||||
|
settings.OAUTH2_PROVIDER, o_settings.DEFAULTS,
|
||||||
|
o_settings.IMPORT_STRINGS, o_settings.MANDATORY
|
||||||
|
)
|
||||||
|
|
||||||
def ready(self):
|
def ready(self):
|
||||||
self.module.autodiscover()
|
self.module.autodiscover()
|
||||||
from .settings import SettingsWrapper
|
from .settings import SettingsWrapper
|
||||||
SettingsWrapper.initialize()
|
SettingsWrapper.initialize()
|
||||||
configure_external_logger(settings)
|
configure_external_logger(settings)
|
||||||
|
self.configure_oauth2_provider(settings)
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ from django.core.exceptions import ObjectDoesNotExist
|
|||||||
# Django REST Framework
|
# Django REST Framework
|
||||||
from rest_framework.exceptions import ParseError, PermissionDenied, ValidationError
|
from rest_framework.exceptions import ParseError, PermissionDenied, ValidationError
|
||||||
|
|
||||||
|
# Django OAuth Toolkit
|
||||||
|
from oauth2_provider.models import Application, AccessToken
|
||||||
|
|
||||||
# AWX
|
# AWX
|
||||||
from awx.main.utils import (
|
from awx.main.utils import (
|
||||||
get_object_or_400,
|
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
|
Return True if user can perform action against model_class with the
|
||||||
provided parameters.
|
provided parameters.
|
||||||
'''
|
'''
|
||||||
|
if 'write' not in getattr(user, 'oauth_scopes', ['write']) and action != 'read':
|
||||||
|
return False
|
||||||
access_class = access_registry[model_class]
|
access_class = access_registry[model_class]
|
||||||
access_instance = access_class(user)
|
access_instance = access_class(user)
|
||||||
access_method = getattr(access_instance, 'can_%s' % action)
|
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)
|
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):
|
class OrganizationAccess(BaseAccess):
|
||||||
'''
|
'''
|
||||||
I can see organizations when:
|
I can see organizations when:
|
||||||
|
|||||||
@@ -1,17 +1,11 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import urllib
|
|
||||||
|
|
||||||
from channels import Group, channel_layers
|
from channels import Group
|
||||||
from channels.sessions import channel_session
|
from channels.auth import channel_session_user_from_http, channel_session_user
|
||||||
from channels.handler import AsgiRequest
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.core.serializers.json import DjangoJSONEncoder
|
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')
|
logger = logging.getLogger('awx.main.consumers')
|
||||||
|
|
||||||
@@ -22,51 +16,29 @@ def discard_groups(message):
|
|||||||
Group(group).discard(message.reply_channel)
|
Group(group).discard(message.reply_channel)
|
||||||
|
|
||||||
|
|
||||||
@channel_session
|
@channel_session_user_from_http
|
||||||
def ws_connect(message):
|
def ws_connect(message):
|
||||||
message.reply_channel.send({"accept": True})
|
message.reply_channel.send({"accept": True})
|
||||||
|
|
||||||
message.content['method'] = 'FAKE'
|
message.content['method'] = 'FAKE'
|
||||||
request = AsgiRequest(message)
|
if message.user.is_authenticated():
|
||||||
token = request.COOKIES.get('token', None)
|
message.reply_channel.send(
|
||||||
if token is not None:
|
{"text": json.dumps({"accept": True, "user": message.user.id})}
|
||||||
token = urllib.unquote(token).strip('"')
|
)
|
||||||
try:
|
else:
|
||||||
auth_token = AuthToken.objects.get(key=token)
|
logger.error("Request user is not authenticated to use websocket.")
|
||||||
if auth_token.in_valid_tokens:
|
message.reply_channel.send({"close": True})
|
||||||
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})
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@channel_session
|
@channel_session_user
|
||||||
def ws_disconnect(message):
|
def ws_disconnect(message):
|
||||||
discard_groups(message)
|
discard_groups(message)
|
||||||
|
|
||||||
|
|
||||||
@channel_session
|
@channel_session_user
|
||||||
def ws_receive(message):
|
def ws_receive(message):
|
||||||
from awx.main.access import consumer_access
|
from awx.main.access import consumer_access
|
||||||
channel_layer_settings = channel_layers.configs[message.channel_layer.alias]
|
user = message.user
|
||||||
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)
|
|
||||||
raw_data = message.content['text']
|
raw_data = message.content['text']
|
||||||
data = json.loads(raw_data)
|
data = json.loads(raw_data)
|
||||||
|
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -24,6 +24,7 @@ from awx.main.models.fact import * # noqa
|
|||||||
from awx.main.models.label import * # noqa
|
from awx.main.models.label import * # noqa
|
||||||
from awx.main.models.workflow import * # noqa
|
from awx.main.models.workflow import * # noqa
|
||||||
from awx.main.models.channels 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
|
# Monkeypatch Django serializer to ignore django-taggit fields (which break
|
||||||
# the dumpdata command; see https://github.com/alex/django-taggit/issues/155).
|
# 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)
|
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 signal handlers only after models have been defined.
|
||||||
import awx.main.signals # noqa
|
import awx.main.signals # noqa
|
||||||
|
|
||||||
@@ -143,6 +162,8 @@ activity_stream_registrar.connect(User)
|
|||||||
activity_stream_registrar.connect(WorkflowJobTemplate)
|
activity_stream_registrar.connect(WorkflowJobTemplate)
|
||||||
activity_stream_registrar.connect(WorkflowJobTemplateNode)
|
activity_stream_registrar.connect(WorkflowJobTemplateNode)
|
||||||
activity_stream_registrar.connect(WorkflowJob)
|
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 API filtering on certain Django-supplied sensitive fields
|
||||||
prevent_search(User._meta.get_field('password'))
|
prevent_search(User._meta.get_field('password'))
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ class ActivityStream(models.Model):
|
|||||||
label = models.ManyToManyField("Label", blank=True)
|
label = models.ManyToManyField("Label", blank=True)
|
||||||
role = models.ManyToManyField("Role", blank=True)
|
role = models.ManyToManyField("Role", blank=True)
|
||||||
instance_group = models.ManyToManyField("InstanceGroup", 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)
|
setting = JSONField(blank=True)
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import uuid
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import models, connection
|
from django.db import models, connection
|
||||||
from django.contrib.auth.models import User
|
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.timezone import now as tz_now
|
||||||
from django.utils.translation import ugettext_lazy as _
|
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
|
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):
|
class Organization(CommonModel, NotificationFieldsModel, ResourceMixin, CustomVirtualEnvMixin):
|
||||||
@@ -269,6 +270,42 @@ class AuthToken(BaseModel):
|
|||||||
return self.key
|
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.
|
# Add get_absolute_url method to User model if not present.
|
||||||
if not hasattr(User, 'get_absolute_url'):
|
if not hasattr(User, 'get_absolute_url'):
|
||||||
def user_get_absolute_url(user, request=None):
|
def user_get_absolute_url(user, request=None):
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import json
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db.models.signals import post_save, pre_delete, post_delete, m2m_changed
|
from django.db.models.signals import post_save, pre_delete, post_delete, m2m_changed
|
||||||
from django.dispatch import receiver
|
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
|
# Django-CRUM
|
||||||
from crum import get_current_request, get_current_user
|
from crum import get_current_request, get_current_user
|
||||||
@@ -20,6 +23,7 @@ import six
|
|||||||
|
|
||||||
# AWX
|
# AWX
|
||||||
from awx.main.models import * # noqa
|
from awx.main.models import * # noqa
|
||||||
|
from django.contrib.sessions.models import Session
|
||||||
from awx.api.serializers import * # noqa
|
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 model_instance_diff, model_to_dict, camelcase_to_underscore
|
||||||
from awx.main.utils import ignore_inventory_computed_fields, ignore_inventory_group_removal, _inventory_updates
|
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))
|
inventory.schedule_deletion(user_id=getattr(user, 'id', None))
|
||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
logger.debug(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'
|
||||||
|
)
|
||||||
|
|||||||
@@ -199,6 +199,8 @@ def handle_setting_changes(self, setting_keys):
|
|||||||
if key.startswith('LOG_AGGREGATOR_'):
|
if key.startswith('LOG_AGGREGATOR_'):
|
||||||
restart_local_services(['uwsgi', 'celery', 'beat', 'callback'])
|
restart_local_services(['uwsgi', 'celery', 'beat', 'callback'])
|
||||||
break
|
break
|
||||||
|
elif key == 'OAUTH2_PROVIDER':
|
||||||
|
restart_local_services(['uwsgi'])
|
||||||
|
|
||||||
|
|
||||||
@shared_task(bind=True, queue='tower_broadcast_all', base=LogErrorsTask)
|
@shared_task(bind=True, queue='tower_broadcast_all', base=LogErrorsTask)
|
||||||
|
|||||||
135
awx/main/tests/functional/api/test_oauth.py
Normal file
135
awx/main/tests/functional/api/test_oauth.py
Normal 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
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from awx.api.versioning import reverse
|
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 response.status_code == 201
|
||||||
assert not response.data['is_superuser']
|
assert not response.data['is_superuser']
|
||||||
assert not response.data['is_system_auditor']
|
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
|
@pytest.mark.django_db
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ from awx.main.models.notifications import (
|
|||||||
)
|
)
|
||||||
from awx.main.models.workflow import WorkflowJobTemplate
|
from awx.main.models.workflow import WorkflowJobTemplate
|
||||||
from awx.main.models.ad_hoc_commands import AdHocCommand
|
from awx.main.models.ad_hoc_commands import AdHocCommand
|
||||||
|
from awx.main.models import Application
|
||||||
|
|
||||||
__SWAGGER_REQUESTS__ = {}
|
__SWAGGER_REQUESTS__ = {}
|
||||||
|
|
||||||
@@ -535,6 +536,9 @@ def _request(verb):
|
|||||||
|
|
||||||
view, view_args, view_kwargs = resolve(urlparse(url)[2])
|
view, view_args, view_kwargs = resolve(urlparse(url)[2])
|
||||||
request = getattr(APIRequestFactory(), verb)(url, **kwargs)
|
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:
|
if middleware:
|
||||||
middleware.process_request(request)
|
middleware.process_request(request)
|
||||||
if user:
|
if user:
|
||||||
@@ -545,7 +549,7 @@ def _request(verb):
|
|||||||
middleware.process_response(request, response)
|
middleware.process_response(request, response)
|
||||||
if expect:
|
if expect:
|
||||||
if response.status_code != expect:
|
if response.status_code != expect:
|
||||||
if response.data is not None:
|
if getattr(response, 'data', None):
|
||||||
try:
|
try:
|
||||||
data_copy = response.data.copy()
|
data_copy = response.data.copy()
|
||||||
# Make translated strings printable
|
# Make translated strings printable
|
||||||
@@ -558,7 +562,6 @@ def _request(verb):
|
|||||||
response.data[key] = str(value)
|
response.data[key] = str(value)
|
||||||
except Exception:
|
except Exception:
|
||||||
response.data = data_copy
|
response.data = data_copy
|
||||||
print(response.data)
|
|
||||||
assert response.status_code == expect
|
assert response.status_code == expect
|
||||||
if hasattr(response, 'render'):
|
if hasattr(response, 'render'):
|
||||||
response.render()
|
response.render()
|
||||||
@@ -727,3 +730,11 @@ def get_db_prep_save(self, value, connection, **kwargs):
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def monkeypatch_jsonbfield_get_db_prep_save(mocker):
|
def monkeypatch_jsonbfield_get_db_prep_save(mocker):
|
||||||
JSONField.get_db_prep_save = get_db_prep_save
|
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'
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
|
|
||||||
114
awx/main/tests/functional/test_rbac_oauth.py
Normal file
114
awx/main/tests/functional/test_rbac_oauth.py
Normal 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
|
||||||
|
)
|
||||||
100
awx/main/tests/functional/test_session.py
Normal file
100
awx/main/tests/functional/test_session.py
Normal 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()
|
||||||
@@ -109,6 +109,8 @@ MEDIA_ROOT = os.path.join(BASE_DIR, 'public', 'media')
|
|||||||
# Examples: "http://media.lawrence.com", "http://example.com/media/"
|
# Examples: "http://media.lawrence.com", "http://example.com/media/"
|
||||||
MEDIA_URL = '/media/'
|
MEDIA_URL = '/media/'
|
||||||
|
|
||||||
|
LOGIN_URL = '/api/login/'
|
||||||
|
|
||||||
# Absolute filesystem path to the directory to host projects (with playbooks).
|
# Absolute filesystem path to the directory to host projects (with playbooks).
|
||||||
# This directory should not be web-accessible.
|
# This directory should not be web-accessible.
|
||||||
PROJECTS_ROOT = os.path.join(BASE_DIR, 'projects')
|
PROJECTS_ROOT = os.path.join(BASE_DIR, 'projects')
|
||||||
@@ -187,6 +189,15 @@ JOB_EVENT_MAX_QUEUE_SIZE = 10000
|
|||||||
# Disallow sending session cookies over insecure connections
|
# Disallow sending session cookies over insecure connections
|
||||||
SESSION_COOKIE_SECURE = True
|
SESSION_COOKIE_SECURE = True
|
||||||
|
|
||||||
|
# Seconds before sessions expire.
|
||||||
|
# Note: This setting may be overridden by database settings.
|
||||||
|
SESSION_COOKIE_AGE = 1209600
|
||||||
|
|
||||||
|
# Maximum number of per-user valid, concurrent sessions.
|
||||||
|
# -1 is unlimited
|
||||||
|
# Note: This setting may be overridden by database settings.
|
||||||
|
SESSIONS_PER_USER = -1
|
||||||
|
|
||||||
# Disallow sending csrf cookies over insecure connections
|
# Disallow sending csrf cookies over insecure connections
|
||||||
CSRF_COOKIE_SECURE = True
|
CSRF_COOKIE_SECURE = True
|
||||||
|
|
||||||
@@ -253,6 +264,7 @@ INSTALLED_APPS = (
|
|||||||
'django.contrib.sessions',
|
'django.contrib.sessions',
|
||||||
'django.contrib.sites',
|
'django.contrib.sites',
|
||||||
'django.contrib.staticfiles',
|
'django.contrib.staticfiles',
|
||||||
|
'oauth2_provider',
|
||||||
'rest_framework',
|
'rest_framework',
|
||||||
'django_extensions',
|
'django_extensions',
|
||||||
'django_celery_results',
|
'django_celery_results',
|
||||||
@@ -275,9 +287,9 @@ REST_FRAMEWORK = {
|
|||||||
'DEFAULT_PAGINATION_CLASS': 'awx.api.pagination.Pagination',
|
'DEFAULT_PAGINATION_CLASS': 'awx.api.pagination.Pagination',
|
||||||
'PAGE_SIZE': 25,
|
'PAGE_SIZE': 25,
|
||||||
'DEFAULT_AUTHENTICATION_CLASSES': (
|
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||||
'awx.api.authentication.TokenAuthentication',
|
'awx.api.authentication.LoggedOAuth2Authentication',
|
||||||
|
'awx.api.authentication.SessionAuthentication',
|
||||||
'awx.api.authentication.LoggedBasicAuthentication',
|
'awx.api.authentication.LoggedBasicAuthentication',
|
||||||
#'rest_framework.authentication.SessionAuthentication',
|
|
||||||
),
|
),
|
||||||
'DEFAULT_PERMISSION_CLASSES': (
|
'DEFAULT_PERMISSION_CLASSES': (
|
||||||
'awx.api.permissions.ModelAccessPermission',
|
'awx.api.permissions.ModelAccessPermission',
|
||||||
@@ -322,6 +334,11 @@ AUTHENTICATION_BACKENDS = (
|
|||||||
'django.contrib.auth.backends.ModelBackend',
|
'django.contrib.auth.backends.ModelBackend',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Django OAuth Toolkit settings
|
||||||
|
OAUTH2_PROVIDER_APPLICATION_MODEL = 'oauth2_provider.Application'
|
||||||
|
OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL = 'oauth2_provider.AccessToken'
|
||||||
|
OAUTH2_PROVIDER = {}
|
||||||
|
|
||||||
# LDAP server (default to None to skip using LDAP authentication).
|
# LDAP server (default to None to skip using LDAP authentication).
|
||||||
# Note: This setting may be overridden by database settings.
|
# Note: This setting may be overridden by database settings.
|
||||||
AUTH_LDAP_SERVER_URI = None
|
AUTH_LDAP_SERVER_URI = None
|
||||||
|
|||||||
@@ -8,18 +8,14 @@ import urllib
|
|||||||
import six
|
import six
|
||||||
|
|
||||||
# Django
|
# Django
|
||||||
from django.contrib.auth import login, logout
|
from django.utils.functional import LazyObject
|
||||||
from django.shortcuts import redirect
|
from django.shortcuts import redirect
|
||||||
from django.utils.timezone import now
|
|
||||||
|
|
||||||
# Python Social Auth
|
# Python Social Auth
|
||||||
from social_core.exceptions import SocialAuthBaseException
|
from social_core.exceptions import SocialAuthBaseException
|
||||||
from social_core.utils import social_logger
|
from social_core.utils import social_logger
|
||||||
from social_django.middleware import SocialAuthExceptionMiddleware
|
from social_django.middleware import SocialAuthExceptionMiddleware
|
||||||
|
|
||||||
# Ansible Tower
|
|
||||||
from awx.main.models import AuthToken
|
|
||||||
|
|
||||||
|
|
||||||
class SocialAuthMiddleware(SocialAuthExceptionMiddleware):
|
class SocialAuthMiddleware(SocialAuthExceptionMiddleware):
|
||||||
|
|
||||||
@@ -35,33 +31,14 @@ class SocialAuthMiddleware(SocialAuthExceptionMiddleware):
|
|||||||
request.successful_authenticator = None
|
request.successful_authenticator = None
|
||||||
|
|
||||||
if not request.path.startswith('/sso/') and 'migrations_notran' not in request.path:
|
if not request.path.startswith('/sso/') and 'migrations_notran' not in request.path:
|
||||||
|
if request.user and request.user.is_authenticated():
|
||||||
# If token isn't present but we still have a user logged in via Django
|
# The rest of the code base rely hevily on type/inheritance checks,
|
||||||
# sessions, log them out.
|
# LazyObject sent from Django auth middleware can be buggy if not
|
||||||
if not token_key and request.user and request.user.is_authenticated():
|
# converted back to its original object.
|
||||||
logout(request)
|
if isinstance(request.user, LazyObject) and request.user._wrapped:
|
||||||
|
request.user = request.user._wrapped
|
||||||
# If a token is present, make sure it matches a valid one in the
|
request.session.pop('social_auth_error', None)
|
||||||
# database, and log the user via Django session if necessary.
|
request.session.pop('social_auth_last_backend', None)
|
||||||
# Otherwise, log the user out via Django sessions.
|
|
||||||
elif token_key:
|
|
||||||
|
|
||||||
try:
|
|
||||||
auth_token = AuthToken.objects.filter(key=token_key, expires__gt=now())[0]
|
|
||||||
except IndexError:
|
|
||||||
auth_token = None
|
|
||||||
|
|
||||||
if not auth_token and request.user and request.user.is_authenticated():
|
|
||||||
logout(request)
|
|
||||||
elif auth_token and request.user.is_anonymous is False and request.user != auth_token.user:
|
|
||||||
logout(request)
|
|
||||||
auth_token.user.backend = ''
|
|
||||||
login(request, auth_token.user)
|
|
||||||
auth_token.refresh()
|
|
||||||
|
|
||||||
if auth_token and request.user and request.user.is_authenticated():
|
|
||||||
request.session.pop('social_auth_error', None)
|
|
||||||
request.session.pop('social_auth_last_backend', None)
|
|
||||||
|
|
||||||
def process_exception(self, request, exception):
|
def process_exception(self, request, exception):
|
||||||
strategy = getattr(request, 'social_strategy', None)
|
strategy = getattr(request, 'social_strategy', None)
|
||||||
|
|||||||
@@ -8,16 +8,15 @@ import logging
|
|||||||
# Django
|
# Django
|
||||||
from django.core.urlresolvers import reverse
|
from django.core.urlresolvers import reverse
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from django.utils.timezone import now, utc
|
|
||||||
from django.views.generic import View
|
from django.views.generic import View
|
||||||
from django.views.generic.base import RedirectView
|
from django.views.generic.base import RedirectView
|
||||||
from django.utils.encoding import smart_text
|
from django.utils.encoding import smart_text
|
||||||
|
from django.contrib import auth
|
||||||
|
|
||||||
# Django REST Framework
|
# Django REST Framework
|
||||||
from rest_framework.renderers import JSONRenderer
|
from rest_framework.renderers import JSONRenderer
|
||||||
|
|
||||||
# AWX
|
# AWX
|
||||||
from awx.main.models import AuthToken
|
|
||||||
from awx.api.serializers import UserSerializer
|
from awx.api.serializers import UserSerializer
|
||||||
|
|
||||||
logger = logging.getLogger('awx.sso.views')
|
logger = logging.getLogger('awx.sso.views')
|
||||||
@@ -46,25 +45,9 @@ class CompleteView(BaseRedirectView):
|
|||||||
def dispatch(self, request, *args, **kwargs):
|
def dispatch(self, request, *args, **kwargs):
|
||||||
response = super(CompleteView, self).dispatch(request, *args, **kwargs)
|
response = super(CompleteView, self).dispatch(request, *args, **kwargs)
|
||||||
if self.request.user and self.request.user.is_authenticated():
|
if self.request.user and self.request.user.is_authenticated():
|
||||||
request_hash = AuthToken.get_request_hash(self.request)
|
auth.login(self.request, self.request.user)
|
||||||
try:
|
logger.info(smart_text(u"User {} logged in".format(self.request.user.username)))
|
||||||
token = AuthToken.objects.filter(user=request.user,
|
# TODO: remove these 2 cookie-sets after UI removes them
|
||||||
request_hash=request_hash,
|
|
||||||
reason='',
|
|
||||||
expires__gt=now())[0]
|
|
||||||
token.refresh()
|
|
||||||
logger.info(smart_text(u"User {} logged in".format(self.request.user.username)))
|
|
||||||
except IndexError:
|
|
||||||
token = AuthToken.objects.create(user=request.user,
|
|
||||||
request_hash=request_hash)
|
|
||||||
logger.info(smart_text(u"User {} logged in".format(self.request.user.username)))
|
|
||||||
request.session['auth_token_key'] = token.key
|
|
||||||
token_key = urllib.quote('"%s"' % token.key)
|
|
||||||
response.set_cookie('token', token_key)
|
|
||||||
token_expires = token.expires.astimezone(utc).strftime('%Y-%m-%dT%H:%M:%S')
|
|
||||||
token_expires = '%s.%03dZ' % (token_expires, token.expires.microsecond / 1000)
|
|
||||||
token_expires = urllib.quote('"%s"' % token_expires)
|
|
||||||
response.set_cookie('token_expires', token_expires)
|
|
||||||
response.set_cookie('userLoggedIn', 'true')
|
response.set_cookie('userLoggedIn', 'true')
|
||||||
current_user = UserSerializer(self.request.user)
|
current_user = UserSerializer(self.request.user)
|
||||||
current_user = JSONRenderer().render(current_user.data)
|
current_user = JSONRenderer().render(current_user.data)
|
||||||
|
|||||||
@@ -33,8 +33,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="collapse navbar-collapse" id="navbar-collapse">
|
<div class="collapse navbar-collapse" id="navbar-collapse">
|
||||||
<ul class="nav navbar-nav navbar-right">
|
<ul class="nav navbar-nav navbar-right">
|
||||||
{% if user.is_authenticated %}
|
{% if user.is_authenticated and not inside_login_context %}
|
||||||
<li><a href="{% url 'api:user_me_list' version=request.version %}" data-toggle="tooltip" data-placement="bottom" data-delay="1000" title="Logged in as {{ user }}{% if user.get_full_name %} ({{ user.get_full_name }}){% endif %}"><span class="glyphicon glyphicon-user"></span> <span class="visible-xs-inline">Logged in as </span>{{ user }}{% if user.get_full_name %}<span class="visible-xs-inline"> ({{ user.get_full_name }})</span>{% endif %}</a></li>
|
<li><a href="{% url 'api:user_me_list' version=request.version %}" data-toggle="tooltip" data-placement="bottom" data-delay="1000" title="Logged in as {{ user }}{% if user.get_full_name %} ({{ user.get_full_name }}){% endif %}"><span class="glyphicon glyphicon-user"></span> <span class="visible-xs-inline">Logged in as </span>{{ user }}{% if user.get_full_name %}<span class="visible-xs-inline"> ({{ user.get_full_name }})</span>{% endif %}</a></li>
|
||||||
|
<li><a href="{% url 'api:logout' %}?next=/api/login/" data-toggle="tooltip" data-placement="bottom" data-delay="1000" title="Log out"><span class="glyphicon glyphicon-log-out"></span>Log out</a></li>
|
||||||
|
{% else %}
|
||||||
|
<li><a href="{% url 'api:login' %}" data-toggle="tooltip" data-placement="bottom" data-delay="1000" title="Log in"><span class="glyphicon glyphicon-log-in"></span>Log in</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<li><a href="//docs.ansible.com/ansible-tower/{{short_tower_version}}/html/towerapi/index.html" target="_blank" data-toggle="tooltip" data-placement="bottom" data-delay="1000" title="{% trans 'Ansible Tower API Guide' %}"><span class="glyphicon glyphicon-question-sign"></span><span class="visible-xs-inline">{% trans 'Ansible Tower API Guide' %}</span></a></li>
|
<li><a href="//docs.ansible.com/ansible-tower/{{short_tower_version}}/html/towerapi/index.html" target="_blank" data-toggle="tooltip" data-placement="bottom" data-delay="1000" title="{% trans 'Ansible Tower API Guide' %}"><span class="glyphicon glyphicon-question-sign"></span><span class="visible-xs-inline">{% trans 'Ansible Tower API Guide' %}</span></a></li>
|
||||||
<li><a href="/" data-toggle="tooltip" data-placement="bottom" data-delay="1000" title="{% trans 'Back to Ansible Tower' %}"><span class="glyphicon glyphicon-circle-arrow-left"></span><span class="visible-xs-inline">{% trans 'Back to Ansible Tower' %}</span></a></li>
|
<li><a href="/" data-toggle="tooltip" data-placement="bottom" data-delay="1000" title="{% trans 'Back to Ansible Tower' %}"><span class="glyphicon glyphicon-circle-arrow-left"></span><span class="visible-xs-inline">{% trans 'Back to Ansible Tower' %}</span></a></li>
|
||||||
|
|||||||
52
awx/templates/rest_framework/login.html
Normal file
52
awx/templates/rest_framework/login.html
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
{# Partial copy of login_base.html from rest_framework with AWX change. #}
|
||||||
|
{% extends 'rest_framework/api.html' %}
|
||||||
|
{% load i18n staticfiles %}
|
||||||
|
|
||||||
|
{% block breadcrumbs %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="well" style="width: 320px; margin-left: auto; margin-right: auto">
|
||||||
|
<div class="row-fluid">
|
||||||
|
|
||||||
|
<form action="{% url 'api:login' %}" role="form" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="next" value={% if request.GET.next %}"{{ request.GET.next }}"{% else %}"{% url 'api:api_root_view' %}"{% endif %} />
|
||||||
|
<div class="clearfix control-group {% if form.username.errors %}error{% endif %}"
|
||||||
|
id="div_id_username">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="id_username">Username:</label>
|
||||||
|
<input type="text" name="username" maxlength="100"
|
||||||
|
autocapitalize="off"
|
||||||
|
autocorrect="off" class="form-control textinput textInput"
|
||||||
|
id="id_username" required autofocus
|
||||||
|
{% if form.username.value %}value="{{ form.username.value }}"{% endif %}>
|
||||||
|
{% if form.username.errors %}
|
||||||
|
<p class="text-error">{{ form.username.errors|striptags }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="clearfix control-group {% if form.password.errors %}error{% endif %}"
|
||||||
|
id="div_id_password">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="id_password">Password:</label>
|
||||||
|
<input type="password" name="password" maxlength="100" autocapitalize="off"
|
||||||
|
autocorrect="off" class="form-control textinput textInput" id="id_password" required>
|
||||||
|
{% if form.password.errors %}
|
||||||
|
<p class="text-error">{{ form.password.errors|striptags }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if form.non_field_errors %}
|
||||||
|
{% for error in form.non_field_errors %}
|
||||||
|
<div class="text-error" style="border: none; color: red">{{ error }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
<div class="form-actions-no-box">
|
||||||
|
<button type="submit" class="btn btn-primary js-tooltip" title="Log in">LOG IN</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</div><!-- /.row-fluid -->
|
||||||
|
</div><!-- /.well -->
|
||||||
|
{% endblock %}
|
||||||
188
docs/auth/oauth.md
Normal file
188
docs/auth/oauth.md
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
## Introduction
|
||||||
|
Starting from Tower 3.3, OAuth 2 will be used as the new means of token-based authentication. Users
|
||||||
|
will be able to manage OAuth tokens as well as applications, a server-side representation of API
|
||||||
|
clients used to generate tokens. By including an OAuth token as part of the HTTP authentication
|
||||||
|
header, a user will be able to authenticate herself and gain more restrictive permissions on top of
|
||||||
|
the base RBAC permissions of the user. The degree of restriction is controllable by the scope of an
|
||||||
|
OAuth token. Refer to [RFC 6749](https://tools.ietf.org/html/rfc6749) for more details of OAuth 2
|
||||||
|
specification.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
#### Managing OAuth applications and tokens
|
||||||
|
The root of OAuth management endpoints is `/api/<version>/me/oauth/`, which gives a list of endpoint
|
||||||
|
roots for managing individual type of OAuth resources: OAuth application under
|
||||||
|
`/api/<version>/me/oauth/applications/` and OAuth token under `/api/<version>/me/oauth/tokens/`. The
|
||||||
|
reason for OAuth management endpoints to be under `/api/<version>/me/` is because, as an authentication
|
||||||
|
approach, OAuth resources will only take effect to the underlying user.
|
||||||
|
|
||||||
|
Each OAuth application belongs to a specific user, and is used to represent a specific API client
|
||||||
|
on the server side. For example, if AWX user Alice wants to make her `curl` command to talk to
|
||||||
|
AWX, the first thing is creating a new application and probably name it `Alice' curl client`.
|
||||||
|
|
||||||
|
Individual applications will be accessible via their primary keys:
|
||||||
|
`/api/<version>/me/oauth/applications/<primary key of an application>/`. Here is a typical application:
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"type": "application",
|
||||||
|
"url": "/api/v2/me/oauth/applications/1/",
|
||||||
|
"related": {
|
||||||
|
"user": "/api/v2/users/1/",
|
||||||
|
"tokens": "/api/v2/me/oauth/applications/1/tokens/",
|
||||||
|
"activity_stream": "/api/v2/me/oauth/applications/1/activity_stream/"
|
||||||
|
},
|
||||||
|
"summary_fields": {
|
||||||
|
"user": {
|
||||||
|
"id": 1,
|
||||||
|
"username": "admin",
|
||||||
|
"first_name": "",
|
||||||
|
"last_name": ""
|
||||||
|
},
|
||||||
|
"tokens": {
|
||||||
|
"count": 13,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"token": "UdglJ1IkG3YrkzPWkEIwBqWP2xL8X7",
|
||||||
|
"id": 16
|
||||||
|
},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"created": "2017-12-07T16:08:21.341687Z",
|
||||||
|
"modified": "2017-12-07T16:08:21.342015Z",
|
||||||
|
"name": "admin's app",
|
||||||
|
"user": 1,
|
||||||
|
"client_id": "l7VbJdYxqKzoewQR7iZAYkiUI7AdqQhJuiAF4TqJ",
|
||||||
|
"client_secret": "gsplwGti48nJhs5dJ9IMJ0BqN3LvwvFPFgbrQzhXz4bT2oOJBmoCj2egpAUF6Ivme1LFLYAeLwYkmj8AVHEkpYfYxMvK6LTNJG8nO2AIGt7l6MCgj9oD5cgwLvsfGxl2",
|
||||||
|
"client_type": "confidential",
|
||||||
|
"redirect_uris": "",
|
||||||
|
"authorization_grant_type": "password",
|
||||||
|
"skip_authorization": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
In the above example, `user` is the underlying user this application associates to; `name` can be
|
||||||
|
used as a human-readable identifier of the application. The rest fields, like `client_id` and
|
||||||
|
`redirect_uris`, are mainly used for OAuth authorization, which will be covered later in 'Using
|
||||||
|
OAuth token system' section.
|
||||||
|
|
||||||
|
Fields `client_id` and `client_secret` are immutable identifiers of applications, and will be
|
||||||
|
generated during creation; Fields `user` and `authorization_grant_type`, on the other hand, are
|
||||||
|
*immutable on update*, meaning they are required fields on creation, but will become read-only after
|
||||||
|
that.
|
||||||
|
|
||||||
|
On RBAC side:
|
||||||
|
- system admins will be able to see and manipulate all applications in the system;
|
||||||
|
- Organization admins will be able to see and manipulate all applications belonging to Organization
|
||||||
|
members;
|
||||||
|
- Other normal users will only be able to see, update and delete their own applications, but
|
||||||
|
cannot create any new application.
|
||||||
|
|
||||||
|
Note a default new application will be created for each new user. So each new user is supposed to see
|
||||||
|
at least one application available to them.
|
||||||
|
|
||||||
|
Tokens, on the other hand, are resources used to actually authenticate incoming requests and mask the
|
||||||
|
permissions of underlying user. There are two ways of creating a token: POSTing to `/me/oauth/tokens/`
|
||||||
|
endpoint by providing `application` and `scope` fields to point to related application and specify
|
||||||
|
token scope; or POSTing to `/me/oauth/applications/<pk>/tokens/` by providing only `scope`, while
|
||||||
|
the parent application will be automatically linked.
|
||||||
|
|
||||||
|
Individual tokens will be accessible via their primary keys:
|
||||||
|
`/api/<version>/me/oauth/tokens/<primary key of a token>/`. Here is a typical token:
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"id": 17,
|
||||||
|
"type": "access_token",
|
||||||
|
"url": "/api/v2/me/oauth/tokens/17/",
|
||||||
|
"related": {
|
||||||
|
"user": "/api/v2/users/1/",
|
||||||
|
"application": "/api/v2/me/oauth/applications/4/",
|
||||||
|
"activity_stream": "/api/v2/me/oauth/tokens/17/activity_stream/"
|
||||||
|
},
|
||||||
|
"summary_fields": {
|
||||||
|
"application": {
|
||||||
|
"id": 4,
|
||||||
|
"name": "admin's token",
|
||||||
|
"client_id": "D6SwhKbfp2LuUjkmiUpMMYFyNqhpv5PTVci7eXTT"
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"id": 1,
|
||||||
|
"username": "admin",
|
||||||
|
"first_name": "",
|
||||||
|
"last_name": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"created": "2017-12-12T16:48:10.489550Z",
|
||||||
|
"modified": "2017-12-12T16:48:10.522189Z",
|
||||||
|
"user": 1,
|
||||||
|
"token": "kqHqxfpHGRRBXLNCOXxT5Zt3tpJogn",
|
||||||
|
"refresh_token": "miZq3hqSugvYxhzdQYJIBDgIHxJPnT",
|
||||||
|
"application": 4,
|
||||||
|
"expires": "2017-12-13T02:48:10.488180Z",
|
||||||
|
"scope": "read"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
For an OAuth token, the only fully mutable field is `scope`. `application` field is *immutable
|
||||||
|
on update*, and all other fields are totally immutable, and will be auto-populated during creation:
|
||||||
|
`user` field will be the `user` field of related application; `expires` will be generated according
|
||||||
|
to Tower configuration setting `OAUTH2_PROVIDER`; `token` and `refresh_token` will be auto-
|
||||||
|
generated to be non-crashing random strings.
|
||||||
|
|
||||||
|
On RBAC side:
|
||||||
|
- A user will be able to create a token if she is able to see the related application;
|
||||||
|
- System admin is able to see and manipulate every token in the system;
|
||||||
|
- Organization admins will be able to see and manipulate all tokens belonging to Organization
|
||||||
|
members;
|
||||||
|
- Other normal users will only be able to see and manipulate their own tokens.
|
||||||
|
|
||||||
|
#### Using OAuth token system
|
||||||
|
The most significant usage of OAuth is authenticating users. `token` field of a token is used
|
||||||
|
as part of HTTP authentication header, in the format `Authorization: Bearer <token field value>`.
|
||||||
|
Here is a `curl` command example:
|
||||||
|
```
|
||||||
|
curl -H "Authorization: Bearer kqHqxfpHGRRBXLNCOXxT5Zt3tpJogn" http://localhost:8013/api/v2/credentials/
|
||||||
|
```
|
||||||
|
|
||||||
|
According to OAuth 2 specification, users should be able to acquire, revoke and refresh an access
|
||||||
|
token. In AWX the equivalent, and the easiest, way of doing that is creating a token, deleting
|
||||||
|
a token, and deleting a token quickly followed by creating a new one.
|
||||||
|
|
||||||
|
On the other hand, the specification also provides standard ways of doing those. RFC 6749 elaborates
|
||||||
|
on those topics, but in summary, an OAuth token is officially acquired via authorization using
|
||||||
|
authorization information provided by applications (special application fields mentioned above).
|
||||||
|
There are dedicated endpoints for authorization and token acquire; The token acquire endpoint
|
||||||
|
is also responsible for token refresh; and token revoke is done by dedicated token revoke endpoint.
|
||||||
|
|
||||||
|
In AWX, our OAuth system is built on top of
|
||||||
|
[Django Oauth Toolkit](https://django-oauth-toolkit.readthedocs.io/en/latest/), which provides full
|
||||||
|
support on standard authorization, token revoke and refresh. AWX reuses them and puts related
|
||||||
|
endpoints under `/api/o/` endpoint. Detailed examples on some most typical usage of those endpoints
|
||||||
|
are available as description text of `/api/o/`.
|
||||||
|
|
||||||
|
#### Token scope mask over RBAC system
|
||||||
|
The scope of an OAuth token is a space-separated string composed of keywords like 'read' and 'write'.
|
||||||
|
These keywords are configurable and used to specify permission level of the authenticated API client.
|
||||||
|
For the initial OAuth implementation, we use the most simple scope configuration, where the only
|
||||||
|
valid scope keywords are 'read' and 'write'.
|
||||||
|
|
||||||
|
Read and write scopes provide a mask layer over the RBAC permission system of AWX. In specific, a
|
||||||
|
'write' scope gives the authenticated user full permissions the RBAC system provides, while 'read'
|
||||||
|
scope gives the authenticated user only read permissions the RBAC system provides.
|
||||||
|
|
||||||
|
For example, if a user has admin permission to a job template, she can both see and modify, launch
|
||||||
|
and delete the job template if authenticated via session or basic auth. On the other hand, if she
|
||||||
|
is authenticated using OAuth token, and the related token scope is 'read', she can only see but
|
||||||
|
not manipulate or launch the job template, despite she has admin role over it; if the token scope is
|
||||||
|
'write' or 'read write', she can take full advantage of the job template as its admin.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
* All CRUD operations for OAuth applications and tokens should function as described.
|
||||||
|
* RBAC rules applied to OAuth applications and tokens should behave as described.
|
||||||
|
* A default application should be auto-created for each new user.
|
||||||
|
* Incoming requests using unexpired OAuth token correctly in authentication header should be able
|
||||||
|
to successfully authenticate themselves.
|
||||||
|
* Token scope mask over RBAC should work as described.
|
||||||
|
* Tower configuration setting `OAUTH2_PROVIDER` should be configurable and function as described.
|
||||||
|
* `/api/o/` endpoint should work as expected. In specific, all examples given in the description
|
||||||
|
help text should be working (user following the steps should get expected result).
|
||||||
83
docs/auth/session.md
Normal file
83
docs/auth/session.md
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
## Introduction
|
||||||
|
Before Tower 3.3, auth token is used as the main authentication method. Starting from Tower 3.3,
|
||||||
|
session-based authentication will take the place as the main authentication, while auth token
|
||||||
|
will be replaced by OAuth tokens also introduced in 3.3.
|
||||||
|
|
||||||
|
Session authentication is a safer way of utilizing HTTP(S) cookies:
|
||||||
|
|
||||||
|
Theoretically, user can provide authentication information, like username and password, as part of the
|
||||||
|
`Cookie` header, but this method is vulnerable to cookie hijacks, where crackers can see and steal user
|
||||||
|
information from cookie payload.
|
||||||
|
|
||||||
|
Session authentication, on the other hand, sets a single `sessionid` cookie, called 'session'. Session
|
||||||
|
is *a random string which will be mapped to user authentication informations by server*. Crackers who
|
||||||
|
hijacks cookie will only get session itself, which does not imply any critical user info, valid only for
|
||||||
|
a limited time, and can be revoked at any time.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
In session authentication, user log in using endpoint `/api/login/`. GET to `/api/login/` displays the
|
||||||
|
log in page of API browser:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
User should enter correct username and password before clicking on 'LOG IN' button, which fires a POST
|
||||||
|
to `/api/login/` to actually log the user in. The return code of a successful login is 302, meaning upon
|
||||||
|
successful login, the browser will be redirected, the redirected destination is determined by `next` form
|
||||||
|
item described below.
|
||||||
|
|
||||||
|
It should be noted that POST body of `/api/login/` is *not* in JSON, but HTTP form format. 4 items should
|
||||||
|
be provided in the form:
|
||||||
|
* `username`: The username of the user trying to log in.
|
||||||
|
* `password`: The password of the user trying to log in.
|
||||||
|
* `next`: The path of the redirect destination, in API browser `"/api/"` is used.
|
||||||
|
* `csrfmiddlewaretoken`: The CSRF token, usually populated by using Django template `{% csrf_token %}`.
|
||||||
|
|
||||||
|
Session is provided as a return `Set-Cookie` header. Here is a typical one:
|
||||||
|
```
|
||||||
|
Set-Cookie: sessionid=lwan8l5ynhrqvps280rg5upp7n3yp6ds; expires=Tue, 21-Nov-2017 16:33:13 GMT; httponly; Max-Age=1209600; Path=/
|
||||||
|
```
|
||||||
|
Any client should follow the standard rules of [cookie protocol](https://tools.ietf.org/html/rfc6265) to
|
||||||
|
parse that header to obtain information about the session, such as session cookie name (`sessionid`),
|
||||||
|
session cookie value, expiration date, duration, etc.
|
||||||
|
|
||||||
|
The duration of the cookie is configurable by Tower Configuration setting `SESSION_COOKIE_AGE` under
|
||||||
|
category `authentication`. It is an integer denoting the number of seconds the session cookie should
|
||||||
|
live.
|
||||||
|
|
||||||
|
After a valid session is acquired, a client should provide session as a cookie for subsequent requests
|
||||||
|
in order to be authenticated. like
|
||||||
|
```
|
||||||
|
Cookie: sessionid=lwan8l5ynhrqvps280rg5upp7n3yp6ds; ...
|
||||||
|
```
|
||||||
|
|
||||||
|
User should use `/api/logout/` endpoint to log out. In API browser, a logged in user can do that by
|
||||||
|
simply clicking logout button on the nav bar. Under the hood the click issues a GET to '/api/logout/',
|
||||||
|
Upon success, server will invalidate current session and the response header will indicate client
|
||||||
|
to delete the session cookie. User should no longer try using this invalid session.
|
||||||
|
|
||||||
|
The duration of a session is constant. However, user can extend the expiration date of a valid session
|
||||||
|
by performing session acquire with the session provided.
|
||||||
|
|
||||||
|
A Tower configuration setting, `SESSIONS_PER_USER` under category `authentication`, is used to set the
|
||||||
|
maximum number of valid sessions a user can have at the same time. For example, if `SESSIONS_PER_USER`
|
||||||
|
is set to 3, while the same user is logged in via 5 different places, and thus have 5 valid sessions
|
||||||
|
available at the same time, the earliest 2 (5 - 3) sessions created will be invalidated. Tower will try
|
||||||
|
broadcasting, via websocket, to all available clients. The websocket message body will contain a list of
|
||||||
|
invalidated sessions. If a client finds its session in that list, it should try logging out.
|
||||||
|
|
||||||
|
Unlike tokens, sessions are meant to be short-lived and UI-only, therefore whenever a user's password
|
||||||
|
is updated, all sessions she owned will be invalidated and deleted.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
* User should be able to log in via `/api/login/` endpoint by correctly providing all necessary fields.
|
||||||
|
* Logged in users should be able to authenticate themselves by providing correct session auth info.
|
||||||
|
* Logged in users should be able to log out via `/api/logout/`.
|
||||||
|
* The duration of a session cookie should be configurable by `SESSION_COOKIE_AGE`.
|
||||||
|
* The maximum number of concurrent login for one user should be configurable by `SESSIONS_PER_USER`,
|
||||||
|
and over-limit user sessions should be warned by websocket.
|
||||||
|
* When a user's password is changed, all her sessions should be invalidated and deleted.
|
||||||
|
* User should not be able to authenticate either HTTPS(S) request or websocket connect using invalid
|
||||||
|
sessions.
|
||||||
|
* No existing behavior, like job run, inventory update or callback receiver, should be affected
|
||||||
|
by session auth.
|
||||||
BIN
docs/img/auth_session_1.png
Normal file
BIN
docs/img/auth_session_1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
@@ -16,6 +16,7 @@ django-celery-results==1.0.1
|
|||||||
django-crum==0.7.1
|
django-crum==0.7.1
|
||||||
django-extensions==1.7.8
|
django-extensions==1.7.8
|
||||||
django-jsonfield==1.0.1
|
django-jsonfield==1.0.1
|
||||||
|
django-oauth-toolkit==1.0.0
|
||||||
django-polymorphic==1.3
|
django-polymorphic==1.3
|
||||||
django-pglocks==1.0.2
|
django-pglocks==1.0.2
|
||||||
django-radius==1.1.0
|
django-radius==1.1.0
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ django-celery-results==1.0.1
|
|||||||
django-crum==0.7.1
|
django-crum==0.7.1
|
||||||
django-extensions==1.7.8
|
django-extensions==1.7.8
|
||||||
django-jsonfield==1.0.1
|
django-jsonfield==1.0.1
|
||||||
|
django-oauth-toolkit==1.0.0
|
||||||
django-pglocks==1.0.2
|
django-pglocks==1.0.2
|
||||||
django-polymorphic==1.3
|
django-polymorphic==1.3
|
||||||
django-radius==1.1.0
|
django-radius==1.1.0
|
||||||
|
|||||||
Reference in New Issue
Block a user