mirror of
https://github.com/jumpserver/jumpserver.git
synced 2025-12-16 09:02:49 +00:00
Compare commits
182 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8af86b163 | ||
|
|
5000a1f586 | ||
|
|
a72bb192c3 | ||
|
|
225b315b7a | ||
|
|
084050a4cc | ||
|
|
2bdaf73d49 | ||
|
|
3052c4cc19 | ||
|
|
bd92045b50 | ||
|
|
23d867dce6 | ||
|
|
2c70e117c6 | ||
|
|
70e8a03888 | ||
|
|
d2df8acd84 | ||
|
|
6e5dcc738e | ||
|
|
d1ccb4af15 | ||
|
|
086ecfc046 | ||
|
|
8f3765a98d | ||
|
|
2d6610b133 | ||
|
|
5a82174c54 | ||
|
|
dac45f234a | ||
|
|
096b2cf8b8 | ||
|
|
0ed0f69be0 | ||
|
|
8af88cd2c6 | ||
|
|
2a9f0f8dcf | ||
|
|
cc2d47e6dc | ||
|
|
22c9dfc0f2 | ||
|
|
ed01f2f1fb | ||
|
|
c5ff0d972b | ||
|
|
7d9da9ff66 | ||
|
|
d6395b64fa | ||
|
|
9bfbdea508 | ||
|
|
cb1c906db4 | ||
|
|
12a0096963 | ||
|
|
a315e8888b | ||
|
|
454d3cba96 | ||
|
|
4a786baf4e | ||
|
|
187977c04a | ||
|
|
4260fe1424 | ||
|
|
7286b1b09e | ||
|
|
9a7919f3ac | ||
|
|
43cbf4f6a9 | ||
|
|
e9dc1ad86a | ||
|
|
90477146ed | ||
|
|
353b66bf8f | ||
|
|
3051996e35 | ||
|
|
8761ed741c | ||
|
|
0facd8a25e | ||
|
|
b28ce9de7a | ||
|
|
f2b72aae37 | ||
|
|
b001443f34 | ||
|
|
17303c0550 | ||
|
|
bac974b4f2 | ||
|
|
f9e970f4ed | ||
|
|
07c60ca75d | ||
|
|
bbe2678df3 | ||
|
|
d57f52ee24 | ||
|
|
55775f0deb | ||
|
|
fde118021e | ||
|
|
d484885762 | ||
|
|
8071f45f92 | ||
|
|
15cdd44c6c | ||
|
|
d44f90ea3d | ||
|
|
42e9fbf37a | ||
|
|
d44656aa10 | ||
|
|
09d4228182 | ||
|
|
3ae8da231a | ||
|
|
2e4b6d150a | ||
|
|
8542d53aff | ||
|
|
141dafc8bf | ||
|
|
28a6024b49 | ||
|
|
6cf2bc4baf | ||
|
|
631f802961 | ||
|
|
0eedda748a | ||
|
|
7183f0d274 | ||
|
|
c93ab15351 | ||
|
|
3fffd667dc | ||
|
|
11fd2afa3a | ||
|
|
1eb59b11da | ||
|
|
a1e2d4ca57 | ||
|
|
8a413563be | ||
|
|
203a01240b | ||
|
|
37fef6153a | ||
|
|
ba6c49e62b | ||
|
|
da79f8beab | ||
|
|
3f72c02049 | ||
|
|
380226a7d2 | ||
|
|
f88e5de3c1 | ||
|
|
7c149fe91b | ||
|
|
f673fed706 | ||
|
|
84326cc999 | ||
|
|
7d56678a8e | ||
|
|
25d1b71448 | ||
|
|
d3920a0cc9 | ||
|
|
52889cb67a | ||
|
|
0b67c7a953 | ||
|
|
1f50a2fe33 | ||
|
|
cd5094f10d | ||
|
|
c244cf5f43 | ||
|
|
a0db2f6ef8 | ||
|
|
ea485c3070 | ||
|
|
705f352cb9 | ||
|
|
dc13134b7b | ||
|
|
06de6c3575 | ||
|
|
c341d01e5a | ||
|
|
d1a3d31d3f | ||
|
|
10f4ff4eec | ||
|
|
25ea3ba01d | ||
|
|
072865f3e5 | ||
|
|
d5c9ec1c3d | ||
|
|
487c945d1d | ||
|
|
a78b2f4b62 | ||
|
|
a1f1dce56b | ||
|
|
a1221a39fd | ||
|
|
8c118b6f47 | ||
|
|
68fd8012d8 | ||
|
|
526928518d | ||
|
|
c5f6c564a7 | ||
|
|
076adec218 | ||
|
|
00d434ceea | ||
|
|
9acfd461b4 | ||
|
|
9424929dde | ||
|
|
b95d3ac9be | ||
|
|
af2a9bb1e6 | ||
|
|
63638ed1ce | ||
|
|
fa68389028 | ||
|
|
63b338085a | ||
|
|
5d8818e69e | ||
|
|
77521119b9 | ||
|
|
baee71e4b8 | ||
|
|
6459c20516 | ||
|
|
0207fe60c5 | ||
|
|
b8c43f5944 | ||
|
|
63ca2ab182 | ||
|
|
f9ea119928 | ||
|
|
d4bdc74bd8 | ||
|
|
42e7ca6a18 | ||
|
|
6e0341b7b1 | ||
|
|
2f25e2b24c | ||
|
|
87dcd2dcb7 | ||
|
|
e96aac058f | ||
|
|
d76bad125b | ||
|
|
913b1a1426 | ||
|
|
0213154a19 | ||
|
|
8f63b488b2 | ||
|
|
f8921004c2 | ||
|
|
5588eab57e | ||
|
|
e0a8f91741 | ||
|
|
467ebfa650 | ||
|
|
0533b77b1b | ||
|
|
a558ee2ac0 | ||
|
|
46a39701d4 | ||
|
|
8c3ab31e4e | ||
|
|
476e6cdc2f | ||
|
|
0b593f4555 | ||
|
|
456116938d | ||
|
|
76b24f62d4 | ||
|
|
e1eef0a3f3 | ||
|
|
2c74727b65 | ||
|
|
08cd91c426 | ||
|
|
90d269d2a2 | ||
|
|
a9ddbcc0cd | ||
|
|
aec31128cf | ||
|
|
b415ee051d | ||
|
|
082a5ae84c | ||
|
|
6b7554d69a | ||
|
|
5923562440 | ||
|
|
d144e7e572 | ||
|
|
cafbd08986 | ||
|
|
b436fc9b44 | ||
|
|
26fc56b4be | ||
|
|
3b1d199669 | ||
|
|
e7edbc9d84 | ||
|
|
41f81bc0bf | ||
|
|
75d2c81d33 | ||
|
|
da2dea5003 | ||
|
|
fc60156c23 | ||
|
|
76fb547551 | ||
|
|
e686f51703 | ||
|
|
7578bda588 | ||
|
|
f2d743ec2b | ||
|
|
7099aef360 | ||
|
|
246f5d8a11 | ||
|
|
6c98bd3b48 |
@@ -6,4 +6,5 @@ tmp/*
|
|||||||
django.db
|
django.db
|
||||||
celerybeat.pid
|
celerybeat.pid
|
||||||
### Vagrant ###
|
### Vagrant ###
|
||||||
.vagrant/
|
.vagrant/
|
||||||
|
apps/xpack/.git
|
||||||
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
*.mmdb filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.mo filter=lfs diff=lfs merge=lfs -text
|
||||||
23
Dockerfile
23
Dockerfile
@@ -8,7 +8,6 @@ WORKDIR /opt/jumpserver
|
|||||||
ADD . .
|
ADD . .
|
||||||
RUN cd utils && bash -ixeu build.sh
|
RUN cd utils && bash -ixeu build.sh
|
||||||
|
|
||||||
|
|
||||||
# 构建运行时环境
|
# 构建运行时环境
|
||||||
FROM python:3.8.6-slim
|
FROM python:3.8.6-slim
|
||||||
ARG PIP_MIRROR=https://pypi.douban.com/simple
|
ARG PIP_MIRROR=https://pypi.douban.com/simple
|
||||||
@@ -18,12 +17,12 @@ ENV PIP_JMS_MIRROR=$PIP_JMS_MIRROR
|
|||||||
|
|
||||||
WORKDIR /opt/jumpserver
|
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 \
|
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 \
|
&& sed -i 's/security.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list \
|
||||||
&& apt update \
|
&& apt update \
|
||||||
&& apt -y install telnet iproute2 redis-tools \
|
&& apt -y install telnet iproute2 redis-tools default-mysql-client vim wget curl locales procps \
|
||||||
&& grep -v '^#' ./requirements/deb_buster_requirements.txt | xargs apt -y install \
|
&& apt -y install $(cat requirements/deb_requirements.txt) \
|
||||||
&& rm -rf /var/lib/apt/lists/* \
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
&& localedef -c -f UTF-8 -i zh_CN zh_CN.UTF-8 \
|
&& localedef -c -f UTF-8 -i zh_CN zh_CN.UTF-8 \
|
||||||
&& cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
|
&& cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
|
||||||
@@ -32,21 +31,23 @@ RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list \
|
|||||||
|
|
||||||
COPY ./requirements/requirements.txt ./requirements/requirements.txt
|
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} \
|
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 $(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
|
COPY --from=stage-build /opt/jumpserver/release/jumpserver /opt/jumpserver
|
||||||
RUN mkdir -p /root/.ssh/ \
|
RUN mkdir -p /root/.ssh/ \
|
||||||
&& echo "Host *\n\tStrictHostKeyChecking no\n\tUserKnownHostsFile /dev/null" > /root/.ssh/config
|
&& echo "Host *\n\tStrictHostKeyChecking no\n\tUserKnownHostsFile /dev/null" > /root/.ssh/config
|
||||||
|
|
||||||
RUN mkdir -p /opt/jumpserver/oracle/
|
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/
|
&& wget https://download.jumpserver.org/public/instantclient-basiclite-linux.x64-21.1.0.0.0.tar > /dev/null \
|
||||||
RUN tar xvf /opt/jumpserver/oracle/instantclient-basiclite-linux.x64-21.1.0.0.0.tar -C /opt/jumpserver/oracle/
|
&& tar xf 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"
|
&& echo "/opt/jumpserver/oracle/instantclient_21_1" > /etc/ld.so.conf.d/oracle-instantclient.conf \
|
||||||
RUN ldconfig
|
&& ldconfig \
|
||||||
|
&& rm -f instantclient-basiclite-linux.x64-21.1.0.0.0.tar
|
||||||
|
|
||||||
RUN echo > config.yml
|
RUN echo > config.yml
|
||||||
|
|
||||||
VOLUME /opt/jumpserver/data
|
VOLUME /opt/jumpserver/data
|
||||||
VOLUME /opt/jumpserver/logs
|
VOLUME /opt/jumpserver/logs
|
||||||
|
|
||||||
|
|||||||
@@ -2,15 +2,16 @@ from common.permissions import IsOrgAdmin, HasQueryParamsUserAndIsCurrentOrgMemb
|
|||||||
from common.drf.api import JMSBulkModelViewSet
|
from common.drf.api import JMSBulkModelViewSet
|
||||||
from ..models import LoginACL
|
from ..models import LoginACL
|
||||||
from .. import serializers
|
from .. import serializers
|
||||||
|
from ..filters import LoginAclFilter
|
||||||
|
|
||||||
__all__ = ['LoginACLViewSet', ]
|
__all__ = ['LoginACLViewSet', ]
|
||||||
|
|
||||||
|
|
||||||
class LoginACLViewSet(JMSBulkModelViewSet):
|
class LoginACLViewSet(JMSBulkModelViewSet):
|
||||||
queryset = LoginACL.objects.all()
|
queryset = LoginACL.objects.all()
|
||||||
filterset_fields = ('name', 'user', )
|
filterset_class = LoginAclFilter
|
||||||
search_fields = filterset_fields
|
search_fields = ('name',)
|
||||||
permission_classes = (IsOrgAdmin, )
|
permission_classes = (IsOrgAdmin,)
|
||||||
serializer_class = serializers.LoginACLSerializer
|
serializer_class = serializers.LoginACLSerializer
|
||||||
|
|
||||||
def get_permissions(self):
|
def get_permissions(self):
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
from orgs.mixins.api import OrgBulkModelViewSet
|
from orgs.mixins.api import OrgBulkModelViewSet
|
||||||
from common.permissions import IsOrgAdmin
|
from common.permissions import IsOrgAdmin
|
||||||
from .. import models, serializers
|
from .. import models, serializers
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
from django.shortcuts import get_object_or_404
|
|
||||||
from rest_framework.response import Response
|
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.permissions import IsAppUser
|
||||||
from common.utils import reverse, lazyproperty
|
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 tickets.api import GenericTicketStatusRetrieveCloseAPI
|
||||||
from ..models import LoginAssetACL
|
from ..models import LoginAssetACL
|
||||||
from .. import serializers
|
from .. import serializers
|
||||||
|
|||||||
15
apps/acls/filters.py
Normal file
15
apps/acls/filters.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
from django_filters import rest_framework as filters
|
||||||
|
from common.drf.filters import BaseFilterSet
|
||||||
|
|
||||||
|
from acls.models import LoginACL
|
||||||
|
|
||||||
|
|
||||||
|
class LoginAclFilter(BaseFilterSet):
|
||||||
|
user = filters.UUIDFilter(field_name='user_id')
|
||||||
|
user_display = filters.CharFilter(field_name='user__name')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = LoginACL
|
||||||
|
fields = (
|
||||||
|
'name', 'user', 'user_display', 'action'
|
||||||
|
)
|
||||||
98
apps/acls/migrations/0002_auto_20210926_1047.py
Normal file
98
apps/acls/migrations/0002_auto_20210926_1047.py
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
# Generated by Django 3.1.12 on 2021-09-26 02:47
|
||||||
|
import django
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models, transaction
|
||||||
|
from acls.models import LoginACL
|
||||||
|
|
||||||
|
LOGIN_CONFIRM_ZH = '登录复核'
|
||||||
|
LOGIN_CONFIRM_EN = 'Login confirm'
|
||||||
|
|
||||||
|
DEFAULT_TIME_PERIODS = [{'id': i, 'value': '00:00~00:00'} for i in range(7)]
|
||||||
|
|
||||||
|
|
||||||
|
def has_zh(name: str) -> bool:
|
||||||
|
for i in name:
|
||||||
|
if u'\u4e00' <= i <= u'\u9fff':
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_login_confirm(apps, schema_editor):
|
||||||
|
login_acl_model = apps.get_model("acls", "LoginACL")
|
||||||
|
login_confirm_model = apps.get_model("authentication", "LoginConfirmSetting")
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
for instance in login_confirm_model.objects.filter(is_active=True):
|
||||||
|
user = instance.user
|
||||||
|
reviewers = instance.reviewers.all()
|
||||||
|
login_confirm = LOGIN_CONFIRM_ZH if has_zh(user.name) else LOGIN_CONFIRM_EN
|
||||||
|
date_created = instance.date_created.strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
if reviewers.count() == 0:
|
||||||
|
continue
|
||||||
|
data = {
|
||||||
|
'user': user,
|
||||||
|
'name': f'{user.name}-{login_confirm} ({date_created})',
|
||||||
|
'created_by': instance.created_by,
|
||||||
|
'action': LoginACL.ActionChoices.confirm,
|
||||||
|
'rules': {'ip_group': ['*'], 'time_period': DEFAULT_TIME_PERIODS}
|
||||||
|
}
|
||||||
|
instance = login_acl_model.objects.create(**data)
|
||||||
|
instance.reviewers.set(reviewers)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_ip_group(apps, schema_editor):
|
||||||
|
login_acl_model = apps.get_model("acls", "LoginACL")
|
||||||
|
updates = list()
|
||||||
|
with transaction.atomic():
|
||||||
|
for instance in login_acl_model.objects.exclude(action=LoginACL.ActionChoices.confirm):
|
||||||
|
instance.rules = {'ip_group': instance.ip_group, 'time_period': DEFAULT_TIME_PERIODS}
|
||||||
|
updates.append(instance)
|
||||||
|
login_acl_model.objects.bulk_update(updates, ['rules', ])
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
('acls', '0001_initial'),
|
||||||
|
('authentication', '0004_ssotoken'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='loginacl',
|
||||||
|
name='action',
|
||||||
|
field=models.CharField(choices=[('reject', 'Reject'), ('allow', 'Allow'), ('confirm', 'Login confirm')],
|
||||||
|
default='reject', max_length=64, verbose_name='Action'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='loginacl',
|
||||||
|
name='reviewers',
|
||||||
|
field=models.ManyToManyField(blank=True, related_name='login_confirm_acls',
|
||||||
|
to=settings.AUTH_USER_MODEL, verbose_name='Reviewers'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='loginacl',
|
||||||
|
name='user',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name='login_acls', to=settings.AUTH_USER_MODEL, verbose_name='User'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='loginacl',
|
||||||
|
name='rules',
|
||||||
|
field=models.JSONField(default=dict, verbose_name='Rule'),
|
||||||
|
),
|
||||||
|
migrations.RunPython(migrate_login_confirm),
|
||||||
|
migrations.RunPython(migrate_ip_group),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='loginacl',
|
||||||
|
name='ip_group',
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='loginacl',
|
||||||
|
options={'ordering': ('priority', '-date_updated', 'name'), 'verbose_name': 'Login acl'},
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='loginassetacl',
|
||||||
|
options={'ordering': ('priority', '-date_updated', 'name'), 'verbose_name': 'Login asset acl'},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
from .base import BaseACL, BaseACLQuerySet
|
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.ip import contains_ip
|
||||||
|
from common.utils.time_period import contains_time_period
|
||||||
|
from common.utils.timezone import local_now_display
|
||||||
|
|
||||||
|
|
||||||
class ACLManager(models.Manager):
|
class ACLManager(models.Manager):
|
||||||
@@ -15,23 +17,29 @@ class LoginACL(BaseACL):
|
|||||||
class ActionChoices(models.TextChoices):
|
class ActionChoices(models.TextChoices):
|
||||||
reject = 'reject', _('Reject')
|
reject = 'reject', _('Reject')
|
||||||
allow = 'allow', _('Allow')
|
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(
|
action = models.CharField(
|
||||||
max_length=64, choices=ActionChoices.choices, default=ActionChoices.reject,
|
max_length=64, verbose_name=_('Action'),
|
||||||
verbose_name=_('Action')
|
choices=ActionChoices.choices, default=ActionChoices.reject
|
||||||
)
|
)
|
||||||
# 关联
|
reviewers = models.ManyToManyField(
|
||||||
user = models.ForeignKey(
|
'users.User', verbose_name=_("Reviewers"),
|
||||||
'users.User', on_delete=models.CASCADE, related_name='login_acls', verbose_name=_('User')
|
related_name="login_confirm_acls", blank=True
|
||||||
)
|
)
|
||||||
|
|
||||||
objects = ACLManager.from_queryset(BaseACLQuerySet)()
|
objects = ACLManager.from_queryset(BaseACLQuerySet)()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ('priority', '-date_updated', 'name')
|
ordering = ('priority', '-date_updated', 'name')
|
||||||
|
verbose_name = _('Login acl')
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
@@ -44,14 +52,77 @@ class LoginACL(BaseACL):
|
|||||||
def action_allow(self):
|
def action_allow(self):
|
||||||
return self.action == self.ActionChoices.allow
|
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
|
@staticmethod
|
||||||
def allow_user_to_login(user, ip):
|
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:
|
if not acl:
|
||||||
return True
|
return True, ''
|
||||||
is_contained = contains_ip(ip, acl.ip_group)
|
ip_group = acl.rules.get('ip_group')
|
||||||
if acl.action_allow and is_contained:
|
time_periods = acl.rules.get('time_period')
|
||||||
return True
|
is_contain_ip = contains_ip(ip, ip_group)
|
||||||
if acl.action_reject and not is_contained:
|
is_contain_time_period = contains_time_period(time_periods)
|
||||||
return True
|
|
||||||
return False
|
reject_type = ''
|
||||||
|
if is_contain_ip and is_contain_time_period:
|
||||||
|
# 满足条件
|
||||||
|
allow = acl.action_allow
|
||||||
|
if not allow:
|
||||||
|
reject_type = 'ip' if is_contain_ip else 'time'
|
||||||
|
else:
|
||||||
|
# 不满足条件
|
||||||
|
# 如果acl本身允许,那就拒绝;如果本身拒绝,那就允许
|
||||||
|
allow = not acl.action_allow
|
||||||
|
if not allow:
|
||||||
|
reject_type = 'ip' if not is_contain_ip else 'time'
|
||||||
|
|
||||||
|
return allow, reject_type
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def construct_confirm_ticket_meta(request=None):
|
||||||
|
login_ip = get_request_ip(request) if request else ''
|
||||||
|
login_ip = login_ip or '0.0.0.0'
|
||||||
|
login_city = get_ip_city(login_ip)
|
||||||
|
login_datetime = local_now_display()
|
||||||
|
ticket_meta = {
|
||||||
|
'apply_login_ip': login_ip,
|
||||||
|
'apply_login_city': login_city,
|
||||||
|
'apply_login_datetime': login_datetime,
|
||||||
|
}
|
||||||
|
return ticket_meta
|
||||||
|
|
||||||
|
def create_confirm_ticket(self, request=None):
|
||||||
|
from tickets import const
|
||||||
|
from tickets.models import Ticket
|
||||||
|
from orgs.models import Organization
|
||||||
|
ticket_title = _('Login confirm') + ' {}'.format(self.user)
|
||||||
|
ticket_meta = self.construct_confirm_ticket_meta(request)
|
||||||
|
data = {
|
||||||
|
'title': ticket_title,
|
||||||
|
'type': const.TicketType.login_confirm.value,
|
||||||
|
'meta': ticket_meta,
|
||||||
|
'org_id': Organization.ROOT_ID,
|
||||||
|
}
|
||||||
|
ticket = Ticket.objects.create(**data)
|
||||||
|
ticket.create_process_map_and_node(self.reviewers.all())
|
||||||
|
ticket.open(self.user)
|
||||||
|
return ticket
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ class LoginAssetACL(BaseACL, OrgModelMixin):
|
|||||||
class Meta:
|
class Meta:
|
||||||
unique_together = ('name', 'org_id')
|
unique_together = ('name', 'org_id')
|
||||||
ordering = ('priority', '-date_updated', 'name')
|
ordering = ('priority', '-date_updated', 'name')
|
||||||
|
verbose_name = _('Login asset acl')
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|||||||
@@ -1,59 +1,56 @@
|
|||||||
from django.utils.translation import ugettext as _
|
from django.utils.translation import ugettext as _
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from common.drf.serializers import BulkModelSerializer
|
from common.drf.serializers import BulkModelSerializer
|
||||||
from orgs.utils import current_org
|
from common.drf.serializers import MethodSerializer
|
||||||
|
from jumpserver.utils import has_valid_xpack_license
|
||||||
from ..models import LoginACL
|
from ..models import LoginACL
|
||||||
from common.utils.ip import is_ip_address, is_ip_network, is_ip_segment
|
from .rules import RuleSerializer
|
||||||
|
|
||||||
|
|
||||||
__all__ = ['LoginACLSerializer', ]
|
__all__ = ['LoginACLSerializer', ]
|
||||||
|
|
||||||
|
common_help_text = _('Format for comma-delimited string, with * indicating a match all. ')
|
||||||
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 LoginACLSerializer(BulkModelSerializer):
|
class LoginACLSerializer(BulkModelSerializer):
|
||||||
ip_group_help_text = _(
|
user_display = serializers.ReadOnlyField(source='user.username', label=_('Username'))
|
||||||
'Format for comma-delimited string, with * indicating a match all. '
|
reviewers_display = serializers.SerializerMethodField(label=_('Reviewers'))
|
||||||
'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'))
|
|
||||||
action_display = serializers.ReadOnlyField(source='get_action_display', label=_('Action'))
|
action_display = serializers.ReadOnlyField(source='get_action_display', label=_('Action'))
|
||||||
|
reviewers_amount = serializers.IntegerField(read_only=True, source='reviewers.count')
|
||||||
|
rules = MethodSerializer()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = LoginACL
|
model = LoginACL
|
||||||
fields_mini = ['id', 'name']
|
fields_mini = ['id', 'name']
|
||||||
fields_small = fields_mini + [
|
fields_small = fields_mini + [
|
||||||
'priority', 'ip_group', 'action', 'action_display',
|
'priority', 'rules', 'action', 'action_display',
|
||||||
'is_active',
|
'is_active', 'user', 'user_display',
|
||||||
'date_created', 'date_updated',
|
'date_created', 'date_updated', 'reviewers_amount',
|
||||||
'comment', 'created_by',
|
'comment', 'created_by'
|
||||||
]
|
]
|
||||||
fields_fk = ['user', 'user_display',]
|
fields_fk = ['user', 'user_display']
|
||||||
fields = fields_small + fields_fk
|
fields_m2m = ['reviewers', 'reviewers_display']
|
||||||
|
fields = fields_small + fields_fk + fields_m2m
|
||||||
extra_kwargs = {
|
extra_kwargs = {
|
||||||
'priority': {'default': 50},
|
'priority': {'default': 50},
|
||||||
'is_active': {'default': True},
|
'is_active': {'default': True},
|
||||||
|
"reviewers": {'allow_null': False, 'required': True},
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
def __init__(self, *args, **kwargs):
|
||||||
def validate_user(user):
|
super().__init__(*args, **kwargs)
|
||||||
if user not in current_org.get_members():
|
self.set_action_choices()
|
||||||
error = _('The user `{}` is not in the current organization: `{}`').format(
|
|
||||||
user, current_org
|
def set_action_choices(self):
|
||||||
)
|
action = self.fields.get('action')
|
||||||
raise serializers.ValidationError(error)
|
if not action:
|
||||||
return user
|
return
|
||||||
|
choices = action._choices
|
||||||
|
if not has_valid_xpack_license():
|
||||||
|
choices.pop(LoginACL.ActionChoices.confirm, None)
|
||||||
|
action._choices = choices
|
||||||
|
|
||||||
|
def get_rules_serializer(self):
|
||||||
|
return RuleSerializer()
|
||||||
|
|
||||||
|
def get_reviewers_display(self, obj):
|
||||||
|
return ','.join([str(user) for user in obj.reviewers.all()])
|
||||||
|
|||||||
1
apps/acls/serializers/rules/__init__.py
Normal file
1
apps/acls/serializers/rules/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from .rules import *
|
||||||
35
apps/acls/serializers/rules/rules.py
Normal file
35
apps/acls/serializers/rules/rules.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# 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', 'ip_group_child_validator', 'ip_group_help_text']
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
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 '
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RuleSerializer(serializers.Serializer):
|
||||||
|
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'))
|
||||||
@@ -6,7 +6,7 @@ from rest_framework.decorators import action
|
|||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
from common.tree import TreeNodeSerializer
|
from common.tree import TreeNodeSerializer
|
||||||
from common.mixins.views import SuggestionMixin
|
from common.mixins.api import SuggestionMixin
|
||||||
from ..hands import IsOrgAdminOrAppUser
|
from ..hands import IsOrgAdminOrAppUser
|
||||||
from .. import serializers
|
from .. import serializers
|
||||||
from ..models import Application
|
from ..models import Application
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ class SerializeApplicationToTreeNodeMixin:
|
|||||||
return node
|
return node
|
||||||
|
|
||||||
def serialize_applications_with_org(self, applications):
|
def serialize_applications_with_org(self, applications):
|
||||||
|
if not applications:
|
||||||
|
return []
|
||||||
root_node = self.create_root_node()
|
root_node = self.create_root_node()
|
||||||
tree_nodes = [root_node]
|
tree_nodes = [root_node]
|
||||||
organizations = self.filter_organizations(applications)
|
organizations = self.filter_organizations(applications)
|
||||||
|
|||||||
23
apps/applications/migrations/0012_auto_20211014_2209.py
Normal file
23
apps/applications/migrations/0012_auto_20211014_2209.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 3.1.13 on 2021-10-14 14:09
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('applications', '0011_auto_20210826_1759'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='account',
|
||||||
|
name='username',
|
||||||
|
field=models.CharField(blank=True, db_index=True, max_length=128, verbose_name='Username'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='historicalaccount',
|
||||||
|
name='username',
|
||||||
|
field=models.CharField(blank=True, db_index=True, max_length=128, verbose_name='Username'),
|
||||||
|
),
|
||||||
|
]
|
||||||
17
apps/applications/migrations/0013_auto_20211026_1711.py
Normal file
17
apps/applications/migrations/0013_auto_20211026_1711.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 3.1.13 on 2021-10-26 09:11
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('applications', '0012_auto_20211014_2209'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='application',
|
||||||
|
options={'ordering': ('name',), 'verbose_name': 'Application'},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -180,6 +180,7 @@ class Application(CommonModelMixin, OrgModelMixin, ApplicationTreeNodeMixin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
verbose_name = _('Application')
|
||||||
unique_together = [('org_id', 'name')]
|
unique_together = [('org_id', 'name')]
|
||||||
ordering = ('name',)
|
ordering = ('name',)
|
||||||
|
|
||||||
|
|||||||
@@ -104,7 +104,8 @@ class AppAccountSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
|
|||||||
extra_kwargs = {
|
extra_kwargs = {
|
||||||
'username': {'default': '', 'required': False},
|
'username': {'default': '', 'required': False},
|
||||||
'password': {'write_only': True},
|
'password': {'write_only': True},
|
||||||
'app_display': {'label': _('Application display')}
|
'app_display': {'label': _('Application display')},
|
||||||
|
'systemuser_display': {'label': _('System User')}
|
||||||
}
|
}
|
||||||
use_model_bulk_create = True
|
use_model_bulk_create = True
|
||||||
model_bulk_create_kwargs = {
|
model_bulk_create_kwargs = {
|
||||||
@@ -134,4 +135,6 @@ class AppAccountSecretSerializer(AppAccountSerializer):
|
|||||||
'password': {'write_only': False},
|
'password': {'write_only': False},
|
||||||
'private_key': {'write_only': False},
|
'private_key': {'write_only': False},
|
||||||
'public_key': {'write_only': False},
|
'public_key': {'write_only': False},
|
||||||
|
'app_display': {'label': _('Application display')},
|
||||||
|
'systemuser_display': {'label': _('System User')}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ class AdminUserViewSet(OrgBulkModelViewSet):
|
|||||||
search_fields = filterset_fields
|
search_fields = filterset_fields
|
||||||
serializer_class = serializers.AdminUserSerializer
|
serializer_class = serializers.AdminUserSerializer
|
||||||
permission_classes = (IsOrgAdmin,)
|
permission_classes = (IsOrgAdmin,)
|
||||||
|
ordering_fields = ('name',)
|
||||||
|
ordering = ('name', )
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
queryset = super().get_queryset().filter(type=SystemUser.Type.admin)
|
queryset = super().get_queryset().filter(type=SystemUser.Type.admin)
|
||||||
|
|||||||
@@ -2,12 +2,19 @@
|
|||||||
#
|
#
|
||||||
from assets.api import FilterAssetByNodeMixin
|
from assets.api import FilterAssetByNodeMixin
|
||||||
from rest_framework.viewsets import ModelViewSet
|
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.shortcuts import get_object_or_404
|
||||||
|
from django.db.models import Q
|
||||||
|
|
||||||
from common.utils import get_logger, get_object_or_none
|
from common.utils import get_logger, get_object_or_none
|
||||||
from common.permissions import IsOrgAdmin, IsOrgAdminOrAppUser, IsSuperUser
|
from common.permissions import IsOrgAdmin, IsOrgAdminOrAppUser, IsSuperUser
|
||||||
from common.mixins.views import SuggestionMixin
|
from common.mixins.api import SuggestionMixin
|
||||||
|
from users.models import User, UserGroup
|
||||||
|
from users.serializers import UserSerializer, UserGroupSerializer
|
||||||
|
from users.filters import UserFilter
|
||||||
|
from perms.models import AssetPermission
|
||||||
|
from perms.serializers import AssetPermissionSerializer
|
||||||
|
from perms.filters import AssetPermissionFilter
|
||||||
from orgs.mixins.api import OrgBulkModelViewSet
|
from orgs.mixins.api import OrgBulkModelViewSet
|
||||||
from orgs.mixins import generics
|
from orgs.mixins import generics
|
||||||
from ..models import Asset, Node, Platform
|
from ..models import Asset, Node, Platform
|
||||||
@@ -23,6 +30,8 @@ __all__ = [
|
|||||||
'AssetViewSet', 'AssetPlatformRetrieveApi',
|
'AssetViewSet', 'AssetPlatformRetrieveApi',
|
||||||
'AssetGatewayListApi', 'AssetPlatformViewSet',
|
'AssetGatewayListApi', 'AssetPlatformViewSet',
|
||||||
'AssetTaskCreateApi', 'AssetsTaskCreateApi',
|
'AssetTaskCreateApi', 'AssetsTaskCreateApi',
|
||||||
|
'AssetPermUserListApi', 'AssetPermUserPermissionsListApi',
|
||||||
|
'AssetPermUserGroupListApi', 'AssetPermUserGroupPermissionsListApi',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -41,6 +50,7 @@ class AssetViewSet(SuggestionMixin, FilterAssetByNodeMixin, OrgBulkModelViewSet)
|
|||||||
}
|
}
|
||||||
search_fields = ("hostname", "ip")
|
search_fields = ("hostname", "ip")
|
||||||
ordering_fields = ("hostname", "ip", "port", "cpu_cores")
|
ordering_fields = ("hostname", "ip", "port", "cpu_cores")
|
||||||
|
ordering = ('hostname', )
|
||||||
serializer_classes = {
|
serializer_classes = {
|
||||||
'default': serializers.AssetSerializer,
|
'default': serializers.AssetSerializer,
|
||||||
'suggestion': serializers.MiniAssetSerializer
|
'suggestion': serializers.MiniAssetSerializer
|
||||||
@@ -170,3 +180,102 @@ class AssetGatewayListApi(generics.ListAPIView):
|
|||||||
return []
|
return []
|
||||||
queryset = asset.domain.gateways.filter(protocol='ssh')
|
queryset = asset.domain.gateways.filter(protocol='ssh')
|
||||||
return queryset
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ class DomainViewSet(OrgBulkModelViewSet):
|
|||||||
search_fields = filterset_fields
|
search_fields = filterset_fields
|
||||||
permission_classes = (IsOrgAdminOrAppUser,)
|
permission_classes = (IsOrgAdminOrAppUser,)
|
||||||
serializer_class = serializers.DomainSerializer
|
serializer_class = serializers.DomainSerializer
|
||||||
|
ordering_fields = ('name',)
|
||||||
|
ordering = ('name', )
|
||||||
|
|
||||||
def get_serializer_class(self):
|
def get_serializer_class(self):
|
||||||
if self.request.query_params.get('gateway'):
|
if self.request.query_params.get('gateway'):
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ from common.utils import get_logger
|
|||||||
from common.permissions import IsOrgAdmin, IsOrgAdminOrAppUser, IsValidUser
|
from common.permissions import IsOrgAdmin, IsOrgAdminOrAppUser, IsValidUser
|
||||||
from orgs.mixins.api import OrgBulkModelViewSet
|
from orgs.mixins.api import OrgBulkModelViewSet
|
||||||
from orgs.mixins import generics
|
from orgs.mixins import generics
|
||||||
from common.mixins.views import SuggestionMixin
|
from common.mixins.api import SuggestionMixin
|
||||||
from orgs.utils import tmp_to_root_org
|
from orgs.utils import tmp_to_root_org
|
||||||
|
from rest_framework.decorators import action
|
||||||
from ..models import SystemUser, Asset
|
from ..models import SystemUser, Asset
|
||||||
from .. import serializers
|
from .. import serializers
|
||||||
from ..serializers import SystemUserWithAuthInfoSerializer, SystemUserTempAuthSerializer
|
from ..serializers import SystemUserWithAuthInfoSerializer, SystemUserTempAuthSerializer
|
||||||
@@ -16,7 +17,6 @@ from ..tasks import (
|
|||||||
push_system_user_to_assets
|
push_system_user_to_assets
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
logger = get_logger(__file__)
|
logger = get_logger(__file__)
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'SystemUserViewSet', 'SystemUserAuthInfoApi', 'SystemUserAssetAuthInfoApi',
|
'SystemUserViewSet', 'SystemUserAuthInfoApi', 'SystemUserAssetAuthInfoApi',
|
||||||
@@ -42,8 +42,36 @@ class SystemUserViewSet(SuggestionMixin, OrgBulkModelViewSet):
|
|||||||
'default': serializers.SystemUserSerializer,
|
'default': serializers.SystemUserSerializer,
|
||||||
'suggestion': serializers.MiniSystemUserSerializer
|
'suggestion': serializers.MiniSystemUserSerializer
|
||||||
}
|
}
|
||||||
|
ordering_fields = ('name', 'protocol', 'login_mode')
|
||||||
|
ordering = ('name', )
|
||||||
permission_classes = (IsOrgAdminOrAppUser,)
|
permission_classes = (IsOrgAdminOrAppUser,)
|
||||||
|
|
||||||
|
@action(methods=['get'], detail=False, url_path='su-from')
|
||||||
|
def su_from(self, request, *args, **kwargs):
|
||||||
|
""" API 获取可选的 su_from 系统用户"""
|
||||||
|
queryset = self.filter_queryset(self.get_queryset())
|
||||||
|
queryset = queryset.filter(
|
||||||
|
protocol=SystemUser.Protocol.ssh, login_mode=SystemUser.LOGIN_AUTO
|
||||||
|
)
|
||||||
|
return self.get_paginate_response_if_need(queryset)
|
||||||
|
|
||||||
|
@action(methods=['get'], detail=True, url_path='su-to')
|
||||||
|
def su_to(self, request, *args, **kwargs):
|
||||||
|
""" 获取系统用户的所有 su_to 系统用户 """
|
||||||
|
pk = kwargs.get('pk')
|
||||||
|
system_user = get_object_or_404(SystemUser, pk=pk)
|
||||||
|
queryset = system_user.su_to.all()
|
||||||
|
queryset = self.filter_queryset(queryset)
|
||||||
|
return self.get_paginate_response_if_need(queryset)
|
||||||
|
|
||||||
|
def get_paginate_response_if_need(self, queryset):
|
||||||
|
page = self.paginate_queryset(queryset)
|
||||||
|
if page is not None:
|
||||||
|
serializer = self.get_serializer(page, many=True)
|
||||||
|
return self.get_paginated_response(serializer.data)
|
||||||
|
serializer = self.get_serializer(queryset, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
|
||||||
class SystemUserAuthInfoApi(generics.RetrieveUpdateDestroyAPIView):
|
class SystemUserAuthInfoApi(generics.RetrieveUpdateDestroyAPIView):
|
||||||
"""
|
"""
|
||||||
@@ -74,7 +102,7 @@ class SystemUserTempAuthInfoApi(generics.CreateAPIView):
|
|||||||
|
|
||||||
with tmp_to_root_org():
|
with tmp_to_root_org():
|
||||||
instance = get_object_or_404(SystemUser, pk=pk)
|
instance = get_object_or_404(SystemUser, pk=pk)
|
||||||
instance.set_temp_auth(instance_id, user, data)
|
instance.set_temp_auth(instance_id, user.id, data)
|
||||||
return Response(serializer.data, status=201)
|
return Response(serializer.data, status=201)
|
||||||
|
|
||||||
|
|
||||||
@@ -91,7 +119,7 @@ class SystemUserAssetAuthInfoApi(generics.RetrieveAPIView):
|
|||||||
asset_id = self.kwargs.get('asset_id')
|
asset_id = self.kwargs.get('asset_id')
|
||||||
user_id = self.request.query_params.get("user_id")
|
user_id = self.request.query_params.get("user_id")
|
||||||
username = self.request.query_params.get("username")
|
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
|
return instance
|
||||||
|
|
||||||
|
|
||||||
@@ -107,8 +135,7 @@ class SystemUserAppAuthInfoApi(generics.RetrieveAPIView):
|
|||||||
instance = super().get_object()
|
instance = super().get_object()
|
||||||
app_id = self.kwargs.get('app_id')
|
app_id = self.kwargs.get('app_id')
|
||||||
user_id = self.request.query_params.get("user_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
|
return instance
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ def migrate_system_assets_to_authbook(apps, schema_editor):
|
|||||||
system_users = system_user_model.objects.all()
|
system_users = system_user_model.objects.all()
|
||||||
for s in system_users:
|
for s in system_users:
|
||||||
while True:
|
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:
|
if not systemuser_asset_relations:
|
||||||
break
|
break
|
||||||
authbooks = []
|
authbooks = []
|
||||||
|
|||||||
24
apps/assets/migrations/0077_auto_20211012_1642.py
Normal file
24
apps/assets/migrations/0077_auto_20211012_1642.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Generated by Django 3.1.12 on 2021-10-12 08:42
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_platform_win2016(apps, schema_editor):
|
||||||
|
platform_model = apps.get_model("assets", "Platform")
|
||||||
|
win2016 = platform_model.objects.filter(name='Windows2016').first()
|
||||||
|
if not win2016:
|
||||||
|
print("Error: Not found Windows2016 platform")
|
||||||
|
return
|
||||||
|
win2016.meta = {"security": "any"}
|
||||||
|
win2016.save()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('assets', '0076_delete_assetuser'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(migrate_platform_win2016)
|
||||||
|
]
|
||||||
38
apps/assets/migrations/0078_auto_20211014_2209.py
Normal file
38
apps/assets/migrations/0078_auto_20211014_2209.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Generated by Django 3.1.13 on 2021-10-14 14:09
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('assets', '0077_auto_20211012_1642'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='adminuser',
|
||||||
|
name='username',
|
||||||
|
field=models.CharField(blank=True, db_index=True, max_length=128, verbose_name='Username'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='authbook',
|
||||||
|
name='username',
|
||||||
|
field=models.CharField(blank=True, db_index=True, max_length=128, verbose_name='Username'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='gateway',
|
||||||
|
name='username',
|
||||||
|
field=models.CharField(blank=True, db_index=True, max_length=128, verbose_name='Username'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='historicalauthbook',
|
||||||
|
name='username',
|
||||||
|
field=models.CharField(blank=True, db_index=True, max_length=128, verbose_name='Username'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='systemuser',
|
||||||
|
name='username',
|
||||||
|
field=models.CharField(blank=True, db_index=True, max_length=128, verbose_name='Username'),
|
||||||
|
),
|
||||||
|
]
|
||||||
33
apps/assets/migrations/0079_auto_20211102_1922.py
Normal file
33
apps/assets/migrations/0079_auto_20211102_1922.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Generated by Django 3.1.12 on 2021-11-02 11:22
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def create_internal_platform(apps, schema_editor):
|
||||||
|
model = apps.get_model("assets", "Platform")
|
||||||
|
db_alias = schema_editor.connection.alias
|
||||||
|
type_platforms = (
|
||||||
|
('Windows-RDP', 'Windows', {'security': 'rdp'}),
|
||||||
|
('Windows-TLS', 'Windows', {'security': 'tls'}),
|
||||||
|
)
|
||||||
|
for name, base, meta in type_platforms:
|
||||||
|
defaults = {'name': name, 'base': base, 'meta': meta, 'internal': True}
|
||||||
|
model.objects.using(db_alias).update_or_create(
|
||||||
|
name=name, defaults=defaults
|
||||||
|
)
|
||||||
|
|
||||||
|
win2016 = model.objects.filter(name='Windows2016').first()
|
||||||
|
if win2016:
|
||||||
|
win2016.internal = False
|
||||||
|
win2016.save(update_fields=['internal'])
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('assets', '0078_auto_20211014_2209'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(create_internal_platform)
|
||||||
|
]
|
||||||
24
apps/assets/migrations/0080_auto_20211104_1347.py
Normal file
24
apps/assets/migrations/0080_auto_20211104_1347.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Generated by Django 3.1.13 on 2021-11-04 05:47
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('assets', '0079_auto_20211102_1922'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='systemuser',
|
||||||
|
name='su_enabled',
|
||||||
|
field=models.BooleanField(default=False, verbose_name='User switch'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='systemuser',
|
||||||
|
name='su_from',
|
||||||
|
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='su_to', to='assets.systemuser', verbose_name='Switch from'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -164,38 +164,7 @@ class Platform(models.Model):
|
|||||||
# ordering = ('name',)
|
# ordering = ('name',)
|
||||||
|
|
||||||
|
|
||||||
class Asset(AbsConnectivity, ProtocolsMixin, NodesRelationMixin, OrgModelMixin):
|
class AbsHardwareInfo(models.Model):
|
||||||
# Important
|
|
||||||
PLATFORM_CHOICES = (
|
|
||||||
('Linux', 'Linux'),
|
|
||||||
('Unix', 'Unix'),
|
|
||||||
('MacOS', 'MacOS'),
|
|
||||||
('BSD', 'BSD'),
|
|
||||||
('Windows', 'Windows'),
|
|
||||||
('Windows2016', 'Windows(2016)'),
|
|
||||||
('Other', 'Other'),
|
|
||||||
)
|
|
||||||
|
|
||||||
id = models.UUIDField(default=uuid.uuid4, primary_key=True)
|
|
||||||
ip = models.CharField(max_length=128, verbose_name=_('IP'), db_index=True)
|
|
||||||
hostname = models.CharField(max_length=128, verbose_name=_('Hostname'))
|
|
||||||
protocol = models.CharField(max_length=128, default=ProtocolsMixin.Protocol.ssh,
|
|
||||||
choices=ProtocolsMixin.Protocol.choices,
|
|
||||||
verbose_name=_('Protocol'))
|
|
||||||
port = models.IntegerField(default=22, verbose_name=_('Port'))
|
|
||||||
protocols = models.CharField(max_length=128, default='ssh/22', blank=True, verbose_name=_("Protocols"))
|
|
||||||
platform = models.ForeignKey(Platform, default=Platform.default, on_delete=models.PROTECT, verbose_name=_("Platform"), related_name='assets')
|
|
||||||
domain = models.ForeignKey("assets.Domain", null=True, blank=True, related_name='assets', verbose_name=_("Domain"), on_delete=models.SET_NULL)
|
|
||||||
nodes = models.ManyToManyField('assets.Node', default=default_node, related_name='assets', verbose_name=_("Nodes"))
|
|
||||||
is_active = models.BooleanField(default=True, verbose_name=_('Is active'))
|
|
||||||
|
|
||||||
# Auth
|
|
||||||
admin_user = models.ForeignKey('assets.SystemUser', on_delete=models.SET_NULL, null=True, verbose_name=_("Admin user"), related_name='admin_assets')
|
|
||||||
|
|
||||||
# Some information
|
|
||||||
public_ip = models.CharField(max_length=128, blank=True, null=True, verbose_name=_('Public IP'))
|
|
||||||
number = models.CharField(max_length=32, null=True, blank=True, verbose_name=_('Asset number'))
|
|
||||||
|
|
||||||
# Collect
|
# Collect
|
||||||
vendor = models.CharField(max_length=64, null=True, blank=True, verbose_name=_('Vendor'))
|
vendor = models.CharField(max_length=64, null=True, blank=True, verbose_name=_('Vendor'))
|
||||||
model = models.CharField(max_length=54, null=True, blank=True, verbose_name=_('Model'))
|
model = models.CharField(max_length=54, null=True, blank=True, verbose_name=_('Model'))
|
||||||
@@ -214,6 +183,49 @@ class Asset(AbsConnectivity, ProtocolsMixin, NodesRelationMixin, OrgModelMixin):
|
|||||||
os_arch = models.CharField(max_length=16, blank=True, null=True, verbose_name=_('OS arch'))
|
os_arch = models.CharField(max_length=16, blank=True, null=True, verbose_name=_('OS arch'))
|
||||||
hostname_raw = models.CharField(max_length=128, blank=True, null=True, verbose_name=_('Hostname raw'))
|
hostname_raw = models.CharField(max_length=128, blank=True, null=True, verbose_name=_('Hostname raw'))
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
abstract = True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cpu_info(self):
|
||||||
|
info = ""
|
||||||
|
if self.cpu_model:
|
||||||
|
info += self.cpu_model
|
||||||
|
if self.cpu_count and self.cpu_cores:
|
||||||
|
info += "{}*{}".format(self.cpu_count, self.cpu_cores)
|
||||||
|
return info
|
||||||
|
|
||||||
|
@property
|
||||||
|
def hardware_info(self):
|
||||||
|
if self.cpu_count:
|
||||||
|
return '{} Core {} {}'.format(
|
||||||
|
self.cpu_vcpus or self.cpu_count * self.cpu_cores,
|
||||||
|
self.memory, self.disk_total
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
class Asset(AbsConnectivity, AbsHardwareInfo, ProtocolsMixin, NodesRelationMixin, OrgModelMixin):
|
||||||
|
id = models.UUIDField(default=uuid.uuid4, primary_key=True)
|
||||||
|
ip = models.CharField(max_length=128, verbose_name=_('IP'), db_index=True)
|
||||||
|
hostname = models.CharField(max_length=128, verbose_name=_('Hostname'))
|
||||||
|
protocol = models.CharField(max_length=128, default=ProtocolsMixin.Protocol.ssh,
|
||||||
|
choices=ProtocolsMixin.Protocol.choices, verbose_name=_('Protocol'))
|
||||||
|
port = models.IntegerField(default=22, verbose_name=_('Port'))
|
||||||
|
protocols = models.CharField(max_length=128, default='ssh/22', blank=True, verbose_name=_("Protocols"))
|
||||||
|
platform = models.ForeignKey(Platform, default=Platform.default, on_delete=models.PROTECT, verbose_name=_("Platform"), related_name='assets')
|
||||||
|
domain = models.ForeignKey("assets.Domain", null=True, blank=True, related_name='assets', verbose_name=_("Domain"), on_delete=models.SET_NULL)
|
||||||
|
nodes = models.ManyToManyField('assets.Node', default=default_node, related_name='assets', verbose_name=_("Nodes"))
|
||||||
|
is_active = models.BooleanField(default=True, verbose_name=_('Is active'))
|
||||||
|
|
||||||
|
# Auth
|
||||||
|
admin_user = models.ForeignKey('assets.SystemUser', on_delete=models.SET_NULL, null=True, verbose_name=_("Admin user"), related_name='admin_assets')
|
||||||
|
|
||||||
|
# Some information
|
||||||
|
public_ip = models.CharField(max_length=128, blank=True, null=True, verbose_name=_('Public IP'))
|
||||||
|
number = models.CharField(max_length=32, null=True, blank=True, verbose_name=_('Asset number'))
|
||||||
|
|
||||||
labels = models.ManyToManyField('assets.Label', blank=True, related_name='assets', verbose_name=_("Labels"))
|
labels = models.ManyToManyField('assets.Label', blank=True, related_name='assets', verbose_name=_("Labels"))
|
||||||
created_by = models.CharField(max_length=128, null=True, blank=True, verbose_name=_('Created by'))
|
created_by = models.CharField(max_length=128, null=True, blank=True, verbose_name=_('Created by'))
|
||||||
date_created = models.DateTimeField(auto_now_add=True, null=True, blank=True, verbose_name=_('Date created'))
|
date_created = models.DateTimeField(auto_now_add=True, null=True, blank=True, verbose_name=_('Date created'))
|
||||||
@@ -269,25 +281,6 @@ class Asset(AbsConnectivity, ProtocolsMixin, NodesRelationMixin, OrgModelMixin):
|
|||||||
def is_support_ansible(self):
|
def is_support_ansible(self):
|
||||||
return self.has_protocol('ssh') and self.platform_base not in ("Other",)
|
return self.has_protocol('ssh') and self.platform_base not in ("Other",)
|
||||||
|
|
||||||
@property
|
|
||||||
def cpu_info(self):
|
|
||||||
info = ""
|
|
||||||
if self.cpu_model:
|
|
||||||
info += self.cpu_model
|
|
||||||
if self.cpu_count and self.cpu_cores:
|
|
||||||
info += "{}*{}".format(self.cpu_count, self.cpu_cores)
|
|
||||||
return info
|
|
||||||
|
|
||||||
@property
|
|
||||||
def hardware_info(self):
|
|
||||||
if self.cpu_count:
|
|
||||||
return '{} Core {} {}'.format(
|
|
||||||
self.cpu_vcpus or self.cpu_count * self.cpu_cores,
|
|
||||||
self.memory, self.disk_total
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
return ''
|
|
||||||
|
|
||||||
def get_auth_info(self):
|
def get_auth_info(self):
|
||||||
if not self.admin_user:
|
if not self.admin_user:
|
||||||
return {}
|
return {}
|
||||||
|
|||||||
@@ -94,25 +94,27 @@ class AuthBook(BaseUser, AbsConnectivity):
|
|||||||
i.private_key = self.private_key
|
i.private_key = self.private_key
|
||||||
i.public_key = self.public_key
|
i.public_key = self.public_key
|
||||||
i.comment = 'Update triggered by account {}'.format(self.id)
|
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):
|
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
|
return
|
||||||
if not self.systemuser.is_admin_user:
|
if not self.systemuser.is_admin_user or self.asset.admin_user != self.systemuser:
|
||||||
return
|
return
|
||||||
logger.debug('Remove asset admin user: {} {}'.format(self.asset, self.systemuser))
|
|
||||||
self.asset.admin_user = None
|
self.asset.admin_user = None
|
||||||
self.asset.save()
|
self.asset.save()
|
||||||
|
logger.debug('Remove asset admin user: {} {}'.format(self.asset, self.systemuser))
|
||||||
|
|
||||||
def update_asset_admin_user_if_need(self):
|
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
|
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
|
return
|
||||||
logger.debug('Update asset admin user: {} {}'.format(self.asset, self.systemuser))
|
|
||||||
self.asset.admin_user = self.systemuser
|
self.asset.admin_user = self.systemuser
|
||||||
self.asset.save()
|
self.asset.save()
|
||||||
|
logger.debug('Update asset admin user: {} {}'.format(self.asset, self.systemuser))
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.smart_name
|
return self.smart_name
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ class AuthMixin:
|
|||||||
class BaseUser(OrgModelMixin, AuthMixin):
|
class BaseUser(OrgModelMixin, AuthMixin):
|
||||||
id = models.UUIDField(default=uuid.uuid4, primary_key=True)
|
id = models.UUIDField(default=uuid.uuid4, primary_key=True)
|
||||||
name = models.CharField(max_length=128, verbose_name=_('Name'))
|
name = models.CharField(max_length=128, verbose_name=_('Name'))
|
||||||
username = models.CharField(max_length=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'))
|
password = fields.EncryptCharField(max_length=256, blank=True, null=True, verbose_name=_('Password'))
|
||||||
private_key = fields.EncryptTextField(blank=True, null=True, verbose_name=_('SSH private key'))
|
private_key = fields.EncryptTextField(blank=True, null=True, verbose_name=_('SSH private key'))
|
||||||
public_key = fields.EncryptTextField(blank=True, null=True, verbose_name=_('SSH public key'))
|
public_key = fields.EncryptTextField(blank=True, null=True, verbose_name=_('SSH public key'))
|
||||||
@@ -185,10 +185,18 @@ class BaseUser(OrgModelMixin, AuthMixin):
|
|||||||
ASSETS_AMOUNT_CACHE_KEY = "ASSET_USER_{}_ASSETS_AMOUNT"
|
ASSETS_AMOUNT_CACHE_KEY = "ASSET_USER_{}_ASSETS_AMOUNT"
|
||||||
ASSET_USER_CACHE_TIME = 600
|
ASSET_USER_CACHE_TIME = 600
|
||||||
|
|
||||||
|
APPS_AMOUNT_CACHE_KEY = "APP_USER_{}_APPS_AMOUNT"
|
||||||
|
APP_USER_CACHE_TIME = 600
|
||||||
|
|
||||||
def get_related_assets(self):
|
def get_related_assets(self):
|
||||||
assets = self.assets.filter(org_id=self.org_id)
|
assets = self.assets.filter(org_id=self.org_id)
|
||||||
return assets
|
return assets
|
||||||
|
|
||||||
|
def get_related_apps(self):
|
||||||
|
from applications.models import Account
|
||||||
|
apps = Account.objects.filter(systemuser=self)
|
||||||
|
return apps
|
||||||
|
|
||||||
def get_username(self):
|
def get_username(self):
|
||||||
return self.username
|
return self.username
|
||||||
|
|
||||||
@@ -201,6 +209,15 @@ class BaseUser(OrgModelMixin, AuthMixin):
|
|||||||
cache.set(cache_key, cached, self.ASSET_USER_CACHE_TIME)
|
cache.set(cache_key, cached, self.ASSET_USER_CACHE_TIME)
|
||||||
return cached
|
return cached
|
||||||
|
|
||||||
|
@property
|
||||||
|
def apps_amount(self):
|
||||||
|
cache_key = self.APPS_AMOUNT_CACHE_KEY.format(self.id)
|
||||||
|
cached = cache.get(cache_key)
|
||||||
|
if not cached:
|
||||||
|
cached = self.get_related_apps().count()
|
||||||
|
cache.set(cache_key, cached, self.APP_USER_CACHE_TIME)
|
||||||
|
return cached
|
||||||
|
|
||||||
def expire_assets_amount(self):
|
def expire_assets_amount(self):
|
||||||
cache_key = self.ASSETS_AMOUNT_CACHE_KEY.format(self.id)
|
cache_key = self.ASSETS_AMOUNT_CACHE_KEY.format(self.id)
|
||||||
cache.delete(cache_key)
|
cache.delete(cache_key)
|
||||||
|
|||||||
@@ -103,16 +103,23 @@ class AuthMixin:
|
|||||||
password = cache.get(key)
|
password = cache.get(key)
|
||||||
return password
|
return password
|
||||||
|
|
||||||
def load_tmp_auth_if_has(self, asset_or_app_id, user):
|
def _clean_auth_info_if_manual_login_mode(self):
|
||||||
if not asset_or_app_id or not user:
|
if self.login_mode == self.LOGIN_MANUAL:
|
||||||
return
|
self.password = ''
|
||||||
|
self.private_key = ''
|
||||||
|
self.public_key = ''
|
||||||
|
|
||||||
|
def _load_tmp_auth_if_has(self, asset_or_app_id, user_id):
|
||||||
if self.login_mode != self.LOGIN_MANUAL:
|
if self.login_mode != self.LOGIN_MANUAL:
|
||||||
return
|
return
|
||||||
|
|
||||||
auth = self.get_temp_auth(asset_or_app_id, user)
|
if not asset_or_app_id or not user_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
auth = self.get_temp_auth(asset_or_app_id, user_id)
|
||||||
if not auth:
|
if not auth:
|
||||||
return
|
return
|
||||||
|
|
||||||
username = auth.get('username')
|
username = auth.get('username')
|
||||||
password = auth.get('password')
|
password = auth.get('password')
|
||||||
|
|
||||||
@@ -122,17 +129,11 @@ class AuthMixin:
|
|||||||
self.password = password
|
self.password = password
|
||||||
|
|
||||||
def load_app_more_auth(self, app_id=None, user_id=None):
|
def load_app_more_auth(self, app_id=None, user_id=None):
|
||||||
from users.models import User
|
self._clean_auth_info_if_manual_login_mode()
|
||||||
|
# 加载临时认证信息
|
||||||
if self.login_mode == self.LOGIN_MANUAL:
|
if self.login_mode == self.LOGIN_MANUAL:
|
||||||
self.password = ''
|
self._load_tmp_auth_if_has(app_id, user_id)
|
||||||
self.private_key = ''
|
|
||||||
if not user_id:
|
|
||||||
return
|
return
|
||||||
user = get_object_or_none(User, pk=user_id)
|
|
||||||
if not user:
|
|
||||||
return
|
|
||||||
self.load_tmp_auth_if_has(app_id, user)
|
|
||||||
|
|
||||||
def load_asset_special_auth(self, asset, username=''):
|
def load_asset_special_auth(self, asset, username=''):
|
||||||
"""
|
"""
|
||||||
@@ -152,34 +153,25 @@ class AuthMixin:
|
|||||||
|
|
||||||
def load_asset_more_auth(self, asset_id=None, username=None, user_id=None):
|
def load_asset_more_auth(self, asset_id=None, username=None, user_id=None):
|
||||||
from users.models import User
|
from users.models import User
|
||||||
|
self._clean_auth_info_if_manual_login_mode()
|
||||||
|
# 加载临时认证信息
|
||||||
if self.login_mode == self.LOGIN_MANUAL:
|
if self.login_mode == self.LOGIN_MANUAL:
|
||||||
self.password = ''
|
self._load_tmp_auth_if_has(asset_id, user_id)
|
||||||
self.private_key = ''
|
|
||||||
|
|
||||||
asset = None
|
|
||||||
if asset_id:
|
|
||||||
asset = get_object_or_none(Asset, pk=asset_id)
|
|
||||||
# 没有资产就没有必要继续了
|
|
||||||
if not asset:
|
|
||||||
logger.debug('Asset not found, pass')
|
|
||||||
return
|
return
|
||||||
|
# 更新用户名
|
||||||
user = None
|
user = get_object_or_none(User, pk=user_id) if user_id else None
|
||||||
if user_id:
|
|
||||||
user = get_object_or_none(User, pk=user_id)
|
|
||||||
|
|
||||||
_username = self.username
|
|
||||||
if self.username_same_with_user:
|
if self.username_same_with_user:
|
||||||
if user and not username:
|
if user and not username:
|
||||||
_username = user.username
|
_username = user.username
|
||||||
else:
|
else:
|
||||||
_username = username
|
_username = username
|
||||||
self.username = _username
|
self.username = _username
|
||||||
|
|
||||||
# 加载某个资产的特殊配置认证信息
|
# 加载某个资产的特殊配置认证信息
|
||||||
self.load_asset_special_auth(asset, _username)
|
asset = get_object_or_none(Asset, pk=asset_id) if asset_id else None
|
||||||
self.load_tmp_auth_if_has(asset_id, user)
|
if not asset:
|
||||||
|
logger.debug('Asset not found, pass')
|
||||||
|
return
|
||||||
|
self.load_asset_special_auth(asset, self.username)
|
||||||
|
|
||||||
|
|
||||||
class SystemUser(ProtocolMixin, AuthMixin, BaseUser):
|
class SystemUser(ProtocolMixin, AuthMixin, BaseUser):
|
||||||
@@ -216,6 +208,9 @@ class SystemUser(ProtocolMixin, AuthMixin, BaseUser):
|
|||||||
home = models.CharField(max_length=4096, default='', verbose_name=_('Home'), blank=True)
|
home = models.CharField(max_length=4096, default='', verbose_name=_('Home'), blank=True)
|
||||||
system_groups = models.CharField(default='', max_length=4096, verbose_name=_('System groups'), blank=True)
|
system_groups = models.CharField(default='', max_length=4096, verbose_name=_('System groups'), blank=True)
|
||||||
ad_domain = models.CharField(default='', max_length=256)
|
ad_domain = models.CharField(default='', max_length=256)
|
||||||
|
# linux su 命令 (switch user)
|
||||||
|
su_enabled = models.BooleanField(default=False, verbose_name=_('User switch'))
|
||||||
|
su_from = models.ForeignKey('self', on_delete=models.SET_NULL, related_name='su_to', null=True, verbose_name=_("Switch from"))
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
username = self.username
|
username = self.username
|
||||||
@@ -275,6 +270,21 @@ class SystemUser(ProtocolMixin, AuthMixin, BaseUser):
|
|||||||
assets = Asset.objects.filter(id__in=asset_ids)
|
assets = Asset.objects.filter(id__in=asset_ids)
|
||||||
return assets
|
return assets
|
||||||
|
|
||||||
|
def add_related_assets(self, assets_or_ids):
|
||||||
|
self.assets.add(*tuple(assets_or_ids))
|
||||||
|
self.add_related_assets_to_su_from_if_need(assets_or_ids)
|
||||||
|
|
||||||
|
def add_related_assets_to_su_from_if_need(self, assets_or_ids):
|
||||||
|
if self.protocol not in [self.Protocol.ssh.value]:
|
||||||
|
return
|
||||||
|
if not self.su_enabled:
|
||||||
|
return
|
||||||
|
if not self.su_from:
|
||||||
|
return
|
||||||
|
if self.su_from.protocol != self.protocol:
|
||||||
|
return
|
||||||
|
self.su_from.assets.add(*tuple(assets_or_ids))
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['name']
|
ordering = ['name']
|
||||||
unique_together = [('name', 'org_id')]
|
unique_together = [('name', 'org_id')]
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ from django.core.validators import RegexValidator
|
|||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
from orgs.mixins.serializers import BulkOrgResourceModelSerializer
|
from orgs.mixins.serializers import BulkOrgResourceModelSerializer
|
||||||
|
from users.models import User, UserGroup
|
||||||
|
from perms.models import AssetPermission
|
||||||
from ..models import Asset, Node, Platform, SystemUser
|
from ..models import Asset, Node, Platform, SystemUser
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'AssetSerializer', 'AssetSimpleSerializer', 'MiniAssetSerializer',
|
'AssetSerializer', 'AssetSimpleSerializer', 'MiniAssetSerializer',
|
||||||
'ProtocolsField', 'PlatformSerializer',
|
'ProtocolsField', 'PlatformSerializer',
|
||||||
'AssetTaskSerializer', 'AssetsTaskSerializer', 'ProtocolsField'
|
'AssetTaskSerializer', 'AssetsTaskSerializer', 'ProtocolsField',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -64,7 +66,9 @@ class AssetSerializer(BulkOrgResourceModelSerializer):
|
|||||||
)
|
)
|
||||||
protocols = ProtocolsField(label=_('Protocols'), required=False, default=['ssh/22'])
|
protocols = ProtocolsField(label=_('Protocols'), required=False, default=['ssh/22'])
|
||||||
domain_display = serializers.ReadOnlyField(source='domain.name', label=_('Domain name'))
|
domain_display = serializers.ReadOnlyField(source='domain.name', label=_('Domain name'))
|
||||||
nodes_display = serializers.ListField(child=serializers.CharField(), label=_('Nodes name'), required=False)
|
nodes_display = serializers.ListField(
|
||||||
|
child=serializers.CharField(), label=_('Nodes name'), required=False
|
||||||
|
)
|
||||||
|
|
||||||
"""
|
"""
|
||||||
资产的数据结构
|
资产的数据结构
|
||||||
@@ -74,14 +78,14 @@ class AssetSerializer(BulkOrgResourceModelSerializer):
|
|||||||
model = Asset
|
model = Asset
|
||||||
fields_mini = ['id', 'hostname', 'ip', 'platform', 'protocols']
|
fields_mini = ['id', 'hostname', 'ip', 'platform', 'protocols']
|
||||||
fields_small = fields_mini + [
|
fields_small = fields_mini + [
|
||||||
'protocol', 'port', 'protocols', 'is_active', 'public_ip',
|
'protocol', 'port', 'protocols', 'is_active',
|
||||||
'comment',
|
'public_ip', 'number', 'comment',
|
||||||
]
|
]
|
||||||
hardware_fields = [
|
fields_hardware = [
|
||||||
'number', 'vendor', 'model', 'sn', 'cpu_model', 'cpu_count',
|
'vendor', 'model', 'sn', 'cpu_model', 'cpu_count',
|
||||||
'cpu_cores', 'cpu_vcpus', 'memory', 'disk_total', 'disk_info',
|
'cpu_cores', 'cpu_vcpus', 'memory', 'disk_total', 'disk_info',
|
||||||
'os', 'os_version', 'os_arch', 'hostname_raw', 'hardware_info',
|
'os', 'os_version', 'os_arch', 'hostname_raw',
|
||||||
'connectivity', 'date_verified'
|
'cpu_info', 'hardware_info',
|
||||||
]
|
]
|
||||||
fields_fk = [
|
fields_fk = [
|
||||||
'domain', 'domain_display', 'platform', 'admin_user', 'admin_user_display'
|
'domain', 'domain_display', 'platform', 'admin_user', 'admin_user_display'
|
||||||
@@ -90,18 +94,17 @@ class AssetSerializer(BulkOrgResourceModelSerializer):
|
|||||||
'nodes', 'nodes_display', 'labels',
|
'nodes', 'nodes_display', 'labels',
|
||||||
]
|
]
|
||||||
read_only_fields = [
|
read_only_fields = [
|
||||||
|
'connectivity', 'date_verified', 'cpu_info', 'hardware_info',
|
||||||
'created_by', 'date_created',
|
'created_by', 'date_created',
|
||||||
]
|
]
|
||||||
fields = fields_small + hardware_fields + fields_fk + fields_m2m + read_only_fields
|
fields = fields_small + fields_hardware + fields_fk + fields_m2m + read_only_fields
|
||||||
|
extra_kwargs = {
|
||||||
extra_kwargs = {k: {'read_only': True} for k in hardware_fields}
|
|
||||||
extra_kwargs.update({
|
|
||||||
'protocol': {'write_only': True},
|
'protocol': {'write_only': True},
|
||||||
'port': {'write_only': True},
|
'port': {'write_only': True},
|
||||||
'hardware_info': {'label': _('Hardware info'), 'read_only': True},
|
'hardware_info': {'label': _('Hardware info'), 'read_only': True},
|
||||||
'org_name': {'label': _('Org name'), 'read_only': True},
|
|
||||||
'admin_user_display': {'label': _('Admin user display'), 'read_only': True},
|
'admin_user_display': {'label': _('Admin user display'), 'read_only': True},
|
||||||
})
|
'cpu_info': {'label': _('CPU info')},
|
||||||
|
}
|
||||||
|
|
||||||
def get_fields(self):
|
def get_fields(self):
|
||||||
fields = super().get_fields()
|
fields = super().get_fields()
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
|
from common.validators import alphanumeric
|
||||||
from orgs.mixins.serializers import BulkOrgResourceModelSerializer
|
from orgs.mixins.serializers import BulkOrgResourceModelSerializer
|
||||||
from ..models import Domain, Gateway
|
from ..models import Domain, Gateway
|
||||||
from .base import AuthSerializerMixin
|
from .base import AuthSerializerMixin
|
||||||
@@ -59,6 +60,7 @@ class GatewaySerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
|
|||||||
fields_fk = ['domain']
|
fields_fk = ['domain']
|
||||||
fields = fields_small + fields_fk
|
fields = fields_small + fields_fk
|
||||||
extra_kwargs = {
|
extra_kwargs = {
|
||||||
|
'username': {"validators": [alphanumeric]},
|
||||||
'password': {'write_only': True},
|
'password': {'write_only': True},
|
||||||
'private_key': {"write_only": True},
|
'private_key': {"write_only": True},
|
||||||
'public_key': {"write_only": True},
|
'public_key': {"write_only": True},
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from django.db.models import Count
|
|||||||
|
|
||||||
from common.mixins.serializers import BulkSerializerMixin
|
from common.mixins.serializers import BulkSerializerMixin
|
||||||
from common.utils import ssh_pubkey_gen
|
from common.utils import ssh_pubkey_gen
|
||||||
|
from common.validators import alphanumeric_re, alphanumeric_cn_re
|
||||||
from orgs.mixins.serializers import BulkOrgResourceModelSerializer
|
from orgs.mixins.serializers import BulkOrgResourceModelSerializer
|
||||||
from ..models import SystemUser, Asset
|
from ..models import SystemUser, Asset
|
||||||
from .utils import validate_password_contains_left_double_curly_bracket
|
from .utils import validate_password_contains_left_double_curly_bracket
|
||||||
@@ -25,6 +26,9 @@ class SystemUserSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
|
|||||||
auto_generate_key = serializers.BooleanField(initial=True, required=False, write_only=True)
|
auto_generate_key = serializers.BooleanField(initial=True, required=False, write_only=True)
|
||||||
type_display = serializers.ReadOnlyField(source='get_type_display', label=_('Type display'))
|
type_display = serializers.ReadOnlyField(source='get_type_display', label=_('Type display'))
|
||||||
ssh_key_fingerprint = serializers.ReadOnlyField(label=_('SSH key fingerprint'))
|
ssh_key_fingerprint = serializers.ReadOnlyField(label=_('SSH key fingerprint'))
|
||||||
|
applications_amount = serializers.IntegerField(
|
||||||
|
source='apps_amount', read_only=True, label=_('Apps amount')
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = SystemUser
|
model = SystemUser
|
||||||
@@ -36,13 +40,15 @@ class SystemUserSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
|
|||||||
'login_mode', 'login_mode_display', 'priority',
|
'login_mode', 'login_mode_display', 'priority',
|
||||||
'sudo', 'shell', 'sftp_root', 'home', 'system_groups', 'ad_domain',
|
'sudo', 'shell', 'sftp_root', 'home', 'system_groups', 'ad_domain',
|
||||||
'username_same_with_user', 'auto_push', 'auto_generate_key',
|
'username_same_with_user', 'auto_push', 'auto_generate_key',
|
||||||
|
'su_enabled', 'su_from',
|
||||||
'date_created', 'date_updated', 'comment', 'created_by',
|
'date_created', 'date_updated', 'comment', 'created_by',
|
||||||
]
|
]
|
||||||
fields_m2m = ['cmd_filters', 'assets_amount', 'nodes']
|
fields_m2m = ['cmd_filters', 'assets_amount', 'applications_amount', 'nodes']
|
||||||
fields = fields_small + fields_m2m
|
fields = fields_small + fields_m2m
|
||||||
extra_kwargs = {
|
extra_kwargs = {
|
||||||
'password': {
|
'password': {
|
||||||
"write_only": True,
|
"write_only": True,
|
||||||
|
'trim_whitespace': False,
|
||||||
"validators": [validate_password_contains_left_double_curly_bracket]
|
"validators": [validate_password_contains_left_double_curly_bracket]
|
||||||
},
|
},
|
||||||
'public_key': {"write_only": True},
|
'public_key': {"write_only": True},
|
||||||
@@ -53,7 +59,8 @@ class SystemUserSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
|
|||||||
'login_mode_display': {'label': _('Login mode display')},
|
'login_mode_display': {'label': _('Login mode display')},
|
||||||
'created_by': {'read_only': True},
|
'created_by': {'read_only': True},
|
||||||
'ad_domain': {'required': False, 'allow_blank': True, 'label': _('Ad domain')},
|
'ad_domain': {'required': False, 'allow_blank': True, 'label': _('Ad domain')},
|
||||||
'is_asset_protocol': {'label': _('Is asset protocol')}
|
'is_asset_protocol': {'label': _('Is asset protocol')},
|
||||||
|
'su_from': {'help_text': _('Only ssh and automatic login system users are supported')}
|
||||||
}
|
}
|
||||||
|
|
||||||
def validate_auto_push(self, value):
|
def validate_auto_push(self, value):
|
||||||
@@ -97,15 +104,20 @@ class SystemUserSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
|
|||||||
raise serializers.ValidationError(error)
|
raise serializers.ValidationError(error)
|
||||||
|
|
||||||
def validate_username(self, username):
|
def validate_username(self, username):
|
||||||
if username:
|
|
||||||
return username
|
|
||||||
login_mode = self.get_initial_value("login_mode")
|
|
||||||
protocol = self.get_initial_value("protocol")
|
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:
|
if username_same_with_user:
|
||||||
return ''
|
return ''
|
||||||
|
|
||||||
|
login_mode = self.get_initial_value("login_mode")
|
||||||
if login_mode == SystemUser.LOGIN_AUTO and protocol != SystemUser.Protocol.vnc:
|
if login_mode == SystemUser.LOGIN_AUTO and protocol != SystemUser.Protocol.vnc:
|
||||||
msg = _('* Automatic login mode must fill in the username.')
|
msg = _('* Automatic login mode must fill in the username.')
|
||||||
raise serializers.ValidationError(msg)
|
raise serializers.ValidationError(msg)
|
||||||
@@ -117,7 +129,8 @@ class SystemUserSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
|
|||||||
return ''
|
return ''
|
||||||
return home
|
return home
|
||||||
|
|
||||||
def validate_sftp_root(self, value):
|
@staticmethod
|
||||||
|
def validate_sftp_root(value):
|
||||||
if value in ['home', 'tmp']:
|
if value in ['home', 'tmp']:
|
||||||
return value
|
return value
|
||||||
if not value.startswith('/'):
|
if not value.startswith('/'):
|
||||||
@@ -125,19 +138,6 @@ class SystemUserSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
|
|||||||
raise serializers.ValidationError(error)
|
raise serializers.ValidationError(error)
|
||||||
return value
|
return value
|
||||||
|
|
||||||
def validate_admin_user(self, attrs):
|
|
||||||
if self.instance:
|
|
||||||
tp = self.instance.type
|
|
||||||
else:
|
|
||||||
tp = attrs.get('type')
|
|
||||||
if tp != SystemUser.Type.admin:
|
|
||||||
return attrs
|
|
||||||
attrs['protocol'] = SystemUser.Protocol.ssh
|
|
||||||
attrs['login_mode'] = SystemUser.LOGIN_AUTO
|
|
||||||
attrs['username_same_with_user'] = False
|
|
||||||
attrs['auto_push'] = False
|
|
||||||
return attrs
|
|
||||||
|
|
||||||
def validate_password(self, password):
|
def validate_password(self, password):
|
||||||
super().validate_password(password)
|
super().validate_password(password)
|
||||||
auto_gen_key = self.get_initial_value("auto_generate_key", False)
|
auto_gen_key = self.get_initial_value("auto_generate_key", False)
|
||||||
@@ -149,7 +149,43 @@ class SystemUserSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
|
|||||||
raise serializers.ValidationError(_("Password or private key required"))
|
raise serializers.ValidationError(_("Password or private key required"))
|
||||||
return password
|
return password
|
||||||
|
|
||||||
def validate_gen_key(self, attrs):
|
def validate_su_from(self, su_from: SystemUser):
|
||||||
|
# self: su enabled
|
||||||
|
su_enabled = self.get_initial_value('su_enabled', default=False)
|
||||||
|
if not su_enabled:
|
||||||
|
return
|
||||||
|
if not su_from:
|
||||||
|
error = _('This field is required.')
|
||||||
|
raise serializers.ValidationError(error)
|
||||||
|
# self: protocol ssh
|
||||||
|
protocol = self.get_initial_value('protocol', default=SystemUser.Protocol.ssh.value)
|
||||||
|
if protocol not in [SystemUser.Protocol.ssh.value]:
|
||||||
|
error = _('Only ssh protocol system users are allowed')
|
||||||
|
raise serializers.ValidationError(error)
|
||||||
|
# su_from: protocol same
|
||||||
|
if su_from.protocol != protocol:
|
||||||
|
error = _('The protocol must be consistent with the current user: {}').format(protocol)
|
||||||
|
raise serializers.ValidationError(error)
|
||||||
|
# su_from: login model auto
|
||||||
|
if su_from.login_mode != su_from.LOGIN_AUTO:
|
||||||
|
error = _('Only system users with automatic login are allowed')
|
||||||
|
raise serializers.ValidationError(error)
|
||||||
|
return su_from
|
||||||
|
|
||||||
|
def _validate_admin_user(self, attrs):
|
||||||
|
if self.instance:
|
||||||
|
tp = self.instance.type
|
||||||
|
else:
|
||||||
|
tp = attrs.get('type')
|
||||||
|
if tp != SystemUser.Type.admin:
|
||||||
|
return attrs
|
||||||
|
attrs['protocol'] = SystemUser.Protocol.ssh
|
||||||
|
attrs['login_mode'] = SystemUser.LOGIN_AUTO
|
||||||
|
attrs['username_same_with_user'] = False
|
||||||
|
attrs['auto_push'] = False
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
def _validate_gen_key(self, attrs):
|
||||||
username = attrs.get("username", "manual")
|
username = attrs.get("username", "manual")
|
||||||
auto_gen_key = attrs.pop("auto_generate_key", False)
|
auto_gen_key = attrs.pop("auto_generate_key", False)
|
||||||
protocol = attrs.get("protocol")
|
protocol = attrs.get("protocol")
|
||||||
@@ -173,15 +209,31 @@ class SystemUserSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
|
|||||||
attrs["public_key"] = public_key
|
attrs["public_key"] = public_key
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
|
def _validate_login_mode(self, attrs):
|
||||||
|
if 'login_mode' in attrs:
|
||||||
|
login_mode = attrs['login_mode']
|
||||||
|
else:
|
||||||
|
login_mode = self.instance.login_mode if self.instance else SystemUser.LOGIN_AUTO
|
||||||
|
|
||||||
|
if login_mode == SystemUser.LOGIN_MANUAL:
|
||||||
|
attrs['password'] = ''
|
||||||
|
attrs['private_key'] = ''
|
||||||
|
attrs['public_key'] = ''
|
||||||
|
|
||||||
|
return attrs
|
||||||
|
|
||||||
def validate(self, attrs):
|
def validate(self, attrs):
|
||||||
attrs = self.validate_admin_user(attrs)
|
attrs = self._validate_admin_user(attrs)
|
||||||
attrs = self.validate_gen_key(attrs)
|
attrs = self._validate_gen_key(attrs)
|
||||||
|
attrs = self._validate_login_mode(attrs)
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def setup_eager_loading(cls, queryset):
|
def setup_eager_loading(cls, queryset):
|
||||||
""" Perform necessary eager loading of data. """
|
""" 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
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -34,9 +34,13 @@ def on_authbook_post_delete(sender, instance, **kwargs):
|
|||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=AuthBook)
|
@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.sync_to_system_user_account()
|
||||||
instance.update_asset_admin_user_if_need()
|
if created:
|
||||||
|
pass
|
||||||
|
# # 不再自动更新资产管理用户,只允许用户手动指定。
|
||||||
|
# 只在创建时进行更新资产的管理用户
|
||||||
|
# instance.update_asset_admin_user_if_need()
|
||||||
|
|
||||||
|
|
||||||
@receiver(pre_save, sender=AuthBook)
|
@receiver(pre_save, sender=AuthBook)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from django.dispatch import receiver
|
|||||||
from django.utils.functional import LazyObject
|
from django.utils.functional import LazyObject
|
||||||
|
|
||||||
from common.signals import django_ready
|
from common.signals import django_ready
|
||||||
|
from common.db.utils import close_old_connections
|
||||||
from common.utils.connection import RedisPubSub
|
from common.utils.connection import RedisPubSub
|
||||||
from common.utils import get_logger
|
from common.utils import get_logger
|
||||||
from assets.models import Asset, Node
|
from assets.models import Asset, Node
|
||||||
@@ -77,11 +78,14 @@ def on_node_asset_change(sender, instance, **kwargs):
|
|||||||
def subscribe_node_assets_mapping_expire(sender, **kwargs):
|
def subscribe_node_assets_mapping_expire(sender, **kwargs):
|
||||||
logger.debug("Start subscribe for expire node assets id mapping from memory")
|
logger.debug("Start subscribe for expire node assets id mapping from memory")
|
||||||
|
|
||||||
def keep_subscribe():
|
def keep_subscribe_node_assets_relation():
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
subscribe = node_assets_mapping_for_memory_pub_sub.subscribe()
|
subscribe = node_assets_mapping_for_memory_pub_sub.subscribe()
|
||||||
for message in subscribe.listen():
|
msgs = subscribe.listen()
|
||||||
|
# 开始之前关闭连接,因为server端可能关闭了连接,而 client 还在 CONN_MAX_AGE 中
|
||||||
|
close_old_connections()
|
||||||
|
for message in msgs:
|
||||||
if message["type"] != "message":
|
if message["type"] != "message":
|
||||||
continue
|
continue
|
||||||
org_id = message['data'].decode()
|
org_id = message['data'].decode()
|
||||||
@@ -95,7 +99,10 @@ def subscribe_node_assets_mapping_expire(sender, **kwargs):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f'subscribe_node_assets_mapping_expire: {e}')
|
logger.exception(f'subscribe_node_assets_mapping_expire: {e}')
|
||||||
Node.expire_all_orgs_node_all_asset_ids_mapping_from_memory()
|
Node.expire_all_orgs_node_all_asset_ids_mapping_from_memory()
|
||||||
|
finally:
|
||||||
|
# 请求结束,关闭连接
|
||||||
|
close_old_connections()
|
||||||
|
|
||||||
t = threading.Thread(target=keep_subscribe)
|
t = threading.Thread(target=keep_subscribe_node_assets_relation)
|
||||||
t.daemon = True
|
t.daemon = True
|
||||||
t.start()
|
t.start()
|
||||||
|
|||||||
@@ -140,3 +140,5 @@ def on_system_user_update(instance: SystemUser, created, **kwargs):
|
|||||||
logger.info("System user update signal recv: {}".format(instance))
|
logger.info("System user update signal recv: {}".format(instance))
|
||||||
assets = instance.assets.all().valid()
|
assets = instance.assets.all().valid()
|
||||||
push_system_user_to_assets.delay(instance.id, [_asset.id for _asset in assets])
|
push_system_user_to_assets.delay(instance.id, [_asset.id for _asset in assets])
|
||||||
|
# add assets to su_from
|
||||||
|
instance.add_related_assets_to_su_from_if_need(assets)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
from celery import shared_task
|
from celery import shared_task
|
||||||
|
|
||||||
from orgs.utils import tmp_to_root_org
|
from orgs.utils import tmp_to_root_org
|
||||||
|
from assets.models import AuthBook
|
||||||
|
|
||||||
__all__ = ['add_nodes_assets_to_system_users']
|
__all__ = ['add_nodes_assets_to_system_users']
|
||||||
|
|
||||||
@@ -15,4 +16,13 @@ def add_nodes_assets_to_system_users(nodes_keys, system_users):
|
|||||||
nodes = Node.objects.filter(key__in=nodes_keys)
|
nodes = Node.objects.filter(key__in=nodes_keys)
|
||||||
assets = Node.get_nodes_all_assets(*nodes)
|
assets = Node.get_nodes_all_assets(*nodes)
|
||||||
for system_user in system_users:
|
for system_user in system_users:
|
||||||
system_user.assets.add(*tuple(assets))
|
""" 解决资产和节点进行关联时,已经关联过的节点不会触发 authbook post_save 信号,
|
||||||
|
无法更新节点下所有资产的管理用户的问题 """
|
||||||
|
for asset in assets:
|
||||||
|
defaults = {'asset': asset, 'systemuser': system_user, 'org_id': asset.org_id}
|
||||||
|
instance, created = AuthBook.objects.update_or_create(
|
||||||
|
defaults=defaults, asset=asset, systemuser=system_user
|
||||||
|
)
|
||||||
|
# # 不再自动更新资产管理用户,只允许用户手动指定。
|
||||||
|
# 只要关联都需要更新资产的管理用户
|
||||||
|
# instance.update_asset_admin_user_if_need()
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ urlpatterns = [
|
|||||||
path('assets/<uuid:pk>/platform/', api.AssetPlatformRetrieveApi.as_view(), name='asset-platform-detail'),
|
path('assets/<uuid:pk>/platform/', api.AssetPlatformRetrieveApi.as_view(), name='asset-platform-detail'),
|
||||||
path('assets/<uuid:pk>/tasks/', api.AssetTaskCreateApi.as_view(), name='asset-task-create'),
|
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/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>/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'),
|
path('system-users/<uuid:pk>/assets/', api.SystemUserAssetsListView.as_view(), name='system-user-assets'),
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ class UserLoginLogViewSet(ListModelMixin, CommonGenericViewSet):
|
|||||||
('datetime', ('date_from', 'date_to'))
|
('datetime', ('date_from', 'date_to'))
|
||||||
]
|
]
|
||||||
filterset_fields = ['username', 'ip', 'city', 'type', 'status', 'mfa']
|
filterset_fields = ['username', 'ip', 'city', 'type', 'status', 'mfa']
|
||||||
search_fields =['username', 'ip', 'city']
|
search_fields = ['username', 'ip', 'city']
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_org_members():
|
def get_org_members():
|
||||||
@@ -48,9 +48,10 @@ class UserLoginLogViewSet(ListModelMixin, CommonGenericViewSet):
|
|||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
queryset = super().get_queryset()
|
queryset = super().get_queryset()
|
||||||
if not current_org.is_default():
|
if current_org.is_root():
|
||||||
users = self.get_org_members()
|
return queryset
|
||||||
queryset = queryset.filter(username__in=users)
|
users = self.get_org_members()
|
||||||
|
queryset = queryset.filter(username__in=users)
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
5
apps/audits/const.py
Normal file
5
apps/audits/const.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
#
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
|
DEFAULT_CITY = _("Unknown")
|
||||||
@@ -35,13 +35,14 @@ class UserLoginLogSerializer(serializers.ModelSerializer):
|
|||||||
fields_mini = ['id']
|
fields_mini = ['id']
|
||||||
fields_small = fields_mini + [
|
fields_small = fields_mini + [
|
||||||
'username', 'type', 'type_display', 'ip', 'city', 'user_agent',
|
'username', 'type', 'type_display', 'ip', 'city', 'user_agent',
|
||||||
'mfa', 'mfa_display', 'reason', 'backend',
|
'mfa', 'mfa_display', 'reason', 'reason_display', 'backend',
|
||||||
'status', 'status_display',
|
'status', 'status_display',
|
||||||
'datetime',
|
'datetime',
|
||||||
]
|
]
|
||||||
fields = fields_small
|
fields = fields_small
|
||||||
extra_kwargs = {
|
extra_kwargs = {
|
||||||
"user_agent": {'label': _('User agent')}
|
"user_agent": {'label': _('User agent')},
|
||||||
|
"reason_display": {'label': _('Reason display')}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
#
|
#
|
||||||
from django.db.models.signals import (
|
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.dispatch import receiver
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -14,25 +14,25 @@ from rest_framework.renderers import JSONRenderer
|
|||||||
from rest_framework.request import Request
|
from rest_framework.request import Request
|
||||||
|
|
||||||
from assets.models import Asset, SystemUser
|
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_if_need
|
||||||
from jumpserver.utils import current_request
|
from jumpserver.utils import current_request
|
||||||
from common.utils import get_request_ip, get_logger, get_syslogger
|
|
||||||
from users.models import User
|
from users.models import User
|
||||||
from users.signals import post_user_change_password
|
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 terminal.models import Session, Command
|
||||||
from common.utils.encode import model_to_json
|
|
||||||
from .utils import write_login_log
|
from .utils import write_login_log
|
||||||
from . import models
|
from . import models
|
||||||
from .models import OperateLog
|
from .models import OperateLog
|
||||||
from orgs.utils import current_org
|
from orgs.utils import current_org
|
||||||
from perms.models import AssetPermission, ApplicationPermission
|
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__)
|
logger = get_logger(__name__)
|
||||||
sys_logger = get_syslogger(__name__)
|
sys_logger = get_syslogger(__name__)
|
||||||
json_render = JSONRenderer()
|
json_render = JSONRenderer()
|
||||||
|
|
||||||
|
|
||||||
MODELS_NEED_RECORD = (
|
MODELS_NEED_RECORD = (
|
||||||
# users
|
# users
|
||||||
'User', 'UserGroup',
|
'User', 'UserGroup',
|
||||||
@@ -165,7 +165,6 @@ M2M_NEED_RECORD = {
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
M2M_ACTION = {
|
M2M_ACTION = {
|
||||||
POST_ADD: 'add',
|
POST_ADD: 'add',
|
||||||
POST_REMOVE: 'remove',
|
POST_REMOVE: 'remove',
|
||||||
@@ -305,6 +304,7 @@ def generate_data(username, request, login_type=None):
|
|||||||
@receiver(post_auth_success)
|
@receiver(post_auth_success)
|
||||||
def on_user_auth_success(sender, user, request, login_type=None, **kwargs):
|
def on_user_auth_success(sender, user, request, login_type=None, **kwargs):
|
||||||
logger.debug('User login success: {}'.format(user.username))
|
logger.debug('User login success: {}'.format(user.username))
|
||||||
|
check_different_city_login_if_need(user, request)
|
||||||
data = generate_data(user.username, request, login_type=login_type)
|
data = generate_data(user.username, request, login_type=login_type)
|
||||||
data.update({'mfa': int(user.mfa_enabled), 'status': True})
|
data.update({'mfa': int(user.mfa_enabled), 'status': True})
|
||||||
write_login_log(**data)
|
write_login_log(**data)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import csv
|
import csv
|
||||||
import codecs
|
import codecs
|
||||||
from django.http import HttpResponse
|
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
|
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):
|
def write_login_log(*args, **kwargs):
|
||||||
from audits.models import UserLoginLog
|
from audits.models import UserLoginLog
|
||||||
default_city = _("Unknown")
|
|
||||||
ip = kwargs.get('ip') or ''
|
ip = kwargs.get('ip') or ''
|
||||||
if not (ip and validate_ip(ip)):
|
if not (ip and validate_ip(ip)):
|
||||||
ip = ip[:15]
|
ip = ip[:15]
|
||||||
city = default_city
|
city = DEFAULT_CITY
|
||||||
else:
|
else:
|
||||||
city = get_ip_city(ip) or default_city
|
city = get_ip_city(ip) or DEFAULT_CITY
|
||||||
kwargs.update({'ip': ip, 'city': city})
|
kwargs.update({'ip': ip, 'city': city})
|
||||||
UserLoginLog.objects.create(**kwargs)
|
UserLoginLog.objects.create(**kwargs)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import urllib.parse
|
|||||||
import json
|
import json
|
||||||
import base64
|
import base64
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
import os
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
@@ -19,11 +20,12 @@ from rest_framework import serializers
|
|||||||
|
|
||||||
from authentication.signals import post_auth_failed, post_auth_success
|
from authentication.signals import post_auth_failed, post_auth_success
|
||||||
from common.utils import get_logger, random_string
|
from common.utils import get_logger, random_string
|
||||||
from common.drf.api import SerializerMixin
|
from common.mixins.api import SerializerMixin
|
||||||
from common.permissions import IsSuperUserOrAppUser, IsValidUser, IsSuperUser
|
from common.permissions import IsSuperUserOrAppUser, IsValidUser, IsSuperUser
|
||||||
from orgs.mixins.api import RootOrgViewMixin
|
from orgs.mixins.api import RootOrgViewMixin
|
||||||
from common.http import is_true
|
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 ..serializers import (
|
from ..serializers import (
|
||||||
ConnectionTokenSerializer, ConnectionTokenSecretSerializer,
|
ConnectionTokenSerializer, ConnectionTokenSecretSerializer,
|
||||||
@@ -48,6 +50,10 @@ class ClientProtocolMixin:
|
|||||||
user = self.request.user
|
user = self.request.user
|
||||||
return asset, application, system_user, user
|
return asset, application, system_user, user
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_env_bool(env_key, env_default, true_value, false_value):
|
||||||
|
return true_value if is_true(os.getenv(env_key, env_default)) else false_value
|
||||||
|
|
||||||
def get_rdp_file_content(self, serializer):
|
def get_rdp_file_content(self, serializer):
|
||||||
options = {
|
options = {
|
||||||
'full address:s': '',
|
'full address:s': '',
|
||||||
@@ -89,21 +95,35 @@ class ClientProtocolMixin:
|
|||||||
drives_redirect = is_true(self.request.query_params.get('drives_redirect'))
|
drives_redirect = is_true(self.request.query_params.get('drives_redirect'))
|
||||||
token = self.create_token(user, asset, application, system_user)
|
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, 0)
|
||||||
|
if actions & Action.UPDOWNLOAD:
|
||||||
|
options['drivestoredirect:s'] = '*'
|
||||||
|
|
||||||
|
# 全屏
|
||||||
options['screen mode id:i'] = '2' if full_screen else '1'
|
options['screen mode id:i'] = '2' if full_screen else '1'
|
||||||
|
|
||||||
|
# RDP Server 地址
|
||||||
address = settings.TERMINAL_RDP_ADDR
|
address = settings.TERMINAL_RDP_ADDR
|
||||||
if not address or address == 'localhost:3389':
|
if not address or address == 'localhost:3389':
|
||||||
address = self.request.get_host().split(':')[0] + ':3389'
|
address = self.request.get_host().split(':')[0] + ':3389'
|
||||||
options['full address:s'] = address
|
options['full address:s'] = address
|
||||||
|
# 用户名
|
||||||
options['username:s'] = '{}|{}'.format(user.username, token)
|
options['username:s'] = '{}|{}'.format(user.username, token)
|
||||||
if system_user.ad_domain:
|
if system_user.ad_domain:
|
||||||
options['domain:s'] = system_user.ad_domain
|
options['domain:s'] = system_user.ad_domain
|
||||||
|
# 宽高
|
||||||
if width and height:
|
if width and height:
|
||||||
options['desktopwidth:i'] = width
|
options['desktopwidth:i'] = width
|
||||||
options['desktopheight:i'] = height
|
options['desktopheight:i'] = height
|
||||||
else:
|
else:
|
||||||
options['smart sizing:i'] = '1'
|
options['smart sizing:i'] = '1'
|
||||||
|
|
||||||
|
options['session bpp:i'] = os.getenv('JUMPSERVER_COLOR_DEPTH', '32')
|
||||||
|
options['audiomode:i'] = self.parse_env_bool('JUMPSERVER_DISABLE_AUDIO', 'false', '2', '0')
|
||||||
|
|
||||||
content = ''
|
content = ''
|
||||||
for k, v in options.items():
|
for k, v in options.items():
|
||||||
content += f'{k}:{v}\n'
|
content += f'{k}:{v}\n'
|
||||||
@@ -142,15 +162,22 @@ class ClientProtocolMixin:
|
|||||||
def get_client_protocol_data(self, serializer):
|
def get_client_protocol_data(self, serializer):
|
||||||
asset, application, system_user, user = self.get_request_resource(serializer)
|
asset, application, system_user, user = self.get_request_resource(serializer)
|
||||||
protocol = system_user.protocol
|
protocol = system_user.protocol
|
||||||
|
username = user.username
|
||||||
|
|
||||||
if protocol == 'rdp':
|
if protocol == 'rdp':
|
||||||
name, config = self.get_rdp_file_content(serializer)
|
name, config = self.get_rdp_file_content(serializer)
|
||||||
elif protocol == 'vnc':
|
elif protocol == 'ssh':
|
||||||
raise HttpResponse(status=404, data={"error": "VNC not support"})
|
# Todo:
|
||||||
else:
|
name = ''
|
||||||
config = 'ssh://system_user@asset@user@jumpserver-ssh'
|
config = 'ssh://system_user@asset@user@jumpserver-ssh'
|
||||||
|
else:
|
||||||
|
raise ValueError('Protocol not support: {}'.format(protocol))
|
||||||
|
|
||||||
|
filename = "{}-{}-jumpserver".format(username, name)
|
||||||
data = {
|
data = {
|
||||||
|
"filename": filename,
|
||||||
"protocol": system_user.protocol,
|
"protocol": system_user.protocol,
|
||||||
"username": user.username,
|
"username": username,
|
||||||
"config": config
|
"config": config
|
||||||
}
|
}
|
||||||
return data
|
return data
|
||||||
@@ -158,8 +185,13 @@ class ClientProtocolMixin:
|
|||||||
@action(methods=['POST', 'GET'], detail=False, url_path='client-url', permission_classes=[IsValidUser])
|
@action(methods=['POST', 'GET'], detail=False, url_path='client-url', permission_classes=[IsValidUser])
|
||||||
def get_client_protocol_url(self, request, *args, **kwargs):
|
def get_client_protocol_url(self, request, *args, **kwargs):
|
||||||
serializer = self.get_valid_serializer()
|
serializer = self.get_valid_serializer()
|
||||||
protocol_data = self.get_client_protocol_data(serializer)
|
try:
|
||||||
protocol_data = base64.b64encode(json.dumps(protocol_data).encode()).decode()
|
protocol_data = self.get_client_protocol_data(serializer)
|
||||||
|
except ValueError as e:
|
||||||
|
return Response({'error': str(e)}, status=401)
|
||||||
|
|
||||||
|
protocol_data = json.dumps(protocol_data).encode()
|
||||||
|
protocol_data = base64.b64encode(protocol_data).decode()
|
||||||
data = {
|
data = {
|
||||||
'url': 'jms://{}'.format(protocol_data),
|
'url': 'jms://{}'.format(protocol_data),
|
||||||
}
|
}
|
||||||
@@ -327,14 +359,12 @@ class UserConnectionTokenViewSet(
|
|||||||
raise serializers.ValidationError("User not valid, disabled or expired")
|
raise serializers.ValidationError("User not valid, disabled or expired")
|
||||||
|
|
||||||
system_user = get_object_or_404(SystemUser, id=value.get('system_user'))
|
system_user = get_object_or_404(SystemUser, id=value.get('system_user'))
|
||||||
|
|
||||||
asset = None
|
asset = None
|
||||||
app = None
|
app = None
|
||||||
if value.get('type') == 'asset':
|
if value.get('type') == 'asset':
|
||||||
asset = get_object_or_404(Asset, id=value.get('asset'))
|
asset = get_object_or_404(Asset, id=value.get('asset'))
|
||||||
if not asset.is_active:
|
if not asset.is_active:
|
||||||
raise serializers.ValidationError("Asset disabled")
|
raise serializers.ValidationError("Asset disabled")
|
||||||
|
|
||||||
has_perm, expired_at = asset_validate_permission(user, asset, system_user, 'connect')
|
has_perm, expired_at = asset_validate_permission(user, asset, system_user, 'connect')
|
||||||
else:
|
else:
|
||||||
app = get_object_or_404(Application, id=value.get('application'))
|
app = get_object_or_404(Application, id=value.get('application'))
|
||||||
|
|||||||
@@ -8,29 +8,12 @@ from django.shortcuts import get_object_or_404
|
|||||||
|
|
||||||
from common.utils import get_logger
|
from common.utils import get_logger
|
||||||
from common.permissions import IsOrgAdmin
|
from common.permissions import IsOrgAdmin
|
||||||
from ..models import LoginConfirmSetting
|
|
||||||
from ..serializers import LoginConfirmSettingSerializer
|
|
||||||
from .. import errors, mixins
|
from .. import errors, mixins
|
||||||
|
|
||||||
__all__ = ['LoginConfirmSettingUpdateApi', 'TicketStatusApi']
|
__all__ = ['TicketStatusApi']
|
||||||
logger = get_logger(__name__)
|
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):
|
class TicketStatusApi(mixins.AuthMixin, APIView):
|
||||||
permission_classes = (AllowAny,)
|
permission_classes = (AllowAny,)
|
||||||
|
|
||||||
|
|||||||
@@ -1,66 +1,86 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
#
|
#
|
||||||
import builtins
|
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from django.utils.translation import ugettext as _
|
from django.utils.translation import ugettext as _
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
from rest_framework.permissions import AllowAny
|
from rest_framework.permissions import AllowAny
|
||||||
from rest_framework.generics import CreateAPIView
|
from rest_framework.generics import CreateAPIView
|
||||||
from rest_framework.serializers import ValidationError
|
from rest_framework.serializers import ValidationError
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
from authentication.sms_verify_code import VerifyCodeUtil
|
from common.permissions import IsValidUser, NeedMFAVerify
|
||||||
from common.exceptions import JMSException
|
from common.utils import get_logger
|
||||||
from common.permissions import IsValidUser, NeedMFAVerify, IsAppUser
|
from users.models.user import User
|
||||||
from users.models.user import MFAType
|
|
||||||
from ..serializers import OtpVerifySerializer
|
from ..serializers import OtpVerifySerializer
|
||||||
from .. import serializers
|
from .. import serializers
|
||||||
from .. import errors
|
from .. import errors
|
||||||
|
from ..mfa.otp import MFAOtp
|
||||||
from ..mixins import AuthMixin
|
from ..mixins import AuthMixin
|
||||||
|
|
||||||
|
|
||||||
__all__ = ['MFAChallengeApi', 'UserOtpVerifyApi', 'SendSMSVerifyCodeApi', 'MFASelectTypeApi']
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'MFAChallengeVerifyApi', 'UserOtpVerifyApi',
|
||||||
|
'MFASendCodeApi'
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class MFASelectTypeApi(AuthMixin, CreateAPIView):
|
# MFASelectAPi 原来的名字
|
||||||
|
class MFASendCodeApi(AuthMixin, CreateAPIView):
|
||||||
|
"""
|
||||||
|
选择 MFA 后对应操作 api,koko 目前在用
|
||||||
|
"""
|
||||||
permission_classes = (AllowAny,)
|
permission_classes = (AllowAny,)
|
||||||
serializer_class = serializers.MFASelectTypeSerializer
|
serializer_class = serializers.MFASelectTypeSerializer
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
|
username = serializer.validated_data.get('username', '')
|
||||||
mfa_type = serializer.validated_data['type']
|
mfa_type = serializer.validated_data['type']
|
||||||
if mfa_type == MFAType.SMS_CODE:
|
if not username:
|
||||||
user = self.get_user_from_session()
|
user = self.get_user_from_session()
|
||||||
user.send_sms_code()
|
else:
|
||||||
|
user = get_object_or_404(User, username=username)
|
||||||
|
|
||||||
|
mfa_backend = user.get_active_mfa_backend_by_type(mfa_type)
|
||||||
|
if not mfa_backend or not mfa_backend.challenge_required:
|
||||||
|
raise ValidationError('MFA type not support: {} {}'.format(mfa_type, mfa_backend))
|
||||||
|
mfa_backend.send_challenge()
|
||||||
|
|
||||||
|
def create(self, request, *args, **kwargs):
|
||||||
|
serializer = self.get_serializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.perform_create(serializer)
|
||||||
|
return Response(serializer.data, status=201)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(e)
|
||||||
|
return Response({'error': str(e)}, status=400)
|
||||||
|
|
||||||
|
|
||||||
class MFAChallengeApi(AuthMixin, CreateAPIView):
|
class MFAChallengeVerifyApi(AuthMixin, CreateAPIView):
|
||||||
permission_classes = (AllowAny,)
|
permission_classes = (AllowAny,)
|
||||||
serializer_class = serializers.MFAChallengeSerializer
|
serializer_class = serializers.MFAChallengeSerializer
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
try:
|
user = self.get_user_from_session()
|
||||||
user = self.get_user_from_session()
|
code = serializer.validated_data.get('code')
|
||||||
code = serializer.validated_data.get('code')
|
mfa_type = serializer.validated_data.get('type', '')
|
||||||
mfa_type = serializer.validated_data.get('type', MFAType.OTP)
|
self._do_check_user_mfa(code, mfa_type, user)
|
||||||
|
|
||||||
valid = user.check_mfa(code, mfa_type=mfa_type)
|
def create(self, request, *args, **kwargs):
|
||||||
if not valid:
|
try:
|
||||||
self.request.session['auth_mfa'] = ''
|
super().create(request, *args, **kwargs)
|
||||||
raise errors.MFAFailedError(
|
return Response({'msg': 'ok'})
|
||||||
username=user.username, request=self.request, ip=self.get_request_ip()
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.request.session['auth_mfa'] = '1'
|
|
||||||
except errors.AuthFailedError as e:
|
except errors.AuthFailedError as e:
|
||||||
data = {"error": e.error, "msg": e.msg}
|
data = {"error": e.error, "msg": e.msg}
|
||||||
raise ValidationError(data)
|
raise ValidationError(data)
|
||||||
except errors.NeedMoreInfoError as e:
|
except errors.NeedMoreInfoError as e:
|
||||||
return Response(e.as_data(), status=200)
|
return Response(e.as_data(), status=200)
|
||||||
|
|
||||||
def create(self, request, *args, **kwargs):
|
|
||||||
super().create(request, *args, **kwargs)
|
|
||||||
return Response({'msg': 'ok'})
|
|
||||||
|
|
||||||
|
|
||||||
class UserOtpVerifyApi(CreateAPIView):
|
class UserOtpVerifyApi(CreateAPIView):
|
||||||
permission_classes = (IsValidUser,)
|
permission_classes = (IsValidUser,)
|
||||||
@@ -73,23 +93,17 @@ class UserOtpVerifyApi(CreateAPIView):
|
|||||||
serializer = self.get_serializer(data=request.data)
|
serializer = self.get_serializer(data=request.data)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
code = serializer.validated_data["code"]
|
code = serializer.validated_data["code"]
|
||||||
|
otp = MFAOtp(request.user)
|
||||||
|
|
||||||
if request.user.check_mfa(code):
|
ok, error = otp.check_code(code)
|
||||||
|
if ok:
|
||||||
request.session["MFA_VERIFY_TIME"] = int(time.time())
|
request.session["MFA_VERIFY_TIME"] = int(time.time())
|
||||||
return Response({"ok": "1"})
|
return Response({"ok": "1"})
|
||||||
else:
|
else:
|
||||||
return Response({"error": _("Code is invalid")}, status=400)
|
return Response({"error": _("Code is invalid, {}").format(error)}, status=400)
|
||||||
|
|
||||||
def get_permissions(self):
|
def get_permissions(self):
|
||||||
if self.request.method.lower() == 'get' and settings.SECURITY_VIEW_AUTH_NEED_MFA:
|
if self.request.method.lower() == 'get' \
|
||||||
|
and settings.SECURITY_VIEW_AUTH_NEED_MFA:
|
||||||
self.permission_classes = [NeedMFAVerify]
|
self.permission_classes = [NeedMFAVerify]
|
||||||
return super().get_permissions()
|
return super().get_permissions()
|
||||||
|
|
||||||
|
|
||||||
class SendSMSVerifyCodeApi(AuthMixin, CreateAPIView):
|
|
||||||
permission_classes = (AllowAny,)
|
|
||||||
|
|
||||||
def create(self, request, *args, **kwargs):
|
|
||||||
user = self.get_user_from_session()
|
|
||||||
timeout = user.send_sms_code()
|
|
||||||
return Response({'code': 'ok','timeout': timeout})
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from rest_framework.response import Response
|
|||||||
from authentication.serializers import PasswordVerifySerializer
|
from authentication.serializers import PasswordVerifySerializer
|
||||||
from common.permissions import IsValidUser
|
from common.permissions import IsValidUser
|
||||||
from authentication.mixins import authenticate
|
from authentication.mixins import authenticate
|
||||||
from authentication.errors import PasswdInvalid
|
from authentication.errors import PasswordInvalid
|
||||||
from authentication.mixins import AuthMixin
|
from authentication.mixins import AuthMixin
|
||||||
|
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ class UserPasswordVerifyApi(AuthMixin, CreateAPIView):
|
|||||||
|
|
||||||
user = authenticate(request=request, username=user.username, password=password)
|
user = authenticate(request=request, username=user.username, password=password)
|
||||||
if not user:
|
if not user:
|
||||||
raise PasswdInvalid
|
raise PasswordInvalid
|
||||||
|
|
||||||
self.set_passwd_verify_on_session(user)
|
self.mark_password_ok(user)
|
||||||
return Response()
|
return Response()
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from rest_framework.response import Response
|
|||||||
from rest_framework.request import Request
|
from rest_framework.request import Request
|
||||||
from rest_framework.permissions import AllowAny
|
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.const.http import POST, GET
|
||||||
from common.drf.api import JMSGenericViewSet
|
from common.drf.api import JMSGenericViewSet
|
||||||
from common.drf.serializers import EmptySerializer
|
from common.drf.serializers import EmptySerializer
|
||||||
@@ -79,7 +79,7 @@ class SSOViewSet(AuthMixin, JMSGenericViewSet):
|
|||||||
return HttpResponseRedirect(next_url)
|
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')
|
self.send_auth_signal(success=False, reason='authkey_timeout')
|
||||||
return HttpResponseRedirect(next_url)
|
return HttpResponseRedirect(next_url)
|
||||||
|
|
||||||
|
|||||||
@@ -33,12 +33,12 @@ class TokenCreateApi(AuthMixin, CreateAPIView):
|
|||||||
self.check_user_mfa_if_need(user)
|
self.check_user_mfa_if_need(user)
|
||||||
self.check_user_login_confirm_if_need(user)
|
self.check_user_login_confirm_if_need(user)
|
||||||
self.send_auth_signal(success=True, user=user)
|
self.send_auth_signal(success=True, user=user)
|
||||||
self.clear_auth_mark()
|
|
||||||
resp = super().create(request, *args, **kwargs)
|
resp = super().create(request, *args, **kwargs)
|
||||||
|
self.clear_auth_mark()
|
||||||
return resp
|
return resp
|
||||||
except errors.AuthFailedError as e:
|
except errors.AuthFailedError as e:
|
||||||
return Response(e.as_data(), status=400)
|
return Response(e.as_data(), status=400)
|
||||||
except errors.NeedMoreInfoError as e:
|
except errors.NeedMoreInfoError as e:
|
||||||
return Response(e.as_data(), status=200)
|
return Response(e.as_data(), status=200)
|
||||||
except errors.PasswdTooSimple as e:
|
except errors.PasswordTooSimple as e:
|
||||||
return redirect(e.url)
|
return redirect(e.url)
|
||||||
|
|||||||
@@ -6,5 +6,6 @@ class AuthenticationConfig(AppConfig):
|
|||||||
|
|
||||||
def ready(self):
|
def ready(self):
|
||||||
from . import signals_handlers
|
from . import signals_handlers
|
||||||
|
from . import notifications
|
||||||
super().ready()
|
super().ready()
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,11 @@
|
|||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from rest_framework import status
|
||||||
|
|
||||||
from authentication import sms_verify_code
|
|
||||||
from common.exceptions import JMSException
|
from common.exceptions import JMSException
|
||||||
from .signals import post_auth_failed
|
from .signals import post_auth_failed
|
||||||
from users.utils import LoginBlockUtil, MFABlockUtils
|
from users.utils import LoginBlockUtil, MFABlockUtils, LoginIpBlockUtil
|
||||||
from users.models import MFAType
|
|
||||||
|
|
||||||
reason_password_failed = 'password_failed'
|
reason_password_failed = 'password_failed'
|
||||||
reason_password_decrypt_failed = 'password_decrypt_failed'
|
reason_password_decrypt_failed = 'password_decrypt_failed'
|
||||||
@@ -52,30 +51,23 @@ invalid_login_msg = _(
|
|||||||
"You can also try {times_try} times "
|
"You can also try {times_try} times "
|
||||||
"(The account will be temporarily locked for {block_time} minutes)"
|
"(The account will be temporarily locked for {block_time} minutes)"
|
||||||
)
|
)
|
||||||
block_login_msg = _(
|
block_user_login_msg = _(
|
||||||
"The account has been locked "
|
"The account has been locked "
|
||||||
"(please contact admin to unlock it or try again after {} minutes)"
|
"(please contact admin to unlock it or try again after {} minutes)"
|
||||||
)
|
)
|
||||||
|
block_ip_login_msg = _(
|
||||||
|
"The ip has been locked "
|
||||||
|
"(please contact admin to unlock it or try again after {} minutes)"
|
||||||
|
)
|
||||||
block_mfa_msg = _(
|
block_mfa_msg = _(
|
||||||
"The account has been locked "
|
"The account has been locked "
|
||||||
"(please contact admin to unlock it or try again after {} minutes)"
|
"(please contact admin to unlock it or try again after {} minutes)"
|
||||||
)
|
)
|
||||||
otp_failed_msg = _(
|
mfa_error_msg = _(
|
||||||
"One-time password invalid, or ntp sync server time, "
|
"{error}, "
|
||||||
"You can also try {times_try} times "
|
"You can also try {times_try} times "
|
||||||
"(The account will be temporarily locked for {block_time} minutes)"
|
"(The account will be temporarily locked for {block_time} minutes)"
|
||||||
)
|
)
|
||||||
sms_failed_msg = _(
|
|
||||||
"SMS verify code invalid,"
|
|
||||||
"You can also try {times_try} times "
|
|
||||||
"(The account will be temporarily locked for {block_time} minutes)"
|
|
||||||
)
|
|
||||||
mfa_type_failed_msg = _(
|
|
||||||
"The MFA type({mfa_type}) is not supported, "
|
|
||||||
"You can also try {times_try} times "
|
|
||||||
"(The account will be temporarily locked for {block_time} minutes)"
|
|
||||||
)
|
|
||||||
|
|
||||||
mfa_required_msg = _("MFA required")
|
mfa_required_msg = _("MFA required")
|
||||||
mfa_unset_msg = _("MFA not set, please set it first")
|
mfa_unset_msg = _("MFA not set, please set it first")
|
||||||
login_confirm_required_msg = _("Login confirm required")
|
login_confirm_required_msg = _("Login confirm required")
|
||||||
@@ -126,13 +118,28 @@ class AuthFailedError(Exception):
|
|||||||
return str(self.msg)
|
return str(self.msg)
|
||||||
|
|
||||||
|
|
||||||
class CredentialError(AuthFailedNeedLogMixin, AuthFailedNeedBlockMixin, AuthFailedError):
|
class BlockGlobalIpLoginError(AuthFailedError):
|
||||||
|
error = 'block_global_ip_login'
|
||||||
|
|
||||||
|
def __init__(self, username, ip, **kwargs):
|
||||||
|
self.msg = block_ip_login_msg.format(settings.SECURITY_LOGIN_IP_LIMIT_TIME)
|
||||||
|
LoginIpBlockUtil(ip).set_block_if_need()
|
||||||
|
super().__init__(username=username, ip=ip, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class CredentialError(
|
||||||
|
AuthFailedNeedLogMixin, AuthFailedNeedBlockMixin, BlockGlobalIpLoginError, AuthFailedError
|
||||||
|
):
|
||||||
def __init__(self, error, username, ip, request):
|
def __init__(self, error, username, ip, request):
|
||||||
super().__init__(error=error, username=username, ip=ip, request=request)
|
super().__init__(error=error, username=username, ip=ip, request=request)
|
||||||
util = LoginBlockUtil(username, ip)
|
util = LoginBlockUtil(username, ip)
|
||||||
times_remainder = util.get_remainder_times()
|
times_remainder = util.get_remainder_times()
|
||||||
block_time = settings.SECURITY_LOGIN_LIMIT_TIME
|
block_time = settings.SECURITY_LOGIN_LIMIT_TIME
|
||||||
|
|
||||||
|
if times_remainder < 1:
|
||||||
|
self.msg = block_user_login_msg.format(settings.SECURITY_LOGIN_LIMIT_TIME)
|
||||||
|
return
|
||||||
|
|
||||||
default_msg = invalid_login_msg.format(
|
default_msg = invalid_login_msg.format(
|
||||||
times_try=times_remainder, block_time=block_time
|
times_try=times_remainder, block_time=block_time
|
||||||
)
|
)
|
||||||
@@ -146,29 +153,19 @@ class MFAFailedError(AuthFailedNeedLogMixin, AuthFailedError):
|
|||||||
error = reason_mfa_failed
|
error = reason_mfa_failed
|
||||||
msg: str
|
msg: str
|
||||||
|
|
||||||
def __init__(self, username, request, ip, mfa_type=MFAType.OTP):
|
def __init__(self, username, request, ip, mfa_type, error):
|
||||||
util = MFABlockUtils(username, ip)
|
super().__init__(username=username, request=request)
|
||||||
util.incr_failed_count()
|
|
||||||
|
|
||||||
times_remainder = util.get_remainder_times()
|
util = MFABlockUtils(username, ip)
|
||||||
|
times_remainder = util.incr_failed_count()
|
||||||
block_time = settings.SECURITY_LOGIN_LIMIT_TIME
|
block_time = settings.SECURITY_LOGIN_LIMIT_TIME
|
||||||
|
|
||||||
if times_remainder:
|
if times_remainder:
|
||||||
if mfa_type == MFAType.OTP:
|
self.msg = mfa_error_msg.format(
|
||||||
self.msg = otp_failed_msg.format(
|
error=error, times_try=times_remainder, block_time=block_time
|
||||||
times_try=times_remainder, block_time=block_time
|
)
|
||||||
)
|
|
||||||
elif mfa_type == MFAType.SMS_CODE:
|
|
||||||
self.msg = sms_failed_msg.format(
|
|
||||||
times_try=times_remainder, block_time=block_time
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.msg = mfa_type_failed_msg.format(
|
|
||||||
mfa_type=mfa_type, times_try=times_remainder, block_time=block_time
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
self.msg = block_mfa_msg.format(settings.SECURITY_LOGIN_LIMIT_TIME)
|
self.msg = block_mfa_msg.format(settings.SECURITY_LOGIN_LIMIT_TIME)
|
||||||
super().__init__(username=username, request=request)
|
|
||||||
|
|
||||||
|
|
||||||
class BlockMFAError(AuthFailedNeedLogMixin, AuthFailedError):
|
class BlockMFAError(AuthFailedNeedLogMixin, AuthFailedError):
|
||||||
@@ -179,13 +176,11 @@ class BlockMFAError(AuthFailedNeedLogMixin, AuthFailedError):
|
|||||||
super().__init__(username=username, request=request, ip=ip)
|
super().__init__(username=username, request=request, ip=ip)
|
||||||
|
|
||||||
|
|
||||||
class MFAUnsetError(AuthFailedNeedLogMixin, AuthFailedError):
|
class MFAUnsetError(Exception):
|
||||||
error = reason_mfa_unset
|
error = reason_mfa_unset
|
||||||
msg = mfa_unset_msg
|
msg = mfa_unset_msg
|
||||||
|
|
||||||
def __init__(self, user, request, url):
|
def __init__(self, user, request, url):
|
||||||
super().__init__(username=user.username, request=request)
|
|
||||||
self.user = user
|
|
||||||
self.url = url
|
self.url = url
|
||||||
|
|
||||||
|
|
||||||
@@ -193,7 +188,7 @@ class BlockLoginError(AuthFailedNeedBlockMixin, AuthFailedError):
|
|||||||
error = 'block_login'
|
error = 'block_login'
|
||||||
|
|
||||||
def __init__(self, username, ip):
|
def __init__(self, username, ip):
|
||||||
self.msg = block_login_msg.format(settings.SECURITY_LOGIN_LIMIT_TIME)
|
self.msg = block_user_login_msg.format(settings.SECURITY_LOGIN_LIMIT_TIME)
|
||||||
super().__init__(username=username, ip=ip)
|
super().__init__(username=username, ip=ip)
|
||||||
|
|
||||||
|
|
||||||
@@ -223,7 +218,7 @@ class MFARequiredError(NeedMoreInfoError):
|
|||||||
msg = mfa_required_msg
|
msg = mfa_required_msg
|
||||||
error = 'mfa_required'
|
error = 'mfa_required'
|
||||||
|
|
||||||
def __init__(self, error='', msg='', mfa_types=tuple(MFAType)):
|
def __init__(self, error='', msg='', mfa_types=()):
|
||||||
super().__init__(error=error, msg=msg)
|
super().__init__(error=error, msg=msg)
|
||||||
self.choices = mfa_types
|
self.choices = mfa_types
|
||||||
|
|
||||||
@@ -260,6 +255,13 @@ class LoginIPNotAllowed(ACLError):
|
|||||||
super().__init__(_("IP is not allowed"), **kwargs)
|
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):
|
class LoginConfirmBaseError(NeedMoreInfoError):
|
||||||
def __init__(self, ticket_id, **kwargs):
|
def __init__(self, ticket_id, **kwargs):
|
||||||
self.ticket_id = ticket_id
|
self.ticket_id = ticket_id
|
||||||
@@ -293,7 +295,7 @@ class SSOAuthClosed(JMSException):
|
|||||||
default_detail = _('SSO auth closed')
|
default_detail = _('SSO auth closed')
|
||||||
|
|
||||||
|
|
||||||
class PasswdTooSimple(JMSException):
|
class PasswordTooSimple(JMSException):
|
||||||
default_code = 'passwd_too_simple'
|
default_code = 'passwd_too_simple'
|
||||||
default_detail = _('Your password is too simple, please change it for security')
|
default_detail = _('Your password is too simple, please change it for security')
|
||||||
|
|
||||||
@@ -302,7 +304,7 @@ class PasswdTooSimple(JMSException):
|
|||||||
self.url = url
|
self.url = url
|
||||||
|
|
||||||
|
|
||||||
class PasswdNeedUpdate(JMSException):
|
class PasswordNeedUpdate(JMSException):
|
||||||
default_code = 'passwd_need_update'
|
default_code = 'passwd_need_update'
|
||||||
default_detail = _('You should to change your password before login')
|
default_detail = _('You should to change your password before login')
|
||||||
|
|
||||||
@@ -345,6 +347,21 @@ class FeiShuNotBound(JMSException):
|
|||||||
default_detail = 'FeiShu is not bound'
|
default_detail = 'FeiShu is not bound'
|
||||||
|
|
||||||
|
|
||||||
class PasswdInvalid(JMSException):
|
class PasswordInvalid(JMSException):
|
||||||
default_code = 'passwd_invalid'
|
default_code = 'passwd_invalid'
|
||||||
default_detail = _('Your password is invalid')
|
default_detail = _('Your password is invalid')
|
||||||
|
|
||||||
|
|
||||||
|
class MFACodeRequiredError(AuthFailedError):
|
||||||
|
error = 'mfa_code_required'
|
||||||
|
msg = _("Please enter MFA code")
|
||||||
|
|
||||||
|
|
||||||
|
class SMSCodeRequiredError(AuthFailedError):
|
||||||
|
error = 'sms_code_required'
|
||||||
|
msg = _("Please enter SMS code")
|
||||||
|
|
||||||
|
|
||||||
|
class UserPhoneNotSet(AuthFailedError):
|
||||||
|
error = 'phone_not_set'
|
||||||
|
msg = _('Phone not set')
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ class UserLoginForm(forms.Form):
|
|||||||
|
|
||||||
|
|
||||||
class UserCheckOtpCodeForm(forms.Form):
|
class UserCheckOtpCodeForm(forms.Form):
|
||||||
code = forms.CharField(label=_('MFA Code'), max_length=6)
|
code = forms.CharField(label=_('MFA Code'), max_length=128, required=False)
|
||||||
mfa_type = forms.CharField(label=_('MFA type'), max_length=6)
|
mfa_type = forms.CharField(label=_('MFA type'), max_length=128)
|
||||||
|
|
||||||
|
|
||||||
class CustomCaptchaTextInput(CaptchaTextInput):
|
class CustomCaptchaTextInput(CaptchaTextInput):
|
||||||
@@ -57,9 +57,9 @@ class CaptchaMixin(forms.Form):
|
|||||||
|
|
||||||
class ChallengeMixin(forms.Form):
|
class ChallengeMixin(forms.Form):
|
||||||
challenge = forms.CharField(
|
challenge = forms.CharField(
|
||||||
label=_('MFA code'), max_length=6, required=False,
|
label=_('MFA code'), max_length=128, required=False,
|
||||||
widget=forms.TextInput(attrs={
|
widget=forms.TextInput(attrs={
|
||||||
'placeholder': _("MFA code"),
|
'placeholder': _("Dynamic code"),
|
||||||
'style': 'width: 50%'
|
'style': 'width: 50%'
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -69,6 +69,8 @@ def get_user_login_form_cls(*, captcha=False):
|
|||||||
bases = []
|
bases = []
|
||||||
if settings.SECURITY_LOGIN_CHALLENGE_ENABLED:
|
if settings.SECURITY_LOGIN_CHALLENGE_ENABLED:
|
||||||
bases.append(ChallengeMixin)
|
bases.append(ChallengeMixin)
|
||||||
|
elif settings.SECURITY_MFA_IN_LOGIN_PAGE:
|
||||||
|
bases.append(UserCheckOtpCodeForm)
|
||||||
elif settings.SECURITY_LOGIN_CAPTCHA_ENABLED and captcha:
|
elif settings.SECURITY_LOGIN_CAPTCHA_ENABLED and captcha:
|
||||||
bases.append(CaptchaMixin)
|
bases.append(CaptchaMixin)
|
||||||
bases.append(UserLoginForm)
|
bases.append(UserLoginForm)
|
||||||
|
|||||||
5
apps/authentication/mfa/__init__.py
Normal file
5
apps/authentication/mfa/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from .otp import MFAOtp, otp_failed_msg
|
||||||
|
from .sms import MFASms
|
||||||
|
from .radius import MFARadius
|
||||||
|
|
||||||
|
MFA_BACKENDS = [MFAOtp, MFASms, MFARadius]
|
||||||
72
apps/authentication/mfa/base.py
Normal file
72
apps/authentication/mfa/base.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import abc
|
||||||
|
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
|
|
||||||
|
class BaseMFA(abc.ABC):
|
||||||
|
placeholder = _('Please input security code')
|
||||||
|
|
||||||
|
def __init__(self, user):
|
||||||
|
"""
|
||||||
|
:param user: Authenticated user, Anonymous or None
|
||||||
|
因为首页登录时,可能没法获取到一些状态
|
||||||
|
"""
|
||||||
|
self.user = user
|
||||||
|
|
||||||
|
def is_authenticated(self):
|
||||||
|
return self.user and self.user.is_authenticated
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abc.abstractmethod
|
||||||
|
def name(self):
|
||||||
|
return ''
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abc.abstractmethod
|
||||||
|
def display_name(self):
|
||||||
|
return ''
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def challenge_required():
|
||||||
|
return False
|
||||||
|
|
||||||
|
def send_challenge(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def check_code(self, code) -> tuple:
|
||||||
|
return False, 'Error msg'
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def is_active(self):
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@abc.abstractmethod
|
||||||
|
def global_enabled():
|
||||||
|
return False
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def get_enable_url(self) -> str:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def get_disable_url(self) -> str:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def disable(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def can_disable(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def help_text_of_enable():
|
||||||
|
return ''
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def help_text_of_disable():
|
||||||
|
return ''
|
||||||
|
|
||||||
52
apps/authentication/mfa/otp.py
Normal file
52
apps/authentication/mfa/otp.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django.shortcuts import reverse
|
||||||
|
|
||||||
|
from .base import BaseMFA
|
||||||
|
|
||||||
|
|
||||||
|
otp_failed_msg = _("OTP code invalid, or server time error")
|
||||||
|
|
||||||
|
|
||||||
|
class MFAOtp(BaseMFA):
|
||||||
|
name = 'otp'
|
||||||
|
display_name = _('OTP')
|
||||||
|
placeholder = _('OTP verification code')
|
||||||
|
|
||||||
|
def check_code(self, code):
|
||||||
|
from users.utils import check_otp_code
|
||||||
|
assert self.is_authenticated()
|
||||||
|
|
||||||
|
ok = check_otp_code(self.user.otp_secret_key, code)
|
||||||
|
msg = '' if ok else otp_failed_msg
|
||||||
|
return ok, msg
|
||||||
|
|
||||||
|
def is_active(self):
|
||||||
|
if not self.is_authenticated():
|
||||||
|
return True
|
||||||
|
return self.user.otp_secret_key
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def global_enabled():
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_enable_url(self) -> str:
|
||||||
|
return reverse('authentication:user-otp-enable-start')
|
||||||
|
|
||||||
|
def disable(self):
|
||||||
|
assert self.is_authenticated()
|
||||||
|
self.user.otp_secret_key = ''
|
||||||
|
self.user.save(update_fields=['otp_secret_key'])
|
||||||
|
|
||||||
|
def can_disable(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_disable_url(self):
|
||||||
|
return reverse('authentication:user-otp-disable')
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def help_text_of_enable():
|
||||||
|
return _("Virtual OTP based MFA")
|
||||||
|
|
||||||
|
def help_text_of_disable(self):
|
||||||
|
return ''
|
||||||
|
|
||||||
47
apps/authentication/mfa/radius.py
Normal file
47
apps/authentication/mfa/radius.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from .base import BaseMFA
|
||||||
|
from ..backends.radius import RadiusBackend
|
||||||
|
|
||||||
|
mfa_failed_msg = _("Radius verify code invalid")
|
||||||
|
|
||||||
|
|
||||||
|
class MFARadius(BaseMFA):
|
||||||
|
name = 'otp_radius'
|
||||||
|
display_name = 'Radius'
|
||||||
|
placeholder = _("Radius verification code")
|
||||||
|
|
||||||
|
def check_code(self, code):
|
||||||
|
assert self.is_authenticated()
|
||||||
|
backend = RadiusBackend()
|
||||||
|
username = self.user.username
|
||||||
|
user = backend.authenticate(
|
||||||
|
None, username=username, password=code
|
||||||
|
)
|
||||||
|
ok = user is not None
|
||||||
|
msg = '' if ok else mfa_failed_msg
|
||||||
|
return ok, msg
|
||||||
|
|
||||||
|
def is_active(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def global_enabled():
|
||||||
|
return settings.OTP_IN_RADIUS
|
||||||
|
|
||||||
|
def get_enable_url(self) -> str:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
def can_disable(self):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def disable(self):
|
||||||
|
return ''
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def help_text_of_disable():
|
||||||
|
return _("Radius global enabled, cannot disable")
|
||||||
|
|
||||||
|
def get_disable_url(self) -> str:
|
||||||
|
return ''
|
||||||
64
apps/authentication/mfa/sms.py
Normal file
64
apps/authentication/mfa/sms.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from .base import BaseMFA
|
||||||
|
from common.sdk.sms import SendAndVerifySMSUtil
|
||||||
|
|
||||||
|
sms_failed_msg = _("SMS verify code invalid")
|
||||||
|
|
||||||
|
|
||||||
|
class MFASms(BaseMFA):
|
||||||
|
name = 'sms'
|
||||||
|
display_name = _("SMS")
|
||||||
|
placeholder = _("SMS verification code")
|
||||||
|
|
||||||
|
def __init__(self, user):
|
||||||
|
super().__init__(user)
|
||||||
|
phone = user.phone if self.is_authenticated() else ''
|
||||||
|
self.sms = SendAndVerifySMSUtil(phone)
|
||||||
|
|
||||||
|
def check_code(self, code):
|
||||||
|
assert self.is_authenticated()
|
||||||
|
ok = False
|
||||||
|
msg = ''
|
||||||
|
try:
|
||||||
|
ok = self.sms.verify(code)
|
||||||
|
except Exception as e:
|
||||||
|
msg = str(e)
|
||||||
|
return ok, msg
|
||||||
|
|
||||||
|
def is_active(self):
|
||||||
|
if not self.is_authenticated():
|
||||||
|
return True
|
||||||
|
return self.user.phone
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def challenge_required():
|
||||||
|
return True
|
||||||
|
|
||||||
|
def send_challenge(self):
|
||||||
|
self.sms.gen_and_send()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def global_enabled():
|
||||||
|
return settings.SMS_ENABLED
|
||||||
|
|
||||||
|
def get_enable_url(self) -> str:
|
||||||
|
return '/ui/#/users/profile/?activeTab=ProfileUpdate'
|
||||||
|
|
||||||
|
def can_disable(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
def disable(self):
|
||||||
|
return '/ui/#/users/profile/?activeTab=ProfileUpdate'
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def help_text_of_enable():
|
||||||
|
return _("Set phone number to enable")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def help_text_of_disable():
|
||||||
|
return _("Clear phone number to disable")
|
||||||
|
|
||||||
|
def get_disable_url(self) -> str:
|
||||||
|
return '/ui/#/users/profile/?activeTab=ProfileUpdate'
|
||||||
@@ -7,8 +7,11 @@ class MFAMiddleware:
|
|||||||
|
|
||||||
def __call__(self, request):
|
def __call__(self, request):
|
||||||
response = self.get_response(request)
|
response = self.get_response(request)
|
||||||
if request.path.find('/auth/login/otp/') > -1:
|
|
||||||
return response
|
white_urls = ['login/mfa', 'mfa/select', 'jsi18n/', '/static/']
|
||||||
|
for url in white_urls:
|
||||||
|
if request.path.find(url) > -1:
|
||||||
|
return response
|
||||||
if request.session.get('auth_mfa_required'):
|
if request.session.get('auth_mfa_required'):
|
||||||
return redirect('authentication:login-otp')
|
return redirect('authentication:login-mfa')
|
||||||
return response
|
return response
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Generated by Django 3.1.12 on 2021-09-26 11:13
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('authentication', '0004_ssotoken'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name='LoginConfirmSetting',
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,24 +1,26 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
#
|
#
|
||||||
import inspect
|
import inspect
|
||||||
from urllib.parse import urlencode
|
|
||||||
from functools import partial
|
from functools import partial
|
||||||
import time
|
import time
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from django.utils.http import urlencode
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib import auth
|
from django.contrib import auth
|
||||||
from django.utils.translation import ugettext as _
|
from django.utils.translation import ugettext as _
|
||||||
|
from rest_framework.request import Request
|
||||||
from django.contrib.auth import (
|
from django.contrib.auth import (
|
||||||
BACKEND_SESSION_KEY, _get_backends,
|
BACKEND_SESSION_KEY, _get_backends,
|
||||||
PermissionDenied, user_login_failed, _clean_credentials
|
PermissionDenied, user_login_failed, _clean_credentials
|
||||||
)
|
)
|
||||||
from django.shortcuts import reverse, redirect
|
from django.shortcuts import reverse, redirect, get_object_or_404
|
||||||
from django.views.generic.edit import FormView
|
|
||||||
|
|
||||||
from common.utils import get_object_or_none, get_request_ip, get_logger, bulk_get, FlashMessageUtil
|
from common.utils import get_request_ip, get_logger, bulk_get, FlashMessageUtil
|
||||||
from users.models import User, MFAType
|
from acls.models import LoginACL
|
||||||
from users.utils import LoginBlockUtil, MFABlockUtils
|
from users.models import User
|
||||||
|
from users.utils import LoginBlockUtil, MFABlockUtils, LoginIpBlockUtil
|
||||||
from . import errors
|
from . import errors
|
||||||
from .utils import rsa_decrypt, gen_key_pair
|
from .utils import rsa_decrypt, gen_key_pair
|
||||||
from .signals import post_auth_success, post_auth_failed
|
from .signals import post_auth_success, post_auth_failed
|
||||||
@@ -31,8 +33,7 @@ def check_backend_can_auth(username, backend_path, allowed_auth_backends):
|
|||||||
if allowed_auth_backends is not None and backend_path not in allowed_auth_backends:
|
if allowed_auth_backends is not None and backend_path not in allowed_auth_backends:
|
||||||
logger.debug('Skip user auth backend: {}, {} not in'.format(
|
logger.debug('Skip user auth backend: {}, {} not in'.format(
|
||||||
username, backend_path, ','.join(allowed_auth_backends)
|
username, backend_path, ','.join(allowed_auth_backends)
|
||||||
)
|
))
|
||||||
)
|
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -74,7 +75,9 @@ def authenticate(request=None, **credentials):
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
# The credentials supplied are invalid to all backends, fire signal
|
# The credentials supplied are invalid to all backends, fire signal
|
||||||
user_login_failed.send(sender=__name__, credentials=_clean_credentials(credentials), request=request)
|
user_login_failed.send(
|
||||||
|
sender=__name__, credentials=_clean_credentials(credentials), request=request
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
auth.authenticate = authenticate
|
auth.authenticate = authenticate
|
||||||
@@ -108,17 +111,18 @@ class PasswordEncryptionViewMixin:
|
|||||||
def decrypt_passwd(self, raw_passwd):
|
def decrypt_passwd(self, raw_passwd):
|
||||||
# 获取解密密钥,对密码进行解密
|
# 获取解密密钥,对密码进行解密
|
||||||
rsa_private_key = self.request.session.get(RSA_PRIVATE_KEY)
|
rsa_private_key = self.request.session.get(RSA_PRIVATE_KEY)
|
||||||
if rsa_private_key is not None:
|
if rsa_private_key is None:
|
||||||
try:
|
return raw_passwd
|
||||||
return rsa_decrypt(raw_passwd, rsa_private_key)
|
|
||||||
except Exception as e:
|
try:
|
||||||
logger.error(e, exc_info=True)
|
return rsa_decrypt(raw_passwd, rsa_private_key)
|
||||||
logger.error(
|
except Exception as e:
|
||||||
f'Decrypt password failed: password[{raw_passwd}] '
|
logger.error(e, exc_info=True)
|
||||||
f'rsa_private_key[{rsa_private_key}]'
|
logger.error(
|
||||||
)
|
f'Decrypt password failed: password[{raw_passwd}] '
|
||||||
return None
|
f'rsa_private_key[{rsa_private_key}]'
|
||||||
return raw_passwd
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
def get_request_ip(self):
|
def get_request_ip(self):
|
||||||
ip = ''
|
ip = ''
|
||||||
@@ -131,7 +135,7 @@ class PasswordEncryptionViewMixin:
|
|||||||
# 生成加解密密钥对,public_key传递给前端,private_key存入session中供解密使用
|
# 生成加解密密钥对,public_key传递给前端,private_key存入session中供解密使用
|
||||||
rsa_public_key = self.request.session.get(RSA_PUBLIC_KEY)
|
rsa_public_key = self.request.session.get(RSA_PUBLIC_KEY)
|
||||||
rsa_private_key = self.request.session.get(RSA_PRIVATE_KEY)
|
rsa_private_key = self.request.session.get(RSA_PRIVATE_KEY)
|
||||||
if not all((rsa_private_key, rsa_public_key)):
|
if not all([rsa_private_key, rsa_public_key]):
|
||||||
rsa_private_key, rsa_public_key = gen_key_pair()
|
rsa_private_key, rsa_public_key = gen_key_pair()
|
||||||
rsa_public_key = rsa_public_key.replace('\n', '\\n')
|
rsa_public_key = rsa_public_key.replace('\n', '\\n')
|
||||||
self.request.session[RSA_PRIVATE_KEY] = rsa_private_key
|
self.request.session[RSA_PRIVATE_KEY] = rsa_private_key
|
||||||
@@ -143,49 +147,9 @@ class PasswordEncryptionViewMixin:
|
|||||||
return super().get_context_data(**kwargs)
|
return super().get_context_data(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
class AuthMixin(PasswordEncryptionViewMixin):
|
class CommonMixin(PasswordEncryptionViewMixin):
|
||||||
request = None
|
request: Request
|
||||||
partial_credential_error = None
|
get_request_ip: Callable
|
||||||
|
|
||||||
key_prefix_captcha = "_LOGIN_INVALID_{}"
|
|
||||||
|
|
||||||
def get_user_from_session(self):
|
|
||||||
if self.request.session.is_empty():
|
|
||||||
raise errors.SessionEmptyError()
|
|
||||||
|
|
||||||
if all((self.request.user,
|
|
||||||
not self.request.user.is_anonymous,
|
|
||||||
BACKEND_SESSION_KEY in self.request.session)):
|
|
||||||
user = self.request.user
|
|
||||||
user.backend = self.request.session[BACKEND_SESSION_KEY]
|
|
||||||
return user
|
|
||||||
|
|
||||||
user_id = self.request.session.get('user_id')
|
|
||||||
if not user_id:
|
|
||||||
user = None
|
|
||||||
else:
|
|
||||||
user = get_object_or_none(User, pk=user_id)
|
|
||||||
if not user:
|
|
||||||
raise errors.SessionEmptyError()
|
|
||||||
user.backend = self.request.session.get("auth_backend")
|
|
||||||
return user
|
|
||||||
|
|
||||||
def _check_is_block(self, username, raise_exception=True):
|
|
||||||
ip = self.get_request_ip()
|
|
||||||
if LoginBlockUtil(username, ip).is_block():
|
|
||||||
logger.warn('Ip was blocked' + ': ' + username + ':' + ip)
|
|
||||||
exception = errors.BlockLoginError(username=username, ip=ip)
|
|
||||||
if raise_exception:
|
|
||||||
raise errors.BlockLoginError(username=username, ip=ip)
|
|
||||||
else:
|
|
||||||
return exception
|
|
||||||
|
|
||||||
def check_is_block(self, raise_exception=True):
|
|
||||||
if hasattr(self.request, 'data'):
|
|
||||||
username = self.request.data.get("username")
|
|
||||||
else:
|
|
||||||
username = self.request.POST.get("username")
|
|
||||||
self._check_is_block(username, raise_exception)
|
|
||||||
|
|
||||||
def raise_credential_error(self, error):
|
def raise_credential_error(self, error):
|
||||||
raise self.partial_credential_error(error=error)
|
raise self.partial_credential_error(error=error)
|
||||||
@@ -196,6 +160,31 @@ class AuthMixin(PasswordEncryptionViewMixin):
|
|||||||
ip=ip, request=request
|
ip=ip, request=request
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def get_user_from_session(self):
|
||||||
|
if self.request.session.is_empty():
|
||||||
|
raise errors.SessionEmptyError()
|
||||||
|
|
||||||
|
if all([
|
||||||
|
self.request.user,
|
||||||
|
not self.request.user.is_anonymous,
|
||||||
|
BACKEND_SESSION_KEY in self.request.session
|
||||||
|
]):
|
||||||
|
user = self.request.user
|
||||||
|
user.backend = self.request.session[BACKEND_SESSION_KEY]
|
||||||
|
return user
|
||||||
|
|
||||||
|
user_id = self.request.session.get('user_id')
|
||||||
|
auth_password = self.request.session.get('auth_password')
|
||||||
|
auth_expired_at = self.request.session.get('auth_password_expired_at')
|
||||||
|
auth_expired = auth_expired_at < time.time() if auth_expired_at else False
|
||||||
|
|
||||||
|
if not user_id or not auth_password or auth_expired:
|
||||||
|
raise errors.SessionEmptyError()
|
||||||
|
|
||||||
|
user = get_object_or_404(User, pk=user_id)
|
||||||
|
user.backend = self.request.session.get("auth_backend")
|
||||||
|
return user
|
||||||
|
|
||||||
def get_auth_data(self, decrypt_passwd=False):
|
def get_auth_data(self, decrypt_passwd=False):
|
||||||
request = self.request
|
request = self.request
|
||||||
if hasattr(request, 'data'):
|
if hasattr(request, 'data'):
|
||||||
@@ -204,101 +193,151 @@ class AuthMixin(PasswordEncryptionViewMixin):
|
|||||||
data = request.POST
|
data = request.POST
|
||||||
|
|
||||||
items = ['username', 'password', 'challenge', 'public_key', 'auto_login']
|
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()
|
ip = self.get_request_ip()
|
||||||
self._set_partial_credential_error(username=username, ip=ip, request=request)
|
self._set_partial_credential_error(username=username, ip=ip, request=request)
|
||||||
|
|
||||||
password = password + challenge.strip()
|
|
||||||
if decrypt_passwd:
|
if decrypt_passwd:
|
||||||
password = self.get_decrypted_password()
|
password = self.get_decrypted_password()
|
||||||
|
password = password + challenge.strip()
|
||||||
return username, password, public_key, ip, auto_login
|
return username, password, public_key, ip, auto_login
|
||||||
|
|
||||||
|
|
||||||
|
class AuthPreCheckMixin:
|
||||||
|
request: Request
|
||||||
|
get_request_ip: Callable
|
||||||
|
raise_credential_error: Callable
|
||||||
|
|
||||||
|
def _check_is_block(self, username, raise_exception=True):
|
||||||
|
ip = self.get_request_ip()
|
||||||
|
|
||||||
|
if LoginIpBlockUtil(ip).is_block():
|
||||||
|
raise errors.BlockGlobalIpLoginError(username=username, ip=ip)
|
||||||
|
|
||||||
|
is_block = LoginBlockUtil(username, ip).is_block()
|
||||||
|
if not is_block:
|
||||||
|
return
|
||||||
|
logger.warn('Ip was blocked' + ': ' + username + ':' + ip)
|
||||||
|
exception = errors.BlockLoginError(username=username, ip=ip)
|
||||||
|
if raise_exception:
|
||||||
|
raise errors.BlockLoginError(username=username, ip=ip)
|
||||||
|
else:
|
||||||
|
return exception
|
||||||
|
|
||||||
|
def check_is_block(self, raise_exception=True):
|
||||||
|
if hasattr(self.request, 'data'):
|
||||||
|
username = self.request.data.get("username")
|
||||||
|
else:
|
||||||
|
username = self.request.POST.get("username")
|
||||||
|
|
||||||
|
self._check_is_block(username, raise_exception)
|
||||||
|
|
||||||
def _check_only_allow_exists_user_auth(self, username):
|
def _check_only_allow_exists_user_auth(self, username):
|
||||||
# 仅允许预先存在的用户认证
|
# 仅允许预先存在的用户认证
|
||||||
if settings.ONLY_ALLOW_EXIST_USER_AUTH:
|
if not settings.ONLY_ALLOW_EXIST_USER_AUTH:
|
||||||
exist = User.objects.filter(username=username).exists()
|
return
|
||||||
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):
|
exist = User.objects.filter(username=username).exists()
|
||||||
user = authenticate(self.request, username=username, password=password, public_key=public_key)
|
if not exist:
|
||||||
if not user:
|
logger.error(f"Only allow exist user auth, login failed: {username}")
|
||||||
self.raise_credential_error(errors.reason_password_failed)
|
self.raise_credential_error(errors.reason_user_not_exist)
|
||||||
elif user.is_expired:
|
|
||||||
self.raise_credential_error(errors.reason_user_expired)
|
|
||||||
elif not user.is_active:
|
|
||||||
self.raise_credential_error(errors.reason_user_inactive)
|
|
||||||
return user
|
|
||||||
|
|
||||||
def _check_login_acl(self, user, ip):
|
|
||||||
# ACL 限制用户登录
|
|
||||||
from acls.models import LoginACL
|
|
||||||
is_allowed = LoginACL.allow_user_to_login(user, ip)
|
|
||||||
if not is_allowed:
|
|
||||||
raise errors.LoginIPNotAllowed(username=user.username, request=self.request)
|
|
||||||
|
|
||||||
def set_login_failed_mark(self):
|
class MFAMixin:
|
||||||
ip = self.get_request_ip()
|
request: Request
|
||||||
cache.set(self.key_prefix_captcha.format(ip), 1, 3600)
|
get_user_from_session: Callable
|
||||||
|
get_request_ip: Callable
|
||||||
|
|
||||||
def set_passwd_verify_on_session(self, user: User):
|
def _check_if_no_active_mfa(self, user):
|
||||||
self.request.session['user_id'] = str(user.id)
|
active_mfa_mapper = user.active_mfa_backends_mapper
|
||||||
self.request.session['auth_password'] = 1
|
if not active_mfa_mapper:
|
||||||
self.request.session['auth_password_expired_at'] = time.time() + settings.AUTH_EXPIRED_SECONDS
|
url = reverse('authentication:user-otp-enable-start')
|
||||||
|
raise errors.MFAUnsetError(user, self.request, url)
|
||||||
|
|
||||||
def check_is_need_captcha(self):
|
def _check_login_page_mfa_if_need(self, user):
|
||||||
# 最近有登录失败时需要填写验证码
|
if not settings.SECURITY_MFA_IN_LOGIN_PAGE:
|
||||||
ip = get_request_ip(self.request)
|
return
|
||||||
need = cache.get(self.key_prefix_captcha.format(ip))
|
self._check_if_no_active_mfa(user)
|
||||||
return need
|
|
||||||
|
|
||||||
def check_user_auth(self, decrypt_passwd=False):
|
|
||||||
self.check_is_block()
|
|
||||||
request = self.request
|
request = self.request
|
||||||
username, password, public_key, ip, auto_login = self.get_auth_data(decrypt_passwd=decrypt_passwd)
|
data = request.data if hasattr(request, 'data') else request.POST
|
||||||
|
code = data.get('code')
|
||||||
|
mfa_type = data.get('mfa_type', 'otp')
|
||||||
|
|
||||||
self._check_only_allow_exists_user_auth(username)
|
if not code:
|
||||||
user = self._check_auth_user_is_valid(username, password, public_key)
|
return
|
||||||
# 校验login-acl规则
|
self._do_check_user_mfa(code, mfa_type, user=user)
|
||||||
self._check_login_acl(user, ip)
|
|
||||||
self._check_password_require_reset_or_not(user)
|
|
||||||
self._check_passwd_is_too_simple(user, password)
|
|
||||||
self._check_passwd_need_update(user)
|
|
||||||
|
|
||||||
LoginBlockUtil(username, ip).clean_failed_count()
|
def check_user_mfa_if_need(self, user):
|
||||||
request.session['auth_password'] = 1
|
if self.request.session.get('auth_mfa'):
|
||||||
request.session['user_id'] = str(user.id)
|
return
|
||||||
request.session['auto_login'] = auto_login
|
if not user.mfa_enabled:
|
||||||
request.session['auth_backend'] = getattr(user, 'backend', settings.AUTH_BACKEND_MODEL)
|
return
|
||||||
return user
|
|
||||||
|
|
||||||
def _check_is_local_user(self, user: User):
|
self._check_if_no_active_mfa(user)
|
||||||
if user.source != User.Source.local:
|
|
||||||
raise self.raise_credential_error(error=errors.only_local_users_are_allowed)
|
|
||||||
|
|
||||||
def check_oauth2_auth(self, user: User, auth_backend):
|
active_mfa_mapper = user.active_mfa_backends_mapper
|
||||||
|
raise errors.MFARequiredError(mfa_types=tuple(active_mfa_mapper.keys()))
|
||||||
|
|
||||||
|
def mark_mfa_ok(self, mfa_type):
|
||||||
|
self.request.session['auth_mfa'] = 1
|
||||||
|
self.request.session['auth_mfa_time'] = time.time()
|
||||||
|
self.request.session['auth_mfa_required'] = 0
|
||||||
|
self.request.session['auth_mfa_type'] = mfa_type
|
||||||
|
|
||||||
|
def clean_mfa_mark(self):
|
||||||
|
keys = ['auth_mfa', 'auth_mfa_time', 'auth_mfa_required', 'auth_mfa_type']
|
||||||
|
for k in keys:
|
||||||
|
self.request.session.pop(k, '')
|
||||||
|
|
||||||
|
def check_mfa_is_block(self, username, ip, raise_exception=True):
|
||||||
|
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 _do_check_user_mfa(self, code, mfa_type, user=None):
|
||||||
|
user = user if user else self.get_user_from_session()
|
||||||
|
if not user.mfa_enabled:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 监测 MFA 是不是屏蔽了
|
||||||
ip = self.get_request_ip()
|
ip = self.get_request_ip()
|
||||||
request = self.request
|
self.check_mfa_is_block(user.username, ip)
|
||||||
|
|
||||||
self._set_partial_credential_error(user.username, ip, request)
|
ok = False
|
||||||
|
mfa_backend = user.get_mfa_backend_by_type(mfa_type)
|
||||||
|
backend_error = _('The MFA type ({}) is not enabled')
|
||||||
|
if not mfa_backend:
|
||||||
|
msg = backend_error.format(mfa_type)
|
||||||
|
elif not mfa_backend.is_active():
|
||||||
|
msg = backend_error.format(mfa_backend.display_name)
|
||||||
|
else:
|
||||||
|
ok, msg = mfa_backend.check_code(code)
|
||||||
|
|
||||||
if user.is_expired:
|
if ok:
|
||||||
self.raise_credential_error(errors.reason_user_expired)
|
self.mark_mfa_ok(mfa_type)
|
||||||
elif not user.is_active:
|
return
|
||||||
self.raise_credential_error(errors.reason_user_inactive)
|
|
||||||
|
|
||||||
self._check_is_block(user.username)
|
raise errors.MFAFailedError(
|
||||||
self._check_login_acl(user, ip)
|
username=user.username,
|
||||||
|
request=self.request,
|
||||||
|
ip=ip, mfa_type=mfa_type,
|
||||||
|
error=msg
|
||||||
|
)
|
||||||
|
|
||||||
LoginBlockUtil(user.username, ip).clean_failed_count()
|
@staticmethod
|
||||||
MFABlockUtils(user.username, ip).clean_failed_count()
|
def get_user_mfa_context(user=None):
|
||||||
|
mfa_backends = User.get_user_mfa_backends(user)
|
||||||
|
return {'mfa_backends': mfa_backends}
|
||||||
|
|
||||||
request.session['auth_password'] = 1
|
|
||||||
request.session['user_id'] = str(user.id)
|
|
||||||
request.session['auth_backend'] = auth_backend
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
class AuthPostCheckMixin:
|
||||||
@classmethod
|
@classmethod
|
||||||
def generate_reset_password_url_with_flash_msg(cls, user, message):
|
def generate_reset_password_url_with_flash_msg(cls, user, message):
|
||||||
reset_passwd_url = reverse('authentication:reset-password')
|
reset_passwd_url = reverse('authentication:reset-password')
|
||||||
@@ -320,14 +359,14 @@ class AuthMixin(PasswordEncryptionViewMixin):
|
|||||||
if user.is_superuser and password == 'admin':
|
if user.is_superuser and password == 'admin':
|
||||||
message = _('Your password is too simple, please change it for security')
|
message = _('Your password is too simple, please change it for security')
|
||||||
url = cls.generate_reset_password_url_with_flash_msg(user, message=message)
|
url = cls.generate_reset_password_url_with_flash_msg(user, message=message)
|
||||||
raise errors.PasswdTooSimple(url)
|
raise errors.PasswordTooSimple(url)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _check_passwd_need_update(cls, user: User):
|
def _check_passwd_need_update(cls, user: User):
|
||||||
if user.need_update_password:
|
if user.need_update_password:
|
||||||
message = _('You should to change your password before login')
|
message = _('You should to change your password before login')
|
||||||
url = cls.generate_reset_password_url_with_flash_msg(user, message)
|
url = cls.generate_reset_password_url_with_flash_msg(user, message)
|
||||||
raise errors.PasswdNeedUpdate(url)
|
raise errors.PasswordNeedUpdate(url)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _check_password_require_reset_or_not(cls, user: User):
|
def _check_password_require_reset_or_not(cls, user: User):
|
||||||
@@ -336,64 +375,20 @@ class AuthMixin(PasswordEncryptionViewMixin):
|
|||||||
url = cls.generate_reset_password_url_with_flash_msg(user, message)
|
url = cls.generate_reset_password_url_with_flash_msg(user, message)
|
||||||
raise errors.PasswordRequireResetError(url)
|
raise errors.PasswordRequireResetError(url)
|
||||||
|
|
||||||
def check_user_auth_if_need(self, decrypt_passwd=False):
|
|
||||||
request = self.request
|
|
||||||
if request.session.get('auth_password') and \
|
|
||||||
request.session.get('user_id'):
|
|
||||||
user = self.get_user_from_session()
|
|
||||||
if user:
|
|
||||||
return user
|
|
||||||
return self.check_user_auth(decrypt_passwd=decrypt_passwd)
|
|
||||||
|
|
||||||
def check_user_mfa_if_need(self, user):
|
class AuthACLMixin:
|
||||||
if self.request.session.get('auth_mfa'):
|
request: Request
|
||||||
|
get_request_ip: Callable
|
||||||
|
|
||||||
|
def _check_login_acl(self, user, ip):
|
||||||
|
# ACL 限制用户登录
|
||||||
|
is_allowed, limit_type = LoginACL.allow_user_to_login(user, ip)
|
||||||
|
if is_allowed:
|
||||||
return
|
return
|
||||||
|
if limit_type == 'ip':
|
||||||
if settings.OTP_IN_RADIUS:
|
raise errors.LoginIPNotAllowed(username=user.username, request=self.request)
|
||||||
return
|
elif limit_type == 'time':
|
||||||
|
raise errors.TimePeriodNotAllowed(username=user.username, request=self.request)
|
||||||
if not user.mfa_enabled:
|
|
||||||
return
|
|
||||||
unset, url = user.mfa_enabled_but_not_set()
|
|
||||||
if unset:
|
|
||||||
raise errors.MFAUnsetError(user, self.request, url)
|
|
||||||
raise errors.MFARequiredError(mfa_types=user.get_supported_mfa_types())
|
|
||||||
|
|
||||||
def mark_mfa_ok(self, mfa_type=MFAType.OTP):
|
|
||||||
self.request.session['auth_mfa'] = 1
|
|
||||||
self.request.session['auth_mfa_time'] = time.time()
|
|
||||||
self.request.session['auth_mfa_required'] = ''
|
|
||||||
self.request.session['auth_mfa_type'] = mfa_type
|
|
||||||
|
|
||||||
def clean_mfa_mark(self):
|
|
||||||
self.request.session['auth_mfa'] = ''
|
|
||||||
self.request.session['auth_mfa_time'] = ''
|
|
||||||
self.request.session['auth_mfa_required'] = ''
|
|
||||||
self.request.session['auth_mfa_type'] = ''
|
|
||||||
|
|
||||||
def check_mfa_is_block(self, username, ip, raise_exception=True):
|
|
||||||
if MFABlockUtils(username, ip).is_block():
|
|
||||||
logger.warn('Ip was blocked' + ': ' + username + ':' + ip)
|
|
||||||
exception = errors.BlockMFAError(username=username, request=self.request, ip=ip)
|
|
||||||
if raise_exception:
|
|
||||||
raise exception
|
|
||||||
else:
|
|
||||||
return exception
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
raise errors.MFAFailedError(
|
|
||||||
username=user.username,
|
|
||||||
request=self.request,
|
|
||||||
ip=ip, mfa_type=mfa_type,
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_ticket(self):
|
def get_ticket(self):
|
||||||
from tickets.models import Ticket
|
from tickets.models import Ticket
|
||||||
@@ -422,12 +417,10 @@ class AuthMixin(PasswordEncryptionViewMixin):
|
|||||||
self.request.session["auth_confirm"] = "1"
|
self.request.session["auth_confirm"] = "1"
|
||||||
return
|
return
|
||||||
elif ticket.state_reject:
|
elif ticket.state_reject:
|
||||||
self.clean_mfa_mark()
|
|
||||||
raise errors.LoginConfirmOtherError(
|
raise errors.LoginConfirmOtherError(
|
||||||
ticket.id, ticket.get_state_display()
|
ticket.id, ticket.get_state_display()
|
||||||
)
|
)
|
||||||
elif ticket.state_close:
|
elif ticket.state_close:
|
||||||
self.clean_mfa_mark()
|
|
||||||
raise errors.LoginConfirmOtherError(
|
raise errors.LoginConfirmOtherError(
|
||||||
ticket.id, ticket.get_state_display()
|
ticket.id, ticket.get_state_display()
|
||||||
)
|
)
|
||||||
@@ -437,19 +430,108 @@ class AuthMixin(PasswordEncryptionViewMixin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def check_user_login_confirm_if_need(self, user):
|
def check_user_login_confirm_if_need(self, user):
|
||||||
if not settings.LOGIN_CONFIRM_ENABLE:
|
ip = self.get_request_ip()
|
||||||
return
|
is_allowed, confirm_setting = LoginACL.allow_user_confirm_if_need(user, ip)
|
||||||
confirm_setting = user.get_login_confirm_setting()
|
if self.request.session.get('auth_confirm') or not is_allowed:
|
||||||
if self.request.session.get('auth_confirm') or not confirm_setting:
|
|
||||||
return
|
return
|
||||||
self.get_ticket_or_create(confirm_setting)
|
self.get_ticket_or_create(confirm_setting)
|
||||||
self.check_user_login_confirm()
|
self.check_user_login_confirm()
|
||||||
|
|
||||||
|
|
||||||
|
class AuthMixin(CommonMixin, AuthPreCheckMixin, AuthACLMixin, MFAMixin, AuthPostCheckMixin):
|
||||||
|
request = None
|
||||||
|
partial_credential_error = None
|
||||||
|
|
||||||
|
key_prefix_captcha = "_LOGIN_INVALID_{}"
|
||||||
|
|
||||||
|
def _check_auth_user_is_valid(self, username, password, public_key):
|
||||||
|
user = authenticate(
|
||||||
|
self.request, username=username,
|
||||||
|
password=password, public_key=public_key
|
||||||
|
)
|
||||||
|
if not user:
|
||||||
|
self.raise_credential_error(errors.reason_password_failed)
|
||||||
|
elif user.is_expired:
|
||||||
|
self.raise_credential_error(errors.reason_user_expired)
|
||||||
|
elif not user.is_active:
|
||||||
|
self.raise_credential_error(errors.reason_user_inactive)
|
||||||
|
return user
|
||||||
|
|
||||||
|
def set_login_failed_mark(self):
|
||||||
|
ip = self.get_request_ip()
|
||||||
|
cache.set(self.key_prefix_captcha.format(ip), 1, 3600)
|
||||||
|
|
||||||
|
def check_is_need_captcha(self):
|
||||||
|
# 最近有登录失败时需要填写验证码
|
||||||
|
ip = get_request_ip(self.request)
|
||||||
|
need = cache.get(self.key_prefix_captcha.format(ip))
|
||||||
|
return need
|
||||||
|
|
||||||
|
def check_user_auth(self, decrypt_passwd=False):
|
||||||
|
# pre check
|
||||||
|
self.check_is_block()
|
||||||
|
username, password, public_key, ip, auto_login = self.get_auth_data(decrypt_passwd)
|
||||||
|
self._check_only_allow_exists_user_auth(username)
|
||||||
|
|
||||||
|
# check auth
|
||||||
|
user = self._check_auth_user_is_valid(username, password, public_key)
|
||||||
|
|
||||||
|
# 校验login-acl规则
|
||||||
|
self._check_login_acl(user, ip)
|
||||||
|
|
||||||
|
# post check
|
||||||
|
self._check_password_require_reset_or_not(user)
|
||||||
|
self._check_passwd_is_too_simple(user, password)
|
||||||
|
self._check_passwd_need_update(user)
|
||||||
|
|
||||||
|
# 校验login-mfa, 如果登录页面上显示 mfa 的话
|
||||||
|
self._check_login_page_mfa_if_need(user)
|
||||||
|
|
||||||
|
# 标记密码验证成功
|
||||||
|
self.mark_password_ok(user=user, auto_login=auto_login)
|
||||||
|
LoginBlockUtil(user.username, ip).clean_failed_count()
|
||||||
|
LoginIpBlockUtil(ip).clean_block_if_need()
|
||||||
|
return user
|
||||||
|
|
||||||
|
def mark_password_ok(self, user, auto_login=False):
|
||||||
|
request = self.request
|
||||||
|
request.session['auth_password'] = 1
|
||||||
|
request.session['auth_password_expired_at'] = time.time() + settings.AUTH_EXPIRED_SECONDS
|
||||||
|
request.session['user_id'] = str(user.id)
|
||||||
|
request.session['auto_login'] = auto_login
|
||||||
|
request.session['auth_backend'] = getattr(user, 'backend', settings.AUTH_BACKEND_MODEL)
|
||||||
|
|
||||||
|
def check_oauth2_auth(self, user: User, auth_backend):
|
||||||
|
ip = self.get_request_ip()
|
||||||
|
request = self.request
|
||||||
|
|
||||||
|
self._set_partial_credential_error(user.username, ip, request)
|
||||||
|
|
||||||
|
if user.is_expired:
|
||||||
|
self.raise_credential_error(errors.reason_user_expired)
|
||||||
|
elif not user.is_active:
|
||||||
|
self.raise_credential_error(errors.reason_user_inactive)
|
||||||
|
|
||||||
|
self._check_is_block(user.username)
|
||||||
|
self._check_login_acl(user, ip)
|
||||||
|
|
||||||
|
LoginBlockUtil(user.username, ip).clean_failed_count()
|
||||||
|
LoginIpBlockUtil(ip).clean_block_if_need()
|
||||||
|
MFABlockUtils(user.username, ip).clean_failed_count()
|
||||||
|
|
||||||
|
self.mark_password_ok(user, False)
|
||||||
|
return user
|
||||||
|
|
||||||
|
def check_user_auth_if_need(self, decrypt_passwd=False):
|
||||||
|
request = self.request
|
||||||
|
if not request.session.get('auth_password'):
|
||||||
|
return self.check_user_auth(decrypt_passwd=decrypt_passwd)
|
||||||
|
return self.get_user_from_session()
|
||||||
|
|
||||||
def clear_auth_mark(self):
|
def clear_auth_mark(self):
|
||||||
self.request.session['auth_password'] = ''
|
keys = ['auth_password', 'user_id', 'auth_confirm', 'auth_ticket_id']
|
||||||
self.request.session['auth_user_id'] = ''
|
for k in keys:
|
||||||
self.request.session['auth_confirm'] = ''
|
self.request.session.pop(k, '')
|
||||||
self.request.session['auth_ticket_id'] = ''
|
|
||||||
|
|
||||||
def send_auth_signal(self, success=True, user=None, username='', reason=''):
|
def send_auth_signal(self, success=True, user=None, username='', reason=''):
|
||||||
if success:
|
if success:
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from django.utils import timezone
|
from django.utils.translation import ugettext_lazy as _
|
||||||
from django.utils.translation import ugettext_lazy as _, ugettext as __
|
|
||||||
from rest_framework.authtoken.models import Token
|
from rest_framework.authtoken.models import Token
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
from common.db import models
|
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):
|
class AccessKey(models.Model):
|
||||||
@@ -40,56 +37,6 @@ class PrivateToken(Token):
|
|||||||
verbose_name = _('Private 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):
|
class SSOToken(models.JMSBaseModel):
|
||||||
"""
|
"""
|
||||||
类似腾讯企业邮的 [单点登录](https://exmail.qq.com/qy_mng_logic/doc#10036)
|
类似腾讯企业邮的 [单点登录](https://exmail.qq.com/qy_mng_logic/doc#10036)
|
||||||
|
|||||||
41
apps/authentication/notifications.py
Normal file
41
apps/authentication/notifications.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
from django.utils import timezone
|
||||||
|
from django.utils.translation import ugettext as _
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
|
||||||
|
from notifications.notifications import UserMessage
|
||||||
|
from common.utils import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__file__)
|
||||||
|
|
||||||
|
|
||||||
|
class DifferentCityLoginMessage(UserMessage):
|
||||||
|
def __init__(self, user, ip, city):
|
||||||
|
self.ip = ip
|
||||||
|
self.city = city
|
||||||
|
super().__init__(user)
|
||||||
|
|
||||||
|
def get_html_msg(self) -> dict:
|
||||||
|
now_local = timezone.localtime(timezone.now())
|
||||||
|
now = now_local.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
subject = _('Different city login reminder')
|
||||||
|
context = dict(
|
||||||
|
subject=subject,
|
||||||
|
name=self.user.name,
|
||||||
|
username=self.user.username,
|
||||||
|
ip=self.ip,
|
||||||
|
time=now,
|
||||||
|
city=self.city,
|
||||||
|
)
|
||||||
|
message = render_to_string('authentication/_msg_different_city.html', context)
|
||||||
|
return {
|
||||||
|
'subject': subject,
|
||||||
|
'message': message
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def gen_test_msg(cls):
|
||||||
|
from users.models import User
|
||||||
|
user = User.objects.first()
|
||||||
|
ip = '8.8.8.8'
|
||||||
|
city = '洛杉矶'
|
||||||
|
return cls(user, ip, city)
|
||||||
@@ -10,12 +10,11 @@ from applications.models import Application
|
|||||||
from users.serializers import UserProfileSerializer
|
from users.serializers import UserProfileSerializer
|
||||||
from assets.serializers import ProtocolsField
|
from assets.serializers import ProtocolsField
|
||||||
from perms.serializers.asset.permission import ActionsField
|
from perms.serializers.asset.permission import ActionsField
|
||||||
from .models import AccessKey, LoginConfirmSetting
|
from .models import AccessKey
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'AccessKeySerializer', 'OtpVerifySerializer', 'BearerTokenSerializer',
|
'AccessKeySerializer', 'OtpVerifySerializer', 'BearerTokenSerializer',
|
||||||
'MFAChallengeSerializer', 'LoginConfirmSettingSerializer', 'SSOTokenSerializer',
|
'MFAChallengeSerializer', 'SSOTokenSerializer',
|
||||||
'ConnectionTokenSerializer', 'ConnectionTokenSecretSerializer',
|
'ConnectionTokenSerializer', 'ConnectionTokenSecretSerializer',
|
||||||
'PasswordVerifySerializer', 'MFASelectTypeSerializer',
|
'PasswordVerifySerializer', 'MFASelectTypeSerializer',
|
||||||
]
|
]
|
||||||
@@ -55,9 +54,9 @@ class BearerTokenSerializer(serializers.Serializer):
|
|||||||
user.last_login = timezone.now()
|
user.last_login = timezone.now()
|
||||||
user.save(update_fields=['last_login'])
|
user.save(update_fields=['last_login'])
|
||||||
|
|
||||||
def create(self, validated_data):
|
def get_request_user(self):
|
||||||
request = self.context.get('request')
|
request = self.context.get('request')
|
||||||
if request.user and not request.user.is_anonymous:
|
if request.user and request.user.is_authenticated:
|
||||||
user = request.user
|
user = request.user
|
||||||
else:
|
else:
|
||||||
user_id = request.session.get('user_id')
|
user_id = request.session.get('user_id')
|
||||||
@@ -66,6 +65,12 @@ class BearerTokenSerializer(serializers.Serializer):
|
|||||||
raise serializers.ValidationError(
|
raise serializers.ValidationError(
|
||||||
"user id {} not exist".format(user_id)
|
"user id {} not exist".format(user_id)
|
||||||
)
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
request = self.context.get('request')
|
||||||
|
user = self.get_request_user()
|
||||||
|
|
||||||
token, date_expired = user.create_bearer_token(request)
|
token, date_expired = user.create_bearer_token(request)
|
||||||
self.update_last_login(user)
|
self.update_last_login(user)
|
||||||
|
|
||||||
@@ -79,6 +84,7 @@ class BearerTokenSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
class MFASelectTypeSerializer(serializers.Serializer):
|
class MFASelectTypeSerializer(serializers.Serializer):
|
||||||
type = serializers.CharField()
|
type = serializers.CharField()
|
||||||
|
username = serializers.CharField(required=False, allow_blank=True, allow_null=True)
|
||||||
|
|
||||||
|
|
||||||
class MFAChallengeSerializer(serializers.Serializer):
|
class MFAChallengeSerializer(serializers.Serializer):
|
||||||
@@ -92,13 +98,6 @@ class MFAChallengeSerializer(serializers.Serializer):
|
|||||||
pass
|
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):
|
class SSOTokenSerializer(serializers.Serializer):
|
||||||
username = serializers.CharField(write_only=True)
|
username = serializers.CharField(write_only=True)
|
||||||
login_url = serializers.CharField(read_only=True)
|
login_url = serializers.CharField(read_only=True)
|
||||||
@@ -201,4 +200,3 @@ class ConnectionTokenSecretSerializer(serializers.Serializer):
|
|||||||
gateway = ConnectionTokenGatewaySerializer(read_only=True)
|
gateway = ConnectionTokenGatewaySerializer(read_only=True)
|
||||||
actions = ActionsField()
|
actions = ActionsField()
|
||||||
expired_at = serializers.IntegerField()
|
expired_at = serializers.IntegerField()
|
||||||
|
|
||||||
|
|||||||
@@ -7,17 +7,16 @@ from django.dispatch import receiver
|
|||||||
from django_cas_ng.signals import cas_user_authenticated
|
from django_cas_ng.signals import cas_user_authenticated
|
||||||
|
|
||||||
from jms_oidc_rp.signals import openid_user_login_failed, openid_user_login_success
|
from jms_oidc_rp.signals import openid_user_login_failed, openid_user_login_success
|
||||||
|
|
||||||
from .signals import post_auth_success, post_auth_failed
|
from .signals import post_auth_success, post_auth_failed
|
||||||
|
|
||||||
|
|
||||||
@receiver(user_logged_in)
|
@receiver(user_logged_in)
|
||||||
def on_user_auth_login_success(sender, user, request, **kwargs):
|
def on_user_auth_login_success(sender, user, request, **kwargs):
|
||||||
# 开启了 MFA,且没有校验过
|
# 开启了 MFA,且没有校验过, 可以全局校验, middleware 中可以全局管理 oidc 等第三方认证的 MFA
|
||||||
|
if user.mfa_enabled and not request.session.get('auth_mfa'):
|
||||||
if user.mfa_enabled and not settings.OTP_IN_RADIUS and not request.session.get('auth_mfa'):
|
|
||||||
request.session['auth_mfa_required'] = 1
|
request.session['auth_mfa_required'] = 1
|
||||||
|
|
||||||
|
# 单点登录,超过了自动退出
|
||||||
if settings.USER_LOGIN_SINGLE_MACHINE_ENABLED:
|
if settings.USER_LOGIN_SINGLE_MACHINE_ENABLED:
|
||||||
user_id = 'single_machine_login_' + str(user.id)
|
user_id = 'single_machine_login_' + str(user.id)
|
||||||
session_key = cache.get(user_id)
|
session_key = cache.get(user_id)
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
<div class="col-sm-6">
|
<div class="col-sm-6">
|
||||||
<div class="input-group-prepend">
|
<div class="input-group-prepend">
|
||||||
{% if audio %}
|
{% if audio %}
|
||||||
<a title="{% trans "Play CAPTCHA as audio file" %}" href="{{ audio }}">
|
<a title="{% trans "Play CAPTCHA as audio file" %}" href="{{ audio }}"></a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% include "django/forms/widgets/multiwidget.html" %}
|
{% include "django/forms/widgets/multiwidget.html" %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{% load i18n %}
|
||||||
|
<p>
|
||||||
|
{% trans 'Hello' %} {{ name }},
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{% trans 'Your account has remote login behavior, please pay attention' %}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<b>{% trans 'Username' %}:</b> {{ username }}<br>
|
||||||
|
<b>{% trans 'Login time' %}:</b> {{ time }}<br>
|
||||||
|
<b>{% trans 'Login city' %}:</b> {{ city }}({{ ip }})
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
{% trans 'If you suspect that the login behavior is abnormal, please modify the account password in time.' %}
|
||||||
|
</p>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{% load i18n %}
|
||||||
|
<p>
|
||||||
|
{% trans 'Hello' %} {{ user.name }},
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{% trans 'Please click the link below to reset your password, if not your request, concern your account security' %}
|
||||||
|
<br>
|
||||||
|
<br>
|
||||||
|
<a href="{{ rest_password_url }}?token={{ rest_password_token}}" class='showLink' target="_blank">
|
||||||
|
{% trans 'Click here reset password' %}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
{% trans 'This link is valid for 1 hour. After it expires' %}
|
||||||
|
<a href="{{ forget_password_url }}?email={{ user.email }}">
|
||||||
|
{% trans 'request new one' %}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{% load i18n %}
|
||||||
|
<p>{% trans 'Hello' %} {{ name }},</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
{% trans 'Your password has just been successfully updated' %}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<b>{% trans 'IP' %}:</b> {{ ip_address }} <br />
|
||||||
|
<b>{% trans 'Browser' %}:</b> {{ browser }}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{% trans 'If the password update was not initiated by you, your account may have security issues' %} <br />
|
||||||
|
{% trans 'If you have any questions, you can contact the administrator' %}
|
||||||
|
</p>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{% load i18n %}
|
||||||
|
<p>{% trans 'Hello' %} {{ name }},</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
{% trans 'Your public key has just been successfully updated' %}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<b>{% trans 'IP' %}:</b> {{ ip_address }} <br />
|
||||||
|
<b>{% trans 'Browser' %}:</b> {{ browser }}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{% trans 'If the public key update was not initiated by you, your account may have security issues' %} <br />
|
||||||
|
{% trans 'If you have any questions, you can contact the administrator' %}
|
||||||
|
</p>
|
||||||
@@ -10,23 +10,15 @@
|
|||||||
{{ JMS_TITLE }}
|
{{ JMS_TITLE }}
|
||||||
</title>
|
</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
{% include '_head_css_js.html' %}
|
||||||
<!-- Stylesheets -->
|
<!-- 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/login-style.css' %}" rel="stylesheet">
|
||||||
<link href="{% static 'css/style.css' %}" rel="stylesheet">
|
|
||||||
<link href="{% static 'css/jumpserver.css' %}" rel="stylesheet">
|
<link href="{% static 'css/jumpserver.css' %}" rel="stylesheet">
|
||||||
|
<script src="{% static "js/jumpserver.js" %}"></script>
|
||||||
<!-- 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>
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.login-content {
|
.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 {
|
.help-block {
|
||||||
@@ -49,17 +41,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.login-content {
|
.login-content {
|
||||||
height: 472px;
|
height: 490px;
|
||||||
width: 984px;
|
width: 1066px;
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
margin-top: calc((100vh - 470px) / 3);
|
margin-top: calc((100vh - 470px) / 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background-color: #f2f2f2;
|
background-color: #f2f2f2;
|
||||||
height: calc(100vh - (100vh - 470px) / 3);
|
height: calc(100vh - (100vh - 470px) / 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.captcha {
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
|
||||||
.right-image-box {
|
.right-image-box {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 50%;
|
width: 50%;
|
||||||
@@ -73,18 +70,10 @@
|
|||||||
width: 50%;
|
width: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.captcha {
|
|
||||||
float: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.red-fonts {
|
.red-fonts {
|
||||||
color: red;
|
color: red;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-error {
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group.has-error {
|
.form-group.has-error {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
@@ -109,14 +98,6 @@
|
|||||||
padding-top: 10px;
|
padding-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.radio, .checkbox {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#github_star {
|
|
||||||
float: right;
|
|
||||||
margin: 10px 10px 0 0;
|
|
||||||
}
|
|
||||||
.more-login-item {
|
.more-login-item {
|
||||||
border-right: 1px dashed #dedede;
|
border-right: 1px dashed #dedede;
|
||||||
padding-left: 5px;
|
padding-left: 5px;
|
||||||
@@ -127,11 +108,18 @@
|
|||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.select-con {
|
||||||
|
width: 30%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mfa-div {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div class="login-content ">
|
<div class="login-content">
|
||||||
<div class="right-image-box">
|
<div class="right-image-box">
|
||||||
<a href="{% if not XPACK_ENABLED %}https://github.com/jumpserver/jumpserver{% endif %}">
|
<a href="{% if not XPACK_ENABLED %}https://github.com/jumpserver/jumpserver{% endif %}">
|
||||||
<img src="{{ LOGIN_IMAGE_URL }}" style="height: 100%; width: 100%"/>
|
<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">
|
<form id="login-form" action="" method="post" role="form" novalidate="novalidate">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<div style="line-height: 17px;margin-bottom: 20px;color: #999999;">
|
<div style="line-height: 17px;margin-bottom: 20px;color: #999999;">
|
||||||
{% if form.errors %}
|
{% if form.non_field_errors %}
|
||||||
<p class="help-block">
|
<p class="help-block red-fonts">
|
||||||
{% if form.non_field_errors %}
|
{{ form.non_field_errors.as_text }}
|
||||||
{{ form.non_field_errors.as_text }}
|
|
||||||
{% endif %}
|
|
||||||
</p>
|
</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="welcome-message">
|
<p class="welcome-message">
|
||||||
@@ -172,6 +158,10 @@
|
|||||||
</div>
|
</div>
|
||||||
{% if form.challenge %}
|
{% if form.challenge %}
|
||||||
{% bootstrap_field form.challenge show_label=False %}
|
{% bootstrap_field form.challenge show_label=False %}
|
||||||
|
{% elif form.mfa_type %}
|
||||||
|
<div class="form-group" style="display: flex">
|
||||||
|
{% include '_mfa_login_field.html' %}
|
||||||
|
</div>
|
||||||
{% elif form.captcha %}
|
{% elif form.captcha %}
|
||||||
<div class="captch-field">
|
<div class="captch-field">
|
||||||
{% bootstrap_field form.captcha show_label=False %}
|
{% bootstrap_field form.captcha show_label=False %}
|
||||||
@@ -197,35 +187,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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 class="hr-line-dashed"></div>
|
||||||
<div style="display: inline-block; float: left">
|
<div style="display: inline-block; float: left">
|
||||||
<b class="text-muted text-left" >{% trans "More login options" %}</b>
|
<b class="text-muted text-left" >{% trans "More login options" %}</b>
|
||||||
{% if AUTH_OPENID %}
|
{% for method in auth_methods %}
|
||||||
<a href="{% url 'authentication:openid:login' %}" class="more-login-item">
|
<a href="{{ method.url }}" class="more-login-item">
|
||||||
<i class="fa fa-openid"></i> {% trans 'OpenID' %}
|
<i class="fa"><img src="{{ method.logo }}" height="13" width="13"></i> {{ method.name }}
|
||||||
</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' %}
|
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="text-center" style="display: inline-block;">
|
<div class="text-center" style="display: inline-block;">
|
||||||
@@ -238,6 +208,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
{% include '_foot_js.html' %}
|
||||||
<script type="text/javascript" src="/static/js/plugins/jsencrypt/jsencrypt.min.js"></script>
|
<script type="text/javascript" src="/static/js/plugins/jsencrypt/jsencrypt.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
function encryptLoginPassword(password, rsaPublicKey) {
|
function encryptLoginPassword(password, rsaPublicKey) {
|
||||||
@@ -255,7 +226,7 @@
|
|||||||
var password = $('#password').val(); //明文密码
|
var password = $('#password').val(); //明文密码
|
||||||
var passwordEncrypted = encryptLoginPassword(password, rsaPublicKey)
|
var passwordEncrypted = encryptLoginPassword(password, rsaPublicKey)
|
||||||
$('#password-hidden').val(passwordEncrypted); //返回给密码输入input
|
$('#password-hidden').val(passwordEncrypted); //返回给密码输入input
|
||||||
$('#login-form').submit();//post提交
|
$('#login-form').submit(); //post提交
|
||||||
}
|
}
|
||||||
|
|
||||||
$(document).ready(function () {
|
$(document).ready(function () {
|
||||||
|
|||||||
30
apps/authentication/templates/authentication/login_mfa.html
Normal file
30
apps/authentication/templates/authentication/login_mfa.html
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{% extends '_base_only_content.html' %}
|
||||||
|
{% load static %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block title %}
|
||||||
|
{% trans 'MFA Auth' %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<form class="m-t" role="form" method="post" action="">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% if 'code' in form.errors %}
|
||||||
|
<p class="red-fonts">{{ form.code.errors.as_text }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<div class="form-group">
|
||||||
|
{% include '_mfa_login_field.html' %}
|
||||||
|
</div>
|
||||||
|
<button id='submit_button' type="submit" class="btn btn-primary block full-width m-b">
|
||||||
|
{% trans 'Next' %}
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<small>{% trans "Can't provide security? Please contact the administrator!" %}</small>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<style>
|
||||||
|
.mfa-div {
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
{% extends '_base_only_content.html' %}
|
|
||||||
{% load static %}
|
|
||||||
{% load i18n %}
|
|
||||||
|
|
||||||
{% block title %}
|
|
||||||
{% trans 'MFA' %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<form class="m-t" role="form" method="post" action="">
|
|
||||||
{% csrf_token %}
|
|
||||||
{% if 'code' in form.errors %}
|
|
||||||
<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>
|
|
||||||
</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>
|
|
||||||
<div>
|
|
||||||
<small>{% trans "Can't provide security? Please contact the administrator!" %}</small>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<style type="text/css">
|
|
||||||
.disabledBtn {
|
|
||||||
background: #e6e4e4!important;
|
|
||||||
border-color: #d8d5d5!important;
|
|
||||||
color: #949191!important;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<script>
|
|
||||||
|
|
||||||
var methodSelect = document.getElementById('verify-method-select');
|
|
||||||
if (methodSelect.value !== null) {
|
|
||||||
select_change(methodSelect.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function select_change(type){
|
|
||||||
var currentBtn = document.getElementById('send-sms-verify-code');
|
|
||||||
|
|
||||||
if (type == "sms") {
|
|
||||||
currentBtn.style.display = "block";
|
|
||||||
currentBtn.disabled = false;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
currentBtn.style.display = "none";
|
|
||||||
currentBtn.disabled = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function sendSMSVerifyCode(){
|
|
||||||
var currentBtn = document.getElementById('send-sms-verify-code');
|
|
||||||
var time = 60
|
|
||||||
var url = "{% url 'api-auth:sms-verify-code-send' %}";
|
|
||||||
requestApi({
|
|
||||||
url: url,
|
|
||||||
method: "POST",
|
|
||||||
success: function (data) {
|
|
||||||
currentBtn.innerHTML = `{% trans 'Wait: ' %} ${time}`;
|
|
||||||
currentBtn.disabled = true
|
|
||||||
currentBtn.classList.add("disabledBtn" )
|
|
||||||
var TimeInterval = setInterval(()=>{
|
|
||||||
--time
|
|
||||||
currentBtn.innerHTML = `{% trans 'Wait: ' %} ${time}`;
|
|
||||||
if(time === 0) {
|
|
||||||
currentBtn.innerHTML = "{% trans 'Send verification code' %}"
|
|
||||||
currentBtn.disabled = false
|
|
||||||
currentBtn.classList.remove("disabledBtn")
|
|
||||||
clearInterval(TimeInterval)
|
|
||||||
}
|
|
||||||
},1000)
|
|
||||||
alert("{% trans 'The verification code has been sent' %}");
|
|
||||||
},
|
|
||||||
error: function (text, data) {
|
|
||||||
alert(data.detail)
|
|
||||||
},
|
|
||||||
flash_message: false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -159,7 +159,9 @@ $(document).ready(function () {
|
|||||||
}).on('click', '.btn-return', function () {
|
}).on('click', '.btn-return', function () {
|
||||||
cancelTicket();
|
cancelTicket();
|
||||||
cancelCloseConfirm();
|
cancelCloseConfirm();
|
||||||
window.location = "{% url 'authentication:login' %}"
|
setTimeout(() => {
|
||||||
|
window.location = "{% url 'authentication:login' %}"
|
||||||
|
}, 1000);
|
||||||
})
|
})
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ router.register('connection-token', api.UserConnectionTokenViewSet, 'connection-
|
|||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
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/', 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'),
|
path('wecom/qr/unbind/<uuid:user_id>/', api.WeComQRUnBindForAdminApi.as_view(), name='wecom-qr-unbind-for-admin'),
|
||||||
|
|
||||||
@@ -26,13 +25,13 @@ urlpatterns = [
|
|||||||
|
|
||||||
path('auth/', api.TokenCreateApi.as_view(), name='user-auth'),
|
path('auth/', api.TokenCreateApi.as_view(), name='user-auth'),
|
||||||
path('tokens/', api.TokenCreateApi.as_view(), name='auth-token'),
|
path('tokens/', api.TokenCreateApi.as_view(), name='auth-token'),
|
||||||
path('mfa/challenge/', api.MFAChallengeApi.as_view(), name='mfa-challenge'),
|
path('mfa/verify/', api.MFAChallengeVerifyApi.as_view(), name='mfa-verify'),
|
||||||
path('mfa/select/', api.MFASelectTypeApi.as_view(), name='mfa-select'),
|
path('mfa/challenge/', api.MFAChallengeVerifyApi.as_view(), name='mfa-challenge'),
|
||||||
|
path('mfa/select/', api.MFASendCodeApi.as_view(), name='mfa-select'),
|
||||||
|
path('mfa/send-code/', api.MFASendCodeApi.as_view(), name='mfa-send-codej'),
|
||||||
path('otp/verify/', api.UserOtpVerifyApi.as_view(), name='user-otp-verify'),
|
path('otp/verify/', api.UserOtpVerifyApi.as_view(), name='user-otp-verify'),
|
||||||
path('sms/verify-code/send/', api.SendSMSVerifyCodeApi.as_view(), name='sms-verify-code-send'),
|
|
||||||
path('password/verify/', api.UserPasswordVerifyApi.as_view(), name='user-password-verify'),
|
path('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-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
|
urlpatterns += router.urls
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ app_name = 'authentication'
|
|||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
# login
|
# login
|
||||||
path('login/', non_atomic_requests(views.UserLoginView.as_view()), name='login'),
|
path('login/', non_atomic_requests(views.UserLoginView.as_view()), name='login'),
|
||||||
path('login/otp/', views.UserLoginOtpView.as_view(), name='login-otp'),
|
path('login/mfa/', views.UserLoginMFAView.as_view(), name='login-mfa'),
|
||||||
path('login/wait-confirm/', views.UserLoginWaitConfirmView.as_view(), name='login-wait-confirm'),
|
path('login/wait-confirm/', views.UserLoginWaitConfirmView.as_view(), name='login-wait-confirm'),
|
||||||
path('login/guard/', views.UserLoginGuardView.as_view(), name='login-guard'),
|
path('login/guard/', views.UserLoginGuardView.as_view(), name='login-guard'),
|
||||||
path('logout/', views.UserLogoutView.as_view(), name='logout'),
|
path('logout/', views.UserLogoutView.as_view(), name='logout'),
|
||||||
@@ -22,24 +22,18 @@ urlpatterns = [
|
|||||||
path('password/reset/', users_view.UserResetPasswordView.as_view(), name='reset-password'),
|
path('password/reset/', users_view.UserResetPasswordView.as_view(), name='reset-password'),
|
||||||
path('password/verify/', users_view.UserVerifyPasswordView.as_view(), name='user-verify-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/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/bind/', views.WeComQRBindView.as_view(), name='wecom-qr-bind'),
|
||||||
path('wecom/qr/login/', views.WeComQRLoginView.as_view(), name='wecom-qr-login'),
|
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/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('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/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/bind/', views.DingTalkQRBindView.as_view(), name='dingtalk-qr-bind'),
|
||||||
path('dingtalk/qr/login/', views.DingTalkQRLoginView.as_view(), name='dingtalk-qr-login'),
|
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/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('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/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/bind/', views.FeiShuQRBindView.as_view(), name='feishu-qr-bind'),
|
||||||
path('feishu/qr/login/', views.FeiShuQRLoginView.as_view(), name='feishu-qr-login'),
|
path('feishu/qr/login/', views.FeiShuQRLoginView.as_view(), name='feishu-qr-login'),
|
||||||
@@ -48,14 +42,15 @@ urlpatterns = [
|
|||||||
|
|
||||||
# Profile
|
# Profile
|
||||||
path('profile/pubkey/generate/', users_view.UserPublicKeyGenerateView.as_view(), name='user-pubkey-generate'),
|
path('profile/pubkey/generate/', users_view.UserPublicKeyGenerateView.as_view(), name='user-pubkey-generate'),
|
||||||
|
path('profile/mfa/', users_view.MFASettingView.as_view(), name='user-mfa-setting'),
|
||||||
|
|
||||||
|
# OTP Setting
|
||||||
path('profile/otp/enable/start/', users_view.UserOtpEnableStartView.as_view(), name='user-otp-enable-start'),
|
path('profile/otp/enable/start/', users_view.UserOtpEnableStartView.as_view(), name='user-otp-enable-start'),
|
||||||
path('profile/otp/enable/install-app/', users_view.UserOtpEnableInstallAppView.as_view(),
|
path('profile/otp/enable/install-app/', users_view.UserOtpEnableInstallAppView.as_view(),
|
||||||
name='user-otp-enable-install-app'),
|
name='user-otp-enable-install-app'),
|
||||||
path('profile/otp/enable/bind/', users_view.UserOtpEnableBindView.as_view(), name='user-otp-enable-bind'),
|
path('profile/otp/enable/bind/', users_view.UserOtpEnableBindView.as_view(), name='user-otp-enable-bind'),
|
||||||
path('profile/otp/disable/authentication/', users_view.UserDisableMFAView.as_view(),
|
path('profile/otp/disable/', users_view.UserOtpDisableView.as_view(),
|
||||||
name='user-otp-disable-authentication'),
|
name='user-otp-disable'),
|
||||||
path('profile/otp/update/', users_view.UserOtpUpdateView.as_view(), name='user-otp-update'),
|
|
||||||
path('profile/otp/settings-success/', users_view.UserOtpSettingsSuccessView.as_view(), name='user-otp-settings-success'),
|
|
||||||
path('first-login/', users_view.UserFirstLoginView.as_view(), name='user-first-login'),
|
path('first-login/', users_view.UserFirstLoginView.as_view(), name='user-first-login'),
|
||||||
|
|
||||||
# openid
|
# openid
|
||||||
|
|||||||
@@ -4,6 +4,13 @@ import base64
|
|||||||
from Cryptodome.PublicKey import RSA
|
from Cryptodome.PublicKey import RSA
|
||||||
from Cryptodome.Cipher import PKCS1_v1_5
|
from Cryptodome.Cipher import PKCS1_v1_5
|
||||||
from Cryptodome import Random
|
from Cryptodome import Random
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
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
|
from common.utils import get_logger
|
||||||
|
|
||||||
logger = get_logger(__file__)
|
logger = get_logger(__file__)
|
||||||
@@ -43,3 +50,23 @@ def rsa_decrypt(cipher_text, rsa_private_key=None):
|
|||||||
cipher_decoded = base64.b16decode(hex_fixed.upper())
|
cipher_decoded = base64.b16decode(hex_fixed.upper())
|
||||||
message = cipher.decrypt(cipher_decoded, b'error').decode()
|
message = cipher.decrypt(cipher_decoded, b'error').decode()
|
||||||
return message
|
return message
|
||||||
|
|
||||||
|
|
||||||
|
def check_different_city_login_if_need(user, request):
|
||||||
|
if not settings.SECURITY_CHECK_DIFFERENT_CITY_LOGIN:
|
||||||
|
return
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
city_white = ['LAN', ]
|
||||||
|
if city not in city_white:
|
||||||
|
last_user_login = UserLoginLog.objects.exclude(city__in=city_white) \
|
||||||
|
.filter(username=user.username, status=True).first()
|
||||||
|
|
||||||
|
if last_user_login and last_user_login.city != city:
|
||||||
|
DifferentCityLoginMessage(user, ip, city).publish_async()
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import urllib
|
|
||||||
|
|
||||||
from django.http.response import HttpResponseRedirect
|
from django.http.response import HttpResponseRedirect
|
||||||
from django.utils.decorators import method_decorator
|
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
from django.views.decorators.cache import never_cache
|
from urllib.parse import urlencode
|
||||||
from django.views.generic import TemplateView
|
|
||||||
from django.views import View
|
from django.views import View
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.http.request import HttpRequest
|
from django.http.request import HttpRequest
|
||||||
@@ -15,14 +11,14 @@ from rest_framework.exceptions import APIException
|
|||||||
from users.views import UserVerifyPasswordView
|
from users.views import UserVerifyPasswordView
|
||||||
from users.utils import is_auth_password_time_valid
|
from users.utils import is_auth_password_time_valid
|
||||||
from users.models import User
|
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.random import random_string
|
||||||
from common.utils.django import reverse, get_object_or_none
|
from common.utils.django import reverse, get_object_or_none
|
||||||
from common.message.backends.dingtalk import URL
|
from common.sdk.im.dingtalk import URL
|
||||||
from common.mixins.views import PermissionsMixin
|
from common.mixins.views import PermissionsMixin
|
||||||
from authentication import errors
|
from authentication import errors
|
||||||
from authentication.mixins import AuthMixin
|
from authentication.mixins import AuthMixin
|
||||||
from common.message.backends.dingtalk import DingTalk
|
from common.sdk.im.dingtalk import DingTalk
|
||||||
|
|
||||||
logger = get_logger(__file__)
|
logger = get_logger(__file__)
|
||||||
|
|
||||||
@@ -39,7 +35,7 @@ class DingTalkQRMixin(PermissionsMixin, View):
|
|||||||
msg = e.detail['errmsg']
|
msg = e.detail['errmsg']
|
||||||
except Exception:
|
except Exception:
|
||||||
msg = _('DingTalk Error, Please contact your system administrator')
|
msg = _('DingTalk Error, Please contact your system administrator')
|
||||||
return self.get_failed_reponse(
|
return self.get_failed_response(
|
||||||
'/',
|
'/',
|
||||||
_('DingTalk Error'),
|
_('DingTalk Error'),
|
||||||
msg
|
msg
|
||||||
@@ -53,8 +49,8 @@ class DingTalkQRMixin(PermissionsMixin, View):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def get_verify_state_failed_response(self, redirect_uri):
|
def get_verify_state_failed_response(self, redirect_uri):
|
||||||
msg = _("You've been hacked")
|
msg = _("The system configuration is incorrect. Please contact your administrator")
|
||||||
return self.get_failed_reponse(redirect_uri, msg, msg)
|
return self.get_failed_response(redirect_uri, msg, msg)
|
||||||
|
|
||||||
def get_qr_url(self, redirect_uri):
|
def get_qr_url(self, redirect_uri):
|
||||||
state = random_string(16)
|
state = random_string(16)
|
||||||
@@ -67,30 +63,32 @@ class DingTalkQRMixin(PermissionsMixin, View):
|
|||||||
'state': state,
|
'state': state,
|
||||||
'redirect_uri': redirect_uri,
|
'redirect_uri': redirect_uri,
|
||||||
}
|
}
|
||||||
url = URL.QR_CONNECT + '?' + urllib.parse.urlencode(params)
|
url = URL.QR_CONNECT + '?' + urlencode(params)
|
||||||
return url
|
return url
|
||||||
|
|
||||||
def get_success_reponse(self, redirect_url, title, msg):
|
@staticmethod
|
||||||
ok_flash_msg_url = reverse('authentication:dingtalk-bind-success-flash-msg')
|
def get_success_response(redirect_url, title, msg):
|
||||||
ok_flash_msg_url += '?' + urllib.parse.urlencode({
|
message_data = {
|
||||||
'redirect_url': redirect_url,
|
|
||||||
'title': title,
|
'title': title,
|
||||||
'msg': msg
|
'message': msg,
|
||||||
})
|
'interval': 5,
|
||||||
return HttpResponseRedirect(ok_flash_msg_url)
|
'redirect_url': redirect_url,
|
||||||
|
}
|
||||||
|
return FlashMessageUtil.gen_and_redirect_to(message_data)
|
||||||
|
|
||||||
def get_failed_reponse(self, redirect_url, title, msg):
|
@staticmethod
|
||||||
failed_flash_msg_url = reverse('authentication:dingtalk-bind-failed-flash-msg')
|
def get_failed_response(redirect_url, title, msg):
|
||||||
failed_flash_msg_url += '?' + urllib.parse.urlencode({
|
message_data = {
|
||||||
'redirect_url': redirect_url,
|
|
||||||
'title': title,
|
'title': title,
|
||||||
'msg': msg
|
'error': msg,
|
||||||
})
|
'interval': 5,
|
||||||
return HttpResponseRedirect(failed_flash_msg_url)
|
'redirect_url': redirect_url,
|
||||||
|
}
|
||||||
|
return FlashMessageUtil.gen_and_redirect_to(message_data)
|
||||||
|
|
||||||
def get_already_bound_response(self, redirect_url):
|
def get_already_bound_response(self, redirect_url):
|
||||||
msg = _('DingTalk is already bound')
|
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
|
return response
|
||||||
|
|
||||||
|
|
||||||
@@ -103,11 +101,11 @@ class DingTalkQRBindView(DingTalkQRMixin, View):
|
|||||||
|
|
||||||
if not is_auth_password_time_valid(request.session):
|
if not is_auth_password_time_valid(request.session):
|
||||||
msg = _('Please verify your password first')
|
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
|
return response
|
||||||
|
|
||||||
redirect_uri = reverse('authentication:dingtalk-qr-bind-callback', kwargs={'user_id': user.id}, external=True)
|
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)
|
url = self.get_qr_url(redirect_uri)
|
||||||
return HttpResponseRedirect(url)
|
return HttpResponseRedirect(url)
|
||||||
@@ -127,7 +125,7 @@ class DingTalkQRBindCallbackView(DingTalkQRMixin, View):
|
|||||||
if user is None:
|
if user is None:
|
||||||
logger.error(f'DingTalkQR bind callback error, user_id invalid: user_id={user_id}')
|
logger.error(f'DingTalkQR bind callback error, user_id invalid: user_id={user_id}')
|
||||||
msg = _('Invalid 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
|
return response
|
||||||
|
|
||||||
if user.dingtalk_id:
|
if user.dingtalk_id:
|
||||||
@@ -143,7 +141,7 @@ class DingTalkQRBindCallbackView(DingTalkQRMixin, View):
|
|||||||
|
|
||||||
if not userid:
|
if not userid:
|
||||||
msg = _('DingTalk query user failed')
|
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
|
return response
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -152,12 +150,12 @@ class DingTalkQRBindCallbackView(DingTalkQRMixin, View):
|
|||||||
except IntegrityError as e:
|
except IntegrityError as e:
|
||||||
if e.args[0] == 1062:
|
if e.args[0] == 1062:
|
||||||
msg = _('The DingTalk is already bound to another user')
|
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
|
return response
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
msg = _('Binding DingTalk successfully')
|
msg = _('Binding DingTalk successfully')
|
||||||
response = self.get_success_reponse(redirect_url, msg, msg)
|
response = self.get_success_response(redirect_url, msg, msg)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@@ -169,7 +167,7 @@ class DingTalkEnableStartView(UserVerifyPasswordView):
|
|||||||
|
|
||||||
success_url = reverse('authentication:dingtalk-qr-bind')
|
success_url = reverse('authentication:dingtalk-qr-bind')
|
||||||
|
|
||||||
success_url += '?' + urllib.parse.urlencode({
|
success_url += '?' + urlencode({
|
||||||
'redirect_url': redirect_url or referer
|
'redirect_url': redirect_url or referer
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -183,7 +181,7 @@ class DingTalkQRLoginView(DingTalkQRMixin, View):
|
|||||||
redirect_url = request.GET.get('redirect_url')
|
redirect_url = request.GET.get('redirect_url')
|
||||||
|
|
||||||
redirect_uri = reverse('authentication:dingtalk-qr-login-callback', external=True)
|
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)
|
url = self.get_qr_url(redirect_uri)
|
||||||
return HttpResponseRedirect(url)
|
return HttpResponseRedirect(url)
|
||||||
@@ -209,14 +207,14 @@ class DingTalkQRLoginCallbackView(AuthMixin, DingTalkQRMixin, View):
|
|||||||
if not userid:
|
if not userid:
|
||||||
# 正常流程不会出这个错误,hack 行为
|
# 正常流程不会出这个错误,hack 行为
|
||||||
msg = _('Failed to get user from DingTalk')
|
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
|
return response
|
||||||
|
|
||||||
user = get_object_or_none(User, dingtalk_id=userid)
|
user = get_object_or_none(User, dingtalk_id=userid)
|
||||||
if user is None:
|
if user is None:
|
||||||
title = _('DingTalk is not bound')
|
title = _('DingTalk is not bound')
|
||||||
msg = _('Please login with a password and then bind the DingTalk')
|
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
|
return response
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -224,43 +222,7 @@ class DingTalkQRLoginCallbackView(AuthMixin, DingTalkQRMixin, View):
|
|||||||
except errors.AuthFailedError as e:
|
except errors.AuthFailedError as e:
|
||||||
self.set_login_failed_mark()
|
self.set_login_failed_mark()
|
||||||
msg = e.msg
|
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 response
|
||||||
|
|
||||||
return self.redirect_to_guard_view()
|
return self.redirect_to_guard_view()
|
||||||
|
|
||||||
|
|
||||||
@method_decorator(never_cache, name='dispatch')
|
|
||||||
class FlashDingTalkBindSucceedMsgView(TemplateView):
|
|
||||||
template_name = 'flash_message_standalone.html'
|
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
|
||||||
title = request.GET.get('title')
|
|
||||||
msg = request.GET.get('msg')
|
|
||||||
|
|
||||||
context = {
|
|
||||||
'title': title or _('Binding DingTalk successfully'),
|
|
||||||
'messages': msg or _('Binding DingTalk successfully'),
|
|
||||||
'interval': 5,
|
|
||||||
'redirect_url': request.GET.get('redirect_url'),
|
|
||||||
'auto_redirect': True,
|
|
||||||
}
|
|
||||||
return self.render_to_response(context)
|
|
||||||
|
|
||||||
|
|
||||||
@method_decorator(never_cache, name='dispatch')
|
|
||||||
class FlashDingTalkBindFailedMsgView(TemplateView):
|
|
||||||
template_name = 'flash_message_standalone.html'
|
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
|
||||||
title = request.GET.get('title')
|
|
||||||
msg = request.GET.get('msg')
|
|
||||||
|
|
||||||
context = {
|
|
||||||
'title': title or _('Binding DingTalk failed'),
|
|
||||||
'messages': msg or _('Binding DingTalk failed'),
|
|
||||||
'interval': 5,
|
|
||||||
'redirect_url': request.GET.get('redirect_url'),
|
|
||||||
'auto_redirect': True,
|
|
||||||
}
|
|
||||||
return self.render_to_response(context)
|
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import urllib
|
from django.http.response import HttpResponseRedirect
|
||||||
|
|
||||||
from django.http.response import HttpResponseRedirect, HttpResponse
|
|
||||||
from django.utils.decorators import method_decorator
|
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
from django.views.decorators.cache import never_cache
|
from urllib.parse import urlencode
|
||||||
from django.views.generic import TemplateView
|
|
||||||
from django.views import View
|
from django.views import View
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.http.request import HttpRequest
|
from django.http.request import HttpRequest
|
||||||
@@ -15,11 +11,11 @@ from rest_framework.exceptions import APIException
|
|||||||
from users.utils import is_auth_password_time_valid
|
from users.utils import is_auth_password_time_valid
|
||||||
from users.views import UserVerifyPasswordView
|
from users.views import UserVerifyPasswordView
|
||||||
from users.models import User
|
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.random import random_string
|
||||||
from common.utils.django import reverse, get_object_or_none
|
from common.utils.django import reverse, get_object_or_none
|
||||||
from common.mixins.views import PermissionsMixin
|
from common.mixins.views import PermissionsMixin
|
||||||
from common.message.backends.feishu import FeiShu, URL
|
from common.sdk.im.feishu import FeiShu, URL
|
||||||
from authentication import errors
|
from authentication import errors
|
||||||
from authentication.mixins import AuthMixin
|
from authentication.mixins import AuthMixin
|
||||||
|
|
||||||
@@ -35,7 +31,7 @@ class FeiShuQRMixin(PermissionsMixin, View):
|
|||||||
return super().dispatch(request, *args, **kwargs)
|
return super().dispatch(request, *args, **kwargs)
|
||||||
except APIException as e:
|
except APIException as e:
|
||||||
msg = str(e.detail)
|
msg = str(e.detail)
|
||||||
return self.get_failed_reponse(
|
return self.get_failed_response(
|
||||||
'/',
|
'/',
|
||||||
_('FeiShu Error'),
|
_('FeiShu Error'),
|
||||||
msg
|
msg
|
||||||
@@ -49,8 +45,8 @@ class FeiShuQRMixin(PermissionsMixin, View):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def get_verify_state_failed_response(self, redirect_uri):
|
def get_verify_state_failed_response(self, redirect_uri):
|
||||||
msg = _("You've been hacked")
|
msg = _("The system configuration is incorrect. Please contact your administrator")
|
||||||
return self.get_failed_reponse(redirect_uri, msg, msg)
|
return self.get_failed_response(redirect_uri, msg, msg)
|
||||||
|
|
||||||
def get_qr_url(self, redirect_uri):
|
def get_qr_url(self, redirect_uri):
|
||||||
state = random_string(16)
|
state = random_string(16)
|
||||||
@@ -61,30 +57,32 @@ class FeiShuQRMixin(PermissionsMixin, View):
|
|||||||
'state': state,
|
'state': state,
|
||||||
'redirect_uri': redirect_uri,
|
'redirect_uri': redirect_uri,
|
||||||
}
|
}
|
||||||
url = URL.AUTHEN + '?' + urllib.parse.urlencode(params)
|
url = URL.AUTHEN + '?' + urlencode(params)
|
||||||
return url
|
return url
|
||||||
|
|
||||||
def get_success_reponse(self, redirect_url, title, msg):
|
@staticmethod
|
||||||
ok_flash_msg_url = reverse('authentication:feishu-bind-success-flash-msg')
|
def get_success_response(redirect_url, title, msg):
|
||||||
ok_flash_msg_url += '?' + urllib.parse.urlencode({
|
message_data = {
|
||||||
'redirect_url': redirect_url,
|
|
||||||
'title': title,
|
'title': title,
|
||||||
'msg': msg
|
'message': msg,
|
||||||
})
|
'interval': 5,
|
||||||
return HttpResponseRedirect(ok_flash_msg_url)
|
'redirect_url': redirect_url,
|
||||||
|
}
|
||||||
|
return FlashMessageUtil.gen_and_redirect_to(message_data)
|
||||||
|
|
||||||
def get_failed_reponse(self, redirect_url, title, msg):
|
@staticmethod
|
||||||
failed_flash_msg_url = reverse('authentication:feishu-bind-failed-flash-msg')
|
def get_failed_response(redirect_url, title, msg):
|
||||||
failed_flash_msg_url += '?' + urllib.parse.urlencode({
|
message_data = {
|
||||||
'redirect_url': redirect_url,
|
|
||||||
'title': title,
|
'title': title,
|
||||||
'msg': msg
|
'error': msg,
|
||||||
})
|
'interval': 5,
|
||||||
return HttpResponseRedirect(failed_flash_msg_url)
|
'redirect_url': redirect_url,
|
||||||
|
}
|
||||||
|
return FlashMessageUtil.gen_and_redirect_to(message_data)
|
||||||
|
|
||||||
def get_already_bound_response(self, redirect_url):
|
def get_already_bound_response(self, redirect_url):
|
||||||
msg = _('FeiShu is already bound')
|
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
|
return response
|
||||||
|
|
||||||
|
|
||||||
@@ -97,11 +95,11 @@ class FeiShuQRBindView(FeiShuQRMixin, View):
|
|||||||
|
|
||||||
if not is_auth_password_time_valid(request.session):
|
if not is_auth_password_time_valid(request.session):
|
||||||
msg = _('Please verify your password first')
|
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
|
return response
|
||||||
|
|
||||||
redirect_uri = reverse('authentication:feishu-qr-bind-callback', external=True)
|
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)
|
url = self.get_qr_url(redirect_uri)
|
||||||
return HttpResponseRedirect(url)
|
return HttpResponseRedirect(url)
|
||||||
@@ -131,7 +129,7 @@ class FeiShuQRBindCallbackView(FeiShuQRMixin, View):
|
|||||||
|
|
||||||
if not user_id:
|
if not user_id:
|
||||||
msg = _('FeiShu query user failed')
|
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
|
return response
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -140,12 +138,12 @@ class FeiShuQRBindCallbackView(FeiShuQRMixin, View):
|
|||||||
except IntegrityError as e:
|
except IntegrityError as e:
|
||||||
if e.args[0] == 1062:
|
if e.args[0] == 1062:
|
||||||
msg = _('The FeiShu is already bound to another user')
|
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
|
return response
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
msg = _('Binding FeiShu successfully')
|
msg = _('Binding FeiShu successfully')
|
||||||
response = self.get_success_reponse(redirect_url, msg, msg)
|
response = self.get_success_response(redirect_url, msg, msg)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@@ -157,7 +155,7 @@ class FeiShuEnableStartView(UserVerifyPasswordView):
|
|||||||
|
|
||||||
success_url = reverse('authentication:feishu-qr-bind')
|
success_url = reverse('authentication:feishu-qr-bind')
|
||||||
|
|
||||||
success_url += '?' + urllib.parse.urlencode({
|
success_url += '?' + urlencode({
|
||||||
'redirect_url': redirect_url or referer
|
'redirect_url': redirect_url or referer
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -171,7 +169,7 @@ class FeiShuQRLoginView(FeiShuQRMixin, View):
|
|||||||
redirect_url = request.GET.get('redirect_url')
|
redirect_url = request.GET.get('redirect_url')
|
||||||
|
|
||||||
redirect_uri = reverse('authentication:feishu-qr-login-callback', external=True)
|
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)
|
url = self.get_qr_url(redirect_uri)
|
||||||
return HttpResponseRedirect(url)
|
return HttpResponseRedirect(url)
|
||||||
@@ -196,14 +194,14 @@ class FeiShuQRLoginCallbackView(AuthMixin, FeiShuQRMixin, View):
|
|||||||
if not user_id:
|
if not user_id:
|
||||||
# 正常流程不会出这个错误,hack 行为
|
# 正常流程不会出这个错误,hack 行为
|
||||||
msg = _('Failed to get user from FeiShu')
|
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
|
return response
|
||||||
|
|
||||||
user = get_object_or_none(User, feishu_id=user_id)
|
user = get_object_or_none(User, feishu_id=user_id)
|
||||||
if user is None:
|
if user is None:
|
||||||
title = _('FeiShu is not bound')
|
title = _('FeiShu is not bound')
|
||||||
msg = _('Please login with a password and then bind the FeiShu')
|
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
|
return response
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -211,43 +209,7 @@ class FeiShuQRLoginCallbackView(AuthMixin, FeiShuQRMixin, View):
|
|||||||
except errors.AuthFailedError as e:
|
except errors.AuthFailedError as e:
|
||||||
self.set_login_failed_mark()
|
self.set_login_failed_mark()
|
||||||
msg = e.msg
|
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 response
|
||||||
|
|
||||||
return self.redirect_to_guard_view()
|
return self.redirect_to_guard_view()
|
||||||
|
|
||||||
|
|
||||||
@method_decorator(never_cache, name='dispatch')
|
|
||||||
class FlashFeiShuBindSucceedMsgView(TemplateView):
|
|
||||||
template_name = 'flash_message_standalone.html'
|
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
|
||||||
title = request.GET.get('title')
|
|
||||||
msg = request.GET.get('msg')
|
|
||||||
|
|
||||||
context = {
|
|
||||||
'title': title or _('Binding FeiShu successfully'),
|
|
||||||
'messages': msg or _('Binding FeiShu successfully'),
|
|
||||||
'interval': 5,
|
|
||||||
'redirect_url': request.GET.get('redirect_url'),
|
|
||||||
'auto_redirect': True,
|
|
||||||
}
|
|
||||||
return self.render_to_response(context)
|
|
||||||
|
|
||||||
|
|
||||||
@method_decorator(never_cache, name='dispatch')
|
|
||||||
class FlashFeiShuBindFailedMsgView(TemplateView):
|
|
||||||
template_name = 'flash_message_standalone.html'
|
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
|
||||||
title = request.GET.get('title')
|
|
||||||
msg = request.GET.get('msg')
|
|
||||||
|
|
||||||
context = {
|
|
||||||
'title': title or _('Binding FeiShu failed'),
|
|
||||||
'messages': msg or _('Binding FeiShu failed'),
|
|
||||||
'interval': 5,
|
|
||||||
'redirect_url': request.GET.get('redirect_url'),
|
|
||||||
'auto_redirect': True,
|
|
||||||
}
|
|
||||||
return self.render_to_response(context)
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from __future__ import unicode_literals
|
|||||||
import os
|
import os
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
|
from django.templatetags.static import static
|
||||||
from django.contrib.auth import login as auth_login, logout as auth_logout
|
from django.contrib.auth import login as auth_login, logout as auth_logout
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from django.shortcuts import reverse, redirect
|
from django.shortcuts import reverse, redirect
|
||||||
@@ -28,7 +29,6 @@ from ..const import RSA_PRIVATE_KEY, RSA_PUBLIC_KEY
|
|||||||
from .. import mixins, errors
|
from .. import mixins, errors
|
||||||
from ..forms import get_user_login_form_cls
|
from ..forms import get_user_login_form_cls
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'UserLoginView', 'UserLogoutView',
|
'UserLoginView', 'UserLogoutView',
|
||||||
'UserLoginGuardView', 'UserLoginWaitConfirmView',
|
'UserLoginGuardView', 'UserLoginWaitConfirmView',
|
||||||
@@ -66,6 +66,8 @@ class UserLoginView(mixins.AuthMixin, FormView):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
login_redirect = settings.LOGIN_REDIRECT_TO_BACKEND.lower()
|
login_redirect = settings.LOGIN_REDIRECT_TO_BACKEND.lower()
|
||||||
|
if login_redirect in ['direct']:
|
||||||
|
return None
|
||||||
if login_redirect in ['cas'] and cas_auth_url:
|
if login_redirect in ['cas'] and cas_auth_url:
|
||||||
auth_url = cas_auth_url
|
auth_url = cas_auth_url
|
||||||
elif login_redirect in ['openid', 'oidc'] and openid_auth_url:
|
elif login_redirect in ['openid', 'oidc'] and openid_auth_url:
|
||||||
@@ -109,20 +111,33 @@ class UserLoginView(mixins.AuthMixin, FormView):
|
|||||||
self.request.session.delete_test_cookie()
|
self.request.session.delete_test_cookie()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with transaction.atomic():
|
self.check_user_auth(decrypt_passwd=True)
|
||||||
self.check_user_auth(decrypt_passwd=True)
|
|
||||||
except errors.AuthFailedError as e:
|
except errors.AuthFailedError as e:
|
||||||
form.add_error(None, e.msg)
|
form.add_error(None, e.msg)
|
||||||
self.set_login_failed_mark()
|
self.set_login_failed_mark()
|
||||||
|
|
||||||
form_cls = get_user_login_form_cls(captcha=True)
|
form_cls = get_user_login_form_cls(captcha=True)
|
||||||
new_form = form_cls(data=form.data)
|
new_form = form_cls(data=form.data)
|
||||||
new_form._errors = form.errors
|
new_form._errors = form.errors
|
||||||
context = self.get_context_data(form=new_form)
|
context = self.get_context_data(form=new_form)
|
||||||
self.request.session.set_test_cookie()
|
self.request.session.set_test_cookie()
|
||||||
return self.render_to_response(context)
|
return self.render_to_response(context)
|
||||||
except (errors.PasswdTooSimple, errors.PasswordRequireResetError, errors.PasswdNeedUpdate) as e:
|
except (
|
||||||
|
errors.MFAUnsetError,
|
||||||
|
errors.PasswordTooSimple,
|
||||||
|
errors.PasswordRequireResetError,
|
||||||
|
errors.PasswordNeedUpdate
|
||||||
|
) as e:
|
||||||
return redirect(e.url)
|
return redirect(e.url)
|
||||||
|
except (
|
||||||
|
errors.MFAFailedError,
|
||||||
|
errors.BlockMFAError,
|
||||||
|
errors.MFACodeRequiredError,
|
||||||
|
errors.SMSCodeRequiredError,
|
||||||
|
errors.UserPhoneNotSet,
|
||||||
|
errors.BlockGlobalIpLoginError
|
||||||
|
) as e:
|
||||||
|
form.add_error('code', e.msg)
|
||||||
|
return super().form_invalid(form)
|
||||||
self.clear_rsa_key()
|
self.clear_rsa_key()
|
||||||
return self.redirect_to_guard_view()
|
return self.redirect_to_guard_view()
|
||||||
|
|
||||||
@@ -136,20 +151,56 @@ class UserLoginView(mixins.AuthMixin, FormView):
|
|||||||
self.request.session[RSA_PRIVATE_KEY] = None
|
self.request.session[RSA_PRIVATE_KEY] = None
|
||||||
self.request.session[RSA_PUBLIC_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')
|
forgot_password_url = reverse('authentication:forgot-password')
|
||||||
has_other_auth_backend = settings.AUTHENTICATION_BACKENDS[0] != settings.AUTH_BACKEND_MODEL
|
has_other_auth_backend = settings.AUTHENTICATION_BACKENDS[0] != settings.AUTH_BACKEND_MODEL
|
||||||
if has_other_auth_backend and settings.FORGOT_PASSWORD_URL:
|
if has_other_auth_backend and settings.FORGOT_PASSWORD_URL:
|
||||||
forgot_password_url = settings.FORGOT_PASSWORD_URL
|
forgot_password_url = settings.FORGOT_PASSWORD_URL
|
||||||
|
return forgot_password_url
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
context = {
|
context = {
|
||||||
'demo_mode': os.environ.get("DEMO_MODE"),
|
'demo_mode': os.environ.get("DEMO_MODE"),
|
||||||
'AUTH_OPENID': settings.AUTH_OPENID,
|
'auth_methods': self.get_support_auth_methods(),
|
||||||
'AUTH_CAS': settings.AUTH_CAS,
|
'forgot_password_url': self.get_forgot_password_url(),
|
||||||
'AUTH_WECOM': settings.AUTH_WECOM,
|
**self.get_user_mfa_context(self.request.user)
|
||||||
'AUTH_DINGTALK': settings.AUTH_DINGTALK,
|
|
||||||
'AUTH_FEISHU': settings.AUTH_FEISHU,
|
|
||||||
'forgot_password_url': forgot_password_url
|
|
||||||
}
|
}
|
||||||
kwargs.update(context)
|
kwargs.update(context)
|
||||||
return super().get_context_data(**kwargs)
|
return super().get_context_data(**kwargs)
|
||||||
@@ -158,7 +209,7 @@ class UserLoginView(mixins.AuthMixin, FormView):
|
|||||||
class UserLoginGuardView(mixins.AuthMixin, RedirectView):
|
class UserLoginGuardView(mixins.AuthMixin, RedirectView):
|
||||||
redirect_field_name = 'next'
|
redirect_field_name = 'next'
|
||||||
login_url = reverse_lazy('authentication:login')
|
login_url = reverse_lazy('authentication:login')
|
||||||
login_otp_url = reverse_lazy('authentication:login-otp')
|
login_mfa_url = reverse_lazy('authentication:login-mfa')
|
||||||
login_confirm_url = reverse_lazy('authentication:login-wait-confirm')
|
login_confirm_url = reverse_lazy('authentication:login-wait-confirm')
|
||||||
|
|
||||||
def format_redirect_url(self, url):
|
def format_redirect_url(self, url):
|
||||||
@@ -179,15 +230,16 @@ class UserLoginGuardView(mixins.AuthMixin, RedirectView):
|
|||||||
user = self.check_user_auth_if_need()
|
user = self.check_user_auth_if_need()
|
||||||
self.check_user_mfa_if_need(user)
|
self.check_user_mfa_if_need(user)
|
||||||
self.check_user_login_confirm_if_need(user)
|
self.check_user_login_confirm_if_need(user)
|
||||||
except (errors.CredentialError, errors.SessionEmptyError):
|
except (errors.CredentialError, errors.SessionEmptyError) as e:
|
||||||
|
print("Error: ", e)
|
||||||
return self.format_redirect_url(self.login_url)
|
return self.format_redirect_url(self.login_url)
|
||||||
except errors.MFARequiredError:
|
except errors.MFARequiredError:
|
||||||
return self.format_redirect_url(self.login_otp_url)
|
return self.format_redirect_url(self.login_mfa_url)
|
||||||
except errors.LoginConfirmBaseError:
|
except errors.LoginConfirmBaseError:
|
||||||
return self.format_redirect_url(self.login_confirm_url)
|
return self.format_redirect_url(self.login_confirm_url)
|
||||||
except errors.MFAUnsetError as e:
|
except errors.MFAUnsetError as e:
|
||||||
return e.url
|
return e.url
|
||||||
except errors.PasswdTooSimple as e:
|
except errors.PasswordTooSimple as e:
|
||||||
return e.url
|
return e.url
|
||||||
else:
|
else:
|
||||||
self.login_it(user)
|
self.login_it(user)
|
||||||
@@ -254,7 +306,7 @@ class UserLogoutView(TemplateView):
|
|||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = {
|
context = {
|
||||||
'title': _('Logout success'),
|
'title': _('Logout success'),
|
||||||
'messages': _('Logout success, return login page'),
|
'message': _('Logout success, return login page'),
|
||||||
'interval': 3,
|
'interval': 3,
|
||||||
'redirect_url': reverse('authentication:login'),
|
'redirect_url': reverse('authentication:login'),
|
||||||
'auto_redirect': True,
|
'auto_redirect': True,
|
||||||
|
|||||||
@@ -3,60 +3,48 @@
|
|||||||
|
|
||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
from django.views.generic.edit import FormView
|
from django.views.generic.edit import FormView
|
||||||
from django.utils.translation import gettext_lazy as _
|
|
||||||
from django.conf import settings
|
from common.utils import get_logger
|
||||||
from .. import forms, errors, mixins
|
from .. import forms, errors, mixins
|
||||||
from .utils import redirect_to_guard_view
|
from .utils import redirect_to_guard_view
|
||||||
|
|
||||||
from common.utils import get_logger
|
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
__all__ = ['UserLoginOtpView']
|
__all__ = ['UserLoginMFAView']
|
||||||
|
|
||||||
|
|
||||||
class UserLoginOtpView(mixins.AuthMixin, FormView):
|
class UserLoginMFAView(mixins.AuthMixin, FormView):
|
||||||
template_name = 'authentication/login_otp.html'
|
template_name = 'authentication/login_mfa.html'
|
||||||
form_class = forms.UserCheckOtpCodeForm
|
form_class = forms.UserCheckOtpCodeForm
|
||||||
redirect_field_name = 'next'
|
redirect_field_name = 'next'
|
||||||
|
|
||||||
|
def get(self, *args, **kwargs):
|
||||||
|
try:
|
||||||
|
self.get_user_from_session()
|
||||||
|
except errors.SessionEmptyError:
|
||||||
|
return redirect_to_guard_view()
|
||||||
|
return super().get(*args, **kwargs)
|
||||||
|
|
||||||
def form_valid(self, form):
|
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')
|
mfa_type = form.cleaned_data.get('mfa_type')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.check_user_mfa(otp_code, mfa_type)
|
self._do_check_user_mfa(code, mfa_type)
|
||||||
return redirect_to_guard_view()
|
return redirect_to_guard_view()
|
||||||
except (errors.MFAFailedError, errors.BlockMFAError) as e:
|
except (errors.MFAFailedError, errors.BlockMFAError) as e:
|
||||||
form.add_error('code', e.msg)
|
form.add_error('code', e.msg)
|
||||||
return super().form_invalid(form)
|
return super().form_invalid(form)
|
||||||
|
except errors.SessionEmptyError:
|
||||||
|
return redirect_to_guard_view()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(e)
|
logger.error(e)
|
||||||
import traceback
|
import traceback
|
||||||
traceback.print_exception(e)
|
traceback.print_exc()
|
||||||
return redirect_to_guard_view()
|
return redirect_to_guard_view()
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
user = self.get_user_from_session()
|
user = self.get_user_from_session()
|
||||||
context = {
|
mfa_context = self.get_user_mfa_context(user)
|
||||||
'methods': [
|
kwargs.update(mfa_context)
|
||||||
{
|
return kwargs
|
||||||
'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
|
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import urllib
|
|
||||||
|
|
||||||
from django.http.response import HttpResponseRedirect
|
from django.http.response import HttpResponseRedirect
|
||||||
from django.utils.decorators import method_decorator
|
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
from django.views.decorators.cache import never_cache
|
from urllib.parse import urlencode
|
||||||
from django.views.generic import TemplateView
|
|
||||||
from django.views import View
|
from django.views import View
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.http.request import HttpRequest
|
from django.http.request import HttpRequest
|
||||||
@@ -15,11 +11,11 @@ from rest_framework.exceptions import APIException
|
|||||||
from users.views import UserVerifyPasswordView
|
from users.views import UserVerifyPasswordView
|
||||||
from users.utils import is_auth_password_time_valid
|
from users.utils import is_auth_password_time_valid
|
||||||
from users.models import User
|
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.random import random_string
|
||||||
from common.utils.django import reverse, get_object_or_none
|
from common.utils.django import reverse, get_object_or_none
|
||||||
from common.message.backends.wecom import URL
|
from common.sdk.im.wecom import URL
|
||||||
from common.message.backends.wecom import WeCom
|
from common.sdk.im.wecom import WeCom
|
||||||
from common.mixins.views import PermissionsMixin
|
from common.mixins.views import PermissionsMixin
|
||||||
from authentication import errors
|
from authentication import errors
|
||||||
from authentication.mixins import AuthMixin
|
from authentication.mixins import AuthMixin
|
||||||
@@ -39,7 +35,7 @@ class WeComQRMixin(PermissionsMixin, View):
|
|||||||
msg = e.detail['errmsg']
|
msg = e.detail['errmsg']
|
||||||
except Exception:
|
except Exception:
|
||||||
msg = _('WeCom Error, Please contact your system administrator')
|
msg = _('WeCom Error, Please contact your system administrator')
|
||||||
return self.get_failed_reponse(
|
return self.get_failed_response(
|
||||||
'/',
|
'/',
|
||||||
_('WeCom Error'),
|
_('WeCom Error'),
|
||||||
msg
|
msg
|
||||||
@@ -53,8 +49,8 @@ class WeComQRMixin(PermissionsMixin, View):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def get_verify_state_failed_response(self, redirect_uri):
|
def get_verify_state_failed_response(self, redirect_uri):
|
||||||
msg = _("You've been hacked")
|
msg = _("The system configuration is incorrect. Please contact your administrator")
|
||||||
return self.get_failed_reponse(redirect_uri, msg, msg)
|
return self.get_failed_response(redirect_uri, msg, msg)
|
||||||
|
|
||||||
def get_qr_url(self, redirect_uri):
|
def get_qr_url(self, redirect_uri):
|
||||||
state = random_string(16)
|
state = random_string(16)
|
||||||
@@ -66,30 +62,32 @@ class WeComQRMixin(PermissionsMixin, View):
|
|||||||
'state': state,
|
'state': state,
|
||||||
'redirect_uri': redirect_uri,
|
'redirect_uri': redirect_uri,
|
||||||
}
|
}
|
||||||
url = URL.QR_CONNECT + '?' + urllib.parse.urlencode(params)
|
url = URL.QR_CONNECT + '?' + urlencode(params)
|
||||||
return url
|
return url
|
||||||
|
|
||||||
def get_success_reponse(self, redirect_url, title, msg):
|
@staticmethod
|
||||||
ok_flash_msg_url = reverse('authentication:wecom-bind-success-flash-msg')
|
def get_success_response(redirect_url, title, msg):
|
||||||
ok_flash_msg_url += '?' + urllib.parse.urlencode({
|
message_data = {
|
||||||
'redirect_url': redirect_url,
|
|
||||||
'title': title,
|
'title': title,
|
||||||
'msg': msg
|
'message': msg,
|
||||||
})
|
'interval': 5,
|
||||||
return HttpResponseRedirect(ok_flash_msg_url)
|
'redirect_url': redirect_url,
|
||||||
|
}
|
||||||
|
return FlashMessageUtil.gen_and_redirect_to(message_data)
|
||||||
|
|
||||||
def get_failed_reponse(self, redirect_url, title, msg):
|
@staticmethod
|
||||||
failed_flash_msg_url = reverse('authentication:wecom-bind-failed-flash-msg')
|
def get_failed_response(redirect_url, title, msg):
|
||||||
failed_flash_msg_url += '?' + urllib.parse.urlencode({
|
message_data = {
|
||||||
'redirect_url': redirect_url,
|
|
||||||
'title': title,
|
'title': title,
|
||||||
'msg': msg
|
'error': msg,
|
||||||
})
|
'interval': 5,
|
||||||
return HttpResponseRedirect(failed_flash_msg_url)
|
'redirect_url': redirect_url,
|
||||||
|
}
|
||||||
|
return FlashMessageUtil.gen_and_redirect_to(message_data)
|
||||||
|
|
||||||
def get_already_bound_response(self, redirect_url):
|
def get_already_bound_response(self, redirect_url):
|
||||||
msg = _('WeCom is already bound')
|
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
|
return response
|
||||||
|
|
||||||
|
|
||||||
@@ -102,11 +100,11 @@ class WeComQRBindView(WeComQRMixin, View):
|
|||||||
|
|
||||||
if not is_auth_password_time_valid(request.session):
|
if not is_auth_password_time_valid(request.session):
|
||||||
msg = _('Please verify your password first')
|
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
|
return response
|
||||||
|
|
||||||
redirect_uri = reverse('authentication:wecom-qr-bind-callback', kwargs={'user_id': user.id}, external=True)
|
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)
|
url = self.get_qr_url(redirect_uri)
|
||||||
return HttpResponseRedirect(url)
|
return HttpResponseRedirect(url)
|
||||||
@@ -126,7 +124,7 @@ class WeComQRBindCallbackView(WeComQRMixin, View):
|
|||||||
if user is None:
|
if user is None:
|
||||||
logger.error(f'WeComQR bind callback error, user_id invalid: user_id={user_id}')
|
logger.error(f'WeComQR bind callback error, user_id invalid: user_id={user_id}')
|
||||||
msg = _('Invalid 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
|
return response
|
||||||
|
|
||||||
if user.wecom_id:
|
if user.wecom_id:
|
||||||
@@ -141,7 +139,7 @@ class WeComQRBindCallbackView(WeComQRMixin, View):
|
|||||||
wecom_userid, __ = wecom.get_user_id_by_code(code)
|
wecom_userid, __ = wecom.get_user_id_by_code(code)
|
||||||
if not wecom_userid:
|
if not wecom_userid:
|
||||||
msg = _('WeCom query user failed')
|
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
|
return response
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -150,27 +148,24 @@ class WeComQRBindCallbackView(WeComQRMixin, View):
|
|||||||
except IntegrityError as e:
|
except IntegrityError as e:
|
||||||
if e.args[0] == 1062:
|
if e.args[0] == 1062:
|
||||||
msg = _('The WeCom is already bound to another user')
|
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
|
return response
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
msg = _('Binding WeCom successfully')
|
msg = _('Binding WeCom successfully')
|
||||||
response = self.get_success_reponse(redirect_url, msg, msg)
|
response = self.get_success_response(redirect_url, msg, msg)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
class WeComEnableStartView(UserVerifyPasswordView):
|
class WeComEnableStartView(UserVerifyPasswordView):
|
||||||
|
|
||||||
def get_success_url(self):
|
def get_success_url(self):
|
||||||
referer = self.request.META.get('HTTP_REFERER')
|
referer = self.request.META.get('HTTP_REFERER')
|
||||||
redirect_url = self.request.GET.get("redirect_url")
|
redirect_url = self.request.GET.get("redirect_url")
|
||||||
|
|
||||||
success_url = reverse('authentication:wecom-qr-bind')
|
success_url = reverse('authentication:wecom-qr-bind')
|
||||||
|
success_url += '?' + urlencode({
|
||||||
success_url += '?' + urllib.parse.urlencode({
|
|
||||||
'redirect_url': redirect_url or referer
|
'redirect_url': redirect_url or referer
|
||||||
})
|
})
|
||||||
|
|
||||||
return success_url
|
return success_url
|
||||||
|
|
||||||
|
|
||||||
@@ -181,7 +176,7 @@ class WeComQRLoginView(WeComQRMixin, View):
|
|||||||
redirect_url = request.GET.get('redirect_url')
|
redirect_url = request.GET.get('redirect_url')
|
||||||
|
|
||||||
redirect_uri = reverse('authentication:wecom-qr-login-callback', external=True)
|
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)
|
url = self.get_qr_url(redirect_uri)
|
||||||
return HttpResponseRedirect(url)
|
return HttpResponseRedirect(url)
|
||||||
@@ -207,14 +202,14 @@ class WeComQRLoginCallbackView(AuthMixin, WeComQRMixin, View):
|
|||||||
if not wecom_userid:
|
if not wecom_userid:
|
||||||
# 正常流程不会出这个错误,hack 行为
|
# 正常流程不会出这个错误,hack 行为
|
||||||
msg = _('Failed to get user from WeCom')
|
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
|
return response
|
||||||
|
|
||||||
user = get_object_or_none(User, wecom_id=wecom_userid)
|
user = get_object_or_none(User, wecom_id=wecom_userid)
|
||||||
if user is None:
|
if user is None:
|
||||||
title = _('WeCom is not bound')
|
title = _('WeCom is not bound')
|
||||||
msg = _('Please login with a password and then bind the WeCom')
|
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
|
return response
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -222,43 +217,7 @@ class WeComQRLoginCallbackView(AuthMixin, WeComQRMixin, View):
|
|||||||
except errors.AuthFailedError as e:
|
except errors.AuthFailedError as e:
|
||||||
self.set_login_failed_mark()
|
self.set_login_failed_mark()
|
||||||
msg = e.msg
|
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 response
|
||||||
|
|
||||||
return self.redirect_to_guard_view()
|
return self.redirect_to_guard_view()
|
||||||
|
|
||||||
|
|
||||||
@method_decorator(never_cache, name='dispatch')
|
|
||||||
class FlashWeComBindSucceedMsgView(TemplateView):
|
|
||||||
template_name = 'flash_message_standalone.html'
|
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
|
||||||
title = request.GET.get('title')
|
|
||||||
msg = request.GET.get('msg')
|
|
||||||
|
|
||||||
context = {
|
|
||||||
'title': title or _('Binding WeCom successfully'),
|
|
||||||
'messages': msg or _('Binding WeCom successfully'),
|
|
||||||
'interval': 5,
|
|
||||||
'redirect_url': request.GET.get('redirect_url'),
|
|
||||||
'auto_redirect': True,
|
|
||||||
}
|
|
||||||
return self.render_to_response(context)
|
|
||||||
|
|
||||||
|
|
||||||
@method_decorator(never_cache, name='dispatch')
|
|
||||||
class FlashWeComBindFailedMsgView(TemplateView):
|
|
||||||
template_name = 'flash_message_standalone.html'
|
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
|
||||||
title = request.GET.get('title')
|
|
||||||
msg = request.GET.get('msg')
|
|
||||||
|
|
||||||
context = {
|
|
||||||
'title': title or _('Binding WeCom failed'),
|
|
||||||
'messages': msg or _('Binding WeCom failed'),
|
|
||||||
'interval': 5,
|
|
||||||
'redirect_url': request.GET.get('redirect_url'),
|
|
||||||
'auto_redirect': True,
|
|
||||||
}
|
|
||||||
return self.render_to_response(context)
|
|
||||||
|
|||||||
@@ -10,5 +10,6 @@ class CommonConfig(AppConfig):
|
|||||||
def ready(self):
|
def ready(self):
|
||||||
from . import signals_handlers
|
from . import signals_handlers
|
||||||
from .signals import django_ready
|
from .signals import django_ready
|
||||||
if 'migrate' not in sys.argv:
|
if 'migrate' in sys.argv or 'compilemessages' in sys.argv:
|
||||||
django_ready.send(CommonConfig)
|
return
|
||||||
|
django_ready.send(CommonConfig)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from common.utils import get_logger
|
from common.utils import get_logger
|
||||||
|
from django.db import connections
|
||||||
|
|
||||||
logger = get_logger(__file__)
|
logger = get_logger(__file__)
|
||||||
|
|
||||||
@@ -38,3 +39,8 @@ def get_objects(model, pks):
|
|||||||
not_found_pks = pks - exists_pks
|
not_found_pks = pks - exists_pks
|
||||||
logger.error(f'DoesNotExist: <{model.__name__}: {not_found_pks}>')
|
logger.error(f'DoesNotExist: <{model.__name__}: {not_found_pks}>')
|
||||||
return objs
|
return objs
|
||||||
|
|
||||||
|
|
||||||
|
def close_old_connections():
|
||||||
|
for conn in connections.all():
|
||||||
|
conn.close_if_unusable_or_obsolete()
|
||||||
|
|||||||
@@ -2,19 +2,10 @@ from rest_framework.viewsets import GenericViewSet, ModelViewSet, ReadOnlyModelV
|
|||||||
from rest_framework_bulk import BulkModelViewSet
|
from rest_framework_bulk import BulkModelViewSet
|
||||||
|
|
||||||
from ..mixins.api import (
|
from ..mixins.api import (
|
||||||
SerializerMixin, QuerySetMixin, ExtraFilterFieldsMixin, PaginatedResponseMixin,
|
RelationMixin, AllowBulkDestroyMixin, CommonMixin
|
||||||
RelationMixin, AllowBulkDestroyMixin, RenderToJsonMixin,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class CommonMixin(SerializerMixin,
|
|
||||||
QuerySetMixin,
|
|
||||||
ExtraFilterFieldsMixin,
|
|
||||||
PaginatedResponseMixin,
|
|
||||||
RenderToJsonMixin):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class JMSGenericViewSet(CommonMixin, GenericViewSet):
|
class JMSGenericViewSet(CommonMixin, GenericViewSet):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from __future__ import unicode_literals
|
|||||||
|
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
import datetime
|
import datetime
|
||||||
|
from itertools import chain
|
||||||
|
|
||||||
from django.core.exceptions import PermissionDenied
|
from django.core.exceptions import PermissionDenied
|
||||||
from django.http import Http404
|
from django.http import Http404
|
||||||
@@ -101,7 +102,13 @@ class SimpleMetadataWithFilters(SimpleMetadata):
|
|||||||
elif hasattr(view, 'get_filterset_fields'):
|
elif hasattr(view, 'get_filterset_fields'):
|
||||||
fields = view.get_filterset_fields(request)
|
fields = view.get_filterset_fields(request)
|
||||||
elif hasattr(view, 'filterset_class'):
|
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):
|
if isinstance(fields, dict):
|
||||||
fields = list(fields.keys())
|
fields = list(fields.keys())
|
||||||
|
|||||||
@@ -1,346 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
#
|
|
||||||
import time
|
|
||||||
from hashlib import md5
|
|
||||||
from threading import Thread
|
|
||||||
from collections import defaultdict
|
|
||||||
from itertools import chain
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db.models.signals import m2m_changed
|
|
||||||
from django.core.cache import cache
|
|
||||||
from django.http import JsonResponse
|
|
||||||
from django.utils.translation import ugettext as _
|
|
||||||
from django.contrib.auth import get_user_model
|
|
||||||
from rest_framework.response import Response
|
|
||||||
from rest_framework.settings import api_settings
|
|
||||||
from rest_framework.decorators import action
|
|
||||||
from rest_framework.request import Request
|
|
||||||
|
|
||||||
from common.const.http import POST
|
|
||||||
from common.drf.filters import IDSpmFilter, CustomFilter, IDInFilter
|
|
||||||
from ..utils import lazyproperty
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
'JSONResponseMixin', 'CommonApiMixin', 'AsyncApiMixin', 'RelationMixin',
|
|
||||||
'QuerySetMixin', 'ExtraFilterFieldsMixin', 'RenderToJsonMixin',
|
|
||||||
'SerializerMixin', 'AllowBulkDestroyMixin', 'PaginatedResponseMixin'
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
UserModel = get_user_model()
|
|
||||||
|
|
||||||
|
|
||||||
class JSONResponseMixin(object):
|
|
||||||
"""JSON mixin"""
|
|
||||||
@staticmethod
|
|
||||||
def render_json_response(context):
|
|
||||||
return JsonResponse(context)
|
|
||||||
|
|
||||||
|
|
||||||
# SerializerMixin
|
|
||||||
# ----------------------
|
|
||||||
|
|
||||||
|
|
||||||
class RenderToJsonMixin:
|
|
||||||
@action(methods=[POST], detail=False, url_path='render-to-json')
|
|
||||||
def render_to_json(self, request: Request):
|
|
||||||
data = {
|
|
||||||
'title': (),
|
|
||||||
'data': request.data,
|
|
||||||
}
|
|
||||||
|
|
||||||
jms_context = getattr(request, 'jms_context', {})
|
|
||||||
column_title_field_pairs = jms_context.get('column_title_field_pairs', ())
|
|
||||||
data['title'] = column_title_field_pairs
|
|
||||||
|
|
||||||
if isinstance(request.data, (list, tuple)) and not any(request.data):
|
|
||||||
error = _("Request file format may be wrong")
|
|
||||||
return Response(data={"error": error}, status=400)
|
|
||||||
return Response(data=data)
|
|
||||||
|
|
||||||
|
|
||||||
class SerializerMixin:
|
|
||||||
""" 根据用户请求动作的不同,获取不同的 `serializer_class `"""
|
|
||||||
|
|
||||||
action: str
|
|
||||||
request: Request
|
|
||||||
|
|
||||||
serializer_classes = None
|
|
||||||
single_actions = ['put', 'retrieve', 'patch']
|
|
||||||
|
|
||||||
def get_serializer_class_by_view_action(self):
|
|
||||||
if not hasattr(self, 'serializer_classes'):
|
|
||||||
return None
|
|
||||||
if not isinstance(self.serializer_classes, dict):
|
|
||||||
return None
|
|
||||||
|
|
||||||
view_action = self.request.query_params.get('action') or self.action or 'list'
|
|
||||||
serializer_class = self.serializer_classes.get(view_action)
|
|
||||||
|
|
||||||
if serializer_class is None:
|
|
||||||
view_method = self.request.method.lower()
|
|
||||||
serializer_class = self.serializer_classes.get(view_method)
|
|
||||||
|
|
||||||
if serializer_class is None and view_action in self.single_actions:
|
|
||||||
serializer_class = self.serializer_classes.get('single')
|
|
||||||
if serializer_class is None:
|
|
||||||
serializer_class = self.serializer_classes.get('display')
|
|
||||||
if serializer_class is None:
|
|
||||||
serializer_class = self.serializer_classes.get('default')
|
|
||||||
return serializer_class
|
|
||||||
|
|
||||||
def get_serializer_class(self):
|
|
||||||
serializer_class = self.get_serializer_class_by_view_action()
|
|
||||||
if serializer_class is None:
|
|
||||||
serializer_class = super().get_serializer_class()
|
|
||||||
return serializer_class
|
|
||||||
|
|
||||||
|
|
||||||
class ExtraFilterFieldsMixin:
|
|
||||||
"""
|
|
||||||
额外的 api filter
|
|
||||||
"""
|
|
||||||
default_added_filters = [CustomFilter, IDSpmFilter, IDInFilter]
|
|
||||||
filter_backends = api_settings.DEFAULT_FILTER_BACKENDS
|
|
||||||
extra_filter_fields = []
|
|
||||||
extra_filter_backends = []
|
|
||||||
|
|
||||||
def get_filter_backends(self):
|
|
||||||
if self.filter_backends != self.__class__.filter_backends:
|
|
||||||
return self.filter_backends
|
|
||||||
backends = list(chain(
|
|
||||||
self.filter_backends,
|
|
||||||
self.default_added_filters,
|
|
||||||
self.extra_filter_backends))
|
|
||||||
return backends
|
|
||||||
|
|
||||||
def filter_queryset(self, queryset):
|
|
||||||
for backend in self.get_filter_backends():
|
|
||||||
queryset = backend().filter_queryset(self.request, queryset, self)
|
|
||||||
return queryset
|
|
||||||
|
|
||||||
|
|
||||||
class PaginatedResponseMixin:
|
|
||||||
def get_paginated_response_with_query_set(self, queryset):
|
|
||||||
page = self.paginate_queryset(queryset)
|
|
||||||
if page is not None:
|
|
||||||
serializer = self.get_serializer(page, many=True)
|
|
||||||
return self.get_paginated_response(serializer.data)
|
|
||||||
|
|
||||||
serializer = self.get_serializer(queryset, many=True)
|
|
||||||
return Response(serializer.data)
|
|
||||||
|
|
||||||
|
|
||||||
class CommonApiMixin(SerializerMixin, ExtraFilterFieldsMixin, RenderToJsonMixin):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class InterceptMixin:
|
|
||||||
"""
|
|
||||||
Hack默认的dispatch, 让用户可以实现 self.do
|
|
||||||
"""
|
|
||||||
def dispatch(self, request, *args, **kwargs):
|
|
||||||
self.args = args
|
|
||||||
self.kwargs = kwargs
|
|
||||||
request = self.initialize_request(request, *args, **kwargs)
|
|
||||||
self.request = request
|
|
||||||
self.headers = self.default_response_headers # deprecate?
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.initial(request, *args, **kwargs)
|
|
||||||
|
|
||||||
# Get the appropriate handler method
|
|
||||||
if request.method.lower() in self.http_method_names:
|
|
||||||
handler = getattr(self, request.method.lower(),
|
|
||||||
self.http_method_not_allowed)
|
|
||||||
else:
|
|
||||||
handler = self.http_method_not_allowed
|
|
||||||
|
|
||||||
response = self.do(handler, request, *args, **kwargs)
|
|
||||||
|
|
||||||
except Exception as exc:
|
|
||||||
response = self.handle_exception(exc)
|
|
||||||
|
|
||||||
self.response = self.finalize_response(request, response, *args, **kwargs)
|
|
||||||
return self.response
|
|
||||||
|
|
||||||
|
|
||||||
class AsyncApiMixin(InterceptMixin):
|
|
||||||
def get_request_user_id(self):
|
|
||||||
user = self.request.user
|
|
||||||
if hasattr(user, 'id'):
|
|
||||||
return str(user.id)
|
|
||||||
return ''
|
|
||||||
|
|
||||||
@lazyproperty
|
|
||||||
def async_cache_key(self):
|
|
||||||
method = self.request.method
|
|
||||||
path = self.get_request_md5()
|
|
||||||
user = self.get_request_user_id()
|
|
||||||
key = '{}_{}_{}'.format(method, path, user)
|
|
||||||
return key
|
|
||||||
|
|
||||||
def get_request_md5(self):
|
|
||||||
path = self.request.path
|
|
||||||
query = {k: v for k, v in self.request.GET.items()}
|
|
||||||
query.pop("_", None)
|
|
||||||
query.pop('refresh', None)
|
|
||||||
query = "&".join(["{}={}".format(k, v) for k, v in query.items()])
|
|
||||||
full_path = "{}?{}".format(path, query)
|
|
||||||
return md5(full_path.encode()).hexdigest()
|
|
||||||
|
|
||||||
@lazyproperty
|
|
||||||
def initial_data(self):
|
|
||||||
data = {
|
|
||||||
"status": "running",
|
|
||||||
"start_time": time.time(),
|
|
||||||
"key": self.async_cache_key,
|
|
||||||
}
|
|
||||||
return data
|
|
||||||
|
|
||||||
def get_cache_data(self):
|
|
||||||
key = self.async_cache_key
|
|
||||||
if self.is_need_refresh():
|
|
||||||
cache.delete(key)
|
|
||||||
return None
|
|
||||||
data = cache.get(key)
|
|
||||||
return data
|
|
||||||
|
|
||||||
def do(self, handler, *args, **kwargs):
|
|
||||||
if not self.is_need_async():
|
|
||||||
return handler(*args, **kwargs)
|
|
||||||
resp = self.do_async(handler, *args, **kwargs)
|
|
||||||
return resp
|
|
||||||
|
|
||||||
def is_need_refresh(self):
|
|
||||||
if self.request.GET.get("refresh"):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def is_need_async(self):
|
|
||||||
return False
|
|
||||||
|
|
||||||
def do_async(self, handler, *args, **kwargs):
|
|
||||||
data = self.get_cache_data()
|
|
||||||
if not data:
|
|
||||||
t = Thread(
|
|
||||||
target=self.do_in_thread,
|
|
||||||
args=(handler, *args),
|
|
||||||
kwargs=kwargs
|
|
||||||
)
|
|
||||||
t.start()
|
|
||||||
resp = Response(self.initial_data)
|
|
||||||
return resp
|
|
||||||
status = data.get("status")
|
|
||||||
resp = data.get("resp")
|
|
||||||
if status == "ok" and resp:
|
|
||||||
resp = Response(**resp)
|
|
||||||
else:
|
|
||||||
resp = Response(data)
|
|
||||||
return resp
|
|
||||||
|
|
||||||
def do_in_thread(self, handler, *args, **kwargs):
|
|
||||||
key = self.async_cache_key
|
|
||||||
data = self.initial_data
|
|
||||||
cache.set(key, data, 600)
|
|
||||||
try:
|
|
||||||
response = handler(*args, **kwargs)
|
|
||||||
data["status"] = "ok"
|
|
||||||
data["resp"] = {
|
|
||||||
"data": response.data,
|
|
||||||
"status": response.status_code
|
|
||||||
}
|
|
||||||
cache.set(key, data, 600)
|
|
||||||
except Exception as e:
|
|
||||||
data["error"] = str(e)
|
|
||||||
data["status"] = "error"
|
|
||||||
cache.set(key, data, 600)
|
|
||||||
|
|
||||||
|
|
||||||
class RelationMixin:
|
|
||||||
m2m_field = None
|
|
||||||
from_field = None
|
|
||||||
to_field = None
|
|
||||||
to_model = None
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
|
|
||||||
assert self.m2m_field is not None, '''
|
|
||||||
`m2m_field` should not be `None`
|
|
||||||
'''
|
|
||||||
|
|
||||||
self.from_field = self.m2m_field.m2m_field_name()
|
|
||||||
self.to_field = self.m2m_field.m2m_reverse_field_name()
|
|
||||||
self.to_model = self.m2m_field.related_model
|
|
||||||
self.through = getattr(self.m2m_field.model, self.m2m_field.attname).through
|
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
# 注意,此处拦截了 `get_queryset` 没有 `super`
|
|
||||||
queryset = self.through.objects.all()
|
|
||||||
return queryset
|
|
||||||
|
|
||||||
def send_m2m_changed_signal(self, instances, action):
|
|
||||||
if not isinstance(instances, list):
|
|
||||||
instances = [instances]
|
|
||||||
|
|
||||||
from_to_mapper = defaultdict(list)
|
|
||||||
|
|
||||||
for i in instances:
|
|
||||||
to_id = getattr(i, self.to_field).id
|
|
||||||
# TODO 优化,不应该每次都查询数据库
|
|
||||||
from_obj = getattr(i, self.from_field)
|
|
||||||
from_to_mapper[from_obj].append(to_id)
|
|
||||||
|
|
||||||
for from_obj, to_ids in from_to_mapper.items():
|
|
||||||
m2m_changed.send(
|
|
||||||
sender=self.through, instance=from_obj, action=action,
|
|
||||||
reverse=False, model=self.to_model, pk_set=to_ids
|
|
||||||
)
|
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
|
||||||
instance = serializer.save()
|
|
||||||
self.send_m2m_changed_signal(instance, 'post_add')
|
|
||||||
|
|
||||||
def perform_destroy(self, instance):
|
|
||||||
instance.delete()
|
|
||||||
self.send_m2m_changed_signal(instance, 'post_remove')
|
|
||||||
|
|
||||||
|
|
||||||
class QuerySetMixin:
|
|
||||||
def get_queryset(self):
|
|
||||||
queryset = super().get_queryset()
|
|
||||||
serializer_class = self.get_serializer_class()
|
|
||||||
|
|
||||||
if serializer_class and hasattr(serializer_class, 'setup_eager_loading'):
|
|
||||||
queryset = serializer_class.setup_eager_loading(queryset)
|
|
||||||
|
|
||||||
return queryset
|
|
||||||
|
|
||||||
|
|
||||||
class AllowBulkDestroyMixin:
|
|
||||||
def allow_bulk_destroy(self, qs, filtered):
|
|
||||||
"""
|
|
||||||
我们规定,批量删除的情况必须用 `id` 指定要删除的数据。
|
|
||||||
"""
|
|
||||||
query = str(filtered.query)
|
|
||||||
return '`id` IN (' in query or '`id` =' in query
|
|
||||||
|
|
||||||
|
|
||||||
class RoleAdminMixin:
|
|
||||||
kwargs: dict
|
|
||||||
user_id_url_kwarg = 'pk'
|
|
||||||
|
|
||||||
@lazyproperty
|
|
||||||
def user(self):
|
|
||||||
user_id = self.kwargs.get(self.user_id_url_kwarg)
|
|
||||||
return UserModel.objects.get(id=user_id)
|
|
||||||
|
|
||||||
|
|
||||||
class RoleUserMixin:
|
|
||||||
request: Request
|
|
||||||
|
|
||||||
@lazyproperty
|
|
||||||
def user(self):
|
|
||||||
return self.request.user
|
|
||||||
7
apps/common/mixins/api/__init__.py
Normal file
7
apps/common/mixins/api/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from .common import *
|
||||||
|
from .action import *
|
||||||
|
from .patch import *
|
||||||
|
from .filter import *
|
||||||
|
from .permission import *
|
||||||
|
from .queryset import *
|
||||||
|
from .serializer import *
|
||||||
55
apps/common/mixins/api/action.py
Normal file
55
apps/common/mixins/api/action.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
#
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from django.utils.translation import ugettext as _
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.request import Request
|
||||||
|
|
||||||
|
from common.const.http import POST
|
||||||
|
from common.permissions import IsValidUser
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ['SuggestionMixin', 'RenderToJsonMixin']
|
||||||
|
|
||||||
|
|
||||||
|
class SuggestionMixin:
|
||||||
|
suggestion_limit = 10
|
||||||
|
|
||||||
|
filter_queryset: Callable
|
||||||
|
get_queryset: Callable
|
||||||
|
paginate_queryset: Callable
|
||||||
|
get_serializer: Callable
|
||||||
|
get_paginated_response: Callable
|
||||||
|
|
||||||
|
@action(methods=['get'], detail=False, permission_classes=(IsValidUser,))
|
||||||
|
def suggestions(self, request, *args, **kwargs):
|
||||||
|
queryset = self.filter_queryset(self.get_queryset())
|
||||||
|
queryset = queryset[:self.suggestion_limit]
|
||||||
|
page = self.paginate_queryset(queryset)
|
||||||
|
|
||||||
|
if page is not None:
|
||||||
|
serializer = self.get_serializer(page, many=True)
|
||||||
|
return self.get_paginated_response(serializer.data)
|
||||||
|
|
||||||
|
serializer = self.get_serializer(queryset, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
|
||||||
|
class RenderToJsonMixin:
|
||||||
|
@action(methods=[POST], detail=False, url_path='render-to-json')
|
||||||
|
def render_to_json(self, request: Request):
|
||||||
|
data = {
|
||||||
|
'title': (),
|
||||||
|
'data': request.data,
|
||||||
|
}
|
||||||
|
|
||||||
|
jms_context = getattr(request, 'jms_context', {})
|
||||||
|
column_title_field_pairs = jms_context.get('column_title_field_pairs', ())
|
||||||
|
data['title'] = column_title_field_pairs
|
||||||
|
|
||||||
|
if isinstance(request.data, (list, tuple)) and not any(request.data):
|
||||||
|
error = _("Request file format may be wrong")
|
||||||
|
return Response(data={"error": error}, status=400)
|
||||||
|
return Response(data=data)
|
||||||
96
apps/common/mixins/api/common.py
Normal file
96
apps/common/mixins/api/common.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
#
|
||||||
|
from typing import Callable
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
from django.db.models.signals import m2m_changed
|
||||||
|
|
||||||
|
from .serializer import SerializerMixin
|
||||||
|
from .filter import ExtraFilterFieldsMixin
|
||||||
|
from .action import RenderToJsonMixin
|
||||||
|
from .queryset import QuerySetMixin
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'CommonApiMixin', 'PaginatedResponseMixin', 'RelationMixin', 'CommonMixin'
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class PaginatedResponseMixin:
|
||||||
|
paginate_queryset: Callable
|
||||||
|
get_serializer: Callable
|
||||||
|
get_paginated_response: Callable
|
||||||
|
|
||||||
|
def get_paginated_response_from_queryset(self, queryset):
|
||||||
|
page = self.paginate_queryset(queryset)
|
||||||
|
if page is not None:
|
||||||
|
serializer = self.get_serializer(page, many=True)
|
||||||
|
return self.get_paginated_response(serializer.data)
|
||||||
|
|
||||||
|
serializer = self.get_serializer(queryset, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
|
||||||
|
class RelationMixin:
|
||||||
|
m2m_field = None
|
||||||
|
from_field = None
|
||||||
|
to_field = None
|
||||||
|
to_model = None
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
assert self.m2m_field is not None, '''
|
||||||
|
`m2m_field` should not be `None`
|
||||||
|
'''
|
||||||
|
|
||||||
|
self.from_field = self.m2m_field.m2m_field_name()
|
||||||
|
self.to_field = self.m2m_field.m2m_reverse_field_name()
|
||||||
|
self.to_model = self.m2m_field.related_model
|
||||||
|
self.through = getattr(self.m2m_field.model, self.m2m_field.attname).through
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
# 注意,此处拦截了 `get_queryset` 没有 `super`
|
||||||
|
queryset = self.through.objects.all()
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
def send_m2m_changed_signal(self, instances, action):
|
||||||
|
if not isinstance(instances, list):
|
||||||
|
instances = [instances]
|
||||||
|
|
||||||
|
from_to_mapper = defaultdict(list)
|
||||||
|
|
||||||
|
for i in instances:
|
||||||
|
to_id = getattr(i, self.to_field).id
|
||||||
|
# TODO 优化,不应该每次都查询数据库
|
||||||
|
from_obj = getattr(i, self.from_field)
|
||||||
|
from_to_mapper[from_obj].append(to_id)
|
||||||
|
|
||||||
|
for from_obj, to_ids in from_to_mapper.items():
|
||||||
|
m2m_changed.send(
|
||||||
|
sender=self.through, instance=from_obj, action=action,
|
||||||
|
reverse=False, model=self.to_model, pk_set=to_ids
|
||||||
|
)
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
instance = serializer.save()
|
||||||
|
self.send_m2m_changed_signal(instance, 'post_add')
|
||||||
|
|
||||||
|
def perform_destroy(self, instance):
|
||||||
|
instance.delete()
|
||||||
|
self.send_m2m_changed_signal(instance, 'post_remove')
|
||||||
|
|
||||||
|
|
||||||
|
class CommonApiMixin(SerializerMixin, ExtraFilterFieldsMixin, RenderToJsonMixin):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class CommonMixin(SerializerMixin,
|
||||||
|
QuerySetMixin,
|
||||||
|
ExtraFilterFieldsMixin,
|
||||||
|
RenderToJsonMixin):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
35
apps/common/mixins/api/filter.py
Normal file
35
apps/common/mixins/api/filter.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
#
|
||||||
|
from itertools import chain
|
||||||
|
|
||||||
|
from rest_framework.settings import api_settings
|
||||||
|
|
||||||
|
from common.drf.filters import IDSpmFilter, CustomFilter, IDInFilter
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ['ExtraFilterFieldsMixin']
|
||||||
|
|
||||||
|
|
||||||
|
class ExtraFilterFieldsMixin:
|
||||||
|
"""
|
||||||
|
额外的 api filter
|
||||||
|
"""
|
||||||
|
default_added_filters = [CustomFilter, IDSpmFilter, IDInFilter]
|
||||||
|
filter_backends = api_settings.DEFAULT_FILTER_BACKENDS
|
||||||
|
extra_filter_fields = []
|
||||||
|
extra_filter_backends = []
|
||||||
|
|
||||||
|
def get_filter_backends(self):
|
||||||
|
if self.filter_backends != self.__class__.filter_backends:
|
||||||
|
return self.filter_backends
|
||||||
|
backends = list(chain(
|
||||||
|
self.filter_backends,
|
||||||
|
self.default_added_filters,
|
||||||
|
self.extra_filter_backends
|
||||||
|
))
|
||||||
|
return backends
|
||||||
|
|
||||||
|
def filter_queryset(self, queryset):
|
||||||
|
for backend in self.get_filter_backends():
|
||||||
|
queryset = backend().filter_queryset(self.request, queryset, self)
|
||||||
|
return queryset
|
||||||
136
apps/common/mixins/api/patch.py
Normal file
136
apps/common/mixins/api/patch.py
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
#
|
||||||
|
import time
|
||||||
|
from hashlib import md5
|
||||||
|
from threading import Thread
|
||||||
|
|
||||||
|
from django.core.cache import cache
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from common.utils import lazyproperty
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ['InterceptMixin', 'AsyncApiMixin']
|
||||||
|
|
||||||
|
|
||||||
|
class InterceptMixin:
|
||||||
|
"""
|
||||||
|
Hack默认的dispatch, 让用户可以实现 self.do
|
||||||
|
"""
|
||||||
|
def dispatch(self, request, *args, **kwargs):
|
||||||
|
self.args = args
|
||||||
|
self.kwargs = kwargs
|
||||||
|
request = self.initialize_request(request, *args, **kwargs)
|
||||||
|
self.request = request
|
||||||
|
self.headers = self.default_response_headers # deprecate?
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.initial(request, *args, **kwargs)
|
||||||
|
|
||||||
|
# Get the appropriate handler method
|
||||||
|
if request.method.lower() in self.http_method_names:
|
||||||
|
handler = getattr(self, request.method.lower(),
|
||||||
|
self.http_method_not_allowed)
|
||||||
|
else:
|
||||||
|
handler = self.http_method_not_allowed
|
||||||
|
|
||||||
|
response = self.do(handler, request, *args, **kwargs)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
response = self.handle_exception(exc)
|
||||||
|
|
||||||
|
self.response = self.finalize_response(request, response, *args, **kwargs)
|
||||||
|
return self.response
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncApiMixin(InterceptMixin):
|
||||||
|
def get_request_user_id(self):
|
||||||
|
user = self.request.user
|
||||||
|
if hasattr(user, 'id'):
|
||||||
|
return str(user.id)
|
||||||
|
return ''
|
||||||
|
|
||||||
|
@lazyproperty
|
||||||
|
def async_cache_key(self):
|
||||||
|
method = self.request.method
|
||||||
|
path = self.get_request_md5()
|
||||||
|
user = self.get_request_user_id()
|
||||||
|
key = '{}_{}_{}'.format(method, path, user)
|
||||||
|
return key
|
||||||
|
|
||||||
|
def get_request_md5(self):
|
||||||
|
path = self.request.path
|
||||||
|
query = {k: v for k, v in self.request.GET.items()}
|
||||||
|
query.pop("_", None)
|
||||||
|
query.pop('refresh', None)
|
||||||
|
query = "&".join(["{}={}".format(k, v) for k, v in query.items()])
|
||||||
|
full_path = "{}?{}".format(path, query)
|
||||||
|
return md5(full_path.encode()).hexdigest()
|
||||||
|
|
||||||
|
@lazyproperty
|
||||||
|
def initial_data(self):
|
||||||
|
data = {
|
||||||
|
"status": "running",
|
||||||
|
"start_time": time.time(),
|
||||||
|
"key": self.async_cache_key,
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
|
||||||
|
def get_cache_data(self):
|
||||||
|
key = self.async_cache_key
|
||||||
|
if self.is_need_refresh():
|
||||||
|
cache.delete(key)
|
||||||
|
return None
|
||||||
|
data = cache.get(key)
|
||||||
|
return data
|
||||||
|
|
||||||
|
def do(self, handler, *args, **kwargs):
|
||||||
|
if not self.is_need_async():
|
||||||
|
return handler(*args, **kwargs)
|
||||||
|
resp = self.do_async(handler, *args, **kwargs)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
def is_need_refresh(self):
|
||||||
|
if self.request.GET.get("refresh"):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def is_need_async(self):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def do_async(self, handler, *args, **kwargs):
|
||||||
|
data = self.get_cache_data()
|
||||||
|
if not data:
|
||||||
|
t = Thread(
|
||||||
|
target=self.do_in_thread,
|
||||||
|
args=(handler, *args),
|
||||||
|
kwargs=kwargs
|
||||||
|
)
|
||||||
|
t.start()
|
||||||
|
resp = Response(self.initial_data)
|
||||||
|
return resp
|
||||||
|
status = data.get("status")
|
||||||
|
resp = data.get("resp")
|
||||||
|
if status == "ok" and resp:
|
||||||
|
resp = Response(**resp)
|
||||||
|
else:
|
||||||
|
resp = Response(data)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
def do_in_thread(self, handler, *args, **kwargs):
|
||||||
|
key = self.async_cache_key
|
||||||
|
data = self.initial_data
|
||||||
|
cache.set(key, data, 600)
|
||||||
|
try:
|
||||||
|
response = handler(*args, **kwargs)
|
||||||
|
data["status"] = "ok"
|
||||||
|
data["resp"] = {
|
||||||
|
"data": response.data,
|
||||||
|
"status": response.status_code
|
||||||
|
}
|
||||||
|
cache.set(key, data, 600)
|
||||||
|
except Exception as e:
|
||||||
|
data["error"] = str(e)
|
||||||
|
data["status"] = "error"
|
||||||
|
cache.set(key, data, 600)
|
||||||
|
|
||||||
37
apps/common/mixins/api/permission.py
Normal file
37
apps/common/mixins/api/permission.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
#
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from rest_framework.request import Request
|
||||||
|
|
||||||
|
from common.utils import lazyproperty
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ['AllowBulkDestroyMixin', 'RoleAdminMixin', 'RoleUserMixin']
|
||||||
|
|
||||||
|
|
||||||
|
class AllowBulkDestroyMixin:
|
||||||
|
def allow_bulk_destroy(self, qs, filtered):
|
||||||
|
"""
|
||||||
|
我们规定,批量删除的情况必须用 `id` 指定要删除的数据。
|
||||||
|
"""
|
||||||
|
query = str(filtered.query)
|
||||||
|
return '`id` IN (' in query or '`id` =' in query
|
||||||
|
|
||||||
|
|
||||||
|
class RoleAdminMixin:
|
||||||
|
kwargs: dict
|
||||||
|
user_id_url_kwarg = 'pk'
|
||||||
|
|
||||||
|
@lazyproperty
|
||||||
|
def user(self):
|
||||||
|
user_id = self.kwargs.get(self.user_id_url_kwarg)
|
||||||
|
user_model = get_user_model()
|
||||||
|
return user_model.objects.get(id=user_id)
|
||||||
|
|
||||||
|
|
||||||
|
class RoleUserMixin:
|
||||||
|
request: Request
|
||||||
|
|
||||||
|
@lazyproperty
|
||||||
|
def user(self):
|
||||||
|
return self.request.user
|
||||||
14
apps/common/mixins/api/queryset.py
Normal file
14
apps/common/mixins/api/queryset.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
#
|
||||||
|
|
||||||
|
__all__ = ['QuerySetMixin']
|
||||||
|
|
||||||
|
|
||||||
|
class QuerySetMixin:
|
||||||
|
def get_queryset(self):
|
||||||
|
queryset = super().get_queryset()
|
||||||
|
serializer_class = self.get_serializer_class()
|
||||||
|
|
||||||
|
if serializer_class and hasattr(serializer_class, 'setup_eager_loading'):
|
||||||
|
queryset = serializer_class.setup_eager_loading(queryset)
|
||||||
|
return queryset
|
||||||
43
apps/common/mixins/api/serializer.py
Normal file
43
apps/common/mixins/api/serializer.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
#
|
||||||
|
|
||||||
|
from rest_framework.request import Request
|
||||||
|
|
||||||
|
__all__ = ['SerializerMixin']
|
||||||
|
|
||||||
|
|
||||||
|
class SerializerMixin:
|
||||||
|
""" 根据用户请求动作的不同,获取不同的 `serializer_class `"""
|
||||||
|
|
||||||
|
action: str
|
||||||
|
request: Request
|
||||||
|
|
||||||
|
serializer_classes = None
|
||||||
|
single_actions = ['put', 'retrieve', 'patch']
|
||||||
|
|
||||||
|
def get_serializer_class_by_view_action(self):
|
||||||
|
if not hasattr(self, 'serializer_classes'):
|
||||||
|
return None
|
||||||
|
if not isinstance(self.serializer_classes, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
view_action = self.request.query_params.get('action') or self.action or 'list'
|
||||||
|
serializer_class = self.serializer_classes.get(view_action)
|
||||||
|
|
||||||
|
if serializer_class is None:
|
||||||
|
view_method = self.request.method.lower()
|
||||||
|
serializer_class = self.serializer_classes.get(view_method)
|
||||||
|
|
||||||
|
if serializer_class is None and view_action in self.single_actions:
|
||||||
|
serializer_class = self.serializer_classes.get('single')
|
||||||
|
if serializer_class is None:
|
||||||
|
serializer_class = self.serializer_classes.get('display')
|
||||||
|
if serializer_class is None:
|
||||||
|
serializer_class = self.serializer_classes.get('default')
|
||||||
|
return serializer_class
|
||||||
|
|
||||||
|
def get_serializer_class(self):
|
||||||
|
serializer_class = self.get_serializer_class_by_view_action()
|
||||||
|
if serializer_class is None:
|
||||||
|
serializer_class = super().get_serializer_class()
|
||||||
|
return serializer_class
|
||||||
@@ -297,10 +297,13 @@ class CommonSerializerMixin(DynamicFieldsMixin, DefaultValueFieldsMixin):
|
|||||||
initial_data: dict
|
initial_data: dict
|
||||||
|
|
||||||
def get_initial_value(self, attr, default=None):
|
def get_initial_value(self, attr, default=None):
|
||||||
|
value = self.initial_data.get(attr)
|
||||||
|
if value is not None:
|
||||||
|
return value
|
||||||
if self.instance:
|
if self.instance:
|
||||||
return getattr(self.instance, attr, default)
|
value = getattr(self.instance, attr, default)
|
||||||
else:
|
return value
|
||||||
return self.initial_data.get(attr)
|
return default
|
||||||
|
|
||||||
|
|
||||||
class CommonBulkSerializerMixin(BulkSerializerMixin, CommonSerializerMixin):
|
class CommonBulkSerializerMixin(BulkSerializerMixin, CommonSerializerMixin):
|
||||||
|
|||||||
@@ -1,49 +1,16 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
#
|
#
|
||||||
# coding: utf-8
|
|
||||||
from django.contrib.auth.mixins import UserPassesTestMixin
|
from django.contrib.auth.mixins import UserPassesTestMixin
|
||||||
from django.utils import timezone
|
|
||||||
from rest_framework.decorators import action
|
|
||||||
from rest_framework import permissions
|
from rest_framework import permissions
|
||||||
from rest_framework.response import Response
|
from rest_framework.request import Request
|
||||||
|
|
||||||
from common.permissions import IsValidUser
|
|
||||||
|
|
||||||
__all__ = ["DatetimeSearchMixin", "PermissionsMixin"]
|
|
||||||
|
|
||||||
|
|
||||||
class DatetimeSearchMixin:
|
__all__ = ["PermissionsMixin"]
|
||||||
date_format = '%Y-%m-%d'
|
|
||||||
date_from = date_to = None
|
|
||||||
|
|
||||||
def get_date_range(self):
|
|
||||||
date_from_s = self.request.GET.get('date_from')
|
|
||||||
date_to_s = self.request.GET.get('date_to')
|
|
||||||
|
|
||||||
if date_from_s:
|
|
||||||
date_from = timezone.datetime.strptime(date_from_s, self.date_format)
|
|
||||||
tz = timezone.get_current_timezone()
|
|
||||||
self.date_from = tz.localize(date_from)
|
|
||||||
else:
|
|
||||||
self.date_from = timezone.now() - timezone.timedelta(7)
|
|
||||||
|
|
||||||
if date_to_s:
|
|
||||||
date_to = timezone.datetime.strptime(
|
|
||||||
date_to_s + ' 23:59:59', self.date_format + ' %H:%M:%S'
|
|
||||||
)
|
|
||||||
self.date_to = date_to.replace(
|
|
||||||
tzinfo=timezone.get_current_timezone()
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.date_to = timezone.now()
|
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
|
||||||
self.get_date_range()
|
|
||||||
return super().get(request, *args, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
class PermissionsMixin(UserPassesTestMixin):
|
class PermissionsMixin(UserPassesTestMixin):
|
||||||
permission_classes = [permissions.IsAuthenticated]
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
request: Request
|
||||||
|
|
||||||
def get_permissions(self):
|
def get_permissions(self):
|
||||||
return self.permission_classes
|
return self.permission_classes
|
||||||
@@ -56,17 +23,3 @@ class PermissionsMixin(UserPassesTestMixin):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
class SuggestionMixin:
|
|
||||||
suggestion_mini_count = 10
|
|
||||||
|
|
||||||
@action(methods=['get'], detail=False, permission_classes=(IsValidUser,))
|
|
||||||
def suggestions(self, request, *args, **kwargs):
|
|
||||||
queryset = self.filter_queryset(self.get_queryset())
|
|
||||||
queryset = queryset[:self.suggestion_mini_count]
|
|
||||||
page = self.paginate_queryset(queryset)
|
|
||||||
if page is not None:
|
|
||||||
serializer = self.get_serializer(page, many=True)
|
|
||||||
return self.get_paginated_response(serializer.data)
|
|
||||||
|
|
||||||
serializer = self.get_serializer(queryset, many=True)
|
|
||||||
return Response(serializer.data)
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ class IsValidUser(permissions.IsAuthenticated, permissions.BasePermission):
|
|||||||
|
|
||||||
def has_permission(self, request, view):
|
def has_permission(self, request, view):
|
||||||
return super(IsValidUser, self).has_permission(request, view) \
|
return super(IsValidUser, self).has_permission(request, view) \
|
||||||
and request.user.is_valid
|
and request.user.is_valid
|
||||||
|
|
||||||
|
|
||||||
class IsAppUser(IsValidUser):
|
class IsAppUser(IsValidUser):
|
||||||
@@ -22,7 +22,7 @@ class IsAppUser(IsValidUser):
|
|||||||
|
|
||||||
def has_permission(self, request, view):
|
def has_permission(self, request, view):
|
||||||
return super(IsAppUser, self).has_permission(request, view) \
|
return super(IsAppUser, self).has_permission(request, view) \
|
||||||
and request.user.is_app
|
and request.user.is_app
|
||||||
|
|
||||||
|
|
||||||
class IsSuperUser(IsValidUser):
|
class IsSuperUser(IsValidUser):
|
||||||
@@ -36,7 +36,7 @@ class IsSuperUserOrAppUser(IsSuperUser):
|
|||||||
if request.user.is_anonymous:
|
if request.user.is_anonymous:
|
||||||
return False
|
return False
|
||||||
return super(IsSuperUserOrAppUser, self).has_permission(request, view) \
|
return super(IsSuperUserOrAppUser, self).has_permission(request, view) \
|
||||||
or request.user.is_app
|
or request.user.is_app
|
||||||
|
|
||||||
|
|
||||||
class IsSuperAuditor(IsValidUser):
|
class IsSuperAuditor(IsValidUser):
|
||||||
@@ -60,7 +60,7 @@ class IsOrgAdmin(IsValidUser):
|
|||||||
if not current_org:
|
if not current_org:
|
||||||
return False
|
return False
|
||||||
return super(IsOrgAdmin, self).has_permission(request, view) \
|
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):
|
class IsOrgAdminOrAppUser(IsValidUser):
|
||||||
@@ -72,7 +72,7 @@ class IsOrgAdminOrAppUser(IsValidUser):
|
|||||||
if request.user.is_anonymous:
|
if request.user.is_anonymous:
|
||||||
return False
|
return False
|
||||||
return super(IsOrgAdminOrAppUser, self).has_permission(request, view) \
|
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):
|
class IsOrgAdminOrAppUserOrUserReadonly(IsOrgAdminOrAppUser):
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user