1 # -*- coding: utf-8 -*-
3 # Copyright 2018 Whitestack, LLC
4 # Copyright 2018 Telefonica S.A.
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
10 # http://www.apache.org/licenses/LICENSE-2.0
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
18 # For those usages not covered by the Apache License, Version 2.0 please
19 # contact: esousa@whitestack.com or alfonso.tiernosepulveda@telefonica.com
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
29 __author__
= "Eduardo Sousa <esousa@whitestack.com>; Alfonso Tierno <alfonso.tiernosepulveda@telefonica.com>"
30 __date__
= "$27-jul-2018 23:59:59$"
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
43 from base_topic
import BaseTopic
# To allow project names in project_id
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
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.
60 periodin_db_pruning
= 60 * 30 # for the internal backend only. every 30 minutes expired tokens will be pruned
64 Authenticator initializer. Setup the initial state of the object,
65 while it waits for the config dictionary and database initialization.
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")
78 def start(self
, config
):
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.
84 :param config: dictionary containing the relevant parameters for this object.
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"])
97 raise AuthException("Invalid configuration param '{}' at '[database]':'driver'"
98 .format(config
["database"]["driver"]))
100 if config
["authentication"]["backend"] == "keystone":
101 self
.backend
= AuthconnKeystone(self
.config
["authentication"])
102 elif config
["authentication"]["backend"] == "internal":
103 self
._internal
_tokens
_prune
()
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"]
112 __file__
[:__file__
.rfind("auth.py")] + "resources_to_operations.yml",
113 "./resources_to_operations.yml"
115 for config_file
in possible_paths
:
116 if path
.isfile(config_file
):
117 self
.resources_to_operations_file
= config_file
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"]
126 __file__
[:__file__
.rfind("auth.py")] + "roles_to_operations.yml",
127 "./roles_to_operations.yml"
129 for config_file
in possible_paths
:
130 if path
.isfile(config_file
):
131 self
.roles_to_operations_file
= config_file
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
))
141 self
.db
.db_disconnect()
142 except DbException
as e
:
143 raise AuthException(str(e
), http_code
=e
.http_code
)
145 def init_db(self
, target_version
='1.0'):
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.
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.
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":
160 with
open(self
.resources_to_operations_file
, "r") as stream
:
161 resources_to_operations_yaml
= yaml
.load(stream
)
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
169 records
= self
.db
.get_list("roles_operations")
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
)
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"])
182 self
.logger
.warning("Duplicated role with name: {0}. Role definition is ignored."
183 .format(role_with_operations
["role"]))
189 if not role_with_operations
["operations"]:
192 for operation
, is_allowed
in role_with_operations
["operations"].items():
193 if not isinstance(is_allowed
, bool):
200 if len(operation
) != 1 and operation
[-1] == ".":
201 self
.logger
.warning("Invalid operation {0} terminated in '.'. "
202 "Operation will be discarded"
206 operation_key
= operation
.replace(".", ":")
207 if operation_key
not in role_ops
.keys():
208 role_ops
[operation_key
] = is_allowed
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
))
215 self
.logger
.info("Root for role {0} not defined. Default value 'False' applied."
216 .format(role_with_operations
["role"]))
219 operation_to_roles_item
= {
224 "name": role_with_operations
["role"],
228 for operation
, value
in role_ops
.items():
229 operation_to_roles_item
[operation
] = value
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"]
236 self
.db
.create("roles_operations", operation_to_roles_item
)
238 permissions
= {oper
: [] for oper
in operations
}
239 records
= self
.db
.get_list("roles_operations")
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(":"))
247 for oper
in operations_joined
:
248 match
= list(filter(lambda x
: x
.find(oper
[0]) == 0, record_permissions
.keys()))
251 record_permissions
[m
] = oper
[1]
253 allowed_operations
= [k
for k
, v
in record_permissions
.items() if v
is True]
255 for allowed_op
in allowed_operations
:
256 permissions
[allowed_op
].append(record
["name"])
258 for oper
, role_list
in permissions
.items():
259 self
.operation_to_allowed_roles
[oper
] = role_list
261 if self
.config
["authentication"]["backend"] != "internal":
262 self
.backend
.assign_role_to_user("admin", "admin", "system_admin")
268 # 1. Get token Authorization bearer
269 auth
= cherrypy
.request
.headers
.get("Authorization")
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]
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
287 user_passwd
= standard_b64decode(user_passwd64
).decode()
288 user
, _
, passwd
= user_passwd
.partition(":")
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
)
298 raise AuthException("Needed a token or Authorization http header",
299 http_code
=HTTPStatus
.UNAUTHORIZED
)
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
)
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
))
315 def new_token(self
, session
, indata
, remote
):
316 if self
.config
["authentication"]["backend"] == "internal":
317 return self
._internal
_new
_token
(session
, indata
, remote
)
319 if indata
.get("username"):
320 token
, projects
= self
.backend
.authenticate_with_user_password(
321 indata
.get("username"), indata
.get("password"))
323 token
, projects
= self
.backend
.authenticate_with_token(
324 session
.get("id"), indata
.get("project_id"))
326 raise AuthException("Provide credentials: username/password or Authorization Bearer token",
327 http_code
=HTTPStatus
.UNAUTHORIZED
)
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
)
335 project_id
= projects
[0]
338 token
, projects
= self
.backend
.authenticate_with_token(token
, project_id
)
340 if project_id
== "admin":
343 session_admin
= reduce(lambda x
, y
: x
or (True if y
== "admin" else False),
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
359 new_session
["remote_host"] = remote
.name
361 new_session
["remote_host"] = remote
.ip
363 # TODO: check if this can be avoided. Backend may provide enough information
364 self
.tokens_cache
[token
] = new_session
366 return deepcopy(new_session
)
368 def get_token_list(self
, session
):
369 if self
.config
["authentication"]["backend"] == "internal":
370 return self
._internal
_get
_token
_list
(session
)
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"]]
376 def get_token(self
, session
, token
):
377 if self
.config
["authentication"]["backend"] == "internal":
378 return self
._internal
_get
_token
(session
, token
)
380 # TODO: check if this can be avoided. Backend may provide enough information
381 token_value
= self
.tokens_cache
.get(token
)
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
)
388 def del_token(self
, token
):
389 if self
.config
["authentication"]["backend"] == "internal":
390 return self
._internal
_del
_token
(token
)
393 self
.backend
.revoke_token(token
)
394 del self
.tokens_cache
[token
]
395 return "token '{}' deleted".format(token
)
397 raise AuthException("Token '{}' not found".format(token
), http_code
=HTTPStatus
.NOT_FOUND
)
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
))
404 key
, parameters
= self
._normalize
_url
(url
, method
)
406 # TODO: Check if parameters might be useful for the decision
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"])
412 if "anonymous" in roles_required
:
415 for role
in roles_allowed
:
416 if role
in roles_required
:
419 raise AuthException("Access denied: lack of permissions.")
421 def get_user_list(self
):
422 return self
.backend
.get_user_list()
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("/")
430 filtered_keys
= [key
for key
in self
.resources_to_operations_mapping
.keys()
431 if method
in key
.split()[0]]
433 for idx
, path_part
in enumerate(normalized_url_splitted
):
435 for tmp_key
in filtered_keys
:
436 splitted
= tmp_key
.split()[1].split("/")
437 if idx
>= len(splitted
):
439 elif "<" in splitted
[idx
] and ">" in splitted
[idx
]:
440 if splitted
[idx
] == "<artifactPath>":
441 tmp_keys
.append(tmp_key
)
443 elif idx
== len(normalized_url_splitted
) - 1 and \
444 len(normalized_url_splitted
) != len(splitted
):
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
):
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>":
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
))
464 filtered_key
= filtered_keys
[0]
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
:])
471 parameters
[path_part
[1:-1]] = normalized_url_splitted
[idx
]
473 return filtered_key
, parameters
475 def _internal_authorize(self
, token_id
):
478 raise AuthException("Needed a token or Authorization http header", http_code
=HTTPStatus
.UNAUTHORIZED
)
479 # try to get from cache first
481 session
= self
.tokens_cache
.get(token_id
)
482 if session
and session
["expires"] < now
:
483 del self
.tokens_cache
[token_id
]
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
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
)
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"]}
508 def _internal_new_token(self
, session
, indata
, remote
):
512 # Try using username/password
513 if indata
.get("username"):
514 user_rows
= self
.db
.get_list("users", {"username": indata
.get("username")})
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"]:
522 raise AuthException("Invalid username/password", http_code
=HTTPStatus
.UNAUTHORIZED
)
524 user_rows
= self
.db
.get_list("users", {"username": session
["username"]})
526 user_content
= user_rows
[0]
528 raise AuthException("Invalid token", http_code
=HTTPStatus
.UNAUTHORIZED
)
530 raise AuthException("Provide credentials: username/password or Authorization Bearer token",
531 http_code
=HTTPStatus
.UNAUTHORIZED
)
533 token_id
= ''.join(random_choice('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789')
534 for _
in range(0, 32))
535 project_id
= indata
.get("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
)
544 project_id
= user_content
["projects"][0]
545 if project_id
== "admin":
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
}
555 new_session
["remote_host"] = remote
.name
557 new_session
["remote_host"] = remote
.ip
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
)
565 def _internal_get_token_list(self
, session
):
567 token_list
= self
.db
.get_list("tokens", {"username": session
["username"], "expires.gt": now
})
570 def _internal_get_token(self
, session
, token_id
):
571 token_value
= self
.db
.get_one("tokens", {"_id": token_id
}, fail_on_empty
=False)
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
)
578 def _internal_del_token(self
, token_id
):
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
)
589 def _internal_tokens_prune(self
, now
=None):
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