From 269daaf2a11199a4ab777fb37a52fe77982c9193 Mon Sep 17 00:00:00 2001 From: Tessa Walsh Date: Wed, 27 May 2020 17:54:10 -0400 Subject: [PATCH] Add CAS authentication to Storage Service This commits adds support for authentication via Central Authentication Service (CAS) to the Storage Service. It includes configuration options for auto-setting email addresses for users based on the rule USERNAME@DOMAIN as well as for setting user.is_superuser based on the presence or absence of configurable expected values in user attributes returned by a CAS server during p3/serviceValidate. Because the CAS middleware bypasses the Archivematica login screen and thus prevents other single sign-on methods from being utilized, an ImproperlyConfigured exception will be raised when attempting to start Archivematica with CAS enabled in addition to Shibboleth or LDAP. --- install/README.md | 51 +++- requirements/base.in | 4 +- requirements/base.txt | 16 +- requirements/local.txt | 16 +- requirements/production.txt | 16 +- requirements/test.txt | 22 +- storage_service/common/backends.py | 16 ++ .../storage_service/settings/base.py | 57 ++++ storage_service/storage_service/signals.py | 78 ++++++ .../storage_service/tests/test_cas.py | 244 ++++++++++++++++++ storage_service/storage_service/urls.py | 22 +- 11 files changed, 500 insertions(+), 42 deletions(-) create mode 100644 storage_service/common/backends.py create mode 100644 storage_service/storage_service/signals.py create mode 100644 storage_service/storage_service/tests/test_cas.py diff --git a/install/README.md b/install/README.md index 0be29c7ad..25a8bdbd9 100644 --- a/install/README.md +++ b/install/README.md @@ -9,6 +9,7 @@ - [Application-specific environment variables](#application-specific-environment-variables) - [Gunicorn-specific environment variables](#gunicorn-specific-environment-variables) - [LDAP-specific environment variables](#ldap-specific-environment-variables) + - [CAS-specific environment variables](#cas-specific-environment-variables) - [Logging configuration](#logging-configuration) ## Introduction @@ -74,6 +75,11 @@ of these settings or provide values to mandatory fields. - **Type:** `boolean` - **Default:** `false` +- **`SS_CAS_AUTHENTICATION`**: + - **Description:** enables the CAS (Central Authentication Service) authentication system. + - **Type:** `boolean` + - **Default:** `false` + - **`SS_BAG_VALIDATION_NO_PROCESSES`**: - **Description:** number of concurrent processes used by BagIt. If Gunicorn is being used to serve the Storage Service and its worker class is set to `gevent`, then BagIt validation must use 1 process. Otherwise, calls to `validate` will hang because of the incompatibility between gevent and multiprocessing (BagIt) concurrency strategies. See [#708](https://github.com/artefactual/archivematica/issues/708). - **Type:** `int` @@ -213,8 +219,7 @@ This is the current list of strings supported: ### LDAP-specific environment variables - These variables specify the behaviour of LDAP authentication. If `SS_LDAP_AUTHENTICATION` is false, - none of the other ones are used. +These variables specify the behaviour of LDAP authentication. If `SS_LDAP_AUTHENTICATION` is false, none of the other ones are used. - **`SS_LDAP_AUTHENTICATION`**: - **Description:** Enables user authentication via LDAP. @@ -343,10 +348,48 @@ This is the current list of strings supported: - **Type:** `string` - **Default:** `` +### CAS-specific environment variables + +These variables specify the behaviour of CAS authentication. If `SS_CAS_AUTHENTICATION` is false, none of the other ones are used. + +- **`AUTH_CAS_SERVER_URL`**: + - **Description:** Address of the CAS server to authenticate against. Defaults to CAS demo server. + - **Type:** `string` + - **Default:** `https://django-cas-ng-demo-server.herokuapp.com/cas/` + +- **`AUTH_CAS_PROTOCOL_VERSION`**: + - **Description:** Version of CAS protocol to use. Allowed values are "1", "2", "3", or "CAS_2_SAML_1_0". + - **Type:** `string` + - **Default:** `3` + +- **`AUTH_CAS_CHECK_ADMIN_ATTRIBUTES`**: + - **Description:** Determines if we check user attributes returned by CAS server to determine if user is an administrator. + - **Type:** `boolean` + - **Default:** `false` + +- **`AUTH_CAS_ADMIN_ATTRIBUTE`**: + - **Description:** Name of attribute to check for administrator status, if `CAS_CHECK_ADMIN_ATTRIBUTES` is True. + - **Type:** `string` + - **Default:** `None` + +- **`AUTH_CAS_ADMIN_ATTRIBUTE_VALUE`**: + - **Description:** Value in `CAS_ADMIN_ATTRIBUTE` that indicates user is an administrator, if `CAS_CHECK_ADMIN_ATTRIBUTES` is True. + - **Type:** `string` + - **Default:** `None` + +- **`AUTH_CAS_AUTOCONFIGURE_EMAIL`**: + - **Description:** Determines if we auto-configure an email address for new users by following the rule username@domain. + - **Type:** `boolean` + - **Default:** `false` + +- **`AUTH_CAS_EMAIL_DOMAIN`**: + - **Description:** Domain to use for auto-configured email addresses, if `AUTH_CAS_AUTOCONFIGURE_EMAIL` is True. + - **Type:** `string` + - **Default:** `None` + ### AWS-specific environment variables - These variables can be set to allow AWS authentication for S3 storage spaces as an alternative - to providing these details via the user interface. See [AWS CLI Environment Variables](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-envvars.html) for details. +These variables can be set to allow AWS authentication for S3 storage spaces as an alternative to providing these details via the user interface. See [AWS CLI Environment Variables](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-envvars.html) for details. - **`AWS_ACCESS_KEY_ID`**: - **Description:** Access key for AWS authentication diff --git a/requirements/base.in b/requirements/base.in index e70ba9332..a86708f76 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -1,5 +1,4 @@ # Base requirements - for all installations -# updated May 9, 2017 for 0.11.0 release bagit==1.7.0 boto3==1.9.174 botocore==1.12.253 @@ -40,3 +39,6 @@ git+https://github.com/seatme/django-longer-username.git@seatme#egg=longeruserna # LDAP support python-ldap==3.2.0 django-auth-ldap==1.3.0 + +# CAS authentication +django-cas-ng==3.6.0 diff --git a/requirements/base.txt b/requirements/base.txt index 7cb230c03..e23ca8b73 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -11,19 +11,20 @@ boto3==1.9.174 # via -r base.in botocore==1.12.253 # via -r base.in, boto3, s3transfer brotli==0.5.2 # via -r base.in certifi==2020.6.20 # via requests -cffi==1.14.0 # via cryptography +cffi==1.14.1 # via cryptography chardet==3.0.4 # via requests configparser==4.0.2 # via importlib-metadata contextlib2==0.6.0.post1 # via importlib-metadata, importlib-resources, zipp -cryptography==2.9.2 # via pyopenssl +cryptography==3.0 # via pyopenssl debtcollector==1.22.0 # via oslo.config, oslo.utils, python-keystoneclient defusedxml==0.5.0 # via -r base.in django-auth-ldap==1.3.0 # via -r base.in +django-cas-ng==3.6.0 # via -r base.in django-extensions==1.7.9 # via -r base.in django-prometheus==1.0.15 # via -r base.in git+https://github.com/Brown-University-Library/django-shibboleth-remoteuser.git@67d270c65c201606fb86d548493d4b3fd8cc7a76#egg=django-shibboleth-remoteuser # via -r base.in django-tastypie==0.14.3 # via -r base.in -django==1.11.29 # via -r base.in, django-auth-ldap, jsonfield +django==1.11.29 # via -r base.in, django-auth-ldap, django-cas-ng, jsonfield docutils==0.15.2 # via botocore enum34==1.1.10 # via cryptography, oslo.config funcsigs==1.0.2 # via debtcollector, oslo.utils @@ -43,7 +44,7 @@ jsonfield==2.0.1 # via -r base.in keystoneauth1==4.0.1 # via python-keystoneclient logutils==0.3.4.1 # via -r base.in git+https://github.com/seatme/django-longer-username.git@seatme#egg=longerusername # via -r base.in -lxml==3.7.3 # via -r base.in, metsrw +lxml==3.7.3 # via -r base.in, metsrw, python-cas metsrw==0.3.15 # via -r base.in monotonic==1.5 # via oslo.utils msgpack==1.0.0 # via oslo.serialization @@ -66,6 +67,7 @@ pyasn1==0.4.8 # via pyasn1-modules, python-ldap pycparser==2.20 # via cffi pyopenssl==19.1.0 # via ndg-httpsclient pyparsing==2.4.7 # via oslo.utils +python-cas==1.5.0 # via django-cas-ng python-dateutil==2.8.1 # via botocore, django-tastypie python-gnupg==0.4.0 # via -r base.in python-keystoneclient==3.10.0 # via -r base.in @@ -75,15 +77,15 @@ python-swiftclient==3.3.0 # via -r base.in pytz==2020.1 # via babel, django, oslo.serialization, oslo.utils pyyaml==5.3.1 # via oslo.config, oslo.serialization requests-oauthlib==1.2.0 # via -r base.in -requests==2.21.0 # via -r base.in, agentarchives, keystoneauth1, oslo.config, python-keystoneclient, python-swiftclient, requests-oauthlib +requests==2.21.0 # via -r base.in, agentarchives, keystoneauth1, oslo.config, python-cas, python-keystoneclient, python-swiftclient, requests-oauthlib rfc3986==1.4.0 # via oslo.config s3transfer==0.2.1 # via boto3 scandir==1.10.0 # via -r base.in, pathlib2 singledispatch==3.4.0.3 # via importlib-resources -six==1.15.0 # via cryptography, debtcollector, django-extensions, keystoneauth1, metsrw, oslo.config, oslo.i18n, oslo.serialization, oslo.utils, pathlib2, pyopenssl, python-dateutil, python-keystoneclient, python-swiftclient, singledispatch, stevedore +six==1.15.0 # via cryptography, debtcollector, django-extensions, keystoneauth1, metsrw, oslo.config, oslo.i18n, oslo.serialization, oslo.utils, pathlib2, pyopenssl, python-cas, python-dateutil, python-keystoneclient, python-swiftclient, singledispatch, stevedore stevedore==1.32.0 # via keystoneauth1, oslo.config, python-keystoneclient sword2==0.2.1 # via -r base.in -typing==3.7.4.2 # via importlib-resources +typing==3.7.4.3 # via importlib-resources urllib3==1.24.3 # via botocore, requests whitenoise==3.3.0 # via -r base.in wrapt==1.12.1 # via debtcollector, positional diff --git a/requirements/local.txt b/requirements/local.txt index ece36597d..9182be7de 100644 --- a/requirements/local.txt +++ b/requirements/local.txt @@ -11,21 +11,22 @@ boto3==1.9.174 # via -r base.txt botocore==1.12.253 # via -r base.txt, boto3, s3transfer brotli==0.5.2 # via -r base.txt certifi==2020.6.20 # via -r base.txt, requests -cffi==1.14.0 # via -r base.txt, cryptography +cffi==1.14.1 # via -r base.txt, cryptography chardet==3.0.4 # via -r base.txt, requests click==7.1.2 # via pip-tools configparser==4.0.2 # via -r base.txt, importlib-metadata contextlib2==0.6.0.post1 # via -r base.txt, importlib-metadata, importlib-resources, zipp -cryptography==2.9.2 # via -r base.txt, pyopenssl +cryptography==3.0 # via -r base.txt, pyopenssl debtcollector==1.22.0 # via -r base.txt, oslo.config, oslo.utils, python-keystoneclient defusedxml==0.5.0 # via -r base.txt dj-database-url==0.4.2 # via -r local.in django-auth-ldap==1.3.0 # via -r base.txt +django-cas-ng==3.6.0 # via -r base.txt django-extensions==1.7.9 # via -r base.txt django-prometheus==1.0.15 # via -r base.txt git+https://github.com/Brown-University-Library/django-shibboleth-remoteuser.git@67d270c65c201606fb86d548493d4b3fd8cc7a76#egg=django-shibboleth-remoteuser # via -r base.txt django-tastypie==0.14.3 # via -r base.txt -django==1.11.29 # via -r base.txt, django-auth-ldap, jsonfield +django==1.11.29 # via -r base.txt, django-auth-ldap, django-cas-ng, jsonfield docutils==0.15.2 # via -r base.txt, botocore, sphinx enum34==1.1.10 # via -r base.txt, cryptography, oslo.config funcsigs==1.0.2 # via -r base.txt, debtcollector, oslo.utils @@ -47,7 +48,7 @@ jsonfield==2.0.1 # via -r base.txt keystoneauth1==4.0.1 # via -r base.txt, python-keystoneclient logutils==0.3.4.1 # via -r base.txt git+https://github.com/seatme/django-longer-username.git@seatme#egg=longerusername # via -r base.txt -lxml==3.7.3 # via -r base.txt, metsrw +lxml==3.7.3 # via -r base.txt, metsrw, python-cas markupsafe==1.1.1 # via jinja2 metsrw==0.3.15 # via -r base.txt monotonic==1.5 # via -r base.txt, oslo.utils @@ -74,6 +75,7 @@ pycparser==2.20 # via -r base.txt, cffi pygments==2.5.2 # via sphinx pyopenssl==19.1.0 # via -r base.txt, ndg-httpsclient pyparsing==2.4.7 # via -r base.txt, oslo.utils +python-cas==1.5.0 # via -r base.txt, django-cas-ng python-dateutil==2.8.1 # via -r base.txt, botocore, django-tastypie python-gnupg==0.4.0 # via -r base.txt python-keystoneclient==3.10.0 # via -r base.txt @@ -83,17 +85,17 @@ python-swiftclient==3.3.0 # via -r base.txt pytz==2020.1 # via -r base.txt, babel, django, oslo.serialization, oslo.utils pyyaml==5.3.1 # via -r base.txt, oslo.config, oslo.serialization requests-oauthlib==1.2.0 # via -r base.txt -requests==2.21.0 # via -r base.txt, agentarchives, keystoneauth1, oslo.config, python-keystoneclient, python-swiftclient, requests-oauthlib +requests==2.21.0 # via -r base.txt, agentarchives, keystoneauth1, oslo.config, python-cas, python-keystoneclient, python-swiftclient, requests-oauthlib rfc3986==1.4.0 # via -r base.txt, oslo.config s3transfer==0.2.1 # via -r base.txt, boto3 scandir==1.10.0 # via -r base.txt, pathlib2 singledispatch==3.4.0.3 # via -r base.txt, importlib-resources -six==1.15.0 # via -r base.txt, cryptography, debtcollector, django-extensions, keystoneauth1, metsrw, oslo.config, oslo.i18n, oslo.serialization, oslo.utils, pathlib2, pip-tools, pyopenssl, python-dateutil, python-keystoneclient, python-swiftclient, singledispatch, stevedore, transifex-client +six==1.15.0 # via -r base.txt, cryptography, debtcollector, django-extensions, keystoneauth1, metsrw, oslo.config, oslo.i18n, oslo.serialization, oslo.utils, pathlib2, pip-tools, pyopenssl, python-cas, python-dateutil, python-keystoneclient, python-swiftclient, singledispatch, stevedore, transifex-client sphinx==1.2b1 # via -r local.in stevedore==1.32.0 # via -r base.txt, keystoneauth1, oslo.config, python-keystoneclient sword2==0.2.1 # via -r base.txt transifex-client==0.12.2 # via -r local.in -typing==3.7.4.2 # via -r base.txt, importlib-resources +typing==3.7.4.3 # via -r base.txt, importlib-resources urllib3==1.24.3 # via -r base.txt, botocore, requests, transifex-client whitenoise==3.3.0 # via -r base.txt wrapt==1.12.1 # via -r base.txt, debtcollector, positional diff --git a/requirements/production.txt b/requirements/production.txt index eace4f70b..052df26d7 100644 --- a/requirements/production.txt +++ b/requirements/production.txt @@ -11,20 +11,21 @@ boto3==1.9.174 # via -r base.txt botocore==1.12.253 # via -r base.txt, boto3, s3transfer brotli==0.5.2 # via -r base.txt certifi==2020.6.20 # via -r base.txt, requests -cffi==1.14.0 # via -r base.txt, cryptography +cffi==1.14.1 # via -r base.txt, cryptography chardet==3.0.4 # via -r base.txt, requests configparser==4.0.2 # via -r base.txt, importlib-metadata contextlib2==0.6.0.post1 # via -r base.txt, importlib-metadata, importlib-resources, zipp -cryptography==2.9.2 # via -r base.txt, pyopenssl +cryptography==3.0 # via -r base.txt, pyopenssl debtcollector==1.22.0 # via -r base.txt, oslo.config, oslo.utils, python-keystoneclient defusedxml==0.5.0 # via -r base.txt dj-database-url==0.4.2 # via -r production.in django-auth-ldap==1.3.0 # via -r base.txt +django-cas-ng==3.6.0 # via -r base.txt django-extensions==1.7.9 # via -r base.txt django-prometheus==1.0.15 # via -r base.txt git+https://github.com/Brown-University-Library/django-shibboleth-remoteuser.git@67d270c65c201606fb86d548493d4b3fd8cc7a76#egg=django-shibboleth-remoteuser # via -r base.txt django-tastypie==0.14.3 # via -r base.txt -django==1.11.29 # via -r base.txt, django-auth-ldap, jsonfield +django==1.11.29 # via -r base.txt, django-auth-ldap, django-cas-ng, jsonfield docutils==0.15.2 # via -r base.txt, botocore enum34==1.1.10 # via -r base.txt, cryptography, oslo.config funcsigs==1.0.2 # via -r base.txt, debtcollector, oslo.utils @@ -44,7 +45,7 @@ jsonfield==2.0.1 # via -r base.txt keystoneauth1==4.0.1 # via -r base.txt, python-keystoneclient logutils==0.3.4.1 # via -r base.txt git+https://github.com/seatme/django-longer-username.git@seatme#egg=longerusername # via -r base.txt -lxml==3.7.3 # via -r base.txt, metsrw +lxml==3.7.3 # via -r base.txt, metsrw, python-cas metsrw==0.3.15 # via -r base.txt monotonic==1.5 # via -r base.txt, oslo.utils msgpack==1.0.0 # via -r base.txt, oslo.serialization @@ -68,6 +69,7 @@ pyasn1==0.4.8 # via -r base.txt, pyasn1-modules, python-ldap pycparser==2.20 # via -r base.txt, cffi pyopenssl==19.1.0 # via -r base.txt, ndg-httpsclient pyparsing==2.4.7 # via -r base.txt, oslo.utils +python-cas==1.5.0 # via -r base.txt, django-cas-ng python-dateutil==2.8.1 # via -r base.txt, botocore, django-tastypie python-gnupg==0.4.0 # via -r base.txt python-keystoneclient==3.10.0 # via -r base.txt @@ -77,15 +79,15 @@ python-swiftclient==3.3.0 # via -r base.txt pytz==2020.1 # via -r base.txt, babel, django, oslo.serialization, oslo.utils pyyaml==5.3.1 # via -r base.txt, oslo.config, oslo.serialization requests-oauthlib==1.2.0 # via -r base.txt -requests==2.21.0 # via -r base.txt, agentarchives, keystoneauth1, oslo.config, python-keystoneclient, python-swiftclient, requests-oauthlib +requests==2.21.0 # via -r base.txt, agentarchives, keystoneauth1, oslo.config, python-cas, python-keystoneclient, python-swiftclient, requests-oauthlib rfc3986==1.4.0 # via -r base.txt, oslo.config s3transfer==0.2.1 # via -r base.txt, boto3 scandir==1.10.0 # via -r base.txt, pathlib2 singledispatch==3.4.0.3 # via -r base.txt, importlib-resources -six==1.15.0 # via -r base.txt, cryptography, debtcollector, django-extensions, keystoneauth1, metsrw, oslo.config, oslo.i18n, oslo.serialization, oslo.utils, pathlib2, pyopenssl, python-dateutil, python-keystoneclient, python-swiftclient, singledispatch, stevedore +six==1.15.0 # via -r base.txt, cryptography, debtcollector, django-extensions, keystoneauth1, metsrw, oslo.config, oslo.i18n, oslo.serialization, oslo.utils, pathlib2, pyopenssl, python-cas, python-dateutil, python-keystoneclient, python-swiftclient, singledispatch, stevedore stevedore==1.32.0 # via -r base.txt, keystoneauth1, oslo.config, python-keystoneclient sword2==0.2.1 # via -r base.txt -typing==3.7.4.2 # via -r base.txt, importlib-resources +typing==3.7.4.3 # via -r base.txt, importlib-resources urllib3==1.24.3 # via -r base.txt, botocore, requests whitenoise==3.3.0 # via -r base.txt wrapt==1.12.1 # via -r base.txt, debtcollector, positional diff --git a/requirements/test.txt b/requirements/test.txt index 19f9a3833..51c0ee225 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -22,25 +22,26 @@ boto==2.49.0 # via moto botocore==1.12.253 # via -r base.txt, aws-xray-sdk, boto3, moto, s3transfer brotli==0.5.2 # via -r base.txt certifi==2020.6.20 # via -r base.txt, requests -cffi==1.14.0 # via -r base.txt, cryptography -cfn-lint==0.33.2 # via moto +cffi==1.14.1 # via -r base.txt, cryptography +cfn-lint==0.34.0 # via moto chardet==3.0.4 # via -r base.txt, requests click==7.1.2 # via pip-tools configparser==4.0.2 # via -r base.txt, importlib-metadata contextlib2==0.6.0.post1 # via -r base.txt, importlib-metadata, importlib-resources, vcrpy, zipp cookies==2.2.1 # via responses coverage==4.2 # via -r test.in, pytest-cov -cryptography==2.9.2 # via -r base.txt, moto, pyopenssl +cryptography==3.0 # via -r base.txt, moto, pyopenssl debtcollector==1.22.0 # via -r base.txt, oslo.config, oslo.utils, python-keystoneclient decorator==4.4.2 # via ipython, networkx, traitlets defusedxml==0.5.0 # via -r base.txt distlib==0.3.1 # via virtualenv django-auth-ldap==1.3.0 # via -r base.txt +django-cas-ng==3.6.0 # via -r base.txt django-extensions==1.7.9 # via -r base.txt django-prometheus==1.0.15 # via -r base.txt git+https://github.com/Brown-University-Library/django-shibboleth-remoteuser.git@67d270c65c201606fb86d548493d4b3fd8cc7a76#egg=django-shibboleth-remoteuser # via -r base.txt django-tastypie==0.14.3 # via -r base.txt -django==1.11.29 # via -r base.txt, django-auth-ldap, jsonfield +django==1.11.29 # via -r base.txt, django-auth-ldap, django-cas-ng, jsonfield docker==4.2.2 # via moto docutils==0.15.2 # via -r base.txt, botocore ecdsa==0.15 # via python-jose @@ -74,7 +75,7 @@ junit-xml==1.9 # via cfn-lint keystoneauth1==4.0.1 # via -r base.txt, python-keystoneclient logutils==0.3.4.1 # via -r base.txt git+https://github.com/seatme/django-longer-username.git@seatme#egg=longerusername # via -r base.txt -lxml==3.7.3 # via -r base.txt, metsrw +lxml==3.7.3 # via -r base.txt, metsrw, python-cas markupsafe==1.1.1 # via jinja2 metsrw==0.3.15 # via -r base.txt mock==3.0.5 # via moto, pytest-mock, responses, vcrpy @@ -116,6 +117,7 @@ pytest-cov==2.4.0 # via -r test.in pytest-django==3.9.0 # via -r test.in pytest-mock==1.13.0 # via -r test.in pytest==3.8.0 # via -r test.in, pytest-cov, pytest-django, pytest-mock +python-cas==1.5.0 # via -r base.txt, django-cas-ng python-dateutil==2.8.1 # via -r base.txt, botocore, django-tastypie, moto python-gnupg==0.4.0 # via -r base.txt python-jose==3.1.0 # via moto @@ -126,7 +128,7 @@ python-swiftclient==3.3.0 # via -r base.txt pytz==2020.1 # via -r base.txt, babel, django, moto, oslo.serialization, oslo.utils pyyaml==5.3.1 # via -r base.txt, cfn-lint, moto, oslo.config, oslo.serialization, vcrpy requests-oauthlib==1.2.0 # via -r base.txt -requests==2.21.0 # via -r base.txt, agentarchives, docker, keystoneauth1, moto, oslo.config, python-keystoneclient, python-swiftclient, requests-oauthlib, responses +requests==2.21.0 # via -r base.txt, agentarchives, docker, keystoneauth1, moto, oslo.config, python-cas, python-keystoneclient, python-swiftclient, requests-oauthlib, responses responses==0.10.15 # via moto rfc3986==1.4.0 # via -r base.txt, oslo.config rsa==4.5 # via python-jose @@ -134,16 +136,16 @@ s3transfer==0.2.1 # via -r base.txt, boto3 scandir==1.10.0 # via -r base.txt, pathlib2 simplegeneric==0.8.1 # via ipython singledispatch==3.4.0.3 # via -r base.txt, importlib-resources -six==1.15.0 # via -r base.txt, aws-sam-translator, cfn-lint, cryptography, debtcollector, django-extensions, docker, ecdsa, jsonschema, junit-xml, keystoneauth1, metsrw, mock, more-itertools, moto, oslo.config, oslo.i18n, oslo.serialization, oslo.utils, packaging, pathlib2, pip-tools, prompt-toolkit, pyopenssl, pyrsistent, pytest, python-dateutil, python-jose, python-keystoneclient, python-swiftclient, responses, singledispatch, stevedore, tox, traitlets, vcrpy, virtualenv, websocket-client +six==1.15.0 # via -r base.txt, aws-sam-translator, cfn-lint, cryptography, debtcollector, django-extensions, docker, ecdsa, jsonschema, junit-xml, keystoneauth1, metsrw, mock, more-itertools, moto, oslo.config, oslo.i18n, oslo.serialization, oslo.utils, packaging, pathlib2, pip-tools, prompt-toolkit, pyopenssl, pyrsistent, pytest, python-cas, python-dateutil, python-jose, python-keystoneclient, python-swiftclient, responses, singledispatch, stevedore, tox, traitlets, vcrpy, virtualenv, websocket-client stevedore==1.32.0 # via -r base.txt, keystoneauth1, oslo.config, python-keystoneclient sword2==0.2.1 # via -r base.txt toml==0.10.1 # via tox -tox==3.16.1 # via -r test.in +tox==3.18.1 # via -r test.in traitlets==4.3.3 # via ipython -typing==3.7.4.2 # via -r base.txt, importlib-resources +typing==3.7.4.3 # via -r base.txt, importlib-resources urllib3==1.24.3 # via -r base.txt, botocore, requests vcrpy==3.0.0 # via -r test.in -virtualenv==20.0.26 # via tox +virtualenv==20.0.28 # via tox wcwidth==0.2.5 # via prompt-toolkit websocket-client==0.57.0 # via docker werkzeug==1.0.1 # via moto diff --git a/storage_service/common/backends.py b/storage_service/common/backends.py new file mode 100644 index 000000000..c49f72611 --- /dev/null +++ b/storage_service/common/backends.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import + +from django.conf import settings +from django_cas_ng.backends import CASBackend + + +class CustomCASBackend(CASBackend): + def configure_user(self, user): + # If CAS_AUTOCONFIGURE_EMAIL and CAS_EMAIL_DOMAIN settings are + # configured, add an email address for this user, using rule + # username@domain. + if settings.CAS_AUTOCONFIGURE_EMAIL and settings.CAS_EMAIL_DOMAIN: + user.email = "{0}@{1}".format(user.username, settings.CAS_EMAIL_DOMAIN) + user.save() + return user diff --git a/storage_service/storage_service/settings/base.py b/storage_service/storage_service/settings/base.py index d8343b5d4..1eb736bf7 100644 --- a/storage_service/storage_service/settings/base.py +++ b/storage_service/storage_service/settings/base.py @@ -465,6 +465,63 @@ def is_true(env_str): ALLOW_USER_EDITS = False +######### CAS CONFIGURATION ######### +CAS_AUTHENTICATION = is_true(environ.get("SS_CAS_AUTHENTICATION", "")) +if CAS_AUTHENTICATION: + # CAS circumvents the Storage Service login screen and prevents + # usage of other authentication methods, so we raise an exception + # if a single sign-on option other than CAS is enabled. + if SHIBBOLETH_AUTHENTICATION or LDAP_AUTHENTICATION: + raise ImproperlyConfigured( + "CAS authentication is not supported in tandem with other single " + "sign-on methods. Please disable other Archivematica SSO settings " + "(e.g. Shibboleth, LDAP) before proceeding." + ) + + # We default to a live demo CAS server to facilitate QA and + # regression testing. The following credentials can be used to + # authenticate: + # Username: admin + # Password: django-cas-ng + CAS_DEMO_SERVER_URL = "https://django-cas-ng-demo-server.herokuapp.com/cas/" + CAS_SERVER_URL = environ.get("AUTH_CAS_SERVER_URL", CAS_DEMO_SERVER_URL) + + ALLOWED_CAS_VERSION_VALUES = ("1", "2", "3", "CAS_2_SAML_1_0") + + CAS_VERSION = environ.get("AUTH_CAS_PROTOCOL_VERSION", "3") + if CAS_VERSION not in ALLOWED_CAS_VERSION_VALUES: + raise ImproperlyConfigured( + ( + "Unexpected value for AUTH_CAS_PROTOCOL_VERSION: {}. " + "Supported values: '1', '2', '3', or 'CAS_2_SAML_1_0'." + ).format(CAS_VERSION) + ) + + CAS_CHECK_ADMIN_ATTRIBUTES = environ.get("AUTH_CAS_CHECK_ADMIN_ATTRIBUTES", False) + CAS_ADMIN_ATTRIBUTE = environ.get("AUTH_CAS_ADMIN_ATTRIBUTE", None) + CAS_ADMIN_ATTRIBUTE_VALUE = environ.get("AUTH_CAS_ADMIN_ATTRIBUTE_VALUE", None) + + CAS_AUTOCONFIGURE_EMAIL = environ.get("AUTH_CAS_AUTOCONFIGURE_EMAIL", False) + CAS_EMAIL_DOMAIN = environ.get("AUTH_CAS_EMAIL_DOMAIN", None) + + CAS_LOGIN_MSG = None + CAS_LOGIN_URL_NAME = "login" + CAS_LOGOUT_URL_NAME = "logout" + + AUTHENTICATION_BACKENDS += ["common.backends.CustomCASBackend"] + + # Insert CAS after the authentication middleware + MIDDLEWARE.insert( + MIDDLEWARE.index("django.contrib.auth.middleware.AuthenticationMiddleware") + 1, + "django_cas_ng.middleware.CASMiddleware", + ) + + INSTALLED_APPS += ["django_cas_ng"] + + ALLOW_USER_EDITS = False + +######### END CAS CONFIGURATION ######### + # WARNING: if Gunicorn is being used to serve the Storage Service and its # worker class is set to `gevent`, then BagIt validation must use 1 process. # Otherwise, calls to `validate` will hang because of the incompatibility diff --git a/storage_service/storage_service/signals.py b/storage_service/storage_service/signals.py new file mode 100644 index 000000000..f69e7aef6 --- /dev/null +++ b/storage_service/storage_service/signals.py @@ -0,0 +1,78 @@ +from __future__ import absolute_import + +import logging + +from django.conf import settings +from django.contrib.auth import get_user_model +from django.db import transaction +from django.dispatch import receiver +from django_cas_ng.signals import cas_user_authenticated + +LOGGER = logging.getLogger(__name__) + + +def _cas_user_is_administrator(cas_attributes): + """Determine if new user is an administrator from CAS attributes. + + :param cas_attributes: Attributes dict returned by CAS server. + + :returns: True if expected value is found, otherwise False. + """ + ADMIN_ATTRIBUTE = settings.CAS_ADMIN_ATTRIBUTE + ADMIN_ATTRIBUTE_VALUE = settings.CAS_ADMIN_ATTRIBUTE_VALUE + if (ADMIN_ATTRIBUTE is None) or (ADMIN_ATTRIBUTE_VALUE is None): + LOGGER.error( + "Error determining if new user is an administrator. Please " + "be sure that CAS settings AUTH_CAS_ADMIN_ATTRIBUTE and " + "AUTH_CAS_ADMIN_ATTRIBUTE_VALUE are properly set." + ) + return False + + # CAS attributes are a dictionary. The value for a given key can be + # a string or a list, so our approach for checking for the expected + # value takes that into account. + ATTRIBUTE_TO_CHECK = cas_attributes.get(ADMIN_ATTRIBUTE) + if isinstance(ATTRIBUTE_TO_CHECK, list): + if ADMIN_ATTRIBUTE_VALUE in ATTRIBUTE_TO_CHECK: + return True + elif isinstance(ATTRIBUTE_TO_CHECK, str): + if ATTRIBUTE_TO_CHECK == ADMIN_ATTRIBUTE_VALUE: + return True + return False + + +@receiver(cas_user_authenticated) +def cas_user_authenticated_callback(sender, **kwargs): + """Set user.is_superuser based on CAS attributes. + + When a user is authenticated, django_cas_ng sends the + cas_user_authenticated signal, which includes any attributes + returned by the CAS server during p3/serviceValidate. + + When the CAS_CHECK_ADMIN_ATTRIBUTES setting is enabled, we use this + receiver to set user.is_superuser to True if we find the expected + key-value combination configured with CAS_ADMIN_ATTRIBUTE and + CAS_ADMIN_ATTRIBUTE_VALUE in the CAS attributes, and False if not. + + This check happens for both new and existing users, so that changes + in group membership on the CAS server (e.g. a user being added or + removed from the administrator group) are applied in Archivematica + on the next login. + """ + if not settings.CAS_CHECK_ADMIN_ATTRIBUTES: + return + + username = kwargs.get("user") + attributes = kwargs.get("attributes") + + if not attributes: + return + + User = get_user_model() + is_administrator = _cas_user_is_administrator(attributes) + + with transaction.atomic(): + user = User.objects.select_for_update().get(username=username) + if user.is_superuser != is_administrator: + user.is_superuser = is_administrator + user.save() diff --git a/storage_service/storage_service/tests/test_cas.py b/storage_service/storage_service/tests/test_cas.py new file mode 100644 index 000000000..413000253 --- /dev/null +++ b/storage_service/storage_service/tests/test_cas.py @@ -0,0 +1,244 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import + +from django.conf import settings +from django.contrib.auth.models import User +from django.test import TestCase, RequestFactory +from django.test.client import Client +import pytest + +try: + import mock +except ImportError: + from unittest import mock + +from common.backends import CustomCASBackend +from storage_service.signals import _cas_user_is_administrator + +TEST_CAS_USER = "casuser" +TEST_CAS_ADMIN_ATTRIBUTE = "usertype" +TEST_CAS_ADMIN_ATTRIBUTE_VALUE_POSITIVE = "admin" +TEST_CAS_ADMIN_ATTRIBUTE_VALUE_NEGATIVE = "regular" + +TEST_CAS_ATTRIBUTES_STRING_POSITIVE = { + TEST_CAS_ADMIN_ATTRIBUTE: TEST_CAS_ADMIN_ATTRIBUTE_VALUE_POSITIVE +} +TEST_CAS_ATTRIBUTES_STRING_NEGATIVE = { + TEST_CAS_ADMIN_ATTRIBUTE: TEST_CAS_ADMIN_ATTRIBUTE_VALUE_NEGATIVE +} +TEST_CAS_ATTRIBUTES_LIST_POSITIVE = { + TEST_CAS_ADMIN_ATTRIBUTE: [ + TEST_CAS_ADMIN_ATTRIBUTE_VALUE_POSITIVE, + "attribute1", + "attribute2", + ] +} +TEST_CAS_ATTRIBUTES_LIST_NEGATIVE = { + TEST_CAS_ADMIN_ATTRIBUTE: [ + TEST_CAS_ADMIN_ATTRIBUTE_VALUE_NEGATIVE, + "attribute1", + "attribute2", + ] +} + + +def mock_verify(ticket, service): + user = TEST_CAS_USER + attributes = { + "ticket": ticket, + "service": service, + TEST_CAS_ADMIN_ATTRIBUTE: TEST_CAS_ADMIN_ATTRIBUTE_VALUE_NEGATIVE, + } + pgtiou = None + return user, attributes, pgtiou + + +def mock_verify_superuser(ticket, service): + user = TEST_CAS_USER + attributes = { + "ticket": ticket, + "service": service, + TEST_CAS_ADMIN_ATTRIBUTE: TEST_CAS_ADMIN_ATTRIBUTE_VALUE_POSITIVE, + } + pgtiou = None + return user, attributes, pgtiou + + +@pytest.mark.skipif( + not settings.CAS_AUTHENTICATION, reason="tests will only pass if CAS is enabled" +) +class TestCAS(TestCase): + def setUp(self): + self.client = Client() + + def authenticate_user(self, request): + """Helper function to authenticate a user using custom backend. + """ + backend = CustomCASBackend() + backend.authenticate(request, ticket="fake-ticket", service="fake-service") + + def create_request(self): + """Helper function to create request that will redirect to CAS. + """ + factory = RequestFactory() + request = factory.get("/") + request.session = {} + return request + + def test_redirect_for_login(self): + """Unauthenticated users should be redirected twice. + + After the initial redirect to LOGIN_URL, the user should be + redirected again to the CAS server for authentication. + """ + response = self.client.get("/") + expected_redirect = settings.LOGIN_URL + "?next=/" + self.assertRedirects( + response, expected_redirect, status_code=302, target_status_code=302 + ) + + @mock.patch("cas.CASClientV2.verify_ticket", mock_verify) + def test_autoconfigure_email(self): + """Test that email is autoconfigured from username and domain. + """ + with self.settings( + CAS_AUTOCONFIGURE_EMAIL=True, CAS_EMAIL_DOMAIN="artefactual.com" + ): + request = self.create_request() + + # Check that user doesn't already exist. + assert not User.objects.filter(username=TEST_CAS_USER).exists() + + # Create the user and check its properties. + self.authenticate_user(request) + user = User.objects.get(username=TEST_CAS_USER) + assert user is not None + assert user.username == TEST_CAS_USER + assert user.email == "casuser@artefactual.com" + + @mock.patch("cas.CASClientV2.verify_ticket", mock_verify_superuser) + def test_check_admin_attributes_superuser_new_user(self): + """Test setting is_superuser for new users. + + If settings are properly configured and expected key-value is + found in the CAS attributes, user.is_superuser should be True. + """ + # Check that user doesn't already exist. + assert not User.objects.filter(username=TEST_CAS_USER).exists() + + with self.settings( + CAS_CHECK_ADMIN_ATTRIBUTES=True, + CAS_ADMIN_ATTRIBUTE=TEST_CAS_ADMIN_ATTRIBUTE, + CAS_ADMIN_ATTRIBUTE_VALUE=TEST_CAS_ADMIN_ATTRIBUTE_VALUE_POSITIVE, + ): + request = self.create_request() + self.authenticate_user(request) + user = User.objects.get(username=TEST_CAS_USER) + assert user is not None + assert user.is_superuser is True + + @mock.patch("cas.CASClientV2.verify_ticket", mock_verify_superuser) + def test_check_admin_attributes_superuser_existing_user(self): + """Test setting is_superuser for existing users. + + If settings are properly configured and expected key-value is + found in the CAS attributes, user.is_superuser for an existing + non-administrative user should be updated to True. + """ + user = User.objects.create(username=TEST_CAS_USER) + assert user is not None + assert user.is_superuser is False + + # Authenticate again with CAS_CHECK_ADMIN_ATTRIBUTES enabled + # and check that user.is_superuser has been updated to True. + with self.settings( + CAS_CHECK_ADMIN_ATTRIBUTES=True, + CAS_ADMIN_ATTRIBUTE=TEST_CAS_ADMIN_ATTRIBUTE, + CAS_ADMIN_ATTRIBUTE_VALUE=TEST_CAS_ADMIN_ATTRIBUTE_VALUE_POSITIVE, + ): + request = self.create_request() + self.authenticate_user(request) + user = User.objects.get(username=TEST_CAS_USER) + assert user is not None + assert user.is_superuser is True + + @mock.patch("cas.CASClientV2.verify_ticket", mock_verify) + def test_check_admin_attributes_regular_new_user(self): + """Test setting is_superuser for new users. + + If settings are properly configured and expected key-value is + not found in the CAS attributes, user.is_superuser should be + False. + """ + # Check that user doesn't already exist. + assert not User.objects.filter(username=TEST_CAS_USER).exists() + + with self.settings( + CAS_CHECK_ADMIN_ATTRIBUTES=True, + CAS_ADMIN_ATTRIBUTE=TEST_CAS_ADMIN_ATTRIBUTE, + CAS_ADMIN_ATTRIBUTE_VALUE=TEST_CAS_ADMIN_ATTRIBUTE_VALUE_POSITIVE, + ): + request = self.create_request() + self.authenticate_user(request) + user = User.objects.get(username=TEST_CAS_USER) + assert user is not None + assert user.is_superuser is False + + @mock.patch("cas.CASClientV2.verify_ticket", mock_verify_superuser) + def test_check_admin_attributes_regular_existing_user(self): + """Test setting is_superuser for existing users. + + If settings are properly configured and expected key-value is + not found in the CAS attributes, user.is_superuser for an + existing administrative user should be updated to False. + """ + # Create a new superuser. + user = User.objects.create(username=TEST_CAS_USER, is_superuser=True) + assert user is not None + assert user.is_superuser is True + + # Authenticate with CAS_ADMIN_ATTRIBUTE_VALUE set to a value + # not present in the CAS attributes and check that + # user.is_superuser has been updated to False. + with self.settings( + CAS_CHECK_ADMIN_ATTRIBUTES=True, + CAS_ADMIN_ATTRIBUTE=TEST_CAS_ADMIN_ATTRIBUTE, + CAS_ADMIN_ATTRIBUTE_VALUE="something else", + ): + request = self.create_request() + self.authenticate_user(request) + user = User.objects.get(username=TEST_CAS_USER) + assert user is not None + assert user.is_superuser is False + + def test_user_is_administrator(self): + """Unit test for _cas_user_is_administrator helper. + """ + # If settings are improperly configured, function should return + # False. + with self.settings( + CAS_CHECK_ADMIN_ATTRIBUTES=True, + CAS_ADMIN_ATTRIBUTE=TEST_CAS_ADMIN_ATTRIBUTE, + CAS_ADMIN_ATTRIBUTE_VALUE=None, + ): + assert ( + _cas_user_is_administrator(TEST_CAS_ATTRIBUTES_STRING_POSITIVE) is False + ) + + # Ensure function returns expected values whether + # CAS_ADMIN_ATTRIBUTE is a string or a list. + with self.settings( + CAS_CHECK_ADMIN_ATTRIBUTES=True, + CAS_ADMIN_ATTRIBUTE=TEST_CAS_ADMIN_ATTRIBUTE, + CAS_ADMIN_ATTRIBUTE_VALUE=TEST_CAS_ADMIN_ATTRIBUTE_VALUE_POSITIVE, + ): + assert ( + _cas_user_is_administrator(TEST_CAS_ATTRIBUTES_STRING_POSITIVE) is True + ) + assert ( + _cas_user_is_administrator(TEST_CAS_ATTRIBUTES_STRING_NEGATIVE) is False + ) + assert _cas_user_is_administrator(TEST_CAS_ATTRIBUTES_LIST_POSITIVE) is True + assert ( + _cas_user_is_administrator(TEST_CAS_ATTRIBUTES_LIST_NEGATIVE) is False + ) diff --git a/storage_service/storage_service/urls.py b/storage_service/storage_service/urls.py index d6aa565b9..68a3f3454 100644 --- a/storage_service/storage_service/urls.py +++ b/storage_service/storage_service/urls.py @@ -18,12 +18,6 @@ url(r"^admin/", admin.site.urls), url(r"^", include(locations.urls)), url(r"^administration/", include(administration.urls)), - url( - r"^login/$", - django.contrib.auth.views.LoginView.as_view(template_name="login.html"), - name="login", - ), - url(r"^logout/$", django.contrib.auth.views.logout_then_login, name="logout"), url(r"^api/", include(locations.api.urls)), url( r"^jsi18n/$", @@ -34,6 +28,22 @@ url(r"^i18n/", include(("django.conf.urls.i18n", "i18n"), namespace="i18n")), ] +if "django_cas_ng" in settings.INSTALLED_APPS: + import django_cas_ng.views + + urlpatterns += [ + url(r"login/$", django_cas_ng.views.LoginView.as_view(), name="login"), + url(r"logout/$", django_cas_ng.views.LogoutView.as_view(), name="logout"), + ] +else: + urlpatterns += [ + url( + r"^login/$", + django.contrib.auth.views.LoginView.as_view(template_name="login.html"), + name="login", + ), + url(r"^logout/$", django.contrib.auth.views.logout_then_login, name="logout"), + ] if "shibboleth" in settings.INSTALLED_APPS: # Simulate a shibboleth urls module (so our custom Shibboleth logout view