mirror of
https://github.com/jumpserver/jumpserver.git
synced 2025-12-24 13:02:37 +00:00
Compare commits
240 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa08517bea | ||
|
|
f86d045c01 | ||
|
|
1a7fd58abf | ||
|
|
d808256e6a | ||
|
|
305a1b10ed | ||
|
|
8c277e8875 | ||
|
|
061b60ef59 | ||
|
|
c008115888 | ||
|
|
8d1fb84aaf | ||
|
|
43d61b5348 | ||
|
|
c26a786287 | ||
|
|
cb2bd0cf2c | ||
|
|
3048e6311b | ||
|
|
5e16b6387a | ||
|
|
93e1adf376 | ||
|
|
556bd3682e | ||
|
|
6bbbe312a2 | ||
|
|
1ac64db0ba | ||
|
|
fa54a98d6c | ||
|
|
31de9375e7 | ||
|
|
697270e3e6 | ||
|
|
56c324b04e | ||
|
|
984b94c874 | ||
|
|
50df7f1304 | ||
|
|
8e5833aef0 | ||
|
|
f20b465ddf | ||
|
|
409d254a2e | ||
|
|
e6d30fa77d | ||
|
|
b25404cac1 | ||
|
|
ef4cc5f646 | ||
|
|
f0dc519423 | ||
|
|
2cb6da3129 | ||
|
|
1819083a25 | ||
|
|
bdeec0d3cb | ||
|
|
8fc5c4cf9e | ||
|
|
89051b2c67 | ||
|
|
9123839b48 | ||
|
|
258c8a30d1 | ||
|
|
af75b5269c | ||
|
|
0a66693a41 | ||
|
|
7151201d58 | ||
|
|
51820f23bf | ||
|
|
8772cd8c71 | ||
|
|
60cb1f8136 | ||
|
|
5f1b7ff8f9 | ||
|
|
37b150bc04 | ||
|
|
1432fe1609 | ||
|
|
8ae98887ee | ||
|
|
24a1738e73 | ||
|
|
188c04c9a6 | ||
|
|
bb4da12366 | ||
|
|
382112ee33 | ||
|
|
3e69e6840b | ||
|
|
a82ed3e924 | ||
|
|
b347acd5ec | ||
|
|
ccd6b01020 | ||
|
|
831b67eae4 | ||
|
|
3ab634d88e | ||
|
|
867ad94a30 | ||
|
|
7d0a19635a | ||
|
|
4642804077 | ||
|
|
d405bae205 | ||
|
|
68841d1f15 | ||
|
|
4cad5affec | ||
|
|
2f8a07e665 | ||
|
|
78133b0c60 | ||
|
|
88d9078c43 | ||
|
|
5559f112db | ||
|
|
9a4b32cb3c | ||
|
|
ddf4b61c9f | ||
|
|
0eaaa7b4f6 | ||
|
|
09160fed5d | ||
|
|
18af5e8c4a | ||
|
|
1ed388459b | ||
|
|
2e944c6898 | ||
|
|
8409523fee | ||
|
|
16634907b4 | ||
|
|
cfa5de13ab | ||
|
|
28c8ec1fab | ||
|
|
a14ebc5f0f | ||
|
|
6af20d298d | ||
|
|
795d6e01dc | ||
|
|
acf8b5798b | ||
|
|
abcd12f645 | ||
|
|
30fe5214c7 | ||
|
|
708a87c903 | ||
|
|
6a30e0739d | ||
|
|
3951b8b080 | ||
|
|
c295f1451a | ||
|
|
c4a94876cc | ||
|
|
dcab934d9f | ||
|
|
4ecb0b760f | ||
|
|
b27b02eb9d | ||
|
|
70cf847cd9 | ||
|
|
2099baaaff | ||
|
|
b22aed0cc3 | ||
|
|
3e7f83d44e | ||
|
|
40f8b99242 | ||
|
|
9ff345747b | ||
|
|
9319c4748c | ||
|
|
e8b4ee5c40 | ||
|
|
429e838973 | ||
|
|
ee1aff243c | ||
|
|
ea7133dea0 | ||
|
|
e7229963bf | ||
|
|
0f7b41d177 | ||
|
|
c4146744e5 | ||
|
|
dc32224294 | ||
|
|
d07a230ba6 | ||
|
|
f52a0ce960 | ||
|
|
9d17f27fb3 | ||
|
|
36d0b8d085 | ||
|
|
046356728a | ||
|
|
d34c4fb7ec | ||
|
|
ca49029d8f | ||
|
|
12036f8c96 | ||
|
|
60e455bea2 | ||
|
|
e7dd731139 | ||
|
|
88ae8ac67a | ||
|
|
626b6da9c4 | ||
|
|
cb8690dd63 | ||
|
|
2b2aa8f072 | ||
|
|
772e540527 | ||
|
|
ca5f6f3c6f | ||
|
|
29656b1630 | ||
|
|
bdf59da0f6 | ||
|
|
7b6eeb2e3d | ||
|
|
fed0732c1e | ||
|
|
c12efffcc9 | ||
|
|
358460e7f0 | ||
|
|
6319be0ea3 | ||
|
|
cc2b858769 | ||
|
|
585ddeb25b | ||
|
|
0eab83f73b | ||
|
|
62d403bf21 | ||
|
|
bb9d32dc18 | ||
|
|
e09383ecf4 | ||
|
|
4d7f8ffc71 | ||
|
|
af5295d30e | ||
|
|
5055d140fd | ||
|
|
2ca72a4bff | ||
|
|
de61e780e3 | ||
|
|
e1b3851be3 | ||
|
|
c665b0dbae | ||
|
|
0eaca0c1cb | ||
|
|
8824b6b54e | ||
|
|
4fd82b9946 | ||
|
|
1b1b70e7bd | ||
|
|
41541a91b9 | ||
|
|
93537c07a1 | ||
|
|
a770a19252 | ||
|
|
395636296d | ||
|
|
9967d52416 | ||
|
|
717f97cd88 | ||
|
|
dec8e3459a | ||
|
|
4a3d7a8524 | ||
|
|
f758414844 | ||
|
|
af080fe38d | ||
|
|
f0fbc73f73 | ||
|
|
ce2f6fdc84 | ||
|
|
2abca39597 | ||
|
|
11e538d417 | ||
|
|
5155b3c184 | ||
|
|
e724cdf53d | ||
|
|
191d37dd56 | ||
|
|
602192696c | ||
|
|
b262643f0a | ||
|
|
cd119a2999 | ||
|
|
d789810984 | ||
|
|
b5cfc6831b | ||
|
|
b64727e04c | ||
|
|
c7c0374c78 | ||
|
|
f3cf071362 | ||
|
|
27cbbfbc79 | ||
|
|
7047e445a3 | ||
|
|
06375110b9 | ||
|
|
0e6dbb3e5d | ||
|
|
bf7c05f753 | ||
|
|
1b4d389f2b | ||
|
|
0f11ca9c37 | ||
|
|
4537e30e4a | ||
|
|
2f71ee71b9 | ||
|
|
98644eeb61 | ||
|
|
001e5d857f | ||
|
|
bbcf992531 | ||
|
|
75aacd0da6 | ||
|
|
0aad0b7279 | ||
|
|
8ebcb4b73a | ||
|
|
88f60b58dd | ||
|
|
a6cc8a8b05 | ||
|
|
ca19e45905 | ||
|
|
c5bf4075e7 | ||
|
|
04ceca1b83 | ||
|
|
90228e69e0 | ||
|
|
62a2a74c27 | ||
|
|
927ae43af2 | ||
|
|
272f64d743 | ||
|
|
af2d927c1f | ||
|
|
011e9ffec4 | ||
|
|
8e65975cd7 | ||
|
|
9465138faf | ||
|
|
081089d636 | ||
|
|
5d80933e7b | ||
|
|
067a90ff9a | ||
|
|
05826abf9d | ||
|
|
e8363ddff8 | ||
|
|
de41747bb2 | ||
|
|
77067f18d5 | ||
|
|
3cbce63c54 | ||
|
|
c3c99cc5e8 | ||
|
|
b33e376c90 | ||
|
|
b619ebf423 | ||
|
|
b784d8ba87 | ||
|
|
fd7f73a18e | ||
|
|
8247f24d3f | ||
|
|
3749a0c6a1 | ||
|
|
fd41fd78cf | ||
|
|
8c31e8e634 | ||
|
|
648fabbe03 | ||
|
|
9388f37c39 | ||
|
|
b264db3e7e | ||
|
|
dbc5b7bdc3 | ||
|
|
ac20bc05ba | ||
|
|
7e2f81a418 | ||
|
|
2471787277 | ||
|
|
e6abdbdadc | ||
|
|
5ed65ca2ff | ||
|
|
ba6b1bf692 | ||
|
|
1aa58e1486 | ||
|
|
fa51465485 | ||
|
|
8f59bb2a48 | ||
|
|
2366da1485 | ||
|
|
f1a22575d3 | ||
|
|
7c1882bb53 | ||
|
|
97baeebb2a | ||
|
|
8b819f3779 | ||
|
|
d1420de4c2 | ||
|
|
379c7198da | ||
|
|
710cd0fb3b | ||
|
|
3fde31f2e0 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -31,12 +31,13 @@ media
|
||||
celerybeat.pid
|
||||
django.db
|
||||
celerybeat-schedule.db
|
||||
data/static
|
||||
docs/_build/
|
||||
xpack
|
||||
xpack.bak
|
||||
logs/*
|
||||
### Vagrant ###
|
||||
.vagrant/
|
||||
release/*
|
||||
releashe
|
||||
/apps/script.py
|
||||
data/*
|
||||
|
||||
23
Dockerfile
23
Dockerfile
@@ -17,6 +17,7 @@ ARG DEPENDENCIES=" \
|
||||
libxmlsec1-dev \
|
||||
libxmlsec1-openssl \
|
||||
libaio-dev \
|
||||
openssh-client \
|
||||
sshpass"
|
||||
|
||||
ARG TOOLS=" \
|
||||
@@ -29,24 +30,22 @@ ARG TOOLS=" \
|
||||
redis-tools \
|
||||
telnet \
|
||||
vim \
|
||||
unzip \
|
||||
unzip \
|
||||
wget"
|
||||
|
||||
RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list \
|
||||
&& sed -i 's/security.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list \
|
||||
&& apt update && sleep 1 && apt update \
|
||||
&& apt -y install ${BUILD_DEPENDENCIES} \
|
||||
&& apt -y install ${DEPENDENCIES} \
|
||||
&& apt -y install ${TOOLS} \
|
||||
RUN sed -i 's@http://.*.debian.org@http://mirrors.ustc.edu.cn@g' /etc/apt/sources.list \
|
||||
&& apt-get update \
|
||||
&& apt-get -y install --no-install-recommends ${BUILD_DEPENDENCIES} \
|
||||
&& apt-get -y install --no-install-recommends ${DEPENDENCIES} \
|
||||
&& apt-get -y install --no-install-recommends ${TOOLS} \
|
||||
&& localedef -c -f UTF-8 -i zh_CN zh_CN.UTF-8 \
|
||||
&& cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
|
||||
&& mkdir -p /root/.ssh/ \
|
||||
&& echo "Host *\n\tStrictHostKeyChecking no\n\tUserKnownHostsFile /dev/null" > /root/.ssh/config \
|
||||
&& sed -i "s@# alias l@alias l@g" ~/.bashrc \
|
||||
&& echo "set mouse-=a" > ~/.vimrc \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& mv /bin/sh /bin/sh.bak \
|
||||
&& ln -s /bin/bash /bin/sh
|
||||
&& echo "no" | dpkg-reconfigure dash \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ARG TARGETARCH
|
||||
ARG ORACLE_LIB_MAJOR=19
|
||||
@@ -65,9 +64,9 @@ RUN mkdir -p /opt/oracle/ \
|
||||
WORKDIR /tmp/build
|
||||
COPY ./requirements ./requirements
|
||||
|
||||
ARG PIP_MIRROR=https://mirrors.aliyun.com/pypi/simple/
|
||||
ARG PIP_MIRROR=https://pypi.douban.com/simple
|
||||
ENV PIP_MIRROR=$PIP_MIRROR
|
||||
ARG PIP_JMS_MIRROR=https://mirrors.aliyun.com/pypi/simple/
|
||||
ARG PIP_JMS_MIRROR=https://pypi.douban.com/simple
|
||||
ENV PIP_JMS_MIRROR=$PIP_JMS_MIRROR
|
||||
# 因为以 jms 或者 jumpserver 开头的 mirror 上可能没有
|
||||
RUN pip install --upgrade pip==20.2.4 setuptools==49.6.0 wheel==0.34.2 -i ${PIP_MIRROR} \
|
||||
|
||||
16
README.md
16
README.md
@@ -16,7 +16,7 @@
|
||||
|
||||
|
||||
|
||||
JumpServer 是全球首款开源的堡垒机,使用 GPLv3 开源协议,是符合 4A 规范的运维安全审计系统。
|
||||
JumpServer 是广受欢迎的开源堡垒机,是符合 4A 规范的专业运维安全审计系统。
|
||||
|
||||
JumpServer 使用 Python 开发,配备了业界领先的 Web Terminal 方案,交互界面美观、用户体验好。
|
||||
|
||||
@@ -95,11 +95,15 @@ JumpServer 采纳分布式架构,支持多机房跨区域部署,支持横向
|
||||
|
||||
### 案例研究
|
||||
|
||||
- [JumpServer 堡垒机护航顺丰科技超大规模资产安全运维](https://blog.fit2cloud.com/?p=1147)
|
||||
- [JumpServer 堡垒机让“大智慧”的混合 IT 运维更智慧](https://blog.fit2cloud.com/?p=882)
|
||||
- [携程 JumpServer 堡垒机部署与运营实战](https://blog.fit2cloud.com/?p=851)
|
||||
- [小红书的JumpServer堡垒机大规模资产跨版本迁移之路](https://blog.fit2cloud.com/?p=516)
|
||||
- [JumpServer堡垒机助力中手游提升多云环境下安全运维能力](https://blog.fit2cloud.com/?p=732)
|
||||
- [腾讯海外游戏:基于JumpServer构建游戏安全运营能力](https://blog.fit2cloud.com/?p=3704)
|
||||
- [万华化学:通过JumpServer管理全球化分布式IT资产,并且实现与云管平台的联动](https://blog.fit2cloud.com/?p=3504)
|
||||
- [雪花啤酒:JumpServer堡垒机使用体会](https://blog.fit2cloud.com/?p=3412)
|
||||
- [顺丰科技:JumpServer 堡垒机护航顺丰科技超大规模资产安全运维](https://blog.fit2cloud.com/?p=1147)
|
||||
- [沐瞳游戏:通过JumpServer管控多项目分布式资产](https://blog.fit2cloud.com/?p=3213)
|
||||
- [携程:JumpServer 堡垒机部署与运营实战](https://blog.fit2cloud.com/?p=851)
|
||||
- [大智慧:JumpServer 堡垒机让“大智慧”的混合 IT 运维更智慧](https://blog.fit2cloud.com/?p=882)
|
||||
- [小红书:的JumpServer堡垒机大规模资产跨版本迁移之路](https://blog.fit2cloud.com/?p=516)
|
||||
- [中手游:JumpServer堡垒机助力中手游提升多云环境下安全运维能力](https://blog.fit2cloud.com/?p=732)
|
||||
- [中通快递:JumpServer主机安全运维实践](https://blog.fit2cloud.com/?p=708)
|
||||
- [东方明珠:JumpServer高效管控异构化、分布式云端资产](https://blog.fit2cloud.com/?p=687)
|
||||
- [江苏农信:JumpServer堡垒机助力行业云安全运维](https://blog.fit2cloud.com/?p=666)
|
||||
|
||||
@@ -47,7 +47,7 @@ class LoginAssetCheckAPI(CreateAPIView):
|
||||
asset=self.serializer.asset,
|
||||
system_user=self.serializer.system_user,
|
||||
assignees=acl.reviewers.all(),
|
||||
org_id=self.serializer.org.id
|
||||
org_id=self.serializer.org.id,
|
||||
)
|
||||
confirm_status_url = reverse(
|
||||
view_name='api-tickets:super-ticket-status',
|
||||
@@ -59,7 +59,7 @@ class LoginAssetCheckAPI(CreateAPIView):
|
||||
external=True, api_to_ui=True
|
||||
)
|
||||
ticket_detail_url = '{url}?type={type}'.format(url=ticket_detail_url, type=ticket.type)
|
||||
ticket_assignees = ticket.current_node.first().ticket_assignees.all()
|
||||
ticket_assignees = ticket.current_step.ticket_assignees.all()
|
||||
data = {
|
||||
'check_confirm_status': {'method': 'GET', 'url': confirm_status_url},
|
||||
'close_confirm': {'method': 'DELETE', 'url': confirm_status_url},
|
||||
|
||||
@@ -44,87 +44,49 @@ class LoginACL(BaseACL):
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@property
|
||||
def action_reject(self):
|
||||
return self.action == self.ActionChoices.reject
|
||||
|
||||
@property
|
||||
def action_allow(self):
|
||||
return self.action == self.ActionChoices.allow
|
||||
def is_action(self, action):
|
||||
return self.action == action
|
||||
|
||||
@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
|
||||
def match(user, ip):
|
||||
acls = LoginACL.filter_acl(user)
|
||||
if not acls:
|
||||
return
|
||||
|
||||
@staticmethod
|
||||
def allow_user_to_login(user, ip):
|
||||
acl = LoginACL.filter_acl(user).exclude(
|
||||
action=LoginACL.ActionChoices.confirm
|
||||
).first()
|
||||
if not acl:
|
||||
return True, ''
|
||||
ip_group = acl.rules.get('ip_group')
|
||||
time_periods = acl.rules.get('time_period')
|
||||
is_contain_ip = contains_ip(ip, ip_group)
|
||||
is_contain_time_period = contains_time_period(time_periods)
|
||||
for acl in acls:
|
||||
if acl.is_action(LoginACL.ActionChoices.confirm) and not acl.reviewers.exists():
|
||||
continue
|
||||
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)
|
||||
if is_contain_ip and is_contain_time_period:
|
||||
# 满足条件,则返回
|
||||
return acl
|
||||
|
||||
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):
|
||||
def create_confirm_ticket(self, request):
|
||||
from tickets import const
|
||||
from tickets.models import ApplyLoginTicket
|
||||
from orgs.models import Organization
|
||||
title = _('Login confirm') + ' {}'.format(self.user)
|
||||
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,
|
||||
'title': title,
|
||||
'type': const.TicketType.login_confirm,
|
||||
'applicant': self.user,
|
||||
'apply_login_city': login_city,
|
||||
'apply_login_ip': login_ip,
|
||||
'apply_login_datetime': login_datetime,
|
||||
'org_id': Organization.ROOT_ID,
|
||||
}
|
||||
ticket = Ticket.objects.create(**data)
|
||||
applicant = self.user
|
||||
ticket = ApplyLoginTicket.objects.create(**data)
|
||||
assignees = self.reviewers.all()
|
||||
ticket.create_process_map_and_node(assignees, applicant)
|
||||
ticket.open(applicant)
|
||||
ticket.open_by_system(assignees)
|
||||
return ticket
|
||||
|
||||
@@ -85,19 +85,18 @@ class LoginAssetACL(BaseACL, OrgModelMixin):
|
||||
@classmethod
|
||||
def create_login_asset_confirm_ticket(cls, user, asset, system_user, assignees, org_id):
|
||||
from tickets.const import TicketType
|
||||
from tickets.models import Ticket
|
||||
from tickets.models import ApplyLoginAssetTicket
|
||||
title = _('Login asset confirm') + ' ({})'.format(user)
|
||||
data = {
|
||||
'title': _('Login asset confirm') + ' ({})'.format(user),
|
||||
'title': title,
|
||||
'type': TicketType.login_asset_confirm,
|
||||
'meta': {
|
||||
'apply_login_user': str(user),
|
||||
'apply_login_asset': str(asset),
|
||||
'apply_login_system_user': str(system_user),
|
||||
},
|
||||
'applicant': user,
|
||||
'apply_login_user': user,
|
||||
'apply_login_asset': asset,
|
||||
'apply_login_system_user': system_user,
|
||||
'org_id': org_id,
|
||||
}
|
||||
ticket = Ticket.objects.create(**data)
|
||||
ticket.create_process_map_and_node(assignees, user)
|
||||
ticket.open(applicant=user)
|
||||
ticket = ApplyLoginAssetTicket.objects.create(**data)
|
||||
ticket.open_by_system(assignees)
|
||||
return ticket
|
||||
|
||||
|
||||
@@ -2,15 +2,16 @@
|
||||
#
|
||||
|
||||
from django_filters import rest_framework as filters
|
||||
from django.db.models import F, Q
|
||||
from django.db.models import Q
|
||||
|
||||
from common.drf.filters import BaseFilterSet
|
||||
from common.drf.api import JMSBulkModelViewSet
|
||||
from common.mixins import RecordViewLogMixin
|
||||
from common.permissions import UserConfirmation
|
||||
from authentication.const import ConfirmType
|
||||
from rbac.permissions import RBACPermission
|
||||
from assets.models import SystemUser
|
||||
from ..models import Account
|
||||
from ..hands import NeedMFAVerify
|
||||
from .. import serializers
|
||||
|
||||
|
||||
@@ -57,7 +58,7 @@ class SystemUserAppRelationViewSet(ApplicationAccountViewSet):
|
||||
|
||||
class ApplicationAccountSecretViewSet(RecordViewLogMixin, ApplicationAccountViewSet):
|
||||
serializer_class = serializers.AppAccountSecretSerializer
|
||||
permission_classes = [RBACPermission, NeedMFAVerify]
|
||||
permission_classes = [RBACPermission, UserConfirmation.require(ConfirmType.MFA)]
|
||||
http_method_names = ['get', 'options']
|
||||
rbac_perms = {
|
||||
'retrieve': 'applications.view_applicationaccountsecret',
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
# coding: utf-8
|
||||
#
|
||||
from django.shortcuts import get_object_or_404
|
||||
from orgs.mixins.api import OrgBulkModelViewSet
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
|
||||
@@ -83,3 +83,9 @@ class AppType(models.TextChoices):
|
||||
if AppCategory.is_xpack(category):
|
||||
return True
|
||||
return tp in ['oracle', 'postgresql', 'sqlserver']
|
||||
|
||||
|
||||
class OracleVersion(models.TextChoices):
|
||||
version_11g = '11g', '11g'
|
||||
version_12c = '12c', '12c'
|
||||
version_other = 'other', _('Other')
|
||||
|
||||
@@ -11,5 +11,4 @@
|
||||
"""
|
||||
|
||||
|
||||
from common.permissions import NeedMFAVerify
|
||||
from users.models import User, UserGroup
|
||||
|
||||
22
apps/applications/migrations/0021_auto_20220629_1826.py
Normal file
22
apps/applications/migrations/0021_auto_20220629_1826.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 3.1.14 on 2022-06-29 10:26
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('applications', '0020_auto_20220316_2028'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='historicalaccount',
|
||||
options={'get_latest_by': ('history_date', 'history_id'), 'ordering': ('-history_date', '-history_id'), 'verbose_name': 'historical Application account', 'verbose_name_plural': 'historical Application accounts'},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='historicalaccount',
|
||||
name='history_date',
|
||||
field=models.DateTimeField(db_index=True),
|
||||
),
|
||||
]
|
||||
23
apps/applications/migrations/0022_auto_20220714_1046.py
Normal file
23
apps/applications/migrations/0022_auto_20220714_1046.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 3.2.12 on 2022-07-14 02:46
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def migrate_db_oracle_version_to_attrs(apps, schema_editor):
|
||||
db_alias = schema_editor.connection.alias
|
||||
model = apps.get_model("applications", "Application")
|
||||
oracles = list(model.objects.using(db_alias).filter(type='oracle'))
|
||||
for o in oracles:
|
||||
o.attrs['version'] = '12c'
|
||||
model.objects.using(db_alias).bulk_update(oracles, ['attrs'])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('applications', '0021_auto_20220629_1826'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(migrate_db_oracle_version_to_attrs)
|
||||
]
|
||||
48
apps/applications/migrations/0023_auto_20220715_1556.py
Normal file
48
apps/applications/migrations/0023_auto_20220715_1556.py
Normal file
@@ -0,0 +1,48 @@
|
||||
# Generated by Django 3.1.14 on 2022-07-15 07:56
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def migrate_account_dirty_data(apps, schema_editor):
|
||||
db_alias = schema_editor.connection.alias
|
||||
account_model = apps.get_model('applications', 'Account')
|
||||
|
||||
count = 0
|
||||
bulk_size = 1000
|
||||
|
||||
while True:
|
||||
accounts = account_model.objects.using(db_alias) \
|
||||
.filter(org_id='')[count:count + bulk_size]
|
||||
|
||||
if not accounts:
|
||||
break
|
||||
|
||||
accounts = list(accounts)
|
||||
start = time.time()
|
||||
for i in accounts:
|
||||
if i.app:
|
||||
org_id = i.app.org_id
|
||||
elif i.systemuser:
|
||||
org_id = i.systemuser.org_id
|
||||
else:
|
||||
org_id = ''
|
||||
if org_id:
|
||||
i.org_id = org_id
|
||||
|
||||
account_model.objects.bulk_update(accounts, ['org_id', ])
|
||||
print("Update account org is empty: {}-{} using: {:.2f}s".format(
|
||||
count, count + len(accounts), time.time() - start
|
||||
))
|
||||
count += len(accounts)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('applications', '0022_auto_20220714_1046'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(migrate_account_dirty_data),
|
||||
]
|
||||
@@ -10,6 +10,7 @@ from common.mixins import CommonModelMixin
|
||||
from common.tree import TreeNode
|
||||
from common.utils import is_uuid
|
||||
from assets.models import Asset, SystemUser
|
||||
from ..const import OracleVersion
|
||||
|
||||
from ..utils import KubernetesTree
|
||||
from .. import const
|
||||
@@ -214,6 +215,8 @@ class ApplicationTreeNodeMixin:
|
||||
|
||||
|
||||
class Application(CommonModelMixin, OrgModelMixin, ApplicationTreeNodeMixin):
|
||||
APP_TYPE = const.AppType
|
||||
|
||||
name = models.CharField(max_length=128, verbose_name=_('Name'))
|
||||
category = models.CharField(
|
||||
max_length=16, choices=const.AppCategory.choices, verbose_name=_('Category')
|
||||
@@ -255,6 +258,9 @@ class Application(CommonModelMixin, OrgModelMixin, ApplicationTreeNodeMixin):
|
||||
def category_db(self):
|
||||
return self.category == const.AppCategory.db.value
|
||||
|
||||
def is_type(self, tp):
|
||||
return self.type == tp
|
||||
|
||||
def get_rdp_remote_app_setting(self):
|
||||
from applications.serializers.attrs import get_serializer_class_by_application_type
|
||||
if not self.category_remote_app:
|
||||
@@ -298,6 +304,15 @@ class Application(CommonModelMixin, OrgModelMixin, ApplicationTreeNodeMixin):
|
||||
target_ip = self.attrs.get('host')
|
||||
return target_ip
|
||||
|
||||
def get_target_protocol_for_oracle(self):
|
||||
""" Oracle 类型需要单独处理,因为要携带版本号 """
|
||||
if not self.is_type(self.APP_TYPE.oracle):
|
||||
return
|
||||
version = self.attrs.get('version', OracleVersion.version_12c)
|
||||
if version == OracleVersion.version_other:
|
||||
return
|
||||
return 'oracle_%s' % version
|
||||
|
||||
|
||||
class ApplicationUser(SystemUser):
|
||||
class Meta:
|
||||
|
||||
@@ -16,7 +16,7 @@ from .. import const
|
||||
|
||||
__all__ = [
|
||||
'AppSerializer', 'MiniAppSerializer', 'AppSerializerMixin',
|
||||
'AppAccountSerializer', 'AppAccountSecretSerializer'
|
||||
'AppAccountSerializer', 'AppAccountSecretSerializer', 'AppAccountBackUpSerializer'
|
||||
]
|
||||
|
||||
|
||||
@@ -32,21 +32,23 @@ class AppSerializerMixin(serializers.Serializer):
|
||||
return instance
|
||||
|
||||
def get_attrs_serializer(self):
|
||||
default_serializer = serializers.Serializer(read_only=True)
|
||||
instance = self.app
|
||||
if instance:
|
||||
_type = instance.type
|
||||
_category = instance.category
|
||||
else:
|
||||
_type = self.context['request'].query_params.get('type')
|
||||
_category = self.context['request'].query_params.get('category')
|
||||
if _type:
|
||||
if isinstance(self, AppAccountSecretSerializer):
|
||||
serializer_class = type_secret_serializer_classes_mapping.get(_type)
|
||||
tp = getattr(self, 'tp', None)
|
||||
default_serializer = serializers.Serializer(read_only=True)
|
||||
if not tp:
|
||||
if instance:
|
||||
tp = instance.type
|
||||
category = instance.category
|
||||
else:
|
||||
serializer_class = type_serializer_classes_mapping.get(_type)
|
||||
elif _category:
|
||||
serializer_class = category_serializer_classes_mapping.get(_category)
|
||||
tp = self.context['request'].query_params.get('type')
|
||||
category = self.context['request'].query_params.get('category')
|
||||
if tp:
|
||||
if isinstance(self, AppAccountBackUpSerializer):
|
||||
serializer_class = type_secret_serializer_classes_mapping.get(tp)
|
||||
else:
|
||||
serializer_class = type_serializer_classes_mapping.get(tp)
|
||||
elif category:
|
||||
serializer_class = category_serializer_classes_mapping.get(category)
|
||||
else:
|
||||
serializer_class = default_serializer
|
||||
|
||||
@@ -154,11 +156,6 @@ class AppAccountSerializer(AppSerializerMixin, AuthSerializerMixin, BulkOrgResou
|
||||
|
||||
class AppAccountSecretSerializer(SecretReadableMixin, AppAccountSerializer):
|
||||
class Meta(AppAccountSerializer.Meta):
|
||||
fields_backup = [
|
||||
'id', 'app_display', 'attrs', 'username', 'password', 'private_key',
|
||||
'public_key', 'date_created', 'date_updated', 'version'
|
||||
]
|
||||
|
||||
extra_kwargs = {
|
||||
'password': {'write_only': False},
|
||||
'private_key': {'write_only': False},
|
||||
@@ -166,3 +163,22 @@ class AppAccountSecretSerializer(SecretReadableMixin, AppAccountSerializer):
|
||||
'app_display': {'label': _('Application display')},
|
||||
'systemuser_display': {'label': _('System User')}
|
||||
}
|
||||
|
||||
|
||||
class AppAccountBackUpSerializer(AppAccountSecretSerializer):
|
||||
class Meta(AppAccountSecretSerializer.Meta):
|
||||
fields = [
|
||||
'id', 'app_display', 'attrs', 'username', 'password', 'private_key',
|
||||
'public_key', 'date_created', 'date_updated', 'version'
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.tp = kwargs.pop('tp', None)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def setup_eager_loading(cls, queryset):
|
||||
return queryset
|
||||
|
||||
def to_representation(self, instance):
|
||||
return super(AppAccountSerializer, self).to_representation(instance)
|
||||
|
||||
@@ -13,3 +13,14 @@ class DBSerializer(serializers.Serializer):
|
||||
database = serializers.CharField(
|
||||
max_length=128, required=True, allow_null=True, label=_('Database')
|
||||
)
|
||||
use_ssl = serializers.BooleanField(default=False, label=_('Use SSL'))
|
||||
ca_cert = serializers.CharField(
|
||||
required=False, allow_null=True, label=_('CA certificate')
|
||||
)
|
||||
client_cert = serializers.CharField(
|
||||
required=False, allow_null=True, label=_('Client certificate file')
|
||||
)
|
||||
cert_key = serializers.CharField(
|
||||
required=False, allow_null=True, label=_('Certificate key file')
|
||||
)
|
||||
allow_invalid_cert = serializers.BooleanField(default=False, label=_('Allow invalid cert'))
|
||||
|
||||
@@ -31,7 +31,7 @@ class ExistAssetPrimaryKeyRelatedField(serializers.PrimaryKeyRelatedField):
|
||||
|
||||
|
||||
class RemoteAppSerializer(serializers.Serializer):
|
||||
asset_info = serializers.SerializerMethodField()
|
||||
asset_info = serializers.SerializerMethodField(label=_('Asset Info'))
|
||||
asset = ExistAssetPrimaryKeyRelatedField(
|
||||
queryset=Asset.objects, required=True, label=_("Asset"), allow_null=True
|
||||
)
|
||||
|
||||
@@ -2,9 +2,15 @@ from rest_framework import serializers
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from ..application_category import DBSerializer
|
||||
from applications.const import OracleVersion
|
||||
|
||||
__all__ = ['OracleSerializer']
|
||||
|
||||
|
||||
class OracleSerializer(DBSerializer):
|
||||
version = serializers.ChoiceField(
|
||||
choices=OracleVersion.choices, default=OracleVersion.version_12c,
|
||||
allow_null=True, label=_('Version'),
|
||||
help_text=_('Magnus currently supports only 11g and 12c connections')
|
||||
)
|
||||
port = serializers.IntegerField(default=1521, label=_('Port'), allow_null=True)
|
||||
|
||||
@@ -11,3 +11,4 @@ from .cmd_filter import *
|
||||
from .gathered_user import *
|
||||
from .favorite_asset import *
|
||||
from .account_backup import *
|
||||
from .account_history import *
|
||||
|
||||
50
apps/assets/api/account_history.py
Normal file
50
apps/assets/api/account_history.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from django.db.models import F
|
||||
|
||||
from assets.api.accounts import (
|
||||
AccountFilterSet, AccountViewSet, AccountSecretsViewSet
|
||||
)
|
||||
from common.mixins import RecordViewLogMixin
|
||||
from .. import serializers
|
||||
from ..models import AuthBook
|
||||
|
||||
__all__ = ['AccountHistoryViewSet', 'AccountHistorySecretsViewSet']
|
||||
|
||||
|
||||
class AccountHistoryFilterSet(AccountFilterSet):
|
||||
class Meta:
|
||||
model = AuthBook.history.model
|
||||
fields = AccountFilterSet.Meta.fields
|
||||
|
||||
|
||||
class AccountHistoryViewSet(AccountViewSet):
|
||||
model = AuthBook.history.model
|
||||
filterset_class = AccountHistoryFilterSet
|
||||
serializer_classes = {
|
||||
'default': serializers.AccountHistorySerializer,
|
||||
}
|
||||
rbac_perms = {
|
||||
'list': 'assets.view_assethistoryaccount',
|
||||
'retrieve': 'assets.view_assethistoryaccount',
|
||||
}
|
||||
|
||||
http_method_names = ['get', 'options']
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = self.model.objects.all() \
|
||||
.annotate(ip=F('asset__ip')) \
|
||||
.annotate(hostname=F('asset__hostname')) \
|
||||
.annotate(platform=F('asset__platform__name')) \
|
||||
.annotate(protocols=F('asset__protocols'))
|
||||
return queryset
|
||||
|
||||
|
||||
class AccountHistorySecretsViewSet(RecordViewLogMixin, AccountHistoryViewSet):
|
||||
serializer_classes = {
|
||||
'default': serializers.AccountHistorySecretSerializer
|
||||
}
|
||||
http_method_names = ['get']
|
||||
permission_classes = AccountSecretsViewSet.permission_classes
|
||||
rbac_perms = {
|
||||
'list': 'assets.view_assethistoryaccountsecret',
|
||||
'retrieve': 'assets.view_assethistoryaccountsecret',
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
from django.db.models import F, Q
|
||||
from django.db.models import Q
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django_filters import rest_framework as filters
|
||||
from rest_framework.decorators import action
|
||||
@@ -9,12 +9,13 @@ from orgs.mixins.api import OrgBulkModelViewSet
|
||||
from rbac.permissions import RBACPermission
|
||||
from common.drf.filters import BaseFilterSet
|
||||
from common.mixins import RecordViewLogMixin
|
||||
from common.permissions import NeedMFAVerify
|
||||
from common.permissions import UserConfirmation
|
||||
from authentication.const import ConfirmType
|
||||
from ..tasks.account_connectivity import test_accounts_connectivity_manual
|
||||
from ..models import AuthBook, Node
|
||||
from .. import serializers
|
||||
|
||||
__all__ = ['AccountViewSet', 'AccountSecretsViewSet', 'AccountTaskCreateAPI']
|
||||
__all__ = ['AccountFilterSet', 'AccountViewSet', 'AccountSecretsViewSet', 'AccountTaskCreateAPI']
|
||||
|
||||
|
||||
class AccountFilterSet(BaseFilterSet):
|
||||
@@ -88,7 +89,7 @@ class AccountSecretsViewSet(RecordViewLogMixin, AccountViewSet):
|
||||
'default': serializers.AccountSecretSerializer
|
||||
}
|
||||
http_method_names = ['get']
|
||||
permission_classes = [RBACPermission, NeedMFAVerify]
|
||||
permission_classes = [RBACPermission, UserConfirmation.require(ConfirmType.MFA)]
|
||||
rbac_perms = {
|
||||
'list': 'assets.view_assetaccountsecret',
|
||||
'retrieve': 'assets.view_assetaccountsecret',
|
||||
|
||||
@@ -6,7 +6,7 @@ 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.mixins.api import SuggestionMixin
|
||||
from common.mixins.api import SuggestionMixin, RenderToJsonMixin
|
||||
from users.models import User, UserGroup
|
||||
from users.serializers import UserSerializer, UserGroupSerializer
|
||||
from users.filters import UserFilter
|
||||
@@ -88,7 +88,7 @@ class AssetPlatformRetrieveApi(RetrieveAPIView):
|
||||
return asset.platform
|
||||
|
||||
|
||||
class AssetPlatformViewSet(ModelViewSet):
|
||||
class AssetPlatformViewSet(ModelViewSet, RenderToJsonMixin):
|
||||
queryset = Platform.objects.all()
|
||||
serializer_class = serializers.PlatformSerializer
|
||||
filterset_fields = ['name', 'base']
|
||||
|
||||
@@ -69,7 +69,7 @@ class CommandConfirmAPI(CreateAPIView):
|
||||
external=True, api_to_ui=True
|
||||
)
|
||||
ticket_detail_url = '{url}?type={type}'.format(url=ticket_detail_url, type=ticket.type)
|
||||
ticket_assignees = ticket.current_node.first().ticket_assignees.all()
|
||||
ticket_assignees = ticket.current_step.ticket_assignees.all()
|
||||
return {
|
||||
'check_confirm_status': {'method': 'GET', 'url': confirm_status_url},
|
||||
'close_confirm': {'method': 'DELETE', 'url': confirm_status_url},
|
||||
|
||||
@@ -24,7 +24,7 @@ class SerializeToTreeNodeMixin:
|
||||
'title': _name(node),
|
||||
'pId': node.parent_key,
|
||||
'isParent': True,
|
||||
'open': node.is_org_root(),
|
||||
'open': True,
|
||||
'meta': {
|
||||
'data': {
|
||||
"id": node.id,
|
||||
|
||||
@@ -43,7 +43,7 @@ __all__ = [
|
||||
class NodeViewSet(SuggestionMixin, OrgBulkModelViewSet):
|
||||
model = Node
|
||||
filterset_fields = ('value', 'key', 'id')
|
||||
search_fields = ('value',)
|
||||
search_fields = ('full_value',)
|
||||
serializer_class = serializers.NodeSerializer
|
||||
rbac_perms = {
|
||||
'match': 'assets.match_node',
|
||||
@@ -101,6 +101,8 @@ class NodeListAsTreeApi(generics.ListAPIView):
|
||||
|
||||
class NodeChildrenApi(generics.ListCreateAPIView):
|
||||
serializer_class = serializers.NodeSerializer
|
||||
search_fields = ('value',)
|
||||
|
||||
instance = None
|
||||
is_initial = False
|
||||
|
||||
@@ -179,8 +181,15 @@ class NodeChildrenAsTreeApi(SerializeToTreeNodeMixin, NodeChildrenApi):
|
||||
"""
|
||||
model = Node
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
if not self.request.GET.get('search'):
|
||||
return queryset
|
||||
queryset = super().filter_queryset(queryset)
|
||||
queryset = self.model.get_ancestor_queryset(queryset)
|
||||
return queryset
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
nodes = self.get_queryset().order_by('value')
|
||||
nodes = self.filter_queryset(self.get_queryset()).order_by('value')
|
||||
nodes = self.serialize_nodes(nodes, with_asset_amount=True)
|
||||
assets = self.get_assets()
|
||||
data = [*nodes, *assets]
|
||||
|
||||
26
apps/assets/migrations/0091_auto_20220629_1826.py
Normal file
26
apps/assets/migrations/0091_auto_20220629_1826.py
Normal file
@@ -0,0 +1,26 @@
|
||||
# Generated by Django 3.1.14 on 2022-06-29 10:26
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('assets', '0090_auto_20220412_1145'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='authbook',
|
||||
options={'permissions': [('test_authbook', 'Can test asset account connectivity'), ('view_assetaccountsecret', 'Can view asset account secret'), ('change_assetaccountsecret', 'Can change asset account secret'), ('view_assethistoryaccount', 'Can view asset history account'), ('view_assethistoryaccountsecret', 'Can view asset history account secret')], 'verbose_name': 'AuthBook'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='historicalauthbook',
|
||||
options={'get_latest_by': ('history_date', 'history_id'), 'ordering': ('-history_date', '-history_id'), 'verbose_name': 'historical AuthBook', 'verbose_name_plural': 'historical AuthBooks'},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='historicalauthbook',
|
||||
name='history_date',
|
||||
field=models.DateTimeField(db_index=True),
|
||||
),
|
||||
]
|
||||
@@ -29,7 +29,9 @@ class AuthBook(BaseUser, AbsConnectivity):
|
||||
permissions = [
|
||||
('test_authbook', _('Can test asset account connectivity')),
|
||||
('view_assetaccountsecret', _('Can view asset account secret')),
|
||||
('change_assetaccountsecret', _('Can change asset account secret'))
|
||||
('change_assetaccountsecret', _('Can change asset account secret')),
|
||||
('view_assethistoryaccount', _('Can view asset history account')),
|
||||
('view_assethistoryaccountsecret', _('Can view asset history account secret')),
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
@@ -125,6 +125,9 @@ class CommandFilterRule(OrgModelMixin):
|
||||
regex.append(cmd)
|
||||
continue
|
||||
|
||||
if not cmd:
|
||||
continue
|
||||
|
||||
# 如果是单个字符
|
||||
if cmd[-1].isalpha():
|
||||
regex.append(r'\b{0}\b'.format(cmd))
|
||||
@@ -165,26 +168,23 @@ class CommandFilterRule(OrgModelMixin):
|
||||
|
||||
def create_command_confirm_ticket(self, run_command, session, cmd_filter_rule, org_id):
|
||||
from tickets.const import TicketType
|
||||
from tickets.models import Ticket
|
||||
from tickets.models import ApplyCommandTicket
|
||||
data = {
|
||||
'title': _('Command confirm') + ' ({})'.format(session.user),
|
||||
'type': TicketType.command_confirm,
|
||||
'meta': {
|
||||
'apply_run_user': session.user,
|
||||
'apply_run_asset': session.asset,
|
||||
'apply_run_system_user': session.system_user,
|
||||
'apply_run_command': run_command,
|
||||
'apply_from_session_id': str(session.id),
|
||||
'apply_from_cmd_filter_rule_id': str(cmd_filter_rule.id),
|
||||
'apply_from_cmd_filter_id': str(cmd_filter_rule.filter.id)
|
||||
},
|
||||
'applicant': session.user_obj,
|
||||
'apply_run_user_id': session.user_id,
|
||||
'apply_run_asset': str(session.asset),
|
||||
'apply_run_system_user_id': session.system_user_id,
|
||||
'apply_run_command': run_command[:4090],
|
||||
'apply_from_session_id': str(session.id),
|
||||
'apply_from_cmd_filter_rule_id': str(cmd_filter_rule.id),
|
||||
'apply_from_cmd_filter_id': str(cmd_filter_rule.filter.id),
|
||||
'org_id': org_id,
|
||||
}
|
||||
ticket = Ticket.objects.create(**data)
|
||||
applicant = session.user_obj
|
||||
ticket = ApplyCommandTicket.objects.create(**data)
|
||||
assignees = self.reviewers.all()
|
||||
ticket.create_process_map_and_node(assignees, applicant)
|
||||
ticket.open(applicant)
|
||||
ticket.open_by_system(assignees)
|
||||
return ticket
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -9,7 +9,7 @@ import paramiko
|
||||
from django.db import models
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from common.utils import get_logger
|
||||
from common.utils import get_logger, lazyproperty
|
||||
from orgs.mixins.models import OrgModelMixin
|
||||
from .base import BaseUser
|
||||
|
||||
@@ -36,7 +36,7 @@ class Domain(OrgModelMixin):
|
||||
def has_gateway(self):
|
||||
return self.gateway_set.filter(is_active=True).exists()
|
||||
|
||||
@property
|
||||
@lazyproperty
|
||||
def gateways(self):
|
||||
return self.gateway_set.filter(is_active=True)
|
||||
|
||||
@@ -44,8 +44,9 @@ class Domain(OrgModelMixin):
|
||||
gateways = [gw for gw in self.gateways if gw.is_connective]
|
||||
if gateways:
|
||||
return random.choice(gateways)
|
||||
else:
|
||||
logger.warn(f'Gateway all bad. domain={self}, gateway_num={len(self.gateways)}.')
|
||||
|
||||
logger.warn(f'Gateway all bad. domain={self}, gateway_num={len(self.gateways)}.')
|
||||
if self.gateways:
|
||||
return random.choice(self.gateways)
|
||||
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@ from orgs.mixins.models import OrgModelMixin, OrgManager
|
||||
from orgs.utils import get_current_org, tmp_to_org, tmp_to_root_org
|
||||
from orgs.models import Organization
|
||||
|
||||
|
||||
__all__ = ['Node', 'FamilyMixin', 'compute_parent_key', 'NodeQuerySet']
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -98,6 +97,14 @@ class FamilyMixin:
|
||||
q |= Q(key=self.key)
|
||||
return Node.objects.filter(q)
|
||||
|
||||
@classmethod
|
||||
def get_ancestor_queryset(cls, queryset, with_self=True):
|
||||
parent_keys = set()
|
||||
for i in queryset:
|
||||
parent_keys.update(set(i.get_ancestor_keys(with_self=with_self)))
|
||||
queryset = queryset.model.objects.filter(key__in=list(parent_keys)).distinct()
|
||||
return queryset
|
||||
|
||||
@property
|
||||
def children(self):
|
||||
return self.get_children(with_self=False)
|
||||
@@ -396,7 +403,7 @@ class NodeAllAssetsMappingMixin:
|
||||
mapping[ancestor_key].update(asset_ids)
|
||||
|
||||
t3 = time.time()
|
||||
logger.info('t1-t2(DB Query): {} s, t3-t2(Generate mapping): {} s'.format(t2-t1, t3-t2))
|
||||
logger.info('t1-t2(DB Query): {} s, t3-t2(Generate mapping): {} s'.format(t2 - t1, t3 - t2))
|
||||
return mapping
|
||||
|
||||
|
||||
|
||||
@@ -11,4 +11,5 @@ from .cmd_filter import *
|
||||
from .gathered_user import *
|
||||
from .favorite_asset import *
|
||||
from .account import *
|
||||
from .account_history import *
|
||||
from .backup import *
|
||||
|
||||
@@ -53,7 +53,15 @@ class AccountSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
|
||||
return attrs
|
||||
|
||||
def get_protocols(self, v):
|
||||
return v.protocols.replace(' ', ', ')
|
||||
""" protocols 是 queryset 中返回的,Post 创建成功后返回序列化时没有这个字段 """
|
||||
if hasattr(v, 'protocols'):
|
||||
protocols = v.protocols
|
||||
elif hasattr(v, 'asset') and v.asset:
|
||||
protocols = v.asset.protocols
|
||||
else:
|
||||
protocols = ''
|
||||
protocols = protocols.replace(' ', ', ')
|
||||
return protocols
|
||||
|
||||
@classmethod
|
||||
def setup_eager_loading(cls, queryset):
|
||||
@@ -68,10 +76,6 @@ class AccountSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
|
||||
|
||||
class AccountSecretSerializer(SecretReadableMixin, AccountSerializer):
|
||||
class Meta(AccountSerializer.Meta):
|
||||
fields_backup = [
|
||||
'hostname', 'ip', 'platform', 'protocols', 'username', 'password',
|
||||
'private_key', 'public_key', 'date_created', 'date_updated', 'version'
|
||||
]
|
||||
extra_kwargs = {
|
||||
'password': {'write_only': False},
|
||||
'private_key': {'write_only': False},
|
||||
@@ -80,6 +84,22 @@ class AccountSecretSerializer(SecretReadableMixin, AccountSerializer):
|
||||
}
|
||||
|
||||
|
||||
class AccountBackUpSerializer(AccountSecretSerializer):
|
||||
class Meta(AccountSecretSerializer.Meta):
|
||||
fields = [
|
||||
'id', 'hostname', 'ip', 'username', 'password',
|
||||
'private_key', 'public_key', 'date_created',
|
||||
'date_updated', 'version'
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def setup_eager_loading(cls, queryset):
|
||||
return queryset
|
||||
|
||||
def to_representation(self, instance):
|
||||
return super(AccountSerializer, self).to_representation(instance)
|
||||
|
||||
|
||||
class AccountTaskSerializer(serializers.Serializer):
|
||||
ACTION_CHOICES = (
|
||||
('test', 'test'),
|
||||
|
||||
38
apps/assets/serializers/account_history.py
Normal file
38
apps/assets/serializers/account_history.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from rest_framework import serializers
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from assets.models import AuthBook
|
||||
from common.drf.serializers import SecretReadableMixin
|
||||
from .account import AccountSerializer, AccountSecretSerializer
|
||||
|
||||
|
||||
class AccountHistorySerializer(AccountSerializer):
|
||||
systemuser_display = serializers.SerializerMethodField(label=_('System user display'))
|
||||
|
||||
class Meta:
|
||||
model = AuthBook.history.model
|
||||
fields = AccountSerializer.Meta.fields_mini + \
|
||||
AccountSerializer.Meta.fields_write_only + \
|
||||
AccountSerializer.Meta.fields_fk + \
|
||||
['history_id', 'date_created', 'date_updated']
|
||||
read_only_fields = fields
|
||||
ref_name = 'AccountHistorySerializer'
|
||||
|
||||
@staticmethod
|
||||
def get_systemuser_display(instance):
|
||||
if not instance.systemuser:
|
||||
return ''
|
||||
return str(instance.systemuser)
|
||||
|
||||
def get_field_names(self, declared_fields, info):
|
||||
fields = super().get_field_names(declared_fields, info)
|
||||
fields = list(set(fields) - {'org_name'})
|
||||
return fields
|
||||
|
||||
def to_representation(self, instance):
|
||||
return super(AccountSerializer, self).to_representation(instance)
|
||||
|
||||
|
||||
class AccountHistorySecretSerializer(SecretReadableMixin, AccountHistorySerializer):
|
||||
class Meta(AccountHistorySerializer.Meta):
|
||||
extra_kwargs = AccountSecretSerializer.Meta.extra_kwargs
|
||||
@@ -189,6 +189,9 @@ class PlatformSerializer(serializers.ModelSerializer):
|
||||
'id', 'name', 'base', 'charset',
|
||||
'internal', 'meta', 'comment'
|
||||
]
|
||||
extra_kwargs = {
|
||||
'internal': {'read_only': True},
|
||||
}
|
||||
|
||||
|
||||
class AssetSimpleSerializer(serializers.ModelSerializer):
|
||||
|
||||
@@ -13,7 +13,7 @@ from .utils import validate_password_for_ansible
|
||||
|
||||
class AuthSerializer(serializers.ModelSerializer):
|
||||
password = EncryptedField(required=False, allow_blank=True, allow_null=True, max_length=1024, label=_('Password'))
|
||||
private_key = EncryptedField(required=False, allow_blank=True, allow_null=True, max_length=4096, label=_('Private key'))
|
||||
private_key = EncryptedField(required=False, allow_blank=True, allow_null=True, max_length=16384, label=_('Private key'))
|
||||
|
||||
def gen_keys(self, private_key=None, password=None):
|
||||
if private_key is None:
|
||||
@@ -38,7 +38,7 @@ class AuthSerializerMixin(serializers.ModelSerializer):
|
||||
validators=[validate_password_for_ansible]
|
||||
)
|
||||
private_key = EncryptedField(
|
||||
label=_('SSH private key'), required=False, allow_blank=True, allow_null=True, max_length=4096
|
||||
label=_('SSH private key'), required=False, allow_blank=True, allow_null=True, max_length=16384
|
||||
)
|
||||
passphrase = serializers.CharField(
|
||||
allow_blank=True, allow_null=True, required=False, max_length=512,
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import os
|
||||
import time
|
||||
import pandas as pd
|
||||
from openpyxl import Workbook
|
||||
from collections import defaultdict, OrderedDict
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.models import F
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from rest_framework import serializers
|
||||
|
||||
from assets.models import AuthBook
|
||||
from assets.serializers import AccountSecretSerializer
|
||||
from assets.models import AuthBook, SystemUser, Asset
|
||||
from assets.serializers import AccountBackUpSerializer
|
||||
from assets.notifications import AccountBackupExecutionTaskMsg
|
||||
from applications.models import Account
|
||||
from applications.models import Account, Application
|
||||
from applications.const import AppType
|
||||
from applications.serializers import AppAccountSecretSerializer
|
||||
from applications.serializers import AppAccountBackUpSerializer
|
||||
from users.models import User
|
||||
from common.utils import get_logger
|
||||
from common.utils.timezone import local_now_display
|
||||
@@ -38,7 +39,7 @@ class BaseAccountHandler:
|
||||
@classmethod
|
||||
def get_header_fields(cls, serializer: serializers.Serializer):
|
||||
try:
|
||||
backup_fields = getattr(serializer, 'Meta').fields_backup
|
||||
backup_fields = getattr(serializer, 'Meta').fields
|
||||
except AttributeError:
|
||||
backup_fields = serializer.fields.keys()
|
||||
header_fields = {}
|
||||
@@ -48,20 +49,44 @@ class BaseAccountHandler:
|
||||
_fields = cls.get_header_fields(v)
|
||||
header_fields.update(_fields)
|
||||
else:
|
||||
header_fields[field] = v.label
|
||||
header_fields[field] = str(v.label)
|
||||
return header_fields
|
||||
|
||||
@staticmethod
|
||||
def load_auth(tp, value, system_user):
|
||||
if value:
|
||||
return value
|
||||
if system_user:
|
||||
return getattr(system_user, tp, '')
|
||||
return ''
|
||||
|
||||
@classmethod
|
||||
def create_row(cls, account, serializer_cls, header_fields=None):
|
||||
serializer = serializer_cls(account)
|
||||
if not header_fields:
|
||||
header_fields = cls.get_header_fields(serializer)
|
||||
data = cls.unpack_data(serializer.data)
|
||||
def replace_auth(cls, account, system_user_dict):
|
||||
system_user = system_user_dict.get(account.systemuser_id)
|
||||
account.username = cls.load_auth('username', account.username, system_user)
|
||||
account.password = cls.load_auth('password', account.password, system_user)
|
||||
account.private_key = cls.load_auth('private_key', account.private_key, system_user)
|
||||
account.public_key = cls.load_auth('public_key', account.public_key, system_user)
|
||||
return account
|
||||
|
||||
@classmethod
|
||||
def create_row(cls, data, header_fields):
|
||||
data = cls.unpack_data(data)
|
||||
row_dict = {}
|
||||
for field, header_name in header_fields.items():
|
||||
row_dict[header_name] = data[field]
|
||||
row_dict[header_name] = str(data.get(field, field))
|
||||
return row_dict
|
||||
|
||||
@classmethod
|
||||
def add_rows(cls, data, header_fields, sheet):
|
||||
data_map = defaultdict(list)
|
||||
for i in data:
|
||||
row = cls.create_row(i, header_fields)
|
||||
if sheet not in data_map:
|
||||
data_map[sheet].append(list(row.keys()))
|
||||
data_map[sheet].append(list(row.values()))
|
||||
return data_map
|
||||
|
||||
|
||||
class AssetAccountHandler(BaseAccountHandler):
|
||||
@staticmethod
|
||||
@@ -72,24 +97,29 @@ class AssetAccountHandler(BaseAccountHandler):
|
||||
return filename
|
||||
|
||||
@classmethod
|
||||
def create_df(cls):
|
||||
df_dict = defaultdict(list)
|
||||
def replace_account_info(cls, account, asset_dict, system_user_dict):
|
||||
asset = asset_dict.get(account.asset_id)
|
||||
account.ip = asset.ip if asset else ''
|
||||
account.hostname = asset.hostname if asset else ''
|
||||
account = cls.replace_auth(account, system_user_dict)
|
||||
return account
|
||||
|
||||
@classmethod
|
||||
def create_data_map(cls, system_user_dict):
|
||||
sheet_name = AuthBook._meta.verbose_name
|
||||
assets = Asset.objects.only('id', 'hostname', 'ip')
|
||||
asset_dict = {asset.id: asset for asset in assets}
|
||||
accounts = AuthBook.objects.all()
|
||||
if not accounts.exists():
|
||||
return
|
||||
|
||||
accounts = AuthBook.get_queryset().select_related('systemuser')
|
||||
if not accounts.first():
|
||||
return df_dict
|
||||
|
||||
header_fields = cls.get_header_fields(AccountSecretSerializer(accounts.first()))
|
||||
header_fields = cls.get_header_fields(AccountBackUpSerializer(accounts.first()))
|
||||
for account in accounts:
|
||||
account.load_auth()
|
||||
row = cls.create_row(account, AccountSecretSerializer, header_fields)
|
||||
df_dict[sheet_name].append(row)
|
||||
for k, v in df_dict.items():
|
||||
df_dict[k] = pd.DataFrame(v)
|
||||
|
||||
cls.replace_account_info(account, asset_dict, system_user_dict)
|
||||
data = AccountBackUpSerializer(accounts, many=True).data
|
||||
data_map = cls.add_rows(data, header_fields, sheet_name)
|
||||
logger.info('\n\033[33m- 共收集 {} 条资产账号\033[0m'.format(accounts.count()))
|
||||
return df_dict
|
||||
return data_map
|
||||
|
||||
|
||||
class AppAccountHandler(BaseAccountHandler):
|
||||
@@ -101,19 +131,37 @@ class AppAccountHandler(BaseAccountHandler):
|
||||
return filename
|
||||
|
||||
@classmethod
|
||||
def create_df(cls):
|
||||
df_dict = defaultdict(list)
|
||||
accounts = Account.get_queryset().select_related('systemuser')
|
||||
for account in accounts:
|
||||
account.load_auth()
|
||||
app_type = account.type
|
||||
def replace_account_info(cls, account, app_dict, system_user_dict):
|
||||
app = app_dict.get(account.app_id)
|
||||
account.type = app.type if app else ''
|
||||
account.app_display = app.name if app else ''
|
||||
account.category = app.category if app else ''
|
||||
account = cls.replace_auth(account, system_user_dict)
|
||||
return account
|
||||
|
||||
@classmethod
|
||||
def create_data_map(cls, system_user_dict):
|
||||
apps = Application.objects.only('id', 'type', 'name', 'category')
|
||||
app_dict = {app.id: app for app in apps}
|
||||
qs = Account.objects.all().annotate(app_type=F('app__type'))
|
||||
if not qs.exists():
|
||||
return
|
||||
|
||||
account_type_map = defaultdict(list)
|
||||
for i in qs:
|
||||
account_type_map[i.app_type].append(i)
|
||||
data_map = {}
|
||||
for app_type, accounts in account_type_map.items():
|
||||
sheet_name = AppType.get_label(app_type)
|
||||
row = cls.create_row(account, AppAccountSecretSerializer)
|
||||
df_dict[sheet_name].append(row)
|
||||
for k, v in df_dict.items():
|
||||
df_dict[k] = pd.DataFrame(v)
|
||||
logger.info('\n\033[33m- 共收集{}条应用账号\033[0m'.format(accounts.count()))
|
||||
return df_dict
|
||||
header_fields = cls.get_header_fields(AppAccountBackUpSerializer(tp=app_type))
|
||||
if not accounts:
|
||||
continue
|
||||
for account in accounts:
|
||||
cls.replace_account_info(account, app_dict, system_user_dict)
|
||||
data = AppAccountBackUpSerializer(accounts, many=True, tp=app_type).data
|
||||
data_map.update(cls.add_rows(data, header_fields, sheet_name))
|
||||
logger.info('\n\033[33m- 共收集{}条应用账号\033[0m'.format(qs.count()))
|
||||
return data_map
|
||||
|
||||
|
||||
handler_map = {
|
||||
@@ -137,20 +185,27 @@ class AccountBackupHandler:
|
||||
# Print task start date
|
||||
time_start = time.time()
|
||||
files = []
|
||||
system_user_qs = SystemUser.objects.only(
|
||||
'id', 'username', 'password', 'private_key', 'public_key'
|
||||
)
|
||||
system_user_dict = {i.id: i for i in system_user_qs}
|
||||
for account_type in self.execution.types:
|
||||
handler = handler_map.get(account_type)
|
||||
if not handler:
|
||||
continue
|
||||
|
||||
df_dict = handler.create_df()
|
||||
if not df_dict:
|
||||
data_map = handler.create_data_map(system_user_dict)
|
||||
if not data_map:
|
||||
continue
|
||||
|
||||
filename = handler.get_filename(self.plan_name)
|
||||
with pd.ExcelWriter(filename) as w:
|
||||
for sheet, df in df_dict.items():
|
||||
sheet = sheet.replace(' ', '-')
|
||||
getattr(df, 'to_excel')(w, sheet_name=sheet, index=False)
|
||||
|
||||
wb = Workbook(filename)
|
||||
for sheet, data in data_map.items():
|
||||
ws = wb.create_sheet(str(sheet))
|
||||
for row in data:
|
||||
ws.append(row)
|
||||
wb.save(filename)
|
||||
files.append(filename)
|
||||
timedelta = round((time.time() - time_start), 2)
|
||||
logger.info('步骤完成: 用时 {}s'.format(timedelta))
|
||||
|
||||
@@ -13,6 +13,8 @@ router = BulkRouter()
|
||||
router.register(r'assets', api.AssetViewSet, 'asset')
|
||||
router.register(r'accounts', api.AccountViewSet, 'account')
|
||||
router.register(r'account-secrets', api.AccountSecretsViewSet, 'account-secret')
|
||||
router.register(r'accounts-history', api.AccountHistoryViewSet, 'account-history')
|
||||
router.register(r'account-history-secrets', api.AccountHistorySecretsViewSet, 'account-history-secret')
|
||||
router.register(r'platforms', api.AssetPlatformViewSet, 'platform')
|
||||
router.register(r'system-users', api.SystemUserViewSet, 'system-user')
|
||||
router.register(r'admin-users', api.AdminUserViewSet, 'admin-user')
|
||||
|
||||
@@ -12,6 +12,7 @@ from common.api import CommonGenericViewSet
|
||||
from orgs.mixins.api import OrgGenericViewSet, OrgBulkModelViewSet, OrgRelationMixin
|
||||
from orgs.utils import current_org
|
||||
from ops.models import CommandExecution
|
||||
from . import filters
|
||||
from .models import FTPLog, UserLoginLog, OperateLog, PasswordChangeLog
|
||||
from .serializers import FTPLogSerializer, UserLoginLogSerializer, CommandExecutionSerializer
|
||||
from .serializers import OperateLogSerializer, PasswordChangeLogSerializer, CommandExecutionHostsRelationSerializer
|
||||
@@ -126,9 +127,7 @@ class CommandExecutionViewSet(ListModelMixin, OrgGenericViewSet):
|
||||
class CommandExecutionHostRelationViewSet(OrgRelationMixin, OrgBulkModelViewSet):
|
||||
serializer_class = CommandExecutionHostsRelationSerializer
|
||||
m2m_field = CommandExecution.hosts.field
|
||||
filterset_fields = [
|
||||
'id', 'asset', 'commandexecution'
|
||||
]
|
||||
filterset_class = filters.CommandExecutionFilter
|
||||
search_fields = ('asset__hostname', )
|
||||
http_method_names = ['options', 'get']
|
||||
rbac_perms = {
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
from django.db.models import F, Value
|
||||
from django.db.models.functions import Concat
|
||||
from django_filters.rest_framework import CharFilter
|
||||
from rest_framework import filters
|
||||
from rest_framework.compat import coreapi, coreschema
|
||||
|
||||
from orgs.utils import current_org
|
||||
from ops.models import CommandExecution
|
||||
from common.drf.filters import BaseFilterSet
|
||||
|
||||
|
||||
__all__ = ['CurrentOrgMembersFilter']
|
||||
__all__ = ['CurrentOrgMembersFilter', 'CommandExecutionFilter']
|
||||
|
||||
|
||||
class CurrentOrgMembersFilter(filters.BaseFilterBackend):
|
||||
@@ -30,3 +34,22 @@ class CurrentOrgMembersFilter(filters.BaseFilterBackend):
|
||||
else:
|
||||
queryset = queryset.filter(user__in=self._get_user_list())
|
||||
return queryset
|
||||
|
||||
|
||||
class CommandExecutionFilter(BaseFilterSet):
|
||||
hostname_ip = CharFilter(method='filter_hostname_ip')
|
||||
|
||||
class Meta:
|
||||
model = CommandExecution.hosts.through
|
||||
fields = (
|
||||
'id', 'asset', 'commandexecution', 'hostname_ip'
|
||||
)
|
||||
|
||||
def filter_hostname_ip(self, queryset, name, value):
|
||||
queryset = queryset.annotate(
|
||||
hostname_ip=Concat(
|
||||
F('asset__hostname'), Value('('),
|
||||
F('asset__ip'), Value(')')
|
||||
)
|
||||
).filter(hostname_ip__icontains=value)
|
||||
return queryset
|
||||
|
||||
@@ -51,6 +51,7 @@ class AuthBackendLabelMapping(LazyObject):
|
||||
backend_label_mapping[settings.AUTH_BACKEND_SSO] = _('SSO')
|
||||
backend_label_mapping[settings.AUTH_BACKEND_AUTH_TOKEN] = _('Auth Token')
|
||||
backend_label_mapping[settings.AUTH_BACKEND_WECOM] = _('WeCom')
|
||||
backend_label_mapping[settings.AUTH_BACKEND_FEISHU] = _('FeiShu')
|
||||
backend_label_mapping[settings.AUTH_BACKEND_DINGTALK] = _('DingTalk')
|
||||
backend_label_mapping[settings.AUTH_BACKEND_TEMP_TOKEN] = _('Temporary token')
|
||||
return backend_label_mapping
|
||||
@@ -277,7 +278,6 @@ def on_user_auth_success(sender, user, request, login_type=None, **kwargs):
|
||||
check_different_city_login_if_need(user, request)
|
||||
data = generate_data(user.username, request, login_type=login_type)
|
||||
request.session['login_time'] = data['datetime'].strftime("%Y-%m-%d %H:%M:%S")
|
||||
request.session["MFA_VERIFY_TIME"] = int(time.time())
|
||||
data.update({'mfa': int(user.mfa_enabled), 'status': True})
|
||||
write_login_log(**data)
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ def clean_ftp_log_period():
|
||||
now = timezone.now()
|
||||
days = get_log_keep_day('FTP_LOG_KEEP_DAYS')
|
||||
expired_day = now - datetime.timedelta(days=days)
|
||||
FTPLog.objects.filter(datetime__lt=expired_day).delete()
|
||||
FTPLog.objects.filter(date_start__lt=expired_day).delete()
|
||||
|
||||
|
||||
@register_as_period_task(interval=3600*24)
|
||||
|
||||
@@ -1,85 +1,64 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
from django.utils import timezone
|
||||
from django.conf import settings
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from rest_framework.generics import ListCreateAPIView
|
||||
from rest_framework.generics import RetrieveAPIView, CreateAPIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
|
||||
from common.permissions import IsValidUser
|
||||
from ..mfa import MFAOtp
|
||||
from common.permissions import IsValidUser, UserConfirmation
|
||||
from ..const import ConfirmType
|
||||
from ..mixins import authenticate
|
||||
from ..serializers import ConfirmSerializer
|
||||
|
||||
|
||||
class ConfirmViewSet(ListCreateAPIView):
|
||||
class ConfirmBindORUNBindOAuth(RetrieveAPIView):
|
||||
permission_classes = (UserConfirmation.require(ConfirmType.ReLogin),)
|
||||
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
return Response('ok')
|
||||
|
||||
|
||||
class ConfirmApi(RetrieveAPIView, CreateAPIView):
|
||||
permission_classes = (IsValidUser,)
|
||||
serializer_class = ConfirmSerializer
|
||||
|
||||
def check(self, confirm_type: str):
|
||||
if confirm_type == ConfirmType.MFA:
|
||||
return self.user.mfa_enabled
|
||||
def get_confirm_backend(self, confirm_type):
|
||||
backend_classes = ConfirmType.get_can_confirm_backend_classes(confirm_type)
|
||||
if not backend_classes:
|
||||
return
|
||||
for backend_cls in backend_classes:
|
||||
backend = backend_cls(self.request.user, self.request)
|
||||
if not backend.check():
|
||||
continue
|
||||
return backend
|
||||
|
||||
if confirm_type == ConfirmType.PASSWORD:
|
||||
return self.user.is_password_authenticate()
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
confirm_type = request.query_params.get('confirm_type')
|
||||
backend = self.get_confirm_backend(confirm_type)
|
||||
if backend is None:
|
||||
msg = _('This action require verify your MFA')
|
||||
return Response(data={'error': msg}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
if confirm_type == ConfirmType.RELOGIN:
|
||||
return not self.user.is_password_authenticate()
|
||||
|
||||
def authenticate(self, confirm_type, secret_key):
|
||||
if confirm_type == ConfirmType.MFA:
|
||||
ok, msg = MFAOtp(self.user).check_code(secret_key)
|
||||
return ok, msg
|
||||
|
||||
if confirm_type == ConfirmType.PASSWORD:
|
||||
ok = authenticate(self.request, username=self.user.username, password=secret_key)
|
||||
msg = '' if ok else _('Authentication failed password incorrect')
|
||||
return ok, msg
|
||||
|
||||
if confirm_type == ConfirmType.RELOGIN:
|
||||
now = timezone.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
now = datetime.strptime(now, '%Y-%m-%d %H:%M:%S')
|
||||
login_time = self.request.session.get('login_time')
|
||||
SPECIFIED_TIME = 5
|
||||
msg = _('Login time has exceeded {} minutes, please login again').format(SPECIFIED_TIME)
|
||||
if not login_time:
|
||||
return False, msg
|
||||
login_time = datetime.strptime(login_time, '%Y-%m-%d %H:%M:%S')
|
||||
if (now - login_time).seconds >= SPECIFIED_TIME * 60:
|
||||
return False, msg
|
||||
return True, ''
|
||||
|
||||
@property
|
||||
def user(self):
|
||||
return self.request.user
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
if not settings.SECURITY_VIEW_AUTH_NEED_MFA:
|
||||
return Response('ok')
|
||||
|
||||
mfa_verify_time = request.session.get('MFA_VERIFY_TIME', 0)
|
||||
if time.time() - mfa_verify_time < settings.SECURITY_MFA_VERIFY_TTL:
|
||||
return Response('ok')
|
||||
|
||||
data = []
|
||||
for i, confirm_type in enumerate(ConfirmType.values, 1):
|
||||
if self.check(confirm_type):
|
||||
data.append({'name': confirm_type, 'level': i})
|
||||
msg = _('This action require verify your MFA')
|
||||
return Response({'error': msg, 'backends': data}, status=400)
|
||||
data = {
|
||||
'confirm_type': backend.name,
|
||||
'content': backend.content,
|
||||
}
|
||||
return Response(data=data)
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
validated_data = serializer.validated_data
|
||||
|
||||
confirm_type = validated_data.get('confirm_type')
|
||||
mfa_type = validated_data.get('mfa_type')
|
||||
secret_key = validated_data.get('secret_key')
|
||||
ok, msg = self.authenticate(confirm_type, secret_key)
|
||||
|
||||
backend = self.get_confirm_backend(confirm_type)
|
||||
ok, msg = backend.authenticate(secret_key, mfa_type)
|
||||
if ok:
|
||||
request.session["MFA_VERIFY_TIME"] = int(time.time())
|
||||
request.session['CONFIRM_LEVEL'] = ConfirmType.values.index(confirm_type) + 1
|
||||
request.session['CONFIRM_TIME'] = int(time.time())
|
||||
return Response('ok')
|
||||
return Response({'error': msg}, status=400)
|
||||
|
||||
@@ -1,58 +1,64 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
import urllib.parse
|
||||
import json
|
||||
from typing import Callable
|
||||
import abc
|
||||
import os
|
||||
import json
|
||||
import base64
|
||||
import ctypes
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.shortcuts import get_object_or_404
|
||||
import urllib.parse
|
||||
from django.http import HttpResponse
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import ugettext as _
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.viewsets import GenericViewSet
|
||||
from rest_framework.decorators import action
|
||||
from django.shortcuts import get_object_or_404
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework import serializers
|
||||
from django.conf import settings
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from rest_framework.request import Request
|
||||
|
||||
from applications.models import Application
|
||||
from authentication.signals import post_auth_failed
|
||||
from common.utils import get_logger, random_string
|
||||
from common.mixins.api import SerializerMixin
|
||||
from common.utils.common import get_file_by_arch
|
||||
from orgs.mixins.api import RootOrgViewMixin
|
||||
from common.drf.api import JMSModelViewSet
|
||||
from common.http import is_true
|
||||
from orgs.mixins.api import RootOrgViewMixin
|
||||
from perms.models.base import Action
|
||||
from perms.utils.application.permission import get_application_actions
|
||||
from perms.utils.asset.permission import get_asset_actions
|
||||
from common.const.http import PATCH
|
||||
from terminal.models import EndpointRule
|
||||
from ..serializers import (
|
||||
ConnectionTokenSerializer, ConnectionTokenSecretSerializer, SuperConnectionTokenSerializer
|
||||
ConnectionTokenSerializer, ConnectionTokenSecretSerializer,
|
||||
SuperConnectionTokenSerializer, ConnectionTokenDisplaySerializer,
|
||||
)
|
||||
from ..models import ConnectionToken
|
||||
|
||||
logger = get_logger(__name__)
|
||||
__all__ = ['UserConnectionTokenViewSet', 'UserSuperConnectionTokenViewSet', 'TokenCacheMixin']
|
||||
__all__ = ['ConnectionTokenViewSet', 'SuperConnectionTokenViewSet']
|
||||
|
||||
|
||||
class ClientProtocolMixin:
|
||||
"""
|
||||
下载客户端支持的连接文件,里面包含了 token,和 其他连接信息
|
||||
|
||||
- [x] RDP
|
||||
- [ ] KoKo
|
||||
|
||||
本质上,这里还是暴露出 token 来,进行使用
|
||||
"""
|
||||
class ConnectionTokenMixin:
|
||||
request: Request
|
||||
get_serializer: Callable
|
||||
create_token: Callable
|
||||
get_serializer_context: Callable
|
||||
|
||||
@staticmethod
|
||||
def check_token_valid(token: ConnectionToken):
|
||||
is_valid, error = token.check_valid()
|
||||
if not is_valid:
|
||||
raise PermissionDenied(error)
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_request_resource_user(self, serializer):
|
||||
raise NotImplementedError
|
||||
|
||||
def get_request_resources(self, serializer):
|
||||
user = self.get_request_resource_user(serializer)
|
||||
asset = serializer.validated_data.get('asset')
|
||||
application = serializer.validated_data.get('application')
|
||||
system_user = serializer.validated_data.get('system_user')
|
||||
return user, asset, application, system_user
|
||||
|
||||
@staticmethod
|
||||
def check_user_has_resource_permission(user, asset, application, system_user):
|
||||
from perms.utils.asset import has_asset_system_permission
|
||||
from perms.utils.application import has_application_system_permission
|
||||
|
||||
if asset and not has_asset_system_permission(user, asset, system_user):
|
||||
error = f'User not has this asset and system user permission: ' \
|
||||
f'user={user.id} system_user={system_user.id} asset={asset.id}'
|
||||
raise PermissionDenied(error)
|
||||
|
||||
if application and not has_application_system_permission(user, application, system_user):
|
||||
error = f'User not has this application and system user permission: ' \
|
||||
f'user={user.id} system_user={system_user.id} application={application.id}'
|
||||
raise PermissionDenied(error)
|
||||
|
||||
def get_smart_endpoint(self, protocol, asset=None, application=None):
|
||||
if asset:
|
||||
@@ -64,21 +70,32 @@ class ClientProtocolMixin:
|
||||
endpoint = EndpointRule.match_endpoint(target_ip, protocol, self.request)
|
||||
return endpoint
|
||||
|
||||
def get_request_resource(self, serializer):
|
||||
asset = serializer.validated_data.get('asset')
|
||||
application = serializer.validated_data.get('application')
|
||||
system_user = serializer.validated_data['system_user']
|
||||
|
||||
user = serializer.validated_data.get('user')
|
||||
user = user if user else self.request.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):
|
||||
options = {
|
||||
def get_client_protocol_data(self, token: ConnectionToken):
|
||||
from assets.models import SystemUser
|
||||
protocol = token.system_user.protocol
|
||||
username = token.user.username
|
||||
rdp_config = ssh_token = ''
|
||||
if protocol == SystemUser.Protocol.rdp:
|
||||
filename, rdp_config = self.get_rdp_file_info(token)
|
||||
elif protocol == SystemUser.Protocol.ssh:
|
||||
filename, ssh_token = self.get_ssh_token(token)
|
||||
else:
|
||||
raise ValueError('Protocol not support: {}'.format(protocol))
|
||||
|
||||
return {
|
||||
"filename": filename,
|
||||
"protocol": protocol,
|
||||
"username": username,
|
||||
"token": ssh_token,
|
||||
"config": rdp_config
|
||||
}
|
||||
|
||||
def get_rdp_file_info(self, token: ConnectionToken):
|
||||
rdp_options = {
|
||||
'full address:s': '',
|
||||
'username:s': '',
|
||||
# 'screen mode id:i': '1',
|
||||
@@ -111,412 +128,186 @@ class ClientProtocolMixin:
|
||||
# 'remoteapplicationcmdline:s': '',
|
||||
}
|
||||
|
||||
asset, application, system_user, user = self.get_request_resource(serializer)
|
||||
# 设置磁盘挂载
|
||||
drives_redirect = is_true(self.request.query_params.get('drives_redirect'))
|
||||
if drives_redirect:
|
||||
actions = Action.choices_to_value(token.actions)
|
||||
if actions & Action.UPDOWNLOAD == Action.UPDOWNLOAD:
|
||||
rdp_options['drivestoredirect:s'] = '*'
|
||||
|
||||
# 设置全屏
|
||||
full_screen = is_true(self.request.query_params.get('full_screen'))
|
||||
rdp_options['screen mode id:i'] = '2' if full_screen else '1'
|
||||
|
||||
# 设置 RDP Server 地址
|
||||
endpoint = self.get_smart_endpoint(
|
||||
protocol='rdp', asset=token.asset, application=token.application
|
||||
)
|
||||
rdp_options['full address:s'] = f'{endpoint.host}:{endpoint.rdp_port}'
|
||||
|
||||
# 设置用户名
|
||||
rdp_options['username:s'] = '{}|{}'.format(token.user.username, str(token.id))
|
||||
if token.system_user.ad_domain:
|
||||
rdp_options['domain:s'] = token.system_user.ad_domain
|
||||
|
||||
# 设置宽高
|
||||
height = self.request.query_params.get('height')
|
||||
width = self.request.query_params.get('width')
|
||||
full_screen = is_true(self.request.query_params.get('full_screen'))
|
||||
drives_redirect = is_true(self.request.query_params.get('drives_redirect'))
|
||||
token, secret = self.create_token(user, asset, application, system_user)
|
||||
|
||||
# 设置磁盘挂载
|
||||
if drives_redirect:
|
||||
actions = 0
|
||||
if asset:
|
||||
actions = get_asset_actions(user, asset, system_user)
|
||||
elif application:
|
||||
actions = get_application_actions(user, application, system_user)
|
||||
|
||||
if actions & Action.UPDOWNLOAD == Action.UPDOWNLOAD:
|
||||
options['drivestoredirect:s'] = '*'
|
||||
|
||||
# 全屏
|
||||
options['screen mode id:i'] = '2' if full_screen else '1'
|
||||
|
||||
# RDP Server 地址
|
||||
endpoint = self.get_smart_endpoint(
|
||||
protocol='rdp', asset=asset, application=application
|
||||
)
|
||||
options['full address:s'] = f'{endpoint.host}:{endpoint.rdp_port}'
|
||||
# 用户名
|
||||
options['username:s'] = '{}|{}'.format(user.username, token)
|
||||
if system_user.ad_domain:
|
||||
options['domain:s'] = system_user.ad_domain
|
||||
# 宽高
|
||||
if width and height:
|
||||
options['desktopwidth:i'] = width
|
||||
options['desktopheight:i'] = height
|
||||
options['winposstr:s:'] = f'0,1,0,0,{width},{height}'
|
||||
rdp_options['desktopwidth:i'] = width
|
||||
rdp_options['desktopheight:i'] = height
|
||||
rdp_options['winposstr:s:'] = f'0,1,0,0,{width},{height}'
|
||||
|
||||
options['session bpp:i'] = os.getenv('JUMPSERVER_COLOR_DEPTH', '32')
|
||||
options['audiomode:i'] = self.parse_env_bool('JUMPSERVER_DISABLE_AUDIO', 'false', '2', '0')
|
||||
# 设置其他选项
|
||||
rdp_options['session bpp:i'] = os.getenv('JUMPSERVER_COLOR_DEPTH', '32')
|
||||
rdp_options['audiomode:i'] = self.parse_env_bool('JUMPSERVER_DISABLE_AUDIO', 'false', '2', '0')
|
||||
|
||||
if asset:
|
||||
name = asset.hostname
|
||||
elif application:
|
||||
name = application.name
|
||||
application.get_rdp_remote_app_setting()
|
||||
|
||||
app = f'||jmservisor'
|
||||
options['remoteapplicationmode:i'] = '1'
|
||||
options['alternate shell:s'] = app
|
||||
options['remoteapplicationprogram:s'] = app
|
||||
options['remoteapplicationname:s'] = name
|
||||
if token.asset:
|
||||
name = token.asset.hostname
|
||||
elif token.application and token.application.category_remote_app:
|
||||
app = '||jmservisor'
|
||||
name = token.application.name
|
||||
rdp_options['remoteapplicationmode:i'] = '1'
|
||||
rdp_options['alternate shell:s'] = app
|
||||
rdp_options['remoteapplicationprogram:s'] = app
|
||||
rdp_options['remoteapplicationname:s'] = name
|
||||
else:
|
||||
name = '*'
|
||||
prefix_name = f'{token.user.username}-{name}'
|
||||
filename = self.get_connect_filename(prefix_name)
|
||||
|
||||
content = ''
|
||||
for k, v in options.items():
|
||||
for k, v in rdp_options.items():
|
||||
content += f'{k}:{v}\n'
|
||||
return name, content
|
||||
|
||||
def get_ssh_token(self, serializer):
|
||||
asset, application, system_user, user = self.get_request_resource(serializer)
|
||||
token, secret = self.create_token(user, asset, application, system_user)
|
||||
if asset:
|
||||
name = asset.hostname
|
||||
elif application:
|
||||
name = application.name
|
||||
return filename, content
|
||||
|
||||
@staticmethod
|
||||
def get_connect_filename(prefix_name):
|
||||
prefix_name = prefix_name.replace('/', '_')
|
||||
prefix_name = prefix_name.replace('\\', '_')
|
||||
prefix_name = prefix_name.replace('.', '_')
|
||||
filename = f'{prefix_name}-jumpserver'
|
||||
filename = urllib.parse.quote(filename)
|
||||
return filename
|
||||
|
||||
def get_ssh_token(self, token: ConnectionToken):
|
||||
if token.asset:
|
||||
name = token.asset.hostname
|
||||
elif token.application:
|
||||
name = token.application.name
|
||||
else:
|
||||
name = '*'
|
||||
prefix_name = f'{token.user.username}-{name}'
|
||||
filename = self.get_connect_filename(prefix_name)
|
||||
|
||||
endpoint = self.get_smart_endpoint(
|
||||
protocol='ssh', asset=asset, application=application
|
||||
protocol='ssh', asset=token.asset, application=token.application
|
||||
)
|
||||
content = {
|
||||
data = {
|
||||
'ip': endpoint.host,
|
||||
'port': str(endpoint.ssh_port),
|
||||
'username': f'JMS-{token}',
|
||||
'password': secret
|
||||
'username': 'JMS-{}'.format(str(token.id)),
|
||||
'password': token.secret
|
||||
}
|
||||
token = json.dumps(content)
|
||||
return name, token
|
||||
|
||||
def get_encrypt_cmdline(self, app: Application):
|
||||
parameters = app.get_rdp_remote_app_setting()['parameters']
|
||||
parameters = parameters.encode('ascii')
|
||||
|
||||
lib_path = get_file_by_arch('xpack/libs', 'librailencrypt.so')
|
||||
lib = ctypes.CDLL(lib_path)
|
||||
lib.encrypt.argtypes = [ctypes.c_char_p, ctypes.c_int]
|
||||
lib.encrypt.restype = ctypes.c_char_p
|
||||
|
||||
rst = lib.encrypt(parameters, len(parameters))
|
||||
rst = rst.decode('ascii')
|
||||
return rst
|
||||
|
||||
def get_valid_serializer(self):
|
||||
if self.request.method == 'GET':
|
||||
data = self.request.query_params
|
||||
else:
|
||||
data = self.request.data
|
||||
serializer = self.get_serializer(data=data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
return serializer
|
||||
|
||||
def get_client_protocol_data(self, serializer):
|
||||
asset, application, system_user, user = self.get_request_resource(serializer)
|
||||
protocol = system_user.protocol
|
||||
username = user.username
|
||||
config, token = '', ''
|
||||
if protocol == 'rdp':
|
||||
name, config = self.get_rdp_file_content(serializer)
|
||||
elif protocol == 'ssh':
|
||||
name, token = self.get_ssh_token(serializer)
|
||||
else:
|
||||
raise ValueError('Protocol not support: {}'.format(protocol))
|
||||
|
||||
filename = "{}-{}-jumpserver".format(username, name)
|
||||
data = {
|
||||
"filename": filename,
|
||||
"protocol": system_user.protocol,
|
||||
"username": username,
|
||||
"token": token,
|
||||
"config": config
|
||||
}
|
||||
return data
|
||||
|
||||
@action(methods=['POST', 'GET'], detail=False, url_path='rdp/file')
|
||||
def get_rdp_file(self, request, *args, **kwargs):
|
||||
if self.request.method == 'GET':
|
||||
data = self.request.query_params
|
||||
else:
|
||||
data = self.request.data
|
||||
serializer = self.get_serializer(data=data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
name, data = self.get_rdp_file_content(serializer)
|
||||
response = HttpResponse(data, content_type='application/octet-stream')
|
||||
filename = "{}-{}-jumpserver.rdp".format(self.request.user.username, name)
|
||||
filename = urllib.parse.quote(filename)
|
||||
response['Content-Disposition'] = 'attachment; filename*=UTF-8\'\'%s' % filename
|
||||
return response
|
||||
|
||||
@action(methods=['POST', 'GET'], detail=False, url_path='client-url')
|
||||
def get_client_protocol_url(self, request, *args, **kwargs):
|
||||
serializer = self.get_valid_serializer()
|
||||
try:
|
||||
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 = {
|
||||
'url': 'jms://{}'.format(protocol_data),
|
||||
}
|
||||
return Response(data=data)
|
||||
token = json.dumps(data)
|
||||
return filename, token
|
||||
|
||||
|
||||
class SecretDetailMixin:
|
||||
valid_token: Callable
|
||||
request: Request
|
||||
get_serializer: Callable
|
||||
|
||||
@staticmethod
|
||||
def _get_application_secret_detail(application):
|
||||
gateway = None
|
||||
remote_app = None
|
||||
asset = None
|
||||
|
||||
if application.category_remote_app:
|
||||
remote_app = application.get_rdp_remote_app_setting()
|
||||
asset = application.get_remote_app_asset()
|
||||
domain = asset.domain
|
||||
else:
|
||||
domain = application.domain
|
||||
|
||||
if domain and domain.has_gateway():
|
||||
gateway = domain.random_gateway()
|
||||
|
||||
return {
|
||||
'asset': asset,
|
||||
'application': application,
|
||||
'gateway': gateway,
|
||||
'domain': domain,
|
||||
'remote_app': remote_app,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _get_asset_secret_detail(asset):
|
||||
gateway = None
|
||||
if asset and asset.domain and asset.domain.has_gateway():
|
||||
gateway = asset.domain.random_gateway()
|
||||
|
||||
return {
|
||||
'asset': asset,
|
||||
'application': None,
|
||||
'domain': asset.domain,
|
||||
'gateway': gateway,
|
||||
'remote_app': None,
|
||||
}
|
||||
|
||||
@action(methods=['POST'], detail=False, url_path='secret-info/detail')
|
||||
def get_secret_detail(self, request, *args, **kwargs):
|
||||
perm_required = 'authentication.view_connectiontokensecret'
|
||||
|
||||
# 非常重要的 api,再逻辑层再判断一下,双重保险
|
||||
if not request.user.has_perm(perm_required):
|
||||
raise PermissionDenied('Not allow to view secret')
|
||||
|
||||
token = request.data.get('token', '')
|
||||
try:
|
||||
value, user, system_user, asset, app, expired_at, actions = self.valid_token(token)
|
||||
except serializers.ValidationError as e:
|
||||
post_auth_failed.send(
|
||||
sender=self.__class__, username='', request=self.request,
|
||||
reason=_('Invalid token')
|
||||
)
|
||||
raise e
|
||||
|
||||
data = dict(
|
||||
id=token, secret=value.get('secret', ''),
|
||||
user=user, system_user=system_user,
|
||||
expired_at=expired_at, actions=actions
|
||||
)
|
||||
cmd_filter_kwargs = {
|
||||
'system_user_id': system_user.id,
|
||||
'user_id': user.id,
|
||||
}
|
||||
if asset:
|
||||
asset_detail = self._get_asset_secret_detail(asset)
|
||||
system_user.load_asset_more_auth(asset.id, user.username, user.id)
|
||||
data['type'] = 'asset'
|
||||
data.update(asset_detail)
|
||||
cmd_filter_kwargs['asset_id'] = asset.id
|
||||
else:
|
||||
app_detail = self._get_application_secret_detail(app)
|
||||
system_user.load_app_more_auth(app.id, user.username, user.id)
|
||||
data['type'] = 'application'
|
||||
data.update(app_detail)
|
||||
cmd_filter_kwargs['application_id'] = app.id
|
||||
|
||||
from assets.models import CommandFilterRule
|
||||
cmd_filter_rules = CommandFilterRule.get_queryset(**cmd_filter_kwargs)
|
||||
data['cmd_filter_rules'] = cmd_filter_rules
|
||||
|
||||
serializer = self.get_serializer(data)
|
||||
return Response(data=serializer.data, status=200)
|
||||
|
||||
|
||||
class TokenCacheMixin:
|
||||
""" endpoint smart view 用到此类来解析token中的资产、应用 """
|
||||
CACHE_KEY_PREFIX = 'CONNECTION_TOKEN_{}'
|
||||
|
||||
def renewal_token(self, token, ttl=None):
|
||||
value = self.get_token_from_cache(token)
|
||||
if value:
|
||||
pre_ttl = self.get_token_ttl(token)
|
||||
self.set_token_to_cache(token, value, ttl)
|
||||
post_ttl = self.get_token_ttl(token)
|
||||
ok = True
|
||||
msg = f'{pre_ttl}s is renewed to {post_ttl}s.'
|
||||
else:
|
||||
ok = False
|
||||
msg = 'Token is not found.'
|
||||
data = {
|
||||
'ok': ok,
|
||||
'msg': msg
|
||||
}
|
||||
return data
|
||||
|
||||
def get_token_ttl(self, token):
|
||||
key = self.get_token_cache_key(token)
|
||||
return cache.ttl(key)
|
||||
|
||||
def set_token_to_cache(self, token, value, ttl=None):
|
||||
key = self.get_token_cache_key(token)
|
||||
ttl = ttl or settings.CONNECTION_TOKEN_EXPIRATION
|
||||
cache.set(key, value, timeout=ttl)
|
||||
|
||||
def get_token_from_cache(self, token):
|
||||
key = self.get_token_cache_key(token)
|
||||
value = cache.get(key, None)
|
||||
return value
|
||||
|
||||
def get_token_cache_key(self, token):
|
||||
return self.CACHE_KEY_PREFIX.format(token)
|
||||
|
||||
|
||||
class BaseUserConnectionTokenViewSet(
|
||||
RootOrgViewMixin, SerializerMixin, ClientProtocolMixin,
|
||||
TokenCacheMixin, GenericViewSet
|
||||
):
|
||||
|
||||
@staticmethod
|
||||
def check_resource_permission(user, asset, application, system_user):
|
||||
from perms.utils.asset import has_asset_system_permission
|
||||
from perms.utils.application import has_application_system_permission
|
||||
|
||||
if asset and not has_asset_system_permission(user, asset, system_user):
|
||||
error = f'User not has this asset and system user permission: ' \
|
||||
f'user={user.id} system_user={system_user.id} asset={asset.id}'
|
||||
raise PermissionDenied(error)
|
||||
if application and not has_application_system_permission(user, application, system_user):
|
||||
error = f'User not has this application and system user permission: ' \
|
||||
f'user={user.id} system_user={system_user.id} application={application.id}'
|
||||
raise PermissionDenied(error)
|
||||
return True
|
||||
|
||||
def create_token(self, user, asset, application, system_user, ttl=None):
|
||||
self.check_resource_permission(user, asset, application, system_user)
|
||||
token = random_string(36)
|
||||
secret = random_string(16)
|
||||
value = {
|
||||
'id': token,
|
||||
'secret': secret,
|
||||
'user': str(user.id),
|
||||
'username': user.username,
|
||||
'system_user': str(system_user.id),
|
||||
'system_user_name': system_user.name,
|
||||
'created_by': str(self.request.user),
|
||||
'date_created': str(timezone.now())
|
||||
}
|
||||
|
||||
if asset:
|
||||
value.update({
|
||||
'type': 'asset',
|
||||
'asset': str(asset.id),
|
||||
'hostname': asset.hostname,
|
||||
})
|
||||
elif application:
|
||||
value.update({
|
||||
'type': 'application',
|
||||
'application': application.id,
|
||||
'application_name': str(application)
|
||||
})
|
||||
|
||||
self.set_token_to_cache(token, value, ttl)
|
||||
return token, secret
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
asset, application, system_user, user = self.get_request_resource(serializer)
|
||||
token, secret = self.create_token(user, asset, application, system_user)
|
||||
tp = 'app' if application else 'asset'
|
||||
data = {
|
||||
"id": token, 'secret': secret,
|
||||
'type': tp, 'protocol': system_user.protocol,
|
||||
'expire_time': self.get_token_ttl(token),
|
||||
}
|
||||
return Response(data, status=201)
|
||||
|
||||
|
||||
class UserConnectionTokenViewSet(BaseUserConnectionTokenViewSet, SecretDetailMixin):
|
||||
class ConnectionTokenViewSet(ConnectionTokenMixin, RootOrgViewMixin, JMSModelViewSet):
|
||||
filterset_fields = (
|
||||
'type', 'user_display', 'system_user_display',
|
||||
'application_display', 'asset_display'
|
||||
)
|
||||
search_fields = filterset_fields
|
||||
serializer_classes = {
|
||||
'default': ConnectionTokenSerializer,
|
||||
'list': ConnectionTokenDisplaySerializer,
|
||||
'retrieve': ConnectionTokenDisplaySerializer,
|
||||
'get_secret_detail': ConnectionTokenSecretSerializer,
|
||||
}
|
||||
rbac_perms = {
|
||||
'GET': 'authentication.view_connectiontoken',
|
||||
'retrieve': 'authentication.view_connectiontoken',
|
||||
'create': 'authentication.add_connectiontoken',
|
||||
'expire': 'authentication.add_connectiontoken',
|
||||
'get_secret_detail': 'authentication.view_connectiontokensecret',
|
||||
'get_rdp_file': 'authentication.add_connectiontoken',
|
||||
'get_client_protocol_url': 'authentication.add_connectiontoken',
|
||||
}
|
||||
|
||||
def valid_token(self, token):
|
||||
from users.models import User
|
||||
from assets.models import SystemUser, Asset
|
||||
from applications.models import Application
|
||||
from perms.utils.asset.permission import validate_permission as asset_validate_permission
|
||||
from perms.utils.application.permission import validate_permission as app_validate_permission
|
||||
def get_queryset(self):
|
||||
return ConnectionToken.objects.filter(user=self.request.user)
|
||||
|
||||
value = self.get_token_from_cache(token)
|
||||
if not value:
|
||||
raise serializers.ValidationError('Token not found')
|
||||
def get_request_resource_user(self, serializer):
|
||||
return self.request.user
|
||||
|
||||
user = get_object_or_404(User, id=value.get('user'))
|
||||
if not user.is_valid:
|
||||
raise serializers.ValidationError("User not valid, disabled or expired")
|
||||
|
||||
system_user = get_object_or_404(SystemUser, id=value.get('system_user'))
|
||||
asset = None
|
||||
app = None
|
||||
if value.get('type') == 'asset':
|
||||
asset = get_object_or_404(Asset, id=value.get('asset'))
|
||||
if not asset.is_active:
|
||||
raise serializers.ValidationError("Asset disabled")
|
||||
has_perm, actions, expired_at = asset_validate_permission(user, asset, system_user)
|
||||
def get_object(self):
|
||||
if self.request.user.is_service_account:
|
||||
# TODO: 组件获取 token 详情,将来放在 Super-connection-token API 中
|
||||
obj = get_object_or_404(ConnectionToken, pk=self.kwargs.get('pk'))
|
||||
else:
|
||||
app = get_object_or_404(Application, id=value.get('application'))
|
||||
has_perm, actions, expired_at = app_validate_permission(user, app, system_user)
|
||||
obj = super(ConnectionTokenViewSet, self).get_object()
|
||||
return obj
|
||||
|
||||
if not has_perm:
|
||||
raise serializers.ValidationError('Permission expired or invalid')
|
||||
return value, user, system_user, asset, app, expired_at, actions
|
||||
def create_connection_token(self):
|
||||
data = self.request.query_params if self.request.method == 'GET' else self.request.data
|
||||
serializer = self.get_serializer(data=data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
self.perform_create(serializer)
|
||||
token: ConnectionToken = serializer.instance
|
||||
return token
|
||||
|
||||
def get(self, request):
|
||||
token = request.query_params.get('token')
|
||||
value = self.get_token_from_cache(token)
|
||||
if not value:
|
||||
return Response('', status=404)
|
||||
return Response(value)
|
||||
def perform_create(self, serializer):
|
||||
user, asset, application, system_user = self.get_request_resources(serializer)
|
||||
self.check_user_has_resource_permission(user, asset, application, system_user)
|
||||
return super(ConnectionTokenViewSet, self).perform_create(serializer)
|
||||
|
||||
@action(methods=['POST'], detail=False, url_path='secret-info/detail')
|
||||
def get_secret_detail(self, request, *args, **kwargs):
|
||||
# 非常重要的 api,在逻辑层再判断一下,双重保险
|
||||
perm_required = 'authentication.view_connectiontokensecret'
|
||||
if not request.user.has_perm(perm_required):
|
||||
raise PermissionDenied('Not allow to view secret')
|
||||
token_id = request.data.get('token') or ''
|
||||
token = get_object_or_404(ConnectionToken, pk=token_id)
|
||||
self.check_token_valid(token)
|
||||
token.load_system_user_auth()
|
||||
serializer = self.get_serializer(instance=token)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@action(methods=['POST', 'GET'], detail=False, url_path='rdp/file')
|
||||
def get_rdp_file(self, request, *args, **kwargs):
|
||||
token = self.create_connection_token()
|
||||
self.check_token_valid(token)
|
||||
filename, content = self.get_rdp_file_info(token)
|
||||
filename = '{}.rdp'.format(filename)
|
||||
response = HttpResponse(content, content_type='application/octet-stream')
|
||||
response['Content-Disposition'] = 'attachment; filename*=UTF-8\'\'%s' % filename
|
||||
return response
|
||||
|
||||
@action(methods=['POST', 'GET'], detail=False, url_path='client-url')
|
||||
def get_client_protocol_url(self, request, *args, **kwargs):
|
||||
token = self.create_connection_token()
|
||||
self.check_token_valid(token)
|
||||
try:
|
||||
protocol_data = self.get_client_protocol_data(token)
|
||||
except ValueError as e:
|
||||
return Response(data={'error': str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
protocol_data = json.dumps(protocol_data).encode()
|
||||
protocol_data = base64.b64encode(protocol_data).decode()
|
||||
data = {
|
||||
'url': 'jms://{}'.format(protocol_data)
|
||||
}
|
||||
return Response(data=data)
|
||||
|
||||
@action(methods=['PATCH'], detail=True)
|
||||
def expire(self, request, *args, **kwargs):
|
||||
instance = self.get_object()
|
||||
instance.expire()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class UserSuperConnectionTokenViewSet(
|
||||
BaseUserConnectionTokenViewSet, TokenCacheMixin, GenericViewSet
|
||||
):
|
||||
class SuperConnectionTokenViewSet(ConnectionTokenViewSet):
|
||||
serializer_classes = {
|
||||
'default': SuperConnectionTokenSerializer,
|
||||
}
|
||||
@@ -525,10 +316,21 @@ class UserSuperConnectionTokenViewSet(
|
||||
'renewal': 'authentication.add_superconnectiontoken'
|
||||
}
|
||||
|
||||
@action(methods=[PATCH], detail=False)
|
||||
def get_request_resource_user(self, serializer):
|
||||
return serializer.validated_data.get('user')
|
||||
|
||||
@action(methods=['PATCH'], detail=False)
|
||||
def renewal(self, request, *args, **kwargs):
|
||||
""" 续期 Token """
|
||||
token = request.data.get('token', '')
|
||||
data = self.renewal_token(token)
|
||||
status_code = 200 if data.get('ok') else 404
|
||||
return Response(data=data, status=status_code)
|
||||
from common.utils.timezone import as_current_tz
|
||||
|
||||
token_id = request.data.get('token') or ''
|
||||
token = get_object_or_404(ConnectionToken, pk=token_id)
|
||||
date_expired = as_current_tz(token.date_expired)
|
||||
if token.is_expired:
|
||||
raise PermissionDenied('Token is expired at: {}'.format(date_expired))
|
||||
token.renewal()
|
||||
data = {
|
||||
'ok': True,
|
||||
'msg': f'Token is renewed, date expired: {date_expired}'
|
||||
}
|
||||
return Response(data=data, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -2,10 +2,11 @@ from rest_framework.views import APIView
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
|
||||
from users.permissions import IsAuthConfirmTimeValid
|
||||
from users.models import User
|
||||
from common.utils import get_logger
|
||||
from common.permissions import UserConfirmation
|
||||
from common.mixins.api import RoleUserMixin, RoleAdminMixin
|
||||
from authentication.const import ConfirmType
|
||||
from authentication import errors
|
||||
|
||||
logger = get_logger(__file__)
|
||||
@@ -26,7 +27,7 @@ class DingTalkQRUnBindBase(APIView):
|
||||
|
||||
|
||||
class DingTalkQRUnBindForUserApi(RoleUserMixin, DingTalkQRUnBindBase):
|
||||
permission_classes = (IsAuthConfirmTimeValid,)
|
||||
permission_classes = (UserConfirmation.require(ConfirmType.ReLogin),)
|
||||
|
||||
|
||||
class DingTalkQRUnBindForAdminApi(RoleAdminMixin, DingTalkQRUnBindBase):
|
||||
|
||||
@@ -2,10 +2,11 @@ from rest_framework.views import APIView
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
|
||||
from users.permissions import IsAuthConfirmTimeValid
|
||||
from users.models import User
|
||||
from common.utils import get_logger
|
||||
from common.permissions import UserConfirmation
|
||||
from common.mixins.api import RoleUserMixin, RoleAdminMixin
|
||||
from authentication.const import ConfirmType
|
||||
from authentication import errors
|
||||
|
||||
logger = get_logger(__file__)
|
||||
@@ -26,7 +27,7 @@ class FeiShuQRUnBindBase(APIView):
|
||||
|
||||
|
||||
class FeiShuQRUnBindForUserApi(RoleUserMixin, FeiShuQRUnBindBase):
|
||||
permission_classes = (IsAuthConfirmTimeValid,)
|
||||
permission_classes = (UserConfirmation.require(ConfirmType.ReLogin),)
|
||||
|
||||
|
||||
class FeiShuQRUnBindForAdminApi(RoleAdminMixin, FeiShuQRUnBindBase):
|
||||
|
||||
@@ -6,6 +6,8 @@ from rest_framework.permissions import AllowAny
|
||||
|
||||
from common.utils import get_logger
|
||||
from .. import errors, mixins
|
||||
from django.contrib.auth import logout as auth_logout
|
||||
|
||||
|
||||
__all__ = ['TicketStatusApi']
|
||||
logger = get_logger(__name__)
|
||||
@@ -17,7 +19,15 @@ class TicketStatusApi(mixins.AuthMixin, APIView):
|
||||
def get(self, request, *args, **kwargs):
|
||||
try:
|
||||
self.check_user_login_confirm()
|
||||
self.request.session['auth_third_party_done'] = 1
|
||||
return Response({"msg": "ok"})
|
||||
except errors.LoginConfirmOtherError as e:
|
||||
reason = e.msg
|
||||
username = e.username
|
||||
self.send_auth_signal(success=False, username=username, reason=reason)
|
||||
# 若为三方登录,此时应退出登录
|
||||
auth_logout(request)
|
||||
return Response(e.as_data(), status=200)
|
||||
except errors.NeedMoreInfoError as e:
|
||||
return Response(e.as_data(), status=200)
|
||||
|
||||
@@ -25,5 +35,5 @@ class TicketStatusApi(mixins.AuthMixin, APIView):
|
||||
ticket = self.get_ticket()
|
||||
if ticket:
|
||||
request.session.pop('auth_ticket_id', '')
|
||||
ticket.close(processor=self.get_user_from_session())
|
||||
ticket.close()
|
||||
return Response('', status=200)
|
||||
|
||||
@@ -10,22 +10,17 @@ from rest_framework.generics import CreateAPIView
|
||||
from rest_framework.serializers import ValidationError
|
||||
from rest_framework.response import Response
|
||||
|
||||
from common.permissions import IsValidUser, NeedMFAVerify
|
||||
from common.utils import get_logger
|
||||
from common.exceptions import UnexpectError
|
||||
from users.models.user import User
|
||||
from ..serializers import OtpVerifySerializer
|
||||
from .. import serializers
|
||||
from .. import errors
|
||||
from ..mfa.otp import MFAOtp
|
||||
from ..mixins import AuthMixin
|
||||
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
__all__ = [
|
||||
'MFAChallengeVerifyApi', 'UserOtpVerifyApi',
|
||||
'MFASendCodeApi'
|
||||
'MFAChallengeVerifyApi', 'MFASendCodeApi'
|
||||
]
|
||||
|
||||
|
||||
@@ -88,30 +83,3 @@ class MFAChallengeVerifyApi(AuthMixin, CreateAPIView):
|
||||
raise ValidationError(data)
|
||||
except errors.NeedMoreInfoError as e:
|
||||
return Response(e.as_data(), status=200)
|
||||
|
||||
|
||||
class UserOtpVerifyApi(CreateAPIView):
|
||||
permission_classes = (IsValidUser,)
|
||||
serializer_class = OtpVerifySerializer
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
return Response({'code': 'valid', 'msg': 'verified'})
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
code = serializer.validated_data["code"]
|
||||
otp = MFAOtp(request.user)
|
||||
|
||||
ok, error = otp.check_code(code)
|
||||
if ok:
|
||||
request.session["MFA_VERIFY_TIME"] = int(time.time())
|
||||
return Response({"ok": "1"})
|
||||
else:
|
||||
return Response({"error": _("Code is invalid, {}").format(error)}, status=400)
|
||||
|
||||
def get_permissions(self):
|
||||
if self.request.method.lower() == 'get' \
|
||||
and settings.SECURITY_VIEW_AUTH_NEED_MFA:
|
||||
self.permission_classes = [NeedMFAVerify]
|
||||
return super().get_permissions()
|
||||
|
||||
@@ -2,10 +2,11 @@ from rest_framework.views import APIView
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
|
||||
from users.permissions import IsAuthConfirmTimeValid
|
||||
from users.models import User
|
||||
from common.utils import get_logger
|
||||
from common.permissions import UserConfirmation
|
||||
from common.mixins.api import RoleUserMixin, RoleAdminMixin
|
||||
from authentication.const import ConfirmType
|
||||
from authentication import errors
|
||||
|
||||
logger = get_logger(__file__)
|
||||
@@ -26,7 +27,7 @@ class WeComQRUnBindBase(APIView):
|
||||
|
||||
|
||||
class WeComQRUnBindForUserApi(RoleUserMixin, WeComQRUnBindBase):
|
||||
permission_classes = (IsAuthConfirmTimeValid,)
|
||||
permission_classes = (UserConfirmation.require(ConfirmType.ReLogin),)
|
||||
|
||||
|
||||
class WeComQRUnBindForAdminApi(RoleAdminMixin, WeComQRUnBindBase):
|
||||
|
||||
@@ -49,7 +49,7 @@ class JMSBaseAuthBackend:
|
||||
if not allow:
|
||||
info = 'User {} skip authentication backend {}, because it not in {}'
|
||||
info = info.format(username, backend_name, ','.join(allowed_backend_names))
|
||||
logger.debug(info)
|
||||
logger.info(info)
|
||||
return allow
|
||||
|
||||
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
from django.urls import path
|
||||
import django_cas_ng.views
|
||||
|
||||
from .views import CASLoginView
|
||||
|
||||
urlpatterns = [
|
||||
path('login/', django_cas_ng.views.LoginView.as_view(), name='cas-login'),
|
||||
path('login/', CASLoginView.as_view(), name='cas-login'),
|
||||
path('logout/', django_cas_ng.views.LogoutView.as_view(), name='cas-logout'),
|
||||
path('callback/', django_cas_ng.views.CallbackView.as_view(), name='cas-proxy-callback'),
|
||||
]
|
||||
|
||||
15
apps/authentication/backends/cas/views.py
Normal file
15
apps/authentication/backends/cas/views.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from django_cas_ng.views import LoginView
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.http import HttpResponseRedirect
|
||||
|
||||
__all__ = ['LoginView']
|
||||
|
||||
|
||||
class CASLoginView(LoginView):
|
||||
def get(self, request):
|
||||
try:
|
||||
return super().get(request)
|
||||
except PermissionDenied:
|
||||
return HttpResponseRedirect('/')
|
||||
|
||||
|
||||
61
apps/authentication/backends/custom.py
Normal file
61
apps/authentication/backends/custom.py
Normal file
@@ -0,0 +1,61 @@
|
||||
from django.conf import settings
|
||||
from django.utils.module_loading import import_string
|
||||
from common.utils import get_logger
|
||||
from django.contrib.auth import get_user_model
|
||||
from authentication.signals import user_auth_failed, user_auth_success
|
||||
|
||||
from .base import JMSModelBackend
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
||||
custom_authenticate_method = None
|
||||
|
||||
if settings.AUTH_CUSTOM:
|
||||
""" 保证自定义认证方法在服务运行时不能被更改,只在第一次调用时加载一次 """
|
||||
try:
|
||||
custom_auth_method_path = 'data.auth.main.authenticate'
|
||||
custom_authenticate_method = import_string(custom_auth_method_path)
|
||||
except Exception as e:
|
||||
logger.warning('Import custom auth method failed: {}, Maybe not enabled'.format(e))
|
||||
|
||||
|
||||
class CustomAuthBackend(JMSModelBackend):
|
||||
|
||||
def is_enabled(self):
|
||||
return settings.AUTH_CUSTOM and callable(custom_authenticate_method)
|
||||
|
||||
@staticmethod
|
||||
def get_or_create_user_from_userinfo(userinfo: dict):
|
||||
username = userinfo['username']
|
||||
attrs = ['name', 'username', 'email', 'is_active']
|
||||
defaults = {attr: userinfo[attr] for attr in attrs}
|
||||
user, created = get_user_model().objects.get_or_create(
|
||||
username=username, defaults=defaults
|
||||
)
|
||||
return user, created
|
||||
|
||||
def authenticate(self, request, username=None, password=None, **kwargs):
|
||||
try:
|
||||
userinfo: dict = custom_authenticate_method(
|
||||
username=username, password=password, **kwargs
|
||||
)
|
||||
user, created = self.get_or_create_user_from_userinfo(userinfo)
|
||||
except Exception as e:
|
||||
logger.error('Custom authenticate error: {}'.format(e))
|
||||
return None
|
||||
|
||||
if self.user_can_authenticate(user):
|
||||
logger.info(f'Custom authenticate success: {user.username}')
|
||||
user_auth_success.send(
|
||||
sender=self.__class__, request=request, user=user,
|
||||
backend=settings.AUTH_BACKEND_CUSTOM
|
||||
)
|
||||
return user
|
||||
else:
|
||||
logger.info(f'Custom authenticate failed: {user.username}')
|
||||
user_auth_failed.send(
|
||||
sender=self.__class__, request=request, username=user.username,
|
||||
reason=_('User invalid, disabled or expired'),
|
||||
backend=settings.AUTH_BACKEND_CUSTOM
|
||||
)
|
||||
return None
|
||||
4
apps/authentication/backends/oauth2/__init__.py
Normal file
4
apps/authentication/backends/oauth2/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
|
||||
from .backends import *
|
||||
161
apps/authentication/backends/oauth2/backends.py
Normal file
161
apps/authentication/backends/oauth2/backends.py
Normal file
@@ -0,0 +1,161 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
import requests
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils.http import urlencode
|
||||
from django.conf import settings
|
||||
from django.urls import reverse
|
||||
|
||||
from common.utils import get_logger
|
||||
from users.utils import construct_user_email
|
||||
from authentication.utils import build_absolute_uri
|
||||
from authentication.signals import user_auth_failed, user_auth_success
|
||||
from common.exceptions import JMSException
|
||||
|
||||
from .signals import (
|
||||
oauth2_create_or_update_user
|
||||
)
|
||||
from ..base import JMSModelBackend
|
||||
|
||||
|
||||
__all__ = ['OAuth2Backend']
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class OAuth2Backend(JMSModelBackend):
|
||||
@staticmethod
|
||||
def is_enabled():
|
||||
return settings.AUTH_OAUTH2
|
||||
|
||||
def get_or_create_user_from_userinfo(self, request, userinfo):
|
||||
log_prompt = "Get or Create user [OAuth2Backend]: {}"
|
||||
logger.debug(log_prompt.format('start'))
|
||||
|
||||
# Construct user attrs value
|
||||
user_attrs = {}
|
||||
for field, attr in settings.AUTH_OAUTH2_USER_ATTR_MAP.items():
|
||||
user_attrs[field] = userinfo.get(attr, '')
|
||||
|
||||
username = user_attrs.get('username')
|
||||
if not username:
|
||||
error_msg = 'username is missing'
|
||||
logger.error(log_prompt.format(error_msg))
|
||||
raise JMSException(error_msg)
|
||||
|
||||
email = user_attrs.get('email', '')
|
||||
email = construct_user_email(user_attrs.get('username'), email)
|
||||
user_attrs.update({'email': email})
|
||||
|
||||
logger.debug(log_prompt.format(user_attrs))
|
||||
user, created = get_user_model().objects.get_or_create(
|
||||
username=username, defaults=user_attrs
|
||||
)
|
||||
logger.debug(log_prompt.format("user: {}|created: {}".format(user, created)))
|
||||
logger.debug(log_prompt.format("Send signal => oauth2 create or update user"))
|
||||
oauth2_create_or_update_user.send(
|
||||
sender=self.__class__, request=request, user=user, created=created,
|
||||
attrs=user_attrs
|
||||
)
|
||||
return user, created
|
||||
|
||||
@staticmethod
|
||||
def get_response_data(response_data):
|
||||
if response_data.get('data') is not None:
|
||||
response_data = response_data['data']
|
||||
return response_data
|
||||
|
||||
@staticmethod
|
||||
def get_query_dict(response_data, query_dict):
|
||||
query_dict.update({
|
||||
'uid': response_data.get('uid', ''),
|
||||
'access_token': response_data.get('access_token', '')
|
||||
})
|
||||
return query_dict
|
||||
|
||||
def authenticate(self, request, code=None, **kwargs):
|
||||
log_prompt = "Process authenticate [OAuth2Backend]: {}"
|
||||
logger.debug(log_prompt.format('Start'))
|
||||
if code is None:
|
||||
logger.error(log_prompt.format('code is missing'))
|
||||
return None
|
||||
|
||||
query_dict = {
|
||||
'client_id': settings.AUTH_OAUTH2_CLIENT_ID,
|
||||
'client_secret': settings.AUTH_OAUTH2_CLIENT_SECRET,
|
||||
'grant_type': 'authorization_code',
|
||||
'code': code,
|
||||
'redirect_uri': build_absolute_uri(
|
||||
request, path=reverse(settings.AUTH_OAUTH2_AUTH_LOGIN_CALLBACK_URL_NAME)
|
||||
)
|
||||
}
|
||||
access_token_url = '{url}?{query}'.format(
|
||||
url=settings.AUTH_OAUTH2_ACCESS_TOKEN_ENDPOINT, query=urlencode(query_dict)
|
||||
)
|
||||
token_method = settings.AUTH_OAUTH2_ACCESS_TOKEN_METHOD.lower()
|
||||
requests_func = getattr(requests, token_method, requests.get)
|
||||
logger.debug(log_prompt.format('Call the access token endpoint[method: %s]' % token_method))
|
||||
headers = {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
access_token_response = requests_func(access_token_url, headers=headers)
|
||||
try:
|
||||
access_token_response.raise_for_status()
|
||||
access_token_response_data = access_token_response.json()
|
||||
response_data = self.get_response_data(access_token_response_data)
|
||||
except Exception as e:
|
||||
error = "Json access token response error, access token response " \
|
||||
"content is: {}, error is: {}".format(access_token_response.content, str(e))
|
||||
logger.error(log_prompt.format(error))
|
||||
return None
|
||||
|
||||
query_dict = self.get_query_dict(response_data, query_dict)
|
||||
|
||||
headers = {
|
||||
'Accept': 'application/json',
|
||||
'Authorization': 'token {}'.format(response_data.get('access_token', ''))
|
||||
}
|
||||
|
||||
logger.debug(log_prompt.format('Get userinfo endpoint'))
|
||||
userinfo_url = '{url}?{query}'.format(
|
||||
url=settings.AUTH_OAUTH2_PROVIDER_USERINFO_ENDPOINT,
|
||||
query=urlencode(query_dict)
|
||||
)
|
||||
userinfo_response = requests.get(userinfo_url, headers=headers)
|
||||
try:
|
||||
userinfo_response.raise_for_status()
|
||||
userinfo_response_data = userinfo_response.json()
|
||||
if 'data' in userinfo_response_data:
|
||||
userinfo = userinfo_response_data['data']
|
||||
else:
|
||||
userinfo = userinfo_response_data
|
||||
except Exception as e:
|
||||
error = "Json userinfo response error, userinfo response " \
|
||||
"content is: {}, error is: {}".format(userinfo_response.content, str(e))
|
||||
logger.error(log_prompt.format(error))
|
||||
return None
|
||||
|
||||
try:
|
||||
logger.debug(log_prompt.format('Update or create oauth2 user'))
|
||||
user, created = self.get_or_create_user_from_userinfo(request, userinfo)
|
||||
except JMSException:
|
||||
return None
|
||||
|
||||
if self.user_can_authenticate(user):
|
||||
logger.debug(log_prompt.format('OAuth2 user login success'))
|
||||
logger.debug(log_prompt.format('Send signal => oauth2 user login success'))
|
||||
user_auth_success.send(
|
||||
sender=self.__class__, request=request, user=user,
|
||||
backend=settings.AUTH_BACKEND_OAUTH2
|
||||
)
|
||||
return user
|
||||
else:
|
||||
logger.debug(log_prompt.format('OAuth2 user login failed'))
|
||||
logger.debug(log_prompt.format('Send signal => oauth2 user login failed'))
|
||||
user_auth_failed.send(
|
||||
sender=self.__class__, request=request, username=user.username,
|
||||
reason=_('User invalid, disabled or expired'),
|
||||
backend=settings.AUTH_BACKEND_OAUTH2
|
||||
)
|
||||
return None
|
||||
7
apps/authentication/backends/oauth2/signals.py
Normal file
7
apps/authentication/backends/oauth2/signals.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.dispatch import Signal
|
||||
|
||||
|
||||
oauth2_create_or_update_user = Signal(
|
||||
providing_args=['request', 'user', 'created', 'name', 'username', 'email']
|
||||
)
|
||||
|
||||
12
apps/authentication/backends/oauth2/urls.py
Normal file
12
apps/authentication/backends/oauth2/urls.py
Normal file
@@ -0,0 +1,12 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path('login/', views.OAuth2AuthRequestView.as_view(), name='login'),
|
||||
path('callback/', views.OAuth2AuthCallbackView.as_view(), name='login-callback'),
|
||||
path('logout/', views.OAuth2EndSessionView.as_view(), name='logout')
|
||||
]
|
||||
88
apps/authentication/backends/oauth2/views.py
Normal file
88
apps/authentication/backends/oauth2/views.py
Normal file
@@ -0,0 +1,88 @@
|
||||
from django.views import View
|
||||
from django.conf import settings
|
||||
from django.contrib import auth
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.urls import reverse
|
||||
from django.utils.http import urlencode
|
||||
|
||||
from authentication.utils import build_absolute_uri
|
||||
from common.utils import get_logger
|
||||
from authentication.mixins import authenticate
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
||||
|
||||
class OAuth2AuthRequestView(View):
|
||||
|
||||
def get(self, request):
|
||||
log_prompt = "Process OAuth2 GET requests: {}"
|
||||
logger.debug(log_prompt.format('Start'))
|
||||
|
||||
query_dict = {
|
||||
'client_id': settings.AUTH_OAUTH2_CLIENT_ID, 'response_type': 'code',
|
||||
'scope': settings.AUTH_OAUTH2_SCOPE,
|
||||
'redirect_uri': build_absolute_uri(
|
||||
request, path=reverse(settings.AUTH_OAUTH2_AUTH_LOGIN_CALLBACK_URL_NAME)
|
||||
)
|
||||
}
|
||||
|
||||
redirect_url = '{url}?{query}'.format(
|
||||
url=settings.AUTH_OAUTH2_PROVIDER_AUTHORIZATION_ENDPOINT,
|
||||
query=urlencode(query_dict)
|
||||
)
|
||||
logger.debug(log_prompt.format('Redirect login url'))
|
||||
return HttpResponseRedirect(redirect_url)
|
||||
|
||||
|
||||
class OAuth2AuthCallbackView(View):
|
||||
http_method_names = ['get', ]
|
||||
|
||||
def get(self, request):
|
||||
""" Processes GET requests. """
|
||||
log_prompt = "Process GET requests [OAuth2AuthCallbackView]: {}"
|
||||
logger.debug(log_prompt.format('Start'))
|
||||
callback_params = request.GET
|
||||
|
||||
if 'code' in callback_params:
|
||||
logger.debug(log_prompt.format('Process authenticate'))
|
||||
user = authenticate(code=callback_params['code'], request=request)
|
||||
if user and user.is_valid:
|
||||
logger.debug(log_prompt.format('Login: {}'.format(user)))
|
||||
auth.login(self.request, user)
|
||||
logger.debug(log_prompt.format('Redirect'))
|
||||
return HttpResponseRedirect(
|
||||
settings.AUTH_OAUTH2_AUTHENTICATION_REDIRECT_URI
|
||||
)
|
||||
|
||||
logger.debug(log_prompt.format('Redirect'))
|
||||
return HttpResponseRedirect(settings.AUTH_OAUTH2_AUTHENTICATION_FAILURE_REDIRECT_URI)
|
||||
|
||||
|
||||
class OAuth2EndSessionView(View):
|
||||
http_method_names = ['get', 'post', ]
|
||||
|
||||
def get(self, request):
|
||||
""" Processes GET requests. """
|
||||
log_prompt = "Process GET requests [OAuth2EndSessionView]: {}"
|
||||
logger.debug(log_prompt.format('Start'))
|
||||
return self.post(request)
|
||||
|
||||
def post(self, request):
|
||||
""" Processes POST requests. """
|
||||
log_prompt = "Process POST requests [OAuth2EndSessionView]: {}"
|
||||
logger.debug(log_prompt.format('Start'))
|
||||
|
||||
logout_url = settings.LOGOUT_REDIRECT_URL or '/'
|
||||
|
||||
# Log out the current user.
|
||||
if request.user.is_authenticated:
|
||||
logger.debug(log_prompt.format('Log out the current user: {}'.format(request.user)))
|
||||
auth.logout(request)
|
||||
|
||||
if settings.AUTH_OAUTH2_LOGOUT_COMPLETELY:
|
||||
logger.debug(log_prompt.format('Log out OAUTH2 platform user session synchronously'))
|
||||
next_url = settings.AUTH_OAUTH2_PROVIDER_END_SESSION_ENDPOINT
|
||||
return HttpResponseRedirect(next_url)
|
||||
|
||||
logger.debug(log_prompt.format('Redirect'))
|
||||
return HttpResponseRedirect(logout_url)
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
import base64
|
||||
import requests
|
||||
|
||||
from rest_framework.exceptions import ParseError
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.backends import ModelBackend
|
||||
@@ -18,14 +19,16 @@ from django.urls import reverse
|
||||
from django.conf import settings
|
||||
|
||||
from common.utils import get_logger
|
||||
from authentication.utils import build_absolute_uri_for_oidc
|
||||
from users.utils import construct_user_email
|
||||
|
||||
from ..base import JMSBaseAuthBackend
|
||||
from .utils import validate_and_return_id_token, build_absolute_uri
|
||||
from .utils import validate_and_return_id_token
|
||||
from .decorator import ssl_verification
|
||||
from .signals import (
|
||||
openid_create_or_update_user, openid_user_login_failed, openid_user_login_success
|
||||
openid_create_or_update_user
|
||||
)
|
||||
from authentication.signals import user_auth_success, user_auth_failed
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
||||
@@ -127,7 +130,7 @@ class OIDCAuthCodeBackend(OIDCBaseBackend):
|
||||
token_payload = {
|
||||
'grant_type': 'authorization_code',
|
||||
'code': code,
|
||||
'redirect_uri': build_absolute_uri(
|
||||
'redirect_uri': build_absolute_uri_for_oidc(
|
||||
request, path=reverse(settings.AUTH_OPENID_AUTH_LOGIN_CALLBACK_URL_NAME)
|
||||
)
|
||||
}
|
||||
@@ -211,14 +214,18 @@ class OIDCAuthCodeBackend(OIDCBaseBackend):
|
||||
if self.user_can_authenticate(user):
|
||||
logger.debug(log_prompt.format('OpenID user login success'))
|
||||
logger.debug(log_prompt.format('Send signal => openid user login success'))
|
||||
openid_user_login_success.send(sender=self.__class__, request=request, user=user)
|
||||
user_auth_success.send(
|
||||
sender=self.__class__, request=request, user=user,
|
||||
backend=settings.AUTH_BACKEND_OIDC_CODE
|
||||
)
|
||||
return user
|
||||
else:
|
||||
logger.debug(log_prompt.format('OpenID user login failed'))
|
||||
logger.debug(log_prompt.format('Send signal => openid user login failed'))
|
||||
openid_user_login_failed.send(
|
||||
user_auth_failed.send(
|
||||
sender=self.__class__, request=request, username=user.username,
|
||||
reason="User is invalid"
|
||||
reason="User is invalid", backend=settings.AUTH_BACKEND_OIDC_CODE
|
||||
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -269,8 +276,9 @@ class OIDCAuthPasswordBackend(OIDCBaseBackend):
|
||||
"content is: {}, error is: {}".format(token_response.content, str(e))
|
||||
logger.debug(log_prompt.format(error))
|
||||
logger.debug(log_prompt.format('Send signal => openid user login failed'))
|
||||
openid_user_login_failed.send(
|
||||
sender=self.__class__, request=request, username=username, reason=error
|
||||
user_auth_failed.send(
|
||||
sender=self.__class__, request=request, username=username, reason=error,
|
||||
backend=settings.AUTH_BACKEND_OIDC_PASSWORD
|
||||
)
|
||||
return
|
||||
|
||||
@@ -297,8 +305,9 @@ class OIDCAuthPasswordBackend(OIDCBaseBackend):
|
||||
"content is: {}, error is: {}".format(claims_response.content, str(e))
|
||||
logger.debug(log_prompt.format(error))
|
||||
logger.debug(log_prompt.format('Send signal => openid user login failed'))
|
||||
openid_user_login_failed.send(
|
||||
sender=self.__class__, request=request, username=username, reason=error
|
||||
user_auth_failed.send(
|
||||
sender=self.__class__, request=request, username=username, reason=error,
|
||||
backend=settings.AUTH_BACKEND_OIDC_PASSWORD
|
||||
)
|
||||
return
|
||||
|
||||
@@ -310,13 +319,16 @@ class OIDCAuthPasswordBackend(OIDCBaseBackend):
|
||||
if self.user_can_authenticate(user):
|
||||
logger.debug(log_prompt.format('OpenID user login success'))
|
||||
logger.debug(log_prompt.format('Send signal => openid user login success'))
|
||||
openid_user_login_success.send(
|
||||
sender=self.__class__, request=request, user=user
|
||||
user_auth_success.send(
|
||||
sender=self.__class__, request=request, user=user,
|
||||
backend=settings.AUTH_BACKEND_OIDC_PASSWORD
|
||||
)
|
||||
return user
|
||||
else:
|
||||
logger.debug(log_prompt.format('OpenID user login failed'))
|
||||
logger.debug(log_prompt.format('Send signal => openid user login failed'))
|
||||
openid_user_login_failed.send(
|
||||
sender=self.__class__, request=request, username=username, reason="User is invalid"
|
||||
user_auth_failed.send(
|
||||
sender=self.__class__, request=request, username=username, reason="User is invalid",
|
||||
backend=settings.AUTH_BACKEND_OIDC_PASSWORD
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -13,6 +13,4 @@ from django.dispatch import Signal
|
||||
openid_create_or_update_user = Signal(
|
||||
providing_args=['request', 'user', 'created', 'name', 'username', 'email']
|
||||
)
|
||||
openid_user_login_success = Signal(providing_args=['request', 'user'])
|
||||
openid_user_login_failed = Signal(providing_args=['request', 'username', 'reason'])
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
import datetime as dt
|
||||
from calendar import timegm
|
||||
from urllib.parse import urlparse, urljoin
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.utils.encoding import force_bytes, smart_bytes
|
||||
@@ -110,17 +110,3 @@ def _validate_claims(id_token, nonce=None, validate_nonce=True):
|
||||
raise SuspiciousOperation('Incorrect id_token: nonce')
|
||||
|
||||
logger.debug(log_prompt.format('End'))
|
||||
|
||||
|
||||
def build_absolute_uri(request, path=None):
|
||||
"""
|
||||
Build absolute redirect uri
|
||||
"""
|
||||
if path is None:
|
||||
path = '/'
|
||||
|
||||
if settings.BASE_SITE_URL:
|
||||
redirect_uri = urljoin(settings.BASE_SITE_URL, path)
|
||||
else:
|
||||
redirect_uri = request.build_absolute_uri(path)
|
||||
return redirect_uri
|
||||
|
||||
@@ -20,7 +20,8 @@ from django.utils.crypto import get_random_string
|
||||
from django.utils.http import is_safe_url, urlencode
|
||||
from django.views.generic import View
|
||||
|
||||
from .utils import get_logger, build_absolute_uri
|
||||
from authentication.utils import build_absolute_uri_for_oidc
|
||||
from .utils import get_logger
|
||||
|
||||
|
||||
logger = get_logger(__file__)
|
||||
@@ -50,7 +51,7 @@ class OIDCAuthRequestView(View):
|
||||
'scope': settings.AUTH_OPENID_SCOPES,
|
||||
'response_type': 'code',
|
||||
'client_id': settings.AUTH_OPENID_CLIENT_ID,
|
||||
'redirect_uri': build_absolute_uri(
|
||||
'redirect_uri': build_absolute_uri_for_oidc(
|
||||
request, path=reverse(settings.AUTH_OPENID_AUTH_LOGIN_CALLBACK_URL_NAME)
|
||||
)
|
||||
})
|
||||
@@ -216,7 +217,7 @@ class OIDCEndSessionView(View):
|
||||
""" Returns the end-session URL. """
|
||||
q = QueryDict(mutable=True)
|
||||
q[settings.AUTH_OPENID_PROVIDER_END_SESSION_REDIRECT_URI_PARAMETER] = \
|
||||
build_absolute_uri(self.request, path=settings.LOGOUT_REDIRECT_URL or '/')
|
||||
build_absolute_uri_for_oidc(self.request, path=settings.LOGOUT_REDIRECT_URL or '/')
|
||||
q[settings.AUTH_OPENID_PROVIDER_END_SESSION_ID_TOKEN_PARAMETER] = \
|
||||
self.request.session['oidc_auth_id_token']
|
||||
return '{}?{}'.format(settings.AUTH_OPENID_PROVIDER_END_SESSION_ENDPOINT, q.urlencode())
|
||||
|
||||
@@ -7,9 +7,9 @@ from django.db import transaction
|
||||
from common.utils import get_logger
|
||||
from authentication.errors import reason_choices, reason_user_invalid
|
||||
from .signals import (
|
||||
saml2_user_authenticated, saml2_user_authentication_failed,
|
||||
saml2_create_or_update_user
|
||||
)
|
||||
from authentication.signals import user_auth_failed, user_auth_success
|
||||
from ..base import JMSModelBackend
|
||||
|
||||
__all__ = ['SAML2Backend']
|
||||
@@ -39,7 +39,7 @@ class SAML2Backend(JMSModelBackend):
|
||||
return user, created
|
||||
|
||||
def authenticate(self, request, saml_user_data=None, **kwargs):
|
||||
log_prompt = "Process authenticate [SAML2AuthCodeBackend]: {}"
|
||||
log_prompt = "Process authenticate [SAML2Backend]: {}"
|
||||
logger.debug(log_prompt.format('Start'))
|
||||
if saml_user_data is None:
|
||||
logger.error(log_prompt.format('saml_user_data is missing'))
|
||||
@@ -48,21 +48,23 @@ class SAML2Backend(JMSModelBackend):
|
||||
logger.debug(log_prompt.format('saml data, {}'.format(saml_user_data)))
|
||||
username = saml_user_data.get('username')
|
||||
if not username:
|
||||
logger.debug(log_prompt.format('username is missing'))
|
||||
logger.warning(log_prompt.format('username is missing'))
|
||||
return None
|
||||
|
||||
user, created = self.get_or_create_from_saml_data(request, **saml_user_data)
|
||||
|
||||
if self.user_can_authenticate(user):
|
||||
logger.debug(log_prompt.format('SAML2 user login success'))
|
||||
saml2_user_authenticated.send(
|
||||
sender=self, request=request, user=user, created=created
|
||||
user_auth_success.send(
|
||||
sender=self.__class__, request=request, user=user, created=created,
|
||||
backend=settings.AUTH_BACKEND_SAML2
|
||||
)
|
||||
return user
|
||||
else:
|
||||
logger.debug(log_prompt.format('SAML2 user login failed'))
|
||||
saml2_user_authentication_failed.send(
|
||||
sender=self, request=request, username=username,
|
||||
reason=reason_choices.get(reason_user_invalid)
|
||||
user_auth_failed.send(
|
||||
sender=self.__class__, request=request, username=username,
|
||||
reason=reason_choices.get(reason_user_invalid),
|
||||
backend=settings.AUTH_BACKEND_SAML2
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -2,5 +2,3 @@ from django.dispatch import Signal
|
||||
|
||||
|
||||
saml2_create_or_update_user = Signal(providing_args=('user', 'created', 'request', 'attrs'))
|
||||
saml2_user_authenticated = Signal(providing_args=('user', 'created', 'request'))
|
||||
saml2_user_authentication_failed = Signal(providing_args=('request', 'username', 'reason'))
|
||||
|
||||
@@ -3,7 +3,7 @@ import copy
|
||||
from urllib import parse
|
||||
|
||||
from django.views import View
|
||||
from django.contrib import auth as auth
|
||||
from django.contrib import auth
|
||||
from django.urls import reverse
|
||||
from django.conf import settings
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
|
||||
5
apps/authentication/confirm/__init__.py
Normal file
5
apps/authentication/confirm/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from .mfa import ConfirmMFA
|
||||
from .password import ConfirmPassword
|
||||
from .relogin import ConfirmReLogin
|
||||
|
||||
CONFIRM_BACKENDS = [ConfirmReLogin, ConfirmPassword, ConfirmMFA]
|
||||
30
apps/authentication/confirm/base.py
Normal file
30
apps/authentication/confirm/base.py
Normal file
@@ -0,0 +1,30 @@
|
||||
import abc
|
||||
|
||||
|
||||
class BaseConfirm(abc.ABC):
|
||||
|
||||
def __init__(self, user, request):
|
||||
self.user = user
|
||||
self.request = request
|
||||
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
def name(self) -> str:
|
||||
return ''
|
||||
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
def display_name(self) -> str:
|
||||
return ''
|
||||
|
||||
@abc.abstractmethod
|
||||
def check(self) -> bool:
|
||||
return False
|
||||
|
||||
@property
|
||||
def content(self):
|
||||
return ''
|
||||
|
||||
@abc.abstractmethod
|
||||
def authenticate(self, secret_key, mfa_type) -> tuple:
|
||||
return False, 'Error msg'
|
||||
26
apps/authentication/confirm/mfa.py
Normal file
26
apps/authentication/confirm/mfa.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from users.models import User
|
||||
|
||||
from .base import BaseConfirm
|
||||
|
||||
|
||||
class ConfirmMFA(BaseConfirm):
|
||||
name = 'mfa'
|
||||
display_name = 'MFA'
|
||||
|
||||
def check(self):
|
||||
return self.user.active_mfa_backends and self.user.mfa_enabled
|
||||
|
||||
@property
|
||||
def content(self):
|
||||
backends = User.get_user_mfa_backends(self.user)
|
||||
return [{
|
||||
'name': backend.name,
|
||||
'disabled': not bool(backend.is_active()),
|
||||
'display_name': backend.display_name,
|
||||
'placeholder': backend.placeholder,
|
||||
} for backend in backends]
|
||||
|
||||
def authenticate(self, secret_key, mfa_type):
|
||||
mfa_backend = self.user.get_mfa_backend_by_type(mfa_type)
|
||||
ok, msg = mfa_backend.check_code(secret_key)
|
||||
return ok, msg
|
||||
17
apps/authentication/confirm/password.py
Normal file
17
apps/authentication/confirm/password.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from authentication.mixins import authenticate
|
||||
from .base import BaseConfirm
|
||||
|
||||
|
||||
class ConfirmPassword(BaseConfirm):
|
||||
name = 'password'
|
||||
display_name = _('Password')
|
||||
|
||||
def check(self):
|
||||
return self.user.is_password_authenticate()
|
||||
|
||||
def authenticate(self, secret_key, mfa_type):
|
||||
ok = authenticate(self.request, username=self.user.username, password=secret_key)
|
||||
msg = '' if ok else _('Authentication failed password incorrect')
|
||||
return ok, msg
|
||||
30
apps/authentication/confirm/relogin.py
Normal file
30
apps/authentication/confirm/relogin.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from datetime import datetime
|
||||
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from .base import BaseConfirm
|
||||
|
||||
SPECIFIED_TIME = 5
|
||||
|
||||
RELOGIN_ERROR = _('Login time has exceeded {} minutes, please login again').format(SPECIFIED_TIME)
|
||||
|
||||
|
||||
class ConfirmReLogin(BaseConfirm):
|
||||
name = 'relogin'
|
||||
display_name = 'Re-Login'
|
||||
|
||||
def check(self):
|
||||
return not self.user.is_password_authenticate()
|
||||
|
||||
def authenticate(self, secret_key, mfa_type):
|
||||
now = timezone.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
now = datetime.strptime(now, '%Y-%m-%d %H:%M:%S')
|
||||
login_time = self.request.session.get('login_time')
|
||||
msg = RELOGIN_ERROR
|
||||
if not login_time:
|
||||
return False, msg
|
||||
login_time = datetime.strptime(login_time, '%Y-%m-%d %H:%M:%S')
|
||||
if (now - login_time).seconds >= SPECIFIED_TIME * 60:
|
||||
return False, msg
|
||||
return True, ''
|
||||
@@ -1,10 +1,37 @@
|
||||
from django.db.models import TextChoices
|
||||
|
||||
from authentication.confirm import CONFIRM_BACKENDS
|
||||
from .confirm import ConfirmMFA, ConfirmPassword, ConfirmReLogin
|
||||
from .mfa import MFAOtp, MFASms, MFARadius
|
||||
|
||||
RSA_PRIVATE_KEY = 'rsa_private_key'
|
||||
RSA_PUBLIC_KEY = 'rsa_public_key'
|
||||
|
||||
CONFIRM_BACKEND_MAP = {backend.name: backend for backend in CONFIRM_BACKENDS}
|
||||
|
||||
|
||||
class ConfirmType(TextChoices):
|
||||
RELOGIN = 'relogin', 'Re-Login'
|
||||
PASSWORD = 'password', 'Password'
|
||||
MFA = 'mfa', 'MFA'
|
||||
ReLogin = ConfirmReLogin.name, ConfirmReLogin.display_name
|
||||
PASSWORD = ConfirmPassword.name, ConfirmPassword.display_name
|
||||
MFA = ConfirmMFA.name, ConfirmMFA.display_name
|
||||
|
||||
@classmethod
|
||||
def get_can_confirm_types(cls, confirm_type):
|
||||
start = cls.values.index(confirm_type)
|
||||
types = cls.values[start:]
|
||||
types.reverse()
|
||||
return types
|
||||
|
||||
@classmethod
|
||||
def get_can_confirm_backend_classes(cls, confirm_type):
|
||||
types = cls.get_can_confirm_types(confirm_type)
|
||||
backend_classes = [
|
||||
CONFIRM_BACKEND_MAP[tp] for tp in types if tp in CONFIRM_BACKEND_MAP
|
||||
]
|
||||
return backend_classes
|
||||
|
||||
|
||||
class MFAType(TextChoices):
|
||||
OTP = MFAOtp.name, MFAOtp.display_name
|
||||
SMS = MFASms.name, MFASms.display_name
|
||||
Radius = MFARadius.name, MFARadius.display_name
|
||||
|
||||
@@ -1,367 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.urls import reverse
|
||||
from django.conf import settings
|
||||
from rest_framework import status
|
||||
|
||||
from common.exceptions import JMSException
|
||||
from .signals import post_auth_failed
|
||||
from users.utils import LoginBlockUtil, MFABlockUtils, LoginIpBlockUtil
|
||||
|
||||
reason_password_failed = 'password_failed'
|
||||
reason_password_decrypt_failed = 'password_decrypt_failed'
|
||||
reason_mfa_failed = 'mfa_failed'
|
||||
reason_mfa_unset = 'mfa_unset'
|
||||
reason_user_not_exist = 'user_not_exist'
|
||||
reason_password_expired = 'password_expired'
|
||||
reason_user_invalid = 'user_invalid'
|
||||
reason_user_inactive = 'user_inactive'
|
||||
reason_user_expired = 'user_expired'
|
||||
reason_backend_not_match = 'backend_not_match'
|
||||
reason_acl_not_allow = 'acl_not_allow'
|
||||
only_local_users_are_allowed = 'only_local_users_are_allowed'
|
||||
|
||||
reason_choices = {
|
||||
reason_password_failed: _('Username/password check failed'),
|
||||
reason_password_decrypt_failed: _('Password decrypt failed'),
|
||||
reason_mfa_failed: _('MFA failed'),
|
||||
reason_mfa_unset: _('MFA unset'),
|
||||
reason_user_not_exist: _("Username does not exist"),
|
||||
reason_password_expired: _("Password expired"),
|
||||
reason_user_invalid: _('Disabled or expired'),
|
||||
reason_user_inactive: _("This account is inactive."),
|
||||
reason_user_expired: _("This account is expired"),
|
||||
reason_backend_not_match: _("Auth backend not match"),
|
||||
reason_acl_not_allow: _("ACL is not allowed"),
|
||||
only_local_users_are_allowed: _("Only local users are allowed")
|
||||
}
|
||||
old_reason_choices = {
|
||||
'0': '-',
|
||||
'1': reason_choices[reason_password_failed],
|
||||
'2': reason_choices[reason_mfa_failed],
|
||||
'3': reason_choices[reason_user_not_exist],
|
||||
'4': reason_choices[reason_password_expired],
|
||||
}
|
||||
|
||||
session_empty_msg = _("No session found, check your cookie")
|
||||
invalid_login_msg = _(
|
||||
"The username or password you entered is incorrect, "
|
||||
"please enter it again. "
|
||||
"You can also try {times_try} times "
|
||||
"(The account will be temporarily locked for {block_time} minutes)"
|
||||
)
|
||||
block_user_login_msg = _(
|
||||
"The account has been locked "
|
||||
"(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 = _(
|
||||
"The account has been locked "
|
||||
"(please contact admin to unlock it or try again after {} minutes)"
|
||||
)
|
||||
mfa_error_msg = _(
|
||||
"{error}, "
|
||||
"You can also try {times_try} times "
|
||||
"(The account will be temporarily locked for {block_time} minutes)"
|
||||
)
|
||||
mfa_required_msg = _("MFA required")
|
||||
mfa_unset_msg = _("MFA not set, please set it first")
|
||||
login_confirm_required_msg = _("Login confirm required")
|
||||
login_confirm_wait_msg = _("Wait login confirm ticket for accept")
|
||||
login_confirm_error_msg = _("Login confirm ticket was {}")
|
||||
|
||||
|
||||
class AuthFailedNeedLogMixin:
|
||||
username = ''
|
||||
request = None
|
||||
error = ''
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
post_auth_failed.send(
|
||||
sender=self.__class__, username=self.username,
|
||||
request=self.request, reason=self.error
|
||||
)
|
||||
|
||||
|
||||
class AuthFailedNeedBlockMixin:
|
||||
username = ''
|
||||
ip = ''
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
LoginBlockUtil(self.username, self.ip).incr_failed_count()
|
||||
|
||||
|
||||
class AuthFailedError(Exception):
|
||||
username = ''
|
||||
msg = ''
|
||||
error = ''
|
||||
request = None
|
||||
ip = ''
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
for k, v in kwargs.items():
|
||||
setattr(self, k, v)
|
||||
|
||||
def as_data(self):
|
||||
return {
|
||||
'error': self.error,
|
||||
'msg': self.msg,
|
||||
}
|
||||
|
||||
def __str__(self):
|
||||
return str(self.msg)
|
||||
|
||||
|
||||
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):
|
||||
super().__init__(error=error, username=username, ip=ip, request=request)
|
||||
util = LoginBlockUtil(username, ip)
|
||||
times_remainder = util.get_remainder_times()
|
||||
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(
|
||||
times_try=times_remainder, block_time=block_time
|
||||
)
|
||||
if error == reason_password_failed:
|
||||
self.msg = default_msg
|
||||
else:
|
||||
self.msg = reason_choices.get(error, default_msg)
|
||||
|
||||
|
||||
class MFAFailedError(AuthFailedNeedLogMixin, AuthFailedError):
|
||||
error = reason_mfa_failed
|
||||
msg: str
|
||||
|
||||
def __init__(self, username, request, ip, mfa_type, error):
|
||||
super().__init__(username=username, request=request)
|
||||
|
||||
util = MFABlockUtils(username, ip)
|
||||
times_remainder = util.incr_failed_count()
|
||||
block_time = settings.SECURITY_LOGIN_LIMIT_TIME
|
||||
|
||||
if times_remainder:
|
||||
self.msg = mfa_error_msg.format(
|
||||
error=error, times_try=times_remainder, block_time=block_time
|
||||
)
|
||||
else:
|
||||
self.msg = block_mfa_msg.format(settings.SECURITY_LOGIN_LIMIT_TIME)
|
||||
|
||||
|
||||
class BlockMFAError(AuthFailedNeedLogMixin, AuthFailedError):
|
||||
error = 'block_mfa'
|
||||
|
||||
def __init__(self, username, request, ip):
|
||||
self.msg = block_mfa_msg.format(settings.SECURITY_LOGIN_LIMIT_TIME)
|
||||
super().__init__(username=username, request=request, ip=ip)
|
||||
|
||||
|
||||
class MFAUnsetError(Exception):
|
||||
error = reason_mfa_unset
|
||||
msg = mfa_unset_msg
|
||||
|
||||
def __init__(self, user, request, url):
|
||||
self.url = url
|
||||
|
||||
|
||||
class BlockLoginError(AuthFailedNeedBlockMixin, AuthFailedError):
|
||||
error = 'block_login'
|
||||
|
||||
def __init__(self, username, ip):
|
||||
self.msg = block_user_login_msg.format(settings.SECURITY_LOGIN_LIMIT_TIME)
|
||||
super().__init__(username=username, ip=ip)
|
||||
|
||||
|
||||
class SessionEmptyError(AuthFailedError):
|
||||
msg = session_empty_msg
|
||||
error = 'session_empty'
|
||||
|
||||
|
||||
class NeedMoreInfoError(Exception):
|
||||
error = ''
|
||||
msg = ''
|
||||
|
||||
def __init__(self, error='', msg=''):
|
||||
if error:
|
||||
self.error = error
|
||||
if msg:
|
||||
self.msg = msg
|
||||
|
||||
def as_data(self):
|
||||
return {
|
||||
'error': self.error,
|
||||
'msg': self.msg,
|
||||
}
|
||||
|
||||
|
||||
class MFARequiredError(NeedMoreInfoError):
|
||||
msg = mfa_required_msg
|
||||
error = 'mfa_required'
|
||||
|
||||
def __init__(self, error='', msg='', mfa_types=()):
|
||||
super().__init__(error=error, msg=msg)
|
||||
self.choices = mfa_types
|
||||
|
||||
def as_data(self):
|
||||
return {
|
||||
'error': self.error,
|
||||
'msg': self.msg,
|
||||
'data': {
|
||||
'choices': self.choices,
|
||||
'url': reverse('api-auth:mfa-challenge')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class ACLError(AuthFailedNeedLogMixin, AuthFailedError):
|
||||
msg = reason_acl_not_allow
|
||||
error = 'acl_error'
|
||||
|
||||
def __init__(self, msg, **kwargs):
|
||||
self.msg = msg
|
||||
super().__init__(**kwargs)
|
||||
|
||||
def as_data(self):
|
||||
return {
|
||||
"error": reason_acl_not_allow,
|
||||
"msg": self.msg
|
||||
}
|
||||
|
||||
|
||||
class LoginIPNotAllowed(ACLError):
|
||||
def __init__(self, username, request, **kwargs):
|
||||
self.username = username
|
||||
self.request = request
|
||||
super().__init__(_("IP is not allowed"), **kwargs)
|
||||
|
||||
|
||||
class TimePeriodNotAllowed(ACLError):
|
||||
def __init__(self, username, request, **kwargs):
|
||||
self.username = username
|
||||
self.request = request
|
||||
super().__init__(_("Time Period is not allowed"), **kwargs)
|
||||
|
||||
|
||||
class LoginConfirmBaseError(NeedMoreInfoError):
|
||||
def __init__(self, ticket_id, **kwargs):
|
||||
self.ticket_id = ticket_id
|
||||
super().__init__(**kwargs)
|
||||
|
||||
def as_data(self):
|
||||
return {
|
||||
"error": self.error,
|
||||
"msg": self.msg,
|
||||
"data": {
|
||||
"ticket_id": self.ticket_id
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class LoginConfirmWaitError(LoginConfirmBaseError):
|
||||
msg = login_confirm_wait_msg
|
||||
error = 'login_confirm_wait'
|
||||
|
||||
|
||||
class LoginConfirmOtherError(LoginConfirmBaseError):
|
||||
error = 'login_confirm_error'
|
||||
|
||||
def __init__(self, ticket_id, status):
|
||||
msg = login_confirm_error_msg.format(status)
|
||||
super().__init__(ticket_id=ticket_id, msg=msg)
|
||||
|
||||
|
||||
class SSOAuthClosed(JMSException):
|
||||
default_code = 'sso_auth_closed'
|
||||
default_detail = _('SSO auth closed')
|
||||
|
||||
|
||||
class PasswordTooSimple(JMSException):
|
||||
default_code = 'passwd_too_simple'
|
||||
default_detail = _('Your password is too simple, please change it for security')
|
||||
|
||||
def __init__(self, url, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.url = url
|
||||
|
||||
|
||||
class PasswordNeedUpdate(JMSException):
|
||||
default_code = 'passwd_need_update'
|
||||
default_detail = _('You should to change your password before login')
|
||||
|
||||
def __init__(self, url, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.url = url
|
||||
|
||||
|
||||
class PasswordRequireResetError(JMSException):
|
||||
default_code = 'passwd_has_expired'
|
||||
default_detail = _('Your password has expired, please reset before logging in')
|
||||
|
||||
def __init__(self, url, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.url = url
|
||||
|
||||
|
||||
class WeComCodeInvalid(JMSException):
|
||||
default_code = 'wecom_code_invalid'
|
||||
default_detail = 'Code invalid, can not get user info'
|
||||
|
||||
|
||||
class WeComBindAlready(JMSException):
|
||||
default_code = 'wecom_bind_already'
|
||||
default_detail = 'WeCom already binded'
|
||||
|
||||
|
||||
class WeComNotBound(JMSException):
|
||||
default_code = 'wecom_not_bound'
|
||||
default_detail = 'WeCom is not bound'
|
||||
|
||||
|
||||
class DingTalkNotBound(JMSException):
|
||||
default_code = 'dingtalk_not_bound'
|
||||
default_detail = 'DingTalk is not bound'
|
||||
|
||||
|
||||
class FeiShuNotBound(JMSException):
|
||||
default_code = 'feishu_not_bound'
|
||||
default_detail = 'FeiShu is not bound'
|
||||
|
||||
|
||||
class PasswordInvalid(JMSException):
|
||||
default_code = 'passwd_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')
|
||||
4
apps/authentication/errors/__init__.py
Normal file
4
apps/authentication/errors/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from .const import *
|
||||
from .mfa import *
|
||||
from .failed import *
|
||||
from .redirect import *
|
||||
67
apps/authentication/errors/const.py
Normal file
67
apps/authentication/errors/const.py
Normal file
@@ -0,0 +1,67 @@
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
reason_password_failed = 'password_failed'
|
||||
reason_password_decrypt_failed = 'password_decrypt_failed'
|
||||
reason_mfa_failed = 'mfa_failed'
|
||||
reason_mfa_unset = 'mfa_unset'
|
||||
reason_user_not_exist = 'user_not_exist'
|
||||
reason_password_expired = 'password_expired'
|
||||
reason_user_invalid = 'user_invalid'
|
||||
reason_user_inactive = 'user_inactive'
|
||||
reason_user_expired = 'user_expired'
|
||||
reason_backend_not_match = 'backend_not_match'
|
||||
reason_acl_not_allow = 'acl_not_allow'
|
||||
only_local_users_are_allowed = 'only_local_users_are_allowed'
|
||||
|
||||
reason_choices = {
|
||||
reason_password_failed: _('Username/password check failed'),
|
||||
reason_password_decrypt_failed: _('Password decrypt failed'),
|
||||
reason_mfa_failed: _('MFA failed'),
|
||||
reason_mfa_unset: _('MFA unset'),
|
||||
reason_user_not_exist: _("Username does not exist"),
|
||||
reason_password_expired: _("Password expired"),
|
||||
reason_user_invalid: _('Disabled or expired'),
|
||||
reason_user_inactive: _("This account is inactive."),
|
||||
reason_user_expired: _("This account is expired"),
|
||||
reason_backend_not_match: _("Auth backend not match"),
|
||||
reason_acl_not_allow: _("ACL is not allowed"),
|
||||
only_local_users_are_allowed: _("Only local users are allowed")
|
||||
}
|
||||
old_reason_choices = {
|
||||
'0': '-',
|
||||
'1': reason_choices[reason_password_failed],
|
||||
'2': reason_choices[reason_mfa_failed],
|
||||
'3': reason_choices[reason_user_not_exist],
|
||||
'4': reason_choices[reason_password_expired],
|
||||
}
|
||||
|
||||
session_empty_msg = _("No session found, check your cookie")
|
||||
invalid_login_msg = _(
|
||||
"The username or password you entered is incorrect, "
|
||||
"please enter it again. "
|
||||
"You can also try {times_try} times "
|
||||
"(The account will be temporarily locked for {block_time} minutes)"
|
||||
)
|
||||
block_user_login_msg = _(
|
||||
"The account has been locked "
|
||||
"(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 = _(
|
||||
"The account has been locked "
|
||||
"(please contact admin to unlock it or try again after {} minutes)"
|
||||
)
|
||||
mfa_error_msg = _(
|
||||
"{error}, "
|
||||
"You can also try {times_try} times "
|
||||
"(The account will be temporarily locked for {block_time} minutes)"
|
||||
)
|
||||
mfa_required_msg = _("MFA required")
|
||||
mfa_unset_msg = _("MFA not set, please set it first")
|
||||
login_confirm_required_msg = _("Login confirm required")
|
||||
login_confirm_wait_msg = _("Wait login confirm ticket for accept")
|
||||
login_confirm_error_msg = _("Login confirm ticket was {}")
|
||||
161
apps/authentication/errors/failed.py
Normal file
161
apps/authentication/errors/failed.py
Normal file
@@ -0,0 +1,161 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.conf import settings
|
||||
|
||||
from users.utils import LoginBlockUtil, MFABlockUtils, LoginIpBlockUtil
|
||||
from ..signals import post_auth_failed
|
||||
from . import const
|
||||
|
||||
|
||||
class AuthFailedNeedLogMixin:
|
||||
username = ''
|
||||
request = None
|
||||
error = ''
|
||||
msg = ''
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
post_auth_failed.send(
|
||||
sender=self.__class__, username=self.username,
|
||||
request=self.request, reason=self.msg
|
||||
)
|
||||
|
||||
|
||||
class AuthFailedNeedBlockMixin:
|
||||
username = ''
|
||||
ip = ''
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
LoginBlockUtil(self.username, self.ip).incr_failed_count()
|
||||
|
||||
|
||||
class AuthFailedError(Exception):
|
||||
username = ''
|
||||
msg = ''
|
||||
error = ''
|
||||
request = None
|
||||
ip = ''
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
for k, v in kwargs.items():
|
||||
setattr(self, k, v)
|
||||
|
||||
def as_data(self):
|
||||
return {
|
||||
'error': self.error,
|
||||
'msg': self.msg,
|
||||
}
|
||||
|
||||
def __str__(self):
|
||||
return str(self.msg)
|
||||
|
||||
|
||||
class BlockGlobalIpLoginError(AuthFailedError):
|
||||
error = 'block_global_ip_login'
|
||||
|
||||
def __init__(self, username, ip, **kwargs):
|
||||
if not self.msg:
|
||||
self.msg = const.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):
|
||||
util = LoginBlockUtil(username, ip)
|
||||
times_remainder = util.get_remainder_times()
|
||||
block_time = settings.SECURITY_LOGIN_LIMIT_TIME
|
||||
if times_remainder < 1:
|
||||
self.msg = const.block_user_login_msg.format(settings.SECURITY_LOGIN_LIMIT_TIME)
|
||||
else:
|
||||
default_msg = const.invalid_login_msg.format(
|
||||
times_try=times_remainder, block_time=block_time
|
||||
)
|
||||
if error == const.reason_password_failed:
|
||||
self.msg = default_msg
|
||||
else:
|
||||
self.msg = const.reason_choices.get(error, default_msg)
|
||||
# 先处理 msg 在 super,记录日志时原因才准确
|
||||
super().__init__(error=error, username=username, ip=ip, request=request)
|
||||
|
||||
|
||||
class MFAFailedError(AuthFailedNeedLogMixin, AuthFailedError):
|
||||
error = const.reason_mfa_failed
|
||||
msg: str
|
||||
|
||||
def __init__(self, username, request, ip, mfa_type, error):
|
||||
super().__init__(username=username, request=request)
|
||||
|
||||
util = MFABlockUtils(username, ip)
|
||||
times_remainder = util.incr_failed_count()
|
||||
block_time = settings.SECURITY_LOGIN_LIMIT_TIME
|
||||
|
||||
if times_remainder:
|
||||
self.msg = const.mfa_error_msg.format(
|
||||
error=error, times_try=times_remainder, block_time=block_time
|
||||
)
|
||||
else:
|
||||
self.msg = const.block_mfa_msg.format(settings.SECURITY_LOGIN_LIMIT_TIME)
|
||||
|
||||
|
||||
class BlockMFAError(AuthFailedNeedLogMixin, AuthFailedError):
|
||||
error = 'block_mfa'
|
||||
|
||||
def __init__(self, username, request, ip):
|
||||
self.msg = const.block_mfa_msg.format(settings.SECURITY_LOGIN_LIMIT_TIME)
|
||||
super().__init__(username=username, request=request, ip=ip)
|
||||
|
||||
|
||||
class BlockLoginError(AuthFailedNeedBlockMixin, AuthFailedError):
|
||||
error = 'block_login'
|
||||
|
||||
def __init__(self, username, ip):
|
||||
self.msg = const.block_user_login_msg.format(settings.SECURITY_LOGIN_LIMIT_TIME)
|
||||
super().__init__(username=username, ip=ip)
|
||||
|
||||
|
||||
class SessionEmptyError(AuthFailedError):
|
||||
msg = const.session_empty_msg
|
||||
error = 'session_empty'
|
||||
|
||||
|
||||
class ACLError(AuthFailedNeedLogMixin, AuthFailedError):
|
||||
msg = const.reason_acl_not_allow
|
||||
error = 'acl_error'
|
||||
|
||||
def __init__(self, msg, **kwargs):
|
||||
self.msg = msg
|
||||
super().__init__(**kwargs)
|
||||
|
||||
def as_data(self):
|
||||
return {
|
||||
"error": const.reason_acl_not_allow,
|
||||
"msg": self.msg
|
||||
}
|
||||
|
||||
|
||||
class LoginACLIPAndTimePeriodNotAllowed(ACLError):
|
||||
def __init__(self, username, request, **kwargs):
|
||||
self.username = username
|
||||
self.request = request
|
||||
super().__init__(_("Current IP and Time period is not allowed"), **kwargs)
|
||||
|
||||
|
||||
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')
|
||||
38
apps/authentication/errors/mfa.py
Normal file
38
apps/authentication/errors/mfa.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from common.exceptions import JMSException
|
||||
|
||||
|
||||
class SSOAuthClosed(JMSException):
|
||||
default_code = 'sso_auth_closed'
|
||||
default_detail = _('SSO auth closed')
|
||||
|
||||
|
||||
class WeComCodeInvalid(JMSException):
|
||||
default_code = 'wecom_code_invalid'
|
||||
default_detail = 'Code invalid, can not get user info'
|
||||
|
||||
|
||||
class WeComBindAlready(JMSException):
|
||||
default_code = 'wecom_not_bound'
|
||||
default_detail = _('WeCom is already bound')
|
||||
|
||||
|
||||
class WeComNotBound(JMSException):
|
||||
default_code = 'wecom_not_bound'
|
||||
default_detail = _('WeCom is not bound')
|
||||
|
||||
|
||||
class DingTalkNotBound(JMSException):
|
||||
default_code = 'dingtalk_not_bound'
|
||||
default_detail = _('DingTalk is not bound')
|
||||
|
||||
|
||||
class FeiShuNotBound(JMSException):
|
||||
default_code = 'feishu_not_bound'
|
||||
default_detail = _('FeiShu is not bound')
|
||||
|
||||
|
||||
class PasswordInvalid(JMSException):
|
||||
default_code = 'passwd_invalid'
|
||||
default_detail = _('Your password is invalid')
|
||||
112
apps/authentication/errors/redirect.py
Normal file
112
apps/authentication/errors/redirect.py
Normal file
@@ -0,0 +1,112 @@
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.urls import reverse
|
||||
|
||||
from common.exceptions import JMSException
|
||||
from . import const
|
||||
|
||||
|
||||
class NeedMoreInfoError(Exception):
|
||||
error = ''
|
||||
msg = ''
|
||||
|
||||
def __init__(self, error='', msg=''):
|
||||
if error:
|
||||
self.error = error
|
||||
if msg:
|
||||
self.msg = msg
|
||||
|
||||
def as_data(self):
|
||||
return {
|
||||
'error': self.error,
|
||||
'msg': self.msg,
|
||||
}
|
||||
|
||||
|
||||
class NeedRedirectError(JMSException):
|
||||
def __init__(self, url, *args, **kwargs):
|
||||
self.url = url
|
||||
|
||||
|
||||
class MFARequiredError(NeedMoreInfoError):
|
||||
msg = const.mfa_required_msg
|
||||
error = 'mfa_required'
|
||||
|
||||
def __init__(self, error='', msg='', mfa_types=()):
|
||||
super().__init__(error=error, msg=msg)
|
||||
self.choices = mfa_types
|
||||
|
||||
def as_data(self):
|
||||
return {
|
||||
'error': self.error,
|
||||
'msg': self.msg,
|
||||
'data': {
|
||||
'choices': self.choices,
|
||||
'url': reverse('api-auth:mfa-challenge')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class LoginConfirmBaseError(NeedMoreInfoError):
|
||||
def __init__(self, ticket_id, **kwargs):
|
||||
self.ticket_id = ticket_id
|
||||
super().__init__(**kwargs)
|
||||
|
||||
def as_data(self):
|
||||
return {
|
||||
"error": self.error,
|
||||
"msg": self.msg,
|
||||
"data": {
|
||||
"ticket_id": self.ticket_id
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class LoginConfirmWaitError(LoginConfirmBaseError):
|
||||
msg = const.login_confirm_wait_msg
|
||||
error = 'login_confirm_wait'
|
||||
|
||||
|
||||
class LoginConfirmOtherError(LoginConfirmBaseError):
|
||||
error = 'login_confirm_error'
|
||||
|
||||
def __init__(self, ticket_id, status, username):
|
||||
self.username = username
|
||||
msg = const.login_confirm_error_msg.format(status)
|
||||
super().__init__(ticket_id=ticket_id, msg=msg)
|
||||
|
||||
def as_data(self):
|
||||
ret = super().as_data()
|
||||
ret['data']['username'] = self.username
|
||||
return ret
|
||||
|
||||
|
||||
class PasswordTooSimple(NeedRedirectError):
|
||||
default_code = 'passwd_too_simple'
|
||||
default_detail = _('Your password is too simple, please change it for security')
|
||||
|
||||
def __init__(self, url, *args, **kwargs):
|
||||
super().__init__(url, *args, **kwargs)
|
||||
|
||||
|
||||
class PasswordNeedUpdate(NeedRedirectError):
|
||||
default_code = 'passwd_need_update'
|
||||
default_detail = _('You should to change your password before login')
|
||||
|
||||
def __init__(self, url, *args, **kwargs):
|
||||
super().__init__(url, *args, **kwargs)
|
||||
|
||||
|
||||
class PasswordRequireResetError(NeedRedirectError):
|
||||
default_code = 'passwd_has_expired'
|
||||
default_detail = _('Your password has expired, please reset before logging in')
|
||||
|
||||
def __init__(self, url, *args, **kwargs):
|
||||
super().__init__(url, *args, **kwargs)
|
||||
|
||||
|
||||
class MFAUnsetError(NeedRedirectError):
|
||||
error = const.reason_mfa_unset
|
||||
msg = const.mfa_unset_msg
|
||||
|
||||
def __init__(self, url, *args, **kwargs):
|
||||
super().__init__(url, *args, **kwargs)
|
||||
@@ -1,11 +1,16 @@
|
||||
import base64
|
||||
|
||||
from django.shortcuts import redirect, reverse
|
||||
from django.shortcuts import redirect, reverse, render
|
||||
from django.utils.deprecation import MiddlewareMixin
|
||||
from django.http import HttpResponse
|
||||
from django.conf import settings
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.contrib.auth import logout as auth_logout
|
||||
|
||||
from apps.authentication import mixins
|
||||
from common.utils import gen_key_pair
|
||||
from common.utils import get_request_ip
|
||||
from .signals import post_auth_failed
|
||||
|
||||
|
||||
class MFAMiddleware:
|
||||
@@ -13,6 +18,7 @@ class MFAMiddleware:
|
||||
这个 中间件 是用来全局拦截开启了 MFA 却没有认证的,如 OIDC, CAS,使用第三方库做的登录,直接 login 了,
|
||||
所以只能在 Middleware 中控制
|
||||
"""
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
@@ -42,10 +48,56 @@ class MFAMiddleware:
|
||||
return redirect(url)
|
||||
|
||||
|
||||
class ThirdPartyLoginMiddleware(mixins.AuthMixin):
|
||||
"""OpenID、CAS、SAML2登录规则设置验证"""
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
response = self.get_response(request)
|
||||
# 没有认证过,证明不是从 第三方 来的
|
||||
if request.user.is_anonymous:
|
||||
return response
|
||||
if not request.session.get('auth_third_party_required'):
|
||||
return response
|
||||
ip = get_request_ip(request)
|
||||
try:
|
||||
self.request = request
|
||||
self._check_login_acl(request.user, ip)
|
||||
except Exception as e:
|
||||
post_auth_failed.send(
|
||||
sender=self.__class__, username=request.user.username,
|
||||
request=self.request, reason=e.msg
|
||||
)
|
||||
auth_logout(request)
|
||||
context = {
|
||||
'title': _('Authentication failed'),
|
||||
'message': _('Authentication failed (before login check failed): {}').format(e),
|
||||
'interval': 10,
|
||||
'redirect_url': reverse('authentication:login'),
|
||||
'auto_redirect': True,
|
||||
}
|
||||
response = render(request, 'authentication/auth_fail_flash_message_standalone.html', context)
|
||||
else:
|
||||
if not self.request.session['auth_confirm_required']:
|
||||
return response
|
||||
guard_url = reverse('authentication:login-guard')
|
||||
args = request.META.get('QUERY_STRING', '')
|
||||
if args:
|
||||
guard_url = "%s?%s" % (guard_url, args)
|
||||
response = redirect(guard_url)
|
||||
finally:
|
||||
request.session.pop('auth_third_party_required', '')
|
||||
return response
|
||||
|
||||
|
||||
class SessionCookieMiddleware(MiddlewareMixin):
|
||||
|
||||
@staticmethod
|
||||
def set_cookie_public_key(request, response):
|
||||
if request.path.startswith('/api'):
|
||||
return
|
||||
pub_key_name = settings.SESSION_RSA_PUBLIC_KEY_NAME
|
||||
public_key = request.session.get(pub_key_name)
|
||||
cookie_key = request.COOKIES.get(pub_key_name)
|
||||
|
||||
89
apps/authentication/migrations/0011_auto_20220705_1940.py
Normal file
89
apps/authentication/migrations/0011_auto_20220705_1940.py
Normal file
@@ -0,0 +1,89 @@
|
||||
# Generated by Django 3.2.12 on 2022-07-05 11:40
|
||||
|
||||
import authentication.models
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('applications', '0021_auto_20220629_1826'),
|
||||
('assets', '0091_auto_20220629_1826'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('authentication', '0010_temptoken'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='connectiontoken',
|
||||
name='application',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='connection_tokens', to='applications.application', verbose_name='Application'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='connectiontoken',
|
||||
name='application_display',
|
||||
field=models.CharField(default='', max_length=128, verbose_name='Application display'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='connectiontoken',
|
||||
name='asset',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='connection_tokens', to='assets.asset', verbose_name='Asset'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='connectiontoken',
|
||||
name='asset_display',
|
||||
field=models.CharField(default='', max_length=128, verbose_name='Asset display'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='connectiontoken',
|
||||
name='date_expired',
|
||||
field=models.DateTimeField(default=authentication.models.date_expired_default, verbose_name='Date expired'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='connectiontoken',
|
||||
name='org_id',
|
||||
field=models.CharField(blank=True, db_index=True, default='', max_length=36, verbose_name='Organization'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='connectiontoken',
|
||||
name='secret',
|
||||
field=models.CharField(default='', max_length=64, verbose_name='Secret'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='connectiontoken',
|
||||
name='system_user',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='connection_tokens', to='assets.systemuser', verbose_name='System user'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='connectiontoken',
|
||||
name='system_user_display',
|
||||
field=models.CharField(default='', max_length=128, verbose_name='System user display'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='connectiontoken',
|
||||
name='type',
|
||||
field=models.CharField(choices=[('asset', 'Asset'), ('application', 'Application')], default='asset', max_length=16, verbose_name='Type'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='connectiontoken',
|
||||
name='user',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='connection_tokens', to=settings.AUTH_USER_MODEL, verbose_name='User'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='connectiontoken',
|
||||
name='user_display',
|
||||
field=models.CharField(default='', max_length=128, verbose_name='User display'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='connectiontoken',
|
||||
name='id',
|
||||
field=models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False),
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='connectiontoken',
|
||||
options={'ordering': ('-date_expired',), 'permissions': [('view_connectiontokensecret', 'Can view connection token secret')], 'verbose_name': 'Connection token'},
|
||||
),
|
||||
]
|
||||
@@ -193,8 +193,8 @@ class MFAMixin:
|
||||
def _check_if_no_active_mfa(self, user):
|
||||
active_mfa_mapper = user.active_mfa_backends_mapper
|
||||
if not active_mfa_mapper:
|
||||
url = reverse('authentication:user-otp-enable-start')
|
||||
raise errors.MFAUnsetError(user, self.request, url)
|
||||
set_url = reverse('authentication:user-otp-enable-start')
|
||||
raise errors.MFAUnsetError(set_url, user, self.request)
|
||||
|
||||
def _check_login_page_mfa_if_need(self, user):
|
||||
if not settings.SECURITY_MFA_IN_LOGIN_PAGE:
|
||||
@@ -328,28 +328,41 @@ class AuthACLMixin:
|
||||
|
||||
def _check_login_acl(self, user, ip):
|
||||
# ACL 限制用户登录
|
||||
is_allowed, limit_type = LoginACL.allow_user_to_login(user, ip)
|
||||
if is_allowed:
|
||||
acl = LoginACL.match(user, ip)
|
||||
if not acl:
|
||||
return
|
||||
if limit_type == 'ip':
|
||||
raise errors.LoginIPNotAllowed(username=user.username, request=self.request)
|
||||
elif limit_type == 'time':
|
||||
raise errors.TimePeriodNotAllowed(username=user.username, request=self.request)
|
||||
|
||||
def get_ticket(self):
|
||||
from tickets.models import Ticket
|
||||
ticket_id = self.request.session.get("auth_ticket_id")
|
||||
logger.debug('Login confirm ticket id: {}'.format(ticket_id))
|
||||
if not ticket_id:
|
||||
ticket = None
|
||||
else:
|
||||
ticket = Ticket.all().filter(id=ticket_id).first()
|
||||
return ticket
|
||||
acl: LoginACL
|
||||
if acl.is_action(acl.ActionChoices.allow):
|
||||
return
|
||||
|
||||
def get_ticket_or_create(self, confirm_setting):
|
||||
if acl.is_action(acl.ActionChoices.reject):
|
||||
raise errors.LoginACLIPAndTimePeriodNotAllowed(user.username, request=self.request)
|
||||
|
||||
if acl.is_action(acl.ActionChoices.confirm):
|
||||
self.request.session['auth_confirm_required'] = '1'
|
||||
self.request.session['auth_acl_id'] = str(acl.id)
|
||||
return
|
||||
|
||||
def check_user_login_confirm_if_need(self, user):
|
||||
if not self.request.session.get("auth_confirm_required"):
|
||||
return
|
||||
acl_id = self.request.session.get('auth_acl_id')
|
||||
logger.debug('Login confirm acl id: {}'.format(acl_id))
|
||||
if not acl_id:
|
||||
return
|
||||
acl = LoginACL.filter_acl(user).filter(id=acl_id).first()
|
||||
if not acl:
|
||||
return
|
||||
if not acl.is_action(acl.ActionChoices.confirm):
|
||||
return
|
||||
self.get_ticket_or_create(acl)
|
||||
self.check_user_login_confirm()
|
||||
|
||||
def get_ticket_or_create(self, acl):
|
||||
ticket = self.get_ticket()
|
||||
if not ticket or ticket.status_closed:
|
||||
ticket = confirm_setting.create_confirm_ticket(self.request)
|
||||
if not ticket or ticket.is_state(ticket.State.closed):
|
||||
ticket = acl.create_confirm_ticket(self.request)
|
||||
self.request.session['auth_ticket_id'] = str(ticket.id)
|
||||
return ticket
|
||||
|
||||
@@ -357,31 +370,27 @@ class AuthACLMixin:
|
||||
ticket = self.get_ticket()
|
||||
if not ticket:
|
||||
raise errors.LoginConfirmOtherError('', "Not found")
|
||||
if ticket.status_open:
|
||||
elif ticket.is_state(ticket.State.approved):
|
||||
self.request.session["auth_confirm_required"] = ''
|
||||
return
|
||||
elif ticket.is_status(ticket.Status.open):
|
||||
raise errors.LoginConfirmWaitError(ticket.id)
|
||||
elif ticket.state_approve:
|
||||
self.request.session["auth_confirm"] = "1"
|
||||
return
|
||||
elif ticket.state_reject:
|
||||
raise errors.LoginConfirmOtherError(
|
||||
ticket.id, ticket.get_state_display()
|
||||
)
|
||||
elif ticket.state_close:
|
||||
raise errors.LoginConfirmOtherError(
|
||||
ticket.id, ticket.get_state_display()
|
||||
)
|
||||
else:
|
||||
raise errors.LoginConfirmOtherError(
|
||||
ticket.id, ticket.get_status_display()
|
||||
)
|
||||
# rejected, closed
|
||||
ticket_id = ticket.id
|
||||
status = ticket.get_state_display()
|
||||
username = ticket.applicant.username
|
||||
raise errors.LoginConfirmOtherError(ticket_id, status, username)
|
||||
|
||||
def check_user_login_confirm_if_need(self, user):
|
||||
ip = self.get_request_ip()
|
||||
is_allowed, confirm_setting = LoginACL.allow_user_confirm_if_need(user, ip)
|
||||
if self.request.session.get('auth_confirm') or not is_allowed:
|
||||
return
|
||||
self.get_ticket_or_create(confirm_setting)
|
||||
self.check_user_login_confirm()
|
||||
def get_ticket(self):
|
||||
from tickets.models import ApplyLoginTicket
|
||||
ticket_id = self.request.session.get("auth_ticket_id")
|
||||
logger.debug('Login confirm ticket id: {}'.format(ticket_id))
|
||||
if not ticket_id:
|
||||
ticket = None
|
||||
else:
|
||||
ticket = ApplyLoginTicket.all().filter(id=ticket_id).first()
|
||||
return ticket
|
||||
|
||||
|
||||
class AuthMixin(CommonMixin, AuthPreCheckMixin, AuthACLMixin, MFAMixin, AuthPostCheckMixin):
|
||||
@@ -442,13 +451,15 @@ class AuthMixin(CommonMixin, AuthPreCheckMixin, AuthACLMixin, MFAMixin, AuthPost
|
||||
LoginIpBlockUtil(ip).clean_block_if_need()
|
||||
return user
|
||||
|
||||
def mark_password_ok(self, user, auto_login=False):
|
||||
def mark_password_ok(self, user, auto_login=False, auth_backend=None):
|
||||
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)
|
||||
if not auth_backend:
|
||||
auth_backend = getattr(user, 'backend', settings.AUTH_BACKEND_MODEL)
|
||||
request.session['auth_backend'] = auth_backend
|
||||
|
||||
def check_oauth2_auth(self, user: User, auth_backend):
|
||||
ip = self.get_request_ip()
|
||||
@@ -468,7 +479,7 @@ class AuthMixin(CommonMixin, AuthPreCheckMixin, AuthACLMixin, MFAMixin, AuthPost
|
||||
LoginIpBlockUtil(ip).clean_block_if_need()
|
||||
MFABlockUtils(user.username, ip).clean_failed_count()
|
||||
|
||||
self.mark_password_ok(user, False)
|
||||
self.mark_password_ok(user, False, auth_backend)
|
||||
return user
|
||||
|
||||
def get_user_or_auth(self, valid_data):
|
||||
@@ -479,7 +490,9 @@ class AuthMixin(CommonMixin, AuthPreCheckMixin, AuthACLMixin, MFAMixin, AuthPost
|
||||
return self.check_user_auth(valid_data)
|
||||
|
||||
def clear_auth_mark(self):
|
||||
keys = ['auth_password', 'user_id', 'auth_confirm', 'auth_ticket_id']
|
||||
keys = [
|
||||
'auth_password', 'user_id', 'auth_confirm_required', 'auth_ticket_id', 'auth_acl_id'
|
||||
]
|
||||
for k in keys:
|
||||
self.request.session.pop(k, '')
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import uuid
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.conf import settings
|
||||
from rest_framework.authtoken.models import Token
|
||||
from orgs.mixins.models import OrgModelMixin
|
||||
|
||||
from common.db import models
|
||||
from common.utils import lazyproperty
|
||||
from common.utils.timezone import as_current_tz
|
||||
|
||||
|
||||
class AccessKey(models.Model):
|
||||
@@ -54,16 +57,204 @@ class SSOToken(models.JMSBaseModel):
|
||||
verbose_name = _('SSO token')
|
||||
|
||||
|
||||
class ConnectionToken(models.JMSBaseModel):
|
||||
# Todo: 未来可能放到这里,不记录到 redis 了,虽然方便,但是不易于审计
|
||||
# Todo: add connection token 可能要授权给 普通用户, 或者放开就行
|
||||
def date_expired_default():
|
||||
return timezone.now() + timedelta(seconds=settings.CONNECTION_TOKEN_EXPIRATION)
|
||||
|
||||
|
||||
class ConnectionToken(OrgModelMixin, models.JMSModel):
|
||||
class Type(models.TextChoices):
|
||||
asset = 'asset', _('Asset')
|
||||
application = 'application', _('Application')
|
||||
|
||||
type = models.CharField(
|
||||
max_length=16, default=Type.asset, choices=Type.choices, verbose_name=_("Type")
|
||||
)
|
||||
secret = models.CharField(max_length=64, default='', verbose_name=_("Secret"))
|
||||
date_expired = models.DateTimeField(
|
||||
default=date_expired_default, verbose_name=_("Date expired")
|
||||
)
|
||||
|
||||
user = models.ForeignKey(
|
||||
'users.User', on_delete=models.SET_NULL, verbose_name=_('User'),
|
||||
related_name='connection_tokens', null=True, blank=True
|
||||
)
|
||||
user_display = models.CharField(max_length=128, default='', verbose_name=_("User display"))
|
||||
system_user = models.ForeignKey(
|
||||
'assets.SystemUser', on_delete=models.SET_NULL, verbose_name=_('System user'),
|
||||
related_name='connection_tokens', null=True, blank=True
|
||||
)
|
||||
system_user_display = models.CharField(
|
||||
max_length=128, default='', verbose_name=_("System user display")
|
||||
)
|
||||
asset = models.ForeignKey(
|
||||
'assets.Asset', on_delete=models.SET_NULL, verbose_name=_('Asset'),
|
||||
related_name='connection_tokens', null=True, blank=True
|
||||
)
|
||||
asset_display = models.CharField(max_length=128, default='', verbose_name=_("Asset display"))
|
||||
application = models.ForeignKey(
|
||||
'applications.Application', on_delete=models.SET_NULL, verbose_name=_('Application'),
|
||||
related_name='connection_tokens', null=True, blank=True
|
||||
)
|
||||
application_display = models.CharField(
|
||||
max_length=128, default='', verbose_name=_("Application display")
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ('-date_expired',)
|
||||
verbose_name = _('Connection token')
|
||||
permissions = [
|
||||
('view_connectiontokensecret', _('Can view connection token secret'))
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def get_default_date_expired(cls):
|
||||
return date_expired_default()
|
||||
|
||||
@property
|
||||
def is_expired(self):
|
||||
return self.date_expired < timezone.now()
|
||||
|
||||
@property
|
||||
def expire_time(self):
|
||||
interval = self.date_expired - timezone.now()
|
||||
seconds = interval.total_seconds()
|
||||
if seconds < 0:
|
||||
seconds = 0
|
||||
return int(seconds)
|
||||
|
||||
def expire(self):
|
||||
self.date_expired = timezone.now()
|
||||
self.save()
|
||||
|
||||
@property
|
||||
def is_valid(self):
|
||||
return not self.is_expired
|
||||
|
||||
def is_type(self, tp):
|
||||
return self.type == tp
|
||||
|
||||
def renewal(self):
|
||||
""" 续期 Token,将来支持用户自定义创建 token 后,续期策略要修改 """
|
||||
self.date_expired = self.get_default_date_expired()
|
||||
self.save()
|
||||
|
||||
actions = expired_at = None # actions 和 expired_at 在 check_valid() 中赋值
|
||||
|
||||
def check_valid(self):
|
||||
from perms.utils.asset.permission import validate_permission as asset_validate_permission
|
||||
from perms.utils.application.permission import validate_permission as app_validate_permission
|
||||
|
||||
if self.is_expired:
|
||||
is_valid = False
|
||||
error = _('Connection token expired at: {}').format(as_current_tz(self.date_expired))
|
||||
return is_valid, error
|
||||
|
||||
if not self.user:
|
||||
is_valid = False
|
||||
error = _('User not exists')
|
||||
return is_valid, error
|
||||
if not self.user.is_valid:
|
||||
is_valid = False
|
||||
error = _('User invalid, disabled or expired')
|
||||
return is_valid, error
|
||||
|
||||
if not self.system_user:
|
||||
is_valid = False
|
||||
error = _('System user not exists')
|
||||
return is_valid, error
|
||||
|
||||
if self.is_type(self.Type.asset):
|
||||
if not self.asset:
|
||||
is_valid = False
|
||||
error = _('Asset not exists')
|
||||
return is_valid, error
|
||||
if not self.asset.is_active:
|
||||
is_valid = False
|
||||
error = _('Asset inactive')
|
||||
return is_valid, error
|
||||
has_perm, actions, expired_at = asset_validate_permission(
|
||||
self.user, self.asset, self.system_user
|
||||
)
|
||||
if not has_perm:
|
||||
is_valid = False
|
||||
error = _('User has no permission to access asset or permission expired')
|
||||
return is_valid, error
|
||||
self.actions = actions
|
||||
self.expired_at = expired_at
|
||||
|
||||
elif self.is_type(self.Type.application):
|
||||
if not self.application:
|
||||
is_valid = False
|
||||
error = _('Application not exists')
|
||||
return is_valid, error
|
||||
has_perm, actions, expired_at = app_validate_permission(
|
||||
self.user, self.application, self.system_user
|
||||
)
|
||||
if not has_perm:
|
||||
is_valid = False
|
||||
error = _('User has no permission to access application or permission expired')
|
||||
return is_valid, error
|
||||
self.actions = actions
|
||||
self.expired_at = expired_at
|
||||
|
||||
return True, ''
|
||||
|
||||
@lazyproperty
|
||||
def domain(self):
|
||||
if self.asset:
|
||||
return self.asset.domain
|
||||
if not self.application:
|
||||
return
|
||||
if self.application.category_remote_app:
|
||||
asset = self.application.get_remote_app_asset()
|
||||
domain = asset.domain if asset else None
|
||||
else:
|
||||
domain = self.application.domain
|
||||
return domain
|
||||
|
||||
@lazyproperty
|
||||
def gateway(self):
|
||||
from assets.models import Domain
|
||||
if not self.domain:
|
||||
return
|
||||
self.domain: Domain
|
||||
return self.domain.random_gateway()
|
||||
|
||||
@lazyproperty
|
||||
def remote_app(self):
|
||||
if not self.application:
|
||||
return {}
|
||||
if not self.application.category_remote_app:
|
||||
return {}
|
||||
return self.application.get_rdp_remote_app_setting()
|
||||
|
||||
@lazyproperty
|
||||
def asset_or_remote_app_asset(self):
|
||||
if self.asset:
|
||||
return self.asset
|
||||
if self.application and self.application.category_remote_app:
|
||||
return self.application.get_remote_app_asset()
|
||||
|
||||
@lazyproperty
|
||||
def cmd_filter_rules(self):
|
||||
from assets.models import CommandFilterRule
|
||||
kwargs = {
|
||||
'user_id': self.user.id,
|
||||
'system_user_id': self.system_user.id,
|
||||
}
|
||||
if self.asset:
|
||||
kwargs['asset_id'] = self.asset.id
|
||||
elif self.application:
|
||||
kwargs['application_id'] = self.application_id
|
||||
rules = CommandFilterRule.get_queryset(**kwargs)
|
||||
return rules
|
||||
|
||||
def load_system_user_auth(self):
|
||||
if self.asset:
|
||||
self.system_user.load_asset_more_auth(self.asset.id, self.user.username, self.user.id)
|
||||
elif self.application:
|
||||
self.system_user.load_app_more_auth(self.application.id, self.user.username, self.user.id)
|
||||
|
||||
|
||||
class TempToken(models.JMSModel):
|
||||
username = models.CharField(max_length=128, verbose_name=_("Username"))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from .token import *
|
||||
from .connect_token import *
|
||||
from .connection_token import *
|
||||
from .password_mfa import *
|
||||
from .confirm import *
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from common.drf.fields import EncryptedField
|
||||
from ..const import ConfirmType
|
||||
from ..const import ConfirmType, MFAType
|
||||
|
||||
|
||||
class ConfirmSerializer(serializers.Serializer):
|
||||
confirm_type = serializers.ChoiceField(
|
||||
required=True, choices=ConfirmType.choices
|
||||
)
|
||||
secret_key = EncryptedField()
|
||||
confirm_type = serializers.ChoiceField(required=True, allow_blank=True, choices=ConfirmType.choices)
|
||||
mfa_type = serializers.ChoiceField(required=False, allow_blank=True, choices=MFAType.choices)
|
||||
secret_key = EncryptedField(allow_blank=True)
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
from rest_framework import serializers
|
||||
|
||||
from users.models import User
|
||||
from assets.models import Asset, SystemUser, Gateway, Domain, CommandFilterRule
|
||||
from applications.models import Application
|
||||
from assets.serializers import ProtocolsField
|
||||
from perms.serializers.base import ActionsField
|
||||
|
||||
__all__ = [
|
||||
'ConnectionTokenSerializer', 'ConnectionTokenApplicationSerializer',
|
||||
'ConnectionTokenUserSerializer', 'ConnectionTokenFilterRuleSerializer',
|
||||
'ConnectionTokenAssetSerializer', 'ConnectionTokenSystemUserSerializer',
|
||||
'ConnectionTokenDomainSerializer', 'ConnectionTokenRemoteAppSerializer',
|
||||
'ConnectionTokenGatewaySerializer', 'ConnectionTokenSecretSerializer',
|
||||
'SuperConnectionTokenSerializer'
|
||||
]
|
||||
|
||||
|
||||
class ConnectionTokenSerializer(serializers.Serializer):
|
||||
system_user = serializers.CharField(max_length=128, required=True)
|
||||
asset = serializers.CharField(max_length=128, required=False)
|
||||
application = serializers.CharField(max_length=128, required=False)
|
||||
|
||||
@staticmethod
|
||||
def validate_system_user(system_user_id):
|
||||
from assets.models import SystemUser
|
||||
system_user = SystemUser.objects.filter(id=system_user_id).first()
|
||||
if system_user is None:
|
||||
raise serializers.ValidationError('system_user id not exist')
|
||||
return system_user
|
||||
|
||||
@staticmethod
|
||||
def validate_asset(asset_id):
|
||||
from assets.models import Asset
|
||||
asset = Asset.objects.filter(id=asset_id).first()
|
||||
if asset is None:
|
||||
raise serializers.ValidationError('asset id not exist')
|
||||
return asset
|
||||
|
||||
@staticmethod
|
||||
def validate_application(app_id):
|
||||
from applications.models import Application
|
||||
app = Application.objects.filter(id=app_id).first()
|
||||
if app is None:
|
||||
raise serializers.ValidationError('app id not exist')
|
||||
return app
|
||||
|
||||
def validate(self, attrs):
|
||||
asset = attrs.get('asset')
|
||||
application = attrs.get('application')
|
||||
if not asset and not application:
|
||||
raise serializers.ValidationError('asset or application required')
|
||||
if asset and application:
|
||||
raise serializers.ValidationError('asset and application should only one')
|
||||
return super().validate(attrs)
|
||||
|
||||
|
||||
class SuperConnectionTokenSerializer(ConnectionTokenSerializer):
|
||||
user = serializers.CharField(max_length=128, required=False, allow_blank=True)
|
||||
|
||||
@staticmethod
|
||||
def validate_user(user_id):
|
||||
from users.models import User
|
||||
user = User.objects.filter(id=user_id).first()
|
||||
if user is None:
|
||||
raise serializers.ValidationError('user id not exist')
|
||||
return user
|
||||
|
||||
|
||||
class ConnectionTokenUserSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ['id', 'name', 'username', 'email']
|
||||
|
||||
|
||||
class ConnectionTokenAssetSerializer(serializers.ModelSerializer):
|
||||
protocols = ProtocolsField(label='Protocols', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Asset
|
||||
fields = ['id', 'hostname', 'ip', 'protocols', 'org_id']
|
||||
|
||||
|
||||
class ConnectionTokenSystemUserSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = SystemUser
|
||||
fields = ['id', 'name', 'username', 'password', 'private_key', 'protocol', 'ad_domain', 'org_id']
|
||||
|
||||
|
||||
class ConnectionTokenGatewaySerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Gateway
|
||||
fields = ['id', 'ip', 'port', 'username', 'password', 'private_key']
|
||||
|
||||
|
||||
class ConnectionTokenRemoteAppSerializer(serializers.Serializer):
|
||||
program = serializers.CharField()
|
||||
working_directory = serializers.CharField()
|
||||
parameters = serializers.CharField()
|
||||
|
||||
|
||||
class ConnectionTokenApplicationSerializer(serializers.ModelSerializer):
|
||||
attrs = serializers.JSONField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Application
|
||||
fields = ['id', 'name', 'category', 'type', 'attrs', 'org_id']
|
||||
|
||||
|
||||
class ConnectionTokenDomainSerializer(serializers.ModelSerializer):
|
||||
gateways = ConnectionTokenGatewaySerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Domain
|
||||
fields = ['id', 'name', 'gateways']
|
||||
|
||||
|
||||
class ConnectionTokenFilterRuleSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = CommandFilterRule
|
||||
fields = [
|
||||
'id', 'type', 'content', 'ignore_case', 'pattern',
|
||||
'priority', 'action',
|
||||
'date_created',
|
||||
]
|
||||
|
||||
|
||||
class ConnectionTokenSecretSerializer(serializers.Serializer):
|
||||
id = serializers.CharField(read_only=True)
|
||||
secret = serializers.CharField(read_only=True)
|
||||
type = serializers.ChoiceField(choices=[('application', 'Application'), ('asset', 'Asset')])
|
||||
user = ConnectionTokenUserSerializer(read_only=True)
|
||||
asset = ConnectionTokenAssetSerializer(read_only=True)
|
||||
remote_app = ConnectionTokenRemoteAppSerializer(read_only=True)
|
||||
application = ConnectionTokenApplicationSerializer(read_only=True)
|
||||
system_user = ConnectionTokenSystemUserSerializer(read_only=True)
|
||||
cmd_filter_rules = ConnectionTokenFilterRuleSerializer(many=True)
|
||||
domain = ConnectionTokenDomainSerializer(read_only=True)
|
||||
gateway = ConnectionTokenGatewaySerializer(read_only=True)
|
||||
actions = ActionsField()
|
||||
expired_at = serializers.IntegerField()
|
||||
194
apps/authentication/serializers/connection_token.py
Normal file
194
apps/authentication/serializers/connection_token.py
Normal file
@@ -0,0 +1,194 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from orgs.mixins.serializers import OrgResourceModelSerializerMixin
|
||||
from authentication.models import ConnectionToken
|
||||
from common.utils import pretty_string
|
||||
from common.utils.random import random_string
|
||||
from assets.models import Asset, SystemUser, Gateway, Domain, CommandFilterRule
|
||||
from users.models import User
|
||||
from applications.models import Application
|
||||
from assets.serializers import ProtocolsField
|
||||
from perms.serializers.base import ActionsField
|
||||
|
||||
|
||||
__all__ = [
|
||||
'ConnectionTokenSerializer', 'ConnectionTokenSecretSerializer',
|
||||
'SuperConnectionTokenSerializer', 'ConnectionTokenDisplaySerializer'
|
||||
]
|
||||
|
||||
|
||||
class ConnectionTokenSerializer(OrgResourceModelSerializerMixin):
|
||||
type_display = serializers.ReadOnlyField(source='get_type_display', label=_("Type display"))
|
||||
is_valid = serializers.BooleanField(read_only=True, label=_('Validity'))
|
||||
expire_time = serializers.IntegerField(read_only=True, label=_('Expired time'))
|
||||
|
||||
class Meta:
|
||||
model = ConnectionToken
|
||||
fields_mini = ['id', 'type']
|
||||
fields_small = fields_mini + [
|
||||
'secret', 'date_expired', 'date_created', 'date_updated',
|
||||
'created_by', 'updated_by', 'org_id', 'org_name',
|
||||
]
|
||||
fields_fk = [
|
||||
'user', 'system_user', 'asset', 'application',
|
||||
]
|
||||
read_only_fields = [
|
||||
# 普通 Token 不支持指定 user
|
||||
'user', 'is_valid', 'expire_time',
|
||||
'type_display', 'user_display', 'system_user_display',
|
||||
'asset_display', 'application_display',
|
||||
]
|
||||
fields = fields_small + fields_fk + read_only_fields
|
||||
|
||||
def validate(self, attrs):
|
||||
fields_attrs = self.construct_internal_fields_attrs(attrs)
|
||||
attrs.update(fields_attrs)
|
||||
return attrs
|
||||
|
||||
@property
|
||||
def request_user(self):
|
||||
request = self.context.get('request')
|
||||
if request:
|
||||
return request.user
|
||||
|
||||
def get_user(self, attrs):
|
||||
return self.request_user
|
||||
|
||||
def construct_internal_fields_attrs(self, attrs):
|
||||
user = self.get_user(attrs)
|
||||
system_user = attrs.get('system_user') or ''
|
||||
asset = attrs.get('asset') or ''
|
||||
application = attrs.get('application') or ''
|
||||
secret = attrs.get('secret') or random_string(16)
|
||||
date_expired = attrs.get('date_expired') or ConnectionToken.get_default_date_expired()
|
||||
|
||||
if isinstance(asset, Asset):
|
||||
tp = ConnectionToken.Type.asset
|
||||
org_id = asset.org_id
|
||||
elif isinstance(application, Application):
|
||||
tp = ConnectionToken.Type.application
|
||||
org_id = application.org_id
|
||||
else:
|
||||
raise serializers.ValidationError(_('Asset or application required'))
|
||||
|
||||
return {
|
||||
'type': tp,
|
||||
'user': user,
|
||||
'secret': secret,
|
||||
'date_expired': date_expired,
|
||||
'user_display': pretty_string(str(user), max_length=128),
|
||||
'system_user_display': pretty_string(str(system_user), max_length=128),
|
||||
'asset_display': pretty_string(str(asset), max_length=128),
|
||||
'application_display': pretty_string(str(application), max_length=128),
|
||||
'org_id': org_id,
|
||||
}
|
||||
|
||||
|
||||
class ConnectionTokenDisplaySerializer(ConnectionTokenSerializer):
|
||||
class Meta(ConnectionTokenSerializer.Meta):
|
||||
extra_kwargs = {
|
||||
'secret': {'write_only': True},
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
# SuperConnectionTokenSerializer
|
||||
#
|
||||
|
||||
|
||||
class SuperConnectionTokenSerializer(ConnectionTokenSerializer):
|
||||
|
||||
class Meta(ConnectionTokenSerializer.Meta):
|
||||
read_only_fields = [
|
||||
'validity', 'user_display', 'system_user_display',
|
||||
'asset_display', 'application_display',
|
||||
]
|
||||
|
||||
def get_user(self, attrs):
|
||||
return attrs.get('user') or self.request_user
|
||||
|
||||
|
||||
#
|
||||
# Connection Token Secret
|
||||
#
|
||||
|
||||
|
||||
class ConnectionTokenUserSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ['id', 'name', 'username', 'email']
|
||||
|
||||
|
||||
class ConnectionTokenAssetSerializer(serializers.ModelSerializer):
|
||||
protocols = ProtocolsField(label='Protocols', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Asset
|
||||
fields = ['id', 'hostname', 'ip', 'protocols', 'org_id']
|
||||
|
||||
|
||||
class ConnectionTokenSystemUserSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = SystemUser
|
||||
fields = [
|
||||
'id', 'name', 'username', 'password', 'private_key',
|
||||
'protocol', 'ad_domain', 'org_id'
|
||||
]
|
||||
|
||||
|
||||
class ConnectionTokenGatewaySerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Gateway
|
||||
fields = ['id', 'ip', 'port', 'username', 'password', 'private_key']
|
||||
|
||||
|
||||
class ConnectionTokenRemoteAppSerializer(serializers.Serializer):
|
||||
program = serializers.CharField(allow_null=True, allow_blank=True)
|
||||
working_directory = serializers.CharField(allow_null=True, allow_blank=True)
|
||||
parameters = serializers.CharField(allow_null=True, allow_blank=True)
|
||||
|
||||
|
||||
class ConnectionTokenApplicationSerializer(serializers.ModelSerializer):
|
||||
attrs = serializers.JSONField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Application
|
||||
fields = ['id', 'name', 'category', 'type', 'attrs', 'org_id']
|
||||
|
||||
|
||||
class ConnectionTokenDomainSerializer(serializers.ModelSerializer):
|
||||
gateways = ConnectionTokenGatewaySerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Domain
|
||||
fields = ['id', 'name', 'gateways']
|
||||
|
||||
|
||||
class ConnectionTokenCmdFilterRuleSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = CommandFilterRule
|
||||
fields = [
|
||||
'id', 'type', 'content', 'ignore_case', 'pattern',
|
||||
'priority', 'action', 'date_created',
|
||||
]
|
||||
|
||||
|
||||
class ConnectionTokenSecretSerializer(OrgResourceModelSerializerMixin):
|
||||
user = ConnectionTokenUserSerializer(read_only=True)
|
||||
asset = ConnectionTokenAssetSerializer(read_only=True, source='asset_or_remote_app_asset')
|
||||
application = ConnectionTokenApplicationSerializer(read_only=True)
|
||||
remote_app = ConnectionTokenRemoteAppSerializer(read_only=True)
|
||||
system_user = ConnectionTokenSystemUserSerializer(read_only=True)
|
||||
gateway = ConnectionTokenGatewaySerializer(read_only=True)
|
||||
domain = ConnectionTokenDomainSerializer(read_only=True)
|
||||
cmd_filter_rules = ConnectionTokenCmdFilterRuleSerializer(many=True)
|
||||
actions = ActionsField()
|
||||
expired_at = serializers.IntegerField()
|
||||
|
||||
class Meta:
|
||||
model = ConnectionToken
|
||||
fields = [
|
||||
'id', 'secret', 'type', 'user', 'asset', 'application', 'system_user',
|
||||
'remote_app', 'cmd_filter_rules', 'domain', 'gateway', 'actions', 'expired_at',
|
||||
]
|
||||
@@ -4,9 +4,8 @@ from rest_framework import serializers
|
||||
|
||||
from common.drf.fields import EncryptedField
|
||||
|
||||
|
||||
__all__ = [
|
||||
'OtpVerifySerializer', 'MFAChallengeSerializer', 'MFASelectTypeSerializer',
|
||||
'MFAChallengeSerializer', 'MFASelectTypeSerializer',
|
||||
'PasswordVerifySerializer',
|
||||
]
|
||||
|
||||
@@ -29,7 +28,3 @@ class MFAChallengeSerializer(serializers.Serializer):
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
pass
|
||||
|
||||
|
||||
class OtpVerifySerializer(serializers.Serializer):
|
||||
code = serializers.CharField(max_length=6, min_length=6)
|
||||
|
||||
@@ -6,13 +6,8 @@ from django.core.cache import cache
|
||||
from django.dispatch import receiver
|
||||
from django_cas_ng.signals import cas_user_authenticated
|
||||
|
||||
from authentication.backends.oidc.signals import (
|
||||
openid_user_login_failed, openid_user_login_success
|
||||
)
|
||||
from authentication.backends.saml2.signals import (
|
||||
saml2_user_authenticated, saml2_user_authentication_failed
|
||||
)
|
||||
from .signals import post_auth_success, post_auth_failed
|
||||
from apps.jumpserver.settings.auth import AUTHENTICATION_BACKENDS_THIRD_PARTY
|
||||
from .signals import post_auth_success, post_auth_failed, user_auth_failed, user_auth_success
|
||||
|
||||
|
||||
@receiver(user_logged_in)
|
||||
@@ -25,7 +20,9 @@ def on_user_auth_login_success(sender, user, request, **kwargs):
|
||||
and user.mfa_enabled \
|
||||
and not request.session.get('auth_mfa'):
|
||||
request.session['auth_mfa_required'] = 1
|
||||
|
||||
if not request.session.get("auth_third_party_done") and \
|
||||
request.session.get('auth_backend') in AUTHENTICATION_BACKENDS_THIRD_PARTY:
|
||||
request.session['auth_third_party_required'] = 1
|
||||
# 单点登录,超过了自动退出
|
||||
if settings.USER_LOGIN_SINGLE_MACHINE_ENABLED:
|
||||
lock_key = 'single_machine_login_' + str(user.id)
|
||||
@@ -39,31 +36,19 @@ def on_user_auth_login_success(sender, user, request, **kwargs):
|
||||
request.session['auth_session_expiration_required'] = 1
|
||||
|
||||
|
||||
@receiver(openid_user_login_success)
|
||||
def on_oidc_user_login_success(sender, request, user, create=False, **kwargs):
|
||||
request.session['auth_backend'] = settings.AUTH_BACKEND_OIDC_CODE
|
||||
post_auth_success.send(sender, user=user, request=request)
|
||||
|
||||
|
||||
@receiver(openid_user_login_failed)
|
||||
def on_oidc_user_login_failed(sender, username, request, reason, **kwargs):
|
||||
request.session['auth_backend'] = settings.AUTH_BACKEND_OIDC_CODE
|
||||
post_auth_failed.send(sender, username=username, request=request, reason=reason)
|
||||
|
||||
|
||||
@receiver(cas_user_authenticated)
|
||||
def on_cas_user_login_success(sender, request, user, **kwargs):
|
||||
request.session['auth_backend'] = settings.AUTH_BACKEND_CAS
|
||||
post_auth_success.send(sender, user=user, request=request)
|
||||
|
||||
|
||||
@receiver(saml2_user_authenticated)
|
||||
def on_saml2_user_login_success(sender, request, user, **kwargs):
|
||||
request.session['auth_backend'] = settings.AUTH_BACKEND_SAML2
|
||||
@receiver(user_auth_success)
|
||||
def on_user_login_success(sender, request, user, backend, create=False, **kwargs):
|
||||
request.session['auth_backend'] = backend
|
||||
post_auth_success.send(sender, user=user, request=request)
|
||||
|
||||
|
||||
@receiver(saml2_user_authentication_failed)
|
||||
def on_saml2_user_login_failed(sender, request, username, reason, **kwargs):
|
||||
request.session['auth_backend'] = settings.AUTH_BACKEND_SAML2
|
||||
@receiver(user_auth_failed)
|
||||
def on_user_login_failed(sender, username, request, reason, backend, **kwargs):
|
||||
request.session['auth_backend'] = backend
|
||||
post_auth_failed.send(sender, username=username, request=request, reason=reason)
|
||||
|
||||
@@ -3,3 +3,7 @@ from django.dispatch import Signal
|
||||
|
||||
post_auth_success = Signal(providing_args=('user', 'request'))
|
||||
post_auth_failed = Signal(providing_args=('username', 'request', 'reason'))
|
||||
|
||||
|
||||
user_auth_success = Signal(providing_args=('user', 'request', 'backend', 'create'))
|
||||
user_auth_failed = Signal(providing_args=('username', 'request', 'reason', 'backend'))
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
{% extends '_base_only_content.html' %}
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% block html_title %} {{ title }} {% endblock %}
|
||||
{% block title %} {{ title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.alert.alert-msg {
|
||||
background: #F5F5F7;
|
||||
}
|
||||
</style>
|
||||
<div>
|
||||
<p>
|
||||
<div class="alert alert-msg" id="messages">
|
||||
{% if error %}
|
||||
{{ error }}
|
||||
{% else %}
|
||||
{{ message|safe }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</p>
|
||||
|
||||
<div class="row">
|
||||
{% if has_cancel %}
|
||||
<div class="col-sm-3">
|
||||
<a href="{{ cancel_url }}" class="btn btn-default block full-width m-b">
|
||||
{% trans 'Cancel' %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="col-sm-3">
|
||||
<a href="{{ redirect_url }}" class="btn btn-primary block full-width m-b">
|
||||
{% if confirm_button %}
|
||||
{{ confirm_button }}
|
||||
{% else %}
|
||||
{% trans 'Confirm' %}
|
||||
{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block custom_foot_js %}
|
||||
<script>
|
||||
var message = ''
|
||||
var time = '{{ interval }}'
|
||||
{% if error %}
|
||||
message = '{{ error }}'
|
||||
{% else %}
|
||||
message = '{{ message|safe }}'
|
||||
{% endif %}
|
||||
|
||||
function redirect_page() {
|
||||
if (time >= 0) {
|
||||
var msg = message + ', <b>' + time + '</b> ...';
|
||||
$('#messages').html(msg);
|
||||
time--;
|
||||
setTimeout(redirect_page, 1000);
|
||||
} else {
|
||||
window.location.href = "{{ redirect_url }}";
|
||||
}
|
||||
}
|
||||
{% if auto_redirect %}
|
||||
window.onload = redirect_page;
|
||||
{% endif %}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -5,16 +5,16 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<link rel="shortcut icon" href="{{ FAVICON_URL }}" type="image/x-icon">
|
||||
<link rel="shortcut icon" href="{{ INTERFACE.favicon }}" type="image/x-icon">
|
||||
<title>
|
||||
{{ JMS_TITLE }}
|
||||
{{ INTERFACE.login_title }}
|
||||
</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
{% include '_head_css_js.html' %}
|
||||
<!-- Stylesheets -->
|
||||
<link href="{% static 'css/login-style.css' %}" rel="stylesheet">
|
||||
<link href="{% static 'css/jumpserver.css' %}" rel="stylesheet">
|
||||
<script src="{% static "js/jumpserver.js" %}"></script>
|
||||
<script src="{% static "js/jumpserver.js" %}?_=9"></script>
|
||||
|
||||
<style>
|
||||
.login-content {
|
||||
@@ -32,12 +32,24 @@
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.hr-line-dashed {
|
||||
border-top: 1px dashed #e7eaec;
|
||||
color: #ffffff;
|
||||
background-color: #ffffff;
|
||||
height: 1px;
|
||||
margin: 20px 0;
|
||||
.form-group {
|
||||
margin-bottom: 30px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.extra-fields-1 .form-group {
|
||||
margin-bottom: 30px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.extra-fields-2 .form-group {
|
||||
margin-bottom: 20px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.extra-fields-3 .form-group {
|
||||
margin-bottom: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.login-content {
|
||||
@@ -78,34 +90,25 @@
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.captch-field .has-error .help-block {
|
||||
.captcha-field .has-error .help-block {
|
||||
margin-top: -8px !important;
|
||||
}
|
||||
|
||||
.no-captcha-challenge .form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.jms-title {
|
||||
padding: 40px 10px 10px;
|
||||
}
|
||||
|
||||
.no-captcha-challenge .jms-title {
|
||||
padding: 60px 10px 10px;
|
||||
}
|
||||
|
||||
.no-captcha-challenge .welcome-message {
|
||||
padding-top: 10px;
|
||||
.more-login-items {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.more-login-item {
|
||||
border-right: 1px dashed #dedede;
|
||||
padding-left: 5px;
|
||||
padding-right: 5px;
|
||||
padding: 2px 5px;
|
||||
}
|
||||
|
||||
.more-login-item:last-child {
|
||||
border: none;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.select-con {
|
||||
@@ -117,6 +120,7 @@
|
||||
}
|
||||
|
||||
.login-page-language {
|
||||
font-size: 12px!important;
|
||||
margin-right: -11px !important;
|
||||
padding-top: 12px !important;
|
||||
padding-left: 0 !important;
|
||||
@@ -125,15 +129,61 @@
|
||||
font-weight: 350 !important;
|
||||
min-height: auto !important;
|
||||
}
|
||||
|
||||
.right-image {
|
||||
height: 100%;
|
||||
width: 100%
|
||||
}
|
||||
|
||||
.jms-title {
|
||||
font-size: 21px;
|
||||
font-weight:400;
|
||||
color: #151515;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.more-methods-title {
|
||||
position: relative;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.more-methods-title:before, .more-methods-title:after {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
content: '';
|
||||
border: 1px dashed #e7eaec;
|
||||
width: 35%;
|
||||
}
|
||||
.more-methods-title:before {
|
||||
left: 0;
|
||||
}
|
||||
.more-methods-title:after {
|
||||
right: 0;
|
||||
}
|
||||
.more-methods-title.ja:before, .more-methods-title.ja:after{
|
||||
width: 26%;
|
||||
}
|
||||
.captcha-field .form-group {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.auto-login.form-group .checkbox {
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
.more-login {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.has-error .more-login {
|
||||
margin-top: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<div class="login-content">
|
||||
<div class="login-content extra-fields-{{ extra_fields_count }}">
|
||||
<div class="right-image-box">
|
||||
<a href="{% if not XPACK_ENABLED %}https://github.com/jumpserver/jumpserver{% endif %}">
|
||||
<img src="{{ LOGIN_IMAGE_URL }}" style="height: 100%; width: 100%"/>
|
||||
<a href="{% if not XPACK_ENABLED %}https://github.com/jumpserver/jumpserver.git{% endif %}">
|
||||
<img src="{{ INTERFACE.login_image }}" class="right-image" alt="screen-image"/>
|
||||
</a>
|
||||
</div>
|
||||
<div class="left-form-box {% if not form.challenge and not form.captcha %} no-captcha-challenge {% endif %}">
|
||||
@@ -142,26 +192,23 @@
|
||||
<li class="dropdown">
|
||||
<a class="dropdown-toggle login-page-language" data-toggle="dropdown" href="#" target="_blank">
|
||||
<i class="fa fa-globe fa-lg" style="margin-right: 2px"></i>
|
||||
{% if request.COOKIES.django_language == 'en' %}
|
||||
<span>English<b class="caret"></b></span>
|
||||
{% elif request.COOKIES.django_language == 'ja' %}
|
||||
<span>日本語<b class="caret"></b></span>
|
||||
{% else %}
|
||||
<span>中文(简体)<b class="caret"></b></span>
|
||||
{% endif %}
|
||||
<span>{{ current_lang.title }}<b class="caret"></b></span>
|
||||
</a>
|
||||
<ul class="dropdown-menu profile-dropdown dropdown-menu-right">
|
||||
<li> <a id="switch_cn" href="{% url 'i18n-switch' lang='zh-hans' %}"> <span>中文(简体)</span> </a> </li>
|
||||
<li> <a id="switch_en" href="{% url 'i18n-switch' lang='en' %}"> <span>English</span> </a> </li>
|
||||
<li> <a id="switch_ja" href="{% url 'i18n-switch' lang='ja' %}"> <span>日本語</span> </a> </li>
|
||||
{% for lang in langs %}
|
||||
<li>
|
||||
<a href="{% url 'i18n-switch' lang=lang.code %}">
|
||||
<span>{{ lang.title }}</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="jms-title">
|
||||
<span style="font-size: 21px;font-weight:400;color: #151515;letter-spacing: 0;">{{ JMS_TITLE }}</span>
|
||||
<span style="">{{ INTERFACE.login_title }}</span>
|
||||
</div>
|
||||
<div class="contact-form col-md-10 col-md-offset-1">
|
||||
|
||||
<form id="login-form" action="" method="post" role="form" novalidate="novalidate">
|
||||
{% csrf_token %}
|
||||
<div style="line-height: 17px;margin-bottom: 20px;color: #999999;">
|
||||
@@ -177,7 +224,6 @@
|
||||
</div>
|
||||
|
||||
{% bootstrap_field form.username show_label=False %}
|
||||
|
||||
<div class="form-group {% if form.password.errors %} has-error {% endif %}">
|
||||
<input type="password" class="form-control" id="password" placeholder="{% trans 'Password' %}" required>
|
||||
<input id="password-hidden" type="text" style="display:none" name="{{ form.password.html_name }}">
|
||||
@@ -194,18 +240,18 @@
|
||||
{% include '_mfa_login_field.html' %}
|
||||
</div>
|
||||
{% elif form.captcha %}
|
||||
<div class="captch-field">
|
||||
<div class="captcha-field">
|
||||
{% bootstrap_field form.captcha show_label=False %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="form-group" style="padding-top: 5px; margin-bottom: 10px">
|
||||
<div class="form-group auto-login" style="margin-bottom: 10px">
|
||||
<div class="row">
|
||||
<div class="col-md-6" style="text-align: left">
|
||||
{% if form.auto_login %}
|
||||
{% bootstrap_field form.auto_login form_group_class='' %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="col-md-6" style="line-height: 25px">
|
||||
<a id="forgot_password" href="{{ forgot_password_url }}" style="float: right">
|
||||
<small>{% trans 'Forgot password' %}?</small>
|
||||
</a>
|
||||
@@ -213,18 +259,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="">
|
||||
<button type="submit" class="btn btn-transparent" onclick="doLogin();return false;">{% trans 'Login' %}</button>
|
||||
<div class="form-group">
|
||||
<button type="submit" class="btn btn-transparent" onclick="doLogin();return false;">
|
||||
{% trans 'Login' %}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="more-login">
|
||||
{% if auth_methods %}
|
||||
<div class="hr-line-dashed"></div>
|
||||
<div style="display: inline-block; float: left">
|
||||
<b class="text-muted text-left" >{% trans "More login options" %}</b>
|
||||
<div class="more-methods-title {{ current_lang.code }}">
|
||||
{% trans "More login options" %}
|
||||
</div>
|
||||
<div class="more-login-items">
|
||||
{% for method in auth_methods %}
|
||||
<a href="{{ method.url }}" class="more-login-item">
|
||||
<i class="fa"><img src="{{ method.logo }}" height="13" width="13"></i> {{ method.name }}
|
||||
<i class="fa"><img src="{{ method.logo }}" height="15" width="15"></i> {{ method.name }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -251,9 +300,6 @@
|
||||
$('#password-hidden').val(passwordEncrypted); //返回给密码输入input
|
||||
$('#login-form').submit(); //post提交
|
||||
}
|
||||
|
||||
$(document).ready(function () {
|
||||
})
|
||||
</script>
|
||||
</html>
|
||||
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="shortcut icon" href="{{ FAVICON_URL }}" type="image/x-icon">
|
||||
<link rel="shortcut icon" href="{{ INTERFACE.favicon }}" type="image/x-icon">
|
||||
<title>{{ title }}</title>
|
||||
{% include '_head_css_js.html' %}
|
||||
<link href="{% static "css/jumpserver.css" %}" rel="stylesheet">
|
||||
<script src="{% static "js/jumpserver.js" %}"></script>
|
||||
<script src="{% static "js/jumpserver.js" %}?_=9"></script>
|
||||
|
||||
</head>
|
||||
|
||||
@@ -20,9 +20,9 @@
|
||||
<div class="col-md-12">
|
||||
<div class="ibox-content">
|
||||
<div>
|
||||
<img src="{{ LOGO_URL }}" style="margin: auto" width="82" height="82">
|
||||
<img src="{{ INTERFACE.logo_logout }}" style="margin: auto" width="82" height="82">
|
||||
<h2 style="display: inline">
|
||||
{{ JMS_TITLE }}
|
||||
{{ INTERFACE.login_title }}
|
||||
</h2>
|
||||
</div>
|
||||
<p></p>
|
||||
@@ -79,6 +79,9 @@ function doRequestAuth() {
|
||||
requestApi({
|
||||
url: url,
|
||||
method: "GET",
|
||||
headers: {
|
||||
"X-JMS-LOGIN-TYPE": "W"
|
||||
},
|
||||
success: function (data) {
|
||||
if (!data.error && data.msg === 'ok') {
|
||||
window.onbeforeunload = function(){};
|
||||
@@ -98,7 +101,7 @@ function doRequestAuth() {
|
||||
},
|
||||
error: function (text, data) {
|
||||
},
|
||||
flash_message: false
|
||||
flash_message: false, // 是否显示flash消息
|
||||
})
|
||||
}
|
||||
function initClipboard() {
|
||||
|
||||
@@ -10,8 +10,8 @@ router = DefaultRouter()
|
||||
router.register('access-keys', api.AccessKeyViewSet, 'access-key')
|
||||
router.register('sso', api.SSOViewSet, 'sso')
|
||||
router.register('temp-tokens', api.TempTokenViewSet, 'temp-token')
|
||||
router.register('connection-token', api.UserConnectionTokenViewSet, 'connection-token')
|
||||
router.register('super-connection-token', api.UserSuperConnectionTokenViewSet, 'super-connection-token')
|
||||
router.register('connection-token', api.ConnectionTokenViewSet, 'connection-token')
|
||||
router.register('super-connection-token', api.SuperConnectionTokenViewSet, 'super-connection-token')
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
@@ -26,13 +26,13 @@ urlpatterns = [
|
||||
path('feishu/event/subscription/callback/', api.FeiShuEventSubscriptionCallback.as_view(), name='feishu-event-subscription-callback'),
|
||||
|
||||
path('auth/', api.TokenCreateApi.as_view(), name='user-auth'),
|
||||
path('confirm/', api.ConfirmViewSet.as_view(), name='user-confirm'),
|
||||
path('confirm/', api.ConfirmApi.as_view(), name='user-confirm'),
|
||||
path('confirm-oauth/', api.ConfirmBindORUNBindOAuth.as_view(), name='confirm-oauth'),
|
||||
path('tokens/', api.TokenCreateApi.as_view(), name='auth-token'),
|
||||
path('mfa/verify/', api.MFAChallengeVerifyApi.as_view(), name='mfa-verify'),
|
||||
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('password/verify/', api.UserPasswordVerifyApi.as_view(), name='user-password-verify'),
|
||||
path('login-confirm-ticket/status/', api.TicketStatusApi.as_view(), name='login-confirm-ticket-status'),
|
||||
]
|
||||
|
||||
@@ -56,9 +56,11 @@ urlpatterns = [
|
||||
path('profile/otp/disable/', users_view.UserOtpDisableView.as_view(),
|
||||
name='user-otp-disable'),
|
||||
|
||||
# openid
|
||||
# other authentication protocol
|
||||
path('cas/', include(('authentication.backends.cas.urls', 'authentication'), namespace='cas')),
|
||||
path('openid/', include(('authentication.backends.oidc.urls', 'authentication'), namespace='openid')),
|
||||
path('saml2/', include(('authentication.backends.saml2.urls', 'authentication'), namespace='saml2')),
|
||||
path('oauth2/', include(('authentication.backends.oauth2.urls', 'authentication'), namespace='oauth2')),
|
||||
|
||||
path('captcha/', include('captcha.urls')),
|
||||
]
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
import ipaddress
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from common.utils import validate_ip, get_ip_city, get_request_ip
|
||||
from common.utils import get_logger
|
||||
@@ -22,10 +25,34 @@ def check_different_city_login_if_need(user, request):
|
||||
else:
|
||||
city = get_ip_city(ip) or DEFAULT_CITY
|
||||
|
||||
city_white = ['LAN', ]
|
||||
if city not in city_white:
|
||||
city_white = [_('LAN'), 'LAN']
|
||||
is_private = ipaddress.ip_address(ip).is_private
|
||||
if not is_private:
|
||||
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()
|
||||
|
||||
|
||||
def build_absolute_uri(request, path=None):
|
||||
""" Build absolute redirect """
|
||||
if path is None:
|
||||
path = '/'
|
||||
site_url = urlparse(settings.SITE_URL)
|
||||
scheme = site_url.scheme or request.scheme
|
||||
host = request.get_host()
|
||||
url = f'{scheme}://{host}'
|
||||
redirect_uri = urljoin(url, path)
|
||||
return redirect_uri
|
||||
|
||||
|
||||
def build_absolute_uri_for_oidc(request, path=None):
|
||||
""" Build absolute redirect uri for OIDC """
|
||||
if path is None:
|
||||
path = '/'
|
||||
if settings.BASE_SITE_URL:
|
||||
# OIDC 专用配置项
|
||||
redirect_uri = urljoin(settings.BASE_SITE_URL, path)
|
||||
return redirect_uri
|
||||
return build_absolute_uri(request, path=path)
|
||||
|
||||
@@ -8,17 +8,17 @@ from django.db.utils import IntegrityError
|
||||
from rest_framework.permissions import IsAuthenticated, AllowAny
|
||||
from rest_framework.exceptions import APIException
|
||||
|
||||
from users.views import UserVerifyPasswordView
|
||||
from users.utils import is_auth_confirm_time_valid
|
||||
from users.models import User
|
||||
from users.permissions import IsAuthConfirmTimeValid
|
||||
from users.views import UserVerifyPasswordView
|
||||
from common.utils import get_logger, FlashMessageUtil
|
||||
from common.utils.random import random_string
|
||||
from common.utils.django import reverse, get_object_or_none
|
||||
from common.sdk.im.dingtalk import URL
|
||||
from common.mixins.views import PermissionsMixin
|
||||
from common.mixins.views import UserConfirmRequiredExceptionMixin, PermissionsMixin
|
||||
from common.permissions import UserConfirmation
|
||||
from authentication import errors
|
||||
from authentication.mixins import AuthMixin
|
||||
from authentication.const import ConfirmType
|
||||
from common.sdk.im.dingtalk import DingTalk
|
||||
from common.utils.common import get_request_ip
|
||||
from authentication.notifications import OAuthBindMessage
|
||||
@@ -30,7 +30,7 @@ logger = get_logger(__file__)
|
||||
DINGTALK_STATE_SESSION_KEY = '_dingtalk_state'
|
||||
|
||||
|
||||
class DingTalkBaseMixin(PermissionsMixin, View):
|
||||
class DingTalkBaseMixin(UserConfirmRequiredExceptionMixin, PermissionsMixin, View):
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
try:
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
@@ -119,7 +119,7 @@ class DingTalkOAuthMixin(DingTalkBaseMixin, View):
|
||||
|
||||
|
||||
class DingTalkQRBindView(DingTalkQRMixin, View):
|
||||
permission_classes = (IsAuthenticated, IsAuthConfirmTimeValid)
|
||||
permission_classes = (IsAuthenticated, UserConfirmation.require(ConfirmType.ReLogin))
|
||||
|
||||
def get(self, request: HttpRequest):
|
||||
user = request.user
|
||||
|
||||
@@ -8,16 +8,17 @@ from django.db.utils import IntegrityError
|
||||
from rest_framework.permissions import IsAuthenticated, AllowAny
|
||||
from rest_framework.exceptions import APIException
|
||||
|
||||
from users.permissions import IsAuthConfirmTimeValid
|
||||
from users.views import UserVerifyPasswordView
|
||||
from users.models import User
|
||||
from users.views import UserVerifyPasswordView
|
||||
from common.utils import get_logger, FlashMessageUtil
|
||||
from common.utils.random import random_string
|
||||
from common.utils.django import reverse, get_object_or_none
|
||||
from common.mixins.views import PermissionsMixin
|
||||
from common.mixins.views import UserConfirmRequiredExceptionMixin, PermissionsMixin
|
||||
from common.permissions import UserConfirmation
|
||||
from common.sdk.im.feishu import FeiShu, URL
|
||||
from common.utils.common import get_request_ip
|
||||
from authentication import errors
|
||||
from authentication.const import ConfirmType
|
||||
from authentication.mixins import AuthMixin
|
||||
from authentication.notifications import OAuthBindMessage
|
||||
|
||||
@@ -27,7 +28,7 @@ logger = get_logger(__file__)
|
||||
FEISHU_STATE_SESSION_KEY = '_feishu_state'
|
||||
|
||||
|
||||
class FeiShuQRMixin(PermissionsMixin, View):
|
||||
class FeiShuQRMixin(UserConfirmRequiredExceptionMixin, PermissionsMixin, View):
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
try:
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
@@ -89,7 +90,7 @@ class FeiShuQRMixin(PermissionsMixin, View):
|
||||
|
||||
|
||||
class FeiShuQRBindView(FeiShuQRMixin, View):
|
||||
permission_classes = (IsAuthenticated, IsAuthConfirmTimeValid)
|
||||
permission_classes = (IsAuthenticated, UserConfirmation.require(ConfirmType.ReLogin))
|
||||
|
||||
def get(self, request: HttpRequest):
|
||||
user = request.user
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
from __future__ import unicode_literals
|
||||
import os
|
||||
import datetime
|
||||
from typing import Callable
|
||||
|
||||
from django.templatetags.static import static
|
||||
from django.contrib.auth import login as auth_login, logout as auth_logout
|
||||
from django.http import HttpResponse
|
||||
from django.http import HttpResponse, HttpRequest
|
||||
from django.shortcuts import reverse, redirect
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.db import transaction
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import ugettext as _, get_language
|
||||
from django.views.decorators.cache import never_cache
|
||||
from django.views.decorators.csrf import csrf_protect
|
||||
from django.views.decorators.debug import sensitive_post_parameters
|
||||
@@ -21,7 +21,7 @@ from django.conf import settings
|
||||
from django.urls import reverse_lazy
|
||||
from django.contrib.auth import BACKEND_SESSION_KEY
|
||||
|
||||
from common.utils import FlashMessageUtil
|
||||
from common.utils import FlashMessageUtil, static_or_direct
|
||||
from users.utils import (
|
||||
redirect_user_first_login_or_index
|
||||
)
|
||||
@@ -35,10 +35,121 @@ __all__ = [
|
||||
]
|
||||
|
||||
|
||||
class UserLoginContextMixin:
|
||||
get_user_mfa_context: Callable
|
||||
request: HttpRequest
|
||||
|
||||
def get_support_auth_methods(self):
|
||||
auth_methods = [
|
||||
{
|
||||
'name': 'OpenID',
|
||||
'enabled': settings.AUTH_OPENID,
|
||||
'url': reverse('authentication:openid:login'),
|
||||
'logo': static('img/login_oidc_logo.png'),
|
||||
'auto_redirect': True # 是否支持自动重定向
|
||||
},
|
||||
{
|
||||
'name': 'CAS',
|
||||
'enabled': settings.AUTH_CAS,
|
||||
'url': reverse('authentication:cas:cas-login'),
|
||||
'logo': static('img/login_cas_logo.png'),
|
||||
'auto_redirect': True
|
||||
},
|
||||
{
|
||||
'name': 'SAML2',
|
||||
'enabled': settings.AUTH_SAML2,
|
||||
'url': reverse('authentication:saml2:saml2-login'),
|
||||
'logo': static('img/login_saml2_logo.png'),
|
||||
'auto_redirect': True
|
||||
},
|
||||
{
|
||||
'name': settings.AUTH_OAUTH2_PROVIDER,
|
||||
'enabled': settings.AUTH_OAUTH2,
|
||||
'url': reverse('authentication:oauth2:login'),
|
||||
'logo': static_or_direct(settings.AUTH_OAUTH2_LOGO_PATH),
|
||||
'auto_redirect': True
|
||||
},
|
||||
{
|
||||
'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_support_langs():
|
||||
langs = [
|
||||
{
|
||||
'title': '中文(简体)',
|
||||
'code': 'zh-hans'
|
||||
},
|
||||
{
|
||||
'title': 'English',
|
||||
'code': 'en'
|
||||
},
|
||||
{
|
||||
'title': '日本語',
|
||||
'code': 'ja'
|
||||
}
|
||||
]
|
||||
return langs
|
||||
|
||||
def get_current_lang(self):
|
||||
langs = self.get_support_langs()
|
||||
matched_lang = filter(lambda x: x['code'] == get_language(), langs)
|
||||
return next(matched_lang, langs[0])
|
||||
|
||||
@staticmethod
|
||||
def get_forgot_password_url():
|
||||
forgot_password_url = reverse('authentication:forgot-password')
|
||||
forgot_password_url = settings.FORGOT_PASSWORD_URL or forgot_password_url
|
||||
return forgot_password_url
|
||||
|
||||
def get_extra_fields_count(self, context):
|
||||
count = 0
|
||||
if self.get_support_auth_methods():
|
||||
count += 1
|
||||
form = context.get('form')
|
||||
if not form:
|
||||
return count
|
||||
if set(form.fields.keys()) & {'captcha', 'challenge', 'mfa_type'}:
|
||||
count += 1
|
||||
if form.errors or form.non_field_errors():
|
||||
count += 1
|
||||
return count
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context.update({
|
||||
'demo_mode': os.environ.get("DEMO_MODE"),
|
||||
'auth_methods': self.get_support_auth_methods(),
|
||||
'langs': self.get_support_langs(),
|
||||
'current_lang': self.get_current_lang(),
|
||||
'forgot_password_url': self.get_forgot_password_url(),
|
||||
'extra_fields_count': self.get_extra_fields_count(context),
|
||||
**self.get_user_mfa_context(self.request.user)
|
||||
})
|
||||
return context
|
||||
|
||||
|
||||
@method_decorator(sensitive_post_parameters(), name='dispatch')
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(never_cache, name='dispatch')
|
||||
class UserLoginView(mixins.AuthMixin, FormView):
|
||||
class UserLoginView(mixins.AuthMixin, UserLoginContextMixin, FormView):
|
||||
redirect_field_name = 'next'
|
||||
template_name = 'authentication/login.html'
|
||||
|
||||
@@ -106,12 +217,7 @@ class UserLoginView(mixins.AuthMixin, FormView):
|
||||
context = self.get_context_data(form=new_form)
|
||||
self.request.session.set_test_cookie()
|
||||
return self.render_to_response(context)
|
||||
except (
|
||||
errors.MFAUnsetError,
|
||||
errors.PasswordTooSimple,
|
||||
errors.PasswordRequireResetError,
|
||||
errors.PasswordNeedUpdate
|
||||
) as e:
|
||||
except errors.NeedRedirectError as e:
|
||||
return redirect(e.url)
|
||||
except (
|
||||
errors.MFAFailedError,
|
||||
@@ -136,67 +242,6 @@ class UserLoginView(mixins.AuthMixin, FormView):
|
||||
self.request.session[RSA_PRIVATE_KEY] = None
|
||||
self.request.session[RSA_PUBLIC_KEY] = None
|
||||
|
||||
@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'),
|
||||
'auto_redirect': True # 是否支持自动重定向
|
||||
},
|
||||
{
|
||||
'name': 'CAS',
|
||||
'enabled': settings.AUTH_CAS,
|
||||
'url': reverse('authentication:cas:cas-login'),
|
||||
'logo': static('img/login_cas_logo.png'),
|
||||
'auto_redirect': True
|
||||
},
|
||||
{
|
||||
'name': 'SAML2',
|
||||
'enabled': settings.AUTH_SAML2,
|
||||
'url': reverse('authentication:saml2:saml2-login'),
|
||||
'logo': static('img/login_saml2_logo.png'),
|
||||
'auto_redirect': True
|
||||
},
|
||||
{
|
||||
'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 = settings.FORGOT_PASSWORD_URL or forgot_password_url
|
||||
return forgot_password_url
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = {
|
||||
'demo_mode': os.environ.get("DEMO_MODE"),
|
||||
'auth_methods': self.get_support_auth_methods(),
|
||||
'forgot_password_url': self.get_forgot_password_url(),
|
||||
**self.get_user_mfa_context(self.request.user)
|
||||
}
|
||||
kwargs.update(context)
|
||||
return super().get_context_data(**kwargs)
|
||||
|
||||
|
||||
class UserLoginGuardView(mixins.AuthMixin, RedirectView):
|
||||
redirect_field_name = 'next'
|
||||
@@ -258,8 +303,7 @@ class UserLoginWaitConfirmView(TemplateView):
|
||||
if ticket:
|
||||
timestamp_created = datetime.datetime.timestamp(ticket.date_created)
|
||||
ticket_detail_url = TICKET_DETAIL_URL.format(id=ticket_id, type=ticket.type)
|
||||
assignees = ticket.current_node.first().ticket_assignees.all()
|
||||
assignees_display = ', '.join([str(i.assignee) for i in assignees])
|
||||
assignees_display = ', '.join([str(assignee) for assignee in ticket.current_assignees])
|
||||
msg = _("""Wait for <b>{}</b> confirm, You also can copy link to her/him <br/>
|
||||
Don't close this page""").format(assignees_display)
|
||||
else:
|
||||
@@ -286,6 +330,8 @@ class UserLogoutView(TemplateView):
|
||||
return settings.CAS_LOGOUT_URL_NAME
|
||||
elif 'saml2' in backend:
|
||||
return settings.SAML2_LOGOUT_URL_NAME
|
||||
elif 'oauth2' in backend:
|
||||
return settings.AUTH_OAUTH2_LOGOUT_URL_NAME
|
||||
return None
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
|
||||
@@ -8,18 +8,19 @@ from django.db.utils import IntegrityError
|
||||
from rest_framework.permissions import IsAuthenticated, AllowAny
|
||||
from rest_framework.exceptions import APIException
|
||||
|
||||
from users.views import UserVerifyPasswordView
|
||||
from users.models import User
|
||||
from users.permissions import IsAuthConfirmTimeValid
|
||||
from users.views import UserVerifyPasswordView
|
||||
from common.utils import get_logger, FlashMessageUtil
|
||||
from common.utils.random import random_string
|
||||
from common.utils.django import reverse, get_object_or_none
|
||||
from common.sdk.im.wecom import URL
|
||||
from common.sdk.im.wecom import WeCom
|
||||
from common.mixins.views import PermissionsMixin
|
||||
from common.mixins.views import UserConfirmRequiredExceptionMixin, PermissionsMixin
|
||||
from common.utils.common import get_request_ip
|
||||
from common.permissions import UserConfirmation
|
||||
from authentication import errors
|
||||
from authentication.mixins import AuthMixin
|
||||
from authentication.const import ConfirmType
|
||||
from authentication.notifications import OAuthBindMessage
|
||||
from .mixins import METAMixin
|
||||
|
||||
@@ -29,7 +30,7 @@ logger = get_logger(__file__)
|
||||
WECOM_STATE_SESSION_KEY = '_wecom_state'
|
||||
|
||||
|
||||
class WeComBaseMixin(PermissionsMixin, View):
|
||||
class WeComBaseMixin(UserConfirmRequiredExceptionMixin, PermissionsMixin, View):
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
try:
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
@@ -118,7 +119,7 @@ class WeComOAuthMixin(WeComBaseMixin, View):
|
||||
|
||||
|
||||
class WeComQRBindView(WeComQRMixin, View):
|
||||
permission_classes = (IsAuthenticated, IsAuthConfirmTimeValid)
|
||||
permission_classes = (IsAuthenticated, UserConfirmation.require(ConfirmType.ReLogin))
|
||||
|
||||
def get(self, request: HttpRequest):
|
||||
user = request.user
|
||||
@@ -299,5 +300,4 @@ class WeComOAuthLoginCallbackView(AuthMixin, WeComOAuthMixin, View):
|
||||
msg = e.msg
|
||||
response = self.get_failed_response(login_url, title=msg, msg=msg)
|
||||
return response
|
||||
|
||||
return self.redirect_to_guard_view()
|
||||
|
||||
@@ -10,5 +10,5 @@ celery_task_pre_key = "CELERY_"
|
||||
KEY_CACHE_RESOURCE_IDS = "RESOURCE_IDS_{}"
|
||||
|
||||
# AD User AccountDisable
|
||||
# https://blog.csdn.net/bytxl/article/details/17763975
|
||||
# https://docs.microsoft.com/en-us/troubleshoot/windows-server/identity/useraccountcontrol-manipulate-account-properties
|
||||
LDAP_AD_ACCOUNT_DISABLE = 2
|
||||
|
||||
@@ -1,20 +1,32 @@
|
||||
import json
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils import timezone as dj_timezone
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
|
||||
lazy_type = type(_('ugettext_lazy'))
|
||||
|
||||
|
||||
class ModelJSONFieldEncoder(json.JSONEncoder):
|
||||
""" 解决一些类型的字段不能序列化的问题 """
|
||||
|
||||
def default(self, obj):
|
||||
if isinstance(obj, datetime):
|
||||
str_cls = (models.Model, lazy_type, models.ImageField, uuid.UUID)
|
||||
if isinstance(obj, str_cls):
|
||||
return str(obj)
|
||||
elif isinstance(obj, datetime):
|
||||
obj = dj_timezone.localtime(obj)
|
||||
return obj.strftime(settings.DATETIME_DISPLAY_FORMAT)
|
||||
if isinstance(obj, uuid.UUID):
|
||||
return str(obj)
|
||||
if isinstance(obj, type(_("ugettext_lazy"))):
|
||||
return str(obj)
|
||||
elif isinstance(obj, (list, tuple)) and len(obj) > 0 \
|
||||
and isinstance(obj[0], models.Model):
|
||||
return [str(i) for i in obj]
|
||||
else:
|
||||
return super().default(obj)
|
||||
try:
|
||||
return super().default(obj)
|
||||
except TypeError:
|
||||
logging.error('Type error: ', type(obj))
|
||||
return str(obj)
|
||||
|
||||
@@ -13,6 +13,7 @@ import uuid
|
||||
from functools import reduce, partial
|
||||
import inspect
|
||||
|
||||
from django.db import transaction
|
||||
from django.db.models import *
|
||||
from django.db.models import QuerySet
|
||||
from django.db.models.functions import Concat
|
||||
@@ -211,3 +212,29 @@ class UnionQuerySet(QuerySet):
|
||||
|
||||
qs = cls(assets1, assets2)
|
||||
return qs
|
||||
|
||||
|
||||
class MultiTableChildQueryset(QuerySet):
|
||||
|
||||
def bulk_create(self, objs, batch_size=None):
|
||||
assert batch_size is None or batch_size > 0
|
||||
if not objs:
|
||||
return objs
|
||||
|
||||
self._for_write = True
|
||||
objs = list(objs)
|
||||
parent_model = self.model._meta.pk.related_model
|
||||
|
||||
parent_objs = []
|
||||
for obj in objs:
|
||||
parent_values = {}
|
||||
for field in [f for f in parent_model._meta.fields if hasattr(obj, f.name)]:
|
||||
parent_values[field.name] = getattr(obj, field.name)
|
||||
parent_objs.append(parent_model(**parent_values))
|
||||
setattr(obj, self.model._meta.pk.attname, obj.id)
|
||||
parent_model.objects.bulk_create(parent_objs, batch_size=batch_size)
|
||||
|
||||
with transaction.atomic(using=self.db, savepoint=False):
|
||||
self._batched_insert(objs, self.model._meta.local_fields, batch_size)
|
||||
|
||||
return objs
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user