forked from mozilla-conduit/lando-api
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathauth.py
604 lines (498 loc) · 20.7 KB
/
auth.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
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
import functools
import hashlib
import hmac
import logging
import os
import requests
from connexion import ProblemException, request
from flask import current_app, g
from jose import jwt
from landoapi.cache import cache
from landoapi.mocks.auth import MockAuth0
from landoapi.systems import Subsystem
logger = logging.getLogger(__name__)
ALGORITHMS = ["RS256"]
mock_auth0 = MockAuth0()
def get_auth_token():
auth = request.headers.get("Authorization")
if auth is None:
raise ProblemException(
401,
"Authorization Header Required",
"Authorization header is required and was not provided",
type="https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/401",
)
if not auth:
raise ProblemException(
401,
"Authorization Header Invalid",
"Authorization header must not be empty",
type="https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/401",
)
parts = auth.split()
n_parts = len(parts)
if parts[0].lower() != "bearer":
raise ProblemException(
401,
"Authorization Header Invalid",
"Authorization header must begin with Bearer",
type="https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/401",
)
if n_parts == 1:
raise ProblemException(
401,
"Authorization Header Invalid",
"Token not found in Authorization header",
type="https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/401",
)
if n_parts > 2:
raise ProblemException(
401,
"Authorization Header Invalid",
"Authorization header must be a Bearer token",
type="https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/401",
)
assert n_parts == 2
return parts[1]
def get_rsa_key(jwks, token):
"""Return the rsa key from jwks for the provided token.
`None` is returned if the key is not found.
"""
unverified_header = jwt.get_unverified_header(token)
for key in jwks["keys"]:
if key["kid"] == unverified_header["kid"]:
return {i: key[i] for i in ("kty", "kid", "use", "n", "e")}
def jwks_cache_key(url):
return "auth0_jwks_{}".format(hashlib.sha256(url.encode("utf-8")).hexdigest())
def get_jwks():
"""Return the auth0 jwks."""
jwks_url = "https://{oidc_domain}/.well-known/jwks.json".format(
oidc_domain=current_app.config["OIDC_DOMAIN"]
)
cache_key = jwks_cache_key(jwks_url)
jwks = None
with cache.suppress_failure():
jwks = cache.get(cache_key)
if jwks is not None:
return jwks
try:
jwks_response = requests.get(jwks_url)
except requests.exceptions.Timeout:
raise ProblemException(
500,
"Auth0 Timeout",
"Authentication server timed out, try again later",
type="https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500",
)
except requests.exceptions.ConnectionError:
raise ProblemException(
500,
"Auth0 Connection Problem",
"Can't connect to authentication server, try again later",
type="https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500",
)
except requests.exceptions.HTTPError:
raise ProblemException(
500,
"Auth0 Response Error",
"Authentication server response was invalid, try again later",
type="https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500",
)
except requests.exceptions.RequestException:
raise ProblemException(
500,
"Auth0 Error",
"Problem communicating with Auth0, try again later",
type="https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500",
)
try:
jwks = jwks_response.json()
except ValueError:
logger.error("Auth0 jwks response was not valid json")
raise ProblemException(
500,
"Auth0 Response Error",
"Authentication server response was invalid, try again later",
type="https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500",
)
with cache.suppress_failure():
cache.set(cache_key, jwks, timeout=60)
return jwks
def userinfo_cache_key(access_token, user_sub):
return "auth0_userinfo_{user_sub}_{token_hash}".format(
user_sub=user_sub,
token_hash=hashlib.sha256(access_token.encode("utf-8")).hexdigest(),
)
def get_userinfo_url():
return "https://{}/userinfo".format(current_app.config["OIDC_DOMAIN"])
def fetch_auth0_userinfo(access_token):
"""Return userinfo response from auth0 endpoint."""
return requests.get(
get_userinfo_url(), headers={"Authorization": "Bearer {}".format(access_token)}
)
def get_auth0_userinfo(access_token, user_sub):
"""Return userinfo data from auth0."""
cache_key = userinfo_cache_key(access_token, user_sub)
userinfo = None
with cache.suppress_failure():
userinfo = cache.get(cache_key)
if userinfo is not None:
return userinfo
try:
resp = fetch_auth0_userinfo(access_token)
except requests.exceptions.Timeout:
raise ProblemException(
500,
"Auth0 Timeout",
"Authentication server timed out, try again later",
type="https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500",
)
except requests.exceptions.ConnectionError:
raise ProblemException(
500,
"Auth0 Connection Problem",
"Can't connect to authentication server, try again later",
type="https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500",
)
except requests.exceptions.HTTPError:
raise ProblemException(
500,
"Auth0 Response Error",
"Authentication server response was invalid, try again later",
type="https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500",
)
except requests.exceptions.RequestException:
raise ProblemException(
500,
"Auth0 Error",
"Problem communicating with Auth0, try again later",
type="https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500",
)
if resp.status_code == 429:
# We should hopefully never hit this in production, so log an error
# to make sure we investigate.
logger.error("Auth0 Rate limit hit when requesting userinfo")
raise ProblemException(
429,
"Auth0 Rate Limit",
"Authentication rate limit hit, please wait before retrying",
type="https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429",
)
if resp.status_code == 401:
raise ProblemException(
401,
"Auth0 Userinfo Unauthorized",
"Unauthorized to access userinfo, check openid scope",
type="https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/401",
)
if resp.status_code != 200:
raise ProblemException(
403,
"Authorization Failure",
"You do not have permission to access this resource",
type="https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/403",
)
try:
userinfo = resp.json()
except ValueError:
logger.error("Auth0 userinfo response was not valid json")
raise ProblemException(
500,
"Auth0 Response Error",
"Authentication server response was invalid, try again later",
type="https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500",
)
with cache.suppress_failure():
cache.set(cache_key, userinfo, timeout=60)
return userinfo
class A0User:
"""Represents a Mozilla auth0 user.
It is assumed that the access_token provided to __init__ has
already been verified properly.
"""
_GROUPS_CLAIM_KEY = "https://sso.mozilla.com/claim/groups"
def __init__(self, access_token, userinfo):
self.access_token = access_token
# We should discourage touching userinfo directly
# outside of this class to keep information about
# its structure contained, hopefully making it
# easier to react to changes.
self._userinfo = userinfo
self._groups = None
self._email = None
@property
def groups(self):
if self._groups is None:
groups = self._userinfo.get(self._GROUPS_CLAIM_KEY, [])
groups = [groups] if isinstance(groups, str) else groups
self._groups = set(groups)
return self._groups
@property
def email(self):
"""The Mozilla LDAP email address of the Auth0 user.
Returns a Mozilla LDAP address or None if the userinfo has no email set
or if the email is not verified.
"""
if self._email is None:
email = self._userinfo.get("email")
if email and self._userinfo.get("email_verified"):
self._email = email
return self._email
def is_in_groups(self, *args):
"""Return True if the user is in all provided groups."""
return set(args).issubset(self.groups)
def _mock_userinfo_claims(userinfo):
"""Partially mocks Auth0 userinfo by only injecting ldap claims
Modifies the userinfo in place with either valid or invalid ldap claims
for landing based on the configured option in docker-compose.yml.
If not configured for claim injection, no changes are made to the userinfo.
"""
a0_mock_option = os.getenv("LOCALDEV_MOCK_AUTH0_USER")
if a0_mock_option == "inject_valid":
userinfo["https://sso.mozilla.com/claim/groups"] = [
"active_scm_level_3",
"all_scm_level_3",
"active_scm_level_2",
"all_scm_level_2",
"active_scm_level_1",
"all_scm_level_1",
]
elif a0_mock_option == "inject_invalid":
userinfo["https://sso.mozilla.com/claim/groups"] = ["invalid_group"]
class require_auth0:
"""Decorator which requires an Auth0 access_token with the provided scopes.
Using this decorator on a connexion handler will require an oidc
access_token be sent as a bearer token in the `Authorization` header
of the request. If the header is not provided or is invalid an HTTP 401
response will be sent.
Scopes provided in the `scopes` argument, as an iterable, will be checked
for presence in the access_token. If any of the provided scopes are
missing an HTTP 401 response will be sent.
Decorated functions may assume the Authorization header is present
containing a Bearer token, flask.g.access_token contains the verified
access_token, and flask.g.access_token_payload contains the decoded jwt
payload.
Optionally, if `userinfo` is set to `True` the verified access_token will
be used to request userinfo from auth0. This request must succeed and the
returned userinfo will be used to construct an A0User object, which is
accessed using flask.g.auth0_user.
"""
def __init__(self, scopes=None, userinfo=False):
assert scopes is not None, (
"`scopes` must be provided. If this endpoint truly does not "
"require any scopes, explicilty pass an empty tuple `()`"
)
self.userinfo = userinfo
self.scopes = scopes
def _require_scopes(self, f):
@functools.wraps(f)
def wrapped(*args, **kwargs):
token_scopes = set(g.access_token_payload.get("scope", "").split())
if [scope for scope in self.scopes if scope not in token_scopes]:
raise ProblemException(
401,
"Missing Scopes",
"Token is missing required scopes for this action",
type="https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/401",
)
return f(*args, **kwargs)
return wrapped
def _require_userinfo(self, f):
"""Decorator which fetches userinfo using an Auth0 access_token.
This decorator assumes that any caller of the wrapped function has
already verified the Auth0 access_token and it is present at
`g.access_token`.
"""
@functools.wraps(f)
def wrapped(*args, **kwargs):
# See docker-compose.yml for details on auth0 mock options.
a0_mock_option = os.getenv("LOCALDEV_MOCK_AUTH0_USER")
if os.getenv("ENV") == "localdev" and a0_mock_option == "default":
g.auth0_user = A0User(g.access_token, mock_auth0.userinfo)
return f(*args, **kwargs)
userinfo = get_auth0_userinfo(g.access_token, g.access_token_payload["sub"])
if os.getenv("ENV") == "localdev":
_mock_userinfo_claims(userinfo)
g.auth0_user = A0User(g.access_token, userinfo)
return f(*args, **kwargs)
return wrapped
def _require_access_token(self, f):
"""Decorator which verifies Auth0 access_token."""
@functools.wraps(f)
def wrapped(*args, **kwargs):
# See docker-compose.yml for details on auth0 mock options.
a0_mock_option = os.getenv("LOCALDEV_MOCK_AUTH0_USER")
if os.getenv("ENV") == "localdev" and a0_mock_option == "default":
g.access_token = mock_auth0.access_token
g.access_token_payload = mock_auth0.access_token_payload
return f(*args, **kwargs)
token = get_auth_token()
jwks = get_jwks()
try:
key = get_rsa_key(jwks, token)
except KeyError:
logger.error("Auth0 jwks response structure unexpected")
raise ProblemException(
500,
"Auth0 Response Error",
"Authentication server response was invalid, try again later",
type="https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500",
)
except jwt.JWTError:
raise ProblemException(
400,
"Invalid Authorization",
"Unable to parse Authorization token",
type="https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/400",
)
if key is None:
raise ProblemException(
400,
"Authorization Header Invalid",
"Appropriate key for Authorization header could not be found",
type="https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/401",
)
issuer = "https://{oidc_domain}/".format(
oidc_domain=current_app.config["OIDC_DOMAIN"]
)
try:
payload = jwt.decode(
token,
key,
algorithms=ALGORITHMS,
audience=current_app.config["OIDC_IDENTIFIER"],
issuer=issuer,
)
except jwt.ExpiredSignatureError:
raise ProblemException(
401,
"Token Expired",
"Appropriate token is expired. Please log out and back in.",
type="https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/401",
)
except jwt.JWTClaimsError:
raise ProblemException(
401,
"Invalid Claims",
"Invalid Authorization claims in token, please check "
"the audience and issuer",
type="https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/401",
)
except Exception:
raise ProblemException(
400,
"Invalid Authorization",
"Unable to parse Authorization token",
type="https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/401",
)
# At this point the access_token has been validated and payload
# contains the parsed token.
g.access_token = token
g.access_token_payload = payload
return f(*args, **kwargs)
return wrapped
def __call__(self, f):
if self.userinfo:
f = self._require_userinfo(f)
if self.scopes:
f = self._require_scopes(f)
return self._require_access_token(f)
def _not_authorized_problem_exception():
return ProblemException(
403,
"Not Authorized",
"You're not authorized to proceed.",
type="https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/403",
)
def require_transplant_authentication(f):
"""Decorator which authenticates requests to only allow Transplant."""
@functools.wraps(f)
def wrapped(*args, **kwargs):
if current_app.config["PINGBACK_ENABLED"] != "y":
try:
# First try to log any arguments that were going to
# the endpoint (could fail json serialization).
logger.warning(
"Attempt to access a disabled pingback",
extra={
"arguments": args,
"kw_arguments": kwargs,
"remote_addr": request.remote_addr,
},
)
except TypeError:
# Reattempt logging without the arguments.
logger.warning(
"Attempt to access a disabled pingback",
extra={"remote_addr": request.remote_addr},
)
raise _not_authorized_problem_exception()
passed_key = request.headers.get("API-Key")
if not passed_key:
try:
# First try to log any arguments that were going to
# the endpoint (could fail json serialization).
logger.critical(
"Attempt to pingback without API-Key header",
extra={
"arguments": args,
"kw_arguments": kwargs,
"remote_addr": request.remote_addr,
},
)
except TypeError:
# Reattempt logging without the arguments.
logger.critical(
"Attempt to pingback without API-Key header",
extra={"remote_addr": request.remote_addr},
)
raise _not_authorized_problem_exception()
required_key = current_app.config["TRANSPLANT_API_KEY"]
if not hmac.compare_digest(passed_key, required_key):
try:
# First try to log any arguments that were going to
# the endpoint (could fail json serialization).
logger.critical(
"Attempt to pingback with incorrect API-Key",
extra={
"arguments": args,
"kw_arguments": kwargs,
"remote_addr": request.remote_addr,
},
)
except TypeError:
# Reattempt logging without the arguments.
logger.critical(
"Attempt to pingback with incorrect API-Key",
extra={"remote_addr": request.remote_addr},
)
raise _not_authorized_problem_exception()
return f(*args, **kwargs)
return wrapped
class Auth0Subsystem(Subsystem):
name = "auth0"
def ready(self):
domain = self.flask_app.config.get("OIDC_DOMAIN")
identifier = self.flask_app.config.get("OIDC_IDENTIFIER")
# OIDC_DOMAIN should be the domain assigned to the auth0 organization.
# Leaving this unset could cause an application security problem. We
# require it to be set.
#
# OIDC_IDENTIFIER should be the custom api identifier defined in auth0.
# Leaving this unset could cause an application security problem. We
# require it to be set.
if not domain:
return "OIDC_DOMAIN isn't set."
if not identifier:
return "OIDC_IDENTIFIER isn't set."
return True
def healthy(self):
try:
get_jwks()
except ProblemException as exc:
return "Exception when requesting jwks: {}".format(exc.detail)
return True
auth0_subsystem = Auth0Subsystem()