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
27 __author__
= "Pedro de la Cruz Ramos <pdelacruzramos@altran.com>, " \
28 "Alfonso Tierno <alfonso.tiernosepulveda@telefoncia.com"
29 __date__
= "$06-jun-2019 11:16:08$"
34 from osm_nbi
.authconn
import Authconn
, AuthException
# , AuthconnOperationException
35 from osm_common
.dbbase
import DbException
36 from osm_nbi
.base_topic
import BaseTopic
37 from osm_nbi
.validation
import is_valid_uuid
38 from time
import time
, sleep
39 from http
import HTTPStatus
40 from uuid
import uuid4
41 from hashlib
import sha256
42 from copy
import deepcopy
43 from random
import choice
as random_choice
46 class AuthconnInternal(Authconn
):
47 token_time_window
= 2 # seconds
48 token_delay
= 1 # seconds to wait upon second request within time window
50 users_collection
= "users"
51 roles_collection
= "roles"
52 projects_collection
= "projects"
53 tokens_collection
= "tokens"
55 def __init__(self
, config
, db
, role_permissions
):
56 Authconn
.__init
__(self
, config
, db
, role_permissions
)
57 self
.logger
= logging
.getLogger("nbi.authenticator.internal")
61 # self.token_cache = token_cache
66 def validate_token(self
, token
):
68 Check if the token is valid.
70 :param token: token to validate
71 :return: dictionary with information associated with the token:
73 "project_id": project id
74 "project_name": project name
77 "roles": list with dict containing {name, id}
78 "expires": expiration date
79 If the token is not valid an exception is raised.
84 raise AuthException("Needed a token or Authorization HTTP header", http_code
=HTTPStatus
.UNAUTHORIZED
)
88 # get from database if not in cache
90 token_info
= self
.db
.get_one(self
.tokens_collection
, {"_id": token
})
91 if token_info
["expires"] < now
:
92 raise AuthException("Expired Token or Authorization HTTP header", http_code
=HTTPStatus
.UNAUTHORIZED
)
96 except DbException
as e
:
97 if e
.http_code
== HTTPStatus
.NOT_FOUND
:
98 raise AuthException("Invalid Token or Authorization HTTP header", http_code
=HTTPStatus
.UNAUTHORIZED
)
101 except AuthException
:
104 self
.logger
.exception("Error during token validation using internal backend")
105 raise AuthException("Error during token validation using internal backend",
106 http_code
=HTTPStatus
.UNAUTHORIZED
)
108 def revoke_token(self
, token
):
112 :param token: token to be revoked
115 # self.token_cache.pop(token, None)
116 self
.db
.del_one(self
.tokens_collection
, {"_id": token
})
118 except DbException
as e
:
119 if e
.http_code
== HTTPStatus
.NOT_FOUND
:
120 raise AuthException("Token '{}' not found".format(token
), http_code
=HTTPStatus
.NOT_FOUND
)
123 exmsg
= "Error during token revocation using internal backend"
124 self
.logger
.exception(exmsg
)
125 raise AuthException(exmsg
, http_code
=HTTPStatus
.UNAUTHORIZED
)
127 def validate_user(self
, user
, password
):
129 Validate username and password via appropriate backend.
130 :param user: username of the user.
131 :param password: password to be validated.
133 user_rows
= self
.db
.get_list(self
.users_collection
, {BaseTopic
.id_field("users", user
): user
})
136 user_content
= user_rows
[0]
137 salt
= user_content
["_admin"]["salt"]
138 shadow_password
= sha256(password
.encode('utf-8') + salt
.encode('utf-8')).hexdigest()
139 if shadow_password
!= user_content
["password"]:
143 def authenticate(self
, credentials
, token_info
=None):
145 Authenticate a user using username/password or previous token_info plus project; its creates a new token
147 :param credentials: dictionary that contains:
148 username: name, id or None
149 password: password or None
150 project_id: name, id, or None. If None first found project will be used to get an scope token
151 other items are allowed and ignored
152 :param token_info: previous token_info to obtain authorization
153 :return: the scoped token info or raises an exception. The token is a dictionary with:
154 _id: token string id,
156 project_id: scoped_token project_id,
157 project_name: scoped_token project_name,
158 expires: epoch time when it expires,
163 user
= credentials
.get("username")
164 password
= credentials
.get("password")
165 project
= credentials
.get("project_id")
167 # Try using username/password
169 user_content
= self
.validate_user(user
, password
)
171 raise AuthException("Invalid username/password", http_code
=HTTPStatus
.UNAUTHORIZED
)
172 if not user_content
.get("_admin", None):
173 raise AuthException("No default project for this user.", http_code
=HTTPStatus
.UNAUTHORIZED
)
175 user_rows
= self
.db
.get_list(self
.users_collection
, {"username": token_info
["username"]})
177 user_content
= user_rows
[0]
179 raise AuthException("Invalid token", http_code
=HTTPStatus
.UNAUTHORIZED
)
181 raise AuthException("Provide credentials: username/password or Authorization Bearer token",
182 http_code
=HTTPStatus
.UNAUTHORIZED
)
183 # Delay upon second request within time window
184 if now
- user_content
["_admin"].get("last_token_time", 0) < self
.token_time_window
:
185 sleep(self
.token_delay
)
186 # user_content["_admin"]["last_token_time"] = now
187 # self.db.replace("users", user_content["_id"], user_content) # might cause race conditions
188 self
.db
.set_one(self
.users_collection
,
189 {"_id": user_content
["_id"]}, {"_admin.last_token_time": now
})
191 token_id
= ''.join(random_choice('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789')
192 for _
in range(0, 32))
194 # projects = user_content.get("projects", [])
195 prm_list
= user_content
.get("project_role_mappings", [])
198 project
= prm_list
[0]["project"] if prm_list
else None
200 raise AuthException("can't find a default project for this user", http_code
=HTTPStatus
.UNAUTHORIZED
)
202 projects
= [prm
["project"] for prm
in prm_list
]
204 proj
= self
.db
.get_one(self
.projects_collection
,
205 {BaseTopic
.id_field("projects", project
): project
})
206 project_name
= proj
["name"]
207 project_id
= proj
["_id"]
208 if project_name
not in projects
and project_id
not in projects
:
209 raise AuthException("project {} not allowed for this user".format(project
),
210 http_code
=HTTPStatus
.UNAUTHORIZED
)
212 # TODO remove admin, this vill be used by roles RBAC
213 if project_name
== "admin":
216 token_admin
= proj
.get("admin", False)
222 if prm
["project"] in [project_id
, project_name
]:
223 role
= self
.db
.get_one(self
.roles_collection
,
224 {BaseTopic
.id_field("roles", prm
["role"]): prm
["role"]})
229 roles_list
.append({"name": rnm
, "id": rid
})
231 rid
= self
.db
.get_one(self
.roles_collection
, {"name": "project_admin"})["_id"]
232 roles_list
= [{"name": "project_admin", "id": rid
}]
234 new_token
= {"issued_at": now
,
235 "expires": now
+ 3600,
238 "project_id": proj
["_id"],
239 "project_name": proj
["name"],
240 "username": user_content
["username"],
241 "user_id": user_content
["_id"],
242 "admin": token_admin
,
246 self
.db
.create(self
.tokens_collection
, new_token
)
247 return deepcopy(new_token
)
249 def get_role_list(self
, filter_q
={}):
253 :return: returns the list of roles.
255 return self
.db
.get_list(self
.roles_collection
, filter_q
)
257 def create_role(self
, role_info
):
261 :param role_info: full role info.
262 :return: returns the role id.
263 :raises AuthconnOperationException: if role creation failed.
265 # TODO: Check that role name does not exist ?
267 role_info
["_id"] = rid
268 rid
= self
.db
.create(self
.roles_collection
, role_info
)
271 def delete_role(self
, role_id
):
275 :param role_id: role identifier.
276 :raises AuthconnOperationException: if role deletion failed.
278 rc
= self
.db
.del_one(self
.roles_collection
, {"_id": role_id
})
279 self
.db
.del_list(self
.tokens_collection
, {"roles.id": role_id
})
282 def update_role(self
, role_info
):
286 :param role_info: full role info.
287 :return: returns the role name and id.
288 :raises AuthconnOperationException: if user creation failed.
290 rid
= role_info
["_id"]
291 self
.db
.set_one(self
.roles_collection
, {"_id": rid
}, role_info
)
292 return {"_id": rid
, "name": role_info
["name"]}
294 def create_user(self
, user_info
):
298 :param user_info: full user info.
299 :return: returns the username and id of the user.
301 BaseTopic
.format_on_new(user_info
, make_public
=False)
303 user_info
["_admin"]["salt"] = salt
304 if "password" in user_info
:
305 user_info
["password"] = sha256(user_info
["password"].encode('utf-8') + salt
.encode('utf-8')).hexdigest()
306 # "projects" are not stored any more
307 if "projects" in user_info
:
308 del user_info
["projects"]
309 self
.db
.create(self
.users_collection
, user_info
)
310 return {"username": user_info
["username"], "_id": user_info
["_id"]}
312 def update_user(self
, user_info
):
314 Change the user name and/or password.
316 :param user_info: user info modifications
318 uid
= user_info
["_id"]
319 user_data
= self
.db
.get_one(self
.users_collection
, {BaseTopic
.id_field("users", uid
): uid
})
320 BaseTopic
.format_on_edit(user_data
, user_info
)
322 usnm
= user_info
.get("username")
324 user_data
["username"] = usnm
325 # If password is given and is not already encripted
326 pswd
= user_info
.get("password")
327 if pswd
and (len(pswd
) != 64 or not re
.match('[a-fA-F0-9]*', pswd
)): # TODO: Improve check?
329 if "_admin" not in user_data
:
330 user_data
["_admin"] = {}
331 user_data
["_admin"]["salt"] = salt
332 user_data
["password"] = sha256(pswd
.encode('utf-8') + salt
.encode('utf-8')).hexdigest()
333 # Project-Role Mappings
334 # TODO: Check that user_info NEVER includes "project_role_mappings"
335 if "project_role_mappings" not in user_data
:
336 user_data
["project_role_mappings"] = []
337 for prm
in user_info
.get("add_project_role_mappings", []):
338 user_data
["project_role_mappings"].append(prm
)
339 for prm
in user_info
.get("remove_project_role_mappings", []):
340 for pidf
in ["project", "project_name"]:
341 for ridf
in ["role", "role_name"]:
343 user_data
["project_role_mappings"].remove({"role": prm
[ridf
], "project": prm
[pidf
]})
348 idf
= BaseTopic
.id_field("users", uid
)
349 self
.db
.set_one(self
.users_collection
, {idf
: uid
}, user_data
)
350 if user_info
.get("remove_project_role_mappings"):
351 idf
= "user_id" if idf
== "_id" else idf
352 self
.db
.del_list(self
.tokens_collection
, {idf
: uid
})
354 def delete_user(self
, user_id
):
358 :param user_id: user identifier.
359 :raises AuthconnOperationException: if user deletion failed.
361 self
.db
.del_one(self
.users_collection
, {"_id": user_id
})
362 self
.db
.del_list(self
.tokens_collection
, {"user_id": user_id
})
365 def get_user_list(self
, filter_q
=None):
369 :param filter_q: dictionary to filter user list by:
370 name (username is also admitted). If a user id is equal to the filter name, it is also provided
372 :return: returns a list of users.
374 filt
= filter_q
or {}
375 if "name" in filt
: # backward compatibility
376 filt
["username"] = filt
.pop("name")
377 if filt
.get("username") and is_valid_uuid(filt
["username"]):
378 # username cannot be a uuid. If this is the case, change from username to _id
379 filt
["_id"] = filt
.pop("username")
380 users
= self
.db
.get_list(self
.users_collection
, filt
)
384 prms
= user
.get("project_role_mappings")
385 projects
= user
.get("projects")
388 # add project_name and role_name. Generate projects for backward compatibility
390 project_id
= prm
["project"]
391 if project_id
not in project_id_name
:
392 pr
= self
.db
.get_one(self
.projects_collection
,
393 {BaseTopic
.id_field("projects", project_id
): project_id
},
395 project_id_name
[project_id
] = pr
["name"] if pr
else None
396 prm
["project_name"] = project_id_name
[project_id
]
397 if prm
["project_name"] not in projects
:
398 projects
.append(prm
["project_name"])
400 role_id
= prm
["role"]
401 if role_id
not in role_id_name
:
402 role
= self
.db
.get_one(self
.roles_collection
,
403 {BaseTopic
.id_field("roles", role_id
): role_id
},
405 role_id_name
[role_id
] = role
["name"] if role
else None
406 prm
["role_name"] = role_id_name
[role_id
]
407 user
["projects"] = projects
# for backward compatibility
409 # user created with an old version. Create a project_role mapping with role project_admin
410 user
["project_role_mappings"] = []
411 role
= self
.db
.get_one(self
.roles_collection
,
412 {BaseTopic
.id_field("roles", "project_admin"): "project_admin"})
413 for p_id_name
in projects
:
414 pr
= self
.db
.get_one(self
.projects_collection
,
415 {BaseTopic
.id_field("projects", p_id_name
): p_id_name
})
416 prm
= {"project": pr
["_id"],
417 "project_name": pr
["name"],
418 "role_name": "project_admin",
421 user
["project_role_mappings"].append(prm
)
423 user
["projects"] = []
424 user
["project_role_mappings"] = []
428 def get_project_list(self
, filter_q
={}):
432 :return: returns the list of projects.
434 return self
.db
.get_list(self
.projects_collection
, filter_q
)
436 def create_project(self
, project_info
):
440 :param project: full project info.
441 :return: the internal id of the created project
442 :raises AuthconnOperationException: if project creation failed.
444 pid
= self
.db
.create(self
.projects_collection
, project_info
)
447 def delete_project(self
, project_id
):
451 :param project_id: project identifier.
452 :raises AuthconnOperationException: if project deletion failed.
454 idf
= BaseTopic
.id_field("projects", project_id
)
455 r
= self
.db
.del_one(self
.projects_collection
, {idf
: project_id
})
456 idf
= "project_id" if idf
== "_id" else "project_name"
457 self
.db
.del_list(self
.tokens_collection
, {idf
: project_id
})
460 def update_project(self
, project_id
, project_info
):
462 Change the name of a project
464 :param project_id: project to be changed
465 :param project_info: full project info
467 :raises AuthconnOperationException: if project update failed.
469 self
.db
.set_one(self
.projects_collection
, {BaseTopic
.id_field("projects", project_id
): project_id
},