Working start of an OAuth flow

pull/160/head
Andrew Godwin 2022-12-10 21:03:14 -07:00
rodzic a8d1450763
commit 1017c71ba1
20 zmienionych plików z 449 dodań i 3 usunięć

Wyświetl plik

@ -95,5 +95,5 @@ class PostAttachment(StatorModel):
"width": self.width,
"height": self.height,
"mediaType": self.mimetype,
"http://joinmastodon.org/ns#focalPoint": [0.5, 0.5],
"http://joinmastodon.org/ns#focalPoint": [0, 0],
}

0
api/__init__.py 100644
Wyświetl plik

13
api/admin.py 100644
Wyświetl plik

@ -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"]

6
api/apps.py 100644
Wyświetl plik

@ -0,0 +1,6 @@
from django.apps import AppConfig
class ApiConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "api"

Wyświetl plik

@ -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,
),
),
],
),
]

Wyświetl plik

Wyświetl plik

@ -0,0 +1,2 @@
from .application import Application # noqa
from .token import Token # noqa

Wyświetl plik

@ -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)

Wyświetl plik

@ -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)

20
api/parser.py 100644
Wyświetl plik

@ -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

Wyświetl plik

@ -0,0 +1,3 @@
from .apps import * # noqa
from .base import api # noqa
from .instance import * # noqa

37
api/views/apps.py 100644
Wyświetl plik

@ -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

Wyświetl plik

@ -0,0 +1,5 @@
from ninja import NinjaAPI
from api.parser import FormOrJsonParser
api = NinjaAPI(parser=FormOrJsonParser())

Wyświetl plik

@ -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": [],
}

105
api/views/oauth.py 100644
Wyświetl plik

@ -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

Wyświetl plik

@ -3,7 +3,10 @@ blurhash-python~=1.1.3
cryptography~=38.0
dj_database_url~=1.0.0
django-cache-url~=3.4.2
django-cors-headers~=3.13.0
django-htmx~=1.13.0
django-ninja~=0.19.1
django-oauth-toolkit~=2.2.0
django-storages[google,boto3]~=1.13.1
django~=4.1
email-validator~=1.3.0

Wyświetl plik

@ -169,16 +169,19 @@ INSTALLED_APPS = [
"django.contrib.messages",
"django.contrib.staticfiles",
"django_htmx",
"corsheaders",
"core",
"activities",
"users",
"stator",
"api",
"mediaproxy",
"stator",
"users",
]
MIDDLEWARE = [
"core.middleware.SentryTaggingMiddleware",
"django.middleware.security.SecurityMiddleware",
"corsheaders.middleware.CorsMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
@ -278,6 +281,7 @@ AUTO_ADMIN_EMAIL = SETUP.AUTO_ADMIN_EMAIL
STATOR_TOKEN = SETUP.STATOR_TOKEN
CORS_ORIGIN_ALLOW_ALL = True # Temporary
CORS_ORIGIN_WHITELIST = SETUP.CORS_HOSTS
CORS_ALLOW_CREDENTIALS = True
CORS_PREFLIGHT_MAX_AGE = 604800
@ -288,6 +292,7 @@ MEDIA_URL = SETUP.MEDIA_URL
MEDIA_ROOT = SETUP.MEDIA_ROOT
MAIN_DOMAIN = SETUP.MAIN_DOMAIN
if SETUP.USE_PROXY_HEADERS:
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")

Wyświetl plik

@ -4,6 +4,7 @@ from django.urls import path, re_path
from django.views.static import serve
from activities.views import compose, explore, follows, posts, search, timelines
from api.views import api, oauth
from core import views as core
from mediaproxy import views as mediaproxy
from stator import views as stator
@ -201,6 +202,11 @@ urlpatterns = [
path("actor/", activitypub.SystemActorView.as_view()),
path("actor/inbox/", activitypub.Inbox.as_view()),
path("inbox/", activitypub.Inbox.as_view(), name="shared_inbox"),
# API/Oauth
path("api/", api.urls),
path("oauth/authorize", oauth.AuthorizationView.as_view()),
path("oauth/token", oauth.TokenView.as_view()),
path("oauth/revoke_token", oauth.RevokeTokenView.as_view()),
# Stator
path(".stator/", stator.RequestRunner.as_view()),
# Django admin

Wyświetl plik

@ -0,0 +1,39 @@
{% extends "base.html" %}
{% block title %}Authorize {{ application.name }}{% endblock %}
{% block content %}
{% if not identities %}
<p>
You cannot give access to {{ application.name }} as you
have no identities yet. Log in via the website and create
at least one identity, then retry this process.
</p>
{% else %}
<form method="POST">
{% csrf_token %}
<fieldset>
<legend>Authorize</legend>
<div class="field">
<div class="label-input">
<label for="identity">Select Identity</label>
<select name="identity" id="identity">
{% for identity in identities %}
<option value="{{ identity.pk }}">{{ identity.handle }}</option>
{% endfor %}
</select>
</div>
</div>
<p>Do you want to give {{ application.name }} access to this identity?</p>
<p>It will have permission to: {{ scope }}</p>
<input type="hidden" name="client_id" value="{{ application.client_id }}">
<input type="hidden" name="redirect_uri" value="{{ redirect_uri }}">
<input type="hidden" name="scope" value="{{ scope }}">
</fieldset>
<div class="buttons">
<a href="#" class="secondary button left">Deny</a>
<button>Allow</button>
</div>
</form>
{% endif %}
{% endblock %}

Wyświetl plik

@ -11,6 +11,7 @@
{% include "forms/_field.html" %}
{% endfor %}
</fieldset>
<input type="hidden" name="next" value="{{ next }}" />
<div class="buttons">
<a href="{% url "trigger_reset" %}" class="secondary button left">Forgot Password</a>
<button>Login</button>