Discord OAuth Integration (#468)

* Implement Discord OAuth
 - extend group caching to Discord
 - use bot token to manipulate api
 - migrate to official API
Addresses #419

* Remove virtualenv wrapper

* Discord OAuth integration playtest corrections
Closes #419
This commit is contained in:
Adarnof 2016-06-13 00:16:27 +00:00 committed by GitHub
parent e6b08fca88
commit 1fd423e20f
11 changed files with 240 additions and 55 deletions

View File

@ -437,13 +437,19 @@ TEAMSPEAK3_PUBLIC_URL = os.environ.get('AA_TEAMSPEAK3_PUBLIC_URL', 'yourdomain.c
###################################### ######################################
# Discord Configuration # Discord Configuration
###################################### ######################################
# DISCORD_SERVER_ID - ID of the server to manage # DISCORD_GUILD_ID - ID of the guild to manage
# DISCORD_USER_EMAIL - email of the server management user # DISCORD_BOT_TOKEN - oauth token of the app bot user
# DISCORD_USER_PASSWORD - password of the server management user # DISCORD_INVITE_CODE - invite code to the server
# DISCORD_APP_ID - oauth app client ID
# DISCORD_APP_SECRET - oauth app secret
# DISCORD_CALLBACK_URL - oauth callback url
###################################### ######################################
DISCORD_SERVER_ID = os.environ.get('AA_DISCORD_SERVER_ID', '') DISCORD_GUILD_ID = os.environ.get('AA_DISCORD_GUILD_ID', '')
DISCORD_USER_EMAIL = os.environ.get('AA_DISCORD_USER_EMAIL', '') DISCORD_BOT_TOKEN = os.environ.get('AA_DISCORD_BOT_TOKEN', '')
DISCORD_USER_PASSWORD = os.environ.get('AA_DISCORD_USER_PASSWORD', '') DISCORD_INVITE_CODE = os.environ.get('AA_DISCORD_INVITE_CODE', '')
DISCORD_APP_ID = os.environ.get('AA_DISCORD_APP_ID', '')
DISCORD_APP_SECRET = os.environ.get('AA_DISCORD_APP_SECRET', '')
DISCORD_CALLBACK_URL = os.environ.get('AA_DISCORD_CALLBACK_URL', 'http://mydomain.com/discord_callback')
###################################### ######################################
# Discourse Configuration # Discourse Configuration

View File

@ -145,6 +145,8 @@ urlpatterns = patterns('',
url(r'^activate_discord/$', 'services.views.activate_discord', name='auth_activate_discord'), url(r'^activate_discord/$', 'services.views.activate_discord', name='auth_activate_discord'),
url(r'^deactivate_discord/$', 'services.views.deactivate_discord', name='auth_deactivate_discord'), url(r'^deactivate_discord/$', 'services.views.deactivate_discord', name='auth_deactivate_discord'),
url(r'^reset_discord/$', 'services.views.reset_discord', name='auth_reset_discord'), url(r'^reset_discord/$', 'services.views.reset_discord', name='auth_reset_discord'),
url(r'^discord_callback/$', 'services.views.discord_callback', name='auth_discord_callback'),
url(r'^discord_add_bot/$', 'services.views.discord_add_bot', name='auth_discord_add_bot'),
# Discourse Service Control # Discourse Service Control
url(r'^activate_discourse/$', 'services.views.activate_discourse', name='auth_activate_discourse'), url(r'^activate_discourse/$', 'services.views.activate_discourse', name='auth_activate_discourse'),

View File

@ -13,4 +13,9 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "alliance_auth.settings")
from django.core.wsgi import get_wsgi_application from django.core.wsgi import get_wsgi_application
# virtualenv wrapper
#activate_env=os.path.join(os.path.dirname(os.path.abspath(__file__)), 'env/bin/activate_this.py')
#execfile(activate_env, dict(__file__=activate_env))
application = get_wsgi_application() application = get_wsgi_application()

View File

@ -11,7 +11,7 @@ from services.managers.phpbb3_manager import Phpbb3Manager
from services.managers.ipboard_manager import IPBoardManager from services.managers.ipboard_manager import IPBoardManager
from services.managers.xenforo_manager import XenForoManager from services.managers.xenforo_manager import XenForoManager
from services.managers.teamspeak3_manager import Teamspeak3Manager from services.managers.teamspeak3_manager import Teamspeak3Manager
from services.managers.discord_manager import DiscordManager, DiscordAPIManager from services.managers.discord_manager import DiscordOAuthManager
from services.managers.discourse_manager import DiscourseManager from services.managers.discourse_manager import DiscourseManager
from services.managers.smf_manager import smfManager from services.managers.smf_manager import smfManager
from services.models import AuthTS from services.models import AuthTS
@ -212,7 +212,7 @@ def update_discord_groups(pk):
groups.append('empty') groups.append('empty')
logger.debug("Updating user %s discord groups to %s" % (user, groups)) logger.debug("Updating user %s discord groups to %s" % (user, groups))
try: try:
DiscordManager.update_groups(authserviceinfo.discord_uid, groups) DiscordOAuthManager.update_groups(authserviceinfo.discord_uid, groups)
except: except:
logger.exception("Discord group sync failed for %s, retrying in 10 mins" % user) logger.exception("Discord group sync failed for %s, retrying in 10 mins" % user)
raise self.retry(countdown = 60 * 10) raise self.retry(countdown = 60 * 10)

View File

@ -8,6 +8,7 @@ requests>=2.9.1
bcrypt bcrypt
zeroc-ice zeroc-ice
slugify slugify
requests-oauthlib
# Django Stuff # # Django Stuff #
django==1.6.5 django==1.6.5

View File

@ -2,6 +2,7 @@ from django.contrib import admin
from .models import AuthTS from .models import AuthTS
from .models import DiscordAuthToken from .models import DiscordAuthToken
from .models import MumbleUser from .models import MumbleUser
from .models import GroupCache
class AuthTSgroupAdmin(admin.ModelAdmin): class AuthTSgroupAdmin(admin.ModelAdmin):
fields = ['auth_group','ts_group'] fields = ['auth_group','ts_group']
@ -12,3 +13,5 @@ admin.site.register(AuthTS, AuthTSgroupAdmin)
admin.site.register(DiscordAuthToken) admin.site.register(DiscordAuthToken)
admin.site.register(MumbleUser) admin.site.register(MumbleUser)
admin.site.register(GroupCache)

View File

@ -5,9 +5,11 @@ import re
import os import os
import urllib import urllib
import base64 import base64
from services.models import DiscordAuthToken from services.models import DiscordAuthToken, GroupCache
from requests_oauthlib import OAuth2Session
import logging import logging
import datetime
from django.utils import timezone
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -445,3 +447,173 @@ class DiscordManager:
except: except:
logger.exception("An unhandled exception has occured.") logger.exception("An unhandled exception has occured.")
return False return False
AUTH_URL = "https://discordapp.com/api/oauth2/authorize"
TOKEN_URL = "https://discordapp.com/api/oauth2/token"
# kick, manage roles
BOT_PERMISSIONS = 0x00000002 + 0x10000000
# get user ID, accept invite
SCOPES = [
'identify',
'guilds.join',
]
GROUP_CACHE_MAX_AGE = datetime.timedelta(minutes=30)
class DiscordOAuthManager:
@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(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 delete_user(user_id):
try:
custom_headers = {'accept': 'application/json', 'authorization': 'Bearer ' + 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))
r.raise_for_status()
return True
except:
logger.exception("Failed to remove Discord user %s" % user_id)
try:
# user maybe already left server?
custom_headers = {'accept': 'application/json', 'authorization': 'Bearer ' + settings.DISCORD_BOT_TOKEN}
path = DISCORD_URL + "/guilds/" + str(settings.DISCORD_GUILD_ID) + "/members/"
r = requests.get(path, headers=custom_headers)
members = r.json()
users = [str(m['user']['id']) == str(user_id) for m in members]
if True in users:
logger.error("Unable to remove Discord user %s" % user_id)
return False
else:
logger.warn("Discord user %s alredy left server." % user_id)
return True
except:
logger.exception("Failed to locate Discord user")
return False
@staticmethod
def __get_groups():
custom_headers = {'accept': 'application/json', 'authorization': 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 __update_group_cache():
GroupCache.objects.filter(service="discord").delete()
cache = GroupCache.objects.create(service="discord")
cache.groups = json.dumps(DiscordOAuthManager.__get_groups())
cache.save()
return cache
@staticmethod
def __get_group_cache():
if not GroupCache.objects.filter(service="discord").exists():
DiscordOAuthManager.__update_group_cache()
cache = GroupCache.objects.get(service="discord")
age = timezone.now() - cache.created
if age > GROUP_CACHE_MAX_AGE:
logger.debug("Group cache has expired. Triggering update.")
cache = DiscordOAuthManager.__update_group_cache()
return json.loads(cache.groups)
@staticmethod
def __group_name_to_id(name):
cache = DiscordOAuthManager.__get_group_cache()
for g in cache:
if g['name'] == name:
return g['id']
logger.debug("Group %s not found on Discord. Creating" % name)
DiscordOAuthManager.__create_group(name)
return DiscordOAuthManager.__group_name_to_id(name)
@staticmethod
def __group_id_to_name(id):
cache = DiscordOAuthManager.__get_group_cache()
for g in cache:
if g['id'] == id:
return g['name']
raise KeyError("Group ID %s not found on Discord" % id)
@staticmethod
def __generate_role():
custom_headers = {'accept':'application/json', 'authorization': 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': 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()
new_role = DiscordOAuthManager.__edit_role(role['id'], name)
DiscordOAuthManager.__update_group_cache()
@staticmethod
def update_groups(user_id, groups):
custom_headers = {'content-type':'application/json', 'authorization': settings.DISCORD_BOT_TOKEN}
group_ids = [DiscordOAuthManager.__group_name_to_id(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

@ -50,8 +50,12 @@ class MumbleUser(models.Model):
class GroupCache(models.Model): class GroupCache(models.Model):
SERVICE_CHOICES = ( SERVICE_CHOICES = (
("discourse", "discourse"), ("discourse", "discourse"),
("discord", "discord"),
) )
created = models.DateTimeField(auto_now_add=True) created = models.DateTimeField(auto_now_add=True)
groups = models.TextField(blank=True, null=True) groups = models.TextField(default={})
service = models.CharField(max_length=254, choices=SERVICE_CHOICES, unique=True) service = models.CharField(max_length=254, choices=SERVICE_CHOICES, unique=True)
def __str__(self):
return self.service

View File

@ -14,7 +14,7 @@ from managers.mumble_manager import MumbleManager
from managers.ipboard_manager import IPBoardManager from managers.ipboard_manager import IPBoardManager
from managers.xenforo_manager import XenForoManager from managers.xenforo_manager import XenForoManager
from managers.teamspeak3_manager import Teamspeak3Manager from managers.teamspeak3_manager import Teamspeak3Manager
from managers.discord_manager import DiscordManager from managers.discord_manager import DiscordOAuthManager
from managers.discourse_manager import DiscourseManager from managers.discourse_manager import DiscourseManager
from managers.ips4_manager import Ips4Manager from managers.ips4_manager import Ips4Manager
from managers.smf_manager import smfManager from managers.smf_manager import smfManager
@ -129,6 +129,8 @@ def services_view(request):
def service_blue_alliance_test(user): def service_blue_alliance_test(user):
return check_if_user_has_permission(user, 'member') or check_if_user_has_permission(user, 'blue_member') return check_if_user_has_permission(user, 'member') or check_if_user_has_permission(user, 'blue_member')
def superuser_test(user):
return user.is_superuser
@login_required @login_required
@user_passes_test(service_blue_alliance_test) @user_passes_test(service_blue_alliance_test)
@ -504,7 +506,7 @@ context_instance=RequestContext(request))
def deactivate_discord(request): def deactivate_discord(request):
logger.debug("deactivate_discord called by user %s" % request.user) logger.debug("deactivate_discord called by user %s" % request.user)
authinfo = AuthServicesInfoManager.get_auth_service_info(request.user) authinfo = AuthServicesInfoManager.get_auth_service_info(request.user)
result = DiscordManager.delete_user(authinfo.discord_uid) result = DiscordOAuthManager.delete_user(authinfo.discord_uid)
if result: if result:
AuthServicesInfoManager.update_user_discord_info("", request.user) AuthServicesInfoManager.update_user_discord_info("", request.user)
logger.info("Succesfully deactivated discord for user %s" % request.user) logger.info("Succesfully deactivated discord for user %s" % request.user)
@ -517,7 +519,7 @@ def deactivate_discord(request):
def reset_discord(request): def reset_discord(request):
logger.debug("reset_discord called by user %s" % request.user) logger.debug("reset_discord called by user %s" % request.user)
authinfo = AuthServicesInfoManager.get_auth_service_info(request.user) authinfo = AuthServicesInfoManager.get_auth_service_info(request.user)
result = DiscordManager.delete_user(authinfo.discord_uid) result = DiscordOAuthManager.delete_user(authinfo.discord_uid)
if result: if result:
AuthServicesInfoManager.update_user_discord_info("",request.user) AuthServicesInfoManager.update_user_discord_info("",request.user)
logger.info("Succesfully deleted discord user for user %s - forwarding to discord activation." % request.user) logger.info("Succesfully deleted discord user for user %s - forwarding to discord activation." % request.user)
@ -529,47 +531,29 @@ def reset_discord(request):
@user_passes_test(service_blue_alliance_test) @user_passes_test(service_blue_alliance_test)
def activate_discord(request): def activate_discord(request):
logger.debug("activate_discord called by user %s" % request.user) logger.debug("activate_discord called by user %s" % request.user)
success = False return HttpResponseRedirect(DiscordOAuthManager.generate_oauth_redirect_url())
if request.method == 'POST':
logger.debug("Received POST request with form.")
form = DiscordForm(request.POST)
logger.debug("Form is valid: %s" % form.is_valid())
if form.is_valid():
email = form.cleaned_data['email']
logger.debug("Form contains email address beginning with %s" % email[0:3])
password = form.cleaned_data['password']
logger.debug("Form contains password of length %s" % len(password))
update_avatar = form.cleaned_data['update_avatar']
logger.debug("Form contains update_avatar set to %r" % bool(update_avatar))
try:
user_id = DiscordManager.add_user(email, password, request.user)
logger.debug("Received discord uid %s" % user_id)
if user_id != "":
AuthServicesInfoManager.update_user_discord_info(user_id, request.user)
logger.debug("Updated discord id %s for user %s" % (user_id, request.user))
update_discord_groups.delay(request.user.pk)
logger.debug("Updated discord groups for user %s." % request.user)
success = True
logger.info("Succesfully activated discord for user %s" % request.user)
if (update_avatar):
authinfo = AuthServicesInfoManager.get_auth_service_info(request.user)
char_id = authinfo.main_char_id
avatar_updated = DiscordManager.update_user_avatar(email, password, request.user, char_id)
if (avatar_updated):
logger.debug("Updated user %s discord avatar." % request.user)
else:
logger.debug("Could not set user %s discord avatar." %request.user)
return HttpResponseRedirect("/services/")
except:
logger.exception("An unhandled exception has occured.")
pass
else:
logger.debug("Request is not type POST - providing empty form.")
form = DiscordForm()
logger.debug("Rendering form for user %s with success %s" % (request.user, success)) @login_required
context = {'form': form, 'success': success} @user_passes_test(service_blue_alliance_test)
return render_to_response('registered/discord.html', context, context_instance=RequestContext(request)) 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 HttpResponseRedirect("/services/")
user_id = DiscordOAuthManager.add_user(code)
if user_id:
AuthServicesInfoManager.update_user_discord_info(user_id, request.user)
update_discord_groups.delay(request.user.pk)
logger.info("Succesfully activated Discord for user %s" % request.user)
else:
logger.error("Failed to activate Discord for user %s" % request.user)
return HttpResponseRedirect("/services/")
@login_required
@user_passes_test(superuser_test)
def discord_add_bot(request):
return HttpResponseRedirect(DiscordOAuthManager.generate_bot_add_url())
@login_required @login_required
@user_passes_test(service_blue_alliance_test) @user_passes_test(service_blue_alliance_test)

View File

@ -9,6 +9,14 @@
{% block content %} {% block content %}
<div class="col-lg-12"> <div class="col-lg-12">
<h1 class="page-header text-center">Available Services</h1> <h1 class="page-header text-center">Available Services</h1>
{% if ENABLE_AUTH_DISCORD or ENABLE_BLUE_DISCORD %}
{% if request.user.is_superuser %}
<div class="text-center">
<a type="button" class="btn btn-lg btn-success" href="{% url 'auth_discord_add_bot' %}">Link Discord Server</a>
</div>
<br>
{% endif %}
{% endif %}
{% if perms.auth.blue_member %} {% if perms.auth.blue_member %}
<table class="table table-bordered"> <table class="table table-bordered">
<tr> <tr>

View File

@ -57,7 +57,7 @@ def domain_url(request):
'ENABLE_BLUE_XENFORO': settings.ENABLE_BLUE_XENFORO, 'ENABLE_BLUE_XENFORO': settings.ENABLE_BLUE_XENFORO,
'TEAMSPEAK3_PUBLIC_URL': settings.TEAMSPEAK3_PUBLIC_URL, 'TEAMSPEAK3_PUBLIC_URL': settings.TEAMSPEAK3_PUBLIC_URL,
'JACK_KNIFE_URL': settings.JACK_KNIFE_URL, 'JACK_KNIFE_URL': settings.JACK_KNIFE_URL,
'DISCORD_SERVER_ID': settings.DISCORD_SERVER_ID, 'DISCORD_SERVER_ID': settings.DISCORD_GUILD_ID,
'KILLBOARD_URL': settings.KILLBOARD_URL, 'KILLBOARD_URL': settings.KILLBOARD_URL,
'DISCOURSE_URL': settings.DISCOURSE_URL, 'DISCOURSE_URL': settings.DISCOURSE_URL,
'IPS4_URL': settings.IPS4_URL, 'IPS4_URL': settings.IPS4_URL,