diff --git a/boofilsic/settings.py b/boofilsic/settings.py index 1b36bbb8..036357e8 100644 --- a/boofilsic/settings.py +++ b/boofilsic/settings.py @@ -305,6 +305,7 @@ INSTALLED_APPS += [ "catalog.apps.CatalogConfig", "journal.apps.JournalConfig", "social.apps.SocialConfig", + "developer.apps.DeveloperConfig", "takahe.apps.TakaheConfig", "legacy.apps.LegacyConfig", ] @@ -312,6 +313,9 @@ INSTALLED_APPS += [ for app in env("NEODB_EXTRA_APPS"): INSTALLED_APPS.append(app) +INSTALLED_APPS += [ # we may override templates in these 3rd party apps + "oauth2_provider", +] MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", diff --git a/developer/__init__.py b/developer/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/developer/admin.py b/developer/admin.py new file mode 100644 index 00000000..8c38f3f3 --- /dev/null +++ b/developer/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/developer/apps.py b/developer/apps.py new file mode 100644 index 00000000..2ee12960 --- /dev/null +++ b/developer/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class DeveloperConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "developer" diff --git a/developer/migrations/0001_initial.py b/developer/migrations/0001_initial.py new file mode 100644 index 00000000..aa05f04b --- /dev/null +++ b/developer/migrations/0001_initial.py @@ -0,0 +1,128 @@ +# Generated by Django 3.2.19 on 2023-06-28 05:09 + +import django.core.validators +import django.db.models.deletion +import markdownx.models +import oauth2_provider.generators +import oauth2_provider.models +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Application", + fields=[ + ("id", models.BigAutoField(primary_key=True, serialize=False)), + ( + "client_id", + models.CharField( + db_index=True, + default=oauth2_provider.generators.generate_client_id, + max_length=100, + unique=True, + ), + ), + ( + "redirect_uris", + models.TextField( + blank=True, help_text="Allowed URIs list, space separated" + ), + ), + ( + "post_logout_redirect_uris", + models.TextField( + blank=True, + help_text="Allowed Post Logout URIs list, space separated", + ), + ), + ( + "client_type", + models.CharField( + choices=[ + ("confidential", "Confidential"), + ("public", "Public"), + ], + max_length=32, + ), + ), + ( + "authorization_grant_type", + models.CharField( + choices=[ + ("authorization-code", "Authorization code"), + ("implicit", "Implicit"), + ("password", "Resource owner password-based"), + ("client-credentials", "Client credentials"), + ("openid-hybrid", "OpenID connect hybrid"), + ], + max_length=32, + ), + ), + ( + "client_secret", + oauth2_provider.models.ClientSecretField( + blank=True, + db_index=True, + default=oauth2_provider.generators.generate_client_secret, + help_text="Hashed on Save. Copy it now if this is a new secret.", + max_length=255, + ), + ), + ("skip_authorization", models.BooleanField(default=False)), + ("created", models.DateTimeField(auto_now_add=True)), + ("updated", models.DateTimeField(auto_now=True)), + ( + "algorithm", + models.CharField( + blank=True, + choices=[ + ("", "No OIDC support"), + ("RS256", "RSA with SHA-2 256"), + ("HS256", "HMAC with SHA-2 256"), + ], + default="", + max_length=5, + ), + ), + ( + "name", + models.CharField( + max_length=255, + validators=[ + django.core.validators.RegexValidator( + message="minimum two characters, words and -_. only, no special characters", + regex="^\\w[\\w_\\-. ]*\\w$", + ) + ], + ), + ), + ( + "description", + markdownx.models.MarkdownxField(blank=True, default=""), + ), + ("url", models.URLField(blank=True, null=True)), + ("is_official", models.BooleanField(default=False)), + ( + "user", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="developer_application", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "abstract": False, + }, + ), + ] diff --git a/developer/migrations/0002_alter_application_user.py b/developer/migrations/0002_alter_application_user.py new file mode 100644 index 00000000..0a8c33f0 --- /dev/null +++ b/developer/migrations/0002_alter_application_user.py @@ -0,0 +1,26 @@ +# Generated by Django 4.2.3 on 2023-07-06 22:53 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("developer", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="application", + name="user", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(app_label)s_%(class)s", + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/developer/migrations/0003_alter_application_redirect_uris.py b/developer/migrations/0003_alter_application_redirect_uris.py new file mode 100644 index 00000000..079c613c --- /dev/null +++ b/developer/migrations/0003_alter_application_redirect_uris.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.3 on 2023-07-13 04:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("developer", "0002_alter_application_user"), + ] + + operations = [ + migrations.AlterField( + model_name="application", + name="redirect_uris", + field=models.TextField( + help_text="Allowed URIs list, space separated, at least one URI is required" + ), + ), + ] diff --git a/developer/migrations/__init__.py b/developer/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/developer/models.py b/developer/models.py new file mode 100644 index 00000000..1ca80821 --- /dev/null +++ b/developer/models.py @@ -0,0 +1,33 @@ +from django.core.validators import RegexValidator +from django.db import models +from django.utils.translation import gettext_lazy as _ +from markdownx.models import MarkdownxField +from oauth2_provider.models import AbstractApplication + +from journal.models.renderers import render_md + + +class Application(AbstractApplication): + name = models.CharField( + max_length=255, + blank=False, + validators=[ + RegexValidator( + regex=r"^\w[\w_\-. ]*\w$", + message=_( + "minimum two characters, words and -_. only, no special characters" + ), + ), + ], + ) + description = MarkdownxField(default="", blank=True) + url = models.URLField(null=True, blank=True) + is_official = models.BooleanField(default=False) + unique_together = [["user", "name"]] + redirect_uris = models.TextField( + blank=False, + help_text=_("Allowed URIs list, space separated, at least one URI is required"), + ) + + def description_html(self): + return render_md(self.description) diff --git a/developer/templates/console.html b/developer/templates/console.html new file mode 100644 index 00000000..605c58ca --- /dev/null +++ b/developer/templates/console.html @@ -0,0 +1,112 @@ +{% load i18n %} + + + + + {{ api.title }} Developer Console + {% include "common_libs.html" %} + + + + {% include "_header.html" %} +
+

+ Developer Console | {% trans "Your applications" %} +

+

+ By using our APIs, you agree to our Terms of Service. +

+
+ + Test Access Token + +
+ {% csrf_token %} + + +
+

+ Click Authorize button below, input your token there to invoke APIs with your account, which is required for APIs like /api/me +
+ Or use it in command line, like + curl -H "Authorization: Bearer YOUR_TOKEN" {{ site_url }}/api/me +

+
+
+
+ + How to authorize + + 0. Create an application (you must have at least one URL included in the Redirect URIs field, e.g. https://example.org/callback) +
+ 1. Guide your user to open this URL + + 2. Once authorizated by user, it will redirect to https://example.org/callback with a code parameter: + + 3. Obtain access token with the following POST request: + + and access token will be returned in the response: + + 4. Use the access token to access protected endpoints like /api/me + + and response will be returned accordingly: + + more endpoints can be found in API Documentation below. +
+
+ + +
+ {% include "_footer.html" %} + + diff --git a/developer/templates/oauth2_provider/application_detail.html b/developer/templates/oauth2_provider/application_detail.html new file mode 100644 index 00000000..547377b0 --- /dev/null +++ b/developer/templates/oauth2_provider/application_detail.html @@ -0,0 +1,45 @@ +{% extends "oauth2_provider/base.html" %} +{% load i18n %} +{% block content %} +
+

{{ application.name }}

+ +
+
+ {% trans "Go Back" %} + {% trans "Edit" %} + {% trans "Delete" %} +
+
+{% endblock content %} diff --git a/developer/templates/oauth2_provider/application_form.html b/developer/templates/oauth2_provider/application_form.html new file mode 100644 index 00000000..1efa7b5b --- /dev/null +++ b/developer/templates/oauth2_provider/application_form.html @@ -0,0 +1,39 @@ +{% extends "oauth2_provider/base.html" %} +{% load i18n %} +{% block content %} +
+

+ {% block app-form-title %} + {% trans "Edit application" %} {{ application.name }} + {% endblock app-form-title %} +

+ {% csrf_token %} +
+ {% for field in form %} + + {% endfor %} +
+
+ {% for error in form.non_field_errors %}{{ error }}{% endfor %} +
+
+
+ + {% trans "Go Back" %} + + +
+
+
+{% endblock %} diff --git a/developer/templates/oauth2_provider/application_list.html b/developer/templates/oauth2_provider/application_list.html new file mode 100644 index 00000000..5bfdc4af --- /dev/null +++ b/developer/templates/oauth2_provider/application_list.html @@ -0,0 +1,27 @@ +{% extends "oauth2_provider/base.html" %} +{% load i18n %} +{% block content %} +

+ Developer Console | {% trans "Your applications" %} +

+
+ {% if not request.user.mastodon_acct %} +

+ Please connect to a Fediverse identity before creating an application. +

+ {% elif applications %} + + {% trans "New Application" %} + {% else %} +

+ {% trans "No applications defined" %}. {% trans "Click here" %} {% trans "if you want to register a new one" %} +

+ {% endif %} +
+{% endblock content %} diff --git a/developer/templates/oauth2_provider/authorize.html b/developer/templates/oauth2_provider/authorize.html new file mode 100644 index 00000000..9714ac18 --- /dev/null +++ b/developer/templates/oauth2_provider/authorize.html @@ -0,0 +1,41 @@ +{% extends "oauth2_provider/base.html" %} +{% load i18n %} +{% block content %} +
+ {% if not error %} +
+

授权 {{ application.name }} 访问你的帐户吗?

+
+
+ {% csrf_token %} + {% if not application.is_official %} +

+ {{ application.name }} 是由 @{{ application.user.identity.handle }} 创建和维护的应用程序。 + {{ site_name }}无法保证其安全性和有效性,请自行验证确认后再授权。 +

+ {% endif %} + {% if application.url %}应用网址: {{ application.url | urlize }}{% endif %} +

{{ application.description_html|safe }}

+ {% for field in form %} + {% if field.is_hidden %}{{ field }}{% endif %} + {% endfor %} +

授权后这个应用将能读写你帐户的全部数据。

+ {% comment %}

{% trans "Application requires the following permissions" %}

+ {% endcomment %} + {{ form.errors }} + {{ form.non_field_errors }} +
+
+ + +
+
+
+ {% else %} +

Error: {{ error.error }}

+

{{ error.description }}

+ {% endif %} +
+{% endblock %} diff --git a/developer/templates/oauth2_provider/base.html b/developer/templates/oauth2_provider/base.html new file mode 100644 index 00000000..9c3cc247 --- /dev/null +++ b/developer/templates/oauth2_provider/base.html @@ -0,0 +1,25 @@ +{% load i18n %} + + + + + + {% block title %} + {% endblock title %} + {{ site_name }} + {% include "common_libs.html" %} + + + + {% include "_header.html" %} +
+ {% block content %} + {% endblock content %} +
+ {% include "_footer.html" %} + + diff --git a/developer/tests.py b/developer/tests.py new file mode 100644 index 00000000..7ce503c2 --- /dev/null +++ b/developer/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/developer/urls.py b/developer/urls.py new file mode 100644 index 00000000..40db32d9 --- /dev/null +++ b/developer/urls.py @@ -0,0 +1,69 @@ +from django.contrib import admin +from django.contrib.auth import views as auth_views +from django.urls import include, path, re_path +from oauth2_provider import views as oauth2_views +from oauth2_provider.views import oidc as oidc_views + +from .views import * + +_urlpatterns = [ + re_path( + r"^auth/oauth/authorize/$", + oauth2_views.AuthorizationView.as_view(), + name="authorize", + ), + re_path(r"^oauth/token/$", oauth2_views.TokenView.as_view(), name="token"), + re_path( + r"^auth/oauth/revoke_token/$", + oauth2_views.RevokeTokenView.as_view(), + name="revoke-token", + ), + re_path( + r"^auth/oauth/introspect/$", + oauth2_views.IntrospectTokenView.as_view(), + name="introspect", + ), + re_path( + r"^auth/oauth/authorized_tokens/$", + oauth2_views.AuthorizedTokensListView.as_view(), + name="authorized-token-list", + ), + re_path( + r"^auth/oauth/authorized_tokens/(?P[\w-]+)/delete/$", + oauth2_views.AuthorizedTokenDeleteView.as_view(), + name="authorized-token-delete", + ), +] + +_urlpatterns += [ + path("developer/", console, name="developer"), + re_path( + r"^developer/applications/$", + oauth2_views.ApplicationList.as_view(), + name="list", + ), + re_path( + r"^developer/applications/register/$", + ApplicationRegistration.as_view(), + name="register", + ), + re_path( + r"^developer/applications/(?P[\w-]+)/$", + oauth2_views.ApplicationDetail.as_view(), + name="detail", + ), + re_path( + r"^developer/applications/(?P[\w-]+)/delete/$", + oauth2_views.ApplicationDelete.as_view(), + name="delete", + ), + re_path( + r"^developer/applications/(?P[\w-]+)/update/$", + ApplicationUpdate.as_view(), + name="update", + ), +] + +urlpatterns = [ + path("", include((_urlpatterns, "oauth2_provider"))), +] diff --git a/developer/views.py b/developer/views.py new file mode 100644 index 00000000..f85ecfb6 --- /dev/null +++ b/developer/views.py @@ -0,0 +1,90 @@ +from dateutil.relativedelta import relativedelta +from django.conf import settings +from django.contrib.auth.decorators import login_required +from django.forms.models import modelform_factory +from django.shortcuts import render +from django.urls import reverse +from django.utils import timezone +from loguru import logger +from oauth2_provider.forms import AllowForm +from oauth2_provider.generators import generate_client_id, generate_client_secret +from oauth2_provider.models import AccessToken, RefreshToken, get_application_model +from oauth2_provider.settings import oauth2_settings +from oauth2_provider.views import ApplicationRegistration as BaseApplicationRegistration +from oauth2_provider.views import ApplicationUpdate as BaseApplicationUpdate +from oauth2_provider.views.base import AuthorizationView as BaseAuthorizationView +from oauthlib.common import generate_token + +from common.api import api + +from .models import Application + + +class ApplicationRegistration(BaseApplicationRegistration): + def get_form_class(self): + return modelform_factory( + Application, + fields=( + "name", + "url", + "description", + "client_secret", + "redirect_uris", + # "post_logout_redirect_uris", + ), + ) + + def form_valid(self, form): + form.instance.user = self.request.user + if not form.instance.id: + form.instance.client_id = generate_client_id() + # form.instance.client_secret = generate_client_secret() + form.instance.client_type = Application.CLIENT_CONFIDENTIAL + form.instance.authorization_grant_type = ( + Application.GRANT_AUTHORIZATION_CODE + ) + return super().form_valid(form) + + +class ApplicationUpdate(BaseApplicationUpdate): + def get_form_class(self): + return modelform_factory( + get_application_model(), # type:ignore + fields=( + "name", + "url", + "description", + # "client_secret", + "redirect_uris", + # "post_logout_redirect_uris", + ), + ) + + +@login_required +def console(request): + token = None + if request.method == "POST": + user = request.user + app = Application.objects.filter( + client_id=settings.DEVELOPER_CONSOLE_APPLICATION_CLIENT_ID + ).first() + if app: + for token in AccessToken.objects.filter(user=user, application=app): + token.revoke() + token = generate_token() + AccessToken.objects.create( + user=user, + application=app, + scope="read write", + expires=timezone.now() + relativedelta(days=365), + token=token, + ) + else: + token = "Configuration error, contact admin" + context = { + "api": api, + "token": token, + "openapi_json_url": reverse(f"{api.urls_namespace}:openapi-json"), + } + return render(request, "console.html", context)