Compare commits

...

263 Commits

Author SHA1 Message Date
wojiushixiaobai
f46c043db5 fix: 添加依赖 2021-09-17 00:07:43 +08:00
wojiushixiaobai
cbc1ab411b fix: 添加 pg 依赖包 2021-09-17 00:07:43 +08:00
老广
5e03af7243 Merge pull request #6903 from jumpserver/master
v2.14.0
2021-09-16 21:19:38 +08:00
老广
6def113cbd Merge pull request #6902 from jumpserver/dev
v2.14.0
2021-09-16 21:17:25 +08:00
ibuler
2dc0af2553 perf: 优化 dockerfile 2021-09-16 21:08:04 +08:00
Jiangjie.Bai
a291592e59 Merge pull request #6900 from jumpserver/dev
修改依赖包版本 jumpserver-django-oidc-rp==0.3.7.7
2021-09-16 20:15:32 +08:00
Michael Bai
6fb4c1e181 修改依赖包版本 jumpserver-django-oidc-rp==0.3.7.7 2021-09-16 19:49:42 +08:00
老广
eee093742c Merge pull request #6898 from jumpserver/dev
merge: dev to master
2021-09-16 19:35:39 +08:00
feng626
743c9bc3f1 fix: 过滤其它类型app 2021-09-16 19:30:32 +08:00
ibuler
f963c5ef9d perf: 修改翻译 2021-09-16 19:29:05 +08:00
ibuler
2c46072db2 fix: 修复serializer问题 2021-09-16 19:08:21 +08:00
xinwen
b375cd3e75 fix: otp 绑定问题 2021-09-16 19:04:19 +08:00
fit2bot
c26ca20ad8 fix: 更新OIDC配置时,将keycloak配置转换为openid (#6893)
* fix: 修改磁盘使用等key值

* fix: 更新OIDC配置时,将keycloak配置转换为openid

Co-authored-by: Michael Bai <baijiangjie@gmail.com>
2021-09-16 18:57:09 +08:00
Jiangjie.Bai
982a510213 Merge pull request #6892 from jumpserver/ibuler-patch-1
Update README.md
2021-09-16 17:46:06 +08:00
老广
5d6880f6e9 Update README.md 2021-09-16 17:45:10 +08:00
xinwen
a784a33203 fix: 去掉手动系统用户提示输入密码 2021-09-16 16:47:15 +08:00
xinwen
a452f3307f fix: 组件监控的消息 2021-09-16 16:29:56 +08:00
Michael Bai
b7a6287925 fix: 修改磁盘使用等key值 2021-09-16 16:29:19 +08:00
feng626
3cba8648cb fix: 工单日期bug 2021-09-16 16:12:42 +08:00
ibuler
ef7b2b7980 perf: 优化登录错误提示 2021-09-16 14:07:51 +08:00
xinwen
1ab247ac22 fix: 用户密码过期提醒 2021-09-16 13:43:09 +08:00
Michael Bai
ef8a027849 fix: 修改翻译,链接过期 2021-09-16 11:16:21 +08:00
Michael Bai
7890e43f5a fix: 修改网关测试可连接性报错提示信息 2021-09-16 11:00:10 +08:00
fit2bot
2030cbd19d fix: 修复authbook信号监听;添加翻译文件.mo (#6882)
Co-authored-by: Michael Bai <baijiangjie@gmail.com>
Co-authored-by: Jiangjie.Bai <32935519+BaiJiangJie@users.noreply.github.com>
2021-09-16 10:36:24 +08:00
feng626
0f7c8c2570 fix: 工单复核bug 2021-09-16 10:35:19 +08:00
Jiangjie.Bai
9b60d86ddd Merge pull request #6880 from jumpserver/dev
v2.14.0 rc3
2021-09-15 21:05:26 +08:00
ibuler
f129f99faa perf: 优化修改获取Hostname 2021-09-15 21:04:56 +08:00
fit2bot
43f30b37da fix: 用户手机号没有校验 (#6875)
Co-authored-by: xinwen <coderWen@126.com>
Co-authored-by: Jiangjie.Bai <32935519+BaiJiangJie@users.noreply.github.com>
2021-09-15 21:00:54 +08:00
xinwen
45aefa6b75 fix: 修改一些翻译 2021-09-15 20:59:50 +08:00
xinwen
b30123054b fix: xrdp 手动登录系统用户仍然不需要输入密码 2021-09-15 20:58:56 +08:00
wojiushixiaobai
b456e71ec4 perf: 更新 client 链接 2021-09-15 20:57:04 +08:00
xinwen
7560b70c4d feat: 用户详情页面显示 手机号,钉钉,企业微信,飞书 2021-09-15 19:42:24 +08:00
Michael Bai
0c96df5283 fix: 系统用户序列类返回nodes 2021-09-15 19:41:45 +08:00
fit2bot
9dda19b8d7 fix: 修改LDAP同步提示信息 (#6872)
* fix: 修改资产和特权用户发生变化时,更新资产的admin_user

* fix: 修改资产和特权用户发生变化时,更新资产的admin_user

* fix: 修改资产和特权用户发生变化时,更新资产的admin_user

* fix: 修改LDAP同步提示信息

Co-authored-by: Michael Bai <baijiangjie@gmail.com>
2021-09-15 19:16:44 +08:00
fit2bot
fbe5f9a63a fix: 修改资产和特权用户发生变化时,更新资产的admin_user (#6871)
* fix: 修改资产和特权用户发生变化时,更新资产的admin_user

* fix: 修改资产和特权用户发生变化时,更新资产的admin_user

* fix: 修改资产和特权用户发生变化时,更新资产的admin_user

Co-authored-by: Michael Bai <baijiangjie@gmail.com>
2021-09-15 19:11:19 +08:00
xinwen
1c4b4951dc fix: 一些提示 2021-09-15 18:06:57 +08:00
ibuler
8e12399058 perf: 资产序列类优化,改为只读 2021-09-15 18:05:55 +08:00
ibuler
741b96ddee perf: 优化设置中的数字大小 2021-09-15 17:22:23 +08:00
xinwen
8c3f89ee51 fix: 高危命令告警偶发 session 不存在 2021-09-15 17:21:36 +08:00
xinwen
dee45ce2e0 fix: 用户离开组织命令复核没有删除 2021-09-15 17:19:37 +08:00
ibuler
cfab30f7f7 perf: 修改 i18n 2021-09-15 17:18:17 +08:00
Michael Bai
02e9a96792 fix: 修改翻译 2021-09-15 17:09:48 +08:00
feng626
aa6dcdc65d Merge pull request #6862 from jumpserver/pr@dev@ticket_time_validation
fix: 工单失效日期小于开始日期
2021-09-15 16:44:23 +08:00
feng626
5a6b64eebd fix: 工单失效日期小于开始日期 2021-09-15 16:25:43 +08:00
feng626
2dd7867b32 Merge pull request #6860 from jumpserver/pr@dev@login_confirm
fix: 登陆复核返回后 删除工单
2021-09-15 15:42:18 +08:00
feng626
6507f0982c fix: 登陆复核返回后 删除工单 2021-09-15 15:39:34 +08:00
ibuler
c115ef7b47 perf: 优化ttl 2021-09-15 15:27:04 +08:00
xinwen
bf68ddf09e fix: 腾讯短信检测是否发送成功 2021-09-15 15:26:35 +08:00
xinwen
f3906ff998 fix: 用户导出 can_public_key_auth 没翻译 2021-09-15 15:25:52 +08:00
Michael Bai
e47ee43631 fix: 修改默认es doc_type 为 _doc 2021-09-15 15:25:09 +08:00
xinwen
d22bb2c92f fix: 重置密码连接生成多个token 2021-09-15 11:58:26 +08:00
xinwen
870dac37b9 fix: 重置密码成功的消息里有 br 标签 2021-09-15 11:10:57 +08:00
feng626
d14c5c58ff Merge pull request #6855 from jumpserver/pr@dev@ticket_notice_bug
fix: ticket_notice_bug
2021-09-14 22:33:42 +08:00
feng626
a6b40510d0 fix: ticket_notice_bug 2021-09-14 22:32:13 +08:00
feng626
f762fe73ff fix: 工单拒绝清除mfa 2021-09-14 19:36:03 +08:00
xinwen
d49d1ba055 perf: oauth2 登录不再限制只能本地用户 2021-09-14 17:40:51 +08:00
ibuler
6e3d950e23 perf: 优化迁移账号,性能提高50倍 2021-09-14 17:39:27 +08:00
Michael Bai
7939ef34b0 fix: 修复服务告警主题替换<br> 2021-09-14 17:39:06 +08:00
Michael Bai
07cd930c0e fix: 删除配置 PERIOD_TASK_ENABLED 2021-09-14 17:38:47 +08:00
Michael Bai
c14c89a758 fix: 修改CAS配置项和CAS登录失败问题 2021-09-14 16:43:57 +08:00
feng626
245367ec29 Merge pull request #6844 from jumpserver/pr@dev@ticket_bug
fix: 修复工单消息推送bug
2021-09-14 15:56:35 +08:00
feng626
c5f4ecc8cc fix: 修复工单消息推送bug 2021-09-14 15:48:22 +08:00
ibuler
eb8bdf8623 perf: 添加安全说明 2021-09-14 14:47:40 +08:00
Michael Bai
a26c5a5e32 fix: 修复设置未分组节点显示单独授权资产的配置时,用户授权树没有变化的问题 2021-09-14 14:46:51 +08:00
Michael Bai
068db6d1ca fix: 修复设置未分组节点显示单独授权资产的配置时,用户授权树没有变化的问题 2021-09-14 14:46:51 +08:00
Michael Bai
e6e2a35745 fix: 修复设置未分组节点显示单独授权资产的配置时,用户授权树没有变化的问题 2021-09-14 14:46:51 +08:00
feng626
fafc2791ab Merge pull request #6841 from jumpserver/pr@dev@ticket_bug
fix: 工单不同组织取相应资产的名字
2021-09-14 14:10:09 +08:00
feng626
39507ef152 fix: 工单不同组织取相应资产的名字 2021-09-14 14:03:28 +08:00
Jiangjie.Bai
683fb9f596 Merge pull request #6838 from jumpserver/dev
v2.14.0 rc2
2021-09-14 00:05:35 +08:00
Bai
ced9e53d62 fix: 修复 debug 日志信息 2021-09-14 00:04:17 +08:00
Jiangjie.Bai
93846234f8 Merge pull request #6834 from jumpserver/dev
v2.14.0 rc2
2021-09-13 21:27:15 +08:00
ibuler
8ac7d4b682 perf: 优化登录backends 2021-09-13 21:26:45 +08:00
Michael Bai
c4890f66e1 fix: i18n 2021-09-13 20:45:34 +08:00
Michael Bai
4618989813 fix: i18n 2021-09-13 20:39:18 +08:00
xinwen
29645768a0 fix: 登录复核没有日志 2021-09-13 20:39:18 +08:00
Michael Bai
8f1c934f73 fix: 授权规则支持通过 from_ticket 工单过滤 2021-09-13 20:24:27 +08:00
fit2bot
7a45f4d129 perf: 优化短信 (#6826)
* perf: 优化短信

* refactor: 适配新的短信模板配置

Co-authored-by: ibuler <ibuler@qq.com>
Co-authored-by: xinwen <coderWen@126.com>
2021-09-13 20:15:59 +08:00
feng626
55a5dd1e34 fix: ticket bug 2021-09-13 20:12:34 +08:00
xinwen
6695d0a8a2 fix: 用户不能禁用或启用自己 2021-09-13 19:52:30 +08:00
Michael Bai
84d6b3de26 fix: 修改测试网关端口错误时提示信息 2021-09-13 19:06:25 +08:00
feng626
17a5e919d5 fix: ticket bug 2021-09-13 19:05:41 +08:00
feng626
3ba07867c8 Merge pull request #6824 from jumpserver/pr@dev@fix_system_msg
feat: 工单多种审批
2021-09-13 18:59:03 +08:00
feng626
75b76170f9 feat: 工单多种审批 2021-09-13 18:54:02 +08:00
xinwen
d34c7edb00 refactor: 重构消息 2021-09-13 18:52:02 +08:00
feng626
f64740c2db fix: 修改翻译及建议接口 2021-09-13 18:04:01 +08:00
Michael Bai
3a09845c29 fix: 修复终端启动时更新remote_addr字段 2021-09-13 18:03:06 +08:00
xinwen
09d51fd5be fix: OTP_IN_RADIUS 与 mfa 冲突 2021-09-13 17:43:19 +08:00
Michael Bai
fc8181b5ed fix: 修复删除关联了资产的特权用户失败的问题 2021-09-13 17:33:27 +08:00
feng626
5a993c255d Merge pull request #6818 from jumpserver/pr@dev@fix_ticket_bug
fix: 修复工单与koko相关接口bug
2021-09-13 16:26:45 +08:00
feng626
ad592fa504 fix: 修复工单与koko相关接口bug 2021-09-13 16:23:58 +08:00
feng626
1dcc8ff0a3 Merge pull request #6816 from jumpserver/pr@dev@perm_user_node_tree_bug
fix: 修复用户详情获取子节点bug
2021-09-13 15:30:49 +08:00
feng626
11a9a49bf8 fix: 修复用户详情获取子节点bug 2021-09-13 15:23:12 +08:00
Michael Bai
b9ffc23066 fix: 修复应用账号不能通过用户名过滤的问题 2021-09-13 15:09:13 +08:00
feng626
ea4dccbab8 fix: 修复工单bug 2021-09-13 14:12:33 +08:00
Michael Bai
683461a49b 修改 FORGOT_PASSWORD_URL 允许为空 2021-09-13 14:02:46 +08:00
Michael Bai
1a1ad0f1a2 perf: 取消API Token的配置 2021-09-13 14:02:46 +08:00
xinwen
773f7048be fix: 通知暂不支持短信 2021-09-13 13:30:54 +08:00
xinwen
f8f783745c fix: 修改密码没有发通知 2021-09-13 13:30:54 +08:00
xinwen
4fe715d953 fix: 首次登录不能提交 2021-09-13 13:29:07 +08:00
Michael Bai
36dfc4bcb8 fix: i18n 2021-09-10 19:02:38 +08:00
xinwen
8925314dc7 refactor: 修改多对多关系日志样式 2021-09-10 19:02:38 +08:00
ibuler
817c02c667 fix: 修复is_app bug 2021-09-10 18:02:07 +08:00
xinwen
58a10778cd fix: 修复短信问题 2021-09-10 18:01:36 +08:00
Michael Bai
fa81652de5 perf: 修改授权中(资产/应用)系统用户字段不必填 2021-09-10 17:55:08 +08:00
fit2bot
7e6fa27719 perf: 优化通知 (#6798)
* perf: 优化通知

* perf: 优化危险命令提示

* fix: i18n

* fix: i18n

Co-authored-by: ibuler <ibuler@qq.com>
Co-authored-by: Michael Bai <baijiangjie@gmail.com>
2021-09-10 17:54:34 +08:00
xinwen
3e737c8cb8 fix: 修复短信问题 2021-09-10 16:43:52 +08:00
feng626
345c0fcf4f fix: 修复工单流bug 添加登陆审核配置 2021-09-10 16:22:30 +08:00
Michael Bai
bf6b685e8c fix: api/health/健康检测添加localhost 2021-09-10 15:11:25 +08:00
xinwen
654ec4970e 更改设置接口 2021-09-10 13:52:51 +08:00
feng626
4a436856b4 feat: sms setting 2021-09-10 13:52:51 +08:00
Jiangjie.Bai
e993e7257c Merge pull request #6795 from jumpserver/dev
v2.14.0 rc1
2021-09-09 21:18:32 +08:00
Michael Bai
f12a59da2f fix: 修改翻译 2021-09-09 21:14:44 +08:00
Michael Bai
42c3c85863 fix: 修改翻译 2021-09-09 21:14:44 +08:00
xinwen
7e638ff8de fix: 翻译 2021-09-09 20:49:56 +08:00
Michael Bai
932a65b840 perf: 修复批量更新组织失败的问题 2021-09-09 20:49:30 +08:00
fit2bot
81000953e2 fix: 修改消息订阅 (#6789)
Co-authored-by: xinwen <coderWen@126.com>
2021-09-09 20:12:52 +08:00
fit2bot
dc742d1281 perf: 提交禁用xrdp的开关 (#6788)
* perf: 提交禁用xrdp的开关

* perf: 修复换行

Co-authored-by: ibuler <ibuler@qq.com>
2021-09-09 19:18:37 +08:00
xinwen
b1fceca8a6 feat: 添加短信服务和用户消息通知 2021-09-09 16:47:24 +08:00
ibuler
d49d1e1414 perf: 修改添加downlaod 2021-09-09 16:31:32 +08:00
xinwen
dac3f7fc71 refactor: 修改 xrdp 挂载磁盘参数名称 2021-09-09 16:31:03 +08:00
Jiangjie.Bai
47989c41a3 perf: Update README 2021-09-09 16:17:46 +08:00
Michael Bai
ca34216141 perf: 优化Core/Celery注册终端名称 2021-09-09 16:17:23 +08:00
fit2cloud-jiangweidong
905014d441 feat: 改密计划支持数据库改密 (#6709)
* feat: 改密计划支持数据库改密

* fix: 将数据库账户信息不保存在资产信息里,保存到自己的存储中

* perf: 早餐村

* perf: 修改account

* perf: 修改app和系统用户

* perf: 优化系统用户和应用关系

* fix: 修复oracle不可连接问题

Co-authored-by: ibuler <ibuler@qq.com>
Co-authored-by: feng626 <1304903146@qq.com>
Co-authored-by: Michael Bai <baijiangjie@gmail.com>
2021-09-09 16:04:54 +08:00
feng626
3e51f4d616 fix: 修复授权500 2021-09-09 14:43:03 +08:00
fit2bot
07179a4d22 feat: 页面配置serializer版 (#6750)
* feat: 页面配置serializer版

* perf: 优化配置

* perf: 优化设置

* perf: 优化设置

* perf: 优化配置页面

* perf: 基本完成设置优化

Co-authored-by: feng626 <1304903146@qq.com>
Co-authored-by: ibuler <ibuler@qq.com>
2021-09-09 14:00:50 +08:00
Michael Bai
7a2e93c087 perf: 优化终端注册时名称长度处理逻辑 2021-09-09 13:27:27 +08:00
xinwen
3fb368c741 fix: 修复 xrdp domain 不生效 2021-09-08 17:54:03 +08:00
fit2bot
fca3a8fbca perf: 绑定MFA认证密码时对密码进行加密传输 (#6776)
* perf: 绑定MFA认证密码时对密码进行加密传输

* perf: 绑定MFA认证密码时对密码进行加密传输

Co-authored-by: Michael Bai <baijiangjie@gmail.com>
2021-09-08 16:40:09 +08:00
xinwen
c1375ed7cb feat: xrdp 支持挂载本地磁盘(仅适用于 win) 2021-09-07 19:10:27 +08:00
Michael Bai
8b483b8c36 fix: 终端获取配置API返回SECURITY_SESSION_SHARE 2021-09-07 19:07:50 +08:00
Jiangjie.Bai
c465fccc33 feat: 支持 Session 会话共享 (#6768)
* feat: 添加 SECURITY_SESSION_SHARE 配置

* feat: 添加 SessionShare / ShareJoinRecord Model

* feat: 添加 SessionShare / ShareJoinRecord Model

* feat: 添加 SessionSharing / SessionJoinRecord Model

* feat: 添加 SessionSharing API

* feat: 添加 SessionJoinRecord API

* feat: 修改迁移文件

* feat: 修改迁移文件

* feat: 修改迁移文件

* feat: 修改API权限
2021-09-07 18:16:27 +08:00
ibuler
3d934dc7c0 perf: 优化迁移 2021-09-06 19:57:37 +08:00
feng626
b69ed8cbe9 Merge pull request #6767 from jumpserver/pr@dev@keep_only_flow_app_asset
perf: 只保留app asset flow
2021-09-06 18:45:50 +08:00
feng626
c27230762b perf: 只保留app asset flow 2021-09-06 18:44:58 +08:00
Bai
7ea8205672 feat: 云管中心添加GCP 2021-09-06 18:21:15 +08:00
wojiushixiaobai
b9b55e3d67 perf: 添加常用工具 2021-09-06 18:20:30 +08:00
feng626
900fc4420c perf: 只保留app asset flow 2021-09-06 18:19:47 +08:00
feng626
0a3e5aed56 perf: 授权分类采用from_ticket字段 2021-09-06 11:03:16 +08:00
feng626
9fb6fd44d1 Merge pull request #6745 from jumpserver/pr@dev@add_authorization_rule
feat: 授权规则分类管理
2021-09-01 16:48:56 +08:00
feng626
4214b220e1 feat: 授权规则分类管理 2021-08-31 14:13:05 +08:00
feng626
ae80797ce4 fix: 修复工单迁移bug 2021-08-27 18:05:14 +08:00
feng626
d1be4a136e Merge pull request #6735 from jumpserver/pr@dev@asset_app_suggestion
feat: add app asset suggestion
2021-08-27 16:52:56 +08:00
feng626
e8e211f47c feat: add app asset suggestion 2021-08-27 16:47:36 +08:00
feng626
44044a7d99 Merge pull request #6730 from jumpserver/pr@dev@ticket_fix
perf: 优化变量名
2021-08-27 10:14:46 +08:00
feng626
5854ad1975 perf: 优化变量名 2021-08-26 18:29:02 +08:00
健健
0b1a1591f8 从 __all__ 中删除 RDPFileSerializer (#6727)
* 从 __all__ 中删除 RDPFileSerializer

RDPFileSerializer 已经被删除

Co-authored-by: Jiangjie.Bai <32935519+BaiJiangJie@users.noreply.github.com>
Co-authored-by: 老广 <ibuler@qq.com>
Co-authored-by: xinwen <coderWen@126.com>
Co-authored-by: Eric_Lee <xplzv@126.com>
2021-08-26 15:12:19 +08:00
ibuler
6241238b45 feat: sso支持验证mfa 2021-08-26 15:07:55 +08:00
fit2bot
0f87f05b3f feat: 工单多级审批 + 模版创建 (#6640)
* feat: 工单多级审批 + 模版创建

* feat: 工单权限处理

* fix: 工单关闭后 再审批bug

* perf: 修改一点

Co-authored-by: feng626 <1304903146@qq.com>
Co-authored-by: ibuler <ibuler@qq.com>
2021-08-25 19:02:50 +08:00
Jiangjie.Bai
19c63a0b19 Merge pull request #6708 from jumpserver/dev
feat:README
2021-08-20 16:52:02 +08:00
fit2bot
1fdc558ef7 feat: 修改readme (#6707)
* feat: 修改readme

* feat: 修改readme

* feat: 修改readme

* feat: 修改readme

Co-authored-by: Bai <bugatti_it@163.com>
2021-08-20 16:47:49 +08:00
ibuler
9f6e26c4db fix: 修复ws引起的redis连接增加 2021-08-20 15:23:23 +08:00
ibuler
628012a7ee perf: 修改健康监测 2021-08-20 14:33:51 +08:00
ibuler
c1579f5fe4 perf: 修改readme 2021-08-20 14:13:40 +08:00
Bai
cbe0483b46 fix: 资产mini接口返回platform、protocols 2021-08-20 14:12:59 +08:00
xinwen
10c2935df4 fix: 用户创建 500 2021-08-20 10:07:17 +08:00
Jiangjie.Bai
10e06a4533 Merge pull request #6692 from jumpserver/dev
v2.13.0
2021-08-19 18:52:25 +08:00
ibuler
98e38ebfd8 perf: 修复系统用户翻译 2021-08-19 17:26:38 +08:00
Bai
9660e20176 fix: 修复远程应用资产必填项 2021-08-19 16:28:51 +08:00
xinwen
21a7ec9fec fix: 升级时清空缓存 2021-08-19 15:45:59 +08:00
xinwen
7d123ff8c5 fix: 登录时验证码卡住 2021-08-19 15:19:01 +08:00
ibuler
2af6ac504d perf: 优化版本号 2021-08-19 11:15:10 +08:00
Jiangjie.Bai
6c8d1c4e77 Merge pull request #6680 from jumpserver/dev
v2.13.0 rc4
2021-08-18 19:50:17 +08:00
Bai
52d3e1b34b fix: 修改协议错误信息翻译 2021-08-18 19:33:50 +08:00
Bai
bf6fcc9020 fix: 修改翻译信息;添加水印配置项到页面 2021-08-18 19:14:49 +08:00
Bai
a0b756ebaa feat: 添加同步任务执行历史保留天数配置项 2021-08-18 18:30:10 +08:00
xinwen
5e8a55f949 fix: AuthBook 变动审计 2021-08-18 16:47:28 +08:00
xinwen
f9218584f4 fix: 组织统计里系统用户与特权用户数量不对 2021-08-18 16:46:01 +08:00
Bai
228446979f fix: 修改过期节点资产映射缓存清除 2021-08-18 15:37:40 +08:00
Bai
aa37d86959 fix: 将flower放到web服务中;修复账号列表过滤节点的逻辑,获取所有子节点。 2021-08-18 15:36:13 +08:00
feng626
0e9079fa2e Merge pull request #6673 from jumpserver/pr@dev@user_role_search
fix: 用户角色搜索
2021-08-18 15:26:56 +08:00
feng626
58c058c1a5 fix: 修复user筛选用户bug 2021-08-18 15:25:35 +08:00
ibuler
f390556a87 perf: 优化spm请求的问题 2021-08-18 14:39:55 +08:00
Bai
b7378da46e fix: 修复创建/更新远程应用资产可以为空的问题 2021-08-18 11:28:28 +08:00
Bai
0c8c926aac fix: 修复校验用户登录规则的API权限 2021-08-18 10:31:42 +08:00
xinwen
81d8592ee1 fix: 系统用户的账号列表里修改密码后不能登录 2021-08-18 10:22:02 +08:00
Jiangjie.Bai
af827f3626 Merge pull request #6664 from jumpserver/dev
v2.13.0 rc3
2021-08-17 20:37:03 +08:00
ibuler
91b269fc36 fix: 修复应用账号 2021-08-17 20:31:19 +08:00
ibuler
1605a57df6 perf: 修复应用账号选择部分导出问题 2021-08-17 17:33:55 +08:00
ibuler
5cd23b843a fix: 修复授权树api数据结构 2021-08-17 17:31:45 +08:00
Bai
d46f1080f8 fix: 修复校验用户密码规则 2021-08-17 16:37:38 +08:00
feng626
9a541ebf05 fix: 资产批量推送和测试连接性 bug 2021-08-17 14:05:03 +08:00
ibuler
dba416f5eb fix: 修复remote app没有返回asset的bug 2021-08-17 11:29:02 +08:00
xinwen
7d7da9bf98 fix: 添加新的 es 时创建索引 2021-08-17 11:27:57 +08:00
老广
4425efd3c2 Merge pull request #6649 from jumpserver/dev
Dev
2021-08-17 10:17:49 +08:00
feng626
c6bb9e97fb fix: 工单已关闭 再审批bug 2021-08-16 19:24:42 +08:00
xinwen
9c7adb7a14 fix: 用户列表导出部分字段没翻译 2021-08-16 17:41:56 +08:00
xinwen
7b4faccf05 fix: 飞书没有翻译 2021-08-16 16:46:20 +08:00
xinwen
0cd3419e09 fix: 修复应用授权列表报错 2021-08-16 16:45:45 +08:00
Eric_Lee
e49dedf6b1 Merge pull request #6645 from jumpserver/dev
Dev
2021-08-16 11:25:24 +08:00
ibuler
bee4e05b5f fix: 修复应用账号中没有应用却有账号的问题 2021-08-16 11:23:15 +08:00
ibuler
a5419b49ee perf: 添加监测 celery 2021-08-16 09:51:48 +08:00
ibuler
84e60283b8 fix(account): 修复应用账号前端不对的问题 2021-08-16 09:51:22 +08:00
Jiangjie.Bai
96206384c0 Merge pull request #6634 from jumpserver/dev
v2.13.0 rc1
2021-08-12 19:51:48 +08:00
Bai
78c61d5afa feat: 升级依赖 jms-storage==0.0.39 2021-08-12 18:38:10 +08:00
Bai
ee712d9a9d feat: 升级依赖 s3transfer==0.5.0 2021-08-12 18:38:10 +08:00
ibuler
a1e8c2849a perf: 修改entrypoint 2021-08-12 17:55:03 +08:00
fit2bot
54751a715c feat: 添加 飞书 (#6602)
* feat: 添加 飞书

Co-authored-by: xinwen <coderWen@126.com>
Co-authored-by: wenyann <64353056+wenyann@users.noreply.github.com>
2021-08-12 16:44:06 +08:00
fit2bot
a2907a6e6d fix: 将 es 的 doc_type 默认值改为 _doc (#6627)
* fix: 无效的 es 报 500

* fix: 修复索引不存在时报错

* fix: 将 es 的 doc_type 默认值改为 _doc

Co-authored-by: xinwen <coderWen@126.com>
2021-08-12 15:37:22 +08:00
Bai
33236aaa47 fix: 修复启动脚本beat进程偶尔不会结束的问题 2021-08-12 15:35:22 +08:00
Bai
cd6c7ce7fa perf: jms脚本添加collectstatic命令 2021-08-11 14:28:39 +08:00
Bai
363baece4f fix: 修复删除远程应用关联资产后,更新页面显示资产ID的问题 2021-08-11 14:23:47 +08:00
feng626
1db0e28346 feat: 用户管理增加角色搜素 2021-08-11 10:36:43 +08:00
xinwen
7366bbb197 fix: 修复 es 命令存储过滤不准确 2021-08-10 17:25:41 +08:00
Bai
7959f84bba fix: 修改启动脚本 2021-08-10 14:45:58 +08:00
ibuler
0c96bf61ef chore: 添加注释 2021-08-09 10:22:16 +08:00
fit2bot
39ce60c93a feat: 系统监控添加 Core/Celery Terminal; 修改检测终端状态逻辑; (#6570)
* feat: 系统监控添加 Core Terminal; 修改检测终端状态逻辑;

* feat: 添加management包

* feat: 添加management包

* feat: 添加 start 模块

* feat: 修改 start 模块

* feat: 修改启动命令目录结构

* feat: 修改启动命令目录结构

* feat: 修改启动命令目录结构

* feat: 修改启动命令目录结构

* feat: 修改启动命令目录结构

* feat: 修改启动命令目录结构

* feat: 修改启动命令目录结构

* feat: 修改启动脚本

* feat: 修改启动脚本

* feat: 修改启动脚本

* feat: 修改启动脚本

* feat: 修改启动脚本

* feat: 修改启动脚本

* feat: 修改启动脚本

* feat: 修改启动脚本

* feat: 修改启动脚本

* feat: 修改启动脚本

Co-authored-by: Bai <bugatti_it@163.com>
2021-08-06 19:16:18 +08:00
Bai
8ad78ffef8 fix: 修改SECURITY_PASSWORD_MIN_LENGTH 2021-08-05 10:38:22 +08:00
xinwen
66b499b8e3 fix: 修复多对多审计内容太长报错&全局组织没有审计 2021-08-04 17:13:25 +08:00
xinwen
22406f47f7 fix: Luna 页面搜索资产,结果按资产名称排序 2021-08-04 10:32:20 +08:00
feng626
72f782b589 Merge pull request #6576 from jumpserver/pr@dev@edit_xpack_internationalization
feat: 修改xpack 国际化
2021-08-03 14:31:20 +08:00
xinwen
cf3df951a9 fix: xrdp 连接可以指定是否全屏 2021-08-03 11:28:49 +08:00
xinwen
4085df913b feat: 记录网关可连接性 2021-08-02 18:42:43 +08:00
ibuler
d93f3aca51 perf: 修改支持客户端拉起
perf: remove print
2021-08-02 18:15:11 +08:00
老广
b180a162cd Merge pull request #6580 from jumpserver/pr@dev@update_pip_version
perf: 修改依赖包版本
2021-08-02 17:59:15 +08:00
ibuler
1bf3ff5e1b perf: 修改依赖包版本 2021-08-02 17:55:22 +08:00
Bai
0def477b63 fix: 修复收集windows资产用户时未收集到全部用户的问题 2021-08-02 16:34:54 +08:00
feng626
337e1ba206 feat: 修改xpack 国际化 2021-08-02 14:19:54 +08:00
Bai
fe2d80046c fix: 修改用户密码更新API取消Retrieve权限 2021-08-02 12:00:14 +08:00
Bai
f16a9ddb86 fix: 修改 settings.SECURITY_MFA_AUTH 开启判断条件 2021-08-02 10:52:41 +08:00
老广
5f6c207721 Merge pull request #6572 from jumpserver/pr@dev@perf_account_filter
perf: 修改账号搜索
2021-07-30 19:52:08 +08:00
ibuler
988d686418 perf: 修改账号搜索 2021-07-30 19:48:23 +08:00
老广
89e654af80 Merge pull request #6571 from jumpserver/pr@dev@perf_i18n
perf: 优化i18n
2021-07-30 19:21:31 +08:00
ibuler
2ab1bbaa2c chore: merge with dev 2021-07-30 19:18:36 +08:00
ibuler
b43626b5a2 perf: 优化i18n 2021-07-30 19:13:47 +08:00
ibuler
5e4b3e924f perf: 优化树 2021-07-30 17:29:50 +08:00
fit2bot
66b0173e20 feat: 服务性能告警指标包含:Core服务和各组件状态;指标包括:cpu/disk/memory/is_alive (#6564)
* feat: 服务性能告警指标包含:Core服务和各组件状态;指标包括:cpu/disk/memory/is_alive

* feat: 服务性能告警指标包含:Core服务和各组件状态;指标包括:cpu/disk/memory/is_alive 2

Co-authored-by: Bai <bugatti_it@163.com>
2021-07-30 15:42:06 +08:00
fit2bot
67f6b1080e feat: 管理员和普通用户支持单独设置MFA和密码长度 (#6562)
* feat: 支持配置系统管理员强制MFA和独立密码长度限制

* feat: 支持配置系统管理员强制MFA和独立密码长度限制

* feat: 支持配置系统管理员强制MFA和独立密码长度限制, 翻译文件

* fix: 设置界面可设置管理员用户开启MFA,当在设置开启全局的时候,不改变用户的mfa字段状态

* fix: 修改管理员最小密码长度变量名称

* perf: 优化不同的配置

* perf: 修改check password rule

* perf: 添加配置文件

* perf: 修改profile

* perf: 优化代码

* fix: 修复bug

Co-authored-by: fit2cloud-jiangweidong <weidong.jiang@fit2cloud.com>
Co-authored-by: ibuler <ibuler@qq.com>
2021-07-30 15:19:00 +08:00
ibuler
b56b897260 perf: 优化计算数量 2021-07-30 11:08:12 +08:00
ibuler
f031f4d560 perf: 修复授权应用树的问题 2021-07-29 19:45:36 +08:00
Bai
d0e119fb50 feat: session.Task model add kwargs field (记录kill_session的用户名称) 2021-07-29 15:06:54 +08:00
Bai
7892e50aa2 feat: session.Task model add kwargs field (记录kill_session的用户名称) 2021-07-29 15:06:54 +08:00
ibuler
bff3582136 perf: 修改时间日志格式,兼容firefox 2021-07-28 17:56:23 +08:00
Bai
bdf95903ce feat: 支持终断DB会话 2021-07-28 17:48:53 +08:00
feng626
c1e6bc5d60 Merge pull request #6555 from jumpserver/pr@dev@user_collection_node_fix
fix: 用户管理-用户-授权的资产-收藏夹下拉菜单报404bug修复
2021-07-28 14:39:53 +08:00
feng626
da588ce0ae perf: 代码优化 2021-07-28 14:33:02 +08:00
feng626
d0680c3753 fix: 用户管理-用户-授权的资产-收藏夹下拉菜单报404bug修复 2021-07-28 14:21:53 +08:00
fit2bot
905d0d5131 perf: 统一应用树 (#6535)
* perf: 添加应用树api

* perf: perms tree

* perf: 统一应用树

* perf: 修改icon

* perf: stash it

* perf: 优化应用账号

* perf: 基本完成应用账号重构

* perf: 修改翻译

Co-authored-by: ibuler <ibuler@qq.com>
2021-07-27 16:06:00 +08:00
feng626
d347ed9862 perf: 优化代码 2021-07-27 15:54:44 +08:00
feng626
8611f765a3 feat: 增加rdp 云端唤醒 api 2021-07-27 15:54:44 +08:00
ibuler
962f1c0310 perf: 资产账号支持,几点过滤 2021-07-27 15:54:08 +08:00
feng626
473a66719b 终端 批量更新 数据必填 2021-07-27 15:52:59 +08:00
Bai
aeb43a04f6 feat: 添加翻译: trigger 触发模式、手动触发、自动触发 2021-07-27 10:45:28 +08:00
xinwen
49a35985a1 feat: 多对多关系添加审计 2021-07-26 18:10:41 +08:00
Bai
21b789e08c perf: 优化测试网域可连接性的错误提示信息 2021-07-26 15:53:08 +08:00
Bai
51387ad97e fix: 解决访问api-docs失败的问题 2021-07-26 14:32:53 +08:00
Bai
290d584ac9 perf: 校验系统用户/账号密码不能包含 {{ 字符;升级依赖包ansible==2.9.24 2021-07-23 19:00:43 +08:00
Bai
160b238058 fix: 修复ssh-private-key错误导致系统用户列表加载出现500的问题 2021-07-23 18:57:53 +08:00
Bai
938255df6f perf: 添加LDAPServerURL ldaps:// ldap:// 协议检测 2021-07-23 14:50:44 +08:00
Bai
4230da0fd9 perf: 添加LDAPServerURL ldaps:// ldap:// 协议检测 2021-07-23 11:24:05 +08:00
xinwen
fee3715d30 fix: 应用授权按type 过滤报错 2021-07-22 11:06:14 +08:00
Bai
689bd093be 云管中心同步任务支持设置同步IP网段和协议组(修改特权用户文案) 2021-07-21 16:50:04 +08:00
feng626
77461d7834 网域网管取消密码不为空校验 2021-07-21 16:33:09 +08:00
ibuler
ee5894c296 perf: 优化工单推荐资产的数量 2021-07-21 14:39:29 +08:00
Bai
07898004b0 feat: 丰富资产任务API创建;支持针对多个系统用户一个资产的推送和测试 2021-07-20 16:59:51 +08:00
Eric_Lee
630164cd51 Merge pull request #6494 from jumpserver/pr@dev@add_watermark
perf: 添加配置文件,控制luna水印
2021-07-19 18:28:24 +08:00
feng626
981319e553 关闭 网域网关 密码特殊字符校验 2021-07-19 18:27:11 +08:00
ibuler
fedd32ea7a merge: 合并dev 2021-07-19 18:26:04 +08:00
ibuler
e57574f10a perf: 添加配置文件,控制luna水印
perf: 添加配置文件样例
2021-07-19 18:20:51 +08:00
xinwen
3f0a0b33b5 feat: 应用按类型筛选可以指定多个类型 2021-07-19 10:11:07 +08:00
Bai
c21217d50c perf: 添加配置项 LOGIN_REDIRECT_TO_BACKEND 2021-07-16 14:34:23 +08:00
Bai
e44c8ae940 perf: 优化登录跳转flash时间间隔可配置;0表示直接跳转 2021-07-16 14:34:23 +08:00
wojiushixiaobai
1da187c373 perf: 优化 MFA 绑定提示 2021-07-16 14:33:08 +08:00
xinwen
36ad42beb2 fix: xrdp 设置分辨率不生效 2021-07-16 14:32:18 +08:00
299 changed files with 10678 additions and 4503 deletions

1
.gitignore vendored
View File

@@ -39,3 +39,4 @@ logs/*
.vagrant/
release/*
releashe
/apps/script.py

View File

@@ -22,21 +22,30 @@ COPY ./requirements/deb_buster_requirements.txt ./requirements/deb_buster_requir
RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list \
&& sed -i 's/security.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list \
&& apt update \
&& apt -y install telnet iproute2 redis-tools \
&& grep -v '^#' ./requirements/deb_buster_requirements.txt | xargs apt -y install \
&& rm -rf /var/lib/apt/lists/* \
&& localedef -c -f UTF-8 -i zh_CN zh_CN.UTF-8 \
&& cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
&& cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
&& sed -i "s@# alias l@alias l@g" ~/.bashrc \
&& echo "set mouse-=a" > ~/.vimrc
COPY ./requirements/requirements.txt ./requirements/requirements.txt
RUN pip install --upgrade pip==20.2.4 setuptools==49.6.0 wheel==0.34.2 -i ${PIP_MIRROR} \
&& pip config set global.index-url ${PIP_MIRROR} \
&& pip install --no-cache-dir $(grep 'jms' requirements/requirements.txt) -i ${PIP_JMS_MIRROR} \
&& pip install --no-cache-dir $(grep -E 'jms|jumpserver' requirements/requirements.txt) -i ${PIP_JMS_MIRROR} \
&& pip install --no-cache-dir -r requirements/requirements.txt
COPY --from=stage-build /opt/jumpserver/release/jumpserver /opt/jumpserver
RUN mkdir -p /root/.ssh/ \
&& echo "Host *\n\tStrictHostKeyChecking no\n\tUserKnownHostsFile /dev/null" > /root/.ssh/config
RUN mkdir -p /opt/jumpserver/oracle/
ADD https://download.jumpserver.org/public/instantclient-basiclite-linux.x64-21.1.0.0.0.tar /opt/jumpserver/oracle/
RUN tar xvf /opt/jumpserver/oracle/instantclient-basiclite-linux.x64-21.1.0.0.0.tar -C /opt/jumpserver/oracle/
RUN sh -c "echo /opt/jumpserver/oracle/instantclient_21_1 > /etc/ld.so.conf.d/oracle-instantclient.conf"
RUN ldconfig
RUN echo > config.yml
VOLUME /opt/jumpserver/data
VOLUME /opt/jumpserver/logs

View File

@@ -21,6 +21,7 @@ JumpServer 采纳分布式架构,支持多机房跨区域部署,支持横向
改变世界,从一点点开始 ...
> 如需进一步了解 JumpServer 开源项目,推荐阅读 [JumpServer 的初心和使命](https://mp.weixin.qq.com/s/S6q_2rP_9MwaVwyqLQnXzA)
### 特色优势
@@ -32,6 +33,19 @@ JumpServer 采纳分布式架构,支持多机房跨区域部署,支持横向
- 多租户: 一套系统,多个子公司和部门同时使用;
- 多应用支持: 数据库Windows远程应用Kubernetes。
### UI 展示
![UI展示](https://www.jumpserver.org/images/screenshot/1.png)
### 在线体验
- 环境地址:<https://demo.jumpserver.org/>
| :warning: 注意 |
| :--------------------------- |
| 该环境仅作体验目的使用,我们会定时清理、重置数据! |
| 请勿修改体验环境用户的密码! |
| 请勿在环境中添加业务生产环境地址、用户名密码等敏感信息! |
### 快速开始
@@ -45,6 +59,8 @@ JumpServer 采纳分布式架构,支持多机房跨区域部署,支持横向
- [Luna](https://github.com/jumpserver/luna) JumpServer Web Terminal 项目
- [KoKo](https://github.com/jumpserver/koko) JumpServer 字符协议 Connector 项目,替代原来 Python 版本的 [Coco](https://github.com/jumpserver/coco)
- [Lion](https://github.com/jumpserver/lion-release) JumpServer 图形协议 Connector 项目,依赖 [Apache Guacamole](https://guacamole.apache.org/)
- [Clients](https://github.com/jumpserver/clients) JumpServer 客户端 项目
- [Installer](https://github.com/jumpserver/installer) JumpServer 安装包 项目
### 社区
@@ -52,7 +68,7 @@ JumpServer 采纳分布式架构,支持多机房跨区域部署,支持横向
#### 微信交流群
<img src="https://download.jumpserver.org/images/weixin-group.jpeg" alt="微信群二维码" width="200"/>
<img src="https://download.jumpserver.org/images/wecom-group.jpeg" alt="微信群二维码" width="200"/>
### 贡献
如果有你好的想法创意,或者帮助我们修复了 Bug, 欢迎提交 Pull Request
@@ -108,7 +124,7 @@ JumpServer是一款安全产品请参考 [基本安全建议](https://docs.ju
### License & Copyright
Copyright (c) 2014-2020 飞致云 FIT2CLOUD, All rights reserved.
Copyright (c) 2014-2021 飞致云 FIT2CLOUD, All rights reserved.
Licensed under The GNU General Public License version 2 (GPLv2) (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at

View File

@@ -85,7 +85,7 @@ If you find a security problem, please contact us directly
- 400-052-0755
### License & Copyright
Copyright (c) 2014-2019 Beijing Duizhan Tech, Inc., All rights reserved.
Copyright (c) 2014-2021 Beijing Duizhan Tech, Inc., All rights reserved.
Licensed under The GNU General Public License version 2 (GPLv2) (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at

9
SECURITY.md Normal file
View File

@@ -0,0 +1,9 @@
# 安全说明
JumpServer 是一款正在成长的安全产品, 请参考 [基本安全建议](https://docs.jumpserver.org/zh/master/install/install_security/) 部署安装.
如果你发现安全问题,请直接联系我们,我们携手让世界更好:
- ibuler@fit2cloud.com
- support@fit2cloud.com
- 400-052-0755

View File

@@ -9,12 +9,11 @@ from tickets.api import GenericTicketStatusRetrieveCloseAPI
from ..models import LoginAssetACL
from .. import serializers
__all__ = ['LoginAssetCheckAPI', 'LoginAssetConfirmStatusAPI']
class LoginAssetCheckAPI(CreateAPIView):
permission_classes = (IsAppUser, )
permission_classes = (IsAppUser,)
serializer_class = serializers.LoginAssetCheckSerializer
def create(self, request, *args, **kwargs):
@@ -57,11 +56,12 @@ class LoginAssetCheckAPI(CreateAPIView):
external=True, api_to_ui=True
)
ticket_detail_url = '{url}?type={type}'.format(url=ticket_detail_url, type=ticket.type)
ticket_assignees = ticket.current_node.first().ticket_assignees.all()
data = {
'check_confirm_status': {'method': 'GET', 'url': confirm_status_url},
'close_confirm': {'method': 'DELETE', 'url': confirm_status_url},
'ticket_detail_url': ticket_detail_url,
'reviewers': [str(user) for user in ticket.assignees.all()],
'reviewers': [str(ticket_assignee.assignee) for ticket_assignee in ticket_assignees]
}
return data
@@ -74,4 +74,3 @@ class LoginAssetCheckAPI(CreateAPIView):
class LoginAssetConfirmStatusAPI(GenericTicketStatusRetrieveCloseAPI):
pass

View File

@@ -2,7 +2,7 @@
from django.db import models
from django.utils.translation import ugettext_lazy as _
from .base import BaseACL, BaseACLQuerySet
from ..utils import contains_ip
from common.utils.ip import contains_ip
class ACLManager(models.Manager):

View File

@@ -3,7 +3,7 @@ from django.db.models import Q
from django.utils.translation import ugettext_lazy as _
from orgs.mixins.models import OrgModelMixin, OrgManager
from .base import BaseACL, BaseACLQuerySet
from ..utils import contains_ip
from common.utils.ip import contains_ip
class ACLManager(OrgManager):
@@ -83,11 +83,11 @@ class LoginAssetACL(BaseACL, OrgModelMixin):
@classmethod
def create_login_asset_confirm_ticket(cls, user, asset, system_user, assignees, org_id):
from tickets.const import TicketTypeChoices
from tickets.const import TicketType
from tickets.models import Ticket
data = {
'title': _('Login asset confirm') + ' ({})'.format(user),
'type': TicketTypeChoices.login_asset_confirm,
'type': TicketType.login_asset_confirm,
'meta': {
'apply_login_user': str(user),
'apply_login_asset': str(asset),
@@ -96,7 +96,7 @@ class LoginAssetACL(BaseACL, OrgModelMixin):
'org_id': org_id,
}
ticket = Ticket.objects.create(**data)
ticket.assignees.set(assignees)
ticket.create_process_map_and_node(assignees)
ticket.open(applicant=user)
return ticket

View File

@@ -3,7 +3,7 @@ from rest_framework import serializers
from common.drf.serializers import BulkModelSerializer
from orgs.utils import current_org
from ..models import LoginACL
from ..utils import is_ip_address, is_ip_network, is_ip_segment
from common.utils.ip import is_ip_address, is_ip_network, is_ip_segment
__all__ = ['LoginACLSerializer', ]

View File

@@ -1,68 +0,0 @@
from ipaddress import ip_network, ip_address
def is_ip_address(address):
""" 192.168.10.1 """
try:
ip_address(address)
except ValueError:
return False
else:
return True
def is_ip_network(ip):
""" 192.168.1.0/24 """
try:
ip_network(ip)
except ValueError:
return False
else:
return True
def is_ip_segment(ip):
""" 10.1.1.1-10.1.1.20 """
if '-' not in ip:
return False
ip_address1, ip_address2 = ip.split('-')
return is_ip_address(ip_address1) and is_ip_address(ip_address2)
def in_ip_segment(ip, ip_segment):
ip1, ip2 = ip_segment.split('-')
ip1 = int(ip_address(ip1))
ip2 = int(ip_address(ip2))
ip = int(ip_address(ip))
return min(ip1, ip2) <= ip <= max(ip1, ip2)
def contains_ip(ip, ip_group):
"""
ip_group:
[192.168.10.1, 192.168.1.0/24, 10.1.1.1-10.1.1.20, 2001:db8:2de::e13, 2001:db8:1a:1110::/64.]
"""
if '*' in ip_group:
return True
for _ip in ip_group:
if is_ip_address(_ip):
# 192.168.10.1
if ip == _ip:
return True
elif is_ip_network(_ip) and is_ip_address(ip):
# 192.168.1.0/24
if ip_address(ip) in ip_network(_ip):
return True
elif is_ip_segment(_ip) and is_ip_address(ip):
# 10.1.1.1-10.1.1.20
if in_ip_segment(ip, _ip):
return True
else:
# is domain name
if ip == _ip:
return True
return False

View File

@@ -1,4 +1,4 @@
from .application import *
from .application_user import *
from .account import *
from .mixin import *
from .remote_app import *

View File

@@ -0,0 +1,58 @@
# coding: utf-8
#
from django_filters import rest_framework as filters
from django.db.models import F, Q
from common.drf.filters import BaseFilterSet
from common.drf.api import JMSBulkModelViewSet
from ..models import Account
from ..hands import IsOrgAdminOrAppUser, IsOrgAdmin, NeedMFAVerify
from .. import serializers
class AccountFilterSet(BaseFilterSet):
username = filters.CharFilter(method='do_nothing')
type = filters.CharFilter(field_name='type', lookup_expr='exact')
category = filters.CharFilter(field_name='category', lookup_expr='exact')
app_display = filters.CharFilter(field_name='app_display', lookup_expr='exact')
class Meta:
model = Account
fields = ['app', 'systemuser']
@property
def qs(self):
qs = super().qs
qs = self.filter_username(qs)
return qs
def filter_username(self, qs):
username = self.get_query_param('username')
if not username:
return qs
qs = qs.filter(Q(username=username) | Q(systemuser__username=username)).distinct()
return qs
class ApplicationAccountViewSet(JMSBulkModelViewSet):
model = Account
search_fields = ['username', 'app_display']
filterset_class = AccountFilterSet
filterset_fields = ['username', 'app_display', 'type', 'category', 'app']
serializer_class = serializers.AppAccountSerializer
permission_classes = (IsOrgAdmin,)
def get_queryset(self):
queryset = Account.objects.all() \
.annotate(type=F('app__type')) \
.annotate(app_display=F('app__name')) \
.annotate(systemuser_display=F('systemuser__name')) \
.annotate(category=F('app__category'))
return queryset
class ApplicationAccountSecretViewSet(ApplicationAccountViewSet):
serializer_class = serializers.AppAccountSecretSerializer
permission_classes = [IsOrgAdminOrAppUser, NeedMFAVerify]
http_method_names = ['get', 'options']

View File

@@ -2,18 +2,37 @@
#
from orgs.mixins.api import OrgBulkModelViewSet
from rest_framework.decorators import action
from rest_framework.response import Response
from common.tree import TreeNodeSerializer
from common.mixins.views import SuggestionMixin
from ..hands import IsOrgAdminOrAppUser
from .. import serializers
from ..models import Application
__all__ = ['ApplicationViewSet']
class ApplicationViewSet(OrgBulkModelViewSet):
class ApplicationViewSet(SuggestionMixin, OrgBulkModelViewSet):
model = Application
filterset_fields = ('name', 'type', 'category')
search_fields = filterset_fields
filterset_fields = {
'name': ['exact'],
'category': ['exact'],
'type': ['exact', 'in'],
}
search_fields = ('name', 'type', 'category')
permission_classes = (IsOrgAdminOrAppUser,)
serializer_class = serializers.ApplicationSerializer
serializer_classes = {
'default': serializers.AppSerializer,
'get_tree': TreeNodeSerializer,
'suggestion': serializers.MiniAppSerializer
}
@action(methods=['GET'], detail=False, url_path='tree')
def get_tree(self, request, *args, **kwargs):
show_count = request.query_params.get('show_count', '1') == '1'
queryset = self.filter_queryset(self.get_queryset())
tree_nodes = Application.create_tree_nodes(queryset, show_count=show_count)
serializer = self.get_serializer(tree_nodes, many=True)
return Response(serializer.data)

View File

@@ -1,55 +0,0 @@
# coding: utf-8
#
from rest_framework import generics
from django.conf import settings
from ..hands import IsOrgAdminOrAppUser, IsOrgAdmin, NeedMFAVerify
from .. import serializers
from ..models import Application, ApplicationUser
from perms.models import ApplicationPermission
class ApplicationUserListApi(generics.ListAPIView):
permission_classes = (IsOrgAdmin, )
filterset_fields = ('name', 'username')
search_fields = filterset_fields
serializer_class = serializers.ApplicationUserSerializer
_application = None
@property
def application(self):
if self._application is None:
app_id = self.request.query_params.get('application_id')
if app_id:
self._application = Application.objects.get(id=app_id)
return self._application
def get_serializer_context(self):
context = super().get_serializer_context()
context.update({
'application': self.application
})
return context
def get_queryset(self):
queryset = ApplicationUser.objects.none()
if not self.application:
return queryset
system_user_ids = ApplicationPermission.objects.filter(applications=self.application)\
.values_list('system_users', flat=True)
if not system_user_ids:
return queryset
queryset = ApplicationUser.objects.filter(id__in=system_user_ids)
return queryset
class ApplicationUserAuthInfoListApi(ApplicationUserListApi):
serializer_class = serializers.ApplicationUserWithAuthInfoSerializer
http_method_names = ['get']
permission_classes = [IsOrgAdminOrAppUser]
def get_permissions(self):
if settings.SECURITY_VIEW_AUTH_NEED_MFA:
self.permission_classes = [IsOrgAdminOrAppUser, NeedMFAVerify]
return super().get_permissions()

View File

@@ -1,89 +1,53 @@
from orgs.models import Organization
from django.utils.translation import ugettext as _
from common.tree import TreeNode
from orgs.models import Organization
from ..models import Application
__all__ = ['SerializeApplicationToTreeNodeMixin']
class SerializeApplicationToTreeNodeMixin:
@staticmethod
def _serialize_db(db):
return {
'id': db.id,
'name': db.name,
'title': db.name,
'pId': '',
'open': False,
'iconSkin': 'database',
'meta': {'type': 'database_app'}
}
@staticmethod
def _serialize_remote_app(remote_app):
return {
'id': remote_app.id,
'name': remote_app.name,
'title': remote_app.name,
'pId': '',
'open': False,
'isParent': False,
'iconSkin': 'chrome',
'meta': {'type': 'remote_app'}
}
@staticmethod
def _serialize_cloud(cloud):
return {
'id': cloud.id,
'name': cloud.name,
'title': cloud.name,
'pId': '',
'open': False,
'isParent': False,
'iconSkin': 'k8s',
'meta': {'type': 'k8s_app'}
}
def _serialize_application(self, application):
method_name = f'_serialize_{application.category}'
data = getattr(self, method_name)(application)
data.update({
'pId': application.org.id,
'org_name': application.org_name
})
return data
def serialize_applications(self, applications):
data = [self._serialize_application(application) for application in applications]
return data
@staticmethod
def _serialize_organization(org):
return {
'id': org.id,
'name': org.name,
'title': org.name,
'pId': '',
'open': True,
'isParent': True,
'meta': {
'type': 'node'
}
}
def serialize_organizations(self, organizations):
data = [self._serialize_organization(org) for org in organizations]
return data
@staticmethod
def filter_organizations(applications):
organization_ids = set(applications.values_list('org_id', flat=True))
organizations = [Organization.get_instance(org_id) for org_id in organization_ids]
organizations.sort(key=lambda x: x.name)
return organizations
@staticmethod
def create_root_node():
name = _('My applications')
node = TreeNode(**{
'id': 'applications',
'name': name,
'title': name,
'pId': '',
'open': True,
'isParent': True,
'meta': {
'type': 'root'
}
})
return node
def serialize_applications_with_org(self, applications):
root_node = self.create_root_node()
tree_nodes = [root_node]
organizations = self.filter_organizations(applications)
data_organizations = self.serialize_organizations(organizations)
data_applications = self.serialize_applications(applications)
data = data_organizations + data_applications
return data
for i, org in enumerate(organizations):
# 组织节点
org_node = org.as_tree_node(pid=root_node.id)
tree_nodes.append(org_node)
org_applications = applications.filter(org_id=org.id)
count = org_applications.count()
org_node.name += '({})'.format(count)
# 各应用节点
apps_nodes = Application.create_tree_nodes(
queryset=org_applications, root_node=org_node,
show_empty=False
)
tree_nodes += apps_nodes
return tree_nodes

View File

@@ -1,11 +1,10 @@
# coding: utf-8
#
from django.db.models import TextChoices
from django.utils.translation import ugettext_lazy as _
class ApplicationCategoryChoices(TextChoices):
class AppCategory(TextChoices):
db = 'db', _('Database')
remote_app = 'remote_app', _('Remote app')
cloud = 'cloud', 'Cloud'
@@ -15,7 +14,7 @@ class ApplicationCategoryChoices(TextChoices):
return dict(cls.choices).get(category, '')
class ApplicationTypeChoices(TextChoices):
class AppType(TextChoices):
# db category
mysql = 'mysql', 'MySQL'
oracle = 'oracle', 'Oracle'
@@ -31,19 +30,38 @@ class ApplicationTypeChoices(TextChoices):
# cloud category
k8s = 'k8s', 'Kubernetes'
@classmethod
def category_types_mapper(cls):
return {
AppCategory.db: [cls.mysql, cls.oracle, cls.pgsql, cls.mariadb],
AppCategory.remote_app: [cls.chrome, cls.mysql_workbench, cls.vmware_client, cls.custom],
AppCategory.cloud: [cls.k8s]
}
@classmethod
def type_category_mapper(cls):
mapper = {}
for category, tps in cls.category_types_mapper().items():
for tp in tps:
mapper[tp] = category
return mapper
@classmethod
def get_label(cls, tp):
return dict(cls.choices).get(tp, '')
@classmethod
def db_types(cls):
return [cls.mysql.value, cls.oracle.value, cls.pgsql.value, cls.mariadb.value]
return [tp.value for tp in cls.category_types_mapper()[AppCategory.db]]
@classmethod
def remote_app_types(cls):
return [cls.chrome.value, cls.mysql_workbench.value, cls.vmware_client.value, cls.custom.value]
return [tp.value for tp in cls.category_types_mapper()[AppCategory.remote_app]]
@classmethod
def cloud_types(cls):
return [cls.k8s.value]
return [tp.value for tp in cls.category_types_mapper()[AppCategory.cloud]]

View File

@@ -0,0 +1,76 @@
# Generated by Django 3.1.12 on 2021-08-26 09:07
import assets.models.base
import common.fields.model
from django.conf import settings
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import simple_history.models
import uuid
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('assets', '0076_delete_assetuser'),
('applications', '0009_applicationuser'),
]
operations = [
migrations.CreateModel(
name='HistoricalAccount',
fields=[
('org_id', models.CharField(blank=True, db_index=True, default='', max_length=36, verbose_name='Organization')),
('id', models.UUIDField(db_index=True, default=uuid.uuid4)),
('name', models.CharField(max_length=128, verbose_name='Name')),
('username', models.CharField(blank=True, db_index=True, max_length=128, validators=[django.core.validators.RegexValidator('^[0-9a-zA-Z_@\\-\\.]*$', 'Special char not allowed')], verbose_name='Username')),
('password', common.fields.model.EncryptCharField(blank=True, max_length=256, null=True, verbose_name='Password')),
('private_key', common.fields.model.EncryptTextField(blank=True, null=True, verbose_name='SSH private key')),
('public_key', common.fields.model.EncryptTextField(blank=True, null=True, verbose_name='SSH public key')),
('comment', models.TextField(blank=True, verbose_name='Comment')),
('date_created', models.DateTimeField(blank=True, editable=False, verbose_name='Date created')),
('date_updated', models.DateTimeField(blank=True, editable=False, verbose_name='Date updated')),
('created_by', models.CharField(max_length=128, null=True, verbose_name='Created by')),
('version', models.IntegerField(default=1, verbose_name='Version')),
('history_id', models.AutoField(primary_key=True, serialize=False)),
('history_date', models.DateTimeField()),
('history_change_reason', models.CharField(max_length=100, null=True)),
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
('app', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='applications.application', verbose_name='Database')),
('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('systemuser', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='assets.systemuser', verbose_name='System user')),
],
options={
'verbose_name': 'historical Account',
'ordering': ('-history_date', '-history_id'),
'get_latest_by': 'history_date',
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
migrations.CreateModel(
name='Account',
fields=[
('org_id', models.CharField(blank=True, db_index=True, default='', max_length=36, verbose_name='Organization')),
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
('name', models.CharField(max_length=128, verbose_name='Name')),
('username', models.CharField(blank=True, db_index=True, max_length=128, validators=[django.core.validators.RegexValidator('^[0-9a-zA-Z_@\\-\\.]*$', 'Special char not allowed')], verbose_name='Username')),
('password', common.fields.model.EncryptCharField(blank=True, max_length=256, null=True, verbose_name='Password')),
('private_key', common.fields.model.EncryptTextField(blank=True, null=True, verbose_name='SSH private key')),
('public_key', common.fields.model.EncryptTextField(blank=True, null=True, verbose_name='SSH public key')),
('comment', models.TextField(blank=True, verbose_name='Comment')),
('date_created', models.DateTimeField(auto_now_add=True, verbose_name='Date created')),
('date_updated', models.DateTimeField(auto_now=True, verbose_name='Date updated')),
('created_by', models.CharField(max_length=128, null=True, verbose_name='Created by')),
('version', models.IntegerField(default=1, verbose_name='Version')),
('app', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='applications.application', verbose_name='Database')),
('systemuser', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='assets.systemuser', verbose_name='System user')),
],
options={
'verbose_name': 'Account',
'unique_together': {('username', 'app', 'systemuser')},
},
bases=(models.Model, assets.models.base.AuthMixin),
),
]

View File

@@ -0,0 +1,40 @@
# Generated by Django 3.1.12 on 2021-08-26 09:59
from django.db import migrations, transaction
from django.db.models import F
def migrate_app_account(apps, schema_editor):
db_alias = schema_editor.connection.alias
app_perm_model = apps.get_model("perms", "ApplicationPermission")
app_account_model = apps.get_model("applications", 'Account')
queryset = app_perm_model.objects \
.exclude(system_users__isnull=True) \
.exclude(applications__isnull=True) \
.annotate(systemuser=F('system_users')) \
.annotate(app=F('applications')) \
.values('app', 'systemuser', 'org_id')
accounts = []
for p in queryset:
if not p['app']:
continue
account = app_account_model(
app_id=p['app'], systemuser_id=p['systemuser'],
version=1, org_id=p['org_id']
)
accounts.append(account)
app_account_model.objects.using(db_alias).bulk_create(accounts, ignore_conflicts=True)
class Migration(migrations.Migration):
dependencies = [
('applications', '0010_appaccount_historicalappaccount'),
]
operations = [
migrations.RunPython(migrate_app_account)
]

View File

@@ -1 +1,2 @@
from .application import *
from .account import *

View File

@@ -0,0 +1,88 @@
from django.db import models
from simple_history.models import HistoricalRecords
from django.utils.translation import ugettext_lazy as _
from common.utils import lazyproperty
from assets.models.base import BaseUser
class Account(BaseUser):
app = models.ForeignKey('applications.Application', on_delete=models.CASCADE, null=True, verbose_name=_('Database'))
systemuser = models.ForeignKey('assets.SystemUser', on_delete=models.CASCADE, null=True, verbose_name=_("System user"))
version = models.IntegerField(default=1, verbose_name=_('Version'))
history = HistoricalRecords()
auth_attrs = ['username', 'password', 'private_key', 'public_key']
class Meta:
verbose_name = _('Account')
unique_together = [('username', 'app', 'systemuser')]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.auth_snapshot = {}
def get_or_systemuser_attr(self, attr):
val = getattr(self, attr, None)
if val:
return val
if self.systemuser:
return getattr(self.systemuser, attr, '')
return ''
def load_auth(self):
for attr in self.auth_attrs:
value = self.get_or_systemuser_attr(attr)
self.auth_snapshot[attr] = [getattr(self, attr), value]
setattr(self, attr, value)
def unload_auth(self):
if not self.systemuser:
return
for attr, values in self.auth_snapshot.items():
origin_value, loaded_value = values
current_value = getattr(self, attr, '')
if current_value == loaded_value:
setattr(self, attr, origin_value)
def save(self, *args, **kwargs):
self.unload_auth()
instance = super().save(*args, **kwargs)
self.load_auth()
return instance
@lazyproperty
def category(self):
return self.app.category
@lazyproperty
def type(self):
return self.app.type
@lazyproperty
def app_display(self):
return self.systemuser.name
@property
def username_display(self):
return self.get_or_systemuser_attr('username') or ''
@lazyproperty
def systemuser_display(self):
if not self.systemuser:
return ''
return str(self.systemuser)
@property
def smart_name(self):
username = self.username_display
if self.app:
app = str(self.app)
else:
app = '*'
return '{}@{}'.format(username, app)
def __str__(self):
return self.smart_name

View File

@@ -1,19 +1,174 @@
from collections import defaultdict
from django.db import models
from django.utils.translation import ugettext_lazy as _
from orgs.mixins.models import OrgModelMixin
from common.mixins import CommonModelMixin
from common.tree import TreeNode
from assets.models import Asset, SystemUser
from .. import const
class Application(CommonModelMixin, OrgModelMixin):
class ApplicationTreeNodeMixin:
id: str
name: str
type: str
category: str
@classmethod
def create_choice_node(cls, c, id_, pid, tp, opened=False, counts=None,
show_empty=True, show_count=True):
count = counts.get(c.value, 0)
if count == 0 and not show_empty:
return None
label = c.label
if count is not None and show_count:
label = '{} ({})'.format(label, count)
data = {
'id': id_,
'name': label,
'title': label,
'pId': pid,
'isParent': bool(count),
'open': opened,
'iconSkin': '',
'meta': {
'type': tp,
'data': {
'name': c.name,
'value': c.value
}
}
}
return TreeNode(**data)
@classmethod
def create_root_tree_node(cls, queryset, show_count=True):
count = queryset.count() if show_count else None
root_id = 'applications'
root_name = _('Applications')
if count is not None and show_count:
root_name = '{} ({})'.format(root_name, count)
node = TreeNode(**{
'id': root_id,
'name': root_name,
'title': root_name,
'pId': '',
'isParent': True,
'open': True,
'iconSkin': '',
'meta': {
'type': 'applications_root',
}
})
return node
@classmethod
def create_category_tree_nodes(cls, root_node, counts=None, show_empty=True, show_count=True):
nodes = []
categories = const.AppType.category_types_mapper().keys()
for category in categories:
i = root_node.id + '_' + category.value
node = cls.create_choice_node(
category, i, pid=root_node.id, tp='category',
counts=counts, opened=False, show_empty=show_empty,
show_count=show_count
)
if not node:
continue
nodes.append(node)
return nodes
@classmethod
def create_types_tree_nodes(cls, root_node, counts, show_empty=True, show_count=True):
nodes = []
type_category_mapper = const.AppType.type_category_mapper()
for tp in const.AppType.type_category_mapper().keys():
category = type_category_mapper.get(tp)
pid = root_node.id + '_' + category.value
i = root_node.id + '_' + tp.value
node = cls.create_choice_node(
tp, i, pid, tp='type', counts=counts, opened=False,
show_empty=show_empty, show_count=show_count
)
if not node:
continue
nodes.append(node)
return nodes
@staticmethod
def get_tree_node_counts(queryset):
counts = defaultdict(int)
values = queryset.values_list('type', 'category')
for i in values:
tp = i[0]
category = i[1]
counts[tp] += 1
counts[category] += 1
return counts
@classmethod
def create_tree_nodes(cls, queryset, root_node=None, show_empty=True, show_count=True):
counts = cls.get_tree_node_counts(queryset)
tree_nodes = []
# 根节点有可能是组织名称
if root_node is None:
root_node = cls.create_root_tree_node(queryset, show_count=show_count)
tree_nodes.append(root_node)
# 类别的节点
tree_nodes += cls.create_category_tree_nodes(
root_node, counts, show_empty=show_empty,
show_count=show_count
)
# 类型的节点
tree_nodes += cls.create_types_tree_nodes(
root_node, counts, show_empty=show_empty,
show_count=show_count
)
# 应用的节点
for app in queryset:
pid = root_node.id + '_' + app.type
tree_nodes.append(app.as_tree_node(pid))
return tree_nodes
def as_tree_node(self, pid):
icon_skin_category_mapper = {
'remote_app': 'chrome',
'db': 'database',
'cloud': 'cloud'
}
icon_skin = icon_skin_category_mapper.get(self.category, 'file')
node = TreeNode(**{
'id': str(self.id),
'name': self.name,
'title': self.name,
'pId': pid,
'isParent': False,
'open': False,
'iconSkin': icon_skin,
'meta': {
'type': 'application',
'data': {
'category': self.category,
'type': self.type,
}
}
})
return node
class Application(CommonModelMixin, OrgModelMixin, ApplicationTreeNodeMixin):
name = models.CharField(max_length=128, verbose_name=_('Name'))
category = models.CharField(
max_length=16, choices=const.ApplicationCategoryChoices.choices, verbose_name=_('Category')
max_length=16, choices=const.AppCategory.choices, verbose_name=_('Category')
)
type = models.CharField(
max_length=16, choices=const.ApplicationTypeChoices.choices, verbose_name=_('Type')
max_length=16, choices=const.AppType.choices, verbose_name=_('Type')
)
domain = models.ForeignKey(
'assets.Domain', null=True, blank=True, related_name='applications',
@@ -35,7 +190,7 @@ class Application(CommonModelMixin, OrgModelMixin):
@property
def category_remote_app(self):
return self.category == const.ApplicationCategoryChoices.remote_app.value
return self.category == const.AppCategory.remote_app.value
def get_rdp_remote_app_setting(self):
from applications.serializers.attrs import get_serializer_class_by_application_type

View File

@@ -3,19 +3,24 @@
from rest_framework import serializers
from django.utils.translation import ugettext_lazy as _
from orgs.mixins.serializers import BulkOrgResourceModelSerializer
from assets.serializers.base import AuthSerializerMixin
from common.drf.serializers import MethodSerializer
from .attrs import category_serializer_classes_mapping, type_serializer_classes_mapping
from assets.serializers import SystemUserSerializer
from .attrs import (
category_serializer_classes_mapping,
type_serializer_classes_mapping
)
from .. import models
from .. import const
__all__ = [
'ApplicationSerializer', 'ApplicationSerializerMixin',
'ApplicationUserSerializer', 'ApplicationUserWithAuthInfoSerializer'
'AppSerializer', 'MiniAppSerializer', 'AppSerializerMixin',
'AppAccountSerializer', 'AppAccountSecretSerializer'
]
class ApplicationSerializerMixin(serializers.Serializer):
class AppSerializerMixin(serializers.Serializer):
attrs = MethodSerializer()
def get_attrs_serializer(self):
@@ -43,18 +48,23 @@ class ApplicationSerializerMixin(serializers.Serializer):
serializer = serializer_class
return serializer
def create(self, validated_data):
return super().create(validated_data)
class ApplicationSerializer(ApplicationSerializerMixin, BulkOrgResourceModelSerializer):
category_display = serializers.ReadOnlyField(source='get_category_display', label=_('Category(Display)'))
type_display = serializers.ReadOnlyField(source='get_type_display', label=_('Type(Dispaly)'))
def update(self, instance, validated_data):
return super().update(instance, validated_data)
class AppSerializer(AppSerializerMixin, BulkOrgResourceModelSerializer):
category_display = serializers.ReadOnlyField(source='get_category_display', label=_('Category display'))
type_display = serializers.ReadOnlyField(source='get_type_display', label=_('Type display'))
class Meta:
model = models.Application
fields_mini = ['id', 'name']
fields_small = fields_mini + [
'category', 'category_display', 'type', 'type_display', 'attrs',
'date_created', 'date_updated',
'created_by', 'comment'
'category', 'category_display', 'type', 'type_display',
'attrs', 'date_created', 'date_updated', 'created_by', 'comment'
]
fields_fk = ['domain']
fields = fields_small + fields_fk
@@ -68,41 +78,60 @@ class ApplicationSerializer(ApplicationSerializerMixin, BulkOrgResourceModelSeri
return _attrs
class ApplicationUserSerializer(SystemUserSerializer):
application_name = serializers.SerializerMethodField(label=_('Application name'))
application_category = serializers.SerializerMethodField(label=_('Application category'))
application_type = serializers.SerializerMethodField(label=_('Application type'))
class MiniAppSerializer(serializers.ModelSerializer):
class Meta:
model = models.Application
fields = AppSerializer.Meta.fields_mini
class Meta(SystemUserSerializer.Meta):
model = models.ApplicationUser
fields_mini = [
'id', 'application_name', 'application_category', 'application_type', 'name', 'username'
class AppAccountSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
category = serializers.ChoiceField(label=_('Category'), choices=const.AppCategory.choices, read_only=True)
category_display = serializers.SerializerMethodField(label=_('Category display'))
type = serializers.ChoiceField(label=_('Type'), choices=const.AppType.choices, read_only=True)
type_display = serializers.SerializerMethodField(label=_('Type display'))
category_mapper = dict(const.AppCategory.choices)
type_mapper = dict(const.AppType.choices)
class Meta:
model = models.Account
fields_mini = ['id', 'username', 'version']
fields_write_only = ['password', 'private_key']
fields_fk = ['systemuser', 'systemuser_display', 'app', 'app_display']
fields = fields_mini + fields_fk + fields_write_only + [
'type', 'type_display', 'category', 'category_display',
]
fields_small = fields_mini + [
'protocol', 'login_mode', 'login_mode_display', 'priority',
"username_same_with_user", 'comment',
]
fields = fields_small
extra_kwargs = {
'login_mode_display': {'label': _('Login mode display')},
'created_by': {'read_only': True},
'username': {'default': '', 'required': False},
'password': {'write_only': True},
'app_display': {'label': _('Application display')}
}
use_model_bulk_create = True
model_bulk_create_kwargs = {
'ignore_conflicts': True
}
@property
def application(self):
return self.context['application']
def get_category_display(self, obj):
return self.category_mapper.get(obj.category)
def get_application_name(self, obj):
return self.application.name
def get_type_display(self, obj):
return self.type_mapper.get(obj.type)
def get_application_category(self, obj):
return self.application.get_category_display()
@classmethod
def setup_eager_loading(cls, queryset):
""" Perform necessary eager loading of data. """
queryset = queryset.prefetch_related('systemuser', 'app')
return queryset
def get_application_type(self, obj):
return self.application.get_type_display()
def to_representation(self, instance):
instance.load_auth()
return super().to_representation(instance)
class ApplicationUserWithAuthInfoSerializer(ApplicationUserSerializer):
class Meta(ApplicationUserSerializer.Meta):
fields = ApplicationUserSerializer.Meta.fields + ['password', 'token']
class AppAccountSecretSerializer(AppAccountSerializer):
class Meta(AppAccountSerializer.Meta):
extra_kwargs = {
'password': {'write_only': False},
'private_key': {'write_only': False},
'public_key': {'write_only': False},
}

View File

@@ -5,7 +5,7 @@ from rest_framework import serializers
from django.utils.translation import ugettext_lazy as _
from django.core.exceptions import ObjectDoesNotExist
from common.utils import get_logger, is_uuid
from common.utils import get_logger, is_uuid, get_object_or_none
from assets.models import Asset
logger = get_logger(__file__)
@@ -14,28 +14,37 @@ logger = get_logger(__file__)
__all__ = ['RemoteAppSerializer']
class CharPrimaryKeyRelatedField(serializers.PrimaryKeyRelatedField):
class ExistAssetPrimaryKeyRelatedField(serializers.PrimaryKeyRelatedField):
def to_internal_value(self, data):
instance = super().to_internal_value(data)
return str(instance.id)
def to_representation(self, value):
# value is instance.id
def to_representation(self, _id):
# _id 是 instance.id
if self.pk_field is not None:
return self.pk_field.to_representation(value)
return value
return self.pk_field.to_representation(_id)
# 解决删除资产后远程应用更新页面会显示资产ID的问题
asset = get_object_or_none(Asset, id=_id)
if not asset:
return None
return _id
class RemoteAppSerializer(serializers.Serializer):
asset_info = serializers.SerializerMethodField()
asset = CharPrimaryKeyRelatedField(
queryset=Asset.objects, required=False, label=_("Asset"), allow_null=True
asset = ExistAssetPrimaryKeyRelatedField(
queryset=Asset.objects, required=True, label=_("Asset"), allow_null=True
)
path = serializers.CharField(
max_length=128, label=_('Application path'), allow_null=True
)
def validate_asset(self, asset):
if not asset:
raise serializers.ValidationError(_('This field is required.'))
return asset
@staticmethod
def get_asset_info(obj):
asset_id = obj.get('asset')

View File

@@ -14,9 +14,9 @@ __all__ = [
# ---------------------------------------------------
category_serializer_classes_mapping = {
const.ApplicationCategoryChoices.db.value: application_category.DBSerializer,
const.ApplicationCategoryChoices.remote_app.value: application_category.RemoteAppSerializer,
const.ApplicationCategoryChoices.cloud.value: application_category.CloudSerializer,
const.AppCategory.db.value: application_category.DBSerializer,
const.AppCategory.remote_app.value: application_category.RemoteAppSerializer,
const.AppCategory.cloud.value: application_category.CloudSerializer,
}
# define `attrs` field `type serializers mapping`
@@ -24,17 +24,17 @@ category_serializer_classes_mapping = {
type_serializer_classes_mapping = {
# db
const.ApplicationTypeChoices.mysql.value: application_type.MySQLSerializer,
const.ApplicationTypeChoices.mariadb.value: application_type.MariaDBSerializer,
const.ApplicationTypeChoices.oracle.value: application_type.OracleSerializer,
const.ApplicationTypeChoices.pgsql.value: application_type.PostgreSerializer,
const.AppType.mysql.value: application_type.MySQLSerializer,
const.AppType.mariadb.value: application_type.MariaDBSerializer,
const.AppType.oracle.value: application_type.OracleSerializer,
const.AppType.pgsql.value: application_type.PostgreSerializer,
# remote-app
const.ApplicationTypeChoices.chrome.value: application_type.ChromeSerializer,
const.ApplicationTypeChoices.mysql_workbench.value: application_type.MySQLWorkbenchSerializer,
const.ApplicationTypeChoices.vmware_client.value: application_type.VMwareClientSerializer,
const.ApplicationTypeChoices.custom.value: application_type.CustomSerializer,
const.AppType.chrome.value: application_type.ChromeSerializer,
const.AppType.mysql_workbench.value: application_type.MySQLWorkbenchSerializer,
const.AppType.vmware_client.value: application_type.VMwareClientSerializer,
const.AppType.custom.value: application_type.CustomSerializer,
# cloud
const.ApplicationTypeChoices.k8s.value: application_type.K8SSerializer
const.AppType.k8s.value: application_type.K8SSerializer
}

View File

@@ -10,12 +10,14 @@ app_name = 'applications'
router = BulkRouter()
router.register(r'applications', api.ApplicationViewSet, 'application')
router.register(r'accounts', api.ApplicationAccountViewSet, 'application-account')
router.register(r'account-secrets', api.ApplicationAccountSecretViewSet, 'application-account-secret')
urlpatterns = [
path('remote-apps/<uuid:pk>/connection-info/', api.RemoteAppConnectionInfoApi.as_view(), name='remote-app-connection-info'),
path('application-users/', api.ApplicationUserListApi.as_view(), name='application-user'),
path('application-user-auth-infos/', api.ApplicationUserAuthInfoListApi.as_view(), name='application-user-auth-info')
# path('accounts/', api.ApplicationAccountViewSet.as_view(), name='application-account'),
# path('account-secrets/', api.ApplicationAccountSecretViewSet.as_view(), name='application-account-secret')
]

View File

@@ -1,15 +1,15 @@
from django.db.models import F, Q
from django.conf import settings
from rest_framework.decorators import action
from django_filters import rest_framework as filters
from rest_framework.response import Response
from django.shortcuts import get_object_or_404
from rest_framework.generics import CreateAPIView
from orgs.mixins.api import OrgBulkModelViewSet
from common.permissions import IsOrgAdmin, IsOrgAdminOrAppUser, NeedMFAVerify
from common.drf.filters import BaseFilterSet
from ..tasks.account_connectivity import test_accounts_connectivity_manual
from ..models import AuthBook
from ..models import AuthBook, Node
from .. import serializers
__all__ = ['AccountViewSet', 'AccountSecretsViewSet', 'AccountTaskCreateAPI']
@@ -19,11 +19,13 @@ class AccountFilterSet(BaseFilterSet):
username = filters.CharFilter(method='do_nothing')
ip = filters.CharFilter(field_name='ip', lookup_expr='exact')
hostname = filters.CharFilter(field_name='hostname', lookup_expr='exact')
node = filters.CharFilter(method='do_nothing')
@property
def qs(self):
qs = super().qs
qs = self.filter_username(qs)
qs = self.filter_node(qs)
return qs
def filter_username(self, qs):
@@ -33,6 +35,16 @@ class AccountFilterSet(BaseFilterSet):
qs = qs.filter(Q(username=username) | Q(systemuser__username=username)).distinct()
return qs
def filter_node(self, qs):
node_id = self.get_query_param('node')
if not node_id:
return qs
node = get_object_or_404(Node, pk=node_id)
node_ids = node.get_all_children(with_self=True).values_list('id', flat=True)
node_ids = list(node_ids)
qs = qs.filter(asset__nodes__in=node_ids)
return qs
class Meta:
model = AuthBook
fields = [
@@ -52,8 +64,8 @@ class AccountViewSet(OrgBulkModelViewSet):
permission_classes = (IsOrgAdmin,)
def get_queryset(self):
queryset = super().get_queryset()\
.annotate(ip=F('asset__ip'))\
queryset = super().get_queryset() \
.annotate(ip=F('asset__ip')) \
.annotate(hostname=F('asset__hostname'))
return queryset
@@ -74,11 +86,6 @@ class AccountSecretsViewSet(AccountViewSet):
permission_classes = (IsOrgAdmin, NeedMFAVerify)
http_method_names = ['get']
def get_permissions(self):
if not settings.SECURITY_VIEW_AUTH_NEED_MFA:
self.permission_classes = [IsOrgAdminOrAppUser]
return super().get_permissions()
class AccountTaskCreateAPI(CreateAPIView):
permission_classes = (IsOrgAdminOrAppUser,)
@@ -103,4 +110,5 @@ class AccountTaskCreateAPI(CreateAPIView):
def get_exception_handler(self):
def handler(e, context):
return Response({"error": str(e)}, status=400)
return handler

View File

@@ -7,16 +7,17 @@ from django.shortcuts import get_object_or_404
from common.utils import get_logger, get_object_or_none
from common.permissions import IsOrgAdmin, IsOrgAdminOrAppUser, IsSuperUser
from common.mixins.views import SuggestionMixin
from orgs.mixins.api import OrgBulkModelViewSet
from orgs.mixins import generics
from ..models import Asset, Node, Platform
from .. import serializers
from ..tasks import (
update_assets_hardware_info_manual, test_assets_connectivity_manual
update_assets_hardware_info_manual, test_assets_connectivity_manual,
test_system_users_connectivity_a_asset, push_system_users_a_asset
)
from ..filters import FilterAssetByNodeFilterBackend, LabelFilterBackend, IpInFilterBackend
logger = get_logger(__file__)
__all__ = [
'AssetViewSet', 'AssetPlatformRetrieveApi',
@@ -25,7 +26,7 @@ __all__ = [
]
class AssetViewSet(FilterAssetByNodeMixin, OrgBulkModelViewSet):
class AssetViewSet(SuggestionMixin, FilterAssetByNodeMixin, OrgBulkModelViewSet):
"""
API endpoint that allows Asset to be viewed or edited.
"""
@@ -42,6 +43,7 @@ class AssetViewSet(FilterAssetByNodeMixin, OrgBulkModelViewSet):
ordering_fields = ("hostname", "ip", "port", "cpu_cores")
serializer_classes = {
'default': serializers.AssetSerializer,
'suggestion': serializers.MiniAssetSerializer
}
permission_classes = (IsOrgAdminOrAppUser,)
extra_filter_backends = [FilterAssetByNodeFilterBackend, LabelFilterBackend, IpInFilterBackend]
@@ -94,21 +96,27 @@ class AssetPlatformViewSet(ModelViewSet):
class AssetsTaskMixin:
def perform_assets_task(self, serializer):
data = serializer.validated_data
assets = data['assets']
action = data['action']
assets = data.get('assets', [])
if action == "refresh":
task = update_assets_hardware_info_manual.delay(assets)
else:
# action == 'test':
task = test_assets_connectivity_manual.delay(assets)
return task
def perform_create(self, serializer):
task = self.perform_assets_task(serializer)
self.set_task_to_serializer_data(serializer, task)
def set_task_to_serializer_data(self, serializer, task):
data = getattr(serializer, '_data', {})
data["task"] = task.id
setattr(serializer, '_data', data)
def perform_create(self, serializer):
self.perform_assets_task(serializer)
class AssetTaskCreateApi(AssetsTaskMixin, generics.CreateAPIView):
model = Asset
@@ -117,13 +125,37 @@ class AssetTaskCreateApi(AssetsTaskMixin, generics.CreateAPIView):
def create(self, request, *args, **kwargs):
pk = self.kwargs.get('pk')
request.data['asset'] = pk
request.data['assets'] = [pk]
return super().create(request, *args, **kwargs)
def perform_asset_task(self, serializer):
data = serializer.validated_data
action = data['action']
if action not in ['push_system_user', 'test_system_user']:
return
asset = data['asset']
system_users = data.get('system_users')
if not system_users:
system_users = asset.get_all_systemusers()
if action == 'push_system_user':
task = push_system_users_a_asset.delay(system_users, asset=asset)
elif action == 'test_system_user':
task = test_system_users_connectivity_a_asset.delay(system_users, asset=asset)
else:
task = None
return task
def perform_create(self, serializer):
task = self.perform_asset_task(serializer)
if not task:
task = self.perform_assets_task(serializer)
self.set_task_to_serializer_data(serializer, task)
class AssetsTaskCreateApi(AssetsTaskMixin, generics.CreateAPIView):
model = Asset
serializer_class = serializers.AssetTaskSerializer
serializer_class = serializers.AssetsTaskSerializer
permission_classes = (IsOrgAdmin,)

View File

@@ -13,7 +13,6 @@ from ..hands import IsOrgAdmin, IsAppUser
from ..models import CommandFilter, CommandFilterRule
from .. import serializers
__all__ = [
'CommandFilterViewSet', 'CommandFilterRuleViewSet', 'CommandConfirmAPI',
'CommandConfirmStatusAPI'
@@ -44,7 +43,7 @@ class CommandFilterRuleViewSet(OrgBulkModelViewSet):
class CommandConfirmAPI(CreateAPIView):
permission_classes = (IsAppUser, )
permission_classes = (IsAppUser,)
serializer_class = serializers.CommandConfirmSerializer
def create(self, request, *args, **kwargs):
@@ -73,11 +72,12 @@ class CommandConfirmAPI(CreateAPIView):
external=True, api_to_ui=True
)
ticket_detail_url = '{url}?type={type}'.format(url=ticket_detail_url, type=ticket.type)
ticket_assignees = ticket.current_node.first().ticket_assignees.all()
return {
'check_confirm_status': {'method': 'GET', 'url': confirm_status_url},
'close_confirm': {'method': 'DELETE', 'url': confirm_status_url},
'ticket_detail_url': ticket_detail_url,
'reviewers': [str(user) for user in ticket.assignees.all()]
'reviewers': [str(ticket_assignee.assignee) for ticket_assignee in ticket_assignees]
}
@lazyproperty
@@ -89,4 +89,3 @@ class CommandConfirmAPI(CreateAPIView):
class CommandConfirmStatusAPI(GenericTicketStatusRetrieveCloseAPI):
pass

View File

@@ -33,7 +33,7 @@ class GatewayViewSet(OrgBulkModelViewSet):
model = Gateway
filterset_fields = ("domain__name", "name", "username", "ip", "domain")
search_fields = ("domain__name", "name", "username", "ip")
permission_classes = (IsOrgAdmin,)
permission_classes = (IsOrgAdminOrAppUser,)
serializer_class = serializers.GatewaySerializer

View File

@@ -26,7 +26,7 @@ class SerializeToTreeNodeMixin:
'isParent': True,
'open': node.is_org_root(),
'meta': {
'node': {
'data': {
"id": node.id,
"key": node.key,
"value": node.value,
@@ -65,7 +65,7 @@ class SerializeToTreeNodeMixin:
'chkDisabled': not asset.is_active,
'meta': {
'type': 'asset',
'asset': {
'data': {
'id': asset.id,
'hostname': asset.hostname,
'ip': asset.ip,

View File

@@ -6,6 +6,7 @@ from common.utils import get_logger
from common.permissions import IsOrgAdmin, IsOrgAdminOrAppUser, IsValidUser
from orgs.mixins.api import OrgBulkModelViewSet
from orgs.mixins import generics
from common.mixins.views import SuggestionMixin
from orgs.utils import tmp_to_root_org
from ..models import SystemUser, Asset
from .. import serializers
@@ -24,7 +25,7 @@ __all__ = [
]
class SystemUserViewSet(OrgBulkModelViewSet):
class SystemUserViewSet(SuggestionMixin, OrgBulkModelViewSet):
"""
System user api set, for add,delete,update,list,retrieve resource
"""
@@ -39,6 +40,7 @@ class SystemUserViewSet(OrgBulkModelViewSet):
serializer_class = serializers.SystemUserSerializer
serializer_classes = {
'default': serializers.SystemUserSerializer,
'suggestion': serializers.MiniSystemUserSerializer
}
permission_classes = (IsOrgAdminOrAppUser,)

View File

@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
#
from collections import defaultdict
from django.db.models import F, Value
from django.db.models import F, Value, Model
from django.db.models.signals import m2m_changed
from django.db.models.functions import Concat
@@ -13,13 +13,15 @@ from .. import models, serializers
__all__ = [
'SystemUserAssetRelationViewSet', 'SystemUserNodeRelationViewSet',
'SystemUserUserRelationViewSet',
'SystemUserUserRelationViewSet', 'BaseRelationViewSet',
]
logger = get_logger(__name__)
class RelationMixin:
model: Model
def get_queryset(self):
queryset = self.model.objects.all()
if not current_org.is_root():

View File

@@ -1,7 +1,8 @@
# Generated by Django 3.1.6 on 2021-06-04 16:46
import uuid
from django.db import migrations, models, transaction
import django.db.models.deletion
from django.db import IntegrityError
from django.db.models import F
@@ -15,7 +16,7 @@ def migrate_admin_user_to_system_user(apps, schema_editor):
for admin_user in admin_users:
kwargs = {}
for attr in [
'id', 'org_id', 'username', 'password', 'private_key', 'public_key',
'org_id', 'username', 'password', 'private_key', 'public_key',
'comment', 'date_created', 'date_updated', 'created_by',
]:
value = getattr(admin_user, attr)
@@ -27,7 +28,16 @@ def migrate_admin_user_to_system_user(apps, schema_editor):
).exists()
if exist:
name = admin_user.name + '_' + str(admin_user.id)[:5]
i = admin_user.id
exist = system_user_model.objects.using(db_alias).filter(
id=i, org_id=admin_user.org_id
).exists()
if exist:
i = uuid.uuid4()
kwargs.update({
'id': i,
'name': name,
'type': 'admin',
'protocol': 'ssh',
@@ -36,7 +46,11 @@ def migrate_admin_user_to_system_user(apps, schema_editor):
with transaction.atomic():
s = system_user_model(**kwargs)
s.save()
try:
s.save()
except IntegrityError:
s.id = None
s.save()
print(" Migrate admin user to system user: {} => {}".format(admin_user.name, s.name))
assets = admin_user.assets.all()
s.assets.set(assets)

View File

@@ -18,7 +18,7 @@ def migrate_old_authbook_to_history(apps, schema_editor):
print()
while True:
authbooks = authbook_model.objects.using(db_alias).filter(is_latest=False)[:20]
authbooks = authbook_model.objects.using(db_alias).filter(is_latest=False)[:1000]
if not authbooks:
break
historys = []

View File

@@ -333,7 +333,7 @@ class Asset(AbsConnectivity, ProtocolsMixin, NodesRelationMixin, OrgModelMixin):
'iconSkin': icon_skin,
'meta': {
'type': 'asset',
'asset': {
'data': {
'id': self.id,
'hostname': self.hostname,
'ip': self.ip,
@@ -345,6 +345,13 @@ class Asset(AbsConnectivity, ProtocolsMixin, NodesRelationMixin, OrgModelMixin):
tree_node = TreeNode(**data)
return tree_node
def get_all_systemusers(self):
from .user import SystemUser
system_user_ids = SystemUser.assets.through.objects.filter(asset=self)\
.values_list('systemuser_id', flat=True)
system_users = SystemUser.objects.filter(id__in=system_user_ids)
return system_users
class Meta:
unique_together = [('org_id', 'hostname')]
verbose_name = _("Asset")

View File

@@ -5,9 +5,12 @@ from django.db import models
from django.utils.translation import ugettext_lazy as _
from simple_history.models import HistoricalRecords
from common.utils import lazyproperty
from common.utils import lazyproperty, get_logger
from .base import BaseUser, AbsConnectivity
logger = get_logger(__name__)
__all__ = ['AuthBook']
@@ -16,7 +19,6 @@ class AuthBook(BaseUser, AbsConnectivity):
systemuser = models.ForeignKey('assets.SystemUser', on_delete=models.CASCADE, null=True, verbose_name=_("System user"))
version = models.IntegerField(default=1, verbose_name=_('Version'))
history = HistoricalRecords()
_systemuser_display = ''
auth_attrs = ['username', 'password', 'private_key', 'public_key']
@@ -64,8 +66,6 @@ class AuthBook(BaseUser, AbsConnectivity):
@lazyproperty
def systemuser_display(self):
if self._systemuser_display:
return self._systemuser_display
if not self.systemuser:
return ''
return str(self.systemuser)
@@ -96,6 +96,24 @@ class AuthBook(BaseUser, AbsConnectivity):
i.comment = 'Update triggered by account {}'.format(self.id)
i.save(update_fields=['password', 'private_key', 'public_key'])
def remove_asset_admin_user_if_need(self):
if not self.asset or not self.asset.admin_user:
return
if not self.systemuser.is_admin_user:
return
logger.debug('Remove asset admin user: {} {}'.format(self.asset, self.systemuser))
self.asset.admin_user = None
self.asset.save()
def update_asset_admin_user_if_need(self):
if not self.systemuser or not self.systemuser.is_admin_user:
return
if not self.asset or self.asset.admin_user == self.systemuser:
return
logger.debug('Update asset admin user: {} {}'.format(self.asset, self.systemuser))
self.asset.admin_user = self.systemuser
self.asset.save()
def __str__(self):
return self.smart_name

View File

@@ -67,7 +67,10 @@ class AuthMixin:
if self.public_key:
public_key = self.public_key
elif self.private_key:
public_key = ssh_pubkey_gen(private_key=self.private_key, password=self.password)
try:
public_key = ssh_pubkey_gen(private_key=self.private_key, password=self.password)
except IOError as e:
return str(e)
else:
return ''

View File

@@ -105,11 +105,11 @@ class CommandFilterRule(OrgModelMixin):
return '{} % {}'.format(self.type, self.content)
def create_command_confirm_ticket(self, run_command, session, cmd_filter_rule, org_id):
from tickets.const import TicketTypeChoices
from tickets.const import TicketType
from tickets.models import Ticket
data = {
'title': _('Command confirm') + ' ({})'.format(session.user),
'type': TicketTypeChoices.command_confirm,
'type': TicketType.command_confirm,
'meta': {
'apply_run_user': session.user,
'apply_run_asset': session.asset,
@@ -122,6 +122,6 @@ class CommandFilterRule(OrgModelMixin):
'org_id': org_id,
}
ticket = Ticket.objects.create(**data)
ticket.assignees.set(self.reviewers.all())
ticket.create_process_map_and_node(self.reviewers.all())
ticket.open(applicant=session.user_obj)
return ticket

View File

@@ -3,17 +3,19 @@
import socket
import uuid
import random
import re
from django.core.cache import cache
import paramiko
from django.db import models
from django.db.models import TextChoices
from django.utils.translation import ugettext_lazy as _
from common.utils.strings import no_special_chars
from common.utils import get_logger
from orgs.mixins.models import OrgModelMixin
from .base import BaseUser
logger = get_logger(__file__)
__all__ = ['Domain', 'Gateway']
@@ -40,10 +42,19 @@ class Domain(OrgModelMixin):
return self.gateway_set.filter(is_active=True)
def random_gateway(self):
return random.choice(self.gateways)
gateways = [gw for gw in self.gateways if gw.is_connective]
if gateways:
return random.choice(gateways)
else:
logger.warn(f'Gateway all bad. domain={self}, gateway_num={len(self.gateways)}.')
return random.choice(self.gateways)
class Gateway(BaseUser):
UNCONNECTIVE_KEY_TMPL = 'asset_unconnective_gateway_{}'
UNCONNECTIVE_SILENCE_PERIOD_KEY_TMPL = 'asset_unconnective_gateway_silence_period_{}'
UNCONNECTIVE_SILENCE_PERIOD_BEGIN_VALUE = 60 * 5
class Protocol(TextChoices):
ssh = 'ssh', 'SSH'
@@ -61,11 +72,40 @@ class Gateway(BaseUser):
unique_together = [('name', 'org_id')]
verbose_name = _("Gateway")
def set_unconnective(self):
unconnective_key = self.UNCONNECTIVE_KEY_TMPL.format(self.id)
unconnective_silence_period_key = self.UNCONNECTIVE_SILENCE_PERIOD_KEY_TMPL.format(self.id)
unconnective_silence_period = cache.get(unconnective_silence_period_key,
self.UNCONNECTIVE_SILENCE_PERIOD_BEGIN_VALUE)
cache.set(unconnective_silence_period_key, unconnective_silence_period * 2)
cache.set(unconnective_key, unconnective_silence_period, unconnective_silence_period)
def set_connective(self):
unconnective_key = self.UNCONNECTIVE_KEY_TMPL.format(self.id)
unconnective_silence_period_key = self.UNCONNECTIVE_SILENCE_PERIOD_KEY_TMPL.format(self.id)
cache.delete(unconnective_key)
cache.delete(unconnective_silence_period_key)
def get_is_unconnective(self):
unconnective_key = self.UNCONNECTIVE_KEY_TMPL.format(self.id)
return cache.get(unconnective_key, False)
@property
def is_connective(self):
return not self.get_is_unconnective()
@is_connective.setter
def is_connective(self, value):
if value:
self.set_connective()
else:
self.set_unconnective()
def test_connective(self, local_port=None):
if local_port is None:
local_port = self.port
if self.password and not no_special_chars(self.password):
return False, _("Password should not contains special characters")
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
@@ -80,9 +120,19 @@ class Gateway(BaseUser):
except(paramiko.AuthenticationException,
paramiko.BadAuthenticationType,
paramiko.SSHException,
paramiko.ChannelException,
paramiko.ssh_exception.NoValidConnectionsError,
socket.gaierror) as e:
return False, str(e)
err = str(e)
if err.startswith('[Errno None] Unable to connect to port'):
err = _('Unable to connect to port {port} on {ip}')
err = err.format(port=self.port, ip=self.ip)
elif err == 'Authentication failed.':
err = _('Authentication failed')
elif err == 'Connect failed':
err = _('Connect failed')
self.is_connective = False
return False, err
try:
sock = proxy.get_transport().open_channel(
@@ -94,9 +144,18 @@ class Gateway(BaseUser):
key_filename=self.private_key_file,
sock=sock,
timeout=5)
except (paramiko.SSHException, paramiko.ssh_exception.SSHException,
paramiko.AuthenticationException, TimeoutError) as e:
return False, str(e)
except (paramiko.SSHException,
paramiko.ssh_exception.SSHException,
paramiko.ChannelException,
paramiko.AuthenticationException,
TimeoutError) as e:
err = getattr(e, 'text', str(e))
if err == 'Connect failed':
err = _('Connect failed')
self.is_connective = False
return False, err
finally:
client.close()
self.is_connective = True
return True, None

View File

@@ -608,7 +608,7 @@ class Node(OrgModelMixin, SomeNodesMixin, FamilyMixin, NodeAssetsMixin):
'isParent': True,
'open': self.is_org_root(),
'meta': {
'node': {
'data': {
"id": self.id,
"name": self.name,
"value": self.value,

View File

@@ -60,10 +60,10 @@ class ProtocolMixin:
@classmethod
def get_protocol_by_application_type(cls, app_type):
from applications.const import ApplicationTypeChoices
from applications.const import AppType
if app_type in cls.APPLICATION_CATEGORY_PROTOCOLS:
protocol = app_type
elif app_type in ApplicationTypeChoices.remote_app_types():
elif app_type in AppType.remote_app_types():
protocol = cls.Protocol.rdp
else:
protocol = None
@@ -73,6 +73,10 @@ class ProtocolMixin:
def can_perm_to_asset(self):
return self.protocol in self.ASSET_CATEGORY_PROTOCOLS
@property
def is_asset_protocol(self):
return self.protocol in self.ASSET_CATEGORY_PROTOCOLS
class AuthMixin:
username_same_with_user: bool

View File

@@ -5,6 +5,7 @@ from assets.models import AuthBook
from orgs.mixins.serializers import BulkOrgResourceModelSerializer
from .base import AuthSerializerMixin
from .utils import validate_password_contains_left_double_curly_bracket
class AccountSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
@@ -21,10 +22,15 @@ class AccountSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
fields = fields_small + fields_fk
extra_kwargs = {
'username': {'required': True},
'password': {'write_only': True},
'password': {
'write_only': True,
"validators": [validate_password_contains_left_double_curly_bracket]
},
'private_key': {'write_only': True},
'public_key': {'write_only': True},
'systemuser_display': {'label': _('System user display')}
}
ref_name = 'AssetAccountSerializer'
@classmethod
def setup_eager_loading(cls, queryset):
@@ -43,6 +49,7 @@ class AccountSecretSerializer(AccountSerializer):
'password': {'write_only': False},
'private_key': {'write_only': False},
'public_key': {'write_only': False},
'systemuser_display': {'label': _('System user display')}
}

View File

@@ -8,16 +8,16 @@ from orgs.mixins.serializers import BulkOrgResourceModelSerializer
from ..models import Asset, Node, Platform, SystemUser
__all__ = [
'AssetSerializer', 'AssetSimpleSerializer',
'AssetSerializer', 'AssetSimpleSerializer', 'MiniAssetSerializer',
'ProtocolsField', 'PlatformSerializer',
'AssetTaskSerializer',
'AssetTaskSerializer', 'AssetsTaskSerializer', 'ProtocolsField'
]
class ProtocolField(serializers.RegexField):
protocols = '|'.join(dict(Asset.Protocol.choices).keys())
default_error_messages = {
'invalid': _('Protocol format should {}/{}'.format(protocols, '1-65535'))
'invalid': _('Protocol format should {}/{}').format(protocols, '1-65535')
}
regex = r'^(%s)/(\d{1,5})$' % protocols
@@ -69,15 +69,19 @@ class AssetSerializer(BulkOrgResourceModelSerializer):
"""
资产的数据结构
"""
class Meta:
model = Asset
fields_mini = ['id', 'hostname', 'ip']
fields_mini = ['id', 'hostname', 'ip', 'platform', 'protocols']
fields_small = fields_mini + [
'protocol', 'port', 'protocols', 'is_active', 'public_ip',
'comment',
]
hardware_fields = [
'number', 'vendor', 'model', 'sn', 'cpu_model', 'cpu_count',
'cpu_cores', 'cpu_vcpus', 'memory', 'disk_total', 'disk_info',
'os', 'os_version', 'os_arch', 'hostname_raw', 'comment',
'hardware_info', 'connectivity', 'date_verified'
'os', 'os_version', 'os_arch', 'hostname_raw', 'hardware_info',
'connectivity', 'date_verified'
]
fields_fk = [
'domain', 'domain_display', 'platform', 'admin_user', 'admin_user_display'
@@ -88,15 +92,16 @@ class AssetSerializer(BulkOrgResourceModelSerializer):
read_only_fields = [
'created_by', 'date_created',
]
fields = fields_small + fields_fk + fields_m2m + read_only_fields
fields = fields_small + hardware_fields + fields_fk + fields_m2m + read_only_fields
extra_kwargs = {
extra_kwargs = {k: {'read_only': True} for k in hardware_fields}
extra_kwargs.update({
'protocol': {'write_only': True},
'port': {'write_only': True},
'hardware_info': {'label': _('Hardware info')},
'org_name': {'label': _('Org name')},
'admin_user_display': {'label': _('Admin user display')}
}
'hardware_info': {'label': _('Hardware info'), 'read_only': True},
'org_name': {'label': _('Org name'), 'read_only': True},
'admin_user_display': {'label': _('Admin user display'), 'read_only': True},
})
def get_fields(self):
fields = super().get_fields()
@@ -157,6 +162,12 @@ class AssetSerializer(BulkOrgResourceModelSerializer):
return instance
class MiniAssetSerializer(serializers.ModelSerializer):
class Meta:
model = Asset
fields = AssetSerializer.Meta.fields_mini
class PlatformSerializer(serializers.ModelSerializer):
meta = serializers.DictField(required=False, allow_null=True, label=_('Meta'))
@@ -177,13 +188,12 @@ class PlatformSerializer(serializers.ModelSerializer):
class AssetSimpleSerializer(serializers.ModelSerializer):
class Meta:
model = Asset
fields = ['id', 'hostname', 'ip', 'port', 'connectivity', 'date_verified']
class AssetTaskSerializer(serializers.Serializer):
class AssetsTaskSerializer(serializers.Serializer):
ACTION_CHOICES = (
('refresh', 'refresh'),
('test', 'test'),
@@ -193,3 +203,17 @@ class AssetTaskSerializer(serializers.Serializer):
assets = serializers.PrimaryKeyRelatedField(
queryset=Asset.objects, required=False, allow_empty=True, many=True
)
class AssetTaskSerializer(AssetsTaskSerializer):
ACTION_CHOICES = tuple(list(AssetsTaskSerializer.ACTION_CHOICES) + [
('push_system_user', 'push_system_user'),
('test_system_user', 'test_system_user')
])
action = serializers.ChoiceField(choices=ACTION_CHOICES, write_only=True)
asset = serializers.PrimaryKeyRelatedField(
queryset=Asset.objects, required=False, allow_empty=True, many=False
)
system_users = serializers.PrimaryKeyRelatedField(
queryset=SystemUser.objects, required=False, allow_empty=True, many=True
)

View File

@@ -4,14 +4,13 @@ from rest_framework import serializers
from django.utils.translation import ugettext_lazy as _
from orgs.mixins.serializers import BulkOrgResourceModelSerializer
from common.validators import NoSpecialChars
from ..models import Domain, Gateway
from .base import AuthSerializerMixin
class DomainSerializer(BulkOrgResourceModelSerializer):
asset_count = serializers.SerializerMethodField(label=_('Assets count'))
application_count = serializers.SerializerMethodField(label=_('Applications count'))
asset_count = serializers.SerializerMethodField(label=_('Assets amount'))
application_count = serializers.SerializerMethodField(label=_('Applications amount'))
gateway_count = serializers.SerializerMethodField(label=_('Gateways count'))
class Meta:
@@ -43,6 +42,8 @@ class DomainSerializer(BulkOrgResourceModelSerializer):
class GatewaySerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
is_connective = serializers.BooleanField(required=False)
class Meta:
model = Gateway
fields_mini = ['id', 'name']
@@ -51,14 +52,14 @@ class GatewaySerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
]
fields_small = fields_mini + fields_write_only + [
'username', 'ip', 'port', 'protocol',
'is_active',
'is_active', 'is_connective',
'date_created', 'date_updated',
'created_by', 'comment',
]
fields_fk = ['domain']
fields = fields_small + fields_fk
extra_kwargs = {
'password': {'write_only': True, 'validators': [NoSpecialChars()]},
'password': {'write_only': True},
'private_key': {"write_only": True},
'public_key': {"write_only": True},
}
@@ -67,7 +68,7 @@ class GatewaySerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
class GatewayWithAuthSerializer(GatewaySerializer):
class Meta(GatewaySerializer.Meta):
extra_kwargs = {
'password': {'write_only': False, 'validators': [NoSpecialChars()]},
'password': {'write_only': False},
'private_key': {"write_only": False},
'public_key': {"write_only": False},
}

View File

@@ -6,14 +6,15 @@ from common.mixins.serializers import BulkSerializerMixin
from common.utils import ssh_pubkey_gen
from orgs.mixins.serializers import BulkOrgResourceModelSerializer
from ..models import SystemUser, Asset
from .utils import validate_password_contains_left_double_curly_bracket
from .base import AuthSerializerMixin
__all__ = [
'SystemUserSerializer',
'SystemUserSerializer', 'MiniSystemUserSerializer',
'SystemUserSimpleSerializer', 'SystemUserAssetRelationSerializer',
'SystemUserNodeRelationSerializer', 'SystemUserTaskSerializer',
'SystemUserUserRelationSerializer', 'SystemUserWithAuthInfoSerializer',
'SystemUserTempAuthSerializer',
'SystemUserTempAuthSerializer', 'RelationMixin',
]
@@ -30,17 +31,20 @@ class SystemUserSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
fields_mini = ['id', 'name', 'username']
fields_write_only = ['password', 'public_key', 'private_key']
fields_small = fields_mini + fields_write_only + [
'type', 'type_display', 'protocol', 'login_mode', 'login_mode_display',
'priority', 'sudo', 'shell', 'sftp_root', 'token', 'ssh_key_fingerprint',
'home', 'system_groups', 'ad_domain',
'token', 'ssh_key_fingerprint',
'type', 'type_display', 'protocol', 'is_asset_protocol',
'login_mode', 'login_mode_display', 'priority',
'sudo', 'shell', 'sftp_root', 'home', 'system_groups', 'ad_domain',
'username_same_with_user', 'auto_push', 'auto_generate_key',
'date_created', 'date_updated',
'comment', 'created_by',
'date_created', 'date_updated', 'comment', 'created_by',
]
fields_m2m = ['cmd_filters', 'assets_amount']
fields_m2m = ['cmd_filters', 'assets_amount', 'nodes']
fields = fields_small + fields_m2m
extra_kwargs = {
'password': {"write_only": True},
'password': {
"write_only": True,
"validators": [validate_password_contains_left_double_curly_bracket]
},
'public_key': {"write_only": True},
'private_key': {"write_only": True},
'token': {"write_only": True},
@@ -49,6 +53,7 @@ class SystemUserSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
'login_mode_display': {'label': _('Login mode display')},
'created_by': {'read_only': True},
'ad_domain': {'required': False, 'allow_blank': True, 'label': _('Ad domain')},
'is_asset_protocol': {'label': _('Is asset protocol')}
}
def validate_auto_push(self, value):
@@ -180,6 +185,12 @@ class SystemUserSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
return queryset
class MiniSystemUserSerializer(serializers.ModelSerializer):
class Meta:
model = SystemUser
fields = SystemUserSerializer.Meta.fields_mini
class SystemUserWithAuthInfoSerializer(SystemUserSerializer):
class Meta(SystemUserSerializer.Meta):
fields_mini = ['id', 'name', 'username']
@@ -203,6 +214,7 @@ class SystemUserSimpleSerializer(serializers.ModelSerializer):
"""
系统用户最基本信息的数据结构
"""
class Meta:
model = SystemUser
fields = ('id', 'name', 'username')

View File

@@ -0,0 +1,9 @@
from django.utils.translation import ugettext_lazy as _
from rest_framework import serializers
def validate_password_contains_left_double_curly_bracket(password):
# validate password contains left double curly bracket
# check password not contains `{{`
if '{{' in password:
raise serializers.ValidationError(_('Password can not contains `{{` '))

View File

@@ -1,7 +1,7 @@
from django.dispatch import receiver
from django.apps import apps
from simple_history.signals import pre_create_historical_record
from django.db.models.signals import post_save, pre_save
from django.db.models.signals import post_save, pre_save, pre_delete
from common.utils import get_logger
from ..models import AuthBook, SystemUser
@@ -28,15 +28,20 @@ def pre_create_historical_record_callback(sender, history_instance=None, **kwarg
setattr(history_instance, attr, system_user_attr_value)
@receiver(pre_delete, sender=AuthBook)
def on_authbook_post_delete(sender, instance, **kwargs):
instance.remove_asset_admin_user_if_need()
@receiver(post_save, sender=AuthBook)
def on_authbook_post_create(sender, instance, **kwargs):
if not instance.systemuser:
instance.sync_to_system_user_account()
instance.sync_to_system_user_account()
instance.update_asset_admin_user_if_need()
@receiver(pre_save, sender=AuthBook)
def on_authbook_pre_create(sender, instance, **kwargs):
# 升级版本号
instance.version = instance.history.all().count() + 1
instance.version += 1
# 即使在 root 组织也不怕
instance.org_id = instance.asset.org_id

View File

@@ -48,7 +48,6 @@ def expire_node_assets_mapping_for_memory(org_id):
Node.expire_node_all_asset_ids_mapping_from_cache(root_org_id)
node_assets_mapping_for_memory_pub_sub.publish(org_id)
node_assets_mapping_for_memory_pub_sub.publish(root_org_id)
@receiver(post_save, sender=Node)
@@ -86,7 +85,9 @@ def subscribe_node_assets_mapping_expire(sender, **kwargs):
if message["type"] != "message":
continue
org_id = message['data'].decode()
root_org_id = Organization.ROOT_ID
Node.expire_node_all_asset_ids_mapping_from_memory(org_id)
Node.expire_node_all_asset_ids_mapping_from_memory(root_org_id)
logger.debug(
"Expire node assets id mapping from memory of org={}, pid={}"
"".format(str(org_id), os.getpid())

View File

@@ -131,8 +131,8 @@ def on_system_user_groups_change(instance, action, pk_set, reverse, **kwargs):
@on_transaction_commit
def on_system_user_update(instance: SystemUser, created, **kwargs):
"""
当系统用户更新时,可能更新了钥,用户名等,这时要自动推送系统用户到资产上,
其实应该当 用户名,密码,钥 sudo等更新时再推送这里偷个懒,
当系统用户更新时,可能更新了钥,用户名等,这时要自动推送系统用户到资产上,
其实应该当 用户名,密码,钥 sudo等更新时再推送这里偷个懒,
这里直接取了 instance.assets 因为nodes和系统用户发生变化时会自动将nodes下的资产
关联到上面
"""

View File

@@ -60,9 +60,12 @@ def parse_windows_result_to_users(result):
task_result.pop()
for line in task_result:
user = space.split(line)
if user[0]:
users[user[0]] = {}
username_list = space.split(line)
# such as: ['Admini', 'appadm', 'DefaultAccount', '']
for username in username_list:
if not username:
continue
users[username] = {}
return users

View File

@@ -4,7 +4,7 @@ from itertools import groupby
from celery import shared_task
from common.db.utils import get_object_if_need, get_objects
from django.utils.translation import ugettext as _
from django.db.models import Empty
from django.db.models import Empty, Q
from common.utils import encrypt_password, get_logger
from assets.models import SystemUser, Asset, AuthBook
@@ -17,6 +17,7 @@ logger = get_logger(__file__)
__all__ = [
'push_system_user_util', 'push_system_user_to_assets',
'push_system_user_to_assets_manual', 'push_system_user_a_asset_manual',
'push_system_users_a_asset'
]
@@ -238,9 +239,12 @@ def push_system_user_util(system_user, assets, task_name, username=None):
no_special_auth = []
special_auth_set = set()
auth_books = AuthBook.objects.filter(username__in=usernames, asset_id__in=asset_ids)
auth_books = AuthBook.objects.filter(asset_id__in=asset_ids).filter(
Q(username__in=usernames) | Q(systemuser__username__in=usernames)
).prefetch_related('systemuser')
for auth_book in auth_books:
auth_book.load_auth()
special_auth_set.add((auth_book.username, auth_book.asset_id))
for _username in usernames:
@@ -280,14 +284,21 @@ def push_system_user_a_asset_manual(system_user, asset, username=None):
"""
将系统用户推送到一个资产上
"""
if username is None:
username = system_user.username
# if username is None:
# username = system_user.username
task_name = _("Push system users to asset: {}({}) => {}").format(
system_user.name, username, asset
)
return push_system_user_util(system_user, [asset], task_name=task_name, username=username)
@shared_task(queue="ansible")
@tmp_to_root_org()
def push_system_users_a_asset(system_users, asset):
for system_user in system_users:
push_system_user_a_asset_manual(system_user, asset)
@shared_task(queue="ansible")
@tmp_to_root_org()
def push_system_user_to_assets(system_user_id, asset_ids, username=None):

View File

@@ -18,6 +18,7 @@ logger = get_logger(__name__)
__all__ = [
'test_system_user_connectivity_util', 'test_system_user_connectivity_manual',
'test_system_user_connectivity_period', 'test_system_user_connectivity_a_asset',
'test_system_users_connectivity_a_asset'
]
@@ -131,6 +132,12 @@ def test_system_user_connectivity_a_asset(system_user, asset):
test_system_user_connectivity_util(system_user, [asset], task_name)
@shared_task(queue="ansible")
def test_system_users_connectivity_a_asset(system_users, asset):
for system_user in system_users:
test_system_user_connectivity_a_asset(system_user, asset)
@shared_task(queue="ansible")
def test_system_user_connectivity_period():
if not const.PERIOD_TASK_ENABLED:

View File

@@ -5,7 +5,7 @@ from django.db.models import Q
from django.utils.translation import ugettext_lazy as _
from django.utils import timezone
from orgs.mixins.models import OrgModelMixin
from orgs.mixins.models import OrgModelMixin, Organization
from orgs.utils import current_org
__all__ = [
@@ -63,6 +63,11 @@ class OperateLog(OrgModelMixin):
def __str__(self):
return "<{}> {} <{}>".format(self.user, self.action, self.resource)
def save(self, *args, **kwargs):
if current_org.is_root() and not self.org_id:
self.org_id = Organization.ROOT_ID
return super(OperateLog, self).save(*args, **kwargs)
class PasswordChangeLog(models.Model):
id = models.UUIDField(default=uuid.uuid4, primary_key=True)

View File

@@ -1,6 +1,8 @@
# -*- coding: utf-8 -*-
#
from django.db.models.signals import post_save, post_delete
from django.db.models.signals import (
post_save, post_delete, m2m_changed, pre_delete
)
from django.dispatch import receiver
from django.conf import settings
from django.db import transaction
@@ -11,6 +13,8 @@ from django.utils.translation import ugettext_lazy as _
from rest_framework.renderers import JSONRenderer
from rest_framework.request import Request
from assets.models import Asset, SystemUser
from common.const.signals import POST_ADD, POST_REMOVE, POST_CLEAR
from jumpserver.utils import current_request
from common.utils import get_request_ip, get_logger, get_syslogger
from users.models import User
@@ -20,6 +24,9 @@ from terminal.models import Session, Command
from common.utils.encode import model_to_json
from .utils import write_login_log
from . import models
from .models import OperateLog
from orgs.utils import current_org
from perms.models import AssetPermission, ApplicationPermission
logger = get_logger(__name__)
sys_logger = get_syslogger(__name__)
@@ -30,10 +37,10 @@ MODELS_NEED_RECORD = (
# users
'User', 'UserGroup',
# acls
'LoginACL', 'LoginAssetACL',
'LoginACL', 'LoginAssetACL', 'LoginConfirmSetting',
# assets
'Asset', 'Node', 'AdminUser', 'SystemUser', 'Domain', 'Gateway', 'CommandFilterRule',
'CommandFilter', 'Platform',
'CommandFilter', 'Platform', 'AuthBook',
# applications
'Application',
# orgs
@@ -90,6 +97,124 @@ def create_operate_log(action, sender, resource):
logger.error("Create operate log error: {}".format(e))
M2M_NEED_RECORD = {
'OrganizationMember': (
_('User and Organization'),
_('{User} JOINED {Organization}'),
_('{User} LEFT {Organization}')
),
User.groups.through._meta.object_name: (
_('User and Group'),
_('{User} JOINED {UserGroup}'),
_('{User} LEFT {UserGroup}')
),
SystemUser.assets.through._meta.object_name: (
_('Asset and SystemUser'),
_('{Asset} ADD {SystemUser}'),
_('{Asset} REMOVE {SystemUser}')
),
Asset.nodes.through._meta.object_name: (
_('Node and Asset'),
_('{Node} ADD {Asset}'),
_('{Node} REMOVE {Asset}')
),
AssetPermission.users.through._meta.object_name: (
_('User asset permissions'),
_('{AssetPermission} ADD {User}'),
_('{AssetPermission} REMOVE {User}'),
),
AssetPermission.user_groups.through._meta.object_name: (
_('User group asset permissions'),
_('{AssetPermission} ADD {UserGroup}'),
_('{AssetPermission} REMOVE {UserGroup}'),
),
AssetPermission.assets.through._meta.object_name: (
_('Asset permission'),
_('{AssetPermission} ADD {Asset}'),
_('{AssetPermission} REMOVE {Asset}'),
),
AssetPermission.nodes.through._meta.object_name: (
_('Node permission'),
_('{AssetPermission} ADD {Node}'),
_('{AssetPermission} REMOVE {Node}'),
),
AssetPermission.system_users.through._meta.object_name: (
_('Asset permission and SystemUser'),
_('{AssetPermission} ADD {SystemUser}'),
_('{AssetPermission} REMOVE {SystemUser}'),
),
ApplicationPermission.users.through._meta.object_name: (
_('User application permissions'),
_('{ApplicationPermission} ADD {User}'),
_('{ApplicationPermission} REMOVE {User}'),
),
ApplicationPermission.user_groups.through._meta.object_name: (
_('User group application permissions'),
_('{ApplicationPermission} ADD {UserGroup}'),
_('{ApplicationPermission} REMOVE {UserGroup}'),
),
ApplicationPermission.applications.through._meta.object_name: (
_('Application permission'),
_('{ApplicationPermission} ADD {Application}'),
_('{ApplicationPermission} REMOVE {Application}'),
),
ApplicationPermission.system_users.through._meta.object_name: (
_('Application permission and SystemUser'),
_('{ApplicationPermission} ADD {SystemUser}'),
_('{ApplicationPermission} REMOVE {SystemUser}'),
),
}
M2M_ACTION = {
POST_ADD: 'add',
POST_REMOVE: 'remove',
POST_CLEAR: 'remove',
}
@receiver(m2m_changed)
def on_m2m_changed(sender, action, instance, reverse, model, pk_set, **kwargs):
if action not in M2M_ACTION:
return
user = current_request.user if current_request else None
if not user or not user.is_authenticated:
return
sender_name = sender._meta.object_name
if sender_name in M2M_NEED_RECORD:
action = M2M_ACTION[action]
org_id = current_org.id
remote_addr = get_request_ip(current_request)
user = str(user)
resource_type, resource_tmpl_add, resource_tmpl_remove = M2M_NEED_RECORD[sender_name]
if action == 'add':
resource_tmpl = resource_tmpl_add
elif action == 'remove':
resource_tmpl = resource_tmpl_remove
to_create = []
objs = model.objects.filter(pk__in=pk_set)
instance_name = instance._meta.object_name
instance_value = str(instance)
model_name = model._meta.object_name
for obj in objs:
resource = resource_tmpl.format(**{
instance_name: instance_value,
model_name: str(obj)
})[:128] # `resource` 字段只有 128 个字符长 😔
to_create.append(OperateLog(
user=user, action=action, resource_type=resource_type,
resource=resource, remote_addr=remote_addr, org_id=org_id
))
OperateLog.objects.bulk_create(to_create)
@receiver(post_save)
def on_object_created_or_update(sender, instance=None, created=False, update_fields=None, **kwargs):
# last_login 改变是最后登录日期, 每次登录都会改变
@@ -103,7 +228,7 @@ def on_object_created_or_update(sender, instance=None, created=False, update_fie
create_operate_log(action, sender, instance)
@receiver(post_delete)
@receiver(pre_delete)
def on_object_delete(sender, instance=None, **kwargs):
create_operate_log(models.OperateLog.ACTION_DELETE, sender, instance)

View File

@@ -5,14 +5,12 @@ from django.utils import timezone
from celery import shared_task
from ops.celery.decorator import (
register_as_period_task, after_app_shutdown_clean_periodic
register_as_period_task
)
from .models import UserLoginLog, OperateLog
from common.utils import get_log_keep_day
@shared_task
@after_app_shutdown_clean_periodic
def clean_login_log_period():
now = timezone.now()
days = get_log_keep_day('LOGIN_LOG_KEEP_DAYS')
@@ -20,8 +18,6 @@ def clean_login_log_period():
UserLoginLog.objects.filter(datetime__lt=expired_day).delete()
@shared_task
@after_app_shutdown_clean_periodic
def clean_operation_log_period():
now = timezone.now()
days = get_log_keep_day('OPERATE_LOG_KEEP_DAYS')
@@ -29,7 +25,6 @@ def clean_operation_log_period():
OperateLog.objects.filter(datetime__lt=expired_day).delete()
@shared_task
def clean_ftp_log_period():
now = timezone.now()
days = get_log_keep_day('FTP_LOG_KEEP_DAYS')

View File

@@ -9,4 +9,5 @@ from .login_confirm import *
from .sso import *
from .wecom import *
from .dingtalk import *
from .feishu import *
from .password import *

View File

@@ -1,6 +1,9 @@
# -*- coding: utf-8 -*-
#
import urllib.parse
import json
import base64
from typing import Callable
from django.conf import settings
from django.core.cache import cache
@@ -8,6 +11,7 @@ from django.shortcuts import get_object_or_404
from django.http import HttpResponse
from django.utils.translation import ugettext as _
from rest_framework.response import Response
from rest_framework.request import Request
from rest_framework.viewsets import GenericViewSet
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied
@@ -17,90 +21,41 @@ from authentication.signals import post_auth_failed, post_auth_success
from common.utils import get_logger, random_string
from common.drf.api import SerializerMixin
from common.permissions import IsSuperUserOrAppUser, IsValidUser, IsSuperUser
from orgs.mixins.api import RootOrgViewMixin
from common.http import is_true
from assets.models import SystemUser
from ..serializers import (
ConnectionTokenSerializer, ConnectionTokenSecretSerializer,
RDPFileSerializer
)
logger = get_logger(__name__)
__all__ = ['UserConnectionTokenViewSet']
class UserConnectionTokenViewSet(RootOrgViewMixin, SerializerMixin, GenericViewSet):
permission_classes = (IsSuperUserOrAppUser,)
serializer_classes = {
'default': ConnectionTokenSerializer,
'get_secret_detail': ConnectionTokenSecretSerializer,
'get_rdp_file': RDPFileSerializer
}
CACHE_KEY_PREFIX = 'CONNECTION_TOKEN_{}'
@staticmethod
def check_resource_permission(user, asset, application, system_user):
from perms.utils.asset import has_asset_system_permission
from perms.utils.application import has_application_system_permission
if asset and not has_asset_system_permission(user, asset, system_user):
error = f'User not has this asset and system user permission: ' \
f'user={user.id} system_user={system_user.id} asset={asset.id}'
raise PermissionDenied(error)
if application and not has_application_system_permission(user, application, system_user):
error = f'User not has this application and system user permission: ' \
f'user={user.id} system_user={system_user.id} application={application.id}'
raise PermissionDenied(error)
return True
def create_token(self, user, asset, application, system_user, ttl=5*60):
if not self.request.user.is_superuser and user != self.request.user:
raise PermissionDenied('Only super user can create user token')
self.check_resource_permission(user, asset, application, system_user)
token = random_string(36)
value = {
'user': str(user.id),
'username': user.username,
'system_user': str(system_user.id),
'system_user_name': system_user.name
}
if asset:
value.update({
'type': 'asset',
'asset': str(asset.id),
'hostname': asset.hostname,
})
elif application:
value.update({
'type': 'application',
'application': application.id,
'application_name': str(application)
})
key = self.CACHE_KEY_PREFIX.format(token)
cache.set(key, value, timeout=ttl)
return token
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
class ClientProtocolMixin:
request: Request
get_serializer: Callable
create_token: Callable
def get_request_resource(self, serializer):
asset = serializer.validated_data.get('asset')
application = serializer.validated_data.get('application')
system_user = serializer.validated_data['system_user']
user = serializer.validated_data.get('user')
token = self.create_token(user, asset, application, system_user)
return Response({"token": token}, status=201)
@action(methods=['POST', 'GET'], detail=False, url_path='rdp/file', permission_classes=[IsValidUser])
def get_rdp_file(self, request, *args, **kwargs):
user = serializer.validated_data.get('user')
if not user or not self.request.user.is_superuser:
user = self.request.user
return asset, application, system_user, user
def get_rdp_file_content(self, serializer):
options = {
'full address:s': '',
'username:s': '',
'screen mode id:i': '0',
# 'screen mode id:i': '1',
# 'desktopwidth:i': '1280',
# 'desktopheight:i': '800',
'use multimon:i': '1',
'use multimon:i': '0',
'session bpp:i': '32',
'audiomode:i': '0',
'disable wallpaper:i': '0',
@@ -120,29 +75,26 @@ class UserConnectionTokenViewSet(RootOrgViewMixin, SerializerMixin, GenericViewS
'bookmarktype:i': '3',
'use redirection server name:i': '0',
'smart sizing:i': '0',
#'drivestoredirect:s': '*',
# 'domain:s': ''
# 'alternate shell:s:': '||MySQLWorkbench',
# 'remoteapplicationname:s': 'Firefox',
# 'remoteapplicationcmdline:s': '',
}
if self.request.method == 'GET':
data = self.request.query_params
else:
data = request.data
serializer = self.get_serializer(data=data)
serializer.is_valid(raise_exception=True)
asset = serializer.validated_data.get('asset')
application = serializer.validated_data.get('application')
system_user = serializer.validated_data['system_user']
height = serializer.validated_data.get('height')
width = serializer.validated_data.get('width')
user = request.user
asset, application, system_user, user = self.get_request_resource(serializer)
height = self.request.query_params.get('height')
width = self.request.query_params.get('width')
full_screen = is_true(self.request.query_params.get('full_screen'))
drives_redirect = is_true(self.request.query_params.get('drives_redirect'))
token = self.create_token(user, asset, application, system_user)
if drives_redirect:
options['drivestoredirect:s'] = '*'
options['screen mode id:i'] = '2' if full_screen else '1'
address = settings.TERMINAL_RDP_ADDR
if not address or address == 'localhost:3389':
address = request.get_host().split(':')[0] + ':3389'
address = self.request.get_host().split(':')[0] + ':3389'
options['full address:s'] = address
options['username:s'] = '{}|{}'.format(user.username, token)
if system_user.ad_domain:
@@ -152,21 +104,73 @@ class UserConnectionTokenViewSet(RootOrgViewMixin, SerializerMixin, GenericViewS
options['desktopheight:i'] = height
else:
options['smart sizing:i'] = '1'
data = ''
content = ''
for k, v in options.items():
data += f'{k}:{v}\n'
content += f'{k}:{v}\n'
if asset:
name = asset.hostname
elif application:
name = application.name
else:
name = '*'
return name, content
@action(methods=['POST', 'GET'], detail=False, url_path='rdp/file', permission_classes=[IsValidUser])
def get_rdp_file(self, request, *args, **kwargs):
if self.request.method == 'GET':
data = self.request.query_params
else:
data = self.request.data
serializer = self.get_serializer(data=data)
serializer.is_valid(raise_exception=True)
name, data = self.get_rdp_file_content(serializer)
response = HttpResponse(data, content_type='application/octet-stream')
filename = "{}-{}-jumpserver.rdp".format(user.username, name)
filename = "{}-{}-jumpserver.rdp".format(self.request.user.username, name)
filename = urllib.parse.quote(filename)
response['Content-Disposition'] = 'attachment; filename*=UTF-8\'\'%s' % filename
return response
def get_valid_serializer(self):
if self.request.method == 'GET':
data = self.request.query_params
else:
data = self.request.data
serializer = self.get_serializer(data=data)
serializer.is_valid(raise_exception=True)
return serializer
def get_client_protocol_data(self, serializer):
asset, application, system_user, user = self.get_request_resource(serializer)
protocol = system_user.protocol
if protocol == 'rdp':
name, config = self.get_rdp_file_content(serializer)
elif protocol == 'vnc':
raise HttpResponse(status=404, data={"error": "VNC not support"})
else:
config = 'ssh://system_user@asset@user@jumpserver-ssh'
data = {
"protocol": system_user.protocol,
"username": user.username,
"config": config
}
return data
@action(methods=['POST', 'GET'], detail=False, url_path='client-url', permission_classes=[IsValidUser])
def get_client_protocol_url(self, request, *args, **kwargs):
serializer = self.get_valid_serializer()
protocol_data = self.get_client_protocol_data(serializer)
protocol_data = base64.b64encode(json.dumps(protocol_data).encode()).decode()
data = {
'url': 'jms://{}'.format(protocol_data),
}
return Response(data=data)
class SecretDetailMixin:
valid_token: Callable
request: Request
get_serializer: Callable
@staticmethod
def _get_application_secret_detail(application):
from perms.models import Action
@@ -212,6 +216,100 @@ class UserConnectionTokenViewSet(RootOrgViewMixin, SerializerMixin, GenericViewS
'actions': actions,
}
@action(methods=['POST'], detail=False, permission_classes=[IsSuperUserOrAppUser], url_path='secret-info/detail')
def get_secret_detail(self, request, *args, **kwargs):
token = request.data.get('token', '')
try:
value, user, system_user, asset, app, expired_at = self.valid_token(token)
except serializers.ValidationError as e:
post_auth_failed.send(
sender=self.__class__, username='', request=self.request,
reason=_('Invalid token')
)
raise e
data = dict(user=user, system_user=system_user, expired_at=expired_at)
if asset:
asset_detail = self._get_asset_secret_detail(asset, user=user, system_user=system_user)
system_user.load_asset_more_auth(asset.id, user.username, user.id)
data['type'] = 'asset'
data.update(asset_detail)
else:
app_detail = self._get_application_secret_detail(app)
system_user.load_app_more_auth(app.id, user.id)
data['type'] = 'application'
data.update(app_detail)
self.request.session['auth_backend'] = settings.AUTH_BACKEND_AUTH_TOKEN
post_auth_success.send(sender=self.__class__, user=user, request=self.request, login_type='T')
serializer = self.get_serializer(data)
return Response(data=serializer.data, status=200)
class UserConnectionTokenViewSet(
RootOrgViewMixin, SerializerMixin, ClientProtocolMixin,
SecretDetailMixin, GenericViewSet
):
permission_classes = (IsSuperUserOrAppUser,)
serializer_classes = {
'default': ConnectionTokenSerializer,
'get_secret_detail': ConnectionTokenSecretSerializer,
}
CACHE_KEY_PREFIX = 'CONNECTION_TOKEN_{}'
@staticmethod
def check_resource_permission(user, asset, application, system_user):
from perms.utils.asset import has_asset_system_permission
from perms.utils.application import has_application_system_permission
if asset and not has_asset_system_permission(user, asset, system_user):
error = f'User not has this asset and system user permission: ' \
f'user={user.id} system_user={system_user.id} asset={asset.id}'
raise PermissionDenied(error)
if application and not has_application_system_permission(user, application, system_user):
error = f'User not has this application and system user permission: ' \
f'user={user.id} system_user={system_user.id} application={application.id}'
raise PermissionDenied(error)
return True
def create_token(self, user, asset, application, system_user, ttl=5 * 60):
if not self.request.user.is_superuser and user != self.request.user:
raise PermissionDenied('Only super user can create user token')
self.check_resource_permission(user, asset, application, system_user)
token = random_string(36)
value = {
'user': str(user.id),
'username': user.username,
'system_user': str(system_user.id),
'system_user_name': system_user.name
}
if asset:
value.update({
'type': 'asset',
'asset': str(asset.id),
'hostname': asset.hostname,
})
elif application:
value.update({
'type': 'application',
'application': application.id,
'application_name': str(application)
})
key = self.CACHE_KEY_PREFIX.format(token)
cache.set(key, value, timeout=ttl)
return token
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
asset, application, system_user, user = self.get_request_resource(serializer)
token = self.create_token(user, asset, application, system_user)
return Response({"token": token}, status=201)
def valid_token(self, token):
from users.models import User
from assets.models import SystemUser, Asset
@@ -244,39 +342,8 @@ class UserConnectionTokenViewSet(RootOrgViewMixin, SerializerMixin, GenericViewS
if not has_perm:
raise serializers.ValidationError('Permission expired or invalid')
return value, user, system_user, asset, app, expired_at
@action(methods=['POST'], detail=False, permission_classes=[IsSuperUserOrAppUser], url_path='secret-info/detail')
def get_secret_detail(self, request, *args, **kwargs):
token = request.data.get('token', '')
try:
value, user, system_user, asset, app, expired_at = self.valid_token(token)
except serializers.ValidationError as e:
post_auth_failed.send(
sender=self.__class__, username='', request=self.request,
reason=_('Invalid token')
)
raise e
data = dict(user=user, system_user=system_user, expired_at=expired_at)
if asset:
asset_detail = self._get_asset_secret_detail(asset, user=user, system_user=system_user)
system_user.load_asset_more_auth(asset.id, user.username, user.id)
data['type'] = 'asset'
data.update(asset_detail)
else:
app_detail = self._get_application_secret_detail(app)
system_user.load_app_more_auth(app.id, user.id)
data['type'] = 'application'
data.update(app_detail)
self.request.session['auth_backend'] = settings.AUTH_BACKEND_AUTH_TOKEN
post_auth_success.send(sender=self.__class__, user=user, request=self.request, login_type='T')
serializer = self.get_serializer(data)
return Response(data=serializer.data, status=200)
def get_permissions(self):
if self.action in ["create", "get_rdp_file"]:
if self.request.data.get('user', None):

View File

@@ -0,0 +1,45 @@
from rest_framework.views import APIView
from rest_framework.request import Request
from rest_framework.response import Response
from users.permissions import IsAuthPasswdTimeValid
from users.models import User
from common.utils import get_logger
from common.permissions import IsOrgAdmin
from common.mixins.api import RoleUserMixin, RoleAdminMixin
from authentication import errors
logger = get_logger(__file__)
class FeiShuQRUnBindBase(APIView):
user: User
def post(self, request: Request, **kwargs):
user = self.user
if not user.feishu_id:
raise errors.FeiShuNotBound
user.feishu_id = None
user.save()
return Response()
class FeiShuQRUnBindForUserApi(RoleUserMixin, FeiShuQRUnBindBase):
permission_classes = (IsAuthPasswdTimeValid,)
class FeiShuQRUnBindForAdminApi(RoleAdminMixin, FeiShuQRUnBindBase):
user_id_url_kwarg = 'user_id'
permission_classes = (IsOrgAdmin,)
class FeiShuEventSubscriptionCallback(APIView):
"""
# https://open.feishu.cn/document/ukTMukTMukTM/uUTNz4SN1MjL1UzM
"""
permission_classes = ()
def post(self, request: Request, *args, **kwargs):
return Response(data=request.data)

View File

@@ -45,5 +45,5 @@ class TicketStatusApi(mixins.AuthMixin, APIView):
ticket = self.get_ticket()
if ticket:
request.session.pop('auth_ticket_id', '')
ticket.close(processor=request.user)
ticket.close(processor=self.get_user_from_session())
return Response('', status=200)

View File

@@ -1,5 +1,6 @@
# -*- coding: utf-8 -*-
#
import builtins
import time
from django.utils.translation import ugettext as _
from django.conf import settings
@@ -8,14 +9,28 @@ from rest_framework.generics import CreateAPIView
from rest_framework.serializers import ValidationError
from rest_framework.response import Response
from common.permissions import IsValidUser, NeedMFAVerify
from authentication.sms_verify_code import VerifyCodeUtil
from common.exceptions import JMSException
from common.permissions import IsValidUser, NeedMFAVerify, IsAppUser
from users.models.user import MFAType
from ..serializers import OtpVerifySerializer
from .. import serializers
from .. import errors
from ..mixins import AuthMixin
__all__ = ['MFAChallengeApi', 'UserOtpVerifyApi']
__all__ = ['MFAChallengeApi', 'UserOtpVerifyApi', 'SendSMSVerifyCodeApi', 'MFASelectTypeApi']
class MFASelectTypeApi(AuthMixin, CreateAPIView):
permission_classes = (AllowAny,)
serializer_class = serializers.MFASelectTypeSerializer
def perform_create(self, serializer):
mfa_type = serializer.validated_data['type']
if mfa_type == MFAType.SMS_CODE:
user = self.get_user_from_session()
user.send_sms_code()
class MFAChallengeApi(AuthMixin, CreateAPIView):
@@ -26,7 +41,9 @@ class MFAChallengeApi(AuthMixin, CreateAPIView):
try:
user = self.get_user_from_session()
code = serializer.validated_data.get('code')
valid = user.check_mfa(code)
mfa_type = serializer.validated_data.get('type', MFAType.OTP)
valid = user.check_mfa(code, mfa_type=mfa_type)
if not valid:
self.request.session['auth_mfa'] = ''
raise errors.MFAFailedError(
@@ -67,3 +84,12 @@ class UserOtpVerifyApi(CreateAPIView):
if self.request.method.lower() == 'get' and settings.SECURITY_VIEW_AUTH_NEED_MFA:
self.permission_classes = [NeedMFAVerify]
return super().get_permissions()
class SendSMSVerifyCodeApi(AuthMixin, CreateAPIView):
permission_classes = (AllowAny,)
def create(self, request, *args, **kwargs):
user = self.get_user_from_session()
timeout = user.send_sms_code()
return Response({'code': 'ok','timeout': timeout})

View File

@@ -11,7 +11,7 @@ from rest_framework.permissions import AllowAny
from common.utils.timezone import utcnow
from common.const.http import POST, GET
from common.drf.api import JmsGenericViewSet
from common.drf.api import JMSGenericViewSet
from common.drf.serializers import EmptySerializer
from common.permissions import IsSuperUser
from common.utils import reverse
@@ -26,7 +26,7 @@ NEXT_URL = 'next'
AUTH_KEY = 'authkey'
class SSOViewSet(AuthMixin, JmsGenericViewSet):
class SSOViewSet(AuthMixin, JMSGenericViewSet):
queryset = SSOToken.objects.all()
serializer_classes = {
'login_url': SSOTokenSerializer,

View File

@@ -240,6 +240,15 @@ class DingTalkAuthentication(JMSModelBackend):
pass
class FeiShuAuthentication(JMSModelBackend):
"""
什么也不做呀😺
"""
def authenticate(self, request, **kwargs):
pass
class AuthorizationTokenAuthentication(JMSModelBackend):
"""
什么也不做呀😺

View File

@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
#
from .backends import *
from .callback import *

View File

@@ -1,16 +0,0 @@
# -*- coding: utf-8 -*-
#
from django.contrib.auth import get_user_model
User = get_user_model()
def cas_callback(response):
username = response['username']
user, user_created = User.objects.get_or_create(username=username)
profile, created = user.get_profile()
profile.role = response['attributes']['role']
profile.birth_date = response['attributes']['birth_date']
profile.save()

View File

@@ -4,9 +4,11 @@ from django.utils.translation import ugettext_lazy as _
from django.urls import reverse
from django.conf import settings
from authentication import sms_verify_code
from common.exceptions import JMSException
from .signals import post_auth_failed
from users.utils import LoginBlockUtil, MFABlockUtils
from users.models import MFAType
reason_password_failed = 'password_failed'
reason_password_decrypt_failed = 'password_decrypt_failed'
@@ -58,8 +60,18 @@ 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, "
otp_failed_msg = _(
"One-time password invalid, or ntp sync server time, "
"You can also try {times_try} times "
"(The account will be temporarily locked for {block_time} minutes)"
)
sms_failed_msg = _(
"SMS verify code invalid,"
"You can also try {times_try} times "
"(The account will be temporarily locked for {block_time} minutes)"
)
mfa_type_failed_msg = _(
"The MFA type({mfa_type}) is not supported, "
"You can also try {times_try} times "
"(The account will be temporarily locked for {block_time} minutes)"
)
@@ -134,7 +146,7 @@ class MFAFailedError(AuthFailedNeedLogMixin, AuthFailedError):
error = reason_mfa_failed
msg: str
def __init__(self, username, request, ip):
def __init__(self, username, request, ip, mfa_type=MFAType.OTP):
util = MFABlockUtils(username, ip)
util.incr_failed_count()
@@ -142,9 +154,18 @@ class MFAFailedError(AuthFailedNeedLogMixin, AuthFailedError):
block_time = settings.SECURITY_LOGIN_LIMIT_TIME
if times_remainder:
self.msg = mfa_failed_msg.format(
times_try=times_remainder, block_time=block_time
)
if mfa_type == MFAType.OTP:
self.msg = otp_failed_msg.format(
times_try=times_remainder, block_time=block_time
)
elif mfa_type == MFAType.SMS_CODE:
self.msg = sms_failed_msg.format(
times_try=times_remainder, block_time=block_time
)
else:
self.msg = mfa_type_failed_msg.format(
mfa_type=mfa_type, times_try=times_remainder, block_time=block_time
)
else:
self.msg = block_mfa_msg.format(settings.SECURITY_LOGIN_LIMIT_TIME)
super().__init__(username=username, request=request)
@@ -202,12 +223,16 @@ class MFARequiredError(NeedMoreInfoError):
msg = mfa_required_msg
error = 'mfa_required'
def __init__(self, error='', msg='', mfa_types=tuple(MFAType)):
super().__init__(error=error, msg=msg)
self.choices = mfa_types
def as_data(self):
return {
'error': self.error,
'msg': self.msg,
'data': {
'choices': ['code'],
'choices': self.choices,
'url': reverse('api-auth:mfa-challenge')
}
}
@@ -315,6 +340,11 @@ class DingTalkNotBound(JMSException):
default_detail = 'DingTalk is not bound'
class FeiShuNotBound(JMSException):
default_code = 'feishu_not_bound'
default_detail = 'FeiShu is not bound'
class PasswdInvalid(JMSException):
default_code = 'passwd_invalid'
default_detail = _('Your password is invalid')

View File

@@ -43,7 +43,8 @@ class UserLoginForm(forms.Form):
class UserCheckOtpCodeForm(forms.Form):
otp_code = forms.CharField(label=_('MFA code'), max_length=6)
code = forms.CharField(label=_('MFA Code'), max_length=6)
mfa_type = forms.CharField(label=_('MFA type'), max_length=6)
class CustomCaptchaTextInput(CaptchaTextInput):
@@ -66,9 +67,9 @@ class ChallengeMixin(forms.Form):
def get_user_login_form_cls(*, captcha=False):
bases = []
if settings.SECURITY_LOGIN_CAPTCHA_ENABLED and captcha:
bases.append(CaptchaMixin)
if settings.SECURITY_LOGIN_CHALLENGE_ENABLED:
bases.append(ChallengeMixin)
elif settings.SECURITY_LOGIN_CAPTCHA_ENABLED and captcha:
bases.append(CaptchaMixin)
bases.append(UserLoginForm)
return type('UserLoginForm', tuple(bases), {})

View File

@@ -0,0 +1,14 @@
from django.shortcuts import redirect
class MFAMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
if request.path.find('/auth/login/otp/') > -1:
return response
if request.session.get('auth_mfa_required'):
return redirect('authentication:login-otp')
return response

View File

@@ -14,14 +14,15 @@ from django.contrib.auth import (
PermissionDenied, user_login_failed, _clean_credentials
)
from django.shortcuts import reverse, redirect
from django.views.generic.edit import FormView
from common.utils import get_object_or_none, get_request_ip, get_logger, bulk_get, FlashMessageUtil
from users.models import User
from users.models import User, MFAType
from users.utils import LoginBlockUtil, MFABlockUtils
from . import errors
from .utils import rsa_decrypt
from .utils import rsa_decrypt, gen_key_pair
from .signals import post_auth_success, post_auth_failed
from .const import RSA_PRIVATE_KEY
from .const import RSA_PRIVATE_KEY, RSA_PUBLIC_KEY
logger = get_logger(__name__)
@@ -29,8 +30,8 @@ logger = get_logger(__name__)
def check_backend_can_auth(username, backend_path, allowed_auth_backends):
if allowed_auth_backends is not None and backend_path not in allowed_auth_backends:
logger.debug('Skip user auth backend: {}, {} not in'.format(
username, backend_path, ','.join(allowed_auth_backends)
)
username, backend_path, ','.join(allowed_auth_backends)
)
)
return False
return True
@@ -79,7 +80,70 @@ def authenticate(request=None, **credentials):
auth.authenticate = authenticate
class AuthMixin:
class PasswordEncryptionViewMixin:
request = None
def get_decrypted_password(self, password=None, username=None):
request = self.request
if hasattr(request, 'data'):
data = request.data
else:
data = request.POST
username = username or data.get('username')
password = password or data.get('password')
password = self.decrypt_passwd(password)
if not password:
self.raise_password_decrypt_failed(username=username)
return password
def raise_password_decrypt_failed(self, username):
ip = self.get_request_ip()
raise errors.CredentialError(
error=errors.reason_password_decrypt_failed,
username=username, ip=ip, request=self.request
)
def decrypt_passwd(self, raw_passwd):
# 获取解密密钥,对密码进行解密
rsa_private_key = self.request.session.get(RSA_PRIVATE_KEY)
if rsa_private_key is not None:
try:
return rsa_decrypt(raw_passwd, rsa_private_key)
except Exception as e:
logger.error(e, exc_info=True)
logger.error(
f'Decrypt password failed: password[{raw_passwd}] '
f'rsa_private_key[{rsa_private_key}]'
)
return None
return raw_passwd
def get_request_ip(self):
ip = ''
if hasattr(self.request, 'data'):
ip = self.request.data.get('remote_addr', '')
ip = ip or get_request_ip(self.request)
return ip
def get_context_data(self, **kwargs):
# 生成加解密密钥对public_key传递给前端private_key存入session中供解密使用
rsa_public_key = self.request.session.get(RSA_PUBLIC_KEY)
rsa_private_key = self.request.session.get(RSA_PRIVATE_KEY)
if not all((rsa_private_key, rsa_public_key)):
rsa_private_key, rsa_public_key = gen_key_pair()
rsa_public_key = rsa_public_key.replace('\n', '\\n')
self.request.session[RSA_PRIVATE_KEY] = rsa_private_key
self.request.session[RSA_PUBLIC_KEY] = rsa_public_key
kwargs.update({
'rsa_public_key': rsa_public_key,
})
return super().get_context_data(**kwargs)
class AuthMixin(PasswordEncryptionViewMixin):
request = None
partial_credential_error = None
@@ -106,13 +170,6 @@ class AuthMixin:
user.backend = self.request.session.get("auth_backend")
return user
def get_request_ip(self):
ip = ''
if hasattr(self.request, 'data'):
ip = self.request.data.get('remote_addr', '')
ip = ip or get_request_ip(self.request)
return ip
def _check_is_block(self, username, raise_exception=True):
ip = self.get_request_ip()
if LoginBlockUtil(username, ip).is_block():
@@ -130,24 +187,14 @@ class AuthMixin:
username = self.request.POST.get("username")
self._check_is_block(username, raise_exception)
def decrypt_passwd(self, raw_passwd):
# 获取解密密钥,对密码进行解密
rsa_private_key = self.request.session.get(RSA_PRIVATE_KEY)
if rsa_private_key is not None:
try:
return rsa_decrypt(raw_passwd, rsa_private_key)
except Exception as e:
logger.error(e, exc_info=True)
logger.error(f'Decrypt password failed: password[{raw_passwd}] '
f'rsa_private_key[{rsa_private_key}]')
return None
return raw_passwd
def raise_credential_error(self, error):
raise self.partial_credential_error(error=error)
def _set_partial_credential_error(self, username, ip, request):
self.partial_credential_error = partial(errors.CredentialError, username=username, ip=ip, request=request)
self.partial_credential_error = partial(
errors.CredentialError, username=username,
ip=ip, request=request
)
def get_auth_data(self, decrypt_passwd=False):
request = self.request
@@ -157,15 +204,13 @@ class AuthMixin:
data = request.POST
items = ['username', 'password', 'challenge', 'public_key', 'auto_login']
username, password, challenge, public_key, auto_login = bulk_get(data, *items, default='')
password = password + challenge.strip()
username, password, challenge, public_key, auto_login = bulk_get(data, *items, default='')
ip = self.get_request_ip()
self._set_partial_credential_error(username=username, ip=ip, request=request)
password = password + challenge.strip()
if decrypt_passwd:
password = self.decrypt_passwd(password)
if not password:
self.raise_credential_error(errors.reason_password_decrypt_failed)
password = self.get_decrypted_password()
return username, password, public_key, ip, auto_login
def _check_only_allow_exists_user_auth(self, username):
@@ -243,7 +288,6 @@ class AuthMixin:
elif not user.is_active:
self.raise_credential_error(errors.reason_user_inactive)
self._check_is_local_user(user)
self._check_is_block(user.username)
self._check_login_acl(user, ip)
@@ -304,17 +348,28 @@ class AuthMixin:
def check_user_mfa_if_need(self, user):
if self.request.session.get('auth_mfa'):
return
if settings.OTP_IN_RADIUS:
return
if not user.mfa_enabled:
return
unset, url = user.mfa_enabled_but_not_set()
if unset:
raise errors.MFAUnsetError(user, self.request, url)
raise errors.MFARequiredError()
raise errors.MFARequiredError(mfa_types=user.get_supported_mfa_types())
def mark_mfa_ok(self):
def mark_mfa_ok(self, mfa_type=MFAType.OTP):
self.request.session['auth_mfa'] = 1
self.request.session['auth_mfa_time'] = time.time()
self.request.session['auth_mfa_type'] = 'otp'
self.request.session['auth_mfa_required'] = ''
self.request.session['auth_mfa_type'] = mfa_type
def clean_mfa_mark(self):
self.request.session['auth_mfa'] = ''
self.request.session['auth_mfa_time'] = ''
self.request.session['auth_mfa_required'] = ''
self.request.session['auth_mfa_type'] = ''
def check_mfa_is_block(self, username, ip, raise_exception=True):
if MFABlockUtils(username, ip).is_block():
@@ -325,11 +380,11 @@ class AuthMixin:
else:
return exception
def check_user_mfa(self, code):
def check_user_mfa(self, code, mfa_type=MFAType.OTP):
user = self.get_user_from_session()
ip = self.get_request_ip()
self.check_mfa_is_block(user.username, ip)
ok = user.check_mfa(code)
ok = user.check_mfa(code, mfa_type=mfa_type)
if ok:
self.mark_mfa_ok()
return
@@ -337,7 +392,7 @@ class AuthMixin:
raise errors.MFAFailedError(
username=user.username,
request=self.request,
ip=ip
ip=ip, mfa_type=mfa_type,
)
def get_ticket(self):
@@ -363,16 +418,18 @@ class AuthMixin:
raise errors.LoginConfirmOtherError('', "Not found")
if ticket.status_open:
raise errors.LoginConfirmWaitError(ticket.id)
elif ticket.action_approve:
elif ticket.state_approve:
self.request.session["auth_confirm"] = "1"
return
elif ticket.action_reject:
elif ticket.state_reject:
self.clean_mfa_mark()
raise errors.LoginConfirmOtherError(
ticket.id, ticket.get_action_display()
ticket.id, ticket.get_state_display()
)
elif ticket.action_close:
elif ticket.state_close:
self.clean_mfa_mark()
raise errors.LoginConfirmOtherError(
ticket.id, ticket.get_action_display()
ticket.id, ticket.get_state_display()
)
else:
raise errors.LoginConfirmOtherError(
@@ -391,7 +448,6 @@ class AuthMixin:
def clear_auth_mark(self):
self.request.session['auth_password'] = ''
self.request.session['auth_user_id'] = ''
self.request.session['auth_mfa'] = ''
self.request.session['auth_confirm'] = ''
self.request.session['auth_ticket_id'] = ''

View File

@@ -45,6 +45,9 @@ class LoginConfirmSetting(CommonModelMixin):
reviewers = models.ManyToManyField('users.User', verbose_name=_("Reviewers"), related_name="review_login_confirm_settings", blank=True)
is_active = models.BooleanField(default=True, verbose_name=_("Is active"))
class Meta:
verbose_name = _('Login Confirm')
@classmethod
def get_user_confirm_setting(cls, user):
return get_object_or_none(cls, user=user)
@@ -71,20 +74,20 @@ class LoginConfirmSetting(CommonModelMixin):
from orgs.models import Organization
ticket_title = _('Login confirm') + ' {}'.format(self.user)
ticket_meta = self.construct_confirm_ticket_meta(request)
ticket_assignees = self.reviewers.all()
data = {
'title': ticket_title,
'type': const.TicketTypeChoices.login_confirm.value,
'type': const.TicketType.login_confirm.value,
'meta': ticket_meta,
'org_id': Organization.ROOT_ID,
}
ticket = Ticket.objects.create(**data)
ticket.assignees.set(ticket_assignees)
ticket.create_process_map_and_node(self.reviewers.all())
ticket.open(self.user)
return ticket
def __str__(self):
return '{} confirm'.format(self.user.username)
reviewers = [u.username for u in self.reviewers.all()]
return _('{} need confirm by {}').format(self.user.username, reviewers)
class SSOToken(models.JMSBaseModel):

View File

@@ -16,8 +16,8 @@ from .models import AccessKey, LoginConfirmSetting
__all__ = [
'AccessKeySerializer', 'OtpVerifySerializer', 'BearerTokenSerializer',
'MFAChallengeSerializer', 'LoginConfirmSettingSerializer', 'SSOTokenSerializer',
'ConnectionTokenSerializer', 'ConnectionTokenSecretSerializer', 'RDPFileSerializer',
'PasswordVerifySerializer',
'ConnectionTokenSerializer', 'ConnectionTokenSecretSerializer',
'PasswordVerifySerializer', 'MFASelectTypeSerializer',
]
@@ -77,6 +77,10 @@ class BearerTokenSerializer(serializers.Serializer):
return instance
class MFASelectTypeSerializer(serializers.Serializer):
type = serializers.CharField()
class MFAChallengeSerializer(serializers.Serializer):
type = serializers.CharField(write_only=True, required=False, allow_blank=True)
code = serializers.CharField(write_only=True)
@@ -166,7 +170,7 @@ class ConnectionTokenAssetSerializer(serializers.ModelSerializer):
class ConnectionTokenSystemUserSerializer(serializers.ModelSerializer):
class Meta:
model = SystemUser
fields = ['id', 'name', 'username', 'password', 'private_key']
fields = ['id', 'name', 'username', 'password', 'private_key', 'ad_domain']
class ConnectionTokenGatewaySerializer(serializers.ModelSerializer):
@@ -198,7 +202,3 @@ class ConnectionTokenSecretSerializer(serializers.Serializer):
actions = ActionsField()
expired_at = serializers.IntegerField()
class RDPFileSerializer(ConnectionTokenSerializer):
width = serializers.IntegerField(allow_null=True, max_value=3112, min_value=100, required=False)
height = serializers.IntegerField(allow_null=True, max_value=4096, min_value=100, required=False)

View File

@@ -13,6 +13,11 @@ from .signals import post_auth_success, post_auth_failed
@receiver(user_logged_in)
def on_user_auth_login_success(sender, user, request, **kwargs):
# 开启了 MFA且没有校验过
if user.mfa_enabled and not settings.OTP_IN_RADIUS and not request.session.get('auth_mfa'):
request.session['auth_mfa_required'] = 1
if settings.USER_LOGIN_SINGLE_MACHINE_ENABLED:
user_id = 'single_machine_login_' + str(user.id)
session_key = cache.get(user_id)

View File

@@ -0,0 +1,100 @@
import random
from django.conf import settings
from django.core.cache import cache
from django.utils.translation import gettext_lazy as _
from common.message.backends.sms.alibaba import AlibabaSMS
from common.message.backends.sms import SMS
from common.utils import get_logger
from common.exceptions import JMSException
logger = get_logger(__file__)
class CodeExpired(JMSException):
default_code = 'verify_code_expired'
default_detail = _('The verification code has expired. Please resend it')
class CodeError(JMSException):
default_code = 'verify_code_error'
default_detail = _('The verification code is incorrect')
class CodeSendTooFrequently(JMSException):
default_code = 'code_send_too_frequently'
default_detail = _('Please wait {} seconds before sending')
def __init__(self, ttl):
super().__init__(detail=self.default_detail.format(ttl))
class VerifyCodeUtil:
KEY_TMPL = 'auth-verify_code-{}'
TIMEOUT = 60
def __init__(self, account, key_suffix=None, timeout=None):
self.account = account
self.key_suffix = key_suffix
self.code = ''
if key_suffix is not None:
self.key = self.KEY_TMPL.format(key_suffix)
else:
self.key = self.KEY_TMPL.format(account)
self.timeout = self.TIMEOUT if timeout is None else timeout
def touch(self):
"""
生成,保存,发送
"""
ttl = self.ttl()
if ttl > 0:
raise CodeSendTooFrequently(ttl)
try:
self.generate()
self.save()
self.send()
except JMSException:
self.clear()
raise
def generate(self):
code = ''.join(random.sample('0123456789', 4))
self.code = code
return code
def clear(self):
cache.delete(self.key)
def save(self):
cache.set(self.key, self.code, self.timeout)
def send(self):
"""
发送信息的方法,如果有错误直接抛出 api 异常
"""
account = self.account
code = self.code
sms = SMS()
sms.send_verify_code(account, code)
logger.info(f'Send sms verify code: account={account} code={code}')
def verify(self, code):
right = cache.get(self.key)
if not right:
raise CodeExpired
if right != code:
raise CodeError
self.clear()
return True
def ttl(self):
return cache.ttl(self.key)
def get_code(self):
return cache.get(self.key)

View File

@@ -147,7 +147,7 @@
{% csrf_token %}
<div style="line-height: 17px;margin-bottom: 20px;color: #999999;">
{% if form.errors %}
<p class="red-fonts" style="color: red">
<p class="help-block">
{% if form.non_field_errors %}
{{ form.non_field_errors.as_text }}
{% endif %}
@@ -160,9 +160,15 @@
</div>
{% bootstrap_field form.username show_label=False %}
<div class="form-group">
<input type="password" class="form-control" id="password" placeholder="{% trans 'Password' %}" required="">
<div class="form-group {% if form.password.errors %} has-error {% endif %}">
<input type="password" class="form-control" id="password" placeholder="{% trans 'Password' %}" required>
<input id="password-hidden" type="text" style="display:none" name="{{ form.password.html_name }}">
{% if form.password.errors %}
<p class="help-block" style="text-align: left">
{{ form.password.errors.as_text }}
</p>
{% endif %}
</div>
{% if form.challenge %}
{% bootstrap_field form.challenge show_label=False %}
@@ -191,7 +197,7 @@
</div>
<div>
{% if AUTH_OPENID or AUTH_CAS or AUTH_WECOM or AUTH_DINGTALK %}
{% if AUTH_OPENID or AUTH_CAS or AUTH_WECOM or AUTH_DINGTALK or AUTH_FEISHU %}
<div class="hr-line-dashed"></div>
<div style="display: inline-block; float: left">
<b class="text-muted text-left" >{% trans "More login options" %}</b>
@@ -215,7 +221,11 @@
<i class="fa"><img src="{{ LOGIN_DINGTALK_LOGO_URL }}" height="13" width="13"></i> {% trans 'DingTalk' %}
</a>
{% endif %}
{% if AUTH_FEISHU %}
<a href="{% url 'authentication:feishu-qr-login' %}" class="more-login-item">
<i class="fa"><img src="{{ LOGIN_FEISHU_LOGO_URL }}" height="13" width="13"></i> {% trans 'FeiShu' %}
</a>
{% endif %}
</div>
{% else %}
<div class="text-center" style="display: inline-block;">
@@ -231,6 +241,9 @@
<script type="text/javascript" src="/static/js/plugins/jsencrypt/jsencrypt.min.js"></script>
<script>
function encryptLoginPassword(password, rsaPublicKey) {
if (!password) {
return ''
}
var jsencrypt = new JSEncrypt(); //加密对象
jsencrypt.setPublicKey(rsaPublicKey); // 设置密钥
return jsencrypt.encrypt(password); //加密

View File

@@ -9,24 +9,82 @@
{% block content %}
<form class="m-t" role="form" method="post" action="">
{% csrf_token %}
{% if 'otp_code' in form.errors %}
<p class="red-fonts">{{ form.otp_code.errors.as_text }}</p>
{% if 'code' in form.errors %}
<p class="red-fonts">{{ form.code.errors.as_text }}</p>
{% endif %}
<div class="form-group">
<select class="form-control">
<option value="otp" selected>{% trans 'One-time password' %}</option>
<select id="verify-method-select" name="mfa_type" class="form-control" onchange="select_change(this.value)">
{% for method in methods %}
<option value="{{ method.name }}" {% if method.selected %} selected {% endif %} {% if not method.enable %} disabled {% endif %}>{{ method.label }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<input type="text" class="form-control" name="otp_code" placeholder="" required="" autofocus="autofocus">
<span class="help-block">
{% trans 'Open MFA Authenticator and enter the 6-bit dynamic code' %}
</span>
</div>
<button type="submit" class="btn btn-primary block full-width m-b">{% trans 'Next' %}</button>
<div class="form-group" style="display: flex">
<input id="mfa-code" required type="text" class="form-control" name="code" placeholder="{% trans 'Please enter the verification code' %}" autofocus="autofocus">
<button id='send-sms-verify-code' type="button" class="btn btn-info full-width m-b" onclick="sendSMSVerifyCode()" style="width: 150px!important;">{% trans 'Send verification code' %}</button>
</div>
<button id='submit_button' type="submit" class="btn btn-primary block full-width m-b">{% trans 'Next' %}</button>
<div>
<small>{% trans "Can't provide security? Please contact the administrator!" %}</small>
</div>
</form>
<style type="text/css">
.disabledBtn {
background: #e6e4e4!important;
border-color: #d8d5d5!important;
color: #949191!important;
}
</style>
<script>
var methodSelect = document.getElementById('verify-method-select');
if (methodSelect.value !== null) {
select_change(methodSelect.value);
}
function select_change(type){
var currentBtn = document.getElementById('send-sms-verify-code');
if (type == "sms") {
currentBtn.style.display = "block";
currentBtn.disabled = false;
}
else {
currentBtn.style.display = "none";
currentBtn.disabled = true;
}
}
function sendSMSVerifyCode(){
var currentBtn = document.getElementById('send-sms-verify-code');
var time = 60
var url = "{% url 'api-auth:sms-verify-code-send' %}";
requestApi({
url: url,
method: "POST",
success: function (data) {
currentBtn.innerHTML = `{% trans 'Wait: ' %} ${time}`;
currentBtn.disabled = true
currentBtn.classList.add("disabledBtn" )
var TimeInterval = setInterval(()=>{
--time
currentBtn.innerHTML = `{% trans 'Wait: ' %} ${time}`;
if(time === 0) {
currentBtn.innerHTML = "{% trans 'Send verification code' %}"
currentBtn.disabled = false
currentBtn.classList.remove("disabledBtn")
clearInterval(TimeInterval)
}
},1000)
alert("{% trans 'The verification code has been sent' %}");
},
error: function (text, data) {
alert(data.detail)
},
flash_message: false
})
}
</script>
{% endblock %}

View File

@@ -157,6 +157,7 @@ $(document).ready(function () {
cancelCloseConfirm();
window.location.reload();
}).on('click', '.btn-return', function () {
cancelTicket();
cancelCloseConfirm();
window.location = "{% url 'authentication:login' %}"
})

View File

@@ -20,10 +20,16 @@ urlpatterns = [
path('dingtalk/qr/unbind/', api.DingTalkQRUnBindForUserApi.as_view(), name='dingtalk-qr-unbind'),
path('dingtalk/qr/unbind/<uuid:user_id>/', api.DingTalkQRUnBindForAdminApi.as_view(), name='dingtalk-qr-unbind-for-admin'),
path('feishu/qr/unbind/', api.FeiShuQRUnBindForUserApi.as_view(), name='feishu-qr-unbind'),
path('feishu/qr/unbind/<uuid:user_id>/', api.FeiShuQRUnBindForAdminApi.as_view(), name='feishu-qr-unbind-for-admin'),
path('feishu/event/subscription/callback/', api.FeiShuEventSubscriptionCallback.as_view(), name='feishu-event-subscription-callback'),
path('auth/', api.TokenCreateApi.as_view(), name='user-auth'),
path('tokens/', api.TokenCreateApi.as_view(), name='auth-token'),
path('mfa/challenge/', api.MFAChallengeApi.as_view(), name='mfa-challenge'),
path('mfa/select/', api.MFASelectTypeApi.as_view(), name='mfa-select'),
path('otp/verify/', api.UserOtpVerifyApi.as_view(), name='user-otp-verify'),
path('sms/verify-code/send/', api.SendSMSVerifyCodeApi.as_view(), name='sms-verify-code-send'),
path('password/verify/', api.UserPasswordVerifyApi.as_view(), name='user-password-verify'),
path('login-confirm-ticket/status/', api.TicketStatusApi.as_view(), name='login-confirm-ticket-status'),
path('login-confirm-settings/<uuid:user_id>/', api.LoginConfirmSettingUpdateApi.as_view(), name='login-confirm-setting-update')

View File

@@ -2,6 +2,7 @@
#
from django.urls import path, include
from django.db.transaction import non_atomic_requests
from .. import views
from users import views as users_view
@@ -10,7 +11,7 @@ app_name = 'authentication'
urlpatterns = [
# login
path('login/', views.UserLoginView.as_view(), name='login'),
path('login/', non_atomic_requests(views.UserLoginView.as_view()), name='login'),
path('login/otp/', views.UserLoginOtpView.as_view(), name='login-otp'),
path('login/wait-confirm/', views.UserLoginWaitConfirmView.as_view(), name='login-wait-confirm'),
path('login/guard/', views.UserLoginGuardView.as_view(), name='login-guard'),
@@ -37,6 +38,14 @@ urlpatterns = [
path('dingtalk/qr/bind/<uuid:user_id>/callback/', views.DingTalkQRBindCallbackView.as_view(), name='dingtalk-qr-bind-callback'),
path('dingtalk/qr/login/callback/', views.DingTalkQRLoginCallbackView.as_view(), name='dingtalk-qr-login-callback'),
path('feishu/bind/success-flash-msg/', views.FlashDingTalkBindSucceedMsgView.as_view(), name='feishu-bind-success-flash-msg'),
path('feishu/bind/failed-flash-msg/', views.FlashDingTalkBindFailedMsgView.as_view(), name='feishu-bind-failed-flash-msg'),
path('feishu/bind/start/', views.FeiShuEnableStartView.as_view(), name='feishu-bind-start'),
path('feishu/qr/bind/', views.FeiShuQRBindView.as_view(), name='feishu-qr-bind'),
path('feishu/qr/login/', views.FeiShuQRLoginView.as_view(), name='feishu-qr-login'),
path('feishu/qr/bind/callback/', views.FeiShuQRBindCallbackView.as_view(), name='feishu-qr-bind-callback'),
path('feishu/qr/login/callback/', views.FeiShuQRLoginCallbackView.as_view(), name='feishu-qr-login-callback'),
# Profile
path('profile/pubkey/generate/', users_view.UserPublicKeyGenerateView.as_view(), name='user-pubkey-generate'),
path('profile/otp/enable/start/', users_view.UserOtpEnableStartView.as_view(), name='user-otp-enable-start'),

View File

@@ -4,7 +4,6 @@ import base64
from Cryptodome.PublicKey import RSA
from Cryptodome.Cipher import PKCS1_v1_5
from Cryptodome import Random
from common.utils import get_logger
logger = get_logger(__file__)

View File

@@ -4,3 +4,4 @@ from .login import *
from .mfa import *
from .wecom import *
from .dingtalk import *
from .feishu import *

View File

@@ -215,7 +215,7 @@ class DingTalkQRLoginCallbackView(AuthMixin, DingTalkQRMixin, View):
user = get_object_or_none(User, dingtalk_id=userid)
if user is None:
title = _('DingTalk is not bound')
msg = _('Please login with a password and then bind the WeCom')
msg = _('Please login with a password and then bind the DingTalk')
response = self.get_failed_reponse(login_url, title=title, msg=msg)
return response

View File

@@ -0,0 +1,253 @@
import urllib
from django.http.response import HttpResponseRedirect, HttpResponse
from django.utils.decorators import method_decorator
from django.utils.translation import ugettext_lazy as _
from django.views.decorators.cache import never_cache
from django.views.generic import TemplateView
from django.views import View
from django.conf import settings
from django.http.request import HttpRequest
from django.db.utils import IntegrityError
from rest_framework.permissions import IsAuthenticated, AllowAny
from rest_framework.exceptions import APIException
from users.utils import is_auth_password_time_valid
from users.views import UserVerifyPasswordView
from users.models import User
from common.utils import get_logger
from common.utils.random import random_string
from common.utils.django import reverse, get_object_or_none
from common.mixins.views import PermissionsMixin
from common.message.backends.feishu import FeiShu, URL
from authentication import errors
from authentication.mixins import AuthMixin
logger = get_logger(__file__)
FEISHU_STATE_SESSION_KEY = '_feishu_state'
class FeiShuQRMixin(PermissionsMixin, View):
def dispatch(self, request, *args, **kwargs):
try:
return super().dispatch(request, *args, **kwargs)
except APIException as e:
msg = str(e.detail)
return self.get_failed_reponse(
'/',
_('FeiShu Error'),
msg
)
def verify_state(self):
state = self.request.GET.get('state')
session_state = self.request.session.get(FEISHU_STATE_SESSION_KEY)
if state != session_state:
return False
return True
def get_verify_state_failed_response(self, redirect_uri):
msg = _("You've been hacked")
return self.get_failed_reponse(redirect_uri, msg, msg)
def get_qr_url(self, redirect_uri):
state = random_string(16)
self.request.session[FEISHU_STATE_SESSION_KEY] = state
params = {
'app_id': settings.FEISHU_APP_ID,
'state': state,
'redirect_uri': redirect_uri,
}
url = URL.AUTHEN + '?' + urllib.parse.urlencode(params)
return url
def get_success_reponse(self, redirect_url, title, msg):
ok_flash_msg_url = reverse('authentication:feishu-bind-success-flash-msg')
ok_flash_msg_url += '?' + urllib.parse.urlencode({
'redirect_url': redirect_url,
'title': title,
'msg': msg
})
return HttpResponseRedirect(ok_flash_msg_url)
def get_failed_reponse(self, redirect_url, title, msg):
failed_flash_msg_url = reverse('authentication:feishu-bind-failed-flash-msg')
failed_flash_msg_url += '?' + urllib.parse.urlencode({
'redirect_url': redirect_url,
'title': title,
'msg': msg
})
return HttpResponseRedirect(failed_flash_msg_url)
def get_already_bound_response(self, redirect_url):
msg = _('FeiShu is already bound')
response = self.get_failed_reponse(redirect_url, msg, msg)
return response
class FeiShuQRBindView(FeiShuQRMixin, View):
permission_classes = (IsAuthenticated,)
def get(self, request: HttpRequest):
user = request.user
redirect_url = request.GET.get('redirect_url')
if not is_auth_password_time_valid(request.session):
msg = _('Please verify your password first')
response = self.get_failed_reponse(redirect_url, msg, msg)
return response
redirect_uri = reverse('authentication:feishu-qr-bind-callback', external=True)
redirect_uri += '?' + urllib.parse.urlencode({'redirect_url': redirect_url})
url = self.get_qr_url(redirect_uri)
return HttpResponseRedirect(url)
class FeiShuQRBindCallbackView(FeiShuQRMixin, View):
permission_classes = (IsAuthenticated,)
def get(self, request: HttpRequest):
code = request.GET.get('code')
redirect_url = request.GET.get('redirect_url')
if not self.verify_state():
return self.get_verify_state_failed_response(redirect_url)
user = request.user
if user.feishu_id:
response = self.get_already_bound_response(redirect_url)
return response
feishu = FeiShu(
app_id=settings.FEISHU_APP_ID,
app_secret=settings.FEISHU_APP_SECRET
)
user_id = feishu.get_user_id_by_code(code)
if not user_id:
msg = _('FeiShu query user failed')
response = self.get_failed_reponse(redirect_url, msg, msg)
return response
try:
user.feishu_id = user_id
user.save()
except IntegrityError as e:
if e.args[0] == 1062:
msg = _('The FeiShu is already bound to another user')
response = self.get_failed_reponse(redirect_url, msg, msg)
return response
raise e
msg = _('Binding FeiShu successfully')
response = self.get_success_reponse(redirect_url, msg, msg)
return response
class FeiShuEnableStartView(UserVerifyPasswordView):
def get_success_url(self):
referer = self.request.META.get('HTTP_REFERER')
redirect_url = self.request.GET.get("redirect_url")
success_url = reverse('authentication:feishu-qr-bind')
success_url += '?' + urllib.parse.urlencode({
'redirect_url': redirect_url or referer
})
return success_url
class FeiShuQRLoginView(FeiShuQRMixin, View):
permission_classes = (AllowAny,)
def get(self, request: HttpRequest):
redirect_url = request.GET.get('redirect_url')
redirect_uri = reverse('authentication:feishu-qr-login-callback', external=True)
redirect_uri += '?' + urllib.parse.urlencode({'redirect_url': redirect_url})
url = self.get_qr_url(redirect_uri)
return HttpResponseRedirect(url)
class FeiShuQRLoginCallbackView(AuthMixin, FeiShuQRMixin, View):
permission_classes = (AllowAny,)
def get(self, request: HttpRequest):
code = request.GET.get('code')
redirect_url = request.GET.get('redirect_url')
login_url = reverse('authentication:login')
if not self.verify_state():
return self.get_verify_state_failed_response(redirect_url)
feishu = FeiShu(
app_id=settings.FEISHU_APP_ID,
app_secret=settings.FEISHU_APP_SECRET
)
user_id = feishu.get_user_id_by_code(code)
if not user_id:
# 正常流程不会出这个错误hack 行为
msg = _('Failed to get user from FeiShu')
response = self.get_failed_reponse(login_url, title=msg, msg=msg)
return response
user = get_object_or_none(User, feishu_id=user_id)
if user is None:
title = _('FeiShu is not bound')
msg = _('Please login with a password and then bind the FeiShu')
response = self.get_failed_reponse(login_url, title=title, msg=msg)
return response
try:
self.check_oauth2_auth(user, settings.AUTH_BACKEND_FEISHU)
except errors.AuthFailedError as e:
self.set_login_failed_mark()
msg = e.msg
response = self.get_failed_reponse(login_url, title=msg, msg=msg)
return response
return self.redirect_to_guard_view()
@method_decorator(never_cache, name='dispatch')
class FlashFeiShuBindSucceedMsgView(TemplateView):
template_name = 'flash_message_standalone.html'
def get(self, request, *args, **kwargs):
title = request.GET.get('title')
msg = request.GET.get('msg')
context = {
'title': title or _('Binding FeiShu successfully'),
'messages': msg or _('Binding FeiShu successfully'),
'interval': 5,
'redirect_url': request.GET.get('redirect_url'),
'auto_redirect': True,
}
return self.render_to_response(context)
@method_decorator(never_cache, name='dispatch')
class FlashFeiShuBindFailedMsgView(TemplateView):
template_name = 'flash_message_standalone.html'
def get(self, request, *args, **kwargs):
title = request.GET.get('title')
msg = request.GET.get('msg')
context = {
'title': title or _('Binding FeiShu failed'),
'messages': msg or _('Binding FeiShu failed'),
'interval': 5,
'redirect_url': request.GET.get('redirect_url'),
'auto_redirect': True,
}
return self.render_to_response(context)

View File

@@ -4,10 +4,12 @@
from __future__ import unicode_literals
import os
import datetime
from django.contrib.auth import login as auth_login, logout as auth_logout
from django.http import HttpResponse
from django.shortcuts import reverse, redirect
from django.utils.decorators import method_decorator
from django.db import transaction
from django.utils.translation import ugettext as _
from django.views.decorators.cache import never_cache
from django.views.decorators.csrf import csrf_protect
@@ -18,12 +20,12 @@ from django.conf import settings
from django.urls import reverse_lazy
from django.contrib.auth import BACKEND_SESSION_KEY
from common.utils import get_request_ip, FlashMessageUtil
from common.utils import FlashMessageUtil
from users.utils import (
redirect_user_first_login_or_index
)
from ..const import RSA_PRIVATE_KEY, RSA_PUBLIC_KEY
from .. import mixins, errors, utils
from .. import mixins, errors
from ..forms import get_user_login_form_cls
@@ -46,24 +48,44 @@ class UserLoginView(mixins.AuthMixin, FormView):
return None
next_url = request.GET.get('next') or '/'
auth_type = ''
auth_url = ''
if settings.AUTH_OPENID:
auth_type = 'OIDC'
auth_url = reverse(settings.AUTH_OPENID_AUTH_LOGIN_URL_NAME) + f'?next={next_url}'
elif settings.AUTH_CAS:
openid_auth_url = reverse(settings.AUTH_OPENID_AUTH_LOGIN_URL_NAME)
openid_auth_url = openid_auth_url + f'?next={next_url}'
else:
openid_auth_url = None
if settings.AUTH_CAS:
auth_type = 'CAS'
auth_url = reverse(settings.CAS_LOGIN_URL_NAME) + f'?next={next_url}'
if not auth_url:
cas_auth_url = reverse(settings.CAS_LOGIN_URL_NAME) + f'?next={next_url}'
else:
cas_auth_url = None
if not any([openid_auth_url, cas_auth_url]):
return None
message_data = {
'title': _('Redirecting'),
'message': _("Redirecting to {} authentication").format(auth_type),
'redirect_url': auth_url,
'has_cancel': True,
'cancel_url': reverse('authentication:login') + '?admin=1'
}
redirect_url = FlashMessageUtil.gen_message_url(message_data)
login_redirect = settings.LOGIN_REDIRECT_TO_BACKEND.lower()
if login_redirect in ['cas'] and cas_auth_url:
auth_url = cas_auth_url
elif login_redirect in ['openid', 'oidc'] and openid_auth_url:
auth_url = openid_auth_url
else:
auth_url = openid_auth_url or cas_auth_url
if settings.LOGIN_REDIRECT_TO_BACKEND or not settings.LOGIN_REDIRECT_MSG_ENABLED:
redirect_url = auth_url
else:
message_data = {
'title': _('Redirecting'),
'message': _("Redirecting to {} authentication").format(auth_type),
'redirect_url': auth_url,
'interval': 3,
'has_cancel': True,
'cancel_url': reverse('authentication:login') + '?admin=1'
}
redirect_url = FlashMessageUtil.gen_message_url(message_data)
query_string = request.GET.urlencode()
redirect_url = "{}&{}".format(redirect_url, query_string)
return redirect_url
@@ -87,7 +109,8 @@ class UserLoginView(mixins.AuthMixin, FormView):
self.request.session.delete_test_cookie()
try:
self.check_user_auth(decrypt_passwd=True)
with transaction.atomic():
self.check_user_auth(decrypt_passwd=True)
except errors.AuthFailedError as e:
form.add_error(None, e.msg)
self.set_login_failed_mark()
@@ -114,15 +137,6 @@ class UserLoginView(mixins.AuthMixin, FormView):
self.request.session[RSA_PUBLIC_KEY] = None
def get_context_data(self, **kwargs):
# 生成加解密密钥对public_key传递给前端private_key存入session中供解密使用
rsa_private_key = self.request.session.get(RSA_PRIVATE_KEY)
rsa_public_key = self.request.session.get(RSA_PUBLIC_KEY)
if not all((rsa_private_key, rsa_public_key)):
rsa_private_key, rsa_public_key = utils.gen_key_pair()
rsa_public_key = rsa_public_key.replace('\n', '\\n')
self.request.session[RSA_PRIVATE_KEY] = rsa_private_key
self.request.session[RSA_PUBLIC_KEY] = rsa_public_key
forgot_password_url = reverse('authentication:forgot-password')
has_other_auth_backend = settings.AUTHENTICATION_BACKENDS[0] != settings.AUTH_BACKEND_MODEL
if has_other_auth_backend and settings.FORGOT_PASSWORD_URL:
@@ -134,7 +148,7 @@ class UserLoginView(mixins.AuthMixin, FormView):
'AUTH_CAS': settings.AUTH_CAS,
'AUTH_WECOM': settings.AUTH_WECOM,
'AUTH_DINGTALK': settings.AUTH_DINGTALK,
'rsa_public_key': rsa_public_key,
'AUTH_FEISHU': settings.AUTH_FEISHU,
'forgot_password_url': forgot_password_url
}
kwargs.update(context)
@@ -200,8 +214,10 @@ class UserLoginWaitConfirmView(TemplateView):
if ticket:
timestamp_created = datetime.datetime.timestamp(ticket.date_created)
ticket_detail_url = TICKET_DETAIL_URL.format(id=ticket_id)
assignees = ticket.current_node.first().ticket_assignees.all()
assignees_display = ', '.join([str(i.assignee) for i in assignees])
msg = _("""Wait for <b>{}</b> confirm, You also can copy link to her/him <br/>
Don't close this page""").format(ticket.assignees_display)
Don't close this page""").format(assignees_display)
else:
timestamp_created = 0
ticket_detail_url = ''

View File

@@ -3,6 +3,8 @@
from __future__ import unicode_literals
from django.views.generic.edit import FormView
from django.utils.translation import gettext_lazy as _
from django.conf import settings
from .. import forms, errors, mixins
from .utils import redirect_to_guard_view
@@ -18,16 +20,43 @@ class UserLoginOtpView(mixins.AuthMixin, FormView):
redirect_field_name = 'next'
def form_valid(self, form):
otp_code = form.cleaned_data.get('otp_code')
otp_code = form.cleaned_data.get('code')
mfa_type = form.cleaned_data.get('mfa_type')
try:
self.check_user_mfa(otp_code)
self.check_user_mfa(otp_code, mfa_type)
return redirect_to_guard_view()
except (errors.MFAFailedError, errors.BlockMFAError) as e:
form.add_error('otp_code', e.msg)
form.add_error('code', e.msg)
return super().form_invalid(form)
except Exception as e:
logger.error(e)
import traceback
traceback.print_exception()
traceback.print_exception(e)
return redirect_to_guard_view()
def get_context_data(self, **kwargs):
user = self.get_user_from_session()
context = {
'methods': [
{
'name': 'otp',
'label': _('One-time password'),
'enable': bool(user.otp_secret_key),
'selected': False,
},
{
'name': 'sms',
'label': _('SMS'),
'enable': bool(user.phone) and settings.SMS_ENABLED and settings.XPACK_ENABLED,
'selected': False,
},
]
}
for item in context['methods']:
if item['enable']:
item['selected'] = True
break
context.update(kwargs)
return context

20
apps/common/db/encoder.py Normal file
View File

@@ -0,0 +1,20 @@
import json
from datetime import datetime
import uuid
from django.utils.translation import ugettext_lazy as _
from django.conf import settings
class ModelJSONFieldEncoder(json.JSONEncoder):
""" 解决一些类型的字段不能序列化的问题 """
def default(self, obj):
if isinstance(obj, datetime):
return obj.strftime(settings.DATETIME_DISPLAY_FORMAT)
if isinstance(obj, uuid.UUID):
return str(obj)
if isinstance(obj, type(_("ugettext_lazy"))):
return str(obj)
else:
return super().default(obj)

View File

@@ -10,3 +10,15 @@ def on_transaction_commit(func):
def inner(*args, **kwargs):
transaction.on_commit(lambda: func(*args, **kwargs))
return inner
class Singleton(object):
""" 单例类 """
def __init__(self, cls):
self._cls = cls
self._instance = {}
def __call__(self):
if self._cls not in self._instance:
self._instance[self._cls] = self._cls()
return self._instance[self._cls]

View File

@@ -1,4 +1,4 @@
from rest_framework.viewsets import GenericViewSet, ModelViewSet
from rest_framework.viewsets import GenericViewSet, ModelViewSet, ReadOnlyModelViewSet, ViewSet
from rest_framework_bulk import BulkModelViewSet
from ..mixins.api import (
@@ -15,19 +15,23 @@ class CommonMixin(SerializerMixin,
pass
class JmsGenericViewSet(CommonMixin,
GenericViewSet):
class JMSGenericViewSet(CommonMixin, GenericViewSet):
pass
class JMSModelViewSet(CommonMixin,
ModelViewSet):
class JMSViewSet(CommonMixin, ViewSet):
pass
class JMSBulkModelViewSet(CommonMixin,
AllowBulkDestroyMixin,
BulkModelViewSet):
class JMSModelViewSet(CommonMixin, ModelViewSet):
pass
class JMSReadOnlyModelViewSet(CommonMixin, ReadOnlyModelViewSet):
pass
class JMSBulkModelViewSet(CommonMixin, AllowBulkDestroyMixin, BulkModelViewSet):
pass

View File

@@ -110,9 +110,15 @@ class IDSpmFilter(filters.BaseFilterBackend):
return queryset
cache_key = const.KEY_CACHE_RESOURCE_IDS.format(spm)
resource_ids = cache.get(cache_key)
if resource_ids is None or not isinstance(resource_ids, list):
return queryset
queryset = queryset.filter(id__in=resource_ids)
if resource_ids is None:
return queryset.none()
if isinstance(resource_ids, str):
resource_ids = [resource_ids]
if hasattr(view, 'filter_spm_queryset'):
queryset = view.filter_spm_queryset(resource_ids, queryset)
else:
queryset = queryset.filter(id__in=resource_ids)
return queryset

View File

View File

@@ -1,6 +1,7 @@
from django.core.management.base import BaseCommand
from assets.signals_handler.node_assets_mapping import expire_node_assets_mapping_for_memory
from orgs.caches import OrgResourceStatisticsCache
from orgs.models import Organization
@@ -12,8 +13,16 @@ def expire_node_assets_mapping():
expire_node_assets_mapping_for_memory(org_id)
def expire_org_resource_statistics_cache():
orgs = Organization.objects.all()
for org in orgs:
cache = OrgResourceStatisticsCache(org)
cache.expire()
class Command(BaseCommand):
help = 'Expire caches'
def handle(self, *args, **options):
expire_node_assets_mapping()
expire_org_resource_statistics_cache()

View File

@@ -0,0 +1,6 @@
from .services.command import BaseActionCommand, Action
class Command(BaseActionCommand):
help = 'Restart services'
action = Action.restart.value

View File

@@ -0,0 +1,139 @@
from django.core.management.base import BaseCommand, CommandError
from django.db.models import TextChoices
from .utils import ServicesUtil
from .hands import *
class Services(TextChoices):
gunicorn = 'gunicorn', 'gunicorn'
daphne = 'daphne', 'daphne'
celery_ansible = 'celery_ansible', 'celery_ansible'
celery_default = 'celery_default', 'celery_default'
beat = 'beat', 'beat'
flower = 'flower', 'flower'
ws = 'ws', 'ws'
web = 'web', 'web'
celery = 'celery', 'celery'
task = 'task', 'task'
all = 'all', 'all'
@classmethod
def get_service_object_class(cls, name):
from . import services
services_map = {
cls.gunicorn.value: services.GunicornService,
cls.daphne: services.DaphneService,
cls.flower: services.FlowerService,
cls.celery_default: services.CeleryDefaultService,
cls.celery_ansible: services.CeleryAnsibleService,
cls.beat: services.BeatService
}
return services_map.get(name)
@classmethod
def ws_services(cls):
return [cls.daphne]
@classmethod
def web_services(cls):
return [cls.gunicorn, cls.daphne, cls.flower]
@classmethod
def celery_services(cls):
return [cls.celery_ansible, cls.celery_default]
@classmethod
def task_services(cls):
return cls.celery_services() + [cls.beat]
@classmethod
def all_services(cls):
return cls.web_services() + cls.task_services()
@classmethod
def export_services_values(cls):
return [cls.all.value, cls.web.value, cls.task.value]
@classmethod
def get_service_objects(cls, service_names, **kwargs):
services = set()
for name in service_names:
method_name = f'{name}_services'
if hasattr(cls, method_name):
_services = getattr(cls, method_name)()
elif hasattr(cls, name):
_services = [getattr(cls, name)]
else:
continue
services.update(set(_services))
service_objects = []
for s in services:
service_class = cls.get_service_object_class(s.value)
if not service_class:
continue
kwargs.update({
'name': s.value
})
service_object = service_class(**kwargs)
service_objects.append(service_object)
return service_objects
class Action(TextChoices):
start = 'start', 'start'
status = 'status', 'status'
stop = 'stop', 'stop'
restart = 'restart', 'restart'
class BaseActionCommand(BaseCommand):
help = 'Service Base Command'
action = None
util = None
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def add_arguments(self, parser):
parser.add_argument(
'services', nargs='+', choices=Services.export_services_values(), help='Service',
)
parser.add_argument('-d', '--daemon', nargs="?", const=True)
parser.add_argument('-w', '--worker', type=int, nargs="?", default=4)
parser.add_argument('-f', '--force', nargs="?", const=True)
def initial_util(self, *args, **options):
service_names = options.get('services')
service_kwargs = {
'worker_gunicorn': options.get('worker')
}
services = Services.get_service_objects(service_names=service_names, **service_kwargs)
kwargs = {
'services': services,
'run_daemon': options.get('daemon', False),
'stop_daemon': self.action == Action.stop.value and Services.all.value in service_names,
'force_stop': options.get('force') or False,
}
self.util = ServicesUtil(**kwargs)
def handle(self, *args, **options):
self.initial_util(*args, **options)
assert self.action in Action.values, f'The action {self.action} is not in the optional list'
_handle = getattr(self, f'_handle_{self.action}', lambda: None)
_handle()
def _handle_start(self):
self.util.start_and_watch()
os._exit(0)
def _handle_stop(self):
self.util.stop()
def _handle_restart(self):
self.util.restart()
def _handle_status(self):
self.util.show_status()

View File

@@ -0,0 +1,26 @@
import os
import sys
import logging
from django.conf import settings
from apps.jumpserver.const import CONFIG
try:
from apps.jumpserver import const
__version__ = const.VERSION
except ImportError as e:
print("Not found __version__: {}".format(e))
print("Python is: ")
logging.info(sys.executable)
__version__ = 'Unknown'
sys.exit(1)
HTTP_HOST = CONFIG.HTTP_BIND_HOST or '127.0.0.1'
HTTP_PORT = CONFIG.HTTP_LISTEN_PORT or 8080
WS_PORT = CONFIG.WS_LISTEN_PORT or 8082
DEBUG = CONFIG.DEBUG or False
BASE_DIR = os.path.dirname(settings.BASE_DIR)
LOG_DIR = os.path.join(BASE_DIR, 'logs')
APPS_DIR = os.path.join(BASE_DIR, 'apps')
TMP_DIR = os.path.join(BASE_DIR, 'tmp')

View File

@@ -0,0 +1,6 @@
from .beat import *
from .celery_ansible import *
from .celery_default import *
from .daphne import *
from .flower import *
from .gunicorn import *

View File

@@ -0,0 +1,204 @@
import abc
import time
import shutil
import psutil
import datetime
import threading
import subprocess
from ..hands import *
class BaseService(object):
def __init__(self, **kwargs):
self.name = kwargs['name']
self._process = None
self.STOP_TIMEOUT = 10
self.max_retry = 0
self.retry = 3
self.LOG_KEEP_DAYS = 7
self.EXIT_EVENT = threading.Event()
@property
@abc.abstractmethod
def cmd(self):
return []
@property
@abc.abstractmethod
def cwd(self):
return ''
@property
def is_running(self):
if self.pid == 0:
return False
try:
os.kill(self.pid, 0)
except (OSError, ProcessLookupError):
return False
else:
return True
def show_status(self):
if self.is_running:
msg = f'{self.name} is running: {self.pid}.'
else:
msg = f'{self.name} is stopped.'
print(msg)
# -- log --
@property
def log_filename(self):
return f'{self.name}.log'
@property
def log_filepath(self):
return os.path.join(LOG_DIR, self.log_filename)
@property
def log_file(self):
return open(self.log_filepath, 'a')
@property
def log_dir(self):
return os.path.dirname(self.log_filepath)
# -- end log --
# -- pid --
@property
def pid_filepath(self):
return os.path.join(TMP_DIR, f'{self.name}.pid')
@property
def pid(self):
if not os.path.isfile(self.pid_filepath):
return 0
with open(self.pid_filepath) as f:
try:
pid = int(f.read().strip())
except ValueError:
pid = 0
return pid
def write_pid(self):
with open(self.pid_filepath, 'w') as f:
f.write(str(self.process.pid))
def remove_pid(self):
if os.path.isfile(self.pid_filepath):
os.unlink(self.pid_filepath)
# -- end pid --
# -- process --
@property
def process(self):
if not self._process:
try:
self._process = psutil.Process(self.pid)
except:
pass
return self._process
# -- end process --
# -- action --
def open_subprocess(self):
kwargs = {'cwd': self.cwd, 'stderr': self.log_file, 'stdout': self.log_file}
self._process = subprocess.Popen(self.cmd, **kwargs)
def start(self):
if self.is_running:
self.show_status()
return
self.remove_pid()
self.open_subprocess()
self.write_pid()
self.start_other()
def start_other(self):
pass
def stop(self, force=False):
if not self.is_running:
self.show_status()
# self.remove_pid()
return
print(f'Stop service: {self.name}', end='')
sig = 9 if force else 15
os.kill(self.pid, sig)
if self.process is None:
print("\033[31m No process found\033[0m")
return
try:
self.process.wait(1)
except:
pass
for i in range(self.STOP_TIMEOUT):
if i == self.STOP_TIMEOUT - 1:
print("\033[31m Error\033[0m")
if not self.is_running:
print("\033[32m Ok\033[0m")
self.remove_pid()
break
else:
time.sleep(1)
continue
def watch(self):
self._check()
if not self.is_running:
self._restart()
self._rotate_log()
def _check(self):
now = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
print(f"{now} Check service status: {self.name} -> ", end='')
if self.process:
try:
self.process.wait(1) # 不wait子进程可能无法回收
except:
pass
if self.is_running:
print(f'running at {self.pid}')
else:
print(f'stopped at {self.pid}')
def _restart(self):
if self.retry > self.max_retry:
logging.info("Service start failed, exit: ", self.name)
self.EXIT_EVENT.set()
return
self.retry += 1
logging.info(f'> Find {self.name} stopped, retry {self.retry}, {self.pid}')
self.start()
def _rotate_log(self):
now = datetime.datetime.now()
_time = now.strftime('%H:%M')
if _time != '23:59':
return
backup_date = now.strftime('%Y-%m-%d')
backup_log_dir = os.path.join(self.log_dir, backup_date)
if not os.path.exists(backup_log_dir):
os.mkdir(backup_log_dir)
backup_log_path = os.path.join(backup_log_dir, self.log_filename)
if os.path.isfile(self.log_filepath) and not os.path.isfile(backup_log_path):
logging.info(f'Rotate log file: {self.log_filepath} => {backup_log_path}')
shutil.copy(self.log_filepath, backup_log_path)
with open(self.log_filepath, 'w') as f:
pass
to_delete_date = now - datetime.timedelta(days=self.LOG_KEEP_DAYS)
to_delete_dir = os.path.join(LOG_DIR, to_delete_date.strftime('%Y-%m-%d'))
if os.path.exists(to_delete_dir):
logging.info(f'Remove old log: {to_delete_dir}')
shutil.rmtree(to_delete_dir, ignore_errors=True)
# -- end action --

View File

@@ -0,0 +1,25 @@
from ..hands import *
from .base import BaseService
from django.core.cache import cache
__all__ = ['BeatService']
class BeatService(BaseService):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.lock = cache.lock('beat-distribute-start-lock', expire=60)
@property
def cmd(self):
print("\n- Start Beat as Periodic Task Scheduler")
cmd = [
sys.executable, 'start_celery_beat.py',
]
return cmd
@property
def cwd(self):
return os.path.join(BASE_DIR, 'utils')

View File

@@ -0,0 +1,11 @@
from .celery_base import CeleryBaseService
__all__ = ['CeleryAnsibleService']
class CeleryAnsibleService(CeleryBaseService):
def __init__(self, **kwargs):
kwargs['queue'] = 'ansible'
super().__init__(**kwargs)

View File

@@ -0,0 +1,38 @@
from ..hands import *
from .base import BaseService
class CeleryBaseService(BaseService):
def __init__(self, queue, num=10, **kwargs):
super().__init__(**kwargs)
self.queue = queue
self.num = num
@property
def cmd(self):
print('\n- Start Celery as Distributed Task Queue: {}'.format(self.queue.capitalize()))
os.environ.setdefault('PYTHONOPTIMIZE', '1')
os.environ.setdefault('ANSIBLE_FORCE_COLOR', 'True')
if os.getuid() == 0:
os.environ.setdefault('C_FORCE_ROOT', '1')
server_hostname = os.environ.get("SERVER_HOSTNAME")
if not server_hostname:
server_hostname = '%h'
cmd = [
'celery', 'worker',
'-P', 'threads',
'-A', 'ops',
'-l', 'INFO',
'-c', str(self.num),
'-Q', self.queue,
'-n', f'{self.queue}@{server_hostname}'
]
return cmd
@property
def cwd(self):
return APPS_DIR

View File

@@ -0,0 +1,16 @@
from .celery_base import CeleryBaseService
__all__ = ['CeleryDefaultService']
class CeleryDefaultService(CeleryBaseService):
def __init__(self, **kwargs):
kwargs['queue'] = 'celery'
super().__init__(**kwargs)
def start_other(self):
from terminal.startup import CeleryTerminal
celery_terminal = CeleryTerminal()
celery_terminal.start_heartbeat_thread()

Some files were not shown because too many files have changed in this diff Show More