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
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
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.
59 periodin_db_pruning
= 60 * 30 # for the internal backend only. every 30 minutes expired tokens will be pruned
63 Authenticator initializer. Setup the initial state of the object,
64 while it waits for the config dictionary and database initialization.
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")
77 def start(self
, config
):
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.
83 :param config: dictionary containing the relevant parameters for this object.
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"])
96 raise AuthException("Invalid configuration param '{}' at '[database]':'driver'"
97 .format(config
["database"]["driver"]))
99 if config
["authentication"]["backend"] == "keystone":
100 self
.backend
= AuthconnKeystone(self
.config
["authentication"])
101 elif config
["authentication"]["backend"] == "internal":
102 self
._internal
_tokens
_prune
()
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"]
111 __file__
[:__file__
.rfind("auth.py")] + "resources_to_operations.yml",
112 "./resources_to_operations.yml"
114 for config_file
in possible_paths
:
115 if path
.isfile(config_file
):
116 self
.resources_to_operations_file
= config_file
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"]
125 __file__
[:__file__
.rfind("auth.py")] + "roles_to_operations.yml",
126 "./roles_to_operations.yml"
128 for config_file
in possible_paths
:
129 if path
.isfile(config_file
):
130 self
.roles_to_operations_file
= config_file
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
))
140 self
.db
.db_disconnect()
141 except DbException
as e
:
142 raise AuthException(str(e
), http_code
=e
.http_code
)
144 def init_db(self
, target_version
='1.0'):
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.
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.
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":
159 with
open(self
.resources_to_operations_file
, "r") as stream
:
160 resources_to_operations_yaml
= yaml
.load(stream
)
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
168 records
= self
.db
.get_list("roles_operations")
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
)
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"])
181 self
.logger
.warning("Duplicated role with name: {0}. Role definition is ignored."
182 .format(role_with_operations
["role"]))
188 if not role_with_operations
["operations"]:
191 for operation
, is_allowed
in role_with_operations
["operations"].items():
192 if not isinstance(is_allowed
, bool):
199 if len(operation
) != 1 and operation
[-1] == ".":
200 self
.logger
.warning("Invalid operation {0} terminated in '.'. "
201 "Operation will be discarded"
205 operation_key
= operation
.replace(".", ":")
206 if operation_key
not in role_ops
.keys():
207 role_ops
[operation_key
] = is_allowed
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
))
214 self
.logger
.info("Root for role {0} not defined. Default value 'False' applied."
215 .format(role_with_operations
["role"]))
218 operation_to_roles_item
= {
223 "name": role_with_operations
["role"],
227 for operation
, value
in role_ops
.items():
228 operation_to_roles_item
[operation
] = value
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"]
235 self
.db
.create("roles_operations", operation_to_roles_item
)
237 permissions
= {oper
: [] for oper
in operations
}
238 records
= self
.db
.get_list("roles_operations")
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(":"))
246 for oper
in operations_joined
:
247 match
= list(filter(lambda x
: x
.find(oper
[0]) == 0, record_permissions
.keys()))
250 record_permissions
[m
] = oper
[1]
252 allowed_operations
= [k
for k
, v
in record_permissions
.items() if v
is True]
254 for allowed_op
in allowed_operations
:
255 permissions
[allowed_op
].append(record
["name"])
257 for oper
, role_list
in permissions
.items():
258 self
.operation_to_allowed_roles
[oper
] = role_list
260 if self
.config
["authentication"]["backend"] != "internal":
261 self
.backend
.assign_role_to_user("admin", "admin", "system_admin")
267 # 1. Get token Authorization bearer
268 auth
= cherrypy
.request
.headers
.get("Authorization")
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]
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
286 user_passwd
= standard_b64decode(user_passwd64
).decode()
287 user
, _
, passwd
= user_passwd
.partition(":")
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
)
297 raise AuthException("Needed a token or Authorization http header",
298 http_code
=HTTPStatus
.UNAUTHORIZED
)
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
)
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
))
314 def new_token(self
, session
, indata
, remote
):
315 if self
.config
["authentication"]["backend"] == "internal":
316 return self
._internal
_new
_token
(session
, indata
, remote
)
318 if indata
.get("username"):
319 token
, projects
= self
.backend
.authenticate_with_user_password(
320 indata
.get("username"), indata
.get("password"))
322 token
, projects
= self
.backend
.authenticate_with_token(
323 session
.get("id"), indata
.get("project_id"))
325 raise AuthException("Provide credentials: username/password or Authorization Bearer token",
326 http_code
=HTTPStatus
.UNAUTHORIZED
)
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
)
334 project_id
= projects
[0]
337 token
, projects
= self
.backend
.authenticate_with_token(token
, project_id
)
339 if project_id
== "admin":
342 session_admin
= reduce(lambda x
, y
: x
or (True if y
== "admin" else False),
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
358 new_session
["remote_host"] = remote
.name
360 new_session
["remote_host"] = remote
.ip
362 # TODO: check if this can be avoided. Backend may provide enough information
363 self
.tokens_cache
[token
] = new_session
365 return deepcopy(new_session
)
367 def get_token_list(self
, session
):
368 if self
.config
["authentication"]["backend"] == "internal":
369 return self
._internal
_get
_token
_list
(session
)
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"]]
375 def get_token(self
, session
, token
):
376 if self
.config
["authentication"]["backend"] == "internal":
377 return self
._internal
_get
_token
(session
, token
)
379 # TODO: check if this can be avoided. Backend may provide enough information
380 token_value
= self
.tokens_cache
.get(token
)
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
)
387 def del_token(self
, token
):
388 if self
.config
["authentication"]["backend"] == "internal":
389 return self
._internal
_del
_token
(token
)
392 self
.backend
.revoke_token(token
)
393 del self
.tokens_cache
[token
]
394 return "token '{}' deleted".format(token
)
396 raise AuthException("Token '{}' not found".format(token
), http_code
=HTTPStatus
.NOT_FOUND
)
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
))
403 key
, parameters
= self
._normalize
_url
(url
, method
)
405 # TODO: Check if parameters might be useful for the decision
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"])
411 if "anonymous" in roles_required
:
414 for role
in roles_allowed
:
415 if role
in roles_required
:
418 raise AuthException("Access denied: lack of permissions.")
420 def get_user_list(self
):
421 return self
.backend
.get_user_list()
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("/")
429 filtered_keys
= [key
for key
in self
.resources_to_operations_mapping
.keys()
430 if method
in key
.split()[0]]
432 for idx
, path_part
in enumerate(normalized_url_splitted
):
434 for tmp_key
in filtered_keys
:
435 splitted
= tmp_key
.split()[1].split("/")
436 if idx
>= len(splitted
):
438 elif "<" in splitted
[idx
] and ">" in splitted
[idx
]:
439 if splitted
[idx
] == "<artifactPath>":
440 tmp_keys
.append(tmp_key
)
442 elif idx
== len(normalized_url_splitted
) - 1 and \
443 len(normalized_url_splitted
) != len(splitted
):
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
):
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>":
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
))
463 filtered_key
= filtered_keys
[0]
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
:])
470 parameters
[path_part
[1:-1]] = normalized_url_splitted
[idx
]
472 return filtered_key
, parameters
474 def _internal_authorize(self
, token_id
):
477 raise AuthException("Needed a token or Authorization http header", http_code
=HTTPStatus
.UNAUTHORIZED
)
478 # try to get from cache first
480 session
= self
.tokens_cache
.get(token_id
)
481 if session
and session
["expires"] < now
:
482 del self
.tokens_cache
[token_id
]
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
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
)
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"]}
507 def _internal_new_token(self
, session
, indata
, remote
):
511 # Try using username/password
512 if indata
.get("username"):
513 user_rows
= self
.db
.get_list("users", {"username": indata
.get("username")})
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"]:
521 raise AuthException("Invalid username/password", http_code
=HTTPStatus
.UNAUTHORIZED
)
523 user_rows
= self
.db
.get_list("users", {"username": session
["username"]})
525 user_content
= user_rows
[0]
527 raise AuthException("Invalid token", http_code
=HTTPStatus
.UNAUTHORIZED
)
529 raise AuthException("Provide credentials: username/password or Authorization Bearer token",
530 http_code
=HTTPStatus
.UNAUTHORIZED
)
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
)
540 project_id
= user_content
["projects"][0]
541 if project_id
== "admin":
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
}
550 new_session
["remote_host"] = remote
.name
552 new_session
["remote_host"] = remote
.ip
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
)
560 def _internal_get_token_list(self
, session
):
562 token_list
= self
.db
.get_list("tokens", {"username": session
["username"], "expires.gt": now
})
565 def _internal_get_token(self
, session
, token_id
):
566 token_value
= self
.db
.get_one("tokens", {"_id": token_id
}, fail_on_empty
=False)
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
)
573 def _internal_del_token(self
, token_id
):
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
)
584 def _internal_tokens_prune(self
, now
=None):
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