Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
99bc330
Initial Allauth dependency and settings/urls
gdude2002 Oct 3, 2019
8cfff31
Remove accidental static files commit; fix Allauth
gdude2002 Oct 3, 2019
b83f1b2
Re-add erroneously removed static files
gdude2002 Oct 3, 2019
01c0598
Disable Allauth email verification
gdude2002 Oct 3, 2019
99263f6
Add model to map Discord roles to Django permissions groups
gdude2002 Oct 3, 2019
646dc95
Remove app_label from role mapping model
gdude2002 Oct 3, 2019
699f1a1
Hook up Allauth and model signals to handle group assignments
gdude2002 Oct 3, 2019
bd11c70
Merge branch 'master' into #201-django-allauth
gdude2002 Oct 4, 2019
292aed8
Tests for signal handlers
gdude2002 Oct 4, 2019
234db56
Add docstrings to tests, and test role mapping __str__()
gdude2002 Oct 4, 2019
f836707
Clean up and comment signal tests
gdude2002 Oct 4, 2019
23f7f9e
Add MessageRedirectView and show messages on the index page
gdude2002 Oct 5, 2019
0ebaa16
Set up needed Allauth URLs and add login/logout to navbar
gdude2002 Oct 5, 2019
fa3cc5e
Test allauth redirect URLs
gdude2002 Oct 5, 2019
486b37b
Move messages to the top right
gdude2002 Oct 6, 2019
b437c26
Message styling (default to light for now)
gdude2002 Oct 6, 2019
23cf296
Add missing CSS for notifications
gdude2002 Oct 6, 2019
3c6d913
Make the Bulma colours less saturated
gdude2002 Oct 6, 2019
8e4fb94
Login page and styling fixes
gdude2002 Oct 6, 2019
8ec060e
Replace card on login page with notification
gdude2002 Oct 6, 2019
e736381
Prevent saving emails, remove login page
gdude2002 Oct 6, 2019
c137325
Remove GH login implementation, out of scope of this PR
gdude2002 Oct 6, 2019
18f4c29
Merge branch 'master' into #201-django-allauth
gdude2002 Oct 6, 2019
a7a5ccf
Re-lock Pipfile due to merge with master
gdude2002 Oct 6, 2019
85d3b3f
Register GH provider manually in tests
gdude2002 Oct 6, 2019
c55ff9a
Clarify _apply_groups kwarg (Thanks, @jchristgit)
gdude2002 Oct 11, 2019
f6db7cc
Simplify signals.py as per @jchristgit's review
gdude2002 Oct 11, 2019
404a9a7
Clean up signal tests as per @jchristgit's review
gdude2002 Oct 11, 2019
84a8096
Signals: `add()` does require a list, a generator won't do.
gdude2002 Oct 11, 2019
17f3d32
Signals: Fix test for `deletion=True` change
gdude2002 Oct 11, 2019
953195e
Signals: Handle (and test) mapping updates/deletions
gdude2002 Oct 11, 2019
689a5d9
Merge branch 'master' into #201-django-allauth
gdude2002 Oct 11, 2019
d29216a
Merge branch 'master' into #201-django-allauth
gdude2002 Oct 11, 2019
2055e0d
Allauth: Address reviews by @lemonsaurus
gdude2002 Oct 14, 2019
6ceb465
Merge branch 'master' into #201-django-allauth
gdude2002 Oct 14, 2019
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Comment thread
jchristgit marked this conversation as resolved.
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.
Comment thread
lemonsaurus marked this conversation as resolved.

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(
Comment thread
gdude2002 marked this conversation as resolved.
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