mirror of
https://github.com/jumpserver/jumpserver.git
synced 2025-12-17 17:42:37 +00:00
Compare commits
123 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
692dd6c8c4 | ||
|
|
15992ad5b3 | ||
|
|
072c3155ca | ||
|
|
9cb5985947 | ||
|
|
0fd2f18240 | ||
|
|
9ca8ab218c | ||
|
|
fcd8356e90 | ||
|
|
64d093e677 | ||
|
|
11493b9f3d | ||
|
|
5a9c91d9dd | ||
|
|
25dcb9510c | ||
|
|
a38a1868ca | ||
|
|
bec9b97092 | ||
|
|
d5b9596e7d | ||
|
|
af85d551ad | ||
|
|
ab8c57894e | ||
|
|
0e0c9275bd | ||
|
|
21b4a8600c | ||
|
|
4cf5573c36 | ||
|
|
962ea67b84 | ||
|
|
31720c9dcc | ||
|
|
54fe4835f6 | ||
|
|
91649a3908 | ||
|
|
0a242c3e81 | ||
|
|
25d1b3334f | ||
|
|
ffde306a04 | ||
|
|
f1e29a91f7 | ||
|
|
ec2b3b4cda | ||
|
|
1a9d9e4145 | ||
|
|
a14f121fad | ||
|
|
a25da8d479 | ||
|
|
15fe7f810b | ||
|
|
f6a4253936 | ||
|
|
c3c5801d2e | ||
|
|
f0d564180c | ||
|
|
8ee7230ead | ||
|
|
90f03dda62 | ||
|
|
4e7a5d8d4f | ||
|
|
2ed0927b18 | ||
|
|
e98235ca27 | ||
|
|
1b052a8729 | ||
|
|
34b188bbe7 | ||
|
|
3e6cd1c1d3 | ||
|
|
f8e248f0af | ||
|
|
b331730422 | ||
|
|
de3865fa1d | ||
|
|
1bc913ab13 | ||
|
|
2f11a70341 | ||
|
|
c277aec561 | ||
|
|
2a53a20808 | ||
|
|
674ad40f67 | ||
|
|
78089e01a3 | ||
|
|
1b71350199 | ||
|
|
5d08438dad | ||
|
|
31ba0564e4 | ||
|
|
ea5b7cd921 | ||
|
|
3e541162e3 | ||
|
|
6e19384231 | ||
|
|
19903c80c3 | ||
|
|
070af8c491 | ||
|
|
0fca33d874 | ||
|
|
08fdc57543 | ||
|
|
bb60d2a1d9 | ||
|
|
0014bd0cb9 | ||
|
|
9488c8bd97 | ||
|
|
1f30d459ae | ||
|
|
4e933fc1ca | ||
|
|
c0f3a1f64a | ||
|
|
0f70f5eccf | ||
|
|
eef942c155 | ||
|
|
061592fa6b | ||
|
|
c7a02586c1 | ||
|
|
ddcd4ebbfc | ||
|
|
9550ea62fb | ||
|
|
abcb589658 | ||
|
|
1bb366ad94 | ||
|
|
a5df7738f6 | ||
|
|
da858c8998 | ||
|
|
724a8f6324 | ||
|
|
437df9a533 | ||
|
|
f2c70d0bba | ||
|
|
ea913a5b6e | ||
|
|
c0cd8878dc | ||
|
|
15e995ade6 | ||
|
|
cadf42f3fa | ||
|
|
f588093cd3 | ||
|
|
7c12f8f462 | ||
|
|
6f5a92c21f | ||
|
|
17a76994dc | ||
|
|
39d793bc47 | ||
|
|
c3eafbee8c | ||
|
|
10f99be100 | ||
|
|
8eb6cfa9c9 | ||
|
|
f430c9e435 | ||
|
|
10c428a432 | ||
|
|
a30c603bdc | ||
|
|
39a75074af | ||
|
|
452ed2baf1 | ||
|
|
8c7240193a | ||
|
|
b622aca9af | ||
|
|
ebf1a9d5e2 | ||
|
|
69f49f7776 | ||
|
|
fcd684e2db | ||
|
|
afcb6bd77c | ||
|
|
1c264399bb | ||
|
|
872e2546e9 | ||
|
|
8f347eee4d | ||
|
|
fa886b90c2 | ||
|
|
caf312c5be | ||
|
|
ac6168a06c | ||
|
|
eba9f2325a | ||
|
|
b46e772d09 | ||
|
|
183df82a75 | ||
|
|
98c91d0f18 | ||
|
|
e17d875206 | ||
|
|
4b1e84ed8a | ||
|
|
71ee33e3be | ||
|
|
5dd24f5cf9 | ||
|
|
2b6e818943 | ||
|
|
fdcda83c93 | ||
|
|
6e3369c944 | ||
|
|
d7e432a851 | ||
|
|
c0a153d13a |
3
.github/ISSUE_TEMPLATE.md
vendored
3
.github/ISSUE_TEMPLATE.md
vendored
@@ -4,6 +4,9 @@
|
||||
##### 使用版本
|
||||
[请提供你使用的JumpServer版本 如 2.0.1 注: 1.4及以下版本不再提供支持]
|
||||
|
||||
##### 使用浏览器版本
|
||||
[请提供你使用的浏览器版本 如 Chrome 84.0.4147.105 ]
|
||||
|
||||
##### 问题复现步骤
|
||||
1. [步骤1]
|
||||
2. [步骤2]
|
||||
|
||||
12
.github/workflows/jms-generic-action-handler.yml
vendored
Normal file
12
.github/workflows/jms-generic-action-handler.yml
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
on: [push, pull_request, release]
|
||||
|
||||
name: JumpServer repos generic handler
|
||||
|
||||
jobs:
|
||||
generic_handler:
|
||||
name: Run generic handler
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: jumpserver/action-generic-handler@master
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.PRIVATE_TOKEN }}
|
||||
32
Dockerfile
32
Dockerfile
@@ -1,19 +1,33 @@
|
||||
FROM registry.fit2cloud.com/public/python:v3
|
||||
FROM registry.fit2cloud.com/public/python:v3 as stage-build
|
||||
MAINTAINER Jumpserver Team <ibuler@qq.com>
|
||||
ARG VERSION
|
||||
ENV VERSION=$VERSION
|
||||
|
||||
WORKDIR /opt/jumpserver
|
||||
ADD . .
|
||||
RUN cd utils && bash -ixeu build.sh
|
||||
|
||||
|
||||
FROM registry.fit2cloud.com/public/python:v3
|
||||
ARG PIP_MIRROR=https://pypi.douban.com/simple
|
||||
ENV PIP_MIRROR=$PIP_MIRROR
|
||||
ARG MYSQL_MIRROR=https://mirrors.tuna.tsinghua.edu.cn/mysql/yum/mysql57-community-el6/
|
||||
ENV MYSQL_MIRROR=$MYSQL_MIRROR
|
||||
|
||||
WORKDIR /opt/jumpserver
|
||||
|
||||
COPY ./requirements ./requirements
|
||||
RUN useradd jumpserver
|
||||
|
||||
COPY ./requirements /tmp/requirements
|
||||
|
||||
RUN yum -y install epel-release && \
|
||||
echo -e "[mysql]\nname=mysql\nbaseurl=https://mirrors.tuna.tsinghua.edu.cn/mysql/yum/mysql57-community-el6/\ngpgcheck=0\nenabled=1" > /etc/yum.repos.d/mysql.repo
|
||||
RUN cd /tmp/requirements && yum -y install $(cat rpm_requirements.txt)
|
||||
RUN cd /tmp/requirements && pip install --upgrade pip setuptools && pip install wheel && \
|
||||
pip install -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt || pip install -r requirements.txt
|
||||
echo -e "[mysql]\nname=mysql\nbaseurl=${MYSQL_MIRROR}\ngpgcheck=0\nenabled=1" > /etc/yum.repos.d/mysql.repo
|
||||
RUN yum -y install $(cat requirements/rpm_requirements.txt)
|
||||
RUN pip install --upgrade pip setuptools wheel -i ${PIP_MIRROR} && \
|
||||
pip config set global.index-url ${PIP_MIRROR}
|
||||
RUN pip install -r requirements/requirements.txt || pip install -r requirements/requirements.txt
|
||||
|
||||
COPY --from=stage-build /opt/jumpserver/release/jumpserver /opt/jumpserver
|
||||
RUN mkdir -p /root/.ssh/ && echo -e "Host *\n\tStrictHostKeyChecking no\n\tUserKnownHostsFile /dev/null" > /root/.ssh/config
|
||||
|
||||
COPY . /opt/jumpserver
|
||||
RUN echo > config.yml
|
||||
VOLUME /opt/jumpserver/data
|
||||
VOLUME /opt/jumpserver/logs
|
||||
|
||||
@@ -16,8 +16,8 @@ JumpServer 采纳分布式架构,支持多机房跨区域部署,支持横向
|
||||
|
||||
## 特色优势
|
||||
|
||||
- 开源: 零门槛,线上快速获取和安装, 修复版本视情况而定;
|
||||
, 修复版本视情况而定- 分布式: 轻松支持大规模并发访问;
|
||||
- 开源: 零门槛,线上快速获取和安装;
|
||||
- 分布式: 轻松支持大规模并发访问;
|
||||
- 无插件: 仅需浏览器,极致的 Web Terminal 使用体验;
|
||||
- 多云支持: 一套系统,同时管理不同云上面的资产;
|
||||
- 云端存储: 审计录像云端存储,永不丢失;
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
from .remote_app import *
|
||||
from .database_app import *
|
||||
from .k8s_app import *
|
||||
|
||||
20
apps/applications/api/k8s_app.py
Normal file
20
apps/applications/api/k8s_app.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# coding: utf-8
|
||||
#
|
||||
|
||||
from orgs.mixins.api import OrgBulkModelViewSet
|
||||
|
||||
from .. import models
|
||||
from .. import serializers
|
||||
from ..hands import IsOrgAdminOrAppUser
|
||||
|
||||
__all__ = [
|
||||
'K8sAppViewSet',
|
||||
]
|
||||
|
||||
|
||||
class K8sAppViewSet(OrgBulkModelViewSet):
|
||||
model = models.K8sApp
|
||||
filter_fields = ('name',)
|
||||
search_fields = filter_fields
|
||||
permission_classes = (IsOrgAdminOrAppUser,)
|
||||
serializer_class = serializers.K8sAppSerializer
|
||||
@@ -23,6 +23,7 @@ REMOTE_APP_TYPE_CHROME_FIELDS = [
|
||||
REMOTE_APP_TYPE_MYSQL_WORKBENCH_FIELDS = [
|
||||
{'name': 'mysql_workbench_ip'},
|
||||
{'name': 'mysql_workbench_name'},
|
||||
{'name': 'mysql_workbench_port'},
|
||||
{'name': 'mysql_workbench_username'},
|
||||
{'name': 'mysql_workbench_password', 'write_only': True}
|
||||
]
|
||||
|
||||
34
apps/applications/migrations/0005_k8sapp.py
Normal file
34
apps/applications/migrations/0005_k8sapp.py
Normal file
@@ -0,0 +1,34 @@
|
||||
# Generated by Django 2.2.13 on 2020-08-07 07:13
|
||||
|
||||
from django.db import migrations, models
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('applications', '0004_auto_20191218_1705'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='K8sApp',
|
||||
fields=[
|
||||
('created_by', models.CharField(blank=True, max_length=32, null=True, verbose_name='Created by')),
|
||||
('updated_by', models.CharField(blank=True, max_length=32, null=True, verbose_name='Updated by')),
|
||||
('date_created', models.DateTimeField(auto_now_add=True, null=True, verbose_name='Date created')),
|
||||
('date_updated', models.DateTimeField(auto_now=True, verbose_name='Date updated')),
|
||||
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
|
||||
('org_id', models.CharField(blank=True, db_index=True, default='', max_length=36, verbose_name='Organization')),
|
||||
('name', models.CharField(max_length=128, verbose_name='Name')),
|
||||
('type', models.CharField(choices=[('k8s', 'Kubernetes')], default='k8s', max_length=128, verbose_name='Type')),
|
||||
('cluster', models.CharField(max_length=1024, verbose_name='Cluster')),
|
||||
('comment', models.TextField(blank=True, default='', max_length=128, verbose_name='Comment')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'KubernetesApp',
|
||||
'ordering': ('name',),
|
||||
'unique_together': {('org_id', 'name')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,2 +1,3 @@
|
||||
from .remote_app import *
|
||||
from .database_app import *
|
||||
from .k8s_app import *
|
||||
|
||||
27
apps/applications/models/k8s_app.py
Normal file
27
apps/applications/models/k8s_app.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from common.db import models
|
||||
from orgs.mixins.models import OrgModelMixin
|
||||
|
||||
|
||||
class K8sApp(OrgModelMixin, models.JMSModel):
|
||||
class TYPE(models.ChoiceSet):
|
||||
K8S = 'k8s', _('Kubernetes')
|
||||
|
||||
name = models.CharField(max_length=128, verbose_name=_('Name'))
|
||||
type = models.CharField(
|
||||
default=TYPE.K8S, choices=TYPE.choices,
|
||||
max_length=128, verbose_name=_('Type')
|
||||
)
|
||||
cluster = models.CharField(max_length=1024, verbose_name=_('Cluster'))
|
||||
comment = models.TextField(
|
||||
max_length=128, default='', blank=True, verbose_name=_('Comment')
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class Meta:
|
||||
unique_together = [('org_id', 'name'), ]
|
||||
verbose_name = _('KubernetesApp')
|
||||
ordering = ('name', )
|
||||
@@ -1,2 +1,3 @@
|
||||
from .remote_app import *
|
||||
from .database_app import *
|
||||
from .k8s_app import *
|
||||
|
||||
22
apps/applications/serializers/k8s_app.py
Normal file
22
apps/applications/serializers/k8s_app.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from orgs.mixins.serializers import BulkOrgResourceModelSerializer
|
||||
from .. import models
|
||||
|
||||
__all__ = [
|
||||
'K8sAppSerializer',
|
||||
]
|
||||
|
||||
|
||||
class K8sAppSerializer(BulkOrgResourceModelSerializer):
|
||||
type_display = serializers.CharField(source='get_type_display', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.K8sApp
|
||||
fields = [
|
||||
'id', 'name', 'type', 'type_display', 'comment', 'created_by',
|
||||
'date_created', 'date_updated', 'cluster'
|
||||
]
|
||||
read_only_fields = [
|
||||
'id', 'created_by', 'date_created', 'date_updated',
|
||||
]
|
||||
@@ -12,6 +12,7 @@ app_name = 'applications'
|
||||
router = BulkRouter()
|
||||
router.register(r'remote-apps', api.RemoteAppViewSet, 'remote-app')
|
||||
router.register(r'database-apps', api.DatabaseAppViewSet, 'database-app')
|
||||
router.register(r'k8s-apps', api.K8sAppViewSet, 'k8s-app')
|
||||
|
||||
urlpatterns = [
|
||||
path('remote-apps/<uuid:pk>/connection-info/', api.RemoteAppConnectionInfoApi.as_view(), name='remote-app-connection-info'),
|
||||
|
||||
@@ -14,7 +14,7 @@ from .. import serializers
|
||||
from ..tasks import (
|
||||
update_asset_hardware_info_manual, test_asset_connectivity_manual
|
||||
)
|
||||
from ..filters import AssetByNodeFilterBackend, LabelFilterBackend
|
||||
from ..filters import AssetByNodeFilterBackend, LabelFilterBackend, IpInFilterBackend
|
||||
|
||||
|
||||
logger = get_logger(__file__)
|
||||
@@ -32,7 +32,7 @@ class AssetViewSet(OrgBulkModelViewSet):
|
||||
model = Asset
|
||||
filter_fields = (
|
||||
"hostname", "ip", "systemuser__id", "admin_user__id", "platform__base",
|
||||
"is_active"
|
||||
"is_active", 'ip'
|
||||
)
|
||||
search_fields = ("hostname", "ip")
|
||||
ordering_fields = ("hostname", "ip", "port", "cpu_cores")
|
||||
@@ -41,7 +41,7 @@ class AssetViewSet(OrgBulkModelViewSet):
|
||||
'display': serializers.AssetDisplaySerializer,
|
||||
}
|
||||
permission_classes = (IsOrgAdminOrAppUser,)
|
||||
extra_filter_backends = [AssetByNodeFilterBackend, LabelFilterBackend]
|
||||
extra_filter_backends = [AssetByNodeFilterBackend, LabelFilterBackend, IpInFilterBackend]
|
||||
|
||||
def set_assets_node(self, assets):
|
||||
if not isinstance(assets, list):
|
||||
|
||||
@@ -18,5 +18,5 @@ class GatheredUserViewSet(OrgModelViewSet):
|
||||
permission_classes = [IsOrgAdmin]
|
||||
extra_filter_backends = [AssetRelatedByNodeFilterBackend]
|
||||
|
||||
filter_fields = ['asset', 'username', 'present', 'asset__ip', 'asset__hostname']
|
||||
filter_fields = ['asset', 'username', 'present', 'asset__ip', 'asset__hostname', 'asset_id']
|
||||
search_fields = ['username', 'asset__ip', 'asset__hostname']
|
||||
|
||||
@@ -28,7 +28,7 @@ class SystemUserViewSet(OrgBulkModelViewSet):
|
||||
System user api set, for add,delete,update,list,retrieve resource
|
||||
"""
|
||||
model = SystemUser
|
||||
filter_fields = ("name", "username")
|
||||
filter_fields = ("name", "username", "protocol")
|
||||
search_fields = filter_fields
|
||||
serializer_class = serializers.SystemUserSerializer
|
||||
serializer_classes = {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
|
||||
import coreapi
|
||||
from rest_framework.compat import coreapi, coreschema
|
||||
from rest_framework import filters
|
||||
from django.db.models import Q
|
||||
|
||||
@@ -117,3 +117,23 @@ class AssetRelatedByNodeFilterBackend(AssetByNodeFilterBackend):
|
||||
def perform_query(pattern, queryset):
|
||||
return queryset.filter(asset__nodes__key__regex=pattern).distinct()
|
||||
|
||||
|
||||
class IpInFilterBackend(filters.BaseFilterBackend):
|
||||
def filter_queryset(self, request, queryset, view):
|
||||
ips = request.query_params.get('ips')
|
||||
if not ips:
|
||||
return queryset
|
||||
ip_list = [i.strip() for i in ips.split(',')]
|
||||
queryset = queryset.filter(ip__in=ip_list)
|
||||
return queryset
|
||||
|
||||
def get_schema_fields(self, view):
|
||||
return [
|
||||
coreapi.Field(
|
||||
name='ips', location='query', required=False, type='string',
|
||||
schema=coreschema.String(
|
||||
title='ips',
|
||||
description='ip in filter'
|
||||
)
|
||||
)
|
||||
]
|
||||
|
||||
18
apps/assets/migrations/0050_auto_20200711_1740.py
Normal file
18
apps/assets/migrations/0050_auto_20200711_1740.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 2.2.10 on 2020-07-11 09:40
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('assets', '0049_systemuser_sftp_root'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='asset',
|
||||
name='created_by',
|
||||
field=models.CharField(blank=True, max_length=128, null=True, verbose_name='Created by'),
|
||||
),
|
||||
]
|
||||
22
apps/assets/migrations/0051_auto_20200713_1143.py
Normal file
22
apps/assets/migrations/0051_auto_20200713_1143.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 2.2.10 on 2020-07-13 03:43
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('assets', '0050_auto_20200711_1740'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='domain',
|
||||
name='name',
|
||||
field=models.CharField(max_length=128, verbose_name='Name'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='domain',
|
||||
unique_together={('org_id', 'name')},
|
||||
),
|
||||
]
|
||||
22
apps/assets/migrations/0052_auto_20200715_1535.py
Normal file
22
apps/assets/migrations/0052_auto_20200715_1535.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 2.2.10 on 2020-07-15 07:35
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('assets', '0051_auto_20200713_1143'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='commandfilter',
|
||||
name='name',
|
||||
field=models.CharField(max_length=64, verbose_name='Name'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='commandfilter',
|
||||
unique_together={('org_id', 'name')},
|
||||
),
|
||||
]
|
||||
34
apps/assets/migrations/0053_auto_20200723_1232.py
Normal file
34
apps/assets/migrations/0053_auto_20200723_1232.py
Normal file
@@ -0,0 +1,34 @@
|
||||
# Generated by Django 2.2.10 on 2020-07-23 04:32
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('assets', '0052_auto_20200715_1535'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='adminuser',
|
||||
name='username',
|
||||
field=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'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='authbook',
|
||||
name='username',
|
||||
field=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'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='gateway',
|
||||
name='username',
|
||||
field=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'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='systemuser',
|
||||
name='username',
|
||||
field=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'),
|
||||
),
|
||||
]
|
||||
23
apps/assets/migrations/0054_auto_20200807_1032.py
Normal file
23
apps/assets/migrations/0054_auto_20200807_1032.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 2.2.13 on 2020-08-07 02:32
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('assets', '0053_auto_20200723_1232'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='systemuser',
|
||||
name='token',
|
||||
field=models.TextField(default='', verbose_name='Token'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='systemuser',
|
||||
name='protocol',
|
||||
field=models.CharField(choices=[('ssh', 'ssh'), ('rdp', 'rdp'), ('telnet', 'telnet'), ('vnc', 'vnc'), ('mysql', 'mysql'), ('k8s', 'k8s')], default='ssh', max_length=16, verbose_name='Protocol'),
|
||||
),
|
||||
]
|
||||
23
apps/assets/migrations/0055_auto_20200811_1845.py
Normal file
23
apps/assets/migrations/0055_auto_20200811_1845.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 2.2.13 on 2020-08-11 10:45
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('assets', '0054_auto_20200807_1032'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='systemuser',
|
||||
name='home',
|
||||
field=models.CharField(blank=True, default='', max_length=4096, verbose_name='Home'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='systemuser',
|
||||
name='system_groups',
|
||||
field=models.CharField(blank=True, default='', max_length=4096, verbose_name='System groups'),
|
||||
),
|
||||
]
|
||||
@@ -221,7 +221,7 @@ class Asset(ProtocolsMixin, NodesRelationMixin, OrgModelMixin):
|
||||
hostname_raw = models.CharField(max_length=128, blank=True, null=True, verbose_name=_('Hostname raw'))
|
||||
|
||||
labels = models.ManyToManyField('assets.Label', blank=True, related_name='assets', verbose_name=_("Labels"))
|
||||
created_by = models.CharField(max_length=32, null=True, blank=True, verbose_name=_('Created by'))
|
||||
created_by = models.CharField(max_length=128, null=True, blank=True, verbose_name=_('Created by'))
|
||||
date_created = models.DateTimeField(auto_now_add=True, null=True, blank=True, verbose_name=_('Date created'))
|
||||
comment = models.TextField(max_length=128, default='', blank=True, verbose_name=_('Comment'))
|
||||
|
||||
@@ -244,10 +244,6 @@ class Asset(ProtocolsMixin, NodesRelationMixin, OrgModelMixin):
|
||||
def platform_base(self):
|
||||
return self.platform.base
|
||||
|
||||
@lazyproperty
|
||||
def admin_user_display(self):
|
||||
return self.admin_user.name
|
||||
|
||||
@lazyproperty
|
||||
def admin_user_username(self):
|
||||
"""求可连接性时,直接用用户名去取,避免再查一次admin user
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
|
||||
from django.db import models, transaction
|
||||
from django.db.models import Max
|
||||
from django.core.cache import cache
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from orgs.mixins.models import OrgManager
|
||||
@@ -59,19 +58,17 @@ class AuthBook(BaseUser):
|
||||
"""
|
||||
username = kwargs['username']
|
||||
asset = kwargs['asset']
|
||||
key_lock = 'KEY_LOCK_CREATE_AUTH_BOOK_{}_{}'.format(username, asset.id)
|
||||
with cache.lock(key_lock):
|
||||
with transaction.atomic():
|
||||
cls.objects.filter(
|
||||
username=username, asset=asset, is_latest=True
|
||||
).update(is_latest=False)
|
||||
max_version = cls.get_max_version(username, asset)
|
||||
kwargs.update({
|
||||
'version': max_version + 1,
|
||||
'is_latest': True
|
||||
})
|
||||
obj = cls.objects.create(**kwargs)
|
||||
return obj
|
||||
with transaction.atomic():
|
||||
# 使用select_for_update限制并发创建相同的username、asset条目
|
||||
instances = cls.objects.select_for_update().filter(username=username, asset=asset)
|
||||
instances.filter(is_latest=True).update(is_latest=False)
|
||||
max_version = cls.get_max_version(username, asset)
|
||||
kwargs.update({
|
||||
'version': max_version + 1,
|
||||
'is_latest': True
|
||||
})
|
||||
obj = cls.objects.create(**kwargs)
|
||||
return obj
|
||||
|
||||
@property
|
||||
def connectivity(self):
|
||||
|
||||
@@ -230,7 +230,7 @@ class AuthMixin:
|
||||
class BaseUser(OrgModelMixin, AuthMixin, ConnectivityMixin):
|
||||
id = models.UUIDField(default=uuid.uuid4, primary_key=True)
|
||||
name = models.CharField(max_length=128, verbose_name=_('Name'))
|
||||
username = models.CharField(max_length=32, blank=True, verbose_name=_('Username'), validators=[alphanumeric], db_index=True)
|
||||
username = models.CharField(max_length=128, blank=True, verbose_name=_('Username'), validators=[alphanumeric], db_index=True)
|
||||
password = fields.EncryptCharField(max_length=256, blank=True, null=True, verbose_name=_('Password'))
|
||||
private_key = fields.EncryptTextField(blank=True, null=True, verbose_name=_('SSH private key'))
|
||||
public_key = fields.EncryptTextField(blank=True, null=True, verbose_name=_('SSH public key'))
|
||||
|
||||
@@ -18,7 +18,7 @@ __all__ = [
|
||||
|
||||
class CommandFilter(OrgModelMixin):
|
||||
id = models.UUIDField(default=uuid.uuid4, primary_key=True)
|
||||
name = models.CharField(max_length=64, unique=True, verbose_name=_("Name"))
|
||||
name = models.CharField(max_length=64, verbose_name=_("Name"))
|
||||
is_active = models.BooleanField(default=True, verbose_name=_('Is active'))
|
||||
comment = models.TextField(blank=True, default='', verbose_name=_("Comment"))
|
||||
date_created = models.DateTimeField(auto_now_add=True)
|
||||
@@ -29,6 +29,7 @@ class CommandFilter(OrgModelMixin):
|
||||
return self.name
|
||||
|
||||
class Meta:
|
||||
unique_together = [('org_id', 'name')]
|
||||
verbose_name = _("Command filter")
|
||||
|
||||
|
||||
|
||||
@@ -17,13 +17,14 @@ __all__ = ['Domain', 'Gateway']
|
||||
|
||||
class Domain(OrgModelMixin):
|
||||
id = models.UUIDField(default=uuid.uuid4, primary_key=True)
|
||||
name = models.CharField(max_length=128, unique=True, verbose_name=_('Name'))
|
||||
name = models.CharField(max_length=128, verbose_name=_('Name'))
|
||||
comment = models.TextField(blank=True, verbose_name=_('Comment'))
|
||||
date_created = models.DateTimeField(auto_now_add=True, null=True,
|
||||
verbose_name=_('Date created'))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Domain")
|
||||
unique_together = [('org_id', 'name')]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@@ -199,6 +199,20 @@ class FamilyMixin:
|
||||
)
|
||||
return child
|
||||
|
||||
def get_or_create_child(self, value, _id=None):
|
||||
"""
|
||||
:return: Node, bool (created)
|
||||
"""
|
||||
children = self.get_children()
|
||||
exist = children.filter(value=value).exists()
|
||||
if exist:
|
||||
child = children.filter(value=value).first()
|
||||
created = False
|
||||
else:
|
||||
child = self.create_child(value, _id)
|
||||
created = True
|
||||
return child, created
|
||||
|
||||
def get_next_child_key(self):
|
||||
mark = self.child_mark
|
||||
self.child_mark += 1
|
||||
|
||||
@@ -9,6 +9,7 @@ from django.utils.translation import ugettext_lazy as _
|
||||
from django.core.validators import MinValueValidator, MaxValueValidator
|
||||
|
||||
from common.utils import signer
|
||||
from common.fields.model import JsonListCharField
|
||||
from .base import BaseUser
|
||||
from .asset import Asset
|
||||
|
||||
@@ -91,12 +92,14 @@ class SystemUser(BaseUser):
|
||||
PROTOCOL_TELNET = 'telnet'
|
||||
PROTOCOL_VNC = 'vnc'
|
||||
PROTOCOL_MYSQL = 'mysql'
|
||||
PROTOCOL_K8S = 'k8s'
|
||||
PROTOCOL_CHOICES = (
|
||||
(PROTOCOL_SSH, 'ssh'),
|
||||
(PROTOCOL_RDP, 'rdp'),
|
||||
(PROTOCOL_TELNET, 'telnet'),
|
||||
(PROTOCOL_VNC, 'vnc'),
|
||||
(PROTOCOL_MYSQL, 'mysql'),
|
||||
(PROTOCOL_K8S, 'k8s'),
|
||||
)
|
||||
|
||||
LOGIN_AUTO = 'auto'
|
||||
@@ -118,6 +121,9 @@ class SystemUser(BaseUser):
|
||||
login_mode = models.CharField(choices=LOGIN_MODE_CHOICES, default=LOGIN_AUTO, max_length=10, verbose_name=_('Login mode'))
|
||||
cmd_filters = models.ManyToManyField('CommandFilter', related_name='system_users', verbose_name=_("Command filter"), blank=True)
|
||||
sftp_root = models.CharField(default='tmp', max_length=128, verbose_name=_("SFTP Root"))
|
||||
token = models.TextField(default='', verbose_name=_('Token'))
|
||||
home = models.CharField(max_length=4096, default='', verbose_name=_('Home'), blank=True)
|
||||
system_groups = models.CharField(default='', max_length=4096, verbose_name=_('System groups'), blank=True)
|
||||
_prefer = 'system_user'
|
||||
|
||||
def __str__(self):
|
||||
|
||||
@@ -67,6 +67,9 @@ class AssetSerializer(BulkOrgResourceModelSerializer):
|
||||
slug_field='name', queryset=Platform.objects.all(), label=_("Platform")
|
||||
)
|
||||
protocols = ProtocolsField(label=_('Protocols'), required=False)
|
||||
domain_display = serializers.ReadOnlyField(source='domain.name')
|
||||
admin_user_display = serializers.ReadOnlyField(source='admin_user.name')
|
||||
|
||||
"""
|
||||
资产的数据结构
|
||||
"""
|
||||
@@ -82,7 +85,7 @@ class AssetSerializer(BulkOrgResourceModelSerializer):
|
||||
'created_by', 'date_created', 'hardware_info',
|
||||
]
|
||||
fields_fk = [
|
||||
'admin_user', 'admin_user_display', 'domain', 'platform'
|
||||
'admin_user', 'admin_user_display', 'domain', 'domain_display', 'platform'
|
||||
]
|
||||
fk_only_fields = {
|
||||
'platform': ['name']
|
||||
|
||||
@@ -33,13 +33,15 @@ class SystemUserSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
|
||||
'login_mode', 'login_mode_display',
|
||||
'priority', 'username_same_with_user',
|
||||
'auto_push', 'cmd_filters', 'sudo', 'shell', 'comment',
|
||||
'auto_generate_key', 'sftp_root',
|
||||
'assets_amount', 'date_created', 'created_by'
|
||||
'auto_generate_key', 'sftp_root', 'token',
|
||||
'assets_amount', 'date_created', 'created_by',
|
||||
'home', 'system_groups'
|
||||
]
|
||||
extra_kwargs = {
|
||||
'password': {"write_only": True},
|
||||
'public_key': {"write_only": True},
|
||||
'private_key': {"write_only": True},
|
||||
'token': {"write_only": True},
|
||||
'nodes_amount': {'label': _('Node')},
|
||||
'assets_amount': {'label': _('Asset')},
|
||||
'login_mode_display': {'label': _('Login mode display')},
|
||||
@@ -143,13 +145,14 @@ class SystemUserSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
|
||||
|
||||
|
||||
class SystemUserListSerializer(SystemUserSerializer):
|
||||
|
||||
class Meta(SystemUserSerializer.Meta):
|
||||
fields = [
|
||||
'id', 'name', 'username', 'protocol',
|
||||
'login_mode', 'login_mode_display',
|
||||
'priority', "username_same_with_user",
|
||||
'auto_push', 'sudo', 'shell', 'comment',
|
||||
"assets_amount",
|
||||
"assets_amount", 'home', 'system_groups',
|
||||
'auto_generate_key',
|
||||
'sftp_root',
|
||||
]
|
||||
@@ -169,7 +172,7 @@ class SystemUserWithAuthInfoSerializer(SystemUserSerializer):
|
||||
'login_mode', 'login_mode_display',
|
||||
'priority', 'username_same_with_user',
|
||||
'auto_push', 'sudo', 'shell', 'comment',
|
||||
'auto_generate_key', 'sftp_root',
|
||||
'auto_generate_key', 'sftp_root', 'token'
|
||||
]
|
||||
extra_kwargs = {
|
||||
'nodes_amount': {'label': _('Node')},
|
||||
|
||||
@@ -185,7 +185,9 @@ def on_asset_nodes_add(sender, instance=None, action='', model=None, pk_set=None
|
||||
|
||||
system_users_assets = defaultdict(set)
|
||||
for system_user in system_users:
|
||||
system_users_assets[system_user].update(set(assets))
|
||||
assets_has_set = system_user.assets.all().filter(id__in=assets).values_list('id', flat=True)
|
||||
assets_remain = set(assets) - set(assets_has_set)
|
||||
system_users_assets[system_user].update(assets_remain)
|
||||
for system_user, _assets in system_users_assets.items():
|
||||
system_user.assets.add(*tuple(_assets))
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ def test_asset_user_connectivity_util(asset_user, task_name):
|
||||
raw, summary = test_user_connectivity(
|
||||
task_name=task_name, asset=asset_user.asset,
|
||||
username=asset_user.username, password=asset_user.password,
|
||||
private_key=asset_user.private_key
|
||||
private_key=asset_user.private_key_file
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warn("Failed run adhoc {}, {}".format(task_name, e))
|
||||
|
||||
@@ -64,7 +64,7 @@ GATHER_ASSET_USERS_TASKS = [
|
||||
"action": {
|
||||
"module": "shell",
|
||||
"args": "users=$(getent passwd | grep -v 'nologin' | "
|
||||
"grep -v 'shudown' | awk -F: '{ print $1 }');for i in $users;do last -F $i -1 | "
|
||||
"grep -v 'shudown' | awk -F: '{ print $1 }');for i in $users;do last -w -F $i -1 | "
|
||||
"head -1 | grep -v '^$' | awk '{ print $1\"@\"$3\"@\"$5,$6,$7,$8 }';done"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
from itertools import groupby
|
||||
from celery import shared_task
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.db.models import Empty
|
||||
|
||||
from common.utils import encrypt_password, get_logger
|
||||
from orgs.utils import tmp_to_org, org_aware_func
|
||||
from orgs.utils import org_aware_func
|
||||
from . import const
|
||||
from .utils import clean_ansible_task_hosts, group_asset_by_platform
|
||||
|
||||
@@ -17,20 +18,42 @@ __all__ = [
|
||||
]
|
||||
|
||||
|
||||
def _split_by_comma(raw: str):
|
||||
try:
|
||||
return [i.strip() for i in raw.split(',')]
|
||||
except AttributeError:
|
||||
return []
|
||||
|
||||
|
||||
def _dump_args(args: dict):
|
||||
return ' '.join([f'{k}={v}' for k, v in args.items() if v is not Empty])
|
||||
|
||||
|
||||
def get_push_unixlike_system_user_tasks(system_user, username=None):
|
||||
if username is None:
|
||||
username = system_user.username
|
||||
password = system_user.password
|
||||
public_key = system_user.public_key
|
||||
|
||||
groups = _split_by_comma(system_user.system_groups)
|
||||
|
||||
if groups:
|
||||
groups = '"%s"' % ','.join(groups)
|
||||
|
||||
add_user_args = {
|
||||
'name': username,
|
||||
'shell': system_user.shell or Empty,
|
||||
'state': 'present',
|
||||
'home': system_user.home or Empty,
|
||||
'groups': groups or Empty
|
||||
}
|
||||
|
||||
tasks = [
|
||||
{
|
||||
'name': 'Add user {}'.format(username),
|
||||
'action': {
|
||||
'module': 'user',
|
||||
'args': 'name={} shell={} state=present'.format(
|
||||
username, system_user.shell or '/bin/bash',
|
||||
),
|
||||
'args': _dump_args(add_user_args),
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -102,6 +125,11 @@ def get_push_windows_system_user_tasks(system_user, username=None):
|
||||
if username is None:
|
||||
username = system_user.username
|
||||
password = system_user.password
|
||||
groups = {'Users', 'Remote Desktop Users'}
|
||||
if system_user.system_groups:
|
||||
groups.update(_split_by_comma(system_user.system_groups))
|
||||
groups = ','.join(groups)
|
||||
|
||||
tasks = []
|
||||
if not password:
|
||||
return tasks
|
||||
@@ -116,9 +144,9 @@ def get_push_windows_system_user_tasks(system_user, username=None):
|
||||
'update_password=always '
|
||||
'password_expired=no '
|
||||
'password_never_expires=yes '
|
||||
'groups="Users,Remote Desktop Users" '
|
||||
'groups="{}" '
|
||||
'groups_action=add '
|
||||
''.format(username, username, password),
|
||||
''.format(username, username, password, groups),
|
||||
}
|
||||
}
|
||||
tasks.append(task)
|
||||
|
||||
@@ -31,7 +31,10 @@ def test_system_user_connectivity_util(system_user, assets, task_name):
|
||||
"""
|
||||
from ops.utils import update_or_create_ansible_task
|
||||
|
||||
hosts = clean_ansible_task_hosts(assets, system_user=system_user)
|
||||
# hosts = clean_ansible_task_hosts(assets, system_user=system_user)
|
||||
# TODO: 这里不传递系统用户,因为clean_ansible_task_hosts会通过system_user来判断是否可以推送,
|
||||
# 不符合测试可连接性逻辑, 后面需要优化此逻辑
|
||||
hosts = clean_ansible_task_hosts(assets)
|
||||
if not hosts:
|
||||
return {}
|
||||
platform_hosts_map = {}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# coding:utf-8
|
||||
from django.urls import path, re_path
|
||||
from rest_framework_nested import routers
|
||||
# from rest_framework.routers import DefaultRouter
|
||||
from rest_framework_bulk.routes import BulkRouter
|
||||
|
||||
from common import api as capi
|
||||
|
||||
@@ -27,6 +27,7 @@ class FTPLogViewSet(CreateModelMixin,
|
||||
]
|
||||
filter_fields = ['user', 'asset', 'system_user', 'filename']
|
||||
search_fields = filter_fields
|
||||
ordering = ['-date_start']
|
||||
|
||||
|
||||
class UserLoginLogViewSet(ListModelMixin, CommonGenericViewSet):
|
||||
@@ -42,7 +43,7 @@ class UserLoginLogViewSet(ListModelMixin, CommonGenericViewSet):
|
||||
|
||||
@staticmethod
|
||||
def get_org_members():
|
||||
users = current_org.get_org_members().values_list('username', flat=True)
|
||||
users = current_org.get_members().values_list('username', flat=True)
|
||||
return users
|
||||
|
||||
def get_queryset(self):
|
||||
@@ -78,7 +79,7 @@ class PasswordChangeLogViewSet(ListModelMixin, CommonGenericViewSet):
|
||||
ordering = ['-datetime']
|
||||
|
||||
def get_queryset(self):
|
||||
users = current_org.get_org_members()
|
||||
users = current_org.get_members()
|
||||
queryset = super().get_queryset().filter(
|
||||
user__in=[user.__str__() for user in users]
|
||||
)
|
||||
@@ -106,7 +107,7 @@ class CommandExecutionViewSet(ListModelMixin, OrgGenericViewSet):
|
||||
class CommandExecutionHostRelationViewSet(OrgRelationMixin, OrgBulkModelViewSet):
|
||||
serializer_class = CommandExecutionHostsRelationSerializer
|
||||
m2m_field = CommandExecution.hosts.field
|
||||
permission_classes = (IsOrgAdmin,)
|
||||
permission_classes = [IsOrgAdmin | IsOrgAuditor]
|
||||
filter_fields = [
|
||||
'id', 'asset', 'commandexecution'
|
||||
]
|
||||
|
||||
@@ -20,7 +20,7 @@ class CurrentOrgMembersFilter(filters.BaseFilterBackend):
|
||||
]
|
||||
|
||||
def _get_user_list(self):
|
||||
users = current_org.get_org_members(exclude=('Auditor',))
|
||||
users = current_org.get_members(exclude=('Auditor',))
|
||||
return users
|
||||
|
||||
def filter_queryset(self, request, queryset, view):
|
||||
|
||||
18
apps/audits/migrations/0009_auto_20200624_1654.py
Normal file
18
apps/audits/migrations/0009_auto_20200624_1654.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 2.2.10 on 2020-06-24 08:54
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('audits', '0008_auto_20200508_2105'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='ftplog',
|
||||
name='operate',
|
||||
field=models.CharField(choices=[('Delete', 'Delete'), ('Upload', 'Upload'), ('Download', 'Download'), ('Rmdir', 'Rmdir'), ('Rename', 'Rename'), ('Mkdir', 'Mkdir'), ('Symlink', 'Symlink')], max_length=16, verbose_name='Operate'),
|
||||
),
|
||||
]
|
||||
18
apps/audits/migrations/0010_auto_20200811_1122.py
Normal file
18
apps/audits/migrations/0010_auto_20200811_1122.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 2.2.13 on 2020-08-11 03:22
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('audits', '0009_auto_20200624_1654'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='operatelog',
|
||||
name='datetime',
|
||||
field=models.DateTimeField(auto_now=True, db_index=True, verbose_name='Datetime'),
|
||||
),
|
||||
]
|
||||
@@ -14,12 +14,30 @@ __all__ = [
|
||||
|
||||
|
||||
class FTPLog(OrgModelMixin):
|
||||
OPERATE_DELETE = 'Delete'
|
||||
OPERATE_UPLOAD = 'Upload'
|
||||
OPERATE_DOWNLOAD = 'Download'
|
||||
OPERATE_RMDIR = 'Rmdir'
|
||||
OPERATE_RENAME = 'Rename'
|
||||
OPERATE_MKDIR = 'Mkdir'
|
||||
OPERATE_SYMLINK = 'Symlink'
|
||||
|
||||
OPERATE_CHOICES = (
|
||||
(OPERATE_DELETE, _('Delete')),
|
||||
(OPERATE_UPLOAD, _('Upload')),
|
||||
(OPERATE_DOWNLOAD, _('Download')),
|
||||
(OPERATE_RMDIR, _('Rmdir')),
|
||||
(OPERATE_RENAME, _('Rename')),
|
||||
(OPERATE_MKDIR, _('Mkdir')),
|
||||
(OPERATE_SYMLINK, _('Symlink'))
|
||||
)
|
||||
|
||||
id = models.UUIDField(default=uuid.uuid4, primary_key=True)
|
||||
user = models.CharField(max_length=128, verbose_name=_('User'))
|
||||
remote_addr = models.CharField(max_length=128, verbose_name=_("Remote addr"), blank=True, null=True)
|
||||
asset = models.CharField(max_length=1024, verbose_name=_("Asset"))
|
||||
system_user = models.CharField(max_length=128, verbose_name=_("System user"))
|
||||
operate = models.CharField(max_length=16, verbose_name=_("Operate"))
|
||||
operate = models.CharField(max_length=16, verbose_name=_("Operate"), choices=OPERATE_CHOICES)
|
||||
filename = models.CharField(max_length=1024, verbose_name=_("Filename"))
|
||||
is_success = models.BooleanField(default=True, verbose_name=_("Success"))
|
||||
date_start = models.DateTimeField(auto_now_add=True, verbose_name=_('Date start'))
|
||||
@@ -40,7 +58,7 @@ class OperateLog(OrgModelMixin):
|
||||
resource_type = models.CharField(max_length=64, verbose_name=_("Resource Type"))
|
||||
resource = models.CharField(max_length=128, verbose_name=_("Resource"))
|
||||
remote_addr = models.CharField(max_length=128, verbose_name=_("Remote addr"), blank=True, null=True)
|
||||
datetime = models.DateTimeField(auto_now=True, verbose_name=_('Datetime'))
|
||||
datetime = models.DateTimeField(auto_now=True, verbose_name=_('Datetime'), db_index=True)
|
||||
|
||||
def __str__(self):
|
||||
return "<{}> {} <{}>".format(self.user, self.action, self.resource)
|
||||
@@ -106,7 +124,7 @@ class UserLoginLog(models.Model):
|
||||
Q(username__contains=keyword)
|
||||
)
|
||||
if not current_org.is_root():
|
||||
username_list = current_org.get_org_members().values_list('username', flat=True)
|
||||
username_list = current_org.get_members().values_list('username', flat=True)
|
||||
login_logs = login_logs.filter(username__in=username_list)
|
||||
return login_logs
|
||||
|
||||
|
||||
@@ -12,12 +12,13 @@ from . import models
|
||||
|
||||
|
||||
class FTPLogSerializer(serializers.ModelSerializer):
|
||||
operate_display = serializers.ReadOnlyField(source='get_operate_display')
|
||||
|
||||
class Meta:
|
||||
model = models.FTPLog
|
||||
fields = (
|
||||
'id', 'user', 'remote_addr', 'asset', 'system_user',
|
||||
'operate', 'filename', 'is_success', 'date_start'
|
||||
'id', 'user', 'remote_addr', 'asset', 'system_user', 'org_id',
|
||||
'operate', 'filename', 'is_success', 'date_start', 'operate_display'
|
||||
)
|
||||
|
||||
|
||||
@@ -39,7 +40,7 @@ class OperateLogSerializer(serializers.ModelSerializer):
|
||||
model = models.OperateLog
|
||||
fields = (
|
||||
'id', 'user', 'action', 'resource_type', 'resource',
|
||||
'remote_addr', 'datetime'
|
||||
'remote_addr', 'datetime', 'org_id'
|
||||
)
|
||||
|
||||
|
||||
@@ -65,7 +66,7 @@ class CommandExecutionSerializer(serializers.ModelSerializer):
|
||||
fields_mini = ['id']
|
||||
fields_small = fields_mini + [
|
||||
'run_as', 'command', 'user', 'is_finished',
|
||||
'date_start', 'result', 'is_success'
|
||||
'date_start', 'result', 'is_success', 'org_id'
|
||||
]
|
||||
fields = fields_small + ['hosts', 'run_as_display', 'user_display']
|
||||
extra_kwargs = {
|
||||
|
||||
@@ -6,3 +6,4 @@ from .token import *
|
||||
from .mfa import *
|
||||
from .access_key import *
|
||||
from .login_confirm import *
|
||||
from .sso import *
|
||||
|
||||
@@ -34,16 +34,6 @@ class LoginConfirmSettingUpdateApi(UpdateAPIView):
|
||||
class TicketStatusApi(mixins.AuthMixin, APIView):
|
||||
permission_classes = ()
|
||||
|
||||
def get_ticket(self):
|
||||
from tickets.models import Ticket
|
||||
ticket_id = self.request.session.get("auth_ticket_id")
|
||||
logger.debug('Login confirm ticket id: {}'.format(ticket_id))
|
||||
if not ticket_id:
|
||||
ticket = None
|
||||
else:
|
||||
ticket = get_object_or_none(Ticket, pk=ticket_id)
|
||||
return ticket
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
try:
|
||||
self.check_user_login_confirm()
|
||||
|
||||
86
apps/authentication/api/sso.py
Normal file
86
apps/authentication/api/sso.py
Normal file
@@ -0,0 +1,86 @@
|
||||
from uuid import UUID
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from django.contrib.auth import login
|
||||
from django.conf import settings
|
||||
from django.http.response import HttpResponseRedirect
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.request import Request
|
||||
|
||||
from common.utils.timezone import utcnow
|
||||
from common.const.http import POST, GET
|
||||
from common.drf.api import JmsGenericViewSet
|
||||
from common.drf.serializers import EmptySerializer
|
||||
from common.permissions import IsSuperUser
|
||||
from common.utils import reverse
|
||||
from users.models import User
|
||||
from ..serializers import SSOTokenSerializer
|
||||
from ..models import SSOToken
|
||||
from ..filters import AuthKeyQueryDeclaration
|
||||
from ..mixins import AuthMixin
|
||||
from ..errors import SSOAuthClosed
|
||||
|
||||
NEXT_URL = 'next'
|
||||
AUTH_KEY = 'authkey'
|
||||
|
||||
|
||||
class SSOViewSet(AuthMixin, JmsGenericViewSet):
|
||||
queryset = SSOToken.objects.all()
|
||||
serializer_classes = {
|
||||
'login_url': SSOTokenSerializer,
|
||||
'login': EmptySerializer
|
||||
}
|
||||
|
||||
@action(methods=[POST], detail=False, permission_classes=[IsSuperUser], url_path='login-url')
|
||||
def login_url(self, request, *args, **kwargs):
|
||||
if not settings.AUTH_SSO:
|
||||
raise SSOAuthClosed()
|
||||
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
username = serializer.validated_data['username']
|
||||
user = User.objects.get(username=username)
|
||||
next_url = serializer.validated_data.get(NEXT_URL)
|
||||
|
||||
operator = request.user.username
|
||||
# TODO `created_by` 和 `created_by` 可以通过 `ThreadLocal` 统一处理
|
||||
token = SSOToken.objects.create(user=user, created_by=operator, updated_by=operator)
|
||||
query = {
|
||||
AUTH_KEY: token.authkey,
|
||||
NEXT_URL: next_url or ''
|
||||
}
|
||||
login_url = '%s?%s' % (reverse('api-auth:sso-login', external=True), urlencode(query))
|
||||
return Response(data={'login_url': login_url})
|
||||
|
||||
@action(methods=[GET], detail=False, filter_backends=[AuthKeyQueryDeclaration], permission_classes=[])
|
||||
def login(self, request: Request, *args, **kwargs):
|
||||
"""
|
||||
此接口违反了 `Restful` 的规范
|
||||
`GET` 应该是安全的方法,但此接口是不安全的
|
||||
"""
|
||||
authkey = request.query_params.get(AUTH_KEY)
|
||||
next_url = request.query_params.get(NEXT_URL)
|
||||
if not next_url or not next_url.startswith('/'):
|
||||
next_url = reverse('index')
|
||||
|
||||
try:
|
||||
authkey = UUID(authkey)
|
||||
token = SSOToken.objects.get(authkey=authkey, expired=False)
|
||||
# 先过期,只能访问这一次
|
||||
token.expired = True
|
||||
token.save()
|
||||
except (ValueError, SSOToken.DoesNotExist):
|
||||
self.send_auth_signal(success=False, reason='authkey_invalid')
|
||||
return HttpResponseRedirect(reverse('authentication:login'))
|
||||
|
||||
# 判断是否过期
|
||||
if (utcnow().timestamp() - token.date_created.timestamp()) > settings.AUTH_SSO_AUTHKEY_TTL:
|
||||
self.send_auth_signal(success=False, reason='authkey_timeout')
|
||||
return HttpResponseRedirect(reverse('authentication:login'))
|
||||
|
||||
user = token.user
|
||||
login(self.request, user, 'authentication.backends.api.SSOAuthentication')
|
||||
self.send_auth_signal(success=True, user=user)
|
||||
return HttpResponseRedirect(next_url)
|
||||
@@ -1,6 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
|
||||
from django.shortcuts import redirect
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.generics import CreateAPIView
|
||||
@@ -40,3 +40,5 @@ class TokenCreateApi(AuthMixin, CreateAPIView):
|
||||
return Response(e.as_data(), status=400)
|
||||
except errors.NeedMoreInfoError as e:
|
||||
return Response(e.as_data(), status=200)
|
||||
except errors.PasswdTooSimple as e:
|
||||
return redirect(e.url)
|
||||
|
||||
@@ -5,14 +5,13 @@ import uuid
|
||||
import time
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.conf import settings
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.six import text_type
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.backends import ModelBackend
|
||||
from rest_framework import HTTP_HEADER_ENCODING
|
||||
from rest_framework import authentication, exceptions
|
||||
from common.auth import signature
|
||||
from rest_framework.authentication import CSRFCheck
|
||||
|
||||
from common.utils import get_object_or_none, make_signature, http_to_unixtime
|
||||
from ..models import AccessKey, PrivateToken
|
||||
@@ -197,3 +196,10 @@ class SignatureAuthentication(signature.SignatureAuthentication):
|
||||
return user, secret
|
||||
except AccessKey.DoesNotExist:
|
||||
return None, None
|
||||
|
||||
|
||||
class SSOAuthentication(ModelBackend):
|
||||
"""
|
||||
什么也不做呀😺
|
||||
"""
|
||||
pass
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
import traceback
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from radiusauth.backends import RADIUSBackend, RADIUSRealmBackend
|
||||
from django.conf import settings
|
||||
|
||||
from pyrad.packet import AccessRequest
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class CreateUserMixin:
|
||||
def get_django_user(self, username, password=None):
|
||||
def get_django_user(self, username, password=None, *args, **kwargs):
|
||||
if isinstance(username, bytes):
|
||||
username = username.decode()
|
||||
try:
|
||||
@@ -27,6 +27,23 @@ class CreateUserMixin:
|
||||
user.save()
|
||||
return user
|
||||
|
||||
def _perform_radius_auth(self, client, packet):
|
||||
# TODO: 等待官方库修复这个BUG
|
||||
try:
|
||||
return super()._perform_radius_auth(client, packet)
|
||||
except UnicodeError as e:
|
||||
import sys
|
||||
tb = ''.join(traceback.format_exception(*sys.exc_info(), limit=2, chain=False))
|
||||
if tb.find("cl.decode") != -1:
|
||||
return [], False, False
|
||||
return None
|
||||
|
||||
def authenticate(self, *args, **kwargs):
|
||||
# 校验用户时,会传入public_key参数,父类authentication中不接受public_key参数,所以要pop掉
|
||||
# TODO:需要优化各backend的authenticate方法,django进行调用前会检测各authenticate的参数
|
||||
kwargs.pop('public_key', None)
|
||||
return super().authenticate(*args, **kwargs)
|
||||
|
||||
|
||||
class RadiusBackend(CreateUserMixin, RADIUSBackend):
|
||||
pass
|
||||
|
||||
2
apps/authentication/const.py
Normal file
2
apps/authentication/const.py
Normal file
@@ -0,0 +1,2 @@
|
||||
RSA_PRIVATE_KEY = 'rsa_private_key'
|
||||
RSA_PUBLIC_KEY = 'rsa_public_key'
|
||||
@@ -4,12 +4,14 @@ from django.utils.translation import ugettext_lazy as _
|
||||
from django.urls import reverse
|
||||
from django.conf import settings
|
||||
|
||||
from common.exceptions import JMSException
|
||||
from .signals import post_auth_failed
|
||||
from users.utils import (
|
||||
increase_login_failed_count, get_login_failed_count
|
||||
)
|
||||
|
||||
reason_password_failed = 'password_failed'
|
||||
reason_password_decrypt_failed = 'password_decrypt_failed'
|
||||
reason_mfa_failed = 'mfa_failed'
|
||||
reason_mfa_unset = 'mfa_unset'
|
||||
reason_user_not_exist = 'user_not_exist'
|
||||
@@ -19,6 +21,7 @@ reason_user_inactive = 'user_inactive'
|
||||
|
||||
reason_choices = {
|
||||
reason_password_failed: _('Username/password check failed'),
|
||||
reason_password_decrypt_failed: _('Password decrypt failed'),
|
||||
reason_mfa_failed: _('MFA failed'),
|
||||
reason_mfa_unset: _('MFA unset'),
|
||||
reason_user_not_exist: _("Username does not exist"),
|
||||
@@ -203,3 +206,17 @@ class LoginConfirmOtherError(LoginConfirmBaseError):
|
||||
def __init__(self, ticket_id, status):
|
||||
msg = login_confirm_error_msg.format(status)
|
||||
super().__init__(ticket_id=ticket_id, msg=msg)
|
||||
|
||||
|
||||
class SSOAuthClosed(JMSException):
|
||||
default_code = 'sso_auth_closed'
|
||||
default_detail = _('SSO auth closed')
|
||||
|
||||
|
||||
class PasswdTooSimple(JMSException):
|
||||
default_code = 'passwd_too_simple'
|
||||
default_detail = _('Your password is too simple, please change it for security')
|
||||
|
||||
def __init__(self, url, *args, **kwargs):
|
||||
super(PasswdTooSimple, self).__init__(*args, **kwargs)
|
||||
self.url = url
|
||||
|
||||
15
apps/authentication/filters.py
Normal file
15
apps/authentication/filters.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from rest_framework import filters
|
||||
from rest_framework.compat import coreapi, coreschema
|
||||
|
||||
|
||||
class AuthKeyQueryDeclaration(filters.BaseFilterBackend):
|
||||
def get_schema_fields(self, view):
|
||||
return [
|
||||
coreapi.Field(
|
||||
name='authkey', location='query', required=True, type='string',
|
||||
schema=coreschema.String(
|
||||
title='authkey',
|
||||
description='authkey'
|
||||
)
|
||||
)
|
||||
]
|
||||
@@ -2,6 +2,7 @@
|
||||
#
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from captcha.fields import CaptchaField
|
||||
|
||||
@@ -10,7 +11,7 @@ class UserLoginForm(forms.Form):
|
||||
username = forms.CharField(label=_('Username'), max_length=100)
|
||||
password = forms.CharField(
|
||||
label=_('Password'), widget=forms.PasswordInput,
|
||||
max_length=128, strip=False
|
||||
max_length=1024, strip=False
|
||||
)
|
||||
|
||||
def confirm_login_allowed(self, user):
|
||||
@@ -21,9 +22,24 @@ class UserLoginForm(forms.Form):
|
||||
)
|
||||
|
||||
|
||||
class UserLoginCaptchaForm(UserLoginForm):
|
||||
class UserCheckOtpCodeForm(forms.Form):
|
||||
otp_code = forms.CharField(label=_('MFA code'), max_length=6)
|
||||
|
||||
|
||||
class CaptchaMixin(forms.Form):
|
||||
captcha = CaptchaField()
|
||||
|
||||
|
||||
class UserCheckOtpCodeForm(forms.Form):
|
||||
otp_code = forms.CharField(label=_('MFA code'), max_length=6)
|
||||
class ChallengeMixin(forms.Form):
|
||||
challenge = forms.CharField(label=_('MFA code'), max_length=6,
|
||||
required=False)
|
||||
|
||||
|
||||
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)
|
||||
bases.append(UserLoginForm)
|
||||
return type('UserLoginForm', tuple(bases), {})
|
||||
|
||||
32
apps/authentication/migrations/0004_ssotoken.py
Normal file
32
apps/authentication/migrations/0004_ssotoken.py
Normal file
@@ -0,0 +1,32 @@
|
||||
# Generated by Django 2.2.10 on 2020-07-31 08:36
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('authentication', '0003_loginconfirmsetting'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='SSOToken',
|
||||
fields=[
|
||||
('created_by', models.CharField(blank=True, max_length=32, null=True, verbose_name='Created by')),
|
||||
('updated_by', models.CharField(blank=True, max_length=32, null=True, verbose_name='Updated by')),
|
||||
('date_created', models.DateTimeField(auto_now_add=True, null=True, verbose_name='Date created')),
|
||||
('date_updated', models.DateTimeField(auto_now=True, verbose_name='Date updated')),
|
||||
('authkey', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False, verbose_name='Token')),
|
||||
('expired', models.BooleanField(default=False, verbose_name='Expired')),
|
||||
('user', models.ForeignKey(db_constraint=False, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL, verbose_name='User')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,7 +1,13 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
from urllib.parse import urlencode
|
||||
from functools import partial
|
||||
import time
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import authenticate
|
||||
from django.shortcuts import reverse
|
||||
from django.contrib.auth import BACKEND_SESSION_KEY
|
||||
|
||||
from common.utils import get_object_or_none, get_request_ip, get_logger
|
||||
from users.models import User
|
||||
@@ -9,8 +15,9 @@ from users.utils import (
|
||||
is_block_login, clean_failed_count
|
||||
)
|
||||
from . import errors
|
||||
from .utils import check_user_valid
|
||||
from .utils import rsa_decrypt
|
||||
from .signals import post_auth_success, post_auth_failed
|
||||
from .const import RSA_PRIVATE_KEY
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -21,8 +28,14 @@ class AuthMixin:
|
||||
def get_user_from_session(self):
|
||||
if self.request.session.is_empty():
|
||||
raise errors.SessionEmptyError()
|
||||
if self.request.user and not self.request.user.is_anonymous:
|
||||
return self.request.user
|
||||
|
||||
if all((self.request.user,
|
||||
not self.request.user.is_anonymous,
|
||||
BACKEND_SESSION_KEY in self.request.session)):
|
||||
user = self.request.user
|
||||
user.backend = self.request.session[BACKEND_SESSION_KEY]
|
||||
return user
|
||||
|
||||
user_id = self.request.session.get('user_id')
|
||||
if not user_id:
|
||||
user = None
|
||||
@@ -50,25 +63,54 @@ class AuthMixin:
|
||||
logger.warn('Ip was blocked' + ': ' + username + ':' + ip)
|
||||
raise errors.BlockLoginError(username=username, ip=ip)
|
||||
|
||||
def check_user_auth(self):
|
||||
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 faild: password[{raw_passwd}] rsa_private_key[{rsa_private_key}]')
|
||||
return None
|
||||
return raw_passwd
|
||||
|
||||
def check_user_auth(self, decrypt_passwd=False):
|
||||
self.check_is_block()
|
||||
request = self.request
|
||||
if hasattr(request, 'data'):
|
||||
username = request.data.get('username', '')
|
||||
password = request.data.get('password', '')
|
||||
public_key = request.data.get('public_key', '')
|
||||
data = request.data
|
||||
else:
|
||||
username = request.POST.get('username', '')
|
||||
password = request.POST.get('password', '')
|
||||
public_key = request.POST.get('public_key', '')
|
||||
user, error = check_user_valid(
|
||||
request=request, username=username, password=password, public_key=public_key
|
||||
)
|
||||
data = request.POST
|
||||
username = data.get('username', '')
|
||||
password = data.get('password', '')
|
||||
challenge = data.get('challenge', '')
|
||||
public_key = data.get('public_key', '')
|
||||
ip = self.get_request_ip()
|
||||
|
||||
CredentialError = partial(errors.CredentialError, username=username, ip=ip, request=request)
|
||||
|
||||
if decrypt_passwd:
|
||||
password = self.decrypt_passwd(password)
|
||||
if not password:
|
||||
raise CredentialError(error=errors.reason_password_decrypt_failed)
|
||||
|
||||
user = authenticate(request,
|
||||
username=username,
|
||||
password=password + challenge.strip(),
|
||||
public_key=public_key)
|
||||
|
||||
if not user:
|
||||
raise errors.CredentialError(
|
||||
username=username, error=error, ip=ip, request=request
|
||||
)
|
||||
raise CredentialError(error=errors.reason_password_failed)
|
||||
elif user.is_expired:
|
||||
raise CredentialError(error=errors.reason_user_inactive)
|
||||
elif not user.is_active:
|
||||
raise CredentialError(error=errors.reason_user_inactive)
|
||||
elif user.password_has_expired:
|
||||
raise CredentialError(error=errors.reason_password_expired)
|
||||
|
||||
self._check_passwd_is_too_simple(user, password)
|
||||
|
||||
clean_failed_count(username, ip)
|
||||
request.session['auth_password'] = 1
|
||||
request.session['user_id'] = str(user.id)
|
||||
@@ -76,14 +118,30 @@ class AuthMixin:
|
||||
request.session['auth_backend'] = auth_backend
|
||||
return user
|
||||
|
||||
def check_user_auth_if_need(self):
|
||||
@classmethod
|
||||
def _check_passwd_is_too_simple(cls, user, password):
|
||||
if user.is_superuser and password == 'admin':
|
||||
reset_passwd_url = reverse('authentication:reset-password')
|
||||
query_str = urlencode({
|
||||
'token': user.generate_reset_token()
|
||||
})
|
||||
reset_passwd_url = f'{reset_passwd_url}?{query_str}'
|
||||
|
||||
flash_page_url = reverse('authentication:passwd-too-simple-flash-msg')
|
||||
query_str = urlencode({
|
||||
'redirect_url': reset_passwd_url
|
||||
})
|
||||
|
||||
raise errors.PasswdTooSimple(f'{flash_page_url}?{query_str}')
|
||||
|
||||
def check_user_auth_if_need(self, decrypt_passwd=False):
|
||||
request = self.request
|
||||
if request.session.get('auth_password') and \
|
||||
request.session.get('user_id'):
|
||||
user = self.get_user_from_session()
|
||||
if user:
|
||||
return user
|
||||
return self.check_user_auth()
|
||||
return self.check_user_auth(decrypt_passwd=decrypt_passwd)
|
||||
|
||||
def check_user_mfa_if_need(self, user):
|
||||
if self.request.session.get('auth_mfa'):
|
||||
@@ -112,12 +170,12 @@ class AuthMixin:
|
||||
if not ticket_id:
|
||||
ticket = None
|
||||
else:
|
||||
ticket = get_object_or_none(Ticket, pk=ticket_id)
|
||||
ticket = Ticket.origin_objects.get(pk=ticket_id)
|
||||
return ticket
|
||||
|
||||
def get_ticket_or_create(self, confirm_setting):
|
||||
ticket = self.get_ticket()
|
||||
if not ticket or ticket.status == ticket.STATUS_CLOSED:
|
||||
if not ticket or ticket.status == ticket.STATUS.CLOSED:
|
||||
ticket = confirm_setting.create_confirm_ticket(self.request)
|
||||
self.request.session['auth_ticket_id'] = str(ticket.id)
|
||||
return ticket
|
||||
@@ -126,12 +184,12 @@ class AuthMixin:
|
||||
ticket = self.get_ticket()
|
||||
if not ticket:
|
||||
raise errors.LoginConfirmOtherError('', "Not found")
|
||||
if ticket.status == ticket.STATUS_OPEN:
|
||||
if ticket.status == ticket.STATUS.OPEN:
|
||||
raise errors.LoginConfirmWaitError(ticket.id)
|
||||
elif ticket.action == ticket.ACTION_APPROVE:
|
||||
elif ticket.action == ticket.ACTION.APPROVE:
|
||||
self.request.session["auth_confirm"] = "1"
|
||||
return
|
||||
elif ticket.action == ticket.ACTION_REJECT:
|
||||
elif ticket.action == ticket.ACTION.REJECT:
|
||||
raise errors.LoginConfirmOtherError(
|
||||
ticket.id, ticket.get_action_display()
|
||||
)
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import uuid
|
||||
from django.db import models
|
||||
from functools import partial
|
||||
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import ugettext_lazy as _, ugettext as __
|
||||
from rest_framework.authtoken.models import Token
|
||||
from django.conf import settings
|
||||
from django.utils.crypto import get_random_string
|
||||
|
||||
from common.db import models
|
||||
from common.mixins.models import CommonModelMixin
|
||||
from common.utils import get_object_or_none, get_request_ip, get_ip_city
|
||||
|
||||
@@ -68,7 +71,7 @@ class LoginConfirmSetting(CommonModelMixin):
|
||||
reviewer = self.reviewers.all()
|
||||
ticket = Ticket.objects.create(
|
||||
user=self.user, title=title, body=body,
|
||||
type=Ticket.TYPE_LOGIN_CONFIRM,
|
||||
type=Ticket.TYPE.LOGIN_CONFIRM,
|
||||
)
|
||||
ticket.assignees.set(reviewer)
|
||||
return ticket
|
||||
@@ -76,3 +79,12 @@ class LoginConfirmSetting(CommonModelMixin):
|
||||
def __str__(self):
|
||||
return '{} confirm'.format(self.user.username)
|
||||
|
||||
|
||||
class SSOToken(models.JMSBaseModel):
|
||||
"""
|
||||
类似腾讯企业邮的 [单点登录](https://exmail.qq.com/qy_mng_logic/doc#10036)
|
||||
出于安全考虑,这里的 `token` 使用一次随即过期。但我们保留每一个生成过的 `token`。
|
||||
"""
|
||||
authkey = models.UUIDField(primary_key=True, default=uuid.uuid4, verbose_name=_('Token'))
|
||||
expired = models.BooleanField(default=False, verbose_name=_('Expired'))
|
||||
user = models.ForeignKey('users.User', on_delete=models.PROTECT, verbose_name=_('User'), db_constraint=False)
|
||||
|
||||
@@ -5,12 +5,12 @@ from rest_framework import serializers
|
||||
from common.utils import get_object_or_none
|
||||
from users.models import User
|
||||
from users.serializers import UserProfileSerializer
|
||||
from .models import AccessKey, LoginConfirmSetting
|
||||
from .models import AccessKey, LoginConfirmSetting, SSOToken
|
||||
|
||||
|
||||
__all__ = [
|
||||
'AccessKeySerializer', 'OtpVerifySerializer', 'BearerTokenSerializer',
|
||||
'MFAChallengeSerializer', 'LoginConfirmSettingSerializer',
|
||||
'MFAChallengeSerializer', 'LoginConfirmSettingSerializer', 'SSOTokenSerializer',
|
||||
]
|
||||
|
||||
|
||||
@@ -76,3 +76,9 @@ class LoginConfirmSettingSerializer(serializers.ModelSerializer):
|
||||
model = LoginConfirmSetting
|
||||
fields = ['id', 'user', 'reviewers', 'date_created', 'date_updated']
|
||||
read_only_fields = ['date_created', 'date_updated']
|
||||
|
||||
|
||||
class SSOTokenSerializer(serializers.Serializer):
|
||||
username = serializers.CharField(write_only=True)
|
||||
login_url = serializers.CharField(read_only=True)
|
||||
next = serializers.CharField(write_only=True, allow_blank=True, required=False, allow_null=True)
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<form class="m-t" role="form" method="post" action="">
|
||||
<form id="form" class="m-t" role="form" method="post" action="">
|
||||
{% csrf_token %}
|
||||
{% if form.non_field_errors %}
|
||||
<div style="line-height: 17px;">
|
||||
@@ -26,17 +26,27 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="password" class="form-control" name="{{ form.password.html_name }}" placeholder="{% trans 'Password' %}" required="">
|
||||
<input type="password" class="form-control" id="password" name="{{ form.password.html_name }}" placeholder="{% trans 'Password' %}" required="">
|
||||
{% if form.errors.password %}
|
||||
<div class="help-block field-error">
|
||||
<p class="red-fonts">{{ form.errors.password.as_text }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if form.challenge %}
|
||||
<div class="form-group">
|
||||
<input type="challenge" class="form-control" id="challenge" name="{{ form.challenge.html_name }}" placeholder="{% trans 'MFA code' %}" >
|
||||
{% if form.errors.challenge %}
|
||||
<div class="help-block field-error">
|
||||
<p class="red-fonts">{{ form.errors.challenge.as_text }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
{{ form.captcha }}
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary block full-width m-b">{% trans 'Login' %}</button>
|
||||
<button type="submit" class="btn btn-primary block full-width m-b" onclick="doLogin();return false;">{% trans 'Login' %}</button>
|
||||
|
||||
{% if demo_mode %}
|
||||
<p class="text-muted font-bold" style="color: red">
|
||||
@@ -64,4 +74,20 @@
|
||||
{% endif %}
|
||||
|
||||
</form>
|
||||
<script type="text/javascript" src="/static/js/plugins/jsencrypt/jsencrypt.min.js"></script>
|
||||
<script>
|
||||
function encryptLoginPassword(password, rsaPublicKey){
|
||||
var jsencrypt = new JSEncrypt(); //加密对象
|
||||
jsencrypt.setPublicKey(rsaPublicKey); // 设置密钥
|
||||
return jsencrypt.encrypt(password); //加密
|
||||
}
|
||||
function doLogin() {
|
||||
//公钥加密
|
||||
var rsaPublicKey = "{{ rsa_public_key }}"
|
||||
var password =$('#password').val(); //明文密码
|
||||
var passwordEncrypted = encryptLoginPassword(password, rsaPublicKey)
|
||||
$('#password').val(passwordEncrypted); //返回给密码输入input
|
||||
$('#form').submit();//post提交
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -67,22 +67,30 @@
|
||||
</div>
|
||||
<div class="box-3">
|
||||
<div style="background-color: white">
|
||||
<div style="margin-top: 30px;padding-top: 40px;padding-left: 20px;padding-right: 20px;height: 80px">
|
||||
<span style="font-size: 21px;font-weight:400;color: #151515;letter-spacing: 0;">{{ JMS_TITLE }}</span>
|
||||
</div>
|
||||
{% if form.challenge %}
|
||||
<div style="margin-top: 20px;padding-top: 30px;padding-left: 20px;padding-right: 20px;height: 60px">
|
||||
{% else %}
|
||||
<div style="margin-top: 20px;padding-top: 40px;padding-left: 20px;padding-right: 20px;height: 80px">
|
||||
{% endif %}
|
||||
<span style="font-size: 21px;font-weight:400;color: #151515;letter-spacing: 0;">{{ JMS_TITLE }}</span>
|
||||
</div>
|
||||
<div style="font-size: 12px;color: #999999;letter-spacing: 0;line-height: 18px;margin-top: 18px">
|
||||
{% trans 'Welcome back, please enter username and password to login' %}
|
||||
</div>
|
||||
<div style="margin-bottom: 10px">
|
||||
<div style="margin-bottom: 0px">
|
||||
<div>
|
||||
<div class="col-md-1"></div>
|
||||
<div class="contact-form col-md-10" style="margin-top: 20px;height: 35px">
|
||||
<div class="contact-form col-md-10" style="margin-top: 0px;height: 35px">
|
||||
<form id="contact-form" action="" method="post" role="form" novalidate="novalidate">
|
||||
{% csrf_token %}
|
||||
{% if form.non_field_errors %}
|
||||
<div style="height: 70px;color: red;line-height: 17px;">
|
||||
<p class="red-fonts">{{ form.non_field_errors.as_text }}</p>
|
||||
</div>
|
||||
{% if form.challenge %}
|
||||
<div style="height: 50px;color: red;line-height: 17px;">
|
||||
{% else %}
|
||||
<div style="height: 70px;color: red;line-height: 17px;">
|
||||
{% endif %}
|
||||
<p class="red-fonts">{{ form.non_field_errors.as_text }}</p>
|
||||
</div>
|
||||
{% elif form.errors.captcha %}
|
||||
<p class="red-fonts">{% trans 'Captcha invalid' %}</p>
|
||||
{% else %}
|
||||
@@ -98,18 +106,28 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="password" class="form-control" name="{{ form.password.html_name }}" placeholder="{% trans 'Password' %}" required="">
|
||||
<input type="password" class="form-control" id="password" name="{{ form.password.html_name }}" placeholder="{% trans 'Password' %}" required="">
|
||||
{% if form.errors.password %}
|
||||
<div class="help-block field-error">
|
||||
<p class="red-fonts">{{ form.errors.password.as_text }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if form.challenge %}
|
||||
<div class="form-group">
|
||||
<input type="challenge" class="form-control" id="challenge" name="{{ form.challenge.html_name }}" placeholder="{% trans 'MFA code' %}" >
|
||||
{% if form.errors.challenge %}
|
||||
<div class="help-block field-error">
|
||||
<p class="red-fonts">{{ form.errors.challenge.as_text }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="form-group" style="height: 50px;margin-bottom: 0;font-size: 13px">
|
||||
{{ form.captcha }}
|
||||
</div>
|
||||
<div class="form-group" style="margin-top: 10px">
|
||||
<button type="submit" class="btn btn-transparent">{% trans 'Login' %}</button>
|
||||
<button type="submit" class="btn btn-transparent" onclick="doLogin();return false;">{% trans 'Login' %}</button>
|
||||
</div>
|
||||
<div style="text-align: center">
|
||||
<a href="{% url 'authentication:forgot-password' %}">
|
||||
@@ -127,4 +145,21 @@
|
||||
</div>
|
||||
|
||||
</body>
|
||||
<script type="text/javascript" src="/static/js/plugins/jsencrypt/jsencrypt.min.js"></script>
|
||||
<script>
|
||||
function encryptLoginPassword(password, rsaPublicKey){
|
||||
var jsencrypt = new JSEncrypt(); //加密对象
|
||||
jsencrypt.setPublicKey(rsaPublicKey); // 设置密钥
|
||||
return jsencrypt.encrypt(password); //加密
|
||||
}
|
||||
function doLogin() {
|
||||
//公钥加密
|
||||
var rsaPublicKey = "{{ rsa_public_key }}"
|
||||
var password =$('#password').val(); //明文密码
|
||||
var passwordEncrypted = encryptLoginPassword(password, rsaPublicKey)
|
||||
$('#password').val(passwordEncrypted); //返回给密码输入input
|
||||
$('#contact-form').submit();//post提交
|
||||
}
|
||||
</script>
|
||||
</html>
|
||||
|
||||
|
||||
@@ -1 +1,15 @@
|
||||
from .utils import gen_key_pair, rsa_decrypt, rsa_encrypt
|
||||
|
||||
|
||||
def test_rsa_encrypt_decrypt(message='test-password-$%^&*'):
|
||||
""" 测试加密/解密 """
|
||||
print('Need to encrypt message: {}'.format(message))
|
||||
rsa_private_key, rsa_public_key = gen_key_pair()
|
||||
print('RSA public key: \n{}'.format(rsa_public_key))
|
||||
print('RSA private key: \n{}'.format(rsa_private_key))
|
||||
message_encrypted = rsa_encrypt(message, rsa_public_key)
|
||||
print('Encrypted message: {}'.format(message_encrypted))
|
||||
message_decrypted = rsa_decrypt(message_encrypted, rsa_private_key)
|
||||
print('Decrypted message: {}'.format(message_decrypted))
|
||||
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from .. import api
|
||||
app_name = 'authentication'
|
||||
router = DefaultRouter()
|
||||
router.register('access-keys', api.AccessKeyViewSet, 'access-key')
|
||||
router.register('sso', api.SSOViewSet, 'sso')
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
|
||||
@@ -21,6 +21,7 @@ urlpatterns = [
|
||||
path('password/forgot/sendmail-success/', users_view.UserForgotPasswordSendmailSuccessView.as_view(),
|
||||
name='forgot-password-sendmail-success'),
|
||||
path('password/reset/', users_view.UserResetPasswordView.as_view(), name='reset-password'),
|
||||
path('password/too-simple-flash-msg/', views.FlashPasswdTooSimpleMsgView.as_view(), name='passwd-too-simple-flash-msg'),
|
||||
path('password/reset/success/', users_view.UserResetPasswordSuccessView.as_view(), name='reset-password-success'),
|
||||
path('password/verify/', users_view.UserVerifyPasswordView.as_view(), name='user-verify-password'),
|
||||
|
||||
|
||||
@@ -1,25 +1,46 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
from django.contrib.auth import authenticate
|
||||
import base64
|
||||
from Crypto.PublicKey import RSA
|
||||
from Crypto.Cipher import PKCS1_v1_5
|
||||
from Crypto import Random
|
||||
|
||||
from . import errors
|
||||
from common.utils import get_logger
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
||||
|
||||
def check_user_valid(**kwargs):
|
||||
password = kwargs.pop('password', None)
|
||||
public_key = kwargs.pop('public_key', None)
|
||||
username = kwargs.pop('username', None)
|
||||
request = kwargs.get('request')
|
||||
def gen_key_pair():
|
||||
""" 生成加密key
|
||||
用于登录页面提交用户名/密码时,对密码进行加密(前端)/解密(后端)
|
||||
"""
|
||||
random_generator = Random.new().read
|
||||
rsa = RSA.generate(1024, random_generator)
|
||||
rsa_private_key = rsa.exportKey().decode()
|
||||
rsa_public_key = rsa.publickey().exportKey().decode()
|
||||
return rsa_private_key, rsa_public_key
|
||||
|
||||
user = authenticate(request, username=username,
|
||||
password=password, public_key=public_key)
|
||||
if not user:
|
||||
return None, errors.reason_password_failed
|
||||
elif user.is_expired:
|
||||
return None, errors.reason_user_inactive
|
||||
elif not user.is_active:
|
||||
return None, errors.reason_user_inactive
|
||||
elif user.password_has_expired:
|
||||
return None, errors.reason_password_expired
|
||||
|
||||
return user, ''
|
||||
def rsa_encrypt(message, rsa_public_key):
|
||||
""" 加密登录密码 """
|
||||
key = RSA.importKey(rsa_public_key)
|
||||
cipher = PKCS1_v1_5.new(key)
|
||||
cipher_text = base64.b64encode(cipher.encrypt(message.encode())).decode()
|
||||
return cipher_text
|
||||
|
||||
|
||||
def rsa_decrypt(cipher_text, rsa_private_key=None):
|
||||
""" 解密登录密码 """
|
||||
if rsa_private_key is None:
|
||||
# rsa_private_key 为 None,可以能是API请求认证,不需要解密
|
||||
return cipher_text
|
||||
|
||||
key = RSA.importKey(rsa_private_key)
|
||||
cipher = PKCS1_v1_5.new(key)
|
||||
cipher_decoded = base64.b64decode(cipher_text.encode())
|
||||
# Todo: 弄明白为何要以下这么写,https://xbuba.com/questions/57035263
|
||||
if len(cipher_decoded) == 127:
|
||||
hex_fixed = '00' + cipher_decoded.hex()
|
||||
cipher_decoded = base64.b16decode(hex_fixed.upper())
|
||||
message = cipher.decrypt(cipher_decoded, b'error').decode()
|
||||
return message
|
||||
|
||||
@@ -18,16 +18,20 @@ from django.views.generic.edit import FormView
|
||||
from django.conf import settings
|
||||
from django.urls import reverse_lazy
|
||||
|
||||
from common.const.front_urls import TICKET_DETAIL
|
||||
from common.utils import get_request_ip, get_object_or_none
|
||||
from users.utils import (
|
||||
redirect_user_first_login_or_index
|
||||
)
|
||||
from .. import forms, mixins, errors
|
||||
from ..const import RSA_PRIVATE_KEY, RSA_PUBLIC_KEY
|
||||
from .. import mixins, errors, utils
|
||||
from ..forms import get_user_login_form_cls
|
||||
|
||||
|
||||
__all__ = [
|
||||
'UserLoginView', 'UserLogoutView',
|
||||
'UserLoginGuardView', 'UserLoginWaitConfirmView',
|
||||
'FlashPasswdTooSimpleMsgView',
|
||||
]
|
||||
|
||||
|
||||
@@ -35,8 +39,6 @@ __all__ = [
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(never_cache, name='dispatch')
|
||||
class UserLoginView(mixins.AuthMixin, FormView):
|
||||
form_class = forms.UserLoginForm
|
||||
form_class_captcha = forms.UserLoginCaptchaForm
|
||||
key_prefix_captcha = "_LOGIN_INVALID_{}"
|
||||
redirect_field_name = 'next'
|
||||
|
||||
@@ -82,15 +84,19 @@ class UserLoginView(mixins.AuthMixin, FormView):
|
||||
if not self.request.session.test_cookie_worked():
|
||||
return HttpResponse(_("Please enable cookies and try again."))
|
||||
try:
|
||||
self.check_user_auth()
|
||||
self.check_user_auth(decrypt_passwd=True)
|
||||
except errors.AuthFailedError as e:
|
||||
form.add_error(None, e.msg)
|
||||
ip = self.get_request_ip()
|
||||
cache.set(self.key_prefix_captcha.format(ip), 1, 3600)
|
||||
new_form = self.form_class_captcha(data=form.data)
|
||||
form_cls = get_user_login_form_cls(captcha=True)
|
||||
new_form = form_cls(data=form.data)
|
||||
new_form._errors = form.errors
|
||||
context = self.get_context_data(form=new_form)
|
||||
return self.render_to_response(context)
|
||||
except errors.PasswdTooSimple as e:
|
||||
return redirect(e.url)
|
||||
self.clear_rsa_key()
|
||||
return self.redirect_to_guard_view()
|
||||
|
||||
def redirect_to_guard_view(self):
|
||||
@@ -103,14 +109,28 @@ class UserLoginView(mixins.AuthMixin, FormView):
|
||||
def get_form_class(self):
|
||||
ip = get_request_ip(self.request)
|
||||
if cache.get(self.key_prefix_captcha.format(ip)):
|
||||
return self.form_class_captcha
|
||||
return get_user_login_form_cls(captcha=True)
|
||||
else:
|
||||
return self.form_class
|
||||
return get_user_login_form_cls()
|
||||
|
||||
def clear_rsa_key(self):
|
||||
self.request.session[RSA_PRIVATE_KEY] = None
|
||||
self.request.session[RSA_PUBLIC_KEY] = None
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
# 生成加解密密钥对,public_key传递给前端,private_key存入session中供解密使用
|
||||
rsa_private_key = self.request.session.get(RSA_PRIVATE_KEY)
|
||||
rsa_public_key = self.request.session.get(RSA_PUBLIC_KEY)
|
||||
if not all((rsa_private_key, rsa_public_key)):
|
||||
rsa_private_key, rsa_public_key = utils.gen_key_pair()
|
||||
rsa_public_key = rsa_public_key.replace('\n', '\\n')
|
||||
self.request.session[RSA_PRIVATE_KEY] = rsa_private_key
|
||||
self.request.session[RSA_PUBLIC_KEY] = rsa_public_key
|
||||
|
||||
context = {
|
||||
'demo_mode': os.environ.get("DEMO_MODE"),
|
||||
'AUTH_OPENID': settings.AUTH_OPENID,
|
||||
'rsa_public_key': rsa_public_key
|
||||
}
|
||||
kwargs.update(context)
|
||||
return super().get_context_data(**kwargs)
|
||||
@@ -141,6 +161,8 @@ class UserLoginGuardView(mixins.AuthMixin, RedirectView):
|
||||
return self.format_redirect_url(self.login_confirm_url)
|
||||
except errors.MFAUnsetError as e:
|
||||
return e.url
|
||||
except errors.PasswdTooSimple as e:
|
||||
return e.url
|
||||
else:
|
||||
auth_login(self.request, user)
|
||||
self.send_auth_signal(success=True, user=user)
|
||||
@@ -164,7 +186,7 @@ class UserLoginWaitConfirmView(TemplateView):
|
||||
context = super().get_context_data(**kwargs)
|
||||
if ticket:
|
||||
timestamp_created = datetime.datetime.timestamp(ticket.date_created)
|
||||
ticket_detail_url = reverse('tickets:ticket-detail', kwargs={'pk': ticket_id})
|
||||
ticket_detail_url = TICKET_DETAIL.format(id=ticket_id)
|
||||
msg = _("""Wait for <b>{}</b> confirm, You also can copy link to her/him <br/>
|
||||
Don't close this page""").format(ticket.assignees_display)
|
||||
else:
|
||||
@@ -212,4 +234,16 @@ class UserLogoutView(TemplateView):
|
||||
return super().get_context_data(**kwargs)
|
||||
|
||||
|
||||
@method_decorator(never_cache, name='dispatch')
|
||||
class FlashPasswdTooSimpleMsgView(TemplateView):
|
||||
template_name = 'flash_message_standalone.html'
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
context = {
|
||||
'title': _('Please change your password'),
|
||||
'messages': _('Your password is too simple, please change it for security'),
|
||||
'interval': 5,
|
||||
'redirect_url': request.GET.get('redirect_url'),
|
||||
'auto_redirect': True,
|
||||
}
|
||||
return self.render_to_response(context)
|
||||
|
||||
8
apps/common/const/choices.py
Normal file
8
apps/common/const/choices.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from common.db.models import ChoiceSet
|
||||
|
||||
|
||||
ADMIN = 'Admin'
|
||||
USER = 'User'
|
||||
AUDITOR = 'Auditor'
|
||||
2
apps/common/const/front_urls.py
Normal file
2
apps/common/const/front_urls.py
Normal file
@@ -0,0 +1,2 @@
|
||||
|
||||
TICKET_DETAIL = '/ui/#/tickets/tickets/{id}'
|
||||
0
apps/common/db/__init__.py
Normal file
0
apps/common/db/__init__.py
Normal file
27
apps/common/db/aggregates.py
Normal file
27
apps/common/db/aggregates.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from django.db.models import Aggregate
|
||||
|
||||
|
||||
class GroupConcat(Aggregate):
|
||||
function = 'GROUP_CONCAT'
|
||||
template = '%(function)s(%(expressions)s %(order_by)s %(separator)s)'
|
||||
allow_distinct = False
|
||||
|
||||
def __init__(self, expression, order_by=None, separator=',', **extra):
|
||||
order_by_clause = ''
|
||||
if order_by is not None:
|
||||
order = 'ASC'
|
||||
prefix, body = order_by[1], order_by[1:]
|
||||
if prefix == '-':
|
||||
order = 'DESC'
|
||||
elif prefix == '+':
|
||||
pass
|
||||
else:
|
||||
body = order_by
|
||||
order_by_clause = f'ORDER BY {body} {order}'
|
||||
|
||||
super().__init__(
|
||||
expression,
|
||||
order_by=order_by_clause,
|
||||
separator=f"SEPARATOR '{separator}'",
|
||||
**extra
|
||||
)
|
||||
84
apps/common/db/models.py
Normal file
84
apps/common/db/models.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""
|
||||
此文件作为 `django.db.models` 的 shortcut
|
||||
|
||||
这样做的优点与缺点为:
|
||||
优点:
|
||||
- 包命名都统一为 `models`
|
||||
- 用户在使用的时候只导入本文件即可
|
||||
缺点:
|
||||
- 此文件中添加代码的时候,注意不要跟 `django.db.models` 中的命名冲突
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from django.db.models import *
|
||||
from django.db.models.functions import Concat
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
|
||||
class Choice(str):
|
||||
def __new__(cls, value, label=''): # `deepcopy` 的时候不会传 `label`
|
||||
self = super().__new__(cls, value)
|
||||
self.label = label
|
||||
return self
|
||||
|
||||
|
||||
class ChoiceSetType(type):
|
||||
def __new__(cls, name, bases, attrs):
|
||||
_choices = []
|
||||
collected = set()
|
||||
new_attrs = {}
|
||||
for k, v in attrs.items():
|
||||
if isinstance(v, tuple):
|
||||
v = Choice(*v)
|
||||
assert v not in collected, 'Cannot be defined repeatedly'
|
||||
_choices.append(v)
|
||||
collected.add(v)
|
||||
new_attrs[k] = v
|
||||
for base in bases:
|
||||
if hasattr(base, '_choices'):
|
||||
for c in base._choices:
|
||||
if c not in collected:
|
||||
_choices.append(c)
|
||||
collected.add(c)
|
||||
new_attrs['_choices'] = _choices
|
||||
new_attrs['_choices_dict'] = {c: c.label for c in _choices}
|
||||
return type.__new__(cls, name, bases, new_attrs)
|
||||
|
||||
def __contains__(self, item):
|
||||
return self._choices_dict.__contains__(item)
|
||||
|
||||
def __getitem__(self, item):
|
||||
return self._choices_dict.__getitem__(item)
|
||||
|
||||
def get(self, item, default=None):
|
||||
return self._choices_dict.get(item, default)
|
||||
|
||||
@property
|
||||
def choices(self):
|
||||
return [(c, c.label) for c in self._choices]
|
||||
|
||||
|
||||
class ChoiceSet(metaclass=ChoiceSetType):
|
||||
choices = None # 用于 Django Model 中的 choices 配置, 为了代码提示在此声明
|
||||
|
||||
|
||||
class JMSBaseModel(Model):
|
||||
created_by = CharField(max_length=32, null=True, blank=True, verbose_name=_('Created by'))
|
||||
updated_by = CharField(max_length=32, null=True, blank=True, verbose_name=_('Updated by'))
|
||||
date_created = DateTimeField(auto_now_add=True, null=True, blank=True, verbose_name=_('Date created'))
|
||||
date_updated = DateTimeField(auto_now=True, verbose_name=_('Date updated'))
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
|
||||
class JMSModel(JMSBaseModel):
|
||||
id = UUIDField(default=uuid.uuid4, primary_key=True)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
|
||||
def concated_display(name1, name2):
|
||||
return Concat(F(name1), Value('('), F(name2), Value(')'))
|
||||
42
apps/common/drf/api.py
Normal file
42
apps/common/drf/api.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from rest_framework.viewsets import GenericViewSet, ModelViewSet
|
||||
from rest_framework_bulk import BulkModelViewSet
|
||||
|
||||
from ..mixins.api import (
|
||||
SerializerMixin2, QuerySetMixin, ExtraFilterFieldsMixin, PaginatedResponseMixin,
|
||||
RelationMixin, AllowBulkDestoryMixin
|
||||
)
|
||||
|
||||
|
||||
class JmsGenericViewSet(SerializerMixin2,
|
||||
QuerySetMixin,
|
||||
ExtraFilterFieldsMixin,
|
||||
PaginatedResponseMixin,
|
||||
GenericViewSet):
|
||||
pass
|
||||
|
||||
|
||||
class JMSModelViewSet(SerializerMixin2,
|
||||
QuerySetMixin,
|
||||
ExtraFilterFieldsMixin,
|
||||
PaginatedResponseMixin,
|
||||
ModelViewSet):
|
||||
pass
|
||||
|
||||
|
||||
class JMSBulkModelViewSet(SerializerMixin2,
|
||||
QuerySetMixin,
|
||||
ExtraFilterFieldsMixin,
|
||||
PaginatedResponseMixin,
|
||||
AllowBulkDestoryMixin,
|
||||
BulkModelViewSet):
|
||||
pass
|
||||
|
||||
|
||||
class JMSBulkRelationModelViewSet(SerializerMixin2,
|
||||
QuerySetMixin,
|
||||
ExtraFilterFieldsMixin,
|
||||
PaginatedResponseMixin,
|
||||
RelationMixin,
|
||||
AllowBulkDestoryMixin,
|
||||
BulkModelViewSet):
|
||||
pass
|
||||
45
apps/common/drf/exc_handlers.py
Normal file
45
apps/common/drf/exc_handlers.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from django.core.exceptions import PermissionDenied, ObjectDoesNotExist as DJObjectDoesNotExist
|
||||
from django.http import Http404
|
||||
from django.utils.translation import gettext
|
||||
|
||||
from rest_framework import exceptions
|
||||
from rest_framework.views import set_rollback
|
||||
from rest_framework.response import Response
|
||||
|
||||
from common.exceptions import JMSObjectDoesNotExist
|
||||
|
||||
|
||||
def extract_object_name(exc, index=0):
|
||||
"""
|
||||
`index` 是从 0 开始数的, 比如:
|
||||
`No User matches the given query.`
|
||||
提取 `User`,`index=1`
|
||||
"""
|
||||
(msg, *_) = exc.args
|
||||
return gettext(msg.split(sep=' ', maxsplit=index + 1)[index])
|
||||
|
||||
|
||||
def common_exception_handler(exc, context):
|
||||
if isinstance(exc, Http404):
|
||||
exc = JMSObjectDoesNotExist(object_name=extract_object_name(exc, 1))
|
||||
elif isinstance(exc, PermissionDenied):
|
||||
exc = exceptions.PermissionDenied()
|
||||
elif isinstance(exc, DJObjectDoesNotExist):
|
||||
exc = JMSObjectDoesNotExist(object_name=extract_object_name(exc, 0))
|
||||
|
||||
if isinstance(exc, exceptions.APIException):
|
||||
headers = {}
|
||||
if getattr(exc, 'auth_header', None):
|
||||
headers['WWW-Authenticate'] = exc.auth_header
|
||||
if getattr(exc, 'wait', None):
|
||||
headers['Retry-After'] = '%d' % exc.wait
|
||||
|
||||
if isinstance(exc.detail, (list, dict)):
|
||||
data = exc.detail
|
||||
else:
|
||||
data = {'detail': exc.detail}
|
||||
|
||||
set_rollback()
|
||||
return Response(data, status=exc.status_code, headers=headers)
|
||||
|
||||
return None
|
||||
43
apps/common/drf/fields.py
Normal file
43
apps/common/drf/fields.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from uuid import UUID
|
||||
|
||||
from rest_framework.fields import get_attribute
|
||||
from rest_framework.relations import ManyRelatedField, PrimaryKeyRelatedField, MANY_RELATION_KWARGS
|
||||
|
||||
|
||||
class GroupConcatedManyRelatedField(ManyRelatedField):
|
||||
def get_attribute(self, instance):
|
||||
if hasattr(instance, 'pk') and instance.pk is None:
|
||||
return []
|
||||
|
||||
attr = self.source_attrs[-1]
|
||||
|
||||
# `gc` 是 `GroupConcat` 的缩写
|
||||
gc_attr = f'gc_{attr}'
|
||||
if hasattr(instance, gc_attr):
|
||||
gc_value = getattr(instance, gc_attr)
|
||||
if isinstance(gc_value, str):
|
||||
return [UUID(pk) for pk in set(gc_value.split(','))]
|
||||
else:
|
||||
return ''
|
||||
|
||||
relationship = get_attribute(instance, self.source_attrs)
|
||||
return relationship.all() if hasattr(relationship, 'all') else relationship
|
||||
|
||||
|
||||
class GroupConcatedPrimaryKeyRelatedField(PrimaryKeyRelatedField):
|
||||
@classmethod
|
||||
def many_init(cls, *args, **kwargs):
|
||||
list_kwargs = {'child_relation': cls(*args, **kwargs)}
|
||||
for key in kwargs:
|
||||
if key in MANY_RELATION_KWARGS:
|
||||
list_kwargs[key] = kwargs[key]
|
||||
return GroupConcatedManyRelatedField(**list_kwargs)
|
||||
|
||||
def to_representation(self, value):
|
||||
if self.pk_field is not None:
|
||||
return self.pk_field.to_representation(value.pk)
|
||||
|
||||
if hasattr(value, 'pk'):
|
||||
return value.pk
|
||||
else:
|
||||
return value
|
||||
@@ -6,18 +6,27 @@ import chardet
|
||||
import codecs
|
||||
import unicodecsv
|
||||
|
||||
from django.utils.translation import ugettext as _
|
||||
from rest_framework.parsers import BaseParser
|
||||
from rest_framework.exceptions import ParseError
|
||||
from rest_framework.exceptions import ParseError, APIException
|
||||
from rest_framework import status
|
||||
|
||||
from common.utils import get_logger
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
||||
|
||||
class CsvDataTooBig(APIException):
|
||||
status_code = status.HTTP_400_BAD_REQUEST
|
||||
default_code = 'csv_data_too_big'
|
||||
default_detail = _('The max size of CSV is %d bytes')
|
||||
|
||||
|
||||
class JMSCSVParser(BaseParser):
|
||||
"""
|
||||
Parses CSV file to serializer data
|
||||
"""
|
||||
CSV_UPLOAD_MAX_SIZE = 1024 * 1024 * 10
|
||||
|
||||
media_type = 'text/csv'
|
||||
|
||||
@@ -38,31 +47,39 @@ class JMSCSVParser(BaseParser):
|
||||
yield row
|
||||
|
||||
@staticmethod
|
||||
def _get_fields_map(serializer):
|
||||
def _get_fields_map(serializer_cls):
|
||||
fields_map = {}
|
||||
fields = serializer.fields
|
||||
fields = serializer_cls().fields
|
||||
fields_map.update({v.label: k for k, v in fields.items()})
|
||||
fields_map.update({k: k for k, _ in fields.items()})
|
||||
return fields_map
|
||||
|
||||
@staticmethod
|
||||
def _process_row(row):
|
||||
def _replace_chinese_quot(str_):
|
||||
trans_table = str.maketrans({
|
||||
'“': '"',
|
||||
'”': '"',
|
||||
'‘': '"',
|
||||
'’': '"',
|
||||
'\'': '"'
|
||||
})
|
||||
return str_.translate(trans_table)
|
||||
|
||||
@classmethod
|
||||
def _process_row(cls, row):
|
||||
"""
|
||||
构建json数据前的行处理
|
||||
"""
|
||||
_row = []
|
||||
|
||||
for col in row:
|
||||
# 列表转换
|
||||
if isinstance(col, str) and col.find("[") != -1 and col.find("]") != -1:
|
||||
# 替换中文格式引号
|
||||
col = col.replace("“", '"').replace("”", '"').\
|
||||
replace("‘", '"').replace('’', '"').replace("'", '"')
|
||||
if isinstance(col, str) and col.startswith('[') and col.endswith(']'):
|
||||
col = cls._replace_chinese_quot(col)
|
||||
col = json.loads(col)
|
||||
# 字典转换
|
||||
if isinstance(col, str) and col.find("{") != -1 and col.find("}") != -1:
|
||||
# 替换中文格式引号
|
||||
col = col.replace("“", '"').replace("”", '"'). \
|
||||
replace("‘", '"').replace('’', '"').replace("'", '"')
|
||||
if isinstance(col, str) and col.startswith("{") and col.endswith("}"):
|
||||
col = cls._replace_chinese_quot(col)
|
||||
col = json.loads(col)
|
||||
_row.append(col)
|
||||
return _row
|
||||
@@ -82,11 +99,19 @@ class JMSCSVParser(BaseParser):
|
||||
def parse(self, stream, media_type=None, parser_context=None):
|
||||
parser_context = parser_context or {}
|
||||
try:
|
||||
serializer = parser_context["view"].get_serializer()
|
||||
view = parser_context['view']
|
||||
meta = view.request.META
|
||||
serializer_cls = view.get_serializer_class()
|
||||
except Exception as e:
|
||||
logger.debug(e, exc_info=True)
|
||||
raise ParseError('The resource does not support imports!')
|
||||
|
||||
content_length = int(meta.get('CONTENT_LENGTH', meta.get('HTTP_CONTENT_LENGTH', 0)))
|
||||
if content_length > self.CSV_UPLOAD_MAX_SIZE:
|
||||
msg = CsvDataTooBig.default_detail % self.CSV_UPLOAD_MAX_SIZE
|
||||
logger.error(msg)
|
||||
raise CsvDataTooBig(msg)
|
||||
|
||||
try:
|
||||
stream_data = stream.read()
|
||||
stream_data = stream_data.strip(codecs.BOM_UTF8)
|
||||
@@ -96,7 +121,7 @@ class JMSCSVParser(BaseParser):
|
||||
rows = self._gen_rows(binary, charset=encoding)
|
||||
|
||||
header = next(rows)
|
||||
fields_map = self._get_fields_map(serializer)
|
||||
fields_map = self._get_fields_map(serializer_cls)
|
||||
header = [fields_map.get(name.strip('*'), '') for name in header]
|
||||
|
||||
data = []
|
||||
|
||||
25
apps/common/drf/serializers.py
Normal file
25
apps/common/drf/serializers.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from rest_framework.serializers import Serializer
|
||||
from rest_framework.serializers import ModelSerializer
|
||||
from rest_framework import serializers
|
||||
from rest_framework_bulk.serializers import BulkListSerializer
|
||||
|
||||
from common.mixins.serializers import BulkSerializerMixin
|
||||
from common.mixins import BulkListSerializerMixin
|
||||
|
||||
__all__ = ['EmptySerializer', 'BulkModelSerializer']
|
||||
|
||||
|
||||
class EmptySerializer(Serializer):
|
||||
pass
|
||||
|
||||
|
||||
class BulkModelSerializer(BulkSerializerMixin, ModelSerializer):
|
||||
pass
|
||||
|
||||
|
||||
class AdaptedBulkListSerializer(BulkListSerializerMixin, BulkListSerializer):
|
||||
pass
|
||||
|
||||
|
||||
class CeleryTaskSerializer(serializers.Serializer):
|
||||
task = serializers.CharField(read_only=True)
|
||||
@@ -1,3 +1,20 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework.exceptions import APIException
|
||||
from rest_framework import status
|
||||
|
||||
|
||||
class JMSException(APIException):
|
||||
status_code = status.HTTP_400_BAD_REQUEST
|
||||
|
||||
|
||||
class JMSObjectDoesNotExist(APIException):
|
||||
status_code = status.HTTP_404_NOT_FOUND
|
||||
default_code = 'object_does_not_exist'
|
||||
default_detail = _('%s object does not exist.')
|
||||
|
||||
def __init__(self, detail=None, code=None, object_name=None):
|
||||
if detail is None and object_name:
|
||||
detail = self.default_detail % object_name
|
||||
super(JMSObjectDoesNotExist, self).__init__(detail=detail, code=code)
|
||||
|
||||
@@ -4,19 +4,22 @@ import time
|
||||
from hashlib import md5
|
||||
from threading import Thread
|
||||
from collections import defaultdict
|
||||
from itertools import chain
|
||||
|
||||
from django.db.models.signals import m2m_changed
|
||||
from django.core.cache import cache
|
||||
from django.http import JsonResponse
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.settings import api_settings
|
||||
from rest_framework import status
|
||||
from rest_framework_bulk.drf3.mixins import BulkDestroyModelMixin
|
||||
|
||||
from common.drf.filters import IDSpmFilter, CustomFilter, IDInFilter
|
||||
from ..utils import lazyproperty
|
||||
|
||||
__all__ = [
|
||||
"JSONResponseMixin", "CommonApiMixin",
|
||||
'AsyncApiMixin', 'RelationMixin'
|
||||
'JSONResponseMixin', 'CommonApiMixin', 'AsyncApiMixin', 'RelationMixin',
|
||||
'SerializerMixin2', 'QuerySetMixin', 'ExtraFilterFieldsMixin'
|
||||
]
|
||||
|
||||
|
||||
@@ -54,9 +57,10 @@ class ExtraFilterFieldsMixin:
|
||||
def get_filter_backends(self):
|
||||
if self.filter_backends != self.__class__.filter_backends:
|
||||
return self.filter_backends
|
||||
backends = list(self.filter_backends) + \
|
||||
list(self.default_added_filters) + \
|
||||
list(self.extra_filter_backends)
|
||||
backends = list(chain(
|
||||
self.filter_backends,
|
||||
self.default_added_filters,
|
||||
self.extra_filter_backends))
|
||||
return backends
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
@@ -65,6 +69,17 @@ class ExtraFilterFieldsMixin:
|
||||
return queryset
|
||||
|
||||
|
||||
class PaginatedResponseMixin:
|
||||
def get_paginated_response_with_query_set(self, queryset):
|
||||
page = self.paginate_queryset(queryset)
|
||||
if page is not None:
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class CommonApiMixin(SerializerMixin, ExtraFilterFieldsMixin):
|
||||
pass
|
||||
|
||||
@@ -210,10 +225,11 @@ class RelationMixin:
|
||||
self.through = getattr(self.m2m_field.model, self.m2m_field.attname).through
|
||||
|
||||
def get_queryset(self):
|
||||
# 注意,此处拦截了 `get_queryset` 没有 `super`
|
||||
queryset = self.through.objects.all()
|
||||
return queryset
|
||||
|
||||
def send_post_add_signal(self, instances):
|
||||
def send_m2m_changed_signal(self, instances, action):
|
||||
if not isinstance(instances, list):
|
||||
instances = [instances]
|
||||
|
||||
@@ -226,10 +242,52 @@ class RelationMixin:
|
||||
|
||||
for from_obj, to_ids in from_to_mapper.items():
|
||||
m2m_changed.send(
|
||||
sender=self.through, instance=from_obj, action='post_add',
|
||||
sender=self.through, instance=from_obj, action=action,
|
||||
reverse=False, model=self.to_model, pk_set=to_ids
|
||||
)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
instance = serializer.save()
|
||||
self.send_post_add_signal(instance)
|
||||
self.send_m2m_changed_signal(instance, 'post_add')
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
instance.delete()
|
||||
self.send_m2m_changed_signal(instance, 'post_remove')
|
||||
|
||||
|
||||
class SerializerMixin2:
|
||||
serializer_classes = {}
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.serializer_classes:
|
||||
serializer_class = self.serializer_classes.get(
|
||||
self.action, self.serializer_classes.get('default')
|
||||
)
|
||||
|
||||
if isinstance(serializer_class, dict):
|
||||
serializer_class = serializer_class.get(
|
||||
self.request.method.lower, serializer_class.get('default')
|
||||
)
|
||||
|
||||
assert serializer_class, '`serializer_classes` config error'
|
||||
return serializer_class
|
||||
return super().get_serializer_class()
|
||||
|
||||
|
||||
class QuerySetMixin:
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
serializer_class = self.get_serializer_class()
|
||||
if serializer_class and hasattr(serializer_class, 'setup_eager_loading'):
|
||||
queryset = serializer_class.setup_eager_loading(queryset)
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
class AllowBulkDestoryMixin:
|
||||
def allow_bulk_destroy(self, qs, filtered):
|
||||
"""
|
||||
我们规定,批量删除的情况必须用 `id` 指定要删除的数据。
|
||||
"""
|
||||
query = str(filtered.query)
|
||||
return '`id` IN (' in query or '`id` =' in query
|
||||
|
||||
@@ -8,7 +8,6 @@ from rest_framework.utils import html
|
||||
from rest_framework.settings import api_settings
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.fields import SkipField, empty
|
||||
|
||||
__all__ = ['BulkSerializerMixin', 'BulkListSerializerMixin', 'CommonSerializerMixin', 'CommonBulkSerializerMixin']
|
||||
|
||||
|
||||
@@ -50,6 +49,15 @@ class BulkSerializerMixin(object):
|
||||
self.initial_data = data
|
||||
return super().run_validation(data)
|
||||
|
||||
@classmethod
|
||||
def many_init(cls, *args, **kwargs):
|
||||
meta = getattr(cls, 'Meta', None)
|
||||
assert meta is not None, 'Must have `Meta`'
|
||||
if not hasattr(meta, 'list_serializer_class'):
|
||||
from common.drf.serializers import AdaptedBulkListSerializer
|
||||
meta.list_serializer_class = AdaptedBulkListSerializer
|
||||
return super(BulkSerializerMixin, cls).many_init(*args, **kwargs)
|
||||
|
||||
|
||||
class BulkListSerializerMixin(object):
|
||||
"""
|
||||
|
||||
@@ -182,3 +182,9 @@ class CanUpdateDeleteUser(permissions.BasePermission):
|
||||
if request.method in ['PUT', 'PATCH']:
|
||||
return self.has_update_object_permission(request, view, obj)
|
||||
return True
|
||||
|
||||
|
||||
class IsObjectOwner(IsValidUser):
|
||||
def has_object_permission(self, request, view, obj):
|
||||
return (super().has_object_permission(request, view, obj) and
|
||||
request.user == getattr(obj, 'user', None))
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
"""
|
||||
老的代码统一到 `apps/common/drf/serializers.py` 中,
|
||||
之后此文件废弃
|
||||
"""
|
||||
|
||||
from rest_framework_bulk.serializers import BulkListSerializer
|
||||
from rest_framework import serializers
|
||||
from .mixins import BulkListSerializerMixin
|
||||
|
||||
|
||||
class AdaptedBulkListSerializer(BulkListSerializerMixin, BulkListSerializer):
|
||||
pass
|
||||
|
||||
|
||||
class CeleryTaskSerializer(serializers.Serializer):
|
||||
task = serializers.CharField(read_only=True)
|
||||
from common.drf.serializers import AdaptedBulkListSerializer, CeleryTaskSerializer
|
||||
|
||||
@@ -11,6 +11,8 @@ import time
|
||||
import ipaddress
|
||||
import psutil
|
||||
|
||||
from .timezone import dt_formater
|
||||
|
||||
|
||||
UUID_PATTERN = re.compile(r'\w{8}(-\w{4}){3}-\w{12}')
|
||||
ipip_db = None
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
#
|
||||
import re
|
||||
from django.shortcuts import reverse as dj_reverse
|
||||
from django.db.models import Subquery, QuerySet
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
33
apps/common/utils/timezone.py
Normal file
33
apps/common/utils/timezone.py
Normal file
@@ -0,0 +1,33 @@
|
||||
import datetime
|
||||
|
||||
import pytz
|
||||
from django.utils import timezone as dj_timezone
|
||||
from rest_framework.fields import DateTimeField
|
||||
|
||||
max = datetime.datetime.max.replace(tzinfo=datetime.timezone.utc)
|
||||
|
||||
|
||||
def astimezone(dt: datetime.datetime, tzinfo: pytz.tzinfo.DstTzInfo):
|
||||
assert dj_timezone.is_aware(dt)
|
||||
return tzinfo.normalize(dt.astimezone(tzinfo))
|
||||
|
||||
|
||||
def as_china_cst(dt: datetime.datetime):
|
||||
return astimezone(dt, pytz.timezone('Asia/Shanghai'))
|
||||
|
||||
|
||||
def as_current_tz(dt: datetime.datetime):
|
||||
return astimezone(dt, dj_timezone.get_current_timezone())
|
||||
|
||||
|
||||
def utcnow():
|
||||
return dj_timezone.now()
|
||||
|
||||
|
||||
def now():
|
||||
return as_current_tz(utcnow())
|
||||
|
||||
|
||||
_rest_dt_field = DateTimeField()
|
||||
dt_parser = _rest_dt_field.to_internal_value
|
||||
dt_formater = _rest_dt_field.to_representation
|
||||
@@ -128,7 +128,7 @@ class DatesLoginMetricMixin:
|
||||
|
||||
@lazyproperty
|
||||
def dates_total_count_inactive_users(self):
|
||||
total = current_org.get_org_members().count()
|
||||
total = current_org.get_members().count()
|
||||
active = self.dates_total_count_active_users
|
||||
count = total - active
|
||||
if count < 0:
|
||||
@@ -137,7 +137,7 @@ class DatesLoginMetricMixin:
|
||||
|
||||
@lazyproperty
|
||||
def dates_total_count_disabled_users(self):
|
||||
return current_org.get_org_members().filter(is_active=False).count()
|
||||
return current_org.get_members().filter(is_active=False).count()
|
||||
|
||||
@lazyproperty
|
||||
def dates_total_count_active_assets(self):
|
||||
@@ -207,7 +207,7 @@ class DatesLoginMetricMixin:
|
||||
class TotalCountMixin:
|
||||
@staticmethod
|
||||
def get_total_count_users():
|
||||
return current_org.get_org_members().count()
|
||||
return current_org.get_members().count()
|
||||
|
||||
@staticmethod
|
||||
def get_total_count_assets():
|
||||
|
||||
@@ -211,6 +211,9 @@ class Config(dict):
|
||||
'CAS_LOGOUT_COMPLETELY': True,
|
||||
'CAS_VERSION': 3,
|
||||
|
||||
'AUTH_SSO': False,
|
||||
'AUTH_SSO_AUTHKEY_TTL': 60 * 15,
|
||||
|
||||
'OTP_VALID_WINDOW': 2,
|
||||
'OTP_ISSUER_NAME': 'JumpServer',
|
||||
'EMAIL_SUFFIX': 'jumpserver.org',
|
||||
@@ -226,6 +229,7 @@ class Config(dict):
|
||||
'TERMINAL_COMMAND_STORAGE': {},
|
||||
|
||||
'SECURITY_MFA_AUTH': False,
|
||||
'SECURITY_COMMAND_EXECUTION': True,
|
||||
'SECURITY_SERVICE_ACCOUNT_REGISTRATION': True,
|
||||
'SECURITY_VIEW_AUTH_NEED_MFA': True,
|
||||
'SECURITY_LOGIN_LIMIT_COUNT': 7,
|
||||
@@ -237,11 +241,13 @@ class Config(dict):
|
||||
'SECURITY_PASSWORD_LOWER_CASE': False,
|
||||
'SECURITY_PASSWORD_NUMBER': False,
|
||||
'SECURITY_PASSWORD_SPECIAL_CHAR': False,
|
||||
'SECURITY_LOGIN_CHALLENGE_ENABLED': False,
|
||||
'SECURITY_LOGIN_CAPTCHA_ENABLED': True,
|
||||
|
||||
'HTTP_BIND_HOST': '0.0.0.0',
|
||||
'HTTP_LISTEN_PORT': 8080,
|
||||
'WS_LISTEN_PORT': 8070,
|
||||
'LOGIN_LOG_KEEP_DAYS': 90,
|
||||
'LOGIN_LOG_KEEP_DAYS': 9999,
|
||||
'TASK_LOG_KEEP_DAYS': 10,
|
||||
'ASSETS_PERM_CACHE_TIME': 3600 * 24,
|
||||
'SECURITY_MFA_VERIFY_TTL': 3600,
|
||||
@@ -437,6 +443,8 @@ class DynamicConfig:
|
||||
backends.insert(0, 'jms_oidc_rp.backends.OIDCAuthCodeBackend')
|
||||
if self.static_config.get('AUTH_RADIUS'):
|
||||
backends.insert(0, 'authentication.backends.radius.RadiusBackend')
|
||||
if self.static_config.get('AUTH_SSO'):
|
||||
backends.insert(0, 'authentication.backends.api.SSOAuthentication')
|
||||
return backends
|
||||
|
||||
def XPACK_LICENSE_IS_VALID(self):
|
||||
|
||||
@@ -92,7 +92,11 @@ CAS_LOGGED_MSG = None
|
||||
CAS_LOGOUT_COMPLETELY = CONFIG.CAS_LOGOUT_COMPLETELY
|
||||
CAS_VERSION = CONFIG.CAS_VERSION
|
||||
CAS_ROOT_PROXIED_AS = CONFIG.CAS_ROOT_PROXIED_AS
|
||||
CAS_CHECK_NEXT = lambda: lambda _next_page: True
|
||||
|
||||
# SSO Auth
|
||||
AUTH_SSO = CONFIG.AUTH_SSO
|
||||
AUTH_SSO_AUTHKEY_TTL = CONFIG.AUTH_SSO_AUTHKEY_TTL
|
||||
|
||||
# Other setting
|
||||
TOKEN_EXPIRATION = CONFIG.TOKEN_EXPIRATION
|
||||
|
||||
@@ -41,6 +41,8 @@ SECURITY_PASSWORD_RULES = [
|
||||
SECURITY_MFA_VERIFY_TTL = CONFIG.SECURITY_MFA_VERIFY_TTL
|
||||
SECURITY_VIEW_AUTH_NEED_MFA = CONFIG.SECURITY_VIEW_AUTH_NEED_MFA
|
||||
SECURITY_SERVICE_ACCOUNT_REGISTRATION = DYNAMIC.SECURITY_SERVICE_ACCOUNT_REGISTRATION
|
||||
SECURITY_LOGIN_CAPTCHA_ENABLED = CONFIG.SECURITY_LOGIN_CAPTCHA_ENABLED
|
||||
SECURITY_LOGIN_CHALLENGE_ENABLED = CONFIG.SECURITY_LOGIN_CHALLENGE_ENABLED
|
||||
|
||||
# Terminal other setting
|
||||
TERMINAL_PASSWORD_AUTH = DYNAMIC.TERMINAL_PASSWORD_AUTH
|
||||
@@ -94,3 +96,5 @@ XPACK_LICENSE_IS_VALID = DYNAMIC.XPACK_LICENSE_IS_VALID
|
||||
LOGO_URLS = DYNAMIC.LOGO_URLS
|
||||
|
||||
CHANGE_AUTH_PLAN_SECURE_MODE_ENABLED = CONFIG.CHANGE_AUTH_PLAN_SECURE_MODE_ENABLED
|
||||
|
||||
DATETIME_DISPLAY_FORMAT = '%Y-%m-%d %H:%M:%S'
|
||||
|
||||
@@ -40,6 +40,7 @@ REST_FRAMEWORK = {
|
||||
'DATETIME_FORMAT': '%Y-%m-%d %H:%M:%S %z',
|
||||
'DATETIME_INPUT_FORMATS': ['iso-8601', '%Y-%m-%d %H:%M:%S %z'],
|
||||
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination',
|
||||
'EXCEPTION_HANDLER': 'common.drf.exc_handlers.common_exception_handler',
|
||||
# 'PAGE_SIZE': 100,
|
||||
# 'MAX_PAGE_SIZE': 5000
|
||||
|
||||
|
||||
@@ -75,8 +75,8 @@ if settings.DEBUG:
|
||||
urlpatterns += [
|
||||
re_path('^api/swagger(?P<format>\.json|\.yaml)$',
|
||||
views.get_swagger_view().without_ui(cache_timeout=1), name='schema-json'),
|
||||
path('api/docs/', views.get_swagger_view().with_ui('swagger', cache_timeout=1), name="docs"),
|
||||
path('api/redoc/', views.get_swagger_view().with_ui('redoc', cache_timeout=1), name='redoc'),
|
||||
re_path('api/docs/?', views.get_swagger_view().with_ui('swagger', cache_timeout=1), name="docs"),
|
||||
re_path('api/redoc/?', views.get_swagger_view().with_ui('redoc', cache_timeout=1), name='redoc'),
|
||||
|
||||
re_path('^api/v2/swagger(?P<format>\.json|\.yaml)$',
|
||||
views.get_swagger_view().without_ui(cache_timeout=1), name='schema-json'),
|
||||
|
||||
@@ -52,6 +52,7 @@ def redirect_format_api(request, *args, **kwargs):
|
||||
return JsonResponse({"msg": "Redirect url failed: {}".format(_path)}, status=404)
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
def redirect_old_apps_view(request, *args, **kwargs):
|
||||
path = request.get_full_path()
|
||||
if path.find('/core') != -1:
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -93,6 +93,12 @@ def delete_celery_periodic_task(task_name):
|
||||
PeriodicTasks.update_changed()
|
||||
|
||||
|
||||
def get_celery_periodic_task(task_name):
|
||||
from django_celery_beat.models import PeriodicTask
|
||||
task = PeriodicTask.objects.filter(name=task_name).first()
|
||||
return task
|
||||
|
||||
|
||||
def get_celery_task_log_path(task_id):
|
||||
task_id = str(task_id)
|
||||
rel_path = os.path.join(task_id[0], task_id[1], task_id + '.log')
|
||||
|
||||
@@ -15,7 +15,10 @@ from .celery.decorator import (
|
||||
register_as_period_task, after_app_shutdown_clean_periodic,
|
||||
after_app_ready_start
|
||||
)
|
||||
from .celery.utils import create_or_update_celery_periodic_tasks
|
||||
from .celery.utils import (
|
||||
create_or_update_celery_periodic_tasks, get_celery_periodic_task,
|
||||
disable_celery_periodic_task, delete_celery_periodic_task
|
||||
)
|
||||
from .models import Task, CommandExecution, CeleryTask
|
||||
from .utils import send_server_performance_mail
|
||||
|
||||
@@ -95,6 +98,29 @@ def clean_celery_tasks_period():
|
||||
subprocess.call(command, shell=True)
|
||||
|
||||
|
||||
@shared_task
|
||||
@after_app_ready_start
|
||||
def clean_celery_periodic_tasks():
|
||||
"""清除celery定时任务"""
|
||||
need_cleaned_tasks = [
|
||||
'handle_be_interrupted_change_auth_task_periodic',
|
||||
]
|
||||
logger.info('Start clean celery periodic tasks: {}'.format(need_cleaned_tasks))
|
||||
for task_name in need_cleaned_tasks:
|
||||
logger.info('Start clean task: {}'.format(task_name))
|
||||
task = get_celery_periodic_task(task_name)
|
||||
if task is None:
|
||||
logger.info('Task does not exist: {}'.format(task_name))
|
||||
continue
|
||||
disable_celery_periodic_task(task_name)
|
||||
delete_celery_periodic_task(task_name)
|
||||
task = get_celery_periodic_task(task_name)
|
||||
if task is None:
|
||||
logger.info('Clean task success: {}'.format(task_name))
|
||||
else:
|
||||
logger.info('Clean task failure: {}'.format(task))
|
||||
|
||||
|
||||
@shared_task
|
||||
@after_app_ready_start
|
||||
def create_or_update_registered_periodic_tasks():
|
||||
|
||||
@@ -69,7 +69,7 @@ def send_server_performance_mail(path, usage, usages):
|
||||
from users.models import User
|
||||
subject = _("Disk used more than 80%: {} => {}").format(path, usage.percent)
|
||||
message = subject
|
||||
admins = User.objects.filter(role=User.ROLE_ADMIN)
|
||||
admins = User.objects.filter(role=User.ROLE.ADMIN)
|
||||
recipient_list = [u.email for u in admins if u.email]
|
||||
logger.info(subject)
|
||||
send_mail_async(subject, message, recipient_list, html_message=message)
|
||||
|
||||
@@ -1,27 +1,31 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.translation import ugettext as _
|
||||
from rest_framework import status, generics
|
||||
from rest_framework.views import Response
|
||||
from rest_framework_bulk import BulkModelViewSet
|
||||
|
||||
from common.permissions import IsSuperUserOrAppUser
|
||||
from .models import Organization
|
||||
from .serializers import OrgSerializer, OrgReadSerializer, \
|
||||
OrgMembershipUserSerializer, OrgMembershipAdminSerializer, \
|
||||
OrgAllUserSerializer, OrgRetrieveSerializer
|
||||
from common.drf.api import JMSBulkRelationModelViewSet
|
||||
from .models import Organization, ROLE
|
||||
from .serializers import (
|
||||
OrgSerializer, OrgReadSerializer,
|
||||
OrgRetrieveSerializer, OrgMemberSerializer
|
||||
)
|
||||
from users.models import User, UserGroup
|
||||
from assets.models import Asset, Domain, AdminUser, SystemUser, Label
|
||||
from perms.models import AssetPermission
|
||||
from orgs.utils import current_org
|
||||
from common.utils import get_logger
|
||||
from .mixins.api import OrgMembershipModelViewSetMixin
|
||||
from .filters import OrgMemberRelationFilterSet
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
||||
|
||||
class OrgViewSet(BulkModelViewSet):
|
||||
filter_fields = ('name',)
|
||||
search_fields = ('name', 'comment')
|
||||
queryset = Organization.objects.all()
|
||||
serializer_class = OrgSerializer
|
||||
permission_classes = (IsSuperUserOrAppUser,)
|
||||
@@ -36,7 +40,7 @@ class OrgViewSet(BulkModelViewSet):
|
||||
|
||||
def get_data_from_model(self, model):
|
||||
if model == User:
|
||||
data = model.objects.filter(related_user_orgs__id=self.org.id)
|
||||
data = model.objects.filter(orgs__id=self.org.id, m2m_org_members__role=ROLE.USER)
|
||||
else:
|
||||
data = model.objects.filter(org_id=self.org.id)
|
||||
return data
|
||||
@@ -51,34 +55,23 @@ class OrgViewSet(BulkModelViewSet):
|
||||
for model in models:
|
||||
data = self.get_data_from_model(model)
|
||||
if data:
|
||||
return Response(status=status.HTTP_400_BAD_REQUEST)
|
||||
msg = _('Organization contains undeleted resources')
|
||||
return Response(data={'error': msg}, status=status.HTTP_403_FORBIDDEN)
|
||||
else:
|
||||
if str(current_org) == str(self.org):
|
||||
return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED)
|
||||
msg = _('The current organization cannot be deleted')
|
||||
return Response(data={'error': msg}, status=status.HTTP_403_FORBIDDEN)
|
||||
self.org.delete()
|
||||
return Response({'msg': True}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class OrgMembershipAdminsViewSet(OrgMembershipModelViewSetMixin, BulkModelViewSet):
|
||||
serializer_class = OrgMembershipAdminSerializer
|
||||
membership_class = Organization.admins.through
|
||||
permission_classes = (IsSuperUserOrAppUser, )
|
||||
|
||||
|
||||
class OrgMembershipUsersViewSet(OrgMembershipModelViewSetMixin, BulkModelViewSet):
|
||||
serializer_class = OrgMembershipUserSerializer
|
||||
membership_class = Organization.users.through
|
||||
permission_classes = (IsSuperUserOrAppUser, )
|
||||
|
||||
|
||||
class OrgAllUserListApi(generics.ListAPIView):
|
||||
class OrgMemberRelationBulkViewSet(JMSBulkRelationModelViewSet):
|
||||
permission_classes = (IsSuperUserOrAppUser,)
|
||||
serializer_class = OrgAllUserSerializer
|
||||
filter_fields = ("username", "name")
|
||||
search_fields = filter_fields
|
||||
m2m_field = Organization.members.field
|
||||
serializer_class = OrgMemberSerializer
|
||||
filterset_class = OrgMemberRelationFilterSet
|
||||
|
||||
def get_queryset(self):
|
||||
pk = self.kwargs.get("pk")
|
||||
org = get_object_or_404(Organization, pk=pk)
|
||||
users = org.get_org_users().only(*self.serializer_class.Meta.only_fields)
|
||||
return users
|
||||
def perform_bulk_destroy(self, queryset):
|
||||
objs = list(queryset.all().prefetch_related('user', 'org'))
|
||||
queryset.delete()
|
||||
self.send_m2m_changed_signal(objs, action='post_remove')
|
||||
|
||||
16
apps/orgs/filters.py
Normal file
16
apps/orgs/filters.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from django_filters.rest_framework import filterset
|
||||
from django_filters.rest_framework import filters
|
||||
|
||||
from .models import OrganizationMember
|
||||
|
||||
|
||||
class UUIDInFilter(filters.BaseInFilter, filters.UUIDFilter):
|
||||
pass
|
||||
|
||||
|
||||
class OrgMemberRelationFilterSet(filterset.FilterSet):
|
||||
id = UUIDInFilter(field_name='id', lookup_expr='in')
|
||||
|
||||
class Meta:
|
||||
model = OrganizationMember
|
||||
fields = ('org_id', 'user_id', 'role', 'id')
|
||||
33
apps/orgs/migrations/0004_organizationmember.py
Normal file
33
apps/orgs/migrations/0004_organizationmember.py
Normal file
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 2.2.10 on 2020-07-21 11:27
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('orgs', '0003_auto_20190916_1057'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='OrganizationMember',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
|
||||
('role', models.CharField(choices=[('Admin', 'Administrator'), ('User', 'User'), ('Auditor', 'Auditor')], default='User', max_length=16, verbose_name='Role')),
|
||||
('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')),
|
||||
('org', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='m2m_org_members', to='orgs.Organization', verbose_name='Organization')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='m2m_org_members', to=settings.AUTH_USER_MODEL, verbose_name='User')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'orgs_organization_members',
|
||||
'unique_together': {('org', 'user', 'role')},
|
||||
},
|
||||
),
|
||||
]
|
||||
35
apps/orgs/migrations/0005_auto_20200721_1937.py
Normal file
35
apps/orgs/migrations/0005_auto_20200721_1937.py
Normal file
@@ -0,0 +1,35 @@
|
||||
# Generated by Django 2.2.10 on 2020-07-21 11:37
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def migrate_old_organization_members(apps, schema_editor):
|
||||
org_model = apps.get_model("orgs", "Organization")
|
||||
org_member_model = apps.get_model('orgs', 'OrganizationMember')
|
||||
orgs = org_model.objects.all()
|
||||
|
||||
roles = ['User', 'Auditor', 'Admin']
|
||||
|
||||
for org in orgs:
|
||||
users = org.users.all().only('id')
|
||||
auditors = org.auditors.all().only('id')
|
||||
admins = org.admins.all().only('id')
|
||||
total_members = zip([users, auditors, admins], roles)
|
||||
|
||||
org_members = []
|
||||
for members, role in total_members:
|
||||
for user in members:
|
||||
org_user = org_member_model(user=user, org=org, role=role)
|
||||
org_members.append(org_user)
|
||||
org_member_model.objects.bulk_create(org_members)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('orgs', '0004_organizationmember'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(migrate_old_organization_members)
|
||||
]
|
||||
32
apps/orgs/migrations/0006_auto_20200721_1937.py
Normal file
32
apps/orgs/migrations/0006_auto_20200721_1937.py
Normal file
@@ -0,0 +1,32 @@
|
||||
# Generated by Django 2.2.10 on 2020-07-21 11:37
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('orgs', '0005_auto_20200721_1937'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='organization',
|
||||
name='admins',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='organization',
|
||||
name='auditors',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='organization',
|
||||
name='users',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='organization',
|
||||
name='members',
|
||||
field=models.ManyToManyField(related_name='orgs', through='orgs.OrganizationMember', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user