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 (
39 AuthconnConflictException
,
40 ) # , AuthconnOperationException
41 from osm_common
.dbbase
import DbException
42 from osm_nbi
.base_topic
import BaseTopic
43 from osm_nbi
.validation
import is_valid_uuid
44 from time
import time
, sleep
45 from http
import HTTPStatus
46 from uuid
import uuid4
47 from hashlib
import sha256
48 from copy
import deepcopy
49 from random
import choice
as random_choice
52 class AuthconnInternal(Authconn
):
53 token_time_window
= 2 # seconds
54 token_delay
= 1 # seconds to wait upon second request within time window
56 users_collection
= "users"
57 roles_collection
= "roles"
58 projects_collection
= "projects"
59 tokens_collection
= "tokens"
61 def __init__(self
, config
, db
, role_permissions
):
62 Authconn
.__init
__(self
, config
, db
, role_permissions
)
63 self
.logger
= logging
.getLogger("nbi.authenticator.internal")
67 # self.token_cache = token_cache
72 def validate_token(self
, token
):
74 Check if the token is valid.
76 :param token: token to validate
77 :return: dictionary with information associated with the token:
79 "project_id": project id
80 "project_name": project name
83 "roles": list with dict containing {name, id}
84 "expires": expiration date
85 If the token is not valid an exception is raised.
91 "Needed a token or Authorization HTTP header",
92 http_code
=HTTPStatus
.UNAUTHORIZED
,
97 # get from database if not in cache
99 token_info
= self
.db
.get_one(self
.tokens_collection
, {"_id": token
})
100 if token_info
["expires"] < now
:
102 "Expired Token or Authorization HTTP header",
103 http_code
=HTTPStatus
.UNAUTHORIZED
,
108 except DbException
as e
:
109 if e
.http_code
== HTTPStatus
.NOT_FOUND
:
111 "Invalid Token or Authorization HTTP header",
112 http_code
=HTTPStatus
.UNAUTHORIZED
,
116 except AuthException
:
119 self
.logger
.exception(
120 "Error during token validation using internal backend"
123 "Error during token validation using internal backend",
124 http_code
=HTTPStatus
.UNAUTHORIZED
,
127 def revoke_token(self
, token
):
131 :param token: token to be revoked
134 # self.token_cache.pop(token, None)
135 self
.db
.del_one(self
.tokens_collection
, {"_id": token
})
137 except DbException
as e
:
138 if e
.http_code
== HTTPStatus
.NOT_FOUND
:
140 "Token '{}' not found".format(token
), http_code
=HTTPStatus
.NOT_FOUND
144 exmsg
= "Error during token revocation using internal backend"
145 self
.logger
.exception(exmsg
)
146 raise AuthException(exmsg
, http_code
=HTTPStatus
.UNAUTHORIZED
)
148 def validate_user(self
, user
, password
):
150 Validate username and password via appropriate backend.
151 :param user: username of the user.
152 :param password: password to be validated.
154 user_rows
= self
.db
.get_list(
155 self
.users_collection
, {BaseTopic
.id_field("users", user
): user
}
159 user_content
= user_rows
[0]
160 salt
= user_content
["_admin"]["salt"]
161 shadow_password
= sha256(
162 password
.encode("utf-8") + salt
.encode("utf-8")
164 if shadow_password
!= user_content
["password"]:
168 def authenticate(self
, credentials
, token_info
=None):
170 Authenticate a user using username/password or previous token_info plus project; its creates a new token
172 :param credentials: dictionary that contains:
173 username: name, id or None
174 password: password or None
175 project_id: name, id, or None. If None first found project will be used to get an scope token
176 other items are allowed and ignored
177 :param token_info: previous token_info to obtain authorization
178 :return: the scoped token info or raises an exception. The token is a dictionary with:
179 _id: token string id,
181 project_id: scoped_token project_id,
182 project_name: scoped_token project_name,
183 expires: epoch time when it expires,
188 user
= credentials
.get("username")
189 password
= credentials
.get("password")
190 project
= credentials
.get("project_id")
192 # Try using username/password
194 user_content
= self
.validate_user(user
, password
)
197 "Invalid username/password", http_code
=HTTPStatus
.UNAUTHORIZED
199 if not user_content
.get("_admin", None):
201 "No default project for this user.",
202 http_code
=HTTPStatus
.UNAUTHORIZED
,
205 user_rows
= self
.db
.get_list(
206 self
.users_collection
, {"username": token_info
["username"]}
209 user_content
= user_rows
[0]
211 raise AuthException("Invalid token", http_code
=HTTPStatus
.UNAUTHORIZED
)
214 "Provide credentials: username/password or Authorization Bearer token",
215 http_code
=HTTPStatus
.UNAUTHORIZED
,
217 # Delay upon second request within time window
219 now
- user_content
["_admin"].get("last_token_time", 0)
220 < self
.token_time_window
222 sleep(self
.token_delay
)
223 # user_content["_admin"]["last_token_time"] = now
224 # self.db.replace("users", user_content["_id"], user_content) # might cause race conditions
226 self
.users_collection
,
227 {"_id": user_content
["_id"]},
228 {"_admin.last_token_time": now
},
233 "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
235 for _
in range(0, 32)
238 # projects = user_content.get("projects", [])
239 prm_list
= user_content
.get("project_role_mappings", [])
242 project
= prm_list
[0]["project"] if prm_list
else None
245 "can't find a default project for this user",
246 http_code
=HTTPStatus
.UNAUTHORIZED
,
249 projects
= [prm
["project"] for prm
in prm_list
]
251 proj
= self
.db
.get_one(
252 self
.projects_collection
, {BaseTopic
.id_field("projects", project
): project
}
254 project_name
= proj
["name"]
255 project_id
= proj
["_id"]
256 if project_name
not in projects
and project_id
not in projects
:
258 "project {} not allowed for this user".format(project
),
259 http_code
=HTTPStatus
.UNAUTHORIZED
,
262 # TODO remove admin, this vill be used by roles RBAC
263 if project_name
== "admin":
266 token_admin
= proj
.get("admin", False)
272 if prm
["project"] in [project_id
, project_name
]:
273 role
= self
.db
.get_one(
274 self
.roles_collection
,
275 {BaseTopic
.id_field("roles", prm
["role"]): prm
["role"]},
281 roles_list
.append({"name": rnm
, "id": rid
})
283 rid
= self
.db
.get_one(self
.roles_collection
, {"name": "project_admin"})[
286 roles_list
= [{"name": "project_admin", "id": rid
}]
290 "expires": now
+ 3600,
293 "project_id": proj
["_id"],
294 "project_name": proj
["name"],
295 "username": user_content
["username"],
296 "user_id": user_content
["_id"],
297 "admin": token_admin
,
301 self
.db
.create(self
.tokens_collection
, new_token
)
302 return deepcopy(new_token
)
304 def get_role_list(self
, filter_q
={}):
308 :return: returns the list of roles.
310 return self
.db
.get_list(self
.roles_collection
, filter_q
)
312 def create_role(self
, role_info
):
316 :param role_info: full role info.
317 :return: returns the role id.
318 :raises AuthconnOperationException: if role creation failed.
320 # TODO: Check that role name does not exist ?
322 role_info
["_id"] = rid
323 rid
= self
.db
.create(self
.roles_collection
, role_info
)
326 def delete_role(self
, role_id
):
330 :param role_id: role identifier.
331 :raises AuthconnOperationException: if role deletion failed.
333 rc
= self
.db
.del_one(self
.roles_collection
, {"_id": role_id
})
334 self
.db
.del_list(self
.tokens_collection
, {"roles.id": role_id
})
337 def update_role(self
, role_info
):
341 :param role_info: full role info.
342 :return: returns the role name and id.
343 :raises AuthconnOperationException: if user creation failed.
345 rid
= role_info
["_id"]
346 self
.db
.set_one(self
.roles_collection
, {"_id": rid
}, role_info
)
347 return {"_id": rid
, "name": role_info
["name"]}
349 def create_user(self
, user_info
):
353 :param user_info: full user info.
354 :return: returns the username and id of the user.
356 BaseTopic
.format_on_new(user_info
, make_public
=False)
358 user_info
["_admin"]["salt"] = salt
360 if not user_info
["username"] == "admin":
361 if self
.config
.get("pwd_expiry_check"):
362 user_info
["_admin"]["modified_time"] = present
363 user_info
["_admin"]["expire_time"] = present
364 if "password" in user_info
:
365 user_info
["password"] = sha256(
366 user_info
["password"].encode("utf-8") + salt
.encode("utf-8")
368 # "projects" are not stored any more
369 if "projects" in user_info
:
370 del user_info
["projects"]
371 self
.db
.create(self
.users_collection
, user_info
)
372 return {"username": user_info
["username"], "_id": user_info
["_id"]}
374 def update_user(self
, user_info
):
376 Change the user name and/or password.
378 :param user_info: user info modifications
380 uid
= user_info
["_id"]
381 old_pwd
= user_info
.get("old_password")
382 user_data
= self
.db
.get_one(
383 self
.users_collection
, {BaseTopic
.id_field("users", uid
): uid
}
386 salt
= user_data
["_admin"]["salt"]
387 shadow_password
= sha256(
388 old_pwd
.encode("utf-8") + salt
.encode("utf-8")
390 if shadow_password
!= user_data
["password"]:
391 raise AuthconnConflictException(
392 "Incorrect password", http_code
=HTTPStatus
.CONFLICT
394 BaseTopic
.format_on_edit(user_data
, user_info
)
396 usnm
= user_info
.get("username")
398 user_data
["username"] = usnm
399 # If password is given and is not already encripted
400 pswd
= user_info
.get("password")
402 len(pswd
) != 64 or not re
.match("[a-fA-F0-9]*", pswd
)
403 ): # TODO: Improve check?
405 if "_admin" not in user_data
:
406 user_data
["_admin"] = {}
407 user_data
["_admin"]["salt"] = salt
408 user_data
["password"] = sha256(
409 pswd
.encode("utf-8") + salt
.encode("utf-8")
411 if not user_data
["username"] == "admin":
412 if self
.config
.get("pwd_expiry_check"):
414 if self
.config
.get("days"):
415 expire
= present
+ 86400 * self
.config
.get("days")
416 user_data
["_admin"]["modified_time"] = present
417 user_data
["_admin"]["expire_time"] = expire
418 # Project-Role Mappings
419 # TODO: Check that user_info NEVER includes "project_role_mappings"
420 if "project_role_mappings" not in user_data
:
421 user_data
["project_role_mappings"] = []
422 for prm
in user_info
.get("add_project_role_mappings", []):
423 user_data
["project_role_mappings"].append(prm
)
424 for prm
in user_info
.get("remove_project_role_mappings", []):
425 for pidf
in ["project", "project_name"]:
426 for ridf
in ["role", "role_name"]:
428 user_data
["project_role_mappings"].remove(
429 {"role": prm
[ridf
], "project": prm
[pidf
]}
435 idf
= BaseTopic
.id_field("users", uid
)
436 self
.db
.set_one(self
.users_collection
, {idf
: uid
}, user_data
)
437 if user_info
.get("remove_project_role_mappings"):
438 idf
= "user_id" if idf
== "_id" else idf
439 self
.db
.del_list(self
.tokens_collection
, {idf
: uid
})
441 def delete_user(self
, user_id
):
445 :param user_id: user identifier.
446 :raises AuthconnOperationException: if user deletion failed.
448 self
.db
.del_one(self
.users_collection
, {"_id": user_id
})
449 self
.db
.del_list(self
.tokens_collection
, {"user_id": user_id
})
452 def get_user_list(self
, filter_q
=None):
456 :param filter_q: dictionary to filter user list by:
457 name (username is also admitted). If a user id is equal to the filter name, it is also provided
459 :return: returns a list of users.
461 filt
= filter_q
or {}
462 if "name" in filt
: # backward compatibility
463 filt
["username"] = filt
.pop("name")
464 if filt
.get("username") and is_valid_uuid(filt
["username"]):
465 # username cannot be a uuid. If this is the case, change from username to _id
466 filt
["_id"] = filt
.pop("username")
467 users
= self
.db
.get_list(self
.users_collection
, filt
)
471 prms
= user
.get("project_role_mappings")
472 projects
= user
.get("projects")
475 # add project_name and role_name. Generate projects for backward compatibility
477 project_id
= prm
["project"]
478 if project_id
not in project_id_name
:
479 pr
= self
.db
.get_one(
480 self
.projects_collection
,
481 {BaseTopic
.id_field("projects", project_id
): project_id
},
484 project_id_name
[project_id
] = pr
["name"] if pr
else None
485 prm
["project_name"] = project_id_name
[project_id
]
486 if prm
["project_name"] not in projects
:
487 projects
.append(prm
["project_name"])
489 role_id
= prm
["role"]
490 if role_id
not in role_id_name
:
491 role
= self
.db
.get_one(
492 self
.roles_collection
,
493 {BaseTopic
.id_field("roles", role_id
): role_id
},
496 role_id_name
[role_id
] = role
["name"] if role
else None
497 prm
["role_name"] = role_id_name
[role_id
]
498 user
["projects"] = projects
# for backward compatibility
500 # user created with an old version. Create a project_role mapping with role project_admin
501 user
["project_role_mappings"] = []
502 role
= self
.db
.get_one(
503 self
.roles_collection
,
504 {BaseTopic
.id_field("roles", "project_admin"): "project_admin"},
506 for p_id_name
in projects
:
507 pr
= self
.db
.get_one(
508 self
.projects_collection
,
509 {BaseTopic
.id_field("projects", p_id_name
): p_id_name
},
512 "project": pr
["_id"],
513 "project_name": pr
["name"],
514 "role_name": "project_admin",
517 user
["project_role_mappings"].append(prm
)
519 user
["projects"] = []
520 user
["project_role_mappings"] = []
524 def get_project_list(self
, filter_q
={}):
528 :return: returns the list of projects.
530 return self
.db
.get_list(self
.projects_collection
, filter_q
)
532 def create_project(self
, project_info
):
536 :param project: full project info.
537 :return: the internal id of the created project
538 :raises AuthconnOperationException: if project creation failed.
540 pid
= self
.db
.create(self
.projects_collection
, project_info
)
543 def delete_project(self
, project_id
):
547 :param project_id: project identifier.
548 :raises AuthconnOperationException: if project deletion failed.
550 idf
= BaseTopic
.id_field("projects", project_id
)
551 r
= self
.db
.del_one(self
.projects_collection
, {idf
: project_id
})
552 idf
= "project_id" if idf
== "_id" else "project_name"
553 self
.db
.del_list(self
.tokens_collection
, {idf
: project_id
})
556 def update_project(self
, project_id
, project_info
):
558 Change the name of a project
560 :param project_id: project to be changed
561 :param project_info: full project info
563 :raises AuthconnOperationException: if project update failed.
566 self
.projects_collection
,
567 {BaseTopic
.id_field("projects", project_id
): project_id
},