diff --git a/apps/authentication/mfa/__init__.py b/apps/authentication/mfa/__init__.py
index 3aa3b34a1..57bf61e58 100644
--- a/apps/authentication/mfa/__init__.py
+++ b/apps/authentication/mfa/__init__.py
@@ -3,3 +3,4 @@ from .face import MFAFace
from .otp import MFAOtp, otp_failed_msg
from .radius import MFARadius
from .sms import MFASms
+from .email import MFAEmail
diff --git a/apps/authentication/mfa/email.py b/apps/authentication/mfa/email.py
new file mode 100644
index 000000000..dcf72d4d7
--- /dev/null
+++ b/apps/authentication/mfa/email.py
@@ -0,0 +1,68 @@
+from django.conf import settings
+from django.template.loader import render_to_string
+from django.utils.translation import gettext_lazy as _
+
+from common.utils import random_string
+from common.utils.verify_code import SendAndVerifyCodeUtil
+from settings.utils import get_login_title
+from .base import BaseMFA
+
+email_failed_msg = _("Email verify code invalid")
+
+
+class MFAEmail(BaseMFA):
+ name = 'email'
+ display_name = _('Email')
+ placeholder = _('Email verification code')
+
+ def check_code(self, code):
+ assert self.is_authenticated()
+ sender_util = SendAndVerifyCodeUtil(self.user.email, backend=self.name)
+ ok = sender_util.verify(code)
+ msg = '' if ok else email_failed_msg
+ return ok, msg
+
+ def is_active(self):
+ if not self.is_authenticated():
+ return True
+ return self.user.email
+
+ @staticmethod
+ def challenge_required():
+ return True
+
+ def send_challenge(self):
+ code = random_string(settings.SMS_CODE_LENGTH, lower=False, upper=False)
+ subject = '%s: %s' % (get_login_title(), _('MFA code'))
+ context = {
+ 'user': self.user, 'title': subject, 'code': code,
+ }
+ message = render_to_string('authentication/_msg_mfa_email_code.html', context)
+ content = {'subject': subject, 'message': message}
+ sender_util = SendAndVerifyCodeUtil(
+ self.user.email, code=code, backend=self.name, timeout=60, **content
+ )
+ sender_util.gen_and_send_async()
+
+ @staticmethod
+ def global_enabled():
+ return settings.SECURITY_MFA_BY_EMAIL
+
+ def disable(self):
+ return '/ui/#/profile/index'
+
+ def get_enable_url(self) -> str:
+ return ''
+
+ def can_disable(self) -> bool:
+ return False
+
+ def get_disable_url(self):
+ return ''
+
+ @staticmethod
+ def help_text_of_enable():
+ return ''
+
+ def help_text_of_disable(self):
+ return ''
diff --git a/apps/authentication/templates/authentication/_msg_mfa_email_code.html b/apps/authentication/templates/authentication/_msg_mfa_email_code.html
new file mode 100644
index 000000000..18abd728d
--- /dev/null
+++ b/apps/authentication/templates/authentication/_msg_mfa_email_code.html
@@ -0,0 +1,18 @@
+{% load i18n %}
+
+
+
+
+ {{ title }} |
+
+
+ {% trans 'Hello' %} {{ user.name }}, |
+
+
+ {% trans 'MFA code' %}: {{ code }} |
+
+
+ {% trans 'The validity period of the verification code is one minute' %} |
+
+
+
diff --git a/apps/i18n/core/zh/LC_MESSAGES/django.po b/apps/i18n/core/zh/LC_MESSAGES/django.po
index 8baac982b..bd2629729 100644
--- a/apps/i18n/core/zh/LC_MESSAGES/django.po
+++ b/apps/i18n/core/zh/LC_MESSAGES/django.po
@@ -7473,6 +7473,14 @@ msgid ""
"account password"
msgstr "单位:秒,目前仅在查看账号密码校验 MFA 时生效"
+#: settings/serializers/security.py:129
+msgid "MFA via Email"
+msgstr "邮件验证 MFA"
+
+#: settings/serializers/security.py:130
+msgid "Email as a method for multi-factor authentication"
+msgstr "将电子邮件作为多因子认证的一种方式"
+
#: settings/serializers/security.py:143
msgid "MFA in login page"
msgstr "MFA 在登录页面输入"
diff --git a/apps/jumpserver/conf.py b/apps/jumpserver/conf.py
index 4da1bb28a..255b7ea10 100644
--- a/apps/jumpserver/conf.py
+++ b/apps/jumpserver/conf.py
@@ -564,6 +564,7 @@ class Config(dict):
# 安全配置
'SECURITY_MFA_AUTH': 0, # 0 不开启 1 全局开启 2 管理员开启
'SECURITY_MFA_AUTH_ENABLED_FOR_THIRD_PARTY': True,
+ 'SECURITY_MFA_BY_EMAIL': False,
'SECURITY_COMMAND_EXECUTION': False,
'SECURITY_COMMAND_BLACKLIST': [
'reboot', 'shutdown', 'poweroff', 'halt', 'dd', 'half', 'top'
diff --git a/apps/jumpserver/settings/auth.py b/apps/jumpserver/settings/auth.py
index e69471dd4..5dc636bbf 100644
--- a/apps/jumpserver/settings/auth.py
+++ b/apps/jumpserver/settings/auth.py
@@ -325,9 +325,10 @@ MFA_BACKEND_OTP = 'authentication.mfa.otp.MFAOtp'
MFA_BACKEND_FACE = 'authentication.mfa.face.MFAFace'
MFA_BACKEND_RADIUS = 'authentication.mfa.radius.MFARadius'
MFA_BACKEND_SMS = 'authentication.mfa.sms.MFASms'
+MFA_BACKEND_EMAIL = 'authentication.mfa.email.MFAEmail'
MFA_BACKEND_CUSTOM = 'authentication.mfa.custom.MFACustom'
-MFA_BACKENDS = [MFA_BACKEND_OTP, MFA_BACKEND_RADIUS, MFA_BACKEND_SMS, MFA_BACKEND_FACE]
+MFA_BACKENDS = [MFA_BACKEND_OTP, MFA_BACKEND_RADIUS, MFA_BACKEND_SMS, MFA_BACKEND_FACE, MFA_BACKEND_EMAIL]
MFA_CUSTOM = CONFIG.MFA_CUSTOM
MFA_CUSTOM_FILE_MD5 = CONFIG.MFA_CUSTOM_FILE_MD5
diff --git a/apps/jumpserver/settings/custom.py b/apps/jumpserver/settings/custom.py
index f0ff095b1..49bdc7366 100644
--- a/apps/jumpserver/settings/custom.py
+++ b/apps/jumpserver/settings/custom.py
@@ -34,6 +34,7 @@ FTP_FILE_MAX_STORE = CONFIG.FTP_FILE_MAX_STORE
# Security settings
SECURITY_MFA_AUTH = CONFIG.SECURITY_MFA_AUTH
SECURITY_MFA_AUTH_ENABLED_FOR_THIRD_PARTY = CONFIG.SECURITY_MFA_AUTH_ENABLED_FOR_THIRD_PARTY
+SECURITY_MFA_BY_EMAIL = CONFIG.SECURITY_MFA_BY_EMAIL
SECURITY_MAX_IDLE_TIME = CONFIG.SECURITY_MAX_IDLE_TIME # Unit: minute
SECURITY_MAX_SESSION_TIME = CONFIG.SECURITY_MAX_SESSION_TIME # Unit: hour
SECURITY_COMMAND_EXECUTION = CONFIG.SECURITY_COMMAND_EXECUTION
diff --git a/apps/settings/serializers/security.py b/apps/settings/serializers/security.py
index 9cb972c0c..0ad66795a 100644
--- a/apps/settings/serializers/security.py
+++ b/apps/settings/serializers/security.py
@@ -124,6 +124,11 @@ class SecurityAuthSerializer(serializers.Serializer):
label=_('Third-party login MFA'),
help_text=_('The third-party login modes include OIDC, CAS, and SAML2'),
)
+ SECURITY_MFA_BY_EMAIL = serializers.BooleanField(
+ required=False, default=False,
+ label=_('MFA via Email'),
+ help_text=_('Email as a method for multi-factor authentication')
+ )
OTP_ISSUER_NAME = serializers.CharField(
required=False, max_length=16, label=_('OTP issuer name'),
)