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 role_ops
.keys():
199 role_ops
[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 role_ops
.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 idx
>= len(splitted
):
431 elif "<" in splitted
[idx
] and ">" in splitted
[idx
]:
432 if splitted
[idx
] == "<artifactPath>":
433 tmp_keys
.append(tmp_key
)
435 elif idx
== len(normalized_url_splitted
) - 1 and \
436 len(normalized_url_splitted
) != len(splitted
):
439 tmp_keys
.append(tmp_key
)
440 elif splitted
[idx
] == path_part
:
441 if idx
== len(normalized_url_splitted
) - 1 and \
442 len(normalized_url_splitted
) != len(splitted
):
445 tmp_keys
.append(tmp_key
)
446 filtered_keys
= tmp_keys
447 if len(filtered_keys
) == 1 and \
448 filtered_keys
[0].split("/")[-1] == "<artifactPath>":
451 if len(filtered_keys
) == 0:
452 raise AuthException("Cannot make an authorization decision. URL not found. URL: {0}".format(url
))
453 elif len(filtered_keys
) > 1:
454 raise AuthException("Cannot make an authorization decision. Multiple URLs found. URL: {0}".format(url
))
456 filtered_key
= filtered_keys
[0]
458 for idx
, path_part
in enumerate(filtered_key
.split()[1].split("/")):
459 if "<" in path_part
and ">" in path_part
:
460 if path_part
== "<artifactPath>":
461 parameters
[path_part
[1:-1]] = "/".join(normalized_url_splitted
[idx
:])
463 parameters
[path_part
[1:-1]] = normalized_url_splitted
[idx
]
465 return filtered_key
, parameters
467 def _internal_authorize(self
, token_id
):
470 raise AuthException("Needed a token or Authorization http header", http_code
=HTTPStatus
.UNAUTHORIZED
)
471 # try to get from cache first
473 session
= self
.tokens_cache
.get(token_id
)
474 if session
and session
["expires"] < now
:
475 del self
.tokens_cache
[token_id
]
480 # get from database if not in cache
481 session
= self
.db
.get_one("tokens", {"_id": token_id
})
482 if session
["expires"] < now
:
483 raise AuthException("Expired Token or Authorization http header", http_code
=HTTPStatus
.UNAUTHORIZED
)
484 self
.tokens_cache
[token_id
] = session
486 except DbException
as e
:
487 if e
.http_code
== HTTPStatus
.NOT_FOUND
:
488 raise AuthException("Invalid Token or Authorization http header", http_code
=HTTPStatus
.UNAUTHORIZED
)
492 except AuthException
:
493 if self
.config
["global"].get("test.user_not_authorized"):
494 return {"id": "fake-token-id-for-test",
495 "project_id": self
.config
["global"].get("test.project_not_authorized", "admin"),
496 "username": self
.config
["global"]["test.user_not_authorized"]}
500 def _internal_new_token(self
, session
, indata
, remote
):
504 # Try using username/password
505 if indata
.get("username"):
506 user_rows
= self
.db
.get_list("users", {"username": indata
.get("username")})
508 user_content
= user_rows
[0]
509 salt
= user_content
["_admin"]["salt"]
510 shadow_password
= sha256(indata
.get("password", "").encode('utf-8') + salt
.encode('utf-8')).hexdigest()
511 if shadow_password
!= user_content
["password"]:
514 raise AuthException("Invalid username/password", http_code
=HTTPStatus
.UNAUTHORIZED
)
516 user_rows
= self
.db
.get_list("users", {"username": session
["username"]})
518 user_content
= user_rows
[0]
520 raise AuthException("Invalid token", http_code
=HTTPStatus
.UNAUTHORIZED
)
522 raise AuthException("Provide credentials: username/password or Authorization Bearer token",
523 http_code
=HTTPStatus
.UNAUTHORIZED
)
525 token_id
= ''.join(random_choice('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789')
526 for _
in range(0, 32))
527 if indata
.get("project_id"):
528 project_id
= indata
.get("project_id")
529 if project_id
not in user_content
["projects"]:
530 raise AuthException("project {} not allowed for this user"
531 .format(project_id
), http_code
=HTTPStatus
.UNAUTHORIZED
)
533 project_id
= user_content
["projects"][0]
534 if project_id
== "admin":
537 project
= self
.db
.get_one("projects", {"_id": project_id
})
538 session_admin
= project
.get("admin", False)
539 new_session
= {"issued_at": now
, "expires": now
+ 3600,
540 "_id": token_id
, "id": token_id
, "project_id": project_id
, "username": user_content
["username"],
541 "remote_port": remote
.port
, "admin": session_admin
}
543 new_session
["remote_host"] = remote
.name
545 new_session
["remote_host"] = remote
.ip
547 self
.tokens_cache
[token_id
] = new_session
548 self
.db
.create("tokens", new_session
)
549 # check if database must be prune
550 self
._internal
_tokens
_prune
(now
)
551 return deepcopy(new_session
)
553 def _internal_get_token_list(self
, session
):
555 token_list
= self
.db
.get_list("tokens", {"username": session
["username"], "expires.gt": now
})
558 def _internal_get_token(self
, session
, token_id
):
559 token_value
= self
.db
.get_one("tokens", {"_id": token_id
}, fail_on_empty
=False)
561 raise AuthException("token not found", http_code
=HTTPStatus
.NOT_FOUND
)
562 if token_value
["username"] != session
["username"] and not session
["admin"]:
563 raise AuthException("needed admin privileges", http_code
=HTTPStatus
.UNAUTHORIZED
)
566 def _internal_del_token(self
, token_id
):
568 self
.tokens_cache
.pop(token_id
, None)
569 self
.db
.del_one("tokens", {"_id": token_id
})
570 return "token '{}' deleted".format(token_id
)
571 except DbException
as e
:
572 if e
.http_code
== HTTPStatus
.NOT_FOUND
:
573 raise AuthException("Token '{}' not found".format(token_id
), http_code
=HTTPStatus
.NOT_FOUND
)
577 def _internal_tokens_prune(self
, now
=None):
579 if not self
.next_db_prune_time
or self
.next_db_prune_time
>= now
:
580 self
.db
.del_list("tokens", {"expires.lt": now
})
581 self
.next_db_prune_time
= self
.periodin_db_pruning
+ now
582 self
.tokens_cache
.clear() # force to reload tokens from database