Compare commits

...

273 Commits

Author SHA1 Message Date
Jiangjie.Bai
42e9fbf37a Merge pull request #7103 from jumpserver/dev
v2.15.0
2021-10-28 15:31:44 +08:00
ibuler
d44656aa10 fix: 修复修改用户信息后,绑定丢失
perf: 还原提示文案
2021-10-28 15:29:55 +08:00
feng626
09d4228182 Merge pull request #7102 from jumpserver/pr@dev@ticket_tips
perf: 工单错误提示优化
2021-10-28 15:25:42 +08:00
feng626
3ae8da231a perf: 工单错误提示优化 2021-10-28 15:16:52 +08:00
feng626
2e4b6d150a fix: 登录复合浏览器不兼容 2021-10-28 15:02:54 +08:00
Michael Bai
8542d53aff fix: 修复ldap用户导入时字段进行strip 2021-10-28 15:02:33 +08:00
Michael Bai
141dafc8bf fix: 创建授权/添加资产到节点;不再自动更新资产的管理用户 2021-10-28 13:39:27 +08:00
Michael Bai
28a6024b49 fix: 修改工单列表默认按照创建日志倒叙排序,修改翻译 2021-10-28 13:38:10 +08:00
ibuler
6cf2bc4baf pref: 优化工单验证 2021-10-28 13:37:24 +08:00
xinwen
631f802961 fix: 钉钉报错的时取得字段不对 2021-10-28 13:31:27 +08:00
feng626
0eedda748a perf: acl username unified 2021-10-28 13:31:05 +08:00
Jiangjie.Bai
7183f0d274 Merge pull request #7089 from jumpserver/dev
v2.15.0-rc3
2021-10-27 19:24:42 +08:00
feng626
c93ab15351 fix: acl migrate bug 2021-10-27 11:31:16 +08:00
Michael Bai
3fffd667dc fix: 修改系统用户、工单列表排序字段 2021-10-27 11:16:01 +08:00
fit2bot
11fd2afa3a perf: 优化下载页面 (#7082)
Co-authored-by: ibuler <ibuler@qq.com>
Co-authored-by: Jiangjie.Bai <32935519+BaiJiangJie@users.noreply.github.com>
2021-10-26 17:27:01 +08:00
Michael Bai
1eb59b11da fix: 添加Model的verbose_name属性 2021-10-26 17:22:05 +08:00
feng626
a1e2d4ca57 Merge pull request #7081 from jumpserver/pr@dev@command_record_filter
fix: 命令记录-导出选择项命令:却导出了所有命令
2021-10-26 16:54:27 +08:00
feng626
8a413563be fix: 命令记录-导出选择项命令:却导出了所有命令 2021-10-26 16:50:14 +08:00
ibuler
203a01240b perf: 优化dockerfile 2021-10-26 15:48:17 +08:00
Michael Bai
37fef6153a fix: 修改依赖包版本: jumpserver-django-oidc-rp==0.3.7.8 2021-10-26 15:16:24 +08:00
feng626
ba6c49e62b Merge pull request #7077 from jumpserver/pr@dev@app_account_translate
fix: 【【账号管理】应用账号导出字段存在英文】
2021-10-26 13:42:31 +08:00
feng626
da79f8beab fix: 【【账号管理】应用账号导出字段存在英文】 2021-10-26 13:34:59 +08:00
ibuler
3f72c02049 perf: 添加命令org 2021-10-26 11:49:56 +08:00
feng626
380226a7d2 fix: 用户登陆mfa code为空限制 2021-10-26 11:01:12 +08:00
fit2bot
f88e5de3c1 perf: 优化用户创建邮件 (#7072)
* perf: 优化通知中的连接点击

* perf: 优化用户创建邮件

* perf: 优化时间日期

Co-authored-by: ibuler <ibuler@qq.com>
2021-10-26 10:52:23 +08:00
Michael Bai
7c149fe91b fix: 修复系统用户applications_amount字段只读 2021-10-25 16:25:12 +08:00
Jiangjie.Bai
f673fed706 Merge pull request #7068 from jumpserver/dev
v2.15.0-rc2
2021-10-25 15:07:25 +08:00
Michael Bai
84326cc999 fix: 系统用户列表添加 应用数量 字段 2021-10-25 15:01:29 +08:00
xinwen
7d56678a8e fix: 修复登录次数出现 0 2021-10-25 14:30:48 +08:00
feng626
25d1b71448 fix: 修复分时登陆bug 2021-10-25 14:26:47 +08:00
feng626
d3920a0cc9 Merge pull request #7065 from jumpserver/pr@dev@ticket_bug
fix: 修复工单发邮件及授权资产备注信息修改
2021-10-25 11:32:01 +08:00
feng626
52889cb67a fix: 修复工单发邮件及授权资产备注信息修改 2021-10-25 11:30:37 +08:00
feng626
0b67c7a953 fix: 修复登陆复合acl时 未添加规则bug 2021-10-25 10:49:00 +08:00
ibuler
1f50a2fe33 perf: 优化签名 2021-10-25 10:48:27 +08:00
ibuler
cd5094f10d perf: 优化通知中的连接点击 2021-10-25 10:48:03 +08:00
fit2bot
c244cf5f43 pref: 修改使用的消息内容 (#7061)
* perf:  再次优化通知

* pref: 修改使用的消息内容

* perf: 修复url地址

Co-authored-by: ibuler <ibuler@qq.com>
2021-10-22 20:06:16 +08:00
feng626
a0db2f6ef8 Merge pull request #7059 from jumpserver/pr@dev@ticket_send_message_multiple_times
fix: 修复工单结束重复发送受理人消息bug
2021-10-22 16:40:51 +08:00
feng626
ea485c3070 fix: 修复工单结束重复发送受理人消息bug 2021-10-22 16:38:14 +08:00
feng626
705f352cb9 Merge pull request #7057 from jumpserver/pr@dev@acl_bug
fix: 修复acl 选择时间段为空bug
2021-10-22 16:12:40 +08:00
feng626
dc13134b7b fix: 修复acl 选择时间段为空bug 2021-10-22 16:09:22 +08:00
Michael Bai
06de6c3575 fix: 修复资产系统用户auth-info获取流程及创建时手动登录方式的校验 2021-10-22 15:19:58 +08:00
ibuler
c341d01e5a perf: 修改工单信息 2021-10-21 20:04:27 +08:00
feng626
d1a3d31d3f feat: 客户端协议内容添加rdp文件名 2021-10-21 19:16:04 +08:00
ibuler
10f4ff4eec fix: 修复工单通知内容bug 2021-10-21 18:36:29 +08:00
ibuler
25ea3ba01d perf: 修改sdk位置 2021-10-21 17:39:28 +08:00
ibuler
072865f3e5 perf: 优化修改 sdk 位置 2021-10-21 17:39:28 +08:00
ibuler
d5c9ec1c3d perf: 优化位置 2021-10-21 17:39:28 +08:00
ibuler
487c945d1d perf: 修改代码位置,用户sugestion增加到6个 2021-10-21 17:39:28 +08:00
feng626
a78b2f4b62 fix: 修复工单发消息报错 2021-10-21 17:31:59 +08:00
ibuler
a1f1dce56b perf: 修改格式错误 2021-10-21 16:22:08 +08:00
xinwen
a1221a39fd fix: 会话管理翻译 2021-10-21 16:21:49 +08:00
xinwen
8c118b6f47 fix: 组织删除报错未翻译 2021-10-21 16:21:11 +08:00
feng626
68fd8012d8 Merge pull request #7042 from jumpserver/pr@dev@acl_perf
perf: acl filter
2021-10-21 15:11:50 +08:00
feng626
526928518d perf: acl filter 2021-10-21 15:00:29 +08:00
feng626
c5f6c564a7 fix: 系统启动bug 2021-10-21 13:05:07 +08:00
Jiangjie.Bai
076adec218 Merge pull request #7033 from jumpserver/dev
v2.15.0 rc1
2021-10-20 20:24:29 +08:00
fit2bot
00d434ceea perf: 优化消息通知 (#7024)
* perf: 优化系统用户列表

* stash

* perf: 优化消息通知

* perf: 修改钉钉

* perf: 修改优化消息通知

* perf: 修改requirements

* perf: 优化datetime

Co-authored-by: ibuler <ibuler@qq.com>
2021-10-20 19:45:37 +08:00
fit2bot
9acfd461b4 feat: user login acl (#6963)
* feat: user login acl

* 添加分时登陆

* acl 部分还原

* 简化acl判断逻辑

Co-authored-by: feng626 <1304903146@qq.com>
Co-authored-by: feng626 <57284900+feng626@users.noreply.github.com>
2021-10-20 17:56:59 +08:00
feng626
9424929dde fix: sms bug 2021-10-19 20:23:06 +08:00
Michael Bai
b95d3ac9be fix: 修改终端status翻译 2021-10-19 16:16:48 +08:00
Michael Bai
af2a9bb1e6 fix: 修复用户登录失败未记录日志的问题 2021-10-19 15:28:39 +08:00
fit2bot
63638ed1ce feat: 首页的 chanlege 和 MFA 统一 (#6989)
* feat: 首页的 chanlege 和 MFA 统一

* 登陆样式调整

* mfa bug

* q

* m

* mfa封装组件 前端可修改配置

* perf: 添加翻译

* login css bug

* perf: 修改一些风格

* perf: 修改命名

* perf: 修改 mfa code 不是必填

* mfa 前端统一组件

* stash

* perf: 统一验证码

Co-authored-by: feng626 <1304903146@qq.com>
Co-authored-by: ibuler <ibuler@qq.com>
2021-10-18 18:41:41 +08:00
fit2bot
fa68389028 perf: 去掉单独的flash msg (#7013)
* perf: 去掉单独的flash msg

perf: 修改使用库

* fix: guangbug

* pref: 修改 context

Co-authored-by: ibuler <ibuler@qq.com>
Co-authored-by: xinwen <coderWen@126.com>
2021-10-18 11:25:39 +08:00
halo
63b338085a feat: 修改username校验,telnet协议支持输入中文 2021-10-15 15:36:29 +08:00
fit2bot
5d8818e69e feat: 添加资产授权用户API、资产授权用户的授权规则API (#7008)
* feat: 添加资产授权用户API、资产授权用户的授权规则API

* feat: 添加资产授权用户组API、资产授权用户组的授权规则API

* feat: 抽象API类

Co-authored-by: Michael Bai <baijiangjie@gmail.com>
2021-10-15 14:02:32 +08:00
xinwen
77521119b9 fix: 修改 oauth 认证提示信息 2021-10-15 11:32:20 +08:00
ibuler
baee71e4b8 perf: 添加requirements 2021-10-15 11:31:06 +08:00
ibuler
6459c20516 perf: Django语言文件推送到lfs
perf: 修改mac依赖,添加安装 git lfs

perf: 修改添加 mac 开发环境依赖
2021-10-14 16:30:18 +08:00
ibuler
0207fe60c5 perf: 修改ip城市获取算法
perf: 优化使用lfs
2021-10-14 10:35:18 +08:00
Eric
b8c43f5944 fix: 修复获取系统用户密码问题 2021-10-13 14:34:57 +08:00
xinwen
63ca2ab182 fix: 修复 traceback 问题 2021-10-13 11:42:54 +08:00
ibuler
f9ea119928 perf: 不再需要 tls 类型,any 方式都可以 2021-10-12 17:45:38 +08:00
xinwen.xu
d4bdc74bd8 feat: 授权过期通知 2021-10-11 15:41:13 +08:00
ibuler
42e7ca6a18 perf: 资产编号允许api更改 2021-10-11 15:26:31 +08:00
xinwen
6e0341b7b1 feat: xrdp挂载受授权的上传下载控制 2021-10-11 15:24:34 +08:00
jiangweidong
2f25e2b24c fix: 系统用户认证方式从托管密码更新到手动输入模式后,不填写用户名报错 2021-10-11 10:02:48 +08:00
halo
87dcd2dcb7 feat: 增加关闭第三方登录跳转配置参数 2021-09-29 16:04:59 +08:00
ibuler
e96aac058f fix: 修复跳转msg页面,取消button太小的问题 2021-09-29 15:30:17 +08:00
Jiangjie.Bai
d76bad125b Merge pull request #6973 from jumpserver/pr@dev@feat_announcement
feat: 支持公告
2021-09-29 14:58:15 +08:00
ibuler
913b1a1426 perf: 修改翻译 2021-09-29 14:55:14 +08:00
ibuler
0213154a19 merge: with dev 2021-09-29 14:53:42 +08:00
ibuler
8f63b488b2 perf: 修改翻译 2021-09-29 14:43:00 +08:00
ibuler
f8921004c2 perf: 修改依赖版本 2021-09-29 14:42:11 +08:00
feng626
5588eab57e feat: 用户异地登陆 2021-09-29 14:33:52 +08:00
ibuler
e0a8f91741 perf: 修改翻译 2021-09-28 18:57:21 +08:00
ibuler
467ebfa650 perf: 添加公告id 2021-09-28 18:17:10 +08:00
ibuler
0533b77b1b perf: 支持公告 2021-09-28 17:01:47 +08:00
ibuler
a558ee2ac0 perf: 修改dockerfile 2021-09-28 10:10:20 +08:00
ibuler
46a39701d4 fix: 修复docker无法构建 2021-09-27 19:27:33 +08:00
ibuler
8c3ab31e4e fix: 修复由于修改filter引起的问题 2021-09-27 18:50:29 +08:00
Jiangjie.Bai
476e6cdc2f Merge pull request #6932 from jumpserver/pr@dev@perf_remove_djangopo
perf: 去掉django.po
2021-09-27 14:12:26 +08:00
ibuler
0b593f4555 perf: 优化drf filter, 修改terminal filter
perf: 去掉debug

perf: 去掉debug
2021-09-27 14:09:22 +08:00
ibuler
456116938d perf: 优化数据迁移,性能提升50倍 2021-09-27 14:05:21 +08:00
ibuler
76b24f62d4 perf: merge with dev 2021-09-27 14:03:19 +08:00
Michael Bai
e1eef0a3f3 fix: 修复authbook systemuser字段问题 2021-09-24 20:24:25 +08:00
Michael Bai
2c74727b65 fix: 修复邮件测试序列类及API 2021-09-24 14:13:55 +08:00
ibuler
08cd91c426 perf: 修改应用授权树,没有的时候不显示 2021-09-24 14:12:31 +08:00
wojiushixiaobai
90d269d2a2 fix: 添加包 2021-09-24 12:56:35 +08:00
Michael Bai
a9ddbcc0cd fix: 修复系统用户和资产/节点关联时资产特权用户更新的问题 2021-09-24 12:48:35 +08:00
Michael Bai
aec31128cf perf: 修改翻译 2021-09-23 11:14:53 +08:00
ibuler
b415ee051d perf: 去掉django.po 2021-09-23 10:26:39 +08:00
ibuler
082a5ae84c perf: 修改不再添加编译后的翻译文件 2021-09-22 18:53:04 +08:00
老广
6b7554d69a Merge pull request #6929 from jumpserver/pr@dev@ticket_comment
perf: 工单优化
2021-09-22 15:51:10 +08:00
feng626
5923562440 perf: 工单优化 2021-09-22 15:17:57 +08:00
老广
d144e7e572 Merge pull request #6922 from jumpserver/pr@dev@fix_tokensmsoptions
fix: 修复KoKo登录时,未开启SMS服务出现了SMS选项的问题
2021-09-22 11:24:03 +08:00
ibuler
cafbd08986 perf: 修改啊 2021-09-22 11:18:38 +08:00
ibuler
b436fc9b44 perf: 优化登录 2021-09-22 11:18:38 +08:00
ibuler
26fc56b4be perf: 优化登录html 2021-09-22 11:18:38 +08:00
Michael Bai
3b1d199669 fix: 修复KoKo登录时,未开启SMS服务出现了SMS选项的问题 2021-09-22 03:17:14 +00:00
ibuler
e7edbc9d84 perf: 修改啊 2021-09-18 15:46:41 +08:00
ibuler
41f81bc0bf merge: with dev 2021-09-18 15:29:29 +08:00
ibuler
75d2c81d33 perf: 优化登录 2021-09-18 15:24:04 +08:00
ibuler
da2dea5003 perf: 登录错误提示颜色 2021-09-18 15:07:05 +08:00
老广
fc60156c23 Merge pull request #6913 from jumpserver/pr@dev@perf_dockerfile
perf: 优化依赖包
2021-09-17 18:03:31 +08:00
wojiushixiaobai
76fb547551 perf: 优化依赖包 2021-09-17 16:14:15 +08:00
ibuler
e686f51703 perf: 优化登录html 2021-09-17 16:00:23 +08:00
feng626
7578bda588 Merge pull request #6911 from jumpserver/pr@dev@ticket_perf
perf: 工单优化
2021-09-17 15:50:48 +08:00
feng626
f2d743ec2b perf: 工单优化 2021-09-17 15:46:33 +08:00
ibuler
7099aef360 perf: 登录错误提示颜色 2021-09-17 14:35:05 +08:00
wojiushixiaobai
246f5d8a11 fix: 添加依赖 2021-09-16 23:24:02 +08:00
wojiushixiaobai
6c98bd3b48 fix: 添加 pg 依赖包 2021-09-16 23:24:02 +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
323 changed files with 9763 additions and 5028 deletions

View File

@@ -6,4 +6,5 @@ tmp/*
django.db
celerybeat.pid
### Vagrant ###
.vagrant/
.vagrant/
apps/xpack/.git

2
.gitattributes vendored Normal file
View File

@@ -0,0 +1,2 @@
*.mmdb filter=lfs diff=lfs merge=lfs -text
*.mo filter=lfs diff=lfs merge=lfs -text

View File

@@ -8,7 +8,6 @@ WORKDIR /opt/jumpserver
ADD . .
RUN cd utils && bash -ixeu build.sh
# 构建运行时环境
FROM python:3.8.6-slim
ARG PIP_MIRROR=https://pypi.douban.com/simple
@@ -18,26 +17,37 @@ ENV PIP_JMS_MIRROR=$PIP_JMS_MIRROR
WORKDIR /opt/jumpserver
COPY ./requirements/deb_buster_requirements.txt ./requirements/deb_buster_requirements.txt
COPY ./requirements/deb_requirements.txt ./requirements/deb_requirements.txt
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 \
&& grep -v '^#' ./requirements/deb_buster_requirements.txt | xargs apt -y install \
&& apt -y install telnet iproute2 redis-tools default-mysql-client vim wget curl locales procps \
&& apt -y install $(cat requirements/deb_requirements.txt) \
&& 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 -r requirements/requirements.txt
&& 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 -i ${PIP_MIRROR} \
&& rm -rf ~/.cache/pip
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/ \
&& wget https://download.jumpserver.org/public/instantclient-basiclite-linux.x64-21.1.0.0.0.tar > /dev/null \
&& tar xf instantclient-basiclite-linux.x64-21.1.0.0.0.tar -C /opt/jumpserver/oracle/ \
&& echo "/opt/jumpserver/oracle/instantclient_21_1" > /etc/ld.so.conf.d/oracle-instantclient.conf \
&& ldconfig \
&& rm -f instantclient-basiclite-linux.x64-21.1.0.0.0.tar
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

@@ -2,15 +2,16 @@ from common.permissions import IsOrgAdmin, HasQueryParamsUserAndIsCurrentOrgMemb
from common.drf.api import JMSBulkModelViewSet
from ..models import LoginACL
from .. import serializers
from ..filters import LoginAclFilter
__all__ = ['LoginACLViewSet', ]
class LoginACLViewSet(JMSBulkModelViewSet):
queryset = LoginACL.objects.all()
filterset_fields = ('name', 'user', )
search_fields = filterset_fields
permission_classes = (IsOrgAdmin, )
filterset_class = LoginAclFilter
search_fields = ('name',)
permission_classes = (IsOrgAdmin,)
serializer_class = serializers.LoginACLSerializer
def get_permissions(self):

View File

@@ -1,4 +1,3 @@
from orgs.mixins.api import OrgBulkModelViewSet
from common.permissions import IsOrgAdmin
from .. import models, serializers

View File

@@ -1,20 +1,18 @@
from django.shortcuts import get_object_or_404
from rest_framework.response import Response
from rest_framework.generics import CreateAPIView, RetrieveDestroyAPIView
from rest_framework.generics import CreateAPIView
from common.permissions import IsAppUser
from common.utils import reverse, lazyproperty
from orgs.utils import tmp_to_org, tmp_to_root_org
from orgs.utils import tmp_to_org
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 +55,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 +73,3 @@ class LoginAssetCheckAPI(CreateAPIView):
class LoginAssetConfirmStatusAPI(GenericTicketStatusRetrieveCloseAPI):
pass

15
apps/acls/filters.py Normal file
View File

@@ -0,0 +1,15 @@
from django_filters import rest_framework as filters
from common.drf.filters import BaseFilterSet
from acls.models import LoginACL
class LoginAclFilter(BaseFilterSet):
user = filters.UUIDFilter(field_name='user_id')
user_display = filters.CharFilter(field_name='user__name')
class Meta:
model = LoginACL
fields = (
'name', 'user', 'user_display', 'action'
)

View File

@@ -0,0 +1,98 @@
# Generated by Django 3.1.12 on 2021-09-26 02:47
import django
from django.conf import settings
from django.db import migrations, models, transaction
from acls.models import LoginACL
LOGIN_CONFIRM_ZH = '登录复核'
LOGIN_CONFIRM_EN = 'Login confirm'
DEFAULT_TIME_PERIODS = [{'id': i, 'value': '00:00~00:00'} for i in range(7)]
def has_zh(name: str) -> bool:
for i in name:
if u'\u4e00' <= i <= u'\u9fff':
return True
return False
def migrate_login_confirm(apps, schema_editor):
login_acl_model = apps.get_model("acls", "LoginACL")
login_confirm_model = apps.get_model("authentication", "LoginConfirmSetting")
with transaction.atomic():
for instance in login_confirm_model.objects.filter(is_active=True):
user = instance.user
reviewers = instance.reviewers.all()
login_confirm = LOGIN_CONFIRM_ZH if has_zh(user.name) else LOGIN_CONFIRM_EN
date_created = instance.date_created.strftime('%Y-%m-%d %H:%M:%S')
if reviewers.count() == 0:
continue
data = {
'user': user,
'name': f'{user.name}-{login_confirm} ({date_created})',
'created_by': instance.created_by,
'action': LoginACL.ActionChoices.confirm,
'rules': {'ip_group': ['*'], 'time_period': DEFAULT_TIME_PERIODS}
}
instance = login_acl_model.objects.create(**data)
instance.reviewers.set(reviewers)
def migrate_ip_group(apps, schema_editor):
login_acl_model = apps.get_model("acls", "LoginACL")
updates = list()
with transaction.atomic():
for instance in login_acl_model.objects.exclude(action=LoginACL.ActionChoices.confirm):
instance.rules = {'ip_group': instance.ip_group, 'time_period': DEFAULT_TIME_PERIODS}
updates.append(instance)
login_acl_model.objects.bulk_update(updates, ['rules', ])
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('acls', '0001_initial'),
('authentication', '0004_ssotoken'),
]
operations = [
migrations.AlterField(
model_name='loginacl',
name='action',
field=models.CharField(choices=[('reject', 'Reject'), ('allow', 'Allow'), ('confirm', 'Login confirm')],
default='reject', max_length=64, verbose_name='Action'),
),
migrations.AddField(
model_name='loginacl',
name='reviewers',
field=models.ManyToManyField(blank=True, related_name='login_confirm_acls',
to=settings.AUTH_USER_MODEL, verbose_name='Reviewers'),
),
migrations.AlterField(
model_name='loginacl',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE,
related_name='login_acls', to=settings.AUTH_USER_MODEL, verbose_name='User'),
),
migrations.RunPython(migrate_login_confirm),
migrations.AddField(
model_name='loginacl',
name='rules',
field=models.JSONField(default=dict, verbose_name='Rule'),
),
migrations.RunPython(migrate_ip_group),
migrations.RemoveField(
model_name='loginacl',
name='ip_group',
),
migrations.AlterModelOptions(
name='loginacl',
options={'ordering': ('priority', '-date_updated', 'name'), 'verbose_name': 'Login acl'},
),
migrations.AlterModelOptions(
name='loginassetacl',
options={'ordering': ('priority', '-date_updated', 'name'), 'verbose_name': 'Login asset acl'},
),
]

View File

@@ -1,8 +1,10 @@
from django.db import models
from django.utils.translation import ugettext_lazy as _
from .base import BaseACL, BaseACLQuerySet
from common.utils import get_request_ip, get_ip_city
from common.utils.ip import contains_ip
from common.utils.time_period import contains_time_period
from common.utils.timezone import local_now_display
class ACLManager(models.Manager):
@@ -15,23 +17,29 @@ class LoginACL(BaseACL):
class ActionChoices(models.TextChoices):
reject = 'reject', _('Reject')
allow = 'allow', _('Allow')
confirm = 'confirm', _('Login confirm')
# 条件
ip_group = models.JSONField(default=list, verbose_name=_('Login IP'))
# 用户
user = models.ForeignKey(
'users.User', on_delete=models.CASCADE, verbose_name=_('User'),
related_name='login_acls'
)
# 规则
rules = models.JSONField(default=dict, verbose_name=_('Rule'))
# 动作
action = models.CharField(
max_length=64, choices=ActionChoices.choices, default=ActionChoices.reject,
verbose_name=_('Action')
max_length=64, verbose_name=_('Action'),
choices=ActionChoices.choices, default=ActionChoices.reject
)
# 关联
user = models.ForeignKey(
'users.User', on_delete=models.CASCADE, related_name='login_acls', verbose_name=_('User')
reviewers = models.ManyToManyField(
'users.User', verbose_name=_("Reviewers"),
related_name="login_confirm_acls", blank=True
)
objects = ACLManager.from_queryset(BaseACLQuerySet)()
class Meta:
ordering = ('priority', '-date_updated', 'name')
verbose_name = _('Login acl')
def __str__(self):
return self.name
@@ -44,14 +52,77 @@ class LoginACL(BaseACL):
def action_allow(self):
return self.action == self.ActionChoices.allow
@classmethod
def filter_acl(cls, user):
return user.login_acls.all().valid().distinct()
@staticmethod
def allow_user_confirm_if_need(user, ip):
acl = LoginACL.filter_acl(user).filter(
action=LoginACL.ActionChoices.confirm
).first()
acl = acl if acl and acl.reviewers.exists() else None
if not acl:
return False, acl
ip_group = acl.rules.get('ip_group')
time_periods = acl.rules.get('time_period')
is_contain_ip = contains_ip(ip, ip_group)
is_contain_time_period = contains_time_period(time_periods)
return is_contain_ip and is_contain_time_period, acl
@staticmethod
def allow_user_to_login(user, ip):
acl = user.login_acls.valid().first()
acl = LoginACL.filter_acl(user).exclude(
action=LoginACL.ActionChoices.confirm
).first()
if not acl:
return True
is_contained = contains_ip(ip, acl.ip_group)
if acl.action_allow and is_contained:
return True
if acl.action_reject and not is_contained:
return True
return False
return True, ''
ip_group = acl.rules.get('ip_group')
time_periods = acl.rules.get('time_period')
is_contain_ip = contains_ip(ip, ip_group)
is_contain_time_period = contains_time_period(time_periods)
reject_type = ''
if is_contain_ip and is_contain_time_period:
# 满足条件
allow = acl.action_allow
if not allow:
reject_type = 'ip' if is_contain_ip else 'time'
else:
# 不满足条件
# 如果acl本身允许那就拒绝如果本身拒绝那就允许
allow = not acl.action_allow
if not allow:
reject_type = 'ip' if not is_contain_ip else 'time'
return allow, reject_type
@staticmethod
def construct_confirm_ticket_meta(request=None):
login_ip = get_request_ip(request) if request else ''
login_ip = login_ip or '0.0.0.0'
login_city = get_ip_city(login_ip)
login_datetime = local_now_display()
ticket_meta = {
'apply_login_ip': login_ip,
'apply_login_city': login_city,
'apply_login_datetime': login_datetime,
}
return ticket_meta
def create_confirm_ticket(self, request=None):
from tickets import const
from tickets.models import Ticket
from orgs.models import Organization
ticket_title = _('Login confirm') + ' {}'.format(self.user)
ticket_meta = self.construct_confirm_ticket_meta(request)
data = {
'title': ticket_title,
'type': const.TicketType.login_confirm.value,
'meta': ticket_meta,
'org_id': Organization.ROOT_ID,
}
ticket = Ticket.objects.create(**data)
ticket.create_process_map_and_node(self.reviewers.all())
ticket.open(self.user)
return ticket

View File

@@ -37,6 +37,7 @@ class LoginAssetACL(BaseACL, OrgModelMixin):
class Meta:
unique_together = ('name', 'org_id')
ordering = ('priority', '-date_updated', 'name')
verbose_name = _('Login asset acl')
def __str__(self):
return self.name
@@ -83,11 +84,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 +97,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

@@ -1,59 +1,42 @@
from django.utils.translation import ugettext as _
from rest_framework import serializers
from common.drf.serializers import BulkModelSerializer
from orgs.utils import current_org
from common.drf.serializers import MethodSerializer
from ..models import LoginACL
from common.utils.ip import is_ip_address, is_ip_network, is_ip_segment
from .rules import RuleSerializer
__all__ = ['LoginACLSerializer', ]
def ip_group_child_validator(ip_group_child):
is_valid = ip_group_child == '*' \
or is_ip_address(ip_group_child) \
or is_ip_network(ip_group_child) \
or is_ip_segment(ip_group_child)
if not is_valid:
error = _('IP address invalid: `{}`').format(ip_group_child)
raise serializers.ValidationError(error)
common_help_text = _('Format for comma-delimited string, with * indicating a match all. ')
class LoginACLSerializer(BulkModelSerializer):
ip_group_help_text = _(
'Format for comma-delimited string, with * indicating a match all. '
'Such as: '
'192.168.10.1, 192.168.1.0/24, 10.1.1.1-10.1.1.20, 2001:db8:2de::e13, 2001:db8:1a:1110::/64 '
)
ip_group = serializers.ListField(
default=['*'], label=_('IP'), help_text=ip_group_help_text,
child=serializers.CharField(max_length=1024, validators=[ip_group_child_validator])
)
user_display = serializers.ReadOnlyField(source='user.name', label=_('User'))
user_display = serializers.ReadOnlyField(source='user.username', label=_('Username'))
reviewers_display = serializers.SerializerMethodField(label=_('Reviewers'))
action_display = serializers.ReadOnlyField(source='get_action_display', label=_('Action'))
reviewers_amount = serializers.IntegerField(read_only=True, source='reviewers.count')
rules = MethodSerializer()
class Meta:
model = LoginACL
fields_mini = ['id', 'name']
fields_small = fields_mini + [
'priority', 'ip_group', 'action', 'action_display',
'is_active',
'date_created', 'date_updated',
'comment', 'created_by',
'priority', 'rules', 'action', 'action_display',
'is_active', 'user', 'user_display',
'date_created', 'date_updated', 'reviewers_amount',
'comment', 'created_by'
]
fields_fk = ['user', 'user_display',]
fields = fields_small + fields_fk
fields_fk = ['user', 'user_display']
fields_m2m = ['reviewers', 'reviewers_display']
fields = fields_small + fields_fk + fields_m2m
extra_kwargs = {
'priority': {'default': 50},
'is_active': {'default': True},
"reviewers": {'allow_null': False, 'required': True},
}
@staticmethod
def validate_user(user):
if user not in current_org.get_members():
error = _('The user `{}` is not in the current organization: `{}`').format(
user, current_org
)
raise serializers.ValidationError(error)
return user
def get_rules_serializer(self):
return RuleSerializer()
def get_reviewers_display(self, obj):
return ','.join([str(user) for user in obj.reviewers.all()])

View File

@@ -0,0 +1 @@
from .rules import *

View File

@@ -0,0 +1,34 @@
# coding: utf-8
#
from rest_framework import serializers
from django.utils.translation import ugettext_lazy as _
from common.utils import get_logger
from common.utils.ip import is_ip_address, is_ip_network, is_ip_segment
logger = get_logger(__file__)
__all__ = ['RuleSerializer']
def ip_group_child_validator(ip_group_child):
is_valid = ip_group_child == '*' \
or is_ip_address(ip_group_child) \
or is_ip_network(ip_group_child) \
or is_ip_segment(ip_group_child)
if not is_valid:
error = _('IP address invalid: `{}`').format(ip_group_child)
raise serializers.ValidationError(error)
class RuleSerializer(serializers.Serializer):
ip_group_help_text = _(
'Format for comma-delimited string, with * indicating a match all. '
'Such as: '
'192.168.10.1, 192.168.1.0/24, 10.1.1.1-10.1.1.20, 2001:db8:2de::e13, 2001:db8:1a:1110::/64 '
)
ip_group = serializers.ListField(
default=['*'], label=_('IP'), help_text=ip_group_help_text,
child=serializers.CharField(max_length=1024, validators=[ip_group_child_validator]))
time_period = serializers.ListField(default=[], label=_('Time Period'))

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.api 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

@@ -32,6 +32,8 @@ class SerializeApplicationToTreeNodeMixin:
return node
def serialize_applications_with_org(self, applications):
if not applications:
return []
root_node = self.create_root_node()
tree_nodes = [root_node]
organizations = self.filter_organizations(applications)

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

@@ -0,0 +1,23 @@
# Generated by Django 3.1.13 on 2021-10-14 14:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('applications', '0011_auto_20210826_1759'),
]
operations = [
migrations.AlterField(
model_name='account',
name='username',
field=models.CharField(blank=True, db_index=True, max_length=128, verbose_name='Username'),
),
migrations.AlterField(
model_name='historicalaccount',
name='username',
field=models.CharField(blank=True, db_index=True, max_length=128, verbose_name='Username'),
),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 3.1.13 on 2021-10-26 09:11
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('applications', '0012_auto_20211014_2209'),
]
operations = [
migrations.AlterModelOptions(
name='application',
options={'ordering': ('name',), 'verbose_name': 'Application'},
),
]

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

@@ -180,6 +180,7 @@ class Application(CommonModelMixin, OrgModelMixin, ApplicationTreeNodeMixin):
)
class Meta:
verbose_name = _('Application')
unique_together = [('org_id', 'name')]
ordering = ('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,63 @@ 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')},
'systemuser_display': {'label': _('System User')}
}
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},
'app_display': {'label': _('Application display')},
'systemuser_display': {'label': _('System User')}
}

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

@@ -21,6 +21,8 @@ class AdminUserViewSet(OrgBulkModelViewSet):
search_fields = filterset_fields
serializer_class = serializers.AdminUserSerializer
permission_classes = (IsOrgAdmin,)
ordering_fields = ('name',)
ordering = ('name', )
def get_queryset(self):
queryset = super().get_queryset().filter(type=SystemUser.Type.admin)

View File

@@ -2,14 +2,22 @@
#
from assets.api import FilterAssetByNodeMixin
from rest_framework.viewsets import ModelViewSet
from rest_framework.generics import RetrieveAPIView
from rest_framework.generics import RetrieveAPIView, ListAPIView
from django.shortcuts import get_object_or_404
from django.db.models import Q
from common.utils import get_logger, get_object_or_none
from common.permissions import IsOrgAdmin, IsOrgAdminOrAppUser, IsSuperUser
from common.mixins.api import SuggestionMixin
from users.models import User, UserGroup
from users.serializers import UserSerializer, UserGroupSerializer
from users.filters import UserFilter
from perms.models import AssetPermission
from perms.serializers import AssetPermissionSerializer
from perms.filters import AssetPermissionFilter
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,16 +25,17 @@ from ..tasks import (
)
from ..filters import FilterAssetByNodeFilterBackend, LabelFilterBackend, IpInFilterBackend
logger = get_logger(__file__)
__all__ = [
'AssetViewSet', 'AssetPlatformRetrieveApi',
'AssetGatewayListApi', 'AssetPlatformViewSet',
'AssetTaskCreateApi', 'AssetsTaskCreateApi',
'AssetPermUserListApi', 'AssetPermUserPermissionsListApi',
'AssetPermUserGroupListApi', 'AssetPermUserGroupPermissionsListApi',
]
class AssetViewSet(FilterAssetByNodeMixin, OrgBulkModelViewSet):
class AssetViewSet(SuggestionMixin, FilterAssetByNodeMixin, OrgBulkModelViewSet):
"""
API endpoint that allows Asset to be viewed or edited.
"""
@@ -41,8 +50,10 @@ class AssetViewSet(FilterAssetByNodeMixin, OrgBulkModelViewSet):
}
search_fields = ("hostname", "ip")
ordering_fields = ("hostname", "ip", "port", "cpu_cores")
ordering = ('hostname', )
serializer_classes = {
'default': serializers.AssetSerializer,
'suggestion': serializers.MiniAssetSerializer
}
permission_classes = (IsOrgAdminOrAppUser,)
extra_filter_backends = [FilterAssetByNodeFilterBackend, LabelFilterBackend, IpInFilterBackend]
@@ -169,3 +180,102 @@ class AssetGatewayListApi(generics.ListAPIView):
return []
queryset = asset.domain.gateways.filter(protocol='ssh')
return queryset
class BaseAssetPermUserOrUserGroupListApi(ListAPIView):
permission_classes = (IsOrgAdmin,)
def get_object(self):
asset_id = self.kwargs.get('pk')
asset = get_object_or_404(Asset, pk=asset_id)
return asset
def get_asset_related_perms(self):
asset = self.get_object()
nodes = asset.get_all_nodes(flat=True)
perms = AssetPermission.objects.filter(Q(assets=asset) | Q(nodes__in=nodes))
return perms
class AssetPermUserListApi(BaseAssetPermUserOrUserGroupListApi):
filterset_class = UserFilter
search_fields = ('username', 'email', 'name', 'id', 'source', 'role')
serializer_class = UserSerializer
def get_queryset(self):
perms = self.get_asset_related_perms()
users = User.objects.filter(
Q(assetpermissions__in=perms) | Q(groups__assetpermissions__in=perms)
).distinct()
return users
class AssetPermUserGroupListApi(BaseAssetPermUserOrUserGroupListApi):
serializer_class = UserGroupSerializer
def get_queryset(self):
perms = self.get_asset_related_perms()
user_groups = UserGroup.objects.filter(assetpermissions__in=perms).distinct()
return user_groups
class BaseAssetPermUserOrUserGroupPermissionsListApiMixin(generics.ListAPIView):
permission_classes = (IsOrgAdmin,)
model = AssetPermission
serializer_class = AssetPermissionSerializer
filterset_class = AssetPermissionFilter
search_fields = ('name',)
def get_object(self):
asset_id = self.kwargs.get('pk')
asset = get_object_or_404(Asset, pk=asset_id)
return asset
def filter_asset_related(self, queryset):
asset = self.get_object()
nodes = asset.get_all_nodes(flat=True)
perms = queryset.filter(Q(assets=asset) | Q(nodes__in=nodes))
return perms
def filter_queryset(self, queryset):
queryset = super().filter_queryset(queryset)
queryset = self.filter_asset_related(queryset)
return queryset
class AssetPermUserPermissionsListApi(BaseAssetPermUserOrUserGroupPermissionsListApiMixin):
def filter_queryset(self, queryset):
queryset = super().filter_queryset(queryset)
queryset = self.filter_user_related(queryset)
queryset = queryset.distinct()
return queryset
def filter_user_related(self, queryset):
user = self.get_perm_user()
user_groups = user.groups.all()
perms = queryset.filter(Q(users=user) | Q(user_groups__in=user_groups))
return perms
def get_perm_user(self):
user_id = self.kwargs.get('perm_user_id')
user = get_object_or_404(User, pk=user_id)
return user
class AssetPermUserGroupPermissionsListApi(BaseAssetPermUserOrUserGroupPermissionsListApiMixin):
def filter_queryset(self, queryset):
queryset = super().filter_queryset(queryset)
queryset = self.filter_user_group_related(queryset)
queryset = queryset.distinct()
return queryset
def filter_user_group_related(self, queryset):
user_group = self.get_perm_user_group()
perms = queryset.filter(user_groups=user_group)
return perms
def get_perm_user_group(self):
user_group_id = self.kwargs.get('perm_user_group_id')
user_group = get_object_or_404(UserGroup, pk=user_group_id)
return user_group

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

@@ -22,6 +22,8 @@ class DomainViewSet(OrgBulkModelViewSet):
search_fields = filterset_fields
permission_classes = (IsOrgAdminOrAppUser,)
serializer_class = serializers.DomainSerializer
ordering_fields = ('name',)
ordering = ('name', )
def get_serializer_class(self):
if self.request.query_params.get('gateway'):

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.api import SuggestionMixin
from orgs.utils import tmp_to_root_org
from ..models import SystemUser, Asset
from .. import serializers
@@ -15,7 +16,6 @@ from ..tasks import (
push_system_user_to_assets
)
logger = get_logger(__file__)
__all__ = [
'SystemUserViewSet', 'SystemUserAuthInfoApi', 'SystemUserAssetAuthInfoApi',
@@ -24,7 +24,7 @@ __all__ = [
]
class SystemUserViewSet(OrgBulkModelViewSet):
class SystemUserViewSet(SuggestionMixin, OrgBulkModelViewSet):
"""
System user api set, for add,delete,update,list,retrieve resource
"""
@@ -39,7 +39,10 @@ class SystemUserViewSet(OrgBulkModelViewSet):
serializer_class = serializers.SystemUserSerializer
serializer_classes = {
'default': serializers.SystemUserSerializer,
'suggestion': serializers.MiniSystemUserSerializer
}
ordering_fields = ('name', 'protocol', 'login_mode')
ordering = ('name', )
permission_classes = (IsOrgAdminOrAppUser,)
@@ -72,7 +75,7 @@ class SystemUserTempAuthInfoApi(generics.CreateAPIView):
with tmp_to_root_org():
instance = get_object_or_404(SystemUser, pk=pk)
instance.set_temp_auth(instance_id, user, data)
instance.set_temp_auth(instance_id, user.id, data)
return Response(serializer.data, status=201)
@@ -89,7 +92,7 @@ class SystemUserAssetAuthInfoApi(generics.RetrieveAPIView):
asset_id = self.kwargs.get('asset_id')
user_id = self.request.query_params.get("user_id")
username = self.request.query_params.get("username")
instance.load_asset_more_auth(asset_id=asset_id, user_id=user_id, username=username)
instance.load_asset_more_auth(asset_id, username, user_id)
return instance
@@ -105,8 +108,7 @@ class SystemUserAppAuthInfoApi(generics.RetrieveAPIView):
instance = super().get_object()
app_id = self.kwargs.get('app_id')
user_id = self.request.query_params.get("user_id")
if user_id:
instance.load_app_more_auth(app_id, user_id)
instance.load_app_more_auth(app_id, user_id)
return instance

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

@@ -15,7 +15,7 @@ def migrate_system_assets_to_authbook(apps, schema_editor):
system_users = system_user_model.objects.all()
for s in system_users:
while True:
systemuser_asset_relations = system_user_asset_model.objects.filter(systemuser=s)[:20]
systemuser_asset_relations = system_user_asset_model.objects.filter(systemuser=s)[:1000]
if not systemuser_asset_relations:
break
authbooks = []

View File

@@ -0,0 +1,24 @@
# Generated by Django 3.1.12 on 2021-10-12 08:42
from django.db import migrations
def migrate_platform_win2016(apps, schema_editor):
platform_model = apps.get_model("assets", "Platform")
win2016 = platform_model.objects.filter(name='Windows2016').first()
if not win2016:
print("Error: Not found Windows2016 platform")
return
win2016.meta = {"security": "any"}
win2016.save()
class Migration(migrations.Migration):
dependencies = [
('assets', '0076_delete_assetuser'),
]
operations = [
migrations.RunPython(migrate_platform_win2016)
]

View File

@@ -0,0 +1,38 @@
# Generated by Django 3.1.13 on 2021-10-14 14:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assets', '0077_auto_20211012_1642'),
]
operations = [
migrations.AlterField(
model_name='adminuser',
name='username',
field=models.CharField(blank=True, db_index=True, max_length=128, verbose_name='Username'),
),
migrations.AlterField(
model_name='authbook',
name='username',
field=models.CharField(blank=True, db_index=True, max_length=128, verbose_name='Username'),
),
migrations.AlterField(
model_name='gateway',
name='username',
field=models.CharField(blank=True, db_index=True, max_length=128, verbose_name='Username'),
),
migrations.AlterField(
model_name='historicalauthbook',
name='username',
field=models.CharField(blank=True, db_index=True, max_length=128, verbose_name='Username'),
),
migrations.AlterField(
model_name='systemuser',
name='username',
field=models.CharField(blank=True, db_index=True, max_length=128, verbose_name='Username'),
),
]

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)
@@ -94,7 +94,27 @@ class AuthBook(BaseUser, AbsConnectivity):
i.private_key = self.private_key
i.public_key = self.public_key
i.comment = 'Update triggered by account {}'.format(self.id)
i.save(update_fields=['password', 'private_key', 'public_key'])
# 不触发post_save信号
self.__class__.objects.bulk_update(matched, fields=['password', 'private_key', 'public_key'])
def remove_asset_admin_user_if_need(self):
if not self.asset or not self.systemuser:
return
if not self.systemuser.is_admin_user or self.asset.admin_user != self.systemuser:
return
self.asset.admin_user = None
self.asset.save()
logger.debug('Remove asset admin user: {} {}'.format(self.asset, self.systemuser))
def update_asset_admin_user_if_need(self):
if not self.asset or not self.systemuser:
return
if not self.systemuser.is_admin_user or self.asset.admin_user == self.systemuser:
return
self.asset.admin_user = self.systemuser
self.asset.save()
logger.debug('Update asset admin user: {} {}'.format(self.asset, self.systemuser))
def __str__(self):
return self.smart_name

View File

@@ -173,7 +173,7 @@ class AuthMixin:
class BaseUser(OrgModelMixin, AuthMixin):
id = models.UUIDField(default=uuid.uuid4, primary_key=True)
name = models.CharField(max_length=128, verbose_name=_('Name'))
username = models.CharField(max_length=128, blank=True, verbose_name=_('Username'), validators=[alphanumeric], db_index=True)
username = models.CharField(max_length=128, blank=True, verbose_name=_('Username'), db_index=True)
password = fields.EncryptCharField(max_length=256, blank=True, null=True, verbose_name=_('Password'))
private_key = fields.EncryptTextField(blank=True, null=True, verbose_name=_('SSH private key'))
public_key = fields.EncryptTextField(blank=True, null=True, verbose_name=_('SSH public key'))
@@ -185,10 +185,18 @@ class BaseUser(OrgModelMixin, AuthMixin):
ASSETS_AMOUNT_CACHE_KEY = "ASSET_USER_{}_ASSETS_AMOUNT"
ASSET_USER_CACHE_TIME = 600
APPS_AMOUNT_CACHE_KEY = "APP_USER_{}_APPS_AMOUNT"
APP_USER_CACHE_TIME = 600
def get_related_assets(self):
assets = self.assets.filter(org_id=self.org_id)
return assets
def get_related_apps(self):
from applications.models import Account
apps = Account.objects.filter(systemuser=self)
return apps
def get_username(self):
return self.username
@@ -201,6 +209,15 @@ class BaseUser(OrgModelMixin, AuthMixin):
cache.set(cache_key, cached, self.ASSET_USER_CACHE_TIME)
return cached
@property
def apps_amount(self):
cache_key = self.APPS_AMOUNT_CACHE_KEY.format(self.id)
cached = cache.get(cache_key)
if not cached:
cached = self.get_related_apps().count()
cache.set(cache_key, cached, self.APP_USER_CACHE_TIME)
return cached
def expire_assets_amount(self):
cache_key = self.ASSETS_AMOUNT_CACHE_KEY.format(self.id)
cache.delete(cache_key)

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
@@ -99,16 +103,23 @@ class AuthMixin:
password = cache.get(key)
return password
def load_tmp_auth_if_has(self, asset_or_app_id, user):
if not asset_or_app_id or not user:
return
def _clean_auth_info_if_manual_login_mode(self):
if self.login_mode == self.LOGIN_MANUAL:
self.password = ''
self.private_key = ''
self.public_key = ''
def _load_tmp_auth_if_has(self, asset_or_app_id, user_id):
if self.login_mode != self.LOGIN_MANUAL:
return
auth = self.get_temp_auth(asset_or_app_id, user)
if not asset_or_app_id or not user_id:
return
auth = self.get_temp_auth(asset_or_app_id, user_id)
if not auth:
return
username = auth.get('username')
password = auth.get('password')
@@ -118,17 +129,11 @@ class AuthMixin:
self.password = password
def load_app_more_auth(self, app_id=None, user_id=None):
from users.models import User
self._clean_auth_info_if_manual_login_mode()
# 加载临时认证信息
if self.login_mode == self.LOGIN_MANUAL:
self.password = ''
self.private_key = ''
if not user_id:
self._load_tmp_auth_if_has(app_id, user_id)
return
user = get_object_or_none(User, pk=user_id)
if not user:
return
self.load_tmp_auth_if_has(app_id, user)
def load_asset_special_auth(self, asset, username=''):
"""
@@ -148,34 +153,25 @@ class AuthMixin:
def load_asset_more_auth(self, asset_id=None, username=None, user_id=None):
from users.models import User
self._clean_auth_info_if_manual_login_mode()
# 加载临时认证信息
if self.login_mode == self.LOGIN_MANUAL:
self.password = ''
self.private_key = ''
asset = None
if asset_id:
asset = get_object_or_none(Asset, pk=asset_id)
# 没有资产就没有必要继续了
if not asset:
logger.debug('Asset not found, pass')
self._load_tmp_auth_if_has(asset_id, user_id)
return
user = None
if user_id:
user = get_object_or_none(User, pk=user_id)
_username = self.username
# 更新用户名
user = get_object_or_none(User, pk=user_id) if user_id else None
if self.username_same_with_user:
if user and not username:
_username = user.username
else:
_username = username
self.username = _username
# 加载某个资产的特殊配置认证信息
self.load_asset_special_auth(asset, _username)
self.load_tmp_auth_if_has(asset_id, user)
asset = get_object_or_none(Asset, pk=asset_id) if asset_id else None
if not asset:
logger.debug('Asset not found, pass')
return
self.load_asset_special_auth(asset, self.username)
class SystemUser(ProtocolMixin, AuthMixin, BaseUser):

View File

@@ -5,12 +5,14 @@ from django.core.validators import RegexValidator
from django.utils.translation import ugettext_lazy as _
from orgs.mixins.serializers import BulkOrgResourceModelSerializer
from users.models import User, UserGroup
from perms.models import AssetPermission
from ..models import Asset, Node, Platform, SystemUser
__all__ = [
'AssetSerializer', 'AssetSimpleSerializer',
'AssetSerializer', 'AssetSimpleSerializer', 'MiniAssetSerializer',
'ProtocolsField', 'PlatformSerializer',
'AssetTaskSerializer', 'AssetsTaskSerializer', 'ProtocolsField'
'AssetTaskSerializer', 'AssetsTaskSerializer', 'ProtocolsField',
]
@@ -69,15 +71,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',
'number', 'vendor', 'model', 'sn', 'cpu_model', 'cpu_count',
'protocol', 'port', 'protocols', 'is_active',
'public_ip', 'number', 'comment',
]
hardware_fields = [
'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 +94,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 +164,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 +190,6 @@ class PlatformSerializer(serializers.ModelSerializer):
class AssetSimpleSerializer(serializers.ModelSerializer):
class Meta:
model = Asset
fields = ['id', 'hostname', 'ip', 'port', 'connectivity', 'date_verified']

View File

@@ -3,6 +3,7 @@
from rest_framework import serializers
from django.utils.translation import ugettext_lazy as _
from common.validators import alphanumeric
from orgs.mixins.serializers import BulkOrgResourceModelSerializer
from ..models import Domain, Gateway
from .base import AuthSerializerMixin
@@ -59,6 +60,7 @@ class GatewaySerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
fields_fk = ['domain']
fields = fields_small + fields_fk
extra_kwargs = {
'username': {"validators": [alphanumeric]},
'password': {'write_only': True},
'private_key': {"write_only": True},
'public_key': {"write_only": True},

View File

@@ -4,17 +4,18 @@ from django.db.models import Count
from common.mixins.serializers import BulkSerializerMixin
from common.utils import ssh_pubkey_gen
from common.validators import alphanumeric_re, alphanumeric_cn_re
from orgs.mixins.serializers import BulkOrgResourceModelSerializer
from ..models import SystemUser, Asset
from .utils import validate_password_contains_left_double_curly_bracket
from .base import AuthSerializerMixin
__all__ = [
'SystemUserSerializer',
'SystemUserSerializer', 'MiniSystemUserSerializer',
'SystemUserSimpleSerializer', 'SystemUserAssetRelationSerializer',
'SystemUserNodeRelationSerializer', 'SystemUserTaskSerializer',
'SystemUserUserRelationSerializer', 'SystemUserWithAuthInfoSerializer',
'SystemUserTempAuthSerializer',
'SystemUserTempAuthSerializer', 'RelationMixin',
]
@@ -25,20 +26,23 @@ class SystemUserSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
auto_generate_key = serializers.BooleanField(initial=True, required=False, write_only=True)
type_display = serializers.ReadOnlyField(source='get_type_display', label=_('Type display'))
ssh_key_fingerprint = serializers.ReadOnlyField(label=_('SSH key fingerprint'))
applications_amount = serializers.IntegerField(
source='apps_amount', read_only=True, label=_('Apps amount')
)
class Meta:
model = SystemUser
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', 'applications_amount', 'nodes']
fields = fields_small + fields_m2m
extra_kwargs = {
'password': {
@@ -53,6 +57,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):
@@ -96,15 +101,20 @@ class SystemUserSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
raise serializers.ValidationError(error)
def validate_username(self, username):
if username:
return username
login_mode = self.get_initial_value("login_mode")
protocol = self.get_initial_value("protocol")
username_same_with_user = self.get_initial_value("username_same_with_user")
if username:
regx = alphanumeric_re
if protocol == SystemUser.Protocol.telnet:
regx = alphanumeric_cn_re
if not regx.match(username):
raise serializers.ValidationError(_('Special char not allowed'))
return username
username_same_with_user = self.get_initial_value("username_same_with_user")
if username_same_with_user:
return ''
login_mode = self.get_initial_value("login_mode")
if login_mode == SystemUser.LOGIN_AUTO and protocol != SystemUser.Protocol.vnc:
msg = _('* Automatic login mode must fill in the username.')
raise serializers.ValidationError(msg)
@@ -116,7 +126,8 @@ class SystemUserSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
return ''
return home
def validate_sftp_root(self, value):
@staticmethod
def validate_sftp_root(value):
if value in ['home', 'tmp']:
return value
if not value.startswith('/'):
@@ -124,19 +135,6 @@ class SystemUserSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
raise serializers.ValidationError(error)
return value
def validate_admin_user(self, attrs):
if self.instance:
tp = self.instance.type
else:
tp = attrs.get('type')
if tp != SystemUser.Type.admin:
return attrs
attrs['protocol'] = SystemUser.Protocol.ssh
attrs['login_mode'] = SystemUser.LOGIN_AUTO
attrs['username_same_with_user'] = False
attrs['auto_push'] = False
return attrs
def validate_password(self, password):
super().validate_password(password)
auto_gen_key = self.get_initial_value("auto_generate_key", False)
@@ -148,7 +146,20 @@ class SystemUserSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
raise serializers.ValidationError(_("Password or private key required"))
return password
def validate_gen_key(self, attrs):
def _validate_admin_user(self, attrs):
if self.instance:
tp = self.instance.type
else:
tp = attrs.get('type')
if tp != SystemUser.Type.admin:
return attrs
attrs['protocol'] = SystemUser.Protocol.ssh
attrs['login_mode'] = SystemUser.LOGIN_AUTO
attrs['username_same_with_user'] = False
attrs['auto_push'] = False
return attrs
def _validate_gen_key(self, attrs):
username = attrs.get("username", "manual")
auto_gen_key = attrs.pop("auto_generate_key", False)
protocol = attrs.get("protocol")
@@ -172,18 +183,40 @@ class SystemUserSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
attrs["public_key"] = public_key
return attrs
def _validate_login_mode(self, attrs):
if 'login_mode' in attrs:
login_mode = attrs['login_mode']
else:
login_mode = self.instance.login_mode if self.instance else SystemUser.LOGIN_AUTO
if login_mode == SystemUser.LOGIN_MANUAL:
attrs['password'] = ''
attrs['private_key'] = ''
attrs['public_key'] = ''
return attrs
def validate(self, attrs):
attrs = self.validate_admin_user(attrs)
attrs = self.validate_gen_key(attrs)
attrs = self._validate_admin_user(attrs)
attrs = self._validate_gen_key(attrs)
attrs = self._validate_login_mode(attrs)
return attrs
@classmethod
def setup_eager_loading(cls, queryset):
""" Perform necessary eager loading of data. """
queryset = queryset.annotate(assets_amount=Count("assets"))
queryset = queryset\
.annotate(assets_amount=Count("assets")) \
.prefetch_related('nodes', 'cmd_filters')
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 +240,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,19 @@ 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()
def on_authbook_post_create(sender, instance, created, **kwargs):
instance.sync_to_system_user_account()
if created:
pass
# # 不再自动更新资产管理用户,只允许用户手动指定。
# 只在创建时进行更新资产的管理用户
# 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

@@ -4,6 +4,7 @@
from celery import shared_task
from orgs.utils import tmp_to_root_org
from assets.models import AuthBook
__all__ = ['add_nodes_assets_to_system_users']
@@ -15,4 +16,13 @@ def add_nodes_assets_to_system_users(nodes_keys, system_users):
nodes = Node.objects.filter(key__in=nodes_keys)
assets = Node.get_nodes_all_assets(*nodes)
for system_user in system_users:
system_user.assets.add(*tuple(assets))
""" 解决资产和节点进行关联时,已经关联过的节点不会触发 authbook post_save 信号,
无法更新节点下所有资产的管理用户的问题 """
for asset in assets:
defaults = {'asset': asset, 'systemuser': system_user, 'org_id': asset.org_id}
instance, created = AuthBook.objects.update_or_create(
defaults=defaults, asset=asset, systemuser=system_user
)
# # 不再自动更新资产管理用户,只允许用户手动指定。
# 只要关联都需要更新资产的管理用户
# instance.update_asset_admin_user_if_need()

View File

@@ -36,6 +36,10 @@ urlpatterns = [
path('assets/<uuid:pk>/platform/', api.AssetPlatformRetrieveApi.as_view(), name='asset-platform-detail'),
path('assets/<uuid:pk>/tasks/', api.AssetTaskCreateApi.as_view(), name='asset-task-create'),
path('assets/tasks/', api.AssetsTaskCreateApi.as_view(), name='assets-task-create'),
path('assets/<uuid:pk>/perm-users/', api.AssetPermUserListApi.as_view(), name='asset-perm-user-list'),
path('assets/<uuid:pk>/perm-users/<uuid:perm_user_id>/permissions/', api.AssetPermUserPermissionsListApi.as_view(), name='asset-perm-user-permission-list'),
path('assets/<uuid:pk>/perm-user-groups/', api.AssetPermUserGroupListApi.as_view(), name='asset-perm-user-group-list'),
path('assets/<uuid:pk>/perm-user-groups/<uuid:perm_user_group_id>/permissions/', api.AssetPermUserGroupPermissionsListApi.as_view(), name='asset-perm-user-group-permission-list'),
path('system-users/<uuid:pk>/auth-info/', api.SystemUserAuthInfoApi.as_view(), name='system-user-auth-info'),
path('system-users/<uuid:pk>/assets/', api.SystemUserAssetsListView.as_view(), name='system-user-assets'),

View File

@@ -39,7 +39,7 @@ class UserLoginLogViewSet(ListModelMixin, CommonGenericViewSet):
('datetime', ('date_from', 'date_to'))
]
filterset_fields = ['username', 'ip', 'city', 'type', 'status', 'mfa']
search_fields =['username', 'ip', 'city']
search_fields = ['username', 'ip', 'city']
@staticmethod
def get_org_members():
@@ -48,9 +48,10 @@ class UserLoginLogViewSet(ListModelMixin, CommonGenericViewSet):
def get_queryset(self):
queryset = super().get_queryset()
if not current_org.is_default():
users = self.get_org_members()
queryset = queryset.filter(username__in=users)
if current_org.is_root():
return queryset
users = self.get_org_members()
queryset = queryset.filter(username__in=users)
return queryset

5
apps/audits/const.py Normal file
View File

@@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
#
from django.utils.translation import ugettext_lazy as _
DEFAULT_CITY = _("Unknown")

View File

@@ -35,13 +35,14 @@ class UserLoginLogSerializer(serializers.ModelSerializer):
fields_mini = ['id']
fields_small = fields_mini + [
'username', 'type', 'type_display', 'ip', 'city', 'user_agent',
'mfa', 'mfa_display', 'reason', 'backend',
'mfa', 'mfa_display', 'reason', 'reason_display', 'backend',
'status', 'status_display',
'datetime',
]
fields = fields_small
extra_kwargs = {
"user_agent": {'label': _('User agent')}
"user_agent": {'label': _('User agent')},
"reason_display": {'label': _('Reason')}
}

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, m2m_changed, pre_delete
)
from django.dispatch import receiver
from django.conf import settings
from django.db import transaction
@@ -12,30 +14,30 @@ from rest_framework.renderers import JSONRenderer
from rest_framework.request import Request
from assets.models import Asset, SystemUser
from common.const.signals import POST_ADD, POST_REMOVE, POST_CLEAR
from authentication.signals import post_auth_failed, post_auth_success
from authentication.utils import check_different_city_login
from jumpserver.utils import current_request
from common.utils import get_request_ip, get_logger, get_syslogger
from users.models import User
from users.signals import post_user_change_password
from authentication.signals import post_auth_failed, post_auth_success
from terminal.models import Session, Command
from common.utils.encode import model_to_json
from .utils import write_login_log
from . import models
from .models import OperateLog
from orgs.utils import current_org
from perms.models import AssetPermission, ApplicationPermission
from common.const.signals import POST_ADD, POST_REMOVE, POST_CLEAR
from common.utils import get_request_ip, get_logger, get_syslogger
from common.utils.encode import model_to_json
logger = get_logger(__name__)
sys_logger = get_syslogger(__name__)
json_render = JSONRenderer()
MODELS_NEED_RECORD = (
# users
'User', 'UserGroup',
# acls
'LoginACL', 'LoginAssetACL',
'LoginACL', 'LoginAssetACL', 'LoginConfirmSetting',
# assets
'Asset', 'Node', 'AdminUser', 'SystemUser', 'Domain', 'Gateway', 'CommandFilterRule',
'CommandFilter', 'Platform', 'AuthBook',
@@ -98,72 +100,71 @@ 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}'),
),
}
M2M_ACTION = {
POST_ADD: 'add',
POST_REMOVE: 'remove',
@@ -226,7 +227,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)
@@ -303,6 +304,7 @@ def generate_data(username, request, login_type=None):
@receiver(post_auth_success)
def on_user_auth_success(sender, user, request, login_type=None, **kwargs):
logger.debug('User login success: {}'.format(user.username))
check_different_city_login(user, request)
data = generate_data(user.username, request, login_type=login_type)
data.update({'mfa': int(user.mfa_enabled), 'status': True})
write_login_log(**data)

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

@@ -1,8 +1,8 @@
import csv
import codecs
from django.http import HttpResponse
from django.utils.translation import ugettext as _
from .const import DEFAULT_CITY
from common.utils import validate_ip, get_ip_city
@@ -27,12 +27,12 @@ def write_content_to_excel(response, header=None, login_logs=None, fields=None):
def write_login_log(*args, **kwargs):
from audits.models import UserLoginLog
default_city = _("Unknown")
ip = kwargs.get('ip') or ''
if not (ip and validate_ip(ip)):
ip = ip[:15]
city = default_city
city = DEFAULT_CITY
else:
city = get_ip_city(ip) or default_city
city = get_ip_city(ip) or DEFAULT_CITY
kwargs.update({'ip': ip, 'city': city})
UserLoginLog.objects.create(**kwargs)

View File

@@ -19,10 +19,13 @@ from rest_framework import serializers
from authentication.signals import post_auth_failed, post_auth_success
from common.utils import get_logger, random_string
from common.drf.api import SerializerMixin
from common.mixins.api import SerializerMixin
from common.permissions import IsSuperUserOrAppUser, IsValidUser, IsSuperUser
from orgs.mixins.api import RootOrgViewMixin
from common.http import is_true
from perms.utils.asset.permission import get_asset_system_user_ids_with_actions_by_user
from perms.models.asset_permission import Action
from authentication.errors import NotHaveUpDownLoadPerm
from ..serializers import (
ConnectionTokenSerializer, ConnectionTokenSecretSerializer,
@@ -74,6 +77,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 +88,17 @@ 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 and asset:
systemuser_actions_mapper = get_asset_system_user_ids_with_actions_by_user(user, asset)
actions = systemuser_actions_mapper.get(system_user.id, [])
if actions & Action.UPDOWNLOAD:
options['drivestoredirect:s'] = '*'
else:
raise NotHaveUpDownLoadPerm
options['screen mode id:i'] = '2' if full_screen else '1'
address = settings.TERMINAL_RDP_ADDR
if not address or address == 'localhost:3389':
@@ -137,15 +150,19 @@ class ClientProtocolMixin:
def get_client_protocol_data(self, serializer):
asset, application, system_user, user = self.get_request_resource(serializer)
protocol = system_user.protocol
username = user.username
name = ''
if protocol == 'rdp':
name, config = self.get_rdp_file_content(serializer)
elif protocol == 'vnc':
raise HttpResponse(status=404, data={"error": "VNC not support"})
else:
config = 'ssh://system_user@asset@user@jumpserver-ssh'
filename = "{}-{}-jumpserver".format(username, name)
data = {
"filename": filename,
"protocol": system_user.protocol,
"username": user.username,
"username": username,
"config": config
}
return data

View File

@@ -8,29 +8,12 @@ from django.shortcuts import get_object_or_404
from common.utils import get_logger
from common.permissions import IsOrgAdmin
from ..models import LoginConfirmSetting
from ..serializers import LoginConfirmSettingSerializer
from .. import errors, mixins
__all__ = ['LoginConfirmSettingUpdateApi', 'TicketStatusApi']
__all__ = ['TicketStatusApi']
logger = get_logger(__name__)
class LoginConfirmSettingUpdateApi(UpdateAPIView):
permission_classes = (IsOrgAdmin,)
serializer_class = LoginConfirmSettingSerializer
def get_object(self):
from users.models import User
user_id = self.kwargs.get('user_id')
user = get_object_or_404(User, pk=user_id)
defaults = {'user': user}
s, created = LoginConfirmSetting.objects.get_or_create(
defaults, user=user,
)
return s
class TicketStatusApi(mixins.AuthMixin, APIView):
permission_classes = (AllowAny,)
@@ -45,5 +28,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,21 +1,36 @@
# -*- coding: utf-8 -*-
#
import builtins
import time
from django.utils.translation import ugettext as _
from django.conf import settings
from django.shortcuts import get_object_or_404
from rest_framework.permissions import AllowAny
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 users.models.user import MFAType, User
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,19 @@ 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):
username = request.data.get('username', '')
username = username.strip()
if username:
user = get_object_or_404(User, username=username)
else:
user = self.get_user_from_session()
if not user.mfa_enabled:
raise errors.NotEnableMFAError
timeout = user.send_sms_code()
return Response({'code': 'ok', 'timeout': timeout})

View File

@@ -9,7 +9,7 @@ from rest_framework.response import Response
from rest_framework.request import Request
from rest_framework.permissions import AllowAny
from common.utils.timezone import utcnow
from common.utils.timezone import utc_now
from common.const.http import POST, GET
from common.drf.api import JMSGenericViewSet
from common.drf.serializers import EmptySerializer
@@ -79,7 +79,7 @@ class SSOViewSet(AuthMixin, JMSGenericViewSet):
return HttpResponseRedirect(next_url)
# 判断是否过期
if (utcnow().timestamp() - token.date_created.timestamp()) > settings.AUTH_SSO_AUTHKEY_TTL:
if (utc_now().timestamp() - token.date_created.timestamp()) > settings.AUTH_SSO_AUTHKEY_TTL:
self.send_auth_signal(success=False, reason='authkey_timeout')
return HttpResponseRedirect(next_url)

View File

@@ -6,5 +6,6 @@ class AuthenticationConfig(AppConfig):
def ready(self):
from . import signals_handlers
from . import notifications
super().ready()

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

@@ -3,10 +3,12 @@
from django.utils.translation import ugettext_lazy as _
from django.urls import reverse
from django.conf import settings
from rest_framework import status
from common.exceptions import JMSException
from .signals import post_auth_failed
from users.utils import LoginBlockUtil, MFABlockUtils
from users.models import MFAType
reason_password_failed = 'password_failed'
reason_password_decrypt_failed = 'password_decrypt_failed'
@@ -58,14 +60,25 @@ 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)"
)
mfa_required_msg = _("MFA required")
mfa_unset_msg = _("MFA not set, please set it first")
otp_unset_msg = _("OTP not set, please set it first")
login_confirm_required_msg = _("Login confirm required")
login_confirm_wait_msg = _("Wait login confirm ticket for accept")
login_confirm_error_msg = _("Login confirm ticket was {}")
@@ -121,6 +134,10 @@ class CredentialError(AuthFailedNeedLogMixin, AuthFailedNeedBlockMixin, AuthFail
times_remainder = util.get_remainder_times()
block_time = settings.SECURITY_LOGIN_LIMIT_TIME
if times_remainder < 1:
self.msg = block_login_msg.format(settings.SECURITY_LOGIN_LIMIT_TIME)
return
default_msg = invalid_login_msg.format(
times_try=times_remainder, block_time=block_time
)
@@ -134,7 +151,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 +159,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 +228,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')
}
}
@@ -235,6 +265,13 @@ class LoginIPNotAllowed(ACLError):
super().__init__(_("IP is not allowed"), **kwargs)
class TimePeriodNotAllowed(ACLError):
def __init__(self, username, request, **kwargs):
self.username = username
self.request = request
super().__init__(_("Time Period is not allowed"), **kwargs)
class LoginConfirmBaseError(NeedMoreInfoError):
def __init__(self, ticket_id, **kwargs):
self.ticket_id = ticket_id
@@ -323,3 +360,31 @@ class FeiShuNotBound(JMSException):
class PasswdInvalid(JMSException):
default_code = 'passwd_invalid'
default_detail = _('Your password is invalid')
class NotHaveUpDownLoadPerm(JMSException):
status_code = status.HTTP_403_FORBIDDEN
code = 'not_have_up_down_load_perm'
default_detail = _('No upload or download permission')
class NotEnableMFAError(JMSException):
default_detail = mfa_unset_msg
class OTPBindRequiredError(JMSException):
default_detail = otp_unset_msg
def __init__(self, url, *args, **kwargs):
super().__init__(*args, **kwargs)
self.url = url
class OTPCodeRequiredError(AuthFailedError):
msg = _("Please enter MFA code")
class SMSCodeRequiredError(AuthFailedError):
msg = _("Please enter SMS code")
class UserPhoneNotSet(AuthFailedError):
msg = _('Phone not set')

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, required=False)
mfa_type = forms.CharField(label=_('MFA type'), max_length=6)
class CustomCaptchaTextInput(CaptchaTextInput):
@@ -58,7 +59,7 @@ class ChallengeMixin(forms.Form):
challenge = forms.CharField(
label=_('MFA code'), max_length=6, required=False,
widget=forms.TextInput(attrs={
'placeholder': _("MFA code"),
'placeholder': _("Dynamic code"),
'style': 'width: 50%'
})
)
@@ -66,9 +67,11 @@ 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_MFA_IN_LOGIN_PAGE:
bases.append(UserCheckOtpCodeForm)
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

@@ -0,0 +1,16 @@
# Generated by Django 3.1.12 on 2021-09-26 11:13
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('authentication', '0004_ssotoken'),
]
operations = [
migrations.DeleteModel(
name='LoginConfirmSetting',
),
]

View File

@@ -1,12 +1,13 @@
# -*- coding: utf-8 -*-
#
import inspect
from urllib.parse import urlencode
from django.utils.http import urlencode
from functools import partial
import time
from django.core.cache import cache
from django.conf import settings
from django.urls import reverse_lazy
from django.contrib import auth
from django.utils.translation import ugettext as _
from django.contrib.auth import (
@@ -16,12 +17,13 @@ from django.contrib.auth import (
from django.shortcuts import reverse, redirect
from common.utils import get_object_or_none, get_request_ip, get_logger, bulk_get, FlashMessageUtil
from users.models import User
from acls.models import LoginACL
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 +31,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 +81,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 +171,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 +188,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,24 +205,24 @@ 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)
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()
password = password + challenge.strip()
return username, password, public_key, ip, auto_login
def _check_only_allow_exists_user_auth(self, username):
# 仅允许预先存在的用户认证
if settings.ONLY_ALLOW_EXIST_USER_AUTH:
exist = User.objects.filter(username=username).exists()
if not exist:
logger.error(f"Only allow exist user auth, login failed: {username}")
self.raise_credential_error(errors.reason_user_not_exist)
if not settings.ONLY_ALLOW_EXIST_USER_AUTH:
return
exist = User.objects.filter(username=username).exists()
if not exist:
logger.error(f"Only allow exist user auth, login failed: {username}")
self.raise_credential_error(errors.reason_user_not_exist)
def _check_auth_user_is_valid(self, username, password, public_key):
user = authenticate(self.request, username=username, password=password, public_key=public_key)
@@ -186,12 +234,30 @@ class AuthMixin:
self.raise_credential_error(errors.reason_user_inactive)
return user
def _check_login_mfa_login_if_need(self, user):
request = self.request
if hasattr(request, 'data'):
data = request.data
else:
data = request.POST
code = data.get('code')
mfa_type = data.get('mfa_type')
if settings.SECURITY_MFA_IN_LOGIN_PAGE and mfa_type:
if not code:
if mfa_type == MFAType.OTP and bool(user.otp_secret_key):
raise errors.OTPCodeRequiredError
elif mfa_type == MFAType.SMS_CODE:
raise errors.SMSCodeRequiredError
self.check_user_mfa(code, mfa_type, user=user)
def _check_login_acl(self, user, ip):
# ACL 限制用户登录
from acls.models import LoginACL
is_allowed = LoginACL.allow_user_to_login(user, ip)
is_allowed, limit_type = LoginACL.allow_user_to_login(user, ip)
if not is_allowed:
raise errors.LoginIPNotAllowed(username=user.username, request=self.request)
if limit_type == 'ip':
raise errors.LoginIPNotAllowed(username=user.username, request=self.request)
elif limit_type == 'time':
raise errors.TimePeriodNotAllowed(username=user.username, request=self.request)
def set_login_failed_mark(self):
ip = self.get_request_ip()
@@ -210,8 +276,7 @@ class AuthMixin:
def check_user_auth(self, decrypt_passwd=False):
self.check_is_block()
request = self.request
username, password, public_key, ip, auto_login = self.get_auth_data(decrypt_passwd=decrypt_passwd)
username, password, public_key, ip, auto_login = self.get_auth_data(decrypt_passwd)
self._check_only_allow_exists_user_auth(username)
user = self._check_auth_user_is_valid(username, password, public_key)
@@ -221,7 +286,11 @@ class AuthMixin:
self._check_passwd_is_too_simple(user, password)
self._check_passwd_need_update(user)
# 校验login-mfa, 如果登录页面上显示 mfa 的话
self._check_login_mfa_login_if_need(user)
LoginBlockUtil(username, ip).clean_failed_count()
request = self.request
request.session['auth_password'] = 1
request.session['user_id'] = str(user.id)
request.session['auto_login'] = auto_login
@@ -243,7 +312,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,32 +372,55 @@ 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():
logger.warn('Ip was blocked' + ': ' + username + ':' + ip)
exception = errors.BlockMFAError(username=username, request=self.request, ip=ip)
if raise_exception:
raise exception
else:
return exception
blocked = MFABlockUtils(username, ip).is_block()
if not blocked:
return
logger.warn('Ip was blocked' + ': ' + username + ':' + ip)
exception = errors.BlockMFAError(username=username, request=self.request, ip=ip)
if raise_exception:
raise exception
else:
return exception
def check_user_mfa(self, code, mfa_type=MFAType.OTP, user=None):
user = user if user else self.get_user_from_session()
if not user.mfa_enabled:
return
if not bool(user.phone) and mfa_type == MFAType.SMS_CODE:
raise errors.UserPhoneNotSet
if not bool(user.otp_secret_key) and mfa_type == MFAType.OTP:
self.set_passwd_verify_on_session(user)
raise errors.OTPBindRequiredError(reverse_lazy('authentication:user-otp-enable-bind'))
def check_user_mfa(self, code):
user = self.get_user_from_session()
ip = self.get_request_ip()
self.check_mfa_is_block(user.username, ip)
ok = user.check_mfa(code)
ok = user.check_mfa(code, mfa_type=mfa_type)
if ok:
self.mark_mfa_ok()
return
@@ -337,7 +428,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 +454,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(
@@ -380,10 +473,9 @@ class AuthMixin:
)
def check_user_login_confirm_if_need(self, user):
if not settings.LOGIN_CONFIRM_ENABLE:
return
confirm_setting = user.get_login_confirm_setting()
if self.request.session.get('auth_confirm') or not confirm_setting:
ip = self.get_request_ip()
is_allowed, confirm_setting = LoginACL.allow_user_confirm_if_need(user, ip)
if self.request.session.get('auth_confirm') or not is_allowed:
return
self.get_ticket_or_create(confirm_setting)
self.check_user_login_confirm()
@@ -391,7 +483,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'] = ''
@@ -412,3 +503,31 @@ class AuthMixin:
if args:
guard_url = "%s?%s" % (guard_url, args)
return redirect(guard_url)
@staticmethod
def get_user_mfa_methods(user=None):
otp_enabled = user.otp_secret_key if user else True
# 没有用户时,或者有用户并且有电话配置
sms_enabled = any([user and user.phone, not user]) \
and settings.SMS_ENABLED and settings.XPACK_ENABLED
methods = [
{
'name': 'otp',
'label': 'MFA',
'enable': otp_enabled,
'selected': False,
},
{
'name': 'sms',
'label': _('SMS'),
'enable': sms_enabled,
'selected': False,
},
]
for item in methods:
if item['enable']:
item['selected'] = True
break
return methods

View File

@@ -1,13 +1,10 @@
import uuid
from django.utils import timezone
from django.utils.translation import ugettext_lazy as _, ugettext as __
from django.utils.translation import ugettext_lazy as _
from rest_framework.authtoken.models import Token
from django.conf import settings
from common.db import models
from common.mixins.models import CommonModelMixin
from common.utils import get_object_or_none, get_request_ip, get_ip_city
class AccessKey(models.Model):
@@ -40,53 +37,6 @@ class PrivateToken(Token):
verbose_name = _('Private Token')
class LoginConfirmSetting(CommonModelMixin):
user = models.OneToOneField('users.User', on_delete=models.CASCADE, verbose_name=_("User"), related_name="login_confirm_setting")
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"))
@classmethod
def get_user_confirm_setting(cls, user):
return get_object_or_none(cls, user=user)
@staticmethod
def construct_confirm_ticket_meta(request=None):
if request:
login_ip = get_request_ip(request)
else:
login_ip = ''
login_ip = login_ip or '0.0.0.0'
login_city = get_ip_city(login_ip)
login_datetime = timezone.now().strftime('%Y-%m-%d %H:%M:%S')
ticket_meta = {
'apply_login_ip': login_ip,
'apply_login_city': login_city,
'apply_login_datetime': login_datetime,
}
return ticket_meta
def create_confirm_ticket(self, request=None):
from tickets import const
from tickets.models import Ticket
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,
'meta': ticket_meta,
'org_id': Organization.ROOT_ID,
}
ticket = Ticket.objects.create(**data)
ticket.assignees.set(ticket_assignees)
ticket.open(self.user)
return ticket
def __str__(self):
return '{} confirm'.format(self.user.username)
class SSOToken(models.JMSBaseModel):
"""
类似腾讯企业邮的 [单点登录](https://exmail.qq.com/qy_mng_logic/doc#10036)

View File

@@ -0,0 +1,41 @@
from django.utils import timezone
from django.utils.translation import ugettext as _
from django.template.loader import render_to_string
from notifications.notifications import UserMessage
from common.utils import get_logger
logger = get_logger(__file__)
class DifferentCityLoginMessage(UserMessage):
def __init__(self, user, ip, city):
self.ip = ip
self.city = city
super().__init__(user)
def get_html_msg(self) -> dict:
now_local = timezone.localtime(timezone.now())
now = now_local.strftime("%Y-%m-%d %H:%M:%S")
subject = _('Different city login reminder')
context = dict(
subject=subject,
name=self.user.name,
username=self.user.username,
ip=self.ip,
time=now,
city=self.city,
)
message = render_to_string('authentication/_msg_different_city.html', context)
return {
'subject': subject,
'message': message
}
@classmethod
def gen_test_msg(cls):
from users.models import User
user = User.objects.first()
ip = '8.8.8.8'
city = '洛杉矶'
return cls(user, ip, city)

View File

@@ -10,14 +10,13 @@ from applications.models import Application
from users.serializers import UserProfileSerializer
from assets.serializers import ProtocolsField
from perms.serializers.asset.permission import ActionsField
from .models import AccessKey, LoginConfirmSetting
from .models import AccessKey
__all__ = [
'AccessKeySerializer', 'OtpVerifySerializer', 'BearerTokenSerializer',
'MFAChallengeSerializer', 'LoginConfirmSettingSerializer', 'SSOTokenSerializer',
'ConnectionTokenSerializer', 'ConnectionTokenSecretSerializer', 'RDPFileSerializer',
'PasswordVerifySerializer',
'MFAChallengeSerializer', 'SSOTokenSerializer',
'ConnectionTokenSerializer', 'ConnectionTokenSecretSerializer',
'PasswordVerifySerializer', 'MFASelectTypeSerializer',
]
@@ -77,6 +76,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)
@@ -88,13 +91,6 @@ class MFAChallengeSerializer(serializers.Serializer):
pass
class LoginConfirmSettingSerializer(serializers.ModelSerializer):
class Meta:
model = LoginConfirmSetting
fields = ['id', 'user', 'reviewers', 'date_created', 'date_updated']
read_only_fields = ['date_created', 'date_updated']
class SSOTokenSerializer(serializers.Serializer):
username = serializers.CharField(write_only=True)
login_url = serializers.CharField(read_only=True)
@@ -166,7 +162,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):
@@ -197,4 +193,3 @@ class ConnectionTokenSecretSerializer(serializers.Serializer):
gateway = ConnectionTokenGatewaySerializer(read_only=True)
actions = ActionsField()
expired_at = serializers.IntegerField()

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,98 @@
import random
from django.core.cache import cache
from django.utils.translation import gettext_lazy as _
from common.sdk.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

@@ -5,9 +5,9 @@
<div class="col-sm-6">
<div class="input-group-prepend">
{% if audio %}
<a title="{% trans "Play CAPTCHA as audio file" %}" href="{{ audio }}">
<a title="{% trans "Play CAPTCHA as audio file" %}" href="{{ audio }}"></a>
{% endif %}
</div>
</div>
{% include "django/forms/widgets/multiwidget.html" %}
</div>
</div>

View File

@@ -0,0 +1,16 @@
{% load i18n %}
<p>
{% trans 'Hello' %} {{ name }},
</p>
<p>
{% trans 'Your account has remote login behavior, please pay attention' %}
</p>
<p>
<b>{% trans 'Username' %}:</b> {{ username }}<br>
<b>{% trans 'Login time' %}:</b> {{ time }}<br>
<b>{% trans 'Login city' %}:</b> {{ city }}({{ ip }})
</p>
<p>
{% trans 'If you suspect that the login behavior is abnormal, please modify the account password in time.' %}
</p>

View File

@@ -0,0 +1,19 @@
{% load i18n %}
<p>
{% trans 'Hello' %} {{ user.name }},
</p>
<p>
{% trans 'Please click the link below to reset your password, if not your request, concern your account security' %}
<br>
<br>
<a href="{{ rest_password_url }}?token={{ rest_password_token}}" class='showLink' target="_blank">
{% trans 'Click here reset password' %}
</a>
</p>
<p>
{% trans 'This link is valid for 1 hour. After it expires' %}
<a href="{{ forget_password_url }}?email={{ user.email }}">
{% trans 'request new one' %}
</a>
</p>

View File

@@ -0,0 +1,14 @@
{% load i18n %}
<p>{% trans 'Hello' %} {{ name }},</p>
<p>
{% trans 'Your password has just been successfully updated' %}
</p>
<p>
<b>{% trans 'IP' %}:</b> {{ ip_address }} <br />
<b>{% trans 'Browser' %}:</b> {{ browser }}
</p>
<p>
{% trans 'If the password update was not initiated by you, your account may have security issues' %} <br />
{% trans 'If you have any questions, you can contact the administrator' %}
</p>

View File

@@ -10,23 +10,15 @@
{{ JMS_TITLE }}
</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{% include '_head_css_js.html' %}
<!-- Stylesheets -->
<link href="{% static 'css/bootstrap.min.css' %}" rel="stylesheet">
<link href="{% static 'css/font-awesome.min.css' %}" rel="stylesheet">
<link href="{% static 'css/bootstrap-style.css' %}" rel="stylesheet">
<link href="{% static 'css/login-style.css' %}" rel="stylesheet">
<link href="{% static 'css/style.css' %}" rel="stylesheet">
<link href="{% static 'css/jumpserver.css' %}" rel="stylesheet">
<!-- scripts -->
<script src="{% static 'js/jquery-3.1.1.min.js' %}"></script>
<script src="{% static 'js/plugins/sweetalert/sweetalert.min.js' %}"></script>
<script src="{% static 'js/bootstrap.min.js' %}"></script>
<script src="{% static 'js/plugins/datatables/datatables.min.js' %}"></script>
<script src="{% static "js/jumpserver.js" %}"></script>
<style>
.login-content {
box-shadow: 0 5px 5px -3px rgb(0 0 0 / 20%), 0 8px 10px 1px rgb(0 0 0 / 14%), 0 3px 14px 2px rgb(0 0 0 / 12%);
box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.15), 0 8px 10px 1px rgba(0, 0, 0, 0.14), 0 3px 14px 2px rgba(0, 0, 0, 0.12);
}
.help-block {
@@ -49,17 +41,22 @@
}
.login-content {
height: 472px;
width: 984px;
height: 490px;
width: 1066px;
margin-right: auto;
margin-left: auto;
margin-top: calc((100vh - 470px) / 3);
}
body {
background-color: #f2f2f2;
height: calc(100vh - (100vh - 470px) / 3);
}
.captcha {
float: right;
}
.right-image-box {
height: 100%;
width: 50%;
@@ -73,18 +70,10 @@
width: 50%;
}
.captcha {
float: right;
}
.red-fonts {
color: red;
}
.field-error {
text-align: left;
}
.form-group.has-error {
margin-bottom: 0;
}
@@ -109,14 +98,6 @@
padding-top: 10px;
}
.radio, .checkbox {
margin: 0;
}
#github_star {
float: right;
margin: 10px 10px 0 0;
}
.more-login-item {
border-right: 1px dashed #dedede;
padding-left: 5px;
@@ -127,11 +108,18 @@
border: none;
}
.select-con {
width: 22%;
}
.mfa-div {
width: 100%;
}
</style>
</head>
<body>
<div class="login-content ">
<div class="login-content">
<div class="right-image-box">
<a href="{% if not XPACK_ENABLED %}https://github.com/jumpserver/jumpserver{% endif %}">
<img src="{{ LOGIN_IMAGE_URL }}" style="height: 100%; width: 100%"/>
@@ -146,11 +134,9 @@
<form id="login-form" action="" method="post" role="form" novalidate="novalidate">
{% csrf_token %}
<div style="line-height: 17px;margin-bottom: 20px;color: #999999;">
{% if form.errors %}
<p class="red-fonts" style="color: red">
{% if form.non_field_errors %}
{{ form.non_field_errors.as_text }}
{% endif %}
{% if form.non_field_errors %}
<p class="help-block red-fonts">
{{ form.non_field_errors.as_text }}
</p>
{% else %}
<p class="welcome-message">
@@ -160,12 +146,22 @@
</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 %}
{% elif form.mfa_type %}
<div class="form-group" style="display: flex">
{% include '_mfa_otp_login.html' %}
</div>
{% elif form.captcha %}
<div class="captch-field">
{% bootstrap_field form.captcha show_label=False %}
@@ -191,36 +187,15 @@
</div>
<div>
{% if AUTH_OPENID or AUTH_CAS or AUTH_WECOM or AUTH_DINGTALK or AUTH_FEISHU %}
{% if auth_methods %}
<div class="hr-line-dashed"></div>
<div style="display: inline-block; float: left">
<b class="text-muted text-left" >{% trans "More login options" %}</b>
{% if AUTH_OPENID %}
<a href="{% url 'authentication:openid:login' %}" class="more-login-item">
<i class="fa fa-openid"></i> {% trans 'OpenID' %}
</a>
{% endif %}
{% if AUTH_CAS %}
<a href="{% url 'authentication:cas:cas-login' %}" class="more-login-item">
<i class="fa"><img src="{{ LOGIN_CAS_LOGO_URL }}" height="13" width="13"></i> {% trans 'CAS' %}
</a>
{% endif %}
{% if AUTH_WECOM %}
<a href="{% url 'authentication:wecom-qr-login' %}" class="more-login-item">
<i class="fa"><img src="{{ LOGIN_WECOM_LOGO_URL }}" height="13" width="13"></i> {% trans 'WeCom' %}
</a>
{% endif %}
{% if AUTH_DINGTALK %}
<a href="{% url 'authentication:dingtalk-qr-login' %}" class="more-login-item">
<i class="fa"><img src="{{ LOGIN_DINGTALK_LOGO_URL }}" height="13" width="13"></i> {% trans 'DingTalk' %}
</a>
{% endif %}
{% if AUTH_FEISHU %}
<a href="{% url 'authentication:feishu-qr-login' %}" class="more-login-item">
<i class="fa"><img src="{{ LOGIN_FEISHU_LOGO_URL }}" height="13" width="13"></i> {% trans 'FeiShu' %}
<b class="text-muted text-left" >{% trans "More login options" %}</b>
{% for method in auth_methods %}
<a href="{{ method.url }}" class="more-login-item">
<i class="fa"><img src="{{ method.logo }}" height="13" width="13"></i> {{ method.name }}
</a>
{% endif %}
{% endfor %}
</div>
{% else %}
<div class="text-center" style="display: inline-block;">
@@ -236,6 +211,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); //加密
@@ -247,7 +225,7 @@
var password = $('#password').val(); //明文密码
var passwordEncrypted = encryptLoginPassword(password, rsaPublicKey)
$('#password-hidden').val(passwordEncrypted); //返回给密码输入input
$('#login-form').submit();//post提交
$('#login-form').submit(); //post提交
}
$(document).ready(function () {

View File

@@ -9,24 +9,23 @@
{% 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>
{% include '_mfa_otp_login.html' %}
</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>
<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">
.mfa-div {
margin-top: 15px;
}
</style>
<script>
</script>
{% endblock %}

View File

@@ -157,8 +157,11 @@ $(document).ready(function () {
cancelCloseConfirm();
window.location.reload();
}).on('click', '.btn-return', function () {
cancelTicket();
cancelCloseConfirm();
window.location = "{% url 'authentication:login' %}"
setTimeout(() => {
window.location = "{% url 'authentication:login' %}"
}, 1000);
})
</script>

View File

@@ -13,7 +13,6 @@ router.register('connection-token', api.UserConnectionTokenViewSet, 'connection-
urlpatterns = [
# path('token/', api.UserToken.as_view(), name='user-token'),
path('wecom/qr/unbind/', api.WeComQRUnBindForUserApi.as_view(), name='wecom-qr-unbind'),
path('wecom/qr/unbind/<uuid:user_id>/', api.WeComQRUnBindForAdminApi.as_view(), name='wecom-qr-unbind-for-admin'),
@@ -27,10 +26,11 @@ urlpatterns = [
path('auth/', api.TokenCreateApi.as_view(), name='user-auth'),
path('tokens/', api.TokenCreateApi.as_view(), name='auth-token'),
path('mfa/challenge/', api.MFAChallengeApi.as_view(), name='mfa-challenge'),
path('mfa/select/', api.MFASelectTypeApi.as_view(), name='mfa-select'),
path('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')
]
urlpatterns += router.urls

View File

@@ -22,24 +22,18 @@ urlpatterns = [
path('password/reset/', users_view.UserResetPasswordView.as_view(), name='reset-password'),
path('password/verify/', users_view.UserVerifyPasswordView.as_view(), name='user-verify-password'),
path('wecom/bind/success-flash-msg/', views.FlashWeComBindSucceedMsgView.as_view(), name='wecom-bind-success-flash-msg'),
path('wecom/bind/failed-flash-msg/', views.FlashWeComBindFailedMsgView.as_view(), name='wecom-bind-failed-flash-msg'),
path('wecom/bind/start/', views.WeComEnableStartView.as_view(), name='wecom-bind-start'),
path('wecom/qr/bind/', views.WeComQRBindView.as_view(), name='wecom-qr-bind'),
path('wecom/qr/login/', views.WeComQRLoginView.as_view(), name='wecom-qr-login'),
path('wecom/qr/bind/<uuid:user_id>/callback/', views.WeComQRBindCallbackView.as_view(), name='wecom-qr-bind-callback'),
path('wecom/qr/login/callback/', views.WeComQRLoginCallbackView.as_view(), name='wecom-qr-login-callback'),
path('dingtalk/bind/success-flash-msg/', views.FlashDingTalkBindSucceedMsgView.as_view(), name='dingtalk-bind-success-flash-msg'),
path('dingtalk/bind/failed-flash-msg/', views.FlashDingTalkBindFailedMsgView.as_view(), name='dingtalk-bind-failed-flash-msg'),
path('dingtalk/bind/start/', views.DingTalkEnableStartView.as_view(), name='dingtalk-bind-start'),
path('dingtalk/qr/bind/', views.DingTalkQRBindView.as_view(), name='dingtalk-qr-bind'),
path('dingtalk/qr/login/', views.DingTalkQRLoginView.as_view(), name='dingtalk-qr-login'),
path('dingtalk/qr/bind/<uuid:user_id>/callback/', views.DingTalkQRBindCallbackView.as_view(), name='dingtalk-qr-bind-callback'),
path('dingtalk/qr/login/callback/', views.DingTalkQRLoginCallbackView.as_view(), name='dingtalk-qr-login-callback'),
path('feishu/bind/success-flash-msg/', views.FlashDingTalkBindSucceedMsgView.as_view(), name='feishu-bind-success-flash-msg'),
path('feishu/bind/failed-flash-msg/', views.FlashDingTalkBindFailedMsgView.as_view(), name='feishu-bind-failed-flash-msg'),
path('feishu/bind/start/', views.FeiShuEnableStartView.as_view(), name='feishu-bind-start'),
path('feishu/qr/bind/', views.FeiShuQRBindView.as_view(), name='feishu-qr-bind'),
path('feishu/qr/login/', views.FeiShuQRLoginView.as_view(), name='feishu-qr-login'),

View File

@@ -5,6 +5,11 @@ from Cryptodome.PublicKey import RSA
from Cryptodome.Cipher import PKCS1_v1_5
from Cryptodome import Random
from .notifications import DifferentCityLoginMessage
from audits.models import UserLoginLog
from audits.const import DEFAULT_CITY
from common.utils import get_request_ip
from common.utils import validate_ip, get_ip_city
from common.utils import get_logger
logger = get_logger(__file__)
@@ -44,3 +49,16 @@ def rsa_decrypt(cipher_text, rsa_private_key=None):
cipher_decoded = base64.b16decode(hex_fixed.upper())
message = cipher.decrypt(cipher_decoded, b'error').decode()
return message
def check_different_city_login(user, request):
ip = get_request_ip(request) or '0.0.0.0'
if not (ip and validate_ip(ip)):
city = DEFAULT_CITY
else:
city = get_ip_city(ip) or DEFAULT_CITY
last_user_login = UserLoginLog.objects.filter(username=user.username, status=True).first()
if last_user_login and last_user_login.city != city:
DifferentCityLoginMessage(user, ip, city).publish_async()

View File

@@ -1,10 +1,6 @@
import urllib
from django.http.response import HttpResponseRedirect
from django.utils.decorators import method_decorator
from django.utils.translation import ugettext_lazy as _
from django.views.decorators.cache import never_cache
from django.views.generic import TemplateView
from urllib.parse import urlencode
from django.views import View
from django.conf import settings
from django.http.request import HttpRequest
@@ -15,14 +11,14 @@ from rest_framework.exceptions import APIException
from users.views import UserVerifyPasswordView
from users.utils import is_auth_password_time_valid
from users.models import User
from common.utils import get_logger
from common.utils import get_logger, FlashMessageUtil
from common.utils.random import random_string
from common.utils.django import reverse, get_object_or_none
from common.message.backends.dingtalk import URL
from common.sdk.im.dingtalk import URL
from common.mixins.views import PermissionsMixin
from authentication import errors
from authentication.mixins import AuthMixin
from common.message.backends.dingtalk import DingTalk
from common.sdk.im.dingtalk import DingTalk
logger = get_logger(__file__)
@@ -39,7 +35,7 @@ class DingTalkQRMixin(PermissionsMixin, View):
msg = e.detail['errmsg']
except Exception:
msg = _('DingTalk Error, Please contact your system administrator')
return self.get_failed_reponse(
return self.get_failed_response(
'/',
_('DingTalk Error'),
msg
@@ -53,8 +49,8 @@ class DingTalkQRMixin(PermissionsMixin, View):
return True
def get_verify_state_failed_response(self, redirect_uri):
msg = _("You've been hacked")
return self.get_failed_reponse(redirect_uri, msg, msg)
msg = _("The system configuration is incorrect. Please contact your administrator")
return self.get_failed_response(redirect_uri, msg, msg)
def get_qr_url(self, redirect_uri):
state = random_string(16)
@@ -67,30 +63,32 @@ class DingTalkQRMixin(PermissionsMixin, View):
'state': state,
'redirect_uri': redirect_uri,
}
url = URL.QR_CONNECT + '?' + urllib.parse.urlencode(params)
url = URL.QR_CONNECT + '?' + urlencode(params)
return url
def get_success_reponse(self, redirect_url, title, msg):
ok_flash_msg_url = reverse('authentication:dingtalk-bind-success-flash-msg')
ok_flash_msg_url += '?' + urllib.parse.urlencode({
'redirect_url': redirect_url,
@staticmethod
def get_success_response(redirect_url, title, msg):
message_data = {
'title': title,
'msg': msg
})
return HttpResponseRedirect(ok_flash_msg_url)
'message': msg,
'interval': 5,
'redirect_url': redirect_url,
}
return FlashMessageUtil.gen_and_redirect_to(message_data)
def get_failed_reponse(self, redirect_url, title, msg):
failed_flash_msg_url = reverse('authentication:dingtalk-bind-failed-flash-msg')
failed_flash_msg_url += '?' + urllib.parse.urlencode({
'redirect_url': redirect_url,
@staticmethod
def get_failed_response(redirect_url, title, msg):
message_data = {
'title': title,
'msg': msg
})
return HttpResponseRedirect(failed_flash_msg_url)
'error': msg,
'interval': 5,
'redirect_url': redirect_url,
}
return FlashMessageUtil.gen_and_redirect_to(message_data)
def get_already_bound_response(self, redirect_url):
msg = _('DingTalk is already bound')
response = self.get_failed_reponse(redirect_url, msg, msg)
response = self.get_failed_response(redirect_url, msg, msg)
return response
@@ -103,11 +101,11 @@ class DingTalkQRBindView(DingTalkQRMixin, View):
if not is_auth_password_time_valid(request.session):
msg = _('Please verify your password first')
response = self.get_failed_reponse(redirect_url, msg, msg)
response = self.get_failed_response(redirect_url, msg, msg)
return response
redirect_uri = reverse('authentication:dingtalk-qr-bind-callback', kwargs={'user_id': user.id}, external=True)
redirect_uri += '?' + urllib.parse.urlencode({'redirect_url': redirect_url})
redirect_uri += '?' + urlencode({'redirect_url': redirect_url})
url = self.get_qr_url(redirect_uri)
return HttpResponseRedirect(url)
@@ -127,7 +125,7 @@ class DingTalkQRBindCallbackView(DingTalkQRMixin, View):
if user is None:
logger.error(f'DingTalkQR bind callback error, user_id invalid: user_id={user_id}')
msg = _('Invalid user_id')
response = self.get_failed_reponse(redirect_url, msg, msg)
response = self.get_failed_response(redirect_url, msg, msg)
return response
if user.dingtalk_id:
@@ -143,7 +141,7 @@ class DingTalkQRBindCallbackView(DingTalkQRMixin, View):
if not userid:
msg = _('DingTalk query user failed')
response = self.get_failed_reponse(redirect_url, msg, msg)
response = self.get_failed_response(redirect_url, msg, msg)
return response
try:
@@ -152,12 +150,12 @@ class DingTalkQRBindCallbackView(DingTalkQRMixin, View):
except IntegrityError as e:
if e.args[0] == 1062:
msg = _('The DingTalk is already bound to another user')
response = self.get_failed_reponse(redirect_url, msg, msg)
response = self.get_failed_response(redirect_url, msg, msg)
return response
raise e
msg = _('Binding DingTalk successfully')
response = self.get_success_reponse(redirect_url, msg, msg)
response = self.get_success_response(redirect_url, msg, msg)
return response
@@ -169,7 +167,7 @@ class DingTalkEnableStartView(UserVerifyPasswordView):
success_url = reverse('authentication:dingtalk-qr-bind')
success_url += '?' + urllib.parse.urlencode({
success_url += '?' + urlencode({
'redirect_url': redirect_url or referer
})
@@ -183,7 +181,7 @@ class DingTalkQRLoginView(DingTalkQRMixin, View):
redirect_url = request.GET.get('redirect_url')
redirect_uri = reverse('authentication:dingtalk-qr-login-callback', external=True)
redirect_uri += '?' + urllib.parse.urlencode({'redirect_url': redirect_url})
redirect_uri += '?' + urlencode({'redirect_url': redirect_url})
url = self.get_qr_url(redirect_uri)
return HttpResponseRedirect(url)
@@ -209,14 +207,14 @@ class DingTalkQRLoginCallbackView(AuthMixin, DingTalkQRMixin, View):
if not userid:
# 正常流程不会出这个错误hack 行为
msg = _('Failed to get user from DingTalk')
response = self.get_failed_reponse(login_url, title=msg, msg=msg)
response = self.get_failed_response(login_url, title=msg, msg=msg)
return response
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')
response = self.get_failed_reponse(login_url, title=title, msg=msg)
msg = _('Please login with a password and then bind the DingTalk')
response = self.get_failed_response(login_url, title=title, msg=msg)
return response
try:
@@ -224,43 +222,7 @@ class DingTalkQRLoginCallbackView(AuthMixin, DingTalkQRMixin, View):
except errors.AuthFailedError as e:
self.set_login_failed_mark()
msg = e.msg
response = self.get_failed_reponse(login_url, title=msg, msg=msg)
response = self.get_failed_response(login_url, title=msg, msg=msg)
return response
return self.redirect_to_guard_view()
@method_decorator(never_cache, name='dispatch')
class FlashDingTalkBindSucceedMsgView(TemplateView):
template_name = 'flash_message_standalone.html'
def get(self, request, *args, **kwargs):
title = request.GET.get('title')
msg = request.GET.get('msg')
context = {
'title': title or _('Binding DingTalk successfully'),
'messages': msg or _('Binding DingTalk successfully'),
'interval': 5,
'redirect_url': request.GET.get('redirect_url'),
'auto_redirect': True,
}
return self.render_to_response(context)
@method_decorator(never_cache, name='dispatch')
class FlashDingTalkBindFailedMsgView(TemplateView):
template_name = 'flash_message_standalone.html'
def get(self, request, *args, **kwargs):
title = request.GET.get('title')
msg = request.GET.get('msg')
context = {
'title': title or _('Binding DingTalk failed'),
'messages': msg or _('Binding DingTalk failed'),
'interval': 5,
'redirect_url': request.GET.get('redirect_url'),
'auto_redirect': True,
}
return self.render_to_response(context)

View File

@@ -1,10 +1,6 @@
import urllib
from django.http.response import HttpResponseRedirect, HttpResponse
from django.utils.decorators import method_decorator
from django.http.response import HttpResponseRedirect
from django.utils.translation import ugettext_lazy as _
from django.views.decorators.cache import never_cache
from django.views.generic import TemplateView
from urllib.parse import urlencode
from django.views import View
from django.conf import settings
from django.http.request import HttpRequest
@@ -15,11 +11,11 @@ from rest_framework.exceptions import APIException
from users.utils import is_auth_password_time_valid
from users.views import UserVerifyPasswordView
from users.models import User
from common.utils import get_logger
from common.utils import get_logger, FlashMessageUtil
from common.utils.random import random_string
from common.utils.django import reverse, get_object_or_none
from common.mixins.views import PermissionsMixin
from common.message.backends.feishu import FeiShu, URL
from common.sdk.im.feishu import FeiShu, URL
from authentication import errors
from authentication.mixins import AuthMixin
@@ -35,7 +31,7 @@ class FeiShuQRMixin(PermissionsMixin, View):
return super().dispatch(request, *args, **kwargs)
except APIException as e:
msg = str(e.detail)
return self.get_failed_reponse(
return self.get_failed_response(
'/',
_('FeiShu Error'),
msg
@@ -49,8 +45,8 @@ class FeiShuQRMixin(PermissionsMixin, View):
return True
def get_verify_state_failed_response(self, redirect_uri):
msg = _("You've been hacked")
return self.get_failed_reponse(redirect_uri, msg, msg)
msg = _("The system configuration is incorrect. Please contact your administrator")
return self.get_failed_response(redirect_uri, msg, msg)
def get_qr_url(self, redirect_uri):
state = random_string(16)
@@ -61,30 +57,32 @@ class FeiShuQRMixin(PermissionsMixin, View):
'state': state,
'redirect_uri': redirect_uri,
}
url = URL.AUTHEN + '?' + urllib.parse.urlencode(params)
url = URL.AUTHEN + '?' + urlencode(params)
return url
def get_success_reponse(self, redirect_url, title, msg):
ok_flash_msg_url = reverse('authentication:feishu-bind-success-flash-msg')
ok_flash_msg_url += '?' + urllib.parse.urlencode({
'redirect_url': redirect_url,
@staticmethod
def get_success_response(redirect_url, title, msg):
message_data = {
'title': title,
'msg': msg
})
return HttpResponseRedirect(ok_flash_msg_url)
'message': msg,
'interval': 5,
'redirect_url': redirect_url,
}
return FlashMessageUtil.gen_and_redirect_to(message_data)
def get_failed_reponse(self, redirect_url, title, msg):
failed_flash_msg_url = reverse('authentication:feishu-bind-failed-flash-msg')
failed_flash_msg_url += '?' + urllib.parse.urlencode({
'redirect_url': redirect_url,
@staticmethod
def get_failed_response(redirect_url, title, msg):
message_data = {
'title': title,
'msg': msg
})
return HttpResponseRedirect(failed_flash_msg_url)
'error': msg,
'interval': 5,
'redirect_url': redirect_url,
}
return FlashMessageUtil.gen_and_redirect_to(message_data)
def get_already_bound_response(self, redirect_url):
msg = _('FeiShu is already bound')
response = self.get_failed_reponse(redirect_url, msg, msg)
response = self.get_failed_response(redirect_url, msg, msg)
return response
@@ -97,11 +95,11 @@ class FeiShuQRBindView(FeiShuQRMixin, View):
if not is_auth_password_time_valid(request.session):
msg = _('Please verify your password first')
response = self.get_failed_reponse(redirect_url, msg, msg)
response = self.get_failed_response(redirect_url, msg, msg)
return response
redirect_uri = reverse('authentication:feishu-qr-bind-callback', external=True)
redirect_uri += '?' + urllib.parse.urlencode({'redirect_url': redirect_url})
redirect_uri += '?' + urlencode({'redirect_url': redirect_url})
url = self.get_qr_url(redirect_uri)
return HttpResponseRedirect(url)
@@ -131,7 +129,7 @@ class FeiShuQRBindCallbackView(FeiShuQRMixin, View):
if not user_id:
msg = _('FeiShu query user failed')
response = self.get_failed_reponse(redirect_url, msg, msg)
response = self.get_failed_response(redirect_url, msg, msg)
return response
try:
@@ -140,12 +138,12 @@ class FeiShuQRBindCallbackView(FeiShuQRMixin, View):
except IntegrityError as e:
if e.args[0] == 1062:
msg = _('The FeiShu is already bound to another user')
response = self.get_failed_reponse(redirect_url, msg, msg)
response = self.get_failed_response(redirect_url, msg, msg)
return response
raise e
msg = _('Binding FeiShu successfully')
response = self.get_success_reponse(redirect_url, msg, msg)
response = self.get_success_response(redirect_url, msg, msg)
return response
@@ -157,7 +155,7 @@ class FeiShuEnableStartView(UserVerifyPasswordView):
success_url = reverse('authentication:feishu-qr-bind')
success_url += '?' + urllib.parse.urlencode({
success_url += '?' + urlencode({
'redirect_url': redirect_url or referer
})
@@ -171,7 +169,7 @@ class FeiShuQRLoginView(FeiShuQRMixin, View):
redirect_url = request.GET.get('redirect_url')
redirect_uri = reverse('authentication:feishu-qr-login-callback', external=True)
redirect_uri += '?' + urllib.parse.urlencode({'redirect_url': redirect_url})
redirect_uri += '?' + urlencode({'redirect_url': redirect_url})
url = self.get_qr_url(redirect_uri)
return HttpResponseRedirect(url)
@@ -196,14 +194,14 @@ class FeiShuQRLoginCallbackView(AuthMixin, FeiShuQRMixin, View):
if not user_id:
# 正常流程不会出这个错误hack 行为
msg = _('Failed to get user from FeiShu')
response = self.get_failed_reponse(login_url, title=msg, msg=msg)
response = self.get_failed_response(login_url, title=msg, msg=msg)
return response
user = get_object_or_none(User, feishu_id=user_id)
if user is None:
title = _('FeiShu is not bound')
msg = _('Please login with a password and then bind the FeiShu')
response = self.get_failed_reponse(login_url, title=title, msg=msg)
response = self.get_failed_response(login_url, title=title, msg=msg)
return response
try:
@@ -211,43 +209,7 @@ class FeiShuQRLoginCallbackView(AuthMixin, FeiShuQRMixin, View):
except errors.AuthFailedError as e:
self.set_login_failed_mark()
msg = e.msg
response = self.get_failed_reponse(login_url, title=msg, msg=msg)
response = self.get_failed_response(login_url, title=msg, msg=msg)
return response
return self.redirect_to_guard_view()
@method_decorator(never_cache, name='dispatch')
class FlashFeiShuBindSucceedMsgView(TemplateView):
template_name = 'flash_message_standalone.html'
def get(self, request, *args, **kwargs):
title = request.GET.get('title')
msg = request.GET.get('msg')
context = {
'title': title or _('Binding FeiShu successfully'),
'messages': msg or _('Binding FeiShu successfully'),
'interval': 5,
'redirect_url': request.GET.get('redirect_url'),
'auto_redirect': True,
}
return self.render_to_response(context)
@method_decorator(never_cache, name='dispatch')
class FlashFeiShuBindFailedMsgView(TemplateView):
template_name = 'flash_message_standalone.html'
def get(self, request, *args, **kwargs):
title = request.GET.get('title')
msg = request.GET.get('msg')
context = {
'title': title or _('Binding FeiShu failed'),
'messages': msg or _('Binding FeiShu failed'),
'interval': 5,
'redirect_url': request.GET.get('redirect_url'),
'auto_redirect': True,
}
return self.render_to_response(context)

View File

@@ -5,10 +5,12 @@ from __future__ import unicode_literals
import os
import datetime
from django.templatetags.static import static
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,17 +20,15 @@ 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
__all__ = [
'UserLoginView', 'UserLogoutView',
'UserLoginGuardView', 'UserLoginWaitConfirmView',
@@ -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,17 @@ 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 ['direct']:
return None
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,20 +111,32 @@ class UserLoginView(mixins.AuthMixin, FormView):
self.request.session.delete_test_cookie()
try:
with atomic():
self.check_user_auth(decrypt_passwd=True)
self.check_user_auth(decrypt_passwd=True)
except errors.AuthFailedError as e:
form.add_error(None, e.msg)
self.set_login_failed_mark()
form_cls = get_user_login_form_cls(captcha=True)
new_form = form_cls(data=form.data)
new_form._errors = form.errors
context = self.get_context_data(form=new_form)
self.request.session.set_test_cookie()
return self.render_to_response(context)
except (errors.PasswdTooSimple, errors.PasswordRequireResetError, errors.PasswdNeedUpdate) as e:
except (
errors.PasswdTooSimple,
errors.PasswordRequireResetError,
errors.PasswdNeedUpdate,
errors.OTPBindRequiredError
) as e:
return redirect(e.url)
except (
errors.MFAFailedError,
errors.BlockMFAError,
errors.OTPCodeRequiredError,
errors.SMSCodeRequiredError,
errors.UserPhoneNotSet
) as e:
form.add_error('code', e.msg)
return super().form_invalid(form)
self.clear_rsa_key()
return self.redirect_to_guard_view()
@@ -136,30 +150,56 @@ class UserLoginView(mixins.AuthMixin, FormView):
self.request.session[RSA_PRIVATE_KEY] = None
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
@staticmethod
def get_support_auth_methods():
auth_methods = [
{
'name': 'OpenID',
'enabled': settings.AUTH_OPENID,
'url': reverse('authentication:openid:login'),
'logo': static('img/login_oidc_logo.png')
},
{
'name': 'CAS',
'enabled': settings.AUTH_CAS,
'url': reverse('authentication:cas:cas-login'),
'logo': static('img/login_cas_logo.png')
},
{
'name': _('WeCom'),
'enabled': settings.AUTH_WECOM,
'url': reverse('authentication:wecom-qr-login'),
'logo': static('img/login_wecom_logo.png')
},
{
'name': _('DingTalk'),
'enabled': settings.AUTH_DINGTALK,
'url': reverse('authentication:dingtalk-qr-login'),
'logo': static('img/login_dingtalk_logo.png')
},
{
'name': _('FeiShu'),
'enabled': settings.AUTH_FEISHU,
'url': reverse('authentication:feishu-qr-login'),
'logo': static('img/login_feishu_logo.png')
}
]
return [method for method in auth_methods if method['enabled']]
@staticmethod
def get_forgot_password_url():
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:
forgot_password_url = settings.FORGOT_PASSWORD_URL
return forgot_password_url
def get_context_data(self, **kwargs):
context = {
'demo_mode': os.environ.get("DEMO_MODE"),
'AUTH_OPENID': settings.AUTH_OPENID,
'AUTH_CAS': settings.AUTH_CAS,
'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
'auth_methods': self.get_support_auth_methods(),
'forgot_password_url': self.get_forgot_password_url(),
'methods': self.get_user_mfa_methods(),
}
kwargs.update(context)
return super().get_context_data(**kwargs)
@@ -224,8 +264,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 = ''
@@ -262,7 +304,7 @@ class UserLogoutView(TemplateView):
def get_context_data(self, **kwargs):
context = {
'title': _('Logout success'),
'messages': _('Logout success, return login page'),
'message': _('Logout success, return login page'),
'interval': 3,
'redirect_url': reverse('authentication:login'),
'auto_redirect': True,

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,23 @@ class UserLoginOtpView(mixins.AuthMixin, FormView):
redirect_field_name = 'next'
def form_valid(self, form):
otp_code = form.cleaned_data.get('otp_code')
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(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_exc()
return redirect_to_guard_view()
def get_context_data(self, **kwargs):
user = self.get_user_from_session()
methods = self.get_user_mfa_methods(user)
kwargs.update({'methods': methods})
return kwargs

View File

@@ -1,10 +1,6 @@
import urllib
from django.http.response import HttpResponseRedirect
from django.utils.decorators import method_decorator
from django.utils.translation import ugettext_lazy as _
from django.views.decorators.cache import never_cache
from django.views.generic import TemplateView
from urllib.parse import urlencode
from django.views import View
from django.conf import settings
from django.http.request import HttpRequest
@@ -15,11 +11,11 @@ from rest_framework.exceptions import APIException
from users.views import UserVerifyPasswordView
from users.utils import is_auth_password_time_valid
from users.models import User
from common.utils import get_logger
from common.utils import get_logger, FlashMessageUtil
from common.utils.random import random_string
from common.utils.django import reverse, get_object_or_none
from common.message.backends.wecom import URL
from common.message.backends.wecom import WeCom
from common.sdk.im.wecom import URL
from common.sdk.im.wecom import WeCom
from common.mixins.views import PermissionsMixin
from authentication import errors
from authentication.mixins import AuthMixin
@@ -39,7 +35,7 @@ class WeComQRMixin(PermissionsMixin, View):
msg = e.detail['errmsg']
except Exception:
msg = _('WeCom Error, Please contact your system administrator')
return self.get_failed_reponse(
return self.get_failed_response(
'/',
_('WeCom Error'),
msg
@@ -53,8 +49,8 @@ class WeComQRMixin(PermissionsMixin, View):
return True
def get_verify_state_failed_response(self, redirect_uri):
msg = _("You've been hacked")
return self.get_failed_reponse(redirect_uri, msg, msg)
msg = _("The system configuration is incorrect. Please contact your administrator")
return self.get_failed_response(redirect_uri, msg, msg)
def get_qr_url(self, redirect_uri):
state = random_string(16)
@@ -66,30 +62,32 @@ class WeComQRMixin(PermissionsMixin, View):
'state': state,
'redirect_uri': redirect_uri,
}
url = URL.QR_CONNECT + '?' + urllib.parse.urlencode(params)
url = URL.QR_CONNECT + '?' + urlencode(params)
return url
def get_success_reponse(self, redirect_url, title, msg):
ok_flash_msg_url = reverse('authentication:wecom-bind-success-flash-msg')
ok_flash_msg_url += '?' + urllib.parse.urlencode({
'redirect_url': redirect_url,
@staticmethod
def get_success_response(redirect_url, title, msg):
message_data = {
'title': title,
'msg': msg
})
return HttpResponseRedirect(ok_flash_msg_url)
'message': msg,
'interval': 5,
'redirect_url': redirect_url,
}
return FlashMessageUtil.gen_and_redirect_to(message_data)
def get_failed_reponse(self, redirect_url, title, msg):
failed_flash_msg_url = reverse('authentication:wecom-bind-failed-flash-msg')
failed_flash_msg_url += '?' + urllib.parse.urlencode({
'redirect_url': redirect_url,
@staticmethod
def get_failed_response(redirect_url, title, msg):
message_data = {
'title': title,
'msg': msg
})
return HttpResponseRedirect(failed_flash_msg_url)
'error': msg,
'interval': 5,
'redirect_url': redirect_url,
}
return FlashMessageUtil.gen_and_redirect_to(message_data)
def get_already_bound_response(self, redirect_url):
msg = _('WeCom is already bound')
response = self.get_failed_reponse(redirect_url, msg, msg)
response = self.get_failed_response(redirect_url, msg, msg)
return response
@@ -102,11 +100,11 @@ class WeComQRBindView(WeComQRMixin, View):
if not is_auth_password_time_valid(request.session):
msg = _('Please verify your password first')
response = self.get_failed_reponse(redirect_url, msg, msg)
response = self.get_failed_response(redirect_url, msg, msg)
return response
redirect_uri = reverse('authentication:wecom-qr-bind-callback', kwargs={'user_id': user.id}, external=True)
redirect_uri += '?' + urllib.parse.urlencode({'redirect_url': redirect_url})
redirect_uri += '?' + urlencode({'redirect_url': redirect_url})
url = self.get_qr_url(redirect_uri)
return HttpResponseRedirect(url)
@@ -126,7 +124,7 @@ class WeComQRBindCallbackView(WeComQRMixin, View):
if user is None:
logger.error(f'WeComQR bind callback error, user_id invalid: user_id={user_id}')
msg = _('Invalid user_id')
response = self.get_failed_reponse(redirect_url, msg, msg)
response = self.get_failed_response(redirect_url, msg, msg)
return response
if user.wecom_id:
@@ -141,7 +139,7 @@ class WeComQRBindCallbackView(WeComQRMixin, View):
wecom_userid, __ = wecom.get_user_id_by_code(code)
if not wecom_userid:
msg = _('WeCom query user failed')
response = self.get_failed_reponse(redirect_url, msg, msg)
response = self.get_failed_response(redirect_url, msg, msg)
return response
try:
@@ -150,27 +148,24 @@ class WeComQRBindCallbackView(WeComQRMixin, View):
except IntegrityError as e:
if e.args[0] == 1062:
msg = _('The WeCom is already bound to another user')
response = self.get_failed_reponse(redirect_url, msg, msg)
response = self.get_failed_response(redirect_url, msg, msg)
return response
raise e
msg = _('Binding WeCom successfully')
response = self.get_success_reponse(redirect_url, msg, msg)
response = self.get_success_response(redirect_url, msg, msg)
return response
class WeComEnableStartView(UserVerifyPasswordView):
def get_success_url(self):
referer = self.request.META.get('HTTP_REFERER')
redirect_url = self.request.GET.get("redirect_url")
success_url = reverse('authentication:wecom-qr-bind')
success_url += '?' + urllib.parse.urlencode({
success_url += '?' + urlencode({
'redirect_url': redirect_url or referer
})
return success_url
@@ -181,7 +176,7 @@ class WeComQRLoginView(WeComQRMixin, View):
redirect_url = request.GET.get('redirect_url')
redirect_uri = reverse('authentication:wecom-qr-login-callback', external=True)
redirect_uri += '?' + urllib.parse.urlencode({'redirect_url': redirect_url})
redirect_uri += '?' + urlencode({'redirect_url': redirect_url})
url = self.get_qr_url(redirect_uri)
return HttpResponseRedirect(url)
@@ -207,14 +202,14 @@ class WeComQRLoginCallbackView(AuthMixin, WeComQRMixin, View):
if not wecom_userid:
# 正常流程不会出这个错误hack 行为
msg = _('Failed to get user from WeCom')
response = self.get_failed_reponse(login_url, title=msg, msg=msg)
response = self.get_failed_response(login_url, title=msg, msg=msg)
return response
user = get_object_or_none(User, wecom_id=wecom_userid)
if user is None:
title = _('WeCom is not bound')
msg = _('Please login with a password and then bind the WeCom')
response = self.get_failed_reponse(login_url, title=title, msg=msg)
response = self.get_failed_response(login_url, title=title, msg=msg)
return response
try:
@@ -222,43 +217,7 @@ class WeComQRLoginCallbackView(AuthMixin, WeComQRMixin, View):
except errors.AuthFailedError as e:
self.set_login_failed_mark()
msg = e.msg
response = self.get_failed_reponse(login_url, title=msg, msg=msg)
response = self.get_failed_response(login_url, title=msg, msg=msg)
return response
return self.redirect_to_guard_view()
@method_decorator(never_cache, name='dispatch')
class FlashWeComBindSucceedMsgView(TemplateView):
template_name = 'flash_message_standalone.html'
def get(self, request, *args, **kwargs):
title = request.GET.get('title')
msg = request.GET.get('msg')
context = {
'title': title or _('Binding WeCom successfully'),
'messages': msg or _('Binding WeCom successfully'),
'interval': 5,
'redirect_url': request.GET.get('redirect_url'),
'auto_redirect': True,
}
return self.render_to_response(context)
@method_decorator(never_cache, name='dispatch')
class FlashWeComBindFailedMsgView(TemplateView):
template_name = 'flash_message_standalone.html'
def get(self, request, *args, **kwargs):
title = request.GET.get('title')
msg = request.GET.get('msg')
context = {
'title': title or _('Binding WeCom failed'),
'messages': msg or _('Binding WeCom failed'),
'interval': 5,
'redirect_url': request.GET.get('redirect_url'),
'auto_redirect': True,
}
return self.render_to_response(context)

View File

@@ -10,5 +10,6 @@ class CommonConfig(AppConfig):
def ready(self):
from . import signals_handlers
from .signals import django_ready
if 'migrate' not in sys.argv:
django_ready.send(CommonConfig)
if 'migrate' in sys.argv or 'compilemessages' in sys.argv:
return
django_ready.send(CommonConfig)

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

@@ -2,19 +2,10 @@ from rest_framework.viewsets import GenericViewSet, ModelViewSet, ReadOnlyModelV
from rest_framework_bulk import BulkModelViewSet
from ..mixins.api import (
SerializerMixin, QuerySetMixin, ExtraFilterFieldsMixin, PaginatedResponseMixin,
RelationMixin, AllowBulkDestroyMixin, RenderToJsonMixin,
RelationMixin, AllowBulkDestroyMixin, CommonMixin
)
class CommonMixin(SerializerMixin,
QuerySetMixin,
ExtraFilterFieldsMixin,
PaginatedResponseMixin,
RenderToJsonMixin):
pass
class JMSGenericViewSet(CommonMixin, GenericViewSet):
pass

View File

@@ -4,6 +4,7 @@ from __future__ import unicode_literals
from collections import OrderedDict
import datetime
from itertools import chain
from django.core.exceptions import PermissionDenied
from django.http import Http404
@@ -101,7 +102,13 @@ class SimpleMetadataWithFilters(SimpleMetadata):
elif hasattr(view, 'get_filterset_fields'):
fields = view.get_filterset_fields(request)
elif hasattr(view, 'filterset_class'):
fields = view.filterset_class.Meta.fields
fields = list(view.filterset_class.Meta.fields) + \
list(view.filterset_class.declared_filters.keys())
if hasattr(view, 'custom_filter_fields'):
# 不能写 fields += view.custom_filter_fields
# 会改变 view 的 filter_fields
fields = list(fields) + list(view.custom_filter_fields)
if isinstance(fields, dict):
fields = list(fields.keys())

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

@@ -1,346 +0,0 @@
# -*- coding: utf-8 -*-
#
import time
from hashlib import md5
from threading import Thread
from collections import defaultdict
from itertools import chain
from django.conf import settings
from django.db.models.signals import m2m_changed
from django.core.cache import cache
from django.http import JsonResponse
from django.utils.translation import ugettext as _
from django.contrib.auth import get_user_model
from rest_framework.response import Response
from rest_framework.settings import api_settings
from rest_framework.decorators import action
from rest_framework.request import Request
from common.const.http import POST
from common.drf.filters import IDSpmFilter, CustomFilter, IDInFilter
from ..utils import lazyproperty
__all__ = [
'JSONResponseMixin', 'CommonApiMixin', 'AsyncApiMixin', 'RelationMixin',
'QuerySetMixin', 'ExtraFilterFieldsMixin', 'RenderToJsonMixin',
'SerializerMixin', 'AllowBulkDestroyMixin', 'PaginatedResponseMixin'
]
UserModel = get_user_model()
class JSONResponseMixin(object):
"""JSON mixin"""
@staticmethod
def render_json_response(context):
return JsonResponse(context)
# SerializerMixin
# ----------------------
class RenderToJsonMixin:
@action(methods=[POST], detail=False, url_path='render-to-json')
def render_to_json(self, request: Request):
data = {
'title': (),
'data': request.data,
}
jms_context = getattr(request, 'jms_context', {})
column_title_field_pairs = jms_context.get('column_title_field_pairs', ())
data['title'] = column_title_field_pairs
if isinstance(request.data, (list, tuple)) and not any(request.data):
error = _("Request file format may be wrong")
return Response(data={"error": error}, status=400)
return Response(data=data)
class SerializerMixin:
""" 根据用户请求动作的不同,获取不同的 `serializer_class `"""
action: str
request: Request
serializer_classes = None
single_actions = ['put', 'retrieve', 'patch']
def get_serializer_class_by_view_action(self):
if not hasattr(self, 'serializer_classes'):
return None
if not isinstance(self.serializer_classes, dict):
return None
view_action = self.request.query_params.get('action') or self.action or 'list'
serializer_class = self.serializer_classes.get(view_action)
if serializer_class is None:
view_method = self.request.method.lower()
serializer_class = self.serializer_classes.get(view_method)
if serializer_class is None and view_action in self.single_actions:
serializer_class = self.serializer_classes.get('single')
if serializer_class is None:
serializer_class = self.serializer_classes.get('display')
if serializer_class is None:
serializer_class = self.serializer_classes.get('default')
return serializer_class
def get_serializer_class(self):
serializer_class = self.get_serializer_class_by_view_action()
if serializer_class is None:
serializer_class = super().get_serializer_class()
return serializer_class
class ExtraFilterFieldsMixin:
"""
额外的 api filter
"""
default_added_filters = [CustomFilter, IDSpmFilter, IDInFilter]
filter_backends = api_settings.DEFAULT_FILTER_BACKENDS
extra_filter_fields = []
extra_filter_backends = []
def get_filter_backends(self):
if self.filter_backends != self.__class__.filter_backends:
return self.filter_backends
backends = list(chain(
self.filter_backends,
self.default_added_filters,
self.extra_filter_backends))
return backends
def filter_queryset(self, queryset):
for backend in self.get_filter_backends():
queryset = backend().filter_queryset(self.request, queryset, self)
return queryset
class PaginatedResponseMixin:
def get_paginated_response_with_query_set(self, queryset):
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
class CommonApiMixin(SerializerMixin, ExtraFilterFieldsMixin, RenderToJsonMixin):
pass
class InterceptMixin:
"""
Hack默认的dispatch, 让用户可以实现 self.do
"""
def dispatch(self, request, *args, **kwargs):
self.args = args
self.kwargs = kwargs
request = self.initialize_request(request, *args, **kwargs)
self.request = request
self.headers = self.default_response_headers # deprecate?
try:
self.initial(request, *args, **kwargs)
# Get the appropriate handler method
if request.method.lower() in self.http_method_names:
handler = getattr(self, request.method.lower(),
self.http_method_not_allowed)
else:
handler = self.http_method_not_allowed
response = self.do(handler, request, *args, **kwargs)
except Exception as exc:
response = self.handle_exception(exc)
self.response = self.finalize_response(request, response, *args, **kwargs)
return self.response
class AsyncApiMixin(InterceptMixin):
def get_request_user_id(self):
user = self.request.user
if hasattr(user, 'id'):
return str(user.id)
return ''
@lazyproperty
def async_cache_key(self):
method = self.request.method
path = self.get_request_md5()
user = self.get_request_user_id()
key = '{}_{}_{}'.format(method, path, user)
return key
def get_request_md5(self):
path = self.request.path
query = {k: v for k, v in self.request.GET.items()}
query.pop("_", None)
query.pop('refresh', None)
query = "&".join(["{}={}".format(k, v) for k, v in query.items()])
full_path = "{}?{}".format(path, query)
return md5(full_path.encode()).hexdigest()
@lazyproperty
def initial_data(self):
data = {
"status": "running",
"start_time": time.time(),
"key": self.async_cache_key,
}
return data
def get_cache_data(self):
key = self.async_cache_key
if self.is_need_refresh():
cache.delete(key)
return None
data = cache.get(key)
return data
def do(self, handler, *args, **kwargs):
if not self.is_need_async():
return handler(*args, **kwargs)
resp = self.do_async(handler, *args, **kwargs)
return resp
def is_need_refresh(self):
if self.request.GET.get("refresh"):
return True
return False
def is_need_async(self):
return False
def do_async(self, handler, *args, **kwargs):
data = self.get_cache_data()
if not data:
t = Thread(
target=self.do_in_thread,
args=(handler, *args),
kwargs=kwargs
)
t.start()
resp = Response(self.initial_data)
return resp
status = data.get("status")
resp = data.get("resp")
if status == "ok" and resp:
resp = Response(**resp)
else:
resp = Response(data)
return resp
def do_in_thread(self, handler, *args, **kwargs):
key = self.async_cache_key
data = self.initial_data
cache.set(key, data, 600)
try:
response = handler(*args, **kwargs)
data["status"] = "ok"
data["resp"] = {
"data": response.data,
"status": response.status_code
}
cache.set(key, data, 600)
except Exception as e:
data["error"] = str(e)
data["status"] = "error"
cache.set(key, data, 600)
class RelationMixin:
m2m_field = None
from_field = None
to_field = None
to_model = None
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
assert self.m2m_field is not None, '''
`m2m_field` should not be `None`
'''
self.from_field = self.m2m_field.m2m_field_name()
self.to_field = self.m2m_field.m2m_reverse_field_name()
self.to_model = self.m2m_field.related_model
self.through = getattr(self.m2m_field.model, self.m2m_field.attname).through
def get_queryset(self):
# 注意,此处拦截了 `get_queryset` 没有 `super`
queryset = self.through.objects.all()
return queryset
def send_m2m_changed_signal(self, instances, action):
if not isinstance(instances, list):
instances = [instances]
from_to_mapper = defaultdict(list)
for i in instances:
to_id = getattr(i, self.to_field).id
# TODO 优化,不应该每次都查询数据库
from_obj = getattr(i, self.from_field)
from_to_mapper[from_obj].append(to_id)
for from_obj, to_ids in from_to_mapper.items():
m2m_changed.send(
sender=self.through, instance=from_obj, action=action,
reverse=False, model=self.to_model, pk_set=to_ids
)
def perform_create(self, serializer):
instance = serializer.save()
self.send_m2m_changed_signal(instance, 'post_add')
def perform_destroy(self, instance):
instance.delete()
self.send_m2m_changed_signal(instance, 'post_remove')
class QuerySetMixin:
def get_queryset(self):
queryset = super().get_queryset()
serializer_class = self.get_serializer_class()
if serializer_class and hasattr(serializer_class, 'setup_eager_loading'):
queryset = serializer_class.setup_eager_loading(queryset)
return queryset
class AllowBulkDestroyMixin:
def allow_bulk_destroy(self, qs, filtered):
"""
我们规定,批量删除的情况必须用 `id` 指定要删除的数据。
"""
query = str(filtered.query)
return '`id` IN (' in query or '`id` =' in query
class RoleAdminMixin:
kwargs: dict
user_id_url_kwarg = 'pk'
@lazyproperty
def user(self):
user_id = self.kwargs.get(self.user_id_url_kwarg)
return UserModel.objects.get(id=user_id)
class RoleUserMixin:
request: Request
@lazyproperty
def user(self):
return self.request.user

View File

@@ -0,0 +1,7 @@
from .common import *
from .action import *
from .patch import *
from .filter import *
from .permission import *
from .queryset import *
from .serializer import *

View File

@@ -0,0 +1,55 @@
# -*- coding: utf-8 -*-
#
from typing import Callable
from django.utils.translation import ugettext as _
from rest_framework.response import Response
from rest_framework.decorators import action
from rest_framework.request import Request
from common.const.http import POST
from common.permissions import IsValidUser
__all__ = ['SuggestionMixin', 'RenderToJsonMixin']
class SuggestionMixin:
suggestion_limit = 10
filter_queryset: Callable
get_queryset: Callable
paginate_queryset: Callable
get_serializer: Callable
get_paginated_response: Callable
@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_limit]
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
class RenderToJsonMixin:
@action(methods=[POST], detail=False, url_path='render-to-json')
def render_to_json(self, request: Request):
data = {
'title': (),
'data': request.data,
}
jms_context = getattr(request, 'jms_context', {})
column_title_field_pairs = jms_context.get('column_title_field_pairs', ())
data['title'] = column_title_field_pairs
if isinstance(request.data, (list, tuple)) and not any(request.data):
error = _("Request file format may be wrong")
return Response(data={"error": error}, status=400)
return Response(data=data)

View File

@@ -0,0 +1,96 @@
# -*- coding: utf-8 -*-
#
from typing import Callable
from rest_framework.response import Response
from collections import defaultdict
from django.db.models.signals import m2m_changed
from .serializer import SerializerMixin
from .filter import ExtraFilterFieldsMixin
from .action import RenderToJsonMixin
from .queryset import QuerySetMixin
__all__ = [
'CommonApiMixin', 'PaginatedResponseMixin', 'RelationMixin', 'CommonMixin'
]
class PaginatedResponseMixin:
paginate_queryset: Callable
get_serializer: Callable
get_paginated_response: Callable
def get_paginated_response_from_queryset(self, queryset):
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
class RelationMixin:
m2m_field = None
from_field = None
to_field = None
to_model = None
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
assert self.m2m_field is not None, '''
`m2m_field` should not be `None`
'''
self.from_field = self.m2m_field.m2m_field_name()
self.to_field = self.m2m_field.m2m_reverse_field_name()
self.to_model = self.m2m_field.related_model
self.through = getattr(self.m2m_field.model, self.m2m_field.attname).through
def get_queryset(self):
# 注意,此处拦截了 `get_queryset` 没有 `super`
queryset = self.through.objects.all()
return queryset
def send_m2m_changed_signal(self, instances, action):
if not isinstance(instances, list):
instances = [instances]
from_to_mapper = defaultdict(list)
for i in instances:
to_id = getattr(i, self.to_field).id
# TODO 优化,不应该每次都查询数据库
from_obj = getattr(i, self.from_field)
from_to_mapper[from_obj].append(to_id)
for from_obj, to_ids in from_to_mapper.items():
m2m_changed.send(
sender=self.through, instance=from_obj, action=action,
reverse=False, model=self.to_model, pk_set=to_ids
)
def perform_create(self, serializer):
instance = serializer.save()
self.send_m2m_changed_signal(instance, 'post_add')
def perform_destroy(self, instance):
instance.delete()
self.send_m2m_changed_signal(instance, 'post_remove')
class CommonApiMixin(SerializerMixin, ExtraFilterFieldsMixin, RenderToJsonMixin):
pass
class CommonMixin(SerializerMixin,
QuerySetMixin,
ExtraFilterFieldsMixin,
RenderToJsonMixin):
pass

View File

@@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
#
from itertools import chain
from rest_framework.settings import api_settings
from common.drf.filters import IDSpmFilter, CustomFilter, IDInFilter
__all__ = ['ExtraFilterFieldsMixin']
class ExtraFilterFieldsMixin:
"""
额外的 api filter
"""
default_added_filters = [CustomFilter, IDSpmFilter, IDInFilter]
filter_backends = api_settings.DEFAULT_FILTER_BACKENDS
extra_filter_fields = []
extra_filter_backends = []
def get_filter_backends(self):
if self.filter_backends != self.__class__.filter_backends:
return self.filter_backends
backends = list(chain(
self.filter_backends,
self.default_added_filters,
self.extra_filter_backends
))
return backends
def filter_queryset(self, queryset):
for backend in self.get_filter_backends():
queryset = backend().filter_queryset(self.request, queryset, self)
return queryset

View File

@@ -0,0 +1,136 @@
# -*- coding: utf-8 -*-
#
import time
from hashlib import md5
from threading import Thread
from django.core.cache import cache
from rest_framework.response import Response
from common.utils import lazyproperty
__all__ = ['InterceptMixin', 'AsyncApiMixin']
class InterceptMixin:
"""
Hack默认的dispatch, 让用户可以实现 self.do
"""
def dispatch(self, request, *args, **kwargs):
self.args = args
self.kwargs = kwargs
request = self.initialize_request(request, *args, **kwargs)
self.request = request
self.headers = self.default_response_headers # deprecate?
try:
self.initial(request, *args, **kwargs)
# Get the appropriate handler method
if request.method.lower() in self.http_method_names:
handler = getattr(self, request.method.lower(),
self.http_method_not_allowed)
else:
handler = self.http_method_not_allowed
response = self.do(handler, request, *args, **kwargs)
except Exception as exc:
response = self.handle_exception(exc)
self.response = self.finalize_response(request, response, *args, **kwargs)
return self.response
class AsyncApiMixin(InterceptMixin):
def get_request_user_id(self):
user = self.request.user
if hasattr(user, 'id'):
return str(user.id)
return ''
@lazyproperty
def async_cache_key(self):
method = self.request.method
path = self.get_request_md5()
user = self.get_request_user_id()
key = '{}_{}_{}'.format(method, path, user)
return key
def get_request_md5(self):
path = self.request.path
query = {k: v for k, v in self.request.GET.items()}
query.pop("_", None)
query.pop('refresh', None)
query = "&".join(["{}={}".format(k, v) for k, v in query.items()])
full_path = "{}?{}".format(path, query)
return md5(full_path.encode()).hexdigest()
@lazyproperty
def initial_data(self):
data = {
"status": "running",
"start_time": time.time(),
"key": self.async_cache_key,
}
return data
def get_cache_data(self):
key = self.async_cache_key
if self.is_need_refresh():
cache.delete(key)
return None
data = cache.get(key)
return data
def do(self, handler, *args, **kwargs):
if not self.is_need_async():
return handler(*args, **kwargs)
resp = self.do_async(handler, *args, **kwargs)
return resp
def is_need_refresh(self):
if self.request.GET.get("refresh"):
return True
return False
def is_need_async(self):
return False
def do_async(self, handler, *args, **kwargs):
data = self.get_cache_data()
if not data:
t = Thread(
target=self.do_in_thread,
args=(handler, *args),
kwargs=kwargs
)
t.start()
resp = Response(self.initial_data)
return resp
status = data.get("status")
resp = data.get("resp")
if status == "ok" and resp:
resp = Response(**resp)
else:
resp = Response(data)
return resp
def do_in_thread(self, handler, *args, **kwargs):
key = self.async_cache_key
data = self.initial_data
cache.set(key, data, 600)
try:
response = handler(*args, **kwargs)
data["status"] = "ok"
data["resp"] = {
"data": response.data,
"status": response.status_code
}
cache.set(key, data, 600)
except Exception as e:
data["error"] = str(e)
data["status"] = "error"
cache.set(key, data, 600)

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