diff --git a/Dockerfile b/Dockerfile index 4546c5bc1711a2fa2df489886bf23750afc47930..e56611a38d04649f8fa365197863596b63b487f6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -137,5 +137,11 @@ RUN chmod 0755 /usr/local/bin/entrypoint && \ find /app/gracedb_project -type d -exec chmod 0755 {} + && \ find /app/gracedb_project -type f -exec chmod 0644 {} + +# create and set scitoken key cache directory +RUN mkdir /app/scitokens_cache && \ + chown gracedb:www-data /app/scitokens_cache && \ + chmod 0750 /app/scitokens_cache +ENV XDG_CACHE_HOME /app/scitokens_cache + ENTRYPOINT [ "/usr/local/bin/entrypoint" ] CMD ["/usr/local/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"] diff --git a/config/settings/base.py b/config/settings/base.py index 02e3600348678a97b9135975eeacf81110dccd0f..3290989b7236215591d8ce81a3c3234e0b4997bb 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -319,6 +319,11 @@ X509_INFOS_HEADER = 'HTTP_X_FORWARDED_TLS_CLIENT_CERT_INFOS' # Path to CA store for X509 certificate verification CAPATH = '/etc/grid-security/certificates' +# SciTokens claims settings +SCITOKEN_ISSUER = "https://cilogon.org/ligo" +SCITOKEN_AUDIENCE = ["ANY"] +SCITOKEN_SCOPE = "read:/GraceDB" + # List of authentication backends to use when attempting to authenticate # a user. Will be used in this order. Authentication for the API is # handled by the REST_FRAMEWORK dictionary. @@ -414,6 +419,7 @@ REST_FRAMEWORK = { }, 'DEFAULT_AUTHENTICATION_CLASSES': ( 'api.backends.GraceDbAuthenticatedAuthentication', + 'api.backends.GraceDbSciTokenAuthentication', 'api.backends.GraceDbX509Authentication', 'api.backends.GraceDbBasicAuthentication', ), diff --git a/config/settings/container/base.py b/config/settings/container/base.py index e86da12dd881bfcebaaa677a60a46c048d597395..7c336a8289a811f27588016aee39a5ee97440355 100644 --- a/config/settings/container/base.py +++ b/config/settings/container/base.py @@ -257,6 +257,9 @@ DATABASES = { 'HOST': os.environ.get('DJANGO_DB_HOST', ''), 'PORT': os.environ.get('DJANGO_DB_PORT', ''), 'CONN_MAX_AGE': 3600, + 'TEST' : { + 'NAME': 'gracedb_test_db', + }, }, } @@ -368,6 +371,7 @@ if (len(LVALERT_OVERSEER_INSTANCES) == 2): # Use full client certificate to authenticate REST_FRAMEWORK['DEFAULT_AUTHENTICATION_CLASSES'] = ( 'api.backends.GraceDbAuthenticatedAuthentication', + 'api.backends.GraceDbSciTokenAuthentication', 'api.backends.GraceDbX509FullCertAuthentication', 'api.backends.GraceDbBasicAuthentication', ) @@ -398,3 +402,6 @@ LOGGING['loggers']['django.request']['handlers'].append('mail_admins') # Turn off debug/error emails when in maintenance mode. if MAINTENANCE_MODE: LOGGING['loggers']['django.request']['handlers'].remove('mail_admins') + +# Set SciToken accepted audience to server FQDN +SCITOKEN_AUDIENCE = ["https://" + SERVER_FQDN, "https://" + LIGO_FQDN] diff --git a/config/settings/vm/base.py b/config/settings/vm/base.py index aaa5d77fedec3c4a50f382834cbc469b43bcf49d..4ca0b7278eda1686e632b970a0f71a8d2b770b04 100644 --- a/config/settings/vm/base.py +++ b/config/settings/vm/base.py @@ -109,3 +109,5 @@ if (len(LVALERT_OVERSEER_INSTANCES) == 2): ENABLED[SEND_XMPP_ALERTS]) INSTANCE_LIST = INSTANCE_LIST + IGWN_LIST +# Set SciToken accepted audience to server FQDN +SCITOKEN_AUDIENCE = ["https://" + SERVER_FQDN, "https://" + LIGO_FQDN] diff --git a/gracedb/api/backends.py b/gracedb/api/backends.py index 8c585db25ad26d3e50d939030bc841b68f3fa03a..f39b554f8fc20648725fac91971e79dedd2877d5 100644 --- a/gracedb/api/backends.py +++ b/gracedb/api/backends.py @@ -6,6 +6,7 @@ import re from django.contrib.auth import get_user_model, authenticate from django.conf import settings +from django.contrib.auth.models import User from django.http import HttpResponseForbidden from django.utils import timezone from django.utils.http import unquote, unquote_plus @@ -17,6 +18,10 @@ from rest_framework import authentication, exceptions from ligoauth.models import X509Cert from .utils import is_api_request +import scitokens +from jwt import InvalidTokenError +from scitokens.utils.errors import SciTokensException + # Set up logger logger = logging.getLogger(__name__) @@ -64,6 +69,52 @@ class GraceDbBasicAuthentication(authentication.BasicAuthentication): return user_auth_tuple +class GraceDbSciTokenAuthentication(authentication.BasicAuthentication): + + def authenticate(self, request, public_key=None): + # Get token from header + try: + bearer = request.headers["Authorization"] + except KeyError: + return None + auth_type, serialized_token = bearer.split() + if auth_type != "Bearer": + return None + + # Deserialize token + try: + token = scitokens.SciToken.deserialize( + serialized_token, + # deserialize all tokens, enforce audience later + audience={"ANY"} | set(settings.SCITOKEN_AUDIENCE), + public_key=public_key, + ) + except (InvalidTokenError, SciTokensException) as exc: + return None + + # Enforce scitoken logic + enforcer = scitokens.Enforcer( + settings.SCITOKEN_ISSUER, + audience = settings.SCITOKEN_AUDIENCE, + ) + + authz, path = settings.SCITOKEN_SCOPE.split(":", 1) + if not enforcer.test(token, authz, path): + return None + + # Get username from token 'Subject' claim. + try: + user = User.objects.get(username=token['sub']) + except User.DoesNotExist: + return None + + if not user.is_active: + raise exceptions.AuthenticationFailed( + _('User inactive or deleted')) + + return (user, None) + + class GraceDbX509Authentication(authentication.BaseAuthentication): """ Authentication based on X509 certificate subject. diff --git a/gracedb/api/tests/test_backends.py b/gracedb/api/tests/test_backends.py index ed61130ad58cf9d4a3e5db0e28594675cc2e93f5..fda771930ff2e0476d748079c99503e19552a67f 100644 --- a/gracedb/api/tests/test_backends.py +++ b/gracedb/api/tests/test_backends.py @@ -12,13 +12,21 @@ from user_sessions.middleware import SessionMiddleware from api.backends import ( GraceDbBasicAuthentication, GraceDbX509Authentication, - GraceDbAuthenticatedAuthentication, + GraceDbSciTokenAuthentication, GraceDbAuthenticatedAuthentication, ) from api.tests.utils import GraceDbApiTestBase from api.utils import api_reverse from ligoauth.middleware import ShibbolethWebAuthMiddleware from ligoauth.models import X509Cert +import scitokens +import time +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.rsa import generate_private_key +from core.tests.utils import GraceDbTestBase +from django.test import override_settings + # Make sure to test password expiration class TestGraceDbBasicAuthentication(GraceDbApiTestBase): @@ -134,6 +142,137 @@ class TestGraceDbBasicAuthentication(GraceDbApiTestBase): user, other = self.backend_instance.authenticate(request) +class TestGraceDbSciTokenAuthentication(GraceDbTestBase): + """Test SciToken auth backend for API""" + + TEST_ISSUER = "local" + TEST_AUDIENCE = ["TEST"] + TEST_SCOPE = "read:/GraceDB" + + @classmethod + def setUpClass(cls): + super(TestGraceDbSciTokenAuthentication, cls).setUpClass() + + # Attach request factory to class + cls.backend_instance = GraceDbSciTokenAuthentication() + cls.factory = APIRequestFactory() + + @classmethod + def setUpTestData(cls): + super(TestGraceDbSciTokenAuthentication, cls).setUpTestData() + + def setUp(self): + self._private_key = generate_private_key( + public_exponent=65537, + key_size=2048, + backend=default_backend() + ) + self._public_key = self._private_key.public_key() + self._public_pem = self._public_key.public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo + ) + keycache = scitokens.utils.keycache.KeyCache.getinstance() + keycache.addkeyinfo("local", "sample_key", self._private_key.public_key()) + now = int(time.time()) + self._token = scitokens.SciToken(key = self._private_key, key_id="sample_key") + self._token.update_claims({ + "iss": self.TEST_ISSUER, + "aud": self.TEST_AUDIENCE, + "scope": self.TEST_SCOPE, + "sub": str(self.internal_user), + }) + self._serialized_token = self._token.serialize(issuer = "local") + self._no_kid_token = scitokens.SciToken(key = self._private_key) + + @override_settings( + SCITOKEN_ISSUER="local", + SCITOKEN_AUDIENCE=["TEST"], + ) + def test_user_authenticate_to_api_with_scitoken(self): + """User can authenticate to API with valid Scitoken""" + # Set up request + request = self.factory.get(api_reverse('api:root')) + token_str = 'Bearer ' + self._serialized_token.decode() + request.headers = {'Authorization': token_str} + + # Authentication attempt + user, other = self.backend_instance.authenticate(request, public_key=self._public_pem) + + # Check authenticated user + self.assertEqual(user, self.internal_user) + + @override_settings( + SCITOKEN_ISSUER="local", + SCITOKEN_AUDIENCE=["TEST"], + ) + def test_user_authenticate_to_api_without_scitoken(self): + """User can authenticate to API without valid Scitoken""" + # Set up request + request = self.factory.get(api_reverse('api:root')) + + # Authentication attempt + resp = self.backend_instance.authenticate(request, public_key=self._public_pem) + + # Check authentication response + assert resp == None + + @override_settings( + SCITOKEN_ISSUER="local", + SCITOKEN_AUDIENCE=["TEST"], + ) + def test_user_authenticate_to_api_with_wrong_audience(self): + """User can authenticate to API with invalid Scitoken audience""" + # Set up request + request = self.factory.get(api_reverse('api:root')) + self._token["aud"] = "https://somethingelse.example.com" + serialized_token = self._token.serialize(issuer = "local") + token_str = 'Bearer ' + serialized_token.decode() + request.headers = {'Authorization': token_str} + + # Authentication attempt + resp = self.backend_instance.authenticate(request, public_key=self._public_pem) + + # Check authentication response + assert resp == None + + @override_settings( + SCITOKEN_ISSUER="local", + SCITOKEN_AUDIENCE=["TEST"], + ) + def test_user_authenticate_to_api_with_expired_scitoken(self): + """User can authenticate to API with valid Scitoken""" + # Set up request + request = self.factory.get(api_reverse('api:root')) + serialized_token = self._token.serialize(issuer = "local", lifetime=-1) + token_str = 'Bearer ' + serialized_token.decode() + request.headers = {'Authorization': token_str} + + # Authentication attempt + resp = self.backend_instance.authenticate(request, public_key=self._public_pem) + + # Check authentication response + assert resp == None + + @override_settings( + SCITOKEN_ISSUER="local", + SCITOKEN_AUDIENCE=["TEST"], + ) + def test_inactive_user_authenticate_to_api_with_scitoken(self): + """Inactive user can't authenticate with valid Scitoken""" + # Set internal user to inactive + self.internal_user.is_active = False + self.internal_user.save(update_fields=['is_active']) + + # Set up request + request = self.factory.get(api_reverse('api:root')) + token_str = 'Bearer ' + self._serialized_token.decode() + request.headers = {'Authorization': token_str} + + # Authentication attempt should fail + with self.assertRaises(exceptions.AuthenticationFailed): + user, other = self.backend_instance.authenticate(request, public_key=self._public_pem) + class TestGraceDbX509Authentication(GraceDbApiTestBase): """Test X509 certificate auth backend for API""" diff --git a/gracedb/ligoauth/management/commands/update_user_accounts_from_ligo_ldap.py b/gracedb/ligoauth/management/commands/update_user_accounts_from_ligo_ldap.py index eb207ffc5a9fd83e7352a4e98c1ceaaa2c812d39..b390e7c06c5c1cbd68ab308fb109c9e4fde0d568 100644 --- a/gracedb/ligoauth/management/commands/update_user_accounts_from_ligo_ldap.py +++ b/gracedb/ligoauth/management/commands/update_user_accounts_from_ligo_ldap.py @@ -11,6 +11,10 @@ from django.core.management.base import BaseCommand, CommandError from ligoauth.models import X509Cert, AuthGroup, \ AuthorizedLdapMember, GenericLdapUser +from events.models import Event, EventLog +from superevents.models import Log +from alerts.models import Notification + UserModel = get_user_model() @@ -64,9 +68,92 @@ class LdapPersonResultProcessor(object): pass def get_or_create_user(self): + if not hasattr(self, 'user_data'): self.extract_user_attributes() + # Kagra members used to use the the 'mail' ldap attribute as a username, + # to be consistent with LIGO members. However, for scitokens, a kagra user's + # eppn is used in the same field as a ligo member's @ligo.org email. So, + # if the eppn attribute exists from the ldap data (which is only polled for + # kagra, not ligo), check for a user with the email, if it exists, then change + # the user's username. If not, then do nothing to the user, but change the 'username' + # user_data attribute either way. This is kind of hacky, but in theory this should + # only actually run once. + + # This loop is only for kagra folks, as it's set up: + if 'eduPersonPrincipalName' in self.ldap_result.keys(): + # Check for an existing user whose username is their eppn: + kagra_user = UserModel.objects.filter(username= + self.user_data['email']) + if kagra_user.exists(): + kagra_user = kagra_user.first() + print("Kagra user {} exists. Implementing logic to merge with {}".format( + kagra_user.username, self.user_data['username'])) + + # Kagra shibbi users don't typically have annotations since they're + # just logging in through the web. but they DO have notifications + # set up. So. check if API (kagra_user) has any logs, eventlogs, or events + # to their name. If so, change the logs to the shibboleth account. Again, + # this unlikely and should only matter the first time this script gets run. + + # Get this shibboleth user: + shibbi_user = UserModel.objects.filter(username= + self.user_data['username']) + if shibbi_user.exists(): + shibbi_user = shibbi_user.first() + print("Shibboleth user {} exists. Checking for API user annotations.") + + # Check for uploaded events: + kagra_events = Event.objects.filter(submitter=kagra_user) + if kagra_events.exists(): + for e in kagra_events: + print("changing submitter for event {} to {}".format( + e, shibbi_user)) + e.submitter = shibbi_user + e.save() + else: + print("No events uploaded by user {}".format(kagra_user)) + + # Check for event log annotations: + kagra_eventlogs = EventLog.objects.filter(issuer=kagra_user) + if kagra_eventlogs.exists(): + for e in kagra_eventlogs: + print("changing issuer for eventlog {} to {}".format( + e, shibbi_user)) + e.issuer = shibbi_user + e.save() + else: + print("No eventlogs annotated by user {}".format(kagra_user)) + + # Check for superevent log annotations: + kagra_seventlogs = Log.objects.filter(issuer=kagra_user) + if kagra_seventlogs.exists(): + for s in kagra_seventlogs: + print("changing issuer for supereventlog {} to {}".format( + s, shibbi_user)) + s.issuer = shibbi_user + s.save() + else: + print("No supereventlogs annotated by user {}".format(kagra_user)) + + # So the old account's annotations have been transferred. Print + # stats for the shibbi account and then delete the old one: + + print("Shibboleth-created account {shib} remains with {e} events, {el} event logs, {se} superevent logs, and {n} alert notifications". format( + shib=shibbi_user.username, + e=Event.objects.filter(submitter=shibbi_user).count(), + el=EventLog.objects.filter(issuer=shibbi_user).count(), + se=Log.objects.filter(issuer=shibbi_user).count(), + n=Notification.objects.filter(user=shibbi_user).count())) + print("Deleting user {}".format(kagra_user)) + kagra_user.delete() + else: + print("No shibboleth user {} exists. Changing username.".format( + self.user_data['username'])) + kagra_user.username = self.user_data['username'] + kagra_user.save() + # Determine if users exist user_exists = UserModel.objects.filter(username= self.user_data['username']).exists() @@ -376,8 +463,6 @@ class LdapRobotResultProcessor(LdapPersonResultProcessor): class LdapKagraResultProcessor(LdapPersonResultProcessor): - #def __init__(self, ldap_member_name='Communities:LSCVirgoLIGOGroupMembers', - # *args, **kwargs): def __init__(self, ldap_dn, ldap_result, ldap_connection=None, verbose=True, stdout=None, ldap_member_name='gw-astronomy:KAGRA-LIGO:members', @@ -411,7 +496,7 @@ class LdapKagraResultProcessor(LdapPersonResultProcessor): 'email': self.ldap_result['mail'][0].decode('utf-8'), 'is_active': bool(self.ldap_connection.lvc_group.authorizedldapmember_set.all() & self.ldap_memberships), - 'username': self.ldap_result['mail'][0].decode('utf-8'), + 'username': self.ldap_result['eduPersonPrincipalName'][0].decode('utf-8'), } def update_user_certificates(self): @@ -545,6 +630,7 @@ class KagraPeopleLdap(LigoPeopleLdap): 'voPersonCertificateDN', 'mail', 'isMemberOf', + 'eduPersonPrincipalName', ] group_names = ['internal_users'] user_processor_class = LdapKagraResultProcessor diff --git a/gracedb/migrations/auth/0028_case_insensitive_username.py b/gracedb/migrations/auth/0028_case_insensitive_username.py new file mode 100644 index 0000000000000000000000000000000000000000..f2fc59810f0689268447c9518183bf0ed4c4e155 --- /dev/null +++ b/gracedb/migrations/auth/0028_case_insensitive_username.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +import django.core.validators +from django.db import migrations, models +from django.contrib.postgres import fields +from django.contrib.postgres.operations import CITextExtension + + +class Migration(migrations.Migration): + + dependencies = [ + ('auth', '0027_auto_20211117_2338'), + ] + + # No database changes; modifies validators and error_messages (#13147). + operations = [ + CITextExtension(), + migrations.AlterField( + model_name='user', + name='username', + field=fields.CICharField(error_messages={'unique': 'A user with that username already exists.'}, max_length=30, validators=[django.core.validators.RegexValidator('^[\\w.@+-]+$', 'Enter a valid username. This value may contain only letters, numbers and @/./+/-/_ characters.', 'invalid')], help_text='Required. 30 characters or fewer. Letters, digits and @/./+/-/_ only.', unique=True, verbose_name='username'), + ), + ] diff --git a/requirements.txt b/requirements.txt index a7f282f80560503303279e9809865b51057800cf..df876e48a737f429ce3b8cb53a6ac1c7bfcd52cb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -37,9 +37,11 @@ packaging==17.1 phonenumbers==8.10.22 plotly==4.14.3 psycopg2==2.8.6 +PyJWT==2.3.0 python-ldap==3.3.1 python3-memcached==1.51 scipy==1.6.1 +scitokens==1.6.2 sentry-sdk==0.7.10 service_identity==21.1.0 simplejson==3.15.0