Feature 11034: Forgot Password in OSM 12/14412/9 master
authorjegan <jegan.s@tataelxsi.co.in>
Tue, 4 Jun 2024 12:05:19 +0000 (12:05 +0000)
committergarciadeblas <gerardo.garciadeblas@telefonica.com>
Mon, 11 Nov 2024 08:52:21 +0000 (09:52 +0100)
Change-Id: I7df89b691f994a4bdf089f1a2677ab61f46b6838
Signed-off-by: jegan <jegan.s@tataelxsi.co.in>
MANIFEST.in
osm_nbi/admin_topics.py
osm_nbi/auth.py
osm_nbi/authconn_internal.py
osm_nbi/nbi.cfg
osm_nbi/nbi.py
osm_nbi/templates/email_template.html [new file with mode: 0644]
osm_nbi/validation.py

index 8615938..860cee8 100644 (file)
@@ -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/html_public *
 recursive-include osm_nbi/http *
 recursive-include devops-stages *
+recursive-include osm_nbi/templates *
index 8ca0b2d..0803ad6 100644 (file)
@@ -1036,6 +1036,7 @@ class UserTopicAuth(UserTopic):
                 or indata.get("add_projects")
                 or indata.get("unlock")
                 or indata.get("renew")
                 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 (
             ):
                 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"),
                     "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}
                 }
             )
             data_to_send = {"_id": _id, "changes": indata}
index a30f60c..7da45db 100644 (file)
@@ -544,10 +544,25 @@ class Authenticator:
             raise
 
     def new_token(self, token_info, indata, remote):
             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"):
 
         new_token_info["remote_port"] = remote.port
         if not new_token_info.get("expires"):
index 94e6e47..cf8c55a 100644 (file)
@@ -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_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
 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):
 
 
 class AuthconnInternal(Authconn):
@@ -147,7 +151,7 @@ class AuthconnInternal(Authconn):
                 self.logger.exception(exmsg)
                 raise AuthException(exmsg, http_code=HTTPStatus.UNAUTHORIZED)
 
                 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.
         """
         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"
                             )
                             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")
                 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")
         user = credentials.get("username")
         password = credentials.get("password")
         project = credentials.get("project_id")
+        otp_validation = credentials.get("otp")
 
         # Try using username/password
 
         # 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(
             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")
         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 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 (
         # 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,
         )
             {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"]}
index 977c610..00cb377 100644 (file)
@@ -122,5 +122,13 @@ version: "0"
 deviceVendor: "OSM"
 deviceProduct: "OSM"
 
 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
 [rbac]
 # roles_to_operations: "roles_to_operations.yml"  # initial role generation when database
index 1b03ea5..1c8b035 100644 (file)
@@ -1236,12 +1236,20 @@ class Server(object):
             outdata = token_info = self.authenticator.new_token(
                 token_info, indata, cherrypy.request.remote
             )
             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)
             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
             # password expiry check
-            if self.authenticator.check_password_expiry(outdata):
+            elif self.authenticator.check_password_expiry(outdata):
                 outdata = {
                     "id": outdata["id"],
                     "message": "change_password",
                 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_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("_")
         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 (file)
index 0000000..7245c04
--- /dev/null
@@ -0,0 +1,32 @@
+<!-- # -*- coding: utf-8 -*-
+
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+# implied.
+# See the License for the specific language governing permissions and
+# limitations under the License. -->
+
+<!DOCTYPE html>
+<html lang="en">
+<body style="font-family: 'Lato', sans-serif;background-color: white;font-size: 25px;max-width: fit-content; margin: 0 auto;padding: 2%;color: black;margin: 10;border: 10;padding: 10;">
+    <div class="wrapper" style="box-shadow: 0 0 10px #666;">
+        <div class="one-col">
+            <h2 style="text-align: center;color: black;">OSM Password Reset Requested</h2>
+            <p style="line-height: 30px; padding-bottom: 20px; padding: 10px;color: black;" class="username">Hi {username},</p>
+            <p style="line-height: 30px; padding-bottom: 20px; padding: 10px;color: black;" class="content">A password reset request was made for your account. If you did not make this request, please contact your system administrator immediately.</p>
+        </div>
+        <div class="sec-col">
+            <h3 style="text-align: center;color: black;">OTP Generated</h3>
+            <h3 style="text-align: center;color: black;" class="otp"><span style="background-color: slategray;color: white;padding: 10px 10px;text-align: center;border-radius: 5px;">{otp}</span></h4>
+            <p style="text-align: center;color: black;">OTP is valid for {validity} minutes</p>
+        </div>
+    </div>
+</body>
+</html>
index a1911dd..e2923a4 100644 (file)
@@ -46,6 +46,12 @@ name_schema = {
     "pattern": "^[^,;()'\"]+$",
 }
 string_schema = {"type": "string", "minLength": 1, "maxLength": 255}
     "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,
 xml_text_schema = {
     "type": "string",
     "minLength": 1,
@@ -1247,6 +1253,7 @@ user_new_schema = {
     "type": "object",
     "properties": {
         "username": string_schema,
     "type": "object",
     "properties": {
         "username": string_schema,
+        "email_id": email_schema,
         "domain_name": shortname_schema,
         "password": user_passwd_schema,
         "projects": nameshort_list_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,
     "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]},
         "old_password": passwd_schema,
         "username": string_schema,  # To allow User Name modification
         "projects": {"oneOf": [nameshort_list_schema, array_edition_schema]},