Merge pull request #904 from ansible/oauth_n_session

Implement session-based  and OAuth 2 authentications
This commit is contained in:
Christian Adams 2018-02-26 12:12:38 -05:00 committed by GitHub
commit 9493b72f29
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
50 changed files with 2127 additions and 624 deletions

View File

@ -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

View File

@ -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',
)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View 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": []
}
```

View File

@ -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
View 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']

View File

@ -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

View File

@ -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']

View 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']

View File

@ -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,

View File

@ -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)

View File

@ -17,6 +17,9 @@ from django.core.exceptions import ObjectDoesNotExist
# Django REST Framework
from rest_framework.exceptions import ParseError, PermissionDenied, ValidationError
# Django OAuth Toolkit
from 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:

View File

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

View File

@ -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()

View File

@ -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]:

View File

@ -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'),
),
]

View File

@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.7 on 2017-11-09 21:54
from __future__ import unicode_literals
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('sessions', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('main', '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)),
],
),
]

View File

@ -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'))

View File

@ -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
View 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,
)

View File

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

View File

@ -11,6 +11,9 @@ import json
from django.conf import settings
from django.db.models.signals import post_save, pre_delete, post_delete, m2m_changed
from django.dispatch import receiver
from django.contrib.auth import SESSION_KEY
from django.utils import timezone
from django.utils.translation import ugettext_lazy as _
# Django-CRUM
from crum import get_current_request, get_current_user
@ -20,6 +23,7 @@ import six
# AWX
from awx.main.models import * # noqa
from django.contrib.sessions.models import Session
from awx.api.serializers import * # noqa
from awx.main.utils import model_instance_diff, model_to_dict, camelcase_to_underscore
from awx.main.utils import ignore_inventory_computed_fields, ignore_inventory_group_removal, _inventory_updates
@ -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'
# )

View File

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

View 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

View File

@ -47,6 +47,7 @@ from awx.main.models.notifications import (
)
from awx.main.models.workflow import WorkflowJobTemplate
from awx.main.models.ad_hoc_commands import AdHocCommand
from awx.main.models.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'
)

View File

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

View File

@ -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')

View 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
)

View 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()

View File

@ -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',

View File

@ -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),

View File

@ -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)

View File

@ -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

View File

@ -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>

View 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 %}

View File

@ -52,6 +52,7 @@ const watch = {
publicPath: '/static/',
host: '127.0.0.1',
port: 3000,
https: true,
proxy: {
'/': {
target: TARGET,

View File

@ -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();

View File

@ -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')
});
},

View File

@ -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) {

View File

@ -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);
}
}
};

View File

@ -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');

View File

@ -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
View 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
View 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:
![Example session log in page](../img/auth_session_1.png?raw=true)
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

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

View File

@ -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

View File

@ -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