From e3dd03f4c7d708498270b0895e857f41ad34bb3f Mon Sep 17 00:00:00 2001 From: xinwen Date: Thu, 15 Oct 2020 12:00:38 +0800 Subject: [PATCH 01/36] Dev (#4791) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(xpack): 修复last login太长的问题 (#4786) Co-authored-by: ibuler * perf: 更新密码中也发送邮件 (#4789) Co-authored-by: ibuler * fix(terminal): 修复获取螺旋的异步api * fix(terminal): 修复有的录像存储有问题的导致下载录像的bug * fix(orgs): 修复组织添加用户bug * perf(requirements): 修改jms-storage==0.0.34 (#4797) Co-authored-by: Bai Co-authored-by: fit2bot <68588906+fit2bot@users.noreply.github.com> Co-authored-by: ibuler Co-authored-by: Bai --- apps/assets/tasks/gather_asset_users.py | 2 +- apps/orgs/models.py | 14 ++++++++++---- apps/terminal/api/session.py | 1 - apps/users/api/profile.py | 5 +++++ requirements/requirements.txt | 2 +- 5 files changed, 17 insertions(+), 7 deletions(-) diff --git a/apps/assets/tasks/gather_asset_users.py b/apps/assets/tasks/gather_asset_users.py index 7914c9720..5d8372451 100644 --- a/apps/assets/tasks/gather_asset_users.py +++ b/apps/assets/tasks/gather_asset_users.py @@ -92,7 +92,7 @@ def add_asset_users(assets, results): for username, data in users.items(): defaults = {'asset': asset, 'username': username, 'present': True} if data.get("ip"): - defaults["ip_last_login"] = data["ip"] + defaults["ip_last_login"] = data["ip"][:32] if data.get("date"): defaults["date_last_login"] = data["date"] GatheredUser.objects.update_or_create( diff --git a/apps/orgs/models.py b/apps/orgs/models.py index 5fb2d7ba6..274080fa4 100644 --- a/apps/orgs/models.py +++ b/apps/orgs/models.py @@ -230,8 +230,14 @@ def _none2list(*args): return ([] if v is None else v for v in args) -def _users2pks(users, admins, auditors): - return [user.pk for user in chain(users, admins, auditors) if hasattr(user, 'pk')] +def _users2pks_if_need(users, admins, auditors): + pks = [] + for user in chain(users, admins, auditors): + if hasattr(user, 'pk'): + pks.append(user.pk) + else: + pks.append(user) + return pks class UserRoleMapper(dict): @@ -271,7 +277,7 @@ class OrgMemeberManager(models.Manager): users, admins, auditors = _none2list(users, admins, auditors) send = partial(signals.m2m_changed.send, sender=self.model, instance=org, reverse=False, - model=User, pk_set=_users2pks(users, admins, auditors), using=self.db) + model=User, pk_set=_users2pks_if_need(users, admins, auditors), using=self.db) send(action="pre_remove") self.filter(org_id=org.id).filter( @@ -302,7 +308,7 @@ class OrgMemeberManager(models.Manager): oms_add.append(self.model(org_id=org.id, user_id=_user, role=_role)) send = partial(signals.m2m_changed.send, sender=self.model, instance=org, reverse=False, - model=User, pk_set=_users2pks(users, admins, auditors), using=self.db) + model=User, pk_set=_users2pks_if_need(users, admins, auditors), using=self.db) send(action='pre_add') self.bulk_create(oms_add, ignore_conflicts=True) diff --git a/apps/terminal/api/session.py b/apps/terminal/api/session.py index 16033b390..719d7af4f 100644 --- a/apps/terminal/api/session.py +++ b/apps/terminal/api/session.py @@ -155,7 +155,6 @@ class SessionReplayViewSet(AsyncApiMixin, viewsets.ViewSet): return data def is_need_async(self): - return False if self.action != 'retrieve': return False return True diff --git a/apps/users/api/profile.py b/apps/users/api/profile.py index b7ba0bbff..473ad3819 100644 --- a/apps/users/api/profile.py +++ b/apps/users/api/profile.py @@ -10,6 +10,7 @@ from common.permissions import ( ) from .. import serializers from ..models import User +from ..utils import send_reset_password_success_mail from .mixins import UserQuerysetMixin __all__ = [ @@ -85,3 +86,7 @@ class UserPublicKeyApi(generics.RetrieveUpdateAPIView): def get_object(self): return self.request.user + + def perform_update(self, serializer): + super().perform_update(serializer) + send_reset_password_success_mail(self.request, self.get_object()) diff --git a/requirements/requirements.txt b/requirements/requirements.txt index 78961dfbf..57bec7f2b 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -61,7 +61,7 @@ pytz==2018.3 PyYAML==5.1 redis==3.2.0 requests==2.22.0 -jms-storage==0.0.31 +jms-storage==0.0.34 s3transfer==0.3.3 simplejson==3.13.2 six==1.11.0 From 108a1da212bffb4ff3075665591488bd698abf69 Mon Sep 17 00:00:00 2001 From: ibuler Date: Tue, 27 Oct 2020 09:49:02 +0800 Subject: [PATCH 02/36] =?UTF-8?q?chore:=20=E4=BF=AE=E6=94=B9github=20?= =?UTF-8?q?=E8=AF=AD=E8=A8=80=E8=AF=86=E5=88=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/.gitattributes | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 apps/.gitattributes diff --git a/apps/.gitattributes b/apps/.gitattributes new file mode 100644 index 000000000..cc448bebc --- /dev/null +++ b/apps/.gitattributes @@ -0,0 +1,2 @@ +*.js linguist-language=python +*.html linguist-language=python From e369a8d51f33669fe2b621efb78131c0f5c57ca1 Mon Sep 17 00:00:00 2001 From: Bai Date: Fri, 30 Oct 2020 15:34:16 +0800 Subject: [PATCH 03/36] =?UTF-8?q?perf(readme):=20=E4=BF=AE=E6=94=B9Readme?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 30befbf73..5cfc92343 100644 --- a/README.md +++ b/README.md @@ -204,7 +204,7 @@ v2.1.0 是 v2.0.0 之后的功能版本。 - [极速安装](https://docs.jumpserver.org/zh/master/install/setup_by_fast/) - [完整文档](https://docs.jumpserver.org) -- [演示视频](https://jumpserver.oss-cn-hangzhou.aliyuncs.com/jms-media/%E3%80%90%E6%BC%94%E7%A4%BA%E8%A7%86%E9%A2%91%E3%80%91Jumpserver%20%E5%A0%A1%E5%9E%92%E6%9C%BA%20V1.5.0%20%E6%BC%94%E7%A4%BA%E8%A7%86%E9%A2%91%20-%20final.mp4) +- [演示视频](https://www.bilibili.com/video/BV1ZV41127GB) ## 组件项目 - [Lina](https://github.com/jumpserver/lina) JumpServer Web UI 项目 From 4b9ed47cdaa253a64dad493df10ef603e58c2950 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=81=E5=B9=BF?= Date: Fri, 15 Jan 2021 18:05:09 +0800 Subject: [PATCH 04/36] Update README.md --- README.md | 81 ++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 78 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 73abee037..c7bfb6f3a 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,84 @@ [![Django](https://img.shields.io/badge/django-2.2-brightgreen.svg?style=plastic)](https://www.djangoproject.com/) [![Docker Pulls](https://img.shields.io/docker/pulls/jumpserver/jms_all.svg)](https://hub.docker.com/u/jumpserver) -|Developer Wanted| -|------------------| -|JumpServer 正在寻找开发者,一起为改变世界做些贡献吧,哪怕一点点,联系我 | +## 紧急BUG修复通知 +JumpServer发现远程执行漏洞,请速度修复 + +**影响版本:** +``` +< v2.6.2 +< v2.5.4 +< v2.4.5 += v1.5.9 +``` +**安全版本:** +``` +>= v2.6.2 +>= v2.5.4 +>= v2.4.5 += v1.5.9 (版本号没变) +``` +**修复方案:** + +将JumpServer升级至安全版本; + +**临时修复方案:** + +修改 Nginx 配置文件屏蔽漏洞接口 + +``` +/api/v1/authentication/connection-token/ +/api/v1/users/connection-token/ +``` + +Nginx 配置文件位置 +``` +# 社区老版本 +/etc/nginx/conf.d/jumpserver.conf + +# 企业老版本 +jumpserver-release/nginx/http_server.conf + +# 新版本在 +jumpserver-release/compose/config_static/http_server.conf +``` + +修改 Nginx 配置文件实例 +``` +### 保证在 /api 之前 和 / 之前 +location /api/v1/authentication/connection-token/ { + return 403; +} + +location /api/v1/users/connection-token/ { + return 403; +} +### 新增以上这些 + +location /api/ { + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_pass http://core:8080; + } + +... +``` + +修改完成后重启 nginx + +``` +docker方式: +docker restart jms_nginx + +nginx方式: +systemctl restart nginx + +``` + + + +JumpServer 正在寻找开发者,一起为改变世界做些贡献吧,哪怕一点点,联系我 JumpServer 是全球首款开源的堡垒机,使用 GNU GPL v2.0 开源协议,是符合 4A 规范的运维安全审计系统。 From 9e0d731a0ce40ca80c2c20afc7548cd870ef41f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=81=E5=B9=BF?= Date: Sat, 16 Jan 2021 16:23:30 +0800 Subject: [PATCH 05/36] Update README.md (#5432) * Update README.md * Update README.md --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index c7bfb6f3a..47254edc4 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,16 @@ systemctl restart nginx ``` +**修复验证** +``` +$ wget https://github.com/jumpserver/jumpserver/releases/download/v2.6.2/jms_bug_check.sh + +# 使用方法 bash jms_bug_check.sh HOST +$ bash jms_bug_check.sh demo.jumpserver.org +漏洞已修复 +``` +-------------------------- JumpServer 正在寻找开发者,一起为改变世界做些贡献吧,哪怕一点点,联系我 From 646f0a568bb04ef352c705359dba1d4c9ee7c110 Mon Sep 17 00:00:00 2001 From: ibuler Date: Mon, 18 Jan 2021 11:20:01 +0800 Subject: [PATCH 06/36] =?UTF-8?q?chore:=20=E4=BF=AE=E6=94=B9readme?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/README.md b/README.md index 47254edc4..e89e8e01c 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ JumpServer发现远程执行漏洞,请速度修复 < v2.5.4 < v2.4.5 = v1.5.9 +>= v1.5.3 ``` **安全版本:** ``` @@ -20,7 +21,9 @@ JumpServer发现远程执行漏洞,请速度修复 >= v2.5.4 >= v2.4.5 = v1.5.9 (版本号没变) +< v1.5.3 ``` + **修复方案:** 将JumpServer升级至安全版本; @@ -88,6 +91,23 @@ $ wget https://github.com/jumpserver/jumpserver/releases/download/v2.6.2/jms_bug $ bash jms_bug_check.sh demo.jumpserver.org 漏洞已修复 ``` + +**入侵检测** + +下载脚本到 jumpserver 日志目录,这个目录中存在 gunicorn.log,然后执行 + +``` +$ pwd +/opt/jumpserver/core/logs + +$ ls gunicorn.log +gunicorn.log + +$ wget 'https://github.com/jumpserver/jumpserver/releases/download/v2.6.2/jms_check_attack.sh' +$ bash jms_check_attack.sh +系统未被入侵 +``` + -------------------------- JumpServer 正在寻找开发者,一起为改变世界做些贡献吧,哪怕一点点,联系我 From df193162f7b02b756f163629b8509a8e40355f6f Mon Sep 17 00:00:00 2001 From: fit2bot <68588906+fit2bot@users.noreply.github.com> Date: Mon, 18 Jan 2021 13:46:55 +0800 Subject: [PATCH 07/36] =?UTF-8?q?chore:=20=E4=BF=AE=E6=94=B9readme=20?= =?UTF-8?q?=E8=8B=B1=E6=96=87=E7=89=88=E6=9C=AC=20(#5448)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: 修改readme 英文版本 Co-authored-by: ibuler --- README.md | 2 + README_EN.md | 122 ++++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 118 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index e89e8e01c..fb682ff50 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ [![Django](https://img.shields.io/badge/django-2.2-brightgreen.svg?style=plastic)](https://www.djangoproject.com/) [![Docker Pulls](https://img.shields.io/docker/pulls/jumpserver/jms_all.svg)](https://hub.docker.com/u/jumpserver) +- [ENGLISH](https://github.com/jumpserver/jumpserver/blob/master/README_EN.md) + ## 紧急BUG修复通知 JumpServer发现远程执行漏洞,请速度修复 diff --git a/README_EN.md b/README_EN.md index b7c3f8cc8..6f18cf741 100644 --- a/README_EN.md +++ b/README_EN.md @@ -1,16 +1,126 @@ ## Jumpserver -![Total visitor](https://visitor-count-badge.herokuapp.com/total.svg?repo_id=jumpserver) -![Visitors in today](https://visitor-count-badge.herokuapp.com/today.svg?repo_id=jumpserver) [![Python3](https://img.shields.io/badge/python-3.6-green.svg?style=plastic)](https://www.python.org/) -[![Django](https://img.shields.io/badge/django-2.1-brightgreen.svg?style=plastic)](https://www.djangoproject.com/) -[![Ansible](https://img.shields.io/badge/ansible-2.4.2.0-blue.svg?style=plastic)](https://www.ansible.com/) -[![Paramiko](https://img.shields.io/badge/paramiko-2.4.1-green.svg?style=plastic)](http://www.paramiko.org/) +[![Django](https://img.shields.io/badge/django-2.2-brightgreen.svg?style=plastic)](https://www.djangoproject.com/) +[![Docker Pulls](https://img.shields.io/docker/pulls/jumpserver/jms_all.svg)](https://hub.docker.com/u/jumpserver) +---- +## CRITICAL BUG WARNING + +JumpServer found a critical bug for pre auth and info leak, You should fix quickly. + +Thanks for reactivity of Alibaba Hackerone bug bounty program report us this bug + +**Vulnerable version:** +``` +< v2.6.2 +< v2.5.4 +< v2.4.5 += v1.5.9 +``` + +**Safe version:** +``` +>= v2.6.2 +>= v2.5.4 +>= v2.4.5 += v1.5.9 (Unstander version, so no change) +``` + +**Fix method:** +Upgrade to save version + + +**Quick temporary fix method:(recommend)** + +Modify nginx config file, disable vulnerable api + +``` +/api/v1/authentication/connection-token/ +/api/v1/users/connection-token/ +``` + +Nginx config path + +``` +# Community old version +/etc/nginx/conf.d/jumpserver.conf + +# Enterpise old version +jumpserver-release/nginx/http_server.conf + +# New version +jumpserver-release/compose/config_static/http_server.conf +``` + +Modify nginx config + +``` +### On the server location top, or before of /api and / +location /api/v1/authentication/connection-token/ { + return 403; +} + +location /api/v1/users/connection-token/ { + return 403; +} +### Add two location above + +location /api/ { + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_pass http://core:8080; + } + +... +``` + +Then restart nginx + +``` +docker deployment: +$ docker restart jms_nginx + +rpm or other deployment: +$ systemctl restart nginx + +``` + +**Fix verify** + +``` +$ wget https://github.com/jumpserver/jumpserver/releases/download/v2.6.2/jms_bug_check.sh + +# bash jms_bug_check.sh HOST +$ bash jms_bug_check.sh demo.jumpserver.org +漏洞已修复 (fixed) +漏洞未修复 (vulnerable) +``` + + +**Attack detection** + +Download the check script under the directory logs than the gunicorn on + +``` +$ pwd +/opt/jumpserver/core/logs + +$ ls gunicorn.log +gunicorn.log + +$ wget 'https://github.com/jumpserver/jumpserver/releases/download/v2.6.2/jms_check_attack.sh' +$ bash jms_check_attack.sh +系统未被入侵 (safe) +系统已被入侵 (attacked) +``` + +-------------------------- ---- -- [中文版](https://github.com/jumpserver/jumpserver/blob/master/README_EN.md) +- [中文版](https://github.com/jumpserver/jumpserver/blob/master/README.md) Jumpserver is the first fully open source bastion in the world, based on the GNU GPL v2.0 open source protocol. Jumpserver is a professional operation and maintenance audit system conforms to 4A specifications. From 0d4e3462101af48211c53269cd6c7d00bb17b572 Mon Sep 17 00:00:00 2001 From: ibuler Date: Mon, 18 Jan 2021 14:25:17 +0800 Subject: [PATCH 08/36] =?UTF-8?q?chore:=20=E4=BF=AE=E6=94=B9readme?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 ++ README_EN.md | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index fb682ff50..beeabadff 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,8 @@ ## 紧急BUG修复通知 JumpServer发现远程执行漏洞,请速度修复 +非常感谢 **reactivity of Alibaba Hackerone bug bounty program**(瑞典) 向我们报告了此 BUG + **影响版本:** ``` < v2.6.2 diff --git a/README_EN.md b/README_EN.md index 6f18cf741..379670f09 100644 --- a/README_EN.md +++ b/README_EN.md @@ -9,7 +9,7 @@ JumpServer found a critical bug for pre auth and info leak, You should fix quickly. -Thanks for reactivity of Alibaba Hackerone bug bounty program report us this bug +Thanks for **reactivity of Alibaba Hackerone bug bounty program** report us this bug **Vulnerable version:** ``` From 1243546627e896bebb2b9bbdffefcf9195667295 Mon Sep 17 00:00:00 2001 From: noon Date: Mon, 18 Jan 2021 18:08:38 +0800 Subject: [PATCH 09/36] Update README_EN.md style: change some sentences in the critical bug warning --- README_EN.md | 59 +++++++++++++++++++++++++++------------------------- 1 file changed, 31 insertions(+), 28 deletions(-) diff --git a/README_EN.md b/README_EN.md index 379670f09..5f2539c9f 100644 --- a/README_EN.md +++ b/README_EN.md @@ -7,9 +7,9 @@ ---- ## CRITICAL BUG WARNING -JumpServer found a critical bug for pre auth and info leak, You should fix quickly. +Recently we have found a critical bug for remote execution vulnerability which leads to pre-auth and info leak, please fix it as soon as possible. -Thanks for **reactivity of Alibaba Hackerone bug bounty program** report us this bug +Thanks for **reactivity from Alibaba Hackerone bug bounty program** report us this bug **Vulnerable version:** ``` @@ -17,46 +17,48 @@ Thanks for **reactivity of Alibaba Hackerone bug bounty program** report us this < v2.5.4 < v2.4.5 = v1.5.9 +>= v1.5.3 ``` -**Safe version:** +**Safe and Stable version:** ``` >= v2.6.2 >= v2.5.4 >= v2.4.5 -= v1.5.9 (Unstander version, so no change) += v1.5.9 (version tag didn't change) +< v1.5.3 ``` -**Fix method:** -Upgrade to save version +**Bug Fix Solution:** +Upgrade to the latest version or the version mentioned above -**Quick temporary fix method:(recommend)** +**Temporary Solution (upgrade asap):** -Modify nginx config file, disable vulnerable api +Modify the Nginx config file and disable the vulnerable api listed below ``` /api/v1/authentication/connection-token/ /api/v1/users/connection-token/ ``` -Nginx config path +Path to Nginx config file ``` -# Community old version +# Previous Community version /etc/nginx/conf.d/jumpserver.conf -# Enterpise old version +# Previous Enterprise version jumpserver-release/nginx/http_server.conf -# New version +# Latest version jumpserver-release/compose/config_static/http_server.conf ``` -Modify nginx config +Changes in Nginx config file ``` -### On the server location top, or before of /api and / +### Put the following code on top of location server, or before /api and / location /api/v1/authentication/connection-token/ { return 403; } @@ -64,7 +66,7 @@ location /api/v1/authentication/connection-token/ { location /api/v1/users/connection-token/ { return 403; } -### Add two location above +### End right here location /api/ { proxy_set_header X-Real-IP $remote_addr; @@ -76,7 +78,7 @@ location /api/ { ... ``` -Then restart nginx +Save the file and restart Nginx ``` docker deployment: @@ -87,21 +89,22 @@ $ systemctl restart nginx ``` -**Fix verify** +**Bug Fix Verification** ``` +# Download the following script to check if it is fixed $ wget https://github.com/jumpserver/jumpserver/releases/download/v2.6.2/jms_bug_check.sh -# bash jms_bug_check.sh HOST +# Run the code to verify it $ bash jms_bug_check.sh demo.jumpserver.org -漏洞已修复 (fixed) -漏洞未修复 (vulnerable) +漏洞已修复 (It means the bug is fixed) +漏洞未修复 (It means the bug is not fixed and the system is still vulnerable) ``` -**Attack detection** +**Attack Simulation** -Download the check script under the directory logs than the gunicorn on +Go to the logs directory which should contain gunicorn.log file. Then download the "attack" script and execute it ``` $ pwd @@ -112,8 +115,8 @@ gunicorn.log $ wget 'https://github.com/jumpserver/jumpserver/releases/download/v2.6.2/jms_check_attack.sh' $ bash jms_check_attack.sh -系统未被入侵 (safe) -系统已被入侵 (attacked) +系统未被入侵 (It means the system is safe) +系统已被入侵 (It means the system is being attacked) ``` -------------------------- @@ -122,11 +125,11 @@ $ bash jms_check_attack.sh - [中文版](https://github.com/jumpserver/jumpserver/blob/master/README.md) -Jumpserver is the first fully open source bastion in the world, based on the GNU GPL v2.0 open source protocol. Jumpserver is a professional operation and maintenance audit system conforms to 4A specifications. +Jumpserver is the world's first open-source PAM (Privileged Access Management System) and is licensed under the GNU GPL v2.0. It is a 4A-compliant professional operation and maintenance security audit system. -Jumpserver is developed using Python / Django, conforms to the Web 2.0 specification, and is equipped with the industry-leading Web Terminal solution which have beautiful interface and great user experience. +Jumpserver uses Python / Django for development, follows Web 2.0 specifications, and is equipped with an industry-leading Web Terminal solution that provides a beautiful user interface and great user experience -Jumpserver adopts a distributed architecture to support multi-branch deployment across multiple areas. The central node provides APIs, and login nodes are deployed in each branch. It can be scaled horizontally without concurrency restrictions. +Jumpserver adopts a distributed architecture to support multi-branch deployment across multiple cross-regional areas. The central node provides APIs, and login nodes are deployed in each branch. It can be scaled horizontally without concurrency restrictions. Change the world, starting from little things. @@ -157,7 +160,7 @@ We provide online demo, demo video and screenshots to get you started quickly. We provide the SDK for your other systems to quickly interact with the Jumpserver API. - [Python](https://github.com/jumpserver/jumpserver-python-sdk) Jumpserver other components use this SDK to complete the interaction. -- [Java](https://github.com/KaiJunYan/jumpserver-java-sdk.git) 恺珺同学提供的Java版本的SDK thanks to 恺珺 for provide Java SDK +- [Java](https://github.com/KaiJunYan/jumpserver-java-sdk.git) Thanks to 恺珺 for providing his Java SDK vesrion. ### License & Copyright From 36f113e30715fa90a4c6c231b9547bc82fa5788b Mon Sep 17 00:00:00 2001 From: fit2bot <68588906+fit2bot@users.noreply.github.com> Date: Wed, 27 Jan 2021 13:03:20 +0800 Subject: [PATCH 10/36] =?UTF-8?q?chore:=20=E6=B7=BB=E5=8A=A0=E8=B4=A1?= =?UTF-8?q?=E7=8C=AE=E8=80=85=E5=9B=BE=E7=89=87=20(#5532)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: 添加贡献者图片 * chore: 优化通知样式 Co-authored-by: ibuler --- README.md | 183 +++++++++++------------------------------------------- 1 file changed, 37 insertions(+), 146 deletions(-) diff --git a/README.md b/README.md index beeabadff..aec972e2b 100644 --- a/README.md +++ b/README.md @@ -6,116 +6,12 @@ - [ENGLISH](https://github.com/jumpserver/jumpserver/blob/master/README_EN.md) -## 紧急BUG修复通知 -JumpServer发现远程执行漏洞,请速度修复 - -非常感谢 **reactivity of Alibaba Hackerone bug bounty program**(瑞典) 向我们报告了此 BUG - -**影响版本:** -``` -< v2.6.2 -< v2.5.4 -< v2.4.5 -= v1.5.9 ->= v1.5.3 -``` -**安全版本:** -``` ->= v2.6.2 ->= v2.5.4 ->= v2.4.5 -= v1.5.9 (版本号没变) -< v1.5.3 -``` - -**修复方案:** - -将JumpServer升级至安全版本; - -**临时修复方案:** - -修改 Nginx 配置文件屏蔽漏洞接口 - -``` -/api/v1/authentication/connection-token/ -/api/v1/users/connection-token/ -``` - -Nginx 配置文件位置 -``` -# 社区老版本 -/etc/nginx/conf.d/jumpserver.conf - -# 企业老版本 -jumpserver-release/nginx/http_server.conf - -# 新版本在 -jumpserver-release/compose/config_static/http_server.conf -``` - -修改 Nginx 配置文件实例 -``` -### 保证在 /api 之前 和 / 之前 -location /api/v1/authentication/connection-token/ { - return 403; -} - -location /api/v1/users/connection-token/ { - return 403; -} -### 新增以上这些 - -location /api/ { - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header Host $host; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_pass http://core:8080; - } - -... -``` - -修改完成后重启 nginx - -``` -docker方式: -docker restart jms_nginx - -nginx方式: -systemctl restart nginx - -``` - -**修复验证** - -``` -$ wget https://github.com/jumpserver/jumpserver/releases/download/v2.6.2/jms_bug_check.sh - -# 使用方法 bash jms_bug_check.sh HOST -$ bash jms_bug_check.sh demo.jumpserver.org -漏洞已修复 -``` - -**入侵检测** - -下载脚本到 jumpserver 日志目录,这个目录中存在 gunicorn.log,然后执行 - -``` -$ pwd -/opt/jumpserver/core/logs - -$ ls gunicorn.log -gunicorn.log - -$ wget 'https://github.com/jumpserver/jumpserver/releases/download/v2.6.2/jms_check_attack.sh' -$ bash jms_check_attack.sh -系统未被入侵 -``` +|![notification](https://raw.githubusercontent.com/goharbor/website/master/docs/img/readme/bell-outline-badged.svg)安全通知| +|------------------| +|2021年1月15日 JumpServer 发现远程执行漏洞,请速度修复 [详见](https://github.com/jumpserver/jumpserver/issues/5533), 非常感谢 **reactivity of Alibaba Hackerone bug bounty program**(瑞典) 向我们报告了此 BUG| -------------------------- -JumpServer 正在寻找开发者,一起为改变世界做些贡献吧,哪怕一点点,联系我 - JumpServer 是全球首款开源的堡垒机,使用 GNU GPL v2.0 开源协议,是符合 4A 规范的运维安全审计系统。 JumpServer 使用 Python / Django 为主进行开发,遵循 Web 2.0 规范,配备了业界领先的 Web Terminal 方案,交互界面美观、用户体验好。 @@ -124,7 +20,6 @@ JumpServer 采纳分布式架构,支持多机房跨区域部署,支持横向 改变世界,从一点点开始。 -> 注: [KubeOperator](https://github.com/KubeOperator/KubeOperator) 是 JumpServer 团队在 Kubernetes 领域的的又一全新力作,欢迎关注和使用。 ## 特色优势 @@ -136,21 +31,6 @@ JumpServer 采纳分布式架构,支持多机房跨区域部署,支持横向 - 多租户: 一套系统,多个子公司和部门同时使用; - 多应用支持: 数据库,Windows远程应用,Kubernetes。 -## 版本说明 - -自 v2.0.0 发布后, JumpServer 版本号命名将变更为:v大版本.功能版本.Bug修复版本。比如: - -``` -v2.0.1 是 v2.0.0 之后的Bug修复版本; -v2.1.0 是 v2.0.0 之后的功能版本。 -``` - -像其它优秀开源项目一样,JumpServer 每个月会发布一个功能版本,并同时维护 3 个功能版本。比如: - -``` -在 v2.4 发布前,我们会同时维护 v2.1、v2.2、v2.3; -在 v2.4 发布后,我们会同时维护 v2.2、v2.3、v2.4;v2.1 会停止维护。 -``` ## 功能列表 @@ -180,8 +60,8 @@ v2.1.0 是 v2.0.0 之后的功能版本。 RADIUS 二次认证 - 登录复核(X-PACK) - 用户登录行为受管理员的监管与控制 + 登录复核 + 用户登录行为受管理员的监管与控制:small_orange_diamond: 账号管理
Account @@ -205,23 +85,23 @@ v2.1.0 是 v2.0.0 之后的功能版本。 密码过期设置 - 批量改密(X-PACK) - 定期批量改密 + 批量改密 + 定期批量改密:small_orange_diamond: - 多种密码策略 + 多种密码策略:small_orange_diamond: - 多云纳管(X-PACK) - 对私有云、公有云资产自动统一纳管 + 多云纳管 + 对私有云、公有云资产自动统一纳管:small_orange_diamond: - 收集用户(X-PACK) - 自定义任务定期收集主机用户 + 收集用户 + 自定义任务定期收集主机用户:small_orange_diamond: - 密码匣子(X-PACK) - 统一对资产主机的用户密码进行查看、更新、测试操作 + 密码匣子 + 统一对资产主机的用户密码进行查看、更新、测试操作:small_orange_diamond: 授权控制
Authorization @@ -246,7 +126,7 @@ v2.1.0 是 v2.0.0 之后的功能版本。 实现更细粒度的应用级授权 - MySQL 数据库应用、RemoteApp 远程应用(X-PACK) + MySQL 数据库应用、RemoteApp 远程应用:small_orange_diamond: 动作授权 @@ -273,12 +153,12 @@ v2.1.0 是 v2.0.0 之后的功能版本。 实现 Web SFTP 文件管理 - 工单管理(X-PACK) - 支持对用户登录请求行为进行控制 + 工单管理 + 支持对用户登录请求行为进行控制:small_orange_diamond: - 组织管理(X-PACK) - 实现多租户管理与权限隔离 + 组织管理 + 实现多租户管理与权限隔离:small_orange_diamond: 安全审计
Audit @@ -297,7 +177,7 @@ v2.1.0 是 v2.0.0 之后的功能版本。 支持对 Linux、Windows 等资产操作的录像进行回放审计 - 支持对 RemoteApp(X-PACK)、MySQL 等应用操作的录像进行回放审计 + 支持对 RemoteApp:small_orange_diamond:、MySQL 等应用操作的录像进行回放审计 指令审计 @@ -313,7 +193,7 @@ v2.1.0 是 v2.0.0 之后的功能版本。 命令方式 - Web UI方式 (X-PACK) + Web UI方式 :small_orange_diamond: @@ -321,13 +201,13 @@ v2.1.0 是 v2.0.0 之后的功能版本。 MySQL - Oracle (X-PACK) + Oracle :small_orange_diamond: - MariaDB (X-PACK) + MariaDB :small_orange_diamond: - PostgreSQL (X-PACK) + PostgreSQL :small_orange_diamond: 功能亮点 @@ -357,6 +237,8 @@ v2.1.0 是 v2.0.0 之后的功能版本。 +**说明**: 带 :small_orange_diamond: 后缀的是 X-PACK 插件有的功能 + ## 快速开始 - [极速安装](https://docs.jumpserver.org/zh/master/install/setup_by_fast/) @@ -366,9 +248,19 @@ v2.1.0 是 v2.0.0 之后的功能版本。 ## 组件项目 - [Lina](https://github.com/jumpserver/lina) JumpServer Web UI 项目 - [Luna](https://github.com/jumpserver/luna) JumpServer Web Terminal 项目 -- [Koko](https://github.com/jumpserver/koko) JumpServer 字符协议 Connector 项目,替代原来 Python 版本的 [Coco](https://github.com/jumpserver/coco) +- [KoKo](https://github.com/jumpserver/koko) JumpServer 字符协议 Connector 项目,替代原来 Python 版本的 [Coco](https://github.com/jumpserver/coco) - [Guacamole](https://github.com/jumpserver/docker-guacamole) JumpServer 图形协议 Connector 项目,依赖 [Apache Guacamole](https://guacamole.apache.org/) +## 贡献 +如果有你好的想法创意,或者帮助我们修复了 Bug, 欢迎提交 Pull Request + +感谢以下贡献者,让 JumpServer 更加完善 + + + + + + ## 致谢 - [Apache Guacamole](https://guacamole.apache.org/) Web页面连接 RDP, SSH, VNC协议设备,JumpServer 图形化连接依赖 - [OmniDB](https://omnidb.org/) Web页面连接使用数据库,JumpServer Web数据库依赖 @@ -376,7 +268,6 @@ v2.1.0 是 v2.0.0 之后的功能版本。 ## JumpServer 企业版 - [申请企业版试用](https://jinshuju.net/f/kyOYpi) -> 注:企业版支持离线安装,申请通过后会提供高速下载链接。 ## 案例研究 From b86f9ac8719ebbdd253a9c3a2fd0fedcae871b88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=81=E5=B9=BF?= Date: Tue, 23 Mar 2021 15:22:35 +0800 Subject: [PATCH 11/36] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index aec972e2b..fdcf28b11 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # JumpServer 多云环境下更好用的堡垒机 -[![Python3](https://img.shields.io/badge/python-3.6-green.svg?style=plastic)](https://www.python.org/) -[![Django](https://img.shields.io/badge/django-2.2-brightgreen.svg?style=plastic)](https://www.djangoproject.com/) +[![License](https://shields.io/github/license/jumpserver/jumpserver)](https://www.gnu.org/licenses/old-licenses/gpl-2.0.html) +[![Release Downloads](https://shields.io/github/downloads/jumpserver/jumpserver/total)](https://github.com/jumpserver/jumpserver/releases) [![Docker Pulls](https://img.shields.io/docker/pulls/jumpserver/jms_all.svg)](https://hub.docker.com/u/jumpserver) - [ENGLISH](https://github.com/jumpserver/jumpserver/blob/master/README_EN.md) From e4938ffc8584e9c185f445384eac0ae4590c7170 Mon Sep 17 00:00:00 2001 From: noon Date: Sat, 27 Mar 2021 20:14:45 +0800 Subject: [PATCH 12/36] Update README_EN.md (#5856) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update README_EN.md Translate parts of the README.md * Update README_EN.md * Update README_EN.md change the word PAM to Bastion host * Update README_EN.md * Update README_EN.md Clip the bug part to JumpServer 远程执行漏洞 2021-01-15 --- README_EN.md | 390 +++++++++++++++++++++++++++++++++++---------------- 1 file changed, 268 insertions(+), 122 deletions(-) diff --git a/README_EN.md b/README_EN.md index 5f2539c9f..072aaadea 100644 --- a/README_EN.md +++ b/README_EN.md @@ -1,143 +1,245 @@ -## Jumpserver +# Jumpserver - The Bastion Host for Multi-Cloud Environment [![Python3](https://img.shields.io/badge/python-3.6-green.svg?style=plastic)](https://www.python.org/) [![Django](https://img.shields.io/badge/django-2.2-brightgreen.svg?style=plastic)](https://www.djangoproject.com/) [![Docker Pulls](https://img.shields.io/docker/pulls/jumpserver/jms_all.svg)](https://hub.docker.com/u/jumpserver) ----- -## CRITICAL BUG WARNING +- [中文版](https://github.com/jumpserver/jumpserver/blob/master/README.md) -Recently we have found a critical bug for remote execution vulnerability which leads to pre-auth and info leak, please fix it as soon as possible. - -Thanks for **reactivity from Alibaba Hackerone bug bounty program** report us this bug - -**Vulnerable version:** -``` -< v2.6.2 -< v2.5.4 -< v2.4.5 -= v1.5.9 ->= v1.5.3 -``` - -**Safe and Stable version:** -``` ->= v2.6.2 ->= v2.5.4 ->= v2.4.5 -= v1.5.9 (version tag didn't change) -< v1.5.3 -``` - -**Bug Fix Solution:** -Upgrade to the latest version or the version mentioned above - - -**Temporary Solution (upgrade asap):** - -Modify the Nginx config file and disable the vulnerable api listed below - -``` -/api/v1/authentication/connection-token/ -/api/v1/users/connection-token/ -``` - -Path to Nginx config file - -``` -# Previous Community version -/etc/nginx/conf.d/jumpserver.conf - -# Previous Enterprise version -jumpserver-release/nginx/http_server.conf - -# Latest version -jumpserver-release/compose/config_static/http_server.conf -``` - -Changes in Nginx config file - -``` -### Put the following code on top of location server, or before /api and / -location /api/v1/authentication/connection-token/ { - return 403; -} - -location /api/v1/users/connection-token/ { - return 403; -} -### End right here - -location /api/ { - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header Host $host; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_pass http://core:8080; - } - -... -``` - -Save the file and restart Nginx - -``` -docker deployment: -$ docker restart jms_nginx - -rpm or other deployment: -$ systemctl restart nginx - -``` - -**Bug Fix Verification** - -``` -# Download the following script to check if it is fixed -$ wget https://github.com/jumpserver/jumpserver/releases/download/v2.6.2/jms_bug_check.sh - -# Run the code to verify it -$ bash jms_bug_check.sh demo.jumpserver.org -漏洞已修复 (It means the bug is fixed) -漏洞未修复 (It means the bug is not fixed and the system is still vulnerable) -``` - - -**Attack Simulation** - -Go to the logs directory which should contain gunicorn.log file. Then download the "attack" script and execute it - -``` -$ pwd -/opt/jumpserver/core/logs - -$ ls gunicorn.log -gunicorn.log - -$ wget 'https://github.com/jumpserver/jumpserver/releases/download/v2.6.2/jms_check_attack.sh' -$ bash jms_check_attack.sh -系统未被入侵 (It means the system is safe) -系统已被入侵 (It means the system is being attacked) -``` +|![notification](https://raw.githubusercontent.com/goharbor/website/master/docs/img/readme/bell-outline-badged.svg)Security Notice| +|------------------| +|On 15th January 2021, JumpServer found a critical bug for remote execution vulnerability. Please fix it asap! [For more detail](https://github.com/jumpserver/jumpserver/issues/5533) Thanks for **reactivity of Alibaba Hackerone bug bounty program** report use the bug| -------------------------- ----- - -- [中文版](https://github.com/jumpserver/jumpserver/blob/master/README.md) - -Jumpserver is the world's first open-source PAM (Privileged Access Management System) and is licensed under the GNU GPL v2.0. It is a 4A-compliant professional operation and maintenance security audit system. +Jumpserver is the world's first open-source Bastion Host and is licensed under the GNU GPL v2.0. It is a 4A-compliant professional operation and maintenance security audit system. Jumpserver uses Python / Django for development, follows Web 2.0 specifications, and is equipped with an industry-leading Web Terminal solution that provides a beautiful user interface and great user experience Jumpserver adopts a distributed architecture to support multi-branch deployment across multiple cross-regional areas. The central node provides APIs, and login nodes are deployed in each branch. It can be scaled horizontally without concurrency restrictions. -Change the world, starting from little things. +Change the world by taking every little step ---- +### Advantages -### Features +- Open Source: huge transparency and free to access with quick installation process. +- Distributed: support large-scale concurrent access with ease. +- No Plugin required: all you need is a browser, the ultimate Web Terminal experience. +- Multi-Cloud supported: a unified system to manage assets on different clouds at the same time +- Cloud storage: audit records are stored in the cloud. Data lost no more! +- Multi-Tenant system: multiple subsidiary companies or departments access the same system simultaneously. +- Many applications supported: link to databases, windows remote applications, and Kubernetes cluster, etc. - ![Jumpserver 功能](https://jumpserver-release.oss-cn-hangzhou.aliyuncs.com/Jumpserver148.jpeg "Jumpserver 功能") +## Features List + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AuthenticationLoginUnified way to access and authenticate resources
LDAP/AD Authentication
RADIUS Authentication
OpenID Authentication(Single Sign-On)
CAS Authentication (Single Sign-On)
MFA (Multi-Factor Authentication)Use Google Authenticator for MFA
RADIUS (Remote Authentication Dial In User Service)
Login SupervisionAny user’s login behavior is supervised and controlled by the administrator:small_orange_diamond:
AccountingCentralized Accounts ManagementAdmin Users management
System Users management
Unified Password ManagementAsset password custody (a matrix storing all asset password with dense security)
Auto-generated passwords
Automatic password handling (auto login assets)
Password expiration settings
Password change SchedularSupport regular batch Linux/Windows assets password changing:small_orange_diamond:
Implement multiple password strategies:small_orange_diamond:
Multi-Cloud ManagementAutomatically manage private cloud and public cloud assets in a unified platform :small_orange_diamond:
Users Acquisition Create regular custom tasks to collect system users in selected assets to identify and track the privileges ownership:small_orange_diamond:
Password Vault Unified operations to check, update, and test system user password to prevent stealing or unauthorised sharing of passwords:small_orange_diamond:
AuthorizationMulti-DimensionalGranting users or user groups to access assets, asset nodes, or applications through system users. Providing precise access control to different roles of users
AssetsAssets are arranged and displayed in a tree structure
Assets and Nodes have immense flexibility for authorizing
Assets in nodes inherit authorization automatically
child nodes automatically inherit authorization from parent nodes
ApplicationProvides granular access control for privileged users on application level to protect from unauthorized access and unintentional errors
Database applications (MySQL, Oracle, PostgreSQL, MariaDB, etc.) and Remote App:small_orange_diamond:
ActionsDeeper restriction on the control of file upload, download and connection actions of authorized assets. Control the permission of clipboard copy/paste (from outer terminal to current asset)
Time BoundSharply limited the available (accessible) time for account access to the authorized resources to reduce the risk and attack surface drastically
Privileged AssignmentAssign the denied/allowed command lists to different system users as privilege elevation, with the latter taking the form of allowing particular commands to be run with a higher level of privileges. (Minimize insider threat)
Command FilteringCreating list of restriction commands that you would like to assign to different authorized system users for filtering purpose
File Transfer and ManagementSupport SFTP file upload/download
File ManagementProvide a Web UI for SFTP file management
Workflow ManagementManage user login confirmation requests and assets or applications authorization requests for Just-In-Time Privileges functionality:small_orange_diamond:
Group Management Establishing a multi-tenant ecosystem that able authority isolation to keep malicious actors away from sensitive administrative backends:small_orange_diamond:
AuditingOperationsAuditing user operation behaviors for any access or usage of given privileged accounts
SessionSupport real-time session audit
Full history of all previous session audits
VideoComplete session audit and playback recordings on assets operation (Linux, Windows)
Full recordings of RemoteApp, MySQL, and Kubernetes:small_orange_diamond:
Supports uploading recordings to public clouds
CommandCommand auditing on assets and applications operation. Send warning alerts when executing illegal commands
File TransferFull recordings of file upload and download
DatabaseHow to connectCommand line
Built-in Web UI:small_orange_diamond:
Supported DatabaseMySQL
Oracle :small_orange_diamond:
MariaDB :small_orange_diamond:
PostgreSQL :small_orange_diamond:
Feature HighlightsSyntax highlights
Prettier SQL formmating
Support Shortcuts
Support selected SQL statements
SQL commands history query
Support page creation: DB, TABLE
Session AuditingFull records of command
Playback videos
+ +**Note**: Rows with :small_orange_diamond: at the end of the sentence means that it is X-PACK features exclusive ([Apply for X-PACK Trial](https://jinshuju.net/f/kyOYpi)) ### Start @@ -162,6 +264,50 @@ We provide the SDK for your other systems to quickly interact with the Jumpserve - [Python](https://github.com/jumpserver/jumpserver-python-sdk) Jumpserver other components use this SDK to complete the interaction. - [Java](https://github.com/KaiJunYan/jumpserver-java-sdk.git) Thanks to 恺珺 for providing his Java SDK vesrion. +## JumpServer Component Projects +- [Lina](https://github.com/jumpserver/lina) JumpServer Web UI +- [Luna](https://github.com/jumpserver/luna) JumpServer Web Terminal +- [KoKo](https://github.com/jumpserver/koko) JumpServer Character protocaol Connector, replace original Python Version [Coco](https://github.com/jumpserver/coco) +- [Guacamole](https://github.com/jumpserver/docker-guacamole) JumpServer Graphics protocol Connector,rely on [Apache Guacamole](https://guacamole.apache.org/) + +## Contribution +If you have any good ideas or helping us to fix bugs, please submit a Pull Request and accept our thanks :) + +Thanks to the following contributors for making JumpServer better everyday! + + + + + + +## Thanks to +- [Apache Guacamole](https://guacamole.apache.org/) Web page connection RDP, SSH, VNC protocol equipment. JumpServer graphical connection dependent. +- [OmniDB](https://omnidb.org/) Web page connection to databases. JumpServer Web database dependent. + + +## JumpServer Enterprise Version +- [Apply for it](https://jinshuju.net/f/kyOYpi) + +## Case Study + +- [JumpServer 堡垒机护航顺丰科技超大规模资产安全运维](https://blog.fit2cloud.com/?p=1147); +- [JumpServer 堡垒机让“大智慧”的混合 IT 运维更智慧](https://blog.fit2cloud.com/?p=882); +- [携程 JumpServer 堡垒机部署与运营实战](https://blog.fit2cloud.com/?p=851); +- [小红书的JumpServer堡垒机大规模资产跨版本迁移之路](https://blog.fit2cloud.com/?p=516); +- [JumpServer堡垒机助力中手游提升多云环境下安全运维能力](https://blog.fit2cloud.com/?p=732); +- [中通快递:JumpServer主机安全运维实践](https://blog.fit2cloud.com/?p=708); +- [东方明珠:JumpServer高效管控异构化、分布式云端资产](https://blog.fit2cloud.com/?p=687); +- [江苏农信:JumpServer堡垒机助力行业云安全运维](https://blog.fit2cloud.com/?p=666)。 + +## For safety instructions + +JumpServer is a security product. Please refer to [Basic Security Recommendations](https://docs.jumpserver.org/zh/master/install/install_security/) for deployment and installation. + +If you find a security problem, please contact us directly: + +- ibuler@fit2cloud.com +- support@fit2cloud.com +- 400-052-0755 ### License & Copyright Copyright (c) 2014-2019 Beijing Duizhan Tech, Inc., All rights reserved. From bb9d92fd7edd294cca581d54624cd5479a9dea9d Mon Sep 17 00:00:00 2001 From: xinwen Date: Mon, 22 Mar 2021 18:00:06 +0800 Subject: [PATCH 13/36] perf: delete_test_cookie --- apps/authentication/views/login.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/authentication/views/login.py b/apps/authentication/views/login.py index 6b0e799b2..9f628e6e8 100644 --- a/apps/authentication/views/login.py +++ b/apps/authentication/views/login.py @@ -55,6 +55,9 @@ class UserLoginView(mixins.AuthMixin, FormView): def form_valid(self, form): if not self.request.session.test_cookie_worked(): return HttpResponse(_("Please enable cookies and try again.")) + # https://docs.djangoproject.com/en/3.1/topics/http/sessions/#setting-test-cookies + self.request.session.delete_test_cookie() + try: self.check_user_auth(decrypt_passwd=True) except errors.AuthFailedError as e: From 06a4e0d395b113ee930530c67c19f055d1e9e0bd Mon Sep 17 00:00:00 2001 From: ibuler Date: Wed, 24 Mar 2021 10:10:38 +0800 Subject: [PATCH 14/36] =?UTF-8?q?perf:=20=E4=BF=AE=E6=94=B9=E8=A1=A8?= =?UTF-8?q?=E7=BB=93=E6=9E=84=E8=BF=81=E7=A7=BB=EF=BC=8C=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?rdp=20terminal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migrations/0033_auto_20210324_1008.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 apps/terminal/migrations/0033_auto_20210324_1008.py diff --git a/apps/terminal/migrations/0033_auto_20210324_1008.py b/apps/terminal/migrations/0033_auto_20210324_1008.py new file mode 100644 index 000000000..f5ecaf6d0 --- /dev/null +++ b/apps/terminal/migrations/0033_auto_20210324_1008.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1 on 2021-03-24 02:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('terminal', '0032_auto_20210302_1853'), + ] + + operations = [ + migrations.AlterField( + model_name='session', + name='login_from', + field=models.CharField(choices=[('ST', 'SSH Terminal'), ('RT', 'RDP Terminal'), ('WT', 'Web Terminal')], default='ST', max_length=2, verbose_name='Login from'), + ), + ] From 2f8042141c206821f55df35f96795829b7b4817a Mon Sep 17 00:00:00 2001 From: xinwen Date: Tue, 23 Mar 2021 18:36:25 +0800 Subject: [PATCH 15/36] =?UTF-8?q?fix:=20=E6=8E=88=E6=9D=83=E6=A0=91?= =?UTF-8?q?=E8=8A=82=E7=82=B9=E6=8E=92=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/perms/utils/asset/user_permission.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/apps/perms/utils/asset/user_permission.py b/apps/perms/utils/asset/user_permission.py index 739a4191e..7f3a0941f 100644 --- a/apps/perms/utils/asset/user_permission.py +++ b/apps/perms/utils/asset/user_permission.py @@ -488,11 +488,12 @@ class UserGrantedAssetsQueryUtils(UserGrantedUtilsBase): if granted_status == NodeFrom.granted: assets = Asset.objects.order_by().filter(nodes__id=node.id) - return assets elif granted_status == NodeFrom.asset: - return self._get_indirect_granted_node_assets(node.id) + assets = self._get_indirect_granted_node_assets(node.id) else: - return Asset.objects.none() + assets = Asset.objects.none() + assets = assets.order_by('hostname') + return assets def _get_indirect_granted_node_assets(self, id) -> AssetQuerySet: assets = Asset.objects.order_by().filter(nodes__id=id).distinct() & self.get_direct_granted_assets() @@ -538,6 +539,10 @@ class UserGrantedAssetsQueryUtils(UserGrantedUtilsBase): class UserGrantedNodesQueryUtils(UserGrantedUtilsBase): + def sort(self, nodes): + nodes = sorted(nodes, key=lambda x: x.value) + return nodes + def get_node_children(self, key): if not key: return self.get_top_level_nodes() @@ -545,11 +550,13 @@ class UserGrantedNodesQueryUtils(UserGrantedUtilsBase): node = PermNode.objects.get(key=key) granted_status = node.get_granted_status(self.user) if granted_status == NodeFrom.granted: - return PermNode.objects.filter(parent_key=key) + nodes = PermNode.objects.filter(parent_key=key) elif granted_status in (NodeFrom.asset, NodeFrom.child): - return self.get_indirect_granted_node_children(key) + nodes = self.get_indirect_granted_node_children(key) else: - return PermNode.objects.none() + nodes = PermNode.objects.none() + nodes = self.sort(nodes) + return nodes def get_indirect_granted_node_children(self, key): """ @@ -571,7 +578,8 @@ class UserGrantedNodesQueryUtils(UserGrantedUtilsBase): def get_top_level_nodes(self): nodes = self.get_special_nodes() - nodes.extend(self.get_indirect_granted_node_children('')) + real_nodes = self.get_indirect_granted_node_children('') + nodes.extend(self.sort(real_nodes)) return nodes def get_ungrouped_node(self): From c2463fe573ade8d534844c29c2f9ebd6040e3211 Mon Sep 17 00:00:00 2001 From: Bai Date: Fri, 26 Mar 2021 10:37:18 +0800 Subject: [PATCH 16/36] =?UTF-8?q?perf:=20Session=20Login=20from=20?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=20RDP=20Terminal=20=E7=B1=BB=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/terminal/models/session.py | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/terminal/models/session.py b/apps/terminal/models/session.py index 8c759a74d..ee7e07a4d 100644 --- a/apps/terminal/models/session.py +++ b/apps/terminal/models/session.py @@ -20,6 +20,7 @@ from .terminal import Terminal class Session(OrgModelMixin): class LOGIN_FROM(ChoiceSet): ST = 'ST', 'SSH Terminal' + RT = 'RT', 'RDP Terminal' WT = 'WT', 'Web Terminal' class PROTOCOL(ChoiceSet): From a5179d159667d3c0036d2dad17f5db0d25367a24 Mon Sep 17 00:00:00 2001 From: xinwen Date: Mon, 29 Mar 2021 12:01:24 +0800 Subject: [PATCH 17/36] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=20es=20?= =?UTF-8?q?=E5=BF=BD=E7=95=A5=20https=20=E8=AF=81=E4=B9=A6=E9=AA=8C?= =?UTF-8?q?=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/terminal/backends/command/es.py | 4 ++++ apps/terminal/serializers/storage.py | 5 ++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/terminal/backends/command/es.py b/apps/terminal/backends/command/es.py index 009435a5b..fc0f247f4 100644 --- a/apps/terminal/backends/command/es.py +++ b/apps/terminal/backends/command/es.py @@ -25,6 +25,10 @@ class CommandStore(): kwargs = config.get("OTHER", {}) self.index = config.get("INDEX") or 'jumpserver' self.doc_type = config.get("DOC_TYPE") or 'command_store' + + ignore_verify_certs = kwargs.pop('ignore_verify_certs', False) + if ignore_verify_certs: + kwargs['verify_certs'] = None self.es = Elasticsearch(hosts=hosts, max_retries=0, **kwargs) @staticmethod diff --git a/apps/terminal/serializers/storage.py b/apps/terminal/serializers/storage.py index 7cc0628ba..7f5dec2fe 100644 --- a/apps/terminal/serializers/storage.py +++ b/apps/terminal/serializers/storage.py @@ -181,7 +181,10 @@ class CommandStorageTypeESSerializer(serializers.Serializer): max_length=1024, default='jumpserver', label=_('Index'), allow_null=True ) DOC_TYPE = ReadableHiddenField(default='command', label=_('Doc type'), allow_null=True) - + ignore_verify_certs = serializers.BooleanField( + default=False, label=_('Ignore Certificate Verification'), + source='OTHER.ignore_verify_certs', allow_null=True, + ) # mapping From 9cd5675209549b82968e44f0150765b7e2411526 Mon Sep 17 00:00:00 2001 From: ibuler Date: Fri, 26 Mar 2021 19:09:34 +0800 Subject: [PATCH 18/36] =?UTF-8?q?perf:=20=E4=BF=AE=E6=94=B9terminal=20stat?= =?UTF-8?q?uts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit perf: 优化status api perf: 优化 status api perf: 修改sesion参数 perf: 修改migrations perf: 优化数据结构 perf: 修改保留日志 perf: 优化之前的一个写法 --- apps/terminal/api/__init__.py | 2 +- apps/terminal/api/component.py | 34 --- apps/terminal/api/status.py | 74 +++++++ apps/terminal/api/terminal.py | 48 +---- apps/terminal/const.py | 1 + .../migrations/0033_auto_20210329_1711.py | 43 ++++ apps/terminal/models/session.py | 3 +- apps/terminal/models/status.py | 52 ++++- apps/terminal/models/terminal.py | 199 ++++++------------ apps/terminal/serializers/__init__.py | 1 - apps/terminal/serializers/components.py | 25 --- apps/terminal/serializers/terminal.py | 35 ++- apps/terminal/tasks.py | 2 +- apps/terminal/urls/api_urls.py | 1 - apps/terminal/utils.py | 166 +++++++++++---- 15 files changed, 380 insertions(+), 306 deletions(-) delete mode 100644 apps/terminal/api/component.py create mode 100644 apps/terminal/api/status.py create mode 100644 apps/terminal/migrations/0033_auto_20210329_1711.py delete mode 100644 apps/terminal/serializers/components.py diff --git a/apps/terminal/api/__init__.py b/apps/terminal/api/__init__.py index c0c6b8197..e6a3b3885 100644 --- a/apps/terminal/api/__init__.py +++ b/apps/terminal/api/__init__.py @@ -5,4 +5,4 @@ from .session import * from .command import * from .task import * from .storage import * -from .component import * +from .status import * diff --git a/apps/terminal/api/component.py b/apps/terminal/api/component.py deleted file mode 100644 index f881b5e98..000000000 --- a/apps/terminal/api/component.py +++ /dev/null @@ -1,34 +0,0 @@ -# -*- coding: utf-8 -*- -# - -import logging -from rest_framework import generics, status -from rest_framework.views import Response - -from .. import serializers -from ..utils import ComponentsMetricsUtil -from common.permissions import IsAppUser, IsSuperUser - -logger = logging.getLogger(__file__) - - -__all__ = [ - 'ComponentsStateAPIView', 'ComponentsMetricsAPIView', -] - - -class ComponentsStateAPIView(generics.CreateAPIView): - """ koko, guacamole, omnidb 上报状态 """ - permission_classes = (IsAppUser,) - serializer_class = serializers.ComponentsStateSerializer - - -class ComponentsMetricsAPIView(generics.GenericAPIView): - """ 返回汇总组件指标数据 """ - permission_classes = (IsSuperUser,) - - def get(self, request, *args, **kwargs): - tp = request.query_params.get('type') - util = ComponentsMetricsUtil() - metrics = util.get_metrics(tp) - return Response(metrics, status=status.HTTP_200_OK) diff --git a/apps/terminal/api/status.py b/apps/terminal/api/status.py new file mode 100644 index 000000000..b39e13ba7 --- /dev/null +++ b/apps/terminal/api/status.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- +# + +import logging +from django.shortcuts import get_object_or_404 +from rest_framework import viewsets, generics +from rest_framework.views import Response +from rest_framework import status + +from common.permissions import IsAppUser, IsOrgAdminOrAppUser, IsSuperUser +from ..models import Terminal, Status, Session +from .. import serializers +from ..utils import TypedComponentsStatusMetricsUtil + +logger = logging.getLogger(__file__) + + +__all__ = [ + 'StatusViewSet', + 'ComponentsMetricsAPIView', +] + + +class StatusViewSet(viewsets.ModelViewSet): + queryset = Status.objects.all() + serializer_class = serializers.StatusSerializer + permission_classes = (IsOrgAdminOrAppUser,) + session_serializer_class = serializers.SessionSerializer + task_serializer_class = serializers.TaskSerializer + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + self.handle_sessions() + self.perform_create(serializer) + tasks = self.request.user.terminal.task_set.filter(is_finished=False) + serializer = self.task_serializer_class(tasks, many=True) + return Response(serializer.data, status=201) + + def handle_sessions(self): + session_ids = self.request.data.get('sessions', []) + # guacamole 上报的 session 是字符串 + # "[53cd3e47-210f-41d8-b3c6-a184f3, 53cd3e47-210f-41d8-b3c6-a184f4]" + if isinstance(session_ids, str): + session_ids = session_ids[1:-1].split(',') + session_ids = [sid.strip() for sid in session_ids if sid.strip()] + Session.set_sessions_active(session_ids) + + def get_queryset(self): + terminal_id = self.kwargs.get("terminal", None) + if terminal_id: + terminal = get_object_or_404(Terminal, id=terminal_id) + return terminal.status_set.all() + return super().get_queryset() + + def perform_create(self, serializer): + serializer.validated_data.pop('sessions', None) + serializer.validated_data["terminal"] = self.request.user.terminal + return super().perform_create(serializer) + + def get_permissions(self): + if self.action == "create": + self.permission_classes = (IsAppUser,) + return super().get_permissions() + + +class ComponentsMetricsAPIView(generics.GenericAPIView): + """ 返回汇总组件指标数据 """ + permission_classes = (IsSuperUser,) + + def get(self, request, *args, **kwargs): + util = TypedComponentsStatusMetricsUtil() + metrics = util.get_metrics() + return Response(metrics, status=status.HTTP_200_OK) diff --git a/apps/terminal/api/terminal.py b/apps/terminal/api/terminal.py index 91e9d4d07..5ee19b3e2 100644 --- a/apps/terminal/api/terminal.py +++ b/apps/terminal/api/terminal.py @@ -4,8 +4,7 @@ import logging import uuid from django.core.cache import cache -from django.shortcuts import get_object_or_404 -from rest_framework import viewsets, generics +from rest_framework import generics from rest_framework.views import APIView, Response from rest_framework import status from django.conf import settings @@ -13,13 +12,13 @@ from django.conf import settings from common.drf.api import JMSBulkModelViewSet from common.utils import get_object_or_none -from common.permissions import IsAppUser, IsOrgAdminOrAppUser, IsSuperUser, WithBootstrapToken -from ..models import Terminal, Status, Session +from common.permissions import IsAppUser, IsSuperUser, WithBootstrapToken +from ..models import Terminal from .. import serializers from .. import exceptions __all__ = [ - 'TerminalViewSet', 'StatusViewSet', 'TerminalConfig', + 'TerminalViewSet', 'TerminalConfig', 'TerminalRegistrationApi', ] logger = logging.getLogger(__file__) @@ -72,45 +71,6 @@ class TerminalViewSet(JMSBulkModelViewSet): return queryset -class StatusViewSet(viewsets.ModelViewSet): - queryset = Status.objects.all() - serializer_class = serializers.StatusSerializer - permission_classes = (IsOrgAdminOrAppUser,) - session_serializer_class = serializers.SessionSerializer - task_serializer_class = serializers.TaskSerializer - - def create(self, request, *args, **kwargs): - self.handle_sessions() - tasks = self.request.user.terminal.task_set.filter(is_finished=False) - serializer = self.task_serializer_class(tasks, many=True) - return Response(serializer.data, status=201) - - def handle_sessions(self): - session_ids = self.request.data.get('sessions', []) - # guacamole 上报的 session 是字符串 - # "[53cd3e47-210f-41d8-b3c6-a184f3, 53cd3e47-210f-41d8-b3c6-a184f4]" - if isinstance(session_ids, str): - session_ids = session_ids[1:-1].split(',') - session_ids = [sid.strip() for sid in session_ids if sid.strip()] - Session.set_sessions_active(session_ids) - - def get_queryset(self): - terminal_id = self.kwargs.get("terminal", None) - if terminal_id: - terminal = get_object_or_404(Terminal, id=terminal_id) - self.queryset = terminal.status_set.all() - return self.queryset - - def perform_create(self, serializer): - serializer.validated_data["terminal"] = self.request.user.terminal - return super().perform_create(serializer) - - def get_permissions(self): - if self.action == "create": - self.permission_classes = (IsAppUser,) - return super().get_permissions() - - class TerminalConfig(APIView): permission_classes = (IsAppUser,) diff --git a/apps/terminal/const.py b/apps/terminal/const.py index 6e3a38027..830913e28 100644 --- a/apps/terminal/const.py +++ b/apps/terminal/const.py @@ -31,6 +31,7 @@ class ComponentStatusChoices(TextChoices): critical = 'critical', _('Critical') high = 'high', _('High') normal = 'normal', _('Normal') + offline = 'offline', _('Offline') @classmethod def status(cls): diff --git a/apps/terminal/migrations/0033_auto_20210329_1711.py b/apps/terminal/migrations/0033_auto_20210329_1711.py new file mode 100644 index 000000000..bbc45a8c7 --- /dev/null +++ b/apps/terminal/migrations/0033_auto_20210329_1711.py @@ -0,0 +1,43 @@ +# Generated by Django 3.1 on 2021-03-29 09:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('terminal', '0032_auto_20210302_1853'), + ] + + operations = [ + migrations.RenameField( + model_name='status', + old_name='cpu_used', + new_name='cpu_load', + ), + migrations.AlterField( + model_name='status', + name='cpu_load', + field=models.FloatField(default=0, verbose_name='CPU Load'), + ), + migrations.AddField( + model_name='status', + name='disk_used', + field=models.FloatField(default=0, verbose_name='Disk Used'), + ), + migrations.AlterField( + model_name='status', + name='boot_time', + field=models.FloatField(default=0, verbose_name='Boot Time'), + ), + migrations.AlterField( + model_name='status', + name='connections', + field=models.IntegerField(default=0, verbose_name='Connections'), + ), + migrations.AlterField( + model_name='status', + name='threads', + field=models.IntegerField(default=0, verbose_name='Threads'), + ), + ] diff --git a/apps/terminal/models/session.py b/apps/terminal/models/session.py index ee7e07a4d..89e338143 100644 --- a/apps/terminal/models/session.py +++ b/apps/terminal/models/session.py @@ -14,7 +14,6 @@ from assets.models import Asset from orgs.mixins.models import OrgModelMixin from common.db.models import ChoiceSet from ..backends import get_multi_command_storage -from .terminal import Terminal class Session(OrgModelMixin): @@ -47,7 +46,7 @@ class Session(OrgModelMixin): is_finished = models.BooleanField(default=False, db_index=True) has_replay = models.BooleanField(default=False, verbose_name=_("Replay")) has_command = models.BooleanField(default=False, verbose_name=_("Command")) - terminal = models.ForeignKey(Terminal, null=True, on_delete=models.DO_NOTHING, db_constraint=False) + terminal = models.ForeignKey('terminal.Terminal', null=True, on_delete=models.DO_NOTHING, db_constraint=False) protocol = models.CharField(choices=PROTOCOL.choices, default='ssh', max_length=16, db_index=True) date_start = models.DateTimeField(verbose_name=_("Date start"), db_index=True, default=timezone.now) date_end = models.DateTimeField(verbose_name=_("Date end"), null=True) diff --git a/apps/terminal/models/status.py b/apps/terminal/models/status.py index a0607e5dc..dddf8f350 100644 --- a/apps/terminal/models/status.py +++ b/apps/terminal/models/status.py @@ -3,26 +3,62 @@ from __future__ import unicode_literals import uuid from django.db import models +from django.forms.models import model_to_dict +from django.core.cache import cache from django.utils.translation import ugettext_lazy as _ -from .terminal import Terminal +from common.utils import get_logger + + +logger = get_logger(__name__) class Status(models.Model): id = models.UUIDField(default=uuid.uuid4, primary_key=True) session_online = models.IntegerField(verbose_name=_("Session Online"), default=0) - cpu_used = models.FloatField(verbose_name=_("CPU Usage")) + cpu_load = models.FloatField(verbose_name=_("CPU Load"), default=0) memory_used = models.FloatField(verbose_name=_("Memory Used")) - connections = models.IntegerField(verbose_name=_("Connections")) - threads = models.IntegerField(verbose_name=_("Threads")) - boot_time = models.FloatField(verbose_name=_("Boot Time")) - terminal = models.ForeignKey(Terminal, null=True, on_delete=models.CASCADE) + disk_used = models.FloatField(verbose_name=_("Disk Used"), default=0) + connections = models.IntegerField(verbose_name=_("Connections"), default=0) + threads = models.IntegerField(verbose_name=_("Threads"), default=0) + boot_time = models.FloatField(verbose_name=_("Boot Time"), default=0) + terminal = models.ForeignKey('terminal.Terminal', null=True, on_delete=models.CASCADE) date_created = models.DateTimeField(auto_now_add=True) + CACHE_KEY = 'TERMINAL_STATUS_{}' + class Meta: db_table = 'terminal_status' get_latest_by = 'date_created' - def __str__(self): - return self.date_created.strftime("%Y-%m-%d %H:%M:%S") + def save_to_cache(self): + if not self.terminal: + return + key = self.CACHE_KEY.format(self.terminal.id) + data = model_to_dict(self) + cache.set(key, data, 60*3) + return data + + @classmethod + def get_terminal_latest_status(cls, terminal): + from ..utils import ComputeStatUtil + stat = cls.get_terminal_latest_stat(terminal) + return ComputeStatUtil.compute_component_status(stat) + + @classmethod + def get_terminal_latest_stat(cls, terminal): + key = cls.CACHE_KEY.format(terminal.id) + data = cache.get(key) + if not data: + return None + data.pop('terminal', None) + stat = cls(**data) + stat.terminal = terminal + return stat + + def save(self, force_insert=False, force_update=False, using=None, + update_fields=None): + self.terminal.set_alive(ttl=120) + return self.save_to_cache() + # return super().save() diff --git a/apps/terminal/models/terminal.py b/apps/terminal/models/terminal.py index 48e225cfd..e13902251 100644 --- a/apps/terminal/models/terminal.py +++ b/apps/terminal/models/terminal.py @@ -1,168 +1,63 @@ -from __future__ import unicode_literals import uuid from django.db import models +from django.core.cache import cache from django.utils.translation import ugettext_lazy as _ from django.conf import settings -from django.core.cache import cache from common.utils import get_logger from users.models import User +from .status import Status from .. import const +from ..const import ComponentStatusChoices as StatusChoice +from .session import Session logger = get_logger(__file__) -class ComputeStatusMixin: - - # system status - @staticmethod - def _common_compute_system_status(value, thresholds): - if thresholds[0] <= value <= thresholds[1]: - return const.ComponentStatusChoices.normal.value - elif thresholds[1] < value <= thresholds[2]: - return const.ComponentStatusChoices.high.value - else: - return const.ComponentStatusChoices.critical.value - - def _compute_system_cpu_load_1_status(self, value): - thresholds = [0, 5, 20] - return self._common_compute_system_status(value, thresholds) - - def _compute_system_memory_used_percent_status(self, value): - thresholds = [0, 85, 95] - return self._common_compute_system_status(value, thresholds) - - def _compute_system_disk_used_percent_status(self, value): - thresholds = [0, 80, 99] - return self._common_compute_system_status(value, thresholds) - - def _compute_system_status(self, state): - system_status_keys = [ - 'system_cpu_load_1', 'system_memory_used_percent', 'system_disk_used_percent' - ] - system_status = [] - for system_status_key in system_status_keys: - state_value = state.get(system_status_key) - if state_value is None: - msg = 'state: {}, state_key: {}, state_value: {}' - logger.debug(msg.format(state, system_status_key, state_value)) - state_value = 0 - status = getattr(self, f'_compute_{system_status_key}_status')(state_value) - system_status.append(status) - return system_status - - def _compute_component_status(self, state): - system_status = self._compute_system_status(state) - if const.ComponentStatusChoices.critical in system_status: - return const.ComponentStatusChoices.critical - elif const.ComponentStatusChoices.high in system_status: - return const.ComponentStatusChoices.high - else: - return const.ComponentStatusChoices.normal - - @staticmethod - def _compute_component_status_display(status): - return getattr(const.ComponentStatusChoices, status).label - - -class TerminalStateMixin(ComputeStatusMixin): - CACHE_KEY_COMPONENT_STATE = 'CACHE_KEY_COMPONENT_STATE_TERMINAL_{}' - CACHE_TIMEOUT = 120 +class TerminalStatusMixin: + ALIVE_KEY = 'TERMINAL_ALIVE_{}' + id: str @property - def cache_key(self): - return self.CACHE_KEY_COMPONENT_STATE.format(str(self.id)) - - # get - def _get_from_cache(self): - return cache.get(self.cache_key) - - def _set_to_cache(self, state): - cache.set(self.cache_key, state, self.CACHE_TIMEOUT) - - # set - def _add_status(self, state): - status = self._compute_component_status(state) - status_display = self._compute_component_status_display(status) - state.update({ - 'status': status, - 'status_display': status_display - }) + def latest_status(self): + return Status.get_terminal_latest_status(self) @property - def state(self): - state = self._get_from_cache() - return state or {} - - @state.setter - def state(self, state): - self._add_status(state) - self._set_to_cache(state) - - -class TerminalStatusMixin(TerminalStateMixin): - - # alive - @property - def is_alive(self): - return bool(self.state) - - # status - @property - def status(self): - if self.is_alive: - return self.state['status'] - else: - return const.ComponentStatusChoices.critical.value + def latest_status_display(self): + return self.latest_status.label @property - def status_display(self): - return self._compute_component_status_display(self.status) + def latest_stat(self): + return Status.get_terminal_latest_stat(self) @property def is_normal(self): - return self.status == const.ComponentStatusChoices.normal.value + return self.latest_status == StatusChoice.normal @property def is_high(self): - return self.status == const.ComponentStatusChoices.high.value + return self.latest_status == StatusChoice.high @property def is_critical(self): - return self.status == const.ComponentStatusChoices.critical.value - - -class Terminal(TerminalStatusMixin, models.Model): - id = models.UUIDField(default=uuid.uuid4, primary_key=True) - name = models.CharField(max_length=128, verbose_name=_('Name')) - type = models.CharField( - choices=const.TerminalTypeChoices.choices, default=const.TerminalTypeChoices.koko.value, - max_length=64, verbose_name=_('type') - ) - remote_addr = models.CharField(max_length=128, blank=True, verbose_name=_('Remote Address')) - ssh_port = models.IntegerField(verbose_name=_('SSH Port'), default=2222) - http_port = models.IntegerField(verbose_name=_('HTTP Port'), default=5000) - command_storage = models.CharField(max_length=128, verbose_name=_("Command storage"), default='default') - replay_storage = models.CharField(max_length=128, verbose_name=_("Replay storage"), default='default') - user = models.OneToOneField(User, related_name='terminal', verbose_name='Application User', null=True, on_delete=models.CASCADE) - is_accepted = models.BooleanField(default=False, verbose_name='Is Accepted') - is_deleted = models.BooleanField(default=False) - date_created = models.DateTimeField(auto_now_add=True) - comment = models.TextField(blank=True, verbose_name=_('Comment')) + return self.latest_status == StatusChoice.critical @property - def is_active(self): - if self.user and self.user.is_active: - return True - return False + def is_alive(self): + key = self.ALIVE_KEY.format(self.id) + # return self.latest_status != StatusChoice.offline + return cache.get(key, False) - @is_active.setter - def is_active(self, active): - if self.user: - self.user.is_active = active - self.user.save() + def set_alive(self, ttl=120): + key = self.ALIVE_KEY.format(self.id) + cache.set(key, True, ttl) + + +class StorageMixin: + command_storage: str + replay_storage: str def get_command_storage(self): from .storage import CommandStorage @@ -198,6 +93,44 @@ class Terminal(TerminalStatusMixin, models.Model): config = self.get_replay_storage_config() return {"TERMINAL_REPLAY_STORAGE": config} + +class Terminal(StorageMixin, TerminalStatusMixin, models.Model): + id = models.UUIDField(default=uuid.uuid4, primary_key=True) + name = models.CharField(max_length=128, verbose_name=_('Name')) + type = models.CharField( + choices=const.TerminalTypeChoices.choices, default=const.TerminalTypeChoices.koko.value, + max_length=64, verbose_name=_('type') + ) + remote_addr = models.CharField(max_length=128, blank=True, verbose_name=_('Remote Address')) + ssh_port = models.IntegerField(verbose_name=_('SSH Port'), default=2222) + http_port = models.IntegerField(verbose_name=_('HTTP Port'), default=5000) + command_storage = models.CharField(max_length=128, verbose_name=_("Command storage"), default='default') + replay_storage = models.CharField(max_length=128, verbose_name=_("Replay storage"), default='default') + user = models.OneToOneField(User, related_name='terminal', verbose_name='Application User', null=True, on_delete=models.CASCADE) + is_accepted = models.BooleanField(default=False, verbose_name='Is Accepted') + is_deleted = models.BooleanField(default=False) + date_created = models.DateTimeField(auto_now_add=True) + comment = models.TextField(blank=True, verbose_name=_('Comment')) + + + @property + def is_active(self): + if self.user and self.user.is_active: + return True + return False + + @is_active.setter + def is_active(self, active): + if self.user: + self.user.is_active = active + self.user.save() + + def get_online_sessions(self): + return Session.objects.filter(terminal=self, is_finished=False) + + def get_online_session_count(self): + return self.get_online_sessions().count() + @staticmethod def get_login_title_setting(): login_title = None diff --git a/apps/terminal/serializers/__init__.py b/apps/terminal/serializers/__init__.py index e958d7955..f1714dc21 100644 --- a/apps/terminal/serializers/__init__.py +++ b/apps/terminal/serializers/__init__.py @@ -4,4 +4,3 @@ from .terminal import * from .session import * from .storage import * from .command import * -from .components import * diff --git a/apps/terminal/serializers/components.py b/apps/terminal/serializers/components.py deleted file mode 100644 index d6e6d7f56..000000000 --- a/apps/terminal/serializers/components.py +++ /dev/null @@ -1,25 +0,0 @@ - -from rest_framework import serializers -from django.utils.translation import ugettext_lazy as _ - - -class ComponentsStateSerializer(serializers.Serializer): - # system - system_cpu_load_1 = serializers.FloatField( - required=False, label=_("System cpu load (1 minutes)") - ) - system_memory_used_percent = serializers.FloatField( - required=False, label=_('System memory used percent') - ) - system_disk_used_percent = serializers.FloatField( - required=False, label=_('System disk used percent') - ) - # sessions - session_active_count = serializers.IntegerField( - required=False, label=_("Session active count") - ) - - def save(self, **kwargs): - request = self.context['request'] - terminal = request.user.terminal - terminal.state = self.validated_data diff --git a/apps/terminal/serializers/terminal.py b/apps/terminal/serializers/terminal.py index caffba522..765634393 100644 --- a/apps/terminal/serializers/terminal.py +++ b/apps/terminal/serializers/terminal.py @@ -9,15 +9,34 @@ from common.utils import get_request_ip from ..models import ( Terminal, Status, Session, Task, CommandStorage, ReplayStorage ) -from .components import ComponentsStateSerializer + + +class StatusSerializer(serializers.ModelSerializer): + sessions = serializers.ListSerializer( + child=serializers.CharField(max_length=35), write_only=True + ) + + class Meta: + fields = [ + 'id', + 'cpu_load', 'memory_used', 'disk_used', + 'session_online', 'sessions', + 'terminal', 'date_created', + ] + extra_kwargs = { + "cpu_load": {'default': 0}, + "memory_used": {'default': 0}, + "disk_used": {'default': 0}, + } + model = Status class TerminalSerializer(BulkModelSerializer): session_online = serializers.SerializerMethodField() is_alive = serializers.BooleanField(read_only=True) - status = serializers.CharField(read_only=True) - status_display = serializers.CharField(read_only=True) - state = ComponentsStateSerializer(read_only=True) + status = serializers.CharField(read_only=True, source='latest_status') + status_display = serializers.CharField(read_only=True, source='latest_status_display') + stat = StatusSerializer(read_only=True, source='latest_stat') class Meta: model = Terminal @@ -25,7 +44,7 @@ class TerminalSerializer(BulkModelSerializer): 'id', 'name', 'type', 'remote_addr', 'http_port', 'ssh_port', 'comment', 'is_accepted', "is_active", 'session_online', 'is_alive', 'date_created', 'command_storage', 'replay_storage', - 'status', 'status_display', 'state' + 'status', 'status_display', 'stat' ] read_only_fields = ['type', 'date_created'] @@ -59,12 +78,6 @@ class TerminalSerializer(BulkModelSerializer): return Session.objects.filter(terminal=obj, is_finished=False).count() -class StatusSerializer(serializers.ModelSerializer): - class Meta: - fields = ['id', 'terminal'] - model = Status - - class TaskSerializer(BulkModelSerializer): class Meta: fields = '__all__' diff --git a/apps/terminal/tasks.py b/apps/terminal/tasks.py index b743f10f9..701aeac96 100644 --- a/apps/terminal/tasks.py +++ b/apps/terminal/tasks.py @@ -29,7 +29,7 @@ logger = get_task_logger(__name__) @after_app_ready_start @after_app_shutdown_clean_periodic def delete_terminal_status_period(): - yesterday = timezone.now() - datetime.timedelta(days=1) + yesterday = timezone.now() - datetime.timedelta(days=7) Status.objects.filter(date_created__lt=yesterday).delete() diff --git a/apps/terminal/urls/api_urls.py b/apps/terminal/urls/api_urls.py index 38b6df976..57fb6eb73 100644 --- a/apps/terminal/urls/api_urls.py +++ b/apps/terminal/urls/api_urls.py @@ -35,7 +35,6 @@ urlpatterns = [ path('command-storages//test-connective/', api.CommandStorageTestConnectiveApi.as_view(), name='command-storage-test-connective'), # components path('components/metrics/', api.ComponentsMetricsAPIView.as_view(), name='components-metrics'), - path('components/state/', api.ComponentsStateAPIView.as_view(), name='components-state'), # v2: get session's replay # path('v2/sessions//replay/', # api.SessionReplayV2ViewSet.as_view({'get': 'retrieve'}), diff --git a/apps/terminal/utils.py b/apps/terminal/utils.py index 8ceff0166..b13383fba 100644 --- a/apps/terminal/utils.py +++ b/apps/terminal/utils.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # import os +from itertools import groupby from django.conf import settings from django.core.files.storage import default_storage @@ -10,9 +11,7 @@ import jms_storage from common.tasks import send_mail_async from common.utils import get_logger, reverse -from settings.models import Setting from . import const - from .models import ReplayStorage, Session, Command logger = get_logger(__name__) @@ -141,23 +140,73 @@ def send_command_execution_alert_mail(command): send_mail_async.delay(subject, message, recipient_list, html_message=message) -class ComponentsMetricsUtil(object): - +class ComputeStatUtil: + # system status @staticmethod - def get_components(tp=None): + def _common_compute_system_status(value, thresholds): + if thresholds[0] <= value <= thresholds[1]: + return const.ComponentStatusChoices.normal.value + elif thresholds[1] < value <= thresholds[2]: + return const.ComponentStatusChoices.high.value + else: + return const.ComponentStatusChoices.critical.value + + @classmethod + def _compute_system_stat_status(cls, stat): + system_stat_thresholds_mapper = { + 'cpu_load': [0, 5, 20], + 'memory_used': [0, 85, 95], + 'disk_used': [0, 80, 99] + } + system_status = {} + for stat_key, thresholds in system_stat_thresholds_mapper.items(): + stat_value = getattr(stat, stat_key) + if stat_value is None: + msg = 'stat: {}, stat_key: {}, stat_value: {}' + logger.debug(msg.format(stat, stat_key, stat_value)) + stat_value = 0 + status = cls._common_compute_system_status(stat_value, thresholds) + system_status[stat_key] = status + return system_status + + @classmethod + def compute_component_status(cls, stat): + if not stat: + return const.ComponentStatusChoices.offline + system_status_values = cls._compute_system_stat_status(stat).values() + if const.ComponentStatusChoices.critical in system_status_values: + return const.ComponentStatusChoices.critical + elif const.ComponentStatusChoices.high in system_status_values: + return const.ComponentStatusChoices.high + else: + return const.ComponentStatusChoices.normal + + +class TypedComponentsStatusMetricsUtil(object): + def __init__(self): + self.components = [] + self.grouped_components = [] + self.get_components() + + def get_components(self): from .models import Terminal components = Terminal.objects.filter(is_deleted=False).order_by('type') - if tp: - components = components.filter(type=tp) - return components + grouped_components = groupby(components, lambda c: c.type) + grouped_components = [(i[0], list(i[1])) for i in grouped_components] + self.grouped_components = grouped_components + self.components = components - def get_metrics(self, tp=None): - components = self.get_components(tp) - total_count = normal_count = high_count = critical_count = offline_count = \ - session_active_total = 0 - for component in components: - total_count += 1 - if component.is_alive: + def get_metrics(self): + metrics = [] + for _tp, components in self.grouped_components: + normal_count = high_count = critical_count = 0 + total_count = offline_count = session_online_total = 0 + + for component in components: + total_count += 1 + if not component.is_alive: + offline_count += 1 + continue if component.is_normal: normal_count += 1 elif component.is_high: @@ -165,20 +214,23 @@ class ComponentsMetricsUtil(object): else: # critical critical_count += 1 - session_active_total += component.state.get('session_active_count', 0) - else: - offline_count += 1 - return { - 'total': total_count, - 'normal': normal_count, - 'high': high_count, - 'critical': critical_count, - 'offline': offline_count, - 'session_active': session_active_total - } + session_online_total += component.get_online_session_count() + metrics.append({ + 'total': total_count, + 'normal': normal_count, + 'high': high_count, + 'critical': critical_count, + 'offline': offline_count, + 'session_active': session_online_total, + 'type': _tp, + }) + return metrics -class ComponentsPrometheusMetricsUtil(ComponentsMetricsUtil): +class ComponentsPrometheusMetricsUtil(TypedComponentsStatusMetricsUtil): + def __init__(self): + super().__init__() + self.metrics = self.get_metrics() @staticmethod def convert_status_metrics(metrics): @@ -190,50 +242,74 @@ class ComponentsPrometheusMetricsUtil(ComponentsMetricsUtil): 'offline': metrics['offline'] } - def get_prometheus_metrics_text(self): + def get_component_status_metrics(self): prometheus_metrics = list() - # 各组件状态个数汇总 prometheus_metrics.append('# JumpServer 各组件状态个数汇总') status_metric_text = 'jumpserver_components_status_total{component_type="%s", status="%s"} %s' - for tp in const.TerminalTypeChoices.types(): + for metric in self.metrics: + tp = metric['type'] prometheus_metrics.append(f'## 组件: {tp}') - metrics_tp = self.get_metrics(tp) - status_metrics = self.convert_status_metrics(metrics_tp) + status_metrics = self.convert_status_metrics(metric) for status, value in status_metrics.items(): metric_text = status_metric_text % (tp, status, value) prometheus_metrics.append(metric_text) + return prometheus_metrics - prometheus_metrics.append('\n') - + def get_component_session_metrics(self): + prometheus_metrics = list() # 各组件在线会话数汇总 prometheus_metrics.append('# JumpServer 各组件在线会话数汇总') session_active_metric_text = 'jumpserver_components_session_active_total{component_type="%s"} %s' - for tp in const.TerminalTypeChoices.types(): + + for metric in self.metrics: + tp = metric['type'] prometheus_metrics.append(f'## 组件: {tp}') - metrics_tp = self.get_metrics(tp) - metric_text = session_active_metric_text % (tp, metrics_tp['session_active']) + metric_text = session_active_metric_text % (tp, metric['session_active']) prometheus_metrics.append(metric_text) + return prometheus_metrics - prometheus_metrics.append('\n') - + def get_component_stat_metrics(self): + prometheus_metrics = list() # 各组件节点指标 prometheus_metrics.append('# JumpServer 各组件一些指标') state_metric_text = 'jumpserver_components_%s{component_type="%s", component="%s"} %s' - states = [ + stats_key = [ + 'cpu_load', 'memory_used', 'disk_used', 'session_online' + ] + old_stats_key = [ 'system_cpu_load_1', 'system_memory_used_percent', 'system_disk_used_percent', 'session_active_count' ] - for state in states: - prometheus_metrics.append(f'## 指标: {state}') - components = self.get_components() - for component in components: + old_stats_key_mapper = dict(zip(stats_key, old_stats_key)) + + for stat_key in stats_key: + prometheus_metrics.append(f'## 指标: {stat_key}') + for component in self.components: if not component.is_alive: continue + component_stat = component.latest_stat + if not component_stat: + continue metric_text = state_metric_text % ( - state, component.type, component.name, component.state.get(state) + stat_key, component.type, component.name, getattr(component_stat, stat_key) ) prometheus_metrics.append(metric_text) + old_stat_key = old_stats_key_mapper.get(stat_key) + old_metric_text = state_metric_text % ( + old_stat_key, component.type, component.name, getattr(component_stat, stat_key) + ) + prometheus_metrics.append(old_metric_text) + return prometheus_metrics + def get_prometheus_metrics_text(self): + prometheus_metrics = list() + for method in [ + self.get_component_status_metrics, + self.get_component_session_metrics, + self.get_component_stat_metrics + ]: + prometheus_metrics.extend(method()) + prometheus_metrics.append('\n') prometheus_metrics_text = '\n'.join(prometheus_metrics) return prometheus_metrics_text From 297fedeffa2a39606b12ecbf781bb5f0fb3c8169 Mon Sep 17 00:00:00 2001 From: xinwen Date: Tue, 30 Mar 2021 09:39:30 +0800 Subject: [PATCH 19/36] =?UTF-8?q?fix:=20Default=20=E7=BB=84=E7=BB=87?= =?UTF-8?q?=E4=B8=8B=E5=87=BA=E7=8E=B0=20app=20user?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/orgs/api.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/orgs/api.py b/apps/orgs/api.py index 4f72fc792..b241301a8 100644 --- a/apps/orgs/api.py +++ b/apps/orgs/api.py @@ -90,6 +90,11 @@ class OrgMemberRelationBulkViewSet(JMSBulkRelationModelViewSet): filterset_class = OrgMemberRelationFilterSet search_fields = ('user__name', 'user__username', 'org__name') + def get_queryset(self): + queryset = super().get_queryset() + queryset = queryset.exclude(user__role=User.ROLE.APP) + return queryset + def perform_bulk_destroy(self, queryset): objs = list(queryset.all().prefetch_related('user', 'org')) queryset.delete() From 96c3b8138306dd85d86a8b67dc7378eaafee47b0 Mon Sep 17 00:00:00 2001 From: ibuler Date: Tue, 23 Mar 2021 19:45:52 +0800 Subject: [PATCH 20/36] perf: upgrade requirements version --- requirements/requirements.txt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/requirements/requirements.txt b/requirements/requirements.txt index b25368599..9dbb87017 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -12,9 +12,9 @@ chardet==3.0.4 configparser==3.5.0 coreapi==2.3.3 coreschema==0.0.4 -cryptography==3.2 +cryptography==3.3.2 decorator==4.1.2 -Django==3.1 +Django==3.1.6 django-auth-ldap==2.2.0 django-bootstrap3==14.2.0 django-celery-beat==2.0 @@ -39,7 +39,7 @@ gunicorn==19.9.0 idna==2.6 itsdangerous==0.24 itypes==1.1.0 -Jinja2==2.10.1 +Jinja2==2.11.3 jmespath==0.9.3 kombu==4.6.8 ldap3==2.4 @@ -49,7 +49,7 @@ olefile==0.44 openapi-codec==1.3.2 paramiko==2.7.2 passlib==1.7.1 -Pillow==7.1.0 +Pillow==8.1.1 pyasn1==0.4.8 pycparser==2.19 pycryptodome==3.10.1 From 1e5e87e62a1a89632f2fad3538989c49e108ead0 Mon Sep 17 00:00:00 2001 From: ibuler Date: Mon, 22 Mar 2021 13:56:40 +0800 Subject: [PATCH 21/36] =?UTF-8?q?perf:=20=E4=BC=98=E5=8C=96acl=E6=8F=90?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/authentication/errors.py | 24 ++++++++++- apps/authentication/mixins.py | 2 +- apps/locale/zh/LC_MESSAGES/django.mo | Bin 73487 -> 73503 bytes apps/locale/zh/LC_MESSAGES/django.po | 57 ++++++++++++++------------- 4 files changed, 54 insertions(+), 29 deletions(-) diff --git a/apps/authentication/errors.py b/apps/authentication/errors.py index 68c85ff87..576a4e4b1 100644 --- a/apps/authentication/errors.py +++ b/apps/authentication/errors.py @@ -31,7 +31,7 @@ reason_choices = { reason_user_invalid: _('Disabled or expired'), reason_user_inactive: _("This account is inactive."), reason_backend_not_match: _("Auth backend not match"), - reason_acl_not_allow: _("ACL is not allowed") + reason_acl_not_allow: _("ACL is not allowed"), } old_reason_choices = { '0': '-', @@ -184,6 +184,28 @@ class MFARequiredError(NeedMoreInfoError): } +class ACLError(AuthFailedNeedLogMixin, AuthFailedError): + msg = reason_acl_not_allow + error = 'acl_error' + + def __init__(self, msg, **kwargs): + self.msg = msg + super().__init__(**kwargs) + + def as_data(self): + return { + "error": reason_acl_not_allow, + "msg": self.msg + } + + +class LoginIPNotAllowed(ACLError): + def __init__(self, username, request, **kwargs): + self.username = username + self.request = request + super().__init__(_("IP is not allowed"), **kwargs) + + class LoginConfirmBaseError(NeedMoreInfoError): def __init__(self, ticket_id, **kwargs): self.ticket_id = ticket_id diff --git a/apps/authentication/mixins.py b/apps/authentication/mixins.py index 97adb0e67..747127ca9 100644 --- a/apps/authentication/mixins.py +++ b/apps/authentication/mixins.py @@ -183,7 +183,7 @@ class AuthMixin: from acls.models import LoginACL is_allowed = LoginACL.allow_user_to_login(user, ip) if not is_allowed: - raise self.raise_credential_error(error=errors.reason_acl_not_allow) + raise errors.LoginIPNotAllowed(username=user.username, request=self.request) def check_user_auth(self, decrypt_passwd=False): self.check_is_block() diff --git a/apps/locale/zh/LC_MESSAGES/django.mo b/apps/locale/zh/LC_MESSAGES/django.mo index 0179f659e7018038f2b842897fbf87589eb4f5cf..b8d77bd5756a5c2893b9c951e17a430682c60d5e 100644 GIT binary patch delta 17249 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 delta 17240 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 j#v5`ucE#IA@`PLEON)12T{vZ\n" "Language-Team: JumpServer team\n" @@ -428,7 +428,7 @@ msgstr "激活" #: assets/models/asset.py:196 assets/models/cluster.py:19 #: assets/models/user.py:66 templates/_nav.html:44 -#: xpack/plugins/cloud/models.py:92 xpack/plugins/cloud/serializers.py:138 +#: xpack/plugins/cloud/models.py:92 xpack/plugins/cloud/serializers.py:137 msgid "Admin user" msgstr "管理用户" @@ -693,7 +693,7 @@ msgstr "ssh私钥" #: users/templates/users/user_asset_permission.html:41 #: users/templates/users/user_asset_permission.html:73 #: users/templates/users/user_asset_permission.html:158 -#: xpack/plugins/cloud/models.py:89 xpack/plugins/cloud/serializers.py:139 +#: xpack/plugins/cloud/models.py:89 xpack/plugins/cloud/serializers.py:138 msgid "Node" msgstr "节点" @@ -1140,7 +1140,7 @@ msgstr "启用" msgid "-" msgstr "" -#: audits/models.py:96 xpack/plugins/cloud/const.py:24 +#: audits/models.py:96 xpack/plugins/cloud/const.py:23 msgid "Failed" msgstr "失败" @@ -1379,15 +1379,19 @@ msgstr "等待登录复核处理" msgid "Login confirm ticket was {}" msgstr "登录复核 {}" -#: authentication/errors.py:217 +#: authentication/errors.py:206 +msgid "IP is not allowed" +msgstr "来源 IP 不被允许登录" + +#: authentication/errors.py:239 msgid "SSO auth closed" msgstr "SSO 认证关闭了" -#: authentication/errors.py:222 authentication/views/login.py:232 +#: authentication/errors.py:244 authentication/views/login.py:232 msgid "Your password is too simple, please change it for security" msgstr "你的密码过于简单,为了安全,请修改" -#: authentication/errors.py:231 authentication/views/login.py:247 +#: authentication/errors.py:253 authentication/views/login.py:247 msgid "Your password has expired, please reset before logging in" msgstr "您的密码已过期,先修改再登录" @@ -2249,7 +2253,7 @@ msgstr "全局启用 MFA 认证" #: settings/serializers/settings.py:133 msgid "All user enable MFA" -msgstr "强制每个启用多因子认证" +msgstr "强制所有用户启用多因子认证" #: settings/serializers/settings.py:136 msgid "Batch command execution" @@ -3784,7 +3788,7 @@ msgstr "安全令牌验证" #: users/templates/users/_base_otp.html:14 users/templates/users/_user.html:13 #: users/templates/users/user_profile_update.html:55 -#: xpack/plugins/cloud/models.py:78 xpack/plugins/cloud/serializers.py:137 +#: xpack/plugins/cloud/models.py:78 xpack/plugins/cloud/serializers.py:136 msgid "Account" msgstr "账户" @@ -4774,46 +4778,42 @@ msgid "Azure (China)" msgstr "Azure (中国)" #: xpack/plugins/cloud/const.py:12 -msgid "Azure (International)" -msgstr "Azure (国际)" - -#: xpack/plugins/cloud/const.py:13 msgid "Huawei Cloud" msgstr "华为云" -#: xpack/plugins/cloud/const.py:14 +#: xpack/plugins/cloud/const.py:13 msgid "Tencent Cloud" msgstr "腾讯云" -#: xpack/plugins/cloud/const.py:15 +#: xpack/plugins/cloud/const.py:14 msgid "VMware" msgstr "" -#: xpack/plugins/cloud/const.py:19 +#: xpack/plugins/cloud/const.py:18 msgid "Instance name" msgstr "实例名称" -#: xpack/plugins/cloud/const.py:20 +#: xpack/plugins/cloud/const.py:19 msgid "Instance name and Partial IP" msgstr "实例名称和部分IP" -#: xpack/plugins/cloud/const.py:25 +#: xpack/plugins/cloud/const.py:24 msgid "Succeed" msgstr "成功" -#: xpack/plugins/cloud/const.py:29 +#: xpack/plugins/cloud/const.py:28 msgid "Unsync" msgstr "未同步" -#: xpack/plugins/cloud/const.py:30 +#: xpack/plugins/cloud/const.py:29 msgid "New Sync" msgstr "新同步" -#: xpack/plugins/cloud/const.py:31 +#: xpack/plugins/cloud/const.py:30 msgid "Synced" msgstr "已同步" -#: xpack/plugins/cloud/const.py:32 +#: xpack/plugins/cloud/const.py:31 msgid "Released" msgstr "已释放" @@ -4829,7 +4829,7 @@ msgstr "云服务商" msgid "Cloud account" msgstr "云账号" -#: xpack/plugins/cloud/models.py:81 xpack/plugins/cloud/serializers.py:118 +#: xpack/plugins/cloud/models.py:81 xpack/plugins/cloud/serializers.py:117 msgid "Regions" msgstr "地域" @@ -4837,7 +4837,7 @@ msgstr "地域" msgid "Hostname strategy" msgstr "主机名策略" -#: xpack/plugins/cloud/models.py:95 xpack/plugins/cloud/serializers.py:141 +#: xpack/plugins/cloud/models.py:95 xpack/plugins/cloud/serializers.py:140 msgid "Always update" msgstr "总是更新" @@ -5029,15 +5029,15 @@ msgstr "" msgid "Subscription ID" msgstr "" -#: xpack/plugins/cloud/serializers.py:116 +#: xpack/plugins/cloud/serializers.py:115 msgid "History count" msgstr "执行次数" -#: xpack/plugins/cloud/serializers.py:117 +#: xpack/plugins/cloud/serializers.py:116 msgid "Instance count" msgstr "实例个数" -#: xpack/plugins/cloud/serializers.py:140 +#: xpack/plugins/cloud/serializers.py:139 #: xpack/plugins/gathered_user/serializers.py:20 msgid "Periodic display" msgstr "定时执行" @@ -5130,6 +5130,9 @@ msgstr "旗舰版" msgid "Community edition" msgstr "社区版" +#~ msgid "Azure (International)" +#~ msgstr "Azure (国际)" + #~ msgid "Root organization only allow view and delete" #~ msgstr "全局组织仅支持 查看和删除" From 4a1fc0e2ac906e544800e69baea32d482d3262ea Mon Sep 17 00:00:00 2001 From: Bai Date: Thu, 1 Apr 2021 10:36:18 +0800 Subject: [PATCH 22/36] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8DNodeChildrenAddA?= =?UTF-8?q?PI=E4=B8=8D=E6=94=AF=E6=8C=81patch=E6=96=B9=E6=B3=95=E7=9A=84?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/assets/api/node.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/assets/api/node.py b/apps/assets/api/node.py index 09508e6e3..e832b83ba 100644 --- a/apps/assets/api/node.py +++ b/apps/assets/api/node.py @@ -221,7 +221,8 @@ class NodeAddChildrenApi(generics.UpdateAPIView): serializer_class = serializers.NodeAddChildrenSerializer instance = None - def put(self, request, *args, **kwargs): + def update(self, request, *args, **kwargs): + """ 同时支持 put 和 patch 方法""" instance = self.get_object() node_ids = request.data.get("nodes") children = Node.objects.filter(id__in=node_ids) From 7a61a671a2ef3329ede07c4b3facecd0bb07a55d Mon Sep 17 00:00:00 2001 From: xinwen Date: Tue, 6 Apr 2021 17:28:15 +0800 Subject: [PATCH 23/36] =?UTF-8?q?fix:=20=E7=AE=A1=E7=90=86=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E8=BE=93=E5=85=A5=E5=B8=A6=E5=AF=86=E7=A0=81=E7=9A=84?= =?UTF-8?q?=E7=A7=98=E9=92=A5=E6=8A=A5=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/assets/models/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/assets/models/base.py b/apps/assets/models/base.py index 404c7a991..5ed16741f 100644 --- a/apps/assets/models/base.py +++ b/apps/assets/models/base.py @@ -113,7 +113,7 @@ class AuthMixin: if self.public_key: public_key = self.public_key elif self.private_key: - public_key = ssh_pubkey_gen(self.private_key, self.password) + public_key = ssh_pubkey_gen(private_key=self.private_key, password=self.password) else: return '' From 7c03af7668f4af74b1cf46a99b9060b8d1a17ab4 Mon Sep 17 00:00:00 2001 From: xinwen Date: Tue, 6 Apr 2021 19:43:37 +0800 Subject: [PATCH 24/36] =?UTF-8?q?feat:=20=E8=B5=84=E4=BA=A7=E6=8E=88?= =?UTF-8?q?=E6=9D=83=E6=94=AF=E6=8C=81=E6=8C=89=E5=90=8D=E7=A7=B0=E6=A8=A1?= =?UTF-8?q?=E7=B3=8A=E6=90=9C=E7=B4=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/perms/api/asset/asset_permission.py | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/perms/api/asset/asset_permission.py b/apps/perms/api/asset/asset_permission.py index 65fe316e6..ff4de7d9f 100644 --- a/apps/perms/api/asset/asset_permission.py +++ b/apps/perms/api/asset/asset_permission.py @@ -20,3 +20,4 @@ class AssetPermissionViewSet(OrgBulkModelViewSet): model = AssetPermission serializer_class = serializers.AssetPermissionSerializer filterset_class = AssetPermissionFilter + search_fields = ('name',) From bee55004259de801828686c209db241b7d30fb24 Mon Sep 17 00:00:00 2001 From: xinwen Date: Tue, 6 Apr 2021 19:37:45 +0800 Subject: [PATCH 25/36] =?UTF-8?q?fix:=20=E5=88=9B=E5=BB=BA=E8=8A=82?= =?UTF-8?q?=E7=82=B9=E7=9A=84=E6=97=B6=E5=80=99=E5=8A=A0=E9=94=81=EF=BC=8C?= =?UTF-8?q?=E5=8F=AF=E4=BB=A5=E5=B9=B6=E5=8F=91=E8=B0=83=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/assets/api/node.py | 20 +++++++++++--------- apps/assets/locks.py | 9 +++++++++ 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/apps/assets/api/node.py b/apps/assets/api/node.py index e832b83ba..c756e9efd 100644 --- a/apps/assets/api/node.py +++ b/apps/assets/api/node.py @@ -28,6 +28,7 @@ from ..tasks import ( ) from .. import serializers from .mixin import SerializeToTreeNodeMixin +from assets.locks import NodeAddChildrenLock logger = get_logger(__file__) @@ -114,15 +115,16 @@ class NodeChildrenApi(generics.ListCreateAPIView): return super().initial(request, *args, **kwargs) def perform_create(self, serializer): - data = serializer.validated_data - _id = data.get("id") - value = data.get("value") - if not value: - value = self.instance.get_next_child_preset_name() - node = self.instance.create_child(value=value, _id=_id) - # 避免查询 full value - node._full_value = node.value - serializer.instance = node + with NodeAddChildrenLock(self.instance): + data = serializer.validated_data + _id = data.get("id") + value = data.get("value") + if not value: + value = self.instance.get_next_child_preset_name() + node = self.instance.create_child(value=value, _id=_id) + # 避免查询 full value + node._full_value = node.value + serializer.instance = node def get_object(self): pk = self.kwargs.get('pk') or self.request.query_params.get('id') diff --git a/apps/assets/locks.py b/apps/assets/locks.py index bdab57080..ad36cd2e4 100644 --- a/apps/assets/locks.py +++ b/apps/assets/locks.py @@ -1,5 +1,6 @@ from orgs.utils import current_org from common.utils.lock import DistributedLock +from assets.models import Node class NodeTreeUpdateLock(DistributedLock): @@ -18,3 +19,11 @@ class NodeTreeUpdateLock(DistributedLock): def __init__(self): name = self.get_name() super().__init__(name=name, release_on_transaction_commit=True, reentrant=True) + + +class NodeAddChildrenLock(DistributedLock): + name_template = 'assets.node.add_children.' + + def __init__(self, node: Node): + name = self.name_template.format(org_id=node.org_id) + super().__init__(name=name, release_on_transaction_commit=True) From 0ab88ce7542733c1c330de80da35f13906a819c6 Mon Sep 17 00:00:00 2001 From: xinwen Date: Wed, 7 Apr 2021 10:29:41 +0800 Subject: [PATCH 26/36] =?UTF-8?q?fix:=20=E8=AE=BF=E9=97=AE=20tokens=20?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E6=9B=B4=E6=96=B0=E7=94=A8=E6=88=B7=E6=9C=80?= =?UTF-8?q?=E5=90=8E=E7=99=BB=E5=BD=95=E6=97=B6=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/authentication/serializers.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/authentication/serializers.py b/apps/authentication/serializers.py index 528cc7cf9..c86b48039 100644 --- a/apps/authentication/serializers.py +++ b/apps/authentication/serializers.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- # +from django.utils import timezone from rest_framework import serializers from common.utils import get_object_or_none @@ -44,6 +45,10 @@ class BearerTokenSerializer(serializers.Serializer): def get_keyword(obj): return 'Bearer' + def update_last_login(self, user): + user.last_login = timezone.now() + user.save(update_fields=['last_login']) + def create(self, validated_data): request = self.context.get('request') if request.user and not request.user.is_anonymous: @@ -56,6 +61,8 @@ class BearerTokenSerializer(serializers.Serializer): "user id {} not exist".format(user_id) ) token, date_expired = user.create_bearer_token(request) + self.update_last_login(user) + instance = { "token": token, "date_expired": date_expired, From 48d1eecc085a7a942beb40d3c51cc10c70bd279c Mon Sep 17 00:00:00 2001 From: xinwen Date: Tue, 6 Apr 2021 09:53:27 +0800 Subject: [PATCH 27/36] =?UTF-8?q?fix:=20=E4=BF=AE=E6=AD=A3=20key=20?= =?UTF-8?q?=E4=B8=BA=200=20=E7=9A=84=E8=8A=82=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../0069_change_node_key0_to_key1.py | 59 +++++++++++++++++++ ...329_1711.py => 0034_auto_20210406_1434.py} | 11 ++-- 2 files changed, 64 insertions(+), 6 deletions(-) create mode 100644 apps/assets/migrations/0069_change_node_key0_to_key1.py rename apps/terminal/migrations/{0033_auto_20210329_1711.py => 0034_auto_20210406_1434.py} (82%) diff --git a/apps/assets/migrations/0069_change_node_key0_to_key1.py b/apps/assets/migrations/0069_change_node_key0_to_key1.py new file mode 100644 index 000000000..4024386dc --- /dev/null +++ b/apps/assets/migrations/0069_change_node_key0_to_key1.py @@ -0,0 +1,59 @@ +from django.db import migrations +from django.db.transaction import atomic + +default_id = '00000000-0000-0000-0000-000000000002' + + +def change_key0_to_key1(apps, schema_editor): + from orgs.utils import set_current_org + + # https://stackoverflow.com/questions/28777338/django-migrations-runpython-not-able-to-call-model-methods + Organization = apps.get_model('orgs', 'Organization') + Node = apps.get_model('assets', 'Node') + + print() + org = Organization.objects.get(id=default_id) + set_current_org(org) + + exists_0 = Node.objects.filter(key__startswith='0').exists() + if not exists_0: + print(f'--> Not exist key=0 nodes, do nothing.') + return + + key_1_count = Node.objects.filter(key__startswith='1').count() + if key_1_count > 1: + print(f'--> Node key=1 have children, can`t just delete it. Please contact JumpServer team') + return + + root_node = Node.objects.filter(key='1').first() + if root_node and root_node.assets.exists(): + print(f'--> Node key=1 has assets, do nothing.') + return + + with atomic(): + if root_node: + print(f'--> Delete node key=1') + root_node.delete() + + nodes_0 = Node.objects.filter(key__startswith='0') + + for n in nodes_0: + old_key = n.key + key_list = n.key.split(':') + key_list[0] = '1' + new_key = ':'.join(key_list) + n.key = new_key + n.save() + print('--> Modify key ( {} > {} )'.format(old_key, new_key)) + + +class Migration(migrations.Migration): + + dependencies = [ + ('orgs', '0010_auto_20210219_1241'), + ('assets', '0068_auto_20210312_1455'), + ] + + operations = [ + migrations.RunPython(change_key0_to_key1) + ] diff --git a/apps/terminal/migrations/0033_auto_20210329_1711.py b/apps/terminal/migrations/0034_auto_20210406_1434.py similarity index 82% rename from apps/terminal/migrations/0033_auto_20210329_1711.py rename to apps/terminal/migrations/0034_auto_20210406_1434.py index bbc45a8c7..59cc89b9b 100644 --- a/apps/terminal/migrations/0033_auto_20210329_1711.py +++ b/apps/terminal/migrations/0034_auto_20210406_1434.py @@ -1,4 +1,4 @@ -# Generated by Django 3.1 on 2021-03-29 09:11 +# Generated by Django 3.1 on 2021-04-06 06:34 from django.db import migrations, models @@ -6,16 +6,15 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('terminal', '0032_auto_20210302_1853'), + ('terminal', '0033_auto_20210324_1008'), ] operations = [ - migrations.RenameField( + migrations.RemoveField( model_name='status', - old_name='cpu_used', - new_name='cpu_load', + name='cpu_used', ), - migrations.AlterField( + migrations.AddField( model_name='status', name='cpu_load', field=models.FloatField(default=0, verbose_name='CPU Load'), From 32e2d195532cea93524c811f56e624a24cfab495 Mon Sep 17 00:00:00 2001 From: xinwen Date: Wed, 7 Apr 2021 18:34:01 +0800 Subject: [PATCH 28/36] =?UTF-8?q?fix:=20=E6=94=B9=E5=AF=86=E8=AE=A1?= =?UTF-8?q?=E5=88=92=E5=85=B3=E6=8E=89=E5=91=A8=E6=9C=9F=E6=89=A7=E8=A1=8C?= =?UTF-8?q?=E5=86=8D=E6=89=93=E5=BC=80=EF=BC=8C=E4=BB=BB=E5=8A=A1=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E6=89=A7=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/ops/celery/utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/ops/celery/utils.py b/apps/ops/celery/utils.py index ed51dc964..959e4c907 100644 --- a/apps/ops/celery/utils.py +++ b/apps/ops/celery/utils.py @@ -72,6 +72,7 @@ def create_or_update_celery_periodic_tasks(tasks): crontab=crontab, name=name, task=detail['task'], + enabled=detail.get('enabled', True), args=json.dumps(detail.get('args', [])), kwargs=json.dumps(detail.get('kwargs', {})), description=detail.get('description') or '' From 8da4027e3280fdd98867406004a7959811db9f50 Mon Sep 17 00:00:00 2001 From: xinwen Date: Wed, 7 Apr 2021 18:20:54 +0800 Subject: [PATCH 29/36] =?UTF-8?q?fix:=20=E6=8E=88=E6=9D=83=E8=B5=84?= =?UTF-8?q?=E4=BA=A7=E5=88=97=E8=A1=A8=20platform=20=E5=BA=94=E8=AF=A5?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E5=90=8D=E7=A7=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/authentication/urls/api_urls.py | 1 - apps/perms/serializers/asset/user_permission.py | 6 ++++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/authentication/urls/api_urls.py b/apps/authentication/urls/api_urls.py index 40eb2912e..d9b302800 100644 --- a/apps/authentication/urls/api_urls.py +++ b/apps/authentication/urls/api_urls.py @@ -23,4 +23,3 @@ urlpatterns = [ ] urlpatterns += router.urls - diff --git a/apps/perms/serializers/asset/user_permission.py b/apps/perms/serializers/asset/user_permission.py index be33d679b..f844b7d4d 100644 --- a/apps/perms/serializers/asset/user_permission.py +++ b/apps/perms/serializers/asset/user_permission.py @@ -4,7 +4,7 @@ from rest_framework import serializers from django.utils.translation import ugettext_lazy as _ -from assets.models import Node, SystemUser, Asset +from assets.models import Node, SystemUser, Asset, Platform from assets.serializers import ProtocolsField from perms.serializers.asset.permission import ActionsField @@ -39,7 +39,9 @@ class AssetGrantedSerializer(serializers.ModelSerializer): 被授权资产的数据结构 """ protocols = ProtocolsField(label=_('Protocols'), required=False, read_only=True) - platform = serializers.ReadOnlyField(source='platform_base') + platform = serializers.SlugRelatedField( + slug_field='name', queryset=Platform.objects.all(), label=_("Platform") + ) class Meta: model = Asset From dcaa798c2ec24d08cdca6b602c3516f356344bab Mon Sep 17 00:00:00 2001 From: fit2bot <68588906+fit2bot@users.noreply.github.com> Date: Thu, 8 Apr 2021 10:11:46 +0800 Subject: [PATCH 30/36] perf: csv upload (#5894) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit perf: 修改翻译 Co-authored-by: ibuler --- apps/common/drf/parsers/base.py | 2 +- apps/common/mixins/api.py | 4 + apps/locale/zh/LC_MESSAGES/django.po | 171 +++++++++++++++------------ 3 files changed, 103 insertions(+), 74 deletions(-) diff --git a/apps/common/drf/parsers/base.py b/apps/common/drf/parsers/base.py index 9b9379b3e..f228960f0 100644 --- a/apps/common/drf/parsers/base.py +++ b/apps/common/drf/parsers/base.py @@ -143,5 +143,5 @@ class BaseFileParser(BaseParser): return data except Exception as e: logger.error(e, exc_info=True) - raise ParseError('Parse error! ({})'.format(self.media_type)) + raise ParseError(_('Parse file error: {}').format(e)) diff --git a/apps/common/mixins/api.py b/apps/common/mixins/api.py index 7078b70b7..ea629bb84 100644 --- a/apps/common/mixins/api.py +++ b/apps/common/mixins/api.py @@ -9,6 +9,7 @@ from itertools import chain from django.db.models.signals import m2m_changed from django.core.cache import cache from django.http import JsonResponse +from django.utils.translation import ugettext as _ from rest_framework.response import Response from rest_framework.settings import api_settings from rest_framework.decorators import action @@ -47,6 +48,9 @@ class RenderToJsonMixin: column_title_field_pairs = jms_context.get('column_title_field_pairs', ()) data['title'] = column_title_field_pairs + if isinstance(request.data, (list, tuple)) and not any(request.data): + error = _("Request file format may be wrong") + return Response(data={"error": error}, status=400) return Response(data=data) diff --git a/apps/locale/zh/LC_MESSAGES/django.po b/apps/locale/zh/LC_MESSAGES/django.po index 08e9f5697..152475b14 100644 --- a/apps/locale/zh/LC_MESSAGES/django.po +++ b/apps/locale/zh/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: JumpServer 0.3.3\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2021-03-22 11:29+0800\n" +"POT-Creation-Date: 2021-04-07 18:15+0800\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: ibuler \n" "Language-Team: JumpServer team\n" @@ -36,7 +36,7 @@ msgstr "" #: assets/models/group.py:20 assets/models/label.py:18 ops/mixin.py:24 #: orgs/models.py:23 perms/models/base.py:49 settings/models.py:29 #: terminal/models/storage.py:23 terminal/models/storage.py:81 -#: terminal/models/task.py:16 terminal/models/terminal.py:139 +#: terminal/models/task.py:16 terminal/models/terminal.py:99 #: users/forms/profile.py:32 users/models/group.py:15 users/models/user.py:530 #: users/templates/users/_select_user_modal.html:13 #: users/templates/users/user_asset_permission.html:37 @@ -83,7 +83,7 @@ msgstr "激活中" #: assets/models/group.py:23 assets/models/label.py:23 ops/models/adhoc.py:37 #: orgs/models.py:26 perms/models/base.py:57 settings/models.py:34 #: terminal/models/storage.py:29 terminal/models/storage.py:87 -#: terminal/models/terminal.py:153 tickets/models/ticket.py:73 +#: terminal/models/terminal.py:113 tickets/models/ticket.py:73 #: users/models/group.py:16 users/models/user.py:563 #: users/templates/users/user_detail.html:115 #: users/templates/users/user_granted_database_app.html:38 @@ -211,7 +211,7 @@ msgstr "用户 `{}` 不在当前组织: `{}`" #: users/templates/users/user_profile.html:47 #: xpack/plugins/change_auth_plan/models.py:47 #: xpack/plugins/change_auth_plan/models.py:278 -#: xpack/plugins/cloud/serializers.py:44 +#: xpack/plugins/cloud/serializers.py:51 msgid "Username" msgstr "用户名" @@ -303,7 +303,7 @@ msgstr "集群" #: applications/serializers/attrs/application_category/db.py:11 #: ops/models/adhoc.py:146 #: users/templates/users/user_granted_database_app.html:36 -#: xpack/plugins/cloud/serializers.py:42 +#: xpack/plugins/cloud/serializers.py:49 msgid "Host" msgstr "主机" @@ -313,7 +313,7 @@ msgstr "主机" #: applications/serializers/attrs/application_type/oracle.py:11 #: applications/serializers/attrs/application_type/pgsql.py:11 #: assets/models/asset.py:188 assets/models/domain.py:53 -#: xpack/plugins/cloud/serializers.py:43 +#: xpack/plugins/cloud/serializers.py:50 msgid "Port" msgstr "端口" @@ -346,7 +346,7 @@ msgstr "目标URL" #: xpack/plugins/change_auth_plan/models.py:68 #: xpack/plugins/change_auth_plan/models.py:190 #: xpack/plugins/change_auth_plan/models.py:285 -#: xpack/plugins/cloud/serializers.py:46 +#: xpack/plugins/cloud/serializers.py:53 msgid "Password" msgstr "密码" @@ -366,15 +366,15 @@ msgstr "删除失败,存在关联资产" msgid "Number required" msgstr "需要为数字" -#: assets/api/node.py:64 +#: assets/api/node.py:65 msgid "You can't update the root node name" msgstr "不能修改根节点名称" -#: assets/api/node.py:71 +#: assets/api/node.py:72 msgid "You can't delete the root node ({})" msgstr "不能删除根节点 ({})" -#: assets/api/node.py:74 +#: assets/api/node.py:75 msgid "Deletion failed and the node contains children or assets" msgstr "删除失败,节点包含子节点或资产" @@ -428,7 +428,7 @@ msgstr "激活" #: assets/models/asset.py:196 assets/models/cluster.py:19 #: assets/models/user.py:66 templates/_nav.html:44 -#: xpack/plugins/cloud/models.py:92 xpack/plugins/cloud/serializers.py:137 +#: xpack/plugins/cloud/models.py:92 xpack/plugins/cloud/serializers.py:146 msgid "Admin user" msgstr "管理用户" @@ -588,6 +588,7 @@ msgid "Operator" msgstr "运营商" #: assets/models/cluster.py:36 assets/models/group.py:34 +#: xpack/plugins/cloud/providers/nutanix.py:30 msgid "Default" msgstr "默认" @@ -693,7 +694,7 @@ msgstr "ssh私钥" #: users/templates/users/user_asset_permission.html:41 #: users/templates/users/user_asset_permission.html:73 #: users/templates/users/user_asset_permission.html:158 -#: xpack/plugins/cloud/models.py:89 xpack/plugins/cloud/serializers.py:138 +#: xpack/plugins/cloud/models.py:89 xpack/plugins/cloud/serializers.py:147 msgid "Node" msgstr "节点" @@ -1140,7 +1141,7 @@ msgstr "启用" msgid "-" msgstr "" -#: audits/models.py:96 xpack/plugins/cloud/const.py:23 +#: audits/models.py:96 xpack/plugins/cloud/const.py:25 msgid "Failed" msgstr "失败" @@ -1387,11 +1388,11 @@ msgstr "来源 IP 不被允许登录" msgid "SSO auth closed" msgstr "SSO 认证关闭了" -#: authentication/errors.py:244 authentication/views/login.py:232 +#: authentication/errors.py:244 authentication/views/login.py:235 msgid "Your password is too simple, please change it for security" msgstr "你的密码过于简单,为了安全,请修改" -#: authentication/errors.py:253 authentication/views/login.py:247 +#: authentication/errors.py:253 authentication/views/login.py:250 msgid "Your password has expired, please reset before logging in" msgstr "您的密码已过期,先修改再登录" @@ -1565,7 +1566,7 @@ msgstr "复制成功" msgid "Please enable cookies and try again." msgstr "设置你的浏览器支持cookie" -#: authentication/views/login.py:178 +#: authentication/views/login.py:181 msgid "" "Wait for {} confirm, You also can copy link to her/him
\n" " Don't close this page" @@ -1573,19 +1574,19 @@ msgstr "" "等待 {} 确认, 你也可以复制链接发给他/她
\n" " 不要关闭本页面" -#: authentication/views/login.py:183 +#: authentication/views/login.py:186 msgid "No ticket found" msgstr "没有发现工单" -#: authentication/views/login.py:215 +#: authentication/views/login.py:218 msgid "Logout success" msgstr "退出登录成功" -#: authentication/views/login.py:216 +#: authentication/views/login.py:219 msgid "Logout success, return login page" msgstr "退出登录成功,返回到登录页面" -#: authentication/views/login.py:231 authentication/views/login.py:246 +#: authentication/views/login.py:234 authentication/views/login.py:249 msgid "Please change your password" msgstr "请修改密码" @@ -1609,7 +1610,11 @@ msgstr "对象" #: common/drf/parsers/base.py:17 msgid "The file content overflowed (The maximum length `{}` bytes)" -msgstr "文件内容益处 (最大长度 `{}` 字节)" +msgstr "文件内容太大 (最大长度 `{}` 字节)" + +#: common/drf/parsers/base.py:146 +msgid "Parse file error: {}" +msgstr "解析文件错误: {}" #: common/exceptions.py:15 #, python-format @@ -1660,6 +1665,10 @@ msgstr "" msgid "Encrypt field using Secret Key" msgstr "" +#: common/mixins/api.py:52 +msgid "Request file format may be wrong" +msgstr "上传的文件格式错误 或 其它类型资源的文件" + #: common/mixins/models.py:33 msgid "is discard" msgstr "" @@ -2991,6 +3000,10 @@ msgstr "较高" msgid "Normal" msgstr "正常" +#: terminal/const.py:34 +msgid "Offline" +msgstr "" + #: terminal/exceptions.py:8 msgid "Bulk create not support" msgstr "不支持批量创建" @@ -3011,27 +3024,31 @@ msgstr "回放" msgid "Date end" msgstr "结束日期" -#: terminal/models/status.py:13 +#: terminal/models/status.py:18 msgid "Session Online" msgstr "在线会话" -#: terminal/models/status.py:14 -msgid "CPU Usage" -msgstr "CPU使用" +#: terminal/models/status.py:19 +msgid "CPU Load" +msgstr "CPU负载" -#: terminal/models/status.py:15 +#: terminal/models/status.py:20 msgid "Memory Used" msgstr "内存使用" -#: terminal/models/status.py:16 +#: terminal/models/status.py:21 +msgid "Disk Used" +msgstr "磁盘使用" + +#: terminal/models/status.py:22 msgid "Connections" msgstr "连接数" -#: terminal/models/status.py:17 +#: terminal/models/status.py:23 msgid "Threads" msgstr "线程数" -#: terminal/models/status.py:18 +#: terminal/models/status.py:24 msgid "Boot Time" msgstr "运行时间" @@ -3039,46 +3056,30 @@ msgstr "运行时间" msgid "Args" msgstr "参数" -#: terminal/models/terminal.py:142 +#: terminal/models/terminal.py:102 msgid "type" msgstr "类型" -#: terminal/models/terminal.py:144 +#: terminal/models/terminal.py:104 msgid "Remote Address" msgstr "远端地址" -#: terminal/models/terminal.py:145 +#: terminal/models/terminal.py:105 msgid "SSH Port" msgstr "SSH端口" -#: terminal/models/terminal.py:146 +#: terminal/models/terminal.py:106 msgid "HTTP Port" msgstr "HTTP端口" -#: terminal/models/terminal.py:147 +#: terminal/models/terminal.py:107 msgid "Command storage" msgstr "命令存储" -#: terminal/models/terminal.py:148 +#: terminal/models/terminal.py:108 msgid "Replay storage" msgstr "录像存储" -#: terminal/serializers/components.py:9 -msgid "System cpu load (1 minutes)" -msgstr "系统CPU负载 (1分钟)" - -#: terminal/serializers/components.py:12 -msgid "System memory used percent" -msgstr "系统内存使用百分比" - -#: terminal/serializers/components.py:15 -msgid "System disk used percent" -msgstr "系统磁盘使用百分比" - -#: terminal/serializers/components.py:19 -msgid "Session active count" -msgstr "活跃会话数量" - #: terminal/serializers/session.py:30 msgid "User ID" msgstr "用户 ID" @@ -3164,18 +3165,22 @@ msgstr "索引" msgid "Doc type" msgstr "文档类型" -#: terminal/serializers/terminal.py:47 terminal/serializers/terminal.py:55 +#: terminal/serializers/storage.py:185 +msgid "Ignore Certificate Verification" +msgstr "" + +#: terminal/serializers/terminal.py:66 terminal/serializers/terminal.py:74 msgid "Not found" msgstr "没有发现" -#: terminal/utils.py:79 +#: terminal/utils.py:78 #, python-format msgid "" "Insecure Command Alert: [%(name)s->%(login_from)s@%(remote_addr)s] $" "%(command)s" msgstr "危险命令告警: [%(name)s->%(login_from)s@%(remote_addr)s] $%(command)s" -#: terminal/utils.py:87 +#: terminal/utils.py:86 #, python-format msgid "" "\n" @@ -3204,12 +3209,12 @@ msgstr "" "
\n" " " -#: terminal/utils.py:114 +#: terminal/utils.py:113 #, python-format msgid "Insecure Web Command Execution Alert: [%(name)s]" msgstr "Web页面-> 命令执行 告警: [%(name)s]" -#: terminal/utils.py:122 +#: terminal/utils.py:121 #, python-format msgid "" "\n" @@ -3788,7 +3793,7 @@ msgstr "安全令牌验证" #: users/templates/users/_base_otp.html:14 users/templates/users/_user.html:13 #: users/templates/users/user_profile_update.html:55 -#: xpack/plugins/cloud/models.py:78 xpack/plugins/cloud/serializers.py:136 +#: xpack/plugins/cloud/models.py:78 xpack/plugins/cloud/serializers.py:145 msgid "Account" msgstr "账户" @@ -4778,42 +4783,50 @@ msgid "Azure (China)" msgstr "Azure (中国)" #: xpack/plugins/cloud/const.py:12 +msgid "Azure (International)" +msgstr "Azure (国际)" + +#: xpack/plugins/cloud/const.py:13 msgid "Huawei Cloud" msgstr "华为云" -#: xpack/plugins/cloud/const.py:13 +#: xpack/plugins/cloud/const.py:14 msgid "Tencent Cloud" msgstr "腾讯云" -#: xpack/plugins/cloud/const.py:14 +#: xpack/plugins/cloud/const.py:15 msgid "VMware" msgstr "" -#: xpack/plugins/cloud/const.py:18 +#: xpack/plugins/cloud/const.py:16 xpack/plugins/cloud/providers/nutanix.py:13 +msgid "Nutanix" +msgstr "" + +#: xpack/plugins/cloud/const.py:20 msgid "Instance name" msgstr "实例名称" -#: xpack/plugins/cloud/const.py:19 +#: xpack/plugins/cloud/const.py:21 msgid "Instance name and Partial IP" msgstr "实例名称和部分IP" -#: xpack/plugins/cloud/const.py:24 +#: xpack/plugins/cloud/const.py:26 msgid "Succeed" msgstr "成功" -#: xpack/plugins/cloud/const.py:28 +#: xpack/plugins/cloud/const.py:30 msgid "Unsync" msgstr "未同步" -#: xpack/plugins/cloud/const.py:29 +#: xpack/plugins/cloud/const.py:31 msgid "New Sync" msgstr "新同步" -#: xpack/plugins/cloud/const.py:30 +#: xpack/plugins/cloud/const.py:32 msgid "Synced" msgstr "已同步" -#: xpack/plugins/cloud/const.py:31 +#: xpack/plugins/cloud/const.py:33 msgid "Released" msgstr "已释放" @@ -4829,7 +4842,7 @@ msgstr "云服务商" msgid "Cloud account" msgstr "云账号" -#: xpack/plugins/cloud/models.py:81 xpack/plugins/cloud/serializers.py:117 +#: xpack/plugins/cloud/models.py:81 xpack/plugins/cloud/serializers.py:126 msgid "Regions" msgstr "地域" @@ -4837,7 +4850,7 @@ msgstr "地域" msgid "Hostname strategy" msgstr "主机名策略" -#: xpack/plugins/cloud/models.py:95 xpack/plugins/cloud/serializers.py:140 +#: xpack/plugins/cloud/models.py:95 xpack/plugins/cloud/serializers.py:149 msgid "Always update" msgstr "总是更新" @@ -5029,15 +5042,15 @@ msgstr "" msgid "Subscription ID" msgstr "" -#: xpack/plugins/cloud/serializers.py:115 +#: xpack/plugins/cloud/serializers.py:124 msgid "History count" msgstr "执行次数" -#: xpack/plugins/cloud/serializers.py:116 +#: xpack/plugins/cloud/serializers.py:125 msgid "Instance count" msgstr "实例个数" -#: xpack/plugins/cloud/serializers.py:139 +#: xpack/plugins/cloud/serializers.py:148 #: xpack/plugins/gathered_user/serializers.py:20 msgid "Periodic display" msgstr "定时执行" @@ -5130,8 +5143,20 @@ msgstr "旗舰版" msgid "Community edition" msgstr "社区版" -#~ msgid "Azure (International)" -#~ msgstr "Azure (国际)" +#~ msgid "CPU Usage" +#~ msgstr "CPU使用" + +#~ msgid "System cpu load (1 minutes)" +#~ msgstr "系统CPU负载 (1分钟)" + +#~ msgid "System memory used percent" +#~ msgstr "系统内存使用百分比" + +#~ msgid "System disk used percent" +#~ msgstr "系统磁盘使用百分比" + +#~ msgid "Session active count" +#~ msgstr "活跃会话数量" #~ msgid "Root organization only allow view and delete" #~ msgstr "全局组织仅支持 查看和删除" From 1ac8537a3472d8e5b880a0058a2988d430d63c4e Mon Sep 17 00:00:00 2001 From: liuboF2c <30886198+liuboF2c@users.noreply.github.com> Date: Thu, 8 Apr 2021 13:55:58 +0800 Subject: [PATCH 31/36] =?UTF-8?q?feat=EF=BC=9A=E6=94=AF=E6=8C=81=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E5=85=A8=E5=B1=80=E7=BB=84=E7=BB=87=E7=9A=84=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E5=90=8D=E7=A7=B0=20(#5919)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: liubo --- apps/jumpserver/conf.py | 1 + apps/jumpserver/settings/custom.py | 3 +++ apps/locale/zh/LC_MESSAGES/django.mo | Bin 73503 -> 73666 bytes apps/locale/zh/LC_MESSAGES/django.po | 8 ++++++++ apps/orgs/models.py | 4 ++-- apps/settings/serializers/settings.py | 4 ++++ 6 files changed, 18 insertions(+), 2 deletions(-) diff --git a/apps/jumpserver/conf.py b/apps/jumpserver/conf.py index f4a1c6641..d09a81bd2 100644 --- a/apps/jumpserver/conf.py +++ b/apps/jumpserver/conf.py @@ -143,6 +143,7 @@ class Config(dict): 'REDIS_DB_SESSION': 5, 'REDIS_DB_WS': 6, + 'GLOBAL_ORG_DISPLAY_NAME': '', 'SITE_URL': 'http://localhost:8080', 'CAPTCHA_TEST_MODE': None, 'TOKEN_EXPIRATION': 3600 * 24, diff --git a/apps/jumpserver/settings/custom.py b/apps/jumpserver/settings/custom.py index 394c8707a..89b8d6d53 100644 --- a/apps/jumpserver/settings/custom.py +++ b/apps/jumpserver/settings/custom.py @@ -120,3 +120,6 @@ CONNECTION_TOKEN_ENABLED = CONFIG.CONNECTION_TOKEN_ENABLED DISK_CHECK_ENABLED = CONFIG.DISK_CHECK_ENABLED FORGOT_PASSWORD_URL = CONFIG.FORGOT_PASSWORD_URL + +# 自定义默认组织名 +GLOBAL_ORG_DISPLAY_NAME = CONFIG.GLOBAL_ORG_DISPLAY_NAME diff --git a/apps/locale/zh/LC_MESSAGES/django.mo b/apps/locale/zh/LC_MESSAGES/django.mo index b8d77bd5756a5c2893b9c951e17a430682c60d5e..2864b472f044cd880033eefde5ae81c4dbfab2be 100644 GIT binary patch delta 22151 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 delta 22028 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 Nz24F|iC_1S{|Bw Date: Thu, 8 Apr 2021 14:18:53 +0800 Subject: [PATCH 32/36] =?UTF-8?q?style:=20=E4=BC=98=E5=8C=96=E5=85=A8?= =?UTF-8?q?=E5=B1=80=E7=BB=84=E7=BB=87=E8=AE=BE=E7=BD=AE=E7=9B=B8=E5=85=B3?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=20(#5921)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat:支持配置全局组织的显示名称 * style: 优化全局组织设置相关代码 Co-authored-by: liubo --- apps/orgs/models.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/orgs/models.py b/apps/orgs/models.py index 310bc709a..72f47e1e3 100644 --- a/apps/orgs/models.py +++ b/apps/orgs/models.py @@ -193,7 +193,8 @@ class Organization(models.Model): @classmethod def root(cls): - return cls(id=cls.ROOT_ID, name=settings.GLOBAL_ORG_DISPLAY_NAME if settings.GLOBAL_ORG_DISPLAY_NAME else cls.ROOT_NAME) + name = settings.GLOBAL_ORG_DISPLAY_NAME or cls.ROOT_NAME + return cls(id=cls.ROOT_ID, name=name) def is_root(self): return self.id == self.ROOT_ID From 632ea87f077e08a85c1086ba2376d9fda3f7e5e3 Mon Sep 17 00:00:00 2001 From: xinwen Date: Thu, 8 Apr 2021 12:47:49 +0800 Subject: [PATCH 33/36] =?UTF-8?q?feat:=20MFA=20=E7=99=BB=E5=BD=95=E6=AC=A1?= =?UTF-8?q?=E6=95=B0=E9=99=90=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/authentication/api/mfa.py | 2 +- apps/authentication/errors.py | 47 ++++++++++---- apps/authentication/mixins.py | 35 ++++++++--- apps/authentication/views/mfa.py | 4 +- apps/users/api/user.py | 10 +-- apps/users/models/user.py | 11 ++-- apps/users/utils.py | 104 ++++++++++++++++++++----------- 7 files changed, 143 insertions(+), 70 deletions(-) diff --git a/apps/authentication/api/mfa.py b/apps/authentication/api/mfa.py index f95593bbe..ac0ba66f4 100644 --- a/apps/authentication/api/mfa.py +++ b/apps/authentication/api/mfa.py @@ -29,7 +29,7 @@ class MFAChallengeApi(AuthMixin, CreateAPIView): if not valid: self.request.session['auth_mfa'] = '' raise errors.MFAFailedError( - username=user.username, request=self.request + username=user.username, request=self.request, ip=self.get_request_ip() ) else: self.request.session['auth_mfa'] = '1' diff --git a/apps/authentication/errors.py b/apps/authentication/errors.py index 576a4e4b1..06631742a 100644 --- a/apps/authentication/errors.py +++ b/apps/authentication/errors.py @@ -6,9 +6,7 @@ from django.conf import settings from common.exceptions import JMSException from .signals import post_auth_failed -from users.utils import ( - increase_login_failed_count, get_login_failed_count -) +from users.utils import LoginBlockUtil, MFABlockUtils reason_password_failed = 'password_failed' reason_password_decrypt_failed = 'password_decrypt_failed' @@ -52,7 +50,15 @@ block_login_msg = _( "The account has been locked " "(please contact admin to unlock it or try again after {} minutes)" ) -mfa_failed_msg = _("MFA code invalid, or ntp sync server time") +block_mfa_msg = _( + "The account has been locked " + "(please contact admin to unlock it or try again after {} minutes)" +) +mfa_failed_msg = _( + "MFA code invalid, or ntp sync server time, " + "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") @@ -80,7 +86,7 @@ class AuthFailedNeedBlockMixin: def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - increase_login_failed_count(self.username, self.ip) + LoginBlockUtil(self.username, self.ip).incr_failed_count() class AuthFailedError(Exception): @@ -107,13 +113,12 @@ class AuthFailedError(Exception): class CredentialError(AuthFailedNeedLogMixin, AuthFailedNeedBlockMixin, AuthFailedError): def __init__(self, error, username, ip, request): super().__init__(error=error, username=username, ip=ip, request=request) - times_up = settings.SECURITY_LOGIN_LIMIT_COUNT - times_failed = get_login_failed_count(username, ip) - times_try = int(times_up) - int(times_failed) + util = LoginBlockUtil(username, ip) + times_remainder = util.get_remainder_times() block_time = settings.SECURITY_LOGIN_LIMIT_TIME default_msg = invalid_login_msg.format( - times_try=times_try, block_time=block_time + times_try=times_remainder, block_time=block_time ) if error == reason_password_failed: self.msg = default_msg @@ -123,12 +128,32 @@ class CredentialError(AuthFailedNeedLogMixin, AuthFailedNeedBlockMixin, AuthFail class MFAFailedError(AuthFailedNeedLogMixin, AuthFailedError): error = reason_mfa_failed - msg = mfa_failed_msg + msg: str - def __init__(self, username, request): + def __init__(self, username, request, ip): + util = MFABlockUtils(username, ip) + util.incr_failed_count() + + times_remainder = util.get_remainder_times() + block_time = settings.SECURITY_LOGIN_LIMIT_TIME + + if times_remainder: + self.msg = mfa_failed_msg.format( + 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): + error = 'block_mfa' + + def __init__(self, username, request, ip): + self.msg = block_mfa_msg.format(settings.SECURITY_LOGIN_LIMIT_TIME) + super().__init__(username=username, request=request, ip=ip) + + class MFAUnsetError(AuthFailedNeedLogMixin, AuthFailedError): error = reason_mfa_unset msg = mfa_unset_msg diff --git a/apps/authentication/mixins.py b/apps/authentication/mixins.py index 747127ca9..e13a88c87 100644 --- a/apps/authentication/mixins.py +++ b/apps/authentication/mixins.py @@ -15,9 +15,7 @@ from django.shortcuts import reverse from common.utils import get_object_or_none, get_request_ip, get_logger, bulk_get from users.models import User -from users.utils import ( - is_block_login, clean_failed_count -) +from users.utils import LoginBlockUtil, MFABlockUtils from . import errors from .utils import rsa_decrypt from .signals import post_auth_success, post_auth_failed @@ -117,7 +115,7 @@ class AuthMixin: else: username = self.request.POST.get("username") ip = self.get_request_ip() - if is_block_login(username, ip): + if LoginBlockUtil(username, ip).is_block(): logger.warn('Ip was blocked' + ': ' + username + ':' + ip) exception = errors.BlockLoginError(username=username, ip=ip) if raise_exception: @@ -197,7 +195,7 @@ class AuthMixin: self._check_password_require_reset_or_not(user) self._check_passwd_is_too_simple(user, password) - clean_failed_count(username, ip) + LoginBlockUtil(username, ip).clean_failed_count() request.session['auth_password'] = 1 request.session['user_id'] = str(user.id) request.session['auto_login'] = auto_login @@ -253,15 +251,34 @@ class AuthMixin: raise errors.MFAUnsetError(user, self.request, url) raise errors.MFARequiredError() + def mark_mfa_ok(self): + self.request.session['auth_mfa'] = 1 + self.request.session['auth_mfa_time'] = time.time() + self.request.session['auth_mfa_type'] = 'otp' + + def check_mfa_is_block(self, username, ip, raise_exception=True): + if MFABlockUtils(username, ip).is_block(): + 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): user = self.get_user_from_session() + ip = self.get_request_ip() + self.check_mfa_is_block(user.username, ip) ok = user.check_mfa(code) if ok: - self.request.session['auth_mfa'] = 1 - self.request.session['auth_mfa_time'] = time.time() - self.request.session['auth_mfa_type'] = 'otp' + self.mark_mfa_ok() return - raise errors.MFAFailedError(username=user.username, request=self.request) + + raise errors.MFAFailedError( + username=user.username, + request=self.request, + ip=ip + ) def get_ticket(self): from tickets.models import Ticket diff --git a/apps/authentication/views/mfa.py b/apps/authentication/views/mfa.py index bedbf9bcf..f3c2602cb 100644 --- a/apps/authentication/views/mfa.py +++ b/apps/authentication/views/mfa.py @@ -22,10 +22,12 @@ class UserLoginOtpView(mixins.AuthMixin, FormView): try: self.check_user_mfa(otp_code) return redirect_to_guard_view() - except errors.MFAFailedError as e: + except (errors.MFAFailedError, errors.BlockMFAError) as e: form.add_error('otp_code', e.msg) return super().form_invalid(form) except Exception as e: logger.error(e) + import traceback + traceback.print_exception() return redirect_to_guard_view() diff --git a/apps/users/api/user.py b/apps/users/api/user.py index 5973be849..6f39f40e5 100644 --- a/apps/users/api/user.py +++ b/apps/users/api/user.py @@ -16,7 +16,7 @@ from common.mixins import CommonApiMixin from common.utils import get_logger from orgs.utils import current_org from orgs.models import ROLE as ORG_ROLE, OrganizationMember -from users.utils import send_reset_mfa_mail +from users.utils import send_reset_mfa_mail, LoginBlockUtil, MFABlockUtils from .. import serializers from ..serializers import UserSerializer, UserRetrieveSerializer, MiniUserSerializer, InviteSerializer from .mixins import UserQuerysetMixin @@ -190,16 +190,12 @@ class UserChangePasswordApi(UserQuerysetMixin, generics.RetrieveUpdateAPIView): class UserUnblockPKApi(UserQuerysetMixin, generics.UpdateAPIView): permission_classes = (IsOrgAdmin,) serializer_class = serializers.UserSerializer - key_prefix_limit = "_LOGIN_LIMIT_{}_{}" - key_prefix_block = "_LOGIN_BLOCK_{}" def perform_update(self, serializer): user = self.get_object() username = user.username if user else '' - key_limit = self.key_prefix_limit.format(username, '*') - key_block = self.key_prefix_block.format(username) - cache.delete_pattern(key_limit) - cache.delete(key_block) + LoginBlockUtil.unblock_user(username) + MFABlockUtils.unblock_user(username) class UserResetOTPApi(UserQuerysetMixin, generics.RetrieveAPIView): diff --git a/apps/users/models/user.py b/apps/users/models/user.py index 096ac260d..52dccbeaa 100644 --- a/apps/users/models/user.py +++ b/apps/users/models/user.py @@ -669,10 +669,13 @@ class User(AuthMixin, TokenMixin, RoleMixin, MFAMixin, AbstractUser): @property def login_blocked(self): - key_prefix_block = "_LOGIN_BLOCK_{}" - key_block = key_prefix_block.format(self.username) - blocked = bool(cache.get(key_block)) - return blocked + from users.utils import LoginBlockUtil, MFABlockUtils + if LoginBlockUtil.is_user_block(self.username): + return True + if MFABlockUtils.is_user_block(self.username): + return True + + return False def delete(self, using=None, keep_parents=False): if self.pk == 1 or self.username == 'admin': diff --git a/apps/users/utils.py b/apps/users/utils.py index 960848f08..374ead56f 100644 --- a/apps/users/utils.py +++ b/apps/users/utils.py @@ -322,50 +322,80 @@ def check_password_rules(password): return bool(match_obj) -key_prefix_limit = "_LOGIN_LIMIT_{}_{}" -key_prefix_block = "_LOGIN_BLOCK_{}" +class BlockUtil: + BLOCK_KEY_TMPL: str + + def __init__(self, username): + self.block_key = self.BLOCK_KEY_TMPL.format(username) + self.key_ttl = int(settings.SECURITY_LOGIN_LIMIT_TIME) * 60 + + def block(self): + cache.set(self.block_key, True, self.key_ttl) + + def is_block(self): + return bool(cache.get(self.block_key)) -# def increase_login_failed_count(key_limit, key_block): -def increase_login_failed_count(username, ip): - key_limit = key_prefix_limit.format(username, ip) - count = cache.get(key_limit) - count = count + 1 if count else 1 +class BlockUtilBase: + LIMIT_KEY_TMPL: str + BLOCK_KEY_TMPL: str - limit_time = settings.SECURITY_LOGIN_LIMIT_TIME - cache.set(key_limit, count, int(limit_time)*60) + def __init__(self, username, ip): + self.username = username + self.ip = ip + self.limit_key = self.LIMIT_KEY_TMPL.format(username, ip) + self.block_key = self.BLOCK_KEY_TMPL.format(username) + self.key_ttl = int(settings.SECURITY_LOGIN_LIMIT_TIME) * 60 + + def get_remainder_times(self): + times_up = settings.SECURITY_LOGIN_LIMIT_COUNT + times_failed = self.get_failed_count() + times_remainder = int(times_up) - int(times_failed) + return times_remainder + + def incr_failed_count(self): + limit_key = self.limit_key + count = cache.get(limit_key, 0) + count += 1 + cache.set(limit_key, count, self.key_ttl) + + limit_count = settings.SECURITY_LOGIN_LIMIT_COUNT + if count >= limit_count: + cache.set(self.block_key, True, self.key_ttl) + + def get_failed_count(self): + count = cache.get(self.limit_key, 0) + return count + + def clean_failed_count(self): + cache.delete(self.limit_key) + cache.delete(self.block_key) + + @classmethod + def unblock_user(cls, username): + key_limit = cls.LIMIT_KEY_TMPL.format(username, '*') + key_block = cls.BLOCK_KEY_TMPL.format(username) + # Redis 尽量不要用通配 + cache.delete_pattern(key_limit) + cache.delete(key_block) + + @classmethod + def is_user_block(cls, username): + block_key = cls.BLOCK_KEY_TMPL.format(username) + return bool(cache.get(block_key)) + + def is_block(self): + return bool(cache.get(self.block_key)) -def get_login_failed_count(username, ip): - key_limit = key_prefix_limit.format(username, ip) - count = cache.get(key_limit, 0) - return count +class LoginBlockUtil(BlockUtilBase): + LIMIT_KEY_TMPL = "_LOGIN_LIMIT_{}_{}" + BLOCK_KEY_TMPL = "_LOGIN_BLOCK_{}" -def clean_failed_count(username, ip): - key_limit = key_prefix_limit.format(username, ip) - key_block = key_prefix_block.format(username) - cache.delete(key_limit) - cache.delete(key_block) - - -def is_block_login(username, ip): - count = get_login_failed_count(username, ip) - key_block = key_prefix_block.format(username) - - limit_count = settings.SECURITY_LOGIN_LIMIT_COUNT - limit_time = settings.SECURITY_LOGIN_LIMIT_TIME - - if count >= limit_count: - cache.set(key_block, 1, int(limit_time)*60) - if count and count >= limit_count: - return True - - -def is_need_unblock(key_block): - if not cache.get(key_block): - return False - return True +class MFABlockUtils(BlockUtilBase): + LIMIT_KEY_TMPL = "_MFA_LIMIT_{}_{}" + BLOCK_KEY_TMPL = "_MFA_BLOCK_{}" def construct_user_email(username, email): From d2678e2a43c4e937f68738eaaedd19dedfda6f0a Mon Sep 17 00:00:00 2001 From: xinwen Date: Thu, 8 Apr 2021 14:59:14 +0800 Subject: [PATCH 34/36] =?UTF-8?q?refactor:=20=E7=A7=BB=E5=8A=A8=20Permissi?= =?UTF-8?q?onsMixin=20=E4=BD=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/common/mixins/views.py | 18 +++++++++++++++++- apps/common/permissions.py | 15 --------------- apps/jumpserver/views/index.py | 3 ++- apps/ops/views.py | 4 ++-- apps/users/views/profile/password.py | 3 ++- apps/users/views/profile/pubkey.py | 3 ++- apps/users/views/profile/reset.py | 3 ++- 7 files changed, 27 insertions(+), 22 deletions(-) diff --git a/apps/common/mixins/views.py b/apps/common/mixins/views.py index b6685def5..13f365662 100644 --- a/apps/common/mixins/views.py +++ b/apps/common/mixins/views.py @@ -1,12 +1,14 @@ # -*- coding: utf-8 -*- # # coding: utf-8 - +from django.contrib.auth.mixins import UserPassesTestMixin from django.utils import timezone __all__ = ["DatetimeSearchMixin"] +from rest_framework import permissions + class DatetimeSearchMixin: date_format = '%Y-%m-%d' @@ -36,3 +38,17 @@ class DatetimeSearchMixin: def get(self, request, *args, **kwargs): self.get_date_range() return super().get(request, *args, **kwargs) + + +class PermissionsMixin(UserPassesTestMixin): + permission_classes = [permissions.IsAuthenticated] + + def get_permissions(self): + return self.permission_classes + + def test_func(self): + permission_classes = self.get_permissions() + for permission_class in permission_classes: + if not permission_class().has_permission(self.request, self): + return False + return True \ No newline at end of file diff --git a/apps/common/permissions.py b/apps/common/permissions.py index 65a57827d..7df83046d 100644 --- a/apps/common/permissions.py +++ b/apps/common/permissions.py @@ -2,7 +2,6 @@ # import time from rest_framework import permissions -from django.contrib.auth.mixins import UserPassesTestMixin from django.conf import settings from orgs.utils import current_org @@ -95,20 +94,6 @@ class WithBootstrapToken(permissions.BasePermission): return settings.BOOTSTRAP_TOKEN == request_bootstrap_token -class PermissionsMixin(UserPassesTestMixin): - permission_classes = [permissions.IsAuthenticated] - - def get_permissions(self): - return self.permission_classes - - def test_func(self): - permission_classes = self.get_permissions() - for permission_class in permission_classes: - if not permission_class().has_permission(self.request, self): - return False - return True - - class UserCanAnyPermCurrentOrg(permissions.BasePermission): def has_permission(self, request, view): return current_org.can_any_by(request.user) diff --git a/apps/jumpserver/views/index.py b/apps/jumpserver/views/index.py index 5050d72c0..8f974a483 100644 --- a/apps/jumpserver/views/index.py +++ b/apps/jumpserver/views/index.py @@ -1,6 +1,7 @@ from django.views.generic import TemplateView from django.shortcuts import redirect -from common.permissions import PermissionsMixin, IsValidUser +from common.permissions import IsValidUser +from common.mixins.views import PermissionsMixin __all__ = ['IndexView'] diff --git a/apps/ops/views.py b/apps/ops/views.py index 9ae2d9755..7b18b0f46 100644 --- a/apps/ops/views.py +++ b/apps/ops/views.py @@ -3,8 +3,8 @@ from django.views.generic import TemplateView from django.conf import settings -from common.permissions import PermissionsMixin, IsOrgAdmin, IsOrgAuditor - +from common.permissions import IsOrgAdmin, IsOrgAuditor +from common.mixins.views import PermissionsMixin __all__ = ['CeleryTaskLogView'] diff --git a/apps/users/views/profile/password.py b/apps/users/views/profile/password.py index 1fbbd64a7..e2cd8f8e2 100644 --- a/apps/users/views/profile/password.py +++ b/apps/users/views/profile/password.py @@ -11,9 +11,10 @@ from django.contrib.auth import logout as auth_logout from common.utils import get_logger from common.permissions import ( - PermissionsMixin, IsValidUser, + IsValidUser, UserCanUpdatePassword ) +from common.mixins.views import PermissionsMixin from ... import forms from ...models import User from ...utils import ( diff --git a/apps/users/views/profile/pubkey.py b/apps/users/views/profile/pubkey.py index 4010fd996..e2125f0cc 100644 --- a/apps/users/views/profile/pubkey.py +++ b/apps/users/views/profile/pubkey.py @@ -8,9 +8,10 @@ from django.views.generic.edit import UpdateView from common.utils import get_logger, ssh_key_gen from common.permissions import ( - PermissionsMixin, IsValidUser, + IsValidUser, UserCanUpdateSSHKey, ) +from common.mixins.views import PermissionsMixin from ... import forms from ...models import User diff --git a/apps/users/views/profile/reset.py b/apps/users/views/profile/reset.py index 8c676c756..73c34396b 100644 --- a/apps/users/views/profile/reset.py +++ b/apps/users/views/profile/reset.py @@ -13,7 +13,8 @@ from formtools.wizard.views import SessionWizardView from django.views.generic import FormView from common.utils import get_object_or_none -from common.permissions import PermissionsMixin, IsValidUser +from common.permissions import IsValidUser +from common.mixins.views import PermissionsMixin from ...models import User from ...utils import ( send_reset_password_mail, get_password_check_rules, check_password_rules, From de9c69843dbe47f6e2619cb6e26a325f18a47004 Mon Sep 17 00:00:00 2001 From: xinwen Date: Thu, 8 Apr 2021 16:51:44 +0800 Subject: [PATCH 35/36] =?UTF-8?q?fix:=20=E7=99=BB=E5=BD=95=E6=97=A5?= =?UTF-8?q?=E5=BF=97=20user=5Fagent=20=E8=BF=87=E9=95=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/audits/signals_handler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/audits/signals_handler.py b/apps/audits/signals_handler.py index c7cf24337..80ff68d1d 100644 --- a/apps/audits/signals_handler.py +++ b/apps/audits/signals_handler.py @@ -153,7 +153,7 @@ def generate_data(username, request): 'username': username, 'ip': login_ip, 'type': login_type, - 'user_agent': user_agent, + 'user_agent': user_agent[0:254], 'datetime': timezone.now(), 'backend': get_login_backend(request) } From ad3bc72dfb389f822d718ccb5e04b48cc9c266db Mon Sep 17 00:00:00 2001 From: ibuler Date: Thu, 8 Apr 2021 17:36:24 +0800 Subject: [PATCH 36/36] =?UTF-8?q?fix(terminal):=20=E4=BF=AE=E5=A4=8Dsessio?= =?UTF-8?q?n=20id=20=E9=95=BF=E5=BA=A6=E8=AF=AF=E5=86=99=E4=B8=BA=2035=20?= =?UTF-8?q?=E7=9A=84bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/terminal/serializers/terminal.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/terminal/serializers/terminal.py b/apps/terminal/serializers/terminal.py index 765634393..aee43e833 100644 --- a/apps/terminal/serializers/terminal.py +++ b/apps/terminal/serializers/terminal.py @@ -13,7 +13,7 @@ from ..models import ( class StatusSerializer(serializers.ModelSerializer): sessions = serializers.ListSerializer( - child=serializers.CharField(max_length=35), write_only=True + child=serializers.CharField(max_length=36), write_only=True ) class Meta: