Compare commits

...

159 Commits

Author SHA1 Message Date
feng626
95412d739a perf: 工单评论优化 2021-09-22 15:51:59 +08:00
老广
93b5876469 Merge pull request #6923 from jumpserver/pr@v2.14@fix_tokensmsoptions
fix: 修复KoKo登录时,未开启SMS服务出现了SMS选项的问题
2021-09-22 11:23:42 +08:00
Michael Bai
da02d1e456 fix: 修复KoKo登录时,未开启SMS服务出现了SMS选项的问题 2021-09-22 03:17:15 +00:00
ibuler
5928b265b7 perf: 登录错误提示颜色 2021-09-18 15:06:36 +08:00
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
208 changed files with 7227 additions and 3422 deletions

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

@@ -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

@@ -2,74 +2,57 @@
#
from django_filters import rest_framework as filters
from django.db.models import F, Value, CharField
from django.db.models.functions import Concat
from django.http import Http404
from django.db.models import F, Q
from common.drf.filters import BaseFilterSet
from common.drf.api import JMSModelViewSet
from common.utils import unique
from perms.models import ApplicationPermission
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(field_name='username')
app = filters.CharFilter(field_name='applications', lookup_expr='exact')
app_name = filters.CharFilter(field_name='app_name', lookup_expr='exact')
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 = ApplicationPermission
fields = ['type', 'category']
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(JMSModelViewSet):
permission_classes = (IsOrgAdmin, )
search_fields = ['username', 'app_name']
class ApplicationAccountViewSet(JMSBulkModelViewSet):
model = Account
search_fields = ['username', 'app_display']
filterset_class = AccountFilterSet
filterset_fields = ['username', 'app_name', 'type', 'category']
serializer_class = serializers.ApplicationAccountSerializer
http_method_names = ['get', 'put', 'patch', 'options']
filterset_fields = ['username', 'app_display', 'type', 'category', 'app']
serializer_class = serializers.AppAccountSerializer
permission_classes = (IsOrgAdmin,)
def get_queryset(self):
queryset = ApplicationPermission.objects\
.exclude(system_users__isnull=True) \
.exclude(applications__isnull=True) \
.annotate(uid=Concat(
'applications', Value('_'), 'system_users', output_field=CharField()
)) \
.annotate(systemuser=F('system_users')) \
.annotate(systemuser_display=F('system_users__name')) \
.annotate(username=F('system_users__username')) \
.annotate(password=F('system_users__password')) \
.annotate(app=F('applications')) \
.annotate(app_name=F("applications__name")) \
.values('username', 'password', 'systemuser', 'systemuser_display',
'app', 'app_name', 'category', 'type', 'uid', 'org_id')
return queryset
def get_object(self):
obj = self.get_queryset().filter(
uid=self.kwargs['pk']
).first()
if not obj:
raise Http404()
return obj
def filter_queryset(self, queryset):
queryset = super().filter_queryset(queryset)
queryset_list = unique(queryset, key=lambda x: (x['app'], x['systemuser']))
return queryset_list
@staticmethod
def filter_spm_queryset(resource_ids, queryset):
queryset = queryset.filter(uid__in=resource_ids)
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.ApplicationAccountSecretSerializer
serializer_class = serializers.AppAccountSecretSerializer
permission_classes = [IsOrgAdminOrAppUser, NeedMFAVerify]
http_method_names = ['get', 'options']

View File

@@ -6,15 +6,15 @@ 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': ['exact'],
@@ -24,8 +24,9 @@ class ApplicationViewSet(OrgBulkModelViewSet):
search_fields = ('name', 'type', 'category')
permission_classes = (IsOrgAdminOrAppUser,)
serializer_classes = {
'default': serializers.ApplicationSerializer,
'get_tree': TreeNodeSerializer
'default': serializers.AppSerializer,
'get_tree': TreeNodeSerializer,
'suggestion': serializers.MiniAppSerializer
}
@action(methods=['GET'], detail=False, url_path='tree')

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

@@ -4,20 +4,23 @@
from rest_framework import serializers
from django.utils.translation import ugettext_lazy as _
from orgs.models import Organization
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 .attrs import (
category_serializer_classes_mapping,
type_serializer_classes_mapping
)
from .. import models
from .. import const
__all__ = [
'ApplicationSerializer', 'ApplicationSerializerMixin',
'ApplicationAccountSerializer', 'ApplicationAccountSecretSerializer'
'AppSerializer', 'MiniAppSerializer', 'AppSerializerMixin',
'AppAccountSerializer', 'AppAccountSecretSerializer'
]
class ApplicationSerializerMixin(serializers.Serializer):
class AppSerializerMixin(serializers.Serializer):
attrs = MethodSerializer()
def get_attrs_serializer(self):
@@ -45,8 +48,14 @@ class ApplicationSerializerMixin(serializers.Serializer):
serializer = serializer_class
return serializer
def create(self, validated_data):
return super().create(validated_data)
class ApplicationSerializer(ApplicationSerializerMixin, BulkOrgResourceModelSerializer):
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'))
@@ -69,42 +78,60 @@ class ApplicationSerializer(ApplicationSerializerMixin, BulkOrgResourceModelSeri
return _attrs
class ApplicationAccountSerializer(serializers.Serializer):
id = serializers.ReadOnlyField(label=_("Id"), source='uid')
username = serializers.ReadOnlyField(label=_("Username"))
password = serializers.CharField(write_only=True, label=_("Password"))
systemuser = serializers.ReadOnlyField(label=_('System user'))
systemuser_display = serializers.ReadOnlyField(label=_("System user display"))
app = serializers.ReadOnlyField(label=_('App'))
app_name = serializers.ReadOnlyField(label=_("Application name"), read_only=True)
class MiniAppSerializer(serializers.ModelSerializer):
class Meta:
model = models.Application
fields = AppSerializer.Meta.fields_mini
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'))
uid = serializers.ReadOnlyField(label=_("Union id"))
org_id = serializers.ReadOnlyField(label=_("Organization"))
org_name = serializers.SerializerMethodField(label=_("Org name"))
category_mapper = dict(const.AppCategory.choices)
type_mapper = dict(const.AppType.choices)
def create(self, validated_data):
pass
def update(self, instance, validated_data):
pass
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',
]
extra_kwargs = {
'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
}
def get_category_display(self, obj):
return self.category_mapper.get(obj['category'])
return self.category_mapper.get(obj.category)
def get_type_display(self, obj):
return self.type_mapper.get(obj['type'])
return self.type_mapper.get(obj.type)
@staticmethod
def get_org_name(obj):
org = Organization.get_instance(obj['org_id'])
return org.name
@classmethod
def setup_eager_loading(cls, queryset):
""" Perform necessary eager loading of data. """
queryset = queryset.prefetch_related('systemuser', 'app')
return queryset
def to_representation(self, instance):
instance.load_auth()
return super().to_representation(instance)
class ApplicationAccountSecretSerializer(ApplicationAccountSerializer):
password = serializers.CharField(write_only=False, label=_("Password"))
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

@@ -64,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
@@ -110,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,9 +7,10 @@ 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, SystemUser
from ..models import Asset, Node, Platform
from .. import serializers
from ..tasks import (
update_assets_hardware_info_manual, test_assets_connectivity_manual,
@@ -17,7 +18,6 @@ from ..tasks import (
)
from ..filters import FilterAssetByNodeFilterBackend, LabelFilterBackend, IpInFilterBackend
logger = get_logger(__file__)
__all__ = [
'AssetViewSet', 'AssetPlatformRetrieveApi',
@@ -26,7 +26,7 @@ __all__ = [
]
class AssetViewSet(FilterAssetByNodeMixin, OrgBulkModelViewSet):
class AssetViewSet(SuggestionMixin, FilterAssetByNodeMixin, OrgBulkModelViewSet):
"""
API endpoint that allows Asset to be viewed or edited.
"""
@@ -43,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]

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

@@ -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

@@ -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

@@ -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

@@ -120,6 +120,7 @@ class Gateway(BaseUser):
except(paramiko.AuthenticationException,
paramiko.BadAuthenticationType,
paramiko.SSHException,
paramiko.ChannelException,
paramiko.ssh_exception.NoValidConnectionsError,
socket.gaierror) as e:
err = str(e)
@@ -128,6 +129,8 @@ class Gateway(BaseUser):
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
@@ -141,10 +144,17 @@ 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:
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, str(e)
return False, err
finally:
client.close()
self.is_connective = True

View File

@@ -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

@@ -8,7 +8,7 @@ from orgs.mixins.serializers import BulkOrgResourceModelSerializer
from ..models import Asset, Node, Platform, SystemUser
__all__ = [
'AssetSerializer', 'AssetSimpleSerializer',
'AssetSerializer', 'AssetSimpleSerializer', 'MiniAssetSerializer',
'ProtocolsField', 'PlatformSerializer',
'AssetTaskSerializer', 'AssetsTaskSerializer', 'ProtocolsField'
]
@@ -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,7 +188,6 @@ class PlatformSerializer(serializers.ModelSerializer):
class AssetSimpleSerializer(serializers.ModelSerializer):
class Meta:
model = Asset
fields = ['id', 'hostname', 'ip', 'port', 'connectivity', 'date_verified']

View File

@@ -10,11 +10,11 @@ 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',
]
@@ -31,14 +31,14 @@ 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': {
@@ -53,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):
@@ -184,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']
@@ -207,6 +214,7 @@ class SystemUserSimpleSerializer(serializers.ModelSerializer):
"""
系统用户最基本信息的数据结构
"""
class Meta:
model = SystemUser
fields = ('id', 'name', 'username')

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,10 +28,15 @@ 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)

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

@@ -1,6 +1,8 @@
# -*- coding: utf-8 -*-
#
from django.db.models.signals import post_save, post_delete, m2m_changed
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
@@ -35,7 +37,7 @@ MODELS_NEED_RECORD = (
# users
'User', 'UserGroup',
# acls
'LoginACL', 'LoginAssetACL',
'LoginACL', 'LoginAssetACL', 'LoginConfirmSetting',
# assets
'Asset', 'Node', 'AdminUser', 'SystemUser', 'Domain', 'Gateway', 'CommandFilterRule',
'CommandFilter', 'Platform', 'AuthBook',
@@ -98,68 +100,68 @@ def create_operate_log(action, sender, resource):
M2M_NEED_RECORD = {
'OrganizationMember': (
_('User and Organization'),
_('{User} *JOINED* {Organization}'),
_('{User} *LEFT* {Organization}')
_('{User} JOINED {Organization}'),
_('{User} LEFT {Organization}')
),
User.groups.through._meta.object_name: (
_('User and Group'),
_('{User} *JOINED* {UserGroup}'),
_('{User} *LEFT* {UserGroup}')
_('{User} JOINED {UserGroup}'),
_('{User} LEFT {UserGroup}')
),
SystemUser.assets.through._meta.object_name: (
_('Asset and SystemUser'),
_('{Asset} *ADD* {SystemUser}'),
_('{Asset} *REMOVE* {SystemUser}')
_('{Asset} ADD {SystemUser}'),
_('{Asset} REMOVE {SystemUser}')
),
Asset.nodes.through._meta.object_name: (
_('Node and Asset'),
_('{Node} *ADD* {Asset}'),
_('{Node} *REMOVE* {Asset}')
_('{Node} ADD {Asset}'),
_('{Node} REMOVE {Asset}')
),
AssetPermission.users.through._meta.object_name: (
_('User asset permissions'),
_('{AssetPermission} *ADD* {User}'),
_('{AssetPermission} *REMOVE* {User}'),
_('{AssetPermission} ADD {User}'),
_('{AssetPermission} REMOVE {User}'),
),
AssetPermission.user_groups.through._meta.object_name: (
_('User group asset permissions'),
_('{AssetPermission} *ADD* {UserGroup}'),
_('{AssetPermission} *REMOVE* {UserGroup}'),
_('{AssetPermission} ADD {UserGroup}'),
_('{AssetPermission} REMOVE {UserGroup}'),
),
AssetPermission.assets.through._meta.object_name: (
_('Asset permission'),
_('{AssetPermission} *ADD* {Asset}'),
_('{AssetPermission} *REMOVE* {Asset}'),
_('{AssetPermission} ADD {Asset}'),
_('{AssetPermission} REMOVE {Asset}'),
),
AssetPermission.nodes.through._meta.object_name: (
_('Node permission'),
_('{AssetPermission} *ADD* {Node}'),
_('{AssetPermission} *REMOVE* {Node}'),
_('{AssetPermission} ADD {Node}'),
_('{AssetPermission} REMOVE {Node}'),
),
AssetPermission.system_users.through._meta.object_name: (
_('Asset permission and SystemUser'),
_('{AssetPermission} *ADD* {SystemUser}'),
_('{AssetPermission} *REMOVE* {SystemUser}'),
_('{AssetPermission} ADD {SystemUser}'),
_('{AssetPermission} REMOVE {SystemUser}'),
),
ApplicationPermission.users.through._meta.object_name: (
_('User application permissions'),
_('{ApplicationPermission} *ADD* {User}'),
_('{ApplicationPermission} *REMOVE* {User}'),
_('{ApplicationPermission} ADD {User}'),
_('{ApplicationPermission} REMOVE {User}'),
),
ApplicationPermission.user_groups.through._meta.object_name: (
_('User group application permissions'),
_('{ApplicationPermission} *ADD* {UserGroup}'),
_('{ApplicationPermission} *REMOVE* {UserGroup}'),
_('{ApplicationPermission} ADD {UserGroup}'),
_('{ApplicationPermission} REMOVE {UserGroup}'),
),
ApplicationPermission.applications.through._meta.object_name: (
_('Application permission'),
_('{ApplicationPermission} *ADD* {Application}'),
_('{ApplicationPermission} *REMOVE* {Application}'),
_('{ApplicationPermission} ADD {Application}'),
_('{ApplicationPermission} REMOVE {Application}'),
),
ApplicationPermission.system_users.through._meta.object_name: (
_('Application permission and SystemUser'),
_('{ApplicationPermission} *ADD* {SystemUser}'),
_('{ApplicationPermission} *REMOVE* {SystemUser}'),
_('{ApplicationPermission} ADD {SystemUser}'),
_('{ApplicationPermission} REMOVE {SystemUser}'),
),
}
@@ -226,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

@@ -23,6 +23,7 @@ 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,
@@ -74,6 +75,7 @@ class ClientProtocolMixin:
'bookmarktype:i': '3',
'use redirection server name:i': '0',
'smart sizing:i': '0',
#'drivestoredirect:s': '*',
# 'domain:s': ''
# 'alternate shell:s:': '||MySQLWorkbench',
# 'remoteapplicationname:s': 'Firefox',
@@ -84,8 +86,11 @@ class ClientProtocolMixin:
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':

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

@@ -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')
}
}

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):

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 red-fonts">
{% 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 %}
@@ -220,7 +226,6 @@
<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;">
@@ -236,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

@@ -27,7 +27,9 @@ urlpatterns = [
path('auth/', api.TokenCreateApi.as_view(), name='user-auth'),
path('tokens/', api.TokenCreateApi.as_view(), name='auth-token'),
path('mfa/challenge/', api.MFAChallengeApi.as_view(), name='mfa-challenge'),
path('mfa/select/', api.MFASelectTypeApi.as_view(), name='mfa-select'),
path('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

@@ -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

@@ -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

@@ -9,6 +9,7 @@ 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,14 +19,13 @@ from django.views.generic.edit import FormView
from django.conf import settings
from django.urls import reverse_lazy
from django.contrib.auth import BACKEND_SESSION_KEY
from django.db.transaction import atomic
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
@@ -51,7 +51,8 @@ class UserLoginView(mixins.AuthMixin, FormView):
if settings.AUTH_OPENID:
auth_type = 'OIDC'
openid_auth_url = reverse(settings.AUTH_OPENID_AUTH_LOGIN_URL_NAME) + f'?next={next_url}'
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
@@ -64,16 +65,15 @@ class UserLoginView(mixins.AuthMixin, FormView):
if not any([openid_auth_url, cas_auth_url]):
return None
if settings.LOGIN_REDIRECT_TO_BACKEND == 'OPENID' and openid_auth_url:
auth_url = openid_auth_url
elif settings.LOGIN_REDIRECT_TO_BACKEND == 'CAS' and cas_auth_url:
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:
if settings.LOGIN_REDIRECT_TO_BACKEND or not settings.LOGIN_REDIRECT_MSG_ENABLED:
redirect_url = auth_url
else:
message_data = {
@@ -109,7 +109,7 @@ class UserLoginView(mixins.AuthMixin, FormView):
self.request.session.delete_test_cookie()
try:
with atomic():
with transaction.atomic():
self.check_user_auth(decrypt_passwd=True)
except errors.AuthFailedError as e:
form.add_error(None, e.msg)
@@ -137,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:
@@ -158,7 +149,6 @@ class UserLoginView(mixins.AuthMixin, FormView):
'AUTH_WECOM': settings.AUTH_WECOM,
'AUTH_DINGTALK': settings.AUTH_DINGTALK,
'AUTH_FEISHU': settings.AUTH_FEISHU,
'rsa_public_key': rsa_public_key,
'forgot_password_url': forgot_password_url
}
kwargs.update(context)
@@ -224,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

@@ -160,7 +160,7 @@ class BaseService(object):
if self.process:
try:
self.process.wait(1) # 不wait子进程可能无法回收
except subprocess.TimeoutExpired:
except:
pass
if self.is_running:

View File

@@ -2,9 +2,12 @@ import time
import hmac
import base64
from common.utils import get_logger
from common.message.backends.utils import digest, as_request
from common.message.backends.mixin import BaseRequest
logger = get_logger(__file__)
def sign(secret, data):
@@ -160,6 +163,7 @@ class DingTalk:
}
}
}
logger.info(f'Dingtalk send text: user_ids={user_ids} msg={msg}')
data = self._request.post(URL.SEND_MESSAGE, json=body, with_token=True)
return data

View File

@@ -106,6 +106,7 @@ class FeiShu(RequestMixin):
body['receive_id'] = user_id
try:
logger.info(f'Feishu send text: user_ids={user_ids} msg={msg}')
self._requests.post(URL.SEND_MESSAGE, params=params, json=body)
except APIException as e:
# 只处理可预知的错误

View File

@@ -0,0 +1,65 @@
from collections import OrderedDict
import importlib
from django.utils.translation import gettext_lazy as _
from django.db.models import TextChoices
from django.conf import settings
from common.utils import get_logger
from common.exceptions import JMSException
logger = get_logger(__file__)
class BACKENDS(TextChoices):
ALIBABA = 'alibaba', _('Alibaba cloud')
TENCENT = 'tencent', _('Tencent cloud')
class BaseSMSClient:
"""
短信终端的基类
"""
SIGN_AND_TMPL_SETTING_FIELD_PREFIX: str
@classmethod
def new_from_settings(cls):
raise NotImplementedError
def send_sms(self, phone_numbers: list, sign_name: str, template_code: str, template_param: dict, **kwargs):
raise NotImplementedError
class SMS:
client: BaseSMSClient
def __init__(self, backend=None):
backend = backend or settings.SMS_BACKEND
if backend not in BACKENDS:
raise JMSException(
code='sms_provider_not_support',
detail=_('SMS provider not support: {}').format(backend)
)
m = importlib.import_module(f'.{backend or settings.SMS_BACKEND}', __package__)
self.client = m.client.new_from_settings()
def send_sms(self, phone_numbers: list, sign_name: str, template_code: str, template_param: dict, **kwargs):
return self.client.send_sms(
phone_numbers=phone_numbers,
sign_name=sign_name,
template_code=template_code,
template_param=template_param,
**kwargs
)
def send_verify_code(self, phone_number, code):
sign_name = getattr(settings, f'{self.client.SIGN_AND_TMPL_SETTING_FIELD_PREFIX}_VERIFY_SIGN_NAME')
template_code = getattr(settings, f'{self.client.SIGN_AND_TMPL_SETTING_FIELD_PREFIX}_VERIFY_TEMPLATE_CODE')
if not (sign_name and template_code):
raise JMSException(
code='verify_code_sign_tmpl_invalid',
detail=_('SMS verification code signature or template invalid')
)
return self.send_sms([phone_number], sign_name, template_code, OrderedDict(code=code))

View File

@@ -0,0 +1,61 @@
import json
from django.utils.translation import gettext_lazy as _
from django.conf import settings
from alibabacloud_dysmsapi20170525.client import Client as Dysmsapi20170525Client
from alibabacloud_tea_openapi import models as open_api_models
from alibabacloud_dysmsapi20170525 import models as dysmsapi_20170525_models
from Tea.exceptions import TeaException
from common.utils import get_logger
from common.exceptions import JMSException
from . import BaseSMSClient
logger = get_logger(__file__)
class AlibabaSMS(BaseSMSClient):
SIGN_AND_TMPL_SETTING_FIELD_PREFIX = 'ALIBABA'
@classmethod
def new_from_settings(cls):
return cls(
access_key_id=settings.ALIBABA_ACCESS_KEY_ID,
access_key_secret=settings.ALIBABA_ACCESS_KEY_SECRET
)
def __init__(self, access_key_id: str, access_key_secret: str):
config = open_api_models.Config(
# 您的AccessKey ID,
access_key_id=access_key_id,
# 您的AccessKey Secret,
access_key_secret=access_key_secret
)
# 访问的域名
config.endpoint = 'dysmsapi.aliyuncs.com'
self.client = Dysmsapi20170525Client(config)
def send_sms(self, phone_numbers: list, sign_name: str, template_code: str, template_param: dict, **kwargs):
phone_numbers_str = ','.join(phone_numbers)
send_sms_request = dysmsapi_20170525_models.SendSmsRequest(
phone_numbers=phone_numbers_str, sign_name=sign_name,
template_code=template_code, template_param=json.dumps(template_param)
)
try:
logger.info(f'Alibaba sms send: '
f'phone_numbers={phone_numbers} '
f'sign_name={sign_name} '
f'template_code={template_code} '
f'template_param={template_param}')
response = self.client.send_sms(send_sms_request)
# 这里只判断是否成功,失败抛出异常
if response.body.code != 'OK':
raise JMSException(detail=response.body.message, code=response.body.code)
except TeaException as e:
if e.code == 'SignatureDoesNotMatch':
raise JMSException(code=e.code, detail=_('Signature does not match'))
raise JMSException(code=e.code, detail=e.message)
return response
client = AlibabaSMS

View File

@@ -0,0 +1,99 @@
import json
from collections import OrderedDict
from django.conf import settings
from common.exceptions import JMSException
from common.utils import get_logger
from tencentcloud.common import credential
from tencentcloud.common.exception.tencent_cloud_sdk_exception import TencentCloudSDKException
# 导入对应产品模块的client models。
from tencentcloud.sms.v20210111 import sms_client, models
# 导入可选配置类
from tencentcloud.common.profile.client_profile import ClientProfile
from tencentcloud.common.profile.http_profile import HttpProfile
from . import BaseSMSClient
logger = get_logger(__file__)
class TencentSMS(BaseSMSClient):
"""
https://cloud.tencent.com/document/product/382/43196#.E5.8F.91.E9.80.81.E7.9F.AD.E4.BF.A1
"""
SIGN_AND_TMPL_SETTING_FIELD_PREFIX = 'TENCENT'
@classmethod
def new_from_settings(cls):
return cls(
secret_id=settings.TENCENT_SECRET_ID,
secret_key=settings.TENCENT_SECRET_KEY,
sdkappid=settings.TENCENT_SDKAPPID
)
def __init__(self, secret_id: str, secret_key: str, sdkappid: str):
self.sdkappid = sdkappid
cred = credential.Credential(secret_id, secret_key)
httpProfile = HttpProfile()
httpProfile.reqMethod = "POST" # post请求(默认为post请求)
httpProfile.reqTimeout = 30 # 请求超时时间,单位为秒(默认60秒)
httpProfile.endpoint = "sms.tencentcloudapi.com"
clientProfile = ClientProfile()
clientProfile.signMethod = "TC3-HMAC-SHA256" # 指定签名算法
clientProfile.language = "en-US"
clientProfile.httpProfile = httpProfile
self.client = sms_client.SmsClient(cred, "ap-guangzhou", clientProfile)
def send_sms(self, phone_numbers: list, sign_name: str, template_code: str, template_param: OrderedDict, **kwargs):
try:
req = models.SendSmsRequest()
# 基本类型的设置:
# SDK采用的是指针风格指定参数即使对于基本类型你也需要用指针来对参数赋值。
# SDK提供对基本类型的指针引用封装函数
# 帮助链接:
# 短信控制台: https://console.cloud.tencent.com/smsv2
# sms helper: https://cloud.tencent.com/document/product/382/3773
# 短信应用ID: 短信SdkAppId在 [短信控制台] 添加应用后生成的实际SdkAppId示例如1400006666
req.SmsSdkAppId = self.sdkappid
# 短信签名内容: 使用 UTF-8 编码,必须填写已审核通过的签名,签名信息可登录 [短信控制台] 查看
req.SignName = sign_name
# 短信码号扩展号: 默认未开通,如需开通请联系 [sms helper]
req.ExtendCode = ""
# 用户的 session 内容: 可以携带用户侧 ID 等上下文信息server 会原样返回
req.SessionContext = "Jumpserver"
# 国际/港澳台短信 senderid: 国内短信填空,默认未开通,如需开通请联系 [sms helper]
req.SenderId = ""
# 下发手机号码,采用 E.164 标准,+[国家或地区码][手机号]
# 示例如:+8613711112222 其中前面有一个+号 86为国家码13711112222为手机号最多不要超过200个手机号
req.PhoneNumberSet = phone_numbers
# 模板 ID: 必须填写已审核通过的模板 ID。模板ID可登录 [短信控制台] 查看
req.TemplateId = template_code
# 模板参数: 若无模板参数,则设置为空
req.TemplateParamSet = list(template_param.values())
# 通过client对象调用DescribeInstances方法发起请求。注意请求方法名与请求对象是对应的。
# 返回的resp是一个DescribeInstancesResponse类的实例与请求对象对应。
logger.info(f'Tencent sms send: '
f'phone_numbers={phone_numbers} '
f'sign_name={sign_name} '
f'template_code={template_code} '
f'template_param={template_param}')
resp = self.client.SendSms(req)
try:
code = resp.SendStatusSet[0].Code
msg = resp.SendStatusSet[0].Message
except IndexError:
raise JMSException(code='response_bad', detail=resp)
if code.lower() != 'ok':
raise JMSException(code=code, detail=msg)
return resp
except TencentCloudSDKException as e:
raise JMSException(code=e.code, detail=e.message)
client = TencentSMS

View File

@@ -115,6 +115,7 @@ class WeCom(RequestMixin):
},
**extra_params
}
logger.info(f'Wecom send text: users={users} msg={msg}')
data = self._requests.post(URL.SEND_MESSAGE, json=body, check_errcode_is_0=False)
errcode = data['errcode']

View File

@@ -3,12 +3,14 @@
# coding: utf-8
from django.contrib.auth.mixins import UserPassesTestMixin
from django.utils import timezone
from rest_framework.decorators import action
from rest_framework import permissions
from rest_framework.response import Response
from common.permissions import IsValidUser
__all__ = ["DatetimeSearchMixin", "PermissionsMixin"]
from rest_framework import permissions
class DatetimeSearchMixin:
date_format = '%Y-%m-%d'
@@ -52,3 +54,19 @@ class PermissionsMixin(UserPassesTestMixin):
if not permission_class().has_permission(self.request, self):
return False
return True
class SuggestionMixin:
suggestion_mini_count = 10
@action(methods=['get'], detail=False, permission_classes=(IsValidUser,))
def suggestions(self, request, *args, **kwargs):
queryset = self.filter_queryset(self.get_queryset())
queryset = queryset[:self.suggestion_mini_count]
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)

View File

@@ -33,6 +33,8 @@ class IsSuperUser(IsValidUser):
class IsSuperUserOrAppUser(IsSuperUser):
def has_permission(self, request, view):
if request.user.is_anonymous:
return False
return super(IsSuperUserOrAppUser, self).has_permission(request, view) \
or request.user.is_app
@@ -67,6 +69,8 @@ class IsOrgAdminOrAppUser(IsValidUser):
def has_permission(self, request, view):
if not current_org:
return False
if request.user.is_anonymous:
return False
return super(IsOrgAdminOrAppUser, self).has_permission(request, view) \
and (current_org.can_admin_by(request.user) or request.user.is_app)
@@ -191,3 +195,11 @@ class HasQueryParamsUserAndIsCurrentOrgMember(permissions.BasePermission):
return False
query_user = current_org.get_members().filter(id=query_user_id).first()
return bool(query_user)
class OnlySuperUserCanList(IsValidUser):
def has_permission(self, request, view):
user = request.user
if view.action == 'list' and not user.is_superuser:
return False
return True

View File

@@ -1,5 +1,7 @@
# -*- coding: utf-8 -*-
#
import re
from django.core.validators import RegexValidator
from django.utils.translation import ugettext_lazy as _
from rest_framework.validators import (
@@ -32,3 +34,12 @@ class NoSpecialChars:
raise serializers.ValidationError(
_("Should not contains special characters")
)
class PhoneValidator:
pattern = re.compile(r"^1[356789]\d{9}$")
message = _('The mobile phone number format is incorrect')
def __call__(self, value):
if not self.pattern.match(value):
raise serializers.ValidationError(self.message)

View File

@@ -17,9 +17,10 @@ from terminal.models import Session
from terminal.utils import ComponentsPrometheusMetricsUtil
from orgs.utils import current_org
from common.permissions import IsOrgAdmin, IsOrgAuditor
from common.utils import lazyproperty
from common.utils import lazyproperty, get_request_ip
from orgs.caches import OrgResourceStatisticsCache
__all__ = ['IndexApi']
@@ -297,19 +298,33 @@ class IndexApi(DatesLoginMetricMixin, APIView):
class HealthApiMixin(APIView):
def is_token_right(self):
token = self.request.query_params.get('token')
ok_token = settings.HEALTH_CHECK_TOKEN
if ok_token and token != ok_token:
return False
return True
pass
def check_permissions(self, request):
if not self.is_token_right():
msg = 'Health check token error, ' \
'Please set query param in url and same with setting HEALTH_CHECK_TOKEN. ' \
'eg: $PATH/?token=$HEALTH_CHECK_TOKEN'
self.permission_denied(request, message={'error': msg}, code=403)
# 先去掉 Health Api 的权限校验,方便各组件直接调用
# def is_token_right(self):
# token = self.request.query_params.get('token')
# ok_token = settings.HEALTH_CHECK_TOKEN
# if ok_token and token != ok_token:
# return False
# return True
# def is_localhost(self):
# ip = get_request_ip(self.request)
# return ip in ['localhost', '127.0.0.1']
# def check_permissions(self, request):
# if self.is_token_right():
# return
# if self.is_localhost():
# return
# msg = '''
# Health check token error,
# Please set query param in url and
# same with setting HEALTH_CHECK_TOKEN.
# eg: $PATH/?token=$HEALTH_CHECK_TOKEN
# '''
# self.permission_denied(request, message={'error': msg}, code=403)
class HealthCheckView(HealthApiMixin):

View File

@@ -14,6 +14,7 @@ import types
import errno
import json
import yaml
import copy
from importlib import import_module
from django.urls import reverse_lazy
from urllib.parse import urljoin, urlparse
@@ -179,13 +180,14 @@ class Config(dict):
'AUTH_OPENID_CLIENT_SECRET': 'client-secret',
'AUTH_OPENID_SHARE_SESSION': True,
'AUTH_OPENID_IGNORE_SSL_VERIFICATION': True,
# OpenID 新配置参数 (version >= 1.5.9)
'AUTH_OPENID_PROVIDER_ENDPOINT': 'https://op-example.com/',
'AUTH_OPENID_PROVIDER_AUTHORIZATION_ENDPOINT': 'https://op-example.com/authorize',
'AUTH_OPENID_PROVIDER_TOKEN_ENDPOINT': 'https://op-example.com/token',
'AUTH_OPENID_PROVIDER_JWKS_ENDPOINT': 'https://op-example.com/jwks',
'AUTH_OPENID_PROVIDER_USERINFO_ENDPOINT': 'https://op-example.com/userinfo',
'AUTH_OPENID_PROVIDER_END_SESSION_ENDPOINT': 'https://op-example.com/logout',
'AUTH_OPENID_PROVIDER_ENDPOINT': 'https://oidc.example.com/',
'AUTH_OPENID_PROVIDER_AUTHORIZATION_ENDPOINT': 'https://oidc.example.com/authorize',
'AUTH_OPENID_PROVIDER_TOKEN_ENDPOINT': 'https://oidc.example.com/token',
'AUTH_OPENID_PROVIDER_JWKS_ENDPOINT': 'https://oidc.example.com/jwks',
'AUTH_OPENID_PROVIDER_USERINFO_ENDPOINT': 'https://oidc.example.com/userinfo',
'AUTH_OPENID_PROVIDER_END_SESSION_ENDPOINT': 'https://oidc.example.com/logout',
'AUTH_OPENID_PROVIDER_SIGNATURE_ALG': 'HS256',
'AUTH_OPENID_PROVIDER_SIGNATURE_KEY': None,
'AUTH_OPENID_SCOPES': 'openid profile email',
@@ -194,10 +196,13 @@ class Config(dict):
'AUTH_OPENID_USE_STATE': True,
'AUTH_OPENID_USE_NONCE': True,
'AUTH_OPENID_ALWAYS_UPDATE_USER': True,
# OpenID 旧配置参数 (version <= 1.5.8 (discarded))
'AUTH_OPENID_SERVER_URL': 'http://openid',
# Keycloak 旧配置参数 (version <= 1.5.8 (discarded))
'AUTH_OPENID_KEYCLOAK': True,
'AUTH_OPENID_SERVER_URL': 'https://keycloak.example.com',
'AUTH_OPENID_REALM_NAME': None,
# Raidus 认证
'AUTH_RADIUS': False,
'RADIUS_SERVER': 'localhost',
'RADIUS_PORT': 1812,
@@ -205,37 +210,60 @@ class Config(dict):
'RADIUS_ENCRYPT_PASSWORD': True,
'OTP_IN_RADIUS': False,
# Cas 认证
'AUTH_CAS': False,
'CAS_SERVER_URL': "http://host/cas/",
'CAS_ROOT_PROXIED_AS': '',
'CAS_SERVER_URL': "https://example.com/cas/",
'CAS_ROOT_PROXIED_AS': 'https://example.com',
'CAS_LOGOUT_COMPLETELY': True,
'CAS_VERSION': 3,
'CAS_USERNAME_ATTRIBUTE': 'uid',
'CAS_APPLY_ATTRIBUTES_TO_USER': False,
'CAS_RENAME_ATTRIBUTES': {},
'CAS_RENAME_ATTRIBUTES': {'uid': 'username'},
'CAS_CREATE_USER': True,
'AUTH_SSO': False,
'AUTH_SSO_AUTHKEY_TTL': 60 * 15,
# 企业微信
'AUTH_WECOM': False,
'WECOM_CORPID': '',
'WECOM_AGENTID': '',
'WECOM_SECRET': '',
# 钉钉
'AUTH_DINGTALK': False,
'DINGTALK_AGENTID': '',
'DINGTALK_APPKEY': '',
'DINGTALK_APPSECRET': '',
# 飞书
'AUTH_FEISHU': False,
'FEISHU_APP_ID': '',
'FEISHU_APP_SECRET': '',
'LOGIN_REDIRECT_TO_BACKEND': '', # 'OPENID / CAS
'LOGIN_REDIRECT_MSG_ENABLED': True,
'SMS_ENABLED': False,
'SMS_BACKEND': '',
'SMS_TEST_PHONE': '',
'ALIBABA_ACCESS_KEY_ID': '',
'ALIBABA_ACCESS_KEY_SECRET': '',
'ALIBABA_VERIFY_SIGN_NAME': '',
'ALIBABA_VERIFY_TEMPLATE_CODE': '',
'TENCENT_SECRET_ID': '',
'TENCENT_SECRET_KEY': '',
'TENCENT_SDKAPPID': '',
'TENCENT_VERIFY_SIGN_NAME': '',
'TENCENT_VERIFY_TEMPLATE_CODE': '',
'OTP_VALID_WINDOW': 2,
'OTP_ISSUER_NAME': 'JumpServer',
'EMAIL_SUFFIX': 'jumpserver.org',
'EMAIL_SUFFIX': 'example.com',
# Terminal配置
'TERMINAL_PASSWORD_AUTH': True,
'TERMINAL_PUBLIC_KEY_AUTH': True,
'TERMINAL_HEARTBEAT_INTERVAL': 20,
@@ -245,7 +273,10 @@ class Config(dict):
'TERMINAL_HOST_KEY': '',
'TERMINAL_TELNET_REGEX': '',
'TERMINAL_COMMAND_STORAGE': {},
'TERMINAL_RDP_ADDR': '',
'XRDP_ENABLED': True,
# 安全配置
'SECURITY_MFA_AUTH': 0, # 0 不开启 1 全局开启 2 管理员开启
'SECURITY_COMMAND_EXECUTION': True,
'SECURITY_SERVICE_ACCOUNT_REGISTRATION': True,
@@ -262,60 +293,64 @@ class Config(dict):
'SECURITY_PASSWORD_SPECIAL_CHAR': False,
'SECURITY_LOGIN_CHALLENGE_ENABLED': False,
'SECURITY_LOGIN_CAPTCHA_ENABLED': True,
'SECURITY_DATA_CRYPTO_ALGO': 'aes',
'SECURITY_INSECURE_COMMAND': False,
'SECURITY_INSECURE_COMMAND_LEVEL': 5,
'SECURITY_INSECURE_COMMAND_EMAIL_RECEIVER': '',
'SECURITY_LUNA_REMEMBER_AUTH': True,
'SECURITY_WATERMARK_ENABLED': True,
'SECURITY_MFA_VERIFY_TTL': 3600,
'SECURITY_SESSION_SHARE': True,
'OLD_PASSWORD_HISTORY_LIMIT_COUNT': 5,
'LOGIN_CONFIRM_ENABLE': False, # 准备废弃,放到 acl 中
'CHANGE_AUTH_PLAN_SECURE_MODE_ENABLED': True,
'USER_LOGIN_SINGLE_MACHINE_ENABLED': False,
'ONLY_ALLOW_EXIST_USER_AUTH': False,
'ONLY_ALLOW_AUTH_FROM_SOURCE': False,
# 启动前
'HTTP_BIND_HOST': '0.0.0.0',
'HTTP_LISTEN_PORT': 8080,
'WS_LISTEN_PORT': 8070,
'SYSLOG_ADDR': '', # '192.168.0.1:514'
'SYSLOG_FACILITY': 'user',
'SYSLOG_SOCKTYPE': 2,
'PERM_EXPIRED_CHECK_PERIODIC': 60 * 60,
'FLOWER_URL': "127.0.0.1:5555",
'LANGUAGE_CODE': 'zh',
'TIME_ZONE': 'Asia/Shanghai',
'FORCE_SCRIPT_NAME': '',
'SESSION_COOKIE_SECURE': False,
'CSRF_COOKIE_SECURE': False,
'REFERER_CHECK_ENABLED': False,
'SESSION_SAVE_EVERY_REQUEST': True,
'SESSION_EXPIRE_AT_BROWSER_CLOSE_FORCE': False,
'SERVER_REPLAY_STORAGE': {},
'SECURITY_DATA_CRYPTO_ALGO': 'aes',
# 记录清理清理
'LOGIN_LOG_KEEP_DAYS': 200,
'TASK_LOG_KEEP_DAYS': 90,
'OPERATE_LOG_KEEP_DAYS': 200,
'FTP_LOG_KEEP_DAYS': 200,
'ASSETS_PERM_CACHE_TIME': 3600 * 24,
'SECURITY_MFA_VERIFY_TTL': 3600,
'OLD_PASSWORD_HISTORY_LIMIT_COUNT': 5,
'ASSETS_PERM_CACHE_ENABLE': HAS_XPACK,
'SYSLOG_ADDR': '', # '192.168.0.1:514'
'SYSLOG_FACILITY': 'user',
'SYSLOG_SOCKTYPE': 2,
'PERM_SINGLE_ASSET_TO_UNGROUP_NODE': False,
'PERM_EXPIRED_CHECK_PERIODIC': 60 * 60,
'WINDOWS_SSH_DEFAULT_SHELL': 'cmd',
'FLOWER_URL': "127.0.0.1:5555",
'DEFAULT_ORG_SHOW_ALL_USERS': True,
'PERIOD_TASK_ENABLED': True,
'FORCE_SCRIPT_NAME': '',
'LOGIN_CONFIRM_ENABLE': False,
'WINDOWS_SKIP_ALL_MANUAL_PASSWORD': False,
'ORG_CHANGE_TO_URL': '',
'LANGUAGE_CODE': 'zh',
'TIME_ZONE': 'Asia/Shanghai',
'CHANGE_AUTH_PLAN_SECURE_MODE_ENABLED': True,
'USER_LOGIN_SINGLE_MACHINE_ENABLED': False,
'TICKETS_ENABLED': True,
'SESSION_COOKIE_SECURE': False,
'CSRF_COOKIE_SECURE': False,
'REFERER_CHECK_ENABLED': False,
'SERVER_REPLAY_STORAGE': {},
'CONNECTION_TOKEN_ENABLED': False,
'ONLY_ALLOW_EXIST_USER_AUTH': False,
'ONLY_ALLOW_AUTH_FROM_SOURCE': False,
'SESSION_SAVE_EVERY_REQUEST': True,
'SESSION_EXPIRE_AT_BROWSER_CLOSE_FORCE': False,
'FORGOT_PASSWORD_URL': '',
'HEALTH_CHECK_TOKEN': '',
'LOGIN_REDIRECT_TO_BACKEND': None, # 'OPENID / CAS
'CLOUD_SYNC_TASK_EXECUTION_KEEP_DAYS': 30,
'TERMINAL_RDP_ADDR': ''
# 废弃的
'DEFAULT_ORG_SHOW_ALL_USERS': True,
'ORG_CHANGE_TO_URL': '',
'WINDOWS_SKIP_ALL_MANUAL_PASSWORD': False,
'CONNECTION_TOKEN_ENABLED': False,
'PERM_SINGLE_ASSET_TO_UNGROUP_NODE': False,
'WINDOWS_SSH_DEFAULT_SHELL': 'cmd',
'PERIOD_TASK_ENABLED': True,
'TICKETS_ENABLED': True,
'FORGOT_PASSWORD_URL': '',
'HEALTH_CHECK_TOKEN': '',
}
def compatible_auth_openid_of_key(self):
@staticmethod
def convert_keycloak_to_openid(keycloak_config):
"""
兼容OpenID旧配置 (即 version <= 1.5.8)
因为旧配置只支持OpenID协议的Keycloak实现,
@@ -323,62 +358,79 @@ class Config(dict):
构造出新配置中标准OpenID协议中所需的Endpoint即可
(Keycloak说明文档参考: https://www.keycloak.org/docs/latest/securing_apps/)
"""
if not self.AUTH_OPENID:
openid_config = copy.deepcopy(keycloak_config)
auth_openid = openid_config.get('AUTH_OPENID')
auth_openid_realm_name = openid_config.get('AUTH_OPENID_REALM_NAME')
auth_openid_server_url = openid_config.get('AUTH_OPENID_SERVER_URL')
if not auth_openid:
return
realm_name = self.AUTH_OPENID_REALM_NAME
if realm_name is None:
if auth_openid and not auth_openid_realm_name:
# 开启的是标准 OpenID 配置,关掉 Keycloak 配置
openid_config.update({
'AUTH_OPENID_KEYCLOAK': False
})
if auth_openid_realm_name is None:
return
compatible_keycloak_config = [
(
'AUTH_OPENID_PROVIDER_ENDPOINT',
self.AUTH_OPENID_SERVER_URL
),
(
'AUTH_OPENID_PROVIDER_AUTHORIZATION_ENDPOINT',
'/realms/{}/protocol/openid-connect/auth'.format(realm_name)
),
(
'AUTH_OPENID_PROVIDER_TOKEN_ENDPOINT',
'/realms/{}/protocol/openid-connect/token'.format(realm_name)
),
(
'AUTH_OPENID_PROVIDER_JWKS_ENDPOINT',
'/realms/{}/protocol/openid-connect/certs'.format(realm_name)
),
(
'AUTH_OPENID_PROVIDER_USERINFO_ENDPOINT',
'/realms/{}/protocol/openid-connect/userinfo'.format(realm_name)
),
(
'AUTH_OPENID_PROVIDER_END_SESSION_ENDPOINT',
'/realms/{}/protocol/openid-connect/logout'.format(realm_name)
)
]
for key, value in compatible_keycloak_config:
self[key] = value
# # convert key # #
compatible_config = {
'AUTH_OPENID_PROVIDER_ENDPOINT': auth_openid_server_url,
def compatible_auth_openid_of_value(self):
"""
兼容值的绝对路径、相对路径
(key 为 AUTH_OPENID_PROVIDER_*_ENDPOINT 的配置)
"""
if not self.AUTH_OPENID:
return
'AUTH_OPENID_PROVIDER_AUTHORIZATION_ENDPOINT': '/realms/{}/protocol/openid-connect/auth'
''.format(auth_openid_realm_name),
'AUTH_OPENID_PROVIDER_TOKEN_ENDPOINT': '/realms/{}/protocol/openid-connect/token'
''.format(auth_openid_realm_name),
'AUTH_OPENID_PROVIDER_JWKS_ENDPOINT': '/realms/{}/protocol/openid-connect/certs'
''.format(auth_openid_realm_name),
'AUTH_OPENID_PROVIDER_USERINFO_ENDPOINT': '/realms/{}/protocol/openid-connect/userinfo'
''.format(auth_openid_realm_name),
'AUTH_OPENID_PROVIDER_END_SESSION_ENDPOINT': '/realms/{}/protocol/openid-connect/logout'
''.format(auth_openid_realm_name)
}
for key, value in compatible_config.items():
openid_config[key] = value
base = self.AUTH_OPENID_PROVIDER_ENDPOINT
config = list(self.items())
for key, value in config:
# # convert value # #
""" 兼容值的绝对路径、相对路径 (key 为 AUTH_OPENID_PROVIDER_*_ENDPOINT 的配置) """
base = openid_config.get('AUTH_OPENID_PROVIDER_ENDPOINT')
for key, value in openid_config.items():
result = re.match(r'^AUTH_OPENID_PROVIDER_.*_ENDPOINT$', key)
if result is None:
continue
if value is None:
# None 在 url 中有特殊含义 (比如对于: end_session_endpoint)
continue
value = build_absolute_uri(base, value)
openid_config[key] = value
return openid_config
def get_keycloak_config(self):
keycloak_config = {
'AUTH_OPENID': self.AUTH_OPENID,
'AUTH_OPENID_REALM_NAME': self.AUTH_OPENID_REALM_NAME,
'AUTH_OPENID_SERVER_URL': self.AUTH_OPENID_SERVER_URL,
'AUTH_OPENID_PROVIDER_ENDPOINT': self.AUTH_OPENID_PROVIDER_ENDPOINT
}
return keycloak_config
def set_openid_config(self, openid_config):
for key, value in openid_config.items():
self[key] = value
def compatible_auth_openid(self, keycloak_config=None):
if keycloak_config is None:
keycloak_config = self.get_keycloak_config()
openid_config = self.convert_keycloak_to_openid(keycloak_config)
if openid_config:
self.set_openid_config(openid_config)
def compatible(self):
"""
对配置做兼容处理
@@ -388,14 +440,8 @@ class Config(dict):
处理顺序要保持先对key做处理, 再对value做处理,
因为处理value的时候只根据最新版本支持的key进行
"""
parts = ['key', 'value']
targets = ['auth_openid']
for part in parts:
for target in targets:
method_name = 'compatible_{}_of_{}'.format(target, part)
method = getattr(self, method_name, None)
if method is not None:
method()
# 兼容 OpenID 配置
self.compatible_auth_openid()
def convert_type(self, k, v):
default_value = self.defaults.get(k)

View File

@@ -122,6 +122,8 @@ AUTH_FEISHU = CONFIG.AUTH_FEISHU
FEISHU_APP_ID = CONFIG.FEISHU_APP_ID
FEISHU_APP_SECRET = CONFIG.FEISHU_APP_SECRET
# Other setting
TOKEN_EXPIRATION = CONFIG.TOKEN_EXPIRATION
LOGIN_CONFIRM_ENABLE = CONFIG.LOGIN_CONFIRM_ENABLE
@@ -145,6 +147,7 @@ AUTH_BACKEND_AUTH_TOKEN = 'authentication.backends.api.AuthorizationTokenAuthent
AUTHENTICATION_BACKENDS = [
AUTH_BACKEND_MODEL, AUTH_BACKEND_PUBKEY, AUTH_BACKEND_WECOM,
AUTH_BACKEND_DINGTALK, AUTH_BACKEND_FEISHU, AUTH_BACKEND_AUTH_TOKEN,
AUTH_BACKEND_SSO,
]
if AUTH_CAS:
@@ -154,8 +157,6 @@ if AUTH_OPENID:
AUTHENTICATION_BACKENDS.insert(0, AUTH_BACKEND_OIDC_CODE)
if AUTH_RADIUS:
AUTHENTICATION_BACKENDS.insert(0, AUTH_BACKEND_RADIUS)
if AUTH_SSO:
AUTHENTICATION_BACKENDS.append(AUTH_BACKEND_SSO)
ONLY_ALLOW_EXIST_USER_AUTH = CONFIG.ONLY_ALLOW_EXIST_USER_AUTH

View File

@@ -87,6 +87,7 @@ MIDDLEWARE = [
'orgs.middleware.OrgMiddleware',
'authentication.backends.oidc.middleware.OIDCRefreshIDTokenMiddleware',
'authentication.backends.cas.middleware.CASMiddleware',
'authentication.middleware.MFAMiddleware',
'simple_history.middleware.HistoryRequestMiddleware',
]

View File

@@ -72,14 +72,9 @@ TERMINAL_HOST_KEY = CONFIG.TERMINAL_HOST_KEY
TERMINAL_HEADER_TITLE = CONFIG.TERMINAL_HEADER_TITLE
TERMINAL_TELNET_REGEX = CONFIG.TERMINAL_TELNET_REGEX
# User or user group permission cache time, default 3600 seconds
ASSETS_PERM_CACHE_ENABLE = CONFIG.ASSETS_PERM_CACHE_ENABLE
ASSETS_PERM_CACHE_TIME = CONFIG.ASSETS_PERM_CACHE_TIME
# Asset user auth external backend, default AuthBook backend
BACKEND_ASSET_USER_AUTH_VAULT = False
DEFAULT_ORG_SHOW_ALL_USERS = CONFIG.DEFAULT_ORG_SHOW_ALL_USERS
PERM_SINGLE_ASSET_TO_UNGROUP_NODE = CONFIG.PERM_SINGLE_ASSET_TO_UNGROUP_NODE
PERM_EXPIRED_CHECK_PERIODIC = CONFIG.PERM_EXPIRED_CHECK_PERIODIC
WINDOWS_SSH_DEFAULT_SHELL = CONFIG.WINDOWS_SSH_DEFAULT_SHELL
@@ -129,7 +124,28 @@ HEALTH_CHECK_TOKEN = CONFIG.HEALTH_CHECK_TOKEN
TERMINAL_RDP_ADDR = CONFIG.TERMINAL_RDP_ADDR
SECURITY_LUNA_REMEMBER_AUTH = CONFIG.SECURITY_LUNA_REMEMBER_AUTH
SECURITY_WATERMARK_ENABLED = CONFIG.SECURITY_WATERMARK_ENABLED
SECURITY_SESSION_SHARE = CONFIG.SECURITY_SESSION_SHARE
LOGIN_REDIRECT_TO_BACKEND = CONFIG.LOGIN_REDIRECT_TO_BACKEND
LOGIN_REDIRECT_MSG_ENABLED = CONFIG.LOGIN_REDIRECT_MSG_ENABLED
CLOUD_SYNC_TASK_EXECUTION_KEEP_DAYS = CONFIG.CLOUD_SYNC_TASK_EXECUTION_KEEP_DAYS
XRDP_ENABLED = CONFIG.XRDP_ENABLED
# SMS enabled
SMS_ENABLED = CONFIG.SMS_ENABLED
SMS_BACKEND = CONFIG.SMS_BACKEND
SMS_TEST_PHONE = CONFIG.SMS_TEST_PHONE
# Alibaba
ALIBABA_ACCESS_KEY_ID = CONFIG.ALIBABA_ACCESS_KEY_ID
ALIBABA_ACCESS_KEY_SECRET = CONFIG.ALIBABA_ACCESS_KEY_SECRET
ALIBABA_SMS_SIGN_AND_TEMPLATES = CONFIG.ALIBABA_SMS_SIGN_AND_TEMPLATES
# TENCENT
TENCENT_SECRET_ID = CONFIG.TENCENT_SECRET_ID
TENCENT_SECRET_KEY = CONFIG.TENCENT_SECRET_KEY
TENCENT_SDKAPPID = CONFIG.TENCENT_SDKAPPID
TENCENT_SMS_SIGN_AND_TEMPLATES = CONFIG.TENCENT_SMS_SIGN_AND_TEMPLATES

View File

@@ -32,6 +32,7 @@ app_view_patterns = [
path('ops/', include('ops.urls.view_urls'), name='ops'),
path('common/', include('common.urls.view_urls'), name='common'),
re_path(r'flower/(?P<path>.*)', views.celery_flower_view, name='flower-view'),
path('download/', views.ResourceDownload.as_view(), name='download')
]
if settings.XPACK_ENABLED:

View File

@@ -4,7 +4,7 @@ import re
from django.http import HttpResponseRedirect, JsonResponse, Http404
from django.conf import settings
from django.views.generic import View
from django.views.generic import View, TemplateView
from django.shortcuts import redirect
from django.utils.translation import ugettext_lazy as _
from django.views.decorators.csrf import csrf_exempt
@@ -16,7 +16,8 @@ from common.http import HttpResponseTemporaryRedirect
__all__ = [
'LunaView', 'I18NView', 'KokoView', 'WsView',
'redirect_format_api', 'redirect_old_apps_view', 'UIView'
'redirect_format_api', 'redirect_old_apps_view', 'UIView',
'ResourceDownload',
]
@@ -84,3 +85,6 @@ class KokoView(View):
"</div>If you see this page, prove that you are not accessing the nginx listening port. Good luck.</div>")
return HttpResponse(msg)
class ResourceDownload(TemplateView):
template_name = 'resource_download.html'

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +1,18 @@
from django.http import Http404
from rest_framework.mixins import ListModelMixin, UpdateModelMixin
from rest_framework.mixins import ListModelMixin, UpdateModelMixin, RetrieveModelMixin
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from common.drf.api import JMSGenericViewSet
from common.permissions import IsObjectOwner, IsSuperUser, OnlySuperUserCanList
from notifications.notifications import system_msgs
from notifications.models import SystemMsgSubscription
from notifications.models import SystemMsgSubscription, UserMsgSubscription
from notifications.backends import BACKEND
from notifications.serializers import (
SystemMsgSubscriptionSerializer, SystemMsgSubscriptionByCategorySerializer
SystemMsgSubscriptionSerializer, SystemMsgSubscriptionByCategorySerializer,
UserMsgSubscriptionSerializer,
)
__all__ = ('BackendListView', 'SystemMsgSubscriptionViewSet')
__all__ = ('BackendListView', 'SystemMsgSubscriptionViewSet', 'UserMsgSubscriptionViewSet')
class BackendListView(APIView):
@@ -70,3 +70,13 @@ class SystemMsgSubscriptionViewSet(ListModelMixin,
serializer = self.get_serializer(data, many=True)
return Response(data=serializer.data)
class UserMsgSubscriptionViewSet(ListModelMixin,
RetrieveModelMixin,
UpdateModelMixin,
JMSGenericViewSet):
lookup_field = 'user_id'
queryset = UserMsgSubscription.objects.all()
serializer_class = UserMsgSubscriptionSerializer
permission_classes = (IsObjectOwner | IsSuperUser, OnlySuperUserCanList)

View File

@@ -1,11 +1,9 @@
import importlib
from django.utils.translation import gettext_lazy as _
from django.db import models
from .dingtalk import DingTalk
from .email import Email
from .site_msg import SiteMessage
from .wecom import WeCom
from .feishu import FeiShu
client_name_mapper = {}
class BACKEND(models.TextChoices):
@@ -14,17 +12,11 @@ class BACKEND(models.TextChoices):
DINGTALK = 'dingtalk', _('DingTalk')
SITE_MSG = 'site_msg', _('Site message')
FEISHU = 'feishu', _('FeiShu')
# SMS = 'sms', _('SMS')
@property
def client(self):
client = {
self.EMAIL: Email,
self.WECOM: WeCom,
self.DINGTALK: DingTalk,
self.SITE_MSG: SiteMessage,
self.FEISHU: FeiShu,
}[self]
return client
return client_name_mapper[self]
def get_account(self, user):
return self.client.get_account(user)
@@ -37,3 +29,8 @@ class BACKEND(models.TextChoices):
def filter_enable_backends(cls, backends):
enable_backends = [b for b in backends if cls(b).is_enable]
return enable_backends
for b in BACKEND:
m = importlib.import_module(f'.{b}', __package__)
client_name_mapper[b] = m.backend

View File

@@ -14,6 +14,9 @@ class DingTalk(BackendBase):
agentid=settings.DINGTALK_AGENTID
)
def send_msg(self, users, msg):
def send_msg(self, users, message, subject=None):
accounts, __, __ = self.get_accounts(users)
return self.dingtalk.send_text(accounts, msg)
return self.dingtalk.send_text(accounts, message)
backend = DingTalk

View File

@@ -8,7 +8,10 @@ class Email(BackendBase):
account_field = 'email'
is_enable_field_in_settings = 'EMAIL_HOST_USER'
def send_msg(self, users, subject, message):
def send_msg(self, users, message, subject):
from_email = settings.EMAIL_FROM or settings.EMAIL_HOST_USER
accounts, __, __ = self.get_accounts(users)
send_mail(subject, message, from_email, accounts, html_message=message)
backend = Email

View File

@@ -14,6 +14,9 @@ class FeiShu(BackendBase):
app_secret=settings.FEISHU_APP_SECRET
)
def send_msg(self, users, msg):
def send_msg(self, users, message, subject=None):
accounts, __, __ = self.get_accounts(users)
return self.client.send_text(accounts, msg)
return self.client.send_text(accounts, message)
backend = FeiShu

View File

@@ -5,10 +5,13 @@ from .base import BackendBase
class SiteMessage(BackendBase):
account_field = 'id'
def send_msg(self, users, subject, message):
def send_msg(self, users, message, subject):
accounts, __, __ = self.get_accounts(users)
Client.send_msg(subject, message, user_ids=accounts)
@classmethod
def is_enable(cls):
return True
backend = SiteMessage

View File

@@ -0,0 +1,25 @@
from django.conf import settings
from common.message.backends.sms.alibaba import AlibabaSMS as Client
from .base import BackendBase
class SMS(BackendBase):
account_field = 'phone'
is_enable_field_in_settings = 'SMS_ENABLED'
def __init__(self):
"""
暂时只对接阿里,之后再扩展
"""
self.client = Client(
access_key_id=settings.ALIBABA_ACCESS_KEY_ID,
access_key_secret=settings.ALIBABA_ACCESS_KEY_SECRET
)
def send_msg(self, users, sign_name: str, template_code: str, template_param: dict):
accounts, __, __ = self.get_accounts(users)
return self.client.send_sms(accounts, sign_name, template_code, template_param)
backend = SMS

View File

@@ -15,6 +15,9 @@ class WeCom(BackendBase):
agentid=settings.WECOM_AGENTID
)
def send_msg(self, users, msg):
def send_msg(self, users, message, subject=None):
accounts, __, __ = self.get_accounts(users)
return self.wecom.send_text(accounts, msg)
return self.wecom.send_text(accounts, message)
backend = WeCom

View File

@@ -44,7 +44,7 @@ class Migration(migrations.Migration):
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
('message_type', models.CharField(max_length=128)),
('receive_backends', models.JSONField(default=list)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_msg_subscriptions', to=settings.AUTH_USER_MODEL)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_msg_subscription', to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,

View File

@@ -0,0 +1,55 @@
# Generated by Django 3.1.12 on 2021-09-09 11:46
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
def init_user_msg_subscription(apps, schema_editor):
UserMsgSubscription = apps.get_model('notifications', 'UserMsgSubscription')
User = apps.get_model('users', 'User')
to_create = []
users = User.objects.all()
for user in users:
receive_backends = []
receive_backends.append('site_msg')
if user.email:
receive_backends.append('email')
if user.wecom_id:
receive_backends.append('wecom')
if user.dingtalk_id:
receive_backends.append('dingtalk')
if user.feishu_id:
receive_backends.append('feishu')
to_create.append(UserMsgSubscription(user=user, receive_backends=receive_backends))
UserMsgSubscription.objects.bulk_create(to_create)
print(f'\n Init user message subscription: {len(to_create)}')
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('notifications', '0001_initial'),
('users', '0036_user_feishu_id'),
]
operations = [
migrations.RemoveField(
model_name='usermsgsubscription',
name='message_type',
),
migrations.AlterField(
model_name='usermsgsubscription',
name='user',
field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='user_msg_subscription', to=settings.AUTH_USER_MODEL),
),
migrations.RunPython(init_user_msg_subscription)
]

View File

@@ -6,12 +6,11 @@ __all__ = ('SystemMsgSubscription', 'UserMsgSubscription')
class UserMsgSubscription(JMSModel):
message_type = models.CharField(max_length=128)
user = models.ForeignKey('users.User', related_name='user_msg_subscriptions', on_delete=models.CASCADE)
user = models.OneToOneField('users.User', related_name='user_msg_subscription', on_delete=models.CASCADE)
receive_backends = models.JSONField(default=list)
def __str__(self):
return f'{self.message_type}'
return f'{self.user} subscription: {self.receive_backends}'
class SystemMsgSubscription(JMSModel):

View File

@@ -1,14 +1,18 @@
from typing import Iterable
import traceback
from itertools import chain
import time
from django.db.utils import ProgrammingError
from celery import shared_task
from django.utils.translation import gettext_lazy as _
from common.utils.timezone import now
from common.utils import lazyproperty
from users.models import User
from notifications.backends import BACKEND
from .models import SystemMsgSubscription
from .models import SystemMsgSubscription, UserMsgSubscription
__all__ = ('SystemMessage', 'UserMessage')
__all__ = ('SystemMessage', 'UserMessage', 'system_msgs')
system_msgs = []
@@ -66,40 +70,81 @@ class Message(metaclass=MessageType):
raise NotImplementedError
def send_msg(self, users: Iterable, backends: Iterable = BACKEND):
backends = set(backends)
backends.add(BACKEND.SITE_MSG) # 站内信必须发
for backend in backends:
try:
backend = BACKEND(backend)
if not backend.is_enable:
continue
get_msg_method = getattr(self, f'get_{backend}_msg', self.get_common_msg)
msg = get_msg_method()
try:
msg = get_msg_method()
except NotImplementedError:
continue
client = backend.client()
if isinstance(msg, dict):
client.send_msg(users, **msg)
else:
client.send_msg(users, msg)
client.send_msg(users, **msg)
except:
traceback.print_exc()
def get_common_msg(self) -> str:
def send_test_msg(self):
from users.models import User
users = User.objects.filter(username='admin')
self.send_msg(users, [])
def get_common_msg(self) -> dict:
raise NotImplementedError
def get_dingtalk_msg(self) -> str:
def get_text_msg(self) -> dict:
return self.common_msg
def get_html_msg(self) -> dict:
return self.common_msg
@lazyproperty
def common_msg(self) -> dict:
return self.get_common_msg()
def get_wecom_msg(self) -> str:
return self.get_common_msg()
@lazyproperty
def text_msg(self) -> dict:
return self.get_text_msg()
@lazyproperty
def html_msg(self) -> dict:
return self.get_html_msg()
# --------------------------------------------------------------
# 支持不同发送消息的方式定义自己的消息内容,比如有些支持 html 标签
def get_dingtalk_msg(self) -> dict:
# 钉钉相同的消息一天只能发一次,所以给所有消息添加基于时间的序号,使他们不相同
message = self.text_msg['message']
suffix = _('\nTime: {}').format(now())
def get_email_msg(self) -> dict:
msg = self.get_common_msg()
subject = f'{msg[:80]} ...' if len(msg) >= 80 else msg
return {
'subject': subject,
'message': msg
'subject': self.text_msg['subject'],
'message': message + suffix
}
def get_wecom_msg(self) -> dict:
return self.text_msg
def get_feishu_msg(self) -> dict:
return self.text_msg
def get_email_msg(self) -> dict:
return self.html_msg
def get_site_msg_msg(self) -> dict:
return self.get_email_msg()
return self.html_msg
def get_sms_msg(self) -> dict:
return self.text_msg
# --------------------------------------------------------------
class SystemMessage(Message):
@@ -125,4 +170,16 @@ class SystemMessage(Message):
class UserMessage(Message):
pass
user: User
def __init__(self, user):
self.user = user
def publish(self):
"""
发送消息到每个用户配置的接收方式上
"""
sub = UserMsgSubscription.objects.get(user=self.user)
self.send_msg([self.user], sub.receive_backends)

View File

@@ -1,7 +1,7 @@
from rest_framework import serializers
from common.drf.serializers import BulkModelSerializer
from notifications.models import SystemMsgSubscription
from notifications.models import SystemMsgSubscription, UserMsgSubscription
class SystemMsgSubscriptionSerializer(BulkModelSerializer):
@@ -27,3 +27,11 @@ class SystemMsgSubscriptionByCategorySerializer(serializers.Serializer):
category = serializers.CharField()
category_label = serializers.CharField()
children = SystemMsgSubscriptionSerializer(many=True)
class UserMsgSubscriptionSerializer(BulkModelSerializer):
receive_backends = serializers.ListField(child=serializers.CharField(), read_only=False)
class Meta:
model = UserMsgSubscription
fields = ('user_id', 'receive_backends',)

View File

@@ -6,14 +6,14 @@ from django.utils.functional import LazyObject
from django.db.models.signals import post_save
from django.db.models.signals import post_migrate
from django.dispatch import receiver
from django.db.utils import DEFAULT_DB_ALIAS
from django.apps import apps as global_apps
from django.apps import AppConfig
from notifications.backends import BACKEND
from users.models import User
from common.utils.connection import RedisPubSub
from common.utils import get_logger
from common.decorator import on_transaction_commit
from .models import SiteMessage, SystemMsgSubscription
from .models import SiteMessage, SystemMsgSubscription, UserMsgSubscription
from .notifications import SystemMessage
@@ -82,3 +82,13 @@ def create_system_messages(app_config: AppConfig, **kwargs):
logger.info(f'Create SystemMsgSubscription: package={app_config.module.__package__} type={message_type}')
except ModuleNotFoundError:
pass
@receiver(post_save, sender=User)
def on_user_post_save(sender, instance, created, **kwargs):
if created:
receive_backends = []
for backend in BACKEND:
if backend.get_account(instance):
receive_backends.append(backend)
UserMsgSubscription.objects.create(user=instance, receive_backends=receive_backends)

View File

@@ -2,9 +2,12 @@ from django.db.models import F
from django.db import transaction
from common.utils.timezone import now
from common.utils import get_logger
from users.models import User
from .models import SiteMessage as SiteMessageModel, SiteMessageUsers
logger = get_logger(__file__)
class SiteMessageUtil:
@@ -14,6 +17,11 @@ class SiteMessageUtil:
if not any((user_ids, group_ids, is_broadcast)):
raise ValueError('No recipient is specified')
logger.info(f'Site message send: '
f'user_ids={user_ids} '
f'group_ids={group_ids} '
f'subject={subject} '
f'message={message}')
with transaction.atomic():
site_msg = SiteMessageModel.objects.create(
subject=subject, message=message,

View File

@@ -8,6 +8,7 @@ app_name = 'notifications'
router = BulkRouter()
router.register('system-msg-subscription', api.SystemMsgSubscriptionViewSet, 'system-msg-subscription')
router.register('user-msg-subscription', api.UserMsgSubscriptionViewSet, 'user-msg-subscription')
router.register('site-message', api.SiteMessageViewSet, 'site-message')
urlpatterns = [

View File

@@ -1,10 +1,9 @@
import threading
import json
from redis.exceptions import ConnectionError
from channels.generic.websocket import JsonWebsocketConsumer
from common.utils import get_logger
from .models import SiteMessage
from .site_msg import SiteMessageUtil
from .signals_handler import new_site_msg_chan
@@ -12,13 +11,14 @@ logger = get_logger(__name__)
class SiteMsgWebsocket(JsonWebsocketConsumer):
disconnected = False
refresh_every_seconds = 10
chan = None
def connect(self):
user = self.scope["user"]
if user.is_authenticated:
self.accept()
self.chan = new_site_msg_chan.subscribe()
thread = threading.Thread(target=self.unread_site_msg_count)
thread.start()
@@ -48,9 +48,8 @@ class SiteMsgWebsocket(JsonWebsocketConsumer):
user_id = str(self.scope["user"].id)
self.send_unread_msg_count()
while not self.disconnected:
subscribe = new_site_msg_chan.subscribe()
for message in subscribe.listen():
try:
for message in self.chan.listen():
if message['type'] != 'message':
continue
try:
@@ -64,7 +63,10 @@ class SiteMsgWebsocket(JsonWebsocketConsumer):
self.send_unread_msg_count()
except json.JSONDecoder as e:
logger.debug('Decode json error: ', e)
except ConnectionError:
logger.debug('Redis chan closed')
def disconnect(self, close_code):
self.disconnected = True
if self.chan is not None:
self.chan.close()
self.close()

View File

@@ -46,7 +46,7 @@ class BaseHost(Host):
if host_data.get('username'):
self.set_variable('ansible_user', host_data['username'])
# 添加密码和
# 添加密码和
if host_data.get('password'):
self.set_variable('ansible_ssh_pass', host_data['password'])
if host_data.get('private_key'):

View File

@@ -159,7 +159,7 @@ class PeriodTaskFormMixin(forms.Form):
)
interval = forms.IntegerField(
required=False, initial=24,
help_text=_('Tips: (Units: hour)'), label=_("Cycle perform"),
help_text=_('Unit: hour'), label=_("Cycle perform"),
)
def get_initial_for_field(self, field, field_name):

View File

@@ -17,8 +17,19 @@ class ServerPerformanceMessage(SystemMessage):
def __init__(self, msg):
self._msg = msg
def get_common_msg(self):
return self._msg
def get_html_msg(self) -> dict:
subject = self._msg[:80]
return {
'subject': subject.replace('<br>', '; '),
'message': self._msg
}
def get_text_msg(self) -> dict:
subject = self._msg[:80]
return {
'subject': subject.replace('<br>', '; '),
'message': self._msg.replace('<br>', '\n')
}
@classmethod
def post_insert_to_db(cls, subscription: SystemMsgSubscription):
@@ -35,14 +46,14 @@ class ServerPerformanceCheckUtil(object):
'max_threshold': False,
'alarm_msg_format': _('The terminal is offline: {name}')
},
'disk_usage': {
'disk_used': {
'default': 0,
'max_threshold': 80,
'alarm_msg_format': _(
'[Disk] Disk used more than {max_threshold}%: => {value} ({name})'
)
},
'memory_usage': {
'memory_used': {
'default': 0,
'max_threshold': 85,
'alarm_msg_format': _(
@@ -78,7 +89,6 @@ class ServerPerformanceCheckUtil(object):
default = data['default']
max_threshold = data['max_threshold']
value = getattr(self._terminal.stat, item, default)
print(value, max_threshold, self._terminal.name, self._terminal.id)
if isinstance(value, bool) and value != max_threshold:
return
elif isinstance(value, (int, float)) and value < max_threshold:

View File

@@ -19,6 +19,7 @@ __all__ = [
class OrgManager(models.Manager):
def all_group_by_org(self):
from ..models import Organization
orgs = list(Organization.objects.all())

View File

@@ -25,7 +25,7 @@ class ResourceStatisticsSerializer(serializers.Serializer):
app_perms_amount = serializers.IntegerField(required=False)
class OrgSerializer(ModelSerializer):
class OrgSerializer(BulkModelSerializer):
users = serializers.PrimaryKeyRelatedField(many=True, queryset=User.objects.all(), write_only=True, required=False)
admins = serializers.PrimaryKeyRelatedField(many=True, queryset=User.objects.all(), write_only=True, required=False)
auditors = serializers.PrimaryKeyRelatedField(many=True, queryset=User.objects.all(), write_only=True, required=False)

View File

@@ -19,6 +19,7 @@ from common.decorator import on_transaction_commit
from common.signals import django_ready
from common.utils import get_logger
from common.utils.connection import RedisPubSub
from assets.models import CommandFilterRule
logger = get_logger(__file__)
@@ -91,17 +92,18 @@ def on_org_delete(sender, instance, **kwargs):
root_node.delete()
def _remove_users(model, users, org):
def _remove_users(model, users, org, user_field_name='users'):
with tmp_to_org(org):
if not isinstance(users, (tuple, list, set)):
users = (users, )
m2m_model = model.users.through
reverse = model.users.reverse
user_field = getattr(model, user_field_name)
m2m_model = user_field.through
reverse = user_field.reverse
if reverse:
m2m_field_name = model.users.field.m2m_reverse_field_name()
m2m_field_name = user_field.field.m2m_reverse_field_name()
else:
m2m_field_name = model.users.field.m2m_field_name()
m2m_field_name = user_field.field.m2m_field_name()
relations = m2m_model.objects.filter(**{
'user__in': users,
f'{m2m_field_name}__org_id': org.id
@@ -149,6 +151,8 @@ def _clear_users_from_org(org, users):
for m in models:
_remove_users(m, users, org)
_remove_users(CommandFilterRule, users, org, user_field_name='reviewers')
@receiver(m2m_changed, sender=OrganizationMember)
def on_org_user_changed(action, instance, reverse, pk_set, **kwargs):

View File

@@ -16,6 +16,7 @@ class ApplicationPermissionViewSet(BasePermissionViewSet):
'name': ['exact'],
'category': ['exact'],
'type': ['exact', 'in'],
'from_ticket': ['exact']
}
search_fields = ['name', 'category', 'type']
custom_filter_fields = BasePermissionViewSet.custom_filter_fields + [

View File

@@ -19,8 +19,8 @@ class UserGroupGrantedApplicationsApi(CommonApiMixin, ListAPIView):
获取用户组直接授权的应用
"""
permission_classes = (IsOrgAdminOrAppUser,)
serializer_class = serializers.ApplicationGrantedSerializer
only_fields = serializers.ApplicationGrantedSerializer.Meta.only_fields
serializer_class = serializers.AppGrantedSerializer
only_fields = serializers.AppGrantedSerializer.Meta.only_fields
filterset_fields = ['id', 'name', 'category', 'type', 'comment']
search_fields = ['name', 'comment']

View File

@@ -24,8 +24,8 @@ __all__ = [
class AllGrantedApplicationsMixin(CommonApiMixin, ListAPIView):
only_fields = serializers.ApplicationGrantedSerializer.Meta.only_fields
serializer_class = serializers.ApplicationGrantedSerializer
only_fields = serializers.AppGrantedSerializer.Meta.only_fields
serializer_class = serializers.AppGrantedSerializer
filterset_fields = {
'id': ['exact'],
'name': ['exact'],

4
apps/perms/const.py Normal file
View File

@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
#
from django.db.models import TextChoices
from django.utils.translation import ugettext_lazy as _

View File

@@ -21,8 +21,8 @@ class PermissionBaseFilter(BaseFilterSet):
class Meta:
fields = (
'user_id', 'username', 'system_user_id', 'system_user', 'user_group_id',
'user_group', 'name', 'all', 'is_valid',
'user_id', 'username', 'system_user_id', 'system_user',
'user_group_id', 'user_group', 'name', 'all', 'is_valid',
)
@property
@@ -118,7 +118,7 @@ class AssetPermissionFilter(PermissionBaseFilter):
fields = (
'user_id', 'username', 'system_user_id', 'system_user', 'user_group_id',
'user_group', 'node_id', 'node', 'asset_id', 'hostname', 'ip', 'name',
'all', 'asset_id', 'is_valid', 'is_effective',
'all', 'asset_id', 'is_valid', 'is_effective', 'from_ticket'
)
@property

View File

@@ -0,0 +1,23 @@
# Generated by Django 3.1.12 on 2021-09-06 02:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('perms', '0018_auto_20210208_1515'),
]
operations = [
migrations.AddField(
model_name='applicationpermission',
name='from_ticket',
field=models.BooleanField(default=False, verbose_name='From ticket'),
),
migrations.AddField(
model_name='assetpermission',
name='from_ticket',
field=models.BooleanField(default=False, verbose_name='From ticket'),
),
]

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