Working start of an OAuth flow

This commit is contained in:
Andrew Godwin 2022-12-10 21:03:14 -07:00
parent a8d1450763
commit 1017c71ba1
20 changed files with 449 additions and 3 deletions

View file

@ -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 Normal file
View file

13
api/admin.py Normal file
View file

@ -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 Normal file
View file

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

View file

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

View file

2
api/models/__init__.py Normal file
View file

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

19
api/models/application.py Normal file
View file

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

39
api/models/token.py Normal file
View file

@ -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 Normal file
View file

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

3
api/views/__init__.py Normal file
View file

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

37
api/views/apps.py Normal file
View file

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

5
api/views/base.py Normal file
View file

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

56
api/views/instance.py Normal file
View file

@ -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 Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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