diff --git a/app/blueprints/oauth/__init__.py b/app/blueprints/oauth/__init__.py new file mode 100644 index 00000000..95a58ada --- /dev/null +++ b/app/blueprints/oauth/__init__.py @@ -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 . + +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//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//apps/new/", methods=["GET", "POST"]) +@bp.route("/users//apps//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//apps//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)) diff --git a/app/blueprints/users/settings.py b/app/blueprints/users/settings.py index 6a2d664f..135f66d5 100644 --- a/app/blueprints/users/settings.py +++ b/app/blueprints/users/settings.py @@ -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", diff --git a/app/flatpages/help.md b/app/flatpages/help.md index d7a4f907..6e34e1af 100644 --- a/app/flatpages/help.md +++ b/app/flatpages/help.md @@ -35,4 +35,5 @@ toc: False ## APIs * [API](api/) +* [OAuth2 Applications](oauth/) * [Prometheus Metrics](metrics/) diff --git a/app/flatpages/help/oauth.md b/app/flatpages/help/oauth.md new file mode 100644 index 00000000..c575af6f --- /dev/null +++ b/app/flatpages/help/oauth.md @@ -0,0 +1,90 @@ +title: OAuth2 API + +

+ The OAuth2 applications API is currently experimental and invite only. +

+ +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" +``` + +

+ + You should make this request on a server to prevent the user + from getting access to your client secret. +

+ +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 diff --git a/app/models/__init__.py b/app/models/__init__.py index 73e1f44f..7612c4ba 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -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 diff --git a/app/models/users.py b/app/models/users.py index 0aaf2d8e..91d43c9a 100644 --- a/app/models/users.py +++ b/app/models/users.py @@ -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) diff --git a/app/templates/api/create_edit_token.html b/app/templates/api/create_edit_token.html index 70078517..02d0dc7b 100644 --- a/app/templates/api/create_edit_token.html +++ b/app/templates/api/create_edit_token.html @@ -20,27 +20,34 @@

{{ self.title() }}

-
- {{ _("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.") }} -
- - {% if token %} -
-
{{ _("Access Token") }}
-
-

- {{ _("For security reasons, access tokens will only be shown once. Reset the token if it is lost.") }} -

- {% if access_token %} - - {% endif %} -
- - -
-
+ {% if token.client %} +

+ {{ _("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.") }} +

+ {% else %} +
+ {{ _("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.") }}
+ + {% if token %} +
+
{{ _("Access Token") }}
+
+

+ {{ _("For security reasons, access tokens will only be shown once. Reset the token if it is lost.") }} +

+ {% if access_token %} + + {% endif %} +
+ + +
+
+
+ {% endif %} {% endif %}
diff --git a/app/templates/api/list_tokens.html b/app/templates/api/list_tokens.html index 12852c63..489e2898 100644 --- a/app/templates/api/list_tokens.html +++ b/app/templates/api/list_tokens.html @@ -12,6 +12,11 @@
{% for token in user.tokens %} + {% if token.client %} + + {{ _("Application") }} + + {% endif %} {{ token.name }} {% else %} diff --git a/app/templates/oauth/authorize.html b/app/templates/oauth/authorize.html new file mode 100644 index 00000000..f751ee4e --- /dev/null +++ b/app/templates/oauth/authorize.html @@ -0,0 +1,60 @@ +{% extends "base.html" %} + +{% block title %} + {{ _("Authorize %(title)s", title=client.title) }} +{% endblock %} + +{% block author_link %} + {{ client.owner.username }} +{% endblock %} + +{% block content %} + +
+
+

{{ self.title() }}

+
+
+ +
+
+

+ {{ _("%(title)s by %(display_name)s", title=client.title, display_name=self.author_link()) }} +

+

+ {{ _("wants to access your account") }} +

+
+
+
+
+ +
+
+

+ {{ _("Public data only") }} +

+

+ {{ _("Display name, username") }} +

+
+
+ +
+
+ +
+
+ +
+
+
+
+ +{% endblock %} diff --git a/app/templates/oauth/create_edit.html b/app/templates/oauth/create_edit.html new file mode 100644 index 00000000..d60ab0ba --- /dev/null +++ b/app/templates/oauth/create_edit.html @@ -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 %} +
+ + +
+ {% endif %} + +

{{ self.title() }}

+ + {% if client %} +
+
+

+ {{ _("Your client has %(count)d users", count=client.tokens.count()) }} +

+
+ + +
+
+ + +
+

+ {{ _("Keep the secret safe") }} +

+
+
+ {% endif %} + +
+ {{ form.hidden_tag() }} + + {{ render_field(form.title) }} + {{ render_field(form.redirect_url) }} + + {{ render_submit_field(form.submit) }} +
+{% endblock %} diff --git a/app/templates/oauth/list_clients.html b/app/templates/oauth/list_clients.html new file mode 100644 index 00000000..33c2d973 --- /dev/null +++ b/app/templates/oauth/list_clients.html @@ -0,0 +1,23 @@ +{% extends "users/settings_base.html" %} + +{% block title %} + {{ _("OAuth2 Applications | %(username)s", username=user.username) }} +{% endblock %} + +{% block pane %} + {{ _("Create") }} + {{ _("OAuth2 Documentation") }} +

{{ _("OAuth2 Applications") }}

+ +
+ {% for client in user.clients %} + + {{ client.title }} + + {% else %} + + {{ _("No applications created") }} + + {% endfor %} +
+{% endblock %} diff --git a/migrations/versions/9395ba96f853_.py b/migrations/versions/9395ba96f853_.py new file mode 100644 index 00000000..fdd64095 --- /dev/null +++ b/migrations/versions/9395ba96f853_.py @@ -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') diff --git a/migrations/versions/dd17239f7144_.py b/migrations/versions/dd17239f7144_.py new file mode 100644 index 00000000..037cf320 --- /dev/null +++ b/migrations/versions/dd17239f7144_.py @@ -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') diff --git a/migrations/versions/f0622f7671d5_.py b/migrations/versions/f0622f7671d5_.py new file mode 100644 index 00000000..f4ceed71 --- /dev/null +++ b/migrations/versions/f0622f7671d5_.py @@ -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')