Compare commits

...

121 Commits

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

* perf: 优化用户创建邮件

* perf: 优化时间日期

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

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

* perf: 修复url地址

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

* stash

* perf: 优化消息通知

* perf: 修改钉钉

* perf: 修改优化消息通知

* perf: 修改requirements

* perf: 优化datetime

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

* 添加分时登陆

* acl 部分还原

* 简化acl判断逻辑

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

* 登陆样式调整

* mfa bug

* q

* m

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

* perf: 添加翻译

* login css bug

* perf: 修改一些风格

* perf: 修改命名

* perf: 修改 mfa code 不是必填

* mfa 前端统一组件

* stash

* perf: 统一验证码

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

perf: 修改使用库

* fix: guangbug

* pref: 修改 context

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

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

* feat: 抽象API类

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

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

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

View File

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

2
.gitattributes vendored Normal file
View File

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

View File

@@ -8,7 +8,6 @@ WORKDIR /opt/jumpserver
ADD . .
RUN cd utils && bash -ixeu build.sh
# 构建运行时环境
FROM python:3.8.6-slim
ARG PIP_MIRROR=https://pypi.douban.com/simple
@@ -18,12 +17,12 @@ ENV PIP_JMS_MIRROR=$PIP_JMS_MIRROR
WORKDIR /opt/jumpserver
COPY ./requirements/deb_buster_requirements.txt ./requirements/deb_buster_requirements.txt
COPY ./requirements/deb_requirements.txt ./requirements/deb_requirements.txt
RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list \
&& sed -i 's/security.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list \
&& apt update \
&& apt -y install telnet iproute2 redis-tools \
&& grep -v '^#' ./requirements/deb_buster_requirements.txt | xargs apt -y install \
&& apt -y install telnet iproute2 redis-tools default-mysql-client vim wget curl locales procps \
&& apt -y install $(cat requirements/deb_requirements.txt) \
&& rm -rf /var/lib/apt/lists/* \
&& localedef -c -f UTF-8 -i zh_CN zh_CN.UTF-8 \
&& cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
@@ -32,21 +31,23 @@ RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list \
COPY ./requirements/requirements.txt ./requirements/requirements.txt
RUN pip install --upgrade pip==20.2.4 setuptools==49.6.0 wheel==0.34.2 -i ${PIP_MIRROR} \
&& pip config set global.index-url ${PIP_MIRROR} \
&& pip install --no-cache-dir $(grep -E 'jms|jumpserver' requirements/requirements.txt) -i ${PIP_JMS_MIRROR} \
&& pip install --no-cache-dir -r requirements/requirements.txt
&& pip install --no-cache-dir -r requirements/requirements.txt -i ${PIP_MIRROR} \
&& rm -rf ~/.cache/pip
COPY --from=stage-build /opt/jumpserver/release/jumpserver /opt/jumpserver
RUN mkdir -p /root/.ssh/ \
&& echo "Host *\n\tStrictHostKeyChecking no\n\tUserKnownHostsFile /dev/null" > /root/.ssh/config
RUN mkdir -p /opt/jumpserver/oracle/
ADD https://download.jumpserver.org/public/instantclient-basiclite-linux.x64-21.1.0.0.0.tar /opt/jumpserver/oracle/
RUN tar xvf /opt/jumpserver/oracle/instantclient-basiclite-linux.x64-21.1.0.0.0.tar -C /opt/jumpserver/oracle/
RUN sh -c "echo /opt/jumpserver/oracle/instantclient_21_1 > /etc/ld.so.conf.d/oracle-instantclient.conf"
RUN ldconfig
RUN mkdir -p /opt/jumpserver/oracle/ \
&& wget https://download.jumpserver.org/public/instantclient-basiclite-linux.x64-21.1.0.0.0.tar > /dev/null \
&& tar xf instantclient-basiclite-linux.x64-21.1.0.0.0.tar -C /opt/jumpserver/oracle/ \
&& echo "/opt/jumpserver/oracle/instantclient_21_1" > /etc/ld.so.conf.d/oracle-instantclient.conf \
&& ldconfig \
&& rm -f instantclient-basiclite-linux.x64-21.1.0.0.0.tar
RUN echo > config.yml
VOLUME /opt/jumpserver/data
VOLUME /opt/jumpserver/logs

View File

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

View File

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

View File

@@ -1,10 +1,9 @@
from django.shortcuts import get_object_or_404
from rest_framework.response import Response
from rest_framework.generics import CreateAPIView, RetrieveDestroyAPIView
from rest_framework.generics import CreateAPIView
from common.permissions import IsAppUser
from common.utils import reverse, lazyproperty
from orgs.utils import tmp_to_org, tmp_to_root_org
from orgs.utils import tmp_to_org
from tickets.api import GenericTicketStatusRetrieveCloseAPI
from ..models import LoginAssetACL
from .. import serializers

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

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

View File

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

View File

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

View File

@@ -37,6 +37,7 @@ class LoginAssetACL(BaseACL, OrgModelMixin):
class Meta:
unique_together = ('name', 'org_id')
ordering = ('priority', '-date_updated', 'name')
verbose_name = _('Login asset acl')
def __str__(self):
return self.name

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,7 @@ from rest_framework.decorators import action
from rest_framework.response import Response
from common.tree import TreeNodeSerializer
from common.mixins.views import SuggestionMixin
from common.mixins.api import SuggestionMixin
from ..hands import IsOrgAdminOrAppUser
from .. import serializers
from ..models import Application

View File

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

View File

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

View File

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

View File

@@ -180,6 +180,7 @@ class Application(CommonModelMixin, OrgModelMixin, ApplicationTreeNodeMixin):
)
class Meta:
verbose_name = _('Application')
unique_together = [('org_id', 'name')]
ordering = ('name',)

View File

@@ -104,7 +104,8 @@ class AppAccountSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
extra_kwargs = {
'username': {'default': '', 'required': False},
'password': {'write_only': True},
'app_display': {'label': _('Application display')}
'app_display': {'label': _('Application display')},
'systemuser_display': {'label': _('System User')}
}
use_model_bulk_create = True
model_bulk_create_kwargs = {
@@ -134,4 +135,6 @@ class AppAccountSecretSerializer(AppAccountSerializer):
'password': {'write_only': False},
'private_key': {'write_only': False},
'public_key': {'write_only': False},
'app_display': {'label': _('Application display')},
'systemuser_display': {'label': _('System User')}
}

View File

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

View File

@@ -2,12 +2,19 @@
#
from assets.api import FilterAssetByNodeMixin
from rest_framework.viewsets import ModelViewSet
from rest_framework.generics import RetrieveAPIView
from rest_framework.generics import RetrieveAPIView, ListAPIView
from django.shortcuts import get_object_or_404
from django.db.models import Q
from common.utils import get_logger, get_object_or_none
from common.permissions import IsOrgAdmin, IsOrgAdminOrAppUser, IsSuperUser
from common.mixins.views import SuggestionMixin
from common.mixins.api import SuggestionMixin
from users.models import User, UserGroup
from users.serializers import UserSerializer, UserGroupSerializer
from users.filters import UserFilter
from perms.models import AssetPermission
from perms.serializers import AssetPermissionSerializer
from perms.filters import AssetPermissionFilter
from orgs.mixins.api import OrgBulkModelViewSet
from orgs.mixins import generics
from ..models import Asset, Node, Platform
@@ -23,6 +30,8 @@ __all__ = [
'AssetViewSet', 'AssetPlatformRetrieveApi',
'AssetGatewayListApi', 'AssetPlatformViewSet',
'AssetTaskCreateApi', 'AssetsTaskCreateApi',
'AssetPermUserListApi', 'AssetPermUserPermissionsListApi',
'AssetPermUserGroupListApi', 'AssetPermUserGroupPermissionsListApi',
]
@@ -41,6 +50,7 @@ class AssetViewSet(SuggestionMixin, FilterAssetByNodeMixin, OrgBulkModelViewSet)
}
search_fields = ("hostname", "ip")
ordering_fields = ("hostname", "ip", "port", "cpu_cores")
ordering = ('hostname', )
serializer_classes = {
'default': serializers.AssetSerializer,
'suggestion': serializers.MiniAssetSerializer
@@ -170,3 +180,102 @@ class AssetGatewayListApi(generics.ListAPIView):
return []
queryset = asset.domain.gateways.filter(protocol='ssh')
return queryset
class BaseAssetPermUserOrUserGroupListApi(ListAPIView):
permission_classes = (IsOrgAdmin,)
def get_object(self):
asset_id = self.kwargs.get('pk')
asset = get_object_or_404(Asset, pk=asset_id)
return asset
def get_asset_related_perms(self):
asset = self.get_object()
nodes = asset.get_all_nodes(flat=True)
perms = AssetPermission.objects.filter(Q(assets=asset) | Q(nodes__in=nodes))
return perms
class AssetPermUserListApi(BaseAssetPermUserOrUserGroupListApi):
filterset_class = UserFilter
search_fields = ('username', 'email', 'name', 'id', 'source', 'role')
serializer_class = UserSerializer
def get_queryset(self):
perms = self.get_asset_related_perms()
users = User.objects.filter(
Q(assetpermissions__in=perms) | Q(groups__assetpermissions__in=perms)
).distinct()
return users
class AssetPermUserGroupListApi(BaseAssetPermUserOrUserGroupListApi):
serializer_class = UserGroupSerializer
def get_queryset(self):
perms = self.get_asset_related_perms()
user_groups = UserGroup.objects.filter(assetpermissions__in=perms).distinct()
return user_groups
class BaseAssetPermUserOrUserGroupPermissionsListApiMixin(generics.ListAPIView):
permission_classes = (IsOrgAdmin,)
model = AssetPermission
serializer_class = AssetPermissionSerializer
filterset_class = AssetPermissionFilter
search_fields = ('name',)
def get_object(self):
asset_id = self.kwargs.get('pk')
asset = get_object_or_404(Asset, pk=asset_id)
return asset
def filter_asset_related(self, queryset):
asset = self.get_object()
nodes = asset.get_all_nodes(flat=True)
perms = queryset.filter(Q(assets=asset) | Q(nodes__in=nodes))
return perms
def filter_queryset(self, queryset):
queryset = super().filter_queryset(queryset)
queryset = self.filter_asset_related(queryset)
return queryset
class AssetPermUserPermissionsListApi(BaseAssetPermUserOrUserGroupPermissionsListApiMixin):
def filter_queryset(self, queryset):
queryset = super().filter_queryset(queryset)
queryset = self.filter_user_related(queryset)
queryset = queryset.distinct()
return queryset
def filter_user_related(self, queryset):
user = self.get_perm_user()
user_groups = user.groups.all()
perms = queryset.filter(Q(users=user) | Q(user_groups__in=user_groups))
return perms
def get_perm_user(self):
user_id = self.kwargs.get('perm_user_id')
user = get_object_or_404(User, pk=user_id)
return user
class AssetPermUserGroupPermissionsListApi(BaseAssetPermUserOrUserGroupPermissionsListApiMixin):
def filter_queryset(self, queryset):
queryset = super().filter_queryset(queryset)
queryset = self.filter_user_group_related(queryset)
queryset = queryset.distinct()
return queryset
def filter_user_group_related(self, queryset):
user_group = self.get_perm_user_group()
perms = queryset.filter(user_groups=user_group)
return perms
def get_perm_user_group(self):
user_group_id = self.kwargs.get('perm_user_group_id')
user_group = get_object_or_404(UserGroup, pk=user_group_id)
return user_group

View File

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

View File

@@ -6,7 +6,7 @@ from common.utils import get_logger
from common.permissions import IsOrgAdmin, IsOrgAdminOrAppUser, IsValidUser
from orgs.mixins.api import OrgBulkModelViewSet
from orgs.mixins import generics
from common.mixins.views import SuggestionMixin
from common.mixins.api import SuggestionMixin
from orgs.utils import tmp_to_root_org
from ..models import SystemUser, Asset
from .. import serializers
@@ -16,7 +16,6 @@ from ..tasks import (
push_system_user_to_assets
)
logger = get_logger(__file__)
__all__ = [
'SystemUserViewSet', 'SystemUserAuthInfoApi', 'SystemUserAssetAuthInfoApi',
@@ -42,6 +41,8 @@ class SystemUserViewSet(SuggestionMixin, OrgBulkModelViewSet):
'default': serializers.SystemUserSerializer,
'suggestion': serializers.MiniSystemUserSerializer
}
ordering_fields = ('name', 'protocol', 'login_mode')
ordering = ('name', )
permission_classes = (IsOrgAdminOrAppUser,)
@@ -74,7 +75,7 @@ class SystemUserTempAuthInfoApi(generics.CreateAPIView):
with tmp_to_root_org():
instance = get_object_or_404(SystemUser, pk=pk)
instance.set_temp_auth(instance_id, user, data)
instance.set_temp_auth(instance_id, user.id, data)
return Response(serializer.data, status=201)
@@ -91,7 +92,7 @@ class SystemUserAssetAuthInfoApi(generics.RetrieveAPIView):
asset_id = self.kwargs.get('asset_id')
user_id = self.request.query_params.get("user_id")
username = self.request.query_params.get("username")
instance.load_asset_more_auth(asset_id=asset_id, user_id=user_id, username=username)
instance.load_asset_more_auth(asset_id, username, user_id)
return instance
@@ -107,8 +108,7 @@ class SystemUserAppAuthInfoApi(generics.RetrieveAPIView):
instance = super().get_object()
app_id = self.kwargs.get('app_id')
user_id = self.request.query_params.get("user_id")
if user_id:
instance.load_app_more_auth(app_id, user_id)
instance.load_app_more_auth(app_id, user_id)
return instance

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,12 +5,14 @@ from django.core.validators import RegexValidator
from django.utils.translation import ugettext_lazy as _
from orgs.mixins.serializers import BulkOrgResourceModelSerializer
from users.models import User, UserGroup
from perms.models import AssetPermission
from ..models import Asset, Node, Platform, SystemUser
__all__ = [
'AssetSerializer', 'AssetSimpleSerializer', 'MiniAssetSerializer',
'ProtocolsField', 'PlatformSerializer',
'AssetTaskSerializer', 'AssetsTaskSerializer', 'ProtocolsField'
'AssetTaskSerializer', 'AssetsTaskSerializer', 'ProtocolsField',
]
@@ -74,11 +76,11 @@ class AssetSerializer(BulkOrgResourceModelSerializer):
model = Asset
fields_mini = ['id', 'hostname', 'ip', 'platform', 'protocols']
fields_small = fields_mini + [
'protocol', 'port', 'protocols', 'is_active', 'public_ip',
'comment',
'protocol', 'port', 'protocols', 'is_active',
'public_ip', 'number', 'comment',
]
hardware_fields = [
'number', 'vendor', 'model', 'sn', 'cpu_model', 'cpu_count',
'vendor', 'model', 'sn', 'cpu_model', 'cpu_count',
'cpu_cores', 'cpu_vcpus', 'memory', 'disk_total', 'disk_info',
'os', 'os_version', 'os_arch', 'hostname_raw', 'hardware_info',
'connectivity', 'date_verified'

View File

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

View File

@@ -4,6 +4,7 @@ from django.db.models import Count
from common.mixins.serializers import BulkSerializerMixin
from common.utils import ssh_pubkey_gen
from common.validators import alphanumeric_re, alphanumeric_cn_re
from orgs.mixins.serializers import BulkOrgResourceModelSerializer
from ..models import SystemUser, Asset
from .utils import validate_password_contains_left_double_curly_bracket
@@ -25,6 +26,9 @@ class SystemUserSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
auto_generate_key = serializers.BooleanField(initial=True, required=False, write_only=True)
type_display = serializers.ReadOnlyField(source='get_type_display', label=_('Type display'))
ssh_key_fingerprint = serializers.ReadOnlyField(label=_('SSH key fingerprint'))
applications_amount = serializers.IntegerField(
source='apps_amount', read_only=True, label=_('Apps amount')
)
class Meta:
model = SystemUser
@@ -38,7 +42,7 @@ class SystemUserSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
'username_same_with_user', 'auto_push', 'auto_generate_key',
'date_created', 'date_updated', 'comment', 'created_by',
]
fields_m2m = ['cmd_filters', 'assets_amount', 'nodes']
fields_m2m = ['cmd_filters', 'assets_amount', 'applications_amount', 'nodes']
fields = fields_small + fields_m2m
extra_kwargs = {
'password': {
@@ -97,15 +101,20 @@ class SystemUserSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
raise serializers.ValidationError(error)
def validate_username(self, username):
if username:
return username
login_mode = self.get_initial_value("login_mode")
protocol = self.get_initial_value("protocol")
username_same_with_user = self.get_initial_value("username_same_with_user")
if username:
regx = alphanumeric_re
if protocol == SystemUser.Protocol.telnet:
regx = alphanumeric_cn_re
if not regx.match(username):
raise serializers.ValidationError(_('Special char not allowed'))
return username
username_same_with_user = self.get_initial_value("username_same_with_user")
if username_same_with_user:
return ''
login_mode = self.get_initial_value("login_mode")
if login_mode == SystemUser.LOGIN_AUTO and protocol != SystemUser.Protocol.vnc:
msg = _('* Automatic login mode must fill in the username.')
raise serializers.ValidationError(msg)
@@ -117,7 +126,8 @@ class SystemUserSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
return ''
return home
def validate_sftp_root(self, value):
@staticmethod
def validate_sftp_root(value):
if value in ['home', 'tmp']:
return value
if not value.startswith('/'):
@@ -125,19 +135,6 @@ class SystemUserSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
raise serializers.ValidationError(error)
return value
def validate_admin_user(self, attrs):
if self.instance:
tp = self.instance.type
else:
tp = attrs.get('type')
if tp != SystemUser.Type.admin:
return attrs
attrs['protocol'] = SystemUser.Protocol.ssh
attrs['login_mode'] = SystemUser.LOGIN_AUTO
attrs['username_same_with_user'] = False
attrs['auto_push'] = False
return attrs
def validate_password(self, password):
super().validate_password(password)
auto_gen_key = self.get_initial_value("auto_generate_key", False)
@@ -149,7 +146,20 @@ class SystemUserSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
raise serializers.ValidationError(_("Password or private key required"))
return password
def validate_gen_key(self, attrs):
def _validate_admin_user(self, attrs):
if self.instance:
tp = self.instance.type
else:
tp = attrs.get('type')
if tp != SystemUser.Type.admin:
return attrs
attrs['protocol'] = SystemUser.Protocol.ssh
attrs['login_mode'] = SystemUser.LOGIN_AUTO
attrs['username_same_with_user'] = False
attrs['auto_push'] = False
return attrs
def _validate_gen_key(self, attrs):
username = attrs.get("username", "manual")
auto_gen_key = attrs.pop("auto_generate_key", False)
protocol = attrs.get("protocol")
@@ -173,15 +183,31 @@ class SystemUserSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
attrs["public_key"] = public_key
return attrs
def _validate_login_mode(self, attrs):
if 'login_mode' in attrs:
login_mode = attrs['login_mode']
else:
login_mode = self.instance.login_mode if self.instance else SystemUser.LOGIN_AUTO
if login_mode == SystemUser.LOGIN_MANUAL:
attrs['password'] = ''
attrs['private_key'] = ''
attrs['public_key'] = ''
return attrs
def validate(self, attrs):
attrs = self.validate_admin_user(attrs)
attrs = self.validate_gen_key(attrs)
attrs = self._validate_admin_user(attrs)
attrs = self._validate_gen_key(attrs)
attrs = self._validate_login_mode(attrs)
return attrs
@classmethod
def setup_eager_loading(cls, queryset):
""" Perform necessary eager loading of data. """
queryset = queryset.annotate(assets_amount=Count("assets"))
queryset = queryset\
.annotate(assets_amount=Count("assets")) \
.prefetch_related('nodes', 'cmd_filters')
return queryset

View File

@@ -34,9 +34,13 @@ def on_authbook_post_delete(sender, instance, **kwargs):
@receiver(post_save, sender=AuthBook)
def on_authbook_post_create(sender, instance, **kwargs):
def on_authbook_post_create(sender, instance, created, **kwargs):
instance.sync_to_system_user_account()
instance.update_asset_admin_user_if_need()
if created:
pass
# # 不再自动更新资产管理用户,只允许用户手动指定。
# 只在创建时进行更新资产的管理用户
# instance.update_asset_admin_user_if_need()
@receiver(pre_save, sender=AuthBook)

View File

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

View File

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

View File

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

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

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

View File

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

View File

@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
#
from django.db.models.signals import (
post_save, post_delete, m2m_changed, pre_delete
post_save, m2m_changed, pre_delete
)
from django.dispatch import receiver
from django.conf import settings
@@ -14,25 +14,25 @@ from rest_framework.renderers import JSONRenderer
from rest_framework.request import Request
from assets.models import Asset, SystemUser
from common.const.signals import POST_ADD, POST_REMOVE, POST_CLEAR
from authentication.signals import post_auth_failed, post_auth_success
from authentication.utils import check_different_city_login
from jumpserver.utils import current_request
from common.utils import get_request_ip, get_logger, get_syslogger
from users.models import User
from users.signals import post_user_change_password
from authentication.signals import post_auth_failed, post_auth_success
from terminal.models import Session, Command
from common.utils.encode import model_to_json
from .utils import write_login_log
from . import models
from .models import OperateLog
from orgs.utils import current_org
from perms.models import AssetPermission, ApplicationPermission
from common.const.signals import POST_ADD, POST_REMOVE, POST_CLEAR
from common.utils import get_request_ip, get_logger, get_syslogger
from common.utils.encode import model_to_json
logger = get_logger(__name__)
sys_logger = get_syslogger(__name__)
json_render = JSONRenderer()
MODELS_NEED_RECORD = (
# users
'User', 'UserGroup',
@@ -165,7 +165,6 @@ M2M_NEED_RECORD = {
),
}
M2M_ACTION = {
POST_ADD: 'add',
POST_REMOVE: 'remove',
@@ -305,6 +304,7 @@ def generate_data(username, request, login_type=None):
@receiver(post_auth_success)
def on_user_auth_success(sender, user, request, login_type=None, **kwargs):
logger.debug('User login success: {}'.format(user.username))
check_different_city_login(user, request)
data = generate_data(user.username, request, login_type=login_type)
data.update({'mfa': int(user.mfa_enabled), 'status': True})
write_login_log(**data)

View File

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

View File

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

View File

@@ -8,29 +8,12 @@ from django.shortcuts import get_object_or_404
from common.utils import get_logger
from common.permissions import IsOrgAdmin
from ..models import LoginConfirmSetting
from ..serializers import LoginConfirmSettingSerializer
from .. import errors, mixins
__all__ = ['LoginConfirmSettingUpdateApi', 'TicketStatusApi']
__all__ = ['TicketStatusApi']
logger = get_logger(__name__)
class LoginConfirmSettingUpdateApi(UpdateAPIView):
permission_classes = (IsOrgAdmin,)
serializer_class = LoginConfirmSettingSerializer
def get_object(self):
from users.models import User
user_id = self.kwargs.get('user_id')
user = get_object_or_404(User, pk=user_id)
defaults = {'user': user}
s, created = LoginConfirmSetting.objects.get_or_create(
defaults, user=user,
)
return s
class TicketStatusApi(mixins.AuthMixin, APIView):
permission_classes = (AllowAny,)

View File

@@ -2,17 +2,17 @@
#
import builtins
import time
from django.utils.translation import ugettext as _
from django.conf import settings
from django.shortcuts import get_object_or_404
from rest_framework.permissions import AllowAny
from rest_framework.generics import CreateAPIView
from rest_framework.serializers import ValidationError
from rest_framework.response import Response
from authentication.sms_verify_code import VerifyCodeUtil
from common.exceptions import JMSException
from common.permissions import IsValidUser, NeedMFAVerify, IsAppUser
from users.models.user import MFAType
from common.permissions import IsValidUser, NeedMFAVerify
from users.models.user import MFAType, User
from ..serializers import OtpVerifySerializer
from .. import serializers
from .. import errors
@@ -90,6 +90,13 @@ class SendSMSVerifyCodeApi(AuthMixin, CreateAPIView):
permission_classes = (AllowAny,)
def create(self, request, *args, **kwargs):
user = self.get_user_from_session()
username = request.data.get('username', '')
username = username.strip()
if username:
user = get_object_or_404(User, username=username)
else:
user = self.get_user_from_session()
if not user.mfa_enabled:
raise errors.NotEnableMFAError
timeout = user.send_sms_code()
return Response({'code': 'ok','timeout': timeout})
return Response({'code': 'ok', 'timeout': timeout})

View File

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

View File

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

View File

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

View File

@@ -43,7 +43,7 @@ class UserLoginForm(forms.Form):
class UserCheckOtpCodeForm(forms.Form):
code = forms.CharField(label=_('MFA Code'), max_length=6)
code = forms.CharField(label=_('MFA Code'), max_length=6, required=False)
mfa_type = forms.CharField(label=_('MFA type'), max_length=6)
@@ -59,7 +59,7 @@ class ChallengeMixin(forms.Form):
challenge = forms.CharField(
label=_('MFA code'), max_length=6, required=False,
widget=forms.TextInput(attrs={
'placeholder': _("MFA code"),
'placeholder': _("Dynamic code"),
'style': 'width: 50%'
})
)
@@ -69,6 +69,8 @@ def get_user_login_form_cls(*, captcha=False):
bases = []
if settings.SECURITY_LOGIN_CHALLENGE_ENABLED:
bases.append(ChallengeMixin)
elif settings.SECURITY_MFA_IN_LOGIN_PAGE:
bases.append(UserCheckOtpCodeForm)
elif settings.SECURITY_LOGIN_CAPTCHA_ENABLED and captcha:
bases.append(CaptchaMixin)
bases.append(UserLoginForm)

View File

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

View File

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

View File

@@ -1,13 +1,10 @@
import uuid
from django.utils import timezone
from django.utils.translation import ugettext_lazy as _, ugettext as __
from django.utils.translation import ugettext_lazy as _
from rest_framework.authtoken.models import Token
from django.conf import settings
from common.db import models
from common.mixins.models import CommonModelMixin
from common.utils import get_object_or_none, get_request_ip, get_ip_city
class AccessKey(models.Model):
@@ -40,56 +37,6 @@ class PrivateToken(Token):
verbose_name = _('Private Token')
class LoginConfirmSetting(CommonModelMixin):
user = models.OneToOneField('users.User', on_delete=models.CASCADE, verbose_name=_("User"), related_name="login_confirm_setting")
reviewers = models.ManyToManyField('users.User', verbose_name=_("Reviewers"), related_name="review_login_confirm_settings", blank=True)
is_active = models.BooleanField(default=True, verbose_name=_("Is active"))
class Meta:
verbose_name = _('Login Confirm')
@classmethod
def get_user_confirm_setting(cls, user):
return get_object_or_none(cls, user=user)
@staticmethod
def construct_confirm_ticket_meta(request=None):
if request:
login_ip = get_request_ip(request)
else:
login_ip = ''
login_ip = login_ip or '0.0.0.0'
login_city = get_ip_city(login_ip)
login_datetime = timezone.now().strftime('%Y-%m-%d %H:%M:%S')
ticket_meta = {
'apply_login_ip': login_ip,
'apply_login_city': login_city,
'apply_login_datetime': login_datetime,
}
return ticket_meta
def create_confirm_ticket(self, request=None):
from tickets import const
from tickets.models import Ticket
from orgs.models import Organization
ticket_title = _('Login confirm') + ' {}'.format(self.user)
ticket_meta = self.construct_confirm_ticket_meta(request)
data = {
'title': ticket_title,
'type': const.TicketType.login_confirm.value,
'meta': ticket_meta,
'org_id': Organization.ROOT_ID,
}
ticket = Ticket.objects.create(**data)
ticket.create_process_map_and_node(self.reviewers.all())
ticket.open(self.user)
return ticket
def __str__(self):
reviewers = [u.username for u in self.reviewers.all()]
return _('{} need confirm by {}').format(self.user.username, reviewers)
class SSOToken(models.JMSBaseModel):
"""
类似腾讯企业邮的 [单点登录](https://exmail.qq.com/qy_mng_logic/doc#10036)

View File

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

View File

@@ -10,12 +10,11 @@ from applications.models import Application
from users.serializers import UserProfileSerializer
from assets.serializers import ProtocolsField
from perms.serializers.asset.permission import ActionsField
from .models import AccessKey, LoginConfirmSetting
from .models import AccessKey
__all__ = [
'AccessKeySerializer', 'OtpVerifySerializer', 'BearerTokenSerializer',
'MFAChallengeSerializer', 'LoginConfirmSettingSerializer', 'SSOTokenSerializer',
'MFAChallengeSerializer', 'SSOTokenSerializer',
'ConnectionTokenSerializer', 'ConnectionTokenSecretSerializer',
'PasswordVerifySerializer', 'MFASelectTypeSerializer',
]
@@ -92,13 +91,6 @@ class MFAChallengeSerializer(serializers.Serializer):
pass
class LoginConfirmSettingSerializer(serializers.ModelSerializer):
class Meta:
model = LoginConfirmSetting
fields = ['id', 'user', 'reviewers', 'date_created', 'date_updated']
read_only_fields = ['date_created', 'date_updated']
class SSOTokenSerializer(serializers.Serializer):
username = serializers.CharField(write_only=True)
login_url = serializers.CharField(read_only=True)
@@ -201,4 +193,3 @@ class ConnectionTokenSecretSerializer(serializers.Serializer):
gateway = ConnectionTokenGatewaySerializer(read_only=True)
actions = ActionsField()
expired_at = serializers.IntegerField()

View File

@@ -1,11 +1,9 @@
import random
from django.conf import settings
from django.core.cache import cache
from django.utils.translation import gettext_lazy as _
from common.message.backends.sms.alibaba import AlibabaSMS
from common.message.backends.sms import SMS
from common.sdk.sms import SMS
from common.utils import get_logger
from common.exceptions import JMSException

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,7 +13,6 @@ router.register('connection-token', api.UserConnectionTokenViewSet, 'connection-
urlpatterns = [
# path('token/', api.UserToken.as_view(), name='user-token'),
path('wecom/qr/unbind/', api.WeComQRUnBindForUserApi.as_view(), name='wecom-qr-unbind'),
path('wecom/qr/unbind/<uuid:user_id>/', api.WeComQRUnBindForAdminApi.as_view(), name='wecom-qr-unbind-for-admin'),
@@ -32,7 +31,6 @@ urlpatterns = [
path('sms/verify-code/send/', api.SendSMSVerifyCodeApi.as_view(), name='sms-verify-code-send'),
path('password/verify/', api.UserPasswordVerifyApi.as_view(), name='user-password-verify'),
path('login-confirm-ticket/status/', api.TicketStatusApi.as_view(), name='login-confirm-ticket-status'),
path('login-confirm-settings/<uuid:user_id>/', api.LoginConfirmSettingUpdateApi.as_view(), name='login-confirm-setting-update')
]
urlpatterns += router.urls

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ from __future__ import unicode_literals
import os
import datetime
from django.templatetags.static import static
from django.contrib.auth import login as auth_login, logout as auth_logout
from django.http import HttpResponse
from django.shortcuts import reverse, redirect
@@ -28,7 +29,6 @@ from ..const import RSA_PRIVATE_KEY, RSA_PUBLIC_KEY
from .. import mixins, errors
from ..forms import get_user_login_form_cls
__all__ = [
'UserLoginView', 'UserLogoutView',
'UserLoginGuardView', 'UserLoginWaitConfirmView',
@@ -66,6 +66,8 @@ class UserLoginView(mixins.AuthMixin, FormView):
return None
login_redirect = settings.LOGIN_REDIRECT_TO_BACKEND.lower()
if login_redirect in ['direct']:
return None
if login_redirect in ['cas'] and cas_auth_url:
auth_url = cas_auth_url
elif login_redirect in ['openid', 'oidc'] and openid_auth_url:
@@ -109,20 +111,32 @@ class UserLoginView(mixins.AuthMixin, FormView):
self.request.session.delete_test_cookie()
try:
with transaction.atomic():
self.check_user_auth(decrypt_passwd=True)
self.check_user_auth(decrypt_passwd=True)
except errors.AuthFailedError as e:
form.add_error(None, e.msg)
self.set_login_failed_mark()
form_cls = get_user_login_form_cls(captcha=True)
new_form = form_cls(data=form.data)
new_form._errors = form.errors
context = self.get_context_data(form=new_form)
self.request.session.set_test_cookie()
return self.render_to_response(context)
except (errors.PasswdTooSimple, errors.PasswordRequireResetError, errors.PasswdNeedUpdate) as e:
except (
errors.PasswdTooSimple,
errors.PasswordRequireResetError,
errors.PasswdNeedUpdate,
errors.OTPBindRequiredError
) as e:
return redirect(e.url)
except (
errors.MFAFailedError,
errors.BlockMFAError,
errors.OTPCodeRequiredError,
errors.SMSCodeRequiredError,
errors.UserPhoneNotSet
) as e:
form.add_error('code', e.msg)
return super().form_invalid(form)
self.clear_rsa_key()
return self.redirect_to_guard_view()
@@ -136,20 +150,56 @@ class UserLoginView(mixins.AuthMixin, FormView):
self.request.session[RSA_PRIVATE_KEY] = None
self.request.session[RSA_PUBLIC_KEY] = None
def get_context_data(self, **kwargs):
@staticmethod
def get_support_auth_methods():
auth_methods = [
{
'name': 'OpenID',
'enabled': settings.AUTH_OPENID,
'url': reverse('authentication:openid:login'),
'logo': static('img/login_oidc_logo.png')
},
{
'name': 'CAS',
'enabled': settings.AUTH_CAS,
'url': reverse('authentication:cas:cas-login'),
'logo': static('img/login_cas_logo.png')
},
{
'name': _('WeCom'),
'enabled': settings.AUTH_WECOM,
'url': reverse('authentication:wecom-qr-login'),
'logo': static('img/login_wecom_logo.png')
},
{
'name': _('DingTalk'),
'enabled': settings.AUTH_DINGTALK,
'url': reverse('authentication:dingtalk-qr-login'),
'logo': static('img/login_dingtalk_logo.png')
},
{
'name': _('FeiShu'),
'enabled': settings.AUTH_FEISHU,
'url': reverse('authentication:feishu-qr-login'),
'logo': static('img/login_feishu_logo.png')
}
]
return [method for method in auth_methods if method['enabled']]
@staticmethod
def get_forgot_password_url():
forgot_password_url = reverse('authentication:forgot-password')
has_other_auth_backend = settings.AUTHENTICATION_BACKENDS[0] != settings.AUTH_BACKEND_MODEL
if has_other_auth_backend and settings.FORGOT_PASSWORD_URL:
forgot_password_url = settings.FORGOT_PASSWORD_URL
return forgot_password_url
def get_context_data(self, **kwargs):
context = {
'demo_mode': os.environ.get("DEMO_MODE"),
'AUTH_OPENID': settings.AUTH_OPENID,
'AUTH_CAS': settings.AUTH_CAS,
'AUTH_WECOM': settings.AUTH_WECOM,
'AUTH_DINGTALK': settings.AUTH_DINGTALK,
'AUTH_FEISHU': settings.AUTH_FEISHU,
'forgot_password_url': forgot_password_url
'auth_methods': self.get_support_auth_methods(),
'forgot_password_url': self.get_forgot_password_url(),
'methods': self.get_user_mfa_methods(),
}
kwargs.update(context)
return super().get_context_data(**kwargs)
@@ -254,7 +304,7 @@ class UserLogoutView(TemplateView):
def get_context_data(self, **kwargs):
context = {
'title': _('Logout success'),
'messages': _('Logout success, return login page'),
'message': _('Logout success, return login page'),
'interval': 3,
'redirect_url': reverse('authentication:login'),
'auto_redirect': True,

View File

@@ -20,11 +20,11 @@ class UserLoginOtpView(mixins.AuthMixin, FormView):
redirect_field_name = 'next'
def form_valid(self, form):
otp_code = form.cleaned_data.get('code')
code = form.cleaned_data.get('code')
mfa_type = form.cleaned_data.get('mfa_type')
try:
self.check_user_mfa(otp_code, mfa_type)
self.check_user_mfa(code, mfa_type)
return redirect_to_guard_view()
except (errors.MFAFailedError, errors.BlockMFAError) as e:
form.add_error('code', e.msg)
@@ -32,31 +32,11 @@ class UserLoginOtpView(mixins.AuthMixin, FormView):
except Exception as e:
logger.error(e)
import traceback
traceback.print_exception(e)
traceback.print_exc()
return redirect_to_guard_view()
def get_context_data(self, **kwargs):
user = self.get_user_from_session()
context = {
'methods': [
{
'name': 'otp',
'label': _('One-time password'),
'enable': bool(user.otp_secret_key),
'selected': False,
},
{
'name': 'sms',
'label': _('SMS'),
'enable': bool(user.phone) and settings.SMS_ENABLED and settings.XPACK_ENABLED,
'selected': False,
},
]
}
for item in context['methods']:
if item['enable']:
item['selected'] = True
break
context.update(kwargs)
return context
methods = self.get_user_mfa_methods(user)
kwargs.update({'methods': methods})
return kwargs

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,37 @@
# -*- coding: utf-8 -*-
#
from django.contrib.auth import get_user_model
from rest_framework.request import Request
from common.utils import lazyproperty
__all__ = ['AllowBulkDestroyMixin', 'RoleAdminMixin', 'RoleUserMixin']
class AllowBulkDestroyMixin:
def allow_bulk_destroy(self, qs, filtered):
"""
我们规定,批量删除的情况必须用 `id` 指定要删除的数据。
"""
query = str(filtered.query)
return '`id` IN (' in query or '`id` =' in query
class RoleAdminMixin:
kwargs: dict
user_id_url_kwarg = 'pk'
@lazyproperty
def user(self):
user_id = self.kwargs.get(self.user_id_url_kwarg)
user_model = get_user_model()
return user_model.objects.get(id=user_id)
class RoleUserMixin:
request: Request
@lazyproperty
def user(self):
return self.request.user

View File

@@ -0,0 +1,14 @@
# -*- coding: utf-8 -*-
#
__all__ = ['QuerySetMixin']
class QuerySetMixin:
def get_queryset(self):
queryset = super().get_queryset()
serializer_class = self.get_serializer_class()
if serializer_class and hasattr(serializer_class, 'setup_eager_loading'):
queryset = serializer_class.setup_eager_loading(queryset)
return queryset

View File

@@ -0,0 +1,43 @@
# -*- coding: utf-8 -*-
#
from rest_framework.request import Request
__all__ = ['SerializerMixin']
class SerializerMixin:
""" 根据用户请求动作的不同,获取不同的 `serializer_class `"""
action: str
request: Request
serializer_classes = None
single_actions = ['put', 'retrieve', 'patch']
def get_serializer_class_by_view_action(self):
if not hasattr(self, 'serializer_classes'):
return None
if not isinstance(self.serializer_classes, dict):
return None
view_action = self.request.query_params.get('action') or self.action or 'list'
serializer_class = self.serializer_classes.get(view_action)
if serializer_class is None:
view_method = self.request.method.lower()
serializer_class = self.serializer_classes.get(view_method)
if serializer_class is None and view_action in self.single_actions:
serializer_class = self.serializer_classes.get('single')
if serializer_class is None:
serializer_class = self.serializer_classes.get('display')
if serializer_class is None:
serializer_class = self.serializer_classes.get('default')
return serializer_class
def get_serializer_class(self):
serializer_class = self.get_serializer_class_by_view_action()
if serializer_class is None:
serializer_class = super().get_serializer_class()
return serializer_class

View File

@@ -297,10 +297,10 @@ class CommonSerializerMixin(DynamicFieldsMixin, DefaultValueFieldsMixin):
initial_data: dict
def get_initial_value(self, attr, default=None):
if self.instance:
return getattr(self.instance, attr, default)
else:
return self.initial_data.get(attr)
value = self.initial_data.get(attr)
if not value and self.instance:
value = getattr(self.instance, attr, default)
return value
class CommonBulkSerializerMixin(BulkSerializerMixin, CommonSerializerMixin):

View File

@@ -1,49 +1,16 @@
# -*- coding: utf-8 -*-
#
# coding: utf-8
from django.contrib.auth.mixins import UserPassesTestMixin
from django.utils import timezone
from rest_framework.decorators import action
from rest_framework import permissions
from rest_framework.response import Response
from common.permissions import IsValidUser
__all__ = ["DatetimeSearchMixin", "PermissionsMixin"]
from rest_framework.request import Request
class DatetimeSearchMixin:
date_format = '%Y-%m-%d'
date_from = date_to = None
def get_date_range(self):
date_from_s = self.request.GET.get('date_from')
date_to_s = self.request.GET.get('date_to')
if date_from_s:
date_from = timezone.datetime.strptime(date_from_s, self.date_format)
tz = timezone.get_current_timezone()
self.date_from = tz.localize(date_from)
else:
self.date_from = timezone.now() - timezone.timedelta(7)
if date_to_s:
date_to = timezone.datetime.strptime(
date_to_s + ' 23:59:59', self.date_format + ' %H:%M:%S'
)
self.date_to = date_to.replace(
tzinfo=timezone.get_current_timezone()
)
else:
self.date_to = timezone.now()
def get(self, request, *args, **kwargs):
self.get_date_range()
return super().get(request, *args, **kwargs)
__all__ = ["PermissionsMixin"]
class PermissionsMixin(UserPassesTestMixin):
permission_classes = [permissions.IsAuthenticated]
request: Request
def get_permissions(self):
return self.permission_classes
@@ -56,17 +23,3 @@ class PermissionsMixin(UserPassesTestMixin):
return True
class SuggestionMixin:
suggestion_mini_count = 10
@action(methods=['get'], detail=False, permission_classes=(IsValidUser,))
def suggestions(self, request, *args, **kwargs):
queryset = self.filter_queryset(self.get_queryset())
queryset = queryset[:self.suggestion_mini_count]
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)

View File

@@ -14,7 +14,7 @@ class IsValidUser(permissions.IsAuthenticated, permissions.BasePermission):
def has_permission(self, request, view):
return super(IsValidUser, self).has_permission(request, view) \
and request.user.is_valid
and request.user.is_valid
class IsAppUser(IsValidUser):
@@ -22,7 +22,7 @@ class IsAppUser(IsValidUser):
def has_permission(self, request, view):
return super(IsAppUser, self).has_permission(request, view) \
and request.user.is_app
and request.user.is_app
class IsSuperUser(IsValidUser):
@@ -36,7 +36,7 @@ class IsSuperUserOrAppUser(IsSuperUser):
if request.user.is_anonymous:
return False
return super(IsSuperUserOrAppUser, self).has_permission(request, view) \
or request.user.is_app
or request.user.is_app
class IsSuperAuditor(IsValidUser):
@@ -60,7 +60,7 @@ class IsOrgAdmin(IsValidUser):
if not current_org:
return False
return super(IsOrgAdmin, self).has_permission(request, view) \
and current_org.can_admin_by(request.user)
and current_org.can_admin_by(request.user)
class IsOrgAdminOrAppUser(IsValidUser):
@@ -72,7 +72,7 @@ class IsOrgAdminOrAppUser(IsValidUser):
if request.user.is_anonymous:
return False
return super(IsOrgAdminOrAppUser, self).has_permission(request, view) \
and (current_org.can_admin_by(request.user) or request.user.is_app)
and (current_org.can_admin_by(request.user) or request.user.is_app)
class IsOrgAdminOrAppUserOrUserReadonly(IsOrgAdminOrAppUser):

View File

@@ -3,8 +3,8 @@ import hmac
import base64
from common.utils import get_logger
from common.message.backends.utils import digest, as_request
from common.message.backends.mixin import BaseRequest
from common.sdk.im.utils import digest, as_request
from common.sdk.im.mixin import BaseRequest
logger = get_logger(__file__)
@@ -14,7 +14,8 @@ def sign(secret, data):
digest = hmac.HMAC(
key=secret.encode('utf8'),
msg=data.encode('utf8'),
digestmod=hmac._hashlib.sha256).digest()
digestmod=hmac._hashlib.sha256
).digest()
signature = base64.standard_b64encode(digest).decode('utf8')
# signature = urllib.parse.quote(signature, safe='')
# signature = signature.replace('+', '%20').replace('*', '%2A').replace('~', '%7E').replace('/', '%2F')
@@ -37,11 +38,12 @@ class URL:
class DingTalkRequests(BaseRequest):
invalid_token_errcodes = (ErrorCode.INVALID_TOKEN,)
msg_key = 'errmsg'
def __init__(self, appid, appsecret, agentid, timeout=None):
self._appid = appid
self._appsecret = appsecret
self._agentid = agentid
self._appid = appid or ''
self._appsecret = appsecret or ''
self._agentid = agentid or ''
super().__init__(timeout=timeout)
@@ -74,7 +76,7 @@ class DingTalkRequests(BaseRequest):
def post(self, url, json=None, params=None,
with_token=False, with_sign=False,
check_errcode_is_0=True,
**kwargs):
**kwargs) -> dict:
pass
post = as_request(post)
@@ -86,11 +88,10 @@ class DingTalkRequests(BaseRequest):
timestamp = str(int(time.time() * 1000))
signature = sign(self._appsecret, timestamp)
accessKey = self._appid
params['timestamp'] = timestamp
params['signature'] = signature
params['accessKey'] = accessKey
params['accessKey'] = self._appid
def request(self, method, url,
with_token=False, with_sign=False,
@@ -102,15 +103,16 @@ class DingTalkRequests(BaseRequest):
data = super().request(
method, url, with_token=with_token,
check_errcode_is_0=check_errcode_is_0, **kwargs)
check_errcode_is_0=check_errcode_is_0, **kwargs
)
return data
class DingTalk:
def __init__(self, appid, appsecret, agentid, timeout=None):
self._appid = appid
self._appsecret = appsecret
self._agentid = agentid
self._appid = appid or ''
self._appsecret = appsecret or ''
self._agentid = agentid or ''
self._request = DingTalkRequests(
appid=appid, appsecret=appsecret, agentid=agentid,

View File

@@ -4,8 +4,8 @@ from django.utils.translation import ugettext_lazy as _
from rest_framework.exceptions import APIException
from common.utils.common import get_logger
from common.message.backends.utils import digest
from common.message.backends.mixin import RequestMixin, BaseRequest
from common.sdk.im.utils import digest
from common.sdk.im.mixin import RequestMixin, BaseRequest
logger = get_logger(__name__)
@@ -69,8 +69,8 @@ class FeiShu(RequestMixin):
"""
def __init__(self, app_id, app_secret, timeout=None):
self._app_id = app_id
self._app_secret = app_secret
self._app_id = app_id or ''
self._app_secret = app_secret or ''
self._requests = FeishuRequests(
app_id=app_id,

View File

@@ -6,7 +6,7 @@ from django.core.cache import cache
from .utils import DictWrapper
from common.utils.common import get_logger
from common.utils import lazyproperty
from common.message.backends.utils import set_default, as_request
from common.sdk.im.utils import set_default, as_request
from . import exceptions as exce

View File

@@ -3,17 +3,17 @@ import inspect
from inspect import Parameter
from common.utils.common import get_logger
from common.message.backends import exceptions as exce
from common.sdk.im import exceptions as exce
logger = get_logger(__name__)
def digest(corpid, corpsecret):
def digest(corp_id, corp_secret):
md5 = hashlib.md5()
md5.update(corpid.encode())
md5.update(corpsecret.encode())
digest = md5.hexdigest()
return digest
md5.update(corp_id.encode())
md5.update(corp_secret.encode())
dist = md5.hexdigest()
return dist
def update_values(default: dict, others: dict):

View File

@@ -4,8 +4,8 @@ from django.utils.translation import ugettext_lazy as _
from rest_framework.exceptions import APIException
from common.utils.common import get_logger
from common.message.backends.utils import digest, DictWrapper, update_values, set_default
from common.message.backends.mixin import RequestMixin, BaseRequest
from common.sdk.im.utils import digest, DictWrapper, update_values, set_default
from common.sdk.im.mixin import RequestMixin, BaseRequest
logger = get_logger(__name__)
@@ -47,9 +47,9 @@ class WeComRequests(BaseRequest):
invalid_token_errcodes = (ErrorCode.INVALID_TOKEN,)
def __init__(self, corpid, corpsecret, agentid, timeout=None):
self._corpid = corpid
self._corpsecret = corpsecret
self._agentid = agentid
self._corpid = corpid or ''
self._corpsecret = corpsecret or ''
self._agentid = agentid or ''
super().__init__(timeout=timeout)
@@ -79,9 +79,9 @@ class WeCom(RequestMixin):
"""
def __init__(self, corpid, corpsecret, agentid, timeout=None):
self._corpid = corpid
self._corpsecret = corpsecret
self._agentid = agentid
self._corpid = corpid or ''
self._corpsecret = corpsecret or ''
self._agentid = agentid or ''
self._requests = WeComRequests(
corpid=corpid,

View File

@@ -1,4 +1,3 @@
import json
from collections import OrderedDict
from django.conf import settings

View File

@@ -5,8 +5,8 @@ from .common import *
from .django import *
from .encode import *
from .http import *
from .ipip import *
from .crypto import *
from .random import *
from .jumpserver import *
from .ip import *
from .geoip import *

View File

@@ -10,7 +10,7 @@ from functools import wraps
import time
import ipaddress
import psutil
from typing import Iterable
UUID_PATTERN = re.compile(r'\w{8}(-\w{4}){3}-\w{12}')
ipip_db = None
@@ -275,7 +275,7 @@ class Time:
last = timestamp
def bulk_get(d, *keys, default=None):
def bulk_get(d, keys, default=None):
values = []
for key in keys:
values.append(d.get(key, default))
@@ -293,4 +293,3 @@ def unique(objects, key=None):
if v not in seen:
seen[v] = obj
return list(seen.values())

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:860b4d38beff81667c64da41c026a7dd28c3c93a28ae61fefaa7c26875f35638
size 73906864

View File

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

View File

@@ -0,0 +1,46 @@
# -*- coding: utf-8 -*-
#
import os
import ipaddress
import geoip2.database
from geoip2.errors import GeoIP2Error
from django.utils.translation import ugettext_lazy as _
from django.conf import settings
__all__ = ['get_ip_city']
reader = None
def get_ip_city(ip):
if not ip or '.' not in ip or not isinstance(ip, str):
return _("Invalid ip")
if ':' in ip:
return 'IPv6'
global reader
if reader is None:
path = os.path.join(os.path.dirname(__file__), 'GeoLite2-City.mmdb')
reader = geoip2.database.Reader(path)
try:
is_private = ipaddress.ip_address(ip.strip()).is_private
if is_private:
return _('LAN')
except ValueError:
return _("Invalid ip")
try:
response = reader.city(ip)
except GeoIP2Error:
return _("Unknown ip")
names = response.city.names
if not names:
names = response.country.names
if 'en' in settings.LANGUAGE_CODE and 'en' in names:
return names['en']
elif 'zh-CN' in names:
return names['zh-CN']
return _("Unknown ip")

View File

@@ -1,3 +0,0 @@
# -*- coding: utf-8 -*-
#
from .utils import *

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