From be1a3df68cdeb1cd4b932f3d8b70e4d45f7bbc2a Mon Sep 17 00:00:00 2001 From: jegan Date: Tue, 4 Jun 2024 12:05:19 +0000 Subject: [PATCH] Feature 11034: Forgot Password in OSM Change-Id: I7df89b691f994a4bdf089f1a2677ab61f46b6838 Signed-off-by: jegan --- MANIFEST.in | 1 + osm_nbi/admin_topics.py | 2 + osm_nbi/auth.py | 23 +++- osm_nbi/authconn_internal.py | 145 +++++++++++++++++++++++++- osm_nbi/nbi.cfg | 8 ++ osm_nbi/nbi.py | 26 ++++- osm_nbi/templates/email_template.html | 32 ++++++ osm_nbi/validation.py | 8 ++ 8 files changed, 236 insertions(+), 9 deletions(-) create mode 100644 osm_nbi/templates/email_template.html diff --git a/MANIFEST.in b/MANIFEST.in index 8615938..860cee8 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -21,3 +21,4 @@ recursive-include osm_nbi *.py *.sh *.cfg *.yml *.txt recursive-include osm_nbi/html_public * recursive-include osm_nbi/http * recursive-include devops-stages * +recursive-include osm_nbi/templates * diff --git a/osm_nbi/admin_topics.py b/osm_nbi/admin_topics.py index 8ca0b2d..0803ad6 100644 --- a/osm_nbi/admin_topics.py +++ b/osm_nbi/admin_topics.py @@ -1036,6 +1036,7 @@ class UserTopicAuth(UserTopic): or indata.get("add_projects") or indata.get("unlock") or indata.get("renew") + or indata.get("email_id") ): return _id if indata.get("project_role_mappings") and ( @@ -1201,6 +1202,7 @@ class UserTopicAuth(UserTopic): "unlock": indata.get("unlock"), "renew": indata.get("renew"), "session_user": session.get("username"), + "email_id": indata.get("email_id"), } ) data_to_send = {"_id": _id, "changes": indata} diff --git a/osm_nbi/auth.py b/osm_nbi/auth.py index a30f60c..7da45db 100644 --- a/osm_nbi/auth.py +++ b/osm_nbi/auth.py @@ -544,10 +544,25 @@ class Authenticator: raise def new_token(self, token_info, indata, remote): - new_token_info = self.backend.authenticate( - credentials=indata, - token_info=token_info, - ) + if indata.get("email_id"): + return self.backend.send_email(indata) + else: + if indata.get("otp"): + otp_validation = self.backend.validate_otp(indata) + if otp_validation.get("password_change"): + new_token_info = self.backend.authenticate( + credentials=indata, + token_info=token_info, + ) + new_token_info["otp"] = "valid" + else: + otp_validation["otp"] = "invalid" + return otp_validation + else: + new_token_info = self.backend.authenticate( + credentials=indata, + token_info=token_info, + ) new_token_info["remote_port"] = remote.port if not new_token_info.get("expires"): diff --git a/osm_nbi/authconn_internal.py b/osm_nbi/authconn_internal.py index 94e6e47..cf8c55a 100644 --- a/osm_nbi/authconn_internal.py +++ b/osm_nbi/authconn_internal.py @@ -41,13 +41,17 @@ from osm_nbi.authconn import ( from osm_common.dbbase import DbException from osm_nbi.base_topic import BaseTopic from osm_nbi.utils import cef_event, cef_event_builder -from osm_nbi.validation import is_valid_uuid +from osm_nbi.validation import is_valid_uuid, email_schema from time import time, sleep from http import HTTPStatus from uuid import uuid4 from hashlib import sha256 from copy import deepcopy from random import choice as random_choice +import smtplib +from email.message import EmailMessage +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart class AuthconnInternal(Authconn): @@ -147,7 +151,7 @@ class AuthconnInternal(Authconn): self.logger.exception(exmsg) raise AuthException(exmsg, http_code=HTTPStatus.UNAUTHORIZED) - def validate_user(self, user, password): + def validate_user(self, user, password, otp=None): """ Validate username and password via appropriate backend. :param user: username of the user. @@ -246,7 +250,8 @@ class AuthconnInternal(Authconn): raise AuthException( "Failed to login as the account is expired" ) - + if otp: + return user_content salt = user_content["_admin"]["salt"] shadow_password = sha256( password.encode("utf-8") + salt.encode("utf-8") @@ -309,9 +314,12 @@ class AuthconnInternal(Authconn): user = credentials.get("username") password = credentials.get("password") project = credentials.get("project_id") + otp_validation = credentials.get("otp") # Try using username/password - if user: + if otp_validation: + user_content = self.validate_user(user, password=None, otp=otp_validation) + elif user: user_content = self.validate_user(user, password) if not user_content: cef_event( @@ -657,8 +665,11 @@ class AuthconnInternal(Authconn): BaseTopic.format_on_edit(user_data, user_info) # User Name usnm = user_info.get("username") + email_id = user_info.get("email_id") if usnm: user_data["username"] = usnm + if email_id: + user_data["email_id"] = email_id # If password is given and is not already encripted pswd = user_info.get("password") if pswd and ( @@ -858,3 +869,129 @@ class AuthconnInternal(Authconn): {BaseTopic.id_field("projects", project_id): project_id}, project_info, ) + + def generate_otp(self): + otp = "".join(random_choice("0123456789") for i in range(0, 4)) + return otp + + def send_email(self, indata): + user = indata.get("username") + user_rows = self.db.get_list(self.users_collection, {"username": user}) + sender_password = None + otp_expiry_time = self.config.get("otp_expiry_time", 300) + if not re.match(email_schema["pattern"], indata.get("email_id")): + raise AuthException( + "Invalid email-id", + http_code=HTTPStatus.BAD_REQUEST, + ) + if self.config.get("sender_email"): + sender_email = self.config["sender_email"] + else: + raise AuthException( + "sender_email not found", + http_code=HTTPStatus.NOT_FOUND, + ) + if self.config.get("smtp_server"): + smtp_server = self.config["smtp_server"] + else: + raise AuthException( + "smtp server not found", + http_code=HTTPStatus.NOT_FOUND, + ) + if self.config.get("smtp_port"): + smtp_port = self.config["smtp_port"] + else: + raise AuthException( + "smtp port not found", + http_code=HTTPStatus.NOT_FOUND, + ) + sender_password = self.config.get("sender_password") or None + if user_rows: + user_data = user_rows[0] + user_status = user_data["_admin"]["user_status"] + if not user_data.get("project_role_mappings", None): + raise AuthException( + "can't find a default project for this user", + http_code=HTTPStatus.UNAUTHORIZED, + ) + if user_status != "active" and user_status != "always-active": + raise AuthException( + f"User account is {user_status}.Please contact the system administrator.", + http_code=HTTPStatus.UNAUTHORIZED, + ) + if user_data.get("email_id"): + if user_data["email_id"] == indata.get("email_id"): + otp = self.generate_otp() + encode_otp = ( + sha256( + otp.encode("utf-8") + + user_data["_admin"]["salt"].encode("utf-8") + ) + ).hexdigest() + otp_field = {encode_otp: time() + otp_expiry_time, "retries": 0} + user_data["OTP"] = otp_field + uid = user_data["_id"] + idf = BaseTopic.id_field("users", uid) + reciever_email = user_data["email_id"] + email_template_path = self.config.get("email_template") + with open(email_template_path, "r") as et: + email_template = et.read() + msg = EmailMessage() + msg = MIMEMultipart("alternative") + html_content = email_template.format( + username=user_data["username"], + otp=otp, + validity=otp_expiry_time // 60, + ) + html = MIMEText(html_content, "html") + msg["Subject"] = "OSM password reset request" + msg.attach(html) + with smtplib.SMTP(smtp_server, smtp_port) as smtp: + smtp.starttls() + if sender_password: + smtp.login(sender_email, sender_password) + smtp.sendmail(sender_email, reciever_email, msg.as_string()) + self.db.set_one(self.users_collection, {idf: uid}, user_data) + return {"email": "sent"} + else: + raise AuthException( + "No email id is registered for this user.Please contact the system administrator.", + http_code=HTTPStatus.NOT_FOUND, + ) + else: + raise AuthException( + "user not found", + http_code=HTTPStatus.NOT_FOUND, + ) + + def validate_otp(self, indata): + otp = indata.get("otp") + user = indata.get("username") + user_rows = self.db.get_list(self.users_collection, {"username": user}) + user_data = user_rows[0] + uid = user_data["_id"] + idf = BaseTopic.id_field("users", uid) + retry_count = self.config.get("retry_count", 3) + if user_data: + salt = user_data["_admin"]["salt"] + actual_otp = sha256(otp.encode("utf-8") + salt.encode("utf-8")).hexdigest() + if not user_data.get("OTP"): + otp_field = {"retries": 1} + user_data["OTP"] = otp_field + self.db.set_one(self.users_collection, {idf: uid}, user_data) + return {"retries": user_data["OTP"]["retries"]} + for key, value in user_data["OTP"].items(): + curr_time = time() + if key == actual_otp and curr_time < value: + user_data["OTP"] = {} + self.db.set_one(self.users_collection, {idf: uid}, user_data) + return {"valid": "True", "password_change": "True"} + else: + user_data["OTP"]["retries"] += 1 + self.db.set_one(self.users_collection, {idf: uid}, user_data) + if user_data["OTP"].get("retries") >= retry_count: + raise AuthException( + "Invalid OTP. Maximum retries exceeded", + http_code=HTTPStatus.TOO_MANY_REQUESTS, + ) + return {"retry_count": user_data["OTP"]["retries"]} diff --git a/osm_nbi/nbi.cfg b/osm_nbi/nbi.cfg index 977c610..00cb377 100644 --- a/osm_nbi/nbi.cfg +++ b/osm_nbi/nbi.cfg @@ -122,5 +122,13 @@ version: "0" deviceVendor: "OSM" deviceProduct: "OSM" +# SMTP Configuration +# smtp_server: "" +# smtp_port: +# sender_email: "" +# otp_retry_count: 3 #Default value +# otp_expiry_time: 300 #Default value +email_template = "/app/osm_nbi/templates/email_template.html" + [rbac] # roles_to_operations: "roles_to_operations.yml" # initial role generation when database diff --git a/osm_nbi/nbi.py b/osm_nbi/nbi.py index 1b03ea5..1c8b035 100644 --- a/osm_nbi/nbi.py +++ b/osm_nbi/nbi.py @@ -1236,12 +1236,20 @@ class Server(object): outdata = token_info = self.authenticator.new_token( token_info, indata, cherrypy.request.remote ) + if outdata.get("email") or outdata.get("otp") == "invalid": + return self._format_out(outdata, token_info) cherrypy.session["Authorization"] = outdata["_id"] # pylint: disable=E1101 self._set_location_header("admin", "v1", "tokens", outdata["_id"]) # for logging self._format_login(token_info) + if outdata.get("otp") == "valid": + outdata = { + "id": outdata["id"], + "message": "valid_otp", + "user_id": outdata["user_id"], + } # password expiry check - if self.authenticator.check_password_expiry(outdata): + elif self.authenticator.check_password_expiry(outdata): outdata = { "id": outdata["id"], "message": "change_password", @@ -2407,6 +2415,22 @@ def _start_service(): elif k == "OSMNBI_ACCOUNT_EXPIRE_DAYS": account_expire_days = int(v) engine_config["authentication"]["account_expire_days"] = account_expire_days + elif k == "OSMNBI_SMTP_SERVER": + engine_config["authentication"]["smtp_server"] = v + engine_config["authentication"]["all"] = environ + elif k == "OSMNBI_SMTP_PORT": + port = int(v) + engine_config["authentication"]["smtp_port"] = port + elif k == "OSMNBI_SENDER_EMAIL": + engine_config["authentication"]["sender_email"] = v + elif k == "OSMNBI_EMAIL_PASSWORD": + engine_config["authentication"]["sender_password"] = v + elif k == "OSMNBI_OTP_RETRY_COUNT": + otp_retry_count = int(v) + engine_config["authentication"]["retry_count"] = otp_retry_count + elif k == "OSMNBI_OTP_EXPIRY_TIME": + otp_expiry_time = int(v) + engine_config["authentication"]["otp_expiry_time"] = otp_expiry_time if not k.startswith("OSMNBI_"): continue k1, _, k2 = k[7:].lower().partition("_") diff --git a/osm_nbi/templates/email_template.html b/osm_nbi/templates/email_template.html new file mode 100644 index 0000000..7245c04 --- /dev/null +++ b/osm_nbi/templates/email_template.html @@ -0,0 +1,32 @@ + + + + + +
+
+

OSM Password Reset Requested

+

Hi {username},

+

A password reset request was made for your account. If you did not make this request, please contact your system administrator immediately.

+
+
+

OTP Generated

+

{otp}

+

OTP is valid for {validity} minutes

+
+
+ + diff --git a/osm_nbi/validation.py b/osm_nbi/validation.py index a1911dd..e2923a4 100644 --- a/osm_nbi/validation.py +++ b/osm_nbi/validation.py @@ -46,6 +46,12 @@ name_schema = { "pattern": "^[^,;()'\"]+$", } string_schema = {"type": "string", "minLength": 1, "maxLength": 255} +email_schema = { + "type": "string", + "minLength": 1, + "maxLength": 320, + "pattern": "^[a-zA-Z0-9+_.-]+@[a-zA-Z0-9.-]+$", +} xml_text_schema = { "type": "string", "minLength": 1, @@ -1247,6 +1253,7 @@ user_new_schema = { "type": "object", "properties": { "username": string_schema, + "email_id": email_schema, "domain_name": shortname_schema, "password": user_passwd_schema, "projects": nameshort_list_schema, @@ -1261,6 +1268,7 @@ user_edit_schema = { "type": "object", "properties": { "password": user_passwd_schema, + "email_id": email_schema, "old_password": passwd_schema, "username": string_schema, # To allow User Name modification "projects": {"oneOf": [nameshort_list_schema, array_edition_schema]}, -- 2.25.1