WIP prototype oauth scopes

This commit is contained in:
rubenwardy 2023-11-01 01:07:23 +00:00
parent d4b1344f6a
commit 27e2b64e41
6 changed files with 151 additions and 27 deletions

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

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

6
app/logic/scope.py Normal file

@ -0,0 +1,6 @@
from app.models import APIToken
class Scope:
def copy_to_token(self, token: APIToken):
pass

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

@ -51,19 +51,21 @@
</div>
</div>
{% endif %}
<div class="row my-4 align-items-center">
<div class="col-2 text-center fs-3">
<i class="fas fa-globe-europe"></i>
{% for item in scopes %}
<div class="row my-4 align-items-center">
<div class="col-2 text-center fs-3">
<i class="fas fa-{{ item.icon }}"></i>
</div>
<div class="col">
<p class="my-0">
{{ item.title }}
</p>
<p class="text-muted my-0">
{{ item.description }}
</p>
</div>
</div>
<div class="col">
<p class="my-0">
{{ _("Public data only") }}
</p>
<p class="text-muted my-0">
{{ _("Read-only access to your public data") }}
</p>
</div>
</div>
{% endfor %}
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<div class="row mt-5">
<div class="col">

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