From 27e2b64e41d8369275b444ad379155b3415d49b3 Mon Sep 17 00:00:00 2001 From: rubenwardy Date: Wed, 1 Nov 2023 01:07:23 +0000 Subject: [PATCH] WIP prototype oauth scopes --- app/blueprints/oauth/__init__.py | 61 ++++++++++++++++++++++++---- app/flatpages/help/oauth.md | 14 ++++--- app/logic/scope.py | 6 +++ app/models/__init__.py | 32 ++++++++++++++- app/templates/oauth/authorize.html | 26 ++++++------ migrations/versions/3d0999440b81_.py | 39 ++++++++++++++++++ 6 files changed, 151 insertions(+), 27 deletions(-) create mode 100644 app/logic/scope.py create mode 100644 migrations/versions/3d0999440b81_.py diff --git a/app/blueprints/oauth/__init__.py b/app/blueprints/oauth/__init__.py index 8d6f21d4..c0a6e74b 100644 --- a/app/blueprints/oauth/__init__.py +++ b/app/blueprints/oauth/__init__.py @@ -66,14 +66,18 @@ def oauth_start(): if not client.approved and client.owner != current_user: abort(404) - scope = request.args.get("scope", "public") - if scope != "public": - return "Unsupported scope, only public is supported", 400 + valid_scopes = {"user:email", "package", "package:release", "package:screenshot"} + scope = request.args.get("scope", "") + scopes = [x.strip() for x in scope.split(",")] + scopes = set([x for x in scopes if x != ""]) + unknown_scopes = scopes - valid_scopes + if unknown_scopes: + return f"Unknown scopes: {', '.join(unknown_scopes)}", 400 state = request.args.get("state") token = APIToken.query.filter(APIToken.client == client, APIToken.owner == current_user).first() - if token: + if token and not (scopes - token.get_scopes()): token.access_token = random_string(32) token.auth_code = random_string(32) db.session.commit() @@ -85,15 +89,19 @@ def oauth_start(): return redirect(client.redirect_url) elif action == "authorize": - token = APIToken() + if token is None: + token = APIToken() + token.name = f"Token for {client.title} by {client.owner.username}" + token.owner = current_user + token.client = client + 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) + token.set_scopes(scopes) + add_audit_log(AuditSeverity.USER, current_user, f"Granted \"{scope}\" to OAuth2 application \"{client.title}\" by {client.owner.username} [{client_id}] ", url_for("users.profile", username=current_user.username)) @@ -102,7 +110,42 @@ def oauth_start(): return redirect(build_redirect_url(client.redirect_url, token.auth_code, state)) - return render_template("oauth/authorize.html", client=client) + scopes_info = [] + if not scopes: + scopes_info.append({ + "icon": "globe-europe", + "title": "Public data only", + "description": "Read-only access to your public data", + }) + + if "user:email" in scopes: + scopes_info.append({ + "icon": "user", + "title": gettext("Personal data"), + "description": gettext("Email address (read-only)"), + }) + + if ("package" in scopes or + "package:release" in scopes or + "package:screenshot" in scopes): + if "package" in scopes: + msg = gettext("Ability to edit packages and their releases, screenshots, and related data") + elif "package:release" in scopes and "package:screenshot" in scopes: + msg = gettext("Ability to create and edit releases and screenshots") + elif "package:release" in scopes: + msg = gettext("Ability to create and edit releases") + elif "package:screenshot" in scopes: + msg = gettext("Ability to create and edit screenshots") + else: + assert False, "This should never happen" + + scopes_info.append({ + "icon": "pen", + "title": gettext("Packages"), + "description": msg, + }) + + return render_template("oauth/authorize.html", client=client, scopes=scopes_info) def error(code: int, msg: str): diff --git a/app/flatpages/help/oauth.md b/app/flatpages/help/oauth.md index 4b5dccab..d5c07810 100644 --- a/app/flatpages/help/oauth.md +++ b/app/flatpages/help/oauth.md @@ -8,11 +8,6 @@ ContentDB allows you to create an OAuth2 Application and obtain access tokens for users. -## Scopes - -OAuth2 applications can currently only access public user data, using the whoami API. - - ## Create an OAuth2 Client Go to Settings > [OAuth2 Applications](/user/apps/) > Create @@ -100,3 +95,12 @@ Next, you should check the access token works by getting the user information: curl https://content.minetest.net/api/whoami/ \ -H "Authorization: Bearer YOURTOKEN" ``` + + +## Scopes + +* (no scope) - public data only +* `user:email`: read user email +* `package`: write access to packages +* `package:release`: create and delete releases +* `package:screenshot`: create, edit, delete screenshots diff --git a/app/logic/scope.py b/app/logic/scope.py new file mode 100644 index 00000000..ed3d765a --- /dev/null +++ b/app/logic/scope.py @@ -0,0 +1,6 @@ +from app.models import APIToken + + +class Scope: + def copy_to_token(self, token: APIToken): + pass diff --git a/app/models/__init__.py b/app/models/__init__.py index 7612c4ba..0f5b8a10 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -52,8 +52,38 @@ class APIToken(db.Model): client = db.relationship("OAuthClient", foreign_keys=[client_id], back_populates="tokens") auth_code = db.Column(db.String(34), unique=True, nullable=True) + scope_user_email = db.Column(db.Boolean, nullable=False, default=False) + scope_package = db.Column(db.Boolean, nullable=False, default=False) + scope_package_release = db.Column(db.Boolean, nullable=False, default=False) + scope_package_screenshot = db.Column(db.Boolean, nullable=False, default=False) + + def get_scopes(self) -> set[str]: + ret = set() + if self.scope_user_email: + ret.add("user:email") + if self.scope_package: + ret.add("package") + if self.scope_package_release: + ret.add("package:release") + if self.scope_package_screenshot: + ret.add("package:screenshot") + return ret + + def set_scopes(self, v: set[str]): + def pop(key: str): + if key in v: + v.remove(key) + return True + + self.scope_user_email = pop("user:email") + self.scope_package = pop("package") + self.scope_package_release = pop("package:release") or self.scope_package + self.scope_package_screenshot = pop("package:screenshot") or self.scope_package + return v + def can_operate_on_package(self, package): - if self.client is not None: + if (self.client is not None and + not (self.scope_package or self.scope_package_release or self.scope_package_screenshot)): return False if self.package and self.package != package: diff --git a/app/templates/oauth/authorize.html b/app/templates/oauth/authorize.html index 66b1eb16..5799e528 100644 --- a/app/templates/oauth/authorize.html +++ b/app/templates/oauth/authorize.html @@ -51,19 +51,21 @@ {% endif %} -
-
- + {% for item in scopes %} +
+
+ +
+
+

+ {{ item.title }} +

+

+ {{ item.description }} +

+
-
-

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

-

- {{ _("Read-only access to your public data") }} -

-
-
+ {% endfor %}
diff --git a/migrations/versions/3d0999440b81_.py b/migrations/versions/3d0999440b81_.py new file mode 100644 index 00000000..a1837d81 --- /dev/null +++ b/migrations/versions/3d0999440b81_.py @@ -0,0 +1,39 @@ +"""empty message + +Revision ID: 3d0999440b81 +Revises: 52cf6746f255 +Create Date: 2023-11-01 00:45:24.057951 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '3d0999440b81' +down_revision = '52cf6746f255' +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('scope_user_email', sa.Boolean(), nullable=False, server_default="false")) + batch_op.add_column(sa.Column('scope_package', sa.Boolean(), nullable=False, server_default="false")) + batch_op.add_column(sa.Column('scope_package_release', sa.Boolean(), nullable=False, server_default="false")) + batch_op.add_column(sa.Column('scope_package_screenshot', sa.Boolean(), nullable=False, server_default="false")) + + op.execute(""" + UPDATE api_token SET + scope_user_email = true, + scope_package = true, + scope_package_release = true, + scope_package_screenshot = true; + """) + + +def downgrade(): + with op.batch_alter_table('api_token', schema=None) as batch_op: + batch_op.drop_column('scope_package_screenshot') + batch_op.drop_column('scope_package_release') + batch_op.drop_column('scope_package') + batch_op.drop_column('scope_user_email')