diff --git a/awx/api/authentication.py b/awx/api/authentication.py index 2520b235be..51e1b34873 100644 --- a/awx/api/authentication.py +++ b/awx/api/authentication.py @@ -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 '' + 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 diff --git a/awx/api/conf.py b/awx/api/conf.py index 6bbfee1d3d..3b89ecd115 100644 --- a/awx/api/conf.py +++ b/awx/api/conf.py @@ -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', +) diff --git a/awx/api/fields.py b/awx/api/fields.py index dd811d81a6..6a1ddb6018 100644 --- a/awx/api/fields.py +++ b/awx/api/fields.py @@ -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 diff --git a/awx/api/generics.py b/awx/api/generics.py index 41724c9440..6afd82683e 100644 --- a/awx/api/generics.py +++ b/awx/api/generics.py @@ -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 diff --git a/awx/api/permissions.py b/awx/api/permissions.py index 3cf18fc55b..1146e012f1 100644 --- a/awx/api/permissions.py +++ b/awx/api/permissions.py @@ -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 diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 0eadc1cb8a..2c4742fc11 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -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})) diff --git a/awx/api/templates/api/api_o_auth_authorization_root_view.md b/awx/api/templates/api/api_o_auth_authorization_root_view.md new file mode 100644 index 0000000000..268a4e4d01 --- /dev/null +++ b/awx/api/templates/api/api_o_auth_authorization_root_view.md @@ -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=&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 +`:`, 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 `:`, 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": [] +} +``` diff --git a/awx/api/urls/oauth.py b/awx/api/urls/oauth.py new file mode 100644 index 0000000000..542c06cd36 --- /dev/null +++ b/awx/api/urls/oauth.py @@ -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'] diff --git a/awx/api/urls/urls.py b/awx/api/urls/urls.py index 387857fd1f..ad879d52aa 100644 --- a/awx/api/urls/urls.py +++ b/awx/api/urls/urls.py @@ -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[0-9]+)/credentials/$', JobCredentialsList.as_view(), name='job_credentials_list'), url(r'^job_templates/(?P[0-9]+)/extra_credentials/$', JobTemplateExtraCredentialsList.as_view(), name='job_template_extra_credentials_list'), url(r'^job_templates/(?P[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(v2))/', include(v2_urls)), url(r'^(?P(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 diff --git a/awx/api/urls/user_oauth.py b/awx/api/urls/user_oauth.py new file mode 100644 index 0000000000..afaea7531c --- /dev/null +++ b/awx/api/urls/user_oauth.py @@ -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[0-9]+)/$', + UserMeOauthApplicationDetail.as_view(), + name='user_me_oauth_application_detail' + ), + url( + r'^applications/(?P[0-9]+)/tokens/$', + UserMeOauthApplicationTokenList.as_view(), + name='user_me_oauth_application_token_list' + ), + url( + r'^applications/(?P[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[0-9]+)/$', + UserMeOauthTokenDetail.as_view(), + name='user_me_oauth_token_detail' + ), + url( + r'^tokens/(?P[0-9]+)/activity_stream/$', + UserMeOauthTokenActivityStreamList.as_view(), + name='user_me_oauth_token_activity_stream_list' + ), +] + +__all__ = ['urls'] diff --git a/awx/api/views.py b/awx/api/views.py index c22cfd9154..1146092209 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -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 diff --git a/awx/conf/apps.py b/awx/conf/apps.py index a70d21326c..06c2facb7a 100644 --- a/awx/conf/apps.py +++ b/awx/conf/apps.py @@ -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) diff --git a/awx/main/access.py b/awx/main/access.py index 485af5a7ef..bdac1fe0d8 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -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: diff --git a/awx/main/consumers.py b/awx/main/consumers.py index 527081f912..bc79a5c000 100644 --- a/awx/main/consumers.py +++ b/awx/main/consumers.py @@ -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) diff --git a/awx/main/migrations/0018_v330_create_user_session_membership.py b/awx/main/migrations/0018_v330_create_user_session_membership.py new file mode 100644 index 0000000000..400ba81e00 --- /dev/null +++ b/awx/main/migrations/0018_v330_create_user_session_membership.py @@ -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)), + ], + ), + ] diff --git a/awx/main/migrations/0019_v330_add_oauth_activity_stream_registrar.py b/awx/main/migrations/0019_v330_add_oauth_activity_stream_registrar.py new file mode 100644 index 0000000000..796719baf8 --- /dev/null +++ b/awx/main/migrations/0019_v330_add_oauth_activity_stream_registrar.py @@ -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), + ), + ] diff --git a/awx/main/models/__init__.py b/awx/main/models/__init__.py index e4fae0e60e..76779700c6 100644 --- a/awx/main/models/__init__.py +++ b/awx/main/models/__init__.py @@ -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')) diff --git a/awx/main/models/activity_stream.py b/awx/main/models/activity_stream.py index 94df2f985c..1a2f89d1db 100644 --- a/awx/main/models/activity_stream.py +++ b/awx/main/models/activity_stream.py @@ -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) diff --git a/awx/main/models/organization.py b/awx/main/models/organization.py index 377a7619bb..8ec785a0c8 100644 --- a/awx/main/models/organization.py +++ b/awx/main/models/organization.py @@ -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): diff --git a/awx/main/signals.py b/awx/main/signals.py index 05a1d5114b..2e5db71e95 100644 --- a/awx/main/signals.py +++ b/awx/main/signals.py @@ -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' + ) diff --git a/awx/main/tasks.py b/awx/main/tasks.py index 917eecc9b9..92d2b61f9b 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -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) diff --git a/awx/main/tests/functional/api/test_oauth.py b/awx/main/tests/functional/api/test_oauth.py new file mode 100644 index 0000000000..50ddbc552c --- /dev/null +++ b/awx/main/tests/functional/api/test_oauth.py @@ -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 diff --git a/awx/main/tests/functional/api/test_user.py b/awx/main/tests/functional/api/test_user.py index 80fe4783b2..fd04727041 100644 --- a/awx/main/tests/functional/api/test_user.py +++ b/awx/main/tests/functional/api/test_user.py @@ -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 diff --git a/awx/main/tests/functional/conftest.py b/awx/main/tests/functional/conftest.py index b799763094..5a7e9c358d 100644 --- a/awx/main/tests/functional/conftest.py +++ b/awx/main/tests/functional/conftest.py @@ -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' + ) diff --git a/awx/main/tests/functional/test_auth_token_limit.py b/awx/main/tests/functional/test_auth_token_limit.py deleted file mode 100644 index bbe30320c4..0000000000 --- a/awx/main/tests/functional/test_auth_token_limit.py +++ /dev/null @@ -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 diff --git a/awx/main/tests/functional/test_rbac_oauth.py b/awx/main/tests/functional/test_rbac_oauth.py new file mode 100644 index 0000000000..87af0c5d84 --- /dev/null +++ b/awx/main/tests/functional/test_rbac_oauth.py @@ -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 + ) diff --git a/awx/main/tests/functional/test_session.py b/awx/main/tests/functional/test_session.py new file mode 100644 index 0000000000..d17562882b --- /dev/null +++ b/awx/main/tests/functional/test_session.py @@ -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() diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index a0618c238d..f648279a0d 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -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 diff --git a/awx/sso/middleware.py b/awx/sso/middleware.py index 7b414b9715..1944ff4d0f 100644 --- a/awx/sso/middleware.py +++ b/awx/sso/middleware.py @@ -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) diff --git a/awx/sso/views.py b/awx/sso/views.py index bce30302c9..570dcd36fc 100644 --- a/awx/sso/views.py +++ b/awx/sso/views.py @@ -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) diff --git a/awx/templates/rest_framework/api.html b/awx/templates/rest_framework/api.html index 0bcc14eaf7..618d980398 100644 --- a/awx/templates/rest_framework/api.html +++ b/awx/templates/rest_framework/api.html @@ -33,8 +33,11 @@