Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
Django Allauth (python-discord#201)
  • Loading branch information
scragly authored Oct 14, 2019
2 parents d1d0d0c + 6ceb465 commit 03d5a4c
Show file tree
Hide file tree
Showing 21 changed files with 1,061 additions and 7 deletions.
1 change: 1 addition & 0 deletions Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ pygments = "~=2.3.1"
wiki = {path = "./docker/wheels/wiki-0.5.dev20190420204942-py3-none-any.whl"}
pyyaml = "~=5.1"
pyuwsgi = {version = "~=2.0", sys_platform = "!='win32'"}
django-allauth = "~=0.40"

[dev-packages]
coverage = "~=4.5.3"
Expand Down
37 changes: 36 additions & 1 deletion Pipfile.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pydis_site/apps/home/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
default_app_config = "pydis_site.apps.home.apps.HomeConfig"
33 changes: 32 additions & 1 deletion pydis_site/apps/home/apps.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,38 @@
from typing import Any, Dict

from django.apps import AppConfig


class HomeConfig(AppConfig):
"""Django AppConfig for the home app."""

name = 'home'
name = 'pydis_site.apps.home'
signal_listener = None

def ready(self) -> None:
"""Run when the app has been loaded and is ready to serve requests."""
from pydis_site.apps.home.signals import AllauthSignalListener

self.signal_listener = AllauthSignalListener()
self.patch_allauth()

def patch_allauth(self) -> None:
"""Monkey-patches Allauth classes so we never collect email addresses."""
# Imported here because we can't import it before our apps are loaded up
from allauth.socialaccount.providers.base import Provider

def extract_extra_data(_: Provider, data: Dict[str, Any]) -> Dict[str, Any]:
"""
Extracts extra data for a SocialAccount provided by Allauth.
This is our version of this function that strips the email address from incoming extra
data. We do this so that we never have to store it.
This is monkey-patched because most OAuth providers - or at least the ones we care
about - all use the function from the base Provider class. This means we don't have
to make a new Django app for each one we want to work with.
"""
data["email"] = ""
return data

Provider.extract_extra_data = extract_extra_data
260 changes: 260 additions & 0 deletions pydis_site/apps/home/signals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
from typing import List, Optional, Type

from allauth.account.signals import user_logged_in
from allauth.socialaccount.models import SocialAccount, SocialLogin
from allauth.socialaccount.providers.base import Provider
from allauth.socialaccount.providers.discord.provider import DiscordProvider
from allauth.socialaccount.signals import (
pre_social_login, social_account_added, social_account_removed,
social_account_updated)
from django.contrib.auth.models import Group, User as DjangoUser
from django.db.models.signals import post_save, pre_delete, pre_save

from pydis_site.apps.api.models import User as DiscordUser
from pydis_site.apps.staff.models import RoleMapping


class AllauthSignalListener:
"""
Listens to and processes events via the Django Signals system.
Django Signals is basically an event dispatcher. It consists of Signals (which are the events)
and Receivers, which listen for and handle those events. Signals are triggered by Senders,
which are essentially just any class at all, and Receivers can filter the Signals they listen
for by choosing a Sender, if required.
Signals themselves define a set of arguments that they will provide to Receivers when the
Signal is sent. They are always keyword arguments, and Django recommends that all Receiver
functions accept them as `**kwargs` (and will supposedly error if you don't do this),
supposedly because Signals can change in the future and your receivers should still work.
Signals do provide a list of their arguments when they're initially constructed, but this
is purely for documentation purposes only and Django does not enforce it.
The Django Signals docs are here: https://docs.djangoproject.com/en/2.2/topics/signals/
"""

def __init__(self):
post_save.connect(self.user_model_updated, sender=DiscordUser)

pre_delete.connect(self.mapping_model_deleted, sender=RoleMapping)
pre_save.connect(self.mapping_model_updated, sender=RoleMapping)

pre_social_login.connect(self.social_account_updated)
social_account_added.connect(self.social_account_updated)
social_account_updated.connect(self.social_account_updated)
social_account_removed.connect(self.social_account_removed)

user_logged_in.connect(self.user_logged_in)

def user_logged_in(self, sender: Type[DjangoUser], **kwargs) -> None:
"""
Processes Allauth login signals to ensure a user has the correct perms.
This method tries to find a Discord SocialAccount for a user - this should always
be the case, but the admin user likely won't have one, so we do check for it.
After that, we try to find the user's stored Discord account details, provided by the
bot on the server. Finally, we pass the relevant information over to the
`_apply_groups()` method for final processing.
"""
user: DjangoUser = kwargs["user"]

try:
account: SocialAccount = SocialAccount.objects.get(
user=user, provider=DiscordProvider.id
)
except SocialAccount.DoesNotExist:
return # User's never linked a Discord account

try:
discord_user: DiscordUser = DiscordUser.objects.get(id=int(account.uid))
except DiscordUser.DoesNotExist:
return

self._apply_groups(discord_user, account)

def social_account_updated(self, sender: Type[SocialLogin], **kwargs) -> None:
"""
Processes Allauth social account update signals to ensure a user has the correct perms.
In this case, a SocialLogin is provided that we can check against. We check that this
is a Discord login in order to ensure that future OAuth logins using other providers
don't break things.
Like most of the other methods that handle signals, this method defers to the
`_apply_groups()` method for final processing.
"""
social_login: SocialLogin = kwargs["sociallogin"]

account: SocialAccount = social_login.account
provider: Provider = account.get_provider()

if not isinstance(provider, DiscordProvider):
return

try:
user: DiscordUser = DiscordUser.objects.get(id=int(account.uid))
except DiscordUser.DoesNotExist:
return

self._apply_groups(user, account)

def social_account_removed(self, sender: Type[SocialLogin], **kwargs) -> None:
"""
Processes Allauth social account reomval signals to ensure a user has the correct perms.
In this case, a SocialAccount is provided that we can check against. If this is a
Discord OAuth being removed from the account, we want to ensure that the user loses
their permissions groups as well.
While this isn't a realistic scenario to reach in our current setup, I've provided it
for the sake of covering any edge cases and ensuring that SocialAccounts can be removed
from Django users in the future if required.
Like most of the other methods that handle signals, this method defers to the
`_apply_groups()` method for final processing.
"""
account: SocialAccount = kwargs["socialaccount"]
provider: Provider = account.get_provider()

if not isinstance(provider, DiscordProvider):
return

try:
user: DiscordUser = DiscordUser.objects.get(id=int(account.uid))
except DiscordUser.DoesNotExist:
return

self._apply_groups(user, account, deletion=True)

def mapping_model_deleted(self, sender: Type[RoleMapping], **kwargs) -> None:
"""
Processes deletion signals from the RoleMapping model, removing perms from users.
We need to do this to ensure that users aren't left with permissions groups that
they shouldn't have assigned to them when a RoleMapping is deleted from the database.
"""
instance: RoleMapping = kwargs["instance"]

for user in instance.group.user_set.all():
user.groups.remove(instance.group)

def mapping_model_updated(self, sender: Type[RoleMapping], **kwargs) -> None:
"""
Processes update signals from the RoleMapping model.
This method is in charge of figuring out what changed when a RoleMapping is updated
(via the Django admin or otherwise). It operates based on what was changed, and can
handle changes to both the role and permissions group assigned to it.
"""
instance: RoleMapping = kwargs["instance"]
raw: bool = kwargs["raw"]

if raw:
# Fixtures are being loaded, so don't touch anything
return

old_instance: Optional[RoleMapping] = None

if instance.id is not None:
# We don't try to catch DoesNotExist here because we can't test for it,
# it should never happen (unless we have a bad DB failure) but I'm still
# kind of antsy about not having the extra security here.

old_instance = RoleMapping.objects.get(id=instance.id)

if old_instance:
self.mapping_model_deleted(RoleMapping, instance=old_instance)

accounts = SocialAccount.objects.filter(
uid__in=(u.id for u in instance.role.user_set.all())
)

for account in accounts:
account.user.groups.add(instance.group)

def user_model_updated(self, sender: Type[DiscordUser], **kwargs) -> None:
"""
Processes update signals from the Discord User model, assigning perms as required.
When a user's roles are changed on the Discord server, this method will ensure that
the user has only the permissions groups that they should have based on the RoleMappings
that have been set up in the Django admin.
Like some of the other signal handlers, this method ensures that a SocialAccount exists
for this Discord User, and defers to `_apply_groups()` to do the heavy lifting of
ensuring the permissions groups are correct.
"""
instance: DiscordUser = kwargs["instance"]
raw: bool = kwargs["raw"]

# `update_fields` could be used for checking changes, but it's None here due to how the
# model is saved without using that argument - so we can't use it.

if raw:
# Fixtures are being loaded, so don't touch anything
return

try:
account: SocialAccount = SocialAccount.objects.get(
uid=str(instance.id), provider=DiscordProvider.id
)
except SocialAccount.DoesNotExist:
return # User has never logged in with Discord on the site

self._apply_groups(instance, account)

def _apply_groups(
self, user: DiscordUser, account: SocialAccount, deletion: bool = False
) -> None:
"""
Ensures that the correct permissions are set for a Django user based on the RoleMappings.
This (private) method is designed to check a Discord User against a given SocialAccount,
and makes sure that the Django user associated with the SocialAccount has the correct
permissions groups.
While it would be possible to get the Discord User object with just the SocialAccount
object, the current approach results in less queries.
The `deletion` parameter is used to signify that the user's SocialAccount is about
to be removed, and so we should always remove all of their permissions groups. The
same thing will happen if the user is no longer actually on the Discord server, as
leaving the server does not currently remove their SocialAccount from the database.
"""
mappings = RoleMapping.objects.all()

try:
current_groups: List[Group] = list(account.user.groups.all())
except SocialAccount.user.RelatedObjectDoesNotExist:
return # There's no user account yet, this will be handled by another receiver

if not user.in_guild:
deletion = True

if deletion:
# They've unlinked Discord or left the server, so we have to remove their groups

if not current_groups:
return # They have no groups anyway, no point in processing

account.user.groups.remove(
*(mapping.group for mapping in mappings)
)
else:
new_groups = []

for role in user.roles.all():
try:
new_groups.append(mappings.get(role=role).group)
except RoleMapping.DoesNotExist:
continue # No mapping exists

account.user.groups.add(
*[group for group in new_groups if group not in current_groups]
)

account.user.groups.remove(
*[mapping.group for mapping in mappings if mapping.group not in new_groups]
)
Loading

0 comments on commit 03d5a4c

Please sign in to comment.