Merge pull request #7033 from jumpserver/dev

v2.15.0 rc1
pull/7119/head
Jiangjie.Bai 2021-10-20 20:24:29 +08:00 committed by GitHub
commit 076adec218
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
143 changed files with 2715 additions and 2193 deletions

2
.gitattributes vendored Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -16,7 +16,6 @@ from ..tasks import (
push_system_user_to_assets
)
logger = get_logger(__file__)
__all__ = [
'SystemUserViewSet', 'SystemUserAuthInfoApi', 'SystemUserAssetAuthInfoApi',
@ -91,7 +90,7 @@ class SystemUserAssetAuthInfoApi(generics.RetrieveAPIView):
asset_id = self.kwargs.get('asset_id')
user_id = self.request.query_params.get("user_id")
username = self.request.query_params.get("username")
instance.load_asset_more_auth(asset_id=asset_id, user_id=user_id, username=username)
instance.load_asset_more_auth(asset_id, username, user_id)
return instance
@ -107,8 +106,7 @@ class SystemUserAppAuthInfoApi(generics.RetrieveAPIView):
instance = super().get_object()
app_id = self.kwargs.get('app_id')
user_id = self.request.query_params.get("user_id")
if user_id:
instance.load_app_more_auth(app_id, user_id)
instance.load_app_more_auth(app_id, user_id)
return instance

View File

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

View File

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

View File

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

View File

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

View File

@ -173,7 +173,7 @@ class AuthMixin:
class BaseUser(OrgModelMixin, AuthMixin):
id = models.UUIDField(default=uuid.uuid4, primary_key=True)
name = models.CharField(max_length=128, verbose_name=_('Name'))
username = models.CharField(max_length=128, blank=True, verbose_name=_('Username'), validators=[alphanumeric], db_index=True)
username = models.CharField(max_length=128, blank=True, verbose_name=_('Username'), db_index=True)
password = fields.EncryptCharField(max_length=256, blank=True, null=True, verbose_name=_('Password'))
private_key = fields.EncryptTextField(blank=True, null=True, verbose_name=_('SSH private key'))
public_key = fields.EncryptTextField(blank=True, null=True, verbose_name=_('SSH public key'))

View File

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

View File

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

View File

@ -4,6 +4,7 @@ from django.db.models import Count
from common.mixins.serializers import BulkSerializerMixin
from common.utils import ssh_pubkey_gen
from common.validators import alphanumeric_re, alphanumeric_cn_re
from orgs.mixins.serializers import BulkOrgResourceModelSerializer
from ..models import SystemUser, Asset
from .utils import validate_password_contains_left_double_curly_bracket
@ -97,15 +98,20 @@ class SystemUserSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
raise serializers.ValidationError(error)
def validate_username(self, username):
if username:
return username
login_mode = self.get_initial_value("login_mode")
protocol = self.get_initial_value("protocol")
username_same_with_user = self.get_initial_value("username_same_with_user")
if username:
regx = alphanumeric_re
if protocol == SystemUser.Protocol.telnet:
regx = alphanumeric_cn_re
if not regx.match(username):
raise serializers.ValidationError(_('Special char not allowed'))
return username
username_same_with_user = self.get_initial_value("username_same_with_user")
if username_same_with_user:
return ''
login_mode = self.get_initial_value("login_mode")
if login_mode == SystemUser.LOGIN_AUTO and protocol != SystemUser.Protocol.vnc:
msg = _('* Automatic login mode must fill in the username.')
raise serializers.ValidationError(msg)
@ -181,7 +187,9 @@ class SystemUserSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
@classmethod
def setup_eager_loading(cls, queryset):
""" Perform necessary eager loading of data. """
queryset = queryset.annotate(assets_amount=Count("assets"))
queryset = queryset\
.annotate(assets_amount=Count("assets"))\
.prefetch_related('nodes', 'cmd_filters')
return queryset

View File

@ -34,9 +34,11 @@ def on_authbook_post_delete(sender, instance, **kwargs):
@receiver(post_save, sender=AuthBook)
def on_authbook_post_create(sender, instance, **kwargs):
def on_authbook_post_create(sender, instance, created, **kwargs):
instance.sync_to_system_user_account()
instance.update_asset_admin_user_if_need()
if created:
# 只在创建时进行更新资产的管理用户
instance.update_asset_admin_user_if_need()
@receiver(pre_save, sender=AuthBook)

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

@ -23,7 +23,9 @@ from common.drf.api import SerializerMixin
from common.permissions import IsSuperUserOrAppUser, IsValidUser, IsSuperUser
from orgs.mixins.api import RootOrgViewMixin
from common.http import is_true
from assets.models import SystemUser
from perms.utils.asset.permission import get_asset_system_user_ids_with_actions_by_user
from perms.models.asset_permission import Action
from authentication.errors import NotHaveUpDownLoadPerm
from ..serializers import (
ConnectionTokenSerializer, ConnectionTokenSecretSerializer,
@ -89,8 +91,14 @@ class ClientProtocolMixin:
drives_redirect = is_true(self.request.query_params.get('drives_redirect'))
token = self.create_token(user, asset, application, system_user)
if drives_redirect:
options['drivestoredirect:s'] = '*'
if drives_redirect and asset:
systemuser_actions_mapper = get_asset_system_user_ids_with_actions_by_user(user, asset)
actions = systemuser_actions_mapper.get(system_user.id, [])
if actions & Action.UPDOWNLOAD:
options['drivestoredirect:s'] = '*'
else:
raise NotHaveUpDownLoadPerm
options['screen mode id:i'] = '2' if full_screen else '1'
address = settings.TERMINAL_RDP_ADDR
if not address or address == 'localhost:3389':

View File

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

View File

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

View File

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

View File

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

View File

@ -3,8 +3,8 @@
from django.utils.translation import ugettext_lazy as _
from django.urls import reverse
from django.conf import settings
from rest_framework import status
from authentication import sms_verify_code
from common.exceptions import JMSException
from .signals import post_auth_failed
from users.utils import LoginBlockUtil, MFABlockUtils
@ -78,6 +78,7 @@ mfa_type_failed_msg = _(
mfa_required_msg = _("MFA required")
mfa_unset_msg = _("MFA not set, please set it first")
otp_unset_msg = _("OTP not set, please set it first")
login_confirm_required_msg = _("Login confirm required")
login_confirm_wait_msg = _("Wait login confirm ticket for accept")
login_confirm_error_msg = _("Login confirm ticket was {}")
@ -260,6 +261,13 @@ class LoginIPNotAllowed(ACLError):
super().__init__(_("IP is not allowed"), **kwargs)
class TimePeriodNotAllowed(ACLError):
def __init__(self, username, request, **kwargs):
self.username = username
self.request = request
super().__init__(_("Time Period is not allowed"), **kwargs)
class LoginConfirmBaseError(NeedMoreInfoError):
def __init__(self, ticket_id, **kwargs):
self.ticket_id = ticket_id
@ -348,3 +356,21 @@ class FeiShuNotBound(JMSException):
class PasswdInvalid(JMSException):
default_code = 'passwd_invalid'
default_detail = _('Your password is invalid')
class NotHaveUpDownLoadPerm(JMSException):
status_code = status.HTTP_403_FORBIDDEN
code = 'not_have_up_down_load_perm'
default_detail = _('No upload or download permission')
class NotEnableMFAError(JMSException):
default_detail = mfa_unset_msg
class OTPRequiredError(JMSException):
default_detail = otp_unset_msg
def __init__(self, url, *args, **kwargs):
super().__init__(*args, **kwargs)
self.url = url

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -14,7 +14,8 @@ def sign(secret, data):
digest = hmac.HMAC(
key=secret.encode('utf8'),
msg=data.encode('utf8'),
digestmod=hmac._hashlib.sha256).digest()
digestmod=hmac._hashlib.sha256
).digest()
signature = base64.standard_b64encode(digest).decode('utf8')
# signature = urllib.parse.quote(signature, safe='')
# signature = signature.replace('+', '%20').replace('*', '%2A').replace('~', '%7E').replace('/', '%2F')
@ -39,9 +40,9 @@ class DingTalkRequests(BaseRequest):
invalid_token_errcodes = (ErrorCode.INVALID_TOKEN,)
def __init__(self, appid, appsecret, agentid, timeout=None):
self._appid = appid
self._appsecret = appsecret
self._agentid = agentid
self._appid = appid or ''
self._appsecret = appsecret or ''
self._agentid = agentid or ''
super().__init__(timeout=timeout)
@ -74,7 +75,7 @@ class DingTalkRequests(BaseRequest):
def post(self, url, json=None, params=None,
with_token=False, with_sign=False,
check_errcode_is_0=True,
**kwargs):
**kwargs) -> dict:
pass
post = as_request(post)
@ -86,11 +87,10 @@ class DingTalkRequests(BaseRequest):
timestamp = str(int(time.time() * 1000))
signature = sign(self._appsecret, timestamp)
accessKey = self._appid
params['timestamp'] = timestamp
params['signature'] = signature
params['accessKey'] = accessKey
params['accessKey'] = self._appid
def request(self, method, url,
with_token=False, with_sign=False,
@ -102,15 +102,16 @@ class DingTalkRequests(BaseRequest):
data = super().request(
method, url, with_token=with_token,
check_errcode_is_0=check_errcode_is_0, **kwargs)
check_errcode_is_0=check_errcode_is_0, **kwargs
)
return data
class DingTalk:
def __init__(self, appid, appsecret, agentid, timeout=None):
self._appid = appid
self._appsecret = appsecret
self._agentid = agentid
self._appid = appid or ''
self._appsecret = appsecret or ''
self._agentid = agentid or ''
self._request = DingTalkRequests(
appid=appid, appsecret=appsecret, agentid=agentid,

View File

@ -69,8 +69,8 @@ class FeiShu(RequestMixin):
"""
def __init__(self, app_id, app_secret, timeout=None):
self._app_id = app_id
self._app_secret = app_secret
self._app_id = app_id or ''
self._app_secret = app_secret or ''
self._requests = FeishuRequests(
app_id=app_id,

View File

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

View File

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

View File

@ -47,9 +47,9 @@ class WeComRequests(BaseRequest):
invalid_token_errcodes = (ErrorCode.INVALID_TOKEN,)
def __init__(self, corpid, corpsecret, agentid, timeout=None):
self._corpid = corpid
self._corpsecret = corpsecret
self._agentid = agentid
self._corpid = corpid or ''
self._corpsecret = corpsecret or ''
self._agentid = agentid or ''
super().__init__(timeout=timeout)
@ -79,9 +79,9 @@ class WeCom(RequestMixin):
"""
def __init__(self, corpid, corpsecret, agentid, timeout=None):
self._corpid = corpid
self._corpsecret = corpsecret
self._agentid = agentid
self._corpid = corpid or ''
self._corpsecret = corpsecret or ''
self._agentid = agentid or ''
self._requests = WeComRequests(
corpid=corpid,

View File

@ -112,7 +112,8 @@ class ExtraFilterFieldsMixin:
backends = list(chain(
self.filter_backends,
self.default_added_filters,
self.extra_filter_backends))
self.extra_filter_backends
))
return backends
def filter_queryset(self, queryset):

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

View File

@ -1,24 +0,0 @@
# -*- coding: utf-8 -*-
#
import os
from django.utils.translation import ugettext as _
import ipdb
__all__ = ['get_ip_city']
ipip_db = None
def get_ip_city(ip):
global ipip_db
if not ip or not isinstance(ip, str):
return _("Invalid ip")
if ':' in ip:
return 'IPv6'
if ipip_db is None:
ipip_db_path = os.path.join(os.path.dirname(__file__), 'ipipfree.ipdb')
ipip_db = ipdb.City(ipip_db_path)
info = list(set(ipip_db.find(ip, 'CN')))
if '' in info:
info.remove('')
return ' '.join(info)

View File

@ -1,5 +1,6 @@
from django.core.cache import cache
from django.shortcuts import reverse
from django.shortcuts import redirect
from .random import random_string
@ -8,6 +9,17 @@ __all__ = ['FlashMessageUtil']
class FlashMessageUtil:
"""
跳转到通用msg页面
message_data: {
'title': '',
'message': '',
'error': '',
'redirect_url': '',
'confirm_button': '',
'cancel_url': ''
}
"""
@staticmethod
def get_key(code):
key = 'MESSAGE_{}'.format(code)
@ -29,3 +41,8 @@ class FlashMessageUtil:
def gen_message_url(cls, message_data):
code = cls.get_message_code(message_data)
return reverse('common:flash-message') + f'?code={code}'
@classmethod
def gen_and_redirect_to(cls, message_data):
url = cls.gen_message_url(message_data)
return redirect(url)

View File

@ -0,0 +1,18 @@
from common.utils.timezone import local_now
def contains_time_period(time_periods):
"""
time_periods: [{"id": 1, "value": "00:00~07:30、10:00~13:00"}, {"id": 2, "value": "00:00~00:00"}]
"""
if not time_periods:
return False
current_time = local_now().strftime('%H:%M')
today_time_period = next(filter(lambda x: str(x['id']) == local_now().strftime("%w"), time_periods))
for time in today_time_period['value'].split(''):
start, end = time.split('~')
end = "24:00" if end == "00:00" else end
if start <= current_time <= end:
return True
return False

View File

@ -20,14 +20,14 @@ def as_current_tz(dt: datetime.datetime):
return astimezone(dt, dj_timezone.get_current_timezone())
def utcnow():
def utc_now():
return dj_timezone.now()
def now():
return as_current_tz(utcnow())
def local_now():
return as_current_tz(utc_now())
_rest_dt_field = DateTimeField()
dt_parser = _rest_dt_field.to_internal_value
dt_formater = _rest_dt_field.to_representation
dt_formatter = _rest_dt_field.to_representation

View File

@ -11,9 +11,12 @@ from rest_framework import serializers
from common.utils.strings import no_special_chars
alphanumeric = RegexValidator(r'^[0-9a-zA-Z_@\-\.]*$', _('Special char not allowed'))
alphanumeric_re = re.compile(r'^[0-9a-zA-Z_@\-\.]*$')
alphanumeric_cn_re = re.compile(r'^[0-9a-zA-Z_@\-\.\u4E00-\u9FA5]*$')
class ProjectUniqueValidator(UniqueTogetherValidator):
def __call__(self, attrs, serializer):

View File

@ -20,21 +20,21 @@ class FlashMessageMsgView(TemplateView):
if not message_data:
return HttpResponse('Message code error')
title, message, redirect_url, confirm_button, cancel_url = bulk_get(
message_data, 'title', 'message', 'redirect_url', 'confirm_button', 'cancel_url'
)
items = ('title', 'message', 'error', 'redirect_url', 'confirm_button', 'cancel_url')
title, msg, error, redirect_url, confirm_btn, cancel_url = bulk_get(message_data, items)
interval = message_data.get('interval', 3)
auto_redirect = message_data.get('auto_redirect', True)
has_cancel = message_data.get('has_cancel', False)
context = {
'title': title,
'messages': message,
'message': msg,
'error': error,
'interval': interval,
'redirect_url': redirect_url,
'auto_redirect': auto_redirect,
'confirm_button': confirm_button,
'confirm_button': confirm_btn,
'has_cancel': has_cancel,
'cancel_url': cancel_url,
}
return self.render_to_response(context)
return self.render_to_response(context)

View File

@ -144,6 +144,10 @@ class Config(dict):
'GLOBAL_ORG_DISPLAY_NAME': '',
'SITE_URL': 'http://localhost:8080',
'USER_GUIDE_URL': '',
'ANNOUNCEMENT_ENABLED': True,
'ANNOUNCEMENT': {},
'CAPTCHA_TEST_MODE': None,
'TOKEN_EXPIRATION': 3600 * 24,
'DISPLAY_PER_PAGE': 25,
@ -291,6 +295,7 @@ class Config(dict):
'SECURITY_PASSWORD_LOWER_CASE': False,
'SECURITY_PASSWORD_NUMBER': False,
'SECURITY_PASSWORD_SPECIAL_CHAR': False,
'SECURITY_MFA_IN_LOGIN_PAGE': False,
'SECURITY_LOGIN_CHALLENGE_ENABLED': False,
'SECURITY_LOGIN_CAPTCHA_ENABLED': True,
'SECURITY_INSECURE_COMMAND': False,
@ -301,7 +306,6 @@ class Config(dict):
'SECURITY_MFA_VERIFY_TTL': 3600,
'SECURITY_SESSION_SHARE': True,
'OLD_PASSWORD_HISTORY_LIMIT_COUNT': 5,
'LOGIN_CONFIRM_ENABLE': False, # 准备废弃,放到 acl 中
'CHANGE_AUTH_PLAN_SECURE_MODE_ENABLED': True,
'USER_LOGIN_SINGLE_MACHINE_ENABLED': False,
'ONLY_ALLOW_EXIST_USER_AUTH': False,

View File

@ -24,7 +24,6 @@ def jumpserver_processor(request):
'SECURITY_MFA_VERIFY_TTL': settings.SECURITY_MFA_VERIFY_TTL,
'FORCE_SCRIPT_NAME': settings.FORCE_SCRIPT_NAME,
'SECURITY_VIEW_AUTH_NEED_MFA': settings.SECURITY_VIEW_AUTH_NEED_MFA,
'LOGIN_CONFIRM_ENABLE': settings.LOGIN_CONFIRM_ENABLE,
}
return context

View File

@ -126,7 +126,6 @@ FEISHU_APP_SECRET = CONFIG.FEISHU_APP_SECRET
# Other setting
TOKEN_EXPIRATION = CONFIG.TOKEN_EXPIRATION
LOGIN_CONFIRM_ENABLE = CONFIG.LOGIN_CONFIRM_ENABLE
OTP_IN_RADIUS = CONFIG.OTP_IN_RADIUS

View File

@ -55,6 +55,7 @@ SECURITY_MFA_VERIFY_TTL = CONFIG.SECURITY_MFA_VERIFY_TTL
SECURITY_VIEW_AUTH_NEED_MFA = CONFIG.SECURITY_VIEW_AUTH_NEED_MFA
SECURITY_SERVICE_ACCOUNT_REGISTRATION = CONFIG.SECURITY_SERVICE_ACCOUNT_REGISTRATION
SECURITY_LOGIN_CAPTCHA_ENABLED = CONFIG.SECURITY_LOGIN_CAPTCHA_ENABLED
SECURITY_MFA_IN_LOGIN_PAGE = CONFIG.SECURITY_MFA_IN_LOGIN_PAGE
SECURITY_LOGIN_CHALLENGE_ENABLED = CONFIG.SECURITY_LOGIN_CHALLENGE_ENABLED
SECURITY_DATA_CRYPTO_ALGO = CONFIG.SECURITY_DATA_CRYPTO_ALGO
SECURITY_INSECURE_COMMAND = CONFIG.SECURITY_INSECURE_COMMAND
@ -149,3 +150,7 @@ TENCENT_SECRET_ID = CONFIG.TENCENT_SECRET_ID
TENCENT_SECRET_KEY = CONFIG.TENCENT_SECRET_KEY
TENCENT_SDKAPPID = CONFIG.TENCENT_SDKAPPID
TENCENT_SMS_SIGN_AND_TEMPLATES = CONFIG.TENCENT_SMS_SIGN_AND_TEMPLATES
# 公告
ANNOUNCEMENT_ENABLED = CONFIG.ANNOUNCEMENT_ENABLED
ANNOUNCEMENT = CONFIG.ANNOUNCEMENT

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@ -6,4 +6,5 @@ class NotificationsConfig(AppConfig):
def ready(self):
from . import signals_handler
from . import notifications
super().ready()

View File

@ -1,12 +1,12 @@
from typing import Iterable
import traceback
from html2text import HTML2Text
from typing import Iterable
from itertools import chain
import time
from celery import shared_task
from django.utils.translation import gettext_lazy as _
from common.utils.timezone import now
from common.utils.timezone import local_now
from common.utils import lazyproperty
from users.models import User
from notifications.backends import BACKEND
@ -17,6 +17,7 @@ __all__ = ('SystemMessage', 'UserMessage', 'system_msgs')
system_msgs = []
user_msgs = []
all_msgs = []
class MessageType(type):
@ -58,6 +59,7 @@ class Message(metaclass=MessageType):
message_type_label: str
category: str
category_label: str
text_msg_ignore_links = True
@classmethod
def get_message_type(cls):
@ -66,6 +68,10 @@ class Message(metaclass=MessageType):
def publish_async(self):
return publish_task.delay(self)
@classmethod
def gen_test_msg(cls):
raise NotImplementedError
def publish(self):
raise NotImplementedError
@ -80,31 +86,46 @@ class Message(metaclass=MessageType):
continue
get_msg_method = getattr(self, f'get_{backend}_msg', self.get_common_msg)
try:
msg = get_msg_method()
except NotImplementedError:
continue
client = backend.client()
client.send_msg(users, **msg)
except:
except Exception:
traceback.print_exc()
def send_test_msg(self):
@classmethod
def send_test_msg(cls, ding=True):
msg = cls.gen_test_msg()
if not msg:
return
from users.models import User
users = User.objects.filter(username='admin')
self.send_msg(users, [])
backends = []
if ding:
backends.append(BACKEND.DINGTALK)
msg.send_msg(users, backends)
def get_common_msg(self) -> dict:
raise NotImplementedError
def get_text_msg(self) -> dict:
return self.common_msg
@staticmethod
def get_common_msg() -> dict:
return {
'subject': '',
'message': ''
}
def get_html_msg(self) -> dict:
return self.common_msg
return self.get_common_msg()
def get_text_msg(self) -> dict:
h = HTML2Text()
msg = self.get_html_msg()
content = msg['message']
h.ignore_links = self.text_msg_ignore_links
msg['message'] = h.handle(content)
return msg
@lazyproperty
def common_msg(self) -> dict:
@ -123,7 +144,8 @@ class Message(metaclass=MessageType):
def get_dingtalk_msg(self) -> dict:
# 钉钉相同的消息一天只能发一次,所以给所有消息添加基于时间的序号,使他们不相同
message = self.text_msg['message']
suffix = _('\nTime: {}').format(now())
time = local_now().strftime('%Y-%m-%d %H:%M:%S')
suffix = '\n{}: {}'.format(_('Time'), time)
return {
'subject': self.text_msg['subject'],
@ -144,7 +166,25 @@ class Message(metaclass=MessageType):
def get_sms_msg(self) -> dict:
return self.text_msg
# --------------------------------------------------------------
@classmethod
def test_all_messages(cls):
def get_subclasses(cls):
"""returns all subclasses of argument, cls"""
if issubclass(cls, type):
subclasses = cls.__subclasses__(cls)
else:
subclasses = cls.__subclasses__()
for subclass in subclasses:
subclasses.extend(get_subclasses(subclass))
return subclasses
messages_cls = get_subclasses(cls)
for _cls in messages_cls:
try:
msg = _cls.send_test_msg()
except NotImplementedError:
continue
class SystemMessage(Message):
@ -161,13 +201,16 @@ class SystemMessage(Message):
*subscription.users.all(),
*chain(*[g.users.all() for g in subscription.groups.all()])
]
self.send_msg(users, receive_backends)
@classmethod
def post_insert_to_db(cls, subscription: SystemMsgSubscription):
pass
@classmethod
def gen_test_msg(cls):
raise NotImplementedError
class UserMessage(Message):
user: User
@ -179,7 +222,9 @@ class UserMessage(Message):
"""
发送消息到每个用户配置的接收方式上
"""
sub = UserMsgSubscription.objects.get(user=self.user)
self.send_msg([self.user], sub.receive_backends)
@classmethod
def gen_test_msg(cls):
raise NotImplementedError

View File

@ -1,7 +1,7 @@
from django.db.models import F
from django.db import transaction
from common.utils.timezone import now
from common.utils.timezone import local_now
from common.utils import get_logger
from users.models import User
from .models import SiteMessage as SiteMessageModel, SiteMessageUsers
@ -88,7 +88,7 @@ class SiteMessageUtil:
for site_msg_user in site_msg_users:
site_msg_user.has_read = True
site_msg_user.read_at = now()
site_msg_user.read_at = local_now()
SiteMessageUsers.objects.bulk_update(
site_msg_users, fields=('has_read', 'read_at'))

View File

@ -11,7 +11,7 @@ from django_celery_beat.models import (
PeriodicTask, IntervalSchedule, CrontabSchedule, PeriodicTasks
)
from common.utils.timezone import now
from common.utils.timezone import local_now
from common.utils import get_logger
logger = get_logger(__name__)
@ -52,7 +52,7 @@ def create_or_update_celery_periodic_tasks(tasks):
interval = IntervalSchedule.objects.filter(**kwargs).first()
if interval is None:
interval = IntervalSchedule.objects.create(**kwargs)
last_run_at = now()
last_run_at = local_now()
elif isinstance(detail.get("crontab"), str):
try:
minute, hour, day, month, week = detail["crontab"].split()

View File

@ -24,13 +24,6 @@ class ServerPerformanceMessage(SystemMessage):
'message': self._msg
}
def get_text_msg(self) -> dict:
subject = self._msg[:80]
return {
'subject': subject.replace('<br>', '; '),
'message': self._msg.replace('<br>', '\n')
}
@classmethod
def post_insert_to_db(cls, subscription: SystemMsgSubscription):
admins = User.objects.filter(role=User.ROLE.ADMIN)
@ -38,6 +31,20 @@ class ServerPerformanceMessage(SystemMessage):
subscription.receive_backends = [BACKEND.EMAIL]
subscription.save()
@classmethod
def gen_test_msg(cls):
alarm_messages = []
items_mapper = ServerPerformanceCheckUtil.items_mapper
for item, data in items_mapper.items():
msg = data['alarm_msg_format']
max_threshold = data['max_threshold']
value = 123
msg = msg.format(max_threshold=max_threshold, value=value, name='Fake terminal')
alarm_messages.append(msg)
msg = '<br>'.join(alarm_messages)
return cls(msg)
class ServerPerformanceCheckUtil(object):
items_mapper = {
@ -50,21 +57,21 @@ class ServerPerformanceCheckUtil(object):
'default': 0,
'max_threshold': 80,
'alarm_msg_format': _(
'[Disk] Disk used more than {max_threshold}%: => {value} ({name})'
'Disk used more than {max_threshold}%: => {value} ({name})'
)
},
'memory_used': {
'default': 0,
'max_threshold': 85,
'alarm_msg_format': _(
'[Memory] Memory used more than {max_threshold}%: => {value} ({name})'
'Memory used more than {max_threshold}%: => {value} ({name})'
),
},
'cpu_load': {
'default': 0,
'max_threshold': 5,
'alarm_msg_format': _(
'[CPU] CPU load more than {max_threshold}: => {value} ({name})'
'CPU load more than {max_threshold}: => {value} ({name})'
),
},
}

View File

@ -9,3 +9,4 @@ class PermsConfig(AppConfig):
def ready(self):
super().ready()
from . import signals_handler
from . import notifications

155
apps/perms/notifications.py Normal file
View File

@ -0,0 +1,155 @@
from django.utils.translation import ugettext as _
from django.template.loader import render_to_string
from common.utils import reverse as js_reverse
from notifications.notifications import UserMessage
class BasePermMsg(UserMessage):
@classmethod
def gen_test_msg(cls):
return
class PermedWillExpireUserMsg(BasePermMsg):
def __init__(self, user, assets):
super().__init__(user)
self.assets = assets
def get_html_msg(self) -> dict:
subject = _("You permed assets is about to expire")
context = {
'name': self.user.name,
'items': [str(asset) for asset in self.assets],
'item_type': _("permed assets"),
'show_help': True
}
message = render_to_string('perms/_msg_permed_items_expire.html', context)
return {
'subject': subject,
'message': message
}
@classmethod
def gen_test_msg(cls):
from users.models import User
from assets.models import Asset
user = User.objects.first()
assets = Asset.objects.all()[:10]
return cls(user, assets)
class AssetPermsWillExpireForOrgAdminMsg(BasePermMsg):
def __init__(self, user, perms, org):
super().__init__(user)
self.perms = perms
self.org = org
def get_items_with_url(self):
items_with_url = []
for perm in self.perms:
url = js_reverse(
'perms:asset-permission-detail',
kwargs={'pk': perm.id}, external=True,
api_to_ui=True
) + f'?oid={perm.org_id}'
items_with_url.append([perm.name, url])
return items_with_url
def get_html_msg(self):
items_with_url = self.get_items_with_url()
subject = _("Asset permissions is about to expire")
context = {
'name': self.user.name,
'items_with_url': items_with_url,
'item_type': _('asset permissions of organization {}').format(self.org)
}
message = render_to_string('perms/_msg_item_permissions_expire.html', context)
return {
'subject': subject,
'message': message
}
@classmethod
def gen_test_msg(cls):
from users.models import User
from perms.models import AssetPermission
from orgs.models import Organization
user = User.objects.first()
perms = AssetPermission.objects.all()[:10]
org = Organization.objects.first()
return cls(user, perms, org)
class PermedAppsWillExpireUserMsg(BasePermMsg):
def __init__(self, user, apps):
super().__init__(user)
self.apps = apps
def get_html_msg(self) -> dict:
subject = _("Your permed applications is about to expire")
context = {
'name': self.user.name,
'item_type': _('permed applications'),
'items': [str(app) for app in self.apps]
}
message = render_to_string('perms/_msg_permed_items_expire.html', context)
return {
'subject': subject,
'message': message
}
@classmethod
def gen_test_msg(cls):
from users.models import User
from applications.models import Application
user = User.objects.first()
apps = Application.objects.all()[:10]
return cls(user, apps)
class AppPermsWillExpireForOrgAdminMsg(BasePermMsg):
def __init__(self, user, perms, org):
super().__init__(user)
self.perms = perms
self.org = org
def get_items_with_url(self):
items_with_url = []
for perm in self.perms:
url = js_reverse(
'perms:application-permission-detail',
kwargs={'pk': perm.id}, external=True,
api_to_ui=True
) + f'?oid={perm.org_id}'
items_with_url.append([perm.name, url])
return items_with_url
def get_html_msg(self) -> dict:
items = self.get_items_with_url()
subject = _('Application permissions is about to expire')
context = {
'name': self.user.name,
'item_type': _('application permissions of organization {}').format(self.org),
'items_with_url': items
}
message = render_to_string('perms/_msg_item_permissions_expire.html', context)
return {
'subject': subject,
'message': message
}
@classmethod
def gen_test_msg(cls):
from users.models import User
from perms.models import ApplicationPermission
from orgs.models import Organization
user = User.objects.first()
perms = ApplicationPermission.objects.all()[:10]
org = Organization.objects.first()
return cls(user, perms, org)

View File

@ -1,14 +1,21 @@
# ~*~ coding: utf-8 ~*~
from __future__ import absolute_import, unicode_literals
from datetime import timedelta
from collections import defaultdict
from django.db.transaction import atomic
from django.conf import settings
from celery import shared_task
from orgs.utils import tmp_to_root_org
from common.utils import get_logger
from common.utils.timezone import now, dt_formater, dt_parser
from common.utils.timezone import local_now, dt_formatter, dt_parser
from ops.celery.decorator import register_as_period_task
from perms.models import AssetPermission
from perms.notifications import (
PermedWillExpireUserMsg, AssetPermsWillExpireForOrgAdminMsg,
PermedAppsWillExpireUserMsg, AppPermsWillExpireForOrgAdminMsg
)
from perms.models import AssetPermission, ApplicationPermission
from perms.utils.asset.user_permission import UserGrantedTreeRefreshController
logger = get_logger(__file__)
@ -17,6 +24,7 @@ logger = get_logger(__file__)
@register_as_period_task(interval=settings.PERM_EXPIRED_CHECK_PERIODIC)
@shared_task()
@atomic()
@tmp_to_root_org()
def check_asset_permission_expired():
"""
这里的任务要足够短不要影响周期任务
@ -25,10 +33,10 @@ def check_asset_permission_expired():
setting_name = 'last_asset_perm_expired_check'
end = now()
end = local_now()
default_start = end - timedelta(days=36000) # Long long ago in china
defaults = {'value': dt_formater(default_start)}
defaults = {'value': dt_formatter(default_start)}
setting, created = Setting.objects.get_or_create(
name=setting_name, defaults=defaults
)
@ -36,7 +44,7 @@ def check_asset_permission_expired():
start = default_start
else:
start = dt_parser(setting.value)
setting.value = dt_formater(end)
setting.value = dt_formatter(end)
setting.save()
asset_perm_ids = AssetPermission.objects.filter(
@ -45,3 +53,72 @@ def check_asset_permission_expired():
asset_perm_ids = list(asset_perm_ids)
logger.info(f'>>> checking {start} to {end} have {asset_perm_ids} expired')
UserGrantedTreeRefreshController.add_need_refresh_by_asset_perm_ids_cross_orgs(asset_perm_ids)
@register_as_period_task(crontab='0 10 * * *')
@shared_task()
@atomic()
@tmp_to_root_org()
def check_asset_permission_will_expired():
start = local_now()
end = start + timedelta(days=3)
user_asset_mapper = defaultdict(set)
org_perm_mapper = defaultdict(set)
asset_perms = AssetPermission.objects.filter(
date_expired__gte=start,
date_expired__lte=end
).distinct()
for asset_perm in asset_perms:
# 资产授权按照组织分类
org_perm_mapper[asset_perm.org].add(asset_perm)
# 计算每个用户即将过期的资产
users = asset_perm.get_all_users()
assets = asset_perm.get_all_assets()
for u in users:
user_asset_mapper[u].update(assets)
for user, assets in user_asset_mapper.items():
PermedWillExpireUserMsg(user, assets).publish_async()
for org, perms in org_perm_mapper.items():
org_admins = org.admins.all()
for org_admin in org_admins:
AssetPermsWillExpireForOrgAdminMsg(org_admin, perms, org).publish_async()
@register_as_period_task(crontab='0 10 * * *')
@shared_task()
@atomic()
@tmp_to_root_org()
def check_app_permission_will_expired():
start = local_now()
end = start + timedelta(days=3)
app_perms = ApplicationPermission.objects.filter(
date_expired__gte=start,
date_expired__lte=end
).distinct()
user_app_mapper = defaultdict(set)
org_perm_mapper = defaultdict(set)
for app_perm in app_perms:
org_perm_mapper[app_perm.org].add(app_perm)
users = app_perm.get_all_users()
apps = app_perm.applications.all()
for u in users:
user_app_mapper[u].update(apps)
for user, apps in user_app_mapper.items():
PermedAppsWillExpireUserMsg(user, apps).publish_async()
for org, perms in org_perm_mapper.items():
org_admins = org.admins.all()
for org_admin in org_admins:
AppPermsWillExpireForOrgAdminMsg(org_admin, perms, org).publish_async()

View File

@ -0,0 +1,16 @@
{% load i18n %}
<p>
{% trans 'Hello' %} {{ name }},
</p>
<p>
{% blocktranslate %}
The following {{ item_type }} will expire in 3 days
{% endblocktranslate %}
</p>
<ul>
{% for item, url in items_with_url %}
<li><a href="{{ url }}">{{ item }}</a></li>
{% endfor %}
</ul>

View File

@ -0,0 +1,24 @@
{% load i18n %}
<p>
{% trans 'Hello' %} {{ name }},
</p>
<p>
{% blocktranslate %}
The following {{ item_type }} will expire in 3 days
{% endblocktranslate %}
</p>
<ul>
{% for item in items %}
<li>{{ item }}</li>
{% endfor %}
</ul>
<br />
<p>
---<br />
<small>
{% trans 'If you have any question, please contact the administrator' %}
</small>
</p>

View File

@ -4,7 +4,7 @@ from rest_framework.exceptions import APIException
from rest_framework import status
from django.utils.translation import gettext_lazy as _
from settings.models import Setting
from django.conf import settings
from common.permissions import IsSuperUser
from common.message.backends.dingtalk import DingTalk
@ -19,24 +19,19 @@ class DingTalkTestingAPI(GenericAPIView):
serializer = self.serializer_class(data=request.data)
serializer.is_valid(raise_exception=True)
dingtalk_appkey = serializer.validated_data['DINGTALK_APPKEY']
dingtalk_agentid = serializer.validated_data['DINGTALK_AGENTID']
dingtalk_appsecret = serializer.validated_data.get('DINGTALK_APPSECRET')
if not dingtalk_appsecret:
secret = Setting.objects.filter(name='DINGTALK_APPSECRET').first()
if secret:
dingtalk_appsecret = secret.cleaned_value
dingtalk_appsecret = dingtalk_appsecret or ''
app_key = serializer.validated_data['DINGTALK_APPKEY']
agent_id = serializer.validated_data['DINGTALK_AGENTID']
app_secret = serializer.validated_data.get('DINGTALK_APPSECRET') \
or settings.DINGTALK_APPSECRET \
or ''
try:
dingtalk = DingTalk(appid=dingtalk_appkey, appsecret=dingtalk_appsecret, agentid=dingtalk_agentid)
dingtalk = DingTalk(appid=app_key, appsecret=app_secret, agentid=agent_id)
dingtalk.send_text(['test'], 'test')
return Response(status=status.HTTP_200_OK, data={'msg': _('Test success')})
except APIException as e:
try:
if 'errmsg' in e.detail:
error = e.detail['errmsg']
except:
else:
error = e.detail
return Response(status=status.HTTP_400_BAD_REQUEST, data={'error': error})

View File

@ -9,6 +9,7 @@ from django.utils.translation import ugettext_lazy as _
from common.permissions import IsSuperUser
from common.utils import get_logger
from .. import serializers
from django.conf import settings
logger = get_logger(__file__)
@ -24,14 +25,15 @@ class MailTestingAPI(APIView):
serializer = self.serializer_class(data=request.data)
serializer.is_valid(raise_exception=True)
email_host = serializer.validated_data['EMAIL_HOST']
email_port = serializer.validated_data['EMAIL_PORT']
email_host_user = serializer.validated_data["EMAIL_HOST_USER"]
email_host_password = serializer.validated_data['EMAIL_HOST_PASSWORD']
email_from = serializer.validated_data["EMAIL_FROM"]
email_recipient = serializer.validated_data["EMAIL_RECIPIENT"]
email_use_ssl = serializer.validated_data['EMAIL_USE_SSL']
email_use_tls = serializer.validated_data['EMAIL_USE_TLS']
# 测试邮件时,邮件服务器信息从配置中获取
email_host = settings.EMAIL_HOST
email_port = settings.EMAIL_PORT
email_host_user = settings.EMAIL_HOST_USER
email_host_password = settings.EMAIL_HOST_PASSWORD
email_from = serializer.validated_data.get('EMAIL_FROM')
email_use_ssl = settings.EMAIL_USE_SSL
email_use_tls = settings.EMAIL_USE_TLS
email_recipient = serializer.validated_data.get('EMAIL_RECIPIENT')
# 设置 settings 的值,会导致动态配置在当前进程失效
# for k, v in serializer.validated_data.items():

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