diff --git a/mastodon/Mastodon.py b/mastodon/Mastodon.py index b19c3de..444c12d 100644 --- a/mastodon/Mastodon.py +++ b/mastodon/Mastodon.py @@ -476,7 +476,71 @@ class Mastodon: self.__logged_in_id = None return response['access_token'] - + + @api_version("2.7.0", "2.7.0", "2.7.0") + def create_account(self, username, password, email, agreement=False, locale="en", scopes=__DEFAULT_SCOPES, to_file=None): + """ + Creates a new user account with the given username, password and email. "agreement" + must be set to true (after showing the user the instances user agreement and having + them agree to it), "locale" specifies the language for the confirmation e-mail as an + ISO 639-1 (two-letter) language code. + + Does not require an access token, but does require a client grant. + + By default, this method is rate-limited by IP to 5 requests per 30 minutes. + + Returns an access token (just like log_in), which it can also persist to to_file, + and sets it internally so that the user is now logged in. Note that this token + can only be used after the user has confirmed their e-mail. + """ + params = self.__generate_params(locals(), ['to_file', 'scopes']) + params['client_id'] = self.client_id + params['client_secret'] = self.client_secret + + if agreement == False: + del params_initial['agreement'] + + # Step 1: Get a user-free token via oauth + try: + oauth_params = {} + oauth_params['scope'] = " ".join(scopes) + oauth_params['client_id'] = self.client_id + oauth_params['client_secret'] = self.client_secret + oauth_params['grant_type'] = 'client_credentials' + + response = self.__api_request('POST', '/oauth/token', oauth_params, do_ratelimiting=False) + temp_access_token = response['access_token'] + except Exception as e: + raise MastodonIllegalArgumentError('Invalid request during oauth phase: %s' % e) + + # Step 2: Use that to create a user + try: + response = self.__api_request('POST', '/api/v1/accounts', params, do_ratelimiting=False, + access_token_override = temp_access_token) + self.access_token = response['access_token'] + self.__set_refresh_token(response.get('refresh_token')) + self.__set_token_expired(int(response.get('expires_in', 0))) + except Exception as e: + raise MastodonIllegalArgumentError('Invalid request: %s' % e) + + # Step 3: Check scopes, persist, et cetera + received_scopes = response["scope"].split(" ") + for scope_set in self.__SCOPE_SETS.keys(): + if scope_set in received_scopes: + received_scopes += self.__SCOPE_SETS[scope_set] + + if not set(scopes) <= set(received_scopes): + raise MastodonAPIError( + 'Granted scopes "' + " ".join(received_scopes) + '" do not contain all of the requested scopes "' + " ".join(scopes) + '".') + + if to_file is not None: + with open(to_file, 'w') as token_file: + token_file.write(response['access_token'] + '\n') + + self.__logged_in_id = None + + return response['access_token'] + ### # Reading data: Instances ### @@ -2287,7 +2351,7 @@ class Mastodon: json_object = Mastodon.__json_allow_dict_attrs(json_object) return json_object - def __api_request(self, method, endpoint, params={}, files={}, headers={}, do_ratelimiting=True): + def __api_request(self, method, endpoint, params={}, files={}, headers={}, access_token_override=None, do_ratelimiting=True): """ Internal API request helper. """ @@ -2314,8 +2378,10 @@ class Mastodon: # Generate request headers headers = copy.deepcopy(headers) - if self.access_token is not None: + if not self.access_token is None: headers['Authorization'] = 'Bearer ' + self.access_token + if not access_token_override is None: + headers['Authorization'] = 'Bearer ' + access_token_override if self.debug_requests: print('Mastodon: Request to endpoint "' + endpoint + '" using method "' + method + '".') diff --git a/tests/cassettes/test_app_account_create.yaml b/tests/cassettes/test_app_account_create.yaml new file mode 100644 index 0000000..83f7212 --- /dev/null +++ b/tests/cassettes/test_app_account_create.yaml @@ -0,0 +1,116 @@ +interactions: +- request: + body: client_name=mastodon.py+generated+test+app&scopes=read+write+follow+push&redirect_uris=urn%3Aietf%3Awg%3Aoauth%3A2.0%3Aoob + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + Content-Length: ['122'] + Content-Type: [application/x-www-form-urlencoded] + User-Agent: [python-requests/2.18.4] + method: POST + uri: http://localhost:3000/api/v1/apps + response: + body: {string: '{"id":"17","name":"mastodon.py generated test app","website":null,"redirect_uri":"urn:ietf:wg:oauth:2.0:oob","client_id":"6b8b07698f3f57d73b0fd779fac2fcc64d6e852d3334425a6b50b53bc32db986","client_secret":"df903d79cc8a27d8d4f9aa8213cf65a9681fea679a56643fcb3e5a3f66c4f9c7","vapid_key":"BCryMB_mKFcSpmXE3kJ1Ri3ZFVdBLjRsX54VYhE21BMyftx8k67qWxFs2OCuQCtj0k1ILESkQhGuOKJcQnodx4g="}'} + headers: + Cache-Control: ['max-age=0, private, must-revalidate'] + Content-Type: [application/json; charset=utf-8] + ETag: [W/"3a06e1b620ce8b2c3ce4045d1e2c179b"] + Referrer-Policy: [strict-origin-when-cross-origin] + Transfer-Encoding: [chunked] + Vary: ['Accept-Encoding, Origin'] + X-Content-Type-Options: [nosniff] + X-Download-Options: [noopen] + X-Frame-Options: [SAMEORIGIN] + X-Permitted-Cross-Domain-Policies: [none] + X-Request-Id: [258ec6bb-4a82-41c3-b55b-560fdfb5ad13] + X-Runtime: ['0.025005'] + X-XSS-Protection: [1; mode=block] + content-length: ['374'] + status: {code: 200, message: OK} +- request: + body: null + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + User-Agent: [python-requests/2.18.4] + method: GET + uri: http://localhost:3000/api/v1/instance/ + response: + body: {string: '{"uri":"localhost","title":"Mastodon","description":"","email":"","version":"2.8.0","urls":{"streaming_api":"ws://localhost:4000"},"stats":{"user_count":2,"status_count":15,"domain_count":0},"thumbnail":"http://localhost/packs/media/images/preview-9a17d32fc48369e8ccd910a75260e67d.jpg","languages":["en"],"registrations":true,"contact_account":null}'} + headers: + Cache-Control: ['max-age=300, public'] + Content-Type: [application/json; charset=utf-8] + Date: ['Sun, 28 Apr 2019 15:56:05 GMT'] + ETag: [W/"4ba18203af6a9d9402c05e8ffc21ac45"] + Referrer-Policy: [strict-origin-when-cross-origin] + Transfer-Encoding: [chunked] + Vary: ['Accept-Encoding, Origin'] + X-Content-Type-Options: [nosniff] + X-Download-Options: [noopen] + X-Frame-Options: [SAMEORIGIN] + X-Permitted-Cross-Domain-Policies: [none] + X-Request-Id: [bfb330d1-53c3-4fcc-b79e-30e256a2f845] + X-Runtime: ['0.025590'] + X-XSS-Protection: [1; mode=block] + content-length: ['349'] + status: {code: 200, message: OK} +- request: + body: scope=read+write+follow+push&client_id=6b8b07698f3f57d73b0fd779fac2fcc64d6e852d3334425a6b50b53bc32db986&client_secret=df903d79cc8a27d8d4f9aa8213cf65a9681fea679a56643fcb3e5a3f66c4f9c7&grant_type=client_credentials + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Connection: [keep-alive] + Content-Length: ['212'] + Content-Type: [application/x-www-form-urlencoded] + User-Agent: [python-requests/2.18.4] + method: POST + uri: http://localhost:3000/oauth/token + response: + body: {string: '{"access_token":"756276912d1d5d74cfbf2e275517a234cee584891ca1f87a3fb2bdbd1fed1ca1","token_type":"Bearer","scope":"read + write follow push","created_at":1556466966}'} + headers: + Cache-Control: ['private, no-store'] + Content-Type: [application/json; charset=utf-8] + ETag: [W/"851cc791f71c2ce9bf821e023e26b361"] + Pragma: [no-cache] + Transfer-Encoding: [chunked] + Vary: ['Accept-Encoding, Origin'] + X-Request-Id: [0205c26b-9c77-4c32-bb59-a2d69eafde33] + X-Runtime: ['0.039194'] + content-length: ['162'] + status: {code: 200, message: OK} +- request: + body: locale=en&agreement=1&email=email%40localhost11707&password=swordfish&username=coolguy11707&client_id=6b8b07698f3f57d73b0fd779fac2fcc64d6e852d3334425a6b50b53bc32db986&client_secret=df903d79cc8a27d8d4f9aa8213cf65a9681fea679a56643fcb3e5a3f66c4f9c7 + headers: + Accept: ['*/*'] + Accept-Encoding: ['gzip, deflate'] + Authorization: [Bearer 756276912d1d5d74cfbf2e275517a234cee584891ca1f87a3fb2bdbd1fed1ca1] + Connection: [keep-alive] + Content-Length: ['245'] + Content-Type: [application/x-www-form-urlencoded] + User-Agent: [python-requests/2.18.4] + method: POST + uri: http://localhost:3000/api/v1/accounts + response: + body: {string: '{"access_token":"b20f513163b154065d17f5aff37b779f51d13c152fe6e7d1be366d64d2e74e39","token_type":"Bearer","scope":"read + write follow push","created_at":1556466966}'} + headers: + Cache-Control: ['private, no-store'] + Content-Type: [application/json; charset=utf-8] + ETag: [W/"7ba50c22bb330ba5a0936c19bea78093"] + Pragma: [no-cache] + Referrer-Policy: [strict-origin-when-cross-origin] + Transfer-Encoding: [chunked] + Vary: ['Accept-Encoding, Origin'] + X-Content-Type-Options: [nosniff] + X-Download-Options: [noopen] + X-Frame-Options: [SAMEORIGIN] + X-Permitted-Cross-Domain-Policies: [none] + X-Request-Id: [51ac5a4c-e850-46be-89c6-681e46f891dd] + X-Runtime: ['0.208778'] + X-XSS-Protection: [1; mode=block] + content-length: ['162'] + status: {code: 200, message: OK} +version: 1 diff --git a/tests/test_create_app.py b/tests/test_create_app.py index 67318e9..f1153bc 100644 --- a/tests/test_create_app.py +++ b/tests/test_create_app.py @@ -1,6 +1,8 @@ from mastodon import Mastodon import pytest import requests +import time + try: from mock import Mock except ImportError: @@ -46,3 +48,22 @@ def test_app_verify_credentials(api): app = api.app_verify_credentials() assert app assert app.name == 'Mastodon.py test suite' + +@pytest.mark.vcr() +def test_app_account_create(): + # This leaves behind stuff on the test server, which is unfortunate, but eh. + suffix = str(time.time()).replace(".", "")[-5:] + + test_app = test_app = Mastodon.create_app( + "mastodon.py generated test app", + api_base_url="http://localhost:3000/" + ) + + test_app_api = Mastodon( + test_app[0], + test_app[1], + api_base_url="http://localhost:3000/" + ) + test_token = test_app_api.create_account("coolguy" + suffix, "swordfish", "email@localhost" + suffix, agreement=True) + assert test_token +