Restructure Alliance Auth package (#867)

* Refactor allianceauth into its own package

* Add setup

* Add missing default_app_config declarations

* Fix timerboard namespacing

* Remove obsolete future imports

* Remove py2 mock support

* Remove six

* Add experimental 3.7 support and multiple Dj versions

* Remove python_2_unicode_compatible

* Add navhelper as local package

* Update requirements
This commit is contained in:
Basraah
2017-09-19 09:46:40 +10:00
committed by GitHub
parent d10580b56b
commit 786859294d
538 changed files with 1197 additions and 1523 deletions

View File

@@ -0,0 +1 @@
default_app_config = 'allianceauth.services.modules.discord.apps.DiscordServiceConfig'

View File

@@ -0,0 +1,9 @@
from django.contrib import admin
from .models import DiscordUser
class DiscordUserAdmin(admin.ModelAdmin):
list_display = ('user', 'uid')
search_fields = ('user__username', 'uid')
admin.site.register(DiscordUser, DiscordUserAdmin)

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class DiscordServiceConfig(AppConfig):
name = 'allianceauth.services.modules.discord'
label = 'discord'

View File

@@ -0,0 +1,54 @@
import logging
from django.template.loader import render_to_string
from allianceauth import hooks
from allianceauth.services.hooks import ServicesHook
from .tasks import DiscordTasks
from .urls import urlpatterns
logger = logging.getLogger(__name__)
class DiscordService(ServicesHook):
def __init__(self):
ServicesHook.__init__(self)
self.urlpatterns = urlpatterns
self.name = 'discord'
self.service_ctrl_template = 'registered/discord_service_ctrl.html'
self.access_perm = 'discord.access_discord'
def delete_user(self, user, notify_user=False):
logger.debug('Deleting user %s %s account' % (user, self.name))
return DiscordTasks.delete_user(user, notify_user=notify_user)
def update_groups(self, user):
logger.debug('Processing %s groups for %s' % (self.name, user))
if DiscordTasks.has_account(user):
DiscordTasks.update_groups.delay(user.pk)
def validate_user(self, user):
logger.debug('Validating user %s %s account' % (user, self.name))
if DiscordTasks.has_account(user) and not self.service_active_for_user(user):
self.delete_user(user, notify_user=True)
def sync_nickname(self, user):
logger.debug('Syncing %s nickname for user %s' % (self.name, user))
DiscordTasks.update_nickname.delay(user.pk)
def update_all_groups(self):
logger.debug('Update all %s groups called' % self.name)
DiscordTasks.update_all_groups.delay()
def service_active_for_user(self, user):
return user.has_perm(self.access_perm)
def render_services_ctrl(self, request):
return render_to_string(self.service_ctrl_template, {
'discord_uid': request.user.discord.uid if DiscordTasks.has_account(request.user) else None,
}, request=request)
@hooks.register('services_hook')
def register_service():
return DiscordService()

View File

@@ -0,0 +1,303 @@
import requests
import json
import re
from django.conf import settings
from requests_oauthlib import OAuth2Session
from functools import wraps
import logging
import datetime
import time
from django.core.cache import cache
from hashlib import md5
logger = logging.getLogger(__name__)
DISCORD_URL = "https://discordapp.com/api"
EVE_IMAGE_SERVER = "https://image.eveonline.com"
AUTH_URL = "https://discordapp.com/api/oauth2/authorize"
TOKEN_URL = "https://discordapp.com/api/oauth2/token"
"""
Previously all we asked for was permission to kick members, manage roles, and manage nicknames.
Users have reported weird unauthorized errors we don't understand. So now we ask for full server admin.
It's almost fixed the problem.
"""
# kick members, manage roles, manage nicknames
# BOT_PERMISSIONS = 0x00000002 + 0x10000000 + 0x08000000
BOT_PERMISSIONS = 0x00000008
# get user ID, accept invite
SCOPES = [
'identify',
'guilds.join',
]
GROUP_CACHE_MAX_AGE = int(getattr(settings, 'DISCORD_GROUP_CACHE_MAX_AGE', 2 * 60 * 60)) # 2 hours default
class DiscordApiException(Exception):
def __init__(self):
super(Exception, self).__init__()
class DiscordApiTooBusy(DiscordApiException):
def __init__(self):
super(DiscordApiException, self).__init__()
self.message = "The Discord API is too busy to process this request now, please try again later."
class DiscordApiBackoff(DiscordApiException):
def __init__(self, retry_after, global_ratelimit):
super(DiscordApiException, self).__init__()
self.retry_after = retry_after
self.global_ratelimit = global_ratelimit
cache_time_format = '%Y-%m-%d %H:%M:%S'
def api_backoff(func):
"""
Decorator, Handles HTTP 429 "Too Many Requests" messages from the Discord API
If blocking=True is specified, this function will block and retry
the function up to max_retries=n times, or 3 if retries is not specified.
If the API call still recieves a backoff timer this function will raise
a <DiscordApiTooBusy> exception.
If the caller chooses blocking=False, the decorator will raise a DiscordApiBackoff
exception and the caller can choose to retry after the given timespan available in
the retry_after property in seconds.
"""
class PerformBackoff(Exception):
def __init__(self, retry_after, retry_datetime, global_ratelimit):
super(Exception, self).__init__()
self.retry_after = int(retry_after)
self.retry_datetime = retry_datetime
self.global_ratelimit = global_ratelimit
@wraps(func)
def decorated(*args, **kwargs):
blocking = kwargs.get('blocking', False)
retries = kwargs.get('max_retries', 3)
# Strip our parameters
if 'max_retries' in kwargs:
del kwargs['max_retries']
if 'blocking' in kwargs:
del kwargs['blocking']
cache_key = 'DISCORD_BACKOFF_' + func.__name__
cache_global_key = 'DISCORD_BACKOFF_GLOBAL'
while retries > 0:
try:
try:
# Check global backoff first, then route backoff
existing_global_backoff = cache.get(cache_global_key)
existing_backoff = existing_global_backoff or cache.get(cache_key)
if existing_backoff:
backoff_timer = datetime.datetime.strptime(existing_backoff, cache_time_format)
if backoff_timer > datetime.datetime.utcnow():
backoff_seconds = (backoff_timer - datetime.datetime.utcnow()).total_seconds()
logger.debug("Still under backoff for {} seconds, backing off" % backoff_seconds)
# Still under backoff
raise PerformBackoff(
retry_after=backoff_seconds,
retry_datetime=backoff_timer,
global_ratelimit=bool(existing_global_backoff)
)
logger.debug("Calling API calling function")
func(*args, **kwargs)
break
except requests.HTTPError as e:
if e.response.status_code == 429:
try:
retry_after = int(e.response.headers['Retry-After'])
except (TypeError, KeyError):
# Pick some random time
retry_after = 5
logger.info("Received backoff from API of %s seconds, handling" % retry_after)
# Store value in redis
backoff_until = (datetime.datetime.utcnow() +
datetime.timedelta(seconds=retry_after))
global_backoff = bool(e.response.headers.get('X-RateLimit-Global', False))
if global_backoff:
logger.info("Global backoff!!")
cache.set(cache_global_key, backoff_until.strftime(cache_time_format), retry_after)
else:
cache.set(cache_key, backoff_until.strftime(cache_time_format), retry_after)
raise PerformBackoff(retry_after=retry_after, retry_datetime=backoff_until,
global_ratelimit=global_backoff)
else:
# Not 429, re-raise
raise e
except PerformBackoff as bo:
# Sleep if we're blocking
if blocking:
logger.info("Blocking Back off from API calls for %s seconds" % bo.retry_after)
time.sleep(10 if bo.retry_after > 10 else bo.retry_after)
else:
# Otherwise raise exception and let caller handle the backoff
raise DiscordApiBackoff(retry_after=bo.retry_after, global_ratelimit=bo.global_ratelimit)
finally:
retries -= 1
if retries == 0:
raise DiscordApiTooBusy()
return decorated
class DiscordOAuthManager:
def __init__(self):
pass
@staticmethod
def _sanitize_name(name):
return re.sub('[^\w.-]', '', name)[:32]
@staticmethod
def _sanitize_groupname(name):
name = name.strip(' _')
return DiscordOAuthManager._sanitize_name(name)
@staticmethod
def generate_bot_add_url():
return AUTH_URL + '?client_id=' + settings.DISCORD_APP_ID + '&scope=bot&permissions=' + str(BOT_PERMISSIONS)
@staticmethod
def generate_oauth_redirect_url():
oauth = OAuth2Session(settings.DISCORD_APP_ID, redirect_uri=settings.DISCORD_CALLBACK_URL, scope=SCOPES)
url, state = oauth.authorization_url(AUTH_URL)
return url
@staticmethod
def _process_callback_code(code):
oauth = OAuth2Session(settings.DISCORD_APP_ID, redirect_uri=settings.DISCORD_CALLBACK_URL)
token = oauth.fetch_token(TOKEN_URL, client_secret=settings.DISCORD_APP_SECRET, code=code)
return token
@staticmethod
def add_user(code):
try:
token = DiscordOAuthManager._process_callback_code(code)['access_token']
logger.debug("Received token from OAuth")
custom_headers = {'accept': 'application/json', 'authorization': 'Bearer ' + token}
path = DISCORD_URL + "/invites/" + str(settings.DISCORD_INVITE_CODE)
r = requests.post(path, headers=custom_headers)
logger.debug("Got status code %s after accepting Discord invite" % r.status_code)
r.raise_for_status()
path = DISCORD_URL + "/users/@me"
r = requests.get(path, headers=custom_headers)
logger.debug("Got status code %s after retrieving Discord profile" % r.status_code)
r.raise_for_status()
user_id = r.json()['id']
logger.info("Added Discord user ID %s to server." % user_id)
return user_id
except:
logger.exception("Failed to add Discord user")
return None
@staticmethod
def update_nickname(user_id, nickname):
try:
nickname = DiscordOAuthManager._sanitize_name(nickname)
custom_headers = {'content-type': 'application/json', 'authorization': 'Bot ' + settings.DISCORD_BOT_TOKEN}
data = {'nick': nickname}
path = DISCORD_URL + "/guilds/" + str(settings.DISCORD_GUILD_ID) + "/members/" + str(user_id)
r = requests.patch(path, headers=custom_headers, json=data)
logger.debug("Got status code %s after setting nickname for Discord user ID %s (%s)" % (
r.status_code, user_id, nickname))
if r.status_code == 404:
logger.warn("Discord user ID %s could not be found in server." % user_id)
return True
r.raise_for_status()
return True
except:
logger.exception("Failed to set nickname for Discord user ID %s (%s)" % (user_id, nickname))
return False
@staticmethod
def delete_user(user_id):
try:
custom_headers = {'accept': 'application/json', 'authorization': 'Bot ' + settings.DISCORD_BOT_TOKEN}
path = DISCORD_URL + "/guilds/" + str(settings.DISCORD_GUILD_ID) + "/members/" + str(user_id)
r = requests.delete(path, headers=custom_headers)
logger.debug("Got status code %s after removing Discord user ID %s" % (r.status_code, user_id))
if r.status_code == 404:
logger.warn("Discord user ID %s already left the server." % user_id)
return True
r.raise_for_status()
return True
except:
logger.exception("Failed to remove Discord user ID %s" % user_id)
return False
@staticmethod
def _get_groups():
custom_headers = {'accept': 'application/json', 'authorization': 'Bot ' + settings.DISCORD_BOT_TOKEN}
path = DISCORD_URL + "/guilds/" + str(settings.DISCORD_GUILD_ID) + "/roles"
r = requests.get(path, headers=custom_headers)
logger.debug("Got status code %s after retrieving Discord roles" % r.status_code)
r.raise_for_status()
return r.json()
@staticmethod
def _generate_cache_role_key(name):
return 'DISCORD_ROLE_NAME__%s' % md5(str(name).encode('utf-8')).hexdigest()
@staticmethod
def _group_name_to_id(name):
name = DiscordOAuthManager._sanitize_groupname(name)
def get_or_make_role():
groups = DiscordOAuthManager._get_groups()
for g in groups:
if g['name'] == name:
return g['id']
return DiscordOAuthManager._create_group(name)['id']
return cache.get_or_set(DiscordOAuthManager._generate_cache_role_key(name), get_or_make_role, GROUP_CACHE_MAX_AGE)
@staticmethod
def __generate_role():
custom_headers = {'accept': 'application/json', 'authorization': 'Bot ' + settings.DISCORD_BOT_TOKEN}
path = DISCORD_URL + "/guilds/" + str(settings.DISCORD_GUILD_ID) + "/roles"
r = requests.post(path, headers=custom_headers)
logger.debug("Received status code %s after generating new role." % r.status_code)
r.raise_for_status()
return r.json()
@staticmethod
def __edit_role(role_id, name, color=0, hoist=True, permissions=36785152):
custom_headers = {'content-type': 'application/json', 'authorization': 'Bot ' + settings.DISCORD_BOT_TOKEN}
data = {
'color': color,
'hoist': hoist,
'name': name,
'permissions': permissions,
}
path = DISCORD_URL + "/guilds/" + str(settings.DISCORD_GUILD_ID) + "/roles/" + str(role_id)
r = requests.patch(path, headers=custom_headers, data=json.dumps(data))
logger.debug("Received status code %s after editing role id %s" % (r.status_code, role_id))
r.raise_for_status()
return r.json()
@staticmethod
def _create_group(name):
role = DiscordOAuthManager.__generate_role()
return DiscordOAuthManager.__edit_role(role['id'], name)
@staticmethod
@api_backoff
def update_groups(user_id, groups):
custom_headers = {'content-type': 'application/json', 'authorization': 'Bot ' + settings.DISCORD_BOT_TOKEN}
group_ids = [DiscordOAuthManager._group_name_to_id(DiscordOAuthManager._sanitize_groupname(g)) for g in groups]
path = DISCORD_URL + "/guilds/" + str(settings.DISCORD_GUILD_ID) + "/members/" + str(user_id)
data = {'roles': group_ids}
r = requests.patch(path, headers=custom_headers, json=data)
logger.debug("Received status code %s after setting user roles" % r.status_code)
r.raise_for_status()

View File

@@ -0,0 +1,26 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.2 on 2016-12-12 03:14
from __future__ import unicode_literals
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0008_alter_user_username_max_length'),
]
operations = [
migrations.CreateModel(
name='DiscordUser',
fields=[
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, related_name='discord', serialize=False, to=settings.AUTH_USER_MODEL)),
('uid', models.CharField(max_length=254)),
],
),
]

View File

@@ -0,0 +1,61 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.5 on 2017-02-02 05:59
from __future__ import unicode_literals
from django.db import migrations
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
from django.contrib.auth.management import create_permissions
import logging
logger = logging.getLogger(__name__)
def migrate_service_enabled(apps, schema_editor):
for app_config in apps.get_app_configs():
app_config.models_module = True
create_permissions(app_config, apps=apps, verbosity=0)
app_config.models_module = None
Group = apps.get_model("auth", "Group")
Permission = apps.get_model("auth", "Permission")
DiscordUser = apps.get_model("discord", "DiscordUser")
perm = Permission.objects.get(codename='access_discord')
member_group_name = getattr(settings, str('DEFAULT_AUTH_GROUP'), 'Member')
blue_group_name = getattr(settings, str('DEFAULT_BLUE_GROUP'), 'Blue')
# Migrate members
if DiscordUser.objects.filter(user__groups__name=member_group_name).exists() or \
getattr(settings, str('ENABLE_AUTH_DISCORD'), False):
try:
group = Group.objects.get(name=member_group_name)
group.permissions.add(perm)
except ObjectDoesNotExist:
logger.warning('Failed to migrate ENABLE_AUTH_DISCORD setting')
# Migrate blues
if DiscordUser.objects.filter(user__groups__name=blue_group_name).exists() or \
getattr(settings, str('ENABLE_BLUE_DISCORD'), False):
try:
group = Group.objects.get(name=blue_group_name)
group.permissions.add(perm)
except ObjectDoesNotExist:
logger.warning('Failed to migrate ENABLE_BLUE_DISCORD setting')
class Migration(migrations.Migration):
dependencies = [
('discord', '0001_initial'),
]
operations = [
migrations.AlterModelOptions(
name='discorduser',
options={'permissions': (('access_discord', 'Can access the Discord service'),)},
),
migrations.RunPython(migrate_service_enabled),
]

View File

@@ -0,0 +1,18 @@
from django.contrib.auth.models import User
from django.db import models
class DiscordUser(models.Model):
user = models.OneToOneField(User,
primary_key=True,
on_delete=models.CASCADE,
related_name='discord')
uid = models.CharField(max_length=254)
def __str__(self):
return "{} - {}".format(self.user.username, self.uid)
class Meta:
permissions = (
("access_discord", u"Can access the Discord service"),
)

View File

@@ -0,0 +1,128 @@
import logging
from django.conf import settings
from django.contrib.auth.models import User
from django.core.exceptions import ObjectDoesNotExist
from allianceauth.notifications import notify
from allianceauth.celeryapp import app
from .manager import DiscordOAuthManager, DiscordApiBackoff
from .models import DiscordUser
logger = logging.getLogger(__name__)
class DiscordTasks:
def __init__(self):
pass
@classmethod
def add_user(cls, user, code):
user_id = DiscordOAuthManager.add_user(code)
if user_id:
discord_user = DiscordUser()
discord_user.user = user
discord_user.uid = user_id
discord_user.save()
if settings.DISCORD_SYNC_NAMES:
cls.update_nickname.delay(user.pk)
cls.update_groups.delay(user.pk)
return True
return False
@classmethod
def delete_user(cls, user, notify_user=False):
if cls.has_account(user):
logger.debug("User %s has discord account %s. Deleting." % (user, user.discord.uid))
if DiscordOAuthManager.delete_user(user.discord.uid):
user.discord.delete()
if notify_user:
notify(user, 'Discord Account Disabled', level='danger')
return True
return False
@classmethod
def has_account(cls, user):
"""
Check if the user has an account (has a DiscordUser record)
:param user: django.contrib.auth.models.User
:return: bool
"""
try:
user.discord
except ObjectDoesNotExist:
return False
else:
return True
@staticmethod
@app.task(bind=True, name='discord.update_groups')
def update_groups(task_self, pk):
user = User.objects.get(pk=pk)
logger.debug("Updating discord groups for user %s" % user)
if DiscordTasks.has_account(user):
groups = []
for group in user.groups.all():
groups.append(str(group.name))
if len(groups) == 0:
logger.debug("No syncgroups found for user. Adding empty group.")
groups.append('empty')
logger.debug("Updating user %s discord groups to %s" % (user, groups))
try:
DiscordOAuthManager.update_groups(user.discord.uid, groups)
except DiscordApiBackoff as bo:
logger.info("Discord group sync API back off for %s, "
"retrying in %s seconds" % (user, bo.retry_after))
raise task_self.retry(countdown=bo.retry_after)
except Exception as e:
if task_self:
logger.exception("Discord group sync failed for %s, retrying in 10 mins" % user)
raise task_self.retry(countdown=60 * 10)
else:
# Rethrow
raise e
logger.debug("Updated user %s discord groups." % user)
else:
logger.debug("User does not have a discord account, skipping")
@staticmethod
@app.task(name='discord.update_all_groups')
def update_all_groups():
logger.debug("Updating ALL discord groups")
for discord_user in DiscordUser.objects.exclude(uid__exact=''):
DiscordTasks.update_groups.delay(discord_user.user.pk)
@staticmethod
@app.task(bind=True, name='discord.update_nickname')
def update_nickname(self, pk):
user = User.objects.get(pk=pk)
logger.debug("Updating discord nickname for user %s" % user)
if DiscordTasks.has_account(user):
if user.profile.main_character:
character = user.profile.main_character
logger.debug("Updating user %s discord nickname to %s" % (user, character.character_name))
try:
DiscordOAuthManager.update_nickname(user.discord.uid, character.character_name)
except Exception as e:
if self:
logger.exception("Discord nickname sync failed for %s, retrying in 10 mins" % user)
raise self.retry(countdown=60 * 10)
else:
# Rethrow
raise e
logger.debug("Updated user %s discord nickname." % user)
else:
logger.debug("User %s does not have a main character" % user)
else:
logger.debug("User %s does not have a discord account" % user)
@staticmethod
@app.task(name='discord.update_all_nicknames')
def update_all_nicknames():
logger.debug("Updating ALL discord nicknames")
for discord_user in DiscordUser.objects.exclude(uid__exact=''):
DiscordTasks.update_nickname.delay(discord_user.user.pk)
@classmethod
def disable(cls):
DiscordUser.objects.all().delete()

View File

@@ -0,0 +1,27 @@
{% load i18n %}
<tr>
<td class="text-center">Discord</td>
<td class="text-center"></td>
<td class="text-center"><a href="https://discordapp.com/channels/{{ DISCORD_SERVER_ID }}/{{ DISCORD_SERVER_ID}}">https://discordapp.com</a></td>
<td class="text-center">
{% if not discord_uid %}
<a href="{% url 'auth_activate_discord' %}" title="Activate" class="btn btn-warning">
<span class="glyphicon glyphicon-ok"></span>
</a>
{% else %}
<a href="{% url 'auth_reset_discord' %}" title="Reset" class="btn btn-primary">
<span class="glyphicon glyphicon-refresh"></span>
</a>
<a href="{% url 'auth_deactivate_discord' %}" title="Deactivate" class="btn btn-danger">
<span class="glyphicon glyphicon-remove"></span>
</a>
{% endif %}
{% if request.user.is_superuser %}
<div class="text-center" style="padding-top:5px;">
<a type="button" class="btn btn-success" href="{% url 'auth_discord_add_bot' %}">{% trans "Link Discord Server" %}</a>
</div>
{% endif %}
</td>
</tr>

View File

@@ -0,0 +1,442 @@
from unittest import mock
from django.test import TestCase, RequestFactory
from django.contrib.auth.models import User, Group, Permission
from django.core.exceptions import ObjectDoesNotExist
from django.conf import settings
from allianceauth.tests.auth_utils import AuthUtils
from .auth_hooks import DiscordService
from .models import DiscordUser
from .tasks import DiscordTasks
from .manager import DiscordOAuthManager
import requests_mock
import datetime
MODULE_PATH = 'allianceauth.services.modules.discord'
DEFAULT_AUTH_GROUP = 'Member'
def add_permissions():
permission = Permission.objects.get(codename='access_discord')
members = Group.objects.get_or_create(name=DEFAULT_AUTH_GROUP)[0]
AuthUtils.add_permissions_to_groups([permission], [members])
class DiscordHooksTestCase(TestCase):
def setUp(self):
self.member = 'member_user'
member = AuthUtils.create_member(self.member)
DiscordUser.objects.create(user=member, uid='12345')
self.none_user = 'none_user'
none_user = AuthUtils.create_user(self.none_user)
self.service = DiscordService
add_permissions()
def test_has_account(self):
member = User.objects.get(username=self.member)
none_user = User.objects.get(username=self.none_user)
self.assertTrue(DiscordTasks.has_account(member))
self.assertFalse(DiscordTasks.has_account(none_user))
def test_service_enabled(self):
service = self.service()
member = User.objects.get(username=self.member)
none_user = User.objects.get(username=self.none_user)
self.assertTrue(service.service_active_for_user(member))
self.assertFalse(service.service_active_for_user(none_user))
@mock.patch(MODULE_PATH + '.tasks.DiscordOAuthManager')
def test_update_all_groups(self, manager):
service = self.service()
service.update_all_groups()
# Check member and blue user have groups updated
self.assertTrue(manager.update_groups.called)
self.assertEqual(manager.update_groups.call_count, 1)
def test_update_groups(self):
# Check member has Member group updated
with mock.patch(MODULE_PATH + '.tasks.DiscordOAuthManager') as manager:
service = self.service()
member = User.objects.get(username=self.member)
AuthUtils.disconnect_signals()
service.update_groups(member)
self.assertTrue(manager.update_groups.called)
args, kwargs = manager.update_groups.call_args
user_id, groups = args
self.assertIn(DEFAULT_AUTH_GROUP, groups)
self.assertEqual(user_id, member.discord.uid)
# Check none user does not have groups updated
with mock.patch(MODULE_PATH + '.tasks.DiscordOAuthManager') as manager:
service = self.service()
none_user = User.objects.get(username=self.none_user)
service.update_groups(none_user)
self.assertFalse(manager.update_groups.called)
@mock.patch(MODULE_PATH + '.tasks.DiscordOAuthManager')
def test_validate_user(self, manager):
service = self.service()
# Test member is not deleted
member = User.objects.get(username=self.member)
service.validate_user(member)
self.assertTrue(member.discord)
# Test none user is deleted
none_user = User.objects.get(username=self.none_user)
DiscordUser.objects.create(user=none_user, uid='abc123')
service.validate_user(none_user)
self.assertTrue(manager.delete_user.called)
with self.assertRaises(ObjectDoesNotExist):
none_discord = User.objects.get(username=self.none_user).discord
@mock.patch(MODULE_PATH + '.tasks.DiscordOAuthManager')
def test_sync_nickname(self, manager):
service = self.service()
member = User.objects.get(username=self.member)
AuthUtils.add_main_character(member, 'test user', '12345', corp_ticker='AAUTH')
service.sync_nickname(member)
self.assertTrue(manager.update_nickname.called)
args, kwargs = manager.update_nickname.call_args
self.assertEqual(args[0], member.discord.uid)
self.assertEqual(args[1], 'test user')
@mock.patch(MODULE_PATH + '.tasks.DiscordOAuthManager')
def test_delete_user(self, manager):
member = User.objects.get(username=self.member)
service = self.service()
result = service.delete_user(member)
self.assertTrue(result)
self.assertTrue(manager.delete_user.called)
with self.assertRaises(ObjectDoesNotExist):
discord_user = User.objects.get(username=self.member).discord
def test_render_services_ctrl(self):
service = self.service()
member = User.objects.get(username=self.member)
request = RequestFactory().get('/en/services/')
request.user = member
response = service.render_services_ctrl(request)
self.assertTemplateUsed(service.service_ctrl_template)
self.assertIn('/discord/reset/', response)
self.assertIn('/discord/deactivate/', response)
# Test register becomes available
member.discord.delete()
member = User.objects.get(username=self.member)
request.user = member
response = service.render_services_ctrl(request)
self.assertIn('/discord/activate/', response)
# TODO: Test update nicknames
class DiscordViewsTestCase(TestCase):
def setUp(self):
self.member = AuthUtils.create_member('auth_member')
self.member.set_password('password')
self.member.save()
add_permissions()
def login(self):
self.client.login(username=self.member.username, password='password')
@mock.patch(MODULE_PATH + '.views.DiscordOAuthManager')
def test_activate(self, manager):
self.login()
manager.generate_oauth_redirect_url.return_value = '/example.com/oauth/'
response = self.client.get('/discord/activate/', follow=False)
self.assertRedirects(response, expected_url='/example.com/oauth/', target_status_code=404)
@mock.patch(MODULE_PATH + '.tasks.DiscordOAuthManager')
def test_callback(self, manager):
self.login()
manager.add_user.return_value = '1234'
response = self.client.get('/discord/callback/', data={'code': '1234'})
self.member = User.objects.get(pk=self.member.pk)
self.assertTrue(manager.add_user.called)
self.assertEqual(manager.update_nickname.called, settings.DISCORD_SYNC_NAMES)
self.assertEqual(self.member.discord.uid, '1234')
self.assertRedirects(response, expected_url='/en/services/', target_status_code=200)
@mock.patch(MODULE_PATH + '.tasks.DiscordOAuthManager')
def test_reset(self, manager):
self.login()
DiscordUser.objects.create(user=self.member, uid='12345')
manager.delete_user.return_value = True
response = self.client.get('/discord/reset/')
self.assertRedirects(response, expected_url='/discord/activate/', target_status_code=302)
@mock.patch(MODULE_PATH + '.tasks.DiscordOAuthManager')
def test_deactivate(self, manager):
self.login()
DiscordUser.objects.create(user=self.member, uid='12345')
manager.delete_user.return_value = True
response = self.client.get('/discord/deactivate/')
self.assertTrue(manager.delete_user.called)
self.assertRedirects(response, expected_url='/en/services/', target_status_code=200)
with self.assertRaises(ObjectDoesNotExist):
discord_user = User.objects.get(pk=self.member.pk).discord
class DiscordManagerTestCase(TestCase):
def setUp(self):
pass
def test__sanitize_groupname(self):
test_group_name = ' Group Name_Test_'
group_name = DiscordOAuthManager._sanitize_groupname(test_group_name)
self.assertEqual(group_name, 'GroupName_Test')
def test_generate_Bot_add_url(self):
from . import manager
bot_add_url = DiscordOAuthManager.generate_bot_add_url()
auth_url = manager.AUTH_URL
real_bot_add_url = '{}?client_id=appid&scope=bot&permissions={}'.format(auth_url, manager.BOT_PERMISSIONS)
self.assertEqual(bot_add_url, real_bot_add_url)
def test_generate_oauth_redirect_url(self):
from . import manager
import urllib
import sys
oauth_url = DiscordOAuthManager.generate_oauth_redirect_url()
self.assertIn(manager.AUTH_URL, oauth_url)
self.assertIn('+'.join(manager.SCOPES), oauth_url)
self.assertIn(settings.DISCORD_APP_ID, oauth_url)
if sys.version_info[0] < 3:
# Py2
self.assertIn(urllib.quote_plus(settings.DISCORD_CALLBACK_URL), oauth_url)
else: # Py3
self.assertIn(urllib.parse.quote_plus(settings.DISCORD_CALLBACK_URL), oauth_url)
@mock.patch(MODULE_PATH + '.manager.OAuth2Session')
def test__process_callback_code(self, oauth):
from . import manager
instance = oauth.return_value
instance.fetch_token.return_value = {'access_token': 'mywonderfultoken'}
token = DiscordOAuthManager._process_callback_code('12345')
self.assertTrue(oauth.called)
args, kwargs = oauth.call_args
self.assertEqual(args[0], settings.DISCORD_APP_ID)
self.assertEqual(kwargs['redirect_uri'], settings.DISCORD_CALLBACK_URL)
self.assertTrue(instance.fetch_token.called)
args, kwargs = instance.fetch_token.call_args
self.assertEqual(args[0], manager.TOKEN_URL)
self.assertEqual(kwargs['client_secret'], settings.DISCORD_APP_SECRET)
self.assertEqual(kwargs['code'], '12345')
self.assertEqual(token['access_token'], 'mywonderfultoken')
@mock.patch(MODULE_PATH + '.manager.DiscordOAuthManager._process_callback_code')
@requests_mock.Mocker()
def test_add_user(self, oauth_token, m):
from . import manager
import json
# Arrange
oauth_token.return_value = {'access_token': 'accesstoken'}
headers = {'accept': 'application/json', 'authorization': 'Bearer accesstoken'}
m.register_uri('POST',
manager.DISCORD_URL + '/invites/' + str(settings.DISCORD_INVITE_CODE),
request_headers=headers,
text='{}')
m.register_uri('GET',
manager.DISCORD_URL + "/users/@me",
request_headers=headers,
text=json.dumps({'id': "123456"}))
# Act
return_value = DiscordOAuthManager.add_user('abcdef')
# Assert
self.assertEqual(return_value, '123456')
self.assertEqual(m.call_count, 2)
@requests_mock.Mocker()
def test_delete_user(self, m):
from . import manager
import json
# Arrange
headers = {'accept': 'application/json', 'authorization': 'Bot ' + settings.DISCORD_BOT_TOKEN}
user_id = 12345
request_url = '{}/guilds/{}/members/{}'.format(manager.DISCORD_URL, settings.DISCORD_GUILD_ID, user_id)
m.register_uri('DELETE',
request_url,
request_headers=headers,
text=json.dumps({}))
# Act
result = DiscordOAuthManager.delete_user(user_id)
# Assert
self.assertTrue(result)
###
# Test 404 (already deleted)
# Arrange
m.register_uri('DELETE',
request_url,
request_headers=headers,
status_code=404)
# Act
result = DiscordOAuthManager.delete_user(user_id)
# Assert
self.assertTrue(result)
###
# Test 500 (some random API error)
# Arrange
m.register_uri('DELETE',
request_url,
request_headers=headers,
status_code=500)
# Act
result = DiscordOAuthManager.delete_user(user_id)
# Assert
self.assertFalse(result)
@requests_mock.Mocker()
def test_update_nickname(self, m):
from . import manager
# Arrange
headers = {'content-type': 'application/json', 'authorization': 'Bot ' + settings.DISCORD_BOT_TOKEN}
user_id = 12345
request_url = '{}/guilds/{}/members/{}'.format(manager.DISCORD_URL, settings.DISCORD_GUILD_ID, user_id)
m.patch(request_url,
request_headers=headers)
# Act
result = DiscordOAuthManager.update_nickname(user_id, 'somenick')
# Assert
self.assertTrue(result)
@mock.patch(MODULE_PATH + '.manager.DiscordOAuthManager._get_groups')
@requests_mock.Mocker()
def test_update_groups(self, group_cache, m):
from . import manager
import json
# Arrange
groups = ['Member', 'Blue', 'Special Group']
group_cache.return_value = [{'id': 111, 'name': 'Member'},
{'id': 222, 'name': 'Blue'},
{'id': 333, 'name': 'SpecialGroup'},
{'id': 444, 'name': 'NotYourGroup'}]
headers = {'content-type': 'application/json', 'authorization': 'Bot ' + settings.DISCORD_BOT_TOKEN}
user_id = 12345
request_url = '{}/guilds/{}/members/{}'.format(manager.DISCORD_URL, settings.DISCORD_GUILD_ID, user_id)
m.patch(request_url,
request_headers=headers)
# Act
DiscordOAuthManager.update_groups(user_id, groups)
# Assert
self.assertEqual(len(m.request_history), 1, 'Must be one HTTP call made')
history = json.loads(m.request_history[0].text)
self.assertIn('roles', history, "'The request must send JSON object with the 'roles' key")
self.assertIn(111, history['roles'], 'The group id 111 must be added to the request')
self.assertIn(222, history['roles'], 'The group id 222 must be added to the request')
self.assertIn(333, history['roles'], 'The group id 333 must be added to the request')
self.assertNotIn(444, history['roles'], 'The group id 444 must NOT be added to the request')
@mock.patch(MODULE_PATH + '.manager.cache')
@mock.patch(MODULE_PATH + '.manager.DiscordOAuthManager._get_groups')
@requests_mock.Mocker()
def test_update_groups_backoff(self, group_cache, djcache, m):
from . import manager
# Arrange
groups = ['Member']
group_cache.return_value = [{'id': 111, 'name': 'Member'}]
headers = {'content-type': 'application/json', 'authorization': 'Bot ' + settings.DISCORD_BOT_TOKEN}
user_id = 12345
request_url = '{}/guilds/{}/members/{}'.format(manager.DISCORD_URL, settings.DISCORD_GUILD_ID, user_id)
djcache.get.return_value = None # No existing backoffs in cache
m.patch(request_url,
request_headers=headers,
headers={'Retry-After': '200'},
status_code=429)
# Act & Assert
with self.assertRaises(manager.DiscordApiBackoff) as bo:
try:
DiscordOAuthManager.update_groups(user_id, groups, blocking=False)
except manager.DiscordApiBackoff as bo:
self.assertEqual(bo.retry_after, 200, 'Retry-After time must be equal to Retry-After set in header')
self.assertFalse(bo.global_ratelimit, 'global_ratelimit must be False')
raise bo
self.assertTrue(djcache.set.called)
args, kwargs = djcache.set.call_args
self.assertEqual(args[0], 'DISCORD_BACKOFF_update_groups')
self.assertTrue(datetime.datetime.strptime(args[1], manager.cache_time_format) > datetime.datetime.now())
@mock.patch(MODULE_PATH + '.manager.cache')
@mock.patch(MODULE_PATH + '.manager.DiscordOAuthManager._get_groups')
@requests_mock.Mocker()
def test_update_groups_global_backoff(self, group_cache, djcache, m):
from . import manager
# Arrange
groups = ['Member']
group_cache.return_value = [{'id': 111, 'name': 'Member'}]
headers = {'content-type': 'application/json', 'authorization': 'Bot ' + settings.DISCORD_BOT_TOKEN}
user_id = 12345
request_url = '{}/guilds/{}/members/{}'.format(manager.DISCORD_URL, settings.DISCORD_GUILD_ID, user_id)
djcache.get.return_value = None # No existing backoffs in cache
m.patch(request_url,
request_headers=headers,
headers={'Retry-After': '200', 'X-RateLimit-Global': 'true'},
status_code=429)
# Act & Assert
with self.assertRaises(manager.DiscordApiBackoff) as bo:
try:
DiscordOAuthManager.update_groups(user_id, groups, blocking=False)
except manager.DiscordApiBackoff as bo:
self.assertEqual(bo.retry_after, 200, 'Retry-After time must be equal to Retry-After set in header')
self.assertTrue(bo.global_ratelimit, 'global_ratelimit must be True')
raise bo
self.assertTrue(djcache.set.called)
args, kwargs = djcache.set.call_args
self.assertEqual(args[0], 'DISCORD_BACKOFF_GLOBAL')
self.assertTrue(datetime.datetime.strptime(args[1], manager.cache_time_format) > datetime.datetime.now())

View File

@@ -0,0 +1,16 @@
from django.conf.urls import url, include
from . import views
module_urls = [
# Discord Service Control
url(r'^activate/$', views.activate_discord, name='auth_activate_discord'),
url(r'^deactivate/$', views.deactivate_discord, name='auth_deactivate_discord'),
url(r'^reset/$', views.reset_discord, name='auth_reset_discord'),
url(r'^callback/$', views.discord_callback, name='auth_discord_callback'),
url(r'^add_bot/$', views.discord_add_bot, name='auth_discord_add_bot'),
]
urlpatterns = [
url(r'^discord/', include(module_urls))
]

View File

@@ -0,0 +1,70 @@
import logging
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.contrib.auth.decorators import permission_required
from django.contrib.auth.decorators import user_passes_test
from django.shortcuts import redirect
from allianceauth.services.views import superuser_test
from .manager import DiscordOAuthManager
from .tasks import DiscordTasks
logger = logging.getLogger(__name__)
ACCESS_PERM = 'discord.access_discord'
@login_required
@permission_required(ACCESS_PERM)
def deactivate_discord(request):
logger.debug("deactivate_discord called by user %s" % request.user)
if DiscordTasks.delete_user(request.user):
logger.info("Successfully deactivated discord for user %s" % request.user)
messages.success(request, 'Deactivated Discord account.')
else:
logger.error("Unsuccessful attempt to deactivate discord for user %s" % request.user)
messages.error(request, 'An error occurred while processing your Discord account.')
return redirect("auth_services")
@login_required
@permission_required(ACCESS_PERM)
def reset_discord(request):
logger.debug("reset_discord called by user %s" % request.user)
if DiscordTasks.delete_user(request.user):
logger.info("Successfully deleted discord user for user %s - forwarding to discord activation." % request.user)
return redirect("auth_activate_discord")
logger.error("Unsuccessful attempt to reset discord for user %s" % request.user)
messages.error(request, 'An error occurred while processing your Discord account.')
return redirect("auth_services")
@login_required
@permission_required(ACCESS_PERM)
def activate_discord(request):
logger.debug("activate_discord called by user %s" % request.user)
return redirect(DiscordOAuthManager.generate_oauth_redirect_url())
@login_required
@permission_required(ACCESS_PERM)
def discord_callback(request):
logger.debug("Received Discord callback for activation of user %s" % request.user)
code = request.GET.get('code', None)
if not code:
logger.warn("Did not receive OAuth code from callback of user %s" % request.user)
return redirect("auth_services")
if DiscordTasks.add_user(request.user, code):
logger.info("Successfully activated Discord for user %s" % request.user)
messages.success(request, 'Activated Discord account.')
else:
logger.error("Failed to activate Discord for user %s" % request.user)
messages.error(request, 'An error occurred while processing your Discord account.')
return redirect("auth_services")
@login_required
@user_passes_test(superuser_test)
def discord_add_bot(request):
return redirect(DiscordOAuthManager.generate_bot_add_url())