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