-
Notifications
You must be signed in to change notification settings - Fork 1
/
resource.py
425 lines (389 loc) · 21.4 KB
/
resource.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
# Copyright (c) Sebastian Scholz
# See LICENSE for details.
""" The authorization endpoint. """
import logging
import time
import warnings
from uuid import uuid4
from abc import ABCMeta, abstractmethod
try:
from urlparse import urlparse
except ImportError:
# noinspection PyUnresolvedReferences
from urllib.parse import urlparse
from twisted.web.resource import Resource
from twisted.web.server import NOT_DONE_YET
from txoauth2.util import addToUrl
from txoauth2.granttypes import GrantTypes
from .errors import MissingParameterError, InsecureConnectionError, InvalidRedirectUriError, \
UserDeniesAuthorization, UnsupportedResponseTypeError, \
UnauthorizedClientError, ServerError, AuthorizationError, MalformedParameterError, \
MultipleParameterError, InvalidScopeError, InvalidParameterError, OAuth2Error
class InvalidDataKeyError(KeyError):
"""
Exception that is raised when an invalid or expired
data key is passed to denyAccess or grantAccess.
"""
class InsecureRedirectUriError(RuntimeError):
""" Exception that is raised when an insecure redirect uri is used in grantAccess. """
class OAuth2(Resource, object):
"""
This resource handles the authorization process by the user.
Clients that want to get tokens need to send the user to
this resource to start the authorization process.
While configuring the client, one needs to specify the address
of this resource as the "Authorization Endpoint".
Authorization Code Grant Flow:
1: A client sends the user to this resource and sends the parameter state, client_id,
response_type, scope, and redirect_uri as query parameters of the (GET) request.
2: After validating the parameters, this class calls onAuthenticate. At this point one
could redirect to a login page an then send the user back when they are logged in.
3: onAuthenticate need to show the user a html page which explains that they allow the client
access to all resources which require the permissions in 'scope'.
4a: If the user denies access, you need to call denyAccess.
4b: If the user agrees, you need to call grantAccess and the user is then redirected to
one of the returnUris of the client. The request to the redirect url will contain a
code in the url parameters. The code does not grant access to the scope and has a very
short lifetime.
5: The client uses the code to get a token from the TokenEndpoint.
Implicit Grant Flow:
1 - 4a: Same as in the Authorization Code Grant.
4b: If the user agrees, you need to call grantAccess and the user is then redirected to
one of the returnUris of the client. The request to the redirect url will contain an
authorization token in the url parameters, which the client can use to access
the resources indicated by the scope.
"""
__metaclass__ = ABCMeta
acceptedGrantTypes = [GrantTypes.AUTHORIZATION_CODE.value, GrantTypes.IMPLICIT.value]
requestDataLifetime = 3600
authTokenLifeTime = 3600
allowInsecureRequestDebug = False
defaultScope = None
_tokenFactory = None
_persistentStorage = None
_clientStorage = None
_authTokenStorage = None
def __init__(self, tokenFactory, persistentStorage, clientStorage,
requestDataLifeTime=3600, authTokenLifeTime=3600, allowInsecureRequestDebug=False,
grantTypes=None, authTokenStorage=None, defaultScope=None):
"""
Creates a new OAuth2 Resource.
:param tokenFactory: A tokenFactory to generate short lived tokens.
:param persistentStorage: A persistent storage that can be accessed by the TokenResource.
:param clientStorage: A handle to the storage of known clients.
:param requestDataLifeTime: The lifetime of the data stored for an authorization request in
seconds. Essentially the maximum amount of time that can pass
between the call to onAuthenticate and deny-/grantAccess.
:param authTokenLifeTime: The lifetime of the tokens generated from the implicit grant flow.
:param allowInsecureRequestDebug: If True, allow requests over insecure connections.
Do NOT use in production!
:param grantTypes: The grant types that are enabled for this authorization endpoint.
:param authTokenStorage: The token storage in which to store tokens generated in the
implicit grant flow. Only needed if the implicit flow is enabled.
Must be the same as the one passed to the token resource.
:param defaultScope: A list of scopes that should be used as a default
for authorization requests if they don't provide one.
"""
super(OAuth2, self).__init__()
self._tokenFactory = tokenFactory
self._persistentStorage = persistentStorage
self._clientStorage = clientStorage
self._authTokenStorage = authTokenStorage
self.allowInsecureRequestDebug = allowInsecureRequestDebug
self.requestDataLifetime = requestDataLifeTime
self.authTokenLifeTime = authTokenLifeTime
if authTokenLifeTime is None:
raise ValueError('Authentication tokens generated with the '
'implicit grant flow need a limited lifetime.')
if grantTypes is not None:
for grantType in [GrantTypes.REFRESH_TOKEN, GrantTypes.PASSWORD,
GrantTypes.CLIENT_CREDENTIALS]:
if grantType in grantTypes:
grantTypes.remove(grantType)
grantTypes = [grantType.value if isinstance(grantType, GrantTypes) else grantType
for grantType in grantTypes]
self.acceptedGrantTypes = grantTypes
if defaultScope is not None:
self.defaultScope = defaultScope
if GrantTypes.IMPLICIT.value in self.acceptedGrantTypes and self._authTokenStorage is None:
raise ValueError('The token storage can not be None '
'when the implicit authorization flow is enabled')
@classmethod
def initFromTokenResource(cls, tokenResource, *args, **kwargs):
"""
Create an OAuth2 Resource with the tokenFactory, the persistentStorage
and the clientStorage of the tokenResource. The allowInsecureRequestDebug
flag is also copied.
If a subPath keyword argument is given, the tokenResource is added as a child to the new
OAuth2 Resource at the subPath.
:param tokenResource: The TokenResource to initialize the new OAuth2 Resource.
:param args: Arguments to the for the classes constructor.
:param kwargs: Keyword arguments to the for the classes constructor.
:return: A new initialized OAuth2 Resource.
"""
keywordArgs = {
'authTokenLifeTime': tokenResource.authTokenLifeTime,
'allowInsecureRequestDebug': tokenResource.allowInsecureRequestDebug,
'authTokenStorage': tokenResource.getTokenStorageSingleton(),
'defaultScope': tokenResource.defaultScope
}
keywordArgs.update(kwargs)
subPath = keywordArgs.pop('subPath', None)
oAuth2Resource = cls(tokenResource.tokenFactory, tokenResource.persistentStorage,
tokenResource.clientStorage, *args, **keywordArgs)
if subPath is not None:
oAuth2Resource.putChild(subPath, tokenResource)
return oAuth2Resource
def render_GET(self, request): # pylint: disable=invalid-name
"""
Handle a GET request to this resource. This initializes
the authorization process.
All parameter necessary for authorization are parsed from the
request and on onAuthenticate is called with the parsed arguments.
:param request: The GET request.
:return: A response or NOT_DONE_YET
"""
if b'client_id' not in request.args:
return MissingParameterError('client_id').generate(request)
if len(request.args[b'client_id']) != 1:
return MultipleParameterError('client_id').generate(request)
try:
clientId = request.args[b'client_id'][0].decode('utf-8')
except UnicodeDecodeError:
return MalformedParameterError('client_id').generate(request)
try:
client = self._clientStorage.getClient(clientId)
except KeyError:
return InvalidParameterError('client_id').generate(request)
if b'redirect_uri' not in request.args:
if len(client.redirectUris) != 1:
return MissingParameterError('redirect_uri').generate(request)
redirectUri = client.redirectUris[0]
elif len(request.args[b'redirect_uri']) != 1:
return MultipleParameterError('redirect_uri').generate(request)
else:
try:
redirectUri = request.args[b'redirect_uri'][0].decode('utf-8')
except UnicodeDecodeError:
return MalformedParameterError('redirect_uri').generate(request)
if redirectUri not in client.redirectUris:
return InvalidRedirectUriError().generate(request)
try:
errorInFragment = request.args[b'response_type'][0] == b'token'
except (KeyError, IndexError):
errorInFragment = False
if b'state' in request.args and len(request.args[b'state']) != 1:
return MultipleParameterError('state').generate(request, redirectUri, errorInFragment)
state = request.args.get(b'state', [None])[0]
if not self.allowInsecureRequestDebug and not request.isSecure():
return InsecureConnectionError(state).generate(request, redirectUri, errorInFragment)
if b'response_type' not in request.args:
return MissingParameterError('response_type', state=state)\
.generate(request, redirectUri, errorInFragment)
elif len(request.args[b'response_type']) != 1:
return MultipleParameterError('response_type', state=state)\
.generate(request, redirectUri, errorInFragment)
try:
responseType = request.args[b'response_type'][0].decode('utf-8')
except UnicodeDecodeError:
return MalformedParameterError('response_type', state)\
.generate(request, redirectUri, errorInFragment)
errorInFragment = responseType == 'token'
if b'scope' not in request.args:
if self.defaultScope is None:
return MissingParameterError('scope', state=state)\
.generate(request, redirectUri, errorInFragment)
scope = self.defaultScope
elif len(request.args[b'scope']) != 1:
return MultipleParameterError('scope', state=state)\
.generate(request, redirectUri, errorInFragment)
else:
try:
scope = request.args[b'scope'][0].decode('utf-8').split()
except UnicodeDecodeError:
return InvalidScopeError(request.args[b'scope'][0], state=state)\
.generate(request, redirectUri, errorInFragment)
grantType = responseType
if responseType == 'code':
grantType = GrantTypes.AUTHORIZATION_CODE.value
elif responseType == 'token':
grantType = GrantTypes.IMPLICIT.value
if grantType not in self.acceptedGrantTypes:
return UnsupportedResponseTypeError(responseType, state)\
.generate(request, redirectUri, errorInFragment)
if grantType not in client.authorizedGrantTypes:
return UnauthorizedClientError(responseType, state)\
.generate(request, redirectUri, errorInFragment)
return self._handleAuthenticationRequest(
request, client, grantType, redirectUri, scope, state, errorInFragment)
@abstractmethod
def onAuthenticate(self, request, client, responseType, scope, redirectUri, state, dataKey):
"""
Called when a valid GET request is made to this OAuth2 resource.
This happens when a clients sends a user to this resource.
The user should be presented with a website that clearly informs him
that he can give access all or a subset of the scopes to the client.
He must have the option to allow or deny the request.
It is also possible to redirect the user to a different site
here (e.g. to a login page).
If the user grants access, call 'grantAccess' with the dataKey.
If the user denies access, call 'denyAccess' with the dataKey.
If the redirect uri does not use TSL, the user should be warned,
because it severely impacts the security of the authorization process.
(See https://tools.ietf.org/html/rfc6749#section-3.1.2.1)
If this method determines that the received request is not valid,
it should return an instance of an AuthorizationError.
:param request: The GET request.
:param client: The client that sent the user.
:param responseType: The OAuth2 response type (one of the values in _acceptedGrantTypes).
:param scope: The list of scopes that the client requests access to.
:param redirectUri: The uri the user should get redirected to
after he grants or denies access.
:param state: The state that was send by the client.
:param dataKey: This key is tied to this request
and must be passed to denyAccess or grantAccess.
:return: A response or NOT_DONE_YET
"""
raise NotImplementedError()
def denyAccess(self, request, dataKey):
"""
The user denies access to the requested scopes.
This method redirects the user to the redirectUri
with an access_denied parameter, as required
by the OAuth2 spec.
The request will be closed and can't be written
to after this function returns.
:raises InvalidDataKeyError: If the given data key is invalid or expired.
:param request: The request made by the user.
:param dataKey: The data key that was given to onAuthenticate.
:return: NOT_DONE_YET
"""
try:
data = self._persistentStorage.pop(dataKey)
except KeyError:
raise InvalidDataKeyError(dataKey)
errorInFragment = data['response_type'] == GrantTypes.IMPLICIT.value
redirectUri = data['redirect_uri']
return UserDeniesAuthorization(data['state'])\
.generate(request, redirectUri, errorInFragment)
def grantAccess(self, request, dataKey, scope=None, codeLifeTime=120, additionalData=None,
allowInsecureRedirectUri=False):
"""
The user grants access to the list of scopes. This list may
contain less values than the original list passed to onAuthenticate.
The user will be redirected to the redirectUri with a code or a
token as a parameter, depending on the responseType.
The request will be closed and can't be written
to after this function returns.
:raises InvalidDataKeyError: If the given data key is invalid or expired.
:raises InsecureRedirectUriError: If the given redirect uri is not
using a secure scheme and insecure connections are not allowed.
:raises ValueError: If the data key belongs to a request with a custom response type.
:param request: The request made by the user.
:param dataKey: The allowInsecureRedirectUri is false and the redirect uri is not secure.
:param scope: The scope the user grants the client access to.
Must be None (=> the same) or a subset of the scope given to onAuthenticate.
:param codeLifeTime: The lifetime of the generated code, if responseType is 'code'.
This code can be used at the TokenResource to get a real token.
The code itself is not a token and should expire soon.
:param additionalData: Any additional data that should be passed associated
with the generated tokens.
:param allowInsecureRedirectUri: If false, this method will throw a InsecureRedirectUriError
if the redirect uri does not use TLS (https).
:return: NOT_DONE_YET
"""
try:
data = self._persistentStorage.pop(dataKey)
except KeyError:
raise InvalidDataKeyError(dataKey)
state = data['state']
responseType = data['response_type']
errorInFragment = responseType == GrantTypes.IMPLICIT.value
if responseType not in [GrantTypes.AUTHORIZATION_CODE.value, GrantTypes.IMPLICIT.value]:
self._persistentStorage.put(
dataKey, data, expireTime=int(time.time()) + self.requestDataLifetime)
raise ValueError(responseType)
redirectUri = data['redirect_uri']
try:
client = self._clientStorage.getClient(data['client_id'])
except KeyError:
return InvalidParameterError('client_id', state=state)\
.generate(request, redirectUri, errorInFragment)
if not self.allowInsecureRequestDebug and not request.isSecure():
return InsecureConnectionError(state).generate(request, redirectUri, errorInFragment)
if not allowInsecureRedirectUri and urlparse(redirectUri).scheme != 'https':
self._persistentStorage.put(
dataKey, data, expireTime=int(time.time()) + self.requestDataLifetime)
raise InsecureRedirectUriError()
if scope is not None:
for acceptedScope in scope:
if acceptedScope not in data['scope']:
return InvalidScopeError(scope, state)\
.generate(request, redirectUri, errorInFragment)
else:
scope = data['scope']
if responseType == GrantTypes.AUTHORIZATION_CODE.value:
code = self._tokenFactory.generateToken(
client, codeLifeTime, scope, additionalData=additionalData)
self._persistentStorage.put('code' + code, {
'client_id': client.id,
'redirect_uri': redirectUri,
'additional_data': additionalData,
'scope': scope
}, expireTime=int(time.time()) + codeLifeTime)
redirectUri = addToUrl(redirectUri, query={'state': state, 'code': code})
else:
token = self._tokenFactory.generateToken(
self.authTokenLifeTime, client, scope, additionalData=additionalData)
self._authTokenStorage.store(token, client, scope, additionalData=additionalData,
expireTime=int(time.time()) + self.authTokenLifeTime)
redirectUri = addToUrl(redirectUri, fragment={
'state': state, 'access_token': token, 'token_type': 'Bearer',
'expires_in': self.authTokenLifeTime, 'scope': ' '.join(scope)})
request.redirect(redirectUri)
request.finish()
return NOT_DONE_YET
def _handleAuthenticationRequest(
self, request, client, grantType, redirectUri, scope, state, errorInFragment):
"""
handle an authentication request. The request has already been validated.
:param request: The request.
:param client: The client that initiated the request.
:param grantType: The grant type of the request.
:param redirectUri: The uri to redirect the user to
after the request was accepted or denied.
:param scope: The scope that the request requests access to.
:param state: The state parameter of the request.
:param errorInFragment: Whether or not the error should be send in the query or fragment.
:return: The result of the request.
"""
dataKey = 'request' + str(uuid4())
self._persistentStorage.put(dataKey, {
'response_type': grantType,
'redirect_uri': redirectUri,
'client_id': client.id,
'scope': scope,
'state': state
}, expireTime=int(time.time()) + self.requestDataLifetime)
try:
result = self.onAuthenticate(request, client, grantType, scope,
redirectUri, state, dataKey)
if isinstance(result, OAuth2Error):
warnings.warn('Returning an error from onAuthenticate is deprecated, '
'raise it instead.', DeprecationWarning)
raise result
return result
except AuthorizationError as error:
return error.generate(request, redirectUri, errorInFragment)
except OAuth2Error as error:
message = error.name
if error.description is not None:
message += ': ' + error.description
warnings.warn('Only AuthorizationErrors are expected to occur during authorization, '
'other errors will get converted to a ServerError.', RuntimeWarning)
return ServerError(state, message).generate(request, redirectUri, errorInFragment)
except Exception as error: # pylint: disable=broad-except
logging.getLogger('txOauth2').error(
'Caught exception in onAuthenticate: %s', str(error), exc_info=True)
return ServerError(state, message=str(error)).generate(
request, redirectUri, errorInFragment)