mirror of
https://github.com/jumpserver/jumpserver.git
synced 2025-12-21 19:42:50 +00:00
Compare commits
168 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b30e329ab | ||
|
|
3dc853c7f2 | ||
|
|
932aed97d3 | ||
|
|
c464e95a21 | ||
|
|
0f0af19d49 | ||
|
|
6a54ff8714 | ||
|
|
1e0489bb96 | ||
|
|
980ddcd833 | ||
|
|
1ec2cd6087 | ||
|
|
257ef464f7 | ||
|
|
9cd3cc1da0 | ||
|
|
9520a23f4c | ||
|
|
a4ff9dace3 | ||
|
|
7a1214f358 | ||
|
|
2fdc4c613f | ||
|
|
df6525933a | ||
|
|
6aef27c824 | ||
|
|
26c3409d84 | ||
|
|
3c54c82ce9 | ||
|
|
91dce82b38 | ||
|
|
d102db7a7b | ||
|
|
1de7af4984 | ||
|
|
9892ff7dd6 | ||
|
|
4cb499953c | ||
|
|
0397bdeb46 | ||
|
|
1d6d92c160 | ||
|
|
cdbe5d31e9 | ||
|
|
b023ca0c69 | ||
|
|
803d590096 | ||
|
|
e11367088a | ||
|
|
1c74dd00ba | ||
|
|
ed832af631 | ||
|
|
948c499d9e | ||
|
|
a51549cf1c | ||
|
|
39baf88055 | ||
|
|
90131db55a | ||
|
|
ea3ff1ebcb | ||
|
|
f3ca45aa74 | ||
|
|
74cc174d7a | ||
|
|
0eba6d2175 | ||
|
|
58592a13e3 | ||
|
|
b8fb23a0a0 | ||
|
|
f5c43488fd | ||
|
|
19c76ba01c | ||
|
|
68c4cd5928 | ||
|
|
e5bfa29c7b | ||
|
|
cbb772def7 | ||
|
|
e6fe7c489e | ||
|
|
0b30f5cf88 | ||
|
|
018f1a0e8d | ||
|
|
24ed57b98a | ||
|
|
04a790c4ee | ||
|
|
2d9a3ef7d4 | ||
|
|
0d2adeccf2 | ||
|
|
886f977311 | ||
|
|
9367e79bcf | ||
|
|
af733ecbad | ||
|
|
09f9775eab | ||
|
|
1c2a362beb | ||
|
|
bb1e674367 | ||
|
|
a75677ab08 | ||
|
|
b1daa4d357 | ||
|
|
c32271ec6f | ||
|
|
beb4f14be9 | ||
|
|
e719904874 | ||
|
|
664bc2a4d9 | ||
|
|
b91db8c146 | ||
|
|
500aeeb77f | ||
|
|
3abc8bddfa | ||
|
|
5cbbf9e737 | ||
|
|
7204a86f87 | ||
|
|
829194420a | ||
|
|
61dc95d9ae | ||
|
|
a9f60a9117 | ||
|
|
82f96d6ed2 | ||
|
|
f6c56d4979 | ||
|
|
54d0a1b871 | ||
|
|
5b4a267ccd | ||
|
|
a6d78834e7 | ||
|
|
07da98e438 | ||
|
|
7c973616cd | ||
|
|
b9997b07db | ||
|
|
bcda879f3b | ||
|
|
d0f79c2df2 | ||
|
|
1249935bab | ||
|
|
5fa1ae9ee5 | ||
|
|
d0755c4719 | ||
|
|
72b215ed03 | ||
|
|
d7ca1a09d4 | ||
|
|
04e341a1bb | ||
|
|
a41909ec8d | ||
|
|
f9d6de9c39 | ||
|
|
816b284a51 | ||
|
|
d4c5dcf069 | ||
|
|
73037c21e8 | ||
|
|
c7f9259a2e | ||
|
|
8632bd2480 | ||
|
|
23723f4eda | ||
|
|
38601a84c2 | ||
|
|
e50189e284 | ||
|
|
da9bd11db5 | ||
|
|
9acb7d6183 | ||
|
|
dbd9a9fdac | ||
|
|
25301aa396 | ||
|
|
8cc1ca2770 | ||
|
|
bad01aefa2 | ||
|
|
56a989bfb9 | ||
|
|
578f66d5e2 | ||
|
|
8d6083bfb2 | ||
|
|
1138cd3334 | ||
|
|
db0b43ee84 | ||
|
|
40a460870a | ||
|
|
51910ea2c1 | ||
|
|
266a360a97 | ||
|
|
24194b4e4d | ||
|
|
992e34d652 | ||
|
|
894249a3d1 | ||
|
|
21c6fe19a1 | ||
|
|
e4e4f82143 | ||
|
|
2a5c635dc5 | ||
|
|
7dbaa28539 | ||
|
|
5bae4cde58 | ||
|
|
35c0d7be35 | ||
|
|
1f2a4b0fb5 | ||
|
|
7c3a3d599b | ||
|
|
d70770775a | ||
|
|
bc217e1bad | ||
|
|
d4469aeaf7 | ||
|
|
904406c5c1 | ||
|
|
09db2ad3e1 | ||
|
|
859268f7f3 | ||
|
|
72bb5a4037 | ||
|
|
6f3871d5fe | ||
|
|
2f0c346365 | ||
|
|
e9c090f656 | ||
|
|
7b0b07cf52 | ||
|
|
bebb90f688 | ||
|
|
ac14a70c51 | ||
|
|
642f92c0a3 | ||
|
|
04f4ecb3d1 | ||
|
|
60703c920c | ||
|
|
9634f397df | ||
|
|
f9a7a95191 | ||
|
|
bced33fd93 | ||
|
|
1044ff004b | ||
|
|
e11c7a264e | ||
|
|
3c497aa81e | ||
|
|
c8a1f4b092 | ||
|
|
9dd2dc8907 | ||
|
|
56285d906f | ||
|
|
44b536a23b | ||
|
|
a97003a03a | ||
|
|
4315cbe6d0 | ||
|
|
b2d9670721 | ||
|
|
78f66c46e8 | ||
|
|
f3af9c3108 | ||
|
|
822a124dbc | ||
|
|
20799ece93 | ||
|
|
4e2c7d7aab | ||
|
|
75e4895314 | ||
|
|
ea7b409a7f | ||
|
|
01d10a25e9 | ||
|
|
61ce39b4ba | ||
|
|
7506c7ea43 | ||
|
|
f6f162ec3a | ||
|
|
2e840e3b05 | ||
|
|
ff4560c2a7 | ||
|
|
deeb8da226 |
1
.github/ISSUE_TEMPLATE/----.md
vendored
1
.github/ISSUE_TEMPLATE/----.md
vendored
@@ -6,7 +6,6 @@ labels: 类型:需求
|
||||
assignees:
|
||||
- ibuler
|
||||
- baijiangjie
|
||||
- wojiushixiaobai
|
||||
---
|
||||
|
||||
**请描述您的需求或者改进建议.**
|
||||
|
||||
4
.github/ISSUE_TEMPLATE/bug---.md
vendored
4
.github/ISSUE_TEMPLATE/bug---.md
vendored
@@ -2,11 +2,9 @@
|
||||
name: Bug 提交
|
||||
about: 提交产品缺陷帮助我们更好的改进
|
||||
title: "[Bug] "
|
||||
labels: 类型:bug
|
||||
labels: 类型:Bug
|
||||
assignees:
|
||||
- wojiushixiaobai
|
||||
- baijiangjie
|
||||
|
||||
---
|
||||
|
||||
**JumpServer 版本( v2.28 之前的版本不再支持 )**
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/question.md
vendored
2
.github/ISSUE_TEMPLATE/question.md
vendored
@@ -4,9 +4,7 @@ about: 提出针对本项目安装部署、使用及其他方面的相关问题
|
||||
title: "[Question] "
|
||||
labels: 类型:提问
|
||||
assignees:
|
||||
- wojiushixiaobai
|
||||
- baijiangjie
|
||||
|
||||
---
|
||||
|
||||
**请描述您的问题.**
|
||||
|
||||
@@ -36,9 +36,11 @@ ARG TOOLS=" \
|
||||
curl \
|
||||
default-libmysqlclient-dev \
|
||||
default-mysql-client \
|
||||
iputils-ping \
|
||||
locales \
|
||||
nmap \
|
||||
openssh-client \
|
||||
patch \
|
||||
sshpass \
|
||||
telnet \
|
||||
vim \
|
||||
|
||||
@@ -6,11 +6,11 @@ from rest_framework.status import HTTP_200_OK
|
||||
|
||||
from accounts import serializers
|
||||
from accounts.filters import AccountFilterSet
|
||||
from accounts.mixins import AccountRecordViewLogMixin
|
||||
from accounts.models import Account
|
||||
from assets.models import Asset, Node
|
||||
from common.api import ExtraFilterFieldsMixin
|
||||
from common.api.mixin import ExtraFilterFieldsMixin
|
||||
from common.permissions import UserConfirmation, ConfirmType, IsValidUser
|
||||
from common.views.mixins import RecordViewLogMixin
|
||||
from orgs.mixins.api import OrgBulkModelViewSet
|
||||
from rbac.permissions import RBACPermission
|
||||
|
||||
@@ -57,19 +57,19 @@ class AccountViewSet(OrgBulkModelViewSet):
|
||||
permission_classes=[IsValidUser]
|
||||
)
|
||||
def username_suggestions(self, request, *args, **kwargs):
|
||||
asset_ids = request.data.get('assets')
|
||||
node_ids = request.data.get('nodes')
|
||||
username = request.data.get('username')
|
||||
asset_ids = request.data.get('assets', [])
|
||||
node_ids = request.data.get('nodes', [])
|
||||
username = request.data.get('username', '')
|
||||
|
||||
assets = Asset.objects.all()
|
||||
if asset_ids:
|
||||
assets = assets.filter(id__in=asset_ids)
|
||||
accounts = Account.objects.all()
|
||||
if node_ids:
|
||||
nodes = Node.objects.filter(id__in=node_ids)
|
||||
node_asset_ids = Node.get_nodes_all_assets(*nodes).values_list('id', flat=True)
|
||||
assets = assets.filter(id__in=set(list(asset_ids) + list(node_asset_ids)))
|
||||
asset_ids.extend(node_asset_ids)
|
||||
|
||||
if asset_ids:
|
||||
accounts = accounts.filter(asset_id__in=list(set(asset_ids)))
|
||||
|
||||
accounts = Account.objects.filter(asset__in=assets)
|
||||
if username:
|
||||
accounts = accounts.filter(username__icontains=username)
|
||||
usernames = list(accounts.values_list('username', flat=True).distinct()[:10])
|
||||
@@ -86,7 +86,7 @@ class AccountViewSet(OrgBulkModelViewSet):
|
||||
return Response(status=HTTP_200_OK)
|
||||
|
||||
|
||||
class AccountSecretsViewSet(RecordViewLogMixin, AccountViewSet):
|
||||
class AccountSecretsViewSet(AccountRecordViewLogMixin, AccountViewSet):
|
||||
"""
|
||||
因为可能要导出所有账号,所以单独建立了一个 viewset
|
||||
"""
|
||||
@@ -115,7 +115,7 @@ class AssetAccountBulkCreateApi(CreateAPIView):
|
||||
return Response(data=serializer.data, status=HTTP_200_OK)
|
||||
|
||||
|
||||
class AccountHistoriesSecretAPI(ExtraFilterFieldsMixin, RecordViewLogMixin, ListAPIView):
|
||||
class AccountHistoriesSecretAPI(ExtraFilterFieldsMixin, AccountRecordViewLogMixin, ListAPIView):
|
||||
model = Account.history.model
|
||||
serializer_class = serializers.AccountHistorySerializer
|
||||
http_method_names = ['get', 'options']
|
||||
@@ -143,4 +143,3 @@ class AccountHistoriesSecretAPI(ExtraFilterFieldsMixin, RecordViewLogMixin, List
|
||||
return histories
|
||||
histories = histories.exclude(history_id=latest_history.history_id)
|
||||
return histories
|
||||
|
||||
|
||||
@@ -19,7 +19,9 @@ class AccountsTaskCreateAPI(CreateAPIView):
|
||||
code = 'accounts.push_account'
|
||||
else:
|
||||
code = 'accounts.verify_account'
|
||||
return request.user.has_perm(code)
|
||||
has = request.user.has_perm(code)
|
||||
if not has:
|
||||
self.permission_denied(request)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
data = serializer.validated_data
|
||||
@@ -44,6 +46,6 @@ class AccountsTaskCreateAPI(CreateAPIView):
|
||||
|
||||
def get_exception_handler(self):
|
||||
def handler(e, context):
|
||||
return Response({"error": str(e)}, status=400)
|
||||
return Response({"error": str(e)}, status=401)
|
||||
|
||||
return handler
|
||||
|
||||
@@ -4,10 +4,10 @@ from rest_framework.response import Response
|
||||
|
||||
from accounts import serializers
|
||||
from accounts.models import AccountTemplate
|
||||
from accounts.mixins import AccountRecordViewLogMixin
|
||||
from assets.const import Protocol
|
||||
from common.drf.filters import BaseFilterSet
|
||||
from common.permissions import UserConfirmation, ConfirmType
|
||||
from common.views.mixins import RecordViewLogMixin
|
||||
from orgs.mixins.api import OrgBulkModelViewSet
|
||||
from rbac.permissions import RBACPermission
|
||||
|
||||
@@ -55,7 +55,7 @@ class AccountTemplateViewSet(OrgBulkModelViewSet):
|
||||
return Response(data=serializer.data)
|
||||
|
||||
|
||||
class AccountTemplateSecretsViewSet(RecordViewLogMixin, AccountTemplateViewSet):
|
||||
class AccountTemplateSecretsViewSet(AccountRecordViewLogMixin, AccountTemplateViewSet):
|
||||
serializer_classes = {
|
||||
'default': serializers.AccountTemplateSecretSerializer,
|
||||
}
|
||||
|
||||
@@ -11,9 +11,9 @@
|
||||
login_host: "{{ jms_asset.address }}"
|
||||
login_port: "{{ jms_asset.port }}"
|
||||
login_database: "{{ jms_asset.spec_info.db_name }}"
|
||||
ssl: "{{ jms_asset.spec_info.use_ssl }}"
|
||||
ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert }}"
|
||||
ssl_certfile: "{{ jms_asset.secret_info.client_key }}"
|
||||
ssl: "{{ jms_asset.spec_info.use_ssl | default('') }}"
|
||||
ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert | default('') }}"
|
||||
ssl_certfile: "{{ jms_asset.secret_info.client_key | default('') }}"
|
||||
connection_options:
|
||||
- tlsAllowInvalidHostnames: "{{ jms_asset.spec_info.allow_invalid_cert}}"
|
||||
register: db_info
|
||||
@@ -31,8 +31,8 @@
|
||||
login_port: "{{ jms_asset.port }}"
|
||||
login_database: "{{ jms_asset.spec_info.db_name }}"
|
||||
ssl: "{{ jms_asset.spec_info.use_ssl }}"
|
||||
ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert }}"
|
||||
ssl_certfile: "{{ jms_asset.secret_info.client_key }}"
|
||||
ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert | default('') }}"
|
||||
ssl_certfile: "{{ jms_asset.secret_info.client_key | default('') }}"
|
||||
connection_options:
|
||||
- tlsAllowInvalidHostnames: "{{ jms_asset.spec_info.allow_invalid_cert}}"
|
||||
db: "{{ jms_asset.spec_info.db_name }}"
|
||||
@@ -49,7 +49,7 @@
|
||||
login_port: "{{ jms_asset.port }}"
|
||||
login_database: "{{ jms_asset.spec_info.db_name }}"
|
||||
ssl: "{{ jms_asset.spec_info.use_ssl }}"
|
||||
ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert }}"
|
||||
ssl_certfile: "{{ jms_asset.secret_info.client_key }}"
|
||||
ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert | default('') }}"
|
||||
ssl_certfile: "{{ jms_asset.secret_info.client_key | default('') }}"
|
||||
connection_options:
|
||||
- tlsAllowInvalidHostnames: "{{ jms_asset.spec_info.allow_invalid_cert}}"
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
login_password: "{{ jms_account.secret }}"
|
||||
login_host: "{{ jms_asset.address }}"
|
||||
login_port: "{{ jms_asset.port }}"
|
||||
check_hostname: "{{ jms_asset.spec_info.use_ssl and not jms_asset.spec_info.allow_invalid_cert }}"
|
||||
ca_cert: "{{ jms_asset.secret_info.ca_cert | default(omit) }}"
|
||||
client_cert: "{{ jms_asset.secret_info.client_cert | default(omit) }}"
|
||||
client_key: "{{ jms_asset.secret_info.client_key | default(omit) }}"
|
||||
filter: version
|
||||
register: db_info
|
||||
|
||||
@@ -24,6 +28,10 @@
|
||||
login_password: "{{ jms_account.secret }}"
|
||||
login_host: "{{ jms_asset.address }}"
|
||||
login_port: "{{ jms_asset.port }}"
|
||||
check_hostname: "{{ jms_asset.spec_info.use_ssl and not jms_asset.spec_info.allow_invalid_cert }}"
|
||||
ca_cert: "{{ jms_asset.secret_info.ca_cert | default(omit) }}"
|
||||
client_cert: "{{ jms_asset.secret_info.client_cert | default(omit) }}"
|
||||
client_key: "{{ jms_asset.secret_info.client_key | default(omit) }}"
|
||||
name: "{{ account.username }}"
|
||||
password: "{{ account.secret }}"
|
||||
host: "%"
|
||||
@@ -37,4 +45,8 @@
|
||||
login_password: "{{ account.secret }}"
|
||||
login_host: "{{ jms_asset.address }}"
|
||||
login_port: "{{ jms_asset.port }}"
|
||||
check_hostname: "{{ jms_asset.spec_info.use_ssl and not jms_asset.spec_info.allow_invalid_cert }}"
|
||||
ca_cert: "{{ jms_asset.secret_info.ca_cert | default(omit) }}"
|
||||
client_cert: "{{ jms_asset.secret_info.client_cert | default(omit) }}"
|
||||
client_key: "{{ jms_asset.secret_info.client_key | default(omit) }}"
|
||||
filter: version
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
login_host: "{{ jms_asset.address }}"
|
||||
login_port: "{{ jms_asset.port }}"
|
||||
name: '{{ jms_asset.spec_info.db_name }}'
|
||||
script: "ALTER LOGIN {{ account.username }} WITH PASSWORD = '{{ account.secret }}'; select @@version"
|
||||
script: "ALTER LOGIN {{ account.username }} WITH PASSWORD = '{{ account.secret }}', DEFAULT_DATABASE = {{ jms_asset.spec_info.db_name }}; select @@version"
|
||||
ignore_errors: true
|
||||
when: user_exist.query_results[0] | length != 0
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
login_host: "{{ jms_asset.address }}"
|
||||
login_port: "{{ jms_asset.port }}"
|
||||
name: '{{ jms_asset.spec_info.db_name }}'
|
||||
script: "CREATE LOGIN {{ account.username }} WITH PASSWORD = '{{ account.secret }}'; select @@version"
|
||||
script: "CREATE LOGIN {{ account.username }} WITH PASSWORD = '{{ account.secret }}', DEFAULT_DATABASE = {{ jms_asset.spec_info.db_name }}; CREATE USER {{ account.username }} FOR LOGIN {{ account.username }}; select @@version"
|
||||
ignore_errors: true
|
||||
when: user_exist.query_results[0] | length == 0
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
login_port: "{{ jms_asset.port }}"
|
||||
login_database: "{{ jms_asset.spec_info.db_name }}"
|
||||
ssl: "{{ jms_asset.spec_info.use_ssl }}"
|
||||
ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert }}"
|
||||
ssl_certfile: "{{ jms_asset.secret_info.client_key }}"
|
||||
ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert | default('') }}"
|
||||
ssl_certfile: "{{ jms_asset.secret_info.client_key | default('') }}"
|
||||
connection_options:
|
||||
- tlsAllowInvalidHostnames: "{{ jms_asset.spec_info.allow_invalid_cert}}"
|
||||
filter: users
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
login_password: "{{ jms_account.secret }}"
|
||||
login_host: "{{ jms_asset.address }}"
|
||||
login_port: "{{ jms_asset.port }}"
|
||||
check_hostname: "{{ jms_asset.spec_info.use_ssl and not jms_asset.spec_info.allow_invalid_cert }}"
|
||||
ca_cert: "{{ jms_asset.secret_info.ca_cert | default(omit) }}"
|
||||
client_cert: "{{ jms_asset.secret_info.client_cert | default(omit) }}"
|
||||
client_key: "{{ jms_asset.secret_info.client_key | default(omit) }}"
|
||||
filter: users
|
||||
register: db_info
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
login_port: "{{ jms_asset.port }}"
|
||||
login_database: "{{ jms_asset.spec_info.db_name }}"
|
||||
ssl: "{{ jms_asset.spec_info.use_ssl }}"
|
||||
ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert }}"
|
||||
ssl_certfile: "{{ jms_asset.secret_info.client_key }}"
|
||||
ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert | default('') }}"
|
||||
ssl_certfile: "{{ jms_asset.secret_info.client_key | default('') }}"
|
||||
connection_options:
|
||||
- tlsAllowInvalidHostnames: "{{ jms_asset.spec_info.allow_invalid_cert}}"
|
||||
register: db_info
|
||||
@@ -31,8 +31,8 @@
|
||||
login_port: "{{ jms_asset.port }}"
|
||||
login_database: "{{ jms_asset.spec_info.db_name }}"
|
||||
ssl: "{{ jms_asset.spec_info.use_ssl }}"
|
||||
ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert }}"
|
||||
ssl_certfile: "{{ jms_asset.secret_info.client_key }}"
|
||||
ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert | default('') }}"
|
||||
ssl_certfile: "{{ jms_asset.secret_info.client_key | default('') }}"
|
||||
connection_options:
|
||||
- tlsAllowInvalidHostnames: "{{ jms_asset.spec_info.allow_invalid_cert}}"
|
||||
db: "{{ jms_asset.spec_info.db_name }}"
|
||||
@@ -49,7 +49,7 @@
|
||||
login_port: "{{ jms_asset.port }}"
|
||||
login_database: "{{ jms_asset.spec_info.db_name }}"
|
||||
ssl: "{{ jms_asset.spec_info.use_ssl }}"
|
||||
ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert }}"
|
||||
ssl_certfile: "{{ jms_asset.secret_info.client_key }}"
|
||||
ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert | default('') }}"
|
||||
ssl_certfile: "{{ jms_asset.secret_info.client_key | default('') }}"
|
||||
connection_options:
|
||||
- tlsAllowInvalidHostnames: "{{ jms_asset.spec_info.allow_invalid_cert}}"
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
login_password: "{{ jms_account.secret }}"
|
||||
login_host: "{{ jms_asset.address }}"
|
||||
login_port: "{{ jms_asset.port }}"
|
||||
check_hostname: "{{ jms_asset.spec_info.use_ssl and not jms_asset.spec_info.allow_invalid_cert }}"
|
||||
ca_cert: "{{ jms_asset.secret_info.ca_cert | default(omit) }}"
|
||||
client_cert: "{{ jms_asset.secret_info.client_cert | default(omit) }}"
|
||||
client_key: "{{ jms_asset.secret_info.client_key | default(omit) }}"
|
||||
filter: version
|
||||
register: db_info
|
||||
|
||||
@@ -24,6 +28,10 @@
|
||||
login_password: "{{ jms_account.secret }}"
|
||||
login_host: "{{ jms_asset.address }}"
|
||||
login_port: "{{ jms_asset.port }}"
|
||||
check_hostname: "{{ jms_asset.spec_info.use_ssl and not jms_asset.spec_info.allow_invalid_cert }}"
|
||||
ca_cert: "{{ jms_asset.secret_info.ca_cert | default(omit) }}"
|
||||
client_cert: "{{ jms_asset.secret_info.client_cert | default(omit) }}"
|
||||
client_key: "{{ jms_asset.secret_info.client_key | default(omit) }}"
|
||||
name: "{{ account.username }}"
|
||||
password: "{{ account.secret }}"
|
||||
host: "%"
|
||||
@@ -37,4 +45,8 @@
|
||||
login_password: "{{ account.secret }}"
|
||||
login_host: "{{ jms_asset.address }}"
|
||||
login_port: "{{ jms_asset.port }}"
|
||||
check_hostname: "{{ jms_asset.spec_info.use_ssl and not jms_asset.spec_info.allow_invalid_cert }}"
|
||||
ca_cert: "{{ jms_asset.secret_info.ca_cert | default(omit) }}"
|
||||
client_cert: "{{ jms_asset.secret_info.client_cert | default(omit) }}"
|
||||
client_key: "{{ jms_asset.secret_info.client_key | default(omit) }}"
|
||||
filter: version
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
role_attr_flags: LOGIN
|
||||
ignore_errors: true
|
||||
when: result is succeeded
|
||||
register: change_info
|
||||
|
||||
- name: Verify password
|
||||
community.postgresql.postgresql_ping:
|
||||
@@ -42,3 +43,5 @@
|
||||
when:
|
||||
- result is succeeded
|
||||
- change_info is succeeded
|
||||
register: result
|
||||
failed_when: not result.is_available
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
login_host: "{{ jms_asset.address }}"
|
||||
login_port: "{{ jms_asset.port }}"
|
||||
name: '{{ jms_asset.spec_info.db_name }}'
|
||||
script: "ALTER LOGIN {{ account.username }} WITH PASSWORD = '{{ account.secret }}'; select @@version"
|
||||
script: "ALTER LOGIN {{ account.username }} WITH PASSWORD = '{{ account.secret }}', DEFAULT_DATABASE = {{ jms_asset.spec_info.db_name }}; select @@version"
|
||||
ignore_errors: true
|
||||
when: user_exist.query_results[0] | length != 0
|
||||
register: change_info
|
||||
@@ -52,7 +52,7 @@
|
||||
login_host: "{{ jms_asset.address }}"
|
||||
login_port: "{{ jms_asset.port }}"
|
||||
name: '{{ jms_asset.spec_info.db_name }}'
|
||||
script: "CREATE LOGIN {{ account.username }} WITH PASSWORD = '{{ account.secret }}'; select @@version"
|
||||
script: "CREATE LOGIN [{{ account.username }}] WITH PASSWORD = '{{ account.secret }}'; CREATE USER [{{ account.username }}] FOR LOGIN [{{ account.username }}]; select @@version"
|
||||
ignore_errors: true
|
||||
when: user_exist.query_results[0] | length == 0
|
||||
register: change_info
|
||||
|
||||
@@ -80,7 +80,7 @@ class PushAccountManager(ChangeSecretManager, AccountBasePlaybookManager):
|
||||
pass
|
||||
|
||||
def on_runner_failed(self, runner, e):
|
||||
logger.error("Pust account error: ", e)
|
||||
logger.error("Pust account error: {}".format(e))
|
||||
|
||||
def run(self, *args, **kwargs):
|
||||
if self.secret_type and not self.check_secret():
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
login_port: "{{ jms_asset.port }}"
|
||||
login_database: "{{ jms_asset.spec_info.db_name }}"
|
||||
ssl: "{{ jms_asset.spec_info.use_ssl }}"
|
||||
ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert }}"
|
||||
ssl_certfile: "{{ jms_asset.secret_info.client_key }}"
|
||||
ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert | default('') }}"
|
||||
ssl_certfile: "{{ jms_asset.secret_info.client_key | default('') }}"
|
||||
connection_options:
|
||||
- tlsAllowInvalidHostnames: "{{ jms_asset.spec_info.allow_invalid_cert}}"
|
||||
- tlsAllowInvalidHostnames: "{{ jms_asset.spec_info.allow_invalid_cert }}"
|
||||
|
||||
@@ -10,4 +10,8 @@
|
||||
login_password: "{{ account.secret }}"
|
||||
login_host: "{{ jms_asset.address }}"
|
||||
login_port: "{{ jms_asset.port }}"
|
||||
check_hostname: "{{ jms_asset.spec_info.use_ssl and not jms_asset.spec_info.allow_invalid_cert }}"
|
||||
ca_cert: "{{ jms_asset.secret_info.ca_cert | default(omit) }}"
|
||||
client_cert: "{{ jms_asset.secret_info.client_cert | default(omit) }}"
|
||||
client_key: "{{ jms_asset.secret_info.client_key | default(omit) }}"
|
||||
filter: version
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
vars:
|
||||
ansible_python_interpreter: /usr/local/bin/python
|
||||
|
||||
|
||||
tasks:
|
||||
- name: Verify account
|
||||
community.postgresql.postgresql_ping:
|
||||
|
||||
@@ -4,11 +4,13 @@ from django.utils.translation import gettext_lazy as _
|
||||
from assets.const import Connectivity
|
||||
from common.db.fields import TreeChoices
|
||||
|
||||
string_punctuation = '!#$%&()*+,-.:;<=>?@[]^_~'
|
||||
DEFAULT_PASSWORD_LENGTH = 30
|
||||
DEFAULT_PASSWORD_RULES = {
|
||||
'length': DEFAULT_PASSWORD_LENGTH,
|
||||
'symbol_set': string_punctuation
|
||||
'uppercase': True,
|
||||
'lowercase': True,
|
||||
'digit': True,
|
||||
'symbol': True,
|
||||
}
|
||||
|
||||
__all__ = [
|
||||
@@ -41,8 +43,8 @@ class AutomationTypes(models.TextChoices):
|
||||
|
||||
|
||||
class SecretStrategy(models.TextChoices):
|
||||
custom = 'specific', _('Specific password')
|
||||
random = 'random', _('Random')
|
||||
custom = 'specific', _('Specific secret')
|
||||
random = 'random', _('Random generate')
|
||||
|
||||
|
||||
class SSHKeyStrategy(models.TextChoices):
|
||||
|
||||
@@ -113,7 +113,7 @@ class Migration(migrations.Migration):
|
||||
('comment', models.TextField(blank=True, default='', verbose_name='Comment')),
|
||||
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
|
||||
('old_secret', common.db.fields.EncryptTextField(blank=True, null=True, verbose_name='Old secret')),
|
||||
('new_secret', common.db.fields.EncryptTextField(blank=True, null=True, verbose_name='Secret')),
|
||||
('new_secret', common.db.fields.EncryptTextField(blank=True, null=True, verbose_name='New secret')),
|
||||
('date_started', models.DateTimeField(blank=True, null=True, verbose_name='Date started')),
|
||||
('date_finished', models.DateTimeField(blank=True, null=True, verbose_name='Date finished')),
|
||||
('status', models.CharField(default='pending', max_length=16)),
|
||||
|
||||
@@ -13,11 +13,11 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='changesecretautomation',
|
||||
name='secret_strategy',
|
||||
field=models.CharField(choices=[('specific', 'Specific password'), ('random', 'Random')], default='specific', max_length=16, verbose_name='Secret strategy'),
|
||||
field=models.CharField(choices=[('specific', 'Specific secret'), ('random', 'Random generate')], default='specific', max_length=16, verbose_name='Secret strategy'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='pushaccountautomation',
|
||||
name='secret_strategy',
|
||||
field=models.CharField(choices=[('specific', 'Specific password'), ('random', 'Random')], default='specific', max_length=16, verbose_name='Secret strategy'),
|
||||
field=models.CharField(choices=[('specific', 'Specific secret'), ('random', 'Random generate')], default='specific', max_length=16, verbose_name='Secret strategy'),
|
||||
),
|
||||
]
|
||||
|
||||
34
apps/accounts/migrations/0015_auto_20230825_1120.py
Normal file
34
apps/accounts/migrations/0015_auto_20230825_1120.py
Normal file
@@ -0,0 +1,34 @@
|
||||
# Generated by Django 4.1.10 on 2023-08-25 03:19
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('assets', '0122_auto_20230803_1553'),
|
||||
('accounts', '0014_virtualaccount'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='accounttemplate',
|
||||
name='auto_push',
|
||||
field=models.BooleanField(default=False, verbose_name='Auto push'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='accounttemplate',
|
||||
name='platforms',
|
||||
field=models.ManyToManyField(related_name='account_templates', to='assets.platform', verbose_name='Platforms', blank=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='accounttemplate',
|
||||
name='push_params',
|
||||
field=models.JSONField(default=dict, verbose_name='Push params'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='accounttemplate',
|
||||
name='secret_strategy',
|
||||
field=models.CharField(choices=[('specific', 'Specific secret'), ('random', 'Random generate')], default='specific', max_length=16, verbose_name='Secret strategy'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.1.10 on 2023-09-18 08:09
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0015_auto_20230825_1120'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='accounttemplate',
|
||||
name='password_rules',
|
||||
field=models.JSONField(default=dict, verbose_name='Password rules'),
|
||||
),
|
||||
]
|
||||
75
apps/accounts/mixins.py
Normal file
75
apps/accounts/mixins.py
Normal file
@@ -0,0 +1,75 @@
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from django.utils import translation
|
||||
from django.utils.translation import gettext_noop
|
||||
|
||||
from audits.const import ActionChoices
|
||||
from common.views.mixins import RecordViewLogMixin
|
||||
from common.utils import i18n_fmt
|
||||
|
||||
|
||||
class AccountRecordViewLogMixin(RecordViewLogMixin):
|
||||
get_object: callable
|
||||
get_queryset: callable
|
||||
|
||||
@staticmethod
|
||||
def _filter_params(params):
|
||||
new_params = {}
|
||||
need_pop_params = ('format', 'order')
|
||||
for key, value in params.items():
|
||||
if key in need_pop_params:
|
||||
continue
|
||||
if isinstance(value, list):
|
||||
value = list(filter(None, value))
|
||||
if value:
|
||||
new_params[key] = value
|
||||
return new_params
|
||||
|
||||
def get_resource_display(self, request):
|
||||
query_params = dict(request.query_params)
|
||||
params = self._filter_params(query_params)
|
||||
|
||||
spm_filter = params.pop("spm", None)
|
||||
|
||||
if not params and not spm_filter:
|
||||
display_message = gettext_noop("Export all")
|
||||
elif spm_filter:
|
||||
display_message = gettext_noop("Export only selected items")
|
||||
else:
|
||||
query = ",".join(
|
||||
["%s=%s" % (key, value) for key, value in params.items()]
|
||||
)
|
||||
display_message = i18n_fmt(gettext_noop("Export filtered: %s"), query)
|
||||
return display_message
|
||||
|
||||
@property
|
||||
def detail_msg(self):
|
||||
return i18n_fmt(
|
||||
gettext_noop('User %s view/export secret'), self.request.user
|
||||
)
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
list_func = getattr(super(), 'list')
|
||||
if not callable(list_func):
|
||||
return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED)
|
||||
response = list_func(request, *args, **kwargs)
|
||||
with translation.override('en'):
|
||||
resource_display = self.get_resource_display(request)
|
||||
ids = [q.id for q in self.get_queryset()]
|
||||
self.record_logs(
|
||||
ids, ActionChoices.view, self.detail_msg, resource_display=resource_display
|
||||
)
|
||||
return response
|
||||
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
retrieve_func = getattr(super(), 'retrieve')
|
||||
if not callable(retrieve_func):
|
||||
return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED)
|
||||
response = retrieve_func(request, *args, **kwargs)
|
||||
with translation.override('en'):
|
||||
resource = self.get_object()
|
||||
self.record_logs(
|
||||
[resource.id], ActionChoices.view, self.detail_msg, resource=resource
|
||||
)
|
||||
return response
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from .account import *
|
||||
from .automations import *
|
||||
from .base import *
|
||||
from .template import *
|
||||
from .virtual import *
|
||||
from .account import * # noqa
|
||||
from .base import * # noqa
|
||||
from .automations import * # noqa
|
||||
from .template import * # noqa
|
||||
from .virtual import * # noqa
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from accounts.const import SSHKeyStrategy
|
||||
from accounts.models import Account, SecretWithRandomMixin
|
||||
from accounts.tasks import execute_account_automation_task
|
||||
from assets.models.automations import (
|
||||
BaseAutomation as AssetBaseAutomation,
|
||||
AutomationExecution as AssetAutomationExecution
|
||||
)
|
||||
|
||||
__all__ = ['AccountBaseAutomation', 'AutomationExecution']
|
||||
__all__ = ['AccountBaseAutomation', 'AutomationExecution', 'ChangeSecretMixin']
|
||||
|
||||
|
||||
class AccountBaseAutomation(AssetBaseAutomation):
|
||||
@@ -43,3 +46,40 @@ class AutomationExecution(AssetAutomationExecution):
|
||||
from accounts.automations.endpoint import ExecutionManager
|
||||
manager = ExecutionManager(execution=self)
|
||||
return manager.run()
|
||||
|
||||
|
||||
class ChangeSecretMixin(SecretWithRandomMixin):
|
||||
ssh_key_change_strategy = models.CharField(
|
||||
choices=SSHKeyStrategy.choices, max_length=16,
|
||||
default=SSHKeyStrategy.add, verbose_name=_('SSH key change strategy')
|
||||
)
|
||||
get_all_assets: callable # get all assets
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def create_nonlocal_accounts(self, usernames, asset):
|
||||
pass
|
||||
|
||||
def get_account_ids(self):
|
||||
usernames = self.accounts
|
||||
accounts = Account.objects.none()
|
||||
for asset in self.get_all_assets():
|
||||
self.create_nonlocal_accounts(usernames, asset)
|
||||
accounts = accounts | asset.accounts.all()
|
||||
account_ids = accounts.filter(
|
||||
username__in=usernames, secret_type=self.secret_type
|
||||
).values_list('id', flat=True)
|
||||
return [str(_id) for _id in account_ids]
|
||||
|
||||
def to_attr_json(self):
|
||||
attr_json = super().to_attr_json()
|
||||
attr_json.update({
|
||||
'secret': self.secret,
|
||||
'secret_type': self.secret_type,
|
||||
'accounts': self.get_account_ids(),
|
||||
'password_rules': self.password_rules,
|
||||
'secret_strategy': self.secret_strategy,
|
||||
'ssh_key_change_strategy': self.ssh_key_change_strategy,
|
||||
})
|
||||
return attr_json
|
||||
|
||||
@@ -2,62 +2,13 @@ from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from accounts.const import (
|
||||
AutomationTypes, SecretType, SecretStrategy, SSHKeyStrategy
|
||||
AutomationTypes
|
||||
)
|
||||
from accounts.models import Account
|
||||
from common.db import fields
|
||||
from common.db.models import JMSBaseModel
|
||||
from .base import AccountBaseAutomation
|
||||
from .base import AccountBaseAutomation, ChangeSecretMixin
|
||||
|
||||
__all__ = ['ChangeSecretAutomation', 'ChangeSecretRecord', 'ChangeSecretMixin']
|
||||
|
||||
|
||||
class ChangeSecretMixin(models.Model):
|
||||
secret_type = models.CharField(
|
||||
choices=SecretType.choices, max_length=16,
|
||||
default=SecretType.PASSWORD, verbose_name=_('Secret type')
|
||||
)
|
||||
secret = fields.EncryptTextField(blank=True, null=True, verbose_name=_('Secret'))
|
||||
secret_strategy = models.CharField(
|
||||
choices=SecretStrategy.choices, max_length=16,
|
||||
default=SecretStrategy.custom, verbose_name=_('Secret strategy')
|
||||
)
|
||||
password_rules = models.JSONField(default=dict, verbose_name=_('Password rules'))
|
||||
ssh_key_change_strategy = models.CharField(
|
||||
choices=SSHKeyStrategy.choices, max_length=16,
|
||||
default=SSHKeyStrategy.add, verbose_name=_('SSH key change strategy')
|
||||
)
|
||||
|
||||
get_all_assets: callable # get all assets
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def create_nonlocal_accounts(self, usernames, asset):
|
||||
pass
|
||||
|
||||
def get_account_ids(self):
|
||||
usernames = self.accounts
|
||||
accounts = Account.objects.none()
|
||||
for asset in self.get_all_assets():
|
||||
self.create_nonlocal_accounts(usernames, asset)
|
||||
accounts = accounts | asset.accounts.all()
|
||||
account_ids = accounts.filter(
|
||||
username__in=usernames, secret_type=self.secret_type
|
||||
).values_list('id', flat=True)
|
||||
return [str(_id) for _id in account_ids]
|
||||
|
||||
def to_attr_json(self):
|
||||
attr_json = super().to_attr_json()
|
||||
attr_json.update({
|
||||
'secret': self.secret,
|
||||
'secret_type': self.secret_type,
|
||||
'accounts': self.get_account_ids(),
|
||||
'password_rules': self.password_rules,
|
||||
'secret_strategy': self.secret_strategy,
|
||||
'ssh_key_change_strategy': self.ssh_key_change_strategy,
|
||||
})
|
||||
return attr_json
|
||||
__all__ = ['ChangeSecretAutomation', 'ChangeSecretRecord', ]
|
||||
|
||||
|
||||
class ChangeSecretAutomation(ChangeSecretMixin, AccountBaseAutomation):
|
||||
|
||||
@@ -17,9 +17,9 @@ class PushAccountAutomation(ChangeSecretMixin, AccountBaseAutomation):
|
||||
|
||||
def create_nonlocal_accounts(self, usernames, asset):
|
||||
secret_type = self.secret_type
|
||||
account_usernames = asset.accounts.filter(secret_type=self.secret_type).values_list(
|
||||
'username', flat=True
|
||||
)
|
||||
account_usernames = asset.accounts \
|
||||
.filter(secret_type=self.secret_type) \
|
||||
.values_list('username', flat=True)
|
||||
create_usernames = set(usernames) - set(account_usernames)
|
||||
create_account_objs = [
|
||||
Account(
|
||||
@@ -30,9 +30,6 @@ class PushAccountAutomation(ChangeSecretMixin, AccountBaseAutomation):
|
||||
]
|
||||
Account.objects.bulk_create(create_account_objs)
|
||||
|
||||
def set_period_schedule(self):
|
||||
pass
|
||||
|
||||
@property
|
||||
def dynamic_username(self):
|
||||
return self.username == '@USER'
|
||||
|
||||
@@ -8,12 +8,14 @@ from django.conf import settings
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from accounts.const import SecretType
|
||||
from accounts.const import SecretType, SecretStrategy
|
||||
from accounts.models.mixins import VaultModelMixin, VaultManagerMixin, VaultQuerySetMixin
|
||||
from accounts.utils import SecretGenerator
|
||||
from common.db import fields
|
||||
from common.utils import (
|
||||
ssh_key_string_to_obj, ssh_key_gen, get_logger,
|
||||
random_string, lazyproperty, parse_ssh_public_key_str, is_openssh_format_key
|
||||
)
|
||||
from accounts.models.mixins import VaultModelMixin, VaultManagerMixin, VaultQuerySetMixin
|
||||
from orgs.mixins.models import JMSOrgBaseModel, OrgManager
|
||||
|
||||
logger = get_logger(__file__)
|
||||
@@ -29,6 +31,35 @@ class BaseAccountManager(VaultManagerMixin, OrgManager):
|
||||
return self.get_queryset().active()
|
||||
|
||||
|
||||
class SecretWithRandomMixin(models.Model):
|
||||
secret_type = models.CharField(
|
||||
choices=SecretType.choices, max_length=16,
|
||||
default=SecretType.PASSWORD, verbose_name=_('Secret type')
|
||||
)
|
||||
secret = fields.EncryptTextField(blank=True, null=True, verbose_name=_('Secret'))
|
||||
secret_strategy = models.CharField(
|
||||
choices=SecretStrategy.choices, max_length=16,
|
||||
default=SecretStrategy.custom, verbose_name=_('Secret strategy')
|
||||
)
|
||||
password_rules = models.JSONField(default=dict, verbose_name=_('Password rules'))
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
@lazyproperty
|
||||
def secret_generator(self):
|
||||
return SecretGenerator(
|
||||
self.secret_strategy, self.secret_type,
|
||||
self.password_rules,
|
||||
)
|
||||
|
||||
def get_secret(self):
|
||||
if self.secret_strategy == 'random':
|
||||
return self.secret_generator.get_secret()
|
||||
else:
|
||||
return self.secret
|
||||
|
||||
|
||||
class BaseAccount(VaultModelMixin, JMSOrgBaseModel):
|
||||
name = models.CharField(max_length=128, verbose_name=_("Name"))
|
||||
username = models.CharField(max_length=128, blank=True, verbose_name=_('Username'), db_index=True)
|
||||
|
||||
@@ -37,8 +37,9 @@ class VaultManagerMixin(models.Manager):
|
||||
post_save.send(obj.__class__, instance=obj, created=True)
|
||||
return objs
|
||||
|
||||
def bulk_update(self, objs, batch_size=None, ignore_conflicts=False):
|
||||
objs = super().bulk_update(objs, batch_size=batch_size, ignore_conflicts=ignore_conflicts)
|
||||
def bulk_update(self, objs, fields, batch_size=None):
|
||||
fields = ["_secret" if field == "secret" else field for field in fields]
|
||||
super().bulk_update(objs, fields, batch_size=batch_size)
|
||||
for obj in objs:
|
||||
post_save.send(obj.__class__, instance=obj, created=False)
|
||||
return objs
|
||||
|
||||
@@ -4,16 +4,22 @@ from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from .account import Account
|
||||
from .base import BaseAccount
|
||||
from .base import BaseAccount, SecretWithRandomMixin
|
||||
|
||||
__all__ = ['AccountTemplate', ]
|
||||
|
||||
|
||||
class AccountTemplate(BaseAccount):
|
||||
class AccountTemplate(BaseAccount, SecretWithRandomMixin):
|
||||
su_from = models.ForeignKey(
|
||||
'self', related_name='su_to', null=True,
|
||||
on_delete=models.SET_NULL, verbose_name=_("Su from")
|
||||
)
|
||||
auto_push = models.BooleanField(default=False, verbose_name=_('Auto push'))
|
||||
platforms = models.ManyToManyField(
|
||||
'assets.Platform', related_name='account_templates',
|
||||
verbose_name=_('Platforms'), blank=True,
|
||||
)
|
||||
push_params = models.JSONField(default=dict, verbose_name=_('Push params'))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('Account template')
|
||||
@@ -25,15 +31,15 @@ class AccountTemplate(BaseAccount):
|
||||
('change_accounttemplatesecret', _('Can change asset account template secret')),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.name}({self.username})'
|
||||
|
||||
@classmethod
|
||||
def get_su_from_account_templates(cls, pk=None):
|
||||
if pk is None:
|
||||
return cls.objects.all()
|
||||
return cls.objects.exclude(Q(id=pk) | Q(su_from_id=pk))
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.name}({self.username})'
|
||||
|
||||
def get_su_from_account(self, asset):
|
||||
su_from = self.su_from
|
||||
if su_from and asset.platform.su_enabled:
|
||||
|
||||
@@ -2,6 +2,7 @@ import uuid
|
||||
from copy import deepcopy
|
||||
|
||||
from django.db import IntegrityError
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework import serializers
|
||||
@@ -73,6 +74,22 @@ class AccountCreateUpdateSerializerMixin(serializers.Serializer):
|
||||
name = name + '_' + uuid.uuid4().hex[:4]
|
||||
initial_data['name'] = name
|
||||
|
||||
@staticmethod
|
||||
def get_template_attr_for_account(template):
|
||||
# Set initial data from template
|
||||
field_names = [
|
||||
'username', 'secret', 'secret_type', 'privileged', 'is_active'
|
||||
]
|
||||
|
||||
attrs = {}
|
||||
for name in field_names:
|
||||
value = getattr(template, name, None)
|
||||
if value is None:
|
||||
continue
|
||||
attrs[name] = value
|
||||
attrs['secret'] = template.get_secret()
|
||||
return attrs
|
||||
|
||||
def from_template_if_need(self, initial_data):
|
||||
if isinstance(initial_data, str):
|
||||
return
|
||||
@@ -89,20 +106,7 @@ class AccountCreateUpdateSerializerMixin(serializers.Serializer):
|
||||
raise serializers.ValidationError({'template': 'Template not found'})
|
||||
|
||||
self._template = template
|
||||
# Set initial data from template
|
||||
ignore_fields = ['id', 'date_created', 'date_updated', 'su_from', 'org_id']
|
||||
field_names = [
|
||||
field.name for field in template._meta.fields
|
||||
if field.name not in ignore_fields
|
||||
]
|
||||
field_names = [name if name != '_secret' else 'secret' for name in field_names]
|
||||
|
||||
attrs = {}
|
||||
for name in field_names:
|
||||
value = getattr(template, name, None)
|
||||
if value is None:
|
||||
continue
|
||||
attrs[name] = value
|
||||
attrs = self.get_template_attr_for_account(template)
|
||||
initial_data.update(attrs)
|
||||
initial_data.update({
|
||||
'source': Source.TEMPLATE,
|
||||
@@ -114,10 +118,13 @@ class AccountCreateUpdateSerializerMixin(serializers.Serializer):
|
||||
asset = get_object_or_404(Asset, pk=asset_id)
|
||||
initial_data['su_from'] = template.get_su_from_account(asset)
|
||||
|
||||
@staticmethod
|
||||
def push_account_if_need(instance, push_now, params, stat):
|
||||
def push_account_if_need(self, instance, push_now, params, stat):
|
||||
if not push_now or stat not in ['created', 'updated']:
|
||||
return
|
||||
transaction.on_commit(lambda: self.start_push(instance, params))
|
||||
|
||||
@staticmethod
|
||||
def start_push(instance, params):
|
||||
push_accounts_to_assets_task.delay([str(instance.id)], params)
|
||||
|
||||
def get_validators(self):
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework import serializers
|
||||
|
||||
from accounts.const import SecretStrategy, SecretType
|
||||
from accounts.models import AccountTemplate, Account
|
||||
from accounts.utils import SecretGenerator
|
||||
from common.serializers import SecretReadableMixin
|
||||
from common.serializers.fields import ObjectRelatedField
|
||||
from .base import BaseAccountSerializer
|
||||
|
||||
|
||||
class PasswordRulesSerializer(serializers.Serializer):
|
||||
length = serializers.IntegerField(min_value=8, max_value=30, default=16, label=_('Password length'))
|
||||
lowercase = serializers.BooleanField(default=True, label=_('Lowercase'))
|
||||
uppercase = serializers.BooleanField(default=True, label=_('Uppercase'))
|
||||
digit = serializers.BooleanField(default=True, label=_('Digit'))
|
||||
symbol = serializers.BooleanField(default=True, label=_('Special symbol'))
|
||||
|
||||
|
||||
class AccountTemplateSerializer(BaseAccountSerializer):
|
||||
is_sync_account = serializers.BooleanField(default=False, write_only=True)
|
||||
_is_sync_account = False
|
||||
|
||||
password_rules = PasswordRulesSerializer(required=False, label=_('Password rules'))
|
||||
su_from = ObjectRelatedField(
|
||||
required=False, queryset=AccountTemplate.objects, allow_null=True,
|
||||
allow_empty=True, label=_('Su from'), attrs=('id', 'name', 'username')
|
||||
@@ -18,7 +29,22 @@ class AccountTemplateSerializer(BaseAccountSerializer):
|
||||
|
||||
class Meta(BaseAccountSerializer.Meta):
|
||||
model = AccountTemplate
|
||||
fields = BaseAccountSerializer.Meta.fields + ['is_sync_account', 'su_from']
|
||||
fields = BaseAccountSerializer.Meta.fields + [
|
||||
'secret_strategy', 'password_rules',
|
||||
'auto_push', 'push_params', 'platforms',
|
||||
'is_sync_account', 'su_from'
|
||||
]
|
||||
extra_kwargs = {
|
||||
'secret_strategy': {'help_text': _('Secret generation strategy for account creation')},
|
||||
'auto_push': {'help_text': _('Whether to automatically push the account to the asset')},
|
||||
'platforms': {
|
||||
'help_text': _(
|
||||
'Associated platform, you can configure push parameters. '
|
||||
'If not associated, default parameters will be used'
|
||||
),
|
||||
'required': False
|
||||
},
|
||||
}
|
||||
|
||||
def sync_accounts_secret(self, instance, diff):
|
||||
if not self._is_sync_account or 'secret' not in diff:
|
||||
@@ -31,9 +57,20 @@ class AccountTemplateSerializer(BaseAccountSerializer):
|
||||
accounts = Account.objects.filter(**query_data)
|
||||
instance.bulk_sync_account_secret(accounts, self.context['request'].user.id)
|
||||
|
||||
@staticmethod
|
||||
def generate_secret(attrs):
|
||||
secret_type = attrs.get('secret_type', SecretType.PASSWORD)
|
||||
secret_strategy = attrs.get('secret_strategy', SecretStrategy.custom)
|
||||
password_rules = attrs.get('password_rules')
|
||||
if secret_strategy != SecretStrategy.random:
|
||||
return
|
||||
generator = SecretGenerator(secret_strategy, secret_type, password_rules)
|
||||
attrs['secret'] = generator.get_secret()
|
||||
|
||||
def validate(self, attrs):
|
||||
self._is_sync_account = attrs.pop('is_sync_account', None)
|
||||
attrs = super().validate(attrs)
|
||||
self.generate_secret(attrs)
|
||||
return attrs
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
|
||||
@@ -19,8 +19,12 @@ class VirtualAccountSerializer(serializers.ModelSerializer):
|
||||
'comment': {'label': _('Comment')},
|
||||
'name': {'label': _('Name')},
|
||||
'username': {'label': _('Username')},
|
||||
'secret_from_login': {'help_text': _('Current only support login from AD/LDAP. Secret priority: '
|
||||
'Same account in asset secret > Login secret > Manual input')
|
||||
},
|
||||
'secret_from_login': {
|
||||
'help_text': _(
|
||||
'Current only support login from AD/LDAP. Secret priority: '
|
||||
'Same account in asset secret > Login secret > Manual input. <br/ >'
|
||||
'For security, please set config CACHE_LOGIN_PASSWORD_ENABLED to true'
|
||||
)
|
||||
},
|
||||
'alias': {'required': False},
|
||||
}
|
||||
|
||||
@@ -4,14 +4,13 @@ from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework import serializers
|
||||
|
||||
from accounts.const import (
|
||||
AutomationTypes, DEFAULT_PASSWORD_RULES,
|
||||
SecretType, SecretStrategy, SSHKeyStrategy
|
||||
AutomationTypes, SecretType, SecretStrategy, SSHKeyStrategy
|
||||
)
|
||||
from accounts.models import (
|
||||
Account, ChangeSecretAutomation,
|
||||
ChangeSecretRecord, AutomationExecution
|
||||
)
|
||||
from accounts.serializers import AuthValidateMixin
|
||||
from accounts.serializers import AuthValidateMixin, PasswordRulesSerializer
|
||||
from assets.models import Asset
|
||||
from common.serializers.fields import LabeledChoiceField, ObjectRelatedField
|
||||
from common.utils import get_logger
|
||||
@@ -42,7 +41,7 @@ class ChangeSecretAutomationSerializer(AuthValidateMixin, BaseAutomationSerializ
|
||||
ssh_key_change_strategy = LabeledChoiceField(
|
||||
choices=SSHKeyStrategy.choices, required=False, label=_('SSH Key strategy')
|
||||
)
|
||||
password_rules = serializers.DictField(default=DEFAULT_PASSWORD_RULES)
|
||||
password_rules = PasswordRulesSerializer(required=False, label=_('Password rules'))
|
||||
secret_type = LabeledChoiceField(choices=get_secret_types(), required=True, label=_('Secret type'))
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
from django.db.models.signals import pre_save, post_save, post_delete
|
||||
from collections import defaultdict
|
||||
|
||||
from django.db.models.signals import post_delete
|
||||
from django.db.models.signals import pre_save, post_save
|
||||
from django.dispatch import receiver
|
||||
from django.utils.translation import gettext_noop
|
||||
|
||||
from accounts.backends import vault_client
|
||||
from common.utils import get_logger
|
||||
from audits.const import ActivityChoices
|
||||
from audits.signal_handlers import create_activities
|
||||
from common.decorators import merge_delay_run
|
||||
from common.utils import get_logger, i18n_fmt
|
||||
from .models import Account, AccountTemplate
|
||||
from .tasks.push_account import push_accounts_to_assets_task
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -16,6 +24,53 @@ def on_account_pre_save(sender, instance, **kwargs):
|
||||
instance.version = instance.history.count()
|
||||
|
||||
|
||||
@merge_delay_run(ttl=5)
|
||||
def push_accounts_if_need(accounts=()):
|
||||
from .models import AccountTemplate
|
||||
|
||||
template_accounts = defaultdict(list)
|
||||
for ac in accounts:
|
||||
# 再强调一次吧
|
||||
if ac.source != 'template':
|
||||
continue
|
||||
template_accounts[ac.source_id].append(ac)
|
||||
|
||||
for source_id, accounts in template_accounts.items():
|
||||
template = AccountTemplate.objects.filter(id=source_id).first()
|
||||
if not template or not template.auto_push:
|
||||
continue
|
||||
logger.debug("Push accounts to source: %s", source_id)
|
||||
account_ids = [str(ac.id) for ac in accounts]
|
||||
task = push_accounts_to_assets_task.delay(account_ids, params=template.push_params)
|
||||
detail = i18n_fmt(
|
||||
gettext_noop('Push related accounts to assets: %s, by system'),
|
||||
len(account_ids)
|
||||
)
|
||||
create_activities([str(template.id)], detail, task.id, ActivityChoices.task, template.org_id)
|
||||
logger.debug("Push accounts to source: %s, task: %s", source_id, task)
|
||||
|
||||
|
||||
def create_accounts_activities(account, action='create'):
|
||||
if action == 'create':
|
||||
detail = i18n_fmt(gettext_noop('Add account: %s'), str(account))
|
||||
else:
|
||||
detail = i18n_fmt(gettext_noop('Delete account: %s'), str(account))
|
||||
create_activities([account.asset_id], detail, None, ActivityChoices.operate_log, account.org_id)
|
||||
|
||||
|
||||
@receiver(post_save, sender=Account)
|
||||
def on_account_create_by_template(sender, instance, created=False, **kwargs):
|
||||
if not created or instance.source != 'template':
|
||||
return
|
||||
push_accounts_if_need(accounts=(instance,))
|
||||
create_accounts_activities(instance, action='create')
|
||||
|
||||
|
||||
@receiver(post_delete, sender=Account)
|
||||
def on_account_delete(sender, instance, **kwargs):
|
||||
create_accounts_activities(instance, action='delete')
|
||||
|
||||
|
||||
class VaultSignalHandler(object):
|
||||
""" 处理 Vault 相关的信号 """
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import copy
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework import serializers
|
||||
|
||||
@@ -18,9 +20,19 @@ class SecretGenerator:
|
||||
return private_key
|
||||
|
||||
def generate_password(self):
|
||||
length = int(self.password_rules.get('length', 0))
|
||||
length = length if length else DEFAULT_PASSWORD_RULES['length']
|
||||
return random_string(length, special_char=True)
|
||||
password_rules = self.password_rules
|
||||
if not password_rules or not isinstance(password_rules, dict):
|
||||
password_rules = {}
|
||||
rules = copy.deepcopy(DEFAULT_PASSWORD_RULES)
|
||||
rules.update(password_rules)
|
||||
rules = {
|
||||
'length': rules['length'],
|
||||
'lower': rules['lowercase'],
|
||||
'upper': rules['uppercase'],
|
||||
'digit': rules['digit'],
|
||||
'special_char': rules['symbol']
|
||||
}
|
||||
return random_string(**rules)
|
||||
|
||||
def get_secret(self):
|
||||
if self.secret_type == SecretType.SSH_KEY:
|
||||
@@ -39,6 +51,8 @@ def validate_password_for_ansible(password):
|
||||
# Ansible 推送的时候不支持
|
||||
if '{{' in password:
|
||||
raise serializers.ValidationError(_('Password can not contains `{{` '))
|
||||
if '{%' in password:
|
||||
raise serializers.ValidationError(_('Password can not contains `{%` '))
|
||||
# Ansible Windows 推送的时候不支持
|
||||
if "'" in password:
|
||||
raise serializers.ValidationError(_("Password can not contains `'` "))
|
||||
|
||||
@@ -103,25 +103,27 @@ class UserAssetAccountBaseACL(OrgModelMixin, UserBaseACL):
|
||||
abstract = True
|
||||
|
||||
@classmethod
|
||||
def filter_queryset(cls, user=None, asset=None, account=None, account_username=None, **kwargs):
|
||||
def _get_filter_queryset(cls, user=None, asset=None, account=None, account_username=None, **kwargs):
|
||||
queryset = cls.objects.all()
|
||||
|
||||
if user:
|
||||
q = cls.users.get_filter_q(user)
|
||||
queryset = queryset.filter(q)
|
||||
q = models.Q()
|
||||
|
||||
if asset:
|
||||
org_id = asset.org_id
|
||||
with tmp_to_org(org_id):
|
||||
q = cls.assets.get_filter_q(asset)
|
||||
queryset = queryset.filter(q)
|
||||
q &= cls.assets.get_filter_q(asset)
|
||||
if user:
|
||||
q &= cls.users.get_filter_q(user)
|
||||
if account and not account_username:
|
||||
account_username = account.username
|
||||
if account_username:
|
||||
q = models.Q(accounts__contains=account_username) | \
|
||||
models.Q(accounts__contains='*') | \
|
||||
models.Q(accounts__contains='@ALL')
|
||||
queryset = queryset.filter(q)
|
||||
q &= models.Q(accounts__contains=account_username) | \
|
||||
models.Q(accounts__contains='*') | \
|
||||
models.Q(accounts__contains='@ALL')
|
||||
if kwargs:
|
||||
queryset = queryset.filter(**kwargs)
|
||||
q &= models.Q(**kwargs)
|
||||
queryset = queryset.filter(q)
|
||||
return queryset.valid().distinct()
|
||||
|
||||
@classmethod
|
||||
def filter_queryset(cls, asset=None, **kwargs):
|
||||
org_id = asset.org_id if asset else ''
|
||||
with tmp_to_org(org_id):
|
||||
return cls._get_filter_queryset(asset=asset, **kwargs)
|
||||
|
||||
@@ -42,7 +42,7 @@ class SerializeToTreeNodeMixin:
|
||||
'name': _name(node),
|
||||
'title': _name(node),
|
||||
'pId': node.parent_key,
|
||||
'isParent': node.assets_amount > 0,
|
||||
'isParent': True,
|
||||
'open': _open(node),
|
||||
'meta': {
|
||||
'data': {
|
||||
|
||||
@@ -49,13 +49,19 @@ class AssetPlatformViewSet(JMSModelViewSet):
|
||||
@action(methods=['post'], detail=False, url_path='filter-nodes-assets')
|
||||
def filter_nodes_assets(self, request, *args, **kwargs):
|
||||
node_ids = request.data.get('node_ids', [])
|
||||
asset_ids = request.data.get('asset_ids', [])
|
||||
nodes = Node.objects.filter(id__in=node_ids)
|
||||
node_asset_ids = Node.get_nodes_all_assets(*nodes).values_list('id', flat=True)
|
||||
direct_asset_ids = Asset.objects.filter(id__in=asset_ids).values_list('id', flat=True)
|
||||
platform_ids = Asset.objects.filter(
|
||||
id__in=set(list(direct_asset_ids) + list(node_asset_ids))
|
||||
).values_list('platform_id', flat=True)
|
||||
asset_ids = set(request.data.get('asset_ids', []))
|
||||
platform_ids = set(request.data.get('platform_ids', []))
|
||||
|
||||
if node_ids:
|
||||
nodes = Node.objects.filter(id__in=node_ids)
|
||||
node_asset_ids = Node.get_nodes_all_assets(*nodes).values_list('id', flat=True)
|
||||
asset_ids |= set(node_asset_ids)
|
||||
|
||||
if asset_ids:
|
||||
_platform_ids = Asset.objects \
|
||||
.filter(id__in=set(asset_ids)) \
|
||||
.values_list('platform_id', flat=True)
|
||||
platform_ids |= set(_platform_ids)
|
||||
platforms = Platform.objects.filter(id__in=platform_ids)
|
||||
serializer = self.get_serializer(platforms, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
# ~*~ coding: utf-8 ~*~
|
||||
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework.generics import get_object_or_404
|
||||
from rest_framework.response import Response
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from assets.locks import NodeAddChildrenLock
|
||||
from common.exceptions import JMSException
|
||||
from common.tree import TreeNodeSerializer
|
||||
from common.utils import get_logger
|
||||
from common.exceptions import JMSException
|
||||
from orgs.mixins import generics
|
||||
from orgs.utils import current_org
|
||||
from .mixin import SerializeToTreeNodeMixin
|
||||
@@ -35,8 +35,8 @@ class NodeChildrenApi(generics.ListCreateAPIView):
|
||||
is_initial = False
|
||||
|
||||
def initial(self, request, *args, **kwargs):
|
||||
super().initial(request, *args, **kwargs)
|
||||
self.instance = self.get_object()
|
||||
return super().initial(request, *args, **kwargs)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
with NodeAddChildrenLock(self.instance):
|
||||
|
||||
@@ -175,7 +175,7 @@ class BasePlaybookManager:
|
||||
method = self.method_id_meta_mapper.get(method_id)
|
||||
if not method:
|
||||
logger.error("Method not found: {}".format(method_id))
|
||||
return method
|
||||
return
|
||||
method_playbook_dir_path = method['dir']
|
||||
sub_playbook_path = os.path.join(sub_playbook_dir, 'project', 'main.yml')
|
||||
shutil.copytree(method_playbook_dir_path, os.path.dirname(sub_playbook_path))
|
||||
@@ -196,6 +196,11 @@ class BasePlaybookManager:
|
||||
print(msg)
|
||||
runners = []
|
||||
for platform, assets in assets_group_by_platform.items():
|
||||
if not assets:
|
||||
continue
|
||||
if not platform.automation or not platform.automation.ansible_enabled:
|
||||
print(_(" - Platform {} ansible disabled").format(platform.name))
|
||||
continue
|
||||
assets_bulked = [assets[i:i + self.bulk_size] for i in range(0, len(assets), self.bulk_size)]
|
||||
|
||||
for i, _assets in enumerate(assets_bulked, start=1):
|
||||
@@ -204,6 +209,8 @@ class BasePlaybookManager:
|
||||
inventory_path = os.path.join(self.runtime_dir, sub_dir, 'hosts.json')
|
||||
self.generate_inventory(_assets, inventory_path)
|
||||
playbook_path = self.generate_playbook(_assets, platform, playbook_dir)
|
||||
if not playbook_path:
|
||||
continue
|
||||
|
||||
runer = PlaybookRunner(
|
||||
inventory_path,
|
||||
@@ -309,6 +316,7 @@ class BasePlaybookManager:
|
||||
shutil.rmtree(self.runtime_dir)
|
||||
|
||||
def run(self, *args, **kwargs):
|
||||
print(">>> 任务准备阶段\n")
|
||||
runners = self.get_runners()
|
||||
if len(runners) > 1:
|
||||
print("### 分次执行任务, 总共 {}\n".format(len(runners)))
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
login_port: "{{ jms_asset.port }}"
|
||||
login_database: "{{ jms_asset.spec_info.db_name }}"
|
||||
ssl: "{{ jms_asset.spec_info.use_ssl }}"
|
||||
ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert }}"
|
||||
ssl_certfile: "{{ jms_asset.secret_info.client_key }}"
|
||||
ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert | default('') }}"
|
||||
ssl_certfile: "{{ jms_asset.secret_info.client_key | default('') }}"
|
||||
connection_options:
|
||||
- tlsAllowInvalidHostnames: "{{ jms_asset.spec_info.allow_invalid_cert}}"
|
||||
register: db_info
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
login_password: "{{ jms_account.secret }}"
|
||||
login_host: "{{ jms_asset.address }}"
|
||||
login_port: "{{ jms_asset.port }}"
|
||||
check_hostname: "{{ jms_asset.spec_info.use_ssl and not jms_asset.spec_info.allow_invalid_cert }}"
|
||||
ca_cert: "{{ jms_asset.secret_info.ca_cert | default(omit) }}"
|
||||
client_cert: "{{ jms_asset.secret_info.client_cert | default(omit) }}"
|
||||
client_key: "{{ jms_asset.secret_info.client_key | default(omit) }}"
|
||||
filter: version
|
||||
register: db_info
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
login_port: "{{ jms_asset.port }}"
|
||||
login_database: "{{ jms_asset.spec_info.db_name }}"
|
||||
ssl: "{{ jms_asset.spec_info.use_ssl }}"
|
||||
ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert }}"
|
||||
ssl_certfile: "{{ jms_asset.secret_info.client_key }}"
|
||||
ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert | default('') }}"
|
||||
ssl_certfile: "{{ jms_asset.secret_info.client_key | default('') }}"
|
||||
connection_options:
|
||||
- tlsAllowInvalidHostnames: "{{ jms_asset.spec_info.allow_invalid_cert}}"
|
||||
|
||||
@@ -10,4 +10,8 @@
|
||||
login_password: "{{ jms_account.secret }}"
|
||||
login_host: "{{ jms_asset.address }}"
|
||||
login_port: "{{ jms_asset.port }}"
|
||||
check_hostname: "{{ jms_asset.spec_info.use_ssl and not jms_asset.spec_info.allow_invalid_cert }}"
|
||||
ca_cert: "{{ jms_asset.secret_info.ca_cert | default(omit) }}"
|
||||
client_cert: "{{ jms_asset.secret_info.client_cert | default(omit) }}"
|
||||
client_key: "{{ jms_asset.secret_info.client_key | default(omit) }}"
|
||||
filter: version
|
||||
|
||||
@@ -24,7 +24,7 @@ class DeviceTypes(BaseType):
|
||||
def _get_protocol_constrains(cls) -> dict:
|
||||
return {
|
||||
'*': {
|
||||
'choices': ['ssh', 'telnet']
|
||||
'choices': ['ssh', 'telnet', 'sftp']
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,7 +45,13 @@ class Protocol(ChoicesMixin, models.TextChoices):
|
||||
'sftp_home': {
|
||||
'type': 'str',
|
||||
'default': '/tmp',
|
||||
'label': _('SFTP home')
|
||||
'label': _('SFTP root'),
|
||||
'help_text': _(
|
||||
'SFTP root directory, Support variable: <br>'
|
||||
'- ${ACCOUNT} The connected account username <br>'
|
||||
'- ${HOME} The home directory of the connected account <br>'
|
||||
'- ${USER} The username of the user'
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -154,6 +160,15 @@ class Protocol(ChoicesMixin, models.TextChoices):
|
||||
'required': True,
|
||||
'secret_types': ['password'],
|
||||
'xpack': True,
|
||||
'setting': {
|
||||
'version': {
|
||||
'type': 'choice',
|
||||
'choices': [('>=2014', '>= 2014'), ('<2014', '< 2014')],
|
||||
'default': '>=2014',
|
||||
'label': _('Version'),
|
||||
'help_text': _('SQL Server version, Different versions have different connection drivers')
|
||||
}
|
||||
}
|
||||
},
|
||||
cls.clickhouse: {
|
||||
'port': 9000,
|
||||
|
||||
@@ -107,8 +107,9 @@ def create_app_nodes(apps, org_id):
|
||||
'key': next_key, 'value': name, 'parent_key': parent_key,
|
||||
'full_value': full_value, 'org_id': org_id
|
||||
}
|
||||
node, created = node_model.objects.get_or_create(
|
||||
node, __ = node_model.objects.get_or_create(
|
||||
defaults=defaults, value=name, org_id=org_id,
|
||||
parent_key=parent_key
|
||||
)
|
||||
node.parent = parent
|
||||
return node
|
||||
|
||||
@@ -25,7 +25,7 @@ def migrate_asset_accounts(apps, schema_editor):
|
||||
count += len(auth_books)
|
||||
# auth book 和 account 相同的属性
|
||||
same_attrs = [
|
||||
'id', 'username', 'comment', 'date_created', 'date_updated',
|
||||
'username', 'comment', 'date_created', 'date_updated',
|
||||
'created_by', 'asset_id', 'org_id',
|
||||
]
|
||||
# 认证的属性,可能是 auth_book 的,可能是 system_user 的
|
||||
|
||||
@@ -49,11 +49,11 @@ def migrate_assets_sftp_protocol(apps, schema_editor):
|
||||
|
||||
count = 0
|
||||
print("\nAsset add sftp protocol: ")
|
||||
asset_ids = asset_cls.objects\
|
||||
asset_ids = list(asset_cls.objects\
|
||||
.filter(platform__in=sftp_platforms)\
|
||||
.exclude(protocols__name='sftp')\
|
||||
.distinct()\
|
||||
.values_list('id', flat=True)
|
||||
.values_list('id', flat=True))
|
||||
while True:
|
||||
_asset_ids = asset_ids[count:count + 1000]
|
||||
if not _asset_ids:
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 4.1.10 on 2023-09-13 10:59
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def migrate_device_automation_ansible_enabled(apps, *args):
|
||||
platform_model = apps.get_model('assets', 'Platform')
|
||||
automation_model = apps.get_model('assets', 'PlatformAutomation')
|
||||
ids = platform_model.objects.filter(category='device').values_list('id', flat=True)
|
||||
automation_model.objects.filter(platform_id__in=ids).update(ansible_enabled=True)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('assets', '0122_auto_20230803_1553'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(migrate_device_automation_ansible_enabled)
|
||||
]
|
||||
@@ -402,12 +402,7 @@ class NodeAssetsMixin(NodeAllAssetsMappingMixin):
|
||||
return Asset.objects.filter(q).distinct()
|
||||
|
||||
def get_assets_amount(self):
|
||||
q = Q(node__key__startswith=f'{self.key}:') | Q(node__key=self.key)
|
||||
return self.assets.through.objects.filter(q).count()
|
||||
|
||||
def get_assets_account_by_children(self):
|
||||
children = self.get_all_children().values_list()
|
||||
return self.assets.through.objects.filter(node_id__in=children).count()
|
||||
return self.get_all_assets().count()
|
||||
|
||||
@classmethod
|
||||
def get_node_all_assets_by_key_v2(cls, key):
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from django.db.models import QuerySet
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework import serializers
|
||||
from rest_framework.validators import UniqueValidator
|
||||
|
||||
from common.serializers import (
|
||||
WritableNestedModelSerializer, type_field_map, MethodSerializer,
|
||||
@@ -123,6 +124,10 @@ class PlatformSerializer(WritableNestedModelSerializer):
|
||||
("super", "super 15"),
|
||||
("super_level", "super level 15")
|
||||
]
|
||||
id = serializers.IntegerField(
|
||||
label='ID', required=False,
|
||||
validators=[UniqueValidator(queryset=Platform.objects.all())]
|
||||
)
|
||||
charset = LabeledChoiceField(choices=Platform.CharsetChoices.choices, label=_("Charset"), default='utf-8')
|
||||
type = LabeledChoiceField(choices=AllTypes.choices(), label=_("Type"))
|
||||
category = LabeledChoiceField(choices=Category.choices, label=_("Category"))
|
||||
@@ -213,7 +218,7 @@ class PlatformSerializer(WritableNestedModelSerializer):
|
||||
def validate_automation(self, automation):
|
||||
automation = automation or {}
|
||||
ansible_enabled = automation.get('ansible_enabled', False) \
|
||||
and self.constraints['automation'].get('ansible_enabled', False)
|
||||
and self.constraints['automation'].get('ansible_enabled', False)
|
||||
automation['ansible_enable'] = ansible_enabled
|
||||
return automation
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ class KubernetesClient:
|
||||
|
||||
remote_bind_address = (
|
||||
urlparse(asset.address).hostname,
|
||||
urlparse(asset.address).port
|
||||
urlparse(asset.address).port or 443
|
||||
)
|
||||
server = SSHTunnelForwarder(
|
||||
(gateway.address, gateway.port),
|
||||
|
||||
@@ -1,50 +1,52 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
import os
|
||||
|
||||
from importlib import import_module
|
||||
|
||||
from django.conf import settings
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.db.models import F, Value, CharField, Q
|
||||
from django.http import HttpResponse, FileResponse
|
||||
from django.utils import timezone
|
||||
from django.utils.encoding import escape_uri_path
|
||||
from rest_framework import generics
|
||||
from rest_framework import status
|
||||
from rest_framework import viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.decorators import action
|
||||
|
||||
from common.api import AsyncApiMixin
|
||||
from common.drf.filters import DatetimeRangeFilter
|
||||
from common.api import CommonApiMixin
|
||||
from common.const.http import GET, POST
|
||||
from common.drf.filters import DatetimeRangeFilterBackend
|
||||
from common.permissions import IsServiceAccount
|
||||
from common.plugins.es import QuerySet as ESQuerySet
|
||||
from common.utils import is_uuid, get_logger, lazyproperty
|
||||
from common.const.http import GET, POST
|
||||
from common.storage.ftp_file import FTPFileStorageHandler
|
||||
from common.utils import is_uuid, get_logger, lazyproperty
|
||||
from orgs.mixins.api import OrgReadonlyModelViewSet, OrgModelViewSet
|
||||
from orgs.utils import current_org, tmp_to_root_org
|
||||
from orgs.models import Organization
|
||||
from orgs.utils import current_org, tmp_to_root_org
|
||||
from rbac.permissions import RBACPermission
|
||||
from terminal.models import default_storage
|
||||
from users.models import User
|
||||
from .backends import TYPE_ENGINE_MAPPING
|
||||
from .const import ActivityChoices
|
||||
from .models import FTPLog, UserLoginLog, OperateLog, PasswordChangeLog, ActivityLog, JobLog
|
||||
from .models import (
|
||||
FTPLog, UserLoginLog, OperateLog, PasswordChangeLog,
|
||||
ActivityLog, JobLog, UserSession
|
||||
)
|
||||
from .serializers import (
|
||||
FTPLogSerializer, UserLoginLogSerializer, JobLogSerializer,
|
||||
OperateLogSerializer, OperateLogActionDetailSerializer,
|
||||
PasswordChangeLogSerializer, ActivityUnionLogSerializer,
|
||||
FileSerializer
|
||||
FileSerializer, UserSessionSerializer
|
||||
)
|
||||
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class JobAuditViewSet(OrgReadonlyModelViewSet):
|
||||
model = JobLog
|
||||
extra_filter_backends = [DatetimeRangeFilter]
|
||||
extra_filter_backends = [DatetimeRangeFilterBackend]
|
||||
date_range_filter_fields = [
|
||||
('date_start', ('date_from', 'date_to'))
|
||||
]
|
||||
@@ -57,7 +59,7 @@ class JobAuditViewSet(OrgReadonlyModelViewSet):
|
||||
class FTPLogViewSet(OrgModelViewSet):
|
||||
model = FTPLog
|
||||
serializer_class = FTPLogSerializer
|
||||
extra_filter_backends = [DatetimeRangeFilter]
|
||||
extra_filter_backends = [DatetimeRangeFilterBackend]
|
||||
date_range_filter_fields = [
|
||||
('date_start', ('date_from', 'date_to'))
|
||||
]
|
||||
@@ -113,7 +115,7 @@ class FTPLogViewSet(OrgModelViewSet):
|
||||
class UserLoginCommonMixin:
|
||||
model = UserLoginLog
|
||||
serializer_class = UserLoginLogSerializer
|
||||
extra_filter_backends = [DatetimeRangeFilter]
|
||||
extra_filter_backends = [DatetimeRangeFilterBackend]
|
||||
date_range_filter_fields = [
|
||||
('datetime', ('date_from', 'date_to'))
|
||||
]
|
||||
@@ -193,7 +195,7 @@ class ResourceActivityAPIView(generics.ListAPIView):
|
||||
class OperateLogViewSet(OrgReadonlyModelViewSet):
|
||||
model = OperateLog
|
||||
serializer_class = OperateLogSerializer
|
||||
extra_filter_backends = [DatetimeRangeFilter]
|
||||
extra_filter_backends = [DatetimeRangeFilterBackend]
|
||||
date_range_filter_fields = [
|
||||
('datetime', ('date_from', 'date_to'))
|
||||
]
|
||||
@@ -232,7 +234,7 @@ class OperateLogViewSet(OrgReadonlyModelViewSet):
|
||||
class PasswordChangeLogViewSet(OrgReadonlyModelViewSet):
|
||||
model = PasswordChangeLog
|
||||
serializer_class = PasswordChangeLogSerializer
|
||||
extra_filter_backends = [DatetimeRangeFilter]
|
||||
extra_filter_backends = [DatetimeRangeFilterBackend]
|
||||
date_range_filter_fields = [
|
||||
('datetime', ('date_from', 'date_to'))
|
||||
]
|
||||
@@ -248,3 +250,44 @@ class PasswordChangeLogViewSet(OrgReadonlyModelViewSet):
|
||||
user__in=[str(user) for user in users]
|
||||
)
|
||||
return queryset
|
||||
|
||||
|
||||
class UserSessionViewSet(CommonApiMixin, viewsets.ModelViewSet):
|
||||
http_method_names = ('get', 'post', 'head', 'options', 'trace')
|
||||
serializer_class = UserSessionSerializer
|
||||
filterset_fields = ['id', 'ip', 'city', 'type']
|
||||
search_fields = ['id', 'ip', 'city']
|
||||
|
||||
rbac_perms = {
|
||||
'offline': ['users.offline_usersession']
|
||||
}
|
||||
|
||||
@property
|
||||
def org_user_ids(self):
|
||||
user_ids = current_org.get_members().values_list('id', flat=True)
|
||||
return user_ids
|
||||
|
||||
def get_queryset(self):
|
||||
keys = UserSession.get_keys()
|
||||
queryset = UserSession.objects.filter(
|
||||
date_expired__gt=timezone.now(), key__in=keys
|
||||
)
|
||||
if current_org.is_root():
|
||||
return queryset
|
||||
user_ids = self.org_user_ids
|
||||
queryset = queryset.filter(user_id__in=user_ids)
|
||||
return queryset
|
||||
|
||||
@action(['POST'], detail=False, url_path='offline')
|
||||
def offline(self, request, *args, **kwargs):
|
||||
ids = request.data.get('ids', [])
|
||||
queryset = self.get_queryset().exclude(key=request.session.session_key).filter(id__in=ids)
|
||||
if not queryset.exists():
|
||||
return Response(status=status.HTTP_200_OK)
|
||||
|
||||
keys = queryset.values_list('key', flat=True)
|
||||
session_store_cls = import_module(settings.SESSION_ENGINE).SessionStore
|
||||
for key in keys:
|
||||
session_store_cls(key).delete()
|
||||
queryset.delete()
|
||||
return Response(status=status.HTTP_200_OK)
|
||||
|
||||
@@ -25,6 +25,7 @@ class ActionChoices(TextChoices):
|
||||
delete = "delete", _("Delete")
|
||||
create = "create", _("Create")
|
||||
# Activities action
|
||||
download = "download", _("Download")
|
||||
connect = "connect", _("Connect")
|
||||
login = "login", _("Login")
|
||||
change_auth = "change_password", _("Change password")
|
||||
|
||||
29
apps/audits/migrations/0023_auto_20230906_1322.py
Normal file
29
apps/audits/migrations/0023_auto_20230906_1322.py
Normal file
@@ -0,0 +1,29 @@
|
||||
# Generated by Django 4.1.10 on 2023-09-06 05:31
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.utils.timezone
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('audits', '0022_auto_20230605_1555'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='ftplog',
|
||||
name='date_start',
|
||||
field=models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='Date start'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='operatelog',
|
||||
name='action',
|
||||
field=models.CharField(choices=[('view', 'View'), ('update', 'Update'), ('delete', 'Delete'), ('create', 'Create'), ('download', 'Download'), ('connect', 'Connect'), ('login', 'Login'), ('change_password', 'Change password')], max_length=16, verbose_name='Action'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='userloginlog',
|
||||
name='datetime',
|
||||
field=models.DateTimeField(db_index=True, default=django.utils.timezone.now, verbose_name='Date login'),
|
||||
),
|
||||
]
|
||||
37
apps/audits/migrations/0024_usersession.py
Normal file
37
apps/audits/migrations/0024_usersession.py
Normal file
@@ -0,0 +1,37 @@
|
||||
# Generated by Django 4.1.10 on 2023-09-15 08:58
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('audits', '0023_auto_20230906_1322'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='UserSession',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
|
||||
('ip', models.GenericIPAddressField(verbose_name='Login IP')),
|
||||
('key', models.CharField(max_length=128, verbose_name='Session key')),
|
||||
('city', models.CharField(blank=True, max_length=254, null=True, verbose_name='Login city')),
|
||||
('user_agent', models.CharField(blank=True, max_length=254, null=True, verbose_name='User agent')),
|
||||
('type', models.CharField(choices=[('W', 'Web'), ('T', 'Terminal'), ('U', 'Unknown')], max_length=2, verbose_name='Login type')),
|
||||
('backend', models.CharField(default='', max_length=32, verbose_name='Authentication backend')),
|
||||
('date_created', models.DateTimeField(blank=True, null=True, verbose_name='Date created')),
|
||||
('date_expired', models.DateTimeField(blank=True, db_index=True, null=True, verbose_name='Date expired')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sessions', to=settings.AUTH_USER_MODEL, verbose_name='User')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'User session',
|
||||
'ordering': ['-date_created'],
|
||||
'permissions': [('offline_usersession', 'Offline ussr session')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,7 +1,9 @@
|
||||
import os
|
||||
import uuid
|
||||
from importlib import import_module
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import caches
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
@@ -28,7 +30,8 @@ __all__ = [
|
||||
"ActivityLog",
|
||||
"PasswordChangeLog",
|
||||
"UserLoginLog",
|
||||
"JobLog"
|
||||
"JobLog",
|
||||
"UserSession"
|
||||
]
|
||||
|
||||
|
||||
@@ -57,7 +60,7 @@ class FTPLog(OrgModelMixin):
|
||||
)
|
||||
filename = models.CharField(max_length=1024, verbose_name=_("Filename"))
|
||||
is_success = models.BooleanField(default=True, verbose_name=_("Success"))
|
||||
date_start = models.DateTimeField(auto_now_add=True, verbose_name=_("Date start"))
|
||||
date_start = models.DateTimeField(auto_now_add=True, verbose_name=_("Date start"), db_index=True)
|
||||
has_file = models.BooleanField(default=False, verbose_name=_("File"))
|
||||
session = models.CharField(max_length=36, verbose_name=_("Session"), default=uuid.uuid4)
|
||||
|
||||
@@ -198,7 +201,7 @@ class UserLoginLog(models.Model):
|
||||
choices=LoginStatusChoices.choices,
|
||||
verbose_name=_("Status"),
|
||||
)
|
||||
datetime = models.DateTimeField(default=timezone.now, verbose_name=_("Date login"))
|
||||
datetime = models.DateTimeField(default=timezone.now, verbose_name=_("Date login"), db_index=True)
|
||||
backend = models.CharField(
|
||||
max_length=32, default="", verbose_name=_("Authentication backend")
|
||||
)
|
||||
@@ -245,3 +248,44 @@ class UserLoginLog(models.Model):
|
||||
class Meta:
|
||||
ordering = ["-datetime", "username"]
|
||||
verbose_name = _("User login log")
|
||||
|
||||
|
||||
class UserSession(models.Model):
|
||||
id = models.UUIDField(default=uuid.uuid4, primary_key=True)
|
||||
ip = models.GenericIPAddressField(verbose_name=_("Login IP"))
|
||||
key = models.CharField(max_length=128, verbose_name=_("Session key"))
|
||||
city = models.CharField(max_length=254, blank=True, null=True, verbose_name=_("Login city"))
|
||||
user_agent = models.CharField(max_length=254, blank=True, null=True, verbose_name=_("User agent"))
|
||||
type = models.CharField(choices=LoginTypeChoices.choices, max_length=2, verbose_name=_("Login type"))
|
||||
backend = models.CharField(max_length=32, default="", verbose_name=_("Authentication backend"))
|
||||
date_created = models.DateTimeField(null=True, blank=True, verbose_name=_('Date created'))
|
||||
date_expired = models.DateTimeField(null=True, blank=True, verbose_name=_("Date expired"), db_index=True)
|
||||
user = models.ForeignKey(
|
||||
'users.User', verbose_name=_('User'), related_name='sessions', on_delete=models.CASCADE
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return '%s(%s)' % (self.user, self.ip)
|
||||
|
||||
@property
|
||||
def backend_display(self):
|
||||
return gettext(self.backend)
|
||||
|
||||
@staticmethod
|
||||
def get_keys():
|
||||
session_store_cls = import_module(settings.SESSION_ENGINE).SessionStore
|
||||
cache_key_prefix = session_store_cls.cache_key_prefix
|
||||
keys = caches[settings.SESSION_CACHE_ALIAS].keys('*')
|
||||
return [k.replace(cache_key_prefix, '') for k in keys]
|
||||
|
||||
@classmethod
|
||||
def clear_expired_sessions(cls):
|
||||
cls.objects.filter(date_expired__lt=timezone.now()).delete()
|
||||
cls.objects.exclude(key__in=cls.get_keys()).delete()
|
||||
|
||||
class Meta:
|
||||
ordering = ['-date_created']
|
||||
verbose_name = _('User session')
|
||||
permissions = [
|
||||
('offline_usersession', _('Offline ussr session')),
|
||||
]
|
||||
|
||||
@@ -4,12 +4,13 @@ from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework import serializers
|
||||
|
||||
from audits.backends.db import OperateLogStore
|
||||
from common.serializers.fields import LabeledChoiceField
|
||||
from common.serializers.fields import LabeledChoiceField, ObjectRelatedField
|
||||
from common.utils import reverse, i18n_trans
|
||||
from common.utils.timezone import as_current_tz
|
||||
from ops.serializers.job import JobExecutionSerializer
|
||||
from orgs.mixins.serializers import BulkOrgResourceModelSerializer
|
||||
from terminal.models import Session
|
||||
from users.models import User
|
||||
from . import models
|
||||
from .const import (
|
||||
ActionChoices, OperateChoices,
|
||||
@@ -163,3 +164,27 @@ class ActivityUnionLogSerializer(serializers.Serializer):
|
||||
|
||||
class FileSerializer(serializers.Serializer):
|
||||
file = serializers.FileField(allow_empty_file=True)
|
||||
|
||||
|
||||
class UserSessionSerializer(serializers.ModelSerializer):
|
||||
type = LabeledChoiceField(choices=LoginTypeChoices.choices, label=_("Type"))
|
||||
user = ObjectRelatedField(required=False, queryset=User.objects, label=_('User'))
|
||||
is_current_user_session = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = models.UserSession
|
||||
fields_mini = ['id']
|
||||
fields_small = fields_mini + [
|
||||
'type', 'ip', 'city', 'user_agent', 'user', 'is_current_user_session',
|
||||
'backend', 'backend_display', 'date_created', 'date_expired'
|
||||
]
|
||||
fields = fields_small
|
||||
extra_kwargs = {
|
||||
"backend_display": {"label": _("Authentication backend")},
|
||||
}
|
||||
|
||||
def get_is_current_user_session(self, obj):
|
||||
request = self.context.get('request')
|
||||
if not request:
|
||||
return False
|
||||
return request.session.session_key == obj.key
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
from datetime import timedelta
|
||||
from importlib import import_module
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import BACKEND_SESSION_KEY
|
||||
from django.dispatch import receiver
|
||||
@@ -8,10 +11,13 @@ from django.utils.functional import LazyObject
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework.request import Request
|
||||
|
||||
from audits.models import UserLoginLog
|
||||
from authentication.signals import post_auth_failed, post_auth_success
|
||||
from authentication.utils import check_different_city_login_if_need
|
||||
from common.utils import get_request_ip, get_logger
|
||||
from users.models import User
|
||||
from ..const import LoginTypeChoices
|
||||
from ..models import UserSession
|
||||
from ..utils import write_login_log
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -32,6 +38,7 @@ class AuthBackendLabelMapping(LazyObject):
|
||||
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")
|
||||
backend_label_mapping[settings.AUTH_BACKEND_PASSKEY] = _("Passkey")
|
||||
return backend_label_mapping
|
||||
|
||||
def _setup(self):
|
||||
@@ -74,6 +81,27 @@ def generate_data(username, request, login_type=None):
|
||||
return data
|
||||
|
||||
|
||||
def create_user_session(request, user_id, instance: UserLoginLog):
|
||||
session_key = request.session.session_key or '-'
|
||||
session_store_cls = import_module(settings.SESSION_ENGINE).SessionStore
|
||||
session_store = session_store_cls(session_key=session_key)
|
||||
ttl = session_store.get_expiry_age()
|
||||
|
||||
online_session_data = {
|
||||
'user_id': user_id,
|
||||
'ip': instance.ip,
|
||||
'key': session_key,
|
||||
'city': instance.city,
|
||||
'type': instance.type,
|
||||
'backend': instance.backend,
|
||||
'user_agent': instance.user_agent,
|
||||
'date_created': instance.datetime,
|
||||
'date_expired': instance.datetime + timedelta(seconds=ttl),
|
||||
}
|
||||
user_session = UserSession.objects.create(**online_session_data)
|
||||
request.session['user_session_id'] = user_session.id
|
||||
|
||||
|
||||
@receiver(post_auth_success)
|
||||
def on_user_auth_success(sender, user, request, login_type=None, **kwargs):
|
||||
logger.debug('User login success: {}'.format(user.username))
|
||||
@@ -83,7 +111,11 @@ def on_user_auth_success(sender, user, request, login_type=None, **kwargs):
|
||||
)
|
||||
request.session['login_time'] = data['datetime'].strftime("%Y-%m-%d %H:%M:%S")
|
||||
data.update({'mfa': int(user.mfa_enabled), 'status': True})
|
||||
write_login_log(**data)
|
||||
instance = write_login_log(**data)
|
||||
# TODO 目前只记录 web 登录的 session
|
||||
if instance.type != LoginTypeChoices.web:
|
||||
return
|
||||
create_user_session(request, user.id, instance)
|
||||
|
||||
|
||||
@receiver(post_auth_failed)
|
||||
|
||||
@@ -15,6 +15,7 @@ router.register(r'operate-logs', api.OperateLogViewSet, 'operate-log')
|
||||
router.register(r'password-change-logs', api.PasswordChangeLogViewSet, 'password-change-log')
|
||||
router.register(r'job-logs', api.JobAuditViewSet, 'job-log')
|
||||
router.register(r'my-login-logs', api.MyLoginLogViewSet, 'my-login-log')
|
||||
router.register(r'user-sessions', api.UserSessionViewSet, 'user-session')
|
||||
|
||||
urlpatterns = [
|
||||
path('activities/', api.ResourceActivityAPIView.as_view(), name='resource-activities'),
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import copy
|
||||
from itertools import chain
|
||||
from datetime import datetime
|
||||
from itertools import chain
|
||||
|
||||
from django.db import models
|
||||
|
||||
from common.utils.timezone import as_current_tz
|
||||
from common.utils import validate_ip, get_ip_city, get_logger
|
||||
from common.db.fields import RelatedManager
|
||||
from common.utils import validate_ip, get_ip_city, get_logger
|
||||
from common.utils.timezone import as_current_tz
|
||||
from .const import DEFAULT_CITY
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -22,7 +22,7 @@ def write_login_log(*args, **kwargs):
|
||||
else:
|
||||
city = get_ip_city(ip) or DEFAULT_CITY
|
||||
kwargs.update({'ip': ip, 'city': city})
|
||||
UserLoginLog.objects.create(**kwargs)
|
||||
return UserLoginLog.objects.create(**kwargs)
|
||||
|
||||
|
||||
def _get_instance_field_value(
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
|
||||
from .connection_token import *
|
||||
from .token import *
|
||||
from .mfa import *
|
||||
from .access_key import *
|
||||
from .confirm import *
|
||||
from .login_confirm import *
|
||||
from .sso import *
|
||||
from .wecom import *
|
||||
from .connection_token import *
|
||||
from .dingtalk import *
|
||||
from .feishu import *
|
||||
from .login_confirm import *
|
||||
from .mfa import *
|
||||
from .password import *
|
||||
from .sso import *
|
||||
from .temp_token import *
|
||||
from .token import *
|
||||
from .wecom import *
|
||||
|
||||
@@ -13,7 +13,7 @@ from ..serializers import ConfirmSerializer
|
||||
|
||||
|
||||
class ConfirmBindORUNBindOAuth(RetrieveAPIView):
|
||||
permission_classes = (UserConfirmation.require(ConfirmType.ReLogin),)
|
||||
permission_classes = (IsValidUser, UserConfirmation.require(ConfirmType.ReLogin),)
|
||||
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
return Response('ok')
|
||||
|
||||
@@ -24,6 +24,8 @@ from orgs.mixins.api import RootOrgViewMixin
|
||||
from perms.models import ActionChoices
|
||||
from terminal.connect_methods import NativeClient, ConnectMethodUtil
|
||||
from terminal.models import EndpointRule, Endpoint
|
||||
from users.const import FileNameConflictResolution
|
||||
from users.models import Preference
|
||||
from ..models import ConnectionToken, date_expired_default
|
||||
from ..serializers import (
|
||||
ConnectionTokenSerializer, ConnectionTokenSecretSerializer,
|
||||
@@ -310,9 +312,20 @@ class ConnectionTokenViewSet(ExtraActionApiMixin, RootOrgViewMixin, JMSModelView
|
||||
self.validate_serializer(serializer)
|
||||
return super().perform_create(serializer)
|
||||
|
||||
def _insert_connect_options(self, data, user):
|
||||
name = 'file_name_conflict_resolution'
|
||||
connect_options = data.pop('connect_options', {})
|
||||
preference = Preference.objects.filter(
|
||||
name=name, user=user, category='koko'
|
||||
).first()
|
||||
value = preference.value if preference else FileNameConflictResolution.REPLACE
|
||||
connect_options[name] = value
|
||||
data['connect_options'] = connect_options
|
||||
|
||||
def validate_serializer(self, serializer):
|
||||
data = serializer.validated_data
|
||||
user = self.get_user(serializer)
|
||||
self._insert_connect_options(data, user)
|
||||
asset = data.get('asset')
|
||||
account_name = data.get('account')
|
||||
_data = self._validate(user, asset, account_name)
|
||||
@@ -363,7 +376,7 @@ class ConnectionTokenViewSet(ExtraActionApiMixin, RootOrgViewMixin, JMSModelView
|
||||
|
||||
def _validate_acl(self, user, asset, account):
|
||||
from acls.models import LoginAssetACL
|
||||
acls = LoginAssetACL.filter_queryset(user, asset, account)
|
||||
acls = LoginAssetACL.filter_queryset(user=user, asset=asset, account=account)
|
||||
ip = get_request_ip(self.request)
|
||||
acl = LoginAssetACL.get_match_rule_acls(user, ip, acls)
|
||||
if not acl:
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from users.models import User
|
||||
from common.utils import get_logger
|
||||
from common.permissions import UserConfirmation
|
||||
from common.api import RoleUserMixin, RoleAdminMixin
|
||||
from authentication.const import ConfirmType
|
||||
from authentication import errors
|
||||
from authentication.const import ConfirmType
|
||||
from common.api import RoleUserMixin, RoleAdminMixin
|
||||
from common.permissions import UserConfirmation, IsValidUser
|
||||
from common.utils import get_logger
|
||||
from users.models import User
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
||||
@@ -27,7 +27,7 @@ class DingTalkQRUnBindBase(APIView):
|
||||
|
||||
|
||||
class DingTalkQRUnBindForUserApi(RoleUserMixin, DingTalkQRUnBindBase):
|
||||
permission_classes = (UserConfirmation.require(ConfirmType.ReLogin),)
|
||||
permission_classes = (IsValidUser, UserConfirmation.require(ConfirmType.ReLogin),)
|
||||
|
||||
|
||||
class DingTalkQRUnBindForAdminApi(RoleAdminMixin, DingTalkQRUnBindBase):
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from users.models import User
|
||||
from common.utils import get_logger
|
||||
from common.permissions import UserConfirmation
|
||||
from common.api import RoleUserMixin, RoleAdminMixin
|
||||
from authentication.const import ConfirmType
|
||||
from authentication import errors
|
||||
from authentication.const import ConfirmType
|
||||
from common.api import RoleUserMixin, RoleAdminMixin
|
||||
from common.permissions import UserConfirmation, IsValidUser
|
||||
from common.utils import get_logger
|
||||
from users.models import User
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
||||
@@ -27,7 +27,7 @@ class FeiShuQRUnBindBase(APIView):
|
||||
|
||||
|
||||
class FeiShuQRUnBindForUserApi(RoleUserMixin, FeiShuQRUnBindBase):
|
||||
permission_classes = (UserConfirmation.require(ConfirmType.ReLogin),)
|
||||
permission_classes = (IsValidUser, UserConfirmation.require(ConfirmType.ReLogin),)
|
||||
|
||||
|
||||
class FeiShuQRUnBindForAdminApi(RoleAdminMixin, FeiShuQRUnBindBase):
|
||||
@@ -38,7 +38,7 @@ class FeiShuEventSubscriptionCallback(APIView):
|
||||
"""
|
||||
# https://open.feishu.cn/document/ukTMukTMukTM/uUTNz4SN1MjL1UzM
|
||||
"""
|
||||
permission_classes = ()
|
||||
permission_classes = (IsValidUser,)
|
||||
|
||||
def post(self, request: Request, *args, **kwargs):
|
||||
return Response(data=request.data)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.translation import gettext as _
|
||||
from rest_framework import exceptions
|
||||
from rest_framework.generics import CreateAPIView
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.response import Response
|
||||
@@ -13,6 +14,7 @@ from common.utils import get_logger
|
||||
from users.models.user import User
|
||||
from .. import errors
|
||||
from .. import serializers
|
||||
from ..errors import SessionEmptyError
|
||||
from ..mixins import AuthMixin
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -56,6 +58,7 @@ class MFASendCodeApi(AuthMixin, CreateAPIView):
|
||||
if not mfa_backend or not mfa_backend.challenge_required:
|
||||
error = _('Current user not support mfa type: {}').format(mfa_type)
|
||||
raise ValidationError({'error': error})
|
||||
|
||||
try:
|
||||
mfa_backend.send_challenge()
|
||||
except Exception as e:
|
||||
@@ -66,6 +69,15 @@ class MFAChallengeVerifyApi(AuthMixin, CreateAPIView):
|
||||
permission_classes = (AllowAny,)
|
||||
serializer_class = serializers.MFAChallengeSerializer
|
||||
|
||||
def initial(self, request, *args, **kwargs):
|
||||
super().initial(request, *args, **kwargs)
|
||||
try:
|
||||
user = self.get_user_from_session()
|
||||
except SessionEmptyError:
|
||||
user = None
|
||||
if not user:
|
||||
raise exceptions.NotAuthenticated()
|
||||
|
||||
def perform_create(self, serializer):
|
||||
user = self.get_user_from_session()
|
||||
code = serializer.validated_data.get('code')
|
||||
|
||||
@@ -1,26 +1,27 @@
|
||||
from uuid import UUID
|
||||
from urllib.parse import urlencode
|
||||
from uuid import UUID
|
||||
|
||||
from django.contrib.auth import login
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import login
|
||||
from django.http.response import HttpResponseRedirect
|
||||
from rest_framework import serializers
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
|
||||
from common.utils.timezone import utc_now
|
||||
from common.const.http import POST, GET
|
||||
from common.api import JMSGenericViewSet
|
||||
from common.serializers import EmptySerializer
|
||||
from common.const.http import POST, GET
|
||||
from common.permissions import OnlySuperUser
|
||||
from common.serializers import EmptySerializer
|
||||
from common.utils import reverse
|
||||
from common.utils.timezone import utc_now
|
||||
from users.models import User
|
||||
from ..serializers import SSOTokenSerializer
|
||||
from ..models import SSOToken
|
||||
from ..errors import SSOAuthClosed
|
||||
from ..filters import AuthKeyQueryDeclaration
|
||||
from ..mixins import AuthMixin
|
||||
from ..errors import SSOAuthClosed
|
||||
from ..models import SSOToken
|
||||
from ..serializers import SSOTokenSerializer
|
||||
|
||||
NEXT_URL = 'next'
|
||||
AUTH_KEY = 'authkey'
|
||||
@@ -67,6 +68,9 @@ class SSOViewSet(AuthMixin, JMSGenericViewSet):
|
||||
if not next_url or not next_url.startswith('/'):
|
||||
next_url = reverse('index')
|
||||
|
||||
if not authkey:
|
||||
raise serializers.ValidationError("authkey is required")
|
||||
|
||||
try:
|
||||
authkey = UUID(authkey)
|
||||
token = SSOToken.objects.get(authkey=authkey, expired=False)
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from users.models import User
|
||||
from common.utils import get_logger
|
||||
from common.permissions import UserConfirmation
|
||||
from common.api import RoleUserMixin, RoleAdminMixin
|
||||
from authentication.const import ConfirmType
|
||||
from authentication import errors
|
||||
from authentication.const import ConfirmType
|
||||
from common.api import RoleUserMixin, RoleAdminMixin
|
||||
from common.permissions import UserConfirmation, IsValidUser
|
||||
from common.utils import get_logger
|
||||
from users.models import User
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
||||
@@ -27,7 +27,7 @@ class WeComQRUnBindBase(APIView):
|
||||
|
||||
|
||||
class WeComQRUnBindForUserApi(RoleUserMixin, WeComQRUnBindBase):
|
||||
permission_classes = (UserConfirmation.require(ConfirmType.ReLogin),)
|
||||
permission_classes = (IsValidUser, UserConfirmation.require(ConfirmType.ReLogin),)
|
||||
|
||||
|
||||
class WeComQRUnBindForAdminApi(RoleAdminMixin, WeComQRUnBindBase):
|
||||
|
||||
1
apps/authentication/backends/passkey/__init__.py
Normal file
1
apps/authentication/backends/passkey/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .backends import *
|
||||
59
apps/authentication/backends/passkey/api.py
Normal file
59
apps/authentication/backends/passkey/api.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from django.conf import settings
|
||||
from django.http import JsonResponse
|
||||
from django.shortcuts import render
|
||||
from django.utils.translation import gettext as _
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.permissions import IsAuthenticated, AllowAny
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from authentication.mixins import AuthMixin
|
||||
from .fido import register_begin, register_complete, auth_begin, auth_complete
|
||||
from .models import Passkey
|
||||
from .serializer import PasskeySerializer
|
||||
from ...views import FlashMessageMixin
|
||||
|
||||
|
||||
class PasskeyViewSet(AuthMixin, FlashMessageMixin, ModelViewSet):
|
||||
serializer_class = PasskeySerializer
|
||||
permission_classes = (IsAuthenticated,)
|
||||
|
||||
def get_queryset(self):
|
||||
return Passkey.objects.filter(user=self.request.user)
|
||||
|
||||
@action(methods=['get', 'post'], detail=False, url_path='register')
|
||||
def register(self, request):
|
||||
if request.method == 'GET':
|
||||
register_data, state = register_begin(request)
|
||||
return JsonResponse(dict(register_data))
|
||||
else:
|
||||
passkey = register_complete(request)
|
||||
return JsonResponse({'id': passkey.id.__str__(), 'name': passkey.name})
|
||||
|
||||
@action(methods=['get'], detail=False, url_path='login', permission_classes=[AllowAny])
|
||||
def login(self, request):
|
||||
return render(request, 'authentication/passkey.html', {})
|
||||
|
||||
def redirect_to_error(self, error):
|
||||
self.send_auth_signal(success=False, username='unknown', reason='passkey')
|
||||
return render(self.request, 'authentication/passkey.html', {'error': error})
|
||||
|
||||
@action(methods=['get', 'post'], detail=False, url_path='auth', permission_classes=[AllowAny])
|
||||
def auth(self, request):
|
||||
if request.method == 'GET':
|
||||
auth_data = auth_begin(request)
|
||||
return JsonResponse(dict(auth_data))
|
||||
|
||||
try:
|
||||
user = auth_complete(request)
|
||||
except ValueError as e:
|
||||
return self.redirect_to_error(str(e))
|
||||
|
||||
if not user:
|
||||
return self.redirect_to_error(_('Auth failed'))
|
||||
|
||||
try:
|
||||
self.check_oauth2_auth(user, settings.AUTH_BACKEND_PASSKEY)
|
||||
return self.redirect_to_guard_view()
|
||||
except Exception as e:
|
||||
msg = getattr(e, 'msg', '') or str(e)
|
||||
return self.redirect_to_error(msg)
|
||||
9
apps/authentication/backends/passkey/backends.py
Normal file
9
apps/authentication/backends/passkey/backends.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from django.conf import settings
|
||||
|
||||
from ..base import JMSModelBackend
|
||||
|
||||
|
||||
class PasskeyAuthBackend(JMSModelBackend):
|
||||
@staticmethod
|
||||
def is_enabled():
|
||||
return settings.AUTH_PASSKEY
|
||||
157
apps/authentication/backends/passkey/fido.py
Normal file
157
apps/authentication/backends/passkey/fido.py
Normal file
@@ -0,0 +1,157 @@
|
||||
import json
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import fido2.features
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext as _
|
||||
from fido2.server import Fido2Server
|
||||
from fido2.utils import websafe_decode, websafe_encode
|
||||
from fido2.webauthn import PublicKeyCredentialRpEntity, AttestedCredentialData, PublicKeyCredentialUserEntity
|
||||
from rest_framework.serializers import ValidationError
|
||||
from user_agents.parsers import parse as ua_parse
|
||||
|
||||
from common.utils import get_logger
|
||||
from .models import Passkey
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
try:
|
||||
fido2.features.webauthn_json_mapping.enabled = True
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
def get_current_platform(request):
|
||||
ua = ua_parse(request.META["HTTP_USER_AGENT"])
|
||||
if 'Safari' in ua.browser.family:
|
||||
return "Apple"
|
||||
elif 'Chrome' in ua.browser.family and ua.os.family == "Mac OS X":
|
||||
return "Chrome on Apple"
|
||||
elif 'Android' in ua.os.family:
|
||||
return "Google"
|
||||
elif "Windows" in ua.os.family:
|
||||
return "Microsoft"
|
||||
else:
|
||||
return "Key"
|
||||
|
||||
|
||||
def get_server_id_from_request(request, allowed=()):
|
||||
origin = request.META.get('HTTP_REFERER')
|
||||
if not origin:
|
||||
origin = request.get_host()
|
||||
p = urlparse(origin)
|
||||
if p.netloc in allowed or p.hostname in allowed:
|
||||
return p.hostname
|
||||
else:
|
||||
return 'localhost'
|
||||
|
||||
|
||||
def default_server_id(request):
|
||||
domains = list(settings.ALLOWED_DOMAINS)
|
||||
if settings.SITE_URL:
|
||||
domains.append(urlparse(settings.SITE_URL).hostname)
|
||||
return get_server_id_from_request(request, allowed=domains)
|
||||
|
||||
|
||||
def get_server(request=None):
|
||||
"""Get Server Info from settings and returns a Fido2Server"""
|
||||
|
||||
server_id = settings.FIDO_SERVER_ID or default_server_id(request)
|
||||
if callable(server_id):
|
||||
fido_server_id = settings.FIDO_SERVER_ID(request)
|
||||
elif ',' in server_id:
|
||||
fido_server_id = get_server_id_from_request(request, allowed=server_id.split(','))
|
||||
else:
|
||||
fido_server_id = server_id
|
||||
|
||||
logger.debug('Fido server id: {}'.format(fido_server_id))
|
||||
if callable(settings.FIDO_SERVER_NAME):
|
||||
fido_server_name = settings.FIDO_SERVER_NAME(request)
|
||||
else:
|
||||
fido_server_name = settings.FIDO_SERVER_NAME
|
||||
|
||||
rp = PublicKeyCredentialRpEntity(id=fido_server_id, name=fido_server_name)
|
||||
return Fido2Server(rp)
|
||||
|
||||
|
||||
def get_user_credentials(username):
|
||||
user_passkeys = Passkey.objects.filter(user__username=username)
|
||||
return [AttestedCredentialData(websafe_decode(uk.token)) for uk in user_passkeys]
|
||||
|
||||
|
||||
def register_begin(request):
|
||||
server = get_server(request)
|
||||
user = request.user
|
||||
user_credentials = get_user_credentials(user.username)
|
||||
|
||||
prefix = request.query_params.get('name', '')
|
||||
prefix = '(' + prefix + ')'
|
||||
user_entity = PublicKeyCredentialUserEntity(
|
||||
id=str(user.id).encode('utf8'),
|
||||
name=user.username + prefix,
|
||||
display_name=user.name,
|
||||
)
|
||||
auth_attachment = getattr(settings, 'KEY_ATTACHMENT', None)
|
||||
data, state = server.register_begin(
|
||||
user_entity, user_credentials,
|
||||
authenticator_attachment=auth_attachment,
|
||||
resident_key_requirement=fido2.webauthn.ResidentKeyRequirement.PREFERRED
|
||||
)
|
||||
request.session['fido2_state'] = state
|
||||
data = dict(data)
|
||||
return data, state
|
||||
|
||||
|
||||
def register_complete(request):
|
||||
if not request.session.get("fido2_state"):
|
||||
raise ValidationError("No state found")
|
||||
data = request.data
|
||||
server = get_server(request)
|
||||
state = request.session.pop("fido2_state")
|
||||
auth_data = server.register_complete(state, response=data)
|
||||
encoded = websafe_encode(auth_data.credential_data)
|
||||
platform = get_current_platform(request)
|
||||
name = data.pop("key_name", '') or platform
|
||||
passkey = Passkey.objects.create(
|
||||
user=request.user,
|
||||
token=encoded,
|
||||
name=name,
|
||||
platform=platform,
|
||||
credential_id=data.get('id')
|
||||
)
|
||||
return passkey
|
||||
|
||||
|
||||
def auth_begin(request):
|
||||
server = get_server(request)
|
||||
credentials = []
|
||||
|
||||
username = None
|
||||
if request.user.is_authenticated:
|
||||
username = request.user.username
|
||||
if username:
|
||||
credentials = get_user_credentials(username)
|
||||
auth_data, state = server.authenticate_begin(credentials)
|
||||
request.session['fido2_state'] = state
|
||||
return auth_data
|
||||
|
||||
|
||||
def auth_complete(request):
|
||||
server = get_server(request)
|
||||
data = request.data.get("passkeys")
|
||||
data = json.loads(data)
|
||||
cid = data['id']
|
||||
|
||||
key = Passkey.objects.filter(credential_id=cid, is_active=True).first()
|
||||
if not key:
|
||||
raise ValueError(_("This key is not registered"))
|
||||
|
||||
credentials = [AttestedCredentialData(websafe_decode(key.token))]
|
||||
state = request.session.get('fido2_state')
|
||||
server.authenticate_complete(state, credentials=credentials, response=data)
|
||||
|
||||
request.session["passkey"] = '{}_{}'.format(key.id, key.name)
|
||||
key.date_last_used = timezone.now()
|
||||
key.save(update_fields=['date_last_used'])
|
||||
return key.user
|
||||
19
apps/authentication/backends/passkey/models.py
Normal file
19
apps/authentication/backends/passkey/models.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from common.db.models import JMSBaseModel
|
||||
|
||||
|
||||
class Passkey(JMSBaseModel):
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
|
||||
name = models.CharField(max_length=255, verbose_name=_("Name"))
|
||||
is_active = models.BooleanField(default=True, verbose_name=_("Enabled"))
|
||||
platform = models.CharField(max_length=255, default='', verbose_name=_("Platform"))
|
||||
added_on = models.DateTimeField(auto_now_add=True, verbose_name=_("Added on"))
|
||||
date_last_used = models.DateTimeField(null=True, default=None, verbose_name=_("Date last used"))
|
||||
credential_id = models.CharField(max_length=255, unique=True, null=False, verbose_name=_("Credential ID"))
|
||||
token = models.CharField(max_length=255, null=False, verbose_name=_("Token"))
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
13
apps/authentication/backends/passkey/serializer.py
Normal file
13
apps/authentication/backends/passkey/serializer.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from .models import Passkey
|
||||
|
||||
|
||||
class PasskeySerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Passkey
|
||||
fields = [
|
||||
'id', 'name', 'is_active', 'platform', 'created_by',
|
||||
'date_last_used', 'date_created',
|
||||
]
|
||||
read_only_fields = list(set(fields) - {'is_active'})
|
||||
9
apps/authentication/backends/passkey/urls.py
Normal file
9
apps/authentication/backends/passkey/urls.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from . import api
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register('passkeys', api.PasskeyViewSet, 'passkey')
|
||||
|
||||
urlpatterns = []
|
||||
urlpatterns += router.urls
|
||||
@@ -1,8 +1,9 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
from common.permissions import ServiceAccountSignaturePermission
|
||||
from .base import JMSBaseAuthBackend
|
||||
|
||||
UserModel = get_user_model()
|
||||
@@ -18,6 +19,10 @@ class PublicKeyAuthBackend(JMSBaseAuthBackend):
|
||||
def authenticate(self, request, username=None, public_key=None, **kwargs):
|
||||
if not public_key:
|
||||
return None
|
||||
|
||||
permission = ServiceAccountSignaturePermission()
|
||||
if not permission.has_permission(request, None):
|
||||
return None
|
||||
if username is None:
|
||||
username = kwargs.get(UserModel.USERNAME_FIELD)
|
||||
try:
|
||||
@@ -26,7 +31,7 @@ class PublicKeyAuthBackend(JMSBaseAuthBackend):
|
||||
return None
|
||||
else:
|
||||
if user.check_public_key(public_key) and \
|
||||
self.user_can_authenticate(user):
|
||||
self.user_can_authenticate(user):
|
||||
return user
|
||||
|
||||
def get_user(self, user_id):
|
||||
|
||||
@@ -146,7 +146,9 @@ class PrepareRequestMixin:
|
||||
},
|
||||
'singleLogoutService': {
|
||||
'url': f"{sp_host}{reverse('authentication:saml2:saml2-logout')}"
|
||||
}
|
||||
},
|
||||
'privateKey': getattr(settings, 'SAML2_SP_KEY_CONTENT', ''),
|
||||
'x509cert': getattr(settings, 'SAML2_SP_CERT_CONTENT', ''),
|
||||
}
|
||||
}
|
||||
sp_settings['sp'].update(attrs)
|
||||
|
||||
39
apps/authentication/migrations/0022_passkey.py
Normal file
39
apps/authentication/migrations/0022_passkey.py
Normal file
@@ -0,0 +1,39 @@
|
||||
# Generated by Django 4.1.10 on 2023-09-08 08:10
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('authentication', '0021_auto_20230713_1459'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Passkey',
|
||||
fields=[
|
||||
('created_by', models.CharField(blank=True, max_length=128, null=True, verbose_name='Created by')),
|
||||
('updated_by', models.CharField(blank=True, max_length=128, null=True, verbose_name='Updated by')),
|
||||
('date_created', models.DateTimeField(auto_now_add=True, null=True, verbose_name='Date created')),
|
||||
('date_updated', models.DateTimeField(auto_now=True, verbose_name='Date updated')),
|
||||
('comment', models.TextField(blank=True, default='', verbose_name='Comment')),
|
||||
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
|
||||
('name', models.CharField(max_length=255, verbose_name='Name')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='Enabled')),
|
||||
('platform', models.CharField(default='', max_length=255, verbose_name='Platform')),
|
||||
('added_on', models.DateTimeField(auto_now_add=True, verbose_name='Added on')),
|
||||
('date_last_used', models.DateTimeField(default=None, null=True, verbose_name='Date last used')),
|
||||
('credential_id', models.CharField(max_length=255, unique=True, verbose_name='Credential ID')),
|
||||
('token', models.CharField(max_length=255, verbose_name='Token')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -132,11 +132,11 @@ class CommonMixin:
|
||||
return user
|
||||
|
||||
user_id = self.request.session.get('user_id')
|
||||
auth_password = self.request.session.get('auth_password')
|
||||
auth_ok = self.request.session.get('auth_password')
|
||||
auth_expired_at = self.request.session.get('auth_password_expired_at')
|
||||
auth_expired = auth_expired_at < time.time() if auth_expired_at else False
|
||||
|
||||
if not user_id or not auth_password or auth_expired:
|
||||
if not user_id or not auth_ok or auth_expired:
|
||||
raise errors.SessionEmptyError()
|
||||
|
||||
user = get_object_or_404(User, pk=user_id)
|
||||
@@ -479,6 +479,7 @@ class AuthMixin(CommonMixin, AuthPreCheckMixin, AuthACLMixin, MFAMixin, AuthPost
|
||||
request.session['auto_login'] = auto_login
|
||||
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):
|
||||
@@ -511,7 +512,8 @@ class AuthMixin(CommonMixin, AuthPreCheckMixin, AuthACLMixin, MFAMixin, AuthPost
|
||||
|
||||
def clear_auth_mark(self):
|
||||
keys = [
|
||||
'auth_password', 'user_id', 'auth_confirm_required', 'auth_ticket_id', 'auth_acl_id'
|
||||
'auth_password', 'user_id', 'auth_confirm_required',
|
||||
'auth_ticket_id', 'auth_acl_id'
|
||||
]
|
||||
for k in keys:
|
||||
self.request.session.pop(k, '')
|
||||
|
||||
@@ -7,6 +7,7 @@ from django.dispatch import receiver
|
||||
from django_cas_ng.signals import cas_user_authenticated
|
||||
|
||||
from apps.jumpserver.settings.auth import AUTHENTICATION_BACKENDS_THIRD_PARTY
|
||||
from audits.models import UserSession
|
||||
from .signals import post_auth_success, post_auth_failed, user_auth_failed, user_auth_success
|
||||
|
||||
|
||||
@@ -23,6 +24,9 @@ def on_user_auth_login_success(sender, user, request, **kwargs):
|
||||
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
|
||||
|
||||
user_session_id = request.session.get('user_session_id')
|
||||
UserSession.objects.filter(id=user_session_id).update(key=request.session.session_key)
|
||||
# 单点登录,超过了自动退出
|
||||
if settings.USER_LOGIN_SINGLE_MACHINE_ENABLED:
|
||||
lock_key = 'single_machine_login_' + str(user.id)
|
||||
@@ -30,6 +34,7 @@ def on_user_auth_login_success(sender, user, request, **kwargs):
|
||||
if session_key and session_key != request.session.session_key:
|
||||
session = import_module(settings.SESSION_ENGINE).SessionStore(session_key)
|
||||
session.delete()
|
||||
UserSession.objects.filter(key=session_key).delete()
|
||||
cache.set(lock_key, request.session.session_key, None)
|
||||
|
||||
# 标记登录,设置 cookie,前端可以控制刷新, Middleware 会拦截这个生成 cookie
|
||||
|
||||
@@ -223,10 +223,55 @@
|
||||
height: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.error-info {
|
||||
font-size: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mobile-logo {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
body {
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.login-content {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.left-form-box {
|
||||
width: 100%;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.right-image-box {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navbar-top-links {
|
||||
display: inline;
|
||||
float: right;
|
||||
}
|
||||
|
||||
.mobile-logo {
|
||||
display: block;
|
||||
padding: 20px 30px 0 30px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
{% if error_origin %}
|
||||
<div class='alert alert-danger error-info'>
|
||||
{% trans 'Configuration file has problems and cannot be logged in. Please contact the administrator or view latest docs' %}<br/>
|
||||
{% trans 'If you are administrator, you can update the config resolve it, set' %} <br/>
|
||||
DOMAINS={{ error_origin }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<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.git{% endif %}">
|
||||
@@ -234,6 +279,11 @@
|
||||
</a>
|
||||
</div>
|
||||
<div class="left-form-box {% if not form.challenge and not form.captcha %} no-captcha-challenge {% endif %}">
|
||||
<div class="mobile-logo">
|
||||
<a href="{% if not XPACK_ENABLED %}https://github.com/jumpserver/jumpserver.git{% endif %}">
|
||||
<img src="{% static 'img/logo_text_green.png' %}" class="right-image" alt="screen-image"/>
|
||||
</a>
|
||||
</div>
|
||||
<div style="position: relative;top: 50%;transform: translateY(-50%);">
|
||||
<div style='padding: 15px 60px; text-align: left'>
|
||||
<h2 style='font-weight: 400;display: inline'>
|
||||
@@ -294,13 +344,13 @@
|
||||
{% endif %}
|
||||
<div class="form-group auto-login" style="margin-bottom: 10px">
|
||||
<div class="row" style="overflow: hidden;">
|
||||
<div class="col-md-6" style="text-align: left">
|
||||
<div class="col-md-6 col-xs-6" style="text-align: left">
|
||||
{% if form.auto_login %}
|
||||
{% bootstrap_field form.auto_login form_group_class='auto_login_box' %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-6" style="line-height: 25px">
|
||||
<div class="col-md-6 col-xs-6" style="line-height: 25px">
|
||||
<a id="forgot_password" href="{{ forgot_password_url }}" style="float: right">
|
||||
<small>{% trans 'Forgot password' %}?</small>
|
||||
</a>
|
||||
|
||||
191
apps/authentication/templates/authentication/passkey.html
Normal file
191
apps/authentication/templates/authentication/passkey.html
Normal file
@@ -0,0 +1,191 @@
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Login passkey</title>
|
||||
<script src="{% static "js/jquery-3.6.1.min.js" %}?_=9"></script>
|
||||
</head>
|
||||
<body>
|
||||
<form action='{% url 'api-auth:passkey-auth' %}' method="post" id="loginForm">
|
||||
<input type="hidden" name="passkeys" id="passkeys"/>
|
||||
</form>
|
||||
</body>
|
||||
<script>
|
||||
const loginUrl = "/core/auth/login/";
|
||||
window.conditionalUI = false;
|
||||
window.conditionUIAbortController = new AbortController();
|
||||
window.conditionUIAbortSignal = conditionUIAbortController.signal;
|
||||
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'
|
||||
|
||||
// Use a lookup table to find the index.
|
||||
const lookup = new Uint8Array(256)
|
||||
for (let i = 0; i < chars.length; i++) {
|
||||
lookup[chars.charCodeAt(i)] = i
|
||||
}
|
||||
|
||||
const encode = function (arraybuffer) {
|
||||
const bytes = new Uint8Array(arraybuffer)
|
||||
let i;
|
||||
const len = bytes.length;
|
||||
let base64url = ''
|
||||
|
||||
for (i = 0; i < len; i += 3) {
|
||||
base64url += chars[bytes[i] >> 2]
|
||||
base64url += chars[((bytes[i] & 3) << 4) | (bytes[i + 1] >> 4)]
|
||||
base64url += chars[((bytes[i + 1] & 15) << 2) | (bytes[i + 2] >> 6)]
|
||||
base64url += chars[bytes[i + 2] & 63]
|
||||
}
|
||||
|
||||
if ((len % 3) === 2) {
|
||||
base64url = base64url.substring(0, base64url.length - 1)
|
||||
} else if (len % 3 === 1) {
|
||||
base64url = base64url.substring(0, base64url.length - 2)
|
||||
}
|
||||
return base64url
|
||||
}
|
||||
|
||||
const decode = function (base64string) {
|
||||
const bufferLength = base64string.length * 0.75
|
||||
const len = base64string.length;
|
||||
let i;
|
||||
let p = 0
|
||||
let encoded1;
|
||||
let encoded2;
|
||||
let encoded3;
|
||||
let encoded4
|
||||
|
||||
const bytes = new Uint8Array(bufferLength)
|
||||
|
||||
for (i = 0; i < len; i += 4) {
|
||||
encoded1 = lookup[base64string.charCodeAt(i)]
|
||||
encoded2 = lookup[base64string.charCodeAt(i + 1)]
|
||||
encoded3 = lookup[base64string.charCodeAt(i + 2)]
|
||||
encoded4 = lookup[base64string.charCodeAt(i + 3)]
|
||||
|
||||
bytes[p++] = (encoded1 << 2) | (encoded2 >> 4)
|
||||
bytes[p++] = ((encoded2 & 15) << 4) | (encoded3 >> 2)
|
||||
bytes[p++] = ((encoded3 & 3) << 6) | (encoded4 & 63)
|
||||
}
|
||||
return bytes.buffer
|
||||
}
|
||||
|
||||
function checkConditionalUI(form) {
|
||||
if (!navigator.credentials) {
|
||||
alert('WebAuthn is not supported in this browser')
|
||||
return
|
||||
}
|
||||
if (window.PublicKeyCredential && PublicKeyCredential.isConditionalMediationAvailable) {
|
||||
// Check if conditional mediation is available.
|
||||
PublicKeyCredential.isConditionalMediationAvailable().then((result) => {
|
||||
window.conditionalUI = result;
|
||||
if (!window.conditionalUI) {
|
||||
alert("Conditional UI is not available. Please use the legacy UI.");
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const publicKeyCredentialToJSON = (pubKeyCred) => {
|
||||
if (pubKeyCred instanceof Array) {
|
||||
const arr = []
|
||||
for (const i of pubKeyCred) {
|
||||
arr.push(publicKeyCredentialToJSON(i))
|
||||
}
|
||||
return arr
|
||||
}
|
||||
|
||||
if (pubKeyCred instanceof ArrayBuffer) {
|
||||
return encode(pubKeyCred)
|
||||
}
|
||||
|
||||
if (pubKeyCred instanceof Object) {
|
||||
const obj = {}
|
||||
for (const key in pubKeyCred) {
|
||||
obj[key] = publicKeyCredentialToJSON(pubKeyCred[key])
|
||||
}
|
||||
return obj
|
||||
}
|
||||
return pubKeyCred
|
||||
}
|
||||
|
||||
function GetAssertReq(getAssert) {
|
||||
getAssert.publicKey.challenge = decode(getAssert.publicKey.challenge)
|
||||
|
||||
for (const allowCred of getAssert.publicKey.allowCredentials) {
|
||||
allowCred.id = decode(allowCred.id)
|
||||
}
|
||||
return getAssert
|
||||
}
|
||||
|
||||
function startAuthn(form, conditionalUI = false) {
|
||||
window.loginForm = form
|
||||
fetch('/api/v1/authentication/passkeys/auth/', {method: 'GET'}).then(function (response) {
|
||||
if (response.ok) {
|
||||
return response.json().then(function (req) {
|
||||
return GetAssertReq(req)
|
||||
})
|
||||
}
|
||||
throw new Error('No credential available to authenticate!')
|
||||
}).then(function (options) {
|
||||
if (conditionalUI) {
|
||||
options.mediation = 'conditional'
|
||||
options.signal = window.conditionUIAbortSignal
|
||||
} else {
|
||||
window.conditionUIAbortController.abort()
|
||||
}
|
||||
return navigator.credentials.get(options)
|
||||
}).then(function (assertion) {
|
||||
const pk = $('#passkeys')
|
||||
if (pk.length === 0) {
|
||||
retry("Did you add the 'passkeys' hidden input field")
|
||||
return
|
||||
}
|
||||
pk.val(JSON.stringify(publicKeyCredentialToJSON(assertion)))
|
||||
const x = document.getElementById(window.loginForm)
|
||||
if (x === null || x === undefined) {
|
||||
console.error('Did you pass the correct form id to auth function')
|
||||
return
|
||||
}
|
||||
x.submit()
|
||||
}).catch(function (err) {
|
||||
retry(err)
|
||||
})
|
||||
}
|
||||
|
||||
function safeStartAuthn(form) {
|
||||
checkConditionalUI('loginForm')
|
||||
const errorMsg = "{% trans 'This page is not served over HTTPS. Please use HTTPS to ensure security of your credentials.' %}"
|
||||
const isSafe = window.location.protocol === 'https:'
|
||||
if (!isSafe && location.hostname !== 'localhost') {
|
||||
alert(errorMsg)
|
||||
window.location.href = loginUrl
|
||||
} else {
|
||||
setTimeout(() => startAuthn('loginForm'), 100)
|
||||
}
|
||||
}
|
||||
|
||||
function retry(error) {
|
||||
const fullError = "{% trans 'Error' %}" + ': ' + error + "\n\n " + "{% trans 'Do you want to retry ?' %}"
|
||||
const result = confirm(fullError)
|
||||
if (result) {
|
||||
safeStartAuthn()
|
||||
} else {
|
||||
window.location.href = loginUrl
|
||||
}
|
||||
}
|
||||
|
||||
{% if not error %}
|
||||
window.onload = function () {
|
||||
safeStartAuthn()
|
||||
}
|
||||
{% else %}
|
||||
const error = "{{ error }}"
|
||||
retry(error)
|
||||
{% endif %}
|
||||
</script>
|
||||
</html>
|
||||
@@ -4,6 +4,7 @@ from django.urls import path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from .. import api
|
||||
from ..backends.passkey.urls import urlpatterns as passkey_urlpatterns
|
||||
|
||||
app_name = 'authentication'
|
||||
router = DefaultRouter()
|
||||
@@ -13,17 +14,19 @@ router.register('temp-tokens', api.TempTokenViewSet, 'temp-token')
|
||||
router.register('connection-token', api.ConnectionTokenViewSet, 'connection-token')
|
||||
router.register('super-connection-token', api.SuperConnectionTokenViewSet, 'super-connection-token')
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path('wecom/qr/unbind/', api.WeComQRUnBindForUserApi.as_view(), name='wecom-qr-unbind'),
|
||||
path('wecom/qr/unbind/<uuid:user_id>/', api.WeComQRUnBindForAdminApi.as_view(), name='wecom-qr-unbind-for-admin'),
|
||||
|
||||
path('dingtalk/qr/unbind/', api.DingTalkQRUnBindForUserApi.as_view(), name='dingtalk-qr-unbind'),
|
||||
path('dingtalk/qr/unbind/<uuid:user_id>/', api.DingTalkQRUnBindForAdminApi.as_view(), name='dingtalk-qr-unbind-for-admin'),
|
||||
path('dingtalk/qr/unbind/<uuid:user_id>/', api.DingTalkQRUnBindForAdminApi.as_view(),
|
||||
name='dingtalk-qr-unbind-for-admin'),
|
||||
|
||||
path('feishu/qr/unbind/', api.FeiShuQRUnBindForUserApi.as_view(), name='feishu-qr-unbind'),
|
||||
path('feishu/qr/unbind/<uuid:user_id>/', api.FeiShuQRUnBindForAdminApi.as_view(), name='feishu-qr-unbind-for-admin'),
|
||||
path('feishu/event/subscription/callback/', api.FeiShuEventSubscriptionCallback.as_view(), name='feishu-event-subscription-callback'),
|
||||
path('feishu/qr/unbind/<uuid:user_id>/', api.FeiShuQRUnBindForAdminApi.as_view(),
|
||||
name='feishu-qr-unbind-for-admin'),
|
||||
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.ConfirmApi.as_view(), name='user-confirm'),
|
||||
@@ -38,4 +41,4 @@ urlpatterns = [
|
||||
path('login-confirm-ticket/status/', api.TicketStatusApi.as_view(), name='login-confirm-ticket-status'),
|
||||
]
|
||||
|
||||
urlpatterns += router.urls
|
||||
urlpatterns += router.urls + passkey_urlpatterns
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# coding:utf-8
|
||||
#
|
||||
|
||||
from django.urls import path, include
|
||||
from django.db.transaction import non_atomic_requests
|
||||
from django.urls import path, include
|
||||
|
||||
from .. import views
|
||||
from users import views as users_view
|
||||
from .. import views
|
||||
|
||||
app_name = 'authentication'
|
||||
|
||||
@@ -18,7 +18,8 @@ urlpatterns = [
|
||||
path('logout/', views.UserLogoutView.as_view(), name='logout'),
|
||||
|
||||
# 原来在users中的
|
||||
path('password/forget/previewing/', users_view.UserForgotPasswordPreviewingView.as_view(), name='forgot-previewing'),
|
||||
path('password/forget/previewing/', users_view.UserForgotPasswordPreviewingView.as_view(),
|
||||
name='forgot-previewing'),
|
||||
path('password/forgot/', users_view.UserForgotPasswordView.as_view(), name='forgot-password'),
|
||||
path('password/reset/', users_view.UserResetPasswordView.as_view(), name='reset-password'),
|
||||
path('password/verify/', users_view.UserVerifyPasswordView.as_view(), name='user-verify-password'),
|
||||
@@ -26,7 +27,8 @@ urlpatterns = [
|
||||
path('wecom/bind/start/', views.WeComEnableStartView.as_view(), name='wecom-bind-start'),
|
||||
path('wecom/qr/bind/', views.WeComQRBindView.as_view(), name='wecom-qr-bind'),
|
||||
path('wecom/qr/login/', views.WeComQRLoginView.as_view(), name='wecom-qr-login'),
|
||||
path('wecom/qr/bind/<uuid:user_id>/callback/', views.WeComQRBindCallbackView.as_view(), name='wecom-qr-bind-callback'),
|
||||
path('wecom/qr/bind/<uuid:user_id>/callback/', views.WeComQRBindCallbackView.as_view(),
|
||||
name='wecom-qr-bind-callback'),
|
||||
path('wecom/qr/login/callback/', views.WeComQRLoginCallbackView.as_view(), name='wecom-qr-login-callback'),
|
||||
path('wecom/oauth/login/', views.WeComOAuthLoginView.as_view(), name='wecom-oauth-login'),
|
||||
path('wecom/oauth/login/callback/', views.WeComOAuthLoginCallbackView.as_view(), name='wecom-oauth-login-callback'),
|
||||
@@ -34,10 +36,12 @@ urlpatterns = [
|
||||
path('dingtalk/bind/start/', views.DingTalkEnableStartView.as_view(), name='dingtalk-bind-start'),
|
||||
path('dingtalk/qr/bind/', views.DingTalkQRBindView.as_view(), name='dingtalk-qr-bind'),
|
||||
path('dingtalk/qr/login/', views.DingTalkQRLoginView.as_view(), name='dingtalk-qr-login'),
|
||||
path('dingtalk/qr/bind/<uuid:user_id>/callback/', views.DingTalkQRBindCallbackView.as_view(), name='dingtalk-qr-bind-callback'),
|
||||
path('dingtalk/qr/bind/<uuid:user_id>/callback/', views.DingTalkQRBindCallbackView.as_view(),
|
||||
name='dingtalk-qr-bind-callback'),
|
||||
path('dingtalk/qr/login/callback/', views.DingTalkQRLoginCallbackView.as_view(), name='dingtalk-qr-login-callback'),
|
||||
path('dingtalk/oauth/login/', views.DingTalkOAuthLoginView.as_view(), name='dingtalk-oauth-login'),
|
||||
path('dingtalk/oauth/login/callback/', views.DingTalkOAuthLoginCallbackView.as_view(), name='dingtalk-oauth-login-callback'),
|
||||
path('dingtalk/oauth/login/callback/', views.DingTalkOAuthLoginCallbackView.as_view(),
|
||||
name='dingtalk-oauth-login-callback'),
|
||||
|
||||
path('feishu/bind/start/', views.FeiShuEnableStartView.as_view(), name='feishu-bind-start'),
|
||||
path('feishu/qr/bind/', views.FeiShuQRBindView.as_view(), name='feishu-qr-bind'),
|
||||
|
||||
@@ -12,6 +12,7 @@ from authentication.mixins import AuthMixin
|
||||
from common.utils import get_logger
|
||||
from common.utils.django import reverse, get_object_or_none
|
||||
from users.models import User
|
||||
from users.signal_handlers import check_only_allow_exist_user_auth
|
||||
from .mixins import FlashMessageMixin
|
||||
|
||||
logger = get_logger(__file__)
|
||||
@@ -49,6 +50,11 @@ class BaseLoginCallbackView(AuthMixin, FlashMessageMixin, View):
|
||||
user, create = User.objects.get_or_create(
|
||||
username=user_attr['username'], defaults=user_attr
|
||||
)
|
||||
|
||||
if not check_only_allow_exist_user_auth(create):
|
||||
user.delete()
|
||||
return user, (self.msg_client_err, self.request.error_message)
|
||||
|
||||
setattr(user, f'{self.user_type}_id', user_id)
|
||||
if create:
|
||||
setattr(user, 'source', self.user_type)
|
||||
|
||||
@@ -6,6 +6,7 @@ from __future__ import unicode_literals
|
||||
import datetime
|
||||
import os
|
||||
from typing import Callable
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import BACKEND_SESSION_KEY
|
||||
@@ -40,6 +41,7 @@ __all__ = [
|
||||
class UserLoginContextMixin:
|
||||
get_user_mfa_context: Callable
|
||||
request: HttpRequest
|
||||
error_origin: str
|
||||
|
||||
def get_support_auth_methods(self):
|
||||
auth_methods = [
|
||||
@@ -88,6 +90,12 @@ class UserLoginContextMixin:
|
||||
'enabled': settings.AUTH_FEISHU,
|
||||
'url': reverse('authentication:feishu-qr-login'),
|
||||
'logo': static('img/login_feishu_logo.png')
|
||||
},
|
||||
{
|
||||
'name': _("Passkey"),
|
||||
'enabled': settings.AUTH_PASSKEY,
|
||||
'url': reverse('api-auth:passkey-login'),
|
||||
'logo': static('img/login_passkey.png')
|
||||
}
|
||||
]
|
||||
return [method for method in auth_methods if method['enabled']]
|
||||
@@ -134,8 +142,27 @@ class UserLoginContextMixin:
|
||||
count += 1
|
||||
return count
|
||||
|
||||
def set_csrf_error_if_need(self, context):
|
||||
if not self.request.GET.get('csrf_failure'):
|
||||
return context
|
||||
|
||||
http_origin = self.request.META.get('HTTP_ORIGIN')
|
||||
http_referer = self.request.META.get('HTTP_REFERER')
|
||||
http_origin = http_origin or http_referer
|
||||
|
||||
if not http_origin:
|
||||
return context
|
||||
|
||||
try:
|
||||
origin = urlparse(http_origin)
|
||||
context['error_origin'] = str(origin.netloc)
|
||||
except ValueError:
|
||||
pass
|
||||
return context
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
self.set_csrf_error_if_need(context)
|
||||
context.update({
|
||||
'demo_mode': os.environ.get("DEMO_MODE"),
|
||||
'auth_methods': self.get_support_auth_methods(),
|
||||
|
||||
@@ -16,12 +16,19 @@ class METAMixin:
|
||||
|
||||
class FlashMessageMixin:
|
||||
@staticmethod
|
||||
def get_response(redirect_url, title, msg, m_type='message'):
|
||||
message_data = {'title': title, 'interval': 5, 'redirect_url': redirect_url, m_type: msg}
|
||||
def get_response(redirect_url='', title='', msg='', m_type='message', interval=5):
|
||||
message_data = {
|
||||
'title': title, 'interval': interval,
|
||||
'redirect_url': redirect_url,
|
||||
}
|
||||
if m_type == 'error':
|
||||
message_data['error'] = msg
|
||||
else:
|
||||
message_data['message'] = msg
|
||||
return FlashMessageUtil.gen_and_redirect_to(message_data)
|
||||
|
||||
def get_success_response(self, redirect_url, title, msg):
|
||||
return self.get_response(redirect_url, title, msg)
|
||||
def get_success_response(self, redirect_url, title, msg, **kwargs):
|
||||
return self.get_response(redirect_url, title, msg, m_type='success', **kwargs)
|
||||
|
||||
def get_failed_response(self, redirect_url, title, msg):
|
||||
return self.get_response(redirect_url, title, msg, 'error')
|
||||
def get_failed_response(self, redirect_url, title, msg, interval=10):
|
||||
return self.get_response(redirect_url, title, msg, 'error', interval)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from .action import *
|
||||
from .common import *
|
||||
from .filter import *
|
||||
from .generic import *
|
||||
from .mixin import *
|
||||
from .patch import *
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
import logging
|
||||
from itertools import chain
|
||||
|
||||
from django.db import models
|
||||
from rest_framework.settings import api_settings
|
||||
|
||||
from common.drf.filters import IDSpmFilter, CustomFilter, IDInFilter
|
||||
|
||||
__all__ = ['ExtraFilterFieldsMixin', 'OrderingFielderFieldsMixin']
|
||||
|
||||
logger = logging.getLogger('jumpserver.common')
|
||||
|
||||
|
||||
class ExtraFilterFieldsMixin:
|
||||
"""
|
||||
额外的 api filter
|
||||
"""
|
||||
default_added_filters = [CustomFilter, IDSpmFilter, IDInFilter]
|
||||
filter_backends = api_settings.DEFAULT_FILTER_BACKENDS
|
||||
extra_filter_fields = []
|
||||
extra_filter_backends = []
|
||||
|
||||
def get_filter_backends(self):
|
||||
if self.filter_backends != self.__class__.filter_backends:
|
||||
return self.filter_backends
|
||||
backends = list(chain(
|
||||
self.filter_backends,
|
||||
self.default_added_filters,
|
||||
self.extra_filter_backends
|
||||
))
|
||||
return backends
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
for backend in self.get_filter_backends():
|
||||
queryset = backend().filter_queryset(self.request, queryset, self)
|
||||
return queryset
|
||||
|
||||
|
||||
class OrderingFielderFieldsMixin:
|
||||
"""
|
||||
额外的 api ordering
|
||||
"""
|
||||
ordering_fields = None
|
||||
extra_ordering_fields = []
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.ordering_fields = self._get_ordering_fields()
|
||||
|
||||
def _get_ordering_fields(self):
|
||||
if isinstance(self.__class__.ordering_fields, (list, tuple)):
|
||||
return self.__class__.ordering_fields
|
||||
|
||||
try:
|
||||
valid_fields = self.get_valid_ordering_fields()
|
||||
except Exception as e:
|
||||
logger.debug('get_valid_ordering_fields error: %s' % e)
|
||||
# 这里千万不要这么用,会让 logging 重复,至于为什么,我也不知道
|
||||
# logging.debug('get_valid_ordering_fields error: %s' % e)
|
||||
valid_fields = []
|
||||
|
||||
fields = list(chain(
|
||||
valid_fields,
|
||||
self.extra_ordering_fields
|
||||
))
|
||||
return fields
|
||||
|
||||
def get_valid_ordering_fields(self):
|
||||
if getattr(self, 'model', None):
|
||||
model = self.model
|
||||
elif getattr(self, 'queryset', None):
|
||||
model = self.queryset.model
|
||||
else:
|
||||
queryset = self.get_queryset()
|
||||
model = queryset.model
|
||||
|
||||
if not model:
|
||||
return []
|
||||
|
||||
excludes_fields = (
|
||||
models.UUIDField, models.Model, models.ForeignKey,
|
||||
models.FileField, models.JSONField, models.ManyToManyField,
|
||||
models.DurationField,
|
||||
)
|
||||
valid_fields = []
|
||||
for field in model._meta.fields:
|
||||
if isinstance(field, excludes_fields):
|
||||
continue
|
||||
valid_fields.append(field.name)
|
||||
return valid_fields
|
||||
@@ -1,19 +1,29 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
from collections import defaultdict
|
||||
from itertools import chain
|
||||
from typing import Callable
|
||||
|
||||
from django.db import models
|
||||
from django.db.models.signals import m2m_changed
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.settings import api_settings
|
||||
|
||||
from common.drf.filters import (
|
||||
IDSpmFilterBackend, CustomFilterBackend, IDInFilterBackend,
|
||||
IDNotFilterBackend, NotOrRelFilterBackend
|
||||
)
|
||||
from common.utils import get_logger
|
||||
from .action import RenderToJsonMixin
|
||||
from .filter import ExtraFilterFieldsMixin, OrderingFielderFieldsMixin
|
||||
from .serializer import SerializerMixin
|
||||
|
||||
__all__ = [
|
||||
'CommonApiMixin', 'PaginatedResponseMixin', 'RelationMixin',
|
||||
'ExtraFilterFieldsMixin',
|
||||
]
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class PaginatedResponseMixin:
|
||||
|
||||
@@ -95,6 +105,100 @@ class QuerySetMixin:
|
||||
return queryset
|
||||
|
||||
|
||||
class CommonApiMixin(SerializerMixin, ExtraFilterFieldsMixin, OrderingFielderFieldsMixin,
|
||||
QuerySetMixin, RenderToJsonMixin, PaginatedResponseMixin):
|
||||
class ExtraFilterFieldsMixin:
|
||||
"""
|
||||
额外的 api filter
|
||||
"""
|
||||
default_added_filters = (
|
||||
CustomFilterBackend, IDSpmFilterBackend, IDInFilterBackend,
|
||||
IDNotFilterBackend,
|
||||
)
|
||||
filter_backends = api_settings.DEFAULT_FILTER_BACKENDS
|
||||
extra_filter_fields = []
|
||||
extra_filter_backends = []
|
||||
|
||||
def set_compatible_fields(self):
|
||||
"""
|
||||
兼容老的 filter_fields
|
||||
"""
|
||||
if not hasattr(self, 'filter_fields') and hasattr(self, 'filterset_fields'):
|
||||
self.filter_fields = self.filterset_fields
|
||||
|
||||
def get_filter_backends(self):
|
||||
self.set_compatible_fields()
|
||||
if self.filter_backends != self.__class__.filter_backends:
|
||||
return self.filter_backends
|
||||
backends = list(chain(
|
||||
self.filter_backends,
|
||||
self.default_added_filters,
|
||||
self.extra_filter_backends,
|
||||
))
|
||||
# 这个要放在最后
|
||||
backends.append(NotOrRelFilterBackend)
|
||||
return backends
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
for backend in self.get_filter_backends():
|
||||
queryset = backend().filter_queryset(self.request, queryset, self)
|
||||
return queryset
|
||||
|
||||
|
||||
class OrderingFielderFieldsMixin:
|
||||
"""
|
||||
额外的 api ordering
|
||||
"""
|
||||
ordering_fields = None
|
||||
extra_ordering_fields = []
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.ordering_fields = self._get_ordering_fields()
|
||||
|
||||
def _get_ordering_fields(self):
|
||||
if isinstance(self.__class__.ordering_fields, (list, tuple)):
|
||||
return self.__class__.ordering_fields
|
||||
|
||||
try:
|
||||
valid_fields = self.get_valid_ordering_fields()
|
||||
except Exception as e:
|
||||
logger.debug('get_valid_ordering_fields error: %s, pass' % e)
|
||||
# 这里千万不要这么用,会让 logging 重复,至于为什么,我也不知道
|
||||
# logging.debug('get_valid_ordering_fields error: %s' % e)
|
||||
valid_fields = []
|
||||
|
||||
fields = list(chain(
|
||||
valid_fields,
|
||||
self.extra_ordering_fields
|
||||
))
|
||||
return fields
|
||||
|
||||
def get_valid_ordering_fields(self):
|
||||
if getattr(self, 'model', None):
|
||||
model = self.model
|
||||
elif getattr(self, 'queryset', None):
|
||||
model = self.queryset.model
|
||||
else:
|
||||
queryset = self.get_queryset()
|
||||
model = queryset.model
|
||||
|
||||
if not model:
|
||||
return []
|
||||
|
||||
excludes_fields = (
|
||||
models.UUIDField, models.Model, models.ForeignKey,
|
||||
models.FileField, models.JSONField, models.ManyToManyField,
|
||||
models.DurationField,
|
||||
)
|
||||
valid_fields = []
|
||||
for field in model._meta.fields:
|
||||
if isinstance(field, excludes_fields):
|
||||
continue
|
||||
valid_fields.append(field.name)
|
||||
return valid_fields
|
||||
|
||||
|
||||
class CommonApiMixin(
|
||||
SerializerMixin, ExtraFilterFieldsMixin, OrderingFielderFieldsMixin,
|
||||
QuerySetMixin, RenderToJsonMixin, PaginatedResponseMixin
|
||||
):
|
||||
pass
|
||||
|
||||
@@ -88,6 +88,7 @@ class AsyncApiMixin(InterceptMixin):
|
||||
if not self.is_need_async():
|
||||
return handler(*args, **kwargs)
|
||||
resp = self.do_async(handler, *args, **kwargs)
|
||||
self.async_callback(*args, **kwargs)
|
||||
return resp
|
||||
|
||||
def is_need_refresh(self):
|
||||
@@ -98,6 +99,9 @@ class AsyncApiMixin(InterceptMixin):
|
||||
def is_need_async(self):
|
||||
return False
|
||||
|
||||
def async_callback(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def do_async(self, handler, *args, **kwargs):
|
||||
data = self.get_cache_data()
|
||||
if not data:
|
||||
|
||||
@@ -13,17 +13,17 @@ from rest_framework.fields import DateTimeField
|
||||
from rest_framework.serializers import ValidationError
|
||||
|
||||
from common import const
|
||||
from common.db.fields import RelatedManager
|
||||
|
||||
logger = logging.getLogger('jumpserver.common')
|
||||
|
||||
__all__ = [
|
||||
"DatetimeRangeFilter", "IDSpmFilter",
|
||||
'IDInFilter', "CustomFilter",
|
||||
"BaseFilterSet"
|
||||
"DatetimeRangeFilterBackend", "IDSpmFilterBackend",
|
||||
'IDInFilterBackend', "CustomFilterBackend",
|
||||
"BaseFilterSet", 'IDNotFilterBackend',
|
||||
'NotOrRelFilterBackend',
|
||||
]
|
||||
|
||||
from common.db.fields import RelatedManager
|
||||
|
||||
|
||||
class BaseFilterSet(drf_filters.FilterSet):
|
||||
def do_nothing(self, queryset, name, value):
|
||||
@@ -35,7 +35,7 @@ class BaseFilterSet(drf_filters.FilterSet):
|
||||
return default
|
||||
|
||||
|
||||
class DatetimeRangeFilter(filters.BaseFilterBackend):
|
||||
class DatetimeRangeFilterBackend(filters.BaseFilterBackend):
|
||||
def get_schema_fields(self, view):
|
||||
ret = []
|
||||
fields = self._get_date_range_filter_fields(view)
|
||||
@@ -102,7 +102,7 @@ class DatetimeRangeFilter(filters.BaseFilterBackend):
|
||||
return queryset
|
||||
|
||||
|
||||
class IDSpmFilter(filters.BaseFilterBackend):
|
||||
class IDSpmFilterBackend(filters.BaseFilterBackend):
|
||||
def get_schema_fields(self, view):
|
||||
return [
|
||||
coreapi.Field(
|
||||
@@ -130,7 +130,7 @@ class IDSpmFilter(filters.BaseFilterBackend):
|
||||
return queryset
|
||||
|
||||
|
||||
class IDInFilter(filters.BaseFilterBackend):
|
||||
class IDInFilterBackend(filters.BaseFilterBackend):
|
||||
def get_schema_fields(self, view):
|
||||
return [
|
||||
coreapi.Field(
|
||||
@@ -149,7 +149,26 @@ class IDInFilter(filters.BaseFilterBackend):
|
||||
return queryset
|
||||
|
||||
|
||||
class CustomFilter(filters.BaseFilterBackend):
|
||||
class IDNotFilterBackend(filters.BaseFilterBackend):
|
||||
def get_schema_fields(self, view):
|
||||
return [
|
||||
coreapi.Field(
|
||||
name='id!', location='query', required=False,
|
||||
type='string', example='/api/v1/users/users?id!=1,2,3',
|
||||
description='Exclude by id set'
|
||||
)
|
||||
]
|
||||
|
||||
def filter_queryset(self, request, queryset, view):
|
||||
ids = request.query_params.get('id!')
|
||||
if not ids:
|
||||
return queryset
|
||||
id_list = [i.strip() for i in ids.split(',')]
|
||||
queryset = queryset.exclude(id__in=id_list)
|
||||
return queryset
|
||||
|
||||
|
||||
class CustomFilterBackend(filters.BaseFilterBackend):
|
||||
|
||||
def get_schema_fields(self, view):
|
||||
fields = []
|
||||
@@ -218,3 +237,25 @@ class AttrRulesFilterBackend(filters.BaseFilterBackend):
|
||||
logger.debug('attr_rules: %s', attr_rules)
|
||||
q = RelatedManager.get_to_filter_q(attr_rules, queryset.model)
|
||||
return queryset.filter(q).distinct()
|
||||
|
||||
|
||||
class NotOrRelFilterBackend(filters.BaseFilterBackend):
|
||||
def get_schema_fields(self, view):
|
||||
return [
|
||||
coreapi.Field(
|
||||
name='_rel', location='query', required=False,
|
||||
type='string', example='/api/v1/users/users?name=abc&username=def&_rel=union',
|
||||
description='Filter by rel, or not, default is and'
|
||||
)
|
||||
]
|
||||
|
||||
def filter_queryset(self, request, queryset, view):
|
||||
_rel = request.query_params.get('_rel')
|
||||
if not _rel or _rel not in ('or', 'not'):
|
||||
return queryset
|
||||
if _rel == 'not':
|
||||
queryset.query.where.negated = True
|
||||
elif _rel == 'or':
|
||||
queryset.query.where.connector = 'OR'
|
||||
queryset._result_cache = None
|
||||
return queryset
|
||||
|
||||
@@ -8,7 +8,8 @@ class PassthroughRenderer(renderers.BaseRenderer):
|
||||
"""
|
||||
Return data as-is. View should supply a Response.
|
||||
"""
|
||||
media_type = ''
|
||||
media_type = 'application/octet-stream'
|
||||
format = ''
|
||||
|
||||
def render(self, data, accepted_media_type=None, renderer_context=None):
|
||||
return data
|
||||
|
||||
126
apps/common/management/commands/check_api.py
Normal file
126
apps/common/management/commands/check_api.py
Normal file
@@ -0,0 +1,126 @@
|
||||
import re
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.test import Client
|
||||
from django.urls import URLPattern, URLResolver
|
||||
|
||||
from jumpserver.urls import api_v1
|
||||
|
||||
path_uuid_pattern = re.compile(r'<\w+:\w+>', re.IGNORECASE)
|
||||
uuid_pattern = re.compile(r'\(\(\?P<.*>[^)]+\)/\)\?', re.IGNORECASE)
|
||||
uuid2_pattern = re.compile(r'\(\?P<.*>\[\/\.\]\+\)', re.IGNORECASE)
|
||||
uuid3_pattern = re.compile(r'\(\?P<.*>\[/\.]\+\)')
|
||||
|
||||
|
||||
def list_urls(patterns, path=None):
|
||||
""" recursive """
|
||||
if not path:
|
||||
path = []
|
||||
result = []
|
||||
for pattern in patterns:
|
||||
if isinstance(pattern, URLPattern):
|
||||
result.append(''.join(path) + str(pattern.pattern))
|
||||
elif isinstance(pattern, URLResolver):
|
||||
result += list_urls(pattern.url_patterns, path + [str(pattern.pattern)])
|
||||
return result
|
||||
|
||||
|
||||
def parse_to_url(url):
|
||||
uid = '00000000-0000-0000-0000-000000000000'
|
||||
|
||||
url = url.replace('^', '')
|
||||
url = url.replace('?$', '')
|
||||
url = url.replace('(?P<format>[a-z0-9]+)', '')
|
||||
url = url.replace('((?P<terminal>[/.]{36})/)?', uid + '/')
|
||||
url = url.replace('(?P<pk>[/.]+)', uid)
|
||||
url = url.replace('\.', '')
|
||||
url = url.replace('//', '/')
|
||||
url = url.strip('$')
|
||||
url = re.sub(path_uuid_pattern, uid, url)
|
||||
url = re.sub(uuid2_pattern, uid, url)
|
||||
url = re.sub(uuid_pattern, uid + '/', url)
|
||||
url = re.sub(uuid3_pattern, uid, url)
|
||||
url = url.replace('(00000000-0000-0000-0000-000000000000/)?', uid + '/')
|
||||
return url
|
||||
|
||||
|
||||
def get_api_urls():
|
||||
urls = []
|
||||
api_urls = list_urls(api_v1)
|
||||
for ourl in api_urls:
|
||||
url = parse_to_url(ourl)
|
||||
if 'render-to-json' in url:
|
||||
continue
|
||||
url = '/api/v1/' + url
|
||||
urls.append((url, ourl))
|
||||
return set(urls)
|
||||
|
||||
|
||||
known_unauth_urls = [
|
||||
"/api/v1/authentication/passkeys/auth/",
|
||||
"/api/v1/prometheus/metrics/",
|
||||
"/api/v1/authentication/auth/",
|
||||
"/api/v1/settings/logo/",
|
||||
"/api/v1/settings/public/open/",
|
||||
"/api/v1/authentication/passkeys/login/",
|
||||
"/api/v1/authentication/tokens/",
|
||||
"/api/v1/authentication/mfa/challenge/",
|
||||
"/api/v1/authentication/password/reset-code/",
|
||||
"/api/v1/authentication/login-confirm-ticket/status/",
|
||||
"/api/v1/authentication/mfa/select/",
|
||||
"/api/v1/authentication/mfa/send-code/",
|
||||
"/api/v1/authentication/sso/login/"
|
||||
]
|
||||
|
||||
known_error_urls = [
|
||||
'/api/v1/terminal/terminals/00000000-0000-0000-0000-000000000000/sessions/00000000-0000-0000-0000-000000000000/replay/download/',
|
||||
'/api/v1/terminal/sessions/00000000-0000-0000-0000-000000000000/replay/download/',
|
||||
]
|
||||
|
||||
errors = {}
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Check api if unauthorized'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
settings.LOG_LEVEL = 'ERROR'
|
||||
urls = get_api_urls()
|
||||
client = Client()
|
||||
unauth_urls = []
|
||||
error_urls = []
|
||||
unformat_urls = []
|
||||
|
||||
for url, ourl in urls:
|
||||
if '(' in url or '<' in url:
|
||||
unformat_urls.append([url, ourl])
|
||||
continue
|
||||
|
||||
try:
|
||||
response = client.get(url, follow=True)
|
||||
if response.status_code != 401:
|
||||
errors[url] = str(response.status_code) + ' ' + str(ourl)
|
||||
unauth_urls.append(url)
|
||||
except Exception as e:
|
||||
errors[url] = str(e)
|
||||
error_urls.append(url)
|
||||
|
||||
unauth_urls = set(unauth_urls) - set(known_unauth_urls)
|
||||
print("\nUnauthorized urls:")
|
||||
if not unauth_urls:
|
||||
print(" Empty, very good!")
|
||||
for url in unauth_urls:
|
||||
print('"{}", {}'.format(url, errors.get(url, '')))
|
||||
|
||||
print("\nError urls:")
|
||||
if not error_urls:
|
||||
print(" Empty, very good!")
|
||||
for url in set(error_urls):
|
||||
print(url, ': ' + errors.get(url))
|
||||
|
||||
print("\nUnformat urls:")
|
||||
if not unformat_urls:
|
||||
print(" Empty, very good!")
|
||||
for url in unformat_urls:
|
||||
print(url)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user