aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'mastodon/authentication.py')
-rw-r--r--mastodon/authentication.py370
1 files changed, 370 insertions, 0 deletions
diff --git a/mastodon/authentication.py b/mastodon/authentication.py
new file mode 100644
index 0000000..b7f15c9
--- /dev/null
+++ b/mastodon/authentication.py
@@ -0,0 +1,370 @@
1import requests
2from requests.models import urlencode
3import datetime
4import os
5import time
6import collections
7
8from .error import MastodonIllegalArgumentError, MastodonNetworkError, MastodonVersionError, MastodonAPIError
9from .defaults import _DEFAULT_SCOPES, _SCOPE_SETS, _DEFAULT_TIMEOUT
10from .utility import parse_version_string
11from .internals import Mastodon as Internals
12
13class Mastodon(Internals):
14 ###
15 # Registering apps
16 ###
17 @staticmethod
18 def create_app(client_name, scopes=_DEFAULT_SCOPES, redirect_uris=None, website=None, to_file=None,
19 api_base_url=None, request_timeout=_DEFAULT_TIMEOUT, session=None):
20 """
21 Create a new app with given `client_name` and `scopes` (The basic scopes are "read", "write", "follow" and "push"
22 - more granular scopes are available, please refer to Mastodon documentation for which) on the instance given
23 by `api_base_url`.
24
25 Specify `redirect_uris` if you want users to be redirected to a certain page after authenticating in an OAuth flow.
26 You can specify multiple URLs by passing a list. Note that if you wish to use OAuth authentication with redirects,
27 the redirect URI must be one of the URLs specified here.
28
29 Specify `to_file` to persist your app's info to a file so you can use it in the constructor.
30 Specify `website` to give a website for your app.
31
32 Specify `session` with a requests.Session for it to be used instead of the default. This can be
33 used to, amongst other things, adjust proxy or SSL certificate settings.
34
35 Presently, app registration is open by default, but this is not guaranteed to be the case for all
36 Mastodon instances in the future.
37
38
39 Returns `client_id` and `client_secret`, both as strings.
40 """
41 if api_base_url is None:
42 raise MastodonIllegalArgumentError("API base URL is required.")
43 api_base_url = Mastodon.__protocolize(api_base_url)
44
45 request_data = {
46 'client_name': client_name,
47 'scopes': " ".join(scopes)
48 }
49
50 try:
51 if redirect_uris is not None:
52 if isinstance(redirect_uris, (list, tuple)):
53 redirect_uris = "\n".join(list(redirect_uris))
54 request_data['redirect_uris'] = redirect_uris
55 else:
56 request_data['redirect_uris'] = 'urn:ietf:wg:oauth:2.0:oob'
57 if website is not None:
58 request_data['website'] = website
59 if session:
60 ret = session.post(api_base_url + '/api/v1/apps', data=request_data, timeout=request_timeout)
61 response = ret.json()
62 else:
63 response = requests.post(api_base_url + '/api/v1/apps', data=request_data, timeout=request_timeout)
64 response = response.json()
65 except Exception as e:
66 raise MastodonNetworkError("Could not complete request: %s" % e)
67
68 if to_file is not None:
69 with open(to_file, 'w') as secret_file:
70 secret_file.write(response['client_id'] + "\n")
71 secret_file.write(response['client_secret'] + "\n")
72 secret_file.write(api_base_url + "\n")
73 secret_file.write(client_name + "\n")
74
75 return (response['client_id'], response['client_secret'])
76
77 ###
78 # Authentication, including constructor
79 ###
80 def __init__(self, client_id=None, client_secret=None, access_token=None, api_base_url=None, debug_requests=False,
81 ratelimit_method="wait", ratelimit_pacefactor=1.1, request_timeout=_DEFAULT_TIMEOUT, mastodon_version=None,
82 version_check_mode="created", session=None, feature_set="mainline", user_agent="mastodonpy", lang=None):
83 """
84 Create a new API wrapper instance based on the given `client_secret` and `client_id` on the
85 instance given by `api_base_url`. If you give a `client_id` and it is not a file, you must
86 also give a secret. If you specify an `access_token` then you don't need to specify a `client_id`.
87 It is allowed to specify neither - in this case, you will be restricted to only using endpoints
88 that do not require authentication. If a file is given as `client_id`, client ID, secret and
89 base url are read from that file.
90
91 You can also specify an `access_token`, directly or as a file (as written by :ref:`log_in() <log_in()>`). If
92 a file is given, Mastodon.py also tries to load the base URL from this file, if present. A
93 client id and secret are not required in this case.
94
95 Mastodon.py can try to respect rate limits in several ways, controlled by `ratelimit_method`.
96 "throw" makes functions throw a `MastodonRatelimitError` when the rate
97 limit is hit. "wait" mode will, once the limit is hit, wait and retry the request as soon
98 as the rate limit resets, until it succeeds. "pace" works like throw, but tries to wait in
99 between calls so that the limit is generally not hit (how hard it tries to avoid hitting the rate
100 limit can be controlled by ratelimit_pacefactor). The default setting is "wait". Note that
101 even in "wait" and "pace" mode, requests can still fail due to network or other problems! Also
102 note that "pace" and "wait" are NOT thread safe.
103
104 By default, a timeout of 300 seconds is used for all requests. If you wish to change this,
105 pass the desired timeout (in seconds) as `request_timeout`.
106
107 For fine-tuned control over the requests object use `session` with a requests.Session.
108
109 The `mastodon_version` parameter can be used to specify the version of Mastodon that Mastodon.py will
110 expect to be installed on the server. The function will throw an error if an unparseable
111 Version is specified. If no version is specified, Mastodon.py will set `mastodon_version` to the
112 detected version.
113
114 The version check mode can be set to "created" (the default behaviour), "changed" or "none". If set to
115 "created", Mastodon.py will throw an error if the version of Mastodon it is connected to is too old
116 to have an endpoint. If it is set to "changed", it will throw an error if the endpoint's behaviour has
117 changed after the version of Mastodon that is connected has been released. If it is set to "none",
118 version checking is disabled.
119
120 `feature_set` can be used to enable behaviour specific to non-mainline Mastodon API implementations.
121 Details are documented in the functions that provide such functionality. Currently supported feature
122 sets are `mainline`, `fedibird` and `pleroma`.
123
124 For some Mastodon instances a `User-Agent` header is needed. This can be set by parameter `user_agent`. Starting from
125 Mastodon.py 1.5.2 `create_app()` stores the application name into the client secret file. If `client_id` points to this file,
126 the app name will be used as `User-Agent` header as default. It is possible to modify old secret files and append
127 a client app name to use it as a `User-Agent` name.
128
129 `lang` can be used to change the locale Mastodon will use to generate responses. Valid parameters are all ISO 639-1 (two letter)
130 or for a language that has none, 639-3 (three letter) language codes. This affects some error messages (those related to validation) and
131 trends. You can change the language using :ref:`set_language()`.
132
133 If no other `User-Agent` is specified, "mastodonpy" will be used.
134 """
135 self.api_base_url = api_base_url
136 if self.api_base_url is not None:
137 self.api_base_url = self.__protocolize(self.api_base_url)
138 self.client_id = client_id
139 self.client_secret = client_secret
140 self.access_token = access_token
141 self.debug_requests = debug_requests
142 self.ratelimit_method = ratelimit_method
143 self._token_expired = datetime.datetime.now()
144 self._refresh_token = None
145
146 self.__logged_in_id = None
147
148 self.ratelimit_limit = 300
149 self.ratelimit_reset = time.time()
150 self.ratelimit_remaining = 300
151 self.ratelimit_lastcall = time.time()
152 self.ratelimit_pacefactor = ratelimit_pacefactor
153
154 self.request_timeout = request_timeout
155
156 if session:
157 self.session = session
158 else:
159 self.session = requests.Session()
160
161 self.feature_set = feature_set
162 if not self.feature_set in ["mainline", "fedibird", "pleroma"]:
163 raise MastodonIllegalArgumentError('Requested invalid feature set')
164
165 # General defined user-agent
166 self.user_agent = user_agent
167
168 # Save language
169 self.lang = lang
170
171 # Token loading
172 if self.client_id is not None:
173 if os.path.isfile(self.client_id):
174 with open(self.client_id, 'r') as secret_file:
175 self.client_id = secret_file.readline().rstrip()
176 self.client_secret = secret_file.readline().rstrip()
177
178 try_base_url = secret_file.readline().rstrip()
179 if try_base_url is not None and len(try_base_url) != 0:
180 try_base_url = Mastodon.__protocolize(try_base_url)
181 if not (self.api_base_url is None or try_base_url == self.api_base_url):
182 raise MastodonIllegalArgumentError('Mismatch in base URLs between files and/or specified')
183 self.api_base_url = try_base_url
184
185 # With new registrations we support the 4th line to store a client_name and use it as user-agent
186 client_name = secret_file.readline()
187 if client_name and self.user_agent is None:
188 self.user_agent = client_name.rstrip()
189 else:
190 if self.client_secret is None:
191 raise MastodonIllegalArgumentError('Specified client id directly, but did not supply secret')
192
193 if self.access_token is not None and os.path.isfile(self.access_token):
194 with open(self.access_token, 'r') as token_file:
195 self.access_token = token_file.readline().rstrip()
196
197 # For newer versions, we also store the URL
198 try_base_url = token_file.readline().rstrip()
199 if try_base_url is not None and len(try_base_url) != 0:
200 try_base_url = Mastodon.__protocolize(try_base_url)
201 if not (self.api_base_url is None or try_base_url == self.api_base_url):
202 raise MastodonIllegalArgumentError('Mismatch in base URLs between files and/or specified')
203 self.api_base_url = try_base_url
204
205 # For EVEN newer vesions, we ALSO ALSO store the client id and secret so that you don't need to reauth to revoke
206 if self.client_id is None:
207 try:
208 self.client_id = token_file.readline().rstrip()
209 self.client_secret = token_file.readline().rstrip()
210 except:
211 pass
212
213 # Verify we have a base URL, protocolize
214 if self.api_base_url is None:
215 raise MastodonIllegalArgumentError("API base URL is required.")
216 self.api_base_url = Mastodon.__protocolize(self.api_base_url)
217
218 if not version_check_mode in ["created", "changed", "none"]:
219 raise MastodonIllegalArgumentError("Invalid version check method.")
220 self.version_check_mode = version_check_mode
221
222 self.mastodon_major = 1
223 self.mastodon_minor = 0
224 self.mastodon_patch = 0
225 self.version_check_worked = None
226
227 # Versioning
228 if mastodon_version is None and self.version_check_mode != 'none':
229 self.retrieve_mastodon_version()
230 elif self.version_check_mode != 'none':
231 try:
232 self.mastodon_major, self.mastodon_minor, self.mastodon_patch = parse_version_string(mastodon_version)
233 except:
234 raise MastodonVersionError("Bad version specified")
235
236 # Ratelimiting parameter check
237 if ratelimit_method not in ["throw", "wait", "pace"]:
238 raise MastodonIllegalArgumentError("Invalid ratelimit method.")
239
240 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, lang=None):
241 """
242 Returns the URL that a client needs to request an OAuth grant from the server.
243
244 To log in with OAuth, send your user to this URL. The user will then log in and
245 get a code which you can pass to :ref:`log_in() <log_in()>`.
246
247 `scopes` are as in :ref:`log_in() <log_in()>`, redirect_uris is where the user should be redirected to
248 after authentication. Note that `redirect_uris` must be one of the URLs given during
249 app registration. When using urn:ietf:wg:oauth:2.0:oob, the code is simply displayed,
250 otherwise it is added to the given URL as the "code" request parameter.
251
252 Pass force_login if you want the user to always log in even when already logged
253 into web Mastodon (i.e. when registering multiple different accounts in an app).
254
255 `state` is the oauth `state` parameter to pass to the server. It is strongly suggested
256 to use a random, nonguessable value (i.e. nothing meaningful and no incrementing ID)
257 to preserve security guarantees. It can be left out for non-web login flows.
258
259 Pass an ISO 639-1 (two letter) or, for languages that do not have one, 639-3 (three letter)
260 language code as `lang` to control the display language for the oauth form.
261 """
262 if client_id is None:
263 client_id = self.client_id
264 else:
265 if os.path.isfile(client_id):
266 with open(client_id, 'r') as secret_file:
267 client_id = secret_file.readline().rstrip()
268
269 params = dict()
270 params['client_id'] = client_id
271 params['response_type'] = "code"
272 params['redirect_uri'] = redirect_uris
273 params['scope'] = " ".join(scopes)
274 params['force_login'] = force_login
275 params['state'] = state
276 params['lang'] = lang
277 formatted_params = urlencode(params)
278 return "".join([self.api_base_url, "/oauth/authorize?", formatted_params])
279
280 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):
281 """
282 Get the access token for a user.
283
284 The username is the email address used to log in into Mastodon.
285
286 Can persist access token to file `to_file`, to be used in the constructor.
287
288 Handles password and OAuth-based authorization.
289
290 Will throw a `MastodonIllegalArgumentError` if the OAuth flow data or the
291 username / password credentials given are incorrect, and
292 `MastodonAPIError` if all of the requested scopes were not granted.
293
294 For OAuth 2, obtain a code via having your user go to the URL returned by
295 :ref:`auth_request_url() <auth_request_url()>` and pass it as the code parameter. In this case,
296 make sure to also pass the same redirect_uri parameter as you used when
297 generating the auth request URL.
298
299 Returns the access token as a string.
300 """
301 if username is not None and password is not None:
302 params = self.__generate_params(locals(), ['scopes', 'to_file', 'code', 'refresh_token'])
303 params['grant_type'] = 'password'
304 elif code is not None:
305 params = self.__generate_params(locals(), ['scopes', 'to_file', 'username', 'password', 'refresh_token'])
306 params['grant_type'] = 'authorization_code'
307 elif refresh_token is not None:
308 params = self.__generate_params(locals(), ['scopes', 'to_file', 'username', 'password', 'code'])
309 params['grant_type'] = 'refresh_token'
310 else:
311 raise MastodonIllegalArgumentError('Invalid arguments given. username and password or code are required.')
312
313 params['client_id'] = self.client_id
314 params['client_secret'] = self.client_secret
315 params['scope'] = " ".join(scopes)
316
317 try:
318 response = self.__api_request('POST', '/oauth/token', params, do_ratelimiting=False)
319 self.access_token = response['access_token']
320 self.__set_refresh_token(response.get('refresh_token'))
321 self.__set_token_expired(int(response.get('expires_in', 0)))
322 except Exception as e:
323 if username is not None or password is not None:
324 raise MastodonIllegalArgumentError('Invalid user name, password, or redirect_uris: %s' % e)
325 elif code is not None:
326 raise MastodonIllegalArgumentError('Invalid access token or redirect_uris: %s' % e)
327 else:
328 raise MastodonIllegalArgumentError('Invalid request: %s' % e)
329
330 received_scopes = response["scope"].split(" ")
331 for scope_set in _SCOPE_SETS.keys():
332 if scope_set in received_scopes:
333 received_scopes += _SCOPE_SETS[scope_set]
334
335 if not set(scopes) <= set(received_scopes):
336 raise MastodonAPIError('Granted scopes "' + " ".join(received_scopes) + '" do not contain all of the requested scopes "' + " ".join(scopes) + '".')
337
338 if to_file is not None:
339 with open(to_file, 'w') as token_file:
340 token_file.write(response['access_token'] + "\n")
341 token_file.write(self.api_base_url + "\n")
342 token_file.write(self.client_id + "\n")
343 token_file.write(self.client_secret + "\n")
344
345 self.__logged_in_id = None
346
347 # Retry version check if needed (might be required in limited federation mode)
348 if not self.version_check_worked:
349 self.retrieve_mastodon_version()
350
351 return response['access_token']
352
353 def revoke_access_token(self):
354 """
355 Revoke the oauth token the user is currently authenticated with, effectively removing
356 the apps access and requiring the user to log in again.
357 """
358 if self.access_token is None:
359 raise MastodonIllegalArgumentError("Not logged in, do not have a token to revoke.")
360 if self.client_id is None or self.client_secret is None:
361 raise MastodonIllegalArgumentError("Client authentication (id + secret) is required to revoke tokens.")
362 params = collections.OrderedDict([])
363 params['client_id'] = self.client_id
364 params['client_secret'] = self.client_secret
365 params['token'] = self.access_token
366 self.__api_request('POST', '/oauth/revoke', params)
367
368 # We are now logged out, clear token and logged in id
369 self.access_token = None
370 self.__logged_in_id = None \ No newline at end of file
Powered by cgit v1.2.3 (git 2.41.0)