Working start of an OAuth flow
This commit is contained in:
parent
a8d1450763
commit
1017c71ba1
@ -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
0
api/__init__.py
Normal file
13
api/admin.py
Normal file
13
api/admin.py
Normal 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
6
api/apps.py
Normal file
@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ApiConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "api"
|
87
api/migrations/0001_initial.py
Normal file
87
api/migrations/0001_initial.py
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
0
api/migrations/__init__.py
Normal file
0
api/migrations/__init__.py
Normal file
2
api/models/__init__.py
Normal file
2
api/models/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
from .application import Application # noqa
|
||||
from .token import Token # noqa
|
19
api/models/application.py
Normal file
19
api/models/application.py
Normal 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
39
api/models/token.py
Normal 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
20
api/parser.py
Normal 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
3
api/views/__init__.py
Normal 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
37
api/views/apps.py
Normal 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
5
api/views/base.py
Normal 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
56
api/views/instance.py
Normal 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
105
api/views/oauth.py
Normal 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
|
@ -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
|
||||
|
@ -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")
|
||||
|
||||
|
@ -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
|
||||
|
39
templates/api/oauth_authorize.html
Normal file
39
templates/api/oauth_authorize.html
Normal 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 %}
|
@ -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>
|
||||
|
Reference in New Issue
Block a user