mirror of
https://github.com/minetest/contentdb.git
synced 2024-11-08 08:33:45 +01:00
parent
1627fa50f2
commit
a29715775e
207
app/blueprints/oauth/__init__.py
Normal file
207
app/blueprints/oauth/__init__.py
Normal file
@ -0,0 +1,207 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2023 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import urllib.parse as urlparse
|
||||
from typing import Optional
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from flask import Blueprint, render_template, redirect, url_for, request, jsonify, abort, make_response
|
||||
from flask_babel import lazy_gettext
|
||||
from flask_login import current_user, login_required
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, SubmitField, URLField
|
||||
from wtforms.validators import InputRequired, Length
|
||||
|
||||
from app import csrf
|
||||
from app.blueprints.users.settings import get_setting_tabs
|
||||
from app.models import db, OAuthClient, User, Permission, APIToken
|
||||
from app.utils import random_string
|
||||
|
||||
bp = Blueprint("oauth", __name__)
|
||||
|
||||
|
||||
def build_redirect_url(url: str, code: str, state: Optional[str]):
|
||||
params = {"code": code}
|
||||
if state is not None:
|
||||
params["state"] = state
|
||||
url_parts = list(urlparse.urlparse(url))
|
||||
query = dict(urlparse.parse_qsl(url_parts[4]))
|
||||
query.update(params)
|
||||
url_parts[4] = urlencode(query)
|
||||
return urlparse.urlunparse(url_parts)
|
||||
|
||||
|
||||
@bp.route("/oauth/authorize/", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def oauth_start():
|
||||
response_type = request.args.get("response_type", "code")
|
||||
if response_type != "code":
|
||||
return "Unsupported response_type, only code is supported", 400
|
||||
|
||||
client_id = request.args.get("client_id")
|
||||
if client_id is None:
|
||||
return "Missing client_id", 400
|
||||
|
||||
redirect_uri = request.args.get("redirect_uri")
|
||||
if redirect_uri is None:
|
||||
return "Missing redirect_uri", 400
|
||||
|
||||
client = OAuthClient.query.get_or_404(client_id)
|
||||
if client.redirect_url != redirect_uri:
|
||||
return "redirect_uri does not match client", 400
|
||||
|
||||
state = request.args.get("state")
|
||||
|
||||
token = APIToken.query.filter(APIToken.client == client, APIToken.owner == current_user).first()
|
||||
if token:
|
||||
token.access_token = random_string(32)
|
||||
token.auth_code = random_string(32)
|
||||
db.session.commit()
|
||||
return redirect(build_redirect_url(client.redirect_url, token.auth_code, state))
|
||||
|
||||
if request.method == "POST":
|
||||
action = request.form["action"]
|
||||
if action == "cancel":
|
||||
return redirect(client.redirect_url)
|
||||
|
||||
elif action == "authorize":
|
||||
token = APIToken()
|
||||
token.access_token = random_string(32)
|
||||
token.name = f"Token for {client.title} by {client.owner.username}"
|
||||
token.owner = current_user
|
||||
token.client = client
|
||||
assert client is not None
|
||||
token.auth_code = random_string(32)
|
||||
db.session.add(token)
|
||||
db.session.commit()
|
||||
|
||||
return redirect(build_redirect_url(client.redirect_url, token.auth_code, state))
|
||||
|
||||
return render_template("oauth/authorize.html", client=client)
|
||||
|
||||
|
||||
def error(code: int, msg: str):
|
||||
abort(make_response(jsonify({"success": False, "error": msg}), code))
|
||||
|
||||
|
||||
@bp.route("/oauth/token/", methods=["POST"])
|
||||
@csrf.exempt
|
||||
def oauth_grant():
|
||||
form = request.form
|
||||
|
||||
grant_type = request.args.get("grant_type", "authorization_code")
|
||||
if grant_type != "authorization_code":
|
||||
error(400, "Unsupported grant_type, only authorization_code is supported")
|
||||
|
||||
client_id = form.get("client_id")
|
||||
if client_id is None:
|
||||
error(400, "Missing client_id")
|
||||
|
||||
client_secret = form.get("client_secret")
|
||||
if client_secret is None:
|
||||
error(400, "Missing client_secret")
|
||||
|
||||
code = form.get("code")
|
||||
if code is None:
|
||||
error(400, "Missing code")
|
||||
|
||||
client = OAuthClient.query.filter_by(id=client_id, secret=client_secret).first()
|
||||
if client is None:
|
||||
error(400, "client_id and/or client_secret is incorrect")
|
||||
|
||||
token = APIToken.query.filter_by(auth_code=code).first()
|
||||
if token is None or token.client != client:
|
||||
error(400, "Incorrect code. It may have already been redeemed")
|
||||
|
||||
token.auth_code = None
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
"access_token": token.access_token,
|
||||
"refresh_token": "abc",
|
||||
"token_type": "Bearer",
|
||||
})
|
||||
|
||||
|
||||
@bp.route("/user/apps/")
|
||||
@login_required
|
||||
def list_clients_redirect():
|
||||
return redirect(url_for("oauth.list_clients", username=current_user.username))
|
||||
|
||||
|
||||
@bp.route("/users/<username>/apps/")
|
||||
@login_required
|
||||
def list_clients(username):
|
||||
user = User.query.filter_by(username=username).first_or_404()
|
||||
if not user.check_perm(current_user, Permission.CREATE_OAUTH_CLIENT):
|
||||
abort(403)
|
||||
|
||||
return render_template("oauth/list_clients.html", user=user, tabs=get_setting_tabs(user), current_tab="oauth_clients")
|
||||
|
||||
|
||||
class OAuthClientForm(FlaskForm):
|
||||
title = StringField(lazy_gettext("Title"), [InputRequired(), Length(5, 30)])
|
||||
redirect_url = URLField(lazy_gettext("Redirect URL"), [InputRequired(), Length(5, 123)])
|
||||
submit = SubmitField(lazy_gettext("Save"))
|
||||
|
||||
|
||||
@bp.route("/users/<username>/apps/new/", methods=["GET", "POST"])
|
||||
@bp.route("/users/<username>/apps/<id_>/edit/", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def create_edit_client(username, id_=None):
|
||||
user = User.query.filter_by(username=username).first_or_404()
|
||||
if not user.check_perm(current_user, Permission.CREATE_OAUTH_CLIENT):
|
||||
abort(403)
|
||||
|
||||
is_new = id_ is None
|
||||
client = None
|
||||
if id_ is not None:
|
||||
client = OAuthClient.query.get_or_404(id_)
|
||||
|
||||
form = OAuthClientForm(formdata=request.form, obj=client)
|
||||
if form.validate_on_submit():
|
||||
if is_new:
|
||||
client = OAuthClient()
|
||||
db.session.add(client)
|
||||
client.owner = user
|
||||
client.id = random_string(24)
|
||||
client.secret = random_string(32)
|
||||
|
||||
form.populate_obj(client)
|
||||
db.session.commit()
|
||||
|
||||
return redirect(url_for("oauth.create_edit_client", username=username, id_=client.id))
|
||||
|
||||
return render_template("oauth/create_edit.html", user=user, form=form, client=client)
|
||||
|
||||
|
||||
@bp.route("/users/<username>/apps/<id_>/delete/", methods=["POST"])
|
||||
@login_required
|
||||
def delete_client(username, id_):
|
||||
user = User.query.filter_by(username=username).first_or_404()
|
||||
if not user.check_perm(current_user, Permission.CREATE_OAUTH_CLIENT):
|
||||
abort(403)
|
||||
|
||||
client = OAuthClient.query.get(id_)
|
||||
if client is None:
|
||||
abort(404)
|
||||
elif client.owner != user:
|
||||
abort(403)
|
||||
|
||||
db.session.delete(client)
|
||||
db.session.commit()
|
||||
|
||||
return redirect(url_for("oauth.list_clients", username=username))
|
@ -53,6 +53,13 @@ def get_setting_tabs(user):
|
||||
},
|
||||
]
|
||||
|
||||
if user.check_perm(current_user, Permission.CREATE_OAUTH_CLIENT):
|
||||
ret.append({
|
||||
"id": "oauth_clients",
|
||||
"title": gettext("OAuth2 Applications"),
|
||||
"url": url_for("oauth.list_clients", username=user.username)
|
||||
})
|
||||
|
||||
if current_user.rank.at_least(UserRank.MODERATOR):
|
||||
ret.append({
|
||||
"id": "modtools",
|
||||
|
@ -35,4 +35,5 @@ toc: False
|
||||
## APIs
|
||||
|
||||
* [API](api/)
|
||||
* [OAuth2 Applications](oauth/)
|
||||
* [Prometheus Metrics](metrics/)
|
||||
|
90
app/flatpages/help/oauth.md
Normal file
90
app/flatpages/help/oauth.md
Normal file
@ -0,0 +1,90 @@
|
||||
title: OAuth2 API
|
||||
|
||||
<p class="alert alert-warning">
|
||||
The OAuth2 applications API is currently experimental and invite only.
|
||||
</p>
|
||||
|
||||
ContentDB allows you to create an OAuth2 Application and obtain access tokens
|
||||
for users.
|
||||
|
||||
|
||||
## Create an OAuth2 Client
|
||||
|
||||
Go to Settings > [OAuth2 Applications](/user/apps/) > Create
|
||||
|
||||
Note: If you don't see this then you don't have access to OAuth2 yet.
|
||||
|
||||
|
||||
## Obtaining access tokens
|
||||
|
||||
ContentDB supports the Authorization Code OAuth2 method.
|
||||
|
||||
### Authorize
|
||||
|
||||
Get the user to open the following URL in a web browser:
|
||||
|
||||
```
|
||||
https://content.minetest.net/oauth/authorize/
|
||||
?response_type=code
|
||||
&client_id={CLIENT_ID}
|
||||
&redirect_uri={REDIRECT_URL}
|
||||
```
|
||||
|
||||
The redirect_url must much the value set in your oauth client. Make sure to URL encode it.
|
||||
ContentDB also supports `state`.
|
||||
|
||||
Afterwards, the user will be redirected to your callback URL.
|
||||
If the user accepts the authorization, you'll receive an authorization code (`code`).
|
||||
Otherwise, the redirect_url will not be modified.
|
||||
|
||||
For example, with `REDIRECT_URL` set as `https://example.com/callback/`:
|
||||
|
||||
* If the user accepts: `https://example.com/callback/?code=abcdef`
|
||||
* If the user cancels: `https://example.com/callback/`
|
||||
|
||||
### Exchange auth code for access token
|
||||
|
||||
Next, you'll need to exchange the auth for an access token.
|
||||
|
||||
Do this by making a POST request to the `/oauth/token/` API:
|
||||
|
||||
```bash
|
||||
curl -X POST https://content.minetest.net/oauth/token/ \
|
||||
-F grant_type=authorization_code
|
||||
-F client_id="CLIENT_ID" \
|
||||
-F client_secret="CLIENT_SECRET" \
|
||||
-F code="abcdef"
|
||||
```
|
||||
|
||||
<p class="alert alert-warning">
|
||||
<i class="fas fa-exclamation-circle me-2"></i>
|
||||
You should make this request on a server to prevent the user
|
||||
from getting access to your client secret.
|
||||
</p>
|
||||
|
||||
If successful, you'll receive:
|
||||
|
||||
```json
|
||||
{
|
||||
"access_token": "access_token",
|
||||
"token_type": "Bearer"
|
||||
}
|
||||
```
|
||||
|
||||
If there's an error, you'll receive a standard API error message:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "The error message"
|
||||
}
|
||||
```
|
||||
|
||||
Possible errors:
|
||||
|
||||
* Unsupported grant_type, only authorization_code is supported
|
||||
* Missing client_id
|
||||
* Missing client_secret
|
||||
* Missing code
|
||||
* client_id and/or client_secret is incorrect
|
||||
* Incorrect code. It may have already been redeemed
|
@ -48,7 +48,14 @@ class APIToken(db.Model):
|
||||
package_id = db.Column(db.Integer, db.ForeignKey("package.id"), nullable=True)
|
||||
package = db.relationship("Package", foreign_keys=[package_id], back_populates="tokens")
|
||||
|
||||
client_id = db.Column(db.String(24), db.ForeignKey("oauth_client.id"), nullable=True)
|
||||
client = db.relationship("OAuthClient", foreign_keys=[client_id], back_populates="tokens")
|
||||
auth_code = db.Column(db.String(34), unique=True, nullable=True)
|
||||
|
||||
def can_operate_on_package(self, package):
|
||||
if self.client is not None:
|
||||
return False
|
||||
|
||||
if self.package and self.package != package:
|
||||
return False
|
||||
|
||||
|
@ -93,6 +93,7 @@ class Permission(enum.Enum):
|
||||
VIEW_AUDIT_DESCRIPTION = "VIEW_AUDIT_DESCRIPTION"
|
||||
EDIT_COLLECTION = "EDIT_COLLECTION"
|
||||
VIEW_COLLECTION = "VIEW_COLLECTION"
|
||||
CREATE_OAUTH_CLIENT = "CREATE_OAUTH_CLIENT"
|
||||
|
||||
# Only return true if the permission is valid for *all* contexts
|
||||
# See Package.check_perm for package-specific contexts
|
||||
@ -187,6 +188,7 @@ class User(db.Model, UserMixin):
|
||||
replies = db.relationship("ThreadReply", back_populates="author", lazy="dynamic", cascade="all, delete, delete-orphan", order_by=db.desc("created_at"))
|
||||
forum_topics = db.relationship("ForumTopic", back_populates="author", lazy="dynamic", cascade="all, delete, delete-orphan")
|
||||
collections = db.relationship("Collection", back_populates="author", lazy="dynamic", cascade="all, delete, delete-orphan", order_by=db.asc("title"))
|
||||
clients = db.relationship("OAuthClient", back_populates="owner", lazy="dynamic", cascade="all, delete, delete-orphan")
|
||||
|
||||
ban = db.relationship("UserBan", foreign_keys="UserBan.user_id", back_populates="user", uselist=False)
|
||||
|
||||
@ -252,6 +254,11 @@ class User(db.Model, UserMixin):
|
||||
return user.rank.at_least(UserRank.NEW_MEMBER)
|
||||
else:
|
||||
return user.rank.at_least(UserRank.MODERATOR) and user.rank.at_least(self.rank)
|
||||
elif perm == Permission.CREATE_OAUTH_CLIENT:
|
||||
if user == self:
|
||||
return user.rank.at_least(UserRank.EDITOR)
|
||||
else:
|
||||
return user.rank.at_least(UserRank.MODERATOR) and user.rank.at_least(self.rank)
|
||||
else:
|
||||
raise Exception("Permission {} is not related to users".format(perm.name))
|
||||
|
||||
@ -545,3 +552,19 @@ class UserBan(db.Model):
|
||||
@property
|
||||
def has_expired(self):
|
||||
return self.expires_at and datetime.datetime.now() > self.expires_at
|
||||
|
||||
|
||||
class OAuthClient(db.Model):
|
||||
__tablename__ = "oauth_client"
|
||||
|
||||
id = db.Column(db.String(24), primary_key=True)
|
||||
title = db.Column(db.String(64), unique=True)
|
||||
secret = db.Column(db.String(32))
|
||||
redirect_url = db.Column(db.String(128))
|
||||
|
||||
owner_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||
owner = db.relationship("User", foreign_keys=[owner_id], back_populates="clients")
|
||||
|
||||
tokens = db.relationship("APIToken", back_populates="client", lazy="dynamic", cascade="all, delete, delete-orphan")
|
||||
|
||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
|
||||
|
@ -20,27 +20,34 @@
|
||||
|
||||
<h1 class="mt-0">{{ self.title() }}</h1>
|
||||
|
||||
<div class="alert alert-warning">
|
||||
{{ _("API Tokens allow scripts to act on your behalf.") }}
|
||||
{{ _("Be careful with what/whom you share tokens with, as you are responsible for your account's actions.") }}
|
||||
</div>
|
||||
|
||||
{% if token %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">{{ _("Access Token") }}</div>
|
||||
<div class="card-body">
|
||||
<p>
|
||||
{{ _("For security reasons, access tokens will only be shown once. Reset the token if it is lost.") }}
|
||||
</p>
|
||||
{% if access_token %}
|
||||
<input class="form-control my-3" type="text" readonly value="{{ access_token }}" class="form-control">
|
||||
{% endif %}
|
||||
<form method="POST" action="{{ url_for('api.reset_token', username=token.owner.username, id=token.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<input class="btn btn-primary" type="submit" value="{{ _('Reset') }}">
|
||||
</form>
|
||||
</div>
|
||||
{% if token.client %}
|
||||
<p class="alert alert-info">
|
||||
{{ _("This token was created by the application '%(title)s' by %(author)s.", title=token.client.title, author=token.client.owner.username) }}
|
||||
{{ _("Click 'Delete' to revoke access.") }}
|
||||
</p>
|
||||
{% else %}
|
||||
<div class="alert alert-warning">
|
||||
{{ _("API Tokens allow scripts to act on your behalf.") }}
|
||||
{{ _("Be careful with what/whom you share tokens with, as you are responsible for your account's actions.") }}
|
||||
</div>
|
||||
|
||||
{% if token %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">{{ _("Access Token") }}</div>
|
||||
<div class="card-body">
|
||||
<p>
|
||||
{{ _("For security reasons, access tokens will only be shown once. Reset the token if it is lost.") }}
|
||||
</p>
|
||||
{% if access_token %}
|
||||
<input class="form-control my-3" type="text" readonly value="{{ access_token }}" class="form-control">
|
||||
{% endif %}
|
||||
<form method="POST" action="{{ url_for('api.reset_token', username=token.owner.username, id=token.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<input class="btn btn-primary" type="submit" value="{{ _('Reset') }}">
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<form method="POST" action="" enctype="multipart/form-data">
|
||||
|
@ -12,6 +12,11 @@
|
||||
<div class="list-group">
|
||||
{% for token in user.tokens %}
|
||||
<a class="list-group-item list-group-item-action" href="{{ url_for('api.create_edit_token', username=user.username, id=token.id) }}">
|
||||
{% if token.client %}
|
||||
<span class="badge bg-info float-end">
|
||||
{{ _("Application") }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{{ token.name }}
|
||||
</a>
|
||||
{% else %}
|
||||
|
60
app/templates/oauth/authorize.html
Normal file
60
app/templates/oauth/authorize.html
Normal file
@ -0,0 +1,60 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}
|
||||
{{ _("Authorize %(title)s", title=client.title) }}
|
||||
{% endblock %}
|
||||
|
||||
{% block author_link %}
|
||||
<a href="{{ url_for('users.profile', username=client.owner.username) }}">{{ client.owner.username }}</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<form method="POST" action="" class="text-center">
|
||||
<article class="card d-inline-block text-start">
|
||||
<div class="card-body">
|
||||
<h1 class="card-title">{{ self.title() }}</h1>
|
||||
<div class="row my-4 align-items-center">
|
||||
<div class="col-2">
|
||||
<img
|
||||
class="img-fluid user-photo img-thumbnail img-thumbnail-1"
|
||||
src="{{ client.owner.get_profile_pic_url() }}" />
|
||||
</div>
|
||||
<div class="col">
|
||||
<p class="my-0">
|
||||
{{ _("%(title)s by %(display_name)s", title=client.title, display_name=self.author_link()) }}
|
||||
</p>
|
||||
<p class="text-muted my-0">
|
||||
{{ _("wants to access your account") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row my-4 align-items-center">
|
||||
<div class="col-2 text-center">
|
||||
<i class="fas fa-globe fa-xl"></i>
|
||||
</div>
|
||||
<div class="col">
|
||||
<p class="my-0">
|
||||
{{ _("Public data only") }}
|
||||
</p>
|
||||
<p class="text-muted my-0">
|
||||
{{ _("Display name, username") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<button type="submit" name="action" value="cancel" href="#" class="btn btn-secondary d-block w-100">
|
||||
{{ _("Cancel") }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="col">
|
||||
<button type="submit" name="action" value="authorize" href="#" class="btn btn-primary d-block w-100">
|
||||
{{ _("Authorize") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</form>
|
||||
{% endblock %}
|
52
app/templates/oauth/create_edit.html
Normal file
52
app/templates/oauth/create_edit.html
Normal file
@ -0,0 +1,52 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}
|
||||
{% if client %}
|
||||
{{ _("Edit - %(name)s", name=client.title) }}
|
||||
{% else %}
|
||||
{{ _("Create OAuth Client") }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% from "macros/forms.html" import render_field, render_submit_field %}
|
||||
|
||||
{% block content %}
|
||||
{% if client %}
|
||||
<form class="float-end" method="POST" action="{{ url_for('oauth.delete_client', username=client.owner.username, id_=client.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<input class="btn btn-danger" type="submit" value="{{ _('Delete') }}">
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<h1 class="mt-0">{{ self.title() }}</h1>
|
||||
|
||||
{% if client %}
|
||||
<div class="card my-5">
|
||||
<div class="card-body">
|
||||
<p>
|
||||
{{ _("Your client has %(count)d users", count=client.tokens.count()) }}
|
||||
</p>
|
||||
<div class="form-group mb-3">
|
||||
<label class="form-label" for="client_id">client_id</label>
|
||||
<input class="form-control" type="text" id="client_id" name="client_id" value="{{ client.id }}" readonly>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="client_secret-label" for="client_id">client_secret</label>
|
||||
<input class="form-control" type="text" id="client_secret" name="client_secret" value="{{ client.secret }}" readonly>
|
||||
</div>
|
||||
<p class="text-muted mb-0">
|
||||
{{ _("Keep the secret safe") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="POST" action="" enctype="multipart/form-data">
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
{{ render_field(form.title) }}
|
||||
{{ render_field(form.redirect_url) }}
|
||||
|
||||
{{ render_submit_field(form.submit) }}
|
||||
</form>
|
||||
{% endblock %}
|
23
app/templates/oauth/list_clients.html
Normal file
23
app/templates/oauth/list_clients.html
Normal file
@ -0,0 +1,23 @@
|
||||
{% extends "users/settings_base.html" %}
|
||||
|
||||
{% block title %}
|
||||
{{ _("OAuth2 Applications | %(username)s", username=user.username) }}
|
||||
{% endblock %}
|
||||
|
||||
{% block pane %}
|
||||
<a class="btn btn-primary float-end" href="{{ url_for('oauth.create_edit_client', username=user.username) }}">{{ _("Create") }}</a>
|
||||
<a class="btn btn-secondary me-2 float-end" href="/help/oauth/">{{ _("OAuth2 Documentation") }}</a>
|
||||
<h2 class="mt-0">{{ _("OAuth2 Applications") }}</h2>
|
||||
|
||||
<div class="list-group">
|
||||
{% for client in user.clients %}
|
||||
<a class="list-group-item list-group-item-action" href="{{ url_for('oauth.create_edit_client', username=user.username, id_=client.id) }}">
|
||||
{{ client.title }}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="list-group-item">
|
||||
<i>{{ _("No applications created") }}</i>
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
25
migrations/versions/9395ba96f853_.py
Normal file
25
migrations/versions/9395ba96f853_.py
Normal file
@ -0,0 +1,25 @@
|
||||
"""empty message
|
||||
|
||||
Revision ID: 9395ba96f853
|
||||
Revises: dd17239f7144
|
||||
Create Date: 2023-10-31 17:39:27.957209
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '9395ba96f853'
|
||||
down_revision = 'dd17239f7144'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
with op.batch_alter_table('oauth_client', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('created_at', sa.DateTime(), nullable=False))
|
||||
|
||||
def downgrade():
|
||||
with op.batch_alter_table('oauth_client', schema=None) as batch_op:
|
||||
batch_op.drop_column('created_at')
|
28
migrations/versions/dd17239f7144_.py
Normal file
28
migrations/versions/dd17239f7144_.py
Normal file
@ -0,0 +1,28 @@
|
||||
"""empty message
|
||||
|
||||
Revision ID: dd17239f7144
|
||||
Revises: f0622f7671d5
|
||||
Create Date: 2023-10-31 16:29:08.892647
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'dd17239f7144'
|
||||
down_revision = 'f0622f7671d5'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
with op.batch_alter_table('api_token', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('auth_code', sa.String(length=34), nullable=True))
|
||||
batch_op.create_unique_constraint(None, ['auth_code'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
with op.batch_alter_table('api_token', schema=None) as batch_op:
|
||||
batch_op.drop_constraint(None, type_='unique')
|
||||
batch_op.drop_column('auth_code')
|
41
migrations/versions/f0622f7671d5_.py
Normal file
41
migrations/versions/f0622f7671d5_.py
Normal file
@ -0,0 +1,41 @@
|
||||
"""empty message
|
||||
|
||||
Revision ID: f0622f7671d5
|
||||
Revises: 49105d276908
|
||||
Create Date: 2023-10-31 16:04:57.708933
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'f0622f7671d5'
|
||||
down_revision = '49105d276908'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('oauth_client',
|
||||
sa.Column('id', sa.String(length=24), nullable=False),
|
||||
sa.Column('title', sa.String(length=64), nullable=True),
|
||||
sa.Column('secret', sa.String(length=32), nullable=True),
|
||||
sa.Column('redirect_url', sa.String(length=128), nullable=True),
|
||||
sa.Column('owner_id', sa.Integer(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['owner_id'], ['user.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('title')
|
||||
)
|
||||
with op.batch_alter_table('api_token', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('client_id', sa.String(length=24), nullable=True))
|
||||
batch_op.create_foreign_key(None, 'oauth_client', ['client_id'], ['id'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
with op.batch_alter_table('api_token', schema=None) as batch_op:
|
||||
batch_op.drop_constraint(None, type_='foreignkey')
|
||||
batch_op.drop_column('client_id')
|
||||
|
||||
op.drop_table('oauth_client')
|
Loading…
Reference in New Issue
Block a user