Compare commits

...

168 Commits
v3.6 ... v3.7

Author SHA1 Message Date
ibuler
3b30e329ab fix: 修改可能迁移的问题 2023-11-01 03:11:20 -05:00
ibuler
3dc853c7f2 perf: 优化选择应用发布机 2023-10-30 16:15:37 +08:00
fit2bot
932aed97d3 fix: 账号批量更新失败 (#11786)
Co-authored-by: feng <1304903146@qq.com>
2023-10-10 17:24:30 +08:00
feng
c464e95a21 fix: 修复账号批量更新失败问题 2023-10-09 10:05:20 +08:00
jiangweidong
0f0af19d49 perf: 更新jms-storage版本 2023-09-28 18:08:04 +05:00
feng
6a54ff8714 fix: 账号授权过滤指定账号api 失效问题 2023-09-27 13:46:35 +05:00
feng
1e0489bb96 fix: 账号授权过滤指定账号api 失效问题 2023-09-27 13:46:35 +05:00
ibuler
980ddcd833 perf: 优化发送邮件 2023-09-27 08:26:48 +05:00
feng
1ec2cd6087 perf: 账号模版 生成随机密码密钥及账号批量更新500 2023-09-26 12:55:29 +08:00
Bai
257ef464f7 fix: 修复未生成的迁移文件 2023-09-26 12:01:45 +08:00
ibuler
9cd3cc1da0 fix: pubkey auth require svc sign 2023-09-25 23:30:46 +08:00
ibuler
9520a23f4c fix: 修复暴力校验验证码 2023-09-25 23:04:53 +08:00
fit2bot
a4ff9dace3 fix: 修复用户username 中文 登录失败问题 (#11693)
Co-authored-by: feng <1304903146@qq.com>
2023-09-25 21:38:56 +08:00
jiangweidong
7a1214f358 perf: 优化找回密码时区号带加号无法匹配的问题 2023-09-25 16:41:28 +08:00
Bai
2fdc4c613f fix: 修复系统用户同步同时包含pwd/ssh-key导致创建账号id冲突报错的问题 2023-09-25 16:23:22 +08:00
吴小白
df6525933a perf: 添加 ping 命令 2023-09-25 10:50:14 +08:00
吴小白
6aef27c824 perf: 添加 patch 命令 2023-09-22 15:20:25 +08:00
Bai
26c3409d84 fix: 解决节点资产数量方法计算不准确的问题 2023-09-22 15:18:44 +08:00
Bryan
3c54c82ce9 Merge pull request #11636 from jumpserver/dev
v3.7.0
2023-09-21 17:02:48 +08:00
fit2bot
91dce82b38 fix: 安全设置开启仅已存在用户登录,企业微信等扫描登录,如果用户不存在,还是会自动创建用户登录成功。 (#11651)
Co-authored-by: feng <1304903146@qq.com>
2023-09-21 17:01:03 +08:00
Bryan
d102db7a7b Merge pull request #11650 from jumpserver/pr@dev@dev_master
fix: 解决 master 冲突
2023-09-21 16:53:09 +08:00
Bai
1de7af4984 fix: 解决 master 冲突 2023-09-21 16:51:54 +08:00
Aaron3S
9892ff7dd6 feat: 代码片段支持 oracle 和 mariadb 2023-09-21 16:37:16 +08:00
老广
4cb499953c Revert "perf: 修复事务中任务执行"
This reverts commit cdbe5d31e9.
2023-09-21 15:40:39 +08:00
老广
0397bdeb46 Revert "perf: 修复 task id 不对的问题"
This reverts commit 1d6d92c160.
2023-09-21 15:39:29 +08:00
ibuler
1d6d92c160 perf: 修复 task id 不对的问题 2023-09-21 15:20:16 +08:00
ibuler
cdbe5d31e9 perf: 修复事务中任务执行 2023-09-21 15:04:58 +08:00
fit2bot
b023ca0c69 fix: saml 用户没现在记录 (#11641)
Co-authored-by: feng <1304903146@qq.com>
2023-09-21 14:02:09 +08:00
ibuler
803d590096 perf: 修改生成 applet accounts 2023-09-21 13:06:02 +08:00
ibuler
e11367088a perf: 修改 acl 登录限制问题 2023-09-21 11:33:28 +08:00
jiangweidong
1c74dd00ba fix: 解决sqlserver无法推送和改密的问题 (#11637) 2023-09-20 21:45:21 +08:00
Aaron3S
ed832af631 fix: 修复运行job 组织切换问题 2023-09-20 18:26:47 +08:00
fit2bot
948c499d9e fix: 修复仪表板图表时间范围不准 (#11633)
Co-authored-by: feng <1304903146@qq.com>
2023-09-20 17:41:35 +08:00
fit2bot
a51549cf1c perf: ansible任务 未激活的时候关闭定时任务 (#11631)
Co-authored-by: feng <1304903146@qq.com>
2023-09-20 15:30:29 +08:00
fit2bot
39baf88055 fix: ansible postgresql (#11629)
Co-authored-by: feng <1304903146@qq.com>
2023-09-20 14:29:53 +08:00
fit2bot
90131db55a perf: 修改任务检查 (#11609)
* perf: 修改任务检查

* perf: 修改翻译

---------

Co-authored-by: ibuler <ibuler@qq.com>
2023-09-20 11:12:15 +08:00
“huailei000”
ea3ff1ebcb perf: 优化登录页面移动端布局 2023-09-19 20:20:28 +08:00
Aaron3S
f3ca45aa74 perf: 优化 Playbook 文件创建逻辑 2023-09-19 18:49:16 +08:00
老广
74cc174d7a Merge pull request #11622 from jumpserver/pr@dev@perf_random_error
fix: 修复 random error
2023-09-19 18:15:50 +08:00
ibuler
0eba6d2175 fix: 修复 random error 2023-09-19 18:11:27 +08:00
fit2bot
58592a13e3 fix: 解锁ip失败问题 (#11611)
Co-authored-by: feng <1304903146@qq.com>
2023-09-19 17:38:46 +08:00
fit2bot
b8fb23a0a0 perf: user setting (#11610)
Co-authored-by: feng <1304903146@qq.com>
2023-09-19 16:30:48 +08:00
Bai
f5c43488fd perf: 优化 es host 中包含 # 字符时提示错误 2023-09-19 15:31:02 +08:00
Eric
19c76ba01c perf: 删除发布机执行的任务目录 2023-09-19 15:02:02 +08:00
Eric
68c4cd5928 perf: 修复发布机安装应用的报错 2023-09-19 15:01:37 +08:00
fit2bot
e5bfa29c7b fix: 创建用户推送失败问题 (#11606)
Co-authored-by: feng <1304903146@qq.com>
2023-09-19 14:53:43 +08:00
fit2bot
cbb772def7 fix: 修复connection token 获取user错误 (#11603)
Co-authored-by: feng <1304903146@qq.com>
2023-09-19 11:09:58 +08:00
fit2bot
e6fe7c489e perf: 修改账号生成 (#11591)
* perf: 修改账号生成

* perf: 修改账号模版支持策略

* perf: 修改特殊字符数量

* perf: 修改 model 继承

* perf: 修改顺序

* perf: 修改 requirements

* perf: 修改翻译

* perf: 修改随机生成密码

* perf: 修改密钥生成

* perf: 修复 bug

---------

Co-authored-by: ibuler <ibuler@qq.com>
2023-09-19 10:59:33 +08:00
fit2bot
0b30f5cf88 perf: 翻译 (#11602)
Co-authored-by: feng <1304903146@qq.com>
2023-09-19 10:36:03 +08:00
fit2bot
018f1a0e8d perf: 删除管理用户错误提醒 (#11596)
Co-authored-by: feng <1304903146@qq.com>
2023-09-18 18:42:02 +08:00
fit2bot
24ed57b98a fix: 三方登录用户无法下线 (#11592)
Co-authored-by: feng <1304903146@qq.com>
2023-09-18 16:20:55 +08:00
ibuler
04a790c4ee perf: 优化 account template platform required 2023-09-18 14:29:41 +08:00
ibuler
2d9a3ef7d4 perf: 修改 migrations,不生成新的迁移文件 2023-09-18 14:29:24 +08:00
ibuler
0d2adeccf2 perf: 优化 applet account delete 2023-09-18 14:18:24 +08:00
Eric
886f977311 perf: 修复 chrome 部分元素定位失败的问题 2023-09-18 14:10:24 +08:00
fit2bot
9367e79bcf perf: 翻译 (#11583)
Co-authored-by: feng <1304903146@qq.com>
2023-09-18 11:11:12 +08:00
fit2bot
af733ecbad fix: 修改平台id序列化属性 改为非只读 (#11581)
Co-authored-by: feng <1304903146@qq.com>
2023-09-17 16:07:05 +08:00
fit2bot
09f9775eab fix: 平台无category type 过滤 (#11580)
Co-authored-by: feng <1304903146@qq.com>
2023-09-17 12:50:31 +08:00
feng
1c2a362beb perf: 修改usersession 模块位置 2023-09-15 17:25:06 +08:00
Eric_Lee
bb1e674367 Merge pull request #11578 from jumpserver/pr@dev@perf_host_deploy_log
perf: 修复发布机历史执行任务日志无法查看的问题
2023-09-15 17:18:45 +08:00
Eric
a75677ab08 perf: 修复发布机历史执行任务日志无法查看的问题 2023-09-15 17:15:12 +08:00
fit2bot
b1daa4d357 fix: 修改不常登录用户锁定逻辑 (#11576)
Co-authored-by: feng <1304903146@qq.com>
2023-09-15 16:39:49 +08:00
fit2bot
c32271ec6f fix: mysql 没配置ssl ansible 连接失败问题 (#11574)
Co-authored-by: feng <1304903146@qq.com>
2023-09-15 16:16:04 +08:00
Aaron3S
beb4f14be9 perf: 优化 jobexecution 创建 2023-09-15 14:39:16 +08:00
fit2bot
e719904874 fix: 修复工单回复报500 (#11571)
Co-authored-by: feng <1304903146@qq.com>
2023-09-15 11:26:13 +08:00
Eric_Lee
664bc2a4d9 Merge pull request #11568 from jumpserver/pr@dev@perf_deplay_task
perf: 优化推送部署任务,事务提交后再执行
2023-09-14 18:30:16 +08:00
ibuler
b91db8c146 perf: 优化推送部署任务,事务提交后再执行 2023-09-14 18:17:22 +08:00
fit2bot
500aeeb77f perf: 升级flower (#11567)
Co-authored-by: feng <1304903146@qq.com>
2023-09-14 18:15:25 +08:00
feng
3abc8bddfa feat: 用户在线session控制 2023-09-14 16:21:57 +08:00
老广
5cbbf9e737 Merge pull request #11561 from jumpserver/pr@dev@perf_i18n
perf: 优化翻译
2023-09-14 14:30:53 +08:00
ibuler
7204a86f87 perf: 优化翻译 2023-09-14 14:26:17 +08:00
老广
829194420a Merge pull request #11559 from jumpserver/pr@dev@limit_super_privilege
feat: 限制超级权限
2023-09-14 13:54:20 +08:00
老广
61dc95d9ae Merge pull request #11560 from jumpserver/pr@dev@perf_i18n
perf: 优化翻译
2023-09-14 11:29:28 +08:00
ibuler
a9f60a9117 perf: 优化翻译 2023-09-14 11:26:12 +08:00
ibuler
82f96d6ed2 feat: 限制超级权限 2023-09-14 10:42:16 +08:00
feng
f6c56d4979 perf: 网络设备 ansible enables true 2023-09-13 19:29:01 +08:00
老广
54d0a1b871 Merge pull request #11554 from jumpserver/pr@dev@perf_add_tip
perf: 添加tips
2023-09-13 17:44:45 +08:00
老广
5b4a267ccd Merge pull request #11553 from jumpserver/pr@dev@feat_support_ansbile_raw
feat: 作业中心支持 raw (网络设备使用)
2023-09-13 17:44:10 +08:00
ibuler
a6d78834e7 perf: 添加tips 2023-09-13 17:43:29 +08:00
Aaron3S
07da98e438 feat: 作业中心支持 raw (网络设备使用) 2023-09-13 17:25:42 +08:00
老广
7c973616cd Merge pull request #11552 from jumpserver/pr@dev@add_api_check_for_unauth
perf: 添加 check api,检测所有 api
2023-09-13 17:24:40 +08:00
ibuler
b9997b07db perf: 去掉不用的 backend 2023-09-13 17:22:50 +08:00
ibuler
bcda879f3b perf: 修改 ticket 认证的 2023-09-13 17:19:13 +08:00
ibuler
d0f79c2df2 perf: 添加 check api 避免未认证 2023-09-13 17:05:01 +08:00
ibuler
1249935bab perf: 优化设置项名称 2023-09-13 10:09:56 +08:00
ibuler
5fa1ae9ee5 perf: 修改说明 2023-09-12 15:59:25 +08:00
Bai
d0755c4719 fix: 修复系统任务支持通过 id、name 进行搜索 2023-09-12 15:35:11 +08:00
fit2bot
72b215ed03 feat: 支持 passkey 登录 (#11519)
* perf: 基本完成功能

* perf: 优化 passkey

* perf: 优化 passkey

* perf: 完成 passkey

---------

Co-authored-by: ibuler <ibuler@qq.com>
2023-09-11 18:15:03 +08:00
fit2bot
d7ca1a09d4 perf: connectiontoken 添加 connect_options file_name_conflict_resolution参数 给koko处理冲冲突文件名 (#11535)
Co-authored-by: feng <1304903146@qq.com>
2023-09-11 16:22:11 +08:00
fit2bot
04e341a1bb perf: 翻译 (#11534)
Co-authored-by: feng <1304903146@qq.com>
2023-09-11 15:26:50 +08:00
fit2bot
a41909ec8d feat: 个人设置 (#11494)
Co-authored-by: feng <1304903146@qq.com>
2023-09-11 14:38:07 +08:00
ibuler
f9d6de9c39 fix: 修复 private storage permission 2023-09-11 11:20:12 +08:00
halo
816b284a51 perf: 支持windows客户端msi格式 2023-09-11 11:15:17 +08:00
Eric
d4c5dcf069 perf: 修改变更时间 2023-09-07 19:30:59 +08:00
Eric
73037c21e8 perf: chrome 代填进度条最大 30s 超时 2023-09-07 19:30:59 +08:00
halo
c7f9259a2e perf: 更新客户端 v2.0.1 2023-09-07 19:30:25 +08:00
feng
8632bd2480 fix: 修复ip被锁定列表展示数据不准问题 2023-09-07 19:28:11 +08:00
ibuler
23723f4eda perf: 优化 ftp log 索引 2023-09-07 19:27:49 +08:00
Bai
38601a84c2 perf: 优化 GitHub Labels 2023-09-06 16:42:42 +08:00
fit2bot
e50189e284 fix: 修复工单审计员切换其他资产,原资产未删除问题 (#11511)
Co-authored-by: feng <1304903146@qq.com>
2023-09-06 15:13:02 +08:00
jiangweidong
da9bd11db5 feat: 系统工具支持traceroute (#11474) 2023-09-06 10:30:55 +08:00
Bai
9acb7d6183 perf: 优化 GitHub 默认 Assignees 2023-09-04 14:43:18 +05:00
Bai
dbd9a9fdac perf: 优化 GitHub 默认 Assignees 2023-09-04 12:03:16 +05:00
fit2bot
25301aa396 perf: 修改 sftp 的说明文案 (#11490)
* perf: 修改 sftp 的说明文案

* perf: 修改翻译问题

---------

Co-authored-by: ibuler <ibuler@qq.com>
2023-09-04 13:48:53 +08:00
老广
8cc1ca2770 Merge pull request #11483 from jumpserver/pr@dev@perf_db_cli
perf: 修改数据库 cli 连接方式的支持
2023-08-31 19:03:20 +08:00
Eric
bad01aefa2 perf: 修改数据库 cli 连接方式的支持 2023-08-31 18:23:30 +08:00
老广
56a989bfb9 Merge pull request #11481 from jumpserver/pr@dev@perf_online_num
perf: 修改在线数量
2023-08-31 17:44:25 +08:00
fit2bot
578f66d5e2 fix: 账号推送定时任务不执行 (#11482)
Co-authored-by: feng <1304903146@qq.com>
2023-08-31 17:43:52 +08:00
ibuler
8d6083bfb2 perf: 修改在线数量 2023-08-31 17:42:21 +08:00
fit2bot
1138cd3334 perf: 添加 session 在线数量 (#11464)
* perf: 添加 session 在线数量

* perf: 优化会话数量

* perf: 优化会话数量

---------

Co-authored-by: ibuler <ibuler@qq.com>
2023-08-31 17:00:03 +08:00
fit2bot
db0b43ee84 perf: 优化 dashboard api (#11478)
Co-authored-by: feng <1304903146@qq.com>
2023-08-31 15:05:05 +08:00
Bai
40a460870a fix: 优化 db_port 日志显示 2023-08-31 10:52:41 +05:00
fit2bot
51910ea2c1 fix: 修复历史会话为负数的情况 (#11472)
Co-authored-by: feng <1304903146@qq.com>
2023-08-30 18:18:08 +08:00
fit2bot
266a360a97 feat: 可查看全局被限制的ip 并且可以解锁 (#11394)
Co-authored-by: feng <1304903146@qq.com>
2023-08-30 17:31:59 +08:00
fit2bot
24194b4e4d perf: 翻译 (#11468)
Co-authored-by: feng <1304903146@qq.com>
2023-08-30 16:01:16 +08:00
fit2bot
992e34d652 feat: mysql 证书 (#11465)
Co-authored-by: feng <1304903146@qq.com>
2023-08-30 15:15:49 +08:00
老广
894249a3d1 Merge pull request #11452 from jumpserver/pr@dev@feat_audit_view_download_replay
feat: 查看/下载录像记录在操作及活动日志中
2023-08-30 13:48:53 +08:00
老广
21c6fe19a1 Merge pull request #11459 from jumpserver/pr@dev@metics
perf: dashboard date metrics
2023-08-30 13:26:31 +08:00
老广
e4e4f82143 Merge pull request #11461 from jumpserver/pr@dev@fix_cas_login_failed
fix: 解决CAS无法登陆问题
2023-08-30 13:25:30 +08:00
jiangweidong
2a5c635dc5 fix: 修改日志内容 2023-08-30 11:32:54 +08:00
jiangweidong
7dbaa28539 fix: 解决CAS无法登陆问题 2023-08-30 11:28:17 +08:00
feng
5bae4cde58 perf: dashboard date metrics 2023-08-29 22:04:08 +08:00
老广
35c0d7be35 Merge pull request #11455 from jumpserver/pr@dev@feat_settings_tool_ping_telnet_multi
feat: telnet、ping支持批量测试
2023-08-29 19:05:56 +08:00
jiangweidong
1f2a4b0fb5 feat: telnet、ping支持批量测试 2023-08-29 17:02:51 +08:00
jiangweidong
7c3a3d599b perf: 参数修改 2023-08-29 15:18:51 +08:00
jiangweidong
d70770775a perf: 翻译 2023-08-29 15:16:02 +08:00
jiangweidong
bc217e1bad Merge branch 'dev' of https://github.com/jumpserver/jumpserver into pr@dev@feat_audit_view_download_replay 2023-08-29 14:21:11 +08:00
jiangweidong
d4469aeaf7 feat: 查看/下载录像被记录在活动日志中 2023-08-29 14:21:06 +08:00
老广
904406c5c1 Merge pull request #11442 from jumpserver/pr@dev@fix_migrate_sftp
fix: 修复迁移的 sftp 数量不对
2023-08-28 19:03:28 +08:00
ibuler
09db2ad3e1 fix: 修复迁移的 sftp 数量不对 2023-08-28 16:48:22 +08:00
fit2bot
859268f7f3 perf: 优化账号创建 (#11440)
* feat: 支持账号模版自动推送
* perf: 修改模版
* perf: 优化账号创建

---------

Co-authored-by: ibuler <ibuler@qq.com>
2023-08-28 15:43:45 +08:00
老广
72bb5a4037 Merge pull request #11439 from jumpserver/pr@dev@change_tips
perf: 修改同名账号的提示
2023-08-28 11:25:55 +08:00
ibuler
6f3871d5fe perf: 修改同名账号的提示 2023-08-28 11:24:54 +08:00
“huailei000”
2f0c346365 perf: 优化不能生成MFA二维码问题 2023-08-25 12:01:51 +05:00
老广
e9c090f656 Merge pull request #11410 from hoilc/hoilc-patch-1
perf: 通过网域连接k8s时支持默认端口
2023-08-24 18:25:14 +08:00
老广
7b0b07cf52 Merge pull request #11415 from jumpserver/pr@dev@perf_select_host
perf: 优化 applet 发布机选择
2023-08-24 18:14:08 +08:00
ibuler
bebb90f688 perf: 优化 applet 发布机选择 2023-08-24 18:00:19 +08:00
hoilc
ac14a70c51 perf: 通过网域连接k8s时支持默认端口 2023-08-24 16:10:29 +08:00
jiangweidong
642f92c0a3 fix: saml2无法登陆问题 2023-08-24 11:05:50 +05:00
fit2bot
04f4ecb3d1 perf: 优化文案 (#11405)
Co-authored-by: ibuler <ibuler@qq.com>
2023-08-24 10:58:27 +08:00
老广
60703c920c Merge pull request #11381 from jumpserver/pr@dev@sqlserver_add_version
perf: sql server 添加驱动标识
2023-08-24 10:55:48 +08:00
ibuler
9634f397df perf: 不允许修改自己的角色 2023-08-23 16:11:05 +05:00
ibuler
f9a7a95191 fix: 修复 Host name 中包含 [ 导致 ansible 错误的问题 2023-08-23 16:07:58 +05:00
ibuler
bced33fd93 perf: sql server 添加驱动标识 2023-08-22 13:40:41 +08:00
老广
1044ff004b Merge pull request #11372 from jumpserver/pr@dev@device_add_sftp
perf: 网络设备支持 sftp
2023-08-21 15:40:30 +08:00
ibuler
e11c7a264e perf: 网络设备支持 sftp 2023-08-21 15:20:58 +08:00
老广
3c497aa81e Merge pull request #11361 from jumpserver/pr@dev@perf_login_csrf
perf: 修改 csrf 登录时判断
2023-08-18 20:44:25 +08:00
ibuler
c8a1f4b092 perf: 修改 csrf 登录时判断 2023-08-18 20:36:58 +08:00
老广
9dd2dc8907 Merge pull request #11358 from jumpserver/pr@dev@perf_csrf_token_error
perf: 修改 csrf token 提示
2023-08-18 18:42:50 +08:00
ibuler
56285d906f perf: 修改 csrf token 提示 2023-08-18 18:41:10 +08:00
ibuler
44b536a23b perf: 去掉 migrate 提示 2023-08-18 15:17:41 +05:00
老广
a97003a03a Merge pull request #11353 from jumpserver/pr@dev@perf_login_info
perf: 优化登录页面提示判断,可能没有端口
2023-08-18 18:00:26 +08:00
ibuler
4315cbe6d0 perf: 优化登录页面提示判断,可能没有端口
perf: 修改 login 检测
2023-08-18 17:59:13 +08:00
老广
b2d9670721 Merge pull request #11349 from jumpserver/pr@dev@perf_info
perf: 修改说明
2023-08-18 17:01:31 +08:00
ibuler
78f66c46e8 perf: 修改说明 2023-08-18 16:59:07 +08:00
老广
f3af9c3108 Merge pull request #11346 from jumpserver/pr@dev@fix_sessionshare
fix: 修复创建会话分享不填写用户报错的问题
2023-08-18 16:52:07 +08:00
ibuler
822a124dbc perf: 优化登录提示 2023-08-18 13:51:27 +05:00
Bai
20799ece93 fix: 修复创建会话分享不填写用户报错的问题 2023-08-18 08:46:14 +00:00
老广
4e2c7d7aab Merge pull request #11343 from jumpserver/pr@dev@allow_hosts_to_all
perf: 修改 allowed hosts
2023-08-18 16:17:25 +08:00
ibuler
75e4895314 perf: 修改 allowed hosts 2023-08-18 16:15:25 +08:00
Bai
ea7b409a7f fix: 修复资产树子节点创建后没有获取到的问题 2023-08-18 13:03:54 +05:00
老广
01d10a25e9 Merge pull request #11337 from jumpserver/pr@dev@perf_change_depends
perf: 修改依赖
2023-08-18 15:27:57 +08:00
ibuler
61ce39b4ba perf: 修改依赖 2023-08-18 15:19:26 +08:00
feng
7506c7ea43 fix: 修复密钥校验ansible不支持{% 2023-08-17 16:16:34 +05:00
老广
f6f162ec3a Merge pull request #11324 from jumpserver/pr@master@perf_django_ca_version
perf: 修改 django cas version
2023-08-17 17:43:23 +08:00
老广
2e840e3b05 Merge pull request #11323 from jumpserver/pr@dev@perf_django_ca_version
perf: 修改 django cas version
2023-08-17 17:43:04 +08:00
ibuler
ff4560c2a7 perf: 修改 django cas version 2023-08-17 09:42:27 +00:00
ibuler
deeb8da226 perf: 修改 django cas version 2023-08-17 17:39:58 +08:00
209 changed files with 5581 additions and 2148 deletions

View File

@@ -6,7 +6,6 @@ labels: 类型:需求
assignees:
- ibuler
- baijiangjie
- wojiushixiaobai
---
**请描述您的需求或者改进建议.**

View File

@@ -2,11 +2,9 @@
name: Bug 提交
about: 提交产品缺陷帮助我们更好的改进
title: "[Bug] "
labels: 类型:bug
labels: 类型:Bug
assignees:
- wojiushixiaobai
- baijiangjie
---
**JumpServer 版本( v2.28 之前的版本不再支持 )**

View File

@@ -4,9 +4,7 @@ about: 提出针对本项目安装部署、使用及其他方面的相关问题
title: "[Question] "
labels: 类型:提问
assignees:
- wojiushixiaobai
- baijiangjie
---
**请描述您的问题.**

View File

@@ -36,9 +36,11 @@ ARG TOOLS=" \
curl \
default-libmysqlclient-dev \
default-mysql-client \
iputils-ping \
locales \
nmap \
openssh-client \
patch \
sshpass \
telnet \
vim \

View File

@@ -6,11 +6,11 @@ from rest_framework.status import HTTP_200_OK
from accounts import serializers
from accounts.filters import AccountFilterSet
from accounts.mixins import AccountRecordViewLogMixin
from accounts.models import Account
from assets.models import Asset, Node
from common.api import ExtraFilterFieldsMixin
from common.api.mixin import ExtraFilterFieldsMixin
from common.permissions import UserConfirmation, ConfirmType, IsValidUser
from common.views.mixins import RecordViewLogMixin
from orgs.mixins.api import OrgBulkModelViewSet
from rbac.permissions import RBACPermission
@@ -57,19 +57,19 @@ class AccountViewSet(OrgBulkModelViewSet):
permission_classes=[IsValidUser]
)
def username_suggestions(self, request, *args, **kwargs):
asset_ids = request.data.get('assets')
node_ids = request.data.get('nodes')
username = request.data.get('username')
asset_ids = request.data.get('assets', [])
node_ids = request.data.get('nodes', [])
username = request.data.get('username', '')
assets = Asset.objects.all()
if asset_ids:
assets = assets.filter(id__in=asset_ids)
accounts = Account.objects.all()
if node_ids:
nodes = Node.objects.filter(id__in=node_ids)
node_asset_ids = Node.get_nodes_all_assets(*nodes).values_list('id', flat=True)
assets = assets.filter(id__in=set(list(asset_ids) + list(node_asset_ids)))
asset_ids.extend(node_asset_ids)
if asset_ids:
accounts = accounts.filter(asset_id__in=list(set(asset_ids)))
accounts = Account.objects.filter(asset__in=assets)
if username:
accounts = accounts.filter(username__icontains=username)
usernames = list(accounts.values_list('username', flat=True).distinct()[:10])
@@ -86,7 +86,7 @@ class AccountViewSet(OrgBulkModelViewSet):
return Response(status=HTTP_200_OK)
class AccountSecretsViewSet(RecordViewLogMixin, AccountViewSet):
class AccountSecretsViewSet(AccountRecordViewLogMixin, AccountViewSet):
"""
因为可能要导出所有账号,所以单独建立了一个 viewset
"""
@@ -115,7 +115,7 @@ class AssetAccountBulkCreateApi(CreateAPIView):
return Response(data=serializer.data, status=HTTP_200_OK)
class AccountHistoriesSecretAPI(ExtraFilterFieldsMixin, RecordViewLogMixin, ListAPIView):
class AccountHistoriesSecretAPI(ExtraFilterFieldsMixin, AccountRecordViewLogMixin, ListAPIView):
model = Account.history.model
serializer_class = serializers.AccountHistorySerializer
http_method_names = ['get', 'options']
@@ -143,4 +143,3 @@ class AccountHistoriesSecretAPI(ExtraFilterFieldsMixin, RecordViewLogMixin, List
return histories
histories = histories.exclude(history_id=latest_history.history_id)
return histories

View File

@@ -19,7 +19,9 @@ class AccountsTaskCreateAPI(CreateAPIView):
code = 'accounts.push_account'
else:
code = 'accounts.verify_account'
return request.user.has_perm(code)
has = request.user.has_perm(code)
if not has:
self.permission_denied(request)
def perform_create(self, serializer):
data = serializer.validated_data
@@ -44,6 +46,6 @@ class AccountsTaskCreateAPI(CreateAPIView):
def get_exception_handler(self):
def handler(e, context):
return Response({"error": str(e)}, status=400)
return Response({"error": str(e)}, status=401)
return handler

View File

@@ -4,10 +4,10 @@ from rest_framework.response import Response
from accounts import serializers
from accounts.models import AccountTemplate
from accounts.mixins import AccountRecordViewLogMixin
from assets.const import Protocol
from common.drf.filters import BaseFilterSet
from common.permissions import UserConfirmation, ConfirmType
from common.views.mixins import RecordViewLogMixin
from orgs.mixins.api import OrgBulkModelViewSet
from rbac.permissions import RBACPermission
@@ -55,7 +55,7 @@ class AccountTemplateViewSet(OrgBulkModelViewSet):
return Response(data=serializer.data)
class AccountTemplateSecretsViewSet(RecordViewLogMixin, AccountTemplateViewSet):
class AccountTemplateSecretsViewSet(AccountRecordViewLogMixin, AccountTemplateViewSet):
serializer_classes = {
'default': serializers.AccountTemplateSecretSerializer,
}

View File

@@ -11,9 +11,9 @@
login_host: "{{ jms_asset.address }}"
login_port: "{{ jms_asset.port }}"
login_database: "{{ jms_asset.spec_info.db_name }}"
ssl: "{{ jms_asset.spec_info.use_ssl }}"
ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert }}"
ssl_certfile: "{{ jms_asset.secret_info.client_key }}"
ssl: "{{ jms_asset.spec_info.use_ssl | default('') }}"
ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert | default('') }}"
ssl_certfile: "{{ jms_asset.secret_info.client_key | default('') }}"
connection_options:
- tlsAllowInvalidHostnames: "{{ jms_asset.spec_info.allow_invalid_cert}}"
register: db_info
@@ -31,8 +31,8 @@
login_port: "{{ jms_asset.port }}"
login_database: "{{ jms_asset.spec_info.db_name }}"
ssl: "{{ jms_asset.spec_info.use_ssl }}"
ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert }}"
ssl_certfile: "{{ jms_asset.secret_info.client_key }}"
ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert | default('') }}"
ssl_certfile: "{{ jms_asset.secret_info.client_key | default('') }}"
connection_options:
- tlsAllowInvalidHostnames: "{{ jms_asset.spec_info.allow_invalid_cert}}"
db: "{{ jms_asset.spec_info.db_name }}"
@@ -49,7 +49,7 @@
login_port: "{{ jms_asset.port }}"
login_database: "{{ jms_asset.spec_info.db_name }}"
ssl: "{{ jms_asset.spec_info.use_ssl }}"
ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert }}"
ssl_certfile: "{{ jms_asset.secret_info.client_key }}"
ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert | default('') }}"
ssl_certfile: "{{ jms_asset.secret_info.client_key | default('') }}"
connection_options:
- tlsAllowInvalidHostnames: "{{ jms_asset.spec_info.allow_invalid_cert}}"

View File

@@ -11,6 +11,10 @@
login_password: "{{ jms_account.secret }}"
login_host: "{{ jms_asset.address }}"
login_port: "{{ jms_asset.port }}"
check_hostname: "{{ jms_asset.spec_info.use_ssl and not jms_asset.spec_info.allow_invalid_cert }}"
ca_cert: "{{ jms_asset.secret_info.ca_cert | default(omit) }}"
client_cert: "{{ jms_asset.secret_info.client_cert | default(omit) }}"
client_key: "{{ jms_asset.secret_info.client_key | default(omit) }}"
filter: version
register: db_info
@@ -24,6 +28,10 @@
login_password: "{{ jms_account.secret }}"
login_host: "{{ jms_asset.address }}"
login_port: "{{ jms_asset.port }}"
check_hostname: "{{ jms_asset.spec_info.use_ssl and not jms_asset.spec_info.allow_invalid_cert }}"
ca_cert: "{{ jms_asset.secret_info.ca_cert | default(omit) }}"
client_cert: "{{ jms_asset.secret_info.client_cert | default(omit) }}"
client_key: "{{ jms_asset.secret_info.client_key | default(omit) }}"
name: "{{ account.username }}"
password: "{{ account.secret }}"
host: "%"
@@ -37,4 +45,8 @@
login_password: "{{ account.secret }}"
login_host: "{{ jms_asset.address }}"
login_port: "{{ jms_asset.port }}"
check_hostname: "{{ jms_asset.spec_info.use_ssl and not jms_asset.spec_info.allow_invalid_cert }}"
ca_cert: "{{ jms_asset.secret_info.ca_cert | default(omit) }}"
client_cert: "{{ jms_asset.secret_info.client_cert | default(omit) }}"
client_key: "{{ jms_asset.secret_info.client_key | default(omit) }}"
filter: version

View File

@@ -40,7 +40,7 @@
login_host: "{{ jms_asset.address }}"
login_port: "{{ jms_asset.port }}"
name: '{{ jms_asset.spec_info.db_name }}'
script: "ALTER LOGIN {{ account.username }} WITH PASSWORD = '{{ account.secret }}'; select @@version"
script: "ALTER LOGIN {{ account.username }} WITH PASSWORD = '{{ account.secret }}', DEFAULT_DATABASE = {{ jms_asset.spec_info.db_name }}; select @@version"
ignore_errors: true
when: user_exist.query_results[0] | length != 0
@@ -51,7 +51,7 @@
login_host: "{{ jms_asset.address }}"
login_port: "{{ jms_asset.port }}"
name: '{{ jms_asset.spec_info.db_name }}'
script: "CREATE LOGIN {{ account.username }} WITH PASSWORD = '{{ account.secret }}'; select @@version"
script: "CREATE LOGIN {{ account.username }} WITH PASSWORD = '{{ account.secret }}', DEFAULT_DATABASE = {{ jms_asset.spec_info.db_name }}; CREATE USER {{ account.username }} FOR LOGIN {{ account.username }}; select @@version"
ignore_errors: true
when: user_exist.query_results[0] | length == 0

View File

@@ -12,8 +12,8 @@
login_port: "{{ jms_asset.port }}"
login_database: "{{ jms_asset.spec_info.db_name }}"
ssl: "{{ jms_asset.spec_info.use_ssl }}"
ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert }}"
ssl_certfile: "{{ jms_asset.secret_info.client_key }}"
ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert | default('') }}"
ssl_certfile: "{{ jms_asset.secret_info.client_key | default('') }}"
connection_options:
- tlsAllowInvalidHostnames: "{{ jms_asset.spec_info.allow_invalid_cert}}"
filter: users

View File

@@ -10,6 +10,10 @@
login_password: "{{ jms_account.secret }}"
login_host: "{{ jms_asset.address }}"
login_port: "{{ jms_asset.port }}"
check_hostname: "{{ jms_asset.spec_info.use_ssl and not jms_asset.spec_info.allow_invalid_cert }}"
ca_cert: "{{ jms_asset.secret_info.ca_cert | default(omit) }}"
client_cert: "{{ jms_asset.secret_info.client_cert | default(omit) }}"
client_key: "{{ jms_asset.secret_info.client_key | default(omit) }}"
filter: users
register: db_info

View File

@@ -12,8 +12,8 @@
login_port: "{{ jms_asset.port }}"
login_database: "{{ jms_asset.spec_info.db_name }}"
ssl: "{{ jms_asset.spec_info.use_ssl }}"
ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert }}"
ssl_certfile: "{{ jms_asset.secret_info.client_key }}"
ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert | default('') }}"
ssl_certfile: "{{ jms_asset.secret_info.client_key | default('') }}"
connection_options:
- tlsAllowInvalidHostnames: "{{ jms_asset.spec_info.allow_invalid_cert}}"
register: db_info
@@ -31,8 +31,8 @@
login_port: "{{ jms_asset.port }}"
login_database: "{{ jms_asset.spec_info.db_name }}"
ssl: "{{ jms_asset.spec_info.use_ssl }}"
ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert }}"
ssl_certfile: "{{ jms_asset.secret_info.client_key }}"
ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert | default('') }}"
ssl_certfile: "{{ jms_asset.secret_info.client_key | default('') }}"
connection_options:
- tlsAllowInvalidHostnames: "{{ jms_asset.spec_info.allow_invalid_cert}}"
db: "{{ jms_asset.spec_info.db_name }}"
@@ -49,7 +49,7 @@
login_port: "{{ jms_asset.port }}"
login_database: "{{ jms_asset.spec_info.db_name }}"
ssl: "{{ jms_asset.spec_info.use_ssl }}"
ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert }}"
ssl_certfile: "{{ jms_asset.secret_info.client_key }}"
ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert | default('') }}"
ssl_certfile: "{{ jms_asset.secret_info.client_key | default('') }}"
connection_options:
- tlsAllowInvalidHostnames: "{{ jms_asset.spec_info.allow_invalid_cert}}"

View File

@@ -11,6 +11,10 @@
login_password: "{{ jms_account.secret }}"
login_host: "{{ jms_asset.address }}"
login_port: "{{ jms_asset.port }}"
check_hostname: "{{ jms_asset.spec_info.use_ssl and not jms_asset.spec_info.allow_invalid_cert }}"
ca_cert: "{{ jms_asset.secret_info.ca_cert | default(omit) }}"
client_cert: "{{ jms_asset.secret_info.client_cert | default(omit) }}"
client_key: "{{ jms_asset.secret_info.client_key | default(omit) }}"
filter: version
register: db_info
@@ -24,6 +28,10 @@
login_password: "{{ jms_account.secret }}"
login_host: "{{ jms_asset.address }}"
login_port: "{{ jms_asset.port }}"
check_hostname: "{{ jms_asset.spec_info.use_ssl and not jms_asset.spec_info.allow_invalid_cert }}"
ca_cert: "{{ jms_asset.secret_info.ca_cert | default(omit) }}"
client_cert: "{{ jms_asset.secret_info.client_cert | default(omit) }}"
client_key: "{{ jms_asset.secret_info.client_key | default(omit) }}"
name: "{{ account.username }}"
password: "{{ account.secret }}"
host: "%"
@@ -37,4 +45,8 @@
login_password: "{{ account.secret }}"
login_host: "{{ jms_asset.address }}"
login_port: "{{ jms_asset.port }}"
check_hostname: "{{ jms_asset.spec_info.use_ssl and not jms_asset.spec_info.allow_invalid_cert }}"
ca_cert: "{{ jms_asset.secret_info.ca_cert | default(omit) }}"
client_cert: "{{ jms_asset.secret_info.client_cert | default(omit) }}"
client_key: "{{ jms_asset.secret_info.client_key | default(omit) }}"
filter: version

View File

@@ -31,6 +31,7 @@
role_attr_flags: LOGIN
ignore_errors: true
when: result is succeeded
register: change_info
- name: Verify password
community.postgresql.postgresql_ping:
@@ -42,3 +43,5 @@
when:
- result is succeeded
- change_info is succeeded
register: result
failed_when: not result.is_available

View File

@@ -40,7 +40,7 @@
login_host: "{{ jms_asset.address }}"
login_port: "{{ jms_asset.port }}"
name: '{{ jms_asset.spec_info.db_name }}'
script: "ALTER LOGIN {{ account.username }} WITH PASSWORD = '{{ account.secret }}'; select @@version"
script: "ALTER LOGIN {{ account.username }} WITH PASSWORD = '{{ account.secret }}', DEFAULT_DATABASE = {{ jms_asset.spec_info.db_name }}; select @@version"
ignore_errors: true
when: user_exist.query_results[0] | length != 0
register: change_info
@@ -52,7 +52,7 @@
login_host: "{{ jms_asset.address }}"
login_port: "{{ jms_asset.port }}"
name: '{{ jms_asset.spec_info.db_name }}'
script: "CREATE LOGIN {{ account.username }} WITH PASSWORD = '{{ account.secret }}'; select @@version"
script: "CREATE LOGIN [{{ account.username }}] WITH PASSWORD = '{{ account.secret }}'; CREATE USER [{{ account.username }}] FOR LOGIN [{{ account.username }}]; select @@version"
ignore_errors: true
when: user_exist.query_results[0] | length == 0
register: change_info

View File

@@ -80,7 +80,7 @@ class PushAccountManager(ChangeSecretManager, AccountBasePlaybookManager):
pass
def on_runner_failed(self, runner, e):
logger.error("Pust account error: ", e)
logger.error("Pust account error: {}".format(e))
def run(self, *args, **kwargs):
if self.secret_type and not self.check_secret():

View File

@@ -12,7 +12,7 @@
login_port: "{{ jms_asset.port }}"
login_database: "{{ jms_asset.spec_info.db_name }}"
ssl: "{{ jms_asset.spec_info.use_ssl }}"
ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert }}"
ssl_certfile: "{{ jms_asset.secret_info.client_key }}"
ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert | default('') }}"
ssl_certfile: "{{ jms_asset.secret_info.client_key | default('') }}"
connection_options:
- tlsAllowInvalidHostnames: "{{ jms_asset.spec_info.allow_invalid_cert}}"
- tlsAllowInvalidHostnames: "{{ jms_asset.spec_info.allow_invalid_cert }}"

View File

@@ -10,4 +10,8 @@
login_password: "{{ account.secret }}"
login_host: "{{ jms_asset.address }}"
login_port: "{{ jms_asset.port }}"
check_hostname: "{{ jms_asset.spec_info.use_ssl and not jms_asset.spec_info.allow_invalid_cert }}"
ca_cert: "{{ jms_asset.secret_info.ca_cert | default(omit) }}"
client_cert: "{{ jms_asset.secret_info.client_cert | default(omit) }}"
client_key: "{{ jms_asset.secret_info.client_key | default(omit) }}"
filter: version

View File

@@ -3,7 +3,6 @@
vars:
ansible_python_interpreter: /usr/local/bin/python
tasks:
- name: Verify account
community.postgresql.postgresql_ping:

View File

@@ -4,11 +4,13 @@ from django.utils.translation import gettext_lazy as _
from assets.const import Connectivity
from common.db.fields import TreeChoices
string_punctuation = '!#$%&()*+,-.:;<=>?@[]^_~'
DEFAULT_PASSWORD_LENGTH = 30
DEFAULT_PASSWORD_RULES = {
'length': DEFAULT_PASSWORD_LENGTH,
'symbol_set': string_punctuation
'uppercase': True,
'lowercase': True,
'digit': True,
'symbol': True,
}
__all__ = [
@@ -41,8 +43,8 @@ class AutomationTypes(models.TextChoices):
class SecretStrategy(models.TextChoices):
custom = 'specific', _('Specific password')
random = 'random', _('Random')
custom = 'specific', _('Specific secret')
random = 'random', _('Random generate')
class SSHKeyStrategy(models.TextChoices):

View File

@@ -113,7 +113,7 @@ class Migration(migrations.Migration):
('comment', models.TextField(blank=True, default='', verbose_name='Comment')),
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
('old_secret', common.db.fields.EncryptTextField(blank=True, null=True, verbose_name='Old secret')),
('new_secret', common.db.fields.EncryptTextField(blank=True, null=True, verbose_name='Secret')),
('new_secret', common.db.fields.EncryptTextField(blank=True, null=True, verbose_name='New secret')),
('date_started', models.DateTimeField(blank=True, null=True, verbose_name='Date started')),
('date_finished', models.DateTimeField(blank=True, null=True, verbose_name='Date finished')),
('status', models.CharField(default='pending', max_length=16)),

View File

@@ -13,11 +13,11 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='changesecretautomation',
name='secret_strategy',
field=models.CharField(choices=[('specific', 'Specific password'), ('random', 'Random')], default='specific', max_length=16, verbose_name='Secret strategy'),
field=models.CharField(choices=[('specific', 'Specific secret'), ('random', 'Random generate')], default='specific', max_length=16, verbose_name='Secret strategy'),
),
migrations.AlterField(
model_name='pushaccountautomation',
name='secret_strategy',
field=models.CharField(choices=[('specific', 'Specific password'), ('random', 'Random')], default='specific', max_length=16, verbose_name='Secret strategy'),
field=models.CharField(choices=[('specific', 'Specific secret'), ('random', 'Random generate')], default='specific', max_length=16, verbose_name='Secret strategy'),
),
]

View File

@@ -0,0 +1,34 @@
# Generated by Django 4.1.10 on 2023-08-25 03:19
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assets', '0122_auto_20230803_1553'),
('accounts', '0014_virtualaccount'),
]
operations = [
migrations.AddField(
model_name='accounttemplate',
name='auto_push',
field=models.BooleanField(default=False, verbose_name='Auto push'),
),
migrations.AddField(
model_name='accounttemplate',
name='platforms',
field=models.ManyToManyField(related_name='account_templates', to='assets.platform', verbose_name='Platforms', blank=True),
),
migrations.AddField(
model_name='accounttemplate',
name='push_params',
field=models.JSONField(default=dict, verbose_name='Push params'),
),
migrations.AddField(
model_name='accounttemplate',
name='secret_strategy',
field=models.CharField(choices=[('specific', 'Specific secret'), ('random', 'Random generate')], default='specific', max_length=16, verbose_name='Secret strategy'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 4.1.10 on 2023-09-18 08:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0015_auto_20230825_1120'),
]
operations = [
migrations.AddField(
model_name='accounttemplate',
name='password_rules',
field=models.JSONField(default=dict, verbose_name='Password rules'),
),
]

75
apps/accounts/mixins.py Normal file
View File

@@ -0,0 +1,75 @@
from rest_framework.response import Response
from rest_framework import status
from django.utils import translation
from django.utils.translation import gettext_noop
from audits.const import ActionChoices
from common.views.mixins import RecordViewLogMixin
from common.utils import i18n_fmt
class AccountRecordViewLogMixin(RecordViewLogMixin):
get_object: callable
get_queryset: callable
@staticmethod
def _filter_params(params):
new_params = {}
need_pop_params = ('format', 'order')
for key, value in params.items():
if key in need_pop_params:
continue
if isinstance(value, list):
value = list(filter(None, value))
if value:
new_params[key] = value
return new_params
def get_resource_display(self, request):
query_params = dict(request.query_params)
params = self._filter_params(query_params)
spm_filter = params.pop("spm", None)
if not params and not spm_filter:
display_message = gettext_noop("Export all")
elif spm_filter:
display_message = gettext_noop("Export only selected items")
else:
query = ",".join(
["%s=%s" % (key, value) for key, value in params.items()]
)
display_message = i18n_fmt(gettext_noop("Export filtered: %s"), query)
return display_message
@property
def detail_msg(self):
return i18n_fmt(
gettext_noop('User %s view/export secret'), self.request.user
)
def list(self, request, *args, **kwargs):
list_func = getattr(super(), 'list')
if not callable(list_func):
return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED)
response = list_func(request, *args, **kwargs)
with translation.override('en'):
resource_display = self.get_resource_display(request)
ids = [q.id for q in self.get_queryset()]
self.record_logs(
ids, ActionChoices.view, self.detail_msg, resource_display=resource_display
)
return response
def retrieve(self, request, *args, **kwargs):
retrieve_func = getattr(super(), 'retrieve')
if not callable(retrieve_func):
return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED)
response = retrieve_func(request, *args, **kwargs)
with translation.override('en'):
resource = self.get_object()
self.record_logs(
[resource.id], ActionChoices.view, self.detail_msg, resource=resource
)
return response

View File

@@ -1,5 +1,5 @@
from .account import *
from .automations import *
from .base import *
from .template import *
from .virtual import *
from .account import * # noqa
from .base import * # noqa
from .automations import * # noqa
from .template import * # noqa
from .virtual import * # noqa

View File

@@ -1,12 +1,15 @@
from django.db import models
from django.utils.translation import gettext_lazy as _
from accounts.const import SSHKeyStrategy
from accounts.models import Account, SecretWithRandomMixin
from accounts.tasks import execute_account_automation_task
from assets.models.automations import (
BaseAutomation as AssetBaseAutomation,
AutomationExecution as AssetAutomationExecution
)
__all__ = ['AccountBaseAutomation', 'AutomationExecution']
__all__ = ['AccountBaseAutomation', 'AutomationExecution', 'ChangeSecretMixin']
class AccountBaseAutomation(AssetBaseAutomation):
@@ -43,3 +46,40 @@ class AutomationExecution(AssetAutomationExecution):
from accounts.automations.endpoint import ExecutionManager
manager = ExecutionManager(execution=self)
return manager.run()
class ChangeSecretMixin(SecretWithRandomMixin):
ssh_key_change_strategy = models.CharField(
choices=SSHKeyStrategy.choices, max_length=16,
default=SSHKeyStrategy.add, verbose_name=_('SSH key change strategy')
)
get_all_assets: callable # get all assets
class Meta:
abstract = True
def create_nonlocal_accounts(self, usernames, asset):
pass
def get_account_ids(self):
usernames = self.accounts
accounts = Account.objects.none()
for asset in self.get_all_assets():
self.create_nonlocal_accounts(usernames, asset)
accounts = accounts | asset.accounts.all()
account_ids = accounts.filter(
username__in=usernames, secret_type=self.secret_type
).values_list('id', flat=True)
return [str(_id) for _id in account_ids]
def to_attr_json(self):
attr_json = super().to_attr_json()
attr_json.update({
'secret': self.secret,
'secret_type': self.secret_type,
'accounts': self.get_account_ids(),
'password_rules': self.password_rules,
'secret_strategy': self.secret_strategy,
'ssh_key_change_strategy': self.ssh_key_change_strategy,
})
return attr_json

View File

@@ -2,62 +2,13 @@ from django.db import models
from django.utils.translation import gettext_lazy as _
from accounts.const import (
AutomationTypes, SecretType, SecretStrategy, SSHKeyStrategy
AutomationTypes
)
from accounts.models import Account
from common.db import fields
from common.db.models import JMSBaseModel
from .base import AccountBaseAutomation
from .base import AccountBaseAutomation, ChangeSecretMixin
__all__ = ['ChangeSecretAutomation', 'ChangeSecretRecord', 'ChangeSecretMixin']
class ChangeSecretMixin(models.Model):
secret_type = models.CharField(
choices=SecretType.choices, max_length=16,
default=SecretType.PASSWORD, verbose_name=_('Secret type')
)
secret = fields.EncryptTextField(blank=True, null=True, verbose_name=_('Secret'))
secret_strategy = models.CharField(
choices=SecretStrategy.choices, max_length=16,
default=SecretStrategy.custom, verbose_name=_('Secret strategy')
)
password_rules = models.JSONField(default=dict, verbose_name=_('Password rules'))
ssh_key_change_strategy = models.CharField(
choices=SSHKeyStrategy.choices, max_length=16,
default=SSHKeyStrategy.add, verbose_name=_('SSH key change strategy')
)
get_all_assets: callable # get all assets
class Meta:
abstract = True
def create_nonlocal_accounts(self, usernames, asset):
pass
def get_account_ids(self):
usernames = self.accounts
accounts = Account.objects.none()
for asset in self.get_all_assets():
self.create_nonlocal_accounts(usernames, asset)
accounts = accounts | asset.accounts.all()
account_ids = accounts.filter(
username__in=usernames, secret_type=self.secret_type
).values_list('id', flat=True)
return [str(_id) for _id in account_ids]
def to_attr_json(self):
attr_json = super().to_attr_json()
attr_json.update({
'secret': self.secret,
'secret_type': self.secret_type,
'accounts': self.get_account_ids(),
'password_rules': self.password_rules,
'secret_strategy': self.secret_strategy,
'ssh_key_change_strategy': self.ssh_key_change_strategy,
})
return attr_json
__all__ = ['ChangeSecretAutomation', 'ChangeSecretRecord', ]
class ChangeSecretAutomation(ChangeSecretMixin, AccountBaseAutomation):

View File

@@ -17,9 +17,9 @@ class PushAccountAutomation(ChangeSecretMixin, AccountBaseAutomation):
def create_nonlocal_accounts(self, usernames, asset):
secret_type = self.secret_type
account_usernames = asset.accounts.filter(secret_type=self.secret_type).values_list(
'username', flat=True
)
account_usernames = asset.accounts \
.filter(secret_type=self.secret_type) \
.values_list('username', flat=True)
create_usernames = set(usernames) - set(account_usernames)
create_account_objs = [
Account(
@@ -30,9 +30,6 @@ class PushAccountAutomation(ChangeSecretMixin, AccountBaseAutomation):
]
Account.objects.bulk_create(create_account_objs)
def set_period_schedule(self):
pass
@property
def dynamic_username(self):
return self.username == '@USER'

View File

@@ -8,12 +8,14 @@ from django.conf import settings
from django.db import models
from django.utils.translation import gettext_lazy as _
from accounts.const import SecretType
from accounts.const import SecretType, SecretStrategy
from accounts.models.mixins import VaultModelMixin, VaultManagerMixin, VaultQuerySetMixin
from accounts.utils import SecretGenerator
from common.db import fields
from common.utils import (
ssh_key_string_to_obj, ssh_key_gen, get_logger,
random_string, lazyproperty, parse_ssh_public_key_str, is_openssh_format_key
)
from accounts.models.mixins import VaultModelMixin, VaultManagerMixin, VaultQuerySetMixin
from orgs.mixins.models import JMSOrgBaseModel, OrgManager
logger = get_logger(__file__)
@@ -29,6 +31,35 @@ class BaseAccountManager(VaultManagerMixin, OrgManager):
return self.get_queryset().active()
class SecretWithRandomMixin(models.Model):
secret_type = models.CharField(
choices=SecretType.choices, max_length=16,
default=SecretType.PASSWORD, verbose_name=_('Secret type')
)
secret = fields.EncryptTextField(blank=True, null=True, verbose_name=_('Secret'))
secret_strategy = models.CharField(
choices=SecretStrategy.choices, max_length=16,
default=SecretStrategy.custom, verbose_name=_('Secret strategy')
)
password_rules = models.JSONField(default=dict, verbose_name=_('Password rules'))
class Meta:
abstract = True
@lazyproperty
def secret_generator(self):
return SecretGenerator(
self.secret_strategy, self.secret_type,
self.password_rules,
)
def get_secret(self):
if self.secret_strategy == 'random':
return self.secret_generator.get_secret()
else:
return self.secret
class BaseAccount(VaultModelMixin, JMSOrgBaseModel):
name = models.CharField(max_length=128, verbose_name=_("Name"))
username = models.CharField(max_length=128, blank=True, verbose_name=_('Username'), db_index=True)

View File

@@ -37,8 +37,9 @@ class VaultManagerMixin(models.Manager):
post_save.send(obj.__class__, instance=obj, created=True)
return objs
def bulk_update(self, objs, batch_size=None, ignore_conflicts=False):
objs = super().bulk_update(objs, batch_size=batch_size, ignore_conflicts=ignore_conflicts)
def bulk_update(self, objs, fields, batch_size=None):
fields = ["_secret" if field == "secret" else field for field in fields]
super().bulk_update(objs, fields, batch_size=batch_size)
for obj in objs:
post_save.send(obj.__class__, instance=obj, created=False)
return objs

View File

@@ -4,16 +4,22 @@ from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from .account import Account
from .base import BaseAccount
from .base import BaseAccount, SecretWithRandomMixin
__all__ = ['AccountTemplate', ]
class AccountTemplate(BaseAccount):
class AccountTemplate(BaseAccount, SecretWithRandomMixin):
su_from = models.ForeignKey(
'self', related_name='su_to', null=True,
on_delete=models.SET_NULL, verbose_name=_("Su from")
)
auto_push = models.BooleanField(default=False, verbose_name=_('Auto push'))
platforms = models.ManyToManyField(
'assets.Platform', related_name='account_templates',
verbose_name=_('Platforms'), blank=True,
)
push_params = models.JSONField(default=dict, verbose_name=_('Push params'))
class Meta:
verbose_name = _('Account template')
@@ -25,15 +31,15 @@ class AccountTemplate(BaseAccount):
('change_accounttemplatesecret', _('Can change asset account template secret')),
]
def __str__(self):
return f'{self.name}({self.username})'
@classmethod
def get_su_from_account_templates(cls, pk=None):
if pk is None:
return cls.objects.all()
return cls.objects.exclude(Q(id=pk) | Q(su_from_id=pk))
def __str__(self):
return f'{self.name}({self.username})'
def get_su_from_account(self, asset):
su_from = self.su_from
if su_from and asset.platform.su_enabled:

View File

@@ -2,6 +2,7 @@ import uuid
from copy import deepcopy
from django.db import IntegrityError
from django.db import transaction
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
@@ -73,6 +74,22 @@ class AccountCreateUpdateSerializerMixin(serializers.Serializer):
name = name + '_' + uuid.uuid4().hex[:4]
initial_data['name'] = name
@staticmethod
def get_template_attr_for_account(template):
# Set initial data from template
field_names = [
'username', 'secret', 'secret_type', 'privileged', 'is_active'
]
attrs = {}
for name in field_names:
value = getattr(template, name, None)
if value is None:
continue
attrs[name] = value
attrs['secret'] = template.get_secret()
return attrs
def from_template_if_need(self, initial_data):
if isinstance(initial_data, str):
return
@@ -89,20 +106,7 @@ class AccountCreateUpdateSerializerMixin(serializers.Serializer):
raise serializers.ValidationError({'template': 'Template not found'})
self._template = template
# Set initial data from template
ignore_fields = ['id', 'date_created', 'date_updated', 'su_from', 'org_id']
field_names = [
field.name for field in template._meta.fields
if field.name not in ignore_fields
]
field_names = [name if name != '_secret' else 'secret' for name in field_names]
attrs = {}
for name in field_names:
value = getattr(template, name, None)
if value is None:
continue
attrs[name] = value
attrs = self.get_template_attr_for_account(template)
initial_data.update(attrs)
initial_data.update({
'source': Source.TEMPLATE,
@@ -114,10 +118,13 @@ class AccountCreateUpdateSerializerMixin(serializers.Serializer):
asset = get_object_or_404(Asset, pk=asset_id)
initial_data['su_from'] = template.get_su_from_account(asset)
@staticmethod
def push_account_if_need(instance, push_now, params, stat):
def push_account_if_need(self, instance, push_now, params, stat):
if not push_now or stat not in ['created', 'updated']:
return
transaction.on_commit(lambda: self.start_push(instance, params))
@staticmethod
def start_push(instance, params):
push_accounts_to_assets_task.delay([str(instance.id)], params)
def get_validators(self):

View File

@@ -1,16 +1,27 @@
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
from accounts.const import SecretStrategy, SecretType
from accounts.models import AccountTemplate, Account
from accounts.utils import SecretGenerator
from common.serializers import SecretReadableMixin
from common.serializers.fields import ObjectRelatedField
from .base import BaseAccountSerializer
class PasswordRulesSerializer(serializers.Serializer):
length = serializers.IntegerField(min_value=8, max_value=30, default=16, label=_('Password length'))
lowercase = serializers.BooleanField(default=True, label=_('Lowercase'))
uppercase = serializers.BooleanField(default=True, label=_('Uppercase'))
digit = serializers.BooleanField(default=True, label=_('Digit'))
symbol = serializers.BooleanField(default=True, label=_('Special symbol'))
class AccountTemplateSerializer(BaseAccountSerializer):
is_sync_account = serializers.BooleanField(default=False, write_only=True)
_is_sync_account = False
password_rules = PasswordRulesSerializer(required=False, label=_('Password rules'))
su_from = ObjectRelatedField(
required=False, queryset=AccountTemplate.objects, allow_null=True,
allow_empty=True, label=_('Su from'), attrs=('id', 'name', 'username')
@@ -18,7 +29,22 @@ class AccountTemplateSerializer(BaseAccountSerializer):
class Meta(BaseAccountSerializer.Meta):
model = AccountTemplate
fields = BaseAccountSerializer.Meta.fields + ['is_sync_account', 'su_from']
fields = BaseAccountSerializer.Meta.fields + [
'secret_strategy', 'password_rules',
'auto_push', 'push_params', 'platforms',
'is_sync_account', 'su_from'
]
extra_kwargs = {
'secret_strategy': {'help_text': _('Secret generation strategy for account creation')},
'auto_push': {'help_text': _('Whether to automatically push the account to the asset')},
'platforms': {
'help_text': _(
'Associated platform, you can configure push parameters. '
'If not associated, default parameters will be used'
),
'required': False
},
}
def sync_accounts_secret(self, instance, diff):
if not self._is_sync_account or 'secret' not in diff:
@@ -31,9 +57,20 @@ class AccountTemplateSerializer(BaseAccountSerializer):
accounts = Account.objects.filter(**query_data)
instance.bulk_sync_account_secret(accounts, self.context['request'].user.id)
@staticmethod
def generate_secret(attrs):
secret_type = attrs.get('secret_type', SecretType.PASSWORD)
secret_strategy = attrs.get('secret_strategy', SecretStrategy.custom)
password_rules = attrs.get('password_rules')
if secret_strategy != SecretStrategy.random:
return
generator = SecretGenerator(secret_strategy, secret_type, password_rules)
attrs['secret'] = generator.get_secret()
def validate(self, attrs):
self._is_sync_account = attrs.pop('is_sync_account', None)
attrs = super().validate(attrs)
self.generate_secret(attrs)
return attrs
def update(self, instance, validated_data):

View File

@@ -19,8 +19,12 @@ class VirtualAccountSerializer(serializers.ModelSerializer):
'comment': {'label': _('Comment')},
'name': {'label': _('Name')},
'username': {'label': _('Username')},
'secret_from_login': {'help_text': _('Current only support login from AD/LDAP. Secret priority: '
'Same account in asset secret > Login secret > Manual input')
},
'secret_from_login': {
'help_text': _(
'Current only support login from AD/LDAP. Secret priority: '
'Same account in asset secret > Login secret > Manual input. <br/ >'
'For security, please set config CACHE_LOGIN_PASSWORD_ENABLED to true'
)
},
'alias': {'required': False},
}

View File

@@ -4,14 +4,13 @@ from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
from accounts.const import (
AutomationTypes, DEFAULT_PASSWORD_RULES,
SecretType, SecretStrategy, SSHKeyStrategy
AutomationTypes, SecretType, SecretStrategy, SSHKeyStrategy
)
from accounts.models import (
Account, ChangeSecretAutomation,
ChangeSecretRecord, AutomationExecution
)
from accounts.serializers import AuthValidateMixin
from accounts.serializers import AuthValidateMixin, PasswordRulesSerializer
from assets.models import Asset
from common.serializers.fields import LabeledChoiceField, ObjectRelatedField
from common.utils import get_logger
@@ -42,7 +41,7 @@ class ChangeSecretAutomationSerializer(AuthValidateMixin, BaseAutomationSerializ
ssh_key_change_strategy = LabeledChoiceField(
choices=SSHKeyStrategy.choices, required=False, label=_('SSH Key strategy')
)
password_rules = serializers.DictField(default=DEFAULT_PASSWORD_RULES)
password_rules = PasswordRulesSerializer(required=False, label=_('Password rules'))
secret_type = LabeledChoiceField(choices=get_secret_types(), required=True, label=_('Secret type'))
class Meta:

View File

@@ -1,9 +1,17 @@
from django.db.models.signals import pre_save, post_save, post_delete
from collections import defaultdict
from django.db.models.signals import post_delete
from django.db.models.signals import pre_save, post_save
from django.dispatch import receiver
from django.utils.translation import gettext_noop
from accounts.backends import vault_client
from common.utils import get_logger
from audits.const import ActivityChoices
from audits.signal_handlers import create_activities
from common.decorators import merge_delay_run
from common.utils import get_logger, i18n_fmt
from .models import Account, AccountTemplate
from .tasks.push_account import push_accounts_to_assets_task
logger = get_logger(__name__)
@@ -16,6 +24,53 @@ def on_account_pre_save(sender, instance, **kwargs):
instance.version = instance.history.count()
@merge_delay_run(ttl=5)
def push_accounts_if_need(accounts=()):
from .models import AccountTemplate
template_accounts = defaultdict(list)
for ac in accounts:
# 再强调一次吧
if ac.source != 'template':
continue
template_accounts[ac.source_id].append(ac)
for source_id, accounts in template_accounts.items():
template = AccountTemplate.objects.filter(id=source_id).first()
if not template or not template.auto_push:
continue
logger.debug("Push accounts to source: %s", source_id)
account_ids = [str(ac.id) for ac in accounts]
task = push_accounts_to_assets_task.delay(account_ids, params=template.push_params)
detail = i18n_fmt(
gettext_noop('Push related accounts to assets: %s, by system'),
len(account_ids)
)
create_activities([str(template.id)], detail, task.id, ActivityChoices.task, template.org_id)
logger.debug("Push accounts to source: %s, task: %s", source_id, task)
def create_accounts_activities(account, action='create'):
if action == 'create':
detail = i18n_fmt(gettext_noop('Add account: %s'), str(account))
else:
detail = i18n_fmt(gettext_noop('Delete account: %s'), str(account))
create_activities([account.asset_id], detail, None, ActivityChoices.operate_log, account.org_id)
@receiver(post_save, sender=Account)
def on_account_create_by_template(sender, instance, created=False, **kwargs):
if not created or instance.source != 'template':
return
push_accounts_if_need(accounts=(instance,))
create_accounts_activities(instance, action='create')
@receiver(post_delete, sender=Account)
def on_account_delete(sender, instance, **kwargs):
create_accounts_activities(instance, action='delete')
class VaultSignalHandler(object):
""" 处理 Vault 相关的信号 """

View File

@@ -1,3 +1,5 @@
import copy
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
@@ -18,9 +20,19 @@ class SecretGenerator:
return private_key
def generate_password(self):
length = int(self.password_rules.get('length', 0))
length = length if length else DEFAULT_PASSWORD_RULES['length']
return random_string(length, special_char=True)
password_rules = self.password_rules
if not password_rules or not isinstance(password_rules, dict):
password_rules = {}
rules = copy.deepcopy(DEFAULT_PASSWORD_RULES)
rules.update(password_rules)
rules = {
'length': rules['length'],
'lower': rules['lowercase'],
'upper': rules['uppercase'],
'digit': rules['digit'],
'special_char': rules['symbol']
}
return random_string(**rules)
def get_secret(self):
if self.secret_type == SecretType.SSH_KEY:
@@ -39,6 +51,8 @@ def validate_password_for_ansible(password):
# Ansible 推送的时候不支持
if '{{' in password:
raise serializers.ValidationError(_('Password can not contains `{{` '))
if '{%' in password:
raise serializers.ValidationError(_('Password can not contains `{%` '))
# Ansible Windows 推送的时候不支持
if "'" in password:
raise serializers.ValidationError(_("Password can not contains `'` "))

View File

@@ -103,25 +103,27 @@ class UserAssetAccountBaseACL(OrgModelMixin, UserBaseACL):
abstract = True
@classmethod
def filter_queryset(cls, user=None, asset=None, account=None, account_username=None, **kwargs):
def _get_filter_queryset(cls, user=None, asset=None, account=None, account_username=None, **kwargs):
queryset = cls.objects.all()
if user:
q = cls.users.get_filter_q(user)
queryset = queryset.filter(q)
q = models.Q()
if asset:
org_id = asset.org_id
with tmp_to_org(org_id):
q = cls.assets.get_filter_q(asset)
queryset = queryset.filter(q)
q &= cls.assets.get_filter_q(asset)
if user:
q &= cls.users.get_filter_q(user)
if account and not account_username:
account_username = account.username
if account_username:
q = models.Q(accounts__contains=account_username) | \
models.Q(accounts__contains='*') | \
models.Q(accounts__contains='@ALL')
queryset = queryset.filter(q)
q &= models.Q(accounts__contains=account_username) | \
models.Q(accounts__contains='*') | \
models.Q(accounts__contains='@ALL')
if kwargs:
queryset = queryset.filter(**kwargs)
q &= models.Q(**kwargs)
queryset = queryset.filter(q)
return queryset.valid().distinct()
@classmethod
def filter_queryset(cls, asset=None, **kwargs):
org_id = asset.org_id if asset else ''
with tmp_to_org(org_id):
return cls._get_filter_queryset(asset=asset, **kwargs)

View File

@@ -42,7 +42,7 @@ class SerializeToTreeNodeMixin:
'name': _name(node),
'title': _name(node),
'pId': node.parent_key,
'isParent': node.assets_amount > 0,
'isParent': True,
'open': _open(node),
'meta': {
'data': {

View File

@@ -49,13 +49,19 @@ class AssetPlatformViewSet(JMSModelViewSet):
@action(methods=['post'], detail=False, url_path='filter-nodes-assets')
def filter_nodes_assets(self, request, *args, **kwargs):
node_ids = request.data.get('node_ids', [])
asset_ids = request.data.get('asset_ids', [])
nodes = Node.objects.filter(id__in=node_ids)
node_asset_ids = Node.get_nodes_all_assets(*nodes).values_list('id', flat=True)
direct_asset_ids = Asset.objects.filter(id__in=asset_ids).values_list('id', flat=True)
platform_ids = Asset.objects.filter(
id__in=set(list(direct_asset_ids) + list(node_asset_ids))
).values_list('platform_id', flat=True)
asset_ids = set(request.data.get('asset_ids', []))
platform_ids = set(request.data.get('platform_ids', []))
if node_ids:
nodes = Node.objects.filter(id__in=node_ids)
node_asset_ids = Node.get_nodes_all_assets(*nodes).values_list('id', flat=True)
asset_ids |= set(node_asset_ids)
if asset_ids:
_platform_ids = Asset.objects \
.filter(id__in=set(asset_ids)) \
.values_list('platform_id', flat=True)
platform_ids |= set(_platform_ids)
platforms = Platform.objects.filter(id__in=platform_ids)
serializer = self.get_serializer(platforms, many=True)
return Response(serializer.data)

View File

@@ -1,14 +1,14 @@
# ~*~ coding: utf-8 ~*~
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from rest_framework.generics import get_object_or_404
from rest_framework.response import Response
from django.utils.translation import gettext_lazy as _
from assets.locks import NodeAddChildrenLock
from common.exceptions import JMSException
from common.tree import TreeNodeSerializer
from common.utils import get_logger
from common.exceptions import JMSException
from orgs.mixins import generics
from orgs.utils import current_org
from .mixin import SerializeToTreeNodeMixin
@@ -35,8 +35,8 @@ class NodeChildrenApi(generics.ListCreateAPIView):
is_initial = False
def initial(self, request, *args, **kwargs):
super().initial(request, *args, **kwargs)
self.instance = self.get_object()
return super().initial(request, *args, **kwargs)
def perform_create(self, serializer):
with NodeAddChildrenLock(self.instance):

View File

@@ -175,7 +175,7 @@ class BasePlaybookManager:
method = self.method_id_meta_mapper.get(method_id)
if not method:
logger.error("Method not found: {}".format(method_id))
return method
return
method_playbook_dir_path = method['dir']
sub_playbook_path = os.path.join(sub_playbook_dir, 'project', 'main.yml')
shutil.copytree(method_playbook_dir_path, os.path.dirname(sub_playbook_path))
@@ -196,6 +196,11 @@ class BasePlaybookManager:
print(msg)
runners = []
for platform, assets in assets_group_by_platform.items():
if not assets:
continue
if not platform.automation or not platform.automation.ansible_enabled:
print(_(" - Platform {} ansible disabled").format(platform.name))
continue
assets_bulked = [assets[i:i + self.bulk_size] for i in range(0, len(assets), self.bulk_size)]
for i, _assets in enumerate(assets_bulked, start=1):
@@ -204,6 +209,8 @@ class BasePlaybookManager:
inventory_path = os.path.join(self.runtime_dir, sub_dir, 'hosts.json')
self.generate_inventory(_assets, inventory_path)
playbook_path = self.generate_playbook(_assets, platform, playbook_dir)
if not playbook_path:
continue
runer = PlaybookRunner(
inventory_path,
@@ -309,6 +316,7 @@ class BasePlaybookManager:
shutil.rmtree(self.runtime_dir)
def run(self, *args, **kwargs):
print(">>> 任务准备阶段\n")
runners = self.get_runners()
if len(runners) > 1:
print("### 分次执行任务, 总共 {}\n".format(len(runners)))

View File

@@ -12,8 +12,8 @@
login_port: "{{ jms_asset.port }}"
login_database: "{{ jms_asset.spec_info.db_name }}"
ssl: "{{ jms_asset.spec_info.use_ssl }}"
ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert }}"
ssl_certfile: "{{ jms_asset.secret_info.client_key }}"
ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert | default('') }}"
ssl_certfile: "{{ jms_asset.secret_info.client_key | default('') }}"
connection_options:
- tlsAllowInvalidHostnames: "{{ jms_asset.spec_info.allow_invalid_cert}}"
register: db_info

View File

@@ -10,6 +10,10 @@
login_password: "{{ jms_account.secret }}"
login_host: "{{ jms_asset.address }}"
login_port: "{{ jms_asset.port }}"
check_hostname: "{{ jms_asset.spec_info.use_ssl and not jms_asset.spec_info.allow_invalid_cert }}"
ca_cert: "{{ jms_asset.secret_info.ca_cert | default(omit) }}"
client_cert: "{{ jms_asset.secret_info.client_cert | default(omit) }}"
client_key: "{{ jms_asset.secret_info.client_key | default(omit) }}"
filter: version
register: db_info

View File

@@ -12,7 +12,7 @@
login_port: "{{ jms_asset.port }}"
login_database: "{{ jms_asset.spec_info.db_name }}"
ssl: "{{ jms_asset.spec_info.use_ssl }}"
ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert }}"
ssl_certfile: "{{ jms_asset.secret_info.client_key }}"
ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert | default('') }}"
ssl_certfile: "{{ jms_asset.secret_info.client_key | default('') }}"
connection_options:
- tlsAllowInvalidHostnames: "{{ jms_asset.spec_info.allow_invalid_cert}}"

View File

@@ -10,4 +10,8 @@
login_password: "{{ jms_account.secret }}"
login_host: "{{ jms_asset.address }}"
login_port: "{{ jms_asset.port }}"
check_hostname: "{{ jms_asset.spec_info.use_ssl and not jms_asset.spec_info.allow_invalid_cert }}"
ca_cert: "{{ jms_asset.secret_info.ca_cert | default(omit) }}"
client_cert: "{{ jms_asset.secret_info.client_cert | default(omit) }}"
client_key: "{{ jms_asset.secret_info.client_key | default(omit) }}"
filter: version

View File

@@ -24,7 +24,7 @@ class DeviceTypes(BaseType):
def _get_protocol_constrains(cls) -> dict:
return {
'*': {
'choices': ['ssh', 'telnet']
'choices': ['ssh', 'telnet', 'sftp']
}
}

View File

@@ -45,7 +45,13 @@ class Protocol(ChoicesMixin, models.TextChoices):
'sftp_home': {
'type': 'str',
'default': '/tmp',
'label': _('SFTP home')
'label': _('SFTP root'),
'help_text': _(
'SFTP root directory, Support variable: <br>'
'- ${ACCOUNT} The connected account username <br>'
'- ${HOME} The home directory of the connected account <br>'
'- ${USER} The username of the user'
)
}
}
},
@@ -154,6 +160,15 @@ class Protocol(ChoicesMixin, models.TextChoices):
'required': True,
'secret_types': ['password'],
'xpack': True,
'setting': {
'version': {
'type': 'choice',
'choices': [('>=2014', '>= 2014'), ('<2014', '< 2014')],
'default': '>=2014',
'label': _('Version'),
'help_text': _('SQL Server version, Different versions have different connection drivers')
}
}
},
cls.clickhouse: {
'port': 9000,

View File

@@ -107,8 +107,9 @@ def create_app_nodes(apps, org_id):
'key': next_key, 'value': name, 'parent_key': parent_key,
'full_value': full_value, 'org_id': org_id
}
node, created = node_model.objects.get_or_create(
node, __ = node_model.objects.get_or_create(
defaults=defaults, value=name, org_id=org_id,
parent_key=parent_key
)
node.parent = parent
return node

View File

@@ -25,7 +25,7 @@ def migrate_asset_accounts(apps, schema_editor):
count += len(auth_books)
# auth book 和 account 相同的属性
same_attrs = [
'id', 'username', 'comment', 'date_created', 'date_updated',
'username', 'comment', 'date_created', 'date_updated',
'created_by', 'asset_id', 'org_id',
]
# 认证的属性,可能是 auth_book 的,可能是 system_user 的

View File

@@ -49,11 +49,11 @@ def migrate_assets_sftp_protocol(apps, schema_editor):
count = 0
print("\nAsset add sftp protocol: ")
asset_ids = asset_cls.objects\
asset_ids = list(asset_cls.objects\
.filter(platform__in=sftp_platforms)\
.exclude(protocols__name='sftp')\
.distinct()\
.values_list('id', flat=True)
.values_list('id', flat=True))
while True:
_asset_ids = asset_ids[count:count + 1000]
if not _asset_ids:

View File

@@ -0,0 +1,20 @@
# Generated by Django 4.1.10 on 2023-09-13 10:59
from django.db import migrations
def migrate_device_automation_ansible_enabled(apps, *args):
platform_model = apps.get_model('assets', 'Platform')
automation_model = apps.get_model('assets', 'PlatformAutomation')
ids = platform_model.objects.filter(category='device').values_list('id', flat=True)
automation_model.objects.filter(platform_id__in=ids).update(ansible_enabled=True)
class Migration(migrations.Migration):
dependencies = [
('assets', '0122_auto_20230803_1553'),
]
operations = [
migrations.RunPython(migrate_device_automation_ansible_enabled)
]

View File

@@ -402,12 +402,7 @@ class NodeAssetsMixin(NodeAllAssetsMappingMixin):
return Asset.objects.filter(q).distinct()
def get_assets_amount(self):
q = Q(node__key__startswith=f'{self.key}:') | Q(node__key=self.key)
return self.assets.through.objects.filter(q).count()
def get_assets_account_by_children(self):
children = self.get_all_children().values_list()
return self.assets.through.objects.filter(node_id__in=children).count()
return self.get_all_assets().count()
@classmethod
def get_node_all_assets_by_key_v2(cls, key):

View File

@@ -1,6 +1,7 @@
from django.db.models import QuerySet
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
from rest_framework.validators import UniqueValidator
from common.serializers import (
WritableNestedModelSerializer, type_field_map, MethodSerializer,
@@ -123,6 +124,10 @@ class PlatformSerializer(WritableNestedModelSerializer):
("super", "super 15"),
("super_level", "super level 15")
]
id = serializers.IntegerField(
label='ID', required=False,
validators=[UniqueValidator(queryset=Platform.objects.all())]
)
charset = LabeledChoiceField(choices=Platform.CharsetChoices.choices, label=_("Charset"), default='utf-8')
type = LabeledChoiceField(choices=AllTypes.choices(), label=_("Type"))
category = LabeledChoiceField(choices=Category.choices, label=_("Category"))
@@ -213,7 +218,7 @@ class PlatformSerializer(WritableNestedModelSerializer):
def validate_automation(self, automation):
automation = automation or {}
ansible_enabled = automation.get('ansible_enabled', False) \
and self.constraints['automation'].get('ansible_enabled', False)
and self.constraints['automation'].get('ansible_enabled', False)
automation['ansible_enable'] = ansible_enabled
return automation

View File

@@ -66,7 +66,7 @@ class KubernetesClient:
remote_bind_address = (
urlparse(asset.address).hostname,
urlparse(asset.address).port
urlparse(asset.address).port or 443
)
server = SSHTunnelForwarder(
(gateway.address, gateway.port),

View File

@@ -1,50 +1,52 @@
# -*- coding: utf-8 -*-
#
import os
from importlib import import_module
from django.conf import settings
from django.shortcuts import get_object_or_404
from django.db.models import F, Value, CharField, Q
from django.http import HttpResponse, FileResponse
from django.utils import timezone
from django.utils.encoding import escape_uri_path
from rest_framework import generics
from rest_framework import status
from rest_framework import viewsets
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.decorators import action
from common.api import AsyncApiMixin
from common.drf.filters import DatetimeRangeFilter
from common.api import CommonApiMixin
from common.const.http import GET, POST
from common.drf.filters import DatetimeRangeFilterBackend
from common.permissions import IsServiceAccount
from common.plugins.es import QuerySet as ESQuerySet
from common.utils import is_uuid, get_logger, lazyproperty
from common.const.http import GET, POST
from common.storage.ftp_file import FTPFileStorageHandler
from common.utils import is_uuid, get_logger, lazyproperty
from orgs.mixins.api import OrgReadonlyModelViewSet, OrgModelViewSet
from orgs.utils import current_org, tmp_to_root_org
from orgs.models import Organization
from orgs.utils import current_org, tmp_to_root_org
from rbac.permissions import RBACPermission
from terminal.models import default_storage
from users.models import User
from .backends import TYPE_ENGINE_MAPPING
from .const import ActivityChoices
from .models import FTPLog, UserLoginLog, OperateLog, PasswordChangeLog, ActivityLog, JobLog
from .models import (
FTPLog, UserLoginLog, OperateLog, PasswordChangeLog,
ActivityLog, JobLog, UserSession
)
from .serializers import (
FTPLogSerializer, UserLoginLogSerializer, JobLogSerializer,
OperateLogSerializer, OperateLogActionDetailSerializer,
PasswordChangeLogSerializer, ActivityUnionLogSerializer,
FileSerializer
FileSerializer, UserSessionSerializer
)
logger = get_logger(__name__)
class JobAuditViewSet(OrgReadonlyModelViewSet):
model = JobLog
extra_filter_backends = [DatetimeRangeFilter]
extra_filter_backends = [DatetimeRangeFilterBackend]
date_range_filter_fields = [
('date_start', ('date_from', 'date_to'))
]
@@ -57,7 +59,7 @@ class JobAuditViewSet(OrgReadonlyModelViewSet):
class FTPLogViewSet(OrgModelViewSet):
model = FTPLog
serializer_class = FTPLogSerializer
extra_filter_backends = [DatetimeRangeFilter]
extra_filter_backends = [DatetimeRangeFilterBackend]
date_range_filter_fields = [
('date_start', ('date_from', 'date_to'))
]
@@ -113,7 +115,7 @@ class FTPLogViewSet(OrgModelViewSet):
class UserLoginCommonMixin:
model = UserLoginLog
serializer_class = UserLoginLogSerializer
extra_filter_backends = [DatetimeRangeFilter]
extra_filter_backends = [DatetimeRangeFilterBackend]
date_range_filter_fields = [
('datetime', ('date_from', 'date_to'))
]
@@ -193,7 +195,7 @@ class ResourceActivityAPIView(generics.ListAPIView):
class OperateLogViewSet(OrgReadonlyModelViewSet):
model = OperateLog
serializer_class = OperateLogSerializer
extra_filter_backends = [DatetimeRangeFilter]
extra_filter_backends = [DatetimeRangeFilterBackend]
date_range_filter_fields = [
('datetime', ('date_from', 'date_to'))
]
@@ -232,7 +234,7 @@ class OperateLogViewSet(OrgReadonlyModelViewSet):
class PasswordChangeLogViewSet(OrgReadonlyModelViewSet):
model = PasswordChangeLog
serializer_class = PasswordChangeLogSerializer
extra_filter_backends = [DatetimeRangeFilter]
extra_filter_backends = [DatetimeRangeFilterBackend]
date_range_filter_fields = [
('datetime', ('date_from', 'date_to'))
]
@@ -248,3 +250,44 @@ class PasswordChangeLogViewSet(OrgReadonlyModelViewSet):
user__in=[str(user) for user in users]
)
return queryset
class UserSessionViewSet(CommonApiMixin, viewsets.ModelViewSet):
http_method_names = ('get', 'post', 'head', 'options', 'trace')
serializer_class = UserSessionSerializer
filterset_fields = ['id', 'ip', 'city', 'type']
search_fields = ['id', 'ip', 'city']
rbac_perms = {
'offline': ['users.offline_usersession']
}
@property
def org_user_ids(self):
user_ids = current_org.get_members().values_list('id', flat=True)
return user_ids
def get_queryset(self):
keys = UserSession.get_keys()
queryset = UserSession.objects.filter(
date_expired__gt=timezone.now(), key__in=keys
)
if current_org.is_root():
return queryset
user_ids = self.org_user_ids
queryset = queryset.filter(user_id__in=user_ids)
return queryset
@action(['POST'], detail=False, url_path='offline')
def offline(self, request, *args, **kwargs):
ids = request.data.get('ids', [])
queryset = self.get_queryset().exclude(key=request.session.session_key).filter(id__in=ids)
if not queryset.exists():
return Response(status=status.HTTP_200_OK)
keys = queryset.values_list('key', flat=True)
session_store_cls = import_module(settings.SESSION_ENGINE).SessionStore
for key in keys:
session_store_cls(key).delete()
queryset.delete()
return Response(status=status.HTTP_200_OK)

View File

@@ -25,6 +25,7 @@ class ActionChoices(TextChoices):
delete = "delete", _("Delete")
create = "create", _("Create")
# Activities action
download = "download", _("Download")
connect = "connect", _("Connect")
login = "login", _("Login")
change_auth = "change_password", _("Change password")

View File

@@ -0,0 +1,29 @@
# Generated by Django 4.1.10 on 2023-09-06 05:31
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('audits', '0022_auto_20230605_1555'),
]
operations = [
migrations.AlterField(
model_name='ftplog',
name='date_start',
field=models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='Date start'),
),
migrations.AlterField(
model_name='operatelog',
name='action',
field=models.CharField(choices=[('view', 'View'), ('update', 'Update'), ('delete', 'Delete'), ('create', 'Create'), ('download', 'Download'), ('connect', 'Connect'), ('login', 'Login'), ('change_password', 'Change password')], max_length=16, verbose_name='Action'),
),
migrations.AlterField(
model_name='userloginlog',
name='datetime',
field=models.DateTimeField(db_index=True, default=django.utils.timezone.now, verbose_name='Date login'),
),
]

View File

@@ -0,0 +1,37 @@
# Generated by Django 4.1.10 on 2023-09-15 08:58
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('audits', '0023_auto_20230906_1322'),
]
operations = [
migrations.CreateModel(
name='UserSession',
fields=[
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
('ip', models.GenericIPAddressField(verbose_name='Login IP')),
('key', models.CharField(max_length=128, verbose_name='Session key')),
('city', models.CharField(blank=True, max_length=254, null=True, verbose_name='Login city')),
('user_agent', models.CharField(blank=True, max_length=254, null=True, verbose_name='User agent')),
('type', models.CharField(choices=[('W', 'Web'), ('T', 'Terminal'), ('U', 'Unknown')], max_length=2, verbose_name='Login type')),
('backend', models.CharField(default='', max_length=32, verbose_name='Authentication backend')),
('date_created', models.DateTimeField(blank=True, null=True, verbose_name='Date created')),
('date_expired', models.DateTimeField(blank=True, db_index=True, null=True, verbose_name='Date expired')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sessions', to=settings.AUTH_USER_MODEL, verbose_name='User')),
],
options={
'verbose_name': 'User session',
'ordering': ['-date_created'],
'permissions': [('offline_usersession', 'Offline ussr session')],
},
),
]

View File

@@ -1,7 +1,9 @@
import os
import uuid
from importlib import import_module
from django.conf import settings
from django.core.cache import caches
from django.db import models
from django.db.models import Q
from django.utils import timezone
@@ -28,7 +30,8 @@ __all__ = [
"ActivityLog",
"PasswordChangeLog",
"UserLoginLog",
"JobLog"
"JobLog",
"UserSession"
]
@@ -57,7 +60,7 @@ class FTPLog(OrgModelMixin):
)
filename = models.CharField(max_length=1024, verbose_name=_("Filename"))
is_success = models.BooleanField(default=True, verbose_name=_("Success"))
date_start = models.DateTimeField(auto_now_add=True, verbose_name=_("Date start"))
date_start = models.DateTimeField(auto_now_add=True, verbose_name=_("Date start"), db_index=True)
has_file = models.BooleanField(default=False, verbose_name=_("File"))
session = models.CharField(max_length=36, verbose_name=_("Session"), default=uuid.uuid4)
@@ -198,7 +201,7 @@ class UserLoginLog(models.Model):
choices=LoginStatusChoices.choices,
verbose_name=_("Status"),
)
datetime = models.DateTimeField(default=timezone.now, verbose_name=_("Date login"))
datetime = models.DateTimeField(default=timezone.now, verbose_name=_("Date login"), db_index=True)
backend = models.CharField(
max_length=32, default="", verbose_name=_("Authentication backend")
)
@@ -245,3 +248,44 @@ class UserLoginLog(models.Model):
class Meta:
ordering = ["-datetime", "username"]
verbose_name = _("User login log")
class UserSession(models.Model):
id = models.UUIDField(default=uuid.uuid4, primary_key=True)
ip = models.GenericIPAddressField(verbose_name=_("Login IP"))
key = models.CharField(max_length=128, verbose_name=_("Session key"))
city = models.CharField(max_length=254, blank=True, null=True, verbose_name=_("Login city"))
user_agent = models.CharField(max_length=254, blank=True, null=True, verbose_name=_("User agent"))
type = models.CharField(choices=LoginTypeChoices.choices, max_length=2, verbose_name=_("Login type"))
backend = models.CharField(max_length=32, default="", verbose_name=_("Authentication backend"))
date_created = models.DateTimeField(null=True, blank=True, verbose_name=_('Date created'))
date_expired = models.DateTimeField(null=True, blank=True, verbose_name=_("Date expired"), db_index=True)
user = models.ForeignKey(
'users.User', verbose_name=_('User'), related_name='sessions', on_delete=models.CASCADE
)
def __str__(self):
return '%s(%s)' % (self.user, self.ip)
@property
def backend_display(self):
return gettext(self.backend)
@staticmethod
def get_keys():
session_store_cls = import_module(settings.SESSION_ENGINE).SessionStore
cache_key_prefix = session_store_cls.cache_key_prefix
keys = caches[settings.SESSION_CACHE_ALIAS].keys('*')
return [k.replace(cache_key_prefix, '') for k in keys]
@classmethod
def clear_expired_sessions(cls):
cls.objects.filter(date_expired__lt=timezone.now()).delete()
cls.objects.exclude(key__in=cls.get_keys()).delete()
class Meta:
ordering = ['-date_created']
verbose_name = _('User session')
permissions = [
('offline_usersession', _('Offline ussr session')),
]

View File

@@ -4,12 +4,13 @@ from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
from audits.backends.db import OperateLogStore
from common.serializers.fields import LabeledChoiceField
from common.serializers.fields import LabeledChoiceField, ObjectRelatedField
from common.utils import reverse, i18n_trans
from common.utils.timezone import as_current_tz
from ops.serializers.job import JobExecutionSerializer
from orgs.mixins.serializers import BulkOrgResourceModelSerializer
from terminal.models import Session
from users.models import User
from . import models
from .const import (
ActionChoices, OperateChoices,
@@ -163,3 +164,27 @@ class ActivityUnionLogSerializer(serializers.Serializer):
class FileSerializer(serializers.Serializer):
file = serializers.FileField(allow_empty_file=True)
class UserSessionSerializer(serializers.ModelSerializer):
type = LabeledChoiceField(choices=LoginTypeChoices.choices, label=_("Type"))
user = ObjectRelatedField(required=False, queryset=User.objects, label=_('User'))
is_current_user_session = serializers.SerializerMethodField()
class Meta:
model = models.UserSession
fields_mini = ['id']
fields_small = fields_mini + [
'type', 'ip', 'city', 'user_agent', 'user', 'is_current_user_session',
'backend', 'backend_display', 'date_created', 'date_expired'
]
fields = fields_small
extra_kwargs = {
"backend_display": {"label": _("Authentication backend")},
}
def get_is_current_user_session(self, obj):
request = self.context.get('request')
if not request:
return False
return request.session.session_key == obj.key

View File

@@ -1,5 +1,8 @@
# -*- coding: utf-8 -*-
#
from datetime import timedelta
from importlib import import_module
from django.conf import settings
from django.contrib.auth import BACKEND_SESSION_KEY
from django.dispatch import receiver
@@ -8,10 +11,13 @@ from django.utils.functional import LazyObject
from django.utils.translation import gettext_lazy as _
from rest_framework.request import Request
from audits.models import UserLoginLog
from authentication.signals import post_auth_failed, post_auth_success
from authentication.utils import check_different_city_login_if_need
from common.utils import get_request_ip, get_logger
from users.models import User
from ..const import LoginTypeChoices
from ..models import UserSession
from ..utils import write_login_log
logger = get_logger(__name__)
@@ -32,6 +38,7 @@ class AuthBackendLabelMapping(LazyObject):
backend_label_mapping[settings.AUTH_BACKEND_FEISHU] = _("FeiShu")
backend_label_mapping[settings.AUTH_BACKEND_DINGTALK] = _("DingTalk")
backend_label_mapping[settings.AUTH_BACKEND_TEMP_TOKEN] = _("Temporary token")
backend_label_mapping[settings.AUTH_BACKEND_PASSKEY] = _("Passkey")
return backend_label_mapping
def _setup(self):
@@ -74,6 +81,27 @@ def generate_data(username, request, login_type=None):
return data
def create_user_session(request, user_id, instance: UserLoginLog):
session_key = request.session.session_key or '-'
session_store_cls = import_module(settings.SESSION_ENGINE).SessionStore
session_store = session_store_cls(session_key=session_key)
ttl = session_store.get_expiry_age()
online_session_data = {
'user_id': user_id,
'ip': instance.ip,
'key': session_key,
'city': instance.city,
'type': instance.type,
'backend': instance.backend,
'user_agent': instance.user_agent,
'date_created': instance.datetime,
'date_expired': instance.datetime + timedelta(seconds=ttl),
}
user_session = UserSession.objects.create(**online_session_data)
request.session['user_session_id'] = user_session.id
@receiver(post_auth_success)
def on_user_auth_success(sender, user, request, login_type=None, **kwargs):
logger.debug('User login success: {}'.format(user.username))
@@ -83,7 +111,11 @@ def on_user_auth_success(sender, user, request, login_type=None, **kwargs):
)
request.session['login_time'] = data['datetime'].strftime("%Y-%m-%d %H:%M:%S")
data.update({'mfa': int(user.mfa_enabled), 'status': True})
write_login_log(**data)
instance = write_login_log(**data)
# TODO 目前只记录 web 登录的 session
if instance.type != LoginTypeChoices.web:
return
create_user_session(request, user.id, instance)
@receiver(post_auth_failed)

View File

@@ -15,6 +15,7 @@ router.register(r'operate-logs', api.OperateLogViewSet, 'operate-log')
router.register(r'password-change-logs', api.PasswordChangeLogViewSet, 'password-change-log')
router.register(r'job-logs', api.JobAuditViewSet, 'job-log')
router.register(r'my-login-logs', api.MyLoginLogViewSet, 'my-login-log')
router.register(r'user-sessions', api.UserSessionViewSet, 'user-session')
urlpatterns = [
path('activities/', api.ResourceActivityAPIView.as_view(), name='resource-activities'),

View File

@@ -1,12 +1,12 @@
import copy
from itertools import chain
from datetime import datetime
from itertools import chain
from django.db import models
from common.utils.timezone import as_current_tz
from common.utils import validate_ip, get_ip_city, get_logger
from common.db.fields import RelatedManager
from common.utils import validate_ip, get_ip_city, get_logger
from common.utils.timezone import as_current_tz
from .const import DEFAULT_CITY
logger = get_logger(__name__)
@@ -22,7 +22,7 @@ def write_login_log(*args, **kwargs):
else:
city = get_ip_city(ip) or DEFAULT_CITY
kwargs.update({'ip': ip, 'city': city})
UserLoginLog.objects.create(**kwargs)
return UserLoginLog.objects.create(**kwargs)
def _get_instance_field_value(

View File

@@ -1,15 +1,15 @@
# -*- coding: utf-8 -*-
#
from .connection_token import *
from .token import *
from .mfa import *
from .access_key import *
from .confirm import *
from .login_confirm import *
from .sso import *
from .wecom import *
from .connection_token import *
from .dingtalk import *
from .feishu import *
from .login_confirm import *
from .mfa import *
from .password import *
from .sso import *
from .temp_token import *
from .token import *
from .wecom import *

View File

@@ -13,7 +13,7 @@ from ..serializers import ConfirmSerializer
class ConfirmBindORUNBindOAuth(RetrieveAPIView):
permission_classes = (UserConfirmation.require(ConfirmType.ReLogin),)
permission_classes = (IsValidUser, UserConfirmation.require(ConfirmType.ReLogin),)
def retrieve(self, request, *args, **kwargs):
return Response('ok')

View File

@@ -24,6 +24,8 @@ from orgs.mixins.api import RootOrgViewMixin
from perms.models import ActionChoices
from terminal.connect_methods import NativeClient, ConnectMethodUtil
from terminal.models import EndpointRule, Endpoint
from users.const import FileNameConflictResolution
from users.models import Preference
from ..models import ConnectionToken, date_expired_default
from ..serializers import (
ConnectionTokenSerializer, ConnectionTokenSecretSerializer,
@@ -310,9 +312,20 @@ class ConnectionTokenViewSet(ExtraActionApiMixin, RootOrgViewMixin, JMSModelView
self.validate_serializer(serializer)
return super().perform_create(serializer)
def _insert_connect_options(self, data, user):
name = 'file_name_conflict_resolution'
connect_options = data.pop('connect_options', {})
preference = Preference.objects.filter(
name=name, user=user, category='koko'
).first()
value = preference.value if preference else FileNameConflictResolution.REPLACE
connect_options[name] = value
data['connect_options'] = connect_options
def validate_serializer(self, serializer):
data = serializer.validated_data
user = self.get_user(serializer)
self._insert_connect_options(data, user)
asset = data.get('asset')
account_name = data.get('account')
_data = self._validate(user, asset, account_name)
@@ -363,7 +376,7 @@ class ConnectionTokenViewSet(ExtraActionApiMixin, RootOrgViewMixin, JMSModelView
def _validate_acl(self, user, asset, account):
from acls.models import LoginAssetACL
acls = LoginAssetACL.filter_queryset(user, asset, account)
acls = LoginAssetACL.filter_queryset(user=user, asset=asset, account=account)
ip = get_request_ip(self.request)
acl = LoginAssetACL.get_match_rule_acls(user, ip, acls)
if not acl:

View File

@@ -1,13 +1,13 @@
from rest_framework.views import APIView
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from users.models import User
from common.utils import get_logger
from common.permissions import UserConfirmation
from common.api import RoleUserMixin, RoleAdminMixin
from authentication.const import ConfirmType
from authentication import errors
from authentication.const import ConfirmType
from common.api import RoleUserMixin, RoleAdminMixin
from common.permissions import UserConfirmation, IsValidUser
from common.utils import get_logger
from users.models import User
logger = get_logger(__file__)
@@ -27,7 +27,7 @@ class DingTalkQRUnBindBase(APIView):
class DingTalkQRUnBindForUserApi(RoleUserMixin, DingTalkQRUnBindBase):
permission_classes = (UserConfirmation.require(ConfirmType.ReLogin),)
permission_classes = (IsValidUser, UserConfirmation.require(ConfirmType.ReLogin),)
class DingTalkQRUnBindForAdminApi(RoleAdminMixin, DingTalkQRUnBindBase):

View File

@@ -1,13 +1,13 @@
from rest_framework.views import APIView
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from users.models import User
from common.utils import get_logger
from common.permissions import UserConfirmation
from common.api import RoleUserMixin, RoleAdminMixin
from authentication.const import ConfirmType
from authentication import errors
from authentication.const import ConfirmType
from common.api import RoleUserMixin, RoleAdminMixin
from common.permissions import UserConfirmation, IsValidUser
from common.utils import get_logger
from users.models import User
logger = get_logger(__file__)
@@ -27,7 +27,7 @@ class FeiShuQRUnBindBase(APIView):
class FeiShuQRUnBindForUserApi(RoleUserMixin, FeiShuQRUnBindBase):
permission_classes = (UserConfirmation.require(ConfirmType.ReLogin),)
permission_classes = (IsValidUser, UserConfirmation.require(ConfirmType.ReLogin),)
class FeiShuQRUnBindForAdminApi(RoleAdminMixin, FeiShuQRUnBindBase):
@@ -38,7 +38,7 @@ class FeiShuEventSubscriptionCallback(APIView):
"""
# https://open.feishu.cn/document/ukTMukTMukTM/uUTNz4SN1MjL1UzM
"""
permission_classes = ()
permission_classes = (IsValidUser,)
def post(self, request: Request, *args, **kwargs):
return Response(data=request.data)

View File

@@ -3,6 +3,7 @@
from django.shortcuts import get_object_or_404
from django.utils.translation import gettext as _
from rest_framework import exceptions
from rest_framework.generics import CreateAPIView
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
@@ -13,6 +14,7 @@ from common.utils import get_logger
from users.models.user import User
from .. import errors
from .. import serializers
from ..errors import SessionEmptyError
from ..mixins import AuthMixin
logger = get_logger(__name__)
@@ -56,6 +58,7 @@ class MFASendCodeApi(AuthMixin, CreateAPIView):
if not mfa_backend or not mfa_backend.challenge_required:
error = _('Current user not support mfa type: {}').format(mfa_type)
raise ValidationError({'error': error})
try:
mfa_backend.send_challenge()
except Exception as e:
@@ -66,6 +69,15 @@ class MFAChallengeVerifyApi(AuthMixin, CreateAPIView):
permission_classes = (AllowAny,)
serializer_class = serializers.MFAChallengeSerializer
def initial(self, request, *args, **kwargs):
super().initial(request, *args, **kwargs)
try:
user = self.get_user_from_session()
except SessionEmptyError:
user = None
if not user:
raise exceptions.NotAuthenticated()
def perform_create(self, serializer):
user = self.get_user_from_session()
code = serializer.validated_data.get('code')

View File

@@ -1,26 +1,27 @@
from uuid import UUID
from urllib.parse import urlencode
from uuid import UUID
from django.contrib.auth import login
from django.conf import settings
from django.contrib.auth import login
from django.http.response import HttpResponseRedirect
from rest_framework import serializers
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.request import Request
from rest_framework.permissions import AllowAny
from rest_framework.request import Request
from rest_framework.response import Response
from common.utils.timezone import utc_now
from common.const.http import POST, GET
from common.api import JMSGenericViewSet
from common.serializers import EmptySerializer
from common.const.http import POST, GET
from common.permissions import OnlySuperUser
from common.serializers import EmptySerializer
from common.utils import reverse
from common.utils.timezone import utc_now
from users.models import User
from ..serializers import SSOTokenSerializer
from ..models import SSOToken
from ..errors import SSOAuthClosed
from ..filters import AuthKeyQueryDeclaration
from ..mixins import AuthMixin
from ..errors import SSOAuthClosed
from ..models import SSOToken
from ..serializers import SSOTokenSerializer
NEXT_URL = 'next'
AUTH_KEY = 'authkey'
@@ -67,6 +68,9 @@ class SSOViewSet(AuthMixin, JMSGenericViewSet):
if not next_url or not next_url.startswith('/'):
next_url = reverse('index')
if not authkey:
raise serializers.ValidationError("authkey is required")
try:
authkey = UUID(authkey)
token = SSOToken.objects.get(authkey=authkey, expired=False)

View File

@@ -1,13 +1,13 @@
from rest_framework.views import APIView
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from users.models import User
from common.utils import get_logger
from common.permissions import UserConfirmation
from common.api import RoleUserMixin, RoleAdminMixin
from authentication.const import ConfirmType
from authentication import errors
from authentication.const import ConfirmType
from common.api import RoleUserMixin, RoleAdminMixin
from common.permissions import UserConfirmation, IsValidUser
from common.utils import get_logger
from users.models import User
logger = get_logger(__file__)
@@ -27,7 +27,7 @@ class WeComQRUnBindBase(APIView):
class WeComQRUnBindForUserApi(RoleUserMixin, WeComQRUnBindBase):
permission_classes = (UserConfirmation.require(ConfirmType.ReLogin),)
permission_classes = (IsValidUser, UserConfirmation.require(ConfirmType.ReLogin),)
class WeComQRUnBindForAdminApi(RoleAdminMixin, WeComQRUnBindBase):

View File

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

View File

@@ -0,0 +1,59 @@
from django.conf import settings
from django.http import JsonResponse
from django.shortcuts import render
from django.utils.translation import gettext as _
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated, AllowAny
from rest_framework.viewsets import ModelViewSet
from authentication.mixins import AuthMixin
from .fido import register_begin, register_complete, auth_begin, auth_complete
from .models import Passkey
from .serializer import PasskeySerializer
from ...views import FlashMessageMixin
class PasskeyViewSet(AuthMixin, FlashMessageMixin, ModelViewSet):
serializer_class = PasskeySerializer
permission_classes = (IsAuthenticated,)
def get_queryset(self):
return Passkey.objects.filter(user=self.request.user)
@action(methods=['get', 'post'], detail=False, url_path='register')
def register(self, request):
if request.method == 'GET':
register_data, state = register_begin(request)
return JsonResponse(dict(register_data))
else:
passkey = register_complete(request)
return JsonResponse({'id': passkey.id.__str__(), 'name': passkey.name})
@action(methods=['get'], detail=False, url_path='login', permission_classes=[AllowAny])
def login(self, request):
return render(request, 'authentication/passkey.html', {})
def redirect_to_error(self, error):
self.send_auth_signal(success=False, username='unknown', reason='passkey')
return render(self.request, 'authentication/passkey.html', {'error': error})
@action(methods=['get', 'post'], detail=False, url_path='auth', permission_classes=[AllowAny])
def auth(self, request):
if request.method == 'GET':
auth_data = auth_begin(request)
return JsonResponse(dict(auth_data))
try:
user = auth_complete(request)
except ValueError as e:
return self.redirect_to_error(str(e))
if not user:
return self.redirect_to_error(_('Auth failed'))
try:
self.check_oauth2_auth(user, settings.AUTH_BACKEND_PASSKEY)
return self.redirect_to_guard_view()
except Exception as e:
msg = getattr(e, 'msg', '') or str(e)
return self.redirect_to_error(msg)

View File

@@ -0,0 +1,9 @@
from django.conf import settings
from ..base import JMSModelBackend
class PasskeyAuthBackend(JMSModelBackend):
@staticmethod
def is_enabled():
return settings.AUTH_PASSKEY

View File

@@ -0,0 +1,157 @@
import json
from urllib.parse import urlparse
import fido2.features
from django.conf import settings
from django.utils import timezone
from django.utils.translation import gettext as _
from fido2.server import Fido2Server
from fido2.utils import websafe_decode, websafe_encode
from fido2.webauthn import PublicKeyCredentialRpEntity, AttestedCredentialData, PublicKeyCredentialUserEntity
from rest_framework.serializers import ValidationError
from user_agents.parsers import parse as ua_parse
from common.utils import get_logger
from .models import Passkey
logger = get_logger(__name__)
try:
fido2.features.webauthn_json_mapping.enabled = True
except:
pass
def get_current_platform(request):
ua = ua_parse(request.META["HTTP_USER_AGENT"])
if 'Safari' in ua.browser.family:
return "Apple"
elif 'Chrome' in ua.browser.family and ua.os.family == "Mac OS X":
return "Chrome on Apple"
elif 'Android' in ua.os.family:
return "Google"
elif "Windows" in ua.os.family:
return "Microsoft"
else:
return "Key"
def get_server_id_from_request(request, allowed=()):
origin = request.META.get('HTTP_REFERER')
if not origin:
origin = request.get_host()
p = urlparse(origin)
if p.netloc in allowed or p.hostname in allowed:
return p.hostname
else:
return 'localhost'
def default_server_id(request):
domains = list(settings.ALLOWED_DOMAINS)
if settings.SITE_URL:
domains.append(urlparse(settings.SITE_URL).hostname)
return get_server_id_from_request(request, allowed=domains)
def get_server(request=None):
"""Get Server Info from settings and returns a Fido2Server"""
server_id = settings.FIDO_SERVER_ID or default_server_id(request)
if callable(server_id):
fido_server_id = settings.FIDO_SERVER_ID(request)
elif ',' in server_id:
fido_server_id = get_server_id_from_request(request, allowed=server_id.split(','))
else:
fido_server_id = server_id
logger.debug('Fido server id: {}'.format(fido_server_id))
if callable(settings.FIDO_SERVER_NAME):
fido_server_name = settings.FIDO_SERVER_NAME(request)
else:
fido_server_name = settings.FIDO_SERVER_NAME
rp = PublicKeyCredentialRpEntity(id=fido_server_id, name=fido_server_name)
return Fido2Server(rp)
def get_user_credentials(username):
user_passkeys = Passkey.objects.filter(user__username=username)
return [AttestedCredentialData(websafe_decode(uk.token)) for uk in user_passkeys]
def register_begin(request):
server = get_server(request)
user = request.user
user_credentials = get_user_credentials(user.username)
prefix = request.query_params.get('name', '')
prefix = '(' + prefix + ')'
user_entity = PublicKeyCredentialUserEntity(
id=str(user.id).encode('utf8'),
name=user.username + prefix,
display_name=user.name,
)
auth_attachment = getattr(settings, 'KEY_ATTACHMENT', None)
data, state = server.register_begin(
user_entity, user_credentials,
authenticator_attachment=auth_attachment,
resident_key_requirement=fido2.webauthn.ResidentKeyRequirement.PREFERRED
)
request.session['fido2_state'] = state
data = dict(data)
return data, state
def register_complete(request):
if not request.session.get("fido2_state"):
raise ValidationError("No state found")
data = request.data
server = get_server(request)
state = request.session.pop("fido2_state")
auth_data = server.register_complete(state, response=data)
encoded = websafe_encode(auth_data.credential_data)
platform = get_current_platform(request)
name = data.pop("key_name", '') or platform
passkey = Passkey.objects.create(
user=request.user,
token=encoded,
name=name,
platform=platform,
credential_id=data.get('id')
)
return passkey
def auth_begin(request):
server = get_server(request)
credentials = []
username = None
if request.user.is_authenticated:
username = request.user.username
if username:
credentials = get_user_credentials(username)
auth_data, state = server.authenticate_begin(credentials)
request.session['fido2_state'] = state
return auth_data
def auth_complete(request):
server = get_server(request)
data = request.data.get("passkeys")
data = json.loads(data)
cid = data['id']
key = Passkey.objects.filter(credential_id=cid, is_active=True).first()
if not key:
raise ValueError(_("This key is not registered"))
credentials = [AttestedCredentialData(websafe_decode(key.token))]
state = request.session.get('fido2_state')
server.authenticate_complete(state, credentials=credentials, response=data)
request.session["passkey"] = '{}_{}'.format(key.id, key.name)
key.date_last_used = timezone.now()
key.save(update_fields=['date_last_used'])
return key.user

View File

@@ -0,0 +1,19 @@
from django.conf import settings
from django.db import models
from django.utils.translation import gettext_lazy as _
from common.db.models import JMSBaseModel
class Passkey(JMSBaseModel):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
name = models.CharField(max_length=255, verbose_name=_("Name"))
is_active = models.BooleanField(default=True, verbose_name=_("Enabled"))
platform = models.CharField(max_length=255, default='', verbose_name=_("Platform"))
added_on = models.DateTimeField(auto_now_add=True, verbose_name=_("Added on"))
date_last_used = models.DateTimeField(null=True, default=None, verbose_name=_("Date last used"))
credential_id = models.CharField(max_length=255, unique=True, null=False, verbose_name=_("Credential ID"))
token = models.CharField(max_length=255, null=False, verbose_name=_("Token"))
def __str__(self):
return self.name

View File

@@ -0,0 +1,13 @@
from rest_framework import serializers
from .models import Passkey
class PasskeySerializer(serializers.ModelSerializer):
class Meta:
model = Passkey
fields = [
'id', 'name', 'is_active', 'platform', 'created_by',
'date_last_used', 'date_created',
]
read_only_fields = list(set(fields) - {'is_active'})

View File

@@ -0,0 +1,9 @@
from rest_framework.routers import DefaultRouter
from . import api
router = DefaultRouter()
router.register('passkeys', api.PasskeyViewSet, 'passkey')
urlpatterns = []
urlpatterns += router.urls

View File

@@ -1,8 +1,9 @@
# -*- coding: utf-8 -*-
#
from django.contrib.auth import get_user_model
from django.conf import settings
from django.contrib.auth import get_user_model
from common.permissions import ServiceAccountSignaturePermission
from .base import JMSBaseAuthBackend
UserModel = get_user_model()
@@ -18,6 +19,10 @@ class PublicKeyAuthBackend(JMSBaseAuthBackend):
def authenticate(self, request, username=None, public_key=None, **kwargs):
if not public_key:
return None
permission = ServiceAccountSignaturePermission()
if not permission.has_permission(request, None):
return None
if username is None:
username = kwargs.get(UserModel.USERNAME_FIELD)
try:
@@ -26,7 +31,7 @@ class PublicKeyAuthBackend(JMSBaseAuthBackend):
return None
else:
if user.check_public_key(public_key) and \
self.user_can_authenticate(user):
self.user_can_authenticate(user):
return user
def get_user(self, user_id):

View File

@@ -146,7 +146,9 @@ class PrepareRequestMixin:
},
'singleLogoutService': {
'url': f"{sp_host}{reverse('authentication:saml2:saml2-logout')}"
}
},
'privateKey': getattr(settings, 'SAML2_SP_KEY_CONTENT', ''),
'x509cert': getattr(settings, 'SAML2_SP_CERT_CONTENT', ''),
}
}
sp_settings['sp'].update(attrs)

View File

@@ -0,0 +1,39 @@
# Generated by Django 4.1.10 on 2023-09-08 08:10
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('authentication', '0021_auto_20230713_1459'),
]
operations = [
migrations.CreateModel(
name='Passkey',
fields=[
('created_by', models.CharField(blank=True, max_length=128, null=True, verbose_name='Created by')),
('updated_by', models.CharField(blank=True, max_length=128, null=True, verbose_name='Updated by')),
('date_created', models.DateTimeField(auto_now_add=True, null=True, verbose_name='Date created')),
('date_updated', models.DateTimeField(auto_now=True, verbose_name='Date updated')),
('comment', models.TextField(blank=True, default='', verbose_name='Comment')),
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
('name', models.CharField(max_length=255, verbose_name='Name')),
('is_active', models.BooleanField(default=True, verbose_name='Enabled')),
('platform', models.CharField(default='', max_length=255, verbose_name='Platform')),
('added_on', models.DateTimeField(auto_now_add=True, verbose_name='Added on')),
('date_last_used', models.DateTimeField(default=None, null=True, verbose_name='Date last used')),
('credential_id', models.CharField(max_length=255, unique=True, verbose_name='Credential ID')),
('token', models.CharField(max_length=255, verbose_name='Token')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
),
]

View File

@@ -132,11 +132,11 @@ class CommonMixin:
return user
user_id = self.request.session.get('user_id')
auth_password = self.request.session.get('auth_password')
auth_ok = self.request.session.get('auth_password')
auth_expired_at = self.request.session.get('auth_password_expired_at')
auth_expired = auth_expired_at < time.time() if auth_expired_at else False
if not user_id or not auth_password or auth_expired:
if not user_id or not auth_ok or auth_expired:
raise errors.SessionEmptyError()
user = get_object_or_404(User, pk=user_id)
@@ -479,6 +479,7 @@ class AuthMixin(CommonMixin, AuthPreCheckMixin, AuthACLMixin, MFAMixin, AuthPost
request.session['auto_login'] = auto_login
if not auth_backend:
auth_backend = getattr(user, 'backend', settings.AUTH_BACKEND_MODEL)
request.session['auth_backend'] = auth_backend
def check_oauth2_auth(self, user: User, auth_backend):
@@ -511,7 +512,8 @@ class AuthMixin(CommonMixin, AuthPreCheckMixin, AuthACLMixin, MFAMixin, AuthPost
def clear_auth_mark(self):
keys = [
'auth_password', 'user_id', 'auth_confirm_required', 'auth_ticket_id', 'auth_acl_id'
'auth_password', 'user_id', 'auth_confirm_required',
'auth_ticket_id', 'auth_acl_id'
]
for k in keys:
self.request.session.pop(k, '')

View File

@@ -7,6 +7,7 @@ from django.dispatch import receiver
from django_cas_ng.signals import cas_user_authenticated
from apps.jumpserver.settings.auth import AUTHENTICATION_BACKENDS_THIRD_PARTY
from audits.models import UserSession
from .signals import post_auth_success, post_auth_failed, user_auth_failed, user_auth_success
@@ -23,6 +24,9 @@ def on_user_auth_login_success(sender, user, request, **kwargs):
if not request.session.get("auth_third_party_done") and \
request.session.get('auth_backend') in AUTHENTICATION_BACKENDS_THIRD_PARTY:
request.session['auth_third_party_required'] = 1
user_session_id = request.session.get('user_session_id')
UserSession.objects.filter(id=user_session_id).update(key=request.session.session_key)
# 单点登录,超过了自动退出
if settings.USER_LOGIN_SINGLE_MACHINE_ENABLED:
lock_key = 'single_machine_login_' + str(user.id)
@@ -30,6 +34,7 @@ def on_user_auth_login_success(sender, user, request, **kwargs):
if session_key and session_key != request.session.session_key:
session = import_module(settings.SESSION_ENGINE).SessionStore(session_key)
session.delete()
UserSession.objects.filter(key=session_key).delete()
cache.set(lock_key, request.session.session_key, None)
# 标记登录,设置 cookie前端可以控制刷新, Middleware 会拦截这个生成 cookie

View File

@@ -223,10 +223,55 @@
height: 13px;
cursor: pointer;
}
.error-info {
font-size: 16px;
text-align: center;
}
.mobile-logo {
display: none;
}
@media (max-width: 768px) {
body {
background-color: #ffffff;
}
.login-content {
width: 100%;
}
.left-form-box {
width: 100%;
border-right: none;
}
.right-image-box {
display: none;
}
.navbar-top-links {
display: inline;
float: right;
}
.mobile-logo {
display: block;
padding: 20px 30px 0 30px;
}
}
</style>
</head>
<body>
{% if error_origin %}
<div class='alert alert-danger error-info'>
{% trans 'Configuration file has problems and cannot be logged in. Please contact the administrator or view latest docs' %}<br/>
{% trans 'If you are administrator, you can update the config resolve it, set' %} <br/>
DOMAINS={{ error_origin }}
</div>
{% endif %}
<div class="login-content extra-fields-{{ extra_fields_count }}">
<div class="right-image-box">
<a href="{% if not XPACK_ENABLED %}https://github.com/jumpserver/jumpserver.git{% endif %}">
@@ -234,6 +279,11 @@
</a>
</div>
<div class="left-form-box {% if not form.challenge and not form.captcha %} no-captcha-challenge {% endif %}">
<div class="mobile-logo">
<a href="{% if not XPACK_ENABLED %}https://github.com/jumpserver/jumpserver.git{% endif %}">
<img src="{% static 'img/logo_text_green.png' %}" class="right-image" alt="screen-image"/>
</a>
</div>
<div style="position: relative;top: 50%;transform: translateY(-50%);">
<div style='padding: 15px 60px; text-align: left'>
<h2 style='font-weight: 400;display: inline'>
@@ -294,13 +344,13 @@
{% endif %}
<div class="form-group auto-login" style="margin-bottom: 10px">
<div class="row" style="overflow: hidden;">
<div class="col-md-6" style="text-align: left">
<div class="col-md-6 col-xs-6" style="text-align: left">
{% if form.auto_login %}
{% bootstrap_field form.auto_login form_group_class='auto_login_box' %}
{% endif %}
</div>
<div class="col-md-6" style="line-height: 25px">
<div class="col-md-6 col-xs-6" style="line-height: 25px">
<a id="forgot_password" href="{{ forgot_password_url }}" style="float: right">
<small>{% trans 'Forgot password' %}?</small>
</a>

View File

@@ -0,0 +1,191 @@
{% load static %}
{% load i18n %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Login passkey</title>
<script src="{% static "js/jquery-3.6.1.min.js" %}?_=9"></script>
</head>
<body>
<form action='{% url 'api-auth:passkey-auth' %}' method="post" id="loginForm">
<input type="hidden" name="passkeys" id="passkeys"/>
</form>
</body>
<script>
const loginUrl = "/core/auth/login/";
window.conditionalUI = false;
window.conditionUIAbortController = new AbortController();
window.conditionUIAbortSignal = conditionUIAbortController.signal;
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'
// Use a lookup table to find the index.
const lookup = new Uint8Array(256)
for (let i = 0; i < chars.length; i++) {
lookup[chars.charCodeAt(i)] = i
}
const encode = function (arraybuffer) {
const bytes = new Uint8Array(arraybuffer)
let i;
const len = bytes.length;
let base64url = ''
for (i = 0; i < len; i += 3) {
base64url += chars[bytes[i] >> 2]
base64url += chars[((bytes[i] & 3) << 4) | (bytes[i + 1] >> 4)]
base64url += chars[((bytes[i + 1] & 15) << 2) | (bytes[i + 2] >> 6)]
base64url += chars[bytes[i + 2] & 63]
}
if ((len % 3) === 2) {
base64url = base64url.substring(0, base64url.length - 1)
} else if (len % 3 === 1) {
base64url = base64url.substring(0, base64url.length - 2)
}
return base64url
}
const decode = function (base64string) {
const bufferLength = base64string.length * 0.75
const len = base64string.length;
let i;
let p = 0
let encoded1;
let encoded2;
let encoded3;
let encoded4
const bytes = new Uint8Array(bufferLength)
for (i = 0; i < len; i += 4) {
encoded1 = lookup[base64string.charCodeAt(i)]
encoded2 = lookup[base64string.charCodeAt(i + 1)]
encoded3 = lookup[base64string.charCodeAt(i + 2)]
encoded4 = lookup[base64string.charCodeAt(i + 3)]
bytes[p++] = (encoded1 << 2) | (encoded2 >> 4)
bytes[p++] = ((encoded2 & 15) << 4) | (encoded3 >> 2)
bytes[p++] = ((encoded3 & 3) << 6) | (encoded4 & 63)
}
return bytes.buffer
}
function checkConditionalUI(form) {
if (!navigator.credentials) {
alert('WebAuthn is not supported in this browser')
return
}
if (window.PublicKeyCredential && PublicKeyCredential.isConditionalMediationAvailable) {
// Check if conditional mediation is available.
PublicKeyCredential.isConditionalMediationAvailable().then((result) => {
window.conditionalUI = result;
if (!window.conditionalUI) {
alert("Conditional UI is not available. Please use the legacy UI.");
} else {
return true
}
});
}
}
const publicKeyCredentialToJSON = (pubKeyCred) => {
if (pubKeyCred instanceof Array) {
const arr = []
for (const i of pubKeyCred) {
arr.push(publicKeyCredentialToJSON(i))
}
return arr
}
if (pubKeyCred instanceof ArrayBuffer) {
return encode(pubKeyCred)
}
if (pubKeyCred instanceof Object) {
const obj = {}
for (const key in pubKeyCred) {
obj[key] = publicKeyCredentialToJSON(pubKeyCred[key])
}
return obj
}
return pubKeyCred
}
function GetAssertReq(getAssert) {
getAssert.publicKey.challenge = decode(getAssert.publicKey.challenge)
for (const allowCred of getAssert.publicKey.allowCredentials) {
allowCred.id = decode(allowCred.id)
}
return getAssert
}
function startAuthn(form, conditionalUI = false) {
window.loginForm = form
fetch('/api/v1/authentication/passkeys/auth/', {method: 'GET'}).then(function (response) {
if (response.ok) {
return response.json().then(function (req) {
return GetAssertReq(req)
})
}
throw new Error('No credential available to authenticate!')
}).then(function (options) {
if (conditionalUI) {
options.mediation = 'conditional'
options.signal = window.conditionUIAbortSignal
} else {
window.conditionUIAbortController.abort()
}
return navigator.credentials.get(options)
}).then(function (assertion) {
const pk = $('#passkeys')
if (pk.length === 0) {
retry("Did you add the 'passkeys' hidden input field")
return
}
pk.val(JSON.stringify(publicKeyCredentialToJSON(assertion)))
const x = document.getElementById(window.loginForm)
if (x === null || x === undefined) {
console.error('Did you pass the correct form id to auth function')
return
}
x.submit()
}).catch(function (err) {
retry(err)
})
}
function safeStartAuthn(form) {
checkConditionalUI('loginForm')
const errorMsg = "{% trans 'This page is not served over HTTPS. Please use HTTPS to ensure security of your credentials.' %}"
const isSafe = window.location.protocol === 'https:'
if (!isSafe && location.hostname !== 'localhost') {
alert(errorMsg)
window.location.href = loginUrl
} else {
setTimeout(() => startAuthn('loginForm'), 100)
}
}
function retry(error) {
const fullError = "{% trans 'Error' %}" + ': ' + error + "\n\n " + "{% trans 'Do you want to retry ?' %}"
const result = confirm(fullError)
if (result) {
safeStartAuthn()
} else {
window.location.href = loginUrl
}
}
{% if not error %}
window.onload = function () {
safeStartAuthn()
}
{% else %}
const error = "{{ error }}"
retry(error)
{% endif %}
</script>
</html>

View File

@@ -4,6 +4,7 @@ from django.urls import path
from rest_framework.routers import DefaultRouter
from .. import api
from ..backends.passkey.urls import urlpatterns as passkey_urlpatterns
app_name = 'authentication'
router = DefaultRouter()
@@ -13,17 +14,19 @@ router.register('temp-tokens', api.TempTokenViewSet, 'temp-token')
router.register('connection-token', api.ConnectionTokenViewSet, 'connection-token')
router.register('super-connection-token', api.SuperConnectionTokenViewSet, 'super-connection-token')
urlpatterns = [
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'),
path('dingtalk/qr/unbind/', api.DingTalkQRUnBindForUserApi.as_view(), name='dingtalk-qr-unbind'),
path('dingtalk/qr/unbind/<uuid:user_id>/', api.DingTalkQRUnBindForAdminApi.as_view(), name='dingtalk-qr-unbind-for-admin'),
path('dingtalk/qr/unbind/<uuid:user_id>/', api.DingTalkQRUnBindForAdminApi.as_view(),
name='dingtalk-qr-unbind-for-admin'),
path('feishu/qr/unbind/', api.FeiShuQRUnBindForUserApi.as_view(), name='feishu-qr-unbind'),
path('feishu/qr/unbind/<uuid:user_id>/', api.FeiShuQRUnBindForAdminApi.as_view(), name='feishu-qr-unbind-for-admin'),
path('feishu/event/subscription/callback/', api.FeiShuEventSubscriptionCallback.as_view(), name='feishu-event-subscription-callback'),
path('feishu/qr/unbind/<uuid:user_id>/', api.FeiShuQRUnBindForAdminApi.as_view(),
name='feishu-qr-unbind-for-admin'),
path('feishu/event/subscription/callback/', api.FeiShuEventSubscriptionCallback.as_view(),
name='feishu-event-subscription-callback'),
path('auth/', api.TokenCreateApi.as_view(), name='user-auth'),
path('confirm/', api.ConfirmApi.as_view(), name='user-confirm'),
@@ -38,4 +41,4 @@ urlpatterns = [
path('login-confirm-ticket/status/', api.TicketStatusApi.as_view(), name='login-confirm-ticket-status'),
]
urlpatterns += router.urls
urlpatterns += router.urls + passkey_urlpatterns

View File

@@ -1,11 +1,11 @@
# coding:utf-8
#
from django.urls import path, include
from django.db.transaction import non_atomic_requests
from django.urls import path, include
from .. import views
from users import views as users_view
from .. import views
app_name = 'authentication'
@@ -18,7 +18,8 @@ urlpatterns = [
path('logout/', views.UserLogoutView.as_view(), name='logout'),
# 原来在users中的
path('password/forget/previewing/', users_view.UserForgotPasswordPreviewingView.as_view(), name='forgot-previewing'),
path('password/forget/previewing/', users_view.UserForgotPasswordPreviewingView.as_view(),
name='forgot-previewing'),
path('password/forgot/', users_view.UserForgotPasswordView.as_view(), name='forgot-password'),
path('password/reset/', users_view.UserResetPasswordView.as_view(), name='reset-password'),
path('password/verify/', users_view.UserVerifyPasswordView.as_view(), name='user-verify-password'),
@@ -26,7 +27,8 @@ urlpatterns = [
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/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('wecom/oauth/login/', views.WeComOAuthLoginView.as_view(), name='wecom-oauth-login'),
path('wecom/oauth/login/callback/', views.WeComOAuthLoginCallbackView.as_view(), name='wecom-oauth-login-callback'),
@@ -34,10 +36,12 @@ urlpatterns = [
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/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('dingtalk/oauth/login/', views.DingTalkOAuthLoginView.as_view(), name='dingtalk-oauth-login'),
path('dingtalk/oauth/login/callback/', views.DingTalkOAuthLoginCallbackView.as_view(), name='dingtalk-oauth-login-callback'),
path('dingtalk/oauth/login/callback/', views.DingTalkOAuthLoginCallbackView.as_view(),
name='dingtalk-oauth-login-callback'),
path('feishu/bind/start/', views.FeiShuEnableStartView.as_view(), name='feishu-bind-start'),
path('feishu/qr/bind/', views.FeiShuQRBindView.as_view(), name='feishu-qr-bind'),

View File

@@ -12,6 +12,7 @@ from authentication.mixins import AuthMixin
from common.utils import get_logger
from common.utils.django import reverse, get_object_or_none
from users.models import User
from users.signal_handlers import check_only_allow_exist_user_auth
from .mixins import FlashMessageMixin
logger = get_logger(__file__)
@@ -49,6 +50,11 @@ class BaseLoginCallbackView(AuthMixin, FlashMessageMixin, View):
user, create = User.objects.get_or_create(
username=user_attr['username'], defaults=user_attr
)
if not check_only_allow_exist_user_auth(create):
user.delete()
return user, (self.msg_client_err, self.request.error_message)
setattr(user, f'{self.user_type}_id', user_id)
if create:
setattr(user, 'source', self.user_type)

View File

@@ -6,6 +6,7 @@ from __future__ import unicode_literals
import datetime
import os
from typing import Callable
from urllib.parse import urlparse
from django.conf import settings
from django.contrib.auth import BACKEND_SESSION_KEY
@@ -40,6 +41,7 @@ __all__ = [
class UserLoginContextMixin:
get_user_mfa_context: Callable
request: HttpRequest
error_origin: str
def get_support_auth_methods(self):
auth_methods = [
@@ -88,6 +90,12 @@ class UserLoginContextMixin:
'enabled': settings.AUTH_FEISHU,
'url': reverse('authentication:feishu-qr-login'),
'logo': static('img/login_feishu_logo.png')
},
{
'name': _("Passkey"),
'enabled': settings.AUTH_PASSKEY,
'url': reverse('api-auth:passkey-login'),
'logo': static('img/login_passkey.png')
}
]
return [method for method in auth_methods if method['enabled']]
@@ -134,8 +142,27 @@ class UserLoginContextMixin:
count += 1
return count
def set_csrf_error_if_need(self, context):
if not self.request.GET.get('csrf_failure'):
return context
http_origin = self.request.META.get('HTTP_ORIGIN')
http_referer = self.request.META.get('HTTP_REFERER')
http_origin = http_origin or http_referer
if not http_origin:
return context
try:
origin = urlparse(http_origin)
context['error_origin'] = str(origin.netloc)
except ValueError:
pass
return context
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
self.set_csrf_error_if_need(context)
context.update({
'demo_mode': os.environ.get("DEMO_MODE"),
'auth_methods': self.get_support_auth_methods(),

View File

@@ -16,12 +16,19 @@ class METAMixin:
class FlashMessageMixin:
@staticmethod
def get_response(redirect_url, title, msg, m_type='message'):
message_data = {'title': title, 'interval': 5, 'redirect_url': redirect_url, m_type: msg}
def get_response(redirect_url='', title='', msg='', m_type='message', interval=5):
message_data = {
'title': title, 'interval': interval,
'redirect_url': redirect_url,
}
if m_type == 'error':
message_data['error'] = msg
else:
message_data['message'] = msg
return FlashMessageUtil.gen_and_redirect_to(message_data)
def get_success_response(self, redirect_url, title, msg):
return self.get_response(redirect_url, title, msg)
def get_success_response(self, redirect_url, title, msg, **kwargs):
return self.get_response(redirect_url, title, msg, m_type='success', **kwargs)
def get_failed_response(self, redirect_url, title, msg):
return self.get_response(redirect_url, title, msg, 'error')
def get_failed_response(self, redirect_url, title, msg, interval=10):
return self.get_response(redirect_url, title, msg, 'error', interval)

View File

@@ -1,6 +1,5 @@
from .action import *
from .common import *
from .filter import *
from .generic import *
from .mixin import *
from .patch import *

View File

@@ -1,92 +0,0 @@
# -*- coding: utf-8 -*-
#
import logging
from itertools import chain
from django.db import models
from rest_framework.settings import api_settings
from common.drf.filters import IDSpmFilter, CustomFilter, IDInFilter
__all__ = ['ExtraFilterFieldsMixin', 'OrderingFielderFieldsMixin']
logger = logging.getLogger('jumpserver.common')
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 OrderingFielderFieldsMixin:
"""
额外的 api ordering
"""
ordering_fields = None
extra_ordering_fields = []
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.ordering_fields = self._get_ordering_fields()
def _get_ordering_fields(self):
if isinstance(self.__class__.ordering_fields, (list, tuple)):
return self.__class__.ordering_fields
try:
valid_fields = self.get_valid_ordering_fields()
except Exception as e:
logger.debug('get_valid_ordering_fields error: %s' % e)
# 这里千万不要这么用,会让 logging 重复,至于为什么,我也不知道
# logging.debug('get_valid_ordering_fields error: %s' % e)
valid_fields = []
fields = list(chain(
valid_fields,
self.extra_ordering_fields
))
return fields
def get_valid_ordering_fields(self):
if getattr(self, 'model', None):
model = self.model
elif getattr(self, 'queryset', None):
model = self.queryset.model
else:
queryset = self.get_queryset()
model = queryset.model
if not model:
return []
excludes_fields = (
models.UUIDField, models.Model, models.ForeignKey,
models.FileField, models.JSONField, models.ManyToManyField,
models.DurationField,
)
valid_fields = []
for field in model._meta.fields:
if isinstance(field, excludes_fields):
continue
valid_fields.append(field.name)
return valid_fields

View File

@@ -1,19 +1,29 @@
# -*- coding: utf-8 -*-
#
from collections import defaultdict
from itertools import chain
from typing import Callable
from django.db import models
from django.db.models.signals import m2m_changed
from rest_framework.response import Response
from rest_framework.settings import api_settings
from common.drf.filters import (
IDSpmFilterBackend, CustomFilterBackend, IDInFilterBackend,
IDNotFilterBackend, NotOrRelFilterBackend
)
from common.utils import get_logger
from .action import RenderToJsonMixin
from .filter import ExtraFilterFieldsMixin, OrderingFielderFieldsMixin
from .serializer import SerializerMixin
__all__ = [
'CommonApiMixin', 'PaginatedResponseMixin', 'RelationMixin',
'ExtraFilterFieldsMixin',
]
logger = get_logger(__name__)
class PaginatedResponseMixin:
@@ -95,6 +105,100 @@ class QuerySetMixin:
return queryset
class CommonApiMixin(SerializerMixin, ExtraFilterFieldsMixin, OrderingFielderFieldsMixin,
QuerySetMixin, RenderToJsonMixin, PaginatedResponseMixin):
class ExtraFilterFieldsMixin:
"""
额外的 api filter
"""
default_added_filters = (
CustomFilterBackend, IDSpmFilterBackend, IDInFilterBackend,
IDNotFilterBackend,
)
filter_backends = api_settings.DEFAULT_FILTER_BACKENDS
extra_filter_fields = []
extra_filter_backends = []
def set_compatible_fields(self):
"""
兼容老的 filter_fields
"""
if not hasattr(self, 'filter_fields') and hasattr(self, 'filterset_fields'):
self.filter_fields = self.filterset_fields
def get_filter_backends(self):
self.set_compatible_fields()
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,
))
# 这个要放在最后
backends.append(NotOrRelFilterBackend)
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 OrderingFielderFieldsMixin:
"""
额外的 api ordering
"""
ordering_fields = None
extra_ordering_fields = []
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.ordering_fields = self._get_ordering_fields()
def _get_ordering_fields(self):
if isinstance(self.__class__.ordering_fields, (list, tuple)):
return self.__class__.ordering_fields
try:
valid_fields = self.get_valid_ordering_fields()
except Exception as e:
logger.debug('get_valid_ordering_fields error: %s, pass' % e)
# 这里千万不要这么用,会让 logging 重复,至于为什么,我也不知道
# logging.debug('get_valid_ordering_fields error: %s' % e)
valid_fields = []
fields = list(chain(
valid_fields,
self.extra_ordering_fields
))
return fields
def get_valid_ordering_fields(self):
if getattr(self, 'model', None):
model = self.model
elif getattr(self, 'queryset', None):
model = self.queryset.model
else:
queryset = self.get_queryset()
model = queryset.model
if not model:
return []
excludes_fields = (
models.UUIDField, models.Model, models.ForeignKey,
models.FileField, models.JSONField, models.ManyToManyField,
models.DurationField,
)
valid_fields = []
for field in model._meta.fields:
if isinstance(field, excludes_fields):
continue
valid_fields.append(field.name)
return valid_fields
class CommonApiMixin(
SerializerMixin, ExtraFilterFieldsMixin, OrderingFielderFieldsMixin,
QuerySetMixin, RenderToJsonMixin, PaginatedResponseMixin
):
pass

View File

@@ -88,6 +88,7 @@ class AsyncApiMixin(InterceptMixin):
if not self.is_need_async():
return handler(*args, **kwargs)
resp = self.do_async(handler, *args, **kwargs)
self.async_callback(*args, **kwargs)
return resp
def is_need_refresh(self):
@@ -98,6 +99,9 @@ class AsyncApiMixin(InterceptMixin):
def is_need_async(self):
return False
def async_callback(self, *args, **kwargs):
pass
def do_async(self, handler, *args, **kwargs):
data = self.get_cache_data()
if not data:

View File

@@ -13,17 +13,17 @@ from rest_framework.fields import DateTimeField
from rest_framework.serializers import ValidationError
from common import const
from common.db.fields import RelatedManager
logger = logging.getLogger('jumpserver.common')
__all__ = [
"DatetimeRangeFilter", "IDSpmFilter",
'IDInFilter', "CustomFilter",
"BaseFilterSet"
"DatetimeRangeFilterBackend", "IDSpmFilterBackend",
'IDInFilterBackend', "CustomFilterBackend",
"BaseFilterSet", 'IDNotFilterBackend',
'NotOrRelFilterBackend',
]
from common.db.fields import RelatedManager
class BaseFilterSet(drf_filters.FilterSet):
def do_nothing(self, queryset, name, value):
@@ -35,7 +35,7 @@ class BaseFilterSet(drf_filters.FilterSet):
return default
class DatetimeRangeFilter(filters.BaseFilterBackend):
class DatetimeRangeFilterBackend(filters.BaseFilterBackend):
def get_schema_fields(self, view):
ret = []
fields = self._get_date_range_filter_fields(view)
@@ -102,7 +102,7 @@ class DatetimeRangeFilter(filters.BaseFilterBackend):
return queryset
class IDSpmFilter(filters.BaseFilterBackend):
class IDSpmFilterBackend(filters.BaseFilterBackend):
def get_schema_fields(self, view):
return [
coreapi.Field(
@@ -130,7 +130,7 @@ class IDSpmFilter(filters.BaseFilterBackend):
return queryset
class IDInFilter(filters.BaseFilterBackend):
class IDInFilterBackend(filters.BaseFilterBackend):
def get_schema_fields(self, view):
return [
coreapi.Field(
@@ -149,7 +149,26 @@ class IDInFilter(filters.BaseFilterBackend):
return queryset
class CustomFilter(filters.BaseFilterBackend):
class IDNotFilterBackend(filters.BaseFilterBackend):
def get_schema_fields(self, view):
return [
coreapi.Field(
name='id!', location='query', required=False,
type='string', example='/api/v1/users/users?id!=1,2,3',
description='Exclude by id set'
)
]
def filter_queryset(self, request, queryset, view):
ids = request.query_params.get('id!')
if not ids:
return queryset
id_list = [i.strip() for i in ids.split(',')]
queryset = queryset.exclude(id__in=id_list)
return queryset
class CustomFilterBackend(filters.BaseFilterBackend):
def get_schema_fields(self, view):
fields = []
@@ -218,3 +237,25 @@ class AttrRulesFilterBackend(filters.BaseFilterBackend):
logger.debug('attr_rules: %s', attr_rules)
q = RelatedManager.get_to_filter_q(attr_rules, queryset.model)
return queryset.filter(q).distinct()
class NotOrRelFilterBackend(filters.BaseFilterBackend):
def get_schema_fields(self, view):
return [
coreapi.Field(
name='_rel', location='query', required=False,
type='string', example='/api/v1/users/users?name=abc&username=def&_rel=union',
description='Filter by rel, or not, default is and'
)
]
def filter_queryset(self, request, queryset, view):
_rel = request.query_params.get('_rel')
if not _rel or _rel not in ('or', 'not'):
return queryset
if _rel == 'not':
queryset.query.where.negated = True
elif _rel == 'or':
queryset.query.where.connector = 'OR'
queryset._result_cache = None
return queryset

View File

@@ -8,7 +8,8 @@ class PassthroughRenderer(renderers.BaseRenderer):
"""
Return data as-is. View should supply a Response.
"""
media_type = ''
media_type = 'application/octet-stream'
format = ''
def render(self, data, accepted_media_type=None, renderer_context=None):
return data

View File

@@ -0,0 +1,126 @@
import re
from django.conf import settings
from django.core.management.base import BaseCommand
from django.test import Client
from django.urls import URLPattern, URLResolver
from jumpserver.urls import api_v1
path_uuid_pattern = re.compile(r'<\w+:\w+>', re.IGNORECASE)
uuid_pattern = re.compile(r'\(\(\?P<.*>[^)]+\)/\)\?', re.IGNORECASE)
uuid2_pattern = re.compile(r'\(\?P<.*>\[\/\.\]\+\)', re.IGNORECASE)
uuid3_pattern = re.compile(r'\(\?P<.*>\[/\.]\+\)')
def list_urls(patterns, path=None):
""" recursive """
if not path:
path = []
result = []
for pattern in patterns:
if isinstance(pattern, URLPattern):
result.append(''.join(path) + str(pattern.pattern))
elif isinstance(pattern, URLResolver):
result += list_urls(pattern.url_patterns, path + [str(pattern.pattern)])
return result
def parse_to_url(url):
uid = '00000000-0000-0000-0000-000000000000'
url = url.replace('^', '')
url = url.replace('?$', '')
url = url.replace('(?P<format>[a-z0-9]+)', '')
url = url.replace('((?P<terminal>[/.]{36})/)?', uid + '/')
url = url.replace('(?P<pk>[/.]+)', uid)
url = url.replace('\.', '')
url = url.replace('//', '/')
url = url.strip('$')
url = re.sub(path_uuid_pattern, uid, url)
url = re.sub(uuid2_pattern, uid, url)
url = re.sub(uuid_pattern, uid + '/', url)
url = re.sub(uuid3_pattern, uid, url)
url = url.replace('(00000000-0000-0000-0000-000000000000/)?', uid + '/')
return url
def get_api_urls():
urls = []
api_urls = list_urls(api_v1)
for ourl in api_urls:
url = parse_to_url(ourl)
if 'render-to-json' in url:
continue
url = '/api/v1/' + url
urls.append((url, ourl))
return set(urls)
known_unauth_urls = [
"/api/v1/authentication/passkeys/auth/",
"/api/v1/prometheus/metrics/",
"/api/v1/authentication/auth/",
"/api/v1/settings/logo/",
"/api/v1/settings/public/open/",
"/api/v1/authentication/passkeys/login/",
"/api/v1/authentication/tokens/",
"/api/v1/authentication/mfa/challenge/",
"/api/v1/authentication/password/reset-code/",
"/api/v1/authentication/login-confirm-ticket/status/",
"/api/v1/authentication/mfa/select/",
"/api/v1/authentication/mfa/send-code/",
"/api/v1/authentication/sso/login/"
]
known_error_urls = [
'/api/v1/terminal/terminals/00000000-0000-0000-0000-000000000000/sessions/00000000-0000-0000-0000-000000000000/replay/download/',
'/api/v1/terminal/sessions/00000000-0000-0000-0000-000000000000/replay/download/',
]
errors = {}
class Command(BaseCommand):
help = 'Check api if unauthorized'
def handle(self, *args, **options):
settings.LOG_LEVEL = 'ERROR'
urls = get_api_urls()
client = Client()
unauth_urls = []
error_urls = []
unformat_urls = []
for url, ourl in urls:
if '(' in url or '<' in url:
unformat_urls.append([url, ourl])
continue
try:
response = client.get(url, follow=True)
if response.status_code != 401:
errors[url] = str(response.status_code) + ' ' + str(ourl)
unauth_urls.append(url)
except Exception as e:
errors[url] = str(e)
error_urls.append(url)
unauth_urls = set(unauth_urls) - set(known_unauth_urls)
print("\nUnauthorized urls:")
if not unauth_urls:
print(" Empty, very good!")
for url in unauth_urls:
print('"{}", {}'.format(url, errors.get(url, '')))
print("\nError urls:")
if not error_urls:
print(" Empty, very good!")
for url in set(error_urls):
print(url, ': ' + errors.get(url))
print("\nUnformat urls:")
if not unformat_urls:
print(" Empty, very good!")
for url in unformat_urls:
print(url)

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