Add OAuth2 Applications API

Fixes #344
This commit is contained in:
rubenwardy 2023-10-31 18:40:01 +00:00
parent 1627fa50f2
commit a29715775e
14 changed files with 596 additions and 20 deletions

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

@ -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,6 +20,12 @@
<h1 class="mt-0">{{ self.title() }}</h1>
{% 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.") }}
@ -42,6 +48,7 @@
</div>
</div>
{% endif %}
{% endif %}
<form method="POST" action="" enctype="multipart/form-data">
{{ form.hidden_tag() }}

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

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

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

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

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

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

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