1 # -*- coding: utf-8 -*-
3 # Copyright 2018 Telefonica S.A.
4 # Copyright 2018 ALTRAN InnovaciĆ³n S.L.
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 glavado@whitestack.com
23 AuthconnInternal implements implements the connector for
24 OSM Internal Authentication Backend and leverages the RBAC model
28 "Pedro de la Cruz Ramos <pdelacruzramos@altran.com>, "
29 "Alfonso Tierno <alfonso.tiernosepulveda@telefoncia.com"
31 __date__
= "$06-jun-2019 11:16:08$"
36 from osm_nbi
.authconn
import Authconn
, AuthException
# , AuthconnOperationException
37 from osm_common
.dbbase
import DbException
38 from osm_nbi
.base_topic
import BaseTopic
39 from osm_nbi
.validation
import is_valid_uuid
40 from time
import time
, sleep
41 from http
import HTTPStatus
42 from uuid
import uuid4
43 from hashlib
import sha256
44 from copy
import deepcopy
45 from random
import choice
as random_choice
48 class AuthconnInternal(Authconn
):
49 token_time_window
= 2 # seconds
50 token_delay
= 1 # seconds to wait upon second request within time window
52 users_collection
= "users"
53 roles_collection
= "roles"
54 projects_collection
= "projects"
55 tokens_collection
= "tokens"
57 def __init__(self
, config
, db
, role_permissions
):
58 Authconn
.__init
__(self
, config
, db
, role_permissions
)
59 self
.logger
= logging
.getLogger("nbi.authenticator.internal")
63 # self.token_cache = token_cache
68 def validate_token(self
, token
):
70 Check if the token is valid.
72 :param token: token to validate
73 :return: dictionary with information associated with the token:
75 "project_id": project id
76 "project_name": project name
79 "roles": list with dict containing {name, id}
80 "expires": expiration date
81 If the token is not valid an exception is raised.
87 "Needed a token or Authorization HTTP header",
88 http_code
=HTTPStatus
.UNAUTHORIZED
,
93 # get from database if not in cache
95 token_info
= self
.db
.get_one(self
.tokens_collection
, {"_id": token
})
96 if token_info
["expires"] < now
:
98 "Expired Token or Authorization HTTP header",
99 http_code
=HTTPStatus
.UNAUTHORIZED
,
104 except DbException
as e
:
105 if e
.http_code
== HTTPStatus
.NOT_FOUND
:
107 "Invalid Token or Authorization HTTP header",
108 http_code
=HTTPStatus
.UNAUTHORIZED
,
112 except AuthException
:
115 self
.logger
.exception(
116 "Error during token validation using internal backend"
119 "Error during token validation using internal backend",
120 http_code
=HTTPStatus
.UNAUTHORIZED
,
123 def revoke_token(self
, token
):
127 :param token: token to be revoked
130 # self.token_cache.pop(token, None)
131 self
.db
.del_one(self
.tokens_collection
, {"_id": token
})
133 except DbException
as e
:
134 if e
.http_code
== HTTPStatus
.NOT_FOUND
:
136 "Token '{}' not found".format(token
), http_code
=HTTPStatus
.NOT_FOUND
140 exmsg
= "Error during token revocation using internal backend"
141 self
.logger
.exception(exmsg
)
142 raise AuthException(exmsg
, http_code
=HTTPStatus
.UNAUTHORIZED
)
144 def validate_user(self
, user
, password
):
146 Validate username and password via appropriate backend.
147 :param user: username of the user.
148 :param password: password to be validated.
150 user_rows
= self
.db
.get_list(
151 self
.users_collection
, {BaseTopic
.id_field("users", user
): user
}
155 user_content
= user_rows
[0]
156 salt
= user_content
["_admin"]["salt"]
157 shadow_password
= sha256(
158 password
.encode("utf-8") + salt
.encode("utf-8")
160 if shadow_password
!= user_content
["password"]:
164 def authenticate(self
, credentials
, token_info
=None):
166 Authenticate a user using username/password or previous token_info plus project; its creates a new token
168 :param credentials: dictionary that contains:
169 username: name, id or None
170 password: password or None
171 project_id: name, id, or None. If None first found project will be used to get an scope token
172 other items are allowed and ignored
173 :param token_info: previous token_info to obtain authorization
174 :return: the scoped token info or raises an exception. The token is a dictionary with:
175 _id: token string id,
177 project_id: scoped_token project_id,
178 project_name: scoped_token project_name,
179 expires: epoch time when it expires,
184 user
= credentials
.get("username")
185 password
= credentials
.get("password")
186 project
= credentials
.get("project_id")
188 # Try using username/password
190 user_content
= self
.validate_user(user
, password
)
193 "Invalid username/password", http_code
=HTTPStatus
.UNAUTHORIZED
195 if not user_content
.get("_admin", None):
197 "No default project for this user.",
198 http_code
=HTTPStatus
.UNAUTHORIZED
,
201 user_rows
= self
.db
.get_list(
202 self
.users_collection
, {"username": token_info
["username"]}
205 user_content
= user_rows
[0]
207 raise AuthException("Invalid token", http_code
=HTTPStatus
.UNAUTHORIZED
)
210 "Provide credentials: username/password or Authorization Bearer token",
211 http_code
=HTTPStatus
.UNAUTHORIZED
,
213 # Delay upon second request within time window
215 now
- user_content
["_admin"].get("last_token_time", 0)
216 < self
.token_time_window
218 sleep(self
.token_delay
)
219 # user_content["_admin"]["last_token_time"] = now
220 # self.db.replace("users", user_content["_id"], user_content) # might cause race conditions
222 self
.users_collection
,
223 {"_id": user_content
["_id"]},
224 {"_admin.last_token_time": now
},
229 "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
231 for _
in range(0, 32)
234 # projects = user_content.get("projects", [])
235 prm_list
= user_content
.get("project_role_mappings", [])
238 project
= prm_list
[0]["project"] if prm_list
else None
241 "can't find a default project for this user",
242 http_code
=HTTPStatus
.UNAUTHORIZED
,
245 projects
= [prm
["project"] for prm
in prm_list
]
247 proj
= self
.db
.get_one(
248 self
.projects_collection
, {BaseTopic
.id_field("projects", project
): project
}
250 project_name
= proj
["name"]
251 project_id
= proj
["_id"]
252 if project_name
not in projects
and project_id
not in projects
:
254 "project {} not allowed for this user".format(project
),
255 http_code
=HTTPStatus
.UNAUTHORIZED
,
258 # TODO remove admin, this vill be used by roles RBAC
259 if project_name
== "admin":
262 token_admin
= proj
.get("admin", False)
268 if prm
["project"] in [project_id
, project_name
]:
269 role
= self
.db
.get_one(
270 self
.roles_collection
,
271 {BaseTopic
.id_field("roles", prm
["role"]): prm
["role"]},
277 roles_list
.append({"name": rnm
, "id": rid
})
279 rid
= self
.db
.get_one(self
.roles_collection
, {"name": "project_admin"})[
282 roles_list
= [{"name": "project_admin", "id": rid
}]
286 "expires": now
+ 3600,
289 "project_id": proj
["_id"],
290 "project_name": proj
["name"],
291 "username": user_content
["username"],
292 "user_id": user_content
["_id"],
293 "admin": token_admin
,
297 self
.db
.create(self
.tokens_collection
, new_token
)
298 return deepcopy(new_token
)
300 def get_role_list(self
, filter_q
={}):
304 :return: returns the list of roles.
306 return self
.db
.get_list(self
.roles_collection
, filter_q
)
308 def create_role(self
, role_info
):
312 :param role_info: full role info.
313 :return: returns the role id.
314 :raises AuthconnOperationException: if role creation failed.
316 # TODO: Check that role name does not exist ?
318 role_info
["_id"] = rid
319 rid
= self
.db
.create(self
.roles_collection
, role_info
)
322 def delete_role(self
, role_id
):
326 :param role_id: role identifier.
327 :raises AuthconnOperationException: if role deletion failed.
329 rc
= self
.db
.del_one(self
.roles_collection
, {"_id": role_id
})
330 self
.db
.del_list(self
.tokens_collection
, {"roles.id": role_id
})
333 def update_role(self
, role_info
):
337 :param role_info: full role info.
338 :return: returns the role name and id.
339 :raises AuthconnOperationException: if user creation failed.
341 rid
= role_info
["_id"]
342 self
.db
.set_one(self
.roles_collection
, {"_id": rid
}, role_info
)
343 return {"_id": rid
, "name": role_info
["name"]}
345 def create_user(self
, user_info
):
349 :param user_info: full user info.
350 :return: returns the username and id of the user.
352 BaseTopic
.format_on_new(user_info
, make_public
=False)
354 user_info
["_admin"]["salt"] = salt
355 if "password" in user_info
:
356 user_info
["password"] = sha256(
357 user_info
["password"].encode("utf-8") + salt
.encode("utf-8")
359 # "projects" are not stored any more
360 if "projects" in user_info
:
361 del user_info
["projects"]
362 self
.db
.create(self
.users_collection
, user_info
)
363 return {"username": user_info
["username"], "_id": user_info
["_id"]}
365 def update_user(self
, user_info
):
367 Change the user name and/or password.
369 :param user_info: user info modifications
371 uid
= user_info
["_id"]
372 user_data
= self
.db
.get_one(
373 self
.users_collection
, {BaseTopic
.id_field("users", uid
): uid
}
375 BaseTopic
.format_on_edit(user_data
, user_info
)
377 usnm
= user_info
.get("username")
379 user_data
["username"] = usnm
380 # If password is given and is not already encripted
381 pswd
= user_info
.get("password")
383 len(pswd
) != 64 or not re
.match("[a-fA-F0-9]*", pswd
)
384 ): # TODO: Improve check?
386 if "_admin" not in user_data
:
387 user_data
["_admin"] = {}
388 user_data
["_admin"]["salt"] = salt
389 user_data
["password"] = sha256(
390 pswd
.encode("utf-8") + salt
.encode("utf-8")
392 # Project-Role Mappings
393 # TODO: Check that user_info NEVER includes "project_role_mappings"
394 if "project_role_mappings" not in user_data
:
395 user_data
["project_role_mappings"] = []
396 for prm
in user_info
.get("add_project_role_mappings", []):
397 user_data
["project_role_mappings"].append(prm
)
398 for prm
in user_info
.get("remove_project_role_mappings", []):
399 for pidf
in ["project", "project_name"]:
400 for ridf
in ["role", "role_name"]:
402 user_data
["project_role_mappings"].remove(
403 {"role": prm
[ridf
], "project": prm
[pidf
]}
409 idf
= BaseTopic
.id_field("users", uid
)
410 self
.db
.set_one(self
.users_collection
, {idf
: uid
}, user_data
)
411 if user_info
.get("remove_project_role_mappings"):
412 idf
= "user_id" if idf
== "_id" else idf
413 self
.db
.del_list(self
.tokens_collection
, {idf
: uid
})
415 def delete_user(self
, user_id
):
419 :param user_id: user identifier.
420 :raises AuthconnOperationException: if user deletion failed.
422 self
.db
.del_one(self
.users_collection
, {"_id": user_id
})
423 self
.db
.del_list(self
.tokens_collection
, {"user_id": user_id
})
426 def get_user_list(self
, filter_q
=None):
430 :param filter_q: dictionary to filter user list by:
431 name (username is also admitted). If a user id is equal to the filter name, it is also provided
433 :return: returns a list of users.
435 filt
= filter_q
or {}
436 if "name" in filt
: # backward compatibility
437 filt
["username"] = filt
.pop("name")
438 if filt
.get("username") and is_valid_uuid(filt
["username"]):
439 # username cannot be a uuid. If this is the case, change from username to _id
440 filt
["_id"] = filt
.pop("username")
441 users
= self
.db
.get_list(self
.users_collection
, filt
)
445 prms
= user
.get("project_role_mappings")
446 projects
= user
.get("projects")
449 # add project_name and role_name. Generate projects for backward compatibility
451 project_id
= prm
["project"]
452 if project_id
not in project_id_name
:
453 pr
= self
.db
.get_one(
454 self
.projects_collection
,
455 {BaseTopic
.id_field("projects", project_id
): project_id
},
458 project_id_name
[project_id
] = pr
["name"] if pr
else None
459 prm
["project_name"] = project_id_name
[project_id
]
460 if prm
["project_name"] not in projects
:
461 projects
.append(prm
["project_name"])
463 role_id
= prm
["role"]
464 if role_id
not in role_id_name
:
465 role
= self
.db
.get_one(
466 self
.roles_collection
,
467 {BaseTopic
.id_field("roles", role_id
): role_id
},
470 role_id_name
[role_id
] = role
["name"] if role
else None
471 prm
["role_name"] = role_id_name
[role_id
]
472 user
["projects"] = projects
# for backward compatibility
474 # user created with an old version. Create a project_role mapping with role project_admin
475 user
["project_role_mappings"] = []
476 role
= self
.db
.get_one(
477 self
.roles_collection
,
478 {BaseTopic
.id_field("roles", "project_admin"): "project_admin"},
480 for p_id_name
in projects
:
481 pr
= self
.db
.get_one(
482 self
.projects_collection
,
483 {BaseTopic
.id_field("projects", p_id_name
): p_id_name
},
486 "project": pr
["_id"],
487 "project_name": pr
["name"],
488 "role_name": "project_admin",
491 user
["project_role_mappings"].append(prm
)
493 user
["projects"] = []
494 user
["project_role_mappings"] = []
498 def get_project_list(self
, filter_q
={}):
502 :return: returns the list of projects.
504 return self
.db
.get_list(self
.projects_collection
, filter_q
)
506 def create_project(self
, project_info
):
510 :param project: full project info.
511 :return: the internal id of the created project
512 :raises AuthconnOperationException: if project creation failed.
514 pid
= self
.db
.create(self
.projects_collection
, project_info
)
517 def delete_project(self
, project_id
):
521 :param project_id: project identifier.
522 :raises AuthconnOperationException: if project deletion failed.
524 idf
= BaseTopic
.id_field("projects", project_id
)
525 r
= self
.db
.del_one(self
.projects_collection
, {idf
: project_id
})
526 idf
= "project_id" if idf
== "_id" else "project_name"
527 self
.db
.del_list(self
.tokens_collection
, {idf
: project_id
})
530 def update_project(self
, project_id
, project_info
):
532 Change the name of a project
534 :param project_id: project to be changed
535 :param project_info: full project info
537 :raises AuthconnOperationException: if project update failed.
540 self
.projects_collection
,
541 {BaseTopic
.id_field("projects", project_id
): project_id
},