1 # -*- coding: utf-8 -*-
3 # Licensed under the Apache License, Version 2.0 (the "License");
4 # you may not use this file except in compliance with the License.
5 # You may obtain a copy of the License at
7 # http://www.apache.org/licenses/LICENSE-2.0
9 # Unless required by applicable law or agreed to in writing, software
10 # distributed under the License is distributed on an "AS IS" BASIS,
11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
13 # See the License for the specific language governing permissions and
14 # limitations under the License.
18 Authenticator is responsible for authenticating the users,
19 create the tokens unscoped and scoped, retrieve the role
20 list inside the projects that they are inserted
24 __author__
= "Eduardo Sousa <esousa@whitestack.com>; Alfonso Tierno <alfonso.tiernosepulveda@telefonica.com>"
25 __date__
= "$27-jul-2018 23:59:59$"
30 from base64
import standard_b64decode
31 from copy
import deepcopy
32 from functools
import reduce
33 from hashlib
import sha256
34 from http
import HTTPStatus
35 from random
import choice
as random_choice
37 from uuid
import uuid4
39 from authconn
import AuthException
40 from authconn_keystone
import AuthconnKeystone
41 from osm_common
import dbmongo
42 from osm_common
import dbmemory
43 from osm_common
.dbbase
import DbException
48 This class should hold all the mechanisms for User Authentication and
49 Authorization. Initially it should support Openstack Keystone as a
50 backend through a plugin model where more backends can be added and a
51 RBAC model to manage permissions on operations.
54 periodin_db_pruning
= 60 * 30 # for the internal backend only. every 30 minutes expired tokens will be pruned
58 Authenticator initializer. Setup the initial state of the object,
59 while it waits for the config dictionary and database initialization.
64 self
.tokens_cache
= dict()
65 self
.next_db_prune_time
= 0 # time when next cleaning of expired tokens must be done
66 self
.resources_to_operations_file
= None
67 self
.roles_to_operations_file
= None
68 self
.resources_to_operations_mapping
= {}
69 self
.operation_to_allowed_roles
= {}
70 self
.logger
= logging
.getLogger("nbi.authenticator")
72 def start(self
, config
):
74 Method to configure the Authenticator object. This method should be called
75 after object creation. It is responsible by initializing the selected backend,
76 as well as the initialization of the database connection.
78 :param config: dictionary containing the relevant parameters for this object.
84 if config
["database"]["driver"] == "mongo":
85 self
.db
= dbmongo
.DbMongo()
86 self
.db
.db_connect(config
["database"])
87 elif config
["database"]["driver"] == "memory":
88 self
.db
= dbmemory
.DbMemory()
89 self
.db
.db_connect(config
["database"])
91 raise AuthException("Invalid configuration param '{}' at '[database]':'driver'"
92 .format(config
["database"]["driver"]))
94 if config
["authentication"]["backend"] == "keystone":
95 self
.backend
= AuthconnKeystone(self
.config
["authentication"])
96 elif config
["authentication"]["backend"] == "internal":
97 self
._internal
_tokens
_prune
()
99 raise AuthException("Unknown authentication backend: {}"
100 .format(config
["authentication"]["backend"]))
101 if not self
.resources_to_operations_file
:
102 if "resources_to_operations" in config
["rbac"]:
103 self
.resources_to_operations_file
= config
["rbac"]["resources_to_operations"]
105 for config_file
in (__file__
[:__file__
.rfind("auth.py")] + "resources_to_operations.yml",
106 "./resources_to_operations.yml"):
107 if path
.isfile(config_file
):
108 self
.resources_to_operations_file
= config_file
110 if not self
.resources_to_operations_file
:
111 raise AuthException("Invalid permission configuration: resources_to_operations file missing")
112 if not self
.roles_to_operations_file
:
113 if "roles_to_operations" in config
["rbac"]:
114 self
.roles_to_operations_file
= config
["rbac"]["roles_to_operations"]
116 for config_file
in (__file__
[:__file__
.rfind("auth.py")] + "roles_to_operations.yml",
117 "./roles_to_operations.yml"):
118 if path
.isfile(config_file
):
119 self
.roles_to_operations_file
= config_file
121 if not self
.roles_to_operations_file
:
122 raise AuthException("Invalid permission configuration: roles_to_operations file missing")
123 except Exception as e
:
124 raise AuthException(str(e
))
129 self
.db
.db_disconnect()
130 except DbException
as e
:
131 raise AuthException(str(e
), http_code
=e
.http_code
)
133 def init_db(self
, target_version
='1.0'):
135 Check if the database has been initialized, with at least one user. If not, create the required tables
136 and insert the predefined mappings between roles and permissions.
138 :param target_version: schema version that should be present in the database.
139 :return: None if OK, exception if error or version is different.
141 # Always reads operation to resource mapping from file (this is static, no need to store it in MongoDB)
142 # Operations encoding: "<METHOD> <URL>"
143 # Note: it is faster to rewrite the value than to check if it is already there or not
145 with
open(self
.resources_to_operations_file
, "r") as stream
:
146 resources_to_operations_yaml
= yaml
.load(stream
)
148 for resource
, operation
in resources_to_operations_yaml
["resources_to_operations"].items():
149 operation_key
= operation
.replace(".", ":")
150 if operation_key
not in operations
:
151 operations
.append(operation_key
)
152 self
.resources_to_operations_mapping
[resource
] = operation_key
154 records
= self
.db
.get_list("roles_operations")
156 # Loading permissions to MongoDB. If there are permissions already in MongoDB, do nothing.
157 if len(records
) == 0:
158 with
open(self
.roles_to_operations_file
, "r") as stream
:
159 roles_to_operations_yaml
= yaml
.load(stream
)
162 for role_with_operations
in roles_to_operations_yaml
["roles_to_operations"]:
163 # Verifying if role already exists. If it does, send warning to log and ignore it.
164 if role_with_operations
["role"] not in roles
:
165 roles
.append(role_with_operations
["role"])
167 self
.logger
.warning("Duplicated role with name: {0}. Role definition is ignored."
168 .format(role_with_operations
["role"]))
174 if not role_with_operations
["operations"]:
177 for operation
, is_allowed
in role_with_operations
["operations"].items():
178 if not isinstance(is_allowed
, bool):
185 if len(operation
) != 1 and operation
[-1] == ".":
186 self
.logger
.warning("Invalid operation {0} terminated in '.'. "
187 "Operation will be discarded"
191 operation_key
= operation
.replace(".", ":")
192 if operation_key
not in operations
.keys():
193 operations
[operation_key
] = is_allowed
195 self
.logger
.info("In role {0}, the operation {1} with the value {2} was discarded due to "
196 "repetition.".format(role_with_operations
["role"], operation
, is_allowed
))
200 self
.logger
.info("Root for role {0} not defined. Default value 'False' applied."
201 .format(role_with_operations
["role"]))
204 operation_to_roles_item
= {
210 "role": role_with_operations
["role"],
214 for operation
, value
in operations
.items():
215 operation_to_roles_item
[operation
] = value
217 self
.db
.create("roles_operations", operation_to_roles_item
)
219 permissions
= {oper
: [] for oper
in operations
}
220 records
= self
.db
.get_list("roles_operations")
222 ignore_fields
= ["_id", "_admin", "role", "root"]
224 for record
in records
:
226 roles
.append(record
["role"])
227 record_permissions
= {oper
: record
["root"] for oper
in operations
}
228 operations_joined
= [(oper
, value
) for oper
, value
in record
.items() if oper
not in ignore_fields
]
229 operations_joined
.sort(key
=lambda x
: x
[0].count(":"))
231 for oper
in operations_joined
:
232 match
= list(filter(lambda x
: x
.find(oper
[0]) == 0, record_permissions
.keys()))
235 record_permissions
[m
] = oper
[1]
237 allowed_operations
= [k
for k
, v
in record_permissions
.items() if v
is True]
239 for allowed_op
in allowed_operations
:
240 permissions
[allowed_op
].append(record
["role"])
242 for oper
, role_list
in permissions
.items():
243 self
.operation_to_allowed_roles
[oper
] = role_list
245 if self
.config
["authentication"]["backend"] != "internal":
247 if role
== "anonymous":
249 self
.backend
.create_role(role
)
251 self
.backend
.assign_role_to_user("admin", "admin", "system_admin")
257 # 1. Get token Authorization bearer
258 auth
= cherrypy
.request
.headers
.get("Authorization")
260 auth_list
= auth
.split(" ")
261 if auth_list
[0].lower() == "bearer":
262 token
= auth_list
[-1]
263 elif auth_list
[0].lower() == "basic":
264 user_passwd64
= auth_list
[-1]
266 if cherrypy
.session
.get("Authorization"):
267 # 2. Try using session before request a new token. If not, basic authentication will generate
268 token
= cherrypy
.session
.get("Authorization")
269 if token
== "logout":
270 token
= None # force Unauthorized response to insert user password again
271 elif user_passwd64
and cherrypy
.request
.config
.get("auth.allow_basic_authentication"):
272 # 3. Get new token from user password
276 user_passwd
= standard_b64decode(user_passwd64
).decode()
277 user
, _
, passwd
= user_passwd
.partition(":")
280 outdata
= self
.new_token(None, {"username": user
, "password": passwd
})
281 token
= outdata
["id"]
282 cherrypy
.session
['Authorization'] = token
283 if self
.config
["authentication"]["backend"] == "internal":
284 return self
._internal
_authorize
(token
)
287 raise AuthException("Needed a token or Authorization http header",
288 http_code
=HTTPStatus
.UNAUTHORIZED
)
290 self
.backend
.validate_token(token
)
291 self
.check_permissions(self
.tokens_cache
[token
], cherrypy
.request
.path_info
,
292 cherrypy
.request
.method
)
293 # TODO: check if this can be avoided. Backend may provide enough information
294 return deepcopy(self
.tokens_cache
[token
])
295 except AuthException
:
296 self
.del_token(token
)
298 except AuthException
as e
:
299 if cherrypy
.session
.get('Authorization'):
300 del cherrypy
.session
['Authorization']
301 cherrypy
.response
.headers
["WWW-Authenticate"] = 'Bearer realm="{}"'.format(e
)
302 raise AuthException(str(e
))
304 def new_token(self
, session
, indata
, remote
):
305 if self
.config
["authentication"]["backend"] == "internal":
306 return self
._internal
_new
_token
(session
, indata
, remote
)
308 if indata
.get("username"):
309 token
, projects
= self
.backend
.authenticate_with_user_password(
310 indata
.get("username"), indata
.get("password"))
312 token
, projects
= self
.backend
.authenticate_with_token(
313 session
.get("id"), indata
.get("project_id"))
315 raise AuthException("Provide credentials: username/password or Authorization Bearer token",
316 http_code
=HTTPStatus
.UNAUTHORIZED
)
318 if indata
.get("project_id"):
319 project_id
= indata
.get("project_id")
320 if project_id
not in projects
:
321 raise AuthException("Project {} not allowed for this user".format(project_id
),
322 http_code
=HTTPStatus
.UNAUTHORIZED
)
324 project_id
= projects
[0]
327 token
, projects
= self
.backend
.authenticate_with_token(token
, project_id
)
329 if project_id
== "admin":
332 session_admin
= reduce(lambda x
, y
: x
or (True if y
== "admin" else False),
340 "expires": now
+ 3600,
341 "project_id": project_id
,
342 "username": indata
.get("username") if not session
else session
.get("username"),
343 "remote_port": remote
.port
,
344 "admin": session_admin
348 new_session
["remote_host"] = remote
.name
350 new_session
["remote_host"] = remote
.ip
352 # TODO: check if this can be avoided. Backend may provide enough information
353 self
.tokens_cache
[token
] = new_session
355 return deepcopy(new_session
)
357 def get_token_list(self
, session
):
358 if self
.config
["authentication"]["backend"] == "internal":
359 return self
._internal
_get
_token
_list
(session
)
361 # TODO: check if this can be avoided. Backend may provide enough information
362 return [deepcopy(token
) for token
in self
.tokens_cache
.values()
363 if token
["username"] == session
["username"]]
365 def get_token(self
, session
, token
):
366 if self
.config
["authentication"]["backend"] == "internal":
367 return self
._internal
_get
_token
(session
, token
)
369 # TODO: check if this can be avoided. Backend may provide enough information
370 token_value
= self
.tokens_cache
.get(token
)
372 raise AuthException("token not found", http_code
=HTTPStatus
.NOT_FOUND
)
373 if token_value
["username"] != session
["username"] and not session
["admin"]:
374 raise AuthException("needed admin privileges", http_code
=HTTPStatus
.UNAUTHORIZED
)
377 def del_token(self
, token
):
378 if self
.config
["authentication"]["backend"] == "internal":
379 return self
._internal
_del
_token
(token
)
382 self
.backend
.revoke_token(token
)
383 del self
.tokens_cache
[token
]
384 return "token '{}' deleted".format(token
)
386 raise AuthException("Token '{}' not found".format(token
), http_code
=HTTPStatus
.NOT_FOUND
)
388 def check_permissions(self
, session
, url
, method
):
389 self
.logger
.info("Session: {}".format(session
))
390 self
.logger
.info("URL: {}".format(url
))
391 self
.logger
.info("Method: {}".format(method
))
393 key
, parameters
= self
._normalize
_url
(url
, method
)
395 # TODO: Check if parameters might be useful for the decision
397 operation
= self
.resources_to_operations_mapping
[key
]
398 roles_required
= self
.operation_to_allowed_roles
[operation
]
399 roles_allowed
= self
.backend
.get_role_list(session
["id"])
401 if "anonymous" in roles_required
:
404 for role
in roles_allowed
:
405 if role
in roles_required
:
408 raise AuthException("Access denied: lack of permissions.")
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 "<" in splitted
[idx
] and ">" in splitted
[idx
]:
424 if splitted
[idx
] == "<artifactPath>":
425 tmp_keys
.append(tmp_key
)
427 elif idx
== len(normalized_url_splitted
) - 1 and \
428 len(normalized_url_splitted
) != len(splitted
):
431 tmp_keys
.append(tmp_key
)
432 elif splitted
[idx
] == path_part
:
433 if idx
== len(normalized_url_splitted
) - 1 and \
434 len(normalized_url_splitted
) != len(splitted
):
437 tmp_keys
.append(tmp_key
)
438 filtered_keys
= tmp_keys
439 if len(filtered_keys
) == 1 and \
440 filtered_keys
[0].split("/")[-1] == "<artifactPath>":
443 if len(filtered_keys
) == 0:
444 raise AuthException("Cannot make an authorization decision. URL not found. URL: {0}".format(url
))
445 elif len(filtered_keys
) > 1:
446 raise AuthException("Cannot make an authorization decision. Multiple URLs found. URL: {0}".format(url
))
448 filtered_key
= filtered_keys
[0]
450 for idx
, path_part
in enumerate(filtered_key
.split()[1].split("/")):
451 if "<" in path_part
and ">" in path_part
:
452 if path_part
== "<artifactPath>":
453 parameters
[path_part
[1:-1]] = "/".join(normalized_url_splitted
[idx
:])
455 parameters
[path_part
[1:-1]] = normalized_url_splitted
[idx
]
457 return filtered_key
, parameters
459 def _internal_authorize(self
, token_id
):
462 raise AuthException("Needed a token or Authorization http header", http_code
=HTTPStatus
.UNAUTHORIZED
)
463 # try to get from cache first
465 session
= self
.tokens_cache
.get(token_id
)
466 if session
and session
["expires"] < now
:
467 del self
.tokens_cache
[token_id
]
472 # get from database if not in cache
473 session
= self
.db
.get_one("tokens", {"_id": token_id
})
474 if session
["expires"] < now
:
475 raise AuthException("Expired Token or Authorization http header", http_code
=HTTPStatus
.UNAUTHORIZED
)
476 self
.tokens_cache
[token_id
] = session
478 except DbException
as e
:
479 if e
.http_code
== HTTPStatus
.NOT_FOUND
:
480 raise AuthException("Invalid Token or Authorization http header", http_code
=HTTPStatus
.UNAUTHORIZED
)
484 except AuthException
:
485 if self
.config
["global"].get("test.user_not_authorized"):
486 return {"id": "fake-token-id-for-test",
487 "project_id": self
.config
["global"].get("test.project_not_authorized", "admin"),
488 "username": self
.config
["global"]["test.user_not_authorized"]}
492 def _internal_new_token(self
, session
, indata
, remote
):
496 # Try using username/password
497 if indata
.get("username"):
498 user_rows
= self
.db
.get_list("users", {"username": indata
.get("username")})
500 user_content
= user_rows
[0]
501 salt
= user_content
["_admin"]["salt"]
502 shadow_password
= sha256(indata
.get("password", "").encode('utf-8') + salt
.encode('utf-8')).hexdigest()
503 if shadow_password
!= user_content
["password"]:
506 raise AuthException("Invalid username/password", http_code
=HTTPStatus
.UNAUTHORIZED
)
508 user_rows
= self
.db
.get_list("users", {"username": session
["username"]})
510 user_content
= user_rows
[0]
512 raise AuthException("Invalid token", http_code
=HTTPStatus
.UNAUTHORIZED
)
514 raise AuthException("Provide credentials: username/password or Authorization Bearer token",
515 http_code
=HTTPStatus
.UNAUTHORIZED
)
517 token_id
= ''.join(random_choice('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789')
518 for _
in range(0, 32))
519 if indata
.get("project_id"):
520 project_id
= indata
.get("project_id")
521 if project_id
not in user_content
["projects"]:
522 raise AuthException("project {} not allowed for this user"
523 .format(project_id
), http_code
=HTTPStatus
.UNAUTHORIZED
)
525 project_id
= user_content
["projects"][0]
526 if project_id
== "admin":
529 project
= self
.db
.get_one("projects", {"_id": project_id
})
530 session_admin
= project
.get("admin", False)
531 new_session
= {"issued_at": now
, "expires": now
+ 3600,
532 "_id": token_id
, "id": token_id
, "project_id": project_id
, "username": user_content
["username"],
533 "remote_port": remote
.port
, "admin": session_admin
}
535 new_session
["remote_host"] = remote
.name
537 new_session
["remote_host"] = remote
.ip
539 self
.tokens_cache
[token_id
] = new_session
540 self
.db
.create("tokens", new_session
)
541 # check if database must be prune
542 self
._internal
_tokens
_prune
(now
)
543 return deepcopy(new_session
)
545 def _internal_get_token_list(self
, session
):
547 token_list
= self
.db
.get_list("tokens", {"username": session
["username"], "expires.gt": now
})
550 def _internal_get_token(self
, session
, token_id
):
551 token_value
= self
.db
.get_one("tokens", {"_id": token_id
}, fail_on_empty
=False)
553 raise AuthException("token not found", http_code
=HTTPStatus
.NOT_FOUND
)
554 if token_value
["username"] != session
["username"] and not session
["admin"]:
555 raise AuthException("needed admin privileges", http_code
=HTTPStatus
.UNAUTHORIZED
)
558 def _internal_del_token(self
, token_id
):
560 self
.tokens_cache
.pop(token_id
, None)
561 self
.db
.del_one("tokens", {"_id": token_id
})
562 return "token '{}' deleted".format(token_id
)
563 except DbException
as e
:
564 if e
.http_code
== HTTPStatus
.NOT_FOUND
:
565 raise AuthException("Token '{}' not found".format(token_id
), http_code
=HTTPStatus
.NOT_FOUND
)
569 def _internal_tokens_prune(self
, now
=None):
571 if not self
.next_db_prune_time
or self
.next_db_prune_time
>= now
:
572 self
.db
.del_list("tokens", {"expires.lt": now
})
573 self
.next_db_prune_time
= self
.periodin_db_pruning
+ now
574 self
.tokens_cache
.clear() # force to reload tokens from database