mirror of
https://github.com/jumpserver/jumpserver.git
synced 2025-12-15 16:42:34 +00:00
Compare commits
119 Commits
v4.10.11
...
pr@dev@top
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1f3aa21be | ||
|
|
2b1fdb937b | ||
|
|
1e754546f1 | ||
|
|
2ec71feafc | ||
|
|
02e8905330 | ||
|
|
8d68f5589b | ||
|
|
4df13fc384 | ||
|
|
78c1162028 | ||
|
|
14c2512b45 | ||
|
|
d6d7072da5 | ||
|
|
993bc36c5e | ||
|
|
ecff2ea07e | ||
|
|
ba70edf221 | ||
|
|
50050dff57 | ||
|
|
944226866c | ||
|
|
fe13221d88 | ||
|
|
ba17863892 | ||
|
|
065bfeda52 | ||
|
|
04af26500a | ||
|
|
e0388364c3 | ||
|
|
3c96480b0c | ||
|
|
95331a0c4b | ||
|
|
b8ecb703cf | ||
|
|
1a3f5e3f9a | ||
|
|
854396e8d5 | ||
|
|
ab08603e66 | ||
|
|
427fd3f72c | ||
|
|
0aba9ba120 | ||
|
|
045ca8807a | ||
|
|
19a68d8930 | ||
|
|
75ed02a2d2 | ||
|
|
f420dac49c | ||
|
|
1ee68134f2 | ||
|
|
937265db5d | ||
|
|
c611d5e88b | ||
|
|
883b6b6383 | ||
|
|
ac4c72064f | ||
|
|
dbf8360e27 | ||
|
|
150d7a09bc | ||
|
|
a7ed20e059 | ||
|
|
1b7b8e6f2e | ||
|
|
cd22fbce19 | ||
|
|
c191d86f43 | ||
|
|
7911137ffb | ||
|
|
1053933cae | ||
|
|
96fdc025cd | ||
|
|
fde19764e0 | ||
|
|
978fbc70e6 | ||
|
|
636ffd786d | ||
|
|
3b756aa26f | ||
|
|
817c0099d1 | ||
|
|
a0d7871130 | ||
|
|
c97124c279 | ||
|
|
32a766ed34 | ||
|
|
58fd15d743 | ||
|
|
f50250dedb | ||
|
|
9e150b7fbe | ||
|
|
16c79f59a7 | ||
|
|
be0f04862a | ||
|
|
1a3fb2f0db | ||
|
|
4cd70efe66 | ||
|
|
28700c01c8 | ||
|
|
4524822245 | ||
|
|
9d04fda018 | ||
|
|
01c277cd1e | ||
|
|
c4b3531d72 | ||
|
|
8870d1ef9e | ||
|
|
6c5086a083 | ||
|
|
e9f762a982 | ||
|
|
d4d4cadbcd | ||
|
|
5e56590405 | ||
|
|
ad8c0f6664 | ||
|
|
47dd6babfc | ||
|
|
691d1c4dba | ||
|
|
ac485804d5 | ||
|
|
51e5fdb301 | ||
|
|
69c4d613f7 | ||
|
|
1ad825bf0d | ||
|
|
a286cb9343 | ||
|
|
1eb489bb2d | ||
|
|
4334ae9e5e | ||
|
|
f2e346a0c3 | ||
|
|
dc20b06431 | ||
|
|
387a9248fc | ||
|
|
705fd6385f | ||
|
|
0ccf36621f | ||
|
|
a9ae12fc2c | ||
|
|
7b1a25adde | ||
|
|
a1b5eb1cd8 | ||
|
|
24ac642c5e | ||
|
|
e4f5e21219 | ||
|
|
a2aae9db47 | ||
|
|
206c43cf75 | ||
|
|
019a657ec3 | ||
|
|
fad60ee40f | ||
|
|
1728412793 | ||
|
|
3e93034fbc | ||
|
|
f4b3a7d73a | ||
|
|
3781c40179 | ||
|
|
fab6219cea | ||
|
|
dd0cacb4bc | ||
|
|
b8639601a1 | ||
|
|
ab9882c9c1 | ||
|
|
77a7b74b15 | ||
|
|
4bc05865f1 | ||
|
|
bec9e4f3a7 | ||
|
|
359adf3dbb | ||
|
|
ac54bb672c | ||
|
|
9e3ba00bc4 | ||
|
|
2ec9a43317 | ||
|
|
06be56ef06 | ||
|
|
b2a618b206 | ||
|
|
1039c2e320 | ||
|
|
8d7267400d | ||
|
|
d67e473884 | ||
|
|
70068c9253 | ||
|
|
d68babb2e1 | ||
|
|
afb6f466d5 | ||
|
|
453ad331ee |
26
.github/.github/issue-spam-config.json
vendored
Normal file
26
.github/.github/issue-spam-config.json
vendored
Normal 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"]
|
||||
}
|
||||
120
.github/workflows/build-base-image.yml
vendored
120
.github/workflows/build-base-image.yml
vendored
@@ -1,74 +1,72 @@
|
||||
name: Build and Push Base Image
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- 'dev'
|
||||
- 'v*'
|
||||
paths:
|
||||
- poetry.lock
|
||||
- pyproject.toml
|
||||
- Dockerfile-base
|
||||
- package.json
|
||||
- go.mod
|
||||
- yarn.lock
|
||||
- pom.xml
|
||||
- install_deps.sh
|
||||
- utils/clean_site_packages.sh
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
pull_request:
|
||||
branches:
|
||||
- 'dev'
|
||||
- 'v*'
|
||||
paths:
|
||||
- poetry.lock
|
||||
- pyproject.toml
|
||||
- Dockerfile-base
|
||||
- package.json
|
||||
- go.mod
|
||||
- yarn.lock
|
||||
- pom.xml
|
||||
- install_deps.sh
|
||||
- utils/clean_site_packages.sh
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
build-and-push:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
image: tonistiigi/binfmt:qemu-v7.0.0-28
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract date
|
||||
id: vars
|
||||
run: echo "IMAGE_TAG=$(date +'%Y%m%d_%H%M%S')" >> $GITHUB_ENV
|
||||
- name: Extract date
|
||||
id: vars
|
||||
run: echo "IMAGE_TAG=$(date +'%Y%m%d_%H%M%S')" >> $GITHUB_ENV
|
||||
|
||||
- name: Extract repository name
|
||||
id: repo
|
||||
run: echo "REPO=$(basename ${{ github.repository }})" >> $GITHUB_ENV
|
||||
- name: Extract repository name
|
||||
id: repo
|
||||
run: echo "REPO=$(basename ${{ github.repository }})" >> $GITHUB_ENV
|
||||
|
||||
- name: Build and push multi-arch image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
file: Dockerfile-base
|
||||
tags: jumpserver/core-base:${{ env.IMAGE_TAG }}
|
||||
- name: Build and push multi-arch image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
file: Dockerfile-base
|
||||
tags: jumpserver/core-base:${{ env.IMAGE_TAG }}
|
||||
|
||||
- name: Update Dockerfile
|
||||
run: |
|
||||
sed -i 's|-base:.* AS stage-build|-base:${{ env.IMAGE_TAG }} AS stage-build|' Dockerfile
|
||||
- name: Update Dockerfile
|
||||
run: |
|
||||
sed -i 's|-base:.* AS stage-build|-base:${{ env.IMAGE_TAG }} AS stage-build|' Dockerfile
|
||||
|
||||
- name: Commit changes
|
||||
run: |
|
||||
git config --global user.name 'github-actions[bot]'
|
||||
git config --global user.email 'github-actions[bot]@users.noreply.github.com'
|
||||
git add Dockerfile
|
||||
git commit -m "perf: Update Dockerfile with new base image tag"
|
||||
git push origin ${{ github.event.pull_request.head.ref }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Commit changes
|
||||
run: |
|
||||
git config --global user.name 'github-actions[bot]'
|
||||
git config --global user.email 'github-actions[bot]@users.noreply.github.com'
|
||||
git add Dockerfile
|
||||
git commit -m "perf: Update Dockerfile with new base image tag"
|
||||
git push origin ${{ github.event.pull_request.head.ref }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
123
.github/workflows/cleanup-branches.yml
vendored
Normal file
123
.github/workflows/cleanup-branches.yml
vendored
Normal file
@@ -0,0 +1,123 @@
|
||||
name: Cleanup PR Branches
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# 每天凌晨2点运行
|
||||
- cron: '0 2 * * *'
|
||||
workflow_dispatch:
|
||||
# 允许手动触发
|
||||
inputs:
|
||||
dry_run:
|
||||
description: 'Dry run mode (default: true)'
|
||||
required: false
|
||||
default: 'true'
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
cleanup-branches:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # 获取所有分支和提交历史
|
||||
|
||||
- name: Setup Git
|
||||
run: |
|
||||
git config --global user.name "GitHub Actions"
|
||||
git config --global user.email "actions@github.com"
|
||||
|
||||
- name: Get dry run setting
|
||||
id: dry-run
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "dry_run=${{ github.event.inputs.dry_run }}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "dry_run=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Cleanup branches
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
DRY_RUN: ${{ steps.dry-run.outputs.dry_run }}
|
||||
run: |
|
||||
echo "Starting branch cleanup..."
|
||||
echo "Dry run mode: $DRY_RUN"
|
||||
|
||||
# 获取所有本地分支
|
||||
git fetch --all --prune
|
||||
|
||||
# 获取以 pr 或 repr 开头的分支
|
||||
branches=$(git branch -r | grep -E 'origin/(pr|repr)' | sed 's/origin\///' | grep -v 'HEAD')
|
||||
|
||||
echo "Found branches matching pattern:"
|
||||
echo "$branches"
|
||||
|
||||
deleted_count=0
|
||||
skipped_count=0
|
||||
|
||||
for branch in $branches; do
|
||||
echo ""
|
||||
echo "Processing branch: $branch"
|
||||
|
||||
# 检查分支是否有未合并的PR
|
||||
pr_info=$(gh pr list --head "$branch" --state open --json number,title,state 2>/dev/null)
|
||||
|
||||
if [ $? -eq 0 ] && [ "$pr_info" != "[]" ]; then
|
||||
echo " ⚠️ Branch has open PR(s), skipping deletion"
|
||||
echo " PR info: $pr_info"
|
||||
skipped_count=$((skipped_count + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
# 检查分支是否有已合并的PR(可选:如果PR已合并也可以删除)
|
||||
merged_pr_info=$(gh pr list --head "$branch" --state merged --json number,title,state 2>/dev/null)
|
||||
|
||||
if [ $? -eq 0 ] && [ "$merged_pr_info" != "[]" ]; then
|
||||
echo " ✅ Branch has merged PR(s), safe to delete"
|
||||
echo " Merged PR info: $merged_pr_info"
|
||||
else
|
||||
echo " ℹ️ No PRs found for this branch"
|
||||
fi
|
||||
|
||||
# 执行删除操作
|
||||
if [ "$DRY_RUN" = "true" ]; then
|
||||
echo " 🔍 [DRY RUN] Would delete branch: $branch"
|
||||
deleted_count=$((deleted_count + 1))
|
||||
else
|
||||
echo " 🗑️ Deleting branch: $branch"
|
||||
|
||||
# 删除远程分支
|
||||
if git push origin --delete "$branch" 2>/dev/null; then
|
||||
echo " ✅ Successfully deleted remote branch: $branch"
|
||||
deleted_count=$((deleted_count + 1))
|
||||
else
|
||||
echo " ❌ Failed to delete remote branch: $branch"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=== Cleanup Summary ==="
|
||||
echo "Branches processed: $(echo "$branches" | wc -l)"
|
||||
echo "Branches deleted: $deleted_count"
|
||||
echo "Branches skipped: $skipped_count"
|
||||
|
||||
if [ "$DRY_RUN" = "true" ]; then
|
||||
echo ""
|
||||
echo "🔍 This was a DRY RUN - no branches were actually deleted"
|
||||
echo "To perform actual deletion, run this workflow manually with dry_run=false"
|
||||
fi
|
||||
|
||||
- name: Create summary
|
||||
if: always()
|
||||
run: |
|
||||
echo "## Branch Cleanup Summary" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Workflow:** ${{ github.workflow }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Run ID:** ${{ github.run_id }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Dry Run:** ${{ steps.dry-run.outputs.dry_run }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Triggered by:** ${{ github.event_name }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Check the logs above for detailed information about processed branches." >> $GITHUB_STEP_SUMMARY
|
||||
29
.github/workflows/jms-generic-action-handler.yml
vendored
29
.github/workflows/jms-generic-action-handler.yml
vendored
@@ -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
|
||||
|
||||
jobs:
|
||||
generic_handler:
|
||||
name: Run generic handler
|
||||
handle_pull_request:
|
||||
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
|
||||
steps:
|
||||
- uses: jumpserver/action-generic-handler@master
|
||||
|
||||
9
.github/workflows/sync-gitee.yml
vendored
9
.github/workflows/sync-gitee.yml
vendored
@@ -1,11 +1,9 @@
|
||||
name: 🔀 Sync mirror to Gitee
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- dev
|
||||
create:
|
||||
schedule:
|
||||
# 每天凌晨3点运行
|
||||
- cron: '0 3 * * *'
|
||||
|
||||
jobs:
|
||||
mirror:
|
||||
@@ -14,7 +12,6 @@ jobs:
|
||||
steps:
|
||||
- name: mirror
|
||||
continue-on-error: true
|
||||
if: github.event_name == 'push' || (github.event_name == 'create' && github.event.ref_type == 'tag')
|
||||
uses: wearerequired/git-mirror-action@v1
|
||||
env:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.GITEE_SSH_PRIVATE_KEY }}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM jumpserver/core-base:20251014_095903 AS stage-build
|
||||
FROM jumpserver/core-base:20251128_025056 AS stage-build
|
||||
|
||||
ARG VERSION
|
||||
|
||||
@@ -19,7 +19,7 @@ RUN set -ex \
|
||||
&& 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 \
|
||||
PATH=/opt/py3/bin:$PATH
|
||||
|
||||
@@ -39,7 +39,7 @@ ARG TOOLS=" \
|
||||
ARG APT_MIRROR=http://deb.debian.org
|
||||
|
||||
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 \
|
||||
&& apt-get update > /dev/null \
|
||||
&& apt-get -y install --no-install-recommends ${DEPENDENCIES} \
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
FROM jumpserver/core-base:python-3.11-slim-bullseye-v1
|
||||
FROM python:3.11.14-slim-trixie
|
||||
ARG TARGETARCH
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.6.14 /uv /uvx /usr/local/bin/
|
||||
# Install APT dependencies
|
||||
ARG DEPENDENCIES=" \
|
||||
ca-certificates \
|
||||
@@ -22,7 +21,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=core \
|
||||
set -ex \
|
||||
&& rm -f /etc/apt/apt.conf.d/docker-clean \
|
||||
&& echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache \
|
||||
&& sed -i "s@http://.*.debian.org@${APT_MIRROR}@g" /etc/apt/sources.list \
|
||||
&& sed -i "s@http://.*.debian.org@${APT_MIRROR}@g" /etc/apt/sources.list.d/debian.sources \
|
||||
&& apt-get update > /dev/null \
|
||||
&& apt-get -y install --no-install-recommends ${DEPENDENCIES} \
|
||||
&& echo "no" | dpkg-reconfigure dash
|
||||
@@ -41,12 +40,10 @@ RUN set -ex \
|
||||
WORKDIR /opt/jumpserver
|
||||
|
||||
ARG PIP_MIRROR=https://pypi.org/simple
|
||||
ENV POETRY_PYPI_MIRROR_URL=${PIP_MIRROR}
|
||||
ENV ANSIBLE_COLLECTIONS_PATHS=/opt/py3/lib/python3.11/site-packages/ansible_collections
|
||||
ENV LANG=en_US.UTF-8 \
|
||||
PATH=/opt/py3/bin:$PATH
|
||||
|
||||
ENV UV_LINK_MODE=copy
|
||||
ENV SETUPTOOLS_SCM_PRETEND_VERSION=3.4.5
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache \
|
||||
--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/static_files.sh,target=utils/static_files.sh \
|
||||
set -ex \
|
||||
&& pip install uv -i${PIP_MIRROR} \
|
||||
&& uv venv \
|
||||
&& uv pip install -i${PIP_MIRROR} -r pyproject.toml \
|
||||
&& ln -sf $(pwd)/.venv /opt/py3 \
|
||||
|
||||
@@ -13,7 +13,7 @@ ARG TOOLS=" \
|
||||
nmap \
|
||||
telnet \
|
||||
vim \
|
||||
postgresql-client-13 \
|
||||
postgresql-client \
|
||||
wget \
|
||||
poppler-utils"
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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 |
|
||||
| [KoKo](https://github.com/jumpserver/koko) | <a href="https://github.com/jumpserver/koko/releases"><img alt="Koko release" src="https://img.shields.io/github/release/jumpserver/koko.svg" /></a> | JumpServer Character Protocol Connector |
|
||||
| [Lion](https://github.com/jumpserver/lion) | <a href="https://github.com/jumpserver/lion/releases"><img alt="Lion release" src="https://img.shields.io/github/release/jumpserver/lion.svg" /></a> | JumpServer Graphical Protocol Connector |
|
||||
| [Chen](https://github.com/jumpserver/chen) | <a href="https://github.com/jumpserver/chen/releases"><img alt="Chen release" src="https://img.shields.io/github/release/jumpserver/chen.svg" /> | JumpServer Web DB |
|
||||
| [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) |
|
||||
| [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 |
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
from django.db import transaction
|
||||
from django.shortcuts import get_object_or_404
|
||||
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.generics import ListAPIView, CreateAPIView
|
||||
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.const import ChangeSecretRecordStatusChoice
|
||||
@@ -184,12 +185,66 @@ class AssetAccountBulkCreateApi(CreateAPIView):
|
||||
'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):
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
data = serializer.create(serializer.validated_data)
|
||||
serializer = serializers.AssetAccountBulkSerializerResultSerializer(data, many=True)
|
||||
return Response(data=serializer.data, status=HTTP_200_OK)
|
||||
if hasattr(request.data, "copy"):
|
||||
base_payload = request.data.copy()
|
||||
else:
|
||||
base_payload = dict(request.data)
|
||||
|
||||
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):
|
||||
|
||||
@@ -25,7 +25,8 @@ class IntegrationApplicationViewSet(OrgBulkModelViewSet):
|
||||
}
|
||||
rbac_perms = {
|
||||
'get_once_secret': 'accounts.change_integrationapplication',
|
||||
'get_account_secret': 'accounts.view_integrationapplication'
|
||||
'get_account_secret': 'accounts.view_integrationapplication',
|
||||
'get_sdks_info': 'accounts.view_integrationapplication'
|
||||
}
|
||||
|
||||
def read_file(self, path):
|
||||
@@ -36,7 +37,6 @@ class IntegrationApplicationViewSet(OrgBulkModelViewSet):
|
||||
|
||||
@action(
|
||||
['GET'], detail=False, url_path='sdks',
|
||||
permission_classes=[IsValidUser]
|
||||
)
|
||||
def get_sdks_info(self, request, *args, **kwargs):
|
||||
code_suffix_mapper = {
|
||||
|
||||
@@ -14,7 +14,7 @@ from accounts.models import Account, AccountTemplate, GatheredAccount
|
||||
from accounts.tasks import push_accounts_to_assets_task
|
||||
from assets.const import Category, AllTypes
|
||||
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.utils import get_logger
|
||||
from .base import BaseAccountSerializer, AuthValidateMixin
|
||||
@@ -292,26 +292,26 @@ class AccountDetailSerializer(AccountSerializer):
|
||||
|
||||
class AssetAccountBulkSerializerResultSerializer(serializers.Serializer):
|
||||
asset = serializers.CharField(read_only=True, label=_('Asset'))
|
||||
account = serializers.CharField(read_only=True, label=_('Account'))
|
||||
state = serializers.CharField(read_only=True, label=_('State'))
|
||||
error = serializers.CharField(read_only=True, label=_('Error'))
|
||||
changed = serializers.BooleanField(read_only=True, label=_('Changed'))
|
||||
|
||||
|
||||
class AssetAccountBulkSerializer(
|
||||
AccountCreateUpdateSerializerMixin, AuthValidateMixin, serializers.ModelSerializer
|
||||
AccountCreateUpdateSerializerMixin, AuthValidateMixin, CommonBulkModelSerializer
|
||||
):
|
||||
su_from_username = serializers.CharField(
|
||||
max_length=128, required=False, write_only=True, allow_null=True, label=_("Su from"),
|
||||
allow_blank=True,
|
||||
)
|
||||
assets = serializers.PrimaryKeyRelatedField(queryset=Asset.objects, many=True, label=_('Assets'))
|
||||
|
||||
class Meta:
|
||||
model = Account
|
||||
fields = [
|
||||
'name', 'username', 'secret', 'secret_type', 'passphrase',
|
||||
'privileged', 'is_active', 'comment', 'template',
|
||||
'on_invalid', 'push_now', 'params', 'assets',
|
||||
'name', 'username', 'secret', 'secret_type', 'secret_reset',
|
||||
'passphrase', 'privileged', 'is_active', 'comment', 'template',
|
||||
'on_invalid', 'push_now', 'params',
|
||||
'su_from_username', 'source', 'source_id',
|
||||
]
|
||||
extra_kwargs = {
|
||||
@@ -393,8 +393,7 @@ class AssetAccountBulkSerializer(
|
||||
handler = self._handle_err_create
|
||||
return handler
|
||||
|
||||
def perform_bulk_create(self, vd):
|
||||
assets = vd.pop('assets')
|
||||
def perform_bulk_create(self, vd, assets):
|
||||
on_invalid = vd.pop('on_invalid', 'skip')
|
||||
secret_type = vd.get('secret_type', 'password')
|
||||
|
||||
@@ -402,8 +401,7 @@ class AssetAccountBulkSerializer(
|
||||
vd['name'] = vd.get('username')
|
||||
|
||||
create_handler = self.get_create_handler(on_invalid)
|
||||
asset_ids = [asset.id for asset in assets]
|
||||
secret_type_supports = Asset.get_secret_type_assets(asset_ids, secret_type)
|
||||
secret_type_supports = Asset.get_secret_type_assets(assets, secret_type)
|
||||
|
||||
_results = {}
|
||||
for asset in assets:
|
||||
@@ -411,6 +409,7 @@ class AssetAccountBulkSerializer(
|
||||
_results[asset] = {
|
||||
'error': _('Asset does not support this secret type: %s') % secret_type,
|
||||
'state': 'error',
|
||||
'account': vd['name'],
|
||||
}
|
||||
continue
|
||||
|
||||
@@ -420,13 +419,13 @@ class AssetAccountBulkSerializer(
|
||||
self.clean_auth_fields(vd)
|
||||
instance, changed, state = self.perform_create(vd, create_handler)
|
||||
_results[asset] = {
|
||||
'changed': changed, 'instance': instance.id, 'state': state
|
||||
'changed': changed, 'instance': instance.id, 'state': state, 'account': vd['name']
|
||||
}
|
||||
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:
|
||||
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()]
|
||||
state_score = {'created': 3, 'updated': 2, 'skipped': 1, 'error': 0}
|
||||
@@ -443,7 +442,8 @@ class AssetAccountBulkSerializer(
|
||||
errors.append({
|
||||
'error': _('Account has exist'),
|
||||
'state': 'error',
|
||||
'asset': str(result['asset'])
|
||||
'asset': str(result['asset']),
|
||||
'account': result.get('account'),
|
||||
})
|
||||
if errors:
|
||||
raise serializers.ValidationError(errors)
|
||||
@@ -462,10 +462,16 @@ class AssetAccountBulkSerializer(
|
||||
account_ids = [str(_id) for _id in accounts.values_list('id', flat=True)]
|
||||
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)
|
||||
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)
|
||||
for res in results:
|
||||
res['asset'] = str(res['asset'])
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
from collections import defaultdict
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.translation import gettext as _
|
||||
from django_filters import rest_framework as drf_filters
|
||||
@@ -113,7 +112,7 @@ class BaseAssetViewSet(OrgBulkModelViewSet):
|
||||
("accounts", AccountSerializer),
|
||||
)
|
||||
rbac_perms = (
|
||||
("match", "assets.match_asset"),
|
||||
("match", "assets.view_asset"),
|
||||
("platform", "assets.view_platform"),
|
||||
("gateways", "assets.view_gateway"),
|
||||
("accounts", "assets.view_account"),
|
||||
@@ -181,33 +180,18 @@ class AssetViewSet(SuggestionMixin, BaseAssetViewSet):
|
||||
def sync_platform_protocols(self, request, *args, **kwargs):
|
||||
platform_id = request.data.get('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 = {
|
||||
p['name']: p['port']
|
||||
for p in platform.protocols.values('name', 'port')
|
||||
}
|
||||
asset_protocols_map = defaultdict(set)
|
||||
protocols = assets.prefetch_related('protocols').values_list(
|
||||
'id', 'protocols__name'
|
||||
)
|
||||
for asset_id, protocol in protocols:
|
||||
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)
|
||||
with transaction.atomic():
|
||||
if asset_ids:
|
||||
Protocol.objects.filter(asset_id__in=asset_ids).delete()
|
||||
if asset_ids and platform_protocols:
|
||||
objs = []
|
||||
for aid in asset_ids:
|
||||
for p in platform_protocols:
|
||||
objs.append(Protocol(name=p['name'], port=p['port'], asset_id=aid))
|
||||
Protocol.objects.bulk_create(objs)
|
||||
return Response(status=status.HTTP_200_OK)
|
||||
|
||||
def filter_bulk_update_data(self):
|
||||
|
||||
@@ -43,7 +43,7 @@ class NodeViewSet(SuggestionMixin, OrgBulkModelViewSet):
|
||||
search_fields = ('full_value',)
|
||||
serializer_class = serializers.NodeSerializer
|
||||
rbac_perms = {
|
||||
'match': 'assets.match_node',
|
||||
'match': 'assets.view_node',
|
||||
'check_assets_amount_task': 'assets.change_node'
|
||||
}
|
||||
|
||||
|
||||
@@ -112,8 +112,10 @@ class PlatformProtocolViewSet(JMSModelViewSet):
|
||||
|
||||
|
||||
class PlatformAutomationMethodsApi(generics.ListAPIView):
|
||||
permission_classes = (IsValidUser,)
|
||||
queryset = PlatformAutomation.objects.none()
|
||||
rbac_perms = {
|
||||
'list': 'assets.view_platform'
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def automation_methods():
|
||||
|
||||
@@ -268,6 +268,14 @@ class Protocol(ChoicesMixin, models.TextChoices):
|
||||
'port_from_addr': True,
|
||||
'required': True,
|
||||
'secret_types': ['token'],
|
||||
'setting': {
|
||||
'namespace': {
|
||||
'type': 'str',
|
||||
'required': False,
|
||||
'default': '',
|
||||
'label': _('Namespace')
|
||||
}
|
||||
}
|
||||
},
|
||||
cls.http: {
|
||||
'port': 80,
|
||||
|
||||
@@ -408,8 +408,7 @@ class Asset(NodesRelationMixin, LabeledMixin, AbsConnectivity, JSONFilterMixin,
|
||||
return tree_node
|
||||
|
||||
@staticmethod
|
||||
def get_secret_type_assets(asset_ids, secret_type):
|
||||
assets = Asset.objects.filter(id__in=asset_ids)
|
||||
def get_secret_type_assets(assets, secret_type):
|
||||
asset_protocol = assets.prefetch_related('protocols').values_list('id', 'protocols__name')
|
||||
protocol_secret_types_map = const.Protocol.protocol_secret_types()
|
||||
asset_secret_types_mapp = defaultdict(set)
|
||||
|
||||
@@ -28,7 +28,8 @@ class MyAsset(JMSBaseModel):
|
||||
|
||||
@staticmethod
|
||||
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}
|
||||
for asset in assets:
|
||||
custom = customs.get(asset.id)
|
||||
|
||||
@@ -84,6 +84,7 @@ class PlatformAutomationSerializer(serializers.ModelSerializer):
|
||||
class PlatformProtocolSerializer(serializers.ModelSerializer):
|
||||
setting = MethodSerializer(required=False, label=_("Setting"))
|
||||
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:
|
||||
model = PlatformProtocol
|
||||
|
||||
@@ -43,7 +43,7 @@ from .serializers import (
|
||||
OperateLogSerializer, OperateLogActionDetailSerializer,
|
||||
PasswordChangeLogSerializer, ActivityUnionLogSerializer,
|
||||
FileSerializer, UserSessionSerializer, JobsAuditSerializer,
|
||||
ServiceAccessLogSerializer
|
||||
ServiceAccessLogSerializer, OperateLogFullSerializer
|
||||
)
|
||||
from .utils import construct_userlogin_usernames, record_operate_log_and_activity_log
|
||||
|
||||
@@ -256,7 +256,9 @@ class OperateLogViewSet(OrgReadonlyModelViewSet):
|
||||
def get_serializer_class(self):
|
||||
if self.is_action_detail:
|
||||
return OperateLogActionDetailSerializer
|
||||
return super().get_serializer_class()
|
||||
elif self.request.query_params.get('format'):
|
||||
return OperateLogFullSerializer
|
||||
return OperateLogSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
current_org_id = str(current_org.id)
|
||||
|
||||
@@ -23,6 +23,8 @@ logger = get_logger(__name__)
|
||||
|
||||
class OperatorLogHandler(metaclass=Singleton):
|
||||
CACHE_KEY = 'OPERATOR_LOG_CACHE_KEY'
|
||||
SYSTEM_OBJECTS = frozenset({"Role"})
|
||||
PREFER_CURRENT_ELSE_USER = frozenset({"SSOToken"})
|
||||
|
||||
def __init__(self):
|
||||
self.log_client = self.get_storage_client()
|
||||
@@ -142,13 +144,21 @@ class OperatorLogHandler(metaclass=Singleton):
|
||||
after = self.__data_processing(after)
|
||||
return before, after
|
||||
|
||||
@staticmethod
|
||||
def get_org_id(object_name):
|
||||
system_obj = ('Role',)
|
||||
org_id = get_current_org_id()
|
||||
if object_name in system_obj:
|
||||
org_id = Organization.SYSTEM_ID
|
||||
return org_id
|
||||
def get_org_id(self, user, object_name):
|
||||
if object_name in self.SYSTEM_OBJECTS:
|
||||
return Organization.SYSTEM_ID
|
||||
|
||||
current = get_current_org_id()
|
||||
current_id = str(current) if current else None
|
||||
|
||||
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(
|
||||
self, action, resource_type, resource=None, resource_display=None,
|
||||
@@ -168,7 +178,7 @@ class OperatorLogHandler(metaclass=Singleton):
|
||||
# 前后都没变化,没必要生成日志,除非手动强制保存
|
||||
return
|
||||
|
||||
org_id = self.get_org_id(object_name)
|
||||
org_id = self.get_org_id(user, object_name)
|
||||
data = {
|
||||
'id': log_id, "user": str(user), 'action': action,
|
||||
'resource_type': str(resource_type), 'org_id': org_id,
|
||||
|
||||
@@ -127,6 +127,21 @@ class OperateLogSerializer(BulkOrgResourceModelSerializer):
|
||||
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 Meta:
|
||||
model = models.PasswordChangeLog
|
||||
|
||||
@@ -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_display = [str(o) for o in objs]
|
||||
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
|
||||
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:
|
||||
before_value = list(
|
||||
set(changed_field).symmetric_difference(set(objs_display))
|
||||
)
|
||||
before_value = list(set(changed_value).symmetric_difference(set(objs_display)))
|
||||
|
||||
if changed_field:
|
||||
after = {field_name: changed_field}
|
||||
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)):
|
||||
return
|
||||
|
||||
@@ -16,3 +16,4 @@ from .sso import *
|
||||
from .temp_token import *
|
||||
from .token import *
|
||||
from .face import *
|
||||
from .access_token import *
|
||||
|
||||
47
apps/authentication/api/access_token.py
Normal file
47
apps/authentication/api/access_token.py
Normal 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)
|
||||
@@ -362,6 +362,7 @@ class ConnectionTokenViewSet(AuthFaceMixin, ExtraActionApiMixin, RootOrgViewMixi
|
||||
self.validate_serializer(serializer)
|
||||
return super().perform_create(serializer)
|
||||
|
||||
|
||||
def _insert_connect_options(self, data, user):
|
||||
connect_options = data.pop('connect_options', {})
|
||||
default_name_opts = {
|
||||
@@ -564,7 +565,9 @@ class SuperConnectionTokenViewSet(ConnectionTokenViewSet):
|
||||
rbac_perms = {
|
||||
'create': 'authentication.add_superconnectiontoken',
|
||||
'renewal': 'authentication.add_superconnectiontoken',
|
||||
'list': 'authentication.view_superconnectiontoken',
|
||||
'check': 'authentication.view_superconnectiontoken',
|
||||
'retrieve': 'authentication.view_superconnectiontoken',
|
||||
'get_secret_detail': 'authentication.view_superconnectiontokensecret',
|
||||
'get_applet_info': 'authentication.view_superconnectiontoken',
|
||||
'release_applet_account': 'authentication.view_superconnectiontoken',
|
||||
@@ -572,7 +575,12 @@ class SuperConnectionTokenViewSet(ConnectionTokenViewSet):
|
||||
}
|
||||
|
||||
def get_queryset(self):
|
||||
return ConnectionToken.objects.all()
|
||||
return ConnectionToken.objects.none()
|
||||
|
||||
def get_object(self):
|
||||
pk = self.kwargs.get(self.lookup_field)
|
||||
token = get_object_or_404(ConnectionToken, pk=pk)
|
||||
return token
|
||||
|
||||
def get_user(self, serializer):
|
||||
return serializer.validated_data.get('user')
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.backends import ModelBackend
|
||||
from django.views import View
|
||||
|
||||
from common.utils import get_logger
|
||||
from users.models import User
|
||||
@@ -66,11 +65,3 @@ class JMSBaseAuthBackend:
|
||||
class JMSModelBackend(JMSBaseAuthBackend, ModelBackend):
|
||||
def user_can_authenticate(self, user):
|
||||
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')
|
||||
|
||||
@@ -1,51 +1,22 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
|
||||
import threading
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django_cas_ng.backends import CASBackend as _CASBackend
|
||||
|
||||
from common.utils import get_logger
|
||||
from ..base import JMSBaseAuthBackend
|
||||
|
||||
__all__ = ['CASBackend', 'CASUserDoesNotExist']
|
||||
__all__ = ['CASBackend']
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class CASUserDoesNotExist(Exception):
|
||||
"""Exception raised when a CAS user does not exist."""
|
||||
pass
|
||||
|
||||
|
||||
class CASBackend(JMSBaseAuthBackend, _CASBackend):
|
||||
@staticmethod
|
||||
def is_enabled():
|
||||
return settings.AUTH_CAS
|
||||
|
||||
def authenticate(self, request, ticket, service):
|
||||
UserModel = get_user_model()
|
||||
manager = UserModel._default_manager
|
||||
original_get_by_natural_key = manager.get_by_natural_key
|
||||
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
|
||||
# 这里做个hack ,让父类始终走CAS_CREATE_USER=True的逻辑,然后调用 authentication/mixins.py 中的 custom_get_or_create 方法
|
||||
settings.CAS_CREATE_USER = True
|
||||
return super().authenticate(request, ticket, service)
|
||||
|
||||
@@ -3,11 +3,10 @@
|
||||
import django_cas_ng.views
|
||||
from django.urls import path
|
||||
|
||||
from .views import CASLoginView, CASCallbackClientView
|
||||
from .views import CASLoginView
|
||||
|
||||
urlpatterns = [
|
||||
path('login/', CASLoginView.as_view(), name='cas-login'),
|
||||
path('logout/', django_cas_ng.views.LogoutView.as_view(), name='cas-logout'),
|
||||
path('callback/', django_cas_ng.views.CallbackView.as_view(), name='cas-proxy-callback'),
|
||||
path('login/client', CASCallbackClientView.as_view(), name='cas-proxy-callback-client'),
|
||||
path('callback/', django_cas_ng.views.CallbackView.as_view(), name='cas-proxy-callback')
|
||||
]
|
||||
|
||||
@@ -3,31 +3,20 @@ from django.http import HttpResponseRedirect
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_cas_ng.views import LoginView
|
||||
|
||||
from authentication.backends.base import BaseAuthCallbackClientView
|
||||
from common.utils import FlashMessageUtil
|
||||
from .backends import CASUserDoesNotExist
|
||||
from authentication.views.mixins import FlashMessageMixin
|
||||
|
||||
__all__ = ['LoginView']
|
||||
|
||||
|
||||
class CASLoginView(LoginView):
|
||||
class CASLoginView(LoginView, FlashMessageMixin):
|
||||
def get(self, request):
|
||||
try:
|
||||
resp = super().get(request)
|
||||
return resp
|
||||
except PermissionDenied:
|
||||
return HttpResponseRedirect('/')
|
||||
except CASUserDoesNotExist as e:
|
||||
message_data = {
|
||||
'title': _('User does not exist: {}').format(e),
|
||||
'error': _(
|
||||
'CAS login was successful, but no corresponding local user was found in the system, and automatic '
|
||||
'user creation is disabled in the CAS authentication configuration. Login failed.'),
|
||||
'interval': 10,
|
||||
'redirect_url': '/',
|
||||
}
|
||||
return FlashMessageUtil.gen_and_redirect_to(message_data)
|
||||
|
||||
|
||||
class CASCallbackClientView(BaseAuthCallbackClientView):
|
||||
pass
|
||||
resp = HttpResponseRedirect('/')
|
||||
error_message = getattr(request, 'error_message', '')
|
||||
if error_message:
|
||||
response = self.get_failed_response('/', title=_('CAS Error'), msg=error_message)
|
||||
return response
|
||||
else:
|
||||
return resp
|
||||
|
||||
@@ -69,6 +69,8 @@ class AccessTokenAuthentication(authentication.BaseAuthentication):
|
||||
msg = _('Invalid token header. Sign string should not contain invalid characters.')
|
||||
raise exceptions.AuthenticationFailed(msg)
|
||||
user, header = self.authenticate_credentials(token)
|
||||
if not user:
|
||||
return None
|
||||
after_authenticate_update_date(user)
|
||||
return user, header
|
||||
|
||||
@@ -77,10 +79,6 @@ class AccessTokenAuthentication(authentication.BaseAuthentication):
|
||||
model = get_user_model()
|
||||
user_id = cache.get(token)
|
||||
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
|
||||
|
||||
def authenticate_header(self, request):
|
||||
|
||||
@@ -7,6 +7,5 @@ from . import views
|
||||
urlpatterns = [
|
||||
path('login/', views.OAuth2AuthRequestView.as_view(), name='login'),
|
||||
path('callback/', views.OAuth2AuthCallbackView.as_view(), name='login-callback'),
|
||||
path('callback/client/', views.OAuth2AuthCallbackClientView.as_view(), name='login-callback-client'),
|
||||
path('logout/', views.OAuth2EndSessionView.as_view(), name='logout')
|
||||
]
|
||||
|
||||
@@ -3,29 +3,37 @@ from django.contrib import auth
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.urls import reverse
|
||||
from django.utils.http import urlencode
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
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.utils import build_absolute_uri
|
||||
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__)
|
||||
|
||||
|
||||
class OAuth2AuthRequestView(View):
|
||||
|
||||
@pre_save_next_to_session()
|
||||
def get(self, request):
|
||||
log_prompt = "Process OAuth2 GET requests: {}"
|
||||
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 = {
|
||||
'client_id': settings.AUTH_OAUTH2_CLIENT_ID, 'response_type': 'code',
|
||||
'scope': settings.AUTH_OAUTH2_SCOPE,
|
||||
'redirect_uri': build_absolute_uri(
|
||||
request, path=reverse(settings.AUTH_OAUTH2_AUTH_LOGIN_CALLBACK_URL_NAME)
|
||||
)
|
||||
'redirect_uri': redirect_uri
|
||||
}
|
||||
|
||||
if '?' in settings.AUTH_OAUTH2_PROVIDER_AUTHORIZATION_ENDPOINT:
|
||||
@@ -44,6 +52,7 @@ class OAuth2AuthRequestView(View):
|
||||
class OAuth2AuthCallbackView(View, FlashMessageMixin):
|
||||
http_method_names = ['get', ]
|
||||
|
||||
@redirect_to_pre_save_next_after_auth
|
||||
def get(self, request):
|
||||
""" Processes GET requests. """
|
||||
log_prompt = "Process GET requests [OAuth2AuthCallbackView]: {}"
|
||||
@@ -58,19 +67,17 @@ class OAuth2AuthCallbackView(View, FlashMessageMixin):
|
||||
logger.debug(log_prompt.format('Login: {}'.format(user)))
|
||||
auth.login(self.request, user)
|
||||
logger.debug(log_prompt.format('Redirect'))
|
||||
return HttpResponseRedirect(
|
||||
settings.AUTH_OAUTH2_AUTHENTICATION_REDIRECT_URI
|
||||
)
|
||||
return HttpResponseRedirect(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'))
|
||||
redirect_url = settings.AUTH_OAUTH2_PROVIDER_END_SESSION_ENDPOINT or '/'
|
||||
return HttpResponseRedirect(redirect_url)
|
||||
|
||||
|
||||
class OAuth2AuthCallbackClientView(BaseAuthCallbackClientView):
|
||||
pass
|
||||
|
||||
|
||||
class OAuth2EndSessionView(View):
|
||||
http_method_names = ['get', 'post', ]
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
14
apps/authentication/backends/oauth2_provider/urls.py
Normal file
14
apps/authentication/backends/oauth2_provider/urls.py
Normal 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"),
|
||||
]
|
||||
31
apps/authentication/backends/oauth2_provider/utils.py
Normal file
31
apps/authentication/backends/oauth2_provider/utils.py
Normal 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)
|
||||
77
apps/authentication/backends/oauth2_provider/views.py
Normal file
77
apps/authentication/backends/oauth2_provider/views.py
Normal 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'
|
||||
@@ -15,6 +15,5 @@ from . import views
|
||||
urlpatterns = [
|
||||
path('login/', views.OIDCAuthRequestView.as_view(), name='login'),
|
||||
path('callback/', views.OIDCAuthCallbackView.as_view(), name='login-callback'),
|
||||
path('callback/client/', views.OIDCAuthCallbackClientView.as_view(), name='login-callback-client'),
|
||||
path('logout/', views.OIDCEndSessionView.as_view(), name='logout'),
|
||||
]
|
||||
|
||||
@@ -25,11 +25,11 @@ from django.utils.http import urlencode
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
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.views.mixins import FlashMessageMixin
|
||||
from common.utils import safe_next_url
|
||||
from .utils import get_logger
|
||||
from ..base import BaseAuthCallbackClientView
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
||||
@@ -58,6 +58,7 @@ class OIDCAuthRequestView(View):
|
||||
b = base64.urlsafe_b64encode(h)
|
||||
return b.decode('ascii')[:-1]
|
||||
|
||||
@pre_save_next_to_session()
|
||||
def get(self, request):
|
||||
""" Processes GET requests. """
|
||||
|
||||
@@ -66,8 +67,9 @@ class OIDCAuthRequestView(View):
|
||||
|
||||
# Defines common parameters used to bootstrap the authentication request.
|
||||
logger.debug(log_prompt.format('Construct request params'))
|
||||
authentication_request_params = request.GET.dict()
|
||||
authentication_request_params.update({
|
||||
request_params = request.GET.dict()
|
||||
request_params.pop('next', None)
|
||||
request_params.update({
|
||||
'scope': settings.AUTH_OPENID_SCOPES,
|
||||
'response_type': 'code',
|
||||
'client_id': settings.AUTH_OPENID_CLIENT_ID,
|
||||
@@ -80,7 +82,7 @@ class OIDCAuthRequestView(View):
|
||||
code_verifier = self.gen_code_verifier()
|
||||
code_challenge_method = settings.AUTH_OPENID_CODE_CHALLENGE_METHOD or 'S256'
|
||||
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': code_challenge
|
||||
})
|
||||
@@ -91,7 +93,7 @@ class OIDCAuthRequestView(View):
|
||||
if settings.AUTH_OPENID_USE_STATE:
|
||||
logger.debug(log_prompt.format('Use state'))
|
||||
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
|
||||
|
||||
# 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:
|
||||
logger.debug(log_prompt.format('Use nonce'))
|
||||
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
|
||||
|
||||
# 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.
|
||||
logger.debug(log_prompt.format('Construct redirect url'))
|
||||
query = urlencode(authentication_request_params)
|
||||
query = urlencode(request_params)
|
||||
redirect_url = '{url}?{query}'.format(
|
||||
url=settings.AUTH_OPENID_PROVIDER_AUTHORIZATION_ENDPOINT, query=query)
|
||||
|
||||
@@ -129,11 +126,14 @@ class OIDCAuthCallbackView(View, FlashMessageMixin):
|
||||
|
||||
http_method_names = ['get', ]
|
||||
|
||||
|
||||
@redirect_to_pre_save_next_after_auth
|
||||
def get(self, request):
|
||||
""" Processes GET requests. """
|
||||
log_prompt = "Process GET requests [OIDCAuthCallbackView]: {}"
|
||||
logger.debug(log_prompt.format('Start'))
|
||||
callback_params = request.GET
|
||||
error_title = _("OpenID Error")
|
||||
|
||||
# Retrieve the state value that was previously generated. No state means that we cannot
|
||||
# authenticate the user (so a failure should be returned).
|
||||
@@ -166,16 +166,14 @@ class OIDCAuthCallbackView(View, FlashMessageMixin):
|
||||
raise SuspiciousOperation('Invalid OpenID Connect callback state value')
|
||||
|
||||
# Authenticates the end-user.
|
||||
next_url = request.session.get('oidc_auth_next_url', None)
|
||||
code_verifier = request.session.get('oidc_auth_code_verifier', None)
|
||||
logger.debug(log_prompt.format('Process authenticate'))
|
||||
try:
|
||||
user = auth.authenticate(nonce=nonce, request=request, code_verifier=code_verifier)
|
||||
except IntegrityError as e:
|
||||
title = _("OpenID Error")
|
||||
msg = _('Please check if a user with the same username or email already exists')
|
||||
logger.error(e, exc_info=True)
|
||||
response = self.get_failed_response('/', title, msg)
|
||||
response = self.get_failed_response('/', error_title, msg)
|
||||
return response
|
||||
if user:
|
||||
logger.debug(log_prompt.format('Login: {}'.format(user)))
|
||||
@@ -191,10 +189,7 @@ class OIDCAuthCallbackView(View, FlashMessageMixin):
|
||||
callback_params.get('session_state', None)
|
||||
|
||||
logger.debug(log_prompt.format('Redirect'))
|
||||
return HttpResponseRedirect(
|
||||
next_url or settings.AUTH_OPENID_AUTHENTICATION_REDIRECT_URI
|
||||
)
|
||||
|
||||
return HttpResponseRedirect(settings.AUTH_OPENID_AUTHENTICATION_REDIRECT_URI)
|
||||
if 'error' in callback_params:
|
||||
logger.debug(
|
||||
log_prompt.format('Error in callback params: {}'.format(callback_params['error']))
|
||||
@@ -205,13 +200,12 @@ class OIDCAuthCallbackView(View, FlashMessageMixin):
|
||||
# OpenID Connect Provider authenticate endpoint.
|
||||
logger.debug(log_prompt.format('Logout'))
|
||||
auth.logout(request)
|
||||
|
||||
redirect_url = settings.AUTH_OPENID_AUTHENTICATION_FAILURE_REDIRECT_URI
|
||||
if not user and getattr(request, 'error_message', ''):
|
||||
response = self.get_failed_response(redirect_url, title=error_title, msg=request.error_message)
|
||||
return response
|
||||
logger.debug(log_prompt.format('Redirect'))
|
||||
return HttpResponseRedirect(settings.AUTH_OPENID_AUTHENTICATION_FAILURE_REDIRECT_URI)
|
||||
|
||||
|
||||
class OIDCAuthCallbackClientView(BaseAuthCallbackClientView):
|
||||
pass
|
||||
return HttpResponseRedirect(redirect_url)
|
||||
|
||||
|
||||
class OIDCEndSessionView(View):
|
||||
|
||||
@@ -8,6 +8,5 @@ urlpatterns = [
|
||||
path('login/', views.Saml2AuthRequestView.as_view(), name='saml2-login'),
|
||||
path('logout/', views.Saml2EndSessionView.as_view(), name='saml2-logout'),
|
||||
path('callback/', views.Saml2AuthCallbackView.as_view(), name='saml2-callback'),
|
||||
path('callback/client/', views.Saml2AuthCallbackClientView.as_view(), name='saml2-callback-client'),
|
||||
path('metadata/', views.Saml2AuthMetadataView.as_view(), name='saml2-metadata'),
|
||||
]
|
||||
|
||||
@@ -17,9 +17,8 @@ from onelogin.saml2.idp_metadata_parser import (
|
||||
)
|
||||
|
||||
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 ..base import BaseAuthCallbackClientView
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
||||
@@ -208,13 +207,16 @@ class Saml2AuthRequestView(View, PrepareRequestMixin):
|
||||
log_prompt = "Process SAML GET requests: {}"
|
||||
logger.debug(log_prompt.format('Start'))
|
||||
|
||||
request_params = request.GET.dict()
|
||||
|
||||
try:
|
||||
saml_instance = self.init_saml_auth(request)
|
||||
except OneLogin_Saml2_Error as error:
|
||||
logger.error(log_prompt.format('Init saml auth error: %s' % error))
|
||||
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)
|
||||
logger.debug(log_prompt.format('Redirect login url'))
|
||||
return HttpResponseRedirect(url)
|
||||
@@ -252,6 +254,7 @@ class Saml2AuthCallbackView(View, PrepareRequestMixin, FlashMessageMixin):
|
||||
def post(self, request):
|
||||
log_prompt = "Process SAML2 POST requests: {}"
|
||||
post_data = request.POST
|
||||
error_title = _("SAML2 Error")
|
||||
|
||||
try:
|
||||
saml_instance = self.init_saml_auth(request)
|
||||
@@ -279,20 +282,24 @@ class Saml2AuthCallbackView(View, PrepareRequestMixin, FlashMessageMixin):
|
||||
try:
|
||||
user = auth.authenticate(request=request, saml_user_data=saml_user_data)
|
||||
except IntegrityError as e:
|
||||
title = _("SAML2 Error")
|
||||
msg = _('Please check if a user with the same username or email already exists')
|
||||
logger.error(e, exc_info=True)
|
||||
response = self.get_failed_response('/', title, msg)
|
||||
response = self.get_failed_response('/', error_title, msg)
|
||||
return response
|
||||
if user and user.is_valid:
|
||||
logger.debug(log_prompt.format('Login: {}'.format(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'))
|
||||
redir = post_data.get('RelayState')
|
||||
if not redir or len(redir) == 0:
|
||||
redir = "/"
|
||||
next_url = saml_instance.redirect_to(redir)
|
||||
relay_state = post_data.get('RelayState')
|
||||
if not relay_state or len(relay_state) == 0:
|
||||
relay_state = "/"
|
||||
next_url = saml_instance.redirect_to(relay_state)
|
||||
next_url = safe_next_url(next_url, request=request)
|
||||
return HttpResponseRedirect(next_url)
|
||||
|
||||
@csrf_exempt
|
||||
@@ -300,10 +307,6 @@ class Saml2AuthCallbackView(View, PrepareRequestMixin, FlashMessageMixin):
|
||||
return super().dispatch(*args, **kwargs)
|
||||
|
||||
|
||||
class Saml2AuthCallbackClientView(BaseAuthCallbackClientView):
|
||||
pass
|
||||
|
||||
|
||||
class Saml2AuthMetadataView(View, PrepareRequestMixin):
|
||||
|
||||
def get(self, request):
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
from django.db.models import TextChoices
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
USER_LOGIN_GUARD_VIEW_REDIRECT_FIELD = 'next'
|
||||
|
||||
RSA_PRIVATE_KEY = 'rsa_private_key'
|
||||
RSA_PUBLIC_KEY = 'rsa_public_key'
|
||||
|
||||
|
||||
193
apps/authentication/decorators.py
Normal file
193
apps/authentication/decorators.py
Normal 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
|
||||
@@ -114,12 +114,12 @@ class BlockMFAError(AuthFailedNeedLogMixin, AuthFailedError):
|
||||
super().__init__(username=username, request=request, ip=ip)
|
||||
|
||||
|
||||
class BlockLoginError(AuthFailedNeedBlockMixin, AuthFailedError):
|
||||
class BlockLoginError(AuthFailedNeedLogMixin, AuthFailedNeedBlockMixin, AuthFailedError):
|
||||
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)
|
||||
super().__init__(username=username, ip=ip)
|
||||
super().__init__(username=username, ip=ip, request=request)
|
||||
|
||||
|
||||
class SessionEmptyError(AuthFailedError):
|
||||
|
||||
0
apps/authentication/management/__init__.py
Normal file
0
apps/authentication/management/__init__.py
Normal file
0
apps/authentication/management/commands/__init__.py
Normal file
0
apps/authentication/management/commands/__init__.py
Normal 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
|
||||
@@ -1,10 +1,12 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
import inspect
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from functools import partial
|
||||
from typing import Callable
|
||||
from werkzeug.local import Local
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import auth
|
||||
@@ -12,8 +14,10 @@ from django.contrib.auth import (
|
||||
BACKEND_SESSION_KEY, load_backend,
|
||||
PermissionDenied, user_login_failed, _clean_credentials,
|
||||
)
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.cache import cache
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.db.models import Q
|
||||
from django.shortcuts import reverse, redirect, get_object_or_404
|
||||
from django.utils.http import urlencode
|
||||
from django.utils.translation import gettext as _
|
||||
@@ -29,6 +33,87 @@ from .signals import post_auth_success, post_auth_failed
|
||||
|
||||
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):
|
||||
backends = []
|
||||
@@ -49,14 +134,13 @@ def _get_backends(return_tuples=False):
|
||||
auth._get_backends = _get_backends
|
||||
|
||||
|
||||
@_authenticate_context
|
||||
def authenticate(request=None, **credentials):
|
||||
"""
|
||||
If the given credentials are valid, return a User object.
|
||||
之所以 hack 这个 authenticate
|
||||
"""
|
||||
username = credentials.get('username')
|
||||
|
||||
temp_user = None
|
||||
username = credentials.get('username')
|
||||
for backend, backend_path in _get_backends(return_tuples=True):
|
||||
# 检查用户名是否允许认证 (预先检查,不浪费认证时间)
|
||||
logger.info('Try using auth backend: {}'.format(str(backend)))
|
||||
@@ -70,18 +154,28 @@ def authenticate(request=None, **credentials):
|
||||
except TypeError:
|
||||
# This backend doesn't accept these credentials as arguments. Try the next one.
|
||||
continue
|
||||
|
||||
try:
|
||||
user = backend.authenticate(request, **credentials)
|
||||
except PermissionDenied:
|
||||
# This backend says to stop in our tracks - this user should not be allowed in at all.
|
||||
break
|
||||
except OnlyAllowExistUserAuthError:
|
||||
if request:
|
||||
request.error_message = _(
|
||||
'''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
|
||||
|
||||
if user is None:
|
||||
continue
|
||||
|
||||
if not user.is_valid:
|
||||
temp_user = user
|
||||
temp_user.backend = backend_path
|
||||
request.error_message = _('User is invalid')
|
||||
if request:
|
||||
request.error_message = _('User is invalid')
|
||||
return temp_user
|
||||
|
||||
# 检查用户是否允许认证
|
||||
@@ -96,8 +190,11 @@ def authenticate(request=None, **credentials):
|
||||
else:
|
||||
if temp_user is not None:
|
||||
source_display = temp_user.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)
|
||||
if request:
|
||||
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
|
||||
|
||||
# The credentials supplied are invalid to all backends, fire signal
|
||||
@@ -176,9 +273,9 @@ class AuthPreCheckMixin:
|
||||
if not is_block:
|
||||
return
|
||||
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:
|
||||
raise errors.BlockLoginError(username=username, ip=ip)
|
||||
raise exception
|
||||
else:
|
||||
return exception
|
||||
|
||||
@@ -195,7 +292,8 @@ class AuthPreCheckMixin:
|
||||
if not settings.ONLY_ALLOW_EXIST_USER_AUTH:
|
||||
return
|
||||
|
||||
exist = User.objects.filter(username=username).exists()
|
||||
q = Q(username=username) | Q(email=username)
|
||||
exist = User.objects.filter(q).exists()
|
||||
if not exist:
|
||||
logger.error(f"Only allow exist user auth, login failed: {username}")
|
||||
self.raise_credential_error(errors.reason_user_not_exist)
|
||||
|
||||
@@ -9,11 +9,12 @@ from common.utils import get_object_or_none, random_string
|
||||
from users.models import User
|
||||
from users.serializers import UserProfileSerializer
|
||||
from ..models import AccessKey, TempToken
|
||||
from oauth2_provider.models import get_access_token_model
|
||||
|
||||
__all__ = [
|
||||
'AccessKeySerializer', 'BearerTokenSerializer',
|
||||
'SSOTokenSerializer', 'TempTokenSerializer',
|
||||
'AccessKeyCreateSerializer'
|
||||
'AccessKeyCreateSerializer', 'AccessTokenSerializer',
|
||||
]
|
||||
|
||||
|
||||
@@ -114,3 +115,28 @@ class TempTokenSerializer(serializers.ModelSerializer):
|
||||
token = TempToken(**kwargs)
|
||||
token.save()
|
||||
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 "****"
|
||||
@@ -9,6 +9,8 @@ from audits.models import UserSession
|
||||
from common.sessions.cache import user_session_manager
|
||||
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)
|
||||
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):
|
||||
request.session['auth_backend'] = backend
|
||||
post_auth_failed.send(sender, username=username, request=request, reason=reason)
|
||||
|
||||
|
||||
@@ -47,3 +47,9 @@ def clean_expire_token():
|
||||
count = TempToken.objects.filter(date_expired__lt=expired_time).delete()
|
||||
logging.info('Deleted %d temporary tokens.', count[0])
|
||||
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()
|
||||
@@ -16,6 +16,7 @@ router.register('super-connection-token', api.SuperConnectionTokenViewSet, 'supe
|
||||
router.register('admin-connection-token', api.AdminConnectionTokenViewSet, 'admin-connection-token')
|
||||
router.register('confirm', api.UserConfirmationViewSet, 'confirm')
|
||||
router.register('ssh-key', api.SSHkeyViewSet, 'ssh-key')
|
||||
router.register('access-tokens', api.AccessTokenViewSet, 'access-token')
|
||||
|
||||
urlpatterns = [
|
||||
path('<str:backend>/qr/unbind/', api.QRUnBindForUserApi.as_view(), name='qr-unbind'),
|
||||
|
||||
@@ -83,4 +83,6 @@ urlpatterns = [
|
||||
path('oauth2/', include(('authentication.backends.oauth2.urls', 'authentication'), namespace='oauth2')),
|
||||
|
||||
path('captcha/', include('captcha.urls')),
|
||||
|
||||
path('oauth2-provider/', include(('authentication.backends.oauth2_provider.urls', 'authentication'), namespace='oauth2-provider'))
|
||||
]
|
||||
|
||||
@@ -11,11 +11,12 @@ from rest_framework.request import Request
|
||||
from authentication import errors
|
||||
from authentication.mixins import AuthMixin
|
||||
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.common import get_request_ip
|
||||
from common.utils.django import reverse, get_object_or_none
|
||||
from users.models import User
|
||||
from users.signal_handlers import check_only_allow_exist_user_auth, bind_user_to_org_role
|
||||
from users.signal_handlers import bind_user_to_org_role, check_only_allow_exist_user_auth
|
||||
from .mixins import FlashMessageMixin
|
||||
|
||||
logger = get_logger(__file__)
|
||||
@@ -55,7 +56,6 @@ class BaseLoginCallbackView(AuthMixin, FlashMessageMixin, IMClientMixin, View):
|
||||
)
|
||||
|
||||
if not check_only_allow_exist_user_auth(create):
|
||||
user.delete()
|
||||
return user, (self.msg_client_err, self.request.error_message)
|
||||
|
||||
setattr(user, f'{self.user_type}_id', user_id)
|
||||
@@ -73,6 +73,7 @@ class BaseLoginCallbackView(AuthMixin, FlashMessageMixin, IMClientMixin, View):
|
||||
|
||||
return user, None
|
||||
|
||||
@post_save_next_to_session_if_guard_redirect
|
||||
def get(self, request: Request):
|
||||
code = request.GET.get('code')
|
||||
redirect_url = request.GET.get('redirect_url')
|
||||
@@ -111,8 +112,6 @@ class BaseLoginCallbackView(AuthMixin, FlashMessageMixin, IMClientMixin, View):
|
||||
response = self.get_failed_response(login_url, title=msg, msg=msg)
|
||||
return response
|
||||
|
||||
if redirect_url and 'next=client' in redirect_url:
|
||||
self.request.META['QUERY_STRING'] += '&next=client'
|
||||
return self.redirect_to_guard_view()
|
||||
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from django.views import View
|
||||
from rest_framework.exceptions import APIException
|
||||
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.const import ConfirmType
|
||||
from authentication.mixins import AuthMixin
|
||||
@@ -24,7 +25,7 @@ from common.views.mixins import PermissionsMixin, UserConfirmRequiredExceptionMi
|
||||
from users.models import User
|
||||
from users.views import UserVerifyPasswordView
|
||||
from .base import BaseLoginCallbackView
|
||||
from .mixins import METAMixin, FlashMessageMixin
|
||||
from .mixins import FlashMessageMixin
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
||||
@@ -171,20 +172,18 @@ class DingTalkEnableStartView(UserVerifyPasswordView):
|
||||
return success_url
|
||||
|
||||
|
||||
class DingTalkQRLoginView(DingTalkQRMixin, METAMixin, View):
|
||||
class DingTalkQRLoginView(DingTalkQRMixin, View):
|
||||
permission_classes = (AllowAny,)
|
||||
|
||||
@pre_save_next_to_session()
|
||||
def get(self, request: HttpRequest):
|
||||
redirect_url = request.GET.get('redirect_url') or reverse('index')
|
||||
query_string = request.GET.urlencode()
|
||||
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 += '?' + urlencode({
|
||||
'redirect_url': redirect_url,
|
||||
'next': next_url,
|
||||
})
|
||||
|
||||
url = self.get_qr_url(redirect_uri)
|
||||
@@ -210,6 +209,7 @@ class DingTalkQRLoginCallbackView(DingTalkQRMixin, BaseLoginCallbackView):
|
||||
class DingTalkOAuthLoginView(DingTalkOAuthMixin, View):
|
||||
permission_classes = (AllowAny,)
|
||||
|
||||
@pre_save_next_to_session()
|
||||
def get(self, request: HttpRequest):
|
||||
redirect_url = request.GET.get('redirect_url')
|
||||
|
||||
@@ -223,6 +223,7 @@ class DingTalkOAuthLoginView(DingTalkOAuthMixin, View):
|
||||
class DingTalkOAuthLoginCallbackView(AuthMixin, DingTalkOAuthMixin, View):
|
||||
permission_classes = (AllowAny,)
|
||||
|
||||
@post_save_next_to_session_if_guard_redirect
|
||||
def get(self, request: HttpRequest):
|
||||
code = request.GET.get('code')
|
||||
redirect_url = request.GET.get('redirect_url')
|
||||
|
||||
@@ -8,6 +8,7 @@ from django.views import View
|
||||
from rest_framework.exceptions import APIException
|
||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
|
||||
from authentication.decorators import pre_save_next_to_session
|
||||
from authentication.const import ConfirmType
|
||||
from authentication.permissions import UserConfirmation
|
||||
from common.sdk.im.feishu import URL
|
||||
@@ -108,9 +109,12 @@ class FeiShuQRBindCallbackView(FeiShuQRMixin, BaseBindCallbackView):
|
||||
class FeiShuQRLoginView(FeiShuQRMixin, View):
|
||||
permission_classes = (AllowAny,)
|
||||
|
||||
@pre_save_next_to_session()
|
||||
def get(self, request: HttpRequest):
|
||||
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_uri = reverse(f'authentication:{self.category}-qr-login-callback', external=True)
|
||||
redirect_uri += '?' + urlencode({
|
||||
|
||||
@@ -29,7 +29,7 @@ from users.utils import (
|
||||
redirect_user_first_login_or_index
|
||||
)
|
||||
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 ..utils import get_auth_methods
|
||||
|
||||
@@ -260,7 +260,7 @@ class UserLoginView(mixins.AuthMixin, UserLoginContextMixin, FormView):
|
||||
|
||||
|
||||
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_mfa_url = reverse_lazy('authentication:login-mfa')
|
||||
login_confirm_url = reverse_lazy('authentication:login-wait-confirm')
|
||||
|
||||
@@ -4,17 +4,6 @@ from django.utils.translation import gettext_lazy as _
|
||||
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:
|
||||
@staticmethod
|
||||
def get_response(redirect_url='', title='', msg='', m_type='message', interval=5):
|
||||
|
||||
@@ -8,6 +8,7 @@ from rest_framework.exceptions import APIException
|
||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
from rest_framework.request import Request
|
||||
|
||||
from authentication.decorators import pre_save_next_to_session
|
||||
from authentication.const import ConfirmType
|
||||
from authentication.permissions import UserConfirmation
|
||||
from common.sdk.im.slack import URL, SLACK_REDIRECT_URI_SESSION_KEY
|
||||
@@ -96,9 +97,12 @@ class SlackEnableStartView(UserVerifyPasswordView):
|
||||
class SlackQRLoginView(SlackMixin, View):
|
||||
permission_classes = (AllowAny,)
|
||||
|
||||
@pre_save_next_to_session()
|
||||
def get(self, request: Request):
|
||||
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_uri = reverse('authentication:slack-qr-login-callback', external=True)
|
||||
redirect_uri += '?' + urlencode({
|
||||
|
||||
@@ -12,6 +12,7 @@ from authentication import errors
|
||||
from authentication.const import ConfirmType
|
||||
from authentication.mixins import AuthMixin
|
||||
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 WeCom, wecom_tool
|
||||
from common.utils import get_logger
|
||||
@@ -20,7 +21,7 @@ from common.views.mixins import UserConfirmRequiredExceptionMixin, PermissionsMi
|
||||
from users.models import User
|
||||
from users.views import UserVerifyPasswordView
|
||||
from .base import BaseLoginCallbackView, BaseBindCallbackView
|
||||
from .mixins import METAMixin, FlashMessageMixin
|
||||
from .mixins import FlashMessageMixin
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
||||
@@ -115,19 +116,14 @@ class WeComEnableStartView(UserVerifyPasswordView):
|
||||
return success_url
|
||||
|
||||
|
||||
class WeComQRLoginView(WeComQRMixin, METAMixin, View):
|
||||
class WeComQRLoginView(WeComQRMixin, View):
|
||||
permission_classes = (AllowAny,)
|
||||
|
||||
@pre_save_next_to_session()
|
||||
def get(self, request: HttpRequest):
|
||||
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 += '?' + urlencode({
|
||||
'redirect_url': redirect_url,
|
||||
'next': next_url,
|
||||
})
|
||||
|
||||
redirect_uri += '?' + urlencode({'redirect_url': redirect_url})
|
||||
url = self.get_qr_url(redirect_uri)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -148,12 +144,11 @@ class WeComQRLoginCallbackView(WeComQRMixin, BaseLoginCallbackView):
|
||||
class WeComOAuthLoginView(WeComOAuthMixin, View):
|
||||
permission_classes = (AllowAny,)
|
||||
|
||||
@pre_save_next_to_session()
|
||||
def get(self, request: HttpRequest):
|
||||
redirect_url = request.GET.get('redirect_url')
|
||||
|
||||
redirect_uri = reverse('authentication:wecom-oauth-login-callback', external=True)
|
||||
redirect_uri += '?' + urlencode({'redirect_url': redirect_url})
|
||||
|
||||
url = self.get_oauth_url(redirect_uri)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -161,6 +156,7 @@ class WeComOAuthLoginView(WeComOAuthMixin, View):
|
||||
class WeComOAuthLoginCallbackView(AuthMixin, WeComOAuthMixin, View):
|
||||
permission_classes = (AllowAny,)
|
||||
|
||||
@post_save_next_to_session_if_guard_redirect
|
||||
def get(self, request: HttpRequest):
|
||||
code = request.GET.get('code')
|
||||
redirect_url = request.GET.get('redirect_url')
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
from django.conf import settings
|
||||
from typing import Callable
|
||||
|
||||
from django.utils.translation import gettext as _
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.throttling import UserRateThrottle
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
|
||||
@@ -14,8 +16,12 @@ from orgs.utils import current_org
|
||||
__all__ = ['SuggestionMixin', 'RenderToJsonMixin']
|
||||
|
||||
|
||||
class CustomUserRateThrottle(UserRateThrottle):
|
||||
rate = '60/m'
|
||||
|
||||
|
||||
class SuggestionMixin:
|
||||
suggestion_limit = 10
|
||||
suggestion_limit = settings.SUGGESTION_LIMIT
|
||||
|
||||
filter_queryset: Callable
|
||||
get_queryset: Callable
|
||||
@@ -35,6 +41,7 @@ class SuggestionMixin:
|
||||
queryset = queryset.none()
|
||||
|
||||
queryset = self.filter_queryset(queryset)
|
||||
|
||||
queryset = queryset[:self.suggestion_limit]
|
||||
page = self.paginate_queryset(queryset)
|
||||
|
||||
@@ -45,6 +52,11 @@ class SuggestionMixin:
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
def get_throttles(self):
|
||||
if self.action == 'match':
|
||||
return [CustomUserRateThrottle()]
|
||||
return super().get_throttles()
|
||||
|
||||
|
||||
class RenderToJsonMixin:
|
||||
@action(methods=[POST, PUT], detail=False, url_path='render-to-json')
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
from rest_framework.filters import SearchFilter as SearchFilterBase
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
@@ -35,6 +36,14 @@ __all__ = [
|
||||
]
|
||||
|
||||
|
||||
class SearchFilter(SearchFilterBase):
|
||||
def get_search_terms(self, request):
|
||||
params = request.query_params.get(self.search_param, '') or request.query_params.get('search', '')
|
||||
params = params.replace('\x00', '') # strip null characters
|
||||
params = params.replace(',', ' ')
|
||||
return params.split()
|
||||
|
||||
|
||||
class BaseFilterSet(drf_filters.FilterSet):
|
||||
days = drf_filters.NumberFilter(method="filter_days")
|
||||
days__lt = drf_filters.NumberFilter(method="filter_days")
|
||||
|
||||
@@ -183,6 +183,7 @@ class BaseFileRenderer(LogMixin, BaseRenderer):
|
||||
for item in data:
|
||||
row = []
|
||||
for field in render_fields:
|
||||
field._row = item
|
||||
value = item.get(field.field_name)
|
||||
value = self.render_value(field, value)
|
||||
row.append(value)
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import re
|
||||
import uuid
|
||||
import time
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.test import Client
|
||||
from django.urls import URLPattern, URLResolver
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
|
||||
from jumpserver.urls import api_v1
|
||||
|
||||
@@ -85,50 +89,262 @@ known_error_urls = [
|
||||
'/api/v1/terminal/sessions/00000000-0000-0000-0000-000000000000/replay/download/',
|
||||
]
|
||||
|
||||
# API 白名单 - 普通用户可以访问的 API
|
||||
user_accessible_urls = known_unauth_urls + [
|
||||
# 添加更多普通用户可以访问的 API
|
||||
"/api/v1/settings/public/",
|
||||
"/api/v1/users/profile/",
|
||||
"/api/v1/users/change-password/",
|
||||
"/api/v1/users/logout/",
|
||||
"/api/v1/settings/chatai-prompts/",
|
||||
"/api/v1/authentication/confirm/",
|
||||
"/api/v1/users/connection-token/",
|
||||
"/api/v1/authentication/temp-tokens/",
|
||||
"/api/v1/notifications/backends/",
|
||||
"/api/v1/authentication/passkeys/",
|
||||
"/api/v1/orgs/orgs/current/",
|
||||
"/api/v1/tickets/apply-asset-tickets/",
|
||||
"/api/v1/ops/celery/task/00000000-0000-0000-0000-000000000000/task-execution/00000000-0000-0000-0000-000000000000/log/",
|
||||
"/api/v1/assets/favorite-assets/",
|
||||
"/api/v1/authentication/connection-token/",
|
||||
"/api/v1/ops/jobs/",
|
||||
"/api/v1/assets/categories/",
|
||||
"/api/v1/tickets/tickets/",
|
||||
"/api/v1/authentication/ssh-key/",
|
||||
"/api/v1/terminal/my-sessions/",
|
||||
"/api/v1/authentication/access-keys/",
|
||||
"/api/v1/users/profile/permissions/",
|
||||
"/api/v1/tickets/apply-login-asset-tickets/",
|
||||
"/api/v1/resources/",
|
||||
"/api/v1/ops/celery/task/00000000-0000-0000-0000-000000000000/task-execution/00000000-0000-0000-0000-000000000000/result/",
|
||||
"/api/v1/notifications/site-messages/",
|
||||
"/api/v1/notifications/site-messages/unread-total/",
|
||||
"/api/v1/assets/assets/suggestions/",
|
||||
"/api/v1/search/",
|
||||
"/api/v1/notifications/user-msg-subscription/",
|
||||
"/api/v1/ops/ansible/job-execution/00000000-0000-0000-0000-000000000000/log/",
|
||||
"/api/v1/tickets/apply-login-tickets/",
|
||||
"/api/v1/ops/variables/form-data/",
|
||||
"/api/v1/ops/variables/help/",
|
||||
"/api/v1/users/profile/password/",
|
||||
"/api/v1/tickets/apply-command-tickets/",
|
||||
"/api/v1/ops/job-executions/",
|
||||
"/api/v1/audits/my-login-logs/",
|
||||
"/api/v1/terminal/components/connect-methods/"
|
||||
"/api/v1/ops/task-executions/",
|
||||
"/api/v1/terminal/sessions/online-info/",
|
||||
"/api/v1/ops/adhocs/",
|
||||
"/api/v1/tickets/apply-nodes/suggestions/",
|
||||
"/api/v1/tickets/apply-assets/suggestions/",
|
||||
"/api/v1/settings/server-info/",
|
||||
"/api/v1/ops/playbooks/",
|
||||
"/api/v1/assets/categories/types/",
|
||||
"/api/v1/assets/protocols/",
|
||||
"/api/v1/common/countries/",
|
||||
"/api/v1/audits/jobs/",
|
||||
"/api/v1/terminal/components/connect-methods/",
|
||||
"/api/v1/ops/task-executions/",
|
||||
]
|
||||
|
||||
errors = {}
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Check api if unauthorized'
|
||||
"""
|
||||
Check API authorization and user access permissions.
|
||||
|
||||
This command performs two types of checks:
|
||||
1. Anonymous access check - finds APIs that can be accessed without authentication
|
||||
2. User access check - finds APIs that can be accessed by a normal user
|
||||
|
||||
The functionality is split into two methods:
|
||||
- check_anonymous_access(): Checks for APIs accessible without authentication
|
||||
- check_user_access(): Checks for APIs accessible by a normal user
|
||||
|
||||
Usage examples:
|
||||
# Check both anonymous and user access (default behavior)
|
||||
python manage.py check_api
|
||||
|
||||
# Check only anonymous access
|
||||
python manage.py check_api --skip-user-check
|
||||
|
||||
# Check only user access
|
||||
python manage.py check_api --skip-anonymous-check
|
||||
|
||||
# Check user access and update whitelist
|
||||
python manage.py check_api --update-whitelist
|
||||
"""
|
||||
help = 'Check API authorization and user access permissions'
|
||||
password = uuid.uuid4().hex
|
||||
unauth_urls = []
|
||||
error_urls = []
|
||||
unformat_urls = []
|
||||
# 用户可以访问的 API,但不在白名单中的 API
|
||||
unexpected_access = []
|
||||
|
||||
def handle(self, *args, **options):
|
||||
settings.LOG_LEVEL = 'ERROR'
|
||||
urls = get_api_urls()
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--skip-anonymous-check',
|
||||
action='store_true',
|
||||
help='Skip anonymous access check (only check user access)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--skip-user-check',
|
||||
action='store_true',
|
||||
help='Skip user access check (only check anonymous access)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--update-whitelist',
|
||||
action='store_true',
|
||||
help='Update the user accessible URLs whitelist based on current scan results',
|
||||
)
|
||||
|
||||
def create_test_user(self):
|
||||
"""创建测试用户"""
|
||||
User = get_user_model()
|
||||
username = 'test_user_api_check'
|
||||
email = 'test@example.com'
|
||||
|
||||
# 删除可能存在的测试用户
|
||||
User.objects.filter(username=username).delete()
|
||||
|
||||
# 创建新的测试用户
|
||||
user = User.objects.create_user(
|
||||
username=username,
|
||||
email=email,
|
||||
password=self.password,
|
||||
is_active=True
|
||||
)
|
||||
return user
|
||||
|
||||
def check_user_api_access(self, urls):
|
||||
"""检查普通用户可以访问的 API"""
|
||||
user = self.create_test_user()
|
||||
client = Client()
|
||||
client.defaults['HTTP_HOST'] = 'localhost'
|
||||
unauth_urls = []
|
||||
|
||||
# 登录用户
|
||||
login_success = client.login(username=user.username, password=self.password)
|
||||
if not login_success:
|
||||
self.stdout.write(
|
||||
self.style.ERROR('Failed to login test user')
|
||||
)
|
||||
return [], []
|
||||
|
||||
accessible_urls = []
|
||||
error_urls = []
|
||||
unformat_urls = []
|
||||
|
||||
self.stdout.write('Checking user API access...')
|
||||
|
||||
for url, ourl in urls:
|
||||
if '(' in url or '<' in url:
|
||||
continue
|
||||
|
||||
try:
|
||||
response = client.get(url, follow=True)
|
||||
time.sleep(0.1)
|
||||
# 如果状态码是 200 或 201,说明用户可以访问
|
||||
if response.status_code in [200, 201]:
|
||||
accessible_urls.append((url, ourl, response.status_code))
|
||||
elif response.status_code == 403:
|
||||
# 403 表示权限不足,这是正常的
|
||||
pass
|
||||
else:
|
||||
# 其他状态码可能是错误
|
||||
error_urls.append((url, ourl, response.status_code))
|
||||
except Exception as e:
|
||||
error_urls.append((url, ourl, str(e)))
|
||||
|
||||
# 清理测试用户
|
||||
user.delete()
|
||||
|
||||
return accessible_urls, error_urls
|
||||
|
||||
def check_anonymous_access(self, urls):
|
||||
"""检查匿名访问权限"""
|
||||
client = Client()
|
||||
client.defaults['HTTP_HOST'] = 'localhost'
|
||||
|
||||
for url, ourl in urls:
|
||||
if '(' in url or '<' in url:
|
||||
unformat_urls.append([url, ourl])
|
||||
self.unformat_urls.append([url, ourl])
|
||||
continue
|
||||
|
||||
try:
|
||||
response = client.get(url, follow=True)
|
||||
if response.status_code != 401:
|
||||
errors[url] = str(response.status_code) + ' ' + str(ourl)
|
||||
unauth_urls.append(url)
|
||||
self.unauth_urls.append(url)
|
||||
except Exception as e:
|
||||
errors[url] = str(e)
|
||||
error_urls.append(url)
|
||||
self.error_urls.append(url)
|
||||
|
||||
unauth_urls = set(unauth_urls) - set(known_unauth_urls)
|
||||
print("\nUnauthorized urls:")
|
||||
if not unauth_urls:
|
||||
self.unauth_urls = set(self.unauth_urls) - set(known_unauth_urls)
|
||||
self.error_urls = set(self.error_urls)
|
||||
self.unformat_urls = set(self.unformat_urls)
|
||||
|
||||
def print_anonymous_access_result(self):
|
||||
print("\n=== Anonymous Access Check ===")
|
||||
print("Unauthorized urls:")
|
||||
if not self.unauth_urls:
|
||||
print(" Empty, very good!")
|
||||
for url in unauth_urls:
|
||||
for url in self.unauth_urls:
|
||||
print('"{}", {}'.format(url, errors.get(url, '')))
|
||||
|
||||
print("\nError urls:")
|
||||
if not error_urls:
|
||||
if not self.error_urls:
|
||||
print(" Empty, very good!")
|
||||
for url in set(error_urls):
|
||||
for url in set(self.error_urls):
|
||||
print(url, ': ' + errors.get(url))
|
||||
|
||||
print("\nUnformat urls:")
|
||||
if not unformat_urls:
|
||||
if not self.unformat_urls:
|
||||
print(" Empty, very good!")
|
||||
for url in unformat_urls:
|
||||
for url in self.unformat_urls:
|
||||
print(url)
|
||||
|
||||
def check_user_access(self, urls, update_whitelist=False):
|
||||
"""检查用户访问权限"""
|
||||
print("\n=== User Access Check ===")
|
||||
accessible_urls, user_error_urls = self.check_user_api_access(urls)
|
||||
|
||||
# 检查是否有不在白名单中的可访问 API
|
||||
accessible_url_list = [url for url, _, _ in accessible_urls]
|
||||
unexpected_access = set(accessible_url_list) - set(user_accessible_urls)
|
||||
self.unexpected_access = unexpected_access
|
||||
|
||||
# 如果启用了更新白名单选项
|
||||
if update_whitelist:
|
||||
print("\n=== Updating Whitelist ===")
|
||||
new_whitelist = sorted(set(user_accessible_urls + accessible_url_list))
|
||||
print("Updated whitelist would include:")
|
||||
for url in new_whitelist:
|
||||
print(f' "{url}",')
|
||||
print(f"\nTotal URLs in whitelist: {len(new_whitelist)}")
|
||||
|
||||
def print_user_access_result(self):
|
||||
print("\n=== User Access Check ===")
|
||||
|
||||
print("User unexpected urls:")
|
||||
if self.unexpected_access:
|
||||
print(f" Error: Found {len(self.unexpected_access)} URLs accessible by user but not in whitelist:")
|
||||
for url in self.unexpected_access:
|
||||
print(f' "{url}"')
|
||||
else:
|
||||
print(" Empty, very good!")
|
||||
|
||||
def handle(self, *args, **options):
|
||||
settings.LOG_LEVEL = 'ERROR'
|
||||
urls = get_api_urls()
|
||||
|
||||
# 检查匿名访问权限(默认执行)
|
||||
if not options['skip_anonymous_check']:
|
||||
self.check_anonymous_access(urls)
|
||||
|
||||
# 检查用户访问权限(默认执行)
|
||||
if not options['skip_user_check']:
|
||||
self.check_user_access(urls, options['update_whitelist'])
|
||||
|
||||
print("\nCheck total urls: ", len(urls))
|
||||
self.print_anonymous_access_result()
|
||||
self.print_user_access_result()
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#
|
||||
import datetime
|
||||
import inspect
|
||||
|
||||
import sys
|
||||
|
||||
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:
|
||||
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):
|
||||
new_kwargs = {}
|
||||
for k, v in kwargs.items():
|
||||
@@ -361,10 +366,10 @@ class ES(object):
|
||||
if index_in_field in kwargs:
|
||||
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 = (
|
||||
mapping
|
||||
.get(self.query_index, {})
|
||||
.get(self.index, {})
|
||||
.get('mappings', {})
|
||||
.get('properties', {})
|
||||
)
|
||||
@@ -375,6 +380,9 @@ class ES(object):
|
||||
if k in ("org_id", "session") and self.is_keyword(props, k):
|
||||
exact[k] = v
|
||||
|
||||
elif self.is_long(props, k):
|
||||
exact[k] = v
|
||||
|
||||
elif k in common_keyword_able:
|
||||
exact[f"{k}.keyword"] = v
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ class Device:
|
||||
self.__load_driver(driver_path)
|
||||
# open device
|
||||
self.__open_device()
|
||||
self.__reset_key_store()
|
||||
|
||||
def close(self):
|
||||
if self.__device is None:
|
||||
@@ -68,3 +69,12 @@ class Device:
|
||||
if ret != 0:
|
||||
raise PiicoError("open piico device failed", ret)
|
||||
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)
|
||||
|
||||
@@ -162,6 +162,7 @@ class FeiShu(RequestMixin):
|
||||
except Exception as e:
|
||||
logger.error(f'Get user detail error: {e} data={data}')
|
||||
|
||||
data.update(kwargs['other_info'] if 'other_info' in kwargs else {})
|
||||
info = flatten_dict(data)
|
||||
default_detail = self.default_user_detail(data, user_id)
|
||||
detail = map_attributes(default_detail, info, self.attributes)
|
||||
|
||||
@@ -192,6 +192,7 @@ class WeCom(RequestMixin):
|
||||
class WeComTool(object):
|
||||
WECOM_STATE_SESSION_KEY = '_wecom_state'
|
||||
WECOM_STATE_VALUE = 'wecom'
|
||||
WECOM_STATE_NEXT_URL_KEY = 'wecom_oauth_next_url'
|
||||
|
||||
@lazyproperty
|
||||
def qr_cb_url(self):
|
||||
@@ -207,7 +208,8 @@ class WeComTool(object):
|
||||
|
||||
def check_state(self, state, request=None):
|
||||
return cache.get(state) == self.WECOM_STATE_VALUE or \
|
||||
request.session[self.WECOM_STATE_SESSION_KEY] == state
|
||||
request.session.get(self.WECOM_STATE_SESSION_KEY) == state or \
|
||||
request.GET.get('state') == state # 在企业微信桌面端打开的话,重新创建了个 session,会导致 session 校验失败
|
||||
|
||||
def wrap_redirect_url(self, next_url):
|
||||
params = {
|
||||
|
||||
@@ -101,7 +101,7 @@ def get_ip_city(ip):
|
||||
|
||||
info = get_ip_city_by_ipip(ip)
|
||||
if info:
|
||||
city = info.get('city', _("Unknown"))
|
||||
city = info.get('city') or _("Unknown")
|
||||
country = info.get('country')
|
||||
|
||||
# 国内城市 并且 语言是中文就使用国内
|
||||
|
||||
@@ -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"}]
|
||||
"""
|
||||
if not time_periods:
|
||||
return None
|
||||
if not time_periods or all(item['value'] == "" for item in time_periods):
|
||||
# 需要处理 [{"id":1,"value":""},{"id":2,"value":""},{"id":3,"value":""},...]情况
|
||||
# 都没选择相当于全选
|
||||
return True
|
||||
|
||||
if ctime is None:
|
||||
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
@@ -9,7 +9,7 @@
|
||||
"ActionPerm": "Разрешения на действия",
|
||||
"Address": "Адрес",
|
||||
"AlreadyExistsPleaseRename": "Файл уже существует, пожалуйста, переименуйте его",
|
||||
"Announcement: ": "Объявление:",
|
||||
"Announcement: ": "Объявление: ",
|
||||
"Authentication failed": "Ошибка аутентификации: неверное имя пользователя или пароль",
|
||||
"AvailableShortcutKey": "Доступные горячие клавиши",
|
||||
"Back": "Назад",
|
||||
|
||||
@@ -121,7 +121,7 @@
|
||||
"AppletHelpText": "In the upload process, if the application does not exist, create the application; if it exists, update the application.",
|
||||
"AppletHostCreate": "Add RemoteApp machine",
|
||||
"AppletHostDetail": "RemoteApp machine",
|
||||
"AppletHostSelectHelpMessage": "When connecting to an asset, the selection of the application publishing machine is random (but the last used one is preferred). if you want to assign a specific publishing machine to an asset, you can tag it as [publishing machine: publishing machine name] or [AppletHost: publishing machine name]; <br>when selecting an account for the publishing machine, the following situations will choose the user's own <b>account with the same name or proprietary account (starting with js)</b>, otherwise use a public account (starting with jms):<br> 1. both the publishing machine and application support concurrent;<br> 2. the publishing machine supports concurrent, but the application does not, and the current application does not use a proprietary account;<br> 3. the publishing machine does not support concurrent, the application either supports or does not support concurrent, and no application uses a proprietary account;<br> note: whether the application supports concurrent connections is decided by the developer, and whether the host supports concurrent connections is decided by the single user single session setting in the publishing machine configuration",
|
||||
"AppletHostSelectHelpMessage": "When connecting to an asset, the selection of the application publishing machine is random (but the last used one is preferred). if you want to assign a specific publishing machine to an asset, you can tag it as [发布机: publishing machine name] [AppletHost: publishing machine name] [仅发布机: publishing machine name] [AppletHostOnly: publishing machine name]; <br>when selecting an account for the publishing machine, the following situations will choose the user's own <b>account with the same name or proprietary account (starting with js)</b>, otherwise use a public account (starting with jms):<br> 1. both the publishing machine and application support concurrent;<br> 2. the publishing machine supports concurrent, but the application does not, and the current application does not use a proprietary account;<br> 3. the publishing machine does not support concurrent, the application either supports or does not support concurrent, and no application uses a proprietary account;<br> note: whether the application supports concurrent connections is decided by the developer, and whether the host supports concurrent connections is decided by the single user single session setting in the publishing machine configuration",
|
||||
"AppletHostUpdate": "Update the remote app publishing machine",
|
||||
"AppletHostZoneHelpText": "This domain belongs to the system organization",
|
||||
"AppletHosts": "RemoteApp machine",
|
||||
@@ -199,7 +199,7 @@
|
||||
"AuditsDashboard": "Audits dashboard",
|
||||
"Auth": "Authentication",
|
||||
"AuthConfig": "Authentication",
|
||||
"AuthIntegration": "AuthIntegration",
|
||||
"AuthIntegration": "Auth Integration",
|
||||
"AuthLimit": "Login restriction",
|
||||
"AuthSAMLCertHelpText": "Save after uploading the certificate key, then view sp metadata",
|
||||
"AuthSAMLKeyHelpText": "Sp certificates and keys are used for encrypted communication with idp",
|
||||
@@ -280,7 +280,8 @@
|
||||
"CACertificate": "Ca certificate",
|
||||
"CAS": "CAS",
|
||||
"CMPP2": "Cmpp v2.0",
|
||||
"CTYunPrivate": "eCloud Private Cloud",
|
||||
"CTYun": "State Cloud",
|
||||
"CTYunPrivate": "State Cloud(Private)",
|
||||
"CalculationResults": "Error in cron expression",
|
||||
"CallRecords": "Call Records",
|
||||
"CanDragSelect": "Select by dragging; Empty means all selected",
|
||||
@@ -773,6 +774,7 @@
|
||||
"LdapBulkImport": "User import",
|
||||
"LdapConnectTest": "Test connection",
|
||||
"LdapLoginTest": "Test login",
|
||||
"LeakPasswordList": "Leaked password list",
|
||||
"LeakedPassword": "Leaked password",
|
||||
"Length": "Length",
|
||||
"LessEqualThan": "Less than or equal to",
|
||||
@@ -1024,6 +1026,7 @@
|
||||
"PleaseAgreeToTheTerms": "Please agree to the terms",
|
||||
"PleaseEnterReason": "Please enter a reason",
|
||||
"PleaseSelect": "Please select ",
|
||||
"PleaseSelectAssetOrNode": "Please select at least one asset or node",
|
||||
"PleaseSelectTheDataYouWantToCheck": "Please select the data you want to check",
|
||||
"PolicyName": "Policy name",
|
||||
"Port": "Port",
|
||||
@@ -1619,13 +1622,21 @@
|
||||
"assetAddress": "Asset address",
|
||||
"assetId": "Asset ID",
|
||||
"assetName": "Asset name",
|
||||
"clickToAdd": "Click to add",
|
||||
"currentTime": "Current time",
|
||||
"description": "No data yet",
|
||||
"disallowSelfUpdateFields": "Not allowed to modify the current fields yourself",
|
||||
"forceEnableMFAHelpText": "If force enable, user can not disable by themselves",
|
||||
"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",
|
||||
"selectFiles": "Selected {number} files",
|
||||
"selectedAssets": "Selected assets",
|
||||
"setVariable": "Set variable",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -1028,6 +1028,7 @@
|
||||
"PleaseAgreeToTheTerms": "Por favor, acepte los términos",
|
||||
"PleaseEnterReason": "Por favor, introduzca el motivo",
|
||||
"PleaseSelect": "Por favor seleccione",
|
||||
"PleaseSelectAssetOrNode": "Por favor, seleccione un activo o nodo",
|
||||
"PleaseSelectTheDataYouWantToCheck": "Por favor, seleccione los datos que necesita marcar.",
|
||||
"PolicyName": "Nombre de la estrategia",
|
||||
"Port": "Puerto",
|
||||
@@ -1628,13 +1629,17 @@
|
||||
"assetAddress": "Dirección de activo",
|
||||
"assetId": "ID de activo",
|
||||
"assetName": "Nombre de activo",
|
||||
"clickToAdd": "Haga clic en agregar",
|
||||
"currentTime": "Hora actual",
|
||||
"description": "Sin datos disponibles.",
|
||||
"disallowSelfUpdateFields": "No se permite modificar el campo actual.",
|
||||
"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",
|
||||
"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?",
|
||||
"selectedAssets": "Activos seleccionados",
|
||||
"setVariable": "configurar parámetros",
|
||||
"userId": "ID de usuario",
|
||||
"userName": "Nombre de usuario"
|
||||
|
||||
@@ -1033,6 +1033,7 @@
|
||||
"PleaseAgreeToTheTerms": "規約に同意してください",
|
||||
"PleaseEnterReason": "理由を入力してください",
|
||||
"PleaseSelect": "選択してくださ",
|
||||
"PleaseSelectAssetOrNode": "資産またはノードを選択してください",
|
||||
"PleaseSelectTheDataYouWantToCheck": "選択するデータをチェックしてください",
|
||||
"PolicyName": "ポリシー名称",
|
||||
"Port": "ポート",
|
||||
@@ -1633,13 +1634,17 @@
|
||||
"assetAddress": "資産アドレス",
|
||||
"assetId": "資産ID",
|
||||
"assetName": "資産名",
|
||||
"clickToAdd": "追加をクリック",
|
||||
"currentTime": "現在の時間",
|
||||
"description": "データはありません。",
|
||||
"disallowSelfUpdateFields": "現在のフィールドを自分で変更することは許可されていません",
|
||||
"forceEnableMFAHelpText": "強制的に有効化すると、ユーザーは自分で無効化することができません。",
|
||||
"isConsoleCanUse": "管理ページの利用可能性",
|
||||
"name": "ユーザー名",
|
||||
"overwriteProtocolsAndPortsMsg": "この操作はすべてのプロトコルとポートを上書きしますが、続行してよろしいですか?",
|
||||
"pleaseSelectAssets": "資産を選択してください",
|
||||
"removeWarningMsg": "削除してもよろしいですか",
|
||||
"selectedAssets": "選択した資産",
|
||||
"setVariable": "パラメータ設定",
|
||||
"userId": "ユーザーID",
|
||||
"userName": "ユーザー名"
|
||||
|
||||
@@ -1028,6 +1028,7 @@
|
||||
"PleaseAgreeToTheTerms": "약관 동의 부탁드립니다",
|
||||
"PleaseEnterReason": "이유를 입력하세요",
|
||||
"PleaseSelect": "선택해주세요",
|
||||
"PleaseSelectAssetOrNode": "자산 또는 노드를 선택해 주세요",
|
||||
"PleaseSelectTheDataYouWantToCheck": "선택할 데이터를 선택해 주십시오.",
|
||||
"PolicyName": "전략 이름",
|
||||
"Port": "포트",
|
||||
@@ -1628,13 +1629,17 @@
|
||||
"assetAddress": "자산 주소",
|
||||
"assetId": "자산 ID",
|
||||
"assetName": "자산 이름",
|
||||
"clickToAdd": "추가를 클릭해 주세요",
|
||||
"currentTime": "현재 시간",
|
||||
"description": "데이터가 없습니다.",
|
||||
"disallowSelfUpdateFields": "현재 필드를 스스로 수정할 수 없음",
|
||||
"forceEnableMFAHelpText": "강제로 활성화하면 사용자가 스스로 비활성화할 수 없음",
|
||||
"isConsoleCanUse": "관리 페이지 사용 가능 여부",
|
||||
"name": "사용자 이름",
|
||||
"overwriteProtocolsAndPortsMsg": "이 작업은 모든 프로토콜과 포트를 덮어씌우게 됩니다. 계속하시겠습니까?",
|
||||
"pleaseSelectAssets": "자산을 선택해 주세요",
|
||||
"removeWarningMsg": "제거할 것인지 확실합니까?",
|
||||
"selectedAssets": "선택한 자산",
|
||||
"setVariable": "설정 매개변수",
|
||||
"userId": "사용자 ID",
|
||||
"userName": "사용자명"
|
||||
|
||||
@@ -1029,6 +1029,7 @@
|
||||
"PleaseAgreeToTheTerms": "Por favor, concorde com os termos",
|
||||
"PleaseEnterReason": "Por favor, insira o motivo",
|
||||
"PleaseSelect": "Por favor, selecione",
|
||||
"PleaseSelectAssetOrNode": "Por favor, selecione um ativo ou nó",
|
||||
"PleaseSelectTheDataYouWantToCheck": "Por favor, selecione os dados que deseja marcar.",
|
||||
"PolicyName": "Nome da Política",
|
||||
"Port": "Porta",
|
||||
@@ -1629,13 +1630,17 @@
|
||||
"assetAddress": "Endereço do ativo",
|
||||
"assetId": "ID do ativo",
|
||||
"assetName": "Nome do ativo",
|
||||
"clickToAdd": "Clique para adicionar",
|
||||
"currentTime": "Hora atual",
|
||||
"description": "Nenhum dado disponível.",
|
||||
"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",
|
||||
"isConsoleCanUse": "Se a página de gerenciamento está disponível",
|
||||
"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",
|
||||
"selectedAssets": "Ativos selecionados",
|
||||
"setVariable": "Parâmetros de configuração",
|
||||
"userId": "ID do usuário",
|
||||
"userName": "Usuário"
|
||||
|
||||
@@ -122,7 +122,7 @@
|
||||
"AppletHelpText": "В процессе загрузки, если приложение отсутствует, оно будет создано; если уже существует, будет выполнено обновление.",
|
||||
"AppletHostCreate": "Добавить сервер RemoteApp",
|
||||
"AppletHostDetail": "Подробности о хосте RemoteApp",
|
||||
"AppletHostSelectHelpMessage": "При подключении к активу выбор машины публикации приложения происходит случайным образом (но предпочтение отдается последней использованной). Если вы хотите назначить активу определенную машину, вы можете использовать следующие теги: [publishing machine: имя машины публикации] или [AppletHost: имя машины публикации]; <br>при выборе учетной записи для машины публикации в следующих ситуациях будет выбрана собственная <b>учетная запись пользователя с тем же именем или собственная учетная запись (начинающаяся с js)</b>, в противном случае будет использоваться публичная учетная запись (начинающаяся с jms):<br> 1. И машина публикации, и приложение поддерживают одновременные подключения; <br> 2. Машина публикации поддерживает одновременные подключения, а приложение — нет, и текущее приложение не использует специализированную учетную запись; <br> 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",
|
||||
"AppletHostZoneHelpText": "Эта зона принадлежит Системной организации",
|
||||
"AppletHosts": "Хост RemoteApp",
|
||||
@@ -447,10 +447,10 @@
|
||||
"DangerCommand": "Опасная команда",
|
||||
"DangerousCommandNum": "Всего опасных команд",
|
||||
"Dashboard": "Панель инструментов",
|
||||
"DataMasking": "Демаскировка данных",
|
||||
"DataMaskingFieldsPatternHelpTip": "Поддержка нескольких имен полей, разделенных запятой, поддержка подстановочных знаков *\nНапример:\nОдно имя поля: password означает, что будет проведено действие по хранению в тайне поля password.\nНесколько имен полей: password, secret означает сохранение в тайне полей password и secret.\nПодстановочный знак *: password* означает, что действие будет применено к полям, содержащим префикс password.\nПодстановочный знак *: .*password означает, что действие будет применено к полям, содержащим суффикс password.",
|
||||
"DataMaskingRuleHelpHelpMsg": "При подключении к базе данных активов результаты запроса могут быть обработаны в соответствии с этим правилом для обеспечения безопасности данных.",
|
||||
"DataMaskingRuleHelpHelpText": "При подключении к базе данных активов можно обезопасить результаты запроса в соответствии с данным правилом.",
|
||||
"DataMasking": "Маскирование данных",
|
||||
"DataMaskingFieldsPatternHelpTip": "Поддерживается нескольких имён полей, разделённых запятыми, а также использование подстановочного знака *.\nПримеры:\nОдно имя поля: password — выполняется маскирование только поля password.\nНесколько имён полей: password,secret — выполняется маскирование полей password и secret.\nПодстановочный знак *: password* — выполняется маскирование всех полей, имя которых начинается с password.\nПодстановочный знак .*: .*password — выполняется маскирование всех полей, имя которых оканчивается на password",
|
||||
"DataMaskingRuleHelpHelpMsg": "При подключении к активу базы данных результаты запросов могут быть подвергнуты маскированию в соответствии с этим правилом",
|
||||
"DataMaskingRuleHelpHelpText": "При подключении к активу базы данных можно выполнять маскирование результатов запросов в соответствии с этим правилом",
|
||||
"Database": "База данных",
|
||||
"DatabaseCreate": "Создать актив - база данных",
|
||||
"DatabasePort": "Порт протокола базы данных",
|
||||
@@ -491,7 +491,7 @@
|
||||
"DeleteOrgMsg": "Пользователь, Группа пользователей, Актив, Папка, Тег, Зона, Разрешение",
|
||||
"DeleteOrgTitle": "Пожалуйста, сначала удалите следующие ресурсы в организации",
|
||||
"DeleteReleasedAssets": "Удалить освобожденные активы",
|
||||
"DeleteRemoteAccount": "Удалить удаленный аккаунт",
|
||||
"DeleteRemoteAccount": "Удалить УЗ на активе",
|
||||
"DeleteSelected": "Удалить выбранное",
|
||||
"DeleteSuccess": "Успешно удалено",
|
||||
"DeleteSuccessMsg": "Успешно удалено",
|
||||
@@ -729,7 +729,7 @@
|
||||
"InstanceName": "Название экземпляра",
|
||||
"InstanceNamePartIp": "Название экземпляра и часть IP",
|
||||
"InstancePlatformName": "Название платформы экземпляра",
|
||||
"Integration": "Интеграция приложений",
|
||||
"Integration": "Интеграция",
|
||||
"Interface": "Сетевой интерфейс",
|
||||
"InterfaceSettings": "Настройки интерфейса",
|
||||
"Interval": "Интервал",
|
||||
@@ -1028,6 +1028,7 @@
|
||||
"PleaseAgreeToTheTerms": "Пожалуйста, согласитесь с условиями",
|
||||
"PleaseEnterReason": "Введите причину",
|
||||
"PleaseSelect": "Пожалуйста, выберите ",
|
||||
"PleaseSelectAssetOrNode": "Пожалуйста, выберите актив или узел.",
|
||||
"PleaseSelectTheDataYouWantToCheck": "Пожалуйста, выберите данные, которые нужно отметить",
|
||||
"PolicyName": "Название политики",
|
||||
"Port": "Порт",
|
||||
@@ -1045,7 +1046,7 @@
|
||||
"PrivilegedOnly": "Только привилегированные",
|
||||
"PrivilegedTemplate": "Привилегированные",
|
||||
"Processing": "В процессе",
|
||||
"ProcessingMessage": "Задача в процессе, пожалуйста, подождите ⏳",
|
||||
"ProcessingMessage": "Задача выполняется, пожалуйста, подождите ⏳",
|
||||
"Product": "Продукт",
|
||||
"ProfileSetting": "Данные профиля",
|
||||
"Project": "Название проекта",
|
||||
@@ -1117,7 +1118,7 @@
|
||||
"RelevantCommand": "Команда",
|
||||
"RelevantSystemUser": "Системный пользователь",
|
||||
"RemoteAddr": "Удалённый адрес",
|
||||
"RemoteAssetFoundAccountDeleteMsg": "Удалить аккаунты, обнаруженные с удалённых активов",
|
||||
"RemoteAssetFoundAccountDeleteMsg": "Удалить УЗ, обнаруженные на удалённых активах",
|
||||
"RemoteLoginProtocolUsageDistribution": "Распределение протоколов входа в активы",
|
||||
"Remove": "Удалить",
|
||||
"RemoveAssetFromNode": "Удалить активы из папки",
|
||||
@@ -1628,13 +1629,17 @@
|
||||
"assetAddress": "Адрес актива",
|
||||
"assetId": "ID актива",
|
||||
"assetName": "Название актива",
|
||||
"clickToAdd": "Нажмите, чтобы добавить",
|
||||
"currentTime": "Текущее время",
|
||||
"description": "Нет данных",
|
||||
"disallowSelfUpdateFields": "Не разрешено самостоятельно изменять текущие поля.",
|
||||
"forceEnableMFAHelpText": "При принудительном включении пользователь не может отключить самостоятельно",
|
||||
"isConsoleCanUse": "Доступна ли Консоль",
|
||||
"name": "Имя пользователя",
|
||||
"overwriteProtocolsAndPortsMsg": "Это действие заменит все протоколы и порты. Продолжить?",
|
||||
"pleaseSelectAssets": "Пожалуйста, выберите актив",
|
||||
"removeWarningMsg": "Вы уверены, что хотите удалить",
|
||||
"selectedAssets": "Выбранные активы",
|
||||
"setVariable": "Задать переменную",
|
||||
"userId": "ID пользователя",
|
||||
"userName": "Имя пользователя"
|
||||
|
||||
@@ -1028,6 +1028,7 @@
|
||||
"PleaseAgreeToTheTerms": "Vui lòng đồng ý với các điều khoản",
|
||||
"PleaseEnterReason": "Vui lòng nhập lý do",
|
||||
"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",
|
||||
"PolicyName": "Tên chính sách",
|
||||
"Port": "Cổng",
|
||||
@@ -1628,13 +1629,17 @@
|
||||
"assetAddress": "Địa chỉ tài sản",
|
||||
"assetId": "ID tài sản",
|
||||
"assetName": "Tên tài sản",
|
||||
"clickToAdd": "Nhấp để thêm",
|
||||
"currentTime": "Thời gian hiện tại",
|
||||
"description": "Chưa có dữ liệu.",
|
||||
"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",
|
||||
"isConsoleCanUse": "Trang quản lý có khả dụng khô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ỏ?",
|
||||
"selectedAssets": "Tài sản đã chọn",
|
||||
"setVariable": "Cài đặt tham số",
|
||||
"userId": "ID người dùng",
|
||||
"userName": "Tên người dùng"
|
||||
|
||||
@@ -122,7 +122,7 @@
|
||||
"AppletHelpText": "在上传过程中,如果应用不存在,则创建该应用;如果已存在,则进行应用更新。",
|
||||
"AppletHostCreate": "添加远程应用发布机",
|
||||
"AppletHostDetail": "远程应用发布机详情",
|
||||
"AppletHostSelectHelpMessage": "连接资产时,应用发布机选择是随机的(但优先选择上次使用的),如果想为某个资产固定发布机,可以指定标签 <发布机:发布机名称> 或 <AppletHost:发布机名称>; <br>连接该发布机选择账号时,以下情况会选择用户的 <b>同名账号 或 专有账号(js开头)</b>,否则使用公用账号(jms开头):<br> 1. 发布机和应用都支持并发; <br> 2. 发布机支持并发,应用不支持并发,当前应用没有使用专有账号; <br> 3. 发布机不支持并发,应用支持并发或不支持,没有任一应用使用专有账号; <br> 注意: 应用支不支持并发是开发者决定,主机支不支持是发布机配置中的 单用户单会话决定",
|
||||
"AppletHostSelectHelpMessage": "连接资产时,应用发布机选择是随机的(但优先选择上次使用的),如果想为某个资产固定发布机,可以指定标签 <发布机:发布机名称>、<AppletHost:发布机名称>、<仅发布机:发布机名称>、 <AppletHostOnly:发布机名称>; <br>连接该发布机选择账号时,以下情况会选择用户的 <b>同名账号 或 专有账号(js开头)</b>,否则使用公用账号(jms开头):<br> 1. 发布机和应用都支持并发; <br> 2. 发布机支持并发,应用不支持并发,当前应用没有使用专有账号; <br> 3. 发布机不支持并发,应用支持并发或不支持,没有任一应用使用专有账号; <br> 注意: 应用支不支持并发是开发者决定,主机支不支持是发布机配置中的 单用户单会话决定",
|
||||
"AppletHostUpdate": "更新远程应用发布机",
|
||||
"AppletHostZoneHelpText": "这里的网域属于 System 组织",
|
||||
"AppletHosts": "应用发布机",
|
||||
@@ -279,6 +279,7 @@
|
||||
"CACertificate": "CA 证书",
|
||||
"CAS": "CAS",
|
||||
"CMPP2": "CMPP v2.0",
|
||||
"CTYun": "天翼云",
|
||||
"CTYunPrivate": "天翼私有云",
|
||||
"CalculationResults": "cron 表达式错误",
|
||||
"CallRecords": "调用记录",
|
||||
@@ -729,7 +730,7 @@
|
||||
"InstanceName": "实例名称",
|
||||
"InstanceNamePartIp": "实例名称和部分IP",
|
||||
"InstancePlatformName": "实例平台名称",
|
||||
"Integration": "应用集成",
|
||||
"Integration": "集成",
|
||||
"Interface": "网络接口",
|
||||
"InterfaceSettings": "界面设置",
|
||||
"Interval": "间隔",
|
||||
@@ -1028,6 +1029,7 @@
|
||||
"PleaseAgreeToTheTerms": "请同意条款",
|
||||
"PleaseEnterReason": "请输入原因",
|
||||
"PleaseSelect": "请选择",
|
||||
"PleaseSelectAssetOrNode": "请选择资产或节点",
|
||||
"PleaseSelectTheDataYouWantToCheck": "请选择需要勾选的数据",
|
||||
"PolicyName": "策略名称",
|
||||
"Port": "端口",
|
||||
@@ -1628,14 +1630,23 @@
|
||||
"assetAddress": "资产地址",
|
||||
"assetId": "资产 ID",
|
||||
"assetName": "资产名称",
|
||||
"clickToAdd": "点击添加",
|
||||
"currentTime": "当前时间",
|
||||
"description": "暂无数据",
|
||||
"disallowSelfUpdateFields": "不允许自己修改当前字段",
|
||||
"forceEnableMFAHelpText": "如果强制启用,用户无法自行禁用",
|
||||
"isConsoleCanUse": "管理页面是否可用",
|
||||
"name": "用户名称",
|
||||
"overwriteProtocolsAndPortsMsg": "此操作将覆盖所有协议和端口,是否继续?",
|
||||
"pleaseSelectAssets": "请选择资产",
|
||||
"removeWarningMsg": "你确定要移除",
|
||||
"selectedAssets": "已选资产",
|
||||
"setVariable": "设置参数",
|
||||
"userId": "用户ID",
|
||||
"userName": "用户名"
|
||||
}
|
||||
"userName": "用户名",
|
||||
"Risk": "风险",
|
||||
"selectFiles": "已选择选择{number}文件",
|
||||
"AccessToken": "访问令牌",
|
||||
"AccessTokenTip": "访问令牌是通过 JumpServer 客户端使用 OAuth2(授权码授权)流程生成的临时凭证,用于访问受保护的资源。",
|
||||
"Revoke": "撤销"
|
||||
}
|
||||
|
||||
@@ -1033,6 +1033,7 @@
|
||||
"PleaseAgreeToTheTerms": "請同意條款",
|
||||
"PleaseEnterReason": "請輸入原因",
|
||||
"PleaseSelect": "請選擇",
|
||||
"PleaseSelectAssetOrNode": "請選擇資產或節點",
|
||||
"PleaseSelectTheDataYouWantToCheck": "請選擇需要勾選的數據",
|
||||
"PolicyName": "策略名稱",
|
||||
"Port": "端口",
|
||||
@@ -1633,13 +1634,17 @@
|
||||
"assetAddress": "資產地址",
|
||||
"assetId": "資產 ID",
|
||||
"assetName": "資產名稱",
|
||||
"clickToAdd": "點擊添加",
|
||||
"currentTime": "當前時間",
|
||||
"description": "目前沒有數據。",
|
||||
"disallowSelfUpdateFields": "不允許自己修改當前欄位",
|
||||
"forceEnableMFAHelpText": "如果強制啟用,用戶無法自行禁用",
|
||||
"isConsoleCanUse": "管理頁面是否可用",
|
||||
"name": "用戶名稱",
|
||||
"overwriteProtocolsAndPortsMsg": "此操作將覆蓋所有協議和端口,是否繼續?",
|
||||
"pleaseSelectAssets": "請選擇資產",
|
||||
"removeWarningMsg": "你確定要移除",
|
||||
"selectedAssets": "已選資產",
|
||||
"setVariable": "設置參數",
|
||||
"userId": "用戶ID",
|
||||
"userName": "用戶名"
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
"Expand all asset": "Развернуть все активы в папке",
|
||||
"Expire time": "Срок действия",
|
||||
"ExpiredTime": "Срок действия",
|
||||
"Face Verify": "Проверка лица",
|
||||
"Face Verify": "Проверка по лицу",
|
||||
"Face online required": "Для входа требуется верификация по лицу и мониторинг. Продолжить?",
|
||||
"Face verify": "Распознавание лица",
|
||||
"Face verify required": "Для входа требуется верификация по лицу. Продолжить?",
|
||||
@@ -107,7 +107,7 @@
|
||||
"GUI": "Графический интерфейс",
|
||||
"General": "Основные настройки",
|
||||
"Go to Settings": "Перейти в настройки",
|
||||
"Go to profile": "Перейти к личной информации",
|
||||
"Go to profile": "Перейти в профиль",
|
||||
"Help": "Помощь",
|
||||
"Help or download": "Помощь → Скачать",
|
||||
"Help text": "Описание",
|
||||
@@ -151,7 +151,7 @@
|
||||
"No": "Нет",
|
||||
"No account available": "Нет доступных учетных записей",
|
||||
"No available connect method": "Нет доступного метода подключения",
|
||||
"No facial features": "Нет характеристик лица, пожалуйста, перейдите в личную инфрмацию для привязки",
|
||||
"No facial features": "Данные лица отсутствуют. Пожалуйста, перейдите на страницу личной информации, чтобы привязать их. ",
|
||||
"No matching found": "Совпадений не найдено",
|
||||
"No permission": "Нет разрешений",
|
||||
"No protocol available": "Нет доступных протоколов",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from collections import defaultdict
|
||||
|
||||
from django.db.models import Count, Max, F, CharField
|
||||
from django.core.cache import cache
|
||||
from django.db.models import Count, Max, F, CharField, Q
|
||||
from django.db.models.functions import Cast
|
||||
from django.http.response import JsonResponse
|
||||
from django.utils import timezone
|
||||
@@ -18,8 +19,8 @@ from common.utils import lazyproperty
|
||||
from common.utils.timezone import local_now, local_zero_hour
|
||||
from ops.const import JobStatus
|
||||
from orgs.caches import OrgResourceStatisticsCache
|
||||
from orgs.utils import current_org
|
||||
from terminal.const import RiskLevelChoices
|
||||
from orgs.utils import current_org, filter_org_queryset
|
||||
from terminal.const import RiskLevelChoices, CommandStorageType
|
||||
from terminal.models import Session, CommandStorage
|
||||
|
||||
__all__ = ['IndexApi']
|
||||
@@ -123,15 +124,18 @@ class DateTimeMixin:
|
||||
return self.get_logs_queryset_filter(qs, 'date_start')
|
||||
|
||||
@lazyproperty
|
||||
def command_queryset_list(self):
|
||||
def command_type_queryset_list(self):
|
||||
qs_list = []
|
||||
for storage in CommandStorage.objects.all():
|
||||
for storage in CommandStorage.objects.exclude(name='null'):
|
||||
if not storage.is_valid():
|
||||
continue
|
||||
|
||||
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_list.append((storage.type, qs))
|
||||
return qs_list
|
||||
|
||||
@lazyproperty
|
||||
@@ -141,9 +145,10 @@ class DateTimeMixin:
|
||||
|
||||
|
||||
class DatesLoginMetricMixin:
|
||||
days: int
|
||||
dates_list: list
|
||||
date_start_end: tuple
|
||||
command_queryset_list: list
|
||||
command_type_queryset_list: list
|
||||
sessions_queryset: Session.objects
|
||||
ftp_logs_queryset: FTPLog.objects
|
||||
job_logs_queryset: JobLog.objects
|
||||
@@ -152,6 +157,8 @@ class DatesLoginMetricMixin:
|
||||
operate_logs_queryset: OperateLog.objects
|
||||
password_change_logs_queryset: PasswordChangeLog.objects
|
||||
|
||||
CACHE_TIMEOUT = 60
|
||||
|
||||
@lazyproperty
|
||||
def get_type_to_assets(self):
|
||||
result = Asset.objects.annotate(type=F('platform__type')). \
|
||||
@@ -211,19 +218,34 @@ class DatesLoginMetricMixin:
|
||||
return date_metrics_dict.get('id', [])
|
||||
|
||||
def get_dates_login_times_assets(self):
|
||||
cache_key = f"stats:top10_assets:{self.days}"
|
||||
data = cache.get(cache_key)
|
||||
if data is not None:
|
||||
return data
|
||||
|
||||
assets = self.sessions_queryset.values("asset") \
|
||||
.annotate(total=Count("asset")) \
|
||||
.annotate(last=Cast(Max("date_start"), output_field=CharField())) \
|
||||
.order_by("-total")
|
||||
return list(assets[:10])
|
||||
|
||||
result = list(assets[:10])
|
||||
cache.set(cache_key, result, self.CACHE_TIMEOUT)
|
||||
return result
|
||||
|
||||
def get_dates_login_times_users(self):
|
||||
cache_key = f"stats:top10_users:{self.days}"
|
||||
data = cache.get(cache_key)
|
||||
if data is not None:
|
||||
return data
|
||||
|
||||
users = self.sessions_queryset.values("user_id") \
|
||||
.annotate(total=Count("user_id")) \
|
||||
.annotate(user=Max('user')) \
|
||||
.annotate(last=Cast(Max("date_start"), output_field=CharField())) \
|
||||
.order_by("-total")
|
||||
return list(users[:10])
|
||||
result = list(users[:10])
|
||||
cache.set(cache_key, result, self.CACHE_TIMEOUT)
|
||||
return result
|
||||
|
||||
def get_dates_login_record_sessions(self):
|
||||
sessions = self.sessions_queryset.order_by('-date_start')
|
||||
@@ -261,11 +283,25 @@ class DatesLoginMetricMixin:
|
||||
|
||||
@lazyproperty
|
||||
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
|
||||
danger_amount = 0
|
||||
for qs in self.command_queryset_list:
|
||||
total_amount += qs.count()
|
||||
danger_amount += qs.filter(risk_level=RiskLevelChoices.reject).count()
|
||||
for tp, qs in self.command_type_queryset_list:
|
||||
t, d = _count_pair(tp, qs)
|
||||
total_amount += t
|
||||
danger_amount += d
|
||||
return total_amount, danger_amount
|
||||
|
||||
@lazyproperty
|
||||
|
||||
@@ -381,7 +381,6 @@ class Config(dict):
|
||||
'CAS_USERNAME_ATTRIBUTE': 'cas:user',
|
||||
'CAS_APPLY_ATTRIBUTES_TO_USER': False,
|
||||
'CAS_RENAME_ATTRIBUTES': {'cas:user': 'username'},
|
||||
'CAS_CREATE_USER': True,
|
||||
'CAS_ORG_IDS': [DEFAULT_ID],
|
||||
|
||||
'AUTH_SSO': False,
|
||||
@@ -692,9 +691,9 @@ class Config(dict):
|
||||
'FTP_FILE_MAX_STORE': 0,
|
||||
|
||||
# API 分页
|
||||
'MAX_LIMIT_PER_PAGE': 10000, # 给导出用
|
||||
'MAX_LIMIT_PER_PAGE': 10000, # 给导出用
|
||||
'MAX_PAGE_SIZE': 1000,
|
||||
'DEFAULT_PAGE_SIZE': 200, # 给没有请求分页的用
|
||||
'DEFAULT_PAGE_SIZE': 200, # 给没有请求分页的用
|
||||
|
||||
'LIMIT_SUPER_PRIV': False,
|
||||
|
||||
@@ -729,6 +728,16 @@ class Config(dict):
|
||||
'LOKI_BASE_URL': 'http://loki:3100',
|
||||
|
||||
'TOOL_USER_ENABLED': False,
|
||||
|
||||
# Suggestion api
|
||||
'SUGGESTION_LIMIT': 10,
|
||||
|
||||
# MCP
|
||||
'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 = {
|
||||
|
||||
@@ -151,8 +151,13 @@ class SafeRedirectMiddleware:
|
||||
|
||||
if not (300 <= response.status_code < 400):
|
||||
return response
|
||||
if request.resolver_match and request.resolver_match.namespace.startswith('authentication'):
|
||||
# 认证相关的路由跳过验证(core/auth/xxxx
|
||||
if (
|
||||
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
|
||||
location = response.get('Location')
|
||||
if not location:
|
||||
|
||||
@@ -159,7 +159,8 @@ CAS_CHECK_NEXT = lambda _next_page: True
|
||||
CAS_USERNAME_ATTRIBUTE = CONFIG.CAS_USERNAME_ATTRIBUTE
|
||||
CAS_APPLY_ATTRIBUTES_TO_USER = CONFIG.CAS_APPLY_ATTRIBUTES_TO_USER
|
||||
CAS_RENAME_ATTRIBUTES = CONFIG.CAS_RENAME_ATTRIBUTES
|
||||
CAS_CREATE_USER = CONFIG.CAS_CREATE_USER
|
||||
CAS_CREATE_USER = True
|
||||
CAS_STORE_NEXT = True
|
||||
|
||||
# SSO auth
|
||||
AUTH_SSO = CONFIG.AUTH_SSO
|
||||
|
||||
@@ -130,6 +130,7 @@ INSTALLED_APPS = [
|
||||
'settings.apps.SettingsConfig',
|
||||
'terminal.apps.TerminalConfig',
|
||||
'audits.apps.AuditsConfig',
|
||||
'oauth2_provider',
|
||||
'authentication.apps.AuthenticationConfig', # authentication
|
||||
'tickets.apps.TicketsConfig',
|
||||
'acls.apps.AclsConfig',
|
||||
@@ -267,9 +268,17 @@ DATABASES = {
|
||||
DB_USE_SSL = CONFIG.DB_USE_SSL
|
||||
if DB_ENGINE == 'mysql':
|
||||
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)
|
||||
DB_OPTIONS['ssl'] = {'ca': DB_CA_PATH}
|
||||
|
||||
if DB_USE_SSL:
|
||||
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
|
||||
# https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators
|
||||
|
||||
@@ -266,3 +266,6 @@ LOKI_LOG_ENABLED = CONFIG.LOKI_LOG_ENABLED
|
||||
LOKI_BASE_URL = CONFIG.LOKI_BASE_URL
|
||||
|
||||
TOOL_USER_ENABLED = CONFIG.TOOL_USER_ENABLED
|
||||
|
||||
SUGGESTION_LIMIT = CONFIG.SUGGESTION_LIMIT
|
||||
MCP_ENABLED = CONFIG.MCP_ENABLED
|
||||
@@ -30,20 +30,21 @@ REST_FRAMEWORK = {
|
||||
),
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||
# 'rest_framework.authentication.BasicAuthentication',
|
||||
'authentication.backends.drf.AccessTokenAuthentication',
|
||||
'authentication.backends.drf.PrivateTokenAuthentication',
|
||||
'authentication.backends.drf.ServiceAuthentication',
|
||||
'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',
|
||||
),
|
||||
'DEFAULT_FILTER_BACKENDS': (
|
||||
'django_filters.rest_framework.DjangoFilterBackend',
|
||||
'rest_framework.filters.SearchFilter',
|
||||
'common.drf.filters.SearchFilter',
|
||||
'common.drf.filters.RewriteOrderingFilter',
|
||||
),
|
||||
'DEFAULT_METADATA_CLASS': 'common.drf.metadata.SimpleMetadataWithFilters',
|
||||
'ORDERING_PARAM': "order",
|
||||
'SEARCH_PARAM': "search",
|
||||
'SEARCH_PARAM': "q",
|
||||
'DATETIME_FORMAT': '%Y/%m/%d %H:%M:%S %z',
|
||||
'DATETIME_INPUT_FORMATS': ['%Y/%m/%d %H:%M:%S %z', 'iso-8601', '%Y-%m-%d %H:%M:%S %z'],
|
||||
'DEFAULT_PAGINATION_CLASS': 'jumpserver.rewriting.pagination.MaxLimitOffsetPagination',
|
||||
@@ -222,3 +223,17 @@ PIICO_DRIVER_PATH = CONFIG.PIICO_DRIVER_PATH
|
||||
LEAK_PASSWORD_DB_PATH = CONFIG.LEAK_PASSWORD_DB_PATH
|
||||
|
||||
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'
|
||||
|
||||
@@ -35,11 +35,14 @@ resource_api = [
|
||||
|
||||
api_v1 = resource_api + [
|
||||
path('prometheus/metrics/', api.PrometheusMetricsApi.as_view()),
|
||||
path('resources/', api.ResourceTypeListApi.as_view(), name='resource-list'),
|
||||
path('resources/<str:resource>/', api.ResourceListApi.as_view()),
|
||||
path('resources/<str:resource>/<str:pk>/', api.ResourceDetailApi.as_view()),
|
||||
path('search/', api.GlobalSearchView.as_view()),
|
||||
]
|
||||
if settings.MCP_ENABLED:
|
||||
api_v1.extend([
|
||||
path('resources/', api.ResourceTypeListApi.as_view(), name='resource-list'),
|
||||
path('resources/<str:resource>/', api.ResourceListApi.as_view()),
|
||||
path('resources/<str:resource>/<str:pk>/', api.ResourceDetailApi.as_view()),
|
||||
])
|
||||
|
||||
app_view_patterns = [
|
||||
path('auth/', include('authentication.urls.view_urls'), name='auth'),
|
||||
|
||||
@@ -104,7 +104,7 @@ class ResourceDownload(TemplateView):
|
||||
OPENSSH_VERSION=v9.4.0.0
|
||||
TINKER_VERSION=v0.1.6
|
||||
VIDEO_PLAYER_VERSION=0.6.0
|
||||
CLIENT_VERSION=v3.0.7
|
||||
CLIENT_VERSION=4.0.0
|
||||
"""
|
||||
|
||||
def get_meta_json(self):
|
||||
@@ -148,6 +148,6 @@ class RedirectConfirm(TemplateView):
|
||||
parsed = urlparse(url)
|
||||
if not parsed.scheme or not parsed.netloc:
|
||||
return False
|
||||
if parsed.scheme not in ['http', 'https']:
|
||||
if parsed.scheme not in ['http', 'https', 'jms']:
|
||||
return False
|
||||
return True
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user