Hide keyboard shortcuts

Hot-keys on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

1# Copyright (C) 2018 Bloomberg LP 

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 

16from collections import namedtuple, OrderedDict 

17from datetime import datetime 

18from enum import Enum 

19import functools 

20import logging 

21 

22import grpc 

23 

24from buildgrid._exceptions import InvalidArgumentError 

25from buildgrid.settings import AUTH_CACHE_SIZE 

26 

27 

28try: 

29 import jwt 

30except ImportError: 

31 HAVE_JWT = False 

32else: 

33 HAVE_JWT = True 

34 

35 

36class AuthMetadataMethod(Enum): 

37 # No authentication: 

38 NONE = 'none' 

39 # JWT based authentication: 

40 JWT = 'jwt' 

41 

42 

43class AuthMetadataAlgorithm(Enum): 

44 # No encryption involved: 

45 UNSPECIFIED = 'unspecified' 

46 # JWT related algorithms: 

47 JWT_ES256 = 'es256' # ECDSA signature algorithm using SHA-256 hash algorithm 

48 JWT_ES384 = 'es384' # ECDSA signature algorithm using SHA-384 hash algorithm 

49 JWT_ES512 = 'es512' # ECDSA signature algorithm using SHA-512 hash algorithm 

50 JWT_HS256 = 'hs256' # HMAC using SHA-256 hash algorithm 

51 JWT_HS384 = 'hs384' # HMAC using SHA-384 hash algorithm 

52 JWT_HS512 = 'hs512' # HMAC using SHA-512 hash algorithm 

53 JWT_PS256 = 'ps256' # RSASSA-PSS using SHA-256 and MGF1 padding with SHA-256 

54 JWT_PS384 = 'ps384' # RSASSA-PSS signature using SHA-384 and MGF1 padding with SHA-384 

55 JWT_PS512 = 'ps512' # RSASSA-PSS signature using SHA-512 and MGF1 padding with SHA-512 

56 JWT_RS256 = 'rs256' # RSASSA-PKCS1-v1_5 signature algorithm using SHA-256 hash algorithm 

57 JWT_RS384 = 'rs384' # RSASSA-PKCS1-v1_5 signature algorithm using SHA-384 hash algorithm 

58 JWT_RS512 = 'rs512' # RSASSA-PKCS1-v1_5 signature algorithm using SHA-512 hash algorithm 

59 

60 

61class AuthContext: 

62 

63 interceptor = None 

64 

65 

66class _InvalidTokenError(Exception): 

67 pass 

68 

69 

70class _ExpiredTokenError(Exception): 

71 pass 

72 

73 

74class _UnboundedTokenError(Exception): 

75 pass 

76 

77 

78def authorize(auth_context): 

79 """RPC method decorator for authorization validations. 

80 

81 This decorator is design to be used together with an :class:`AuthContext` 

82 authorization context holder:: 

83 

84 @authorize(AuthContext) 

85 def Execute(self, request, context): 

86 

87 By default, any request is accepted. Authorization validation can be 

88 activated by setting up a :class:`grpc.ServerInterceptor`:: 

89 

90 AuthContext.interceptor = AuthMetadataServerInterceptor() 

91 

92 Args: 

93 auth_context(AuthContext): Authorization context holder. 

94 """ 

95 def __authorize_decorator(behavior): 

96 """RPC authorization method decorator.""" 

97 _HandlerCallDetails = namedtuple( 

98 '_HandlerCallDetails', ('invocation_metadata', 'method',)) 

99 

100 @functools.wraps(behavior) 

101 def __authorize_wrapper(self, request, context): 

102 """RPC authorization method wrapper.""" 

103 if auth_context.interceptor is None: 

104 return behavior(self, request, context) 

105 

106 authorized = False 

107 

108 def __continuator(handler_call_details): 

109 nonlocal authorized 

110 authorized = True 

111 

112 details = _HandlerCallDetails(context.invocation_metadata(), 

113 behavior.__name__) 

114 

115 auth_context.interceptor.intercept_service(__continuator, details) 

116 

117 if authorized: 

118 return behavior(self, request, context) 

119 

120 context.abort(grpc.StatusCode.UNAUTHENTICATED, 

121 "No valid authorization or authentication provided") 

122 

123 return None 

124 

125 return __authorize_wrapper 

126 

127 return __authorize_decorator 

128 

129 

130class AuthMetadataServerInterceptor(grpc.ServerInterceptor): 

131 

132 __auth_errors = { 

133 'missing-bearer': "Missing authentication header field", 

134 'invalid-bearer': "Invalid authentication header field", 

135 'invalid-token': "Invalid authentication token", 

136 'expired-token': "Expired authentication token", 

137 'unbounded-token': "Unbounded authentication token", 

138 } 

139 

140 def __init__(self, method, secret=None, algorithm=AuthMetadataAlgorithm.UNSPECIFIED): 

141 """Initialises a new :class:`AuthMetadataServerInterceptor`. 

142 

143 Args: 

144 method (AuthMetadataMethod): Type of authorization method. 

145 secret (str): The secret or key to be used for validating request, 

146 depending on `method`. Defaults to ``None``. 

147 algorithm (AuthMetadataAlgorithm): The crytographic algorithm used 

148 to encode `secret`. Defaults to ``UNSPECIFIED``. 

149 

150 Raises: 

151 InvalidArgumentError: If `method` is not supported or if `algorithm` 

152 is not supported for the given `method`. 

153 """ 

154 self.__logger = logging.getLogger(__name__) 

155 

156 self.__bearer_cache = OrderedDict() 

157 self.__terminators = {} 

158 self.__validator = None 

159 self.__secret = secret 

160 

161 self._method = method 

162 self._algorithm = algorithm 

163 

164 if self._method == AuthMetadataMethod.JWT: 

165 self._check_jwt_support(self._algorithm) 

166 self.__validator = self._validate_jwt_token 

167 

168 for code, message in self.__auth_errors.items(): 

169 self.__terminators[code] = _unary_unary_rpc_terminator(message) 

170 

171 # --- Public API --- 

172 

173 @property 

174 def method(self): 

175 return self._method 

176 

177 @property 

178 def algorithm(self): 

179 return self._algorithm 

180 

181 def intercept_service(self, continuation, handler_call_details): 

182 try: 

183 # Reject requests not carrying a token: 

184 bearer = dict(handler_call_details.invocation_metadata)['authorization'] 

185 

186 except KeyError: 

187 self.__logger.error(f"Rejecting '{handler_call_details.method.split('/')[-1]}' " 

188 f"request: {self.__auth_errors['missing-bearer']}") 

189 return self.__terminators['missing-bearer'] 

190 

191 # Reject requests with malformated bearer: 

192 if not bearer.startswith('Bearer '): 

193 self.__logger.error(f"Rejecting '{handler_call_details.method.split('/')[-1]}' " 

194 f"request: {self.__auth_errors['invalid-bearer']}") 

195 return self.__terminators['invalid-bearer'] 

196 

197 try: 

198 # Hit the cache for already validated token: 

199 expiration_time = self.__bearer_cache[bearer] 

200 

201 # Accept request if cached token hasn't expired yet: 

202 if expiration_time >= datetime.utcnow(): 

203 return continuation(handler_call_details) # Accepted 

204 

205 else: 

206 del self.__bearer_cache[bearer] 

207 

208 # Cached token has expired, reject the request: 

209 self.__logger.error(f"Rejecting '{handler_call_details.method.split('/')[-1]}' " 

210 f"request: {self.__auth_errors['expired-token']}") 

211 # TODO: Use grpc.Status.details to inform the client of the expiry? 

212 return self.__terminators['expired-token'] 

213 

214 except KeyError: 

215 pass 

216 

217 assert self.__validator is not None 

218 

219 try: 

220 # Decode and validate the new token: 

221 expiration_time = self.__validator(bearer[7:]) 

222 

223 except _InvalidTokenError as e: 

224 self.__logger.error(f"Rejecting '{handler_call_details.method.split('/')[-1]}' " 

225 f"request: {self.__auth_errors['invalid-token']}; {str(e)}") 

226 return self.__terminators['invalid-token'] 

227 

228 except _ExpiredTokenError as e: 

229 self.__logger.error(f"Rejecting '{handler_call_details.method.split('/')[-1]}' " 

230 f"request: {self.__auth_errors['expired-token']}; {str(e)}") 

231 return self.__terminators['expired-token'] 

232 

233 except _UnboundedTokenError as e: 

234 self.__logger.error(f"Rejecting '{handler_call_details.method.split('/')[-1]}' " 

235 f"request: {self.__auth_errors['unbounded-token']}; {str(e)}") 

236 return self.__terminators['unbounded-token'] 

237 

238 # Cache the validated token and store expiration time: 

239 self.__bearer_cache[bearer] = expiration_time 

240 if len(self.__bearer_cache) > AUTH_CACHE_SIZE: 

241 self.__bearer_cache.popitem(last=False) 

242 

243 return continuation(handler_call_details) # Accepted 

244 

245 # --- Private API: JWT --- 

246 

247 def _check_jwt_support(self, algorithm=AuthMetadataAlgorithm.UNSPECIFIED): 

248 """Ensures JWT and possible dependencies are available.""" 

249 if not HAVE_JWT: 

250 raise InvalidArgumentError("JWT authorization method requires PyJWT") 

251 

252 try: 

253 if algorithm != AuthMetadataAlgorithm.UNSPECIFIED: 

254 jwt.register_algorithm(algorithm.value.upper(), None) 

255 

256 except TypeError: 

257 raise InvalidArgumentError( 

258 f"Algorithm not supported for JWT decoding: [{self._algorithm}]") 

259 

260 except ValueError: 

261 pass 

262 

263 def _validate_jwt_token(self, token): 

264 """Validates a JWT token and returns its expiry date.""" 

265 if self._algorithm != AuthMetadataAlgorithm.UNSPECIFIED: 

266 algorithms = [self._algorithm.value.upper()] 

267 else: 

268 algorithms = None 

269 

270 try: 

271 payload = jwt.decode(token, self.__secret, algorithms=algorithms) 

272 

273 except jwt.exceptions.ExpiredSignatureError as e: 

274 raise _ExpiredTokenError(e) 

275 

276 except jwt.exceptions.InvalidTokenError as e: 

277 raise _InvalidTokenError(e) 

278 

279 if 'exp' not in payload or not isinstance(payload['exp'], int): 

280 raise _UnboundedTokenError("Missing 'exp' in payload") 

281 

282 return datetime.utcfromtimestamp(payload['exp']) 

283 

284 

285def _unary_unary_rpc_terminator(details): 

286 

287 def terminate(ignored_request, context): 

288 context.abort(grpc.StatusCode.UNAUTHENTICATED, details) 

289 

290 return grpc.unary_unary_rpc_method_handler(terminate)