mirror of
https://github.com/jumpserver/jumpserver.git
synced 2025-12-16 09:02:49 +00:00
Compare commits
259 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
482319fadf | ||
|
|
2ca4002624 | ||
|
|
543dde57ab | ||
|
|
c088437fe5 | ||
|
|
e721ec147c | ||
|
|
5d18d6dee0 | ||
|
|
ecfd338428 | ||
|
|
4b28b079dc | ||
|
|
c1c3236a30 | ||
|
|
4b19750581 | ||
|
|
eafb5ecfb3 | ||
|
|
583486e26e | ||
|
|
8198620a2e | ||
|
|
c0b301d52b | ||
|
|
7791d6222a | ||
|
|
b740d9d42f | ||
|
|
48d0187604 | ||
|
|
6217018427 | ||
|
|
923f40e523 | ||
|
|
1f1fe2084b | ||
|
|
b8b1a6ac9c | ||
|
|
35f88722af | ||
|
|
7e6d2749ae | ||
|
|
be57b101ff | ||
|
|
41c8cb6307 | ||
|
|
3a7ae01ede | ||
|
|
053d640e4c | ||
|
|
d17ca4f6a7 | ||
|
|
f3acc28ded | ||
|
|
5a14bb13d0 | ||
|
|
2956f2e4b7 | ||
|
|
e983ac3cbc | ||
|
|
fab156dc5f | ||
|
|
f6f897317e | ||
|
|
a0441cd6ea | ||
|
|
e9abd1e72d | ||
|
|
9fcb4ecba0 | ||
|
|
4b637ad86e | ||
|
|
829f867962 | ||
|
|
7f965b55f4 | ||
|
|
0e0be618e5 | ||
|
|
9577af3221 | ||
|
|
a6b7cc9d1b | ||
|
|
7a9a71197a | ||
|
|
3cd68ba0a9 | ||
|
|
02bdd0f07d | ||
|
|
98cf6f82b7 | ||
|
|
27fd5d51b9 | ||
|
|
095ca91e30 | ||
|
|
d05514962a | ||
|
|
c4066a03fa | ||
|
|
a7d4c4ca2a | ||
|
|
5b0f8f63a3 | ||
|
|
c4bcae68bf | ||
|
|
29ca50f97e | ||
|
|
49aaf8d53e | ||
|
|
931e15173b | ||
|
|
4018a59b2e | ||
|
|
88905bd28d | ||
|
|
abad98a190 | ||
|
|
7419139b29 | ||
|
|
a1fd3b1ecb | ||
|
|
8a8a7f9947 | ||
|
|
f9e6fc98fb | ||
|
|
0dd015bcba | ||
|
|
d1ea31c9a4 | ||
|
|
e2bf56e624 | ||
|
|
26040a5560 | ||
|
|
54726f0a2d | ||
|
|
7fd88b95f9 | ||
|
|
4f271d6405 | ||
|
|
fe17a8c3a0 | ||
|
|
ee5e97e860 | ||
|
|
dddfc66efd | ||
|
|
d005bd804f | ||
|
|
08de04fdbc | ||
|
|
9ed7c41514 | ||
|
|
1a81b76a46 | ||
|
|
cf99a7a031 | ||
|
|
64551b13a1 | ||
|
|
c715300416 | ||
|
|
d9031ae02b | ||
|
|
0d2ba5c518 | ||
|
|
817957dbac | ||
|
|
3796af78a6 | ||
|
|
1191e4ab2d | ||
|
|
1c6fcc5826 | ||
|
|
4728f95634 | ||
|
|
013502186b | ||
|
|
a6d040cd34 | ||
|
|
398758baa6 | ||
|
|
e29bddd89e | ||
|
|
e35c915ee3 | ||
|
|
de2dd583d0 | ||
|
|
43f1d7eeae | ||
|
|
9bb63e0933 | ||
|
|
c9e03fd5d8 | ||
|
|
7a147242c9 | ||
|
|
392c261a96 | ||
|
|
2bbccae0f5 | ||
|
|
606fa9bfbc | ||
|
|
96e7b165dd | ||
|
|
148413d280 | ||
|
|
a46a81d477 | ||
|
|
ff0f9eb6eb | ||
|
|
d8dfaf0868 | ||
|
|
3267c8074b | ||
|
|
7b14d680b2 | ||
|
|
0980808bb7 | ||
|
|
0519f15bbf | ||
|
|
f6742eb4c6 | ||
|
|
f8d11013fc | ||
|
|
7875777ed1 | ||
|
|
0ca81a8f30 | ||
|
|
09accbd922 | ||
|
|
945204c45b | ||
|
|
2d62dc0657 | ||
|
|
fa61688c28 | ||
|
|
801edc7cc9 | ||
|
|
d0617a0ea4 | ||
|
|
1191ed1793 | ||
|
|
4036420d0e | ||
|
|
35a1655905 | ||
|
|
d4dc31aefa | ||
|
|
04ec34364f | ||
|
|
01b8c1f7a8 | ||
|
|
77598a0f23 | ||
|
|
eafb074fda | ||
|
|
d4d903f5c6 | ||
|
|
c9c55b5fcb | ||
|
|
25987545db | ||
|
|
f7313bfcc1 | ||
|
|
d2f7376f78 | ||
|
|
6db56eb2aa | ||
|
|
442290703a | ||
|
|
e491a724ed | ||
|
|
230924baac | ||
|
|
0ae2f04f28 | ||
|
|
68a490d305 | ||
|
|
6abfeee683 | ||
|
|
1a03f7b265 | ||
|
|
2dae2b3789 | ||
|
|
bdbbebab76 | ||
|
|
33170887f4 | ||
|
|
88302c8846 | ||
|
|
4068b5c76a | ||
|
|
9966ad4c71 | ||
|
|
9cfe974c52 | ||
|
|
d9a9f890f5 | ||
|
|
e2904ab042 | ||
|
|
f92c557235 | ||
|
|
cfadbc164c | ||
|
|
374a102bc4 | ||
|
|
84e1411c22 | ||
|
|
e28bf170d1 | ||
|
|
7c9e3a1362 | ||
|
|
fba80342a5 | ||
|
|
5eeff0aabf | ||
|
|
5b4de02fff | ||
|
|
b6a5854fa2 | ||
|
|
9771d3c817 | ||
|
|
b33a0cf0b1 | ||
|
|
f9fa6ad9c1 | ||
|
|
4b2db2b6a1 | ||
|
|
822b353a40 | ||
|
|
2908d4ee5f | ||
|
|
482c4ced0c | ||
|
|
b2a5e457a9 | ||
|
|
343c3607fa | ||
|
|
f03263eedf | ||
|
|
98d7ecbf3e | ||
|
|
477ccda8ca | ||
|
|
fcdc2b9510 | ||
|
|
1ee57cfda0 | ||
|
|
804bd289a4 | ||
|
|
86273865c8 | ||
|
|
5142f0340c | ||
|
|
7c80c52d02 | ||
|
|
eb30b61ca9 | ||
|
|
dd5a272cdf | ||
|
|
5b27acf4ef | ||
|
|
1a41a7450e | ||
|
|
e1b501c7d4 | ||
|
|
b660bfb7ff | ||
|
|
5724912480 | ||
|
|
11b3bafd5a | ||
|
|
9f90838df1 | ||
|
|
b01916001e | ||
|
|
c96ae1022b | ||
|
|
8f11167db0 | ||
|
|
a53397b76f | ||
|
|
8f13224454 | ||
|
|
8f4dd25e69 | ||
|
|
9c8762e3a0 | ||
|
|
a8cf788122 | ||
|
|
7355a4f152 | ||
|
|
2cf80e6615 | ||
|
|
9a18ed631c | ||
|
|
1e16f1cb9f | ||
|
|
35b8b080ab | ||
|
|
4219d54db3 | ||
|
|
c3620254b3 | ||
|
|
d30de0b6a0 | ||
|
|
af91b6faeb | ||
|
|
49b84b019d | ||
|
|
a0ee520572 | ||
|
|
972afe0bfe | ||
|
|
e47e9b0a11 | ||
|
|
87e54d8823 | ||
|
|
a73c8d8285 | ||
|
|
b0dd8d044d | ||
|
|
7c55c42582 | ||
|
|
cc1fcd2b98 | ||
|
|
8434d8d5ba | ||
|
|
044fd238b8 | ||
|
|
be096a1319 | ||
|
|
6fa14833b3 | ||
|
|
1f32ab274c | ||
|
|
6720ecc6e0 | ||
|
|
b0f86e43a6 | ||
|
|
9b0c81333f | ||
|
|
05fc966444 | ||
|
|
b87650038f | ||
|
|
d4f69a7ff8 | ||
|
|
0e1e26c29c | ||
|
|
1b8cdbc4dd | ||
|
|
2a781c228f | ||
|
|
35d6b0f16a | ||
|
|
ca8987fef6 | ||
|
|
b385133071 | ||
|
|
aa78a03efa | ||
|
|
31f8a19392 | ||
|
|
7a528b499a | ||
|
|
1c6ce422cf | ||
|
|
f9cf2ea2e5 | ||
|
|
575b3a617f | ||
|
|
b7362d3f51 | ||
|
|
6ee3860124 | ||
|
|
7e111da529 | ||
|
|
578458f734 | ||
|
|
bd56697d6d | ||
|
|
aad824d127 | ||
|
|
63f828da0b | ||
|
|
7c211b3fb6 | ||
|
|
3881edd2ba | ||
|
|
b882b12d04 | ||
|
|
addd2e7d1c | ||
|
|
ad6d2e1cd7 | ||
|
|
5f07271afa | ||
|
|
efdcd4c708 | ||
|
|
b62763bca3 | ||
|
|
e95da730f2 | ||
|
|
43fa3f420a | ||
|
|
0311446384 | ||
|
|
f7030e4fee | ||
|
|
fce8cc375f | ||
|
|
920199c6df | ||
|
|
d09eb3c4fa | ||
|
|
6e8affcdd6 |
24
.github/workflows/discord-release.yml
vendored
Normal file
24
.github/workflows/discord-release.yml
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
name: Publish Release to Discord
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
send_discord_notification:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: startsWith(github.event.release.tag_name, 'v4.')
|
||||||
|
steps:
|
||||||
|
- name: Send release notification to Discord
|
||||||
|
env:
|
||||||
|
WEBHOOK_URL: ${{ secrets.DISCORD_CHANGELOG_WEBHOOK }}
|
||||||
|
run: |
|
||||||
|
# 获取标签名称和 release body
|
||||||
|
TAG_NAME="${{ github.event.release.tag_name }}"
|
||||||
|
RELEASE_BODY="${{ github.event.release.body }}"
|
||||||
|
|
||||||
|
# 使用 jq 构建 JSON 数据,以确保安全传递
|
||||||
|
JSON_PAYLOAD=$(jq -n --arg tag "# JumpServer $TAG_NAME Released! 🚀" --arg body "$RELEASE_BODY" '{content: "\($tag)\n\($body)"}')
|
||||||
|
|
||||||
|
# 使用 curl 发送 JSON 数据
|
||||||
|
curl -X POST -H "Content-Type: application/json" -d "$JSON_PAYLOAD" "$WEBHOOK_URL"
|
||||||
24
.github/workflows/docs-release.yml
vendored
Normal file
24
.github/workflows/docs-release.yml
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
name: Auto update docs changelog
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
update_docs_changelog:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: startsWith(github.event.release.tag_name, 'v4.')
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Update docs changelog
|
||||||
|
env:
|
||||||
|
TAG_NAME: ${{ github.event.release.tag_name }}
|
||||||
|
DOCS_TOKEN: ${{ secrets.DOCS_TOKEN }}
|
||||||
|
run: |
|
||||||
|
git config --global user.name 'BaiJiangJie'
|
||||||
|
git config --global user.email 'jiangjie.bai@fit2cloud.com'
|
||||||
|
|
||||||
|
git clone https://$DOCS_TOKEN@github.com/jumpservice/documentation.git
|
||||||
|
cd documentation/utils
|
||||||
|
bash update_changelog.sh
|
||||||
28
.github/workflows/llm-code-review.yml
vendored
Normal file
28
.github/workflows/llm-code-review.yml
vendored
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
name: LLM Code Review
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, reopened, synchronize]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
llm-code-review:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: fit2cloud/LLM-CodeReview-Action@main
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.FIT2CLOUDRD_LLM_CODE_REVIEW_TOKEN }}
|
||||||
|
OPENAI_API_KEY: ${{ secrets.ALIYUN_LLM_API_KEY }}
|
||||||
|
LANGUAGE: English
|
||||||
|
OPENAI_API_ENDPOINT: https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||||
|
MODEL: qwen2-1.5b-instruct
|
||||||
|
PROMPT: "Please check the following code differences for any irregularities, potential issues, or optimization suggestions, and provide your answers in English."
|
||||||
|
top_p: 1
|
||||||
|
temperature: 1
|
||||||
|
# max_tokens: 10000
|
||||||
|
MAX_PATCH_LENGTH: 10000
|
||||||
|
IGNORE_PATTERNS: "/node_modules,*.md,/dist,/.github"
|
||||||
|
FILE_PATTERNS: "*.java,*.go,*.py,*.vue,*.ts,*.js,*.css,*.scss,*.html"
|
||||||
40
.github/workflows/translate-readme.yml
vendored
Normal file
40
.github/workflows/translate-readme.yml
vendored
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
name: Translate README
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
target_langs:
|
||||||
|
description: "Target Languages"
|
||||||
|
required: false
|
||||||
|
default: "zh-hans,zh-hant,ja,pt-br"
|
||||||
|
gen_dir_path:
|
||||||
|
description: "Generate Dir Name"
|
||||||
|
required: false
|
||||||
|
default: "readmes/"
|
||||||
|
push_branch:
|
||||||
|
description: "Push Branch"
|
||||||
|
required: false
|
||||||
|
default: "pr@dev@translate_readme"
|
||||||
|
prompt:
|
||||||
|
description: "AI Translate Prompt"
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
|
||||||
|
gpt_mode:
|
||||||
|
description: "GPT Mode"
|
||||||
|
required: false
|
||||||
|
default: "gpt-4o-mini"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Auto Translate
|
||||||
|
uses: jumpserver-dev/action-translate-readme@main
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.PRIVATE_TOKEN }}
|
||||||
|
OPENAI_API_KEY: ${{ secrets.GPT_API_TOKEN }}
|
||||||
|
GPT_MODE: ${{ github.event.inputs.gpt_mode }}
|
||||||
|
TARGET_LANGUAGES: ${{ github.event.inputs.target_langs }}
|
||||||
|
PUSH_BRANCH: ${{ github.event.inputs.push_branch }}
|
||||||
|
GEN_DIR_PATH: ${{ github.event.inputs.gen_dir_path }}
|
||||||
|
PROMPT: ${{ github.event.inputs.prompt }}
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -45,3 +45,4 @@ test.py
|
|||||||
.history/
|
.history/
|
||||||
.test/
|
.test/
|
||||||
*.mo
|
*.mo
|
||||||
|
apps.iml
|
||||||
|
|||||||
12
Dockerfile
12
Dockerfile
@@ -1,4 +1,4 @@
|
|||||||
FROM jumpserver/core-base:20240919_024156 AS stage-build
|
FROM jumpserver/core-base:20241210_070105 AS stage-build
|
||||||
|
|
||||||
ARG VERSION
|
ARG VERSION
|
||||||
|
|
||||||
@@ -28,6 +28,7 @@ ARG DEPENDENCIES=" \
|
|||||||
libx11-dev"
|
libx11-dev"
|
||||||
|
|
||||||
ARG TOOLS=" \
|
ARG TOOLS=" \
|
||||||
|
cron \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
default-libmysqlclient-dev \
|
default-libmysqlclient-dev \
|
||||||
openssh-client \
|
openssh-client \
|
||||||
@@ -35,19 +36,20 @@ ARG TOOLS=" \
|
|||||||
bubblewrap"
|
bubblewrap"
|
||||||
|
|
||||||
ARG APT_MIRROR=http://deb.debian.org
|
ARG APT_MIRROR=http://deb.debian.org
|
||||||
|
|
||||||
RUN set -ex \
|
RUN set -ex \
|
||||||
&& rm -f /etc/apt/apt.conf.d/docker-clean \
|
|
||||||
&& sed -i "s@http://.*.debian.org@${APT_MIRROR}@g" /etc/apt/sources.list \
|
&& sed -i "s@http://.*.debian.org@${APT_MIRROR}@g" /etc/apt/sources.list \
|
||||||
&& ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
|
&& ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
|
||||||
&& apt-get update > /dev/null \
|
&& apt-get update > /dev/null \
|
||||||
&& apt-get -y install --no-install-recommends ${DEPENDENCIES} \
|
&& apt-get -y install --no-install-recommends ${DEPENDENCIES} \
|
||||||
&& apt-get -y install --no-install-recommends ${TOOLS} \
|
&& apt-get -y install --no-install-recommends ${TOOLS} \
|
||||||
&& apt-get clean \
|
|
||||||
&& mkdir -p /root/.ssh/ \
|
&& mkdir -p /root/.ssh/ \
|
||||||
&& echo "Host *\n\tStrictHostKeyChecking no\n\tUserKnownHostsFile /dev/null\n\tCiphers +aes128-cbc\n\tKexAlgorithms +diffie-hellman-group1-sha1\n\tHostKeyAlgorithms +ssh-rsa" > /root/.ssh/config \
|
&& echo "Host *\n\tStrictHostKeyChecking no\n\tUserKnownHostsFile /dev/null\n\tCiphers +aes128-cbc\n\tKexAlgorithms +diffie-hellman-group1-sha1\n\tHostKeyAlgorithms +ssh-rsa" > /root/.ssh/config \
|
||||||
&& echo "no" | dpkg-reconfigure dash \
|
&& echo "no" | dpkg-reconfigure dash \
|
||||||
&& sed -i "s@# export @export @g" ~/.bashrc \
|
&& apt-get clean all \
|
||||||
&& sed -i "s@# alias @alias @g" ~/.bashrc
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& echo "0 3 * * * root find /tmp -type f -mtime +1 -size +1M -exec rm -f {} \; && date > /tmp/clean.log" > /etc/cron.d/cleanup_tmp \
|
||||||
|
&& chmod 0644 /etc/cron.d/cleanup_tmp
|
||||||
|
|
||||||
COPY --from=stage-build /opt /opt
|
COPY --from=stage-build /opt /opt
|
||||||
COPY --from=stage-build /usr/local/bin /usr/local/bin
|
COPY --from=stage-build /usr/local/bin /usr/local/bin
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ ARG DEPENDENCIES=" \
|
|||||||
libldap2-dev \
|
libldap2-dev \
|
||||||
libsasl2-dev"
|
libsasl2-dev"
|
||||||
|
|
||||||
|
|
||||||
ARG APT_MIRROR=http://deb.debian.org
|
ARG APT_MIRROR=http://deb.debian.org
|
||||||
|
|
||||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=core \
|
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=core \
|
||||||
--mount=type=cache,target=/var/lib/apt,sharing=locked,id=core \
|
--mount=type=cache,target=/var/lib/apt,sharing=locked,id=core \
|
||||||
set -ex \
|
set -ex \
|
||||||
@@ -27,9 +27,8 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=core \
|
|||||||
&& apt-get -y install --no-install-recommends ${DEPENDENCIES} \
|
&& apt-get -y install --no-install-recommends ${DEPENDENCIES} \
|
||||||
&& echo "no" | dpkg-reconfigure dash
|
&& echo "no" | dpkg-reconfigure dash
|
||||||
|
|
||||||
|
|
||||||
# Install bin tools
|
# Install bin tools
|
||||||
ARG CHECK_VERSION=v1.0.3
|
ARG CHECK_VERSION=v1.0.4
|
||||||
RUN set -ex \
|
RUN set -ex \
|
||||||
&& wget https://github.com/jumpserver-dev/healthcheck/releases/download/${CHECK_VERSION}/check-${CHECK_VERSION}-linux-${TARGETARCH}.tar.gz \
|
&& wget https://github.com/jumpserver-dev/healthcheck/releases/download/${CHECK_VERSION}/check-${CHECK_VERSION}-linux-${TARGETARCH}.tar.gz \
|
||||||
&& tar -xf check-${CHECK_VERSION}-linux-${TARGETARCH}.tar.gz \
|
&& tar -xf check-${CHECK_VERSION}-linux-${TARGETARCH}.tar.gz \
|
||||||
@@ -38,23 +37,24 @@ RUN set -ex \
|
|||||||
&& chmod 755 /usr/local/bin/check \
|
&& chmod 755 /usr/local/bin/check \
|
||||||
&& rm -f check-${CHECK_VERSION}-linux-${TARGETARCH}.tar.gz
|
&& rm -f check-${CHECK_VERSION}-linux-${TARGETARCH}.tar.gz
|
||||||
|
|
||||||
|
|
||||||
# Install Python dependencies
|
# Install Python dependencies
|
||||||
WORKDIR /opt/jumpserver
|
WORKDIR /opt/jumpserver
|
||||||
|
|
||||||
ARG PIP_MIRROR=https://pypi.org/simple
|
ARG PIP_MIRROR=https://pypi.org/simple
|
||||||
|
ENV POETRY_PYPI_MIRROR_URL=${PIP_MIRROR}
|
||||||
ENV ANSIBLE_COLLECTIONS_PATHS=/opt/py3/lib/python3.11/site-packages/ansible_collections
|
ENV ANSIBLE_COLLECTIONS_PATHS=/opt/py3/lib/python3.11/site-packages/ansible_collections
|
||||||
|
|
||||||
RUN --mount=type=cache,target=/root/.cache,sharing=locked,id=core \
|
RUN --mount=type=cache,target=/root/.cache \
|
||||||
--mount=type=bind,source=poetry.lock,target=poetry.lock \
|
--mount=type=bind,source=poetry.lock,target=poetry.lock \
|
||||||
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||||
--mount=type=bind,source=utils/clean_site_packages.sh,target=clean_site_packages.sh \
|
--mount=type=bind,source=utils/clean_site_packages.sh,target=clean_site_packages.sh \
|
||||||
--mount=type=bind,source=requirements/collections.yml,target=collections.yml \
|
--mount=type=bind,source=requirements/collections.yml,target=collections.yml \
|
||||||
set -ex \
|
set -ex \
|
||||||
&& python3 -m venv /opt/py3 \
|
&& python3 -m venv /opt/py3 \
|
||||||
&& pip install poetry -i ${PIP_MIRROR} \
|
&& pip install poetry poetry-plugin-pypi-mirror -i ${PIP_MIRROR} \
|
||||||
&& poetry config virtualenvs.create false \
|
|
||||||
&& . /opt/py3/bin/activate \
|
&& . /opt/py3/bin/activate \
|
||||||
&& poetry install --only main \
|
&& poetry config virtualenvs.create false \
|
||||||
|
&& poetry install --no-cache --only main \
|
||||||
&& ansible-galaxy collection install -r collections.yml --force --ignore-certs \
|
&& ansible-galaxy collection install -r collections.yml --force --ignore-certs \
|
||||||
&& bash clean_site_packages.sh
|
&& bash clean_site_packages.sh \
|
||||||
|
&& poetry cache clear pypi --all
|
||||||
|
|||||||
@@ -15,21 +15,20 @@ ARG TOOLS=" \
|
|||||||
vim \
|
vim \
|
||||||
wget"
|
wget"
|
||||||
|
|
||||||
ARG APT_MIRROR=http://deb.debian.org
|
|
||||||
RUN set -ex \
|
RUN set -ex \
|
||||||
&& rm -f /etc/apt/apt.conf.d/docker-clean \
|
|
||||||
&& echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache \
|
|
||||||
&& sed -i "s@http://.*.debian.org@${APT_MIRROR}@g" /etc/apt/sources.list \
|
|
||||||
&& apt-get update \
|
&& apt-get update \
|
||||||
&& apt-get -y install --no-install-recommends ${TOOLS} \
|
&& apt-get -y install --no-install-recommends ${TOOLS} \
|
||||||
&& echo "no" | dpkg-reconfigure dash
|
&& apt-get clean all \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /opt/jumpserver
|
WORKDIR /opt/jumpserver
|
||||||
|
|
||||||
ARG PIP_MIRROR=https://pypi.org/simple
|
ARG PIP_MIRROR=https://pypi.org/simple
|
||||||
|
ENV POETRY_PYPI_MIRROR_URL=${PIP_MIRROR}
|
||||||
COPY poetry.lock pyproject.toml ./
|
COPY poetry.lock pyproject.toml ./
|
||||||
RUN set -ex \
|
RUN set -ex \
|
||||||
&& . /opt/py3/bin/activate \
|
&& . /opt/py3/bin/activate \
|
||||||
&& pip install poetry -i ${PIP_MIRROR} \
|
&& pip install poetry poetry-plugin-pypi-mirror -i ${PIP_MIRROR} \
|
||||||
&& poetry install --only xpack
|
&& poetry install --only xpack \
|
||||||
|
&& poetry cache clear pypi --all
|
||||||
|
|
||||||
|
|||||||
16
README.md
16
README.md
@@ -10,7 +10,8 @@
|
|||||||
[![][github-release-shield]][github-release-link]
|
[![][github-release-shield]][github-release-link]
|
||||||
[![][github-stars-shield]][github-stars-link]
|
[![][github-stars-shield]][github-stars-link]
|
||||||
|
|
||||||
**English** · [简体中文](./README.zh-CN.md)
|
[English](/README.md) · [中文(简体)](/readmes/README.zh-hans.md) · [中文(繁體)](/readmes/README.zh-hant.md) · [日本語](/readmes/README.ja.md) · [Português (Brasil)](/readmes/README.pt-br.md)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<br/>
|
<br/>
|
||||||
|
|
||||||
@@ -68,10 +69,13 @@ JumpServer consists of multiple key components, which collectively form the func
|
|||||||
| [KoKo](https://github.com/jumpserver/koko) | <a href="https://github.com/jumpserver/koko/releases"><img alt="Koko release" src="https://img.shields.io/github/release/jumpserver/koko.svg" /></a> | JumpServer Character Protocol Connector |
|
| [KoKo](https://github.com/jumpserver/koko) | <a href="https://github.com/jumpserver/koko/releases"><img alt="Koko release" src="https://img.shields.io/github/release/jumpserver/koko.svg" /></a> | JumpServer Character Protocol Connector |
|
||||||
| [Lion](https://github.com/jumpserver/lion) | <a href="https://github.com/jumpserver/lion/releases"><img alt="Lion release" src="https://img.shields.io/github/release/jumpserver/lion.svg" /></a> | JumpServer Graphical Protocol Connector |
|
| [Lion](https://github.com/jumpserver/lion) | <a href="https://github.com/jumpserver/lion/releases"><img alt="Lion release" src="https://img.shields.io/github/release/jumpserver/lion.svg" /></a> | JumpServer Graphical Protocol Connector |
|
||||||
| [Chen](https://github.com/jumpserver/chen) | <a href="https://github.com/jumpserver/chen/releases"><img alt="Chen release" src="https://img.shields.io/github/release/jumpserver/chen.svg" /> | JumpServer Web DB |
|
| [Chen](https://github.com/jumpserver/chen) | <a href="https://github.com/jumpserver/chen/releases"><img alt="Chen release" src="https://img.shields.io/github/release/jumpserver/chen.svg" /> | JumpServer Web DB |
|
||||||
| [Razor](https://github.com/jumpserver/razor) | <img alt="Chen" src="https://img.shields.io/badge/release-private-red" /> | JumpServer EE RDP Proxy Connector |
|
| [Razor](https://github.com/jumpserver/razor) | <img alt="Chen" src="https://img.shields.io/badge/release-private-red" /> | JumpServer EE RDP Proxy Connector |
|
||||||
| [Tinker](https://github.com/jumpserver/tinker) | <img alt="Tinker" src="https://img.shields.io/badge/release-private-red" /> | JumpServer EE Remote Application Connector (Windows) |
|
| [Tinker](https://github.com/jumpserver/tinker) | <img alt="Tinker" src="https://img.shields.io/badge/release-private-red" /> | JumpServer EE Remote Application Connector (Windows) |
|
||||||
| [Panda](https://github.com/jumpserver/Panda) | <img alt="Panda" src="https://img.shields.io/badge/release-private-red" /> | JumpServer EE Remote Application Connector (Linux) |
|
| [Panda](https://github.com/jumpserver/Panda) | <img alt="Panda" src="https://img.shields.io/badge/release-private-red" /> | JumpServer EE Remote Application Connector (Linux) |
|
||||||
| [Magnus](https://github.com/jumpserver/magnus) | <img alt="Magnus" src="https://img.shields.io/badge/release-private-red" /> | JumpServer EE Database Proxy Connector |
|
| [Magnus](https://github.com/jumpserver/magnus) | <img alt="Magnus" src="https://img.shields.io/badge/release-private-red" /> | JumpServer EE Database Proxy Connector |
|
||||||
|
| [Nec](https://github.com/jumpserver/nec) | <img alt="Nec" src="https://img.shields.io/badge/release-private-red" /> | JumpServer EE VNC Proxy Connector |
|
||||||
|
| [Facelive](https://github.com/jumpserver/facelive) | <img alt="Facelive" src="https://img.shields.io/badge/release-private-red" /> | JumpServer EE Facial Recognition |
|
||||||
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
@@ -85,7 +89,7 @@ JumpServer is a mission critical product. Please refer to the Basic Security Rec
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Copyright (c) 2014-2024 飞致云 FIT2CLOUD, All rights reserved.
|
Copyright (c) 2014-2025 FIT2CLOUD, All rights reserved.
|
||||||
|
|
||||||
Licensed under The GNU General Public License version 3 (GPLv3) (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at
|
Licensed under The GNU General Public License version 3 (GPLv3) (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at
|
||||||
|
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ JumpServer是一款安全产品,请参考 [基本安全建议](https://docs.ju
|
|||||||
|
|
||||||
## License & Copyright
|
## License & Copyright
|
||||||
|
|
||||||
Copyright (c) 2014-2024 飞致云 FIT2CLOUD, All rights reserved.
|
Copyright (c) 2014-2024 飞致云, All rights reserved.
|
||||||
|
|
||||||
Licensed under The GNU General Public License version 3 (GPLv3) (the "License"); you may not use this file except in
|
Licensed under The GNU General Public License version 3 (GPLv3) (the "License"); you may not use this file except in
|
||||||
compliance with the License. You may obtain a copy of the License at
|
compliance with the License. You may obtain a copy of the License at
|
||||||
|
|||||||
@@ -30,6 +30,6 @@
|
|||||||
login_user: "{{ account.username }}"
|
login_user: "{{ account.username }}"
|
||||||
login_password: "{{ account.secret }}"
|
login_password: "{{ account.secret }}"
|
||||||
login_secret_type: "{{ account.secret_type }}"
|
login_secret_type: "{{ account.secret_type }}"
|
||||||
gateway_args: "{{ jms_gateway | default(None) }}"
|
gateway_args: "{{ jms_gateway | default({}) }}"
|
||||||
when: account.secret_type == "password"
|
when: account.secret_type == "password"
|
||||||
delegate_to: localhost
|
delegate_to: localhost
|
||||||
|
|||||||
@@ -160,6 +160,10 @@ class ChangeSecretManager(AccountBasePlaybookManager):
|
|||||||
ChangeSecretRecord.objects.bulk_create(records)
|
ChangeSecretRecord.objects.bulk_create(records)
|
||||||
return inventory_hosts
|
return inventory_hosts
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def require_update_version(account, recorder):
|
||||||
|
return account.secret != recorder.new_secret
|
||||||
|
|
||||||
def on_host_success(self, host, result):
|
def on_host_success(self, host, result):
|
||||||
recorder = self.name_recorder_mapper.get(host)
|
recorder = self.name_recorder_mapper.get(host)
|
||||||
if not recorder:
|
if not recorder:
|
||||||
@@ -171,6 +175,8 @@ class ChangeSecretManager(AccountBasePlaybookManager):
|
|||||||
if not account:
|
if not account:
|
||||||
print("Account not found, deleted ?")
|
print("Account not found, deleted ?")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
version_update_required = self.require_update_version(account, recorder)
|
||||||
account.secret = recorder.new_secret
|
account.secret = recorder.new_secret
|
||||||
account.date_updated = timezone.now()
|
account.date_updated = timezone.now()
|
||||||
|
|
||||||
@@ -180,7 +186,10 @@ class ChangeSecretManager(AccountBasePlaybookManager):
|
|||||||
while retry_count < max_retries:
|
while retry_count < max_retries:
|
||||||
try:
|
try:
|
||||||
recorder.save()
|
recorder.save()
|
||||||
account.save(update_fields=['secret', 'version', 'date_updated'])
|
account_update_fields = ['secret', 'date_updated']
|
||||||
|
if version_update_required:
|
||||||
|
account_update_fields.append('version')
|
||||||
|
account.save(update_fields=account_update_fields)
|
||||||
break
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
retry_count += 1
|
retry_count += 1
|
||||||
|
|||||||
@@ -30,6 +30,6 @@
|
|||||||
login_user: "{{ account.username }}"
|
login_user: "{{ account.username }}"
|
||||||
login_password: "{{ account.secret }}"
|
login_password: "{{ account.secret }}"
|
||||||
login_secret_type: "{{ account.secret_type }}"
|
login_secret_type: "{{ account.secret_type }}"
|
||||||
gateway_args: "{{ jms_gateway | default(None) }}"
|
gateway_args: "{{ jms_gateway | default({}) }}"
|
||||||
when: account.secret_type == "password"
|
when: account.secret_type == "password"
|
||||||
delegate_to: localhost
|
delegate_to: localhost
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ logger = get_logger(__name__)
|
|||||||
|
|
||||||
class PushAccountManager(ChangeSecretManager, AccountBasePlaybookManager):
|
class PushAccountManager(ChangeSecretManager, AccountBasePlaybookManager):
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def require_update_version(account, recorder):
|
||||||
|
account.skip_history_when_saving = True
|
||||||
|
return False
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def method_type(cls):
|
def method_type(cls):
|
||||||
return AutomationTypes.push_account
|
return AutomationTypes.push_account
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from accounts.const import AutomationTypes
|
from accounts.const import AutomationTypes
|
||||||
from assets.automations.ping_gateway.manager import PingGatewayManager
|
from assets.automations.ping_gateway.manager import PingGatewayManager
|
||||||
from common.utils import get_logger
|
from common.utils import get_logger
|
||||||
@@ -13,7 +15,7 @@ class VerifyGatewayAccountManager(PingGatewayManager):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def before_runner_start():
|
def before_runner_start():
|
||||||
logger.info(">>> 开始执行测试网关账号可连接性任务")
|
logger.info(_(">>> Start executing the task to test gateway account connectivity"))
|
||||||
|
|
||||||
def get_accounts(self, gateway):
|
def get_accounts(self, gateway):
|
||||||
account_ids = self.execution.snapshot['accounts']
|
account_ids = self.execution.snapshot['accounts']
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
from importlib import import_module
|
from importlib import import_module
|
||||||
|
|
||||||
from django.utils.functional import LazyObject
|
from django.utils.functional import LazyObject, empty
|
||||||
|
|
||||||
from common.utils import get_logger
|
from common.utils import get_logger
|
||||||
from ..const import VaultTypeChoices
|
from ..const import VaultTypeChoices
|
||||||
|
|
||||||
__all__ = ['vault_client', 'get_vault_client']
|
__all__ = ['vault_client', 'get_vault_client', 'refresh_vault_client']
|
||||||
|
|
||||||
|
|
||||||
logger = get_logger(__file__)
|
logger = get_logger(__file__)
|
||||||
|
|
||||||
|
|
||||||
def get_vault_client(raise_exception=False, **kwargs):
|
def get_vault_client(raise_exception=False, **kwargs):
|
||||||
enabled = kwargs.get('VAULT_ENABLED')
|
tp = kwargs.get('VAULT_BACKEND') if kwargs.get('VAULT_ENABLED') else VaultTypeChoices.local
|
||||||
tp = 'hcp' if enabled else 'local'
|
|
||||||
try:
|
try:
|
||||||
module_path = f'apps.accounts.backends.{tp}.main'
|
module_path = f'apps.accounts.backends.{tp}.main'
|
||||||
client = import_module(module_path).Vault(**kwargs)
|
client = import_module(module_path).Vault(**kwargs)
|
||||||
@@ -39,3 +38,7 @@ class VaultClient(LazyObject):
|
|||||||
|
|
||||||
""" 为了安全, 页面修改配置, 重启服务后才会重新初始化 vault_client """
|
""" 为了安全, 页面修改配置, 重启服务后才会重新初始化 vault_client """
|
||||||
vault_client = VaultClient()
|
vault_client = VaultClient()
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_vault_client():
|
||||||
|
vault_client._wrapped = empty
|
||||||
|
|||||||
1
apps/accounts/backends/aws/__init__.py
Normal file
1
apps/accounts/backends/aws/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from .main import *
|
||||||
16
apps/accounts/backends/aws/main.py
Normal file
16
apps/accounts/backends/aws/main.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
from .service import AmazonSecretsManagerClient
|
||||||
|
from ..base.vault import BaseVault
|
||||||
|
from ..utils.mixins import GeneralVaultMixin
|
||||||
|
from ...const import VaultTypeChoices
|
||||||
|
|
||||||
|
|
||||||
|
class Vault(GeneralVaultMixin, BaseVault):
|
||||||
|
type = VaultTypeChoices.aws
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.client = AmazonSecretsManagerClient(
|
||||||
|
region_name=kwargs.get('VAULT_AWS_REGION_NAME'),
|
||||||
|
access_key_id=kwargs.get('VAULT_AWS_ACCESS_KEY_ID'),
|
||||||
|
secret_key=kwargs.get('VAULT_AWS_ACCESS_SECRET_KEY'),
|
||||||
|
)
|
||||||
56
apps/accounts/backends/aws/service.py
Normal file
56
apps/accounts/backends/aws/service.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import boto3
|
||||||
|
|
||||||
|
from common.utils import get_logger, random_string
|
||||||
|
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
__all__ = ['AmazonSecretsManagerClient']
|
||||||
|
|
||||||
|
|
||||||
|
class AmazonSecretsManagerClient(object):
|
||||||
|
def __init__(self, region_name, access_key_id, secret_key):
|
||||||
|
self.client = boto3.client(
|
||||||
|
'secretsmanager', region_name=region_name,
|
||||||
|
aws_access_key_id=access_key_id, aws_secret_access_key=secret_key,
|
||||||
|
)
|
||||||
|
self.empty_secret = '#{empty}#'
|
||||||
|
|
||||||
|
def is_active(self):
|
||||||
|
try:
|
||||||
|
secret_id = f'jumpserver/test-{random_string(12)}'
|
||||||
|
self.create(secret_id, 'secret')
|
||||||
|
self.get(secret_id)
|
||||||
|
self.update(secret_id, 'secret')
|
||||||
|
self.delete(secret_id)
|
||||||
|
except Exception as e:
|
||||||
|
return False, f'Vault is not reachable: {e}'
|
||||||
|
else:
|
||||||
|
return True, ''
|
||||||
|
|
||||||
|
def get(self, name, version=''):
|
||||||
|
params = {'SecretId': name}
|
||||||
|
if version:
|
||||||
|
params['VersionStage'] = version
|
||||||
|
|
||||||
|
try:
|
||||||
|
secret = self.client.get_secret_value(**params)['SecretString']
|
||||||
|
return secret if secret != self.empty_secret else ''
|
||||||
|
except Exception: # noqa
|
||||||
|
return ''
|
||||||
|
|
||||||
|
def create(self, name, secret):
|
||||||
|
self.client.create_secret(Name=name, SecretString=secret or self.empty_secret)
|
||||||
|
|
||||||
|
def update(self, name, secret):
|
||||||
|
self.client.update_secret(SecretId=name, SecretString=secret or self.empty_secret)
|
||||||
|
|
||||||
|
def delete(self, name):
|
||||||
|
self.client.delete_secret(SecretId=name)
|
||||||
|
|
||||||
|
def update_metadata(self, name, metadata: dict):
|
||||||
|
tags = [{'Key': k, 'Value': v} for k, v in metadata.items()]
|
||||||
|
try:
|
||||||
|
self.client.tag_resource(SecretId=name, Tags=tags)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'update_metadata: {name} {str(e)}')
|
||||||
1
apps/accounts/backends/azure/__init__.py
Normal file
1
apps/accounts/backends/azure/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from .main import *
|
||||||
33
apps/accounts/backends/azure/entries.py
Normal file
33
apps/accounts/backends/azure/entries.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
from ..base.entries import BaseEntry
|
||||||
|
|
||||||
|
|
||||||
|
class AzureBaseEntry(BaseEntry):
|
||||||
|
@property
|
||||||
|
def full_path(self):
|
||||||
|
return self.path_spec
|
||||||
|
|
||||||
|
|
||||||
|
class AccountEntry(AzureBaseEntry):
|
||||||
|
|
||||||
|
@property
|
||||||
|
def path_spec(self):
|
||||||
|
# 长度 0-127
|
||||||
|
account_id = str(self.instance.id)[:18]
|
||||||
|
path = f'assets-{self.instance.asset_id}-accounts-{account_id}'
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
class AccountTemplateEntry(AzureBaseEntry):
|
||||||
|
|
||||||
|
@property
|
||||||
|
def path_spec(self):
|
||||||
|
path = f'account-templates-{self.instance.id}'
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
class HistoricalAccountEntry(AzureBaseEntry):
|
||||||
|
|
||||||
|
@property
|
||||||
|
def path_spec(self):
|
||||||
|
path = f'accounts-{self.instance.instance.id}-histories-{self.instance.history_id}'
|
||||||
|
return path
|
||||||
17
apps/accounts/backends/azure/main.py
Normal file
17
apps/accounts/backends/azure/main.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
from .service import AZUREVaultClient
|
||||||
|
from ..base.vault import BaseVault
|
||||||
|
from ..utils.mixins import GeneralVaultMixin
|
||||||
|
from ...const import VaultTypeChoices
|
||||||
|
|
||||||
|
|
||||||
|
class Vault(GeneralVaultMixin, BaseVault):
|
||||||
|
type = VaultTypeChoices.azure
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.client = AZUREVaultClient(
|
||||||
|
vault_url=kwargs.get('VAULT_AZURE_HOST'),
|
||||||
|
tenant_id=kwargs.get('VAULT_AZURE_TENANT_ID'),
|
||||||
|
client_id=kwargs.get('VAULT_AZURE_CLIENT_ID'),
|
||||||
|
client_secret=kwargs.get('VAULT_AZURE_CLIENT_SECRET')
|
||||||
|
)
|
||||||
58
apps/accounts/backends/azure/service.py
Normal file
58
apps/accounts/backends/azure/service.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
#
|
||||||
|
from azure.core.exceptions import ResourceNotFoundError, ClientAuthenticationError
|
||||||
|
from azure.identity import ClientSecretCredential
|
||||||
|
from azure.keyvault.secrets import SecretClient
|
||||||
|
|
||||||
|
from common.utils import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
__all__ = ['AZUREVaultClient']
|
||||||
|
|
||||||
|
|
||||||
|
class AZUREVaultClient(object):
|
||||||
|
|
||||||
|
def __init__(self, vault_url, tenant_id, client_id, client_secret):
|
||||||
|
authentication_endpoint = 'https://login.microsoftonline.com/' \
|
||||||
|
if ('azure.net' in vault_url) else 'https://login.chinacloudapi.cn/'
|
||||||
|
|
||||||
|
credentials = ClientSecretCredential(
|
||||||
|
client_id=client_id, client_secret=client_secret, tenant_id=tenant_id, authority=authentication_endpoint
|
||||||
|
)
|
||||||
|
self.client = SecretClient(vault_url=vault_url, credential=credentials)
|
||||||
|
|
||||||
|
def is_active(self):
|
||||||
|
try:
|
||||||
|
self.client.set_secret('jumpserver', '666')
|
||||||
|
except (ResourceNotFoundError, ClientAuthenticationError) as e:
|
||||||
|
logger.error(str(e))
|
||||||
|
return False, f'Vault is not reachable: {e}'
|
||||||
|
else:
|
||||||
|
return True, ''
|
||||||
|
|
||||||
|
def get(self, name, version=None):
|
||||||
|
try:
|
||||||
|
secret = self.client.get_secret(name, version)
|
||||||
|
return secret.value
|
||||||
|
except (ResourceNotFoundError, ClientAuthenticationError) as e:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
def create(self, name, secret):
|
||||||
|
if not secret:
|
||||||
|
secret = ''
|
||||||
|
self.client.set_secret(name, secret)
|
||||||
|
|
||||||
|
def update(self, name, secret):
|
||||||
|
if not secret:
|
||||||
|
secret = ''
|
||||||
|
self.client.set_secret(name, secret)
|
||||||
|
|
||||||
|
def delete(self, name):
|
||||||
|
self.client.begin_delete_secret(name)
|
||||||
|
|
||||||
|
def update_metadata(self, name, metadata: dict):
|
||||||
|
try:
|
||||||
|
self.client.update_secret_properties(name, tags=metadata)
|
||||||
|
except (ResourceNotFoundError, ClientAuthenticationError) as e:
|
||||||
|
logger.error(f'update_metadata: {name} {str(e)}')
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
from abc import ABC, abstractmethod
|
|
||||||
|
|
||||||
from django.forms.models import model_to_dict
|
|
||||||
|
|
||||||
__all__ = ['BaseVault']
|
|
||||||
|
|
||||||
|
|
||||||
class BaseVault(ABC):
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
self.enabled = kwargs.get('VAULT_ENABLED')
|
|
||||||
|
|
||||||
def get(self, instance):
|
|
||||||
""" 返回 secret 值 """
|
|
||||||
return self._get(instance)
|
|
||||||
|
|
||||||
def create(self, instance):
|
|
||||||
if not instance.secret_has_save_to_vault:
|
|
||||||
self._create(instance)
|
|
||||||
self._clean_db_secret(instance)
|
|
||||||
self.save_metadata(instance)
|
|
||||||
|
|
||||||
if instance.is_sync_metadata:
|
|
||||||
self.save_metadata(instance)
|
|
||||||
|
|
||||||
def update(self, instance):
|
|
||||||
if not instance.secret_has_save_to_vault:
|
|
||||||
self._update(instance)
|
|
||||||
self._clean_db_secret(instance)
|
|
||||||
self.save_metadata(instance)
|
|
||||||
|
|
||||||
if instance.is_sync_metadata:
|
|
||||||
self.save_metadata(instance)
|
|
||||||
|
|
||||||
def delete(self, instance):
|
|
||||||
self._delete(instance)
|
|
||||||
|
|
||||||
def save_metadata(self, instance):
|
|
||||||
metadata = model_to_dict(instance, fields=[
|
|
||||||
'name', 'username', 'secret_type',
|
|
||||||
'connectivity', 'su_from', 'privileged'
|
|
||||||
])
|
|
||||||
metadata = {k: str(v)[:500] for k, v in metadata.items() if v}
|
|
||||||
return self._save_metadata(instance, metadata)
|
|
||||||
|
|
||||||
# -------- abstractmethod -------- #
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def _get(self, instance):
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def _create(self, instance):
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def _update(self, instance):
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def _delete(self, instance):
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def _clean_db_secret(self, instance):
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def _save_metadata(self, instance, metadata):
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def is_active(self, *args, **kwargs) -> (bool, str):
|
|
||||||
raise NotImplementedError
|
|
||||||
0
apps/accounts/backends/base/__init__.py
Normal file
0
apps/accounts/backends/base/__init__.py
Normal file
@@ -1,19 +1,18 @@
|
|||||||
import sys
|
|
||||||
from abc import ABC
|
from abc import ABC
|
||||||
|
|
||||||
from common.db.utils import Encryptor
|
from common.db.utils import Encryptor
|
||||||
from common.utils import lazyproperty
|
from common.utils import lazyproperty
|
||||||
|
|
||||||
current_module = sys.modules[__name__]
|
|
||||||
|
|
||||||
__all__ = ['build_entry']
|
|
||||||
|
|
||||||
|
|
||||||
class BaseEntry(ABC):
|
class BaseEntry(ABC):
|
||||||
|
|
||||||
def __init__(self, instance):
|
def __init__(self, instance):
|
||||||
self.instance = instance
|
self.instance = instance
|
||||||
|
|
||||||
|
@property
|
||||||
|
def path_base(self):
|
||||||
|
path = f'orgs/{self.instance.org_id}'
|
||||||
|
return path
|
||||||
|
|
||||||
@lazyproperty
|
@lazyproperty
|
||||||
def full_path(self):
|
def full_path(self):
|
||||||
path_base = self.path_base
|
path_base = self.path_base
|
||||||
@@ -21,32 +20,24 @@ class BaseEntry(ABC):
|
|||||||
path = f'{path_base}/{path_spec}'
|
path = f'{path_base}/{path_spec}'
|
||||||
return path
|
return path
|
||||||
|
|
||||||
@property
|
|
||||||
def path_base(self):
|
|
||||||
path = f'orgs/{self.instance.org_id}'
|
|
||||||
return path
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def path_spec(self):
|
def path_spec(self):
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
def to_internal_data(self):
|
def get_encrypt_secret(self):
|
||||||
secret = getattr(self.instance, '_secret', None)
|
secret = getattr(self.instance, '_secret', None)
|
||||||
if secret is not None:
|
if secret is not None:
|
||||||
secret = Encryptor(secret).encrypt()
|
secret = Encryptor(secret).encrypt()
|
||||||
data = {'secret': secret}
|
return secret
|
||||||
return data
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def to_external_data(data):
|
def get_decrypt_secret(secret):
|
||||||
secret = data.pop('secret', None)
|
|
||||||
if secret is not None:
|
if secret is not None:
|
||||||
secret = Encryptor(secret).decrypt()
|
secret = Encryptor(secret).decrypt()
|
||||||
return secret
|
return secret
|
||||||
|
|
||||||
|
|
||||||
class AccountEntry(BaseEntry):
|
class AccountEntry(BaseEntry):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def path_spec(self):
|
def path_spec(self):
|
||||||
path = f'assets/{self.instance.asset_id}/accounts/{self.instance.id}'
|
path = f'assets/{self.instance.asset_id}/accounts/{self.instance.id}'
|
||||||
@@ -54,7 +45,6 @@ class AccountEntry(BaseEntry):
|
|||||||
|
|
||||||
|
|
||||||
class AccountTemplateEntry(BaseEntry):
|
class AccountTemplateEntry(BaseEntry):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def path_spec(self):
|
def path_spec(self):
|
||||||
path = f'account-templates/{self.instance.id}'
|
path = f'account-templates/{self.instance.id}'
|
||||||
@@ -62,23 +52,12 @@ class AccountTemplateEntry(BaseEntry):
|
|||||||
|
|
||||||
|
|
||||||
class HistoricalAccountEntry(BaseEntry):
|
class HistoricalAccountEntry(BaseEntry):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def path_base(self):
|
def path_base(self):
|
||||||
account = self.instance.instance
|
path = f'accounts/{self.instance.instance.id}'
|
||||||
path = f'accounts/{account.id}/'
|
|
||||||
return path
|
return path
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def path_spec(self):
|
def path_spec(self):
|
||||||
path = f'histories/{self.instance.history_id}'
|
path = f'histories/{self.instance.history_id}'
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
def build_entry(instance) -> BaseEntry:
|
|
||||||
class_name = instance.__class__.__name__
|
|
||||||
entry_class_name = f'{class_name}Entry'
|
|
||||||
entry_class = getattr(current_module, entry_class_name, None)
|
|
||||||
if not entry_class:
|
|
||||||
raise Exception(f'Entry class {entry_class_name} is not found')
|
|
||||||
return entry_class(instance)
|
|
||||||
109
apps/accounts/backends/base/vault.py
Normal file
109
apps/accounts/backends/base/vault.py
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import importlib
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
from django.forms.models import model_to_dict
|
||||||
|
|
||||||
|
from .entries import BaseEntry
|
||||||
|
from ...const import VaultTypeChoices
|
||||||
|
|
||||||
|
|
||||||
|
class BaseVault(ABC):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.enabled = kwargs.get('VAULT_ENABLED')
|
||||||
|
self._entry_classes = {}
|
||||||
|
self._load_entries()
|
||||||
|
|
||||||
|
def _load_entries_import_module(self, module_name):
|
||||||
|
module = importlib.import_module(module_name)
|
||||||
|
for name, obj in inspect.getmembers(module, inspect.isclass):
|
||||||
|
self._entry_classes.setdefault(name, obj)
|
||||||
|
|
||||||
|
def _load_entries(self):
|
||||||
|
if self.type == VaultTypeChoices.local:
|
||||||
|
return
|
||||||
|
|
||||||
|
module_name = f'accounts.backends.{self.type}.entries'
|
||||||
|
if importlib.util.find_spec(module_name): # noqa
|
||||||
|
self._load_entries_import_module(module_name)
|
||||||
|
base_module = 'accounts.backends.base.entries'
|
||||||
|
self._load_entries_import_module(base_module)
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abstractmethod
|
||||||
|
def type(self):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def get(self, instance):
|
||||||
|
""" 返回 secret 值 """
|
||||||
|
return self._get(self.build_entry(instance))
|
||||||
|
|
||||||
|
def create(self, instance):
|
||||||
|
if not instance.secret_has_save_to_vault:
|
||||||
|
entry = self.build_entry(instance)
|
||||||
|
self._create(entry)
|
||||||
|
self._clean_db_secret(instance)
|
||||||
|
self.save_metadata(entry)
|
||||||
|
|
||||||
|
def update(self, instance):
|
||||||
|
entry = self.build_entry(instance)
|
||||||
|
if not instance.secret_has_save_to_vault:
|
||||||
|
self._update(entry)
|
||||||
|
self._clean_db_secret(instance)
|
||||||
|
self.save_metadata(entry)
|
||||||
|
|
||||||
|
if instance.is_sync_metadata:
|
||||||
|
self.save_metadata(entry)
|
||||||
|
|
||||||
|
def delete(self, instance):
|
||||||
|
entry = self.build_entry(instance)
|
||||||
|
self._delete(entry)
|
||||||
|
|
||||||
|
def save_metadata(self, entry):
|
||||||
|
metadata = model_to_dict(entry.instance, fields=[
|
||||||
|
'name', 'username', 'secret_type',
|
||||||
|
'connectivity', 'su_from', 'privileged'
|
||||||
|
])
|
||||||
|
metadata = {k: str(v)[:500] for k, v in metadata.items() if v}
|
||||||
|
return self._save_metadata(entry, metadata)
|
||||||
|
|
||||||
|
def build_entry(self, instance):
|
||||||
|
if self.type == VaultTypeChoices.local:
|
||||||
|
return BaseEntry(instance)
|
||||||
|
|
||||||
|
entry_class_name = f'{instance.__class__.__name__}Entry'
|
||||||
|
entry_class = self._entry_classes.get(entry_class_name)
|
||||||
|
if not entry_class:
|
||||||
|
raise Exception(f'Entry class {entry_class_name} is not found')
|
||||||
|
return entry_class(instance)
|
||||||
|
|
||||||
|
def _clean_db_secret(self, instance):
|
||||||
|
instance.is_sync_metadata = False
|
||||||
|
instance.mark_secret_save_to_vault()
|
||||||
|
|
||||||
|
# -------- abstractmethod -------- #
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def _get(self, instance):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def _create(self, entry):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def _update(self, entry):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def _delete(self, entry):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def _save_metadata(self, instance, metadata):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def is_active(self, *args, **kwargs) -> (bool, str):
|
||||||
|
raise NotImplementedError
|
||||||
@@ -1,14 +1,18 @@
|
|||||||
from common.db.utils import get_logger
|
from common.db.utils import get_logger
|
||||||
from .entries import build_entry
|
|
||||||
from .service import VaultKVClient
|
from .service import VaultKVClient
|
||||||
from ..base import BaseVault
|
from ..base.vault import BaseVault
|
||||||
|
|
||||||
|
from ...const import VaultTypeChoices
|
||||||
|
|
||||||
__all__ = ['Vault']
|
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
__all__ = ['Vault']
|
||||||
|
|
||||||
|
|
||||||
class Vault(BaseVault):
|
class Vault(BaseVault):
|
||||||
|
type = VaultTypeChoices.hcp
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.client = VaultKVClient(
|
self.client = VaultKVClient(
|
||||||
@@ -20,34 +24,25 @@ class Vault(BaseVault):
|
|||||||
def is_active(self):
|
def is_active(self):
|
||||||
return self.client.is_active()
|
return self.client.is_active()
|
||||||
|
|
||||||
def _get(self, instance):
|
def _get(self, entry):
|
||||||
entry = build_entry(instance)
|
|
||||||
# TODO: get data 是不是层数太多了
|
# TODO: get data 是不是层数太多了
|
||||||
data = self.client.get(path=entry.full_path).get('data', {})
|
data = self.client.get(path=entry.full_path).get('data', {})
|
||||||
data = entry.to_external_data(data)
|
data = entry.get_decrypt_secret(data.get('secret'))
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def _create(self, instance):
|
def _create(self, entry):
|
||||||
entry = build_entry(instance)
|
data = {'secret': entry.get_encrypt_secret()}
|
||||||
data = entry.to_internal_data()
|
|
||||||
self.client.create(path=entry.full_path, data=data)
|
self.client.create(path=entry.full_path, data=data)
|
||||||
|
|
||||||
def _update(self, instance):
|
def _update(self, entry):
|
||||||
entry = build_entry(instance)
|
data = {'secret': entry.get_encrypt_secret()}
|
||||||
data = entry.to_internal_data()
|
|
||||||
self.client.patch(path=entry.full_path, data=data)
|
self.client.patch(path=entry.full_path, data=data)
|
||||||
|
|
||||||
def _delete(self, instance):
|
def _delete(self, entry):
|
||||||
entry = build_entry(instance)
|
|
||||||
self.client.delete(path=entry.full_path)
|
self.client.delete(path=entry.full_path)
|
||||||
|
|
||||||
def _clean_db_secret(self, instance):
|
def _save_metadata(self, entry, metadata):
|
||||||
instance.is_sync_metadata = False
|
|
||||||
instance.mark_secret_save_to_vault()
|
|
||||||
|
|
||||||
def _save_metadata(self, instance, metadata):
|
|
||||||
try:
|
try:
|
||||||
entry = build_entry(instance)
|
|
||||||
self.client.update_metadata(path=entry.full_path, metadata=metadata)
|
self.client.update_metadata(path=entry.full_path, metadata=metadata)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f'save metadata error: {e}')
|
logger.error(f'save metadata error: {e}')
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from common.utils import get_logger
|
from common.utils import get_logger
|
||||||
from ..base import BaseVault
|
from ..base.vault import BaseVault
|
||||||
|
from ...const import VaultTypeChoices
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -7,27 +8,28 @@ __all__ = ['Vault']
|
|||||||
|
|
||||||
|
|
||||||
class Vault(BaseVault):
|
class Vault(BaseVault):
|
||||||
|
type = VaultTypeChoices.local
|
||||||
|
|
||||||
def is_active(self):
|
def is_active(self):
|
||||||
return True, ''
|
return True, ''
|
||||||
|
|
||||||
def _get(self, instance):
|
def _get(self, entry):
|
||||||
secret = getattr(instance, '_secret', None)
|
secret = getattr(entry.instance, '_secret', None)
|
||||||
return secret
|
return secret
|
||||||
|
|
||||||
def _create(self, instance):
|
def _create(self, entry):
|
||||||
""" Ignore """
|
""" Ignore """
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def _update(self, instance):
|
def _update(self, entry):
|
||||||
""" Ignore """
|
""" Ignore """
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def _delete(self, instance):
|
def _delete(self, entry):
|
||||||
""" Ignore """
|
""" Ignore """
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def _save_metadata(self, instance, metadata):
|
def _save_metadata(self, entry, metadata):
|
||||||
""" Ignore """
|
""" Ignore """
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
0
apps/accounts/backends/utils/__init__.py
Normal file
0
apps/accounts/backends/utils/__init__.py
Normal file
32
apps/accounts/backends/utils/mixins.py
Normal file
32
apps/accounts/backends/utils/mixins.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
from common.utils import get_logger
|
||||||
|
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class GeneralVaultMixin(object):
|
||||||
|
client = None
|
||||||
|
|
||||||
|
def is_active(self):
|
||||||
|
return self.client.is_active()
|
||||||
|
|
||||||
|
def _get(self, entry):
|
||||||
|
secret = self.client.get(name=entry.full_path)
|
||||||
|
return entry.get_decrypt_secret(secret)
|
||||||
|
|
||||||
|
def _create(self, entry):
|
||||||
|
secret = entry.get_encrypt_secret()
|
||||||
|
self.client.create(name=entry.full_path, secret=secret)
|
||||||
|
|
||||||
|
def _update(self, entry):
|
||||||
|
secret = entry.get_encrypt_secret()
|
||||||
|
self.client.update(name=entry.full_path, secret=secret)
|
||||||
|
|
||||||
|
def _delete(self, entry):
|
||||||
|
self.client.delete(name=entry.full_path)
|
||||||
|
|
||||||
|
def _save_metadata(self, entry, metadata):
|
||||||
|
try:
|
||||||
|
self.client.update_metadata(name=entry.full_path, metadata=metadata)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'save metadata error: {e}')
|
||||||
@@ -49,9 +49,9 @@ class SecretStrategy(models.TextChoices):
|
|||||||
|
|
||||||
|
|
||||||
class SSHKeyStrategy(models.TextChoices):
|
class SSHKeyStrategy(models.TextChoices):
|
||||||
add = 'add', _('Append SSH KEY')
|
|
||||||
set = 'set', _('Empty and append SSH KEY')
|
|
||||||
set_jms = 'set_jms', _('Replace (Replace only keys pushed by JumpServer) ')
|
set_jms = 'set_jms', _('Replace (Replace only keys pushed by JumpServer) ')
|
||||||
|
set = 'set', _('Empty and append SSH KEY')
|
||||||
|
add = 'add', _('Append SSH KEY')
|
||||||
|
|
||||||
|
|
||||||
class TriggerChoice(models.TextChoices, TreeChoices):
|
class TriggerChoice(models.TextChoices, TreeChoices):
|
||||||
|
|||||||
@@ -7,3 +7,5 @@ __all__ = ['VaultTypeChoices']
|
|||||||
class VaultTypeChoices(models.TextChoices):
|
class VaultTypeChoices(models.TextChoices):
|
||||||
local = 'local', _('Database')
|
local = 'local', _('Database')
|
||||||
hcp = 'hcp', _('HCP Vault')
|
hcp = 'hcp', _('HCP Vault')
|
||||||
|
azure = 'azure', _('Azure Key Vault')
|
||||||
|
aws = 'aws', _('Amazon Secrets Manager')
|
||||||
|
|||||||
8
apps/accounts/exceptions.py
Normal file
8
apps/accounts/exceptions.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
from common.exceptions import JMSException
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
|
||||||
|
class VaultException(JMSException):
|
||||||
|
default_detail = _(
|
||||||
|
'Vault operation failed. Please retry or check your account information on Vault.'
|
||||||
|
)
|
||||||
@@ -50,7 +50,7 @@ class Migration(migrations.Migration):
|
|||||||
('secret', common.db.fields.EncryptTextField(blank=True, null=True, verbose_name='Secret')),
|
('secret', common.db.fields.EncryptTextField(blank=True, null=True, verbose_name='Secret')),
|
||||||
('secret_strategy', models.CharField(choices=[('specific', 'Specific secret'), ('random', 'Random generate')], default='specific', max_length=16, verbose_name='Secret strategy')),
|
('secret_strategy', models.CharField(choices=[('specific', 'Specific secret'), ('random', 'Random generate')], default='specific', max_length=16, verbose_name='Secret strategy')),
|
||||||
('password_rules', models.JSONField(default=dict, verbose_name='Password rules')),
|
('password_rules', models.JSONField(default=dict, verbose_name='Password rules')),
|
||||||
('ssh_key_change_strategy', models.CharField(choices=[('add', 'Append SSH KEY'), ('set', 'Empty and append SSH KEY'), ('set_jms', 'Replace (Replace only keys pushed by JumpServer) ')], default='add', max_length=16, verbose_name='SSH key change strategy')),
|
('ssh_key_change_strategy', models.CharField(choices=[('set_jms', 'Replace (Replace only keys pushed by JumpServer) '), ('set', 'Empty and append SSH KEY'), ('add', 'Append SSH KEY')], default='set_jms', max_length=16, verbose_name='SSH key change strategy')),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
'verbose_name': 'Change secret automation',
|
'verbose_name': 'Change secret automation',
|
||||||
@@ -76,7 +76,7 @@ class Migration(migrations.Migration):
|
|||||||
('secret', common.db.fields.EncryptTextField(blank=True, null=True, verbose_name='Secret')),
|
('secret', common.db.fields.EncryptTextField(blank=True, null=True, verbose_name='Secret')),
|
||||||
('secret_strategy', models.CharField(choices=[('specific', 'Specific secret'), ('random', 'Random generate')], default='specific', max_length=16, verbose_name='Secret strategy')),
|
('secret_strategy', models.CharField(choices=[('specific', 'Specific secret'), ('random', 'Random generate')], default='specific', max_length=16, verbose_name='Secret strategy')),
|
||||||
('password_rules', models.JSONField(default=dict, verbose_name='Password rules')),
|
('password_rules', models.JSONField(default=dict, verbose_name='Password rules')),
|
||||||
('ssh_key_change_strategy', models.CharField(choices=[('add', 'Append SSH KEY'), ('set', 'Empty and append SSH KEY'), ('set_jms', 'Replace (Replace only keys pushed by JumpServer) ')], default='add', max_length=16, verbose_name='SSH key change strategy')),
|
('ssh_key_change_strategy', models.CharField(choices=[('set_jms', 'Replace (Replace only keys pushed by JumpServer) '), ('set', 'Empty and append SSH KEY'), ('add', 'Append SSH KEY')], default='set_jms', max_length=16, verbose_name='SSH key change strategy')),
|
||||||
('triggers', models.JSONField(default=list, max_length=16, verbose_name='Triggers')),
|
('triggers', models.JSONField(default=list, max_length=16, verbose_name='Triggers')),
|
||||||
('username', models.CharField(max_length=128, verbose_name='Username')),
|
('username', models.CharField(max_length=128, verbose_name='Username')),
|
||||||
('action', models.CharField(max_length=16, verbose_name='Action')),
|
('action', models.CharField(max_length=16, verbose_name='Action')),
|
||||||
|
|||||||
@@ -53,7 +53,8 @@ class Account(AbsConnectivity, LabeledMixin, BaseAccount):
|
|||||||
on_delete=models.SET_NULL, verbose_name=_("Su from")
|
on_delete=models.SET_NULL, verbose_name=_("Su from")
|
||||||
)
|
)
|
||||||
version = models.IntegerField(default=0, verbose_name=_('Version'))
|
version = models.IntegerField(default=0, verbose_name=_('Version'))
|
||||||
history = AccountHistoricalRecords(included_fields=['id', '_secret', 'secret_type', 'version'])
|
history = AccountHistoricalRecords(included_fields=['id', '_secret', 'secret_type', 'version'],
|
||||||
|
verbose_name=_("historical Account"))
|
||||||
source = models.CharField(max_length=30, default=Source.LOCAL, verbose_name=_('Source'))
|
source = models.CharField(max_length=30, default=Source.LOCAL, verbose_name=_('Source'))
|
||||||
source_id = models.CharField(max_length=128, null=True, blank=True, verbose_name=_('Source ID'))
|
source_id = models.CharField(max_length=128, null=True, blank=True, verbose_name=_('Source ID'))
|
||||||
|
|
||||||
|
|||||||
@@ -14,13 +14,17 @@ from common.db import fields
|
|||||||
from common.db.encoder import ModelJSONFieldEncoder
|
from common.db.encoder import ModelJSONFieldEncoder
|
||||||
from common.utils import get_logger, lazyproperty
|
from common.utils import get_logger, lazyproperty
|
||||||
from ops.mixin import PeriodTaskModelMixin
|
from ops.mixin import PeriodTaskModelMixin
|
||||||
from orgs.mixins.models import OrgModelMixin, JMSOrgBaseModel
|
from orgs.mixins.models import OrgModelMixin, JMSOrgBaseModel, OrgManager
|
||||||
|
|
||||||
__all__ = ['AccountBackupAutomation', 'AccountBackupExecution']
|
__all__ = ['AccountBackupAutomation', 'AccountBackupExecution']
|
||||||
|
|
||||||
logger = get_logger(__file__)
|
logger = get_logger(__file__)
|
||||||
|
|
||||||
|
|
||||||
|
class BaseBackupAutomationManager(OrgManager):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class AccountBackupAutomation(PeriodTaskModelMixin, JMSOrgBaseModel):
|
class AccountBackupAutomation(PeriodTaskModelMixin, JMSOrgBaseModel):
|
||||||
types = models.JSONField(default=list)
|
types = models.JSONField(default=list)
|
||||||
backup_type = models.CharField(max_length=128, choices=AccountBackupType.choices,
|
backup_type = models.CharField(max_length=128, choices=AccountBackupType.choices,
|
||||||
@@ -47,6 +51,8 @@ class AccountBackupAutomation(PeriodTaskModelMixin, JMSOrgBaseModel):
|
|||||||
max_length=4096, blank=True, null=True, verbose_name=_('Zip encrypt password')
|
max_length=4096, blank=True, null=True, verbose_name=_('Zip encrypt password')
|
||||||
)
|
)
|
||||||
|
|
||||||
|
objects = BaseBackupAutomationManager.from_queryset(models.QuerySet)()
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f'{self.name}({self.org_id})'
|
return f'{self.name}({self.org_id})'
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ class AutomationExecution(AssetAutomationExecution):
|
|||||||
class ChangeSecretMixin(SecretWithRandomMixin):
|
class ChangeSecretMixin(SecretWithRandomMixin):
|
||||||
ssh_key_change_strategy = models.CharField(
|
ssh_key_change_strategy = models.CharField(
|
||||||
choices=SSHKeyStrategy.choices, max_length=16,
|
choices=SSHKeyStrategy.choices, max_length=16,
|
||||||
default=SSHKeyStrategy.add, verbose_name=_('SSH key change strategy')
|
default=SSHKeyStrategy.set_jms, verbose_name=_('SSH key change strategy')
|
||||||
)
|
)
|
||||||
get_all_assets: callable # get all assets
|
get_all_assets: callable # get all assets
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from django.conf import settings
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from accounts.const import AutomationTypes
|
from accounts.const import AutomationTypes, SecretType
|
||||||
from accounts.models import Account
|
from accounts.models import Account
|
||||||
from .base import AccountBaseAutomation
|
from .base import AccountBaseAutomation
|
||||||
from .change_secret import ChangeSecretMixin
|
from .change_secret import ChangeSecretMixin
|
||||||
@@ -23,7 +23,8 @@ class PushAccountAutomation(ChangeSecretMixin, AccountBaseAutomation):
|
|||||||
create_usernames = set(usernames) - set(account_usernames)
|
create_usernames = set(usernames) - set(account_usernames)
|
||||||
create_account_objs = [
|
create_account_objs = [
|
||||||
Account(
|
Account(
|
||||||
name=f'{username}-{secret_type}', username=username,
|
name=f"{username}-{secret_type}" if secret_type != SecretType.PASSWORD else username,
|
||||||
|
username=username,
|
||||||
secret_type=secret_type, asset=asset,
|
secret_type=secret_type, asset=asset,
|
||||||
)
|
)
|
||||||
for username in create_usernames
|
for username in create_usernames
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ class VaultModelMixin(models.Model):
|
|||||||
|
|
||||||
def mark_secret_save_to_vault(self):
|
def mark_secret_save_to_vault(self):
|
||||||
self._secret = self._secret_save_to_vault_mark
|
self._secret = self._secret_save_to_vault_mark
|
||||||
|
self.skip_history_when_saving = True
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -385,7 +385,7 @@ class AssetAccountBulkSerializer(
|
|||||||
|
|
||||||
_results = {}
|
_results = {}
|
||||||
for asset in assets:
|
for asset in assets:
|
||||||
if asset not in secret_type_supports:
|
if asset not in secret_type_supports and asset.category != Category.CUSTOM:
|
||||||
_results[asset] = {
|
_results[asset] = {
|
||||||
'error': _('Asset does not support this secret type: %s') % secret_type,
|
'error': _('Asset does not support this secret type: %s') % secret_type,
|
||||||
'state': 'error',
|
'state': 'error',
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from .base import BaseAccountSerializer
|
|||||||
|
|
||||||
|
|
||||||
class PasswordRulesSerializer(serializers.Serializer):
|
class PasswordRulesSerializer(serializers.Serializer):
|
||||||
length = serializers.IntegerField(min_value=8, max_value=30, default=16, label=_('Password length'))
|
length = serializers.IntegerField(min_value=8, max_value=36, default=16, label=_('Password length'))
|
||||||
lowercase = serializers.BooleanField(default=True, label=_('Lowercase'))
|
lowercase = serializers.BooleanField(default=True, label=_('Lowercase'))
|
||||||
uppercase = serializers.BooleanField(default=True, label=_('Uppercase'))
|
uppercase = serializers.BooleanField(default=True, label=_('Uppercase'))
|
||||||
digit = serializers.BooleanField(default=True, label=_('Digit'))
|
digit = serializers.BooleanField(default=True, label=_('Digit'))
|
||||||
|
|||||||
@@ -63,6 +63,26 @@ class ChangeSecretAutomationSerializer(AuthValidateMixin, BaseAutomationSerializ
|
|||||||
)},
|
)},
|
||||||
}}
|
}}
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.set_ssh_key_change_strategy_choices()
|
||||||
|
|
||||||
|
def set_ssh_key_change_strategy_choices(self):
|
||||||
|
ssh_key_change_strategy = self.fields.get("ssh_key_change_strategy")
|
||||||
|
if not ssh_key_change_strategy:
|
||||||
|
return
|
||||||
|
ssh_key_change_strategy._choices.pop(SSHKeyStrategy.add, None)
|
||||||
|
|
||||||
|
def to_representation(self, instance):
|
||||||
|
data = super().to_representation(instance)
|
||||||
|
ssh_strategy_value = data.get('ssh_key_change_strategy', {}).get('value')
|
||||||
|
if ssh_strategy_value == SSHKeyStrategy.add:
|
||||||
|
data['ssh_key_change_strategy'] = {
|
||||||
|
'label': SSHKeyStrategy.set_jms.label,
|
||||||
|
'value': SSHKeyStrategy.set_jms.value
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def model_type(self):
|
def model_type(self):
|
||||||
return AutomationTypes.change_secret
|
return AutomationTypes.change_secret
|
||||||
@@ -75,19 +95,6 @@ class ChangeSecretAutomationSerializer(AuthValidateMixin, BaseAutomationSerializ
|
|||||||
if self.initial_data.get('secret_strategy') == SecretStrategy.custom:
|
if self.initial_data.get('secret_strategy') == SecretStrategy.custom:
|
||||||
return password_rules
|
return password_rules
|
||||||
|
|
||||||
length = password_rules.get('length')
|
|
||||||
|
|
||||||
try:
|
|
||||||
length = int(length)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(e)
|
|
||||||
msg = _("* Please enter the correct password length")
|
|
||||||
raise serializers.ValidationError(msg)
|
|
||||||
|
|
||||||
if length < 6 or length > 30:
|
|
||||||
msg = _('* Password length range 6-30 bits')
|
|
||||||
raise serializers.ValidationError(msg)
|
|
||||||
|
|
||||||
return password_rules
|
return password_rules
|
||||||
|
|
||||||
def validate(self, attrs):
|
def validate(self, attrs):
|
||||||
|
|||||||
@@ -3,14 +3,18 @@ from collections import defaultdict
|
|||||||
from django.db.models.signals import post_delete
|
from django.db.models.signals import post_delete
|
||||||
from django.db.models.signals import pre_save, post_save
|
from django.db.models.signals import pre_save, post_save
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
|
from django.utils.functional import LazyObject
|
||||||
from django.utils.translation import gettext_noop
|
from django.utils.translation import gettext_noop
|
||||||
|
|
||||||
from accounts.backends import vault_client
|
from accounts.backends import vault_client, refresh_vault_client
|
||||||
from accounts.const import Source
|
from accounts.const import Source
|
||||||
from audits.const import ActivityChoices
|
from audits.const import ActivityChoices
|
||||||
from audits.signal_handlers import create_activities
|
from audits.signal_handlers import create_activities
|
||||||
from common.decorators import merge_delay_run
|
from common.decorators import merge_delay_run
|
||||||
|
from common.signals import django_ready
|
||||||
from common.utils import get_logger, i18n_fmt
|
from common.utils import get_logger, i18n_fmt
|
||||||
|
from common.utils.connection import RedisPubSub
|
||||||
|
from .exceptions import VaultException
|
||||||
from .models import Account, AccountTemplate
|
from .models import Account, AccountTemplate
|
||||||
from .tasks.push_account import push_accounts_to_assets_task
|
from .tasks.push_account import push_accounts_to_assets_task
|
||||||
|
|
||||||
@@ -19,6 +23,9 @@ logger = get_logger(__name__)
|
|||||||
|
|
||||||
@receiver(pre_save, sender=Account)
|
@receiver(pre_save, sender=Account)
|
||||||
def on_account_pre_save(sender, instance, **kwargs):
|
def on_account_pre_save(sender, instance, **kwargs):
|
||||||
|
if getattr(instance, 'skip_history_when_saving', False):
|
||||||
|
return
|
||||||
|
|
||||||
if instance.version == 0:
|
if instance.version == 0:
|
||||||
instance.version = 1
|
instance.version = 1
|
||||||
else:
|
else:
|
||||||
@@ -62,7 +69,7 @@ def create_accounts_activities(account, action='create'):
|
|||||||
|
|
||||||
@receiver(post_save, sender=Account)
|
@receiver(post_save, sender=Account)
|
||||||
def on_account_create_by_template(sender, instance, created=False, **kwargs):
|
def on_account_create_by_template(sender, instance, created=False, **kwargs):
|
||||||
if not created or instance.source != Source.TEMPLATE:
|
if not created:
|
||||||
return
|
return
|
||||||
push_accounts_if_need.delay(accounts=(instance,))
|
push_accounts_if_need.delay(accounts=(instance,))
|
||||||
create_accounts_activities(instance, action='create')
|
create_accounts_activities(instance, action='create')
|
||||||
@@ -78,16 +85,39 @@ class VaultSignalHandler(object):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def save_to_vault(sender, instance, created, **kwargs):
|
def save_to_vault(sender, instance, created, **kwargs):
|
||||||
if created:
|
try:
|
||||||
vault_client.create(instance)
|
if created:
|
||||||
else:
|
vault_client.create(instance)
|
||||||
vault_client.update(instance)
|
else:
|
||||||
|
vault_client.update(instance)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error('Vault save failed: {}'.format(e))
|
||||||
|
raise VaultException()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def delete_to_vault(sender, instance, **kwargs):
|
def delete_to_vault(sender, instance, **kwargs):
|
||||||
vault_client.delete(instance)
|
try:
|
||||||
|
vault_client.delete(instance)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error('Vault delete failed: {}'.format(e))
|
||||||
|
raise VaultException()
|
||||||
|
|
||||||
|
|
||||||
for model in (Account, AccountTemplate, Account.history.model):
|
for model in (Account, AccountTemplate, Account.history.model):
|
||||||
post_save.connect(VaultSignalHandler.save_to_vault, sender=model)
|
post_save.connect(VaultSignalHandler.save_to_vault, sender=model)
|
||||||
post_delete.connect(VaultSignalHandler.delete_to_vault, sender=model)
|
post_delete.connect(VaultSignalHandler.delete_to_vault, sender=model)
|
||||||
|
|
||||||
|
|
||||||
|
class VaultPubSub(LazyObject):
|
||||||
|
def _setup(self):
|
||||||
|
self._wrapped = RedisPubSub('refresh_vault')
|
||||||
|
|
||||||
|
|
||||||
|
vault_pub_sub = VaultPubSub()
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(django_ready)
|
||||||
|
def subscribe_vault_change(sender, **kwargs):
|
||||||
|
logger.debug("Start subscribe vault change")
|
||||||
|
|
||||||
|
vault_pub_sub.subscribe(lambda name: refresh_vault_client())
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from celery import shared_task
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from accounts.backends import vault_client
|
from accounts.backends import vault_client
|
||||||
|
from accounts.const import VaultTypeChoices
|
||||||
from accounts.models import Account, AccountTemplate
|
from accounts.models import Account, AccountTemplate
|
||||||
from common.utils import get_logger
|
from common.utils import get_logger
|
||||||
from orgs.utils import tmp_to_root_org
|
from orgs.utils import tmp_to_root_org
|
||||||
@@ -39,6 +40,9 @@ def sync_secret_to_vault():
|
|||||||
# 这里不能判断 settings.VAULT_ENABLED, 必须判断当前 vault_client 的类型
|
# 这里不能判断 settings.VAULT_ENABLED, 必须判断当前 vault_client 的类型
|
||||||
print('\033[35m>>> 当前 Vault 功能未开启, 不需要同步')
|
print('\033[35m>>> 当前 Vault 功能未开启, 不需要同步')
|
||||||
return
|
return
|
||||||
|
if VaultTypeChoices.local == vault_client.type:
|
||||||
|
print('\033[31m>>> 当前第三方 Vault 客户端初始化失败,数据存储在本地数据库')
|
||||||
|
return
|
||||||
|
|
||||||
failed, skipped, succeeded = 0, 0, 0
|
failed, skipped, succeeded = 0, 0, 0
|
||||||
to_sync_models = [Account, AccountTemplate, Account.history.model]
|
to_sync_models = [Account, AccountTemplate, Account.history.model]
|
||||||
@@ -48,7 +52,8 @@ def sync_secret_to_vault():
|
|||||||
for model in to_sync_models:
|
for model in to_sync_models:
|
||||||
instances += list(model.objects.all())
|
instances += list(model.objects.all())
|
||||||
|
|
||||||
with ThreadPoolExecutor(max_workers=10) as executor:
|
max_workers = 1 if VaultTypeChoices.azure == vault_client.type else 10
|
||||||
|
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||||
tasks = [executor.submit(sync_instance, instance) for instance in instances]
|
tasks = [executor.submit(sync_instance, instance) for instance in instances]
|
||||||
|
|
||||||
for future in as_completed(tasks):
|
for future in as_completed(tasks):
|
||||||
|
|||||||
@@ -9,3 +9,5 @@ class ActionChoices(models.TextChoices):
|
|||||||
warning = 'warning', _('Warn')
|
warning = 'warning', _('Warn')
|
||||||
notice = 'notice', _('Notify')
|
notice = 'notice', _('Notify')
|
||||||
notify_and_warn = 'notify_and_warn', _('Notify and warn')
|
notify_and_warn = 'notify_and_warn', _('Notify and warn')
|
||||||
|
face_verify = 'face_verify', _('Face Verify')
|
||||||
|
face_online = 'face_online', _('Face Online')
|
||||||
|
|||||||
@@ -70,6 +70,13 @@ class ActionAclSerializer(serializers.Serializer):
|
|||||||
return
|
return
|
||||||
if not settings.XPACK_LICENSE_IS_VALID:
|
if not settings.XPACK_LICENSE_IS_VALID:
|
||||||
field_action._choices.pop(ActionChoices.review, None)
|
field_action._choices.pop(ActionChoices.review, None)
|
||||||
|
if not (
|
||||||
|
settings.XPACK_LICENSE_IS_VALID and
|
||||||
|
settings.XPACK_LICENSE_EDITION_ULTIMATE and
|
||||||
|
settings.FACE_RECOGNITION_ENABLED
|
||||||
|
):
|
||||||
|
field_action._choices.pop(ActionChoices.face_verify, None)
|
||||||
|
field_action._choices.pop(ActionChoices.face_online, None)
|
||||||
for choice in self.Meta.action_choices_exclude:
|
for choice in self.Meta.action_choices_exclude:
|
||||||
field_action._choices.pop(choice, None)
|
field_action._choices.pop(choice, None)
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ class CommandFilterACLSerializer(BaseSerializer, BulkOrgResourceModelSerializer)
|
|||||||
class Meta(BaseSerializer.Meta):
|
class Meta(BaseSerializer.Meta):
|
||||||
model = CommandFilterACL
|
model = CommandFilterACL
|
||||||
fields = BaseSerializer.Meta.fields + ['command_groups']
|
fields = BaseSerializer.Meta.fields + ['command_groups']
|
||||||
action_choices_exclude = [ActionChoices.notice]
|
action_choices_exclude = [ActionChoices.notice,
|
||||||
|
ActionChoices.face_verify,
|
||||||
|
ActionChoices.face_online]
|
||||||
|
|
||||||
|
|
||||||
class CommandReviewSerializer(serializers.Serializer):
|
class CommandReviewSerializer(serializers.Serializer):
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from common.serializers import MethodSerializer
|
|||||||
from orgs.mixins.serializers import BulkOrgResourceModelSerializer
|
from orgs.mixins.serializers import BulkOrgResourceModelSerializer
|
||||||
from .base import BaseUserACLSerializer
|
from .base import BaseUserACLSerializer
|
||||||
from .rules import RuleSerializer
|
from .rules import RuleSerializer
|
||||||
|
from ..const import ActionChoices
|
||||||
from ..models import LoginACL
|
from ..models import LoginACL
|
||||||
|
|
||||||
__all__ = ["LoginACLSerializer"]
|
__all__ = ["LoginACLSerializer"]
|
||||||
@@ -17,6 +18,7 @@ class LoginACLSerializer(BaseUserACLSerializer, BulkOrgResourceModelSerializer):
|
|||||||
class Meta(BaseUserACLSerializer.Meta):
|
class Meta(BaseUserACLSerializer.Meta):
|
||||||
model = LoginACL
|
model = LoginACL
|
||||||
fields = BaseUserACLSerializer.Meta.fields + ['rules', ]
|
fields = BaseUserACLSerializer.Meta.fields + ['rules', ]
|
||||||
|
action_choices_exclude = [ActionChoices.face_online, ActionChoices.face_verify]
|
||||||
|
|
||||||
def get_rules_serializer(self):
|
def get_rules_serializer(self):
|
||||||
return RuleSerializer()
|
return RuleSerializer()
|
||||||
|
|||||||
@@ -123,6 +123,10 @@ class AssetViewSet(SuggestionMixin, OrgBulkModelViewSet):
|
|||||||
NodeFilterBackend, AttrRulesFilterBackend
|
NodeFilterBackend, AttrRulesFilterBackend
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def perform_destroy(self, instance):
|
||||||
|
instance.accounts.update(su_from_id=None)
|
||||||
|
instance.delete()
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
queryset = super().get_queryset()
|
queryset = super().get_queryset()
|
||||||
if queryset.model is not Asset:
|
if queryset.model is not Asset:
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
from django.db.models import Count
|
from django.db.models import Subquery, OuterRef, Count, Value
|
||||||
|
from django.db.models.functions import Coalesce
|
||||||
from django_filters import rest_framework as filters
|
from django_filters import rest_framework as filters
|
||||||
from rest_framework import generics
|
from rest_framework import generics
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
from assets.const import AllTypes
|
from assets.const import AllTypes
|
||||||
from assets.models import Platform, Node, Asset, PlatformProtocol
|
from assets.models import Platform, Node, Asset, PlatformProtocol
|
||||||
from assets.serializers import PlatformSerializer, PlatformProtocolSerializer, PlatformListSerializer
|
from assets.serializers import PlatformSerializer, PlatformProtocolSerializer, PlatformListSerializer
|
||||||
@@ -42,7 +42,10 @@ class AssetPlatformViewSet(JMSModelViewSet):
|
|||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
# 因为没有走分页逻辑,所以需要这里 prefetch
|
# 因为没有走分页逻辑,所以需要这里 prefetch
|
||||||
queryset = super().get_queryset().annotate(assets_amount=Count('assets')).prefetch_related(
|
asset_count_subquery = Asset.objects.filter(platform=OuterRef('pk')).values('platform').annotate(
|
||||||
|
count=Count('id')).values('count')
|
||||||
|
queryset = super().get_queryset().annotate(
|
||||||
|
assets_amount=Coalesce(Subquery(asset_count_subquery), Value(0))).prefetch_related(
|
||||||
'protocols', 'automation', 'labels', 'labels__label'
|
'protocols', 'automation', 'labels', 'labels__label'
|
||||||
)
|
)
|
||||||
queryset = queryset.filter(type__in=AllTypes.get_types_values())
|
queryset = queryset.filter(type__in=AllTypes.get_types_values())
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ class PingGatewayManager:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def before_runner_start():
|
def before_runner_start():
|
||||||
print(">>> 开始执行测试网关可连接性任务")
|
print(_(">>> Start executing the task to test gateway connectivity"))
|
||||||
|
|
||||||
def get_accounts(self, gateway):
|
def get_accounts(self, gateway):
|
||||||
account = gateway.select_account
|
account = gateway.select_account
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ class BaseType(TextChoices):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_choices(cls):
|
def get_choices(cls):
|
||||||
if not settings.XPACK_ENABLED:
|
if not settings.XPACK_LICENSE_IS_VALID:
|
||||||
choices = [(tp.value, tp.label) for tp in cls.get_community_types()]
|
choices = [(tp.value, tp.label) for tp in cls.get_community_types()]
|
||||||
else:
|
else:
|
||||||
choices = cls.choices
|
choices = cls.choices
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from collections import defaultdict
|
|||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.utils.functional import lazy
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
from common.db.models import ChoicesMixin
|
from common.db.models import ChoicesMixin
|
||||||
@@ -29,15 +30,15 @@ class AllTypes(ChoicesMixin):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def choices(cls):
|
def choices(cls):
|
||||||
|
return lazy(cls.get_choices, list)()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_choices(cls):
|
||||||
choices = []
|
choices = []
|
||||||
for tp in cls.includes:
|
for tp in cls.includes:
|
||||||
choices.extend(tp.get_choices())
|
choices.extend(tp.get_choices())
|
||||||
return choices
|
return choices
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_choices(cls):
|
|
||||||
return cls.choices()
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def filter_choices(cls, category):
|
def filter_choices(cls, category):
|
||||||
choices = dict(cls.category_types()).get(category, cls).get_choices()
|
choices = dict(cls.category_types()).get(category, cls).get_choices()
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ from assets.tasks import execute_asset_automation_task
|
|||||||
from common.const.choices import Trigger
|
from common.const.choices import Trigger
|
||||||
from common.db.fields import EncryptJsonDictTextField
|
from common.db.fields import EncryptJsonDictTextField
|
||||||
from ops.mixin import PeriodTaskModelMixin
|
from ops.mixin import PeriodTaskModelMixin
|
||||||
from orgs.mixins.models import OrgModelMixin, JMSOrgBaseModel
|
from orgs.mixins.models import OrgModelMixin, JMSOrgBaseModel, OrgManager
|
||||||
|
|
||||||
|
|
||||||
|
class BaseAutomationManager(OrgManager):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class BaseAutomation(PeriodTaskModelMixin, JMSOrgBaseModel):
|
class BaseAutomation(PeriodTaskModelMixin, JMSOrgBaseModel):
|
||||||
@@ -21,6 +25,8 @@ class BaseAutomation(PeriodTaskModelMixin, JMSOrgBaseModel):
|
|||||||
is_active = models.BooleanField(default=True, verbose_name=_("Is active"))
|
is_active = models.BooleanField(default=True, verbose_name=_("Is active"))
|
||||||
params = models.JSONField(default=dict, verbose_name=_("Parameters"))
|
params = models.JSONField(default=dict, verbose_name=_("Parameters"))
|
||||||
|
|
||||||
|
objects = BaseAutomationManager.from_queryset(models.QuerySet)()
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name + '@' + str(self.created_by)
|
return self.name + '@' + str(self.created_by)
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from common.serializers.fields import LabeledChoiceField
|
|||||||
from labels.models import Label
|
from labels.models import Label
|
||||||
from orgs.mixins.serializers import BulkOrgResourceModelSerializer
|
from orgs.mixins.serializers import BulkOrgResourceModelSerializer
|
||||||
from ...const import Category, AllTypes
|
from ...const import Category, AllTypes
|
||||||
from ...models import Asset, Node, Platform, Protocol
|
from ...models import Asset, Node, Platform, Protocol, Host, Device, Database, Cloud, Web, Custom
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'AssetSerializer', 'AssetSimpleSerializer', 'MiniAssetSerializer',
|
'AssetSerializer', 'AssetSimpleSerializer', 'MiniAssetSerializer',
|
||||||
@@ -309,6 +309,17 @@ class AssetSerializer(BulkOrgResourceModelSerializer, ResourceLabelsMixin, Writa
|
|||||||
})
|
})
|
||||||
return protocols_data_map.values()
|
return protocols_data_map.values()
|
||||||
|
|
||||||
|
def validate_platform(self, platform_data):
|
||||||
|
check_models = {Host, Device, Database, Cloud, Web, Custom}
|
||||||
|
if self.Meta.model not in check_models:
|
||||||
|
return platform_data
|
||||||
|
model_name = self.Meta.model.__name__.lower()
|
||||||
|
if model_name != platform_data.category:
|
||||||
|
raise serializers.ValidationError({
|
||||||
|
'platform': f"Platform does not match: {platform_data.name}"
|
||||||
|
})
|
||||||
|
return platform_data
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def update_account_su_from(accounts, include_su_from_accounts):
|
def update_account_su_from(accounts, include_su_from_accounts):
|
||||||
if not include_su_from_accounts:
|
if not include_su_from_accounts:
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class DomainSerializer(ResourceLabelsMixin, BulkOrgResourceModelSerializer):
|
|||||||
model = Domain
|
model = Domain
|
||||||
fields_mini = ['id', 'name']
|
fields_mini = ['id', 'name']
|
||||||
fields_small = fields_mini + ['comment']
|
fields_small = fields_mini + ['comment']
|
||||||
fields_m2m = ['assets', 'gateways', 'assets_amount']
|
fields_m2m = ['assets', 'gateways', 'labels', 'assets_amount']
|
||||||
read_only_fields = ['date_created']
|
read_only_fields = ['date_created']
|
||||||
fields = fields_small + fields_m2m + read_only_fields
|
fields = fields_small + fields_m2m + read_only_fields
|
||||||
extra_kwargs = {
|
extra_kwargs = {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from django.db.models import F, Value, CharField, Q
|
|||||||
from django.db.models.functions import Cast
|
from django.db.models.functions import Cast
|
||||||
from django.http import HttpResponse, FileResponse
|
from django.http import HttpResponse, FileResponse
|
||||||
from django.utils.encoding import escape_uri_path
|
from django.utils.encoding import escape_uri_path
|
||||||
|
from django_celery_beat.models import PeriodicTask
|
||||||
from rest_framework import generics
|
from rest_framework import generics
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework import viewsets
|
from rest_framework import viewsets
|
||||||
@@ -22,6 +23,9 @@ from common.plugins.es import QuerySet as ESQuerySet
|
|||||||
from common.sessions.cache import user_session_manager
|
from common.sessions.cache import user_session_manager
|
||||||
from common.storage.ftp_file import FTPFileStorageHandler
|
from common.storage.ftp_file import FTPFileStorageHandler
|
||||||
from common.utils import is_uuid, get_logger, lazyproperty
|
from common.utils import is_uuid, get_logger, lazyproperty
|
||||||
|
from ops.const import Types
|
||||||
|
from ops.models import Job
|
||||||
|
from ops.serializers.job import JobSerializer
|
||||||
from orgs.mixins.api import OrgReadonlyModelViewSet, OrgModelViewSet
|
from orgs.mixins.api import OrgReadonlyModelViewSet, OrgModelViewSet
|
||||||
from orgs.models import Organization
|
from orgs.models import Organization
|
||||||
from orgs.utils import current_org, tmp_to_root_org
|
from orgs.utils import current_org, tmp_to_root_org
|
||||||
@@ -39,14 +43,14 @@ from .serializers import (
|
|||||||
FTPLogSerializer, UserLoginLogSerializer, JobLogSerializer,
|
FTPLogSerializer, UserLoginLogSerializer, JobLogSerializer,
|
||||||
OperateLogSerializer, OperateLogActionDetailSerializer,
|
OperateLogSerializer, OperateLogActionDetailSerializer,
|
||||||
PasswordChangeLogSerializer, ActivityUnionLogSerializer,
|
PasswordChangeLogSerializer, ActivityUnionLogSerializer,
|
||||||
FileSerializer, UserSessionSerializer
|
FileSerializer, UserSessionSerializer, JobsAuditSerializer
|
||||||
)
|
)
|
||||||
from .utils import construct_userlogin_usernames
|
from .utils import construct_userlogin_usernames
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class JobAuditViewSet(OrgReadonlyModelViewSet):
|
class JobLogAuditViewSet(OrgReadonlyModelViewSet):
|
||||||
model = JobLog
|
model = JobLog
|
||||||
extra_filter_backends = [DatetimeRangeFilterBackend]
|
extra_filter_backends = [DatetimeRangeFilterBackend]
|
||||||
date_range_filter_fields = [
|
date_range_filter_fields = [
|
||||||
@@ -58,6 +62,35 @@ class JobAuditViewSet(OrgReadonlyModelViewSet):
|
|||||||
ordering = ['-date_start']
|
ordering = ['-date_start']
|
||||||
|
|
||||||
|
|
||||||
|
class JobsAuditViewSet(OrgModelViewSet):
|
||||||
|
model = Job
|
||||||
|
search_fields = ['creator__name']
|
||||||
|
filterset_fields = ['creator__name']
|
||||||
|
serializer_class = JobsAuditSerializer
|
||||||
|
ordering = ['-is_periodic', '-date_updated']
|
||||||
|
http_method_names = ['get', 'options', 'patch']
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
queryset = super().get_queryset()
|
||||||
|
queryset = queryset.exclude(type=Types.upload_file).filter(instant=False)
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
def perform_update(self, serializer):
|
||||||
|
job = self.get_object()
|
||||||
|
is_periodic = serializer.validated_data.get('is_periodic')
|
||||||
|
if job.is_periodic != is_periodic:
|
||||||
|
job.is_periodic = is_periodic
|
||||||
|
job.save()
|
||||||
|
name, task, args, kwargs = job.get_register_task()
|
||||||
|
task_obj = PeriodicTask.objects.filter(name=name).first()
|
||||||
|
if task_obj:
|
||||||
|
is_periodic = job.is_periodic
|
||||||
|
if task_obj.enabled != is_periodic:
|
||||||
|
task_obj.enabled = is_periodic
|
||||||
|
task_obj.save()
|
||||||
|
return super().perform_update(serializer)
|
||||||
|
|
||||||
|
|
||||||
class FTPLogViewSet(OrgModelViewSet):
|
class FTPLogViewSet(OrgModelViewSet):
|
||||||
model = FTPLog
|
model = FTPLog
|
||||||
serializer_class = FTPLogSerializer
|
serializer_class = FTPLogSerializer
|
||||||
@@ -146,7 +179,9 @@ class MyLoginLogViewSet(UserLoginCommonMixin, OrgReadonlyModelViewSet):
|
|||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
qs = super().get_queryset()
|
qs = super().get_queryset()
|
||||||
qs = qs.filter(username=self.request.user.username)
|
username = self.request.user.username
|
||||||
|
q = Q(username=username) | Q(username__icontains=f'({username})')
|
||||||
|
qs = qs.filter(q)
|
||||||
return qs
|
return qs
|
||||||
|
|
||||||
|
|
||||||
@@ -187,9 +222,13 @@ class ResourceActivityAPIView(generics.ListAPIView):
|
|||||||
'id', 'datetime', 'r_detail', 'r_detail_id',
|
'id', 'datetime', 'r_detail', 'r_detail_id',
|
||||||
'r_user', 'r_action', 'r_type'
|
'r_user', 'r_action', 'r_type'
|
||||||
)
|
)
|
||||||
org_q = Q(org_id=Organization.SYSTEM_ID) | Q(org_id=current_org.id)
|
|
||||||
if resource_id:
|
org_q = Q()
|
||||||
org_q |= Q(org_id='') | Q(org_id=Organization.ROOT_ID)
|
if not current_org.is_root():
|
||||||
|
org_q = Q(org_id=Organization.SYSTEM_ID) | Q(org_id=current_org.id)
|
||||||
|
if resource_id:
|
||||||
|
org_q |= Q(org_id='') | Q(org_id=Organization.ROOT_ID)
|
||||||
|
|
||||||
with tmp_to_root_org():
|
with tmp_to_root_org():
|
||||||
qs1 = self.get_operate_log_qs(fields, limit, org_q, resource_id=resource_id)
|
qs1 = self.get_operate_log_qs(fields, limit, org_q, resource_id=resource_id)
|
||||||
qs2 = self.get_activity_log_qs(fields, limit, org_q, resource_id=resource_id)
|
qs2 = self.get_activity_log_qs(fields, limit, org_q, resource_id=resource_id)
|
||||||
|
|||||||
@@ -74,6 +74,9 @@ class OperateLogStore(object):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def convert_diff_friendly(cls, op_log):
|
def convert_diff_friendly(cls, op_log):
|
||||||
diff_list = list()
|
diff_list = list()
|
||||||
|
# 标记翻译字符串
|
||||||
|
labels = _("labels")
|
||||||
|
operate_log_id = _("operate_log_id")
|
||||||
handler = cls._get_special_handler(op_log.resource_type)
|
handler = cls._get_special_handler(op_log.resource_type)
|
||||||
for k, v in op_log.diff.items():
|
for k, v in op_log.diff.items():
|
||||||
before_value, after_value = cls.split_value(v)
|
before_value, after_value = cls.split_value(v)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from audits.backends.db import OperateLogStore
|
|||||||
from common.serializers.fields import LabeledChoiceField, ObjectRelatedField
|
from common.serializers.fields import LabeledChoiceField, ObjectRelatedField
|
||||||
from common.utils import reverse, i18n_trans
|
from common.utils import reverse, i18n_trans
|
||||||
from common.utils.timezone import as_current_tz
|
from common.utils.timezone import as_current_tz
|
||||||
from ops.serializers.job import JobExecutionSerializer
|
from ops.serializers.job import JobExecutionSerializer, JobSerializer
|
||||||
from orgs.mixins.serializers import BulkOrgResourceModelSerializer
|
from orgs.mixins.serializers import BulkOrgResourceModelSerializer
|
||||||
from terminal.models import Session
|
from terminal.models import Session
|
||||||
from users.models import User
|
from users.models import User
|
||||||
@@ -34,6 +34,30 @@ class JobLogSerializer(JobExecutionSerializer):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class JobsAuditSerializer(JobSerializer):
|
||||||
|
material = serializers.ReadOnlyField(label=_("Command"))
|
||||||
|
summary = serializers.ReadOnlyField(label=_("Summary"))
|
||||||
|
crontab = serializers.ReadOnlyField(label=_("Execution cycle"))
|
||||||
|
is_periodic_display = serializers.BooleanField(read_only=True, source='is_periodic')
|
||||||
|
|
||||||
|
class Meta(JobSerializer.Meta):
|
||||||
|
read_only_fields = [
|
||||||
|
"id", 'name', 'args', 'material', 'type', 'crontab', 'interval', 'date_last_run', 'summary', 'created_by',
|
||||||
|
'is_periodic_display'
|
||||||
|
]
|
||||||
|
fields = read_only_fields + ['is_periodic']
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
allowed_fields = {'is_periodic'}
|
||||||
|
submitted_fields = set(attrs.keys())
|
||||||
|
invalid_fields = submitted_fields - allowed_fields
|
||||||
|
if invalid_fields:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
f"Updating {', '.join(invalid_fields)} fields is not allowed"
|
||||||
|
)
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
class FTPLogSerializer(serializers.ModelSerializer):
|
class FTPLogSerializer(serializers.ModelSerializer):
|
||||||
operate = LabeledChoiceField(choices=OperateChoices.choices, label=_("Operate"))
|
operate = LabeledChoiceField(choices=OperateChoices.choices, label=_("Operate"))
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from audits.handler import (
|
|||||||
create_or_update_operate_log, get_instance_dict_from_cache
|
create_or_update_operate_log, get_instance_dict_from_cache
|
||||||
)
|
)
|
||||||
from audits.utils import model_to_dict_for_operate_log as model_to_dict
|
from audits.utils import model_to_dict_for_operate_log as model_to_dict
|
||||||
from common.const.signals import POST_ADD, POST_REMOVE, POST_CLEAR, SKIP_SIGNAL
|
from common.const.signals import POST_ADD, POST_REMOVE, POST_CLEAR, OP_LOG_SKIP_SIGNAL
|
||||||
from common.signals import django_ready
|
from common.signals import django_ready
|
||||||
from jumpserver.utils import current_request
|
from jumpserver.utils import current_request
|
||||||
from ..const import MODELS_NEED_RECORD, ActionChoices
|
from ..const import MODELS_NEED_RECORD, ActionChoices
|
||||||
@@ -77,7 +77,7 @@ def signal_of_operate_log_whether_continue(
|
|||||||
condition = True
|
condition = True
|
||||||
if not instance:
|
if not instance:
|
||||||
condition = False
|
condition = False
|
||||||
if instance and getattr(instance, SKIP_SIGNAL, False):
|
if instance and getattr(instance, OP_LOG_SKIP_SIGNAL, False):
|
||||||
condition = False
|
condition = False
|
||||||
# 不记录组件的操作日志
|
# 不记录组件的操作日志
|
||||||
user = current_request.user if current_request else None
|
user = current_request.user if current_request else None
|
||||||
@@ -187,7 +187,7 @@ def on_django_start_set_operate_log_monitor_models(sender, **kwargs):
|
|||||||
'PermedAsset', 'PermedAccount', 'MenuPermission',
|
'PermedAsset', 'PermedAccount', 'MenuPermission',
|
||||||
'Permission', 'TicketSession', 'ApplyLoginTicket',
|
'Permission', 'TicketSession', 'ApplyLoginTicket',
|
||||||
'ApplyCommandTicket', 'ApplyLoginAssetTicket',
|
'ApplyCommandTicket', 'ApplyLoginAssetTicket',
|
||||||
'FavoriteAsset', 'ChangeSecretRecord'
|
'FavoriteAsset', 'ChangeSecretRecord', 'AppProvider', 'Variable'
|
||||||
}
|
}
|
||||||
include_models = {'UserSession'}
|
include_models = {'UserSession'}
|
||||||
for i, app in enumerate(apps.get_models(), 1):
|
for i, app in enumerate(apps.get_models(), 1):
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ router.register(r'ftp-logs', api.FTPLogViewSet, 'ftp-log')
|
|||||||
router.register(r'login-logs', api.UserLoginLogViewSet, 'login-log')
|
router.register(r'login-logs', api.UserLoginLogViewSet, 'login-log')
|
||||||
router.register(r'operate-logs', api.OperateLogViewSet, 'operate-log')
|
router.register(r'operate-logs', api.OperateLogViewSet, 'operate-log')
|
||||||
router.register(r'password-change-logs', api.PasswordChangeLogViewSet, 'password-change-log')
|
router.register(r'password-change-logs', api.PasswordChangeLogViewSet, 'password-change-log')
|
||||||
router.register(r'job-logs', api.JobAuditViewSet, 'job-log')
|
router.register(r'job-logs', api.JobLogAuditViewSet, 'job-log')
|
||||||
|
router.register(r'jobs', api.JobsAuditViewSet, 'job')
|
||||||
|
|
||||||
router.register(r'my-login-logs', api.MyLoginLogViewSet, 'my-login-log')
|
router.register(r'my-login-logs', api.MyLoginLogViewSet, 'my-login-log')
|
||||||
router.register(r'user-sessions', api.UserSessionViewSet, 'user-session')
|
router.register(r'user-sessions', api.UserSessionViewSet, 'user-session')
|
||||||
|
|
||||||
|
|||||||
@@ -15,3 +15,4 @@ from .ssh_key import *
|
|||||||
from .sso import *
|
from .sso import *
|
||||||
from .temp_token import *
|
from .temp_token import *
|
||||||
from .token import *
|
from .token import *
|
||||||
|
from .face import *
|
||||||
|
|||||||
@@ -24,11 +24,13 @@ from common.utils.http import is_true, is_false
|
|||||||
from orgs.mixins.api import RootOrgViewMixin
|
from orgs.mixins.api import RootOrgViewMixin
|
||||||
from orgs.utils import tmp_to_org
|
from orgs.utils import tmp_to_org
|
||||||
from perms.models import ActionChoices
|
from perms.models import ActionChoices
|
||||||
from terminal.connect_methods import NativeClient, ConnectMethodUtil
|
from terminal.connect_methods import NativeClient, ConnectMethodUtil, WebMethod
|
||||||
from terminal.models import EndpointRule, Endpoint
|
from terminal.models import EndpointRule, Endpoint
|
||||||
from users.const import FileNameConflictResolution
|
from users.const import FileNameConflictResolution
|
||||||
from users.const import RDPSmartSize, RDPColorQuality
|
from users.const import RDPSmartSize, RDPColorQuality
|
||||||
from users.models import Preference
|
from users.models import Preference
|
||||||
|
from .face import FaceMonitorContext
|
||||||
|
from ..mixins import AuthFaceMixin
|
||||||
from ..models import ConnectionToken, date_expired_default
|
from ..models import ConnectionToken, date_expired_default
|
||||||
from ..serializers import (
|
from ..serializers import (
|
||||||
ConnectionTokenSerializer, ConnectionTokenSecretSerializer,
|
ConnectionTokenSerializer, ConnectionTokenSecretSerializer,
|
||||||
@@ -67,6 +69,36 @@ class RDPFileClientProtocolURLMixin:
|
|||||||
'bookmarktype:i': '3',
|
'bookmarktype:i': '3',
|
||||||
'use redirection server name:i': '0',
|
'use redirection server name:i': '0',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# copy from
|
||||||
|
# https://learn.microsoft.com/zh-cn/windows-server/administration/performance-tuning/role/remote-desktop/session-hosts
|
||||||
|
rdp_low_speed_broadband_option = {
|
||||||
|
"connection type:i": 2,
|
||||||
|
"disable wallpaper:i": 1,
|
||||||
|
"bitmapcachepersistenable:i": 1,
|
||||||
|
"disable full window drag:i": 1,
|
||||||
|
"disable menu anims:i": 1,
|
||||||
|
"allow font smoothing:i": 0,
|
||||||
|
"allow desktop composition:i": 0,
|
||||||
|
"disable themes:i": 0
|
||||||
|
}
|
||||||
|
|
||||||
|
rdp_high_speed_broadband_option = {
|
||||||
|
"connection type:i": 4,
|
||||||
|
"disable wallpaper:i": 0,
|
||||||
|
"bitmapcachepersistenable:i": 1,
|
||||||
|
"disable full window drag:i": 1,
|
||||||
|
"disable menu anims:i": 0,
|
||||||
|
"allow font smoothing:i": 0,
|
||||||
|
"allow desktop composition:i": 1,
|
||||||
|
"disable themes:i": 0
|
||||||
|
}
|
||||||
|
|
||||||
|
RDP_CONNECTION_SPEED_OPTION_MAP = {
|
||||||
|
"auto": {},
|
||||||
|
"low_speed_broadband": rdp_low_speed_broadband_option,
|
||||||
|
"high_speed_broadband": rdp_high_speed_broadband_option,
|
||||||
|
}
|
||||||
# 设置多屏显示
|
# 设置多屏显示
|
||||||
multi_mon = is_true(self.request.query_params.get('multi_mon'))
|
multi_mon = is_true(self.request.query_params.get('multi_mon'))
|
||||||
if multi_mon:
|
if multi_mon:
|
||||||
@@ -91,13 +123,15 @@ class RDPFileClientProtocolURLMixin:
|
|||||||
# rdp_options['domain:s'] = token.account_ad_domain
|
# rdp_options['domain:s'] = token.account_ad_domain
|
||||||
|
|
||||||
# 设置宽高
|
# 设置宽高
|
||||||
height = self.request.query_params.get('height')
|
|
||||||
width = self.request.query_params.get('width')
|
resolution_value = token.connect_options.get('resolution', 'auto')
|
||||||
if width and height:
|
if resolution_value != 'auto':
|
||||||
rdp_options['desktopwidth:i'] = width
|
width, height = resolution_value.split('x')
|
||||||
rdp_options['desktopheight:i'] = height
|
if width and height:
|
||||||
rdp_options['winposstr:s'] = f'0,1,0,0,{width},{height}'
|
rdp_options['desktopwidth:i'] = width
|
||||||
rdp_options['dynamic resolution:i'] = '0'
|
rdp_options['desktopheight:i'] = height
|
||||||
|
rdp_options['winposstr:s'] = f'0,1,0,0,{width},{height}'
|
||||||
|
rdp_options['dynamic resolution:i'] = '0'
|
||||||
|
|
||||||
color_quality = self.request.query_params.get('rdp_color_quality')
|
color_quality = self.request.query_params.get('rdp_color_quality')
|
||||||
color_quality = color_quality if color_quality else os.getenv('JUMPSERVER_COLOR_DEPTH', RDPColorQuality.HIGH)
|
color_quality = color_quality if color_quality else os.getenv('JUMPSERVER_COLOR_DEPTH', RDPColorQuality.HIGH)
|
||||||
@@ -115,6 +149,8 @@ class RDPFileClientProtocolURLMixin:
|
|||||||
rdp = token.asset.platform.protocols.filter(name='rdp').first()
|
rdp = token.asset.platform.protocols.filter(name='rdp').first()
|
||||||
if rdp and rdp.setting.get('console'):
|
if rdp and rdp.setting.get('console'):
|
||||||
rdp_options['administrative session:i'] = '1'
|
rdp_options['administrative session:i'] = '1'
|
||||||
|
rdp_connection_speed = token.connect_options.get('rdp_connection_speed', 'auto')
|
||||||
|
rdp_options.update(RDP_CONNECTION_SPEED_OPTION_MAP.get(rdp_connection_speed, {}))
|
||||||
|
|
||||||
# 文件名
|
# 文件名
|
||||||
name = token.asset.name
|
name = token.asset.name
|
||||||
@@ -221,6 +257,8 @@ class ExtraActionApiMixin(RDPFileClientProtocolURLMixin):
|
|||||||
get_serializer: callable
|
get_serializer: callable
|
||||||
perform_create: callable
|
perform_create: callable
|
||||||
validate_exchange_token: callable
|
validate_exchange_token: callable
|
||||||
|
need_face_verify: bool
|
||||||
|
create_face_verify: callable
|
||||||
|
|
||||||
@action(methods=['POST', 'GET'], detail=True, url_path='rdp-file')
|
@action(methods=['POST', 'GET'], detail=True, url_path='rdp-file')
|
||||||
def get_rdp_file(self, request, *args, **kwargs):
|
def get_rdp_file(self, request, *args, **kwargs):
|
||||||
@@ -280,10 +318,13 @@ class ExtraActionApiMixin(RDPFileClientProtocolURLMixin):
|
|||||||
instance.date_expired = date_expired_default()
|
instance.date_expired = date_expired_default()
|
||||||
instance.save()
|
instance.save()
|
||||||
serializer = self.get_serializer(instance)
|
serializer = self.get_serializer(instance)
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
response = Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||||
|
if self.need_face_verify:
|
||||||
|
self.create_face_verify(response)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
class ConnectionTokenViewSet(ExtraActionApiMixin, RootOrgViewMixin, JMSModelViewSet):
|
class ConnectionTokenViewSet(AuthFaceMixin, ExtraActionApiMixin, RootOrgViewMixin, JMSModelViewSet):
|
||||||
filterset_fields = (
|
filterset_fields = (
|
||||||
'user_display', 'asset_display'
|
'user_display', 'asset_display'
|
||||||
)
|
)
|
||||||
@@ -304,6 +345,8 @@ class ConnectionTokenViewSet(ExtraActionApiMixin, RootOrgViewMixin, JMSModelView
|
|||||||
'get_client_protocol_url': 'authentication.add_connectiontoken',
|
'get_client_protocol_url': 'authentication.add_connectiontoken',
|
||||||
}
|
}
|
||||||
input_username = ''
|
input_username = ''
|
||||||
|
need_face_verify = False
|
||||||
|
face_monitor_token = ''
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
queryset = ConnectionToken.objects \
|
queryset = ConnectionToken.objects \
|
||||||
@@ -355,8 +398,9 @@ class ConnectionTokenViewSet(ExtraActionApiMixin, RootOrgViewMixin, JMSModelView
|
|||||||
asset = data.get('asset')
|
asset = data.get('asset')
|
||||||
account_name = data.get('account')
|
account_name = data.get('account')
|
||||||
protocol = data.get('protocol')
|
protocol = data.get('protocol')
|
||||||
|
connect_method = data.get('connect_method')
|
||||||
self.input_username = self.get_input_username(data)
|
self.input_username = self.get_input_username(data)
|
||||||
_data = self._validate(user, asset, account_name, protocol)
|
_data = self._validate(user, asset, account_name, protocol, connect_method)
|
||||||
data.update(_data)
|
data.update(_data)
|
||||||
return serializer
|
return serializer
|
||||||
|
|
||||||
@@ -364,12 +408,12 @@ class ConnectionTokenViewSet(ExtraActionApiMixin, RootOrgViewMixin, JMSModelView
|
|||||||
user = token.user
|
user = token.user
|
||||||
asset = token.asset
|
asset = token.asset
|
||||||
account_name = token.account
|
account_name = token.account
|
||||||
_data = self._validate(user, asset, account_name, token.protocol)
|
_data = self._validate(user, asset, account_name, token.protocol, token.connect_method)
|
||||||
for k, v in _data.items():
|
for k, v in _data.items():
|
||||||
setattr(token, k, v)
|
setattr(token, k, v)
|
||||||
return token
|
return token
|
||||||
|
|
||||||
def _validate(self, user, asset, account_name, protocol):
|
def _validate(self, user, asset, account_name, protocol, connect_method):
|
||||||
data = dict()
|
data = dict()
|
||||||
data['org_id'] = asset.org_id
|
data['org_id'] = asset.org_id
|
||||||
data['user'] = user
|
data['user'] = user
|
||||||
@@ -385,10 +429,16 @@ class ConnectionTokenViewSet(ExtraActionApiMixin, RootOrgViewMixin, JMSModelView
|
|||||||
if account.username != AliasAccount.INPUT:
|
if account.username != AliasAccount.INPUT:
|
||||||
data['input_username'] = ''
|
data['input_username'] = ''
|
||||||
|
|
||||||
ticket = self._validate_acl(user, asset, account)
|
ticket = self._validate_acl(user, asset, account, connect_method)
|
||||||
if ticket:
|
if ticket:
|
||||||
data['from_ticket'] = ticket
|
data['from_ticket'] = ticket
|
||||||
|
|
||||||
|
if ticket or self.need_face_verify:
|
||||||
data['is_active'] = False
|
data['is_active'] = False
|
||||||
|
if self.face_monitor_token:
|
||||||
|
FaceMonitorContext.get_or_create_context(self.face_monitor_token,
|
||||||
|
self.request.user.id)
|
||||||
|
data['face_monitor_token'] = self.face_monitor_token
|
||||||
return data
|
return data
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -417,7 +467,7 @@ class ConnectionTokenViewSet(ExtraActionApiMixin, RootOrgViewMixin, JMSModelView
|
|||||||
after=after, object_name=object_name
|
after=after, object_name=object_name
|
||||||
)
|
)
|
||||||
|
|
||||||
def _validate_acl(self, user, asset, account):
|
def _validate_acl(self, user, asset, account, connect_method):
|
||||||
from acls.models import LoginAssetACL
|
from acls.models import LoginAssetACL
|
||||||
kwargs = {'user': user, 'asset': asset, 'account': account}
|
kwargs = {'user': user, 'asset': asset, 'account': account}
|
||||||
if account.username == AliasAccount.INPUT:
|
if account.username == AliasAccount.INPUT:
|
||||||
@@ -444,6 +494,26 @@ class ConnectionTokenViewSet(ExtraActionApiMixin, RootOrgViewMixin, JMSModelView
|
|||||||
assignees=acl.reviewers.all(), org_id=asset.org_id
|
assignees=acl.reviewers.all(), org_id=asset.org_id
|
||||||
)
|
)
|
||||||
return ticket
|
return ticket
|
||||||
|
if acl.is_action(acl.ActionChoices.face_verify):
|
||||||
|
if not self.request.query_params.get('face_verify'):
|
||||||
|
msg = _('ACL action is face verify')
|
||||||
|
raise JMSException(code='acl_face_verify', detail=msg)
|
||||||
|
self.need_face_verify = True
|
||||||
|
if acl.is_action(acl.ActionChoices.face_online):
|
||||||
|
if connect_method not in [WebMethod.web_cli, WebMethod.web_gui]:
|
||||||
|
msg = _('ACL action not supported for this asset')
|
||||||
|
raise JMSException(detail=msg, code='acl_face_online_not_supported')
|
||||||
|
|
||||||
|
face_verify = self.request.query_params.get('face_verify')
|
||||||
|
face_monitor_token = self.request.query_params.get('face_monitor_token')
|
||||||
|
|
||||||
|
if not face_verify or not face_monitor_token:
|
||||||
|
msg = _('ACL action is face online')
|
||||||
|
raise JMSException(code='acl_face_online', detail=msg)
|
||||||
|
|
||||||
|
self.need_face_verify = True
|
||||||
|
self.face_monitor_token = face_monitor_token
|
||||||
|
|
||||||
if acl.is_action(acl.ActionChoices.notice):
|
if acl.is_action(acl.ActionChoices.notice):
|
||||||
reviewers = acl.reviewers.all()
|
reviewers = acl.reviewers.all()
|
||||||
if not reviewers:
|
if not reviewers:
|
||||||
@@ -455,9 +525,22 @@ class ConnectionTokenViewSet(ExtraActionApiMixin, RootOrgViewMixin, JMSModelView
|
|||||||
reviewer, asset, user, account, self.input_username
|
reviewer, asset, user, account, self.input_username
|
||||||
).publish_async()
|
).publish_async()
|
||||||
|
|
||||||
|
def create_face_verify(self, response):
|
||||||
|
if not self.request.user.face_vector:
|
||||||
|
raise JMSException(code='no_face_feature', detail=_('No available face feature'))
|
||||||
|
connection_token_id = response.data.get('id')
|
||||||
|
context_data = {
|
||||||
|
"action": "login_asset",
|
||||||
|
"connection_token_id": connection_token_id,
|
||||||
|
}
|
||||||
|
face_verify_token = self.create_face_verify_context(context_data)
|
||||||
|
response.data['face_token'] = face_verify_token
|
||||||
|
|
||||||
def create(self, request, *args, **kwargs):
|
def create(self, request, *args, **kwargs):
|
||||||
try:
|
try:
|
||||||
response = super().create(request, *args, **kwargs)
|
response = super().create(request, *args, **kwargs)
|
||||||
|
if self.need_face_verify:
|
||||||
|
self.create_face_verify(response)
|
||||||
except JMSException as e:
|
except JMSException as e:
|
||||||
data = {'code': e.detail.code, 'detail': e.detail}
|
data = {'code': e.detail.code, 'detail': e.detail}
|
||||||
return Response(data, status=e.status_code)
|
return Response(data, status=e.status_code)
|
||||||
@@ -472,6 +555,7 @@ class SuperConnectionTokenViewSet(ConnectionTokenViewSet):
|
|||||||
rbac_perms = {
|
rbac_perms = {
|
||||||
'create': 'authentication.add_superconnectiontoken',
|
'create': 'authentication.add_superconnectiontoken',
|
||||||
'renewal': 'authentication.add_superconnectiontoken',
|
'renewal': 'authentication.add_superconnectiontoken',
|
||||||
|
'check': 'authentication.view_superconnectiontoken',
|
||||||
'get_secret_detail': 'authentication.view_superconnectiontokensecret',
|
'get_secret_detail': 'authentication.view_superconnectiontokensecret',
|
||||||
'get_applet_info': 'authentication.view_superconnectiontoken',
|
'get_applet_info': 'authentication.view_superconnectiontoken',
|
||||||
'release_applet_account': 'authentication.view_superconnectiontoken',
|
'release_applet_account': 'authentication.view_superconnectiontoken',
|
||||||
@@ -484,6 +568,28 @@ class SuperConnectionTokenViewSet(ConnectionTokenViewSet):
|
|||||||
def get_user(self, serializer):
|
def get_user(self, serializer):
|
||||||
return serializer.validated_data.get('user')
|
return serializer.validated_data.get('user')
|
||||||
|
|
||||||
|
@action(methods=['GET'], detail=True, url_path='check')
|
||||||
|
def check(self, request, *args, **kwargs):
|
||||||
|
instance = self.get_object()
|
||||||
|
data = {
|
||||||
|
"detail": "OK",
|
||||||
|
"code": "perm_ok",
|
||||||
|
"expired": instance.is_expired
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
self._validate_perm(
|
||||||
|
instance.user,
|
||||||
|
instance.asset,
|
||||||
|
instance.account,
|
||||||
|
instance.protocol
|
||||||
|
)
|
||||||
|
except JMSException as e:
|
||||||
|
data['code'] = e.detail.code
|
||||||
|
data['detail'] = str(e.detail)
|
||||||
|
return Response(data=data, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
return Response(data=data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
@action(methods=['PATCH'], detail=False)
|
@action(methods=['PATCH'], detail=False)
|
||||||
def renewal(self, request, *args, **kwargs):
|
def renewal(self, request, *args, **kwargs):
|
||||||
from common.utils.timezone import as_current_tz
|
from common.utils.timezone import as_current_tz
|
||||||
|
|||||||
256
apps/authentication/api/face.py
Normal file
256
apps/authentication/api/face.py
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
from django.core.cache import cache
|
||||||
|
from django.utils.translation import gettext as _
|
||||||
|
from rest_framework.generics import CreateAPIView, RetrieveAPIView
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.serializers import ValidationError
|
||||||
|
from rest_framework.permissions import AllowAny
|
||||||
|
from rest_framework.exceptions import NotFound
|
||||||
|
|
||||||
|
from common.permissions import IsServiceAccount
|
||||||
|
from common.utils import get_logger, get_object_or_none
|
||||||
|
from orgs.utils import tmp_to_root_org
|
||||||
|
from terminal.api.session.task import create_sessions_tasks
|
||||||
|
from users.models import User
|
||||||
|
|
||||||
|
from .. import serializers
|
||||||
|
from ..mixins import AuthMixin
|
||||||
|
from ..const import FACE_CONTEXT_CACHE_KEY_PREFIX, FACE_SESSION_KEY, FACE_CONTEXT_CACHE_TTL, FaceMonitorActionChoices
|
||||||
|
from ..models import ConnectionToken
|
||||||
|
from ..serializers.face import FaceMonitorCallbackSerializer, FaceMonitorContextSerializer
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'FaceCallbackApi',
|
||||||
|
'FaceContextApi',
|
||||||
|
'FaceMonitorContext',
|
||||||
|
'FaceMonitorContextApi',
|
||||||
|
'FaceMonitorCallbackApi'
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class FaceCallbackApi(AuthMixin, CreateAPIView):
|
||||||
|
permission_classes = (IsServiceAccount,)
|
||||||
|
serializer_class = serializers.FaceCallbackSerializer
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
token = serializer.validated_data.get('token')
|
||||||
|
context = self._get_context_from_cache(token)
|
||||||
|
|
||||||
|
if not serializer.validated_data.get('success', False):
|
||||||
|
self._update_context_with_error(
|
||||||
|
context,
|
||||||
|
serializer.validated_data.get('error_message', 'Unknown error')
|
||||||
|
)
|
||||||
|
return Response(status=200)
|
||||||
|
|
||||||
|
face_code = serializer.validated_data.get('face_code')
|
||||||
|
if not face_code:
|
||||||
|
self._update_context_with_error(context, "missing field 'face_code'")
|
||||||
|
raise ValidationError({'error': "missing field 'face_code'"})
|
||||||
|
try:
|
||||||
|
self._handle_success(context, face_code)
|
||||||
|
except Exception as e:
|
||||||
|
self._update_context_with_error(context, str(e))
|
||||||
|
return Response(status=200)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_face_cache_key(token):
|
||||||
|
return f"{FACE_CONTEXT_CACHE_KEY_PREFIX}_{token}"
|
||||||
|
|
||||||
|
def _get_context_from_cache(self, token):
|
||||||
|
cache_key = self.get_face_cache_key(token)
|
||||||
|
context = cache.get(cache_key)
|
||||||
|
if not context:
|
||||||
|
raise ValidationError({'error': "token not exists or expired"})
|
||||||
|
return context
|
||||||
|
|
||||||
|
def _update_context_with_error(self, context, error_message):
|
||||||
|
context.update({
|
||||||
|
'is_finished': True,
|
||||||
|
'success': False,
|
||||||
|
'error_message': error_message,
|
||||||
|
})
|
||||||
|
self._update_cache(context)
|
||||||
|
|
||||||
|
def _update_cache(self, context):
|
||||||
|
cache_key = self.get_face_cache_key(context['token'])
|
||||||
|
cache.set(cache_key, context, FACE_CONTEXT_CACHE_TTL)
|
||||||
|
|
||||||
|
def _handle_success(self, context, face_code):
|
||||||
|
context.update({
|
||||||
|
'is_finished': True,
|
||||||
|
'success': True,
|
||||||
|
'face_code': face_code
|
||||||
|
})
|
||||||
|
action = context.get('action', None)
|
||||||
|
if action == 'login_asset':
|
||||||
|
user_id = context.get('user_id')
|
||||||
|
user = User.objects.get(id=user_id)
|
||||||
|
|
||||||
|
if user.check_face(face_code):
|
||||||
|
with tmp_to_root_org():
|
||||||
|
connection_token_id = context.get('connection_token_id')
|
||||||
|
token = ConnectionToken.objects.filter(id=connection_token_id).first()
|
||||||
|
token.is_active = True
|
||||||
|
token.save()
|
||||||
|
else:
|
||||||
|
context.update({
|
||||||
|
'success': False,
|
||||||
|
'error_message': _('Facial comparison failed')
|
||||||
|
})
|
||||||
|
self._update_cache(context)
|
||||||
|
|
||||||
|
|
||||||
|
class FaceContextApi(AuthMixin, RetrieveAPIView, CreateAPIView):
|
||||||
|
permission_classes = (AllowAny,)
|
||||||
|
face_token_session_key = FACE_SESSION_KEY
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_face_cache_key(token):
|
||||||
|
return f"{FACE_CONTEXT_CACHE_KEY_PREFIX}_{token}"
|
||||||
|
|
||||||
|
def new_face_context(self):
|
||||||
|
return self.create_face_verify_context()
|
||||||
|
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
token = self.new_face_context()
|
||||||
|
return Response({'token': token})
|
||||||
|
|
||||||
|
def get(self, request, *args, **kwargs):
|
||||||
|
token = self.request.session.get(self.face_token_session_key)
|
||||||
|
|
||||||
|
cache_key = self.get_face_cache_key(token)
|
||||||
|
context = cache.get(cache_key)
|
||||||
|
if not context:
|
||||||
|
raise NotFound({'error': "Token does not exist or has expired."})
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
"is_finished": context.get('is_finished', False),
|
||||||
|
"success": context.get('success', False),
|
||||||
|
"error_message": _(context.get("error_message", ''))
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class FaceMonitorContext:
|
||||||
|
def __init__(self, token, user_id, session_ids=None):
|
||||||
|
self.token = token
|
||||||
|
self.user_id = user_id
|
||||||
|
if session_ids is None:
|
||||||
|
self.session_ids = []
|
||||||
|
else:
|
||||||
|
self.session_ids = session_ids
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_cache_key(cls, token):
|
||||||
|
return 'FACE_MONITOR_CONTEXT_{}'.format(token)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_or_create_context(cls, token, user_id):
|
||||||
|
context = cls.get(token)
|
||||||
|
if not context:
|
||||||
|
context = FaceMonitorContext(token=token,
|
||||||
|
user_id=user_id)
|
||||||
|
context.save()
|
||||||
|
return context
|
||||||
|
|
||||||
|
def add_session(self, session_id):
|
||||||
|
self.session_ids.append(session_id)
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get(cls, token):
|
||||||
|
cache_key = cls.get_cache_key(token)
|
||||||
|
return cache.get(cache_key, None)
|
||||||
|
|
||||||
|
def save(self):
|
||||||
|
cache_key = self.get_cache_key(self.token)
|
||||||
|
cache.set(cache_key, self)
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.terminal_sessions()
|
||||||
|
self._destroy()
|
||||||
|
|
||||||
|
def _destroy(self):
|
||||||
|
cache_key = self.get_cache_key(self.token)
|
||||||
|
cache.delete(cache_key)
|
||||||
|
|
||||||
|
def pause_sessions(self):
|
||||||
|
self._send_task('lock_session')
|
||||||
|
|
||||||
|
def resume_sessions(self):
|
||||||
|
self._send_task('unlock_session')
|
||||||
|
|
||||||
|
def terminal_sessions(self):
|
||||||
|
self._send_task("kill_session")
|
||||||
|
|
||||||
|
def _send_task(self, task_name):
|
||||||
|
create_sessions_tasks(self.session_ids, 'facelive', task_name=task_name)
|
||||||
|
|
||||||
|
|
||||||
|
class FaceMonitorContextApi(CreateAPIView):
|
||||||
|
permission_classes = (IsServiceAccount,)
|
||||||
|
serializer_class = FaceMonitorContextSerializer
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
face_monitor_token = serializer.validated_data.get('face_monitor_token')
|
||||||
|
session_id = serializer.validated_data.get('session_id')
|
||||||
|
|
||||||
|
context = FaceMonitorContext.get(face_monitor_token)
|
||||||
|
context.add_session(session_id)
|
||||||
|
|
||||||
|
def create(self, request, *args, **kwargs):
|
||||||
|
serializer = self.get_serializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
self.perform_create(serializer)
|
||||||
|
return Response(status=201)
|
||||||
|
|
||||||
|
|
||||||
|
class FaceMonitorCallbackApi(CreateAPIView):
|
||||||
|
permission_classes = (IsServiceAccount,)
|
||||||
|
serializer_class = FaceMonitorCallbackSerializer
|
||||||
|
|
||||||
|
def create(self, request, *args, **kwargs):
|
||||||
|
serializer = self.get_serializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
token = serializer.validated_data.get('token')
|
||||||
|
|
||||||
|
context = FaceMonitorContext.get(token=token)
|
||||||
|
is_finished = serializer.validated_data.get('is_finished')
|
||||||
|
if is_finished:
|
||||||
|
context.close()
|
||||||
|
return Response(status=200)
|
||||||
|
|
||||||
|
action = serializer.validated_data.get('action')
|
||||||
|
if action == FaceMonitorActionChoices.Verify:
|
||||||
|
user = get_object_or_none(User, pk=context.user_id)
|
||||||
|
face_codes = serializer.validated_data.get('face_codes')
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
context.save()
|
||||||
|
return Response(data={'msg': 'user {} not found'
|
||||||
|
.format(context.user_id)}, status=400)
|
||||||
|
|
||||||
|
if not face_codes or not self._check_face_codes(face_codes, user):
|
||||||
|
context.save()
|
||||||
|
return Response(data={'msg': 'face codes not matched'}, status=400)
|
||||||
|
|
||||||
|
if action == FaceMonitorActionChoices.Pause:
|
||||||
|
context.pause_sessions()
|
||||||
|
if action == FaceMonitorActionChoices.Resume:
|
||||||
|
context.resume_sessions()
|
||||||
|
|
||||||
|
context.save()
|
||||||
|
return Response(status=200)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _check_face_codes(face_codes, user):
|
||||||
|
matched = False
|
||||||
|
for face_code in face_codes:
|
||||||
|
matched = user.check_face(face_code,
|
||||||
|
distance_threshold=0.45,
|
||||||
|
similarity_threshold=0.92)
|
||||||
|
if matched:
|
||||||
|
break
|
||||||
|
return matched
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
#
|
#
|
||||||
|
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
from rest_framework import exceptions
|
from rest_framework import exceptions
|
||||||
from rest_framework.generics import CreateAPIView
|
from rest_framework.generics import CreateAPIView, RetrieveAPIView
|
||||||
from rest_framework.permissions import AllowAny
|
from rest_framework.permissions import AllowAny
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.serializers import ValidationError
|
from rest_framework.serializers import ValidationError
|
||||||
@@ -20,10 +19,12 @@ from ..mixins import AuthMixin
|
|||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'MFAChallengeVerifyApi', 'MFASendCodeApi'
|
'MFAChallengeVerifyApi', 'MFASendCodeApi',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# MFASelectAPi 原来的名字
|
# MFASelectAPi 原来的名字
|
||||||
class MFASendCodeApi(AuthMixin, CreateAPIView):
|
class MFASendCodeApi(AuthMixin, CreateAPIView):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.http import HttpResponseRedirect
|
from django.http import HttpResponseRedirect
|
||||||
from django.shortcuts import reverse
|
from django.shortcuts import reverse
|
||||||
@@ -40,12 +41,15 @@ class UserResetPasswordSendCodeApi(CreateAPIView):
|
|||||||
return user, None
|
return user, None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def safe_send_code(token, code, target, form_type, content):
|
def safe_send_code(token, code, target, form_type, content, user_info):
|
||||||
token_sent_key = '{}_send_at'.format(token)
|
token_sent_key = '{}_send_at'.format(token)
|
||||||
token_send_at = cache.get(token_sent_key, 0)
|
token_send_at = cache.get(token_sent_key, 0)
|
||||||
if token_send_at:
|
if token_send_at:
|
||||||
raise IntervalTooShort(60)
|
raise IntervalTooShort(60)
|
||||||
SendAndVerifyCodeUtil(target, code, backend=form_type, **content).gen_and_send_async()
|
tooler = SendAndVerifyCodeUtil(
|
||||||
|
target, code, backend=form_type, user_info=user_info, **content
|
||||||
|
)
|
||||||
|
tooler.gen_and_send_async()
|
||||||
cache.set(token_sent_key, int(time.time()), 60)
|
cache.set(token_sent_key, int(time.time()), 60)
|
||||||
|
|
||||||
def prepare_code_data(self, user_info, serializer):
|
def prepare_code_data(self, user_info, serializer):
|
||||||
@@ -61,7 +65,7 @@ class UserResetPasswordSendCodeApi(CreateAPIView):
|
|||||||
if not user:
|
if not user:
|
||||||
raise ValueError(err)
|
raise ValueError(err)
|
||||||
|
|
||||||
code = random_string(6, lower=False, upper=False)
|
code = random_string(settings.SMS_CODE_LENGTH, lower=False, upper=False)
|
||||||
subject = '%s: %s' % (get_login_title(), _('Forgot password'))
|
subject = '%s: %s' % (get_login_title(), _('Forgot password'))
|
||||||
context = {
|
context = {
|
||||||
'user': user, 'title': subject, 'code': code,
|
'user': user, 'title': subject, 'code': code,
|
||||||
@@ -82,7 +86,7 @@ class UserResetPasswordSendCodeApi(CreateAPIView):
|
|||||||
code, target, form_type, content = self.prepare_code_data(user_info, serializer)
|
code, target, form_type, content = self.prepare_code_data(user_info, serializer)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return Response({'error': str(e)}, status=400)
|
return Response({'error': str(e)}, status=400)
|
||||||
self.safe_send_code(token, code, target, form_type, content)
|
self.safe_send_code(token, code, target, form_type, content, user_info)
|
||||||
return Response({'data': 'ok'}, status=200)
|
return Response({'data': 'ok'}, status=200)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -23,10 +23,9 @@ class JMSBaseAuthBackend:
|
|||||||
Reject users with is_valid=False. Custom user models that don't have
|
Reject users with is_valid=False. Custom user models that don't have
|
||||||
that attribute are allowed.
|
that attribute are allowed.
|
||||||
"""
|
"""
|
||||||
# 在 check_user_auth 中进行了校验,可以返回对应的错误信息
|
# 三方用户认证完成后,在后续的 get_user 获取逻辑中,也应该需要检查用户是否有效
|
||||||
# is_valid = getattr(user, 'is_valid', None)
|
is_valid = getattr(user, 'is_valid', None)
|
||||||
# return is_valid or is_valid is None
|
return is_valid or is_valid is None
|
||||||
return True
|
|
||||||
|
|
||||||
# allow user to authenticate
|
# allow user to authenticate
|
||||||
def username_allow_authenticate(self, username):
|
def username_allow_authenticate(self, username):
|
||||||
@@ -52,6 +51,14 @@ class JMSBaseAuthBackend:
|
|||||||
logger.info(info)
|
logger.info(info)
|
||||||
return allow
|
return allow
|
||||||
|
|
||||||
|
def get_user(self, user_id):
|
||||||
|
""" 三方用户认证成功后 request.user 赋值时会调用 backend 的当前方法获取用户 """
|
||||||
|
try:
|
||||||
|
user = UserModel._default_manager.get(pk=user_id)
|
||||||
|
except UserModel.DoesNotExist:
|
||||||
|
return None
|
||||||
|
return user if self.user_can_authenticate(user) else None
|
||||||
|
|
||||||
|
|
||||||
class JMSModelBackend(JMSBaseAuthBackend, ModelBackend):
|
class JMSModelBackend(JMSBaseAuthBackend, ModelBackend):
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
#
|
#
|
||||||
from django.urls import path
|
|
||||||
import django_cas_ng.views
|
import django_cas_ng.views
|
||||||
|
from django.urls import path
|
||||||
|
|
||||||
from .views import CASLoginView
|
from .views import CASLoginView, CASCallbackClientView
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('login/', CASLoginView.as_view(), name='cas-login'),
|
path('login/', CASLoginView.as_view(), name='cas-login'),
|
||||||
path('logout/', django_cas_ng.views.LogoutView.as_view(), name='cas-logout'),
|
path('logout/', django_cas_ng.views.LogoutView.as_view(), name='cas-logout'),
|
||||||
path('callback/', django_cas_ng.views.CallbackView.as_view(), name='cas-proxy-callback'),
|
path('callback/', django_cas_ng.views.CallbackView.as_view(), name='cas-proxy-callback'),
|
||||||
|
path('login/client', CASCallbackClientView.as_view(), name='cas-proxy-callback-client'),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
from django_cas_ng.views import LoginView
|
|
||||||
from django.core.exceptions import PermissionDenied
|
from django.core.exceptions import PermissionDenied
|
||||||
from django.http import HttpResponseRedirect
|
from django.http import HttpResponseRedirect
|
||||||
|
from django.views.generic import View
|
||||||
|
from django_cas_ng.views import LoginView
|
||||||
|
|
||||||
__all__ = ['LoginView']
|
__all__ = ['LoginView']
|
||||||
|
|
||||||
|
from authentication.views.utils import redirect_to_guard_view
|
||||||
|
|
||||||
|
|
||||||
class CASLoginView(LoginView):
|
class CASLoginView(LoginView):
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
@@ -13,3 +16,8 @@ class CASLoginView(LoginView):
|
|||||||
return HttpResponseRedirect('/')
|
return HttpResponseRedirect('/')
|
||||||
|
|
||||||
|
|
||||||
|
class CASCallbackClientView(View):
|
||||||
|
http_method_names = ['get', ]
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
return redirect_to_guard_view(query_string='next=client')
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
|
|
||||||
from authentication.signals import user_auth_failed, user_auth_success
|
from authentication.signals import user_auth_failed, user_auth_success
|
||||||
from common.utils import get_logger
|
from common.utils import get_logger
|
||||||
from .base import JMSModelBackend
|
from .base import JMSBaseAuthBackend
|
||||||
|
|
||||||
logger = get_logger(__file__)
|
logger = get_logger(__file__)
|
||||||
|
|
||||||
@@ -20,9 +20,10 @@ if settings.AUTH_CUSTOM:
|
|||||||
logger.warning('Import custom auth method failed: {}, Maybe not enabled'.format(e))
|
logger.warning('Import custom auth method failed: {}, Maybe not enabled'.format(e))
|
||||||
|
|
||||||
|
|
||||||
class CustomAuthBackend(JMSModelBackend):
|
class CustomAuthBackend(JMSBaseAuthBackend):
|
||||||
|
|
||||||
def is_enabled(self):
|
@staticmethod
|
||||||
|
def is_enabled():
|
||||||
return settings.AUTH_CUSTOM and callable(custom_authenticate_method)
|
return settings.AUTH_CUSTOM and callable(custom_authenticate_method)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -35,10 +36,10 @@ class CustomAuthBackend(JMSModelBackend):
|
|||||||
)
|
)
|
||||||
return user, created
|
return user, created
|
||||||
|
|
||||||
def authenticate(self, request, username=None, password=None, **kwargs):
|
def authenticate(self, request, username=None, password=None):
|
||||||
try:
|
try:
|
||||||
userinfo: dict = custom_authenticate_method(
|
userinfo: dict = custom_authenticate_method(
|
||||||
username=username, password=password, **kwargs
|
username=username, password=password
|
||||||
)
|
)
|
||||||
user, created = self.get_or_create_user_from_userinfo(userinfo)
|
user, created = self.get_or_create_user_from_userinfo(userinfo)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
import abc
|
import abc
|
||||||
import ldap
|
import ldap
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.core.cache import cache
|
||||||
from django.core.exceptions import ImproperlyConfigured, ObjectDoesNotExist
|
from django.core.exceptions import ImproperlyConfigured, ObjectDoesNotExist
|
||||||
from django_auth_ldap.backend import _LDAPUser, LDAPBackend
|
from django_auth_ldap.backend import _LDAPUser, LDAPBackend, valid_cache_key
|
||||||
from django_auth_ldap.config import _LDAPConfig, LDAPSearch, LDAPSearchUnion
|
from django_auth_ldap.config import _LDAPConfig, LDAPSearch, LDAPSearchUnion
|
||||||
|
|
||||||
from users.utils import construct_user_email
|
from users.utils import construct_user_email
|
||||||
@@ -146,30 +147,53 @@ class LDAPHAAuthorizationBackend(JMSBaseAuthBackend, LDAPBaseBackend):
|
|||||||
|
|
||||||
class LDAPUser(_LDAPUser):
|
class LDAPUser(_LDAPUser):
|
||||||
|
|
||||||
|
def __init__(self, backend, username=None, user=None, request=None):
|
||||||
|
super().__init__(backend=backend, username=username, user=user, request=request)
|
||||||
|
config_prefix = "" if isinstance(self.backend, LDAPAuthorizationBackend) else "_ha"
|
||||||
|
self.user_dn_cache_key = valid_cache_key(
|
||||||
|
f"django_auth_ldap{config_prefix}.user_dn.{self._username}"
|
||||||
|
)
|
||||||
|
self.category = f"ldap{config_prefix}"
|
||||||
|
self.search_filter = getattr(settings, f"AUTH_LDAP{config_prefix.upper()}_SEARCH_FILTER", None)
|
||||||
|
self.search_ou = getattr(settings, f"AUTH_LDAP{config_prefix.upper()}_SEARCH_OU", None)
|
||||||
|
|
||||||
def _search_for_user_dn_from_ldap_util(self):
|
def _search_for_user_dn_from_ldap_util(self):
|
||||||
from settings.utils import LDAPServerUtil
|
from settings.utils import LDAPServerUtil
|
||||||
util = LDAPServerUtil()
|
util = LDAPServerUtil(category=self.category)
|
||||||
user_dn = util.search_for_user_dn(self._username)
|
user_dn = util.search_for_user_dn(self._username)
|
||||||
return user_dn
|
return user_dn
|
||||||
|
|
||||||
|
def _load_user_dn(self):
|
||||||
|
"""
|
||||||
|
Populates self._user_dn with the distinguished name of our user.
|
||||||
|
|
||||||
|
This will either construct the DN from a template in
|
||||||
|
AUTH_LDAP_USER_DN_TEMPLATE or connect to the server and search for it.
|
||||||
|
If we have to search, we'll cache the DN.
|
||||||
|
|
||||||
|
"""
|
||||||
|
if self._using_simple_bind_mode():
|
||||||
|
self._user_dn = self._construct_simple_user_dn()
|
||||||
|
else:
|
||||||
|
if self.settings.CACHE_TIMEOUT > 0:
|
||||||
|
self._user_dn = cache.get_or_set(
|
||||||
|
self.user_dn_cache_key, self._search_for_user_dn, self.settings.CACHE_TIMEOUT
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._user_dn = self._search_for_user_dn()
|
||||||
|
|
||||||
def _search_for_user_dn(self):
|
def _search_for_user_dn(self):
|
||||||
"""
|
"""
|
||||||
This method was overridden because the AUTH_LDAP_USER_SEARCH
|
This method was overridden because the AUTH_LDAP_USER_SEARCH
|
||||||
configuration in the settings.py file
|
configuration in the settings.py file
|
||||||
is configured with a `lambda` problem value
|
is configured with a `lambda` problem value
|
||||||
"""
|
"""
|
||||||
if isinstance(self.backend, LDAPAuthorizationBackend):
|
|
||||||
search_filter = settings.AUTH_LDAP_SEARCH_FILTER
|
|
||||||
search_ou = settings.AUTH_LDAP_SEARCH_OU
|
|
||||||
else:
|
|
||||||
search_filter = settings.AUTH_LDAP_HA_SEARCH_FILTER
|
|
||||||
search_ou = settings.AUTH_LDAP_HA_SEARCH_OU
|
|
||||||
user_search_union = [
|
user_search_union = [
|
||||||
LDAPSearch(
|
LDAPSearch(
|
||||||
USER_SEARCH, ldap.SCOPE_SUBTREE,
|
USER_SEARCH, ldap.SCOPE_SUBTREE,
|
||||||
search_filter
|
self.search_filter
|
||||||
)
|
)
|
||||||
for USER_SEARCH in str(search_ou).split("|")
|
for USER_SEARCH in str(self.search_ou).split("|")
|
||||||
]
|
]
|
||||||
|
|
||||||
search = LDAPSearchUnion(*user_search_union)
|
search = LDAPSearchUnion(*user_search_union)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
#
|
#
|
||||||
|
import base64
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
@@ -17,7 +18,7 @@ from common.exceptions import JMSException
|
|||||||
from .signals import (
|
from .signals import (
|
||||||
oauth2_create_or_update_user
|
oauth2_create_or_update_user
|
||||||
)
|
)
|
||||||
from ..base import JMSModelBackend
|
from ..base import JMSBaseAuthBackend
|
||||||
|
|
||||||
|
|
||||||
__all__ = ['OAuth2Backend']
|
__all__ = ['OAuth2Backend']
|
||||||
@@ -25,7 +26,7 @@ __all__ = ['OAuth2Backend']
|
|||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class OAuth2Backend(JMSModelBackend):
|
class OAuth2Backend(JMSBaseAuthBackend):
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def is_enabled():
|
def is_enabled():
|
||||||
return settings.AUTH_OAUTH2
|
return settings.AUTH_OAUTH2
|
||||||
@@ -67,15 +68,7 @@ class OAuth2Backend(JMSModelBackend):
|
|||||||
response_data = response_data['data']
|
response_data = response_data['data']
|
||||||
return response_data
|
return response_data
|
||||||
|
|
||||||
@staticmethod
|
def authenticate(self, request, code=None):
|
||||||
def get_query_dict(response_data, query_dict):
|
|
||||||
query_dict.update({
|
|
||||||
'uid': response_data.get('uid', ''),
|
|
||||||
'access_token': response_data.get('access_token', '')
|
|
||||||
})
|
|
||||||
return query_dict
|
|
||||||
|
|
||||||
def authenticate(self, request, code=None, **kwargs):
|
|
||||||
log_prompt = "Process authenticate [OAuth2Backend]: {}"
|
log_prompt = "Process authenticate [OAuth2Backend]: {}"
|
||||||
logger.debug(log_prompt.format('Start'))
|
logger.debug(log_prompt.format('Start'))
|
||||||
if code is None:
|
if code is None:
|
||||||
@@ -83,29 +76,31 @@ class OAuth2Backend(JMSModelBackend):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
query_dict = {
|
query_dict = {
|
||||||
'client_id': settings.AUTH_OAUTH2_CLIENT_ID,
|
'grant_type': 'authorization_code', 'code': code,
|
||||||
'client_secret': settings.AUTH_OAUTH2_CLIENT_SECRET,
|
|
||||||
'grant_type': 'authorization_code',
|
|
||||||
'code': code,
|
|
||||||
'redirect_uri': build_absolute_uri(
|
'redirect_uri': build_absolute_uri(
|
||||||
request, path=reverse(settings.AUTH_OAUTH2_AUTH_LOGIN_CALLBACK_URL_NAME)
|
request, path=reverse(settings.AUTH_OAUTH2_AUTH_LOGIN_CALLBACK_URL_NAME)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if '?' in settings.AUTH_OAUTH2_ACCESS_TOKEN_ENDPOINT:
|
separator = '&' if '?' in settings.AUTH_OAUTH2_ACCESS_TOKEN_ENDPOINT else '?'
|
||||||
separator = '&'
|
|
||||||
else:
|
|
||||||
separator = '?'
|
|
||||||
access_token_url = '{url}{separator}{query}'.format(
|
access_token_url = '{url}{separator}{query}'.format(
|
||||||
url=settings.AUTH_OAUTH2_ACCESS_TOKEN_ENDPOINT, separator=separator, query=urlencode(query_dict)
|
url=settings.AUTH_OAUTH2_ACCESS_TOKEN_ENDPOINT,
|
||||||
|
separator=separator, query=urlencode(query_dict)
|
||||||
)
|
)
|
||||||
# token_method -> get, post(post_data), post_json
|
# token_method -> get, post(post_data), post_json
|
||||||
token_method = settings.AUTH_OAUTH2_ACCESS_TOKEN_METHOD.lower()
|
token_method = settings.AUTH_OAUTH2_ACCESS_TOKEN_METHOD.lower()
|
||||||
logger.debug(log_prompt.format('Call the access token endpoint[method: %s]' % token_method))
|
logger.debug(log_prompt.format('Call the access token endpoint[method: %s]' % token_method))
|
||||||
|
encoded_credentials = base64.b64encode(
|
||||||
|
f"{settings.AUTH_OAUTH2_CLIENT_ID}:{settings.AUTH_OAUTH2_CLIENT_SECRET}".encode()
|
||||||
|
).decode()
|
||||||
headers = {
|
headers = {
|
||||||
'Accept': 'application/json'
|
'Accept': 'application/json', 'Authorization': f'Basic {encoded_credentials}'
|
||||||
}
|
}
|
||||||
if token_method.startswith('post'):
|
if token_method.startswith('post'):
|
||||||
body_key = 'json' if token_method.endswith('json') else 'data'
|
body_key = 'json' if token_method.endswith('json') else 'data'
|
||||||
|
query_dict.update({
|
||||||
|
'client_id': settings.AUTH_OAUTH2_CLIENT_ID,
|
||||||
|
'client_secret': settings.AUTH_OAUTH2_CLIENT_SECRET,
|
||||||
|
})
|
||||||
access_token_response = requests.post(
|
access_token_response = requests.post(
|
||||||
access_token_url, headers=headers, **{body_key: query_dict}
|
access_token_url, headers=headers, **{body_key: query_dict}
|
||||||
)
|
)
|
||||||
@@ -121,22 +116,12 @@ class OAuth2Backend(JMSModelBackend):
|
|||||||
logger.error(log_prompt.format(error))
|
logger.error(log_prompt.format(error))
|
||||||
return None
|
return None
|
||||||
|
|
||||||
query_dict = self.get_query_dict(response_data, query_dict)
|
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
'Authorization': 'Bearer {}'.format(response_data.get('access_token', ''))
|
'Authorization': 'Bearer {}'.format(response_data.get('access_token', ''))
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug(log_prompt.format('Get userinfo endpoint'))
|
logger.debug(log_prompt.format('Get userinfo endpoint'))
|
||||||
if '?' in settings.AUTH_OAUTH2_PROVIDER_USERINFO_ENDPOINT:
|
userinfo_url = settings.AUTH_OAUTH2_PROVIDER_USERINFO_ENDPOINT
|
||||||
separator = '&'
|
|
||||||
else:
|
|
||||||
separator = '?'
|
|
||||||
userinfo_url = '{url}{separator}{query}'.format(
|
|
||||||
url=settings.AUTH_OAUTH2_PROVIDER_USERINFO_ENDPOINT, separator=separator,
|
|
||||||
query=urlencode(query_dict)
|
|
||||||
)
|
|
||||||
userinfo_response = requests.get(userinfo_url, headers=headers)
|
userinfo_response = requests.get(userinfo_url, headers=headers)
|
||||||
try:
|
try:
|
||||||
userinfo_response.raise_for_status()
|
userinfo_response.raise_for_status()
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ from django.urls import path
|
|||||||
|
|
||||||
from . import views
|
from . import views
|
||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('login/', views.OAuth2AuthRequestView.as_view(), name='login'),
|
path('login/', views.OAuth2AuthRequestView.as_view(), name='login'),
|
||||||
path('callback/', views.OAuth2AuthCallbackView.as_view(), name='login-callback'),
|
path('callback/', views.OAuth2AuthCallbackView.as_view(), name='login-callback'),
|
||||||
|
path('callback/client/', views.OAuth2AuthCallbackClientView.as_view(), name='login-callback-client'),
|
||||||
path('logout/', views.OAuth2EndSessionView.as_view(), name='logout')
|
path('logout/', views.OAuth2EndSessionView.as_view(), name='logout')
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
from django.views import View
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib import auth
|
from django.contrib import auth
|
||||||
from django.http import HttpResponseRedirect
|
from django.http import HttpResponseRedirect
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.http import urlencode
|
from django.utils.http import urlencode
|
||||||
|
from django.views import View
|
||||||
|
|
||||||
|
from authentication.mixins import authenticate
|
||||||
from authentication.utils import build_absolute_uri
|
from authentication.utils import build_absolute_uri
|
||||||
from authentication.views.mixins import FlashMessageMixin
|
from authentication.views.mixins import FlashMessageMixin
|
||||||
from authentication.mixins import authenticate
|
from authentication.views.utils import redirect_to_guard_view
|
||||||
from common.utils import get_logger
|
from common.utils import get_logger
|
||||||
|
|
||||||
|
|
||||||
logger = get_logger(__file__)
|
logger = get_logger(__file__)
|
||||||
|
|
||||||
|
|
||||||
@@ -67,6 +67,13 @@ class OAuth2AuthCallbackView(View, FlashMessageMixin):
|
|||||||
return HttpResponseRedirect(redirect_url)
|
return HttpResponseRedirect(redirect_url)
|
||||||
|
|
||||||
|
|
||||||
|
class OAuth2AuthCallbackClientView(View):
|
||||||
|
http_method_names = ['get', ]
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
return redirect_to_guard_view(query_string='next=client')
|
||||||
|
|
||||||
|
|
||||||
class OAuth2EndSessionView(View):
|
class OAuth2EndSessionView(View):
|
||||||
http_method_names = ['get', 'post', ]
|
http_method_names = ['get', 'post', ]
|
||||||
|
|
||||||
|
|||||||
@@ -13,10 +13,8 @@ import requests
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.auth.backends import ModelBackend
|
from django.contrib.auth.backends import ModelBackend
|
||||||
from django.core.exceptions import SuspiciousOperation
|
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from rest_framework.exceptions import ParseError
|
|
||||||
|
|
||||||
from authentication.signals import user_auth_success, user_auth_failed
|
from authentication.signals import user_auth_success, user_auth_failed
|
||||||
from authentication.utils import build_absolute_uri_for_oidc
|
from authentication.utils import build_absolute_uri_for_oidc
|
||||||
@@ -88,7 +86,7 @@ class OIDCAuthCodeBackend(OIDCBaseBackend):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
@ssl_verification
|
@ssl_verification
|
||||||
def authenticate(self, request, nonce=None, code_verifier=None, **kwargs):
|
def authenticate(self, request, nonce=None, code_verifier=None):
|
||||||
""" Authenticates users in case of the OpenID Connect Authorization code flow. """
|
""" Authenticates users in case of the OpenID Connect Authorization code flow. """
|
||||||
log_prompt = "Process authenticate [OIDCAuthCodeBackend]: {}"
|
log_prompt = "Process authenticate [OIDCAuthCodeBackend]: {}"
|
||||||
logger.debug(log_prompt.format('start'))
|
logger.debug(log_prompt.format('start'))
|
||||||
@@ -107,7 +105,7 @@ class OIDCAuthCodeBackend(OIDCBaseBackend):
|
|||||||
# parameters because we won't be able to get a valid token for the user in that case.
|
# parameters because we won't be able to get a valid token for the user in that case.
|
||||||
if (state is None and settings.AUTH_OPENID_USE_STATE) or code is None:
|
if (state is None and settings.AUTH_OPENID_USE_STATE) or code is None:
|
||||||
logger.debug(log_prompt.format('Authorization code or state value is missing'))
|
logger.debug(log_prompt.format('Authorization code or state value is missing'))
|
||||||
raise SuspiciousOperation('Authorization code or state value is missing')
|
return
|
||||||
|
|
||||||
# Prepares the token payload that will be used to request an authentication token to the
|
# Prepares the token payload that will be used to request an authentication token to the
|
||||||
# token endpoint of the OIDC provider.
|
# token endpoint of the OIDC provider.
|
||||||
@@ -165,7 +163,7 @@ class OIDCAuthCodeBackend(OIDCBaseBackend):
|
|||||||
error = "Json token response error, token response " \
|
error = "Json token response error, token response " \
|
||||||
"content is: {}, error is: {}".format(token_response.content, str(e))
|
"content is: {}, error is: {}".format(token_response.content, str(e))
|
||||||
logger.debug(log_prompt.format(error))
|
logger.debug(log_prompt.format(error))
|
||||||
raise ParseError(error)
|
return
|
||||||
|
|
||||||
# Validates the token.
|
# Validates the token.
|
||||||
logger.debug(log_prompt.format('Validate ID Token'))
|
logger.debug(log_prompt.format('Validate ID Token'))
|
||||||
@@ -206,7 +204,7 @@ class OIDCAuthCodeBackend(OIDCBaseBackend):
|
|||||||
error = "Json claims response error, claims response " \
|
error = "Json claims response error, claims response " \
|
||||||
"content is: {}, error is: {}".format(claims_response.content, str(e))
|
"content is: {}, error is: {}".format(claims_response.content, str(e))
|
||||||
logger.debug(log_prompt.format(error))
|
logger.debug(log_prompt.format(error))
|
||||||
raise ParseError(error)
|
return
|
||||||
|
|
||||||
logger.debug(log_prompt.format('Get or create user from claims'))
|
logger.debug(log_prompt.format('Get or create user from claims'))
|
||||||
user, created = self.get_or_create_user_from_claims(request, claims)
|
user, created = self.get_or_create_user_from_claims(request, claims)
|
||||||
@@ -235,15 +233,15 @@ class OIDCAuthCodeBackend(OIDCBaseBackend):
|
|||||||
class OIDCAuthPasswordBackend(OIDCBaseBackend):
|
class OIDCAuthPasswordBackend(OIDCBaseBackend):
|
||||||
|
|
||||||
@ssl_verification
|
@ssl_verification
|
||||||
def authenticate(self, request, username=None, password=None, **kwargs):
|
def authenticate(self, request, username=None, password=None):
|
||||||
try:
|
try:
|
||||||
return self._authenticate(request, username, password, **kwargs)
|
return self._authenticate(request, username, password)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error = f'Authenticate exception: {e}'
|
error = f'Authenticate exception: {e}'
|
||||||
logger.error(error, exc_info=True)
|
logger.error(error, exc_info=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
def _authenticate(self, request, username=None, password=None, **kwargs):
|
def _authenticate(self, request, username=None, password=None):
|
||||||
"""
|
"""
|
||||||
https://oauth.net/2/
|
https://oauth.net/2/
|
||||||
https://aaronparecki.com/oauth-2-simplified/#password
|
https://aaronparecki.com/oauth-2-simplified/#password
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
import warnings
|
import warnings
|
||||||
import contextlib
|
import contextlib
|
||||||
import requests
|
import requests
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
from functools import wraps
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from urllib3.exceptions import InsecureRequestWarning
|
from urllib3.exceptions import InsecureRequestWarning
|
||||||
|
|
||||||
@@ -52,6 +54,7 @@ def no_ssl_verification():
|
|||||||
|
|
||||||
|
|
||||||
def ssl_verification(func):
|
def ssl_verification(func):
|
||||||
|
@wraps(func)
|
||||||
def wrapper(*args, **kwargs):
|
def wrapper(*args, **kwargs):
|
||||||
if not settings.AUTH_OPENID_IGNORE_SSL_VERIFICATION:
|
if not settings.AUTH_OPENID_IGNORE_SSL_VERIFICATION:
|
||||||
return func(*args, **kwargs)
|
return func(*args, **kwargs)
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ from django.urls import path
|
|||||||
|
|
||||||
from . import views
|
from . import views
|
||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('login/', views.OIDCAuthRequestView.as_view(), name='login'),
|
path('login/', views.OIDCAuthRequestView.as_view(), name='login'),
|
||||||
path('callback/', views.OIDCAuthCallbackView.as_view(), name='login-callback'),
|
path('callback/', views.OIDCAuthCallbackView.as_view(), name='login-callback'),
|
||||||
|
path('callback/client/', views.OIDCAuthCallbackClientView.as_view(), name='login-callback-client'),
|
||||||
path('logout/', views.OIDCEndSessionView.as_view(), name='logout'),
|
path('logout/', views.OIDCEndSessionView.as_view(), name='logout'),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -22,13 +22,14 @@ from django.http import HttpResponseRedirect, QueryDict
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.crypto import get_random_string
|
from django.utils.crypto import get_random_string
|
||||||
from django.utils.http import urlencode
|
from django.utils.http import urlencode
|
||||||
from django.views.generic import View
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django.views.generic import View
|
||||||
|
|
||||||
from authentication.utils import build_absolute_uri_for_oidc
|
from authentication.utils import build_absolute_uri_for_oidc
|
||||||
from authentication.views.mixins import FlashMessageMixin
|
from authentication.views.mixins import FlashMessageMixin
|
||||||
from common.utils import safe_next_url
|
from common.utils import safe_next_url
|
||||||
from .utils import get_logger
|
from .utils import get_logger
|
||||||
|
from ...views.utils import redirect_to_guard_view
|
||||||
|
|
||||||
logger = get_logger(__file__)
|
logger = get_logger(__file__)
|
||||||
|
|
||||||
@@ -208,6 +209,13 @@ class OIDCAuthCallbackView(View, FlashMessageMixin):
|
|||||||
return HttpResponseRedirect(settings.AUTH_OPENID_AUTHENTICATION_FAILURE_REDIRECT_URI)
|
return HttpResponseRedirect(settings.AUTH_OPENID_AUTHENTICATION_FAILURE_REDIRECT_URI)
|
||||||
|
|
||||||
|
|
||||||
|
class OIDCAuthCallbackClientView(View):
|
||||||
|
http_method_names = ['get', ]
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
return redirect_to_guard_view(query_string='next=client')
|
||||||
|
|
||||||
|
|
||||||
class OIDCEndSessionView(View):
|
class OIDCEndSessionView(View):
|
||||||
""" Allows to end the session of any user authenticated using OpenID Connect.
|
""" Allows to end the session of any user authenticated using OpenID Connect.
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ class Passkey(JMSBaseModel):
|
|||||||
added_on = models.DateTimeField(auto_now_add=True, verbose_name=_("Added on"))
|
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"))
|
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"))
|
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"))
|
token = models.CharField(max_length=1024, null=False, verbose_name=_("Token"))
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|||||||
@@ -51,10 +51,10 @@ class RadiusBaseBackend(CreateUserMixin, JMSBaseAuthBackend):
|
|||||||
|
|
||||||
|
|
||||||
class RadiusBackend(RadiusBaseBackend, RADIUSBackend):
|
class RadiusBackend(RadiusBaseBackend, RADIUSBackend):
|
||||||
def authenticate(self, request, username='', password='', **kwargs):
|
def authenticate(self, request, username='', password=''):
|
||||||
return super().authenticate(request, username=username, password=password)
|
return super().authenticate(request, username=username, password=password)
|
||||||
|
|
||||||
|
|
||||||
class RadiusRealmBackend(RadiusBaseBackend, RADIUSRealmBackend):
|
class RadiusRealmBackend(RadiusBaseBackend, RADIUSRealmBackend):
|
||||||
def authenticate(self, request, username='', password='', realm=None, **kwargs):
|
def authenticate(self, request, username='', password='', realm=None):
|
||||||
return super().authenticate(request, username=username, password=password, realm=realm)
|
return super().authenticate(request, username=username, password=password, realm=realm)
|
||||||
|
|||||||
@@ -10,14 +10,14 @@ from .signals import (
|
|||||||
saml2_create_or_update_user
|
saml2_create_or_update_user
|
||||||
)
|
)
|
||||||
from authentication.signals import user_auth_failed, user_auth_success
|
from authentication.signals import user_auth_failed, user_auth_success
|
||||||
from ..base import JMSModelBackend
|
from ..base import JMSBaseAuthBackend
|
||||||
|
|
||||||
__all__ = ['SAML2Backend']
|
__all__ = ['SAML2Backend']
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class SAML2Backend(JMSModelBackend):
|
class SAML2Backend(JMSBaseAuthBackend):
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def is_enabled():
|
def is_enabled():
|
||||||
return settings.AUTH_SAML2
|
return settings.AUTH_SAML2
|
||||||
@@ -42,7 +42,7 @@ class SAML2Backend(JMSModelBackend):
|
|||||||
)
|
)
|
||||||
return user, created
|
return user, created
|
||||||
|
|
||||||
def authenticate(self, request, saml_user_data=None, **kwargs):
|
def authenticate(self, request, saml_user_data=None):
|
||||||
log_prompt = "Process authenticate [SAML2Backend]: {}"
|
log_prompt = "Process authenticate [SAML2Backend]: {}"
|
||||||
logger.debug(log_prompt.format('Start'))
|
logger.debug(log_prompt.format('Start'))
|
||||||
if saml_user_data is None:
|
if saml_user_data is None:
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ from django.urls import path
|
|||||||
|
|
||||||
from . import views
|
from . import views
|
||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('login/', views.Saml2AuthRequestView.as_view(), name='saml2-login'),
|
path('login/', views.Saml2AuthRequestView.as_view(), name='saml2-login'),
|
||||||
path('logout/', views.Saml2EndSessionView.as_view(), name='saml2-logout'),
|
path('logout/', views.Saml2EndSessionView.as_view(), name='saml2-logout'),
|
||||||
path('callback/', views.Saml2AuthCallbackView.as_view(), name='saml2-callback'),
|
path('callback/', views.Saml2AuthCallbackView.as_view(), name='saml2-callback'),
|
||||||
|
path('callback/client/', views.Saml2AuthCallbackClientView.as_view(), name='saml2-callback-client'),
|
||||||
path('metadata/', views.Saml2AuthMetadataView.as_view(), name='saml2-metadata'),
|
path('metadata/', views.Saml2AuthMetadataView.as_view(), name='saml2-metadata'),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from onelogin.saml2.idp_metadata_parser import (
|
|||||||
from authentication.views.mixins import FlashMessageMixin
|
from authentication.views.mixins import FlashMessageMixin
|
||||||
from common.utils import get_logger
|
from common.utils import get_logger
|
||||||
from .settings import JmsSaml2Settings
|
from .settings import JmsSaml2Settings
|
||||||
|
from ...views.utils import redirect_to_guard_view
|
||||||
|
|
||||||
logger = get_logger(__file__)
|
logger = get_logger(__file__)
|
||||||
|
|
||||||
@@ -298,6 +299,13 @@ class Saml2AuthCallbackView(View, PrepareRequestMixin, FlashMessageMixin):
|
|||||||
return super().dispatch(*args, **kwargs)
|
return super().dispatch(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class Saml2AuthCallbackClientView(View):
|
||||||
|
http_method_names = ['get', ]
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
return redirect_to_guard_view(query_string='next=client')
|
||||||
|
|
||||||
|
|
||||||
class Saml2AuthMetadataView(View, PrepareRequestMixin):
|
class Saml2AuthMetadataView(View, PrepareRequestMixin):
|
||||||
|
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
|
|||||||
@@ -1,57 +1,41 @@
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
from .base import JMSModelBackend
|
from .base import JMSBaseAuthBackend
|
||||||
|
|
||||||
|
|
||||||
class SSOAuthentication(JMSModelBackend):
|
class SSOAuthentication(JMSBaseAuthBackend):
|
||||||
"""
|
|
||||||
什么也不做呀😺
|
|
||||||
"""
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def is_enabled():
|
def is_enabled():
|
||||||
return settings.AUTH_SSO
|
return settings.AUTH_SSO
|
||||||
|
|
||||||
def authenticate(self, request, sso_token=None, **kwargs):
|
def authenticate(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class WeComAuthentication(JMSModelBackend):
|
class WeComAuthentication(JMSBaseAuthBackend):
|
||||||
"""
|
|
||||||
什么也不做呀😺
|
|
||||||
"""
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def is_enabled():
|
def is_enabled():
|
||||||
return settings.AUTH_WECOM
|
return settings.AUTH_WECOM
|
||||||
|
|
||||||
def authenticate(self, request, **kwargs):
|
def authenticate(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class DingTalkAuthentication(JMSModelBackend):
|
class DingTalkAuthentication(JMSBaseAuthBackend):
|
||||||
"""
|
|
||||||
什么也不做呀😺
|
|
||||||
"""
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def is_enabled():
|
def is_enabled():
|
||||||
return settings.AUTH_DINGTALK
|
return settings.AUTH_DINGTALK
|
||||||
|
|
||||||
def authenticate(self, request, **kwargs):
|
def authenticate(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class FeiShuAuthentication(JMSModelBackend):
|
class FeiShuAuthentication(JMSBaseAuthBackend):
|
||||||
"""
|
|
||||||
什么也不做呀😺
|
|
||||||
"""
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def is_enabled():
|
def is_enabled():
|
||||||
return settings.AUTH_FEISHU
|
return settings.AUTH_FEISHU
|
||||||
|
|
||||||
def authenticate(self, request, **kwargs):
|
def authenticate(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@@ -61,23 +45,15 @@ class LarkAuthentication(FeiShuAuthentication):
|
|||||||
return settings.AUTH_LARK
|
return settings.AUTH_LARK
|
||||||
|
|
||||||
|
|
||||||
class SlackAuthentication(JMSModelBackend):
|
class SlackAuthentication(JMSBaseAuthBackend):
|
||||||
"""
|
|
||||||
什么也不做呀😺
|
|
||||||
"""
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def is_enabled():
|
def is_enabled():
|
||||||
return settings.AUTH_SLACK
|
return settings.AUTH_SLACK
|
||||||
|
|
||||||
def authenticate(self, request, **kwargs):
|
def authenticate(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class AuthorizationTokenAuthentication(JMSModelBackend):
|
class AuthorizationTokenAuthentication(JMSBaseAuthBackend):
|
||||||
"""
|
def authenticate(self):
|
||||||
什么也不做呀😺
|
|
||||||
"""
|
|
||||||
|
|
||||||
def authenticate(self, request, **kwargs):
|
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -3,13 +3,17 @@ from django.conf import settings
|
|||||||
from django.core.exceptions import PermissionDenied
|
from django.core.exceptions import PermissionDenied
|
||||||
|
|
||||||
from authentication.models import TempToken
|
from authentication.models import TempToken
|
||||||
from .base import JMSModelBackend
|
from .base import JMSBaseAuthBackend
|
||||||
|
|
||||||
|
|
||||||
class TempTokenAuthBackend(JMSModelBackend):
|
class TempTokenAuthBackend(JMSBaseAuthBackend):
|
||||||
model = TempToken
|
model = TempToken
|
||||||
|
|
||||||
def authenticate(self, request, username='', password='', *args, **kwargs):
|
@staticmethod
|
||||||
|
def is_enabled():
|
||||||
|
return settings.AUTH_TEMP_TOKEN
|
||||||
|
|
||||||
|
def authenticate(self, request, username='', password=''):
|
||||||
token = self.model.objects.filter(username=username, secret=password).first()
|
token = self.model.objects.filter(username=username, secret=password).first()
|
||||||
if not token:
|
if not token:
|
||||||
return None
|
return None
|
||||||
@@ -21,6 +25,3 @@ class TempTokenAuthBackend(JMSModelBackend):
|
|||||||
token.save()
|
token.save()
|
||||||
return token.user
|
return token.user
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def is_enabled():
|
|
||||||
return settings.AUTH_TEMP_TOKEN
|
|
||||||
|
|||||||
@@ -22,5 +22,6 @@ class ConfirmMFA(BaseConfirm):
|
|||||||
|
|
||||||
def authenticate(self, secret_key, mfa_type):
|
def authenticate(self, secret_key, mfa_type):
|
||||||
mfa_backend = self.user.get_mfa_backend_by_type(mfa_type)
|
mfa_backend = self.user.get_mfa_backend_by_type(mfa_type)
|
||||||
|
mfa_backend.set_request(self.request)
|
||||||
ok, msg = mfa_backend.check_code(secret_key)
|
ok, msg = mfa_backend.check_code(secret_key)
|
||||||
return ok, msg
|
return ok, msg
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from django.db.models import TextChoices
|
|||||||
|
|
||||||
from authentication.confirm import CONFIRM_BACKENDS
|
from authentication.confirm import CONFIRM_BACKENDS
|
||||||
from .confirm import ConfirmMFA, ConfirmPassword, ConfirmReLogin
|
from .confirm import ConfirmMFA, ConfirmPassword, ConfirmReLogin
|
||||||
from .mfa import MFAOtp, MFASms, MFARadius, MFACustom
|
from .mfa import MFAOtp, MFASms, MFARadius, MFAFace, MFACustom
|
||||||
|
|
||||||
RSA_PRIVATE_KEY = 'rsa_private_key'
|
RSA_PRIVATE_KEY = 'rsa_private_key'
|
||||||
RSA_PUBLIC_KEY = 'rsa_public_key'
|
RSA_PUBLIC_KEY = 'rsa_public_key'
|
||||||
@@ -35,5 +35,17 @@ class ConfirmType(TextChoices):
|
|||||||
class MFAType(TextChoices):
|
class MFAType(TextChoices):
|
||||||
OTP = MFAOtp.name, MFAOtp.display_name
|
OTP = MFAOtp.name, MFAOtp.display_name
|
||||||
SMS = MFASms.name, MFASms.display_name
|
SMS = MFASms.name, MFASms.display_name
|
||||||
|
Face = MFAFace.name, MFAFace.display_name
|
||||||
Radius = MFARadius.name, MFARadius.display_name
|
Radius = MFARadius.name, MFARadius.display_name
|
||||||
Custom = MFACustom.name, MFACustom.display_name
|
Custom = MFACustom.name, MFACustom.display_name
|
||||||
|
|
||||||
|
|
||||||
|
FACE_CONTEXT_CACHE_KEY_PREFIX = "FACE_CONTEXT"
|
||||||
|
FACE_CONTEXT_CACHE_TTL = 60
|
||||||
|
FACE_SESSION_KEY = "face_token"
|
||||||
|
|
||||||
|
|
||||||
|
class FaceMonitorActionChoices(TextChoices):
|
||||||
|
Verify = 'verify', 'verify'
|
||||||
|
Pause = 'pause', 'pause'
|
||||||
|
Resume = 'resume', 'resume'
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ from .otp import MFAOtp, otp_failed_msg
|
|||||||
from .sms import MFASms
|
from .sms import MFASms
|
||||||
from .radius import MFARadius
|
from .radius import MFARadius
|
||||||
from .custom import MFACustom
|
from .custom import MFACustom
|
||||||
|
from .face import MFAFace
|
||||||
@@ -12,10 +12,14 @@ class BaseMFA(abc.ABC):
|
|||||||
因为首页登录时,可能没法获取到一些状态
|
因为首页登录时,可能没法获取到一些状态
|
||||||
"""
|
"""
|
||||||
self.user = user
|
self.user = user
|
||||||
|
self.request = None
|
||||||
|
|
||||||
def is_authenticated(self):
|
def is_authenticated(self):
|
||||||
return self.user and self.user.is_authenticated
|
return self.user and self.user.is_authenticated
|
||||||
|
|
||||||
|
def set_request(self, request):
|
||||||
|
self.request = request
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
def name(self):
|
def name(self):
|
||||||
|
|||||||
59
apps/authentication/mfa/face.py
Normal file
59
apps/authentication/mfa/face.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
from authentication.mfa.base import BaseMFA
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from authentication.mixins import AuthFaceMixin
|
||||||
|
from common.const import LicenseEditionChoices
|
||||||
|
from settings.api import settings
|
||||||
|
|
||||||
|
|
||||||
|
class MFAFace(BaseMFA, AuthFaceMixin):
|
||||||
|
name = "face"
|
||||||
|
display_name = _('Face Recognition')
|
||||||
|
placeholder = 'Face Recognition'
|
||||||
|
|
||||||
|
def check_code(self, code):
|
||||||
|
|
||||||
|
assert self.is_authenticated()
|
||||||
|
|
||||||
|
try:
|
||||||
|
code = self.get_face_code()
|
||||||
|
if not self.user.check_face(code):
|
||||||
|
return False, _('Facial comparison failed')
|
||||||
|
except Exception as e:
|
||||||
|
return False, "{}:{}".format(_('Facial comparison failed'), str(e))
|
||||||
|
return True, ''
|
||||||
|
|
||||||
|
def is_active(self):
|
||||||
|
if not self.is_authenticated():
|
||||||
|
return True
|
||||||
|
return bool(self.user.face_vector)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def global_enabled():
|
||||||
|
return (
|
||||||
|
settings.XPACK_LICENSE_IS_VALID and
|
||||||
|
settings.XPACK_LICENSE_EDITION_ULTIMATE and
|
||||||
|
settings.FACE_RECOGNITION_ENABLED
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_enable_url(self) -> str:
|
||||||
|
return '/ui/#/profile/index'
|
||||||
|
|
||||||
|
def get_disable_url(self) -> str:
|
||||||
|
return '/ui/#/profile/index'
|
||||||
|
|
||||||
|
def disable(self):
|
||||||
|
assert self.is_authenticated()
|
||||||
|
self.user.face_vector = ''
|
||||||
|
self.user.save(update_fields=['face_vector'])
|
||||||
|
|
||||||
|
def can_disable(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def help_text_of_enable():
|
||||||
|
return _("Bind face to enable")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def help_text_of_disable():
|
||||||
|
return _("Unbind face to disable")
|
||||||
@@ -12,7 +12,7 @@ class MFARadius(BaseMFA):
|
|||||||
display_name = 'Radius'
|
display_name = 'Radius'
|
||||||
placeholder = _("Radius verification code")
|
placeholder = _("Radius verification code")
|
||||||
|
|
||||||
def check_code(self, code):
|
def check_code(self, code=None):
|
||||||
assert self.is_authenticated()
|
assert self.is_authenticated()
|
||||||
backend = RadiusBackend()
|
backend = RadiusBackend()
|
||||||
username = self.user.username
|
username = self.user.username
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from django.conf import settings
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from common.utils.verify_code import SendAndVerifyCodeUtil
|
from common.utils.verify_code import SendAndVerifyCodeUtil
|
||||||
|
from users.serializers import SmsUserSerializer
|
||||||
from .base import BaseMFA
|
from .base import BaseMFA
|
||||||
|
|
||||||
sms_failed_msg = _("SMS verify code invalid")
|
sms_failed_msg = _("SMS verify code invalid")
|
||||||
@@ -14,8 +15,13 @@ class MFASms(BaseMFA):
|
|||||||
|
|
||||||
def __init__(self, user):
|
def __init__(self, user):
|
||||||
super().__init__(user)
|
super().__init__(user)
|
||||||
phone = user.phone if self.is_authenticated() else ''
|
phone, user_info = '', None
|
||||||
self.sms = SendAndVerifyCodeUtil(phone, backend=self.name)
|
if self.is_authenticated():
|
||||||
|
phone = user.phone
|
||||||
|
user_info = SmsUserSerializer(user).data
|
||||||
|
self.sms = SendAndVerifyCodeUtil(
|
||||||
|
phone, backend=self.name, user_info=user_info
|
||||||
|
)
|
||||||
|
|
||||||
def check_code(self, code):
|
def check_code(self, code):
|
||||||
assert self.is_authenticated()
|
assert self.is_authenticated()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import base64
|
|||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth import logout as auth_logout
|
from django.contrib.auth import logout as auth_logout
|
||||||
|
from django.core.cache import cache
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from django.shortcuts import redirect, reverse, render
|
from django.shortcuts import redirect, reverse, render
|
||||||
from django.utils.deprecation import MiddlewareMixin
|
from django.utils.deprecation import MiddlewareMixin
|
||||||
@@ -34,7 +35,7 @@ class MFAMiddleware:
|
|||||||
|
|
||||||
# 这个是 mfa 登录页需要的请求, 也得放出来, 用户其实已经在 CAS/OIDC 中完成登录了
|
# 这个是 mfa 登录页需要的请求, 也得放出来, 用户其实已经在 CAS/OIDC 中完成登录了
|
||||||
white_urls = [
|
white_urls = [
|
||||||
'login/mfa', 'mfa/select', 'jsi18n/', '/static/',
|
'login/mfa', 'mfa/select', 'face/context','jsi18n/', '/static/',
|
||||||
'/profile/otp', '/logout/',
|
'/profile/otp', '/logout/',
|
||||||
]
|
]
|
||||||
for url in white_urls:
|
for url in white_urls:
|
||||||
@@ -116,23 +117,43 @@ class ThirdPartyLoginMiddleware(mixins.AuthMixin):
|
|||||||
|
|
||||||
|
|
||||||
class SessionCookieMiddleware(MiddlewareMixin):
|
class SessionCookieMiddleware(MiddlewareMixin):
|
||||||
|
USER_LOGIN_ENCRYPTION_KEY_PAIR = 'user_login_encryption_key_pair'
|
||||||
|
|
||||||
@staticmethod
|
def set_cookie_public_key(self, request, response):
|
||||||
def set_cookie_public_key(request, response):
|
|
||||||
if request.path.startswith('/api'):
|
if request.path.startswith('/api'):
|
||||||
return
|
return
|
||||||
pub_key_name = settings.SESSION_RSA_PUBLIC_KEY_NAME
|
|
||||||
public_key = request.session.get(pub_key_name)
|
session_public_key_name = settings.SESSION_RSA_PUBLIC_KEY_NAME
|
||||||
cookie_key = request.COOKIES.get(pub_key_name)
|
session_private_key_name = settings.SESSION_RSA_PRIVATE_KEY_NAME
|
||||||
if public_key and public_key == cookie_key:
|
|
||||||
|
session_public_key = request.session.get(session_public_key_name)
|
||||||
|
cookie_public_key = request.COOKIES.get(session_public_key_name)
|
||||||
|
|
||||||
|
if session_public_key and session_public_key == cookie_public_key:
|
||||||
return
|
return
|
||||||
|
|
||||||
pri_key_name = settings.SESSION_RSA_PRIVATE_KEY_NAME
|
private_key, public_key = self.get_key_pair()
|
||||||
private_key, public_key = gen_key_pair()
|
|
||||||
public_key_decode = base64.b64encode(public_key.encode()).decode()
|
public_key_decode = base64.b64encode(public_key.encode()).decode()
|
||||||
request.session[pub_key_name] = public_key_decode
|
|
||||||
request.session[pri_key_name] = private_key
|
request.session[session_public_key_name] = public_key_decode
|
||||||
response.set_cookie(pub_key_name, public_key_decode)
|
request.session[session_private_key_name] = private_key
|
||||||
|
response.set_cookie(session_public_key_name, public_key_decode)
|
||||||
|
|
||||||
|
def get_key_pair(self):
|
||||||
|
key_pair = cache.get(self.USER_LOGIN_ENCRYPTION_KEY_PAIR)
|
||||||
|
if key_pair:
|
||||||
|
return key_pair['private_key'], key_pair['public_key']
|
||||||
|
|
||||||
|
private_key, public_key = gen_key_pair()
|
||||||
|
|
||||||
|
key_pair = {
|
||||||
|
'private_key': private_key,
|
||||||
|
'public_key': public_key
|
||||||
|
}
|
||||||
|
cache.set(self.USER_LOGIN_ENCRYPTION_KEY_PAIR, key_pair, None)
|
||||||
|
|
||||||
|
return private_key, public_key
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def set_cookie_session_prefix(request, response):
|
def set_cookie_session_prefix(request, response):
|
||||||
|
|||||||
18
apps/authentication/migrations/0004_alter_passkey_token.py
Normal file
18
apps/authentication/migrations/0004_alter_passkey_token.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.1.13 on 2024-12-12 06:25
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('authentication', '0003_sshkey'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='passkey',
|
||||||
|
name='token',
|
||||||
|
field=models.CharField(max_length=1024, verbose_name='Token'),
|
||||||
|
),
|
||||||
|
]
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user