Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rotate tokens every login #442

Merged
merged 4 commits into from
Jun 22, 2021
Merged

Rotate tokens every login #442

merged 4 commits into from
Jun 22, 2021

Conversation

mbakke
Copy link
Contributor

@mbakke mbakke commented Jun 9, 2021

Hello,

This PR ensures that API tokens are rotated every login.

Using a token within the expiry window will extend the expiry time so e.g. automated jobs can keep using the same token.

@coveralls
Copy link
Collaborator

coveralls commented Jun 9, 2021

Coverage Status

Coverage increased (+0.09%) to 98.609% when pulling 0e9deb9 on mbakke:token-rotation into fca26db on unioslo:master.

mreg/authentication.py Outdated Show resolved Hide resolved
ponas
ponas previously approved these changes Jun 9, 2021
@mbakke mbakke marked this pull request as draft June 9, 2021 15:53
mreg/models.py Outdated Show resolved Hide resolved
mreg/authentication.py Outdated Show resolved Hide resolved
@mbakke
Copy link
Contributor Author

mbakke commented Jun 9, 2021

Errh, I did not try logging into an existing mreg installation after this change, and just blindly trusted the tests.

Logging in with a user that already has a token causes a crash:

psycopg2.errors.UniqueViolation: duplicate key value violates unique constraint "authtoken_token_user_id_key"                                                                                       
DETAIL:  Key (user_id)=(2) already exists.

Demoted to draft status until I find a solution.

@oyvindkolbu
Copy link
Collaborator

oyvindkolbu commented Jun 9, 2021

Errh, I did not try logging into an existing mreg installation after this change, and just blindly trusted the tests.

Possibly because it already contained a token from created by Token and not TokenExpire, therefore you need to
add a similar cleanup of Token here
https://github.com/unioslo/mreg/pull/442/files#diff-98101bc9229886064733bd64cbf050fde15778a4361b28382277fa40f295d3eaR30

The tests don't have stale data in them, so that's why it didn't catch it.

Could easily be provoked by adding a Token.object.create... before the post in test_token_rotation. Also check that it is gone afterwards. Probably better as a dedicated test.

@mbakke
Copy link
Contributor Author

mbakke commented Jun 10, 2021

Could easily be provoked by adding a Token.object.create... before the post in test_token_rotation. Also check that it is gone afterwards. Probably better as a dedicated test.

Right, thanks for the hint. I had to override get_token_client to prevent ExpiringToken from being issued first and made a new test class:

diff --git a/mreg/api/v1/tests/tests.py b/mreg/api/v1/tests/tests.py
index 9790561..4140ebe 100644
--- a/mreg/api/v1/tests/tests.py
+++ b/mreg/api/v1/tests/tests.py
@@ -238,6 +238,39 @@ class APITokenAuthenticationTestCase(MregAPITestCase):
         self.assert_post_and_400("/api/token-auth/", {"who":"someone","why":"because"})
         self.assert_post_and_400("/api/token-auth/", {})
 
+
+from rest_framework.authtoken.models import Token
+class MigratingTokenTestCase(MregAPITestCase):
+    """ Ensure that an existing Token is replaced by ExpiringToken. """
+    def get_token_client(self, username=None, superuser=True, adminuser=False):
+        if username is None:
+            if superuser:
+                username = 'superuser'
+            elif adminuser:
+                username = 'adminuser'
+            else:
+                username = 'nobody'
+        self.user = get_user_model().objects.create_user(username=username,
+                                                         password="test")
+        self.user.groups.clear()
+        token, created = Token.objects.get_or_create(user=self.user)
+        if superuser:
+            self.add_user_to_groups('SUPERUSER_GROUP')
+        if adminuser:
+            self.add_user_to_groups('ADMINUSER_GROUP')
+        client = APIClient()
+        client.credentials(HTTP_AUTHORIZATION='Token ' + token.key)
+        return client
+
+    def test_conflicting_token(self):
+        self.assert_post_and_401("/api/token-auth/",
+                                 {"username": self.user,
+                                  "password":"test"})
+        self.assertFalse(Token.objects.get(user=self.user))
+
 class APIAutoupdateZonesTestCase(MregAPITestCase):
     """This class tests the autoupdate of zones' updated_at whenever
        various models are added/deleted/renamed/changed etc."""

...however the request never makes it to our view in the test and is 401'd somewhere in APIView AFAICT. When running the server normally, clearing the Token inside ObtainExpiringAuthToken works. Any idea what can cause this discrepancy?

@mbakke
Copy link
Contributor Author

mbakke commented Jun 10, 2021

To clarify, this does work in practice:

diff --git a/mreg/api/views.py b/mreg/api/views.py
index adacb03..9833e66 100644
--- a/mreg/api/views.py
+++ b/mreg/api/views.py
@@ -9,6 +9,8 @@ from rest_framework import serializers
 from rest_framework.exceptions import AuthenticationFailed
 
 from mreg.models import ExpiringToken
+# Include the "vanilla" DRF Token in order to migrate existing installations.
+from rest_framework.authtoken.models import Token
 
 
 class ObtainExpiringAuthToken(ObtainAuthToken):
@@ -26,6 +28,9 @@ class ObtainExpiringAuthToken(ObtainAuthToken):
 
         user = serializer.validated_data['user']
 
+        # Delete any "old-style" DRF Token to prevent a conflict.
+        Token.objects.filter(user=user).delete()
+
         # Force token rotation.
         ExpiringToken.objects.filter(user=user).delete()
 

...but I haven't been able to write a test for it.

I wonder if we can take a "shotgun" approach and just clear all tokens at server startup?

@oyvindkolbu
Copy link
Collaborator

To clarify, this does work in practice:

diff --git a/mreg/api/views.py b/mreg/api/views.py
index adacb03..9833e66 100644
--- a/mreg/api/views.py
+++ b/mreg/api/views.py
@@ -9,6 +9,8 @@ from rest_framework import serializers
 from rest_framework.exceptions import AuthenticationFailed
 
 from mreg.models import ExpiringToken
+# Include the "vanilla" DRF Token in order to migrate existing installations.
+from rest_framework.authtoken.models import Token
 
 
 class ObtainExpiringAuthToken(ObtainAuthToken):
@@ -26,6 +28,9 @@ class ObtainExpiringAuthToken(ObtainAuthToken):
 
         user = serializer.validated_data['user']
 
+        # Delete any "old-style" DRF Token to prevent a conflict.
+        Token.objects.filter(user=user).delete()
+
         # Force token rotation.
         ExpiringToken.objects.filter(user=user).delete()
 

This makes sense.

I wonder if we can take a "shotgun" approach and just clear all tokens at server startup?

A crude but efficient solution. All old tokens will be invalidated on first usage anyhow, so
should be fine.

@mbakke
Copy link
Contributor Author

mbakke commented Jun 11, 2021

I wonder if we can take a "shotgun" approach and just clear all tokens at server startup?

A crude but efficient solution. All old tokens will be invalidated on first usage anyhow, so
should be fine.

It turns out that doing database operations during initialization is unsafe and discouraged:

https://docs.djangoproject.com/en/dev/ref/applications/#django.apps.AppConfig.ready

Instead I opted to add a management command to delete all tokens. It should be run before starting the server, and only needs to be done once. I also added it to the Docker image and will remove once its consumers are updated.

Oh, and there is a new Admin view to inspect the new ExpiringToken. The old Token view still works, but does not show last_used.

@mbakke mbakke marked this pull request as ready for review June 11, 2021 11:51
mbakke added 3 commits June 11, 2021 13:51
* mreg/api/views.py (ObtainExpiringAuthToken.post): Delete existing
token when logging in.
* mreg/authentication.py
(ExpiringTokenAuthentication.authenticate_credentials): Extend token
expiry time on usage.
* mreg/api/v1/tests/tests.py (MregAPITestCase.get_token_client): Set a
password for the test user.
(APITokenAutheticationTestCase): Rename to ...
(APITokenAuthenticationTestCase): ... this.
(APITokenAuthenticationTestCase.test_token_rotation): New test.
* mreg/models.py (ExpiringToken): New class.
* mreg/migrations/0012_expiringtoken.py: New migration.
* mreg/authentication.py (ExpiringTokenAuthentication.__init__):
Use ExpiringToken instead of Token.
(ExpiringTokenAuthentication.authenticate_credentials): Use
token.last_used instead of token.created.
* mreg/api/v1/tests/tests.py: Adjust to use ExpiringToken instead of
Token.
(APITokenAuthenticationTestCase.test_force_expire): Expire based on
last usage rather than creation time.
(APITokenAuthenticationTestCase.test_token_usage): New test.
* requirements-dev.txt: New file.
* mreg/admin.py (AuthTokenAdmin): New class.
* mreg/management/commands/delete_all_tokens.py: New file.
* ci/manifest.scm (mreg-wrapper): Run it before starting the server.
@mbakke mbakke merged commit 0e9deb9 into unioslo:master Jun 22, 2021
@mbakke mbakke deleted the token-rotation branch June 22, 2021 12:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants