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/views/__init__.py | 3 ++ api/views/apps.py | 37 ++++++++++++++++++ api/views/base.py | 5 +++ api/views/instance.py | 56 +++++++++++++++++++++++++++ api/views/oauth.py | 105 ++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 206 insertions(+) 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/views') 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