From 1017c71ba1d80a1690e357a938ad46f246a456ae Mon Sep 17 00:00:00 2001 From: Andrew Godwin Date: Sat, 10 Dec 2022 21:03:14 -0700 Subject: Working start of an OAuth flow --- api/__init__.py | 0 api/admin.py | 13 +++++ api/apps.py | 6 +++ api/migrations/0001_initial.py | 87 ++++++++++++++++++++++++++++++++++ api/migrations/__init__.py | 0 api/models/__init__.py | 2 + api/models/application.py | 19 ++++++++ api/models/token.py | 39 +++++++++++++++ api/parser.py | 20 ++++++++ api/views/__init__.py | 3 ++ api/views/apps.py | 37 +++++++++++++++ api/views/base.py | 5 ++ api/views/instance.py | 56 ++++++++++++++++++++++ api/views/oauth.py | 105 +++++++++++++++++++++++++++++++++++++++++ 14 files changed, 392 insertions(+) create mode 100644 api/__init__.py create mode 100644 api/admin.py create mode 100644 api/apps.py create mode 100644 api/migrations/0001_initial.py create mode 100644 api/migrations/__init__.py create mode 100644 api/models/__init__.py create mode 100644 api/models/application.py create mode 100644 api/models/token.py create mode 100644 api/parser.py create mode 100644 api/views/__init__.py create mode 100644 api/views/apps.py create mode 100644 api/views/base.py create mode 100644 api/views/instance.py create mode 100644 api/views/oauth.py (limited to 'api') diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/admin.py b/api/admin.py new file mode 100644 index 0000000..072abb0 --- /dev/null +++ b/api/admin.py @@ -0,0 +1,13 @@ +from django.contrib import admin + +from api.models import Application, Token + + +@admin.register(Application) +class ApplicationAdmin(admin.ModelAdmin): + list_display = ["id", "name", "website", "created"] + + +@admin.register(Token) +class TokenAdmin(admin.ModelAdmin): + list_display = ["id", "user", "application", "created"] diff --git a/api/apps.py b/api/apps.py new file mode 100644 index 0000000..878e7d5 --- /dev/null +++ b/api/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ApiConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "api" diff --git a/api/migrations/0001_initial.py b/api/migrations/0001_initial.py new file mode 100644 index 0000000..e9d37f3 --- /dev/null +++ b/api/migrations/0001_initial.py @@ -0,0 +1,87 @@ +# Generated by Django 4.1.3 on 2022-12-11 03:39 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("users", "0003_identity_followers_etc"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Application", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("client_id", models.CharField(max_length=500)), + ("client_secret", models.CharField(max_length=500)), + ("redirect_uris", models.TextField()), + ("scopes", models.TextField()), + ("name", models.CharField(max_length=500)), + ("website", models.CharField(blank=True, max_length=500, null=True)), + ("created", models.DateTimeField(auto_now_add=True)), + ("updated", models.DateTimeField(auto_now=True)), + ], + ), + migrations.CreateModel( + name="Token", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("token", models.CharField(max_length=500)), + ("code", models.CharField(blank=True, max_length=100, null=True)), + ("scopes", models.JSONField()), + ("created", models.DateTimeField(auto_now_add=True)), + ("updated", models.DateTimeField(auto_now=True)), + ( + "application", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="tokens", + to="api.application", + ), + ), + ( + "identity", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="tokens", + to="users.identity", + ), + ), + ( + "user", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="tokens", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + ] diff --git a/api/migrations/__init__.py b/api/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/models/__init__.py b/api/models/__init__.py new file mode 100644 index 0000000..663cd7e --- /dev/null +++ b/api/models/__init__.py @@ -0,0 +1,2 @@ +from .application import Application # noqa +from .token import Token # noqa diff --git a/api/models/application.py b/api/models/application.py new file mode 100644 index 0000000..89bea5f --- /dev/null +++ b/api/models/application.py @@ -0,0 +1,19 @@ +from django.db import models + + +class Application(models.Model): + """ + OAuth applications + """ + + client_id = models.CharField(max_length=500) + client_secret = models.CharField(max_length=500) + + redirect_uris = models.TextField() + scopes = models.TextField() + + name = models.CharField(max_length=500) + website = models.CharField(max_length=500, blank=True, null=True) + + created = models.DateTimeField(auto_now_add=True) + updated = models.DateTimeField(auto_now=True) diff --git a/api/models/token.py b/api/models/token.py new file mode 100644 index 0000000..dc57cec --- /dev/null +++ b/api/models/token.py @@ -0,0 +1,39 @@ +from django.db import models + + +class Token(models.Model): + """ + An (access) token to call the API with. + + Can be either tied to a user, or app-level only. + """ + + application = models.ForeignKey( + "api.Application", + on_delete=models.CASCADE, + related_name="tokens", + ) + + user = models.ForeignKey( + "users.User", + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="tokens", + ) + + identity = models.ForeignKey( + "users.Identity", + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="tokens", + ) + + token = models.CharField(max_length=500) + code = models.CharField(max_length=100, blank=True, null=True) + + scopes = models.JSONField() + + created = models.DateTimeField(auto_now_add=True) + updated = models.DateTimeField(auto_now=True) diff --git a/api/parser.py b/api/parser.py new file mode 100644 index 0000000..63e283f --- /dev/null +++ b/api/parser.py @@ -0,0 +1,20 @@ +import json + +from ninja.parser import Parser + + +class FormOrJsonParser(Parser): + """ + If there's form data in a request, makes it into a JSON dict. + This is needed as the Mastodon API allows form data OR json body as input. + """ + + def parse_body(self, request): + # Did they submit JSON? + if request.content_type == "application/json": + return json.loads(request.body) + # Fall back to form data + value = {} + for key, item in request.POST.items(): + value[key] = item + return value diff --git a/api/views/__init__.py b/api/views/__init__.py new file mode 100644 index 0000000..d661e7c --- /dev/null +++ b/api/views/__init__.py @@ -0,0 +1,3 @@ +from .apps import * # noqa +from .base import api # noqa +from .instance import * # noqa diff --git a/api/views/apps.py b/api/views/apps.py new file mode 100644 index 0000000..33ecf0f --- /dev/null +++ b/api/views/apps.py @@ -0,0 +1,37 @@ +import secrets + +from ninja import Field, Schema + +from ..models import Application +from .base import api + + +class CreateApplicationSchema(Schema): + client_name: str + redirect_uris: str + scopes: None | str = None + website: None | str = None + + +class ApplicationSchema(Schema): + id: str + name: str + website: str | None + client_id: str + client_secret: str + redirect_uri: str = Field(alias="redirect_uris") + + +@api.post("/v1/apps", response=ApplicationSchema) +def add_app(request, details: CreateApplicationSchema): + client_id = "tk-" + secrets.token_urlsafe(16) + client_secret = secrets.token_urlsafe(40) + application = Application.objects.create( + name=details.client_name, + website=details.website, + client_id=client_id, + client_secret=client_secret, + redirect_uris=details.redirect_uris, + scopes=details.scopes or "read", + ) + return application diff --git a/api/views/base.py b/api/views/base.py new file mode 100644 index 0000000..e9a087d --- /dev/null +++ b/api/views/base.py @@ -0,0 +1,5 @@ +from ninja import NinjaAPI + +from api.parser import FormOrJsonParser + +api = NinjaAPI(parser=FormOrJsonParser()) diff --git a/api/views/instance.py b/api/views/instance.py new file mode 100644 index 0000000..5923d30 --- /dev/null +++ b/api/views/instance.py @@ -0,0 +1,56 @@ +from django.conf import settings + +from activities.models import Post +from core.models import Config +from takahe import __version__ +from users.models import Domain, Identity + +from .base import api + + +@api.get("/v1/instance") +@api.get("/v1/instance/") +def instance_info(request): + return { + "uri": request.headers.get("host", settings.SETUP.MAIN_DOMAIN), + "title": Config.system.site_name, + "short_description": "", + "description": "", + "email": "", + "version": __version__, + "urls": {}, + "stats": { + "user_count": Identity.objects.filter(local=True).count(), + "status_count": Post.objects.filter(local=True).count(), + "domain_count": Domain.objects.count(), + }, + "thumbnail": Config.system.site_banner, + "languages": ["en"], + "registrations": ( + Config.system.signup_allowed and not Config.system.signup_invite_only + ), + "approval_required": False, + "invites_enabled": False, + "configuration": { + "accounts": {}, + "statuses": { + "max_characters": Config.system.post_length, + "max_media_attachments": 4, + "characters_reserved_per_url": 23, + }, + "media_attachments": { + "supported_mime_types": [ + "image/apng", + "image/avif", + "image/gif", + "image/jpeg", + "image/png", + "image/webp", + ], + "image_size_limit": (1024**2) * 10, + "image_matrix_limit": 2000 * 2000, + }, + }, + "contact_account": None, + "rules": [], + } diff --git a/api/views/oauth.py b/api/views/oauth.py new file mode 100644 index 0000000..6be2778 --- /dev/null +++ b/api/views/oauth.py @@ -0,0 +1,105 @@ +import secrets +from urllib.parse import urlparse + +from django.contrib.auth.mixins import LoginRequiredMixin +from django.http import HttpResponseRedirect, JsonResponse +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import csrf_exempt +from django.views.generic import TemplateView, View + +from api.models import Application, Token + + +class OauthRedirect(HttpResponseRedirect): + def __init__(self, redirect_uri, key, value): + self.allowed_schemes = [urlparse(redirect_uri).scheme] + super().__init__(redirect_uri + f"?{key}={value}") + + +class AuthorizationView(LoginRequiredMixin, TemplateView): + """ + Asks the user to authorize access. + + Could maybe be a FormView, but things are weird enough we just handle the + POST manually. + """ + + template_name = "api/oauth_authorize.html" + + def get_context_data(self): + redirect_uri = self.request.GET["redirect_uri"] + scope = self.request.GET.get("scope", "read") + try: + application = Application.objects.get( + client_id=self.request.GET["client_id"] + ) + except (Application.DoesNotExist, KeyError): + return OauthRedirect(redirect_uri, "error", "invalid_application") + return { + "application": application, + "redirect_uri": redirect_uri, + "scope": scope, + "identities": self.request.user.identities.all(), + } + + def post(self, request): + # Grab the application and other details again + redirect_uri = self.request.POST["redirect_uri"] + scope = self.request.POST["scope"] + application = Application.objects.get(client_id=self.request.POST["client_id"]) + # Get the identity + identity = self.request.user.identities.get(pk=self.request.POST["identity"]) + # Make a token + token = Token.objects.create( + application=application, + user=self.request.user, + identity=identity, + token=secrets.token_urlsafe(32), + code=secrets.token_urlsafe(16), + scopes=scope.split(), + ) + # Redirect with the token's code + return OauthRedirect(redirect_uri, "code", token.code) + + +@method_decorator(csrf_exempt, name="dispatch") +class TokenView(View): + def post(self, request): + grant_type = request.POST["grant_type"] + scopes = set(self.request.POST.get("scope", "read").split()) + try: + application = Application.objects.get( + client_id=self.request.POST["client_id"] + ) + except (Application.DoesNotExist, KeyError): + return JsonResponse({"error": "invalid_client_id"}, status=400) + # TODO: Implement client credentials flow + if grant_type == "client_credentials": + return JsonResponse({"error": "invalid_grant_type"}, status=400) + elif grant_type == "authorization_code": + code = request.POST["code"] + # Retrieve the token by code + # TODO: Check code expiry based on created date + try: + token = Token.objects.get(code=code, application=application) + except Token.DoesNotExist: + return JsonResponse({"error": "invalid_code"}, status=400) + # Verify the scopes match the token + if scopes != set(token.scopes): + return JsonResponse({"error": "invalid_scope"}, status=400) + # Update the token to remove its code + token.code = None + token.save() + # Return them the token + return JsonResponse( + { + "access_token": token.token, + "token_type": "Bearer", + "scope": " ".join(token.scopes), + "created_at": int(token.created.timestamp()), + } + ) + + +class RevokeTokenView(View): + pass -- cgit v1.2.3