Implement session-based and OAuth 2 authentications

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

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

View File

@ -16,6 +16,9 @@ from rest_framework import authentication
from rest_framework import exceptions
from rest_framework import HTTP_HEADER_ENCODING
# Django OAuth Toolkit
from oauth2_provider.contrib.rest_framework import OAuth2Authentication
# AWX
from awx.main.models import AuthToken
@ -137,3 +140,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, user
)
))
setattr(user, 'oauth_scopes', [x for x in token.scope.split() if x])
return ret

View File

@ -1,8 +1,9 @@
# Django
from django.utils.translation import ugettext_lazy as _
# Tower
# AWX
from awx.conf import fields, register
from awx.api.fields import OAuth2ProviderField
register(
@ -24,7 +25,24 @@ register(
category=_('Authentication'),
category_slug='authentication',
)
register(
'SESSION_COOKIE_AGE',
field_class=fields.IntegerField,
min_value=60,
label=_('Idle Time Force Log Out'),
help_text=_('Number of seconds that a user is inactive before they will need to login again.'),
category=_('Authentication'),
category_slug='authentication',
)
register(
'SESSIONS_PER_USER',
field_class=fields.IntegerField,
min_value=-1,
label=_('Maximum number of simultaneous logged in sessions'),
help_text=_('Maximum number of simultaneous logged in sessions a user may have. To disable enter -1.'),
category=_('Authentication'),
category_slug='authentication',
)
register(
'AUTH_BASIC_ENABLED',
field_class=fields.BooleanField,
@ -33,3 +51,15 @@ register(
category=_('Authentication'),
category_slug='authentication',
)
register(
'OAUTH2_PROVIDER',
field_class=OAuth2ProviderField,
default={'ACCESS_TOKEN_EXPIRE_SECONDS': 36000, 'AUTHORIZATION_CODE_EXPIRE_SECONDS': 60},
label=_('OAuth 2 Timeout Settings'),
help_text=_('Dictionary for customizing OAuth 2 timeouts, available items are '
'`ACCESS_TOKEN_EXPIRE_SECONDS`, the duration of access tokens in the number '
'of seconds, and `AUTHORIZATION_CODE_EXPIRE_SECONDS`, the duration of '
'authorization grants in the number of seconds.'),
category=_('Authentication'),
category_slug='authentication',
)

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,29 @@ 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))
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,12 @@ import re
import six
import urllib
from collections import OrderedDict
from dateutil import rrule
from datetime import timedelta
# OAuth
from oauthlib.common import generate_token
from oauth2_provider.settings import oauth2_settings
# Django
from django.conf import settings
@ -67,6 +73,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 +435,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 +842,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'])
@ -906,6 +924,113 @@ class UserSerializer(BaseSerializer):
return self._validate_ldap_managed_field(value, 'is_superuser')
class OauthApplicationSerializer(BaseSerializer):
class Meta:
model = Application
fields = (
'*', '-description', 'user', 'client_id', 'client_secret', 'client_type',
'redirect_uris', 'authorization_grant_type', 'skip_authorization',
)
read_only_fields = ('client_id', 'client_secret')
read_only_on_update_fields = ('user', 'authorization_grant_type')
extra_kwargs = {
'user': {'allow_null': False, 'required': True},
'authorization_grant_type': {'allow_null': False}
}
def get_modified(self, obj):
if obj is None:
return None
return obj.updated
def get_related(self, obj):
ret = super(OauthApplicationSerializer, self).get_related(obj)
if obj.user:
ret['user'] = self.reverse('api:user_detail', kwargs={'pk': obj.user.pk})
ret['tokens'] = self.reverse(
'api:user_me_oauth_application_token_list', kwargs={'pk': obj.pk}
)
ret['activity_stream'] = self.reverse(
'api:user_me_oauth_application_activity_stream_list', kwargs={'pk': obj.pk}
)
return ret
def _summary_field_tokens(self, obj):
token_list = [{'id': x.pk, 'token': x.token} for x in obj.accesstoken_set.all()[:10]]
if has_model_field_prefetched(obj, 'accesstoken_set'):
token_count = len(obj.accesstoken_set.all())
else:
if len(token_list) < 10:
token_count = len(token_list)
else:
token_count = obj.accesstoken_set.count()
return {'count': token_count, 'results': token_list}
def get_summary_fields(self, obj):
ret = super(OauthApplicationSerializer, self).get_summary_fields(obj)
ret['tokens'] = self._summary_field_tokens(obj)
return ret
class OauthTokenSerializer(BaseSerializer):
refresh_token = serializers.SerializerMethodField()
class Meta:
model = AccessToken
fields = (
'*', '-name', '-description', 'user', 'token', 'refresh_token',
'application', 'expires', 'scope',
)
read_only_fields = ('user', 'token', 'expires')
read_only_on_update_fields = ('application',)
extra_kwargs = {
'application': {'allow_null': False}
}
def get_modified(self, obj):
if obj is None:
return None
return obj.updated
def get_related(self, obj):
ret = super(OauthTokenSerializer, self).get_related(obj)
if obj.user:
ret['user'] = self.reverse('api:user_detail', kwargs={'pk': obj.user.pk})
if obj.application:
ret['application'] = self.reverse(
'api:user_me_oauth_application_detail', kwargs={'pk': obj.application.pk}
)
ret['activity_stream'] = self.reverse(
'api:user_me_oauth_token_activity_stream_list', kwargs={'pk': obj.pk}
)
return ret
def get_refresh_token(self, obj):
try:
return getattr(obj.refresh_token, 'token', '')
except ObjectDoesNotExist:
return ''
def create(self, validated_data):
validated_data['token'] = generate_token()
validated_data['expires'] = now() + timedelta(
seconds=oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS
)
obj = super(OauthTokenSerializer, self).create(validated_data)
if obj.application and obj.application.user:
obj.user = obj.application.user
obj.save()
RefreshToken.objects.create(
user=obj.application.user if obj.application and obj.application.user else None,
token=generate_token(),
application=obj.application if obj.application else None,
access_token=obj
)
return obj
class OrganizationSerializer(BaseSerializer):
show_capabilities = ['edit', 'delete']
@ -4219,7 +4344,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 +4402,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:user_me_oauth_application_detail', kwargs={'pk': thisItem.pk}
))
elif fk == 'access_token':
rel[fk].append(self.reverse(
'api:user_me_oauth_token_detail', kwargs={'pk': thisItem.pk}
))
else:
rel[fk].append(self.reverse('api:' + fk + '_detail', kwargs={'pk': thisItem.id}))

View File

@ -0,0 +1,311 @@
This page lists OAuth utility endpoints used for authorization, token refresh and revoke.
Note endpoints other than `/api/o/authorize/` are not meant to be used in browsers and do not
support HTTP GET. The endpoints here strictly follow
[RFC specs for OAuth2](https://tools.ietf.org/html/rfc6749), so please use that for detailed
reference. Here we give some examples to demonstrate the typical usage of these endpoints in
AWX context (Note AWX net location default to `http://localhost:8013` in examples):
## Authorization using application of grant type `implicit`
Suppose we have an application `admin's app` of grant type `implicit`:
```text
{
"id": 1,
"type": "application",
"related": {
...
"name": "admin's app",
"user": 1,
"client_id": "L0uQQWW8pKX51hoqIRQGsuqmIdPi2AcXZ9EJRGmj",
"client_secret": "9Wp4dUrUsigI8J15fQYJ3jn0MJHLkAjyw7ikBsABeWTNJbZwy7eB2Xro9ykYuuygerTPQ2gIF2DCTtN3kurkt0Me3AhanEw6peRNvNLs1NNfI4f53mhX8zo5JQX0BKy5",
"client_type": "confidential",
"redirect_uris": "http://localhost:8013/api/",
"authorization_grant_type": "implicit",
"skip_authorization": false
}
```
In API browser, first make sure the user is logged in via session auth, then visit authorization
endpoint with given parameters:
```text
http://localhost:8013/api/o/authorize/?response_type=token&client_id=L0uQQWW8pKX51hoqIRQGsuqmIdPi2AcXZ9EJRGmj&scope=read
```
Here the value of `client_id` should be the same as that of `client_id` field of underlying application.
On success, an authorization page should be displayed asking logged in user to grant/deny access token.
Once user click on 'grant', API browser will try POSTing to the same endpoint with the same parameters
in POST body, on success a 302 redirect will be returned:
```text
HTTP/1.1 302 Found
Connection:keep-alive
Content-Language:en
Content-Length:0
Content-Type:text/html; charset=utf-8
Date:Tue, 05 Dec 2017 20:36:19 GMT
Location:http://localhost:8013/api/#access_token=0lVJJkolFTwYawHyGkk7NTmSKdzBen&token_type=Bearer&state=&expires_in=36000&scope=read
Server:nginx/1.12.2
Strict-Transport-Security:max-age=15768000
Vary:Accept-Language, Cookie
```
By inspecting the fragment part of redirect URL given by `Location` header, we can get access token
(given by `access_token` key) as well as other standard fields specified in OAuth spec. Internally
an OAuth token is created under the given application. Verify by
`GET /api/v2/me/oauth/tokens/?token=0lVJJkolFTwYawHyGkk7NTmSKdzBen`
```text
HTTP 200 OK
Allow: GET, POST, HEAD, OPTIONS
Content-Type: application/json
...
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"id": 2,
"type": "access_token",
...
"user": 1,
"token": "0lVJJkolFTwYawHyGkk7NTmSKdzBen",
"refresh_token": "",
"application": 1,
"expires": "2017-12-06T06:36:19.743062Z",
"scope": "read"
}
]
}
```
## Authorization using application of grant type `password`
Suppose we have an application `curl for admin` with grant type `password`:
```text
{
"id": 6,
"type": "application",
...
"name": "curl for admin",
"user": 1,
"client_id": "gwSPoasWSdNkMDtBN3Hu2WYQpPWCO9SwUEsKK22l",
"client_secret": "fI6ZpfocHYBGfm1tP92r0yIgCyfRdDQt0Tos9L8a4fNsJjQQMwp9569eIaUBsaVDgt2eiwOGe0bg5m5vCSstClZmtdy359RVx2rQK5YlIWyPlrolpt2LEpVeKXWaiybo",
"client_type": "confidential",
"redirect_uris": "",
"authorization_grant_type": "password",
"skip_authorization": false
}
```
Log in is not required for `password` grant type, so we can simply use `curl` to acquire access token
via `/api/o/token/`:
```bash
curl -X POST \
-d "grant_type=password&username=<username>&password=<password>&scope=read" \
-u "gwSPoasWSdNkMDtBN3Hu2WYQpPWCO9SwUEsKK22l:fI6ZpfocHYBGfm1tP92r0yIgCyfRdDQt0Tos9L8a4fNsJjQQMwp9569e
IaUBsaVDgt2eiwOGe0bg5m5vCSstClZmtdy359RVx2rQK5YlIWyPlrolpt2LEpVeKXWaiybo" \
http://localhost:8013/api/o/token/ -i
```
In the above post request, parameters `username` and `password` are username and password of the related
AWX user of the underlying application, and the authentication information is of format
`<client_id>:<client_secret>`, where `client_id` and `client_secret` are the corresponding fields of
underlying application.
Upon success, access token, refresh token and other information are given in the response body in JSON
format:
```text
HTTP/1.1 200 OK
Server: nginx/1.12.2
Date: Tue, 05 Dec 2017 16:48:09 GMT
Content-Type: application/json
Content-Length: 163
Connection: keep-alive
Content-Language: en
Vary: Accept-Language, Cookie
Pragma: no-cache
Cache-Control: no-store
Strict-Transport-Security: max-age=15768000
{"access_token": "9epHOqHhnXUcgYK8QanOmUQPSgX92g", "token_type": "Bearer", "expires_in": 36000, "refresh_token": "jMRX6QvzOTf046KHee3TU5mT3nyXsz", "scope": "read"}
```
Verify by searching created token:
```text
GET /api/v2/me/oauth/tokens/?token=9epHOqHhnXUcgYK8QanOmUQPSgX92g
HTTP 200 OK
Allow: GET, POST, HEAD, OPTIONS
Content-Type: application/json
...
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"id": 26,
"type": "access_token",
...
"user": 1,
"token": "9epHOqHhnXUcgYK8QanOmUQPSgX92g",
"refresh_token": "jMRX6QvzOTf046KHee3TU5mT3nyXsz",
"application": 6,
"expires": "2017-12-06T02:48:09.812720Z",
"scope": "read"
}
]
}
```
## Refresh an existing access token
Suppose we have an existing access token with refresh token provided:
```text
{
"id": 35,
"type": "access_token",
...
"user": 1,
"token": "omMFLk7UKpB36WN2Qma9H3gbwEBSOc",
"refresh_token": "AL0NK9TTpv0qp54dGbC4VUZtsZ9r8z",
"application": 6,
"expires": "2017-12-06T03:46:17.087022Z",
"scope": "read write"
}
```
`/api/o/token/` endpoint is used for refreshing access token:
```bash
curl -X POST \
-d "grant_type=refresh_token&refresh_token=AL0NK9TTpv0qp54dGbC4VUZtsZ9r8z" \
-u "gwSPoasWSdNkMDtBN3Hu2WYQpPWCO9SwUEsKK22l:fI6ZpfocHYBGfm1tP92r0yIgCyfRdDQt0Tos9L8a4fNsJjQQMwp9569eIaUBsaVDgt2eiwOGe0bg5m5vCSstClZmtdy359RVx2rQK5YlIWyPlrolpt2LEpVeKXWaiybo" \
http://localhost:8013/api/o/token/ -i
```
In the above post request, `refresh_token` is provided by `refresh_token` field of the access token
above. The authentication information is of format `<client_id>:<client_secret>`, where `client_id`
and `client_secret` are the corresponding fields of underlying related application of the access token.
Upon success, the new (refreshed) access token with the same scope information as the previous one is
given in the response body in JSON format:
```text
HTTP/1.1 200 OK
Server: nginx/1.12.2
Date: Tue, 05 Dec 2017 17:54:06 GMT
Content-Type: application/json
Content-Length: 169
Connection: keep-alive
Content-Language: en
Vary: Accept-Language, Cookie
Pragma: no-cache
Cache-Control: no-store
Strict-Transport-Security: max-age=15768000
{"access_token": "NDInWxGJI4iZgqpsreujjbvzCfJqgR", "token_type": "Bearer", "expires_in": 36000, "refresh_token": "DqOrmz8bx3srlHkZNKmDpqA86bnQkT", "scope": "read write"}
```
Internally, the refresh operation deletes the existing token and a new token is created immediately
after, with information like scope and related application identical to the original one. We can
verify by checking the new token is present
```text
GET /api/v2/me/oauth/tokens/?token=NDInWxGJI4iZgqpsreujjbvzCfJqgR
HTTP 200 OK
Allow: GET, POST, HEAD, OPTIONS
Content-Type: application/json
Vary: Accept
X-API-Node: awx
X-API-Query-Count: 4
X-API-Query-Time: 0.004s
X-API-Time: 0.021s
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"id": 36,
"type": "access_token",
...
"user": 1,
"token": "NDInWxGJI4iZgqpsreujjbvzCfJqgR",
"refresh_token": "DqOrmz8bx3srlHkZNKmDpqA86bnQkT",
"application": 6,
"expires": "2017-12-06T03:54:06.181917Z",
"scope": "read write"
}
]
}
```
and the old token is deleted.
```text
GET /api/v2/me/oauth/tokens/?token=omMFLk7UKpB36WN2Qma9H3gbwEBSOc
HTTP 200 OK
Allow: GET, POST, HEAD, OPTIONS
Content-Type: application/json
Vary: Accept
X-API-Node: awx
X-API-Query-Count: 2
X-API-Query-Time: 0.003s
X-API-Time: 0.018s
{
"count": 0,
"next": null,
"previous": null,
"results": []
}
```
## Revoke an access token
Revoking an access token is the same as deleting the token resource object. Suppose we have
an existing token to revoke:
```text
{
"id": 30,
"type": "access_token",
"url": "/api/v2/me/oauth/tokens/30/",
...
"user": null,
"token": "rQONsve372fQwuc2pn76k3IHDCYpi7",
"refresh_token": "",
"application": 6,
"expires": "2017-12-06T03:24:25.614523Z",
"scope": "read"
}
```
Revoking is conducted by POSTing to `/api/o/revoke_token/` with the token to revoke as parameter:
```bash
curl -X POST -d "token=rQONsve372fQwuc2pn76k3IHDCYpi7" \
-u "gwSPoasWSdNkMDtBN3Hu2WYQpPWCO9SwUEsKK22l:fI6ZpfocHYBGfm1tP92r0yIgCyfRdDQt0Tos9L8a4fNsJjQQMwp9569eIaUBsaVDgt2eiwOGe0bg5m5vCSstClZmtdy359RVx2rQK5YlIWyPlrolpt2LEpVeKXWaiybo" \
http://localhost:8013/api/o/revoke_token/ -i
```
`200 OK` means a successful delete.
```text
HTTP/1.1 200 OK
Server: nginx/1.12.2
Date: Tue, 05 Dec 2017 18:05:18 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 0
Connection: keep-alive
Vary: Accept-Language, Cookie
Content-Language: en
Strict-Transport-Security: max-age=15768000
```
We can verify the effect by checking if the token is no longer present.
```text
GET /api/v2/me/oauth/tokens/?token=rQONsve372fQwuc2pn76k3IHDCYpi7
HTTP 200 OK
Allow: GET, POST, HEAD, OPTIONS
Content-Type: application/json
Vary: Accept
X-API-Node: awx
X-API-Query-Count: 3
X-API-Query-Time: 0.003s
X-API-Time: 0.098s
{
"count": 0,
"next": null,
"previous": null,
"results": []
}
```

18
awx/api/urls/oauth.py Normal file
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,
@ -60,6 +64,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 = [
@ -116,6 +122,7 @@ v2_urls = [
url(r'^jobs/(?P<pk>[0-9]+)/credentials/$', JobCredentialsList.as_view(), name='job_credentials_list'),
url(r'^job_templates/(?P<pk>[0-9]+)/extra_credentials/$', JobTemplateExtraCredentialsList.as_view(), name='job_template_extra_credentials_list'),
url(r'^job_templates/(?P<pk>[0-9]+)/credentials/$', JobTemplateCredentialsList.as_view(), name='job_template_credentials_list'),
url(r'^me/oauth/', include(user_oauth_urls))
url(r'^schedules/preview/$', SchedulePreview.as_view(), name='schedule_rrule'),
url(r'^schedules/zoneinfo/$', ScheduleZoneInfo.as_view(), name='schedule_zoneinfo'),
]
@ -125,6 +132,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

@ -0,0 +1,49 @@
# Copyright (c) 2017 Ansible, Inc.
# All Rights Reserved.
from django.conf.urls import url
from awx.api.views import (
UserMeOauthRootView,
UserMeOauthApplicationList,
UserMeOauthApplicationDetail,
UserMeOauthApplicationTokenList,
UserMeOauthApplicationActivityStreamList,
UserMeOauthTokenList,
UserMeOauthTokenDetail,
UserMeOauthTokenActivityStreamList
)
urls = [
url(r'^$', UserMeOauthRootView.as_view(), name='user_me_oauth_root_view'),
url(r'^applications/$', UserMeOauthApplicationList.as_view(), name='user_me_oauth_application_list'),
url(
r'^applications/(?P<pk>[0-9]+)/$',
UserMeOauthApplicationDetail.as_view(),
name='user_me_oauth_application_detail'
),
url(
r'^applications/(?P<pk>[0-9]+)/tokens/$',
UserMeOauthApplicationTokenList.as_view(),
name='user_me_oauth_application_token_list'
),
url(
r'^applications/(?P<pk>[0-9]+)/activity_stream/$',
UserMeOauthApplicationActivityStreamList.as_view(),
name='user_me_oauth_application_activity_stream_list'
),
url(r'^tokens/$', UserMeOauthTokenList.as_view(), name='user_me_oauth_token_list'),
url(
r'^tokens/(?P<pk>[0-9]+)/$',
UserMeOauthTokenDetail.as_view(),
name='user_me_oauth_token_detail'
),
url(
r'^tokens/(?P<pk>[0-9]+)/activity_stream/$',
UserMeOauthTokenActivityStreamList.as_view(),
name='user_me_oauth_token_activity_stream_list'
),
]
__all__ = ['urls']

View File

@ -64,7 +64,7 @@ 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
@ -204,6 +204,22 @@ class ApiRootView(APIView):
if feature_enabled('rebranding'):
data['custom_logo'] = settings.CUSTOM_LOGO
data['custom_login_info'] = settings.CUSTOM_LOGIN_INFO
data['oauth'] = drf_reverse('api:oauth_authorization_root_view')
return Response(data)
class ApiOAuthAuthorizationRootView(APIView):
authentication_classes = []
permission_classes = (AllowAny,)
view_name = _("API OAuth Authorization Root")
versioning_class = None
def get(self, request, format=None):
data = OrderedDict()
data['authorize'] = drf_reverse('api:authorize')
data['token'] = drf_reverse('api:token')
data['revoke_token'] = drf_reverse('api:revoke-token')
return Response(data)
@ -223,6 +239,8 @@ class ApiVersionRootView(APIView):
data['config'] = reverse('api:api_v1_config_view', request=request)
data['settings'] = reverse('api:setting_category_list', request=request)
data['me'] = reverse('api:user_me_list', request=request)
if get_request_version(request) > 1:
data['oauth'] = reverse('api:user_me_oauth_root_view', request=request)
data['dashboard'] = reverse('api:dashboard_view', request=request)
data['organizations'] = reverse('api:organization_list', request=request)
data['users'] = reverse('api:user_list', request=request)
@ -1554,6 +1572,76 @@ class UserMeList(ListAPIView):
return self.model.objects.filter(pk=self.request.user.pk)
class UserMeOauthRootView(APIView):
view_name = _("OAuth Root")
def get(self, request, format=None):
data = OrderedDict()
data['applications'] = reverse('api:user_me_oauth_application_list', request=request)
data['tokens'] = reverse('api:user_me_oauth_token_list', request=request)
return Response(data)
class UserMeOauthApplicationList(ListCreateAPIView):
view_name = _("OAuth Applications")
model = Application
serializer_class = OauthApplicationSerializer
class UserMeOauthApplicationDetail(RetrieveUpdateDestroyAPIView):
view_name = _("OAuth Application Detail")
model = Application
serializer_class = OauthApplicationSerializer
class UserMeOauthApplicationTokenList(SubListCreateAPIView):
view_name = _("OAuth Application Tokens")
model = AccessToken
serializer_class = OauthTokenSerializer
parent_model = Application
relationship = 'accesstoken_set'
parent_key = 'application'
class UserMeOauthApplicationActivityStreamList(ActivityStreamEnforcementMixin, SubListAPIView):
model = ActivityStream
serializer_class = ActivityStreamSerializer
parent_model = Application
relationship = 'activitystream_set'
class UserMeOauthTokenList(ListCreateAPIView):
view_name = _("OAuth Tokens")
model = AccessToken
serializer_class = OauthTokenSerializer
class UserMeOauthTokenDetail(RetrieveUpdateDestroyAPIView):
view_name = _("OAuth Token Detail")
model = AccessToken
serializer_class = OauthTokenSerializer
class UserMeOauthTokenActivityStreamList(ActivityStreamEnforcementMixin, SubListAPIView):
model = ActivityStream
serializer_class = ActivityStreamSerializer
parent_model = AccessToken
relationship = 'activitystream_set'
class UserTeamsList(ListAPIView):
model = User

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

View File

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

View File

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

View File

@ -0,0 +1,29 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.7 on 2017-12-04 19:49
from __future__ import unicode_literals
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.OAUTH2_PROVIDER_APPLICATION_MODEL),
migrations.swappable_dependency(settings.OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL),
('main', '0018_v330_create_user_session_membership'),
]
operations = [
migrations.AddField(
model_name='activitystream',
name='access_token',
field=models.ManyToManyField(blank=True, to=settings.OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL),
),
migrations.AddField(
model_name='activitystream',
name='application',
field=models.ManyToManyField(blank=True, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL),
),
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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
@ -253,6 +264,7 @@ INSTALLED_APPS = (
'django.contrib.sessions',
'django.contrib.sites',
'django.contrib.staticfiles',
'oauth2_provider',
'rest_framework',
'django_extensions',
'django_celery_results',
@ -275,9 +287,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 +334,11 @@ AUTHENTICATION_BACKENDS = (
'django.contrib.auth.backends.ModelBackend',
)
# Django OAuth Toolkit settings
OAUTH2_PROVIDER_APPLICATION_MODEL = 'oauth2_provider.Application'
OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL = 'oauth2_provider.AccessToken'
OAUTH2_PROVIDER = {}
# LDAP server (default to None to skip using LDAP authentication).
# Note: This setting may be overridden by database settings.
AUTH_LDAP_SERVER_URI = None

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,16 +8,15 @@ 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
from django.contrib import auth
# Django REST Framework
from rest_framework.renderers import JSONRenderer
# AWX
from awx.main.models import AuthToken
from awx.api.serializers import UserSerializer
logger = logging.getLogger('awx.sso.views')
@ -46,25 +45,9 @@ 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)
auth.login(self.request, self.request.user)
logger.info(smart_text(u"User {} logged in".format(self.request.user.username)))
# TODO: remove these 2 cookie-sets after UI removes them
response.set_cookie('userLoggedIn', 'true')
current_user = UserSerializer(self.request.user)
current_user = JSONRenderer().render(current_user.data)

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 %}
{% if user.is_authenticated and not inside_login_context %}
<li><a href="{% url 'api:user_me_list' version=request.version %}" data-toggle="tooltip" data-placement="bottom" data-delay="1000" title="Logged in as {{ user }}{% if user.get_full_name %} ({{ user.get_full_name }}){% endif %}"><span class="glyphicon glyphicon-user"></span> <span class="visible-xs-inline">Logged in as </span>{{ user }}{% if user.get_full_name %}<span class="visible-xs-inline"> ({{ user.get_full_name }})</span>{% endif %}</a></li>
<li><a href="{% url 'api: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 %}

188
docs/auth/oauth.md Normal file
View File

@ -0,0 +1,188 @@
## Introduction
Starting from Tower 3.3, OAuth 2 will be used as the new means of token-based authentication. Users
will be able to manage OAuth tokens as well as applications, a server-side representation of API
clients used to generate tokens. By including an OAuth token as part of the HTTP authentication
header, a user will be able to authenticate herself and gain more restrictive permissions on top of
the base RBAC permissions of the user. The degree of restriction is controllable by the scope of an
OAuth token. Refer to [RFC 6749](https://tools.ietf.org/html/rfc6749) for more details of OAuth 2
specification.
## Usage
#### Managing OAuth applications and tokens
The root of OAuth management endpoints is `/api/<version>/me/oauth/`, which gives a list of endpoint
roots for managing individual type of OAuth resources: OAuth application under
`/api/<version>/me/oauth/applications/` and OAuth token under `/api/<version>/me/oauth/tokens/`. The
reason for OAuth management endpoints to be under `/api/<version>/me/` is because, as an authentication
approach, OAuth resources will only take effect to the underlying user.
Each OAuth application belongs to a specific user, and is used to represent a specific API client
on the server side. For example, if AWX user Alice wants to make her `curl` command to talk to
AWX, the first thing is creating a new application and probably name it `Alice' curl client`.
Individual applications will be accessible via their primary keys:
`/api/<version>/me/oauth/applications/<primary key of an application>/`. Here is a typical application:
```
{
"id": 1,
"type": "application",
"url": "/api/v2/me/oauth/applications/1/",
"related": {
"user": "/api/v2/users/1/",
"tokens": "/api/v2/me/oauth/applications/1/tokens/",
"activity_stream": "/api/v2/me/oauth/applications/1/activity_stream/"
},
"summary_fields": {
"user": {
"id": 1,
"username": "admin",
"first_name": "",
"last_name": ""
},
"tokens": {
"count": 13,
"results": [
{
"token": "UdglJ1IkG3YrkzPWkEIwBqWP2xL8X7",
"id": 16
},
...
]
}
},
"created": "2017-12-07T16:08:21.341687Z",
"modified": "2017-12-07T16:08:21.342015Z",
"name": "admin's app",
"user": 1,
"client_id": "l7VbJdYxqKzoewQR7iZAYkiUI7AdqQhJuiAF4TqJ",
"client_secret": "gsplwGti48nJhs5dJ9IMJ0BqN3LvwvFPFgbrQzhXz4bT2oOJBmoCj2egpAUF6Ivme1LFLYAeLwYkmj8AVHEkpYfYxMvK6LTNJG8nO2AIGt7l6MCgj9oD5cgwLvsfGxl2",
"client_type": "confidential",
"redirect_uris": "",
"authorization_grant_type": "password",
"skip_authorization": false
}
```
In the above example, `user` is the underlying user this application associates to; `name` can be
used as a human-readable identifier of the application. The rest fields, like `client_id` and
`redirect_uris`, are mainly used for OAuth authorization, which will be covered later in 'Using
OAuth token system' section.
Fields `client_id` and `client_secret` are immutable identifiers of applications, and will be
generated during creation; Fields `user` and `authorization_grant_type`, on the other hand, are
*immutable on update*, meaning they are required fields on creation, but will become read-only after
that.
On RBAC side:
- system admins will be able to see and manipulate all applications in the system;
- Organization admins will be able to see and manipulate all applications belonging to Organization
members;
- Other normal users will only be able to see, update and delete their own applications, but
cannot create any new application.
Note a default new application will be created for each new user. So each new user is supposed to see
at least one application available to them.
Tokens, on the other hand, are resources used to actually authenticate incoming requests and mask the
permissions of underlying user. There are two ways of creating a token: POSTing to `/me/oauth/tokens/`
endpoint by providing `application` and `scope` fields to point to related application and specify
token scope; or POSTing to `/me/oauth/applications/<pk>/tokens/` by providing only `scope`, while
the parent application will be automatically linked.
Individual tokens will be accessible via their primary keys:
`/api/<version>/me/oauth/tokens/<primary key of a token>/`. Here is a typical token:
```
{
"id": 17,
"type": "access_token",
"url": "/api/v2/me/oauth/tokens/17/",
"related": {
"user": "/api/v2/users/1/",
"application": "/api/v2/me/oauth/applications/4/",
"activity_stream": "/api/v2/me/oauth/tokens/17/activity_stream/"
},
"summary_fields": {
"application": {
"id": 4,
"name": "admin's token",
"client_id": "D6SwhKbfp2LuUjkmiUpMMYFyNqhpv5PTVci7eXTT"
},
"user": {
"id": 1,
"username": "admin",
"first_name": "",
"last_name": ""
}
},
"created": "2017-12-12T16:48:10.489550Z",
"modified": "2017-12-12T16:48:10.522189Z",
"user": 1,
"token": "kqHqxfpHGRRBXLNCOXxT5Zt3tpJogn",
"refresh_token": "miZq3hqSugvYxhzdQYJIBDgIHxJPnT",
"application": 4,
"expires": "2017-12-13T02:48:10.488180Z",
"scope": "read"
}
```
For an OAuth token, the only fully mutable field is `scope`. `application` field is *immutable
on update*, and all other fields are totally immutable, and will be auto-populated during creation:
`user` field will be the `user` field of related application; `expires` will be generated according
to Tower configuration setting `OAUTH2_PROVIDER`; `token` and `refresh_token` will be auto-
generated to be non-crashing random strings.
On RBAC side:
- A user will be able to create a token if she is able to see the related application;
- System admin is able to see and manipulate every token in the system;
- Organization admins will be able to see and manipulate all tokens belonging to Organization
members;
- Other normal users will only be able to see and manipulate their own tokens.
#### Using OAuth token system
The most significant usage of OAuth is authenticating users. `token` field of a token is used
as part of HTTP authentication header, in the format `Authorization: Bearer <token field value>`.
Here is a `curl` command example:
```
curl -H "Authorization: Bearer kqHqxfpHGRRBXLNCOXxT5Zt3tpJogn" http://localhost:8013/api/v2/credentials/
```
According to OAuth 2 specification, users should be able to acquire, revoke and refresh an access
token. In AWX the equivalent, and the easiest, way of doing that is creating a token, deleting
a token, and deleting a token quickly followed by creating a new one.
On the other hand, the specification also provides standard ways of doing those. RFC 6749 elaborates
on those topics, but in summary, an OAuth token is officially acquired via authorization using
authorization information provided by applications (special application fields mentioned above).
There are dedicated endpoints for authorization and token acquire; The token acquire endpoint
is also responsible for token refresh; and token revoke is done by dedicated token revoke endpoint.
In AWX, our OAuth system is built on top of
[Django Oauth Toolkit](https://django-oauth-toolkit.readthedocs.io/en/latest/), which provides full
support on standard authorization, token revoke and refresh. AWX reuses them and puts related
endpoints under `/api/o/` endpoint. Detailed examples on some most typical usage of those endpoints
are available as description text of `/api/o/`.
#### Token scope mask over RBAC system
The scope of an OAuth token is a space-separated string composed of keywords like 'read' and 'write'.
These keywords are configurable and used to specify permission level of the authenticated API client.
For the initial OAuth implementation, we use the most simple scope configuration, where the only
valid scope keywords are 'read' and 'write'.
Read and write scopes provide a mask layer over the RBAC permission system of AWX. In specific, a
'write' scope gives the authenticated user full permissions the RBAC system provides, while 'read'
scope gives the authenticated user only read permissions the RBAC system provides.
For example, if a user has admin permission to a job template, she can both see and modify, launch
and delete the job template if authenticated via session or basic auth. On the other hand, if she
is authenticated using OAuth token, and the related token scope is 'read', she can only see but
not manipulate or launch the job template, despite she has admin role over it; if the token scope is
'write' or 'read write', she can take full advantage of the job template as its admin.
## Acceptance Criteria
* All CRUD operations for OAuth applications and tokens should function as described.
* RBAC rules applied to OAuth applications and tokens should behave as described.
* A default application should be auto-created for each new user.
* Incoming requests using unexpired OAuth token correctly in authentication header should be able
to successfully authenticate themselves.
* Token scope mask over RBAC should work as described.
* Tower configuration setting `OAUTH2_PROVIDER` should be configurable and function as described.
* `/api/o/` endpoint should work as expected. In specific, all examples given in the description
help text should be working (user following the steps should get expected result).

83
docs/auth/session.md Normal file
View File

@ -0,0 +1,83 @@
## Introduction
Before Tower 3.3, auth token is used as the main authentication method. Starting from Tower 3.3,
session-based authentication will take the place as the main authentication, while auth token
will be replaced by OAuth tokens also introduced in 3.3.
Session authentication is a safer way of utilizing HTTP(S) cookies:
Theoretically, user can provide authentication information, like username and password, as part of the
`Cookie` header, but this method is vulnerable to cookie hijacks, where crackers can see and steal user
information from cookie payload.
Session authentication, on the other hand, sets a single `sessionid` cookie, called 'session'. Session
is *a random string which will be mapped to user authentication informations by server*. Crackers who
hijacks cookie will only get session itself, which does not imply any critical user info, valid only for
a limited time, and can be revoked at any time.
## Usage
In session authentication, user log in using endpoint `/api/login/`. GET to `/api/login/` displays the
log in page of API browser:
![Example session log in page](../img/auth_session_1.png?raw=true)
User should enter correct username and password before clicking on 'LOG IN' button, which fires a POST
to `/api/login/` to actually log the user in. The return code of a successful login is 302, meaning upon
successful login, the browser will be redirected, the redirected destination is determined by `next` form
item described below.
It should be noted that POST body of `/api/login/` is *not* in JSON, but HTTP form format. 4 items should
be provided in the form:
* `username`: The username of the user trying to log in.
* `password`: The password of the user trying to log in.
* `next`: The path of the redirect destination, in API browser `"/api/"` is used.
* `csrfmiddlewaretoken`: The CSRF token, usually populated by using Django template `{% csrf_token %}`.
Session is provided as a return `Set-Cookie` header. Here is a typical one:
```
Set-Cookie: sessionid=lwan8l5ynhrqvps280rg5upp7n3yp6ds; expires=Tue, 21-Nov-2017 16:33:13 GMT; httponly; Max-Age=1209600; Path=/
```
Any client should follow the standard rules of [cookie protocol](https://tools.ietf.org/html/rfc6265) to
parse that header to obtain information about the session, such as session cookie name (`sessionid`),
session cookie value, expiration date, duration, etc.
The duration of the cookie is configurable by Tower Configuration setting `SESSION_COOKIE_AGE` under
category `authentication`. It is an integer denoting the number of seconds the session cookie should
live.
After a valid session is acquired, a client should provide session as a cookie for subsequent requests
in order to be authenticated. like
```
Cookie: sessionid=lwan8l5ynhrqvps280rg5upp7n3yp6ds; ...
```
User should use `/api/logout/` endpoint to log out. In API browser, a logged in user can do that by
simply clicking logout button on the nav bar. Under the hood the click issues a GET to '/api/logout/',
Upon success, server will invalidate current session and the response header will indicate client
to delete the session cookie. User should no longer try using this invalid session.
The duration of a session is constant. However, user can extend the expiration date of a valid session
by performing session acquire with the session provided.
A Tower configuration setting, `SESSIONS_PER_USER` under category `authentication`, is used to set the
maximum number of valid sessions a user can have at the same time. For example, if `SESSIONS_PER_USER`
is set to 3, while the same user is logged in via 5 different places, and thus have 5 valid sessions
available at the same time, the earliest 2 (5 - 3) sessions created will be invalidated. Tower will try
broadcasting, via websocket, to all available clients. The websocket message body will contain a list of
invalidated sessions. If a client finds its session in that list, it should try logging out.
Unlike tokens, sessions are meant to be short-lived and UI-only, therefore whenever a user's password
is updated, all sessions she owned will be invalidated and deleted.
## Acceptance Criteria
* User should be able to log in via `/api/login/` endpoint by correctly providing all necessary fields.
* Logged in users should be able to authenticate themselves by providing correct session auth info.
* Logged in users should be able to log out via `/api/logout/`.
* The duration of a session cookie should be configurable by `SESSION_COOKIE_AGE`.
* The maximum number of concurrent login for one user should be configurable by `SESSIONS_PER_USER`,
and over-limit user sessions should be warned by websocket.
* When a user's password is changed, all her sessions should be invalidated and deleted.
* User should not be able to authenticate either HTTPS(S) request or websocket connect using invalid
sessions.
* No existing behavior, like job run, inventory update or callback receiver, should be affected
by session auth.

BIN
docs/img/auth_session_1.png Normal file

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