Compare commits

...

149 Commits
v4.4.1 ... v4.7

Author SHA1 Message Date
Bryan
4d37dca0de Merge pull request #14901 from jumpserver/dev
v4.7.0
2025-02-20 10:21:16 +08:00
Chenyang Shen
8730fa8dee Merge pull request #14900 from jumpserver/pr@dev@translate
perf: translate
2025-02-19 19:26:09 +08:00
Aaron3S
9a5a775652 feat: add luna i18n 2025-02-19 19:17:21 +08:00
feng
8304ae9070 perf: translate 2025-02-19 16:27:10 +08:00
wangruidong
9533861e24 fix: VariableSerializer allow null in default_value 2025-02-18 15:26:11 +08:00
Aaron3S
abbfbcde83 feat: add i18n 2025-02-18 15:10:13 +08:00
wangruidong
046a9d41bf fix: Removing labels from asset details will cause an error. 2025-02-17 18:21:28 +08:00
fit2bot
363bb20da7 feat: Chen i18n (#14851)
Co-authored-by: jiangweidong <1053570670@qq.com>
2025-02-17 11:25:54 +08:00
wangruidong
2b7c8b9c07 fix: Upgrading v3 to v4 generates two ticket flow 2025-02-14 16:10:20 +08:00
Aaron3S
db040bbd06 feat: add translate 2025-02-14 11:30:56 +08:00
feng
a761ec9aa1 perf: Translate 2025-02-13 16:55:09 +08:00
feng
c0ffe45ce9 perf: deepseek 2025-02-13 16:40:29 +08:00
wangruidong
404d58a9c9 fix: When the organization does not exist, close ticket with an error. 2025-02-13 16:31:47 +08:00
feng
f64eab7a15 perf: Translate 2025-02-13 11:48:59 +08:00
feng
46f94fd138 perf: Chat ai help text 2025-02-13 11:30:07 +08:00
wangruidong
2f1c0090b7 fix: markdown render issue 2025-02-12 15:48:05 +08:00
feng
b0d6a09276 perf: translate 2025-02-10 19:15:39 +08:00
feng
d8db76cc7b perf: DeepSeek 2025-02-08 15:40:24 +08:00
wangruidong
b35a55ed54 fix: Cannot set original org when exception occurs 2025-02-08 11:13:14 +08:00
Bai
dc5ecfcc4b fix: setting field encrypt issue 2025-02-06 17:14:10 +08:00
Bryan
2ca4002624 Merge pull request #14813 from jumpserver/dev
v4.6.0
2025-01-15 14:38:17 +08:00
wangruidong
543dde57ab perf: modify average_time_cost calculation in job model 2025-01-14 18:28:31 +08:00
w940853815
c088437fe5 Revert "perf: Optimize average_time_cost calculation in job model"
This reverts commit eafb5ecfb3.
2025-01-14 18:18:44 +08:00
feng
e721ec147c perf: luna translate 2025-01-14 17:30:00 +08:00
wangruidong
5d18d6dee0 fix: Add with_expired param to permission utils 2025-01-14 16:41:39 +08:00
feng
ecfd338428 perf: Lina translate 2025-01-14 15:16:17 +08:00
Eric
4b28b079dc perf: fix rdp file resolution value 2025-01-14 13:57:11 +08:00
wangruidong
c1c3236a30 fix: Add redirect_url check in base view 2025-01-14 11:54:17 +08:00
feng
4b19750581 perf: Client version 2025-01-14 10:32:07 +08:00
wangruidong
eafb5ecfb3 perf: Optimize average_time_cost calculation in job model 2025-01-13 17:12:11 +08:00
Bai
583486e26e fix: radius user auth skip backend 2025-01-13 15:49:23 +08:00
Bai
8198620a2e feat: add gitignore 2025-01-09 10:50:57 +08:00
wangruidong
c0b301d52b fix: ldap ha periodic task did not execute as expected 2025-01-08 18:34:53 +08:00
feng
7791d6222a perf: translate 2025-01-08 14:33:04 +08:00
Bai
b740d9d42f fix: circle imported for perms-api 2025-01-08 10:35:13 +08:00
wangruidong
48d0187604 fix: circular import 2025-01-07 14:01:38 +08:00
wangruidong
6217018427 perf: Translate var_name help_text 2025-01-06 11:36:11 +08:00
jiangweidong
923f40e523 feat: VMware automatically syncs folders to node-translation 2025-01-03 18:50:12 +08:00
Bai
1f1fe2084b fix: koko press r dont refresh user perm-nodes 2025-01-03 17:13:32 +08:00
刘瑞斌
b8b1a6ac9c chore: update readme 2024-12-30 16:04:32 +08:00
wangruidong
35f88722af fix: Add type check for secure command execution 2024-12-24 15:58:56 +08:00
Bai
7e6d2749ae fix: core download page error 2024-12-20 16:35:24 +08:00
Bai
be57b101ff fix: set default ldap user dn cache time (0) 2024-12-20 16:35:03 +08:00
Bai
41c8cb6307 fix: api prometheus count 2024-12-20 11:14:55 +08:00
Bai
3a7ae01ede fix: add settings for license version and facelive 2024-12-19 17:37:39 +08:00
Bryan
053d640e4c Merge pull request #14699 from jumpserver/dev
v4.5.0
2024-12-19 16:04:45 +08:00
老广
d17ca4f6a7 Revert "perf: update const import"
This reverts commit 2956f2e4b7.
2024-12-19 16:03:29 +08:00
Bryan
f3acc28ded Merge pull request #14697 from jumpserver/dev
v4.5.0
2024-12-19 15:57:11 +08:00
Aaron3S
5a14bb13d0 feat: remove mfa check when unbind face code 2024-12-19 15:50:38 +08:00
ibuler
2956f2e4b7 perf: update const import 2024-12-19 15:49:13 +08:00
feng
e983ac3cbc perf: Translate 2024-12-19 15:18:39 +08:00
ibuler
fab156dc5f perf: update login success redirect 2024-12-19 14:34:49 +08:00
wangruidong
f6f897317e perf: default_value field allow blank 2024-12-19 12:01:08 +08:00
Aaron3S
a0441cd6ea feat: add translate 2024-12-19 11:13:13 +08:00
Chenyang Shen
e9abd1e72d Merge pull request #14688 from jumpserver/pr@dev@fix_face_openid
fix: fix openid user can't login with face verify
2024-12-19 11:08:45 +08:00
Aaron3S
9fcb4ecba0 fix: fix openid user can't login with face verify 2024-12-19 10:56:44 +08:00
feng
4b637ad86e perf: Client version 3.0.0 2024-12-18 19:33:45 +08:00
jiangweidong
829f867962 perf: The command amount does not record operation logs. 2024-12-18 19:33:02 +08:00
feng
7f965b55f4 perf: Translate 2024-12-18 18:21:43 +08:00
Chenyang Shen
0e0be618e5 Merge pull request #14684 from jumpserver/pr@dev@feat_refresh_cache_facelive
feat: refresh facelive cache
2024-12-18 18:10:39 +08:00
Aaron3S
9577af3221 feat: refresh facelive cache 2024-12-18 18:08:13 +08:00
Aaron3S
a6b7cc9d1b fix: fix 401 error on face verify when use openid login 2024-12-18 18:07:42 +08:00
feng
7a9a71197a perf: Client login 2024-12-18 18:01:38 +08:00
jiangweidong
3cd68ba0a9 perf: push account without increasing version. 2024-12-18 16:51:39 +08:00
jiangweidong
02bdd0f07d perf: push account without increasing version. 2024-12-18 16:51:39 +08:00
jiangweidong
98cf6f82b7 perf: create account add activity log 2024-12-18 15:54:57 +08:00
wangruidong
27fd5d51b9 perf: Translate 2024-12-18 15:53:47 +08:00
wangruidong
095ca91e30 feat: add 'labels' to DomainSerializer fields_m2m 2024-12-18 10:57:30 +08:00
wangruidong
d05514962a fix: calc platform asset count 2024-12-17 19:08:55 +08:00
Bai
c4066a03fa fix: login show system org 2024-12-17 19:08:31 +08:00
Aaron3S
a7d4c4ca2a feat: change face online killer name 2024-12-17 18:53:09 +08:00
Chenyang Shen
5b0f8f63a3 Merge pull request #14670 from jumpserver/pr@dev@feat_add_some_translate
feat: add some translate
2024-12-17 18:24:01 +08:00
Aaron3S
c4bcae68bf feat: add some translate 2024-12-17 18:03:13 +08:00
Aaron3S
29ca50f97e feat: add face online acl check for exchange token 2024-12-17 17:18:37 +08:00
feng
49aaf8d53e perf: Remove the login status after the client logs in 2024-12-17 15:32:39 +08:00
feng
931e15173b perf: perm asset api date_updated order 2024-12-17 14:43:19 +08:00
feng
4018a59b2e perf: Account backup filter org 2024-12-17 11:23:32 +08:00
Chenyang Shen
88905bd28d Merge pull request #14664 from jumpserver/pr@dev@feat_add_face_verify_on_exchange_token
feat: add face verify on exchange connect token
2024-12-16 19:00:56 +08:00
Aaron3S
abad98a190 feat: add face verify on exchange connect token 2024-12-16 18:54:32 +08:00
Chenyang Shen
7419139b29 Merge pull request #14663 from jumpserver/pr@dev@feat_exclude_some_action_for_acl
feat: exclude face action for login acl and command acl
2024-12-16 18:22:09 +08:00
Aaron3S
a1fd3b1ecb feat: exclude face action for login acl and command acl 2024-12-16 18:17:00 +08:00
wangruidong
8a8a7f9947 fix: filter custom assets in secret type check 2024-12-16 17:37:05 +08:00
Chenyang Shen
f9e6fc98fb Merge pull request #14654 from jumpserver/pr@dev@feat_update_migrations
feat: update migrations
2024-12-13 12:42:17 +08:00
Aaron3S
0dd015bcba feat: update migrations 2024-12-13 12:40:06 +08:00
Aaron3S
d1ea31c9a4 feat: face online 2024-12-12 18:31:21 +08:00
feng
e2bf56e624 perf: translate 2024-12-12 15:49:37 +08:00
feng
26040a5560 perf: pt_br translate 2024-12-12 14:40:59 +08:00
Bai
54726f0a2d perf: Passkey Model field token max_length 1024 2024-12-12 14:29:23 +08:00
Eric
7fd88b95f9 perf: update lion i18n 2024-12-12 11:21:19 +08:00
feng
4f271d6405 perf: RBAC remove assets gpt custom 2024-12-11 19:05:33 +08:00
feng
fe17a8c3a0 perf: The entire organization can view activity log 2024-12-11 18:45:41 +08:00
fit2bot
ee5e97e860 perf: add rdp connection speed option (#14641)
* perf: add rdp connection speed option

* perf: remove print code

---------

Co-authored-by: Eric <xplzv@126.com>
2024-12-11 18:42:05 +08:00
fit2bot
dddfc66efd perf: add encrypted configuration API (#14632)
* perf: 添加加密配置API

* perf: modify url

---------

Co-authored-by: Eric <xplzv@126.com>
2024-12-11 11:34:09 +08:00
Bai
d005bd804f fix: user orgs add field: is_system 2024-12-10 19:19:06 +08:00
Bai
08de04fdbc fix: fixed an issue when third-part user auth 2024-12-10 16:41:38 +08:00
Bai
9ed7c41514 fix: fixed an issue when third-part user auth 2024-12-10 16:41:38 +08:00
Eric
1a81b76a46 perf: add new Qwerty for keyboard layout 2024-12-10 15:26:18 +08:00
github-actions[bot]
cf99a7a031 perf: Update Dockerfile with new base image tag 2024-12-10 15:25:37 +08:00
Bai
64551b13a1 feat: deps add ipython==8.30.0 2024-12-10 15:25:37 +08:00
Chenyang Shen
c715300416 Merge pull request #14623 from jumpserver/pr@dev@separate_face_module
feat: Separate the face recognition module.
2024-12-09 17:00:34 +08:00
feng
d9031ae02b perf: Ticket filter assignee_id 2024-12-09 16:58:17 +08:00
Aaron3S
0d2ba5c518 feat: Separate the face recognition module. 2024-12-09 16:57:05 +08:00
Bai
817957dbac fix: fixed an issue where auth backend could pass inspect 2024-12-09 15:38:20 +08:00
feng
3796af78a6 perf: Random secret string 2024-12-09 15:25:31 +08:00
github-actions[bot]
1191e4ab2d perf: Update Dockerfile with new base image tag 2024-12-09 14:21:01 +08:00
吴小白
1c6fcc5826 feat: migrating boto to boto3 2024-12-09 14:21:01 +08:00
Chenyang Shen
4728f95634 Merge pull request #14610 from jumpserver/pr@dev@feat_face_login_acl
feat: login asset face verify acl
2024-12-09 11:34:09 +08:00
Aaron3S
013502186b feat: login asset face verify acl 2024-12-09 11:19:04 +08:00
feng
a6d040cd34 perf: Automation filter org 2024-12-06 18:00:56 +08:00
Bai
398758baa6 fix: when oidc enabled and use_state user login raise 400 2024-12-06 16:26:28 +08:00
吴小白
e29bddd89e feat: bump python from 3.11.10 to 3.11.11 2024-12-06 10:29:41 +08:00
Bai
e35c915ee3 perf: add workflows auto release docs 2024-12-06 10:20:24 +08:00
Bryan
de2dd583d0 Update README.zh-hant.md 2024-12-05 14:49:42 +08:00
Bryan
43f1d7eeae Update README.pt-br.md 2024-12-05 14:49:42 +08:00
Bryan
9bb63e0933 Update README.zh-hans.md 2024-12-05 14:49:42 +08:00
Bryan
c9e03fd5d8 Update README.ja.md 2024-12-05 14:49:42 +08:00
github-actions[bot]
7a147242c9 Auto-translate README 2024-12-05 14:49:42 +08:00
github-actions[bot]
392c261a96 Auto-translate README 2024-12-05 14:49:42 +08:00
Bai
2bbccae0f5 perf: readme 2024-12-05 14:39:23 +08:00
Bai
606fa9bfbc feat: change action 2024-12-05 14:24:38 +08:00
fit2bot
96e7b165dd Auto-translate README (#14584)
* Auto-translate README

* Auto-translate README

* Auto-translate README

* Auto-translate README

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-12-05 14:17:27 +08:00
Bai
148413d280 feat: add auto translate readme 2024-12-05 13:34:04 +08:00
Bai
a46a81d477 feat: add auto translate readme 2024-12-05 13:34:04 +08:00
feng
ff0f9eb6eb perf: Change secret update version 2024-12-05 10:50:07 +08:00
jiangweidong
d8dfaf0868 fix: Solve the problem of version increase caused by push account 2024-12-04 18:50:30 +08:00
Bai
3267c8074b feat: add actions for translate readme 2024-12-04 17:45:21 +08:00
Bai
7b14d680b2 feat: add actions for translate readme 2024-12-04 17:24:12 +08:00
Bai
0980808bb7 fix: compile languages error 2024-12-03 18:14:42 +08:00
Bai
0519f15bbf fix: compile languages error 2024-12-03 18:07:55 +08:00
Eric
f6742eb4c6 perf: add dbeaver-patch version 2024-12-03 17:36:59 +08:00
fit2bot
f8d11013fc feat: support pt-br language (#14567)
Co-authored-by: Bai <baijiangjie@gmail.com>
2024-12-03 17:11:08 +08:00
jiangweidong
7875777ed1 fix: Resolving Azure test connection failure issues 2024-12-03 14:34:48 +08:00
jiangweidong
0ca81a8f30 fix: To resolve the 500 error during local updates after an account is deleted from Vault 2024-12-03 14:34:07 +08:00
Bryan
09accbd922 perf: update issue template sorted (#14563)
* perf: update issue template sorted

* perf: update issue template sorted

* Rename 1_bug_report_cn.yml to 2_bug_report_cn.yml

* perf: update issue template sorted

* Rename 1_question.yml to 2_question.yml

* Update and rename 2_feature_request.yml to 3_feature_request.yml

* Rename 2_bug_report_cn.yml to 4_bug_report_cn.yml

* Rename 3_question_cn.yml to 5_question_cn.yml

* Update and rename 2_feature_request_cn.yml to 6_feature_request_cn.yml
2024-12-03 11:21:29 +08:00
fit2bot
945204c45b perf: Script to Add a Non-existent release_assets Field (#14558)
* perf: Script to Add a Non-existent release_assets Field

* perf: docstring

---------

Co-authored-by: jiangweidong <1053570670@qq.com>
2024-12-02 16:51:52 +08:00
Bai
2d62dc0657 fix: azure vault max_workers 2024-12-02 11:08:59 +08:00
fit2bot
fa61688c28 feat: Vault adds Amazon Secrets Manager (#14515)
* feat: Vault adds Amazon Secrets Manager

* perf: optimizing the code

---------

Co-authored-by: jiangweidong <1053570670@qq.com>
2024-11-29 17:51:28 +08:00
halo
801edc7cc9 perf: After optimizing the execution of the azure vault task, the data is out of sync 2024-11-29 16:27:35 +08:00
Bai
d0617a0ea4 fix: login log get ipv6 error 2024-11-29 14:59:01 +08:00
Chenyang Shen
1191ed1793 Merge pull request #14546 from jumpserver/pr@dev@feat_move_face_to_profile
feat: move face setiing to profile
2024-11-28 18:19:32 +08:00
Aaron3S
4036420d0e feat: move face setiing to profile 2024-11-28 18:06:57 +08:00
jiangweidong
35a1655905 perf: Oauth2.0 support two methods for passing authentication credentials. 2024-11-26 14:12:56 +08:00
feng
d4dc31aefa perf: Modify default_context COPYRIGHT 2024-11-25 15:30:26 +08:00
wangruidong
04ec34364f perf: Add viewAssetOnlineSessionInfo conf 2024-11-25 15:28:57 +08:00
Aaron3S
01b8c1f7a8 fix: Fix the uncaught exception when face capture fails 2024-11-25 10:17:28 +08:00
Bai
77598a0f23 perf: update readme 2024-11-22 16:43:44 +08:00
wangruidong
eafb074fda refactor: API endpoint 2024-11-22 15:14:26 +08:00
Bryan
d4d903f5c6 perf: Update README.md (#14516)
* perf: Update README.md

* perf: Update README.md
2024-11-22 10:38:32 +08:00
吴小白
c9c55b5fcb fix: add libldap2-dev 2024-11-21 20:53:11 +08:00
203 changed files with 19010 additions and 3919 deletions

24
.github/workflows/docs-release.yml vendored Normal file
View File

@@ -0,0 +1,24 @@
name: Auto update docs changelog
on:
release:
types: [published]
jobs:
update_docs_changelog:
runs-on: ubuntu-latest
if: startsWith(github.event.release.tag_name, 'v4.')
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Update docs changelog
env:
TAG_NAME: ${{ github.event.release.tag_name }}
DOCS_TOKEN: ${{ secrets.DOCS_TOKEN }}
run: |
git config --global user.name 'BaiJiangJie'
git config --global user.email 'jiangjie.bai@fit2cloud.com'
git clone https://$DOCS_TOKEN@github.com/jumpservice/documentation.git
cd documentation/utils
bash update_changelog.sh

40
.github/workflows/translate-readme.yml vendored Normal file
View File

@@ -0,0 +1,40 @@
name: Translate README
on:
workflow_dispatch:
inputs:
target_langs:
description: "Target Languages"
required: false
default: "zh-hans,zh-hant,ja,pt-br"
gen_dir_path:
description: "Generate Dir Name"
required: false
default: "readmes/"
push_branch:
description: "Push Branch"
required: false
default: "pr@dev@translate_readme"
prompt:
description: "AI Translate Prompt"
required: false
default: ""
gpt_mode:
description: "GPT Mode"
required: false
default: "gpt-4o-mini"
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Auto Translate
uses: jumpserver-dev/action-translate-readme@main
env:
GITHUB_TOKEN: ${{ secrets.PRIVATE_TOKEN }}
OPENAI_API_KEY: ${{ secrets.GPT_API_TOKEN }}
GPT_MODE: ${{ github.event.inputs.gpt_mode }}
TARGET_LANGUAGES: ${{ github.event.inputs.target_langs }}
PUSH_BRANCH: ${{ github.event.inputs.push_branch }}
GEN_DIR_PATH: ${{ github.event.inputs.gen_dir_path }}
PROMPT: ${{ github.event.inputs.prompt }}

1
.gitignore vendored
View File

@@ -45,3 +45,4 @@ test.py
.history/ .history/
.test/ .test/
*.mo *.mo
apps.iml

View File

@@ -1,4 +1,4 @@
FROM jumpserver/core-base:20241105_025649 AS stage-build FROM jumpserver/core-base:20241210_070105 AS stage-build
ARG VERSION ARG VERSION
@@ -24,6 +24,7 @@ ENV LANG=en_US.UTF-8 \
PATH=/opt/py3/bin:$PATH PATH=/opt/py3/bin:$PATH
ARG DEPENDENCIES=" \ ARG DEPENDENCIES=" \
libldap2-dev \
libx11-dev" libx11-dev"
ARG TOOLS=" \ ARG TOOLS=" \

View File

@@ -10,7 +10,8 @@
[![][github-release-shield]][github-release-link] [![][github-release-shield]][github-release-link]
[![][github-stars-shield]][github-stars-link] [![][github-stars-shield]][github-stars-link]
**English** · [简体中文](./README.zh-CN.md) [English](/README.md) · [中文(简体)](/readmes/README.zh-hans.md) · [中文(繁體)](/readmes/README.zh-hant.md) · [日本語](/readmes/README.ja.md) · [Português (Brasil)](/readmes/README.pt-br.md)
</div> </div>
<br/> <br/>
@@ -68,10 +69,13 @@ JumpServer consists of multiple key components, which collectively form the func
| [KoKo](https://github.com/jumpserver/koko) | <a href="https://github.com/jumpserver/koko/releases"><img alt="Koko release" src="https://img.shields.io/github/release/jumpserver/koko.svg" /></a> | JumpServer Character Protocol Connector | | [KoKo](https://github.com/jumpserver/koko) | <a href="https://github.com/jumpserver/koko/releases"><img alt="Koko release" src="https://img.shields.io/github/release/jumpserver/koko.svg" /></a> | JumpServer Character Protocol Connector |
| [Lion](https://github.com/jumpserver/lion) | <a href="https://github.com/jumpserver/lion/releases"><img alt="Lion release" src="https://img.shields.io/github/release/jumpserver/lion.svg" /></a> | JumpServer Graphical Protocol Connector | | [Lion](https://github.com/jumpserver/lion) | <a href="https://github.com/jumpserver/lion/releases"><img alt="Lion release" src="https://img.shields.io/github/release/jumpserver/lion.svg" /></a> | JumpServer Graphical Protocol Connector |
| [Chen](https://github.com/jumpserver/chen) | <a href="https://github.com/jumpserver/chen/releases"><img alt="Chen release" src="https://img.shields.io/github/release/jumpserver/chen.svg" /> | JumpServer Web DB | | [Chen](https://github.com/jumpserver/chen) | <a href="https://github.com/jumpserver/chen/releases"><img alt="Chen release" src="https://img.shields.io/github/release/jumpserver/chen.svg" /> | JumpServer Web DB |
| [Razor](https://github.com/jumpserver/razor) | <img alt="Chen" src="https://img.shields.io/badge/release-private-red" /> | JumpServer EE RDP Proxy Connector | | [Razor](https://github.com/jumpserver/razor) | <img alt="Chen" src="https://img.shields.io/badge/release-private-red" /> | JumpServer EE RDP Proxy Connector |
| [Tinker](https://github.com/jumpserver/tinker) | <img alt="Tinker" src="https://img.shields.io/badge/release-private-red" /> | JumpServer EE Remote Application Connector (Windows) | | [Tinker](https://github.com/jumpserver/tinker) | <img alt="Tinker" src="https://img.shields.io/badge/release-private-red" /> | JumpServer EE Remote Application Connector (Windows) |
| [Panda](https://github.com/jumpserver/Panda) | <img alt="Panda" src="https://img.shields.io/badge/release-private-red" /> | JumpServer EE Remote Application Connector (Linux) | | [Panda](https://github.com/jumpserver/Panda) | <img alt="Panda" src="https://img.shields.io/badge/release-private-red" /> | JumpServer EE Remote Application Connector (Linux) |
| [Magnus](https://github.com/jumpserver/magnus) | <img alt="Magnus" src="https://img.shields.io/badge/release-private-red" /> | JumpServer EE Database Proxy Connector | | [Magnus](https://github.com/jumpserver/magnus) | <img alt="Magnus" src="https://img.shields.io/badge/release-private-red" /> | JumpServer EE Database Proxy Connector |
| [Nec](https://github.com/jumpserver/nec) | <img alt="Nec" src="https://img.shields.io/badge/release-private-red" /> | JumpServer EE VNC Proxy Connector |
| [Facelive](https://github.com/jumpserver/facelive) | <img alt="Facelive" src="https://img.shields.io/badge/release-private-red" /> | JumpServer EE Facial Recognition |
## Contributing ## Contributing
@@ -85,7 +89,7 @@ JumpServer is a mission critical product. Please refer to the Basic Security Rec
## License ## License
Copyright (c) 2014-2024 飞致云 FIT2CLOUD, All rights reserved. Copyright (c) 2014-2025 FIT2CLOUD, All rights reserved.
Licensed under The GNU General Public License version 3 (GPLv3) (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at Licensed under The GNU General Public License version 3 (GPLv3) (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at

View File

@@ -109,7 +109,7 @@ JumpServer是一款安全产品请参考 [基本安全建议](https://docs.ju
## License & Copyright ## License & Copyright
Copyright (c) 2014-2024 飞致云 FIT2CLOUD, All rights reserved. Copyright (c) 2014-2024 飞致云, All rights reserved.
Licensed under The GNU General Public License version 3 (GPLv3) (the "License"); you may not use this file except in Licensed under The GNU General Public License version 3 (GPLv3) (the "License"); you may not use this file except in
compliance with the License. You may obtain a copy of the License at compliance with the License. You may obtain a copy of the License at

View File

@@ -160,6 +160,10 @@ class ChangeSecretManager(AccountBasePlaybookManager):
ChangeSecretRecord.objects.bulk_create(records) ChangeSecretRecord.objects.bulk_create(records)
return inventory_hosts return inventory_hosts
@staticmethod
def require_update_version(account, recorder):
return account.secret != recorder.new_secret
def on_host_success(self, host, result): def on_host_success(self, host, result):
recorder = self.name_recorder_mapper.get(host) recorder = self.name_recorder_mapper.get(host)
if not recorder: if not recorder:
@@ -171,6 +175,8 @@ class ChangeSecretManager(AccountBasePlaybookManager):
if not account: if not account:
print("Account not found, deleted ?") print("Account not found, deleted ?")
return return
version_update_required = self.require_update_version(account, recorder)
account.secret = recorder.new_secret account.secret = recorder.new_secret
account.date_updated = timezone.now() account.date_updated = timezone.now()
@@ -180,7 +186,10 @@ class ChangeSecretManager(AccountBasePlaybookManager):
while retry_count < max_retries: while retry_count < max_retries:
try: try:
recorder.save() recorder.save()
account.save(update_fields=['secret', 'version', 'date_updated']) account_update_fields = ['secret', 'date_updated']
if version_update_required:
account_update_fields.append('version')
account.save(update_fields=account_update_fields)
break break
except Exception as e: except Exception as e:
retry_count += 1 retry_count += 1

View File

@@ -8,6 +8,11 @@ logger = get_logger(__name__)
class PushAccountManager(ChangeSecretManager, AccountBasePlaybookManager): class PushAccountManager(ChangeSecretManager, AccountBasePlaybookManager):
@staticmethod
def require_update_version(account, recorder):
account.skip_history_when_saving = True
return False
@classmethod @classmethod
def method_type(cls): def method_type(cls):
return AutomationTypes.push_account return AutomationTypes.push_account

View File

@@ -13,9 +13,6 @@ logger = get_logger(__file__)
def get_vault_client(raise_exception=False, **kwargs): def get_vault_client(raise_exception=False, **kwargs):
tp = kwargs.get('VAULT_BACKEND') if kwargs.get('VAULT_ENABLED') else VaultTypeChoices.local tp = kwargs.get('VAULT_BACKEND') if kwargs.get('VAULT_ENABLED') else VaultTypeChoices.local
# TODO: Temporary processing, subsequent deletion
tp = VaultTypeChoices.local if tp == VaultTypeChoices.azure else tp
try: try:
module_path = f'apps.accounts.backends.{tp}.main' module_path = f'apps.accounts.backends.{tp}.main'
client = import_module(module_path).Vault(**kwargs) client = import_module(module_path).Vault(**kwargs)

View File

@@ -0,0 +1 @@
from .main import *

View File

@@ -0,0 +1,16 @@
from .service import AmazonSecretsManagerClient
from ..base.vault import BaseVault
from ..utils.mixins import GeneralVaultMixin
from ...const import VaultTypeChoices
class Vault(GeneralVaultMixin, BaseVault):
type = VaultTypeChoices.aws
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.client = AmazonSecretsManagerClient(
region_name=kwargs.get('VAULT_AWS_REGION_NAME'),
access_key_id=kwargs.get('VAULT_AWS_ACCESS_KEY_ID'),
secret_key=kwargs.get('VAULT_AWS_ACCESS_SECRET_KEY'),
)

View File

@@ -0,0 +1,56 @@
import boto3
from common.utils import get_logger, random_string
logger = get_logger(__name__)
__all__ = ['AmazonSecretsManagerClient']
class AmazonSecretsManagerClient(object):
def __init__(self, region_name, access_key_id, secret_key):
self.client = boto3.client(
'secretsmanager', region_name=region_name,
aws_access_key_id=access_key_id, aws_secret_access_key=secret_key,
)
self.empty_secret = '#{empty}#'
def is_active(self):
try:
secret_id = f'jumpserver/test-{random_string(12)}'
self.create(secret_id, 'secret')
self.get(secret_id)
self.update(secret_id, 'secret')
self.delete(secret_id)
except Exception as e:
return False, f'Vault is not reachable: {e}'
else:
return True, ''
def get(self, name, version=''):
params = {'SecretId': name}
if version:
params['VersionStage'] = version
try:
secret = self.client.get_secret_value(**params)['SecretString']
return secret if secret != self.empty_secret else ''
except Exception: # noqa
return ''
def create(self, name, secret):
self.client.create_secret(Name=name, SecretString=secret or self.empty_secret)
def update(self, name, secret):
self.client.update_secret(SecretId=name, SecretString=secret or self.empty_secret)
def delete(self, name):
self.client.delete_secret(SecretId=name)
def update_metadata(self, name, metadata: dict):
tags = [{'Key': k, 'Value': v} for k, v in metadata.items()]
try:
self.client.tag_resource(SecretId=name, Tags=tags)
except Exception as e:
logger.error(f'update_metadata: {name} {str(e)}')

View File

@@ -1,41 +1,13 @@
import sys from ..base.entries import BaseEntry
from abc import ABC
from common.db.utils import Encryptor
from common.utils import lazyproperty
current_module = sys.modules[__name__]
__all__ = ['build_entry']
class BaseEntry(ABC): class AzureBaseEntry(BaseEntry):
@property
def __init__(self, instance):
self.instance = instance
@lazyproperty
def full_path(self): def full_path(self):
return self.path_spec return self.path_spec
@property
def path_spec(self):
raise NotImplementedError
def to_internal_data(self): class AccountEntry(AzureBaseEntry):
secret = getattr(self.instance, '_secret', None)
if secret is not None:
secret = Encryptor(secret).encrypt()
return secret
@staticmethod
def to_external_data(secret):
if secret is not None:
secret = Encryptor(secret).decrypt()
return secret
class AccountEntry(BaseEntry):
@property @property
def path_spec(self): def path_spec(self):
@@ -45,7 +17,7 @@ class AccountEntry(BaseEntry):
return path return path
class AccountTemplateEntry(BaseEntry): class AccountTemplateEntry(AzureBaseEntry):
@property @property
def path_spec(self): def path_spec(self):
@@ -53,18 +25,9 @@ class AccountTemplateEntry(BaseEntry):
return path return path
class HistoricalAccountEntry(BaseEntry): class HistoricalAccountEntry(AzureBaseEntry):
@property @property
def path_spec(self): def path_spec(self):
path = f'accounts-{self.instance.instance.id}-histories-{self.instance.history_id}' path = f'accounts-{self.instance.instance.id}-histories-{self.instance.history_id}'
return path return path
def build_entry(instance) -> BaseEntry:
class_name = instance.__class__.__name__
entry_class_name = f'{class_name}Entry'
entry_class = getattr(current_module, entry_class_name, None)
if not entry_class:
raise Exception(f'Entry class {entry_class_name} is not found')
return entry_class(instance)

View File

@@ -1,16 +1,10 @@
from common.db.utils import get_logger
from .entries import build_entry
from .service import AZUREVaultClient from .service import AZUREVaultClient
from ..base import BaseVault from ..base.vault import BaseVault
from ..utils.mixins import GeneralVaultMixin
from ...const import VaultTypeChoices from ...const import VaultTypeChoices
logger = get_logger(__name__)
__all__ = ['Vault'] class Vault(GeneralVaultMixin, BaseVault):
class Vault(BaseVault):
type = VaultTypeChoices.azure type = VaultTypeChoices.azure
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -21,37 +15,3 @@ class Vault(BaseVault):
client_id=kwargs.get('VAULT_AZURE_CLIENT_ID'), client_id=kwargs.get('VAULT_AZURE_CLIENT_ID'),
client_secret=kwargs.get('VAULT_AZURE_CLIENT_SECRET') client_secret=kwargs.get('VAULT_AZURE_CLIENT_SECRET')
) )
def is_active(self):
return self.client.is_active()
def _get(self, instance):
entry = build_entry(instance)
secret = self.client.get(name=entry.full_path)
secret = entry.to_external_data(secret)
return secret
def _create(self, instance):
entry = build_entry(instance)
secret = entry.to_internal_data()
self.client.create(name=entry.full_path, secret=secret)
def _update(self, instance):
entry = build_entry(instance)
secret = entry.to_internal_data()
self.client.update(name=entry.full_path, secret=secret)
def _delete(self, instance):
entry = build_entry(instance)
self.client.delete(name=entry.full_path)
def _clean_db_secret(self, instance):
instance.is_sync_metadata = False
instance.mark_secret_save_to_vault()
def _save_metadata(self, instance, metadata):
try:
entry = build_entry(instance)
self.client.update_metadata(name=entry.full_path, metadata=metadata)
except Exception as e:
logger.error(f'save metadata error: {e}')

View File

@@ -36,7 +36,6 @@ class AZUREVaultClient(object):
secret = self.client.get_secret(name, version) secret = self.client.get_secret(name, version)
return secret.value return secret.value
except (ResourceNotFoundError, ClientAuthenticationError) as e: except (ResourceNotFoundError, ClientAuthenticationError) as e:
logger.error(f'get: {name} {str(e)}')
return '' return ''
def create(self, name, secret): def create(self, name, secret):

View File

@@ -1,76 +0,0 @@
from abc import ABC, abstractmethod
from django.forms.models import model_to_dict
__all__ = ['BaseVault']
class BaseVault(ABC):
def __init__(self, *args, **kwargs):
self.enabled = kwargs.get('VAULT_ENABLED')
@property
@abstractmethod
def type(self):
raise NotImplementedError
def get(self, instance):
""" 返回 secret 值 """
return self._get(instance)
def create(self, instance):
if not instance.secret_has_save_to_vault:
self._create(instance)
self._clean_db_secret(instance)
self.save_metadata(instance)
def update(self, instance):
if not instance.secret_has_save_to_vault:
self._update(instance)
self._clean_db_secret(instance)
self.save_metadata(instance)
if instance.is_sync_metadata:
self.save_metadata(instance)
def delete(self, instance):
self._delete(instance)
def save_metadata(self, instance):
metadata = model_to_dict(instance, fields=[
'name', 'username', 'secret_type',
'connectivity', 'su_from', 'privileged'
])
metadata = {k: str(v)[:500] for k, v in metadata.items() if v}
return self._save_metadata(instance, metadata)
# -------- abstractmethod -------- #
@abstractmethod
def _get(self, instance):
raise NotImplementedError
@abstractmethod
def _create(self, instance):
raise NotImplementedError
@abstractmethod
def _update(self, instance):
raise NotImplementedError
@abstractmethod
def _delete(self, instance):
raise NotImplementedError
@abstractmethod
def _clean_db_secret(self, instance):
raise NotImplementedError
@abstractmethod
def _save_metadata(self, instance, metadata):
raise NotImplementedError
@abstractmethod
def is_active(self, *args, **kwargs) -> (bool, str):
raise NotImplementedError

View File

View File

@@ -1,19 +1,18 @@
import sys
from abc import ABC from abc import ABC
from common.db.utils import Encryptor from common.db.utils import Encryptor
from common.utils import lazyproperty from common.utils import lazyproperty
current_module = sys.modules[__name__]
__all__ = ['build_entry']
class BaseEntry(ABC): class BaseEntry(ABC):
def __init__(self, instance): def __init__(self, instance):
self.instance = instance self.instance = instance
@property
def path_base(self):
path = f'orgs/{self.instance.org_id}'
return path
@lazyproperty @lazyproperty
def full_path(self): def full_path(self):
path_base = self.path_base path_base = self.path_base
@@ -21,32 +20,24 @@ class BaseEntry(ABC):
path = f'{path_base}/{path_spec}' path = f'{path_base}/{path_spec}'
return path return path
@property
def path_base(self):
path = f'orgs/{self.instance.org_id}'
return path
@property @property
def path_spec(self): def path_spec(self):
raise NotImplementedError raise NotImplementedError
def to_internal_data(self): def get_encrypt_secret(self):
secret = getattr(self.instance, '_secret', None) secret = getattr(self.instance, '_secret', None)
if secret is not None: if secret is not None:
secret = Encryptor(secret).encrypt() secret = Encryptor(secret).encrypt()
data = {'secret': secret} return secret
return data
@staticmethod @staticmethod
def to_external_data(data): def get_decrypt_secret(secret):
secret = data.pop('secret', None)
if secret is not None: if secret is not None:
secret = Encryptor(secret).decrypt() secret = Encryptor(secret).decrypt()
return secret return secret
class AccountEntry(BaseEntry): class AccountEntry(BaseEntry):
@property @property
def path_spec(self): def path_spec(self):
path = f'assets/{self.instance.asset_id}/accounts/{self.instance.id}' path = f'assets/{self.instance.asset_id}/accounts/{self.instance.id}'
@@ -54,7 +45,6 @@ class AccountEntry(BaseEntry):
class AccountTemplateEntry(BaseEntry): class AccountTemplateEntry(BaseEntry):
@property @property
def path_spec(self): def path_spec(self):
path = f'account-templates/{self.instance.id}' path = f'account-templates/{self.instance.id}'
@@ -62,23 +52,12 @@ class AccountTemplateEntry(BaseEntry):
class HistoricalAccountEntry(BaseEntry): class HistoricalAccountEntry(BaseEntry):
@property @property
def path_base(self): def path_base(self):
account = self.instance.instance path = f'accounts/{self.instance.instance.id}'
path = f'accounts/{account.id}/'
return path return path
@property @property
def path_spec(self): def path_spec(self):
path = f'histories/{self.instance.history_id}' path = f'histories/{self.instance.history_id}'
return path return path
def build_entry(instance) -> BaseEntry:
class_name = instance.__class__.__name__
entry_class_name = f'{class_name}Entry'
entry_class = getattr(current_module, entry_class_name, None)
if not entry_class:
raise Exception(f'Entry class {entry_class_name} is not found')
return entry_class(instance)

View File

@@ -0,0 +1,109 @@
import importlib
import inspect
from abc import ABC, abstractmethod
from django.forms.models import model_to_dict
from .entries import BaseEntry
from ...const import VaultTypeChoices
class BaseVault(ABC):
def __init__(self, *args, **kwargs):
self.enabled = kwargs.get('VAULT_ENABLED')
self._entry_classes = {}
self._load_entries()
def _load_entries_import_module(self, module_name):
module = importlib.import_module(module_name)
for name, obj in inspect.getmembers(module, inspect.isclass):
self._entry_classes.setdefault(name, obj)
def _load_entries(self):
if self.type == VaultTypeChoices.local:
return
module_name = f'accounts.backends.{self.type}.entries'
if importlib.util.find_spec(module_name): # noqa
self._load_entries_import_module(module_name)
base_module = 'accounts.backends.base.entries'
self._load_entries_import_module(base_module)
@property
@abstractmethod
def type(self):
raise NotImplementedError
def get(self, instance):
""" 返回 secret 值 """
return self._get(self.build_entry(instance))
def create(self, instance):
if not instance.secret_has_save_to_vault:
entry = self.build_entry(instance)
self._create(entry)
self._clean_db_secret(instance)
self.save_metadata(entry)
def update(self, instance):
entry = self.build_entry(instance)
if not instance.secret_has_save_to_vault:
self._update(entry)
self._clean_db_secret(instance)
self.save_metadata(entry)
if instance.is_sync_metadata:
self.save_metadata(entry)
def delete(self, instance):
entry = self.build_entry(instance)
self._delete(entry)
def save_metadata(self, entry):
metadata = model_to_dict(entry.instance, fields=[
'name', 'username', 'secret_type',
'connectivity', 'su_from', 'privileged'
])
metadata = {k: str(v)[:500] for k, v in metadata.items() if v}
return self._save_metadata(entry, metadata)
def build_entry(self, instance):
if self.type == VaultTypeChoices.local:
return BaseEntry(instance)
entry_class_name = f'{instance.__class__.__name__}Entry'
entry_class = self._entry_classes.get(entry_class_name)
if not entry_class:
raise Exception(f'Entry class {entry_class_name} is not found')
return entry_class(instance)
def _clean_db_secret(self, instance):
instance.is_sync_metadata = False
instance.mark_secret_save_to_vault()
# -------- abstractmethod -------- #
@abstractmethod
def _get(self, instance):
raise NotImplementedError
@abstractmethod
def _create(self, entry):
raise NotImplementedError
@abstractmethod
def _update(self, entry):
raise NotImplementedError
@abstractmethod
def _delete(self, entry):
raise NotImplementedError
@abstractmethod
def _save_metadata(self, instance, metadata):
raise NotImplementedError
@abstractmethod
def is_active(self, *args, **kwargs) -> (bool, str):
raise NotImplementedError

View File

@@ -1,10 +1,10 @@
from common.db.utils import get_logger from common.db.utils import get_logger
from .entries import build_entry
from .service import VaultKVClient from .service import VaultKVClient
from ..base import BaseVault from ..base.vault import BaseVault
from ...const import VaultTypeChoices from ...const import VaultTypeChoices
logger = get_logger(__name__) logger = get_logger(__name__)
__all__ = ['Vault'] __all__ = ['Vault']
@@ -24,34 +24,25 @@ class Vault(BaseVault):
def is_active(self): def is_active(self):
return self.client.is_active() return self.client.is_active()
def _get(self, instance): def _get(self, entry):
entry = build_entry(instance)
# TODO: get data 是不是层数太多了 # TODO: get data 是不是层数太多了
data = self.client.get(path=entry.full_path).get('data', {}) data = self.client.get(path=entry.full_path).get('data', {})
data = entry.to_external_data(data) data = entry.get_decrypt_secret(data.get('secret'))
return data return data
def _create(self, instance): def _create(self, entry):
entry = build_entry(instance) data = {'secret': entry.get_encrypt_secret()}
data = entry.to_internal_data()
self.client.create(path=entry.full_path, data=data) self.client.create(path=entry.full_path, data=data)
def _update(self, instance): def _update(self, entry):
entry = build_entry(instance) data = {'secret': entry.get_encrypt_secret()}
data = entry.to_internal_data()
self.client.patch(path=entry.full_path, data=data) self.client.patch(path=entry.full_path, data=data)
def _delete(self, instance): def _delete(self, entry):
entry = build_entry(instance)
self.client.delete(path=entry.full_path) self.client.delete(path=entry.full_path)
def _clean_db_secret(self, instance): def _save_metadata(self, entry, metadata):
instance.is_sync_metadata = False
instance.mark_secret_save_to_vault()
def _save_metadata(self, instance, metadata):
try: try:
entry = build_entry(instance)
self.client.update_metadata(path=entry.full_path, metadata=metadata) self.client.update_metadata(path=entry.full_path, metadata=metadata)
except Exception as e: except Exception as e:
logger.error(f'save metadata error: {e}') logger.error(f'save metadata error: {e}')

View File

@@ -1,5 +1,5 @@
from common.utils import get_logger from common.utils import get_logger
from ..base import BaseVault from ..base.vault import BaseVault
from ...const import VaultTypeChoices from ...const import VaultTypeChoices
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -13,23 +13,23 @@ class Vault(BaseVault):
def is_active(self): def is_active(self):
return True, '' return True, ''
def _get(self, instance): def _get(self, entry):
secret = getattr(instance, '_secret', None) secret = getattr(entry.instance, '_secret', None)
return secret return secret
def _create(self, instance): def _create(self, entry):
""" Ignore """ """ Ignore """
pass pass
def _update(self, instance): def _update(self, entry):
""" Ignore """ """ Ignore """
pass pass
def _delete(self, instance): def _delete(self, entry):
""" Ignore """ """ Ignore """
pass pass
def _save_metadata(self, instance, metadata): def _save_metadata(self, entry, metadata):
""" Ignore """ """ Ignore """
pass pass

View File

View File

@@ -0,0 +1,32 @@
from common.utils import get_logger
logger = get_logger(__name__)
class GeneralVaultMixin(object):
client = None
def is_active(self):
return self.client.is_active()
def _get(self, entry):
secret = self.client.get(name=entry.full_path)
return entry.get_decrypt_secret(secret)
def _create(self, entry):
secret = entry.get_encrypt_secret()
self.client.create(name=entry.full_path, secret=secret)
def _update(self, entry):
secret = entry.get_encrypt_secret()
self.client.update(name=entry.full_path, secret=secret)
def _delete(self, entry):
self.client.delete(name=entry.full_path)
def _save_metadata(self, entry, metadata):
try:
self.client.update_metadata(name=entry.full_path, metadata=metadata)
except Exception as e:
logger.error(f'save metadata error: {e}')

View File

@@ -8,3 +8,4 @@ class VaultTypeChoices(models.TextChoices):
local = 'local', _('Database') local = 'local', _('Database')
hcp = 'hcp', _('HCP Vault') hcp = 'hcp', _('HCP Vault')
azure = 'azure', _('Azure Key Vault') azure = 'azure', _('Azure Key Vault')
aws = 'aws', _('Amazon Secrets Manager')

View File

@@ -0,0 +1,8 @@
from common.exceptions import JMSException
from django.utils.translation import gettext_lazy as _
class VaultException(JMSException):
default_detail = _(
'Vault operation failed. Please retry or check your account information on Vault.'
)

View File

@@ -14,13 +14,17 @@ from common.db import fields
from common.db.encoder import ModelJSONFieldEncoder from common.db.encoder import ModelJSONFieldEncoder
from common.utils import get_logger, lazyproperty from common.utils import get_logger, lazyproperty
from ops.mixin import PeriodTaskModelMixin from ops.mixin import PeriodTaskModelMixin
from orgs.mixins.models import OrgModelMixin, JMSOrgBaseModel from orgs.mixins.models import OrgModelMixin, JMSOrgBaseModel, OrgManager
__all__ = ['AccountBackupAutomation', 'AccountBackupExecution'] __all__ = ['AccountBackupAutomation', 'AccountBackupExecution']
logger = get_logger(__file__) logger = get_logger(__file__)
class BaseBackupAutomationManager(OrgManager):
pass
class AccountBackupAutomation(PeriodTaskModelMixin, JMSOrgBaseModel): class AccountBackupAutomation(PeriodTaskModelMixin, JMSOrgBaseModel):
types = models.JSONField(default=list) types = models.JSONField(default=list)
backup_type = models.CharField(max_length=128, choices=AccountBackupType.choices, backup_type = models.CharField(max_length=128, choices=AccountBackupType.choices,
@@ -47,6 +51,8 @@ class AccountBackupAutomation(PeriodTaskModelMixin, JMSOrgBaseModel):
max_length=4096, blank=True, null=True, verbose_name=_('Zip encrypt password') max_length=4096, blank=True, null=True, verbose_name=_('Zip encrypt password')
) )
objects = BaseBackupAutomationManager.from_queryset(models.QuerySet)()
def __str__(self): def __str__(self):
return f'{self.name}({self.org_id})' return f'{self.name}({self.org_id})'

View File

@@ -80,6 +80,7 @@ class VaultModelMixin(models.Model):
def mark_secret_save_to_vault(self): def mark_secret_save_to_vault(self):
self._secret = self._secret_save_to_vault_mark self._secret = self._secret_save_to_vault_mark
self.skip_history_when_saving = True
self.save() self.save()
@property @property

View File

@@ -385,7 +385,7 @@ class AssetAccountBulkSerializer(
_results = {} _results = {}
for asset in assets: for asset in assets:
if asset not in secret_type_supports: if asset not in secret_type_supports and asset.category != Category.CUSTOM:
_results[asset] = { _results[asset] = {
'error': _('Asset does not support this secret type: %s') % secret_type, 'error': _('Asset does not support this secret type: %s') % secret_type,
'state': 'error', 'state': 'error',

View File

@@ -14,6 +14,7 @@ from common.decorators import merge_delay_run
from common.signals import django_ready from common.signals import django_ready
from common.utils import get_logger, i18n_fmt from common.utils import get_logger, i18n_fmt
from common.utils.connection import RedisPubSub from common.utils.connection import RedisPubSub
from .exceptions import VaultException
from .models import Account, AccountTemplate from .models import Account, AccountTemplate
from .tasks.push_account import push_accounts_to_assets_task from .tasks.push_account import push_accounts_to_assets_task
@@ -22,6 +23,9 @@ logger = get_logger(__name__)
@receiver(pre_save, sender=Account) @receiver(pre_save, sender=Account)
def on_account_pre_save(sender, instance, **kwargs): def on_account_pre_save(sender, instance, **kwargs):
if getattr(instance, 'skip_history_when_saving', False):
return
if instance.version == 0: if instance.version == 0:
instance.version = 1 instance.version = 1
else: else:
@@ -65,7 +69,7 @@ def create_accounts_activities(account, action='create'):
@receiver(post_save, sender=Account) @receiver(post_save, sender=Account)
def on_account_create_by_template(sender, instance, created=False, **kwargs): def on_account_create_by_template(sender, instance, created=False, **kwargs):
if not created or instance.source != Source.TEMPLATE: if not created:
return return
push_accounts_if_need.delay(accounts=(instance,)) push_accounts_if_need.delay(accounts=(instance,))
create_accounts_activities(instance, action='create') create_accounts_activities(instance, action='create')
@@ -81,14 +85,22 @@ class VaultSignalHandler(object):
@staticmethod @staticmethod
def save_to_vault(sender, instance, created, **kwargs): def save_to_vault(sender, instance, created, **kwargs):
if created: try:
vault_client.create(instance) if created:
else: vault_client.create(instance)
vault_client.update(instance) else:
vault_client.update(instance)
except Exception as e:
logger.error('Vault save failed: {}'.format(e))
raise VaultException()
@staticmethod @staticmethod
def delete_to_vault(sender, instance, **kwargs): def delete_to_vault(sender, instance, **kwargs):
vault_client.delete(instance) try:
vault_client.delete(instance)
except Exception as e:
logger.error('Vault delete failed: {}'.format(e))
raise VaultException()
for model in (Account, AccountTemplate, Account.history.model): for model in (Account, AccountTemplate, Account.history.model):

View File

@@ -52,7 +52,8 @@ def sync_secret_to_vault():
for model in to_sync_models: for model in to_sync_models:
instances += list(model.objects.all()) instances += list(model.objects.all())
with ThreadPoolExecutor(max_workers=10) as executor: max_workers = 1 if VaultTypeChoices.azure == vault_client.type else 10
with ThreadPoolExecutor(max_workers=max_workers) as executor:
tasks = [executor.submit(sync_instance, instance) for instance in instances] tasks = [executor.submit(sync_instance, instance) for instance in instances]
for future in as_completed(tasks): for future in as_completed(tasks):

View File

@@ -9,3 +9,5 @@ class ActionChoices(models.TextChoices):
warning = 'warning', _('Warn') warning = 'warning', _('Warn')
notice = 'notice', _('Notify') notice = 'notice', _('Notify')
notify_and_warn = 'notify_and_warn', _('Notify and warn') notify_and_warn = 'notify_and_warn', _('Notify and warn')
face_verify = 'face_verify', _('Face Verify')
face_online = 'face_online', _('Face Online')

View File

@@ -70,6 +70,13 @@ class ActionAclSerializer(serializers.Serializer):
return return
if not settings.XPACK_LICENSE_IS_VALID: if not settings.XPACK_LICENSE_IS_VALID:
field_action._choices.pop(ActionChoices.review, None) field_action._choices.pop(ActionChoices.review, None)
if not (
settings.XPACK_LICENSE_IS_VALID and
settings.XPACK_LICENSE_EDITION_ULTIMATE and
settings.FACE_RECOGNITION_ENABLED
):
field_action._choices.pop(ActionChoices.face_verify, None)
field_action._choices.pop(ActionChoices.face_online, None)
for choice in self.Meta.action_choices_exclude: for choice in self.Meta.action_choices_exclude:
field_action._choices.pop(choice, None) field_action._choices.pop(choice, None)

View File

@@ -32,7 +32,9 @@ class CommandFilterACLSerializer(BaseSerializer, BulkOrgResourceModelSerializer)
class Meta(BaseSerializer.Meta): class Meta(BaseSerializer.Meta):
model = CommandFilterACL model = CommandFilterACL
fields = BaseSerializer.Meta.fields + ['command_groups'] fields = BaseSerializer.Meta.fields + ['command_groups']
action_choices_exclude = [ActionChoices.notice] action_choices_exclude = [ActionChoices.notice,
ActionChoices.face_verify,
ActionChoices.face_online]
class CommandReviewSerializer(serializers.Serializer): class CommandReviewSerializer(serializers.Serializer):

View File

@@ -4,6 +4,7 @@ from common.serializers import MethodSerializer
from orgs.mixins.serializers import BulkOrgResourceModelSerializer from orgs.mixins.serializers import BulkOrgResourceModelSerializer
from .base import BaseUserACLSerializer from .base import BaseUserACLSerializer
from .rules import RuleSerializer from .rules import RuleSerializer
from ..const import ActionChoices
from ..models import LoginACL from ..models import LoginACL
__all__ = ["LoginACLSerializer"] __all__ = ["LoginACLSerializer"]
@@ -17,6 +18,7 @@ class LoginACLSerializer(BaseUserACLSerializer, BulkOrgResourceModelSerializer):
class Meta(BaseUserACLSerializer.Meta): class Meta(BaseUserACLSerializer.Meta):
model = LoginACL model = LoginACL
fields = BaseUserACLSerializer.Meta.fields + ['rules', ] fields = BaseUserACLSerializer.Meta.fields + ['rules', ]
action_choices_exclude = [ActionChoices.face_online, ActionChoices.face_verify]
def get_rules_serializer(self): def get_rules_serializer(self):
return RuleSerializer() return RuleSerializer()

View File

@@ -1,10 +1,10 @@
from django.db.models import Count from django.db.models import Subquery, OuterRef, Count, Value
from django.db.models.functions import Coalesce
from django_filters import rest_framework as filters from django_filters import rest_framework as filters
from rest_framework import generics from rest_framework import generics
from rest_framework import serializers from rest_framework import serializers
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.response import Response from rest_framework.response import Response
from assets.const import AllTypes from assets.const import AllTypes
from assets.models import Platform, Node, Asset, PlatformProtocol from assets.models import Platform, Node, Asset, PlatformProtocol
from assets.serializers import PlatformSerializer, PlatformProtocolSerializer, PlatformListSerializer from assets.serializers import PlatformSerializer, PlatformProtocolSerializer, PlatformListSerializer
@@ -42,7 +42,10 @@ class AssetPlatformViewSet(JMSModelViewSet):
def get_queryset(self): def get_queryset(self):
# 因为没有走分页逻辑,所以需要这里 prefetch # 因为没有走分页逻辑,所以需要这里 prefetch
queryset = super().get_queryset().annotate(assets_amount=Count('assets')).prefetch_related( asset_count_subquery = Asset.objects.filter(platform=OuterRef('pk')).values('platform').annotate(
count=Count('id')).values('count')
queryset = super().get_queryset().annotate(
assets_amount=Coalesce(Subquery(asset_count_subquery), Value(0))).prefetch_related(
'protocols', 'automation', 'labels', 'labels__label' 'protocols', 'automation', 'labels', 'labels__label'
) )
queryset = queryset.filter(type__in=AllTypes.get_types_values()) queryset = queryset.filter(type__in=AllTypes.get_types_values())

View File

@@ -10,7 +10,11 @@ from assets.tasks import execute_asset_automation_task
from common.const.choices import Trigger from common.const.choices import Trigger
from common.db.fields import EncryptJsonDictTextField from common.db.fields import EncryptJsonDictTextField
from ops.mixin import PeriodTaskModelMixin from ops.mixin import PeriodTaskModelMixin
from orgs.mixins.models import OrgModelMixin, JMSOrgBaseModel from orgs.mixins.models import OrgModelMixin, JMSOrgBaseModel, OrgManager
class BaseAutomationManager(OrgManager):
pass
class BaseAutomation(PeriodTaskModelMixin, JMSOrgBaseModel): class BaseAutomation(PeriodTaskModelMixin, JMSOrgBaseModel):
@@ -21,6 +25,8 @@ class BaseAutomation(PeriodTaskModelMixin, JMSOrgBaseModel):
is_active = models.BooleanField(default=True, verbose_name=_("Is active")) is_active = models.BooleanField(default=True, verbose_name=_("Is active"))
params = models.JSONField(default=dict, verbose_name=_("Parameters")) params = models.JSONField(default=dict, verbose_name=_("Parameters"))
objects = BaseAutomationManager.from_queryset(models.QuerySet)()
def __str__(self): def __str__(self):
return self.name + '@' + str(self.created_by) return self.name + '@' + str(self.created_by)

View File

@@ -27,7 +27,7 @@ class DomainSerializer(ResourceLabelsMixin, BulkOrgResourceModelSerializer):
model = Domain model = Domain
fields_mini = ['id', 'name'] fields_mini = ['id', 'name']
fields_small = fields_mini + ['comment'] fields_small = fields_mini + ['comment']
fields_m2m = ['assets', 'gateways', 'assets_amount'] fields_m2m = ['assets', 'gateways', 'labels', 'assets_amount']
read_only_fields = ['date_created'] read_only_fields = ['date_created']
fields = fields_small + fields_m2m + read_only_fields fields = fields_small + fields_m2m + read_only_fields
extra_kwargs = { extra_kwargs = {

View File

@@ -222,9 +222,13 @@ class ResourceActivityAPIView(generics.ListAPIView):
'id', 'datetime', 'r_detail', 'r_detail_id', 'id', 'datetime', 'r_detail', 'r_detail_id',
'r_user', 'r_action', 'r_type' 'r_user', 'r_action', 'r_type'
) )
org_q = Q(org_id=Organization.SYSTEM_ID) | Q(org_id=current_org.id)
if resource_id: org_q = Q()
org_q |= Q(org_id='') | Q(org_id=Organization.ROOT_ID) if not current_org.is_root():
org_q = Q(org_id=Organization.SYSTEM_ID) | Q(org_id=current_org.id)
if resource_id:
org_q |= Q(org_id='') | Q(org_id=Organization.ROOT_ID)
with tmp_to_root_org(): with tmp_to_root_org():
qs1 = self.get_operate_log_qs(fields, limit, org_q, resource_id=resource_id) qs1 = self.get_operate_log_qs(fields, limit, org_q, resource_id=resource_id)
qs2 = self.get_activity_log_qs(fields, limit, org_q, resource_id=resource_id) qs2 = self.get_activity_log_qs(fields, limit, org_q, resource_id=resource_id)

View File

@@ -14,7 +14,7 @@ from audits.handler import (
create_or_update_operate_log, get_instance_dict_from_cache create_or_update_operate_log, get_instance_dict_from_cache
) )
from audits.utils import model_to_dict_for_operate_log as model_to_dict from audits.utils import model_to_dict_for_operate_log as model_to_dict
from common.const.signals import POST_ADD, POST_REMOVE, POST_CLEAR, SKIP_SIGNAL from common.const.signals import POST_ADD, POST_REMOVE, POST_CLEAR, OP_LOG_SKIP_SIGNAL
from common.signals import django_ready from common.signals import django_ready
from jumpserver.utils import current_request from jumpserver.utils import current_request
from ..const import MODELS_NEED_RECORD, ActionChoices from ..const import MODELS_NEED_RECORD, ActionChoices
@@ -77,7 +77,7 @@ def signal_of_operate_log_whether_continue(
condition = True condition = True
if not instance: if not instance:
condition = False condition = False
if instance and getattr(instance, SKIP_SIGNAL, False): if instance and getattr(instance, OP_LOG_SKIP_SIGNAL, False):
condition = False condition = False
# 不记录组件的操作日志 # 不记录组件的操作日志
user = current_request.user if current_request else None user = current_request.user if current_request else None

View File

@@ -14,7 +14,7 @@ router.register(r'login-logs', api.UserLoginLogViewSet, 'login-log')
router.register(r'operate-logs', api.OperateLogViewSet, 'operate-log') router.register(r'operate-logs', api.OperateLogViewSet, 'operate-log')
router.register(r'password-change-logs', api.PasswordChangeLogViewSet, 'password-change-log') router.register(r'password-change-logs', api.PasswordChangeLogViewSet, 'password-change-log')
router.register(r'job-logs', api.JobLogAuditViewSet, 'job-log') router.register(r'job-logs', api.JobLogAuditViewSet, 'job-log')
router.register(r'jobs', api.JobsAuditViewSet, 'jobs') router.register(r'jobs', api.JobsAuditViewSet, 'job')
router.register(r'my-login-logs', api.MyLoginLogViewSet, 'my-login-log') router.register(r'my-login-logs', api.MyLoginLogViewSet, 'my-login-log')
router.register(r'user-sessions', api.UserSessionViewSet, 'user-session') router.register(r'user-sessions', api.UserSessionViewSet, 'user-session')

View File

@@ -15,3 +15,4 @@ from .ssh_key import *
from .sso import * from .sso import *
from .temp_token import * from .temp_token import *
from .token import * from .token import *
from .face import *

View File

@@ -24,11 +24,13 @@ from common.utils.http import is_true, is_false
from orgs.mixins.api import RootOrgViewMixin from orgs.mixins.api import RootOrgViewMixin
from orgs.utils import tmp_to_org from orgs.utils import tmp_to_org
from perms.models import ActionChoices from perms.models import ActionChoices
from terminal.connect_methods import NativeClient, ConnectMethodUtil from terminal.connect_methods import NativeClient, ConnectMethodUtil, WebMethod
from terminal.models import EndpointRule, Endpoint from terminal.models import EndpointRule, Endpoint
from users.const import FileNameConflictResolution from users.const import FileNameConflictResolution
from users.const import RDPSmartSize, RDPColorQuality from users.const import RDPSmartSize, RDPColorQuality
from users.models import Preference from users.models import Preference
from .face import FaceMonitorContext
from ..mixins import AuthFaceMixin
from ..models import ConnectionToken, date_expired_default from ..models import ConnectionToken, date_expired_default
from ..serializers import ( from ..serializers import (
ConnectionTokenSerializer, ConnectionTokenSecretSerializer, ConnectionTokenSerializer, ConnectionTokenSecretSerializer,
@@ -67,6 +69,36 @@ class RDPFileClientProtocolURLMixin:
'bookmarktype:i': '3', 'bookmarktype:i': '3',
'use redirection server name:i': '0', 'use redirection server name:i': '0',
} }
# copy from
# https://learn.microsoft.com/zh-cn/windows-server/administration/performance-tuning/role/remote-desktop/session-hosts
rdp_low_speed_broadband_option = {
"connection type:i": 2,
"disable wallpaper:i": 1,
"bitmapcachepersistenable:i": 1,
"disable full window drag:i": 1,
"disable menu anims:i": 1,
"allow font smoothing:i": 0,
"allow desktop composition:i": 0,
"disable themes:i": 0
}
rdp_high_speed_broadband_option = {
"connection type:i": 4,
"disable wallpaper:i": 0,
"bitmapcachepersistenable:i": 1,
"disable full window drag:i": 1,
"disable menu anims:i": 0,
"allow font smoothing:i": 0,
"allow desktop composition:i": 1,
"disable themes:i": 0
}
RDP_CONNECTION_SPEED_OPTION_MAP = {
"auto": {},
"low_speed_broadband": rdp_low_speed_broadband_option,
"high_speed_broadband": rdp_high_speed_broadband_option,
}
# 设置多屏显示 # 设置多屏显示
multi_mon = is_true(self.request.query_params.get('multi_mon')) multi_mon = is_true(self.request.query_params.get('multi_mon'))
if multi_mon: if multi_mon:
@@ -91,13 +123,15 @@ class RDPFileClientProtocolURLMixin:
# rdp_options['domain:s'] = token.account_ad_domain # rdp_options['domain:s'] = token.account_ad_domain
# 设置宽高 # 设置宽高
height = self.request.query_params.get('height')
width = self.request.query_params.get('width') resolution_value = token.connect_options.get('resolution', 'auto')
if width and height: if resolution_value != 'auto':
rdp_options['desktopwidth:i'] = width width, height = resolution_value.split('x')
rdp_options['desktopheight:i'] = height if width and height:
rdp_options['winposstr:s'] = f'0,1,0,0,{width},{height}' rdp_options['desktopwidth:i'] = width
rdp_options['dynamic resolution:i'] = '0' rdp_options['desktopheight:i'] = height
rdp_options['winposstr:s'] = f'0,1,0,0,{width},{height}'
rdp_options['dynamic resolution:i'] = '0'
color_quality = self.request.query_params.get('rdp_color_quality') color_quality = self.request.query_params.get('rdp_color_quality')
color_quality = color_quality if color_quality else os.getenv('JUMPSERVER_COLOR_DEPTH', RDPColorQuality.HIGH) color_quality = color_quality if color_quality else os.getenv('JUMPSERVER_COLOR_DEPTH', RDPColorQuality.HIGH)
@@ -115,6 +149,8 @@ class RDPFileClientProtocolURLMixin:
rdp = token.asset.platform.protocols.filter(name='rdp').first() rdp = token.asset.platform.protocols.filter(name='rdp').first()
if rdp and rdp.setting.get('console'): if rdp and rdp.setting.get('console'):
rdp_options['administrative session:i'] = '1' rdp_options['administrative session:i'] = '1'
rdp_connection_speed = token.connect_options.get('rdp_connection_speed', 'auto')
rdp_options.update(RDP_CONNECTION_SPEED_OPTION_MAP.get(rdp_connection_speed, {}))
# 文件名 # 文件名
name = token.asset.name name = token.asset.name
@@ -221,6 +257,8 @@ class ExtraActionApiMixin(RDPFileClientProtocolURLMixin):
get_serializer: callable get_serializer: callable
perform_create: callable perform_create: callable
validate_exchange_token: callable validate_exchange_token: callable
need_face_verify: bool
create_face_verify: callable
@action(methods=['POST', 'GET'], detail=True, url_path='rdp-file') @action(methods=['POST', 'GET'], detail=True, url_path='rdp-file')
def get_rdp_file(self, request, *args, **kwargs): def get_rdp_file(self, request, *args, **kwargs):
@@ -280,10 +318,13 @@ class ExtraActionApiMixin(RDPFileClientProtocolURLMixin):
instance.date_expired = date_expired_default() instance.date_expired = date_expired_default()
instance.save() instance.save()
serializer = self.get_serializer(instance) serializer = self.get_serializer(instance)
return Response(serializer.data, status=status.HTTP_201_CREATED) response = Response(serializer.data, status=status.HTTP_201_CREATED)
if self.need_face_verify:
self.create_face_verify(response)
return response
class ConnectionTokenViewSet(ExtraActionApiMixin, RootOrgViewMixin, JMSModelViewSet): class ConnectionTokenViewSet(AuthFaceMixin, ExtraActionApiMixin, RootOrgViewMixin, JMSModelViewSet):
filterset_fields = ( filterset_fields = (
'user_display', 'asset_display' 'user_display', 'asset_display'
) )
@@ -304,6 +345,8 @@ class ConnectionTokenViewSet(ExtraActionApiMixin, RootOrgViewMixin, JMSModelView
'get_client_protocol_url': 'authentication.add_connectiontoken', 'get_client_protocol_url': 'authentication.add_connectiontoken',
} }
input_username = '' input_username = ''
need_face_verify = False
face_monitor_token = ''
def get_queryset(self): def get_queryset(self):
queryset = ConnectionToken.objects \ queryset = ConnectionToken.objects \
@@ -355,8 +398,9 @@ class ConnectionTokenViewSet(ExtraActionApiMixin, RootOrgViewMixin, JMSModelView
asset = data.get('asset') asset = data.get('asset')
account_name = data.get('account') account_name = data.get('account')
protocol = data.get('protocol') protocol = data.get('protocol')
connect_method = data.get('connect_method')
self.input_username = self.get_input_username(data) self.input_username = self.get_input_username(data)
_data = self._validate(user, asset, account_name, protocol) _data = self._validate(user, asset, account_name, protocol, connect_method)
data.update(_data) data.update(_data)
return serializer return serializer
@@ -364,12 +408,12 @@ class ConnectionTokenViewSet(ExtraActionApiMixin, RootOrgViewMixin, JMSModelView
user = token.user user = token.user
asset = token.asset asset = token.asset
account_name = token.account account_name = token.account
_data = self._validate(user, asset, account_name, token.protocol) _data = self._validate(user, asset, account_name, token.protocol, token.connect_method)
for k, v in _data.items(): for k, v in _data.items():
setattr(token, k, v) setattr(token, k, v)
return token return token
def _validate(self, user, asset, account_name, protocol): def _validate(self, user, asset, account_name, protocol, connect_method):
data = dict() data = dict()
data['org_id'] = asset.org_id data['org_id'] = asset.org_id
data['user'] = user data['user'] = user
@@ -385,10 +429,16 @@ class ConnectionTokenViewSet(ExtraActionApiMixin, RootOrgViewMixin, JMSModelView
if account.username != AliasAccount.INPUT: if account.username != AliasAccount.INPUT:
data['input_username'] = '' data['input_username'] = ''
ticket = self._validate_acl(user, asset, account) ticket = self._validate_acl(user, asset, account, connect_method)
if ticket: if ticket:
data['from_ticket'] = ticket data['from_ticket'] = ticket
if ticket or self.need_face_verify:
data['is_active'] = False data['is_active'] = False
if self.face_monitor_token:
FaceMonitorContext.get_or_create_context(self.face_monitor_token,
self.request.user.id)
data['face_monitor_token'] = self.face_monitor_token
return data return data
@staticmethod @staticmethod
@@ -417,7 +467,7 @@ class ConnectionTokenViewSet(ExtraActionApiMixin, RootOrgViewMixin, JMSModelView
after=after, object_name=object_name after=after, object_name=object_name
) )
def _validate_acl(self, user, asset, account): def _validate_acl(self, user, asset, account, connect_method):
from acls.models import LoginAssetACL from acls.models import LoginAssetACL
kwargs = {'user': user, 'asset': asset, 'account': account} kwargs = {'user': user, 'asset': asset, 'account': account}
if account.username == AliasAccount.INPUT: if account.username == AliasAccount.INPUT:
@@ -444,6 +494,26 @@ class ConnectionTokenViewSet(ExtraActionApiMixin, RootOrgViewMixin, JMSModelView
assignees=acl.reviewers.all(), org_id=asset.org_id assignees=acl.reviewers.all(), org_id=asset.org_id
) )
return ticket return ticket
if acl.is_action(acl.ActionChoices.face_verify):
if not self.request.query_params.get('face_verify'):
msg = _('ACL action is face verify')
raise JMSException(code='acl_face_verify', detail=msg)
self.need_face_verify = True
if acl.is_action(acl.ActionChoices.face_online):
if connect_method not in [WebMethod.web_cli, WebMethod.web_gui]:
msg = _('ACL action not supported for this asset')
raise JMSException(detail=msg, code='acl_face_online_not_supported')
face_verify = self.request.query_params.get('face_verify')
face_monitor_token = self.request.query_params.get('face_monitor_token')
if not face_verify or not face_monitor_token:
msg = _('ACL action is face online')
raise JMSException(code='acl_face_online', detail=msg)
self.need_face_verify = True
self.face_monitor_token = face_monitor_token
if acl.is_action(acl.ActionChoices.notice): if acl.is_action(acl.ActionChoices.notice):
reviewers = acl.reviewers.all() reviewers = acl.reviewers.all()
if not reviewers: if not reviewers:
@@ -455,9 +525,22 @@ class ConnectionTokenViewSet(ExtraActionApiMixin, RootOrgViewMixin, JMSModelView
reviewer, asset, user, account, self.input_username reviewer, asset, user, account, self.input_username
).publish_async() ).publish_async()
def create_face_verify(self, response):
if not self.request.user.face_vector:
raise JMSException(code='no_face_feature', detail=_('No available face feature'))
connection_token_id = response.data.get('id')
context_data = {
"action": "login_asset",
"connection_token_id": connection_token_id,
}
face_verify_token = self.create_face_verify_context(context_data)
response.data['face_token'] = face_verify_token
def create(self, request, *args, **kwargs): def create(self, request, *args, **kwargs):
try: try:
response = super().create(request, *args, **kwargs) response = super().create(request, *args, **kwargs)
if self.need_face_verify:
self.create_face_verify(response)
except JMSException as e: except JMSException as e:
data = {'code': e.detail.code, 'detail': e.detail} data = {'code': e.detail.code, 'detail': e.detail}
return Response(data, status=e.status_code) return Response(data, status=e.status_code)

View File

@@ -0,0 +1,256 @@
from django.core.cache import cache
from django.utils.translation import gettext as _
from rest_framework.generics import CreateAPIView, RetrieveAPIView
from rest_framework.response import Response
from rest_framework.serializers import ValidationError
from rest_framework.permissions import AllowAny
from rest_framework.exceptions import NotFound
from common.permissions import IsServiceAccount
from common.utils import get_logger, get_object_or_none
from orgs.utils import tmp_to_root_org
from terminal.api.session.task import create_sessions_tasks
from users.models import User
from .. import serializers
from ..mixins import AuthMixin
from ..const import FACE_CONTEXT_CACHE_KEY_PREFIX, FACE_SESSION_KEY, FACE_CONTEXT_CACHE_TTL, FaceMonitorActionChoices
from ..models import ConnectionToken
from ..serializers.face import FaceMonitorCallbackSerializer, FaceMonitorContextSerializer
logger = get_logger(__name__)
__all__ = [
'FaceCallbackApi',
'FaceContextApi',
'FaceMonitorContext',
'FaceMonitorContextApi',
'FaceMonitorCallbackApi'
]
class FaceCallbackApi(AuthMixin, CreateAPIView):
permission_classes = (IsServiceAccount,)
serializer_class = serializers.FaceCallbackSerializer
def perform_create(self, serializer):
token = serializer.validated_data.get('token')
context = self._get_context_from_cache(token)
if not serializer.validated_data.get('success', False):
self._update_context_with_error(
context,
serializer.validated_data.get('error_message', 'Unknown error')
)
return Response(status=200)
face_code = serializer.validated_data.get('face_code')
if not face_code:
self._update_context_with_error(context, "missing field 'face_code'")
raise ValidationError({'error': "missing field 'face_code'"})
try:
self._handle_success(context, face_code)
except Exception as e:
self._update_context_with_error(context, str(e))
return Response(status=200)
@staticmethod
def get_face_cache_key(token):
return f"{FACE_CONTEXT_CACHE_KEY_PREFIX}_{token}"
def _get_context_from_cache(self, token):
cache_key = self.get_face_cache_key(token)
context = cache.get(cache_key)
if not context:
raise ValidationError({'error': "token not exists or expired"})
return context
def _update_context_with_error(self, context, error_message):
context.update({
'is_finished': True,
'success': False,
'error_message': error_message,
})
self._update_cache(context)
def _update_cache(self, context):
cache_key = self.get_face_cache_key(context['token'])
cache.set(cache_key, context, FACE_CONTEXT_CACHE_TTL)
def _handle_success(self, context, face_code):
context.update({
'is_finished': True,
'success': True,
'face_code': face_code
})
action = context.get('action', None)
if action == 'login_asset':
user_id = context.get('user_id')
user = User.objects.get(id=user_id)
if user.check_face(face_code):
with tmp_to_root_org():
connection_token_id = context.get('connection_token_id')
token = ConnectionToken.objects.filter(id=connection_token_id).first()
token.is_active = True
token.save()
else:
context.update({
'success': False,
'error_message': _('Facial comparison failed')
})
self._update_cache(context)
class FaceContextApi(AuthMixin, RetrieveAPIView, CreateAPIView):
permission_classes = (AllowAny,)
face_token_session_key = FACE_SESSION_KEY
@staticmethod
def get_face_cache_key(token):
return f"{FACE_CONTEXT_CACHE_KEY_PREFIX}_{token}"
def new_face_context(self):
return self.create_face_verify_context()
def post(self, request, *args, **kwargs):
token = self.new_face_context()
return Response({'token': token})
def get(self, request, *args, **kwargs):
token = self.request.session.get(self.face_token_session_key)
cache_key = self.get_face_cache_key(token)
context = cache.get(cache_key)
if not context:
raise NotFound({'error': "Token does not exist or has expired."})
return Response({
"is_finished": context.get('is_finished', False),
"success": context.get('success', False),
"error_message": _(context.get("error_message", ''))
})
class FaceMonitorContext:
def __init__(self, token, user_id, session_ids=None):
self.token = token
self.user_id = user_id
if session_ids is None:
self.session_ids = []
else:
self.session_ids = session_ids
@classmethod
def get_cache_key(cls, token):
return 'FACE_MONITOR_CONTEXT_{}'.format(token)
@classmethod
def get_or_create_context(cls, token, user_id):
context = cls.get(token)
if not context:
context = FaceMonitorContext(token=token,
user_id=user_id)
context.save()
return context
def add_session(self, session_id):
self.session_ids.append(session_id)
self.save()
@classmethod
def get(cls, token):
cache_key = cls.get_cache_key(token)
return cache.get(cache_key, None)
def save(self):
cache_key = self.get_cache_key(self.token)
cache.set(cache_key, self)
def close(self):
self.terminal_sessions()
self._destroy()
def _destroy(self):
cache_key = self.get_cache_key(self.token)
cache.delete(cache_key)
def pause_sessions(self):
self._send_task('lock_session')
def resume_sessions(self):
self._send_task('unlock_session')
def terminal_sessions(self):
self._send_task("kill_session")
def _send_task(self, task_name):
create_sessions_tasks(self.session_ids, 'facelive', task_name=task_name)
class FaceMonitorContextApi(CreateAPIView):
permission_classes = (IsServiceAccount,)
serializer_class = FaceMonitorContextSerializer
def perform_create(self, serializer):
face_monitor_token = serializer.validated_data.get('face_monitor_token')
session_id = serializer.validated_data.get('session_id')
context = FaceMonitorContext.get(face_monitor_token)
context.add_session(session_id)
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
self.perform_create(serializer)
return Response(status=201)
class FaceMonitorCallbackApi(CreateAPIView):
permission_classes = (IsServiceAccount,)
serializer_class = FaceMonitorCallbackSerializer
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
token = serializer.validated_data.get('token')
context = FaceMonitorContext.get(token=token)
is_finished = serializer.validated_data.get('is_finished')
if is_finished:
context.close()
return Response(status=200)
action = serializer.validated_data.get('action')
if action == FaceMonitorActionChoices.Verify:
user = get_object_or_none(User, pk=context.user_id)
face_codes = serializer.validated_data.get('face_codes')
if not user:
context.save()
return Response(data={'msg': 'user {} not found'
.format(context.user_id)}, status=400)
if not face_codes or not self._check_face_codes(face_codes, user):
context.save()
return Response(data={'msg': 'face codes not matched'}, status=400)
if action == FaceMonitorActionChoices.Pause:
context.pause_sessions()
if action == FaceMonitorActionChoices.Resume:
context.resume_sessions()
context.save()
return Response(status=200)
@staticmethod
def _check_face_codes(face_codes, user):
matched = False
for face_code in face_codes:
matched = user.check_face(face_code,
distance_threshold=0.45,
similarity_threshold=0.92)
if matched:
break
return matched

View File

@@ -1,8 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# #
import uuid
from django.core.cache import cache
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from rest_framework import exceptions from rest_framework import exceptions
@@ -10,15 +7,12 @@ from rest_framework.generics import CreateAPIView, RetrieveAPIView
from rest_framework.permissions import AllowAny from rest_framework.permissions import AllowAny
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.serializers import ValidationError from rest_framework.serializers import ValidationError
from rest_framework.exceptions import NotFound
from common.exceptions import JMSException, UnexpectError from common.exceptions import JMSException, UnexpectError
from common.permissions import WithBootstrapToken, IsServiceAccount
from common.utils import get_logger from common.utils import get_logger
from users.models.user import User from users.models.user import User
from .. import errors from .. import errors
from .. import serializers from .. import serializers
from ..const import MFA_FACE_CONTEXT_CACHE_KEY_PREFIX, MFA_FACE_SESSION_KEY, MFA_FACE_CONTEXT_CACHE_TTL
from ..errors import SessionEmptyError from ..errors import SessionEmptyError
from ..mixins import AuthMixin from ..mixins import AuthMixin
@@ -26,101 +20,9 @@ logger = get_logger(__name__)
__all__ = [ __all__ = [
'MFAChallengeVerifyApi', 'MFASendCodeApi', 'MFAChallengeVerifyApi', 'MFASendCodeApi',
'MFAFaceCallbackApi', 'MFAFaceContextApi'
] ]
class MFAFaceCallbackApi(AuthMixin, CreateAPIView):
permission_classes = (IsServiceAccount,)
serializer_class = serializers.MFAFaceCallbackSerializer
def perform_create(self, serializer):
token = serializer.validated_data.get('token')
context = self._get_context_from_cache(token)
if not serializer.validated_data.get('success', False):
self._update_context_with_error(
context,
serializer.validated_data.get('error_message', 'Unknown error')
)
return Response(status=200)
face_code = serializer.validated_data.get('face_code')
if not face_code:
self._update_context_with_error(context, "missing field 'face_code'")
raise ValidationError({'error': "missing field 'face_code'"})
self._handle_success(context, face_code)
return Response(status=200)
@staticmethod
def get_face_cache_key(token):
return f"{MFA_FACE_CONTEXT_CACHE_KEY_PREFIX}_{token}"
def _get_context_from_cache(self, token):
cache_key = self.get_face_cache_key(token)
context = cache.get(cache_key)
if not context:
raise ValidationError({'error': "token not exists or expired"})
return context
def _update_context_with_error(self, context, error_message):
context.update({
'is_finished': True,
'success': False,
'error_message': error_message,
})
self._update_cache(context)
def _update_cache(self, context):
cache_key = self.get_face_cache_key(context['token'])
cache.set(cache_key, context, MFA_FACE_CONTEXT_CACHE_TTL)
def _handle_success(self, context, face_code):
context.update({
'is_finished': True,
'success': True,
'face_code': face_code
})
self._update_cache(context)
class MFAFaceContextApi(AuthMixin, RetrieveAPIView, CreateAPIView):
permission_classes = (AllowAny,)
face_token_session_key = MFA_FACE_SESSION_KEY
@staticmethod
def get_face_cache_key(token):
return f"{MFA_FACE_CONTEXT_CACHE_KEY_PREFIX}_{token}"
def new_face_context(self):
token = uuid.uuid4().hex
cache_key = self.get_face_cache_key(token)
face_context = {
"token": token,
"is_finished": False
}
cache.set(cache_key, face_context, MFA_FACE_CONTEXT_CACHE_TTL)
self.request.session[self.face_token_session_key] = token
return token
def post(self, request, *args, **kwargs):
token = self.new_face_context()
return Response({'token': token})
def get(self, request, *args, **kwargs):
token = self.request.session.get('mfa_face_token')
cache_key = self.get_face_cache_key(token)
context = cache.get(cache_key)
if not context:
raise NotFound({'error': "Token does not exist or has expired."})
return Response({
"is_finished": context.get('is_finished', False),
"success": context.get('success', False),
"error_message": context.get("error_message", '')
})
# MFASelectAPi 原来的名字 # MFASelectAPi 原来的名字

View File

@@ -23,10 +23,9 @@ class JMSBaseAuthBackend:
Reject users with is_valid=False. Custom user models that don't have Reject users with is_valid=False. Custom user models that don't have
that attribute are allowed. that attribute are allowed.
""" """
# 在 check_user_auth 中进行了校验,可以返回对应的错误信息 # 三方用户认证完成后,在后续的 get_user 获取逻辑中,也应该需要检查用户是否有效
# is_valid = getattr(user, 'is_valid', None) is_valid = getattr(user, 'is_valid', None)
# return is_valid or is_valid is None return is_valid or is_valid is None
return True
# allow user to authenticate # allow user to authenticate
def username_allow_authenticate(self, username): def username_allow_authenticate(self, username):
@@ -52,6 +51,14 @@ class JMSBaseAuthBackend:
logger.info(info) logger.info(info)
return allow return allow
def get_user(self, user_id):
""" 三方用户认证成功后 request.user 赋值时会调用 backend 的当前方法获取用户 """
try:
user = UserModel._default_manager.get(pk=user_id)
except UserModel.DoesNotExist:
return None
return user if self.user_can_authenticate(user) else None
class JMSModelBackend(JMSBaseAuthBackend, ModelBackend): class JMSModelBackend(JMSBaseAuthBackend, ModelBackend):
pass pass

View File

@@ -1,12 +1,13 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# #
from django.urls import path
import django_cas_ng.views import django_cas_ng.views
from django.urls import path
from .views import CASLoginView from .views import CASLoginView, CASCallbackClientView
urlpatterns = [ urlpatterns = [
path('login/', CASLoginView.as_view(), name='cas-login'), path('login/', CASLoginView.as_view(), name='cas-login'),
path('logout/', django_cas_ng.views.LogoutView.as_view(), name='cas-logout'), path('logout/', django_cas_ng.views.LogoutView.as_view(), name='cas-logout'),
path('callback/', django_cas_ng.views.CallbackView.as_view(), name='cas-proxy-callback'), path('callback/', django_cas_ng.views.CallbackView.as_view(), name='cas-proxy-callback'),
path('login/client', CASCallbackClientView.as_view(), name='cas-proxy-callback-client'),
] ]

View File

@@ -1,9 +1,12 @@
from django_cas_ng.views import LoginView
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from django.views.generic import View
from django_cas_ng.views import LoginView
__all__ = ['LoginView'] __all__ = ['LoginView']
from authentication.views.utils import redirect_to_guard_view
class CASLoginView(LoginView): class CASLoginView(LoginView):
def get(self, request): def get(self, request):
@@ -13,3 +16,8 @@ class CASLoginView(LoginView):
return HttpResponseRedirect('/') return HttpResponseRedirect('/')
class CASCallbackClientView(View):
http_method_names = ['get', ]
def get(self, request):
return redirect_to_guard_view(query_string='next=client')

View File

@@ -5,7 +5,7 @@ from django.utils.translation import gettext_lazy as _
from authentication.signals import user_auth_failed, user_auth_success from authentication.signals import user_auth_failed, user_auth_success
from common.utils import get_logger from common.utils import get_logger
from .base import JMSModelBackend from .base import JMSBaseAuthBackend
logger = get_logger(__file__) logger = get_logger(__file__)
@@ -20,9 +20,10 @@ if settings.AUTH_CUSTOM:
logger.warning('Import custom auth method failed: {}, Maybe not enabled'.format(e)) logger.warning('Import custom auth method failed: {}, Maybe not enabled'.format(e))
class CustomAuthBackend(JMSModelBackend): class CustomAuthBackend(JMSBaseAuthBackend):
def is_enabled(self): @staticmethod
def is_enabled():
return settings.AUTH_CUSTOM and callable(custom_authenticate_method) return settings.AUTH_CUSTOM and callable(custom_authenticate_method)
@staticmethod @staticmethod
@@ -35,10 +36,10 @@ class CustomAuthBackend(JMSModelBackend):
) )
return user, created return user, created
def authenticate(self, request, username=None, password=None, **kwargs): def authenticate(self, request, username=None, password=None):
try: try:
userinfo: dict = custom_authenticate_method( userinfo: dict = custom_authenticate_method(
username=username, password=password, **kwargs username=username, password=password
) )
user, created = self.get_or_create_user_from_userinfo(userinfo) user, created = self.get_or_create_user_from_userinfo(userinfo)
except Exception as e: except Exception as e:

View File

@@ -1,5 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# #
import base64
import requests import requests
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@@ -17,7 +18,7 @@ from common.exceptions import JMSException
from .signals import ( from .signals import (
oauth2_create_or_update_user oauth2_create_or_update_user
) )
from ..base import JMSModelBackend from ..base import JMSBaseAuthBackend
__all__ = ['OAuth2Backend'] __all__ = ['OAuth2Backend']
@@ -25,7 +26,7 @@ __all__ = ['OAuth2Backend']
logger = get_logger(__name__) logger = get_logger(__name__)
class OAuth2Backend(JMSModelBackend): class OAuth2Backend(JMSBaseAuthBackend):
@staticmethod @staticmethod
def is_enabled(): def is_enabled():
return settings.AUTH_OAUTH2 return settings.AUTH_OAUTH2
@@ -67,15 +68,7 @@ class OAuth2Backend(JMSModelBackend):
response_data = response_data['data'] response_data = response_data['data']
return response_data return response_data
@staticmethod def authenticate(self, request, code=None):
def get_query_dict(response_data, query_dict):
query_dict.update({
'uid': response_data.get('uid', ''),
'access_token': response_data.get('access_token', '')
})
return query_dict
def authenticate(self, request, code=None, **kwargs):
log_prompt = "Process authenticate [OAuth2Backend]: {}" log_prompt = "Process authenticate [OAuth2Backend]: {}"
logger.debug(log_prompt.format('Start')) logger.debug(log_prompt.format('Start'))
if code is None: if code is None:
@@ -83,29 +76,31 @@ class OAuth2Backend(JMSModelBackend):
return None return None
query_dict = { query_dict = {
'client_id': settings.AUTH_OAUTH2_CLIENT_ID, 'grant_type': 'authorization_code', 'code': code,
'client_secret': settings.AUTH_OAUTH2_CLIENT_SECRET,
'grant_type': 'authorization_code',
'code': code,
'redirect_uri': build_absolute_uri( 'redirect_uri': build_absolute_uri(
request, path=reverse(settings.AUTH_OAUTH2_AUTH_LOGIN_CALLBACK_URL_NAME) request, path=reverse(settings.AUTH_OAUTH2_AUTH_LOGIN_CALLBACK_URL_NAME)
) )
} }
if '?' in settings.AUTH_OAUTH2_ACCESS_TOKEN_ENDPOINT: separator = '&' if '?' in settings.AUTH_OAUTH2_ACCESS_TOKEN_ENDPOINT else '?'
separator = '&'
else:
separator = '?'
access_token_url = '{url}{separator}{query}'.format( access_token_url = '{url}{separator}{query}'.format(
url=settings.AUTH_OAUTH2_ACCESS_TOKEN_ENDPOINT, separator=separator, query=urlencode(query_dict) url=settings.AUTH_OAUTH2_ACCESS_TOKEN_ENDPOINT,
separator=separator, query=urlencode(query_dict)
) )
# token_method -> get, post(post_data), post_json # token_method -> get, post(post_data), post_json
token_method = settings.AUTH_OAUTH2_ACCESS_TOKEN_METHOD.lower() token_method = settings.AUTH_OAUTH2_ACCESS_TOKEN_METHOD.lower()
logger.debug(log_prompt.format('Call the access token endpoint[method: %s]' % token_method)) logger.debug(log_prompt.format('Call the access token endpoint[method: %s]' % token_method))
encoded_credentials = base64.b64encode(
f"{settings.AUTH_OAUTH2_CLIENT_ID}:{settings.AUTH_OAUTH2_CLIENT_SECRET}".encode()
).decode()
headers = { headers = {
'Accept': 'application/json' 'Accept': 'application/json', 'Authorization': f'Basic {encoded_credentials}'
} }
if token_method.startswith('post'): if token_method.startswith('post'):
body_key = 'json' if token_method.endswith('json') else 'data' body_key = 'json' if token_method.endswith('json') else 'data'
query_dict.update({
'client_id': settings.AUTH_OAUTH2_CLIENT_ID,
'client_secret': settings.AUTH_OAUTH2_CLIENT_SECRET,
})
access_token_response = requests.post( access_token_response = requests.post(
access_token_url, headers=headers, **{body_key: query_dict} access_token_url, headers=headers, **{body_key: query_dict}
) )
@@ -121,22 +116,12 @@ class OAuth2Backend(JMSModelBackend):
logger.error(log_prompt.format(error)) logger.error(log_prompt.format(error))
return None return None
query_dict = self.get_query_dict(response_data, query_dict)
headers = { headers = {
'Accept': 'application/json', 'Accept': 'application/json',
'Authorization': 'Bearer {}'.format(response_data.get('access_token', '')) 'Authorization': 'Bearer {}'.format(response_data.get('access_token', ''))
} }
logger.debug(log_prompt.format('Get userinfo endpoint')) logger.debug(log_prompt.format('Get userinfo endpoint'))
if '?' in settings.AUTH_OAUTH2_PROVIDER_USERINFO_ENDPOINT: userinfo_url = settings.AUTH_OAUTH2_PROVIDER_USERINFO_ENDPOINT
separator = '&'
else:
separator = '?'
userinfo_url = '{url}{separator}{query}'.format(
url=settings.AUTH_OAUTH2_PROVIDER_USERINFO_ENDPOINT, separator=separator,
query=urlencode(query_dict)
)
userinfo_response = requests.get(userinfo_url, headers=headers) userinfo_response = requests.get(userinfo_url, headers=headers)
try: try:
userinfo_response.raise_for_status() userinfo_response.raise_for_status()

View File

@@ -4,9 +4,9 @@ from django.urls import path
from . import views from . import views
urlpatterns = [ urlpatterns = [
path('login/', views.OAuth2AuthRequestView.as_view(), name='login'), path('login/', views.OAuth2AuthRequestView.as_view(), name='login'),
path('callback/', views.OAuth2AuthCallbackView.as_view(), name='login-callback'), path('callback/', views.OAuth2AuthCallbackView.as_view(), name='login-callback'),
path('callback/client/', views.OAuth2AuthCallbackClientView.as_view(), name='login-callback-client'),
path('logout/', views.OAuth2EndSessionView.as_view(), name='logout') path('logout/', views.OAuth2EndSessionView.as_view(), name='logout')
] ]

View File

@@ -1,16 +1,16 @@
from django.views import View
from django.conf import settings from django.conf import settings
from django.contrib import auth from django.contrib import auth
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from django.urls import reverse from django.urls import reverse
from django.utils.http import urlencode from django.utils.http import urlencode
from django.views import View
from authentication.mixins import authenticate
from authentication.utils import build_absolute_uri from authentication.utils import build_absolute_uri
from authentication.views.mixins import FlashMessageMixin from authentication.views.mixins import FlashMessageMixin
from authentication.mixins import authenticate from authentication.views.utils import redirect_to_guard_view
from common.utils import get_logger from common.utils import get_logger
logger = get_logger(__file__) logger = get_logger(__file__)
@@ -67,6 +67,13 @@ class OAuth2AuthCallbackView(View, FlashMessageMixin):
return HttpResponseRedirect(redirect_url) return HttpResponseRedirect(redirect_url)
class OAuth2AuthCallbackClientView(View):
http_method_names = ['get', ]
def get(self, request):
return redirect_to_guard_view(query_string='next=client')
class OAuth2EndSessionView(View): class OAuth2EndSessionView(View):
http_method_names = ['get', 'post', ] http_method_names = ['get', 'post', ]

View File

@@ -13,10 +13,8 @@ import requests
from django.conf import settings from django.conf import settings
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.backends import ModelBackend from django.contrib.auth.backends import ModelBackend
from django.core.exceptions import SuspiciousOperation
from django.db import transaction from django.db import transaction
from django.urls import reverse from django.urls import reverse
from rest_framework.exceptions import ParseError
from authentication.signals import user_auth_success, user_auth_failed from authentication.signals import user_auth_success, user_auth_failed
from authentication.utils import build_absolute_uri_for_oidc from authentication.utils import build_absolute_uri_for_oidc
@@ -88,7 +86,7 @@ class OIDCAuthCodeBackend(OIDCBaseBackend):
""" """
@ssl_verification @ssl_verification
def authenticate(self, request, nonce=None, code_verifier=None, **kwargs): def authenticate(self, request, nonce=None, code_verifier=None):
""" Authenticates users in case of the OpenID Connect Authorization code flow. """ """ Authenticates users in case of the OpenID Connect Authorization code flow. """
log_prompt = "Process authenticate [OIDCAuthCodeBackend]: {}" log_prompt = "Process authenticate [OIDCAuthCodeBackend]: {}"
logger.debug(log_prompt.format('start')) logger.debug(log_prompt.format('start'))
@@ -107,7 +105,7 @@ class OIDCAuthCodeBackend(OIDCBaseBackend):
# parameters because we won't be able to get a valid token for the user in that case. # parameters because we won't be able to get a valid token for the user in that case.
if (state is None and settings.AUTH_OPENID_USE_STATE) or code is None: if (state is None and settings.AUTH_OPENID_USE_STATE) or code is None:
logger.debug(log_prompt.format('Authorization code or state value is missing')) logger.debug(log_prompt.format('Authorization code or state value is missing'))
raise SuspiciousOperation('Authorization code or state value is missing') return
# Prepares the token payload that will be used to request an authentication token to the # Prepares the token payload that will be used to request an authentication token to the
# token endpoint of the OIDC provider. # token endpoint of the OIDC provider.
@@ -165,7 +163,7 @@ class OIDCAuthCodeBackend(OIDCBaseBackend):
error = "Json token response error, token response " \ error = "Json token response error, token response " \
"content is: {}, error is: {}".format(token_response.content, str(e)) "content is: {}, error is: {}".format(token_response.content, str(e))
logger.debug(log_prompt.format(error)) logger.debug(log_prompt.format(error))
raise ParseError(error) return
# Validates the token. # Validates the token.
logger.debug(log_prompt.format('Validate ID Token')) logger.debug(log_prompt.format('Validate ID Token'))
@@ -206,7 +204,7 @@ class OIDCAuthCodeBackend(OIDCBaseBackend):
error = "Json claims response error, claims response " \ error = "Json claims response error, claims response " \
"content is: {}, error is: {}".format(claims_response.content, str(e)) "content is: {}, error is: {}".format(claims_response.content, str(e))
logger.debug(log_prompt.format(error)) logger.debug(log_prompt.format(error))
raise ParseError(error) return
logger.debug(log_prompt.format('Get or create user from claims')) logger.debug(log_prompt.format('Get or create user from claims'))
user, created = self.get_or_create_user_from_claims(request, claims) user, created = self.get_or_create_user_from_claims(request, claims)
@@ -235,15 +233,15 @@ class OIDCAuthCodeBackend(OIDCBaseBackend):
class OIDCAuthPasswordBackend(OIDCBaseBackend): class OIDCAuthPasswordBackend(OIDCBaseBackend):
@ssl_verification @ssl_verification
def authenticate(self, request, username=None, password=None, **kwargs): def authenticate(self, request, username=None, password=None):
try: try:
return self._authenticate(request, username, password, **kwargs) return self._authenticate(request, username, password)
except Exception as e: except Exception as e:
error = f'Authenticate exception: {e}' error = f'Authenticate exception: {e}'
logger.error(error, exc_info=True) logger.error(error, exc_info=True)
return return
def _authenticate(self, request, username=None, password=None, **kwargs): def _authenticate(self, request, username=None, password=None):
""" """
https://oauth.net/2/ https://oauth.net/2/
https://aaronparecki.com/oauth-2-simplified/#password https://aaronparecki.com/oauth-2-simplified/#password

View File

@@ -4,7 +4,9 @@
import warnings import warnings
import contextlib import contextlib
import requests import requests
import inspect
from functools import wraps
from django.conf import settings from django.conf import settings
from urllib3.exceptions import InsecureRequestWarning from urllib3.exceptions import InsecureRequestWarning
@@ -52,6 +54,7 @@ def no_ssl_verification():
def ssl_verification(func): def ssl_verification(func):
@wraps(func)
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
if not settings.AUTH_OPENID_IGNORE_SSL_VERIFICATION: if not settings.AUTH_OPENID_IGNORE_SSL_VERIFICATION:
return func(*args, **kwargs) return func(*args, **kwargs)

View File

@@ -12,9 +12,9 @@ from django.urls import path
from . import views from . import views
urlpatterns = [ urlpatterns = [
path('login/', views.OIDCAuthRequestView.as_view(), name='login'), path('login/', views.OIDCAuthRequestView.as_view(), name='login'),
path('callback/', views.OIDCAuthCallbackView.as_view(), name='login-callback'), path('callback/', views.OIDCAuthCallbackView.as_view(), name='login-callback'),
path('callback/client/', views.OIDCAuthCallbackClientView.as_view(), name='login-callback-client'),
path('logout/', views.OIDCEndSessionView.as_view(), name='logout'), path('logout/', views.OIDCEndSessionView.as_view(), name='logout'),
] ]

View File

@@ -22,13 +22,14 @@ from django.http import HttpResponseRedirect, QueryDict
from django.urls import reverse from django.urls import reverse
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
from django.utils.http import urlencode from django.utils.http import urlencode
from django.views.generic import View
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic import View
from authentication.utils import build_absolute_uri_for_oidc from authentication.utils import build_absolute_uri_for_oidc
from authentication.views.mixins import FlashMessageMixin from authentication.views.mixins import FlashMessageMixin
from common.utils import safe_next_url from common.utils import safe_next_url
from .utils import get_logger from .utils import get_logger
from ...views.utils import redirect_to_guard_view
logger = get_logger(__file__) logger = get_logger(__file__)
@@ -208,6 +209,13 @@ class OIDCAuthCallbackView(View, FlashMessageMixin):
return HttpResponseRedirect(settings.AUTH_OPENID_AUTHENTICATION_FAILURE_REDIRECT_URI) return HttpResponseRedirect(settings.AUTH_OPENID_AUTHENTICATION_FAILURE_REDIRECT_URI)
class OIDCAuthCallbackClientView(View):
http_method_names = ['get', ]
def get(self, request):
return redirect_to_guard_view(query_string='next=client')
class OIDCEndSessionView(View): class OIDCEndSessionView(View):
""" Allows to end the session of any user authenticated using OpenID Connect. """ Allows to end the session of any user authenticated using OpenID Connect.

View File

@@ -13,7 +13,7 @@ class Passkey(JMSBaseModel):
added_on = models.DateTimeField(auto_now_add=True, verbose_name=_("Added on")) added_on = models.DateTimeField(auto_now_add=True, verbose_name=_("Added on"))
date_last_used = models.DateTimeField(null=True, default=None, verbose_name=_("Date last used")) date_last_used = models.DateTimeField(null=True, default=None, verbose_name=_("Date last used"))
credential_id = models.CharField(max_length=255, unique=True, null=False, verbose_name=_("Credential ID")) credential_id = models.CharField(max_length=255, unique=True, null=False, verbose_name=_("Credential ID"))
token = models.CharField(max_length=255, null=False, verbose_name=_("Token")) token = models.CharField(max_length=1024, null=False, verbose_name=_("Token"))
def __str__(self): def __str__(self):
return self.name return self.name

View File

@@ -51,10 +51,10 @@ class RadiusBaseBackend(CreateUserMixin, JMSBaseAuthBackend):
class RadiusBackend(RadiusBaseBackend, RADIUSBackend): class RadiusBackend(RadiusBaseBackend, RADIUSBackend):
def authenticate(self, request, username='', password='', **kwargs): def authenticate(self, request, username='', password=''):
return super().authenticate(request, username=username, password=password) return super().authenticate(request, username=username, password=password)
class RadiusRealmBackend(RadiusBaseBackend, RADIUSRealmBackend): class RadiusRealmBackend(RadiusBaseBackend, RADIUSRealmBackend):
def authenticate(self, request, username='', password='', realm=None, **kwargs): def authenticate(self, request, username='', password='', realm=None):
return super().authenticate(request, username=username, password=password, realm=realm) return super().authenticate(request, username=username, password=password, realm=realm)

View File

@@ -10,14 +10,14 @@ from .signals import (
saml2_create_or_update_user saml2_create_or_update_user
) )
from authentication.signals import user_auth_failed, user_auth_success from authentication.signals import user_auth_failed, user_auth_success
from ..base import JMSModelBackend from ..base import JMSBaseAuthBackend
__all__ = ['SAML2Backend'] __all__ = ['SAML2Backend']
logger = get_logger(__name__) logger = get_logger(__name__)
class SAML2Backend(JMSModelBackend): class SAML2Backend(JMSBaseAuthBackend):
@staticmethod @staticmethod
def is_enabled(): def is_enabled():
return settings.AUTH_SAML2 return settings.AUTH_SAML2
@@ -42,7 +42,7 @@ class SAML2Backend(JMSModelBackend):
) )
return user, created return user, created
def authenticate(self, request, saml_user_data=None, **kwargs): def authenticate(self, request, saml_user_data=None):
log_prompt = "Process authenticate [SAML2Backend]: {}" log_prompt = "Process authenticate [SAML2Backend]: {}"
logger.debug(log_prompt.format('Start')) logger.debug(log_prompt.format('Start'))
if saml_user_data is None: if saml_user_data is None:

View File

@@ -4,10 +4,10 @@ from django.urls import path
from . import views from . import views
urlpatterns = [ urlpatterns = [
path('login/', views.Saml2AuthRequestView.as_view(), name='saml2-login'), path('login/', views.Saml2AuthRequestView.as_view(), name='saml2-login'),
path('logout/', views.Saml2EndSessionView.as_view(), name='saml2-logout'), path('logout/', views.Saml2EndSessionView.as_view(), name='saml2-logout'),
path('callback/', views.Saml2AuthCallbackView.as_view(), name='saml2-callback'), path('callback/', views.Saml2AuthCallbackView.as_view(), name='saml2-callback'),
path('callback/client/', views.Saml2AuthCallbackClientView.as_view(), name='saml2-callback-client'),
path('metadata/', views.Saml2AuthMetadataView.as_view(), name='saml2-metadata'), path('metadata/', views.Saml2AuthMetadataView.as_view(), name='saml2-metadata'),
] ]

View File

@@ -19,6 +19,7 @@ from onelogin.saml2.idp_metadata_parser import (
from authentication.views.mixins import FlashMessageMixin from authentication.views.mixins import FlashMessageMixin
from common.utils import get_logger from common.utils import get_logger
from .settings import JmsSaml2Settings from .settings import JmsSaml2Settings
from ...views.utils import redirect_to_guard_view
logger = get_logger(__file__) logger = get_logger(__file__)
@@ -298,6 +299,13 @@ class Saml2AuthCallbackView(View, PrepareRequestMixin, FlashMessageMixin):
return super().dispatch(*args, **kwargs) return super().dispatch(*args, **kwargs)
class Saml2AuthCallbackClientView(View):
http_method_names = ['get', ]
def get(self, request):
return redirect_to_guard_view(query_string='next=client')
class Saml2AuthMetadataView(View, PrepareRequestMixin): class Saml2AuthMetadataView(View, PrepareRequestMixin):
def get(self, request): def get(self, request):

View File

@@ -1,57 +1,41 @@
from django.conf import settings from django.conf import settings
from .base import JMSModelBackend from .base import JMSBaseAuthBackend
class SSOAuthentication(JMSModelBackend): class SSOAuthentication(JMSBaseAuthBackend):
"""
什么也不做呀😺
"""
@staticmethod @staticmethod
def is_enabled(): def is_enabled():
return settings.AUTH_SSO return settings.AUTH_SSO
def authenticate(self, request, sso_token=None, **kwargs): def authenticate(self):
pass pass
class WeComAuthentication(JMSModelBackend): class WeComAuthentication(JMSBaseAuthBackend):
"""
什么也不做呀😺
"""
@staticmethod @staticmethod
def is_enabled(): def is_enabled():
return settings.AUTH_WECOM return settings.AUTH_WECOM
def authenticate(self, request, **kwargs): def authenticate(self):
pass pass
class DingTalkAuthentication(JMSModelBackend): class DingTalkAuthentication(JMSBaseAuthBackend):
"""
什么也不做呀😺
"""
@staticmethod @staticmethod
def is_enabled(): def is_enabled():
return settings.AUTH_DINGTALK return settings.AUTH_DINGTALK
def authenticate(self, request, **kwargs): def authenticate(self):
pass pass
class FeiShuAuthentication(JMSModelBackend): class FeiShuAuthentication(JMSBaseAuthBackend):
"""
什么也不做呀😺
"""
@staticmethod @staticmethod
def is_enabled(): def is_enabled():
return settings.AUTH_FEISHU return settings.AUTH_FEISHU
def authenticate(self, request, **kwargs): def authenticate(self):
pass pass
@@ -61,23 +45,15 @@ class LarkAuthentication(FeiShuAuthentication):
return settings.AUTH_LARK return settings.AUTH_LARK
class SlackAuthentication(JMSModelBackend): class SlackAuthentication(JMSBaseAuthBackend):
"""
什么也不做呀😺
"""
@staticmethod @staticmethod
def is_enabled(): def is_enabled():
return settings.AUTH_SLACK return settings.AUTH_SLACK
def authenticate(self, request, **kwargs): def authenticate(self):
pass pass
class AuthorizationTokenAuthentication(JMSModelBackend): class AuthorizationTokenAuthentication(JMSBaseAuthBackend):
""" def authenticate(self):
什么也不做呀😺
"""
def authenticate(self, request, **kwargs):
pass pass

View File

@@ -3,13 +3,17 @@ from django.conf import settings
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from authentication.models import TempToken from authentication.models import TempToken
from .base import JMSModelBackend from .base import JMSBaseAuthBackend
class TempTokenAuthBackend(JMSModelBackend): class TempTokenAuthBackend(JMSBaseAuthBackend):
model = TempToken model = TempToken
def authenticate(self, request, username='', password='', *args, **kwargs): @staticmethod
def is_enabled():
return settings.AUTH_TEMP_TOKEN
def authenticate(self, request, username='', password=''):
token = self.model.objects.filter(username=username, secret=password).first() token = self.model.objects.filter(username=username, secret=password).first()
if not token: if not token:
return None return None
@@ -21,6 +25,3 @@ class TempTokenAuthBackend(JMSModelBackend):
token.save() token.save()
return token.user return token.user
@staticmethod
def is_enabled():
return settings.AUTH_TEMP_TOKEN

View File

@@ -40,6 +40,12 @@ class MFAType(TextChoices):
Custom = MFACustom.name, MFACustom.display_name Custom = MFACustom.name, MFACustom.display_name
MFA_FACE_CONTEXT_CACHE_KEY_PREFIX = "MFA_FACE_RECOGNITION_CONTEXT" FACE_CONTEXT_CACHE_KEY_PREFIX = "FACE_CONTEXT"
MFA_FACE_CONTEXT_CACHE_TTL = 60 FACE_CONTEXT_CACHE_TTL = 60
MFA_FACE_SESSION_KEY = "mfa_face_token" FACE_SESSION_KEY = "face_token"
class FaceMonitorActionChoices(TextChoices):
Verify = 'verify', 'verify'
Pause = 'pause', 'pause'
Resume = 'resume', 'resume'

View File

@@ -1,15 +1,12 @@
from django.core.cache import cache
from authentication.mfa.base import BaseMFA from authentication.mfa.base import BaseMFA
from django.shortcuts import reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from authentication.mixins import MFAFaceMixin from authentication.mixins import AuthFaceMixin
from common.const import LicenseEditionChoices from common.const import LicenseEditionChoices
from settings.api import settings from settings.api import settings
class MFAFace(BaseMFA, MFAFaceMixin): class MFAFace(BaseMFA, AuthFaceMixin):
name = "face" name = "face"
display_name = _('Face Recognition') display_name = _('Face Recognition')
placeholder = 'Face Recognition' placeholder = 'Face Recognition'
@@ -33,16 +30,17 @@ class MFAFace(BaseMFA, MFAFaceMixin):
@staticmethod @staticmethod
def global_enabled(): def global_enabled():
return settings.XPACK_LICENSE_IS_VALID \ return (
and LicenseEditionChoices.ULTIMATE == \ settings.XPACK_LICENSE_IS_VALID and
LicenseEditionChoices.from_key(settings.XPACK_LICENSE_EDITION) \ settings.XPACK_LICENSE_EDITION_ULTIMATE and
and settings.FACE_RECOGNITION_ENABLED settings.FACE_RECOGNITION_ENABLED
)
def get_enable_url(self) -> str: def get_enable_url(self) -> str:
return reverse('authentication:user-face-enable') return '/ui/#/profile/index'
def get_disable_url(self) -> str: def get_disable_url(self) -> str:
return reverse('authentication:user-face-disable') return '/ui/#/profile/index'
def disable(self): def disable(self):
assert self.is_authenticated() assert self.is_authenticated()
@@ -54,4 +52,8 @@ class MFAFace(BaseMFA, MFAFaceMixin):
@staticmethod @staticmethod
def help_text_of_enable(): def help_text_of_enable():
return _("Frontal Face Recognition") return _("Bind face to enable")
@staticmethod
def help_text_of_disable():
return _("Unbind face to disable")

View File

@@ -35,7 +35,7 @@ class MFAMiddleware:
# 这个是 mfa 登录页需要的请求, 也得放出来, 用户其实已经在 CAS/OIDC 中完成登录了 # 这个是 mfa 登录页需要的请求, 也得放出来, 用户其实已经在 CAS/OIDC 中完成登录了
white_urls = [ white_urls = [
'login/mfa', 'mfa/select', 'mfa/face','jsi18n/', '/static/', 'login/mfa', 'mfa/select', 'face/context','jsi18n/', '/static/',
'/profile/otp', '/logout/', '/profile/otp', '/logout/',
] ]
for url in white_urls: for url in white_urls:

View File

@@ -0,0 +1,18 @@
# Generated by Django 4.1.13 on 2024-12-12 06:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('authentication', '0003_sshkey'),
]
operations = [
migrations.AlterField(
model_name='passkey',
name='token',
field=models.CharField(max_length=1024, verbose_name='Token'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 4.1.13 on 2024-12-11 02:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('authentication', '0004_alter_passkey_token'),
]
operations = [
migrations.AddField(
model_name='connectiontoken',
name='face_monitor_token',
field=models.CharField(blank=True, max_length=128, null=True, verbose_name='Face monitor token'),
),
]

View File

@@ -2,6 +2,7 @@
# #
import inspect import inspect
import time import time
import uuid
from functools import partial from functools import partial
from typing import Callable from typing import Callable
@@ -50,7 +51,7 @@ auth._get_backends = _get_backends
def authenticate(request=None, **credentials): def authenticate(request=None, **credentials):
""" """
If the given credentials are valid, return a User object. If the given credentials are valid, return a User object.
之所以 hack 这个 auticate 之所以 hack 这个 authenticate
""" """
username = credentials.get('username') username = credentials.get('username')
@@ -199,53 +200,6 @@ class AuthPreCheckMixin:
self.raise_credential_error(errors.reason_user_not_exist) self.raise_credential_error(errors.reason_user_not_exist)
class MFAFaceMixin:
request = None
def get_face_recognition_token(self):
from authentication.const import MFA_FACE_SESSION_KEY
token = self.request.session.get(MFA_FACE_SESSION_KEY)
if not token:
raise ValueError("Face recognition token is missing from the session.")
return token
@staticmethod
def get_face_cache_key(token):
from authentication.const import MFA_FACE_CONTEXT_CACHE_KEY_PREFIX
return f"{MFA_FACE_CONTEXT_CACHE_KEY_PREFIX}_{token}"
def get_face_recognition_context(self):
token = self.get_face_recognition_token()
cache_key = self.get_face_cache_key(token)
context = cache.get(cache_key)
if not context:
raise ValueError(f"Face recognition context does not exist for token: {token}")
return context
@staticmethod
def is_context_finished(context):
return context.get('is_finished', False)
@staticmethod
def is_context_success(context):
return context.get('success', False)
def get_face_code(self):
context = self.get_face_recognition_context()
if not self.is_context_finished(context):
raise RuntimeError("Face recognition is not yet completed.")
if not self.is_context_success(context):
msg = context.get('error_message', '')
raise RuntimeError(msg)
face_code = context.get('face_code')
if not face_code:
raise ValueError("Face code is missing from the context.")
return face_code
class MFAMixin: class MFAMixin:
request: Request request: Request
get_user_from_session: Callable get_user_from_session: Callable
@@ -475,17 +429,83 @@ class AuthACLMixin:
return ticket return ticket
class AuthMixin(CommonMixin, AuthPreCheckMixin, AuthACLMixin, MFAMixin, AuthPostCheckMixin): class AuthFaceMixin:
request: Request
@staticmethod
def _get_face_cache_key(token):
from authentication.const import FACE_CONTEXT_CACHE_KEY_PREFIX
return f"{FACE_CONTEXT_CACHE_KEY_PREFIX}_{token}"
@staticmethod
def _is_context_finished(context):
return context.get('is_finished', False)
@staticmethod
def _is_context_success(context):
return context.get('success', False)
def create_face_verify_context(self, data=None):
token = uuid.uuid4().hex
context_data = {
"action": "mfa",
"token": token,
"user_id": self.request.user.id,
"is_finished": False
}
if data:
context_data.update(data)
cache_key = self._get_face_cache_key(token)
from .const import FACE_CONTEXT_CACHE_TTL, FACE_SESSION_KEY
cache.set(cache_key, context_data, FACE_CONTEXT_CACHE_TTL)
self.request.session[FACE_SESSION_KEY] = token
return token
def get_face_token_from_session(self):
from authentication.const import FACE_SESSION_KEY
token = self.request.session.get(FACE_SESSION_KEY)
if not token:
raise ValueError("Face recognition token is missing from the session.")
return token
def get_face_verify_context(self):
token = self.get_face_token_from_session()
cache_key = self._get_face_cache_key(token)
context = cache.get(cache_key)
if not context:
raise ValueError(f"Face recognition context does not exist for token: {token}")
return context
def get_face_code(self):
context = self.get_face_verify_context()
if not self._is_context_finished(context):
raise RuntimeError("Face recognition is not yet completed.")
if not self._is_context_success(context):
msg = context.get('error_message', '')
raise RuntimeError(msg)
face_code = context.get('face_code')
if not face_code:
raise ValueError("Face code is missing from the context.")
return face_code
class AuthMixin(CommonMixin, AuthPreCheckMixin, AuthACLMixin, AuthFaceMixin, MFAMixin, AuthPostCheckMixin, ):
request = None request = None
partial_credential_error = None partial_credential_error = None
key_prefix_captcha = "_LOGIN_INVALID_{}" key_prefix_captcha = "_LOGIN_INVALID_{}"
def _check_auth_user_is_valid(self, username, password, public_key): def _check_auth_user_is_valid(self, username, password, public_key):
user = authenticate( credentials = {'username': username}
self.request, username=username, if password:
password=password, public_key=public_key credentials['password'] = password
) if public_key:
credentials['public_key'] = public_key
user = authenticate(self.request, **credentials)
if not user: if not user:
self.raise_credential_error(errors.reason_password_failed) self.raise_credential_error(errors.reason_password_failed)

View File

@@ -50,6 +50,7 @@ class ConnectionToken(JMSOrgBaseModel):
on_delete=models.SET_NULL, null=True, blank=True, on_delete=models.SET_NULL, null=True, blank=True,
verbose_name=_('From ticket') verbose_name=_('From ticket')
) )
face_monitor_token = models.CharField(max_length=128, null=True, blank=True, verbose_name=_("Face monitor token"))
is_active = models.BooleanField(default=True, verbose_name=_("Active")) is_active = models.BooleanField(default=True, verbose_name=_("Active"))
class Meta: class Meta:

View File

@@ -4,3 +4,4 @@ from .connection_token import *
from .password_mfa import * from .password_mfa import *
from .ssh_key import * from .ssh_key import *
from .token import * from .token import *
from .face import *

View File

@@ -148,9 +148,10 @@ class ConnectionTokenSecretSerializer(OrgResourceModelSerializerMixin):
'platform', 'command_filter_acls', 'protocol', 'platform', 'command_filter_acls', 'protocol',
'domain', 'gateway', 'actions', 'expire_at', 'domain', 'gateway', 'actions', 'expire_at',
'from_ticket', 'expire_now', 'connect_method', 'from_ticket', 'expire_now', 'connect_method',
'connect_options', 'connect_options', 'face_monitor_token'
] ]
extra_kwargs = { extra_kwargs = {
'face_monitor_token': {'read_only': True},
'value': {'read_only': True}, 'value': {'read_only': True},
} }

View File

@@ -28,7 +28,7 @@ class ConnectionTokenSerializer(CommonModelSerializer):
'connect_method', 'connect_options', 'protocol', 'actions', 'connect_method', 'connect_options', 'protocol', 'actions',
'is_active', 'is_reusable', 'from_ticket', 'from_ticket_info', 'is_active', 'is_reusable', 'from_ticket', 'from_ticket_info',
'date_expired', 'date_created', 'date_updated', 'created_by', 'date_expired', 'date_created', 'date_updated', 'created_by',
'updated_by', 'org_id', 'org_name', 'updated_by', 'org_id', 'org_name','face_monitor_token',
] ]
read_only_fields = [ read_only_fields = [
# 普通 Token 不支持指定 user # 普通 Token 不支持指定 user
@@ -37,6 +37,7 @@ class ConnectionTokenSerializer(CommonModelSerializer):
] ]
fields = fields_small + read_only_fields fields = fields_small + read_only_fields
extra_kwargs = { extra_kwargs = {
'face_monitor_token': {'read_only': True},
'from_ticket': {'read_only': True}, 'from_ticket': {'read_only': True},
'value': {'read_only': True}, 'value': {'read_only': True},
'is_expired': {'read_only': True, 'label': _('Is expired')}, 'is_expired': {'read_only': True, 'label': _('Is expired')},

View File

@@ -0,0 +1,50 @@
from django.core.validators import RegexValidator
from rest_framework import serializers
__all__ = [
'FaceCallbackSerializer', 'FaceMonitorCallbackSerializer'
]
from authentication.const import FaceMonitorActionChoices
class FaceCallbackSerializer(serializers.Serializer):
token = serializers.CharField(required=True, allow_blank=False)
success = serializers.BooleanField(required=True, allow_null=False)
error_message = serializers.CharField(required=False, allow_null=True, allow_blank=True)
face_code = serializers.CharField(required=False, allow_null=True, allow_blank=True)
def update(self, instance, validated_data):
pass
def create(self, validated_data):
pass
class FaceMonitorContextSerializer(serializers.Serializer):
session_id = serializers.CharField(required=True, allow_null=False, allow_blank=False)
face_monitor_token = serializers.CharField(required=True, allow_blank=False, allow_null=False)
def update(self, instance, validated_data):
pass
def create(self, validated_data):
pass
class FaceMonitorCallbackSerializer(serializers.Serializer):
token = serializers.CharField(required=True, allow_blank=False)
is_finished = serializers.BooleanField(required=True)
success = serializers.BooleanField(required=True)
error_message = serializers.CharField(required=True, allow_blank=True)
action = serializers.ChoiceField(required=True, choices=FaceMonitorActionChoices.choices)
face_codes = serializers.ListField(
required=False, allow_null=True, allow_empty=True,
child=serializers.CharField(),
)
def update(self, instance, validated_data):
pass
def create(self, validated_data):
pass

View File

@@ -8,7 +8,6 @@ from common.serializers.fields import EncryptedField
__all__ = [ __all__ = [
'MFAChallengeSerializer', 'MFASelectTypeSerializer', 'MFAChallengeSerializer', 'MFASelectTypeSerializer',
'PasswordVerifySerializer', 'ResetPasswordCodeSerializer', 'PasswordVerifySerializer', 'ResetPasswordCodeSerializer',
'MFAFaceCallbackSerializer'
] ]
@@ -52,16 +51,3 @@ class MFAChallengeSerializer(serializers.Serializer):
def update(self, instance, validated_data): def update(self, instance, validated_data):
pass pass
class MFAFaceCallbackSerializer(serializers.Serializer):
token = serializers.CharField(required=True, allow_blank=False)
success = serializers.BooleanField(required=True, allow_null=False)
error_message = serializers.CharField(required=False, allow_null=True, allow_blank=True)
face_code = serializers.CharField(required=False, allow_null=True, allow_blank=True)
def update(self, instance, validated_data):
pass
def create(self, validated_data):
pass

View File

@@ -34,7 +34,7 @@
<script> <script>
$(document).ready(function () { $(document).ready(function () {
const apiUrl = "{% url 'api-auth:mfa-face-context' %}"; const apiUrl = "{% url 'api-auth:face-context' %}";
const faceCaptureUrl = "/facelive/capture"; const faceCaptureUrl = "/facelive/capture";
let token; let token;
@@ -85,7 +85,7 @@
} }
$('#retry_button').on('click', function () { $('#retry_button').on('click', function () {
window.location.href = "{% url 'authentication:login-face-capture' %}"; window.location.href = "{{ request.get_full_path }}";
}); });
}); });
</script> </script>

View File

@@ -26,6 +26,12 @@ urlpatterns = [
path('lark/event/subscription/callback/', api.LarkEventSubscriptionCallback.as_view(), path('lark/event/subscription/callback/', api.LarkEventSubscriptionCallback.as_view(),
name='lark-event-subscription-callback'), name='lark-event-subscription-callback'),
path('face/callback/', api.FaceCallbackApi.as_view(), name='face-callback'),
path('face/context/', api.FaceContextApi.as_view(), name='face-context'),
path('face-monitor/callback/', api.FaceMonitorCallbackApi.as_view(), name='face-monitor-callback'),
path('face-monitor/context/', api.FaceMonitorContextApi.as_view(), name='face-monitor-context'),
path('auth/', api.TokenCreateApi.as_view(), name='user-auth'), path('auth/', api.TokenCreateApi.as_view(), name='user-auth'),
path('confirm-oauth/', api.ConfirmBindORUNBindOAuth.as_view(), name='confirm-oauth'), path('confirm-oauth/', api.ConfirmBindORUNBindOAuth.as_view(), name='confirm-oauth'),
path('tokens/', api.TokenCreateApi.as_view(), name='auth-token'), path('tokens/', api.TokenCreateApi.as_view(), name='auth-token'),
@@ -33,8 +39,6 @@ urlpatterns = [
path('mfa/challenge/', api.MFAChallengeVerifyApi.as_view(), name='mfa-challenge'), path('mfa/challenge/', api.MFAChallengeVerifyApi.as_view(), name='mfa-challenge'),
path('mfa/select/', api.MFASendCodeApi.as_view(), name='mfa-select'), path('mfa/select/', api.MFASendCodeApi.as_view(), name='mfa-select'),
path('mfa/send-code/', api.MFASendCodeApi.as_view(), name='mfa-send-code'), path('mfa/send-code/', api.MFASendCodeApi.as_view(), name='mfa-send-code'),
path('mfa/face/callback/', api.MFAFaceCallbackApi.as_view(), name='mfa-face-callback'),
path('mfa/face/context/', api.MFAFaceContextApi.as_view(), name='mfa-face-context'),
path('password/reset-code/', api.UserResetPasswordSendCodeApi.as_view(), name='reset-password-code'), path('password/reset-code/', api.UserResetPasswordSendCodeApi.as_view(), name='reset-password-code'),
path('password/verify/', api.UserPasswordVerifyApi.as_view(), name='user-password-verify'), path('password/verify/', api.UserPasswordVerifyApi.as_view(), name='user-password-verify'),
path('login-confirm-ticket/status/', api.TicketStatusApi.as_view(), name='login-confirm-ticket-status'), path('login-confirm-ticket/status/', api.TicketStatusApi.as_view(), name='login-confirm-ticket-status'),

View File

@@ -110,6 +110,9 @@ class BaseLoginCallbackView(AuthMixin, FlashMessageMixin, IMClientMixin, View):
msg = e.msg msg = e.msg
response = self.get_failed_response(login_url, title=msg, msg=msg) response = self.get_failed_response(login_url, title=msg, msg=msg)
return response return response
if redirect_url and 'next=client' in redirect_url:
self.request.META['QUERY_STRING'] += '&next=client'
return self.redirect_to_guard_view() return self.redirect_to_guard_view()

View File

@@ -175,6 +175,8 @@ class DingTalkQRLoginView(DingTalkQRMixin, METAMixin, View):
def get(self, request: HttpRequest): def get(self, request: HttpRequest):
redirect_url = request.GET.get('redirect_url') or reverse('index') redirect_url = request.GET.get('redirect_url') or reverse('index')
query_string = request.GET.urlencode()
redirect_url = f'{redirect_url}?{query_string}'
next_url = self.get_next_url_from_meta() or reverse('index') next_url = self.get_next_url_from_meta() or reverse('index')
next_url = safe_next_url(next_url, request=request) next_url = safe_next_url(next_url, request=request)

View File

@@ -110,6 +110,8 @@ class FeiShuQRLoginView(FeiShuQRMixin, View):
def get(self, request: HttpRequest): def get(self, request: HttpRequest):
redirect_url = request.GET.get('redirect_url') or reverse('index') redirect_url = request.GET.get('redirect_url') or reverse('index')
query_string = request.GET.urlencode()
redirect_url = f'{redirect_url}?{query_string}'
redirect_uri = reverse(f'authentication:{self.category}-qr-login-callback', external=True) redirect_uri = reverse(f'authentication:{self.category}-qr-login-callback', external=True)
redirect_uri += '?' + urlencode({ redirect_uri += '?' + urlencode({
'redirect_url': redirect_url, 'redirect_url': redirect_url,

View File

@@ -45,69 +45,70 @@ class UserLoginContextMixin:
error_origin: str error_origin: str
def get_support_auth_methods(self): def get_support_auth_methods(self):
query_string = self.request.GET.urlencode()
auth_methods = [ auth_methods = [
{ {
'name': 'OpenID', 'name': 'OpenID',
'enabled': settings.AUTH_OPENID, 'enabled': settings.AUTH_OPENID,
'url': reverse('authentication:openid:login'), 'url': f"{reverse('authentication:openid:login')}?{query_string}",
'logo': static('img/login_oidc_logo.png'), 'logo': static('img/login_oidc_logo.png'),
'auto_redirect': True # 是否支持自动重定向 'auto_redirect': True # 是否支持自动重定向
}, },
{ {
'name': 'CAS', 'name': 'CAS',
'enabled': settings.AUTH_CAS, 'enabled': settings.AUTH_CAS,
'url': reverse('authentication:cas:cas-login'), 'url': f"{reverse('authentication:cas:cas-login')}?{query_string}",
'logo': static('img/login_cas_logo.png'), 'logo': static('img/login_cas_logo.png'),
'auto_redirect': True 'auto_redirect': True
}, },
{ {
'name': 'SAML2', 'name': 'SAML2',
'enabled': settings.AUTH_SAML2, 'enabled': settings.AUTH_SAML2,
'url': reverse('authentication:saml2:saml2-login'), 'url': f"{reverse('authentication:saml2:saml2-login')}?{query_string}",
'logo': static('img/login_saml2_logo.png'), 'logo': static('img/login_saml2_logo.png'),
'auto_redirect': True 'auto_redirect': True
}, },
{ {
'name': settings.AUTH_OAUTH2_PROVIDER, 'name': settings.AUTH_OAUTH2_PROVIDER,
'enabled': settings.AUTH_OAUTH2, 'enabled': settings.AUTH_OAUTH2,
'url': reverse('authentication:oauth2:login'), 'url': f"{reverse('authentication:oauth2:login')}?{query_string}",
'logo': static_or_direct(settings.AUTH_OAUTH2_LOGO_PATH), 'logo': static_or_direct(settings.AUTH_OAUTH2_LOGO_PATH),
'auto_redirect': True 'auto_redirect': True
}, },
{ {
'name': _('WeCom'), 'name': _('WeCom'),
'enabled': settings.AUTH_WECOM, 'enabled': settings.AUTH_WECOM,
'url': reverse('authentication:wecom-qr-login'), 'url': f"{reverse('authentication:wecom-qr-login')}?{query_string}",
'logo': static('img/login_wecom_logo.png'), 'logo': static('img/login_wecom_logo.png'),
}, },
{ {
'name': _('DingTalk'), 'name': _('DingTalk'),
'enabled': settings.AUTH_DINGTALK, 'enabled': settings.AUTH_DINGTALK,
'url': reverse('authentication:dingtalk-qr-login'), 'url': f"{reverse('authentication:dingtalk-qr-login')}?{query_string}",
'logo': static('img/login_dingtalk_logo.png') 'logo': static('img/login_dingtalk_logo.png')
}, },
{ {
'name': _('FeiShu'), 'name': _('FeiShu'),
'enabled': settings.AUTH_FEISHU, 'enabled': settings.AUTH_FEISHU,
'url': reverse('authentication:feishu-qr-login'), 'url': f"{reverse('authentication:feishu-qr-login')}?{query_string}",
'logo': static('img/login_feishu_logo.png') 'logo': static('img/login_feishu_logo.png')
}, },
{ {
'name': 'Lark', 'name': 'Lark',
'enabled': settings.AUTH_LARK, 'enabled': settings.AUTH_LARK,
'url': reverse('authentication:lark-qr-login'), 'url': f"{reverse('authentication:lark-qr-login')}?{query_string}",
'logo': static('img/login_lark_logo.png') 'logo': static('img/login_lark_logo.png')
}, },
{ {
'name': _('Slack'), 'name': _('Slack'),
'enabled': settings.AUTH_SLACK, 'enabled': settings.AUTH_SLACK,
'url': reverse('authentication:slack-qr-login'), 'url': f"{reverse('authentication:slack-qr-login')}?{query_string}",
'logo': static('img/login_slack_logo.png') 'logo': static('img/login_slack_logo.png')
}, },
{ {
'name': _("Passkey"), 'name': _("Passkey"),
'enabled': settings.AUTH_PASSKEY, 'enabled': settings.AUTH_PASSKEY,
'url': reverse('api-auth:passkey-login'), 'url': f"{reverse('api-auth:passkey-login')}?{query_string}",
'logo': static('img/login_passkey.png') 'logo': static('img/login_passkey.png')
} }
] ]
@@ -220,12 +221,16 @@ class UserLoginView(mixins.AuthMixin, UserLoginContextMixin, FormView):
'redirect_url': redirect_url, 'redirect_url': redirect_url,
'interval': 3, 'interval': 3,
'has_cancel': True, 'has_cancel': True,
'cancel_url': reverse('authentication:login') + '?admin=1' 'cancel_url': reverse('authentication:login') + f'?admin=1&{query_string}'
} }
redirect_url = FlashMessageUtil.gen_message_url(message_data) redirect_url = FlashMessageUtil.gen_message_url(message_data)
return redirect_url return redirect_url
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
next_page = request.GET.get(self.redirect_field_name)
if next_page:
request.session[self.redirect_field_name] = next_page
if request.user.is_staff: if request.user.is_staff:
first_login_url = redirect_user_first_login_or_index( first_login_url = redirect_user_first_login_or_index(
request, self.redirect_field_name request, self.redirect_field_name
@@ -306,6 +311,7 @@ class UserLoginGuardView(mixins.AuthMixin, RedirectView):
login_url = reverse_lazy('authentication:login') login_url = reverse_lazy('authentication:login')
login_mfa_url = reverse_lazy('authentication:login-mfa') login_mfa_url = reverse_lazy('authentication:login-mfa')
login_confirm_url = reverse_lazy('authentication:login-wait-confirm') login_confirm_url = reverse_lazy('authentication:login-wait-confirm')
query_string = True
def format_redirect_url(self, url): def format_redirect_url(self, url):
args = self.request.META.get('QUERY_STRING', '') args = self.request.META.get('QUERY_STRING', '')

View File

@@ -2,13 +2,14 @@
# #
from __future__ import unicode_literals from __future__ import unicode_literals
from django.views.generic.edit import FormView
from django.shortcuts import redirect, reverse from django.shortcuts import redirect, reverse
from django.views.generic.edit import FormView
from common.utils import get_logger from common.utils import get_logger
from users.views import UserFaceCaptureView from users.views import UserFaceCaptureView
from .. import forms, errors, mixins
from .utils import redirect_to_guard_view from .utils import redirect_to_guard_view
from .. import forms, errors, mixins
from ..const import MFAType from ..const import MFAType
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -48,7 +49,8 @@ class UserLoginMFAView(mixins.AuthMixin, FormView):
self._do_check_user_mfa(code, mfa_type) self._do_check_user_mfa(code, mfa_type)
user, ip = self.get_user_from_session(), self.get_request_ip() user, ip = self.get_user_from_session(), self.get_request_ip()
MFABlockUtils(user.username, ip).clean_failed_count() MFABlockUtils(user.username, ip).clean_failed_count()
return redirect_to_guard_view('mfa_ok') query_string = self.request.GET.urlencode()
return redirect_to_guard_view('mfa_ok', query_string)
except (errors.MFAFailedError, errors.BlockMFAError) as e: except (errors.MFAFailedError, errors.BlockMFAError) as e:
form.add_error('code', e.msg) form.add_error('code', e.msg)
return super().form_invalid(form) return super().form_invalid(form)

View File

@@ -98,6 +98,8 @@ class SlackQRLoginView(SlackMixin, View):
def get(self, request: Request): def get(self, request: Request):
redirect_url = request.GET.get('redirect_url') or reverse('index') redirect_url = request.GET.get('redirect_url') or reverse('index')
query_string = request.GET.urlencode()
redirect_url = f'{redirect_url}?{query_string}'
redirect_uri = reverse('authentication:slack-qr-login-callback', external=True) redirect_uri = reverse('authentication:slack-qr-login-callback', external=True)
redirect_uri += '?' + urlencode({ redirect_uri += '?' + urlencode({
'redirect_url': redirect_url, 'redirect_url': redirect_url,

View File

@@ -1,8 +1,18 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# #
from urllib.parse import urlencode, parse_qsl
from django.shortcuts import reverse, redirect from django.shortcuts import reverse, redirect
def redirect_to_guard_view(comment=''): def redirect_to_guard_view(comment='', query_string=None):
continue_url = reverse('authentication:login-guard') + '?_=' + comment params = {'_': comment}
base_url = reverse('authentication:login-guard')
if query_string:
params.update(dict(parse_qsl(query_string)))
query_string = urlencode(params)
continue_url = f"{base_url}?{query_string}"
return redirect(continue_url) return redirect(continue_url)

View File

@@ -22,7 +22,6 @@ from users.views import UserVerifyPasswordView
from .base import BaseLoginCallbackView, BaseBindCallbackView from .base import BaseLoginCallbackView, BaseBindCallbackView
from .mixins import METAMixin, FlashMessageMixin from .mixins import METAMixin, FlashMessageMixin
logger = get_logger(__file__) logger = get_logger(__file__)
@@ -86,6 +85,8 @@ class WeComQRBindView(WeComQRMixin, View):
def get(self, request: HttpRequest): def get(self, request: HttpRequest):
redirect_url = request.GET.get('redirect_url') redirect_url = request.GET.get('redirect_url')
query_string = request.GET.urlencode()
redirect_url = f'{redirect_url}?{query_string}'
redirect_uri = reverse('authentication:wecom-qr-bind-callback', external=True) redirect_uri = reverse('authentication:wecom-qr-bind-callback', external=True)
redirect_uri += '?' + urlencode({'redirect_url': redirect_url}) redirect_uri += '?' + urlencode({'redirect_url': redirect_url})

View File

@@ -3,6 +3,7 @@ import pycountry
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from phonenumbers import PhoneMetadata from phonenumbers import PhoneMetadata
from common.utils import lazyproperty
ADMIN = 'Admin' ADMIN = 'Admin'
USER = 'User' USER = 'User'
@@ -72,7 +73,38 @@ class Language(models.TextChoices):
en = 'en', 'English' en = 'en', 'English'
zh_hans = 'zh-hans', '中文(简体)' zh_hans = 'zh-hans', '中文(简体)'
zh_hant = 'zh-hant', '中文(繁體)' zh_hant = 'zh-hant', '中文(繁體)'
jp = 'ja', '日本語', ja = 'ja', '日本語',
pt_br = 'pt-br', 'Português (Brasil)'
@classmethod
def get_code_mapper(cls):
code_mapper = {code: code for code, name in cls.choices}
code_mapper.update({
'zh': cls.zh_hans.value,
'zh-cn': cls.zh_hans.value,
'zh-tw': cls.zh_hant.value,
'zh-hk': cls.zh_hant.value,
})
return code_mapper
@classmethod
def to_internal_code(cls, code: str, default='en', with_filename=False):
code_mapper = cls.get_code_mapper()
code = code.lower()
code = code_mapper.get(code) or code_mapper.get(default)
if with_filename:
cf_mapper = {
cls.zh_hans.value: 'zh',
}
code = cf_mapper.get(code, code)
code = code.replace('-', '_')
return code
@classmethod
def get_other_codes(cls, code):
code_mapper = cls.get_code_mapper()
other_codes = [other_code for other_code, _code in code_mapper.items() if code == _code]
return other_codes
COUNTRY_CALLING_CODES = get_country_phone_choices() COUNTRY_CALLING_CODES = get_country_phone_choices()

View File

@@ -16,4 +16,4 @@ POST_CLEAR = 'post_clear'
POST_PREFIX = 'post' POST_PREFIX = 'post'
PRE_PREFIX = 'pre' PRE_PREFIX = 'pre'
SKIP_SIGNAL = 'skip_signal' OP_LOG_SKIP_SIGNAL = 'operate_log_skip_signal'

View File

@@ -17,7 +17,7 @@ from django.db.models import F, ExpressionWrapper, CASCADE
from django.db.models import QuerySet from django.db.models import QuerySet
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from ..const.signals import SKIP_SIGNAL from ..const.signals import OP_LOG_SKIP_SIGNAL
class ChoicesMixin: class ChoicesMixin:
@@ -83,7 +83,7 @@ def CASCADE_SIGNAL_SKIP(collector, field, sub_objs, using):
# 级联删除时,操作日志标记不保存,以免用户混淆 # 级联删除时,操作日志标记不保存,以免用户混淆
try: try:
for obj in sub_objs: for obj in sub_objs:
setattr(obj, SKIP_SIGNAL, True) setattr(obj, OP_LOG_SKIP_SIGNAL, True)
except: except:
pass pass

View File

@@ -73,7 +73,7 @@ known_unauth_urls = [
"/api/v1/authentication/password/reset-code/", "/api/v1/authentication/password/reset-code/",
"/api/v1/authentication/login-confirm-ticket/status/", "/api/v1/authentication/login-confirm-ticket/status/",
"/api/v1/authentication/mfa/select/", "/api/v1/authentication/mfa/select/",
"/api/v1/authentication/mfa/face/context/", "/api/v1/authentication/face/context/",
"/api/v1/authentication/mfa/send-code/", "/api/v1/authentication/mfa/send-code/",
"/api/v1/authentication/sso/login/", "/api/v1/authentication/sso/login/",
"/api/v1/authentication/user-session/", "/api/v1/authentication/user-session/",

View File

@@ -8,7 +8,7 @@ from rest_framework import serializers
from rest_framework.fields import ChoiceField, empty from rest_framework.fields import ChoiceField, empty
from common.db.fields import TreeChoices, JSONManyToManyField as ModelJSONManyToManyField from common.db.fields import TreeChoices, JSONManyToManyField as ModelJSONManyToManyField
from common.utils import decrypt_password from common.utils import decrypt_password, is_uuid
__all__ = [ __all__ = [
"ReadableHiddenField", "ReadableHiddenField",
@@ -127,12 +127,14 @@ class LabelRelatedField(serializers.RelatedField):
if isinstance(data, dict) and (data.get("id") or data.get("pk")): if isinstance(data, dict) and (data.get("id") or data.get("pk")):
pk = data.get("id") or data.get("pk") pk = data.get("id") or data.get("pk")
label = Label.objects.get(pk=pk) label = Label.objects.get(pk=pk)
elif is_uuid(data):
label = Label.objects.get(pk=data)
else: else:
if isinstance(data, dict): if isinstance(data, dict):
k = data.get("name") k = data.get("name")
v = data.get("value") v = data.get("value")
elif isinstance(data, str) and ":" in data: elif isinstance(data, str) and ":" in data:
k, v = data.split(":", 1) k, v = [x.strip() for x in data.split(":", 1)]
else: else:
raise serializers.ValidationError(_("Invalid data type")) raise serializers.ValidationError(_("Invalid data type"))
label, __ = Label.objects.get_or_create(name=k, value=v, defaults={'name': k, 'value': v}) label, __ = Label.objects.get_or_create(name=k, value=v, defaults={'name': k, 'value': v})

View File

@@ -1,68 +1,74 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# #
import boto3
import os import os
import boto
import boto.s3.connection
from .base import ObjectStorage from .base import ObjectStorage
class CEPHStorage(ObjectStorage): class CEPHStorage(ObjectStorage):
def __init__(self, config): def __init__(self, config):
self.bucket = config.get("BUCKET", None) self.bucket = config.get("BUCKET", "jumpserver")
self.region = config.get("REGION", None) self.region = config.get("REGION", None)
self.access_key = config.get("ACCESS_KEY", None) self.access_key = config.get("ACCESS_KEY", None)
self.secret_key = config.get("SECRET_KEY", None) self.secret_key = config.get("SECRET_KEY", None)
self.hostname = config.get("HOSTNAME", None) self.endpoint = config.get("ENDPOINT", None)
self.port = config.get("PORT", 7480)
if self.hostname and self.access_key and self.secret_key:
self.conn = boto.connect_s3(
aws_access_key_id=self.access_key,
aws_secret_access_key=self.secret_key,
host=self.hostname,
port=self.port,
is_secure=False,
calling_format=boto.s3.connection.OrdinaryCallingFormat(),
)
try: try:
self.client = self.conn.get_bucket(bucket_name=self.bucket) self.client = boto3.client(
except Exception: 's3', region_name=self.region,
self.client = None aws_access_key_id=self.access_key,
aws_secret_access_key=self.secret_key,
endpoint_url=self.endpoint
)
except ValueError:
pass
def upload(self, src, target): def upload(self, src, target):
try: try:
key = self.client.new_key(target) self.client.upload_file(Filename=src, Bucket=self.bucket, Key=target)
key.set_contents_from_filename(src)
return True, None
except Exception as e:
return False, e
def download(self, src, target):
try:
os.makedirs(os.path.dirname(target), 0o755, exist_ok=True)
key = self.client.get_key(src)
key.get_contents_to_filename(target)
return True, None
except Exception as e:
return False, e
def delete(self, path):
try:
self.client.delete_key(path)
return True, None return True, None
except Exception as e: except Exception as e:
return False, e return False, e
def exists(self, path): def exists(self, path):
try: try:
return self.client.get_key(path) self.client.head_object(Bucket=self.bucket, Key=path)
except Exception: return True
except Exception as e:
return False return False
def download(self, src, target):
try:
os.makedirs(os.path.dirname(target), 0o755, exist_ok=True)
self.client.download_file(self.bucket, src, target)
return True, None
except Exception as e:
return False, e
def delete(self, path):
try:
self.client.delete_object(Bucket=self.bucket, Key=path)
return True, None
except Exception as e:
return False, e
def generate_presigned_url(self, path, expire=3600):
try:
return self.client.generate_presigned_url(
ClientMethod='get_object',
Params={'Bucket': self.bucket, 'Key': path},
ExpiresIn=expire,
HttpMethod='GET'), None
except Exception as e:
return False, e
def list_buckets(self):
response = self.client.list_buckets()
buckets = response.get('Buckets', [])
result = [b['Name'] for b in buckets if b.get('Name')]
return result
@property @property
def type(self): def type(self):
return 'ceph' return 'ceph'

View File

@@ -158,7 +158,10 @@ def is_uuid(seq):
def get_request_ip(request): def get_request_ip(request):
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR', '').split(',') x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR', '').split(',')
if x_forwarded_for and x_forwarded_for[0]: if x_forwarded_for and x_forwarded_for[0]:
login_ip = x_forwarded_for[0].split(":")[0] login_ip = x_forwarded_for[0]
if login_ip.count(':') == 1:
# format: ipv4:port (非标准格式的 X-Forwarded-For)
login_ip = login_ip.split(":")[0]
return login_ip return login_ip
login_ip = request.META.get('REMOTE_ADDR', '') login_ip = request.META.get('REMOTE_ADDR', '')

View File

@@ -18,9 +18,8 @@ def random_ip():
return socket.inet_ntoa(struct.pack('>I', random.randint(1, 0xffffffff))) return socket.inet_ntoa(struct.pack('>I', random.randint(1, 0xffffffff)))
def random_replace_char(s, chars, length): def random_replace_char(seq, chars, length):
using_index = set() using_index = set()
seq = list(s)
while length > 0: while length > 0:
index = secrets.randbelow(len(seq) - 1) index = secrets.randbelow(len(seq) - 1)
@@ -29,7 +28,7 @@ def random_replace_char(s, chars, length):
seq[index] = secrets.choice(chars) seq[index] = secrets.choice(chars)
using_index.add(index) using_index.add(index)
length -= 1 length -= 1
return ''.join(seq) return seq
def remove_exclude_char(s, exclude_chars): def remove_exclude_char(s, exclude_chars):
@@ -47,19 +46,40 @@ def random_string(
if length < 4: if length < 4:
raise ValueError('The length of the string must be greater than 3') raise ValueError('The length of the string must be greater than 3')
chars_map = ( char_list = []
(lower, string.ascii_lowercase), if lower:
(upper, string.ascii_uppercase),
(digit, string.digits), lower_chars = remove_exclude_char(string.ascii_lowercase, exclude_chars)
) if not lower_chars:
chars = ''.join([i[1] for i in chars_map if i[0]]) raise ValueError('After excluding characters, no lowercase letters are available.')
chars = remove_exclude_char(chars, exclude_chars) char_list.append(lower_chars)
texts = list(secrets.choice(chars) for __ in range(length))
texts = ''.join(texts) if upper:
upper_chars = remove_exclude_char(string.ascii_uppercase, exclude_chars)
if not upper_chars:
raise ValueError('After excluding characters, no uppercase letters are available.')
char_list.append(upper_chars)
if digit:
digit_chars = remove_exclude_char(string.digits, exclude_chars)
if not digit_chars:
raise ValueError('After excluding characters, no digits are available.')
char_list.append(digit_chars)
secret_chars = [secrets.choice(chars) for chars in char_list]
all_chars = ''.join(char_list)
remaining_length = length - len(secret_chars)
seq = [secrets.choice(all_chars) for _ in range(remaining_length)]
# 控制一下特殊字符的数量, 别随机出来太多
if special_char: if special_char:
symbols = remove_exclude_char(symbols, exclude_chars) special_chars = remove_exclude_char(symbols, exclude_chars)
if not special_chars:
raise ValueError('After excluding characters, no special characters are available.')
symbol_num = length // 16 + 1 symbol_num = length // 16 + 1
texts = random_replace_char(texts, symbols, symbol_num) seq = random_replace_char(seq, symbols, symbol_num)
return texts secret_chars += seq
secrets.SystemRandom().shuffle(secret_chars)
return ''.join(secret_chars)

View File

@@ -1,6 +1,7 @@
# #
from django.http import HttpResponse from django.http import HttpResponse
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.utils.translation import gettext_lazy as _
from django.views.decorators.cache import never_cache from django.views.decorators.cache import never_cache
from django.views.generic.base import TemplateView from django.views.generic.base import TemplateView
@@ -14,11 +15,11 @@ class FlashMessageMsgView(TemplateView):
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
code = request.GET.get('code') code = request.GET.get('code')
if not code: if not code:
return HttpResponse('Not found the code') return HttpResponse(_('Not found the code'))
message_data = FlashMessageUtil.get_message_by_code(code) message_data = FlashMessageUtil.get_message_by_code(code)
if not message_data: if not message_data:
return HttpResponse('Message code error') return HttpResponse(_('The message code provided is invalid or has expired'))
items = ('title', 'message', 'error', 'redirect_url', 'confirm_button', 'cancel_url') items = ('title', 'message', 'error', 'redirect_url', 'confirm_button', 'cancel_url')
title, msg, error, redirect_url, confirm_btn, cancel_url = bulk_get(message_data, items) title, msg, error, redirect_url, confirm_btn, cancel_url = bulk_get(message_data, items)

View File

@@ -11,8 +11,8 @@ class BaseTranslateManager:
SEPARATOR = "<SEP>" SEPARATOR = "<SEP>"
LANG_MAPPER = { LANG_MAPPER = {
'ja': 'Japanese', 'ja': 'Japanese',
'zh_hant': 'Traditional Chinese', 'zh_Hant': 'Traditional Chinese',
# 'en': 'English', 'pt_BR': 'Portuguese (Brazil)',
} }
def __init__(self, dir_path, oai_trans_instance): def __init__(self, dir_path, oai_trans_instance):

View File

@@ -37,7 +37,6 @@ class CoreTranslateManager(BaseTranslateManager):
zh_dict = {entry.msgid: entry.msgstr for entry in po.translated_entries()} zh_dict = {entry.msgid: entry.msgstr for entry in po.translated_entries()}
for file_prefix, target_lang in self.LANG_MAPPER.items(): for file_prefix, target_lang in self.LANG_MAPPER.items():
file_prefix = 'zh_Hant' if file_prefix == 'zh_hant' else file_prefix
po_file_path = os.path.join(self._dir, file_prefix, 'LC_MESSAGES', 'django.po') po_file_path = os.path.join(self._dir, file_prefix, 'LC_MESSAGES', 'django.po')
trans_po = polib.pofile(po_file_path) trans_po = polib.pofile(po_file_path)
need_trans_dict = self.get_need_trans_dict(zh_dict, trans_po) need_trans_dict = self.get_need_trans_dict(zh_dict, trans_po)

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