18986a3ab4d7e64a00280519a495fb37d510ea40
[osm/NBI.git] / osm_nbi / auth.py
1 # -*- coding: utf-8 -*-
2
3 # Copyright 2018 Whitestack, LLC
4 # Copyright 2018 Telefonica S.A.
5 #
6 # Licensed under the Apache License, Version 2.0 (the "License"); you may
7 # not use this file except in compliance with the License. You may obtain
8 # a copy of the License at
9 #
10 # http://www.apache.org/licenses/LICENSE-2.0
11 #
12 # Unless required by applicable law or agreed to in writing, software
13 # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
14 # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
15 # License for the specific language governing permissions and limitations
16 # under the License.
17 #
18 # For those usages not covered by the Apache License, Version 2.0 please
19 # contact: esousa@whitestack.com or alfonso.tiernosepulveda@telefonica.com
20 ##
21
22
23 """
24 Authenticator is responsible for authenticating the users,
25 create the tokens unscoped and scoped, retrieve the role
26 list inside the projects that they are inserted
27 """
28 from os import path
29
30 __author__ = "Eduardo Sousa <esousa@whitestack.com>; Alfonso Tierno <alfonso.tiernosepulveda@telefonica.com>"
31 __date__ = "$27-jul-2018 23:59:59$"
32
33 import cherrypy
34 import logging
35 import yaml
36 from base64 import standard_b64decode
37 from copy import deepcopy
38 from functools import reduce
39 from hashlib import sha256
40 from http import HTTPStatus
41 from random import choice as random_choice
42 from time import time
43 from uuid import uuid4
44
45 from authconn import AuthException
46 from authconn_keystone import AuthconnKeystone
47 from osm_common import dbmongo
48 from osm_common import dbmemory
49 from osm_common.dbbase import DbException
50
51
52 class Authenticator:
53 """
54 This class should hold all the mechanisms for User Authentication and
55 Authorization. Initially it should support Openstack Keystone as a
56 backend through a plugin model where more backends can be added and a
57 RBAC model to manage permissions on operations.
58 """
59
60 periodin_db_pruning = 60 * 30 # for the internal backend only. every 30 minutes expired tokens will be pruned
61
62 def __init__(self):
63 """
64 Authenticator initializer. Setup the initial state of the object,
65 while it waits for the config dictionary and database initialization.
66 """
67 self.backend = None
68 self.config = None
69 self.db = None
70 self.tokens_cache = dict()
71 self.next_db_prune_time = 0 # time when next cleaning of expired tokens must be done
72 self.resources_to_operations_file = None
73 self.roles_to_operations_file = None
74 self.resources_to_operations_mapping = {}
75 self.operation_to_allowed_roles = {}
76 self.logger = logging.getLogger("nbi.authenticator")
77
78 def start(self, config):
79 """
80 Method to configure the Authenticator object. This method should be called
81 after object creation. It is responsible by initializing the selected backend,
82 as well as the initialization of the database connection.
83
84 :param config: dictionary containing the relevant parameters for this object.
85 """
86 self.config = config
87
88 try:
89 if not self.db:
90 if config["database"]["driver"] == "mongo":
91 self.db = dbmongo.DbMongo()
92 self.db.db_connect(config["database"])
93 elif config["database"]["driver"] == "memory":
94 self.db = dbmemory.DbMemory()
95 self.db.db_connect(config["database"])
96 else:
97 raise AuthException("Invalid configuration param '{}' at '[database]':'driver'"
98 .format(config["database"]["driver"]))
99 if not self.backend:
100 if config["authentication"]["backend"] == "keystone":
101 self.backend = AuthconnKeystone(self.config["authentication"])
102 elif config["authentication"]["backend"] == "internal":
103 self._internal_tokens_prune()
104 else:
105 raise AuthException("Unknown authentication backend: {}"
106 .format(config["authentication"]["backend"]))
107 if not self.resources_to_operations_file:
108 if "resources_to_operations" in config["rbac"]:
109 self.resources_to_operations_file = config["rbac"]["resources_to_operations"]
110 else:
111 for config_file in (__file__[:__file__.rfind("auth.py")] + "resources_to_operations.yml",
112 "./resources_to_operations.yml"):
113 if path.isfile(config_file):
114 self.resources_to_operations_file = config_file
115 break
116 if not self.resources_to_operations_file:
117 raise AuthException("Invalid permission configuration: resources_to_operations file missing")
118 if not self.roles_to_operations_file:
119 if "roles_to_operations" in config["rbac"]:
120 self.roles_to_operations_file = config["rbac"]["roles_to_operations"]
121 else:
122 for config_file in (__file__[:__file__.rfind("auth.py")] + "roles_to_operations.yml",
123 "./roles_to_operations.yml"):
124 if path.isfile(config_file):
125 self.roles_to_operations_file = config_file
126 break
127 if not self.roles_to_operations_file:
128 raise AuthException("Invalid permission configuration: roles_to_operations file missing")
129 except Exception as e:
130 raise AuthException(str(e))
131
132 def stop(self):
133 try:
134 if self.db:
135 self.db.db_disconnect()
136 except DbException as e:
137 raise AuthException(str(e), http_code=e.http_code)
138
139 def init_db(self, target_version='1.0'):
140 """
141 Check if the database has been initialized, with at least one user. If not, create the required tables
142 and insert the predefined mappings between roles and permissions.
143
144 :param target_version: schema version that should be present in the database.
145 :return: None if OK, exception if error or version is different.
146 """
147 # Always reads operation to resource mapping from file (this is static, no need to store it in MongoDB)
148 # Operations encoding: "<METHOD> <URL>"
149 # Note: it is faster to rewrite the value than to check if it is already there or not
150 operations = []
151 with open(self.resources_to_operations_file, "r") as stream:
152 resources_to_operations_yaml = yaml.load(stream)
153
154 for resource, operation in resources_to_operations_yaml["resources_to_operations"].items():
155 operation_key = operation.replace(".", ":")
156 if operation_key not in operations:
157 operations.append(operation_key)
158 self.resources_to_operations_mapping[resource] = operation_key
159
160 records = self.db.get_list("roles_operations")
161
162 # Loading permissions to MongoDB. If there are permissions already in MongoDB, do nothing.
163 if len(records) == 0:
164 with open(self.roles_to_operations_file, "r") as stream:
165 roles_to_operations_yaml = yaml.load(stream)
166
167 roles = []
168 for role_with_operations in roles_to_operations_yaml["roles_to_operations"]:
169 # Verifying if role already exists. If it does, send warning to log and ignore it.
170 if role_with_operations["role"] not in roles:
171 roles.append(role_with_operations["role"])
172 else:
173 self.logger.warning("Duplicated role with name: {0}. Role definition is ignored."
174 .format(role_with_operations["role"]))
175 continue
176
177 role_ops = {}
178 root = None
179
180 if not role_with_operations["operations"]:
181 continue
182
183 for operation, is_allowed in role_with_operations["operations"].items():
184 if not isinstance(is_allowed, bool):
185 continue
186
187 if operation == ".":
188 root = is_allowed
189 continue
190
191 if len(operation) != 1 and operation[-1] == ".":
192 self.logger.warning("Invalid operation {0} terminated in '.'. "
193 "Operation will be discarded"
194 .format(operation))
195 continue
196
197 operation_key = operation.replace(".", ":")
198 if operation_key not in role_ops.keys():
199 role_ops[operation_key] = is_allowed
200 else:
201 self.logger.info("In role {0}, the operation {1} with the value {2} was discarded due to "
202 "repetition.".format(role_with_operations["role"], operation, is_allowed))
203
204 if not root:
205 root = False
206 self.logger.info("Root for role {0} not defined. Default value 'False' applied."
207 .format(role_with_operations["role"]))
208
209 now = time()
210 operation_to_roles_item = {
211 "_id": str(uuid4()),
212 "_admin": {
213 "created": now,
214 "modified": now,
215 },
216 "role": role_with_operations["role"],
217 "root": root
218 }
219
220 for operation, value in role_ops.items():
221 operation_to_roles_item[operation] = value
222
223 self.db.create("roles_operations", operation_to_roles_item)
224
225 permissions = {oper: [] for oper in operations}
226 records = self.db.get_list("roles_operations")
227
228 ignore_fields = ["_id", "_admin", "role", "root"]
229 roles = []
230 for record in records:
231
232 roles.append(record["role"])
233 record_permissions = {oper: record["root"] for oper in operations}
234 operations_joined = [(oper, value) for oper, value in record.items() if oper not in ignore_fields]
235 operations_joined.sort(key=lambda x: x[0].count(":"))
236
237 for oper in operations_joined:
238 match = list(filter(lambda x: x.find(oper[0]) == 0, record_permissions.keys()))
239
240 for m in match:
241 record_permissions[m] = oper[1]
242
243 allowed_operations = [k for k, v in record_permissions.items() if v is True]
244
245 for allowed_op in allowed_operations:
246 permissions[allowed_op].append(record["role"])
247
248 for oper, role_list in permissions.items():
249 self.operation_to_allowed_roles[oper] = role_list
250
251 if self.config["authentication"]["backend"] != "internal":
252 for role in roles:
253 if role == "anonymous":
254 continue
255 self.backend.create_role(role)
256
257 self.backend.assign_role_to_user("admin", "admin", "system_admin")
258
259 def authorize(self):
260 token = None
261 user_passwd64 = None
262 try:
263 # 1. Get token Authorization bearer
264 auth = cherrypy.request.headers.get("Authorization")
265 if auth:
266 auth_list = auth.split(" ")
267 if auth_list[0].lower() == "bearer":
268 token = auth_list[-1]
269 elif auth_list[0].lower() == "basic":
270 user_passwd64 = auth_list[-1]
271 if not token:
272 if cherrypy.session.get("Authorization"):
273 # 2. Try using session before request a new token. If not, basic authentication will generate
274 token = cherrypy.session.get("Authorization")
275 if token == "logout":
276 token = None # force Unauthorized response to insert user password again
277 elif user_passwd64 and cherrypy.request.config.get("auth.allow_basic_authentication"):
278 # 3. Get new token from user password
279 user = None
280 passwd = None
281 try:
282 user_passwd = standard_b64decode(user_passwd64).decode()
283 user, _, passwd = user_passwd.partition(":")
284 except Exception:
285 pass
286 outdata = self.new_token(None, {"username": user, "password": passwd})
287 token = outdata["id"]
288 cherrypy.session['Authorization'] = token
289 if self.config["authentication"]["backend"] == "internal":
290 return self._internal_authorize(token)
291 else:
292 if not token:
293 raise AuthException("Needed a token or Authorization http header",
294 http_code=HTTPStatus.UNAUTHORIZED)
295 try:
296 self.backend.validate_token(token)
297 self.check_permissions(self.tokens_cache[token], cherrypy.request.path_info,
298 cherrypy.request.method)
299 # TODO: check if this can be avoided. Backend may provide enough information
300 return deepcopy(self.tokens_cache[token])
301 except AuthException:
302 self.del_token(token)
303 raise
304 except AuthException as e:
305 if cherrypy.session.get('Authorization'):
306 del cherrypy.session['Authorization']
307 cherrypy.response.headers["WWW-Authenticate"] = 'Bearer realm="{}"'.format(e)
308 raise AuthException(str(e))
309
310 def new_token(self, session, indata, remote):
311 if self.config["authentication"]["backend"] == "internal":
312 return self._internal_new_token(session, indata, remote)
313 else:
314 if indata.get("username"):
315 token, projects = self.backend.authenticate_with_user_password(
316 indata.get("username"), indata.get("password"))
317 elif session:
318 token, projects = self.backend.authenticate_with_token(
319 session.get("id"), indata.get("project_id"))
320 else:
321 raise AuthException("Provide credentials: username/password or Authorization Bearer token",
322 http_code=HTTPStatus.UNAUTHORIZED)
323
324 if indata.get("project_id"):
325 project_id = indata.get("project_id")
326 if project_id not in projects:
327 raise AuthException("Project {} not allowed for this user".format(project_id),
328 http_code=HTTPStatus.UNAUTHORIZED)
329 else:
330 project_id = projects[0]
331
332 if not session:
333 token, projects = self.backend.authenticate_with_token(token, project_id)
334
335 if project_id == "admin":
336 session_admin = True
337 else:
338 session_admin = reduce(lambda x, y: x or (True if y == "admin" else False),
339 projects, False)
340
341 now = time()
342 new_session = {
343 "_id": token,
344 "id": token,
345 "issued_at": now,
346 "expires": now + 3600,
347 "project_id": project_id,
348 "username": indata.get("username") if not session else session.get("username"),
349 "remote_port": remote.port,
350 "admin": session_admin
351 }
352
353 if remote.name:
354 new_session["remote_host"] = remote.name
355 elif remote.ip:
356 new_session["remote_host"] = remote.ip
357
358 # TODO: check if this can be avoided. Backend may provide enough information
359 self.tokens_cache[token] = new_session
360
361 return deepcopy(new_session)
362
363 def get_token_list(self, session):
364 if self.config["authentication"]["backend"] == "internal":
365 return self._internal_get_token_list(session)
366 else:
367 # TODO: check if this can be avoided. Backend may provide enough information
368 return [deepcopy(token) for token in self.tokens_cache.values()
369 if token["username"] == session["username"]]
370
371 def get_token(self, session, token):
372 if self.config["authentication"]["backend"] == "internal":
373 return self._internal_get_token(session, token)
374 else:
375 # TODO: check if this can be avoided. Backend may provide enough information
376 token_value = self.tokens_cache.get(token)
377 if not token_value:
378 raise AuthException("token not found", http_code=HTTPStatus.NOT_FOUND)
379 if token_value["username"] != session["username"] and not session["admin"]:
380 raise AuthException("needed admin privileges", http_code=HTTPStatus.UNAUTHORIZED)
381 return token_value
382
383 def del_token(self, token):
384 if self.config["authentication"]["backend"] == "internal":
385 return self._internal_del_token(token)
386 else:
387 try:
388 self.backend.revoke_token(token)
389 del self.tokens_cache[token]
390 return "token '{}' deleted".format(token)
391 except KeyError:
392 raise AuthException("Token '{}' not found".format(token), http_code=HTTPStatus.NOT_FOUND)
393
394 def check_permissions(self, session, url, method):
395 self.logger.info("Session: {}".format(session))
396 self.logger.info("URL: {}".format(url))
397 self.logger.info("Method: {}".format(method))
398
399 key, parameters = self._normalize_url(url, method)
400
401 # TODO: Check if parameters might be useful for the decision
402
403 operation = self.resources_to_operations_mapping[key]
404 roles_required = self.operation_to_allowed_roles[operation]
405 roles_allowed = self.backend.get_role_list(session["id"])
406
407 if "anonymous" in roles_required:
408 return
409
410 for role in roles_allowed:
411 if role in roles_required:
412 return
413
414 raise AuthException("Access denied: lack of permissions.")
415
416 def _normalize_url(self, url, method):
417 # Removing query strings
418 normalized_url = url if '?' not in url else url[:url.find("?")]
419 normalized_url_splitted = normalized_url.split("/")
420 parameters = {}
421
422 filtered_keys = [key for key in self.resources_to_operations_mapping.keys()
423 if method in key.split()[0]]
424
425 for idx, path_part in enumerate(normalized_url_splitted):
426 tmp_keys = []
427 for tmp_key in filtered_keys:
428 splitted = tmp_key.split()[1].split("/")
429 if idx >= len(splitted):
430 continue
431 elif "<" in splitted[idx] and ">" in splitted[idx]:
432 if splitted[idx] == "<artifactPath>":
433 tmp_keys.append(tmp_key)
434 continue
435 elif idx == len(normalized_url_splitted) - 1 and \
436 len(normalized_url_splitted) != len(splitted):
437 continue
438 else:
439 tmp_keys.append(tmp_key)
440 elif splitted[idx] == path_part:
441 if idx == len(normalized_url_splitted) - 1 and \
442 len(normalized_url_splitted) != len(splitted):
443 continue
444 else:
445 tmp_keys.append(tmp_key)
446 filtered_keys = tmp_keys
447 if len(filtered_keys) == 1 and \
448 filtered_keys[0].split("/")[-1] == "<artifactPath>":
449 break
450
451 if len(filtered_keys) == 0:
452 raise AuthException("Cannot make an authorization decision. URL not found. URL: {0}".format(url))
453 elif len(filtered_keys) > 1:
454 raise AuthException("Cannot make an authorization decision. Multiple URLs found. URL: {0}".format(url))
455
456 filtered_key = filtered_keys[0]
457
458 for idx, path_part in enumerate(filtered_key.split()[1].split("/")):
459 if "<" in path_part and ">" in path_part:
460 if path_part == "<artifactPath>":
461 parameters[path_part[1:-1]] = "/".join(normalized_url_splitted[idx:])
462 else:
463 parameters[path_part[1:-1]] = normalized_url_splitted[idx]
464
465 return filtered_key, parameters
466
467 def _internal_authorize(self, token_id):
468 try:
469 if not token_id:
470 raise AuthException("Needed a token or Authorization http header", http_code=HTTPStatus.UNAUTHORIZED)
471 # try to get from cache first
472 now = time()
473 session = self.tokens_cache.get(token_id)
474 if session and session["expires"] < now:
475 del self.tokens_cache[token_id]
476 session = None
477 if session:
478 return session
479
480 # get from database if not in cache
481 session = self.db.get_one("tokens", {"_id": token_id})
482 if session["expires"] < now:
483 raise AuthException("Expired Token or Authorization http header", http_code=HTTPStatus.UNAUTHORIZED)
484 self.tokens_cache[token_id] = session
485 return session
486 except DbException as e:
487 if e.http_code == HTTPStatus.NOT_FOUND:
488 raise AuthException("Invalid Token or Authorization http header", http_code=HTTPStatus.UNAUTHORIZED)
489 else:
490 raise
491
492 except AuthException:
493 if self.config["global"].get("test.user_not_authorized"):
494 return {"id": "fake-token-id-for-test",
495 "project_id": self.config["global"].get("test.project_not_authorized", "admin"),
496 "username": self.config["global"]["test.user_not_authorized"]}
497 else:
498 raise
499
500 def _internal_new_token(self, session, indata, remote):
501 now = time()
502 user_content = None
503
504 # Try using username/password
505 if indata.get("username"):
506 user_rows = self.db.get_list("users", {"username": indata.get("username")})
507 if user_rows:
508 user_content = user_rows[0]
509 salt = user_content["_admin"]["salt"]
510 shadow_password = sha256(indata.get("password", "").encode('utf-8') + salt.encode('utf-8')).hexdigest()
511 if shadow_password != user_content["password"]:
512 user_content = None
513 if not user_content:
514 raise AuthException("Invalid username/password", http_code=HTTPStatus.UNAUTHORIZED)
515 elif session:
516 user_rows = self.db.get_list("users", {"username": session["username"]})
517 if user_rows:
518 user_content = user_rows[0]
519 else:
520 raise AuthException("Invalid token", http_code=HTTPStatus.UNAUTHORIZED)
521 else:
522 raise AuthException("Provide credentials: username/password or Authorization Bearer token",
523 http_code=HTTPStatus.UNAUTHORIZED)
524
525 token_id = ''.join(random_choice('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789')
526 for _ in range(0, 32))
527 if indata.get("project_id"):
528 project_id = indata.get("project_id")
529 if project_id not in user_content["projects"]:
530 raise AuthException("project {} not allowed for this user"
531 .format(project_id), http_code=HTTPStatus.UNAUTHORIZED)
532 else:
533 project_id = user_content["projects"][0]
534 if project_id == "admin":
535 session_admin = True
536 else:
537 project = self.db.get_one("projects", {"_id": project_id})
538 session_admin = project.get("admin", False)
539 new_session = {"issued_at": now, "expires": now + 3600,
540 "_id": token_id, "id": token_id, "project_id": project_id, "username": user_content["username"],
541 "remote_port": remote.port, "admin": session_admin}
542 if remote.name:
543 new_session["remote_host"] = remote.name
544 elif remote.ip:
545 new_session["remote_host"] = remote.ip
546
547 self.tokens_cache[token_id] = new_session
548 self.db.create("tokens", new_session)
549 # check if database must be prune
550 self._internal_tokens_prune(now)
551 return deepcopy(new_session)
552
553 def _internal_get_token_list(self, session):
554 now = time()
555 token_list = self.db.get_list("tokens", {"username": session["username"], "expires.gt": now})
556 return token_list
557
558 def _internal_get_token(self, session, token_id):
559 token_value = self.db.get_one("tokens", {"_id": token_id}, fail_on_empty=False)
560 if not token_value:
561 raise AuthException("token not found", http_code=HTTPStatus.NOT_FOUND)
562 if token_value["username"] != session["username"] and not session["admin"]:
563 raise AuthException("needed admin privileges", http_code=HTTPStatus.UNAUTHORIZED)
564 return token_value
565
566 def _internal_del_token(self, token_id):
567 try:
568 self.tokens_cache.pop(token_id, None)
569 self.db.del_one("tokens", {"_id": token_id})
570 return "token '{}' deleted".format(token_id)
571 except DbException as e:
572 if e.http_code == HTTPStatus.NOT_FOUND:
573 raise AuthException("Token '{}' not found".format(token_id), http_code=HTTPStatus.NOT_FOUND)
574 else:
575 raise
576
577 def _internal_tokens_prune(self, now=None):
578 now = now or time()
579 if not self.next_db_prune_time or self.next_db_prune_time >= now:
580 self.db.del_list("tokens", {"expires.lt": now})
581 self.next_db_prune_time = self.periodin_db_pruning + now
582 self.tokens_cache.clear() # force to reload tokens from database