9171c9494beacaaf38408982a57e48bb80eaf3f2
[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
29 __author__ = "Eduardo Sousa <esousa@whitestack.com>; Alfonso Tierno <alfonso.tiernosepulveda@telefonica.com>"
30 __date__ = "$27-jul-2018 23:59:59$"
31
32 import cherrypy
33 import logging
34 import yaml
35 from base64 import standard_b64decode
36 from copy import deepcopy
37 from functools import reduce
38 from hashlib import sha256
39 from http import HTTPStatus
40 from random import choice as random_choice
41 from time import time
42 from os import path
43 from base_topic import BaseTopic # To allow project names in project_id
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 possible_paths = (
112 __file__[:__file__.rfind("auth.py")] + "resources_to_operations.yml",
113 "./resources_to_operations.yml"
114 )
115 for config_file in possible_paths:
116 if path.isfile(config_file):
117 self.resources_to_operations_file = config_file
118 break
119 if not self.resources_to_operations_file:
120 raise AuthException("Invalid permission configuration: resources_to_operations file missing")
121 if not self.roles_to_operations_file:
122 if "roles_to_operations" in config["rbac"]:
123 self.roles_to_operations_file = config["rbac"]["roles_to_operations"]
124 else:
125 possible_paths = (
126 __file__[:__file__.rfind("auth.py")] + "roles_to_operations.yml",
127 "./roles_to_operations.yml"
128 )
129 for config_file in possible_paths:
130 if path.isfile(config_file):
131 self.roles_to_operations_file = config_file
132 break
133 if not self.roles_to_operations_file:
134 raise AuthException("Invalid permission configuration: roles_to_operations file missing")
135 except Exception as e:
136 raise AuthException(str(e))
137
138 def stop(self):
139 try:
140 if self.db:
141 self.db.db_disconnect()
142 except DbException as e:
143 raise AuthException(str(e), http_code=e.http_code)
144
145 def init_db(self, target_version='1.0'):
146 """
147 Check if the database has been initialized, with at least one user. If not, create the required tables
148 and insert the predefined mappings between roles and permissions.
149
150 :param target_version: schema version that should be present in the database.
151 :return: None if OK, exception if error or version is different.
152 """
153 # Always reads operation to resource mapping from file (this is static, no need to store it in MongoDB)
154 # Operations encoding: "<METHOD> <URL>"
155 # Note: it is faster to rewrite the value than to check if it is already there or not
156 if self.config["authentication"]["backend"] == "internal":
157 return
158
159 operations = []
160 with open(self.resources_to_operations_file, "r") as stream:
161 resources_to_operations_yaml = yaml.load(stream)
162
163 for resource, operation in resources_to_operations_yaml["resources_to_operations"].items():
164 operation_key = operation.replace(".", ":")
165 if operation_key not in operations:
166 operations.append(operation_key)
167 self.resources_to_operations_mapping[resource] = operation_key
168
169 records = self.db.get_list("roles_operations")
170
171 # Loading permissions to MongoDB. If there are permissions already in MongoDB, do nothing.
172 if len(records) == 0:
173 with open(self.roles_to_operations_file, "r") as stream:
174 roles_to_operations_yaml = yaml.load(stream)
175
176 roles = []
177 for role_with_operations in roles_to_operations_yaml["roles_to_operations"]:
178 # Verifying if role already exists. If it does, send warning to log and ignore it.
179 if role_with_operations["role"] not in roles:
180 roles.append(role_with_operations["role"])
181 else:
182 self.logger.warning("Duplicated role with name: {0}. Role definition is ignored."
183 .format(role_with_operations["role"]))
184 continue
185
186 role_ops = {}
187 root = None
188
189 if not role_with_operations["operations"]:
190 continue
191
192 for operation, is_allowed in role_with_operations["operations"].items():
193 if not isinstance(is_allowed, bool):
194 continue
195
196 if operation == ".":
197 root = is_allowed
198 continue
199
200 if len(operation) != 1 and operation[-1] == ".":
201 self.logger.warning("Invalid operation {0} terminated in '.'. "
202 "Operation will be discarded"
203 .format(operation))
204 continue
205
206 operation_key = operation.replace(".", ":")
207 if operation_key not in role_ops.keys():
208 role_ops[operation_key] = is_allowed
209 else:
210 self.logger.info("In role {0}, the operation {1} with the value {2} was discarded due to "
211 "repetition.".format(role_with_operations["role"], operation, is_allowed))
212
213 if not root:
214 root = False
215 self.logger.info("Root for role {0} not defined. Default value 'False' applied."
216 .format(role_with_operations["role"]))
217
218 now = time()
219 operation_to_roles_item = {
220 "_admin": {
221 "created": now,
222 "modified": now,
223 },
224 "name": role_with_operations["role"],
225 "root": root
226 }
227
228 for operation, value in role_ops.items():
229 operation_to_roles_item[operation] = value
230
231 if self.config["authentication"]["backend"] != "internal" and \
232 role_with_operations["role"] != "anonymous":
233 keystone_id = self.backend.create_role(role_with_operations["role"])
234 operation_to_roles_item["_id"] = keystone_id["_id"]
235
236 self.db.create("roles_operations", operation_to_roles_item)
237
238 permissions = {oper: [] for oper in operations}
239 records = self.db.get_list("roles_operations")
240
241 ignore_fields = ["_id", "_admin", "name", "root"]
242 for record in records:
243 record_permissions = {oper: record["root"] for oper in operations}
244 operations_joined = [(oper, value) for oper, value in record.items() if oper not in ignore_fields]
245 operations_joined.sort(key=lambda x: x[0].count(":"))
246
247 for oper in operations_joined:
248 match = list(filter(lambda x: x.find(oper[0]) == 0, record_permissions.keys()))
249
250 for m in match:
251 record_permissions[m] = oper[1]
252
253 allowed_operations = [k for k, v in record_permissions.items() if v is True]
254
255 for allowed_op in allowed_operations:
256 permissions[allowed_op].append(record["name"])
257
258 for oper, role_list in permissions.items():
259 self.operation_to_allowed_roles[oper] = role_list
260
261 if self.config["authentication"]["backend"] != "internal":
262 self.backend.assign_role_to_user("admin", "admin", "system_admin")
263
264 def authorize(self):
265 token = None
266 user_passwd64 = None
267 try:
268 # 1. Get token Authorization bearer
269 auth = cherrypy.request.headers.get("Authorization")
270 if auth:
271 auth_list = auth.split(" ")
272 if auth_list[0].lower() == "bearer":
273 token = auth_list[-1]
274 elif auth_list[0].lower() == "basic":
275 user_passwd64 = auth_list[-1]
276 if not token:
277 if cherrypy.session.get("Authorization"):
278 # 2. Try using session before request a new token. If not, basic authentication will generate
279 token = cherrypy.session.get("Authorization")
280 if token == "logout":
281 token = None # force Unauthorized response to insert user password again
282 elif user_passwd64 and cherrypy.request.config.get("auth.allow_basic_authentication"):
283 # 3. Get new token from user password
284 user = None
285 passwd = None
286 try:
287 user_passwd = standard_b64decode(user_passwd64).decode()
288 user, _, passwd = user_passwd.partition(":")
289 except Exception:
290 pass
291 outdata = self.new_token(None, {"username": user, "password": passwd})
292 token = outdata["id"]
293 cherrypy.session['Authorization'] = token
294 if self.config["authentication"]["backend"] == "internal":
295 return self._internal_authorize(token)
296 else:
297 if not token:
298 raise AuthException("Needed a token or Authorization http header",
299 http_code=HTTPStatus.UNAUTHORIZED)
300 try:
301 self.backend.validate_token(token)
302 self.check_permissions(self.tokens_cache[token], cherrypy.request.path_info,
303 cherrypy.request.method)
304 # TODO: check if this can be avoided. Backend may provide enough information
305 return deepcopy(self.tokens_cache[token])
306 except AuthException:
307 self.del_token(token)
308 raise
309 except AuthException as e:
310 if cherrypy.session.get('Authorization'):
311 del cherrypy.session['Authorization']
312 cherrypy.response.headers["WWW-Authenticate"] = 'Bearer realm="{}"'.format(e)
313 raise AuthException(str(e))
314
315 def new_token(self, session, indata, remote):
316 if self.config["authentication"]["backend"] == "internal":
317 return self._internal_new_token(session, indata, remote)
318 else:
319 if indata.get("username"):
320 token, projects = self.backend.authenticate_with_user_password(
321 indata.get("username"), indata.get("password"))
322 elif session:
323 token, projects = self.backend.authenticate_with_token(
324 session.get("id"), indata.get("project_id"))
325 else:
326 raise AuthException("Provide credentials: username/password or Authorization Bearer token",
327 http_code=HTTPStatus.UNAUTHORIZED)
328
329 if indata.get("project_id"):
330 project_id = indata.get("project_id")
331 if project_id not in projects:
332 raise AuthException("Project {} not allowed for this user".format(project_id),
333 http_code=HTTPStatus.UNAUTHORIZED)
334 else:
335 project_id = projects[0]
336
337 if not session:
338 token, projects = self.backend.authenticate_with_token(token, project_id)
339
340 if project_id == "admin":
341 session_admin = True
342 else:
343 session_admin = reduce(lambda x, y: x or (True if y == "admin" else False),
344 projects, False)
345
346 now = time()
347 new_session = {
348 "_id": token,
349 "id": token,
350 "issued_at": now,
351 "expires": now + 3600,
352 "project_id": project_id,
353 "username": indata.get("username") if not session else session.get("username"),
354 "remote_port": remote.port,
355 "admin": session_admin
356 }
357
358 if remote.name:
359 new_session["remote_host"] = remote.name
360 elif remote.ip:
361 new_session["remote_host"] = remote.ip
362
363 # TODO: check if this can be avoided. Backend may provide enough information
364 self.tokens_cache[token] = new_session
365
366 return deepcopy(new_session)
367
368 def get_token_list(self, session):
369 if self.config["authentication"]["backend"] == "internal":
370 return self._internal_get_token_list(session)
371 else:
372 # TODO: check if this can be avoided. Backend may provide enough information
373 return [deepcopy(token) for token in self.tokens_cache.values()
374 if token["username"] == session["username"]]
375
376 def get_token(self, session, token):
377 if self.config["authentication"]["backend"] == "internal":
378 return self._internal_get_token(session, token)
379 else:
380 # TODO: check if this can be avoided. Backend may provide enough information
381 token_value = self.tokens_cache.get(token)
382 if not token_value:
383 raise AuthException("token not found", http_code=HTTPStatus.NOT_FOUND)
384 if token_value["username"] != session["username"] and not session["admin"]:
385 raise AuthException("needed admin privileges", http_code=HTTPStatus.UNAUTHORIZED)
386 return token_value
387
388 def del_token(self, token):
389 if self.config["authentication"]["backend"] == "internal":
390 return self._internal_del_token(token)
391 else:
392 try:
393 self.backend.revoke_token(token)
394 del self.tokens_cache[token]
395 return "token '{}' deleted".format(token)
396 except KeyError:
397 raise AuthException("Token '{}' not found".format(token), http_code=HTTPStatus.NOT_FOUND)
398
399 def check_permissions(self, session, url, method):
400 self.logger.info("Session: {}".format(session))
401 self.logger.info("URL: {}".format(url))
402 self.logger.info("Method: {}".format(method))
403
404 key, parameters = self._normalize_url(url, method)
405
406 # TODO: Check if parameters might be useful for the decision
407
408 operation = self.resources_to_operations_mapping[key]
409 roles_required = self.operation_to_allowed_roles[operation]
410 roles_allowed = self.backend.get_user_role_list(session["id"])
411
412 if "anonymous" in roles_required:
413 return
414
415 for role in roles_allowed:
416 if role in roles_required:
417 return
418
419 raise AuthException("Access denied: lack of permissions.")
420
421 def get_user_list(self):
422 return self.backend.get_user_list()
423
424 def _normalize_url(self, url, method):
425 # Removing query strings
426 normalized_url = url if '?' not in url else url[:url.find("?")]
427 normalized_url_splitted = normalized_url.split("/")
428 parameters = {}
429
430 filtered_keys = [key for key in self.resources_to_operations_mapping.keys()
431 if method in key.split()[0]]
432
433 for idx, path_part in enumerate(normalized_url_splitted):
434 tmp_keys = []
435 for tmp_key in filtered_keys:
436 splitted = tmp_key.split()[1].split("/")
437 if idx >= len(splitted):
438 continue
439 elif "<" in splitted[idx] and ">" in splitted[idx]:
440 if splitted[idx] == "<artifactPath>":
441 tmp_keys.append(tmp_key)
442 continue
443 elif idx == len(normalized_url_splitted) - 1 and \
444 len(normalized_url_splitted) != len(splitted):
445 continue
446 else:
447 tmp_keys.append(tmp_key)
448 elif splitted[idx] == path_part:
449 if idx == len(normalized_url_splitted) - 1 and \
450 len(normalized_url_splitted) != len(splitted):
451 continue
452 else:
453 tmp_keys.append(tmp_key)
454 filtered_keys = tmp_keys
455 if len(filtered_keys) == 1 and \
456 filtered_keys[0].split("/")[-1] == "<artifactPath>":
457 break
458
459 if len(filtered_keys) == 0:
460 raise AuthException("Cannot make an authorization decision. URL not found. URL: {0}".format(url))
461 elif len(filtered_keys) > 1:
462 raise AuthException("Cannot make an authorization decision. Multiple URLs found. URL: {0}".format(url))
463
464 filtered_key = filtered_keys[0]
465
466 for idx, path_part in enumerate(filtered_key.split()[1].split("/")):
467 if "<" in path_part and ">" in path_part:
468 if path_part == "<artifactPath>":
469 parameters[path_part[1:-1]] = "/".join(normalized_url_splitted[idx:])
470 else:
471 parameters[path_part[1:-1]] = normalized_url_splitted[idx]
472
473 return filtered_key, parameters
474
475 def _internal_authorize(self, token_id):
476 try:
477 if not token_id:
478 raise AuthException("Needed a token or Authorization http header", http_code=HTTPStatus.UNAUTHORIZED)
479 # try to get from cache first
480 now = time()
481 session = self.tokens_cache.get(token_id)
482 if session and session["expires"] < now:
483 del self.tokens_cache[token_id]
484 session = None
485 if session:
486 return session
487
488 # get from database if not in cache
489 session = self.db.get_one("tokens", {"_id": token_id})
490 if session["expires"] < now:
491 raise AuthException("Expired Token or Authorization http header", http_code=HTTPStatus.UNAUTHORIZED)
492 self.tokens_cache[token_id] = session
493 return session
494 except DbException as e:
495 if e.http_code == HTTPStatus.NOT_FOUND:
496 raise AuthException("Invalid Token or Authorization http header", http_code=HTTPStatus.UNAUTHORIZED)
497 else:
498 raise
499
500 except AuthException:
501 if self.config["global"].get("test.user_not_authorized"):
502 return {"id": "fake-token-id-for-test",
503 "project_id": self.config["global"].get("test.project_not_authorized", "admin"),
504 "username": self.config["global"]["test.user_not_authorized"]}
505 else:
506 raise
507
508 def _internal_new_token(self, session, indata, remote):
509 now = time()
510 user_content = None
511
512 # Try using username/password
513 if indata.get("username"):
514 user_rows = self.db.get_list("users", {"username": indata.get("username")})
515 if user_rows:
516 user_content = user_rows[0]
517 salt = user_content["_admin"]["salt"]
518 shadow_password = sha256(indata.get("password", "").encode('utf-8') + salt.encode('utf-8')).hexdigest()
519 if shadow_password != user_content["password"]:
520 user_content = None
521 if not user_content:
522 raise AuthException("Invalid username/password", http_code=HTTPStatus.UNAUTHORIZED)
523 elif session:
524 user_rows = self.db.get_list("users", {"username": session["username"]})
525 if user_rows:
526 user_content = user_rows[0]
527 else:
528 raise AuthException("Invalid token", http_code=HTTPStatus.UNAUTHORIZED)
529 else:
530 raise AuthException("Provide credentials: username/password or Authorization Bearer token",
531 http_code=HTTPStatus.UNAUTHORIZED)
532
533 token_id = ''.join(random_choice('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789')
534 for _ in range(0, 32))
535 project_id = indata.get("project_id")
536 if project_id:
537 if project_id != "admin":
538 # To allow project names in project_id
539 proj = self.db.get_one("projects", {BaseTopic.id_field("projects", project_id): project_id})
540 if proj["_id"] not in user_content["projects"] and proj["name"] not in user_content["projects"]:
541 raise AuthException("project {} not allowed for this user"
542 .format(project_id), http_code=HTTPStatus.UNAUTHORIZED)
543 else:
544 project_id = user_content["projects"][0]
545 if project_id == "admin":
546 session_admin = True
547 else:
548 # To allow project names in project_id
549 project = self.db.get_one("projects", {BaseTopic.id_field("projects", project_id): project_id})
550 session_admin = project.get("admin", False)
551 new_session = {"issued_at": now, "expires": now + 3600,
552 "_id": token_id, "id": token_id, "project_id": project_id, "username": user_content["username"],
553 "remote_port": remote.port, "admin": session_admin}
554 if remote.name:
555 new_session["remote_host"] = remote.name
556 elif remote.ip:
557 new_session["remote_host"] = remote.ip
558
559 self.tokens_cache[token_id] = new_session
560 self.db.create("tokens", new_session)
561 # check if database must be prune
562 self._internal_tokens_prune(now)
563 return deepcopy(new_session)
564
565 def _internal_get_token_list(self, session):
566 now = time()
567 token_list = self.db.get_list("tokens", {"username": session["username"], "expires.gt": now})
568 return token_list
569
570 def _internal_get_token(self, session, token_id):
571 token_value = self.db.get_one("tokens", {"_id": token_id}, fail_on_empty=False)
572 if not token_value:
573 raise AuthException("token not found", http_code=HTTPStatus.NOT_FOUND)
574 if token_value["username"] != session["username"] and not session["admin"]:
575 raise AuthException("needed admin privileges", http_code=HTTPStatus.UNAUTHORIZED)
576 return token_value
577
578 def _internal_del_token(self, token_id):
579 try:
580 self.tokens_cache.pop(token_id, None)
581 self.db.del_one("tokens", {"_id": token_id})
582 return "token '{}' deleted".format(token_id)
583 except DbException as e:
584 if e.http_code == HTTPStatus.NOT_FOUND:
585 raise AuthException("Token '{}' not found".format(token_id), http_code=HTTPStatus.NOT_FOUND)
586 else:
587 raise
588
589 def _internal_tokens_prune(self, now=None):
590 now = now or time()
591 if not self.next_db_prune_time or self.next_db_prune_time >= now:
592 self.db.del_list("tokens", {"expires.lt": now})
593 self.next_db_prune_time = self.periodin_db_pruning + now
594 self.tokens_cache.clear() # force to reload tokens from database