Skip to content

Commit

Permalink
feat: Discord auth system
Browse files Browse the repository at this point in the history
  • Loading branch information
SzczurekYT committed May 2, 2024
1 parent eab7ab2 commit 6a0379c
Show file tree
Hide file tree
Showing 8 changed files with 371 additions and 4 deletions.
62 changes: 62 additions & 0 deletions core/discord_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import requests

from gwardia_hub.settings import CLIENT_ID, CLIENT_SECRET, DEBUG, GUILD_ID

API_ENDPOINT = "https://discord.com/api/v10"
REDIRECT_URI = (
"http://localhost:8000/auth/discord/code"
if DEBUG
else "https://gwardiahub.fly.dev/auth/discord/code"
)
DISCORD_TOKEN_COOKIE = "discord_access_token"
DISCORD_REFRESH_COOKIE = "discord_refresh_token"


class UnauthorizedException(Exception):
pass


def code_to_token(code):
data = {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": REDIRECT_URI,
}
headers = {"Content-Type": "application/x-www-form-urlencoded"}
r = requests.post(
f"{API_ENDPOINT}/oauth2/token",
data=data,
headers=headers,
auth=(CLIENT_ID, CLIENT_SECRET),
)
r.raise_for_status()
return r.json()


def refresh_token(refresh_token):
data = {"grant_type": "refresh_token", "refresh_token": refresh_token}
headers = {"Content-Type": "application/x-www-form-urlencoded"}
r = requests.post(
f"{API_ENDPOINT}/oauth2/token",
data=data,
headers=headers,
auth=(CLIENT_ID, CLIENT_SECRET),
)
if r.status_code == 401:
raise UnauthorizedException
r.raise_for_status()
return r.json()


def fetch_guild_user(token):
headers = {
"Authorization": f"Bearer {token}",
}
r = requests.get(
f"{API_ENDPOINT}/users/@me/guilds/{GUILD_ID}/member",
headers=headers,
)
if r.status_code == 401:
raise UnauthorizedException
r.raise_for_status()
return r.json()
74 changes: 74 additions & 0 deletions core/discord_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import functools
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,
UnauthorizedException,
fetch_guild_user,
)
from gwardia_hub.settings import MEMBER_ROLE_ID


# Usage
# Add @require_discord_login() to a view to require the user to be logged in via discord
# This adds a second positional argument containing the guild member object from discord api
# https://discord.com/developers/docs/resources/guild#guild-member-object
# Example
# @require_discord_login()
# def index(request, user):
# By defult the user needs the member role to pass, but optionally you can pass a role id to override this behaviour
# E.g. @require_discord_login("123456789") will require the user to have a role with id 123456789
# Note: Python's way of defining decorators is far from being sane
def require_discord_login(required_role=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 bounce_redirect("core:discord_refresh", request)

try:
discord_user = fetch_guild_user(token)
except UnauthorizedException:
return bounce_redirect("core:discord_refresh", request)

if required_role not in discord_user["roles"]:
return HttpResponse("Unauthorized", status=401)

return func(*args, discord_user, **kwargs)

return wrapper

return decorator


# Does a redirect, but also stores the current url, so we can go back to it later using the go_back function
def bounce_redirect(target, request):
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):
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_cookies(response: HttpResponse, tokens_response):
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
15 changes: 15 additions & 0 deletions core/templates/core/login.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>

<body>
To be replaced by https://github.com/Gwardia-Czapli/GwardiaHub/pull/63 <br>
<a href="{% url 'core:discord_login' %}">Discord</a>
</body>

</html>
10 changes: 9 additions & 1 deletion core/urls.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
from django.urls import path
from django.urls import include, path

from . import views

login_urls = [
path("login", views.login, name="login"),
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"),
]

app_name = "core"
urlpatterns = [
path("", views.index, name="index"),
path("profile/", views.profile, name="profile"),
path("auth/", include(login_urls)),
]
45 changes: 43 additions & 2 deletions core/views.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,21 @@
from django.shortcuts import render
from urllib.parse import urlencode
from django.shortcuts import redirect, render
from core.discord_api import (
CLIENT_ID,
DISCORD_REFRESH_COOKIE,
REDIRECT_URI,
code_to_token,
refresh_token,
)
from core.discord_auth import go_back, require_discord_login, set_cookies


def index(request):
return render(request, "core/index.html")


def profile(request):
@require_discord_login()
def profile(request, _):
# Those are placeholder data
user = {
"name": "Salieri",
Expand All @@ -15,3 +25,34 @@ def profile(request):
"max_exp": "2137",
}
return render(request, "core/profile.html", context={"user": user})


def login(request):
return render(request, "core/login.html")


def discord_login(_):
query = urlencode(
{
"client_id": CLIENT_ID,
"response_type": "code",
"redirect_uri": REDIRECT_URI,
"scope": "identify guilds.members.read",
"prompt": "none",
}
)
return redirect(f"https://discord.com/oauth2/authorize?{query}")


def discord_code(request):
code = request.GET["code"]
auth_data = code_to_token(code)
return set_cookies(go_back(request), auth_data)


def discord_refresh(request):
token = request.COOKIES.get(DISCORD_REFRESH_COOKIE)
if token is None:
return redirect("core:login")

return set_cookies(go_back(request), refresh_token(token))
7 changes: 7 additions & 0 deletions gwardia_hub/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,3 +135,10 @@
# https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field

DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"

# Discord Auth
CLIENT_ID = env("CLIENT_ID")
CLIENT_SECRET = env("CLIENT_SECRET")
GUILD_ID = env("GUILD_ID")
MEMBER_ROLE_ID = env("MEMBER_ROLE_ID")
LEADER_ROLE_ID = env("LEADER_ROLE_ID")
Loading

0 comments on commit 6a0379c

Please sign in to comment.