+ 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 @@- {{ _("For security reasons, access tokens will only be shown once. Reset the token if it is lost.") }} -
- {% if access_token %} - - {% endif %} - -+ {{ _("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 %} ++ {{ _("For security reasons, access tokens will only be shown once. Reset the token if it is lost.") }} +
+ {% if access_token %} + + {% endif %} + +