add back developer temporarily for migration

This commit is contained in:
Your Name 2024-06-10 21:06:34 -04:00 committed by Henri Dickson
parent 4969b7b371
commit a6bd62b1a4
18 changed files with 670 additions and 0 deletions

View file

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

0
developer/__init__.py Normal file
View file

3
developer/admin.py Normal file
View file

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

6
developer/apps.py Normal file
View file

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

View file

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

View file

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

View file

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

View file

33
developer/models.py Normal file
View file

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

View file

@ -0,0 +1,112 @@
{% load i18n %}
<!DOCTYPE html>
<html lang="en">
<head>
<link rel="stylesheet"
href="{{ cdn_url }}/npm/swagger-ui-dist@5.13.0/swagger-ui.min.css">
<title>{{ api.title }} Developer Console</title>
{% include "common_libs.html" %}
<style type="text/css">
.information-container, #operations-tag-default {
display: none;
}
.scheme-container {
margin: 0 !important;
background: none !important;
box-shadow: none !important;
}
button svg {
fill: var(--pico-primary);
}
#swagger-ui>div>div>.wrapper {
background-color: white;
padding: 0;
}
#swagger-ui>div>div>.wrapper button {
background-color: unset;
}
#swagger-ui>div>div>.wrapper button.btn.execute {
background-color: #4990e2;
}
</style>
</head>
<body>
{% include "_header.html" %}
<main class="container">
<h3>
Developer Console | <a href="{% url 'oauth2_provider:list' %}">{% trans "Your applications" %}</a>
</h3>
<p class="empty">
By using our APIs, you agree to our <a href="/pages/terms">Terms of Service</a>.
</p>
<details {% if token %}open{% endif %}>
<summary>
<a>Test Access Token</a>
</summary>
<form method="post" role="group">
{% csrf_token %}
<input type="text"
readonly
value="{{ token | default:'Once generated, token will only be shown once here, previous tokens will be revoked.' }}">
<input type="submit" value="Generate" />
</form>
<p>
Click <code>Authorize</code> button below, input your token there to invoke APIs with your account, which is required for APIs like <code>/api/me</code>
<br>
Or use it in command line, like
<code>curl -H "Authorization: Bearer YOUR_TOKEN" {{ site_url }}/api/me</code>
</p>
</details>
<br>
<details>
<summary>
<a>How to authorize</a>
</summary>
0. Create an application (you must have at least one URL included in the Redirect URIs field, e.g. <code>https://example.org/callback</code>)
<br>
1. Guide your user to open this URL
<input type="text"
value="{{ site_url }}/auth/oauth/authorize/?response_type=code&amp;client_id=CLIENT_ID&amp;redirect_uri=https://example.org/callback"
readonly>
2. Once authorizated by user, it will redirect to <code>https://example.org/callback</code> with a <code>code</code> parameter:
<input type="text"
value="https://example.org/callback?code=AUTH_CODE"
readonly>
3. Obtain access token with the following POST request:
<textarea readonly rows="7">
curl -X POST {{ site_url }}/auth/oauth/token/ \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "client_id=CLIENT_ID" \
-d "client_secret=CLIENT_SECRET" \
-d "code=AUTH_CODE" \
-d "redirect_uri=https://example.org/callback" \
-d "grant_type=authorization_code"</textarea>
and access token will be returned in the response:
<textarea readonly rows="1">{"access_token": "ACCESS_TOKEN", "expires_in": 31536000, "token_type": "Bearer", "scope": "read write", "refresh_token": "REFRESH_TOKEN"}</textarea>
4. Use the access token to access protected endpoints like <code>/api/me</code>
<textarea readonly rows="1">curl -H "Authorization: Bearer ACCESS_TOKEN" -X GET http://localhost:8000/api/me</textarea>
and response will be returned accordingly:
<textarea readonly rows="1">{"url": "https://neodb.social/users/xxx@yyy.zzz/", "external_acct": "xxx@yyy.zzz", "display_name": "XYZ", "avatar": "https://yyy.zzz/xxx.gif"}</textarea>
more endpoints can be found in API Documentation below.
</details>
<div id="swagger-ui" data-theme="light"></div>
<script src="{{ cdn_url }}/npm/swagger-ui-dist@5.13.0/swagger-ui-bundle.min.js"></script>
<script>
const ui = SwaggerUIBundle({
url: '{{ openapi_json_url }}',
dom_id: '#swagger-ui',
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIBundle.SwaggerUIStandalonePreset
],
layout: "BaseLayout",
{% if api.csrf and csrf_token %}
requestInterceptor: (req) => {req.headers['X-CSRFToken'] = "{{csrf_token}}"; return req;},
{% endif %}
deepLinking: true
})
</script>
</main>
{% include "_footer.html" %}
</body>
</html>

View file

@ -0,0 +1,45 @@
{% extends "oauth2_provider/base.html" %}
{% load i18n %}
{% block content %}
<div class="block-center">
<h3 class="block-center-heading">{{ application.name }}</h3>
<ul class="unstyled">
<li>
<p>
<b>{% trans "Client ID" %}</b>
</p>
<input class="input-block-level"
type="text"
value="{{ application.client_id }}"
readonly>
</li>
<li>
<p>
<b>{% trans "URL" %}</b>
</p>
<p>{{ application.url | default:"" | urlize }}</p>
</li>
<li>
<p>
<b>{% trans "Description" %}</b>
</p>
<p>{{ application.description_html|safe }}</p>
</li>
<li>
<p>
<b>{% trans "Redirect Uris" %}</b>
{% if not application.redirect_uris %}WARNING: no redirect uris have been set, authorization may not work.{% endif %}
</p>
<textarea readonly>{{ application.redirect_uris }}</textarea>
</li>
</ul>
<hr>
<div class="btn-toolbar">
<a class="btn" href="{% url "oauth2_provider:list" %}">{% trans "Go Back" %}</a>
<a class="btn btn-primary"
href="{% url "oauth2_provider:update" application.id %}">{% trans "Edit" %}</a>
<a class="btn btn-danger"
href="{% url "oauth2_provider:delete" application.id %}">{% trans "Delete" %}</a>
</div>
</div>
{% endblock content %}

View file

@ -0,0 +1,39 @@
{% extends "oauth2_provider/base.html" %}
{% load i18n %}
{% block content %}
<form method="post"
action="{% block app-form-action-url %}{% url 'oauth2_provider:update' application.id %}{% endblock app-form-action-url %}">
<h3>
{% block app-form-title %}
{% trans "Edit application" %} {{ application.name }}
{% endblock app-form-title %}
</h3>
{% csrf_token %}
<fieldset>
{% for field in form %}
<label {% if field.errors %}aria-invalid="true"{% endif %}>
{{ field.label }}
{% if field.name == 'client_secret' %}
<small><b>(please save it properly as it will NOT be editable or shown again)</b></small>
{% elif field.name == 'description' %}
<small>(markdown syntax supported)</small>
{% endif %}
{{ field }}
{% for error in field.errors %}<small>{{ error }}</small>{% endfor %}
</label>
{% endfor %}
</fieldset>
<div class="control-group {% if form.non_field_errors %}error{% endif %}">
{% for error in form.non_field_errors %}<span class="help-inline">{{ error }}</span>{% endfor %}
</div>
<div class="control-group">
<div class="controls">
<a class="btn"
href="{% block app-form-back-url %}{% url "oauth2_provider:detail" application.id %}{% endblock app-form-back-url %}">
{% trans "Go Back" %}
</a>
<button type="submit" class="btn btn-primary">{% trans "Save" %}</button>
</div>
</div>
</form>
{% endblock %}

View file

@ -0,0 +1,27 @@
{% extends "oauth2_provider/base.html" %}
{% load i18n %}
{% block content %}
<h3>
<a href="{% url 'oauth2_provider:developer' %}">Developer Console</a> | {% trans "Your applications" %}
</h3>
<div class="block-center">
{% if not request.user.mastodon_acct %}
<p>
Please <a href="{% url 'users:info' %}">connect to a Fediverse identity</a> before creating an application.
</p>
{% elif applications %}
<ul>
{% for application in applications %}
<li>
<a href="{{ application.get_absolute_url }}">{{ application.name }}</a>
</li>
{% endfor %}
</ul>
<a class="btn btn-success" href="{% url "oauth2_provider:register" %}">{% trans "New Application" %}</a>
{% else %}
<p>
{% trans "No applications defined" %}. <a href="{% url 'oauth2_provider:register' %}">{% trans "Click here" %}</a> {% trans "if you want to register a new one" %}
</p>
{% endif %}
</div>
{% endblock content %}

View file

@ -0,0 +1,41 @@
{% extends "oauth2_provider/base.html" %}
{% load i18n %}
{% block content %}
<article>
{% if not error %}
<header>
<h4>授权 {{ application.name }} 访问你的帐户吗?</h4>
</header>
<form id="authorizationForm" method="post">
{% csrf_token %}
{% if not application.is_official %}
<p>
<b>{{ application.name }}</b> 是由 <a href="{{ application.user.identity.url }}">@{{ application.user.identity.handle }}</a> 创建和维护的应用程序。
{{ site_name }}无法保证其安全性和有效性,请自行验证确认后再授权。
</p>
{% endif %}
{% if application.url %}应用网址: {{ application.url | urlize }}{% endif %}
<p>{{ application.description_html|safe }}</p>
{% for field in form %}
{% if field.is_hidden %}{{ field }}{% endif %}
{% endfor %}
<p>授权后这个应用将能读写你帐户的全部数据。</p>
{% comment %} <p>{% trans "Application requires the following permissions" %}</p>
<ul>
{% for scope in scopes_descriptions %}<li>{{ scope }}</li>{% endfor %}
</ul> {% endcomment %}
{{ form.errors }}
{{ form.non_field_errors }}
<footer>
<div class="grid">
<input type="submit" class="primary" name="allow" value="确认授权" />
<input type="submit" class="secondary" value="取消" />
</div>
</footer>
</form>
{% else %}
<h2>Error: {{ error.error }}</h2>
<p>{{ error.description }}</p>
{% endif %}
</article>
{% endblock %}

View file

@ -0,0 +1,25 @@
{% load i18n %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{% block title %}
{% endblock title %}
<title>{{ site_name }}</title>
{% include "common_libs.html" %}
<style>
.controls input {
width: unset;
}
</style>
</head>
<body>
{% include "_header.html" %}
<main class="container">
{% block content %}
{% endblock content %}
</main>
{% include "_footer.html" %}
</body>
</html>

3
developer/tests.py Normal file
View file

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

69
developer/urls.py Normal file
View file

@ -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<pk>[\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<pk>[\w-]+)/$",
oauth2_views.ApplicationDetail.as_view(),
name="detail",
),
re_path(
r"^developer/applications/(?P<pk>[\w-]+)/delete/$",
oauth2_views.ApplicationDelete.as_view(),
name="delete",
),
re_path(
r"^developer/applications/(?P<pk>[\w-]+)/update/$",
ApplicationUpdate.as_view(),
name="update",
),
]
urlpatterns = [
path("", include((_urlpatterns, "oauth2_provider"))),
]

90
developer/views.py Normal file
View file

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