mirror of
https://github.com/jumpserver/jumpserver.git
synced 2025-12-15 08:32:48 +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/
|
||||
.test/
|
||||
*.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
|
||||
|
||||
@@ -28,6 +28,7 @@ ARG DEPENDENCIES=" \
|
||||
libx11-dev"
|
||||
|
||||
ARG TOOLS=" \
|
||||
cron \
|
||||
ca-certificates \
|
||||
default-libmysqlclient-dev \
|
||||
openssh-client \
|
||||
@@ -35,19 +36,20 @@ ARG TOOLS=" \
|
||||
bubblewrap"
|
||||
|
||||
ARG APT_MIRROR=http://deb.debian.org
|
||||
|
||||
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 \
|
||||
&& ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
|
||||
&& apt-get update > /dev/null \
|
||||
&& apt-get -y install --no-install-recommends ${DEPENDENCIES} \
|
||||
&& apt-get -y install --no-install-recommends ${TOOLS} \
|
||||
&& apt-get clean \
|
||||
&& 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 "no" | dpkg-reconfigure dash \
|
||||
&& sed -i "s@# export @export @g" ~/.bashrc \
|
||||
&& sed -i "s@# alias @alias @g" ~/.bashrc
|
||||
&& apt-get clean all \
|
||||
&& 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 /usr/local/bin /usr/local/bin
|
||||
|
||||
@@ -15,8 +15,8 @@ ARG DEPENDENCIES=" \
|
||||
libldap2-dev \
|
||||
libsasl2-dev"
|
||||
|
||||
|
||||
ARG APT_MIRROR=http://deb.debian.org
|
||||
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=core \
|
||||
--mount=type=cache,target=/var/lib/apt,sharing=locked,id=core \
|
||||
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} \
|
||||
&& echo "no" | dpkg-reconfigure dash
|
||||
|
||||
|
||||
# Install bin tools
|
||||
ARG CHECK_VERSION=v1.0.3
|
||||
ARG CHECK_VERSION=v1.0.4
|
||||
RUN set -ex \
|
||||
&& 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 \
|
||||
@@ -38,23 +37,24 @@ RUN set -ex \
|
||||
&& chmod 755 /usr/local/bin/check \
|
||||
&& rm -f check-${CHECK_VERSION}-linux-${TARGETARCH}.tar.gz
|
||||
|
||||
|
||||
# Install Python dependencies
|
||||
WORKDIR /opt/jumpserver
|
||||
|
||||
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
|
||||
|
||||
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=pyproject.toml,target=pyproject.toml \
|
||||
--mount=type=bind,source=utils/clean_site_packages.sh,target=clean_site_packages.sh \
|
||||
--mount=type=bind,source=requirements/collections.yml,target=collections.yml \
|
||||
set -ex \
|
||||
&& python3 -m venv /opt/py3 \
|
||||
&& pip install poetry -i ${PIP_MIRROR} \
|
||||
&& poetry config virtualenvs.create false \
|
||||
&& pip install poetry poetry-plugin-pypi-mirror -i ${PIP_MIRROR} \
|
||||
&& . /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 \
|
||||
&& bash clean_site_packages.sh
|
||||
&& bash clean_site_packages.sh \
|
||||
&& poetry cache clear pypi --all
|
||||
|
||||
@@ -15,21 +15,20 @@ ARG TOOLS=" \
|
||||
vim \
|
||||
wget"
|
||||
|
||||
ARG APT_MIRROR=http://deb.debian.org
|
||||
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 -y install --no-install-recommends ${TOOLS} \
|
||||
&& echo "no" | dpkg-reconfigure dash
|
||||
&& apt-get clean all \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /opt/jumpserver
|
||||
|
||||
ARG PIP_MIRROR=https://pypi.org/simple
|
||||
ENV POETRY_PYPI_MIRROR_URL=${PIP_MIRROR}
|
||||
COPY poetry.lock pyproject.toml ./
|
||||
RUN set -ex \
|
||||
&& . /opt/py3/bin/activate \
|
||||
&& pip install poetry -i ${PIP_MIRROR} \
|
||||
&& poetry install --only xpack
|
||||
&& pip install poetry poetry-plugin-pypi-mirror -i ${PIP_MIRROR} \
|
||||
&& 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-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>
|
||||
<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 |
|
||||
| [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 |
|
||||
| [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) |
|
||||
| [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 |
|
||||
| [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) |
|
||||
| [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 |
|
||||
| [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
|
||||
|
||||
@@ -85,7 +89,7 @@ JumpServer is a mission critical product. Please refer to the Basic Security Rec
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ JumpServer是一款安全产品,请参考 [基本安全建议](https://docs.ju
|
||||
|
||||
## 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
|
||||
compliance with the License. You may obtain a copy of the License at
|
||||
|
||||
@@ -30,6 +30,6 @@
|
||||
login_user: "{{ account.username }}"
|
||||
login_password: "{{ account.secret }}"
|
||||
login_secret_type: "{{ account.secret_type }}"
|
||||
gateway_args: "{{ jms_gateway | default(None) }}"
|
||||
gateway_args: "{{ jms_gateway | default({}) }}"
|
||||
when: account.secret_type == "password"
|
||||
delegate_to: localhost
|
||||
|
||||
@@ -160,6 +160,10 @@ class ChangeSecretManager(AccountBasePlaybookManager):
|
||||
ChangeSecretRecord.objects.bulk_create(records)
|
||||
return inventory_hosts
|
||||
|
||||
@staticmethod
|
||||
def require_update_version(account, recorder):
|
||||
return account.secret != recorder.new_secret
|
||||
|
||||
def on_host_success(self, host, result):
|
||||
recorder = self.name_recorder_mapper.get(host)
|
||||
if not recorder:
|
||||
@@ -171,6 +175,8 @@ class ChangeSecretManager(AccountBasePlaybookManager):
|
||||
if not account:
|
||||
print("Account not found, deleted ?")
|
||||
return
|
||||
|
||||
version_update_required = self.require_update_version(account, recorder)
|
||||
account.secret = recorder.new_secret
|
||||
account.date_updated = timezone.now()
|
||||
|
||||
@@ -180,7 +186,10 @@ class ChangeSecretManager(AccountBasePlaybookManager):
|
||||
while retry_count < max_retries:
|
||||
try:
|
||||
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
|
||||
except Exception as e:
|
||||
retry_count += 1
|
||||
|
||||
@@ -30,6 +30,6 @@
|
||||
login_user: "{{ account.username }}"
|
||||
login_password: "{{ account.secret }}"
|
||||
login_secret_type: "{{ account.secret_type }}"
|
||||
gateway_args: "{{ jms_gateway | default(None) }}"
|
||||
gateway_args: "{{ jms_gateway | default({}) }}"
|
||||
when: account.secret_type == "password"
|
||||
delegate_to: localhost
|
||||
|
||||
@@ -8,6 +8,11 @@ logger = get_logger(__name__)
|
||||
|
||||
class PushAccountManager(ChangeSecretManager, AccountBasePlaybookManager):
|
||||
|
||||
@staticmethod
|
||||
def require_update_version(account, recorder):
|
||||
account.skip_history_when_saving = True
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def method_type(cls):
|
||||
return AutomationTypes.push_account
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from accounts.const import AutomationTypes
|
||||
from assets.automations.ping_gateway.manager import PingGatewayManager
|
||||
from common.utils import get_logger
|
||||
@@ -13,7 +15,7 @@ class VerifyGatewayAccountManager(PingGatewayManager):
|
||||
|
||||
@staticmethod
|
||||
def before_runner_start():
|
||||
logger.info(">>> 开始执行测试网关账号可连接性任务")
|
||||
logger.info(_(">>> Start executing the task to test gateway account connectivity"))
|
||||
|
||||
def get_accounts(self, gateway):
|
||||
account_ids = self.execution.snapshot['accounts']
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
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 ..const import VaultTypeChoices
|
||||
|
||||
__all__ = ['vault_client', 'get_vault_client']
|
||||
|
||||
__all__ = ['vault_client', 'get_vault_client', 'refresh_vault_client']
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
||||
|
||||
def get_vault_client(raise_exception=False, **kwargs):
|
||||
enabled = kwargs.get('VAULT_ENABLED')
|
||||
tp = 'hcp' if enabled else 'local'
|
||||
tp = kwargs.get('VAULT_BACKEND') if kwargs.get('VAULT_ENABLED') else VaultTypeChoices.local
|
||||
|
||||
try:
|
||||
module_path = f'apps.accounts.backends.{tp}.main'
|
||||
client = import_module(module_path).Vault(**kwargs)
|
||||
@@ -39,3 +38,7 @@ class VaultClient(LazyObject):
|
||||
|
||||
""" 为了安全, 页面修改配置, 重启服务后才会重新初始化 vault_client """
|
||||
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 common.db.utils import Encryptor
|
||||
from common.utils import lazyproperty
|
||||
|
||||
current_module = sys.modules[__name__]
|
||||
|
||||
__all__ = ['build_entry']
|
||||
|
||||
|
||||
class BaseEntry(ABC):
|
||||
|
||||
def __init__(self, instance):
|
||||
self.instance = instance
|
||||
|
||||
@property
|
||||
def path_base(self):
|
||||
path = f'orgs/{self.instance.org_id}'
|
||||
return path
|
||||
|
||||
@lazyproperty
|
||||
def full_path(self):
|
||||
path_base = self.path_base
|
||||
@@ -21,32 +20,24 @@ class BaseEntry(ABC):
|
||||
path = f'{path_base}/{path_spec}'
|
||||
return path
|
||||
|
||||
@property
|
||||
def path_base(self):
|
||||
path = f'orgs/{self.instance.org_id}'
|
||||
return path
|
||||
|
||||
@property
|
||||
def path_spec(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def to_internal_data(self):
|
||||
def get_encrypt_secret(self):
|
||||
secret = getattr(self.instance, '_secret', None)
|
||||
if secret is not None:
|
||||
secret = Encryptor(secret).encrypt()
|
||||
data = {'secret': secret}
|
||||
return data
|
||||
return secret
|
||||
|
||||
@staticmethod
|
||||
def to_external_data(data):
|
||||
secret = data.pop('secret', None)
|
||||
def get_decrypt_secret(secret):
|
||||
if secret is not None:
|
||||
secret = Encryptor(secret).decrypt()
|
||||
return secret
|
||||
|
||||
|
||||
class AccountEntry(BaseEntry):
|
||||
|
||||
@property
|
||||
def path_spec(self):
|
||||
path = f'assets/{self.instance.asset_id}/accounts/{self.instance.id}'
|
||||
@@ -54,7 +45,6 @@ class AccountEntry(BaseEntry):
|
||||
|
||||
|
||||
class AccountTemplateEntry(BaseEntry):
|
||||
|
||||
@property
|
||||
def path_spec(self):
|
||||
path = f'account-templates/{self.instance.id}'
|
||||
@@ -62,23 +52,12 @@ class AccountTemplateEntry(BaseEntry):
|
||||
|
||||
|
||||
class HistoricalAccountEntry(BaseEntry):
|
||||
|
||||
@property
|
||||
def path_base(self):
|
||||
account = self.instance.instance
|
||||
path = f'accounts/{account.id}/'
|
||||
path = f'accounts/{self.instance.instance.id}'
|
||||
return path
|
||||
|
||||
@property
|
||||
def path_spec(self):
|
||||
path = f'histories/{self.instance.history_id}'
|
||||
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 .entries import build_entry
|
||||
from .service import VaultKVClient
|
||||
from ..base import BaseVault
|
||||
from ..base.vault import BaseVault
|
||||
|
||||
from ...const import VaultTypeChoices
|
||||
|
||||
__all__ = ['Vault']
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
__all__ = ['Vault']
|
||||
|
||||
|
||||
class Vault(BaseVault):
|
||||
type = VaultTypeChoices.hcp
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.client = VaultKVClient(
|
||||
@@ -20,34 +24,25 @@ class Vault(BaseVault):
|
||||
def is_active(self):
|
||||
return self.client.is_active()
|
||||
|
||||
def _get(self, instance):
|
||||
entry = build_entry(instance)
|
||||
def _get(self, entry):
|
||||
# TODO: 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
|
||||
|
||||
def _create(self, instance):
|
||||
entry = build_entry(instance)
|
||||
data = entry.to_internal_data()
|
||||
def _create(self, entry):
|
||||
data = {'secret': entry.get_encrypt_secret()}
|
||||
self.client.create(path=entry.full_path, data=data)
|
||||
|
||||
def _update(self, instance):
|
||||
entry = build_entry(instance)
|
||||
data = entry.to_internal_data()
|
||||
def _update(self, entry):
|
||||
data = {'secret': entry.get_encrypt_secret()}
|
||||
self.client.patch(path=entry.full_path, data=data)
|
||||
|
||||
def _delete(self, instance):
|
||||
entry = build_entry(instance)
|
||||
def _delete(self, entry):
|
||||
self.client.delete(path=entry.full_path)
|
||||
|
||||
def _clean_db_secret(self, instance):
|
||||
instance.is_sync_metadata = False
|
||||
instance.mark_secret_save_to_vault()
|
||||
|
||||
def _save_metadata(self, instance, metadata):
|
||||
def _save_metadata(self, entry, metadata):
|
||||
try:
|
||||
entry = build_entry(instance)
|
||||
self.client.update_metadata(path=entry.full_path, metadata=metadata)
|
||||
except Exception as e:
|
||||
logger.error(f'save metadata error: {e}')
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from common.utils import get_logger
|
||||
from ..base import BaseVault
|
||||
from ..base.vault import BaseVault
|
||||
from ...const import VaultTypeChoices
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -7,27 +8,28 @@ __all__ = ['Vault']
|
||||
|
||||
|
||||
class Vault(BaseVault):
|
||||
type = VaultTypeChoices.local
|
||||
|
||||
def is_active(self):
|
||||
return True, ''
|
||||
|
||||
def _get(self, instance):
|
||||
secret = getattr(instance, '_secret', None)
|
||||
def _get(self, entry):
|
||||
secret = getattr(entry.instance, '_secret', None)
|
||||
return secret
|
||||
|
||||
def _create(self, instance):
|
||||
def _create(self, entry):
|
||||
""" Ignore """
|
||||
pass
|
||||
|
||||
def _update(self, instance):
|
||||
def _update(self, entry):
|
||||
""" Ignore """
|
||||
pass
|
||||
|
||||
def _delete(self, instance):
|
||||
def _delete(self, entry):
|
||||
""" Ignore """
|
||||
pass
|
||||
|
||||
def _save_metadata(self, instance, metadata):
|
||||
def _save_metadata(self, entry, metadata):
|
||||
""" Ignore """
|
||||
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):
|
||||
add = 'add', _('Append SSH KEY')
|
||||
set = 'set', _('Empty and append SSH KEY')
|
||||
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):
|
||||
|
||||
@@ -7,3 +7,5 @@ __all__ = ['VaultTypeChoices']
|
||||
class VaultTypeChoices(models.TextChoices):
|
||||
local = 'local', _('Database')
|
||||
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_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')),
|
||||
('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={
|
||||
'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_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')),
|
||||
('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')),
|
||||
('username', models.CharField(max_length=128, verbose_name='Username')),
|
||||
('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")
|
||||
)
|
||||
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_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.utils import get_logger, lazyproperty
|
||||
from ops.mixin import PeriodTaskModelMixin
|
||||
from orgs.mixins.models import OrgModelMixin, JMSOrgBaseModel
|
||||
from orgs.mixins.models import OrgModelMixin, JMSOrgBaseModel, OrgManager
|
||||
|
||||
__all__ = ['AccountBackupAutomation', 'AccountBackupExecution']
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
||||
|
||||
class BaseBackupAutomationManager(OrgManager):
|
||||
pass
|
||||
|
||||
|
||||
class AccountBackupAutomation(PeriodTaskModelMixin, JMSOrgBaseModel):
|
||||
types = models.JSONField(default=list)
|
||||
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')
|
||||
)
|
||||
|
||||
objects = BaseBackupAutomationManager.from_queryset(models.QuerySet)()
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.name}({self.org_id})'
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ class AutomationExecution(AssetAutomationExecution):
|
||||
class ChangeSecretMixin(SecretWithRandomMixin):
|
||||
ssh_key_change_strategy = models.CharField(
|
||||
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
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ from django.conf import settings
|
||||
from django.db import models
|
||||
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 .base import AccountBaseAutomation
|
||||
from .change_secret import ChangeSecretMixin
|
||||
@@ -23,7 +23,8 @@ class PushAccountAutomation(ChangeSecretMixin, AccountBaseAutomation):
|
||||
create_usernames = set(usernames) - set(account_usernames)
|
||||
create_account_objs = [
|
||||
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,
|
||||
)
|
||||
for username in create_usernames
|
||||
|
||||
@@ -80,6 +80,7 @@ class VaultModelMixin(models.Model):
|
||||
|
||||
def mark_secret_save_to_vault(self):
|
||||
self._secret = self._secret_save_to_vault_mark
|
||||
self.skip_history_when_saving = True
|
||||
self.save()
|
||||
|
||||
@property
|
||||
|
||||
@@ -385,7 +385,7 @@ class AssetAccountBulkSerializer(
|
||||
|
||||
_results = {}
|
||||
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] = {
|
||||
'error': _('Asset does not support this secret type: %s') % secret_type,
|
||||
'state': 'error',
|
||||
|
||||
@@ -10,7 +10,7 @@ from .base import BaseAccountSerializer
|
||||
|
||||
|
||||
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'))
|
||||
uppercase = serializers.BooleanField(default=True, label=_('Uppercase'))
|
||||
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
|
||||
def model_type(self):
|
||||
return AutomationTypes.change_secret
|
||||
@@ -75,19 +95,6 @@ class ChangeSecretAutomationSerializer(AuthValidateMixin, BaseAutomationSerializ
|
||||
if self.initial_data.get('secret_strategy') == SecretStrategy.custom:
|
||||
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
|
||||
|
||||
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 pre_save, post_save
|
||||
from django.dispatch import receiver
|
||||
from django.utils.functional import LazyObject
|
||||
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 audits.const import ActivityChoices
|
||||
from audits.signal_handlers import create_activities
|
||||
from common.decorators import merge_delay_run
|
||||
from common.signals import django_ready
|
||||
from common.utils import get_logger, i18n_fmt
|
||||
from common.utils.connection import RedisPubSub
|
||||
from .exceptions import VaultException
|
||||
from .models import Account, AccountTemplate
|
||||
from .tasks.push_account import push_accounts_to_assets_task
|
||||
|
||||
@@ -19,6 +23,9 @@ logger = get_logger(__name__)
|
||||
|
||||
@receiver(pre_save, sender=Account)
|
||||
def on_account_pre_save(sender, instance, **kwargs):
|
||||
if getattr(instance, 'skip_history_when_saving', False):
|
||||
return
|
||||
|
||||
if instance.version == 0:
|
||||
instance.version = 1
|
||||
else:
|
||||
@@ -62,7 +69,7 @@ def create_accounts_activities(account, action='create'):
|
||||
|
||||
@receiver(post_save, sender=Account)
|
||||
def on_account_create_by_template(sender, instance, created=False, **kwargs):
|
||||
if not created or instance.source != Source.TEMPLATE:
|
||||
if not created:
|
||||
return
|
||||
push_accounts_if_need.delay(accounts=(instance,))
|
||||
create_accounts_activities(instance, action='create')
|
||||
@@ -78,16 +85,39 @@ class VaultSignalHandler(object):
|
||||
|
||||
@staticmethod
|
||||
def save_to_vault(sender, instance, created, **kwargs):
|
||||
if created:
|
||||
vault_client.create(instance)
|
||||
else:
|
||||
vault_client.update(instance)
|
||||
try:
|
||||
if created:
|
||||
vault_client.create(instance)
|
||||
else:
|
||||
vault_client.update(instance)
|
||||
except Exception as e:
|
||||
logger.error('Vault save failed: {}'.format(e))
|
||||
raise VaultException()
|
||||
|
||||
@staticmethod
|
||||
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):
|
||||
post_save.connect(VaultSignalHandler.save_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 accounts.backends import vault_client
|
||||
from accounts.const import VaultTypeChoices
|
||||
from accounts.models import Account, AccountTemplate
|
||||
from common.utils import get_logger
|
||||
from orgs.utils import tmp_to_root_org
|
||||
@@ -39,6 +40,9 @@ def sync_secret_to_vault():
|
||||
# 这里不能判断 settings.VAULT_ENABLED, 必须判断当前 vault_client 的类型
|
||||
print('\033[35m>>> 当前 Vault 功能未开启, 不需要同步')
|
||||
return
|
||||
if VaultTypeChoices.local == vault_client.type:
|
||||
print('\033[31m>>> 当前第三方 Vault 客户端初始化失败,数据存储在本地数据库')
|
||||
return
|
||||
|
||||
failed, skipped, succeeded = 0, 0, 0
|
||||
to_sync_models = [Account, AccountTemplate, Account.history.model]
|
||||
@@ -48,7 +52,8 @@ def sync_secret_to_vault():
|
||||
for model in to_sync_models:
|
||||
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]
|
||||
|
||||
for future in as_completed(tasks):
|
||||
|
||||
@@ -9,3 +9,5 @@ class ActionChoices(models.TextChoices):
|
||||
warning = 'warning', _('Warn')
|
||||
notice = 'notice', _('Notify')
|
||||
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
|
||||
if not settings.XPACK_LICENSE_IS_VALID:
|
||||
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:
|
||||
field_action._choices.pop(choice, None)
|
||||
|
||||
|
||||
@@ -32,7 +32,9 @@ class CommandFilterACLSerializer(BaseSerializer, BulkOrgResourceModelSerializer)
|
||||
class Meta(BaseSerializer.Meta):
|
||||
model = CommandFilterACL
|
||||
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):
|
||||
|
||||
@@ -4,6 +4,7 @@ from common.serializers import MethodSerializer
|
||||
from orgs.mixins.serializers import BulkOrgResourceModelSerializer
|
||||
from .base import BaseUserACLSerializer
|
||||
from .rules import RuleSerializer
|
||||
from ..const import ActionChoices
|
||||
from ..models import LoginACL
|
||||
|
||||
__all__ = ["LoginACLSerializer"]
|
||||
@@ -17,6 +18,7 @@ class LoginACLSerializer(BaseUserACLSerializer, BulkOrgResourceModelSerializer):
|
||||
class Meta(BaseUserACLSerializer.Meta):
|
||||
model = LoginACL
|
||||
fields = BaseUserACLSerializer.Meta.fields + ['rules', ]
|
||||
action_choices_exclude = [ActionChoices.face_online, ActionChoices.face_verify]
|
||||
|
||||
def get_rules_serializer(self):
|
||||
return RuleSerializer()
|
||||
|
||||
@@ -123,6 +123,10 @@ class AssetViewSet(SuggestionMixin, OrgBulkModelViewSet):
|
||||
NodeFilterBackend, AttrRulesFilterBackend
|
||||
]
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
instance.accounts.update(su_from_id=None)
|
||||
instance.delete()
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
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 rest_framework import generics
|
||||
from rest_framework import serializers
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
|
||||
from assets.const import AllTypes
|
||||
from assets.models import Platform, Node, Asset, PlatformProtocol
|
||||
from assets.serializers import PlatformSerializer, PlatformProtocolSerializer, PlatformListSerializer
|
||||
@@ -42,7 +42,10 @@ class AssetPlatformViewSet(JMSModelViewSet):
|
||||
|
||||
def get_queryset(self):
|
||||
# 因为没有走分页逻辑,所以需要这里 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'
|
||||
)
|
||||
queryset = queryset.filter(type__in=AllTypes.get_types_values())
|
||||
|
||||
@@ -115,7 +115,7 @@ class PingGatewayManager:
|
||||
|
||||
@staticmethod
|
||||
def before_runner_start():
|
||||
print(">>> 开始执行测试网关可连接性任务")
|
||||
print(_(">>> Start executing the task to test gateway connectivity"))
|
||||
|
||||
def get_accounts(self, gateway):
|
||||
account = gateway.select_account
|
||||
|
||||
@@ -112,7 +112,7 @@ class BaseType(TextChoices):
|
||||
|
||||
@classmethod
|
||||
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()]
|
||||
else:
|
||||
choices = cls.choices
|
||||
|
||||
@@ -3,6 +3,7 @@ from collections import defaultdict
|
||||
from copy import deepcopy
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils.functional import lazy
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from common.db.models import ChoicesMixin
|
||||
@@ -29,15 +30,15 @@ class AllTypes(ChoicesMixin):
|
||||
|
||||
@classmethod
|
||||
def choices(cls):
|
||||
return lazy(cls.get_choices, list)()
|
||||
|
||||
@classmethod
|
||||
def get_choices(cls):
|
||||
choices = []
|
||||
for tp in cls.includes:
|
||||
choices.extend(tp.get_choices())
|
||||
return choices
|
||||
|
||||
@classmethod
|
||||
def get_choices(cls):
|
||||
return cls.choices()
|
||||
|
||||
@classmethod
|
||||
def filter_choices(cls, category):
|
||||
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.db.fields import EncryptJsonDictTextField
|
||||
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):
|
||||
@@ -21,6 +25,8 @@ class BaseAutomation(PeriodTaskModelMixin, JMSOrgBaseModel):
|
||||
is_active = models.BooleanField(default=True, verbose_name=_("Is active"))
|
||||
params = models.JSONField(default=dict, verbose_name=_("Parameters"))
|
||||
|
||||
objects = BaseAutomationManager.from_queryset(models.QuerySet)()
|
||||
|
||||
def __str__(self):
|
||||
return self.name + '@' + str(self.created_by)
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ from common.serializers.fields import LabeledChoiceField
|
||||
from labels.models import Label
|
||||
from orgs.mixins.serializers import BulkOrgResourceModelSerializer
|
||||
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__ = [
|
||||
'AssetSerializer', 'AssetSimpleSerializer', 'MiniAssetSerializer',
|
||||
@@ -309,6 +309,17 @@ class AssetSerializer(BulkOrgResourceModelSerializer, ResourceLabelsMixin, Writa
|
||||
})
|
||||
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
|
||||
def update_account_su_from(accounts, include_su_from_accounts):
|
||||
if not include_su_from_accounts:
|
||||
|
||||
@@ -27,7 +27,7 @@ class DomainSerializer(ResourceLabelsMixin, BulkOrgResourceModelSerializer):
|
||||
model = Domain
|
||||
fields_mini = ['id', 'name']
|
||||
fields_small = fields_mini + ['comment']
|
||||
fields_m2m = ['assets', 'gateways', 'assets_amount']
|
||||
fields_m2m = ['assets', 'gateways', 'labels', 'assets_amount']
|
||||
read_only_fields = ['date_created']
|
||||
fields = fields_small + fields_m2m + read_only_fields
|
||||
extra_kwargs = {
|
||||
|
||||
@@ -7,6 +7,7 @@ from django.db.models import F, Value, CharField, Q
|
||||
from django.db.models.functions import Cast
|
||||
from django.http import HttpResponse, FileResponse
|
||||
from django.utils.encoding import escape_uri_path
|
||||
from django_celery_beat.models import PeriodicTask
|
||||
from rest_framework import generics
|
||||
from rest_framework import status
|
||||
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.storage.ftp_file import FTPFileStorageHandler
|
||||
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.models import Organization
|
||||
from orgs.utils import current_org, tmp_to_root_org
|
||||
@@ -39,14 +43,14 @@ from .serializers import (
|
||||
FTPLogSerializer, UserLoginLogSerializer, JobLogSerializer,
|
||||
OperateLogSerializer, OperateLogActionDetailSerializer,
|
||||
PasswordChangeLogSerializer, ActivityUnionLogSerializer,
|
||||
FileSerializer, UserSessionSerializer
|
||||
FileSerializer, UserSessionSerializer, JobsAuditSerializer
|
||||
)
|
||||
from .utils import construct_userlogin_usernames
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class JobAuditViewSet(OrgReadonlyModelViewSet):
|
||||
class JobLogAuditViewSet(OrgReadonlyModelViewSet):
|
||||
model = JobLog
|
||||
extra_filter_backends = [DatetimeRangeFilterBackend]
|
||||
date_range_filter_fields = [
|
||||
@@ -58,6 +62,35 @@ class JobAuditViewSet(OrgReadonlyModelViewSet):
|
||||
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):
|
||||
model = FTPLog
|
||||
serializer_class = FTPLogSerializer
|
||||
@@ -146,7 +179,9 @@ class MyLoginLogViewSet(UserLoginCommonMixin, OrgReadonlyModelViewSet):
|
||||
|
||||
def get_queryset(self):
|
||||
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
|
||||
|
||||
|
||||
@@ -187,9 +222,13 @@ class ResourceActivityAPIView(generics.ListAPIView):
|
||||
'id', 'datetime', 'r_detail', 'r_detail_id',
|
||||
'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_id='') | Q(org_id=Organization.ROOT_ID)
|
||||
|
||||
org_q = Q()
|
||||
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():
|
||||
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)
|
||||
|
||||
@@ -74,6 +74,9 @@ class OperateLogStore(object):
|
||||
@classmethod
|
||||
def convert_diff_friendly(cls, op_log):
|
||||
diff_list = list()
|
||||
# 标记翻译字符串
|
||||
labels = _("labels")
|
||||
operate_log_id = _("operate_log_id")
|
||||
handler = cls._get_special_handler(op_log.resource_type)
|
||||
for k, v in op_log.diff.items():
|
||||
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.utils import reverse, i18n_trans
|
||||
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 terminal.models import Session
|
||||
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):
|
||||
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
|
||||
)
|
||||
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 jumpserver.utils import current_request
|
||||
from ..const import MODELS_NEED_RECORD, ActionChoices
|
||||
@@ -77,7 +77,7 @@ def signal_of_operate_log_whether_continue(
|
||||
condition = True
|
||||
if not instance:
|
||||
condition = False
|
||||
if instance and getattr(instance, SKIP_SIGNAL, False):
|
||||
if instance and getattr(instance, OP_LOG_SKIP_SIGNAL, False):
|
||||
condition = False
|
||||
# 不记录组件的操作日志
|
||||
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',
|
||||
'Permission', 'TicketSession', 'ApplyLoginTicket',
|
||||
'ApplyCommandTicket', 'ApplyLoginAssetTicket',
|
||||
'FavoriteAsset', 'ChangeSecretRecord'
|
||||
'FavoriteAsset', 'ChangeSecretRecord', 'AppProvider', 'Variable'
|
||||
}
|
||||
include_models = {'UserSession'}
|
||||
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'operate-logs', api.OperateLogViewSet, 'operate-log')
|
||||
router.register(r'password-change-logs', api.PasswordChangeLogViewSet, 'password-change-log')
|
||||
router.register(r'job-logs', api.JobAuditViewSet, 'job-log')
|
||||
router.register(r'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'user-sessions', api.UserSessionViewSet, 'user-session')
|
||||
|
||||
|
||||
@@ -15,3 +15,4 @@ from .ssh_key import *
|
||||
from .sso import *
|
||||
from .temp_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.utils import tmp_to_org
|
||||
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 users.const import FileNameConflictResolution
|
||||
from users.const import RDPSmartSize, RDPColorQuality
|
||||
from users.models import Preference
|
||||
from .face import FaceMonitorContext
|
||||
from ..mixins import AuthFaceMixin
|
||||
from ..models import ConnectionToken, date_expired_default
|
||||
from ..serializers import (
|
||||
ConnectionTokenSerializer, ConnectionTokenSecretSerializer,
|
||||
@@ -67,6 +69,36 @@ class RDPFileClientProtocolURLMixin:
|
||||
'bookmarktype:i': '3',
|
||||
'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'))
|
||||
if multi_mon:
|
||||
@@ -91,13 +123,15 @@ class RDPFileClientProtocolURLMixin:
|
||||
# rdp_options['domain:s'] = token.account_ad_domain
|
||||
|
||||
# 设置宽高
|
||||
height = self.request.query_params.get('height')
|
||||
width = self.request.query_params.get('width')
|
||||
if width and height:
|
||||
rdp_options['desktopwidth:i'] = width
|
||||
rdp_options['desktopheight:i'] = height
|
||||
rdp_options['winposstr:s'] = f'0,1,0,0,{width},{height}'
|
||||
rdp_options['dynamic resolution:i'] = '0'
|
||||
|
||||
resolution_value = token.connect_options.get('resolution', 'auto')
|
||||
if resolution_value != 'auto':
|
||||
width, height = resolution_value.split('x')
|
||||
if width and height:
|
||||
rdp_options['desktopwidth:i'] = width
|
||||
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 = 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()
|
||||
if rdp and rdp.setting.get('console'):
|
||||
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
|
||||
@@ -221,6 +257,8 @@ class ExtraActionApiMixin(RDPFileClientProtocolURLMixin):
|
||||
get_serializer: callable
|
||||
perform_create: callable
|
||||
validate_exchange_token: callable
|
||||
need_face_verify: bool
|
||||
create_face_verify: callable
|
||||
|
||||
@action(methods=['POST', 'GET'], detail=True, url_path='rdp-file')
|
||||
def get_rdp_file(self, request, *args, **kwargs):
|
||||
@@ -280,10 +318,13 @@ class ExtraActionApiMixin(RDPFileClientProtocolURLMixin):
|
||||
instance.date_expired = date_expired_default()
|
||||
instance.save()
|
||||
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 = (
|
||||
'user_display', 'asset_display'
|
||||
)
|
||||
@@ -304,6 +345,8 @@ class ConnectionTokenViewSet(ExtraActionApiMixin, RootOrgViewMixin, JMSModelView
|
||||
'get_client_protocol_url': 'authentication.add_connectiontoken',
|
||||
}
|
||||
input_username = ''
|
||||
need_face_verify = False
|
||||
face_monitor_token = ''
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = ConnectionToken.objects \
|
||||
@@ -355,8 +398,9 @@ class ConnectionTokenViewSet(ExtraActionApiMixin, RootOrgViewMixin, JMSModelView
|
||||
asset = data.get('asset')
|
||||
account_name = data.get('account')
|
||||
protocol = data.get('protocol')
|
||||
connect_method = data.get('connect_method')
|
||||
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)
|
||||
return serializer
|
||||
|
||||
@@ -364,12 +408,12 @@ class ConnectionTokenViewSet(ExtraActionApiMixin, RootOrgViewMixin, JMSModelView
|
||||
user = token.user
|
||||
asset = token.asset
|
||||
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():
|
||||
setattr(token, k, v)
|
||||
return token
|
||||
|
||||
def _validate(self, user, asset, account_name, protocol):
|
||||
def _validate(self, user, asset, account_name, protocol, connect_method):
|
||||
data = dict()
|
||||
data['org_id'] = asset.org_id
|
||||
data['user'] = user
|
||||
@@ -385,10 +429,16 @@ class ConnectionTokenViewSet(ExtraActionApiMixin, RootOrgViewMixin, JMSModelView
|
||||
if account.username != AliasAccount.INPUT:
|
||||
data['input_username'] = ''
|
||||
|
||||
ticket = self._validate_acl(user, asset, account)
|
||||
ticket = self._validate_acl(user, asset, account, connect_method)
|
||||
if ticket:
|
||||
data['from_ticket'] = ticket
|
||||
|
||||
if ticket or self.need_face_verify:
|
||||
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
|
||||
|
||||
@staticmethod
|
||||
@@ -417,7 +467,7 @@ class ConnectionTokenViewSet(ExtraActionApiMixin, RootOrgViewMixin, JMSModelView
|
||||
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
|
||||
kwargs = {'user': user, 'asset': asset, 'account': account}
|
||||
if account.username == AliasAccount.INPUT:
|
||||
@@ -444,6 +494,26 @@ class ConnectionTokenViewSet(ExtraActionApiMixin, RootOrgViewMixin, JMSModelView
|
||||
assignees=acl.reviewers.all(), org_id=asset.org_id
|
||||
)
|
||||
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):
|
||||
reviewers = acl.reviewers.all()
|
||||
if not reviewers:
|
||||
@@ -455,9 +525,22 @@ class ConnectionTokenViewSet(ExtraActionApiMixin, RootOrgViewMixin, JMSModelView
|
||||
reviewer, asset, user, account, self.input_username
|
||||
).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):
|
||||
try:
|
||||
response = super().create(request, *args, **kwargs)
|
||||
if self.need_face_verify:
|
||||
self.create_face_verify(response)
|
||||
except JMSException as e:
|
||||
data = {'code': e.detail.code, 'detail': e.detail}
|
||||
return Response(data, status=e.status_code)
|
||||
@@ -472,6 +555,7 @@ class SuperConnectionTokenViewSet(ConnectionTokenViewSet):
|
||||
rbac_perms = {
|
||||
'create': 'authentication.add_superconnectiontoken',
|
||||
'renewal': 'authentication.add_superconnectiontoken',
|
||||
'check': 'authentication.view_superconnectiontoken',
|
||||
'get_secret_detail': 'authentication.view_superconnectiontokensecret',
|
||||
'get_applet_info': 'authentication.view_superconnectiontoken',
|
||||
'release_applet_account': 'authentication.view_superconnectiontoken',
|
||||
@@ -484,6 +568,28 @@ class SuperConnectionTokenViewSet(ConnectionTokenViewSet):
|
||||
def get_user(self, serializer):
|
||||
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)
|
||||
def renewal(self, request, *args, **kwargs):
|
||||
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 -*-
|
||||
#
|
||||
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.translation import gettext as _
|
||||
from rest_framework import exceptions
|
||||
from rest_framework.generics import CreateAPIView
|
||||
from rest_framework.generics import CreateAPIView, RetrieveAPIView
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.serializers import ValidationError
|
||||
@@ -20,10 +19,12 @@ from ..mixins import AuthMixin
|
||||
logger = get_logger(__name__)
|
||||
|
||||
__all__ = [
|
||||
'MFAChallengeVerifyApi', 'MFASendCodeApi'
|
||||
'MFAChallengeVerifyApi', 'MFASendCodeApi',
|
||||
]
|
||||
|
||||
|
||||
|
||||
|
||||
# MFASelectAPi 原来的名字
|
||||
class MFASendCodeApi(AuthMixin, CreateAPIView):
|
||||
"""
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import time
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.shortcuts import reverse
|
||||
@@ -40,12 +41,15 @@ class UserResetPasswordSendCodeApi(CreateAPIView):
|
||||
return user, None
|
||||
|
||||
@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_send_at = cache.get(token_sent_key, 0)
|
||||
if token_send_at:
|
||||
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)
|
||||
|
||||
def prepare_code_data(self, user_info, serializer):
|
||||
@@ -61,7 +65,7 @@ class UserResetPasswordSendCodeApi(CreateAPIView):
|
||||
if not user:
|
||||
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'))
|
||||
context = {
|
||||
'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)
|
||||
except ValueError as e:
|
||||
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)
|
||||
|
||||
|
||||
|
||||
@@ -23,10 +23,9 @@ class JMSBaseAuthBackend:
|
||||
Reject users with is_valid=False. Custom user models that don't have
|
||||
that attribute are allowed.
|
||||
"""
|
||||
# 在 check_user_auth 中进行了校验,可以返回对应的错误信息
|
||||
# is_valid = getattr(user, 'is_valid', None)
|
||||
# return is_valid or is_valid is None
|
||||
return True
|
||||
# 三方用户认证完成后,在后续的 get_user 获取逻辑中,也应该需要检查用户是否有效
|
||||
is_valid = getattr(user, 'is_valid', None)
|
||||
return is_valid or is_valid is None
|
||||
|
||||
# allow user to authenticate
|
||||
def username_allow_authenticate(self, username):
|
||||
@@ -52,6 +51,14 @@ class JMSBaseAuthBackend:
|
||||
logger.info(info)
|
||||
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):
|
||||
pass
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
from django.urls import path
|
||||
import django_cas_ng.views
|
||||
from django.urls import path
|
||||
|
||||
from .views import CASLoginView
|
||||
from .views import CASLoginView, CASCallbackClientView
|
||||
|
||||
urlpatterns = [
|
||||
path('login/', CASLoginView.as_view(), name='cas-login'),
|
||||
path('logout/', django_cas_ng.views.LogoutView.as_view(), name='cas-logout'),
|
||||
path('callback/', django_cas_ng.views.CallbackView.as_view(), name='cas-proxy-callback'),
|
||||
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.http import HttpResponseRedirect
|
||||
from django.views.generic import View
|
||||
from django_cas_ng.views import LoginView
|
||||
|
||||
__all__ = ['LoginView']
|
||||
|
||||
from authentication.views.utils import redirect_to_guard_view
|
||||
|
||||
|
||||
class CASLoginView(LoginView):
|
||||
def get(self, request):
|
||||
@@ -13,3 +16,8 @@ class CASLoginView(LoginView):
|
||||
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 common.utils import get_logger
|
||||
from .base import JMSModelBackend
|
||||
from .base import JMSBaseAuthBackend
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
||||
@@ -20,9 +20,10 @@ if settings.AUTH_CUSTOM:
|
||||
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)
|
||||
|
||||
@staticmethod
|
||||
@@ -35,10 +36,10 @@ class CustomAuthBackend(JMSModelBackend):
|
||||
)
|
||||
return user, created
|
||||
|
||||
def authenticate(self, request, username=None, password=None, **kwargs):
|
||||
def authenticate(self, request, username=None, password=None):
|
||||
try:
|
||||
userinfo: dict = custom_authenticate_method(
|
||||
username=username, password=password, **kwargs
|
||||
username=username, password=password
|
||||
)
|
||||
user, created = self.get_or_create_user_from_userinfo(userinfo)
|
||||
except Exception as e:
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
import abc
|
||||
import ldap
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
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 users.utils import construct_user_email
|
||||
@@ -146,30 +147,53 @@ class LDAPHAAuthorizationBackend(JMSBaseAuthBackend, LDAPBaseBackend):
|
||||
|
||||
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):
|
||||
from settings.utils import LDAPServerUtil
|
||||
util = LDAPServerUtil()
|
||||
util = LDAPServerUtil(category=self.category)
|
||||
user_dn = util.search_for_user_dn(self._username)
|
||||
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):
|
||||
"""
|
||||
This method was overridden because the AUTH_LDAP_USER_SEARCH
|
||||
configuration in the settings.py file
|
||||
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 = [
|
||||
LDAPSearch(
|
||||
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)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
import base64
|
||||
import requests
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -17,7 +18,7 @@ from common.exceptions import JMSException
|
||||
from .signals import (
|
||||
oauth2_create_or_update_user
|
||||
)
|
||||
from ..base import JMSModelBackend
|
||||
from ..base import JMSBaseAuthBackend
|
||||
|
||||
|
||||
__all__ = ['OAuth2Backend']
|
||||
@@ -25,7 +26,7 @@ __all__ = ['OAuth2Backend']
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class OAuth2Backend(JMSModelBackend):
|
||||
class OAuth2Backend(JMSBaseAuthBackend):
|
||||
@staticmethod
|
||||
def is_enabled():
|
||||
return settings.AUTH_OAUTH2
|
||||
@@ -67,15 +68,7 @@ class OAuth2Backend(JMSModelBackend):
|
||||
response_data = response_data['data']
|
||||
return response_data
|
||||
|
||||
@staticmethod
|
||||
def get_query_dict(response_data, query_dict):
|
||||
query_dict.update({
|
||||
'uid': response_data.get('uid', ''),
|
||||
'access_token': response_data.get('access_token', '')
|
||||
})
|
||||
return query_dict
|
||||
|
||||
def authenticate(self, request, code=None, **kwargs):
|
||||
def authenticate(self, request, code=None):
|
||||
log_prompt = "Process authenticate [OAuth2Backend]: {}"
|
||||
logger.debug(log_prompt.format('Start'))
|
||||
if code is None:
|
||||
@@ -83,29 +76,31 @@ class OAuth2Backend(JMSModelBackend):
|
||||
return None
|
||||
|
||||
query_dict = {
|
||||
'client_id': settings.AUTH_OAUTH2_CLIENT_ID,
|
||||
'client_secret': settings.AUTH_OAUTH2_CLIENT_SECRET,
|
||||
'grant_type': 'authorization_code',
|
||||
'code': code,
|
||||
'grant_type': 'authorization_code', 'code': code,
|
||||
'redirect_uri': build_absolute_uri(
|
||||
request, path=reverse(settings.AUTH_OAUTH2_AUTH_LOGIN_CALLBACK_URL_NAME)
|
||||
)
|
||||
}
|
||||
if '?' in settings.AUTH_OAUTH2_ACCESS_TOKEN_ENDPOINT:
|
||||
separator = '&'
|
||||
else:
|
||||
separator = '?'
|
||||
separator = '&' if '?' in settings.AUTH_OAUTH2_ACCESS_TOKEN_ENDPOINT else '?'
|
||||
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 = settings.AUTH_OAUTH2_ACCESS_TOKEN_METHOD.lower()
|
||||
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 = {
|
||||
'Accept': 'application/json'
|
||||
'Accept': 'application/json', 'Authorization': f'Basic {encoded_credentials}'
|
||||
}
|
||||
if token_method.startswith('post'):
|
||||
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_url, headers=headers, **{body_key: query_dict}
|
||||
)
|
||||
@@ -121,22 +116,12 @@ class OAuth2Backend(JMSModelBackend):
|
||||
logger.error(log_prompt.format(error))
|
||||
return None
|
||||
|
||||
query_dict = self.get_query_dict(response_data, query_dict)
|
||||
|
||||
headers = {
|
||||
'Accept': 'application/json',
|
||||
'Authorization': 'Bearer {}'.format(response_data.get('access_token', ''))
|
||||
}
|
||||
|
||||
logger.debug(log_prompt.format('Get userinfo endpoint'))
|
||||
if '?' in 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_url = settings.AUTH_OAUTH2_PROVIDER_USERINFO_ENDPOINT
|
||||
userinfo_response = requests.get(userinfo_url, headers=headers)
|
||||
try:
|
||||
userinfo_response.raise_for_status()
|
||||
|
||||
@@ -4,9 +4,9 @@ from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path('login/', views.OAuth2AuthRequestView.as_view(), name='login'),
|
||||
path('callback/', views.OAuth2AuthCallbackView.as_view(), name='login-callback'),
|
||||
path('callback/client/', views.OAuth2AuthCallbackClientView.as_view(), name='login-callback-client'),
|
||||
path('logout/', views.OAuth2EndSessionView.as_view(), name='logout')
|
||||
]
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
from django.views import View
|
||||
from django.conf import settings
|
||||
from django.contrib import auth
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.urls import reverse
|
||||
from django.utils.http import urlencode
|
||||
from django.views import View
|
||||
|
||||
from authentication.mixins import authenticate
|
||||
from authentication.utils import build_absolute_uri
|
||||
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
|
||||
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
||||
|
||||
@@ -67,6 +67,13 @@ class OAuth2AuthCallbackView(View, FlashMessageMixin):
|
||||
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):
|
||||
http_method_names = ['get', 'post', ]
|
||||
|
||||
|
||||
@@ -13,10 +13,8 @@ import requests
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.backends import ModelBackend
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.db import transaction
|
||||
from django.urls import reverse
|
||||
from rest_framework.exceptions import ParseError
|
||||
|
||||
from authentication.signals import user_auth_success, user_auth_failed
|
||||
from authentication.utils import build_absolute_uri_for_oidc
|
||||
@@ -88,7 +86,7 @@ class OIDCAuthCodeBackend(OIDCBaseBackend):
|
||||
"""
|
||||
|
||||
@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. """
|
||||
log_prompt = "Process authenticate [OIDCAuthCodeBackend]: {}"
|
||||
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.
|
||||
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'))
|
||||
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
|
||||
# token endpoint of the OIDC provider.
|
||||
@@ -165,7 +163,7 @@ class OIDCAuthCodeBackend(OIDCBaseBackend):
|
||||
error = "Json token response error, token response " \
|
||||
"content is: {}, error is: {}".format(token_response.content, str(e))
|
||||
logger.debug(log_prompt.format(error))
|
||||
raise ParseError(error)
|
||||
return
|
||||
|
||||
# Validates the token.
|
||||
logger.debug(log_prompt.format('Validate ID Token'))
|
||||
@@ -206,7 +204,7 @@ class OIDCAuthCodeBackend(OIDCBaseBackend):
|
||||
error = "Json claims response error, claims response " \
|
||||
"content is: {}, error is: {}".format(claims_response.content, str(e))
|
||||
logger.debug(log_prompt.format(error))
|
||||
raise ParseError(error)
|
||||
return
|
||||
|
||||
logger.debug(log_prompt.format('Get or create user from claims'))
|
||||
user, created = self.get_or_create_user_from_claims(request, claims)
|
||||
@@ -235,15 +233,15 @@ class OIDCAuthCodeBackend(OIDCBaseBackend):
|
||||
class OIDCAuthPasswordBackend(OIDCBaseBackend):
|
||||
|
||||
@ssl_verification
|
||||
def authenticate(self, request, username=None, password=None, **kwargs):
|
||||
def authenticate(self, request, username=None, password=None):
|
||||
try:
|
||||
return self._authenticate(request, username, password, **kwargs)
|
||||
return self._authenticate(request, username, password)
|
||||
except Exception as e:
|
||||
error = f'Authenticate exception: {e}'
|
||||
logger.error(error, exc_info=True)
|
||||
return
|
||||
|
||||
def _authenticate(self, request, username=None, password=None, **kwargs):
|
||||
def _authenticate(self, request, username=None, password=None):
|
||||
"""
|
||||
https://oauth.net/2/
|
||||
https://aaronparecki.com/oauth-2-simplified/#password
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
import warnings
|
||||
import contextlib
|
||||
import requests
|
||||
import inspect
|
||||
|
||||
from functools import wraps
|
||||
from django.conf import settings
|
||||
from urllib3.exceptions import InsecureRequestWarning
|
||||
|
||||
@@ -52,6 +54,7 @@ def no_ssl_verification():
|
||||
|
||||
|
||||
def ssl_verification(func):
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
if not settings.AUTH_OPENID_IGNORE_SSL_VERIFICATION:
|
||||
return func(*args, **kwargs)
|
||||
|
||||
@@ -12,9 +12,9 @@ from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path('login/', views.OIDCAuthRequestView.as_view(), name='login'),
|
||||
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'),
|
||||
]
|
||||
|
||||
@@ -22,13 +22,14 @@ from django.http import HttpResponseRedirect, QueryDict
|
||||
from django.urls import reverse
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.http import urlencode
|
||||
from django.views.generic import View
|
||||
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.views.mixins import FlashMessageMixin
|
||||
from common.utils import safe_next_url
|
||||
from .utils import get_logger
|
||||
from ...views.utils import redirect_to_guard_view
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
||||
@@ -208,6 +209,13 @@ class OIDCAuthCallbackView(View, FlashMessageMixin):
|
||||
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):
|
||||
""" 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"))
|
||||
date_last_used = models.DateTimeField(null=True, default=None, verbose_name=_("Date last used"))
|
||||
credential_id = models.CharField(max_length=255, unique=True, null=False, verbose_name=_("Credential ID"))
|
||||
token = models.CharField(max_length=255, null=False, verbose_name=_("Token"))
|
||||
token = models.CharField(max_length=1024, null=False, verbose_name=_("Token"))
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@@ -51,10 +51,10 @@ class RadiusBaseBackend(CreateUserMixin, JMSBaseAuthBackend):
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -10,14 +10,14 @@ from .signals import (
|
||||
saml2_create_or_update_user
|
||||
)
|
||||
from authentication.signals import user_auth_failed, user_auth_success
|
||||
from ..base import JMSModelBackend
|
||||
from ..base import JMSBaseAuthBackend
|
||||
|
||||
__all__ = ['SAML2Backend']
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class SAML2Backend(JMSModelBackend):
|
||||
class SAML2Backend(JMSBaseAuthBackend):
|
||||
@staticmethod
|
||||
def is_enabled():
|
||||
return settings.AUTH_SAML2
|
||||
@@ -42,7 +42,7 @@ class SAML2Backend(JMSModelBackend):
|
||||
)
|
||||
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]: {}"
|
||||
logger.debug(log_prompt.format('Start'))
|
||||
if saml_user_data is None:
|
||||
|
||||
@@ -4,10 +4,10 @@ from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path('login/', views.Saml2AuthRequestView.as_view(), name='saml2-login'),
|
||||
path('logout/', views.Saml2EndSessionView.as_view(), name='saml2-logout'),
|
||||
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'),
|
||||
]
|
||||
|
||||
@@ -19,6 +19,7 @@ from onelogin.saml2.idp_metadata_parser import (
|
||||
from authentication.views.mixins import FlashMessageMixin
|
||||
from common.utils import get_logger
|
||||
from .settings import JmsSaml2Settings
|
||||
from ...views.utils import redirect_to_guard_view
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
||||
@@ -298,6 +299,13 @@ class Saml2AuthCallbackView(View, PrepareRequestMixin, FlashMessageMixin):
|
||||
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):
|
||||
|
||||
def get(self, request):
|
||||
|
||||
@@ -1,57 +1,41 @@
|
||||
from django.conf import settings
|
||||
|
||||
from .base import JMSModelBackend
|
||||
from .base import JMSBaseAuthBackend
|
||||
|
||||
|
||||
class SSOAuthentication(JMSModelBackend):
|
||||
"""
|
||||
什么也不做呀😺
|
||||
"""
|
||||
|
||||
class SSOAuthentication(JMSBaseAuthBackend):
|
||||
@staticmethod
|
||||
def is_enabled():
|
||||
return settings.AUTH_SSO
|
||||
|
||||
def authenticate(self, request, sso_token=None, **kwargs):
|
||||
def authenticate(self):
|
||||
pass
|
||||
|
||||
|
||||
class WeComAuthentication(JMSModelBackend):
|
||||
"""
|
||||
什么也不做呀😺
|
||||
"""
|
||||
|
||||
class WeComAuthentication(JMSBaseAuthBackend):
|
||||
@staticmethod
|
||||
def is_enabled():
|
||||
return settings.AUTH_WECOM
|
||||
|
||||
def authenticate(self, request, **kwargs):
|
||||
def authenticate(self):
|
||||
pass
|
||||
|
||||
|
||||
class DingTalkAuthentication(JMSModelBackend):
|
||||
"""
|
||||
什么也不做呀😺
|
||||
"""
|
||||
|
||||
class DingTalkAuthentication(JMSBaseAuthBackend):
|
||||
@staticmethod
|
||||
def is_enabled():
|
||||
return settings.AUTH_DINGTALK
|
||||
|
||||
def authenticate(self, request, **kwargs):
|
||||
def authenticate(self):
|
||||
pass
|
||||
|
||||
|
||||
class FeiShuAuthentication(JMSModelBackend):
|
||||
"""
|
||||
什么也不做呀😺
|
||||
"""
|
||||
|
||||
class FeiShuAuthentication(JMSBaseAuthBackend):
|
||||
@staticmethod
|
||||
def is_enabled():
|
||||
return settings.AUTH_FEISHU
|
||||
|
||||
def authenticate(self, request, **kwargs):
|
||||
def authenticate(self):
|
||||
pass
|
||||
|
||||
|
||||
@@ -61,23 +45,15 @@ class LarkAuthentication(FeiShuAuthentication):
|
||||
return settings.AUTH_LARK
|
||||
|
||||
|
||||
class SlackAuthentication(JMSModelBackend):
|
||||
"""
|
||||
什么也不做呀😺
|
||||
"""
|
||||
|
||||
class SlackAuthentication(JMSBaseAuthBackend):
|
||||
@staticmethod
|
||||
def is_enabled():
|
||||
return settings.AUTH_SLACK
|
||||
|
||||
def authenticate(self, request, **kwargs):
|
||||
def authenticate(self):
|
||||
pass
|
||||
|
||||
|
||||
class AuthorizationTokenAuthentication(JMSModelBackend):
|
||||
"""
|
||||
什么也不做呀😺
|
||||
"""
|
||||
|
||||
def authenticate(self, request, **kwargs):
|
||||
class AuthorizationTokenAuthentication(JMSBaseAuthBackend):
|
||||
def authenticate(self):
|
||||
pass
|
||||
|
||||
@@ -3,13 +3,17 @@ from django.conf import settings
|
||||
from django.core.exceptions import PermissionDenied
|
||||
|
||||
from authentication.models import TempToken
|
||||
from .base import JMSModelBackend
|
||||
from .base import JMSBaseAuthBackend
|
||||
|
||||
|
||||
class TempTokenAuthBackend(JMSModelBackend):
|
||||
class TempTokenAuthBackend(JMSBaseAuthBackend):
|
||||
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()
|
||||
if not token:
|
||||
return None
|
||||
@@ -21,6 +25,3 @@ class TempTokenAuthBackend(JMSModelBackend):
|
||||
token.save()
|
||||
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):
|
||||
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)
|
||||
return ok, msg
|
||||
|
||||
@@ -2,7 +2,7 @@ from django.db.models import TextChoices
|
||||
|
||||
from authentication.confirm import CONFIRM_BACKENDS
|
||||
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_PUBLIC_KEY = 'rsa_public_key'
|
||||
@@ -35,5 +35,17 @@ class ConfirmType(TextChoices):
|
||||
class MFAType(TextChoices):
|
||||
OTP = MFAOtp.name, MFAOtp.display_name
|
||||
SMS = MFASms.name, MFASms.display_name
|
||||
Face = MFAFace.name, MFAFace.display_name
|
||||
Radius = MFARadius.name, MFARadius.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 .radius import MFARadius
|
||||
from .custom import MFACustom
|
||||
from .face import MFAFace
|
||||
@@ -12,10 +12,14 @@ class BaseMFA(abc.ABC):
|
||||
因为首页登录时,可能没法获取到一些状态
|
||||
"""
|
||||
self.user = user
|
||||
self.request = None
|
||||
|
||||
def is_authenticated(self):
|
||||
return self.user and self.user.is_authenticated
|
||||
|
||||
def set_request(self, request):
|
||||
self.request = request
|
||||
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
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'
|
||||
placeholder = _("Radius verification code")
|
||||
|
||||
def check_code(self, code):
|
||||
def check_code(self, code=None):
|
||||
assert self.is_authenticated()
|
||||
backend = RadiusBackend()
|
||||
username = self.user.username
|
||||
|
||||
@@ -2,6 +2,7 @@ from django.conf import settings
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from common.utils.verify_code import SendAndVerifyCodeUtil
|
||||
from users.serializers import SmsUserSerializer
|
||||
from .base import BaseMFA
|
||||
|
||||
sms_failed_msg = _("SMS verify code invalid")
|
||||
@@ -14,8 +15,13 @@ class MFASms(BaseMFA):
|
||||
|
||||
def __init__(self, user):
|
||||
super().__init__(user)
|
||||
phone = user.phone if self.is_authenticated() else ''
|
||||
self.sms = SendAndVerifyCodeUtil(phone, backend=self.name)
|
||||
phone, user_info = '', None
|
||||
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):
|
||||
assert self.is_authenticated()
|
||||
|
||||
@@ -2,6 +2,7 @@ import base64
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import logout as auth_logout
|
||||
from django.core.cache import cache
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import redirect, reverse, render
|
||||
from django.utils.deprecation import MiddlewareMixin
|
||||
@@ -34,7 +35,7 @@ class MFAMiddleware:
|
||||
|
||||
# 这个是 mfa 登录页需要的请求, 也得放出来, 用户其实已经在 CAS/OIDC 中完成登录了
|
||||
white_urls = [
|
||||
'login/mfa', 'mfa/select', 'jsi18n/', '/static/',
|
||||
'login/mfa', 'mfa/select', 'face/context','jsi18n/', '/static/',
|
||||
'/profile/otp', '/logout/',
|
||||
]
|
||||
for url in white_urls:
|
||||
@@ -116,23 +117,43 @@ class ThirdPartyLoginMiddleware(mixins.AuthMixin):
|
||||
|
||||
|
||||
class SessionCookieMiddleware(MiddlewareMixin):
|
||||
USER_LOGIN_ENCRYPTION_KEY_PAIR = 'user_login_encryption_key_pair'
|
||||
|
||||
@staticmethod
|
||||
def set_cookie_public_key(request, response):
|
||||
def set_cookie_public_key(self, request, response):
|
||||
if request.path.startswith('/api'):
|
||||
return
|
||||
pub_key_name = settings.SESSION_RSA_PUBLIC_KEY_NAME
|
||||
public_key = request.session.get(pub_key_name)
|
||||
cookie_key = request.COOKIES.get(pub_key_name)
|
||||
if public_key and public_key == cookie_key:
|
||||
|
||||
session_public_key_name = settings.SESSION_RSA_PUBLIC_KEY_NAME
|
||||
session_private_key_name = settings.SESSION_RSA_PRIVATE_KEY_NAME
|
||||
|
||||
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
|
||||
|
||||
pri_key_name = settings.SESSION_RSA_PRIVATE_KEY_NAME
|
||||
private_key, public_key = gen_key_pair()
|
||||
private_key, public_key = self.get_key_pair()
|
||||
|
||||
public_key_decode = base64.b64encode(public_key.encode()).decode()
|
||||
request.session[pub_key_name] = public_key_decode
|
||||
request.session[pri_key_name] = private_key
|
||||
response.set_cookie(pub_key_name, public_key_decode)
|
||||
|
||||
request.session[session_public_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
|
||||
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