Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: Discord auth system #68

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
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
6 changes: 5 additions & 1 deletion core/admin.py
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
# Register your models here.
from django.contrib import admin
from core.models import UserRole, User

admin.site.register(UserRole)
admin.site.register(User)
12 changes: 11 additions & 1 deletion core/context_processors.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from core.discord_auth import user_logged_in

SIDEBAR_LINKS = {
"Genshin": [
{"name": "Genshin", "url": "/genshin", "icon": "gamepad"},
Expand Down Expand Up @@ -25,4 +27,12 @@


def sidebar_links(request):
return {"sidebar_links": SIDEBAR_LINKS}
user = user_logged_in(request)
if user is None:
return {}
links = {}
for role in user.roles.all():
if SIDEBAR_LINKS.get(role.permissions) is None:
goteusz-maszyk marked this conversation as resolved.
Show resolved Hide resolved
continue
links[role.permissions] = SIDEBAR_LINKS.get(role.permissions)
return {"sidebar_links": links}
110 changes: 110 additions & 0 deletions core/discord_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
from datetime import datetime, timedelta

import requests
from django.core.handlers.wsgi import WSGIRequest
from django.urls import reverse
from django.utils import timezone
from requests import HTTPError
from requests.exceptions import JSONDecodeError

from core.models import User, UserRole
from gwardia_hub.settings import CLIENT_ID, CLIENT_SECRET, GUILD_ID

API_ENDPOINT = "https://discord.com/api/v10"
DISCORD_TOKEN_COOKIE = "discord_access_token"
DISCORD_REFRESH_COOKIE = "discord_refresh_token"
Norbiros marked this conversation as resolved.
Show resolved Hide resolved
DISCORD_ID_COOKIE = "discord_id"


def authorise_code(request: WSGIRequest) -> dict[str, str] | None:
"""Returns HTTP JSON response from Discord API or None when authorization fails."""
code = request.GET.get("code")
if code is None:
return None

data = {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": request.build_absolute_uri(reverse("core:discord_code")),
}
headers = {"Content-Type": "application/x-www-form-urlencoded"}
response = requests.post(
f"{API_ENDPOINT}/oauth2/token",
data=data,
headers=headers,
auth=(CLIENT_ID, CLIENT_SECRET),
)
try:
response.raise_for_status()
goteusz-maszyk marked this conversation as resolved.
Show resolved Hide resolved
except HTTPError:
return None
return response.json()


def refresh_access_token(refresh_token):
data = {"grant_type": "refresh_token", "refresh_token": refresh_token}
headers = {"Content-Type": "application/x-www-form-urlencoded"}
response = requests.post(
f"{API_ENDPOINT}/oauth2/token",
data=data,
headers=headers,
auth=(CLIENT_ID, CLIENT_SECRET),
)
if not response.ok:
return None
return response.json()


def fetch_user(access_token: str) -> User | None:
headers = {
"Authorization": f"Bearer {access_token}",
}
response = requests.get(
f"{API_ENDPOINT}/oauth2/@me",
headers=headers,
)
if not response.ok:
return None
try:
json = response.json()
except JSONDecodeError:
return None

user = User.objects.get_or_create(discord_id=json["user"]["id"])[0]
user.username = json["user"]["global_name"] or json["user"]["username"]
user.avatar_hash = json["user"]["avatar"]
if user.data_valid_until < timezone.now():
if fetch_user_details(access_token):
user.data_valid_until = datetime.now() + timedelta(minutes=10)
user.save()

return user


def fetch_user_details(access_token: str) -> bool:
"""
:param access_token: access token for Discord API
:return: True if data was fetched successfully
"""

headers = {
"Authorization": f"Bearer {access_token}",
}
response = requests.get(
f"{API_ENDPOINT}/users/@me/guilds/{GUILD_ID}/member",
headers=headers,
)
if not response.ok:
return False
try:
json = response.json()
except JSONDecodeError:
return False
goteusz-maszyk marked this conversation as resolved.
Show resolved Hide resolved
user = User.objects.get_or_create(discord_id=json["user"]["id"])[0]
for role_id in json["roles"]:
try:
role = UserRole.objects.get(id=role_id)
user.roles.add(role)
except UserRole.DoesNotExist:
pass
return True
115 changes: 115 additions & 0 deletions core/discord_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import functools
import typing

from django.http import HttpRequest, HttpResponse
from django.shortcuts import redirect
from django.urls import reverse
from core.discord_api import (
DISCORD_REFRESH_COOKIE,
DISCORD_TOKEN_COOKIE,
fetch_user,
DISCORD_ID_COOKIE,
)
from core.models import User, UserRole


def user_logged_in(request: HttpRequest) -> User | None:
"""Returns user if logged in. If not, returns 0 when session should be refreshed or 1 when session is invalid."""
token = request.COOKIES.get(DISCORD_TOKEN_COOKIE)
if token is None:
return None

discord_id = request.COOKIES.get(DISCORD_ID_COOKIE)
if discord_id is None:
user = fetch_user(token)
goteusz-maszyk marked this conversation as resolved.
Show resolved Hide resolved
else:
try:
user = User.objects.get(discord_id=discord_id)
if fetch_user(token) != user:
return None
except User.DoesNotExist or ValueError:
return None
return user


def require_no_user():
def decorator(func: typing.Callable) -> typing.Callable:
@functools.wraps(func)
def wrapper(request: HttpRequest, *args, **kwargs) -> HttpResponse:
if user_logged_in(request):
return redirect("core:profile")
return func(request, *args, **kwargs)

return wrapper

return decorator


def require_user(required_permissions: str = None):
"""Decorator that checks if a user is logged in and has the required role.

Adds a second positional argument to the decorated function containting the guild member.

:param required_permissions: The required permissions.
"""

def decorator(func):
@functools.wraps(func)
def wrapper(request: HttpRequest, *args, **kwargs):
user = user_logged_in(request)
if not user:
return temporary_redirect("core:discord_refresh", request)
try:
if not user.has_permission(required_permissions):
return redirect("core:profile", request)
except UserRole.DoesNotExist:
pass

return set_id_cookie(func(request, *args, user, **kwargs), user)

return wrapper

return decorator


def temporary_redirect(target: str, request: HttpRequest) -> HttpResponse:
"""Does a temporary redirect that can be reverted using `go_back` method."""
request.session["next_url"] = request.build_absolute_uri()
return redirect(target)


def go_back(request: HttpRequest) -> HttpResponse:
"""Redirects to a stored url if set or else to index page"""
next_url = request.session.get("next_url")
if next_url is None:
return redirect(reverse("core:index"))
del request.session["next_url"]
return redirect(next_url)


def set_token_cookies(
response: HttpResponse, tokens_response: dict[str, str]
) -> HttpResponse:
response.set_cookie(
DISCORD_TOKEN_COOKIE,
tokens_response["access_token"],
max_age=tokens_response["expires_in"],
)
response.set_cookie(
DISCORD_REFRESH_COOKIE,
tokens_response["refresh_token"],
max_age=14 * 24 * 60 * 60,
)
return response


def set_id_cookie(response: HttpResponse, user: User) -> HttpResponse:
response.set_cookie(DISCORD_ID_COOKIE, user.discord_id, max_age=5 * 60)
return response


def reset_cookies(response: HttpResponse) -> HttpResponse:
response.delete_cookie(DISCORD_ID_COOKIE)
response.delete_cookie(DISCORD_TOKEN_COOKIE)
response.delete_cookie(DISCORD_REFRESH_COOKIE)
return response
42 changes: 42 additions & 0 deletions core/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Generated by Django 5.0.4 on 2024-05-07 20:06

from django.db import migrations, models


class Migration(migrations.Migration):
initial = True

dependencies = []

operations = [
migrations.CreateModel(
name="UserRole",
fields=[
(
"id",
models.BigIntegerField(
primary_key=True, serialize=False, unique=True
),
),
("name", models.CharField()),
],
),
migrations.CreateModel(
name="User",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("discord_id", models.BigIntegerField(unique=True)),
("username", models.CharField()),
("avatar_hash", models.CharField()),
("roles", models.ManyToManyField(to="core.userrole")),
],
),
]
22 changes: 22 additions & 0 deletions core/migrations/0002_user_data_valid_until.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Generated by Django 5.0.4 on 2024-05-08 09:35

import datetime
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("core", "0001_initial"),
]

operations = [
migrations.AddField(
model_name="user",
name="data_valid_until",
field=models.DateTimeField(
default=datetime.datetime(
1971, 1, 1, 0, 0, tzinfo=datetime.timezone.utc
)
),
),
]
20 changes: 20 additions & 0 deletions core/migrations/0003_userrole_permissions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Generated by Django 5.0.6 on 2024-06-02 11:08

import core.models
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("core", "0002_user_data_valid_until"),
]

operations = [
migrations.AddField(
model_name="userrole",
name="permissions",
field=models.CharField(
choices=core.models.get_permission_choices, default=""
),
),
]
29 changes: 29 additions & 0 deletions core/migrations/0004_userrole_prefill.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Generated by Django 5.0.6 on 2024-06-02 11:08

from django.db import migrations


def create_default_roles(apps, schema_editor):
UserRole = apps.get_model("core", "UserRole")
UserRole.objects.create(
name="Genshiniara", id=1215012858346086461, permissions="Genshin"
)
UserRole.objects.create(
name="Członek gwardii", id=1200540359919411380, permissions="Gwardia"
)
UserRole.objects.create(
name="Z klasy", id=1148642506859368448, permissions="Klasowe"
)


def remove_default_roles(apps, schema_editor):
UserRole = apps.get_model("core", "UserRole")
UserRole.objects.all().delete()


class Migration(migrations.Migration):
dependencies = [
("core", "0003_userrole_permissions"),
]

operations = [migrations.RunPython(create_default_roles, remove_default_roles)]
Loading