mirror of
https://github.com/jumpserver/jumpserver.git
synced 2025-12-16 00:52:41 +00:00
Compare commits
159 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95412d739a | ||
|
|
93b5876469 | ||
|
|
da02d1e456 | ||
|
|
5928b265b7 | ||
|
|
f46c043db5 | ||
|
|
cbc1ab411b | ||
|
|
5e03af7243 | ||
|
|
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 |
13
Dockerfile
13
Dockerfile
@@ -22,21 +22,30 @@ COPY ./requirements/deb_buster_requirements.txt ./requirements/deb_buster_requir
|
||||
RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list \
|
||||
&& sed -i 's/security.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list \
|
||||
&& apt update \
|
||||
&& apt -y install telnet iproute2 redis-tools \
|
||||
&& grep -v '^#' ./requirements/deb_buster_requirements.txt | xargs apt -y install \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& localedef -c -f UTF-8 -i zh_CN zh_CN.UTF-8 \
|
||||
&& cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
|
||||
&& cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
|
||||
&& sed -i "s@# alias l@alias l@g" ~/.bashrc \
|
||||
&& echo "set mouse-=a" > ~/.vimrc
|
||||
|
||||
COPY ./requirements/requirements.txt ./requirements/requirements.txt
|
||||
RUN pip install --upgrade pip==20.2.4 setuptools==49.6.0 wheel==0.34.2 -i ${PIP_MIRROR} \
|
||||
&& pip config set global.index-url ${PIP_MIRROR} \
|
||||
&& pip install --no-cache-dir $(grep 'jms' requirements/requirements.txt) -i ${PIP_JMS_MIRROR} \
|
||||
&& pip install --no-cache-dir $(grep -E 'jms|jumpserver' requirements/requirements.txt) -i ${PIP_JMS_MIRROR} \
|
||||
&& pip install --no-cache-dir -r requirements/requirements.txt
|
||||
|
||||
COPY --from=stage-build /opt/jumpserver/release/jumpserver /opt/jumpserver
|
||||
RUN mkdir -p /root/.ssh/ \
|
||||
&& echo "Host *\n\tStrictHostKeyChecking no\n\tUserKnownHostsFile /dev/null" > /root/.ssh/config
|
||||
|
||||
RUN mkdir -p /opt/jumpserver/oracle/
|
||||
ADD https://download.jumpserver.org/public/instantclient-basiclite-linux.x64-21.1.0.0.0.tar /opt/jumpserver/oracle/
|
||||
RUN tar xvf /opt/jumpserver/oracle/instantclient-basiclite-linux.x64-21.1.0.0.0.tar -C /opt/jumpserver/oracle/
|
||||
RUN sh -c "echo /opt/jumpserver/oracle/instantclient_21_1 > /etc/ld.so.conf.d/oracle-instantclient.conf"
|
||||
RUN ldconfig
|
||||
|
||||
RUN echo > config.yml
|
||||
VOLUME /opt/jumpserver/data
|
||||
VOLUME /opt/jumpserver/logs
|
||||
|
||||
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
|
||||
@@ -9,12 +9,11 @@ from tickets.api import GenericTicketStatusRetrieveCloseAPI
|
||||
from ..models import LoginAssetACL
|
||||
from .. import serializers
|
||||
|
||||
|
||||
__all__ = ['LoginAssetCheckAPI', 'LoginAssetConfirmStatusAPI']
|
||||
|
||||
|
||||
class LoginAssetCheckAPI(CreateAPIView):
|
||||
permission_classes = (IsAppUser, )
|
||||
permission_classes = (IsAppUser,)
|
||||
serializer_class = serializers.LoginAssetCheckSerializer
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
@@ -57,11 +56,12 @@ class LoginAssetCheckAPI(CreateAPIView):
|
||||
external=True, api_to_ui=True
|
||||
)
|
||||
ticket_detail_url = '{url}?type={type}'.format(url=ticket_detail_url, type=ticket.type)
|
||||
ticket_assignees = ticket.current_node.first().ticket_assignees.all()
|
||||
data = {
|
||||
'check_confirm_status': {'method': 'GET', 'url': confirm_status_url},
|
||||
'close_confirm': {'method': 'DELETE', 'url': confirm_status_url},
|
||||
'ticket_detail_url': ticket_detail_url,
|
||||
'reviewers': [str(user) for user in ticket.assignees.all()],
|
||||
'reviewers': [str(ticket_assignee.assignee) for ticket_assignee in ticket_assignees]
|
||||
}
|
||||
return data
|
||||
|
||||
@@ -74,4 +74,3 @@ class LoginAssetCheckAPI(CreateAPIView):
|
||||
|
||||
class LoginAssetConfirmStatusAPI(GenericTicketStatusRetrieveCloseAPI):
|
||||
pass
|
||||
|
||||
|
||||
@@ -83,11 +83,11 @@ class LoginAssetACL(BaseACL, OrgModelMixin):
|
||||
|
||||
@classmethod
|
||||
def create_login_asset_confirm_ticket(cls, user, asset, system_user, assignees, org_id):
|
||||
from tickets.const import TicketTypeChoices
|
||||
from tickets.const import TicketType
|
||||
from tickets.models import Ticket
|
||||
data = {
|
||||
'title': _('Login asset confirm') + ' ({})'.format(user),
|
||||
'type': TicketTypeChoices.login_asset_confirm,
|
||||
'type': TicketType.login_asset_confirm,
|
||||
'meta': {
|
||||
'apply_login_user': str(user),
|
||||
'apply_login_asset': str(asset),
|
||||
@@ -96,7 +96,7 @@ class LoginAssetACL(BaseACL, OrgModelMixin):
|
||||
'org_id': org_id,
|
||||
}
|
||||
ticket = Ticket.objects.create(**data)
|
||||
ticket.assignees.set(assignees)
|
||||
ticket.create_process_map_and_node(assignees)
|
||||
ticket.open(applicant=user)
|
||||
return ticket
|
||||
|
||||
|
||||
@@ -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.views import SuggestionMixin
|
||||
from ..hands import IsOrgAdminOrAppUser
|
||||
from .. import serializers
|
||||
from ..models import Application
|
||||
|
||||
|
||||
__all__ = ['ApplicationViewSet']
|
||||
|
||||
|
||||
class ApplicationViewSet(OrgBulkModelViewSet):
|
||||
class ApplicationViewSet(SuggestionMixin, OrgBulkModelViewSet):
|
||||
model = Application
|
||||
filterset_fields = {
|
||||
'name': ['exact'],
|
||||
@@ -24,8 +24,9 @@ class ApplicationViewSet(OrgBulkModelViewSet):
|
||||
search_fields = ('name', 'type', 'category')
|
||||
permission_classes = (IsOrgAdminOrAppUser,)
|
||||
serializer_classes = {
|
||||
'default': serializers.ApplicationSerializer,
|
||||
'get_tree': TreeNodeSerializer
|
||||
'default': serializers.AppSerializer,
|
||||
'get_tree': TreeNodeSerializer,
|
||||
'suggestion': serializers.MiniAppSerializer
|
||||
}
|
||||
|
||||
@action(methods=['GET'], detail=False, url_path='tree')
|
||||
|
||||
@@ -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)
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -4,20 +4,23 @@
|
||||
from rest_framework import serializers
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from orgs.models import Organization
|
||||
from orgs.mixins.serializers import BulkOrgResourceModelSerializer
|
||||
from assets.serializers.base import AuthSerializerMixin
|
||||
from common.drf.serializers import MethodSerializer
|
||||
from .attrs import category_serializer_classes_mapping, type_serializer_classes_mapping
|
||||
from .attrs import (
|
||||
category_serializer_classes_mapping,
|
||||
type_serializer_classes_mapping
|
||||
)
|
||||
from .. import models
|
||||
from .. import const
|
||||
|
||||
__all__ = [
|
||||
'ApplicationSerializer', 'ApplicationSerializerMixin',
|
||||
'ApplicationAccountSerializer', 'ApplicationAccountSecretSerializer'
|
||||
'AppSerializer', 'MiniAppSerializer', 'AppSerializerMixin',
|
||||
'AppAccountSerializer', 'AppAccountSecretSerializer'
|
||||
]
|
||||
|
||||
|
||||
class ApplicationSerializerMixin(serializers.Serializer):
|
||||
class AppSerializerMixin(serializers.Serializer):
|
||||
attrs = MethodSerializer()
|
||||
|
||||
def get_attrs_serializer(self):
|
||||
@@ -45,8 +48,14 @@ class ApplicationSerializerMixin(serializers.Serializer):
|
||||
serializer = serializer_class
|
||||
return serializer
|
||||
|
||||
def create(self, validated_data):
|
||||
return super().create(validated_data)
|
||||
|
||||
class ApplicationSerializer(ApplicationSerializerMixin, BulkOrgResourceModelSerializer):
|
||||
def update(self, instance, validated_data):
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
class AppSerializer(AppSerializerMixin, BulkOrgResourceModelSerializer):
|
||||
category_display = serializers.ReadOnlyField(source='get_category_display', label=_('Category display'))
|
||||
type_display = serializers.ReadOnlyField(source='get_type_display', label=_('Type display'))
|
||||
|
||||
@@ -69,42 +78,60 @@ class ApplicationSerializer(ApplicationSerializerMixin, BulkOrgResourceModelSeri
|
||||
return _attrs
|
||||
|
||||
|
||||
class ApplicationAccountSerializer(serializers.Serializer):
|
||||
id = serializers.ReadOnlyField(label=_("Id"), source='uid')
|
||||
username = serializers.ReadOnlyField(label=_("Username"))
|
||||
password = serializers.CharField(write_only=True, label=_("Password"))
|
||||
systemuser = serializers.ReadOnlyField(label=_('System user'))
|
||||
systemuser_display = serializers.ReadOnlyField(label=_("System user display"))
|
||||
app = serializers.ReadOnlyField(label=_('App'))
|
||||
app_name = serializers.ReadOnlyField(label=_("Application name"), read_only=True)
|
||||
class MiniAppSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = models.Application
|
||||
fields = AppSerializer.Meta.fields_mini
|
||||
|
||||
|
||||
class AppAccountSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
|
||||
category = serializers.ChoiceField(label=_('Category'), choices=const.AppCategory.choices, read_only=True)
|
||||
category_display = serializers.SerializerMethodField(label=_('Category display'))
|
||||
type = serializers.ChoiceField(label=_('Type'), choices=const.AppType.choices, read_only=True)
|
||||
type_display = serializers.SerializerMethodField(label=_('Type display'))
|
||||
uid = serializers.ReadOnlyField(label=_("Union id"))
|
||||
org_id = serializers.ReadOnlyField(label=_("Organization"))
|
||||
org_name = serializers.SerializerMethodField(label=_("Org name"))
|
||||
|
||||
category_mapper = dict(const.AppCategory.choices)
|
||||
type_mapper = dict(const.AppType.choices)
|
||||
|
||||
def create(self, validated_data):
|
||||
pass
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
pass
|
||||
class Meta:
|
||||
model = models.Account
|
||||
fields_mini = ['id', 'username', 'version']
|
||||
fields_write_only = ['password', 'private_key']
|
||||
fields_fk = ['systemuser', 'systemuser_display', 'app', 'app_display']
|
||||
fields = fields_mini + fields_fk + fields_write_only + [
|
||||
'type', 'type_display', 'category', 'category_display',
|
||||
]
|
||||
extra_kwargs = {
|
||||
'username': {'default': '', 'required': False},
|
||||
'password': {'write_only': True},
|
||||
'app_display': {'label': _('Application display')}
|
||||
}
|
||||
use_model_bulk_create = True
|
||||
model_bulk_create_kwargs = {
|
||||
'ignore_conflicts': True
|
||||
}
|
||||
|
||||
def get_category_display(self, obj):
|
||||
return self.category_mapper.get(obj['category'])
|
||||
return self.category_mapper.get(obj.category)
|
||||
|
||||
def get_type_display(self, obj):
|
||||
return self.type_mapper.get(obj['type'])
|
||||
return self.type_mapper.get(obj.type)
|
||||
|
||||
@staticmethod
|
||||
def get_org_name(obj):
|
||||
org = Organization.get_instance(obj['org_id'])
|
||||
return org.name
|
||||
@classmethod
|
||||
def setup_eager_loading(cls, queryset):
|
||||
""" Perform necessary eager loading of data. """
|
||||
queryset = queryset.prefetch_related('systemuser', 'app')
|
||||
return queryset
|
||||
|
||||
def to_representation(self, instance):
|
||||
instance.load_auth()
|
||||
return super().to_representation(instance)
|
||||
|
||||
|
||||
class ApplicationAccountSecretSerializer(ApplicationAccountSerializer):
|
||||
password = serializers.CharField(write_only=False, label=_("Password"))
|
||||
class AppAccountSecretSerializer(AppAccountSerializer):
|
||||
class Meta(AppAccountSerializer.Meta):
|
||||
extra_kwargs = {
|
||||
'password': {'write_only': False},
|
||||
'private_key': {'write_only': False},
|
||||
'public_key': {'write_only': False},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,9 +7,10 @@ from django.shortcuts import get_object_or_404
|
||||
|
||||
from common.utils import get_logger, get_object_or_none
|
||||
from common.permissions import IsOrgAdmin, IsOrgAdminOrAppUser, IsSuperUser
|
||||
from common.mixins.views import SuggestionMixin
|
||||
from orgs.mixins.api import OrgBulkModelViewSet
|
||||
from orgs.mixins import generics
|
||||
from ..models import Asset, Node, Platform, SystemUser
|
||||
from ..models import Asset, Node, Platform
|
||||
from .. import serializers
|
||||
from ..tasks import (
|
||||
update_assets_hardware_info_manual, test_assets_connectivity_manual,
|
||||
@@ -17,7 +18,6 @@ from ..tasks import (
|
||||
)
|
||||
from ..filters import FilterAssetByNodeFilterBackend, LabelFilterBackend, IpInFilterBackend
|
||||
|
||||
|
||||
logger = get_logger(__file__)
|
||||
__all__ = [
|
||||
'AssetViewSet', 'AssetPlatformRetrieveApi',
|
||||
@@ -26,7 +26,7 @@ __all__ = [
|
||||
]
|
||||
|
||||
|
||||
class AssetViewSet(FilterAssetByNodeMixin, OrgBulkModelViewSet):
|
||||
class AssetViewSet(SuggestionMixin, FilterAssetByNodeMixin, OrgBulkModelViewSet):
|
||||
"""
|
||||
API endpoint that allows Asset to be viewed or edited.
|
||||
"""
|
||||
@@ -43,6 +43,7 @@ class AssetViewSet(FilterAssetByNodeMixin, OrgBulkModelViewSet):
|
||||
ordering_fields = ("hostname", "ip", "port", "cpu_cores")
|
||||
serializer_classes = {
|
||||
'default': serializers.AssetSerializer,
|
||||
'suggestion': serializers.MiniAssetSerializer
|
||||
}
|
||||
permission_classes = (IsOrgAdminOrAppUser,)
|
||||
extra_filter_backends = [FilterAssetByNodeFilterBackend, LabelFilterBackend, IpInFilterBackend]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from common.utils import get_logger
|
||||
from common.permissions import IsOrgAdmin, IsOrgAdminOrAppUser, IsValidUser
|
||||
from orgs.mixins.api import OrgBulkModelViewSet
|
||||
from orgs.mixins import generics
|
||||
from common.mixins.views import SuggestionMixin
|
||||
from orgs.utils import tmp_to_root_org
|
||||
from ..models import SystemUser, Asset
|
||||
from .. import serializers
|
||||
@@ -24,7 +25,7 @@ __all__ = [
|
||||
]
|
||||
|
||||
|
||||
class SystemUserViewSet(OrgBulkModelViewSet):
|
||||
class SystemUserViewSet(SuggestionMixin, OrgBulkModelViewSet):
|
||||
"""
|
||||
System user api set, for add,delete,update,list,retrieve resource
|
||||
"""
|
||||
@@ -39,6 +40,7 @@ class SystemUserViewSet(OrgBulkModelViewSet):
|
||||
serializer_class = serializers.SystemUserSerializer
|
||||
serializer_classes = {
|
||||
'default': serializers.SystemUserSerializer,
|
||||
'suggestion': serializers.MiniSystemUserSerializer
|
||||
}
|
||||
permission_classes = (IsOrgAdminOrAppUser,)
|
||||
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -5,9 +5,12 @@ from django.db import models
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from simple_history.models import HistoricalRecords
|
||||
|
||||
from common.utils import lazyproperty
|
||||
from common.utils import lazyproperty, get_logger
|
||||
from .base import BaseUser, AbsConnectivity
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
__all__ = ['AuthBook']
|
||||
|
||||
|
||||
@@ -16,7 +19,6 @@ class AuthBook(BaseUser, AbsConnectivity):
|
||||
systemuser = models.ForeignKey('assets.SystemUser', on_delete=models.CASCADE, null=True, verbose_name=_("System user"))
|
||||
version = models.IntegerField(default=1, verbose_name=_('Version'))
|
||||
history = HistoricalRecords()
|
||||
_systemuser_display = ''
|
||||
|
||||
auth_attrs = ['username', 'password', 'private_key', 'public_key']
|
||||
|
||||
@@ -64,8 +66,6 @@ class AuthBook(BaseUser, AbsConnectivity):
|
||||
|
||||
@lazyproperty
|
||||
def systemuser_display(self):
|
||||
if self._systemuser_display:
|
||||
return self._systemuser_display
|
||||
if not self.systemuser:
|
||||
return ''
|
||||
return str(self.systemuser)
|
||||
@@ -96,6 +96,24 @@ class AuthBook(BaseUser, AbsConnectivity):
|
||||
i.comment = 'Update triggered by account {}'.format(self.id)
|
||||
i.save(update_fields=['password', 'private_key', 'public_key'])
|
||||
|
||||
def remove_asset_admin_user_if_need(self):
|
||||
if not self.asset or not self.asset.admin_user:
|
||||
return
|
||||
if not self.systemuser.is_admin_user:
|
||||
return
|
||||
logger.debug('Remove asset admin user: {} {}'.format(self.asset, self.systemuser))
|
||||
self.asset.admin_user = None
|
||||
self.asset.save()
|
||||
|
||||
def update_asset_admin_user_if_need(self):
|
||||
if not self.systemuser or not self.systemuser.is_admin_user:
|
||||
return
|
||||
if not self.asset or self.asset.admin_user == self.systemuser:
|
||||
return
|
||||
logger.debug('Update asset admin user: {} {}'.format(self.asset, self.systemuser))
|
||||
self.asset.admin_user = self.systemuser
|
||||
self.asset.save()
|
||||
|
||||
def __str__(self):
|
||||
return self.smart_name
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,7 +8,7 @@ from orgs.mixins.serializers import BulkOrgResourceModelSerializer
|
||||
from ..models import Asset, Node, Platform, SystemUser
|
||||
|
||||
__all__ = [
|
||||
'AssetSerializer', 'AssetSimpleSerializer',
|
||||
'AssetSerializer', 'AssetSimpleSerializer', 'MiniAssetSerializer',
|
||||
'ProtocolsField', 'PlatformSerializer',
|
||||
'AssetTaskSerializer', 'AssetsTaskSerializer', 'ProtocolsField'
|
||||
]
|
||||
@@ -69,15 +69,19 @@ class AssetSerializer(BulkOrgResourceModelSerializer):
|
||||
"""
|
||||
资产的数据结构
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
model = Asset
|
||||
fields_mini = ['id', 'hostname', 'ip']
|
||||
fields_mini = ['id', 'hostname', 'ip', 'platform', 'protocols']
|
||||
fields_small = fields_mini + [
|
||||
'protocol', 'port', 'protocols', 'is_active', 'public_ip',
|
||||
'comment',
|
||||
]
|
||||
hardware_fields = [
|
||||
'number', 'vendor', 'model', 'sn', 'cpu_model', 'cpu_count',
|
||||
'cpu_cores', 'cpu_vcpus', 'memory', 'disk_total', 'disk_info',
|
||||
'os', 'os_version', 'os_arch', 'hostname_raw', 'comment',
|
||||
'hardware_info', 'connectivity', 'date_verified'
|
||||
'os', 'os_version', 'os_arch', 'hostname_raw', 'hardware_info',
|
||||
'connectivity', 'date_verified'
|
||||
]
|
||||
fields_fk = [
|
||||
'domain', 'domain_display', 'platform', 'admin_user', 'admin_user_display'
|
||||
@@ -88,15 +92,16 @@ class AssetSerializer(BulkOrgResourceModelSerializer):
|
||||
read_only_fields = [
|
||||
'created_by', 'date_created',
|
||||
]
|
||||
fields = fields_small + fields_fk + fields_m2m + read_only_fields
|
||||
fields = fields_small + hardware_fields + fields_fk + fields_m2m + read_only_fields
|
||||
|
||||
extra_kwargs = {
|
||||
extra_kwargs = {k: {'read_only': True} for k in hardware_fields}
|
||||
extra_kwargs.update({
|
||||
'protocol': {'write_only': True},
|
||||
'port': {'write_only': True},
|
||||
'hardware_info': {'label': _('Hardware info')},
|
||||
'org_name': {'label': _('Org name')},
|
||||
'admin_user_display': {'label': _('Admin user display')}
|
||||
}
|
||||
'hardware_info': {'label': _('Hardware info'), 'read_only': True},
|
||||
'org_name': {'label': _('Org name'), 'read_only': True},
|
||||
'admin_user_display': {'label': _('Admin user display'), 'read_only': True},
|
||||
})
|
||||
|
||||
def get_fields(self):
|
||||
fields = super().get_fields()
|
||||
@@ -157,6 +162,12 @@ class AssetSerializer(BulkOrgResourceModelSerializer):
|
||||
return instance
|
||||
|
||||
|
||||
class MiniAssetSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Asset
|
||||
fields = AssetSerializer.Meta.fields_mini
|
||||
|
||||
|
||||
class PlatformSerializer(serializers.ModelSerializer):
|
||||
meta = serializers.DictField(required=False, allow_null=True, label=_('Meta'))
|
||||
|
||||
@@ -177,7 +188,6 @@ class PlatformSerializer(serializers.ModelSerializer):
|
||||
|
||||
|
||||
class AssetSimpleSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Asset
|
||||
fields = ['id', 'hostname', 'ip', 'port', 'connectivity', 'date_verified']
|
||||
|
||||
@@ -10,11 +10,11 @@ from .utils import validate_password_contains_left_double_curly_bracket
|
||||
from .base import AuthSerializerMixin
|
||||
|
||||
__all__ = [
|
||||
'SystemUserSerializer',
|
||||
'SystemUserSerializer', 'MiniSystemUserSerializer',
|
||||
'SystemUserSimpleSerializer', 'SystemUserAssetRelationSerializer',
|
||||
'SystemUserNodeRelationSerializer', 'SystemUserTaskSerializer',
|
||||
'SystemUserUserRelationSerializer', 'SystemUserWithAuthInfoSerializer',
|
||||
'SystemUserTempAuthSerializer',
|
||||
'SystemUserTempAuthSerializer', 'RelationMixin',
|
||||
]
|
||||
|
||||
|
||||
@@ -31,14 +31,14 @@ class SystemUserSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
|
||||
fields_mini = ['id', 'name', 'username']
|
||||
fields_write_only = ['password', 'public_key', 'private_key']
|
||||
fields_small = fields_mini + fields_write_only + [
|
||||
'type', 'type_display', 'protocol', 'login_mode', 'login_mode_display',
|
||||
'priority', 'sudo', 'shell', 'sftp_root', 'token', 'ssh_key_fingerprint',
|
||||
'home', 'system_groups', 'ad_domain',
|
||||
'token', 'ssh_key_fingerprint',
|
||||
'type', 'type_display', 'protocol', 'is_asset_protocol',
|
||||
'login_mode', 'login_mode_display', 'priority',
|
||||
'sudo', 'shell', 'sftp_root', 'home', 'system_groups', 'ad_domain',
|
||||
'username_same_with_user', 'auto_push', 'auto_generate_key',
|
||||
'date_created', 'date_updated',
|
||||
'comment', 'created_by',
|
||||
'date_created', 'date_updated', 'comment', 'created_by',
|
||||
]
|
||||
fields_m2m = ['cmd_filters', 'assets_amount']
|
||||
fields_m2m = ['cmd_filters', 'assets_amount', 'nodes']
|
||||
fields = fields_small + fields_m2m
|
||||
extra_kwargs = {
|
||||
'password': {
|
||||
@@ -53,6 +53,7 @@ class SystemUserSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
|
||||
'login_mode_display': {'label': _('Login mode display')},
|
||||
'created_by': {'read_only': True},
|
||||
'ad_domain': {'required': False, 'allow_blank': True, 'label': _('Ad domain')},
|
||||
'is_asset_protocol': {'label': _('Is asset protocol')}
|
||||
}
|
||||
|
||||
def validate_auto_push(self, value):
|
||||
@@ -184,6 +185,12 @@ class SystemUserSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
|
||||
return queryset
|
||||
|
||||
|
||||
class MiniSystemUserSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = SystemUser
|
||||
fields = SystemUserSerializer.Meta.fields_mini
|
||||
|
||||
|
||||
class SystemUserWithAuthInfoSerializer(SystemUserSerializer):
|
||||
class Meta(SystemUserSerializer.Meta):
|
||||
fields_mini = ['id', 'name', 'username']
|
||||
@@ -207,6 +214,7 @@ class SystemUserSimpleSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
系统用户最基本信息的数据结构
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
model = SystemUser
|
||||
fields = ('id', 'name', 'username')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from django.dispatch import receiver
|
||||
from django.apps import apps
|
||||
from simple_history.signals import pre_create_historical_record
|
||||
from django.db.models.signals import post_save, pre_save
|
||||
from django.db.models.signals import post_save, pre_save, pre_delete
|
||||
|
||||
from common.utils import get_logger
|
||||
from ..models import AuthBook, SystemUser
|
||||
@@ -28,10 +28,15 @@ def pre_create_historical_record_callback(sender, history_instance=None, **kwarg
|
||||
setattr(history_instance, attr, system_user_attr_value)
|
||||
|
||||
|
||||
@receiver(pre_delete, sender=AuthBook)
|
||||
def on_authbook_post_delete(sender, instance, **kwargs):
|
||||
instance.remove_asset_admin_user_if_need()
|
||||
|
||||
|
||||
@receiver(post_save, sender=AuthBook)
|
||||
def on_authbook_post_create(sender, instance, **kwargs):
|
||||
if not instance.systemuser:
|
||||
instance.sync_to_system_user_account()
|
||||
instance.sync_to_system_user_account()
|
||||
instance.update_asset_admin_user_if_need()
|
||||
|
||||
|
||||
@receiver(pre_save, sender=AuthBook)
|
||||
|
||||
@@ -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下的资产
|
||||
关联到上面
|
||||
"""
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
from django.db.models.signals import post_save, post_delete, m2m_changed
|
||||
from django.db.models.signals import (
|
||||
post_save, post_delete, m2m_changed, pre_delete
|
||||
)
|
||||
from django.dispatch import receiver
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
@@ -35,7 +37,7 @@ MODELS_NEED_RECORD = (
|
||||
# users
|
||||
'User', 'UserGroup',
|
||||
# acls
|
||||
'LoginACL', 'LoginAssetACL',
|
||||
'LoginACL', 'LoginAssetACL', 'LoginConfirmSetting',
|
||||
# assets
|
||||
'Asset', 'Node', 'AdminUser', 'SystemUser', 'Domain', 'Gateway', 'CommandFilterRule',
|
||||
'CommandFilter', 'Platform', 'AuthBook',
|
||||
@@ -98,68 +100,68 @@ def create_operate_log(action, sender, resource):
|
||||
M2M_NEED_RECORD = {
|
||||
'OrganizationMember': (
|
||||
_('User and Organization'),
|
||||
_('{User} *JOINED* {Organization}'),
|
||||
_('{User} *LEFT* {Organization}')
|
||||
_('{User} JOINED {Organization}'),
|
||||
_('{User} LEFT {Organization}')
|
||||
),
|
||||
User.groups.through._meta.object_name: (
|
||||
_('User and Group'),
|
||||
_('{User} *JOINED* {UserGroup}'),
|
||||
_('{User} *LEFT* {UserGroup}')
|
||||
_('{User} JOINED {UserGroup}'),
|
||||
_('{User} LEFT {UserGroup}')
|
||||
),
|
||||
SystemUser.assets.through._meta.object_name: (
|
||||
_('Asset and SystemUser'),
|
||||
_('{Asset} *ADD* {SystemUser}'),
|
||||
_('{Asset} *REMOVE* {SystemUser}')
|
||||
_('{Asset} ADD {SystemUser}'),
|
||||
_('{Asset} REMOVE {SystemUser}')
|
||||
),
|
||||
Asset.nodes.through._meta.object_name: (
|
||||
_('Node and Asset'),
|
||||
_('{Node} *ADD* {Asset}'),
|
||||
_('{Node} *REMOVE* {Asset}')
|
||||
_('{Node} ADD {Asset}'),
|
||||
_('{Node} REMOVE {Asset}')
|
||||
),
|
||||
AssetPermission.users.through._meta.object_name: (
|
||||
_('User asset permissions'),
|
||||
_('{AssetPermission} *ADD* {User}'),
|
||||
_('{AssetPermission} *REMOVE* {User}'),
|
||||
_('{AssetPermission} ADD {User}'),
|
||||
_('{AssetPermission} REMOVE {User}'),
|
||||
),
|
||||
AssetPermission.user_groups.through._meta.object_name: (
|
||||
_('User group asset permissions'),
|
||||
_('{AssetPermission} *ADD* {UserGroup}'),
|
||||
_('{AssetPermission} *REMOVE* {UserGroup}'),
|
||||
_('{AssetPermission} ADD {UserGroup}'),
|
||||
_('{AssetPermission} REMOVE {UserGroup}'),
|
||||
),
|
||||
AssetPermission.assets.through._meta.object_name: (
|
||||
_('Asset permission'),
|
||||
_('{AssetPermission} *ADD* {Asset}'),
|
||||
_('{AssetPermission} *REMOVE* {Asset}'),
|
||||
_('{AssetPermission} ADD {Asset}'),
|
||||
_('{AssetPermission} REMOVE {Asset}'),
|
||||
),
|
||||
AssetPermission.nodes.through._meta.object_name: (
|
||||
_('Node permission'),
|
||||
_('{AssetPermission} *ADD* {Node}'),
|
||||
_('{AssetPermission} *REMOVE* {Node}'),
|
||||
_('{AssetPermission} ADD {Node}'),
|
||||
_('{AssetPermission} REMOVE {Node}'),
|
||||
),
|
||||
AssetPermission.system_users.through._meta.object_name: (
|
||||
_('Asset permission and SystemUser'),
|
||||
_('{AssetPermission} *ADD* {SystemUser}'),
|
||||
_('{AssetPermission} *REMOVE* {SystemUser}'),
|
||||
_('{AssetPermission} ADD {SystemUser}'),
|
||||
_('{AssetPermission} REMOVE {SystemUser}'),
|
||||
),
|
||||
ApplicationPermission.users.through._meta.object_name: (
|
||||
_('User application permissions'),
|
||||
_('{ApplicationPermission} *ADD* {User}'),
|
||||
_('{ApplicationPermission} *REMOVE* {User}'),
|
||||
_('{ApplicationPermission} ADD {User}'),
|
||||
_('{ApplicationPermission} REMOVE {User}'),
|
||||
),
|
||||
ApplicationPermission.user_groups.through._meta.object_name: (
|
||||
_('User group application permissions'),
|
||||
_('{ApplicationPermission} *ADD* {UserGroup}'),
|
||||
_('{ApplicationPermission} *REMOVE* {UserGroup}'),
|
||||
_('{ApplicationPermission} ADD {UserGroup}'),
|
||||
_('{ApplicationPermission} REMOVE {UserGroup}'),
|
||||
),
|
||||
ApplicationPermission.applications.through._meta.object_name: (
|
||||
_('Application permission'),
|
||||
_('{ApplicationPermission} *ADD* {Application}'),
|
||||
_('{ApplicationPermission} *REMOVE* {Application}'),
|
||||
_('{ApplicationPermission} ADD {Application}'),
|
||||
_('{ApplicationPermission} REMOVE {Application}'),
|
||||
),
|
||||
ApplicationPermission.system_users.through._meta.object_name: (
|
||||
_('Application permission and SystemUser'),
|
||||
_('{ApplicationPermission} *ADD* {SystemUser}'),
|
||||
_('{ApplicationPermission} *REMOVE* {SystemUser}'),
|
||||
_('{ApplicationPermission} ADD {SystemUser}'),
|
||||
_('{ApplicationPermission} REMOVE {SystemUser}'),
|
||||
),
|
||||
}
|
||||
|
||||
@@ -226,7 +228,7 @@ def on_object_created_or_update(sender, instance=None, created=False, update_fie
|
||||
create_operate_log(action, sender, instance)
|
||||
|
||||
|
||||
@receiver(post_delete)
|
||||
@receiver(pre_delete)
|
||||
def on_object_delete(sender, instance=None, **kwargs):
|
||||
create_operate_log(models.OperateLog.ACTION_DELETE, sender, instance)
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -23,6 +23,7 @@ from common.drf.api import SerializerMixin
|
||||
from common.permissions import IsSuperUserOrAppUser, IsValidUser, IsSuperUser
|
||||
from orgs.mixins.api import RootOrgViewMixin
|
||||
from common.http import is_true
|
||||
from assets.models import SystemUser
|
||||
|
||||
from ..serializers import (
|
||||
ConnectionTokenSerializer, ConnectionTokenSecretSerializer,
|
||||
@@ -74,6 +75,7 @@ class ClientProtocolMixin:
|
||||
'bookmarktype:i': '3',
|
||||
'use redirection server name:i': '0',
|
||||
'smart sizing:i': '0',
|
||||
#'drivestoredirect:s': '*',
|
||||
# 'domain:s': ''
|
||||
# 'alternate shell:s:': '||MySQLWorkbench',
|
||||
# 'remoteapplicationname:s': 'Firefox',
|
||||
@@ -84,8 +86,11 @@ class ClientProtocolMixin:
|
||||
height = self.request.query_params.get('height')
|
||||
width = self.request.query_params.get('width')
|
||||
full_screen = is_true(self.request.query_params.get('full_screen'))
|
||||
drives_redirect = is_true(self.request.query_params.get('drives_redirect'))
|
||||
token = self.create_token(user, asset, application, system_user)
|
||||
|
||||
if drives_redirect:
|
||||
options['drivestoredirect:s'] = '*'
|
||||
options['screen mode id:i'] = '2' if full_screen else '1'
|
||||
address = settings.TERMINAL_RDP_ADDR
|
||||
if not address or address == 'localhost:3389':
|
||||
|
||||
@@ -45,5 +45,5 @@ class TicketStatusApi(mixins.AuthMixin, APIView):
|
||||
ticket = self.get_ticket()
|
||||
if ticket:
|
||||
request.session.pop('auth_ticket_id', '')
|
||||
ticket.close(processor=request.user)
|
||||
ticket.close(processor=self.get_user_from_session())
|
||||
return Response('', status=200)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
import builtins
|
||||
import time
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.conf import settings
|
||||
@@ -8,14 +9,28 @@ from rest_framework.generics import CreateAPIView
|
||||
from rest_framework.serializers import ValidationError
|
||||
from rest_framework.response import Response
|
||||
|
||||
from common.permissions import IsValidUser, NeedMFAVerify
|
||||
from authentication.sms_verify_code import VerifyCodeUtil
|
||||
from common.exceptions import JMSException
|
||||
from common.permissions import IsValidUser, NeedMFAVerify, IsAppUser
|
||||
from users.models.user import MFAType
|
||||
from ..serializers import OtpVerifySerializer
|
||||
from .. import serializers
|
||||
from .. import errors
|
||||
from ..mixins import AuthMixin
|
||||
|
||||
|
||||
__all__ = ['MFAChallengeApi', 'UserOtpVerifyApi']
|
||||
__all__ = ['MFAChallengeApi', 'UserOtpVerifyApi', 'SendSMSVerifyCodeApi', 'MFASelectTypeApi']
|
||||
|
||||
|
||||
class MFASelectTypeApi(AuthMixin, CreateAPIView):
|
||||
permission_classes = (AllowAny,)
|
||||
serializer_class = serializers.MFASelectTypeSerializer
|
||||
|
||||
def perform_create(self, serializer):
|
||||
mfa_type = serializer.validated_data['type']
|
||||
if mfa_type == MFAType.SMS_CODE:
|
||||
user = self.get_user_from_session()
|
||||
user.send_sms_code()
|
||||
|
||||
|
||||
class MFAChallengeApi(AuthMixin, CreateAPIView):
|
||||
@@ -26,7 +41,9 @@ class MFAChallengeApi(AuthMixin, CreateAPIView):
|
||||
try:
|
||||
user = self.get_user_from_session()
|
||||
code = serializer.validated_data.get('code')
|
||||
valid = user.check_mfa(code)
|
||||
mfa_type = serializer.validated_data.get('type', MFAType.OTP)
|
||||
|
||||
valid = user.check_mfa(code, mfa_type=mfa_type)
|
||||
if not valid:
|
||||
self.request.session['auth_mfa'] = ''
|
||||
raise errors.MFAFailedError(
|
||||
@@ -67,3 +84,12 @@ class UserOtpVerifyApi(CreateAPIView):
|
||||
if self.request.method.lower() == 'get' and settings.SECURITY_VIEW_AUTH_NEED_MFA:
|
||||
self.permission_classes = [NeedMFAVerify]
|
||||
return super().get_permissions()
|
||||
|
||||
|
||||
class SendSMSVerifyCodeApi(AuthMixin, CreateAPIView):
|
||||
permission_classes = (AllowAny,)
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
user = self.get_user_from_session()
|
||||
timeout = user.send_sms_code()
|
||||
return Response({'code': 'ok','timeout': timeout})
|
||||
|
||||
@@ -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()
|
||||
@@ -4,9 +4,11 @@ from django.utils.translation import ugettext_lazy as _
|
||||
from django.urls import reverse
|
||||
from django.conf import settings
|
||||
|
||||
from authentication import sms_verify_code
|
||||
from common.exceptions import JMSException
|
||||
from .signals import post_auth_failed
|
||||
from users.utils import LoginBlockUtil, MFABlockUtils
|
||||
from users.models import MFAType
|
||||
|
||||
reason_password_failed = 'password_failed'
|
||||
reason_password_decrypt_failed = 'password_decrypt_failed'
|
||||
@@ -58,8 +60,18 @@ block_mfa_msg = _(
|
||||
"The account has been locked "
|
||||
"(please contact admin to unlock it or try again after {} minutes)"
|
||||
)
|
||||
mfa_failed_msg = _(
|
||||
"MFA code invalid, or ntp sync server time, "
|
||||
otp_failed_msg = _(
|
||||
"One-time password invalid, or ntp sync server time, "
|
||||
"You can also try {times_try} times "
|
||||
"(The account will be temporarily locked for {block_time} minutes)"
|
||||
)
|
||||
sms_failed_msg = _(
|
||||
"SMS verify code invalid,"
|
||||
"You can also try {times_try} times "
|
||||
"(The account will be temporarily locked for {block_time} minutes)"
|
||||
)
|
||||
mfa_type_failed_msg = _(
|
||||
"The MFA type({mfa_type}) is not supported, "
|
||||
"You can also try {times_try} times "
|
||||
"(The account will be temporarily locked for {block_time} minutes)"
|
||||
)
|
||||
@@ -134,7 +146,7 @@ class MFAFailedError(AuthFailedNeedLogMixin, AuthFailedError):
|
||||
error = reason_mfa_failed
|
||||
msg: str
|
||||
|
||||
def __init__(self, username, request, ip):
|
||||
def __init__(self, username, request, ip, mfa_type=MFAType.OTP):
|
||||
util = MFABlockUtils(username, ip)
|
||||
util.incr_failed_count()
|
||||
|
||||
@@ -142,9 +154,18 @@ class MFAFailedError(AuthFailedNeedLogMixin, AuthFailedError):
|
||||
block_time = settings.SECURITY_LOGIN_LIMIT_TIME
|
||||
|
||||
if times_remainder:
|
||||
self.msg = mfa_failed_msg.format(
|
||||
times_try=times_remainder, block_time=block_time
|
||||
)
|
||||
if mfa_type == MFAType.OTP:
|
||||
self.msg = otp_failed_msg.format(
|
||||
times_try=times_remainder, block_time=block_time
|
||||
)
|
||||
elif mfa_type == MFAType.SMS_CODE:
|
||||
self.msg = sms_failed_msg.format(
|
||||
times_try=times_remainder, block_time=block_time
|
||||
)
|
||||
else:
|
||||
self.msg = mfa_type_failed_msg.format(
|
||||
mfa_type=mfa_type, times_try=times_remainder, block_time=block_time
|
||||
)
|
||||
else:
|
||||
self.msg = block_mfa_msg.format(settings.SECURITY_LOGIN_LIMIT_TIME)
|
||||
super().__init__(username=username, request=request)
|
||||
@@ -202,12 +223,16 @@ class MFARequiredError(NeedMoreInfoError):
|
||||
msg = mfa_required_msg
|
||||
error = 'mfa_required'
|
||||
|
||||
def __init__(self, error='', msg='', mfa_types=tuple(MFAType)):
|
||||
super().__init__(error=error, msg=msg)
|
||||
self.choices = mfa_types
|
||||
|
||||
def as_data(self):
|
||||
return {
|
||||
'error': self.error,
|
||||
'msg': self.msg,
|
||||
'data': {
|
||||
'choices': ['code'],
|
||||
'choices': self.choices,
|
||||
'url': reverse('api-auth:mfa-challenge')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,8 @@ class UserLoginForm(forms.Form):
|
||||
|
||||
|
||||
class UserCheckOtpCodeForm(forms.Form):
|
||||
otp_code = forms.CharField(label=_('MFA code'), max_length=6)
|
||||
code = forms.CharField(label=_('MFA Code'), max_length=6)
|
||||
mfa_type = forms.CharField(label=_('MFA type'), max_length=6)
|
||||
|
||||
|
||||
class CustomCaptchaTextInput(CaptchaTextInput):
|
||||
@@ -66,9 +67,9 @@ class ChallengeMixin(forms.Form):
|
||||
|
||||
def get_user_login_form_cls(*, captcha=False):
|
||||
bases = []
|
||||
if settings.SECURITY_LOGIN_CAPTCHA_ENABLED and captcha:
|
||||
bases.append(CaptchaMixin)
|
||||
if settings.SECURITY_LOGIN_CHALLENGE_ENABLED:
|
||||
bases.append(ChallengeMixin)
|
||||
elif settings.SECURITY_LOGIN_CAPTCHA_ENABLED and captcha:
|
||||
bases.append(CaptchaMixin)
|
||||
bases.append(UserLoginForm)
|
||||
return type('UserLoginForm', tuple(bases), {})
|
||||
|
||||
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
|
||||
@@ -14,14 +14,15 @@ from django.contrib.auth import (
|
||||
PermissionDenied, user_login_failed, _clean_credentials
|
||||
)
|
||||
from django.shortcuts import reverse, redirect
|
||||
from django.views.generic.edit import FormView
|
||||
|
||||
from common.utils import get_object_or_none, get_request_ip, get_logger, bulk_get, FlashMessageUtil
|
||||
from users.models import User
|
||||
from users.models import User, MFAType
|
||||
from users.utils import LoginBlockUtil, MFABlockUtils
|
||||
from . import errors
|
||||
from .utils import rsa_decrypt
|
||||
from .utils import rsa_decrypt, gen_key_pair
|
||||
from .signals import post_auth_success, post_auth_failed
|
||||
from .const import RSA_PRIVATE_KEY
|
||||
from .const import RSA_PRIVATE_KEY, RSA_PUBLIC_KEY
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -29,8 +30,8 @@ logger = get_logger(__name__)
|
||||
def check_backend_can_auth(username, backend_path, allowed_auth_backends):
|
||||
if allowed_auth_backends is not None and backend_path not in allowed_auth_backends:
|
||||
logger.debug('Skip user auth backend: {}, {} not in'.format(
|
||||
username, backend_path, ','.join(allowed_auth_backends)
|
||||
)
|
||||
username, backend_path, ','.join(allowed_auth_backends)
|
||||
)
|
||||
)
|
||||
return False
|
||||
return True
|
||||
@@ -79,7 +80,70 @@ def authenticate(request=None, **credentials):
|
||||
auth.authenticate = authenticate
|
||||
|
||||
|
||||
class AuthMixin:
|
||||
class PasswordEncryptionViewMixin:
|
||||
request = None
|
||||
|
||||
def get_decrypted_password(self, password=None, username=None):
|
||||
request = self.request
|
||||
if hasattr(request, 'data'):
|
||||
data = request.data
|
||||
else:
|
||||
data = request.POST
|
||||
|
||||
username = username or data.get('username')
|
||||
password = password or data.get('password')
|
||||
|
||||
password = self.decrypt_passwd(password)
|
||||
if not password:
|
||||
self.raise_password_decrypt_failed(username=username)
|
||||
return password
|
||||
|
||||
def raise_password_decrypt_failed(self, username):
|
||||
ip = self.get_request_ip()
|
||||
raise errors.CredentialError(
|
||||
error=errors.reason_password_decrypt_failed,
|
||||
username=username, ip=ip, request=self.request
|
||||
)
|
||||
|
||||
def decrypt_passwd(self, raw_passwd):
|
||||
# 获取解密密钥,对密码进行解密
|
||||
rsa_private_key = self.request.session.get(RSA_PRIVATE_KEY)
|
||||
if rsa_private_key is not None:
|
||||
try:
|
||||
return rsa_decrypt(raw_passwd, rsa_private_key)
|
||||
except Exception as e:
|
||||
logger.error(e, exc_info=True)
|
||||
logger.error(
|
||||
f'Decrypt password failed: password[{raw_passwd}] '
|
||||
f'rsa_private_key[{rsa_private_key}]'
|
||||
)
|
||||
return None
|
||||
return raw_passwd
|
||||
|
||||
def get_request_ip(self):
|
||||
ip = ''
|
||||
if hasattr(self.request, 'data'):
|
||||
ip = self.request.data.get('remote_addr', '')
|
||||
ip = ip or get_request_ip(self.request)
|
||||
return ip
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
# 生成加解密密钥对,public_key传递给前端,private_key存入session中供解密使用
|
||||
rsa_public_key = self.request.session.get(RSA_PUBLIC_KEY)
|
||||
rsa_private_key = self.request.session.get(RSA_PRIVATE_KEY)
|
||||
if not all((rsa_private_key, rsa_public_key)):
|
||||
rsa_private_key, rsa_public_key = gen_key_pair()
|
||||
rsa_public_key = rsa_public_key.replace('\n', '\\n')
|
||||
self.request.session[RSA_PRIVATE_KEY] = rsa_private_key
|
||||
self.request.session[RSA_PUBLIC_KEY] = rsa_public_key
|
||||
|
||||
kwargs.update({
|
||||
'rsa_public_key': rsa_public_key,
|
||||
})
|
||||
return super().get_context_data(**kwargs)
|
||||
|
||||
|
||||
class AuthMixin(PasswordEncryptionViewMixin):
|
||||
request = None
|
||||
partial_credential_error = None
|
||||
|
||||
@@ -106,13 +170,6 @@ class AuthMixin:
|
||||
user.backend = self.request.session.get("auth_backend")
|
||||
return user
|
||||
|
||||
def get_request_ip(self):
|
||||
ip = ''
|
||||
if hasattr(self.request, 'data'):
|
||||
ip = self.request.data.get('remote_addr', '')
|
||||
ip = ip or get_request_ip(self.request)
|
||||
return ip
|
||||
|
||||
def _check_is_block(self, username, raise_exception=True):
|
||||
ip = self.get_request_ip()
|
||||
if LoginBlockUtil(username, ip).is_block():
|
||||
@@ -130,24 +187,14 @@ class AuthMixin:
|
||||
username = self.request.POST.get("username")
|
||||
self._check_is_block(username, raise_exception)
|
||||
|
||||
def decrypt_passwd(self, raw_passwd):
|
||||
# 获取解密密钥,对密码进行解密
|
||||
rsa_private_key = self.request.session.get(RSA_PRIVATE_KEY)
|
||||
if rsa_private_key is not None:
|
||||
try:
|
||||
return rsa_decrypt(raw_passwd, rsa_private_key)
|
||||
except Exception as e:
|
||||
logger.error(e, exc_info=True)
|
||||
logger.error(f'Decrypt password failed: password[{raw_passwd}] '
|
||||
f'rsa_private_key[{rsa_private_key}]')
|
||||
return None
|
||||
return raw_passwd
|
||||
|
||||
def raise_credential_error(self, error):
|
||||
raise self.partial_credential_error(error=error)
|
||||
|
||||
def _set_partial_credential_error(self, username, ip, request):
|
||||
self.partial_credential_error = partial(errors.CredentialError, username=username, ip=ip, request=request)
|
||||
self.partial_credential_error = partial(
|
||||
errors.CredentialError, username=username,
|
||||
ip=ip, request=request
|
||||
)
|
||||
|
||||
def get_auth_data(self, decrypt_passwd=False):
|
||||
request = self.request
|
||||
@@ -157,15 +204,13 @@ class AuthMixin:
|
||||
data = request.POST
|
||||
|
||||
items = ['username', 'password', 'challenge', 'public_key', 'auto_login']
|
||||
username, password, challenge, public_key, auto_login = bulk_get(data, *items, default='')
|
||||
password = password + challenge.strip()
|
||||
username, password, challenge, public_key, auto_login = bulk_get(data, *items, default='')
|
||||
ip = self.get_request_ip()
|
||||
self._set_partial_credential_error(username=username, ip=ip, request=request)
|
||||
|
||||
password = password + challenge.strip()
|
||||
if decrypt_passwd:
|
||||
password = self.decrypt_passwd(password)
|
||||
if not password:
|
||||
self.raise_credential_error(errors.reason_password_decrypt_failed)
|
||||
password = self.get_decrypted_password()
|
||||
return username, password, public_key, ip, auto_login
|
||||
|
||||
def _check_only_allow_exists_user_auth(self, username):
|
||||
@@ -243,7 +288,6 @@ class AuthMixin:
|
||||
elif not user.is_active:
|
||||
self.raise_credential_error(errors.reason_user_inactive)
|
||||
|
||||
self._check_is_local_user(user)
|
||||
self._check_is_block(user.username)
|
||||
self._check_login_acl(user, ip)
|
||||
|
||||
@@ -304,17 +348,28 @@ class AuthMixin:
|
||||
def check_user_mfa_if_need(self, user):
|
||||
if self.request.session.get('auth_mfa'):
|
||||
return
|
||||
|
||||
if settings.OTP_IN_RADIUS:
|
||||
return
|
||||
|
||||
if not user.mfa_enabled:
|
||||
return
|
||||
unset, url = user.mfa_enabled_but_not_set()
|
||||
if unset:
|
||||
raise errors.MFAUnsetError(user, self.request, url)
|
||||
raise errors.MFARequiredError()
|
||||
raise errors.MFARequiredError(mfa_types=user.get_supported_mfa_types())
|
||||
|
||||
def mark_mfa_ok(self):
|
||||
def mark_mfa_ok(self, mfa_type=MFAType.OTP):
|
||||
self.request.session['auth_mfa'] = 1
|
||||
self.request.session['auth_mfa_time'] = time.time()
|
||||
self.request.session['auth_mfa_type'] = 'otp'
|
||||
self.request.session['auth_mfa_required'] = ''
|
||||
self.request.session['auth_mfa_type'] = mfa_type
|
||||
|
||||
def clean_mfa_mark(self):
|
||||
self.request.session['auth_mfa'] = ''
|
||||
self.request.session['auth_mfa_time'] = ''
|
||||
self.request.session['auth_mfa_required'] = ''
|
||||
self.request.session['auth_mfa_type'] = ''
|
||||
|
||||
def check_mfa_is_block(self, username, ip, raise_exception=True):
|
||||
if MFABlockUtils(username, ip).is_block():
|
||||
@@ -325,11 +380,11 @@ class AuthMixin:
|
||||
else:
|
||||
return exception
|
||||
|
||||
def check_user_mfa(self, code):
|
||||
def check_user_mfa(self, code, mfa_type=MFAType.OTP):
|
||||
user = self.get_user_from_session()
|
||||
ip = self.get_request_ip()
|
||||
self.check_mfa_is_block(user.username, ip)
|
||||
ok = user.check_mfa(code)
|
||||
ok = user.check_mfa(code, mfa_type=mfa_type)
|
||||
if ok:
|
||||
self.mark_mfa_ok()
|
||||
return
|
||||
@@ -337,7 +392,7 @@ class AuthMixin:
|
||||
raise errors.MFAFailedError(
|
||||
username=user.username,
|
||||
request=self.request,
|
||||
ip=ip
|
||||
ip=ip, mfa_type=mfa_type,
|
||||
)
|
||||
|
||||
def get_ticket(self):
|
||||
@@ -363,16 +418,18 @@ class AuthMixin:
|
||||
raise errors.LoginConfirmOtherError('', "Not found")
|
||||
if ticket.status_open:
|
||||
raise errors.LoginConfirmWaitError(ticket.id)
|
||||
elif ticket.action_approve:
|
||||
elif ticket.state_approve:
|
||||
self.request.session["auth_confirm"] = "1"
|
||||
return
|
||||
elif ticket.action_reject:
|
||||
elif ticket.state_reject:
|
||||
self.clean_mfa_mark()
|
||||
raise errors.LoginConfirmOtherError(
|
||||
ticket.id, ticket.get_action_display()
|
||||
ticket.id, ticket.get_state_display()
|
||||
)
|
||||
elif ticket.action_close:
|
||||
elif ticket.state_close:
|
||||
self.clean_mfa_mark()
|
||||
raise errors.LoginConfirmOtherError(
|
||||
ticket.id, ticket.get_action_display()
|
||||
ticket.id, ticket.get_state_display()
|
||||
)
|
||||
else:
|
||||
raise errors.LoginConfirmOtherError(
|
||||
@@ -391,7 +448,6 @@ class AuthMixin:
|
||||
def clear_auth_mark(self):
|
||||
self.request.session['auth_password'] = ''
|
||||
self.request.session['auth_user_id'] = ''
|
||||
self.request.session['auth_mfa'] = ''
|
||||
self.request.session['auth_confirm'] = ''
|
||||
self.request.session['auth_ticket_id'] = ''
|
||||
|
||||
|
||||
@@ -45,6 +45,9 @@ class LoginConfirmSetting(CommonModelMixin):
|
||||
reviewers = models.ManyToManyField('users.User', verbose_name=_("Reviewers"), related_name="review_login_confirm_settings", blank=True)
|
||||
is_active = models.BooleanField(default=True, verbose_name=_("Is active"))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('Login Confirm')
|
||||
|
||||
@classmethod
|
||||
def get_user_confirm_setting(cls, user):
|
||||
return get_object_or_none(cls, user=user)
|
||||
@@ -71,20 +74,20 @@ class LoginConfirmSetting(CommonModelMixin):
|
||||
from orgs.models import Organization
|
||||
ticket_title = _('Login confirm') + ' {}'.format(self.user)
|
||||
ticket_meta = self.construct_confirm_ticket_meta(request)
|
||||
ticket_assignees = self.reviewers.all()
|
||||
data = {
|
||||
'title': ticket_title,
|
||||
'type': const.TicketTypeChoices.login_confirm.value,
|
||||
'type': const.TicketType.login_confirm.value,
|
||||
'meta': ticket_meta,
|
||||
'org_id': Organization.ROOT_ID,
|
||||
}
|
||||
ticket = Ticket.objects.create(**data)
|
||||
ticket.assignees.set(ticket_assignees)
|
||||
ticket.create_process_map_and_node(self.reviewers.all())
|
||||
ticket.open(self.user)
|
||||
return ticket
|
||||
|
||||
def __str__(self):
|
||||
return '{} confirm'.format(self.user.username)
|
||||
reviewers = [u.username for u in self.reviewers.all()]
|
||||
return _('{} need confirm by {}').format(self.user.username, reviewers)
|
||||
|
||||
|
||||
class SSOToken(models.JMSBaseModel):
|
||||
|
||||
@@ -16,8 +16,8 @@ from .models import AccessKey, LoginConfirmSetting
|
||||
__all__ = [
|
||||
'AccessKeySerializer', 'OtpVerifySerializer', 'BearerTokenSerializer',
|
||||
'MFAChallengeSerializer', 'LoginConfirmSettingSerializer', 'SSOTokenSerializer',
|
||||
'ConnectionTokenSerializer', 'ConnectionTokenSecretSerializer', 'RDPFileSerializer',
|
||||
'PasswordVerifySerializer',
|
||||
'ConnectionTokenSerializer', 'ConnectionTokenSecretSerializer',
|
||||
'PasswordVerifySerializer', 'MFASelectTypeSerializer',
|
||||
]
|
||||
|
||||
|
||||
@@ -77,6 +77,10 @@ class BearerTokenSerializer(serializers.Serializer):
|
||||
return instance
|
||||
|
||||
|
||||
class MFASelectTypeSerializer(serializers.Serializer):
|
||||
type = serializers.CharField()
|
||||
|
||||
|
||||
class MFAChallengeSerializer(serializers.Serializer):
|
||||
type = serializers.CharField(write_only=True, required=False, allow_blank=True)
|
||||
code = serializers.CharField(write_only=True)
|
||||
@@ -166,7 +170,7 @@ class ConnectionTokenAssetSerializer(serializers.ModelSerializer):
|
||||
class ConnectionTokenSystemUserSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = SystemUser
|
||||
fields = ['id', 'name', 'username', 'password', 'private_key']
|
||||
fields = ['id', 'name', 'username', 'password', 'private_key', 'ad_domain']
|
||||
|
||||
|
||||
class ConnectionTokenGatewaySerializer(serializers.ModelSerializer):
|
||||
|
||||
@@ -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)
|
||||
|
||||
100
apps/authentication/sms_verify_code.py
Normal file
100
apps/authentication/sms_verify_code.py
Normal file
@@ -0,0 +1,100 @@
|
||||
import random
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from common.message.backends.sms.alibaba import AlibabaSMS
|
||||
from common.message.backends.sms import SMS
|
||||
from common.utils import get_logger
|
||||
from common.exceptions import JMSException
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
||||
|
||||
class CodeExpired(JMSException):
|
||||
default_code = 'verify_code_expired'
|
||||
default_detail = _('The verification code has expired. Please resend it')
|
||||
|
||||
|
||||
class CodeError(JMSException):
|
||||
default_code = 'verify_code_error'
|
||||
default_detail = _('The verification code is incorrect')
|
||||
|
||||
|
||||
class CodeSendTooFrequently(JMSException):
|
||||
default_code = 'code_send_too_frequently'
|
||||
default_detail = _('Please wait {} seconds before sending')
|
||||
|
||||
def __init__(self, ttl):
|
||||
super().__init__(detail=self.default_detail.format(ttl))
|
||||
|
||||
|
||||
class VerifyCodeUtil:
|
||||
KEY_TMPL = 'auth-verify_code-{}'
|
||||
TIMEOUT = 60
|
||||
|
||||
def __init__(self, account, key_suffix=None, timeout=None):
|
||||
self.account = account
|
||||
self.key_suffix = key_suffix
|
||||
self.code = ''
|
||||
|
||||
if key_suffix is not None:
|
||||
self.key = self.KEY_TMPL.format(key_suffix)
|
||||
else:
|
||||
self.key = self.KEY_TMPL.format(account)
|
||||
self.timeout = self.TIMEOUT if timeout is None else timeout
|
||||
|
||||
def touch(self):
|
||||
"""
|
||||
生成,保存,发送
|
||||
"""
|
||||
ttl = self.ttl()
|
||||
if ttl > 0:
|
||||
raise CodeSendTooFrequently(ttl)
|
||||
try:
|
||||
self.generate()
|
||||
self.save()
|
||||
self.send()
|
||||
except JMSException:
|
||||
self.clear()
|
||||
raise
|
||||
|
||||
def generate(self):
|
||||
code = ''.join(random.sample('0123456789', 4))
|
||||
self.code = code
|
||||
return code
|
||||
|
||||
def clear(self):
|
||||
cache.delete(self.key)
|
||||
|
||||
def save(self):
|
||||
cache.set(self.key, self.code, self.timeout)
|
||||
|
||||
def send(self):
|
||||
"""
|
||||
发送信息的方法,如果有错误直接抛出 api 异常
|
||||
"""
|
||||
account = self.account
|
||||
code = self.code
|
||||
|
||||
sms = SMS()
|
||||
sms.send_verify_code(account, code)
|
||||
logger.info(f'Send sms verify code: account={account} code={code}')
|
||||
|
||||
def verify(self, code):
|
||||
right = cache.get(self.key)
|
||||
if not right:
|
||||
raise CodeExpired
|
||||
|
||||
if right != code:
|
||||
raise CodeError
|
||||
|
||||
self.clear()
|
||||
return True
|
||||
|
||||
def ttl(self):
|
||||
return cache.ttl(self.key)
|
||||
|
||||
def get_code(self):
|
||||
return cache.get(self.key)
|
||||
@@ -147,7 +147,7 @@
|
||||
{% csrf_token %}
|
||||
<div style="line-height: 17px;margin-bottom: 20px;color: #999999;">
|
||||
{% if form.errors %}
|
||||
<p class="red-fonts" style="color: red">
|
||||
<p class="help-block red-fonts">
|
||||
{% if form.non_field_errors %}
|
||||
{{ form.non_field_errors.as_text }}
|
||||
{% endif %}
|
||||
@@ -160,9 +160,15 @@
|
||||
</div>
|
||||
|
||||
{% bootstrap_field form.username show_label=False %}
|
||||
<div class="form-group">
|
||||
<input type="password" class="form-control" id="password" placeholder="{% trans 'Password' %}" required="">
|
||||
|
||||
<div class="form-group {% if form.password.errors %} has-error {% endif %}">
|
||||
<input type="password" class="form-control" id="password" placeholder="{% trans 'Password' %}" required>
|
||||
<input id="password-hidden" type="text" style="display:none" name="{{ form.password.html_name }}">
|
||||
{% if form.password.errors %}
|
||||
<p class="help-block" style="text-align: left">
|
||||
{{ form.password.errors.as_text }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if form.challenge %}
|
||||
{% bootstrap_field form.challenge show_label=False %}
|
||||
@@ -220,7 +226,6 @@
|
||||
<i class="fa"><img src="{{ LOGIN_FEISHU_LOGO_URL }}" height="13" width="13"></i> {% trans 'FeiShu' %}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center" style="display: inline-block;">
|
||||
@@ -236,6 +241,9 @@
|
||||
<script type="text/javascript" src="/static/js/plugins/jsencrypt/jsencrypt.min.js"></script>
|
||||
<script>
|
||||
function encryptLoginPassword(password, rsaPublicKey) {
|
||||
if (!password) {
|
||||
return ''
|
||||
}
|
||||
var jsencrypt = new JSEncrypt(); //加密对象
|
||||
jsencrypt.setPublicKey(rsaPublicKey); // 设置密钥
|
||||
return jsencrypt.encrypt(password); //加密
|
||||
|
||||
@@ -9,24 +9,82 @@
|
||||
{% block content %}
|
||||
<form class="m-t" role="form" method="post" action="">
|
||||
{% csrf_token %}
|
||||
{% if 'otp_code' in form.errors %}
|
||||
<p class="red-fonts">{{ form.otp_code.errors.as_text }}</p>
|
||||
{% if 'code' in form.errors %}
|
||||
<p class="red-fonts">{{ form.code.errors.as_text }}</p>
|
||||
{% endif %}
|
||||
<div class="form-group">
|
||||
<select class="form-control">
|
||||
<option value="otp" selected>{% trans 'One-time password' %}</option>
|
||||
<select id="verify-method-select" name="mfa_type" class="form-control" onchange="select_change(this.value)">
|
||||
{% for method in methods %}
|
||||
<option value="{{ method.name }}" {% if method.selected %} selected {% endif %} {% if not method.enable %} disabled {% endif %}>{{ method.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="text" class="form-control" name="otp_code" placeholder="" required="" autofocus="autofocus">
|
||||
<span class="help-block">
|
||||
{% trans 'Open MFA Authenticator and enter the 6-bit dynamic code' %}
|
||||
</span>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary block full-width m-b">{% trans 'Next' %}</button>
|
||||
<div class="form-group" style="display: flex">
|
||||
|
||||
<input id="mfa-code" required type="text" class="form-control" name="code" placeholder="{% trans 'Please enter the verification code' %}" autofocus="autofocus">
|
||||
<button id='send-sms-verify-code' type="button" class="btn btn-info full-width m-b" onclick="sendSMSVerifyCode()" style="width: 150px!important;">{% trans 'Send verification code' %}</button>
|
||||
|
||||
</div>
|
||||
|
||||
<button id='submit_button' type="submit" class="btn btn-primary block full-width m-b">{% trans 'Next' %}</button>
|
||||
<div>
|
||||
<small>{% trans "Can't provide security? Please contact the administrator!" %}</small>
|
||||
</div>
|
||||
</form>
|
||||
<style type="text/css">
|
||||
.disabledBtn {
|
||||
background: #e6e4e4!important;
|
||||
border-color: #d8d5d5!important;
|
||||
color: #949191!important;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
|
||||
var methodSelect = document.getElementById('verify-method-select');
|
||||
if (methodSelect.value !== null) {
|
||||
select_change(methodSelect.value);
|
||||
}
|
||||
|
||||
function select_change(type){
|
||||
var currentBtn = document.getElementById('send-sms-verify-code');
|
||||
|
||||
if (type == "sms") {
|
||||
currentBtn.style.display = "block";
|
||||
currentBtn.disabled = false;
|
||||
}
|
||||
else {
|
||||
currentBtn.style.display = "none";
|
||||
currentBtn.disabled = true;
|
||||
}
|
||||
}
|
||||
function sendSMSVerifyCode(){
|
||||
var currentBtn = document.getElementById('send-sms-verify-code');
|
||||
var time = 60
|
||||
var url = "{% url 'api-auth:sms-verify-code-send' %}";
|
||||
requestApi({
|
||||
url: url,
|
||||
method: "POST",
|
||||
success: function (data) {
|
||||
currentBtn.innerHTML = `{% trans 'Wait: ' %} ${time}`;
|
||||
currentBtn.disabled = true
|
||||
currentBtn.classList.add("disabledBtn" )
|
||||
var TimeInterval = setInterval(()=>{
|
||||
--time
|
||||
currentBtn.innerHTML = `{% trans 'Wait: ' %} ${time}`;
|
||||
if(time === 0) {
|
||||
currentBtn.innerHTML = "{% trans 'Send verification code' %}"
|
||||
currentBtn.disabled = false
|
||||
currentBtn.classList.remove("disabledBtn")
|
||||
clearInterval(TimeInterval)
|
||||
}
|
||||
},1000)
|
||||
alert("{% trans 'The verification code has been sent' %}");
|
||||
},
|
||||
error: function (text, data) {
|
||||
alert(data.detail)
|
||||
},
|
||||
flash_message: false
|
||||
})
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -157,6 +157,7 @@ $(document).ready(function () {
|
||||
cancelCloseConfirm();
|
||||
window.location.reload();
|
||||
}).on('click', '.btn-return', function () {
|
||||
cancelTicket();
|
||||
cancelCloseConfirm();
|
||||
window.location = "{% url 'authentication:login' %}"
|
||||
})
|
||||
|
||||
@@ -27,7 +27,9 @@ urlpatterns = [
|
||||
path('auth/', api.TokenCreateApi.as_view(), name='user-auth'),
|
||||
path('tokens/', api.TokenCreateApi.as_view(), name='auth-token'),
|
||||
path('mfa/challenge/', api.MFAChallengeApi.as_view(), name='mfa-challenge'),
|
||||
path('mfa/select/', api.MFASelectTypeApi.as_view(), name='mfa-select'),
|
||||
path('otp/verify/', api.UserOtpVerifyApi.as_view(), name='user-otp-verify'),
|
||||
path('sms/verify-code/send/', api.SendSMSVerifyCodeApi.as_view(), name='sms-verify-code-send'),
|
||||
path('password/verify/', api.UserPasswordVerifyApi.as_view(), name='user-password-verify'),
|
||||
path('login-confirm-ticket/status/', api.TicketStatusApi.as_view(), name='login-confirm-ticket-status'),
|
||||
path('login-confirm-settings/<uuid:user_id>/', api.LoginConfirmSettingUpdateApi.as_view(), name='login-confirm-setting-update')
|
||||
|
||||
@@ -4,7 +4,6 @@ import base64
|
||||
from Cryptodome.PublicKey import RSA
|
||||
from Cryptodome.Cipher import PKCS1_v1_5
|
||||
from Cryptodome import Random
|
||||
|
||||
from common.utils import get_logger
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
||||
@@ -215,7 +215,7 @@ class DingTalkQRLoginCallbackView(AuthMixin, DingTalkQRMixin, View):
|
||||
user = get_object_or_none(User, dingtalk_id=userid)
|
||||
if user is None:
|
||||
title = _('DingTalk is not bound')
|
||||
msg = _('Please login with a password and then bind the WeCom')
|
||||
msg = _('Please login with a password and then bind the DingTalk')
|
||||
response = self.get_failed_reponse(login_url, title=title, msg=msg)
|
||||
return response
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from django.contrib.auth import login as auth_login, logout as auth_logout
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import reverse, redirect
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.db import transaction
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.views.decorators.cache import never_cache
|
||||
from django.views.decorators.csrf import csrf_protect
|
||||
@@ -18,14 +19,13 @@ from django.views.generic.edit import FormView
|
||||
from django.conf import settings
|
||||
from django.urls import reverse_lazy
|
||||
from django.contrib.auth import BACKEND_SESSION_KEY
|
||||
from django.db.transaction import atomic
|
||||
|
||||
from common.utils import get_request_ip, FlashMessageUtil
|
||||
from common.utils import FlashMessageUtil
|
||||
from users.utils import (
|
||||
redirect_user_first_login_or_index
|
||||
)
|
||||
from ..const import RSA_PRIVATE_KEY, RSA_PUBLIC_KEY
|
||||
from .. import mixins, errors, utils
|
||||
from .. import mixins, errors
|
||||
from ..forms import get_user_login_form_cls
|
||||
|
||||
|
||||
@@ -51,7 +51,8 @@ class UserLoginView(mixins.AuthMixin, FormView):
|
||||
|
||||
if settings.AUTH_OPENID:
|
||||
auth_type = 'OIDC'
|
||||
openid_auth_url = reverse(settings.AUTH_OPENID_AUTH_LOGIN_URL_NAME) + f'?next={next_url}'
|
||||
openid_auth_url = reverse(settings.AUTH_OPENID_AUTH_LOGIN_URL_NAME)
|
||||
openid_auth_url = openid_auth_url + f'?next={next_url}'
|
||||
else:
|
||||
openid_auth_url = None
|
||||
|
||||
@@ -64,16 +65,15 @@ class UserLoginView(mixins.AuthMixin, FormView):
|
||||
if not any([openid_auth_url, cas_auth_url]):
|
||||
return None
|
||||
|
||||
if settings.LOGIN_REDIRECT_TO_BACKEND == 'OPENID' and openid_auth_url:
|
||||
auth_url = openid_auth_url
|
||||
|
||||
elif settings.LOGIN_REDIRECT_TO_BACKEND == 'CAS' and cas_auth_url:
|
||||
login_redirect = settings.LOGIN_REDIRECT_TO_BACKEND.lower()
|
||||
if login_redirect in ['cas'] and cas_auth_url:
|
||||
auth_url = cas_auth_url
|
||||
|
||||
elif login_redirect in ['openid', 'oidc'] and openid_auth_url:
|
||||
auth_url = openid_auth_url
|
||||
else:
|
||||
auth_url = openid_auth_url or cas_auth_url
|
||||
|
||||
if settings.LOGIN_REDIRECT_TO_BACKEND:
|
||||
if settings.LOGIN_REDIRECT_TO_BACKEND or not settings.LOGIN_REDIRECT_MSG_ENABLED:
|
||||
redirect_url = auth_url
|
||||
else:
|
||||
message_data = {
|
||||
@@ -109,7 +109,7 @@ class UserLoginView(mixins.AuthMixin, FormView):
|
||||
self.request.session.delete_test_cookie()
|
||||
|
||||
try:
|
||||
with atomic():
|
||||
with transaction.atomic():
|
||||
self.check_user_auth(decrypt_passwd=True)
|
||||
except errors.AuthFailedError as e:
|
||||
form.add_error(None, e.msg)
|
||||
@@ -137,15 +137,6 @@ class UserLoginView(mixins.AuthMixin, FormView):
|
||||
self.request.session[RSA_PUBLIC_KEY] = None
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
# 生成加解密密钥对,public_key传递给前端,private_key存入session中供解密使用
|
||||
rsa_private_key = self.request.session.get(RSA_PRIVATE_KEY)
|
||||
rsa_public_key = self.request.session.get(RSA_PUBLIC_KEY)
|
||||
if not all((rsa_private_key, rsa_public_key)):
|
||||
rsa_private_key, rsa_public_key = utils.gen_key_pair()
|
||||
rsa_public_key = rsa_public_key.replace('\n', '\\n')
|
||||
self.request.session[RSA_PRIVATE_KEY] = rsa_private_key
|
||||
self.request.session[RSA_PUBLIC_KEY] = rsa_public_key
|
||||
|
||||
forgot_password_url = reverse('authentication:forgot-password')
|
||||
has_other_auth_backend = settings.AUTHENTICATION_BACKENDS[0] != settings.AUTH_BACKEND_MODEL
|
||||
if has_other_auth_backend and settings.FORGOT_PASSWORD_URL:
|
||||
@@ -158,7 +149,6 @@ class UserLoginView(mixins.AuthMixin, FormView):
|
||||
'AUTH_WECOM': settings.AUTH_WECOM,
|
||||
'AUTH_DINGTALK': settings.AUTH_DINGTALK,
|
||||
'AUTH_FEISHU': settings.AUTH_FEISHU,
|
||||
'rsa_public_key': rsa_public_key,
|
||||
'forgot_password_url': forgot_password_url
|
||||
}
|
||||
kwargs.update(context)
|
||||
@@ -224,8 +214,10 @@ class UserLoginWaitConfirmView(TemplateView):
|
||||
if ticket:
|
||||
timestamp_created = datetime.datetime.timestamp(ticket.date_created)
|
||||
ticket_detail_url = TICKET_DETAIL_URL.format(id=ticket_id)
|
||||
assignees = ticket.current_node.first().ticket_assignees.all()
|
||||
assignees_display = ', '.join([str(i.assignee) for i in assignees])
|
||||
msg = _("""Wait for <b>{}</b> confirm, You also can copy link to her/him <br/>
|
||||
Don't close this page""").format(ticket.assignees_display)
|
||||
Don't close this page""").format(assignees_display)
|
||||
else:
|
||||
timestamp_created = 0
|
||||
ticket_detail_url = ''
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
|
||||
from __future__ import unicode_literals
|
||||
from django.views.generic.edit import FormView
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.conf import settings
|
||||
from .. import forms, errors, mixins
|
||||
from .utils import redirect_to_guard_view
|
||||
|
||||
@@ -18,16 +20,43 @@ class UserLoginOtpView(mixins.AuthMixin, FormView):
|
||||
redirect_field_name = 'next'
|
||||
|
||||
def form_valid(self, form):
|
||||
otp_code = form.cleaned_data.get('otp_code')
|
||||
otp_code = form.cleaned_data.get('code')
|
||||
mfa_type = form.cleaned_data.get('mfa_type')
|
||||
|
||||
try:
|
||||
self.check_user_mfa(otp_code)
|
||||
self.check_user_mfa(otp_code, mfa_type)
|
||||
return redirect_to_guard_view()
|
||||
except (errors.MFAFailedError, errors.BlockMFAError) as e:
|
||||
form.add_error('otp_code', e.msg)
|
||||
form.add_error('code', e.msg)
|
||||
return super().form_invalid(form)
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
import traceback
|
||||
traceback.print_exception()
|
||||
traceback.print_exception(e)
|
||||
return redirect_to_guard_view()
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
user = self.get_user_from_session()
|
||||
context = {
|
||||
'methods': [
|
||||
{
|
||||
'name': 'otp',
|
||||
'label': _('One-time password'),
|
||||
'enable': bool(user.otp_secret_key),
|
||||
'selected': False,
|
||||
},
|
||||
{
|
||||
'name': 'sms',
|
||||
'label': _('SMS'),
|
||||
'enable': bool(user.phone) and settings.SMS_ENABLED and settings.XPACK_ENABLED,
|
||||
'selected': False,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
for item in context['methods']:
|
||||
if item['enable']:
|
||||
item['selected'] = True
|
||||
break
|
||||
context.update(kwargs)
|
||||
return context
|
||||
|
||||
20
apps/common/db/encoder.py
Normal file
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)
|
||||
@@ -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:
|
||||
|
||||
@@ -2,9 +2,12 @@ import time
|
||||
import hmac
|
||||
import base64
|
||||
|
||||
from common.utils import get_logger
|
||||
from common.message.backends.utils import digest, as_request
|
||||
from common.message.backends.mixin import BaseRequest
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
||||
|
||||
def sign(secret, data):
|
||||
|
||||
@@ -160,6 +163,7 @@ class DingTalk:
|
||||
}
|
||||
}
|
||||
}
|
||||
logger.info(f'Dingtalk send text: user_ids={user_ids} msg={msg}')
|
||||
data = self._request.post(URL.SEND_MESSAGE, json=body, with_token=True)
|
||||
return data
|
||||
|
||||
|
||||
@@ -106,6 +106,7 @@ class FeiShu(RequestMixin):
|
||||
body['receive_id'] = user_id
|
||||
|
||||
try:
|
||||
logger.info(f'Feishu send text: user_ids={user_ids} msg={msg}')
|
||||
self._requests.post(URL.SEND_MESSAGE, params=params, json=body)
|
||||
except APIException as e:
|
||||
# 只处理可预知的错误
|
||||
|
||||
65
apps/common/message/backends/sms/__init__.py
Normal file
65
apps/common/message/backends/sms/__init__.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from collections import OrderedDict
|
||||
import importlib
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.db.models import TextChoices
|
||||
from django.conf import settings
|
||||
|
||||
from common.utils import get_logger
|
||||
from common.exceptions import JMSException
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
||||
|
||||
class BACKENDS(TextChoices):
|
||||
ALIBABA = 'alibaba', _('Alibaba cloud')
|
||||
TENCENT = 'tencent', _('Tencent cloud')
|
||||
|
||||
|
||||
class BaseSMSClient:
|
||||
"""
|
||||
短信终端的基类
|
||||
"""
|
||||
|
||||
SIGN_AND_TMPL_SETTING_FIELD_PREFIX: str
|
||||
|
||||
@classmethod
|
||||
def new_from_settings(cls):
|
||||
raise NotImplementedError
|
||||
|
||||
def send_sms(self, phone_numbers: list, sign_name: str, template_code: str, template_param: dict, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class SMS:
|
||||
client: BaseSMSClient
|
||||
|
||||
def __init__(self, backend=None):
|
||||
backend = backend or settings.SMS_BACKEND
|
||||
if backend not in BACKENDS:
|
||||
raise JMSException(
|
||||
code='sms_provider_not_support',
|
||||
detail=_('SMS provider not support: {}').format(backend)
|
||||
)
|
||||
m = importlib.import_module(f'.{backend or settings.SMS_BACKEND}', __package__)
|
||||
self.client = m.client.new_from_settings()
|
||||
|
||||
def send_sms(self, phone_numbers: list, sign_name: str, template_code: str, template_param: dict, **kwargs):
|
||||
return self.client.send_sms(
|
||||
phone_numbers=phone_numbers,
|
||||
sign_name=sign_name,
|
||||
template_code=template_code,
|
||||
template_param=template_param,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
def send_verify_code(self, phone_number, code):
|
||||
sign_name = getattr(settings, f'{self.client.SIGN_AND_TMPL_SETTING_FIELD_PREFIX}_VERIFY_SIGN_NAME')
|
||||
template_code = getattr(settings, f'{self.client.SIGN_AND_TMPL_SETTING_FIELD_PREFIX}_VERIFY_TEMPLATE_CODE')
|
||||
|
||||
if not (sign_name and template_code):
|
||||
raise JMSException(
|
||||
code='verify_code_sign_tmpl_invalid',
|
||||
detail=_('SMS verification code signature or template invalid')
|
||||
)
|
||||
return self.send_sms([phone_number], sign_name, template_code, OrderedDict(code=code))
|
||||
61
apps/common/message/backends/sms/alibaba.py
Normal file
61
apps/common/message/backends/sms/alibaba.py
Normal file
@@ -0,0 +1,61 @@
|
||||
import json
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.conf import settings
|
||||
from alibabacloud_dysmsapi20170525.client import Client as Dysmsapi20170525Client
|
||||
from alibabacloud_tea_openapi import models as open_api_models
|
||||
from alibabacloud_dysmsapi20170525 import models as dysmsapi_20170525_models
|
||||
from Tea.exceptions import TeaException
|
||||
|
||||
from common.utils import get_logger
|
||||
from common.exceptions import JMSException
|
||||
from . import BaseSMSClient
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
||||
|
||||
class AlibabaSMS(BaseSMSClient):
|
||||
SIGN_AND_TMPL_SETTING_FIELD_PREFIX = 'ALIBABA'
|
||||
|
||||
@classmethod
|
||||
def new_from_settings(cls):
|
||||
return cls(
|
||||
access_key_id=settings.ALIBABA_ACCESS_KEY_ID,
|
||||
access_key_secret=settings.ALIBABA_ACCESS_KEY_SECRET
|
||||
)
|
||||
|
||||
def __init__(self, access_key_id: str, access_key_secret: str):
|
||||
config = open_api_models.Config(
|
||||
# 您的AccessKey ID,
|
||||
access_key_id=access_key_id,
|
||||
# 您的AccessKey Secret,
|
||||
access_key_secret=access_key_secret
|
||||
)
|
||||
# 访问的域名
|
||||
config.endpoint = 'dysmsapi.aliyuncs.com'
|
||||
self.client = Dysmsapi20170525Client(config)
|
||||
|
||||
def send_sms(self, phone_numbers: list, sign_name: str, template_code: str, template_param: dict, **kwargs):
|
||||
phone_numbers_str = ','.join(phone_numbers)
|
||||
send_sms_request = dysmsapi_20170525_models.SendSmsRequest(
|
||||
phone_numbers=phone_numbers_str, sign_name=sign_name,
|
||||
template_code=template_code, template_param=json.dumps(template_param)
|
||||
)
|
||||
try:
|
||||
logger.info(f'Alibaba sms send: '
|
||||
f'phone_numbers={phone_numbers} '
|
||||
f'sign_name={sign_name} '
|
||||
f'template_code={template_code} '
|
||||
f'template_param={template_param}')
|
||||
response = self.client.send_sms(send_sms_request)
|
||||
# 这里只判断是否成功,失败抛出异常
|
||||
if response.body.code != 'OK':
|
||||
raise JMSException(detail=response.body.message, code=response.body.code)
|
||||
except TeaException as e:
|
||||
if e.code == 'SignatureDoesNotMatch':
|
||||
raise JMSException(code=e.code, detail=_('Signature does not match'))
|
||||
raise JMSException(code=e.code, detail=e.message)
|
||||
return response
|
||||
|
||||
|
||||
client = AlibabaSMS
|
||||
99
apps/common/message/backends/sms/tencent.py
Normal file
99
apps/common/message/backends/sms/tencent.py
Normal file
@@ -0,0 +1,99 @@
|
||||
import json
|
||||
from collections import OrderedDict
|
||||
|
||||
from django.conf import settings
|
||||
from common.exceptions import JMSException
|
||||
from common.utils import get_logger
|
||||
from tencentcloud.common import credential
|
||||
from tencentcloud.common.exception.tencent_cloud_sdk_exception import TencentCloudSDKException
|
||||
# 导入对应产品模块的client models。
|
||||
from tencentcloud.sms.v20210111 import sms_client, models
|
||||
# 导入可选配置类
|
||||
from tencentcloud.common.profile.client_profile import ClientProfile
|
||||
from tencentcloud.common.profile.http_profile import HttpProfile
|
||||
from . import BaseSMSClient
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
||||
|
||||
class TencentSMS(BaseSMSClient):
|
||||
"""
|
||||
https://cloud.tencent.com/document/product/382/43196#.E5.8F.91.E9.80.81.E7.9F.AD.E4.BF.A1
|
||||
"""
|
||||
SIGN_AND_TMPL_SETTING_FIELD_PREFIX = 'TENCENT'
|
||||
|
||||
@classmethod
|
||||
def new_from_settings(cls):
|
||||
return cls(
|
||||
secret_id=settings.TENCENT_SECRET_ID,
|
||||
secret_key=settings.TENCENT_SECRET_KEY,
|
||||
sdkappid=settings.TENCENT_SDKAPPID
|
||||
)
|
||||
|
||||
def __init__(self, secret_id: str, secret_key: str, sdkappid: str):
|
||||
self.sdkappid = sdkappid
|
||||
|
||||
cred = credential.Credential(secret_id, secret_key)
|
||||
httpProfile = HttpProfile()
|
||||
httpProfile.reqMethod = "POST" # post请求(默认为post请求)
|
||||
httpProfile.reqTimeout = 30 # 请求超时时间,单位为秒(默认60秒)
|
||||
httpProfile.endpoint = "sms.tencentcloudapi.com"
|
||||
|
||||
clientProfile = ClientProfile()
|
||||
clientProfile.signMethod = "TC3-HMAC-SHA256" # 指定签名算法
|
||||
clientProfile.language = "en-US"
|
||||
clientProfile.httpProfile = httpProfile
|
||||
self.client = sms_client.SmsClient(cred, "ap-guangzhou", clientProfile)
|
||||
|
||||
def send_sms(self, phone_numbers: list, sign_name: str, template_code: str, template_param: OrderedDict, **kwargs):
|
||||
try:
|
||||
req = models.SendSmsRequest()
|
||||
# 基本类型的设置:
|
||||
# SDK采用的是指针风格指定参数,即使对于基本类型你也需要用指针来对参数赋值。
|
||||
# SDK提供对基本类型的指针引用封装函数
|
||||
# 帮助链接:
|
||||
# 短信控制台: https://console.cloud.tencent.com/smsv2
|
||||
# sms helper: https://cloud.tencent.com/document/product/382/3773
|
||||
|
||||
# 短信应用ID: 短信SdkAppId在 [短信控制台] 添加应用后生成的实际SdkAppId,示例如1400006666
|
||||
req.SmsSdkAppId = self.sdkappid
|
||||
# 短信签名内容: 使用 UTF-8 编码,必须填写已审核通过的签名,签名信息可登录 [短信控制台] 查看
|
||||
req.SignName = sign_name
|
||||
# 短信码号扩展号: 默认未开通,如需开通请联系 [sms helper]
|
||||
req.ExtendCode = ""
|
||||
# 用户的 session 内容: 可以携带用户侧 ID 等上下文信息,server 会原样返回
|
||||
req.SessionContext = "Jumpserver"
|
||||
# 国际/港澳台短信 senderid: 国内短信填空,默认未开通,如需开通请联系 [sms helper]
|
||||
req.SenderId = ""
|
||||
# 下发手机号码,采用 E.164 标准,+[国家或地区码][手机号]
|
||||
# 示例如:+8613711112222, 其中前面有一个+号 ,86为国家码,13711112222为手机号,最多不要超过200个手机号
|
||||
req.PhoneNumberSet = phone_numbers
|
||||
# 模板 ID: 必须填写已审核通过的模板 ID。模板ID可登录 [短信控制台] 查看
|
||||
req.TemplateId = template_code
|
||||
# 模板参数: 若无模板参数,则设置为空
|
||||
req.TemplateParamSet = list(template_param.values())
|
||||
# 通过client对象调用DescribeInstances方法发起请求。注意请求方法名与请求对象是对应的。
|
||||
# 返回的resp是一个DescribeInstancesResponse类的实例,与请求对象对应。
|
||||
logger.info(f'Tencent sms send: '
|
||||
f'phone_numbers={phone_numbers} '
|
||||
f'sign_name={sign_name} '
|
||||
f'template_code={template_code} '
|
||||
f'template_param={template_param}')
|
||||
|
||||
resp = self.client.SendSms(req)
|
||||
|
||||
try:
|
||||
code = resp.SendStatusSet[0].Code
|
||||
msg = resp.SendStatusSet[0].Message
|
||||
except IndexError:
|
||||
raise JMSException(code='response_bad', detail=resp)
|
||||
|
||||
if code.lower() != 'ok':
|
||||
raise JMSException(code=code, detail=msg)
|
||||
|
||||
return resp
|
||||
except TencentCloudSDKException as e:
|
||||
raise JMSException(code=e.code, detail=e.message)
|
||||
|
||||
|
||||
client = TencentSMS
|
||||
@@ -115,6 +115,7 @@ class WeCom(RequestMixin):
|
||||
},
|
||||
**extra_params
|
||||
}
|
||||
logger.info(f'Wecom send text: users={users} msg={msg}')
|
||||
data = self._requests.post(URL.SEND_MESSAGE, json=body, check_errcode_is_0=False)
|
||||
|
||||
errcode = data['errcode']
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
# coding: utf-8
|
||||
from django.contrib.auth.mixins import UserPassesTestMixin
|
||||
from django.utils import timezone
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework import permissions
|
||||
from rest_framework.response import Response
|
||||
|
||||
from common.permissions import IsValidUser
|
||||
|
||||
__all__ = ["DatetimeSearchMixin", "PermissionsMixin"]
|
||||
|
||||
from rest_framework import permissions
|
||||
|
||||
|
||||
class DatetimeSearchMixin:
|
||||
date_format = '%Y-%m-%d'
|
||||
@@ -52,3 +54,19 @@ class PermissionsMixin(UserPassesTestMixin):
|
||||
if not permission_class().has_permission(self.request, self):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class SuggestionMixin:
|
||||
suggestion_mini_count = 10
|
||||
|
||||
@action(methods=['get'], detail=False, permission_classes=(IsValidUser,))
|
||||
def suggestions(self, request, *args, **kwargs):
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
queryset = queryset[:self.suggestion_mini_count]
|
||||
page = self.paginate_queryset(queryset)
|
||||
if page is not None:
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@@ -33,6 +33,8 @@ class IsSuperUser(IsValidUser):
|
||||
|
||||
class IsSuperUserOrAppUser(IsSuperUser):
|
||||
def has_permission(self, request, view):
|
||||
if request.user.is_anonymous:
|
||||
return False
|
||||
return super(IsSuperUserOrAppUser, self).has_permission(request, view) \
|
||||
or request.user.is_app
|
||||
|
||||
@@ -67,6 +69,8 @@ class IsOrgAdminOrAppUser(IsValidUser):
|
||||
def has_permission(self, request, view):
|
||||
if not current_org:
|
||||
return False
|
||||
if request.user.is_anonymous:
|
||||
return False
|
||||
return super(IsOrgAdminOrAppUser, self).has_permission(request, view) \
|
||||
and (current_org.can_admin_by(request.user) or request.user.is_app)
|
||||
|
||||
@@ -191,3 +195,11 @@ class HasQueryParamsUserAndIsCurrentOrgMember(permissions.BasePermission):
|
||||
return False
|
||||
query_user = current_org.get_members().filter(id=query_user_id).first()
|
||||
return bool(query_user)
|
||||
|
||||
|
||||
class OnlySuperUserCanList(IsValidUser):
|
||||
def has_permission(self, request, view):
|
||||
user = request.user
|
||||
if view.action == 'list' and not user.is_superuser:
|
||||
return False
|
||||
return True
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
import re
|
||||
|
||||
from django.core.validators import RegexValidator
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from rest_framework.validators import (
|
||||
@@ -32,3 +34,12 @@ class NoSpecialChars:
|
||||
raise serializers.ValidationError(
|
||||
_("Should not contains special characters")
|
||||
)
|
||||
|
||||
|
||||
class PhoneValidator:
|
||||
pattern = re.compile(r"^1[356789]\d{9}$")
|
||||
message = _('The mobile phone number format is incorrect')
|
||||
|
||||
def __call__(self, value):
|
||||
if not self.pattern.match(value):
|
||||
raise serializers.ValidationError(self.message)
|
||||
|
||||
@@ -17,9 +17,10 @@ from terminal.models import Session
|
||||
from terminal.utils import ComponentsPrometheusMetricsUtil
|
||||
from orgs.utils import current_org
|
||||
from common.permissions import IsOrgAdmin, IsOrgAuditor
|
||||
from common.utils import lazyproperty
|
||||
from common.utils import lazyproperty, get_request_ip
|
||||
from orgs.caches import OrgResourceStatisticsCache
|
||||
|
||||
|
||||
__all__ = ['IndexApi']
|
||||
|
||||
|
||||
@@ -297,19 +298,33 @@ class IndexApi(DatesLoginMetricMixin, APIView):
|
||||
|
||||
|
||||
class HealthApiMixin(APIView):
|
||||
def is_token_right(self):
|
||||
token = self.request.query_params.get('token')
|
||||
ok_token = settings.HEALTH_CHECK_TOKEN
|
||||
if ok_token and token != ok_token:
|
||||
return False
|
||||
return True
|
||||
pass
|
||||
|
||||
def check_permissions(self, request):
|
||||
if not self.is_token_right():
|
||||
msg = 'Health check token error, ' \
|
||||
'Please set query param in url and same with setting HEALTH_CHECK_TOKEN. ' \
|
||||
'eg: $PATH/?token=$HEALTH_CHECK_TOKEN'
|
||||
self.permission_denied(request, message={'error': msg}, code=403)
|
||||
# 先去掉 Health Api 的权限校验,方便各组件直接调用
|
||||
|
||||
# def is_token_right(self):
|
||||
# token = self.request.query_params.get('token')
|
||||
# ok_token = settings.HEALTH_CHECK_TOKEN
|
||||
# if ok_token and token != ok_token:
|
||||
# return False
|
||||
# return True
|
||||
|
||||
# def is_localhost(self):
|
||||
# ip = get_request_ip(self.request)
|
||||
# return ip in ['localhost', '127.0.0.1']
|
||||
|
||||
# def check_permissions(self, request):
|
||||
# if self.is_token_right():
|
||||
# return
|
||||
# if self.is_localhost():
|
||||
# return
|
||||
# msg = '''
|
||||
# Health check token error,
|
||||
# Please set query param in url and
|
||||
# same with setting HEALTH_CHECK_TOKEN.
|
||||
# eg: $PATH/?token=$HEALTH_CHECK_TOKEN
|
||||
# '''
|
||||
# self.permission_denied(request, message={'error': msg}, code=403)
|
||||
|
||||
|
||||
class HealthCheckView(HealthApiMixin):
|
||||
|
||||
@@ -14,6 +14,7 @@ import types
|
||||
import errno
|
||||
import json
|
||||
import yaml
|
||||
import copy
|
||||
from importlib import import_module
|
||||
from django.urls import reverse_lazy
|
||||
from urllib.parse import urljoin, urlparse
|
||||
@@ -179,13 +180,14 @@ class Config(dict):
|
||||
'AUTH_OPENID_CLIENT_SECRET': 'client-secret',
|
||||
'AUTH_OPENID_SHARE_SESSION': True,
|
||||
'AUTH_OPENID_IGNORE_SSL_VERIFICATION': True,
|
||||
|
||||
# OpenID 新配置参数 (version >= 1.5.9)
|
||||
'AUTH_OPENID_PROVIDER_ENDPOINT': 'https://op-example.com/',
|
||||
'AUTH_OPENID_PROVIDER_AUTHORIZATION_ENDPOINT': 'https://op-example.com/authorize',
|
||||
'AUTH_OPENID_PROVIDER_TOKEN_ENDPOINT': 'https://op-example.com/token',
|
||||
'AUTH_OPENID_PROVIDER_JWKS_ENDPOINT': 'https://op-example.com/jwks',
|
||||
'AUTH_OPENID_PROVIDER_USERINFO_ENDPOINT': 'https://op-example.com/userinfo',
|
||||
'AUTH_OPENID_PROVIDER_END_SESSION_ENDPOINT': 'https://op-example.com/logout',
|
||||
'AUTH_OPENID_PROVIDER_ENDPOINT': 'https://oidc.example.com/',
|
||||
'AUTH_OPENID_PROVIDER_AUTHORIZATION_ENDPOINT': 'https://oidc.example.com/authorize',
|
||||
'AUTH_OPENID_PROVIDER_TOKEN_ENDPOINT': 'https://oidc.example.com/token',
|
||||
'AUTH_OPENID_PROVIDER_JWKS_ENDPOINT': 'https://oidc.example.com/jwks',
|
||||
'AUTH_OPENID_PROVIDER_USERINFO_ENDPOINT': 'https://oidc.example.com/userinfo',
|
||||
'AUTH_OPENID_PROVIDER_END_SESSION_ENDPOINT': 'https://oidc.example.com/logout',
|
||||
'AUTH_OPENID_PROVIDER_SIGNATURE_ALG': 'HS256',
|
||||
'AUTH_OPENID_PROVIDER_SIGNATURE_KEY': None,
|
||||
'AUTH_OPENID_SCOPES': 'openid profile email',
|
||||
@@ -194,10 +196,13 @@ class Config(dict):
|
||||
'AUTH_OPENID_USE_STATE': True,
|
||||
'AUTH_OPENID_USE_NONCE': True,
|
||||
'AUTH_OPENID_ALWAYS_UPDATE_USER': True,
|
||||
# OpenID 旧配置参数 (version <= 1.5.8 (discarded))
|
||||
'AUTH_OPENID_SERVER_URL': 'http://openid',
|
||||
|
||||
# Keycloak 旧配置参数 (version <= 1.5.8 (discarded))
|
||||
'AUTH_OPENID_KEYCLOAK': True,
|
||||
'AUTH_OPENID_SERVER_URL': 'https://keycloak.example.com',
|
||||
'AUTH_OPENID_REALM_NAME': None,
|
||||
|
||||
# Raidus 认证
|
||||
'AUTH_RADIUS': False,
|
||||
'RADIUS_SERVER': 'localhost',
|
||||
'RADIUS_PORT': 1812,
|
||||
@@ -205,37 +210,60 @@ class Config(dict):
|
||||
'RADIUS_ENCRYPT_PASSWORD': True,
|
||||
'OTP_IN_RADIUS': False,
|
||||
|
||||
# Cas 认证
|
||||
'AUTH_CAS': False,
|
||||
'CAS_SERVER_URL': "http://host/cas/",
|
||||
'CAS_ROOT_PROXIED_AS': '',
|
||||
'CAS_SERVER_URL': "https://example.com/cas/",
|
||||
'CAS_ROOT_PROXIED_AS': 'https://example.com',
|
||||
'CAS_LOGOUT_COMPLETELY': True,
|
||||
'CAS_VERSION': 3,
|
||||
'CAS_USERNAME_ATTRIBUTE': 'uid',
|
||||
'CAS_APPLY_ATTRIBUTES_TO_USER': False,
|
||||
'CAS_RENAME_ATTRIBUTES': {},
|
||||
'CAS_RENAME_ATTRIBUTES': {'uid': 'username'},
|
||||
'CAS_CREATE_USER': True,
|
||||
|
||||
'AUTH_SSO': False,
|
||||
'AUTH_SSO_AUTHKEY_TTL': 60 * 15,
|
||||
|
||||
# 企业微信
|
||||
'AUTH_WECOM': False,
|
||||
'WECOM_CORPID': '',
|
||||
'WECOM_AGENTID': '',
|
||||
'WECOM_SECRET': '',
|
||||
|
||||
# 钉钉
|
||||
'AUTH_DINGTALK': False,
|
||||
'DINGTALK_AGENTID': '',
|
||||
'DINGTALK_APPKEY': '',
|
||||
'DINGTALK_APPSECRET': '',
|
||||
|
||||
# 飞书
|
||||
'AUTH_FEISHU': False,
|
||||
'FEISHU_APP_ID': '',
|
||||
'FEISHU_APP_SECRET': '',
|
||||
|
||||
'LOGIN_REDIRECT_TO_BACKEND': '', # 'OPENID / CAS
|
||||
'LOGIN_REDIRECT_MSG_ENABLED': True,
|
||||
|
||||
'SMS_ENABLED': False,
|
||||
'SMS_BACKEND': '',
|
||||
'SMS_TEST_PHONE': '',
|
||||
|
||||
'ALIBABA_ACCESS_KEY_ID': '',
|
||||
'ALIBABA_ACCESS_KEY_SECRET': '',
|
||||
'ALIBABA_VERIFY_SIGN_NAME': '',
|
||||
'ALIBABA_VERIFY_TEMPLATE_CODE': '',
|
||||
|
||||
'TENCENT_SECRET_ID': '',
|
||||
'TENCENT_SECRET_KEY': '',
|
||||
'TENCENT_SDKAPPID': '',
|
||||
'TENCENT_VERIFY_SIGN_NAME': '',
|
||||
'TENCENT_VERIFY_TEMPLATE_CODE': '',
|
||||
|
||||
'OTP_VALID_WINDOW': 2,
|
||||
'OTP_ISSUER_NAME': 'JumpServer',
|
||||
'EMAIL_SUFFIX': 'jumpserver.org',
|
||||
'EMAIL_SUFFIX': 'example.com',
|
||||
|
||||
# Terminal配置
|
||||
'TERMINAL_PASSWORD_AUTH': True,
|
||||
'TERMINAL_PUBLIC_KEY_AUTH': True,
|
||||
'TERMINAL_HEARTBEAT_INTERVAL': 20,
|
||||
@@ -245,7 +273,10 @@ class Config(dict):
|
||||
'TERMINAL_HOST_KEY': '',
|
||||
'TERMINAL_TELNET_REGEX': '',
|
||||
'TERMINAL_COMMAND_STORAGE': {},
|
||||
'TERMINAL_RDP_ADDR': '',
|
||||
'XRDP_ENABLED': True,
|
||||
|
||||
# 安全配置
|
||||
'SECURITY_MFA_AUTH': 0, # 0 不开启 1 全局开启 2 管理员开启
|
||||
'SECURITY_COMMAND_EXECUTION': True,
|
||||
'SECURITY_SERVICE_ACCOUNT_REGISTRATION': True,
|
||||
@@ -262,60 +293,64 @@ class Config(dict):
|
||||
'SECURITY_PASSWORD_SPECIAL_CHAR': False,
|
||||
'SECURITY_LOGIN_CHALLENGE_ENABLED': False,
|
||||
'SECURITY_LOGIN_CAPTCHA_ENABLED': True,
|
||||
'SECURITY_DATA_CRYPTO_ALGO': 'aes',
|
||||
'SECURITY_INSECURE_COMMAND': False,
|
||||
'SECURITY_INSECURE_COMMAND_LEVEL': 5,
|
||||
'SECURITY_INSECURE_COMMAND_EMAIL_RECEIVER': '',
|
||||
'SECURITY_LUNA_REMEMBER_AUTH': True,
|
||||
'SECURITY_WATERMARK_ENABLED': True,
|
||||
'SECURITY_MFA_VERIFY_TTL': 3600,
|
||||
'SECURITY_SESSION_SHARE': True,
|
||||
'OLD_PASSWORD_HISTORY_LIMIT_COUNT': 5,
|
||||
'LOGIN_CONFIRM_ENABLE': False, # 准备废弃,放到 acl 中
|
||||
'CHANGE_AUTH_PLAN_SECURE_MODE_ENABLED': True,
|
||||
'USER_LOGIN_SINGLE_MACHINE_ENABLED': False,
|
||||
'ONLY_ALLOW_EXIST_USER_AUTH': False,
|
||||
'ONLY_ALLOW_AUTH_FROM_SOURCE': False,
|
||||
|
||||
# 启动前
|
||||
'HTTP_BIND_HOST': '0.0.0.0',
|
||||
'HTTP_LISTEN_PORT': 8080,
|
||||
'WS_LISTEN_PORT': 8070,
|
||||
'SYSLOG_ADDR': '', # '192.168.0.1:514'
|
||||
'SYSLOG_FACILITY': 'user',
|
||||
'SYSLOG_SOCKTYPE': 2,
|
||||
'PERM_EXPIRED_CHECK_PERIODIC': 60 * 60,
|
||||
'FLOWER_URL': "127.0.0.1:5555",
|
||||
'LANGUAGE_CODE': 'zh',
|
||||
'TIME_ZONE': 'Asia/Shanghai',
|
||||
'FORCE_SCRIPT_NAME': '',
|
||||
'SESSION_COOKIE_SECURE': False,
|
||||
'CSRF_COOKIE_SECURE': False,
|
||||
'REFERER_CHECK_ENABLED': False,
|
||||
'SESSION_SAVE_EVERY_REQUEST': True,
|
||||
'SESSION_EXPIRE_AT_BROWSER_CLOSE_FORCE': False,
|
||||
'SERVER_REPLAY_STORAGE': {},
|
||||
'SECURITY_DATA_CRYPTO_ALGO': 'aes',
|
||||
|
||||
# 记录清理清理
|
||||
'LOGIN_LOG_KEEP_DAYS': 200,
|
||||
'TASK_LOG_KEEP_DAYS': 90,
|
||||
'OPERATE_LOG_KEEP_DAYS': 200,
|
||||
'FTP_LOG_KEEP_DAYS': 200,
|
||||
'ASSETS_PERM_CACHE_TIME': 3600 * 24,
|
||||
'SECURITY_MFA_VERIFY_TTL': 3600,
|
||||
'OLD_PASSWORD_HISTORY_LIMIT_COUNT': 5,
|
||||
'ASSETS_PERM_CACHE_ENABLE': HAS_XPACK,
|
||||
'SYSLOG_ADDR': '', # '192.168.0.1:514'
|
||||
'SYSLOG_FACILITY': 'user',
|
||||
'SYSLOG_SOCKTYPE': 2,
|
||||
'PERM_SINGLE_ASSET_TO_UNGROUP_NODE': False,
|
||||
'PERM_EXPIRED_CHECK_PERIODIC': 60 * 60,
|
||||
'WINDOWS_SSH_DEFAULT_SHELL': 'cmd',
|
||||
'FLOWER_URL': "127.0.0.1:5555",
|
||||
'DEFAULT_ORG_SHOW_ALL_USERS': True,
|
||||
'PERIOD_TASK_ENABLED': True,
|
||||
'FORCE_SCRIPT_NAME': '',
|
||||
'LOGIN_CONFIRM_ENABLE': False,
|
||||
'WINDOWS_SKIP_ALL_MANUAL_PASSWORD': False,
|
||||
'ORG_CHANGE_TO_URL': '',
|
||||
'LANGUAGE_CODE': 'zh',
|
||||
'TIME_ZONE': 'Asia/Shanghai',
|
||||
'CHANGE_AUTH_PLAN_SECURE_MODE_ENABLED': True,
|
||||
'USER_LOGIN_SINGLE_MACHINE_ENABLED': False,
|
||||
'TICKETS_ENABLED': True,
|
||||
'SESSION_COOKIE_SECURE': False,
|
||||
'CSRF_COOKIE_SECURE': False,
|
||||
'REFERER_CHECK_ENABLED': False,
|
||||
'SERVER_REPLAY_STORAGE': {},
|
||||
'CONNECTION_TOKEN_ENABLED': False,
|
||||
'ONLY_ALLOW_EXIST_USER_AUTH': False,
|
||||
'ONLY_ALLOW_AUTH_FROM_SOURCE': False,
|
||||
'SESSION_SAVE_EVERY_REQUEST': True,
|
||||
'SESSION_EXPIRE_AT_BROWSER_CLOSE_FORCE': False,
|
||||
'FORGOT_PASSWORD_URL': '',
|
||||
'HEALTH_CHECK_TOKEN': '',
|
||||
'LOGIN_REDIRECT_TO_BACKEND': None, # 'OPENID / CAS
|
||||
'CLOUD_SYNC_TASK_EXECUTION_KEEP_DAYS': 30,
|
||||
|
||||
'TERMINAL_RDP_ADDR': ''
|
||||
# 废弃的
|
||||
'DEFAULT_ORG_SHOW_ALL_USERS': True,
|
||||
'ORG_CHANGE_TO_URL': '',
|
||||
'WINDOWS_SKIP_ALL_MANUAL_PASSWORD': False,
|
||||
'CONNECTION_TOKEN_ENABLED': False,
|
||||
|
||||
'PERM_SINGLE_ASSET_TO_UNGROUP_NODE': False,
|
||||
'WINDOWS_SSH_DEFAULT_SHELL': 'cmd',
|
||||
'PERIOD_TASK_ENABLED': True,
|
||||
|
||||
'TICKETS_ENABLED': True,
|
||||
'FORGOT_PASSWORD_URL': '',
|
||||
'HEALTH_CHECK_TOKEN': '',
|
||||
}
|
||||
|
||||
def compatible_auth_openid_of_key(self):
|
||||
@staticmethod
|
||||
def convert_keycloak_to_openid(keycloak_config):
|
||||
"""
|
||||
兼容OpenID旧配置 (即 version <= 1.5.8)
|
||||
因为旧配置只支持OpenID协议的Keycloak实现,
|
||||
@@ -323,62 +358,79 @@ class Config(dict):
|
||||
构造出新配置中标准OpenID协议中所需的Endpoint即可
|
||||
(Keycloak说明文档参考: https://www.keycloak.org/docs/latest/securing_apps/)
|
||||
"""
|
||||
if not self.AUTH_OPENID:
|
||||
|
||||
openid_config = copy.deepcopy(keycloak_config)
|
||||
|
||||
auth_openid = openid_config.get('AUTH_OPENID')
|
||||
auth_openid_realm_name = openid_config.get('AUTH_OPENID_REALM_NAME')
|
||||
auth_openid_server_url = openid_config.get('AUTH_OPENID_SERVER_URL')
|
||||
|
||||
if not auth_openid:
|
||||
return
|
||||
|
||||
realm_name = self.AUTH_OPENID_REALM_NAME
|
||||
if realm_name is None:
|
||||
if auth_openid and not auth_openid_realm_name:
|
||||
# 开启的是标准 OpenID 配置,关掉 Keycloak 配置
|
||||
openid_config.update({
|
||||
'AUTH_OPENID_KEYCLOAK': False
|
||||
})
|
||||
|
||||
if auth_openid_realm_name is None:
|
||||
return
|
||||
|
||||
compatible_keycloak_config = [
|
||||
(
|
||||
'AUTH_OPENID_PROVIDER_ENDPOINT',
|
||||
self.AUTH_OPENID_SERVER_URL
|
||||
),
|
||||
(
|
||||
'AUTH_OPENID_PROVIDER_AUTHORIZATION_ENDPOINT',
|
||||
'/realms/{}/protocol/openid-connect/auth'.format(realm_name)
|
||||
),
|
||||
(
|
||||
'AUTH_OPENID_PROVIDER_TOKEN_ENDPOINT',
|
||||
'/realms/{}/protocol/openid-connect/token'.format(realm_name)
|
||||
),
|
||||
(
|
||||
'AUTH_OPENID_PROVIDER_JWKS_ENDPOINT',
|
||||
'/realms/{}/protocol/openid-connect/certs'.format(realm_name)
|
||||
),
|
||||
(
|
||||
'AUTH_OPENID_PROVIDER_USERINFO_ENDPOINT',
|
||||
'/realms/{}/protocol/openid-connect/userinfo'.format(realm_name)
|
||||
),
|
||||
(
|
||||
'AUTH_OPENID_PROVIDER_END_SESSION_ENDPOINT',
|
||||
'/realms/{}/protocol/openid-connect/logout'.format(realm_name)
|
||||
)
|
||||
]
|
||||
for key, value in compatible_keycloak_config:
|
||||
self[key] = value
|
||||
# # convert key # #
|
||||
compatible_config = {
|
||||
'AUTH_OPENID_PROVIDER_ENDPOINT': auth_openid_server_url,
|
||||
|
||||
def compatible_auth_openid_of_value(self):
|
||||
"""
|
||||
兼容值的绝对路径、相对路径
|
||||
(key 为 AUTH_OPENID_PROVIDER_*_ENDPOINT 的配置)
|
||||
"""
|
||||
if not self.AUTH_OPENID:
|
||||
return
|
||||
'AUTH_OPENID_PROVIDER_AUTHORIZATION_ENDPOINT': '/realms/{}/protocol/openid-connect/auth'
|
||||
''.format(auth_openid_realm_name),
|
||||
'AUTH_OPENID_PROVIDER_TOKEN_ENDPOINT': '/realms/{}/protocol/openid-connect/token'
|
||||
''.format(auth_openid_realm_name),
|
||||
'AUTH_OPENID_PROVIDER_JWKS_ENDPOINT': '/realms/{}/protocol/openid-connect/certs'
|
||||
''.format(auth_openid_realm_name),
|
||||
'AUTH_OPENID_PROVIDER_USERINFO_ENDPOINT': '/realms/{}/protocol/openid-connect/userinfo'
|
||||
''.format(auth_openid_realm_name),
|
||||
'AUTH_OPENID_PROVIDER_END_SESSION_ENDPOINT': '/realms/{}/protocol/openid-connect/logout'
|
||||
''.format(auth_openid_realm_name)
|
||||
}
|
||||
for key, value in compatible_config.items():
|
||||
openid_config[key] = value
|
||||
|
||||
base = self.AUTH_OPENID_PROVIDER_ENDPOINT
|
||||
config = list(self.items())
|
||||
for key, value in config:
|
||||
# # convert value # #
|
||||
""" 兼容值的绝对路径、相对路径 (key 为 AUTH_OPENID_PROVIDER_*_ENDPOINT 的配置) """
|
||||
base = openid_config.get('AUTH_OPENID_PROVIDER_ENDPOINT')
|
||||
for key, value in openid_config.items():
|
||||
result = re.match(r'^AUTH_OPENID_PROVIDER_.*_ENDPOINT$', key)
|
||||
if result is None:
|
||||
continue
|
||||
if value is None:
|
||||
# None 在 url 中有特殊含义 (比如对于: end_session_endpoint)
|
||||
continue
|
||||
|
||||
value = build_absolute_uri(base, value)
|
||||
openid_config[key] = value
|
||||
|
||||
return openid_config
|
||||
|
||||
def get_keycloak_config(self):
|
||||
keycloak_config = {
|
||||
'AUTH_OPENID': self.AUTH_OPENID,
|
||||
'AUTH_OPENID_REALM_NAME': self.AUTH_OPENID_REALM_NAME,
|
||||
'AUTH_OPENID_SERVER_URL': self.AUTH_OPENID_SERVER_URL,
|
||||
'AUTH_OPENID_PROVIDER_ENDPOINT': self.AUTH_OPENID_PROVIDER_ENDPOINT
|
||||
}
|
||||
return keycloak_config
|
||||
|
||||
def set_openid_config(self, openid_config):
|
||||
for key, value in openid_config.items():
|
||||
self[key] = value
|
||||
|
||||
def compatible_auth_openid(self, keycloak_config=None):
|
||||
if keycloak_config is None:
|
||||
keycloak_config = self.get_keycloak_config()
|
||||
openid_config = self.convert_keycloak_to_openid(keycloak_config)
|
||||
if openid_config:
|
||||
self.set_openid_config(openid_config)
|
||||
|
||||
def compatible(self):
|
||||
"""
|
||||
对配置做兼容处理
|
||||
@@ -388,14 +440,8 @@ class Config(dict):
|
||||
处理顺序要保持先对key做处理, 再对value做处理,
|
||||
因为处理value的时候,只根据最新版本支持的key进行
|
||||
"""
|
||||
parts = ['key', 'value']
|
||||
targets = ['auth_openid']
|
||||
for part in parts:
|
||||
for target in targets:
|
||||
method_name = 'compatible_{}_of_{}'.format(target, part)
|
||||
method = getattr(self, method_name, None)
|
||||
if method is not None:
|
||||
method()
|
||||
# 兼容 OpenID 配置
|
||||
self.compatible_auth_openid()
|
||||
|
||||
def convert_type(self, k, v):
|
||||
default_value = self.defaults.get(k)
|
||||
|
||||
@@ -122,6 +122,8 @@ AUTH_FEISHU = CONFIG.AUTH_FEISHU
|
||||
FEISHU_APP_ID = CONFIG.FEISHU_APP_ID
|
||||
FEISHU_APP_SECRET = CONFIG.FEISHU_APP_SECRET
|
||||
|
||||
|
||||
|
||||
# Other setting
|
||||
TOKEN_EXPIRATION = CONFIG.TOKEN_EXPIRATION
|
||||
LOGIN_CONFIRM_ENABLE = CONFIG.LOGIN_CONFIRM_ENABLE
|
||||
@@ -145,6 +147,7 @@ AUTH_BACKEND_AUTH_TOKEN = 'authentication.backends.api.AuthorizationTokenAuthent
|
||||
AUTHENTICATION_BACKENDS = [
|
||||
AUTH_BACKEND_MODEL, AUTH_BACKEND_PUBKEY, AUTH_BACKEND_WECOM,
|
||||
AUTH_BACKEND_DINGTALK, AUTH_BACKEND_FEISHU, AUTH_BACKEND_AUTH_TOKEN,
|
||||
AUTH_BACKEND_SSO,
|
||||
]
|
||||
|
||||
if AUTH_CAS:
|
||||
@@ -154,8 +157,6 @@ if AUTH_OPENID:
|
||||
AUTHENTICATION_BACKENDS.insert(0, AUTH_BACKEND_OIDC_CODE)
|
||||
if AUTH_RADIUS:
|
||||
AUTHENTICATION_BACKENDS.insert(0, AUTH_BACKEND_RADIUS)
|
||||
if AUTH_SSO:
|
||||
AUTHENTICATION_BACKENDS.append(AUTH_BACKEND_SSO)
|
||||
|
||||
|
||||
ONLY_ALLOW_EXIST_USER_AUTH = CONFIG.ONLY_ALLOW_EXIST_USER_AUTH
|
||||
|
||||
@@ -87,6 +87,7 @@ MIDDLEWARE = [
|
||||
'orgs.middleware.OrgMiddleware',
|
||||
'authentication.backends.oidc.middleware.OIDCRefreshIDTokenMiddleware',
|
||||
'authentication.backends.cas.middleware.CASMiddleware',
|
||||
'authentication.middleware.MFAMiddleware',
|
||||
'simple_history.middleware.HistoryRequestMiddleware',
|
||||
]
|
||||
|
||||
|
||||
@@ -72,14 +72,9 @@ TERMINAL_HOST_KEY = CONFIG.TERMINAL_HOST_KEY
|
||||
TERMINAL_HEADER_TITLE = CONFIG.TERMINAL_HEADER_TITLE
|
||||
TERMINAL_TELNET_REGEX = CONFIG.TERMINAL_TELNET_REGEX
|
||||
|
||||
# User or user group permission cache time, default 3600 seconds
|
||||
ASSETS_PERM_CACHE_ENABLE = CONFIG.ASSETS_PERM_CACHE_ENABLE
|
||||
ASSETS_PERM_CACHE_TIME = CONFIG.ASSETS_PERM_CACHE_TIME
|
||||
|
||||
# Asset user auth external backend, default AuthBook backend
|
||||
BACKEND_ASSET_USER_AUTH_VAULT = False
|
||||
|
||||
DEFAULT_ORG_SHOW_ALL_USERS = CONFIG.DEFAULT_ORG_SHOW_ALL_USERS
|
||||
PERM_SINGLE_ASSET_TO_UNGROUP_NODE = CONFIG.PERM_SINGLE_ASSET_TO_UNGROUP_NODE
|
||||
PERM_EXPIRED_CHECK_PERIODIC = CONFIG.PERM_EXPIRED_CHECK_PERIODIC
|
||||
WINDOWS_SSH_DEFAULT_SHELL = CONFIG.WINDOWS_SSH_DEFAULT_SHELL
|
||||
@@ -129,7 +124,28 @@ HEALTH_CHECK_TOKEN = CONFIG.HEALTH_CHECK_TOKEN
|
||||
TERMINAL_RDP_ADDR = CONFIG.TERMINAL_RDP_ADDR
|
||||
SECURITY_LUNA_REMEMBER_AUTH = CONFIG.SECURITY_LUNA_REMEMBER_AUTH
|
||||
SECURITY_WATERMARK_ENABLED = CONFIG.SECURITY_WATERMARK_ENABLED
|
||||
SECURITY_SESSION_SHARE = CONFIG.SECURITY_SESSION_SHARE
|
||||
|
||||
LOGIN_REDIRECT_TO_BACKEND = CONFIG.LOGIN_REDIRECT_TO_BACKEND
|
||||
LOGIN_REDIRECT_MSG_ENABLED = CONFIG.LOGIN_REDIRECT_MSG_ENABLED
|
||||
|
||||
CLOUD_SYNC_TASK_EXECUTION_KEEP_DAYS = CONFIG.CLOUD_SYNC_TASK_EXECUTION_KEEP_DAYS
|
||||
|
||||
XRDP_ENABLED = CONFIG.XRDP_ENABLED
|
||||
|
||||
|
||||
# SMS enabled
|
||||
SMS_ENABLED = CONFIG.SMS_ENABLED
|
||||
SMS_BACKEND = CONFIG.SMS_BACKEND
|
||||
SMS_TEST_PHONE = CONFIG.SMS_TEST_PHONE
|
||||
|
||||
# Alibaba
|
||||
ALIBABA_ACCESS_KEY_ID = CONFIG.ALIBABA_ACCESS_KEY_ID
|
||||
ALIBABA_ACCESS_KEY_SECRET = CONFIG.ALIBABA_ACCESS_KEY_SECRET
|
||||
ALIBABA_SMS_SIGN_AND_TEMPLATES = CONFIG.ALIBABA_SMS_SIGN_AND_TEMPLATES
|
||||
|
||||
# TENCENT
|
||||
TENCENT_SECRET_ID = CONFIG.TENCENT_SECRET_ID
|
||||
TENCENT_SECRET_KEY = CONFIG.TENCENT_SECRET_KEY
|
||||
TENCENT_SDKAPPID = CONFIG.TENCENT_SDKAPPID
|
||||
TENCENT_SMS_SIGN_AND_TEMPLATES = CONFIG.TENCENT_SMS_SIGN_AND_TEMPLATES
|
||||
|
||||
@@ -32,6 +32,7 @@ app_view_patterns = [
|
||||
path('ops/', include('ops.urls.view_urls'), name='ops'),
|
||||
path('common/', include('common.urls.view_urls'), name='common'),
|
||||
re_path(r'flower/(?P<path>.*)', views.celery_flower_view, name='flower-view'),
|
||||
path('download/', views.ResourceDownload.as_view(), name='download')
|
||||
]
|
||||
|
||||
if settings.XPACK_ENABLED:
|
||||
|
||||
@@ -4,7 +4,7 @@ import re
|
||||
|
||||
from django.http import HttpResponseRedirect, JsonResponse, Http404
|
||||
from django.conf import settings
|
||||
from django.views.generic import View
|
||||
from django.views.generic import View, TemplateView
|
||||
from django.shortcuts import redirect
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
@@ -16,7 +16,8 @@ from common.http import HttpResponseTemporaryRedirect
|
||||
|
||||
__all__ = [
|
||||
'LunaView', 'I18NView', 'KokoView', 'WsView',
|
||||
'redirect_format_api', 'redirect_old_apps_view', 'UIView'
|
||||
'redirect_format_api', 'redirect_old_apps_view', 'UIView',
|
||||
'ResourceDownload',
|
||||
]
|
||||
|
||||
|
||||
@@ -84,3 +85,6 @@ class KokoView(View):
|
||||
"</div>If you see this page, prove that you are not accessing the nginx listening port. Good luck.</div>")
|
||||
return HttpResponse(msg)
|
||||
|
||||
|
||||
class ResourceDownload(TemplateView):
|
||||
template_name = 'resource_download.html'
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -1,18 +1,18 @@
|
||||
from django.http import Http404
|
||||
from rest_framework.mixins import ListModelMixin, UpdateModelMixin
|
||||
from rest_framework.mixins import ListModelMixin, UpdateModelMixin, RetrieveModelMixin
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
|
||||
from common.drf.api import JMSGenericViewSet
|
||||
from common.permissions import IsObjectOwner, IsSuperUser, OnlySuperUserCanList
|
||||
from notifications.notifications import system_msgs
|
||||
from notifications.models import SystemMsgSubscription
|
||||
from notifications.models import SystemMsgSubscription, UserMsgSubscription
|
||||
from notifications.backends import BACKEND
|
||||
from notifications.serializers import (
|
||||
SystemMsgSubscriptionSerializer, SystemMsgSubscriptionByCategorySerializer
|
||||
SystemMsgSubscriptionSerializer, SystemMsgSubscriptionByCategorySerializer,
|
||||
UserMsgSubscriptionSerializer,
|
||||
)
|
||||
|
||||
__all__ = ('BackendListView', 'SystemMsgSubscriptionViewSet')
|
||||
__all__ = ('BackendListView', 'SystemMsgSubscriptionViewSet', 'UserMsgSubscriptionViewSet')
|
||||
|
||||
|
||||
class BackendListView(APIView):
|
||||
@@ -70,3 +70,13 @@ class SystemMsgSubscriptionViewSet(ListModelMixin,
|
||||
|
||||
serializer = self.get_serializer(data, many=True)
|
||||
return Response(data=serializer.data)
|
||||
|
||||
|
||||
class UserMsgSubscriptionViewSet(ListModelMixin,
|
||||
RetrieveModelMixin,
|
||||
UpdateModelMixin,
|
||||
JMSGenericViewSet):
|
||||
lookup_field = 'user_id'
|
||||
queryset = UserMsgSubscription.objects.all()
|
||||
serializer_class = UserMsgSubscriptionSerializer
|
||||
permission_classes = (IsObjectOwner | IsSuperUser, OnlySuperUserCanList)
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import importlib
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.db import models
|
||||
|
||||
from .dingtalk import DingTalk
|
||||
from .email import Email
|
||||
from .site_msg import SiteMessage
|
||||
from .wecom import WeCom
|
||||
from .feishu import FeiShu
|
||||
client_name_mapper = {}
|
||||
|
||||
|
||||
class BACKEND(models.TextChoices):
|
||||
@@ -14,17 +12,11 @@ class BACKEND(models.TextChoices):
|
||||
DINGTALK = 'dingtalk', _('DingTalk')
|
||||
SITE_MSG = 'site_msg', _('Site message')
|
||||
FEISHU = 'feishu', _('FeiShu')
|
||||
# SMS = 'sms', _('SMS')
|
||||
|
||||
@property
|
||||
def client(self):
|
||||
client = {
|
||||
self.EMAIL: Email,
|
||||
self.WECOM: WeCom,
|
||||
self.DINGTALK: DingTalk,
|
||||
self.SITE_MSG: SiteMessage,
|
||||
self.FEISHU: FeiShu,
|
||||
}[self]
|
||||
return client
|
||||
return client_name_mapper[self]
|
||||
|
||||
def get_account(self, user):
|
||||
return self.client.get_account(user)
|
||||
@@ -37,3 +29,8 @@ class BACKEND(models.TextChoices):
|
||||
def filter_enable_backends(cls, backends):
|
||||
enable_backends = [b for b in backends if cls(b).is_enable]
|
||||
return enable_backends
|
||||
|
||||
|
||||
for b in BACKEND:
|
||||
m = importlib.import_module(f'.{b}', __package__)
|
||||
client_name_mapper[b] = m.backend
|
||||
|
||||
@@ -14,6 +14,9 @@ class DingTalk(BackendBase):
|
||||
agentid=settings.DINGTALK_AGENTID
|
||||
)
|
||||
|
||||
def send_msg(self, users, msg):
|
||||
def send_msg(self, users, message, subject=None):
|
||||
accounts, __, __ = self.get_accounts(users)
|
||||
return self.dingtalk.send_text(accounts, msg)
|
||||
return self.dingtalk.send_text(accounts, message)
|
||||
|
||||
|
||||
backend = DingTalk
|
||||
|
||||
@@ -8,7 +8,10 @@ class Email(BackendBase):
|
||||
account_field = 'email'
|
||||
is_enable_field_in_settings = 'EMAIL_HOST_USER'
|
||||
|
||||
def send_msg(self, users, subject, message):
|
||||
def send_msg(self, users, message, subject):
|
||||
from_email = settings.EMAIL_FROM or settings.EMAIL_HOST_USER
|
||||
accounts, __, __ = self.get_accounts(users)
|
||||
send_mail(subject, message, from_email, accounts, html_message=message)
|
||||
|
||||
|
||||
backend = Email
|
||||
|
||||
@@ -14,6 +14,9 @@ class FeiShu(BackendBase):
|
||||
app_secret=settings.FEISHU_APP_SECRET
|
||||
)
|
||||
|
||||
def send_msg(self, users, msg):
|
||||
def send_msg(self, users, message, subject=None):
|
||||
accounts, __, __ = self.get_accounts(users)
|
||||
return self.client.send_text(accounts, msg)
|
||||
return self.client.send_text(accounts, message)
|
||||
|
||||
|
||||
backend = FeiShu
|
||||
|
||||
@@ -5,10 +5,13 @@ from .base import BackendBase
|
||||
class SiteMessage(BackendBase):
|
||||
account_field = 'id'
|
||||
|
||||
def send_msg(self, users, subject, message):
|
||||
def send_msg(self, users, message, subject):
|
||||
accounts, __, __ = self.get_accounts(users)
|
||||
Client.send_msg(subject, message, user_ids=accounts)
|
||||
|
||||
@classmethod
|
||||
def is_enable(cls):
|
||||
return True
|
||||
|
||||
|
||||
backend = SiteMessage
|
||||
|
||||
25
apps/notifications/backends/sms.py
Normal file
25
apps/notifications/backends/sms.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from django.conf import settings
|
||||
|
||||
from common.message.backends.sms.alibaba import AlibabaSMS as Client
|
||||
from .base import BackendBase
|
||||
|
||||
|
||||
class SMS(BackendBase):
|
||||
account_field = 'phone'
|
||||
is_enable_field_in_settings = 'SMS_ENABLED'
|
||||
|
||||
def __init__(self):
|
||||
"""
|
||||
暂时只对接阿里,之后再扩展
|
||||
"""
|
||||
self.client = Client(
|
||||
access_key_id=settings.ALIBABA_ACCESS_KEY_ID,
|
||||
access_key_secret=settings.ALIBABA_ACCESS_KEY_SECRET
|
||||
)
|
||||
|
||||
def send_msg(self, users, sign_name: str, template_code: str, template_param: dict):
|
||||
accounts, __, __ = self.get_accounts(users)
|
||||
return self.client.send_sms(accounts, sign_name, template_code, template_param)
|
||||
|
||||
|
||||
backend = SMS
|
||||
@@ -15,6 +15,9 @@ class WeCom(BackendBase):
|
||||
agentid=settings.WECOM_AGENTID
|
||||
)
|
||||
|
||||
def send_msg(self, users, msg):
|
||||
def send_msg(self, users, message, subject=None):
|
||||
accounts, __, __ = self.get_accounts(users)
|
||||
return self.wecom.send_text(accounts, msg)
|
||||
return self.wecom.send_text(accounts, message)
|
||||
|
||||
|
||||
backend = WeCom
|
||||
|
||||
@@ -44,7 +44,7 @@ class Migration(migrations.Migration):
|
||||
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
|
||||
('message_type', models.CharField(max_length=128)),
|
||||
('receive_backends', models.JSONField(default=list)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_msg_subscriptions', to=settings.AUTH_USER_MODEL)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_msg_subscription', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
|
||||
55
apps/notifications/migrations/0002_auto_20210909_1946.py
Normal file
55
apps/notifications/migrations/0002_auto_20210909_1946.py
Normal file
@@ -0,0 +1,55 @@
|
||||
# Generated by Django 3.1.12 on 2021-09-09 11:46
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
def init_user_msg_subscription(apps, schema_editor):
|
||||
UserMsgSubscription = apps.get_model('notifications', 'UserMsgSubscription')
|
||||
User = apps.get_model('users', 'User')
|
||||
|
||||
to_create = []
|
||||
users = User.objects.all()
|
||||
for user in users:
|
||||
receive_backends = []
|
||||
|
||||
receive_backends.append('site_msg')
|
||||
|
||||
if user.email:
|
||||
receive_backends.append('email')
|
||||
|
||||
if user.wecom_id:
|
||||
receive_backends.append('wecom')
|
||||
|
||||
if user.dingtalk_id:
|
||||
receive_backends.append('dingtalk')
|
||||
|
||||
if user.feishu_id:
|
||||
receive_backends.append('feishu')
|
||||
|
||||
to_create.append(UserMsgSubscription(user=user, receive_backends=receive_backends))
|
||||
UserMsgSubscription.objects.bulk_create(to_create)
|
||||
print(f'\n Init user message subscription: {len(to_create)}')
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('notifications', '0001_initial'),
|
||||
('users', '0036_user_feishu_id'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='usermsgsubscription',
|
||||
name='message_type',
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='usermsgsubscription',
|
||||
name='user',
|
||||
field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='user_msg_subscription', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.RunPython(init_user_msg_subscription)
|
||||
]
|
||||
@@ -6,12 +6,11 @@ __all__ = ('SystemMsgSubscription', 'UserMsgSubscription')
|
||||
|
||||
|
||||
class UserMsgSubscription(JMSModel):
|
||||
message_type = models.CharField(max_length=128)
|
||||
user = models.ForeignKey('users.User', related_name='user_msg_subscriptions', on_delete=models.CASCADE)
|
||||
user = models.OneToOneField('users.User', related_name='user_msg_subscription', on_delete=models.CASCADE)
|
||||
receive_backends = models.JSONField(default=list)
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.message_type}'
|
||||
return f'{self.user} subscription: {self.receive_backends}'
|
||||
|
||||
|
||||
class SystemMsgSubscription(JMSModel):
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
from typing import Iterable
|
||||
import traceback
|
||||
from itertools import chain
|
||||
import time
|
||||
|
||||
from django.db.utils import ProgrammingError
|
||||
from celery import shared_task
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from common.utils.timezone import now
|
||||
from common.utils import lazyproperty
|
||||
from users.models import User
|
||||
from notifications.backends import BACKEND
|
||||
from .models import SystemMsgSubscription
|
||||
from .models import SystemMsgSubscription, UserMsgSubscription
|
||||
|
||||
__all__ = ('SystemMessage', 'UserMessage')
|
||||
__all__ = ('SystemMessage', 'UserMessage', 'system_msgs')
|
||||
|
||||
|
||||
system_msgs = []
|
||||
@@ -66,40 +70,81 @@ class Message(metaclass=MessageType):
|
||||
raise NotImplementedError
|
||||
|
||||
def send_msg(self, users: Iterable, backends: Iterable = BACKEND):
|
||||
backends = set(backends)
|
||||
backends.add(BACKEND.SITE_MSG) # 站内信必须发
|
||||
|
||||
for backend in backends:
|
||||
try:
|
||||
backend = BACKEND(backend)
|
||||
if not backend.is_enable:
|
||||
continue
|
||||
|
||||
get_msg_method = getattr(self, f'get_{backend}_msg', self.get_common_msg)
|
||||
msg = get_msg_method()
|
||||
|
||||
try:
|
||||
msg = get_msg_method()
|
||||
except NotImplementedError:
|
||||
continue
|
||||
|
||||
client = backend.client()
|
||||
|
||||
if isinstance(msg, dict):
|
||||
client.send_msg(users, **msg)
|
||||
else:
|
||||
client.send_msg(users, msg)
|
||||
client.send_msg(users, **msg)
|
||||
except:
|
||||
traceback.print_exc()
|
||||
|
||||
def get_common_msg(self) -> str:
|
||||
def send_test_msg(self):
|
||||
from users.models import User
|
||||
users = User.objects.filter(username='admin')
|
||||
self.send_msg(users, [])
|
||||
|
||||
def get_common_msg(self) -> dict:
|
||||
raise NotImplementedError
|
||||
|
||||
def get_dingtalk_msg(self) -> str:
|
||||
def get_text_msg(self) -> dict:
|
||||
return self.common_msg
|
||||
|
||||
def get_html_msg(self) -> dict:
|
||||
return self.common_msg
|
||||
|
||||
@lazyproperty
|
||||
def common_msg(self) -> dict:
|
||||
return self.get_common_msg()
|
||||
|
||||
def get_wecom_msg(self) -> str:
|
||||
return self.get_common_msg()
|
||||
@lazyproperty
|
||||
def text_msg(self) -> dict:
|
||||
return self.get_text_msg()
|
||||
|
||||
@lazyproperty
|
||||
def html_msg(self) -> dict:
|
||||
return self.get_html_msg()
|
||||
|
||||
# --------------------------------------------------------------
|
||||
# 支持不同发送消息的方式定义自己的消息内容,比如有些支持 html 标签
|
||||
def get_dingtalk_msg(self) -> dict:
|
||||
# 钉钉相同的消息一天只能发一次,所以给所有消息添加基于时间的序号,使他们不相同
|
||||
message = self.text_msg['message']
|
||||
suffix = _('\nTime: {}').format(now())
|
||||
|
||||
def get_email_msg(self) -> dict:
|
||||
msg = self.get_common_msg()
|
||||
subject = f'{msg[:80]} ...' if len(msg) >= 80 else msg
|
||||
return {
|
||||
'subject': subject,
|
||||
'message': msg
|
||||
'subject': self.text_msg['subject'],
|
||||
'message': message + suffix
|
||||
}
|
||||
|
||||
def get_wecom_msg(self) -> dict:
|
||||
return self.text_msg
|
||||
|
||||
def get_feishu_msg(self) -> dict:
|
||||
return self.text_msg
|
||||
|
||||
def get_email_msg(self) -> dict:
|
||||
return self.html_msg
|
||||
|
||||
def get_site_msg_msg(self) -> dict:
|
||||
return self.get_email_msg()
|
||||
return self.html_msg
|
||||
|
||||
def get_sms_msg(self) -> dict:
|
||||
return self.text_msg
|
||||
# --------------------------------------------------------------
|
||||
|
||||
|
||||
class SystemMessage(Message):
|
||||
@@ -125,4 +170,16 @@ class SystemMessage(Message):
|
||||
|
||||
|
||||
class UserMessage(Message):
|
||||
pass
|
||||
user: User
|
||||
|
||||
def __init__(self, user):
|
||||
self.user = user
|
||||
|
||||
def publish(self):
|
||||
"""
|
||||
发送消息到每个用户配置的接收方式上
|
||||
"""
|
||||
|
||||
sub = UserMsgSubscription.objects.get(user=self.user)
|
||||
|
||||
self.send_msg([self.user], sub.receive_backends)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from common.drf.serializers import BulkModelSerializer
|
||||
from notifications.models import SystemMsgSubscription
|
||||
from notifications.models import SystemMsgSubscription, UserMsgSubscription
|
||||
|
||||
|
||||
class SystemMsgSubscriptionSerializer(BulkModelSerializer):
|
||||
@@ -27,3 +27,11 @@ class SystemMsgSubscriptionByCategorySerializer(serializers.Serializer):
|
||||
category = serializers.CharField()
|
||||
category_label = serializers.CharField()
|
||||
children = SystemMsgSubscriptionSerializer(many=True)
|
||||
|
||||
|
||||
class UserMsgSubscriptionSerializer(BulkModelSerializer):
|
||||
receive_backends = serializers.ListField(child=serializers.CharField(), read_only=False)
|
||||
|
||||
class Meta:
|
||||
model = UserMsgSubscription
|
||||
fields = ('user_id', 'receive_backends',)
|
||||
|
||||
@@ -6,14 +6,14 @@ from django.utils.functional import LazyObject
|
||||
from django.db.models.signals import post_save
|
||||
from django.db.models.signals import post_migrate
|
||||
from django.dispatch import receiver
|
||||
from django.db.utils import DEFAULT_DB_ALIAS
|
||||
from django.apps import apps as global_apps
|
||||
from django.apps import AppConfig
|
||||
|
||||
from notifications.backends import BACKEND
|
||||
from users.models import User
|
||||
from common.utils.connection import RedisPubSub
|
||||
from common.utils import get_logger
|
||||
from common.decorator import on_transaction_commit
|
||||
from .models import SiteMessage, SystemMsgSubscription
|
||||
from .models import SiteMessage, SystemMsgSubscription, UserMsgSubscription
|
||||
from .notifications import SystemMessage
|
||||
|
||||
|
||||
@@ -82,3 +82,13 @@ def create_system_messages(app_config: AppConfig, **kwargs):
|
||||
logger.info(f'Create SystemMsgSubscription: package={app_config.module.__package__} type={message_type}')
|
||||
except ModuleNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
def on_user_post_save(sender, instance, created, **kwargs):
|
||||
if created:
|
||||
receive_backends = []
|
||||
for backend in BACKEND:
|
||||
if backend.get_account(instance):
|
||||
receive_backends.append(backend)
|
||||
UserMsgSubscription.objects.create(user=instance, receive_backends=receive_backends)
|
||||
|
||||
@@ -2,9 +2,12 @@ from django.db.models import F
|
||||
from django.db import transaction
|
||||
|
||||
from common.utils.timezone import now
|
||||
from common.utils import get_logger
|
||||
from users.models import User
|
||||
from .models import SiteMessage as SiteMessageModel, SiteMessageUsers
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
||||
|
||||
class SiteMessageUtil:
|
||||
|
||||
@@ -14,6 +17,11 @@ class SiteMessageUtil:
|
||||
if not any((user_ids, group_ids, is_broadcast)):
|
||||
raise ValueError('No recipient is specified')
|
||||
|
||||
logger.info(f'Site message send: '
|
||||
f'user_ids={user_ids} '
|
||||
f'group_ids={group_ids} '
|
||||
f'subject={subject} '
|
||||
f'message={message}')
|
||||
with transaction.atomic():
|
||||
site_msg = SiteMessageModel.objects.create(
|
||||
subject=subject, message=message,
|
||||
|
||||
@@ -8,6 +8,7 @@ app_name = 'notifications'
|
||||
|
||||
router = BulkRouter()
|
||||
router.register('system-msg-subscription', api.SystemMsgSubscriptionViewSet, 'system-msg-subscription')
|
||||
router.register('user-msg-subscription', api.UserMsgSubscriptionViewSet, 'user-msg-subscription')
|
||||
router.register('site-message', api.SiteMessageViewSet, 'site-message')
|
||||
|
||||
urlpatterns = [
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import threading
|
||||
import json
|
||||
|
||||
from redis.exceptions import ConnectionError
|
||||
from channels.generic.websocket import JsonWebsocketConsumer
|
||||
|
||||
from common.utils import get_logger
|
||||
from .models import SiteMessage
|
||||
from .site_msg import SiteMessageUtil
|
||||
from .signals_handler import new_site_msg_chan
|
||||
|
||||
@@ -12,13 +11,14 @@ logger = get_logger(__name__)
|
||||
|
||||
|
||||
class SiteMsgWebsocket(JsonWebsocketConsumer):
|
||||
disconnected = False
|
||||
refresh_every_seconds = 10
|
||||
chan = None
|
||||
|
||||
def connect(self):
|
||||
user = self.scope["user"]
|
||||
if user.is_authenticated:
|
||||
self.accept()
|
||||
self.chan = new_site_msg_chan.subscribe()
|
||||
|
||||
thread = threading.Thread(target=self.unread_site_msg_count)
|
||||
thread.start()
|
||||
@@ -48,9 +48,8 @@ class SiteMsgWebsocket(JsonWebsocketConsumer):
|
||||
user_id = str(self.scope["user"].id)
|
||||
self.send_unread_msg_count()
|
||||
|
||||
while not self.disconnected:
|
||||
subscribe = new_site_msg_chan.subscribe()
|
||||
for message in subscribe.listen():
|
||||
try:
|
||||
for message in self.chan.listen():
|
||||
if message['type'] != 'message':
|
||||
continue
|
||||
try:
|
||||
@@ -64,7 +63,10 @@ class SiteMsgWebsocket(JsonWebsocketConsumer):
|
||||
self.send_unread_msg_count()
|
||||
except json.JSONDecoder as e:
|
||||
logger.debug('Decode json error: ', e)
|
||||
except ConnectionError:
|
||||
logger.debug('Redis chan closed')
|
||||
|
||||
def disconnect(self, close_code):
|
||||
self.disconnected = True
|
||||
if self.chan is not None:
|
||||
self.chan.close()
|
||||
self.close()
|
||||
|
||||
@@ -46,7 +46,7 @@ class BaseHost(Host):
|
||||
if host_data.get('username'):
|
||||
self.set_variable('ansible_user', host_data['username'])
|
||||
|
||||
# 添加密码和秘钥
|
||||
# 添加密码和密钥
|
||||
if host_data.get('password'):
|
||||
self.set_variable('ansible_ssh_pass', host_data['password'])
|
||||
if host_data.get('private_key'):
|
||||
|
||||
@@ -159,7 +159,7 @@ class PeriodTaskFormMixin(forms.Form):
|
||||
)
|
||||
interval = forms.IntegerField(
|
||||
required=False, initial=24,
|
||||
help_text=_('Tips: (Units: hour)'), label=_("Cycle perform"),
|
||||
help_text=_('Unit: hour'), label=_("Cycle perform"),
|
||||
)
|
||||
|
||||
def get_initial_for_field(self, field, field_name):
|
||||
|
||||
@@ -17,8 +17,19 @@ class ServerPerformanceMessage(SystemMessage):
|
||||
def __init__(self, msg):
|
||||
self._msg = msg
|
||||
|
||||
def get_common_msg(self):
|
||||
return self._msg
|
||||
def get_html_msg(self) -> dict:
|
||||
subject = self._msg[:80]
|
||||
return {
|
||||
'subject': subject.replace('<br>', '; '),
|
||||
'message': self._msg
|
||||
}
|
||||
|
||||
def get_text_msg(self) -> dict:
|
||||
subject = self._msg[:80]
|
||||
return {
|
||||
'subject': subject.replace('<br>', '; '),
|
||||
'message': self._msg.replace('<br>', '\n')
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def post_insert_to_db(cls, subscription: SystemMsgSubscription):
|
||||
@@ -35,14 +46,14 @@ class ServerPerformanceCheckUtil(object):
|
||||
'max_threshold': False,
|
||||
'alarm_msg_format': _('The terminal is offline: {name}')
|
||||
},
|
||||
'disk_usage': {
|
||||
'disk_used': {
|
||||
'default': 0,
|
||||
'max_threshold': 80,
|
||||
'alarm_msg_format': _(
|
||||
'[Disk] Disk used more than {max_threshold}%: => {value} ({name})'
|
||||
)
|
||||
},
|
||||
'memory_usage': {
|
||||
'memory_used': {
|
||||
'default': 0,
|
||||
'max_threshold': 85,
|
||||
'alarm_msg_format': _(
|
||||
@@ -78,7 +89,6 @@ class ServerPerformanceCheckUtil(object):
|
||||
default = data['default']
|
||||
max_threshold = data['max_threshold']
|
||||
value = getattr(self._terminal.stat, item, default)
|
||||
print(value, max_threshold, self._terminal.name, self._terminal.id)
|
||||
if isinstance(value, bool) and value != max_threshold:
|
||||
return
|
||||
elif isinstance(value, (int, float)) and value < max_threshold:
|
||||
|
||||
@@ -19,6 +19,7 @@ __all__ = [
|
||||
|
||||
|
||||
class OrgManager(models.Manager):
|
||||
|
||||
def all_group_by_org(self):
|
||||
from ..models import Organization
|
||||
orgs = list(Organization.objects.all())
|
||||
|
||||
@@ -25,7 +25,7 @@ class ResourceStatisticsSerializer(serializers.Serializer):
|
||||
app_perms_amount = serializers.IntegerField(required=False)
|
||||
|
||||
|
||||
class OrgSerializer(ModelSerializer):
|
||||
class OrgSerializer(BulkModelSerializer):
|
||||
users = serializers.PrimaryKeyRelatedField(many=True, queryset=User.objects.all(), write_only=True, required=False)
|
||||
admins = serializers.PrimaryKeyRelatedField(many=True, queryset=User.objects.all(), write_only=True, required=False)
|
||||
auditors = serializers.PrimaryKeyRelatedField(many=True, queryset=User.objects.all(), write_only=True, required=False)
|
||||
|
||||
@@ -19,6 +19,7 @@ from common.decorator import on_transaction_commit
|
||||
from common.signals import django_ready
|
||||
from common.utils import get_logger
|
||||
from common.utils.connection import RedisPubSub
|
||||
from assets.models import CommandFilterRule
|
||||
|
||||
|
||||
logger = get_logger(__file__)
|
||||
@@ -91,17 +92,18 @@ def on_org_delete(sender, instance, **kwargs):
|
||||
root_node.delete()
|
||||
|
||||
|
||||
def _remove_users(model, users, org):
|
||||
def _remove_users(model, users, org, user_field_name='users'):
|
||||
with tmp_to_org(org):
|
||||
if not isinstance(users, (tuple, list, set)):
|
||||
users = (users, )
|
||||
|
||||
m2m_model = model.users.through
|
||||
reverse = model.users.reverse
|
||||
user_field = getattr(model, user_field_name)
|
||||
m2m_model = user_field.through
|
||||
reverse = user_field.reverse
|
||||
if reverse:
|
||||
m2m_field_name = model.users.field.m2m_reverse_field_name()
|
||||
m2m_field_name = user_field.field.m2m_reverse_field_name()
|
||||
else:
|
||||
m2m_field_name = model.users.field.m2m_field_name()
|
||||
m2m_field_name = user_field.field.m2m_field_name()
|
||||
relations = m2m_model.objects.filter(**{
|
||||
'user__in': users,
|
||||
f'{m2m_field_name}__org_id': org.id
|
||||
@@ -149,6 +151,8 @@ def _clear_users_from_org(org, users):
|
||||
for m in models:
|
||||
_remove_users(m, users, org)
|
||||
|
||||
_remove_users(CommandFilterRule, users, org, user_field_name='reviewers')
|
||||
|
||||
|
||||
@receiver(m2m_changed, sender=OrganizationMember)
|
||||
def on_org_user_changed(action, instance, reverse, pk_set, **kwargs):
|
||||
|
||||
@@ -16,6 +16,7 @@ class ApplicationPermissionViewSet(BasePermissionViewSet):
|
||||
'name': ['exact'],
|
||||
'category': ['exact'],
|
||||
'type': ['exact', 'in'],
|
||||
'from_ticket': ['exact']
|
||||
}
|
||||
search_fields = ['name', 'category', 'type']
|
||||
custom_filter_fields = BasePermissionViewSet.custom_filter_fields + [
|
||||
|
||||
@@ -19,8 +19,8 @@ class UserGroupGrantedApplicationsApi(CommonApiMixin, ListAPIView):
|
||||
获取用户组直接授权的应用
|
||||
"""
|
||||
permission_classes = (IsOrgAdminOrAppUser,)
|
||||
serializer_class = serializers.ApplicationGrantedSerializer
|
||||
only_fields = serializers.ApplicationGrantedSerializer.Meta.only_fields
|
||||
serializer_class = serializers.AppGrantedSerializer
|
||||
only_fields = serializers.AppGrantedSerializer.Meta.only_fields
|
||||
filterset_fields = ['id', 'name', 'category', 'type', 'comment']
|
||||
search_fields = ['name', 'comment']
|
||||
|
||||
|
||||
@@ -24,8 +24,8 @@ __all__ = [
|
||||
|
||||
|
||||
class AllGrantedApplicationsMixin(CommonApiMixin, ListAPIView):
|
||||
only_fields = serializers.ApplicationGrantedSerializer.Meta.only_fields
|
||||
serializer_class = serializers.ApplicationGrantedSerializer
|
||||
only_fields = serializers.AppGrantedSerializer.Meta.only_fields
|
||||
serializer_class = serializers.AppGrantedSerializer
|
||||
filterset_fields = {
|
||||
'id': ['exact'],
|
||||
'name': ['exact'],
|
||||
|
||||
4
apps/perms/const.py
Normal file
4
apps/perms/const.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
from django.db.models import TextChoices
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
@@ -21,8 +21,8 @@ class PermissionBaseFilter(BaseFilterSet):
|
||||
|
||||
class Meta:
|
||||
fields = (
|
||||
'user_id', 'username', 'system_user_id', 'system_user', 'user_group_id',
|
||||
'user_group', 'name', 'all', 'is_valid',
|
||||
'user_id', 'username', 'system_user_id', 'system_user',
|
||||
'user_group_id', 'user_group', 'name', 'all', 'is_valid',
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -118,7 +118,7 @@ class AssetPermissionFilter(PermissionBaseFilter):
|
||||
fields = (
|
||||
'user_id', 'username', 'system_user_id', 'system_user', 'user_group_id',
|
||||
'user_group', 'node_id', 'node', 'asset_id', 'hostname', 'ip', 'name',
|
||||
'all', 'asset_id', 'is_valid', 'is_effective',
|
||||
'all', 'asset_id', 'is_valid', 'is_effective', 'from_ticket'
|
||||
)
|
||||
|
||||
@property
|
||||
|
||||
23
apps/perms/migrations/0019_auto_20210906_1044.py
Normal file
23
apps/perms/migrations/0019_auto_20210906_1044.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 3.1.12 on 2021-09-06 02:44
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('perms', '0018_auto_20210208_1515'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='applicationpermission',
|
||||
name='from_ticket',
|
||||
field=models.BooleanField(default=False, verbose_name='From ticket'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='assetpermission',
|
||||
name='from_ticket',
|
||||
field=models.BooleanField(default=False, verbose_name='From ticket'),
|
||||
),
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user