diff --git a/backend/api/management/commands/refresh_tokens.py b/backend/api/management/commands/refresh_tokens.py new file mode 100644 index 00000000..1ae65237 --- /dev/null +++ b/backend/api/management/commands/refresh_tokens.py @@ -0,0 +1,45 @@ +from datetime import timedelta +import requests +from allauth.socialaccount.models import SocialApp, SocialToken +from django.core.management.base import BaseCommand +from django.utils import timezone + +THRESHOLD_HOURS = 24 +URL = 'https://discord.com/api/oauth2/token' + + +class Command(BaseCommand): + help = 'Refresh any discord oauth tokens that are expiring within the threshold' + + def handle(self, *args, **kwargs): + # We only have one social app + app = SocialApp.objects.first() + + # First, get all social tokens that expire within the threshold + current_time = timezone.now() + expiry_threshold = current_time + timedelta(hours=THRESHOLD_HOURS) + self.stdout.write(f'Searching for tokens expiring between {current_time} and {expiry_threshold}') + to_refresh = SocialToken.objects.filter(expires_at__lte=expiry_threshold, expires_at__gte=current_time) + self.stdout.write(f'Found {to_refresh.count()} tokens') + + # Loop through refreshable tokens and make requests + for token in to_refresh: + refresh_data = { + 'grant_type': 'refresh_token', + 'client_id': app.client_id, + 'client_secret': app.secret, + 'refresh_token': token.token_secret, + } + self.stdout.write(f'Refreshing token for {token.account.user.username}') + + response = requests.post(URL, refresh_data) + if response.status_code != 200: + self.stderr.write(f'Token refresh failed: {response.content}') + continue + + # Save the updated data for the token + response_data = response.json() + token.token = response_data['access_token'] + token.token_secret = response_data['refresh_token'] + token.expires_at = timezone.now() + timedelta(seconds=response_data['expires_in']) + token.save() diff --git a/backend/api/tasks.py b/backend/api/tasks.py index 99ac4086..8785516f 100644 --- a/backend/api/tasks.py +++ b/backend/api/tasks.py @@ -13,6 +13,7 @@ from celery import shared_task from celery.utils.log import get_task_logger from channels.layers import get_channel_layer +from django.core.management import call_command from django.utils import timezone # local from . import notifier @@ -132,3 +133,11 @@ def cleanup(): objs = Character.objects.filter(verified=False, user__isnull=False, created__lt=older_than) logger.debug(f'Found {objs.count()} characters. Deleting them.') objs.delete() + + +@shared_task(name='refresh_tokens') +def refresh_tokens(): + """ + Refresh any tokens that are about to expire + """ + call_command('refresh_tokens') diff --git a/backend/api/tests/test_etro.py b/backend/api/tests/test_etro.py index d38761da..e558396d 100644 --- a/backend/api/tests/test_etro.py +++ b/backend/api/tests/test_etro.py @@ -58,4 +58,3 @@ def test_import_400(self): self.client.force_authenticate(user) response = self.client.get(url) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.json()['message'], '404 Not Found') diff --git a/backend/api/tests/test_tasks.py b/backend/api/tests/test_tasks.py index 4bc751b7..12128498 100644 --- a/backend/api/tests/test_tasks.py +++ b/backend/api/tests/test_tasks.py @@ -4,6 +4,7 @@ from typing import Dict from unittest.mock import patch # lib +from allauth.socialaccount.models import SocialAccount, SocialApp, SocialToken from django.core.management import call_command from django.utils import timezone # local @@ -52,6 +53,24 @@ def get_error_response(url: str, headers: Dict[str, str]): return type('response', (), {'status_code': 400}) +def get_token_response(url: str, data: Dict[str, str]): + """ + Return a faked http response for a valid refresh token + """ + return type( + 'response', + (), + { + 'status_code': 200, + 'json': lambda: { + 'access_token': 'new access token', + 'refresh_token': 'new refresh token', + 'expires_in': 60 * 60 * 24, + }, + }, + ) + + class TasksTestSuite(SavageAimTestCase): """ Test the functions in the tasks file to make sure they work as intended @@ -113,6 +132,32 @@ def test_cleanup(self): Character.objects.get(pk=old_unver.pk) self.assertEqual(Character.objects.filter(pk__in=[old_ver.pk, new_unver.pk, proxy.pk]).count(), 3) + @patch('requests.post', side_effect=get_token_response) + def test_token_refresh(self, mocked_post): + """ + Test a refresh token attempt while mocking the request + """ + app = SocialApp.objects.create() + account = SocialAccount.objects.create( + user=self._get_user(), + ) + token = SocialToken.objects.create( + expires_at=timezone.now() + timedelta(hours=6), + token='current token', + token_secret='current secret', + account_id=account.id, + app_id=1, + ) + + call_command('refresh_tokens', stdout=StringIO(), stderr=StringIO()) + new_token_data = SocialToken.objects.first() + + self.assertNotEqual(token.token, new_token_data.token) + self.assertNotEqual(token.token_secret, new_token_data.token_secret) + self.assertGreater(new_token_data.expires_at, token.expires_at) + + app.delete() + @patch('requests.get', side_effect=get_desktop_response) def test_verify_character(self, mocked_get): """ diff --git a/backend/backend/celery.py b/backend/backend/celery.py index caced241..e379a234 100644 --- a/backend/backend/celery.py +++ b/backend/backend/celery.py @@ -22,4 +22,8 @@ 'task': 'cleanup', 'schedule': crontab(minute=0), }, + 'refresh_oauth_tokens': { + 'task': 'refresh_tokens', + 'schedule': crontab(hour=0, minute=0), + } } diff --git a/backend/backend/settings_live.py b/backend/backend/settings_live.py index be418b43..80963a10 100644 --- a/backend/backend/settings_live.py +++ b/backend/backend/settings_live.py @@ -178,7 +178,7 @@ def sampler(context): # If you wish to associate users to errors (assuming you are using # django.contrib.auth) you may enable sending PII data. send_default_pii=True, - release='savageaim@20221102', + release='savageaim@20221217', ) # Channels diff --git a/backend/requirements.txt b/backend/requirements.txt index e52affeb..c91b9f2c 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -8,7 +8,7 @@ Automat==20.2.0 beautifulsoup4==4.10.0 billiard==3.6.4.0 celery==5.2.3 -certifi==2021.10.8 +certifi==2022.12.7 cffi==1.15.0 channels==3.0.4 channels-redis==3.4.0 diff --git a/frontend/.env b/frontend/.env index 4c84fcc0..ba369aa2 100644 --- a/frontend/.env +++ b/frontend/.env @@ -1 +1 @@ -VUE_APP_VERSION="20221102" +VUE_APP_VERSION="20221217" diff --git a/frontend/src/components/modals/changelog.vue b/frontend/src/components/modals/changelog.vue index 43bd6dec..af4a40b1 100644 --- a/frontend/src/components/modals/changelog.vue +++ b/frontend/src/components/modals/changelog.vue @@ -12,17 +12,9 @@

{{ version }}

-
expand_more Major Changes expand_more
-

Added handling to detect if the backend is up and responding before loading data, to prevent random errors during deployments.

-

- Added a new "Update" button on Character pages to refresh Character data from the Lodestone. -

-

expand_more Minor Changes expand_more
-

Fixed some under-the-hood errors that may have had minor effects on the website.

+

Potentially improved user experience by making it so Discord logins refresh without you logging in again.

diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 5b921aa0..95130b73 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -27,7 +27,7 @@ Sentry.init({ Vue, dsn: 'https://06f41b525a40497a848fb726f6d03244@o242258.ingest.sentry.io/6180221', logErrors: true, - release: 'savageaim@20221102', + release: 'savageaim@20221217', }) new Vue({