Compare commits

...

98 Commits

Author SHA1 Message Date
ibuler
e41f6e27e2 perf: update schema 2025-12-12 15:40:40 +08:00
ibuler
d2386fb56c perf: update swagger for mcp 2025-12-10 18:17:10 +08:00
fit2bot
5f1ba56e56 Merge pull request #16094 from jumpserver/pr@dev@chat_model
perf: Add open ui chat model
2025-12-10 10:43:14 +08:00
Chenyang Shen
2b1fdb937b Merge pull request #16404 from jumpserver/pr@dev@feat_reset_key_store
feat: reset piico device after open device
2025-12-09 15:16:41 +08:00
Aaron3S
1e754546f1 feat: reset piico device after open device 2025-12-09 14:47:37 +08:00
Bai
2ec71feafc perf: rbac oauth2_provider perms i18n 2025-12-09 10:17:34 +08:00
Bai
02e8905330 perf: redirect/confirm page and i18n 2025-12-08 18:43:04 +08:00
Bai
8d68f5589b perf: redirect/confirm page and i18n 2025-12-08 18:43:04 +08:00
Bai
4df13fc384 perf: redirect/confirm page and i18n 2025-12-08 18:40:12 +08:00
Bai
78c1162028 perf: when DEBUG_DEV=True, allow OAUTH2_PROVIDER redirect_url localhost listen 2025-12-08 16:42:07 +08:00
Bai
14c2512b45 fix: accesskey authentication user is None error 2025-12-08 15:06:47 +08:00
Bai
d6d7072da5 perf: request.GET.copy() to dict(), because copy() returned values is list [] 2025-12-08 12:50:49 +08:00
fit2bot
993bc36c5e perf: handling the next parameter propagation issue in third-party authentication flows (#16395)
* perf: remove call client old- method via ?next=client

* feat: add 2 decorators for login-get and login-callback-get to set next_page and get next_page from session

* perf: code style

* perf: handling the next parameter propagation issue in third-party authentication flows

* perf: request.GET.dict() to copy()

* perf: style import

---------

Co-authored-by: Bai <baijiangjie@gmail.com>
2025-12-08 12:34:32 +08:00
fit2bot
ecff2ea07e perf: move oauth2_provider api auth_backend to the end, and while accesstoken_backend not user do not raise execption, go on next bakcned auth (#16393)
* perf: move oauth2_provider api auth_backend to the end, and while accesstoken_backend not user do not raise execption, go on next bakcned auth

* perf: re-sorted DEFAULT_AUTHENTICATION_CLASSES

---------

Co-authored-by: Bai <baijiangjie@gmail.com>
2025-12-08 09:57:17 +08:00
fit2bot
ba70edf221 perf: when oauth2 application delete expired well-known page cache via post_delete signal (#16392)
Co-authored-by: Bai <baijiangjie@gmail.com>
2025-12-08 09:54:18 +08:00
Bai
50050dff57 fix: cas only allow exist user login 2025-12-04 18:37:54 +08:00
jiangweidong
944226866c perf: Add a diff field to operate-log export 2025-12-04 18:01:01 +08:00
fit2bot
fe13221d88 fix: Improve server URI validation and connection testing in LDAP module (#16377)
Co-authored-by: wangruidong <940853815@qq.com>
2025-12-04 17:59:01 +08:00
fit2bot
ba17863892 perf: Remove unused CAS user exception handling and simplify login view error response (#16380)
* perf: Remove unused CAS user exception handling and simplify login view error response

* perf: position code

---------

Co-authored-by: wangruidong <940853815@qq.com>
Co-authored-by: Bai <baijiangjie@gmail.com>
2025-12-04 17:49:58 +08:00
fit2bot
065bfeda52 fix: only exists user login maybe invalid (#16379)
* fix: only exists user login maybe invalid

* fix: only exists user login maybe invalid

* fix: only exists user login maybe invalid

---------

Co-authored-by: Bai <baijiangjie@gmail.com>
2025-12-04 16:18:47 +08:00
wangruidong
04af26500a fix: Allow login with username or email for existing users 2025-12-04 10:04:32 +08:00
fit2bot
e0388364c3 fix: use third part authentication service rediect to client failed (#16370)
* perf: .well-known cached 1h and support saml2 redirect_to client

* fix: support wecom redirect_to client (reslove wecom waf 501 error)

* fix: support oauth2 auth rediect to client

* fix: safe next url

---------

Co-authored-by: Bai <baijiangjie@gmail.com>
2025-12-03 19:07:00 +08:00
Bai
3c96480b0c perf: add manage.py command: init_oauth2_provider, resolve init jumpserver client failed issue 2025-12-03 14:37:20 +08:00
Bai
95331a0c4b perf: redirect to client show tips 2025-12-02 18:39:48 +08:00
Bai
b8ecb703cf perf: url revoke_token/ to revoke/ 2025-12-02 18:21:13 +08:00
Bai
1a3f5e3f9a perf: default access token/refresh token expired at 1h/7day 2025-12-02 15:34:55 +08:00
Bai
854396e8d5 perf: access-token api 2025-12-02 15:25:55 +08:00
Bai
ab08603e66 perf: organize oauth2_provider urls, add .well-known API 2025-12-02 14:55:09 +08:00
Bai
427fd3f72c perf: organize oauth2_provider urls, add .well-known API 2025-12-02 14:55:09 +08:00
Bai
0aba9ba120 perf: hide the unused URLs in OAuth2 provider 2025-12-02 14:55:09 +08:00
Bai
045ca8807a feat: modify client redirect url 2025-12-01 19:04:19 +08:00
Bai
19a68d8930 feat: add api access token 2025-12-01 17:55:08 +08:00
Bai
75ed02a2d2 feat: add oauth2 provider accesstokens api 2025-12-01 17:55:08 +08:00
fit2bot
f420dac49c feat: Host cloud sync supports state cloud - i18n (#16304)
Co-authored-by: jiangweidong <1053570670@qq.com>
Co-authored-by: Jiangjie Bai <jiangjie.bai@fit2cloud.com>
2025-12-01 10:56:14 +08:00
Bai
1ee68134f2 fix: rename utils methond 2025-12-01 10:41:14 +08:00
Bai
937265db5d perf: add period task clear oauth2 provider expired tokens 2025-12-01 10:41:14 +08:00
Bai
c611d5e88b perf: add utils delete oauth2 provider application 2025-12-01 10:41:14 +08:00
Bai
883b6b6383 perf: skip_authorization for redirect to jms client 2025-12-01 10:41:14 +08:00
Bai
ac4c72064f perf: register jumpserver client logic 2025-12-01 10:41:14 +08:00
Bai
dbf8360e27 feat: add OAUTH2_PROVIDER_ACCESS_TOKEN_EXPIRE_SECONDS 2025-12-01 10:41:14 +08:00
github-actions[bot]
150d7a09bc perf: Update Dockerfile with new base image tag 2025-11-28 16:28:23 +08:00
Bai
a7ed20e059 perf: support as oauth2 provider 2025-11-28 16:28:23 +08:00
github-actions[bot]
1b7b8e6f2e perf: Update Dockerfile with new base image tag 2025-11-28 16:28:23 +08:00
Bai
cd22fbce19 perf: support as oauth2 provider 2025-11-28 16:28:23 +08:00
老广
c191d86f43 Refactor GitHub Actions workflow for event handling 2025-11-27 14:27:27 +08:00
wangruidong
7911137ffb fix: Truncate asset URL to 128 characters to prevent exceeding length limit 2025-11-27 14:17:19 +08:00
wangruidong
1053933cae fix: Add migration to refresh PostgreSQL collation version 2025-11-27 14:16:44 +08:00
wangruidong
96fdc025cd fix: Search for risk_level, search result is empty 2025-11-26 18:07:20 +08:00
wangruidong
fde19764e0 fix: Processing redirection url unquote 2025-11-25 14:00:31 +08:00
wangruidong
978fbc70e6 perf: Improve city retrieval fallback to handle missing values 2025-11-25 13:59:48 +08:00
Ewall555
636ffd786d feat: add namespace setting to k8s protocol configuration 2025-11-25 11:08:23 +08:00
feng
3b756aa26f perf: Component i18n lang lower 2025-11-25 10:56:37 +08:00
Bai
817c0099d1 perf: client pkg rename 2025-11-21 18:45:49 +08:00
Bai
a0d7871130 perf: client pkg rename 2025-11-21 18:45:49 +08:00
Bai
c97124c279 perf: client pkg rename 2025-11-20 17:59:22 +08:00
Bai
32a766ed34 perf: client pkg rename 2025-11-20 17:59:22 +08:00
Bai
58fd15d743 perf: client pkg rename 2025-11-20 17:59:22 +08:00
feng
f50250dedb perf: Client version 2025-11-20 16:37:23 +08:00
wangruidong
9e150b7fbe fix: One login lock, resulting in two logs 2025-11-20 15:01:06 +08:00
wangruidong
16c79f59a7 fix: Handle case where all time_periods have empty values as a selection of all 2025-11-20 11:31:09 +08:00
wangruidong
be0f04862a fix: Correctly pass runas value in ACL check for job execution 2025-11-19 19:08:29 +08:00
feng
1a3fb2f0db perf: Account bulk error prompt 2025-11-19 17:42:39 +08:00
Eric
4cd70efe66 perf: fix mp4 type replay 2025-11-19 17:10:26 +08:00
wangruidong
28700c01c8 perf: The login log records the locked login log 2025-11-19 17:08:55 +08:00
wangruidong
4524822245 fix: Solve this version of Mysql doesn't yet support 'LIMIT & IN/ALL/ANY/S0ME subquery' error 2025-11-19 09:52:05 +08:00
Eric
9d04fda018 perf: add match perm to user for suggestions api 2025-11-19 09:48:31 +08:00
老广
01c277cd1e Add Client to JumpServer components list 2025-11-19 09:19:52 +08:00
wangruidong
c4b3531d72 fix: correct handling of changed field values in operate log 2025-11-18 10:32:49 +08:00
feng
8870d1ef9e perf: Translate 2025-11-17 18:25:40 +08:00
wangruidong
6c5086a083 perf: implement login asset ACL checks in Job and JobExecution viewsets 2025-11-17 10:53:22 +08:00
wrd
e9f762a982 Revert "perf: Reduce the number of pub sub processing threads (#16072)"
This reverts commit 70068c9253.
2025-11-17 10:52:16 +08:00
wangruidong
d4d4cadbcd fix: OAuth2 Only allow existing users to log in operate log error 2025-11-13 18:42:28 +08:00
fit2bot
5e56590405 perf: change base img (#16279)
* perf: change base img

* perf: add gcc

* perf: change base image

* perf: Update Dockerfile with new base image tag

---------

Co-authored-by: ibuler <ibuler@qq.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-11-13 17:32:51 +08:00
wangruidong
ad8c0f6664 fix: SAML2 Only allow existing users to log in operate log error 2025-11-13 16:36:58 +08:00
wangruidong
47dd6babfc perf: add id verbose_name 2025-11-13 15:17:14 +08:00
ibuler
691d1c4dba perf: remove client key 2025-11-13 14:36:40 +08:00
ibuler
ac485804d5 perf: postgresql support ssl 2025-11-13 14:36:40 +08:00
ibuler
51e5fdb301 perf: change i18n 2025-11-13 10:05:37 +08:00
feng
69c4d613f7 perf: Add client support version 2025-11-11 16:37:12 +08:00
github-actions[bot]
1ad825bf0d perf: Update Dockerfile with new base image tag 2025-11-11 15:11:51 +08:00
ibuler
a286cb9343 deps: upgrade playwright 2025-11-11 15:11:51 +08:00
ibuler
1eb489bb2d perf: upgrade pg client 2025-11-11 14:24:53 +08:00
fit2bot
4334ae9e5e perf: update apt source config (#16265)
* perf: upgrade os to trixie

* perf: update apt source config

* perf: Update Dockerfile with new base image tag

---------

Co-authored-by: ibuler <ibuler@qq.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-11-11 14:17:35 +08:00
fit2bot
f2e346a0c3 perf: upgrade os to trixie (#16263)
* perf: upgrade os to trixie

* perf: Update Dockerfile with new base image tag

---------

Co-authored-by: ibuler <ibuler@qq.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-11-11 11:52:17 +08:00
wangruidong
dc20b06431 fix: i18n error 2025-11-10 18:14:18 +08:00
fit2bot
387a9248fc perf: Add a key to cover all protocols and ports (#16227)
Co-authored-by: wangruidong <940853815@qq.com>
2025-11-10 18:04:00 +08:00
wangruidong
705fd6385f fix: i18n error 2025-11-10 18:03:51 +08:00
fit2bot
0ccf36621f perf: Translate select files (#16212)
Co-authored-by: wangruidong <940853815@qq.com>
2025-11-06 18:26:54 +08:00
fit2bot
a9ae12fc2c perf: Implement data masking rules ACL check before job execution (#16216)
* perf: Implement data masking rules ACL check before job execution

* perf: Add login asset ACL check during job creation

* perf: Remove unused code.

---------

Co-authored-by: wangruidong <940853815@qq.com>
2025-11-06 18:25:34 +08:00
老广
7b1a25adde Add issue spam configuration file 2025-11-06 18:13:42 +08:00
feng
a1b5eb1cd8 perf: Translate 2025-11-06 15:50:15 +08:00
wangruidong
24ac642c5e fix: Escape percentage signs in gateway password for sshpass command 2025-11-06 14:10:24 +08:00
wangruidong
e4f5e21219 perf: Support batch import of leak passwords 2025-11-06 14:09:09 +08:00
feng
a2aae9db47 perf: Translate 2025-11-05 19:07:48 +08:00
feng
206c43cf75 fix: Fixed the issue of inaccurate calculation of the number of dashboard commands. 2025-11-04 18:14:02 +08:00
feng
019a657ec3 perf: Ssotoken login create operator choose org_id 2025-11-03 17:36:04 +08:00
feng
fad60ee40f perf: Translate 2025-11-03 10:51:22 +08:00
feng
1728412793 perf: Bulk account support node 2025-10-31 17:19:48 +08:00
154 changed files with 12335 additions and 8636 deletions

26
.github/.github/issue-spam-config.json vendored Normal file
View File

@@ -0,0 +1,26 @@
{
"dry_run": false,
"min_account_age_days": 3,
"max_urls_for_spam": 1,
"min_body_len_for_links": 40,
"spam_words": [
"call now",
"zadzwoń",
"zadzwoń teraz",
"kontakt",
"telefon",
"telefone",
"contato",
"suporte",
"infolinii",
"click here",
"buy now",
"subscribe",
"visit"
],
"bracket_max": 6,
"special_char_density_threshold": 0.12,
"phone_regex": "\\+?\\d[\\d\\-\\s\\(\\)\\.]{6,}\\d",
"labels_for_spam": ["spam"],
"labels_for_review": ["needs-triage"]
}

View File

@@ -1,74 +1,72 @@
name: Build and Push Base Image name: Build and Push Base Image
on: on:
pull_request: pull_request:
branches: branches:
- 'dev' - 'dev'
- 'v*' - 'v*'
paths: paths:
- poetry.lock - poetry.lock
- pyproject.toml - pyproject.toml
- Dockerfile-base - Dockerfile-base
- package.json - package.json
- go.mod - go.mod
- yarn.lock - yarn.lock
- pom.xml - pom.xml
- install_deps.sh - install_deps.sh
- utils/clean_site_packages.sh - utils/clean_site_packages.sh
types: types:
- opened - opened
- synchronize - synchronize
- reopened - reopened
jobs: jobs:
build-and-push: build-and-push:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
ref: ${{ github.event.pull_request.head.ref }} ref: ${{ github.event.pull_request.head.ref }}
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
with:
image: tonistiigi/binfmt:qemu-v7.0.0-28
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Login to DockerHub - name: Login to DockerHub
uses: docker/login-action@v2 uses: docker/login-action@v2
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract date - name: Extract date
id: vars id: vars
run: echo "IMAGE_TAG=$(date +'%Y%m%d_%H%M%S')" >> $GITHUB_ENV run: echo "IMAGE_TAG=$(date +'%Y%m%d_%H%M%S')" >> $GITHUB_ENV
- name: Extract repository name - name: Extract repository name
id: repo id: repo
run: echo "REPO=$(basename ${{ github.repository }})" >> $GITHUB_ENV run: echo "REPO=$(basename ${{ github.repository }})" >> $GITHUB_ENV
- name: Build and push multi-arch image - name: Build and push multi-arch image
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
push: true push: true
file: Dockerfile-base file: Dockerfile-base
tags: jumpserver/core-base:${{ env.IMAGE_TAG }} tags: jumpserver/core-base:${{ env.IMAGE_TAG }}
- name: Update Dockerfile - name: Update Dockerfile
run: | run: |
sed -i 's|-base:.* AS stage-build|-base:${{ env.IMAGE_TAG }} AS stage-build|' Dockerfile sed -i 's|-base:.* AS stage-build|-base:${{ env.IMAGE_TAG }} AS stage-build|' Dockerfile
- name: Commit changes - name: Commit changes
run: | run: |
git config --global user.name 'github-actions[bot]' git config --global user.name 'github-actions[bot]'
git config --global user.email 'github-actions[bot]@users.noreply.github.com' git config --global user.email 'github-actions[bot]@users.noreply.github.com'
git add Dockerfile git add Dockerfile
git commit -m "perf: Update Dockerfile with new base image tag" git commit -m "perf: Update Dockerfile with new base image tag"
git push origin ${{ github.event.pull_request.head.ref }} git push origin ${{ github.event.pull_request.head.ref }}
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,10 +1,33 @@
on: [push, pull_request, release] on:
push:
pull_request:
types: [opened, synchronize, closed]
release:
types: [created]
name: JumpServer repos generic handler name: JumpServer repos generic handler
jobs: jobs:
generic_handler: handle_pull_request:
name: Run generic handler if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: jumpserver/action-generic-handler@master
env:
GITHUB_TOKEN: ${{ secrets.PRIVATE_TOKEN }}
I18N_TOKEN: ${{ secrets.I18N_TOKEN }}
handle_push:
if: github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- uses: jumpserver/action-generic-handler@master
env:
GITHUB_TOKEN: ${{ secrets.PRIVATE_TOKEN }}
I18N_TOKEN: ${{ secrets.I18N_TOKEN }}
handle_release:
if: github.event_name == 'release'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: jumpserver/action-generic-handler@master - uses: jumpserver/action-generic-handler@master

View File

@@ -1,4 +1,4 @@
FROM jumpserver/core-base:20251014_095903 AS stage-build FROM jumpserver/core-base:20251128_025056 AS stage-build
ARG VERSION ARG VERSION
@@ -19,7 +19,7 @@ RUN set -ex \
&& python manage.py compilemessages && python manage.py compilemessages
FROM jumpserver/core-base:python-3.11-slim-bullseye-v1 FROM python:3.11-slim-trixie
ENV LANG=en_US.UTF-8 \ ENV LANG=en_US.UTF-8 \
PATH=/opt/py3/bin:$PATH PATH=/opt/py3/bin:$PATH
@@ -39,7 +39,7 @@ ARG TOOLS=" \
ARG APT_MIRROR=http://deb.debian.org ARG APT_MIRROR=http://deb.debian.org
RUN set -ex \ RUN set -ex \
&& sed -i "s@http://.*.debian.org@${APT_MIRROR}@g" /etc/apt/sources.list \ && sed -i "s@http://.*.debian.org@${APT_MIRROR}@g" /etc/apt/sources.list.d/debian.sources \
&& ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \ && ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
&& apt-get update > /dev/null \ && apt-get update > /dev/null \
&& apt-get -y install --no-install-recommends ${DEPENDENCIES} \ && apt-get -y install --no-install-recommends ${DEPENDENCIES} \

View File

@@ -1,6 +1,5 @@
FROM jumpserver/core-base:python-3.11-slim-bullseye-v1 FROM python:3.11.14-slim-trixie
ARG TARGETARCH ARG TARGETARCH
COPY --from=ghcr.io/astral-sh/uv:0.6.14 /uv /uvx /usr/local/bin/
# Install APT dependencies # Install APT dependencies
ARG DEPENDENCIES=" \ ARG DEPENDENCIES=" \
ca-certificates \ ca-certificates \
@@ -22,7 +21,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=core \
set -ex \ set -ex \
&& rm -f /etc/apt/apt.conf.d/docker-clean \ && rm -f /etc/apt/apt.conf.d/docker-clean \
&& echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache \ && 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 \ && sed -i "s@http://.*.debian.org@${APT_MIRROR}@g" /etc/apt/sources.list.d/debian.sources \
&& apt-get update > /dev/null \ && apt-get update > /dev/null \
&& apt-get -y install --no-install-recommends ${DEPENDENCIES} \ && apt-get -y install --no-install-recommends ${DEPENDENCIES} \
&& echo "no" | dpkg-reconfigure dash && echo "no" | dpkg-reconfigure dash
@@ -41,12 +40,10 @@ RUN set -ex \
WORKDIR /opt/jumpserver WORKDIR /opt/jumpserver
ARG PIP_MIRROR=https://pypi.org/simple ARG PIP_MIRROR=https://pypi.org/simple
ENV POETRY_PYPI_MIRROR_URL=${PIP_MIRROR}
ENV ANSIBLE_COLLECTIONS_PATHS=/opt/py3/lib/python3.11/site-packages/ansible_collections ENV ANSIBLE_COLLECTIONS_PATHS=/opt/py3/lib/python3.11/site-packages/ansible_collections
ENV LANG=en_US.UTF-8 \ ENV LANG=en_US.UTF-8 \
PATH=/opt/py3/bin:$PATH PATH=/opt/py3/bin:$PATH
ENV SETUPTOOLS_SCM_PRETEND_VERSION=3.4.5
ENV UV_LINK_MODE=copy
RUN --mount=type=cache,target=/root/.cache \ RUN --mount=type=cache,target=/root/.cache \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \ --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
@@ -54,6 +51,7 @@ RUN --mount=type=cache,target=/root/.cache \
--mount=type=bind,source=requirements/collections.yml,target=collections.yml \ --mount=type=bind,source=requirements/collections.yml,target=collections.yml \
--mount=type=bind,source=requirements/static_files.sh,target=utils/static_files.sh \ --mount=type=bind,source=requirements/static_files.sh,target=utils/static_files.sh \
set -ex \ set -ex \
&& pip install uv -i${PIP_MIRROR} \
&& uv venv \ && uv venv \
&& uv pip install -i${PIP_MIRROR} -r pyproject.toml \ && uv pip install -i${PIP_MIRROR} -r pyproject.toml \
&& ln -sf $(pwd)/.venv /opt/py3 \ && ln -sf $(pwd)/.venv /opt/py3 \

View File

@@ -13,7 +13,7 @@ ARG TOOLS=" \
nmap \ nmap \
telnet \ telnet \
vim \ vim \
postgresql-client-13 \ postgresql-client \
wget \ wget \
poppler-utils" poppler-utils"

View File

@@ -1,11 +0,0 @@
FROM python:3.11-slim-bullseye
ARG TARGETARCH
# Install APT dependencies
ENV DEBIAN_FRONTEND=noninteractive
RUN set -eux; \
apt-get update; \
apt-get -y --no-install-recommends upgrade; \
rm -rf /var/lib/apt/lists/*
# upgrade pip and setuptools
RUN pip install --no-cache-dir --upgrade pip setuptools wheel

View File

@@ -77,7 +77,8 @@ JumpServer consists of multiple key components, which collectively form the func
| [Luna](https://github.com/jumpserver/luna) | <a href="https://github.com/jumpserver/luna/releases"><img alt="Luna release" src="https://img.shields.io/github/release/jumpserver/luna.svg" /></a> | JumpServer Web Terminal | | [Luna](https://github.com/jumpserver/luna) | <a href="https://github.com/jumpserver/luna/releases"><img alt="Luna release" src="https://img.shields.io/github/release/jumpserver/luna.svg" /></a> | JumpServer Web Terminal |
| [KoKo](https://github.com/jumpserver/koko) | <a href="https://github.com/jumpserver/koko/releases"><img alt="Koko release" src="https://img.shields.io/github/release/jumpserver/koko.svg" /></a> | JumpServer Character Protocol Connector | | [KoKo](https://github.com/jumpserver/koko) | <a href="https://github.com/jumpserver/koko/releases"><img alt="Koko release" src="https://img.shields.io/github/release/jumpserver/koko.svg" /></a> | JumpServer Character Protocol Connector |
| [Lion](https://github.com/jumpserver/lion) | <a href="https://github.com/jumpserver/lion/releases"><img alt="Lion release" src="https://img.shields.io/github/release/jumpserver/lion.svg" /></a> | JumpServer Graphical Protocol Connector | | [Lion](https://github.com/jumpserver/lion) | <a href="https://github.com/jumpserver/lion/releases"><img alt="Lion release" src="https://img.shields.io/github/release/jumpserver/lion.svg" /></a> | JumpServer Graphical Protocol Connector |
| [Chen](https://github.com/jumpserver/chen) | <a href="https://github.com/jumpserver/chen/releases"><img alt="Chen release" src="https://img.shields.io/github/release/jumpserver/chen.svg" /> | JumpServer Web DB | | [Chen](https://github.com/jumpserver/chen) | <a href="https://github.com/jumpserver/chen/releases"><img alt="Chen release" src="https://img.shields.io/github/release/jumpserver/chen.svg" /> | JumpServer Web DB
| [Client](https://github.com/jumpserver/clients) | <a href="https://github.com/jumpserver/clients/releases"><img alt="Clients release" src="https://img.shields.io/github/release/jumpserver/clients.svg" /> | JumpServer Client |
| [Tinker](https://github.com/jumpserver/tinker) | <img alt="Tinker" src="https://img.shields.io/badge/release-private-red" /> | JumpServer Remote Application Connector (Windows) | | [Tinker](https://github.com/jumpserver/tinker) | <img alt="Tinker" src="https://img.shields.io/badge/release-private-red" /> | JumpServer Remote Application Connector (Windows) |
| [Panda](https://github.com/jumpserver/Panda) | <img alt="Panda" src="https://img.shields.io/badge/release-private-red" /> | JumpServer EE Remote Application Connector (Linux) | | [Panda](https://github.com/jumpserver/Panda) | <img alt="Panda" src="https://img.shields.io/badge/release-private-red" /> | JumpServer EE Remote Application Connector (Linux) |
| [Razor](https://github.com/jumpserver/razor) | <img alt="Chen" src="https://img.shields.io/badge/release-private-red" /> | JumpServer EE RDP Proxy Connector | | [Razor](https://github.com/jumpserver/razor) | <img alt="Chen" src="https://img.shields.io/badge/release-private-red" /> | JumpServer EE RDP Proxy Connector |

View File

@@ -1,10 +1,11 @@
from django.db import transaction from django.db import transaction
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from rest_framework import serializers as drf_serializers
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.generics import ListAPIView, CreateAPIView from rest_framework.generics import ListAPIView, CreateAPIView
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.status import HTTP_200_OK from rest_framework.status import HTTP_200_OK, HTTP_400_BAD_REQUEST
from accounts import serializers from accounts import serializers
from accounts.const import ChangeSecretRecordStatusChoice from accounts.const import ChangeSecretRecordStatusChoice
@@ -184,12 +185,66 @@ class AssetAccountBulkCreateApi(CreateAPIView):
'POST': 'accounts.add_account', 'POST': 'accounts.add_account',
} }
@staticmethod
def get_all_assets(base_payload: dict):
nodes = base_payload.pop('nodes', [])
asset_ids = base_payload.pop('assets', [])
nodes = Node.objects.filter(id__in=nodes).only('id', 'key')
node_asset_ids = Node.get_nodes_all_assets(*nodes).values_list('id', flat=True)
asset_ids = set(asset_ids + list(node_asset_ids))
return Asset.objects.filter(id__in=asset_ids)
def create(self, request, *args, **kwargs): def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data) if hasattr(request.data, "copy"):
serializer.is_valid(raise_exception=True) base_payload = request.data.copy()
data = serializer.create(serializer.validated_data) else:
serializer = serializers.AssetAccountBulkSerializerResultSerializer(data, many=True) base_payload = dict(request.data)
return Response(data=serializer.data, status=HTTP_200_OK)
templates = base_payload.pop("template", None)
assets = self.get_all_assets(base_payload)
if not assets.exists():
error = _("No valid assets found for account creation.")
return Response(
data={
"detail": error,
"code": "no_valid_assets"
},
status=HTTP_400_BAD_REQUEST
)
result = []
errors = []
def handle_one(_payload):
try:
ser = self.get_serializer(data=_payload)
ser.is_valid(raise_exception=True)
data = ser.bulk_create(ser.validated_data, assets)
if isinstance(data, (list, tuple)):
result.extend(data)
else:
result.append(data)
except drf_serializers.ValidationError as e:
errors.extend(list(e.detail))
except Exception as e:
errors.extend([str(e)])
if not templates:
handle_one(base_payload)
else:
if not isinstance(templates, (list, tuple)):
templates = [templates]
for tpl in templates:
payload = dict(base_payload)
payload["template"] = tpl
handle_one(payload)
if errors:
raise drf_serializers.ValidationError(errors)
out_ser = serializers.AssetAccountBulkSerializerResultSerializer(result, many=True)
return Response(data=out_ser.data, status=HTTP_200_OK)
class AccountHistoriesSecretAPI(ExtraFilterFieldsMixin, AccountRecordViewLogMixin, ListAPIView): class AccountHistoriesSecretAPI(ExtraFilterFieldsMixin, AccountRecordViewLogMixin, ListAPIView):

View File

@@ -14,7 +14,7 @@ from accounts.models import Account, AccountTemplate, GatheredAccount
from accounts.tasks import push_accounts_to_assets_task from accounts.tasks import push_accounts_to_assets_task
from assets.const import Category, AllTypes from assets.const import Category, AllTypes
from assets.models import Asset from assets.models import Asset
from common.serializers import SecretReadableMixin from common.serializers import SecretReadableMixin, CommonBulkModelSerializer
from common.serializers.fields import ObjectRelatedField, LabeledChoiceField from common.serializers.fields import ObjectRelatedField, LabeledChoiceField
from common.utils import get_logger from common.utils import get_logger
from .base import BaseAccountSerializer, AuthValidateMixin from .base import BaseAccountSerializer, AuthValidateMixin
@@ -292,27 +292,27 @@ class AccountDetailSerializer(AccountSerializer):
class AssetAccountBulkSerializerResultSerializer(serializers.Serializer): class AssetAccountBulkSerializerResultSerializer(serializers.Serializer):
asset = serializers.CharField(read_only=True, label=_('Asset')) asset = serializers.CharField(read_only=True, label=_('Asset'))
account = serializers.CharField(read_only=True, label=_('Account'))
state = serializers.CharField(read_only=True, label=_('State')) state = serializers.CharField(read_only=True, label=_('State'))
error = serializers.CharField(read_only=True, label=_('Error')) error = serializers.CharField(read_only=True, label=_('Error'))
changed = serializers.BooleanField(read_only=True, label=_('Changed')) changed = serializers.BooleanField(read_only=True, label=_('Changed'))
class AssetAccountBulkSerializer( class AssetAccountBulkSerializer(
AccountCreateUpdateSerializerMixin, AuthValidateMixin, serializers.ModelSerializer AccountCreateUpdateSerializerMixin, AuthValidateMixin, CommonBulkModelSerializer
): ):
su_from_username = serializers.CharField( su_from_username = serializers.CharField(
max_length=128, required=False, write_only=True, allow_null=True, label=_("Su from"), max_length=128, required=False, write_only=True, allow_null=True, label=_("Su from"),
allow_blank=True, allow_blank=True,
) )
assets = serializers.PrimaryKeyRelatedField(queryset=Asset.objects, many=True, label=_('Assets'))
class Meta: class Meta:
model = Account model = Account
fields = [ fields = [
'name', 'username', 'secret', 'secret_type', 'secret_reset', 'name', 'username', 'secret', 'secret_type', 'secret_reset',
'passphrase', 'privileged', 'is_active', 'comment', 'template', 'passphrase', 'privileged', 'is_active', 'comment', 'template',
'on_invalid', 'push_now', 'params', 'assets', 'su_from_username', 'on_invalid', 'push_now', 'params',
'source', 'source_id', 'su_from_username', 'source', 'source_id',
] ]
extra_kwargs = { extra_kwargs = {
'name': {'required': False}, 'name': {'required': False},
@@ -393,8 +393,7 @@ class AssetAccountBulkSerializer(
handler = self._handle_err_create handler = self._handle_err_create
return handler return handler
def perform_bulk_create(self, vd): def perform_bulk_create(self, vd, assets):
assets = vd.pop('assets')
on_invalid = vd.pop('on_invalid', 'skip') on_invalid = vd.pop('on_invalid', 'skip')
secret_type = vd.get('secret_type', 'password') secret_type = vd.get('secret_type', 'password')
@@ -402,8 +401,7 @@ class AssetAccountBulkSerializer(
vd['name'] = vd.get('username') vd['name'] = vd.get('username')
create_handler = self.get_create_handler(on_invalid) create_handler = self.get_create_handler(on_invalid)
asset_ids = [asset.id for asset in assets] secret_type_supports = Asset.get_secret_type_assets(assets, secret_type)
secret_type_supports = Asset.get_secret_type_assets(asset_ids, secret_type)
_results = {} _results = {}
for asset in assets: for asset in assets:
@@ -411,6 +409,7 @@ class AssetAccountBulkSerializer(
_results[asset] = { _results[asset] = {
'error': _('Asset does not support this secret type: %s') % secret_type, 'error': _('Asset does not support this secret type: %s') % secret_type,
'state': 'error', 'state': 'error',
'account': vd['name'],
} }
continue continue
@@ -420,13 +419,13 @@ class AssetAccountBulkSerializer(
self.clean_auth_fields(vd) self.clean_auth_fields(vd)
instance, changed, state = self.perform_create(vd, create_handler) instance, changed, state = self.perform_create(vd, create_handler)
_results[asset] = { _results[asset] = {
'changed': changed, 'instance': instance.id, 'state': state 'changed': changed, 'instance': instance.id, 'state': state, 'account': vd['name']
} }
except serializers.ValidationError as e: except serializers.ValidationError as e:
_results[asset] = {'error': e.detail[0], 'state': 'error'} _results[asset] = {'error': e.detail[0], 'state': 'error', 'account': vd['name']}
except Exception as e: except Exception as e:
logger.exception(e) logger.exception(e)
_results[asset] = {'error': str(e), 'state': 'error'} _results[asset] = {'error': str(e), 'state': 'error', 'account': vd['name']}
results = [{'asset': asset, **result} for asset, result in _results.items()] results = [{'asset': asset, **result} for asset, result in _results.items()]
state_score = {'created': 3, 'updated': 2, 'skipped': 1, 'error': 0} state_score = {'created': 3, 'updated': 2, 'skipped': 1, 'error': 0}
@@ -443,7 +442,8 @@ class AssetAccountBulkSerializer(
errors.append({ errors.append({
'error': _('Account has exist'), 'error': _('Account has exist'),
'state': 'error', 'state': 'error',
'asset': str(result['asset']) 'asset': str(result['asset']),
'account': result.get('account'),
}) })
if errors: if errors:
raise serializers.ValidationError(errors) raise serializers.ValidationError(errors)
@@ -462,10 +462,16 @@ class AssetAccountBulkSerializer(
account_ids = [str(_id) for _id in accounts.values_list('id', flat=True)] account_ids = [str(_id) for _id in accounts.values_list('id', flat=True)]
push_accounts_to_assets_task.delay(account_ids, params) push_accounts_to_assets_task.delay(account_ids, params)
def create(self, validated_data): def bulk_create(self, validated_data, assets):
if not assets:
raise serializers.ValidationError(
{'assets': _('At least one asset or node must be specified')},
{'nodes': _('At least one asset or node must be specified')}
)
params = validated_data.pop('params', None) params = validated_data.pop('params', None)
push_now = validated_data.pop('push_now', False) push_now = validated_data.pop('push_now', False)
results = self.perform_bulk_create(validated_data) results = self.perform_bulk_create(validated_data, assets)
self.push_accounts_if_need(results, push_now, params) self.push_accounts_if_need(results, push_now, params)
for res in results: for res in results:
res['asset'] = str(res['asset']) res['asset'] = str(res['asset'])

View File

@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# #
from collections import defaultdict
from django.conf import settings from django.conf import settings
from django.db import transaction
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django_filters import rest_framework as drf_filters from django_filters import rest_framework as drf_filters
@@ -181,33 +180,18 @@ class AssetViewSet(SuggestionMixin, BaseAssetViewSet):
def sync_platform_protocols(self, request, *args, **kwargs): def sync_platform_protocols(self, request, *args, **kwargs):
platform_id = request.data.get('platform_id') platform_id = request.data.get('platform_id')
platform = get_object_or_404(Platform, pk=platform_id) platform = get_object_or_404(Platform, pk=platform_id)
assets = platform.assets.all() asset_ids = list(platform.assets.values_list('id', flat=True))
platform_protocols = list(platform.protocols.values('name', 'port'))
platform_protocols = { with transaction.atomic():
p['name']: p['port'] if asset_ids:
for p in platform.protocols.values('name', 'port') Protocol.objects.filter(asset_id__in=asset_ids).delete()
} if asset_ids and platform_protocols:
asset_protocols_map = defaultdict(set) objs = []
protocols = assets.prefetch_related('protocols').values_list( for aid in asset_ids:
'id', 'protocols__name' for p in platform_protocols:
) objs.append(Protocol(name=p['name'], port=p['port'], asset_id=aid))
for asset_id, protocol in protocols: Protocol.objects.bulk_create(objs)
asset_id = str(asset_id)
asset_protocols_map[asset_id].add(protocol)
objs = []
for asset_id, protocols in asset_protocols_map.items():
protocol_names = set(platform_protocols) - protocols
if not protocol_names:
continue
for name in protocol_names:
objs.append(
Protocol(
name=name,
port=platform_protocols[name],
asset_id=asset_id,
)
)
Protocol.objects.bulk_create(objs)
return Response(status=status.HTTP_200_OK) return Response(status=status.HTTP_200_OK)
def filter_bulk_update_data(self): def filter_bulk_update_data(self):

View File

@@ -268,6 +268,14 @@ class Protocol(ChoicesMixin, models.TextChoices):
'port_from_addr': True, 'port_from_addr': True,
'required': True, 'required': True,
'secret_types': ['token'], 'secret_types': ['token'],
'setting': {
'namespace': {
'type': 'str',
'required': False,
'default': '',
'label': _('Namespace')
}
}
}, },
cls.http: { cls.http: {
'port': 80, 'port': 80,

View File

@@ -408,8 +408,7 @@ class Asset(NodesRelationMixin, LabeledMixin, AbsConnectivity, JSONFilterMixin,
return tree_node return tree_node
@staticmethod @staticmethod
def get_secret_type_assets(asset_ids, secret_type): def get_secret_type_assets(assets, secret_type):
assets = Asset.objects.filter(id__in=asset_ids)
asset_protocol = assets.prefetch_related('protocols').values_list('id', 'protocols__name') asset_protocol = assets.prefetch_related('protocols').values_list('id', 'protocols__name')
protocol_secret_types_map = const.Protocol.protocol_secret_types() protocol_secret_types_map = const.Protocol.protocol_secret_types()
asset_secret_types_mapp = defaultdict(set) asset_secret_types_mapp = defaultdict(set)

View File

@@ -28,7 +28,8 @@ class MyAsset(JMSBaseModel):
@staticmethod @staticmethod
def set_asset_custom_value(assets, user): def set_asset_custom_value(assets, user):
my_assets = MyAsset.objects.filter(asset__in=assets, user=user).all() asset_ids = [asset.id for asset in assets]
my_assets = MyAsset.objects.filter(asset_id__in=asset_ids, user=user).all()
customs = {my_asset.asset.id: my_asset.custom_to_dict() for my_asset in my_assets} customs = {my_asset.asset.id: my_asset.custom_to_dict() for my_asset in my_assets}
for asset in assets: for asset in assets:
custom = customs.get(asset.id) custom = customs.get(asset.id)

View File

@@ -150,6 +150,7 @@ class AssetSerializer(BulkOrgResourceModelSerializer, ResourceLabelsMixin, Writa
auto_config = serializers.DictField(read_only=True, label=_('Auto info')) auto_config = serializers.DictField(read_only=True, label=_('Auto info'))
platform = ObjectRelatedField(queryset=Platform.objects, required=True, label=_('Platform'), platform = ObjectRelatedField(queryset=Platform.objects, required=True, label=_('Platform'),
attrs=('id', 'name', 'type')) attrs=('id', 'name', 'type'))
spec_info = serializers.DictField(read_only=True, label=_('Spec info'))
accounts_amount = serializers.IntegerField(read_only=True, label=_('Accounts amount')) accounts_amount = serializers.IntegerField(read_only=True, label=_('Accounts amount'))
_accounts = None _accounts = None
@@ -164,7 +165,8 @@ class AssetSerializer(BulkOrgResourceModelSerializer, ResourceLabelsMixin, Writa
'directory_services', 'directory_services',
] ]
read_only_fields = [ read_only_fields = [
'accounts_amount', 'category', 'type', 'connectivity', 'auto_config', 'accounts_amount', 'category', 'type', 'connectivity',
'auto_config', 'spec_info',
'date_verified', 'created_by', 'date_created', 'date_updated', 'date_verified', 'created_by', 'date_created', 'date_updated',
] ]
fields = fields_small + fields_fk + fields_m2m + read_only_fields fields = fields_small + fields_fk + fields_m2m + read_only_fields
@@ -186,6 +188,7 @@ class AssetSerializer(BulkOrgResourceModelSerializer, ResourceLabelsMixin, Writa
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self._init_field_choices() self._init_field_choices()
self._extract_accounts() self._extract_accounts()
self._set_platform()
def _extract_accounts(self): def _extract_accounts(self):
if not getattr(self, 'initial_data', None): if not getattr(self, 'initial_data', None):
@@ -217,6 +220,21 @@ class AssetSerializer(BulkOrgResourceModelSerializer, ResourceLabelsMixin, Writa
protocols_data = [{'name': p.name, 'port': p.port} for p in protocols] protocols_data = [{'name': p.name, 'port': p.port} for p in protocols]
self.initial_data['protocols'] = protocols_data self.initial_data['protocols'] = protocols_data
def _set_platform(self):
if not hasattr(self, 'initial_data'):
return
platform_id = self.initial_data.get('platform')
if not platform_id:
return
if isinstance(platform_id, int) or str(platform_id).isdigit() or not isinstance(platform_id, str):
return
platform = Platform.objects.filter(name=platform_id).first()
if not platform:
return
self.initial_data['platform'] = platform.id
def _init_field_choices(self): def _init_field_choices(self):
request = self.context.get('request') request = self.context.get('request')
if not request: if not request:
@@ -231,6 +249,19 @@ class AssetSerializer(BulkOrgResourceModelSerializer, ResourceLabelsMixin, Writa
return return
field_type.choices = AllTypes.filter_choices(category) field_type.choices = AllTypes.filter_choices(category)
@staticmethod
def get_spec_info(obj):
return {}
def get_auto_config(self, obj):
return obj.auto_config()
def get_gathered_info(self, obj):
return obj.gathered_info()
def get_accounts_amount(self, obj):
return obj.accounts_amount()
@classmethod @classmethod
def setup_eager_loading(cls, queryset): def setup_eager_loading(cls, queryset):
""" Perform necessary eager loading of data. """ """ Perform necessary eager loading of data. """
@@ -265,8 +296,10 @@ class AssetSerializer(BulkOrgResourceModelSerializer, ResourceLabelsMixin, Writa
if not platform_id and self.instance: if not platform_id and self.instance:
platform = self.instance.platform platform = self.instance.platform
else: elif isinstance(platform_id, int):
platform = Platform.objects.filter(id=platform_id).first() platform = Platform.objects.filter(id=platform_id).first()
else:
platform = Platform.objects.filter(name=platform_id).first()
if not platform: if not platform:
raise serializers.ValidationError({'platform': _("Platform not exist")}) raise serializers.ValidationError({'platform': _("Platform not exist")})
@@ -297,6 +330,7 @@ class AssetSerializer(BulkOrgResourceModelSerializer, ResourceLabelsMixin, Writa
def is_valid(self, raise_exception=False): def is_valid(self, raise_exception=False):
self._set_protocols_default() self._set_protocols_default()
self._set_platform()
return super().is_valid(raise_exception=raise_exception) return super().is_valid(raise_exception=raise_exception)
def validate_protocols(self, protocols_data): def validate_protocols(self, protocols_data):
@@ -422,7 +456,7 @@ class AssetSerializer(BulkOrgResourceModelSerializer, ResourceLabelsMixin, Writa
class DetailMixin(serializers.Serializer): class DetailMixin(serializers.Serializer):
accounts = AssetAccountSerializer(many=True, required=False, label=_('Accounts')) accounts = AssetAccountSerializer(many=True, required=False, label=_('Accounts'))
spec_info = MethodSerializer(label=_('Spec info'), read_only=True) spec_info = MethodSerializer(label=_('Spec info'), read_only=True, required=False)
gathered_info = MethodSerializer(label=_('Gathered info'), read_only=True) gathered_info = MethodSerializer(label=_('Gathered info'), read_only=True)
auto_config = serializers.DictField(read_only=True, label=_('Auto info')) auto_config = serializers.DictField(read_only=True, label=_('Auto info'))

View File

@@ -84,6 +84,7 @@ class PlatformAutomationSerializer(serializers.ModelSerializer):
class PlatformProtocolSerializer(serializers.ModelSerializer): class PlatformProtocolSerializer(serializers.ModelSerializer):
setting = MethodSerializer(required=False, label=_("Setting")) setting = MethodSerializer(required=False, label=_("Setting"))
port_from_addr = serializers.BooleanField(label=_("Port from addr"), read_only=True) port_from_addr = serializers.BooleanField(label=_("Port from addr"), read_only=True)
port = serializers.IntegerField(label=_("Port"), required=False, min_value=0, max_value=65535)
class Meta: class Meta:
model = PlatformProtocol model = PlatformProtocol

View File

@@ -43,7 +43,7 @@ from .serializers import (
OperateLogSerializer, OperateLogActionDetailSerializer, OperateLogSerializer, OperateLogActionDetailSerializer,
PasswordChangeLogSerializer, ActivityUnionLogSerializer, PasswordChangeLogSerializer, ActivityUnionLogSerializer,
FileSerializer, UserSessionSerializer, JobsAuditSerializer, FileSerializer, UserSessionSerializer, JobsAuditSerializer,
ServiceAccessLogSerializer ServiceAccessLogSerializer, OperateLogFullSerializer
) )
from .utils import construct_userlogin_usernames, record_operate_log_and_activity_log from .utils import construct_userlogin_usernames, record_operate_log_and_activity_log
@@ -256,7 +256,9 @@ class OperateLogViewSet(OrgReadonlyModelViewSet):
def get_serializer_class(self): def get_serializer_class(self):
if self.is_action_detail: if self.is_action_detail:
return OperateLogActionDetailSerializer return OperateLogActionDetailSerializer
return super().get_serializer_class() elif self.request.query_params.get('format'):
return OperateLogFullSerializer
return OperateLogSerializer
def get_queryset(self): def get_queryset(self):
current_org_id = str(current_org.id) current_org_id = str(current_org.id)

View File

@@ -23,6 +23,8 @@ logger = get_logger(__name__)
class OperatorLogHandler(metaclass=Singleton): class OperatorLogHandler(metaclass=Singleton):
CACHE_KEY = 'OPERATOR_LOG_CACHE_KEY' CACHE_KEY = 'OPERATOR_LOG_CACHE_KEY'
SYSTEM_OBJECTS = frozenset({"Role"})
PREFER_CURRENT_ELSE_USER = frozenset({"SSOToken"})
def __init__(self): def __init__(self):
self.log_client = self.get_storage_client() self.log_client = self.get_storage_client()
@@ -142,13 +144,21 @@ class OperatorLogHandler(metaclass=Singleton):
after = self.__data_processing(after) after = self.__data_processing(after)
return before, after return before, after
@staticmethod def get_org_id(self, user, object_name):
def get_org_id(object_name): if object_name in self.SYSTEM_OBJECTS:
system_obj = ('Role',) return Organization.SYSTEM_ID
org_id = get_current_org_id()
if object_name in system_obj: current = get_current_org_id()
org_id = Organization.SYSTEM_ID current_id = str(current) if current else None
return org_id
if object_name in self.PREFER_CURRENT_ELSE_USER:
if current_id and current_id != Organization.DEFAULT_ID:
return current_id
org = user.orgs.distinct().first()
return str(org.id) if org else Organization.DEFAULT_ID
return current_id or Organization.DEFAULT_ID
def create_or_update_operate_log( def create_or_update_operate_log(
self, action, resource_type, resource=None, resource_display=None, self, action, resource_type, resource=None, resource_display=None,
@@ -168,7 +178,7 @@ class OperatorLogHandler(metaclass=Singleton):
# 前后都没变化,没必要生成日志,除非手动强制保存 # 前后都没变化,没必要生成日志,除非手动强制保存
return return
org_id = self.get_org_id(object_name) org_id = self.get_org_id(user, object_name)
data = { data = {
'id': log_id, "user": str(user), 'action': action, 'id': log_id, "user": str(user), 'action': action,
'resource_type': str(resource_type), 'org_id': org_id, 'resource_type': str(resource_type), 'org_id': org_id,

View File

@@ -127,6 +127,21 @@ class OperateLogSerializer(BulkOrgResourceModelSerializer):
return i18n_trans(instance.resource) return i18n_trans(instance.resource)
class DiffFieldSerializer(serializers.JSONField):
def to_file_representation(self, value):
row = getattr(self, '_row') or {}
attrs = {'diff': value, 'resource_type': row.get('resource_type')}
instance = type('OperateLog', (), attrs)
return OperateLogStore.convert_diff_friendly(instance)
class OperateLogFullSerializer(OperateLogSerializer):
diff = DiffFieldSerializer(label=_("Diff"))
class Meta(OperateLogSerializer.Meta):
fields = OperateLogSerializer.Meta.fields + ['diff']
class PasswordChangeLogSerializer(serializers.ModelSerializer): class PasswordChangeLogSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = models.PasswordChangeLog model = models.PasswordChangeLog

View File

@@ -47,20 +47,21 @@ def on_m2m_changed(sender, action, instance, reverse, model, pk_set, **kwargs):
objs = model.objects.filter(pk__in=pk_set) objs = model.objects.filter(pk__in=pk_set)
objs_display = [str(o) for o in objs] objs_display = [str(o) for o in objs]
action = M2M_ACTION[action] action = M2M_ACTION[action]
changed_field = current_instance.get(field_name, []) changed_field = current_instance.get(field_name, {})
changed_value = changed_field.get('value', [])
after, before, before_value = None, None, None after, before, before_value = None, None, None
if action == ActionChoices.create: if action == ActionChoices.create:
before_value = list(set(changed_field) - set(objs_display)) before_value = list(set(changed_value) - set(objs_display))
elif action == ActionChoices.delete: elif action == ActionChoices.delete:
before_value = list( before_value = list(set(changed_value).symmetric_difference(set(objs_display)))
set(changed_field).symmetric_difference(set(objs_display))
)
if changed_field: if changed_field:
after = {field_name: changed_field} after = {field_name: changed_field}
if before_value: if before_value:
before = {field_name: before_value} before_change_field = changed_field.copy()
before_change_field['value'] = before_value
before = {field_name: before_change_field}
if sorted(str(before)) == sorted(str(after)): if sorted(str(before)) == sorted(str(after)):
return return

View File

@@ -16,3 +16,4 @@ from .sso import *
from .temp_token import * from .temp_token import *
from .token import * from .token import *
from .face import * from .face import *
from .access_token import *

View File

@@ -0,0 +1,47 @@
from django.shortcuts import get_object_or_404
from django.utils.translation import gettext as _
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.status import HTTP_204_NO_CONTENT, HTTP_404_NOT_FOUND
from oauth2_provider.models import get_access_token_model
from common.api import JMSModelViewSet
from rbac.permissions import RBACPermission
from ..serializers import AccessTokenSerializer
AccessToken = get_access_token_model()
class AccessTokenViewSet(JMSModelViewSet):
"""
OAuth2 Access Token 管理视图集
用户只能查看和撤销自己的 access token
"""
serializer_class = AccessTokenSerializer
permission_classes = [RBACPermission]
http_method_names = ['get', 'options', 'delete']
rbac_perms = {
'revoke': 'oauth2_provider.delete_accesstoken',
}
def get_queryset(self):
"""只返回当前用户的 access token按创建时间倒序"""
return AccessToken.objects.filter(user=self.request.user).order_by('-created')
@action(methods=['DELETE'], detail=True, url_path='revoke')
def revoke(self, request, *args, **kwargs):
"""
撤销 access token 及其关联的 refresh token
如果 token 不存在或不属于当前用户,返回 404
"""
token = get_object_or_404(
AccessToken.objects.filter(user=request.user),
id=kwargs['pk']
)
# 优先撤销 refresh token会自动撤销关联的 access token
token_to_revoke = token.refresh_token if token.refresh_token else token
token_to_revoke.revoke()
return Response(status=HTTP_204_NO_CONTENT)

View File

@@ -1,6 +1,5 @@
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.backends import ModelBackend from django.contrib.auth.backends import ModelBackend
from django.views import View
from common.utils import get_logger from common.utils import get_logger
from users.models import User from users.models import User
@@ -66,11 +65,3 @@ class JMSBaseAuthBackend:
class JMSModelBackend(JMSBaseAuthBackend, ModelBackend): class JMSModelBackend(JMSBaseAuthBackend, ModelBackend):
def user_can_authenticate(self, user): def user_can_authenticate(self, user):
return True return True
class BaseAuthCallbackClientView(View):
http_method_names = ['get']
def get(self, request):
from authentication.views.utils import redirect_to_guard_view
return redirect_to_guard_view(query_string='next=client')

View File

@@ -1,51 +1,22 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# #
import threading
from django.conf import settings from django.conf import settings
from django.contrib.auth import get_user_model
from django_cas_ng.backends import CASBackend as _CASBackend from django_cas_ng.backends import CASBackend as _CASBackend
from common.utils import get_logger from common.utils import get_logger
from ..base import JMSBaseAuthBackend from ..base import JMSBaseAuthBackend
__all__ = ['CASBackend', 'CASUserDoesNotExist'] __all__ = ['CASBackend']
logger = get_logger(__name__) logger = get_logger(__name__)
class CASUserDoesNotExist(Exception):
"""Exception raised when a CAS user does not exist."""
pass
class CASBackend(JMSBaseAuthBackend, _CASBackend): class CASBackend(JMSBaseAuthBackend, _CASBackend):
@staticmethod @staticmethod
def is_enabled(): def is_enabled():
return settings.AUTH_CAS return settings.AUTH_CAS
def authenticate(self, request, ticket, service): def authenticate(self, request, ticket, service):
UserModel = get_user_model() # 这里做个hack ,让父类始终走CAS_CREATE_USER=True的逻辑然后调用 authentication/mixins.py 中的 custom_get_or_create 方法
manager = UserModel._default_manager settings.CAS_CREATE_USER = True
original_get_by_natural_key = manager.get_by_natural_key return super().authenticate(request, ticket, service)
thread_local = threading.local()
thread_local.thread_id = threading.get_ident()
logger.debug(f"CASBackend.authenticate: thread_id={thread_local.thread_id}")
def get_by_natural_key(self, username):
logger.debug(f"CASBackend.get_by_natural_key: thread_id={threading.get_ident()}, username={username}")
if threading.get_ident() != thread_local.thread_id:
return original_get_by_natural_key(username)
try:
user = original_get_by_natural_key(username)
except UserModel.DoesNotExist:
raise CASUserDoesNotExist(username)
return user
try:
manager.get_by_natural_key = get_by_natural_key.__get__(manager, type(manager))
user = super().authenticate(request, ticket=ticket, service=service)
finally:
manager.get_by_natural_key = original_get_by_natural_key
return user

View File

@@ -3,11 +3,10 @@
import django_cas_ng.views import django_cas_ng.views
from django.urls import path from django.urls import path
from .views import CASLoginView, CASCallbackClientView from .views import CASLoginView
urlpatterns = [ urlpatterns = [
path('login/', CASLoginView.as_view(), name='cas-login'), path('login/', CASLoginView.as_view(), name='cas-login'),
path('logout/', django_cas_ng.views.LogoutView.as_view(), name='cas-logout'), path('logout/', django_cas_ng.views.LogoutView.as_view(), name='cas-logout'),
path('callback/', django_cas_ng.views.CallbackView.as_view(), name='cas-proxy-callback'), path('callback/', django_cas_ng.views.CallbackView.as_view(), name='cas-proxy-callback')
path('login/client', CASCallbackClientView.as_view(), name='cas-proxy-callback-client'),
] ]

View File

@@ -3,31 +3,20 @@ from django.http import HttpResponseRedirect
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_cas_ng.views import LoginView from django_cas_ng.views import LoginView
from authentication.backends.base import BaseAuthCallbackClientView from authentication.views.mixins import FlashMessageMixin
from common.utils import FlashMessageUtil
from .backends import CASUserDoesNotExist
__all__ = ['LoginView'] __all__ = ['LoginView']
class CASLoginView(LoginView): class CASLoginView(LoginView, FlashMessageMixin):
def get(self, request): def get(self, request):
try: try:
resp = super().get(request) resp = super().get(request)
return resp
except PermissionDenied: except PermissionDenied:
return HttpResponseRedirect('/') resp = HttpResponseRedirect('/')
except CASUserDoesNotExist as e: error_message = getattr(request, 'error_message', '')
message_data = { if error_message:
'title': _('User does not exist: {}').format(e), response = self.get_failed_response('/', title=_('CAS Error'), msg=error_message)
'error': _( return response
'CAS login was successful, but no corresponding local user was found in the system, and automatic ' else:
'user creation is disabled in the CAS authentication configuration. Login failed.'), return resp
'interval': 10,
'redirect_url': '/',
}
return FlashMessageUtil.gen_and_redirect_to(message_data)
class CASCallbackClientView(BaseAuthCallbackClientView):
pass

View File

@@ -69,6 +69,8 @@ class AccessTokenAuthentication(authentication.BaseAuthentication):
msg = _('Invalid token header. Sign string should not contain invalid characters.') msg = _('Invalid token header. Sign string should not contain invalid characters.')
raise exceptions.AuthenticationFailed(msg) raise exceptions.AuthenticationFailed(msg)
user, header = self.authenticate_credentials(token) user, header = self.authenticate_credentials(token)
if not user:
return None
after_authenticate_update_date(user) after_authenticate_update_date(user)
return user, header return user, header
@@ -77,10 +79,6 @@ class AccessTokenAuthentication(authentication.BaseAuthentication):
model = get_user_model() model = get_user_model()
user_id = cache.get(token) user_id = cache.get(token)
user = get_object_or_none(model, id=user_id) user = get_object_or_none(model, id=user_id)
if not user:
msg = _('Invalid token or cache refreshed.')
raise exceptions.AuthenticationFailed(msg)
return user, None return user, None
def authenticate_header(self, request): def authenticate_header(self, request):

View File

@@ -7,6 +7,5 @@ from . import views
urlpatterns = [ urlpatterns = [
path('login/', views.OAuth2AuthRequestView.as_view(), name='login'), path('login/', views.OAuth2AuthRequestView.as_view(), name='login'),
path('callback/', views.OAuth2AuthCallbackView.as_view(), name='login-callback'), path('callback/', views.OAuth2AuthCallbackView.as_view(), name='login-callback'),
path('callback/client/', views.OAuth2AuthCallbackClientView.as_view(), name='login-callback-client'),
path('logout/', views.OAuth2EndSessionView.as_view(), name='logout') path('logout/', views.OAuth2EndSessionView.as_view(), name='logout')
] ]

View File

@@ -3,29 +3,37 @@ from django.contrib import auth
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from django.urls import reverse from django.urls import reverse
from django.utils.http import urlencode from django.utils.http import urlencode
from django.utils.translation import gettext_lazy as _
from django.views import View from django.views import View
from authentication.backends.base import BaseAuthCallbackClientView from authentication.decorators import pre_save_next_to_session, redirect_to_pre_save_next_after_auth
from authentication.mixins import authenticate from authentication.mixins import authenticate
from authentication.utils import build_absolute_uri from authentication.utils import build_absolute_uri
from authentication.views.mixins import FlashMessageMixin from authentication.views.mixins import FlashMessageMixin
from common.utils import get_logger from common.utils import get_logger, safe_next_url
logger = get_logger(__file__) logger = get_logger(__file__)
class OAuth2AuthRequestView(View): class OAuth2AuthRequestView(View):
@pre_save_next_to_session()
def get(self, request): def get(self, request):
log_prompt = "Process OAuth2 GET requests: {}" log_prompt = "Process OAuth2 GET requests: {}"
logger.debug(log_prompt.format('Start')) logger.debug(log_prompt.format('Start'))
request_params = request.GET.dict()
request_params.pop('next', None)
query = urlencode(request_params)
redirect_uri = build_absolute_uri(
request, path=reverse(settings.AUTH_OAUTH2_AUTH_LOGIN_CALLBACK_URL_NAME)
)
redirect_uri = f"{redirect_uri}?{query}"
query_dict = { query_dict = {
'client_id': settings.AUTH_OAUTH2_CLIENT_ID, 'response_type': 'code', 'client_id': settings.AUTH_OAUTH2_CLIENT_ID, 'response_type': 'code',
'scope': settings.AUTH_OAUTH2_SCOPE, 'scope': settings.AUTH_OAUTH2_SCOPE,
'redirect_uri': build_absolute_uri( 'redirect_uri': redirect_uri
request, path=reverse(settings.AUTH_OAUTH2_AUTH_LOGIN_CALLBACK_URL_NAME)
)
} }
if '?' in settings.AUTH_OAUTH2_PROVIDER_AUTHORIZATION_ENDPOINT: if '?' in settings.AUTH_OAUTH2_PROVIDER_AUTHORIZATION_ENDPOINT:
@@ -44,6 +52,7 @@ class OAuth2AuthRequestView(View):
class OAuth2AuthCallbackView(View, FlashMessageMixin): class OAuth2AuthCallbackView(View, FlashMessageMixin):
http_method_names = ['get', ] http_method_names = ['get', ]
@redirect_to_pre_save_next_after_auth
def get(self, request): def get(self, request):
""" Processes GET requests. """ """ Processes GET requests. """
log_prompt = "Process GET requests [OAuth2AuthCallbackView]: {}" log_prompt = "Process GET requests [OAuth2AuthCallbackView]: {}"
@@ -58,19 +67,17 @@ class OAuth2AuthCallbackView(View, FlashMessageMixin):
logger.debug(log_prompt.format('Login: {}'.format(user))) logger.debug(log_prompt.format('Login: {}'.format(user)))
auth.login(self.request, user) auth.login(self.request, user)
logger.debug(log_prompt.format('Redirect')) logger.debug(log_prompt.format('Redirect'))
return HttpResponseRedirect( return HttpResponseRedirect(settings.AUTH_OAUTH2_AUTHENTICATION_REDIRECT_URI)
settings.AUTH_OAUTH2_AUTHENTICATION_REDIRECT_URI else:
) if getattr(request, 'error_message', ''):
response = self.get_failed_response('/', title=_('OAuth2 Error'), msg=request.error_message)
return response
logger.debug(log_prompt.format('Redirect')) logger.debug(log_prompt.format('Redirect'))
redirect_url = settings.AUTH_OAUTH2_PROVIDER_END_SESSION_ENDPOINT or '/' redirect_url = settings.AUTH_OAUTH2_PROVIDER_END_SESSION_ENDPOINT or '/'
return HttpResponseRedirect(redirect_url) return HttpResponseRedirect(redirect_url)
class OAuth2AuthCallbackClientView(BaseAuthCallbackClientView):
pass
class OAuth2EndSessionView(View): class OAuth2EndSessionView(View):
http_method_names = ['get', 'post', ] http_method_names = ['get', 'post', ]

View File

@@ -0,0 +1,20 @@
from django.db.models.signals import post_delete
from django.dispatch import receiver
from django.core.cache import cache
from django.conf import settings
from oauth2_provider.models import get_application_model
from .utils import clear_oauth2_authorization_server_view_cache
__all__ = ['on_oauth2_provider_application_deleted']
Application = get_application_model()
@receiver(post_delete, sender=Application)
def on_oauth2_provider_application_deleted(sender, instance, **kwargs):
if instance.name == settings.OAUTH2_PROVIDER_JUMPSERVER_CLIENT_NAME:
clear_oauth2_authorization_server_view_cache()

View File

@@ -0,0 +1,14 @@
# -*- coding: utf-8 -*-
#
from django.urls import path
from oauth2_provider import views as op_views
from . import views
urlpatterns = [
path("authorize/", op_views.AuthorizationView.as_view(), name="authorize"),
path("token/", op_views.TokenView.as_view(), name="token"),
path("revoke/", op_views.RevokeTokenView.as_view(), name="revoke-token"),
path(".well-known/oauth-authorization-server", views.OAuthAuthorizationServerView.as_view(), name="oauth-authorization-server"),
]

View File

@@ -0,0 +1,31 @@
from django.conf import settings
from django.core.cache import cache
from oauth2_provider.models import get_application_model
from common.utils import get_logger
logger = get_logger(__name__)
def get_or_create_jumpserver_client_application():
"""Auto get or create OAuth2 JumpServer Client application."""
Application = get_application_model()
application, created = Application.objects.get_or_create(
name=settings.OAUTH2_PROVIDER_JUMPSERVER_CLIENT_NAME,
defaults={
'client_type': Application.CLIENT_PUBLIC,
'authorization_grant_type': Application.GRANT_AUTHORIZATION_CODE,
'redirect_uris': settings.OAUTH2_PROVIDER_CLIENT_REDIRECT_URI,
'skip_authorization': True,
}
)
return application
CACHE_OAUTH_SERVER_VIEW_KEY_PREFIX = 'oauth2_provider_metadata'
def clear_oauth2_authorization_server_view_cache():
logger.info("Clearing OAuth2 Authorization Server Metadata view cache")
cache_key = f'views.decorators.cache.cache_page.{CACHE_OAUTH_SERVER_VIEW_KEY_PREFIX}.GET*'
cache.delete_pattern(cache_key)

View File

@@ -0,0 +1,77 @@
from django.views.generic import View
from django.http import JsonResponse
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_page
from django.views.decorators.csrf import csrf_exempt
from django.conf import settings
from django.urls import reverse
from oauth2_provider.settings import oauth2_settings
from typing import List, Dict, Any
from .utils import get_or_create_jumpserver_client_application, CACHE_OAUTH_SERVER_VIEW_KEY_PREFIX
@method_decorator(csrf_exempt, name='dispatch')
@method_decorator(cache_page(timeout=60 * 60 * 24, key_prefix=CACHE_OAUTH_SERVER_VIEW_KEY_PREFIX), name='dispatch')
class OAuthAuthorizationServerView(View):
"""
OAuth 2.0 Authorization Server Metadata Endpoint
RFC 8414: https://datatracker.ietf.org/doc/html/rfc8414
This endpoint provides machine-readable information about the
OAuth 2.0 authorization server's configuration.
"""
def get_base_url(self, request) -> str:
scheme = 'https' if request.is_secure() else 'http'
host = request.get_host()
return f"{scheme}://{host}"
def get_supported_scopes(self) -> List[str]:
scopes_config = oauth2_settings.SCOPES
if isinstance(scopes_config, dict):
return list(scopes_config.keys())
return []
def get_metadata(self, request) -> Dict[str, Any]:
base_url = self.get_base_url(request)
application = get_or_create_jumpserver_client_application()
metadata = {
"issuer": base_url,
"client_id": application.client_id if application else "Not found any application.",
"authorization_endpoint": base_url + reverse('authentication:oauth2-provider:authorize'),
"token_endpoint": base_url + reverse('authentication:oauth2-provider:token'),
"revocation_endpoint": base_url + reverse('authentication:oauth2-provider:revoke-token'),
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"scopes_supported": self.get_supported_scopes(),
"token_endpoint_auth_methods_supported": ["none"],
"revocation_endpoint_auth_methods_supported": ["none"],
"code_challenge_methods_supported": ["S256"],
"response_modes_supported": ["query"],
}
if hasattr(oauth2_settings, 'ACCESS_TOKEN_EXPIRE_SECONDS'):
metadata["token_expires_in"] = oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS
if hasattr(oauth2_settings, 'REFRESH_TOKEN_EXPIRE_SECONDS'):
if oauth2_settings.REFRESH_TOKEN_EXPIRE_SECONDS:
metadata["refresh_token_expires_in"] = oauth2_settings.REFRESH_TOKEN_EXPIRE_SECONDS
return metadata
def get(self, request, *args, **kwargs):
metadata = self.get_metadata(request)
response = JsonResponse(metadata)
self.add_cors_headers(response)
return response
def options(self, request, *args, **kwargs):
response = JsonResponse({})
self.add_cors_headers(response)
return response
@staticmethod
def add_cors_headers(response):
response['Access-Control-Allow-Origin'] = '*'
response['Access-Control-Allow-Methods'] = 'GET, OPTIONS'
response['Access-Control-Allow-Headers'] = 'Content-Type, Authorization'
response['Access-Control-Max-Age'] = '3600'

View File

@@ -15,6 +15,5 @@ from . import views
urlpatterns = [ urlpatterns = [
path('login/', views.OIDCAuthRequestView.as_view(), name='login'), path('login/', views.OIDCAuthRequestView.as_view(), name='login'),
path('callback/', views.OIDCAuthCallbackView.as_view(), name='login-callback'), path('callback/', views.OIDCAuthCallbackView.as_view(), name='login-callback'),
path('callback/client/', views.OIDCAuthCallbackClientView.as_view(), name='login-callback-client'),
path('logout/', views.OIDCEndSessionView.as_view(), name='logout'), path('logout/', views.OIDCEndSessionView.as_view(), name='logout'),
] ]

View File

@@ -25,11 +25,11 @@ from django.utils.http import urlencode
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic import View from django.views.generic import View
from authentication.decorators import pre_save_next_to_session, redirect_to_pre_save_next_after_auth
from authentication.utils import build_absolute_uri_for_oidc from authentication.utils import build_absolute_uri_for_oidc
from authentication.views.mixins import FlashMessageMixin from authentication.views.mixins import FlashMessageMixin
from common.utils import safe_next_url from common.utils import safe_next_url
from .utils import get_logger from .utils import get_logger
from ..base import BaseAuthCallbackClientView
logger = get_logger(__file__) logger = get_logger(__file__)
@@ -58,6 +58,7 @@ class OIDCAuthRequestView(View):
b = base64.urlsafe_b64encode(h) b = base64.urlsafe_b64encode(h)
return b.decode('ascii')[:-1] return b.decode('ascii')[:-1]
@pre_save_next_to_session()
def get(self, request): def get(self, request):
""" Processes GET requests. """ """ Processes GET requests. """
@@ -66,8 +67,9 @@ class OIDCAuthRequestView(View):
# Defines common parameters used to bootstrap the authentication request. # Defines common parameters used to bootstrap the authentication request.
logger.debug(log_prompt.format('Construct request params')) logger.debug(log_prompt.format('Construct request params'))
authentication_request_params = request.GET.dict() request_params = request.GET.dict()
authentication_request_params.update({ request_params.pop('next', None)
request_params.update({
'scope': settings.AUTH_OPENID_SCOPES, 'scope': settings.AUTH_OPENID_SCOPES,
'response_type': 'code', 'response_type': 'code',
'client_id': settings.AUTH_OPENID_CLIENT_ID, 'client_id': settings.AUTH_OPENID_CLIENT_ID,
@@ -80,7 +82,7 @@ class OIDCAuthRequestView(View):
code_verifier = self.gen_code_verifier() code_verifier = self.gen_code_verifier()
code_challenge_method = settings.AUTH_OPENID_CODE_CHALLENGE_METHOD or 'S256' code_challenge_method = settings.AUTH_OPENID_CODE_CHALLENGE_METHOD or 'S256'
code_challenge = self.gen_code_challenge(code_verifier, code_challenge_method) code_challenge = self.gen_code_challenge(code_verifier, code_challenge_method)
authentication_request_params.update({ request_params.update({
'code_challenge_method': code_challenge_method, 'code_challenge_method': code_challenge_method,
'code_challenge': code_challenge 'code_challenge': code_challenge
}) })
@@ -91,7 +93,7 @@ class OIDCAuthRequestView(View):
if settings.AUTH_OPENID_USE_STATE: if settings.AUTH_OPENID_USE_STATE:
logger.debug(log_prompt.format('Use state')) logger.debug(log_prompt.format('Use state'))
state = get_random_string(settings.AUTH_OPENID_STATE_LENGTH) state = get_random_string(settings.AUTH_OPENID_STATE_LENGTH)
authentication_request_params.update({'state': state}) request_params.update({'state': state})
request.session['oidc_auth_state'] = state request.session['oidc_auth_state'] = state
# Nonces should be used too! In that case the generated nonce is stored both in the # Nonces should be used too! In that case the generated nonce is stored both in the
@@ -99,17 +101,12 @@ class OIDCAuthRequestView(View):
if settings.AUTH_OPENID_USE_NONCE: if settings.AUTH_OPENID_USE_NONCE:
logger.debug(log_prompt.format('Use nonce')) logger.debug(log_prompt.format('Use nonce'))
nonce = get_random_string(settings.AUTH_OPENID_NONCE_LENGTH) nonce = get_random_string(settings.AUTH_OPENID_NONCE_LENGTH)
authentication_request_params.update({'nonce': nonce, }) request_params.update({'nonce': nonce, })
request.session['oidc_auth_nonce'] = nonce request.session['oidc_auth_nonce'] = nonce
# Stores the "next" URL in the session if applicable.
logger.debug(log_prompt.format('Stores next url in the session'))
next_url = request.GET.get('next')
request.session['oidc_auth_next_url'] = safe_next_url(next_url, request=request)
# Redirects the user to authorization endpoint. # Redirects the user to authorization endpoint.
logger.debug(log_prompt.format('Construct redirect url')) logger.debug(log_prompt.format('Construct redirect url'))
query = urlencode(authentication_request_params) query = urlencode(request_params)
redirect_url = '{url}?{query}'.format( redirect_url = '{url}?{query}'.format(
url=settings.AUTH_OPENID_PROVIDER_AUTHORIZATION_ENDPOINT, query=query) url=settings.AUTH_OPENID_PROVIDER_AUTHORIZATION_ENDPOINT, query=query)
@@ -129,6 +126,8 @@ class OIDCAuthCallbackView(View, FlashMessageMixin):
http_method_names = ['get', ] http_method_names = ['get', ]
@redirect_to_pre_save_next_after_auth
def get(self, request): def get(self, request):
""" Processes GET requests. """ """ Processes GET requests. """
log_prompt = "Process GET requests [OIDCAuthCallbackView]: {}" log_prompt = "Process GET requests [OIDCAuthCallbackView]: {}"
@@ -167,7 +166,6 @@ class OIDCAuthCallbackView(View, FlashMessageMixin):
raise SuspiciousOperation('Invalid OpenID Connect callback state value') raise SuspiciousOperation('Invalid OpenID Connect callback state value')
# Authenticates the end-user. # Authenticates the end-user.
next_url = request.session.get('oidc_auth_next_url', None)
code_verifier = request.session.get('oidc_auth_code_verifier', None) code_verifier = request.session.get('oidc_auth_code_verifier', None)
logger.debug(log_prompt.format('Process authenticate')) logger.debug(log_prompt.format('Process authenticate'))
try: try:
@@ -191,9 +189,7 @@ class OIDCAuthCallbackView(View, FlashMessageMixin):
callback_params.get('session_state', None) callback_params.get('session_state', None)
logger.debug(log_prompt.format('Redirect')) logger.debug(log_prompt.format('Redirect'))
return HttpResponseRedirect( return HttpResponseRedirect(settings.AUTH_OPENID_AUTHENTICATION_REDIRECT_URI)
next_url or settings.AUTH_OPENID_AUTHENTICATION_REDIRECT_URI
)
if 'error' in callback_params: if 'error' in callback_params:
logger.debug( logger.debug(
log_prompt.format('Error in callback params: {}'.format(callback_params['error'])) log_prompt.format('Error in callback params: {}'.format(callback_params['error']))
@@ -212,10 +208,6 @@ class OIDCAuthCallbackView(View, FlashMessageMixin):
return HttpResponseRedirect(redirect_url) return HttpResponseRedirect(redirect_url)
class OIDCAuthCallbackClientView(BaseAuthCallbackClientView):
pass
class OIDCEndSessionView(View): class OIDCEndSessionView(View):
""" Allows to end the session of any user authenticated using OpenID Connect. """ Allows to end the session of any user authenticated using OpenID Connect.

View File

@@ -8,6 +8,5 @@ urlpatterns = [
path('login/', views.Saml2AuthRequestView.as_view(), name='saml2-login'), path('login/', views.Saml2AuthRequestView.as_view(), name='saml2-login'),
path('logout/', views.Saml2EndSessionView.as_view(), name='saml2-logout'), path('logout/', views.Saml2EndSessionView.as_view(), name='saml2-logout'),
path('callback/', views.Saml2AuthCallbackView.as_view(), name='saml2-callback'), path('callback/', views.Saml2AuthCallbackView.as_view(), name='saml2-callback'),
path('callback/client/', views.Saml2AuthCallbackClientView.as_view(), name='saml2-callback-client'),
path('metadata/', views.Saml2AuthMetadataView.as_view(), name='saml2-metadata'), path('metadata/', views.Saml2AuthMetadataView.as_view(), name='saml2-metadata'),
] ]

View File

@@ -17,9 +17,8 @@ from onelogin.saml2.idp_metadata_parser import (
) )
from authentication.views.mixins import FlashMessageMixin from authentication.views.mixins import FlashMessageMixin
from common.utils import get_logger from common.utils import get_logger, safe_next_url
from .settings import JmsSaml2Settings from .settings import JmsSaml2Settings
from ..base import BaseAuthCallbackClientView
logger = get_logger(__file__) logger = get_logger(__file__)
@@ -208,13 +207,16 @@ class Saml2AuthRequestView(View, PrepareRequestMixin):
log_prompt = "Process SAML GET requests: {}" log_prompt = "Process SAML GET requests: {}"
logger.debug(log_prompt.format('Start')) logger.debug(log_prompt.format('Start'))
request_params = request.GET.dict()
try: try:
saml_instance = self.init_saml_auth(request) saml_instance = self.init_saml_auth(request)
except OneLogin_Saml2_Error as error: except OneLogin_Saml2_Error as error:
logger.error(log_prompt.format('Init saml auth error: %s' % error)) logger.error(log_prompt.format('Init saml auth error: %s' % error))
return HttpResponse(error, status=412) return HttpResponse(error, status=412)
next_url = settings.AUTH_SAML2_PROVIDER_AUTHORIZATION_ENDPOINT next_url = request_params.get('next') or settings.AUTH_SAML2_PROVIDER_AUTHORIZATION_ENDPOINT
next_url = safe_next_url(next_url, request=request)
url = saml_instance.login(return_to=next_url) url = saml_instance.login(return_to=next_url)
logger.debug(log_prompt.format('Redirect login url')) logger.debug(log_prompt.format('Redirect login url'))
return HttpResponseRedirect(url) return HttpResponseRedirect(url)
@@ -252,6 +254,7 @@ class Saml2AuthCallbackView(View, PrepareRequestMixin, FlashMessageMixin):
def post(self, request): def post(self, request):
log_prompt = "Process SAML2 POST requests: {}" log_prompt = "Process SAML2 POST requests: {}"
post_data = request.POST post_data = request.POST
error_title = _("SAML2 Error")
try: try:
saml_instance = self.init_saml_auth(request) saml_instance = self.init_saml_auth(request)
@@ -279,20 +282,24 @@ class Saml2AuthCallbackView(View, PrepareRequestMixin, FlashMessageMixin):
try: try:
user = auth.authenticate(request=request, saml_user_data=saml_user_data) user = auth.authenticate(request=request, saml_user_data=saml_user_data)
except IntegrityError as e: except IntegrityError as e:
title = _("SAML2 Error")
msg = _('Please check if a user with the same username or email already exists') msg = _('Please check if a user with the same username or email already exists')
logger.error(e, exc_info=True) logger.error(e, exc_info=True)
response = self.get_failed_response('/', title, msg) response = self.get_failed_response('/', error_title, msg)
return response return response
if user and user.is_valid: if user and user.is_valid:
logger.debug(log_prompt.format('Login: {}'.format(user))) logger.debug(log_prompt.format('Login: {}'.format(user)))
auth.login(self.request, user) auth.login(self.request, user)
if not user and getattr(request, 'error_message', ''):
response = self.get_failed_response('/', title=error_title, msg=request.error_message)
return response
logger.debug(log_prompt.format('Redirect')) logger.debug(log_prompt.format('Redirect'))
redir = post_data.get('RelayState') relay_state = post_data.get('RelayState')
if not redir or len(redir) == 0: if not relay_state or len(relay_state) == 0:
redir = "/" relay_state = "/"
next_url = saml_instance.redirect_to(redir) next_url = saml_instance.redirect_to(relay_state)
next_url = safe_next_url(next_url, request=request)
return HttpResponseRedirect(next_url) return HttpResponseRedirect(next_url)
@csrf_exempt @csrf_exempt
@@ -300,10 +307,6 @@ class Saml2AuthCallbackView(View, PrepareRequestMixin, FlashMessageMixin):
return super().dispatch(*args, **kwargs) return super().dispatch(*args, **kwargs)
class Saml2AuthCallbackClientView(BaseAuthCallbackClientView):
pass
class Saml2AuthMetadataView(View, PrepareRequestMixin): class Saml2AuthMetadataView(View, PrepareRequestMixin):
def get(self, request): def get(self, request):

View File

@@ -1,6 +1,9 @@
from django.db.models import TextChoices from django.db.models import TextChoices
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
USER_LOGIN_GUARD_VIEW_REDIRECT_FIELD = 'next'
RSA_PRIVATE_KEY = 'rsa_private_key' RSA_PRIVATE_KEY = 'rsa_private_key'
RSA_PUBLIC_KEY = 'rsa_public_key' RSA_PUBLIC_KEY = 'rsa_public_key'

View File

@@ -0,0 +1,193 @@
"""
This module provides decorators to handle redirect URLs during the authentication flow:
1. pre_save_next_to_session: Captures and stores the intended next URL before redirecting to auth provider
2. redirect_to_pre_save_next_after_auth: Redirects to the stored next URL after successful authentication
3. post_save_next_to_session: Copies the stored next URL to session['next'] after view execution
"""
from urllib.parse import urlparse
from django.http import HttpResponseRedirect
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from functools import wraps
from common.utils import get_logger, safe_next_url
from .const import USER_LOGIN_GUARD_VIEW_REDIRECT_FIELD
logger = get_logger(__file__)
__all__ = [
'pre_save_next_to_session', 'redirect_to_pre_save_next_after_auth',
'post_save_next_to_session_if_guard_redirect'
]
# Session key for storing the redirect URL after authentication
AUTH_SESSION_NEXT_URL_KEY = 'auth_next_url'
def pre_save_next_to_session(get_next_url=None):
"""
Decorator to capture and store the 'next' parameter into session BEFORE view execution.
This decorator is applied to the authentication request view to preserve the user's
intended destination URL before redirecting to the authentication provider.
Args:
get_next_url: Optional callable that extracts the next URL from request.
Default: lambda req: req.GET.get('next')
Usage:
# Use default (request.GET.get('next'))
@pre_save_next_to_session()
def get(self, request):
pass
# Custom extraction from POST data
@pre_save_next_to_session(get_next_url=lambda req: req.POST.get('next'))
def post(self, request):
pass
# Custom extraction from both GET and POST
@pre_save_next_to_session(
get_next_url=lambda req: req.GET.get('next') or req.POST.get('next')
)
def get(self, request):
pass
Example flow:
User accesses: /auth/oauth2/?next=/dashboard/
↓ (decorator saves '/dashboard/' to session)
Redirected to OAuth2 provider for authentication
"""
# Default function to extract next URL from request.GET
if get_next_url is None:
get_next_url = lambda req: req.GET.get('next')
def decorator(view_func):
@wraps(view_func)
def wrapper(self, request, *args, **kwargs):
next_url = get_next_url(request)
if next_url:
request.session[AUTH_SESSION_NEXT_URL_KEY] = next_url
logger.debug(f"[Auth] Saved next_url to session: {next_url}")
return view_func(self, request, *args, **kwargs)
return wrapper
return decorator
def redirect_to_pre_save_next_after_auth(view_func):
"""
Decorator to redirect to the previously saved 'next' URL after successful authentication.
This decorator is applied to the authentication callback view. After the user successfully
authenticates, if a 'next' URL was previously saved in the session (by pre_save_next_to_session),
the user will be redirected to that URL instead of the default redirect location.
Conditions for redirect:
- User must be authenticated (request.user.is_authenticated)
- Session must contain the saved next URL (AUTH_SESSION_NEXT_URL_KEY)
- The next URL must not be '/' (avoid unnecessary redirects)
- The next URL must pass security validation (safe_next_url)
If any condition fails, returns the original view response.
Usage:
@redirect_to_pre_save_next_after_auth
def get(self, request):
# Process authentication callback
if user_authenticated:
auth.login(request, user)
return HttpResponseRedirect(default_url)
Example flow:
User redirected back from OAuth2 provider: /auth/oauth2/callback/?code=xxx
↓ (view processes authentication, user becomes authenticated)
Decorator checks session for saved next URL
↓ (finds '/dashboard/' in session)
Redirects to: /dashboard/
↓ (clears saved URL from session)
"""
@wraps(view_func)
def wrapper(self, request, *args, **kwargs):
# Execute the original view method first
response = view_func(self, request, *args, **kwargs)
# Check if user has been authenticated
if request.user and request.user.is_authenticated:
# Check if session contains a saved next URL
saved_next_url = request.session.get(AUTH_SESSION_NEXT_URL_KEY)
if saved_next_url and saved_next_url != '/':
# Validate the URL for security
safe_url = safe_next_url(saved_next_url, request=request)
if safe_url:
# Clear the saved URL from session (one-time use)
request.session.pop(AUTH_SESSION_NEXT_URL_KEY, None)
logger.debug(f"[Auth] Redirecting authenticated user to saved next_url: {safe_url}")
return HttpResponseRedirect(safe_url)
# Return the original response if no redirect conditions are met
return response
return wrapper
def post_save_next_to_session_if_guard_redirect(view_func):
"""
Decorator to copy AUTH_SESSION_NEXT_URL_KEY to session['next'] after view execution,
but only if redirecting to login-guard view.
This decorator is applied AFTER view execution. It copies the value from
AUTH_SESSION_NEXT_URL_KEY (internal storage) to 'next' (standard session key)
for use by downstream code.
Only sets the 'next' session key when the response is a redirect to guard-view
(i.e., response with redirect status code and location path matching login-guard view URL).
Usage:
@post_save_next_to_session_if_guard_redirect
def get(self, request):
# Process the request and return response
if some_condition:
return self.redirect_to_guard_view() # Decorator will copy next to session
return HttpResponseRedirect(url) # Decorator won't copy if not to guard-view
Example flow:
View executes and returns redirect to guard view
↓ (response is redirect with 'login-guard' in Location)
Decorator checks if response is redirect to guard-view and session has saved next URL
↓ (copies AUTH_SESSION_NEXT_URL_KEY to session['next'])
User is redirected to guard-view with 'next' available in session
"""
@wraps(view_func)
def wrapper(self, request, *args, **kwargs):
# Execute the original view method
response = view_func(self, request, *args, **kwargs)
# Check if response is a redirect to guard view
# Redirect responses typically have status codes 301, 302, 303, 307, 308
is_guard_redirect = False
if hasattr(response, 'status_code') and response.status_code in (301, 302, 303, 307, 308):
# Check if the redirect location is to guard view
location = response.get('Location', '')
if location:
# Extract path from location URL (handle both absolute and relative URLs)
parsed_url = urlparse(location)
path = parsed_url.path
# Check if path matches guard view URL pattern
guard_view_url = reverse('authentication:login-guard')
if path == guard_view_url:
is_guard_redirect = True
# Only set 'next' if response is a redirect to guard view
if is_guard_redirect:
# Copy AUTH_SESSION_NEXT_URL_KEY to 'next' if it exists
saved_next_url = request.session.get(AUTH_SESSION_NEXT_URL_KEY)
if saved_next_url:
# 这里 'next' 是 UserLoginGuardView.redirect_field_name
request.session[USER_LOGIN_GUARD_VIEW_REDIRECT_FIELD] = saved_next_url
logger.debug(f"[Auth] Copied {AUTH_SESSION_NEXT_URL_KEY} to 'next' in session: {saved_next_url}")
return response
return wrapper

View File

@@ -114,12 +114,12 @@ class BlockMFAError(AuthFailedNeedLogMixin, AuthFailedError):
super().__init__(username=username, request=request, ip=ip) super().__init__(username=username, request=request, ip=ip)
class BlockLoginError(AuthFailedNeedBlockMixin, AuthFailedError): class BlockLoginError(AuthFailedNeedLogMixin, AuthFailedNeedBlockMixin, AuthFailedError):
error = 'block_login' error = 'block_login'
def __init__(self, username, ip): def __init__(self, username, ip, request):
self.msg = const.block_user_login_msg.format(settings.SECURITY_LOGIN_LIMIT_TIME) self.msg = const.block_user_login_msg.format(settings.SECURITY_LOGIN_LIMIT_TIME)
super().__init__(username=username, ip=ip) super().__init__(username=username, ip=ip, request=request)
class SessionEmptyError(AuthFailedError): class SessionEmptyError(AuthFailedError):

View File

@@ -0,0 +1,75 @@
# -*- coding: utf-8 -*-
#
from django.core.management.base import BaseCommand
from django.db.utils import OperationalError, ProgrammingError
from django.conf import settings
class Command(BaseCommand):
help = 'Initialize OAuth2 Provider - Create default JumpServer Client application'
def add_arguments(self, parser):
parser.add_argument(
'--force',
action='store_true',
help='Force recreate the application even if it exists',
)
def handle(self, *args, **options):
force = options.get('force', False)
try:
from authentication.backends.oauth2_provider.utils import (
get_or_create_jumpserver_client_application
)
from oauth2_provider.models import get_application_model
Application = get_application_model()
# 检查表是否存在
try:
Application.objects.exists()
except (OperationalError, ProgrammingError) as e:
self.stdout.write(
self.style.ERROR(
f'OAuth2 Provider tables not found. Please run migrations first:\n'
f' python manage.py migrate oauth2_provider\n'
f'Error: {e}'
)
)
return
# 如果强制重建,先删除已存在的应用
if force:
deleted_count, _ = Application.objects.filter(
name=settings.OAUTH2_PROVIDER_JUMPSERVER_CLIENT_NAME
).delete()
if deleted_count > 0:
self.stdout.write(
self.style.WARNING(f'Deleted {deleted_count} existing application(s)')
)
# 创建或获取应用
application = get_or_create_jumpserver_client_application()
if application:
self.stdout.write(
self.style.SUCCESS(
f'✓ OAuth2 JumpServer Client application initialized successfully\n'
f' - Client ID: {application.client_id}\n'
f' - Client Type: {application.get_client_type_display()}\n'
f' - Grant Type: {application.get_authorization_grant_type_display()}\n'
f' - Redirect URIs: {application.redirect_uris}\n'
f' - Skip Authorization: {application.skip_authorization}'
)
)
else:
self.stdout.write(
self.style.ERROR('Failed to create OAuth2 application')
)
except Exception as e:
self.stdout.write(
self.style.ERROR(f'Error initializing OAuth2 Provider: {e}')
)
raise

View File

@@ -6,6 +6,7 @@ import time
import uuid import uuid
from functools import partial from functools import partial
from typing import Callable from typing import Callable
from werkzeug.local import Local
from django.conf import settings from django.conf import settings
from django.contrib import auth from django.contrib import auth
@@ -16,6 +17,7 @@ from django.contrib.auth import (
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.core.cache import cache from django.core.cache import cache
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.db.models import Q
from django.shortcuts import reverse, redirect, get_object_or_404 from django.shortcuts import reverse, redirect, get_object_or_404
from django.utils.http import urlencode from django.utils.http import urlencode
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
@@ -31,6 +33,87 @@ from .signals import post_auth_success, post_auth_failed
logger = get_logger(__name__) logger = get_logger(__name__)
# 模块级别的线程上下文,用于 authenticate 函数中标记当前线程
_auth_thread_context = Local()
# 保存 Django 原始的 get_or_create 方法(在模块加载时保存一次)
def _save_original_get_or_create():
"""保存 Django 原始的 get_or_create 方法"""
from django.contrib.auth import get_user_model as get_user_model_func
UserModel = get_user_model_func()
return UserModel.objects.get_or_create
_django_original_get_or_create = _save_original_get_or_create()
class OnlyAllowExistUserAuthError(Exception):
pass
def _authenticate_context(func):
"""
装饰器:管理 authenticate 函数的执行上下文
功能:
1. 执行前:
- 在线程本地存储中标记当前正在执行 authenticate
- 临时替换 UserModel.objects.get_or_create 方法
2. 执行后:
- 清理线程本地存储标记
- 恢复 get_or_create 为 Django 原始方法
作用:
- 确保 get_or_create 行为仅在 authenticate 生命周期内生效
- 支持 ONLY_ALLOW_EXIST_USER_AUTH 配置的线程安全实现
- 防止跨请求或跨线程的状态污染
"""
from functools import wraps
@wraps(func)
def wrapper(request=None, **credentials):
from django.contrib.auth import get_user_model
UserModel = get_user_model()
def custom_get_or_create(*args, **kwargs):
create_username = kwargs.get('username')
logger.debug(f"get_or_create: thread_id={threading.get_ident()}, username={create_username}")
# 如果当前线程正在执行 authenticate 且仅允许已存在用户认证,则提前判断用户是否存在
if (
getattr(_auth_thread_context, 'in_authenticate', False) and
settings.ONLY_ALLOW_EXIST_USER_AUTH
):
try:
UserModel.objects.get(username=create_username)
except UserModel.DoesNotExist:
raise OnlyAllowExistUserAuthError
# 调用 Django 原始方法(已是绑定方法,直接传参)
return _django_original_get_or_create(*args, **kwargs)
try:
# 执行前:设置线程上下文和 monkey-patch
setattr(_auth_thread_context, 'in_authenticate', True)
UserModel.objects.get_or_create = custom_get_or_create
# 执行原函数
return func(request, **credentials)
finally:
# 执行后:清理线程上下文和恢复原始方法
try:
if hasattr(_auth_thread_context, 'in_authenticate'):
delattr(_auth_thread_context, 'in_authenticate')
except Exception:
pass
try:
UserModel.objects.get_or_create = _django_original_get_or_create
except Exception:
pass
return wrapper
def _get_backends(return_tuples=False): def _get_backends(return_tuples=False):
backends = [] backends = []
@@ -48,39 +131,16 @@ def _get_backends(return_tuples=False):
return backends return backends
class OnlyAllowExistUserAuthError(Exception):
pass
auth._get_backends = _get_backends auth._get_backends = _get_backends
@_authenticate_context
def authenticate(request=None, **credentials): def authenticate(request=None, **credentials):
""" """
If the given credentials are valid, return a User object. If the given credentials are valid, return a User object.
之所以 hack 这个 authenticate
""" """
UserModel = get_user_model()
original_get_or_create = UserModel.objects.get_or_create
thread_local = threading.local()
thread_local.thread_id = threading.get_ident()
def custom_get_or_create(self, *args, **kwargs):
logger.debug(f"get_or_create: thread_id={threading.get_ident()}, username={username}")
if threading.get_ident() != thread_local.thread_id or not settings.ONLY_ALLOW_EXIST_USER_AUTH:
return original_get_or_create(*args, **kwargs)
create_username = kwargs.get('username')
try:
UserModel.objects.get(username=create_username)
except UserModel.DoesNotExist:
raise OnlyAllowExistUserAuthError
return original_get_or_create(*args, **kwargs)
username = credentials.get('username')
temp_user = None temp_user = None
username = credentials.get('username')
for backend, backend_path in _get_backends(return_tuples=True): for backend, backend_path in _get_backends(return_tuples=True):
# 检查用户名是否允许认证 (预先检查,不浪费认证时间) # 检查用户名是否允许认证 (预先检查,不浪费认证时间)
logger.info('Try using auth backend: {}'.format(str(backend))) logger.info('Try using auth backend: {}'.format(str(backend)))
@@ -94,27 +154,28 @@ def authenticate(request=None, **credentials):
except TypeError: except TypeError:
# This backend doesn't accept these credentials as arguments. Try the next one. # This backend doesn't accept these credentials as arguments. Try the next one.
continue continue
try: try:
UserModel.objects.get_or_create = custom_get_or_create.__get__(UserModel.objects)
user = backend.authenticate(request, **credentials) user = backend.authenticate(request, **credentials)
except PermissionDenied: except PermissionDenied:
# This backend says to stop in our tracks - this user should not be allowed in at all. # This backend says to stop in our tracks - this user should not be allowed in at all.
break break
except OnlyAllowExistUserAuthError: except OnlyAllowExistUserAuthError:
request.error_message = _( if request:
'''The administrator has enabled "Only allow existing users to log in", request.error_message = _(
and the current user is not in the user list. Please contact the administrator.''' '''The administrator has enabled "Only allow existing users to log in",
) and the current user is not in the user list. Please contact the administrator.'''
)
continue continue
finally:
UserModel.objects.get_or_create = original_get_or_create
if user is None: if user is None:
continue continue
if not user.is_valid: if not user.is_valid:
temp_user = user temp_user = user
temp_user.backend = backend_path temp_user.backend = backend_path
request.error_message = _('User is invalid') if request:
request.error_message = _('User is invalid')
return temp_user return temp_user
# 检查用户是否允许认证 # 检查用户是否允许认证
@@ -129,8 +190,11 @@ def authenticate(request=None, **credentials):
else: else:
if temp_user is not None: if temp_user is not None:
source_display = temp_user.source_display source_display = temp_user.source_display
request.error_message = _('''The administrator has enabled 'Only allow login from user source'. if request:
The current user source is {}. Please contact the administrator.''').format(source_display) request.error_message = _(
''' The administrator has enabled 'Only allow login from user source'.
The current user source is {}. Please contact the administrator. '''
).format(source_display)
return temp_user return temp_user
# The credentials supplied are invalid to all backends, fire signal # The credentials supplied are invalid to all backends, fire signal
@@ -209,9 +273,9 @@ class AuthPreCheckMixin:
if not is_block: if not is_block:
return return
logger.warning('Ip was blocked' + ': ' + username + ':' + ip) logger.warning('Ip was blocked' + ': ' + username + ':' + ip)
exception = errors.BlockLoginError(username=username, ip=ip) exception = errors.BlockLoginError(username=username, ip=ip, request=self.request)
if raise_exception: if raise_exception:
raise errors.BlockLoginError(username=username, ip=ip) raise exception
else: else:
return exception return exception
@@ -228,7 +292,8 @@ class AuthPreCheckMixin:
if not settings.ONLY_ALLOW_EXIST_USER_AUTH: if not settings.ONLY_ALLOW_EXIST_USER_AUTH:
return return
exist = User.objects.filter(username=username).exists() q = Q(username=username) | Q(email=username)
exist = User.objects.filter(q).exists()
if not exist: if not exist:
logger.error(f"Only allow exist user auth, login failed: {username}") logger.error(f"Only allow exist user auth, login failed: {username}")
self.raise_credential_error(errors.reason_user_not_exist) self.raise_credential_error(errors.reason_user_not_exist)

View File

@@ -9,11 +9,12 @@ from common.utils import get_object_or_none, random_string
from users.models import User from users.models import User
from users.serializers import UserProfileSerializer from users.serializers import UserProfileSerializer
from ..models import AccessKey, TempToken from ..models import AccessKey, TempToken
from oauth2_provider.models import get_access_token_model
__all__ = [ __all__ = [
'AccessKeySerializer', 'BearerTokenSerializer', 'AccessKeySerializer', 'BearerTokenSerializer',
'SSOTokenSerializer', 'TempTokenSerializer', 'SSOTokenSerializer', 'TempTokenSerializer',
'AccessKeyCreateSerializer' 'AccessKeyCreateSerializer', 'AccessTokenSerializer',
] ]
@@ -114,3 +115,28 @@ class TempTokenSerializer(serializers.ModelSerializer):
token = TempToken(**kwargs) token = TempToken(**kwargs)
token.save() token.save()
return token return token
class AccessTokenSerializer(serializers.ModelSerializer):
token_preview = serializers.SerializerMethodField(label=_("Token"))
class Meta:
model = get_access_token_model()
fields = [
'id', 'user', 'token_preview', 'is_valid',
'is_expired', 'expires', 'scope', 'created', 'updated',
]
read_only_fields = fields
extra_kwargs = {
'scope': { 'label': _('Scope') },
'expires': { 'label': _('Date expired') },
'updated': { 'label': _('Date updated') },
'created': { 'label': _('Date created') },
}
def get_token_preview(self, obj):
token_string = obj.token
if len(token_string) > 16:
return f"{token_string[:6]}...{token_string[-4:]}"
return "****"

View File

@@ -9,6 +9,8 @@ from audits.models import UserSession
from common.sessions.cache import user_session_manager from common.sessions.cache import user_session_manager
from .signals import post_auth_success, post_auth_failed, user_auth_failed, user_auth_success from .signals import post_auth_success, post_auth_failed, user_auth_failed, user_auth_success
from .backends.oauth2_provider.signal_handlers import *
@receiver(user_logged_in) @receiver(user_logged_in)
def on_user_auth_login_success(sender, user, request, **kwargs): def on_user_auth_login_success(sender, user, request, **kwargs):
@@ -57,3 +59,4 @@ def on_user_login_success(sender, request, user, backend, create=False, **kwargs
def on_user_login_failed(sender, username, request, reason, backend, **kwargs): def on_user_login_failed(sender, username, request, reason, backend, **kwargs):
request.session['auth_backend'] = backend request.session['auth_backend'] = backend
post_auth_failed.send(sender, username=username, request=request, reason=reason) post_auth_failed.send(sender, username=username, request=request, reason=reason)

View File

@@ -47,3 +47,9 @@ def clean_expire_token():
count = TempToken.objects.filter(date_expired__lt=expired_time).delete() count = TempToken.objects.filter(date_expired__lt=expired_time).delete()
logging.info('Deleted %d temporary tokens.', count[0]) logging.info('Deleted %d temporary tokens.', count[0])
logging.info('Cleaned expired temporary and connection tokens.') logging.info('Cleaned expired temporary and connection tokens.')
@register_as_period_task(crontab=CRONTAB_AT_AM_TWO)
def clear_oauth2_provider_expired_tokens():
from oauth2_provider.models import clear_expired
clear_expired()

View File

@@ -16,6 +16,7 @@ router.register('super-connection-token', api.SuperConnectionTokenViewSet, 'supe
router.register('admin-connection-token', api.AdminConnectionTokenViewSet, 'admin-connection-token') router.register('admin-connection-token', api.AdminConnectionTokenViewSet, 'admin-connection-token')
router.register('confirm', api.UserConfirmationViewSet, 'confirm') router.register('confirm', api.UserConfirmationViewSet, 'confirm')
router.register('ssh-key', api.SSHkeyViewSet, 'ssh-key') router.register('ssh-key', api.SSHkeyViewSet, 'ssh-key')
router.register('access-tokens', api.AccessTokenViewSet, 'access-token')
urlpatterns = [ urlpatterns = [
path('<str:backend>/qr/unbind/', api.QRUnBindForUserApi.as_view(), name='qr-unbind'), path('<str:backend>/qr/unbind/', api.QRUnBindForUserApi.as_view(), name='qr-unbind'),

View File

@@ -83,4 +83,6 @@ urlpatterns = [
path('oauth2/', include(('authentication.backends.oauth2.urls', 'authentication'), namespace='oauth2')), path('oauth2/', include(('authentication.backends.oauth2.urls', 'authentication'), namespace='oauth2')),
path('captcha/', include('captcha.urls')), path('captcha/', include('captcha.urls')),
path('oauth2-provider/', include(('authentication.backends.oauth2_provider.urls', 'authentication'), namespace='oauth2-provider'))
] ]

View File

@@ -11,6 +11,7 @@ from rest_framework.request import Request
from authentication import errors from authentication import errors
from authentication.mixins import AuthMixin from authentication.mixins import AuthMixin
from authentication.notifications import OAuthBindMessage from authentication.notifications import OAuthBindMessage
from authentication.decorators import post_save_next_to_session_if_guard_redirect
from common.utils import get_logger from common.utils import get_logger
from common.utils.common import get_request_ip from common.utils.common import get_request_ip
from common.utils.django import reverse, get_object_or_none from common.utils.django import reverse, get_object_or_none
@@ -72,6 +73,7 @@ class BaseLoginCallbackView(AuthMixin, FlashMessageMixin, IMClientMixin, View):
return user, None return user, None
@post_save_next_to_session_if_guard_redirect
def get(self, request: Request): def get(self, request: Request):
code = request.GET.get('code') code = request.GET.get('code')
redirect_url = request.GET.get('redirect_url') redirect_url = request.GET.get('redirect_url')
@@ -110,8 +112,6 @@ class BaseLoginCallbackView(AuthMixin, FlashMessageMixin, IMClientMixin, View):
response = self.get_failed_response(login_url, title=msg, msg=msg) response = self.get_failed_response(login_url, title=msg, msg=msg)
return response return response
if redirect_url and 'next=client' in redirect_url:
self.request.META['QUERY_STRING'] += '&next=client'
return self.redirect_to_guard_view() return self.redirect_to_guard_view()

View File

@@ -10,6 +10,7 @@ from django.views import View
from rest_framework.exceptions import APIException from rest_framework.exceptions import APIException
from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.permissions import AllowAny, IsAuthenticated
from authentication.decorators import post_save_next_to_session_if_guard_redirect, pre_save_next_to_session
from authentication import errors from authentication import errors
from authentication.const import ConfirmType from authentication.const import ConfirmType
from authentication.mixins import AuthMixin from authentication.mixins import AuthMixin
@@ -24,7 +25,7 @@ from common.views.mixins import PermissionsMixin, UserConfirmRequiredExceptionMi
from users.models import User from users.models import User
from users.views import UserVerifyPasswordView from users.views import UserVerifyPasswordView
from .base import BaseLoginCallbackView from .base import BaseLoginCallbackView
from .mixins import METAMixin, FlashMessageMixin from .mixins import FlashMessageMixin
logger = get_logger(__file__) logger = get_logger(__file__)
@@ -171,20 +172,18 @@ class DingTalkEnableStartView(UserVerifyPasswordView):
return success_url return success_url
class DingTalkQRLoginView(DingTalkQRMixin, METAMixin, View): class DingTalkQRLoginView(DingTalkQRMixin, View):
permission_classes = (AllowAny,) permission_classes = (AllowAny,)
@pre_save_next_to_session()
def get(self, request: HttpRequest): def get(self, request: HttpRequest):
redirect_url = request.GET.get('redirect_url') or reverse('index') redirect_url = request.GET.get('redirect_url') or reverse('index')
query_string = request.GET.urlencode() query_string = request.GET.urlencode()
redirect_url = f'{redirect_url}?{query_string}' redirect_url = f'{redirect_url}?{query_string}'
next_url = self.get_next_url_from_meta() or reverse('index')
next_url = safe_next_url(next_url, request=request)
redirect_uri = reverse('authentication:dingtalk-qr-login-callback', external=True) redirect_uri = reverse('authentication:dingtalk-qr-login-callback', external=True)
redirect_uri += '?' + urlencode({ redirect_uri += '?' + urlencode({
'redirect_url': redirect_url, 'redirect_url': redirect_url,
'next': next_url,
}) })
url = self.get_qr_url(redirect_uri) url = self.get_qr_url(redirect_uri)
@@ -210,6 +209,7 @@ class DingTalkQRLoginCallbackView(DingTalkQRMixin, BaseLoginCallbackView):
class DingTalkOAuthLoginView(DingTalkOAuthMixin, View): class DingTalkOAuthLoginView(DingTalkOAuthMixin, View):
permission_classes = (AllowAny,) permission_classes = (AllowAny,)
@pre_save_next_to_session()
def get(self, request: HttpRequest): def get(self, request: HttpRequest):
redirect_url = request.GET.get('redirect_url') redirect_url = request.GET.get('redirect_url')
@@ -223,6 +223,7 @@ class DingTalkOAuthLoginView(DingTalkOAuthMixin, View):
class DingTalkOAuthLoginCallbackView(AuthMixin, DingTalkOAuthMixin, View): class DingTalkOAuthLoginCallbackView(AuthMixin, DingTalkOAuthMixin, View):
permission_classes = (AllowAny,) permission_classes = (AllowAny,)
@post_save_next_to_session_if_guard_redirect
def get(self, request: HttpRequest): def get(self, request: HttpRequest):
code = request.GET.get('code') code = request.GET.get('code')
redirect_url = request.GET.get('redirect_url') redirect_url = request.GET.get('redirect_url')

View File

@@ -8,6 +8,7 @@ from django.views import View
from rest_framework.exceptions import APIException from rest_framework.exceptions import APIException
from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.permissions import AllowAny, IsAuthenticated
from authentication.decorators import pre_save_next_to_session
from authentication.const import ConfirmType from authentication.const import ConfirmType
from authentication.permissions import UserConfirmation from authentication.permissions import UserConfirmation
from common.sdk.im.feishu import URL from common.sdk.im.feishu import URL
@@ -108,9 +109,12 @@ class FeiShuQRBindCallbackView(FeiShuQRMixin, BaseBindCallbackView):
class FeiShuQRLoginView(FeiShuQRMixin, View): class FeiShuQRLoginView(FeiShuQRMixin, View):
permission_classes = (AllowAny,) permission_classes = (AllowAny,)
@pre_save_next_to_session()
def get(self, request: HttpRequest): def get(self, request: HttpRequest):
redirect_url = request.GET.get('redirect_url') or reverse('index') redirect_url = request.GET.get('redirect_url') or reverse('index')
query_string = request.GET.urlencode() query_string = request.GET.copy()
query_string.pop('next', None)
query_string = query_string.urlencode()
redirect_url = f'{redirect_url}?{query_string}' redirect_url = f'{redirect_url}?{query_string}'
redirect_uri = reverse(f'authentication:{self.category}-qr-login-callback', external=True) redirect_uri = reverse(f'authentication:{self.category}-qr-login-callback', external=True)
redirect_uri += '?' + urlencode({ redirect_uri += '?' + urlencode({

View File

@@ -29,7 +29,7 @@ from users.utils import (
redirect_user_first_login_or_index redirect_user_first_login_or_index
) )
from .. import mixins, errors from .. import mixins, errors
from ..const import RSA_PRIVATE_KEY, RSA_PUBLIC_KEY from ..const import RSA_PRIVATE_KEY, RSA_PUBLIC_KEY, USER_LOGIN_GUARD_VIEW_REDIRECT_FIELD
from ..forms import get_user_login_form_cls from ..forms import get_user_login_form_cls
from ..utils import get_auth_methods from ..utils import get_auth_methods
@@ -260,7 +260,7 @@ class UserLoginView(mixins.AuthMixin, UserLoginContextMixin, FormView):
class UserLoginGuardView(mixins.AuthMixin, RedirectView): class UserLoginGuardView(mixins.AuthMixin, RedirectView):
redirect_field_name = 'next' redirect_field_name = USER_LOGIN_GUARD_VIEW_REDIRECT_FIELD
login_url = reverse_lazy('authentication:login') login_url = reverse_lazy('authentication:login')
login_mfa_url = reverse_lazy('authentication:login-mfa') login_mfa_url = reverse_lazy('authentication:login-mfa')
login_confirm_url = reverse_lazy('authentication:login-wait-confirm') login_confirm_url = reverse_lazy('authentication:login-wait-confirm')

View File

@@ -67,6 +67,7 @@ class UserLoginMFAView(mixins.AuthMixin, FormView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
user = self.get_user_from_session() user = self.get_user_from_session()
mfa_context = self.get_user_mfa_context(user) mfa_context = self.get_user_mfa_context(user)
print(mfa_context)
kwargs.update(mfa_context) kwargs.update(mfa_context)
return kwargs return kwargs

View File

@@ -4,17 +4,6 @@ from django.utils.translation import gettext_lazy as _
from common.utils import FlashMessageUtil from common.utils import FlashMessageUtil
class METAMixin:
def get_next_url_from_meta(self):
request_meta = self.request.META or {}
next_url = None
referer = request_meta.get('HTTP_REFERER', '')
next_url_item = referer.rsplit('next=', 1)
if len(next_url_item) > 1:
next_url = next_url_item[-1]
return next_url
class FlashMessageMixin: class FlashMessageMixin:
@staticmethod @staticmethod
def get_response(redirect_url='', title='', msg='', m_type='message', interval=5): def get_response(redirect_url='', title='', msg='', m_type='message', interval=5):

View File

@@ -8,6 +8,7 @@ from rest_framework.exceptions import APIException
from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.request import Request from rest_framework.request import Request
from authentication.decorators import pre_save_next_to_session
from authentication.const import ConfirmType from authentication.const import ConfirmType
from authentication.permissions import UserConfirmation from authentication.permissions import UserConfirmation
from common.sdk.im.slack import URL, SLACK_REDIRECT_URI_SESSION_KEY from common.sdk.im.slack import URL, SLACK_REDIRECT_URI_SESSION_KEY
@@ -96,9 +97,12 @@ class SlackEnableStartView(UserVerifyPasswordView):
class SlackQRLoginView(SlackMixin, View): class SlackQRLoginView(SlackMixin, View):
permission_classes = (AllowAny,) permission_classes = (AllowAny,)
@pre_save_next_to_session()
def get(self, request: Request): def get(self, request: Request):
redirect_url = request.GET.get('redirect_url') or reverse('index') redirect_url = request.GET.get('redirect_url') or reverse('index')
query_string = request.GET.urlencode() query_string = request.GET.copy()
query_string.pop('next', None)
query_string = query_string.urlencode()
redirect_url = f'{redirect_url}?{query_string}' redirect_url = f'{redirect_url}?{query_string}'
redirect_uri = reverse('authentication:slack-qr-login-callback', external=True) redirect_uri = reverse('authentication:slack-qr-login-callback', external=True)
redirect_uri += '?' + urlencode({ redirect_uri += '?' + urlencode({

View File

@@ -12,6 +12,7 @@ from authentication import errors
from authentication.const import ConfirmType from authentication.const import ConfirmType
from authentication.mixins import AuthMixin from authentication.mixins import AuthMixin
from authentication.permissions import UserConfirmation from authentication.permissions import UserConfirmation
from authentication.decorators import post_save_next_to_session_if_guard_redirect, pre_save_next_to_session
from common.sdk.im.wecom import URL from common.sdk.im.wecom import URL
from common.sdk.im.wecom import WeCom, wecom_tool from common.sdk.im.wecom import WeCom, wecom_tool
from common.utils import get_logger from common.utils import get_logger
@@ -20,7 +21,7 @@ from common.views.mixins import UserConfirmRequiredExceptionMixin, PermissionsMi
from users.models import User from users.models import User
from users.views import UserVerifyPasswordView from users.views import UserVerifyPasswordView
from .base import BaseLoginCallbackView, BaseBindCallbackView from .base import BaseLoginCallbackView, BaseBindCallbackView
from .mixins import METAMixin, FlashMessageMixin from .mixins import FlashMessageMixin
logger = get_logger(__file__) logger = get_logger(__file__)
@@ -115,19 +116,14 @@ class WeComEnableStartView(UserVerifyPasswordView):
return success_url return success_url
class WeComQRLoginView(WeComQRMixin, METAMixin, View): class WeComQRLoginView(WeComQRMixin, View):
permission_classes = (AllowAny,) permission_classes = (AllowAny,)
@pre_save_next_to_session()
def get(self, request: HttpRequest): def get(self, request: HttpRequest):
redirect_url = request.GET.get('redirect_url') or reverse('index') redirect_url = request.GET.get('redirect_url') or reverse('index')
next_url = self.get_next_url_from_meta() or reverse('index')
next_url = safe_next_url(next_url, request=request)
redirect_uri = reverse('authentication:wecom-qr-login-callback', external=True) redirect_uri = reverse('authentication:wecom-qr-login-callback', external=True)
redirect_uri += '?' + urlencode({ redirect_uri += '?' + urlencode({'redirect_url': redirect_url})
'redirect_url': redirect_url,
'next': next_url,
})
url = self.get_qr_url(redirect_uri) url = self.get_qr_url(redirect_uri)
return HttpResponseRedirect(url) return HttpResponseRedirect(url)
@@ -148,12 +144,11 @@ class WeComQRLoginCallbackView(WeComQRMixin, BaseLoginCallbackView):
class WeComOAuthLoginView(WeComOAuthMixin, View): class WeComOAuthLoginView(WeComOAuthMixin, View):
permission_classes = (AllowAny,) permission_classes = (AllowAny,)
@pre_save_next_to_session()
def get(self, request: HttpRequest): def get(self, request: HttpRequest):
redirect_url = request.GET.get('redirect_url') redirect_url = request.GET.get('redirect_url')
redirect_uri = reverse('authentication:wecom-oauth-login-callback', external=True) redirect_uri = reverse('authentication:wecom-oauth-login-callback', external=True)
redirect_uri += '?' + urlencode({'redirect_url': redirect_url}) redirect_uri += '?' + urlencode({'redirect_url': redirect_url})
url = self.get_oauth_url(redirect_uri) url = self.get_oauth_url(redirect_uri)
return HttpResponseRedirect(url) return HttpResponseRedirect(url)
@@ -161,6 +156,7 @@ class WeComOAuthLoginView(WeComOAuthMixin, View):
class WeComOAuthLoginCallbackView(AuthMixin, WeComOAuthMixin, View): class WeComOAuthLoginCallbackView(AuthMixin, WeComOAuthMixin, View):
permission_classes = (AllowAny,) permission_classes = (AllowAny,)
@post_save_next_to_session_if_guard_redirect
def get(self, request: HttpRequest): def get(self, request: HttpRequest):
code = request.GET.get('code') code = request.GET.get('code')
redirect_url = request.GET.get('redirect_url') redirect_url = request.GET.get('redirect_url')

View File

@@ -183,6 +183,7 @@ class BaseFileRenderer(LogMixin, BaseRenderer):
for item in data: for item in data:
row = [] row = []
for field in render_fields: for field in render_fields:
field._row = item
value = item.get(field.field_name) value = item.get(field.field_name)
value = self.render_value(field, value) value = self.render_value(field, value)
row.append(value) row.append(value)

View File

@@ -2,6 +2,7 @@
# #
import datetime import datetime
import inspect import inspect
import sys import sys
if sys.version_info.major == 3 and sys.version_info.minor >= 10: if sys.version_info.major == 3 and sys.version_info.minor >= 10:
@@ -334,6 +335,10 @@ class ES(object):
def is_keyword(props: dict, field: str) -> bool: def is_keyword(props: dict, field: str) -> bool:
return props.get(field, {}).get("type", "keyword") == "keyword" return props.get(field, {}).get("type", "keyword") == "keyword"
@staticmethod
def is_long(props: dict, field: str) -> bool:
return props.get(field, {}).get("type") == "long"
def get_query_body(self, **kwargs): def get_query_body(self, **kwargs):
new_kwargs = {} new_kwargs = {}
for k, v in kwargs.items(): for k, v in kwargs.items():
@@ -361,10 +366,10 @@ class ES(object):
if index_in_field in kwargs: if index_in_field in kwargs:
index['values'] = kwargs[index_in_field] index['values'] = kwargs[index_in_field]
mapping = self.es.indices.get_mapping(index=self.query_index) mapping = self.es.indices.get_mapping(index=self.index)
props = ( props = (
mapping mapping
.get(self.query_index, {}) .get(self.index, {})
.get('mappings', {}) .get('mappings', {})
.get('properties', {}) .get('properties', {})
) )
@@ -375,6 +380,9 @@ class ES(object):
if k in ("org_id", "session") and self.is_keyword(props, k): if k in ("org_id", "session") and self.is_keyword(props, k):
exact[k] = v exact[k] = v
elif self.is_long(props, k):
exact[k] = v
elif k in common_keyword_able: elif k in common_keyword_able:
exact[f"{k}.keyword"] = v exact[f"{k}.keyword"] = v

View File

@@ -15,6 +15,7 @@ class Device:
self.__load_driver(driver_path) self.__load_driver(driver_path)
# open device # open device
self.__open_device() self.__open_device()
self.__reset_key_store()
def close(self): def close(self):
if self.__device is None: if self.__device is None:
@@ -68,3 +69,12 @@ class Device:
if ret != 0: if ret != 0:
raise PiicoError("open piico device failed", ret) raise PiicoError("open piico device failed", ret)
self.__device = device self.__device = device
def __reset_key_store(self):
if self._driver is None:
raise PiicoError("no driver loaded", 0)
if self.__device is None:
raise PiicoError("device not open", 0)
ret = self._driver.SPII_ResetModule(self.__device)
if ret != 0:
raise PiicoError("reset device failed", ret)

View File

@@ -192,6 +192,7 @@ class WeCom(RequestMixin):
class WeComTool(object): class WeComTool(object):
WECOM_STATE_SESSION_KEY = '_wecom_state' WECOM_STATE_SESSION_KEY = '_wecom_state'
WECOM_STATE_VALUE = 'wecom' WECOM_STATE_VALUE = 'wecom'
WECOM_STATE_NEXT_URL_KEY = 'wecom_oauth_next_url'
@lazyproperty @lazyproperty
def qr_cb_url(self): def qr_cb_url(self):

View File

@@ -1,137 +1,16 @@
import json import json
import threading
import time import time
import redis import redis
from django.core.cache import cache from django.core.cache import cache
from redis.client import PubSub
from common.db.utils import safe_db_connection from common.db.utils import safe_db_connection
from common.utils import get_logger from common.utils import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
import threading
from concurrent.futures import ThreadPoolExecutor
_PUBSUB_HUBS = {}
def _get_pubsub_hub(db=10):
hub = _PUBSUB_HUBS.get(db)
if not hub:
hub = PubSubHub(db=db)
_PUBSUB_HUBS[db] = hub
return hub
class PubSubHub:
def __init__(self, db=10):
self.db = db
self.redis = get_redis_client(db)
self.pubsub = self.redis.pubsub()
self.handlers = {}
self.lock = threading.RLock()
self.listener = None
self.running = False
self.executor = ThreadPoolExecutor(max_workers=8, thread_name_prefix='pubsub_handler')
def __del__(self):
self.executor.shutdown(wait=True)
def start(self):
with self.lock:
if self.listener and self.listener.is_alive():
return
self.running = True
self.listener = threading.Thread(name='pubsub_listen', target=self._listen_loop, daemon=True)
self.listener.start()
def _listen_loop(self):
backoff = 1
while self.running:
try:
for msg in self.pubsub.listen():
if msg.get("type") != "message":
continue
ch = msg.get("channel")
if isinstance(ch, bytes):
ch = ch.decode()
data = msg.get("data")
try:
if isinstance(data, bytes):
item = json.loads(data.decode())
elif isinstance(data, str):
item = json.loads(data)
else:
item = data
except Exception:
item = data
# 使用线程池处理消息
future = self.executor.submit(self._dispatch, ch, msg, item)
future.add_done_callback(
lambda f: f.exception() and logger.error(f"handle pubsub msg {msg} failed: {f.exception()}"))
backoff = 1
except Exception as e:
logger.error(f'PubSub listen error: {e}')
time.sleep(backoff)
backoff = min(backoff * 2, 30)
try:
self._reconnect()
except Exception as re:
logger.error(f'PubSub reconnect error: {re}')
def _dispatch(self, ch, raw_msg, item):
with self.lock:
handler = self.handlers.get(ch)
if not handler:
return
_next, error, _complete = handler
try:
with safe_db_connection():
_next(item)
except Exception as e:
logger.error(f'Subscribe handler handle msg error: {e}')
try:
if error:
error(raw_msg, item)
except Exception:
pass
def add_subscription(self, pb, _next, error, complete):
ch = pb.ch
with self.lock:
existed = bool(self.handlers.get(ch))
self.handlers[ch] = (_next, error, complete)
try:
if not existed:
self.pubsub.subscribe(ch)
except Exception as e:
logger.error(f'Subscribe channel {ch} error: {e}')
self.start()
return Subscription(pb=pb, hub=self, ch=ch, handler=(_next, error, complete))
def remove_subscription(self, sub):
ch = sub.ch
with self.lock:
existed = self.handlers.pop(ch, None)
if existed:
try:
self.pubsub.unsubscribe(ch)
except Exception as e:
logger.warning(f'Unsubscribe {ch} error: {e}')
def _reconnect(self):
with self.lock:
channels = [ch for ch, h in self.handlers.items() if h]
try:
self.pubsub.close()
except Exception:
pass
self.redis = get_redis_client(self.db)
self.pubsub = self.redis.pubsub()
if channels:
self.pubsub.subscribe(channels)
def get_redis_client(db=0): def get_redis_client(db=0):
client = cache.client.get_client() client = cache.client.get_client()
@@ -146,11 +25,15 @@ class RedisPubSub:
self.redis = get_redis_client(db) self.redis = get_redis_client(db)
def subscribe(self, _next, error=None, complete=None): def subscribe(self, _next, error=None, complete=None):
hub = _get_pubsub_hub(self.db) ps = self.redis.pubsub()
return hub.add_subscription(self, _next, error, complete) ps.subscribe(self.ch)
sub = Subscription(self, ps)
sub.keep_handle_msg(_next, error, complete)
return sub
def resubscribe(self, _next, error=None, complete=None): def resubscribe(self, _next, error=None, complete=None):
return self.subscribe(_next, error, complete) self.redis = get_redis_client(self.db)
self.subscribe(_next, error, complete)
def publish(self, data): def publish(self, data):
data_json = json.dumps(data) data_json = json.dumps(data)
@@ -159,19 +42,85 @@ class RedisPubSub:
class Subscription: class Subscription:
def __init__(self, pb: RedisPubSub, hub: PubSubHub, ch: str, handler): def __init__(self, pb: RedisPubSub, sub: PubSub):
self.pb = pb self.pb = pb
self.ch = ch self.ch = pb.ch
self.hub = hub self.sub = sub
self.handler = handler
self.unsubscribed = False self.unsubscribed = False
def unsubscribe(self): def _handle_msg(self, _next, error, complete):
if self.unsubscribed: """
return handle arg is the pub published
self.unsubscribed = True :param _next: next msg handler
logger.info(f"Unsubscribed from channel: {self.ch}") :param error: error msg handler
:param complete: complete msg handler
:return:
"""
msgs = self.sub.listen()
if error is None:
error = lambda m, i: None
if complete is None:
complete = lambda: None
try: try:
self.hub.remove_subscription(self) for msg in msgs:
if msg["type"] != "message":
continue
item = None
try:
item_json = msg['data'].decode()
item = json.loads(item_json)
with safe_db_connection():
_next(item)
except Exception as e:
error(msg, item)
logger.error('Subscribe handler handle msg error: {}'.format(e))
except Exception as e:
if self.unsubscribed:
logger.debug('Subscription unsubscribed')
else:
logger.error('Consume msg error: {}'.format(e))
self.retry(_next, error, complete)
return
try:
complete()
except Exception as e:
logger.error('Complete subscribe error: {}'.format(e))
pass
try:
self.unsubscribe()
except Exception as e:
logger.error("Redis observer close error: {}".format(e))
def keep_handle_msg(self, _next, error, complete):
t = threading.Thread(target=self._handle_msg, args=(_next, error, complete))
t.daemon = True
t.start()
return t
def unsubscribe(self):
self.unsubscribed = True
logger.info(f"Unsubscribed from channel: {self.sub}")
try:
self.sub.close()
except Exception as e: except Exception as e:
logger.warning(f'Unsubscribe msg error: {e}') logger.warning(f'Unsubscribe msg error: {e}')
def retry(self, _next, error, complete):
logger.info('Retry subscribe channel: {}'.format(self.ch))
times = 0
while True:
try:
self.unsubscribe()
self.pb.resubscribe(_next, error, complete)
break
except Exception as e:
logger.error('Retry #{} {} subscribe channel error: {}'.format(times, self.ch, e))
times += 1
time.sleep(times * 2)

View File

@@ -101,7 +101,7 @@ def get_ip_city(ip):
info = get_ip_city_by_ipip(ip) info = get_ip_city_by_ipip(ip)
if info: if info:
city = info.get('city', _("Unknown")) city = info.get('city') or _("Unknown")
country = info.get('country') country = info.get('country')
# 国内城市 并且 语言是中文就使用国内 # 国内城市 并且 语言是中文就使用国内

View File

@@ -61,8 +61,10 @@ def contains_time_period(time_periods, ctime=None):
""" """
time_periods: [{"id": 1, "value": "00:00~07:30、10:00~13:00"}, {"id": 2, "value": "00:00~00:00"}] time_periods: [{"id": 1, "value": "00:00~07:30、10:00~13:00"}, {"id": 2, "value": "00:00~00:00"}]
""" """
if not time_periods: if not time_periods or all(item['value'] == "" for item in time_periods):
return None # 需要处理 [{"id":1,"value":""},{"id":2,"value":""},{"id":3,"value":""},...]情况
# 都没选择相当于全选
return True
if ctime is None: if ctime is None:
ctime = local_now() ctime = local_now()

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,7 @@
"ActionPerm": "Разрешения на действия", "ActionPerm": "Разрешения на действия",
"Address": "Адрес", "Address": "Адрес",
"AlreadyExistsPleaseRename": "Файл уже существует, пожалуйста, переименуйте его", "AlreadyExistsPleaseRename": "Файл уже существует, пожалуйста, переименуйте его",
"Announcement: ": "Объявление:", "Announcement: ": "Объявление: ",
"Authentication failed": "Ошибка аутентификации: неверное имя пользователя или пароль", "Authentication failed": "Ошибка аутентификации: неверное имя пользователя или пароль",
"AvailableShortcutKey": "Доступные горячие клавиши", "AvailableShortcutKey": "Доступные горячие клавиши",
"Back": "Назад", "Back": "Назад",

View File

@@ -199,7 +199,7 @@
"AuditsDashboard": "Audits dashboard", "AuditsDashboard": "Audits dashboard",
"Auth": "Authentication", "Auth": "Authentication",
"AuthConfig": "Authentication", "AuthConfig": "Authentication",
"AuthIntegration": "AuthIntegration", "AuthIntegration": "Auth Integration",
"AuthLimit": "Login restriction", "AuthLimit": "Login restriction",
"AuthSAMLCertHelpText": "Save after uploading the certificate key, then view sp metadata", "AuthSAMLCertHelpText": "Save after uploading the certificate key, then view sp metadata",
"AuthSAMLKeyHelpText": "Sp certificates and keys are used for encrypted communication with idp", "AuthSAMLKeyHelpText": "Sp certificates and keys are used for encrypted communication with idp",
@@ -280,7 +280,8 @@
"CACertificate": "Ca certificate", "CACertificate": "Ca certificate",
"CAS": "CAS", "CAS": "CAS",
"CMPP2": "Cmpp v2.0", "CMPP2": "Cmpp v2.0",
"CTYunPrivate": "eCloud Private Cloud", "CTYun": "State Cloud",
"CTYunPrivate": "State Cloud(Private)",
"CalculationResults": "Error in cron expression", "CalculationResults": "Error in cron expression",
"CallRecords": "Call Records", "CallRecords": "Call Records",
"CanDragSelect": "Select by dragging; Empty means all selected", "CanDragSelect": "Select by dragging; Empty means all selected",
@@ -773,6 +774,7 @@
"LdapBulkImport": "User import", "LdapBulkImport": "User import",
"LdapConnectTest": "Test connection", "LdapConnectTest": "Test connection",
"LdapLoginTest": "Test login", "LdapLoginTest": "Test login",
"LeakPasswordList": "Leaked password list",
"LeakedPassword": "Leaked password", "LeakedPassword": "Leaked password",
"Length": "Length", "Length": "Length",
"LessEqualThan": "Less than or equal to", "LessEqualThan": "Less than or equal to",
@@ -1024,6 +1026,7 @@
"PleaseAgreeToTheTerms": "Please agree to the terms", "PleaseAgreeToTheTerms": "Please agree to the terms",
"PleaseEnterReason": "Please enter a reason", "PleaseEnterReason": "Please enter a reason",
"PleaseSelect": "Please select ", "PleaseSelect": "Please select ",
"PleaseSelectAssetOrNode": "Please select at least one asset or node",
"PleaseSelectTheDataYouWantToCheck": "Please select the data you want to check", "PleaseSelectTheDataYouWantToCheck": "Please select the data you want to check",
"PolicyName": "Policy name", "PolicyName": "Policy name",
"Port": "Port", "Port": "Port",
@@ -1619,13 +1622,21 @@
"assetAddress": "Asset address", "assetAddress": "Asset address",
"assetId": "Asset ID", "assetId": "Asset ID",
"assetName": "Asset name", "assetName": "Asset name",
"clickToAdd": "Click to add",
"currentTime": "Current time", "currentTime": "Current time",
"description": "No data yet", "description": "No data yet",
"disallowSelfUpdateFields": "Not allowed to modify the current fields yourself", "disallowSelfUpdateFields": "Not allowed to modify the current fields yourself",
"forceEnableMFAHelpText": "If force enable, user can not disable by themselves", "forceEnableMFAHelpText": "If force enable, user can not disable by themselves",
"isConsoleCanUse": "is Console page can use", "isConsoleCanUse": "is Console page can use",
"overwriteProtocolsAndPortsMsg": "This operation will overwrite the protocols and ports of the selected assets. Are you sure you want to continue?",
"pleaseSelectAssets": "Please select assets",
"removeWarningMsg": "Are you sure you want to remove", "removeWarningMsg": "Are you sure you want to remove",
"selectFiles": "Selected {number} files",
"selectedAssets": "Selected assets",
"setVariable": "Set variable", "setVariable": "Set variable",
"userId": "User ID", "userId": "User ID",
"userName": "User name" "userName": "User name",
"AccessToken": "Access tokens",
"AccessTokenTip": "Access Token is a temporary credential generated through the OAuth2 (Authorization Code Grant) flow using the JumpServer client, which is used to access protected resources.",
"Revoke": "Revoke"
} }

View File

@@ -1028,6 +1028,7 @@
"PleaseAgreeToTheTerms": "Por favor, acepte los términos", "PleaseAgreeToTheTerms": "Por favor, acepte los términos",
"PleaseEnterReason": "Por favor, introduzca el motivo", "PleaseEnterReason": "Por favor, introduzca el motivo",
"PleaseSelect": "Por favor seleccione", "PleaseSelect": "Por favor seleccione",
"PleaseSelectAssetOrNode": "Por favor, seleccione un activo o nodo",
"PleaseSelectTheDataYouWantToCheck": "Por favor, seleccione los datos que necesita marcar.", "PleaseSelectTheDataYouWantToCheck": "Por favor, seleccione los datos que necesita marcar.",
"PolicyName": "Nombre de la estrategia", "PolicyName": "Nombre de la estrategia",
"Port": "Puerto", "Port": "Puerto",
@@ -1628,13 +1629,17 @@
"assetAddress": "Dirección de activo", "assetAddress": "Dirección de activo",
"assetId": "ID de activo", "assetId": "ID de activo",
"assetName": "Nombre de activo", "assetName": "Nombre de activo",
"clickToAdd": "Haga clic en agregar",
"currentTime": "Hora actual", "currentTime": "Hora actual",
"description": "Sin datos disponibles.", "description": "Sin datos disponibles.",
"disallowSelfUpdateFields": "No se permite modificar el campo actual.", "disallowSelfUpdateFields": "No se permite modificar el campo actual.",
"forceEnableMFAHelpText": "Si se habilita forzosamente, el usuario no podrá desactivarlo por sí mismo", "forceEnableMFAHelpText": "Si se habilita forzosamente, el usuario no podrá desactivarlo por sí mismo",
"isConsoleCanUse": "¿Está disponible la página de gestión?< -SEP->Agregar puerta de enlace al dominio", "isConsoleCanUse": "¿Está disponible la página de gestión?< -SEP->Agregar puerta de enlace al dominio",
"name": "Nombre de usuario", "name": "Nombre de usuario",
"overwriteProtocolsAndPortsMsg": "Esta acción reemplazará todos los protocolos y puertos, ¿continuar?",
"pleaseSelectAssets": "Por favor, seleccione un activo.",
"removeWarningMsg": "¿Está seguro de que desea eliminar?", "removeWarningMsg": "¿Está seguro de que desea eliminar?",
"selectedAssets": "Activos seleccionados",
"setVariable": "configurar parámetros", "setVariable": "configurar parámetros",
"userId": "ID de usuario", "userId": "ID de usuario",
"userName": "Nombre de usuario" "userName": "Nombre de usuario"

View File

@@ -1033,6 +1033,7 @@
"PleaseAgreeToTheTerms": "規約に同意してください", "PleaseAgreeToTheTerms": "規約に同意してください",
"PleaseEnterReason": "理由を入力してください", "PleaseEnterReason": "理由を入力してください",
"PleaseSelect": "選択してくださ", "PleaseSelect": "選択してくださ",
"PleaseSelectAssetOrNode": "資産またはノードを選択してください",
"PleaseSelectTheDataYouWantToCheck": "選択するデータをチェックしてください", "PleaseSelectTheDataYouWantToCheck": "選択するデータをチェックしてください",
"PolicyName": "ポリシー名称", "PolicyName": "ポリシー名称",
"Port": "ポート", "Port": "ポート",
@@ -1633,13 +1634,17 @@
"assetAddress": "資産アドレス", "assetAddress": "資産アドレス",
"assetId": "資産ID", "assetId": "資産ID",
"assetName": "資産名", "assetName": "資産名",
"clickToAdd": "追加をクリック",
"currentTime": "現在の時間", "currentTime": "現在の時間",
"description": "データはありません。", "description": "データはありません。",
"disallowSelfUpdateFields": "現在のフィールドを自分で変更することは許可されていません", "disallowSelfUpdateFields": "現在のフィールドを自分で変更することは許可されていません",
"forceEnableMFAHelpText": "強制的に有効化すると、ユーザーは自分で無効化することができません。", "forceEnableMFAHelpText": "強制的に有効化すると、ユーザーは自分で無効化することができません。",
"isConsoleCanUse": "管理ページの利用可能性", "isConsoleCanUse": "管理ページの利用可能性",
"name": "ユーザー名", "name": "ユーザー名",
"overwriteProtocolsAndPortsMsg": "この操作はすべてのプロトコルとポートを上書きしますが、続行してよろしいですか?",
"pleaseSelectAssets": "資産を選択してください",
"removeWarningMsg": "削除してもよろしいですか", "removeWarningMsg": "削除してもよろしいですか",
"selectedAssets": "選択した資産",
"setVariable": "パラメータ設定", "setVariable": "パラメータ設定",
"userId": "ユーザーID", "userId": "ユーザーID",
"userName": "ユーザー名" "userName": "ユーザー名"

View File

@@ -1028,6 +1028,7 @@
"PleaseAgreeToTheTerms": "약관 동의 부탁드립니다", "PleaseAgreeToTheTerms": "약관 동의 부탁드립니다",
"PleaseEnterReason": "이유를 입력하세요", "PleaseEnterReason": "이유를 입력하세요",
"PleaseSelect": "선택해주세요", "PleaseSelect": "선택해주세요",
"PleaseSelectAssetOrNode": "자산 또는 노드를 선택해 주세요",
"PleaseSelectTheDataYouWantToCheck": "선택할 데이터를 선택해 주십시오.", "PleaseSelectTheDataYouWantToCheck": "선택할 데이터를 선택해 주십시오.",
"PolicyName": "전략 이름", "PolicyName": "전략 이름",
"Port": "포트", "Port": "포트",
@@ -1628,13 +1629,17 @@
"assetAddress": "자산 주소", "assetAddress": "자산 주소",
"assetId": "자산 ID", "assetId": "자산 ID",
"assetName": "자산 이름", "assetName": "자산 이름",
"clickToAdd": "추가를 클릭해 주세요",
"currentTime": "현재 시간", "currentTime": "현재 시간",
"description": "데이터가 없습니다.", "description": "데이터가 없습니다.",
"disallowSelfUpdateFields": "현재 필드를 스스로 수정할 수 없음", "disallowSelfUpdateFields": "현재 필드를 스스로 수정할 수 없음",
"forceEnableMFAHelpText": "강제로 활성화하면 사용자가 스스로 비활성화할 수 없음", "forceEnableMFAHelpText": "강제로 활성화하면 사용자가 스스로 비활성화할 수 없음",
"isConsoleCanUse": "관리 페이지 사용 가능 여부", "isConsoleCanUse": "관리 페이지 사용 가능 여부",
"name": "사용자 이름", "name": "사용자 이름",
"overwriteProtocolsAndPortsMsg": "이 작업은 모든 프로토콜과 포트를 덮어씌우게 됩니다. 계속하시겠습니까?",
"pleaseSelectAssets": "자산을 선택해 주세요",
"removeWarningMsg": "제거할 것인지 확실합니까?", "removeWarningMsg": "제거할 것인지 확실합니까?",
"selectedAssets": "선택한 자산",
"setVariable": "설정 매개변수", "setVariable": "설정 매개변수",
"userId": "사용자 ID", "userId": "사용자 ID",
"userName": "사용자명" "userName": "사용자명"

View File

@@ -1029,6 +1029,7 @@
"PleaseAgreeToTheTerms": "Por favor, concorde com os termos", "PleaseAgreeToTheTerms": "Por favor, concorde com os termos",
"PleaseEnterReason": "Por favor, insira o motivo", "PleaseEnterReason": "Por favor, insira o motivo",
"PleaseSelect": "Por favor, selecione", "PleaseSelect": "Por favor, selecione",
"PleaseSelectAssetOrNode": "Por favor, selecione um ativo ou nó",
"PleaseSelectTheDataYouWantToCheck": "Por favor, selecione os dados que deseja marcar.", "PleaseSelectTheDataYouWantToCheck": "Por favor, selecione os dados que deseja marcar.",
"PolicyName": "Nome da Política", "PolicyName": "Nome da Política",
"Port": "Porta", "Port": "Porta",
@@ -1629,13 +1630,17 @@
"assetAddress": "Endereço do ativo", "assetAddress": "Endereço do ativo",
"assetId": "ID do ativo", "assetId": "ID do ativo",
"assetName": "Nome do ativo", "assetName": "Nome do ativo",
"clickToAdd": "Clique para adicionar",
"currentTime": "Hora atual", "currentTime": "Hora atual",
"description": "Nenhum dado disponível.", "description": "Nenhum dado disponível.",
"disallowSelfUpdateFields": "Não é permitido alterar o campo atual", "disallowSelfUpdateFields": "Não é permitido alterar o campo atual",
"forceEnableMFAHelpText": "Se for habilitado forçosamente, o usuário não pode desativar por conta própria", "forceEnableMFAHelpText": "Se for habilitado forçosamente, o usuário não pode desativar por conta própria",
"isConsoleCanUse": "Se a página de gerenciamento está disponível", "isConsoleCanUse": "Se a página de gerenciamento está disponível",
"name": "Nome de usuário", "name": "Nome de usuário",
"overwriteProtocolsAndPortsMsg": "Esta ação substituirá todos os protocolos e portas. Deseja continuar?",
"pleaseSelectAssets": "Por favor, selecione um ativo.",
"removeWarningMsg": "Tem certeza de que deseja remover", "removeWarningMsg": "Tem certeza de que deseja remover",
"selectedAssets": "Ativos selecionados",
"setVariable": "Parâmetros de configuração", "setVariable": "Parâmetros de configuração",
"userId": "ID do usuário", "userId": "ID do usuário",
"userName": "Usuário" "userName": "Usuário"

View File

@@ -122,7 +122,7 @@
"AppletHelpText": "В процессе загрузки, если приложение отсутствует, оно будет создано; если уже существует, будет выполнено обновление.", "AppletHelpText": "В процессе загрузки, если приложение отсутствует, оно будет создано; если уже существует, будет выполнено обновление.",
"AppletHostCreate": "Добавить сервер RemoteApp", "AppletHostCreate": "Добавить сервер RemoteApp",
"AppletHostDetail": "Подробности о хосте RemoteApp", "AppletHostDetail": "Подробности о хосте RemoteApp",
"AppletHostSelectHelpMessage": "При подключении к активу выбор машины публикации приложения происходит случайным образом (но предпочтение отдается последней использованной). Если вы хотите назначить активу определенную машину, вы можете использовать следующие теги: [publishing machine: имя машины публикации] или [AppletHost: имя машины публикации]; <br>при выборе учетной записи для машины публикации в следующих ситуациях будет выбрана собственная <b>учетная запись пользователя с тем же именем или собственная учетная запись (начинающаяся с js)</b>, в противном случае будет использоваться публичная учетная запись (начинающаяся с jms):<br>&nbsp; 1. И машина публикации, и приложение поддерживают одновременные подключения; <br>&nbsp; 2. Машина публикации поддерживает одновременные подключения, а приложение — нет, и текущее приложение не использует специализированную учетную запись; <br>&nbsp; 3. Машина публикации не поддерживает одновременные подключения, а приложение может как поддерживать, так и не поддерживать одновременные подключения, и ни одно приложение не использует специализированную учетную запись; <br> Примечание: поддержка одновременных подключений со стороны приложения определяется разработчиком, а поддержка одновременных подключений со стороны хоста определяется настройкой «один пользователь — одна сессия» в конфигурации машины публикации.", "AppletHostSelectHelpMessage": "При подключении к активу выбор сервера публикации приложения происходит случайным образом (но предпочтение отдается последнему использованному).<br>\nЕсли необходимо закрепить сервер публикации за конкретным активом, можно указать один из тегов:\n[AppletHost:имя_сервера], [AppletHostOnly:имя_сервера].<br>\nПри подключении к выбранному серверу публикации и выборе учётной записи применяются следующие правила:<br>\nв перечисленных ниже случаях будет использована <b>учетная запись пользователя с тем же именем</b> или <b>специальная учётная запись (начинающаяся с js)</b>,<br>\nв противном случае будет использоваться общая учётная запись (начинающаяся с jms)<br>\n1. И сервер публикации, и приложение поддерживают одновременные подключения;<br>\n2. Сервер публикации поддерживает одновременные подключения, а приложение — нет, и текущее приложение не использует специальную учётную запись;<br>\n3. Сервер публикации не поддерживает одновременные подключения, а приложение может как поддерживать, так и не поддерживать одновременные подключения, и ни одно приложение не использует специализированную учетную запись;<br>\n\nПримечание: поддержка одновременных подключений со стороны приложения определяется разработчиком,<br>\nа поддержка одновременных подключений со стороны хоста определяется настройкой «один пользователь — одна сессия» в настройках сервера публикации.",
"AppletHostUpdate": "Обновить машину публикации RemoteApp", "AppletHostUpdate": "Обновить машину публикации RemoteApp",
"AppletHostZoneHelpText": "Эта зона принадлежит Системной организации", "AppletHostZoneHelpText": "Эта зона принадлежит Системной организации",
"AppletHosts": "Хост RemoteApp", "AppletHosts": "Хост RemoteApp",
@@ -447,10 +447,10 @@
"DangerCommand": "Опасная команда", "DangerCommand": "Опасная команда",
"DangerousCommandNum": "Всего опасных команд", "DangerousCommandNum": "Всего опасных команд",
"Dashboard": "Панель инструментов", "Dashboard": "Панель инструментов",
"DataMasking": "Демаскировка данных", "DataMasking": "Маскирование данных",
"DataMaskingFieldsPatternHelpTip": "Поддержка нескольких имен полей, разделенных запятой, поддержка подстановочных знаков *\nНапример:\nОдно имя поля: password означает, что будет проведено действие по хранению в тайне поля password.\nНесколько имен полей: password, secret означает сохранение в тайне полей password и secret.\nПодстановочный знак *: password* означает, что действие будет применено к полям, содержащим префикс password.\nПодстановочный знак *: .*password означает, что действие будет применено к полям, содержащим суффикс password.", "DataMaskingFieldsPatternHelpTip": "Поддерживается нескольких имён полей, разделённых запятыми, а также использование подстановочного знака *.\nПримеры:\nОдно имя поля: password — выполняется маскирование только поля password.\nНесколько имён полей: password,secret — выполняется маскирование полей password и secret.\nПодстановочный знак *: password* — выполняется маскирование всех полей, имя которых начинается с password.\nПодстановочный знак .*: .*password — выполняется маскирование всех полей, имя которых оканчивается на password",
"DataMaskingRuleHelpHelpMsg": "При подключении к базе данных активов результаты запроса могут быть обработаны в соответствии с этим правилом для обеспечения безопасности данных.", "DataMaskingRuleHelpHelpMsg": "При подключении к активу базы данных результаты запросов могут быть подвергнуты маскированию в соответствии с этим правилом",
"DataMaskingRuleHelpHelpText": "При подключении к базе данных активов можно обезопасить результаты запроса в соответствии с данным правилом.", "DataMaskingRuleHelpHelpText": "При подключении к активу базы данных можно выполнять маскирование результатов запросов в соответствии с этим правилом",
"Database": "База данных", "Database": "База данных",
"DatabaseCreate": "Создать актив - база данных", "DatabaseCreate": "Создать актив - база данных",
"DatabasePort": "Порт протокола базы данных", "DatabasePort": "Порт протокола базы данных",
@@ -491,7 +491,7 @@
"DeleteOrgMsg": "Пользователь, Группа пользователей, Актив, Папка, Тег, Зона, Разрешение", "DeleteOrgMsg": "Пользователь, Группа пользователей, Актив, Папка, Тег, Зона, Разрешение",
"DeleteOrgTitle": "Пожалуйста, сначала удалите следующие ресурсы в организации", "DeleteOrgTitle": "Пожалуйста, сначала удалите следующие ресурсы в организации",
"DeleteReleasedAssets": "Удалить освобожденные активы", "DeleteReleasedAssets": "Удалить освобожденные активы",
"DeleteRemoteAccount": "Удалить удаленный аккаунт", "DeleteRemoteAccount": "Удалить УЗ на активе",
"DeleteSelected": "Удалить выбранное", "DeleteSelected": "Удалить выбранное",
"DeleteSuccess": "Успешно удалено", "DeleteSuccess": "Успешно удалено",
"DeleteSuccessMsg": "Успешно удалено", "DeleteSuccessMsg": "Успешно удалено",
@@ -729,7 +729,7 @@
"InstanceName": "Название экземпляра", "InstanceName": "Название экземпляра",
"InstanceNamePartIp": "Название экземпляра и часть IP", "InstanceNamePartIp": "Название экземпляра и часть IP",
"InstancePlatformName": "Название платформы экземпляра", "InstancePlatformName": "Название платформы экземпляра",
"Integration": "Интеграция приложений", "Integration": "Интеграция",
"Interface": "Сетевой интерфейс", "Interface": "Сетевой интерфейс",
"InterfaceSettings": "Настройки интерфейса", "InterfaceSettings": "Настройки интерфейса",
"Interval": "Интервал", "Interval": "Интервал",
@@ -1028,6 +1028,7 @@
"PleaseAgreeToTheTerms": "Пожалуйста, согласитесь с условиями", "PleaseAgreeToTheTerms": "Пожалуйста, согласитесь с условиями",
"PleaseEnterReason": "Введите причину", "PleaseEnterReason": "Введите причину",
"PleaseSelect": "Пожалуйста, выберите ", "PleaseSelect": "Пожалуйста, выберите ",
"PleaseSelectAssetOrNode": "Пожалуйста, выберите актив или узел.",
"PleaseSelectTheDataYouWantToCheck": "Пожалуйста, выберите данные, которые нужно отметить", "PleaseSelectTheDataYouWantToCheck": "Пожалуйста, выберите данные, которые нужно отметить",
"PolicyName": "Название политики", "PolicyName": "Название политики",
"Port": "Порт", "Port": "Порт",
@@ -1045,7 +1046,7 @@
"PrivilegedOnly": "Только привилегированные", "PrivilegedOnly": "Только привилегированные",
"PrivilegedTemplate": "Привилегированные", "PrivilegedTemplate": "Привилегированные",
"Processing": "В процессе", "Processing": "В процессе",
"ProcessingMessage": "Задача в процессе, пожалуйста, подождите ⏳", "ProcessingMessage": "Задача выполняется, пожалуйста, подождите ⏳",
"Product": "Продукт", "Product": "Продукт",
"ProfileSetting": "Данные профиля", "ProfileSetting": "Данные профиля",
"Project": "Название проекта", "Project": "Название проекта",
@@ -1117,7 +1118,7 @@
"RelevantCommand": "Команда", "RelevantCommand": "Команда",
"RelevantSystemUser": "Системный пользователь", "RelevantSystemUser": "Системный пользователь",
"RemoteAddr": "Удалённый адрес", "RemoteAddr": "Удалённый адрес",
"RemoteAssetFoundAccountDeleteMsg": "Удалить аккаунты, обнаруженные с удалённых активов", "RemoteAssetFoundAccountDeleteMsg": "Удалить УЗ, обнаруженные на удалённых активах",
"RemoteLoginProtocolUsageDistribution": "Распределение протоколов входа в активы", "RemoteLoginProtocolUsageDistribution": "Распределение протоколов входа в активы",
"Remove": "Удалить", "Remove": "Удалить",
"RemoveAssetFromNode": "Удалить активы из папки", "RemoveAssetFromNode": "Удалить активы из папки",
@@ -1628,13 +1629,17 @@
"assetAddress": "Адрес актива", "assetAddress": "Адрес актива",
"assetId": "ID актива", "assetId": "ID актива",
"assetName": "Название актива", "assetName": "Название актива",
"clickToAdd": "Нажмите, чтобы добавить",
"currentTime": "Текущее время", "currentTime": "Текущее время",
"description": "Нет данных", "description": "Нет данных",
"disallowSelfUpdateFields": "Не разрешено самостоятельно изменять текущие поля.", "disallowSelfUpdateFields": "Не разрешено самостоятельно изменять текущие поля.",
"forceEnableMFAHelpText": "При принудительном включении пользователь не может отключить самостоятельно", "forceEnableMFAHelpText": "При принудительном включении пользователь не может отключить самостоятельно",
"isConsoleCanUse": "Доступна ли Консоль", "isConsoleCanUse": "Доступна ли Консоль",
"name": "Имя пользователя", "name": "Имя пользователя",
"overwriteProtocolsAndPortsMsg": "Это действие заменит все протоколы и порты. Продолжить?",
"pleaseSelectAssets": "Пожалуйста, выберите актив",
"removeWarningMsg": "Вы уверены, что хотите удалить", "removeWarningMsg": "Вы уверены, что хотите удалить",
"selectedAssets": "Выбранные активы",
"setVariable": "Задать переменную", "setVariable": "Задать переменную",
"userId": "ID пользователя", "userId": "ID пользователя",
"userName": "Имя пользователя" "userName": "Имя пользователя"

View File

@@ -1028,6 +1028,7 @@
"PleaseAgreeToTheTerms": "Vui lòng đồng ý với các điều khoản", "PleaseAgreeToTheTerms": "Vui lòng đồng ý với các điều khoản",
"PleaseEnterReason": "Vui lòng nhập lý do", "PleaseEnterReason": "Vui lòng nhập lý do",
"PleaseSelect": "Vui lòng chọn", "PleaseSelect": "Vui lòng chọn",
"PleaseSelectAssetOrNode": "Vui lòng chọn tài sản hoặc nút",
"PleaseSelectTheDataYouWantToCheck": "Vui lòng chọn dữ liệu cần đánh dấu", "PleaseSelectTheDataYouWantToCheck": "Vui lòng chọn dữ liệu cần đánh dấu",
"PolicyName": "Tên chính sách", "PolicyName": "Tên chính sách",
"Port": "Cổng", "Port": "Cổng",
@@ -1628,13 +1629,17 @@
"assetAddress": "Địa chỉ tài sản", "assetAddress": "Địa chỉ tài sản",
"assetId": "ID tài sản", "assetId": "ID tài sản",
"assetName": "Tên tài sản", "assetName": "Tên tài sản",
"clickToAdd": "Nhấp để thêm",
"currentTime": "Thời gian hiện tại", "currentTime": "Thời gian hiện tại",
"description": "Chưa có dữ liệu.", "description": "Chưa có dữ liệu.",
"disallowSelfUpdateFields": "Không cho phép tự chỉnh sửa trường hiện tại", "disallowSelfUpdateFields": "Không cho phép tự chỉnh sửa trường hiện tại",
"forceEnableMFAHelpText": "Nếu buộc kích hoạt, người dùng sẽ không thể tự động vô hiệu hóa", "forceEnableMFAHelpText": "Nếu buộc kích hoạt, người dùng sẽ không thể tự động vô hiệu hóa",
"isConsoleCanUse": "Trang quản lý có khả dụng không", "isConsoleCanUse": "Trang quản lý có khả dụng không",
"name": "Tên người dùng", "name": "Tên người dùng",
"overwriteProtocolsAndPortsMsg": "Hành động này sẽ ghi đè lên tất cả các giao thức và cổng, có tiếp tục không?",
"pleaseSelectAssets": "Vui lòng chọn tài sản.",
"removeWarningMsg": "Bạn có chắc chắn muốn xóa bỏ?", "removeWarningMsg": "Bạn có chắc chắn muốn xóa bỏ?",
"selectedAssets": "Tài sản đã chọn",
"setVariable": "Cài đặt tham số", "setVariable": "Cài đặt tham số",
"userId": "ID người dùng", "userId": "ID người dùng",
"userName": "Tên người dùng" "userName": "Tên người dùng"

View File

@@ -279,6 +279,7 @@
"CACertificate": "CA 证书", "CACertificate": "CA 证书",
"CAS": "CAS", "CAS": "CAS",
"CMPP2": "CMPP v2.0", "CMPP2": "CMPP v2.0",
"CTYun": "天翼云",
"CTYunPrivate": "天翼私有云", "CTYunPrivate": "天翼私有云",
"CalculationResults": "cron 表达式错误", "CalculationResults": "cron 表达式错误",
"CallRecords": "调用记录", "CallRecords": "调用记录",
@@ -729,7 +730,7 @@
"InstanceName": "实例名称", "InstanceName": "实例名称",
"InstanceNamePartIp": "实例名称和部分IP", "InstanceNamePartIp": "实例名称和部分IP",
"InstancePlatformName": "实例平台名称", "InstancePlatformName": "实例平台名称",
"Integration": "应用集成", "Integration": "集成",
"Interface": "网络接口", "Interface": "网络接口",
"InterfaceSettings": "界面设置", "InterfaceSettings": "界面设置",
"Interval": "间隔", "Interval": "间隔",
@@ -1028,6 +1029,7 @@
"PleaseAgreeToTheTerms": "请同意条款", "PleaseAgreeToTheTerms": "请同意条款",
"PleaseEnterReason": "请输入原因", "PleaseEnterReason": "请输入原因",
"PleaseSelect": "请选择", "PleaseSelect": "请选择",
"PleaseSelectAssetOrNode": "请选择资产或节点",
"PleaseSelectTheDataYouWantToCheck": "请选择需要勾选的数据", "PleaseSelectTheDataYouWantToCheck": "请选择需要勾选的数据",
"PolicyName": "策略名称", "PolicyName": "策略名称",
"Port": "端口", "Port": "端口",
@@ -1628,14 +1630,23 @@
"assetAddress": "资产地址", "assetAddress": "资产地址",
"assetId": "资产 ID", "assetId": "资产 ID",
"assetName": "资产名称", "assetName": "资产名称",
"clickToAdd": "点击添加",
"currentTime": "当前时间", "currentTime": "当前时间",
"description": "暂无数据", "description": "暂无数据",
"disallowSelfUpdateFields": "不允许自己修改当前字段", "disallowSelfUpdateFields": "不允许自己修改当前字段",
"forceEnableMFAHelpText": "如果强制启用,用户无法自行禁用", "forceEnableMFAHelpText": "如果强制启用,用户无法自行禁用",
"isConsoleCanUse": "管理页面是否可用", "isConsoleCanUse": "管理页面是否可用",
"name": "用户名称", "name": "用户名称",
"overwriteProtocolsAndPortsMsg": "此操作将覆盖所有协议和端口,是否继续?",
"pleaseSelectAssets": "请选择资产",
"removeWarningMsg": "你确定要移除", "removeWarningMsg": "你确定要移除",
"selectedAssets": "已选资产",
"setVariable": "设置参数", "setVariable": "设置参数",
"userId": "用户ID", "userId": "用户ID",
"userName": "用户名" "userName": "用户名",
"Risk": "风险",
"selectFiles": "已选择选择{number}文件",
"AccessToken": "访问令牌",
"AccessTokenTip": "访问令牌是通过 JumpServer 客户端使用 OAuth2授权码授权流程生成的临时凭证用于访问受保护的资源。",
"Revoke": "撤销"
} }

View File

@@ -1033,6 +1033,7 @@
"PleaseAgreeToTheTerms": "請同意條款", "PleaseAgreeToTheTerms": "請同意條款",
"PleaseEnterReason": "請輸入原因", "PleaseEnterReason": "請輸入原因",
"PleaseSelect": "請選擇", "PleaseSelect": "請選擇",
"PleaseSelectAssetOrNode": "請選擇資產或節點",
"PleaseSelectTheDataYouWantToCheck": "請選擇需要勾選的數據", "PleaseSelectTheDataYouWantToCheck": "請選擇需要勾選的數據",
"PolicyName": "策略名稱", "PolicyName": "策略名稱",
"Port": "端口", "Port": "端口",
@@ -1633,13 +1634,17 @@
"assetAddress": "資產地址", "assetAddress": "資產地址",
"assetId": "資產 ID", "assetId": "資產 ID",
"assetName": "資產名稱", "assetName": "資產名稱",
"clickToAdd": "點擊添加",
"currentTime": "當前時間", "currentTime": "當前時間",
"description": "目前沒有數據。", "description": "目前沒有數據。",
"disallowSelfUpdateFields": "不允許自己修改當前欄位", "disallowSelfUpdateFields": "不允許自己修改當前欄位",
"forceEnableMFAHelpText": "如果強制啟用,用戶無法自行禁用", "forceEnableMFAHelpText": "如果強制啟用,用戶無法自行禁用",
"isConsoleCanUse": "管理頁面是否可用", "isConsoleCanUse": "管理頁面是否可用",
"name": "用戶名稱", "name": "用戶名稱",
"overwriteProtocolsAndPortsMsg": "此操作將覆蓋所有協議和端口,是否繼續?",
"pleaseSelectAssets": "請選擇資產",
"removeWarningMsg": "你確定要移除", "removeWarningMsg": "你確定要移除",
"selectedAssets": "已選資產",
"setVariable": "設置參數", "setVariable": "設置參數",
"userId": "用戶ID", "userId": "用戶ID",
"userName": "用戶名" "userName": "用戶名"

View File

@@ -86,7 +86,7 @@
"Expand all asset": "Развернуть все активы в папке", "Expand all asset": "Развернуть все активы в папке",
"Expire time": "Срок действия", "Expire time": "Срок действия",
"ExpiredTime": "Срок действия", "ExpiredTime": "Срок действия",
"Face Verify": "Проверка лица", "Face Verify": "Проверка по лицу",
"Face online required": "Для входа требуется верификация по лицу и мониторинг. Продолжить?", "Face online required": "Для входа требуется верификация по лицу и мониторинг. Продолжить?",
"Face verify": "Распознавание лица", "Face verify": "Распознавание лица",
"Face verify required": "Для входа требуется верификация по лицу. Продолжить?", "Face verify required": "Для входа требуется верификация по лицу. Продолжить?",
@@ -107,7 +107,7 @@
"GUI": "Графический интерфейс", "GUI": "Графический интерфейс",
"General": "Основные настройки", "General": "Основные настройки",
"Go to Settings": "Перейти в настройки", "Go to Settings": "Перейти в настройки",
"Go to profile": "Перейти к личной информации", "Go to profile": "Перейти в профиль",
"Help": "Помощь", "Help": "Помощь",
"Help or download": "Помощь → Скачать", "Help or download": "Помощь → Скачать",
"Help text": "Описание", "Help text": "Описание",
@@ -151,7 +151,7 @@
"No": "Нет", "No": "Нет",
"No account available": "Нет доступных учетных записей", "No account available": "Нет доступных учетных записей",
"No available connect method": "Нет доступного метода подключения", "No available connect method": "Нет доступного метода подключения",
"No facial features": "Нет характеристик лица, пожалуйста, перейдите в личную инфрмацию для привязки", "No facial features": "Данные лица отсутствуют. Пожалуйста, перейдите на страницу личной информации, чтобы привязать их. ",
"No matching found": "Совпадений не найдено", "No matching found": "Совпадений не найдено",
"No permission": "Нет разрешений", "No permission": "Нет разрешений",
"No protocol available": "Нет доступных протоколов", "No protocol available": "Нет доступных протоколов",

View File

@@ -1,4 +1,3 @@
from .aggregate import *
from .dashboard import IndexApi from .dashboard import IndexApi
from .health import PrometheusMetricsApi, HealthCheckView from .health import PrometheusMetricsApi, HealthCheckView
from .search import GlobalSearchView from .search import GlobalSearchView

View File

@@ -1,9 +0,0 @@
from .detail import ResourceDetailApi
from .list import ResourceListApi
from .supported import ResourceTypeListApi
__all__ = [
'ResourceListApi',
'ResourceDetailApi',
'ResourceTypeListApi',
]

View File

@@ -1,57 +0,0 @@
list_params = [
{
"name": "search",
"in": "query",
"description": "A search term.",
"required": False,
"type": "string"
},
{
"name": "order",
"in": "query",
"description": "Which field to use when ordering the results.",
"required": False,
"type": "string"
},
{
"name": "limit",
"in": "query",
"description": "Number of results to return per page. Default is 10.",
"required": False,
"type": "integer"
},
{
"name": "offset",
"in": "query",
"description": "The initial index from which to return the results.",
"required": False,
"type": "integer"
},
]
common_params = [
{
"name": "resource",
"in": "path",
"description": """Resource to query, e.g. users, assets, permissions, acls, user-groups, policies, nodes, hosts,
devices, clouds, webs, databases,
gpts, ds, customs, platforms, zones, gateways, protocol-settings, labels, virtual-accounts,
gathered-accounts, account-templates, account-template-secrets, account-backups, account-backup-executions,
change-secret-automations, change-secret-executions, change-secret-records, gather-account-automations,
gather-account-executions, push-account-automations, push-account-executions, push-account-records,
check-account-automations, check-account-executions, account-risks, integration-apps, asset-permissions,
zones, gateways, virtual-accounts, gathered-accounts, account-templates, account-template-secrets,,
GET /api/v1/resources/ to get full supported resource.
""",
"required": True,
"type": "string"
},
{
"name": "X-JMS-ORG",
"in": "header",
"description": "The organization ID to use for the request. Organization is the namespace for resources, if not set, use default org",
"required": False,
"type": "string"
}
]

View File

@@ -1,75 +0,0 @@
# views.py
from drf_spectacular.utils import extend_schema, OpenApiParameter
from rest_framework.permissions import IsAuthenticated
from rest_framework.views import APIView
from .const import common_params
from .proxy import ProxyMixin
from .utils import param_dic_to_param
one_param = [
{
'name': 'id',
'in': 'path',
'required': True,
'description': 'Resource ID',
'type': 'string',
}
]
object_params = [
param_dic_to_param(d)
for d in common_params + one_param
]
class ResourceDetailApi(ProxyMixin, APIView):
permission_classes = [IsAuthenticated]
@extend_schema(
operation_id="get_resource_detail",
summary="Get resource detail",
parameters=object_params,
description="""
Get resource detail.
{resource} is the resource name, GET /api/v1/resources/ to get full supported resource.
""",
)
def get(self, request, resource, pk=None):
return self._proxy(request, resource, pk=pk, action='retrieve')
@extend_schema(
operation_id="delete_resource",
summary="Delete the resource",
parameters=object_params,
description="Delete the resource, and can not be restored",
)
def delete(self, request, resource, pk=None):
return self._proxy(request, resource, pk, action='destroy')
@extend_schema(
operation_id="update_resource",
summary="Update the resource property",
parameters=object_params,
description="""
Update the resource property, all property will be update,
{resource} is the resource name, GET /api/v1/resources/ to get full supported resource.
OPTION /api/v1/resources/{resource}/{id}/?action=put to get field type and helptext.
""",
)
def put(self, request, resource, pk=None):
return self._proxy(request, resource, pk, action='update')
@extend_schema(
operation_id="partial_update_resource",
summary="Update the resource property",
parameters=object_params,
description="""
Partial update the resource property, only request property will be update,
OPTION /api/v1/resources/{resource}/{id}/?action=patch to get field type and helptext.
""",
)
def patch(self, request, resource, pk=None):
return self._proxy(request, resource, pk, action='partial_update')

View File

@@ -1,87 +0,0 @@
# views.py
from drf_spectacular.utils import extend_schema
from rest_framework.routers import DefaultRouter
from rest_framework.views import APIView
from .const import list_params, common_params
from .proxy import ProxyMixin
from .utils import param_dic_to_param
router = DefaultRouter()
BASE_URL = "http://localhost:8080"
list_params = [
param_dic_to_param(d)
for d in list_params + common_params
]
create_params = [
param_dic_to_param(d)
for d in common_params
]
list_schema = {
"required": [
"count",
"results"
],
"type": "object",
"properties": {
"count": {
"type": "integer"
},
"next": {
"type": "string",
"format": "uri",
"x-nullable": True
},
"previous": {
"type": "string",
"format": "uri",
"x-nullable": True
},
"results": {
"type": "array",
"items": {
}
}
}
}
from drf_spectacular.openapi import OpenApiResponse, OpenApiExample
class ResourceListApi(ProxyMixin, APIView):
@extend_schema(
operation_id="get_resource_list",
summary="Get resource list",
parameters=list_params,
responses={200: OpenApiResponse(description="Resource list response")},
description="""
Get resource list, you should set the resource name in the url.
OPTIONS /api/v1/resources/{resource}/?action=get to get every type resource's field type and help text.
""",
)
# ↓↓↓ Swagger 自动文档 ↓↓↓
def get(self, request, resource):
return self._proxy(request, resource)
@extend_schema(
operation_id="create_resource_by_type",
summary="Create resource",
parameters=create_params,
description="""
Create resource,
OPTIONS /api/v1/resources/{resource}/?action=post to get every resource type field type and helptext, and
you will know how to create it.
""",
)
def post(self, request, resource, pk=None):
if not resource:
resource = request.data.pop('resource', '')
return self._proxy(request, resource, pk, action='create')
def options(self, request, resource, pk=None):
return self._proxy(request, resource, pk, action='metadata')

View File

@@ -1,75 +0,0 @@
# views.py
from urllib.parse import urlencode
import requests
from rest_framework.exceptions import NotFound, APIException
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.routers import DefaultRouter
from rest_framework.views import APIView
from .utils import get_full_resource_map
router = DefaultRouter()
BASE_URL = "http://localhost:8080"
class ProxyMixin(APIView):
"""
通用资源代理 API支持动态路径、自动文档生成
"""
permission_classes = [IsAuthenticated]
def _build_url(self, resource_name: str, pk: str = None, query_params=None):
resource_map = get_full_resource_map()
resource = resource_map.get(resource_name)
if not resource:
raise NotFound(f"Unknown resource: {resource_name}")
base_path = resource['path']
if pk:
base_path += f"{pk}/"
if query_params:
base_path += f"?{urlencode(query_params)}"
return f"{BASE_URL}{base_path}"
def _proxy(self, request, resource: str, pk: str = None, action='list'):
method = request.method.lower()
if method not in ['get', 'post', 'put', 'patch', 'delete', 'options']:
raise APIException("Unsupported method")
if not resource or resource == '{resource}':
if request.data:
resource = request.data.get('resource')
query_params = request.query_params.dict()
if action == 'list':
query_params['limit'] = 10
url = self._build_url(resource, pk, query_params)
headers = {k: v for k, v in request.headers.items() if k.lower() != 'host'}
cookies = request.COOKIES
body = request.body if method in ['post', 'put', 'patch'] else None
try:
resp = requests.request(
method=method,
url=url,
headers=headers,
cookies=cookies,
data=body,
timeout=10,
)
content_type = resp.headers.get('Content-Type', '')
if 'application/json' in content_type:
data = resp.json()
else:
data = resp.text # 或者 bytesresp.content
return Response(data=data, status=resp.status_code)
except requests.RequestException as e:
raise APIException(f"Proxy request failed: {str(e)}")

View File

@@ -1,45 +0,0 @@
# views.py
from drf_spectacular.utils import extend_schema
from rest_framework import serializers
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.routers import DefaultRouter
from rest_framework.views import APIView
from .utils import get_full_resource_map
router = DefaultRouter()
BASE_URL = "http://localhost:8080"
class ResourceTypeResourceSerializer(serializers.Serializer):
name = serializers.CharField()
path = serializers.CharField()
app = serializers.CharField()
verbose_name = serializers.CharField()
description = serializers.CharField()
class ResourceTypeListApi(APIView):
permission_classes = [IsAuthenticated]
@extend_schema(
operation_id="get_supported_resources",
summary="Get-all-support-resources",
description="Get all support resources, name, path, verbose_name description",
responses={200: ResourceTypeResourceSerializer(many=True)}, # Specify the response serializer
)
def get(self, request):
result = []
resource_map = get_full_resource_map()
for name, desc in resource_map.items():
desc = resource_map.get(name, {})
resource = {
"name": name,
**desc,
"path": f'/api/v1/resources/{name}/',
}
result.append(resource)
return Response(result)

View File

@@ -1,128 +0,0 @@
# views.py
import re
from functools import lru_cache
from typing import Dict
from django.urls import URLPattern
from django.urls import URLResolver
from drf_spectacular.utils import OpenApiParameter
from rest_framework.routers import DefaultRouter
router = DefaultRouter()
BASE_URL = "http://localhost:8080"
def clean_path(path: str) -> str:
"""
清理掉 DRF 自动生成的正则格式内容,让其变成普通 RESTful URL path。
"""
# 去掉格式后缀匹配: \.(?P<format>xxx)
path = re.sub(r'\\\.\(\?P<format>[^)]+\)', '', path)
# 去掉括号式格式匹配
path = re.sub(r'\(\?P<format>[^)]+\)', '', path)
# 移除 DRF 中正则参数的部分 (?P<param>pattern)
path = re.sub(r'\(\?P<\w+>[^)]+\)', '{param}', path)
# 如果有多个括号包裹的正则(比如前缀路径),去掉可选部分包装
path = re.sub(r'\(\(([^)]+)\)\?\)', r'\1', path) # ((...))? => ...
# 去掉中间和两边的 ^ 和 $
path = path.replace('^', '').replace('$', '')
# 去掉尾部 ?/
path = re.sub(r'\?/?$', '', path)
# 去掉反斜杠
path = path.replace('\\', '')
# 替换多重斜杠
path = re.sub(r'/+', '/', path)
# 添加开头斜杠,移除多余空格
path = path.strip()
if not path.startswith('/'):
path = '/' + path
if not path.endswith('/'):
path += '/'
return path
def extract_resource_paths(urlpatterns, prefix='/api/v1/') -> Dict[str, Dict[str, str]]:
resource_map = {}
for pattern in urlpatterns:
if isinstance(pattern, URLResolver):
nested_prefix = prefix + str(pattern.pattern)
resource_map.update(extract_resource_paths(pattern.url_patterns, nested_prefix))
elif isinstance(pattern, URLPattern):
callback = pattern.callback
actions = getattr(callback, 'actions', {})
if not actions:
continue
if 'get' in actions and actions['get'] == 'list':
path = clean_path(prefix + str(pattern.pattern))
# 尝试获取资源名称
name = pattern.name
if name and name.endswith('-list'):
resource = name[:-5]
else:
resource = path.strip('/').split('/')[-1]
# 不强行加 s资源名保持原状即可
resource = resource if resource.endswith('s') else resource + 's'
# 获取 View 类和 model 的 verbose_name
view_cls = getattr(callback, 'cls', None)
model = None
if view_cls:
queryset = getattr(view_cls, 'queryset', None)
if queryset is not None:
model = getattr(queryset, 'model', None)
else:
# 有些 View 用 get_queryset()
try:
instance = view_cls()
qs = instance.get_queryset()
model = getattr(qs, 'model', None)
except Exception:
pass
if not model:
continue
app = str(getattr(model._meta, 'app_label', ''))
verbose_name = str(getattr(model._meta, 'verbose_name', ''))
resource_map[resource] = {
'path': path,
'app': app,
'verbose_name': verbose_name,
'description': model.__doc__.__str__()
}
print("Extracted resource paths:", list(resource_map.keys()))
return resource_map
def param_dic_to_param(d):
return OpenApiParameter(
name=d['name'], location=d['in'],
description=d['description'], type=d['type'], required=d.get('required', False)
)
@lru_cache()
def get_full_resource_map():
from apps.jumpserver.urls import resource_api
resource_map = extract_resource_paths(resource_api)
print("Building URL for resource:", resource_map)
return resource_map

View File

@@ -1,6 +1,6 @@
from collections import defaultdict from collections import defaultdict
from django.db.models import Count, Max, F, CharField from django.db.models import Count, Max, F, CharField, Q
from django.db.models.functions import Cast from django.db.models.functions import Cast
from django.http.response import JsonResponse from django.http.response import JsonResponse
from django.utils import timezone from django.utils import timezone
@@ -18,8 +18,8 @@ from common.utils import lazyproperty
from common.utils.timezone import local_now, local_zero_hour from common.utils.timezone import local_now, local_zero_hour
from ops.const import JobStatus from ops.const import JobStatus
from orgs.caches import OrgResourceStatisticsCache from orgs.caches import OrgResourceStatisticsCache
from orgs.utils import current_org from orgs.utils import current_org, filter_org_queryset
from terminal.const import RiskLevelChoices from terminal.const import RiskLevelChoices, CommandStorageType
from terminal.models import Session, CommandStorage from terminal.models import Session, CommandStorage
__all__ = ['IndexApi'] __all__ = ['IndexApi']
@@ -123,15 +123,18 @@ class DateTimeMixin:
return self.get_logs_queryset_filter(qs, 'date_start') return self.get_logs_queryset_filter(qs, 'date_start')
@lazyproperty @lazyproperty
def command_queryset_list(self): def command_type_queryset_list(self):
qs_list = [] qs_list = []
for storage in CommandStorage.objects.all(): for storage in CommandStorage.objects.exclude(name='null'):
if not storage.is_valid(): if not storage.is_valid():
continue continue
qs = storage.get_command_queryset() qs = storage.get_command_queryset()
qs_list.append(self.get_logs_queryset_filter( qs = filter_org_queryset(qs)
qs = self.get_logs_queryset_filter(
qs, 'timestamp', is_timestamp=True qs, 'timestamp', is_timestamp=True
)) )
qs_list.append((storage.type, qs))
return qs_list return qs_list
@lazyproperty @lazyproperty
@@ -143,7 +146,7 @@ class DateTimeMixin:
class DatesLoginMetricMixin: class DatesLoginMetricMixin:
dates_list: list dates_list: list
date_start_end: tuple date_start_end: tuple
command_queryset_list: list command_type_queryset_list: list
sessions_queryset: Session.objects sessions_queryset: Session.objects
ftp_logs_queryset: FTPLog.objects ftp_logs_queryset: FTPLog.objects
job_logs_queryset: JobLog.objects job_logs_queryset: JobLog.objects
@@ -261,11 +264,25 @@ class DatesLoginMetricMixin:
@lazyproperty @lazyproperty
def command_statistics(self): def command_statistics(self):
def _count_pair(_tp, _qs):
if _tp == CommandStorageType.es:
total = _qs.count(limit_to_max_result_window=False)
danger = _qs.filter(risk_level=RiskLevelChoices.reject) \
.count(limit_to_max_result_window=False)
return total, danger
agg = _qs.aggregate(
total=Count('pk'),
danger=Count('pk', filter=Q(risk_level=RiskLevelChoices.reject)),
)
return (agg['total'] or 0), (agg['danger'] or 0)
total_amount = 0 total_amount = 0
danger_amount = 0 danger_amount = 0
for qs in self.command_queryset_list: for tp, qs in self.command_type_queryset_list:
total_amount += qs.count() t, d = _count_pair(tp, qs)
danger_amount += qs.filter(risk_level=RiskLevelChoices.reject).count() total_amount += t
danger_amount += d
return total_amount, danger_amount return total_amount, danger_amount
@lazyproperty @lazyproperty

View File

@@ -381,7 +381,6 @@ class Config(dict):
'CAS_USERNAME_ATTRIBUTE': 'cas:user', 'CAS_USERNAME_ATTRIBUTE': 'cas:user',
'CAS_APPLY_ATTRIBUTES_TO_USER': False, 'CAS_APPLY_ATTRIBUTES_TO_USER': False,
'CAS_RENAME_ATTRIBUTES': {'cas:user': 'username'}, 'CAS_RENAME_ATTRIBUTES': {'cas:user': 'username'},
'CAS_CREATE_USER': True,
'CAS_ORG_IDS': [DEFAULT_ID], 'CAS_ORG_IDS': [DEFAULT_ID],
'AUTH_SSO': False, 'AUTH_SSO': False,
@@ -692,9 +691,9 @@ class Config(dict):
'FTP_FILE_MAX_STORE': 0, 'FTP_FILE_MAX_STORE': 0,
# API 分页 # API 分页
'MAX_LIMIT_PER_PAGE': 10000, # 给导出用 'MAX_LIMIT_PER_PAGE': 10000, # 给导出用
'MAX_PAGE_SIZE': 1000, 'MAX_PAGE_SIZE': 1000,
'DEFAULT_PAGE_SIZE': 200, # 给没有请求分页的用 'DEFAULT_PAGE_SIZE': 200, # 给没有请求分页的用
'LIMIT_SUPER_PRIV': False, 'LIMIT_SUPER_PRIV': False,
@@ -702,15 +701,7 @@ class Config(dict):
'CHAT_AI_ENABLED': False, 'CHAT_AI_ENABLED': False,
'CHAT_AI_METHOD': 'api', 'CHAT_AI_METHOD': 'api',
'CHAT_AI_EMBED_URL': '', 'CHAT_AI_EMBED_URL': '',
'CHAT_AI_TYPE': 'gpt', 'CHAT_AI_PROVIDERS': [],
'GPT_BASE_URL': '',
'GPT_API_KEY': '',
'GPT_PROXY': '',
'GPT_MODEL': 'gpt-4o-mini',
'DEEPSEEK_BASE_URL': '',
'DEEPSEEK_API_KEY': '',
'DEEPSEEK_PROXY': '',
'DEEPSEEK_MODEL': 'deepseek-chat',
'VIRTUAL_APP_ENABLED': False, 'VIRTUAL_APP_ENABLED': False,
'FILE_UPLOAD_SIZE_LIMIT_MB': 200, 'FILE_UPLOAD_SIZE_LIMIT_MB': 200,
@@ -735,6 +726,10 @@ class Config(dict):
# MCP # MCP
'MCP_ENABLED': False, 'MCP_ENABLED': False,
# oauth2_provider settings
'OAUTH2_PROVIDER_ACCESS_TOKEN_EXPIRE_SECONDS': 60 * 60,
'OAUTH2_PROVIDER_REFRESH_TOKEN_EXPIRE_SECONDS': 60 * 60 * 24 * 7,
} }
old_config_map = { old_config_map = {

View File

@@ -151,8 +151,13 @@ class SafeRedirectMiddleware:
if not (300 <= response.status_code < 400): if not (300 <= response.status_code < 400):
return response return response
if request.resolver_match and request.resolver_match.namespace.startswith('authentication'): if (
# 认证相关的路由跳过验证core/auth/xxxx request.resolver_match and
request.resolver_match.namespace.startswith('authentication') and
not request.resolver_match.namespace.startswith('authentication:oauth2-provider')
):
# 认证相关的路由跳过验证 /core/auth/...,
# 但 oauth2-provider 除外, 因为它会重定向到第三方客户端, 希望给出更友好的提示
return response return response
location = response.get('Location') location = response.get('Location')
if not location: if not location:

View File

@@ -5,6 +5,7 @@ from rest_framework.pagination import LimitOffsetPagination
class MaxLimitOffsetPagination(LimitOffsetPagination): class MaxLimitOffsetPagination(LimitOffsetPagination):
max_limit = settings.MAX_PAGE_SIZE max_limit = settings.MAX_PAGE_SIZE
default_limit = settings.DEFAULT_PAGE_SIZE
def get_count(self, queryset): def get_count(self, queryset):
try: try:

View File

@@ -159,7 +159,8 @@ CAS_CHECK_NEXT = lambda _next_page: True
CAS_USERNAME_ATTRIBUTE = CONFIG.CAS_USERNAME_ATTRIBUTE CAS_USERNAME_ATTRIBUTE = CONFIG.CAS_USERNAME_ATTRIBUTE
CAS_APPLY_ATTRIBUTES_TO_USER = CONFIG.CAS_APPLY_ATTRIBUTES_TO_USER CAS_APPLY_ATTRIBUTES_TO_USER = CONFIG.CAS_APPLY_ATTRIBUTES_TO_USER
CAS_RENAME_ATTRIBUTES = CONFIG.CAS_RENAME_ATTRIBUTES CAS_RENAME_ATTRIBUTES = CONFIG.CAS_RENAME_ATTRIBUTES
CAS_CREATE_USER = CONFIG.CAS_CREATE_USER CAS_CREATE_USER = True
CAS_STORE_NEXT = True
# SSO auth # SSO auth
AUTH_SSO = CONFIG.AUTH_SSO AUTH_SSO = CONFIG.AUTH_SSO

View File

@@ -130,6 +130,7 @@ INSTALLED_APPS = [
'settings.apps.SettingsConfig', 'settings.apps.SettingsConfig',
'terminal.apps.TerminalConfig', 'terminal.apps.TerminalConfig',
'audits.apps.AuditsConfig', 'audits.apps.AuditsConfig',
'oauth2_provider',
'authentication.apps.AuthenticationConfig', # authentication 'authentication.apps.AuthenticationConfig', # authentication
'tickets.apps.TicketsConfig', 'tickets.apps.TicketsConfig',
'acls.apps.AclsConfig', 'acls.apps.AclsConfig',
@@ -267,9 +268,17 @@ DATABASES = {
DB_USE_SSL = CONFIG.DB_USE_SSL DB_USE_SSL = CONFIG.DB_USE_SSL
if DB_ENGINE == 'mysql': if DB_ENGINE == 'mysql':
DB_OPTIONS['init_command'] = "SET sql_mode='STRICT_TRANS_TABLES'" DB_OPTIONS['init_command'] = "SET sql_mode='STRICT_TRANS_TABLES'"
if DB_USE_SSL:
DB_CA_PATH = exist_or_default(os.path.join(CERTS_DIR, 'db_ca.pem'), None) if DB_USE_SSL:
DB_OPTIONS['ssl'] = {'ca': DB_CA_PATH} DB_CA_PATH = exist_or_default(os.path.join(CERTS_DIR, 'db_ca.pem'), None)
if DB_ENGINE == 'mysql':
DB_OPTIONS['ssl'] = {'ca': DB_CA_PATH }
elif DB_ENGINE == 'postgresql':
DB_OPTIONS.update({
'sslmode': 'require',
'sslrootcert': DB_CA_PATH,
})
# Password validation # Password validation
# https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators # https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators

View File

@@ -241,15 +241,7 @@ ASSET_SIZE = 'small'
CHAT_AI_ENABLED = CONFIG.CHAT_AI_ENABLED CHAT_AI_ENABLED = CONFIG.CHAT_AI_ENABLED
CHAT_AI_METHOD = CONFIG.CHAT_AI_METHOD CHAT_AI_METHOD = CONFIG.CHAT_AI_METHOD
CHAT_AI_EMBED_URL = CONFIG.CHAT_AI_EMBED_URL CHAT_AI_EMBED_URL = CONFIG.CHAT_AI_EMBED_URL
CHAT_AI_TYPE = CONFIG.CHAT_AI_TYPE CHAT_AI_DEFAULT_PROVIDER = CONFIG.CHAT_AI_DEFAULT_PROVIDER
GPT_BASE_URL = CONFIG.GPT_BASE_URL
GPT_API_KEY = CONFIG.GPT_API_KEY
GPT_PROXY = CONFIG.GPT_PROXY
GPT_MODEL = CONFIG.GPT_MODEL
DEEPSEEK_BASE_URL = CONFIG.DEEPSEEK_BASE_URL
DEEPSEEK_API_KEY = CONFIG.DEEPSEEK_API_KEY
DEEPSEEK_PROXY = CONFIG.DEEPSEEK_PROXY
DEEPSEEK_MODEL = CONFIG.DEEPSEEK_MODEL
VIRTUAL_APP_ENABLED = CONFIG.VIRTUAL_APP_ENABLED VIRTUAL_APP_ENABLED = CONFIG.VIRTUAL_APP_ENABLED
@@ -269,3 +261,5 @@ TOOL_USER_ENABLED = CONFIG.TOOL_USER_ENABLED
SUGGESTION_LIMIT = CONFIG.SUGGESTION_LIMIT SUGGESTION_LIMIT = CONFIG.SUGGESTION_LIMIT
MCP_ENABLED = CONFIG.MCP_ENABLED MCP_ENABLED = CONFIG.MCP_ENABLED
CHAT_AI_PROVIDERS = CONFIG.CHAT_AI_PROVIDERS

View File

@@ -30,10 +30,11 @@ REST_FRAMEWORK = {
), ),
'DEFAULT_AUTHENTICATION_CLASSES': ( 'DEFAULT_AUTHENTICATION_CLASSES': (
# 'rest_framework.authentication.BasicAuthentication', # 'rest_framework.authentication.BasicAuthentication',
'authentication.backends.drf.AccessTokenAuthentication',
'authentication.backends.drf.PrivateTokenAuthentication',
'authentication.backends.drf.ServiceAuthentication',
'authentication.backends.drf.SignatureAuthentication', 'authentication.backends.drf.SignatureAuthentication',
'authentication.backends.drf.ServiceAuthentication',
'authentication.backends.drf.PrivateTokenAuthentication',
'authentication.backends.drf.AccessTokenAuthentication',
"oauth2_provider.contrib.rest_framework.OAuth2Authentication",
'authentication.backends.drf.SessionAuthentication', 'authentication.backends.drf.SessionAuthentication',
), ),
'DEFAULT_FILTER_BACKENDS': ( 'DEFAULT_FILTER_BACKENDS': (
@@ -84,6 +85,7 @@ SPECTACULAR_SETTINGS = {
'jumpserver.views.schema.LabeledChoiceFieldExtension', 'jumpserver.views.schema.LabeledChoiceFieldExtension',
'jumpserver.views.schema.BitChoicesFieldExtension', 'jumpserver.views.schema.BitChoicesFieldExtension',
'jumpserver.views.schema.LabelRelatedFieldExtension', 'jumpserver.views.schema.LabelRelatedFieldExtension',
'jumpserver.views.schema.DateTimeFieldExtension',
], ],
'SECURITY': [{'Bearer': []}], 'SECURITY': [{'Bearer': []}],
} }
@@ -222,3 +224,17 @@ PIICO_DRIVER_PATH = CONFIG.PIICO_DRIVER_PATH
LEAK_PASSWORD_DB_PATH = CONFIG.LEAK_PASSWORD_DB_PATH LEAK_PASSWORD_DB_PATH = CONFIG.LEAK_PASSWORD_DB_PATH
JUMPSERVER_UPTIME = int(time.time()) JUMPSERVER_UPTIME = int(time.time())
# OAuth2 Provider settings
OAUTH2_PROVIDER = {
'ALLOWED_REDIRECT_URI_SCHEMES': ['https', 'jms'],
'PKCE_REQUIRED': True,
'ACCESS_TOKEN_EXPIRE_SECONDS': CONFIG.OAUTH2_PROVIDER_ACCESS_TOKEN_EXPIRE_SECONDS,
'REFRESH_TOKEN_EXPIRE_SECONDS': CONFIG.OAUTH2_PROVIDER_REFRESH_TOKEN_EXPIRE_SECONDS,
}
OAUTH2_PROVIDER_CLIENT_REDIRECT_URI = 'jms://auth/callback'
OAUTH2_PROVIDER_JUMPSERVER_CLIENT_NAME = 'JumpServer Client'
if CONFIG.DEBUG_DEV:
OAUTH2_PROVIDER['ALLOWED_REDIRECT_URI_SCHEMES'].append('http')
OAUTH2_PROVIDER_CLIENT_REDIRECT_URI += ' http://127.0.0.1:14876/auth/callback'

Some files were not shown because too many files have changed in this diff Show More