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

Add Discord authentication #13

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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
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