diff --git a/frappe/core/doctype/system_settings/system_settings.json b/frappe/core/doctype/system_settings/system_settings.json index 13f4ee83f5..8218d10939 100644 --- a/frappe/core/doctype/system_settings/system_settings.json +++ b/frappe/core/doctype/system_settings/system_settings.json @@ -51,12 +51,15 @@ "column_break_34", "allow_login_after_fail", "two_factor_authentication", + "column_break_odhl", "enable_two_factor_auth", "bypass_2fa_for_retricted_ip_users", "bypass_restrict_ip_check_if_2fa_enabled", + "column_break_bzfr", "two_factor_method", "lifespan_qrcode_image", "otp_issuer_name", + "otp_sms_template", "password_tab", "password_settings", "logout_on_password_reset", @@ -752,12 +755,27 @@ "fieldtype": "Select", "label": "Show External Link Warning", "options": "Never\nAsk\nAlways" + }, + { + "fieldname": "column_break_odhl", + "fieldtype": "Column Break" + }, + { + "depends_on": "eval:doc.enable_two_factor_auth==1 && doc.two_factor_method==\"SMS\"", + "description": "OTP placeholder should be defined as {{ otp }} ", + "fieldname": "otp_sms_template", + "fieldtype": "Small Text", + "label": "OTP SMS Template" + }, + { + "fieldname": "column_break_bzfr", + "fieldtype": "Column Break" } ], "icon": "fa fa-cog", "issingle": 1, "links": [], - "modified": "2025-09-24 16:04:02.016562", + "modified": "2025-11-04 16:47:54.230874", "modified_by": "Administrator", "module": "Core", "name": "System Settings", diff --git a/frappe/core/doctype/system_settings/system_settings.py b/frappe/core/doctype/system_settings/system_settings.py index 9eb5d7f944..93a81d4465 100644 --- a/frappe/core/doctype/system_settings/system_settings.py +++ b/frappe/core/doctype/system_settings/system_settings.py @@ -89,6 +89,7 @@ class SystemSettings(Document): "#,###", ] otp_issuer_name: DF.Data | None + otp_sms_template: DF.SmallText | None password_reset_limit: DF.Int rate_limit_email_link_login: DF.Int reset_password_link_expiry_duration: DF.Duration | None @@ -145,6 +146,7 @@ class SystemSettings(Document): self.validate_user_pass_login() self.validate_backup_limit() self.validate_file_extensions() + self.validate_otp_sms_template() if not self.link_field_results_limit: self.link_field_results_limit = 10 @@ -156,6 +158,17 @@ class SystemSettings(Document): _("{0} can not be more than {1}").format(label, 50), alert=True, indicator="yellow" ) + def validate_otp_sms_template(self): + if not self.enable_two_factor_auth or self.two_factor_method != "SMS" or not self.otp_sms_template: + return + + if "{{otp}}" not in self.otp_sms_template.replace(" ", ""): + frappe.throw( + _("OTP SMS Template must contain {0} placeholder to insert the OTP.").format( + "{{otp}}" + ) + ) + def validate_user_pass_login(self): if not self.disable_user_pass_login: return diff --git a/frappe/twofactor.py b/frappe/twofactor.py index 940e420ad2..adb6a80758 100644 --- a/frappe/twofactor.py +++ b/frappe/twofactor.py @@ -320,7 +320,8 @@ def send_token_via_sms(otpsecret, token=None, phone_no=None): return False hotp = pyotp.HOTP(otpsecret) - args = {ss.message_parameter: f"Your verification code is {hotp.at(int(token))}"} + otp = hotp.at(int(token)) + args = {ss.message_parameter: get_rendered_otp_message(otp)} for d in ss.get("parameters"): args[d.parameter] = d.value @@ -341,6 +342,14 @@ def send_token_via_sms(otpsecret, token=None, phone_no=None): return True +def get_rendered_otp_message(otp: str) -> str: + default_template = "Your verification code is {{otp}}" + custom_template = frappe.get_system_settings("otp_sms_template") + template = custom_template or default_template + + return frappe.render_template(template, {"otp": otp}) + + def send_token_via_email(user, token, otp_secret, otp_issuer, subject=None, message=None): """Send token to user as email.""" user_email = frappe.db.get_value("User", user, "email")