Skip to content

Commit

Permalink
Add Discord authentication
Browse files Browse the repository at this point in the history
Fixes #12
  • Loading branch information
haykam821 committed Apr 22, 2023
1 parent 98054c7 commit 26cb2c0
Show file tree
Hide file tree
Showing 5 changed files with 202 additions and 13 deletions.
17 changes: 10 additions & 7 deletions sidewinder/identity/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from django.urls import path
from solo.admin import SingletonModelAdmin

from sidewinder.identity.models import User, RedditCredentials, RedditApplication
from sidewinder.identity.models import User, RedditCredentials, DiscordCredentials, RedditApplication, DiscordApplication


class IdentityUserChangeForm(UserChangeForm):
Expand All @@ -17,17 +17,20 @@ class UserAdmin(admin.ModelAdmin):
form = IdentityUserChangeForm
list_display = ('username', 'uid', 'pronouns',)
list_filter = ('is_staff', 'is_active',)
readonly_fields = ('uid',)
readonly_fields = ('uid', 'discord_id',)
change_password_form = AdminPasswordChangeForm
change_user_password_template = None

fieldsets = (
('User details', {
"fields": ('username', 'uid', 'password', 'date_joined',)
"fields": ('username', 'password', 'date_joined',)
}),
('Profile', {
"fields": ('email', 'pronouns',)
}),
('Connections', {
"fields": ('uid', 'discord_id',)
}),
('Permissions', {
"fields": ('is_active', 'is_staff', 'is_superuser', 'groups', 'user_permissions',)
}),
Expand All @@ -44,10 +47,10 @@ def lookup_allowed(self, lookup, value):
# Don't allow lookups involving passwords.
return not lookup.startswith('password') and super().lookup_allowed(lookup, value)

@admin.register(RedditApplication)
class RedditAppAdmin(SingletonModelAdmin):
@admin.register(RedditApplication, DiscordApplication)
class RedditDiscordAppAdmin(SingletonModelAdmin):
list_display = ('name',)

@admin.register(RedditCredentials)
class RedditCredentialsAdmin(admin.ModelAdmin):
@admin.register(RedditCredentials, DiscordCredentials)
class CredentialsAdmin(admin.ModelAdmin):
list_display = ('user', 'last_refresh',)
52 changes: 52 additions & 0 deletions sidewinder/identity/migrations/0005_add_discord_authentication.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Generated by Django 4.0.3 on 2023-04-22 22:02

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
('identity', '0004_increase_client_id_secret_length'),
]

operations = [
migrations.CreateModel(
name='DiscordApplication',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=128)),
('client_id', models.CharField(max_length=20)),
('client_secret', models.CharField(max_length=32)),
],
options={
'verbose_name': 'Discord Application',
'verbose_name_plural': 'Discord Applications',
},
),
migrations.AddField(
model_name='user',
name='discord_id',
field=models.CharField(blank=True, verbose_name='Discord ID', max_length=20),
),
migrations.AlterField(
model_name='redditcredentials',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reddit_tokens', to=settings.AUTH_USER_MODEL),
),
migrations.CreateModel(
name='DiscordCredentials',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('access_token', models.CharField(max_length=200)),
('refresh_token', models.CharField(max_length=200)),
('last_refresh', models.DateTimeField()),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='discord_tokens', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Discord Credentials',
'verbose_name_plural': 'Discord Credentials',
},
),
]
30 changes: 28 additions & 2 deletions sidewinder/identity/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ class User(AbstractBaseUser, PermissionsMixin, UserMixin):
email = models.EmailField(verbose_name='email address', blank=True)
pronouns = models.CharField(max_length=300, default='unspecified')

discord_id = models.CharField(max_length=20, verbose_name="Discord ID", blank=True)

USERNAME_FIELD = 'username'
EMAIL_FIELD = 'email'
REQUIRED_FIELDS = ['uid', 'email']
Expand All @@ -50,16 +52,40 @@ class Meta:
verbose_name = 'Reddit Application'
verbose_name_plural = 'Reddit Applications'

class RedditCredentials(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='tokens')
class DiscordApplication(SingletonModel):
name = models.CharField(max_length=128)

client_id = models.CharField(max_length=20)
client_secret = models.CharField(max_length=32)

class Meta:
verbose_name = 'Discord Application'
verbose_name_plural = 'Discord Applications'

class Credentials(models.Model):
access_token = models.CharField(max_length=200)
refresh_token = models.CharField(max_length=200)
last_refresh = models.DateTimeField()

class Meta:
abstract = True

class RedditCredentials(Credentials):
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='reddit_tokens')

def __str__(self):
return f"{self.user.username} - Reddit"

class Meta:
verbose_name = 'Reddit Credentials'
verbose_name_plural = 'Reddit Credentials'

class DiscordCredentials(Credentials):
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='discord_tokens')

def __str__(self):
return f"{self.user.username} - Discord"

class Meta:
verbose_name = 'Discord Credentials'
verbose_name_plural = 'Discord Credentials'
4 changes: 3 additions & 1 deletion sidewinder/identity/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,7 @@
path('@me', views.get_current_user),
path('@me/profile/', views.edit_profile),
path('reddit/login/', views.reddit_login),
path('reddit/authorize/', views.authorize_callback),
path('discord/login/', views.discord_login),
path('reddit/authorize/', views.reddit_authorize_callback),
path('discord/authorize/', views.discord_authorize_callback),
]
112 changes: 109 additions & 3 deletions sidewinder/identity/views.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import requests

from django.contrib import messages
from django.contrib.auth import login
from django.http import HttpRequest, HttpResponseRedirect, JsonResponse, HttpResponse
Expand All @@ -6,14 +8,15 @@
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from praw import Reddit
from requests.models import PreparedRequest

from sidewinder.identity.models import RedditApplication, User, RedditCredentials
from sidewinder.identity.models import RedditApplication, DiscordApplication, User, RedditCredentials, DiscordCredentials
from sidewinder.utils import generate_state_token

def _build_reddit(request: HttpRequest) -> Reddit:
app = RedditApplication.get_solo()
return Reddit(client_id=app.client_id, client_secret=app.client_secret,
redirect_uri=request.build_absolute_uri(reverse(authorize_callback)),
redirect_uri=request.build_absolute_uri(reverse(reddit_authorize_callback)),
user_agent='Sidewinder/1.0.0')


Expand All @@ -30,8 +33,29 @@ def reddit_login(request: HttpRequest):

return HttpResponseRedirect(redirect_url)

def discord_login(request: HttpRequest):
app = DiscordApplication.get_solo()

state = generate_state_token()

redirect = PreparedRequest()
redirect.prepare_url("https://discord.com/api/oauth2/authorize", {
'response_type': 'code',
'client_id': app.client_id,
'scope': 'identify',
'state': state,
'redirect_uri': request.build_absolute_uri(reverse(discord_authorize_callback)),
'prompt': 'none'
})

if 'return_to' in request.GET:
request.session['return_to'] = request.GET['return_to']

request.session['state'] = state

return HttpResponseRedirect(redirect.url)

def authorize_callback(request: HttpRequest):
def reddit_authorize_callback(request: HttpRequest):
reddit = _build_reddit(request)
redirect_to = "/"

Expand Down Expand Up @@ -78,6 +102,87 @@ def authorize_callback(request: HttpRequest):
return HttpResponseRedirect(redirect_to)


def discord_authorize_callback(request: HttpRequest):
redirect_to = "/"

if 'return_to' in request.session:
redirect_to = request.session['return_to']
redirect_to = request.build_absolute_uri(redirect_to)

if not request.user.is_authenticated:
messages.error(request, 'Not signed in')

return HttpResponseRedirect(redirect_to)

if 'error' in request.GET:
error_msg = request.GET['error']
messages.error(request, f"Couldn't authorize you with Discord: {error_msg}")

return HttpResponseRedirect(redirect_to)

if request.GET['state'] != request.session['state']:
messages.error(request, "Couldn't authorize you with Discord: invalid state parameter")

return HttpResponseRedirect(redirect_to)

code = request.GET['code']

app = DiscordApplication.get_solo()

token_response = requests.post('https://discord.com/api/v10/oauth2/token', data={
'client_id': app.client_id,
'client_secret': app.client_secret,
'grant_type': 'authorization_code',
'code': code,
'redirect_uri': request.build_absolute_uri(reverse(discord_authorize_callback)),
}, headers={
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': 'Sidewinder/1.0.0'
}).json()

if 'error' in token_response:
error_msg = token_response['error']
messages.error(request, f"Couldn't authorize you with Discord: failed to get token: {error_msg}")

return HttpResponseRedirect(redirect_to)

refresh_token = token_response['refresh_token']
access_token = token_response['access_token']

user_response = requests.get('https://discord.com/api/v10/users/@me', headers={
'Authorization': 'Bearer ' + access_token,
'User-Agent': 'Sidewinder/1.0.0'
}).json()

if 'error' in user_response:
error_msg = user_response['error']
messages.error(request, f"Couldn't authorize you with Discord: failed to identify user: {error_msg}")

return HttpResponseRedirect(redirect_to)

id = user_response['id']

if request.user.discord_id != '' and request.user.discord_id != id:
messages.error(request, f"Couldn't authorize you with Discord: mismatched user")

return HttpResponseRedirect(redirect_to)

request.user.discord_id = id
request.user.save(update_fields=['discord_id'])

creds, created = DiscordCredentials.objects.get_or_create(
user=request.user,
defaults=dict(access_token=access_token, refresh_token=refresh_token, last_refresh=timezone.now())
)

if not created:
creds.access_token = access_token
creds.refresh_token = refresh_token
creds.last_refresh = timezone.now()
creds.save()

return HttpResponseRedirect(redirect_to)

def get_current_user(request):
if request.user.is_authenticated:
user: User = request.user
Expand All @@ -86,6 +191,7 @@ def get_current_user(request):
"uid": user.uid,
"username": user.username,
"pronouns": user.pronouns,
"discord_id": user.discord_id,
"is_staff": user.is_staff,
})
else:
Expand Down

0 comments on commit 26cb2c0

Please sign in to comment.