mirror of
https://github.com/jumpserver/jumpserver.git
synced 2025-12-15 16:42:34 +00:00
Compare commits
273 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42e9fbf37a | ||
|
|
d44656aa10 | ||
|
|
09d4228182 | ||
|
|
3ae8da231a | ||
|
|
2e4b6d150a | ||
|
|
8542d53aff | ||
|
|
141dafc8bf | ||
|
|
28a6024b49 | ||
|
|
6cf2bc4baf | ||
|
|
631f802961 | ||
|
|
0eedda748a | ||
|
|
7183f0d274 | ||
|
|
c93ab15351 | ||
|
|
3fffd667dc | ||
|
|
11fd2afa3a | ||
|
|
1eb59b11da | ||
|
|
a1e2d4ca57 | ||
|
|
8a413563be | ||
|
|
203a01240b | ||
|
|
37fef6153a | ||
|
|
ba6c49e62b | ||
|
|
da79f8beab | ||
|
|
3f72c02049 | ||
|
|
380226a7d2 | ||
|
|
f88e5de3c1 | ||
|
|
7c149fe91b | ||
|
|
f673fed706 | ||
|
|
84326cc999 | ||
|
|
7d56678a8e | ||
|
|
25d1b71448 | ||
|
|
d3920a0cc9 | ||
|
|
52889cb67a | ||
|
|
0b67c7a953 | ||
|
|
1f50a2fe33 | ||
|
|
cd5094f10d | ||
|
|
c244cf5f43 | ||
|
|
a0db2f6ef8 | ||
|
|
ea485c3070 | ||
|
|
705f352cb9 | ||
|
|
dc13134b7b | ||
|
|
06de6c3575 | ||
|
|
c341d01e5a | ||
|
|
d1a3d31d3f | ||
|
|
10f4ff4eec | ||
|
|
25ea3ba01d | ||
|
|
072865f3e5 | ||
|
|
d5c9ec1c3d | ||
|
|
487c945d1d | ||
|
|
a78b2f4b62 | ||
|
|
a1f1dce56b | ||
|
|
a1221a39fd | ||
|
|
8c118b6f47 | ||
|
|
68fd8012d8 | ||
|
|
526928518d | ||
|
|
c5f6c564a7 | ||
|
|
076adec218 | ||
|
|
00d434ceea | ||
|
|
9acfd461b4 | ||
|
|
9424929dde | ||
|
|
b95d3ac9be | ||
|
|
af2a9bb1e6 | ||
|
|
63638ed1ce | ||
|
|
fa68389028 | ||
|
|
63b338085a | ||
|
|
5d8818e69e | ||
|
|
77521119b9 | ||
|
|
baee71e4b8 | ||
|
|
6459c20516 | ||
|
|
0207fe60c5 | ||
|
|
b8c43f5944 | ||
|
|
63ca2ab182 | ||
|
|
f9ea119928 | ||
|
|
d4bdc74bd8 | ||
|
|
42e7ca6a18 | ||
|
|
6e0341b7b1 | ||
|
|
2f25e2b24c | ||
|
|
87dcd2dcb7 | ||
|
|
e96aac058f | ||
|
|
d76bad125b | ||
|
|
913b1a1426 | ||
|
|
0213154a19 | ||
|
|
8f63b488b2 | ||
|
|
f8921004c2 | ||
|
|
5588eab57e | ||
|
|
e0a8f91741 | ||
|
|
467ebfa650 | ||
|
|
0533b77b1b | ||
|
|
a558ee2ac0 | ||
|
|
46a39701d4 | ||
|
|
8c3ab31e4e | ||
|
|
476e6cdc2f | ||
|
|
0b593f4555 | ||
|
|
456116938d | ||
|
|
76b24f62d4 | ||
|
|
e1eef0a3f3 | ||
|
|
2c74727b65 | ||
|
|
08cd91c426 | ||
|
|
90d269d2a2 | ||
|
|
a9ddbcc0cd | ||
|
|
aec31128cf | ||
|
|
b415ee051d | ||
|
|
082a5ae84c | ||
|
|
6b7554d69a | ||
|
|
5923562440 | ||
|
|
d144e7e572 | ||
|
|
cafbd08986 | ||
|
|
b436fc9b44 | ||
|
|
26fc56b4be | ||
|
|
3b1d199669 | ||
|
|
e7edbc9d84 | ||
|
|
41f81bc0bf | ||
|
|
75d2c81d33 | ||
|
|
da2dea5003 | ||
|
|
fc60156c23 | ||
|
|
76fb547551 | ||
|
|
e686f51703 | ||
|
|
7578bda588 | ||
|
|
f2d743ec2b | ||
|
|
7099aef360 | ||
|
|
246f5d8a11 | ||
|
|
6c98bd3b48 | ||
|
|
6def113cbd | ||
|
|
2dc0af2553 | ||
|
|
a291592e59 | ||
|
|
6fb4c1e181 | ||
|
|
eee093742c | ||
|
|
743c9bc3f1 | ||
|
|
f963c5ef9d | ||
|
|
2c46072db2 | ||
|
|
b375cd3e75 | ||
|
|
c26ca20ad8 | ||
|
|
982a510213 | ||
|
|
5d6880f6e9 | ||
|
|
a784a33203 | ||
|
|
a452f3307f | ||
|
|
b7a6287925 | ||
|
|
3cba8648cb | ||
|
|
ef7b2b7980 | ||
|
|
1ab247ac22 | ||
|
|
ef8a027849 | ||
|
|
7890e43f5a | ||
|
|
2030cbd19d | ||
|
|
0f7c8c2570 | ||
|
|
9b60d86ddd | ||
|
|
f129f99faa | ||
|
|
43f30b37da | ||
|
|
45aefa6b75 | ||
|
|
b30123054b | ||
|
|
b456e71ec4 | ||
|
|
7560b70c4d | ||
|
|
0c96df5283 | ||
|
|
9dda19b8d7 | ||
|
|
fbe5f9a63a | ||
|
|
1c4b4951dc | ||
|
|
8e12399058 | ||
|
|
741b96ddee | ||
|
|
8c3f89ee51 | ||
|
|
dee45ce2e0 | ||
|
|
cfab30f7f7 | ||
|
|
02e9a96792 | ||
|
|
aa6dcdc65d | ||
|
|
5a6b64eebd | ||
|
|
2dd7867b32 | ||
|
|
6507f0982c | ||
|
|
c115ef7b47 | ||
|
|
bf68ddf09e | ||
|
|
f3906ff998 | ||
|
|
e47ee43631 | ||
|
|
d22bb2c92f | ||
|
|
870dac37b9 | ||
|
|
d14c5c58ff | ||
|
|
a6b40510d0 | ||
|
|
f762fe73ff | ||
|
|
d49d1ba055 | ||
|
|
6e3d950e23 | ||
|
|
7939ef34b0 | ||
|
|
07cd930c0e | ||
|
|
c14c89a758 | ||
|
|
245367ec29 | ||
|
|
c5f4ecc8cc | ||
|
|
eb8bdf8623 | ||
|
|
a26c5a5e32 | ||
|
|
068db6d1ca | ||
|
|
e6e2a35745 | ||
|
|
fafc2791ab | ||
|
|
39507ef152 | ||
|
|
683fb9f596 | ||
|
|
ced9e53d62 | ||
|
|
93846234f8 | ||
|
|
8ac7d4b682 | ||
|
|
c4890f66e1 | ||
|
|
4618989813 | ||
|
|
29645768a0 | ||
|
|
8f1c934f73 | ||
|
|
7a45f4d129 | ||
|
|
55a5dd1e34 | ||
|
|
6695d0a8a2 | ||
|
|
84d6b3de26 | ||
|
|
17a5e919d5 | ||
|
|
3ba07867c8 | ||
|
|
75b76170f9 | ||
|
|
d34c7edb00 | ||
|
|
f64740c2db | ||
|
|
3a09845c29 | ||
|
|
09d51fd5be | ||
|
|
fc8181b5ed | ||
|
|
5a993c255d | ||
|
|
ad592fa504 | ||
|
|
1dcc8ff0a3 | ||
|
|
11a9a49bf8 | ||
|
|
b9ffc23066 | ||
|
|
ea4dccbab8 | ||
|
|
683461a49b | ||
|
|
1a1ad0f1a2 | ||
|
|
773f7048be | ||
|
|
f8f783745c | ||
|
|
4fe715d953 | ||
|
|
36dfc4bcb8 | ||
|
|
8925314dc7 | ||
|
|
817c02c667 | ||
|
|
58a10778cd | ||
|
|
fa81652de5 | ||
|
|
7e6fa27719 | ||
|
|
3e737c8cb8 | ||
|
|
345c0fcf4f | ||
|
|
bf6b685e8c | ||
|
|
654ec4970e | ||
|
|
4a436856b4 | ||
|
|
e993e7257c | ||
|
|
f12a59da2f | ||
|
|
42c3c85863 | ||
|
|
7e638ff8de | ||
|
|
932a65b840 | ||
|
|
81000953e2 | ||
|
|
dc742d1281 | ||
|
|
b1fceca8a6 | ||
|
|
d49d1e1414 | ||
|
|
dac3f7fc71 | ||
|
|
47989c41a3 | ||
|
|
ca34216141 | ||
|
|
905014d441 | ||
|
|
3e51f4d616 | ||
|
|
07179a4d22 | ||
|
|
7a2e93c087 | ||
|
|
3fb368c741 | ||
|
|
fca3a8fbca | ||
|
|
c1375ed7cb | ||
|
|
8b483b8c36 | ||
|
|
c465fccc33 | ||
|
|
3d934dc7c0 | ||
|
|
b69ed8cbe9 | ||
|
|
c27230762b | ||
|
|
7ea8205672 | ||
|
|
b9b55e3d67 | ||
|
|
900fc4420c | ||
|
|
0a3e5aed56 | ||
|
|
9fb6fd44d1 | ||
|
|
4214b220e1 | ||
|
|
ae80797ce4 | ||
|
|
d1be4a136e | ||
|
|
e8e211f47c | ||
|
|
44044a7d99 | ||
|
|
5854ad1975 | ||
|
|
0b1a1591f8 | ||
|
|
6241238b45 | ||
|
|
0f87f05b3f | ||
|
|
19c63a0b19 | ||
|
|
1fdc558ef7 | ||
|
|
9f6e26c4db | ||
|
|
628012a7ee | ||
|
|
c1579f5fe4 | ||
|
|
cbe0483b46 | ||
|
|
10c2935df4 |
@@ -6,4 +6,5 @@ tmp/*
|
||||
django.db
|
||||
celerybeat.pid
|
||||
### Vagrant ###
|
||||
.vagrant/
|
||||
.vagrant/
|
||||
apps/xpack/.git
|
||||
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*.mmdb filter=lfs diff=lfs merge=lfs -text
|
||||
*.mo filter=lfs diff=lfs merge=lfs -text
|
||||
24
Dockerfile
24
Dockerfile
@@ -8,7 +8,6 @@ WORKDIR /opt/jumpserver
|
||||
ADD . .
|
||||
RUN cd utils && bash -ixeu build.sh
|
||||
|
||||
|
||||
# 构建运行时环境
|
||||
FROM python:3.8.6-slim
|
||||
ARG PIP_MIRROR=https://pypi.douban.com/simple
|
||||
@@ -18,26 +17,37 @@ ENV PIP_JMS_MIRROR=$PIP_JMS_MIRROR
|
||||
|
||||
WORKDIR /opt/jumpserver
|
||||
|
||||
COPY ./requirements/deb_buster_requirements.txt ./requirements/deb_buster_requirements.txt
|
||||
COPY ./requirements/deb_requirements.txt ./requirements/deb_requirements.txt
|
||||
RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list \
|
||||
&& sed -i 's/security.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list \
|
||||
&& apt update \
|
||||
&& grep -v '^#' ./requirements/deb_buster_requirements.txt | xargs apt -y install \
|
||||
&& apt -y install telnet iproute2 redis-tools default-mysql-client vim wget curl locales procps \
|
||||
&& apt -y install $(cat requirements/deb_requirements.txt) \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& localedef -c -f UTF-8 -i zh_CN zh_CN.UTF-8 \
|
||||
&& cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
|
||||
&& cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
|
||||
&& sed -i "s@# alias l@alias l@g" ~/.bashrc \
|
||||
&& echo "set mouse-=a" > ~/.vimrc
|
||||
|
||||
COPY ./requirements/requirements.txt ./requirements/requirements.txt
|
||||
RUN pip install --upgrade pip==20.2.4 setuptools==49.6.0 wheel==0.34.2 -i ${PIP_MIRROR} \
|
||||
&& pip config set global.index-url ${PIP_MIRROR} \
|
||||
&& pip install --no-cache-dir $(grep 'jms' requirements/requirements.txt) -i ${PIP_JMS_MIRROR} \
|
||||
&& pip install --no-cache-dir -r requirements/requirements.txt
|
||||
&& pip install --no-cache-dir $(grep -E 'jms|jumpserver' requirements/requirements.txt) -i ${PIP_JMS_MIRROR} \
|
||||
&& pip install --no-cache-dir -r requirements/requirements.txt -i ${PIP_MIRROR} \
|
||||
&& rm -rf ~/.cache/pip
|
||||
|
||||
COPY --from=stage-build /opt/jumpserver/release/jumpserver /opt/jumpserver
|
||||
RUN mkdir -p /root/.ssh/ \
|
||||
&& echo "Host *\n\tStrictHostKeyChecking no\n\tUserKnownHostsFile /dev/null" > /root/.ssh/config
|
||||
|
||||
RUN mkdir -p /opt/jumpserver/oracle/ \
|
||||
&& wget https://download.jumpserver.org/public/instantclient-basiclite-linux.x64-21.1.0.0.0.tar > /dev/null \
|
||||
&& tar xf instantclient-basiclite-linux.x64-21.1.0.0.0.tar -C /opt/jumpserver/oracle/ \
|
||||
&& echo "/opt/jumpserver/oracle/instantclient_21_1" > /etc/ld.so.conf.d/oracle-instantclient.conf \
|
||||
&& ldconfig \
|
||||
&& rm -f instantclient-basiclite-linux.x64-21.1.0.0.0.tar
|
||||
|
||||
RUN echo > config.yml
|
||||
|
||||
VOLUME /opt/jumpserver/data
|
||||
VOLUME /opt/jumpserver/logs
|
||||
|
||||
|
||||
20
README.md
20
README.md
@@ -21,6 +21,7 @@ JumpServer 采纳分布式架构,支持多机房跨区域部署,支持横向
|
||||
|
||||
改变世界,从一点点开始 ...
|
||||
|
||||
> 如需进一步了解 JumpServer 开源项目,推荐阅读 [JumpServer 的初心和使命](https://mp.weixin.qq.com/s/S6q_2rP_9MwaVwyqLQnXzA)
|
||||
|
||||
### 特色优势
|
||||
|
||||
@@ -32,6 +33,19 @@ JumpServer 采纳分布式架构,支持多机房跨区域部署,支持横向
|
||||
- 多租户: 一套系统,多个子公司和部门同时使用;
|
||||
- 多应用支持: 数据库,Windows远程应用,Kubernetes。
|
||||
|
||||
### UI 展示
|
||||
|
||||

|
||||
|
||||
### 在线体验
|
||||
|
||||
- 环境地址:<https://demo.jumpserver.org/>
|
||||
|
||||
| :warning: 注意 |
|
||||
| :--------------------------- |
|
||||
| 该环境仅作体验目的使用,我们会定时清理、重置数据! |
|
||||
| 请勿修改体验环境用户的密码! |
|
||||
| 请勿在环境中添加业务生产环境地址、用户名密码等敏感信息! |
|
||||
|
||||
### 快速开始
|
||||
|
||||
@@ -45,6 +59,8 @@ JumpServer 采纳分布式架构,支持多机房跨区域部署,支持横向
|
||||
- [Luna](https://github.com/jumpserver/luna) JumpServer Web Terminal 项目
|
||||
- [KoKo](https://github.com/jumpserver/koko) JumpServer 字符协议 Connector 项目,替代原来 Python 版本的 [Coco](https://github.com/jumpserver/coco)
|
||||
- [Lion](https://github.com/jumpserver/lion-release) JumpServer 图形协议 Connector 项目,依赖 [Apache Guacamole](https://guacamole.apache.org/)
|
||||
- [Clients](https://github.com/jumpserver/clients) JumpServer 客户端 项目
|
||||
- [Installer](https://github.com/jumpserver/installer) JumpServer 安装包 项目
|
||||
|
||||
### 社区
|
||||
|
||||
@@ -52,7 +68,7 @@ JumpServer 采纳分布式架构,支持多机房跨区域部署,支持横向
|
||||
|
||||
#### 微信交流群
|
||||
|
||||
<img src="https://download.jumpserver.org/images/weixin-group.jpeg" alt="微信群二维码" width="200"/>
|
||||
<img src="https://download.jumpserver.org/images/wecom-group.jpeg" alt="微信群二维码" width="200"/>
|
||||
|
||||
### 贡献
|
||||
如果有你好的想法创意,或者帮助我们修复了 Bug, 欢迎提交 Pull Request
|
||||
@@ -108,7 +124,7 @@ JumpServer是一款安全产品,请参考 [基本安全建议](https://docs.ju
|
||||
|
||||
### License & Copyright
|
||||
|
||||
Copyright (c) 2014-2020 飞致云 FIT2CLOUD, All rights reserved.
|
||||
Copyright (c) 2014-2021 飞致云 FIT2CLOUD, All rights reserved.
|
||||
|
||||
Licensed under The GNU General Public License version 2 (GPLv2) (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ If you find a security problem, please contact us directly:
|
||||
- 400-052-0755
|
||||
|
||||
### License & Copyright
|
||||
Copyright (c) 2014-2019 Beijing Duizhan Tech, Inc., All rights reserved.
|
||||
Copyright (c) 2014-2021 Beijing Duizhan Tech, Inc., All rights reserved.
|
||||
|
||||
Licensed under The GNU General Public License version 2 (GPLv2) (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at
|
||||
|
||||
|
||||
9
SECURITY.md
Normal file
9
SECURITY.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# 安全说明
|
||||
|
||||
JumpServer 是一款正在成长的安全产品, 请参考 [基本安全建议](https://docs.jumpserver.org/zh/master/install/install_security/) 部署安装.
|
||||
|
||||
如果你发现安全问题,请直接联系我们,我们携手让世界更好:
|
||||
|
||||
- ibuler@fit2cloud.com
|
||||
- support@fit2cloud.com
|
||||
- 400-052-0755
|
||||
@@ -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):
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
from orgs.mixins.api import OrgBulkModelViewSet
|
||||
from common.permissions import IsOrgAdmin
|
||||
from .. import models, serializers
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
from django.shortcuts import get_object_or_404
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.generics import CreateAPIView, RetrieveDestroyAPIView
|
||||
from rest_framework.generics import CreateAPIView
|
||||
|
||||
from common.permissions import IsAppUser
|
||||
from common.utils import reverse, lazyproperty
|
||||
from orgs.utils import tmp_to_org, tmp_to_root_org
|
||||
from orgs.utils import tmp_to_org
|
||||
from tickets.api import GenericTicketStatusRetrieveCloseAPI
|
||||
from ..models import LoginAssetACL
|
||||
from .. import serializers
|
||||
|
||||
|
||||
__all__ = ['LoginAssetCheckAPI', 'LoginAssetConfirmStatusAPI']
|
||||
|
||||
|
||||
class LoginAssetCheckAPI(CreateAPIView):
|
||||
permission_classes = (IsAppUser, )
|
||||
permission_classes = (IsAppUser,)
|
||||
serializer_class = serializers.LoginAssetCheckSerializer
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
@@ -57,11 +55,12 @@ class LoginAssetCheckAPI(CreateAPIView):
|
||||
external=True, api_to_ui=True
|
||||
)
|
||||
ticket_detail_url = '{url}?type={type}'.format(url=ticket_detail_url, type=ticket.type)
|
||||
ticket_assignees = ticket.current_node.first().ticket_assignees.all()
|
||||
data = {
|
||||
'check_confirm_status': {'method': 'GET', 'url': confirm_status_url},
|
||||
'close_confirm': {'method': 'DELETE', 'url': confirm_status_url},
|
||||
'ticket_detail_url': ticket_detail_url,
|
||||
'reviewers': [str(user) for user in ticket.assignees.all()],
|
||||
'reviewers': [str(ticket_assignee.assignee) for ticket_assignee in ticket_assignees]
|
||||
}
|
||||
return data
|
||||
|
||||
@@ -74,4 +73,3 @@ class LoginAssetCheckAPI(CreateAPIView):
|
||||
|
||||
class LoginAssetConfirmStatusAPI(GenericTicketStatusRetrieveCloseAPI):
|
||||
pass
|
||||
|
||||
|
||||
15
apps/acls/filters.py
Normal file
15
apps/acls/filters.py
Normal 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'
|
||||
)
|
||||
98
apps/acls/migrations/0002_auto_20210926_1047.py
Normal file
98
apps/acls/migrations/0002_auto_20210926_1047.py
Normal 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'},
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -37,6 +37,7 @@ class LoginAssetACL(BaseACL, OrgModelMixin):
|
||||
class Meta:
|
||||
unique_together = ('name', 'org_id')
|
||||
ordering = ('priority', '-date_updated', 'name')
|
||||
verbose_name = _('Login asset acl')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@@ -83,11 +84,11 @@ class LoginAssetACL(BaseACL, OrgModelMixin):
|
||||
|
||||
@classmethod
|
||||
def create_login_asset_confirm_ticket(cls, user, asset, system_user, assignees, org_id):
|
||||
from tickets.const import TicketTypeChoices
|
||||
from tickets.const import TicketType
|
||||
from tickets.models import Ticket
|
||||
data = {
|
||||
'title': _('Login asset confirm') + ' ({})'.format(user),
|
||||
'type': TicketTypeChoices.login_asset_confirm,
|
||||
'type': TicketType.login_asset_confirm,
|
||||
'meta': {
|
||||
'apply_login_user': str(user),
|
||||
'apply_login_asset': str(asset),
|
||||
@@ -96,7 +97,7 @@ class LoginAssetACL(BaseACL, OrgModelMixin):
|
||||
'org_id': org_id,
|
||||
}
|
||||
ticket = Ticket.objects.create(**data)
|
||||
ticket.assignees.set(assignees)
|
||||
ticket.create_process_map_and_node(assignees)
|
||||
ticket.open(applicant=user)
|
||||
return ticket
|
||||
|
||||
|
||||
@@ -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()])
|
||||
|
||||
1
apps/acls/serializers/rules/__init__.py
Normal file
1
apps/acls/serializers/rules/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .rules import *
|
||||
34
apps/acls/serializers/rules/rules.py
Normal file
34
apps/acls/serializers/rules/rules.py
Normal 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'))
|
||||
@@ -2,74 +2,57 @@
|
||||
#
|
||||
|
||||
from django_filters import rest_framework as filters
|
||||
from django.db.models import F, Value, CharField
|
||||
from django.db.models.functions import Concat
|
||||
from django.http import Http404
|
||||
from django.db.models import F, Q
|
||||
|
||||
from common.drf.filters import BaseFilterSet
|
||||
from common.drf.api import JMSModelViewSet
|
||||
from common.utils import unique
|
||||
from perms.models import ApplicationPermission
|
||||
from common.drf.api import JMSBulkModelViewSet
|
||||
from ..models import Account
|
||||
from ..hands import IsOrgAdminOrAppUser, IsOrgAdmin, NeedMFAVerify
|
||||
from .. import serializers
|
||||
|
||||
|
||||
class AccountFilterSet(BaseFilterSet):
|
||||
username = filters.CharFilter(field_name='username')
|
||||
app = filters.CharFilter(field_name='applications', lookup_expr='exact')
|
||||
app_name = filters.CharFilter(field_name='app_name', lookup_expr='exact')
|
||||
username = filters.CharFilter(method='do_nothing')
|
||||
type = filters.CharFilter(field_name='type', lookup_expr='exact')
|
||||
category = filters.CharFilter(field_name='category', lookup_expr='exact')
|
||||
app_display = filters.CharFilter(field_name='app_display', lookup_expr='exact')
|
||||
|
||||
class Meta:
|
||||
model = ApplicationPermission
|
||||
fields = ['type', 'category']
|
||||
model = Account
|
||||
fields = ['app', 'systemuser']
|
||||
|
||||
@property
|
||||
def qs(self):
|
||||
qs = super().qs
|
||||
qs = self.filter_username(qs)
|
||||
return qs
|
||||
|
||||
def filter_username(self, qs):
|
||||
username = self.get_query_param('username')
|
||||
if not username:
|
||||
return qs
|
||||
qs = qs.filter(Q(username=username) | Q(systemuser__username=username)).distinct()
|
||||
return qs
|
||||
|
||||
|
||||
class ApplicationAccountViewSet(JMSModelViewSet):
|
||||
permission_classes = (IsOrgAdmin, )
|
||||
search_fields = ['username', 'app_name']
|
||||
class ApplicationAccountViewSet(JMSBulkModelViewSet):
|
||||
model = Account
|
||||
search_fields = ['username', 'app_display']
|
||||
filterset_class = AccountFilterSet
|
||||
filterset_fields = ['username', 'app_name', 'type', 'category']
|
||||
serializer_class = serializers.ApplicationAccountSerializer
|
||||
http_method_names = ['get', 'put', 'patch', 'options']
|
||||
filterset_fields = ['username', 'app_display', 'type', 'category', 'app']
|
||||
serializer_class = serializers.AppAccountSerializer
|
||||
permission_classes = (IsOrgAdmin,)
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = ApplicationPermission.objects\
|
||||
.exclude(system_users__isnull=True) \
|
||||
.exclude(applications__isnull=True) \
|
||||
.annotate(uid=Concat(
|
||||
'applications', Value('_'), 'system_users', output_field=CharField()
|
||||
)) \
|
||||
.annotate(systemuser=F('system_users')) \
|
||||
.annotate(systemuser_display=F('system_users__name')) \
|
||||
.annotate(username=F('system_users__username')) \
|
||||
.annotate(password=F('system_users__password')) \
|
||||
.annotate(app=F('applications')) \
|
||||
.annotate(app_name=F("applications__name")) \
|
||||
.values('username', 'password', 'systemuser', 'systemuser_display',
|
||||
'app', 'app_name', 'category', 'type', 'uid', 'org_id')
|
||||
return queryset
|
||||
|
||||
def get_object(self):
|
||||
obj = self.get_queryset().filter(
|
||||
uid=self.kwargs['pk']
|
||||
).first()
|
||||
if not obj:
|
||||
raise Http404()
|
||||
return obj
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
queryset = super().filter_queryset(queryset)
|
||||
queryset_list = unique(queryset, key=lambda x: (x['app'], x['systemuser']))
|
||||
return queryset_list
|
||||
|
||||
@staticmethod
|
||||
def filter_spm_queryset(resource_ids, queryset):
|
||||
queryset = queryset.filter(uid__in=resource_ids)
|
||||
queryset = Account.objects.all() \
|
||||
.annotate(type=F('app__type')) \
|
||||
.annotate(app_display=F('app__name')) \
|
||||
.annotate(systemuser_display=F('systemuser__name')) \
|
||||
.annotate(category=F('app__category'))
|
||||
return queryset
|
||||
|
||||
|
||||
class ApplicationAccountSecretViewSet(ApplicationAccountViewSet):
|
||||
serializer_class = serializers.ApplicationAccountSecretSerializer
|
||||
serializer_class = serializers.AppAccountSecretSerializer
|
||||
permission_classes = [IsOrgAdminOrAppUser, NeedMFAVerify]
|
||||
http_method_names = ['get', 'options']
|
||||
|
||||
|
||||
@@ -6,15 +6,15 @@ from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
|
||||
from common.tree import TreeNodeSerializer
|
||||
from common.mixins.api import SuggestionMixin
|
||||
from ..hands import IsOrgAdminOrAppUser
|
||||
from .. import serializers
|
||||
from ..models import Application
|
||||
|
||||
|
||||
__all__ = ['ApplicationViewSet']
|
||||
|
||||
|
||||
class ApplicationViewSet(OrgBulkModelViewSet):
|
||||
class ApplicationViewSet(SuggestionMixin, OrgBulkModelViewSet):
|
||||
model = Application
|
||||
filterset_fields = {
|
||||
'name': ['exact'],
|
||||
@@ -24,8 +24,9 @@ class ApplicationViewSet(OrgBulkModelViewSet):
|
||||
search_fields = ('name', 'type', 'category')
|
||||
permission_classes = (IsOrgAdminOrAppUser,)
|
||||
serializer_classes = {
|
||||
'default': serializers.ApplicationSerializer,
|
||||
'get_tree': TreeNodeSerializer
|
||||
'default': serializers.AppSerializer,
|
||||
'get_tree': TreeNodeSerializer,
|
||||
'suggestion': serializers.MiniAppSerializer
|
||||
}
|
||||
|
||||
@action(methods=['GET'], detail=False, url_path='tree')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# Generated by Django 3.1.12 on 2021-08-26 09:07
|
||||
|
||||
import assets.models.base
|
||||
import common.fields.model
|
||||
from django.conf import settings
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import simple_history.models
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('assets', '0076_delete_assetuser'),
|
||||
('applications', '0009_applicationuser'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='HistoricalAccount',
|
||||
fields=[
|
||||
('org_id', models.CharField(blank=True, db_index=True, default='', max_length=36, verbose_name='Organization')),
|
||||
('id', models.UUIDField(db_index=True, default=uuid.uuid4)),
|
||||
('name', models.CharField(max_length=128, verbose_name='Name')),
|
||||
('username', models.CharField(blank=True, db_index=True, max_length=128, validators=[django.core.validators.RegexValidator('^[0-9a-zA-Z_@\\-\\.]*$', 'Special char not allowed')], verbose_name='Username')),
|
||||
('password', common.fields.model.EncryptCharField(blank=True, max_length=256, null=True, verbose_name='Password')),
|
||||
('private_key', common.fields.model.EncryptTextField(blank=True, null=True, verbose_name='SSH private key')),
|
||||
('public_key', common.fields.model.EncryptTextField(blank=True, null=True, verbose_name='SSH public key')),
|
||||
('comment', models.TextField(blank=True, verbose_name='Comment')),
|
||||
('date_created', models.DateTimeField(blank=True, editable=False, verbose_name='Date created')),
|
||||
('date_updated', models.DateTimeField(blank=True, editable=False, verbose_name='Date updated')),
|
||||
('created_by', models.CharField(max_length=128, null=True, verbose_name='Created by')),
|
||||
('version', models.IntegerField(default=1, verbose_name='Version')),
|
||||
('history_id', models.AutoField(primary_key=True, serialize=False)),
|
||||
('history_date', models.DateTimeField()),
|
||||
('history_change_reason', models.CharField(max_length=100, null=True)),
|
||||
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
|
||||
('app', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='applications.application', verbose_name='Database')),
|
||||
('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
|
||||
('systemuser', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='assets.systemuser', verbose_name='System user')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'historical Account',
|
||||
'ordering': ('-history_date', '-history_id'),
|
||||
'get_latest_by': 'history_date',
|
||||
},
|
||||
bases=(simple_history.models.HistoricalChanges, models.Model),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Account',
|
||||
fields=[
|
||||
('org_id', models.CharField(blank=True, db_index=True, default='', max_length=36, verbose_name='Organization')),
|
||||
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
|
||||
('name', models.CharField(max_length=128, verbose_name='Name')),
|
||||
('username', models.CharField(blank=True, db_index=True, max_length=128, validators=[django.core.validators.RegexValidator('^[0-9a-zA-Z_@\\-\\.]*$', 'Special char not allowed')], verbose_name='Username')),
|
||||
('password', common.fields.model.EncryptCharField(blank=True, max_length=256, null=True, verbose_name='Password')),
|
||||
('private_key', common.fields.model.EncryptTextField(blank=True, null=True, verbose_name='SSH private key')),
|
||||
('public_key', common.fields.model.EncryptTextField(blank=True, null=True, verbose_name='SSH public key')),
|
||||
('comment', models.TextField(blank=True, verbose_name='Comment')),
|
||||
('date_created', models.DateTimeField(auto_now_add=True, verbose_name='Date created')),
|
||||
('date_updated', models.DateTimeField(auto_now=True, verbose_name='Date updated')),
|
||||
('created_by', models.CharField(max_length=128, null=True, verbose_name='Created by')),
|
||||
('version', models.IntegerField(default=1, verbose_name='Version')),
|
||||
('app', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='applications.application', verbose_name='Database')),
|
||||
('systemuser', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='assets.systemuser', verbose_name='System user')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Account',
|
||||
'unique_together': {('username', 'app', 'systemuser')},
|
||||
},
|
||||
bases=(models.Model, assets.models.base.AuthMixin),
|
||||
),
|
||||
]
|
||||
40
apps/applications/migrations/0011_auto_20210826_1759.py
Normal file
40
apps/applications/migrations/0011_auto_20210826_1759.py
Normal file
@@ -0,0 +1,40 @@
|
||||
# Generated by Django 3.1.12 on 2021-08-26 09:59
|
||||
|
||||
from django.db import migrations, transaction
|
||||
from django.db.models import F
|
||||
|
||||
|
||||
def migrate_app_account(apps, schema_editor):
|
||||
db_alias = schema_editor.connection.alias
|
||||
app_perm_model = apps.get_model("perms", "ApplicationPermission")
|
||||
app_account_model = apps.get_model("applications", 'Account')
|
||||
|
||||
queryset = app_perm_model.objects \
|
||||
.exclude(system_users__isnull=True) \
|
||||
.exclude(applications__isnull=True) \
|
||||
.annotate(systemuser=F('system_users')) \
|
||||
.annotate(app=F('applications')) \
|
||||
.values('app', 'systemuser', 'org_id')
|
||||
|
||||
accounts = []
|
||||
for p in queryset:
|
||||
if not p['app']:
|
||||
continue
|
||||
account = app_account_model(
|
||||
app_id=p['app'], systemuser_id=p['systemuser'],
|
||||
version=1, org_id=p['org_id']
|
||||
)
|
||||
accounts.append(account)
|
||||
|
||||
app_account_model.objects.using(db_alias).bulk_create(accounts, ignore_conflicts=True)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('applications', '0010_appaccount_historicalappaccount'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(migrate_app_account)
|
||||
]
|
||||
23
apps/applications/migrations/0012_auto_20211014_2209.py
Normal file
23
apps/applications/migrations/0012_auto_20211014_2209.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
17
apps/applications/migrations/0013_auto_20211026_1711.py
Normal file
17
apps/applications/migrations/0013_auto_20211026_1711.py
Normal 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'},
|
||||
),
|
||||
]
|
||||
@@ -1 +1,2 @@
|
||||
from .application import *
|
||||
from .account import *
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
from django.db import models
|
||||
from simple_history.models import HistoricalRecords
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from common.utils import lazyproperty
|
||||
from assets.models.base import BaseUser
|
||||
|
||||
|
||||
class Account(BaseUser):
|
||||
app = models.ForeignKey('applications.Application', on_delete=models.CASCADE, null=True, verbose_name=_('Database'))
|
||||
systemuser = models.ForeignKey('assets.SystemUser', on_delete=models.CASCADE, null=True, verbose_name=_("System user"))
|
||||
version = models.IntegerField(default=1, verbose_name=_('Version'))
|
||||
history = HistoricalRecords()
|
||||
|
||||
auth_attrs = ['username', 'password', 'private_key', 'public_key']
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('Account')
|
||||
unique_together = [('username', 'app', 'systemuser')]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.auth_snapshot = {}
|
||||
|
||||
def get_or_systemuser_attr(self, attr):
|
||||
val = getattr(self, attr, None)
|
||||
if val:
|
||||
return val
|
||||
if self.systemuser:
|
||||
return getattr(self.systemuser, attr, '')
|
||||
return ''
|
||||
|
||||
def load_auth(self):
|
||||
for attr in self.auth_attrs:
|
||||
value = self.get_or_systemuser_attr(attr)
|
||||
self.auth_snapshot[attr] = [getattr(self, attr), value]
|
||||
setattr(self, attr, value)
|
||||
|
||||
def unload_auth(self):
|
||||
if not self.systemuser:
|
||||
return
|
||||
|
||||
for attr, values in self.auth_snapshot.items():
|
||||
origin_value, loaded_value = values
|
||||
current_value = getattr(self, attr, '')
|
||||
if current_value == loaded_value:
|
||||
setattr(self, attr, origin_value)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.unload_auth()
|
||||
instance = super().save(*args, **kwargs)
|
||||
self.load_auth()
|
||||
return instance
|
||||
|
||||
@lazyproperty
|
||||
def category(self):
|
||||
return self.app.category
|
||||
|
||||
@lazyproperty
|
||||
def type(self):
|
||||
return self.app.type
|
||||
|
||||
@lazyproperty
|
||||
def app_display(self):
|
||||
return self.systemuser.name
|
||||
|
||||
@property
|
||||
def username_display(self):
|
||||
return self.get_or_systemuser_attr('username') or ''
|
||||
|
||||
@lazyproperty
|
||||
def systemuser_display(self):
|
||||
if not self.systemuser:
|
||||
return ''
|
||||
return str(self.systemuser)
|
||||
|
||||
@property
|
||||
def smart_name(self):
|
||||
username = self.username_display
|
||||
|
||||
if self.app:
|
||||
app = str(self.app)
|
||||
else:
|
||||
app = '*'
|
||||
return '{}@{}'.format(username, app)
|
||||
|
||||
def __str__(self):
|
||||
return self.smart_name
|
||||
|
||||
@@ -180,6 +180,7 @@ class Application(CommonModelMixin, OrgModelMixin, ApplicationTreeNodeMixin):
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('Application')
|
||||
unique_together = [('org_id', 'name')]
|
||||
ordering = ('name',)
|
||||
|
||||
|
||||
@@ -4,20 +4,23 @@
|
||||
from rest_framework import serializers
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from orgs.models import Organization
|
||||
from orgs.mixins.serializers import BulkOrgResourceModelSerializer
|
||||
from assets.serializers.base import AuthSerializerMixin
|
||||
from common.drf.serializers import MethodSerializer
|
||||
from .attrs import category_serializer_classes_mapping, type_serializer_classes_mapping
|
||||
from .attrs import (
|
||||
category_serializer_classes_mapping,
|
||||
type_serializer_classes_mapping
|
||||
)
|
||||
from .. import models
|
||||
from .. import const
|
||||
|
||||
__all__ = [
|
||||
'ApplicationSerializer', 'ApplicationSerializerMixin',
|
||||
'ApplicationAccountSerializer', 'ApplicationAccountSecretSerializer'
|
||||
'AppSerializer', 'MiniAppSerializer', 'AppSerializerMixin',
|
||||
'AppAccountSerializer', 'AppAccountSecretSerializer'
|
||||
]
|
||||
|
||||
|
||||
class ApplicationSerializerMixin(serializers.Serializer):
|
||||
class AppSerializerMixin(serializers.Serializer):
|
||||
attrs = MethodSerializer()
|
||||
|
||||
def get_attrs_serializer(self):
|
||||
@@ -45,8 +48,14 @@ class ApplicationSerializerMixin(serializers.Serializer):
|
||||
serializer = serializer_class
|
||||
return serializer
|
||||
|
||||
def create(self, validated_data):
|
||||
return super().create(validated_data)
|
||||
|
||||
class ApplicationSerializer(ApplicationSerializerMixin, BulkOrgResourceModelSerializer):
|
||||
def update(self, instance, validated_data):
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
class AppSerializer(AppSerializerMixin, BulkOrgResourceModelSerializer):
|
||||
category_display = serializers.ReadOnlyField(source='get_category_display', label=_('Category display'))
|
||||
type_display = serializers.ReadOnlyField(source='get_type_display', label=_('Type display'))
|
||||
|
||||
@@ -69,42 +78,63 @@ class ApplicationSerializer(ApplicationSerializerMixin, BulkOrgResourceModelSeri
|
||||
return _attrs
|
||||
|
||||
|
||||
class ApplicationAccountSerializer(serializers.Serializer):
|
||||
id = serializers.ReadOnlyField(label=_("Id"), source='uid')
|
||||
username = serializers.ReadOnlyField(label=_("Username"))
|
||||
password = serializers.CharField(write_only=True, label=_("Password"))
|
||||
systemuser = serializers.ReadOnlyField(label=_('System user'))
|
||||
systemuser_display = serializers.ReadOnlyField(label=_("System user display"))
|
||||
app = serializers.ReadOnlyField(label=_('App'))
|
||||
app_name = serializers.ReadOnlyField(label=_("Application name"), read_only=True)
|
||||
class MiniAppSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = models.Application
|
||||
fields = AppSerializer.Meta.fields_mini
|
||||
|
||||
|
||||
class AppAccountSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
|
||||
category = serializers.ChoiceField(label=_('Category'), choices=const.AppCategory.choices, read_only=True)
|
||||
category_display = serializers.SerializerMethodField(label=_('Category display'))
|
||||
type = serializers.ChoiceField(label=_('Type'), choices=const.AppType.choices, read_only=True)
|
||||
type_display = serializers.SerializerMethodField(label=_('Type display'))
|
||||
uid = serializers.ReadOnlyField(label=_("Union id"))
|
||||
org_id = serializers.ReadOnlyField(label=_("Organization"))
|
||||
org_name = serializers.SerializerMethodField(label=_("Org name"))
|
||||
|
||||
category_mapper = dict(const.AppCategory.choices)
|
||||
type_mapper = dict(const.AppType.choices)
|
||||
|
||||
def create(self, validated_data):
|
||||
pass
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
pass
|
||||
class Meta:
|
||||
model = models.Account
|
||||
fields_mini = ['id', 'username', 'version']
|
||||
fields_write_only = ['password', 'private_key']
|
||||
fields_fk = ['systemuser', 'systemuser_display', 'app', 'app_display']
|
||||
fields = fields_mini + fields_fk + fields_write_only + [
|
||||
'type', 'type_display', 'category', 'category_display',
|
||||
]
|
||||
extra_kwargs = {
|
||||
'username': {'default': '', 'required': False},
|
||||
'password': {'write_only': True},
|
||||
'app_display': {'label': _('Application display')},
|
||||
'systemuser_display': {'label': _('System User')}
|
||||
}
|
||||
use_model_bulk_create = True
|
||||
model_bulk_create_kwargs = {
|
||||
'ignore_conflicts': True
|
||||
}
|
||||
|
||||
def get_category_display(self, obj):
|
||||
return self.category_mapper.get(obj['category'])
|
||||
return self.category_mapper.get(obj.category)
|
||||
|
||||
def get_type_display(self, obj):
|
||||
return self.type_mapper.get(obj['type'])
|
||||
return self.type_mapper.get(obj.type)
|
||||
|
||||
@staticmethod
|
||||
def get_org_name(obj):
|
||||
org = Organization.get_instance(obj['org_id'])
|
||||
return org.name
|
||||
@classmethod
|
||||
def setup_eager_loading(cls, queryset):
|
||||
""" Perform necessary eager loading of data. """
|
||||
queryset = queryset.prefetch_related('systemuser', 'app')
|
||||
return queryset
|
||||
|
||||
def to_representation(self, instance):
|
||||
instance.load_auth()
|
||||
return super().to_representation(instance)
|
||||
|
||||
|
||||
class ApplicationAccountSecretSerializer(ApplicationAccountSerializer):
|
||||
password = serializers.CharField(write_only=False, label=_("Password"))
|
||||
class AppAccountSecretSerializer(AppAccountSerializer):
|
||||
class Meta(AppAccountSerializer.Meta):
|
||||
extra_kwargs = {
|
||||
'password': {'write_only': False},
|
||||
'private_key': {'write_only': False},
|
||||
'public_key': {'write_only': False},
|
||||
'app_display': {'label': _('Application display')},
|
||||
'systemuser_display': {'label': _('System User')}
|
||||
}
|
||||
|
||||
@@ -64,8 +64,8 @@ class AccountViewSet(OrgBulkModelViewSet):
|
||||
permission_classes = (IsOrgAdmin,)
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()\
|
||||
.annotate(ip=F('asset__ip'))\
|
||||
queryset = super().get_queryset() \
|
||||
.annotate(ip=F('asset__ip')) \
|
||||
.annotate(hostname=F('asset__hostname'))
|
||||
return queryset
|
||||
|
||||
@@ -110,4 +110,5 @@ class AccountTaskCreateAPI(CreateAPIView):
|
||||
def get_exception_handler(self):
|
||||
def handler(e, context):
|
||||
return Response({"error": str(e)}, status=400)
|
||||
|
||||
return handler
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -2,14 +2,22 @@
|
||||
#
|
||||
from assets.api import FilterAssetByNodeMixin
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
from rest_framework.generics import RetrieveAPIView
|
||||
from rest_framework.generics import RetrieveAPIView, ListAPIView
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.db.models import Q
|
||||
|
||||
from common.utils import get_logger, get_object_or_none
|
||||
from common.permissions import IsOrgAdmin, IsOrgAdminOrAppUser, IsSuperUser
|
||||
from common.mixins.api import SuggestionMixin
|
||||
from users.models import User, UserGroup
|
||||
from users.serializers import UserSerializer, UserGroupSerializer
|
||||
from users.filters import UserFilter
|
||||
from perms.models import AssetPermission
|
||||
from perms.serializers import AssetPermissionSerializer
|
||||
from perms.filters import AssetPermissionFilter
|
||||
from orgs.mixins.api import OrgBulkModelViewSet
|
||||
from orgs.mixins import generics
|
||||
from ..models import Asset, Node, Platform, SystemUser
|
||||
from ..models import Asset, Node, Platform
|
||||
from .. import serializers
|
||||
from ..tasks import (
|
||||
update_assets_hardware_info_manual, test_assets_connectivity_manual,
|
||||
@@ -17,16 +25,17 @@ from ..tasks import (
|
||||
)
|
||||
from ..filters import FilterAssetByNodeFilterBackend, LabelFilterBackend, IpInFilterBackend
|
||||
|
||||
|
||||
logger = get_logger(__file__)
|
||||
__all__ = [
|
||||
'AssetViewSet', 'AssetPlatformRetrieveApi',
|
||||
'AssetGatewayListApi', 'AssetPlatformViewSet',
|
||||
'AssetTaskCreateApi', 'AssetsTaskCreateApi',
|
||||
'AssetPermUserListApi', 'AssetPermUserPermissionsListApi',
|
||||
'AssetPermUserGroupListApi', 'AssetPermUserGroupPermissionsListApi',
|
||||
]
|
||||
|
||||
|
||||
class AssetViewSet(FilterAssetByNodeMixin, OrgBulkModelViewSet):
|
||||
class AssetViewSet(SuggestionMixin, FilterAssetByNodeMixin, OrgBulkModelViewSet):
|
||||
"""
|
||||
API endpoint that allows Asset to be viewed or edited.
|
||||
"""
|
||||
@@ -41,8 +50,10 @@ class AssetViewSet(FilterAssetByNodeMixin, OrgBulkModelViewSet):
|
||||
}
|
||||
search_fields = ("hostname", "ip")
|
||||
ordering_fields = ("hostname", "ip", "port", "cpu_cores")
|
||||
ordering = ('hostname', )
|
||||
serializer_classes = {
|
||||
'default': serializers.AssetSerializer,
|
||||
'suggestion': serializers.MiniAssetSerializer
|
||||
}
|
||||
permission_classes = (IsOrgAdminOrAppUser,)
|
||||
extra_filter_backends = [FilterAssetByNodeFilterBackend, LabelFilterBackend, IpInFilterBackend]
|
||||
@@ -169,3 +180,102 @@ class AssetGatewayListApi(generics.ListAPIView):
|
||||
return []
|
||||
queryset = asset.domain.gateways.filter(protocol='ssh')
|
||||
return queryset
|
||||
|
||||
|
||||
class BaseAssetPermUserOrUserGroupListApi(ListAPIView):
|
||||
permission_classes = (IsOrgAdmin,)
|
||||
|
||||
def get_object(self):
|
||||
asset_id = self.kwargs.get('pk')
|
||||
asset = get_object_or_404(Asset, pk=asset_id)
|
||||
return asset
|
||||
|
||||
def get_asset_related_perms(self):
|
||||
asset = self.get_object()
|
||||
nodes = asset.get_all_nodes(flat=True)
|
||||
perms = AssetPermission.objects.filter(Q(assets=asset) | Q(nodes__in=nodes))
|
||||
return perms
|
||||
|
||||
|
||||
class AssetPermUserListApi(BaseAssetPermUserOrUserGroupListApi):
|
||||
filterset_class = UserFilter
|
||||
search_fields = ('username', 'email', 'name', 'id', 'source', 'role')
|
||||
serializer_class = UserSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
perms = self.get_asset_related_perms()
|
||||
users = User.objects.filter(
|
||||
Q(assetpermissions__in=perms) | Q(groups__assetpermissions__in=perms)
|
||||
).distinct()
|
||||
return users
|
||||
|
||||
|
||||
class AssetPermUserGroupListApi(BaseAssetPermUserOrUserGroupListApi):
|
||||
serializer_class = UserGroupSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
perms = self.get_asset_related_perms()
|
||||
user_groups = UserGroup.objects.filter(assetpermissions__in=perms).distinct()
|
||||
return user_groups
|
||||
|
||||
|
||||
class BaseAssetPermUserOrUserGroupPermissionsListApiMixin(generics.ListAPIView):
|
||||
permission_classes = (IsOrgAdmin,)
|
||||
model = AssetPermission
|
||||
serializer_class = AssetPermissionSerializer
|
||||
filterset_class = AssetPermissionFilter
|
||||
search_fields = ('name',)
|
||||
|
||||
def get_object(self):
|
||||
asset_id = self.kwargs.get('pk')
|
||||
asset = get_object_or_404(Asset, pk=asset_id)
|
||||
return asset
|
||||
|
||||
def filter_asset_related(self, queryset):
|
||||
asset = self.get_object()
|
||||
nodes = asset.get_all_nodes(flat=True)
|
||||
perms = queryset.filter(Q(assets=asset) | Q(nodes__in=nodes))
|
||||
return perms
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
queryset = super().filter_queryset(queryset)
|
||||
queryset = self.filter_asset_related(queryset)
|
||||
return queryset
|
||||
|
||||
|
||||
class AssetPermUserPermissionsListApi(BaseAssetPermUserOrUserGroupPermissionsListApiMixin):
|
||||
def filter_queryset(self, queryset):
|
||||
queryset = super().filter_queryset(queryset)
|
||||
queryset = self.filter_user_related(queryset)
|
||||
queryset = queryset.distinct()
|
||||
return queryset
|
||||
|
||||
def filter_user_related(self, queryset):
|
||||
user = self.get_perm_user()
|
||||
user_groups = user.groups.all()
|
||||
perms = queryset.filter(Q(users=user) | Q(user_groups__in=user_groups))
|
||||
return perms
|
||||
|
||||
def get_perm_user(self):
|
||||
user_id = self.kwargs.get('perm_user_id')
|
||||
user = get_object_or_404(User, pk=user_id)
|
||||
return user
|
||||
|
||||
|
||||
class AssetPermUserGroupPermissionsListApi(BaseAssetPermUserOrUserGroupPermissionsListApiMixin):
|
||||
def filter_queryset(self, queryset):
|
||||
queryset = super().filter_queryset(queryset)
|
||||
queryset = self.filter_user_group_related(queryset)
|
||||
queryset = queryset.distinct()
|
||||
return queryset
|
||||
|
||||
def filter_user_group_related(self, queryset):
|
||||
user_group = self.get_perm_user_group()
|
||||
perms = queryset.filter(user_groups=user_group)
|
||||
return perms
|
||||
|
||||
def get_perm_user_group(self):
|
||||
user_group_id = self.kwargs.get('perm_user_group_id')
|
||||
user_group = get_object_or_404(UserGroup, pk=user_group_id)
|
||||
return user_group
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ from ..hands import IsOrgAdmin, IsAppUser
|
||||
from ..models import CommandFilter, CommandFilterRule
|
||||
from .. import serializers
|
||||
|
||||
|
||||
__all__ = [
|
||||
'CommandFilterViewSet', 'CommandFilterRuleViewSet', 'CommandConfirmAPI',
|
||||
'CommandConfirmStatusAPI'
|
||||
@@ -44,7 +43,7 @@ class CommandFilterRuleViewSet(OrgBulkModelViewSet):
|
||||
|
||||
|
||||
class CommandConfirmAPI(CreateAPIView):
|
||||
permission_classes = (IsAppUser, )
|
||||
permission_classes = (IsAppUser,)
|
||||
serializer_class = serializers.CommandConfirmSerializer
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
@@ -73,11 +72,12 @@ class CommandConfirmAPI(CreateAPIView):
|
||||
external=True, api_to_ui=True
|
||||
)
|
||||
ticket_detail_url = '{url}?type={type}'.format(url=ticket_detail_url, type=ticket.type)
|
||||
ticket_assignees = ticket.current_node.first().ticket_assignees.all()
|
||||
return {
|
||||
'check_confirm_status': {'method': 'GET', 'url': confirm_status_url},
|
||||
'close_confirm': {'method': 'DELETE', 'url': confirm_status_url},
|
||||
'ticket_detail_url': ticket_detail_url,
|
||||
'reviewers': [str(user) for user in ticket.assignees.all()]
|
||||
'reviewers': [str(ticket_assignee.assignee) for ticket_assignee in ticket_assignees]
|
||||
}
|
||||
|
||||
@lazyproperty
|
||||
@@ -89,4 +89,3 @@ class CommandConfirmAPI(CreateAPIView):
|
||||
|
||||
class CommandConfirmStatusAPI(GenericTicketStatusRetrieveCloseAPI):
|
||||
pass
|
||||
|
||||
|
||||
@@ -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'):
|
||||
|
||||
@@ -6,6 +6,7 @@ from common.utils import get_logger
|
||||
from common.permissions import IsOrgAdmin, IsOrgAdminOrAppUser, IsValidUser
|
||||
from orgs.mixins.api import OrgBulkModelViewSet
|
||||
from orgs.mixins import generics
|
||||
from common.mixins.api import SuggestionMixin
|
||||
from orgs.utils import tmp_to_root_org
|
||||
from ..models import SystemUser, Asset
|
||||
from .. import serializers
|
||||
@@ -15,7 +16,6 @@ from ..tasks import (
|
||||
push_system_user_to_assets
|
||||
)
|
||||
|
||||
|
||||
logger = get_logger(__file__)
|
||||
__all__ = [
|
||||
'SystemUserViewSet', 'SystemUserAuthInfoApi', 'SystemUserAssetAuthInfoApi',
|
||||
@@ -24,7 +24,7 @@ __all__ = [
|
||||
]
|
||||
|
||||
|
||||
class SystemUserViewSet(OrgBulkModelViewSet):
|
||||
class SystemUserViewSet(SuggestionMixin, OrgBulkModelViewSet):
|
||||
"""
|
||||
System user api set, for add,delete,update,list,retrieve resource
|
||||
"""
|
||||
@@ -39,7 +39,10 @@ class SystemUserViewSet(OrgBulkModelViewSet):
|
||||
serializer_class = serializers.SystemUserSerializer
|
||||
serializer_classes = {
|
||||
'default': serializers.SystemUserSerializer,
|
||||
'suggestion': serializers.MiniSystemUserSerializer
|
||||
}
|
||||
ordering_fields = ('name', 'protocol', 'login_mode')
|
||||
ordering = ('name', )
|
||||
permission_classes = (IsOrgAdminOrAppUser,)
|
||||
|
||||
|
||||
@@ -72,7 +75,7 @@ class SystemUserTempAuthInfoApi(generics.CreateAPIView):
|
||||
|
||||
with tmp_to_root_org():
|
||||
instance = get_object_or_404(SystemUser, pk=pk)
|
||||
instance.set_temp_auth(instance_id, user, data)
|
||||
instance.set_temp_auth(instance_id, user.id, data)
|
||||
return Response(serializer.data, status=201)
|
||||
|
||||
|
||||
@@ -89,7 +92,7 @@ class SystemUserAssetAuthInfoApi(generics.RetrieveAPIView):
|
||||
asset_id = self.kwargs.get('asset_id')
|
||||
user_id = self.request.query_params.get("user_id")
|
||||
username = self.request.query_params.get("username")
|
||||
instance.load_asset_more_auth(asset_id=asset_id, user_id=user_id, username=username)
|
||||
instance.load_asset_more_auth(asset_id, username, user_id)
|
||||
return instance
|
||||
|
||||
|
||||
@@ -105,8 +108,7 @@ class SystemUserAppAuthInfoApi(generics.RetrieveAPIView):
|
||||
instance = super().get_object()
|
||||
app_id = self.kwargs.get('app_id')
|
||||
user_id = self.request.query_params.get("user_id")
|
||||
if user_id:
|
||||
instance.load_app_more_auth(app_id, user_id)
|
||||
instance.load_app_more_auth(app_id, user_id)
|
||||
return instance
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
from collections import defaultdict
|
||||
from django.db.models import F, Value
|
||||
from django.db.models import F, Value, Model
|
||||
from django.db.models.signals import m2m_changed
|
||||
from django.db.models.functions import Concat
|
||||
|
||||
@@ -13,13 +13,15 @@ from .. import models, serializers
|
||||
|
||||
__all__ = [
|
||||
'SystemUserAssetRelationViewSet', 'SystemUserNodeRelationViewSet',
|
||||
'SystemUserUserRelationViewSet',
|
||||
'SystemUserUserRelationViewSet', 'BaseRelationViewSet',
|
||||
]
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class RelationMixin:
|
||||
model: Model
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = self.model.objects.all()
|
||||
if not current_org.is_root():
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# Generated by Django 3.1.6 on 2021-06-04 16:46
|
||||
|
||||
import uuid
|
||||
from django.db import migrations, models, transaction
|
||||
import django.db.models.deletion
|
||||
from django.db import IntegrityError
|
||||
from django.db.models import F
|
||||
|
||||
|
||||
@@ -15,7 +16,7 @@ def migrate_admin_user_to_system_user(apps, schema_editor):
|
||||
for admin_user in admin_users:
|
||||
kwargs = {}
|
||||
for attr in [
|
||||
'id', 'org_id', 'username', 'password', 'private_key', 'public_key',
|
||||
'org_id', 'username', 'password', 'private_key', 'public_key',
|
||||
'comment', 'date_created', 'date_updated', 'created_by',
|
||||
]:
|
||||
value = getattr(admin_user, attr)
|
||||
@@ -27,7 +28,16 @@ def migrate_admin_user_to_system_user(apps, schema_editor):
|
||||
).exists()
|
||||
if exist:
|
||||
name = admin_user.name + '_' + str(admin_user.id)[:5]
|
||||
|
||||
i = admin_user.id
|
||||
exist = system_user_model.objects.using(db_alias).filter(
|
||||
id=i, org_id=admin_user.org_id
|
||||
).exists()
|
||||
if exist:
|
||||
i = uuid.uuid4()
|
||||
|
||||
kwargs.update({
|
||||
'id': i,
|
||||
'name': name,
|
||||
'type': 'admin',
|
||||
'protocol': 'ssh',
|
||||
@@ -36,7 +46,11 @@ def migrate_admin_user_to_system_user(apps, schema_editor):
|
||||
|
||||
with transaction.atomic():
|
||||
s = system_user_model(**kwargs)
|
||||
s.save()
|
||||
try:
|
||||
s.save()
|
||||
except IntegrityError:
|
||||
s.id = None
|
||||
s.save()
|
||||
print(" Migrate admin user to system user: {} => {}".format(admin_user.name, s.name))
|
||||
assets = admin_user.assets.all()
|
||||
s.assets.set(assets)
|
||||
|
||||
@@ -18,7 +18,7 @@ def migrate_old_authbook_to_history(apps, schema_editor):
|
||||
|
||||
print()
|
||||
while True:
|
||||
authbooks = authbook_model.objects.using(db_alias).filter(is_latest=False)[:20]
|
||||
authbooks = authbook_model.objects.using(db_alias).filter(is_latest=False)[:1000]
|
||||
if not authbooks:
|
||||
break
|
||||
historys = []
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
24
apps/assets/migrations/0077_auto_20211012_1642.py
Normal file
24
apps/assets/migrations/0077_auto_20211012_1642.py
Normal 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)
|
||||
]
|
||||
38
apps/assets/migrations/0078_auto_20211014_2209.py
Normal file
38
apps/assets/migrations/0078_auto_20211014_2209.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
@@ -5,9 +5,12 @@ from django.db import models
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from simple_history.models import HistoricalRecords
|
||||
|
||||
from common.utils import lazyproperty
|
||||
from common.utils import lazyproperty, get_logger
|
||||
from .base import BaseUser, AbsConnectivity
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
__all__ = ['AuthBook']
|
||||
|
||||
|
||||
@@ -16,7 +19,6 @@ class AuthBook(BaseUser, AbsConnectivity):
|
||||
systemuser = models.ForeignKey('assets.SystemUser', on_delete=models.CASCADE, null=True, verbose_name=_("System user"))
|
||||
version = models.IntegerField(default=1, verbose_name=_('Version'))
|
||||
history = HistoricalRecords()
|
||||
_systemuser_display = ''
|
||||
|
||||
auth_attrs = ['username', 'password', 'private_key', 'public_key']
|
||||
|
||||
@@ -64,8 +66,6 @@ class AuthBook(BaseUser, AbsConnectivity):
|
||||
|
||||
@lazyproperty
|
||||
def systemuser_display(self):
|
||||
if self._systemuser_display:
|
||||
return self._systemuser_display
|
||||
if not self.systemuser:
|
||||
return ''
|
||||
return str(self.systemuser)
|
||||
@@ -94,7 +94,27 @@ class AuthBook(BaseUser, AbsConnectivity):
|
||||
i.private_key = self.private_key
|
||||
i.public_key = self.public_key
|
||||
i.comment = 'Update triggered by account {}'.format(self.id)
|
||||
i.save(update_fields=['password', 'private_key', 'public_key'])
|
||||
|
||||
# 不触发post_save信号
|
||||
self.__class__.objects.bulk_update(matched, fields=['password', 'private_key', 'public_key'])
|
||||
|
||||
def remove_asset_admin_user_if_need(self):
|
||||
if not self.asset or not self.systemuser:
|
||||
return
|
||||
if not self.systemuser.is_admin_user or self.asset.admin_user != self.systemuser:
|
||||
return
|
||||
self.asset.admin_user = None
|
||||
self.asset.save()
|
||||
logger.debug('Remove asset admin user: {} {}'.format(self.asset, self.systemuser))
|
||||
|
||||
def update_asset_admin_user_if_need(self):
|
||||
if not self.asset or not self.systemuser:
|
||||
return
|
||||
if not self.systemuser.is_admin_user or self.asset.admin_user == self.systemuser:
|
||||
return
|
||||
self.asset.admin_user = self.systemuser
|
||||
self.asset.save()
|
||||
logger.debug('Update asset admin user: {} {}'.format(self.asset, self.systemuser))
|
||||
|
||||
def __str__(self):
|
||||
return self.smart_name
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -105,11 +105,11 @@ class CommandFilterRule(OrgModelMixin):
|
||||
return '{} % {}'.format(self.type, self.content)
|
||||
|
||||
def create_command_confirm_ticket(self, run_command, session, cmd_filter_rule, org_id):
|
||||
from tickets.const import TicketTypeChoices
|
||||
from tickets.const import TicketType
|
||||
from tickets.models import Ticket
|
||||
data = {
|
||||
'title': _('Command confirm') + ' ({})'.format(session.user),
|
||||
'type': TicketTypeChoices.command_confirm,
|
||||
'type': TicketType.command_confirm,
|
||||
'meta': {
|
||||
'apply_run_user': session.user,
|
||||
'apply_run_asset': session.asset,
|
||||
@@ -122,6 +122,6 @@ class CommandFilterRule(OrgModelMixin):
|
||||
'org_id': org_id,
|
||||
}
|
||||
ticket = Ticket.objects.create(**data)
|
||||
ticket.assignees.set(self.reviewers.all())
|
||||
ticket.create_process_map_and_node(self.reviewers.all())
|
||||
ticket.open(applicant=session.user_obj)
|
||||
return ticket
|
||||
|
||||
@@ -120,6 +120,7 @@ class Gateway(BaseUser):
|
||||
except(paramiko.AuthenticationException,
|
||||
paramiko.BadAuthenticationType,
|
||||
paramiko.SSHException,
|
||||
paramiko.ChannelException,
|
||||
paramiko.ssh_exception.NoValidConnectionsError,
|
||||
socket.gaierror) as e:
|
||||
err = str(e)
|
||||
@@ -128,6 +129,8 @@ class Gateway(BaseUser):
|
||||
err = err.format(port=self.port, ip=self.ip)
|
||||
elif err == 'Authentication failed.':
|
||||
err = _('Authentication failed')
|
||||
elif err == 'Connect failed':
|
||||
err = _('Connect failed')
|
||||
self.is_connective = False
|
||||
return False, err
|
||||
|
||||
@@ -141,10 +144,17 @@ class Gateway(BaseUser):
|
||||
key_filename=self.private_key_file,
|
||||
sock=sock,
|
||||
timeout=5)
|
||||
except (paramiko.SSHException, paramiko.ssh_exception.SSHException,
|
||||
paramiko.AuthenticationException, TimeoutError) as e:
|
||||
except (paramiko.SSHException,
|
||||
paramiko.ssh_exception.SSHException,
|
||||
paramiko.ChannelException,
|
||||
paramiko.AuthenticationException,
|
||||
TimeoutError) as e:
|
||||
|
||||
err = getattr(e, 'text', str(e))
|
||||
if err == 'Connect failed':
|
||||
err = _('Connect failed')
|
||||
self.is_connective = False
|
||||
return False, str(e)
|
||||
return False, err
|
||||
finally:
|
||||
client.close()
|
||||
self.is_connective = True
|
||||
|
||||
@@ -73,6 +73,10 @@ class ProtocolMixin:
|
||||
def can_perm_to_asset(self):
|
||||
return self.protocol in self.ASSET_CATEGORY_PROTOCOLS
|
||||
|
||||
@property
|
||||
def is_asset_protocol(self):
|
||||
return self.protocol in self.ASSET_CATEGORY_PROTOCOLS
|
||||
|
||||
|
||||
class AuthMixin:
|
||||
username_same_with_user: bool
|
||||
@@ -99,16 +103,23 @@ class AuthMixin:
|
||||
password = cache.get(key)
|
||||
return password
|
||||
|
||||
def load_tmp_auth_if_has(self, asset_or_app_id, user):
|
||||
if not asset_or_app_id or not user:
|
||||
return
|
||||
def _clean_auth_info_if_manual_login_mode(self):
|
||||
if self.login_mode == self.LOGIN_MANUAL:
|
||||
self.password = ''
|
||||
self.private_key = ''
|
||||
self.public_key = ''
|
||||
|
||||
def _load_tmp_auth_if_has(self, asset_or_app_id, user_id):
|
||||
if self.login_mode != self.LOGIN_MANUAL:
|
||||
return
|
||||
|
||||
auth = self.get_temp_auth(asset_or_app_id, user)
|
||||
if not asset_or_app_id or not user_id:
|
||||
return
|
||||
|
||||
auth = self.get_temp_auth(asset_or_app_id, user_id)
|
||||
if not auth:
|
||||
return
|
||||
|
||||
username = auth.get('username')
|
||||
password = auth.get('password')
|
||||
|
||||
@@ -118,17 +129,11 @@ class AuthMixin:
|
||||
self.password = password
|
||||
|
||||
def load_app_more_auth(self, app_id=None, user_id=None):
|
||||
from users.models import User
|
||||
|
||||
self._clean_auth_info_if_manual_login_mode()
|
||||
# 加载临时认证信息
|
||||
if self.login_mode == self.LOGIN_MANUAL:
|
||||
self.password = ''
|
||||
self.private_key = ''
|
||||
if not user_id:
|
||||
self._load_tmp_auth_if_has(app_id, user_id)
|
||||
return
|
||||
user = get_object_or_none(User, pk=user_id)
|
||||
if not user:
|
||||
return
|
||||
self.load_tmp_auth_if_has(app_id, user)
|
||||
|
||||
def load_asset_special_auth(self, asset, username=''):
|
||||
"""
|
||||
@@ -148,34 +153,25 @@ class AuthMixin:
|
||||
|
||||
def load_asset_more_auth(self, asset_id=None, username=None, user_id=None):
|
||||
from users.models import User
|
||||
|
||||
self._clean_auth_info_if_manual_login_mode()
|
||||
# 加载临时认证信息
|
||||
if self.login_mode == self.LOGIN_MANUAL:
|
||||
self.password = ''
|
||||
self.private_key = ''
|
||||
|
||||
asset = None
|
||||
if asset_id:
|
||||
asset = get_object_or_none(Asset, pk=asset_id)
|
||||
# 没有资产就没有必要继续了
|
||||
if not asset:
|
||||
logger.debug('Asset not found, pass')
|
||||
self._load_tmp_auth_if_has(asset_id, user_id)
|
||||
return
|
||||
|
||||
user = None
|
||||
if user_id:
|
||||
user = get_object_or_none(User, pk=user_id)
|
||||
|
||||
_username = self.username
|
||||
# 更新用户名
|
||||
user = get_object_or_none(User, pk=user_id) if user_id else None
|
||||
if self.username_same_with_user:
|
||||
if user and not username:
|
||||
_username = user.username
|
||||
else:
|
||||
_username = username
|
||||
self.username = _username
|
||||
|
||||
# 加载某个资产的特殊配置认证信息
|
||||
self.load_asset_special_auth(asset, _username)
|
||||
self.load_tmp_auth_if_has(asset_id, user)
|
||||
asset = get_object_or_none(Asset, pk=asset_id) if asset_id else None
|
||||
if not asset:
|
||||
logger.debug('Asset not found, pass')
|
||||
return
|
||||
self.load_asset_special_auth(asset, self.username)
|
||||
|
||||
|
||||
class SystemUser(ProtocolMixin, AuthMixin, BaseUser):
|
||||
|
||||
@@ -5,12 +5,14 @@ from django.core.validators import RegexValidator
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from orgs.mixins.serializers import BulkOrgResourceModelSerializer
|
||||
from users.models import User, UserGroup
|
||||
from perms.models import AssetPermission
|
||||
from ..models import Asset, Node, Platform, SystemUser
|
||||
|
||||
__all__ = [
|
||||
'AssetSerializer', 'AssetSimpleSerializer',
|
||||
'AssetSerializer', 'AssetSimpleSerializer', 'MiniAssetSerializer',
|
||||
'ProtocolsField', 'PlatformSerializer',
|
||||
'AssetTaskSerializer', 'AssetsTaskSerializer', 'ProtocolsField'
|
||||
'AssetTaskSerializer', 'AssetsTaskSerializer', 'ProtocolsField',
|
||||
]
|
||||
|
||||
|
||||
@@ -69,15 +71,19 @@ class AssetSerializer(BulkOrgResourceModelSerializer):
|
||||
"""
|
||||
资产的数据结构
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
model = Asset
|
||||
fields_mini = ['id', 'hostname', 'ip']
|
||||
fields_mini = ['id', 'hostname', 'ip', 'platform', 'protocols']
|
||||
fields_small = fields_mini + [
|
||||
'protocol', 'port', 'protocols', 'is_active', 'public_ip',
|
||||
'number', 'vendor', 'model', 'sn', 'cpu_model', 'cpu_count',
|
||||
'protocol', 'port', 'protocols', 'is_active',
|
||||
'public_ip', 'number', 'comment',
|
||||
]
|
||||
hardware_fields = [
|
||||
'vendor', 'model', 'sn', 'cpu_model', 'cpu_count',
|
||||
'cpu_cores', 'cpu_vcpus', 'memory', 'disk_total', 'disk_info',
|
||||
'os', 'os_version', 'os_arch', 'hostname_raw', 'comment',
|
||||
'hardware_info', 'connectivity', 'date_verified'
|
||||
'os', 'os_version', 'os_arch', 'hostname_raw', 'hardware_info',
|
||||
'connectivity', 'date_verified'
|
||||
]
|
||||
fields_fk = [
|
||||
'domain', 'domain_display', 'platform', 'admin_user', 'admin_user_display'
|
||||
@@ -88,15 +94,16 @@ class AssetSerializer(BulkOrgResourceModelSerializer):
|
||||
read_only_fields = [
|
||||
'created_by', 'date_created',
|
||||
]
|
||||
fields = fields_small + fields_fk + fields_m2m + read_only_fields
|
||||
fields = fields_small + hardware_fields + fields_fk + fields_m2m + read_only_fields
|
||||
|
||||
extra_kwargs = {
|
||||
extra_kwargs = {k: {'read_only': True} for k in hardware_fields}
|
||||
extra_kwargs.update({
|
||||
'protocol': {'write_only': True},
|
||||
'port': {'write_only': True},
|
||||
'hardware_info': {'label': _('Hardware info')},
|
||||
'org_name': {'label': _('Org name')},
|
||||
'admin_user_display': {'label': _('Admin user display')}
|
||||
}
|
||||
'hardware_info': {'label': _('Hardware info'), 'read_only': True},
|
||||
'org_name': {'label': _('Org name'), 'read_only': True},
|
||||
'admin_user_display': {'label': _('Admin user display'), 'read_only': True},
|
||||
})
|
||||
|
||||
def get_fields(self):
|
||||
fields = super().get_fields()
|
||||
@@ -157,6 +164,12 @@ class AssetSerializer(BulkOrgResourceModelSerializer):
|
||||
return instance
|
||||
|
||||
|
||||
class MiniAssetSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Asset
|
||||
fields = AssetSerializer.Meta.fields_mini
|
||||
|
||||
|
||||
class PlatformSerializer(serializers.ModelSerializer):
|
||||
meta = serializers.DictField(required=False, allow_null=True, label=_('Meta'))
|
||||
|
||||
@@ -177,7 +190,6 @@ class PlatformSerializer(serializers.ModelSerializer):
|
||||
|
||||
|
||||
class AssetSimpleSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Asset
|
||||
fields = ['id', 'hostname', 'ip', 'port', 'connectivity', 'date_verified']
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -4,17 +4,18 @@ from django.db.models import Count
|
||||
|
||||
from common.mixins.serializers import BulkSerializerMixin
|
||||
from common.utils import ssh_pubkey_gen
|
||||
from common.validators import alphanumeric_re, alphanumeric_cn_re
|
||||
from orgs.mixins.serializers import BulkOrgResourceModelSerializer
|
||||
from ..models import SystemUser, Asset
|
||||
from .utils import validate_password_contains_left_double_curly_bracket
|
||||
from .base import AuthSerializerMixin
|
||||
|
||||
__all__ = [
|
||||
'SystemUserSerializer',
|
||||
'SystemUserSerializer', 'MiniSystemUserSerializer',
|
||||
'SystemUserSimpleSerializer', 'SystemUserAssetRelationSerializer',
|
||||
'SystemUserNodeRelationSerializer', 'SystemUserTaskSerializer',
|
||||
'SystemUserUserRelationSerializer', 'SystemUserWithAuthInfoSerializer',
|
||||
'SystemUserTempAuthSerializer',
|
||||
'SystemUserTempAuthSerializer', 'RelationMixin',
|
||||
]
|
||||
|
||||
|
||||
@@ -25,20 +26,23 @@ class SystemUserSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
|
||||
auto_generate_key = serializers.BooleanField(initial=True, required=False, write_only=True)
|
||||
type_display = serializers.ReadOnlyField(source='get_type_display', label=_('Type display'))
|
||||
ssh_key_fingerprint = serializers.ReadOnlyField(label=_('SSH key fingerprint'))
|
||||
applications_amount = serializers.IntegerField(
|
||||
source='apps_amount', read_only=True, label=_('Apps amount')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = SystemUser
|
||||
fields_mini = ['id', 'name', 'username']
|
||||
fields_write_only = ['password', 'public_key', 'private_key']
|
||||
fields_small = fields_mini + fields_write_only + [
|
||||
'type', 'type_display', 'protocol', 'login_mode', 'login_mode_display',
|
||||
'priority', 'sudo', 'shell', 'sftp_root', 'token', 'ssh_key_fingerprint',
|
||||
'home', 'system_groups', 'ad_domain',
|
||||
'token', 'ssh_key_fingerprint',
|
||||
'type', 'type_display', 'protocol', 'is_asset_protocol',
|
||||
'login_mode', 'login_mode_display', 'priority',
|
||||
'sudo', 'shell', 'sftp_root', 'home', 'system_groups', 'ad_domain',
|
||||
'username_same_with_user', 'auto_push', 'auto_generate_key',
|
||||
'date_created', 'date_updated',
|
||||
'comment', 'created_by',
|
||||
'date_created', 'date_updated', 'comment', 'created_by',
|
||||
]
|
||||
fields_m2m = ['cmd_filters', 'assets_amount']
|
||||
fields_m2m = ['cmd_filters', 'assets_amount', 'applications_amount', 'nodes']
|
||||
fields = fields_small + fields_m2m
|
||||
extra_kwargs = {
|
||||
'password': {
|
||||
@@ -53,6 +57,7 @@ class SystemUserSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
|
||||
'login_mode_display': {'label': _('Login mode display')},
|
||||
'created_by': {'read_only': True},
|
||||
'ad_domain': {'required': False, 'allow_blank': True, 'label': _('Ad domain')},
|
||||
'is_asset_protocol': {'label': _('Is asset protocol')}
|
||||
}
|
||||
|
||||
def validate_auto_push(self, value):
|
||||
@@ -96,15 +101,20 @@ class SystemUserSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
|
||||
raise serializers.ValidationError(error)
|
||||
|
||||
def validate_username(self, username):
|
||||
if username:
|
||||
return username
|
||||
login_mode = self.get_initial_value("login_mode")
|
||||
protocol = self.get_initial_value("protocol")
|
||||
username_same_with_user = self.get_initial_value("username_same_with_user")
|
||||
if username:
|
||||
regx = alphanumeric_re
|
||||
if protocol == SystemUser.Protocol.telnet:
|
||||
regx = alphanumeric_cn_re
|
||||
if not regx.match(username):
|
||||
raise serializers.ValidationError(_('Special char not allowed'))
|
||||
return username
|
||||
|
||||
username_same_with_user = self.get_initial_value("username_same_with_user")
|
||||
if username_same_with_user:
|
||||
return ''
|
||||
|
||||
login_mode = self.get_initial_value("login_mode")
|
||||
if login_mode == SystemUser.LOGIN_AUTO and protocol != SystemUser.Protocol.vnc:
|
||||
msg = _('* Automatic login mode must fill in the username.')
|
||||
raise serializers.ValidationError(msg)
|
||||
@@ -116,7 +126,8 @@ class SystemUserSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
|
||||
return ''
|
||||
return home
|
||||
|
||||
def validate_sftp_root(self, value):
|
||||
@staticmethod
|
||||
def validate_sftp_root(value):
|
||||
if value in ['home', 'tmp']:
|
||||
return value
|
||||
if not value.startswith('/'):
|
||||
@@ -124,19 +135,6 @@ class SystemUserSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
|
||||
raise serializers.ValidationError(error)
|
||||
return value
|
||||
|
||||
def validate_admin_user(self, attrs):
|
||||
if self.instance:
|
||||
tp = self.instance.type
|
||||
else:
|
||||
tp = attrs.get('type')
|
||||
if tp != SystemUser.Type.admin:
|
||||
return attrs
|
||||
attrs['protocol'] = SystemUser.Protocol.ssh
|
||||
attrs['login_mode'] = SystemUser.LOGIN_AUTO
|
||||
attrs['username_same_with_user'] = False
|
||||
attrs['auto_push'] = False
|
||||
return attrs
|
||||
|
||||
def validate_password(self, password):
|
||||
super().validate_password(password)
|
||||
auto_gen_key = self.get_initial_value("auto_generate_key", False)
|
||||
@@ -148,7 +146,20 @@ class SystemUserSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
|
||||
raise serializers.ValidationError(_("Password or private key required"))
|
||||
return password
|
||||
|
||||
def validate_gen_key(self, attrs):
|
||||
def _validate_admin_user(self, attrs):
|
||||
if self.instance:
|
||||
tp = self.instance.type
|
||||
else:
|
||||
tp = attrs.get('type')
|
||||
if tp != SystemUser.Type.admin:
|
||||
return attrs
|
||||
attrs['protocol'] = SystemUser.Protocol.ssh
|
||||
attrs['login_mode'] = SystemUser.LOGIN_AUTO
|
||||
attrs['username_same_with_user'] = False
|
||||
attrs['auto_push'] = False
|
||||
return attrs
|
||||
|
||||
def _validate_gen_key(self, attrs):
|
||||
username = attrs.get("username", "manual")
|
||||
auto_gen_key = attrs.pop("auto_generate_key", False)
|
||||
protocol = attrs.get("protocol")
|
||||
@@ -172,18 +183,40 @@ class SystemUserSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
|
||||
attrs["public_key"] = public_key
|
||||
return attrs
|
||||
|
||||
def _validate_login_mode(self, attrs):
|
||||
if 'login_mode' in attrs:
|
||||
login_mode = attrs['login_mode']
|
||||
else:
|
||||
login_mode = self.instance.login_mode if self.instance else SystemUser.LOGIN_AUTO
|
||||
|
||||
if login_mode == SystemUser.LOGIN_MANUAL:
|
||||
attrs['password'] = ''
|
||||
attrs['private_key'] = ''
|
||||
attrs['public_key'] = ''
|
||||
|
||||
return attrs
|
||||
|
||||
def validate(self, attrs):
|
||||
attrs = self.validate_admin_user(attrs)
|
||||
attrs = self.validate_gen_key(attrs)
|
||||
attrs = self._validate_admin_user(attrs)
|
||||
attrs = self._validate_gen_key(attrs)
|
||||
attrs = self._validate_login_mode(attrs)
|
||||
return attrs
|
||||
|
||||
@classmethod
|
||||
def setup_eager_loading(cls, queryset):
|
||||
""" Perform necessary eager loading of data. """
|
||||
queryset = queryset.annotate(assets_amount=Count("assets"))
|
||||
queryset = queryset\
|
||||
.annotate(assets_amount=Count("assets")) \
|
||||
.prefetch_related('nodes', 'cmd_filters')
|
||||
return queryset
|
||||
|
||||
|
||||
class MiniSystemUserSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = SystemUser
|
||||
fields = SystemUserSerializer.Meta.fields_mini
|
||||
|
||||
|
||||
class SystemUserWithAuthInfoSerializer(SystemUserSerializer):
|
||||
class Meta(SystemUserSerializer.Meta):
|
||||
fields_mini = ['id', 'name', 'username']
|
||||
@@ -207,6 +240,7 @@ class SystemUserSimpleSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
系统用户最基本信息的数据结构
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
model = SystemUser
|
||||
fields = ('id', 'name', 'username')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from django.dispatch import receiver
|
||||
from django.apps import apps
|
||||
from simple_history.signals import pre_create_historical_record
|
||||
from django.db.models.signals import post_save, pre_save
|
||||
from django.db.models.signals import post_save, pre_save, pre_delete
|
||||
|
||||
from common.utils import get_logger
|
||||
from ..models import AuthBook, SystemUser
|
||||
@@ -28,10 +28,19 @@ def pre_create_historical_record_callback(sender, history_instance=None, **kwarg
|
||||
setattr(history_instance, attr, system_user_attr_value)
|
||||
|
||||
|
||||
@receiver(pre_delete, sender=AuthBook)
|
||||
def on_authbook_post_delete(sender, instance, **kwargs):
|
||||
instance.remove_asset_admin_user_if_need()
|
||||
|
||||
|
||||
@receiver(post_save, sender=AuthBook)
|
||||
def on_authbook_post_create(sender, instance, **kwargs):
|
||||
if not instance.systemuser:
|
||||
instance.sync_to_system_user_account()
|
||||
def on_authbook_post_create(sender, instance, created, **kwargs):
|
||||
instance.sync_to_system_user_account()
|
||||
if created:
|
||||
pass
|
||||
# # 不再自动更新资产管理用户,只允许用户手动指定。
|
||||
# 只在创建时进行更新资产的管理用户
|
||||
# instance.update_asset_admin_user_if_need()
|
||||
|
||||
|
||||
@receiver(pre_save, sender=AuthBook)
|
||||
|
||||
@@ -131,8 +131,8 @@ def on_system_user_groups_change(instance, action, pk_set, reverse, **kwargs):
|
||||
@on_transaction_commit
|
||||
def on_system_user_update(instance: SystemUser, created, **kwargs):
|
||||
"""
|
||||
当系统用户更新时,可能更新了秘钥,用户名等,这时要自动推送系统用户到资产上,
|
||||
其实应该当 用户名,密码,秘钥 sudo等更新时再推送,这里偷个懒,
|
||||
当系统用户更新时,可能更新了密钥,用户名等,这时要自动推送系统用户到资产上,
|
||||
其实应该当 用户名,密码,密钥 sudo等更新时再推送,这里偷个懒,
|
||||
这里直接取了 instance.assets 因为nodes和系统用户发生变化时,会自动将nodes下的资产
|
||||
关联到上面
|
||||
"""
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
5
apps/audits/const.py
Normal file
@@ -0,0 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
DEFAULT_CITY = _("Unknown")
|
||||
@@ -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')}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
from django.db.models.signals import post_save, post_delete, m2m_changed
|
||||
from django.db.models.signals import (
|
||||
post_save, m2m_changed, pre_delete
|
||||
)
|
||||
from django.dispatch import receiver
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
@@ -12,30 +14,30 @@ from rest_framework.renderers import JSONRenderer
|
||||
from rest_framework.request import Request
|
||||
|
||||
from assets.models import Asset, SystemUser
|
||||
from common.const.signals import POST_ADD, POST_REMOVE, POST_CLEAR
|
||||
from authentication.signals import post_auth_failed, post_auth_success
|
||||
from authentication.utils import check_different_city_login
|
||||
from jumpserver.utils import current_request
|
||||
from common.utils import get_request_ip, get_logger, get_syslogger
|
||||
from users.models import User
|
||||
from users.signals import post_user_change_password
|
||||
from authentication.signals import post_auth_failed, post_auth_success
|
||||
from terminal.models import Session, Command
|
||||
from common.utils.encode import model_to_json
|
||||
from .utils import write_login_log
|
||||
from . import models
|
||||
from .models import OperateLog
|
||||
from orgs.utils import current_org
|
||||
from perms.models import AssetPermission, ApplicationPermission
|
||||
from common.const.signals import POST_ADD, POST_REMOVE, POST_CLEAR
|
||||
from common.utils import get_request_ip, get_logger, get_syslogger
|
||||
from common.utils.encode import model_to_json
|
||||
|
||||
logger = get_logger(__name__)
|
||||
sys_logger = get_syslogger(__name__)
|
||||
json_render = JSONRenderer()
|
||||
|
||||
|
||||
MODELS_NEED_RECORD = (
|
||||
# users
|
||||
'User', 'UserGroup',
|
||||
# acls
|
||||
'LoginACL', 'LoginAssetACL',
|
||||
'LoginACL', 'LoginAssetACL', 'LoginConfirmSetting',
|
||||
# assets
|
||||
'Asset', 'Node', 'AdminUser', 'SystemUser', 'Domain', 'Gateway', 'CommandFilterRule',
|
||||
'CommandFilter', 'Platform', 'AuthBook',
|
||||
@@ -98,72 +100,71 @@ def create_operate_log(action, sender, resource):
|
||||
M2M_NEED_RECORD = {
|
||||
'OrganizationMember': (
|
||||
_('User and Organization'),
|
||||
_('{User} *JOINED* {Organization}'),
|
||||
_('{User} *LEFT* {Organization}')
|
||||
_('{User} JOINED {Organization}'),
|
||||
_('{User} LEFT {Organization}')
|
||||
),
|
||||
User.groups.through._meta.object_name: (
|
||||
_('User and Group'),
|
||||
_('{User} *JOINED* {UserGroup}'),
|
||||
_('{User} *LEFT* {UserGroup}')
|
||||
_('{User} JOINED {UserGroup}'),
|
||||
_('{User} LEFT {UserGroup}')
|
||||
),
|
||||
SystemUser.assets.through._meta.object_name: (
|
||||
_('Asset and SystemUser'),
|
||||
_('{Asset} *ADD* {SystemUser}'),
|
||||
_('{Asset} *REMOVE* {SystemUser}')
|
||||
_('{Asset} ADD {SystemUser}'),
|
||||
_('{Asset} REMOVE {SystemUser}')
|
||||
),
|
||||
Asset.nodes.through._meta.object_name: (
|
||||
_('Node and Asset'),
|
||||
_('{Node} *ADD* {Asset}'),
|
||||
_('{Node} *REMOVE* {Asset}')
|
||||
_('{Node} ADD {Asset}'),
|
||||
_('{Node} REMOVE {Asset}')
|
||||
),
|
||||
AssetPermission.users.through._meta.object_name: (
|
||||
_('User asset permissions'),
|
||||
_('{AssetPermission} *ADD* {User}'),
|
||||
_('{AssetPermission} *REMOVE* {User}'),
|
||||
_('{AssetPermission} ADD {User}'),
|
||||
_('{AssetPermission} REMOVE {User}'),
|
||||
),
|
||||
AssetPermission.user_groups.through._meta.object_name: (
|
||||
_('User group asset permissions'),
|
||||
_('{AssetPermission} *ADD* {UserGroup}'),
|
||||
_('{AssetPermission} *REMOVE* {UserGroup}'),
|
||||
_('{AssetPermission} ADD {UserGroup}'),
|
||||
_('{AssetPermission} REMOVE {UserGroup}'),
|
||||
),
|
||||
AssetPermission.assets.through._meta.object_name: (
|
||||
_('Asset permission'),
|
||||
_('{AssetPermission} *ADD* {Asset}'),
|
||||
_('{AssetPermission} *REMOVE* {Asset}'),
|
||||
_('{AssetPermission} ADD {Asset}'),
|
||||
_('{AssetPermission} REMOVE {Asset}'),
|
||||
),
|
||||
AssetPermission.nodes.through._meta.object_name: (
|
||||
_('Node permission'),
|
||||
_('{AssetPermission} *ADD* {Node}'),
|
||||
_('{AssetPermission} *REMOVE* {Node}'),
|
||||
_('{AssetPermission} ADD {Node}'),
|
||||
_('{AssetPermission} REMOVE {Node}'),
|
||||
),
|
||||
AssetPermission.system_users.through._meta.object_name: (
|
||||
_('Asset permission and SystemUser'),
|
||||
_('{AssetPermission} *ADD* {SystemUser}'),
|
||||
_('{AssetPermission} *REMOVE* {SystemUser}'),
|
||||
_('{AssetPermission} ADD {SystemUser}'),
|
||||
_('{AssetPermission} REMOVE {SystemUser}'),
|
||||
),
|
||||
ApplicationPermission.users.through._meta.object_name: (
|
||||
_('User application permissions'),
|
||||
_('{ApplicationPermission} *ADD* {User}'),
|
||||
_('{ApplicationPermission} *REMOVE* {User}'),
|
||||
_('{ApplicationPermission} ADD {User}'),
|
||||
_('{ApplicationPermission} REMOVE {User}'),
|
||||
),
|
||||
ApplicationPermission.user_groups.through._meta.object_name: (
|
||||
_('User group application permissions'),
|
||||
_('{ApplicationPermission} *ADD* {UserGroup}'),
|
||||
_('{ApplicationPermission} *REMOVE* {UserGroup}'),
|
||||
_('{ApplicationPermission} ADD {UserGroup}'),
|
||||
_('{ApplicationPermission} REMOVE {UserGroup}'),
|
||||
),
|
||||
ApplicationPermission.applications.through._meta.object_name: (
|
||||
_('Application permission'),
|
||||
_('{ApplicationPermission} *ADD* {Application}'),
|
||||
_('{ApplicationPermission} *REMOVE* {Application}'),
|
||||
_('{ApplicationPermission} ADD {Application}'),
|
||||
_('{ApplicationPermission} REMOVE {Application}'),
|
||||
),
|
||||
ApplicationPermission.system_users.through._meta.object_name: (
|
||||
_('Application permission and SystemUser'),
|
||||
_('{ApplicationPermission} *ADD* {SystemUser}'),
|
||||
_('{ApplicationPermission} *REMOVE* {SystemUser}'),
|
||||
_('{ApplicationPermission} ADD {SystemUser}'),
|
||||
_('{ApplicationPermission} REMOVE {SystemUser}'),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
M2M_ACTION = {
|
||||
POST_ADD: 'add',
|
||||
POST_REMOVE: 'remove',
|
||||
@@ -226,7 +227,7 @@ def on_object_created_or_update(sender, instance=None, created=False, update_fie
|
||||
create_operate_log(action, sender, instance)
|
||||
|
||||
|
||||
@receiver(post_delete)
|
||||
@receiver(pre_delete)
|
||||
def on_object_delete(sender, instance=None, **kwargs):
|
||||
create_operate_log(models.OperateLog.ACTION_DELETE, sender, instance)
|
||||
|
||||
@@ -303,6 +304,7 @@ def generate_data(username, request, login_type=None):
|
||||
@receiver(post_auth_success)
|
||||
def on_user_auth_success(sender, user, request, login_type=None, **kwargs):
|
||||
logger.debug('User login success: {}'.format(user.username))
|
||||
check_different_city_login(user, request)
|
||||
data = generate_data(user.username, request, login_type=login_type)
|
||||
data.update({'mfa': int(user.mfa_enabled), 'status': True})
|
||||
write_login_log(**data)
|
||||
|
||||
@@ -5,14 +5,12 @@ from django.utils import timezone
|
||||
from celery import shared_task
|
||||
|
||||
from ops.celery.decorator import (
|
||||
register_as_period_task, after_app_shutdown_clean_periodic
|
||||
register_as_period_task
|
||||
)
|
||||
from .models import UserLoginLog, OperateLog
|
||||
from common.utils import get_log_keep_day
|
||||
|
||||
|
||||
@shared_task
|
||||
@after_app_shutdown_clean_periodic
|
||||
def clean_login_log_period():
|
||||
now = timezone.now()
|
||||
days = get_log_keep_day('LOGIN_LOG_KEEP_DAYS')
|
||||
@@ -20,8 +18,6 @@ def clean_login_log_period():
|
||||
UserLoginLog.objects.filter(datetime__lt=expired_day).delete()
|
||||
|
||||
|
||||
@shared_task
|
||||
@after_app_shutdown_clean_periodic
|
||||
def clean_operation_log_period():
|
||||
now = timezone.now()
|
||||
days = get_log_keep_day('OPERATE_LOG_KEEP_DAYS')
|
||||
@@ -29,7 +25,6 @@ def clean_operation_log_period():
|
||||
OperateLog.objects.filter(datetime__lt=expired_day).delete()
|
||||
|
||||
|
||||
@shared_task
|
||||
def clean_ftp_log_period():
|
||||
now = timezone.now()
|
||||
days = get_log_keep_day('FTP_LOG_KEEP_DAYS')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -19,10 +19,13 @@ from rest_framework import serializers
|
||||
|
||||
from authentication.signals import post_auth_failed, post_auth_success
|
||||
from common.utils import get_logger, random_string
|
||||
from common.drf.api import SerializerMixin
|
||||
from common.mixins.api import SerializerMixin
|
||||
from common.permissions import IsSuperUserOrAppUser, IsValidUser, IsSuperUser
|
||||
from orgs.mixins.api import RootOrgViewMixin
|
||||
from common.http import is_true
|
||||
from perms.utils.asset.permission import get_asset_system_user_ids_with_actions_by_user
|
||||
from perms.models.asset_permission import Action
|
||||
from authentication.errors import NotHaveUpDownLoadPerm
|
||||
|
||||
from ..serializers import (
|
||||
ConnectionTokenSerializer, ConnectionTokenSecretSerializer,
|
||||
@@ -74,6 +77,7 @@ class ClientProtocolMixin:
|
||||
'bookmarktype:i': '3',
|
||||
'use redirection server name:i': '0',
|
||||
'smart sizing:i': '0',
|
||||
#'drivestoredirect:s': '*',
|
||||
# 'domain:s': ''
|
||||
# 'alternate shell:s:': '||MySQLWorkbench',
|
||||
# 'remoteapplicationname:s': 'Firefox',
|
||||
@@ -84,8 +88,17 @@ class ClientProtocolMixin:
|
||||
height = self.request.query_params.get('height')
|
||||
width = self.request.query_params.get('width')
|
||||
full_screen = is_true(self.request.query_params.get('full_screen'))
|
||||
drives_redirect = is_true(self.request.query_params.get('drives_redirect'))
|
||||
token = self.create_token(user, asset, application, system_user)
|
||||
|
||||
if drives_redirect and asset:
|
||||
systemuser_actions_mapper = get_asset_system_user_ids_with_actions_by_user(user, asset)
|
||||
actions = systemuser_actions_mapper.get(system_user.id, [])
|
||||
if actions & Action.UPDOWNLOAD:
|
||||
options['drivestoredirect:s'] = '*'
|
||||
else:
|
||||
raise NotHaveUpDownLoadPerm
|
||||
|
||||
options['screen mode id:i'] = '2' if full_screen else '1'
|
||||
address = settings.TERMINAL_RDP_ADDR
|
||||
if not address or address == 'localhost:3389':
|
||||
@@ -137,15 +150,19 @@ class ClientProtocolMixin:
|
||||
def get_client_protocol_data(self, serializer):
|
||||
asset, application, system_user, user = self.get_request_resource(serializer)
|
||||
protocol = system_user.protocol
|
||||
username = user.username
|
||||
name = ''
|
||||
if protocol == 'rdp':
|
||||
name, config = self.get_rdp_file_content(serializer)
|
||||
elif protocol == 'vnc':
|
||||
raise HttpResponse(status=404, data={"error": "VNC not support"})
|
||||
else:
|
||||
config = 'ssh://system_user@asset@user@jumpserver-ssh'
|
||||
filename = "{}-{}-jumpserver".format(username, name)
|
||||
data = {
|
||||
"filename": filename,
|
||||
"protocol": system_user.protocol,
|
||||
"username": user.username,
|
||||
"username": username,
|
||||
"config": config
|
||||
}
|
||||
return data
|
||||
|
||||
@@ -8,29 +8,12 @@ from django.shortcuts import get_object_or_404
|
||||
|
||||
from common.utils import get_logger
|
||||
from common.permissions import IsOrgAdmin
|
||||
from ..models import LoginConfirmSetting
|
||||
from ..serializers import LoginConfirmSettingSerializer
|
||||
from .. import errors, mixins
|
||||
|
||||
__all__ = ['LoginConfirmSettingUpdateApi', 'TicketStatusApi']
|
||||
__all__ = ['TicketStatusApi']
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class LoginConfirmSettingUpdateApi(UpdateAPIView):
|
||||
permission_classes = (IsOrgAdmin,)
|
||||
serializer_class = LoginConfirmSettingSerializer
|
||||
|
||||
def get_object(self):
|
||||
from users.models import User
|
||||
user_id = self.kwargs.get('user_id')
|
||||
user = get_object_or_404(User, pk=user_id)
|
||||
defaults = {'user': user}
|
||||
s, created = LoginConfirmSetting.objects.get_or_create(
|
||||
defaults, user=user,
|
||||
)
|
||||
return s
|
||||
|
||||
|
||||
class TicketStatusApi(mixins.AuthMixin, APIView):
|
||||
permission_classes = (AllowAny,)
|
||||
|
||||
@@ -45,5 +28,5 @@ class TicketStatusApi(mixins.AuthMixin, APIView):
|
||||
ticket = self.get_ticket()
|
||||
if ticket:
|
||||
request.session.pop('auth_ticket_id', '')
|
||||
ticket.close(processor=request.user)
|
||||
ticket.close(processor=self.get_user_from_session())
|
||||
return Response('', status=200)
|
||||
|
||||
@@ -1,21 +1,36 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
import builtins
|
||||
import time
|
||||
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.conf import settings
|
||||
from django.shortcuts import get_object_or_404
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.generics import CreateAPIView
|
||||
from rest_framework.serializers import ValidationError
|
||||
from rest_framework.response import Response
|
||||
|
||||
from common.permissions import IsValidUser, NeedMFAVerify
|
||||
from users.models.user import MFAType, User
|
||||
from ..serializers import OtpVerifySerializer
|
||||
from .. import serializers
|
||||
from .. import errors
|
||||
from ..mixins import AuthMixin
|
||||
|
||||
|
||||
__all__ = ['MFAChallengeApi', 'UserOtpVerifyApi']
|
||||
__all__ = ['MFAChallengeApi', 'UserOtpVerifyApi', 'SendSMSVerifyCodeApi', 'MFASelectTypeApi']
|
||||
|
||||
|
||||
class MFASelectTypeApi(AuthMixin, CreateAPIView):
|
||||
permission_classes = (AllowAny,)
|
||||
serializer_class = serializers.MFASelectTypeSerializer
|
||||
|
||||
def perform_create(self, serializer):
|
||||
mfa_type = serializer.validated_data['type']
|
||||
if mfa_type == MFAType.SMS_CODE:
|
||||
user = self.get_user_from_session()
|
||||
user.send_sms_code()
|
||||
|
||||
|
||||
class MFAChallengeApi(AuthMixin, CreateAPIView):
|
||||
@@ -26,7 +41,9 @@ class MFAChallengeApi(AuthMixin, CreateAPIView):
|
||||
try:
|
||||
user = self.get_user_from_session()
|
||||
code = serializer.validated_data.get('code')
|
||||
valid = user.check_mfa(code)
|
||||
mfa_type = serializer.validated_data.get('type', MFAType.OTP)
|
||||
|
||||
valid = user.check_mfa(code, mfa_type=mfa_type)
|
||||
if not valid:
|
||||
self.request.session['auth_mfa'] = ''
|
||||
raise errors.MFAFailedError(
|
||||
@@ -67,3 +84,19 @@ class UserOtpVerifyApi(CreateAPIView):
|
||||
if self.request.method.lower() == 'get' and settings.SECURITY_VIEW_AUTH_NEED_MFA:
|
||||
self.permission_classes = [NeedMFAVerify]
|
||||
return super().get_permissions()
|
||||
|
||||
|
||||
class SendSMSVerifyCodeApi(AuthMixin, CreateAPIView):
|
||||
permission_classes = (AllowAny,)
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
username = request.data.get('username', '')
|
||||
username = username.strip()
|
||||
if username:
|
||||
user = get_object_or_404(User, username=username)
|
||||
else:
|
||||
user = self.get_user_from_session()
|
||||
if not user.mfa_enabled:
|
||||
raise errors.NotEnableMFAError
|
||||
timeout = user.send_sms_code()
|
||||
return Response({'code': 'ok', 'timeout': timeout})
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -6,5 +6,6 @@ class AuthenticationConfig(AppConfig):
|
||||
|
||||
def ready(self):
|
||||
from . import signals_handlers
|
||||
from . import notifications
|
||||
super().ready()
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
from .backends import *
|
||||
from .callback import *
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
def cas_callback(response):
|
||||
username = response['username']
|
||||
user, user_created = User.objects.get_or_create(username=username)
|
||||
profile, created = user.get_profile()
|
||||
|
||||
profile.role = response['attributes']['role']
|
||||
profile.birth_date = response['attributes']['birth_date']
|
||||
profile.save()
|
||||
@@ -3,10 +3,12 @@
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.urls import reverse
|
||||
from django.conf import settings
|
||||
from rest_framework import status
|
||||
|
||||
from common.exceptions import JMSException
|
||||
from .signals import post_auth_failed
|
||||
from users.utils import LoginBlockUtil, MFABlockUtils
|
||||
from users.models import MFAType
|
||||
|
||||
reason_password_failed = 'password_failed'
|
||||
reason_password_decrypt_failed = 'password_decrypt_failed'
|
||||
@@ -58,14 +60,25 @@ block_mfa_msg = _(
|
||||
"The account has been locked "
|
||||
"(please contact admin to unlock it or try again after {} minutes)"
|
||||
)
|
||||
mfa_failed_msg = _(
|
||||
"MFA code invalid, or ntp sync server time, "
|
||||
otp_failed_msg = _(
|
||||
"One-time password invalid, or ntp sync server time, "
|
||||
"You can also try {times_try} times "
|
||||
"(The account will be temporarily locked for {block_time} minutes)"
|
||||
)
|
||||
sms_failed_msg = _(
|
||||
"SMS verify code invalid,"
|
||||
"You can also try {times_try} times "
|
||||
"(The account will be temporarily locked for {block_time} minutes)"
|
||||
)
|
||||
mfa_type_failed_msg = _(
|
||||
"The MFA type({mfa_type}) is not supported, "
|
||||
"You can also try {times_try} times "
|
||||
"(The account will be temporarily locked for {block_time} minutes)"
|
||||
)
|
||||
|
||||
mfa_required_msg = _("MFA required")
|
||||
mfa_unset_msg = _("MFA not set, please set it first")
|
||||
otp_unset_msg = _("OTP not set, please set it first")
|
||||
login_confirm_required_msg = _("Login confirm required")
|
||||
login_confirm_wait_msg = _("Wait login confirm ticket for accept")
|
||||
login_confirm_error_msg = _("Login confirm ticket was {}")
|
||||
@@ -121,6 +134,10 @@ class CredentialError(AuthFailedNeedLogMixin, AuthFailedNeedBlockMixin, AuthFail
|
||||
times_remainder = util.get_remainder_times()
|
||||
block_time = settings.SECURITY_LOGIN_LIMIT_TIME
|
||||
|
||||
if times_remainder < 1:
|
||||
self.msg = block_login_msg.format(settings.SECURITY_LOGIN_LIMIT_TIME)
|
||||
return
|
||||
|
||||
default_msg = invalid_login_msg.format(
|
||||
times_try=times_remainder, block_time=block_time
|
||||
)
|
||||
@@ -134,7 +151,7 @@ class MFAFailedError(AuthFailedNeedLogMixin, AuthFailedError):
|
||||
error = reason_mfa_failed
|
||||
msg: str
|
||||
|
||||
def __init__(self, username, request, ip):
|
||||
def __init__(self, username, request, ip, mfa_type=MFAType.OTP):
|
||||
util = MFABlockUtils(username, ip)
|
||||
util.incr_failed_count()
|
||||
|
||||
@@ -142,9 +159,18 @@ class MFAFailedError(AuthFailedNeedLogMixin, AuthFailedError):
|
||||
block_time = settings.SECURITY_LOGIN_LIMIT_TIME
|
||||
|
||||
if times_remainder:
|
||||
self.msg = mfa_failed_msg.format(
|
||||
times_try=times_remainder, block_time=block_time
|
||||
)
|
||||
if mfa_type == MFAType.OTP:
|
||||
self.msg = otp_failed_msg.format(
|
||||
times_try=times_remainder, block_time=block_time
|
||||
)
|
||||
elif mfa_type == MFAType.SMS_CODE:
|
||||
self.msg = sms_failed_msg.format(
|
||||
times_try=times_remainder, block_time=block_time
|
||||
)
|
||||
else:
|
||||
self.msg = mfa_type_failed_msg.format(
|
||||
mfa_type=mfa_type, times_try=times_remainder, block_time=block_time
|
||||
)
|
||||
else:
|
||||
self.msg = block_mfa_msg.format(settings.SECURITY_LOGIN_LIMIT_TIME)
|
||||
super().__init__(username=username, request=request)
|
||||
@@ -202,12 +228,16 @@ class MFARequiredError(NeedMoreInfoError):
|
||||
msg = mfa_required_msg
|
||||
error = 'mfa_required'
|
||||
|
||||
def __init__(self, error='', msg='', mfa_types=tuple(MFAType)):
|
||||
super().__init__(error=error, msg=msg)
|
||||
self.choices = mfa_types
|
||||
|
||||
def as_data(self):
|
||||
return {
|
||||
'error': self.error,
|
||||
'msg': self.msg,
|
||||
'data': {
|
||||
'choices': ['code'],
|
||||
'choices': self.choices,
|
||||
'url': reverse('api-auth:mfa-challenge')
|
||||
}
|
||||
}
|
||||
@@ -235,6 +265,13 @@ class LoginIPNotAllowed(ACLError):
|
||||
super().__init__(_("IP is not allowed"), **kwargs)
|
||||
|
||||
|
||||
class TimePeriodNotAllowed(ACLError):
|
||||
def __init__(self, username, request, **kwargs):
|
||||
self.username = username
|
||||
self.request = request
|
||||
super().__init__(_("Time Period is not allowed"), **kwargs)
|
||||
|
||||
|
||||
class LoginConfirmBaseError(NeedMoreInfoError):
|
||||
def __init__(self, ticket_id, **kwargs):
|
||||
self.ticket_id = ticket_id
|
||||
@@ -323,3 +360,31 @@ class FeiShuNotBound(JMSException):
|
||||
class PasswdInvalid(JMSException):
|
||||
default_code = 'passwd_invalid'
|
||||
default_detail = _('Your password is invalid')
|
||||
|
||||
|
||||
class NotHaveUpDownLoadPerm(JMSException):
|
||||
status_code = status.HTTP_403_FORBIDDEN
|
||||
code = 'not_have_up_down_load_perm'
|
||||
default_detail = _('No upload or download permission')
|
||||
|
||||
|
||||
class NotEnableMFAError(JMSException):
|
||||
default_detail = mfa_unset_msg
|
||||
|
||||
|
||||
class OTPBindRequiredError(JMSException):
|
||||
default_detail = otp_unset_msg
|
||||
|
||||
def __init__(self, url, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.url = url
|
||||
|
||||
|
||||
class OTPCodeRequiredError(AuthFailedError):
|
||||
msg = _("Please enter MFA code")
|
||||
|
||||
class SMSCodeRequiredError(AuthFailedError):
|
||||
msg = _("Please enter SMS code")
|
||||
|
||||
class UserPhoneNotSet(AuthFailedError):
|
||||
msg = _('Phone not set')
|
||||
|
||||
@@ -43,7 +43,8 @@ class UserLoginForm(forms.Form):
|
||||
|
||||
|
||||
class UserCheckOtpCodeForm(forms.Form):
|
||||
otp_code = forms.CharField(label=_('MFA code'), max_length=6)
|
||||
code = forms.CharField(label=_('MFA Code'), max_length=6, required=False)
|
||||
mfa_type = forms.CharField(label=_('MFA type'), max_length=6)
|
||||
|
||||
|
||||
class CustomCaptchaTextInput(CaptchaTextInput):
|
||||
@@ -58,7 +59,7 @@ class ChallengeMixin(forms.Form):
|
||||
challenge = forms.CharField(
|
||||
label=_('MFA code'), max_length=6, required=False,
|
||||
widget=forms.TextInput(attrs={
|
||||
'placeholder': _("MFA code"),
|
||||
'placeholder': _("Dynamic code"),
|
||||
'style': 'width: 50%'
|
||||
})
|
||||
)
|
||||
@@ -66,9 +67,11 @@ class ChallengeMixin(forms.Form):
|
||||
|
||||
def get_user_login_form_cls(*, captcha=False):
|
||||
bases = []
|
||||
if settings.SECURITY_LOGIN_CAPTCHA_ENABLED and captcha:
|
||||
bases.append(CaptchaMixin)
|
||||
if settings.SECURITY_LOGIN_CHALLENGE_ENABLED:
|
||||
bases.append(ChallengeMixin)
|
||||
elif settings.SECURITY_MFA_IN_LOGIN_PAGE:
|
||||
bases.append(UserCheckOtpCodeForm)
|
||||
elif settings.SECURITY_LOGIN_CAPTCHA_ENABLED and captcha:
|
||||
bases.append(CaptchaMixin)
|
||||
bases.append(UserLoginForm)
|
||||
return type('UserLoginForm', tuple(bases), {})
|
||||
|
||||
14
apps/authentication/middleware.py
Normal file
14
apps/authentication/middleware.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from django.shortcuts import redirect
|
||||
|
||||
|
||||
class MFAMiddleware:
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
response = self.get_response(request)
|
||||
if request.path.find('/auth/login/otp/') > -1:
|
||||
return response
|
||||
if request.session.get('auth_mfa_required'):
|
||||
return redirect('authentication:login-otp')
|
||||
return response
|
||||
@@ -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',
|
||||
),
|
||||
]
|
||||
@@ -1,12 +1,13 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
import inspect
|
||||
from urllib.parse import urlencode
|
||||
from django.utils.http import urlencode
|
||||
from functools import partial
|
||||
import time
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.conf import settings
|
||||
from django.urls import reverse_lazy
|
||||
from django.contrib import auth
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.contrib.auth import (
|
||||
@@ -16,12 +17,13 @@ from django.contrib.auth import (
|
||||
from django.shortcuts import reverse, redirect
|
||||
|
||||
from common.utils import get_object_or_none, get_request_ip, get_logger, bulk_get, FlashMessageUtil
|
||||
from users.models import User
|
||||
from acls.models import LoginACL
|
||||
from users.models import User, MFAType
|
||||
from users.utils import LoginBlockUtil, MFABlockUtils
|
||||
from . import errors
|
||||
from .utils import rsa_decrypt
|
||||
from .utils import rsa_decrypt, gen_key_pair
|
||||
from .signals import post_auth_success, post_auth_failed
|
||||
from .const import RSA_PRIVATE_KEY
|
||||
from .const import RSA_PRIVATE_KEY, RSA_PUBLIC_KEY
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -29,8 +31,8 @@ logger = get_logger(__name__)
|
||||
def check_backend_can_auth(username, backend_path, allowed_auth_backends):
|
||||
if allowed_auth_backends is not None and backend_path not in allowed_auth_backends:
|
||||
logger.debug('Skip user auth backend: {}, {} not in'.format(
|
||||
username, backend_path, ','.join(allowed_auth_backends)
|
||||
)
|
||||
username, backend_path, ','.join(allowed_auth_backends)
|
||||
)
|
||||
)
|
||||
return False
|
||||
return True
|
||||
@@ -79,7 +81,70 @@ def authenticate(request=None, **credentials):
|
||||
auth.authenticate = authenticate
|
||||
|
||||
|
||||
class AuthMixin:
|
||||
class PasswordEncryptionViewMixin:
|
||||
request = None
|
||||
|
||||
def get_decrypted_password(self, password=None, username=None):
|
||||
request = self.request
|
||||
if hasattr(request, 'data'):
|
||||
data = request.data
|
||||
else:
|
||||
data = request.POST
|
||||
|
||||
username = username or data.get('username')
|
||||
password = password or data.get('password')
|
||||
|
||||
password = self.decrypt_passwd(password)
|
||||
if not password:
|
||||
self.raise_password_decrypt_failed(username=username)
|
||||
return password
|
||||
|
||||
def raise_password_decrypt_failed(self, username):
|
||||
ip = self.get_request_ip()
|
||||
raise errors.CredentialError(
|
||||
error=errors.reason_password_decrypt_failed,
|
||||
username=username, ip=ip, request=self.request
|
||||
)
|
||||
|
||||
def decrypt_passwd(self, raw_passwd):
|
||||
# 获取解密密钥,对密码进行解密
|
||||
rsa_private_key = self.request.session.get(RSA_PRIVATE_KEY)
|
||||
if rsa_private_key is not None:
|
||||
try:
|
||||
return rsa_decrypt(raw_passwd, rsa_private_key)
|
||||
except Exception as e:
|
||||
logger.error(e, exc_info=True)
|
||||
logger.error(
|
||||
f'Decrypt password failed: password[{raw_passwd}] '
|
||||
f'rsa_private_key[{rsa_private_key}]'
|
||||
)
|
||||
return None
|
||||
return raw_passwd
|
||||
|
||||
def get_request_ip(self):
|
||||
ip = ''
|
||||
if hasattr(self.request, 'data'):
|
||||
ip = self.request.data.get('remote_addr', '')
|
||||
ip = ip or get_request_ip(self.request)
|
||||
return ip
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
# 生成加解密密钥对,public_key传递给前端,private_key存入session中供解密使用
|
||||
rsa_public_key = self.request.session.get(RSA_PUBLIC_KEY)
|
||||
rsa_private_key = self.request.session.get(RSA_PRIVATE_KEY)
|
||||
if not all((rsa_private_key, rsa_public_key)):
|
||||
rsa_private_key, rsa_public_key = gen_key_pair()
|
||||
rsa_public_key = rsa_public_key.replace('\n', '\\n')
|
||||
self.request.session[RSA_PRIVATE_KEY] = rsa_private_key
|
||||
self.request.session[RSA_PUBLIC_KEY] = rsa_public_key
|
||||
|
||||
kwargs.update({
|
||||
'rsa_public_key': rsa_public_key,
|
||||
})
|
||||
return super().get_context_data(**kwargs)
|
||||
|
||||
|
||||
class AuthMixin(PasswordEncryptionViewMixin):
|
||||
request = None
|
||||
partial_credential_error = None
|
||||
|
||||
@@ -106,13 +171,6 @@ class AuthMixin:
|
||||
user.backend = self.request.session.get("auth_backend")
|
||||
return user
|
||||
|
||||
def get_request_ip(self):
|
||||
ip = ''
|
||||
if hasattr(self.request, 'data'):
|
||||
ip = self.request.data.get('remote_addr', '')
|
||||
ip = ip or get_request_ip(self.request)
|
||||
return ip
|
||||
|
||||
def _check_is_block(self, username, raise_exception=True):
|
||||
ip = self.get_request_ip()
|
||||
if LoginBlockUtil(username, ip).is_block():
|
||||
@@ -130,24 +188,14 @@ class AuthMixin:
|
||||
username = self.request.POST.get("username")
|
||||
self._check_is_block(username, raise_exception)
|
||||
|
||||
def decrypt_passwd(self, raw_passwd):
|
||||
# 获取解密密钥,对密码进行解密
|
||||
rsa_private_key = self.request.session.get(RSA_PRIVATE_KEY)
|
||||
if rsa_private_key is not None:
|
||||
try:
|
||||
return rsa_decrypt(raw_passwd, rsa_private_key)
|
||||
except Exception as e:
|
||||
logger.error(e, exc_info=True)
|
||||
logger.error(f'Decrypt password failed: password[{raw_passwd}] '
|
||||
f'rsa_private_key[{rsa_private_key}]')
|
||||
return None
|
||||
return raw_passwd
|
||||
|
||||
def raise_credential_error(self, error):
|
||||
raise self.partial_credential_error(error=error)
|
||||
|
||||
def _set_partial_credential_error(self, username, ip, request):
|
||||
self.partial_credential_error = partial(errors.CredentialError, username=username, ip=ip, request=request)
|
||||
self.partial_credential_error = partial(
|
||||
errors.CredentialError, username=username,
|
||||
ip=ip, request=request
|
||||
)
|
||||
|
||||
def get_auth_data(self, decrypt_passwd=False):
|
||||
request = self.request
|
||||
@@ -157,24 +205,24 @@ class AuthMixin:
|
||||
data = request.POST
|
||||
|
||||
items = ['username', 'password', 'challenge', 'public_key', 'auto_login']
|
||||
username, password, challenge, public_key, auto_login = bulk_get(data, *items, default='')
|
||||
password = password + challenge.strip()
|
||||
username, password, challenge, public_key, auto_login = bulk_get(data, items, default='')
|
||||
ip = self.get_request_ip()
|
||||
self._set_partial_credential_error(username=username, ip=ip, request=request)
|
||||
|
||||
if decrypt_passwd:
|
||||
password = self.decrypt_passwd(password)
|
||||
if not password:
|
||||
self.raise_credential_error(errors.reason_password_decrypt_failed)
|
||||
password = self.get_decrypted_password()
|
||||
password = password + challenge.strip()
|
||||
return username, password, public_key, ip, auto_login
|
||||
|
||||
def _check_only_allow_exists_user_auth(self, username):
|
||||
# 仅允许预先存在的用户认证
|
||||
if settings.ONLY_ALLOW_EXIST_USER_AUTH:
|
||||
exist = User.objects.filter(username=username).exists()
|
||||
if not exist:
|
||||
logger.error(f"Only allow exist user auth, login failed: {username}")
|
||||
self.raise_credential_error(errors.reason_user_not_exist)
|
||||
if not settings.ONLY_ALLOW_EXIST_USER_AUTH:
|
||||
return
|
||||
|
||||
exist = User.objects.filter(username=username).exists()
|
||||
if not exist:
|
||||
logger.error(f"Only allow exist user auth, login failed: {username}")
|
||||
self.raise_credential_error(errors.reason_user_not_exist)
|
||||
|
||||
def _check_auth_user_is_valid(self, username, password, public_key):
|
||||
user = authenticate(self.request, username=username, password=password, public_key=public_key)
|
||||
@@ -186,12 +234,30 @@ class AuthMixin:
|
||||
self.raise_credential_error(errors.reason_user_inactive)
|
||||
return user
|
||||
|
||||
def _check_login_mfa_login_if_need(self, user):
|
||||
request = self.request
|
||||
if hasattr(request, 'data'):
|
||||
data = request.data
|
||||
else:
|
||||
data = request.POST
|
||||
code = data.get('code')
|
||||
mfa_type = data.get('mfa_type')
|
||||
if settings.SECURITY_MFA_IN_LOGIN_PAGE and mfa_type:
|
||||
if not code:
|
||||
if mfa_type == MFAType.OTP and bool(user.otp_secret_key):
|
||||
raise errors.OTPCodeRequiredError
|
||||
elif mfa_type == MFAType.SMS_CODE:
|
||||
raise errors.SMSCodeRequiredError
|
||||
self.check_user_mfa(code, mfa_type, user=user)
|
||||
|
||||
def _check_login_acl(self, user, ip):
|
||||
# ACL 限制用户登录
|
||||
from acls.models import LoginACL
|
||||
is_allowed = LoginACL.allow_user_to_login(user, ip)
|
||||
is_allowed, limit_type = LoginACL.allow_user_to_login(user, ip)
|
||||
if not is_allowed:
|
||||
raise errors.LoginIPNotAllowed(username=user.username, request=self.request)
|
||||
if limit_type == 'ip':
|
||||
raise errors.LoginIPNotAllowed(username=user.username, request=self.request)
|
||||
elif limit_type == 'time':
|
||||
raise errors.TimePeriodNotAllowed(username=user.username, request=self.request)
|
||||
|
||||
def set_login_failed_mark(self):
|
||||
ip = self.get_request_ip()
|
||||
@@ -210,8 +276,7 @@ class AuthMixin:
|
||||
|
||||
def check_user_auth(self, decrypt_passwd=False):
|
||||
self.check_is_block()
|
||||
request = self.request
|
||||
username, password, public_key, ip, auto_login = self.get_auth_data(decrypt_passwd=decrypt_passwd)
|
||||
username, password, public_key, ip, auto_login = self.get_auth_data(decrypt_passwd)
|
||||
|
||||
self._check_only_allow_exists_user_auth(username)
|
||||
user = self._check_auth_user_is_valid(username, password, public_key)
|
||||
@@ -221,7 +286,11 @@ class AuthMixin:
|
||||
self._check_passwd_is_too_simple(user, password)
|
||||
self._check_passwd_need_update(user)
|
||||
|
||||
# 校验login-mfa, 如果登录页面上显示 mfa 的话
|
||||
self._check_login_mfa_login_if_need(user)
|
||||
|
||||
LoginBlockUtil(username, ip).clean_failed_count()
|
||||
request = self.request
|
||||
request.session['auth_password'] = 1
|
||||
request.session['user_id'] = str(user.id)
|
||||
request.session['auto_login'] = auto_login
|
||||
@@ -243,7 +312,6 @@ class AuthMixin:
|
||||
elif not user.is_active:
|
||||
self.raise_credential_error(errors.reason_user_inactive)
|
||||
|
||||
self._check_is_local_user(user)
|
||||
self._check_is_block(user.username)
|
||||
self._check_login_acl(user, ip)
|
||||
|
||||
@@ -304,32 +372,55 @@ class AuthMixin:
|
||||
def check_user_mfa_if_need(self, user):
|
||||
if self.request.session.get('auth_mfa'):
|
||||
return
|
||||
if settings.OTP_IN_RADIUS:
|
||||
return
|
||||
if not user.mfa_enabled:
|
||||
return
|
||||
|
||||
unset, url = user.mfa_enabled_but_not_set()
|
||||
if unset:
|
||||
raise errors.MFAUnsetError(user, self.request, url)
|
||||
raise errors.MFARequiredError()
|
||||
raise errors.MFARequiredError(mfa_types=user.get_supported_mfa_types())
|
||||
|
||||
def mark_mfa_ok(self):
|
||||
def mark_mfa_ok(self, mfa_type=MFAType.OTP):
|
||||
self.request.session['auth_mfa'] = 1
|
||||
self.request.session['auth_mfa_time'] = time.time()
|
||||
self.request.session['auth_mfa_type'] = 'otp'
|
||||
self.request.session['auth_mfa_required'] = ''
|
||||
self.request.session['auth_mfa_type'] = mfa_type
|
||||
|
||||
def clean_mfa_mark(self):
|
||||
self.request.session['auth_mfa'] = ''
|
||||
self.request.session['auth_mfa_time'] = ''
|
||||
self.request.session['auth_mfa_required'] = ''
|
||||
self.request.session['auth_mfa_type'] = ''
|
||||
|
||||
def check_mfa_is_block(self, username, ip, raise_exception=True):
|
||||
if MFABlockUtils(username, ip).is_block():
|
||||
logger.warn('Ip was blocked' + ': ' + username + ':' + ip)
|
||||
exception = errors.BlockMFAError(username=username, request=self.request, ip=ip)
|
||||
if raise_exception:
|
||||
raise exception
|
||||
else:
|
||||
return exception
|
||||
blocked = MFABlockUtils(username, ip).is_block()
|
||||
if not blocked:
|
||||
return
|
||||
logger.warn('Ip was blocked' + ': ' + username + ':' + ip)
|
||||
exception = errors.BlockMFAError(username=username, request=self.request, ip=ip)
|
||||
if raise_exception:
|
||||
raise exception
|
||||
else:
|
||||
return exception
|
||||
|
||||
def check_user_mfa(self, code, mfa_type=MFAType.OTP, user=None):
|
||||
user = user if user else self.get_user_from_session()
|
||||
if not user.mfa_enabled:
|
||||
return
|
||||
|
||||
if not bool(user.phone) and mfa_type == MFAType.SMS_CODE:
|
||||
raise errors.UserPhoneNotSet
|
||||
|
||||
if not bool(user.otp_secret_key) and mfa_type == MFAType.OTP:
|
||||
self.set_passwd_verify_on_session(user)
|
||||
raise errors.OTPBindRequiredError(reverse_lazy('authentication:user-otp-enable-bind'))
|
||||
|
||||
def check_user_mfa(self, code):
|
||||
user = self.get_user_from_session()
|
||||
ip = self.get_request_ip()
|
||||
self.check_mfa_is_block(user.username, ip)
|
||||
ok = user.check_mfa(code)
|
||||
ok = user.check_mfa(code, mfa_type=mfa_type)
|
||||
|
||||
if ok:
|
||||
self.mark_mfa_ok()
|
||||
return
|
||||
@@ -337,7 +428,7 @@ class AuthMixin:
|
||||
raise errors.MFAFailedError(
|
||||
username=user.username,
|
||||
request=self.request,
|
||||
ip=ip
|
||||
ip=ip, mfa_type=mfa_type,
|
||||
)
|
||||
|
||||
def get_ticket(self):
|
||||
@@ -363,16 +454,18 @@ class AuthMixin:
|
||||
raise errors.LoginConfirmOtherError('', "Not found")
|
||||
if ticket.status_open:
|
||||
raise errors.LoginConfirmWaitError(ticket.id)
|
||||
elif ticket.action_approve:
|
||||
elif ticket.state_approve:
|
||||
self.request.session["auth_confirm"] = "1"
|
||||
return
|
||||
elif ticket.action_reject:
|
||||
elif ticket.state_reject:
|
||||
self.clean_mfa_mark()
|
||||
raise errors.LoginConfirmOtherError(
|
||||
ticket.id, ticket.get_action_display()
|
||||
ticket.id, ticket.get_state_display()
|
||||
)
|
||||
elif ticket.action_close:
|
||||
elif ticket.state_close:
|
||||
self.clean_mfa_mark()
|
||||
raise errors.LoginConfirmOtherError(
|
||||
ticket.id, ticket.get_action_display()
|
||||
ticket.id, ticket.get_state_display()
|
||||
)
|
||||
else:
|
||||
raise errors.LoginConfirmOtherError(
|
||||
@@ -380,10 +473,9 @@ class AuthMixin:
|
||||
)
|
||||
|
||||
def check_user_login_confirm_if_need(self, user):
|
||||
if not settings.LOGIN_CONFIRM_ENABLE:
|
||||
return
|
||||
confirm_setting = user.get_login_confirm_setting()
|
||||
if self.request.session.get('auth_confirm') or not confirm_setting:
|
||||
ip = self.get_request_ip()
|
||||
is_allowed, confirm_setting = LoginACL.allow_user_confirm_if_need(user, ip)
|
||||
if self.request.session.get('auth_confirm') or not is_allowed:
|
||||
return
|
||||
self.get_ticket_or_create(confirm_setting)
|
||||
self.check_user_login_confirm()
|
||||
@@ -391,7 +483,6 @@ class AuthMixin:
|
||||
def clear_auth_mark(self):
|
||||
self.request.session['auth_password'] = ''
|
||||
self.request.session['auth_user_id'] = ''
|
||||
self.request.session['auth_mfa'] = ''
|
||||
self.request.session['auth_confirm'] = ''
|
||||
self.request.session['auth_ticket_id'] = ''
|
||||
|
||||
@@ -412,3 +503,31 @@ class AuthMixin:
|
||||
if args:
|
||||
guard_url = "%s?%s" % (guard_url, args)
|
||||
return redirect(guard_url)
|
||||
|
||||
@staticmethod
|
||||
def get_user_mfa_methods(user=None):
|
||||
otp_enabled = user.otp_secret_key if user else True
|
||||
# 没有用户时,或者有用户并且有电话配置
|
||||
sms_enabled = any([user and user.phone, not user]) \
|
||||
and settings.SMS_ENABLED and settings.XPACK_ENABLED
|
||||
|
||||
methods = [
|
||||
{
|
||||
'name': 'otp',
|
||||
'label': 'MFA',
|
||||
'enable': otp_enabled,
|
||||
'selected': False,
|
||||
},
|
||||
{
|
||||
'name': 'sms',
|
||||
'label': _('SMS'),
|
||||
'enable': sms_enabled,
|
||||
'selected': False,
|
||||
},
|
||||
]
|
||||
|
||||
for item in methods:
|
||||
if item['enable']:
|
||||
item['selected'] = True
|
||||
break
|
||||
return methods
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import uuid
|
||||
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import ugettext_lazy as _, ugettext as __
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from rest_framework.authtoken.models import Token
|
||||
from django.conf import settings
|
||||
|
||||
from common.db import models
|
||||
from common.mixins.models import CommonModelMixin
|
||||
from common.utils import get_object_or_none, get_request_ip, get_ip_city
|
||||
|
||||
|
||||
class AccessKey(models.Model):
|
||||
@@ -40,53 +37,6 @@ class PrivateToken(Token):
|
||||
verbose_name = _('Private Token')
|
||||
|
||||
|
||||
class LoginConfirmSetting(CommonModelMixin):
|
||||
user = models.OneToOneField('users.User', on_delete=models.CASCADE, verbose_name=_("User"), related_name="login_confirm_setting")
|
||||
reviewers = models.ManyToManyField('users.User', verbose_name=_("Reviewers"), related_name="review_login_confirm_settings", blank=True)
|
||||
is_active = models.BooleanField(default=True, verbose_name=_("Is active"))
|
||||
|
||||
@classmethod
|
||||
def get_user_confirm_setting(cls, user):
|
||||
return get_object_or_none(cls, user=user)
|
||||
|
||||
@staticmethod
|
||||
def construct_confirm_ticket_meta(request=None):
|
||||
if request:
|
||||
login_ip = get_request_ip(request)
|
||||
else:
|
||||
login_ip = ''
|
||||
login_ip = login_ip or '0.0.0.0'
|
||||
login_city = get_ip_city(login_ip)
|
||||
login_datetime = timezone.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
ticket_meta = {
|
||||
'apply_login_ip': login_ip,
|
||||
'apply_login_city': login_city,
|
||||
'apply_login_datetime': login_datetime,
|
||||
}
|
||||
return ticket_meta
|
||||
|
||||
def create_confirm_ticket(self, request=None):
|
||||
from tickets import const
|
||||
from tickets.models import Ticket
|
||||
from orgs.models import Organization
|
||||
ticket_title = _('Login confirm') + ' {}'.format(self.user)
|
||||
ticket_meta = self.construct_confirm_ticket_meta(request)
|
||||
ticket_assignees = self.reviewers.all()
|
||||
data = {
|
||||
'title': ticket_title,
|
||||
'type': const.TicketTypeChoices.login_confirm.value,
|
||||
'meta': ticket_meta,
|
||||
'org_id': Organization.ROOT_ID,
|
||||
}
|
||||
ticket = Ticket.objects.create(**data)
|
||||
ticket.assignees.set(ticket_assignees)
|
||||
ticket.open(self.user)
|
||||
return ticket
|
||||
|
||||
def __str__(self):
|
||||
return '{} confirm'.format(self.user.username)
|
||||
|
||||
|
||||
class SSOToken(models.JMSBaseModel):
|
||||
"""
|
||||
类似腾讯企业邮的 [单点登录](https://exmail.qq.com/qy_mng_logic/doc#10036)
|
||||
|
||||
41
apps/authentication/notifications.py
Normal file
41
apps/authentication/notifications.py
Normal 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)
|
||||
@@ -10,14 +10,13 @@ from applications.models import Application
|
||||
from users.serializers import UserProfileSerializer
|
||||
from assets.serializers import ProtocolsField
|
||||
from perms.serializers.asset.permission import ActionsField
|
||||
from .models import AccessKey, LoginConfirmSetting
|
||||
|
||||
from .models import AccessKey
|
||||
|
||||
__all__ = [
|
||||
'AccessKeySerializer', 'OtpVerifySerializer', 'BearerTokenSerializer',
|
||||
'MFAChallengeSerializer', 'LoginConfirmSettingSerializer', 'SSOTokenSerializer',
|
||||
'ConnectionTokenSerializer', 'ConnectionTokenSecretSerializer', 'RDPFileSerializer',
|
||||
'PasswordVerifySerializer',
|
||||
'MFAChallengeSerializer', 'SSOTokenSerializer',
|
||||
'ConnectionTokenSerializer', 'ConnectionTokenSecretSerializer',
|
||||
'PasswordVerifySerializer', 'MFASelectTypeSerializer',
|
||||
]
|
||||
|
||||
|
||||
@@ -77,6 +76,10 @@ class BearerTokenSerializer(serializers.Serializer):
|
||||
return instance
|
||||
|
||||
|
||||
class MFASelectTypeSerializer(serializers.Serializer):
|
||||
type = serializers.CharField()
|
||||
|
||||
|
||||
class MFAChallengeSerializer(serializers.Serializer):
|
||||
type = serializers.CharField(write_only=True, required=False, allow_blank=True)
|
||||
code = serializers.CharField(write_only=True)
|
||||
@@ -88,13 +91,6 @@ class MFAChallengeSerializer(serializers.Serializer):
|
||||
pass
|
||||
|
||||
|
||||
class LoginConfirmSettingSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = LoginConfirmSetting
|
||||
fields = ['id', 'user', 'reviewers', 'date_created', 'date_updated']
|
||||
read_only_fields = ['date_created', 'date_updated']
|
||||
|
||||
|
||||
class SSOTokenSerializer(serializers.Serializer):
|
||||
username = serializers.CharField(write_only=True)
|
||||
login_url = serializers.CharField(read_only=True)
|
||||
@@ -166,7 +162,7 @@ class ConnectionTokenAssetSerializer(serializers.ModelSerializer):
|
||||
class ConnectionTokenSystemUserSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = SystemUser
|
||||
fields = ['id', 'name', 'username', 'password', 'private_key']
|
||||
fields = ['id', 'name', 'username', 'password', 'private_key', 'ad_domain']
|
||||
|
||||
|
||||
class ConnectionTokenGatewaySerializer(serializers.ModelSerializer):
|
||||
@@ -197,4 +193,3 @@ class ConnectionTokenSecretSerializer(serializers.Serializer):
|
||||
gateway = ConnectionTokenGatewaySerializer(read_only=True)
|
||||
actions = ActionsField()
|
||||
expired_at = serializers.IntegerField()
|
||||
|
||||
|
||||
@@ -13,6 +13,11 @@ from .signals import post_auth_success, post_auth_failed
|
||||
|
||||
@receiver(user_logged_in)
|
||||
def on_user_auth_login_success(sender, user, request, **kwargs):
|
||||
# 开启了 MFA,且没有校验过
|
||||
|
||||
if user.mfa_enabled and not settings.OTP_IN_RADIUS and not request.session.get('auth_mfa'):
|
||||
request.session['auth_mfa_required'] = 1
|
||||
|
||||
if settings.USER_LOGIN_SINGLE_MACHINE_ENABLED:
|
||||
user_id = 'single_machine_login_' + str(user.id)
|
||||
session_key = cache.get(user_id)
|
||||
|
||||
98
apps/authentication/sms_verify_code.py
Normal file
98
apps/authentication/sms_verify_code.py
Normal file
@@ -0,0 +1,98 @@
|
||||
import random
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from common.sdk.sms import SMS
|
||||
from common.utils import get_logger
|
||||
from common.exceptions import JMSException
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
||||
|
||||
class CodeExpired(JMSException):
|
||||
default_code = 'verify_code_expired'
|
||||
default_detail = _('The verification code has expired. Please resend it')
|
||||
|
||||
|
||||
class CodeError(JMSException):
|
||||
default_code = 'verify_code_error'
|
||||
default_detail = _('The verification code is incorrect')
|
||||
|
||||
|
||||
class CodeSendTooFrequently(JMSException):
|
||||
default_code = 'code_send_too_frequently'
|
||||
default_detail = _('Please wait {} seconds before sending')
|
||||
|
||||
def __init__(self, ttl):
|
||||
super().__init__(detail=self.default_detail.format(ttl))
|
||||
|
||||
|
||||
class VerifyCodeUtil:
|
||||
KEY_TMPL = 'auth-verify_code-{}'
|
||||
TIMEOUT = 60
|
||||
|
||||
def __init__(self, account, key_suffix=None, timeout=None):
|
||||
self.account = account
|
||||
self.key_suffix = key_suffix
|
||||
self.code = ''
|
||||
|
||||
if key_suffix is not None:
|
||||
self.key = self.KEY_TMPL.format(key_suffix)
|
||||
else:
|
||||
self.key = self.KEY_TMPL.format(account)
|
||||
self.timeout = self.TIMEOUT if timeout is None else timeout
|
||||
|
||||
def touch(self):
|
||||
"""
|
||||
生成,保存,发送
|
||||
"""
|
||||
ttl = self.ttl()
|
||||
if ttl > 0:
|
||||
raise CodeSendTooFrequently(ttl)
|
||||
try:
|
||||
self.generate()
|
||||
self.save()
|
||||
self.send()
|
||||
except JMSException:
|
||||
self.clear()
|
||||
raise
|
||||
|
||||
def generate(self):
|
||||
code = ''.join(random.sample('0123456789', 4))
|
||||
self.code = code
|
||||
return code
|
||||
|
||||
def clear(self):
|
||||
cache.delete(self.key)
|
||||
|
||||
def save(self):
|
||||
cache.set(self.key, self.code, self.timeout)
|
||||
|
||||
def send(self):
|
||||
"""
|
||||
发送信息的方法,如果有错误直接抛出 api 异常
|
||||
"""
|
||||
account = self.account
|
||||
code = self.code
|
||||
|
||||
sms = SMS()
|
||||
sms.send_verify_code(account, code)
|
||||
logger.info(f'Send sms verify code: account={account} code={code}')
|
||||
|
||||
def verify(self, code):
|
||||
right = cache.get(self.key)
|
||||
if not right:
|
||||
raise CodeExpired
|
||||
|
||||
if right != code:
|
||||
raise CodeError
|
||||
|
||||
self.clear()
|
||||
return True
|
||||
|
||||
def ttl(self):
|
||||
return cache.ttl(self.key)
|
||||
|
||||
def get_code(self):
|
||||
return cache.get(self.key)
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -10,23 +10,15 @@
|
||||
{{ JMS_TITLE }}
|
||||
</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
{% include '_head_css_js.html' %}
|
||||
<!-- Stylesheets -->
|
||||
<link href="{% static 'css/bootstrap.min.css' %}" rel="stylesheet">
|
||||
<link href="{% static 'css/font-awesome.min.css' %}" rel="stylesheet">
|
||||
<link href="{% static 'css/bootstrap-style.css' %}" rel="stylesheet">
|
||||
<link href="{% static 'css/login-style.css' %}" rel="stylesheet">
|
||||
<link href="{% static 'css/style.css' %}" rel="stylesheet">
|
||||
<link href="{% static 'css/jumpserver.css' %}" rel="stylesheet">
|
||||
|
||||
<!-- scripts -->
|
||||
<script src="{% static 'js/jquery-3.1.1.min.js' %}"></script>
|
||||
<script src="{% static 'js/plugins/sweetalert/sweetalert.min.js' %}"></script>
|
||||
<script src="{% static 'js/bootstrap.min.js' %}"></script>
|
||||
<script src="{% static 'js/plugins/datatables/datatables.min.js' %}"></script>
|
||||
<script src="{% static "js/jumpserver.js" %}"></script>
|
||||
|
||||
<style>
|
||||
.login-content {
|
||||
box-shadow: 0 5px 5px -3px rgb(0 0 0 / 20%), 0 8px 10px 1px rgb(0 0 0 / 14%), 0 3px 14px 2px rgb(0 0 0 / 12%);
|
||||
box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.15), 0 8px 10px 1px rgba(0, 0, 0, 0.14), 0 3px 14px 2px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.help-block {
|
||||
@@ -49,17 +41,22 @@
|
||||
}
|
||||
|
||||
.login-content {
|
||||
height: 472px;
|
||||
width: 984px;
|
||||
height: 490px;
|
||||
width: 1066px;
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
margin-top: calc((100vh - 470px) / 3);
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #f2f2f2;
|
||||
height: calc(100vh - (100vh - 470px) / 3);
|
||||
}
|
||||
|
||||
.captcha {
|
||||
float: right;
|
||||
}
|
||||
|
||||
.right-image-box {
|
||||
height: 100%;
|
||||
width: 50%;
|
||||
@@ -73,18 +70,10 @@
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.captcha {
|
||||
float: right;
|
||||
}
|
||||
|
||||
.red-fonts {
|
||||
color: red;
|
||||
}
|
||||
|
||||
.field-error {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.form-group.has-error {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
@@ -109,14 +98,6 @@
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.radio, .checkbox {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#github_star {
|
||||
float: right;
|
||||
margin: 10px 10px 0 0;
|
||||
}
|
||||
.more-login-item {
|
||||
border-right: 1px dashed #dedede;
|
||||
padding-left: 5px;
|
||||
@@ -127,11 +108,18 @@
|
||||
border: none;
|
||||
}
|
||||
|
||||
.select-con {
|
||||
width: 22%;
|
||||
}
|
||||
|
||||
.mfa-div {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="login-content ">
|
||||
<div class="login-content">
|
||||
<div class="right-image-box">
|
||||
<a href="{% if not XPACK_ENABLED %}https://github.com/jumpserver/jumpserver{% endif %}">
|
||||
<img src="{{ LOGIN_IMAGE_URL }}" style="height: 100%; width: 100%"/>
|
||||
@@ -146,11 +134,9 @@
|
||||
<form id="login-form" action="" method="post" role="form" novalidate="novalidate">
|
||||
{% csrf_token %}
|
||||
<div style="line-height: 17px;margin-bottom: 20px;color: #999999;">
|
||||
{% if form.errors %}
|
||||
<p class="red-fonts" style="color: red">
|
||||
{% if form.non_field_errors %}
|
||||
{{ form.non_field_errors.as_text }}
|
||||
{% endif %}
|
||||
{% if form.non_field_errors %}
|
||||
<p class="help-block red-fonts">
|
||||
{{ form.non_field_errors.as_text }}
|
||||
</p>
|
||||
{% else %}
|
||||
<p class="welcome-message">
|
||||
@@ -160,12 +146,22 @@
|
||||
</div>
|
||||
|
||||
{% bootstrap_field form.username show_label=False %}
|
||||
<div class="form-group">
|
||||
<input type="password" class="form-control" id="password" placeholder="{% trans 'Password' %}" required="">
|
||||
|
||||
<div class="form-group {% if form.password.errors %} has-error {% endif %}">
|
||||
<input type="password" class="form-control" id="password" placeholder="{% trans 'Password' %}" required>
|
||||
<input id="password-hidden" type="text" style="display:none" name="{{ form.password.html_name }}">
|
||||
{% if form.password.errors %}
|
||||
<p class="help-block" style="text-align: left">
|
||||
{{ form.password.errors.as_text }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if form.challenge %}
|
||||
{% bootstrap_field form.challenge show_label=False %}
|
||||
{% elif form.mfa_type %}
|
||||
<div class="form-group" style="display: flex">
|
||||
{% include '_mfa_otp_login.html' %}
|
||||
</div>
|
||||
{% elif form.captcha %}
|
||||
<div class="captch-field">
|
||||
{% bootstrap_field form.captcha show_label=False %}
|
||||
@@ -191,36 +187,15 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{% if AUTH_OPENID or AUTH_CAS or AUTH_WECOM or AUTH_DINGTALK or AUTH_FEISHU %}
|
||||
{% if auth_methods %}
|
||||
<div class="hr-line-dashed"></div>
|
||||
<div style="display: inline-block; float: left">
|
||||
<b class="text-muted text-left" >{% trans "More login options" %}</b>
|
||||
{% if AUTH_OPENID %}
|
||||
<a href="{% url 'authentication:openid:login' %}" class="more-login-item">
|
||||
<i class="fa fa-openid"></i> {% trans 'OpenID' %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if AUTH_CAS %}
|
||||
<a href="{% url 'authentication:cas:cas-login' %}" class="more-login-item">
|
||||
<i class="fa"><img src="{{ LOGIN_CAS_LOGO_URL }}" height="13" width="13"></i> {% trans 'CAS' %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if AUTH_WECOM %}
|
||||
<a href="{% url 'authentication:wecom-qr-login' %}" class="more-login-item">
|
||||
<i class="fa"><img src="{{ LOGIN_WECOM_LOGO_URL }}" height="13" width="13"></i> {% trans 'WeCom' %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if AUTH_DINGTALK %}
|
||||
<a href="{% url 'authentication:dingtalk-qr-login' %}" class="more-login-item">
|
||||
<i class="fa"><img src="{{ LOGIN_DINGTALK_LOGO_URL }}" height="13" width="13"></i> {% trans 'DingTalk' %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if AUTH_FEISHU %}
|
||||
<a href="{% url 'authentication:feishu-qr-login' %}" class="more-login-item">
|
||||
<i class="fa"><img src="{{ LOGIN_FEISHU_LOGO_URL }}" height="13" width="13"></i> {% trans 'FeiShu' %}
|
||||
<b class="text-muted text-left" >{% trans "More login options" %}</b>
|
||||
{% for method in auth_methods %}
|
||||
<a href="{{ method.url }}" class="more-login-item">
|
||||
<i class="fa"><img src="{{ method.logo }}" height="13" width="13"></i> {{ method.name }}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center" style="display: inline-block;">
|
||||
@@ -236,6 +211,9 @@
|
||||
<script type="text/javascript" src="/static/js/plugins/jsencrypt/jsencrypt.min.js"></script>
|
||||
<script>
|
||||
function encryptLoginPassword(password, rsaPublicKey) {
|
||||
if (!password) {
|
||||
return ''
|
||||
}
|
||||
var jsencrypt = new JSEncrypt(); //加密对象
|
||||
jsencrypt.setPublicKey(rsaPublicKey); // 设置密钥
|
||||
return jsencrypt.encrypt(password); //加密
|
||||
@@ -247,7 +225,7 @@
|
||||
var password = $('#password').val(); //明文密码
|
||||
var passwordEncrypted = encryptLoginPassword(password, rsaPublicKey)
|
||||
$('#password-hidden').val(passwordEncrypted); //返回给密码输入input
|
||||
$('#login-form').submit();//post提交
|
||||
$('#login-form').submit(); //post提交
|
||||
}
|
||||
|
||||
$(document).ready(function () {
|
||||
|
||||
@@ -9,24 +9,23 @@
|
||||
{% block content %}
|
||||
<form class="m-t" role="form" method="post" action="">
|
||||
{% csrf_token %}
|
||||
{% if 'otp_code' in form.errors %}
|
||||
<p class="red-fonts">{{ form.otp_code.errors.as_text }}</p>
|
||||
{% if 'code' in form.errors %}
|
||||
<p class="red-fonts">{{ form.code.errors.as_text }}</p>
|
||||
{% endif %}
|
||||
<div class="form-group">
|
||||
<select class="form-control">
|
||||
<option value="otp" selected>{% trans 'One-time password' %}</option>
|
||||
</select>
|
||||
{% include '_mfa_otp_login.html' %}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="text" class="form-control" name="otp_code" placeholder="" required="" autofocus="autofocus">
|
||||
<span class="help-block">
|
||||
{% trans 'Open MFA Authenticator and enter the 6-bit dynamic code' %}
|
||||
</span>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary block full-width m-b">{% trans 'Next' %}</button>
|
||||
|
||||
<button id='submit_button' type="submit"
|
||||
class="btn btn-primary block full-width m-b">{% trans 'Next' %}</button>
|
||||
<div>
|
||||
<small>{% trans "Can't provide security? Please contact the administrator!" %}</small>
|
||||
</div>
|
||||
</form>
|
||||
<style type="text/css">
|
||||
.mfa-div {
|
||||
margin-top: 15px;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -157,8 +157,11 @@ $(document).ready(function () {
|
||||
cancelCloseConfirm();
|
||||
window.location.reload();
|
||||
}).on('click', '.btn-return', function () {
|
||||
cancelTicket();
|
||||
cancelCloseConfirm();
|
||||
window.location = "{% url 'authentication:login' %}"
|
||||
setTimeout(() => {
|
||||
window.location = "{% url 'authentication:login' %}"
|
||||
}, 1000);
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
@@ -13,7 +13,6 @@ router.register('connection-token', api.UserConnectionTokenViewSet, 'connection-
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
# path('token/', api.UserToken.as_view(), name='user-token'),
|
||||
path('wecom/qr/unbind/', api.WeComQRUnBindForUserApi.as_view(), name='wecom-qr-unbind'),
|
||||
path('wecom/qr/unbind/<uuid:user_id>/', api.WeComQRUnBindForAdminApi.as_view(), name='wecom-qr-unbind-for-admin'),
|
||||
|
||||
@@ -27,10 +26,11 @@ urlpatterns = [
|
||||
path('auth/', api.TokenCreateApi.as_view(), name='user-auth'),
|
||||
path('tokens/', api.TokenCreateApi.as_view(), name='auth-token'),
|
||||
path('mfa/challenge/', api.MFAChallengeApi.as_view(), name='mfa-challenge'),
|
||||
path('mfa/select/', api.MFASelectTypeApi.as_view(), name='mfa-select'),
|
||||
path('otp/verify/', api.UserOtpVerifyApi.as_view(), name='user-otp-verify'),
|
||||
path('sms/verify-code/send/', api.SendSMSVerifyCodeApi.as_view(), name='sms-verify-code-send'),
|
||||
path('password/verify/', api.UserPasswordVerifyApi.as_view(), name='user-password-verify'),
|
||||
path('login-confirm-ticket/status/', api.TicketStatusApi.as_view(), name='login-confirm-ticket-status'),
|
||||
path('login-confirm-settings/<uuid:user_id>/', api.LoginConfirmSettingUpdateApi.as_view(), name='login-confirm-setting-update')
|
||||
]
|
||||
|
||||
urlpatterns += router.urls
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -5,6 +5,11 @@ from Cryptodome.PublicKey import RSA
|
||||
from Cryptodome.Cipher import PKCS1_v1_5
|
||||
from Cryptodome import Random
|
||||
|
||||
from .notifications import DifferentCityLoginMessage
|
||||
from audits.models import UserLoginLog
|
||||
from audits.const import DEFAULT_CITY
|
||||
from common.utils import get_request_ip
|
||||
from common.utils import validate_ip, get_ip_city
|
||||
from common.utils import get_logger
|
||||
|
||||
logger = get_logger(__file__)
|
||||
@@ -44,3 +49,16 @@ def rsa_decrypt(cipher_text, rsa_private_key=None):
|
||||
cipher_decoded = base64.b16decode(hex_fixed.upper())
|
||||
message = cipher.decrypt(cipher_decoded, b'error').decode()
|
||||
return message
|
||||
|
||||
|
||||
def check_different_city_login(user, request):
|
||||
ip = get_request_ip(request) or '0.0.0.0'
|
||||
|
||||
if not (ip and validate_ip(ip)):
|
||||
city = DEFAULT_CITY
|
||||
else:
|
||||
city = get_ip_city(ip) or DEFAULT_CITY
|
||||
|
||||
last_user_login = UserLoginLog.objects.filter(username=user.username, status=True).first()
|
||||
if last_user_login and last_user_login.city != city:
|
||||
DifferentCityLoginMessage(user, ip, city).publish_async()
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import urllib
|
||||
|
||||
from django.http.response import HttpResponseRedirect
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.views.decorators.cache import never_cache
|
||||
from django.views.generic import TemplateView
|
||||
from urllib.parse import urlencode
|
||||
from django.views import View
|
||||
from django.conf import settings
|
||||
from django.http.request import HttpRequest
|
||||
@@ -15,14 +11,14 @@ from rest_framework.exceptions import APIException
|
||||
from users.views import UserVerifyPasswordView
|
||||
from users.utils import is_auth_password_time_valid
|
||||
from users.models import User
|
||||
from common.utils import get_logger
|
||||
from common.utils import get_logger, FlashMessageUtil
|
||||
from common.utils.random import random_string
|
||||
from common.utils.django import reverse, get_object_or_none
|
||||
from common.message.backends.dingtalk import URL
|
||||
from common.sdk.im.dingtalk import URL
|
||||
from common.mixins.views import PermissionsMixin
|
||||
from authentication import errors
|
||||
from authentication.mixins import AuthMixin
|
||||
from common.message.backends.dingtalk import DingTalk
|
||||
from common.sdk.im.dingtalk import DingTalk
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
||||
@@ -39,7 +35,7 @@ class DingTalkQRMixin(PermissionsMixin, View):
|
||||
msg = e.detail['errmsg']
|
||||
except Exception:
|
||||
msg = _('DingTalk Error, Please contact your system administrator')
|
||||
return self.get_failed_reponse(
|
||||
return self.get_failed_response(
|
||||
'/',
|
||||
_('DingTalk Error'),
|
||||
msg
|
||||
@@ -53,8 +49,8 @@ class DingTalkQRMixin(PermissionsMixin, View):
|
||||
return True
|
||||
|
||||
def get_verify_state_failed_response(self, redirect_uri):
|
||||
msg = _("You've been hacked")
|
||||
return self.get_failed_reponse(redirect_uri, msg, msg)
|
||||
msg = _("The system configuration is incorrect. Please contact your administrator")
|
||||
return self.get_failed_response(redirect_uri, msg, msg)
|
||||
|
||||
def get_qr_url(self, redirect_uri):
|
||||
state = random_string(16)
|
||||
@@ -67,30 +63,32 @@ class DingTalkQRMixin(PermissionsMixin, View):
|
||||
'state': state,
|
||||
'redirect_uri': redirect_uri,
|
||||
}
|
||||
url = URL.QR_CONNECT + '?' + urllib.parse.urlencode(params)
|
||||
url = URL.QR_CONNECT + '?' + urlencode(params)
|
||||
return url
|
||||
|
||||
def get_success_reponse(self, redirect_url, title, msg):
|
||||
ok_flash_msg_url = reverse('authentication:dingtalk-bind-success-flash-msg')
|
||||
ok_flash_msg_url += '?' + urllib.parse.urlencode({
|
||||
'redirect_url': redirect_url,
|
||||
@staticmethod
|
||||
def get_success_response(redirect_url, title, msg):
|
||||
message_data = {
|
||||
'title': title,
|
||||
'msg': msg
|
||||
})
|
||||
return HttpResponseRedirect(ok_flash_msg_url)
|
||||
'message': msg,
|
||||
'interval': 5,
|
||||
'redirect_url': redirect_url,
|
||||
}
|
||||
return FlashMessageUtil.gen_and_redirect_to(message_data)
|
||||
|
||||
def get_failed_reponse(self, redirect_url, title, msg):
|
||||
failed_flash_msg_url = reverse('authentication:dingtalk-bind-failed-flash-msg')
|
||||
failed_flash_msg_url += '?' + urllib.parse.urlencode({
|
||||
'redirect_url': redirect_url,
|
||||
@staticmethod
|
||||
def get_failed_response(redirect_url, title, msg):
|
||||
message_data = {
|
||||
'title': title,
|
||||
'msg': msg
|
||||
})
|
||||
return HttpResponseRedirect(failed_flash_msg_url)
|
||||
'error': msg,
|
||||
'interval': 5,
|
||||
'redirect_url': redirect_url,
|
||||
}
|
||||
return FlashMessageUtil.gen_and_redirect_to(message_data)
|
||||
|
||||
def get_already_bound_response(self, redirect_url):
|
||||
msg = _('DingTalk is already bound')
|
||||
response = self.get_failed_reponse(redirect_url, msg, msg)
|
||||
response = self.get_failed_response(redirect_url, msg, msg)
|
||||
return response
|
||||
|
||||
|
||||
@@ -103,11 +101,11 @@ class DingTalkQRBindView(DingTalkQRMixin, View):
|
||||
|
||||
if not is_auth_password_time_valid(request.session):
|
||||
msg = _('Please verify your password first')
|
||||
response = self.get_failed_reponse(redirect_url, msg, msg)
|
||||
response = self.get_failed_response(redirect_url, msg, msg)
|
||||
return response
|
||||
|
||||
redirect_uri = reverse('authentication:dingtalk-qr-bind-callback', kwargs={'user_id': user.id}, external=True)
|
||||
redirect_uri += '?' + urllib.parse.urlencode({'redirect_url': redirect_url})
|
||||
redirect_uri += '?' + urlencode({'redirect_url': redirect_url})
|
||||
|
||||
url = self.get_qr_url(redirect_uri)
|
||||
return HttpResponseRedirect(url)
|
||||
@@ -127,7 +125,7 @@ class DingTalkQRBindCallbackView(DingTalkQRMixin, View):
|
||||
if user is None:
|
||||
logger.error(f'DingTalkQR bind callback error, user_id invalid: user_id={user_id}')
|
||||
msg = _('Invalid user_id')
|
||||
response = self.get_failed_reponse(redirect_url, msg, msg)
|
||||
response = self.get_failed_response(redirect_url, msg, msg)
|
||||
return response
|
||||
|
||||
if user.dingtalk_id:
|
||||
@@ -143,7 +141,7 @@ class DingTalkQRBindCallbackView(DingTalkQRMixin, View):
|
||||
|
||||
if not userid:
|
||||
msg = _('DingTalk query user failed')
|
||||
response = self.get_failed_reponse(redirect_url, msg, msg)
|
||||
response = self.get_failed_response(redirect_url, msg, msg)
|
||||
return response
|
||||
|
||||
try:
|
||||
@@ -152,12 +150,12 @@ class DingTalkQRBindCallbackView(DingTalkQRMixin, View):
|
||||
except IntegrityError as e:
|
||||
if e.args[0] == 1062:
|
||||
msg = _('The DingTalk is already bound to another user')
|
||||
response = self.get_failed_reponse(redirect_url, msg, msg)
|
||||
response = self.get_failed_response(redirect_url, msg, msg)
|
||||
return response
|
||||
raise e
|
||||
|
||||
msg = _('Binding DingTalk successfully')
|
||||
response = self.get_success_reponse(redirect_url, msg, msg)
|
||||
response = self.get_success_response(redirect_url, msg, msg)
|
||||
return response
|
||||
|
||||
|
||||
@@ -169,7 +167,7 @@ class DingTalkEnableStartView(UserVerifyPasswordView):
|
||||
|
||||
success_url = reverse('authentication:dingtalk-qr-bind')
|
||||
|
||||
success_url += '?' + urllib.parse.urlencode({
|
||||
success_url += '?' + urlencode({
|
||||
'redirect_url': redirect_url or referer
|
||||
})
|
||||
|
||||
@@ -183,7 +181,7 @@ class DingTalkQRLoginView(DingTalkQRMixin, View):
|
||||
redirect_url = request.GET.get('redirect_url')
|
||||
|
||||
redirect_uri = reverse('authentication:dingtalk-qr-login-callback', external=True)
|
||||
redirect_uri += '?' + urllib.parse.urlencode({'redirect_url': redirect_url})
|
||||
redirect_uri += '?' + urlencode({'redirect_url': redirect_url})
|
||||
|
||||
url = self.get_qr_url(redirect_uri)
|
||||
return HttpResponseRedirect(url)
|
||||
@@ -209,14 +207,14 @@ class DingTalkQRLoginCallbackView(AuthMixin, DingTalkQRMixin, View):
|
||||
if not userid:
|
||||
# 正常流程不会出这个错误,hack 行为
|
||||
msg = _('Failed to get user from DingTalk')
|
||||
response = self.get_failed_reponse(login_url, title=msg, msg=msg)
|
||||
response = self.get_failed_response(login_url, title=msg, msg=msg)
|
||||
return response
|
||||
|
||||
user = get_object_or_none(User, dingtalk_id=userid)
|
||||
if user is None:
|
||||
title = _('DingTalk is not bound')
|
||||
msg = _('Please login with a password and then bind the WeCom')
|
||||
response = self.get_failed_reponse(login_url, title=title, msg=msg)
|
||||
msg = _('Please login with a password and then bind the DingTalk')
|
||||
response = self.get_failed_response(login_url, title=title, msg=msg)
|
||||
return response
|
||||
|
||||
try:
|
||||
@@ -224,43 +222,7 @@ class DingTalkQRLoginCallbackView(AuthMixin, DingTalkQRMixin, View):
|
||||
except errors.AuthFailedError as e:
|
||||
self.set_login_failed_mark()
|
||||
msg = e.msg
|
||||
response = self.get_failed_reponse(login_url, title=msg, msg=msg)
|
||||
response = self.get_failed_response(login_url, title=msg, msg=msg)
|
||||
return response
|
||||
|
||||
return self.redirect_to_guard_view()
|
||||
|
||||
|
||||
@method_decorator(never_cache, name='dispatch')
|
||||
class FlashDingTalkBindSucceedMsgView(TemplateView):
|
||||
template_name = 'flash_message_standalone.html'
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
title = request.GET.get('title')
|
||||
msg = request.GET.get('msg')
|
||||
|
||||
context = {
|
||||
'title': title or _('Binding DingTalk successfully'),
|
||||
'messages': msg or _('Binding DingTalk successfully'),
|
||||
'interval': 5,
|
||||
'redirect_url': request.GET.get('redirect_url'),
|
||||
'auto_redirect': True,
|
||||
}
|
||||
return self.render_to_response(context)
|
||||
|
||||
|
||||
@method_decorator(never_cache, name='dispatch')
|
||||
class FlashDingTalkBindFailedMsgView(TemplateView):
|
||||
template_name = 'flash_message_standalone.html'
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
title = request.GET.get('title')
|
||||
msg = request.GET.get('msg')
|
||||
|
||||
context = {
|
||||
'title': title or _('Binding DingTalk failed'),
|
||||
'messages': msg or _('Binding DingTalk failed'),
|
||||
'interval': 5,
|
||||
'redirect_url': request.GET.get('redirect_url'),
|
||||
'auto_redirect': True,
|
||||
}
|
||||
return self.render_to_response(context)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -5,10 +5,12 @@ from __future__ import unicode_literals
|
||||
import os
|
||||
import datetime
|
||||
|
||||
from django.templatetags.static import static
|
||||
from django.contrib.auth import login as auth_login, logout as auth_logout
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import reverse, redirect
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.db import transaction
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.views.decorators.cache import never_cache
|
||||
from django.views.decorators.csrf import csrf_protect
|
||||
@@ -18,17 +20,15 @@ from django.views.generic.edit import FormView
|
||||
from django.conf import settings
|
||||
from django.urls import reverse_lazy
|
||||
from django.contrib.auth import BACKEND_SESSION_KEY
|
||||
from django.db.transaction import atomic
|
||||
|
||||
from common.utils import get_request_ip, FlashMessageUtil
|
||||
from common.utils import FlashMessageUtil
|
||||
from users.utils import (
|
||||
redirect_user_first_login_or_index
|
||||
)
|
||||
from ..const import RSA_PRIVATE_KEY, RSA_PUBLIC_KEY
|
||||
from .. import mixins, errors, utils
|
||||
from .. import mixins, errors
|
||||
from ..forms import get_user_login_form_cls
|
||||
|
||||
|
||||
__all__ = [
|
||||
'UserLoginView', 'UserLogoutView',
|
||||
'UserLoginGuardView', 'UserLoginWaitConfirmView',
|
||||
@@ -51,7 +51,8 @@ class UserLoginView(mixins.AuthMixin, FormView):
|
||||
|
||||
if settings.AUTH_OPENID:
|
||||
auth_type = 'OIDC'
|
||||
openid_auth_url = reverse(settings.AUTH_OPENID_AUTH_LOGIN_URL_NAME) + f'?next={next_url}'
|
||||
openid_auth_url = reverse(settings.AUTH_OPENID_AUTH_LOGIN_URL_NAME)
|
||||
openid_auth_url = openid_auth_url + f'?next={next_url}'
|
||||
else:
|
||||
openid_auth_url = None
|
||||
|
||||
@@ -64,16 +65,17 @@ class UserLoginView(mixins.AuthMixin, FormView):
|
||||
if not any([openid_auth_url, cas_auth_url]):
|
||||
return None
|
||||
|
||||
if settings.LOGIN_REDIRECT_TO_BACKEND == 'OPENID' and openid_auth_url:
|
||||
auth_url = openid_auth_url
|
||||
|
||||
elif settings.LOGIN_REDIRECT_TO_BACKEND == 'CAS' and cas_auth_url:
|
||||
login_redirect = settings.LOGIN_REDIRECT_TO_BACKEND.lower()
|
||||
if login_redirect in ['direct']:
|
||||
return None
|
||||
if login_redirect in ['cas'] and cas_auth_url:
|
||||
auth_url = cas_auth_url
|
||||
|
||||
elif login_redirect in ['openid', 'oidc'] and openid_auth_url:
|
||||
auth_url = openid_auth_url
|
||||
else:
|
||||
auth_url = openid_auth_url or cas_auth_url
|
||||
|
||||
if settings.LOGIN_REDIRECT_TO_BACKEND:
|
||||
if settings.LOGIN_REDIRECT_TO_BACKEND or not settings.LOGIN_REDIRECT_MSG_ENABLED:
|
||||
redirect_url = auth_url
|
||||
else:
|
||||
message_data = {
|
||||
@@ -109,20 +111,32 @@ class UserLoginView(mixins.AuthMixin, FormView):
|
||||
self.request.session.delete_test_cookie()
|
||||
|
||||
try:
|
||||
with atomic():
|
||||
self.check_user_auth(decrypt_passwd=True)
|
||||
self.check_user_auth(decrypt_passwd=True)
|
||||
except errors.AuthFailedError as e:
|
||||
form.add_error(None, e.msg)
|
||||
self.set_login_failed_mark()
|
||||
|
||||
form_cls = get_user_login_form_cls(captcha=True)
|
||||
new_form = form_cls(data=form.data)
|
||||
new_form._errors = form.errors
|
||||
context = self.get_context_data(form=new_form)
|
||||
self.request.session.set_test_cookie()
|
||||
return self.render_to_response(context)
|
||||
except (errors.PasswdTooSimple, errors.PasswordRequireResetError, errors.PasswdNeedUpdate) as e:
|
||||
except (
|
||||
errors.PasswdTooSimple,
|
||||
errors.PasswordRequireResetError,
|
||||
errors.PasswdNeedUpdate,
|
||||
errors.OTPBindRequiredError
|
||||
) as e:
|
||||
return redirect(e.url)
|
||||
except (
|
||||
errors.MFAFailedError,
|
||||
errors.BlockMFAError,
|
||||
errors.OTPCodeRequiredError,
|
||||
errors.SMSCodeRequiredError,
|
||||
errors.UserPhoneNotSet
|
||||
) as e:
|
||||
form.add_error('code', e.msg)
|
||||
return super().form_invalid(form)
|
||||
self.clear_rsa_key()
|
||||
return self.redirect_to_guard_view()
|
||||
|
||||
@@ -136,30 +150,56 @@ class UserLoginView(mixins.AuthMixin, FormView):
|
||||
self.request.session[RSA_PRIVATE_KEY] = None
|
||||
self.request.session[RSA_PUBLIC_KEY] = None
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
# 生成加解密密钥对,public_key传递给前端,private_key存入session中供解密使用
|
||||
rsa_private_key = self.request.session.get(RSA_PRIVATE_KEY)
|
||||
rsa_public_key = self.request.session.get(RSA_PUBLIC_KEY)
|
||||
if not all((rsa_private_key, rsa_public_key)):
|
||||
rsa_private_key, rsa_public_key = utils.gen_key_pair()
|
||||
rsa_public_key = rsa_public_key.replace('\n', '\\n')
|
||||
self.request.session[RSA_PRIVATE_KEY] = rsa_private_key
|
||||
self.request.session[RSA_PUBLIC_KEY] = rsa_public_key
|
||||
@staticmethod
|
||||
def get_support_auth_methods():
|
||||
auth_methods = [
|
||||
{
|
||||
'name': 'OpenID',
|
||||
'enabled': settings.AUTH_OPENID,
|
||||
'url': reverse('authentication:openid:login'),
|
||||
'logo': static('img/login_oidc_logo.png')
|
||||
},
|
||||
{
|
||||
'name': 'CAS',
|
||||
'enabled': settings.AUTH_CAS,
|
||||
'url': reverse('authentication:cas:cas-login'),
|
||||
'logo': static('img/login_cas_logo.png')
|
||||
},
|
||||
{
|
||||
'name': _('WeCom'),
|
||||
'enabled': settings.AUTH_WECOM,
|
||||
'url': reverse('authentication:wecom-qr-login'),
|
||||
'logo': static('img/login_wecom_logo.png')
|
||||
},
|
||||
{
|
||||
'name': _('DingTalk'),
|
||||
'enabled': settings.AUTH_DINGTALK,
|
||||
'url': reverse('authentication:dingtalk-qr-login'),
|
||||
'logo': static('img/login_dingtalk_logo.png')
|
||||
},
|
||||
{
|
||||
'name': _('FeiShu'),
|
||||
'enabled': settings.AUTH_FEISHU,
|
||||
'url': reverse('authentication:feishu-qr-login'),
|
||||
'logo': static('img/login_feishu_logo.png')
|
||||
}
|
||||
]
|
||||
return [method for method in auth_methods if method['enabled']]
|
||||
|
||||
@staticmethod
|
||||
def get_forgot_password_url():
|
||||
forgot_password_url = reverse('authentication:forgot-password')
|
||||
has_other_auth_backend = settings.AUTHENTICATION_BACKENDS[0] != settings.AUTH_BACKEND_MODEL
|
||||
if has_other_auth_backend and settings.FORGOT_PASSWORD_URL:
|
||||
forgot_password_url = settings.FORGOT_PASSWORD_URL
|
||||
return forgot_password_url
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = {
|
||||
'demo_mode': os.environ.get("DEMO_MODE"),
|
||||
'AUTH_OPENID': settings.AUTH_OPENID,
|
||||
'AUTH_CAS': settings.AUTH_CAS,
|
||||
'AUTH_WECOM': settings.AUTH_WECOM,
|
||||
'AUTH_DINGTALK': settings.AUTH_DINGTALK,
|
||||
'AUTH_FEISHU': settings.AUTH_FEISHU,
|
||||
'rsa_public_key': rsa_public_key,
|
||||
'forgot_password_url': forgot_password_url
|
||||
'auth_methods': self.get_support_auth_methods(),
|
||||
'forgot_password_url': self.get_forgot_password_url(),
|
||||
'methods': self.get_user_mfa_methods(),
|
||||
}
|
||||
kwargs.update(context)
|
||||
return super().get_context_data(**kwargs)
|
||||
@@ -224,8 +264,10 @@ class UserLoginWaitConfirmView(TemplateView):
|
||||
if ticket:
|
||||
timestamp_created = datetime.datetime.timestamp(ticket.date_created)
|
||||
ticket_detail_url = TICKET_DETAIL_URL.format(id=ticket_id)
|
||||
assignees = ticket.current_node.first().ticket_assignees.all()
|
||||
assignees_display = ', '.join([str(i.assignee) for i in assignees])
|
||||
msg = _("""Wait for <b>{}</b> confirm, You also can copy link to her/him <br/>
|
||||
Don't close this page""").format(ticket.assignees_display)
|
||||
Don't close this page""").format(assignees_display)
|
||||
else:
|
||||
timestamp_created = 0
|
||||
ticket_detail_url = ''
|
||||
@@ -262,7 +304,7 @@ class UserLogoutView(TemplateView):
|
||||
def get_context_data(self, **kwargs):
|
||||
context = {
|
||||
'title': _('Logout success'),
|
||||
'messages': _('Logout success, return login page'),
|
||||
'message': _('Logout success, return login page'),
|
||||
'interval': 3,
|
||||
'redirect_url': reverse('authentication:login'),
|
||||
'auto_redirect': True,
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
|
||||
from __future__ import unicode_literals
|
||||
from django.views.generic.edit import FormView
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.conf import settings
|
||||
from .. import forms, errors, mixins
|
||||
from .utils import redirect_to_guard_view
|
||||
|
||||
@@ -18,16 +20,23 @@ class UserLoginOtpView(mixins.AuthMixin, FormView):
|
||||
redirect_field_name = 'next'
|
||||
|
||||
def form_valid(self, form):
|
||||
otp_code = form.cleaned_data.get('otp_code')
|
||||
code = form.cleaned_data.get('code')
|
||||
mfa_type = form.cleaned_data.get('mfa_type')
|
||||
|
||||
try:
|
||||
self.check_user_mfa(otp_code)
|
||||
self.check_user_mfa(code, mfa_type)
|
||||
return redirect_to_guard_view()
|
||||
except (errors.MFAFailedError, errors.BlockMFAError) as e:
|
||||
form.add_error('otp_code', e.msg)
|
||||
form.add_error('code', e.msg)
|
||||
return super().form_invalid(form)
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
import traceback
|
||||
traceback.print_exception()
|
||||
traceback.print_exc()
|
||||
return redirect_to_guard_view()
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
user = self.get_user_from_session()
|
||||
methods = self.get_user_mfa_methods(user)
|
||||
kwargs.update({'methods': methods})
|
||||
return kwargs
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -10,5 +10,6 @@ class CommonConfig(AppConfig):
|
||||
def ready(self):
|
||||
from . import signals_handlers
|
||||
from .signals import django_ready
|
||||
if 'migrate' not in sys.argv:
|
||||
django_ready.send(CommonConfig)
|
||||
if 'migrate' in sys.argv or 'compilemessages' in sys.argv:
|
||||
return
|
||||
django_ready.send(CommonConfig)
|
||||
|
||||
20
apps/common/db/encoder.py
Normal file
20
apps/common/db/encoder.py
Normal file
@@ -0,0 +1,20 @@
|
||||
import json
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class ModelJSONFieldEncoder(json.JSONEncoder):
|
||||
""" 解决一些类型的字段不能序列化的问题 """
|
||||
|
||||
def default(self, obj):
|
||||
if isinstance(obj, datetime):
|
||||
return obj.strftime(settings.DATETIME_DISPLAY_FORMAT)
|
||||
if isinstance(obj, uuid.UUID):
|
||||
return str(obj)
|
||||
if isinstance(obj, type(_("ugettext_lazy"))):
|
||||
return str(obj)
|
||||
else:
|
||||
return super().default(obj)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -160,7 +160,7 @@ class BaseService(object):
|
||||
if self.process:
|
||||
try:
|
||||
self.process.wait(1) # 不wait,子进程可能无法回收
|
||||
except subprocess.TimeoutExpired:
|
||||
except:
|
||||
pass
|
||||
|
||||
if self.is_running:
|
||||
|
||||
@@ -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
|
||||
7
apps/common/mixins/api/__init__.py
Normal file
7
apps/common/mixins/api/__init__.py
Normal 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 *
|
||||
55
apps/common/mixins/api/action.py
Normal file
55
apps/common/mixins/api/action.py
Normal 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)
|
||||
96
apps/common/mixins/api/common.py
Normal file
96
apps/common/mixins/api/common.py
Normal 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
|
||||
|
||||
|
||||
|
||||
35
apps/common/mixins/api/filter.py
Normal file
35
apps/common/mixins/api/filter.py
Normal 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
|
||||
136
apps/common/mixins/api/patch.py
Normal file
136
apps/common/mixins/api/patch.py
Normal file
@@ -0,0 +1,136 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
import time
|
||||
from hashlib import md5
|
||||
from threading import Thread
|
||||
|
||||
from django.core.cache import cache
|
||||
from rest_framework.response import Response
|
||||
|
||||
from common.utils import lazyproperty
|
||||
|
||||
|
||||
__all__ = ['InterceptMixin', 'AsyncApiMixin']
|
||||
|
||||
|
||||
class InterceptMixin:
|
||||
"""
|
||||
Hack默认的dispatch, 让用户可以实现 self.do
|
||||
"""
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
self.args = args
|
||||
self.kwargs = kwargs
|
||||
request = self.initialize_request(request, *args, **kwargs)
|
||||
self.request = request
|
||||
self.headers = self.default_response_headers # deprecate?
|
||||
|
||||
try:
|
||||
self.initial(request, *args, **kwargs)
|
||||
|
||||
# Get the appropriate handler method
|
||||
if request.method.lower() in self.http_method_names:
|
||||
handler = getattr(self, request.method.lower(),
|
||||
self.http_method_not_allowed)
|
||||
else:
|
||||
handler = self.http_method_not_allowed
|
||||
|
||||
response = self.do(handler, request, *args, **kwargs)
|
||||
|
||||
except Exception as exc:
|
||||
response = self.handle_exception(exc)
|
||||
|
||||
self.response = self.finalize_response(request, response, *args, **kwargs)
|
||||
return self.response
|
||||
|
||||
|
||||
class AsyncApiMixin(InterceptMixin):
|
||||
def get_request_user_id(self):
|
||||
user = self.request.user
|
||||
if hasattr(user, 'id'):
|
||||
return str(user.id)
|
||||
return ''
|
||||
|
||||
@lazyproperty
|
||||
def async_cache_key(self):
|
||||
method = self.request.method
|
||||
path = self.get_request_md5()
|
||||
user = self.get_request_user_id()
|
||||
key = '{}_{}_{}'.format(method, path, user)
|
||||
return key
|
||||
|
||||
def get_request_md5(self):
|
||||
path = self.request.path
|
||||
query = {k: v for k, v in self.request.GET.items()}
|
||||
query.pop("_", None)
|
||||
query.pop('refresh', None)
|
||||
query = "&".join(["{}={}".format(k, v) for k, v in query.items()])
|
||||
full_path = "{}?{}".format(path, query)
|
||||
return md5(full_path.encode()).hexdigest()
|
||||
|
||||
@lazyproperty
|
||||
def initial_data(self):
|
||||
data = {
|
||||
"status": "running",
|
||||
"start_time": time.time(),
|
||||
"key": self.async_cache_key,
|
||||
}
|
||||
return data
|
||||
|
||||
def get_cache_data(self):
|
||||
key = self.async_cache_key
|
||||
if self.is_need_refresh():
|
||||
cache.delete(key)
|
||||
return None
|
||||
data = cache.get(key)
|
||||
return data
|
||||
|
||||
def do(self, handler, *args, **kwargs):
|
||||
if not self.is_need_async():
|
||||
return handler(*args, **kwargs)
|
||||
resp = self.do_async(handler, *args, **kwargs)
|
||||
return resp
|
||||
|
||||
def is_need_refresh(self):
|
||||
if self.request.GET.get("refresh"):
|
||||
return True
|
||||
return False
|
||||
|
||||
def is_need_async(self):
|
||||
return False
|
||||
|
||||
def do_async(self, handler, *args, **kwargs):
|
||||
data = self.get_cache_data()
|
||||
if not data:
|
||||
t = Thread(
|
||||
target=self.do_in_thread,
|
||||
args=(handler, *args),
|
||||
kwargs=kwargs
|
||||
)
|
||||
t.start()
|
||||
resp = Response(self.initial_data)
|
||||
return resp
|
||||
status = data.get("status")
|
||||
resp = data.get("resp")
|
||||
if status == "ok" and resp:
|
||||
resp = Response(**resp)
|
||||
else:
|
||||
resp = Response(data)
|
||||
return resp
|
||||
|
||||
def do_in_thread(self, handler, *args, **kwargs):
|
||||
key = self.async_cache_key
|
||||
data = self.initial_data
|
||||
cache.set(key, data, 600)
|
||||
try:
|
||||
response = handler(*args, **kwargs)
|
||||
data["status"] = "ok"
|
||||
data["resp"] = {
|
||||
"data": response.data,
|
||||
"status": response.status_code
|
||||
}
|
||||
cache.set(key, data, 600)
|
||||
except Exception as e:
|
||||
data["error"] = str(e)
|
||||
data["status"] = "error"
|
||||
cache.set(key, data, 600)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user