OLD | NEW |
(Empty) | |
| 1 # Copyright 2016 Google Inc. All Rights Reserved. |
| 2 # |
| 3 # Licensed under the Apache License, Version 2.0 (the "License"); |
| 4 # you may not use this file except in compliance with the License. |
| 5 # You may obtain a copy of the License at |
| 6 # |
| 7 # http://www.apache.org/licenses/LICENSE-2.0 |
| 8 # |
| 9 # Unless required by applicable law or agreed to in writing, software |
| 10 # distributed under the License is distributed on an "AS IS" BASIS, |
| 11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 12 # See the License for the specific language governing permissions and |
| 13 # limitations under the License. |
| 14 |
| 15 """Decodes and verifies the signature of auth tokens.""" |
| 16 |
| 17 import datetime |
| 18 import jwkest |
| 19 import time |
| 20 |
| 21 from dogpile import cache |
| 22 from jwkest import jws |
| 23 from jwkest import jwt |
| 24 |
| 25 from google.api.auth import suppliers |
| 26 |
| 27 |
| 28 class Authenticator(object): # pylint: disable=too-few-public-methods |
| 29 """Decodes and verifies the signature of auth tokens.""" |
| 30 |
| 31 def __init__(self, issuers_to_provider_ids, jwks_supplier, cache_capacity=200)
: |
| 32 """Construct an instance of AuthTokenDecoder. |
| 33 |
| 34 Args: |
| 35 issuers_to_provider_ids: a dictionary mapping from issuers to provider |
| 36 IDs defined in the service configuration. |
| 37 jwks_supplier: an instance of JwksSupplier that supplies JWKS based on |
| 38 issuer. |
| 39 cache_capacity: the cache_capacity with default value of 200. |
| 40 """ |
| 41 self._issuers_to_provider_ids = issuers_to_provider_ids |
| 42 self._jwks_supplier = jwks_supplier |
| 43 |
| 44 arguments = {"capacity": cache_capacity} |
| 45 expiration_time = datetime.timedelta(minutes=5) |
| 46 self._cache = cache.make_region().configure("lru_cache", |
| 47 arguments=arguments, |
| 48 expiration_time=expiration_time) |
| 49 |
| 50 def authenticate(self, auth_token, auth_info, service_name): |
| 51 """Authenticates the current auth token. |
| 52 |
| 53 Args: |
| 54 auth_token: the auth token. |
| 55 auth_info: the auth configurations of the API method being called. |
| 56 service_name: the name of this service. |
| 57 |
| 58 Returns: |
| 59 A constructed UserInfo object representing the identity of the caller. |
| 60 |
| 61 Raises: |
| 62 UnauthenticatedException: When |
| 63 * the issuer is not allowed; |
| 64 * the audiences are not allowed; |
| 65 * the auth token has already expired. |
| 66 """ |
| 67 try: |
| 68 jwt_claims = self.get_jwt_claims(auth_token) |
| 69 except Exception as error: |
| 70 raise suppliers.UnauthenticatedException("Cannot decode the auth token", |
| 71 error) |
| 72 _check_jwt_claims(jwt_claims) |
| 73 |
| 74 user_info = UserInfo(jwt_claims) |
| 75 |
| 76 issuer = user_info.issuer |
| 77 if issuer not in self._issuers_to_provider_ids: |
| 78 raise suppliers.UnauthenticatedException("Unknown issuer: " + issuer) |
| 79 provider_id = self._issuers_to_provider_ids[issuer] |
| 80 |
| 81 if not auth_info.is_provider_allowed(provider_id): |
| 82 raise suppliers.UnauthenticatedException("The requested method does not " |
| 83 "allow provider id: " + provider_
id) |
| 84 |
| 85 # Check the audiences decoded from the auth token. The auth token is |
| 86 # allowed when 1) an audience is equal to the service name, or 2) at least |
| 87 # one audience is allowed in the method configuration. |
| 88 audiences = user_info.audiences |
| 89 has_service_name = service_name in audiences |
| 90 |
| 91 allowed_audiences = auth_info.get_allowed_audiences(provider_id) |
| 92 intersected_audiences = set(allowed_audiences).intersection(audiences) |
| 93 if not has_service_name and not intersected_audiences: |
| 94 raise suppliers.UnauthenticatedException("Audiences not allowed") |
| 95 |
| 96 return user_info |
| 97 |
| 98 def get_jwt_claims(self, auth_token): |
| 99 """Decodes the auth_token into JWT claims represented as a JSON object. |
| 100 |
| 101 This method first tries to look up the cache and returns the result |
| 102 immediately in case of a cache hit. When cache misses, the method tries to |
| 103 decode the given auth token, verify its signature, and check the existence |
| 104 of required JWT claims. When successful, the decoded JWT claims are loaded |
| 105 into the cache and then returned. |
| 106 |
| 107 Args: |
| 108 auth_token: the auth token to be decoded. |
| 109 |
| 110 Returns: |
| 111 The decoded JWT claims. |
| 112 |
| 113 Raises: |
| 114 UnauthenticatedException: When the signature verification fails, or when |
| 115 required claims are missing. |
| 116 """ |
| 117 |
| 118 def _decode_and_verify(): |
| 119 jwt_claims = jwt.JWT().unpack(auth_token).payload() |
| 120 _verify_required_claims_exist(jwt_claims) |
| 121 |
| 122 issuer = jwt_claims["iss"] |
| 123 keys = self._jwks_supplier.supply(issuer) |
| 124 try: |
| 125 return jws.JWS().verify_compact(auth_token, keys) |
| 126 except (jwkest.BadSignature, jws.NoSuitableSigningKeys, |
| 127 jws.SignerAlgError) as exception: |
| 128 raise suppliers.UnauthenticatedException("Signature verification failed"
, |
| 129 exception) |
| 130 |
| 131 return self._cache.get_or_create(auth_token, _decode_and_verify) |
| 132 |
| 133 |
| 134 class UserInfo(object): |
| 135 """An object that holds the authentication results.""" |
| 136 |
| 137 def __init__(self, jwt_claims): |
| 138 audiences = jwt_claims["aud"] |
| 139 if isinstance(audiences, basestring): |
| 140 audiences = [audiences] |
| 141 self._audiences = audiences |
| 142 |
| 143 # email is not required |
| 144 self._email = jwt_claims["email"] if "email" in jwt_claims else None |
| 145 self._subject_id = jwt_claims["sub"] |
| 146 self._issuer = jwt_claims["iss"] |
| 147 |
| 148 @property |
| 149 def audiences(self): |
| 150 return self._audiences |
| 151 |
| 152 @property |
| 153 def email(self): |
| 154 return self._email |
| 155 |
| 156 @property |
| 157 def subject_id(self): |
| 158 return self._subject_id |
| 159 |
| 160 @property |
| 161 def issuer(self): |
| 162 return self._issuer |
| 163 |
| 164 |
| 165 def _check_jwt_claims(jwt_claims): |
| 166 """Checks whether the JWT claims should be accepted. |
| 167 |
| 168 Specifically, this method checks the "exp" claim and the "nbf" claim (if |
| 169 present), and raises UnauthenticatedException if 1) the current time is |
| 170 before the time identified by the "nbf" claim, or 2) the current time is |
| 171 equal to or after the time identified by the "exp" claim. |
| 172 |
| 173 Args: |
| 174 jwt_claims: the JWT claims whose expiratio to be checked. |
| 175 |
| 176 Raises: |
| 177 UnauthenticatedException: When the "exp" claim is malformed or the JWT has |
| 178 already expired. |
| 179 """ |
| 180 current_time = time.time() |
| 181 |
| 182 expiration = jwt_claims["exp"] |
| 183 if not isinstance(expiration, (int, long)): |
| 184 raise suppliers.UnauthenticatedException('Malformed claim: "exp" must be an
integer') |
| 185 if current_time >= expiration: |
| 186 raise suppliers.UnauthenticatedException("The auth token has already expired
") |
| 187 |
| 188 if "nbf" not in jwt_claims: |
| 189 return |
| 190 |
| 191 not_before_time = jwt_claims["nbf"] |
| 192 if not isinstance(not_before_time, (int, long)): |
| 193 raise suppliers.UnauthenticatedException('Malformed claim: "nbf" must be an
integer') |
| 194 if current_time < not_before_time: |
| 195 raise suppliers.UnauthenticatedException('Current time is less than the "nbf
" time') |
| 196 |
| 197 def _verify_required_claims_exist(jwt_claims): |
| 198 """Verifies that the required claims exist. |
| 199 |
| 200 Args: |
| 201 jwt_claims: the JWT claims to be verified. |
| 202 |
| 203 Raises: |
| 204 UnauthenticatedException: if some claim doesn't exist. |
| 205 """ |
| 206 for claim_name in ["aud", "exp", "iss", "sub"]: |
| 207 if claim_name not in jwt_claims: |
| 208 raise suppliers.UnauthenticatedException('Missing "%s" claim' % claim_name
) |
OLD | NEW |