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
.utils
import cef_event
, cef_event_builder
44 from osm_nbi
.validation
import is_valid_uuid
45 from time
import time
, sleep
46 from http
import HTTPStatus
47 from uuid
import uuid4
48 from hashlib
import sha256
49 from copy
import deepcopy
50 from random
import choice
as random_choice
53 class AuthconnInternal(Authconn
):
54 token_time_window
= 2 # seconds
55 token_delay
= 1 # seconds to wait upon second request within time window
57 users_collection
= "users"
58 roles_collection
= "roles"
59 projects_collection
= "projects"
60 tokens_collection
= "tokens"
62 def __init__(self
, config
, db
, role_permissions
):
63 Authconn
.__init
__(self
, config
, db
, role_permissions
)
64 self
.logger
= logging
.getLogger("nbi.authenticator.internal")
68 # self.token_cache = token_cache
72 self
.cef_logger
= cef_event_builder(config
)
74 def validate_token(self
, token
):
76 Check if the token is valid.
78 :param token: token to validate
79 :return: dictionary with information associated with the token:
81 "project_id": project id
82 "project_name": project name
85 "roles": list with dict containing {name, id}
86 "expires": expiration date
87 If the token is not valid an exception is raised.
93 "Needed a token or Authorization HTTP header",
94 http_code
=HTTPStatus
.UNAUTHORIZED
,
99 # get from database if not in cache
101 token_info
= self
.db
.get_one(self
.tokens_collection
, {"_id": token
})
102 if token_info
["expires"] < now
:
104 "Expired Token or Authorization HTTP header",
105 http_code
=HTTPStatus
.UNAUTHORIZED
,
110 except DbException
as e
:
111 if e
.http_code
== HTTPStatus
.NOT_FOUND
:
113 "Invalid Token or Authorization HTTP header",
114 http_code
=HTTPStatus
.UNAUTHORIZED
,
118 except AuthException
:
121 self
.logger
.exception(
122 "Error during token validation using internal backend"
125 "Error during token validation using internal backend",
126 http_code
=HTTPStatus
.UNAUTHORIZED
,
129 def revoke_token(self
, token
):
133 :param token: token to be revoked
136 # self.token_cache.pop(token, None)
137 self
.db
.del_one(self
.tokens_collection
, {"_id": token
})
139 except DbException
as e
:
140 if e
.http_code
== HTTPStatus
.NOT_FOUND
:
142 "Token '{}' not found".format(token
), http_code
=HTTPStatus
.NOT_FOUND
146 exmsg
= "Error during token revocation using internal backend"
147 self
.logger
.exception(exmsg
)
148 raise AuthException(exmsg
, http_code
=HTTPStatus
.UNAUTHORIZED
)
150 def validate_user(self
, user
, password
):
152 Validate username and password via appropriate backend.
153 :param user: username of the user.
154 :param password: password to be validated.
156 user_rows
= self
.db
.get_list(
157 self
.users_collection
, {BaseTopic
.id_field("users", user
): user
}
161 user_content
= user_rows
[0]
162 salt
= user_content
["_admin"]["salt"]
163 shadow_password
= sha256(
164 password
.encode("utf-8") + salt
.encode("utf-8")
166 if shadow_password
!= user_content
["password"]:
170 def authenticate(self
, credentials
, token_info
=None):
172 Authenticate a user using username/password or previous token_info plus project; its creates a new token
174 :param credentials: dictionary that contains:
175 username: name, id or None
176 password: password or None
177 project_id: name, id, or None. If None first found project will be used to get an scope token
178 other items are allowed and ignored
179 :param token_info: previous token_info to obtain authorization
180 :return: the scoped token info or raises an exception. The token is a dictionary with:
181 _id: token string id,
183 project_id: scoped_token project_id,
184 project_name: scoped_token project_name,
185 expires: epoch time when it expires,
190 user
= credentials
.get("username")
191 password
= credentials
.get("password")
192 project
= credentials
.get("project_id")
194 # Try using username/password
196 user_content
= self
.validate_user(user
, password
)
201 "name": "User login",
202 "sourceUserName": user
,
203 "message": "Invalid username/password Project={} Outcome=Failure".format(
209 self
.logger
.exception("{}".format(self
.cef_logger
))
211 "Invalid username/password", http_code
=HTTPStatus
.UNAUTHORIZED
213 if not user_content
.get("_admin", None):
215 "No default project for this user.",
216 http_code
=HTTPStatus
.UNAUTHORIZED
,
219 user_rows
= self
.db
.get_list(
220 self
.users_collection
, {"username": token_info
["username"]}
223 user_content
= user_rows
[0]
225 raise AuthException("Invalid token", http_code
=HTTPStatus
.UNAUTHORIZED
)
228 "Provide credentials: username/password or Authorization Bearer token",
229 http_code
=HTTPStatus
.UNAUTHORIZED
,
231 # Delay upon second request within time window
233 now
- user_content
["_admin"].get("last_token_time", 0)
234 < self
.token_time_window
236 sleep(self
.token_delay
)
237 # user_content["_admin"]["last_token_time"] = now
238 # self.db.replace("users", user_content["_id"], user_content) # might cause race conditions
240 self
.users_collection
,
241 {"_id": user_content
["_id"]},
242 {"_admin.last_token_time": now
},
247 "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
249 for _
in range(0, 32)
252 # projects = user_content.get("projects", [])
253 prm_list
= user_content
.get("project_role_mappings", [])
256 project
= prm_list
[0]["project"] if prm_list
else None
259 "can't find a default project for this user",
260 http_code
=HTTPStatus
.UNAUTHORIZED
,
263 projects
= [prm
["project"] for prm
in prm_list
]
265 proj
= self
.db
.get_one(
266 self
.projects_collection
, {BaseTopic
.id_field("projects", project
): project
}
268 project_name
= proj
["name"]
269 project_id
= proj
["_id"]
270 if project_name
not in projects
and project_id
not in projects
:
272 "project {} not allowed for this user".format(project
),
273 http_code
=HTTPStatus
.UNAUTHORIZED
,
276 # TODO remove admin, this vill be used by roles RBAC
277 if project_name
== "admin":
280 token_admin
= proj
.get("admin", False)
286 if prm
["project"] in [project_id
, project_name
]:
287 role
= self
.db
.get_one(
288 self
.roles_collection
,
289 {BaseTopic
.id_field("roles", prm
["role"]): prm
["role"]},
295 roles_list
.append({"name": rnm
, "id": rid
})
297 rid
= self
.db
.get_one(self
.roles_collection
, {"name": "project_admin"})[
300 roles_list
= [{"name": "project_admin", "id": rid
}]
304 "expires": now
+ 3600,
307 "project_id": proj
["_id"],
308 "project_name": proj
["name"],
309 "username": user_content
["username"],
310 "user_id": user_content
["_id"],
311 "admin": token_admin
,
315 self
.db
.create(self
.tokens_collection
, new_token
)
316 return deepcopy(new_token
)
318 def get_role_list(self
, filter_q
={}):
322 :return: returns the list of roles.
324 return self
.db
.get_list(self
.roles_collection
, filter_q
)
326 def create_role(self
, role_info
):
330 :param role_info: full role info.
331 :return: returns the role id.
332 :raises AuthconnOperationException: if role creation failed.
334 # TODO: Check that role name does not exist ?
336 role_info
["_id"] = rid
337 rid
= self
.db
.create(self
.roles_collection
, role_info
)
340 def delete_role(self
, role_id
):
344 :param role_id: role identifier.
345 :raises AuthconnOperationException: if role deletion failed.
347 rc
= self
.db
.del_one(self
.roles_collection
, {"_id": role_id
})
348 self
.db
.del_list(self
.tokens_collection
, {"roles.id": role_id
})
351 def update_role(self
, role_info
):
355 :param role_info: full role info.
356 :return: returns the role name and id.
357 :raises AuthconnOperationException: if user creation failed.
359 rid
= role_info
["_id"]
360 self
.db
.set_one(self
.roles_collection
, {"_id": rid
}, role_info
)
361 return {"_id": rid
, "name": role_info
["name"]}
363 def create_user(self
, user_info
):
367 :param user_info: full user info.
368 :return: returns the username and id of the user.
370 BaseTopic
.format_on_new(user_info
, make_public
=False)
372 user_info
["_admin"]["salt"] = salt
374 if not user_info
["username"] == "admin":
375 if self
.config
.get("pwd_expiry_check"):
376 user_info
["_admin"]["modified_time"] = present
377 user_info
["_admin"]["expire_time"] = present
378 if "password" in user_info
:
379 user_info
["password"] = sha256(
380 user_info
["password"].encode("utf-8") + salt
.encode("utf-8")
382 # "projects" are not stored any more
383 if "projects" in user_info
:
384 del user_info
["projects"]
385 self
.db
.create(self
.users_collection
, user_info
)
386 return {"username": user_info
["username"], "_id": user_info
["_id"]}
388 def update_user(self
, user_info
):
390 Change the user name and/or password.
392 :param user_info: user info modifications
394 uid
= user_info
["_id"]
395 old_pwd
= user_info
.get("old_password")
396 user_data
= self
.db
.get_one(
397 self
.users_collection
, {BaseTopic
.id_field("users", uid
): uid
}
400 salt
= user_data
["_admin"]["salt"]
401 shadow_password
= sha256(
402 old_pwd
.encode("utf-8") + salt
.encode("utf-8")
404 if shadow_password
!= user_data
["password"]:
405 raise AuthconnConflictException(
406 "Incorrect password", http_code
=HTTPStatus
.CONFLICT
408 BaseTopic
.format_on_edit(user_data
, user_info
)
410 usnm
= user_info
.get("username")
412 user_data
["username"] = usnm
413 # If password is given and is not already encripted
414 pswd
= user_info
.get("password")
416 len(pswd
) != 64 or not re
.match("[a-fA-F0-9]*", pswd
)
417 ): # TODO: Improve check?
421 "name": "Change Password",
422 "sourceUserName": user_data
["username"],
423 "message": "Changing Password for user, Outcome=Success",
427 self
.logger
.info("{}".format(self
.cef_logger
))
429 if "_admin" not in user_data
:
430 user_data
["_admin"] = {}
431 user_data
["_admin"]["salt"] = salt
432 user_data
["password"] = sha256(
433 pswd
.encode("utf-8") + salt
.encode("utf-8")
435 if not user_data
["username"] == "admin":
436 if self
.config
.get("pwd_expiry_check"):
438 if self
.config
.get("days"):
439 expire
= present
+ 86400 * self
.config
.get("days")
440 user_data
["_admin"]["modified_time"] = present
441 user_data
["_admin"]["expire_time"] = expire
442 # Project-Role Mappings
443 # TODO: Check that user_info NEVER includes "project_role_mappings"
444 if "project_role_mappings" not in user_data
:
445 user_data
["project_role_mappings"] = []
446 for prm
in user_info
.get("add_project_role_mappings", []):
447 user_data
["project_role_mappings"].append(prm
)
448 for prm
in user_info
.get("remove_project_role_mappings", []):
449 for pidf
in ["project", "project_name"]:
450 for ridf
in ["role", "role_name"]:
452 user_data
["project_role_mappings"].remove(
453 {"role": prm
[ridf
], "project": prm
[pidf
]}
459 idf
= BaseTopic
.id_field("users", uid
)
460 self
.db
.set_one(self
.users_collection
, {idf
: uid
}, user_data
)
461 if user_info
.get("remove_project_role_mappings"):
462 idf
= "user_id" if idf
== "_id" else idf
463 self
.db
.del_list(self
.tokens_collection
, {idf
: uid
})
465 def delete_user(self
, user_id
):
469 :param user_id: user identifier.
470 :raises AuthconnOperationException: if user deletion failed.
472 self
.db
.del_one(self
.users_collection
, {"_id": user_id
})
473 self
.db
.del_list(self
.tokens_collection
, {"user_id": user_id
})
476 def get_user_list(self
, filter_q
=None):
480 :param filter_q: dictionary to filter user list by:
481 name (username is also admitted). If a user id is equal to the filter name, it is also provided
483 :return: returns a list of users.
485 filt
= filter_q
or {}
486 if "name" in filt
: # backward compatibility
487 filt
["username"] = filt
.pop("name")
488 if filt
.get("username") and is_valid_uuid(filt
["username"]):
489 # username cannot be a uuid. If this is the case, change from username to _id
490 filt
["_id"] = filt
.pop("username")
491 users
= self
.db
.get_list(self
.users_collection
, filt
)
495 prms
= user
.get("project_role_mappings")
496 projects
= user
.get("projects")
499 # add project_name and role_name. Generate projects for backward compatibility
501 project_id
= prm
["project"]
502 if project_id
not in project_id_name
:
503 pr
= self
.db
.get_one(
504 self
.projects_collection
,
505 {BaseTopic
.id_field("projects", project_id
): project_id
},
508 project_id_name
[project_id
] = pr
["name"] if pr
else None
509 prm
["project_name"] = project_id_name
[project_id
]
510 if prm
["project_name"] not in projects
:
511 projects
.append(prm
["project_name"])
513 role_id
= prm
["role"]
514 if role_id
not in role_id_name
:
515 role
= self
.db
.get_one(
516 self
.roles_collection
,
517 {BaseTopic
.id_field("roles", role_id
): role_id
},
520 role_id_name
[role_id
] = role
["name"] if role
else None
521 prm
["role_name"] = role_id_name
[role_id
]
522 user
["projects"] = projects
# for backward compatibility
524 # user created with an old version. Create a project_role mapping with role project_admin
525 user
["project_role_mappings"] = []
526 role
= self
.db
.get_one(
527 self
.roles_collection
,
528 {BaseTopic
.id_field("roles", "project_admin"): "project_admin"},
530 for p_id_name
in projects
:
531 pr
= self
.db
.get_one(
532 self
.projects_collection
,
533 {BaseTopic
.id_field("projects", p_id_name
): p_id_name
},
536 "project": pr
["_id"],
537 "project_name": pr
["name"],
538 "role_name": "project_admin",
541 user
["project_role_mappings"].append(prm
)
543 user
["projects"] = []
544 user
["project_role_mappings"] = []
548 def get_project_list(self
, filter_q
={}):
552 :return: returns the list of projects.
554 return self
.db
.get_list(self
.projects_collection
, filter_q
)
556 def create_project(self
, project_info
):
560 :param project: full project info.
561 :return: the internal id of the created project
562 :raises AuthconnOperationException: if project creation failed.
564 pid
= self
.db
.create(self
.projects_collection
, project_info
)
567 def delete_project(self
, project_id
):
571 :param project_id: project identifier.
572 :raises AuthconnOperationException: if project deletion failed.
574 idf
= BaseTopic
.id_field("projects", project_id
)
575 r
= self
.db
.del_one(self
.projects_collection
, {idf
: project_id
})
576 idf
= "project_id" if idf
== "_id" else "project_name"
577 self
.db
.del_list(self
.tokens_collection
, {idf
: project_id
})
580 def update_project(self
, project_id
, project_info
):
582 Change the name of a project
584 :param project_id: project to be changed
585 :param project_info: full project info
587 :raises AuthconnOperationException: if project update failed.
590 self
.projects_collection
,
591 {BaseTopic
.id_field("projects", project_id
): project_id
},