Skip to content

Commit

Permalink
feat: anti-rate-limit system, improved auth
Browse files Browse the repository at this point in the history
  • Loading branch information
goteusz-maszyk committed May 20, 2024
1 parent 5a97d37 commit 9f628af
Show file tree
Hide file tree
Showing 9 changed files with 205 additions and 69 deletions.
3 changes: 2 additions & 1 deletion core/admin.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from django.contrib import admin
from core.models import UserRole
from core.models import UserRole, User

admin.site.register(UserRole)
admin.site.register(User)
55 changes: 41 additions & 14 deletions core/discord_api.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
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

Expand All @@ -22,43 +25,42 @@ def authorise_code(request: WSGIRequest) -> dict[str, str] | None:
data = {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": request.build_absolute_uri(reverse("discord_callback")),
"redirect_uri": request.build_absolute_uri(reverse("core:discord_code")),
}
headers = {"Content-Type": "application/x-www-form-urlencoded"}
r = requests.post(
response = requests.post(
f"{API_ENDPOINT}/oauth2/token",
data=data,
headers=headers,
auth=(CLIENT_ID, CLIENT_SECRET),
)
try:
r.raise_for_status()
response.raise_for_status()
except HTTPError:
return None
return r.json()
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"}
r = requests.post(
response = requests.post(
f"{API_ENDPOINT}/oauth2/token",
data=data,
headers=headers,
auth=(CLIENT_ID, CLIENT_SECRET),
)
if r.status_code == 401:
if not response.ok:
return None
r.raise_for_status()
return r.json()
return response.json()


def fetch_guild_member(access_token: str) -> User | None:
def fetch_user(access_token: str) -> User | None:
headers = {
"Authorization": f"Bearer {access_token}",
}
response = requests.get(
f"{API_ENDPOINT}/users/@me/guilds/{GUILD_ID}/member",
f"{API_ENDPOINT}/oauth2/@me",
headers=headers,
)
if not response.ok:
Expand All @@ -71,13 +73,38 @@ def fetch_guild_member(access_token: str) -> User | 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"]
user.roles.set([])
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
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
user.save()

return user
return True
76 changes: 53 additions & 23 deletions core/discord_auth.py
Original file line number Diff line number Diff line change
@@ -1,64 +1,87 @@
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_guild_member,
fetch_user,
DISCORD_ID_COOKIE,
)
from core.models import User, UserRole
from gwardia_hub.settings import MEMBER_ROLE_ID


def require_discord_login(required_role_id: str | None = MEMBER_ROLE_ID):
"""
Decorator that checks if a user is logged in and has the required role.
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)
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_role_id: str | None = MEMBER_ROLE_ID):
"""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_role_id: A role ID required for the user to be authorised.
:param required_role_id: A role ID required for the user to be authorised. Defaults to MEMBER_ROLE_ID.
"""

def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
request: HttpRequest = args[0]
token = request.COOKIES.get(DISCORD_TOKEN_COOKIE)
if token is None:
return auth_redirect("core:discord_refresh", request)

discord_id = request.COOKIES.get(DISCORD_ID_COOKIE)
if discord_id is None:
user = fetch_guild_member(token)
else:
user = User.objects.get(discord_id=discord_id)

def wrapper(request: HttpRequest, *args, **kwargs):
user = user_logged_in(request)
if not user:
return temporary_redirect("core:discord_refresh", request)
try:
required_role = UserRole.objects.get(id=required_role_id)
if not user.roles.contains(required_role):
return set_id_cookie(HttpResponse("Unauthorized", status=401), user)
except UserRole.DoesNotExist:
pass

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

return wrapper

return decorator


def auth_redirect(target: str, request: HttpRequest) -> HttpResponse:
"""
Does a redirect, but also stores the current url, so we can go back to it later using the go_back function
"""
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)


# Redirects to a stored url if set or else to index page
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"))
Expand All @@ -85,3 +108,10 @@ def set_token_cookies(
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
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(
2024, 5, 8, 9, 45, 28, 581656, tzinfo=datetime.timezone.utc
)
),
),
]
28 changes: 28 additions & 0 deletions core/models.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
from datetime import timedelta
from django.utils import timezone
from django.db import models


class UserRole(models.Model):
id = models.BigIntegerField(unique=True, primary_key=True)
name = models.CharField()

def __str__(self):
return f"{self.name} ({self.id})"


class User(models.Model):
discord_id = models.BigIntegerField(
Expand All @@ -14,6 +19,11 @@ class User(models.Model):
avatar_hash = models.CharField()
roles = models.ManyToManyField(UserRole)

# caching data for 10 minutes to prevent Rate-Limits from Discord API
data_valid_until = models.DateTimeField(
default=timezone.now() + timedelta(minutes=10)
)

def avatar_url(self):
return f"https://cdn.discordapp.com/avatars/{self.discord_id}/{self.avatar_hash}.png"

Expand All @@ -25,3 +35,21 @@ def role_names(self):
for role in self.roles.iterator():
role_names.append(role.name)
return role_names

def to_json(self):
return {
"name": self.username,
"roles": self.role_names(),
"level": 42,
"exp": 621,
"max_exp": 2137,
"avatar": self.avatar_url(),
}

@classmethod
def exists(cls, user_id: int) -> bool:
try:
User.objects.get(discord_id=user_id)
except User.DoesNotExist:
return False
return True
10 changes: 7 additions & 3 deletions core/templates/core/panel.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,16 @@
<a href="https://github.com/Gwardia-Czapli/GwardiaHub" class="text-md md:text-xl font-bold">GwardiaHub</a>
</div>
<div class="flex justify-between items-center gap-4 m-2">
<a href="{% url 'core:profile' 'TODO: Your nickname' %}">
<a href="{% url 'core:profile' %}">
<img class="rounded-full h-8"
src="https://better-default-discord.netlify.app/Icons/Gradient-Blue.png"
alt="User profile picture" title="TODO: Your nickname"
src="{{ user.avatar }}"
alt="User profile picture"
/>
</a>
<form method="post" action="{% url 'core:logout' %}">
{% csrf_token %}
<button type="submit">Logout</button>
</form>
<div class="md:hidden">
<button id="mobile-menu-btn" class="fa-solid fa-bars"></button>
</div>
Expand Down
3 changes: 2 additions & 1 deletion core/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@
path("discord/login", views.discord_login, name="discord_login"),
path("discord/code", views.discord_code, name="discord_code"),
path("discord/refresh", views.discord_refresh, name="discord_refresh"),
path("logout", views.logout, name="logout"),
]

app_name = "core"
urlpatterns = [
path("", views.index, name="index"),
path("profile/", views.profile, name="profile"),
path("panel/profile/", views.profile, name="profile"),
path("auth/", include(login_urls)),
path("panel/profile/<str:name>", views.profile, name="profile"),
path("api/gh_webhook", views.gh_webhook, name="gh_webhook"),
Expand Down
Loading

0 comments on commit 9f628af

Please sign in to comment.