mirror of
https://github.com/jumpserver/jumpserver.git
synced 2025-12-15 16:42:34 +00:00
Compare commits
212 Commits
pr@dev@ter
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
933e6e4c15 | ||
|
|
46384e19b5 | ||
|
|
e388a7efa0 | ||
|
|
4bc345542c | ||
|
|
829e9b1497 | ||
|
|
8e703d306c | ||
|
|
af908480f4 | ||
|
|
fc2d4ae751 | ||
|
|
cde5fb7a3e | ||
|
|
19da95c6fb | ||
|
|
bc4f29a6f6 | ||
|
|
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 | ||
|
|
d309d11a8f | ||
|
|
4771693a56 | ||
|
|
cefc820ac1 | ||
|
|
d007afdb43 | ||
|
|
e8921a43be | ||
|
|
a9b44103d4 | ||
|
|
4abf2bded6 | ||
|
|
54693089a0 | ||
|
|
0b859dd502 | ||
|
|
3fb27f969a | ||
|
|
45627a1d92 | ||
|
|
245e2dab66 | ||
|
|
8f0a41b1a8 | ||
|
|
1a9e56c520 | ||
|
|
67c2f471b4 | ||
|
|
b04f96f5f2 | ||
|
|
30f03b7d89 | ||
|
|
28a97d0b5a | ||
|
|
3410686690 | ||
|
|
6860e2327f | ||
|
|
20253e760c | ||
|
|
a63cfde8d2 | ||
|
|
92e250e03b | ||
|
|
098f0950cb | ||
|
|
39b0830a6b | ||
|
|
2e847bc2bc | ||
|
|
f82f31876a | ||
|
|
cde182c015 | ||
|
|
b990cdf561 | ||
|
|
c9a062823d | ||
|
|
643ba4fc15 | ||
|
|
d16a55bbe2 | ||
|
|
ae31554729 | ||
|
|
53b47980a2 | ||
|
|
d31b5ee570 | ||
|
|
65aea1ea36 | ||
|
|
5abb5c5d5a | ||
|
|
93e41a5004 | ||
|
|
95f51bbe48 | ||
|
|
0184d292ec | ||
|
|
23a6d320c7 | ||
|
|
b16304c48a | ||
|
|
7cd1e4d3a0 | ||
|
|
64a9987c3f | ||
|
|
18bfe312fa | ||
|
|
c593f91d77 | ||
|
|
46da05652a | ||
|
|
9249aba1a9 | ||
|
|
eca637c120 | ||
|
|
ddacd5fce1 | ||
|
|
3ca5c04099 | ||
|
|
6603a073ec | ||
|
|
d745f7495a | ||
|
|
76f1667c89 | ||
|
|
1ab1954299 | ||
|
|
c8335999a4 | ||
|
|
5b4a67362d | ||
|
|
e025073da2 | ||
|
|
2155bc6862 | ||
|
|
953b515817 | ||
|
|
7f7a354b2d | ||
|
|
2b2f7ea3f0 | ||
|
|
529123e1b5 | ||
|
|
e156ab6ad8 | ||
|
|
3c1fd134ae | ||
|
|
b15f663c87 | ||
|
|
93906dff0a | ||
|
|
307befdacd | ||
|
|
dbfc4d3981 | ||
|
|
849edd33c1 | ||
|
|
37cceec8fe | ||
|
|
d2494c25cc | ||
|
|
023952582e | ||
|
|
863fe95100 | ||
|
|
4b0bdb18c9 | ||
|
|
10da053a95 | ||
|
|
c40bc46520 | ||
|
|
a732cc614e | ||
|
|
bb29d519c6 | ||
|
|
b56c3a76a7 | ||
|
|
ab908d24a7 | ||
|
|
79cabe1b3c | ||
|
|
231b7287c1 |
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 }}
|
||||
|
||||
46
.github/workflows/build-python-image.yml
vendored
Normal file
46
.github/workflows/build-python-image.yml
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
name: Build and Push Python Base Image
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Tag to build'
|
||||
required: true
|
||||
default: '3.11-slim-bullseye-v1'
|
||||
type: string
|
||||
|
||||
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 }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
image: tonistiigi/binfmt:qemu-v7.0.0-28
|
||||
|
||||
- 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: 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-python
|
||||
tags: jumpserver/core-base:python-${{ inputs.tag }}
|
||||
|
||||
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:20250827_025554 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 python:3.11-slim-bullseye
|
||||
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 python:3.11-slim-bullseye
|
||||
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,13 +21,13 @@ 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
|
||||
|
||||
# Install bin tools
|
||||
ARG CHECK_VERSION=v1.0.4
|
||||
ARG CHECK_VERSION=v1.0.5
|
||||
RUN set -ex \
|
||||
&& wget https://github.com/jumpserver-dev/healthcheck/releases/download/${CHECK_VERSION}/check-${CHECK_VERSION}-linux-${TARGETARCH}.tar.gz \
|
||||
&& tar -xf check-${CHECK_VERSION}-linux-${TARGETARCH}.tar.gz \
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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,16 +1,19 @@
|
||||
from django.conf import settings
|
||||
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
|
||||
from accounts.filters import AccountFilterSet, NodeFilterBackend
|
||||
from accounts.mixins import AccountRecordViewLogMixin
|
||||
from accounts.models import Account, ChangeSecretRecord
|
||||
from assets.const.gpt import create_or_update_chatx_resources
|
||||
from assets.models import Asset, Node
|
||||
from authentication.permissions import UserConfirmation, ConfirmType
|
||||
from common.api.mixin import ExtraFilterFieldsMixin
|
||||
@@ -18,6 +21,7 @@ from common.drf.filters import AttrRulesFilterBackend
|
||||
from common.permissions import IsValidUser
|
||||
from common.utils import lazyproperty, get_logger
|
||||
from orgs.mixins.api import OrgBulkModelViewSet
|
||||
from orgs.utils import tmp_to_root_org
|
||||
from rbac.permissions import RBACPermission
|
||||
|
||||
logger = get_logger(__file__)
|
||||
@@ -43,6 +47,7 @@ class AccountViewSet(OrgBulkModelViewSet):
|
||||
'clear_secret': 'accounts.change_account',
|
||||
'move_to_assets': 'accounts.delete_account',
|
||||
'copy_to_assets': 'accounts.add_account',
|
||||
'chat': 'accounts.view_account',
|
||||
}
|
||||
export_as_zip = True
|
||||
|
||||
@@ -152,10 +157,17 @@ class AccountViewSet(OrgBulkModelViewSet):
|
||||
def copy_to_assets(self, request, *args, **kwargs):
|
||||
return self._copy_or_move_to_assets(request, move=False)
|
||||
|
||||
@action(methods=['get'], detail=False, url_path='chat')
|
||||
def chat(self, request, *args, **kwargs):
|
||||
with tmp_to_root_org():
|
||||
__, account = create_or_update_chatx_resources()
|
||||
serializer = self.get_serializer(account)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class AccountSecretsViewSet(AccountRecordViewLogMixin, AccountViewSet):
|
||||
"""
|
||||
因为可能要导出所有账号,所以单独建立了一个 viewset
|
||||
因为可能要导出所有账号,所以单独建立了一个 viewset
|
||||
"""
|
||||
serializer_classes = {
|
||||
'default': serializers.AccountSecretSerializer,
|
||||
@@ -174,12 +186,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 = {
|
||||
@@ -81,4 +81,7 @@ class IntegrationApplicationViewSet(OrgBulkModelViewSet):
|
||||
remote_addr=get_request_ip(request), service=service.name, service_id=service.id,
|
||||
account=f'{account.name}({account.username})', asset=f'{asset.name}({asset.address})',
|
||||
)
|
||||
return Response(data={'id': request.user.id, 'secret': account.secret})
|
||||
|
||||
# 根据配置决定是否返回密码
|
||||
secret = account.secret if settings.SECURITY_ACCOUNT_SECRET_READ else None
|
||||
return Response(data={'id': request.user.id, 'secret': secret})
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from django.conf import settings
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_filters import rest_framework as drf_filters
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import action
|
||||
|
||||
@@ -104,7 +104,7 @@ class AutomationExecutionViewSet(
|
||||
mixins.CreateModelMixin, mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin, viewsets.GenericViewSet
|
||||
):
|
||||
search_fields = ('trigger', 'automation__name')
|
||||
search_fields = ('id', 'trigger', 'automation__name')
|
||||
filterset_fields = ('trigger', 'automation_id', 'automation__name')
|
||||
filterset_class = AutomationExecutionFilterSet
|
||||
serializer_class = serializers.AutomationExecutionSerializer
|
||||
|
||||
@@ -235,8 +235,8 @@ class AccountBackupHandler:
|
||||
except Exception as e:
|
||||
error = str(e)
|
||||
print(f'\033[31m>>> {error}\033[0m')
|
||||
self.execution.status = Status.error
|
||||
self.execution.summary['error'] = error
|
||||
self.manager.status = Status.error
|
||||
self.manager.summary['error'] = error
|
||||
|
||||
def backup_by_obj_storage(self):
|
||||
object_id = self.execution.snapshot.get('id')
|
||||
|
||||
@@ -118,12 +118,10 @@ class BaseChangeSecretPushManager(AccountBasePlaybookManager):
|
||||
if self.secret_type == SecretType.SSH_KEY:
|
||||
host['error'] = _("Windows does not support SSH key authentication")
|
||||
return host
|
||||
|
||||
if self.secret_strategy == SecretStrategy.custom:
|
||||
new_secret = self.execution.snapshot['secret']
|
||||
if '>' in new_secret or '^' in new_secret:
|
||||
host['error'] = _("Windows password cannot contain special characters like > ^")
|
||||
return host
|
||||
new_secret = self.get_secret(account)
|
||||
if '>' in new_secret or '^' in new_secret:
|
||||
host['error'] = _("Windows password cannot contain special characters like > ^")
|
||||
return host
|
||||
|
||||
host['ssh_params'] = {}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
uid: "{{ params.uid | int if params.uid | length > 0 else omit }}"
|
||||
shell: "{{ params.shell if params.shell | length > 0 else omit }}"
|
||||
home: "{{ params.home if params.home | length > 0 else '/home/' + account.username }}"
|
||||
group: "{{ params.group if params.group | length > 0 else omit }}"
|
||||
groups: "{{ params.groups if params.groups | length > 0 else omit }}"
|
||||
append: "{{ true if params.groups | length > 0 else false }}"
|
||||
expires: -1
|
||||
|
||||
@@ -28,6 +28,12 @@ params:
|
||||
default: ''
|
||||
help_text: "{{ 'Params home help text' | trans }}"
|
||||
|
||||
- name: group
|
||||
type: str
|
||||
label: "{{ 'Params group label' | trans }}"
|
||||
default: ''
|
||||
help_text: "{{ 'Params group help text' | trans }}"
|
||||
|
||||
- name: groups
|
||||
type: str
|
||||
label: "{{ 'Params groups label' | trans }}"
|
||||
@@ -61,6 +67,11 @@ i18n:
|
||||
ja: 'デフォルトのホームディレクトリ /home/{アカウントユーザ名}'
|
||||
en: 'Default home directory /home/{account username}'
|
||||
|
||||
Params group help text:
|
||||
zh: '请输入用户组(名字或数字),只能输入一个(需填写已存在的用户组)'
|
||||
ja: 'ユーザー グループ (名前または番号) を入力してください。入力できるのは 1 つだけです (既存のユーザー グループを入力する必要があります)'
|
||||
en: 'Please enter a user group (name or number), only one can be entered (must fill in an existing user group)'
|
||||
|
||||
Params groups help text:
|
||||
zh: '请输入用户组,多个用户组使用逗号分隔(需填写已存在的用户组)'
|
||||
ja: 'グループを入力してください。複数のグループはコンマで区切ってください(既存のグループを入力してください)'
|
||||
@@ -86,6 +97,11 @@ i18n:
|
||||
ja: 'グループ'
|
||||
en: 'Groups'
|
||||
|
||||
Params group label:
|
||||
zh: '主组'
|
||||
ja: '主组'
|
||||
en: 'Main group'
|
||||
|
||||
Params uid label:
|
||||
zh: '用户ID'
|
||||
ja: 'ユーザーID'
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
uid: "{{ params.uid | int if params.uid | length > 0 else omit }}"
|
||||
shell: "{{ params.shell if params.shell | length > 0 else omit }}"
|
||||
home: "{{ params.home if params.home | length > 0 else '/home/' + account.username }}"
|
||||
group: "{{ params.group if params.group | length > 0 else omit }}"
|
||||
groups: "{{ params.groups if params.groups | length > 0 else omit }}"
|
||||
append: "{{ true if params.groups | length > 0 else false }}"
|
||||
expires: -1
|
||||
|
||||
@@ -30,6 +30,12 @@ params:
|
||||
default: ''
|
||||
help_text: "{{ 'Params home help text' | trans }}"
|
||||
|
||||
- name: group
|
||||
type: str
|
||||
label: "{{ 'Params group label' | trans }}"
|
||||
default: ''
|
||||
help_text: "{{ 'Params group help text' | trans }}"
|
||||
|
||||
- name: groups
|
||||
type: str
|
||||
label: "{{ 'Params groups label' | trans }}"
|
||||
@@ -63,6 +69,11 @@ i18n:
|
||||
ja: 'デフォルトのホームディレクトリ /home/{アカウントユーザ名}'
|
||||
en: 'Default home directory /home/{account username}'
|
||||
|
||||
Params group help text:
|
||||
zh: '请输入用户组(名字或数字),只能输入一个(需填写已存在的用户组)'
|
||||
ja: 'ユーザー グループ (名前または番号) を入力してください。入力できるのは 1 つだけです (既存のユーザー グループを入力する必要があります)'
|
||||
en: 'Please enter a user group (name or number), only one can be entered (must fill in an existing user group)'
|
||||
|
||||
Params groups help text:
|
||||
zh: '请输入用户组,多个用户组使用逗号分隔(需填写已存在的用户组)'
|
||||
ja: 'グループを入力してください。複数のグループはコンマで区切ってください(既存のグループを入力してください)'
|
||||
@@ -88,6 +99,11 @@ i18n:
|
||||
ja: 'グループ'
|
||||
en: 'Groups'
|
||||
|
||||
Params group label:
|
||||
zh: '主组'
|
||||
ja: '主组'
|
||||
en: 'Main group'
|
||||
|
||||
Params uid label:
|
||||
zh: '用户ID'
|
||||
ja: 'ユーザーID'
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
uid: "{{ params.uid | int if params.uid | length > 0 else omit }}"
|
||||
shell: "{{ params.shell if params.shell | length > 0 else omit }}"
|
||||
home: "{{ params.home if params.home | length > 0 else '/home/' + account.username }}"
|
||||
group: "{{ params.group if params.group | length > 0 else omit }}"
|
||||
groups: "{{ params.groups if params.groups | length > 0 else omit }}"
|
||||
append: "{{ true if params.groups | length > 0 else false }}"
|
||||
expires: -1
|
||||
|
||||
@@ -28,6 +28,12 @@ params:
|
||||
default: ''
|
||||
help_text: "{{ 'Params home help text' | trans }}"
|
||||
|
||||
- name: group
|
||||
type: str
|
||||
label: "{{ 'Params group label' | trans }}"
|
||||
default: ''
|
||||
help_text: "{{ 'Params group help text' | trans }}"
|
||||
|
||||
- name: groups
|
||||
type: str
|
||||
label: "{{ 'Params groups label' | trans }}"
|
||||
@@ -61,6 +67,11 @@ i18n:
|
||||
ja: 'デフォルトのホームディレクトリ /home/{アカウントユーザ名}'
|
||||
en: 'Default home directory /home/{account username}'
|
||||
|
||||
Params group help text:
|
||||
zh: '请输入用户组(名字或数字),只能输入一个(需填写已存在的用户组)'
|
||||
ja: 'ユーザー グループ (名前または番号) を入力してください。入力できるのは 1 つだけです (既存のユーザー グループを入力する必要があります)'
|
||||
en: 'Please enter a user group (name or number), only one can be entered (must fill in an existing user group)'
|
||||
|
||||
Params groups help text:
|
||||
zh: '请输入用户组,多个用户组使用逗号分隔(需填写已存在的用户组)'
|
||||
ja: 'グループを入力してください。複数のグループはコンマで区切ってください(既存のグループを入力してください)'
|
||||
@@ -86,6 +97,11 @@ i18n:
|
||||
ja: 'グループ'
|
||||
en: 'Groups'
|
||||
|
||||
Params group label:
|
||||
zh: '主组'
|
||||
ja: '主组'
|
||||
en: 'Main group'
|
||||
|
||||
Params uid label:
|
||||
zh: '用户ID'
|
||||
ja: 'ユーザーID'
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
uid: "{{ params.uid | int if params.uid | length > 0 else omit }}"
|
||||
shell: "{{ params.shell if params.shell | length > 0 else omit }}"
|
||||
home: "{{ params.home if params.home | length > 0 else '/home/' + account.username }}"
|
||||
group: "{{ params.group if params.group | length > 0 else omit }}"
|
||||
groups: "{{ params.groups if params.groups | length > 0 else omit }}"
|
||||
append: "{{ true if params.groups | length > 0 else false }}"
|
||||
expires: -1
|
||||
|
||||
@@ -30,6 +30,12 @@ params:
|
||||
default: ''
|
||||
help_text: "{{ 'Params home help text' | trans }}"
|
||||
|
||||
- name: group
|
||||
type: str
|
||||
label: "{{ 'Params group label' | trans }}"
|
||||
default: ''
|
||||
help_text: "{{ 'Params group help text' | trans }}"
|
||||
|
||||
- name: groups
|
||||
type: str
|
||||
label: "{{ 'Params groups label' | trans }}"
|
||||
@@ -63,6 +69,11 @@ i18n:
|
||||
ja: 'デフォルトのホームディレクトリ /home/{アカウントユーザ名}'
|
||||
en: 'Default home directory /home/{account username}'
|
||||
|
||||
Params group help text:
|
||||
zh: '请输入用户组(名字或数字),只能输入一个(需填写已存在的用户组)'
|
||||
ja: 'ユーザー グループ (名前または番号) を入力してください。入力できるのは 1 つだけです (既存のユーザー グループを入力する必要があります)'
|
||||
en: 'Please enter a user group (name or number), only one can be entered (must fill in an existing user group)'
|
||||
|
||||
Params groups help text:
|
||||
zh: '请输入用户组,多个用户组使用逗号分隔(需填写已存在的用户组)'
|
||||
ja: 'グループを入力してください。複数のグループはコンマで区切ってください(既存のグループを入力してください)'
|
||||
@@ -84,9 +95,14 @@ i18n:
|
||||
en: 'Home'
|
||||
|
||||
Params groups label:
|
||||
zh: '用户组'
|
||||
ja: 'グループ'
|
||||
en: 'Groups'
|
||||
zh: '附加组'
|
||||
ja: '追加グループ'
|
||||
en: 'Additional Group'
|
||||
|
||||
Params group label:
|
||||
zh: '主组'
|
||||
ja: '主组'
|
||||
en: 'Main group'
|
||||
|
||||
Params uid label:
|
||||
zh: '用户ID'
|
||||
|
||||
@@ -234,7 +234,7 @@ class AutomationExecutionFilterSet(DaysExecutionFilterMixin, BaseFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = AutomationExecution
|
||||
fields = ["days", 'trigger', 'automation__name']
|
||||
fields = ["id", "days", 'trigger', 'automation__name']
|
||||
|
||||
|
||||
class PushAccountRecordFilterSet(SecretRecordMixin, UUIDFilterMixin, BaseFilterSet):
|
||||
|
||||
@@ -81,7 +81,9 @@ class VaultModelMixin(models.Model):
|
||||
def mark_secret_save_to_vault(self):
|
||||
self._secret = self._secret_save_to_vault_mark
|
||||
self.skip_history_when_saving = True
|
||||
self.save()
|
||||
# Avoid calling overridden `save()` on concrete models (e.g. AccountTemplate)
|
||||
# which may mutate `secret/_secret` again and cause post_save recursion.
|
||||
super(VaultModelMixin, self).save(update_fields=['_secret'])
|
||||
|
||||
@property
|
||||
def secret_has_save_to_vault(self):
|
||||
|
||||
@@ -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, SecretReadableCheckMixin, 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,17 +462,23 @@ 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'])
|
||||
return results
|
||||
|
||||
|
||||
class AccountSecretSerializer(SecretReadableMixin, AccountSerializer):
|
||||
class AccountSecretSerializer(SecretReadableCheckMixin, SecretReadableMixin, AccountSerializer):
|
||||
spec_info = serializers.DictField(label=_('Spec info'), read_only=True)
|
||||
|
||||
class Meta(AccountSerializer.Meta):
|
||||
@@ -485,9 +491,10 @@ class AccountSecretSerializer(SecretReadableMixin, AccountSerializer):
|
||||
exclude_backup_fields = [
|
||||
'passphrase', 'push_now', 'params', 'spec_info'
|
||||
]
|
||||
secret_fields = ['secret']
|
||||
|
||||
|
||||
class AccountHistorySerializer(serializers.ModelSerializer):
|
||||
class AccountHistorySerializer(SecretReadableCheckMixin, serializers.ModelSerializer):
|
||||
secret_type = LabeledChoiceField(choices=SecretType.choices, label=_('Secret type'))
|
||||
secret = serializers.CharField(label=_('Secret'), read_only=True)
|
||||
id = serializers.IntegerField(label=_('ID'), source='history_id', read_only=True)
|
||||
@@ -503,6 +510,7 @@ class AccountHistorySerializer(serializers.ModelSerializer):
|
||||
'history_user': {'label': _('User')},
|
||||
'history_date': {'label': _('Date')},
|
||||
}
|
||||
secret_fields = ['secret']
|
||||
|
||||
|
||||
class AccountTaskSerializer(serializers.Serializer):
|
||||
|
||||
@@ -2,7 +2,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework import serializers
|
||||
|
||||
from accounts.models import AccountTemplate
|
||||
from common.serializers import SecretReadableMixin
|
||||
from common.serializers import SecretReadableMixin, SecretReadableCheckMixin
|
||||
from common.serializers.fields import ObjectRelatedField
|
||||
from .base import BaseAccountSerializer
|
||||
|
||||
@@ -62,10 +62,11 @@ class AccountDetailTemplateSerializer(AccountTemplateSerializer):
|
||||
fields = AccountTemplateSerializer.Meta.fields + ['spec_info']
|
||||
|
||||
|
||||
class AccountTemplateSecretSerializer(SecretReadableMixin, AccountDetailTemplateSerializer):
|
||||
class AccountTemplateSecretSerializer(SecretReadableCheckMixin, SecretReadableMixin, AccountDetailTemplateSerializer):
|
||||
class Meta(AccountDetailTemplateSerializer.Meta):
|
||||
fields = AccountDetailTemplateSerializer.Meta.fields
|
||||
extra_kwargs = {
|
||||
**AccountDetailTemplateSerializer.Meta.extra_kwargs,
|
||||
'secret': {'write_only': False},
|
||||
}
|
||||
secret_fields = ['secret']
|
||||
|
||||
@@ -79,7 +79,7 @@ class VaultSignalHandler(object):
|
||||
else:
|
||||
vault_client.update(instance)
|
||||
except Exception as e:
|
||||
logger.error('Vault save failed: {}'.format(e))
|
||||
logger.exception('Vault save failed: %s', e)
|
||||
raise VaultException()
|
||||
|
||||
@staticmethod
|
||||
@@ -87,7 +87,7 @@ class VaultSignalHandler(object):
|
||||
try:
|
||||
vault_client.delete(instance)
|
||||
except Exception as e:
|
||||
logger.error('Vault delete failed: {}'.format(e))
|
||||
logger.exception('Vault delete failed: %s', e)
|
||||
raise VaultException()
|
||||
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from accounts.backends import vault_client
|
||||
from accounts.const import VaultTypeChoices
|
||||
from accounts.models import Account, AccountTemplate
|
||||
from accounts.models import AccountTemplate, Account
|
||||
from common.utils import get_logger
|
||||
from orgs.utils import tmp_to_root_org
|
||||
|
||||
|
||||
@@ -3,3 +3,4 @@ from .connect_method import *
|
||||
from .login_acl import *
|
||||
from .login_asset_acl import *
|
||||
from .login_asset_check import *
|
||||
from .data_masking import *
|
||||
20
apps/acls/api/data_masking.py
Normal file
20
apps/acls/api/data_masking.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from orgs.mixins.api import OrgBulkModelViewSet
|
||||
|
||||
from .common import ACLUserFilterMixin
|
||||
from ..models import DataMaskingRule
|
||||
from .. import serializers
|
||||
|
||||
__all__ = ['DataMaskingRuleViewSet']
|
||||
|
||||
|
||||
class DataMaskingRuleFilter(ACLUserFilterMixin):
|
||||
class Meta:
|
||||
model = DataMaskingRule
|
||||
fields = ('name',)
|
||||
|
||||
|
||||
class DataMaskingRuleViewSet(OrgBulkModelViewSet):
|
||||
model = DataMaskingRule
|
||||
filterset_class = DataMaskingRuleFilter
|
||||
search_fields = ('name',)
|
||||
serializer_class = serializers.DataMaskingRuleSerializer
|
||||
@@ -8,7 +8,7 @@ __all__ = ['LoginAssetACLViewSet']
|
||||
class LoginAssetACLFilter(ACLUserAssetFilterMixin):
|
||||
class Meta:
|
||||
model = models.LoginAssetACL
|
||||
fields = ['name', ]
|
||||
fields = ['name', 'action']
|
||||
|
||||
|
||||
class LoginAssetACLViewSet(OrgBulkModelViewSet):
|
||||
|
||||
45
apps/acls/migrations/0003_datamaskingrule.py
Normal file
45
apps/acls/migrations/0003_datamaskingrule.py
Normal file
@@ -0,0 +1,45 @@
|
||||
# Generated by Django 4.1.13 on 2025-10-07 16:16
|
||||
|
||||
import common.db.fields
|
||||
from django.conf import settings
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('acls', '0002_auto_20210926_1047'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='DataMaskingRule',
|
||||
fields=[
|
||||
('created_by', models.CharField(blank=True, max_length=128, null=True, verbose_name='Created by')),
|
||||
('updated_by', models.CharField(blank=True, max_length=128, null=True, verbose_name='Updated by')),
|
||||
('date_created', models.DateTimeField(auto_now_add=True, null=True, verbose_name='Date created')),
|
||||
('date_updated', models.DateTimeField(auto_now=True, verbose_name='Date updated')),
|
||||
('comment', models.TextField(blank=True, default='', verbose_name='Comment')),
|
||||
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
|
||||
('org_id', models.CharField(blank=True, db_index=True, default='', max_length=36, verbose_name='Organization')),
|
||||
('priority', models.IntegerField(default=50, help_text='1-100, the lower the value will be match first', validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100)], verbose_name='Priority')),
|
||||
('action', models.CharField(default='reject', max_length=64, verbose_name='Action')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='Active')),
|
||||
('users', common.db.fields.JSONManyToManyField(default=dict, to='users.User', verbose_name='Users')),
|
||||
('assets', common.db.fields.JSONManyToManyField(default=dict, to='assets.Asset', verbose_name='Assets')),
|
||||
('accounts', models.JSONField(default=list, verbose_name='Accounts')),
|
||||
('name', models.CharField(max_length=128, verbose_name='Name')),
|
||||
('fields_pattern', models.CharField(default='password', max_length=128, verbose_name='Fields pattern')),
|
||||
('masking_method', models.CharField(choices=[('fixed_char', 'Fixed Character Replacement'), ('hide_middle', 'Hide Middle Characters'), ('keep_prefix', 'Keep Prefix Only'), ('keep_suffix', 'Keep Suffix Only')], default='fixed_char', max_length=32, verbose_name='Masking Method')),
|
||||
('mask_pattern', models.CharField(blank=True, default='######', max_length=128, null=True, verbose_name='Mask Pattern')),
|
||||
('reviewers', models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL, verbose_name='Reviewers')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Data Masking Rule',
|
||||
'unique_together': {('org_id', 'name')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -2,3 +2,4 @@ from .command_acl import *
|
||||
from .connect_method import *
|
||||
from .login_acl import *
|
||||
from .login_asset_acl import *
|
||||
from .data_masking import *
|
||||
42
apps/acls/models/data_masking.py
Normal file
42
apps/acls/models/data_masking.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from django.db import models
|
||||
|
||||
from acls.models import UserAssetAccountBaseACL
|
||||
from common.utils import get_logger
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
||||
__all__ = ['MaskingMethod', 'DataMaskingRule']
|
||||
|
||||
|
||||
class MaskingMethod(models.TextChoices):
|
||||
fixed_char = "fixed_char", _("Fixed Character Replacement") # 固定字符替换
|
||||
hide_middle = "hide_middle", _("Hide Middle Characters") # 隐藏中间几位
|
||||
keep_prefix = "keep_prefix", _("Keep Prefix Only") # 只保留前缀
|
||||
keep_suffix = "keep_suffix", _("Keep Suffix Only") # 只保留后缀
|
||||
|
||||
|
||||
class DataMaskingRule(UserAssetAccountBaseACL):
|
||||
name = models.CharField(max_length=128, verbose_name=_("Name"))
|
||||
fields_pattern = models.CharField(max_length=128, default='password', verbose_name=_("Fields pattern"))
|
||||
|
||||
masking_method = models.CharField(
|
||||
max_length=32,
|
||||
choices=MaskingMethod.choices,
|
||||
default=MaskingMethod.fixed_char,
|
||||
verbose_name=_("Masking Method"),
|
||||
)
|
||||
mask_pattern = models.CharField(
|
||||
max_length=128,
|
||||
verbose_name=_("Mask Pattern"),
|
||||
default="######",
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class Meta:
|
||||
unique_together = [('org_id', 'name')]
|
||||
verbose_name = _("Data Masking Rule")
|
||||
@@ -1,30 +1,52 @@
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from accounts.models import Account
|
||||
from acls.models import LoginACL, LoginAssetACL
|
||||
from assets.models import Asset
|
||||
from audits.models import UserLoginLog
|
||||
from common.views.template import custom_render_to_string
|
||||
from notifications.notifications import UserMessage
|
||||
from users.models import User
|
||||
|
||||
|
||||
class UserLoginReminderMsg(UserMessage):
|
||||
subject = _('User login reminder')
|
||||
template_name = 'acls/user_login_reminder.html'
|
||||
contexts = [
|
||||
{"name": "city", "label": _('Login city'), "default": "Shanghai"},
|
||||
{"name": "username", "label": _('User'), "default": "john"},
|
||||
{"name": "ip", "label": "IP", "default": "192.168.1.1"},
|
||||
{"name": "recipient_name", "label": _("Recipient name"), "default": "John"},
|
||||
{"name": "recipient_username", "label": _("Recipient username"), "default": "john"},
|
||||
{"name": "user_agent", "label": _('User agent'), "default": "Mozilla/5.0"},
|
||||
{"name": "acl_name", "label": _('ACL name'), "default": "login acl"},
|
||||
{"name": "login_from", "label": _('Login from'), "default": "web"},
|
||||
{"name": "time", "label": _('Login time'), "default": "2025-01-01 12:00:00"},
|
||||
]
|
||||
|
||||
def __init__(self, user, user_log: UserLoginLog):
|
||||
def __init__(self, user, user_log: UserLoginLog, acl: LoginACL):
|
||||
self.user_log = user_log
|
||||
self.acl_name = str(acl)
|
||||
self.login_from = user_log.get_type_display()
|
||||
now = timezone.localtime(user_log.datetime)
|
||||
self.time = now.strftime('%Y-%m-%d %H:%M:%S')
|
||||
super().__init__(user)
|
||||
|
||||
def get_html_msg(self) -> dict:
|
||||
user_log = self.user_log
|
||||
context = {
|
||||
'ip': user_log.ip,
|
||||
'time': self.time,
|
||||
'city': user_log.city,
|
||||
'acl_name': self.acl_name,
|
||||
'login_from': self.login_from,
|
||||
'username': user_log.username,
|
||||
'recipient': self.user,
|
||||
'recipient_name': self.user.name,
|
||||
'recipient_username': self.user.username,
|
||||
'user_agent': user_log.user_agent,
|
||||
}
|
||||
message = render_to_string('acls/user_login_reminder.html', context)
|
||||
message = custom_render_to_string(self.template_name, context)
|
||||
|
||||
return {
|
||||
'subject': str(self.subject),
|
||||
@@ -40,24 +62,55 @@ class UserLoginReminderMsg(UserMessage):
|
||||
|
||||
class AssetLoginReminderMsg(UserMessage):
|
||||
subject = _('User login alert for asset')
|
||||
template_name = 'acls/asset_login_reminder.html'
|
||||
contexts = [
|
||||
{"name": "city", "label": _('Login city'), "default": "Shanghai"},
|
||||
{"name": "username", "label": _('User'), "default": "john"},
|
||||
{"name": "name", "label": _('Name'), "default": "John"},
|
||||
{"name": "asset", "label": _('Asset'), "default": "dev server"},
|
||||
{"name": "recipient_name", "label": _('Recipient name'), "default": "John"},
|
||||
{"name": "recipient_username", "label": _('Recipient username'), "default": "john"},
|
||||
{"name": "account", "label": _('Account Input username'), "default": "root"},
|
||||
{"name": "account_name", "label": _('Account name'), "default": "root"},
|
||||
{"name": "acl_name", "label": _('ACL name'), "default": "login acl"},
|
||||
{"name": "ip", "label": "IP", "default": "192.168.1.1"},
|
||||
{"name": "login_from", "label": _('Login from'), "default": "web"},
|
||||
{"name": "time", "label": _('Login time'), "default": "2025-01-01 12:00:00"}
|
||||
]
|
||||
|
||||
def __init__(self, user, asset: Asset, login_user: User, account: Account, input_username):
|
||||
def __init__(
|
||||
self, user, asset: Asset, login_user: User,
|
||||
account: Account, acl: LoginAssetACL,
|
||||
ip, input_username, login_from
|
||||
):
|
||||
self.ip = ip
|
||||
self.asset = asset
|
||||
self.login_user = login_user
|
||||
self.account = account
|
||||
self.acl_name = str(acl)
|
||||
self.login_from = login_from
|
||||
self.login_user = login_user
|
||||
self.input_username = input_username
|
||||
|
||||
now = timezone.localtime(timezone.now())
|
||||
self.time = now.strftime('%Y-%m-%d %H:%M:%S')
|
||||
super().__init__(user)
|
||||
|
||||
def get_html_msg(self) -> dict:
|
||||
context = {
|
||||
'recipient': self.user,
|
||||
'ip': self.ip,
|
||||
'time': self.time,
|
||||
'login_from': self.login_from,
|
||||
'recipient_name': self.user.name,
|
||||
'recipient_username': self.user.username,
|
||||
'username': self.login_user.username,
|
||||
'name': self.login_user.name,
|
||||
'asset': str(self.asset),
|
||||
'account': self.input_username,
|
||||
'account_name': self.account.name,
|
||||
'acl_name': self.acl_name,
|
||||
}
|
||||
message = render_to_string('acls/asset_login_reminder.html', context)
|
||||
message = custom_render_to_string(self.template_name, context)
|
||||
|
||||
return {
|
||||
'subject': str(self.subject),
|
||||
|
||||
@@ -3,3 +3,4 @@ from .connect_method import *
|
||||
from .login_acl import *
|
||||
from .login_asset_acl import *
|
||||
from .login_asset_check import *
|
||||
from .data_masking import *
|
||||
@@ -90,7 +90,7 @@ class BaseACLSerializer(ActionAclSerializer, serializers.Serializer):
|
||||
fields_small = fields_mini + [
|
||||
"is_active", "priority", "action",
|
||||
"date_created", "date_updated",
|
||||
"comment", "created_by", "org_id",
|
||||
"comment", "created_by"
|
||||
]
|
||||
fields_m2m = ["reviewers", ]
|
||||
fields = fields_small + fields_m2m
|
||||
@@ -100,6 +100,20 @@ class BaseACLSerializer(ActionAclSerializer, serializers.Serializer):
|
||||
'reviewers': {'label': _('Recipients')},
|
||||
}
|
||||
|
||||
class BaseUserACLSerializer(BaseACLSerializer):
|
||||
users = JSONManyToManyField(label=_('User'))
|
||||
|
||||
class Meta(BaseACLSerializer.Meta):
|
||||
fields = BaseACLSerializer.Meta.fields + ['users']
|
||||
|
||||
|
||||
class BaseUserAssetAccountACLSerializer(BaseUserACLSerializer):
|
||||
assets = JSONManyToManyField(label=_('Asset'))
|
||||
accounts = serializers.ListField(label=_('Account'))
|
||||
|
||||
class Meta(BaseUserACLSerializer.Meta):
|
||||
fields = BaseUserACLSerializer.Meta.fields + ['assets', 'accounts', 'org_id']
|
||||
|
||||
def validate_reviewers(self, reviewers):
|
||||
action = self.initial_data.get('action')
|
||||
if not action and self.instance:
|
||||
@@ -118,19 +132,4 @@ class BaseACLSerializer(ActionAclSerializer, serializers.Serializer):
|
||||
"None of the reviewers belong to Organization `{}`".format(org.name)
|
||||
)
|
||||
raise serializers.ValidationError(error)
|
||||
return valid_reviewers
|
||||
|
||||
|
||||
class BaseUserACLSerializer(BaseACLSerializer):
|
||||
users = JSONManyToManyField(label=_('User'))
|
||||
|
||||
class Meta(BaseACLSerializer.Meta):
|
||||
fields = BaseACLSerializer.Meta.fields + ['users']
|
||||
|
||||
|
||||
class BaseUserAssetAccountACLSerializer(BaseUserACLSerializer):
|
||||
assets = JSONManyToManyField(label=_('Asset'))
|
||||
accounts = serializers.ListField(label=_('Account'))
|
||||
|
||||
class Meta(BaseUserACLSerializer.Meta):
|
||||
fields = BaseUserACLSerializer.Meta.fields + ['assets', 'accounts']
|
||||
return valid_reviewers
|
||||
19
apps/acls/serializers/data_masking.py
Normal file
19
apps/acls/serializers/data_masking.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from acls.models import MaskingMethod, DataMaskingRule
|
||||
from common.serializers.fields import LabeledChoiceField
|
||||
from common.serializers.mixin import CommonBulkModelSerializer
|
||||
from orgs.mixins.serializers import BulkOrgResourceModelSerializer
|
||||
from .base import BaseUserAssetAccountACLSerializer as BaseSerializer
|
||||
|
||||
__all__ = ['DataMaskingRuleSerializer']
|
||||
|
||||
|
||||
class DataMaskingRuleSerializer(BaseSerializer, BulkOrgResourceModelSerializer):
|
||||
masking_method = LabeledChoiceField(
|
||||
choices=MaskingMethod.choices, default=MaskingMethod.fixed_char, label=_('Masking Method')
|
||||
)
|
||||
|
||||
class Meta(BaseSerializer.Meta):
|
||||
model = DataMaskingRule
|
||||
fields = BaseSerializer.Meta.fields + ['fields_pattern', 'masking_method', 'mask_pattern']
|
||||
@@ -17,7 +17,7 @@ class LoginACLSerializer(BaseUserACLSerializer, CommonBulkModelSerializer):
|
||||
|
||||
class Meta(BaseUserACLSerializer.Meta):
|
||||
model = LoginACL
|
||||
fields = list((set(BaseUserACLSerializer.Meta.fields) | {'rules'}) - {'org_id'})
|
||||
fields = list((set(BaseUserACLSerializer.Meta.fields) | {'rules'}))
|
||||
action_choices_exclude = [
|
||||
ActionChoices.warning,
|
||||
ActionChoices.notify_and_warn,
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
{% load i18n %}
|
||||
|
||||
<h3>{% trans 'Dear' %}: {{ recipient.name }}[{{ recipient.username }}]</h3>
|
||||
<h3>{% trans 'Dear' %}: {{ recipient_name }}[{{ recipient_username }}]</h3>
|
||||
<hr>
|
||||
<p>{% trans 'We would like to inform you that a user has recently logged into the following asset:' %}<p>
|
||||
<p><strong>{% trans 'Asset details' %}:</strong></p>
|
||||
<ul>
|
||||
<li><strong>{% trans 'User' %}:</strong> [{{ name }}({{ username }})]</li>
|
||||
<li><strong>IP:</strong> [{{ ip }}]</li>
|
||||
<li><strong>{% trans 'Assets' %}:</strong> [{{ asset }}]</li>
|
||||
<li><strong>{% trans 'Account' %}:</strong> [{{ account_name }}({{ account }})]</li>
|
||||
<li><strong>{% trans 'Login asset acl' %}:</strong> [{{ acl_name }}]</li>
|
||||
<li><strong>{% trans 'Login from' %}:</strong> [{{ login_from }}]</li>
|
||||
<li><strong>{% trans 'Time' %}:</strong> [{{ time }}]</li>
|
||||
</ul>
|
||||
<hr>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% load i18n %}
|
||||
|
||||
<h3>{% trans 'Dear' %}: {{ recipient.name }}[{{ recipient.username }}]</h3>
|
||||
<h3>{% trans 'Dear' %}: {{ recipient_name }}[{{ recipient_username }}]</h3>
|
||||
<hr>
|
||||
<p>{% trans 'We would like to inform you that a user has recently logged:' %}<p>
|
||||
<p><strong>{% trans 'User details' %}:</strong></p>
|
||||
@@ -8,7 +8,10 @@
|
||||
<li><strong>{% trans 'User' %}:</strong> [{{ username }}]</li>
|
||||
<li><strong>IP:</strong> [{{ ip }}]</li>
|
||||
<li><strong>{% trans 'Login city' %}:</strong> [{{ city }}]</li>
|
||||
<li><strong>{% trans 'Login from' %}:</strong> [{{ login_from }}]</li>
|
||||
<li><strong>{% trans 'User agent' %}:</strong> [{{ user_agent }}]</li>
|
||||
<li><strong>{% trans 'Login acl' %}:</strong> [{{ acl_name }}]</li>
|
||||
<li><strong>{% trans 'Time' %}:</strong> [{{ time }}]</li>
|
||||
</ul>
|
||||
<hr>
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ router.register(r'login-asset-acls', api.LoginAssetACLViewSet, 'login-asset-acl'
|
||||
router.register(r'command-filter-acls', api.CommandFilterACLViewSet, 'command-filter-acl')
|
||||
router.register(r'command-groups', api.CommandGroupViewSet, 'command-group')
|
||||
router.register(r'connect-method-acls', api.ConnectMethodACLViewSet, 'connect-method-acl')
|
||||
router.register(r'data-masking-rules', api.DataMaskingRuleViewSet, 'data-masking-rule')
|
||||
|
||||
urlpatterns = [
|
||||
path('login-asset/check/', api.LoginAssetCheckAPI.as_view(), name='login-asset-check'),
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -16,7 +16,6 @@ class CategoryViewSet(ListModelMixin, JMSGenericViewSet):
|
||||
'types': TypeSerializer,
|
||||
}
|
||||
permission_classes = (IsValidUser,)
|
||||
default_limit = None
|
||||
|
||||
def get_queryset(self):
|
||||
return AllTypes.categories()
|
||||
|
||||
@@ -14,7 +14,7 @@ class FavoriteAssetViewSet(BulkModelViewSet):
|
||||
serializer_class = FavoriteAssetSerializer
|
||||
permission_classes = (IsValidUser,)
|
||||
filterset_fields = ['asset']
|
||||
default_limit = None
|
||||
page_no_limit = True
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
with tmp_to_root_org():
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ class AssetPlatformViewSet(JMSModelViewSet):
|
||||
'ops_methods': 'assets.view_platform',
|
||||
'filter_nodes_assets': 'assets.view_platform',
|
||||
}
|
||||
default_limit = None
|
||||
page_no_limit = True
|
||||
|
||||
def get_queryset(self):
|
||||
# 因为没有走分页逻辑,所以需要这里 prefetch
|
||||
@@ -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():
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
from rest_framework.generics import ListAPIView
|
||||
|
||||
from assets import serializers
|
||||
from assets.const import Protocol
|
||||
from common.permissions import IsValidUser
|
||||
from assets.models import Protocol
|
||||
|
||||
__all__ = ['ProtocolListApi']
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from orgs.models import Organization
|
||||
from .base import BaseType
|
||||
|
||||
|
||||
@@ -52,3 +53,41 @@ class GPTTypes(BaseType):
|
||||
return [
|
||||
cls.CHATGPT,
|
||||
]
|
||||
|
||||
|
||||
CHATX_NAME = 'ChatX'
|
||||
|
||||
|
||||
def create_or_update_chatx_resources(chatx_name=CHATX_NAME, org_id=Organization.SYSTEM_ID):
|
||||
from django.apps import apps
|
||||
|
||||
platform_model = apps.get_model('assets', 'Platform')
|
||||
asset_model = apps.get_model('assets', 'Asset')
|
||||
account_model = apps.get_model('accounts', 'Account')
|
||||
|
||||
platform, __ = platform_model.objects.get_or_create(
|
||||
name=chatx_name,
|
||||
defaults={
|
||||
'internal': True,
|
||||
'type': chatx_name,
|
||||
'category': 'ai',
|
||||
}
|
||||
)
|
||||
asset, __ = asset_model.objects.get_or_create(
|
||||
address=chatx_name,
|
||||
defaults={
|
||||
'name': chatx_name,
|
||||
'platform': platform,
|
||||
'org_id': org_id
|
||||
}
|
||||
)
|
||||
|
||||
account, __ = account_model.objects.get_or_create(
|
||||
username=chatx_name,
|
||||
defaults={
|
||||
'name': chatx_name,
|
||||
'asset': asset,
|
||||
'org_id': org_id
|
||||
}
|
||||
)
|
||||
return asset, account
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -59,7 +59,10 @@ class DatabaseSerializer(AssetSerializer):
|
||||
if not platform:
|
||||
return
|
||||
|
||||
if platform.type in ['mysql', 'mariadb']:
|
||||
if platform.type in [
|
||||
'mysql', 'mariadb', 'oracle', 'sqlserver',
|
||||
'db2', 'dameng', 'clickhouse', 'redis'
|
||||
]:
|
||||
db_field.required = False
|
||||
db_field.allow_blank = True
|
||||
db_field.allow_null = True
|
||||
|
||||
@@ -26,4 +26,13 @@ class WebSerializer(AssetSerializer):
|
||||
'submit_selector': {
|
||||
'default': 'id=login_button',
|
||||
},
|
||||
'script': {
|
||||
'default': [],
|
||||
}
|
||||
}
|
||||
|
||||
def to_internal_value(self, data):
|
||||
data = data.copy()
|
||||
if data.get('script') in ("", None):
|
||||
data.pop('script', None)
|
||||
return super().to_internal_value(data)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -116,7 +116,7 @@ def send_login_info_to_reviewers(instance: UserLoginLog | str, auth_acl_id):
|
||||
|
||||
reviewers = acl.reviewers.all()
|
||||
for reviewer in reviewers:
|
||||
UserLoginReminderMsg(reviewer, instance).publish_async()
|
||||
UserLoginReminderMsg(reviewer, instance, acl).publish_async()
|
||||
|
||||
|
||||
@receiver(post_auth_success)
|
||||
|
||||
@@ -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)
|
||||
@@ -69,6 +69,8 @@ class RDPFileClientProtocolURLMixin:
|
||||
'autoreconnection enabled:i': '1',
|
||||
'bookmarktype:i': '3',
|
||||
'use redirection server name:i': '0',
|
||||
'bitmapcachepersistenable:i': '0',
|
||||
'bitmapcachesize:i': '1500',
|
||||
}
|
||||
|
||||
# copy from
|
||||
@@ -76,7 +78,6 @@ class RDPFileClientProtocolURLMixin:
|
||||
rdp_low_speed_broadband_option = {
|
||||
"connection type:i": 2,
|
||||
"disable wallpaper:i": 1,
|
||||
"bitmapcachepersistenable:i": 1,
|
||||
"disable full window drag:i": 1,
|
||||
"disable menu anims:i": 1,
|
||||
"allow font smoothing:i": 0,
|
||||
@@ -87,7 +88,6 @@ class RDPFileClientProtocolURLMixin:
|
||||
rdp_high_speed_broadband_option = {
|
||||
"connection type:i": 4,
|
||||
"disable wallpaper:i": 0,
|
||||
"bitmapcachepersistenable:i": 1,
|
||||
"disable full window drag:i": 1,
|
||||
"disable menu anims:i": 0,
|
||||
"allow font smoothing:i": 0,
|
||||
@@ -219,8 +219,18 @@ class RDPFileClientProtocolURLMixin:
|
||||
}
|
||||
})
|
||||
else:
|
||||
if connect_method_dict['type'] == 'virtual_app':
|
||||
endpoint_protocol = 'vnc'
|
||||
token_protocol = 'vnc'
|
||||
data.update({
|
||||
'protocol': 'vnc',
|
||||
})
|
||||
else:
|
||||
endpoint_protocol = connect_method_dict['endpoint_protocol']
|
||||
token_protocol = token.protocol
|
||||
|
||||
endpoint = self.get_smart_endpoint(
|
||||
protocol=connect_method_dict['endpoint_protocol'],
|
||||
protocol=endpoint_protocol,
|
||||
asset=asset
|
||||
)
|
||||
data.update({
|
||||
@@ -236,7 +246,7 @@ class RDPFileClientProtocolURLMixin:
|
||||
},
|
||||
'endpoint': {
|
||||
'host': endpoint.host,
|
||||
'port': endpoint.get_port(token.asset, token.protocol),
|
||||
'port': endpoint.get_port(token.asset, token_protocol),
|
||||
}
|
||||
})
|
||||
return data
|
||||
@@ -362,6 +372,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 = {
|
||||
@@ -375,7 +386,7 @@ class ConnectionTokenViewSet(AuthFaceMixin, ExtraActionApiMixin, RootOrgViewMixi
|
||||
for name in default_name_opts.keys():
|
||||
value = preferences.get(name, default_name_opts[name])
|
||||
connect_options[name] = value
|
||||
connect_options['lang'] = getattr(user, 'lang', settings.LANGUAGE_CODE)
|
||||
connect_options['lang'] = getattr(user, 'lang') or settings.LANGUAGE_CODE
|
||||
data['connect_options'] = connect_options
|
||||
|
||||
@staticmethod
|
||||
@@ -431,7 +442,7 @@ class ConnectionTokenViewSet(AuthFaceMixin, ExtraActionApiMixin, RootOrgViewMixi
|
||||
if account.username != AliasAccount.INPUT:
|
||||
data['input_username'] = ''
|
||||
|
||||
ticket = self._validate_acl(user, asset, account, connect_method)
|
||||
ticket = self._validate_acl(user, asset, account, connect_method, protocol)
|
||||
if ticket:
|
||||
data['from_ticket'] = ticket
|
||||
|
||||
@@ -470,7 +481,7 @@ class ConnectionTokenViewSet(AuthFaceMixin, ExtraActionApiMixin, RootOrgViewMixi
|
||||
after=after, object_name=object_name
|
||||
)
|
||||
|
||||
def _validate_acl(self, user, asset, account, connect_method):
|
||||
def _validate_acl(self, user, asset, account, connect_method, protocol):
|
||||
from acls.models import LoginAssetACL
|
||||
kwargs = {'user': user, 'asset': asset, 'account': account}
|
||||
if account.username == AliasAccount.INPUT:
|
||||
@@ -523,9 +534,15 @@ class ConnectionTokenViewSet(AuthFaceMixin, ExtraActionApiMixin, RootOrgViewMixi
|
||||
return
|
||||
|
||||
self._record_operate_log(acl, asset)
|
||||
os = get_request_os(self.request) if self.request else 'windows'
|
||||
method = ConnectMethodUtil.get_connect_method(
|
||||
connect_method, protocol=protocol, os=os
|
||||
)
|
||||
login_from = method['label'] if method else connect_method
|
||||
for reviewer in reviewers:
|
||||
AssetLoginReminderMsg(
|
||||
reviewer, asset, user, account, self.input_username
|
||||
reviewer, asset, user, account, acl,
|
||||
ip, self.input_username, login_from
|
||||
).publish_async()
|
||||
|
||||
def create_face_verify(self, response):
|
||||
@@ -558,7 +575,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',
|
||||
@@ -566,7 +585,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')
|
||||
|
||||
@@ -67,8 +67,9 @@ class UserResetPasswordSendCodeApi(CreateAPIView):
|
||||
|
||||
code = random_string(settings.SMS_CODE_LENGTH, lower=False, upper=False)
|
||||
subject = '%s: %s' % (get_login_title(), _('Forgot password'))
|
||||
tip = _('The validity period of the verification code is {} minute').format(settings.VERIFY_CODE_TTL // 60)
|
||||
context = {
|
||||
'user': user, 'title': subject, 'code': code,
|
||||
'user': user, 'title': subject, 'code': code, 'tip': tip,
|
||||
}
|
||||
message = render_to_string('authentication/_msg_reset_password_code.html', context)
|
||||
content = {'subject': subject, 'message': message}
|
||||
|
||||
@@ -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
|
||||
@@ -25,7 +24,10 @@ class JMSBaseAuthBackend:
|
||||
"""
|
||||
# 三方用户认证完成后,在后续的 get_user 获取逻辑中,也应该需要检查用户是否有效
|
||||
is_valid = getattr(user, 'is_valid', None)
|
||||
return is_valid or is_valid is None
|
||||
if not is_valid:
|
||||
logger.info("User %s is not valid", getattr(user, "username", "<unknown>"))
|
||||
return False
|
||||
return True
|
||||
|
||||
# allow user to authenticate
|
||||
def username_allow_authenticate(self, username):
|
||||
@@ -63,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):
|
||||
@@ -110,7 +108,7 @@ class SessionAuthentication(authentication.SessionAuthentication):
|
||||
user = getattr(request._request, 'user', None)
|
||||
|
||||
# Unauthenticated, CSRF validation not required
|
||||
if not user or not user.is_active:
|
||||
if not user or not user.is_active or not user.is_valid:
|
||||
return None
|
||||
|
||||
try:
|
||||
@@ -136,7 +134,7 @@ class SignatureAuthentication(signature.SignatureAuthentication):
|
||||
# example implementation:
|
||||
try:
|
||||
key = AccessKey.objects.get(id=key_id)
|
||||
if not key.is_active:
|
||||
if not key.is_valid:
|
||||
return None, None
|
||||
user, secret = key.user, str(key.secret)
|
||||
after_authenticate_update_date(user, key)
|
||||
|
||||
@@ -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
|
||||
@@ -38,7 +38,7 @@ class BaseMFA(abc.ABC):
|
||||
if not ok:
|
||||
return False, msg
|
||||
|
||||
cache.set(cache_key, code, 60)
|
||||
cache.set(cache_key, code, settings.VERIFY_CODE_TTL)
|
||||
return True, msg
|
||||
|
||||
def is_authenticated(self):
|
||||
|
||||
@@ -39,13 +39,14 @@ class MFAEmail(BaseMFA):
|
||||
def send_challenge(self):
|
||||
code = random_string(settings.SMS_CODE_LENGTH, lower=False, upper=False)
|
||||
subject = '%s: %s' % (get_login_title(), _('MFA code'))
|
||||
tip = _('The validity period of the verification code is {} minute').format(settings.VERIFY_CODE_TTL // 60)
|
||||
context = {
|
||||
'user': self.user, 'title': subject, 'code': code,
|
||||
'user': self.user, 'title': subject, 'code': code, 'tip': tip,
|
||||
}
|
||||
message = render_to_string('authentication/_msg_mfa_email_code.html', context)
|
||||
content = {'subject': subject, 'message': message}
|
||||
sender_util = SendAndVerifyCodeUtil(
|
||||
self.user.email, code=code, backend=self.name, timeout=60, **content
|
||||
self.user.email, code=code, backend=self.name, **content
|
||||
)
|
||||
sender_util.gen_and_send_async()
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ class MFAMiddleware:
|
||||
# 这个是 mfa 登录页需要的请求, 也得放出来, 用户其实已经在 CAS/OIDC 中完成登录了
|
||||
white_urls = [
|
||||
'login/mfa', 'mfa/select', 'face/context', 'jsi18n/', '/static/',
|
||||
'/profile/otp', '/logout/',
|
||||
'/profile/otp', '/logout/', '/media/'
|
||||
]
|
||||
for url in white_urls:
|
||||
if request.path.find(url) > -1:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -25,6 +25,10 @@ class AccessKey(models.Model):
|
||||
date_last_used = models.DateTimeField(null=True, blank=True, verbose_name=_('Date last used'))
|
||||
date_created = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
@property
|
||||
def is_valid(self):
|
||||
return self.is_active and self.user.is_valid
|
||||
|
||||
def get_id(self):
|
||||
return str(self.id)
|
||||
|
||||
|
||||
@@ -338,6 +338,18 @@ class ConnectionToken(JMSOrgBaseModel):
|
||||
acls = CommandFilterACL.filter_queryset(**kwargs).valid()
|
||||
return acls
|
||||
|
||||
@lazyproperty
|
||||
def data_masking_rules(self):
|
||||
from acls.models import DataMaskingRule
|
||||
kwargs = {
|
||||
'user': self.user,
|
||||
'asset': self.asset,
|
||||
'account': self.account_object,
|
||||
}
|
||||
with tmp_to_org(self.asset.org_id):
|
||||
rules = DataMaskingRule.filter_queryset(**kwargs).valid()
|
||||
return rules
|
||||
|
||||
|
||||
class SuperConnectionToken(ConnectionToken):
|
||||
_type = ConnectionTokenType.SUPER
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.translation import gettext as _
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from common.utils import get_logger
|
||||
from common.utils.timezone import local_now_display
|
||||
from common.views.template import custom_render_to_string
|
||||
from notifications.notifications import UserMessage
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
||||
|
||||
class DifferentCityLoginMessage(UserMessage):
|
||||
subject = _('Different city login reminder')
|
||||
template_name = 'authentication/_msg_different_city.html'
|
||||
contexts = [
|
||||
{"name": "city", "label": _('Login city'), "default": "Shanghai"},
|
||||
{"name": "username", "label": _('User'), "default": "john"},
|
||||
{"name": "name", "label": _('Name'), "default": "John"},
|
||||
{"name": "ip", "label": "IP", "default": "192.168.1.1"},
|
||||
{"name": "time", "label": _('Login Date'), "default": "2025-01-01 12:00:00"},
|
||||
]
|
||||
|
||||
def __init__(self, user, ip, city):
|
||||
self.ip = ip
|
||||
self.city = city
|
||||
@@ -16,18 +26,16 @@ class DifferentCityLoginMessage(UserMessage):
|
||||
|
||||
def get_html_msg(self) -> dict:
|
||||
now = local_now_display()
|
||||
subject = _('Different city login reminder')
|
||||
context = dict(
|
||||
subject=subject,
|
||||
name=self.user.name,
|
||||
username=self.user.username,
|
||||
ip=self.ip,
|
||||
time=now,
|
||||
city=self.city,
|
||||
)
|
||||
message = render_to_string('authentication/_msg_different_city.html', context)
|
||||
message = custom_render_to_string(self.template_name, context)
|
||||
return {
|
||||
'subject': subject,
|
||||
'subject': str(self.subject),
|
||||
'message': message
|
||||
}
|
||||
|
||||
@@ -41,6 +49,16 @@ class DifferentCityLoginMessage(UserMessage):
|
||||
|
||||
|
||||
class OAuthBindMessage(UserMessage):
|
||||
subject = _('OAuth binding reminder')
|
||||
template_name = 'authentication/_msg_oauth_bind.html'
|
||||
contexts = [
|
||||
{"name": "username", "label": _('User'), "default": "john"},
|
||||
{"name": "name", "label": _('Name'), "default": "John"},
|
||||
{"name": "ip", "label": "IP", "default": "192.168.1.1"},
|
||||
{"name": "oauth_name", "label": _('OAuth name'), "default": "WeCom"},
|
||||
{"name": "oauth_id", "label": _('OAuth ID'), "default": "000001"},
|
||||
]
|
||||
|
||||
def __init__(self, user, ip, oauth_name, oauth_id):
|
||||
super().__init__(user)
|
||||
self.ip = ip
|
||||
@@ -51,7 +69,6 @@ class OAuthBindMessage(UserMessage):
|
||||
now = local_now_display()
|
||||
subject = self.oauth_name + ' ' + _('binding reminder')
|
||||
context = dict(
|
||||
subject=subject,
|
||||
name=self.user.name,
|
||||
username=self.user.username,
|
||||
ip=self.ip,
|
||||
@@ -59,9 +76,9 @@ class OAuthBindMessage(UserMessage):
|
||||
oauth_name=self.oauth_name,
|
||||
oauth_id=self.oauth_id
|
||||
)
|
||||
message = render_to_string('authentication/_msg_oauth_bind.html', context)
|
||||
message = custom_render_to_string(self.template_name, context)
|
||||
return {
|
||||
'subject': subject,
|
||||
'subject': str(subject),
|
||||
'message': message
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ from rest_framework import serializers
|
||||
|
||||
from accounts.const import SecretType
|
||||
from accounts.models import Account
|
||||
from acls.models import CommandGroup, CommandFilterACL
|
||||
from acls.models import CommandGroup, CommandFilterACL, DataMaskingRule
|
||||
from assets.models import Asset, Platform, Gateway, Zone
|
||||
from assets.serializers.asset import AssetProtocolsSerializer
|
||||
from assets.serializers.platform import PlatformSerializer
|
||||
@@ -83,6 +83,14 @@ class _ConnectionTokenGatewaySerializer(serializers.ModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class _ConnectionTokenDataMaskingRuleSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = DataMaskingRule
|
||||
fields = ['id', 'name', 'fields_pattern',
|
||||
'masking_method', 'mask_pattern',
|
||||
'is_active', 'priority']
|
||||
|
||||
|
||||
class _ConnectionTokenCommandFilterACLSerializer(serializers.ModelSerializer):
|
||||
command_groups = ObjectRelatedField(
|
||||
many=True, required=False, queryset=CommandGroup.objects,
|
||||
@@ -105,7 +113,7 @@ class _ConnectionTokenPlatformSerializer(PlatformSerializer):
|
||||
class Meta(PlatformSerializer.Meta):
|
||||
model = Platform
|
||||
fields = [field for field in PlatformSerializer.Meta.fields
|
||||
if field not in PlatformSerializer.Meta.fields_m2m]
|
||||
if field not in PlatformSerializer.Meta.fields_m2m]
|
||||
|
||||
def get_field_names(self, declared_fields, info):
|
||||
names = super().get_field_names(declared_fields, info)
|
||||
@@ -139,6 +147,7 @@ class ConnectionTokenSecretSerializer(OrgResourceModelSerializerMixin):
|
||||
platform = _ConnectionTokenPlatformSerializer(read_only=True)
|
||||
zone = ObjectRelatedField(queryset=Zone.objects, required=False, label=_('Domain'))
|
||||
command_filter_acls = _ConnectionTokenCommandFilterACLSerializer(read_only=True, many=True)
|
||||
data_masking_rules = _ConnectionTokenDataMaskingRuleSerializer(read_only=True, many=True)
|
||||
expire_now = serializers.BooleanField(label=_('Expired now'), write_only=True, default=True)
|
||||
connect_method = _ConnectTokenConnectMethodSerializer(read_only=True, source='connect_method_object')
|
||||
connect_options = serializers.JSONField(read_only=True)
|
||||
@@ -149,7 +158,7 @@ class ConnectionTokenSecretSerializer(OrgResourceModelSerializerMixin):
|
||||
model = ConnectionToken
|
||||
fields = [
|
||||
'id', 'value', 'user', 'asset', 'account',
|
||||
'platform', 'command_filter_acls', 'protocol',
|
||||
'platform', 'command_filter_acls', 'data_masking_rules', 'protocol',
|
||||
'zone', 'gateway', 'actions', 'expire_at',
|
||||
'from_ticket', 'expire_now', 'connect_method',
|
||||
'connect_options', 'face_monitor_token'
|
||||
|
||||
@@ -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()
|
||||
@@ -12,7 +12,7 @@
|
||||
<td style="height: 50px;">{% trans 'MFA code' %}: <span style="font-weight: bold;">{{ code }}</span></td>
|
||||
</tr>
|
||||
<tr style="border: 1px solid #eee">
|
||||
<td style="height: 30px;">{% trans 'The validity period of the verification code is one minute' %}</td>
|
||||
<td style="height: 30px;">{{ tip }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -11,8 +11,6 @@
|
||||
<b>{% trans 'Time' %}:</b> {{ time }}<br>
|
||||
<b>{% trans 'IP' %}:</b> {{ ip }}
|
||||
</p>
|
||||
|
||||
-
|
||||
<p>
|
||||
{% trans 'If the operation is not your own, unbind and change the password.' %}
|
||||
</p>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user