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 http
import HTTPStatus
42 from authconn
import AuthException
, AuthExceptionUnauthorized
43 from authconn_keystone
import AuthconnKeystone
44 from authconn_internal
import AuthconnInternal
# Comment out for testing&debugging, uncomment when ready
45 from osm_common
import dbmongo
46 from osm_common
import dbmemory
47 from osm_common
.dbbase
import DbException
49 from uuid
import uuid4
# For Role _id with internal authentication backend
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 This class must be threading safe
61 periodin_db_pruning
= 60 * 30 # for the internal backend only. every 30 minutes expired tokens will be pruned
65 Authenticator initializer. Setup the initial state of the object,
66 while it waits for the config dictionary and database initialization.
71 self
.tokens_cache
= dict()
72 self
.next_db_prune_time
= 0 # time when next cleaning of expired tokens must be done
73 self
.resources_to_operations_file
= None
74 self
.roles_to_operations_file
= None
75 self
.roles_to_operations_table
= None
76 self
.resources_to_operations_mapping
= {}
77 self
.operation_to_allowed_roles
= {}
78 self
.logger
= logging
.getLogger("nbi.authenticator")
81 def start(self
, config
):
83 Method to configure the Authenticator object. This method should be called
84 after object creation. It is responsible by initializing the selected backend,
85 as well as the initialization of the database connection.
87 :param config: dictionary containing the relevant parameters for this object.
93 if config
["database"]["driver"] == "mongo":
94 self
.db
= dbmongo
.DbMongo()
95 self
.db
.db_connect(config
["database"])
96 elif config
["database"]["driver"] == "memory":
97 self
.db
= dbmemory
.DbMemory()
98 self
.db
.db_connect(config
["database"])
100 raise AuthException("Invalid configuration param '{}' at '[database]':'driver'"
101 .format(config
["database"]["driver"]))
103 if config
["authentication"]["backend"] == "keystone":
104 self
.backend
= AuthconnKeystone(self
.config
["authentication"])
105 elif config
["authentication"]["backend"] == "internal":
106 self
.backend
= AuthconnInternal(self
.config
["authentication"], self
.db
, self
.tokens_cache
)
107 self
._internal
_tokens
_prune
()
109 raise AuthException("Unknown authentication backend: {}"
110 .format(config
["authentication"]["backend"]))
111 if not self
.resources_to_operations_file
:
112 if "resources_to_operations" in config
["rbac"]:
113 self
.resources_to_operations_file
= config
["rbac"]["resources_to_operations"]
116 __file__
[:__file__
.rfind("auth.py")] + "resources_to_operations.yml",
117 "./resources_to_operations.yml"
119 for config_file
in possible_paths
:
120 if path
.isfile(config_file
):
121 self
.resources_to_operations_file
= config_file
123 if not self
.resources_to_operations_file
:
124 raise AuthException("Invalid permission configuration: resources_to_operations file missing")
125 if not self
.roles_to_operations_file
:
126 if "roles_to_operations" in config
["rbac"]:
127 self
.roles_to_operations_file
= config
["rbac"]["roles_to_operations"]
130 __file__
[:__file__
.rfind("auth.py")] + "roles_to_operations.yml",
131 "./roles_to_operations.yml"
133 for config_file
in possible_paths
:
134 if path
.isfile(config_file
):
135 self
.roles_to_operations_file
= config_file
137 if not self
.roles_to_operations_file
:
138 raise AuthException("Invalid permission configuration: roles_to_operations file missing")
139 if not self
.roles_to_operations_table
: # PROVISIONAL ?
140 self
.roles_to_operations_table
= "roles_operations" \
141 if config
["authentication"]["backend"] == "keystone" \
143 except Exception as e
:
144 raise AuthException(str(e
))
149 self
.db
.db_disconnect()
150 except DbException
as e
:
151 raise AuthException(str(e
), http_code
=e
.http_code
)
153 def init_db(self
, target_version
='1.0'):
155 Check if the database has been initialized, with at least one user. If not, create the required tables
156 and insert the predefined mappings between roles and permissions.
158 :param target_version: schema version that should be present in the database.
159 :return: None if OK, exception if error or version is different.
161 # Always reads operation to resource mapping from file (this is static, no need to store it in MongoDB)
162 # Operations encoding: "<METHOD> <URL>"
163 # Note: it is faster to rewrite the value than to check if it is already there or not
165 # PCR 28/05/2019 Commented out to allow initialization for internal backend
166 # if self.config["authentication"]["backend"] == "internal":
169 with
open(self
.resources_to_operations_file
, "r") as stream
:
170 resources_to_operations_yaml
= yaml
.load(stream
)
172 for resource
, operation
in resources_to_operations_yaml
["resources_to_operations"].items():
173 if operation
not in self
.operations
:
174 self
.operations
.append(operation
)
175 self
.resources_to_operations_mapping
[resource
] = operation
177 records
= self
.db
.get_list(self
.roles_to_operations_table
)
179 # Loading permissions to MongoDB if there is not any permission.
181 with
open(self
.roles_to_operations_file
, "r") as stream
:
182 roles_to_operations_yaml
= yaml
.load(stream
)
185 for role_with_operations
in roles_to_operations_yaml
["roles"]:
186 # Verifying if role already exists. If it does, raise exception
187 if role_with_operations
["name"] not in role_names
:
188 role_names
.append(role_with_operations
["name"])
190 raise AuthException("Duplicated role name '{}' at file '{}''"
191 .format(role_with_operations
["name"], self
.roles_to_operations_file
))
193 if not role_with_operations
["permissions"]:
196 for permission
, is_allowed
in role_with_operations
["permissions"].items():
197 if not isinstance(is_allowed
, bool):
198 raise AuthException("Invalid value for permission '{}' at role '{}'; at file '{}'"
199 .format(permission
, role_with_operations
["name"],
200 self
.roles_to_operations_file
))
202 # TODO chek permission is ok
203 if permission
[-1] == ":":
204 raise AuthException("Invalid permission '{}' terminated in ':' for role '{}'; at file {}"
205 .format(permission
, role_with_operations
["name"],
206 self
.roles_to_operations_file
))
208 if "default" not in role_with_operations
["permissions"]:
209 role_with_operations
["permissions"]["default"] = False
210 if "admin" not in role_with_operations
["permissions"]:
211 role_with_operations
["permissions"]["admin"] = False
214 role_with_operations
["_admin"] = {
219 if self
.config
["authentication"]["backend"] == "keystone":
220 if role_with_operations
["name"] != "anonymous":
221 backend_roles
= self
.backend
.get_role_list(filter_q
={"name": role_with_operations
["name"]})
223 backend_id
= backend_roles
[0]["_id"]
225 backend_id
= self
.backend
.create_role(role_with_operations
["name"])
226 role_with_operations
["_id"] = backend_id
228 role_with_operations
["_id"] = str(uuid4())
230 self
.db
.create(self
.roles_to_operations_table
, role_with_operations
)
232 if self
.config
["authentication"]["backend"] != "internal":
233 self
.backend
.assign_role_to_user("admin", "admin", "system_admin")
235 self
.load_operation_to_allowed_roles()
237 def load_operation_to_allowed_roles(self
):
239 Fills the internal self.operation_to_allowed_roles based on database role content and self.operations
240 It works in a shadow copy and replace at the end to allow other threads working with the old copy
244 permissions
= {oper
: [] for oper
in self
.operations
}
245 records
= self
.db
.get_list(self
.roles_to_operations_table
)
247 ignore_fields
= ["_id", "_admin", "name", "default"]
248 for record
in records
:
249 record_permissions
= {oper
: record
["permissions"].get("default", False) for oper
in self
.operations
}
250 operations_joined
= [(oper
, value
) for oper
, value
in record
["permissions"].items()
251 if oper
not in ignore_fields
]
252 operations_joined
.sort(key
=lambda x
: x
[0].count(":"))
254 for oper
in operations_joined
:
255 match
= list(filter(lambda x
: x
.find(oper
[0]) == 0, record_permissions
.keys()))
258 record_permissions
[m
] = oper
[1]
260 allowed_operations
= [k
for k
, v
in record_permissions
.items() if v
is True]
262 for allowed_op
in allowed_operations
:
263 permissions
[allowed_op
].append(record
["name"])
265 self
.operation_to_allowed_roles
= permissions
271 # 1. Get token Authorization bearer
272 auth
= cherrypy
.request
.headers
.get("Authorization")
274 auth_list
= auth
.split(" ")
275 if auth_list
[0].lower() == "bearer":
276 token
= auth_list
[-1]
277 elif auth_list
[0].lower() == "basic":
278 user_passwd64
= auth_list
[-1]
280 if cherrypy
.session
.get("Authorization"):
281 # 2. Try using session before request a new token. If not, basic authentication will generate
282 token
= cherrypy
.session
.get("Authorization")
283 if token
== "logout":
284 token
= None # force Unauthorized response to insert user password again
285 elif user_passwd64
and cherrypy
.request
.config
.get("auth.allow_basic_authentication"):
286 # 3. Get new token from user password
290 user_passwd
= standard_b64decode(user_passwd64
).decode()
291 user
, _
, passwd
= user_passwd
.partition(":")
294 outdata
= self
.new_token(None, {"username": user
, "password": passwd
})
295 token
= outdata
["id"]
296 cherrypy
.session
['Authorization'] = token
299 raise AuthException("Needed a token or Authorization http header",
300 http_code
=HTTPStatus
.UNAUTHORIZED
)
301 token_info
= self
.backend
.validate_token(token
)
302 # TODO add to token info remote host, port
304 self
.check_permissions(token_info
, cherrypy
.request
.path_info
,
305 cherrypy
.request
.method
)
308 except AuthException
as e
:
309 if not isinstance(e
, AuthExceptionUnauthorized
):
310 if cherrypy
.session
.get('Authorization'):
311 del cherrypy
.session
['Authorization']
312 cherrypy
.response
.headers
["WWW-Authenticate"] = 'Bearer realm="{}"'.format(e
)
315 def new_token(self
, session
, indata
, remote
):
318 # current_token = session.get("token")
319 current_token
= session
.get("_id") if self
.config
["authentication"]["backend"] == "keystone" \
321 token_info
= self
.backend
.authenticate(
322 user
=indata
.get("username"),
323 password
=indata
.get("password"),
325 project
=indata
.get("project_id")
330 "_id": token_info
["_id"],
331 "id": token_info
["_id"],
333 "expires": token_info
.get("expires", now
+ 3600),
334 "project_id": token_info
["project_id"],
335 "username": token_info
.get("username") or session
.get("username"),
336 "remote_port": remote
.port
,
337 "admin": True if token_info
.get("project_name") == "admin" else False # TODO put admin in RBAC
341 new_session
["remote_host"] = remote
.name
343 new_session
["remote_host"] = remote
.ip
345 # TODO: check if this can be avoided. Backend may provide enough information
346 self
.tokens_cache
[token_info
["_id"]] = new_session
348 return deepcopy(new_session
)
350 def get_token_list(self
, session
):
351 if self
.config
["authentication"]["backend"] == "internal":
352 return self
._internal
_get
_token
_list
(session
)
354 # TODO: check if this can be avoided. Backend may provide enough information
355 return [deepcopy(token
) for token
in self
.tokens_cache
.values()
356 if token
["username"] == session
["username"]]
358 def get_token(self
, session
, token
):
359 if self
.config
["authentication"]["backend"] == "internal":
360 return self
._internal
_get
_token
(session
, token
)
362 # TODO: check if this can be avoided. Backend may provide enough information
363 token_value
= self
.tokens_cache
.get(token
)
365 raise AuthException("token not found", http_code
=HTTPStatus
.NOT_FOUND
)
366 if token_value
["username"] != session
["username"] and not session
["admin"]:
367 raise AuthException("needed admin privileges", http_code
=HTTPStatus
.UNAUTHORIZED
)
370 def del_token(self
, token
):
372 self
.backend
.revoke_token(token
)
373 self
.tokens_cache
.pop(token
, None)
374 return "token '{}' deleted".format(token
)
376 raise AuthException("Token '{}' not found".format(token
), http_code
=HTTPStatus
.NOT_FOUND
)
378 def check_permissions(self
, session
, url
, method
):
379 self
.logger
.info("Session: {}".format(session
))
380 self
.logger
.info("URL: {}".format(url
))
381 self
.logger
.info("Method: {}".format(method
))
383 key
, parameters
= self
._normalize
_url
(url
, method
)
385 # TODO: Check if parameters might be useful for the decision
387 operation
= self
.resources_to_operations_mapping
[key
]
388 roles_required
= self
.operation_to_allowed_roles
[operation
]
389 roles_allowed
= [role
["name"] for role
in session
["roles"]]
391 # fills session["admin"] if some roles allows it
392 session
["admin"] = False
393 for role
in roles_allowed
:
394 if role
in self
.operation_to_allowed_roles
["admin"]:
395 session
["admin"] = True
398 if "anonymous" in roles_required
:
401 for role
in roles_allowed
:
402 if role
in roles_required
:
405 raise AuthExceptionUnauthorized("Access denied: lack of permissions.")
407 def get_user_list(self
):
408 return self
.backend
.get_user_list()
410 def _normalize_url(self
, url
, method
):
411 # Removing query strings
412 normalized_url
= url
if '?' not in url
else url
[:url
.find("?")]
413 normalized_url_splitted
= normalized_url
.split("/")
416 filtered_keys
= [key
for key
in self
.resources_to_operations_mapping
.keys()
417 if method
in key
.split()[0]]
419 for idx
, path_part
in enumerate(normalized_url_splitted
):
421 for tmp_key
in filtered_keys
:
422 splitted
= tmp_key
.split()[1].split("/")
423 if idx
>= len(splitted
):
425 elif "<" in splitted
[idx
] and ">" in splitted
[idx
]:
426 if splitted
[idx
] == "<artifactPath>":
427 tmp_keys
.append(tmp_key
)
429 elif idx
== len(normalized_url_splitted
) - 1 and \
430 len(normalized_url_splitted
) != len(splitted
):
433 tmp_keys
.append(tmp_key
)
434 elif splitted
[idx
] == path_part
:
435 if idx
== len(normalized_url_splitted
) - 1 and \
436 len(normalized_url_splitted
) != len(splitted
):
439 tmp_keys
.append(tmp_key
)
440 filtered_keys
= tmp_keys
441 if len(filtered_keys
) == 1 and \
442 filtered_keys
[0].split("/")[-1] == "<artifactPath>":
445 if len(filtered_keys
) == 0:
446 raise AuthException("Cannot make an authorization decision. URL not found. URL: {0}".format(url
))
447 elif len(filtered_keys
) > 1:
448 raise AuthException("Cannot make an authorization decision. Multiple URLs found. URL: {0}".format(url
))
450 filtered_key
= filtered_keys
[0]
452 for idx
, path_part
in enumerate(filtered_key
.split()[1].split("/")):
453 if "<" in path_part
and ">" in path_part
:
454 if path_part
== "<artifactPath>":
455 parameters
[path_part
[1:-1]] = "/".join(normalized_url_splitted
[idx
:])
457 parameters
[path_part
[1:-1]] = normalized_url_splitted
[idx
]
459 return filtered_key
, parameters
461 def _internal_get_token_list(self
, session
):
463 token_list
= self
.db
.get_list("tokens", {"username": session
["username"], "expires.gt": now
})
466 def _internal_get_token(self
, session
, token_id
):
467 token_value
= self
.db
.get_one("tokens", {"_id": token_id
}, fail_on_empty
=False)
469 raise AuthException("token not found", http_code
=HTTPStatus
.NOT_FOUND
)
470 if token_value
["username"] != session
["username"] and not session
["admin"]:
471 raise AuthException("needed admin privileges", http_code
=HTTPStatus
.UNAUTHORIZED
)
474 def _internal_tokens_prune(self
, now
=None):
476 if not self
.next_db_prune_time
or self
.next_db_prune_time
>= now
:
477 self
.db
.del_list("tokens", {"expires.lt": now
})
478 self
.next_db_prune_time
= self
.periodin_db_pruning
+ now
479 self
.tokens_cache
.clear() # force to reload tokens from database