diff --git a/sidewinder/identity/admin.py b/sidewinder/identity/admin.py index c4ea9f4..f4280ec 100644 --- a/sidewinder/identity/admin.py +++ b/sidewinder/identity/admin.py @@ -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): @@ -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',) }), @@ -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',) diff --git a/sidewinder/identity/migrations/0005_add_discord_authentication.py b/sidewinder/identity/migrations/0005_add_discord_authentication.py new file mode 100644 index 0000000..6df9cc2 --- /dev/null +++ b/sidewinder/identity/migrations/0005_add_discord_authentication.py @@ -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', + }, + ), + ] diff --git a/sidewinder/identity/models.py b/sidewinder/identity/models.py index 837bbcd..2042693 100644 --- a/sidewinder/identity/models.py +++ b/sidewinder/identity/models.py @@ -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'] @@ -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' diff --git a/sidewinder/identity/urls.py b/sidewinder/identity/urls.py index cc07e2d..dc65abb 100644 --- a/sidewinder/identity/urls.py +++ b/sidewinder/identity/urls.py @@ -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), ] diff --git a/sidewinder/identity/views.py b/sidewinder/identity/views.py index 8299edb..2bf95b8 100644 --- a/sidewinder/identity/views.py +++ b/sidewinder/identity/views.py @@ -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 @@ -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') @@ -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 = "/" @@ -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 @@ -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: