mirror of
https://github.com/ansible/awx.git
synced 2026-01-11 10:00:01 -03:30
Merge pull request #904 from ansible/oauth_n_session
Implement session-based and OAuth 2 authentications
This commit is contained in:
commit
9493b72f29
@ -2,126 +2,21 @@
|
||||
# All Rights Reserved.
|
||||
|
||||
# Python
|
||||
import urllib
|
||||
import logging
|
||||
|
||||
# Django
|
||||
from django.conf import settings
|
||||
from django.utils.timezone import now as tz_now
|
||||
from django.utils.encoding import smart_text
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
# Django REST Framework
|
||||
from rest_framework import authentication
|
||||
from rest_framework import exceptions
|
||||
from rest_framework import HTTP_HEADER_ENCODING
|
||||
|
||||
# AWX
|
||||
from awx.main.models import AuthToken
|
||||
# Django OAuth Toolkit
|
||||
from oauth2_provider.contrib.rest_framework import OAuth2Authentication
|
||||
|
||||
logger = logging.getLogger('awx.api.authentication')
|
||||
|
||||
|
||||
class TokenAuthentication(authentication.TokenAuthentication):
|
||||
'''
|
||||
Custom token authentication using tokens that expire and are associated
|
||||
with parameters specific to the request.
|
||||
'''
|
||||
|
||||
model = AuthToken
|
||||
|
||||
@staticmethod
|
||||
def _get_x_auth_token_header(request):
|
||||
auth = request.META.get('HTTP_X_AUTH_TOKEN', '')
|
||||
if isinstance(auth, type('')):
|
||||
# Work around django test client oddness
|
||||
auth = auth.encode(HTTP_HEADER_ENCODING)
|
||||
return auth
|
||||
|
||||
@staticmethod
|
||||
def _get_auth_token_cookie(request):
|
||||
token = request.COOKIES.get('token', '')
|
||||
if token:
|
||||
token = urllib.unquote(token).strip('"')
|
||||
return 'token %s' % token
|
||||
return ''
|
||||
|
||||
def authenticate(self, request):
|
||||
self.request = request
|
||||
|
||||
# Prefer the custom X-Auth-Token header over the Authorization header,
|
||||
# to handle cases where the browser submits saved Basic auth and
|
||||
# overrides the UI's normal use of the Authorization header.
|
||||
auth = TokenAuthentication._get_x_auth_token_header(request).split()
|
||||
if not auth or auth[0].lower() != 'token':
|
||||
auth = authentication.get_authorization_header(request).split()
|
||||
# Prefer basic auth over cookie token
|
||||
if auth and auth[0].lower() == 'basic':
|
||||
return None
|
||||
elif not auth or auth[0].lower() != 'token':
|
||||
auth = TokenAuthentication._get_auth_token_cookie(request).split()
|
||||
if not auth or auth[0].lower() != 'token':
|
||||
return None
|
||||
|
||||
if len(auth) == 1:
|
||||
msg = _('Invalid token header. No credentials provided.')
|
||||
raise exceptions.AuthenticationFailed(msg)
|
||||
elif len(auth) > 2:
|
||||
msg = _('Invalid token header. Token string should not contain spaces.')
|
||||
raise exceptions.AuthenticationFailed(msg)
|
||||
|
||||
return self.authenticate_credentials(auth[1])
|
||||
|
||||
def authenticate_credentials(self, key):
|
||||
now = tz_now()
|
||||
# Retrieve the request hash and token.
|
||||
try:
|
||||
request_hash = self.model.get_request_hash(self.request)
|
||||
token = self.model.objects.select_related('user').get(
|
||||
key=key,
|
||||
request_hash=request_hash,
|
||||
)
|
||||
except self.model.DoesNotExist:
|
||||
raise exceptions.AuthenticationFailed(AuthToken.reason_long('invalid_token'))
|
||||
|
||||
# Tell the user why their token was previously invalidated.
|
||||
if token.invalidated:
|
||||
raise exceptions.AuthenticationFailed(AuthToken.reason_long(token.reason))
|
||||
|
||||
# Explicitly handle expired tokens
|
||||
if token.is_expired(now=now):
|
||||
token.invalidate(reason='timeout_reached')
|
||||
raise exceptions.AuthenticationFailed(AuthToken.reason_long('timeout_reached'))
|
||||
|
||||
# Token invalidated due to session limit config being reduced
|
||||
# Session limit reached invalidation will also take place on authentication
|
||||
if settings.AUTH_TOKEN_PER_USER != -1:
|
||||
if not token.in_valid_tokens(now=now):
|
||||
token.invalidate(reason='limit_reached')
|
||||
raise exceptions.AuthenticationFailed(AuthToken.reason_long('limit_reached'))
|
||||
|
||||
# If the user is inactive, then return an error.
|
||||
if not token.user.is_active:
|
||||
raise exceptions.AuthenticationFailed(_('User inactive or deleted'))
|
||||
|
||||
# Refresh the token.
|
||||
# The token is extended from "right now" + configurable setting amount.
|
||||
token.refresh(now=now)
|
||||
|
||||
# Return the user object and the token.
|
||||
return (token.user, token)
|
||||
|
||||
|
||||
class TokenGetAuthentication(TokenAuthentication):
|
||||
|
||||
def authenticate(self, request):
|
||||
if request.method.lower() == 'get':
|
||||
token = request.GET.get('token', None)
|
||||
if token:
|
||||
request.META['HTTP_X_AUTH_TOKEN'] = 'Token %s' % token
|
||||
return super(TokenGetAuthentication, self).authenticate(request)
|
||||
|
||||
|
||||
class LoggedBasicAuthentication(authentication.BasicAuthentication):
|
||||
|
||||
def authenticate(self, request):
|
||||
@ -137,3 +32,28 @@ class LoggedBasicAuthentication(authentication.BasicAuthentication):
|
||||
if not settings.AUTH_BASIC_ENABLED:
|
||||
return
|
||||
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, token.pk
|
||||
)
|
||||
))
|
||||
setattr(user, 'oauth_scopes', [x for x in token.scope.split() if x])
|
||||
return ret
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
# Django
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
# Tower
|
||||
# AWX
|
||||
from awx.conf import fields, register
|
||||
from awx.api.fields import OAuth2ProviderField
|
||||
|
||||
|
||||
register(
|
||||
'AUTH_TOKEN_EXPIRATION',
|
||||
'SESSION_COOKIE_AGE',
|
||||
field_class=fields.IntegerField,
|
||||
min_value=60,
|
||||
label=_('Idle Time Force Log Out'),
|
||||
@ -14,17 +15,15 @@ register(
|
||||
category=_('Authentication'),
|
||||
category_slug='authentication',
|
||||
)
|
||||
|
||||
register(
|
||||
'AUTH_TOKEN_PER_USER',
|
||||
'SESSIONS_PER_USER',
|
||||
field_class=fields.IntegerField,
|
||||
min_value=-1,
|
||||
label=_('Maximum number of simultaneous logins'),
|
||||
help_text=_('Maximum number of simultaneous logins a user may have. To disable enter -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(
|
||||
'AUTH_BASIC_ENABLED',
|
||||
field_class=fields.BooleanField,
|
||||
@ -33,3 +32,15 @@ register(
|
||||
category=_('Authentication'),
|
||||
category_slug='authentication',
|
||||
)
|
||||
register(
|
||||
'OAUTH2_PROVIDER',
|
||||
field_class=OAuth2ProviderField,
|
||||
default={'ACCESS_TOKEN_EXPIRE_SECONDS': 315360000000, 'AUTHORIZATION_CODE_EXPIRE_SECONDS': 600},
|
||||
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.
|
||||
# All Rights Reserved.
|
||||
|
||||
# Django
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
# Django REST Framework
|
||||
from rest_framework import serializers
|
||||
|
||||
# AWX
|
||||
from awx.conf import fields
|
||||
|
||||
__all__ = ['BooleanNullField', 'CharNullField', 'ChoiceNullField', 'VerbatimField']
|
||||
|
||||
|
||||
@ -66,3 +71,19 @@ class VerbatimField(serializers.Field):
|
||||
|
||||
def to_representation(self, 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.contrib.contenttypes.models import ContentType
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.contrib.auth import views as auth_views
|
||||
|
||||
# Django REST Framework
|
||||
from rest_framework.authentication import get_authorization_header
|
||||
@ -59,6 +60,33 @@ logger = logging.getLogger('awx.api.generics')
|
||||
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))
|
||||
if request.user.is_authenticated:
|
||||
return ret
|
||||
else:
|
||||
ret.status = 401
|
||||
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):
|
||||
'''
|
||||
Wrapper around REST framework get_view_name() to support get_name() method
|
||||
|
||||
@ -103,7 +103,8 @@ class ModelAccessPermission(permissions.BasePermission):
|
||||
return False
|
||||
|
||||
# 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
|
||||
|
||||
# Check if view supports the request method before checking permission
|
||||
|
||||
@ -9,6 +9,11 @@ import re
|
||||
import six
|
||||
import urllib
|
||||
from collections import OrderedDict
|
||||
from datetime import timedelta
|
||||
|
||||
# OAuth2
|
||||
from oauthlib.common import generate_token
|
||||
from oauth2_provider.settings import oauth2_settings
|
||||
|
||||
# Django
|
||||
from django.conf import settings
|
||||
@ -67,6 +72,7 @@ DEFAULT_SUMMARY_FIELDS = ('id', 'name', 'description')# , 'created_by', 'modifie
|
||||
SUMMARIZABLE_FK_FIELDS = {
|
||||
'organization': DEFAULT_SUMMARY_FIELDS,
|
||||
'user': ('id', 'username', 'first_name', 'last_name'),
|
||||
'application': ('id', 'name', 'client_id'),
|
||||
'team': DEFAULT_SUMMARY_FIELDS,
|
||||
'inventory': DEFAULT_SUMMARY_FIELDS + ('has_active_failures',
|
||||
'total_hosts',
|
||||
@ -428,6 +434,16 @@ class BaseSerializer(serializers.ModelSerializer):
|
||||
return obj.modified
|
||||
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):
|
||||
# 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.
|
||||
@ -825,6 +841,7 @@ class UserSerializer(BaseSerializer):
|
||||
if new_password:
|
||||
obj.set_password(new_password)
|
||||
obj.save(update_fields=['password'])
|
||||
UserSessionMembership.clear_session_for_user(obj)
|
||||
elif not obj.password:
|
||||
obj.set_unusable_password()
|
||||
obj.save(update_fields=['password'])
|
||||
@ -863,14 +880,19 @@ class UserSerializer(BaseSerializer):
|
||||
def get_related(self, obj):
|
||||
res = super(UserSerializer, self).get_related(obj)
|
||||
res.update(dict(
|
||||
teams = self.reverse('api:user_teams_list', kwargs={'pk': obj.pk}),
|
||||
organizations = self.reverse('api:user_organizations_list', kwargs={'pk': obj.pk}),
|
||||
teams = self.reverse('api:user_teams_list', kwargs={'pk': obj.pk}),
|
||||
organizations = self.reverse('api:user_organizations_list', kwargs={'pk': obj.pk}),
|
||||
admin_of_organizations = self.reverse('api:user_admin_of_organizations_list', kwargs={'pk': obj.pk}),
|
||||
projects = self.reverse('api:user_projects_list', kwargs={'pk': obj.pk}),
|
||||
credentials = self.reverse('api:user_credentials_list', kwargs={'pk': obj.pk}),
|
||||
roles = self.reverse('api:user_roles_list', kwargs={'pk': obj.pk}),
|
||||
activity_stream = self.reverse('api:user_activity_stream_list', kwargs={'pk': obj.pk}),
|
||||
access_list = self.reverse('api:user_access_list', kwargs={'pk': obj.pk}),
|
||||
projects = self.reverse('api:user_projects_list', kwargs={'pk': obj.pk}),
|
||||
credentials = self.reverse('api:user_credentials_list', kwargs={'pk': obj.pk}),
|
||||
roles = self.reverse('api:user_roles_list', kwargs={'pk': obj.pk}),
|
||||
activity_stream = self.reverse('api:user_activity_stream_list', kwargs={'pk': obj.pk}),
|
||||
access_list = self.reverse('api:user_access_list', kwargs={'pk': obj.pk}),
|
||||
applications = self.reverse('api:o_auth2_application_list', kwargs={'pk': obj.pk}),
|
||||
tokens = self.reverse('api:o_auth2_token_list', kwargs={'pk': obj.pk}),
|
||||
authorized_tokens = self.reverse('api:user_authorized_token_list', kwargs={'pk': obj.pk}),
|
||||
personal_tokens = self.reverse('api:o_auth2_personal_token_list', kwargs={'pk': obj.pk}),
|
||||
|
||||
))
|
||||
return res
|
||||
|
||||
@ -906,6 +928,295 @@ class UserSerializer(BaseSerializer):
|
||||
return self._validate_ldap_managed_field(value, 'is_superuser')
|
||||
|
||||
|
||||
class UserAuthorizedTokenSerializer(BaseSerializer):
|
||||
|
||||
refresh_token = serializers.SerializerMethodField()
|
||||
token = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = OAuth2AccessToken
|
||||
fields = (
|
||||
'*', '-name', 'description', 'user', 'token', 'refresh_token',
|
||||
'expires', 'scope', 'application',
|
||||
)
|
||||
read_only_fields = ('user', 'token', 'expires')
|
||||
read_only_on_update_fields = ('application',)
|
||||
|
||||
def get_token(self, obj):
|
||||
request = self.context.get('request', None)
|
||||
try:
|
||||
if request.method == 'POST':
|
||||
return obj.token
|
||||
else:
|
||||
return '*************'
|
||||
except ObjectDoesNotExist:
|
||||
return ''
|
||||
|
||||
def get_refresh_token(self, obj):
|
||||
request = self.context.get('request', None)
|
||||
try:
|
||||
if request.method == 'POST':
|
||||
return getattr(obj.refresh_token, 'token', '')
|
||||
else:
|
||||
return '**************'
|
||||
except ObjectDoesNotExist:
|
||||
return ''
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data['user'] = self.context['request'].user
|
||||
validated_data['token'] = generate_token()
|
||||
validated_data['expires'] = now() + timedelta(
|
||||
seconds=oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS
|
||||
)
|
||||
obj = super(OAuth2TokenSerializer, self).create(validated_data)
|
||||
obj.save()
|
||||
if obj.application is not None:
|
||||
OAuth2RefreshToken.objects.create(
|
||||
user=self.context['request'].user,
|
||||
token=generate_token(),
|
||||
application=obj.application,
|
||||
access_token=obj
|
||||
)
|
||||
return obj
|
||||
|
||||
|
||||
class OAuth2ApplicationSerializer(BaseSerializer):
|
||||
|
||||
class Meta:
|
||||
model = OAuth2Application
|
||||
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 to_representation(self, obj):
|
||||
ret = super(OAuth2ApplicationSerializer, self).to_representation(obj)
|
||||
if obj.client_type == 'public':
|
||||
ret.pop('client_secret')
|
||||
return ret
|
||||
|
||||
|
||||
def get_modified(self, obj):
|
||||
if obj is None:
|
||||
return None
|
||||
return obj.updated
|
||||
|
||||
def get_related(self, obj):
|
||||
ret = super(OAuth2ApplicationSerializer, self).get_related(obj)
|
||||
if obj.user:
|
||||
ret['user'] = self.reverse('api:user_detail', kwargs={'pk': obj.user.pk})
|
||||
ret['tokens'] = self.reverse(
|
||||
'api:o_auth2_application_token_list', kwargs={'pk': obj.pk}
|
||||
)
|
||||
ret['activity_stream'] = self.reverse(
|
||||
'api:o_auth2_application_activity_stream_list', kwargs={'pk': obj.pk}
|
||||
)
|
||||
return ret
|
||||
|
||||
def _summary_field_tokens(self, obj):
|
||||
token_list = [{'id': x.pk, 'token': '**************', 'scope': x.scope} for x in obj.oauth2accesstoken_set.all()[:10]]
|
||||
if has_model_field_prefetched(obj, 'oauth2accesstoken_set'):
|
||||
token_count = len(obj.oauth2accesstoken_set.all())
|
||||
else:
|
||||
if len(token_list) < 10:
|
||||
token_count = len(token_list)
|
||||
else:
|
||||
token_count = obj.oauth2accesstoken_set.count()
|
||||
return {'count': token_count, 'results': token_list}
|
||||
|
||||
def get_summary_fields(self, obj):
|
||||
ret = super(OAuth2ApplicationSerializer, self).get_summary_fields(obj)
|
||||
ret['tokens'] = self._summary_field_tokens(obj)
|
||||
return ret
|
||||
|
||||
|
||||
class OAuth2TokenSerializer(BaseSerializer):
|
||||
|
||||
refresh_token = serializers.SerializerMethodField()
|
||||
token = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = OAuth2AccessToken
|
||||
fields = (
|
||||
'*', '-name', 'description', 'user', 'token', 'refresh_token',
|
||||
'application', 'expires', 'scope',
|
||||
)
|
||||
read_only_fields = ('user', 'token', 'expires')
|
||||
|
||||
def get_modified(self, obj):
|
||||
if obj is None:
|
||||
return None
|
||||
return obj.updated
|
||||
|
||||
def get_related(self, obj):
|
||||
ret = super(OAuth2TokenSerializer, 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:o_auth2_application_detail', kwargs={'pk': obj.application.pk}
|
||||
)
|
||||
ret['activity_stream'] = self.reverse(
|
||||
'api:o_auth2_token_activity_stream_list', kwargs={'pk': obj.pk}
|
||||
)
|
||||
return ret
|
||||
|
||||
def get_token(self, obj):
|
||||
request = self.context.get('request', None)
|
||||
try:
|
||||
if request.method == 'POST':
|
||||
return obj.token
|
||||
else:
|
||||
return '*************'
|
||||
except ObjectDoesNotExist:
|
||||
return ''
|
||||
|
||||
def get_refresh_token(self, obj):
|
||||
request = self.context.get('request', None)
|
||||
try:
|
||||
if request.method == 'POST':
|
||||
return getattr(obj.refresh_token, 'token', '')
|
||||
else:
|
||||
return '**************'
|
||||
except ObjectDoesNotExist:
|
||||
return ''
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data['user'] = self.context['request'].user
|
||||
validated_data['token'] = generate_token()
|
||||
validated_data['expires'] = now() + timedelta(
|
||||
seconds=oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS
|
||||
)
|
||||
obj = super(OAuth2TokenSerializer, self).create(validated_data)
|
||||
if obj.application and obj.application.user:
|
||||
obj.user = obj.application.user
|
||||
obj.save()
|
||||
if obj.application is not None:
|
||||
OAuth2RefreshToken.objects.create(
|
||||
user=obj.application.user if obj.application.user else None,
|
||||
token=generate_token(),
|
||||
application=obj.application,
|
||||
access_token=obj
|
||||
)
|
||||
return obj
|
||||
|
||||
|
||||
class OAuth2AuthorizedTokenSerializer(BaseSerializer):
|
||||
|
||||
refresh_token = serializers.SerializerMethodField()
|
||||
token = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = OAuth2AccessToken
|
||||
fields = (
|
||||
'*', '-name', 'description', 'user', 'token', 'refresh_token',
|
||||
'expires', 'scope', 'application',
|
||||
)
|
||||
read_only_fields = ('user', 'token', 'expires')
|
||||
read_only_on_update_fields = ('application',)
|
||||
|
||||
def get_token(self, obj):
|
||||
request = self.context.get('request', None)
|
||||
try:
|
||||
if request.method == 'POST':
|
||||
return obj.token
|
||||
else:
|
||||
return '*************'
|
||||
except ObjectDoesNotExist:
|
||||
return ''
|
||||
|
||||
def get_refresh_token(self, obj):
|
||||
request = self.context.get('request', None)
|
||||
try:
|
||||
if request.method == 'POST':
|
||||
return getattr(obj.refresh_token, 'token', '')
|
||||
else:
|
||||
return '**************'
|
||||
except ObjectDoesNotExist:
|
||||
return ''
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data['user'] = self.context['request'].user
|
||||
validated_data['token'] = generate_token()
|
||||
validated_data['expires'] = now() + timedelta(
|
||||
seconds=oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS
|
||||
)
|
||||
obj = super(OAuth2AuthorizedTokenSerializer, self).create(validated_data)
|
||||
if obj.application and obj.application.user:
|
||||
obj.user = obj.application.user
|
||||
obj.save()
|
||||
if obj.application is not None:
|
||||
OAuth2RefreshToken.objects.create(
|
||||
user=obj.application.user if obj.application.user else None,
|
||||
token=generate_token(),
|
||||
application=obj.application,
|
||||
access_token=obj
|
||||
)
|
||||
return obj
|
||||
|
||||
|
||||
class OAuth2PersonalTokenSerializer(BaseSerializer):
|
||||
|
||||
refresh_token = serializers.SerializerMethodField()
|
||||
token = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = OAuth2AccessToken
|
||||
fields = (
|
||||
'*', '-name', 'description', 'user', 'token', 'refresh_token',
|
||||
'application', 'expires', 'scope',
|
||||
)
|
||||
read_only_fields = ('user', 'token', 'expires')
|
||||
read_only_on_update_fields = ('application',)
|
||||
|
||||
def get_modified(self, obj):
|
||||
if obj is None:
|
||||
return None
|
||||
return obj.updated
|
||||
|
||||
def get_related(self, obj):
|
||||
ret = super(OAuth2PersonalTokenSerializer, 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:o_auth2_application_detail', kwargs={'pk': obj.application.pk}
|
||||
)
|
||||
ret['activity_stream'] = self.reverse(
|
||||
'api:o_auth2_token_activity_stream_list', kwargs={'pk': obj.pk}
|
||||
)
|
||||
return ret
|
||||
|
||||
def get_token(self, obj):
|
||||
request = self.context.get('request', None)
|
||||
try:
|
||||
if request.method == 'POST':
|
||||
return obj.token
|
||||
else:
|
||||
return '*************'
|
||||
except ObjectDoesNotExist:
|
||||
return ''
|
||||
|
||||
def get_refresh_token(self, obj):
|
||||
return None
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data['user'] = self.context['request'].user
|
||||
validated_data['token'] = generate_token()
|
||||
validated_data['expires'] = now() + timedelta(
|
||||
seconds=oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS
|
||||
)
|
||||
obj = super(OAuth2PersonalTokenSerializer, self).create(validated_data)
|
||||
obj.save()
|
||||
return obj
|
||||
|
||||
|
||||
class OrganizationSerializer(BaseSerializer):
|
||||
show_capabilities = ['edit', 'delete']
|
||||
|
||||
@ -4219,7 +4530,8 @@ class ActivityStreamSerializer(BaseSerializer):
|
||||
field_list += [
|
||||
('workflow_job_template_node', ('id', 'unified_job_template_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
|
||||
|
||||
@ -4276,6 +4588,14 @@ class ActivityStreamSerializer(BaseSerializer):
|
||||
id_list.append(getattr(thisItem, 'id', None))
|
||||
if fk == 'custom_inventory_script':
|
||||
rel[fk].append(self.reverse('api:inventory_script_detail', kwargs={'pk': thisItem.id}))
|
||||
elif fk == 'application':
|
||||
rel[fk].append(self.reverse(
|
||||
'api:o_auth2_application_detail', kwargs={'pk': thisItem.pk}
|
||||
))
|
||||
elif fk == 'access_token':
|
||||
rel[fk].append(self.reverse(
|
||||
'api:o_auth2_token_detail', kwargs={'pk': thisItem.pk}
|
||||
))
|
||||
else:
|
||||
rel[fk].append(self.reverse('api:' + fk + '_detail', kwargs={'pk': thisItem.id}))
|
||||
|
||||
@ -4286,6 +4606,7 @@ class ActivityStreamSerializer(BaseSerializer):
|
||||
'api:setting_singleton_detail',
|
||||
kwargs={'category_slug': obj.setting['category']}
|
||||
)
|
||||
rel['access_token'] = '*************'
|
||||
return rel
|
||||
|
||||
def _get_rel(self, obj, fk):
|
||||
@ -4339,6 +4660,7 @@ class ActivityStreamSerializer(BaseSerializer):
|
||||
last_name = obj.actor.last_name)
|
||||
if obj.setting:
|
||||
summary_fields['setting'] = [obj.setting]
|
||||
summary_fields['access_token'] = '*************'
|
||||
return summary_fields
|
||||
|
||||
|
||||
|
||||
287
awx/api/templates/api/api_o_auth_authorization_root_view.md
Normal file
287
awx/api/templates/api/api_o_auth_authorization_root_view.md
Normal file
@ -0,0 +1,287 @@
|
||||
# Handling Personal Access Tokens (PAT) using OAuth2
|
||||
|
||||
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. The `implicit` grant type can only be used to acquire a access token if the user is already logged in via session authentication, as that confirms that the user is authorized to create an access token. 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 the logged in user to grant/deny the access token.
|
||||
Once the user clicks on 'grant', the 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 `Default Application` with grant type `password`:
|
||||
```text
|
||||
{
|
||||
"id": 6,
|
||||
"type": "application",
|
||||
...
|
||||
"name": "Default Application",
|
||||
"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 a personal 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"}
|
||||
```
|
||||
|
||||
|
||||
## 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"
|
||||
}
|
||||
```
|
||||
The `/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": []
|
||||
}
|
||||
```
|
||||
@ -1,43 +0,0 @@
|
||||
{% ifmeth POST %}
|
||||
# Generate an Auth Token
|
||||
Make a POST request to this resource with `username` and `password` fields to
|
||||
obtain an authentication token to use for subsequent requests.
|
||||
|
||||
Example JSON to POST (content type is `application/json`):
|
||||
|
||||
{"username": "user", "password": "my pass"}
|
||||
|
||||
Example form data to post (content type is `application/x-www-form-urlencoded`):
|
||||
|
||||
username=user&password=my%20pass
|
||||
|
||||
If the username and password provided are valid, the response will contain a
|
||||
`token` field with the authentication token to use and an `expires` field with
|
||||
the timestamp when the token will expire:
|
||||
|
||||
{
|
||||
"token": "8f17825cf08a7efea124f2638f3896f6637f8745",
|
||||
"expires": "2013-09-05T21:46:35.729Z"
|
||||
}
|
||||
|
||||
Otherwise, the response will indicate the error that occurred and return a 4xx
|
||||
status code.
|
||||
|
||||
For subsequent requests, pass the token via the HTTP `Authorization` request
|
||||
header:
|
||||
|
||||
Authorization: Token 8f17825cf08a7efea124f2638f3896f6637f8745
|
||||
|
||||
The auth token is only valid when used from the same remote address and user
|
||||
agent that originally obtained it.
|
||||
|
||||
Each request that uses the token for authentication will refresh its expiration
|
||||
timestamp and keep it from expiring. A token only expires when it is not used
|
||||
for the configured timeout interval (default 1800 seconds).
|
||||
{% endifmeth %}
|
||||
|
||||
{% ifmeth DELETE %}
|
||||
# Delete an Auth Token
|
||||
A DELETE request with the token header set will cause the token to be
|
||||
invalidated and no further requests can be made with it.
|
||||
{% endifmeth %}
|
||||
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.urls import include, url
|
||||
|
||||
from awx.api.generics import (
|
||||
LoggedLoginView,
|
||||
LoggedLogoutView,
|
||||
)
|
||||
from awx.api.views import (
|
||||
ApiRootView,
|
||||
ApiV1RootView,
|
||||
@ -12,7 +16,6 @@ from awx.api.views import (
|
||||
ApiV1PingView,
|
||||
ApiV1ConfigView,
|
||||
AuthView,
|
||||
AuthTokenView,
|
||||
UserMeList,
|
||||
DashboardView,
|
||||
DashboardJobsGraphView,
|
||||
@ -25,6 +28,10 @@ from awx.api.views import (
|
||||
JobTemplateExtraCredentialsList,
|
||||
SchedulePreview,
|
||||
ScheduleZoneInfo,
|
||||
OAuth2ApplicationList,
|
||||
OAuth2TokenList,
|
||||
ApplicationOAuth2TokenList,
|
||||
OAuth2ApplicationDetail,
|
||||
)
|
||||
|
||||
from .organization import urls as organization_urls
|
||||
@ -60,6 +67,8 @@ from .schedule import urls as schedule_urls
|
||||
from .activity_stream import urls as activity_stream_urls
|
||||
from .instance import urls as instance_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 = [
|
||||
@ -67,7 +76,6 @@ v1_urls = [
|
||||
url(r'^ping/$', ApiV1PingView.as_view(), name='api_v1_ping_view'),
|
||||
url(r'^config/$', ApiV1ConfigView.as_view(), name='api_v1_config_view'),
|
||||
url(r'^auth/$', AuthView.as_view()),
|
||||
url(r'^authtoken/$', AuthTokenView.as_view(), name='auth_token_view'),
|
||||
url(r'^me/$', UserMeList.as_view(), name='user_me_list'),
|
||||
url(r'^dashboard/$', DashboardView.as_view(), name='dashboard_view'),
|
||||
url(r'^dashboard/graphs/jobs/$', DashboardJobsGraphView.as_view(), name='dashboard_jobs_graph_view'),
|
||||
@ -118,6 +126,11 @@ v2_urls = [
|
||||
url(r'^job_templates/(?P<pk>[0-9]+)/credentials/$', JobTemplateCredentialsList.as_view(), name='job_template_credentials_list'),
|
||||
url(r'^schedules/preview/$', SchedulePreview.as_view(), name='schedule_rrule'),
|
||||
url(r'^schedules/zoneinfo/$', ScheduleZoneInfo.as_view(), name='schedule_zoneinfo'),
|
||||
url(r'^applications/$', OAuth2ApplicationList.as_view(), name='o_auth2_application_list'),
|
||||
url(r'^applications/(?P<pk>[0-9]+)/$', OAuth2ApplicationDetail.as_view(), name='o_auth2_application_detail'),
|
||||
url(r'^applications/(?P<pk>[0-9]+)/tokens/$', ApplicationOAuth2TokenList.as_view(), name='application_o_auth2_token_list'),
|
||||
url(r'^tokens/$', OAuth2TokenList.as_view(), name='o_auth2_token_list'),
|
||||
url(r'^', include(user_oauth_urls)),
|
||||
]
|
||||
|
||||
app_name = 'api'
|
||||
@ -125,6 +138,14 @@ urlpatterns = [
|
||||
url(r'^$', ApiRootView.as_view(), name='api_root_view'),
|
||||
url(r'^(?P<version>(v2))/', include(v2_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':
|
||||
from awx.api.swagger import SwaggerSchemaView
|
||||
|
||||
@ -14,9 +14,12 @@ from awx.api.views import (
|
||||
UserRolesList,
|
||||
UserActivityStreamList,
|
||||
UserAccessList,
|
||||
OAuth2ApplicationList,
|
||||
OAuth2TokenList,
|
||||
OAuth2PersonalTokenList,
|
||||
UserAuthorizedTokenList,
|
||||
)
|
||||
|
||||
|
||||
urls = [
|
||||
url(r'^$', UserList.as_view(), name='user_list'),
|
||||
url(r'^(?P<pk>[0-9]+)/$', UserDetail.as_view(), name='user_detail'),
|
||||
@ -28,6 +31,11 @@ urls = [
|
||||
url(r'^(?P<pk>[0-9]+)/roles/$', UserRolesList.as_view(), name='user_roles_list'),
|
||||
url(r'^(?P<pk>[0-9]+)/activity_stream/$', UserActivityStreamList.as_view(), name='user_activity_stream_list'),
|
||||
url(r'^(?P<pk>[0-9]+)/access_list/$', UserAccessList.as_view(), name='user_access_list'),
|
||||
url(r'^(?P<pk>[0-9]+)/applications/$', OAuth2ApplicationList.as_view(), name='o_auth2_application_list'),
|
||||
url(r'^(?P<pk>[0-9]+)/tokens/$', OAuth2TokenList.as_view(), name='o_auth2_token_list'),
|
||||
url(r'^(?P<pk>[0-9]+)/authorized_tokens/$', UserAuthorizedTokenList.as_view(), name='user_authorized_token_list'),
|
||||
url(r'^(?P<pk>[0-9]+)/personal_tokens/$', OAuth2PersonalTokenList.as_view(), name='o_auth2_personal_token_list'),
|
||||
|
||||
]
|
||||
|
||||
__all__ = ['urls']
|
||||
|
||||
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 (
|
||||
OAuth2ApplicationList,
|
||||
OAuth2ApplicationDetail,
|
||||
ApplicationOAuth2TokenList,
|
||||
OAuth2ApplicationActivityStreamList,
|
||||
OAuth2TokenList,
|
||||
OAuth2TokenDetail,
|
||||
OAuth2TokenActivityStreamList,
|
||||
OAuth2PersonalTokenList
|
||||
)
|
||||
|
||||
|
||||
urls = [
|
||||
url(r'^applications/$', OAuth2ApplicationList.as_view(), name='o_auth2_application_list'),
|
||||
url(
|
||||
r'^applications/(?P<pk>[0-9]+)/$',
|
||||
OAuth2ApplicationDetail.as_view(),
|
||||
name='o_auth2_application_detail'
|
||||
),
|
||||
url(
|
||||
r'^applications/(?P<pk>[0-9]+)/tokens/$',
|
||||
ApplicationOAuth2TokenList.as_view(),
|
||||
name='o_auth2_application_token_list'
|
||||
),
|
||||
url(
|
||||
r'^applications/(?P<pk>[0-9]+)/activity_stream/$',
|
||||
OAuth2ApplicationActivityStreamList.as_view(),
|
||||
name='o_auth2_application_activity_stream_list'
|
||||
),
|
||||
url(r'^tokens/$', OAuth2TokenList.as_view(), name='o_auth2_token_list'),
|
||||
url(
|
||||
r'^tokens/(?P<pk>[0-9]+)/$',
|
||||
OAuth2TokenDetail.as_view(),
|
||||
name='o_auth2_token_detail'
|
||||
),
|
||||
url(
|
||||
r'^tokens/(?P<pk>[0-9]+)/activity_stream/$',
|
||||
OAuth2TokenActivityStreamList.as_view(),
|
||||
name='o_auth2_token_activity_stream_list'
|
||||
),
|
||||
url(r'^personal_tokens/$', OAuth2PersonalTokenList.as_view(), name='o_auth2_personal_token_list'),
|
||||
]
|
||||
|
||||
__all__ = ['urls']
|
||||
206
awx/api/views.py
206
awx/api/views.py
@ -14,17 +14,17 @@ from base64 import b64encode
|
||||
from collections import OrderedDict, Iterable
|
||||
import six
|
||||
|
||||
|
||||
# Django
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import FieldError, ObjectDoesNotExist
|
||||
from django.db.models import Q, Count, F
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.encoding import smart_text, force_text
|
||||
from django.utils.encoding import smart_text
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.timezone import now
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.cache import never_cache
|
||||
from django.template.loader import render_to_string
|
||||
from django.http import HttpResponse
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
@ -53,6 +53,9 @@ import ansiconv
|
||||
# Python Social Auth
|
||||
from social_core.backends.utils import load_backends
|
||||
|
||||
# Django OAuth Toolkit
|
||||
from oauth2_provider.models import get_access_token_model
|
||||
|
||||
import pytz
|
||||
from wsgiref.util import FileWrapper
|
||||
|
||||
@ -60,11 +63,10 @@ from wsgiref.util import FileWrapper
|
||||
from awx.main.tasks import send_notifications, handle_ha_toplogy_changes
|
||||
from awx.main.access import get_user_queryset
|
||||
from awx.main.ha import is_ha_environment
|
||||
from awx.api.authentication import TokenGetAuthentication
|
||||
from awx.api.filters import V1CredentialFilterBackend
|
||||
from awx.api.generics import get_view_name
|
||||
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.main.models import * # noqa
|
||||
from awx.main.utils import * # noqa
|
||||
@ -80,7 +82,6 @@ from awx.api.permissions import * # noqa
|
||||
from awx.api.renderers import * # noqa
|
||||
from awx.api.serializers import * # noqa
|
||||
from awx.api.metadata import RoleMetadata, JobTypeMetadata
|
||||
from awx.main.consumers import emit_channel_notification
|
||||
from awx.main.models.unified_jobs import ACTIVE_STATES
|
||||
from awx.main.scheduler.tasks import run_job_complete
|
||||
|
||||
@ -185,7 +186,6 @@ class InstanceGroupMembershipMixin(object):
|
||||
|
||||
class ApiRootView(APIView):
|
||||
|
||||
authentication_classes = []
|
||||
permission_classes = (AllowAny,)
|
||||
view_name = _('REST API')
|
||||
versioning_class = None
|
||||
@ -204,19 +204,32 @@ class ApiRootView(APIView):
|
||||
if feature_enabled('rebranding'):
|
||||
data['custom_logo'] = settings.CUSTOM_LOGO
|
||||
data['custom_login_info'] = settings.CUSTOM_LOGIN_INFO
|
||||
data['oauth2'] = drf_reverse('api:oauth_authorization_root_view')
|
||||
return Response(data)
|
||||
|
||||
|
||||
class ApiOAuthAuthorizationRootView(APIView):
|
||||
|
||||
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)
|
||||
|
||||
|
||||
class ApiVersionRootView(APIView):
|
||||
|
||||
authentication_classes = []
|
||||
permission_classes = (AllowAny,)
|
||||
swagger_topic = 'Versioning'
|
||||
|
||||
def get(self, request, format=None):
|
||||
''' List top level resources '''
|
||||
data = OrderedDict()
|
||||
data['authtoken'] = reverse('api:auth_token_view', request=request)
|
||||
data['ping'] = reverse('api:api_v1_ping_view', request=request)
|
||||
data['instances'] = reverse('api:instance_list', request=request)
|
||||
data['instance_groups'] = reverse('api:instance_group_list', request=request)
|
||||
@ -232,6 +245,8 @@ class ApiVersionRootView(APIView):
|
||||
data['credentials'] = reverse('api:credential_list', request=request)
|
||||
if get_request_version(request) > 1:
|
||||
data['credential_types'] = reverse('api:credential_type_list', request=request)
|
||||
data['applications'] = reverse('api:o_auth2_application_list', request=request)
|
||||
data['tokens'] = reverse('api:o_auth2_token_list', request=request)
|
||||
data['inventory'] = reverse('api:inventory_list', request=request)
|
||||
data['inventory_scripts'] = reverse('api:inventory_script_list', request=request)
|
||||
data['inventory_sources'] = reverse('api:inventory_source_list', request=request)
|
||||
@ -780,78 +795,6 @@ class AuthView(APIView):
|
||||
return Response(data)
|
||||
|
||||
|
||||
class AuthTokenView(APIView):
|
||||
|
||||
authentication_classes = []
|
||||
permission_classes = (AllowAny,)
|
||||
serializer_class = AuthTokenSerializer
|
||||
model = AuthToken
|
||||
swagger_topic = 'Authentication'
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
serializer = self.serializer_class(*args, **kwargs)
|
||||
# Override when called from browsable API to generate raw data form;
|
||||
# update serializer "validated" data to be displayed by the raw data
|
||||
# form.
|
||||
if hasattr(self, '_raw_data_form_marker'):
|
||||
# Always remove read only fields from serializer.
|
||||
for name, field in serializer.fields.items():
|
||||
if getattr(field, 'read_only', None):
|
||||
del serializer.fields[name]
|
||||
serializer._data = self.update_raw_data(serializer.data)
|
||||
return serializer
|
||||
|
||||
@never_cache
|
||||
def post(self, request):
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
request_hash = AuthToken.get_request_hash(self.request)
|
||||
try:
|
||||
token = AuthToken.objects.filter(user=serializer.validated_data['user'],
|
||||
request_hash=request_hash,
|
||||
expires__gt=now(),
|
||||
reason='')[0]
|
||||
token.refresh()
|
||||
if 'username' in request.data:
|
||||
logger.info(smart_text(u"User {} logged in".format(request.data['username'])),
|
||||
extra=dict(actor=request.data['username']))
|
||||
except IndexError:
|
||||
token = AuthToken.objects.create(user=serializer.validated_data['user'],
|
||||
request_hash=request_hash)
|
||||
if 'username' in request.data:
|
||||
logger.info(smart_text(u"User {} logged in".format(request.data['username'])),
|
||||
extra=dict(actor=request.data['username']))
|
||||
# Get user un-expired tokens that are not invalidated that are
|
||||
# over the configured limit.
|
||||
# Mark them as invalid and inform the user
|
||||
invalid_tokens = AuthToken.get_tokens_over_limit(serializer.validated_data['user'])
|
||||
for t in invalid_tokens:
|
||||
emit_channel_notification('control-limit_reached', dict(group_name='control',
|
||||
reason=force_text(AuthToken.reason_long('limit_reached')),
|
||||
token_key=t.key))
|
||||
t.invalidate(reason='limit_reached')
|
||||
|
||||
# Note: This header is normally added in the middleware whenever an
|
||||
# auth token is included in the request header.
|
||||
headers = {
|
||||
'Auth-Token-Timeout': int(settings.AUTH_TOKEN_EXPIRATION),
|
||||
'Pragma': 'no-cache',
|
||||
}
|
||||
return Response({'token': token.key, 'expires': token.expires}, headers=headers)
|
||||
if 'username' in request.data:
|
||||
logger.warning(smart_text(u"Login failed for user {}".format(request.data['username'])),
|
||||
extra=dict(actor=request.data['username']))
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def delete(self, request):
|
||||
if 'HTTP_AUTHORIZATION' in request.META:
|
||||
token_match = re.match("Token\s(.+)", request.META['HTTP_AUTHORIZATION'])
|
||||
if token_match:
|
||||
filter_tokens = AuthToken.objects.filter(key=token_match.groups()[0])
|
||||
if filter_tokens.exists():
|
||||
filter_tokens[0].invalidate()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class OrganizationCountsMixin(object):
|
||||
|
||||
@ -1554,6 +1497,107 @@ class UserMeList(ListAPIView):
|
||||
return self.model.objects.filter(pk=self.request.user.pk)
|
||||
|
||||
|
||||
class OAuth2ApplicationList(ListCreateAPIView):
|
||||
|
||||
view_name = _("OAuth Applications")
|
||||
|
||||
model = OAuth2Application
|
||||
serializer_class = OAuth2ApplicationSerializer
|
||||
|
||||
|
||||
class OAuth2ApplicationDetail(RetrieveUpdateDestroyAPIView):
|
||||
|
||||
view_name = _("OAuth Application Detail")
|
||||
|
||||
model = OAuth2Application
|
||||
serializer_class = OAuth2ApplicationSerializer
|
||||
|
||||
|
||||
class ApplicationOAuth2TokenList(SubListCreateAPIView):
|
||||
|
||||
view_name = _("OAuth Application Tokens")
|
||||
|
||||
model = OAuth2AccessToken
|
||||
serializer_class = OAuth2TokenSerializer
|
||||
parent_model = OAuth2Application
|
||||
relationship = 'oauth2accesstoken_set'
|
||||
parent_key = 'application'
|
||||
|
||||
|
||||
class OAuth2ApplicationActivityStreamList(ActivityStreamEnforcementMixin, SubListAPIView):
|
||||
|
||||
model = ActivityStream
|
||||
serializer_class = ActivityStreamSerializer
|
||||
parent_model = OAuth2Application
|
||||
relationship = 'activitystream_set'
|
||||
|
||||
|
||||
class OAuth2TokenList(ListCreateAPIView):
|
||||
|
||||
view_name = _("OAuth2 Tokens")
|
||||
|
||||
model = OAuth2AccessToken
|
||||
serializer_class = OAuth2TokenSerializer
|
||||
|
||||
|
||||
class OAuth2AuthorizedTokenList(SubListCreateAPIView):
|
||||
|
||||
view_name = _("OAuth2 Authorized Access Tokens")
|
||||
|
||||
model = OAuth2AccessToken
|
||||
serializer_class = OAuth2AuthorizedTokenSerializer
|
||||
parent_model = OAuth2Application
|
||||
relationship = 'oauth2accesstoken_set'
|
||||
parent_key = 'application'
|
||||
|
||||
def get_queryset(self):
|
||||
return get_access_token_model().objects.filter(application__isnull=False, user=self.request.user)
|
||||
|
||||
|
||||
class UserAuthorizedTokenList(SubListCreateAPIView):
|
||||
|
||||
view_name = _("OAuth2 User Authorized Access Tokens")
|
||||
|
||||
model = OAuth2AccessToken
|
||||
serializer_class = OAuth2AuthorizedTokenSerializer
|
||||
parent_model = User
|
||||
relationship = 'oauth2accesstoken_set'
|
||||
parent_key = 'user'
|
||||
|
||||
def get_queryset(self):
|
||||
return get_access_token_model().objects.filter(application__isnull=False, user=self.request.user)
|
||||
|
||||
|
||||
class OAuth2PersonalTokenList(SubListCreateAPIView):
|
||||
|
||||
view_name = _("OAuth2 Personal Access Tokens")
|
||||
|
||||
model = OAuth2AccessToken
|
||||
serializer_class = OAuth2PersonalTokenSerializer
|
||||
parent_model = User
|
||||
relationship = 'main_oauth2accesstoken'
|
||||
parent_key = 'user'
|
||||
|
||||
def get_queryset(self):
|
||||
return get_access_token_model().objects.filter(application__isnull=True, user=self.request.user)
|
||||
|
||||
|
||||
class OAuth2TokenDetail(RetrieveUpdateDestroyAPIView):
|
||||
|
||||
view_name = _("OAuth Token Detail")
|
||||
|
||||
model = OAuth2AccessToken
|
||||
serializer_class = OAuth2TokenSerializer
|
||||
|
||||
|
||||
class OAuth2TokenActivityStreamList(ActivityStreamEnforcementMixin, SubListAPIView):
|
||||
|
||||
model = ActivityStream
|
||||
serializer_class = ActivityStreamSerializer
|
||||
parent_model = OAuth2AccessToken
|
||||
relationship = 'activitystream_set'
|
||||
|
||||
|
||||
class UserTeamsList(ListAPIView):
|
||||
|
||||
model = User
|
||||
@ -4568,7 +4612,7 @@ class StdoutANSIFilter(object):
|
||||
|
||||
class UnifiedJobStdout(RetrieveAPIView):
|
||||
|
||||
authentication_classes = [TokenGetAuthentication] + api_settings.DEFAULT_AUTHENTICATION_CLASSES
|
||||
authentication_classes = api_settings.DEFAULT_AUTHENTICATION_CLASSES
|
||||
serializer_class = UnifiedJobStdoutSerializer
|
||||
renderer_classes = [BrowsableAPIRenderer, renderers.StaticHTMLRenderer,
|
||||
PlainTextRenderer, AnsiTextRenderer,
|
||||
|
||||
@ -11,8 +11,16 @@ class ConfConfig(AppConfig):
|
||||
name = 'awx.conf'
|
||||
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):
|
||||
self.module.autodiscover()
|
||||
from .settings import SettingsWrapper
|
||||
SettingsWrapper.initialize()
|
||||
configure_external_logger(settings)
|
||||
self.configure_oauth2_provider(settings)
|
||||
|
||||
@ -17,6 +17,9 @@ from django.core.exceptions import ObjectDoesNotExist
|
||||
# Django REST Framework
|
||||
from rest_framework.exceptions import ParseError, PermissionDenied, ValidationError
|
||||
|
||||
# Django OAuth Toolkit
|
||||
from awx.main.models.oauth import OAuth2Application, OAuth2AccessToken
|
||||
|
||||
# AWX
|
||||
from awx.main.utils import (
|
||||
get_object_or_400,
|
||||
@ -117,6 +120,8 @@ def check_user_access(user, model_class, action, *args, **kwargs):
|
||||
Return True if user can perform action against model_class with the
|
||||
provided parameters.
|
||||
'''
|
||||
if 'write' not in getattr(user, 'oauth_scopes', ['write']) and action != 'read':
|
||||
return False
|
||||
access_class = access_registry[model_class]
|
||||
access_instance = access_class(user)
|
||||
access_method = getattr(access_instance, 'can_%s' % action)
|
||||
@ -468,7 +473,7 @@ class InstanceGroupAccess(BaseAccess):
|
||||
class UserAccess(BaseAccess):
|
||||
'''
|
||||
I can see user records when:
|
||||
- I'm a useruser
|
||||
- I'm a superuser
|
||||
- I'm in a role with them (such as in an organization or team)
|
||||
- They are in a role which includes a role of mine
|
||||
- I am in a role that includes a role of theirs
|
||||
@ -552,6 +557,73 @@ class UserAccess(BaseAccess):
|
||||
return super(UserAccess, self).can_unattach(obj, sub_obj, relationship, *args, **kwargs)
|
||||
|
||||
|
||||
class OAuth2ApplicationAccess(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 = OAuth2Application
|
||||
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 OAuth2TokenAccess(BaseAccess):
|
||||
'''
|
||||
I can read, change or delete an OAuth2 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 = OAuth2AccessToken
|
||||
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', OAuth2Application, data)
|
||||
if not app:
|
||||
return True
|
||||
return OAuth2ApplicationAccess(self.user).can_read(app)
|
||||
|
||||
|
||||
class OrganizationAccess(BaseAccess):
|
||||
'''
|
||||
I can see organizations when:
|
||||
|
||||
@ -1,17 +1,11 @@
|
||||
import json
|
||||
import logging
|
||||
import urllib
|
||||
|
||||
from channels import Group, channel_layers
|
||||
from channels.sessions import channel_session
|
||||
from channels.handler import AsgiRequest
|
||||
from channels import Group
|
||||
from channels.auth import channel_session_user_from_http, channel_session_user
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from awx.main.models.organization import AuthToken
|
||||
|
||||
|
||||
logger = logging.getLogger('awx.main.consumers')
|
||||
|
||||
@ -22,51 +16,29 @@ def discard_groups(message):
|
||||
Group(group).discard(message.reply_channel)
|
||||
|
||||
|
||||
@channel_session
|
||||
@channel_session_user_from_http
|
||||
def ws_connect(message):
|
||||
message.reply_channel.send({"accept": True})
|
||||
|
||||
message.content['method'] = 'FAKE'
|
||||
request = AsgiRequest(message)
|
||||
token = request.COOKIES.get('token', None)
|
||||
if token is not None:
|
||||
token = urllib.unquote(token).strip('"')
|
||||
try:
|
||||
auth_token = AuthToken.objects.get(key=token)
|
||||
if auth_token.in_valid_tokens:
|
||||
message.channel_session['user_id'] = auth_token.user_id
|
||||
message.reply_channel.send({"text": json.dumps({"accept": True, "user": auth_token.user_id})})
|
||||
return None
|
||||
except AuthToken.DoesNotExist:
|
||||
logger.error("auth_token provided was invalid.")
|
||||
message.reply_channel.send({"close": True})
|
||||
if message.user.is_authenticated():
|
||||
message.reply_channel.send(
|
||||
{"text": json.dumps({"accept": True, "user": message.user.id})}
|
||||
)
|
||||
else:
|
||||
logger.error("Request user is not authenticated to use websocket.")
|
||||
message.reply_channel.send({"close": True})
|
||||
return None
|
||||
|
||||
|
||||
@channel_session
|
||||
@channel_session_user
|
||||
def ws_disconnect(message):
|
||||
discard_groups(message)
|
||||
|
||||
|
||||
@channel_session
|
||||
@channel_session_user
|
||||
def ws_receive(message):
|
||||
from awx.main.access import consumer_access
|
||||
channel_layer_settings = channel_layers.configs[message.channel_layer.alias]
|
||||
max_retries = channel_layer_settings.get('RECEIVE_MAX_RETRY', settings.CHANNEL_LAYER_RECEIVE_MAX_RETRY)
|
||||
|
||||
user_id = message.channel_session.get('user_id', None)
|
||||
if user_id is None:
|
||||
retries = message.content.get('connect_retries', 0) + 1
|
||||
message.content['connect_retries'] = retries
|
||||
message.reply_channel.send({"text": json.dumps({"error": "no valid user"})})
|
||||
retries_left = max_retries - retries
|
||||
if retries_left > 0:
|
||||
message.channel_layer.send(message.channel.name, message.content)
|
||||
else:
|
||||
logger.error("No valid user found for websocket.")
|
||||
return None
|
||||
|
||||
user = User.objects.get(pk=user_id)
|
||||
user = message.user
|
||||
raw_data = message.content['text']
|
||||
data = json.loads(raw_data)
|
||||
|
||||
|
||||
@ -1,35 +0,0 @@
|
||||
# Copyright (c) 2015 Ansible, Inc.
|
||||
# All Rights Reserved.
|
||||
|
||||
# Python
|
||||
import logging
|
||||
|
||||
# Django
|
||||
from django.db import transaction
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils.timezone import now
|
||||
|
||||
# AWX
|
||||
from awx.main.models import * # noqa
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
'''
|
||||
Management command to cleanup expired auth tokens
|
||||
'''
|
||||
|
||||
help = 'Cleanup expired auth tokens.'
|
||||
|
||||
def init_logging(self):
|
||||
self.logger = logging.getLogger('awx.main.commands.cleanup_authtokens')
|
||||
handler = logging.StreamHandler()
|
||||
handler.setFormatter(logging.Formatter('%(message)s'))
|
||||
self.logger.addHandler(handler)
|
||||
self.logger.propagate = False
|
||||
|
||||
@transaction.atomic
|
||||
def handle(self, *args, **options):
|
||||
self.init_logging()
|
||||
tokens_removed = AuthToken.objects.filter(expires__lt=now())
|
||||
self.logger.log(99, "Removing %d expired auth tokens" % tokens_removed.count())
|
||||
tokens_removed.delete()
|
||||
@ -22,7 +22,6 @@ from django.utils.translation import ugettext_lazy as _
|
||||
from django.core.urlresolvers import reverse
|
||||
|
||||
from awx.main.models import ActivityStream
|
||||
from awx.api.authentication import TokenAuthentication
|
||||
from awx.main.utils.named_url_graph import generate_graph, GraphNode
|
||||
from awx.conf import fields, register
|
||||
|
||||
@ -119,21 +118,6 @@ class ActivityStreamMiddleware(threading.local):
|
||||
self.instance_ids.append(instance.id)
|
||||
|
||||
|
||||
class AuthTokenTimeoutMiddleware(object):
|
||||
"""Presume that when the user includes the auth header, they go through the
|
||||
authentication mechanism. Further, that mechanism is presumed to extend
|
||||
the users session validity time by AUTH_TOKEN_EXPIRATION.
|
||||
|
||||
If the auth token is not supplied, then don't include the header
|
||||
"""
|
||||
def process_response(self, request, response):
|
||||
if not TokenAuthentication._get_x_auth_token_header(request):
|
||||
return response
|
||||
|
||||
response['Auth-Token-Timeout'] = int(settings.AUTH_TOKEN_EXPIRATION)
|
||||
return response
|
||||
|
||||
|
||||
def _customize_graph():
|
||||
from awx.main.models import Instance, Schedule, UnifiedJobTemplate
|
||||
for model in [Schedule, UnifiedJobTemplate]:
|
||||
|
||||
@ -0,0 +1,84 @@
|
||||
# -*- 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
|
||||
import oauth2_provider
|
||||
import re
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('main', '0024_v330_create_user_session_membership'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
|
||||
migrations.CreateModel(
|
||||
name='OAuth2Application',
|
||||
fields=[
|
||||
('id', models.BigAutoField(primary_key=True, serialize=False)),
|
||||
('client_id', models.CharField(db_index=True, default=oauth2_provider.generators.generate_client_id, max_length=100, unique=True)),
|
||||
('redirect_uris', models.TextField(blank=True, help_text='Allowed URIs list, space separated', validators=[oauth2_provider.validators.validate_uris])),
|
||||
('client_type', models.CharField(choices=[('confidential', 'Confidential'), ('public', 'Public')], max_length=32)),
|
||||
('authorization_grant_type', models.CharField(choices=[('authorization-code', 'Authorization code'), ('implicit', 'Implicit'), ('password', 'Resource owner password-based'), ('client-credentials', 'Client credentials')], max_length=32)),
|
||||
('client_secret', models.CharField(blank=True, db_index=True, default=oauth2_provider.generators.generate_client_secret, max_length=255)),
|
||||
('name', models.CharField(blank=True, max_length=255)),
|
||||
('skip_authorization', models.BooleanField(default=False)),
|
||||
('created', models.DateTimeField(auto_now_add=True)),
|
||||
('updated', models.DateTimeField(auto_now=True)),
|
||||
('description', models.TextField(blank=True, default=b'')),
|
||||
('logo_data', models.TextField(default=b'', editable=False, validators=[django.core.validators.RegexValidator(re.compile(b'.*'))])),
|
||||
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='main_oauth2application', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'application',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='OAuth2AccessToken',
|
||||
fields=[
|
||||
('id', models.BigAutoField(primary_key=True, serialize=False)),
|
||||
('token', models.CharField(max_length=255, unique=True)),
|
||||
('expires', models.DateTimeField()),
|
||||
('scope', models.TextField(blank=True)),
|
||||
('created', models.DateTimeField(auto_now_add=True)),
|
||||
('updated', models.DateTimeField(auto_now=True)),
|
||||
('description', models.CharField(blank=True, default=b'', max_length=200)),
|
||||
('last_used', models.DateTimeField(default=None, editable=False, null=True)),
|
||||
('application', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)),
|
||||
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='main_oauth2accesstoken', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'access token',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='OAuth2RefreshToken',
|
||||
fields=[
|
||||
('id', models.BigAutoField(primary_key=True, serialize=False)),
|
||||
('token', models.CharField(max_length=255, unique=True)),
|
||||
('created', models.DateTimeField(auto_now_add=True)),
|
||||
('updated', models.DateTimeField(auto_now=True)),
|
||||
('access_token', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='refresh_token', to=settings.OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL)),
|
||||
('application', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='main_oauth2refreshtoken', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'refresh token',
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='activitystream',
|
||||
name='o_auth2_access_token',
|
||||
field=models.ManyToManyField(to='main.OAuth2AccessToken', blank=True, related_name='main_o_auth2_accesstoken'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='activitystream',
|
||||
name='o_auth2_application',
|
||||
field=models.ManyToManyField(to='main.OAuth2Application', blank=True, related_name='main_o_auth2_application'),
|
||||
),
|
||||
|
||||
]
|
||||
@ -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', '0023_v330_inventory_multicred'),
|
||||
]
|
||||
|
||||
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)),
|
||||
],
|
||||
),
|
||||
]
|
||||
@ -24,6 +24,11 @@ from awx.main.models.fact import * # noqa
|
||||
from awx.main.models.label import * # noqa
|
||||
from awx.main.models.workflow import * # noqa
|
||||
from awx.main.models.channels import * # noqa
|
||||
from awx.api.versioning import reverse
|
||||
from awx.main.models.oauth import * # noqa
|
||||
|
||||
from oauth2_provider.models import Grant # noqa
|
||||
|
||||
|
||||
# Monkeypatch Django serializer to ignore django-taggit fields (which break
|
||||
# the dumpdata command; see https://github.com/alex/django-taggit/issues/155).
|
||||
@ -113,6 +118,23 @@ def user_is_in_enterprise_category(user, category):
|
||||
|
||||
User.add_to_class('is_in_enterprise_category', user_is_in_enterprise_category)
|
||||
|
||||
|
||||
|
||||
|
||||
def o_auth2_application_get_absolute_url(self, request=None):
|
||||
return reverse('api:o_auth2_application_detail', kwargs={'pk': self.pk}, request=request)
|
||||
|
||||
|
||||
OAuth2Application.add_to_class('get_absolute_url', o_auth2_application_get_absolute_url)
|
||||
|
||||
|
||||
def o_auth2_token_get_absolute_url(self, request=None):
|
||||
return reverse('api:o_auth2_token_detail', kwargs={'pk': self.pk}, request=request)
|
||||
|
||||
|
||||
OAuth2AccessToken.add_to_class('get_absolute_url', o_auth2_token_get_absolute_url)
|
||||
|
||||
|
||||
# Import signal handlers only after models have been defined.
|
||||
import awx.main.signals # noqa
|
||||
|
||||
@ -143,6 +165,8 @@ activity_stream_registrar.connect(User)
|
||||
activity_stream_registrar.connect(WorkflowJobTemplate)
|
||||
activity_stream_registrar.connect(WorkflowJobTemplateNode)
|
||||
activity_stream_registrar.connect(WorkflowJob)
|
||||
activity_stream_registrar.connect(OAuth2Application)
|
||||
activity_stream_registrar.connect(OAuth2AccessToken)
|
||||
|
||||
# prevent API filtering on certain Django-supplied sensitive fields
|
||||
prevent_search(User._meta.get_field('password'))
|
||||
|
||||
@ -66,6 +66,11 @@ class ActivityStream(models.Model):
|
||||
label = models.ManyToManyField("Label", blank=True)
|
||||
role = models.ManyToManyField("Role", blank=True)
|
||||
instance_group = models.ManyToManyField("InstanceGroup", blank=True)
|
||||
|
||||
o_auth2_application = models.ManyToManyField("OAuth2Application", blank=True)
|
||||
o_auth2_access_token = models.ManyToManyField("OAuth2AccessToken", blank=True)
|
||||
|
||||
|
||||
|
||||
setting = JSONField(blank=True)
|
||||
|
||||
|
||||
73
awx/main/models/oauth.py
Normal file
73
awx/main/models/oauth.py
Normal file
@ -0,0 +1,73 @@
|
||||
# Python
|
||||
import re
|
||||
|
||||
# Django
|
||||
from django.core.validators import RegexValidator
|
||||
from django.db import models
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
# Django OAuth Toolkit
|
||||
from oauth2_provider.models import AbstractApplication, AbstractAccessToken, AbstractRefreshToken
|
||||
|
||||
|
||||
DATA_URI_RE = re.compile(r'.*') # FIXME
|
||||
|
||||
__all__ = ['OAuth2AccessToken', 'OAuth2Application', 'OAuth2RefreshToken']
|
||||
|
||||
|
||||
class OAuth2Application(AbstractApplication):
|
||||
|
||||
class Meta:
|
||||
app_label = 'main'
|
||||
verbose_name = _('application')
|
||||
|
||||
description = models.TextField(
|
||||
default='',
|
||||
blank=True,
|
||||
)
|
||||
logo_data = models.TextField(
|
||||
default='',
|
||||
editable=False,
|
||||
validators=[RegexValidator(DATA_URI_RE)],
|
||||
)
|
||||
|
||||
|
||||
class OAuth2AccessToken(AbstractAccessToken):
|
||||
|
||||
class Meta:
|
||||
app_label = 'main'
|
||||
verbose_name = _('access token')
|
||||
|
||||
description = models.CharField(
|
||||
max_length=200,
|
||||
default='',
|
||||
blank=True,
|
||||
)
|
||||
last_used = models.DateTimeField(
|
||||
null=True,
|
||||
default=None,
|
||||
editable=False,
|
||||
)
|
||||
|
||||
def is_valid(self, scopes=None):
|
||||
valid = super(OAuth2AccessToken, self).is_valid(scopes)
|
||||
if valid:
|
||||
self.last_used = now()
|
||||
self.save(update_fields=['last_used'])
|
||||
return valid
|
||||
|
||||
|
||||
class OAuth2RefreshToken(AbstractRefreshToken):
|
||||
|
||||
class Meta:
|
||||
app_label = 'main'
|
||||
verbose_name = _('refresh token')
|
||||
|
||||
application = models.ForeignKey(
|
||||
OAuth2Application,
|
||||
on_delete=models.CASCADE,
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
|
||||
@ -11,6 +11,7 @@ import uuid
|
||||
from django.conf import settings
|
||||
from django.db import models, connection
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.sessions.models import Session
|
||||
from django.utils.timezone import now as tz_now
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
@ -26,7 +27,7 @@ from awx.main.models.rbac import (
|
||||
)
|
||||
from awx.main.models.mixins import ResourceMixin, CustomVirtualEnvMixin
|
||||
|
||||
__all__ = ['Organization', 'Team', 'Profile', 'AuthToken']
|
||||
__all__ = ['Organization', 'Team', 'Profile', 'AuthToken', 'UserSessionMembership']
|
||||
|
||||
|
||||
class Organization(CommonModel, NotificationFieldsModel, ResourceMixin, CustomVirtualEnvMixin):
|
||||
@ -269,6 +270,42 @@ class AuthToken(BaseModel):
|
||||
return self.key
|
||||
|
||||
|
||||
class UserSessionMembership(BaseModel):
|
||||
'''
|
||||
A lookup table for session membership given user.
|
||||
'''
|
||||
|
||||
class Meta:
|
||||
app_label = 'main'
|
||||
|
||||
user = models.ForeignKey(
|
||||
'auth.User', related_name='+', blank=False, null=False, on_delete=models.CASCADE
|
||||
)
|
||||
session = models.OneToOneField(
|
||||
Session, related_name='+', blank=False, null=False, on_delete=models.CASCADE
|
||||
)
|
||||
created = models.DateTimeField(default=None, editable=False)
|
||||
|
||||
@staticmethod
|
||||
def get_memberships_over_limit(user, now=None):
|
||||
if settings.SESSIONS_PER_USER == -1:
|
||||
return []
|
||||
if now is None:
|
||||
now = tz_now()
|
||||
query_set = UserSessionMembership.objects\
|
||||
.select_related('session')\
|
||||
.filter(user=user)\
|
||||
.order_by('-created')
|
||||
non_expire_memberships = [x for x in query_set if x.session.expire_date > now]
|
||||
return non_expire_memberships[settings.SESSIONS_PER_USER:]
|
||||
|
||||
@staticmethod
|
||||
def clear_session_for_user(user):
|
||||
query_set = UserSessionMembership.objects.select_related('session').filter(user=user)
|
||||
sessions_to_delete = [obj.session.pk for obj in query_set]
|
||||
Session.objects.filter(pk__in=sessions_to_delete).delete()
|
||||
|
||||
|
||||
# Add get_absolute_url method to User model if not present.
|
||||
if not hasattr(User, 'get_absolute_url'):
|
||||
def user_get_absolute_url(user, request=None):
|
||||
|
||||
@ -11,6 +11,9 @@ import json
|
||||
from django.conf import settings
|
||||
from django.db.models.signals import post_save, pre_delete, post_delete, m2m_changed
|
||||
from django.dispatch import receiver
|
||||
from django.contrib.auth import SESSION_KEY
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
# Django-CRUM
|
||||
from crum import get_current_request, get_current_user
|
||||
@ -20,6 +23,7 @@ import six
|
||||
|
||||
# AWX
|
||||
from awx.main.models import * # noqa
|
||||
from django.contrib.sessions.models import Session
|
||||
from awx.api.serializers import * # noqa
|
||||
from awx.main.utils import model_instance_diff, model_to_dict, camelcase_to_underscore
|
||||
from awx.main.utils import ignore_inventory_computed_fields, ignore_inventory_group_removal, _inventory_updates
|
||||
@ -414,6 +418,8 @@ def activity_stream_create(sender, instance, created, **kwargs):
|
||||
if type(instance) == Job:
|
||||
if 'extra_vars' in changes:
|
||||
changes['extra_vars'] = instance.display_extra_vars()
|
||||
if type(instance) == OAuth2AccessToken:
|
||||
changes['token'] = '*************'
|
||||
activity_entry = ActivityStream(
|
||||
operation='create',
|
||||
object1=object1,
|
||||
@ -581,3 +587,45 @@ def delete_inventory_for_org(sender, instance, **kwargs):
|
||||
inventory.schedule_deletion(user_id=getattr(user, 'id', None))
|
||||
except RuntimeError as e:
|
||||
logger.debug(e)
|
||||
|
||||
|
||||
@receiver(post_save, sender=Session)
|
||||
def save_user_session_membership(sender, **kwargs):
|
||||
session = kwargs.get('instance', None)
|
||||
if not session:
|
||||
return
|
||||
user = session.get_decoded().get(SESSION_KEY, None)
|
||||
if not user:
|
||||
return
|
||||
user = User.objects.get(pk=user)
|
||||
if UserSessionMembership.objects.filter(user=user, session=session).exists():
|
||||
return
|
||||
UserSessionMembership.objects.create(user=user, session=session, created=timezone.now())
|
||||
for membership in UserSessionMembership.get_memberships_over_limit(user):
|
||||
emit_channel_notification(
|
||||
'control-limit_reached',
|
||||
dict(group_name='control',
|
||||
reason=unicode(_('limit_reached')),
|
||||
session_key=membership.session.session_key)
|
||||
)
|
||||
|
||||
|
||||
@receiver(post_save, sender=OAuth2AccessToken)
|
||||
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=OAuth2AccessToken)
|
||||
obj.save()
|
||||
post_save.connect(create_access_token_user_if_missing, sender=OAuth2AccessToken)
|
||||
|
||||
|
||||
# @receiver(post_save, sender=User)
|
||||
# def create_default_oauth_app(sender, **kwargs):
|
||||
# if kwargs.get('created', False):
|
||||
# user = kwargs['instance']
|
||||
# OAuth2Application.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_'):
|
||||
restart_local_services(['uwsgi', 'celery', 'beat', 'callback'])
|
||||
break
|
||||
elif key == 'OAUTH2_PROVIDER':
|
||||
restart_local_services(['uwsgi'])
|
||||
|
||||
|
||||
@shared_task(bind=True, queue='tower_broadcast_all', base=LogErrorsTask)
|
||||
@ -288,12 +290,6 @@ def run_administrative_checks(self):
|
||||
fail_silently=True)
|
||||
|
||||
|
||||
@shared_task(bind=True, queue='tower', base=LogErrorsTask)
|
||||
def cleanup_authtokens(self):
|
||||
logger.warn("Cleaning up expired authtokens.")
|
||||
AuthToken.objects.filter(expires__lt=now()).delete()
|
||||
|
||||
|
||||
@shared_task(bind=True, base=LogErrorsTask)
|
||||
def purge_old_stdout_files(self):
|
||||
nowtime = time.time()
|
||||
|
||||
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.oauth import (OAuth2Application as Application,
|
||||
OAuth2AccessToken as AccessToken,
|
||||
OAuth2RefreshToken as RefreshToken
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_oauth_application_create(admin, post):
|
||||
response = post(
|
||||
reverse('api:o_auth2_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:o_auth2_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.skip(reason="Needs Update - CA")
|
||||
@pytest.mark.django_db
|
||||
def test_oauth_token_create(oauth_application, get, post, admin):
|
||||
response = post(
|
||||
reverse('api:o_auth2_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:o_auth2_application_token_list', kwargs={'pk': oauth_application.pk}),
|
||||
admin, expect=200
|
||||
)
|
||||
assert response.data['count'] == 1
|
||||
response = get(
|
||||
reverse('api:o_auth2_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:o_auth2_application_token_list', kwargs={'pk': oauth_application.pk}),
|
||||
{'scope': 'read'}, admin, expect=201
|
||||
)
|
||||
token = AccessToken.objects.get(token=response.data['token'])
|
||||
patch(
|
||||
reverse('api:o_auth2_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:o_auth2_application_token_list', kwargs={'pk': oauth_application.pk}),
|
||||
{'scope': 'read'}, admin, expect=201
|
||||
)
|
||||
token = AccessToken.objects.get(token=response.data['token'])
|
||||
delete(
|
||||
reverse('api:o_auth2_token_detail', kwargs={'pk': token.pk}),
|
||||
admin, expect=204
|
||||
)
|
||||
assert AccessToken.objects.count() == 0
|
||||
assert RefreshToken.objects.count() == 0
|
||||
response = get(
|
||||
reverse('api:o_auth2_application_token_list', kwargs={'pk': oauth_application.pk}),
|
||||
admin, expect=200
|
||||
)
|
||||
assert response.data['count'] == 0
|
||||
response = get(
|
||||
reverse('api:o_auth2_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:o_auth2_application_token_list', kwargs={'pk': oauth_application.pk}),
|
||||
{'scope': 'read'}, admin, expect=201
|
||||
)
|
||||
delete(
|
||||
reverse('api:o_auth2_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
|
||||
@ -47,6 +47,7 @@ from awx.main.models.notifications import (
|
||||
)
|
||||
from awx.main.models.workflow import WorkflowJobTemplate
|
||||
from awx.main.models.ad_hoc_commands import AdHocCommand
|
||||
from awx.main.models.oauth import OAuth2Application as Application
|
||||
|
||||
__SWAGGER_REQUESTS__ = {}
|
||||
|
||||
@ -535,6 +536,9 @@ def _request(verb):
|
||||
|
||||
view, view_args, view_kwargs = resolve(urlparse(url)[2])
|
||||
request = getattr(APIRequestFactory(), verb)(url, **kwargs)
|
||||
if isinstance(kwargs.get('cookies', None), dict):
|
||||
for key, value in kwargs['cookies'].items():
|
||||
request.COOKIES[key] = value
|
||||
if middleware:
|
||||
middleware.process_request(request)
|
||||
if user:
|
||||
@ -545,7 +549,7 @@ def _request(verb):
|
||||
middleware.process_response(request, response)
|
||||
if expect:
|
||||
if response.status_code != expect:
|
||||
if response.data is not None:
|
||||
if getattr(response, 'data', None):
|
||||
try:
|
||||
data_copy = response.data.copy()
|
||||
# Make translated strings printable
|
||||
@ -558,7 +562,6 @@ def _request(verb):
|
||||
response.data[key] = str(value)
|
||||
except Exception:
|
||||
response.data = data_copy
|
||||
print(response.data)
|
||||
assert response.status_code == expect
|
||||
if hasattr(response, 'render'):
|
||||
response.render()
|
||||
@ -727,3 +730,11 @@ def get_db_prep_save(self, value, connection, **kwargs):
|
||||
@pytest.fixture
|
||||
def monkeypatch_jsonbfield_get_db_prep_save(mocker):
|
||||
JSONField.get_db_prep_save = get_db_prep_save
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def oauth_application(admin):
|
||||
return Application.objects.create(
|
||||
name='test app', user=admin, client_type='confidential',
|
||||
authorization_grant_type='password'
|
||||
)
|
||||
|
||||
@ -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
|
||||
@ -104,6 +104,7 @@ def ldap_settings_generator():
|
||||
|
||||
# Note: mockldap isn't fully featured. Fancy queries aren't fully baked.
|
||||
# However, objects returned are solid so they should flow through django ldap middleware nicely.
|
||||
@pytest.mark.skip(reason="Needs Update - CA")
|
||||
@pytest.mark.django_db
|
||||
def test_login(ldap_generator, patch, post, admin, ldap_settings_generator):
|
||||
auth_url = reverse('api:auth_token_view')
|
||||
|
||||
116
awx/main/tests/functional/test_rbac_oauth.py
Normal file
116
awx/main/tests/functional/test_rbac_oauth.py
Normal file
@ -0,0 +1,116 @@
|
||||
import pytest
|
||||
|
||||
from awx.main.access import (
|
||||
OAuth2ApplicationAccess,
|
||||
OAuth2TokenAccess,
|
||||
)
|
||||
from awx.main.models.oauth import (
|
||||
OAuth2Application as Application,
|
||||
OAuth2AccessToken as 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 = OAuth2ApplicationAccess(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 = OAuth2ApplicationAccess(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 = OAuth2ApplicationAccess(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 = OAuth2ApplicationAccess(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.skip(reason="Needs Update - CA")
|
||||
@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 = OAuth2TokenAccess(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:o_auth2_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 # TODO: fix this test
|
||||
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:o_auth2_application_token_list', kwargs={'pk': app.pk}),
|
||||
{'scope': 'read'}, user_list[user_for_access], expect=201 if can_access else 403
|
||||
)
|
||||
103
awx/main/tests/functional/test_session.py
Normal file
103
awx/main/tests/functional/test_session.py
Normal file
@ -0,0 +1,103 @@
|
||||
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.skip(reason="Needs Update - CA")
|
||||
@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.skip(reason="Needs Update - CA")
|
||||
@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.skip(reason="Needs Update - CA")
|
||||
@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()
|
||||
@ -28,7 +28,6 @@ def mock_response_new(mocker):
|
||||
class TestApiRootView:
|
||||
def test_get_endpoints(self, mocker, mock_response_new):
|
||||
endpoints = [
|
||||
'authtoken',
|
||||
'ping',
|
||||
'config',
|
||||
#'settings',
|
||||
|
||||
@ -109,6 +109,8 @@ MEDIA_ROOT = os.path.join(BASE_DIR, 'public', 'media')
|
||||
# Examples: "http://media.lawrence.com", "http://example.com/media/"
|
||||
MEDIA_URL = '/media/'
|
||||
|
||||
LOGIN_URL = '/api/login/'
|
||||
|
||||
# Absolute filesystem path to the directory to host projects (with playbooks).
|
||||
# This directory should not be web-accessible.
|
||||
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
|
||||
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
|
||||
CSRF_COOKIE_SECURE = True
|
||||
|
||||
@ -237,7 +248,6 @@ MIDDLEWARE_CLASSES = ( # NOQA
|
||||
'awx.main.middleware.ActivityStreamMiddleware',
|
||||
'awx.sso.middleware.SocialAuthMiddleware',
|
||||
'crum.CurrentRequestUserMiddleware',
|
||||
'awx.main.middleware.AuthTokenTimeoutMiddleware',
|
||||
'awx.main.middleware.URLModificationMiddleware',
|
||||
)
|
||||
|
||||
@ -253,6 +263,7 @@ INSTALLED_APPS = (
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.sites',
|
||||
'django.contrib.staticfiles',
|
||||
'oauth2_provider',
|
||||
'rest_framework',
|
||||
'django_extensions',
|
||||
'django_celery_results',
|
||||
@ -275,9 +286,9 @@ REST_FRAMEWORK = {
|
||||
'DEFAULT_PAGINATION_CLASS': 'awx.api.pagination.Pagination',
|
||||
'PAGE_SIZE': 25,
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||
'awx.api.authentication.TokenAuthentication',
|
||||
'awx.api.authentication.LoggedOAuth2Authentication',
|
||||
'awx.api.authentication.SessionAuthentication',
|
||||
'awx.api.authentication.LoggedBasicAuthentication',
|
||||
#'rest_framework.authentication.SessionAuthentication',
|
||||
),
|
||||
'DEFAULT_PERMISSION_CLASSES': (
|
||||
'awx.api.permissions.ModelAccessPermission',
|
||||
@ -322,6 +333,14 @@ AUTHENTICATION_BACKENDS = (
|
||||
'django.contrib.auth.backends.ModelBackend',
|
||||
)
|
||||
|
||||
|
||||
# Django OAuth Toolkit settings
|
||||
OAUTH2_PROVIDER_APPLICATION_MODEL = 'main.OAuth2Application'
|
||||
OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL = 'main.OAuth2AccessToken'
|
||||
OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL = 'main.OAuth2RefreshToken'
|
||||
|
||||
OAUTH2_PROVIDER = {}
|
||||
|
||||
# LDAP server (default to None to skip using LDAP authentication).
|
||||
# Note: This setting may be overridden by database settings.
|
||||
AUTH_LDAP_SERVER_URI = None
|
||||
@ -464,10 +483,6 @@ CELERY_BEAT_SCHEDULE = {
|
||||
'task': 'awx.main.tasks.run_administrative_checks',
|
||||
'schedule': timedelta(days=30)
|
||||
},
|
||||
'authtoken_cleanup': {
|
||||
'task': 'awx.main.tasks.cleanup_authtokens',
|
||||
'schedule': timedelta(days=30)
|
||||
},
|
||||
'cluster_heartbeat': {
|
||||
'task': 'awx.main.tasks.cluster_node_heartbeat',
|
||||
'schedule': timedelta(seconds=60),
|
||||
|
||||
@ -8,18 +8,14 @@ import urllib
|
||||
import six
|
||||
|
||||
# Django
|
||||
from django.contrib.auth import login, logout
|
||||
from django.utils.functional import LazyObject
|
||||
from django.shortcuts import redirect
|
||||
from django.utils.timezone import now
|
||||
|
||||
# Python Social Auth
|
||||
from social_core.exceptions import SocialAuthBaseException
|
||||
from social_core.utils import social_logger
|
||||
from social_django.middleware import SocialAuthExceptionMiddleware
|
||||
|
||||
# Ansible Tower
|
||||
from awx.main.models import AuthToken
|
||||
|
||||
|
||||
class SocialAuthMiddleware(SocialAuthExceptionMiddleware):
|
||||
|
||||
@ -35,33 +31,14 @@ class SocialAuthMiddleware(SocialAuthExceptionMiddleware):
|
||||
request.successful_authenticator = None
|
||||
|
||||
if not request.path.startswith('/sso/') and 'migrations_notran' not in request.path:
|
||||
|
||||
# If token isn't present but we still have a user logged in via Django
|
||||
# sessions, log them out.
|
||||
if not token_key and request.user and request.user.is_authenticated():
|
||||
logout(request)
|
||||
|
||||
# If a token is present, make sure it matches a valid one in the
|
||||
# database, and log the user via Django session if necessary.
|
||||
# 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)
|
||||
if request.user and request.user.is_authenticated():
|
||||
# The rest of the code base rely hevily on type/inheritance checks,
|
||||
# LazyObject sent from Django auth middleware can be buggy if not
|
||||
# converted back to its original object.
|
||||
if isinstance(request.user, LazyObject) and request.user._wrapped:
|
||||
request.user = request.user._wrapped
|
||||
request.session.pop('social_auth_error', None)
|
||||
request.session.pop('social_auth_last_backend', None)
|
||||
|
||||
def process_exception(self, request, exception):
|
||||
strategy = getattr(request, 'social_strategy', None)
|
||||
|
||||
@ -8,17 +8,10 @@ import logging
|
||||
# Django
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.http import HttpResponse
|
||||
from django.utils.timezone import now, utc
|
||||
from django.views.generic import View
|
||||
from django.views.generic.base import RedirectView
|
||||
from django.utils.encoding import smart_text
|
||||
|
||||
# Django REST Framework
|
||||
from rest_framework.renderers import JSONRenderer
|
||||
|
||||
# AWX
|
||||
from awx.main.models import AuthToken
|
||||
from awx.api.serializers import UserSerializer
|
||||
from django.contrib import auth
|
||||
|
||||
logger = logging.getLogger('awx.sso.views')
|
||||
|
||||
@ -46,30 +39,8 @@ class CompleteView(BaseRedirectView):
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
response = super(CompleteView, self).dispatch(request, *args, **kwargs)
|
||||
if self.request.user and self.request.user.is_authenticated():
|
||||
request_hash = AuthToken.get_request_hash(self.request)
|
||||
try:
|
||||
token = AuthToken.objects.filter(user=request.user,
|
||||
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')
|
||||
current_user = UserSerializer(self.request.user)
|
||||
current_user = JSONRenderer().render(current_user.data)
|
||||
current_user = urllib.quote('%s' % current_user, '')
|
||||
response.set_cookie('current_user', current_user)
|
||||
auth.login(self.request, self.request.user)
|
||||
logger.info(smart_text(u"User {} logged in".format(self.request.user.username)))
|
||||
return response
|
||||
|
||||
|
||||
|
||||
@ -33,8 +33,11 @@
|
||||
</div>
|
||||
<div class="collapse navbar-collapse" id="navbar-collapse">
|
||||
<ul class="nav navbar-nav navbar-right">
|
||||
{% if user.is_authenticated %}
|
||||
<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>
|
||||
{% if user.is_authenticated and not inside_login_context %}
|
||||
<li><a href="{% if request.version %}{% url 'api:user_me_list' version=request.version%}{% else %}{% url 'api:user_me_list' version="v2" %}{% endif %}" 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 %}
|
||||
<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>
|
||||
|
||||
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 %}
|
||||
@ -52,6 +52,7 @@ const watch = {
|
||||
publicPath: '/static/',
|
||||
host: '127.0.0.1',
|
||||
port: 3000,
|
||||
https: true,
|
||||
proxy: {
|
||||
'/': {
|
||||
target: TARGET,
|
||||
|
||||
@ -374,7 +374,7 @@ angular
|
||||
}
|
||||
});
|
||||
|
||||
if (!Authorization.getToken() || !Authorization.isUserLoggedIn()) {
|
||||
if (!Authorization.isUserLoggedIn()) {
|
||||
// User not authenticated, redirect to login page
|
||||
if (!/^\/(login|logout)/.test($location.path())) {
|
||||
$rootScope.preAuthUrl = $location.path();
|
||||
|
||||
@ -21,21 +21,13 @@ export default
|
||||
$injector) {
|
||||
return {
|
||||
setToken: function (token, expires) {
|
||||
// set the session cookie
|
||||
$cookies.remove('token');
|
||||
$cookies.remove('token_expires');
|
||||
$cookies.remove('userLoggedIn');
|
||||
|
||||
if (token && !(/^"[a-f0-9]+"$/ig.test(token))) {
|
||||
$cookies.put('token', `"${token}"`);
|
||||
} else {
|
||||
$cookies.put('token', token);
|
||||
}
|
||||
|
||||
$cookies.put('token_expires', expires);
|
||||
$cookies.put('userLoggedIn', true);
|
||||
$cookies.put('sessionExpired', false);
|
||||
$rootScope.token = token;
|
||||
|
||||
$rootScope.userLoggedIn = true;
|
||||
$rootScope.token_expires = expires;
|
||||
$rootScope.sessionExpired = false;
|
||||
@ -44,43 +36,34 @@ export default
|
||||
isUserLoggedIn: function () {
|
||||
if ($rootScope.userLoggedIn === undefined) {
|
||||
// Browser refresh may have occurred
|
||||
$rootScope.userLoggedIn = $cookies.get('userLoggedIn');
|
||||
$rootScope.sessionExpired = $cookies.get('sessionExpired');
|
||||
$rootScope.userLoggedIn = ($cookies.get('userLoggedIn') === 'true');
|
||||
$rootScope.sessionExpired = ($cookies.get('sessionExpired') === 'true');
|
||||
}
|
||||
return $rootScope.userLoggedIn;
|
||||
},
|
||||
|
||||
getToken: function () {
|
||||
if ($rootScope.token) {
|
||||
return $rootScope.token;
|
||||
}
|
||||
|
||||
let token = $cookies.get('token');
|
||||
|
||||
return token ? token.replace(/"/g, '') : undefined;
|
||||
},
|
||||
|
||||
retrieveToken: function (username, password) {
|
||||
return $http({
|
||||
method: 'POST',
|
||||
url: GetBasePath('authtoken'),
|
||||
data: {
|
||||
"username": username,
|
||||
"password": password
|
||||
},
|
||||
headers: {
|
||||
'Cache-Control': 'no-store',
|
||||
'Pragma': 'no-cache'
|
||||
}
|
||||
var getCSRFToken = $http({
|
||||
method: 'GET',
|
||||
url: `/api/login/`
|
||||
});
|
||||
|
||||
return getCSRFToken.then(function({data}) {
|
||||
var csrfmiddlewaretoken = /name='csrfmiddlewaretoken' value='([0-9a-zA-Z]+)' \//.exec(data)[1];
|
||||
// TODO: data needs to be encoded
|
||||
return $http({
|
||||
method: 'POST',
|
||||
url: `/api/login/`,
|
||||
data: `username=${username}&password=${password}&csrfmiddlewaretoken=${csrfmiddlewaretoken}&next=%2fapi%2f`,
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
deleteToken: function () {
|
||||
return $http({
|
||||
method: 'DELETE',
|
||||
url: GetBasePath('authtoken'),
|
||||
headers: {
|
||||
'Authorization': 'Token ' + this.getToken()
|
||||
}
|
||||
method: 'GET',
|
||||
url: '/api/logout/'
|
||||
});
|
||||
},
|
||||
|
||||
@ -125,7 +108,6 @@ export default
|
||||
SocketService.disconnect();
|
||||
$cookies.remove('token_expires');
|
||||
$cookies.remove('current_user');
|
||||
$cookies.remove('token');
|
||||
$cookies.put('userLoggedIn', false);
|
||||
$cookies.put('sessionExpired', false);
|
||||
$cookies.putObject('current_user', {});
|
||||
@ -134,7 +116,6 @@ export default
|
||||
$rootScope.userLoggedIn = false;
|
||||
$rootScope.sessionExpired = false;
|
||||
$rootScope.licenseMissing = true;
|
||||
$rootScope.token = null;
|
||||
$rootScope.token_expires = null;
|
||||
$rootScope.login_username = null;
|
||||
$rootScope.login_password = null;
|
||||
@ -168,11 +149,7 @@ export default
|
||||
getUser: function () {
|
||||
return $http({
|
||||
method: 'GET',
|
||||
url: GetBasePath('me'),
|
||||
headers: {
|
||||
'Authorization': 'Token ' + this.getToken(),
|
||||
"X-Auth-Token": 'Token ' + this.getToken()
|
||||
}
|
||||
url: GetBasePath('me')
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@ -169,7 +169,7 @@ export default ['$log', '$cookies', '$compile', '$rootScope',
|
||||
Authorization.retrieveToken(username, password)
|
||||
.then(function (data) {
|
||||
$('#login-modal').modal('hide');
|
||||
Authorization.setToken(data.data.token, data.data.expires);
|
||||
Authorization.setToken(data.data.expires);
|
||||
scope.$emit('AuthorizationGetUser');
|
||||
},
|
||||
function (data) {
|
||||
|
||||
@ -55,8 +55,8 @@
|
||||
*/
|
||||
|
||||
export default
|
||||
['$http', '$rootScope', '$q', 'Authorization',
|
||||
function ($http, $rootScope, $q, Authorization) {
|
||||
['$http', '$rootScope', '$q',
|
||||
function ($http, $rootScope, $q) {
|
||||
return {
|
||||
|
||||
headers: {},
|
||||
@ -113,150 +113,88 @@ export default
|
||||
args = (args) ? args : {};
|
||||
this.params = (args.params) ? args.params : null;
|
||||
this.pReplace();
|
||||
var expired = this.checkExpired(),
|
||||
token = Authorization.getToken();
|
||||
var expired = this.checkExpired();
|
||||
if (expired) {
|
||||
return this.createResponse({
|
||||
detail: 'Token is expired'
|
||||
detail: 'Session is expired'
|
||||
}, 401);
|
||||
} else if (token) {
|
||||
this.setHeader({
|
||||
Authorization: 'Token ' + token
|
||||
});
|
||||
this.setHeader({
|
||||
"X-Auth-Token": 'Token ' + token
|
||||
});
|
||||
} else {
|
||||
return $http({
|
||||
method: 'GET',
|
||||
url: this.url,
|
||||
headers: this.headers,
|
||||
params: this.params
|
||||
});
|
||||
} else {
|
||||
return this.createResponse({
|
||||
detail: 'Invalid token'
|
||||
}, 401);
|
||||
}
|
||||
},
|
||||
post: function (data) {
|
||||
var token = Authorization.getToken(),
|
||||
expired = this.checkExpired();
|
||||
var expired = this.checkExpired();
|
||||
if (expired) {
|
||||
return this.createResponse({
|
||||
detail: 'Token is expired'
|
||||
detail: 'Session is expired'
|
||||
}, 401);
|
||||
} else if (token) {
|
||||
this.setHeader({
|
||||
Authorization: 'Token ' + token
|
||||
});
|
||||
this.setHeader({
|
||||
"X-Auth-Token": 'Token ' + token
|
||||
});
|
||||
} else {
|
||||
return $http({
|
||||
method: 'POST',
|
||||
url: this.url,
|
||||
headers: this.headers,
|
||||
data: data
|
||||
});
|
||||
} else {
|
||||
return this.createResponse({
|
||||
detail: 'Invalid token'
|
||||
}, 401);
|
||||
}
|
||||
},
|
||||
put: function (data) {
|
||||
var token = Authorization.getToken(),
|
||||
expired = this.checkExpired();
|
||||
var expired = this.checkExpired();
|
||||
if (expired) {
|
||||
return this.createResponse({
|
||||
detail: 'Token is expired'
|
||||
detail: 'Session is expired'
|
||||
}, 401);
|
||||
} else if (token) {
|
||||
this.setHeader({
|
||||
Authorization: 'Token ' + token
|
||||
});
|
||||
this.setHeader({
|
||||
"X-Auth-Token": 'Token ' + token
|
||||
});
|
||||
} else {
|
||||
return $http({
|
||||
method: 'PUT',
|
||||
url: this.url,
|
||||
headers: this.headers,
|
||||
data: data
|
||||
});
|
||||
} else {
|
||||
return this.createResponse({
|
||||
detail: 'Invalid token'
|
||||
}, 401);
|
||||
}
|
||||
},
|
||||
patch: function (data) {
|
||||
var token = Authorization.getToken(),
|
||||
expired = this.checkExpired();
|
||||
var expired = this.checkExpired();
|
||||
if (expired) {
|
||||
return this.createResponse({
|
||||
detail: 'Token is expired'
|
||||
detail: 'Session is expired'
|
||||
}, 401);
|
||||
} else if (token) {
|
||||
this.setHeader({
|
||||
Authorization: 'Token ' + token
|
||||
});
|
||||
this.setHeader({
|
||||
"X-Auth-Token": 'Token ' + token
|
||||
});
|
||||
} else {
|
||||
return $http({
|
||||
method: 'PATCH',
|
||||
url: this.url,
|
||||
headers: this.headers,
|
||||
data: data
|
||||
});
|
||||
} else {
|
||||
return this.createResponse({
|
||||
detail: 'Invalid token'
|
||||
}, 401);
|
||||
}
|
||||
},
|
||||
destroy: function (data) {
|
||||
var token = Authorization.getToken(),
|
||||
expired = this.checkExpired();
|
||||
var expired = this.checkExpired();
|
||||
if (expired) {
|
||||
return this.createResponse({
|
||||
detail: 'Token is expired'
|
||||
detail: 'Session is expired'
|
||||
}, 401);
|
||||
} else if (token) {
|
||||
this.setHeader({
|
||||
Authorization: 'Token ' + token
|
||||
});
|
||||
this.setHeader({
|
||||
"X-Auth-Token": 'Token ' + token
|
||||
});
|
||||
} else {
|
||||
return $http({
|
||||
method: 'DELETE',
|
||||
url: this.url,
|
||||
headers: this.headers,
|
||||
data: data
|
||||
});
|
||||
} else {
|
||||
return this.createResponse({
|
||||
detail: 'Invalid token'
|
||||
}, 401);
|
||||
}
|
||||
},
|
||||
options: function (cache) {
|
||||
var params,
|
||||
token = Authorization.getToken(),
|
||||
expired = this.checkExpired();
|
||||
if (expired) {
|
||||
return this.createResponse({
|
||||
detail: 'Token is expired'
|
||||
detail: 'Session is expired'
|
||||
}, 401);
|
||||
} else if (token) {
|
||||
this.setHeader({
|
||||
Authorization: 'Token ' + token
|
||||
});
|
||||
this.setHeader({
|
||||
"X-Auth-Token": 'Token ' + token
|
||||
});
|
||||
} else {
|
||||
params = {
|
||||
method: 'OPTIONS',
|
||||
url: this.url,
|
||||
@ -265,10 +203,6 @@ export default
|
||||
cache: (cache ? true : false)
|
||||
};
|
||||
return $http(params);
|
||||
} else {
|
||||
return this.createResponse({
|
||||
detail: 'Invalid token'
|
||||
}, 401);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -165,7 +165,7 @@ angular.module('Utilities', ['RestServices', 'Utilities'])
|
||||
Alert('Conflict', data.conflict || "Resource currently in use.");
|
||||
} else if (status === 410) {
|
||||
Alert('Deleted Object', 'The requested object was previously deleted and can no longer be accessed.');
|
||||
} else if ((status === 'Token is expired') || (status === 401 && data.detail && data.detail === 'Token is expired') ||
|
||||
} else if ((status === 'Session is expired') || (status === 401 && data.detail && data.detail === 'Token is expired') ||
|
||||
(status === 401 && data && data.detail && data.detail === 'Invalid token')) {
|
||||
if ($rootScope.sessionTimer) {
|
||||
$rootScope.sessionTimer.expireSession('idle');
|
||||
|
||||
@ -8,15 +8,17 @@ import {
|
||||
AWX_E2E_PASSWORD
|
||||
} from './settings';
|
||||
|
||||
let authenticated;
|
||||
|
||||
const session = axios.create({
|
||||
baseURL: AWX_E2E_URL,
|
||||
xsrfHeaderName: 'X-CSRFToken',
|
||||
xsrfCookieName: 'csrftoken',
|
||||
httpsAgent: new https.Agent({
|
||||
rejectUnauthorized: false
|
||||
})
|
||||
}),
|
||||
auth: {
|
||||
username: AWX_E2E_USERNAME,
|
||||
password: AWX_E2E_PASSWORD
|
||||
}
|
||||
});
|
||||
|
||||
const getEndpoint = location => {
|
||||
@ -27,36 +29,15 @@ const getEndpoint = location => {
|
||||
return `${AWX_E2E_URL}/api/v2${location}`;
|
||||
};
|
||||
|
||||
const authenticate = () => {
|
||||
if (authenticated) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const uri = getEndpoint('/authtoken/');
|
||||
|
||||
const credentials = {
|
||||
username: AWX_E2E_USERNAME,
|
||||
password: AWX_E2E_PASSWORD
|
||||
};
|
||||
|
||||
return session.post(uri, credentials).then(res => {
|
||||
session.defaults.headers.Authorization = `Token ${res.data.token}`;
|
||||
authenticated = true;
|
||||
|
||||
return res;
|
||||
});
|
||||
};
|
||||
|
||||
const request = (method, location, data) => {
|
||||
const uri = getEndpoint(location);
|
||||
const action = session[method.toLowerCase()];
|
||||
|
||||
return authenticate()
|
||||
.then(() => action(uri, data))
|
||||
return action(uri, data)
|
||||
.then(res => {
|
||||
console.log([ // eslint-disable-line no-console
|
||||
res.config.method.toUpperCase(),
|
||||
uri,
|
||||
res.config.url,
|
||||
res.status,
|
||||
res.statusText
|
||||
].join(' '));
|
||||
|
||||
198
docs/auth/oauth.md
Normal file
198
docs/auth/oauth.md
Normal file
@ -0,0 +1,198 @@
|
||||
## 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 2 tokens as well as applications, a server-side representation of API
|
||||
clients used to generate tokens. With OAuth 2, a user can authenticate by passing a token as part of
|
||||
the HTTP authentication header. The token can be scoped to have more restrictive permissions on top of
|
||||
the base RBAC permissions of the user. Refer to [RFC 6749](https://tools.ietf.org/html/rfc6749) for
|
||||
more details of OAuth 2 specification.
|
||||
|
||||
## Usage
|
||||
|
||||
#### Managing OAuth 2 applications and tokens
|
||||
Applications and tokens can be managed as a top-level resource at `/api/<version>/applications` and
|
||||
`/api/<version>/tokens`. These resources can also be accessed respective to the user at
|
||||
`/api/<version>/users/N/<resource>`. Applications can be created by making a POST to either `api/<version>/applications`
|
||||
or `/api/<version>/users/N/applications`.
|
||||
|
||||
Each OAuth 2 application represents a specific API client on the server side. For an API client to use the API,
|
||||
it must first have an application, and issue an access token.
|
||||
|
||||
Individual applications will be accessible via their primary keys:
|
||||
`/api/<version>/applications/<primary key of an application>/`. Here is a typical application:
|
||||
```
|
||||
{
|
||||
"id": 1,
|
||||
"type": "o_auth2_application",
|
||||
"url": "/api/v2/applications/1/",
|
||||
"related": {
|
||||
"user": "/api/v2/users/1/",
|
||||
"tokens": "/api/v2/applications/1/tokens/",
|
||||
"activity_stream": "/api/v2/applications/1/activity_stream/"
|
||||
},
|
||||
"summary_fields": {
|
||||
"user": {
|
||||
"id": 1,
|
||||
"username": "root",
|
||||
"first_name": "",
|
||||
"last_name": ""
|
||||
},
|
||||
"tokens": {
|
||||
"count": 1,
|
||||
"results": [
|
||||
{
|
||||
"scope": "read",
|
||||
"token": "**************",
|
||||
"id": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"created": "2018-02-20T23:06:43.215315Z",
|
||||
"modified": "2018-02-20T23:06:43.215375Z",
|
||||
"name": "Default application for root",
|
||||
"user": 1,
|
||||
"client_id": "BIyE720WAjr14nNxGXrBbsRsG0FkjgeL8cxNmIWP",
|
||||
"client_secret": "OdO6TMNAYxUVv4HLitLOnRdAvtClEV8l99zlb8EJEZjlzVNaVVlWiKXicznLDeANwu5qRgeQRvD3AnuisQGCPXXRCx79W1ARQ5cSmc9mrU1JbqW7nX3IZYhLIFgsDH8u",
|
||||
"client_type": "confidential",
|
||||
"redirect_uris": "",
|
||||
"authorization_grant_type": "password",
|
||||
"skip_authorization": false
|
||||
},
|
||||
```
|
||||
In the above example, `user` is the primary key of the user this application associates to and `name` is
|
||||
a human-readable identifier for the application. The other fields, like `client_id` and
|
||||
`redirect_uris`, are mainly used for OAuth 2 authorization, which will be covered later in the '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 applications.
|
||||
|
||||
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. Tokens can be created by POSTing to `/api/v2/tokens/`
|
||||
endpoint by providing `application` and `scope` fields to point to related application and specify
|
||||
token scope; or POSTing to `/api/applications/<pk>/tokens/` by providing only `scope`, while
|
||||
the parent application will be automatically linked.
|
||||
|
||||
# More Docs Coming Soon
|
||||
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. Tokens can be created by POSTing to `/api/v2/tokens/`
|
||||
endpoint by providing `application` and `scope` fields to point to related application and specify
|
||||
token scope; or POSTing to `/api/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`. The `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 they are 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.
|
||||
> Note: Users can only see the token or refresh-token _value_ at the time of creation ONLY.
|
||||
|
||||
#### Using OAuth 2 token system as a Personal Access Token (PAT)
|
||||
The most common usage of OAuth 2 is authenticating users. The `token` field of a token is used
|
||||
as part of the HTTP authentication header, in the format `Authorization: Bearer <token field value>`. This _Bearer_
|
||||
token can be obtained by doing a curl to the `/api/o/token/` endpoint as shown in `api_o_auth_authorization_root_view.md`.
|
||||
|
||||
Here is an example of using that PAT to access an API endpoint using `curl`:
|
||||
```
|
||||
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 acquiring tokens. The token acquire endpoint
|
||||
is also responsible for token refresh, and token revoke is done by a 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 implements them and puts related
|
||||
endpoints under `/api/o/` endpoint. Detailed examples on the 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 was used as the main authentication method. Starting from Tower 3.3,
|
||||
session-based authentication will take the place as the main authentication method, and auth token
|
||||
will be replaced by OAuth 2 tokens.
|
||||
|
||||
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 `session_id` cookie. The session_id
|
||||
is *a random string which will be mapped to user authentication informations by server*. Crackers who
|
||||
hijacks cookie will only get the session_id itself, which does not imply any critical user info, is valid only for
|
||||
a limited time, and can be revoked at any time.
|
||||
|
||||
## Usage
|
||||
|
||||
In session authentication, users log in using the `/api/login/` endpoint. A GET to `/api/login/` displays the
|
||||
log in page of API browser:
|
||||
|
||||

|
||||
|
||||
Users 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 %}`.
|
||||
|
||||
The session_id 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. The default session cookie age is 2 weeks.
|
||||
|
||||
After a valid session is acquired, a client should provide the session_id as a cookie for subsequent requests
|
||||
in order to be authenticated. For example:
|
||||
```
|
||||
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 and the same user is logged in from 5 different places, the earliest 2 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 by HTTPS(S) request nor 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-extensions==1.7.8
|
||||
django-jsonfield==1.0.1
|
||||
django-oauth-toolkit==1.0.0
|
||||
django-polymorphic==1.3
|
||||
django-pglocks==1.0.2
|
||||
django-radius==1.1.0
|
||||
|
||||
@ -59,6 +59,7 @@ django-celery-results==1.0.1
|
||||
django-crum==0.7.1
|
||||
django-extensions==1.7.8
|
||||
django-jsonfield==1.0.1
|
||||
django-oauth-toolkit==1.0.0
|
||||
django-pglocks==1.0.2
|
||||
django-polymorphic==1.3
|
||||
django-radius==1.1.0
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user