Merge pull request #4488 from jumpserver/dev

Dev
pull/4561/head
BaiJiangJie 2020-08-14 11:45:42 +08:00 committed by GitHub
commit ab8c57894e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
132 changed files with 3139 additions and 837 deletions

View File

@ -4,6 +4,9 @@
##### 使用版本 ##### 使用版本
[请提供你使用的JumpServer版本 如 2.0.1 注: 1.4及以下版本不再提供支持] [请提供你使用的JumpServer版本 如 2.0.1 注: 1.4及以下版本不再提供支持]
##### 使用浏览器版本
[请提供你使用的浏览器版本 如 Chrome 84.0.4147.105 ]
##### 问题复现步骤 ##### 问题复现步骤
1. [步骤1] 1. [步骤1]
2. [步骤2] 2. [步骤2]

View File

@ -9,18 +9,23 @@ RUN cd utils && bash -ixeu build.sh
FROM registry.fit2cloud.com/public/python:v3 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 WORKDIR /opt/jumpserver
COPY --from=stage-build /opt/jumpserver/release/jumpserver /opt/jumpserver
COPY ./requirements ./requirements
RUN useradd jumpserver RUN useradd jumpserver
RUN yum -y install epel-release && \ 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 echo -e "[mysql]\nname=mysql\nbaseurl=${MYSQL_MIRROR}\ngpgcheck=0\nenabled=1" > /etc/yum.repos.d/mysql.repo
COPY . .
RUN yum -y install $(cat requirements/rpm_requirements.txt) RUN yum -y install $(cat requirements/rpm_requirements.txt)
RUN pip install --upgrade pip setuptools && pip install wheel && \ RUN pip install --upgrade pip setuptools wheel -i ${PIP_MIRROR} && \
pip install -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements/requirements.txt || pip install -r requirements/requirements.txt 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 RUN mkdir -p /root/.ssh/ && echo -e "Host *\n\tStrictHostKeyChecking no\n\tUserKnownHostsFile /dev/null" > /root/.ssh/config
RUN echo > config.yml RUN echo > config.yml

View File

@ -1,2 +1,3 @@
from .remote_app import * from .remote_app import *
from .database_app import * from .database_app import *
from .k8s_app import *

View 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

View File

@ -23,6 +23,7 @@ REMOTE_APP_TYPE_CHROME_FIELDS = [
REMOTE_APP_TYPE_MYSQL_WORKBENCH_FIELDS = [ REMOTE_APP_TYPE_MYSQL_WORKBENCH_FIELDS = [
{'name': 'mysql_workbench_ip'}, {'name': 'mysql_workbench_ip'},
{'name': 'mysql_workbench_name'}, {'name': 'mysql_workbench_name'},
{'name': 'mysql_workbench_port'},
{'name': 'mysql_workbench_username'}, {'name': 'mysql_workbench_username'},
{'name': 'mysql_workbench_password', 'write_only': True} {'name': 'mysql_workbench_password', 'write_only': True}
] ]

View 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')},
},
),
]

View File

@ -1,2 +1,3 @@
from .remote_app import * from .remote_app import *
from .database_app import * from .database_app import *
from .k8s_app import *

View 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', )

View File

@ -1,2 +1,3 @@
from .remote_app import * from .remote_app import *
from .database_app import * from .database_app import *
from .k8s_app import *

View 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',
]

View File

@ -12,6 +12,7 @@ app_name = 'applications'
router = BulkRouter() router = BulkRouter()
router.register(r'remote-apps', api.RemoteAppViewSet, 'remote-app') router.register(r'remote-apps', api.RemoteAppViewSet, 'remote-app')
router.register(r'database-apps', api.DatabaseAppViewSet, 'database-app') router.register(r'database-apps', api.DatabaseAppViewSet, 'database-app')
router.register(r'k8s-apps', api.K8sAppViewSet, 'k8s-app')
urlpatterns = [ urlpatterns = [
path('remote-apps/<uuid:pk>/connection-info/', api.RemoteAppConnectionInfoApi.as_view(), name='remote-app-connection-info'), path('remote-apps/<uuid:pk>/connection-info/', api.RemoteAppConnectionInfoApi.as_view(), name='remote-app-connection-info'),

View 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'),
),
]

View 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'),
),
]

View 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'),
),
]

View File

@ -230,7 +230,7 @@ class AuthMixin:
class BaseUser(OrgModelMixin, AuthMixin, ConnectivityMixin): class BaseUser(OrgModelMixin, AuthMixin, ConnectivityMixin):
id = models.UUIDField(default=uuid.uuid4, primary_key=True) id = models.UUIDField(default=uuid.uuid4, primary_key=True)
name = models.CharField(max_length=128, verbose_name=_('Name')) 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')) 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')) 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')) public_key = fields.EncryptTextField(blank=True, null=True, verbose_name=_('SSH public key'))

View File

@ -9,6 +9,7 @@ from django.utils.translation import ugettext_lazy as _
from django.core.validators import MinValueValidator, MaxValueValidator from django.core.validators import MinValueValidator, MaxValueValidator
from common.utils import signer from common.utils import signer
from common.fields.model import JsonListCharField
from .base import BaseUser from .base import BaseUser
from .asset import Asset from .asset import Asset
@ -91,12 +92,14 @@ class SystemUser(BaseUser):
PROTOCOL_TELNET = 'telnet' PROTOCOL_TELNET = 'telnet'
PROTOCOL_VNC = 'vnc' PROTOCOL_VNC = 'vnc'
PROTOCOL_MYSQL = 'mysql' PROTOCOL_MYSQL = 'mysql'
PROTOCOL_K8S = 'k8s'
PROTOCOL_CHOICES = ( PROTOCOL_CHOICES = (
(PROTOCOL_SSH, 'ssh'), (PROTOCOL_SSH, 'ssh'),
(PROTOCOL_RDP, 'rdp'), (PROTOCOL_RDP, 'rdp'),
(PROTOCOL_TELNET, 'telnet'), (PROTOCOL_TELNET, 'telnet'),
(PROTOCOL_VNC, 'vnc'), (PROTOCOL_VNC, 'vnc'),
(PROTOCOL_MYSQL, 'mysql'), (PROTOCOL_MYSQL, 'mysql'),
(PROTOCOL_K8S, 'k8s'),
) )
LOGIN_AUTO = 'auto' 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')) 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) 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")) 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' _prefer = 'system_user'
def __str__(self): def __str__(self):

View File

@ -33,13 +33,15 @@ class SystemUserSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
'login_mode', 'login_mode_display', 'login_mode', 'login_mode_display',
'priority', 'username_same_with_user', 'priority', 'username_same_with_user',
'auto_push', 'cmd_filters', 'sudo', 'shell', 'comment', 'auto_push', 'cmd_filters', 'sudo', 'shell', 'comment',
'auto_generate_key', 'sftp_root', 'auto_generate_key', 'sftp_root', 'token',
'assets_amount', 'date_created', 'created_by' 'assets_amount', 'date_created', 'created_by',
'home', 'system_groups'
] ]
extra_kwargs = { extra_kwargs = {
'password': {"write_only": True}, 'password': {"write_only": True},
'public_key': {"write_only": True}, 'public_key': {"write_only": True},
'private_key': {"write_only": True}, 'private_key': {"write_only": True},
'token': {"write_only": True},
'nodes_amount': {'label': _('Node')}, 'nodes_amount': {'label': _('Node')},
'assets_amount': {'label': _('Asset')}, 'assets_amount': {'label': _('Asset')},
'login_mode_display': {'label': _('Login mode display')}, 'login_mode_display': {'label': _('Login mode display')},
@ -143,13 +145,14 @@ class SystemUserSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
class SystemUserListSerializer(SystemUserSerializer): class SystemUserListSerializer(SystemUserSerializer):
class Meta(SystemUserSerializer.Meta): class Meta(SystemUserSerializer.Meta):
fields = [ fields = [
'id', 'name', 'username', 'protocol', 'id', 'name', 'username', 'protocol',
'login_mode', 'login_mode_display', 'login_mode', 'login_mode_display',
'priority', "username_same_with_user", 'priority', "username_same_with_user",
'auto_push', 'sudo', 'shell', 'comment', 'auto_push', 'sudo', 'shell', 'comment',
"assets_amount", "assets_amount", 'home', 'system_groups',
'auto_generate_key', 'auto_generate_key',
'sftp_root', 'sftp_root',
] ]
@ -169,7 +172,7 @@ class SystemUserWithAuthInfoSerializer(SystemUserSerializer):
'login_mode', 'login_mode_display', 'login_mode', 'login_mode_display',
'priority', 'username_same_with_user', 'priority', 'username_same_with_user',
'auto_push', 'sudo', 'shell', 'comment', 'auto_push', 'sudo', 'shell', 'comment',
'auto_generate_key', 'sftp_root', 'auto_generate_key', 'sftp_root', 'token'
] ]
extra_kwargs = { extra_kwargs = {
'nodes_amount': {'label': _('Node')}, 'nodes_amount': {'label': _('Node')},

View File

@ -3,9 +3,10 @@
from itertools import groupby from itertools import groupby
from celery import shared_task from celery import shared_task
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django.db.models import Empty
from common.utils import encrypt_password, get_logger 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 . import const
from .utils import clean_ansible_task_hosts, group_asset_by_platform 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): def get_push_unixlike_system_user_tasks(system_user, username=None):
if username is None: if username is None:
username = system_user.username username = system_user.username
password = system_user.password password = system_user.password
public_key = system_user.public_key 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 = [ tasks = [
{ {
'name': 'Add user {}'.format(username), 'name': 'Add user {}'.format(username),
'action': { 'action': {
'module': 'user', 'module': 'user',
'args': 'name={} shell={} state=present'.format( 'args': _dump_args(add_user_args),
username, system_user.shell or '/bin/bash',
),
} }
}, },
{ {
@ -102,6 +125,11 @@ def get_push_windows_system_user_tasks(system_user, username=None):
if username is None: if username is None:
username = system_user.username username = system_user.username
password = system_user.password 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 = [] tasks = []
if not password: if not password:
return tasks return tasks
@ -116,9 +144,9 @@ def get_push_windows_system_user_tasks(system_user, username=None):
'update_password=always ' 'update_password=always '
'password_expired=no ' 'password_expired=no '
'password_never_expires=yes ' 'password_never_expires=yes '
'groups="Users,Remote Desktop Users" ' 'groups="{}" '
'groups_action=add ' 'groups_action=add '
''.format(username, username, password), ''.format(username, username, password, groups),
} }
} }
tasks.append(task) tasks.append(task)

View File

@ -1,7 +1,6 @@
# coding:utf-8 # coding:utf-8
from django.urls import path, re_path from django.urls import path, re_path
from rest_framework_nested import routers from rest_framework_nested import routers
# from rest_framework.routers import DefaultRouter
from rest_framework_bulk.routes import BulkRouter from rest_framework_bulk.routes import BulkRouter
from common import api as capi from common import api as capi

View File

@ -43,7 +43,7 @@ class UserLoginLogViewSet(ListModelMixin, CommonGenericViewSet):
@staticmethod @staticmethod
def get_org_members(): 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 return users
def get_queryset(self): def get_queryset(self):
@ -79,7 +79,7 @@ class PasswordChangeLogViewSet(ListModelMixin, CommonGenericViewSet):
ordering = ['-datetime'] ordering = ['-datetime']
def get_queryset(self): def get_queryset(self):
users = current_org.get_org_members() users = current_org.get_members()
queryset = super().get_queryset().filter( queryset = super().get_queryset().filter(
user__in=[user.__str__() for user in users] user__in=[user.__str__() for user in users]
) )

View File

@ -20,7 +20,7 @@ class CurrentOrgMembersFilter(filters.BaseFilterBackend):
] ]
def _get_user_list(self): def _get_user_list(self):
users = current_org.get_org_members(exclude=('Auditor',)) users = current_org.get_members(exclude=('Auditor',))
return users return users
def filter_queryset(self, request, queryset, view): def filter_queryset(self, request, queryset, view):

View 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'),
),
]

View File

@ -58,7 +58,7 @@ class OperateLog(OrgModelMixin):
resource_type = models.CharField(max_length=64, verbose_name=_("Resource Type")) resource_type = models.CharField(max_length=64, verbose_name=_("Resource Type"))
resource = models.CharField(max_length=128, verbose_name=_("Resource")) resource = models.CharField(max_length=128, verbose_name=_("Resource"))
remote_addr = models.CharField(max_length=128, verbose_name=_("Remote addr"), blank=True, null=True) 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): def __str__(self):
return "<{}> {} <{}>".format(self.user, self.action, self.resource) return "<{}> {} <{}>".format(self.user, self.action, self.resource)
@ -124,7 +124,7 @@ class UserLoginLog(models.Model):
Q(username__contains=keyword) Q(username__contains=keyword)
) )
if not current_org.is_root(): 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) login_logs = login_logs.filter(username__in=username_list)
return login_logs return login_logs

View File

@ -6,3 +6,4 @@ from .token import *
from .mfa import * from .mfa import *
from .access_key import * from .access_key import *
from .login_confirm import * from .login_confirm import *
from .sso import *

View 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)

View File

@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# #
from django.shortcuts import redirect
from rest_framework.permissions import AllowAny from rest_framework.permissions import AllowAny
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.generics import CreateAPIView from rest_framework.generics import CreateAPIView
@ -40,3 +40,5 @@ class TokenCreateApi(AuthMixin, CreateAPIView):
return Response(e.as_data(), status=400) return Response(e.as_data(), status=400)
except errors.NeedMoreInfoError as e: except errors.NeedMoreInfoError as e:
return Response(e.as_data(), status=200) return Response(e.as_data(), status=200)
except errors.PasswdTooSimple as e:
return redirect(e.url)

View File

@ -5,14 +5,13 @@ import uuid
import time import time
from django.core.cache import cache from django.core.cache import cache
from django.conf import settings
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django.utils.six import text_type from django.utils.six import text_type
from django.contrib.auth import get_user_model 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 HTTP_HEADER_ENCODING
from rest_framework import authentication, exceptions from rest_framework import authentication, exceptions
from common.auth import signature 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 common.utils import get_object_or_none, make_signature, http_to_unixtime
from ..models import AccessKey, PrivateToken from ..models import AccessKey, PrivateToken
@ -197,3 +196,10 @@ class SignatureAuthentication(signature.SignatureAuthentication):
return user, secret return user, secret
except AccessKey.DoesNotExist: except AccessKey.DoesNotExist:
return None, None return None, None
class SSOAuthentication(ModelBackend):
"""
什么也不做呀😺
"""
pass

View File

@ -1,11 +1,11 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# #
import traceback
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from radiusauth.backends import RADIUSBackend, RADIUSRealmBackend from radiusauth.backends import RADIUSBackend, RADIUSRealmBackend
from django.conf import settings from django.conf import settings
from pyrad.packet import AccessRequest
User = get_user_model() User = get_user_model()
@ -27,11 +27,22 @@ class CreateUserMixin:
user.save() user.save()
return user 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): def authenticate(self, *args, **kwargs):
# 校验用户时会传入public_key参数父类authentication中不接受public_key参数所以要pop掉 # 校验用户时会传入public_key参数父类authentication中不接受public_key参数所以要pop掉
# TODO:需要优化各backend的authenticate方法django进行调用前会检测各authenticate的参数 # TODO:需要优化各backend的authenticate方法django进行调用前会检测各authenticate的参数
kwargs.pop('public_key', None) kwargs.pop('public_key', None)
return super().authenticate(*args, *kwargs) return super().authenticate(*args, **kwargs)
class RadiusBackend(CreateUserMixin, RADIUSBackend): class RadiusBackend(CreateUserMixin, RADIUSBackend):

View File

@ -0,0 +1,2 @@
RSA_PRIVATE_KEY = 'rsa_private_key'
RSA_PUBLIC_KEY = 'rsa_public_key'

View File

@ -4,6 +4,7 @@ from django.utils.translation import ugettext_lazy as _
from django.urls import reverse from django.urls import reverse
from django.conf import settings from django.conf import settings
from common.exceptions import JMSException
from .signals import post_auth_failed from .signals import post_auth_failed
from users.utils import ( from users.utils import (
increase_login_failed_count, get_login_failed_count increase_login_failed_count, get_login_failed_count
@ -205,3 +206,17 @@ class LoginConfirmOtherError(LoginConfirmBaseError):
def __init__(self, ticket_id, status): def __init__(self, ticket_id, status):
msg = login_confirm_error_msg.format(status) msg = login_confirm_error_msg.format(status)
super().__init__(ticket_id=ticket_id, msg=msg) 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

View 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'
)
)
]

View File

@ -2,6 +2,7 @@
# #
from django import forms from django import forms
from django.conf import settings
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from captcha.fields import CaptchaField from captcha.fields import CaptchaField
@ -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() captcha = CaptchaField()
class UserCheckOtpCodeForm(forms.Form): class ChallengeMixin(forms.Form):
otp_code = forms.CharField(label=_('MFA code'), max_length=6) 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), {})

View 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,
},
),
]

View File

@ -1,7 +1,12 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# #
from urllib.parse import urlencode
from functools import partial
import time import time
from django.conf import settings from django.conf import settings
from django.contrib.auth import authenticate
from django.shortcuts import reverse
from common.utils import get_object_or_none, get_request_ip, get_logger from common.utils import get_object_or_none, get_request_ip, get_logger
from users.models import User from users.models import User
@ -9,8 +14,9 @@ from users.utils import (
is_block_login, clean_failed_count is_block_login, clean_failed_count
) )
from . import errors from . import errors
from .utils import check_user_valid from .utils import rsa_decrypt
from .signals import post_auth_success, post_auth_failed from .signals import post_auth_success, post_auth_failed
from .const import RSA_PRIVATE_KEY
logger = get_logger(__name__) logger = get_logger(__name__)
@ -50,25 +56,54 @@ class AuthMixin:
logger.warn('Ip was blocked' + ': ' + username + ':' + ip) logger.warn('Ip was blocked' + ': ' + username + ':' + ip)
raise errors.BlockLoginError(username=username, ip=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() self.check_is_block()
request = self.request request = self.request
if hasattr(request, 'data'): if hasattr(request, 'data'):
username = request.data.get('username', '') data = request.data
password = request.data.get('password', '')
public_key = request.data.get('public_key', '')
else: else:
username = request.POST.get('username', '') data = request.POST
password = request.POST.get('password', '') username = data.get('username', '')
public_key = request.POST.get('public_key', '') password = data.get('password', '')
user, error = check_user_valid( challenge = data.get('challenge', '')
request=request, username=username, password=password, public_key=public_key public_key = data.get('public_key', '')
)
ip = self.get_request_ip() 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: if not user:
raise errors.CredentialError( raise CredentialError(error=errors.reason_password_failed)
username=username, error=error, ip=ip, request=request 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) clean_failed_count(username, ip)
request.session['auth_password'] = 1 request.session['auth_password'] = 1
request.session['user_id'] = str(user.id) request.session['user_id'] = str(user.id)
@ -76,14 +111,30 @@ class AuthMixin:
request.session['auth_backend'] = auth_backend request.session['auth_backend'] = auth_backend
return user 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 request = self.request
if request.session.get('auth_password') and \ if request.session.get('auth_password') and \
request.session.get('user_id'): request.session.get('user_id'):
user = self.get_user_from_session() user = self.get_user_from_session()
if user: if user:
return 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): def check_user_mfa_if_need(self, user):
if self.request.session.get('auth_mfa'): if self.request.session.get('auth_mfa'):
@ -117,7 +168,7 @@ class AuthMixin:
def get_ticket_or_create(self, confirm_setting): def get_ticket_or_create(self, confirm_setting):
ticket = self.get_ticket() 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) ticket = confirm_setting.create_confirm_ticket(self.request)
self.request.session['auth_ticket_id'] = str(ticket.id) self.request.session['auth_ticket_id'] = str(ticket.id)
return ticket return ticket
@ -126,12 +177,12 @@ class AuthMixin:
ticket = self.get_ticket() ticket = self.get_ticket()
if not ticket: if not ticket:
raise errors.LoginConfirmOtherError('', "Not found") raise errors.LoginConfirmOtherError('', "Not found")
if ticket.status == ticket.STATUS_OPEN: if ticket.status == ticket.STATUS.OPEN:
raise errors.LoginConfirmWaitError(ticket.id) raise errors.LoginConfirmWaitError(ticket.id)
elif ticket.action == ticket.ACTION_APPROVE: elif ticket.action == ticket.ACTION.APPROVE:
self.request.session["auth_confirm"] = "1" self.request.session["auth_confirm"] = "1"
return return
elif ticket.action == ticket.ACTION_REJECT: elif ticket.action == ticket.ACTION.REJECT:
raise errors.LoginConfirmOtherError( raise errors.LoginConfirmOtherError(
ticket.id, ticket.get_action_display() ticket.id, ticket.get_action_display()
) )

View File

@ -1,10 +1,13 @@
import uuid import uuid
from django.db import models from functools import partial
from django.utils import timezone from django.utils import timezone
from django.utils.translation import ugettext_lazy as _, ugettext as __ from django.utils.translation import ugettext_lazy as _, ugettext as __
from rest_framework.authtoken.models import Token from rest_framework.authtoken.models import Token
from django.conf import settings 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.mixins.models import CommonModelMixin
from common.utils import get_object_or_none, get_request_ip, get_ip_city 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() reviewer = self.reviewers.all()
ticket = Ticket.objects.create( ticket = Ticket.objects.create(
user=self.user, title=title, body=body, user=self.user, title=title, body=body,
type=Ticket.TYPE_LOGIN_CONFIRM, type=Ticket.TYPE.LOGIN_CONFIRM,
) )
ticket.assignees.set(reviewer) ticket.assignees.set(reviewer)
return ticket return ticket
@ -76,3 +79,12 @@ class LoginConfirmSetting(CommonModelMixin):
def __str__(self): def __str__(self):
return '{} confirm'.format(self.user.username) 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)

View File

@ -5,12 +5,12 @@ from rest_framework import serializers
from common.utils import get_object_or_none from common.utils import get_object_or_none
from users.models import User from users.models import User
from users.serializers import UserProfileSerializer from users.serializers import UserProfileSerializer
from .models import AccessKey, LoginConfirmSetting from .models import AccessKey, LoginConfirmSetting, SSOToken
__all__ = [ __all__ = [
'AccessKeySerializer', 'OtpVerifySerializer', 'BearerTokenSerializer', 'AccessKeySerializer', 'OtpVerifySerializer', 'BearerTokenSerializer',
'MFAChallengeSerializer', 'LoginConfirmSettingSerializer', 'MFAChallengeSerializer', 'LoginConfirmSettingSerializer', 'SSOTokenSerializer',
] ]
@ -76,3 +76,9 @@ class LoginConfirmSettingSerializer(serializers.ModelSerializer):
model = LoginConfirmSetting model = LoginConfirmSetting
fields = ['id', 'user', 'reviewers', 'date_created', 'date_updated'] fields = ['id', 'user', 'reviewers', 'date_created', 'date_updated']
read_only_fields = ['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)

View File

@ -33,6 +33,16 @@
</div> </div>
{% endif %} {% endif %}
</div> </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> <div>
{{ form.captcha }} {{ form.captcha }}
</div> </div>

View File

@ -67,22 +67,30 @@
</div> </div>
<div class="box-3"> <div class="box-3">
<div style="background-color: white"> <div style="background-color: white">
<div style="margin-top: 30px;padding-top: 40px;padding-left: 20px;padding-right: 20px;height: 80px"> {% if form.challenge %}
<span style="font-size: 21px;font-weight:400;color: #151515;letter-spacing: 0;">{{ JMS_TITLE }}</span> <div style="margin-top: 20px;padding-top: 30px;padding-left: 20px;padding-right: 20px;height: 60px">
</div> {% 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"> <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' %} {% trans 'Welcome back, please enter username and password to login' %}
</div> </div>
<div style="margin-bottom: 10px"> <div style="margin-bottom: 0px">
<div> <div>
<div class="col-md-1"></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"> <form id="contact-form" action="" method="post" role="form" novalidate="novalidate">
{% csrf_token %} {% csrf_token %}
{% if form.non_field_errors %} {% if form.non_field_errors %}
<div style="height: 70px;color: red;line-height: 17px;"> {% if form.challenge %}
<p class="red-fonts">{{ form.non_field_errors.as_text }}</p> <div style="height: 50px;color: red;line-height: 17px;">
</div> {% 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 %} {% elif form.errors.captcha %}
<p class="red-fonts">{% trans 'Captcha invalid' %}</p> <p class="red-fonts">{% trans 'Captcha invalid' %}</p>
{% else %} {% else %}
@ -105,6 +113,16 @@
</div> </div>
{% endif %} {% endif %}
</div> </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"> <div class="form-group" style="height: 50px;margin-bottom: 0;font-size: 13px">
{{ form.captcha }} {{ form.captcha }}
</div> </div>

View File

@ -8,6 +8,7 @@ from .. import api
app_name = 'authentication' app_name = 'authentication'
router = DefaultRouter() router = DefaultRouter()
router.register('access-keys', api.AccessKeyViewSet, 'access-key') router.register('access-keys', api.AccessKeyViewSet, 'access-key')
router.register('sso', api.SSOViewSet, 'sso')
urlpatterns = [ urlpatterns = [

View File

@ -21,6 +21,7 @@ urlpatterns = [
path('password/forgot/sendmail-success/', users_view.UserForgotPasswordSendmailSuccessView.as_view(), path('password/forgot/sendmail-success/', users_view.UserForgotPasswordSendmailSuccessView.as_view(),
name='forgot-password-sendmail-success'), name='forgot-password-sendmail-success'),
path('password/reset/', users_view.UserResetPasswordView.as_view(), name='reset-password'), 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/reset/success/', users_view.UserResetPasswordSuccessView.as_view(), name='reset-password-success'),
path('password/verify/', users_view.UserVerifyPasswordView.as_view(), name='user-verify-password'), path('password/verify/', users_view.UserVerifyPasswordView.as_view(), name='user-verify-password'),

View File

@ -4,12 +4,9 @@ import base64
from Crypto.PublicKey import RSA from Crypto.PublicKey import RSA
from Crypto.Cipher import PKCS1_v1_5 from Crypto.Cipher import PKCS1_v1_5
from Crypto import Random from Crypto import Random
from django.contrib.auth import authenticate
from common.utils import get_logger from common.utils import get_logger
from . import errors
logger = get_logger(__file__) logger = get_logger(__file__)
@ -41,33 +38,3 @@ def rsa_decrypt(cipher_text, rsa_private_key=None):
cipher = PKCS1_v1_5.new(key) cipher = PKCS1_v1_5.new(key)
message = cipher.decrypt(base64.b64decode(cipher_text.encode()), 'error').decode() message = cipher.decrypt(base64.b64decode(cipher_text.encode()), 'error').decode()
return message return message
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')
# 获取解密密钥,对密码进行解密
rsa_private_key = request.session.get('rsa_private_key')
if rsa_private_key is not None:
try:
password = rsa_decrypt(password, rsa_private_key)
except Exception as e:
logger.error(e, exc_info=True)
logger.error('Need decrypt password => {}'.format(password))
return None, errors.reason_password_decrypt_failed
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, ''

View File

@ -22,12 +22,15 @@ from common.utils import get_request_ip, get_object_or_none
from users.utils import ( from users.utils import (
redirect_user_first_login_or_index redirect_user_first_login_or_index
) )
from .. import forms, mixins, errors, utils from ..const import RSA_PRIVATE_KEY, RSA_PUBLIC_KEY
from .. import mixins, errors, utils
from ..forms import get_user_login_form_cls
__all__ = [ __all__ = [
'UserLoginView', 'UserLogoutView', 'UserLoginView', 'UserLogoutView',
'UserLoginGuardView', 'UserLoginWaitConfirmView', 'UserLoginGuardView', 'UserLoginWaitConfirmView',
'FlashPasswdTooSimpleMsgView',
] ]
@ -35,8 +38,6 @@ __all__ = [
@method_decorator(csrf_protect, name='dispatch') @method_decorator(csrf_protect, name='dispatch')
@method_decorator(never_cache, name='dispatch') @method_decorator(never_cache, name='dispatch')
class UserLoginView(mixins.AuthMixin, FormView): class UserLoginView(mixins.AuthMixin, FormView):
form_class = forms.UserLoginForm
form_class_captcha = forms.UserLoginCaptchaForm
key_prefix_captcha = "_LOGIN_INVALID_{}" key_prefix_captcha = "_LOGIN_INVALID_{}"
redirect_field_name = 'next' redirect_field_name = 'next'
@ -82,15 +83,19 @@ class UserLoginView(mixins.AuthMixin, FormView):
if not self.request.session.test_cookie_worked(): if not self.request.session.test_cookie_worked():
return HttpResponse(_("Please enable cookies and try again.")) return HttpResponse(_("Please enable cookies and try again."))
try: try:
self.check_user_auth() self.check_user_auth(decrypt_passwd=True)
except errors.AuthFailedError as e: except errors.AuthFailedError as e:
form.add_error(None, e.msg) form.add_error(None, e.msg)
ip = self.get_request_ip() ip = self.get_request_ip()
cache.set(self.key_prefix_captcha.format(ip), 1, 3600) 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 new_form._errors = form.errors
context = self.get_context_data(form=new_form) context = self.get_context_data(form=new_form)
return self.render_to_response(context) 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() return self.redirect_to_guard_view()
def redirect_to_guard_view(self): def redirect_to_guard_view(self):
@ -103,18 +108,28 @@ class UserLoginView(mixins.AuthMixin, FormView):
def get_form_class(self): def get_form_class(self):
ip = get_request_ip(self.request) ip = get_request_ip(self.request)
if cache.get(self.key_prefix_captcha.format(ip)): if cache.get(self.key_prefix_captcha.format(ip)):
return self.form_class_captcha return get_user_login_form_cls(captcha=True)
else: 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): def get_context_data(self, **kwargs):
# 生成加解密密钥对public_key传递给前端private_key存入session中供解密使用 # 生成加解密密钥对public_key传递给前端private_key存入session中供解密使用
rsa_private_key, rsa_public_key = utils.gen_key_pair() rsa_private_key = self.request.session.get(RSA_PRIVATE_KEY)
self.request.session['rsa_private_key'] = 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 = { context = {
'demo_mode': os.environ.get("DEMO_MODE"), 'demo_mode': os.environ.get("DEMO_MODE"),
'AUTH_OPENID': settings.AUTH_OPENID, 'AUTH_OPENID': settings.AUTH_OPENID,
'rsa_public_key': rsa_public_key.replace('\n', '\\n') 'rsa_public_key': rsa_public_key
} }
kwargs.update(context) kwargs.update(context)
return super().get_context_data(**kwargs) return super().get_context_data(**kwargs)
@ -145,6 +160,8 @@ class UserLoginGuardView(mixins.AuthMixin, RedirectView):
return self.format_redirect_url(self.login_confirm_url) return self.format_redirect_url(self.login_confirm_url)
except errors.MFAUnsetError as e: except errors.MFAUnsetError as e:
return e.url return e.url
except errors.PasswdTooSimple as e:
return e.url
else: else:
auth_login(self.request, user) auth_login(self.request, user)
self.send_auth_signal(success=True, user=user) self.send_auth_signal(success=True, user=user)
@ -216,4 +233,16 @@ class UserLogoutView(TemplateView):
return super().get_context_data(**kwargs) 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)

View 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'

View File

@ -3,10 +3,10 @@ from django.db.models import Aggregate
class GroupConcat(Aggregate): class GroupConcat(Aggregate):
function = 'GROUP_CONCAT' function = 'GROUP_CONCAT'
template = '%(function)s(%(distinct)s %(expressions)s %(order_by)s %(separator))' template = '%(function)s(%(expressions)s %(order_by)s %(separator)s)'
allow_distinct = False allow_distinct = False
def __init__(self, expression, distinct=False, order_by=None, separator=',', **extra): def __init__(self, expression, order_by=None, separator=',', **extra):
order_by_clause = '' order_by_clause = ''
if order_by is not None: if order_by is not None:
order = 'ASC' order = 'ASC'
@ -21,8 +21,7 @@ class GroupConcat(Aggregate):
super().__init__( super().__init__(
expression, expression,
distinct='DISTINCT' if distinct else '',
order_by=order_by_clause, order_by=order_by_clause,
separator=f'SEPARATOR {separator}', separator=f"SEPARATOR '{separator}'",
**extra **extra
) )

84
apps/common/db/models.py Normal file
View 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(')'))

View File

@ -1,11 +1,42 @@
from rest_framework.viewsets import GenericViewSet, ModelViewSet from rest_framework.viewsets import GenericViewSet, ModelViewSet
from rest_framework_bulk import BulkModelViewSet
from ..mixins.api import SerializerMixin2, QuerySetMixin, ExtraFilterFieldsMixin from ..mixins.api import (
SerializerMixin2, QuerySetMixin, ExtraFilterFieldsMixin, PaginatedResponseMixin,
RelationMixin, AllowBulkDestoryMixin
)
class JmsGenericViewSet(SerializerMixin2, QuerySetMixin, ExtraFilterFieldsMixin, GenericViewSet): class JmsGenericViewSet(SerializerMixin2,
QuerySetMixin,
ExtraFilterFieldsMixin,
PaginatedResponseMixin,
GenericViewSet):
pass pass
class JMSModelViewSet(SerializerMixin2, QuerySetMixin, ExtraFilterFieldsMixin, ModelViewSet): 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 pass

View 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
View 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

View File

@ -47,9 +47,9 @@ class JMSCSVParser(BaseParser):
yield row yield row
@staticmethod @staticmethod
def _get_fields_map(serializer): def _get_fields_map(serializer_cls):
fields_map = {} fields_map = {}
fields = serializer.fields fields = serializer_cls().fields
fields_map.update({v.label: k for k, v in fields.items()}) fields_map.update({v.label: k for k, v in fields.items()})
fields_map.update({k: k for k, _ in fields.items()}) fields_map.update({k: k for k, _ in fields.items()})
return fields_map return fields_map
@ -101,7 +101,7 @@ class JMSCSVParser(BaseParser):
try: try:
view = parser_context['view'] view = parser_context['view']
meta = view.request.META meta = view.request.META
serializer = view.get_serializer() serializer_cls = view.get_serializer_class()
except Exception as e: except Exception as e:
logger.debug(e, exc_info=True) logger.debug(e, exc_info=True)
raise ParseError('The resource does not support imports!') raise ParseError('The resource does not support imports!')
@ -121,7 +121,7 @@ class JMSCSVParser(BaseParser):
rows = self._gen_rows(binary, charset=encoding) rows = self._gen_rows(binary, charset=encoding)
header = next(rows) 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] header = [fields_map.get(name.strip('*'), '') for name in header]
data = [] data = []

View File

@ -1,5 +1,25 @@
from rest_framework.serializers import Serializer 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): class EmptySerializer(Serializer):
pass pass
class BulkModelSerializer(BulkSerializerMixin, ModelSerializer):
pass
class AdaptedBulkListSerializer(BulkListSerializerMixin, BulkListSerializer):
pass
class CeleryTaskSerializer(serializers.Serializer):
task = serializers.CharField(read_only=True)

View File

@ -1,7 +1,20 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# #
from django.utils.translation import gettext_lazy as _
from rest_framework.exceptions import APIException from rest_framework.exceptions import APIException
from rest_framework import status
class JMSException(APIException): class JMSException(APIException):
pass 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)

View File

@ -11,6 +11,8 @@ from django.core.cache import cache
from django.http import JsonResponse from django.http import JsonResponse
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.settings import api_settings 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 common.drf.filters import IDSpmFilter, CustomFilter, IDInFilter
from ..utils import lazyproperty from ..utils import lazyproperty
@ -67,6 +69,17 @@ class ExtraFilterFieldsMixin:
return queryset 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): class CommonApiMixin(SerializerMixin, ExtraFilterFieldsMixin):
pass pass
@ -212,10 +225,11 @@ class RelationMixin:
self.through = getattr(self.m2m_field.model, self.m2m_field.attname).through self.through = getattr(self.m2m_field.model, self.m2m_field.attname).through
def get_queryset(self): def get_queryset(self):
# 注意,此处拦截了 `get_queryset` 没有 `super`
queryset = self.through.objects.all() queryset = self.through.objects.all()
return queryset return queryset
def send_post_add_signal(self, instances): def send_m2m_changed_signal(self, instances, action):
if not isinstance(instances, list): if not isinstance(instances, list):
instances = [instances] instances = [instances]
@ -228,13 +242,17 @@ class RelationMixin:
for from_obj, to_ids in from_to_mapper.items(): for from_obj, to_ids in from_to_mapper.items():
m2m_changed.send( 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 reverse=False, model=self.to_model, pk_set=to_ids
) )
def perform_create(self, serializer): def perform_create(self, serializer):
instance = serializer.save() 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: class SerializerMixin2:
@ -264,3 +282,12 @@ class QuerySetMixin:
queryset = serializer_class.setup_eager_loading(queryset) queryset = serializer_class.setup_eager_loading(queryset)
return 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

View File

@ -8,7 +8,6 @@ from rest_framework.utils import html
from rest_framework.settings import api_settings from rest_framework.settings import api_settings
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
from rest_framework.fields import SkipField, empty from rest_framework.fields import SkipField, empty
__all__ = ['BulkSerializerMixin', 'BulkListSerializerMixin', 'CommonSerializerMixin', 'CommonBulkSerializerMixin'] __all__ = ['BulkSerializerMixin', 'BulkListSerializerMixin', 'CommonSerializerMixin', 'CommonBulkSerializerMixin']
@ -50,6 +49,15 @@ class BulkSerializerMixin(object):
self.initial_data = data self.initial_data = data
return super().run_validation(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): class BulkListSerializerMixin(object):
""" """

View File

@ -1,14 +1,6 @@
# -*- coding: utf-8 -*- """
# 老的代码统一到 `apps/common/drf/serializers.py`
之后此文件废弃
"""
from rest_framework_bulk.serializers import BulkListSerializer from common.drf.serializers import AdaptedBulkListSerializer, CeleryTaskSerializer
from rest_framework import serializers
from .mixins import BulkListSerializerMixin
class AdaptedBulkListSerializer(BulkListSerializerMixin, BulkListSerializer):
pass
class CeleryTaskSerializer(serializers.Serializer):
task = serializers.CharField(read_only=True)

View File

@ -11,6 +11,8 @@ import time
import ipaddress import ipaddress
import psutil import psutil
from .timezone import dt_formater
UUID_PATTERN = re.compile(r'\w{8}(-\w{4}){3}-\w{12}') UUID_PATTERN = re.compile(r'\w{8}(-\w{4}){3}-\w{12}')
ipip_db = None ipip_db = None

View File

@ -2,7 +2,6 @@
# #
import re import re
from django.shortcuts import reverse as dj_reverse from django.shortcuts import reverse as dj_reverse
from django.db.models import Subquery, QuerySet
from django.conf import settings from django.conf import settings
from django.utils import timezone from django.utils import timezone

View 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

View File

@ -128,7 +128,7 @@ class DatesLoginMetricMixin:
@lazyproperty @lazyproperty
def dates_total_count_inactive_users(self): 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 active = self.dates_total_count_active_users
count = total - active count = total - active
if count < 0: if count < 0:
@ -137,7 +137,7 @@ class DatesLoginMetricMixin:
@lazyproperty @lazyproperty
def dates_total_count_disabled_users(self): 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 @lazyproperty
def dates_total_count_active_assets(self): def dates_total_count_active_assets(self):
@ -207,7 +207,7 @@ class DatesLoginMetricMixin:
class TotalCountMixin: class TotalCountMixin:
@staticmethod @staticmethod
def get_total_count_users(): def get_total_count_users():
return current_org.get_org_members().count() return current_org.get_members().count()
@staticmethod @staticmethod
def get_total_count_assets(): def get_total_count_assets():

View File

@ -211,6 +211,9 @@ class Config(dict):
'CAS_LOGOUT_COMPLETELY': True, 'CAS_LOGOUT_COMPLETELY': True,
'CAS_VERSION': 3, 'CAS_VERSION': 3,
'AUTH_SSO': False,
'AUTH_SSO_AUTHKEY_TTL': 60 * 15,
'OTP_VALID_WINDOW': 2, 'OTP_VALID_WINDOW': 2,
'OTP_ISSUER_NAME': 'JumpServer', 'OTP_ISSUER_NAME': 'JumpServer',
'EMAIL_SUFFIX': 'jumpserver.org', 'EMAIL_SUFFIX': 'jumpserver.org',
@ -238,6 +241,8 @@ class Config(dict):
'SECURITY_PASSWORD_LOWER_CASE': False, 'SECURITY_PASSWORD_LOWER_CASE': False,
'SECURITY_PASSWORD_NUMBER': False, 'SECURITY_PASSWORD_NUMBER': False,
'SECURITY_PASSWORD_SPECIAL_CHAR': False, 'SECURITY_PASSWORD_SPECIAL_CHAR': False,
'SECURITY_LOGIN_CHALLENGE_ENABLED': False,
'SECURITY_LOGIN_CAPTCHA_ENABLED': True,
'HTTP_BIND_HOST': '0.0.0.0', 'HTTP_BIND_HOST': '0.0.0.0',
'HTTP_LISTEN_PORT': 8080, 'HTTP_LISTEN_PORT': 8080,
@ -438,6 +443,8 @@ class DynamicConfig:
backends.insert(0, 'jms_oidc_rp.backends.OIDCAuthCodeBackend') backends.insert(0, 'jms_oidc_rp.backends.OIDCAuthCodeBackend')
if self.static_config.get('AUTH_RADIUS'): if self.static_config.get('AUTH_RADIUS'):
backends.insert(0, 'authentication.backends.radius.RadiusBackend') backends.insert(0, 'authentication.backends.radius.RadiusBackend')
if self.static_config.get('AUTH_SSO'):
backends.insert(0, 'authentication.backends.api.SSOAuthentication')
return backends return backends
def XPACK_LICENSE_IS_VALID(self): def XPACK_LICENSE_IS_VALID(self):

View File

@ -92,7 +92,11 @@ CAS_LOGGED_MSG = None
CAS_LOGOUT_COMPLETELY = CONFIG.CAS_LOGOUT_COMPLETELY CAS_LOGOUT_COMPLETELY = CONFIG.CAS_LOGOUT_COMPLETELY
CAS_VERSION = CONFIG.CAS_VERSION CAS_VERSION = CONFIG.CAS_VERSION
CAS_ROOT_PROXIED_AS = CONFIG.CAS_ROOT_PROXIED_AS 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 # Other setting
TOKEN_EXPIRATION = CONFIG.TOKEN_EXPIRATION TOKEN_EXPIRATION = CONFIG.TOKEN_EXPIRATION

View File

@ -41,6 +41,8 @@ SECURITY_PASSWORD_RULES = [
SECURITY_MFA_VERIFY_TTL = CONFIG.SECURITY_MFA_VERIFY_TTL SECURITY_MFA_VERIFY_TTL = CONFIG.SECURITY_MFA_VERIFY_TTL
SECURITY_VIEW_AUTH_NEED_MFA = CONFIG.SECURITY_VIEW_AUTH_NEED_MFA SECURITY_VIEW_AUTH_NEED_MFA = CONFIG.SECURITY_VIEW_AUTH_NEED_MFA
SECURITY_SERVICE_ACCOUNT_REGISTRATION = DYNAMIC.SECURITY_SERVICE_ACCOUNT_REGISTRATION 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 other setting
TERMINAL_PASSWORD_AUTH = DYNAMIC.TERMINAL_PASSWORD_AUTH 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 LOGO_URLS = DYNAMIC.LOGO_URLS
CHANGE_AUTH_PLAN_SECURE_MODE_ENABLED = CONFIG.CHANGE_AUTH_PLAN_SECURE_MODE_ENABLED CHANGE_AUTH_PLAN_SECURE_MODE_ENABLED = CONFIG.CHANGE_AUTH_PLAN_SECURE_MODE_ENABLED
DATETIME_DISPLAY_FORMAT = '%Y-%m-%d %H:%M:%S'

View File

@ -40,6 +40,7 @@ REST_FRAMEWORK = {
'DATETIME_FORMAT': '%Y-%m-%d %H:%M:%S %z', 'DATETIME_FORMAT': '%Y-%m-%d %H:%M:%S %z',
'DATETIME_INPUT_FORMATS': ['iso-8601', '%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', 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination',
'EXCEPTION_HANDLER': 'common.drf.exc_handlers.common_exception_handler',
# 'PAGE_SIZE': 100, # 'PAGE_SIZE': 100,
# 'MAX_PAGE_SIZE': 5000 # 'MAX_PAGE_SIZE': 5000

View File

@ -75,8 +75,8 @@ if settings.DEBUG:
urlpatterns += [ urlpatterns += [
re_path('^api/swagger(?P<format>\.json|\.yaml)$', re_path('^api/swagger(?P<format>\.json|\.yaml)$',
views.get_swagger_view().without_ui(cache_timeout=1), name='schema-json'), 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"), re_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/redoc/?', views.get_swagger_view().with_ui('redoc', cache_timeout=1), name='redoc'),
re_path('^api/v2/swagger(?P<format>\.json|\.yaml)$', re_path('^api/v2/swagger(?P<format>\.json|\.yaml)$',
views.get_swagger_view().without_ui(cache_timeout=1), name='schema-json'), views.get_swagger_view().without_ui(cache_timeout=1), name='schema-json'),

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@ -93,6 +93,12 @@ def delete_celery_periodic_task(task_name):
PeriodicTasks.update_changed() 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): def get_celery_task_log_path(task_id):
task_id = str(task_id) task_id = str(task_id)
rel_path = os.path.join(task_id[0], task_id[1], task_id + '.log') rel_path = os.path.join(task_id[0], task_id[1], task_id + '.log')

View File

@ -15,7 +15,10 @@ from .celery.decorator import (
register_as_period_task, after_app_shutdown_clean_periodic, register_as_period_task, after_app_shutdown_clean_periodic,
after_app_ready_start 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 .models import Task, CommandExecution, CeleryTask
from .utils import send_server_performance_mail from .utils import send_server_performance_mail
@ -95,6 +98,29 @@ def clean_celery_tasks_period():
subprocess.call(command, shell=True) 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 @shared_task
@after_app_ready_start @after_app_ready_start
def create_or_update_registered_periodic_tasks(): def create_or_update_registered_periodic_tasks():

View File

@ -69,7 +69,7 @@ def send_server_performance_mail(path, usage, usages):
from users.models import User from users.models import User
subject = _("Disk used more than 80%: {} => {}").format(path, usage.percent) subject = _("Disk used more than 80%: {} => {}").format(path, usage.percent)
message = subject 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] recipient_list = [u.email for u in admins if u.email]
logger.info(subject) logger.info(subject)
send_mail_async(subject, message, recipient_list, html_message=message) send_mail_async(subject, message, recipient_list, html_message=message)

View File

@ -1,23 +1,24 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# #
from django.shortcuts import get_object_or_404
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from rest_framework import status, generics from rest_framework import status, generics
from rest_framework.views import Response from rest_framework.views import Response
from rest_framework_bulk import BulkModelViewSet from rest_framework_bulk import BulkModelViewSet
from common.permissions import IsSuperUserOrAppUser from common.permissions import IsSuperUserOrAppUser
from .models import Organization from common.drf.api import JMSBulkRelationModelViewSet
from .serializers import OrgSerializer, OrgReadSerializer, \ from .models import Organization, ROLE
OrgMembershipUserSerializer, OrgMembershipAdminSerializer, \ from .serializers import (
OrgAllUserSerializer, OrgRetrieveSerializer OrgSerializer, OrgReadSerializer,
OrgRetrieveSerializer, OrgMemberSerializer
)
from users.models import User, UserGroup from users.models import User, UserGroup
from assets.models import Asset, Domain, AdminUser, SystemUser, Label from assets.models import Asset, Domain, AdminUser, SystemUser, Label
from perms.models import AssetPermission from perms.models import AssetPermission
from orgs.utils import current_org from orgs.utils import current_org
from common.utils import get_logger from common.utils import get_logger
from .mixins.api import OrgMembershipModelViewSetMixin from .filters import OrgMemberRelationFilterSet
logger = get_logger(__file__) logger = get_logger(__file__)
@ -39,7 +40,7 @@ class OrgViewSet(BulkModelViewSet):
def get_data_from_model(self, model): def get_data_from_model(self, model):
if model == User: 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: else:
data = model.objects.filter(org_id=self.org.id) data = model.objects.filter(org_id=self.org.id)
return data return data
@ -64,26 +65,13 @@ class OrgViewSet(BulkModelViewSet):
return Response({'msg': True}, status=status.HTTP_200_OK) return Response({'msg': True}, status=status.HTTP_200_OK)
class OrgMembershipAdminsViewSet(OrgMembershipModelViewSetMixin, BulkModelViewSet): class OrgMemberRelationBulkViewSet(JMSBulkRelationModelViewSet):
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):
permission_classes = (IsSuperUserOrAppUser,) permission_classes = (IsSuperUserOrAppUser,)
serializer_class = OrgAllUserSerializer m2m_field = Organization.members.field
filter_fields = ("username", "name") serializer_class = OrgMemberSerializer
search_fields = filter_fields filterset_class = OrgMemberRelationFilterSet
def get_queryset(self): def perform_bulk_destroy(self, queryset):
pk = self.kwargs.get("pk") objs = list(queryset.all().prefetch_related('user', 'org'))
org = get_object_or_404(Organization, pk=pk) queryset.delete()
users = org.get_org_users().only(*self.serializer_class.Meta.only_fields) self.send_m2m_changed_signal(objs, action='post_remove')
return users

16
apps/orgs/filters.py Normal file
View 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')

View 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')},
},
),
]

View 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)
]

View 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),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 2.2.10 on 2020-07-28 10:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('orgs', '0006_auto_20200721_1937'),
]
operations = [
migrations.AlterField(
model_name='organizationmember',
name='role',
field=models.CharField(choices=[('Admin', 'Organization administrator'), ('User', 'User'), ('Auditor', 'Organization auditor')], default='User', max_length=16, verbose_name='Role'),
),
]

View File

@ -55,8 +55,8 @@ class OrgBulkModelViewSet(CommonApiMixin, OrgQuerySetMixin, BulkModelViewSet):
filtered_count = filtered.count() filtered_count = filtered.count()
if filtered_count == 1: if filtered_count == 1:
return True return True
if qs_count <= filtered_count: if qs_count > filtered_count:
return False return True
if self.request.query_params.get('spm', ''): if self.request.query_params.get('spm', ''):
return True return True
return False return False

View File

@ -62,7 +62,6 @@ class OrgModelMixin(models.Model):
org = get_current_org() org = get_current_org()
if org is None: if org is None:
return super().save(*args, **kwargs) return super().save(*args, **kwargs)
if org.is_real() or org.is_system(): if org.is_real() or org.is_system():
self.org_id = org.id self.org_id = org.id
elif org.is_default(): elif org.is_default():

View File

@ -11,8 +11,7 @@ from ..utils import get_current_org_id_for_serializer
__all__ = [ __all__ = [
"OrgResourceSerializerMixin", "BulkOrgResourceSerializerMixin", "OrgResourceSerializerMixin", "BulkOrgResourceSerializerMixin",
"BulkOrgResourceModelSerializer", "OrgMembershipSerializerMixin", "BulkOrgResourceModelSerializer", "OrgResourceModelSerializerMixin",
"OrgResourceModelSerializerMixin",
] ]
@ -53,9 +52,3 @@ class BulkOrgResourceSerializerMixin(BulkSerializerMixin, OrgResourceSerializerM
class BulkOrgResourceModelSerializer(BulkOrgResourceSerializerMixin, serializers.ModelSerializer): class BulkOrgResourceModelSerializer(BulkOrgResourceSerializerMixin, serializers.ModelSerializer):
pass pass
class OrgMembershipSerializerMixin:
def run_validation(self, initial_data=None):
initial_data['organization'] = str(self.context['org'].id)
return super().run_validation(initial_data)

View File

@ -1,21 +1,30 @@
import uuid import uuid
from django.conf import settings from functools import partial
from django.db import models from django.db import models
from django.db.models import signals
from django.db.models import Q
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from common.utils import is_uuid, lazyproperty from common.utils import is_uuid
from common.const import choices
from common.db.models import ChoiceSet
class ROLE(ChoiceSet):
ADMIN = choices.ADMIN, _('Organization administrator')
USER = choices.USER, _('User')
AUDITOR = choices.AUDITOR, _("Organization auditor")
class Organization(models.Model): class Organization(models.Model):
id = models.UUIDField(default=uuid.uuid4, primary_key=True) 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, unique=True, verbose_name=_("Name"))
users = models.ManyToManyField('users.User', related_name='related_user_orgs', blank=True)
admins = models.ManyToManyField('users.User', related_name='related_admin_orgs', blank=True)
auditors = models.ManyToManyField('users.User', related_name='related_audit_orgs', blank=True)
created_by = models.CharField(max_length=32, null=True, blank=True, verbose_name=_('Created by')) created_by = models.CharField(max_length=32, null=True, blank=True, verbose_name=_('Created by'))
date_created = models.DateTimeField(auto_now_add=True, null=True, blank=True, verbose_name=_('Date created')) 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')) comment = models.TextField(max_length=128, default='', blank=True, verbose_name=_('Comment'))
members = models.ManyToManyField('users.User', related_name='orgs', through='orgs.OrganizationMember',
through_fields=('org', 'user'))
orgs = None orgs = None
CACHE_PREFIX = 'JMS_ORG_{}' CACHE_PREFIX = 'JMS_ORG_{}'
@ -72,29 +81,24 @@ class Organization(models.Model):
org = cls.default() if default else None org = cls.default() if default else None
return org return org
# @lazyproperty def get_org_members_by_role(self, role):
# lazyproperty 导致用户列表中角色显示出现不稳定的情况, 如果不加会导致数据库操作次数太多
def org_users(self):
from users.models import User from users.models import User
if self.is_real(): if self.is_real():
return self.users.all() return self.members.filter(m2m_org_members__role=role)
users = User.objects.filter(role=User.ROLE_USER) users = User.objects.filter(role=role)
if self.is_default() and not settings.DEFAULT_ORG_SHOW_ALL_USERS:
users = users.filter(related_user_orgs__isnull=True)
return users return users
def get_org_users(self): @property
return self.org_users() def users(self):
return self.get_org_members_by_role(ROLE.USER)
# @lazyproperty @property
def org_admins(self): def admins(self):
from users.models import User return self.get_org_members_by_role(ROLE.ADMIN)
if self.is_real():
return self.admins.all()
return User.objects.filter(role=User.ROLE_ADMIN)
def get_org_admins(self): @property
return self.org_admins() def auditors(self):
return self.get_org_members_by_role(ROLE.AUDITOR)
def org_id(self): def org_id(self):
if self.is_real(): if self.is_real():
@ -104,87 +108,86 @@ class Organization(models.Model):
else: else:
return '' return ''
# @lazyproperty def get_members(self, exclude=()):
def org_auditors(self):
from users.models import User from users.models import User
if self.is_real(): if self.is_real():
return self.auditors.all() members = self.members.exclude(m2m_org_members__role__in=exclude)
return User.objects.filter(role=User.ROLE_AUDITOR) else:
members = User.objects.exclude(role__in=exclude)
def get_org_auditors(self): return members.exclude(role=User.ROLE.APP).distinct()
return self.org_auditors()
def get_org_members(self, exclude=()):
from users.models import User
members = User.objects.none()
if 'Admin' not in exclude:
members |= self.get_org_admins()
if 'User' not in exclude:
members |= self.get_org_users()
if 'Auditor' not in exclude:
members |= self.get_org_auditors()
return members.exclude(role=User.ROLE_APP).distinct()
def can_admin_by(self, user): def can_admin_by(self, user):
if user.is_superuser: if user.is_superuser:
return True return True
if self.get_org_admins().filter(id=user.id): if self.admins.filter(id=user.id).exists():
return True return True
return False return False
def can_audit_by(self, user): def can_audit_by(self, user):
if user.is_super_auditor: if user.is_super_auditor:
return True return True
if self.get_org_auditors().filter(id=user.id): if self.auditors.filter(id=user.id).exists():
return True return True
return False return False
def can_user_by(self, user): def can_user_by(self, user):
if self.get_org_users().filter(id=user.id): if self.users.filter(id=user.id).exists():
return True return True
return False return False
def is_real(self): def is_real(self):
return self.id not in (self.DEFAULT_NAME, self.ROOT_ID, self.SYSTEM_ID) return self.id not in (self.DEFAULT_NAME, self.ROOT_ID, self.SYSTEM_ID)
@classmethod
def get_user_orgs_by_role(cls, user, role):
if not isinstance(role, (tuple, list)):
role = (role, )
return cls.objects.filter(
m2m_org_members__role__in=role,
m2m_org_members__user_id=user.id
).distinct()
@classmethod
def get_user_all_orgs(cls, user):
return [
*cls.objects.filter(members=user).distinct(),
cls.default()
]
@classmethod @classmethod
def get_user_admin_orgs(cls, user): def get_user_admin_orgs(cls, user):
admin_orgs = []
if user.is_anonymous: if user.is_anonymous:
return admin_orgs return cls.objects.none()
elif user.is_superuser: if user.is_superuser:
admin_orgs = list(cls.objects.all()) return [*cls.objects.all(), cls.default()]
admin_orgs.append(cls.default()) return cls.get_user_orgs_by_role(user, ROLE.ADMIN)
elif user.is_org_admin:
admin_orgs = user.related_admin_orgs.all()
return admin_orgs
@classmethod @classmethod
def get_user_user_orgs(cls, user): def get_user_user_orgs(cls, user):
user_orgs = []
if user.is_anonymous: if user.is_anonymous:
return user_orgs return cls.objects.none()
user_orgs = user.related_user_orgs.all() return [
return user_orgs *cls.get_user_orgs_by_role(user, ROLE.USER),
cls.default()
]
@classmethod @classmethod
def get_user_audit_orgs(cls, user): def get_user_audit_orgs(cls, user):
audit_orgs = []
if user.is_anonymous: if user.is_anonymous:
return audit_orgs return cls.objects.none()
elif user.is_super_auditor: if user.is_super_auditor:
audit_orgs = list(cls.objects.all()) return [*cls.objects.all(), cls.default()]
audit_orgs.append(cls.default()) return cls.get_user_orgs_by_role(user, ROLE.AUDITOR)
elif user.is_org_auditor:
audit_orgs = user.related_audit_orgs.all()
return audit_orgs
@classmethod @classmethod
def get_user_admin_or_audit_orgs(self, user): def get_user_admin_or_audit_orgs(cls, user):
admin_orgs = self.get_user_admin_orgs(user) if user.is_anonymous:
audit_orgs = self.get_user_audit_orgs(user) return cls.objects.none()
orgs = set(admin_orgs) | set(audit_orgs) if user.is_superuser or user.is_super_auditor:
return orgs return [*cls.objects.all(), cls.default()]
return cls.get_user_orgs_by_role(user, (ROLE.AUDITOR, ROLE.ADMIN))
@classmethod @classmethod
def default(cls): def default(cls):
@ -211,8 +214,122 @@ class Organization(models.Model):
from .utils import set_current_org from .utils import set_current_org
set_current_org(self) set_current_org(self)
@classmethod
def all_orgs(cls): def _convert_to_uuid_set(users):
orgs = list(cls.objects.all()) rst = set()
orgs.append(cls.default()) for user in users:
return orgs if isinstance(user, models.Model):
rst.add(user.id)
elif not isinstance(user, uuid.UUID):
rst.add(uuid.UUID(user))
return rst
def _none2list(*args):
return ([] if v is None else v for v in args)
class OrgMemeberManager(models.Manager):
def remove_users_by_role(self, org, users=None, admins=None, auditors=None):
if not any((users, admins, auditors)):
return
users, admins, auditors = _none2list(users, admins, auditors)
send = partial(signals.m2m_changed.send, sender=self.model, instance=org, reverse=False,
model=Organization, pk_set=[*users, *admins, *auditors], using=self.db)
send(action="pre_remove")
self.filter(org_id=org.id).filter(
Q(user__in=users, role=ROLE.USER) |
Q(user__in=admins, role=ROLE.ADMIN) |
Q(user__in=auditors, role=ROLE.AUDITOR)
).delete()
send(action="post_remove")
def add_users_by_role(self, org, users=None, admins=None, auditors=None):
if not any((users, admins, auditors)):
return
users, admins, auditors = _none2list(users, admins, auditors)
add_mapper = (
(users, ROLE.USER),
(admins, ROLE.ADMIN),
(auditors, ROLE.AUDITOR)
)
oms_add = []
for users, role in add_mapper:
for user in users:
if isinstance(user, models.Model):
user = user.id
oms_add.append(self.model(org_id=org.id, user_id=user, role=role))
send = partial(signals.m2m_changed.send, sender=self.model, instance=org, reverse=False,
model=Organization, pk_set=[*users, *admins, *auditors], using=self.db)
send(action='pre_add')
self.bulk_create(oms_add)
send(action='post_add')
def _get_remove_add_set(self, new_users, old_users):
if new_users is None:
return None, None
new_users = _convert_to_uuid_set(new_users)
return (old_users - new_users), (new_users - old_users)
def set_users_by_role(self, org, users=None, admins=None, auditors=None):
oms = self.filter(org_id=org.id).values_list('role', 'user_id')
old_users, old_admins, old_auditors = set(), set(), set()
mapper = {
ROLE.USER: old_users,
ROLE.ADMIN: old_admins,
ROLE.AUDITOR: old_auditors
}
for role, user_id in oms:
if role in mapper:
mapper[role].add(user_id)
users_remove, users_add = self._get_remove_add_set(users, old_users)
admins_remove, admins_add = self._get_remove_add_set(admins, old_admins)
auditors_remove, auditors_add = self._get_remove_add_set(auditors, old_auditors)
self.remove_users_by_role(
org,
users_remove,
admins_remove,
auditors_remove
)
self.add_users_by_role(
org,
users_add,
admins_add,
auditors_add
)
class OrganizationMember(models.Model):
"""
注意直接调用该 `Model.delete` `Model.objects.delete` 不会触发清理该用户的信号
"""
id = models.UUIDField(default=uuid.uuid4, primary_key=True)
org = models.ForeignKey(Organization, related_name='m2m_org_members', on_delete=models.CASCADE, verbose_name=_('Organization'))
user = models.ForeignKey('users.User', related_name='m2m_org_members', on_delete=models.CASCADE, verbose_name=_('User'))
role = models.CharField(max_length=16, choices=ROLE.choices, default=ROLE.USER, 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'))
objects = OrgMemeberManager()
class Meta:
unique_together = [('org', 'user', 'role')]
db_table = 'orgs_organization_members'
def __str__(self):
return '{} is {}: {}'.format(self.user.name, self.org.name, self.role)

View File

@ -1,16 +1,19 @@
from django.db.models import F
from rest_framework.serializers import ModelSerializer from rest_framework.serializers import ModelSerializer
from rest_framework import serializers from rest_framework import serializers
from users.models import UserGroup
from assets.models import Asset, Domain, AdminUser, SystemUser, Label from users.models.user import User
from perms.models import AssetPermission
from common.serializers import AdaptedBulkListSerializer from common.serializers import AdaptedBulkListSerializer
from .utils import set_current_org, get_current_org from common.drf.serializers import BulkModelSerializer
from .models import Organization from common.db.models import concated_display as display
from .mixins.serializers import OrgMembershipSerializerMixin from .models import Organization, OrganizationMember, ROLE as ORG_ROLE
class OrgSerializer(ModelSerializer): class OrgSerializer(ModelSerializer):
users = serializers.PrimaryKeyRelatedField(many=True, queryset=User.objects.all(), write_only=True)
admins = serializers.PrimaryKeyRelatedField(many=True, queryset=User.objects.all(), write_only=True)
auditors = serializers.PrimaryKeyRelatedField(many=True, queryset=User.objects.all(), write_only=True)
class Meta: class Meta:
model = Organization model = Organization
list_serializer_class = AdaptedBulkListSerializer list_serializer_class = AdaptedBulkListSerializer
@ -21,41 +24,48 @@ class OrgSerializer(ModelSerializer):
fields_m2m = ['users', 'admins', 'auditors'] fields_m2m = ['users', 'admins', 'auditors']
fields = fields_small + fields_m2m fields = fields_small + fields_m2m
read_only_fields = ['created_by', 'date_created'] read_only_fields = ['created_by', 'date_created']
extra_kwargs = {
'admins': {'write_only': True}, def create(self, validated_data):
'users': {'write_only': True}, members = self._pop_memebers(validated_data)
'auditors': {'write_only': True}, instance = Organization.objects.create(**validated_data)
} OrganizationMember.objects.add_users_by_role(instance, *members)
return instance
def _pop_memebers(self, validated_data):
return (
validated_data.pop('users', None),
validated_data.pop('admins', None),
validated_data.pop('auditors', None)
)
def update(self, instance, validated_data):
members = self._pop_memebers(validated_data)
for attr, value in validated_data.items():
setattr(instance, attr, value)
instance.save()
OrganizationMember.objects.set_users_by_role(instance, *members)
return instance
class OrgReadSerializer(OrgSerializer): class OrgReadSerializer(OrgSerializer):
pass pass
class OrgMembershipAdminSerializer(OrgMembershipSerializerMixin, ModelSerializer): class OrgMemberSerializer(BulkModelSerializer):
class Meta: org_display = serializers.CharField()
model = Organization.admins.through user_display = serializers.CharField()
list_serializer_class = AdaptedBulkListSerializer role_display = serializers.CharField(source='get_role_display')
fields = '__all__'
class OrgMembershipUserSerializer(OrgMembershipSerializerMixin, ModelSerializer):
class Meta:
model = Organization.users.through
list_serializer_class = AdaptedBulkListSerializer
fields = '__all__'
class OrgAllUserSerializer(serializers.Serializer):
user = serializers.UUIDField(read_only=True, source='id')
user_display = serializers.SerializerMethodField()
class Meta: class Meta:
only_fields = ['id', 'username', 'name'] model = OrganizationMember
fields = ('id', 'org', 'user', 'role', 'org_display', 'user_display', 'role_display')
@staticmethod @classmethod
def get_user_display(obj): def setup_eager_loading(cls, queryset):
return str(obj) return queryset.annotate(
org_display=F('org__name'),
user_display=display('user__name', 'user__username')
).distinct()
class OrgRetrieveSerializer(OrgReadSerializer): class OrgRetrieveSerializer(OrgReadSerializer):

View File

@ -5,7 +5,7 @@ from django.db.models.signals import m2m_changed
from django.db.models.signals import post_save from django.db.models.signals import post_save
from django.dispatch import receiver from django.dispatch import receiver
from .models import Organization from .models import Organization, OrganizationMember
from .hands import set_current_org, current_org, Node, get_current_org from .hands import set_current_org, current_org, Node, get_current_org
from perms.models import AssetPermission from perms.models import AssetPermission
from users.models import UserGroup from users.models import UserGroup
@ -26,23 +26,31 @@ def on_org_create_or_update(sender, instance=None, created=False, **kwargs):
instance.expire_cache() instance.expire_cache()
@receiver(m2m_changed, sender=Organization.users.through) def _remove_users(model, users, org, reverse=False):
def on_org_user_changed(sender, instance=None, **kwargs): if not isinstance(users, (tuple, list, set)):
if isinstance(instance, Organization): users = (users, )
old_org = current_org
set_current_org(instance) m2m_model = model.users.through
if kwargs['action'] == 'pre_remove': if reverse:
users = kwargs['model'].objects.filter(pk__in=kwargs['pk_set']) m2m_field_name = model.users.field.m2m_reverse_field_name()
for user in users: else:
perms = AssetPermission.objects.filter(users=user) m2m_field_name = model.users.field.m2m_field_name()
user_groups = UserGroup.objects.filter(users=user) m2m_model.objects.filter(**{'user__in': users, f'{m2m_field_name}__org_id': org.id}).delete()
for perm in perms:
perm.users.remove(user)
for user_group in user_groups:
user_group.users.remove(user)
set_current_org(old_org)
@receiver(m2m_changed, sender=Organization.admins.through) def _clear_users_from_org(org, users):
def on_org_admin_change(sender, **kwargs): if not users:
Organization._user_admin_orgs = None return
old_org = current_org
set_current_org(org)
_remove_users(AssetPermission, users, org)
_remove_users(UserGroup, users, org, reverse=True)
set_current_org(old_org)
@receiver(m2m_changed, sender=OrganizationMember)
def on_org_user_changed(sender, instance=None, action=None, pk_set=None, **kwargs):
if action == 'post_remove':
leaved_users = set(pk_set) - set(instance.members.values_list('id', flat=True))
_clear_users_from_org(instance, leaved_users)

View File

@ -1,3 +1,16 @@
from django.test import TestCase from django.urls import reverse
from rest_framework.test import APITestCase
from rest_framework import status
from users.models.user import User
class OrgTests(APITestCase):
def test_create(self):
print(User.objects.all())
reverse('api-orgs:org-list')
{"name":"a-07","admins":["138167d2-6843-4e25-b838-59657157c6c6"],"auditors":["8d4b3ec4-8339-4a2c-b33c-c2633da62c84"],"users":["ea60e8ce-876d-493b-a641-ff836258629c"]}
# Create your tests here.

View File

@ -1,8 +1,9 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# #
from django.urls import re_path, path from django.urls import re_path
from rest_framework.routers import DefaultRouter from rest_framework.routers import DefaultRouter
from rest_framework_bulk.routes import BulkRouter
from common import api as capi from common import api as capi
from .. import api from .. import api
@ -10,21 +11,13 @@ from .. import api
app_name = 'orgs' app_name = 'orgs'
router = DefaultRouter() router = DefaultRouter()
bulk_router = BulkRouter()
# 将会删除
router.register(r'orgs/(?P<org_id>[0-9a-zA-Z\-]{36})/membership/admins',
api.OrgMembershipAdminsViewSet, 'membership-admins')
router.register(r'orgs/(?P<org_id>[0-9a-zA-Z\-]{36})/membership/users',
api.OrgMembershipUsersViewSet, 'membership-users'),
router.register(r'orgs', api.OrgViewSet, 'org') router.register(r'orgs', api.OrgViewSet, 'org')
bulk_router.register(r'org-memeber-relation', api.OrgMemberRelationBulkViewSet, 'org-memeber-relation')
old_version_urlpatterns = [ old_version_urlpatterns = [
re_path('(?P<resource>org)/.*', capi.redirect_plural_name_api) re_path('(?P<resource>org)/.*', capi.redirect_plural_name_api)
] ]
urlpatterns = [ urlpatterns = router.urls + bulk_router.urls + old_version_urlpatterns
path('<uuid:pk>/users/all/', api.OrgAllUserListApi.as_view(), name='org-all-users'),
]
urlpatterns += router.urls + old_version_urlpatterns

View File

@ -12,3 +12,6 @@ from .database_app_permission import *
from .database_app_permission_relation import * from .database_app_permission_relation import *
from .user_database_app_permission import * from .user_database_app_permission import *
from .system_user_permission import * from .system_user_permission import *
from .k8s_app_permission import *
from .k8s_app_permission_relation import *
from .user_k8s_app_permission import *

View File

@ -0,0 +1,21 @@
# coding: utf-8
#
from orgs.mixins.api import OrgBulkModelViewSet
from .. import models, serializers
from common.permissions import IsOrgAdmin
__all__ = ['K8sAppPermissionViewSet']
class K8sAppPermissionViewSet(OrgBulkModelViewSet):
model = models.K8sAppPermission
serializer_classes = {
'default': serializers.K8sAppPermissionSerializer,
'display': serializers.K8sAppPermissionListSerializer
}
filter_fields = ('name',)
search_fields = filter_fields
permission_classes = (IsOrgAdmin,)

View File

@ -0,0 +1,111 @@
# coding: utf-8
#
from rest_framework import generics
from django.db.models import F, Value
from django.db.models.functions import Concat
from django.shortcuts import get_object_or_404
from common.permissions import IsOrgAdmin
from .base import RelationViewSet
from .. import models, serializers
class K8sAppPermissionUserRelationViewSet(RelationViewSet):
serializer_class = serializers.K8sAppPermissionUserRelationSerializer
m2m_field = models.K8sAppPermission.users.field
permission_classes = (IsOrgAdmin,)
filter_fields = [
'id', 'user', 'k8sapppermission'
]
search_fields = ('user__name', 'user__username', 'k8sapppermission__name')
def get_queryset(self):
queryset = super().get_queryset()
queryset = queryset.annotate(user_display=F('user__name'))
return queryset
class K8sAppPermissionUserGroupRelationViewSet(RelationViewSet):
serializer_class = serializers.K8sAppPermissionUserGroupRelationSerializer
m2m_field = models.K8sAppPermission.user_groups.field
permission_classes = (IsOrgAdmin,)
filter_fields = [
'id', "usergroup", "k8sapppermission"
]
search_fields = ["usergroup__name", "k8sapppermission__name"]
def get_queryset(self):
queryset = super().get_queryset()
queryset = queryset \
.annotate(usergroup_display=F('usergroup__name'))
return queryset
class K8sAppPermissionAllUserListApi(generics.ListAPIView):
permission_classes = (IsOrgAdmin,)
serializer_class = serializers.K8sAppPermissionAllUserSerializer
filter_fields = ("username", "name")
search_fields = filter_fields
def get_queryset(self):
pk = self.kwargs.get("pk")
perm = get_object_or_404(models.K8sAppPermission, pk=pk)
users = perm.get_all_users().only(
*self.serializer_class.Meta.only_fields
)
return users
class K8sAppPermissionK8sAppRelationViewSet(RelationViewSet):
serializer_class = serializers.K8sAppPermissionK8sAppRelationSerializer
m2m_field = models.K8sAppPermission.k8s_apps.field
permission_classes = (IsOrgAdmin,)
filter_fields = [
'id', 'k8sapp', 'k8sapppermission',
]
search_fields = [
"id", "k8sapp__name", "k8sapppermission__name"
]
def get_queryset(self):
queryset = super().get_queryset()
queryset = queryset \
.annotate(k8sapp_display=F('k8sapp__name'))
return queryset
class K8sAppPermissionAllK8sAppListApi(generics.ListAPIView):
permission_classes = (IsOrgAdmin,)
serializer_class = serializers.K8sAppPermissionAllK8sAppSerializer
filter_fields = ("name",)
search_fields = filter_fields
def get_queryset(self):
pk = self.kwargs.get("pk")
perm = get_object_or_404(models.K8sAppPermission, pk=pk)
database_apps = perm.get_all_k8s_apps().only(
*self.serializer_class.Meta.only_fields
)
return database_apps
class K8sAppPermissionSystemUserRelationViewSet(RelationViewSet):
serializer_class = serializers.K8sAppPermissionSystemUserRelationSerializer
m2m_field = models.K8sAppPermission.system_users.field
permission_classes = (IsOrgAdmin,)
filter_fields = [
'id', 'systemuser', 'k8sapppermission'
]
search_fields = [
'k8sapppermission__name', 'systemuser__name', 'systemuser__username'
]
def get_queryset(self):
queryset = super().get_queryset()
queryset = queryset.annotate(
systemuser_display=Concat(
F('systemuser__name'), Value('('), F('systemuser__username'),
Value(')')
)
)
return queryset

View File

@ -21,7 +21,7 @@ class UserPermissionMixin:
obj = None obj = None
def initial(self, *args, **kwargs): def initial(self, *args, **kwargs):
super().initial(*args, *kwargs) super().initial(*args, **kwargs)
self.obj = self.get_obj() self.obj = self.get_obj()
def get_obj(self): def get_obj(self):

View File

@ -0,0 +1,119 @@
# coding: utf-8
#
import uuid
from django.shortcuts import get_object_or_404
from rest_framework.views import APIView, Response
from common.permissions import IsOrgAdminOrAppUser, IsValidUser
from common.tree import TreeNodeSerializer
from orgs.mixins import generics
from users.models import User, UserGroup
from applications.serializers import K8sAppSerializer
from applications.models import K8sApp
from assets.models import SystemUser
from .. import utils, serializers
from .mixin import UserPermissionMixin
class UserGrantedK8sAppsApi(generics.ListAPIView):
permission_classes = (IsOrgAdminOrAppUser,)
serializer_class = K8sAppSerializer
filter_fields = ['id', 'name', 'type', 'comment']
search_fields = ['name', 'comment']
def get_object(self):
user_id = self.kwargs.get('pk', '')
if user_id:
user = get_object_or_404(User, id=user_id)
else:
user = self.request.user
return user
def get_queryset(self):
util = utils.K8sAppPermissionUtil(self.get_object())
queryset = util.get_k8s_apps()
return queryset
def get_permissions(self):
if self.kwargs.get('pk') is None:
self.permission_classes = (IsValidUser,)
return super().get_permissions()
class UserGrantedK8sAppsAsTreeApi(UserGrantedK8sAppsApi):
serializer_class = TreeNodeSerializer
permission_classes = (IsOrgAdminOrAppUser,)
def get_serializer(self, k8s_apps, *args, **kwargs):
if k8s_apps is None:
k8s_apps = []
only_k8s_app = self.request.query_params.get('only', '0') == '1'
tree_root = None
data = []
if not only_k8s_app:
tree_root = utils.construct_k8s_apps_tree_root()
data.append(tree_root)
for k8s_app in k8s_apps:
node = utils.parse_k8s_app_to_tree_node(tree_root, k8s_app)
data.append(node)
data.sort()
return super().get_serializer(data, many=True)
class UserGrantedK8sAppSystemUsersApi(UserPermissionMixin, generics.ListAPIView):
permission_classes = (IsOrgAdminOrAppUser,)
serializer_class = serializers.K8sAppSystemUserSerializer
only_fields = serializers.K8sAppSystemUserSerializer.Meta.only_fields
def get_queryset(self):
util = utils.K8sAppPermissionUtil(self.obj)
k8s_app_id = self.kwargs.get('k8s_app_id')
k8s_app = get_object_or_404(K8sApp, id=k8s_app_id)
system_users = util.get_k8s_app_system_users(k8s_app)
return system_users
# Validate
class ValidateUserK8sAppPermissionApi(APIView):
permission_classes = (IsOrgAdminOrAppUser,)
def get(self, request, *args, **kwargs):
user_id = request.query_params.get('user_id', '')
k8s_app_id = request.query_params.get('k8s_app_id', '')
system_user_id = request.query_params.get('system_user_id', '')
try:
user_id = uuid.UUID(user_id)
k8s_app_id = uuid.UUID(k8s_app_id)
system_user_id = uuid.UUID(system_user_id)
except ValueError:
return Response({'msg': False}, status=403)
user = get_object_or_404(User, id=user_id)
k8s_app = get_object_or_404(K8sApp, id=k8s_app_id)
system_user = get_object_or_404(SystemUser, id=system_user_id)
util = utils.K8sAppPermissionUtil(user)
system_users = util.get_k8s_app_system_users(k8s_app)
if system_user in system_users:
return Response({'msg': True}, status=200)
return Response({'msg': False}, status=403)
# UserGroup
class UserGroupGrantedK8sAppsApi(generics.ListAPIView):
permission_classes = (IsOrgAdminOrAppUser,)
serializer_class = K8sAppSerializer
def get_queryset(self):
queryset = []
user_group_id = self.kwargs.get('pk')
if not user_group_id:
return queryset
user_group = get_object_or_404(UserGroup, id=user_group_id)
util = utils.K8sAppPermissionUtil(user_group)
queryset = util.get_k8s_apps()
return queryset

View File

@ -0,0 +1,28 @@
# Generated by Django 2.2.10 on 2020-07-21 09:39
from django.db import migrations, models
from django.db.models import F
from ..models.asset_permission import Action
def migrate_asset_permission(apps, schema_editor):
# 已有的资产权限默认拥有剪切板复制粘贴动作
AssetPermission = apps.get_model('perms', 'AssetPermission')
AssetPermission.objects.all().update(actions=F('actions').bitor(Action.CLIPBOARD_COPY_PASTE))
class Migration(migrations.Migration):
dependencies = [
('perms', '0010_auto_20191218_1705'),
]
operations = [
migrations.AlterField(
model_name='assetpermission',
name='actions',
field=models.IntegerField(choices=[(255, 'All'), (1, 'Connect'), (2, 'Upload file'), (4, 'Download file'), (6, 'Upload download'), (8, 'Clipboard copy'), (16, 'Clipboard paste'), (24, 'Clipboard copy paste')], default=255, verbose_name='Actions'),
),
migrations.RunPython(migrate_asset_permission)
]

View File

@ -0,0 +1,44 @@
# Generated by Django 2.2.13 on 2020-08-07 07:13
import common.utils.django
from django.conf import settings
from django.db import migrations, models
import django.utils.timezone
import uuid
class Migration(migrations.Migration):
dependencies = [
('assets', '0054_auto_20200807_1032'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('applications', '0005_k8sapp'),
('users', '0028_auto_20200728_1805'),
('perms', '0011_auto_20200721_1739'),
]
operations = [
migrations.CreateModel(
name='K8sAppPermission',
fields=[
('org_id', models.CharField(blank=True, db_index=True, default='', max_length=36, verbose_name='Organization')),
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
('name', models.CharField(max_length=128, verbose_name='Name')),
('is_active', models.BooleanField(default=True, verbose_name='Active')),
('date_start', models.DateTimeField(db_index=True, default=django.utils.timezone.now, verbose_name='Date start')),
('date_expired', models.DateTimeField(db_index=True, default=common.utils.django.date_expired_default, verbose_name='Date expired')),
('created_by', models.CharField(blank=True, max_length=128, verbose_name='Created by')),
('date_created', models.DateTimeField(auto_now_add=True, verbose_name='Date created')),
('comment', models.TextField(blank=True, verbose_name='Comment')),
('k8s_apps', models.ManyToManyField(blank=True, related_name='granted_by_permissions', to='applications.K8sApp', verbose_name='KubernetesApp')),
('system_users', models.ManyToManyField(related_name='granted_by_k8s_app_permissions', to='assets.SystemUser', verbose_name='System user')),
('user_groups', models.ManyToManyField(blank=True, to='users.UserGroup', verbose_name='User group')),
('users', models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL, verbose_name='User')),
],
options={
'verbose_name': 'KubernetesApp permission',
'ordering': ('name',),
'unique_together': {('org_id', 'name')},
},
),
]

View File

@ -4,3 +4,4 @@
from .asset_permission import * from .asset_permission import *
from .remote_app_permission import * from .remote_app_permission import *
from .database_app_permission import * from .database_app_permission import *
from .k8s_app_permission import *

View File

@ -21,11 +21,15 @@ logger = logging.getLogger(__name__)
class Action: class Action:
NONE = 0 NONE = 0
CONNECT = 0b00000001
UPLOAD = 0b00000010 CONNECT = 0b1
DOWNLOAD = 0b00000100 UPLOAD = 0b1 << 1
DOWNLOAD = 0b1 << 2
CLIPBOARD_COPY = 0b1 << 3
CLIPBOARD_PASTE = 0b1 << 4
ALL = 0xff
UPDOWNLOAD = UPLOAD | DOWNLOAD UPDOWNLOAD = UPLOAD | DOWNLOAD
ALL = 0b11111111 CLIPBOARD_COPY_PASTE = CLIPBOARD_COPY | CLIPBOARD_PASTE
DB_CHOICES = ( DB_CHOICES = (
(ALL, _('All')), (ALL, _('All')),
@ -33,6 +37,9 @@ class Action:
(UPLOAD, _('Upload file')), (UPLOAD, _('Upload file')),
(DOWNLOAD, _('Download file')), (DOWNLOAD, _('Download file')),
(UPDOWNLOAD, _("Upload download")), (UPDOWNLOAD, _("Upload download")),
(CLIPBOARD_COPY, _('Clipboard copy')),
(CLIPBOARD_PASTE, _('Clipboard paste')),
(CLIPBOARD_COPY_PASTE, _('Clipboard copy paste'))
) )
NAME_MAP = { NAME_MAP = {
@ -41,9 +48,12 @@ class Action:
UPLOAD: "upload_file", UPLOAD: "upload_file",
DOWNLOAD: "download_file", DOWNLOAD: "download_file",
UPDOWNLOAD: "updownload", UPDOWNLOAD: "updownload",
CLIPBOARD_COPY: 'clipboard_copy',
CLIPBOARD_PASTE: 'clipboard_paste',
CLIPBOARD_COPY_PASTE: 'clipboard_copy_paste'
} }
NAME_MAP_REVERSE = dict({v: k for k, v in NAME_MAP.items()}) NAME_MAP_REVERSE = {v: k for k, v in NAME_MAP.items()}
CHOICES = [] CHOICES = []
for i, j in DB_CHOICES: for i, j in DB_CHOICES:
CHOICES.append((NAME_MAP[i], j)) CHOICES.append((NAME_MAP[i], j))

View File

@ -0,0 +1,39 @@
# coding: utf-8
#
from django.db import models
from django.utils.translation import ugettext_lazy as _
from common.utils import lazyproperty
from .base import BasePermission
__all__ = [
'K8sAppPermission',
]
class K8sAppPermission(BasePermission):
k8s_apps = models.ManyToManyField(
'applications.K8sApp', related_name='granted_by_permissions',
blank=True, verbose_name=_("KubernetesApp")
)
system_users = models.ManyToManyField(
'assets.SystemUser', related_name='granted_by_k8s_app_permissions',
verbose_name=_("System user")
)
class Meta:
unique_together = [('org_id', 'name')]
verbose_name = _('KubernetesApp permission')
ordering = ('name',)
def get_all_k8s_apps(self):
return self.k8s_apps.all()
@lazyproperty
def k8s_apps_amount(self):
return self.k8s_apps.count()
@lazyproperty
def system_users_amount(self):
return self.system_users.count()

View File

@ -9,3 +9,5 @@ from .asset_permission_relation import *
from .database_app_permission import * from .database_app_permission import *
from .database_app_permission_relation import * from .database_app_permission_relation import *
from .base import * from .base import *
from .k8s_app_permission import *
from .k8s_app_permission_relation import *

View File

@ -0,0 +1,50 @@
# coding: utf-8
#
from django.db.models import Count
from rest_framework import serializers
from orgs.mixins.serializers import BulkOrgResourceModelSerializer
from .. import models
__all__ = [
'K8sAppPermissionSerializer', 'K8sAppPermissionListSerializer'
]
class AmountMixin:
@classmethod
def setup_eager_loading(cls, queryset):
""" Perform necessary eager loading of data. """
queryset = queryset.annotate(
users_amount=Count('users', distinct=True), user_groups_amount=Count('user_groups', distinct=True),
k8s_apps_amount=Count('k8s_apps', distinct=True),
system_users_amount=Count('system_users', distinct=True)
)
return queryset
class K8sAppPermissionSerializer(AmountMixin, BulkOrgResourceModelSerializer):
class Meta:
model = models.K8sAppPermission
fields = [
'id', 'name', 'users', 'user_groups', 'k8s_apps', 'system_users',
'comment', 'is_active', 'date_start', 'date_expired', 'is_valid',
'created_by', 'date_created', 'users_amount', 'user_groups_amount',
'k8s_apps_amount', 'system_users_amount',
]
read_only_fields = [
'created_by', 'date_created', 'users_amount', 'user_groups_amount',
'k8s_apps_amount', 'system_users_amount', 'id'
]
class K8sAppPermissionListSerializer(AmountMixin, BulkOrgResourceModelSerializer):
is_expired = serializers.BooleanField()
class Meta:
model = models.K8sAppPermission
fields = [
'id', 'name', 'comment', 'is_active', 'users_amount', 'user_groups_amount',
'date_start', 'date_expired', 'is_valid', 'k8s_apps_amount', 'system_users_amount',
'created_by', 'date_created', 'is_expired'
]

View File

@ -0,0 +1,73 @@
# coding: utf-8
#
from perms.serializers.base import PermissionAllUserSerializer
from rest_framework import serializers
from common.drf.serializers import BulkModelSerializer
from .. import models
class K8sAppPermissionUserRelationSerializer(BulkModelSerializer):
user_display = serializers.ReadOnlyField()
k8sapppermission_display = serializers.ReadOnlyField()
class Meta:
model = models.K8sAppPermission.users.through
fields = [
'id', 'user', 'user_display', 'k8sapppermission',
'k8sapppermission_display'
]
class K8sAppPermissionUserGroupRelationSerializer(BulkModelSerializer):
usergroup_display = serializers.ReadOnlyField()
k8sapppermission_display = serializers.ReadOnlyField()
class Meta:
model = models.K8sAppPermission.user_groups.through
fields = [
'id', 'usergroup', 'usergroup_display', 'k8sapppermission',
'k8sapppermission_display'
]
class K8sAppPermissionAllUserSerializer(PermissionAllUserSerializer):
class Meta(PermissionAllUserSerializer.Meta):
pass
class K8sAppPermissionK8sAppRelationSerializer(BulkModelSerializer):
k8sapp_display = serializers.ReadOnlyField()
k8sapppermission_display = serializers.ReadOnlyField()
class Meta:
model = models.K8sAppPermission.k8s_apps.through
fields = [
'id', "k8sapp", "k8sapp_display", 'k8sapppermission',
'k8sapppermission_display'
]
class K8sAppPermissionAllK8sAppSerializer(serializers.Serializer):
k8sapp = serializers.UUIDField(read_only=True, source='id')
k8sapp_display = serializers.SerializerMethodField()
class Meta:
only_fields = ['id', 'name']
@staticmethod
def get_k8sapp_display(obj):
return str(obj)
class K8sAppPermissionSystemUserRelationSerializer(BulkModelSerializer):
systemuser_display = serializers.ReadOnlyField()
k8sapppermission_display = serializers.ReadOnlyField()
class Meta:
model = models.K8sAppPermission.system_users.through
fields = [
'id', 'systemuser', 'systemuser_display', 'k8sapppermission',
'k8sapppermission_display'
]

View File

@ -14,6 +14,7 @@ __all__ = [
'ActionsSerializer', 'AssetSystemUserSerializer', 'ActionsSerializer', 'AssetSystemUserSerializer',
'RemoteAppSystemUserSerializer', 'RemoteAppSystemUserSerializer',
'DatabaseAppSystemUserSerializer', 'DatabaseAppSystemUserSerializer',
'K8sAppSystemUserSerializer',
] ]
@ -53,6 +54,16 @@ class DatabaseAppSystemUserSerializer(serializers.ModelSerializer):
read_only_fields = fields read_only_fields = fields
class K8sAppSystemUserSerializer(serializers.ModelSerializer):
class Meta:
model = SystemUser
only_fields = (
'id', 'name', 'username', 'priority', 'protocol', 'login_mode',
)
fields = list(only_fields)
read_only_fields = fields
class AssetGrantedSerializer(serializers.ModelSerializer): class AssetGrantedSerializer(serializers.ModelSerializer):
""" """
被授权资产的数据结构 被授权资产的数据结构

View File

@ -6,6 +6,7 @@ from .asset_permission import asset_permission_urlpatterns
from .remote_app_permission import remote_app_permission_urlpatterns from .remote_app_permission import remote_app_permission_urlpatterns
from .database_app_permission import database_app_permission_urlpatterns from .database_app_permission import database_app_permission_urlpatterns
from .system_user_permission import system_users_permission_urlpatterns from .system_user_permission import system_users_permission_urlpatterns
from .k8s_app_permission import k8s_app_permission_urlpatterns
app_name = 'perms' app_name = 'perms'
@ -16,5 +17,6 @@ old_version_urlpatterns = [
urlpatterns = asset_permission_urlpatterns + \ urlpatterns = asset_permission_urlpatterns + \
remote_app_permission_urlpatterns + \ remote_app_permission_urlpatterns + \
database_app_permission_urlpatterns + \ database_app_permission_urlpatterns + \
k8s_app_permission_urlpatterns + \
old_version_urlpatterns + \ old_version_urlpatterns + \
system_users_permission_urlpatterns system_users_permission_urlpatterns

View File

@ -0,0 +1,45 @@
# coding: utf-8
#
from django.urls import path, include
from rest_framework_bulk.routes import BulkRouter
from .. import api
router = BulkRouter()
router.register('k8s-app-permissions', api.K8sAppPermissionViewSet, 'k8s-app-permission')
router.register('k8s-app-permissions-users-relations', api.K8sAppPermissionUserRelationViewSet, 'k8s-app-permissions-users-relation')
router.register('k8s-app-permissions-user-groups-relations', api.K8sAppPermissionUserGroupRelationViewSet, 'k8s-app-permissions-user-groups-relation')
router.register('k8s-app-permissions-k8s-apps-relations', api.K8sAppPermissionK8sAppRelationViewSet, 'k8s-app-permissions-k8s-apps-relation')
router.register('k8s-app-permissions-system-users-relations', api.K8sAppPermissionSystemUserRelationViewSet, 'k8s-app-permissions-system-users-relation')
user_permission_urlpatterns = [
path('<uuid:pk>/k8s-apps/', api.UserGrantedK8sAppsApi.as_view(), name='user-k8s-apps'),
path('k8s-apps/', api.UserGrantedK8sAppsApi.as_view(), name='my-k8s-apps'),
# k8sApps as tree
path('<uuid:pk>/k8s-apps/tree/', api.UserGrantedK8sAppsAsTreeApi.as_view(), name='user-k8ss-apps-tree'),
path('k8s-apps/tree/', api.UserGrantedK8sAppsAsTreeApi.as_view(), name='my-k8ss-apps-tree'),
path('<uuid:pk>/k8s-apps/<uuid:k8s_app_id>/system-users/', api.UserGrantedK8sAppSystemUsersApi.as_view(), name='user-k8s-app-system-users'),
path('k8s-apps/<uuid:k8s_app_id>/system-users/', api.UserGrantedK8sAppSystemUsersApi.as_view(), name='user-k8s-app-system-users'),
]
user_group_permission_urlpatterns = [
path('<uuid:pk>/k8s-apps/', api.UserGroupGrantedK8sAppsApi.as_view(), name='user-group-k8s-apps'),
]
permission_urlpatterns = [
path('<uuid:pk>/users/all/', api.K8sAppPermissionAllUserListApi.as_view(), name='k8s-app-permission-all-users'),
path('<uuid:pk>/k8s-apps/all/', api.K8sAppPermissionAllK8sAppListApi.as_view(), name='k8s-app-permission-all-k8s-apps'),
path('user/validate/', api.ValidateUserK8sAppPermissionApi.as_view(), name='validate-user-k8s-app-permission'),
]
k8s_app_permission_urlpatterns = [
path('users/', include(user_permission_urlpatterns)),
path('user-groups/', include(user_group_permission_urlpatterns)),
path('k8s-app-permissions/', include(permission_urlpatterns))
]
k8s_app_permission_urlpatterns += router.urls

View File

@ -4,3 +4,4 @@
from .asset_permission import * from .asset_permission import *
from .remote_app_permission import * from .remote_app_permission import *
from .database_app_permission import * from .database_app_permission import *
from .k8s_app_permission import *

View File

@ -0,0 +1,93 @@
# coding: utf-8
#
from django.utils.translation import ugettext as _
from django.db.models import Q
from orgs.utils import set_to_root_org
from ..models import K8sAppPermission
from common.tree import TreeNode
from applications.models import K8sApp
from assets.models import SystemUser
def get_user_k8s_app_permissions(user, include_group=True):
if include_group:
groups = user.groups.all()
arg = Q(users=user) | Q(user_groups__in=groups)
else:
arg = Q(users=user)
return K8sAppPermission.objects.all().valid().filter(arg)
def get_user_group_k8s_app_permission(user_group):
return K8sAppPermission.objects.all().valid().filter(
user_groups=user_group
)
class K8sAppPermissionUtil:
get_permissions_map = {
'User': get_user_k8s_app_permissions,
'UserGroup': get_user_group_k8s_app_permission
}
def __init__(self, obj):
self.object = obj
self.change_org_if_need()
@staticmethod
def change_org_if_need():
set_to_root_org()
@property
def permissions(self):
obj_class = self.object.__class__.__name__
func = self.get_permissions_map[obj_class]
_permissions = func(self.object)
return _permissions
def get_k8s_apps(self):
k8s_apps = K8sApp.objects.filter(
granted_by_permissions__in=self.permissions
).distinct()
return k8s_apps
def get_k8s_app_system_users(self, k8s_app):
queryset = self.permissions
kwargs = {'k8s_apps': k8s_app}
queryset = queryset.filter(**kwargs)
system_users_ids = queryset.values_list('system_users', flat=True)
system_users_ids = system_users_ids.distinct()
system_users = SystemUser.objects.filter(id__in=system_users_ids)
system_users = system_users.order_by('-priority')
return system_users
def construct_k8s_apps_tree_root():
tree_root = {
'id': 'ID_K8S_APP_ROOT',
'name': _('KubernetesApp'),
'title': 'K8sApp',
'pId': '',
'open': False,
'isParent': True,
'iconSkin': '',
'meta': {'type': 'k8s_app'}
}
return TreeNode(**tree_root)
def parse_k8s_app_to_tree_node(parent, k8s_app):
pid = parent.id if parent else ''
tree_node = {
'id': k8s_app.id,
'name': k8s_app.name,
'title': k8s_app.name,
'pId': pid,
'open': False,
'isParent': False,
'iconSkin': 'file',
'meta': {'type': 'k8s_app'}
}
return TreeNode(**tree_node)

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