Skip to content

Commit

Permalink
Refresh OAuth Tokens (#51)
Browse files Browse the repository at this point in the history
# 20221217

## Minor Changes
- Potentially improved user experience by making it so Discord logins refresh without you logging in again.
  • Loading branch information
freyamade authored Dec 17, 2022
1 parent 284ac97 commit 09cf22e
Show file tree
Hide file tree
Showing 10 changed files with 108 additions and 14 deletions.
45 changes: 45 additions & 0 deletions backend/api/management/commands/refresh_tokens.py
Original file line number Diff line number Diff line change
@@ -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()
9 changes: 9 additions & 0 deletions backend/api/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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')
1 change: 0 additions & 1 deletion backend/api/tests/test_etro.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
45 changes: 45 additions & 0 deletions backend/api/tests/test_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):
"""
Expand Down
4 changes: 4 additions & 0 deletions backend/backend/celery.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,8 @@
'task': 'cleanup',
'schedule': crontab(minute=0),
},
'refresh_oauth_tokens': {
'task': 'refresh_tokens',
'schedule': crontab(hour=0, minute=0),
}
}
2 changes: 1 addition & 1 deletion backend/backend/settings_live.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion frontend/.env
Original file line number Diff line number Diff line change
@@ -1 +1 @@
VUE_APP_VERSION="20221102"
VUE_APP_VERSION="20221217"
10 changes: 1 addition & 9 deletions frontend/src/components/modals/changelog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,9 @@
</div>
<div class="card-content content">
<h2 class="has-text-primary subtitle">{{ version }}</h2>
<div class="divider"><i class="material-icons icon">expand_more</i> Major Changes <i class="material-icons icon">expand_more</i></div>
<p>Added handling to detect if the backend is up and responding before loading data, to prevent random errors during deployments.</p>
<p>
Added a new "Update" button on Character pages to refresh Character data from the Lodestone.
<ul>
<li>This allows Characters who have moved worlds or changed names to have the current information on Savage Aim.</li>
</ul>
</p>

<div class="divider"><i class="material-icons icon">expand_more</i> Minor Changes <i class="material-icons icon">expand_more</i></div>
<p>Fixed some under-the-hood errors that may have had minor effects on the website.</p>
<p>Potentially improved user experience by making it so Discord logins refresh without you logging in again.</p>

</div>
</div>
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ Sentry.init({
Vue,
dsn: 'https://[email protected]/6180221',
logErrors: true,
release: 'savageaim@20221102',
release: 'savageaim@20221217',
})

new Vue({
Expand Down

0 comments on commit 09cf22e

Please sign in to comment.