From 8071f45f9209b3b06d37aada174dfb1d9520538d Mon Sep 17 00:00:00 2001 From: feng626 <1304903146@qq.com> Date: Fri, 29 Oct 2021 15:29:57 +0800 Subject: [PATCH 01/15] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8Dxpack=E5=BC=95?= =?UTF-8?q?=E5=85=A5=E5=8C=85=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/acls/serializers/login_acl.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/acls/serializers/login_acl.py b/apps/acls/serializers/login_acl.py index cf40e078b..a699ae1ea 100644 --- a/apps/acls/serializers/login_acl.py +++ b/apps/acls/serializers/login_acl.py @@ -2,6 +2,7 @@ from django.utils.translation import ugettext as _ from rest_framework import serializers from common.drf.serializers import BulkModelSerializer from common.drf.serializers import MethodSerializer +from jumpserver.utils import has_valid_xpack_license from ..models import LoginACL from .rules import RuleSerializer @@ -40,12 +41,11 @@ class LoginACLSerializer(BulkModelSerializer): self.set_action_choices() def set_action_choices(self): - from xpack.plugins.license.models import License action = self.fields.get('action') if not action: return choices = action._choices - if not License.has_valid_license(): + if not has_valid_xpack_license(): choices.pop(LoginACL.ActionChoices.confirm, None) action._choices = choices From d484885762bc9680a13746abc69d64904018b021 Mon Sep 17 00:00:00 2001 From: ibuler Date: Mon, 1 Nov 2021 18:12:45 +0800 Subject: [PATCH 02/15] =?UTF-8?q?perf:=20=E4=BF=AE=E5=A4=8Dansible=20stdou?= =?UTF-8?q?t=20=E4=B8=AD=E9=9D=9Eutf8=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/common/utils/strings.py | 4 ++++ apps/ops/ansible/callback.py | 6 ++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/common/utils/strings.py b/apps/common/utils/strings.py index f6dcdef18..045263a12 100644 --- a/apps/common/utils/strings.py +++ b/apps/common/utils/strings.py @@ -3,3 +3,7 @@ import re def no_special_chars(s): return bool(re.match(r'\w+$', s)) + + +def safe_str(s): + return s.encode('utf-8', errors='ignore').decode('utf-8') diff --git a/apps/ops/ansible/callback.py b/apps/ops/ansible/callback.py index 3264e59c2..cb42350b7 100644 --- a/apps/ops/ansible/callback.py +++ b/apps/ops/ansible/callback.py @@ -10,6 +10,8 @@ from ansible.plugins.callback import CallbackBase from ansible.plugins.callback.default import CallbackModule from ansible.plugins.callback.minimal import CallbackModule as CMDCallBackModule +from common.utils.strings import safe_str + class CallbackMixin: def __init__(self, display=None): @@ -84,7 +86,7 @@ class AdHocResultCallback(CallbackMixin, CallbackModule, CMDCallBackModule): detail = { 'cmd': cmd, 'stderr': task_result.get('stderr'), - 'stdout': task_result.get('stdout'), + 'stdout': safe_str(str(task_result.get('stdout', ''))), 'rc': task_result.get('rc'), 'delta': task_result.get('delta'), 'msg': task_result.get('msg', '') @@ -216,7 +218,7 @@ class CommandResultCallback(AdHocResultCallback): if t == "ok": cmd['cmd'] = res._result.get('cmd') cmd['stderr'] = res._result.get('stderr') - cmd['stdout'] = res._result.get('stdout') + cmd['stdout'] = safe_str(str(res._result.get('stdout', ''))) cmd['rc'] = res._result.get('rc') cmd['delta'] = res._result.get('delta') else: From fde118021e1d843ae6a64a18c82089162e6a9001 Mon Sep 17 00:00:00 2001 From: xinwen Date: Mon, 1 Nov 2021 11:01:24 +0800 Subject: [PATCH 03/15] =?UTF-8?q?fix:=20Radius=20=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/locale/zh/LC_MESSAGES/django.po | 6 +++--- apps/settings/serializers/auth/radius.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/locale/zh/LC_MESSAGES/django.po b/apps/locale/zh/LC_MESSAGES/django.po index 64cea435b..7e8ee4289 100644 --- a/apps/locale/zh/LC_MESSAGES/django.po +++ b/apps/locale/zh/LC_MESSAGES/django.po @@ -2938,11 +2938,11 @@ msgid "Always update user" msgstr "总是更新用户信息" #: settings/serializers/auth/radius.py:13 -msgid "Enable RADIUS Auth" -msgstr "启用 RADIUS 认证" +msgid "Enable Radius Auth" +msgstr "启用 Radius 认证" #: settings/serializers/auth/radius.py:19 -msgid "OTP in radius" +msgid "OTP in Radius" msgstr "使用 Radius OTP" #: settings/serializers/auth/sms.py:10 diff --git a/apps/settings/serializers/auth/radius.py b/apps/settings/serializers/auth/radius.py index 0a956d18a..fa5633fc8 100644 --- a/apps/settings/serializers/auth/radius.py +++ b/apps/settings/serializers/auth/radius.py @@ -10,10 +10,10 @@ __all__ = [ class RadiusSettingSerializer(serializers.Serializer): - AUTH_RADIUS = serializers.BooleanField(required=False, label=_('Enable RADIUS Auth')) - RADIUS_SERVER = serializers.CharField(required=False, max_length=1024, label=_('Host')) + AUTH_RADIUS = serializers.BooleanField(required=False, label=_('Enable Radius Auth')) + RADIUS_SERVER = serializers.CharField(required=False, allow_blank=True, max_length=1024, label=_('Host')) RADIUS_PORT = serializers.IntegerField(required=False, label=_('Port')) RADIUS_SECRET = serializers.CharField( required=False, max_length=1024, allow_null=True, label=_('Secret'), write_only=True ) - OTP_IN_RADIUS = serializers.BooleanField(required=False, label=_('OTP in radius')) + OTP_IN_RADIUS = serializers.BooleanField(required=False, label=_('OTP in Radius')) From 55775f0deb5e22808ed94acf76436d43a2c99d55 Mon Sep 17 00:00:00 2001 From: feng626 <1304903146@qq.com> Date: Tue, 2 Nov 2021 15:41:25 +0800 Subject: [PATCH 04/15] =?UTF-8?q?perf:=20=E5=BC=82=E5=9C=B0=E7=99=BB?= =?UTF-8?q?=E9=99=86=E5=8E=BB=E6=8E=89=E6=9C=AC=E5=9C=B0=E5=88=A4=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/authentication/utils.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/authentication/utils.py b/apps/authentication/utils.py index 6e8e45570..0e1dd5e9c 100644 --- a/apps/authentication/utils.py +++ b/apps/authentication/utils.py @@ -59,6 +59,10 @@ def check_different_city_login(user, request): else: city = get_ip_city(ip) or DEFAULT_CITY - last_user_login = UserLoginLog.objects.filter(username=user.username, status=True).first() - if last_user_login and last_user_login.city != city: - DifferentCityLoginMessage(user, ip, city).publish_async() + city_white = ['LAN', ] + if city not in city_white: + last_user_login = UserLoginLog.objects.exclude(city__in=city_white) \ + .filter(username=user.username, status=True).first() + + if last_user_login and last_user_login.city != city: + DifferentCityLoginMessage(user, ip, city).publish_async() From d57f52ee243392e866bb45e7f23634d9c1452dd3 Mon Sep 17 00:00:00 2001 From: ibuler Date: Tue, 2 Nov 2021 20:08:50 +0800 Subject: [PATCH 05/15] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20window=20RDP?= =?UTF-8?q?=20TLS=20=E5=B9=B3=E5=8F=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migrations/0079_auto_20211102_1922.py | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 apps/assets/migrations/0079_auto_20211102_1922.py diff --git a/apps/assets/migrations/0079_auto_20211102_1922.py b/apps/assets/migrations/0079_auto_20211102_1922.py new file mode 100644 index 000000000..64f4124f3 --- /dev/null +++ b/apps/assets/migrations/0079_auto_20211102_1922.py @@ -0,0 +1,28 @@ +# Generated by Django 3.1.12 on 2021-11-02 11:22 + +from django.db import migrations + + +def create_internal_platform(apps, schema_editor): + model = apps.get_model("assets", "Platform") + db_alias = schema_editor.connection.alias + type_platforms = ( + ('Windows-RDP', 'Windows', {'security': 'rdp'}), + ('Windows-TLS', 'Windows', {'security': 'tls'}), + ) + for name, base, meta in type_platforms: + defaults = {'name': name, 'base': base, 'meta': meta} + model.objects.using(db_alias).update_or_create( + name=name, defaults=defaults + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('assets', '0078_auto_20211014_2209'), + ] + + operations = [ + migrations.RunPython(create_internal_platform) + ] From bbe2678df3a8e1b3139d69b30513992a51f3ca0c Mon Sep 17 00:00:00 2001 From: ibuler Date: Wed, 3 Nov 2021 11:17:18 +0800 Subject: [PATCH 06/15] =?UTF-8?q?perf:=20=E4=BC=98=E5=8C=96=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E5=88=9B=E5=BB=BA=E9=82=AE=E4=BB=B6=E4=BF=A1=E6=81=AF?= =?UTF-8?q?=EF=BC=8C=E6=94=AF=E6=8C=81=E9=83=A8=E5=88=86=E6=A0=87=E7=AD=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/locale/zh/LC_MESSAGES/django.po | 121 ++++++++++++++------------- apps/settings/serializers/email.py | 2 +- apps/users/notifications.py | 19 +++-- 3 files changed, 79 insertions(+), 63 deletions(-) diff --git a/apps/locale/zh/LC_MESSAGES/django.po b/apps/locale/zh/LC_MESSAGES/django.po index 7e8ee4289..f0d24b15a 100644 --- a/apps/locale/zh/LC_MESSAGES/django.po +++ b/apps/locale/zh/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: JumpServer 0.3.3\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2021-10-26 17:16+0800\n" +"POT-Creation-Date: 2021-11-03 11:14+0800\n" "PO-Revision-Date: 2021-05-20 10:54+0800\n" "Last-Translator: ibuler \n" "Language-Team: JumpServer team\n" @@ -66,20 +66,20 @@ msgstr "激活中" msgid "Comment" msgstr "备注" -#: acls/models/login_acl.py:19 tickets/const.py:38 +#: acls/models/login_acl.py:18 tickets/const.py:38 msgid "Reject" msgstr "拒绝" -#: acls/models/login_acl.py:20 assets/models/cmd_filter.py:48 +#: acls/models/login_acl.py:19 assets/models/cmd_filter.py:48 msgid "Allow" msgstr "允许" -#: acls/models/login_acl.py:21 acls/models/login_acl.py:114 +#: acls/models/login_acl.py:20 acls/models/login_acl.py:117 #: acls/models/login_asset_acl.py:17 tickets/const.py:9 msgid "Login confirm" msgstr "登录复核" -#: acls/models/login_acl.py:25 acls/models/login_asset_acl.py:20 +#: acls/models/login_acl.py:24 acls/models/login_asset_acl.py:20 #: assets/models/label.py:15 audits/models.py:36 audits/models.py:56 #: audits/models.py:74 audits/serializers.py:94 authentication/models.py:47 #: orgs/models.py:19 orgs/models.py:433 perms/models/base.py:45 @@ -96,12 +96,12 @@ msgstr "登录复核" msgid "User" msgstr "用户" -#: acls/models/login_acl.py:29 +#: acls/models/login_acl.py:28 msgid "Rule" msgstr "规则" -#: acls/models/login_acl.py:32 acls/models/login_asset_acl.py:26 -#: acls/serializers/login_acl.py:16 acls/serializers/login_asset_acl.py:75 +#: acls/models/login_acl.py:31 acls/models/login_asset_acl.py:26 +#: acls/serializers/login_acl.py:17 acls/serializers/login_asset_acl.py:75 #: assets/models/cmd_filter.py:57 audits/models.py:57 #: authentication/templates/authentication/_access_key_modal.html:34 #: users/templates/users/_granted_assets.html:29 @@ -111,12 +111,12 @@ msgstr "规则" msgid "Action" msgstr "动作" -#: acls/models/login_acl.py:36 acls/models/login_asset_acl.py:32 -#: acls/serializers/login_acl.py:15 assets/models/cmd_filter.py:62 +#: acls/models/login_acl.py:35 acls/models/login_asset_acl.py:32 +#: acls/serializers/login_acl.py:16 assets/models/cmd_filter.py:62 msgid "Reviewers" msgstr "审批人" -#: acls/models/login_acl.py:43 +#: acls/models/login_acl.py:42 msgid "Login acl" msgstr "登录访问控制" @@ -149,11 +149,11 @@ msgstr "登录资产访问控制" msgid "Login asset confirm" msgstr "登录资产复核" -#: acls/serializers/login_acl.py:10 acls/serializers/login_asset_acl.py:12 +#: acls/serializers/login_acl.py:11 acls/serializers/login_asset_acl.py:12 msgid "Format for comma-delimited string, with * indicating a match all. " msgstr "格式为逗号分隔的字符串, * 表示匹配所有. " -#: acls/serializers/login_acl.py:14 acls/serializers/login_asset_acl.py:17 +#: acls/serializers/login_acl.py:15 acls/serializers/login_asset_acl.py:17 #: acls/serializers/login_asset_acl.py:51 #: applications/serializers/attrs/application_type/chrome.py:20 #: applications/serializers/attrs/application_type/custom.py:21 @@ -1813,7 +1813,8 @@ msgid "Need MFA for view auth" msgstr "需要多因子认证来查看账号信息" #: authentication/templates/authentication/_mfa_confirm_modal.html:20 -#: templates/_modal.html:23 users/templates/users/user_password_verify.html:20 +#: templates/_modal.html:23 templates/flash_message_standalone.html:34 +#: users/templates/users/user_password_verify.html:20 msgid "Confirm" msgstr "确认" @@ -2606,7 +2607,7 @@ msgstr "组织 ({}) 的应用授权" #: perms/serializers/application/permission.py:18 #: perms/serializers/application/permission.py:38 #: perms/serializers/asset/permission.py:42 -#: perms/serializers/asset/permission.py:68 users/serializers/user.py:78 +#: perms/serializers/asset/permission.py:68 users/serializers/user.py:79 msgid "Is valid" msgstr "账户是否有效" @@ -2614,7 +2615,7 @@ msgstr "账户是否有效" #: perms/serializers/application/permission.py:37 #: perms/serializers/asset/permission.py:43 #: perms/serializers/asset/permission.py:67 users/serializers/user.py:28 -#: users/serializers/user.py:79 +#: users/serializers/user.py:80 msgid "Is expired" msgstr "已过期" @@ -3144,8 +3145,11 @@ msgid "Create user email content" msgstr "邮件的内容" #: settings/serializers/email.py:60 -msgid "Tips:When creating a user, send the content of the email" -msgstr "提示: 创建用户时,发送设置密码邮件的内容" +#, python-brace-format +msgid "" +"Tips: When creating a user, send the content of the email, support " +"{username} {name} {email} label" +msgstr "提示: 创建用户时,发送设置密码邮件的内容, 支持 {username} {name} {email} 标签" #: settings/serializers/email.py:64 msgid "Tips: Email signature (eg:jumpserver)" @@ -3864,10 +3868,6 @@ msgstr "您确定删除吗?" msgid "Cancel" msgstr "取消" -#: templates/flash_message_standalone.html:34 -msgid "Go" -msgstr "立即" - #: templates/index.html:11 msgid "Total users" msgstr "用户总数" @@ -4014,22 +4014,28 @@ msgstr "前" msgid "Login in " msgstr "登录了" -#: templates/resource_download.html:15 templates/resource_download.html:21 -#: templates/resource_download.html:22 templates/resource_download.html:27 +#: templates/resource_download.html:18 templates/resource_download.html:24 +#: templates/resource_download.html:25 templates/resource_download.html:30 msgid "Client" msgstr "客户端" -#: templates/resource_download.html:17 +#: templates/resource_download.html:20 msgid "" "JumpServer Client, currently used to launch the client, now only support " "launch RDP client, The SSH client will next" -msgstr "JumpServer 客户端,目前用来唤起 特定客户端程序 连接资产, 目前仅支持 RDP 客户端,SSH、Telnet 会在未来支持" +msgstr "" +"JumpServer 客户端,目前用来唤起 特定客户端程序 连接资产, 目前仅支持 RDP 客户" +"端,SSH、Telnet 会在未来支持" -#: templates/resource_download.html:27 +#: templates/resource_download.html:30 +msgid "Microsoft" +msgstr "" + +#: templates/resource_download.html:30 msgid "Official" msgstr "官方" -#: templates/resource_download.html:29 +#: templates/resource_download.html:32 msgid "" "macOS needs to download the client to connect RDP asset, which comes with " "Windows" @@ -4704,8 +4710,8 @@ msgstr "批准的系统用户名称" msgid "Permission named `{}` already exists" msgstr "授权名称 `{}` 已存在" -#: tickets/serializers/ticket/meta/ticket_type/apply_application.py:89 -#: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:72 +#: tickets/serializers/ticket/meta/ticket_type/apply_application.py:88 +#: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:71 msgid "The expiration date should be greater than the start date" msgstr "过期时间要大于开始时间" @@ -4950,30 +4956,30 @@ msgstr "管理员" msgid "Administrator is the super user of system" msgstr "Administrator是初始的超级管理员" -#: users/notifications.py:48 +#: users/notifications.py:55 #: users/templates/users/_msg_password_expire_reminder.html:17 #: users/templates/users/reset_password.html:5 #: users/templates/users/reset_password.html:6 msgid "Reset password" msgstr "重置密码" -#: users/notifications.py:78 users/views/profile/reset.py:127 +#: users/notifications.py:85 users/views/profile/reset.py:127 msgid "Reset password success" msgstr "重置密码成功" -#: users/notifications.py:104 +#: users/notifications.py:111 msgid "Password is about expire" msgstr "密码即将过期" -#: users/notifications.py:132 +#: users/notifications.py:139 msgid "Account is about expire" msgstr "账号即将过期" -#: users/notifications.py:154 +#: users/notifications.py:161 msgid "Reset SSH Key" msgstr "重置 SSH 密钥" -#: users/notifications.py:175 +#: users/notifications.py:182 msgid "Reset MFA" msgstr "重置 MFA" @@ -4981,7 +4987,7 @@ msgstr "重置 MFA" msgid "The old password is incorrect" msgstr "旧密码错误" -#: users/serializers/profile.py:36 users/serializers/user.py:141 +#: users/serializers/profile.py:36 users/serializers/user.py:142 msgid "Password does not match security rules" msgstr "密码不满足安全规则" @@ -4993,7 +4999,7 @@ msgstr "新密码不能是最近 {} 次的密码" msgid "The newly set password is inconsistent" msgstr "两次密码不一致" -#: users/serializers/profile.py:121 users/serializers/user.py:77 +#: users/serializers/profile.py:121 users/serializers/user.py:78 msgid "Is first login" msgstr "首次登录" @@ -5031,51 +5037,51 @@ msgstr "是否可删除" msgid "Can public key authentication" msgstr "能否公钥认证" -#: users/serializers/user.py:34 users/serializers/user.py:84 +#: users/serializers/user.py:34 users/serializers/user.py:85 msgid "Organization role name" msgstr "组织角色名称" -#: users/serializers/user.py:80 +#: users/serializers/user.py:81 msgid "Avatar url" msgstr "头像路径" -#: users/serializers/user.py:82 +#: users/serializers/user.py:83 msgid "Groups name" msgstr "用户组名" -#: users/serializers/user.py:83 +#: users/serializers/user.py:84 msgid "Source name" msgstr "用户来源名" -#: users/serializers/user.py:85 +#: users/serializers/user.py:86 msgid "Super role name" msgstr "超级角色名称" -#: users/serializers/user.py:86 +#: users/serializers/user.py:87 msgid "Total role name" msgstr "汇总角色名称" -#: users/serializers/user.py:88 +#: users/serializers/user.py:89 msgid "Is wecom bound" msgstr "是否绑定了企业微信" -#: users/serializers/user.py:89 +#: users/serializers/user.py:90 msgid "Is dingtalk bound" msgstr "是否绑定了钉钉" -#: users/serializers/user.py:90 +#: users/serializers/user.py:91 msgid "Is feishu bound" msgstr "是否绑定了飞书" -#: users/serializers/user.py:91 +#: users/serializers/user.py:92 msgid "Is OTP bound" msgstr "是否绑定了虚拟MFA" -#: users/serializers/user.py:115 +#: users/serializers/user.py:116 msgid "Role limit to {}" msgstr "角色只能为 {}" -#: users/serializers/user.py:227 +#: users/serializers/user.py:228 msgid "name not unique" msgstr "名称重复" @@ -5398,8 +5404,8 @@ msgstr "* 新密码不能是最近 {} 次的密码" msgid "Reset password success, return to login page" msgstr "重置密码成功,返回到登录页面" -#: xpack/plugins/change_auth_plan/api/app.py:114 -#: xpack/plugins/change_auth_plan/api/asset.py:101 +#: xpack/plugins/change_auth_plan/api/app.py:113 +#: xpack/plugins/change_auth_plan/api/asset.py:100 msgid "The parameter 'action' must be [{}]" msgstr "参数 'action' 必须是 [{}]" @@ -5530,15 +5536,15 @@ msgstr "* 请输入正确的密码长度" msgid "* Password length range 6-30 bits" msgstr "* 密码长度范围 6-30 位" -#: xpack/plugins/change_auth_plan/task_handlers/base/handler.py:249 +#: xpack/plugins/change_auth_plan/task_handlers/base/handler.py:248 msgid "Invalid/incorrect password" msgstr "无效/错误 密码" -#: xpack/plugins/change_auth_plan/task_handlers/base/handler.py:251 +#: xpack/plugins/change_auth_plan/task_handlers/base/handler.py:250 msgid "Failed to connect to the host" msgstr "连接主机失败" -#: xpack/plugins/change_auth_plan/task_handlers/base/handler.py:253 +#: xpack/plugins/change_auth_plan/task_handlers/base/handler.py:252 msgid "Data could not be sent to remote" msgstr "无法将数据发送到远程" @@ -5896,7 +5902,7 @@ msgstr "执行次数" msgid "Instance count" msgstr "实例个数" -#: xpack/plugins/cloud/utils.py:68 +#: xpack/plugins/cloud/utils.py:65 msgid "Account unavailable" msgstr "账户无效" @@ -5984,6 +5990,9 @@ msgstr "旗舰版" msgid "Community edition" msgstr "社区版" +#~ msgid "Go" +#~ msgstr "立即" + #, python-brace-format #~ msgid "Hello {name}" #~ msgstr "你好 {name}" diff --git a/apps/settings/serializers/email.py b/apps/settings/serializers/email.py index 3474de52a..4395d7473 100644 --- a/apps/settings/serializers/email.py +++ b/apps/settings/serializers/email.py @@ -57,7 +57,7 @@ class EmailContentSettingSerializer(serializers.Serializer): EMAIL_CUSTOM_USER_CREATED_BODY = serializers.CharField( max_length=4096, allow_blank=True, required=False, label=_('Create user email content'), - help_text=_('Tips:When creating a user, send the content of the email') + help_text=_('Tips: When creating a user, send the content of the email, support {username} {name} {email} label') ) EMAIL_CUSTOM_USER_CREATED_SIGNATURE = serializers.CharField( max_length=512, allow_blank=True, required=False, label=_('Signature'), diff --git a/apps/users/notifications.py b/apps/users/notifications.py index c97a5d690..d1fbad546 100644 --- a/apps/users/notifications.py +++ b/apps/users/notifications.py @@ -1,4 +1,5 @@ from urllib.parse import urljoin +from collections import defaultdict from django.utils import timezone from django.utils.translation import ugettext as _ @@ -13,13 +14,19 @@ class UserCreatedMsg(UserMessage): def get_html_msg(self) -> dict: user = self.user - subject = str(settings.EMAIL_CUSTOM_USER_CREATED_SUBJECT) - honorific = str(settings.EMAIL_CUSTOM_USER_CREATED_HONORIFIC) - content = str(settings.EMAIL_CUSTOM_USER_CREATED_BODY) + mail_context = { + 'subject': str(settings.EMAIL_CUSTOM_USER_CREATED_SUBJECT), + 'honorific': str(settings.EMAIL_CUSTOM_USER_CREATED_HONORIFIC), + 'content': str(settings.EMAIL_CUSTOM_USER_CREATED_BODY) + } + + user_info = {'username': user.username, 'name': user.name, 'email': user.email} + # 转换成 defaultdict,否则 format 时会报 KeyError + user_info = defaultdict(str, **user_info) + mail_context = {k: v.format_map(user_info) for k, v in mail_context.items()} context = { - 'honorific': honorific, - 'content': content, + **mail_context, 'user': user, 'rest_password_url': reverse('authentication:reset-password', external=True), 'rest_password_token': user.generate_reset_token(), @@ -28,7 +35,7 @@ class UserCreatedMsg(UserMessage): } message = render_to_string('users/_msg_user_created.html', context) return { - 'subject': subject, + 'subject': mail_context['subject'], 'message': message } From 07c60ca75df22efba081a2a088eab4ed726ed084 Mon Sep 17 00:00:00 2001 From: "Jiangjie.Bai" <32935519+BaiJiangJie@users.noreply.github.com> Date: Fri, 5 Nov 2021 16:11:29 +0800 Subject: [PATCH 07/15] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E4=BA=8C?= =?UTF-8?q?=E7=BA=A7=E7=99=BB=E5=BD=95=E8=B5=84=E4=BA=A7=20(#7143)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 支持su切换系统用户 * feat: 支持su切换系统用户 * feat: 支持su切换系统用户 --- apps/assets/api/system_user.py | 27 +++++++ .../migrations/0080_auto_20211104_1347.py | 24 ++++++ apps/assets/models/user.py | 18 +++++ apps/assets/serializers/system_user.py | 27 ++++++- apps/assets/signals_handler/system_user.py | 2 + apps/common/mixins/serializers.py | 7 +- apps/locale/zh/LC_MESSAGES/django.mo | 4 +- apps/locale/zh/LC_MESSAGES/django.po | 76 +++++++++++++------ apps/perms/signals_handler/app_permission.py | 2 +- .../perms/signals_handler/asset_permission.py | 5 +- 10 files changed, 159 insertions(+), 33 deletions(-) create mode 100644 apps/assets/migrations/0080_auto_20211104_1347.py diff --git a/apps/assets/api/system_user.py b/apps/assets/api/system_user.py index 213858e13..b9f1007d9 100644 --- a/apps/assets/api/system_user.py +++ b/apps/assets/api/system_user.py @@ -8,6 +8,7 @@ from orgs.mixins.api import OrgBulkModelViewSet from orgs.mixins import generics from common.mixins.api import SuggestionMixin from orgs.utils import tmp_to_root_org +from rest_framework.decorators import action from ..models import SystemUser, Asset from .. import serializers from ..serializers import SystemUserWithAuthInfoSerializer, SystemUserTempAuthSerializer @@ -45,6 +46,32 @@ class SystemUserViewSet(SuggestionMixin, OrgBulkModelViewSet): ordering = ('name', ) permission_classes = (IsOrgAdminOrAppUser,) + @action(methods=['get'], detail=False, url_path='su-from') + def su_from(self, request, *args, **kwargs): + """ API 获取可选的 su_from 系统用户""" + queryset = self.filter_queryset(self.get_queryset()) + queryset = queryset.filter( + protocol=SystemUser.Protocol.ssh, login_mode=SystemUser.LOGIN_AUTO + ) + return self.get_paginate_response_if_need(queryset) + + @action(methods=['get'], detail=True, url_path='su-to') + def su_to(self, request, *args, **kwargs): + """ 获取系统用户的所有 su_to 系统用户 """ + pk = kwargs.get('pk') + system_user = get_object_or_404(SystemUser, pk=pk) + queryset = system_user.su_to.all() + queryset = self.filter_queryset(queryset) + return self.get_paginate_response_if_need(queryset) + + def get_paginate_response_if_need(self, queryset): + page = self.paginate_queryset(queryset) + if page is not None: + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data) + class SystemUserAuthInfoApi(generics.RetrieveUpdateDestroyAPIView): """ diff --git a/apps/assets/migrations/0080_auto_20211104_1347.py b/apps/assets/migrations/0080_auto_20211104_1347.py new file mode 100644 index 000000000..a456c825e --- /dev/null +++ b/apps/assets/migrations/0080_auto_20211104_1347.py @@ -0,0 +1,24 @@ +# Generated by Django 3.1.13 on 2021-11-04 05:47 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('assets', '0079_auto_20211102_1922'), + ] + + operations = [ + migrations.AddField( + model_name='systemuser', + name='su_enabled', + field=models.BooleanField(default=False, verbose_name='Switch user'), + ), + migrations.AddField( + model_name='systemuser', + name='su_from', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='su_to', to='assets.systemuser', verbose_name='Switch from'), + ), + ] diff --git a/apps/assets/models/user.py b/apps/assets/models/user.py index 52e3c2af8..48f2bac30 100644 --- a/apps/assets/models/user.py +++ b/apps/assets/models/user.py @@ -208,6 +208,9 @@ class SystemUser(ProtocolMixin, AuthMixin, BaseUser): home = models.CharField(max_length=4096, default='', verbose_name=_('Home'), blank=True) system_groups = models.CharField(default='', max_length=4096, verbose_name=_('System groups'), blank=True) ad_domain = models.CharField(default='', max_length=256) + # linux su 命令 (switch user) + su_enabled = models.BooleanField(default=False, verbose_name=_('Switch user')) + su_from = models.ForeignKey('self', on_delete=models.SET_NULL, related_name='su_to', null=True, verbose_name=_("Switch from")) def __str__(self): username = self.username @@ -267,6 +270,21 @@ class SystemUser(ProtocolMixin, AuthMixin, BaseUser): assets = Asset.objects.filter(id__in=asset_ids) return assets + def add_related_assets(self, assets_or_ids): + self.assets.add(*tuple(assets_or_ids)) + self.add_related_assets_to_su_from_if_need(assets_or_ids) + + def add_related_assets_to_su_from_if_need(self, assets_or_ids): + if self.protocol not in [self.Protocol.ssh.value]: + return + if not self.su_enabled: + return + if not self.su_from: + return + if self.su_from.protocol != self.protocol: + return + self.su_from.assets.add(*tuple(assets_or_ids)) + class Meta: ordering = ['name'] unique_together = [('name', 'org_id')] diff --git a/apps/assets/serializers/system_user.py b/apps/assets/serializers/system_user.py index b662d062c..b86740f8c 100644 --- a/apps/assets/serializers/system_user.py +++ b/apps/assets/serializers/system_user.py @@ -40,6 +40,7 @@ class SystemUserSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer): 'login_mode', 'login_mode_display', 'priority', 'sudo', 'shell', 'sftp_root', 'home', 'system_groups', 'ad_domain', 'username_same_with_user', 'auto_push', 'auto_generate_key', + 'su_enabled', 'su_from', 'date_created', 'date_updated', 'comment', 'created_by', ] fields_m2m = ['cmd_filters', 'assets_amount', 'applications_amount', 'nodes'] @@ -57,7 +58,8 @@ class SystemUserSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer): 'login_mode_display': {'label': _('Login mode display')}, 'created_by': {'read_only': True}, 'ad_domain': {'required': False, 'allow_blank': True, 'label': _('Ad domain')}, - 'is_asset_protocol': {'label': _('Is asset protocol')} + 'is_asset_protocol': {'label': _('Is asset protocol')}, + 'su_from': {'help_text': _('Only ssh and automatic login system users are supported')} } def validate_auto_push(self, value): @@ -146,6 +148,29 @@ class SystemUserSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer): raise serializers.ValidationError(_("Password or private key required")) return password + def validate_su_from(self, su_from: SystemUser): + # self: su enabled + su_enabled = self.get_initial_value('su_enabled', default=False) + if not su_enabled: + return + if not su_from: + error = _('This field is required.') + raise serializers.ValidationError(error) + # self: protocol ssh + protocol = self.get_initial_value('protocol', default=SystemUser.Protocol.ssh.value) + if protocol not in [SystemUser.Protocol.ssh.value]: + error = _('Only ssh protocol system users are allowed') + raise serializers.ValidationError(error) + # su_from: protocol same + if su_from.protocol != protocol: + error = _('The protocol must be consistent with the current user: {}').format(protocol) + raise serializers.ValidationError(error) + # su_from: login model auto + if su_from.login_mode != su_from.LOGIN_AUTO: + error = _('Only system users with automatic login are allowed') + raise serializers.ValidationError(error) + return su_from + def _validate_admin_user(self, attrs): if self.instance: tp = self.instance.type diff --git a/apps/assets/signals_handler/system_user.py b/apps/assets/signals_handler/system_user.py index 00111030c..00b19e110 100644 --- a/apps/assets/signals_handler/system_user.py +++ b/apps/assets/signals_handler/system_user.py @@ -140,3 +140,5 @@ def on_system_user_update(instance: SystemUser, created, **kwargs): logger.info("System user update signal recv: {}".format(instance)) assets = instance.assets.all().valid() push_system_user_to_assets.delay(instance.id, [_asset.id for _asset in assets]) + # add assets to su_from + instance.add_related_assets_to_su_from_if_need(assets) diff --git a/apps/common/mixins/serializers.py b/apps/common/mixins/serializers.py index e9a1641c4..72b7610d4 100644 --- a/apps/common/mixins/serializers.py +++ b/apps/common/mixins/serializers.py @@ -298,9 +298,12 @@ class CommonSerializerMixin(DynamicFieldsMixin, DefaultValueFieldsMixin): def get_initial_value(self, attr, default=None): value = self.initial_data.get(attr) - if not value and self.instance: + if value is not None: + return value + if self.instance: value = getattr(self.instance, attr, default) - return value + return value + return default class CommonBulkSerializerMixin(BulkSerializerMixin, CommonSerializerMixin): diff --git a/apps/locale/zh/LC_MESSAGES/django.mo b/apps/locale/zh/LC_MESSAGES/django.mo index 8d7ab082e..5dbd80da8 100644 --- a/apps/locale/zh/LC_MESSAGES/django.mo +++ b/apps/locale/zh/LC_MESSAGES/django.mo @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bdad14124356449843ef2e77801fd1add5147862488baa2f24f5f14f1ad8f125 -size 90869 +oid sha256:ed378b8e141b884f9b6d82135b37446c02d6621b46f282461e733d610b36030d +size 91465 diff --git a/apps/locale/zh/LC_MESSAGES/django.po b/apps/locale/zh/LC_MESSAGES/django.po index f0d24b15a..9584d0cc5 100644 --- a/apps/locale/zh/LC_MESSAGES/django.po +++ b/apps/locale/zh/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: JumpServer 0.3.3\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2021-11-03 11:14+0800\n" +"POT-Creation-Date: 2021-11-05 11:41+0800\n" "PO-Revision-Date: 2021-05-20 10:54+0800\n" "Last-Translator: ibuler \n" "Language-Team: JumpServer team\n" @@ -129,7 +129,7 @@ msgstr "系统用户" #: acls/models/login_asset_acl.py:22 #: applications/serializers/attrs/application_category/remote_app.py:37 #: assets/models/asset.py:357 assets/models/authbook.py:18 -#: assets/models/gathered_user.py:14 assets/serializers/system_user.py:233 +#: assets/models/gathered_user.py:14 assets/serializers/system_user.py:258 #: audits/models.py:38 perms/models/asset_permission.py:99 #: templates/index.html:82 terminal/backends/command/models.py:19 #: terminal/backends/command/serializers.py:13 terminal/models/session.py:40 @@ -262,7 +262,7 @@ msgid "Custom" msgstr "自定义" #: applications/models/account.py:11 assets/models/authbook.py:19 -#: assets/models/user.py:273 audits/models.py:39 +#: assets/models/user.py:291 audits/models.py:39 #: perms/models/application_permission.py:32 #: perms/models/asset_permission.py:101 templates/_nav.html:45 #: terminal/backends/command/models.py:20 @@ -379,6 +379,7 @@ msgid "Application path" msgstr "应用路径" #: applications/serializers/attrs/application_category/remote_app.py:45 +#: assets/serializers/system_user.py:157 #: xpack/plugins/change_auth_plan/serializers/asset.py:65 #: xpack/plugins/change_auth_plan/serializers/asset.py:68 #: xpack/plugins/change_auth_plan/serializers/asset.py:71 @@ -476,7 +477,7 @@ msgid "Is active" msgstr "激活" #: assets/models/asset.py:193 assets/models/cluster.py:19 -#: assets/models/user.py:187 assets/models/user.py:322 templates/_nav.html:44 +#: assets/models/user.py:187 assets/models/user.py:340 templates/_nav.html:44 msgid "Admin user" msgstr "特权用户" @@ -763,7 +764,7 @@ msgstr "全称" msgid "Parent key" msgstr "ssh私钥" -#: assets/models/node.py:559 assets/serializers/system_user.py:232 +#: assets/models/node.py:559 assets/serializers/system_user.py:257 #: users/templates/users/user_asset_permission.html:41 #: users/templates/users/user_asset_permission.html:73 #: users/templates/users/user_asset_permission.html:158 @@ -835,6 +836,14 @@ msgstr "家目录" msgid "System groups" msgstr "用户组" +#: assets/models/user.py:212 +msgid "Switch user" +msgstr "切换用户" + +#: assets/models/user.py:213 +msgid "Switch from" +msgstr "切换自" + #: assets/models/utils.py:35 #, python-format msgid "%(value)s is not an even number" @@ -864,7 +873,7 @@ msgstr "节点名称" msgid "Hardware info" msgstr "硬件信息" -#: assets/serializers/asset.py:104 assets/serializers/system_user.py:251 +#: assets/serializers/asset.py:104 assets/serializers/system_user.py:276 #: orgs/mixins/serializers.py:26 msgid "Org name" msgstr "组织名称" @@ -878,7 +887,7 @@ msgid "private key invalid" msgstr "密钥不合法" #: assets/serializers/domain.py:13 assets/serializers/label.py:12 -#: assets/serializers/system_user.py:56 +#: assets/serializers/system_user.py:57 #: perms/serializers/asset/permission.py:72 msgid "Assets amount" msgstr "资产数量" @@ -912,48 +921,64 @@ msgstr "密钥指纹" msgid "Apps amount" msgstr "应用数量" -#: assets/serializers/system_user.py:55 +#: assets/serializers/system_user.py:56 #: perms/serializers/asset/permission.py:73 msgid "Nodes amount" msgstr "节点数量" -#: assets/serializers/system_user.py:57 assets/serializers/system_user.py:234 +#: assets/serializers/system_user.py:58 assets/serializers/system_user.py:259 msgid "Login mode display" msgstr "认证方式名称" -#: assets/serializers/system_user.py:59 +#: assets/serializers/system_user.py:60 msgid "Ad domain" msgstr "Ad 网域" -#: assets/serializers/system_user.py:60 +#: assets/serializers/system_user.py:61 msgid "Is asset protocol" msgstr "资产协议" -#: assets/serializers/system_user.py:100 +#: assets/serializers/system_user.py:62 +msgid "Only ssh and automatic login system users are supported" +msgstr "仅支持ssh协议和自动登录的系统用户" + +#: assets/serializers/system_user.py:102 msgid "Username same with user with protocol {} only allow 1" msgstr "用户名和用户相同的一种协议只允许存在一个" -#: assets/serializers/system_user.py:110 common/validators.py:14 +#: assets/serializers/system_user.py:112 common/validators.py:14 msgid "Special char not allowed" msgstr "不能包含特殊字符" -#: assets/serializers/system_user.py:119 +#: assets/serializers/system_user.py:121 msgid "* Automatic login mode must fill in the username." msgstr "自动登录模式,必须填写用户名" -#: assets/serializers/system_user.py:134 +#: assets/serializers/system_user.py:136 msgid "Path should starts with /" msgstr "路径应该以 / 开头" -#: assets/serializers/system_user.py:146 +#: assets/serializers/system_user.py:148 msgid "Password or private key required" msgstr "密码或密钥密码需要一个" -#: assets/serializers/system_user.py:250 +#: assets/serializers/system_user.py:162 +msgid "Only ssh protocol system users are allowed" +msgstr "仅允许ssh协议的系统用户" + +#: assets/serializers/system_user.py:166 +msgid "The protocol must be consistent with the current user: {}" +msgstr "协议必须和当前用户保持一致: {}" + +#: assets/serializers/system_user.py:170 +msgid "Only system users with automatic login are allowed" +msgstr "仅允许自动登录的系统用户" + +#: assets/serializers/system_user.py:275 msgid "System user name" msgstr "系统用户名称" -#: assets/serializers/system_user.py:260 +#: assets/serializers/system_user.py:285 msgid "Asset hostname" msgstr "资产主机名" @@ -3149,7 +3174,8 @@ msgstr "邮件的内容" msgid "" "Tips: When creating a user, send the content of the email, support " "{username} {name} {email} label" -msgstr "提示: 创建用户时,发送设置密码邮件的内容, 支持 {username} {name} {email} 标签" +msgstr "" +"提示: 创建用户时,发送设置密码邮件的内容, 支持 {username} {name} {email} 标签" #: settings/serializers/email.py:64 msgid "Tips: Email signature (eg:jumpserver)" @@ -5404,8 +5430,8 @@ msgstr "* 新密码不能是最近 {} 次的密码" msgid "Reset password success, return to login page" msgstr "重置密码成功,返回到登录页面" -#: xpack/plugins/change_auth_plan/api/app.py:113 -#: xpack/plugins/change_auth_plan/api/asset.py:100 +#: xpack/plugins/change_auth_plan/api/app.py:114 +#: xpack/plugins/change_auth_plan/api/asset.py:101 msgid "The parameter 'action' must be [{}]" msgstr "参数 'action' 必须是 [{}]" @@ -5536,15 +5562,15 @@ msgstr "* 请输入正确的密码长度" msgid "* Password length range 6-30 bits" msgstr "* 密码长度范围 6-30 位" -#: xpack/plugins/change_auth_plan/task_handlers/base/handler.py:248 +#: xpack/plugins/change_auth_plan/task_handlers/base/handler.py:249 msgid "Invalid/incorrect password" msgstr "无效/错误 密码" -#: xpack/plugins/change_auth_plan/task_handlers/base/handler.py:250 +#: xpack/plugins/change_auth_plan/task_handlers/base/handler.py:251 msgid "Failed to connect to the host" msgstr "连接主机失败" -#: xpack/plugins/change_auth_plan/task_handlers/base/handler.py:252 +#: xpack/plugins/change_auth_plan/task_handlers/base/handler.py:253 msgid "Data could not be sent to remote" msgstr "无法将数据发送到远程" @@ -5902,7 +5928,7 @@ msgstr "执行次数" msgid "Instance count" msgstr "实例个数" -#: xpack/plugins/cloud/utils.py:65 +#: xpack/plugins/cloud/utils.py:68 msgid "Account unavailable" msgstr "账户无效" diff --git a/apps/perms/signals_handler/app_permission.py b/apps/perms/signals_handler/app_permission.py index 84ee58807..779c99dca 100644 --- a/apps/perms/signals_handler/app_permission.py +++ b/apps/perms/signals_handler/app_permission.py @@ -53,7 +53,7 @@ def set_remote_app_asset_system_users_if_need(instance: ApplicationPermission, s system_users = system_users or instance.system_users.all() for system_user in system_users: - system_user.assets.add(*asset_ids) + system_user.add_related_assets(asset_ids) if system_user.username_same_with_user: users = users or instance.users.all() diff --git a/apps/perms/signals_handler/asset_permission.py b/apps/perms/signals_handler/asset_permission.py index 0b2c1aeee..e889c318a 100644 --- a/apps/perms/signals_handler/asset_permission.py +++ b/apps/perms/signals_handler/asset_permission.py @@ -70,7 +70,8 @@ def on_permission_assets_changed(instance, action, reverse, pk_set, model, **kwa # TODO 待优化 system_users = instance.system_users.all() for system_user in system_users: - system_user.assets.add(*tuple(assets)) + system_user: SystemUser + system_user.add_related_assets(assets) @receiver(m2m_changed, sender=AssetPermission.system_users.through) @@ -88,7 +89,7 @@ def on_asset_permission_system_users_changed(instance, action, reverse, **kwargs for system_user in system_users: system_user.nodes.add(*tuple(nodes)) - system_user.assets.add(*tuple(assets)) + system_user.add_related_assets(assets) # 动态系统用户,需要关联用户和用户组了 if system_user.username_same_with_user: From f9e970f4ed84d41b48d25682a173cba031761a0a Mon Sep 17 00:00:00 2001 From: Michael Bai Date: Fri, 5 Nov 2021 16:39:08 +0800 Subject: [PATCH 08/15] =?UTF-8?q?fix:=20=E4=BF=AE=E6=94=B9=E6=96=87?= =?UTF-8?q?=E6=A1=88=20=E5=88=87=E6=8D=A2=E7=94=A8=E6=88=B7=20->=20?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E5=88=87=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/assets/migrations/0080_auto_20211104_1347.py | 2 +- apps/assets/models/user.py | 2 +- apps/locale/zh/LC_MESSAGES/django.mo | 2 +- apps/locale/zh/LC_MESSAGES/django.po | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/assets/migrations/0080_auto_20211104_1347.py b/apps/assets/migrations/0080_auto_20211104_1347.py index a456c825e..75210149e 100644 --- a/apps/assets/migrations/0080_auto_20211104_1347.py +++ b/apps/assets/migrations/0080_auto_20211104_1347.py @@ -14,7 +14,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='systemuser', name='su_enabled', - field=models.BooleanField(default=False, verbose_name='Switch user'), + field=models.BooleanField(default=False, verbose_name='User switch'), ), migrations.AddField( model_name='systemuser', diff --git a/apps/assets/models/user.py b/apps/assets/models/user.py index 48f2bac30..5f1a8df76 100644 --- a/apps/assets/models/user.py +++ b/apps/assets/models/user.py @@ -209,7 +209,7 @@ class SystemUser(ProtocolMixin, AuthMixin, BaseUser): system_groups = models.CharField(default='', max_length=4096, verbose_name=_('System groups'), blank=True) ad_domain = models.CharField(default='', max_length=256) # linux su 命令 (switch user) - su_enabled = models.BooleanField(default=False, verbose_name=_('Switch user')) + su_enabled = models.BooleanField(default=False, verbose_name=_('User switch')) su_from = models.ForeignKey('self', on_delete=models.SET_NULL, related_name='su_to', null=True, verbose_name=_("Switch from")) def __str__(self): diff --git a/apps/locale/zh/LC_MESSAGES/django.mo b/apps/locale/zh/LC_MESSAGES/django.mo index 5dbd80da8..ee33a1fcc 100644 --- a/apps/locale/zh/LC_MESSAGES/django.mo +++ b/apps/locale/zh/LC_MESSAGES/django.mo @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ed378b8e141b884f9b6d82135b37446c02d6621b46f282461e733d610b36030d +oid sha256:3cb74767fc92b67608deb32d27bf945b7fd4ad46fc02f0cc5ef4cf4a42ebcd10 size 91465 diff --git a/apps/locale/zh/LC_MESSAGES/django.po b/apps/locale/zh/LC_MESSAGES/django.po index 9584d0cc5..b7912c24a 100644 --- a/apps/locale/zh/LC_MESSAGES/django.po +++ b/apps/locale/zh/LC_MESSAGES/django.po @@ -837,8 +837,8 @@ msgid "System groups" msgstr "用户组" #: assets/models/user.py:212 -msgid "Switch user" -msgstr "切换用户" +msgid "User switch" +msgstr "用户切换" #: assets/models/user.py:213 msgid "Switch from" From bac974b4f291c11c6e99b1096b1784c2af3bed47 Mon Sep 17 00:00:00 2001 From: xinwen Date: Mon, 8 Nov 2021 15:12:12 +0800 Subject: [PATCH 09/15] =?UTF-8?q?feat:=20=E5=BC=82=E5=9C=B0=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E6=8F=90=E9=86=92=E5=8F=AF=E9=85=8D=E7=BD=AE=E6=98=AF?= =?UTF-8?q?=E5=90=A6=E5=90=AF=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/audits/signals_handler.py | 4 ++-- apps/authentication/utils.py | 6 +++++- apps/jumpserver/conf.py | 1 + apps/jumpserver/settings/custom.py | 1 + apps/locale/zh/LC_MESSAGES/django.po | 13 ++++++++++++- apps/settings/serializers/security.py | 5 +++++ 6 files changed, 26 insertions(+), 4 deletions(-) diff --git a/apps/audits/signals_handler.py b/apps/audits/signals_handler.py index 4ba7e8408..362bd4c11 100644 --- a/apps/audits/signals_handler.py +++ b/apps/audits/signals_handler.py @@ -15,7 +15,7 @@ from rest_framework.request import Request from assets.models import Asset, SystemUser from authentication.signals import post_auth_failed, post_auth_success -from authentication.utils import check_different_city_login +from authentication.utils import check_different_city_login_if_need from jumpserver.utils import current_request from users.models import User from users.signals import post_user_change_password @@ -304,7 +304,7 @@ def generate_data(username, request, login_type=None): @receiver(post_auth_success) def on_user_auth_success(sender, user, request, login_type=None, **kwargs): logger.debug('User login success: {}'.format(user.username)) - check_different_city_login(user, request) + check_different_city_login_if_need(user, request) data = generate_data(user.username, request, login_type=login_type) data.update({'mfa': int(user.mfa_enabled), 'status': True}) write_login_log(**data) diff --git a/apps/authentication/utils.py b/apps/authentication/utils.py index 0e1dd5e9c..6dc3866fe 100644 --- a/apps/authentication/utils.py +++ b/apps/authentication/utils.py @@ -5,6 +5,7 @@ from Cryptodome.PublicKey import RSA from Cryptodome.Cipher import PKCS1_v1_5 from Cryptodome import Random +from django.conf import settings from .notifications import DifferentCityLoginMessage from audits.models import UserLoginLog from audits.const import DEFAULT_CITY @@ -51,7 +52,10 @@ def rsa_decrypt(cipher_text, rsa_private_key=None): return message -def check_different_city_login(user, request): +def check_different_city_login_if_need(user, request): + if not settings.SECURITY_CHECK_DIFFERENT_CITY_LOGIN: + return + ip = get_request_ip(request) or '0.0.0.0' if not (ip and validate_ip(ip)): diff --git a/apps/jumpserver/conf.py b/apps/jumpserver/conf.py index b525dff3c..662c91fba 100644 --- a/apps/jumpserver/conf.py +++ b/apps/jumpserver/conf.py @@ -311,6 +311,7 @@ class Config(dict): 'SECURITY_WATERMARK_ENABLED': True, 'SECURITY_MFA_VERIFY_TTL': 3600, 'SECURITY_SESSION_SHARE': True, + 'SECURITY_CHECK_DIFFERENT_CITY_LOGIN': True, 'OLD_PASSWORD_HISTORY_LIMIT_COUNT': 5, 'CHANGE_AUTH_PLAN_SECURE_MODE_ENABLED': True, 'USER_LOGIN_SINGLE_MACHINE_ENABLED': False, diff --git a/apps/jumpserver/settings/custom.py b/apps/jumpserver/settings/custom.py index b01e976f1..bc483cb79 100644 --- a/apps/jumpserver/settings/custom.py +++ b/apps/jumpserver/settings/custom.py @@ -61,6 +61,7 @@ SECURITY_DATA_CRYPTO_ALGO = CONFIG.SECURITY_DATA_CRYPTO_ALGO SECURITY_INSECURE_COMMAND = CONFIG.SECURITY_INSECURE_COMMAND SECURITY_INSECURE_COMMAND_LEVEL = CONFIG.SECURITY_INSECURE_COMMAND_LEVEL SECURITY_INSECURE_COMMAND_EMAIL_RECEIVER = CONFIG.SECURITY_INSECURE_COMMAND_EMAIL_RECEIVER +SECURITY_CHECK_DIFFERENT_CITY_LOGIN = CONFIG.SECURITY_CHECK_DIFFERENT_CITY_LOGIN # Terminal other setting TERMINAL_PASSWORD_AUTH = CONFIG.TERMINAL_PASSWORD_AUTH diff --git a/apps/locale/zh/LC_MESSAGES/django.po b/apps/locale/zh/LC_MESSAGES/django.po index b7912c24a..d4b50cb47 100644 --- a/apps/locale/zh/LC_MESSAGES/django.po +++ b/apps/locale/zh/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: JumpServer 0.3.3\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2021-11-05 11:41+0800\n" +"POT-Creation-Date: 2021-11-08 15:08+0800\n" "PO-Revision-Date: 2021-05-20 10:54+0800\n" "Last-Translator: ibuler \n" "Language-Team: JumpServer team\n" @@ -3416,6 +3416,17 @@ msgstr "会话分享" msgid "Enabled, Allows user active session to be shared with other users" msgstr "开启后允许用户分享已连接的资产会话给它人,协同工作" +#: settings/serializers/security.py:144 +msgid "Remote Login Protection" +msgstr "异地登录保护" + +#: settings/serializers/security.py:145 +msgid "" +"The system determines whether the login IP address belongs to a common login " +"city. If the account is logged in from a common login city, the system sends " +"a remote login reminder" +msgstr "根据登录IP是否所属常用登录城市进行判断,若账号在非常用城市登录,会发送异地登录提醒" + #: settings/serializers/sms.py:7 msgid "Label" msgstr "标签" diff --git a/apps/settings/serializers/security.py b/apps/settings/serializers/security.py index 06e5038fb..51d03eab5 100644 --- a/apps/settings/serializers/security.py +++ b/apps/settings/serializers/security.py @@ -140,3 +140,8 @@ class SecuritySettingSerializer(SecurityPasswordRuleSerializer, SecurityAuthSeri required=True, label=_('Session share'), help_text=_("Enabled, Allows user active session to be shared with other users") ) + SECURITY_CHECK_DIFFERENT_CITY_LOGIN = serializers.BooleanField( + required=False, label=_('Remote Login Protection'), + help_text=_('The system determines whether the login IP address belongs to a common login city. ' + 'If the account is logged in from a common login city, the system sends a remote login reminder') + ) From 17303c05500668f86512213aa77cf248d58c2c5d Mon Sep 17 00:00:00 2001 From: fit2bot <68588906+fit2bot@users.noreply.github.com> Date: Wed, 10 Nov 2021 11:30:48 +0800 Subject: [PATCH 10/15] =?UTF-8?q?pref:=20=E4=BC=98=E5=8C=96MFA=20(#7153)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf: 优化mfa 和登录 * perf: stash * stash * pref: 基本完成 * perf: remove init function * perf: 优化命名 * perf: 优化backends * perf: 基本完成优化 * perf: 修复首页登录时没有 toastr 的问题 Co-authored-by: ibuler Co-authored-by: Jiangjie.Bai <32935519+BaiJiangJie@users.noreply.github.com> --- apps/authentication/api/mfa.py | 91 ++-- apps/authentication/api/password.py | 6 +- apps/authentication/api/token.py | 2 +- apps/authentication/errors.py | 56 +- apps/authentication/mfa/__init__.py | 5 + apps/authentication/mfa/base.py | 72 +++ apps/authentication/mfa/otp.py | 51 ++ apps/authentication/mfa/radius.py | 46 ++ apps/authentication/mfa/sms.py | 60 +++ apps/authentication/middleware.py | 2 +- apps/authentication/mixins.py | 501 +++++++++--------- apps/authentication/serializers.py | 1 + apps/authentication/signals_handlers.py | 6 +- .../templates/authentication/login.html | 3 +- .../{login_otp.html => login_mfa.html} | 11 +- apps/authentication/urls/api_urls.py | 7 +- apps/authentication/urls/view_urls.py | 11 +- apps/authentication/views/login.py | 17 +- apps/authentication/views/mfa.py | 28 +- apps/common/sdk/sms/__init__.py | 67 +-- apps/common/sdk/sms/alibaba.py | 2 +- apps/common/sdk/sms/base.py | 20 + apps/common/sdk/sms/endpoint.py | 51 ++ apps/common/sdk/sms/tencent.py | 3 +- .../sdk/sms/utils.py} | 47 +- apps/jumpserver/settings/custom.py | 2 +- apps/locale/zh/LC_MESSAGES/django.mo | 4 +- apps/locale/zh/LC_MESSAGES/django.po | 464 +++++++++------- apps/notifications/ws.py | 5 +- apps/static/css/style.css | 8 + apps/templates/_base_only_content.html | 2 +- apps/templates/_mfa_login_field.html | 117 ++++ apps/templates/_mfa_otp_login.html | 81 --- apps/templates/_without_nav_base.html | 16 +- apps/users/backends/__init__.py | 0 apps/users/models/user.py | 120 ++--- apps/users/templates/users/_base_otp.html | 2 - apps/users/templates/users/mfa_setting.html | 116 ++++ .../users/user_otp_check_password.html | 4 +- .../templates/users/user_verify_mfa.html | 6 +- apps/users/utils.py | 5 +- apps/users/views/profile/mfa.py | 22 + apps/users/views/profile/otp.py | 196 +++---- apps/users/views/profile/password.py | 14 +- 44 files changed, 1373 insertions(+), 977 deletions(-) create mode 100644 apps/authentication/mfa/__init__.py create mode 100644 apps/authentication/mfa/base.py create mode 100644 apps/authentication/mfa/otp.py create mode 100644 apps/authentication/mfa/radius.py create mode 100644 apps/authentication/mfa/sms.py rename apps/authentication/templates/authentication/{login_otp.html => login_mfa.html} (72%) create mode 100644 apps/common/sdk/sms/base.py create mode 100644 apps/common/sdk/sms/endpoint.py rename apps/{authentication/sms_verify_code.py => common/sdk/sms/utils.py} (69%) create mode 100644 apps/templates/_mfa_login_field.html delete mode 100644 apps/templates/_mfa_otp_login.html create mode 100644 apps/users/backends/__init__.py create mode 100644 apps/users/templates/users/mfa_setting.html diff --git a/apps/authentication/api/mfa.py b/apps/authentication/api/mfa.py index 978c52072..751231819 100644 --- a/apps/authentication/api/mfa.py +++ b/apps/authentication/api/mfa.py @@ -1,6 +1,5 @@ # -*- coding: utf-8 -*- # -import builtins import time from django.utils.translation import ugettext as _ @@ -12,55 +11,76 @@ from rest_framework.serializers import ValidationError from rest_framework.response import Response from common.permissions import IsValidUser, NeedMFAVerify -from users.models.user import MFAType, User +from common.utils import get_logger +from users.models.user import User from ..serializers import OtpVerifySerializer from .. import serializers from .. import errors +from ..mfa.otp import MFAOtp from ..mixins import AuthMixin -__all__ = ['MFAChallengeApi', 'UserOtpVerifyApi', 'SendSMSVerifyCodeApi', 'MFASelectTypeApi'] +logger = get_logger(__name__) + +__all__ = [ + 'MFAChallengeVerifyApi', 'UserOtpVerifyApi', + 'MFASendCodeApi' +] -class MFASelectTypeApi(AuthMixin, CreateAPIView): +# MFASelectAPi 原来的名字 +class MFASendCodeApi(AuthMixin, CreateAPIView): + """ + 选择 MFA 后对应操作 api,koko 目前在用 + """ permission_classes = (AllowAny,) serializer_class = serializers.MFASelectTypeSerializer def perform_create(self, serializer): + username = serializer.validated_data.get('username', '') mfa_type = serializer.validated_data['type'] - if mfa_type == MFAType.SMS_CODE: + if not username: user = self.get_user_from_session() - user.send_sms_code() + else: + user = get_object_or_404(User, username=username) + + mfa_backend = user.get_mfa_backend_by_type(mfa_type) + if not mfa_backend or not mfa_backend.challenge_required: + raise ValidationError('MFA type not support: {} {}'.format(mfa_type, mfa_backend)) + mfa_backend.send_challenge() + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + try: + self.perform_create(serializer) + return Response(serializer.data, status=201) + except Exception as e: + logger.exception(e) + return Response({'error': str(e)}, status=400) -class MFAChallengeApi(AuthMixin, CreateAPIView): +class MFAChallengeVerifyApi(AuthMixin, CreateAPIView): permission_classes = (AllowAny,) serializer_class = serializers.MFAChallengeSerializer def perform_create(self, serializer): - try: - user = self.get_user_from_session() - code = serializer.validated_data.get('code') - mfa_type = serializer.validated_data.get('type', MFAType.OTP) + user = self.get_user_from_session() + code = serializer.validated_data.get('code') + mfa_type = serializer.validated_data.get('type', '') + self._do_check_user_mfa(code, mfa_type, user) - valid = user.check_mfa(code, mfa_type=mfa_type) - if not valid: - self.request.session['auth_mfa'] = '' - raise errors.MFAFailedError( - username=user.username, request=self.request, ip=self.get_request_ip() - ) - else: - self.request.session['auth_mfa'] = '1' + def create(self, request, *args, **kwargs): + try: + super().create(request, *args, **kwargs) + return Response({'msg': 'ok'}) except errors.AuthFailedError as e: data = {"error": e.error, "msg": e.msg} raise ValidationError(data) except errors.NeedMoreInfoError as e: return Response(e.as_data(), status=200) - def create(self, request, *args, **kwargs): - super().create(request, *args, **kwargs) - return Response({'msg': 'ok'}) - class UserOtpVerifyApi(CreateAPIView): permission_classes = (IsValidUser,) @@ -73,30 +93,17 @@ class UserOtpVerifyApi(CreateAPIView): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) code = serializer.validated_data["code"] + otp = MFAOtp(request.user) - if request.user.check_mfa(code): + ok, error = otp.check_code(code) + if ok: request.session["MFA_VERIFY_TIME"] = int(time.time()) return Response({"ok": "1"}) else: - return Response({"error": _("Code is invalid")}, status=400) + return Response({"error": _("Code is invalid") + ", " + error}, status=400) def get_permissions(self): - if self.request.method.lower() == 'get' and settings.SECURITY_VIEW_AUTH_NEED_MFA: + if self.request.method.lower() == 'get' \ + and settings.SECURITY_VIEW_AUTH_NEED_MFA: self.permission_classes = [NeedMFAVerify] return super().get_permissions() - - -class SendSMSVerifyCodeApi(AuthMixin, CreateAPIView): - permission_classes = (AllowAny,) - - def create(self, request, *args, **kwargs): - username = request.data.get('username', '') - username = username.strip() - if username: - user = get_object_or_404(User, username=username) - else: - user = self.get_user_from_session() - if not user.mfa_enabled: - raise errors.NotEnableMFAError - timeout = user.send_sms_code() - return Response({'code': 'ok', 'timeout': timeout}) diff --git a/apps/authentication/api/password.py b/apps/authentication/api/password.py index af8b41358..95ebe6edc 100644 --- a/apps/authentication/api/password.py +++ b/apps/authentication/api/password.py @@ -4,7 +4,7 @@ from rest_framework.response import Response from authentication.serializers import PasswordVerifySerializer from common.permissions import IsValidUser from authentication.mixins import authenticate -from authentication.errors import PasswdInvalid +from authentication.errors import PasswordInvalid from authentication.mixins import AuthMixin @@ -20,7 +20,7 @@ class UserPasswordVerifyApi(AuthMixin, CreateAPIView): user = authenticate(request=request, username=user.username, password=password) if not user: - raise PasswdInvalid + raise PasswordInvalid - self.set_passwd_verify_on_session(user) + self.mark_password_ok(user) return Response() diff --git a/apps/authentication/api/token.py b/apps/authentication/api/token.py index f7516496c..d8e8eb6fc 100644 --- a/apps/authentication/api/token.py +++ b/apps/authentication/api/token.py @@ -40,5 +40,5 @@ class TokenCreateApi(AuthMixin, CreateAPIView): return Response(e.as_data(), status=400) except errors.NeedMoreInfoError as e: return Response(e.as_data(), status=200) - except errors.PasswdTooSimple as e: + except errors.PasswordTooSimple as e: return redirect(e.url) diff --git a/apps/authentication/errors.py b/apps/authentication/errors.py index 8a6f219bd..19b13ab8e 100644 --- a/apps/authentication/errors.py +++ b/apps/authentication/errors.py @@ -8,7 +8,6 @@ from rest_framework import status from common.exceptions import JMSException from .signals import post_auth_failed from users.utils import LoginBlockUtil, MFABlockUtils -from users.models import MFAType reason_password_failed = 'password_failed' reason_password_decrypt_failed = 'password_decrypt_failed' @@ -60,22 +59,11 @@ block_mfa_msg = _( "The account has been locked " "(please contact admin to unlock it or try again after {} minutes)" ) -otp_failed_msg = _( - "One-time password invalid, or ntp sync server time, " +mfa_error_msg = _( + "{error}," "You can also try {times_try} times " "(The account will be temporarily locked for {block_time} minutes)" ) -sms_failed_msg = _( - "SMS verify code invalid," - "You can also try {times_try} times " - "(The account will be temporarily locked for {block_time} minutes)" -) -mfa_type_failed_msg = _( - "The MFA type({mfa_type}) is not supported, " - "You can also try {times_try} times " - "(The account will be temporarily locked for {block_time} minutes)" -) - mfa_required_msg = _("MFA required") mfa_unset_msg = _("MFA not set, please set it first") otp_unset_msg = _("OTP not set, please set it first") @@ -151,29 +139,19 @@ class MFAFailedError(AuthFailedNeedLogMixin, AuthFailedError): error = reason_mfa_failed msg: str - def __init__(self, username, request, ip, mfa_type=MFAType.OTP): - util = MFABlockUtils(username, ip) - util.incr_failed_count() + def __init__(self, username, request, ip, mfa_type, error): + super().__init__(username=username, request=request) - times_remainder = util.get_remainder_times() + util = MFABlockUtils(username, ip) + times_remainder = util.incr_failed_count() block_time = settings.SECURITY_LOGIN_LIMIT_TIME if times_remainder: - if mfa_type == MFAType.OTP: - self.msg = otp_failed_msg.format( - times_try=times_remainder, block_time=block_time - ) - elif mfa_type == MFAType.SMS_CODE: - self.msg = sms_failed_msg.format( - times_try=times_remainder, block_time=block_time - ) - else: - self.msg = mfa_type_failed_msg.format( - mfa_type=mfa_type, times_try=times_remainder, block_time=block_time - ) + self.msg = mfa_error_msg.format( + error=error, times_try=times_remainder, block_time=block_time + ) else: self.msg = block_mfa_msg.format(settings.SECURITY_LOGIN_LIMIT_TIME) - super().__init__(username=username, request=request) class BlockMFAError(AuthFailedNeedLogMixin, AuthFailedError): @@ -228,7 +206,7 @@ class MFARequiredError(NeedMoreInfoError): msg = mfa_required_msg error = 'mfa_required' - def __init__(self, error='', msg='', mfa_types=tuple(MFAType)): + def __init__(self, error='', msg='', mfa_types=()): super().__init__(error=error, msg=msg) self.choices = mfa_types @@ -305,7 +283,7 @@ class SSOAuthClosed(JMSException): default_detail = _('SSO auth closed') -class PasswdTooSimple(JMSException): +class PasswordTooSimple(JMSException): default_code = 'passwd_too_simple' default_detail = _('Your password is too simple, please change it for security') @@ -314,7 +292,7 @@ class PasswdTooSimple(JMSException): self.url = url -class PasswdNeedUpdate(JMSException): +class PasswordNeedUpdate(JMSException): default_code = 'passwd_need_update' default_detail = _('You should to change your password before login') @@ -357,7 +335,7 @@ class FeiShuNotBound(JMSException): default_detail = 'FeiShu is not bound' -class PasswdInvalid(JMSException): +class PasswordInvalid(JMSException): default_code = 'passwd_invalid' default_detail = _('Your password is invalid') @@ -368,10 +346,6 @@ class NotHaveUpDownLoadPerm(JMSException): default_detail = _('No upload or download permission') -class NotEnableMFAError(JMSException): - default_detail = mfa_unset_msg - - class OTPBindRequiredError(JMSException): default_detail = otp_unset_msg @@ -380,11 +354,13 @@ class OTPBindRequiredError(JMSException): self.url = url -class OTPCodeRequiredError(AuthFailedError): +class MFACodeRequiredError(AuthFailedError): msg = _("Please enter MFA code") + class SMSCodeRequiredError(AuthFailedError): msg = _("Please enter SMS code") + class UserPhoneNotSet(AuthFailedError): msg = _('Phone not set') diff --git a/apps/authentication/mfa/__init__.py b/apps/authentication/mfa/__init__.py new file mode 100644 index 000000000..16279eb0d --- /dev/null +++ b/apps/authentication/mfa/__init__.py @@ -0,0 +1,5 @@ +from .otp import MFAOtp, otp_failed_msg +from .sms import MFASms +from .radius import MFARadius + +MFA_BACKENDS = [MFAOtp, MFASms, MFARadius] diff --git a/apps/authentication/mfa/base.py b/apps/authentication/mfa/base.py new file mode 100644 index 000000000..2158b8fe1 --- /dev/null +++ b/apps/authentication/mfa/base.py @@ -0,0 +1,72 @@ +import abc + +from django.utils.translation import ugettext_lazy as _ + + +class BaseMFA(abc.ABC): + placeholder = _('Please input security code') + + def __init__(self, user): + """ + :param user: Authenticated user, Anonymous or None + 因为首页登录时,可能没法获取到一些状态 + """ + self.user = user + + def is_authenticated(self): + return self.user and self.user.is_authenticated + + @property + @abc.abstractmethod + def name(self): + return '' + + @property + @abc.abstractmethod + def display_name(self): + return '' + + @staticmethod + def challenge_required(): + return False + + def send_challenge(self): + pass + + @abc.abstractmethod + def check_code(self, code) -> tuple: + return False, 'Error msg' + + @abc.abstractmethod + def is_active(self): + return False + + @staticmethod + @abc.abstractmethod + def global_enabled(): + return False + + @abc.abstractmethod + def get_enable_url(self) -> str: + return '' + + @abc.abstractmethod + def get_disable_url(self) -> str: + return '' + + @abc.abstractmethod + def disable(self): + pass + + @abc.abstractmethod + def can_disable(self) -> bool: + return True + + @staticmethod + def help_text_of_enable(): + return '' + + @staticmethod + def help_text_of_disable(): + return '' + diff --git a/apps/authentication/mfa/otp.py b/apps/authentication/mfa/otp.py new file mode 100644 index 000000000..9d67c4ae2 --- /dev/null +++ b/apps/authentication/mfa/otp.py @@ -0,0 +1,51 @@ +from django.utils.translation import gettext_lazy as _ +from django.shortcuts import reverse + +from .base import BaseMFA + + +otp_failed_msg = _("OTP code invalid, or server time error") + + +class MFAOtp(BaseMFA): + name = 'otp' + display_name = _('OTP') + + def check_code(self, code): + from users.utils import check_otp_code + assert self.is_authenticated() + + ok = check_otp_code(self.user.otp_secret_key, code) + msg = '' if ok else otp_failed_msg + return ok, msg + + def is_active(self): + if not self.is_authenticated(): + return True + return self.user.otp_secret_key + + @staticmethod + def global_enabled(): + return True + + def get_enable_url(self) -> str: + return reverse('authentication:user-otp-enable-start') + + def disable(self): + assert self.is_authenticated() + self.user.otp_secret_key = '' + self.user.save(update_fields=['otp_secret_key']) + + def can_disable(self) -> bool: + return True + + def get_disable_url(self): + return reverse('authentication:user-otp-disable') + + @staticmethod + def help_text_of_enable(): + return _("Virtual OTP based MFA") + + def help_text_of_disable(self): + return '' + diff --git a/apps/authentication/mfa/radius.py b/apps/authentication/mfa/radius.py new file mode 100644 index 000000000..ad20456c1 --- /dev/null +++ b/apps/authentication/mfa/radius.py @@ -0,0 +1,46 @@ +from django.utils.translation import ugettext_lazy as _ +from django.conf import settings + +from .base import BaseMFA +from ..backends.radius import RadiusBackend + +mfa_failed_msg = _("Radius verify code invalid") + + +class MFARadius(BaseMFA): + name = 'otp_radius' + display_name = _('Radius MFA') + + def check_code(self, code): + assert self.is_authenticated() + backend = RadiusBackend() + username = self.user.username + user = backend.authenticate( + None, username=username, password=code + ) + ok = user is not None + msg = '' if ok else mfa_failed_msg + return ok, msg + + def is_active(self): + return True + + @staticmethod + def global_enabled(): + return settings.OTP_IN_RADIUS + + def get_enable_url(self) -> str: + return '' + + def can_disable(self): + return False + + def disable(self): + return '' + + @staticmethod + def help_text_of_disable(): + return _("Radius global enabled, cannot disable") + + def get_disable_url(self) -> str: + return '' diff --git a/apps/authentication/mfa/sms.py b/apps/authentication/mfa/sms.py new file mode 100644 index 000000000..cc2855cfd --- /dev/null +++ b/apps/authentication/mfa/sms.py @@ -0,0 +1,60 @@ +from django.utils.translation import ugettext_lazy as _ +from django.conf import settings + +from .base import BaseMFA +from common.sdk.sms import SendAndVerifySMSUtil + +sms_failed_msg = _("SMS verify code invalid") + + +class MFASms(BaseMFA): + name = 'sms' + display_name = _("SMS") + placeholder = _("SMS verification code") + + def __init__(self, user): + super().__init__(user) + phone = user.phone if self.is_authenticated() else '' + self.sms = SendAndVerifySMSUtil(phone) + + def check_code(self, code): + assert self.is_authenticated() + ok = self.sms.verify(code) + msg = '' if ok else sms_failed_msg + return ok, msg + + def is_active(self): + if not self.is_authenticated(): + return True + return self.user.phone + + @staticmethod + def challenge_required(): + return True + + def send_challenge(self): + self.sms.gen_and_send() + + @staticmethod + def global_enabled(): + return settings.SMS_ENABLED + + def get_enable_url(self) -> str: + return '/ui/#/users/profile/?activeTab=ProfileUpdate' + + def can_disable(self) -> bool: + return True + + def disable(self): + return '/ui/#/users/profile/?activeTab=ProfileUpdate' + + @staticmethod + def help_text_of_enable(): + return _("Set phone number to enable") + + @staticmethod + def help_text_of_disable(): + return _("Clear phone number to disable") + + def get_disable_url(self) -> str: + return '/ui/#/users/profile/?activeTab=ProfileUpdate' diff --git a/apps/authentication/middleware.py b/apps/authentication/middleware.py index 59eabff75..9a5e4e793 100644 --- a/apps/authentication/middleware.py +++ b/apps/authentication/middleware.py @@ -10,5 +10,5 @@ class MFAMiddleware: if request.path.find('/auth/login/otp/') > -1: return response if request.session.get('auth_mfa_required'): - return redirect('authentication:login-otp') + return redirect('authentication:login-mfa') return response diff --git a/apps/authentication/mixins.py b/apps/authentication/mixins.py index d07cfb0d7..a7d845662 100644 --- a/apps/authentication/mixins.py +++ b/apps/authentication/mixins.py @@ -1,24 +1,26 @@ # -*- coding: utf-8 -*- # import inspect -from django.utils.http import urlencode from functools import partial import time +from typing import Callable +from django.utils.http import urlencode from django.core.cache import cache from django.conf import settings from django.urls import reverse_lazy from django.contrib import auth from django.utils.translation import ugettext as _ +from rest_framework.request import Request from django.contrib.auth import ( BACKEND_SESSION_KEY, _get_backends, PermissionDenied, user_login_failed, _clean_credentials ) -from django.shortcuts import reverse, redirect +from django.shortcuts import reverse, redirect, get_object_or_404 from common.utils import get_object_or_none, get_request_ip, get_logger, bulk_get, FlashMessageUtil from acls.models import LoginACL -from users.models import User, MFAType +from users.models import User from users.utils import LoginBlockUtil, MFABlockUtils from . import errors from .utils import rsa_decrypt, gen_key_pair @@ -32,8 +34,7 @@ def check_backend_can_auth(username, backend_path, allowed_auth_backends): if allowed_auth_backends is not None and backend_path not in allowed_auth_backends: logger.debug('Skip user auth backend: {}, {} not in'.format( username, backend_path, ','.join(allowed_auth_backends) - ) - ) + )) return False return True @@ -109,17 +110,18 @@ class PasswordEncryptionViewMixin: def decrypt_passwd(self, raw_passwd): # 获取解密密钥,对密码进行解密 rsa_private_key = self.request.session.get(RSA_PRIVATE_KEY) - if rsa_private_key is not None: - try: - return rsa_decrypt(raw_passwd, rsa_private_key) - except Exception as e: - logger.error(e, exc_info=True) - logger.error( - f'Decrypt password failed: password[{raw_passwd}] ' - f'rsa_private_key[{rsa_private_key}]' - ) - return None - return raw_passwd + if rsa_private_key is None: + return raw_passwd + + try: + return rsa_decrypt(raw_passwd, rsa_private_key) + except Exception as e: + logger.error(e, exc_info=True) + logger.error( + f'Decrypt password failed: password[{raw_passwd}] ' + f'rsa_private_key[{rsa_private_key}]' + ) + return None def get_request_ip(self): ip = '' @@ -132,7 +134,7 @@ class PasswordEncryptionViewMixin: # 生成加解密密钥对,public_key传递给前端,private_key存入session中供解密使用 rsa_public_key = self.request.session.get(RSA_PUBLIC_KEY) rsa_private_key = self.request.session.get(RSA_PRIVATE_KEY) - if not all((rsa_private_key, rsa_public_key)): + if not all([rsa_private_key, rsa_public_key]): rsa_private_key, rsa_public_key = gen_key_pair() rsa_public_key = rsa_public_key.replace('\n', '\\n') self.request.session[RSA_PRIVATE_KEY] = rsa_private_key @@ -144,49 +146,9 @@ class PasswordEncryptionViewMixin: return super().get_context_data(**kwargs) -class AuthMixin(PasswordEncryptionViewMixin): - request = None - partial_credential_error = None - - key_prefix_captcha = "_LOGIN_INVALID_{}" - - def get_user_from_session(self): - if self.request.session.is_empty(): - raise errors.SessionEmptyError() - - if all((self.request.user, - not self.request.user.is_anonymous, - BACKEND_SESSION_KEY in self.request.session)): - user = self.request.user - user.backend = self.request.session[BACKEND_SESSION_KEY] - return user - - user_id = self.request.session.get('user_id') - if not user_id: - user = None - else: - user = get_object_or_none(User, pk=user_id) - if not user: - raise errors.SessionEmptyError() - user.backend = self.request.session.get("auth_backend") - return user - - def _check_is_block(self, username, raise_exception=True): - ip = self.get_request_ip() - if LoginBlockUtil(username, ip).is_block(): - logger.warn('Ip was blocked' + ': ' + username + ':' + ip) - exception = errors.BlockLoginError(username=username, ip=ip) - if raise_exception: - raise errors.BlockLoginError(username=username, ip=ip) - else: - return exception - - def check_is_block(self, raise_exception=True): - if hasattr(self.request, 'data'): - username = self.request.data.get("username") - else: - username = self.request.POST.get("username") - self._check_is_block(username, raise_exception) +class CommonMixin(PasswordEncryptionViewMixin): + request: Request + get_request_ip: Callable def raise_credential_error(self, error): raise self.partial_credential_error(error=error) @@ -197,6 +159,31 @@ class AuthMixin(PasswordEncryptionViewMixin): ip=ip, request=request ) + def get_user_from_session(self): + if self.request.session.is_empty(): + raise errors.SessionEmptyError() + + if all([ + self.request.user, + not self.request.user.is_anonymous, + BACKEND_SESSION_KEY in self.request.session + ]): + user = self.request.user + user.backend = self.request.session[BACKEND_SESSION_KEY] + return user + + user_id = self.request.session.get('user_id') + auth_password = self.request.session.get('auth_password') + auth_expired_at = self.request.session.get('auth_password_expired_at') + auth_expired = auth_expired_at < time.time() if auth_expired_at else False + + if not user_id or not auth_password or auth_expired: + raise errors.SessionEmptyError() + + user = get_object_or_404(User, pk=user_id) + user.backend = self.request.session.get("auth_backend") + return user + def get_auth_data(self, decrypt_passwd=False): request = self.request if hasattr(request, 'data'): @@ -214,6 +201,31 @@ class AuthMixin(PasswordEncryptionViewMixin): password = password + challenge.strip() return username, password, public_key, ip, auto_login + +class AuthPreCheckMixin: + request: Request + get_request_ip: Callable + raise_credential_error: Callable + + def _check_is_block(self, username, raise_exception=True): + ip = self.get_request_ip() + is_block = LoginBlockUtil(username, ip).is_block() + if not is_block: + return + logger.warn('Ip was blocked' + ': ' + username + ':' + ip) + exception = errors.BlockLoginError(username=username, ip=ip) + if raise_exception: + raise errors.BlockLoginError(username=username, ip=ip) + else: + return exception + + def check_is_block(self, raise_exception=True): + if hasattr(self.request, 'data'): + username = self.request.data.get("username") + else: + username = self.request.POST.get("username") + self._check_is_block(username, raise_exception) + def _check_only_allow_exists_user_auth(self, username): # 仅允许预先存在的用户认证 if not settings.ONLY_ALLOW_EXIST_USER_AUTH: @@ -224,105 +236,92 @@ class AuthMixin(PasswordEncryptionViewMixin): logger.error(f"Only allow exist user auth, login failed: {username}") self.raise_credential_error(errors.reason_user_not_exist) - def _check_auth_user_is_valid(self, username, password, public_key): - user = authenticate(self.request, username=username, password=password, public_key=public_key) - if not user: - self.raise_credential_error(errors.reason_password_failed) - elif user.is_expired: - self.raise_credential_error(errors.reason_user_expired) - elif not user.is_active: - self.raise_credential_error(errors.reason_user_inactive) - return user - def _check_login_mfa_login_if_need(self, user): +class MFAMixin: + request: Request + get_user_from_session: Callable + get_request_ip: Callable + + def _check_login_page_mfa_if_need(self, user): + if not settings.SECURITY_MFA_IN_LOGIN_PAGE: + return + request = self.request - if hasattr(request, 'data'): - data = request.data - else: - data = request.POST + data = request.data if hasattr(request, 'data') else request.POST code = data.get('code') - mfa_type = data.get('mfa_type') - if settings.SECURITY_MFA_IN_LOGIN_PAGE and mfa_type: - if not code: - if mfa_type == MFAType.OTP and bool(user.otp_secret_key): - raise errors.OTPCodeRequiredError - elif mfa_type == MFAType.SMS_CODE: - raise errors.SMSCodeRequiredError - self.check_user_mfa(code, mfa_type, user=user) + mfa_type = data.get('mfa_type', 'otp') + if not code: + raise errors.MFACodeRequiredError + self._do_check_user_mfa(code, mfa_type, user=user) - def _check_login_acl(self, user, ip): - # ACL 限制用户登录 - is_allowed, limit_type = LoginACL.allow_user_to_login(user, ip) - if not is_allowed: - if limit_type == 'ip': - raise errors.LoginIPNotAllowed(username=user.username, request=self.request) - elif limit_type == 'time': - raise errors.TimePeriodNotAllowed(username=user.username, request=self.request) + def check_user_mfa_if_need(self, user): + if self.request.session.get('auth_mfa'): + return + if not user.mfa_enabled: + return - def set_login_failed_mark(self): + active_mfa_mapper = user.active_mfa_backends_mapper + if not active_mfa_mapper: + url = reverse('authentication:user-otp-enable-start') + raise errors.MFAUnsetError(user, self.request, url) + raise errors.MFARequiredError(mfa_types=tuple(active_mfa_mapper.keys())) + + def mark_mfa_ok(self, mfa_type): + self.request.session['auth_mfa'] = 1 + self.request.session['auth_mfa_time'] = time.time() + self.request.session['auth_mfa_required'] = 0 + self.request.session['auth_mfa_type'] = mfa_type + + def clean_mfa_mark(self): + keys = ['auth_mfa', 'auth_mfa_time', 'auth_mfa_required', 'auth_mfa_type'] + for k in keys: + self.request.session.pop(k, '') + + def check_mfa_is_block(self, username, ip, raise_exception=True): + blocked = MFABlockUtils(username, ip).is_block() + if not blocked: + return + logger.warn('Ip was blocked' + ': ' + username + ':' + ip) + exception = errors.BlockMFAError(username=username, request=self.request, ip=ip) + if raise_exception: + raise exception + else: + return exception + + def _do_check_user_mfa(self, code, mfa_type, user=None): + user = user if user else self.get_user_from_session() + if not user.mfa_enabled: + return + + # 监测 MFA 是不是屏蔽了 ip = self.get_request_ip() - cache.set(self.key_prefix_captcha.format(ip), 1, 3600) + self.check_mfa_is_block(user.username, ip) - def set_passwd_verify_on_session(self, user: User): - self.request.session['user_id'] = str(user.id) - self.request.session['auth_password'] = 1 - self.request.session['auth_password_expired_at'] = time.time() + settings.AUTH_EXPIRED_SECONDS + ok = False + mfa_backend = user.get_mfa_backend_by_type(mfa_type) + if mfa_backend: + ok, msg = mfa_backend.check_code(code) + else: + msg = _('The MFA type({}) is not supported'.format(mfa_type)) - def check_is_need_captcha(self): - # 最近有登录失败时需要填写验证码 - ip = get_request_ip(self.request) - need = cache.get(self.key_prefix_captcha.format(ip)) - return need + if ok: + self.mark_mfa_ok(mfa_type) + return - def check_user_auth(self, decrypt_passwd=False): - self.check_is_block() - username, password, public_key, ip, auto_login = self.get_auth_data(decrypt_passwd) + raise errors.MFAFailedError( + username=user.username, + request=self.request, + ip=ip, mfa_type=mfa_type, + error=msg + ) - self._check_only_allow_exists_user_auth(username) - user = self._check_auth_user_is_valid(username, password, public_key) - # 校验login-acl规则 - self._check_login_acl(user, ip) - self._check_password_require_reset_or_not(user) - self._check_passwd_is_too_simple(user, password) - self._check_passwd_need_update(user) + @staticmethod + def get_user_mfa_context(user=None): + mfa_backends = User.get_user_mfa_backends(user) + return {'mfa_backends': mfa_backends} - # 校验login-mfa, 如果登录页面上显示 mfa 的话 - self._check_login_mfa_login_if_need(user) - - LoginBlockUtil(username, ip).clean_failed_count() - request = self.request - request.session['auth_password'] = 1 - request.session['user_id'] = str(user.id) - request.session['auto_login'] = auto_login - request.session['auth_backend'] = getattr(user, 'backend', settings.AUTH_BACKEND_MODEL) - return user - - def _check_is_local_user(self, user: User): - if user.source != User.Source.local: - raise self.raise_credential_error(error=errors.only_local_users_are_allowed) - - def check_oauth2_auth(self, user: User, auth_backend): - ip = self.get_request_ip() - request = self.request - - self._set_partial_credential_error(user.username, ip, request) - - if user.is_expired: - self.raise_credential_error(errors.reason_user_expired) - elif not user.is_active: - self.raise_credential_error(errors.reason_user_inactive) - - self._check_is_block(user.username) - self._check_login_acl(user, ip) - - LoginBlockUtil(user.username, ip).clean_failed_count() - MFABlockUtils(user.username, ip).clean_failed_count() - - request.session['auth_password'] = 1 - request.session['user_id'] = str(user.id) - request.session['auth_backend'] = auth_backend - return user +class AuthPostCheckMixin: @classmethod def generate_reset_password_url_with_flash_msg(cls, user, message): reset_passwd_url = reverse('authentication:reset-password') @@ -344,14 +343,14 @@ class AuthMixin(PasswordEncryptionViewMixin): if user.is_superuser and password == 'admin': message = _('Your password is too simple, please change it for security') url = cls.generate_reset_password_url_with_flash_msg(user, message=message) - raise errors.PasswdTooSimple(url) + raise errors.PasswordTooSimple(url) @classmethod def _check_passwd_need_update(cls, user: User): if user.need_update_password: message = _('You should to change your password before login') url = cls.generate_reset_password_url_with_flash_msg(user, message) - raise errors.PasswdNeedUpdate(url) + raise errors.PasswordNeedUpdate(url) @classmethod def _check_password_require_reset_or_not(cls, user: User): @@ -360,76 +359,20 @@ class AuthMixin(PasswordEncryptionViewMixin): url = cls.generate_reset_password_url_with_flash_msg(user, message) raise errors.PasswordRequireResetError(url) - def check_user_auth_if_need(self, decrypt_passwd=False): - request = self.request - if request.session.get('auth_password') and \ - request.session.get('user_id'): - user = self.get_user_from_session() - if user: - return user - return self.check_user_auth(decrypt_passwd=decrypt_passwd) - def check_user_mfa_if_need(self, user): - if self.request.session.get('auth_mfa'): +class AuthACLMixin: + request: Request + get_request_ip: Callable + + def _check_login_acl(self, user, ip): + # ACL 限制用户登录 + is_allowed, limit_type = LoginACL.allow_user_to_login(user, ip) + if is_allowed: return - if settings.OTP_IN_RADIUS: - return - if not user.mfa_enabled: - return - - unset, url = user.mfa_enabled_but_not_set() - if unset: - raise errors.MFAUnsetError(user, self.request, url) - raise errors.MFARequiredError(mfa_types=user.get_supported_mfa_types()) - - def mark_mfa_ok(self, mfa_type=MFAType.OTP): - self.request.session['auth_mfa'] = 1 - self.request.session['auth_mfa_time'] = time.time() - self.request.session['auth_mfa_required'] = '' - self.request.session['auth_mfa_type'] = mfa_type - - def clean_mfa_mark(self): - self.request.session['auth_mfa'] = '' - self.request.session['auth_mfa_time'] = '' - self.request.session['auth_mfa_required'] = '' - self.request.session['auth_mfa_type'] = '' - - def check_mfa_is_block(self, username, ip, raise_exception=True): - blocked = MFABlockUtils(username, ip).is_block() - if not blocked: - return - logger.warn('Ip was blocked' + ': ' + username + ':' + ip) - exception = errors.BlockMFAError(username=username, request=self.request, ip=ip) - if raise_exception: - raise exception - else: - return exception - - def check_user_mfa(self, code, mfa_type=MFAType.OTP, user=None): - user = user if user else self.get_user_from_session() - if not user.mfa_enabled: - return - - if not bool(user.phone) and mfa_type == MFAType.SMS_CODE: - raise errors.UserPhoneNotSet - - if not bool(user.otp_secret_key) and mfa_type == MFAType.OTP: - self.set_passwd_verify_on_session(user) - raise errors.OTPBindRequiredError(reverse_lazy('authentication:user-otp-enable-bind')) - - ip = self.get_request_ip() - self.check_mfa_is_block(user.username, ip) - ok = user.check_mfa(code, mfa_type=mfa_type) - - if ok: - self.mark_mfa_ok() - return - - raise errors.MFAFailedError( - username=user.username, - request=self.request, - ip=ip, mfa_type=mfa_type, - ) + if limit_type == 'ip': + raise errors.LoginIPNotAllowed(username=user.username, request=self.request) + elif limit_type == 'time': + raise errors.TimePeriodNotAllowed(username=user.username, request=self.request) def get_ticket(self): from tickets.models import Ticket @@ -480,11 +423,99 @@ class AuthMixin(PasswordEncryptionViewMixin): self.get_ticket_or_create(confirm_setting) self.check_user_login_confirm() + +class AuthMixin(CommonMixin, AuthPreCheckMixin, AuthACLMixin, MFAMixin, AuthPostCheckMixin): + request = None + partial_credential_error = None + + key_prefix_captcha = "_LOGIN_INVALID_{}" + + def _check_auth_user_is_valid(self, username, password, public_key): + user = authenticate( + self.request, username=username, + password=password, public_key=public_key + ) + if not user: + self.raise_credential_error(errors.reason_password_failed) + elif user.is_expired: + self.raise_credential_error(errors.reason_user_expired) + elif not user.is_active: + self.raise_credential_error(errors.reason_user_inactive) + return user + + def set_login_failed_mark(self): + ip = self.get_request_ip() + cache.set(self.key_prefix_captcha.format(ip), 1, 3600) + + def check_is_need_captcha(self): + # 最近有登录失败时需要填写验证码 + ip = get_request_ip(self.request) + need = cache.get(self.key_prefix_captcha.format(ip)) + return need + + def check_user_auth(self, decrypt_passwd=False): + # pre check + self.check_is_block() + username, password, public_key, ip, auto_login = self.get_auth_data(decrypt_passwd) + self._check_only_allow_exists_user_auth(username) + + # check auth + user = self._check_auth_user_is_valid(username, password, public_key) + + # 校验login-acl规则 + self._check_login_acl(user, ip) + + # post check + self._check_password_require_reset_or_not(user) + self._check_passwd_is_too_simple(user, password) + self._check_passwd_need_update(user) + + # 校验login-mfa, 如果登录页面上显示 mfa 的话 + self._check_login_page_mfa_if_need(user) + + # 标记密码验证成功 + self.mark_password_ok(user=user, auto_login=auto_login) + LoginBlockUtil(user.username, ip).clean_failed_count() + return user + + def mark_password_ok(self, user, auto_login=False): + request = self.request + request.session['auth_password'] = 1 + request.session['auth_password_expired_at'] = time.time() + settings.AUTH_EXPIRED_SECONDS + request.session['user_id'] = str(user.id) + request.session['auto_login'] = auto_login + request.session['auth_backend'] = getattr(user, 'backend', settings.AUTH_BACKEND_MODEL) + + def check_oauth2_auth(self, user: User, auth_backend): + ip = self.get_request_ip() + request = self.request + + self._set_partial_credential_error(user.username, ip, request) + + if user.is_expired: + self.raise_credential_error(errors.reason_user_expired) + elif not user.is_active: + self.raise_credential_error(errors.reason_user_inactive) + + self._check_is_block(user.username) + self._check_login_acl(user, ip) + + LoginBlockUtil(user.username, ip).clean_failed_count() + MFABlockUtils(user.username, ip).clean_failed_count() + + self.mark_password_ok(user, False) + return user + + def check_user_auth_if_need(self, decrypt_passwd=False): + request = self.request + if not request.session.get('auth_password'): + return self.check_user_auth(decrypt_passwd=decrypt_passwd) + return self.get_user_from_session() + def clear_auth_mark(self): - self.request.session['auth_password'] = '' - self.request.session['auth_user_id'] = '' - self.request.session['auth_confirm'] = '' - self.request.session['auth_ticket_id'] = '' + keys = ['auth_password', 'user_id', 'auth_confirm', 'auth_ticket_id'] + for k in keys: + self.request.session.pop(k, '') def send_auth_signal(self, success=True, user=None, username='', reason=''): if success: @@ -503,31 +534,3 @@ class AuthMixin(PasswordEncryptionViewMixin): if args: guard_url = "%s?%s" % (guard_url, args) return redirect(guard_url) - - @staticmethod - def get_user_mfa_methods(user=None): - otp_enabled = user.otp_secret_key if user else True - # 没有用户时,或者有用户并且有电话配置 - sms_enabled = any([user and user.phone, not user]) \ - and settings.SMS_ENABLED and settings.XPACK_ENABLED - - methods = [ - { - 'name': 'otp', - 'label': 'MFA', - 'enable': otp_enabled, - 'selected': False, - }, - { - 'name': 'sms', - 'label': _('SMS'), - 'enable': sms_enabled, - 'selected': False, - }, - ] - - for item in methods: - if item['enable']: - item['selected'] = True - break - return methods diff --git a/apps/authentication/serializers.py b/apps/authentication/serializers.py index 548819089..a87e1e942 100644 --- a/apps/authentication/serializers.py +++ b/apps/authentication/serializers.py @@ -78,6 +78,7 @@ class BearerTokenSerializer(serializers.Serializer): class MFASelectTypeSerializer(serializers.Serializer): type = serializers.CharField() + username = serializers.CharField(required=False, allow_blank=True, allow_null=True) class MFAChallengeSerializer(serializers.Serializer): diff --git a/apps/authentication/signals_handlers.py b/apps/authentication/signals_handlers.py index 87b177ff5..d895c8498 100644 --- a/apps/authentication/signals_handlers.py +++ b/apps/authentication/signals_handlers.py @@ -13,11 +13,11 @@ from .signals import post_auth_success, post_auth_failed @receiver(user_logged_in) def on_user_auth_login_success(sender, user, request, **kwargs): - # 开启了 MFA,且没有校验过 - - if user.mfa_enabled and not settings.OTP_IN_RADIUS and not request.session.get('auth_mfa'): + # 开启了 MFA,且没有校验过, 可以全局校验, middleware 中可以全局管理 oidc 等第三方认证的 MFA + if user.mfa_enabled and not request.session.get('auth_mfa'): request.session['auth_mfa_required'] = 1 + # 单点登录,超过了自动退出 if settings.USER_LOGIN_SINGLE_MACHINE_ENABLED: user_id = 'single_machine_login_' + str(user.id) session_key = cache.get(user_id) diff --git a/apps/authentication/templates/authentication/login.html b/apps/authentication/templates/authentication/login.html index f968e325c..a6550e725 100644 --- a/apps/authentication/templates/authentication/login.html +++ b/apps/authentication/templates/authentication/login.html @@ -160,7 +160,7 @@ {% bootstrap_field form.challenge show_label=False %} {% elif form.mfa_type %}
- {% include '_mfa_otp_login.html' %} + {% include '_mfa_login_field.html' %}
{% elif form.captcha %}
@@ -208,6 +208,7 @@
+{% include '_foot_js.html' %} {% endblock %} diff --git a/apps/authentication/urls/api_urls.py b/apps/authentication/urls/api_urls.py index 9f9690c74..1a6c43dd7 100644 --- a/apps/authentication/urls/api_urls.py +++ b/apps/authentication/urls/api_urls.py @@ -25,10 +25,11 @@ urlpatterns = [ path('auth/', api.TokenCreateApi.as_view(), name='user-auth'), path('tokens/', api.TokenCreateApi.as_view(), name='auth-token'), - path('mfa/challenge/', api.MFAChallengeApi.as_view(), name='mfa-challenge'), - path('mfa/select/', api.MFASelectTypeApi.as_view(), name='mfa-select'), + path('mfa/verify/', api.MFAChallengeVerifyApi.as_view(), name='mfa-verify'), + path('mfa/challenge/', api.MFAChallengeVerifyApi.as_view(), name='mfa-challenge'), + path('mfa/select/', api.MFASendCodeApi.as_view(), name='mfa-select'), + path('mfa/send-code/', api.MFASendCodeApi.as_view(), name='mfa-send-codej'), path('otp/verify/', api.UserOtpVerifyApi.as_view(), name='user-otp-verify'), - path('sms/verify-code/send/', api.SendSMSVerifyCodeApi.as_view(), name='sms-verify-code-send'), path('password/verify/', api.UserPasswordVerifyApi.as_view(), name='user-password-verify'), path('login-confirm-ticket/status/', api.TicketStatusApi.as_view(), name='login-confirm-ticket-status'), ] diff --git a/apps/authentication/urls/view_urls.py b/apps/authentication/urls/view_urls.py index 1db4a477a..a976b624f 100644 --- a/apps/authentication/urls/view_urls.py +++ b/apps/authentication/urls/view_urls.py @@ -12,7 +12,7 @@ app_name = 'authentication' urlpatterns = [ # login path('login/', non_atomic_requests(views.UserLoginView.as_view()), name='login'), - path('login/otp/', views.UserLoginOtpView.as_view(), name='login-otp'), + path('login/mfa/', views.UserLoginMFAView.as_view(), name='login-mfa'), path('login/wait-confirm/', views.UserLoginWaitConfirmView.as_view(), name='login-wait-confirm'), path('login/guard/', views.UserLoginGuardView.as_view(), name='login-guard'), path('logout/', views.UserLogoutView.as_view(), name='logout'), @@ -42,14 +42,15 @@ urlpatterns = [ # Profile path('profile/pubkey/generate/', users_view.UserPublicKeyGenerateView.as_view(), name='user-pubkey-generate'), + path('profile/mfa/', users_view.MFASettingView.as_view(), name='user-mfa-setting'), + + # OTP Setting path('profile/otp/enable/start/', users_view.UserOtpEnableStartView.as_view(), name='user-otp-enable-start'), path('profile/otp/enable/install-app/', users_view.UserOtpEnableInstallAppView.as_view(), name='user-otp-enable-install-app'), path('profile/otp/enable/bind/', users_view.UserOtpEnableBindView.as_view(), name='user-otp-enable-bind'), - path('profile/otp/disable/authentication/', users_view.UserDisableMFAView.as_view(), - name='user-otp-disable-authentication'), - path('profile/otp/update/', users_view.UserOtpUpdateView.as_view(), name='user-otp-update'), - path('profile/otp/settings-success/', users_view.UserOtpSettingsSuccessView.as_view(), name='user-otp-settings-success'), + path('profile/otp/disable/', users_view.UserOtpDisableView.as_view(), + name='user-otp-disable'), path('first-login/', users_view.UserFirstLoginView.as_view(), name='user-first-login'), # openid diff --git a/apps/authentication/views/login.py b/apps/authentication/views/login.py index ab86f2a0b..e14f47256 100644 --- a/apps/authentication/views/login.py +++ b/apps/authentication/views/login.py @@ -122,16 +122,16 @@ class UserLoginView(mixins.AuthMixin, FormView): self.request.session.set_test_cookie() return self.render_to_response(context) except ( - errors.PasswdTooSimple, + errors.PasswordTooSimple, errors.PasswordRequireResetError, - errors.PasswdNeedUpdate, + errors.PasswordNeedUpdate, errors.OTPBindRequiredError ) as e: return redirect(e.url) except ( errors.MFAFailedError, errors.BlockMFAError, - errors.OTPCodeRequiredError, + errors.MFACodeRequiredError, errors.SMSCodeRequiredError, errors.UserPhoneNotSet ) as e: @@ -199,7 +199,7 @@ class UserLoginView(mixins.AuthMixin, FormView): 'demo_mode': os.environ.get("DEMO_MODE"), 'auth_methods': self.get_support_auth_methods(), 'forgot_password_url': self.get_forgot_password_url(), - 'methods': self.get_user_mfa_methods(), + **self.get_user_mfa_context(self.request.user) } kwargs.update(context) return super().get_context_data(**kwargs) @@ -208,7 +208,7 @@ class UserLoginView(mixins.AuthMixin, FormView): class UserLoginGuardView(mixins.AuthMixin, RedirectView): redirect_field_name = 'next' login_url = reverse_lazy('authentication:login') - login_otp_url = reverse_lazy('authentication:login-otp') + login_mfa_url = reverse_lazy('authentication:login-mfa') login_confirm_url = reverse_lazy('authentication:login-wait-confirm') def format_redirect_url(self, url): @@ -229,15 +229,16 @@ class UserLoginGuardView(mixins.AuthMixin, RedirectView): user = self.check_user_auth_if_need() self.check_user_mfa_if_need(user) self.check_user_login_confirm_if_need(user) - except (errors.CredentialError, errors.SessionEmptyError): + except (errors.CredentialError, errors.SessionEmptyError) as e: + print("Error: ", e) return self.format_redirect_url(self.login_url) except errors.MFARequiredError: - return self.format_redirect_url(self.login_otp_url) + return self.format_redirect_url(self.login_mfa_url) except errors.LoginConfirmBaseError: return self.format_redirect_url(self.login_confirm_url) except errors.MFAUnsetError as e: return e.url - except errors.PasswdTooSimple as e: + except errors.PasswordTooSimple as e: return e.url else: self.login_it(user) diff --git a/apps/authentication/views/mfa.py b/apps/authentication/views/mfa.py index 3984c8cd4..ec51ed63c 100644 --- a/apps/authentication/views/mfa.py +++ b/apps/authentication/views/mfa.py @@ -3,32 +3,39 @@ from __future__ import unicode_literals from django.views.generic.edit import FormView -from django.utils.translation import gettext_lazy as _ -from django.conf import settings + +from common.utils import get_logger from .. import forms, errors, mixins from .utils import redirect_to_guard_view -from common.utils import get_logger - logger = get_logger(__name__) -__all__ = ['UserLoginOtpView'] +__all__ = ['UserLoginMFAView'] -class UserLoginOtpView(mixins.AuthMixin, FormView): - template_name = 'authentication/login_otp.html' +class UserLoginMFAView(mixins.AuthMixin, FormView): + template_name = 'authentication/login_mfa.html' form_class = forms.UserCheckOtpCodeForm redirect_field_name = 'next' + def get(self, *args, **kwargs): + try: + self.get_user_from_session() + except errors.SessionEmptyError: + return redirect_to_guard_view() + return super().get(*args, **kwargs) + def form_valid(self, form): code = form.cleaned_data.get('code') mfa_type = form.cleaned_data.get('mfa_type') try: - self.check_user_mfa(code, mfa_type) + self._do_check_user_mfa(code, mfa_type) return redirect_to_guard_view() except (errors.MFAFailedError, errors.BlockMFAError) as e: form.add_error('code', e.msg) return super().form_invalid(form) + except errors.SessionEmptyError: + return redirect_to_guard_view() except Exception as e: logger.error(e) import traceback @@ -37,6 +44,7 @@ class UserLoginOtpView(mixins.AuthMixin, FormView): def get_context_data(self, **kwargs): user = self.get_user_from_session() - methods = self.get_user_mfa_methods(user) - kwargs.update({'methods': methods}) + mfa_context = self.get_user_mfa_context(user) + kwargs.update(mfa_context) return kwargs + diff --git a/apps/common/sdk/sms/__init__.py b/apps/common/sdk/sms/__init__.py index 3eaf6fecf..21a51e37b 100644 --- a/apps/common/sdk/sms/__init__.py +++ b/apps/common/sdk/sms/__init__.py @@ -1,65 +1,2 @@ -from collections import OrderedDict -import importlib - -from django.utils.translation import gettext_lazy as _ -from django.db.models import TextChoices -from django.conf import settings - -from common.utils import get_logger -from common.exceptions import JMSException - -logger = get_logger(__file__) - - -class BACKENDS(TextChoices): - ALIBABA = 'alibaba', _('Alibaba cloud') - TENCENT = 'tencent', _('Tencent cloud') - - -class BaseSMSClient: - """ - 短信终端的基类 - """ - - SIGN_AND_TMPL_SETTING_FIELD_PREFIX: str - - @classmethod - def new_from_settings(cls): - raise NotImplementedError - - def send_sms(self, phone_numbers: list, sign_name: str, template_code: str, template_param: dict, **kwargs): - raise NotImplementedError - - -class SMS: - client: BaseSMSClient - - def __init__(self, backend=None): - backend = backend or settings.SMS_BACKEND - if backend not in BACKENDS: - raise JMSException( - code='sms_provider_not_support', - detail=_('SMS provider not support: {}').format(backend) - ) - m = importlib.import_module(f'.{backend or settings.SMS_BACKEND}', __package__) - self.client = m.client.new_from_settings() - - def send_sms(self, phone_numbers: list, sign_name: str, template_code: str, template_param: dict, **kwargs): - return self.client.send_sms( - phone_numbers=phone_numbers, - sign_name=sign_name, - template_code=template_code, - template_param=template_param, - **kwargs - ) - - def send_verify_code(self, phone_number, code): - sign_name = getattr(settings, f'{self.client.SIGN_AND_TMPL_SETTING_FIELD_PREFIX}_VERIFY_SIGN_NAME') - template_code = getattr(settings, f'{self.client.SIGN_AND_TMPL_SETTING_FIELD_PREFIX}_VERIFY_TEMPLATE_CODE') - - if not (sign_name and template_code): - raise JMSException( - code='verify_code_sign_tmpl_invalid', - detail=_('SMS verification code signature or template invalid') - ) - return self.send_sms([phone_number], sign_name, template_code, OrderedDict(code=code)) +from .endpoint import SMS, BACKENDS +from .utils import SendAndVerifySMSUtil diff --git a/apps/common/sdk/sms/alibaba.py b/apps/common/sdk/sms/alibaba.py index 2c5532c4f..fe040c1a9 100644 --- a/apps/common/sdk/sms/alibaba.py +++ b/apps/common/sdk/sms/alibaba.py @@ -9,7 +9,7 @@ from Tea.exceptions import TeaException from common.utils import get_logger from common.exceptions import JMSException -from . import BaseSMSClient +from .base import BaseSMSClient logger = get_logger(__file__) diff --git a/apps/common/sdk/sms/base.py b/apps/common/sdk/sms/base.py new file mode 100644 index 000000000..4d02370b1 --- /dev/null +++ b/apps/common/sdk/sms/base.py @@ -0,0 +1,20 @@ +from common.utils import get_logger + +logger = get_logger(__file__) + + +class BaseSMSClient: + """ + 短信终端的基类 + """ + + SIGN_AND_TMPL_SETTING_FIELD_PREFIX: str + + @classmethod + def new_from_settings(cls): + raise NotImplementedError + + def send_sms(self, phone_numbers: list, sign_name: str, template_code: str, template_param: dict, **kwargs): + raise NotImplementedError + + diff --git a/apps/common/sdk/sms/endpoint.py b/apps/common/sdk/sms/endpoint.py new file mode 100644 index 000000000..610bf2d99 --- /dev/null +++ b/apps/common/sdk/sms/endpoint.py @@ -0,0 +1,51 @@ +from collections import OrderedDict +import importlib + +from django.utils.translation import gettext_lazy as _ +from django.db.models import TextChoices +from django.conf import settings + +from common.utils import get_logger +from common.exceptions import JMSException +from .base import BaseSMSClient + +logger = get_logger(__name__) + + +class BACKENDS(TextChoices): + ALIBABA = 'alibaba', _('Alibaba cloud') + TENCENT = 'tencent', _('Tencent cloud') + + +class SMS: + client: BaseSMSClient + + def __init__(self, backend=None): + backend = backend or settings.SMS_BACKEND + if backend not in BACKENDS: + raise JMSException( + code='sms_provider_not_support', + detail=_('SMS provider not support: {}').format(backend) + ) + m = importlib.import_module(f'.{backend or settings.SMS_BACKEND}', __package__) + self.client = m.client.new_from_settings() + + def send_sms(self, phone_numbers: list, sign_name: str, template_code: str, template_param: dict, **kwargs): + return self.client.send_sms( + phone_numbers=phone_numbers, + sign_name=sign_name, + template_code=template_code, + template_param=template_param, + **kwargs + ) + + def send_verify_code(self, phone_number, code): + sign_name = getattr(settings, f'{self.client.SIGN_AND_TMPL_SETTING_FIELD_PREFIX}_VERIFY_SIGN_NAME') + template_code = getattr(settings, f'{self.client.SIGN_AND_TMPL_SETTING_FIELD_PREFIX}_VERIFY_TEMPLATE_CODE') + + if not (sign_name and template_code): + raise JMSException( + code='verify_code_sign_tmpl_invalid', + detail=_('SMS verification code signature or template invalid') + ) + return self.send_sms([phone_number], sign_name, template_code, OrderedDict(code=code)) diff --git a/apps/common/sdk/sms/tencent.py b/apps/common/sdk/sms/tencent.py index 6ba89a2a9..30cd5887c 100644 --- a/apps/common/sdk/sms/tencent.py +++ b/apps/common/sdk/sms/tencent.py @@ -10,7 +10,8 @@ from tencentcloud.sms.v20210111 import sms_client, models # 导入可选配置类 from tencentcloud.common.profile.client_profile import ClientProfile from tencentcloud.common.profile.http_profile import HttpProfile -from . import BaseSMSClient + +from .base import BaseSMSClient logger = get_logger(__file__) diff --git a/apps/authentication/sms_verify_code.py b/apps/common/sdk/sms/utils.py similarity index 69% rename from apps/authentication/sms_verify_code.py rename to apps/common/sdk/sms/utils.py index 56bc6ac78..a0285df86 100644 --- a/apps/authentication/sms_verify_code.py +++ b/apps/common/sdk/sms/utils.py @@ -3,7 +3,7 @@ import random from django.core.cache import cache from django.utils.translation import gettext_lazy as _ -from common.sdk.sms import SMS +from .endpoint import SMS from common.utils import get_logger from common.exceptions import JMSException @@ -28,32 +28,24 @@ class CodeSendTooFrequently(JMSException): super().__init__(detail=self.default_detail.format(ttl)) -class VerifyCodeUtil: - KEY_TMPL = 'auth-verify_code-{}' +class SendAndVerifySMSUtil: + KEY_TMPL = 'auth-verify-code-{}' TIMEOUT = 60 - def __init__(self, account, key_suffix=None, timeout=None): - self.account = account - self.key_suffix = key_suffix + def __init__(self, phone, key_suffix=None, timeout=None): + self.phone = phone self.code = '' + self.timeout = timeout or self.TIMEOUT + self.key_suffix = key_suffix or str(phone) + self.key = self.KEY_TMPL.format(key_suffix) - if key_suffix is not None: - self.key = self.KEY_TMPL.format(key_suffix) - else: - self.key = self.KEY_TMPL.format(account) - self.timeout = self.TIMEOUT if timeout is None else timeout - - def touch(self): + def gen_and_send(self): """ 生成,保存,发送 """ - ttl = self.ttl() - if ttl > 0: - raise CodeSendTooFrequently(ttl) try: - self.generate() - self.save() - self.send() + code = self.generate() + self.send(code) except JMSException: self.clear() raise @@ -66,19 +58,18 @@ class VerifyCodeUtil: def clear(self): cache.delete(self.key) - def save(self): - cache.set(self.key, self.code, self.timeout) - - def send(self): + def send(self, code): """ 发送信息的方法,如果有错误直接抛出 api 异常 """ - account = self.account - code = self.code - + ttl = self.ttl() + if ttl > 0: + logger.error('Send sms too frequently, delay {}'.format(ttl)) + raise CodeSendTooFrequently(ttl) sms = SMS() - sms.send_verify_code(account, code) - logger.info(f'Send sms verify code: account={account} code={code}') + sms.send_verify_code(self.phone, code) + cache.set(self.key, self.code, self.timeout) + logger.info(f'Send sms verify code to {self.phone}: {code}') def verify(self, code): right = cache.get(self.key) diff --git a/apps/jumpserver/settings/custom.py b/apps/jumpserver/settings/custom.py index bc483cb79..edc46c795 100644 --- a/apps/jumpserver/settings/custom.py +++ b/apps/jumpserver/settings/custom.py @@ -106,7 +106,7 @@ TASK_LOG_KEEP_DAYS = CONFIG.TASK_LOG_KEEP_DAYS ORG_CHANGE_TO_URL = CONFIG.ORG_CHANGE_TO_URL WINDOWS_SKIP_ALL_MANUAL_PASSWORD = CONFIG.WINDOWS_SKIP_ALL_MANUAL_PASSWORD -AUTH_EXPIRED_SECONDS = 60 * 5 +AUTH_EXPIRED_SECONDS = 60 * 10 CHANGE_AUTH_PLAN_SECURE_MODE_ENABLED = CONFIG.CHANGE_AUTH_PLAN_SECURE_MODE_ENABLED diff --git a/apps/locale/zh/LC_MESSAGES/django.mo b/apps/locale/zh/LC_MESSAGES/django.mo index ee33a1fcc..03f88c57e 100644 --- a/apps/locale/zh/LC_MESSAGES/django.mo +++ b/apps/locale/zh/LC_MESSAGES/django.mo @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3cb74767fc92b67608deb32d27bf945b7fd4ad46fc02f0cc5ef4cf4a42ebcd10 -size 91465 +oid sha256:925c5a219a4ee6835ad59e3b8e9f7ea5074ee3df6527c0f73ef1a50eaedaf59c +size 91777 diff --git a/apps/locale/zh/LC_MESSAGES/django.po b/apps/locale/zh/LC_MESSAGES/django.po index d4b50cb47..c29febe61 100644 --- a/apps/locale/zh/LC_MESSAGES/django.po +++ b/apps/locale/zh/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: JumpServer 0.3.3\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2021-11-08 15:08+0800\n" +"POT-Creation-Date: 2021-11-10 10:53+0800\n" "PO-Revision-Date: 2021-05-20 10:54+0800\n" "Last-Translator: ibuler \n" "Language-Team: JumpServer team\n" @@ -25,7 +25,7 @@ msgstr "" #: orgs/models.py:24 perms/models/base.py:44 settings/models.py:29 #: settings/serializers/sms.py:6 terminal/models/storage.py:23 #: terminal/models/task.py:16 terminal/models/terminal.py:100 -#: users/forms/profile.py:32 users/models/group.py:15 users/models/user.py:597 +#: users/forms/profile.py:32 users/models/group.py:15 users/models/user.py:541 #: users/templates/users/_select_user_modal.html:13 #: users/templates/users/user_asset_permission.html:37 #: users/templates/users/user_asset_permission.html:154 @@ -60,7 +60,7 @@ msgstr "激活中" #: orgs/models.py:27 perms/models/base.py:53 settings/models.py:34 #: terminal/models/storage.py:26 terminal/models/terminal.py:114 #: tickets/models/ticket.py:71 users/models/group.py:16 -#: users/models/user.py:630 xpack/plugins/change_auth_plan/models/base.py:41 +#: users/models/user.py:574 xpack/plugins/change_auth_plan/models/base.py:41 #: xpack/plugins/cloud/models.py:35 xpack/plugins/cloud/models.py:113 #: xpack/plugins/gathered_user/models.py:26 msgid "Comment" @@ -86,8 +86,8 @@ msgstr "登录复核" #: templates/index.html:78 terminal/backends/command/models.py:18 #: terminal/backends/command/serializers.py:12 terminal/models/session.py:38 #: terminal/notifications.py:90 terminal/notifications.py:138 -#: tickets/models/comment.py:17 users/const.py:14 users/models/user.py:173 -#: users/models/user.py:801 users/models/user.py:827 +#: tickets/models/comment.py:17 users/const.py:14 users/models/user.py:169 +#: users/models/user.py:745 users/models/user.py:771 #: users/serializers/group.py:19 #: users/templates/users/user_asset_permission.html:38 #: users/templates/users/user_asset_permission.html:64 @@ -162,7 +162,7 @@ msgstr "格式为逗号分隔的字符串, * 表示匹配所有. " #: assets/models/base.py:176 assets/models/gathered_user.py:15 #: audits/models.py:105 authentication/forms.py:15 authentication/forms.py:17 #: authentication/templates/authentication/_msg_different_city.html:9 -#: ops/models/adhoc.py:148 users/forms/profile.py:31 users/models/user.py:595 +#: ops/models/adhoc.py:148 users/forms/profile.py:31 users/models/user.py:539 #: users/templates/users/_msg_user_created.html:12 #: users/templates/users/_select_user_modal.html:14 #: xpack/plugins/change_auth_plan/models/asset.py:35 @@ -323,7 +323,7 @@ msgid "Attrs" msgstr "" #: applications/models/application.py:183 -#: perms/models/application_permission.py:27 users/models/user.py:174 +#: perms/models/application_permission.py:27 users/models/user.py:170 msgid "Application" msgstr "应用程序" @@ -402,7 +402,7 @@ msgstr "目标URL" #: authentication/templates/authentication/login.html:151 #: settings/serializers/auth/ldap.py:44 users/forms/profile.py:21 #: users/templates/users/_msg_user_created.html:13 -#: users/templates/users/user_otp_check_password.html:13 +#: users/templates/users/user_otp_check_password.html:15 #: users/templates/users/user_password_update.html:43 #: users/templates/users/user_password_verify.html:18 #: xpack/plugins/change_auth_plan/models/base.py:39 @@ -553,7 +553,7 @@ msgstr "标签管理" #: assets/models/cluster.py:28 assets/models/cmd_filter.py:26 #: assets/models/cmd_filter.py:67 assets/models/group.py:21 #: common/db/models.py:70 common/mixins/models.py:49 orgs/models.py:25 -#: orgs/models.py:437 perms/models/base.py:51 users/models/user.py:638 +#: orgs/models.py:437 perms/models/base.py:51 users/models/user.py:582 #: users/serializers/group.py:33 #: xpack/plugins/change_auth_plan/models/base.py:45 #: xpack/plugins/cloud/models.py:119 xpack/plugins/gathered_user/models.py:30 @@ -566,7 +566,7 @@ msgstr "创建者" #: assets/models/label.py:25 common/db/models.py:72 common/mixins/models.py:50 #: ops/models/adhoc.py:38 ops/models/command.py:29 orgs/models.py:26 #: orgs/models.py:435 perms/models/base.py:52 users/models/group.py:18 -#: users/models/user.py:828 xpack/plugins/cloud/models.py:122 +#: users/models/user.py:772 xpack/plugins/cloud/models.py:122 msgid "Date created" msgstr "创建日期" @@ -621,7 +621,7 @@ msgstr "带宽" msgid "Contact" msgstr "联系人" -#: assets/models/cluster.py:22 users/models/user.py:616 +#: assets/models/cluster.py:22 users/models/user.py:560 msgid "Phone" msgstr "手机" @@ -647,7 +647,7 @@ msgid "Default" msgstr "默认" #: assets/models/cluster.py:36 assets/models/label.py:14 -#: users/models/user.py:813 +#: users/models/user.py:757 msgid "System" msgstr "系统" @@ -1219,8 +1219,8 @@ msgstr "用户代理" #: audits/models.py:110 #: authentication/templates/authentication/_mfa_confirm_modal.html:14 -#: authentication/templates/authentication/login_otp.html:6 -#: users/forms/profile.py:64 users/models/user.py:619 +#: authentication/templates/authentication/login_mfa.html:6 +#: users/forms/profile.py:64 users/models/user.py:563 #: users/serializers/profile.py:102 msgid "MFA" msgstr "多因子认证" @@ -1299,12 +1299,12 @@ msgid "Auth Token" msgstr "认证令牌" #: audits/signals_handler.py:68 authentication/views/login.py:169 -#: notifications/backends/__init__.py:11 users/models/user.py:652 +#: notifications/backends/__init__.py:11 users/models/user.py:596 msgid "WeCom" msgstr "企业微信" #: audits/signals_handler.py:69 authentication/views/login.py:175 -#: notifications/backends/__init__.py:12 users/models/user.py:653 +#: notifications/backends/__init__.py:12 users/models/user.py:597 msgid "DingTalk" msgstr "钉钉" @@ -1495,9 +1495,11 @@ msgstr "{ApplicationPermission} 移除 {SystemUser}" msgid "Invalid token" msgstr "无效的令牌" -#: authentication/api/mfa.py:81 +#: authentication/api/mfa.py:102 +#, fuzzy +#| msgid "Code is invalid, " msgid "Code is invalid" -msgstr "Code无效" +msgstr "验证码无效" #: authentication/backends/api.py:67 msgid "Invalid signature header. No credentials provided." @@ -1550,59 +1552,59 @@ msgstr "" msgid "Invalid token or cache refreshed." msgstr "" -#: authentication/errors.py:27 +#: authentication/errors.py:26 msgid "Username/password check failed" msgstr "用户名/密码 校验失败" -#: authentication/errors.py:28 +#: authentication/errors.py:27 msgid "Password decrypt failed" msgstr "密码解密失败" -#: authentication/errors.py:29 +#: authentication/errors.py:28 msgid "MFA failed" msgstr "多因子认证失败" -#: authentication/errors.py:30 +#: authentication/errors.py:29 msgid "MFA unset" msgstr "多因子认证没有设定" -#: authentication/errors.py:31 +#: authentication/errors.py:30 msgid "Username does not exist" msgstr "用户名不存在" -#: authentication/errors.py:32 +#: authentication/errors.py:31 msgid "Password expired" msgstr "密码已过期" -#: authentication/errors.py:33 +#: authentication/errors.py:32 msgid "Disabled or expired" msgstr "禁用或失效" -#: authentication/errors.py:34 +#: authentication/errors.py:33 msgid "This account is inactive." msgstr "此账户已禁用" -#: authentication/errors.py:35 +#: authentication/errors.py:34 msgid "This account is expired" msgstr "此账户已过期" -#: authentication/errors.py:36 +#: authentication/errors.py:35 msgid "Auth backend not match" msgstr "没有匹配到认证后端" -#: authentication/errors.py:37 +#: authentication/errors.py:36 msgid "ACL is not allowed" msgstr "ACL 不被允许" -#: authentication/errors.py:38 +#: authentication/errors.py:37 msgid "Only local users are allowed" msgstr "仅允许本地用户" -#: authentication/errors.py:48 +#: authentication/errors.py:47 msgid "No session found, check your cookie" msgstr "会话已变更,刷新页面" -#: authentication/errors.py:50 +#: authentication/errors.py:49 #, python-brace-format msgid "" "The username or password you entered is incorrect, please enter it again. " @@ -1612,105 +1614,85 @@ msgstr "" "您输入的用户名或密码不正确,请重新输入。 您还可以尝试 {times_try} 次(账号将" "被临时 锁定 {block_time} 分钟)" -#: authentication/errors.py:56 authentication/errors.py:60 +#: authentication/errors.py:55 authentication/errors.py:59 msgid "" "The account has been locked (please contact admin to unlock it or try again " "after {} minutes)" msgstr "账号已被锁定(请联系管理员解锁 或 {}分钟后重试)" -#: authentication/errors.py:64 +#: authentication/errors.py:63 #, python-brace-format msgid "" -"One-time password invalid, or ntp sync server time, You can also try " -"{times_try} times (The account will be temporarily locked for {block_time} " -"minutes)" +"{error},You can also try {times_try} times (The account will be temporarily " +"locked for {block_time} minutes)" msgstr "" -"虚拟MFA 不正确,或者服务器端时间不对。 您还可以尝试 {times_try} 次(账号将被" -"临时 锁定 {block_time} 分钟)" +"{error},您还可以尝试 {times_try} 次(账号将被临时锁定 {block_time} 分钟)" -#: authentication/errors.py:69 -#, python-brace-format -msgid "" -"SMS verify code invalid,You can also try {times_try} times (The account will " -"be temporarily locked for {block_time} minutes)" -msgstr "" -"短信验证码不正确。 您还可以尝试 {times_try} 次(账号将被临时 锁定 " -"{block_time} 分钟)" - -#: authentication/errors.py:74 -#, python-brace-format -msgid "" -"The MFA type({mfa_type}) is not supported, You can also try {times_try} " -"times (The account will be temporarily locked for {block_time} minutes)" -msgstr "" -"该({mfa_type}) MFA 类型不支持, 您还可以尝试 {times_try} 次(账号将被临时 锁" -"定 {block_time} 分钟)" - -#: authentication/errors.py:79 +#: authentication/errors.py:67 msgid "MFA required" msgstr "需要多因子认证" -#: authentication/errors.py:80 +#: authentication/errors.py:68 msgid "MFA not set, please set it first" msgstr "多因子认证没有设置,请先完成设置" -#: authentication/errors.py:81 +#: authentication/errors.py:69 msgid "OTP not set, please set it first" msgstr "OTP认证没有设置,请先完成设置" -#: authentication/errors.py:82 +#: authentication/errors.py:70 msgid "Login confirm required" msgstr "需要登录复核" -#: authentication/errors.py:83 +#: authentication/errors.py:71 msgid "Wait login confirm ticket for accept" msgstr "等待登录复核处理" -#: authentication/errors.py:84 +#: authentication/errors.py:72 msgid "Login confirm ticket was {}" msgstr "登录复核 {}" -#: authentication/errors.py:265 +#: authentication/errors.py:243 msgid "IP is not allowed" msgstr "来源 IP 不被允许登录" -#: authentication/errors.py:272 +#: authentication/errors.py:250 msgid "Time Period is not allowed" msgstr "该 时间段 不被允许登录" -#: authentication/errors.py:305 +#: authentication/errors.py:283 msgid "SSO auth closed" msgstr "SSO 认证关闭了" -#: authentication/errors.py:310 authentication/mixins.py:345 +#: authentication/errors.py:288 authentication/mixins.py:344 msgid "Your password is too simple, please change it for security" msgstr "你的密码过于简单,为了安全,请修改" -#: authentication/errors.py:319 authentication/mixins.py:352 +#: authentication/errors.py:297 authentication/mixins.py:351 msgid "You should to change your password before login" msgstr "登录完成前,请先修改密码" -#: authentication/errors.py:328 authentication/mixins.py:359 +#: authentication/errors.py:306 authentication/mixins.py:358 msgid "Your password has expired, please reset before logging in" msgstr "您的密码已过期,先修改再登录" -#: authentication/errors.py:362 +#: authentication/errors.py:340 msgid "Your password is invalid" msgstr "您的密码无效" -#: authentication/errors.py:368 +#: authentication/errors.py:346 msgid "No upload or download permission" msgstr "没有上传下载权限" -#: authentication/errors.py:384 templates/_mfa_otp_login.html:37 +#: authentication/errors.py:358 msgid "Please enter MFA code" msgstr "请输入6位动态安全码" -#: authentication/errors.py:387 templates/_mfa_otp_login.html:38 +#: authentication/errors.py:362 msgid "Please enter SMS code" msgstr "请输入短信验证码" -#: authentication/errors.py:390 users/exceptions.py:15 +#: authentication/errors.py:366 users/exceptions.py:15 msgid "Phone not set" msgstr "手机号没有设置" @@ -1734,14 +1716,62 @@ msgstr "多因子认证验证码" msgid "Dynamic code" msgstr "动态码" -#: authentication/mixins.py:335 -msgid "Please change your password" -msgstr "请修改密码" +#: authentication/mfa/base.py:7 +msgid "Please input security code" +msgstr "请输入 6 位动态安全码" -#: authentication/mixins.py:523 +#: authentication/mfa/otp.py:7 +msgid "OTP code invalid, or server time error" +msgstr "MFA (OTP) 验证码错误,或者服务器端时间不对" + +#: authentication/mfa/otp.py:12 +msgid "OTP" +msgstr "MFA (OTP)" + +#: authentication/mfa/otp.py:47 +msgid "Virtual OTP based MFA" +msgstr "虚拟 MFA (OTP)" + +#: authentication/mfa/radius.py:7 +msgid "Radius verify code invalid" +msgstr "Radius 校验失败" + +#: authentication/mfa/radius.py:12 +msgid "Radius MFA" +msgstr "Radius MFA" + +#: authentication/mfa/radius.py:43 +msgid "Radius global enabled, cannot disable" +msgstr "Radius MFA 全局开启,无法被禁用" + +#: authentication/mfa/sms.py:7 +msgid "SMS verify code invalid" +msgstr "短信验证码校验失败" + +#: authentication/mfa/sms.py:12 msgid "SMS" msgstr "短信" +#: authentication/mfa/sms.py:13 +msgid "SMS verification code" +msgstr "短信验证码" + +#: authentication/mfa/sms.py:53 +msgid "Set phone number to enable" +msgstr "设置手机号码启用" + +#: authentication/mfa/sms.py:57 +msgid "Clear phone number to disable" +msgstr "清空手机号码禁用" + +#: authentication/mixins.py:305 +msgid "The MFA type({}) is not supported" +msgstr "该 MFA 方法 ({}) 不被支持" + +#: authentication/mixins.py:334 +msgid "Please change your password" +msgstr "请修改密码" + #: authentication/models.py:37 msgid "Private Token" msgstr "SSH密钥" @@ -1754,18 +1784,6 @@ msgstr "过期时间" msgid "Different city login reminder" msgstr "异地登录提醒" -#: authentication/sms_verify_code.py:15 -msgid "The verification code has expired. Please resend it" -msgstr "验证码已过期,请重新发送" - -#: authentication/sms_verify_code.py:20 -msgid "The verification code is incorrect" -msgstr "验证码错误" - -#: authentication/sms_verify_code.py:25 -msgid "Please wait {} seconds before sending" -msgstr "请在 {} 秒后发送" - #: authentication/templates/authentication/_access_key_modal.html:6 msgid "API key list" msgstr "API Key列表" @@ -1799,14 +1817,16 @@ msgid "Show" msgstr "显示" #: authentication/templates/authentication/_access_key_modal.html:66 -#: settings/serializers/security.py:25 users/models/user.py:462 -#: users/serializers/profile.py:99 -#: users/templates/users/user_verify_mfa.html:32 +#: settings/serializers/security.py:25 users/models/user.py:458 +#: users/serializers/profile.py:99 users/templates/users/mfa_setting.html:60 +#: users/templates/users/user_verify_mfa.html:36 msgid "Disable" msgstr "禁用" #: authentication/templates/authentication/_access_key_modal.html:67 -#: users/models/user.py:463 users/serializers/profile.py:100 +#: users/models/user.py:459 users/serializers/profile.py:100 +#: users/templates/users/mfa_setting.html:26 +#: users/templates/users/mfa_setting.html:67 msgid "Enable" msgstr "启用" @@ -1931,15 +1951,15 @@ msgstr "登录" msgid "More login options" msgstr "更多登录方式" -#: authentication/templates/authentication/login_otp.html:19 -#: users/templates/users/user_otp_check_password.html:16 +#: authentication/templates/authentication/login_mfa.html:19 +#: users/templates/users/user_otp_check_password.html:18 #: users/templates/users/user_otp_enable_bind.html:24 #: users/templates/users/user_otp_enable_install_app.html:29 -#: users/templates/users/user_verify_mfa.html:26 +#: users/templates/users/user_verify_mfa.html:30 msgid "Next" msgstr "下一步" -#: authentication/templates/authentication/login_otp.html:21 +#: authentication/templates/authentication/login_mfa.html:22 msgid "Can't provide security? Please contact the administrator!" msgstr "如果不能提供多因子认证验证码,请联系管理员!" @@ -2055,11 +2075,11 @@ msgid "Please enable cookies and try again." msgstr "设置你的浏览器支持cookie" #: authentication/views/login.py:181 notifications/backends/__init__.py:14 -#: users/models/user.py:654 +#: users/models/user.py:598 msgid "FeiShu" msgstr "飞书" -#: authentication/views/login.py:269 +#: authentication/views/login.py:270 msgid "" "Wait for {} confirm, You also can copy link to her/him
\n" " Don't close this page" @@ -2067,15 +2087,15 @@ msgstr "" "等待 {} 确认, 你也可以复制链接发给他/她
\n" " 不要关闭本页面" -#: authentication/views/login.py:274 +#: authentication/views/login.py:275 msgid "No ticket found" msgstr "没有发现工单" -#: authentication/views/login.py:306 +#: authentication/views/login.py:307 msgid "Logout success" msgstr "退出登录成功" -#: authentication/views/login.py:307 +#: authentication/views/login.py:308 msgid "Logout success, return login page" msgstr "退出登录成功,返回到登录页面" @@ -2218,26 +2238,38 @@ msgstr "网络错误,请联系系统管理员" msgid "WeCom error, please contact system administrator" msgstr "企业微信错误,请联系系统管理员" -#: common/sdk/sms/__init__.py:15 -msgid "Alibaba cloud" -msgstr "阿里云" - -#: common/sdk/sms/__init__.py:16 -msgid "Tencent cloud" -msgstr "腾讯云" - -#: common/sdk/sms/__init__.py:42 -msgid "SMS provider not support: {}" -msgstr "短信服务商不支持:{}" - -#: common/sdk/sms/__init__.py:63 -msgid "SMS verification code signature or template invalid" -msgstr "短信验证码签名或模版无效" - #: common/sdk/sms/alibaba.py:56 msgid "Signature does not match" msgstr "签名不匹配" +#: common/sdk/sms/endpoint.py:16 +msgid "Alibaba cloud" +msgstr "阿里云" + +#: common/sdk/sms/endpoint.py:17 +msgid "Tencent cloud" +msgstr "腾讯云" + +#: common/sdk/sms/endpoint.py:28 +msgid "SMS provider not support: {}" +msgstr "短信服务商不支持:{}" + +#: common/sdk/sms/endpoint.py:49 +msgid "SMS verification code signature or template invalid" +msgstr "短信验证码签名或模版无效" + +#: common/sdk/sms/utils.py:15 +msgid "The verification code has expired. Please resend it" +msgstr "验证码已过期,请重新发送" + +#: common/sdk/sms/utils.py:20 +msgid "The verification code is incorrect" +msgstr "验证码错误" + +#: common/sdk/sms/utils.py:25 +msgid "Please wait {} seconds before sending" +msgstr "请在 {} 秒后发送" + #: common/utils/geoip/utils.py:17 common/utils/geoip/utils.py:30 msgid "Invalid ip" msgstr "无效IP" @@ -2302,7 +2334,7 @@ msgstr "" "div>" #: notifications/backends/__init__.py:10 users/forms/profile.py:101 -#: users/models/user.py:599 +#: users/models/user.py:543 msgid "Email" msgstr "邮件" @@ -2517,7 +2549,7 @@ msgstr "组织审计员" msgid "GLOBAL" msgstr "全局组织" -#: orgs/models.py:434 users/models/user.py:607 users/serializers/user.py:37 +#: orgs/models.py:434 users/models/user.py:551 users/serializers/user.py:37 #: users/templates/users/_select_user_modal.html:15 msgid "Role" msgstr "角色" @@ -2578,7 +2610,7 @@ msgid "Favorite" msgstr "收藏夹" #: perms/models/base.py:47 templates/_nav.html:21 users/models/group.py:31 -#: users/models/user.py:603 users/templates/users/_select_user_modal.html:16 +#: users/models/user.py:547 users/templates/users/_select_user_modal.html:16 #: users/templates/users/user_asset_permission.html:39 #: users/templates/users/user_asset_permission.html:67 #: users/templates/users/user_database_app_permission.html:38 @@ -2589,7 +2621,7 @@ msgstr "用户组" #: perms/models/base.py:50 #: tickets/serializers/ticket/meta/ticket_type/apply_application.py:60 #: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:50 -#: users/models/user.py:635 +#: users/models/user.py:579 msgid "Date expired" msgstr "失效日期" @@ -3634,7 +3666,7 @@ msgstr "下载更新模版" msgid "Help" msgstr "帮助" -#: templates/_header_bar.html:19 templates/_without_nav_base.html:27 +#: templates/_header_bar.html:19 msgid "Docs" msgstr "文档" @@ -3737,19 +3769,15 @@ msgstr "" "\"%(user_pubkey_update)s\"> 链接 更新\n" " " -#: templates/_mfa_otp_login.html:14 -msgid "Please enter verification code" -msgstr "请输入验证码" - -#: templates/_mfa_otp_login.html:16 templates/_mfa_otp_login.html:67 +#: templates/_mfa_login_field.html:28 msgid "Send verification code" msgstr "发送验证码" -#: templates/_mfa_otp_login.html:60 templates/_mfa_otp_login.html:65 +#: templates/_mfa_login_field.html:91 msgid "Wait: " msgstr "等待:" -#: templates/_mfa_otp_login.html:73 +#: templates/_mfa_login_field.html:101 msgid "The verification code has been sent" msgstr "验证码已发送" @@ -3889,7 +3917,7 @@ msgid "" "Displays the results of items _START_ to _END_; A total of _TOTAL_ entries" msgstr "显示第 _START_ 至 _END_ 项结果; 总共 _TOTAL_ 项" -#: templates/_without_nav_base.html:25 +#: templates/_without_nav_base.html:26 msgid "Home page" msgstr "首页" @@ -4845,11 +4873,11 @@ msgstr "点击查看" msgid "Could not reset self otp, use profile reset instead" msgstr "不能在该页面重置多因子认证, 请去个人信息页面重置" -#: users/const.py:10 users/models/user.py:171 +#: users/const.py:10 users/models/user.py:167 msgid "System administrator" msgstr "系统管理员" -#: users/const.py:11 users/models/user.py:172 +#: users/const.py:11 users/models/user.py:168 msgid "System auditor" msgstr "系统审计员" @@ -4940,56 +4968,48 @@ msgstr "不能和原来的密钥相同" msgid "Not a valid ssh public key" msgstr "SSH密钥不合法" -#: users/forms/profile.py:160 users/models/user.py:627 +#: users/forms/profile.py:160 users/models/user.py:571 #: users/templates/users/user_password_update.html:48 msgid "Public key" msgstr "SSH公钥" -#: users/models/user.py:36 -msgid "One-time password" -msgstr "一次性密码" - -#: users/models/user.py:37 -msgid "SMS verify code" -msgstr "短信验证码" - -#: users/models/user.py:464 +#: users/models/user.py:460 msgid "Force enable" msgstr "强制启用" -#: users/models/user.py:576 +#: users/models/user.py:520 msgid "Local" msgstr "数据库" -#: users/models/user.py:610 +#: users/models/user.py:554 msgid "Avatar" msgstr "头像" -#: users/models/user.py:613 +#: users/models/user.py:557 msgid "Wechat" msgstr "微信" -#: users/models/user.py:624 +#: users/models/user.py:568 msgid "Private key" msgstr "ssh私钥" -#: users/models/user.py:643 +#: users/models/user.py:587 msgid "Source" msgstr "来源" -#: users/models/user.py:647 +#: users/models/user.py:591 msgid "Date password last updated" msgstr "最后更新密码日期" -#: users/models/user.py:650 +#: users/models/user.py:594 msgid "Need update password" msgstr "需要更新密码" -#: users/models/user.py:809 +#: users/models/user.py:753 msgid "Administrator" msgstr "管理员" -#: users/models/user.py:812 +#: users/models/user.py:756 msgid "Administrator is the super user of system" msgstr "Administrator是初始的超级管理员" @@ -5122,18 +5142,6 @@ msgstr "角色只能为 {}" msgid "name not unique" msgstr "名称重复" -#: users/templates/users/_base_otp.html:14 -msgid "Please enter the password of" -msgstr "请输入" - -#: users/templates/users/_base_otp.html:14 -msgid "account" -msgstr "账户" - -#: users/templates/users/_base_otp.html:14 -msgid "to complete the binding operation" -msgstr "的密码完成绑定操作" - #: users/templates/users/_granted_assets.html:7 msgid "Loading" msgstr "加载中" @@ -5256,6 +5264,18 @@ msgstr "输入您的邮箱, 将会发一封重置邮件到您的邮箱中" msgid "Submit" msgstr "提交" +#: users/templates/users/mfa_setting.html:24 +msgid "Enable MFA" +msgstr "启用 MFA 多因子认证" + +#: users/templates/users/mfa_setting.html:30 +msgid "MFA force enable, cannot disable" +msgstr "MFA 已强制启用,无法禁用" + +#: users/templates/users/mfa_setting.html:48 +msgid "MFA setting" +msgstr "设置 MFA 多因子认证" + #: users/templates/users/reset_password.html:23 #: users/templates/users/user_password_update.html:64 msgid "Your password must satisfy" @@ -5311,13 +5331,24 @@ msgid "Exclude" msgstr "不包含" #: users/templates/users/user_otp_check_password.html:6 -#: users/templates/users/user_verify_mfa.html:6 -msgid "Authenticate" -msgstr "验证身份" +msgid "Enable OTP" +msgstr "启用 MFA (OTP)" + +#: users/templates/users/user_otp_check_password.html:10 +msgid "Please enter the password of" +msgstr "请输入" + +#: users/templates/users/user_otp_check_password.html:10 +msgid "account" +msgstr "账户" + +#: users/templates/users/user_otp_check_password.html:10 +msgid "to complete the binding operation" +msgstr "的密码完成绑定操作" #: users/templates/users/user_otp_enable_bind.html:6 msgid "Bind one-time password authenticator" -msgstr "绑定一次性密码验证器" +msgstr "绑定MFA验证器" #: users/templates/users/user_otp_enable_bind.html:13 msgid "" @@ -5326,7 +5357,7 @@ msgid "" msgstr "使用MFA验证器应用扫描以下二维码,获取6位验证码" #: users/templates/users/user_otp_enable_bind.html:22 -#: users/templates/users/user_verify_mfa.html:23 +#: users/templates/users/user_verify_mfa.html:27 msgid "Six figures" msgstr "6位数字" @@ -5363,38 +5394,49 @@ msgstr "重置" msgid "Verify password" msgstr "校验密码" -#: users/templates/users/user_verify_mfa.html:11 +#: users/templates/users/user_verify_mfa.html:9 +msgid "Authenticate" +msgstr "验证身份" + +#: users/templates/users/user_verify_mfa.html:15 msgid "" "The account protection has been opened, please complete the following " "operations according to the prompts" msgstr "账号保护已开启,请根据提示完成以下操作" -#: users/templates/users/user_verify_mfa.html:13 +#: users/templates/users/user_verify_mfa.html:17 msgid "Open MFA Authenticator and enter the 6-bit dynamic code" msgstr "请打开MFA验证器,输入6位动态码" -#: users/views/profile/otp.py:122 users/views/profile/otp.py:161 -#: users/views/profile/otp.py:181 -msgid "MFA code invalid, or ntp sync server time" -msgstr "MFA验证码不正确,或者服务器端时间不对" +#: users/views/profile/otp.py:80 +msgid "Already bound" +msgstr "已经绑定" -#: users/views/profile/otp.py:205 -msgid "MFA enable success" -msgstr "多因子认证启用成功" +#: users/views/profile/otp.py:81 +msgid "MFA already bound, disable first, then bound" +msgstr "MFA (OTP) 已经绑定,请先禁用,再绑定" -#: users/views/profile/otp.py:206 -msgid "MFA enable success, return login page" -msgstr "多因子认证启用成功,返回到登录页面" +#: users/views/profile/otp.py:108 +msgid "OTP enable success" +msgstr "MFA (OTP) 启用成功" -#: users/views/profile/otp.py:208 -msgid "MFA disable success" -msgstr "多因子认证禁用成功" +#: users/views/profile/otp.py:109 +msgid "OTP enable success, return login page" +msgstr "MFA (OTP) 启用成功,返回到登录页面" -#: users/views/profile/otp.py:209 -msgid "MFA disable success, return login page" -msgstr "多因子认证禁用成功,返回登录页面" +#: users/views/profile/otp.py:151 +msgid "Disable OTP" +msgstr "禁用 MFA (OTP)" -#: users/views/profile/password.py:32 users/views/profile/password.py:36 +#: users/views/profile/otp.py:157 +msgid "OTP disable success" +msgstr "MFA (OTP) 禁用成功" + +#: users/views/profile/otp.py:158 +msgid "OTP disable success, return login page" +msgstr "MFA (OTP) 禁用成功,返回登录页面" + +#: users/views/profile/password.py:36 users/views/profile/password.py:41 msgid "Password invalid" msgstr "用户名或密码无效" @@ -5441,8 +5483,8 @@ msgstr "* 新密码不能是最近 {} 次的密码" msgid "Reset password success, return to login page" msgstr "重置密码成功,返回到登录页面" -#: xpack/plugins/change_auth_plan/api/app.py:114 -#: xpack/plugins/change_auth_plan/api/asset.py:101 +#: xpack/plugins/change_auth_plan/api/app.py:113 +#: xpack/plugins/change_auth_plan/api/asset.py:100 msgid "The parameter 'action' must be [{}]" msgstr "参数 'action' 必须是 [{}]" @@ -5573,15 +5615,15 @@ msgstr "* 请输入正确的密码长度" msgid "* Password length range 6-30 bits" msgstr "* 密码长度范围 6-30 位" -#: xpack/plugins/change_auth_plan/task_handlers/base/handler.py:249 +#: xpack/plugins/change_auth_plan/task_handlers/base/handler.py:248 msgid "Invalid/incorrect password" msgstr "无效/错误 密码" -#: xpack/plugins/change_auth_plan/task_handlers/base/handler.py:251 +#: xpack/plugins/change_auth_plan/task_handlers/base/handler.py:250 msgid "Failed to connect to the host" msgstr "连接主机失败" -#: xpack/plugins/change_auth_plan/task_handlers/base/handler.py:253 +#: xpack/plugins/change_auth_plan/task_handlers/base/handler.py:252 msgid "Data could not be sent to remote" msgstr "无法将数据发送到远程" @@ -5939,7 +5981,7 @@ msgstr "执行次数" msgid "Instance count" msgstr "实例个数" -#: xpack/plugins/cloud/utils.py:68 +#: xpack/plugins/cloud/utils.py:65 msgid "Account unavailable" msgstr "账户无效" @@ -6027,6 +6069,38 @@ msgstr "旗舰版" msgid "Community edition" msgstr "社区版" +#~ msgid "One-time password invalid, or ntp sync server time" +#~ msgstr "MFA 验证码不正确,或者服务器端时间不对" + +#~ msgid "Download MFA APP, Using dynamic code" +#~ msgstr "下载 MFA APP, 使用一次性动态码" + +#~ msgid "MFA Radius" +#~ msgstr "Radius MFA" + +#~ msgid "Please enter verification code" +#~ msgstr "请输入验证码" + +#, python-brace-format +#~ msgid "" +#~ "One-time password invalid, or ntp sync server time, You can also try " +#~ "{times_try} times (The account will be temporarily locked for " +#~ "{block_time} minutes)" +#~ msgstr "" +#~ "虚拟MFA 不正确,或者服务器端时间不对。 您还可以尝试 {times_try} 次(账号将" +#~ "被临时 锁定 {block_time} 分钟)" + +#, python-brace-format +#~ msgid "" +#~ "The MFA type({mfa_type}) is not supported, You can also try {times_try} " +#~ "times (The account will be temporarily locked for {block_time} minutes)" +#~ msgstr "" +#~ "该({mfa_type}) MFA 类型不支持, 您还可以尝试 {times_try} 次(账号将被临时 " +#~ "锁定 {block_time} 分钟)" + +#~ msgid "One-time password" +#~ msgstr "一次性密码" + #~ msgid "Go" #~ msgstr "立即" diff --git a/apps/notifications/ws.py b/apps/notifications/ws.py index 4b9d9e4bd..a9426a7c6 100644 --- a/apps/notifications/ws.py +++ b/apps/notifications/ws.py @@ -68,5 +68,8 @@ class SiteMsgWebsocket(JsonWebsocketConsumer): def disconnect(self, close_code): if self.chan is not None: - self.chan.close() + try: + self.chan.close() + except: + pass self.close() diff --git a/apps/static/css/style.css b/apps/static/css/style.css index 70f7f971c..2d3d54b99 100644 --- a/apps/static/css/style.css +++ b/apps/static/css/style.css @@ -1174,6 +1174,14 @@ button.dim:active:before { .onoffswitch-checkbox:checked + .onoffswitch-label .onoffswitch-switch { right: 0; } + +.onoffswitch-checkbox:disabled + .onoffswitch-label .onoffswitch-inner:before { + background-color: #919191; +} + +.onoffswitch-checkbox:disabled + .onoffswitch-label, .onoffswitch-checkbox:disabled + .onoffswitch-label .onoffswitch-switch { + border-color: #919191; +} /* CHOSEN PLUGIN */ .chosen-container-single .chosen-single { background: #ffffff; diff --git a/apps/templates/_base_only_content.html b/apps/templates/_base_only_content.html index 9da6c0157..8d7258966 100644 --- a/apps/templates/_base_only_content.html +++ b/apps/templates/_base_only_content.html @@ -11,7 +11,6 @@ {% include '_head_css_js.html' %} - + \ No newline at end of file diff --git a/apps/templates/_mfa_otp_login.html b/apps/templates/_mfa_otp_login.html deleted file mode 100644 index ab80ee51c..000000000 --- a/apps/templates/_mfa_otp_login.html +++ /dev/null @@ -1,81 +0,0 @@ -{% load i18n %} - -
- - -
- - - \ No newline at end of file diff --git a/apps/templates/_without_nav_base.html b/apps/templates/_without_nav_base.html index 2737d82c1..f0e99902a 100644 --- a/apps/templates/_without_nav_base.html +++ b/apps/templates/_without_nav_base.html @@ -7,26 +7,23 @@ {{ JMS_TITLE }} -{# #} + {% include '_head_css_js.html' %} + + - - +
@@ -34,10 +31,11 @@ {% endblock %}
-
+
{% include '_copyright.html' %}
+ {% include '_foot_js.html' %} diff --git a/apps/users/backends/__init__.py b/apps/users/backends/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/users/models/user.py b/apps/users/models/user.py index 4c14f83cc..0611d67cb 100644 --- a/apps/users/models/user.py +++ b/apps/users/models/user.py @@ -12,33 +12,28 @@ from django.contrib.auth.models import AbstractUser from django.contrib.auth.hashers import check_password from django.core.cache import cache from django.db import models - from django.utils.translation import ugettext_lazy as _ from django.utils import timezone from django.shortcuts import reverse from orgs.utils import current_org from orgs.models import OrganizationMember, Organization -from common.exceptions import JMSException from common.utils import date_expired_default, get_logger, lazyproperty, random_string from common import fields from common.const import choices from common.db.models import TextChoices -from users.exceptions import MFANotEnabled, PhoneNotSet from ..signals import post_user_change_password -__all__ = ['User', 'UserPasswordHistory', 'MFAType'] +__all__ = ['User', 'UserPasswordHistory'] logger = get_logger(__file__) -class MFAType(TextChoices): - OTP = 'otp', _('One-time password') - SMS_CODE = 'sms', _('SMS verify code') - - class AuthMixin: date_password_last_updated: datetime.datetime + history_passwords: models.Manager + need_update_password: bool + public_key: str is_local: bool @property @@ -77,7 +72,8 @@ class AuthMixin: def is_history_password(self, password): allow_history_password_count = settings.OLD_PASSWORD_HISTORY_LIMIT_COUNT - history_passwords = self.history_passwords.all().order_by('-date_created')[:int(allow_history_password_count)] + history_passwords = self.history_passwords.all() \ + .order_by('-date_created')[:int(allow_history_password_count)] for history_password in history_passwords: if check_password(password, history_password.password): @@ -474,9 +470,11 @@ class MFAMixin: @property def mfa_force_enabled(self): - if settings.SECURITY_MFA_AUTH in [True, 1]: + force_level = settings.SECURITY_MFA_AUTH + if force_level in [True, 1]: return True - if settings.SECURITY_MFA_AUTH == 2 and self.is_org_admin: + # 2 管理员强制开启 + if force_level == 2 and self.is_org_admin: return True return self.mfa_level == 2 @@ -489,86 +487,32 @@ class MFAMixin: def disable_mfa(self): self.mfa_level = 0 - self.otp_secret_key = None - def reset_mfa(self): - if self.mfa_is_otp(): - self.otp_secret_key = '' + def no_active_mfa(self): + return len(self.active_mfa_backends) == 0 + + @lazyproperty + def active_mfa_backends(self): + backends = self.get_user_mfa_backends(self) + active_backends = [b for b in backends if b.is_active()] + return active_backends + + @property + def active_mfa_backends_mapper(self): + return {b.name: b for b in self.active_mfa_backends} @staticmethod - def mfa_is_otp(): - if settings.OTP_IN_RADIUS: - return False - return True + def get_user_mfa_backends(user): + from authentication.mfa import MFA_BACKENDS + backends = [cls(user) for cls in MFA_BACKENDS if cls.global_enabled()] + return backends - def check_radius(self, code): - from authentication.backends.radius import RadiusBackend - backend = RadiusBackend() - user = backend.authenticate(None, username=self.username, password=code) - if user: - return True - return False - - def check_otp(self, code): - from ..utils import check_otp_code - return check_otp_code(self.otp_secret_key, code) - - def check_mfa(self, code, mfa_type=MFAType.OTP): - if not self.mfa_enabled: - raise MFANotEnabled - - if mfa_type == MFAType.OTP: - if settings.OTP_IN_RADIUS: - return self.check_radius(code) - else: - return self.check_otp(code) - elif mfa_type == MFAType.SMS_CODE: - return self.check_sms_code(code) - - def get_supported_mfa_types(self): - methods = [] - if self.otp_secret_key: - methods.append(MFAType.OTP) - if settings.XPACK_ENABLED and settings.SMS_ENABLED and self.phone: - methods.append(MFAType.SMS_CODE) - return methods - - def check_sms_code(self, code): - from authentication.sms_verify_code import VerifyCodeUtil - - if not self.phone: - raise PhoneNotSet - - try: - util = VerifyCodeUtil(self.phone) - return util.verify(code) - except JMSException: - return False - - def send_sms_code(self): - from authentication.sms_verify_code import VerifyCodeUtil - - if not self.phone: - raise PhoneNotSet - - util = VerifyCodeUtil(self.phone) - util.touch() - return util.timeout - - def mfa_enabled_but_not_set(self): - if not self.mfa_enabled: - return False, None - - if not self.mfa_is_otp(): - return False, None - - if self.mfa_is_otp() and self.otp_secret_key: - return False, None - - if self.phone and settings.SMS_ENABLED and settings.XPACK_ENABLED: - return False, None - - return True, reverse('authentication:user-otp-enable-start') + def get_mfa_backend_by_type(self, mfa_type): + mfa_mapper = self.active_mfa_backends_mapper + backend = mfa_mapper.get(mfa_type) + if not backend: + return None + return backend class User(AuthMixin, TokenMixin, RoleMixin, MFAMixin, AbstractUser): diff --git a/apps/users/templates/users/_base_otp.html b/apps/users/templates/users/_base_otp.html index cac472621..0642574f1 100644 --- a/apps/users/templates/users/_base_otp.html +++ b/apps/users/templates/users/_base_otp.html @@ -11,8 +11,6 @@
-
{% trans 'Please enter the password of' %} {% trans 'account' %} {{ user.username }} {% trans 'to complete the binding operation' %}
-
{% block content %} {% endblock %}
diff --git a/apps/users/templates/users/mfa_setting.html b/apps/users/templates/users/mfa_setting.html new file mode 100644 index 000000000..e1c6067f3 --- /dev/null +++ b/apps/users/templates/users/mfa_setting.html @@ -0,0 +1,116 @@ +{% extends '_without_nav_base.html' %} +{% load static %} +{% load i18n %} + +{% block body %} + +
+
+{# // Todoi:#} +

{% trans 'Enable MFA' %}

+
+
  • {% trans 'Enable' %} MFA
  • +
    + + {% if user.mfa_force_enabled %} + {% trans 'MFA force enable, cannot disable' %} + {% endif %} + +
    + + +
    +
    +
    +
    + +
    + + +{% endblock %} diff --git a/apps/users/templates/users/user_otp_check_password.html b/apps/users/templates/users/user_otp_check_password.html index 2f04c4b33..edb9b2519 100644 --- a/apps/users/templates/users/user_otp_check_password.html +++ b/apps/users/templates/users/user_otp_check_password.html @@ -3,10 +3,12 @@ {% load i18n %} {% block small_title %} - {% trans 'Authenticate' %} + {% trans 'Enable OTP' %} {% endblock %} {% block content %} +
    {% trans 'Please enter the password of' %} {% trans 'account' %} {{ user.username }} {% trans 'to complete the binding operation' %}
    +
    {% csrf_token %}
    diff --git a/apps/users/templates/users/user_verify_mfa.html b/apps/users/templates/users/user_verify_mfa.html index 3f5c4472a..aaa3dcc5f 100644 --- a/apps/users/templates/users/user_verify_mfa.html +++ b/apps/users/templates/users/user_verify_mfa.html @@ -3,7 +3,11 @@ {% load i18n %} {% block small_title %} - {% trans 'Authenticate' %} + {% if title %} + {{ title }} + {% else %} + {% trans 'Authenticate' %} + {% endif %} {% endblock %} {% block content %} diff --git a/apps/users/utils.py b/apps/users/utils.py index cadbe6790..f00e363a6 100644 --- a/apps/users/utils.py +++ b/apps/users/utils.py @@ -137,7 +137,7 @@ class BlockUtilBase: times_remainder = int(times_up) - int(times_failed) return times_remainder - def incr_failed_count(self): + def incr_failed_count(self) -> int: limit_key = self.limit_key count = cache.get(limit_key, 0) count += 1 @@ -146,6 +146,7 @@ class BlockUtilBase: limit_count = settings.SECURITY_LOGIN_LIMIT_COUNT if count >= limit_count: cache.set(self.block_key, True, self.key_ttl) + return limit_count - count def get_failed_count(self): count = cache.get(self.limit_key, 0) @@ -205,4 +206,4 @@ def is_auth_password_time_valid(session): def is_auth_otp_time_valid(session): - return is_auth_time_valid(session, 'auth_opt_expired_at') + return is_auth_time_valid(session, 'auth_otp_expired_at') diff --git a/apps/users/views/profile/mfa.py b/apps/users/views/profile/mfa.py index ec51c5a2b..432dba5c8 100644 --- a/apps/users/views/profile/mfa.py +++ b/apps/users/views/profile/mfa.py @@ -1,2 +1,24 @@ # -*- coding: utf-8 -*- # +from __future__ import unicode_literals +from django.views.generic.base import TemplateView + +from common.permissions import IsValidUser +from common.mixins.views import PermissionsMixin +from users.models import User + +__all__ = ['MFASettingView'] + + +class MFASettingView(PermissionsMixin, TemplateView): + template_name = 'users/mfa_setting.html' + permission_classes = [IsValidUser] + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + mfa_backends = User.get_user_mfa_backends(self.request.user) + context.update({ + 'mfa_backends': mfa_backends, + }) + return context + diff --git a/apps/users/views/profile/otp.py b/apps/users/views/profile/otp.py index 545ebf36d..eb75d335f 100644 --- a/apps/users/views/profile/otp.py +++ b/apps/users/views/profile/otp.py @@ -1,31 +1,30 @@ # ~*~ coding: utf-8 ~*~ import time -from django.urls import reverse_lazy, reverse -from django.shortcuts import get_object_or_404 +from django.urls import reverse from django.utils.translation import ugettext as _ from django.views.generic.base import TemplateView from django.views.generic.edit import FormView from django.contrib.auth import logout as auth_logout -from django.conf import settings -from django.http.response import HttpResponseForbidden +from django.http.response import HttpResponseRedirect from authentication.mixins import AuthMixin -from users.models import User -from common.utils import get_logger, get_object_or_none +from authentication.mfa import MFAOtp, otp_failed_msg +from common.utils import get_logger, FlashMessageUtil +from common.mixins.views import PermissionsMixin from common.permissions import IsValidUser -from ... import forms from .password import UserVerifyPasswordView +from ... import forms from ...utils import ( - generate_otp_uri, check_otp_code, get_user_or_pre_auth_user, - is_auth_password_time_valid, is_auth_otp_time_valid + generate_otp_uri, check_otp_code, + get_user_or_pre_auth_user, ) __all__ = [ 'UserOtpEnableStartView', 'UserOtpEnableInstallAppView', - 'UserOtpEnableBindView', 'UserOtpSettingsSuccessView', - 'UserDisableMFAView', 'UserOtpUpdateView', + 'UserOtpEnableBindView', + 'UserOtpDisableView', ] logger = get_logger(__name__) @@ -34,22 +33,8 @@ logger = get_logger(__name__) class UserOtpEnableStartView(UserVerifyPasswordView): template_name = 'users/user_otp_check_password.html' - def form_valid(self, form): - # 开启了 OTP IN RADIUS 就不用绑定了 - resp = super().form_valid(form) - if settings.OTP_IN_RADIUS: - user_id = self.request.session.get('user_id') - user = get_object_or_404(User, id=user_id) - user.enable_mfa() - user.save() - return resp - def get_success_url(self): - if settings.OTP_IN_RADIUS: - success_url = reverse_lazy('authentication:user-otp-settings-success') - else: - success_url = reverse('authentication:user-otp-enable-install-app') - return success_url + return reverse('authentication:user-otp-enable-install-app') class UserOtpEnableInstallAppView(TemplateView): @@ -65,69 +50,68 @@ class UserOtpEnableInstallAppView(TemplateView): class UserOtpEnableBindView(AuthMixin, TemplateView, FormView): template_name = 'users/user_otp_enable_bind.html' form_class = forms.UserCheckOtpCodeForm - success_url = reverse_lazy('authentication:user-otp-settings-success') def get(self, request, *args, **kwargs): - if self._check_can_bind(): - return super().get(request, *args, **kwargs) - return HttpResponseForbidden() + pre_response = self._pre_check_can_bind() + if pre_response: + return pre_response + return super().get(request, *args, **kwargs) def post(self, request, *args, **kwargs): - if self._check_can_bind(): - return super().post(request, *args, **kwargs) - return HttpResponseForbidden() + pre_response = self._pre_check_can_bind() + if pre_response: + return pre_response + return super().post(request, *args, **kwargs) - def _check_authenticated_user_can_bind(self): - user = self.request.user - session = self.request.session + def _pre_check_can_bind(self): + try: + user = self.get_user_from_session() + except: + verify_url = reverse('authentication:user-otp-enable-start') + return HttpResponseRedirect(verify_url) - if not user.mfa_enabled: - return is_auth_password_time_valid(session) + if user.otp_secret_key: + return self.has_already_bound_message() + return None - if not user.otp_secret_key: - return is_auth_password_time_valid(session) - - return is_auth_otp_time_valid(session) - - def _check_unauthenticated_user_can_bind(self): - session_user = None - if not self.request.session.is_empty(): - user_id = self.request.session.get('user_id') - session_user = get_object_or_none(User, pk=user_id) - - if session_user: - if all(( - is_auth_password_time_valid(self.request.session), - session_user.mfa_enabled, - not session_user.otp_secret_key - )): - return True - return False - - def _check_can_bind(self): - if self.request.user.is_authenticated: - return self._check_authenticated_user_can_bind() - else: - return self._check_unauthenticated_user_can_bind() + @staticmethod + def has_already_bound_message(): + message_data = { + 'title': _('Already bound'), + 'error': _('MFA already bound, disable first, then bound'), + 'interval': 10, + 'redirect_url': reverse('authentication:user-otp-disable'), + } + response = FlashMessageUtil.gen_and_redirect_to(message_data) + return response def form_valid(self, form): otp_code = form.cleaned_data.get('otp_code') otp_secret_key = self.request.session.get('otp_secret_key', '') valid = check_otp_code(otp_secret_key, otp_code) - if valid: - self.save_otp(otp_secret_key) - return super().form_valid(form) - else: - error = _("MFA code invalid, or ntp sync server time") - form.add_error("otp_code", error) + if not valid: + form.add_error("otp_code", otp_failed_msg) return self.form_invalid(form) + self.save_otp(otp_secret_key) + auth_logout(self.request) + return super().form_valid(form) + def save_otp(self, otp_secret_key): user = get_user_or_pre_auth_user(self.request) - user.enable_mfa() user.otp_secret_key = otp_secret_key - user.save() + user.save(update_fields=['otp_secret_key']) + + def get_success_url(self): + message_data = { + 'title': _('OTP enable success'), + 'message': _('OTP enable success, return login page'), + 'interval': 5, + 'redirect_url': reverse('authentication:login'), + } + url = FlashMessageUtil.gen_message_url(message_data) + return url def get_context_data(self, **kwargs): user = get_user_or_pre_auth_user(self.request) @@ -142,70 +126,40 @@ class UserOtpEnableBindView(AuthMixin, TemplateView, FormView): return super().get_context_data(**kwargs) -class UserDisableMFAView(FormView): +class UserOtpDisableView(PermissionsMixin, FormView): template_name = 'users/user_verify_mfa.html' form_class = forms.UserCheckOtpCodeForm - success_url = reverse_lazy('authentication:user-otp-settings-success') permission_classes = [IsValidUser] def form_valid(self, form): user = self.request.user otp_code = form.cleaned_data.get('otp_code') + otp = MFAOtp(user) - valid = user.check_mfa(otp_code) - if valid: - user.disable_mfa() - user.save() - return super().form_valid(form) - else: - error = _('MFA code invalid, or ntp sync server time') + ok, error = otp.check_code(otp_code) + if not ok: form.add_error('otp_code', error) return super().form_invalid(form) - -class UserOtpUpdateView(FormView): - template_name = 'users/user_verify_mfa.html' - form_class = forms.UserCheckOtpCodeForm - success_url = reverse_lazy('authentication:user-otp-enable-bind') - permission_classes = [IsValidUser] - - def form_valid(self, form): - user = self.request.user - otp_code = form.cleaned_data.get('otp_code') - - valid = user.check_mfa(otp_code) - if valid: - self.request.session['auth_opt_expired_at'] = time.time() + settings.AUTH_EXPIRED_SECONDS - return super().form_valid(form) - else: - error = _('MFA code invalid, or ntp sync server time') - form.add_error('otp_code', error) - return super().form_invalid(form) - - -class UserOtpSettingsSuccessView(TemplateView): - template_name = 'flash_message_standalone.html' + otp.disable() + auth_logout(self.request) + return super().form_valid(form) def get_context_data(self, **kwargs): - title, describe = self.get_title_describe() - context = { - 'title': title, - 'message': describe, - 'interval': 1, + context = super().get_context_data(**kwargs) + context.update({ + 'title': _("Disable OTP") + }) + return context + + def get_success_url(self): + message_data = { + 'title': _('OTP disable success'), + 'message': _('OTP disable success, return login page'), + 'interval': 5, 'redirect_url': reverse('authentication:login'), - 'auto_redirect': True, } - kwargs.update(context) - return super().get_context_data(**kwargs) + url = FlashMessageUtil.gen_message_url(message_data) + return url - def get_title_describe(self): - user = get_user_or_pre_auth_user(self.request) - if self.request.user.is_authenticated: - auth_logout(self.request) - title = _('MFA enable success') - describe = _('MFA enable success, return login page') - if not user.mfa_enabled: - title = _('MFA disable success') - describe = _('MFA disable success, return login page') - return title, describe diff --git a/apps/users/views/profile/password.py b/apps/users/views/profile/password.py index 8c92487c1..7c69dccd3 100644 --- a/apps/users/views/profile/password.py +++ b/apps/users/views/profile/password.py @@ -6,7 +6,8 @@ from django.contrib.auth import authenticate from django.shortcuts import redirect from django.utils.translation import ugettext as _ from django.views.generic.edit import FormView -from authentication.mixins import PasswordEncryptionViewMixin + +from authentication.mixins import PasswordEncryptionViewMixin, AuthMixin from authentication import errors from common.utils import get_logger @@ -20,24 +21,27 @@ __all__ = ['UserVerifyPasswordView'] logger = get_logger(__name__) -class UserVerifyPasswordView(PasswordEncryptionViewMixin, FormView): +class UserVerifyPasswordView(AuthMixin, FormView): template_name = 'users/user_password_verify.html' form_class = forms.UserCheckPasswordForm def form_valid(self, form): user = get_user_or_pre_auth_user(self.request) + if user is None: + return redirect('authentication:login') + try: password = self.get_decrypted_password(username=user.username) except errors.AuthFailedError as e: form.add_error("password", _(f"Password invalid") + f'({e.msg})') return self.form_invalid(form) + user = authenticate(request=self.request, username=user.username, password=password) if not user: form.add_error("password", _("Password invalid")) return self.form_invalid(form) - self.request.session['user_id'] = str(user.id) - self.request.session['auth_password'] = 1 - self.request.session['auth_password_expired_at'] = time.time() + settings.AUTH_EXPIRED_SECONDS + + self.mark_password_ok(user) return redirect(self.get_success_url()) def get_success_url(self): From b001443f34874bb774cbe5554a2b0d4cb74d4a1c Mon Sep 17 00:00:00 2001 From: ibuler Date: Wed, 10 Nov 2021 14:28:17 +0800 Subject: [PATCH 11/15] =?UTF-8?q?pref:=20=E4=BF=AE=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E5=86=85=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/assets/migrations/0079_auto_20211102_1922.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/assets/migrations/0079_auto_20211102_1922.py b/apps/assets/migrations/0079_auto_20211102_1922.py index 64f4124f3..f0a05dc06 100644 --- a/apps/assets/migrations/0079_auto_20211102_1922.py +++ b/apps/assets/migrations/0079_auto_20211102_1922.py @@ -11,7 +11,7 @@ def create_internal_platform(apps, schema_editor): ('Windows-TLS', 'Windows', {'security': 'tls'}), ) for name, base, meta in type_platforms: - defaults = {'name': name, 'base': base, 'meta': meta} + defaults = {'name': name, 'base': base, 'meta': meta, 'internal': True} model.objects.using(db_alias).update_or_create( name=name, defaults=defaults ) From f2b72aae37eb4247368cfbe5c638087c40d30661 Mon Sep 17 00:00:00 2001 From: jiangweidong Date: Wed, 10 Nov 2021 15:52:28 +0800 Subject: [PATCH 12/15] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=91=98=E9=85=8D=E7=BD=AE=E5=AF=BC=E8=88=AA=E6=A0=8F?= =?UTF-8?q?=E4=B8=8A=E5=B8=AE=E5=8A=A9=E4=B8=AD=E7=9A=84url?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/jumpserver/conf.py | 5 +++++ apps/settings/serializers/other.py | 15 +++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/apps/jumpserver/conf.py b/apps/jumpserver/conf.py index 662c91fba..1f8f42980 100644 --- a/apps/jumpserver/conf.py +++ b/apps/jumpserver/conf.py @@ -355,6 +355,11 @@ class Config(dict): 'WINDOWS_SSH_DEFAULT_SHELL': 'cmd', 'PERIOD_TASK_ENABLED': True, + # 导航栏 帮助 + 'HELP_DOCUMENT_URL': 'http://docs.jumpserver.org', + 'HELP_SUPPORT_URL': 'http://www.jumpserver.org/support/', + 'OFFICIAL_WEBSITE_URL': 'http://www.jumpserver.org', + 'TICKETS_ENABLED': True, 'FORGOT_PASSWORD_URL': '', 'HEALTH_CHECK_TOKEN': '', diff --git a/apps/settings/serializers/other.py b/apps/settings/serializers/other.py index a999ae6fe..4341b6bb9 100644 --- a/apps/settings/serializers/other.py +++ b/apps/settings/serializers/other.py @@ -29,6 +29,21 @@ class OtherSettingSerializer(serializers.Serializer): required=False, label=_("Perm single to ungroup node") ) + HELP_DOCUMENT_URL = serializers.URLField( + required=False, allow_blank=True, allow_null=True, label=_("Help Docs URL"), + help_text=_('default: http://docs.jumpserver.org') + ) + + HELP_SUPPORT_URL = serializers.URLField( + required=False, allow_blank=True, allow_null=True, label=_("Help Support URL"), + help_text=_('default: http://www.jumpserver.org/support/') + ) + + OFFICIAL_WEBSITE_URL = serializers.URLField( + required=False, allow_blank=True, allow_null=True, label=_("Help Website URL"), + help_text=_('default: http://www.jumpserver.org') + ) + # 准备废弃 # PERIOD_TASK_ENABLED = serializers.BooleanField( # required=False, label=_("Enable period task") From b28ce9de7a9d105ec076fa02309e277137104e6c Mon Sep 17 00:00:00 2001 From: xinwen Date: Tue, 9 Nov 2021 17:10:29 +0800 Subject: [PATCH 13/15] =?UTF-8?q?feat:=20xrdp=20session=20bpp=20=E8=AF=BB?= =?UTF-8?q?=E5=8F=96=E7=8E=AF=E5=A2=83=E5=8F=98=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/authentication/api/connection_token.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/authentication/api/connection_token.py b/apps/authentication/api/connection_token.py index 842011a94..2ff4fa71c 100644 --- a/apps/authentication/api/connection_token.py +++ b/apps/authentication/api/connection_token.py @@ -4,6 +4,7 @@ import urllib.parse import json import base64 from typing import Callable +import os from django.conf import settings from django.core.cache import cache @@ -50,6 +51,10 @@ class ClientProtocolMixin: user = self.request.user return asset, application, system_user, user + @staticmethod + def parse_env_bool(env_key, env_default, true_value, false_value): + return true_value if is_true(os.getenv(env_key, env_default)) else false_value + def get_rdp_file_content(self, serializer): options = { 'full address:s': '', @@ -112,6 +117,10 @@ class ClientProtocolMixin: options['desktopheight:i'] = height else: options['smart sizing:i'] = '1' + + options['session bpp:i'] = os.getenv('JUMPSERVER_COLOR_DEPTH', '32') + options['audiomode:i'] = self.parse_env_bool('JUMPSERVER_DISABLE_AUDIO', 'false', '2', '0') + content = '' for k, v in options.items(): content += f'{k}:{v}\n' From 0facd8a25e2e3a515833e935dca56b4f8a5857c3 Mon Sep 17 00:00:00 2001 From: ibuler Date: Wed, 10 Nov 2021 18:15:46 +0800 Subject: [PATCH 14/15] =?UTF-8?q?perf:=20=E5=8E=BB=E6=8E=89=E5=AE=98?= =?UTF-8?q?=E7=BD=91=E7=9A=84=E9=93=BE=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/jumpserver/conf.py | 1 - apps/locale/zh/LC_MESSAGES/django.po | 42 ++++++++++++++++++++++++---- apps/settings/serializers/other.py | 8 ++---- 3 files changed, 38 insertions(+), 13 deletions(-) diff --git a/apps/jumpserver/conf.py b/apps/jumpserver/conf.py index 1f8f42980..c634ca723 100644 --- a/apps/jumpserver/conf.py +++ b/apps/jumpserver/conf.py @@ -358,7 +358,6 @@ class Config(dict): # 导航栏 帮助 'HELP_DOCUMENT_URL': 'http://docs.jumpserver.org', 'HELP_SUPPORT_URL': 'http://www.jumpserver.org/support/', - 'OFFICIAL_WEBSITE_URL': 'http://www.jumpserver.org', 'TICKETS_ENABLED': True, 'FORGOT_PASSWORD_URL': '', diff --git a/apps/locale/zh/LC_MESSAGES/django.po b/apps/locale/zh/LC_MESSAGES/django.po index c29febe61..4305720a7 100644 --- a/apps/locale/zh/LC_MESSAGES/django.po +++ b/apps/locale/zh/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: JumpServer 0.3.3\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2021-11-10 10:53+0800\n" +"POT-Creation-Date: 2021-11-10 17:18+0800\n" "PO-Revision-Date: 2021-05-20 10:54+0800\n" "Last-Translator: ibuler \n" "Language-Team: JumpServer team\n" @@ -1495,7 +1495,7 @@ msgstr "{ApplicationPermission} 移除 {SystemUser}" msgid "Invalid token" msgstr "无效的令牌" -#: authentication/api/mfa.py:102 +#: authentication/api/mfa.py:103 #, fuzzy #| msgid "Code is invalid, " msgid "Code is invalid" @@ -3247,8 +3247,36 @@ msgid "The shell type used when Windows assets perform ansible tasks" msgstr "windows 资产执行 Ansible 任务时,使用的 Shell 类型。" #: settings/serializers/other.py:29 +msgid "Perm ungroup node" +msgstr "授权未分组节点" + +#: settings/serializers/other.py:30 msgid "Perm single to ungroup node" -msgstr "直接授权资产放在未分组节点" +msgstr "授权未分组节点" + +#: settings/serializers/other.py:34 +msgid "Help Docs URL" +msgstr "" + +#: settings/serializers/other.py:35 +msgid "default: http://docs.jumpserver.org" +msgstr "默认: http://dev.jumpserver.org:8080" + +#: settings/serializers/other.py:39 +msgid "Help Support URL" +msgstr "支持链接" + +#: settings/serializers/other.py:40 +msgid "default: http://www.jumpserver.org/support/" +msgstr "默认: http://www.jumpserver.org/support/" + +#: settings/serializers/other.py:44 +msgid "Help Website URL" +msgstr "官网链接" + +#: settings/serializers/other.py:45 +msgid "default: http://www.jumpserver.org" +msgstr "如: http://dev.jumpserver.org:8080" #: settings/serializers/security.py:8 msgid "Password minimum length" @@ -3457,7 +3485,9 @@ msgid "" "The system determines whether the login IP address belongs to a common login " "city. If the account is logged in from a common login city, the system sends " "a remote login reminder" -msgstr "根据登录IP是否所属常用登录城市进行判断,若账号在非常用城市登录,会发送异地登录提醒" +msgstr "" +"根据登录IP是否所属常用登录城市进行判断,若账号在非常用城市登录,会发送异地登" +"录提醒" #: settings/serializers/sms.py:7 msgid "Label" @@ -3773,11 +3803,11 @@ msgstr "" msgid "Send verification code" msgstr "发送验证码" -#: templates/_mfa_login_field.html:91 +#: templates/_mfa_login_field.html:92 msgid "Wait: " msgstr "等待:" -#: templates/_mfa_login_field.html:101 +#: templates/_mfa_login_field.html:102 msgid "The verification code has been sent" msgstr "验证码已发送" diff --git a/apps/settings/serializers/other.py b/apps/settings/serializers/other.py index 4341b6bb9..7d701756c 100644 --- a/apps/settings/serializers/other.py +++ b/apps/settings/serializers/other.py @@ -26,7 +26,8 @@ class OtherSettingSerializer(serializers.Serializer): ) PERM_SINGLE_ASSET_TO_UNGROUP_NODE = serializers.BooleanField( - required=False, label=_("Perm single to ungroup node") + required=False, label=_("Perm ungroup node"), + help_text=_("Perm single to ungroup node") ) HELP_DOCUMENT_URL = serializers.URLField( @@ -39,11 +40,6 @@ class OtherSettingSerializer(serializers.Serializer): help_text=_('default: http://www.jumpserver.org/support/') ) - OFFICIAL_WEBSITE_URL = serializers.URLField( - required=False, allow_blank=True, allow_null=True, label=_("Help Website URL"), - help_text=_('default: http://www.jumpserver.org') - ) - # 准备废弃 # PERIOD_TASK_ENABLED = serializers.BooleanField( # required=False, label=_("Enable period task") From 8761ed741c8f4299e0ced6d0fa748f0e92d84965 Mon Sep 17 00:00:00 2001 From: ibuler Date: Thu, 11 Nov 2021 11:56:30 +0800 Subject: [PATCH 15/15] =?UTF-8?q?pref:=20=E8=B5=84=E4=BA=A7=E7=9A=84?= =?UTF-8?q?=E7=A1=AC=E4=BB=B6=E4=BF=A1=E6=81=AF=E5=8F=AF=E4=BB=A5=E6=9B=B4?= =?UTF-8?q?=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/assets/models/asset.py | 95 +++++++++++++++----------------- apps/assets/serializers/asset.py | 20 +++---- 2 files changed, 54 insertions(+), 61 deletions(-) diff --git a/apps/assets/models/asset.py b/apps/assets/models/asset.py index 91acd3d34..7d9d6d0d0 100644 --- a/apps/assets/models/asset.py +++ b/apps/assets/models/asset.py @@ -164,38 +164,7 @@ class Platform(models.Model): # ordering = ('name',) -class Asset(AbsConnectivity, ProtocolsMixin, NodesRelationMixin, OrgModelMixin): - # Important - PLATFORM_CHOICES = ( - ('Linux', 'Linux'), - ('Unix', 'Unix'), - ('MacOS', 'MacOS'), - ('BSD', 'BSD'), - ('Windows', 'Windows'), - ('Windows2016', 'Windows(2016)'), - ('Other', 'Other'), - ) - - id = models.UUIDField(default=uuid.uuid4, primary_key=True) - ip = models.CharField(max_length=128, verbose_name=_('IP'), db_index=True) - hostname = models.CharField(max_length=128, verbose_name=_('Hostname')) - protocol = models.CharField(max_length=128, default=ProtocolsMixin.Protocol.ssh, - choices=ProtocolsMixin.Protocol.choices, - verbose_name=_('Protocol')) - port = models.IntegerField(default=22, verbose_name=_('Port')) - protocols = models.CharField(max_length=128, default='ssh/22', blank=True, verbose_name=_("Protocols")) - platform = models.ForeignKey(Platform, default=Platform.default, on_delete=models.PROTECT, verbose_name=_("Platform"), related_name='assets') - domain = models.ForeignKey("assets.Domain", null=True, blank=True, related_name='assets', verbose_name=_("Domain"), on_delete=models.SET_NULL) - nodes = models.ManyToManyField('assets.Node', default=default_node, related_name='assets', verbose_name=_("Nodes")) - is_active = models.BooleanField(default=True, verbose_name=_('Is active')) - - # Auth - admin_user = models.ForeignKey('assets.SystemUser', on_delete=models.SET_NULL, null=True, verbose_name=_("Admin user"), related_name='admin_assets') - - # Some information - public_ip = models.CharField(max_length=128, blank=True, null=True, verbose_name=_('Public IP')) - number = models.CharField(max_length=32, null=True, blank=True, verbose_name=_('Asset number')) - +class AbsHardwareInfo(models.Model): # Collect vendor = models.CharField(max_length=64, null=True, blank=True, verbose_name=_('Vendor')) model = models.CharField(max_length=54, null=True, blank=True, verbose_name=_('Model')) @@ -214,6 +183,49 @@ class Asset(AbsConnectivity, ProtocolsMixin, NodesRelationMixin, OrgModelMixin): os_arch = models.CharField(max_length=16, blank=True, null=True, verbose_name=_('OS arch')) hostname_raw = models.CharField(max_length=128, blank=True, null=True, verbose_name=_('Hostname raw')) + class Meta: + abstract = True + + @property + def cpu_info(self): + info = "" + if self.cpu_model: + info += self.cpu_model + if self.cpu_count and self.cpu_cores: + info += "{}*{}".format(self.cpu_count, self.cpu_cores) + return info + + @property + def hardware_info(self): + if self.cpu_count: + return '{} Core {} {}'.format( + self.cpu_vcpus or self.cpu_count * self.cpu_cores, + self.memory, self.disk_total + ) + else: + return '' + + +class Asset(AbsConnectivity, AbsHardwareInfo, ProtocolsMixin, NodesRelationMixin, OrgModelMixin): + id = models.UUIDField(default=uuid.uuid4, primary_key=True) + ip = models.CharField(max_length=128, verbose_name=_('IP'), db_index=True) + hostname = models.CharField(max_length=128, verbose_name=_('Hostname')) + protocol = models.CharField(max_length=128, default=ProtocolsMixin.Protocol.ssh, + choices=ProtocolsMixin.Protocol.choices, verbose_name=_('Protocol')) + port = models.IntegerField(default=22, verbose_name=_('Port')) + protocols = models.CharField(max_length=128, default='ssh/22', blank=True, verbose_name=_("Protocols")) + platform = models.ForeignKey(Platform, default=Platform.default, on_delete=models.PROTECT, verbose_name=_("Platform"), related_name='assets') + domain = models.ForeignKey("assets.Domain", null=True, blank=True, related_name='assets', verbose_name=_("Domain"), on_delete=models.SET_NULL) + nodes = models.ManyToManyField('assets.Node', default=default_node, related_name='assets', verbose_name=_("Nodes")) + is_active = models.BooleanField(default=True, verbose_name=_('Is active')) + + # Auth + admin_user = models.ForeignKey('assets.SystemUser', on_delete=models.SET_NULL, null=True, verbose_name=_("Admin user"), related_name='admin_assets') + + # Some information + public_ip = models.CharField(max_length=128, blank=True, null=True, verbose_name=_('Public IP')) + number = models.CharField(max_length=32, null=True, blank=True, verbose_name=_('Asset number')) + labels = models.ManyToManyField('assets.Label', blank=True, related_name='assets', verbose_name=_("Labels")) created_by = models.CharField(max_length=128, null=True, blank=True, verbose_name=_('Created by')) date_created = models.DateTimeField(auto_now_add=True, null=True, blank=True, verbose_name=_('Date created')) @@ -269,25 +281,6 @@ class Asset(AbsConnectivity, ProtocolsMixin, NodesRelationMixin, OrgModelMixin): def is_support_ansible(self): return self.has_protocol('ssh') and self.platform_base not in ("Other",) - @property - def cpu_info(self): - info = "" - if self.cpu_model: - info += self.cpu_model - if self.cpu_count and self.cpu_cores: - info += "{}*{}".format(self.cpu_count, self.cpu_cores) - return info - - @property - def hardware_info(self): - if self.cpu_count: - return '{} Core {} {}'.format( - self.cpu_vcpus or self.cpu_count * self.cpu_cores, - self.memory, self.disk_total - ) - else: - return '' - def get_auth_info(self): if not self.admin_user: return {} diff --git a/apps/assets/serializers/asset.py b/apps/assets/serializers/asset.py index fccbcaa9e..b13eda715 100644 --- a/apps/assets/serializers/asset.py +++ b/apps/assets/serializers/asset.py @@ -66,7 +66,9 @@ class AssetSerializer(BulkOrgResourceModelSerializer): ) protocols = ProtocolsField(label=_('Protocols'), required=False, default=['ssh/22']) domain_display = serializers.ReadOnlyField(source='domain.name', label=_('Domain name')) - nodes_display = serializers.ListField(child=serializers.CharField(), label=_('Nodes name'), required=False) + nodes_display = serializers.ListField( + child=serializers.CharField(), label=_('Nodes name'), required=False + ) """ 资产的数据结构 @@ -79,11 +81,11 @@ class AssetSerializer(BulkOrgResourceModelSerializer): 'protocol', 'port', 'protocols', 'is_active', 'public_ip', 'number', 'comment', ] - hardware_fields = [ + fields_hardware = [ 'vendor', 'model', 'sn', 'cpu_model', 'cpu_count', 'cpu_cores', 'cpu_vcpus', 'memory', 'disk_total', 'disk_info', - 'os', 'os_version', 'os_arch', 'hostname_raw', 'hardware_info', - 'connectivity', 'date_verified' + 'os', 'os_version', 'os_arch', 'hostname_raw', + 'cpu_info', 'hardware_info', ] fields_fk = [ 'domain', 'domain_display', 'platform', 'admin_user', 'admin_user_display' @@ -92,18 +94,16 @@ class AssetSerializer(BulkOrgResourceModelSerializer): 'nodes', 'nodes_display', 'labels', ] read_only_fields = [ + 'connectivity', 'date_verified', 'cpu_info', 'hardware_info', 'created_by', 'date_created', ] - fields = fields_small + hardware_fields + fields_fk + fields_m2m + read_only_fields - - extra_kwargs = {k: {'read_only': True} for k in hardware_fields} - extra_kwargs.update({ + fields = fields_small + fields_hardware + fields_fk + fields_m2m + read_only_fields + extra_kwargs = { 'protocol': {'write_only': True}, 'port': {'write_only': True}, 'hardware_info': {'label': _('Hardware info'), 'read_only': True}, - 'org_name': {'label': _('Org name'), 'read_only': True}, 'admin_user_display': {'label': _('Admin user display'), 'read_only': True}, - }) + } def get_fields(self): fields = super().get_fields()