From 24c686f6b274e04082b13f159ea10d995c2ca281 Mon Sep 17 00:00:00 2001 From: halcy Date: Sun, 13 Nov 2022 22:32:04 +0200 Subject: Improve auth support --- mastodon/Mastodon.py | 31 ++++- tests/cassettes/test_revoke.yaml | 253 +++++++++++++++++++++++++++++++++++++++ tests/test_auth.py | 27 ++++- 3 files changed, 304 insertions(+), 7 deletions(-) create mode 100644 tests/cassettes/test_revoke.yaml diff --git a/mastodon/Mastodon.py b/mastodon/Mastodon.py index e84df6d..48d850b 100644 --- a/mastodon/Mastodon.py +++ b/mastodon/Mastodon.py @@ -486,8 +486,7 @@ class Mastodon: """ return Mastodon.__SUPPORTED_MASTODON_VERSION - def auth_request_url(self, client_id=None, redirect_uris="urn:ietf:wg:oauth:2.0:oob", - scopes=__DEFAULT_SCOPES, force_login=False): + def auth_request_url(self, client_id=None, redirect_uris="urn:ietf:wg:oauth:2.0:oob", scopes=__DEFAULT_SCOPES, force_login=False, state=None): """ Returns the url that a client needs to request an oauth grant from the server. @@ -501,6 +500,10 @@ class Mastodon: Pass force_login if you want the user to always log in even when already logged into web mastodon (i.e. when registering multiple different accounts in an app). + + State is the oauth `state`parameter to pass to the server. It is strongly suggested + to use a random, nonguessable value (i.e. nothing meaningful and no incrementing ID) + to preserve security guarantees. It can be left out for non-web login flows. """ if client_id is None: client_id = self.client_id @@ -515,12 +518,11 @@ class Mastodon: params['redirect_uri'] = redirect_uris params['scope'] = " ".join(scopes) params['force_login'] = force_login + params['state'] = state formatted_params = urlencode(params) return "".join([self.api_base_url, "/oauth/authorize?", formatted_params]) - def log_in(self, username=None, password=None, - code=None, redirect_uri="urn:ietf:wg:oauth:2.0:oob", refresh_token=None, - scopes=__DEFAULT_SCOPES, to_file=None): + def log_in(self, username=None, password=None, code=None, redirect_uri="urn:ietf:wg:oauth:2.0:oob", refresh_token=None, scopes=__DEFAULT_SCOPES, to_file=None): """ Get the access token for a user. @@ -588,6 +590,25 @@ class Mastodon: return response['access_token'] + def revoke_access_token(self): + """ + Revoke the oauth token the user is currently authenticated with, effectively removing + the apps access and requiring the user to log in again. + """ + if self.access_token is None: + raise MastodonIllegalArgumentError("Not logged in, do not have a token to revoke.") + if self.client_id is None or self.client_secret is None: + raise MastodonIllegalArgumentError("Client authentication (id + secret) is required to revoke tokens.") + params = collections.OrderedDict([]) + params['client_id'] = self.client_id + params['client_secret'] = self.client_secret + params['token'] = self.access_token + self.__api_request('POST', '/oauth/revoke', params) + + # We are now logged out, clear token and logged in id + self.access_token = None + self.__logged_in_id = None + @api_version("2.7.0", "2.7.0", "2.7.0") def create_account(self, username, password, email, agreement=False, reason=None, locale="en", scopes=__DEFAULT_SCOPES, to_file=None): """ diff --git a/tests/cassettes/test_revoke.yaml b/tests/cassettes/test_revoke.yaml new file mode 100644 index 0000000..1bbc487 --- /dev/null +++ b/tests/cassettes/test_revoke.yaml @@ -0,0 +1,253 @@ +interactions: +- request: + body: username=mastodonpy_test_2%40localhost%3A3000&password=5fc638e0e53eafd9c4145b6bb852667d&redirect_uri=urn%3Aietf%3Awg%3Aoauth%3A2.0%3Aoob&grant_type=password&client_id=__MASTODON_PY_TEST_CLIENT_ID&client_secret=__MASTODON_PY_TEST_CLIENT_SECRET&scope=read+write+follow+push + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '271' + Content-Type: + - application/x-www-form-urlencoded + User-Agent: + - tests/v311 + method: POST + uri: http://localhost:3000/oauth/token + response: + body: + string: '{"access_token":"s3ZSxpaa2Uhe9EcHankvkfaQZQGiWpdEWIhX7GuhDlk","token_type":"Bearer","scope":"read + write follow push","created_at":1668370881}' + headers: + Cache-Control: + - no-store + Content-Security-Policy: + - 'base-uri ''none''; default-src ''none''; frame-ancestors ''none''; font-src + ''self'' http://localhost:3000; img-src ''self'' https: data: blob: http://localhost:3000; + style-src ''self'' http://localhost:3000 ''nonce-pCi2AQ9aKYXwS29cp2OHAg==''; + media-src ''self'' https: data: http://localhost:3000; frame-src ''self'' + https:; manifest-src ''self'' http://localhost:3000; connect-src ''self'' + data: blob: http://localhost:3000 http://localhost:3000 ws://localhost:4000 + ws://localhost:3035 http://localhost:3035; script-src ''self'' ''unsafe-inline'' + ''unsafe-eval'' http://localhost:3000; child-src ''self'' blob: http://localhost:3000; + worker-src ''self'' blob: http://localhost:3000' + Content-Type: + - application/json; charset=utf-8 + ETag: + - W/"b4c5259bc2edbe94aab5df0f3b8ba79a" + Pragma: + - no-cache + Referrer-Policy: + - strict-origin-when-cross-origin + Transfer-Encoding: + - chunked + Vary: + - Accept, Origin + X-Content-Type-Options: + - nosniff + X-Download-Options: + - noopen + X-Frame-Options: + - SAMEORIGIN + X-Permitted-Cross-Domain-Policies: + - none + X-Request-Id: + - 086350f4-00fc-4d82-a5ce-2b557df59682 + X-Runtime: + - '0.040539' + X-XSS-Protection: + - 1; mode=block + status: + code: 200 + message: OK +- request: + body: client_id=__MASTODON_PY_TEST_CLIENT_ID&client_secret=__MASTODON_PY_TEST_CLIENT_SECRET&token=s3ZSxpaa2Uhe9EcHankvkfaQZQGiWpdEWIhX7GuhDlk + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Authorization: + - Bearer s3ZSxpaa2Uhe9EcHankvkfaQZQGiWpdEWIhX7GuhDlk + Connection: + - keep-alive + Content-Length: + - '135' + Content-Type: + - application/x-www-form-urlencoded + User-Agent: + - tests/v311 + method: POST + uri: http://localhost:3000/oauth/revoke + response: + body: + string: '{}' + headers: + Cache-Control: + - max-age=0, private, must-revalidate + Content-Security-Policy: + - 'base-uri ''none''; default-src ''none''; frame-ancestors ''none''; font-src + ''self'' http://localhost:3000; img-src ''self'' https: data: blob: http://localhost:3000; + style-src ''self'' http://localhost:3000 ''nonce-PqM4ChK427oGZ5jIKprkYQ==''; + media-src ''self'' https: data: http://localhost:3000; frame-src ''self'' + https:; manifest-src ''self'' http://localhost:3000; connect-src ''self'' + data: blob: http://localhost:3000 http://localhost:3000 ws://localhost:4000 + ws://localhost:3035 http://localhost:3035; script-src ''self'' ''unsafe-inline'' + ''unsafe-eval'' http://localhost:3000; child-src ''self'' blob: http://localhost:3000; + worker-src ''self'' blob: http://localhost:3000' + Content-Type: + - application/json; charset=utf-8 + ETag: + - W/"44136fa355b3678a1146ad16f7e8649e" + Referrer-Policy: + - strict-origin-when-cross-origin + Transfer-Encoding: + - chunked + Vary: + - Accept + X-Content-Type-Options: + - nosniff + X-Download-Options: + - noopen + X-Frame-Options: + - SAMEORIGIN + X-Permitted-Cross-Domain-Policies: + - none + X-Request-Id: + - 36ec7e63-b15b-487a-aa4a-c396db945794 + X-Runtime: + - '0.010431' + X-XSS-Protection: + - 1; mode=block + status: + code: 200 + message: OK +- request: + body: status=illegal+access+detected + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '30' + Content-Type: + - application/x-www-form-urlencoded + User-Agent: + - tests/v311 + method: POST + uri: http://localhost:3000/api/v1/statuses + response: + body: + string: '{"error":"The access token is invalid"}' + headers: + Cache-Control: + - no-store + Content-Security-Policy: + - 'base-uri ''none''; default-src ''none''; frame-ancestors ''none''; font-src + ''self'' http://localhost:3000; img-src ''self'' https: data: blob: http://localhost:3000; + style-src ''self'' http://localhost:3000 ''nonce-UClOh6+Y0zf3a4O/ysqT/w==''; + media-src ''self'' https: data: http://localhost:3000; frame-src ''self'' + https:; manifest-src ''self'' http://localhost:3000; connect-src ''self'' + data: blob: http://localhost:3000 http://localhost:3000 ws://localhost:4000 + ws://localhost:3035 http://localhost:3035; script-src ''self'' ''unsafe-inline'' + ''unsafe-eval'' http://localhost:3000; child-src ''self'' blob: http://localhost:3000; + worker-src ''self'' blob: http://localhost:3000' + Content-Type: + - application/json; charset=utf-8 + Pragma: + - no-cache + Referrer-Policy: + - strict-origin-when-cross-origin + Transfer-Encoding: + - chunked + Vary: + - Accept, Origin + WWW-Authenticate: + - Bearer realm="Doorkeeper", error="invalid_token", error_description="The access + token is invalid" + X-Content-Type-Options: + - nosniff + X-Download-Options: + - noopen + X-Frame-Options: + - SAMEORIGIN + X-Permitted-Cross-Domain-Policies: + - none + X-Request-Id: + - ab1f9d04-149b-431a-b2b0-79921d45f3bc + X-Runtime: + - '0.005292' + X-XSS-Protection: + - 1; mode=block + status: + code: 401 + message: Unauthorized +- request: + body: status=illegal+access+detected + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '30' + Content-Type: + - application/x-www-form-urlencoded + User-Agent: + - tests/v311 + method: POST + uri: http://localhost:3000/api/v1/statuses + response: + body: + string: '{"error":"The access token is invalid"}' + headers: + Cache-Control: + - no-store + Content-Security-Policy: + - 'base-uri ''none''; default-src ''none''; frame-ancestors ''none''; font-src + ''self'' http://localhost:3000; img-src ''self'' https: data: blob: http://localhost:3000; + style-src ''self'' http://localhost:3000 ''nonce-ZTmQUUs9q7lX74Aa3bTgzA==''; + media-src ''self'' https: data: http://localhost:3000; frame-src ''self'' + https:; manifest-src ''self'' http://localhost:3000; connect-src ''self'' + data: blob: http://localhost:3000 http://localhost:3000 ws://localhost:4000 + ws://localhost:3035 http://localhost:3035; script-src ''self'' ''unsafe-inline'' + ''unsafe-eval'' http://localhost:3000; child-src ''self'' blob: http://localhost:3000; + worker-src ''self'' blob: http://localhost:3000' + Content-Type: + - application/json; charset=utf-8 + Pragma: + - no-cache + Referrer-Policy: + - strict-origin-when-cross-origin + Transfer-Encoding: + - chunked + Vary: + - Accept, Origin + WWW-Authenticate: + - Bearer realm="Doorkeeper", error="invalid_token", error_description="The access + token is invalid" + X-Content-Type-Options: + - nosniff + X-Download-Options: + - noopen + X-Frame-Options: + - SAMEORIGIN + X-Permitted-Cross-Domain-Policies: + - none + X-Request-Id: + - 9a7ad262-0d94-4df6-92a5-a670d6515979 + X-Runtime: + - '0.004613' + X-XSS-Protection: + - 1; mode=block + status: + code: 401 + message: Unauthorized +version: 1 diff --git a/tests/test_auth.py b/tests/test_auth.py index c3acb66..a14b4e7 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -22,14 +22,37 @@ def test_log_in_none(api_anonymous): with pytest.raises(MastodonIllegalArgumentError): api_anonymous.log_in() - @pytest.mark.vcr() def test_log_in_password(api_anonymous): token = api_anonymous.log_in( username='mastodonpy_test_2@localhost:3000', - password='5fc638e0e53eafd9c4145b6bb852667d') + password='5fc638e0e53eafd9c4145b6bb852667d' + ) assert token +@pytest.mark.vcr() +def test_revoke(api_anonymous): + token = api_anonymous.log_in( + username='mastodonpy_test_2@localhost:3000', + password='5fc638e0e53eafd9c4145b6bb852667d' + ) + api_anonymous.revoke_access_token() + + try: + api_anonymous.toot("illegal access detected") + assert False + except Exception as e: + print(e) + pass + + api_revoked_token = Mastodon(access_token = token) + try: + api_anonymous.toot("illegal access detected") + assert False + except Exception as e: + print(e) + pass + @pytest.mark.vcr() def test_log_in_password_incorrect(api_anonymous): with pytest.raises(MastodonIllegalArgumentError): -- cgit v1.2.3