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
30 __author__
= "Eduardo Sousa <esousa@whitestack.com>; Alfonso Tierno <alfonso.tiernosepulveda@telefonica.com>"
31 __date__
= "$27-jul-2018 23:59:59$"
36 from base64
import standard_b64decode
37 from copy
import deepcopy
38 from functools
import reduce
39 from hashlib
import sha256
40 from http
import HTTPStatus
41 from random
import choice
as random_choice
43 from uuid
import uuid4
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"]
111 for config_file
in (__file__
[:__file__
.rfind("auth.py")] + "resources_to_operations.yml",
112 "./resources_to_operations.yml"):
113 if path
.isfile(config_file
):
114 self
.resources_to_operations_file
= config_file
116 if not self
.resources_to_operations_file
:
117 raise AuthException("Invalid permission configuration: resources_to_operations file missing")
118 if not self
.roles_to_operations_file
:
119 if "roles_to_operations" in config
["rbac"]:
120 self
.roles_to_operations_file
= config
["rbac"]["roles_to_operations"]
122 for config_file
in (__file__
[:__file__
.rfind("auth.py")] + "roles_to_operations.yml",
123 "./roles_to_operations.yml"):
124 if path
.isfile(config_file
):
125 self
.roles_to_operations_file
= config_file
127 if not self
.roles_to_operations_file
:
128 raise AuthException("Invalid permission configuration: roles_to_operations file missing")
129 except Exception as e
:
130 raise AuthException(str(e
))
135 self
.db
.db_disconnect()
136 except DbException
as e
:
137 raise AuthException(str(e
), http_code
=e
.http_code
)
139 def init_db(self
, target_version
='1.0'):
141 Check if the database has been initialized, with at least one user. If not, create the required tables
142 and insert the predefined mappings between roles and permissions.
144 :param target_version: schema version that should be present in the database.
145 :return: None if OK, exception if error or version is different.
147 # Always reads operation to resource mapping from file (this is static, no need to store it in MongoDB)
148 # Operations encoding: "<METHOD> <URL>"
149 # Note: it is faster to rewrite the value than to check if it is already there or not
151 with
open(self
.resources_to_operations_file
, "r") as stream
:
152 resources_to_operations_yaml
= yaml
.load(stream
)
154 for resource
, operation
in resources_to_operations_yaml
["resources_to_operations"].items():
155 operation_key
= operation
.replace(".", ":")
156 if operation_key
not in operations
:
157 operations
.append(operation_key
)
158 self
.resources_to_operations_mapping
[resource
] = operation_key
160 records
= self
.db
.get_list("roles_operations")
162 # Loading permissions to MongoDB. If there are permissions already in MongoDB, do nothing.
163 if len(records
) == 0:
164 with
open(self
.roles_to_operations_file
, "r") as stream
:
165 roles_to_operations_yaml
= yaml
.load(stream
)
168 for role_with_operations
in roles_to_operations_yaml
["roles_to_operations"]:
169 # Verifying if role already exists. If it does, send warning to log and ignore it.
170 if role_with_operations
["role"] not in roles
:
171 roles
.append(role_with_operations
["role"])
173 self
.logger
.warning("Duplicated role with name: {0}. Role definition is ignored."
174 .format(role_with_operations
["role"]))
180 if not role_with_operations
["operations"]:
183 for operation
, is_allowed
in role_with_operations
["operations"].items():
184 if not isinstance(is_allowed
, bool):
191 if len(operation
) != 1 and operation
[-1] == ".":
192 self
.logger
.warning("Invalid operation {0} terminated in '.'. "
193 "Operation will be discarded"
197 operation_key
= operation
.replace(".", ":")
198 if operation_key
not in operations
.keys():
199 operations
[operation_key
] = is_allowed
201 self
.logger
.info("In role {0}, the operation {1} with the value {2} was discarded due to "
202 "repetition.".format(role_with_operations
["role"], operation
, is_allowed
))
206 self
.logger
.info("Root for role {0} not defined. Default value 'False' applied."
207 .format(role_with_operations
["role"]))
210 operation_to_roles_item
= {
216 "role": role_with_operations
["role"],
220 for operation
, value
in operations
.items():
221 operation_to_roles_item
[operation
] = value
223 self
.db
.create("roles_operations", operation_to_roles_item
)
225 permissions
= {oper
: [] for oper
in operations
}
226 records
= self
.db
.get_list("roles_operations")
228 ignore_fields
= ["_id", "_admin", "role", "root"]
230 for record
in records
:
232 roles
.append(record
["role"])
233 record_permissions
= {oper
: record
["root"] for oper
in operations
}
234 operations_joined
= [(oper
, value
) for oper
, value
in record
.items() if oper
not in ignore_fields
]
235 operations_joined
.sort(key
=lambda x
: x
[0].count(":"))
237 for oper
in operations_joined
:
238 match
= list(filter(lambda x
: x
.find(oper
[0]) == 0, record_permissions
.keys()))
241 record_permissions
[m
] = oper
[1]
243 allowed_operations
= [k
for k
, v
in record_permissions
.items() if v
is True]
245 for allowed_op
in allowed_operations
:
246 permissions
[allowed_op
].append(record
["role"])
248 for oper
, role_list
in permissions
.items():
249 self
.operation_to_allowed_roles
[oper
] = role_list
251 if self
.config
["authentication"]["backend"] != "internal":
253 if role
== "anonymous":
255 self
.backend
.create_role(role
)
257 self
.backend
.assign_role_to_user("admin", "admin", "system_admin")
263 # 1. Get token Authorization bearer
264 auth
= cherrypy
.request
.headers
.get("Authorization")
266 auth_list
= auth
.split(" ")
267 if auth_list
[0].lower() == "bearer":
268 token
= auth_list
[-1]
269 elif auth_list
[0].lower() == "basic":
270 user_passwd64
= auth_list
[-1]
272 if cherrypy
.session
.get("Authorization"):
273 # 2. Try using session before request a new token. If not, basic authentication will generate
274 token
= cherrypy
.session
.get("Authorization")
275 if token
== "logout":
276 token
= None # force Unauthorized response to insert user password again
277 elif user_passwd64
and cherrypy
.request
.config
.get("auth.allow_basic_authentication"):
278 # 3. Get new token from user password
282 user_passwd
= standard_b64decode(user_passwd64
).decode()
283 user
, _
, passwd
= user_passwd
.partition(":")
286 outdata
= self
.new_token(None, {"username": user
, "password": passwd
})
287 token
= outdata
["id"]
288 cherrypy
.session
['Authorization'] = token
289 if self
.config
["authentication"]["backend"] == "internal":
290 return self
._internal
_authorize
(token
)
293 raise AuthException("Needed a token or Authorization http header",
294 http_code
=HTTPStatus
.UNAUTHORIZED
)
296 self
.backend
.validate_token(token
)
297 self
.check_permissions(self
.tokens_cache
[token
], cherrypy
.request
.path_info
,
298 cherrypy
.request
.method
)
299 # TODO: check if this can be avoided. Backend may provide enough information
300 return deepcopy(self
.tokens_cache
[token
])
301 except AuthException
:
302 self
.del_token(token
)
304 except AuthException
as e
:
305 if cherrypy
.session
.get('Authorization'):
306 del cherrypy
.session
['Authorization']
307 cherrypy
.response
.headers
["WWW-Authenticate"] = 'Bearer realm="{}"'.format(e
)
308 raise AuthException(str(e
))
310 def new_token(self
, session
, indata
, remote
):
311 if self
.config
["authentication"]["backend"] == "internal":
312 return self
._internal
_new
_token
(session
, indata
, remote
)
314 if indata
.get("username"):
315 token
, projects
= self
.backend
.authenticate_with_user_password(
316 indata
.get("username"), indata
.get("password"))
318 token
, projects
= self
.backend
.authenticate_with_token(
319 session
.get("id"), indata
.get("project_id"))
321 raise AuthException("Provide credentials: username/password or Authorization Bearer token",
322 http_code
=HTTPStatus
.UNAUTHORIZED
)
324 if indata
.get("project_id"):
325 project_id
= indata
.get("project_id")
326 if project_id
not in projects
:
327 raise AuthException("Project {} not allowed for this user".format(project_id
),
328 http_code
=HTTPStatus
.UNAUTHORIZED
)
330 project_id
= projects
[0]
333 token
, projects
= self
.backend
.authenticate_with_token(token
, project_id
)
335 if project_id
== "admin":
338 session_admin
= reduce(lambda x
, y
: x
or (True if y
== "admin" else False),
346 "expires": now
+ 3600,
347 "project_id": project_id
,
348 "username": indata
.get("username") if not session
else session
.get("username"),
349 "remote_port": remote
.port
,
350 "admin": session_admin
354 new_session
["remote_host"] = remote
.name
356 new_session
["remote_host"] = remote
.ip
358 # TODO: check if this can be avoided. Backend may provide enough information
359 self
.tokens_cache
[token
] = new_session
361 return deepcopy(new_session
)
363 def get_token_list(self
, session
):
364 if self
.config
["authentication"]["backend"] == "internal":
365 return self
._internal
_get
_token
_list
(session
)
367 # TODO: check if this can be avoided. Backend may provide enough information
368 return [deepcopy(token
) for token
in self
.tokens_cache
.values()
369 if token
["username"] == session
["username"]]
371 def get_token(self
, session
, token
):
372 if self
.config
["authentication"]["backend"] == "internal":
373 return self
._internal
_get
_token
(session
, token
)
375 # TODO: check if this can be avoided. Backend may provide enough information
376 token_value
= self
.tokens_cache
.get(token
)
378 raise AuthException("token not found", http_code
=HTTPStatus
.NOT_FOUND
)
379 if token_value
["username"] != session
["username"] and not session
["admin"]:
380 raise AuthException("needed admin privileges", http_code
=HTTPStatus
.UNAUTHORIZED
)
383 def del_token(self
, token
):
384 if self
.config
["authentication"]["backend"] == "internal":
385 return self
._internal
_del
_token
(token
)
388 self
.backend
.revoke_token(token
)
389 del self
.tokens_cache
[token
]
390 return "token '{}' deleted".format(token
)
392 raise AuthException("Token '{}' not found".format(token
), http_code
=HTTPStatus
.NOT_FOUND
)
394 def check_permissions(self
, session
, url
, method
):
395 self
.logger
.info("Session: {}".format(session
))
396 self
.logger
.info("URL: {}".format(url
))
397 self
.logger
.info("Method: {}".format(method
))
399 key
, parameters
= self
._normalize
_url
(url
, method
)
401 # TODO: Check if parameters might be useful for the decision
403 operation
= self
.resources_to_operations_mapping
[key
]
404 roles_required
= self
.operation_to_allowed_roles
[operation
]
405 roles_allowed
= self
.backend
.get_role_list(session
["id"])
407 if "anonymous" in roles_required
:
410 for role
in roles_allowed
:
411 if role
in roles_required
:
414 raise AuthException("Access denied: lack of permissions.")
416 def _normalize_url(self
, url
, method
):
417 # Removing query strings
418 normalized_url
= url
if '?' not in url
else url
[:url
.find("?")]
419 normalized_url_splitted
= normalized_url
.split("/")
422 filtered_keys
= [key
for key
in self
.resources_to_operations_mapping
.keys()
423 if method
in key
.split()[0]]
425 for idx
, path_part
in enumerate(normalized_url_splitted
):
427 for tmp_key
in filtered_keys
:
428 splitted
= tmp_key
.split()[1].split("/")
429 if "<" in splitted
[idx
] and ">" in splitted
[idx
]:
430 if splitted
[idx
] == "<artifactPath>":
431 tmp_keys
.append(tmp_key
)
433 elif idx
== len(normalized_url_splitted
) - 1 and \
434 len(normalized_url_splitted
) != len(splitted
):
437 tmp_keys
.append(tmp_key
)
438 elif splitted
[idx
] == path_part
:
439 if idx
== len(normalized_url_splitted
) - 1 and \
440 len(normalized_url_splitted
) != len(splitted
):
443 tmp_keys
.append(tmp_key
)
444 filtered_keys
= tmp_keys
445 if len(filtered_keys
) == 1 and \
446 filtered_keys
[0].split("/")[-1] == "<artifactPath>":
449 if len(filtered_keys
) == 0:
450 raise AuthException("Cannot make an authorization decision. URL not found. URL: {0}".format(url
))
451 elif len(filtered_keys
) > 1:
452 raise AuthException("Cannot make an authorization decision. Multiple URLs found. URL: {0}".format(url
))
454 filtered_key
= filtered_keys
[0]
456 for idx
, path_part
in enumerate(filtered_key
.split()[1].split("/")):
457 if "<" in path_part
and ">" in path_part
:
458 if path_part
== "<artifactPath>":
459 parameters
[path_part
[1:-1]] = "/".join(normalized_url_splitted
[idx
:])
461 parameters
[path_part
[1:-1]] = normalized_url_splitted
[idx
]
463 return filtered_key
, parameters
465 def _internal_authorize(self
, token_id
):
468 raise AuthException("Needed a token or Authorization http header", http_code
=HTTPStatus
.UNAUTHORIZED
)
469 # try to get from cache first
471 session
= self
.tokens_cache
.get(token_id
)
472 if session
and session
["expires"] < now
:
473 del self
.tokens_cache
[token_id
]
478 # get from database if not in cache
479 session
= self
.db
.get_one("tokens", {"_id": token_id
})
480 if session
["expires"] < now
:
481 raise AuthException("Expired Token or Authorization http header", http_code
=HTTPStatus
.UNAUTHORIZED
)
482 self
.tokens_cache
[token_id
] = session
484 except DbException
as e
:
485 if e
.http_code
== HTTPStatus
.NOT_FOUND
:
486 raise AuthException("Invalid Token or Authorization http header", http_code
=HTTPStatus
.UNAUTHORIZED
)
490 except AuthException
:
491 if self
.config
["global"].get("test.user_not_authorized"):
492 return {"id": "fake-token-id-for-test",
493 "project_id": self
.config
["global"].get("test.project_not_authorized", "admin"),
494 "username": self
.config
["global"]["test.user_not_authorized"]}
498 def _internal_new_token(self
, session
, indata
, remote
):
502 # Try using username/password
503 if indata
.get("username"):
504 user_rows
= self
.db
.get_list("users", {"username": indata
.get("username")})
506 user_content
= user_rows
[0]
507 salt
= user_content
["_admin"]["salt"]
508 shadow_password
= sha256(indata
.get("password", "").encode('utf-8') + salt
.encode('utf-8')).hexdigest()
509 if shadow_password
!= user_content
["password"]:
512 raise AuthException("Invalid username/password", http_code
=HTTPStatus
.UNAUTHORIZED
)
514 user_rows
= self
.db
.get_list("users", {"username": session
["username"]})
516 user_content
= user_rows
[0]
518 raise AuthException("Invalid token", http_code
=HTTPStatus
.UNAUTHORIZED
)
520 raise AuthException("Provide credentials: username/password or Authorization Bearer token",
521 http_code
=HTTPStatus
.UNAUTHORIZED
)
523 token_id
= ''.join(random_choice('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789')
524 for _
in range(0, 32))
525 if indata
.get("project_id"):
526 project_id
= indata
.get("project_id")
527 if project_id
not in user_content
["projects"]:
528 raise AuthException("project {} not allowed for this user"
529 .format(project_id
), http_code
=HTTPStatus
.UNAUTHORIZED
)
531 project_id
= user_content
["projects"][0]
532 if project_id
== "admin":
535 project
= self
.db
.get_one("projects", {"_id": project_id
})
536 session_admin
= project
.get("admin", False)
537 new_session
= {"issued_at": now
, "expires": now
+ 3600,
538 "_id": token_id
, "id": token_id
, "project_id": project_id
, "username": user_content
["username"],
539 "remote_port": remote
.port
, "admin": session_admin
}
541 new_session
["remote_host"] = remote
.name
543 new_session
["remote_host"] = remote
.ip
545 self
.tokens_cache
[token_id
] = new_session
546 self
.db
.create("tokens", new_session
)
547 # check if database must be prune
548 self
._internal
_tokens
_prune
(now
)
549 return deepcopy(new_session
)
551 def _internal_get_token_list(self
, session
):
553 token_list
= self
.db
.get_list("tokens", {"username": session
["username"], "expires.gt": now
})
556 def _internal_get_token(self
, session
, token_id
):
557 token_value
= self
.db
.get_one("tokens", {"_id": token_id
}, fail_on_empty
=False)
559 raise AuthException("token not found", http_code
=HTTPStatus
.NOT_FOUND
)
560 if token_value
["username"] != session
["username"] and not session
["admin"]:
561 raise AuthException("needed admin privileges", http_code
=HTTPStatus
.UNAUTHORIZED
)
564 def _internal_del_token(self
, token_id
):
566 self
.tokens_cache
.pop(token_id
, None)
567 self
.db
.del_one("tokens", {"_id": token_id
})
568 return "token '{}' deleted".format(token_id
)
569 except DbException
as e
:
570 if e
.http_code
== HTTPStatus
.NOT_FOUND
:
571 raise AuthException("Token '{}' not found".format(token_id
), http_code
=HTTPStatus
.NOT_FOUND
)
575 def _internal_tokens_prune(self
, now
=None):
577 if not self
.next_db_prune_time
or self
.next_db_prune_time
>= now
:
578 self
.db
.del_list("tokens", {"expires.lt": now
})
579 self
.next_db_prune_time
= self
.periodin_db_pruning
+ now
580 self
.tokens_cache
.clear() # force to reload tokens from database