Compare commits

...

132 Commits

Author SHA1 Message Date
7c38a99973 fix(ci): исправить путь container registry
All checks were successful
Build & Push Docker Image / build (push) Successful in 2m15s
- исправлен путь образа на gromov/casdoor
2026-04-11 19:42:25 +03:00
7c26dbb7d0 fix(ci): убрать тест версии из Dockerfile
Some checks failed
Build & Push Docker Image / build (push) Failing after 6m15s
- удалён TestGetVersionInfo, падает без .git в контейнере
2026-04-11 19:32:22 +03:00
61bc75b12e chore(ci): добавить ручной запуск workflow
Some checks failed
Build & Push Docker Image / build (push) Failing after 2m5s
- добавлен workflow_dispatch для ручного запуска сборки
2026-04-11 19:26:23 +03:00
18a8694d28 chore(ci): добавить Gitea Actions для сборки Docker-образа
- добавлен workflow сборки и пуша в container registry
- сборка при пуше в ветку custom
- target STANDARD (Alpine)
2026-04-11 19:19:01 +03:00
8478543c6b feat(i18n): добавить русский язык и конфигурацию разработки
- добавлен русский перевод интерфейса (web/src/locales/ru)
- восстановлен русский перевод бэкенда из Crowdin (i18n/locales/ru)
- добавлен ru в список языков организации
- добавлен Русский в селект языков
- добавлена конфигурация для локальной разработки (PostgreSQL, порт 5434)
- добавлен docker-compose.dev.yml
2026-04-11 19:11:15 +03:00
Yang Luo
25d8595e66 fix: improve top-left logo position 2026-04-11 22:33:20 +08:00
Yang Luo
3aafa91937 fix: improve hosting badge position and UI 2026-04-11 22:23:46 +08:00
Yang Luo
0077839549 fix: hide global scrollbar 2026-04-11 22:07:54 +08:00
Yang Luo
e1ee2ddee8 fix: add margin to 3 store pages 2026-04-11 22:02:05 +08:00
Yang Luo
b93be2d3e2 fix: add top breadcrumb bar 2026-04-11 21:53:57 +08:00
Yang Luo
77b56a2e40 fix: increase left sidebar width 2026-04-11 21:48:13 +08:00
Yang Luo
c0591f316e fix: increase org-select's width 2026-04-11 21:43:56 +08:00
Yang Luo
6749d46561 fix: improve top-left logo position 2026-04-11 21:42:21 +08:00
Yang Luo
a4a50f182b fix: hide left sidebar's scrollbar 2026-04-11 21:21:11 +08:00
Yang Luo
221d10a172 fix: fix Can't resolve 'rc-util/es/isEqual' bug 2026-04-11 20:46:34 +08:00
Yang Luo
5c051ba03d feat: improve table column width
BREAKING CHANGE: major release
2026-04-11 19:08:54 +08:00
Yang Luo
c16f4d2fb5 fix: improve xxx list page table's column row height 2026-04-11 19:01:28 +08:00
Yang Luo
fe185f880c fix: improve i18n keys 2026-04-11 19:00:45 +08:00
Yang Luo
b3bed1992b fix: improve "Loading" position 2026-04-11 18:54:05 +08:00
Yang Luo
be38d178fd fix: increase org-select's width 2026-04-11 18:50:19 +08:00
Yang Luo
3eb164e149 fix: add left margin to top-right user avatar 2026-04-11 18:46:47 +08:00
Yang Luo
6c3cd8a74b fix: set Sidebar menu: selected item - darker background 2026-04-11 18:43:10 +08:00
Yang Luo
c5ab4eec59 fix: fix top-left logo missing bug 2026-04-11 18:41:20 +08:00
Yang Luo
e8170884d7 fix: improve record and session list page UI 2026-04-11 18:40:22 +08:00
Yang Luo
729b21e8ae fix: use Apple Inter font 2026-04-11 18:32:36 +08:00
Yang Luo
bed67a1ff2 fix: improve top-left menu text 2026-04-11 18:21:29 +08:00
Yang Luo
df5f5def31 fix: improve list page's table title bar height 2026-04-11 18:10:29 +08:00
Yang Luo
76c56e9b2d fix: fix top-left menu highlight 2026-04-11 18:09:27 +08:00
Yang Luo
f46e229d5b fix: improve top-left logo 2026-04-11 17:50:25 +08:00
Yang Luo
112be9714b fix: reduce content area margin 2026-04-11 17:47:46 +08:00
Yang Luo
9d85362a24 fix: reduce top bar height 2026-04-11 17:43:03 +08:00
Yang Luo
37e2f13d99 feat: change to left sidebar 2026-04-11 17:32:13 +08:00
Yang Luo
f35398ea5c fix: use outlined icons in top navbar 2026-04-11 17:19:09 +08:00
Yang Luo
5a5470d5a3 fix: use shadcn theme by default 2026-04-11 17:15:35 +08:00
Yang Luo
948fc017e1 fix: improve i18n data 2026-04-11 17:01:26 +08:00
Yang Luo
c63184fc67 feat: upgrade to Antd 6.3.5 2026-04-11 16:53:23 +08:00
Yang Luo
f5f4032b3b fix: fix code format 2026-04-11 11:02:59 +08:00
Yang Luo
7006041fa9 fix: remove OpenClaw session heartbeat logs 2026-04-11 10:54:59 +08:00
Yang Luo
d7bc2bf052 feat: add support for OAuth 2.0 DPoP (Demonstrating Proof of Possession) 2026-04-11 10:45:33 +08:00
Yang Luo
29eeb03f85 fix: refactor out token_oauth_util.go 2026-04-11 10:19:04 +08:00
Yang Luo
14b4b557f9 feat: support user's accessKey in auto signin filter 2026-04-11 01:07:36 +08:00
Yang Luo
49d35ac161 feat: add Provider.CustomLogoutUrl field 2026-04-11 01:01:58 +08:00
Yang Luo
5ed9158368 fix: improve GetSortedUsers code 2026-04-11 00:58:40 +08:00
Yang Luo
2bb728ad7d feat: fix wrong verb sent in oauth state (signup instead of signin) 2026-04-11 00:42:57 +08:00
nkanf-dev
f4665df477 fix: fix checking shared-app login permission in user organization (#5381) 2026-04-11 00:09:59 +08:00
Paperlz
12bbecb69d feat: build OpenClaw session graphs from DB entries (#5382) 2026-04-11 00:02:04 +08:00
Yang Luo
a5079cd0c5 feat: fix UpdatePolicy/UpdatePolicies APIs for empty field update 2026-04-09 01:00:02 +08:00
Yang Luo
e361044f86 fix: add RequestBodyFilter to fix POST body JSON error without Content-Type issue 2026-04-09 00:35:05 +08:00
Yang Luo
91cdf56636 feat: Enable ABAC support in /api/enforce and /api/batch-enforce 2026-04-09 00:32:48 +08:00
Yang Luo
10daed237e feat: improve objOwner check 2026-04-08 23:17:54 +08:00
cooronx
315a6bb040 feat: deduplicate permission RBAC by building grouping policies in run time (#5374) 2026-04-08 23:01:00 +08:00
Yang Luo
cef6b85389 feat: failed HTTP Basic Authentication won't trigger error now 2026-04-08 22:59:52 +08:00
Yang Luo
14a802f2c5 fix: fix add-new logic for provider 2026-04-08 22:18:44 +08:00
Yang Luo
40d1f63cd6 fix: don't collect OpenClaw metrics entries for now 2026-04-08 19:23:14 +08:00
Yang Luo
85c91c50d3 fix: add provider.State to log providers 2026-04-08 09:52:45 +08:00
Yang Luo
0e5f810f2f feat: fix "sqlite3" driverName to "sqlite" 2026-04-08 09:27:16 +08:00
asuka
e9c2ec0d6c fix: fix authz permission for well-known OAuth endpoints (#5372) 2026-04-07 22:38:12 +08:00
Paperlz
2a8ac578da feat: add local OpenClaw transcript sync for session logs (#5370) 2026-04-07 22:34:55 +08:00
DacongDA
31ce1512df feat: apply loginPage captcha rule check to SendCodeInput.js (#5369) 2026-04-06 17:54:27 +08:00
Yang Luo
bac824cb4f feat: improve filter check 2026-04-06 12:26:29 +08:00
DacongDA
1637ca1dfb feat: support GET request in MCP SSE (#5363) 2026-04-06 10:35:49 +08:00
Paperlz
c7ad2052c9 feat: add provider-based entry viewers for SELinux logs (#5364) 2026-04-06 00:59:32 +08:00
Yang Luo
117bf608ea fix: include xs namespace in C14N10 exclusive canonicalization prefix list 2026-04-05 21:09:54 +08:00
DacongDA
13e0af4b0a feat: switch server source to "https://mcp.casdoor.org/registry.json" (#5366) 2026-04-05 20:57:03 +08:00
Yang Luo
e8a0b268dc feat: add redirectUriMatchesPattern() 2026-04-05 20:38:12 +08:00
Yang Luo
2762390c32 fix: add Clear button to MCP server edit page 2026-04-05 17:54:27 +08:00
cooronx
a69c4454ca feat: add back buy-product API for compatibility (#5362) 2026-04-05 17:41:15 +08:00
Paperlz
c76d0d17ed fix: use SELinux collector for SELinux log providers (#5361) 2026-04-05 16:55:55 +08:00
Yang Luo
e10706cb6d fix: fix linter in saml_sp.go 2026-04-05 15:43:03 +08:00
Yang Luo
d92b856868 feat: add parseAndValidateSubjectToken() 2026-04-05 15:32:38 +08:00
Yang Luo
d14674e60e fix: improve buildSpCertificateStore logic 2026-04-05 15:22:09 +08:00
DacongDA
284dde292a feat: add Sync button to sync tools in MCP server edit page (#5360) 2026-04-05 15:09:39 +08:00
Yang Luo
ea56cfec2b fix: improve IsRedirectUriValid logic 2026-04-05 15:00:00 +08:00
Yang Luo
82d7f241bb fix: refactor out application_util.go 2026-04-05 14:56:24 +08:00
Yang Luo
56ac5cd221 feat: add Application.EnableGuestSignin field 2026-04-05 14:28:53 +08:00
Yang Luo
203a61cfef feat: improve GetOAuthToken logic 2026-04-05 14:22:09 +08:00
Yang Luo
b9500a27d9 fix: improve buildAuthFilterString logic 2026-04-05 10:19:19 +08:00
Yang Luo
c979a05c25 fix: fix bug in responseOtlpError() 2026-04-05 09:44:45 +08:00
Yang Luo
1e7a2d8dad fix: improve random handling 2026-04-05 09:42:52 +08:00
Yang Luo
f6a3fb9455 feat: improve error handling in OLTP APIs 2026-04-05 02:50:12 +08:00
Yang Luo
9030a06792 fix: add client IP and UA to entry 2026-04-05 02:08:45 +08:00
Yang Luo
fffb26deb9 fix: remove entry's useless fields 2026-04-05 01:59:32 +08:00
Yang Luo
fab57364db fix: refactor out log/agent_openclaw.go 2026-04-05 01:52:25 +08:00
Yang Luo
e73cfe8b40 fix: fix go linter issue 2026-04-05 00:18:31 +08:00
Yang Luo
facc1ec203 feat: support "/api/v1/metrics" and "/api/v1/logs" APIs from OpenClaw 2026-04-05 00:13:37 +08:00
Yang Luo
6cb9978475 feat: add SELinuxLogProvider 2026-04-04 23:48:56 +08:00
Yang Luo
f75cee76ae fix: set empty providerUrl in new provider 2026-04-04 23:43:43 +08:00
Yang Luo
c92e553e9b fix: fix provider_log_system_log's wrong provider field 2026-04-04 23:36:06 +08:00
Yang Luo
a824fc0f3c fix: improve getAutoProviderName with subtype 2026-04-04 23:36:06 +08:00
Yang Luo
98dea3a15a fix: add provider_log_agent_openclaw and IP check 2026-04-04 23:36:06 +08:00
DacongDA
c0d3fdf812 feat: can scan intranet MCP servers (#5359) 2026-04-04 23:25:13 +08:00
Yang Luo
1c60a4ddfa fix: fix parseAndPersistEvents() code in Windows system log provider 2026-04-04 23:06:51 +08:00
Yang Luo
ac43fb9cac fix: fix system log provider code 2026-04-04 22:46:19 +08:00
Yang Luo
2f7e6c1cc2 fix: refactor system log provider 2026-04-04 22:11:47 +08:00
Yang Luo
28b76cce76 fix: improve entry list page UI again 2026-04-04 22:03:54 +08:00
Yang Luo
319896267e fix: improve entry list page UI 2026-04-04 21:51:38 +08:00
Yang Luo
a3698024bc fix: fix writePermissionLog() hook position 2026-04-04 21:33:48 +08:00
Yang Luo
8ffca95c59 fix: add "Casdoor Permission Log" option to frontend 2026-04-04 21:03:03 +08:00
Yang Luo
4f68432349 feat: auto set name and display name for new provider 2026-04-04 21:00:52 +08:00
Yang Luo
17a52da2b8 fix: add Entry.Provider field 2026-04-04 21:00:52 +08:00
Yang Luo
5140053083 fix: add "Casdoor Permission Log" provider 2026-04-04 20:59:21 +08:00
Yang Luo
9b86530763 fix: rename to log package 2026-04-04 20:59:21 +08:00
Yang Luo
84f289ddc4 fix: ignore claude config 2026-04-04 20:59:20 +08:00
Modo
23cdb279e6 feat: add EntryMessageViewer (#5357) 2026-04-04 16:48:00 +08:00
Yang Luo
ea2408a7d2 feat: improve permission command API 2026-04-04 01:30:14 +08:00
Yang Luo
4ccb28571b fix: improve value in GetSession() 2026-04-04 01:15:29 +08:00
Yang Luo
1439031780 feat: add LogProvider interface 2026-04-03 01:33:15 +08:00
Modo
2ebe3f1d5d feat: add "/api/v1/traces" API to receive OpenClaw's OpenTelemetry metric (#5349) 2026-04-01 12:13:44 +08:00
Paperlz
0ff862dbc5 feat: enhance webhook worker reliability and event pagination (#5344) 2026-03-31 09:51:24 +08:00
DacongDA
bb11511029 feat: add MCP store page (#5343) 2026-03-31 09:49:49 +08:00
Yang Luo
18979caea4 feat: add Entry list and edit pages 2026-03-31 00:25:44 +08:00
Yang Luo
a61575f9d1 fix: improve menu for LLM AI 2026-03-30 23:26:37 +08:00
Paperlz
863d86d55f feat: add webhook delivery persistence, retry mechanism and replay UI (#5337) 2026-03-30 22:53:56 +08:00
Modo
b690ee4ea3 feat: add Agent list and edit pages (#5338) 2026-03-30 09:10:18 +08:00
ANormalDD
5b58d8bf16 feat: move configs of defaultApplication and maxItemsForFlatMenu to backend app.conf (#5325) 2026-03-28 12:27:53 +08:00
Yijun Gao
e5d2feb73d feat: add disk and network usage to system info page with improved layout (#5304) 2026-03-27 21:39:23 +08:00
Yang Luo
96359f78c5 feat: add CheckVerifyCodeWithLimitAndIp() 2026-03-26 23:02:11 +08:00
Stephane P. Pericat
6f18f67138 feat: upgrade Go toolchain to 1.25.8 (#5328) 2026-03-26 00:51:27 +08:00
Yang Luo
9038d8ab5b fix: improve LDAP handling for "(uid={})" filter 2026-03-25 22:40:27 +08:00
Yang Luo
b396a69ed7 fix: fix go.sum 2026-03-24 23:26:52 +08:00
Chlx
189277f9a2 feat: add Antd Select component for language selection (#5319) 2026-03-24 23:23:12 +08:00
Yang Luo
655777f0f1 feat: upgrade grpc dependency (#5318) 2026-03-24 22:12:48 +08:00
Yang Luo
fb0b93873c feat: fix 100% CPU/memory usage displaying as green success state on System Info page (#5306) 2026-03-23 21:28:13 +08:00
DacongDA
f5af87683d feat: can sync MCP tools and set "is allowed" for MCP tool (#5301) 2026-03-23 11:47:06 +08:00
DacongDA
df47f5785c fix: rename mcp package to mcpself (#5302) 2026-03-23 10:59:10 +08:00
ANormalDD
4879926977 fix: remove old use keys (#5299) 2026-03-22 14:40:14 +08:00
ANormalDD
7148c9db85 fix: check owner in UpdateKey() API (#5297) 2026-03-22 12:50:03 +08:00
Modo
29dccbe32f feat: preserve RFC 8707 resource across browser login flow (#5298) 2026-03-22 09:30:59 +08:00
Br1an
65755d3b28 feat: redirect to account page for forced password change (#5181) 2026-03-22 00:55:58 +08:00
Yang Luo
239e8bd694 feat: add key list/edit pages (#5285) 2026-03-21 20:06:06 +08:00
Modo
d23e8b205b feat: add permanent avatar switch to organization settings (#5295) 2026-03-21 09:21:11 +08:00
ANormalDD
1260db8c27 feat: remove Casvisor dependency and use local Record struct (#5287) 2026-03-19 22:48:06 +08:00
193 changed files with 17225 additions and 3170 deletions

View File

@@ -0,0 +1,31 @@
name: Build & Push Docker Image
on:
push:
branches:
- custom
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login to Container Registry
uses: docker/login-action@v3
with:
registry: gromlab.ru
username: ${{ secrets.CR_USER }}
password: ${{ secrets.CR_TOKEN }}
- name: Build and Push
uses: docker/build-push-action@v5
with:
context: .
target: STANDARD
push: true
tags: |
gromlab.ru/gromov/casdoor:latest
gromlab.ru/gromov/casdoor:${{ github.sha }}

View File

@@ -1,5 +1,8 @@
name: Build
env:
GO_VERSION: "1.25.8"
on:
push:
branches:
@@ -7,7 +10,6 @@ on:
pull_request:
jobs:
go-tests:
name: Running Go tests
runs-on: ubuntu-latest
@@ -24,7 +26,7 @@ jobs:
- uses: actions/checkout@v3
- uses: actions/setup-go@v4
with:
go-version: '1.23'
go-version: ${{ env.GO_VERSION }}
cache-dependency-path: ./go.mod
- name: Tests
run: |
@@ -34,13 +36,13 @@ jobs:
frontend:
name: Front-end
runs-on: ubuntu-latest
needs: [ go-tests ]
needs: [go-tests]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 20
cache: 'yarn'
cache: "yarn"
cache-dependency-path: ./web/yarn.lock
- run: yarn install && CI=false yarn run build
working-directory: ./web
@@ -54,12 +56,12 @@ jobs:
backend:
name: Back-end
runs-on: ubuntu-latest
needs: [ go-tests ]
needs: [go-tests]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v4
with:
go-version: '1.23'
go-version: ${{ env.GO_VERSION }}
cache-dependency-path: ./go.mod
- run: go version
- name: Build
@@ -70,27 +72,28 @@ jobs:
linter:
name: Go-Linter
runs-on: ubuntu-latest
needs: [ go-tests ]
needs: [go-tests]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v4
with:
go-version: '1.23'
go-version: ${{ env.GO_VERSION }}
cache: false
# gen a dummy config file
- run: touch dummy.yml
- name: Sync vendor tree
run: go mod vendor
# CI and local `make lint` both use the repo's gofumpt-only golangci-lint config.
- name: golangci-lint
uses: golangci/golangci-lint-action@v3
uses: golangci/golangci-lint-action@v9.2.0
with:
version: latest
args: --disable-all -c dummy.yml -E=gofumpt --max-same-issues=0 --timeout 5m --modules-download-mode=mod
version: v2.11.4
args: --config .golangci.yml ./...
e2e:
name: e2e-test
runs-on: ubuntu-latest
needs: [ go-tests ]
needs: [go-tests]
services:
mysql:
image: mysql:5.7
@@ -104,7 +107,7 @@ jobs:
- uses: actions/checkout@v3
- uses: actions/setup-go@v4
with:
go-version: '1.23'
go-version: ${{ env.GO_VERSION }}
cache-dependency-path: ./go.mod
- name: start backend
run: nohup go run ./main.go > /tmp/backend.log 2>&1 &
@@ -129,7 +132,7 @@ jobs:
- uses: actions/setup-node@v3
with:
node-version: 20
cache: 'yarn'
cache: "yarn"
cache-dependency-path: ./web/yarn.lock
- run: yarn install
working-directory: ./web
@@ -137,7 +140,7 @@ jobs:
with:
browser: chrome
start: yarn start
wait-on: 'http://localhost:7001'
wait-on: "http://localhost:7001"
wait-on-timeout: 210
working-directory: ./web
@@ -159,7 +162,7 @@ jobs:
contents: write
issues: write
if: github.repository == 'casdoor/casdoor' && github.event_name == 'push'
needs: [ frontend, backend, linter, e2e ]
needs: [frontend, backend, linter, e2e]
outputs:
new-release-published: ${{ steps.semantic.outputs.new_release_published }}
new-release-version: ${{ steps.semantic.outputs.new_release_version }}
@@ -180,13 +183,18 @@ jobs:
contents: write
issues: write
if: github.repository == 'casdoor/casdoor' && github.event_name == 'push' && needs.tag-release.outputs.new-release-published == 'true'
needs: [ tag-release ]
needs: [tag-release]
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v4
with:
go-version: ${{ env.GO_VERSION }}
cache-dependency-path: ./go.mod
- name: Free disk space
uses: jlumbroso/free-disk-space@v1.3.1
with:
@@ -213,7 +221,7 @@ jobs:
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: '~> v2'
version: "~> v2"
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -225,7 +233,7 @@ jobs:
contents: write
issues: write
if: github.repository == 'casdoor/casdoor' && github.event_name == 'push' && needs.tag-release.outputs.new-release-published == 'true'
needs: [ tag-release ]
needs: [tag-release]
steps:
- name: Checkout
uses: actions/checkout@v3
@@ -295,7 +303,7 @@ jobs:
if: steps.should_push.outputs.push=='true'
with:
repository: casdoor/casdoor-helm
ref: 'master'
ref: "master"
token: ${{ secrets.GH_BOT_TOKEN }}
- name: Update Helm Chart

1
.gitignore vendored
View File

@@ -20,6 +20,7 @@ bin/
.idea/
*.iml
.vscode/settings.json
.claude
tmp/
tmpFiles/

View File

@@ -1,42 +1,26 @@
linters:
disable-all: true
enable:
- deadcode
- dupl
- errcheck
- goconst
- gocyclo
- gofmt
- goimports
- gosec
- gosimple
- govet
- ineffassign
- lll
- misspell
- nakedret
- prealloc
- staticcheck
- structcheck
- typecheck
- unconvert
- unparam
- unused
- varcheck
- revive
- exportloopref
version: "2"
run:
deadline: 5m
skip-dirs:
- api
# skip-files:
# - ".*_test\\.go$"
modules-download-mode: mod
# all available settings of specific linters
linters-settings:
lll:
# max line length, lines longer will be reported. Default is 120.
# '\t' is counted as 1 character by default, and can be changed with the tab-width option
line-length: 150
# tab width in spaces. Default to 1.
tab-width: 1
relative-path-mode: gomod
modules-download-mode: vendor
linters:
default: none
exclusions:
generated: lax
presets:
- comments
- common-false-positives
- legacy
- std-error-handling
paths:
- third_party$
- builtin$
- examples$
formatters:
enable:
- gofumpt
exclusions:
generated: lax
paths:
- third_party$
- builtin$
- examples$

View File

@@ -9,7 +9,7 @@ RUN yarn install --frozen-lockfile --network-timeout 1000000
COPY ./web .
RUN NODE_OPTIONS="--max-old-space-size=4096" yarn run build
FROM --platform=$BUILDPLATFORM golang:1.24.13 AS BACK
FROM --platform=$BUILDPLATFORM golang:1.25.8 AS BACK
WORKDIR /go/src/casdoor
# Copy only go.mod and go.sum first for dependency caching
@@ -19,7 +19,6 @@ RUN go mod download
# Copy source files
COPY . .
RUN go test -v -run TestGetVersionInfo ./util/system_test.go ./util/system.go ./util/variable.go
RUN ./build.sh
FROM alpine:latest AS STANDARD

View File

@@ -90,12 +90,12 @@ deps: ## Run dependencies for local development
docker compose up -d db
lint-install: ## Install golangci-lint
@# The following installs a specific version of golangci-lint, which is appropriate for a CI server to avoid different results from build to build
go get github.com/golangci/golangci-lint/cmd/golangci-lint@v1.40.1
@# Keep the local golangci-lint version aligned with CI. Both local and CI lint run the gofumpt-only ruleset from .golangci.yml.
GOTOOLCHAIN=go1.25.8 go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.4
lint: ## Run golangci-lint
lint: vendor ## Run golangci-lint
@echo "---lint---"
golangci-lint run --modules-download-mode=vendor ./...
golangci-lint run ./...
##@ Deployment

View File

@@ -69,6 +69,7 @@ p, *, *, GET, /api/get-resources, *, *
p, *, *, GET, /api/get-records, *, *
p, *, *, GET, /api/get-product, *, *
p, *, *, GET, /api/get-products, *, *
p, *, *, POST, /api/buy-product, *, *
p, *, *, GET, /api/get-order, *, *
p, *, *, GET, /api/get-orders, *, *
p, *, *, GET, /api/get-user-orders, *, *
@@ -85,12 +86,19 @@ p, *, *, POST, /api/send-verification-code, *, *
p, *, *, GET, /api/get-captcha, *, *
p, *, *, POST, /api/verify-captcha, *, *
p, *, *, POST, /api/verify-code, *, *
p, *, *, POST, /api/v1/traces, *, *
p, *, *, POST, /api/v1/metrics, *, *
p, *, *, POST, /api/v1/logs, *, *
p, *, *, POST, /api/reset-email-or-phone, *, *
p, *, *, POST, /api/upload-resource, *, *
p, *, *, GET, /.well-known/openid-configuration, *, *
p, *, *, GET, /.well-known/oauth-authorization-server, *, *
p, *, *, GET, /.well-known/oauth-protected-resource, *, *
p, *, *, GET, /.well-known/webfinger, *, *
p, *, *, *, /.well-known/jwks, *, *
p, *, *, GET, /.well-known/:application/openid-configuration, *, *
p, *, *, GET, /.well-known/:application/oauth-authorization-server, *, *
p, *, *, GET, /.well-known/:application/oauth-protected-resource, *, *
p, *, *, GET, /.well-known/:application/webfinger, *, *
p, *, *, *, /.well-known/:application/jwks, *, *
p, *, *, GET, /api/get-saml-login, *, *
@@ -171,7 +179,7 @@ func IsAllowed(subOwner string, subName string, method string, urlPath string, o
return true
}
if user.IsAdmin && (subOwner == objOwner || (objOwner == "admin")) {
if user.IsAdmin && subOwner == objOwner {
return true
}
}

View File

@@ -1,41 +1,37 @@
appname = casdoor
httpport = 8000
runmode = dev
copyrequestbody = true
driverName = mysql
dataSourceName = root:123456@tcp(localhost:3306)/
dbName = casdoor
tableNamePrefix =
showSql = false
redisEndpoint =
defaultStorageProvider =
isCloudIntranet = false
authState = "casdoor"
socks5Proxy = "127.0.0.1:10808"
verificationCodeTimeout = 10
initScore = 0
logPostOnly = true
isUsernameLowered = false
origin =
originFrontend =
staticBaseUrl = "https://cdn.casbin.org"
isDemoMode = false
batchSize = 100
showGithubCorner = false
forceLanguage = ""
defaultLanguage = "en"
aiAssistantUrl = "https://ai.casbin.com"
enableErrorMask = false
enableGzip = true
inactiveTimeoutMinutes =
ldapServerPort = 389
ldapsCertId = ""
ldapsServerPort = 636
radiusServerPort = 1812
radiusDefaultOrganization = "built-in"
radiusSecret = "secret"
quota = {"organization": -1, "user": -1, "application": -1, "provider": -1}
logConfig = {"adapter":"file", "filename": "logs/casdoor.log", "maxdays":99999, "perm":"0770"}
initDataNewOnly = false
initDataFile = "./init_data.json"
frontendBaseDir = "../cc_0"
appname = casdoor
httpport = 8000
runmode = dev
copyrequestbody = true
driverName = postgres
dataSourceName = "user=casdoor password=casdoor_dev host=localhost port=5434 sslmode=disable dbname=casdoor"
dbName = casdoor
tableNamePrefix =
showSql = false
redisEndpoint =
defaultStorageProvider =
isCloudIntranet = false
authState = "casdoor"
socks5Proxy = ""
verificationCodeTimeout = 10
initScore = 0
logPostOnly = true
isUsernameLowered = false
origin = "http://localhost:8000"
originFrontend = "http://localhost:7001"
staticBaseUrl = "https://cdn.casbin.org"
isDemoMode = false
batchSize = 100
showGithubCorner = false
forceLanguage = ""
defaultLanguage = "ru"
enableErrorMask = false
enableGzip = true
ldapServerPort = 389
ldapsServerPort = 636
radiusServerPort = 1812
radiusDefaultOrganization = "built-in"
radiusSecret = "secret"
quota = {"organization": -1, "user": -1, "application": -1, "provider": -1}
logConfig = {"adapter":"file", "filename": "logs/casdoor.log", "maxdays":99999, "perm":"0770"}
initDataNewOnly = false
initDataFile = "./init_data.json"

43
conf/app.conf.orig Normal file
View File

@@ -0,0 +1,43 @@
appname = casdoor
httpport = 8000
runmode = dev
copyrequestbody = true
driverName = mysql
dataSourceName = root:123456@tcp(localhost:3306)/
dbName = casdoor
tableNamePrefix =
showSql = false
redisEndpoint =
defaultStorageProvider =
isCloudIntranet = false
authState = "casdoor"
socks5Proxy = "127.0.0.1:10808"
verificationCodeTimeout = 10
initScore = 0
logPostOnly = true
isUsernameLowered = false
origin =
originFrontend =
staticBaseUrl = "https://cdn.casbin.org"
isDemoMode = false
batchSize = 100
showGithubCorner = false
forceLanguage = ""
defaultLanguage = "en"
aiAssistantUrl = "https://ai.casbin.com"
defaultApplication = "app-built-in"
maxItemsForFlatMenu = 7
enableErrorMask = false
enableGzip = true
inactiveTimeoutMinutes =
ldapServerPort = 389
ldapsCertId = ""
ldapsServerPort = 636
radiusServerPort = 1812
radiusDefaultOrganization = "built-in"
radiusSecret = "secret"
quota = {"organization": -1, "user": -1, "application": -1, "provider": -1}
logConfig = {"adapter":"file", "filename": "logs/casdoor.log", "maxdays":99999, "perm":"0770"}
initDataNewOnly = false
initDataFile = "./init_data.json"
frontendBaseDir = "../cc_0"

37
conf/app.dev.conf Normal file
View File

@@ -0,0 +1,37 @@
appname = casdoor
httpport = 8000
runmode = dev
copyrequestbody = true
driverName = postgres
dataSourceName = "user=casdoor password=casdoor_dev host=localhost port=5434 sslmode=disable dbname=casdoor"
dbName = casdoor
tableNamePrefix =
showSql = false
redisEndpoint =
defaultStorageProvider =
isCloudIntranet = false
authState = "casdoor"
socks5Proxy = ""
verificationCodeTimeout = 10
initScore = 0
logPostOnly = true
isUsernameLowered = false
origin = "http://localhost:8000"
originFrontend = "http://localhost:7001"
staticBaseUrl = "https://cdn.casbin.org"
isDemoMode = false
batchSize = 100
showGithubCorner = false
forceLanguage = ""
defaultLanguage = "ru"
enableErrorMask = false
enableGzip = true
ldapServerPort = 389
ldapsServerPort = 636
radiusServerPort = 1812
radiusDefaultOrganization = "built-in"
radiusSecret = "secret"
quota = {"organization": -1, "user": -1, "application": -1, "provider": -1}
logConfig = {"adapter":"file", "filename": "logs/casdoor.log", "maxdays":99999, "perm":"0770"}
initDataNewOnly = false
initDataFile = "./init_data.json"

View File

@@ -15,12 +15,14 @@
package conf
type WebConfig struct {
ShowGithubCorner bool `json:"showGithubCorner"`
ForceLanguage string `json:"forceLanguage"`
DefaultLanguage string `json:"defaultLanguage"`
IsDemoMode bool `json:"isDemoMode"`
StaticBaseUrl string `json:"staticBaseUrl"`
AiAssistantUrl string `json:"aiAssistantUrl"`
ShowGithubCorner bool `json:"showGithubCorner"`
ForceLanguage string `json:"forceLanguage"`
DefaultLanguage string `json:"defaultLanguage"`
IsDemoMode bool `json:"isDemoMode"`
StaticBaseUrl string `json:"staticBaseUrl"`
AiAssistantUrl string `json:"aiAssistantUrl"`
DefaultApplication string `json:"defaultApplication"`
MaxItemsForFlatMenu int64 `json:"maxItemsForFlatMenu"`
}
func GetWebConfig() *WebConfig {
@@ -32,6 +34,16 @@ func GetWebConfig() *WebConfig {
config.IsDemoMode = IsDemoMode()
config.StaticBaseUrl = GetConfigString("staticBaseUrl")
config.AiAssistantUrl = GetConfigString("aiAssistantUrl")
config.DefaultApplication = GetConfigString("defaultApplication")
if config.DefaultApplication == "" {
config.DefaultApplication = "app-built-in"
}
maxItemsForFlatMenu, err := GetConfigInt64("maxItemsForFlatMenu")
if err != nil {
maxItemsForFlatMenu = 7
}
config.MaxItemsForFlatMenu = maxItemsForFlatMenu
return config
}

View File

@@ -374,6 +374,10 @@ func (c *ApiController) Logout() {
return
}
// Retrieve application and token before clearing the session
application := c.GetSessionApplication()
sessionToken := c.GetSessionToken()
c.ClearUserSession()
c.ClearTokenSession()
@@ -382,7 +386,9 @@ func (c *ApiController) Logout() {
return
}
application := c.GetSessionApplication()
// Propagate logout to external Custom OAuth2 providers
object.InvokeCustomProviderLogout(application, sessionToken)
if application == nil || application.Name == "app-built-in" || application.HomepageUrl == "" {
c.ResponseOk(user)
return
@@ -427,6 +433,9 @@ func (c *ApiController) Logout() {
return
}
// Propagate logout to external Custom OAuth2 providers
object.InvokeCustomProviderLogout(application, accessToken)
if redirectUri == "" {
c.ResponseOk()
return
@@ -469,6 +478,10 @@ func (c *ApiController) SsoLogout() {
logoutAll := c.Ctx.Input.Query("logoutAll")
logoutAllSessions := logoutAll == "" || logoutAll == "true" || logoutAll == "1"
// Retrieve application and token before clearing the session
ssoApplication := c.GetSessionApplication()
ssoSessionToken := c.GetSessionToken()
c.ClearUserSession()
c.ClearTokenSession()
owner, username, err := util.GetOwnerAndNameFromIdWithError(user)
@@ -548,6 +561,9 @@ func (c *ApiController) SsoLogout() {
}
}
// Propagate logout to external Custom OAuth2 providers
object.InvokeCustomProviderLogout(ssoApplication, ssoSessionToken)
c.ResponseOk()
}

149
controllers/agent.go Normal file
View File

@@ -0,0 +1,149 @@
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package controllers
import (
"encoding/json"
"github.com/beego/beego/v2/server/web/pagination"
"github.com/casdoor/casdoor/object"
"github.com/casdoor/casdoor/util"
)
// GetAgents
// @Title GetAgents
// @Tag Agent API
// @Description get agents
// @Param owner query string true "The owner of agents"
// @Success 200 {array} object.Agent The Response object
// @router /get-agents [get]
func (c *ApiController) GetAgents() {
owner := c.Ctx.Input.Query("owner")
if owner == "admin" {
owner = ""
}
limit := c.Ctx.Input.Query("pageSize")
page := c.Ctx.Input.Query("p")
field := c.Ctx.Input.Query("field")
value := c.Ctx.Input.Query("value")
sortField := c.Ctx.Input.Query("sortField")
sortOrder := c.Ctx.Input.Query("sortOrder")
if limit == "" || page == "" {
agents, err := object.GetAgents(owner)
if err != nil {
c.ResponseError(err.Error())
return
}
c.ResponseOk(agents)
return
}
limitInt := util.ParseInt(limit)
count, err := object.GetAgentCount(owner, field, value)
if err != nil {
c.ResponseError(err.Error())
return
}
paginator := pagination.SetPaginator(c.Ctx, limitInt, count)
agents, err := object.GetPaginationAgents(owner, paginator.Offset(), limitInt, field, value, sortField, sortOrder)
if err != nil {
c.ResponseError(err.Error())
return
}
c.ResponseOk(agents, paginator.Nums())
}
// GetAgent
// @Title GetAgent
// @Tag Agent API
// @Description get agent
// @Param id query string true "The id ( owner/name ) of the agent"
// @Success 200 {object} object.Agent The Response object
// @router /get-agent [get]
func (c *ApiController) GetAgent() {
id := c.Ctx.Input.Query("id")
agent, err := object.GetAgent(id)
if err != nil {
c.ResponseError(err.Error())
return
}
c.ResponseOk(agent)
}
// UpdateAgent
// @Title UpdateAgent
// @Tag Agent API
// @Description update agent
// @Param id query string true "The id ( owner/name ) of the agent"
// @Param body body object.Agent true "The details of the agent"
// @Success 200 {object} controllers.Response The Response object
// @router /update-agent [post]
func (c *ApiController) UpdateAgent() {
id := c.Ctx.Input.Query("id")
var agent object.Agent
err := json.Unmarshal(c.Ctx.Input.RequestBody, &agent)
if err != nil {
c.ResponseError(err.Error())
return
}
c.Data["json"] = wrapActionResponse(object.UpdateAgent(id, &agent))
c.ServeJSON()
}
// AddAgent
// @Title AddAgent
// @Tag Agent API
// @Description add agent
// @Param body body object.Agent true "The details of the agent"
// @Success 200 {object} controllers.Response The Response object
// @router /add-agent [post]
func (c *ApiController) AddAgent() {
var agent object.Agent
err := json.Unmarshal(c.Ctx.Input.RequestBody, &agent)
if err != nil {
c.ResponseError(err.Error())
return
}
c.Data["json"] = wrapActionResponse(object.AddAgent(&agent))
c.ServeJSON()
}
// DeleteAgent
// @Title DeleteAgent
// @Tag Agent API
// @Description delete agent
// @Param body body object.Agent true "The details of the agent"
// @Success 200 {object} controllers.Response The Response object
// @router /delete-agent [post]
func (c *ApiController) DeleteAgent() {
var agent object.Agent
err := json.Unmarshal(c.Ctx.Input.RequestBody, &agent)
if err != nil {
c.ResponseError(err.Error())
return
}
c.Data["json"] = wrapActionResponse(object.DeleteAgent(&agent))
c.ServeJSON()
}

View File

@@ -37,7 +37,6 @@ import (
"github.com/casdoor/casdoor/object"
"github.com/casdoor/casdoor/proxy"
"github.com/casdoor/casdoor/util"
"github.com/google/uuid"
"golang.org/x/oauth2"
)
@@ -938,14 +937,7 @@ func (c *ApiController) Login() {
}
if tmpUser != nil {
var uid uuid.UUID
uid, err = uuid.NewRandom()
if err != nil {
c.ResponseError(err.Error())
return
}
uidStr := strings.Split(uid.String(), "-")
uidStr := strings.Split(util.GenerateUUID(), "-")
userInfo.Username = fmt.Sprintf("%s_%s", userInfo.Username, uidStr[1])
}

View File

@@ -21,6 +21,7 @@ import (
"github.com/beego/beego/v2/core/logs"
"github.com/beego/beego/v2/server/web"
"github.com/casdoor/casdoor/mcpself"
"github.com/casdoor/casdoor/object"
"github.com/casdoor/casdoor/util"
)
@@ -291,3 +292,14 @@ func (c *ApiController) Finish() {
}
c.Controller.Finish()
}
func (c *ApiController) McpResponseError(id interface{}, code int, message string, data interface{}) {
resp := mcpself.BuildMcpResponse(id, nil, &mcpself.McpError{
Code: code,
Message: message,
Data: data,
})
c.Ctx.Output.Header("Content-Type", "application/json")
c.Data["json"] = resp
c.ServeJSON()
}

View File

@@ -57,7 +57,9 @@ func (c *ApiController) Enforce() {
return
}
var request []string
// Accept both plain string arrays (["alice","data1","read"]) and mixed arrays
// with JSON objects ([{"DivisionGuid":"x"}, "resource", "read"]) for ABAC support.
var request []interface{}
err := json.Unmarshal(c.Ctx.Input.RequestBody, &request)
if err != nil {
c.ResponseError(err.Error())
@@ -74,8 +76,8 @@ func (c *ApiController) Enforce() {
res := []bool{}
keyRes := []string{}
// type transformation
interfaceRequest := util.StringToInterfaceArray(request)
// Convert elements: JSON-object strings and maps become anonymous structs for ABAC.
interfaceRequest := util.InterfaceToEnforceArray(request)
enforceResult, err := enforcer.Enforce(interfaceRequest...)
if err != nil {
@@ -197,7 +199,8 @@ func (c *ApiController) BatchEnforce() {
return
}
var requests [][]string
// Accept both string arrays and mixed arrays with JSON objects for ABAC support.
var requests [][]interface{}
err := json.Unmarshal(c.Ctx.Input.RequestBody, &requests)
if err != nil {
c.ResponseError(err.Error())
@@ -214,8 +217,8 @@ func (c *ApiController) BatchEnforce() {
res := [][]bool{}
keyRes := []string{}
// type transformation
interfaceRequests := util.StringToInterfaceArray2d(requests)
// Convert elements: JSON-object strings and maps become anonymous structs for ABAC.
interfaceRequests := util.InterfaceToEnforceArray2d(requests)
enforceResult, err := enforcer.BatchEnforce(interfaceRequests)
if err != nil {

View File

@@ -26,6 +26,8 @@ import (
"strings"
"sync"
"time"
"github.com/casdoor/casdoor/conf"
)
type CLIVersionInfo struct {
@@ -164,6 +166,11 @@ func processArgsToTempFiles(args []string) ([]string, []string, error) {
// @Success 200 {object} controllers.Response The Response object
// @router /run-casbin-command [get]
func (c *ApiController) RunCasbinCommand() {
if !conf.IsDemoMode() && !c.IsAdmin() {
c.ResponseError(c.T("auth:Unauthorized operation"))
return
}
if err := validateIdentifier(c); err != nil {
c.ResponseError(err.Error())
return

View File

@@ -16,6 +16,7 @@ import (
"time"
"github.com/beego/beego/v2/server/web"
"github.com/casdoor/casdoor/conf"
"github.com/casdoor/casdoor/proxy"
"github.com/casdoor/casdoor/util"
)
@@ -446,8 +447,8 @@ func downloadCLI() error {
// @Success 200 {object} controllers.Response The Response object
// @router /refresh-engines [post]
func (c *ApiController) RefreshEngines() {
if !web.AppConfig.DefaultBool("isDemoMode", false) {
c.ResponseError("refresh engines is only available in demo mode")
if !conf.IsDemoMode() && !c.IsAdmin() {
c.ResponseError(c.T("auth:Unauthorized operation"))
return
}

168
controllers/entry.go Normal file
View File

@@ -0,0 +1,168 @@
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package controllers
import (
"encoding/json"
"github.com/beego/beego/v2/server/web/pagination"
"github.com/casdoor/casdoor/object"
"github.com/casdoor/casdoor/util"
)
// GetEntries
// @Title GetEntries
// @Tag Entry API
// @Description get entries
// @Param owner query string true "The owner of entries"
// @Success 200 {array} object.Entry The Response object
// @router /get-entries [get]
func (c *ApiController) GetEntries() {
owner := c.Ctx.Input.Query("owner")
if owner == "admin" {
owner = ""
}
limit := c.Ctx.Input.Query("pageSize")
page := c.Ctx.Input.Query("p")
field := c.Ctx.Input.Query("field")
value := c.Ctx.Input.Query("value")
sortField := c.Ctx.Input.Query("sortField")
sortOrder := c.Ctx.Input.Query("sortOrder")
if limit == "" || page == "" {
entries, err := object.GetEntries(owner)
if err != nil {
c.ResponseError(err.Error())
return
}
c.ResponseOk(entries)
return
}
limitInt := util.ParseInt(limit)
count, err := object.GetEntryCount(owner, field, value)
if err != nil {
c.ResponseError(err.Error())
return
}
paginator := pagination.SetPaginator(c.Ctx, limitInt, count)
entries, err := object.GetPaginationEntries(owner, paginator.Offset(), limitInt, field, value, sortField, sortOrder)
if err != nil {
c.ResponseError(err.Error())
return
}
c.ResponseOk(entries, paginator.Nums())
}
// GetEntry
// @Title GetEntry
// @Tag Entry API
// @Description get entry
// @Param id query string true "The id ( owner/name ) of the entry"
// @Success 200 {object} object.Entry The Response object
// @router /get-entry [get]
func (c *ApiController) GetEntry() {
id := c.Ctx.Input.Query("id")
entry, err := object.GetEntry(id)
if err != nil {
c.ResponseError(err.Error())
return
}
c.ResponseOk(entry)
}
// GetOpenClawSessionGraph
// @Title GetOpenClawSessionGraph
// @Tag Entry API
// @Description get OpenClaw session graph
// @Param id query string true "The id ( owner/name ) of the entry"
// @Success 200 {object} object.OpenClawSessionGraph The Response object
// @router /get-openclaw-session-graph [get]
func (c *ApiController) GetOpenClawSessionGraph() {
id := c.Ctx.Input.Query("id")
graph, err := object.GetOpenClawSessionGraph(id)
if err != nil {
c.ResponseError(err.Error())
return
}
c.ResponseOk(graph)
}
// UpdateEntry
// @Title UpdateEntry
// @Tag Entry API
// @Description update entry
// @Param id query string true "The id ( owner/name ) of the entry"
// @Param body body object.Entry true "The details of the entry"
// @Success 200 {object} controllers.Response The Response object
// @router /update-entry [post]
func (c *ApiController) UpdateEntry() {
id := c.Ctx.Input.Query("id")
var entry object.Entry
err := json.Unmarshal(c.Ctx.Input.RequestBody, &entry)
if err != nil {
c.ResponseError(err.Error())
return
}
c.Data["json"] = wrapActionResponse(object.UpdateEntry(id, &entry))
c.ServeJSON()
}
// AddEntry
// @Title AddEntry
// @Tag Entry API
// @Description add entry
// @Param body body object.Entry true "The details of the entry"
// @Success 200 {object} controllers.Response The Response object
// @router /add-entry [post]
func (c *ApiController) AddEntry() {
var entry object.Entry
err := json.Unmarshal(c.Ctx.Input.RequestBody, &entry)
if err != nil {
c.ResponseError(err.Error())
return
}
c.Data["json"] = wrapActionResponse(object.AddEntry(&entry))
c.ServeJSON()
}
// DeleteEntry
// @Title DeleteEntry
// @Tag Entry API
// @Description delete entry
// @Param body body object.Entry true "The details of the entry"
// @Success 200 {object} controllers.Response The Response object
// @router /delete-entry [post]
func (c *ApiController) DeleteEntry() {
var entry object.Entry
err := json.Unmarshal(c.Ctx.Input.RequestBody, &entry)
if err != nil {
c.ResponseError(err.Error())
return
}
c.Data["json"] = wrapActionResponse(object.DeleteEntry(&entry))
c.ServeJSON()
}

View File

@@ -0,0 +1,148 @@
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package controllers
import (
collogspb "go.opentelemetry.io/proto/otlp/collector/logs/v1"
colmetricspb "go.opentelemetry.io/proto/otlp/collector/metrics/v1"
coltracepb "go.opentelemetry.io/proto/otlp/collector/trace/v1"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"
"github.com/casdoor/casdoor/util"
)
// @Title AddOtlpTrace
// @Tag OTLP API
// @Description receive otlp trace protobuf
// @Success 200 {object} string
// @router /api/v1/traces [post]
func (c *ApiController) AddOtlpTrace() {
body := readProtobufBody(c.Ctx)
if body == nil {
return
}
provider, status, err := resolveOpenClawProvider(c.Ctx)
if err != nil {
responseOtlpError(c.Ctx, status, body, "%s", err.Error())
return
}
var req coltracepb.ExportTraceServiceRequest
if err := proto.Unmarshal(body, &req); err != nil {
responseOtlpError(c.Ctx, 400, body, "bad protobuf: %v", err)
return
}
message, err := protojson.MarshalOptions{Multiline: true, Indent: " "}.Marshal(&req)
if err != nil {
responseOtlpError(c.Ctx, 500, body, "marshal trace failed: %v", err)
return
}
clientIp := util.GetClientIpFromRequest(c.Ctx.Request)
userAgent := c.Ctx.Request.Header.Get("User-Agent")
if err := provider.AddTrace(message, clientIp, userAgent); err != nil {
responseOtlpError(c.Ctx, 500, body, "save trace failed: %v", err)
return
}
resp, _ := proto.Marshal(&coltracepb.ExportTraceServiceResponse{})
c.Ctx.Output.Header("Content-Type", "application/x-protobuf")
c.Ctx.Output.SetStatus(200)
c.Ctx.Output.Body(resp)
}
// @Title AddOtlpMetrics
// @Tag OTLP API
// @Description receive otlp metrics protobuf
// @Success 200 {object} string
// @router /api/v1/metrics [post]
func (c *ApiController) AddOtlpMetrics() {
body := readProtobufBody(c.Ctx)
if body == nil {
return
}
provider, status, err := resolveOpenClawProvider(c.Ctx)
if err != nil {
responseOtlpError(c.Ctx, status, body, "%s", err.Error())
return
}
var req colmetricspb.ExportMetricsServiceRequest
if err := proto.Unmarshal(body, &req); err != nil {
responseOtlpError(c.Ctx, 400, body, "bad protobuf: %v", err)
return
}
message, err := protojson.MarshalOptions{Multiline: true, Indent: " "}.Marshal(&req)
if err != nil {
responseOtlpError(c.Ctx, 500, body, "marshal metrics failed: %v", err)
return
}
clientIp := util.GetClientIpFromRequest(c.Ctx.Request)
userAgent := c.Ctx.Request.Header.Get("User-Agent")
if err := provider.AddMetrics(message, clientIp, userAgent); err != nil {
responseOtlpError(c.Ctx, 500, body, "save metrics failed: %v", err)
return
}
resp, _ := proto.Marshal(&colmetricspb.ExportMetricsServiceResponse{})
c.Ctx.Output.Header("Content-Type", "application/x-protobuf")
c.Ctx.Output.SetStatus(200)
c.Ctx.Output.Body(resp)
}
// @Title AddOtlpLogs
// @Tag OTLP API
// @Description receive otlp logs protobuf
// @Success 200 {object} string
// @router /api/v1/logs [post]
func (c *ApiController) AddOtlpLogs() {
body := readProtobufBody(c.Ctx)
if body == nil {
return
}
provider, status, err := resolveOpenClawProvider(c.Ctx)
if err != nil {
responseOtlpError(c.Ctx, status, body, "%s", err.Error())
return
}
var req collogspb.ExportLogsServiceRequest
if err := proto.Unmarshal(body, &req); err != nil {
responseOtlpError(c.Ctx, 400, body, "bad protobuf: %v", err)
return
}
message, err := protojson.MarshalOptions{Multiline: true, Indent: " "}.Marshal(&req)
if err != nil {
responseOtlpError(c.Ctx, 500, body, "marshal logs failed: %v", err)
return
}
clientIp := util.GetClientIpFromRequest(c.Ctx.Request)
userAgent := c.Ctx.Request.Header.Get("User-Agent")
if err := provider.AddLogs(message, clientIp, userAgent); err != nil {
responseOtlpError(c.Ctx, 500, body, "save logs failed: %v", err)
return
}
resp, _ := proto.Marshal(&collogspb.ExportLogsServiceResponse{})
c.Ctx.Output.Header("Content-Type", "application/x-protobuf")
c.Ctx.Output.SetStatus(200)
c.Ctx.Output.Body(resp)
}

78
controllers/entry_util.go Normal file
View File

@@ -0,0 +1,78 @@
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package controllers
import (
"fmt"
"io"
"strings"
"github.com/beego/beego/v2/server/web/context"
"github.com/casdoor/casdoor/log"
"github.com/casdoor/casdoor/object"
"github.com/casdoor/casdoor/util"
)
func responseOtlpError(ctx *context.Context, status int, body []byte, format string, args ...interface{}) {
msg := fmt.Sprintf(format, args...)
req := ctx.Request
bodyInfo := "(no body)"
if len(body) > 0 {
bodyInfo = fmt.Sprintf("%d bytes: %q", len(body), truncate(body, 256))
}
fmt.Printf("responseOtlpError: [%d] %s | %s %s | remoteAddr=%s | Content-Type=%s | User-Agent=%s | body=%s\n",
status, msg,
req.Method, req.URL.Path,
req.RemoteAddr,
req.Header.Get("Content-Type"),
req.Header.Get("User-Agent"),
bodyInfo,
)
ctx.Output.SetStatus(status)
ctx.Output.Body([]byte(msg))
}
func truncate(b []byte, max int) []byte {
if len(b) <= max {
return b
}
return b[:max]
}
func resolveOpenClawProvider(ctx *context.Context) (*log.OpenClawProvider, int, error) {
clientIP := util.GetClientIpFromRequest(ctx.Request)
provider, err := object.GetOpenClawProviderByIP(clientIP)
if err != nil {
return nil, 500, fmt.Errorf("provider lookup failed: %w", err)
}
if provider == nil {
return nil, 403, fmt.Errorf("forbidden: no OpenClaw provider configured for IP %s", clientIP)
}
return provider, 0, nil
}
func readProtobufBody(ctx *context.Context) []byte {
if !strings.HasPrefix(ctx.Input.Header("Content-Type"), "application/x-protobuf") {
preview, _ := io.ReadAll(io.LimitReader(ctx.Request.Body, 256))
responseOtlpError(ctx, 415, preview, "unsupported content type")
return nil
}
body, err := io.ReadAll(ctx.Request.Body)
if err != nil {
responseOtlpError(ctx, 400, nil, "read body failed")
return nil
}
return body
}

222
controllers/key.go Normal file
View File

@@ -0,0 +1,222 @@
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package controllers
import (
"encoding/json"
"github.com/beego/beego/v2/core/utils/pagination"
"github.com/casdoor/casdoor/object"
"github.com/casdoor/casdoor/util"
)
// GetKeys
// @Title GetKeys
// @Tag Key API
// @Description get keys
// @Param owner query string true "The owner of keys"
// @Success 200 {array} object.Key The Response object
// @router /get-keys [get]
func (c *ApiController) GetKeys() {
owner := c.Ctx.Input.Query("owner")
limit := c.Ctx.Input.Query("pageSize")
page := c.Ctx.Input.Query("p")
field := c.Ctx.Input.Query("field")
value := c.Ctx.Input.Query("value")
sortField := c.Ctx.Input.Query("sortField")
sortOrder := c.Ctx.Input.Query("sortOrder")
if limit == "" || page == "" {
keys, err := object.GetKeys(owner)
if err != nil {
c.ResponseError(err.Error())
return
}
maskedKeys, err := object.GetMaskedKeys(keys, true, nil)
if err != nil {
c.ResponseError(err.Error())
return
}
c.ResponseOk(maskedKeys)
} else {
limit := util.ParseInt(limit)
count, err := object.GetKeyCount(owner, field, value)
if err != nil {
c.ResponseError(err.Error())
return
}
paginator := pagination.NewPaginator(c.Ctx.Request, limit, count)
keys, err := object.GetPaginationKeys(owner, paginator.Offset(), limit, field, value, sortField, sortOrder)
if err != nil {
c.ResponseError(err.Error())
return
}
maskedKeys, err := object.GetMaskedKeys(keys, true, nil)
if err != nil {
c.ResponseError(err.Error())
return
}
c.ResponseOk(maskedKeys, paginator.Nums())
}
}
// GetGlobalKeys
// @Title GetGlobalKeys
// @Tag Key API
// @Description get global keys
// @Success 200 {array} object.Key The Response object
// @router /get-global-keys [get]
func (c *ApiController) GetGlobalKeys() {
limit := c.Ctx.Input.Query("pageSize")
page := c.Ctx.Input.Query("p")
field := c.Ctx.Input.Query("field")
value := c.Ctx.Input.Query("value")
sortField := c.Ctx.Input.Query("sortField")
sortOrder := c.Ctx.Input.Query("sortOrder")
if limit == "" || page == "" {
keys, err := object.GetGlobalKeys()
if err != nil {
c.ResponseError(err.Error())
return
}
maskedKeys, err := object.GetMaskedKeys(keys, true, nil)
if err != nil {
c.ResponseError(err.Error())
return
}
c.ResponseOk(maskedKeys)
} else {
limit := util.ParseInt(limit)
count, err := object.GetGlobalKeyCount(field, value)
if err != nil {
c.ResponseError(err.Error())
return
}
paginator := pagination.NewPaginator(c.Ctx.Request, limit, count)
keys, err := object.GetPaginationGlobalKeys(paginator.Offset(), limit, field, value, sortField, sortOrder)
if err != nil {
c.ResponseError(err.Error())
return
}
maskedKeys, err := object.GetMaskedKeys(keys, true, nil)
if err != nil {
c.ResponseError(err.Error())
return
}
c.ResponseOk(maskedKeys, paginator.Nums())
}
}
// GetKey
// @Title GetKey
// @Tag Key API
// @Description get key
// @Param id query string true "The id ( owner/name ) of the key"
// @Success 200 {object} object.Key The Response object
// @router /get-key [get]
func (c *ApiController) GetKey() {
id := c.Ctx.Input.Query("id")
key, err := object.GetKey(id)
if err != nil {
c.ResponseError(err.Error())
return
}
c.ResponseOk(key)
}
// UpdateKey
// @Title UpdateKey
// @Tag Key API
// @Description update key
// @Param id query string true "The id ( owner/name ) of the key"
// @Param body body object.Key true "The details of the key"
// @Success 200 {object} controllers.Response The Response object
// @router /update-key [post]
func (c *ApiController) UpdateKey() {
id := c.Ctx.Input.Query("id")
oldKey, err := object.GetKey(id)
if err != nil {
c.ResponseError(err.Error())
return
}
if oldKey == nil {
c.Data["json"] = wrapActionResponse(false)
c.ServeJSON()
return
}
var key object.Key
err = json.Unmarshal(c.Ctx.Input.RequestBody, &key)
if err != nil {
c.ResponseError(err.Error())
return
}
if !c.IsGlobalAdmin() && oldKey.Owner != key.Owner {
c.ResponseError(c.T("auth:Unauthorized operation"))
return
}
c.Data["json"] = wrapActionResponse(object.UpdateKey(id, &key))
c.ServeJSON()
}
// AddKey
// @Title AddKey
// @Tag Key API
// @Description add key
// @Param body body object.Key true "The details of the key"
// @Success 200 {object} controllers.Response The Response object
// @router /add-key [post]
func (c *ApiController) AddKey() {
var key object.Key
err := json.Unmarshal(c.Ctx.Input.RequestBody, &key)
if err != nil {
c.ResponseError(err.Error())
return
}
c.Data["json"] = wrapActionResponse(object.AddKey(&key))
c.ServeJSON()
}
// DeleteKey
// @Title DeleteKey
// @Tag Key API
// @Description delete key
// @Param body body object.Key true "The details of the key"
// @Success 200 {object} controllers.Response The Response object
// @router /delete-key [post]
func (c *ApiController) DeleteKey() {
var key object.Key
err := json.Unmarshal(c.Ctx.Input.RequestBody, &key)
if err != nil {
c.ResponseError(err.Error())
return
}
c.Data["json"] = wrapActionResponse(object.DeleteKey(&key))
c.ServeJSON()
}

112
controllers/mcp_server.go Normal file
View File

@@ -0,0 +1,112 @@
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package controllers
import (
"encoding/json"
"net/http"
"net/http/httputil"
"net/url"
"github.com/casdoor/casdoor/mcpself"
"github.com/casdoor/casdoor/object"
"github.com/casdoor/casdoor/util"
)
// ProxyServer
// @Title ProxyServer
// @Tag Server API
// @Description proxy request to the upstream MCP server by Server URL
// @Param owner path string true "The owner name of the server"
// @Param name path string true "The name of the server"
// @Success 200 {object} mcp.McpResponse The Response object
// @router /server/:owner/:name [get,post]
func (c *ApiController) ProxyServer() {
owner := c.Ctx.Input.Param(":owner")
name := c.Ctx.Input.Param(":name")
var mcpReq *mcpself.McpRequest
err := json.Unmarshal(c.Ctx.Input.RequestBody, &mcpReq)
if err != nil {
c.McpResponseError(1, -32700, "Parse error", err.Error())
return
}
if util.IsStringsEmpty(owner, name) {
c.McpResponseError(1, -32600, "invalid server identifier", nil)
return
}
server, err := object.GetServer(util.GetId(owner, name))
if err != nil {
c.McpResponseError(mcpReq.ID, -32600, "server not found", err.Error())
return
}
if server == nil {
c.McpResponseError(mcpReq.ID, -32600, "server not found", nil)
return
}
if server.Url == "" {
c.McpResponseError(mcpReq.ID, -32600, "server URL is empty", nil)
return
}
targetUrl, err := url.Parse(server.Url)
if err != nil || !targetUrl.IsAbs() || targetUrl.Host == "" {
c.McpResponseError(mcpReq.ID, -32600, "server URL is invalid", nil)
return
}
if targetUrl.Scheme != "http" && targetUrl.Scheme != "https" {
c.McpResponseError(mcpReq.ID, -32600, "server URL scheme is invalid", nil)
return
}
if mcpReq.Method == "tools/call" {
var params mcpself.McpCallToolParams
err = json.Unmarshal(mcpReq.Params, &params)
if err != nil {
c.McpResponseError(mcpReq.ID, -32600, "Invalid request", err.Error())
return
}
for _, tool := range server.Tools {
if tool.Name == params.Name && !tool.IsAllowed {
c.McpResponseError(mcpReq.ID, -32600, "tool is forbidden", nil)
return
} else if tool.Name == params.Name {
break
}
}
}
proxy := httputil.NewSingleHostReverseProxy(targetUrl)
proxy.ErrorHandler = func(writer http.ResponseWriter, request *http.Request, proxyErr error) {
c.Ctx.Output.SetStatus(http.StatusBadGateway)
c.McpResponseError(mcpReq.ID, -32603, "failed to proxy server request: %s", proxyErr.Error())
}
proxy.Director = func(request *http.Request) {
request.URL.Scheme = targetUrl.Scheme
request.URL.Host = targetUrl.Host
request.Host = targetUrl.Host
request.URL.Path = targetUrl.Path
request.URL.RawPath = ""
request.URL.RawQuery = targetUrl.RawQuery
if server.Token != "" {
request.Header.Set("Authorization", "Bearer "+server.Token)
}
}
proxy.ServeHTTP(c.Ctx.ResponseWriter, c.Ctx.Request)
}

View File

@@ -19,7 +19,6 @@ import (
"github.com/casdoor/casdoor/object"
"github.com/casdoor/casdoor/util"
"github.com/google/uuid"
)
// MfaSetupInitiate
@@ -77,7 +76,7 @@ func (c *ApiController) MfaSetupInitiate() {
return
}
recoveryCode := uuid.NewString()
recoveryCode := util.GenerateUUID()
mfaProps.RecoveryCodes = []string{recoveryCode}
mfaProps.MfaRememberInHours = organization.MfaRememberInHours

View File

@@ -16,6 +16,8 @@ package controllers
import (
"encoding/json"
"fmt"
"strconv"
"github.com/beego/beego/v2/core/utils/pagination"
"github.com/casdoor/casdoor/object"
@@ -149,3 +151,78 @@ func (c *ApiController) DeleteProduct() {
c.Data["json"] = wrapActionResponse(object.DeleteProduct(&product))
c.ServeJSON()
}
// BuyProduct
// @Title BuyProduct (Deprecated)
// @Tag Product API
// @Description buy product using the deprecated compatibility endpoint, prefer place-order plus pay-order for new integrations
// @Param id query string true "The id ( owner/name ) of the product"
// @Param providerName query string true "The name of the provider"
// @Param pricingName query string false "The name of the pricing (for subscription)"
// @Param planName query string false "The name of the plan (for subscription)"
// @Param userName query string false "The username to buy product for (admin only)"
// @Param paymentEnv query string false "The payment environment"
// @Param customPrice query number false "Custom price for recharge products"
// @Success 200 {object} controllers.Response The Response object
// @router /buy-product [post]
func (c *ApiController) BuyProduct() {
id := c.Ctx.Input.Query("id")
host := c.Ctx.Request.Host
providerName := c.Ctx.Input.Query("providerName")
paymentEnv := c.Ctx.Input.Query("paymentEnv")
customPriceStr := c.Ctx.Input.Query("customPrice")
if customPriceStr == "" {
customPriceStr = "0"
}
customPrice, err := strconv.ParseFloat(customPriceStr, 64)
if err != nil {
c.ResponseError(err.Error())
return
}
pricingName := c.Ctx.Input.Query("pricingName")
planName := c.Ctx.Input.Query("planName")
paidUserName := c.Ctx.Input.Query("userName")
owner, _, err := util.GetOwnerAndNameFromIdWithError(id)
if err != nil {
c.ResponseError(err.Error())
return
}
var userId string
if paidUserName != "" {
userId = util.GetId(owner, paidUserName)
if userId != c.GetSessionUsername() && !c.IsAdmin() && userId != c.GetPaidUsername() {
c.ResponseError(c.T("general:Only admin user can specify user"))
return
}
c.SetSession("paidUsername", "")
} else {
userId = c.GetSessionUsername()
}
if userId == "" {
c.ResponseError(c.T("general:Please login first"))
return
}
user, err := object.GetUser(userId)
if err != nil {
c.ResponseError(err.Error())
return
}
if user == nil {
c.ResponseError(fmt.Sprintf(c.T("general:The user: %s doesn't exist"), userId))
return
}
payment, attachInfo, err := object.BuyProduct(id, user, providerName, pricingName, planName, host, paymentEnv, customPrice, c.GetAcceptLanguage())
if err != nil {
c.ResponseError(err.Error())
return
}
c.ResponseOk(payment, attachInfo)
}

View File

@@ -17,8 +17,6 @@ package controllers
import (
"encoding/json"
"github.com/casvisor/casvisor-go-sdk/casvisorsdk"
"github.com/beego/beego/v2/core/utils/pagination"
"github.com/casdoor/casdoor/object"
"github.com/casdoor/casdoor/util"
@@ -59,7 +57,7 @@ func (c *ApiController) GetRecords() {
if c.IsGlobalAdmin() && organizationName != "" {
organization = organizationName
}
filterRecord := &casvisorsdk.Record{Organization: organization}
filterRecord := &object.Record{Organization: organization}
count, err := object.GetRecordCount(field, value, filterRecord)
if err != nil {
c.ResponseError(err.Error())
@@ -92,7 +90,7 @@ func (c *ApiController) GetRecordsByFilter() {
body := string(c.Ctx.Input.RequestBody)
record := &casvisorsdk.Record{}
record := &object.Record{}
err := util.JsonToStruct(body, record)
if err != nil {
c.ResponseError(err.Error())
@@ -116,7 +114,7 @@ func (c *ApiController) GetRecordsByFilter() {
// @Success 200 {object} controllers.Response The Response object
// @router /add-record [post]
func (c *ApiController) AddRecord() {
var record casvisorsdk.Record
var record object.Record
err := json.Unmarshal(c.Ctx.Input.RequestBody, &record)
if err != nil {
c.ResponseError(err.Error())

View File

@@ -16,10 +16,6 @@ package controllers
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httputil"
"net/url"
"github.com/beego/beego/v2/server/web/pagination"
"github.com/casdoor/casdoor/object"
@@ -114,6 +110,30 @@ func (c *ApiController) UpdateServer() {
c.ServeJSON()
}
// SyncMcpTool
// @Title SyncMcpTool
// @Tag Server API
// @Description sync MCP tools for a server and return sync errors directly
// @Param id query string true "The id ( owner/name ) of the server"
// @Param isCleared query bool false "Whether to clear all tools instead of syncing"
// @Param body body object.Server true "The details of the server"
// @Success 200 {object} controllers.Response The Response object
// @router /sync-mcp-tool [post]
func (c *ApiController) SyncMcpTool() {
id := c.Ctx.Input.Query("id")
isCleared := c.Ctx.Input.Query("isCleared") == "1"
var server object.Server
err := json.Unmarshal(c.Ctx.Input.RequestBody, &server)
if err != nil {
c.ResponseError(err.Error())
return
}
c.Data["json"] = wrapActionResponse(object.SyncMcpTool(id, &server, isCleared))
c.ServeJSON()
}
// AddServer
// @Title AddServer
// @Tag Server API
@@ -151,61 +171,3 @@ func (c *ApiController) DeleteServer() {
c.Data["json"] = wrapActionResponse(object.DeleteServer(&server))
c.ServeJSON()
}
// ProxyServer
// @Title ProxyServer
// @Tag Server API
// @Description proxy request to the upstream MCP server by Server URL
// @Param owner path string true "The owner name of the server"
// @Param name path string true "The name of the server"
// @Success 200 {object} controllers.Response The Response object
// @router /server/:owner/:name [get,post]
func (c *ApiController) ProxyServer() {
owner := c.Ctx.Input.Param(":owner")
name := c.Ctx.Input.Param(":name")
if util.IsStringsEmpty(owner, name) {
c.ResponseError("invalid server identifier")
return
}
server, err := object.GetServer(util.GetId(owner, name))
if err != nil {
c.ResponseError(err.Error())
return
}
if server == nil {
c.ResponseError("server not found")
return
}
if server.Url == "" {
c.ResponseError("server URL is empty")
return
}
targetUrl, err := url.Parse(server.Url)
if err != nil || !targetUrl.IsAbs() || targetUrl.Host == "" {
c.ResponseError("server URL is invalid")
return
}
if targetUrl.Scheme != "http" && targetUrl.Scheme != "https" {
c.ResponseError("server URL scheme is invalid")
return
}
proxy := httputil.NewSingleHostReverseProxy(targetUrl)
proxy.ErrorHandler = func(writer http.ResponseWriter, request *http.Request, proxyErr error) {
c.Ctx.Output.SetStatus(http.StatusBadGateway)
c.ResponseError(fmt.Sprintf("failed to proxy server request: %s", proxyErr.Error()))
}
proxy.Director = func(request *http.Request) {
request.URL.Scheme = targetUrl.Scheme
request.URL.Host = targetUrl.Host
request.Host = targetUrl.Host
request.URL.Path = targetUrl.Path
request.URL.RawPath = ""
request.URL.RawQuery = targetUrl.RawQuery
}
proxy.ServeHTTP(c.Ctx.ResponseWriter, c.Ctx.Request)
}

View File

@@ -0,0 +1,56 @@
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package controllers
import (
"encoding/json"
"fmt"
"net/http"
"time"
)
const onlineServerListUrl = "https://mcp.casdoor.org/registry.json"
// GetOnlineServers
// @Title GetOnlineServers
// @Tag Server API
// @Description get online MCP server list
// @Success 200 {object} controllers.Response The Response object
// @router /get-online-servers [get]
func (c *ApiController) GetOnlineServers() {
httpClient := &http.Client{Timeout: 10 * time.Second}
resp, err := httpClient.Get(onlineServerListUrl)
if err != nil {
c.ResponseError(err.Error())
return
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
c.ResponseError(fmt.Sprintf("failed to get online server list, status code: %d", resp.StatusCode))
return
}
var onlineServers interface{}
err = json.NewDecoder(resp.Body).Decode(&onlineServers)
if err != nil {
c.ResponseError(err.Error())
return
}
c.ResponseOk(onlineServers)
}

170
controllers/server_sync.go Normal file
View File

@@ -0,0 +1,170 @@
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package controllers
import (
"context"
"encoding/json"
"net/http"
"slices"
"strings"
"sync"
"time"
"github.com/casdoor/casdoor/mcp"
)
const (
defaultSyncTimeoutMs = 1200
defaultSyncMaxConcurrency = 32
maxSyncHosts = 1024
)
var (
defaultSyncPorts = []int{3000, 8080, 80}
defaultSyncPaths = []string{"/", "/mcp", "/sse", "/mcp/sse"}
)
type SyncInnerServersRequest struct {
CIDR []string `json:"cidr"`
Scheme string `json:"scheme"`
Ports []string `json:"ports"`
Paths []string `json:"paths"`
TimeoutMs int `json:"timeoutMs"`
MaxConcurrency int `json:"maxConcurrency"`
}
type SyncInnerServersResult struct {
CIDR []string `json:"cidr"`
ScannedHosts int `json:"scannedHosts"`
OnlineHosts []string `json:"onlineHosts"`
Servers []*mcp.InnerMcpServer `json:"servers"`
}
// SyncIntranetServers
// @Title SyncIntranetServers
// @Tag Server API
// @Description scan intranet IP/CIDR targets and detect MCP servers by probing common ports and paths
// @Param body body controllers.SyncInnerServersRequest true "Intranet MCP server scan request"
// @Success 200 {object} controllers.Response The Response object
// @router /sync-intranet-servers [post]
func (c *ApiController) SyncIntranetServers() {
_, ok := c.RequireAdmin()
if !ok {
return
}
var req SyncInnerServersRequest
if err := json.Unmarshal(c.Ctx.Input.RequestBody, &req); err != nil {
c.ResponseError(err.Error())
return
}
for i := range req.CIDR {
req.CIDR[i] = strings.TrimSpace(req.CIDR[i])
}
if len(req.CIDR) == 0 {
c.ResponseError("scan target (CIDR/IP) is required")
return
}
hosts, err := mcp.ParseScanTargets(req.CIDR, maxSyncHosts)
if err != nil {
c.ResponseError(err.Error())
return
}
timeout := mcp.SanitizeTimeout(req.TimeoutMs, defaultSyncTimeoutMs, 10000)
concurrency := mcp.SanitizeConcurrency(req.MaxConcurrency, defaultSyncMaxConcurrency, 256)
ports := mcp.SanitizePorts(req.Ports, defaultSyncPorts)
paths := mcp.SanitizePaths(req.Paths, defaultSyncPaths)
scheme := mcp.SanitizeScheme(req.Scheme)
client := &http.Client{
Timeout: timeout,
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
},
}
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
onlineHostSet := map[string]struct{}{}
serverMap := map[string]*mcp.InnerMcpServer{}
mutex := sync.Mutex{}
waitGroup := sync.WaitGroup{}
sem := make(chan struct{}, concurrency)
for _, host := range hosts {
host := host.String()
waitGroup.Add(1)
go func() {
defer waitGroup.Done()
select {
case sem <- struct{}{}:
case <-ctx.Done():
return
}
defer func() { <-sem }()
isOnline, servers := mcp.ProbeHost(ctx, client, scheme, host, ports, paths, timeout)
if !isOnline {
return
}
mutex.Lock()
onlineHostSet[host] = struct{}{}
for _, server := range servers {
serverMap[server.Url] = server
}
mutex.Unlock()
}()
}
waitGroup.Wait()
onlineHosts := make([]string, 0, len(onlineHostSet))
for host := range onlineHostSet {
onlineHosts = append(onlineHosts, host)
}
slices.Sort(onlineHosts)
servers := make([]*mcp.InnerMcpServer, 0, len(serverMap))
for _, server := range serverMap {
servers = append(servers, server)
}
slices.SortFunc(servers, func(a, b *mcp.InnerMcpServer) int {
if a.Url < b.Url {
return -1
}
if a.Url > b.Url {
return 1
}
return 0
})
c.ResponseOk(&SyncInnerServersResult{
CIDR: req.CIDR,
ScannedHosts: len(hosts),
OnlineHosts: onlineHosts,
Servers: servers,
})
}
func (c *ApiController) SyncInnerServers() {
c.SyncIntranetServers()
}

View File

@@ -250,6 +250,9 @@ func (c *ApiController) GetOAuthToken() {
}
}
// Extract DPoP proof header (RFC 9449). Empty string when DPoP is not used.
dpopProof := c.Ctx.Request.Header.Get("DPoP")
host := c.Ctx.Request.Host
if deviceCode != "" {
deviceAuthCache, ok := object.DeviceAuthMap.Load(deviceCode)
@@ -291,7 +294,7 @@ func (c *ApiController) GetOAuthToken() {
username = deviceAuthCacheCast.UserName
}
token, err := object.GetOAuthToken(grantType, clientId, clientSecret, code, verifier, scope, nonce, username, password, host, refreshToken, tag, avatar, c.GetAcceptLanguage(), subjectToken, subjectTokenType, assertion, clientAssertion, clientAssertionType, audience, resource)
token, err := object.GetOAuthToken(grantType, clientId, clientSecret, code, verifier, scope, nonce, username, password, host, refreshToken, tag, avatar, c.GetAcceptLanguage(), subjectToken, subjectTokenType, assertion, clientAssertion, clientAssertionType, audience, resource, dpopProof)
if err != nil {
c.ResponseError(err.Error())
return
@@ -340,7 +343,8 @@ func (c *ApiController) RefreshToken() {
return
}
refreshToken2, err := object.RefreshToken(application, grantType, refreshToken, scope, clientId, clientSecret, host)
dpopProof := c.Ctx.Request.Header.Get("DPoP")
refreshToken2, err := object.RefreshToken(application, grantType, refreshToken, scope, clientId, clientSecret, host, dpopProof)
if err != nil {
c.ResponseError(err.Error())
return
@@ -556,6 +560,11 @@ func (c *ApiController) IntrospectToken() {
introspectionResponse.TokenType = token.TokenType
introspectionResponse.ClientId = application.ClientId
// Expose DPoP key binding in the introspection response (RFC 9449 §8).
if token.DPoPJkt != "" {
introspectionResponse.Cnf = &object.DPoPConfirmation{JKT: token.DPoPJkt}
}
}
c.Data["json"] = introspectionResponse

View File

@@ -730,29 +730,6 @@ func (c *ApiController) GetUserCount() {
c.ResponseOk(count)
}
// AddUserKeys
// @Title AddUserKeys
// @router /add-user-keys [post]
// @Tag User API
// @Success 200 {object} object.Userinfo The Response object
func (c *ApiController) AddUserKeys() {
var user object.User
err := json.Unmarshal(c.Ctx.Input.RequestBody, &user)
if err != nil {
c.ResponseError(err.Error())
return
}
isAdmin := c.IsAdmin()
affected, err := object.AddUserKeys(&user, isAdmin)
if err != nil {
c.ResponseError(err.Error())
return
}
c.ResponseOk(affected)
}
func (c *ApiController) RemoveUserFromGroup() {
owner := c.Ctx.Request.Form.Get("owner")
name := c.Ctx.Request.Form.Get("name")

View File

@@ -252,6 +252,10 @@ func (c *ApiController) SendVerificationCode() {
return
}
if vform.CaptchaToken != "" {
enableCaptcha = true
}
// Only verify CAPTCHA if it should be enabled
if enableCaptcha {
captchaProvider, err := object.GetCaptchaProviderByApplication(vform.ApplicationId, "false", c.GetAcceptLanguage())
@@ -440,6 +444,8 @@ func (c *ApiController) ResetEmailOrPhone() {
return
}
clientIp := util.GetClientIpFromRequest(c.Ctx.Request)
destType := c.Ctx.Request.Form.Get("type")
dest := c.Ctx.Request.Form.Get("dest")
code := c.Ctx.Request.Form.Get("code")
@@ -494,13 +500,9 @@ func (c *ApiController) ResetEmailOrPhone() {
}
}
result, err := object.CheckVerificationCode(checkDest, code, c.GetAcceptLanguage())
err = object.CheckVerifyCodeWithLimitAndIp(user, clientIp, checkDest, code, c.GetAcceptLanguage())
if err != nil {
c.ResponseError(c.T(err.Error()))
return
}
if result.Code != object.VerificationSuccess {
c.ResponseError(result.Msg)
c.ResponseError(err.Error())
return
}
@@ -598,7 +600,8 @@ func (c *ApiController) VerifyCode() {
}
if !passed {
err = object.CheckVerifyCodeWithLimit(user, checkDest, authForm.Code, c.GetAcceptLanguage())
clientIp := util.GetClientIpFromRequest(c.Ctx.Request)
err = object.CheckVerifyCodeWithLimitAndIp(user, clientIp, checkDest, authForm.Code, c.GetAcceptLanguage())
if err != nil {
c.ResponseError(err.Error())
return

View File

@@ -0,0 +1,202 @@
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package controllers
import (
"encoding/json"
"github.com/beego/beego/v2/core/utils/pagination"
"github.com/casdoor/casdoor/object"
"github.com/casdoor/casdoor/util"
)
const defaultWebhookEventListLimit = 100
func (c *ApiController) getScopedWebhookEventQuery() (string, string, bool) {
organization, ok := c.RequireAdmin()
if !ok {
return "", "", false
}
owner := ""
if c.IsGlobalAdmin() {
owner = c.Ctx.Input.Query("owner")
requestedOrganization := c.Ctx.Input.Query("organization")
if requestedOrganization != "" {
organization = requestedOrganization
}
}
return owner, organization, true
}
func (c *ApiController) checkWebhookEventAccess(event *object.WebhookEvent, organization string) bool {
if event == nil || c.IsGlobalAdmin() {
return true
}
if event.Organization != organization {
c.ResponseError(c.T("auth:Unauthorized operation"))
return false
}
return true
}
// GetWebhookEvents
// @Title GetWebhookEvents
// @Tag Webhook Event API
// @Description get webhook events with filtering
// @Param owner query string false "The owner of webhook events"
// @Param organization query string false "The organization"
// @Param webhookName query string false "The webhook name"
// @Param status query string false "Event status (pending, success, failed, retrying)"
// @Success 200 {array} object.WebhookEvent The Response object
// @router /get-webhook-events [get]
func (c *ApiController) GetWebhookEvents() {
owner, organization, ok := c.getScopedWebhookEventQuery()
if !ok {
return
}
webhookName := c.Ctx.Input.Query("webhookName")
status := c.Ctx.Input.Query("status")
limit := c.Ctx.Input.Query("pageSize")
page := c.Ctx.Input.Query("p")
sortField := c.Ctx.Input.Query("sortField")
sortOrder := c.Ctx.Input.Query("sortOrder")
if limit != "" && page != "" {
limit := util.ParseInt(limit)
count, err := object.GetWebhookEventCount(owner, organization, webhookName, object.WebhookEventStatus(status))
if err != nil {
c.ResponseError(err.Error())
return
}
paginator := pagination.NewPaginator(c.Ctx.Request, limit, count)
events, err := object.GetWebhookEvents(owner, organization, webhookName, object.WebhookEventStatus(status), paginator.Offset(), limit, sortField, sortOrder)
if err != nil {
c.ResponseError(err.Error())
return
}
c.ResponseOk(events, paginator.Nums())
} else {
events, err := object.GetWebhookEvents(owner, organization, webhookName, object.WebhookEventStatus(status), 0, defaultWebhookEventListLimit, sortField, sortOrder)
if err != nil {
c.ResponseError(err.Error())
return
}
c.ResponseOk(events)
}
}
// GetWebhookEvent
// @Title GetWebhookEvent
// @Tag Webhook Event API
// @Description get webhook event
// @Param id query string true "The id ( owner/name ) of the webhook event"
// @Success 200 {object} object.WebhookEvent The Response object
// @router /get-webhook-event-detail [get]
func (c *ApiController) GetWebhookEvent() {
organization, ok := c.RequireAdmin()
if !ok {
return
}
id := c.Ctx.Input.Query("id")
event, err := object.GetWebhookEvent(id)
if err != nil {
c.ResponseError(err.Error())
return
}
if !c.checkWebhookEventAccess(event, organization) {
return
}
c.ResponseOk(event)
}
// ReplayWebhookEvent
// @Title ReplayWebhookEvent
// @Tag Webhook Event API
// @Description replay a webhook event
// @Param id query string true "The id ( owner/name ) of the webhook event"
// @Success 200 {object} controllers.Response The Response object
// @router /replay-webhook-event [post]
func (c *ApiController) ReplayWebhookEvent() {
organization, ok := c.RequireAdmin()
if !ok {
return
}
id := c.Ctx.Input.Query("id")
event, err := object.GetWebhookEvent(id)
if err != nil {
c.ResponseError(err.Error())
return
}
if !c.checkWebhookEventAccess(event, organization) {
return
}
err = object.ReplayWebhookEvent(id)
if err != nil {
c.ResponseError(err.Error())
return
}
c.ResponseOk("Webhook event replay triggered")
}
// DeleteWebhookEvent
// @Title DeleteWebhookEvent
// @Tag Webhook Event API
// @Description delete webhook event
// @Param body body object.WebhookEvent true "The details of the webhook event"
// @Success 200 {object} controllers.Response The Response object
// @router /delete-webhook-event [post]
func (c *ApiController) DeleteWebhookEvent() {
organization, ok := c.RequireAdmin()
if !ok {
return
}
var event object.WebhookEvent
err := json.Unmarshal(c.Ctx.Input.RequestBody, &event)
if err != nil {
c.ResponseError(err.Error())
return
}
storedEvent, err := object.GetWebhookEvent(event.GetId())
if err != nil {
c.ResponseError(err.Error())
return
}
if !c.checkWebhookEventAccess(storedEvent, organization) {
return
}
c.Data["json"] = wrapActionResponse(object.DeleteWebhookEvent(&event))
c.ServeJSON()
}

20
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,20 @@
services:
db:
image: postgres:16-alpine
restart: unless-stopped
ports:
- "5434:5432"
environment:
POSTGRES_USER: casdoor
POSTGRES_PASSWORD: casdoor_dev
POSTGRES_DB: casdoor
volumes:
- casdoor_pg_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U casdoor"]
interval: 5s
timeout: 5s
retries: 5
volumes:
casdoor_pg_data:

View File

@@ -26,7 +26,7 @@ import (
"strings"
"time"
"github.com/google/uuid"
"github.com/casdoor/casdoor/util"
)
const (
@@ -141,7 +141,7 @@ func (a *AzureACSEmailProvider) Send(fromAddress string, fromName string, toAddr
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("repeatability-request-id", uuid.New().String())
req.Header.Set("repeatability-request-id", util.GenerateUUID())
req.Header.Set("repeatability-first-sent", time.Now().UTC().Format(http.TimeFormat))
client := &http.Client{}

32
go.mod
View File

@@ -1,8 +1,8 @@
module github.com/casdoor/casdoor
go 1.24.0
go 1.25.0
toolchain go1.24.13
toolchain go1.25.8
require (
github.com/Masterminds/squirrel v1.5.3
@@ -32,7 +32,6 @@ require (
github.com/casdoor/notify2 v1.6.0
github.com/casdoor/oss v1.8.0
github.com/casdoor/xorm-adapter/v3 v3.1.0
github.com/casvisor/casvisor-go-sdk v1.4.0
github.com/corazawaf/coraza/v3 v3.3.3
github.com/dchest/captcha v0.0.0-20200903113550-03f5f0333e1f
github.com/elimity-com/scim v0.0.0-20230426070224-941a5eac92f3
@@ -47,7 +46,7 @@ require (
github.com/go-sql-driver/mysql v1.8.1
github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible
github.com/go-webauthn/webauthn v0.10.2
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/google/uuid v1.6.0
github.com/hsluoyz/modsecurity-go v0.0.7
github.com/jcmturner/gokrb5/v8 v8.4.4
@@ -60,6 +59,7 @@ require (
github.com/markbates/goth v1.82.0
github.com/microsoft/go-mssqldb v1.9.0
github.com/mitchellh/mapstructure v1.5.0
github.com/modelcontextprotocol/go-sdk v1.4.0
github.com/nyaruka/phonenumbers v1.2.2
github.com/polarsource/polar-go v0.12.0
github.com/pquerna/otp v1.4.0
@@ -80,9 +80,10 @@ require (
github.com/xorm-io/builder v0.3.13
github.com/xorm-io/core v0.7.4
github.com/xorm-io/xorm v1.1.6
go.opentelemetry.io/proto/otlp v1.7.1
golang.org/x/crypto v0.47.0
golang.org/x/net v0.49.0
golang.org/x/oauth2 v0.32.0
golang.org/x/oauth2 v0.34.0
golang.org/x/text v0.33.0
golang.org/x/time v0.8.0
google.golang.org/api v0.215.0
@@ -92,7 +93,7 @@ require (
)
require (
cel.dev/expr v0.24.0 // indirect
cel.dev/expr v0.25.1 // indirect
cloud.google.com/go v0.116.0 // indirect
cloud.google.com/go/auth v0.13.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.6 // indirect
@@ -137,7 +138,7 @@ require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/clbanning/mxj/v2 v2.7.0 // indirect
github.com/cloudflare/circl v1.6.1 // indirect
github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f // indirect
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect
github.com/corazawaf/libinjection-go v0.2.2 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect
github.com/cschomburg/go-pushbullet v0.0.0-20171206132031-67759df45fbb // indirect
@@ -152,8 +153,8 @@ require (
github.com/drswork/go-twitter v0.0.0-20221107160839-dea1b6ed53d7 // indirect
github.com/ebitengine/purego v0.9.0 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/envoyproxy/go-control-plane/envoy v1.35.0 // indirect
github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect
github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fxamacker/cbor/v2 v2.6.0 // indirect
github.com/ggicci/httpin v0.19.0 // indirect
@@ -180,11 +181,13 @@ require (
github.com/golang/snappy v0.0.4 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/go-tpm v0.9.0 // indirect
github.com/google/jsonschema-go v0.4.2 // indirect
github.com/google/s2a-go v0.1.8 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
github.com/googleapis/gax-go/v2 v2.14.1 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/gregdel/pushover v1.3.1 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-uuid v1.0.3 // indirect
github.com/hashicorp/golang-lru v0.5.4 // indirect
@@ -238,6 +241,8 @@ require (
github.com/rs/zerolog v1.33.0 // indirect
github.com/russross/blackfriday/v2 v2.0.1 // indirect
github.com/scim2/filter-parser/v2 v2.2.0 // indirect
github.com/segmentio/asm v1.1.3 // indirect
github.com/segmentio/encoding v0.5.3 // indirect
github.com/sendgrid/rest v2.6.9+incompatible // indirect
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
github.com/shiena/ansicolor v0.0.0-20200904210342-c7312218db18 // indirect
@@ -269,6 +274,7 @@ require (
github.com/volcengine/volc-sdk-golang v1.0.117 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.mau.fi/util v0.8.3 // indirect
go.opencensus.io v0.24.0 // indirect
@@ -286,14 +292,14 @@ require (
go.uber.org/zap v1.19.1 // indirect
golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e // indirect
golang.org/x/image v0.18.0 // indirect
golang.org/x/mod v0.31.0 // indirect
golang.org/x/mod v0.32.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/tools v0.40.0 // indirect
golang.org/x/tools v0.41.0 // indirect
google.golang.org/genproto v0.0.0-20241118233622-e639e219e697 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect
google.golang.org/grpc v1.78.0 // indirect
google.golang.org/grpc v1.79.3 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
gopkg.in/ini.v1 v1.67.0 // indirect

60
go.sum
View File

@@ -1,5 +1,5 @@
cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY=
cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=
cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
@@ -867,8 +867,6 @@ github.com/casdoor/oss v1.8.0 h1:uuyKhDIp7ydOtV4lpqhAY23Ban2Ln8La8+QT36CwylM=
github.com/casdoor/oss v1.8.0/go.mod h1:uaqO7KBI2lnZcnB8rF7O6C2bN7llIbfC5Ql8ex1yR1U=
github.com/casdoor/xorm-adapter/v3 v3.1.0 h1:NodWayRtSLVSeCvL9H3Hc61k0G17KhV9IymTCNfh3kk=
github.com/casdoor/xorm-adapter/v3 v3.1.0/go.mod h1:4WTcUw+bTgBylGHeGHzTtBvuTXRS23dtwzFLl9tsgFM=
github.com/casvisor/casvisor-go-sdk v1.4.0 h1:hbZEGGJ1cwdHFAxeXrMoNw6yha6Oyg2F0qQhBNCN/dg=
github.com/casvisor/casvisor-go-sdk v1.4.0/go.mod h1:frnNtH5GA0wxzAQLyZxxfL0RSsSub9GQPi2Ybe86ocE=
github.com/cenkalti/backoff/v4 v4.1.1/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw=
github.com/cenkalti/backoff/v4 v4.1.2/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
@@ -907,8 +905,8 @@ github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWH
github.com/cncf/xds/go v0.0.0-20220314180256-7f1daf1720fc/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20230105202645-06c439db220b/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20230310173818-32f1caf87195/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f h1:Y8xYupdHxryycyPlc9Y+bSQAYZnetRJ70VMVKm5CKI0=
github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f/go.mod h1:HlzOvOjVBOfTGSRXRyY0OiCS/3J1akRGQQpRO/7zyF4=
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w=
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI=
github.com/corazawaf/coraza-coreruleset v0.0.0-20240226094324-415b1017abdc h1:OlJhrgI3I+FLUCTI3JJW8MoqyM78WbqJjecqMnqG+wc=
github.com/corazawaf/coraza-coreruleset v0.0.0-20240226094324-415b1017abdc/go.mod h1:7rsocqNDkTCira5T0M7buoKR2ehh7YZiPkzxRuAgvVU=
github.com/corazawaf/coraza/v3 v3.3.3 h1:kqjStHAgWqwP5dh7n0vhTOF0a3t+VikNS/EaMiG0Fhk=
@@ -982,18 +980,18 @@ github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.
github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.mod h1:KJwIaB5Mv44NWtYuAOFCVOjcI94vtpEz2JU/D2v6IjE=
github.com/envoyproxy/go-control-plane v0.10.3/go.mod h1:fJJn/j26vwOu972OllsvAgJJM//w9BV6Fxbg2LuVd34=
github.com/envoyproxy/go-control-plane v0.11.0/go.mod h1:VnHyVMpzcLvCFt9yUz1UnCwHLhwx1WguiVDV7pTG/tI=
github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329 h1:K+fnvUM0VZ7ZFJf0n4L/BRlnsb9pL/GuDG6FqaH+PwM=
github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329/go.mod h1:Alz8LEClvR7xKsrq3qzoc4N0guvVNSS8KmSChGYr9hs=
github.com/envoyproxy/go-control-plane/envoy v1.35.0 h1:ixjkELDE+ru6idPxcHLj8LBVc2bFP7iBytj353BoHUo=
github.com/envoyproxy/go-control-plane/envoy v1.35.0/go.mod h1:09qwbGVuSWWAyN5t/b3iyVfz5+z8QWGrzkoqm/8SbEs=
github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU=
github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g=
github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98=
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI=
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/envoyproxy/protoc-gen-validate v0.6.7/go.mod h1:dyJXwwfPK2VSqiB9Klm1J6romD608Ba7Hij42vrOBCo=
github.com/envoyproxy/protoc-gen-validate v0.9.1/go.mod h1:OKNgG7TCp5pF4d6XftA0++PMirau2/yoOwVac3AbF2w=
github.com/envoyproxy/protoc-gen-validate v0.10.0/go.mod h1:DRjgyB0I43LtJapqN6NiRwroiAU2PaFuvk/vjgh61ss=
github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8=
github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU=
github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4=
github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
github.com/fatih/color v1.12.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM=
@@ -1116,8 +1114,8 @@ github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzw
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
@@ -1194,6 +1192,8 @@ github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17
github.com/google/go-tpm v0.9.0 h1:sQF6YqWMi+SCXpsmS3fd21oPy/vSddwZry4JnmltHVk=
github.com/google/go-tpm v0.9.0/go.mod h1:FkNVkc6C+IsvDI9Jw1OveJmxGZUUaKxtrpOS47QWKfU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
@@ -1280,6 +1280,8 @@ github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgf
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.7.0/go.mod h1:hgWBS7lorOAVIJEQMi4ZsPv9hVvWI6+ch50m39Pf2Ks=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.11.3/go.mod h1:o//XUCC/F+yRGJoPO/VU0GSB0f8Nhgmxx0VIRUvaC0w=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90=
github.com/hashicorp/consul/api v1.10.1/go.mod h1:XjsvQN+RJGWI2TWy1/kqaE16HrR2J/FWgkYjdZQsX9M=
github.com/hashicorp/consul/sdk v0.8.0/go.mod h1:GBvyrGALthsZObzUGsfgHZQDXjg4lOjagTIwIR1vPms=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
@@ -1498,6 +1500,8 @@ github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh
github.com/mitchellh/mapstructure v1.4.2/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/modelcontextprotocol/go-sdk v1.4.0 h1:u0kr8lbJc1oBcawK7Df+/ajNMpIDFE41OEPxdeTLOn8=
github.com/modelcontextprotocol/go-sdk v1.4.0/go.mod h1:Nxc2n+n/GdCebUaqCOhTetptS17SXXNu9IfNTaLDi1E=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -1658,6 +1662,10 @@ github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdh
github.com/scim2/filter-parser/v2 v2.2.0 h1:QGadEcsmypxg8gYChRSM2j1edLyE/2j72j+hdmI4BJM=
github.com/scim2/filter-parser/v2 v2.2.0/go.mod h1:jWnkDToqX/Y0ugz0P5VvpVEUKcWcyHHj+X+je9ce5JA=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w=
github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
github.com/sendgrid/rest v2.6.9+incompatible h1:1EyIcsNdn9KIisLW50MKwmSRSK+ekueiEMJ7NEoxJo0=
github.com/sendgrid/rest v2.6.9+incompatible/go.mod h1:kXX7q3jZtJXK5c5qK83bSGMdV6tsOE70KbHoqJls4lE=
github.com/sendgrid/sendgrid-go v3.16.0+incompatible h1:i8eE6IMkiCy7vusSdacHHSBUpXyTcTXy/Rl9N9aZ/Qw=
@@ -1796,6 +1804,8 @@ github.com/xorm-io/core v0.7.4 h1:qIznlqqmYNEb03ewzRXCrNkbbxpkgc/44nVF8yoFV7Y=
github.com/xorm-io/core v0.7.4/go.mod h1:GueyhafDnkB0KK0fXX/dEhr/P1EAGW0GLmoNDUEE1Mo=
github.com/xorm-io/xorm v1.1.6 h1:s4fDpUXJx8Zr/PBovXNaadn+v1P3h/U3iV4OxAkWS8s=
github.com/xorm-io/xorm v1.1.6/go.mod h1:7nsSUdmgLIcqHSSaKOzbVQiZtzIzbpGf1GGSYp6DD70=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.30/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
@@ -1847,6 +1857,8 @@ go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTq
go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
go.opentelemetry.io/proto/otlp v0.15.0/go.mod h1:H7XAot3MsfNsj7EXtrA2q5xSNQ10UqI405h3+duxN4U=
go.opentelemetry.io/proto/otlp v0.19.0/go.mod h1:H7XAot3MsfNsj7EXtrA2q5xSNQ10UqI405h3+duxN4U=
go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4=
go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE=
go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
@@ -1977,8 +1989,8 @@ golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/net v0.0.0-20171115151908-9dfe39835686/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -2099,8 +2111,8 @@ golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw
golang.org/x/oauth2 v0.7.0/go.mod h1:hPLQkd9LyjfXTiRohC/41GhcFqxisoUQ99sCUOHO9x4=
golang.org/x/oauth2 v0.8.0/go.mod h1:yr7u4HXZRm1R1kBWqr/xKNqewf0plRYoB7sla+BCIXE=
golang.org/x/oauth2 v0.11.0/go.mod h1:LdF7O/8bLR/qWK9DrpXmbHLTouvRHK0SgJl0GmDBchk=
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20171101214715-fd80eb99c8f6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -2385,8 +2397,8 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -2617,8 +2629,8 @@ google.golang.org/genproto/googleapis/api v0.0.0-20230525234020-1aefcd67740a/go.
google.golang.org/genproto/googleapis/api v0.0.0-20230525234035-dd9d682886f9/go.mod h1:vHYtlOoi6TsQ3Uk2yxR7NI5z8uoV+3pZtR4jmHIkRig=
google.golang.org/genproto/googleapis/api v0.0.0-20230526203410-71b5a4ffd15e/go.mod h1:vHYtlOoi6TsQ3Uk2yxR7NI5z8uoV+3pZtR4jmHIkRig=
google.golang.org/genproto/googleapis/api v0.0.0-20230530153820-e85fd2cbaebc/go.mod h1:vHYtlOoi6TsQ3Uk2yxR7NI5z8uoV+3pZtR4jmHIkRig=
google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda h1:+2XxjfsAu6vqFxwGBRcHiMaDCuZiqXGDUDVWVtrFAnE=
google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
google.golang.org/genproto/googleapis/bytestream v0.0.0-20230530153820-e85fd2cbaebc/go.mod h1:ylj+BE99M198VPbBh6A8d9n3w8fChvyLK3wwBOjXBFA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20230525234015-3fc162c6f38a/go.mod h1:xURIpW9ES5+/GZhnV6beoEtxQrnkRGIfP5VQG2tCBLc=
google.golang.org/genproto/googleapis/rpc v0.0.0-20230525234030-28d5490b6b19/go.mod h1:66JfowdXAEgad5O9NnYcsNPLCPZJD++2L9X0PCMODrA=
@@ -2667,8 +2679,8 @@ google.golang.org/grpc v1.52.0/go.mod h1:pu6fVzoFb+NBYNAvQL08ic+lvB2IojljRYuun5v
google.golang.org/grpc v1.53.0/go.mod h1:OnIrk0ipVdj4N5d9IUoFUx72/VlD7+jUsHwZgwSMQpw=
google.golang.org/grpc v1.54.0/go.mod h1:PUSEXI6iWghWaB6lXM4knEgpJNu2qUcKfDtNci3EC2g=
google.golang.org/grpc v1.55.0/go.mod h1:iYEXKGkEBhg1PjZQvoYEVPTDkHo1/bjTnfwTeGONTY8=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=

230
i18n/locales/ru/data.json Normal file
View File

@@ -0,0 +1,230 @@
{
"account": {
"Failed to add user": "Не удалось добавить пользователя",
"Get init score failed, error: %w": "Не удалось получить исходный балл, ошибка: %w",
"The application does not allow to sign up new account": "Приложение не позволяет зарегистрироваться новому аккаунту"
},
"auth": {
"Challenge method should be S256": "Метод проверки должен быть S256",
"DeviceCode Invalid": "Неверный код устройства",
"Failed to create user, user information is invalid: %s": "Не удалось создать пользователя, информация о пользователе недействительна: %s",
"Failed to login in: %s": "Не удалось войти в систему: %s",
"Invalid token": "Недействительный токен",
"State expected: %s, but got: %s": "Ожидался статус: %s, но получен: %s",
"The account for provider: %s and username: %s (%s) does not exist and is not allowed to sign up as new account via %s, please use another way to sign up": "Аккаунт провайдера: %s и имя пользователя: %s (%s) не существует и не может быть зарегистрирован через %s, пожалуйста, используйте другой способ регистрации",
"The account for provider: %s and username: %s (%s) does not exist and is not allowed to sign up as new account, please contact your IT support": "Аккаунт для провайдера: %s и имя пользователя: %s (%s) не существует и не может быть зарегистрирован как новый аккаунт. Пожалуйста, обратитесь в службу поддержки IT",
"The account for provider: %s and username: %s (%s) is already linked to another account: %s (%s)": "Аккаунт поставщика: %s и имя пользователя: %s (%s) уже связаны с другим аккаунтом: %s (%s)",
"The application: %s does not exist": "Приложение: %s не существует",
"The application: %s has disabled users to signin": "Приложение: %s отключило вход пользователей",
"The group: %s does not exist": "Группа: %s не существует",
"The login method: login with LDAP is not enabled for the application": "Метод входа через LDAP отключен для этого приложения",
"The login method: login with SMS is not enabled for the application": "Метод входа через SMS отключен для этого приложения",
"The login method: login with email is not enabled for the application": "Метод входа через электронную почту отключен для этого приложения",
"The login method: login with face is not enabled for the application": "Метод входа через распознавание лица отключен для этого приложения",
"The login method: login with password is not enabled for the application": "Метод входа: вход с паролем не включен для приложения",
"The order: %s does not exist": "The order: %s does not exist",
"The organization: %s does not exist": "Организация: %s не существует",
"The organization: %s has disabled users to signin": "Организация: %s отключила вход пользователей",
"The plan: %s does not exist": "План: %s не существует",
"The pricing: %s does not exist": "Тариф: %s не существует",
"The pricing: %s does not have plan: %s": "Тариф: %s не имеет план: %s",
"The provider: %s does not exist": "Провайдер: %s не существует",
"The provider: %s is not enabled for the application": "Провайдер: %s не включен для приложения",
"Unauthorized operation": "Несанкционированная операция",
"Unknown authentication type (not password or provider), form = %s": "Неизвестный тип аутентификации (не пароль и не провайдер), форма = %s",
"User's tag: %s is not listed in the application's tags": "Тег пользователя: %s отсутствует в списке тегов приложения",
"UserCode Expired": "Срок действия кода пользователя истек",
"UserCode Invalid": "Неверный код пользователя",
"paid-user %s does not have active or pending subscription and the application: %s does not have default pricing": "Платный пользователь %s не имеет активной или ожидающей подписки, а приложение %s не имеет цены по умолчанию",
"the application for user %s is not found": "Приложение для пользователя %s не найдено",
"the organization: %s is not found": "Организация: %s не найдена"
},
"cas": {
"Service %s and %s do not match": "Сервисы %s и %s не совпадают"
},
"check": {
"%s does not meet the CIDR format requirements: %s": "%s не соответствует требованиям формата CIDR: %s",
"Affiliation cannot be blank": "Принадлежность не может быть пустым значением",
"CIDR for IP: %s should not be empty": "CIDR для IP: %s не должен быть пустым",
"Default code does not match the code's matching rules": "Код по умолчанию не соответствует правилам соответствия кода",
"DisplayName cannot be blank": "Имя отображения не может быть пустым",
"DisplayName is not valid real name": "DisplayName не является действительным именем",
"Email already exists": "Электронная почта уже существует",
"Email cannot be empty": "Электронная почта не может быть пустой",
"Email is invalid": "Адрес электронной почты недействительный",
"Empty username.": "Пустое имя пользователя.",
"Face data does not exist, cannot log in": "Данные лица отсутствуют, вход невозможен",
"Face data mismatch": "Несоответствие данных лица",
"Failed to parse client IP: %s": "Не удалось разобрать IP клиента: %s",
"FirstName cannot be blank": "Имя не может быть пустым",
"Guest users must upgrade their account by setting a username and password before they can sign in directly": "Guest users must upgrade their account by setting a username and password before they can sign in directly",
"Invitation code cannot be blank": "Код приглашения не может быть пустым",
"Invitation code exhausted": "Код приглашения исчерпан",
"Invitation code is invalid": "Код приглашения недействителен",
"Invitation code suspended": "Код приглашения приостановлен",
"LastName cannot be blank": "Фамилия не может быть пустой",
"Multiple accounts with same uid, please check your ldap server": "Множественные учетные записи с тем же UID. Пожалуйста, проверьте свой сервер LDAP",
"Organization does not exist": "Организация не существует",
"Password cannot be empty": "Пароль не может быть пустым",
"Phone already exists": "Телефон уже существует",
"Phone cannot be empty": "Телефон не может быть пустым",
"Phone number is invalid": "Номер телефона является недействительным",
"Please register using the email corresponding to the invitation code": "Пожалуйста, зарегистрируйтесь, используя электронную почту, соответствующую коду приглашения",
"Please register using the phone corresponding to the invitation code": "Пожалуйста, зарегистрируйтесь, используя номер телефона, соответствующий коду приглашения",
"Please register using the username corresponding to the invitation code": "Пожалуйста, зарегистрируйтесь, используя имя пользователя, соответствующее коду приглашения",
"Session outdated, please login again": "Сессия устарела, пожалуйста, войдите снова",
"The invitation code has already been used": "Код приглашения уже использован",
"The password must contain at least one special character": "Пароль должен содержать хотя бы один специальный символ",
"The password must contain at least one uppercase letter, one lowercase letter and one digit": "Пароль должен содержать хотя бы одну заглавную букву, одну строчную букву и одну цифру",
"The password must have at least 6 characters": "Пароль должен содержать не менее 6 символов",
"The password must have at least 8 characters": "Пароль должен содержать не менее 8 символов",
"The password must not contain any repeated characters": "Пароль не должен содержать повторяющихся символов",
"The user has been deleted and cannot be used to sign in, please contact the administrator": "Пользователь был удален и не может быть использован для входа, пожалуйста, свяжитесь с администратором",
"The user is forbidden to sign in, please contact the administrator": "Пользователю запрещен вход, пожалуйста, обратитесь к администратору",
"The user: %s doesn't exist in LDAP server": "Пользователь: %s не существует на сервере LDAP",
"The username may only contain alphanumeric characters, underlines or hyphens, cannot have consecutive hyphens or underlines, and cannot begin or end with a hyphen or underline.": "Имя пользователя может состоять только из буквенно-цифровых символов, нижних подчеркиваний или дефисов, не может содержать последовательные дефисы или подчеркивания, а также не может начинаться или заканчиваться на дефис или подчеркивание.",
"The value \"%s\" for account field \"%s\" doesn't match the account item regex": "The value \"%s\" for account field \"%s\" doesn't match the account item regex",
"The value \"%s\" for signup field \"%s\" doesn't match the signup item regex of the application \"%s\"": "The value \"%s\" for signup field \"%s\" doesn't match the signup item regex of the application \"%s\"",
"Username already exists": "Имя пользователя уже существует",
"Username cannot be an email address": "Имя пользователя не может быть адресом электронной почты",
"Username cannot contain white spaces": "Имя пользователя не может содержать пробелы",
"Username cannot start with a digit": "Имя пользователя не может начинаться с цифры",
"Username is too long (maximum is 255 characters).": "Имя пользователя слишком длинное (максимальная длина - 255 символов).",
"Username must have at least 2 characters": "Имя пользователя должно содержать не менее 2 символов",
"Username supports email format. Also The username may only contain alphanumeric characters, underlines or hyphens, cannot have consecutive hyphens or underlines, and cannot begin or end with a hyphen or underline. Also pay attention to the email format.": "Имя пользователя поддерживает формат электронной почты. Также имя пользователя может содержать только буквенно-цифровые символы, подчеркивания или дефисы, не может иметь последовательных дефисов или подчеркиваний и не может начинаться или заканчиваться дефисом или подчеркиванием. Также обратите внимание на формат электронной почты.",
"You have entered the wrong password or code too many times, please wait for %d minutes and try again": "Вы ввели неправильный пароль или код слишком много раз, пожалуйста, подождите %d минут и попробуйте снова",
"Your IP address: %s has been banned according to the configuration of: ": "Ваш IP-адрес: %s заблокирован согласно конфигурации: ",
"Your password has expired. Please reset your password by clicking \"Forgot password\"": "Срок действия вашего пароля истек. Пожалуйста, сбросьте пароль, нажав \"Забыли пароль\"",
"Your region is not allow to signup by phone": "Ваш регион не разрешает регистрацию по телефону",
"password or code is incorrect": "пароль или код неверны",
"password or code is incorrect, you have %s remaining chances": "Неправильный пароль или код, у вас осталось %s попыток",
"unsupported password type: %s": "неподдерживаемый тип пароля: %s"
},
"enforcer": {
"the adapter: %s is not found": "адаптер: %s не найден"
},
"general": {
"Failed to import groups": "Не удалось импортировать группы",
"Failed to import users": "Не удалось импортировать пользователей",
"Insufficient balance: new balance %v would be below credit limit %v": "Insufficient balance: new balance %v would be below credit limit %v",
"Insufficient balance: new organization balance %v would be below credit limit %v": "Insufficient balance: new organization balance %v would be below credit limit %v",
"Missing parameter": "Отсутствующий параметр",
"Only admin user can specify user": "Только администратор может указать пользователя",
"Please login first": "Пожалуйста, сначала войдите в систему",
"The LDAP: %s does not exist": "Группа LDAP: %s не существует",
"The organization: %s should have one application at least": "Организация: %s должна иметь хотя бы одно приложение",
"The syncer: %s does not exist": "The syncer: %s does not exist",
"The user: %s doesn't exist": "Пользователь %s не существует",
"The user: %s is not found": "The user: %s is not found",
"User is required for User category transaction": "User is required for User category transaction",
"Wrong userId": "Неверный идентификатор пользователя",
"don't support captchaProvider: ": "неподдерживаемый captchaProvider: ",
"this operation is not allowed in demo mode": "эта операция недоступна в демонстрационном режиме",
"this operation requires administrator to perform": "эта операция требует прав администратора"
},
"invitation": {
"Invitation %s does not exist": "Приглашение %s не существует"
},
"ldap": {
"Ldap server exist": "LDAP-сервер существует"
},
"link": {
"Please link first": "Пожалуйста, сначала установите ссылку",
"This application has no providers": "Это приложение не имеет провайдеров",
"This application has no providers of type": "Это приложение не имеет провайдеров данного типа",
"This provider can't be unlinked": "Этот провайдер не может быть отсоединен",
"You are not the global admin, you can't unlink other users": "Вы не являетесь глобальным администратором, вы не можете отсоединять других пользователей",
"You can't unlink yourself, you are not a member of any application": "Вы не можете отвязаться, так как вы не являетесь участником никакого приложения"
},
"organization": {
"Only admin can modify the %s.": "Только администратор может изменять %s.",
"The %s is immutable.": "%s неизменяемый.",
"Unknown modify rule %s.": "Неизвестное изменение правила %s.",
"adding a new user to the 'built-in' organization is currently disabled. Please note: all users in the 'built-in' organization are global administrators in Casdoor. Refer to the docs: https://casdoor.org/docs/basic/core-concepts#how-does-casdoor-manage-itself. If you still wish to create a user for the 'built-in' organization, go to the organization's settings page and enable the 'Has privilege consent' option.": "Добавление нового пользователя в организацию «built-in» (встроенная) в настоящее время отключено. Обратите внимание: все пользователи в организации «built-in» являются глобальными администраторами в Casdoor. См. документацию: https://casdoor.org/docs/basic/core-concepts#how-does-casdoor-manage-itself. Если вы все еще хотите создать пользователя для организации «built-in», перейдите на страницу настроек организации и включите опцию «Имеет согласие на привилегии»."
},
"permission": {
"The permission: \"%s\" doesn't exist": "The permission: \"%s\" doesn't exist"
},
"product": {
"Product list cannot be empty": "Product list cannot be empty"
},
"provider": {
"Failed to initialize ID Verification provider": "Failed to initialize ID Verification provider",
"Invalid application id": "Неверный идентификатор приложения",
"No ID Verification provider configured": "No ID Verification provider configured",
"Provider is not an ID Verification provider": "Provider is not an ID Verification provider",
"the provider: %s does not exist": "Провайдер: %s не существует"
},
"resource": {
"User is nil for tag: avatar": "Пользователь равен нулю для тега: аватар",
"Username or fullFilePath is empty: username = %s, fullFilePath = %s": "Имя пользователя или полный путь к файлу пусты: имя_пользователя = %s, полный_путь_к_файлу = %s"
},
"saml": {
"Application %s not found": "Приложение %s не найдено"
},
"saml_sp": {
"provider %s's category is not SAML": "Категория провайдера %s не является SAML"
},
"service": {
"Empty parameters for emailForm: %v": "Пустые параметры для emailForm: %v",
"Invalid Email receivers: %s": "Некорректные получатели электронной почты: %s",
"Invalid phone receivers: %s": "Некорректные получатели телефонных звонков: %s"
},
"session": {
"session id %s is the current session and cannot be deleted": "session id %s is the current session and cannot be deleted"
},
"storage": {
"The objectKey: %s is not allowed": "Объект «objectKey: %s» не разрешен",
"The provider type: %s is not supported": "Тип провайдера: %s не поддерживается"
},
"subscription": {
"Error": "Ошибка"
},
"ticket": {
"Ticket not found": "Ticket not found"
},
"token": {
"Grant_type: %s is not supported in this application": "Тип предоставления: %s не поддерживается в данном приложении",
"Invalid application or wrong clientSecret": "Недействительное приложение или неправильный clientSecret",
"Invalid client_id": "Недействительный идентификатор клиента",
"Redirect URI: %s doesn't exist in the allowed Redirect URI list": "URI перенаправления: %s не существует в списке разрешенных URI перенаправления",
"Token not found, invalid accessToken": "Токен не найден, недействительный accessToken"
},
"user": {
"Display name cannot be empty": "Отображаемое имя не может быть пустым",
"ID card information and real name are required": "ID card information and real name are required",
"Identity verification failed": "Identity verification failed",
"MFA email is enabled but email is empty": "MFA по электронной почте включен, но электронная почта не указана",
"MFA phone is enabled but phone number is empty": "MFA по телефону включен, но номер телефона не указан",
"New password cannot contain blank space.": "Новый пароль не может содержать пробелы.",
"No application found for user": "No application found for user",
"The new password must be different from your current password": "Новый пароль должен отличаться от текущего пароля",
"User is already verified": "Пользователь уже подтвержден",
"the user's owner and name should not be empty": "владелец и имя пользователя не должны быть пустыми"
},
"util": {
"No application is found for userId: %s": "Не найдено заявки для пользователя с идентификатором: %s",
"No provider for category: %s is found for application: %s": "Нет провайдера для категории: %s для приложения: %s",
"The provider: %s is not found": "Поставщик: %s не найден"
},
"verification": {
"Invalid captcha provider.": "Недействительный поставщик CAPTCHA.",
"Phone number is invalid in your region %s": "Номер телефона недействителен в вашем регионе %s",
"The forgot password feature is disabled": "The forgot password feature is disabled",
"The verification code has already been used!": "Код подтверждения уже использован!",
"The verification code has not been sent yet!": "Код подтверждения еще не был отправлен!",
"Turing test failed.": "Тест Тьюринга не удался.",
"Unable to get the email modify rule.": "Невозможно получить правило изменения электронной почты.",
"Unable to get the phone modify rule.": "Невозможно получить правило изменения телефона.",
"Unknown type": "Неизвестный тип",
"Wrong verification code!": "Неправильный код подтверждения!",
"You should verify your code in %d min!": "Вы должны проверить свой код через %d минут!",
"please add a SMS provider to the \"Providers\" list for the application: %s": "Пожалуйста, добавьте SMS-провайдера в список \"Провайдеры\" для приложения: %s",
"please add an Email provider to the \"Providers\" list for the application: %s": "пожалуйста, добавьте Email-провайдера в список \\\"Провайдеры\\\" для приложения: %s",
"the user does not exist, please sign up first": "Пользователь не существует, пожалуйста, сначала зарегистрируйтесь"
},
"webauthn": {
"Found no credentials for this user": "Учетные данные для этого пользователя не найдены",
"Please call WebAuthnSigninBegin first": "Пожалуйста, сначала вызовите WebAuthnSigninBegin"
}
}

View File

@@ -54,6 +54,7 @@
"pt",
"tr",
"pl",
"ru",
"uk"
],
"masterPassword": "",

63
log/agent_openclaw.go Normal file
View File

@@ -0,0 +1,63 @@
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package log
import "fmt"
// OtlpAdder persists a single OTLP record into the backing store.
// Parameters: entryType ("trace"/"metrics"/"log"), message (JSON payload),
// clientIp and userAgent from the originating HTTP request.
// The unique entry name is generated by the implementation.
type OtlpAdder func(entryType, message, clientIp, userAgent string) error
// OpenClawProvider receives OpenTelemetry data pushed by an OpenClaw agent over
// HTTP and persists each record as an Entry row via the OtlpAdder supplied at
// construction time. It is passive (push-based via HTTP): Start/Stop are no-ops
// and Write is not applicable.
type OpenClawProvider struct {
providerName string
addOtlpEntry OtlpAdder
}
// NewOpenClawProvider creates an OpenClawProvider backed by addOtlpEntry.
func NewOpenClawProvider(providerName string, addOtlpEntry OtlpAdder) *OpenClawProvider {
return &OpenClawProvider{providerName: providerName, addOtlpEntry: addOtlpEntry}
}
// Write is not applicable for an HTTP-push provider and always returns an error.
func (p *OpenClawProvider) Write(_, _ string) error {
return fmt.Errorf("OpenClawProvider receives data over HTTP and does not accept Write calls")
}
// Start is a no-op; OpenClawProvider is passive and has no background goroutine.
func (p *OpenClawProvider) Start(_ EntryAdder, _ func(error)) error { return nil }
// Stop is a no-op.
func (p *OpenClawProvider) Stop() error { return nil }
// AddTrace persists an OTLP trace payload (already serialised to JSON).
func (p *OpenClawProvider) AddTrace(message []byte, clientIp, userAgent string) error {
return p.addOtlpEntry("trace", string(message), clientIp, userAgent)
}
// AddMetrics persists an OTLP metrics payload (already serialised to JSON).
func (p *OpenClawProvider) AddMetrics(message []byte, clientIp, userAgent string) error {
return p.addOtlpEntry("metrics", string(message), clientIp, userAgent)
}
// AddLogs persists an OTLP logs payload (already serialised to JSON).
func (p *OpenClawProvider) AddLogs(message []byte, clientIp, userAgent string) error {
return p.addOtlpEntry("log", string(message), clientIp, userAgent)
}

47
log/casdoor_permission.go Normal file
View File

@@ -0,0 +1,47 @@
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package log
import (
"fmt"
"time"
)
// PermissionLogProvider records Casbin authorization decisions as Entry rows.
// It is push-based: callers supply log lines via Write, which are immediately
// persisted through the injected EntryAdder. Start and Stop are no-ops.
type PermissionLogProvider struct {
providerName string
addEntry EntryAdder
}
// NewPermissionLogProvider creates a PermissionLogProvider backed by addEntry.
func NewPermissionLogProvider(providerName string, addEntry EntryAdder) *PermissionLogProvider {
return &PermissionLogProvider{providerName: providerName, addEntry: addEntry}
}
// Write stores one permission-log entry.
// severity follows syslog conventions (e.g. info, warning, err).
func (p *PermissionLogProvider) Write(severity string, message string) error {
createdTime := time.Now().UTC().Format(time.RFC3339)
return p.addEntry("built-in", createdTime, p.providerName, fmt.Sprintf("[%s] %s", severity, message))
}
// Start is a no-op for PermissionLogProvider; it received its EntryAdder at
// construction time and does not require background collection.
func (p *PermissionLogProvider) Start(_ EntryAdder, _ func(error)) error { return nil }
// Stop is a no-op for PermissionLogProvider.
func (p *PermissionLogProvider) Stop() error { return nil }

75
log/provider.go Normal file
View File

@@ -0,0 +1,75 @@
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package log
import (
"fmt"
"github.com/thanhpk/randstr"
)
// GenerateEntryName returns a cryptographically random 32-character hex string
// suitable for use as an Entry.Name primary key.
func GenerateEntryName() string {
return randstr.Hex(16)
}
// EntryAdder persists a collected log entry into the backing store.
// Parameters map to the Entry table columns: owner, createdTime (RFC3339),
// provider (the log provider name), and message. The unique entry name is
// generated by the implementation, so callers do not need to supply one.
// Defined here so it is shared by all LogProvider implementations without
// creating import cycles with the object package.
type EntryAdder func(owner, createdTime, provider, message string) error
// LogProvider is the common interface for all log providers.
//
// Push-based providers (e.g. PermissionLogProvider) receive individual log
// lines through Write and persist them immediately. Start and Stop are no-ops
// for these providers.
//
// Pull-based providers (e.g. SystemLogProvider) actively collect logs from an
// external source. Start begins a background collection goroutine that calls
// addEntry for every new record; Stop halts collection. Write returns an error
// for these providers as they are not designed to accept external input.
type LogProvider interface {
// Write records a single log line. Used by push-based providers.
Write(severity string, message string) error
// Start begins background log collection with the given EntryAdder.
// For push-based providers this is a no-op (they received addEntry at
// construction time). onError is called from the background goroutine
// when collection stops with a fatal error; it may be nil.
Start(addEntry EntryAdder, onError func(error)) error
// Stop halts background collection and releases any OS resources.
Stop() error
}
// GetLogProvider returns a concrete log provider for the given type and connection settings.
// The title parameter is used as the OS log tag for System Log.
// Types that are not yet implemented return a non-nil error.
func GetLogProvider(typ string, _ string, _ int, title string) (LogProvider, error) {
switch typ {
case "System Log":
tag := title
if tag == "" {
tag = "casdoor"
}
return NewSystemLogProvider(tag)
case "SELinux Log":
return NewSELinuxLogProvider()
default:
return nil, fmt.Errorf("unsupported log provider type: %s", typ)
}
}

66
log/selinux_log.go Normal file
View File

@@ -0,0 +1,66 @@
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package log
import (
"context"
"fmt"
)
// SELinuxLogProvider collects SELinux audit events (AVC denials and related
// records) from the local system and stores each record as an Entry row via
// the EntryAdder supplied to Start.
//
// It is pull-based: Write is not applicable and returns an error.
// Start launches the background collector; Stop cancels it.
// On platforms where SELinux is not supported, Start returns an error.
type SELinuxLogProvider struct {
cancel context.CancelFunc
}
// NewSELinuxLogProvider creates a SELinuxLogProvider.
// Call Start to begin collection.
func NewSELinuxLogProvider() (*SELinuxLogProvider, error) {
return &SELinuxLogProvider{}, nil
}
// Write is not applicable for a pull-based collector and always returns an error.
func (s *SELinuxLogProvider) Write(severity string, message string) error {
return fmt.Errorf("SELinuxLogProvider is a log collector and does not accept Write calls")
}
// Start launches a background goroutine that reads new SELinux audit records
// and persists each one by calling addEntry. Returns immediately; collection
// runs until Stop is called. If the goroutine encounters a fatal error,
// onError is called with that error (onError may be nil).
func (s *SELinuxLogProvider) Start(addEntry EntryAdder, onError func(error)) error {
ctx, cancel := context.WithCancel(context.Background())
s.cancel = cancel
go func() {
if err := collectSELinuxLogs(ctx, addEntry); err != nil && onError != nil {
onError(err)
}
}()
return nil
}
// Stop cancels background collection. It is safe to call multiple times.
func (s *SELinuxLogProvider) Stop() error {
if s.cancel != nil {
s.cancel()
s.cancel = nil
}
return nil
}

140
log/selinux_log_linux.go Normal file
View File

@@ -0,0 +1,140 @@
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build linux
package log
import (
"bufio"
"context"
"fmt"
"os"
"os/exec"
"regexp"
"strconv"
"strings"
"time"
)
const auditLogPath = "/var/log/audit/audit.log"
// selinuxAuditTypes is the set of audit record types that are SELinux-related.
var selinuxAuditTypes = map[string]bool{
"AVC": true,
"USER_AVC": true,
"SELINUX_ERR": true,
"MAC_POLICY_LOAD": true,
"MAC_STATUS": true,
}
// auditTimestampRe matches the msg=audit(seconds.millis:serial) field.
var auditTimestampRe = regexp.MustCompile(`msg=audit\((\d+)\.\d+:\d+\)`)
// CheckSELinuxAvailable returns nil if SELinux is active and the audit log is
// readable on this system. Returns a descriptive error otherwise.
func CheckSELinuxAvailable() error {
if _, err := os.Stat("/sys/fs/selinux/enforce"); os.IsNotExist(err) {
return fmt.Errorf("SELinux is not available or not mounted on this system")
}
if _, err := os.Stat(auditLogPath); os.IsNotExist(err) {
return fmt.Errorf("SELinux audit log not found at %s (is auditd running?)", auditLogPath)
}
return nil
}
// collectSELinuxLogs tails /var/log/audit/audit.log and persists each
// SELinux-related audit record via addEntry until ctx is cancelled.
func collectSELinuxLogs(ctx context.Context, addEntry EntryAdder) error {
if err := CheckSELinuxAvailable(); err != nil {
return fmt.Errorf("SELinuxLogProvider: %w", err)
}
cmd := exec.CommandContext(ctx, "tail", "-f", "-n", "0", auditLogPath)
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("SELinuxLogProvider: failed to open audit log pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("SELinuxLogProvider: failed to start tail: %w", err)
}
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
select {
case <-ctx.Done():
return nil
default:
}
line := scanner.Text()
if !isSELinuxAuditLine(line) {
continue
}
severity := selinuxSeverity(line)
createdTime := parseAuditTimestamp(line)
if err := addEntry("built-in", createdTime, "",
fmt.Sprintf("[%s] %s", severity, line)); err != nil {
return fmt.Errorf("SELinuxLogProvider: failed to persist audit entry: %w", err)
}
}
if err := scanner.Err(); err != nil {
if ctx.Err() != nil {
return nil
}
return fmt.Errorf("SELinuxLogProvider: audit log read error: %w", err)
}
return nil
}
// isSELinuxAuditLine reports whether the audit log line is an SELinux record.
func isSELinuxAuditLine(line string) bool {
// Audit lines start with "type=<TYPE> "
const prefix = "type="
if !strings.HasPrefix(line, prefix) {
return false
}
end := strings.IndexByte(line[len(prefix):], ' ')
var typ string
if end < 0 {
typ = line[len(prefix):]
} else {
typ = line[len(prefix) : len(prefix)+end]
}
return selinuxAuditTypes[typ]
}
// selinuxSeverity maps SELinux audit record types to a syslog severity name.
func selinuxSeverity(line string) string {
if strings.HasPrefix(line, "type=AVC") || strings.HasPrefix(line, "type=USER_AVC") || strings.HasPrefix(line, "type=SELINUX_ERR") {
return "warning"
}
return "info"
}
// parseAuditTimestamp extracts the Unix timestamp from an audit log line and
// returns it as an RFC3339 string. Falls back to the current time on failure.
func parseAuditTimestamp(line string) string {
m := auditTimestampRe.FindStringSubmatch(line)
if m == nil {
return time.Now().UTC().Format(time.RFC3339)
}
sec, err := strconv.ParseInt(m[1], 10, 64)
if err != nil {
return time.Now().UTC().Format(time.RFC3339)
}
return time.Unix(sec, 0).UTC().Format(time.RFC3339)
}

View File

@@ -0,0 +1,33 @@
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build !linux
package log
import (
"context"
"fmt"
"runtime"
)
// CheckSELinuxAvailable always returns an error on non-Linux platforms.
func CheckSELinuxAvailable() error {
return fmt.Errorf("SELinux is not supported on %s", runtime.GOOS)
}
// collectSELinuxLogs is a no-op on non-Linux platforms.
func collectSELinuxLogs(_ context.Context, _ EntryAdder) error {
return CheckSELinuxAvailable()
}

79
log/system_log.go Normal file
View File

@@ -0,0 +1,79 @@
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package log
import (
"context"
"fmt"
)
// platformCollector is an OS-specific log reader.
// Implementations are in system_log_unix.go and system_log_windows.go.
type platformCollector interface {
// collect blocks and streams new OS log records to addEntry until ctx is
// cancelled or a fatal error occurs. It must return promptly when
// ctx.Done() is closed. A non-nil error means collection stopped
// unexpectedly and should be reported to the operator.
collect(ctx context.Context, addEntry EntryAdder) error
}
// SystemLogProvider collects log records from the operating-system's native
// logging facility (journald/syslog on Linux/Unix, Event Log on Windows) and
// stores each record as an Entry row via the EntryAdder supplied to Start.
//
// It is pull-based: Write is not applicable and returns an error.
// Start launches the background collector; Stop cancels it.
type SystemLogProvider struct {
tag string
cancel context.CancelFunc
}
// NewSystemLogProvider creates a SystemLogProvider that will identify itself
// with the given tag when collecting OS log records.
// Call Start to begin collection.
func NewSystemLogProvider(tag string) (*SystemLogProvider, error) {
return &SystemLogProvider{tag: tag}, nil
}
// Write is not applicable for a pull-based collector and always returns an
// error. Callers in the permission-log path should skip System Log providers.
func (s *SystemLogProvider) Write(severity string, message string) error {
return fmt.Errorf("SystemLogProvider is a log collector and does not accept Write calls")
}
// Start launches a background goroutine that reads new OS log records and
// persists each one by calling addEntry. It returns immediately; collection
// runs until Stop is called. If the goroutine encounters a fatal error,
// onError is called with that error (onError may be nil).
func (s *SystemLogProvider) Start(addEntry EntryAdder, onError func(error)) error {
ctx, cancel := context.WithCancel(context.Background())
s.cancel = cancel
collector := newPlatformCollector(s.tag)
go func() {
if err := collector.collect(ctx, addEntry); err != nil && onError != nil {
onError(err)
}
}()
return nil
}
// Stop cancels background collection. It is safe to call multiple times.
func (s *SystemLogProvider) Stop() error {
if s.cancel != nil {
s.cancel()
s.cancel = nil
}
return nil
}

121
log/system_log_unix.go Normal file
View File

@@ -0,0 +1,121 @@
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build !windows
package log
import (
"bufio"
"context"
"encoding/json"
"fmt"
"os/exec"
"strconv"
"time"
)
type unixCollector struct {
tag string
}
func newPlatformCollector(tag string) platformCollector {
return &unixCollector{tag: tag}
}
// collect streams new journald records to addEntry until ctx is cancelled or
// a fatal error occurs. It runs `journalctl -n 0 -f --output=json` so only
// records that arrive after Start is called are collected (no backfill).
// Returns nil when ctx is cancelled normally; returns a non-nil error if the
// process could not be started or the output pipe broke unexpectedly.
func (u *unixCollector) collect(ctx context.Context, addEntry EntryAdder) error {
cmd := exec.CommandContext(ctx, "journalctl", "-n", "0", "-f", "--output=json")
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("SystemLogProvider: failed to open journalctl stdout pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("SystemLogProvider: failed to start journalctl: %w", err)
}
scanner := bufio.NewScanner(stdout)
// journald JSON lines can be large; use a 1 MB buffer.
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
for scanner.Scan() {
select {
case <-ctx.Done():
return nil
default:
}
var fields map[string]interface{}
if err := json.Unmarshal(scanner.Bytes(), &fields); err != nil {
continue
}
severity := journalSeverity(fields)
message := journalMessage(fields)
createdTime := journalTimestamp(fields)
if err := addEntry("built-in", createdTime, u.tag,
fmt.Sprintf("[%s] %s", severity, message)); err != nil {
return fmt.Errorf("SystemLogProvider: failed to persist journal entry: %w", err)
}
}
if err := scanner.Err(); err != nil {
// A cancelled context causes the pipe to close; treat that as normal exit.
if ctx.Err() != nil {
return nil
}
return fmt.Errorf("SystemLogProvider: journalctl output error: %w", err)
}
return nil
}
// journalSeverity maps the journald PRIORITY field to a syslog severity name.
// PRIORITY values: 0=emerg 1=alert 2=crit 3=err 4=warning 5=notice 6=info 7=debug
func journalSeverity(fields map[string]interface{}) string {
mapping := map[string]string{
"0": "emerg", "1": "alert", "2": "crit", "3": "err",
"4": "warning", "5": "notice", "6": "info", "7": "debug",
}
if p, ok := fields["PRIORITY"].(string); ok {
if s, ok2 := mapping[p]; ok2 {
return s
}
}
return "info"
}
// journalMessage extracts the human-readable message from journald JSON.
func journalMessage(fields map[string]interface{}) string {
if msg, ok := fields["MESSAGE"].(string); ok {
return msg
}
return ""
}
// journalTimestamp converts the journald __REALTIME_TIMESTAMP (microseconds
// since Unix epoch) to an RFC3339 string.
func journalTimestamp(fields map[string]interface{}) string {
if ts, ok := fields["__REALTIME_TIMESTAMP"].(string); ok {
usec, err := strconv.ParseInt(ts, 10, 64)
if err == nil {
t := time.Unix(usec/1_000_000, (usec%1_000_000)*1_000).UTC()
return t.Format(time.RFC3339)
}
}
return time.Now().UTC().Format(time.RFC3339)
}

180
log/system_log_windows.go Normal file
View File

@@ -0,0 +1,180 @@
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build windows
package log
import (
"context"
"encoding/xml"
"fmt"
"io"
"os/exec"
"strings"
"time"
)
// Windows Event Log channels to collect from.
var eventLogChannels = []string{"System", "Application"}
type windowsCollector struct {
tag string
}
func newPlatformCollector(tag string) platformCollector {
return &windowsCollector{tag: tag}
}
// collect polls Windows Event Log channels every 5 seconds via wevtutil.exe
// and persists new records to addEntry. Only events that arrive after Start
// is called are collected; historical events are not backfilled.
// Returns nil when ctx is cancelled normally.
func (w *windowsCollector) collect(ctx context.Context, addEntry EntryAdder) error {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
lastCheck := time.Now().UTC()
for {
select {
case <-ctx.Done():
return nil
case tick := <-ticker.C:
for _, channel := range eventLogChannels {
if err := w.queryChannel(ctx, channel, lastCheck, addEntry); err != nil {
return fmt.Errorf("SystemLogProvider: error querying channel %s: %w", channel, err)
}
}
lastCheck = tick.UTC()
}
}
}
// queryChannel runs wevtutil.exe to fetch events from channel that were
// created after since, then stores each event via addEntry.
// Returns a non-nil error if the wevtutil command fails or XML parsing fails.
func (w *windowsCollector) queryChannel(ctx context.Context, channel string, since time.Time, addEntry EntryAdder) error {
sinceStr := since.Format("2006-01-02T15:04:05.000Z")
query := fmt.Sprintf("*[System[TimeCreated[@SystemTime>='%s']]]", sinceStr)
cmd := exec.CommandContext(ctx, "wevtutil.exe", "qe", channel,
"/f:RenderedXml", "/rd:false",
fmt.Sprintf("/q:%s", query),
)
out, err := cmd.Output()
if err != nil {
// A cancelled context is a normal shutdown, not an error.
if ctx.Err() != nil {
return nil
}
return fmt.Errorf("wevtutil.exe failed for channel %s: %w", channel, err)
}
if len(out) == 0 {
return nil
}
return w.parseAndPersistEvents(out, channel, addEntry)
}
// parseAndPersistEvents decodes wevtutil XML output and persists each Event
// record via addEntry. wevtutil outputs one <Event> element per record;
// the output is wrapped in a synthetic <Events> root so the decoder can
// handle multiple records in one pass. Token()+DecodeElement() is used to
// skip the wrapper element without triggering an XMLName mismatch error.
func (w *windowsCollector) parseAndPersistEvents(out []byte, channel string, addEntry EntryAdder) error {
wrapped := "<Events>" + string(out) + "</Events>"
decoder := xml.NewDecoder(strings.NewReader(wrapped))
for {
token, err := decoder.Token()
if err != nil {
if err == io.EOF {
break
}
return fmt.Errorf("SystemLogProvider: failed to parse event XML (channel=%s): %w", channel, err)
}
se, ok := token.(xml.StartElement)
if !ok || se.Name.Local != "Event" {
continue
}
var event winEvent
if err := decoder.DecodeElement(&event, &se); err != nil {
return fmt.Errorf("SystemLogProvider: failed to decode event XML (channel=%s): %w", channel, err)
}
severity := winEventSeverity(event.System.Level)
message := strings.TrimSpace(event.RenderingInfo.Message)
if message == "" {
message = fmt.Sprintf("EventID=%d Source=%s", event.System.EventID, event.System.Provider.Name)
}
createdTime := winEventTimestamp(event.System.TimeCreated.SystemTime)
if err := addEntry("built-in", createdTime, w.tag,
fmt.Sprintf("[%s] [%s] %s", severity, channel, message)); err != nil {
return fmt.Errorf("SystemLogProvider: failed to persist event (channel=%s EventID=%d): %w",
channel, event.System.EventID, err)
}
}
return nil
}
// winEvent represents the subset of the Windows Event XML schema that we need.
type winEvent struct {
XMLName xml.Name `xml:"Event"`
System struct {
Provider struct {
Name string `xml:"Name,attr"`
} `xml:"Provider"`
EventID int `xml:"EventID"`
Level int `xml:"Level"`
TimeCreated struct {
SystemTime string `xml:"SystemTime,attr"`
} `xml:"TimeCreated"`
} `xml:"System"`
RenderingInfo struct {
Message string `xml:"Message"`
} `xml:"RenderingInfo"`
}
// winEventSeverity maps Windows Event Log Level values to syslog severity names.
// Level: 1=Critical 2=Error 3=Warning 4=Information 5=Verbose
func winEventSeverity(level int) string {
switch level {
case 1:
return "crit"
case 2:
return "err"
case 3:
return "warning"
case 5:
return "debug"
default: // 4=Information and anything else
return "info"
}
}
// winEventTimestamp parses a Windows Event SystemTime attribute string to RFC3339.
func winEventTimestamp(s string) string {
// SystemTime is in the form "2024-01-15T10:30:00.000000000Z"
t, err := time.Parse(time.RFC3339Nano, s)
if err != nil {
// Try without nanoseconds
t, err = time.Parse("2006-01-02T15:04:05.000000000Z", s)
if err != nil {
return time.Now().UTC().Format(time.RFC3339)
}
}
return t.UTC().Format(time.RFC3339)
}

View File

@@ -66,12 +66,12 @@ func main() {
}
object.InitDefaultStorageProvider()
object.InitLogProviders()
object.InitLdapAutoSynchronizer()
proxy.InitHttpClient()
authz.InitApi()
object.InitUserManager()
object.InitFromFile()
object.InitCasvisorConfig()
object.InitCleanupTokens()
object.InitSiteMap()
@@ -90,6 +90,7 @@ func main() {
web.SetStaticPath("/swagger", "swagger")
web.SetStaticPath("/files", "files")
// https://studygolang.com/articles/2303
web.InsertFilter("*", web.BeforeStatic, routers.RequestBodyFilter)
web.InsertFilter("*", web.BeforeRouter, routers.StaticFilter)
web.InsertFilter("*", web.BeforeRouter, routers.AutoSigninFilter)
web.InsertFilter("*", web.BeforeRouter, routers.CorsFilter)
@@ -133,6 +134,9 @@ func main() {
go radius.StartRadiusServer()
go object.ClearThroughputPerSecond()
// Start webhook delivery worker
object.StartWebhookDeliveryWorker()
if len(object.SiteMap) != 0 {
service.Start()
}

367
mcp/util.go Normal file
View File

@@ -0,0 +1,367 @@
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package mcp
import (
"context"
"encoding/binary"
"fmt"
"net"
"net/http"
"strconv"
"strings"
"time"
"github.com/casdoor/casdoor/util"
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
"golang.org/x/oauth2"
)
type InnerMcpServer struct {
Host string `json:"host"`
Port int `json:"port"`
Path string `json:"path"`
Url string `json:"url"`
}
func GetServerTools(owner, name, url, token string) ([]*mcpsdk.Tool, error) {
var session *mcpsdk.ClientSession
var err error
ctx, cancel := context.WithTimeout(context.Background(), time.Minute*10)
defer cancel()
client := mcpsdk.NewClient(&mcpsdk.Implementation{Name: util.GetId(owner, name), Version: "1.0.0"}, nil)
if strings.HasSuffix(url, "sse") {
if token != "" {
httpClient := oauth2.NewClient(ctx, oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}))
session, err = client.Connect(ctx, &mcpsdk.StreamableClientTransport{Endpoint: url, HTTPClient: httpClient}, nil)
} else {
session, err = client.Connect(ctx, &mcpsdk.StreamableClientTransport{Endpoint: url}, nil)
}
} else {
if token != "" {
httpClient := oauth2.NewClient(ctx, oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}))
session, err = client.Connect(ctx, &mcpsdk.StreamableClientTransport{Endpoint: url, HTTPClient: httpClient}, nil)
} else {
session, err = client.Connect(ctx, &mcpsdk.StreamableClientTransport{Endpoint: url}, nil)
}
}
if err != nil {
return nil, err
}
defer session.Close()
toolResult, err := session.ListTools(ctx, nil)
if err != nil {
return nil, err
}
return toolResult.Tools, nil
}
func SanitizeScheme(scheme string) string {
scheme = strings.ToLower(strings.TrimSpace(scheme))
if scheme == "https" {
return "https"
}
return "http"
}
func SanitizeTimeout(timeoutMs int, defaultTimeoutMs int, maxTimeoutMs int) time.Duration {
if timeoutMs <= 0 {
timeoutMs = defaultTimeoutMs
}
if timeoutMs > maxTimeoutMs {
timeoutMs = maxTimeoutMs
}
return time.Duration(timeoutMs) * time.Millisecond
}
func SanitizeConcurrency(maxConcurrency int, defaultConcurrency int, maxAllowed int) int {
if maxConcurrency <= 0 {
maxConcurrency = defaultConcurrency
}
if maxConcurrency > maxAllowed {
maxConcurrency = maxAllowed
}
return maxConcurrency
}
func SanitizePorts(portInputs []string, defaultPorts []int) []int {
if len(portInputs) == 0 {
return append([]int{}, defaultPorts...)
}
portSet := map[int]struct{}{}
result := make([]int, 0, len(portInputs))
for _, portInput := range portInputs {
portInput = strings.TrimSpace(portInput)
if portInput == "" {
continue
}
if strings.Contains(portInput, "-") {
parts := strings.SplitN(portInput, "-", 2)
if len(parts) != 2 {
continue
}
start, err := strconv.Atoi(strings.TrimSpace(parts[0]))
if err != nil {
continue
}
end, err := strconv.Atoi(strings.TrimSpace(parts[1]))
if err != nil {
continue
}
if start > end {
continue
}
if start < 1 {
start = 1
}
if end > 65535 {
end = 65535
}
if start > end {
continue
}
for port := start; port <= end; port++ {
if _, ok := portSet[port]; ok {
continue
}
portSet[port] = struct{}{}
result = append(result, port)
}
continue
}
port, err := strconv.Atoi(portInput)
if err != nil {
continue
}
if port <= 0 || port > 65535 {
continue
}
if _, ok := portSet[port]; ok {
continue
}
portSet[port] = struct{}{}
result = append(result, port)
}
if len(result) == 0 {
return append([]int{}, defaultPorts...)
}
return result
}
func SanitizePaths(paths []string, defaultPaths []string) []string {
if len(paths) == 0 {
return append([]string{}, defaultPaths...)
}
pathSet := map[string]struct{}{}
result := make([]string, 0, len(paths))
for _, path := range paths {
path = strings.TrimSpace(path)
if path == "" {
continue
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
if _, ok := pathSet[path]; ok {
continue
}
pathSet[path] = struct{}{}
result = append(result, path)
}
if len(result) == 0 {
return append([]string{}, defaultPaths...)
}
return result
}
func ParseScanTargets(targets []string, maxHosts int) ([]net.IP, error) {
hostSet := map[uint32]struct{}{}
hosts := make([]net.IP, 0)
addHost := func(ipv4 net.IP) error {
value := binary.BigEndian.Uint32(ipv4)
if _, ok := hostSet[value]; ok {
return nil
}
if len(hosts) >= maxHosts {
return fmt.Errorf("scan targets exceed max %d hosts", maxHosts)
}
hostSet[value] = struct{}{}
host := make(net.IP, net.IPv4len)
copy(host, ipv4)
hosts = append(hosts, host)
return nil
}
for _, target := range targets {
target = strings.TrimSpace(target)
if target == "" {
continue
}
if ip := net.ParseIP(target); ip != nil {
ipv4 := ip.To4()
if ipv4 == nil {
return nil, fmt.Errorf("only IPv4 is supported: %s", target)
}
if !util.IsIntranetIp(ipv4.String()) {
return nil, fmt.Errorf("target must be intranet: %s", target)
}
if err := addHost(ipv4); err != nil {
return nil, err
}
continue
}
cidrHosts, err := ParseCIDRHosts(target, maxHosts)
if err != nil {
return nil, err
}
for _, host := range cidrHosts {
if !util.IsIntranetIp(host.String()) {
return nil, fmt.Errorf("target must be intranet: %s", target)
}
if err = addHost(host.To4()); err != nil {
return nil, err
}
}
}
if len(hosts) == 0 {
return nil, fmt.Errorf("cidr is required")
}
return hosts, nil
}
func ParseCIDRHosts(cidr string, maxHosts int) ([]net.IP, error) {
baseIp, ipNet, err := net.ParseCIDR(cidr)
if err != nil {
return nil, err
}
ipv4 := baseIp.To4()
if ipv4 == nil {
return nil, fmt.Errorf("only IPv4 CIDR is supported")
}
if !util.IsIntranetIp(ipv4.String()) {
return nil, fmt.Errorf("cidr must be intranet: %s", cidr)
}
ones, bits := ipNet.Mask.Size()
hostBits := bits - ones
if hostBits < 0 {
return nil, fmt.Errorf("invalid cidr mask: %s", cidr)
}
if hostBits >= 63 {
return nil, fmt.Errorf("cidr range is too large")
}
total := uint64(1) << hostBits
if total > uint64(maxHosts)+2 {
return nil, fmt.Errorf("cidr range is too large, max %d hosts", maxHosts)
}
totalInt := int(total)
start := binary.BigEndian.Uint32(ipv4.Mask(ipNet.Mask))
end := start + uint32(total) - 1
hosts := make([]net.IP, 0, totalInt)
for value := start; value <= end; value++ {
if total > 2 && (value == start || value == end) {
continue
}
candidate := make(net.IP, net.IPv4len)
binary.BigEndian.PutUint32(candidate, value)
if ipNet.Contains(candidate) {
hosts = append(hosts, candidate)
}
}
if len(hosts) == 0 {
return nil, fmt.Errorf("cidr has no usable hosts: %s", cidr)
}
return hosts, nil
}
func ProbeHost(ctx context.Context, client *http.Client, scheme, host string, ports []int, paths []string, timeout time.Duration) (bool, []*InnerMcpServer) {
if !util.IsIntranetIp(host) {
return false, nil
}
dialer := &net.Dialer{Timeout: timeout}
isOnline := false
var servers []*InnerMcpServer
for _, port := range ports {
address := net.JoinHostPort(host, strconv.Itoa(port))
conn, err := dialer.DialContext(ctx, "tcp", address)
if err != nil {
continue
}
_ = conn.Close()
isOnline = true
for _, path := range paths {
server, ok := probeMcpInitialize(ctx, client, scheme, host, port, path)
if ok {
servers = append(servers, server)
}
}
}
return isOnline, servers
}
func probeMcpInitialize(ctx context.Context, client *http.Client, scheme, host string, port int, path string) (*InnerMcpServer, bool) {
fullUrl := fmt.Sprintf("%s://%s%s", scheme, net.JoinHostPort(host, strconv.Itoa(port)), path)
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, fullUrl, nil)
if err != nil {
return nil, false
}
resp, err := client.Do(httpReq)
if err != nil {
return nil, false
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode == http.StatusNotFound {
return nil, false
}
return &InnerMcpServer{
Host: host,
Port: port,
Path: path,
Url: fullUrl,
}, true
}

View File

@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
package mcp
package mcpself
import (
"fmt"

View File

@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
package mcp
package mcpself
import (
"strings"

View File

@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
package mcp
package mcpself
import (
"encoding/json"

View File

@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
package mcp
package mcpself
import (
"github.com/casdoor/casdoor/object"

View File

@@ -161,6 +161,9 @@ func (adapter *Adapter) InitAdapter() error {
}
} else {
driverName = adapter.DatabaseType
if driverName == "sqlite3" {
driverName = "sqlite"
}
switch driverName {
case "mssql":
dataSourceName = fmt.Sprintf("sqlserver://%s:%s@%s:%d?database=%s", adapter.User,
@@ -174,7 +177,7 @@ func (adapter *Adapter) InitAdapter() error {
case "CockroachDB":
dataSourceName = fmt.Sprintf("user=%s password=%s host=%s port=%d sslmode=disable dbname=%s serial_normalization=virtual_sequence",
adapter.User, adapter.Password, adapter.Host, adapter.Port, adapter.Database)
case "sqlite3":
case "sqlite":
dataSourceName = fmt.Sprintf("file:%s", adapter.Host)
default:
return fmt.Errorf("unsupported database type: %s", adapter.DatabaseType)

View File

@@ -64,6 +64,53 @@ func (a *SafeAdapter) RemovePolicies(sec string, ptype string, rules [][]string)
return err
}
func (a *SafeAdapter) UpdatePolicy(sec string, ptype string, oldRule []string, newRule []string) error {
oldLine := a.buildCasbinRule(ptype, oldRule)
newLine := a.buildCasbinRule(ptype, newRule)
session := a.engine.NewSession()
defer session.Close()
if a.tableName != "" {
session = session.Table(a.tableName)
}
_, err := session.
Where("ptype = ? AND v0 = ? AND v1 = ? AND v2 = ? AND v3 = ? AND v4 = ? AND v5 = ?",
oldLine.Ptype, oldLine.V0, oldLine.V1, oldLine.V2, oldLine.V3, oldLine.V4, oldLine.V5).
MustCols("ptype", "v0", "v1", "v2", "v3", "v4", "v5").
Update(newLine)
return err
}
func (a *SafeAdapter) UpdatePolicies(sec string, ptype string, oldRules [][]string, newRules [][]string) error {
_, err := a.engine.Transaction(func(tx *xorm.Session) (interface{}, error) {
for i, oldRule := range oldRules {
oldLine := a.buildCasbinRule(ptype, oldRule)
newLine := a.buildCasbinRule(ptype, newRules[i])
var session *xorm.Session
if a.tableName != "" {
session = tx.Table(a.tableName)
} else {
session = tx
}
_, err := session.
Where("ptype = ? AND v0 = ? AND v1 = ? AND v2 = ? AND v3 = ? AND v4 = ? AND v5 = ?",
oldLine.Ptype, oldLine.V0, oldLine.V1, oldLine.V2, oldLine.V3, oldLine.V4, oldLine.V5).
MustCols("ptype", "v0", "v1", "v2", "v3", "v4", "v5").
Update(newLine)
if err != nil {
return nil, err
}
}
return nil, nil
})
return err
}
func (a *SafeAdapter) buildCasbinRule(ptype string, rule []string) *xormadapter.CasbinRule {
line := xormadapter.CasbinRule{Ptype: ptype}

118
object/agent.go Normal file
View File

@@ -0,0 +1,118 @@
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package object
import (
"fmt"
"github.com/casdoor/casdoor/util"
"github.com/xorm-io/core"
)
type Agent struct {
Owner string `xorm:"varchar(100) notnull pk" json:"owner"`
Name string `xorm:"varchar(100) notnull pk" json:"name"`
CreatedTime string `xorm:"varchar(100)" json:"createdTime"`
UpdatedTime string `xorm:"varchar(100)" json:"updatedTime"`
DisplayName string `xorm:"varchar(100)" json:"displayName"`
Url string `xorm:"varchar(500)" json:"url"`
Token string `xorm:"varchar(500)" json:"token"`
Application string `xorm:"varchar(100)" json:"application"`
}
func GetAgents(owner string) ([]*Agent, error) {
agents := []*Agent{}
err := ormer.Engine.Desc("created_time").Find(&agents, &Agent{Owner: owner})
if err != nil {
return nil, err
}
return agents, nil
}
func getAgent(owner string, name string) (*Agent, error) {
agent := Agent{Owner: owner, Name: name}
existed, err := ormer.Engine.Get(&agent)
if err != nil {
return nil, err
}
if existed {
return &agent, nil
}
return nil, nil
}
func GetAgent(id string) (*Agent, error) {
owner, name := util.GetOwnerAndNameFromIdNoCheck(id)
return getAgent(owner, name)
}
func UpdateAgent(id string, agent *Agent) (bool, error) {
owner, name := util.GetOwnerAndNameFromIdNoCheck(id)
if a, err := getAgent(owner, name); err != nil {
return false, err
} else if a == nil {
return false, nil
}
agent.UpdatedTime = util.GetCurrentTime()
_, err := ormer.Engine.ID(core.PK{owner, name}).AllCols().Update(agent)
if err != nil {
return false, err
}
return true, nil
}
func AddAgent(agent *Agent) (bool, error) {
affected, err := ormer.Engine.Insert(agent)
if err != nil {
return false, err
}
return affected != 0, nil
}
func DeleteAgent(agent *Agent) (bool, error) {
affected, err := ormer.Engine.ID(core.PK{agent.Owner, agent.Name}).Delete(&Agent{})
if err != nil {
return false, err
}
return affected != 0, nil
}
func (agent *Agent) GetId() string {
return fmt.Sprintf("%s/%s", agent.Owner, agent.Name)
}
func GetAgentCount(owner, field, value string) (int64, error) {
session := GetSession(owner, -1, -1, field, value, "", "")
return session.Count(&Agent{})
}
func GetPaginationAgents(owner string, offset, limit int, field, value, sortField, sortOrder string) ([]*Agent, error) {
agents := []*Agent{}
session := GetSession(owner, offset, limit, field, value, sortField, sortOrder)
err := session.Find(&agents)
if err != nil {
return agents, err
}
return agents, nil
}

View File

@@ -17,8 +17,6 @@ package object
import (
"errors"
"fmt"
"regexp"
"strings"
"github.com/casdoor/casdoor/i18n"
"github.com/casdoor/casdoor/util"
@@ -96,6 +94,7 @@ type Application struct {
HeaderHtml string `xorm:"mediumtext" json:"headerHtml"`
EnablePassword bool `json:"enablePassword"`
EnableSignUp bool `json:"enableSignUp"`
EnableGuestSignin bool `json:"enableGuestSignin"`
DisableSignin bool `json:"disableSignin"`
EnableSigninSession bool `json:"enableSigninSession"`
EnableAutoSignin bool `json:"enableAutoSignin"`
@@ -221,192 +220,6 @@ func GetPaginationOrganizationApplications(owner, organization string, offset, l
return applications, nil
}
func getProviderMap(owner string) (m map[string]*Provider, err error) {
providers, err := GetProviders(owner)
if err != nil {
return nil, err
}
m = map[string]*Provider{}
for _, provider := range providers {
m[provider.Name] = GetMaskedProvider(provider, true)
}
return m, err
}
func extendApplicationWithProviders(application *Application) (err error) {
m, err := getProviderMap(application.Organization)
if err != nil {
return err
}
for _, providerItem := range application.Providers {
if provider, ok := m[providerItem.Name]; ok {
providerItem.Provider = provider
}
}
return
}
func extendApplicationWithOrg(application *Application) (err error) {
organization, err := getOrganization(application.Owner, application.Organization)
application.OrganizationObj = organization
return
}
func extendApplicationWithSigninItems(application *Application) (err error) {
if len(application.SigninItems) == 0 {
signinItem := &SigninItem{
Name: "Back button",
Visible: true,
CustomCss: ".back-button {\n top: 65px;\n left: 15px;\n position: absolute;\n}\n.back-inner-button{}",
Placeholder: "",
Rule: "None",
}
application.SigninItems = append(application.SigninItems, signinItem)
signinItem = &SigninItem{
Name: "Languages",
Visible: true,
CustomCss: ".login-languages {\n top: 55px;\n right: 5px;\n position: absolute;\n}",
Placeholder: "",
Rule: "None",
}
application.SigninItems = append(application.SigninItems, signinItem)
signinItem = &SigninItem{
Name: "Logo",
Visible: true,
CustomCss: ".login-logo-box {}",
Placeholder: "",
Rule: "None",
}
application.SigninItems = append(application.SigninItems, signinItem)
signinItem = &SigninItem{
Name: "Signin methods",
Visible: true,
CustomCss: ".signin-methods {}",
Placeholder: "",
Rule: "None",
}
application.SigninItems = append(application.SigninItems, signinItem)
signinItem = &SigninItem{
Name: "Username",
Visible: true,
CustomCss: ".login-username {}\n.login-username-input{}",
Placeholder: "",
Rule: "None",
}
application.SigninItems = append(application.SigninItems, signinItem)
signinItem = &SigninItem{
Name: "Password",
Visible: true,
CustomCss: ".login-password {}\n.login-password-input{}",
Placeholder: "",
Rule: "None",
}
application.SigninItems = append(application.SigninItems, signinItem)
signinItem = &SigninItem{
Name: "Verification code",
Visible: true,
CustomCss: ".verification-code {}\n.verification-code-input{}",
Placeholder: "",
Rule: "None",
}
application.SigninItems = append(application.SigninItems, signinItem)
signinItem = &SigninItem{
Name: "Agreement",
Visible: true,
CustomCss: ".login-agreement {}",
Placeholder: "",
Rule: "None",
}
application.SigninItems = append(application.SigninItems, signinItem)
signinItem = &SigninItem{
Name: "Forgot password?",
Visible: true,
CustomCss: ".login-forget-password {\n display: inline-flex;\n justify-content: space-between;\n width: 320px;\n margin-bottom: 25px;\n}",
Placeholder: "",
Rule: "None",
}
application.SigninItems = append(application.SigninItems, signinItem)
signinItem = &SigninItem{
Name: "Login button",
Visible: true,
CustomCss: ".login-button-box {\n margin-bottom: 5px;\n}\n.login-button {\n width: 100%;\n}",
Placeholder: "",
Rule: "None",
}
application.SigninItems = append(application.SigninItems, signinItem)
signinItem = &SigninItem{
Name: "Signup link",
Visible: true,
CustomCss: ".login-signup-link {\n margin-bottom: 24px;\n display: flex;\n justify-content: end;\n}",
Placeholder: "",
Rule: "None",
}
application.SigninItems = append(application.SigninItems, signinItem)
signinItem = &SigninItem{
Name: "Providers",
Visible: true,
CustomCss: ".provider-img {\n width: 30px;\n margin: 5px;\n}\n.provider-big-img {\n margin-bottom: 10px;\n}",
Placeholder: "",
Rule: "None",
}
application.SigninItems = append(application.SigninItems, signinItem)
}
for idx, item := range application.SigninItems {
if item.Label != "" && item.CustomCss == "" {
application.SigninItems[idx].CustomCss = item.Label
application.SigninItems[idx].Label = ""
}
}
return
}
func extendApplicationWithSigninMethods(application *Application) (err error) {
if len(application.SigninMethods) == 0 {
if application.EnablePassword {
signinMethod := &SigninMethod{Name: "Password", DisplayName: "Password", Rule: "All"}
application.SigninMethods = append(application.SigninMethods, signinMethod)
}
if application.EnableCodeSignin {
signinMethod := &SigninMethod{Name: "Verification code", DisplayName: "Verification code", Rule: "All"}
application.SigninMethods = append(application.SigninMethods, signinMethod)
}
if application.EnableWebAuthn {
signinMethod := &SigninMethod{Name: "WebAuthn", DisplayName: "WebAuthn", Rule: "None"}
application.SigninMethods = append(application.SigninMethods, signinMethod)
}
signinMethod := &SigninMethod{Name: "Face ID", DisplayName: "Face ID", Rule: "None"}
application.SigninMethods = append(application.SigninMethods, signinMethod)
}
if len(application.SigninMethods) == 0 {
signinMethod := &SigninMethod{Name: "Password", DisplayName: "Password", Rule: "All"}
application.SigninMethods = append(application.SigninMethods, signinMethod)
}
return
}
func extendApplicationWithSignupItems(application *Application) (err error) {
if len(application.SignupItems) == 0 {
application.SignupItems = []*SignupItem{
{Name: "ID", Visible: false, Required: true, Prompted: false, Rule: "Random"},
{Name: "Username", Visible: true, Required: true, Prompted: false, Rule: "None"},
{Name: "Display name", Visible: true, Required: true, Prompted: false, Rule: "None"},
{Name: "Password", Visible: true, Required: true, Prompted: false, Rule: "None"},
{Name: "Confirm password", Visible: true, Required: true, Prompted: false, Rule: "None"},
{Name: "Email", Visible: true, Required: true, Prompted: false, Rule: "None"},
{Name: "Phone", Visible: true, Required: true, Prompted: false, Rule: "None"},
{Name: "Agreement", Visible: true, Required: true, Prompted: false, Rule: "None"},
}
}
return
}
func getApplication(owner string, name string) (*Application, error) {
if owner == "" || name == "" {
return nil, nil
@@ -559,155 +372,6 @@ func GetApplication(id string) (*Application, error) {
return getApplication(owner, name)
}
func GetMaskedApplication(application *Application, userId string) *Application {
if application == nil {
return nil
}
if application.TokenFields == nil {
application.TokenFields = []string{}
}
if application.FailedSigninLimit == 0 {
application.FailedSigninLimit = DefaultFailedSigninLimit
}
if application.FailedSigninFrozenTime == 0 {
application.FailedSigninFrozenTime = DefaultFailedSigninFrozenTime
}
isOrgUser := false
if userId != "" {
if isUserIdGlobalAdmin(userId) {
return application
}
user, err := GetUser(userId)
if err != nil {
panic(err)
}
if user != nil {
if user.IsApplicationAdmin(application) {
return application
}
if user.Owner == application.Organization {
isOrgUser = true
}
}
}
application.ClientSecret = "***"
application.Cert = "***"
application.EnablePassword = false
application.EnableSigninSession = false
application.EnableCodeSignin = false
application.EnableSamlCompress = false
application.EnableSamlC14n10 = false
application.EnableSamlPostBinding = false
application.DisableSamlAttributes = false
application.EnableWebAuthn = false
application.EnableLinkWithEmail = false
application.SamlReplyUrl = "***"
providerItems := []*ProviderItem{}
for _, providerItem := range application.Providers {
if providerItem.Provider != nil && (providerItem.Provider.Category == "OAuth" || providerItem.Provider.Category == "Web3" || providerItem.Provider.Category == "Captcha" || providerItem.Provider.Category == "SAML" || providerItem.Provider.Category == "Face ID") {
providerItems = append(providerItems, providerItem)
}
}
application.Providers = providerItems
application.GrantTypes = []string{}
application.RedirectUris = []string{}
application.TokenFormat = "***"
application.TokenFields = []string{}
application.ExpireInHours = -1
application.RefreshExpireInHours = -1
application.FailedSigninLimit = -1
application.FailedSigninFrozenTime = -1
if application.OrganizationObj != nil {
application.OrganizationObj.MasterPassword = "***"
application.OrganizationObj.DefaultPassword = "***"
application.OrganizationObj.MasterVerificationCode = "***"
application.OrganizationObj.PasswordType = "***"
application.OrganizationObj.PasswordSalt = "***"
application.OrganizationObj.InitScore = -1
application.OrganizationObj.EnableSoftDeletion = false
if !isOrgUser {
application.OrganizationObj.MfaItems = nil
if !application.OrganizationObj.IsProfilePublic {
application.OrganizationObj.AccountItems = nil
}
}
}
return application
}
func GetMaskedApplications(applications []*Application, userId string) []*Application {
if isUserIdGlobalAdmin(userId) {
return applications
}
for _, application := range applications {
application = GetMaskedApplication(application, userId)
}
return applications
}
func GetAllowedApplications(applications []*Application, userId string, lang string) ([]*Application, error) {
if userId == "" {
return nil, errors.New(i18n.Translate(lang, "auth:Unauthorized operation"))
}
if isUserIdGlobalAdmin(userId) {
return applications, nil
}
user, err := GetUser(userId)
if err != nil {
return nil, err
}
if user == nil {
return nil, errors.New(i18n.Translate(lang, "auth:Unauthorized operation"))
}
if user.IsAdmin {
return applications, nil
}
res := []*Application{}
for _, application := range applications {
var allowed bool
allowed, err = CheckLoginPermission(userId, application)
if err != nil {
return nil, err
}
if allowed {
res = append(res, application)
}
}
return res, nil
}
func checkMultipleCaptchaProviders(application *Application, lang string) error {
var captchaProviders []string
for _, providerItem := range application.Providers {
if providerItem.Provider != nil && providerItem.Provider.Category == "Captcha" {
captchaProviders = append(captchaProviders, providerItem.Name)
}
}
if len(captchaProviders) > 1 {
return fmt.Errorf(i18n.Translate(lang, "general:Multiple captcha providers are not allowed in the same application: %s"), strings.Join(captchaProviders, ", "))
}
return nil
}
func UpdateApplication(id string, application *Application, isGlobalAdmin bool, lang string) (bool, error) {
owner, name, err := util.GetOwnerAndNameFromIdWithError(id)
if err != nil {
@@ -844,205 +508,3 @@ func DeleteApplication(application *Application) (bool, error) {
return deleteApplication(application)
}
func (application *Application) GetId() string {
return fmt.Sprintf("%s/%s", application.Owner, application.Name)
}
func (application *Application) IsRedirectUriValid(redirectUri string) bool {
isValid, err := util.IsValidOrigin(redirectUri)
if err != nil {
panic(err)
}
if isValid {
return true
}
for _, targetUri := range application.RedirectUris {
if targetUri == "" {
continue
}
targetUriRegex := regexp.MustCompile(targetUri)
if targetUriRegex.MatchString(redirectUri) || strings.Contains(redirectUri, targetUri) {
return true
}
}
return false
}
func (application *Application) IsPasswordEnabled() bool {
if len(application.SigninMethods) == 0 {
return application.EnablePassword
} else {
for _, signinMethod := range application.SigninMethods {
if signinMethod.Name == "Password" {
return true
}
}
return false
}
}
func (application *Application) IsPasswordWithLdapEnabled() bool {
if len(application.SigninMethods) == 0 {
return application.EnablePassword
} else {
for _, signinMethod := range application.SigninMethods {
if signinMethod.Name == "Password" && signinMethod.Rule == "All" {
return true
}
}
return false
}
}
func (application *Application) IsCodeSigninViaEmailEnabled() bool {
if len(application.SigninMethods) == 0 {
return application.EnableCodeSignin
} else {
for _, signinMethod := range application.SigninMethods {
if signinMethod.Name == "Verification code" && signinMethod.Rule != "Phone only" {
return true
}
}
return false
}
}
func (application *Application) IsCodeSigninViaSmsEnabled() bool {
if len(application.SigninMethods) == 0 {
return application.EnableCodeSignin
} else {
for _, signinMethod := range application.SigninMethods {
if signinMethod.Name == "Verification code" && signinMethod.Rule != "Email only" {
return true
}
}
return false
}
}
func (application *Application) IsLdapEnabled() bool {
if len(application.SigninMethods) > 0 {
for _, signinMethod := range application.SigninMethods {
if signinMethod.Name == "LDAP" {
return true
}
}
}
return false
}
func (application *Application) IsFaceIdEnabled() bool {
if len(application.SigninMethods) > 0 {
for _, signinMethod := range application.SigninMethods {
if signinMethod.Name == "Face ID" {
return true
}
}
}
return false
}
func IsOriginAllowed(origin string) (bool, error) {
applications, err := GetApplications("")
if err != nil {
return false, err
}
for _, application := range applications {
if application.IsRedirectUriValid(origin) {
return true, nil
}
}
return false, nil
}
func getApplicationMap(organization string) (map[string]*Application, error) {
applicationMap := make(map[string]*Application)
applications, err := GetOrganizationApplications("admin", organization)
if err != nil {
return applicationMap, err
}
for _, application := range applications {
applicationMap[application.Name] = application
}
return applicationMap, nil
}
func ExtendManagedAccountsWithUser(user *User) (*User, error) {
if user.ManagedAccounts == nil || len(user.ManagedAccounts) == 0 {
return user, nil
}
applicationMap, err := getApplicationMap(user.Owner)
if err != nil {
return user, err
}
var managedAccounts []ManagedAccount
for _, managedAccount := range user.ManagedAccounts {
application := applicationMap[managedAccount.Application]
if application != nil {
managedAccount.SigninUrl = application.SigninUrl
managedAccounts = append(managedAccounts, managedAccount)
}
}
user.ManagedAccounts = managedAccounts
return user, nil
}
func applicationChangeTrigger(oldName string, newName string) error {
session := ormer.Engine.NewSession()
defer session.Close()
err := session.Begin()
if err != nil {
return err
}
organization := new(Organization)
organization.DefaultApplication = newName
_, err = session.Where("default_application=?", oldName).Update(organization)
if err != nil {
return err
}
user := new(User)
user.SignupApplication = newName
_, err = session.Where("signup_application=?", oldName).Update(user)
if err != nil {
return err
}
resource := new(Resource)
resource.Application = newName
_, err = session.Where("application=?", oldName).Update(resource)
if err != nil {
return err
}
var permissions []*Permission
err = ormer.Engine.Find(&permissions)
if err != nil {
return err
}
for i := 0; i < len(permissions); i++ {
permissionResoureces := permissions[i].Resources
for j := 0; j < len(permissionResoureces); j++ {
if permissionResoureces[j] == oldName {
permissionResoureces[j] = newName
}
}
permissions[i].Resources = permissionResoureces
_, err = session.Where("owner=?", permissions[i].Owner).Where("name=?", permissions[i].Name).Update(permissions[i])
if err != nil {
return err
}
}
return session.Commit()
}

615
object/application_util.go Normal file
View File

@@ -0,0 +1,615 @@
// Copyright 2021 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package object
import (
"errors"
"fmt"
"net/url"
"regexp"
"strings"
"github.com/casdoor/casdoor/i18n"
"github.com/casdoor/casdoor/util"
)
func getProviderMap(owner string) (m map[string]*Provider, err error) {
providers, err := GetProviders(owner)
if err != nil {
return nil, err
}
m = map[string]*Provider{}
for _, provider := range providers {
m[provider.Name] = GetMaskedProvider(provider, true)
}
return m, err
}
func extendApplicationWithProviders(application *Application) (err error) {
m, err := getProviderMap(application.Organization)
if err != nil {
return err
}
for _, providerItem := range application.Providers {
if provider, ok := m[providerItem.Name]; ok {
providerItem.Provider = provider
}
}
return
}
func extendApplicationWithOrg(application *Application) (err error) {
organization, err := getOrganization(application.Owner, application.Organization)
application.OrganizationObj = organization
return
}
func extendApplicationWithSigninItems(application *Application) (err error) {
if len(application.SigninItems) == 0 {
signinItem := &SigninItem{
Name: "Back button",
Visible: true,
CustomCss: ".back-button {\n top: 65px;\n left: 15px;\n position: absolute;\n}\n.back-inner-button{}",
Placeholder: "",
Rule: "None",
}
application.SigninItems = append(application.SigninItems, signinItem)
signinItem = &SigninItem{
Name: "Languages",
Visible: true,
CustomCss: ".login-languages {\n top: 55px;\n right: 5px;\n position: absolute;\n}",
Placeholder: "",
Rule: "None",
}
application.SigninItems = append(application.SigninItems, signinItem)
signinItem = &SigninItem{
Name: "Logo",
Visible: true,
CustomCss: ".login-logo-box {}",
Placeholder: "",
Rule: "None",
}
application.SigninItems = append(application.SigninItems, signinItem)
signinItem = &SigninItem{
Name: "Signin methods",
Visible: true,
CustomCss: ".signin-methods {}",
Placeholder: "",
Rule: "None",
}
application.SigninItems = append(application.SigninItems, signinItem)
signinItem = &SigninItem{
Name: "Username",
Visible: true,
CustomCss: ".login-username {}\n.login-username-input{}",
Placeholder: "",
Rule: "None",
}
application.SigninItems = append(application.SigninItems, signinItem)
signinItem = &SigninItem{
Name: "Password",
Visible: true,
CustomCss: ".login-password {}\n.login-password-input{}",
Placeholder: "",
Rule: "None",
}
application.SigninItems = append(application.SigninItems, signinItem)
signinItem = &SigninItem{
Name: "Verification code",
Visible: true,
CustomCss: ".verification-code {}\n.verification-code-input{}",
Placeholder: "",
Rule: "None",
}
application.SigninItems = append(application.SigninItems, signinItem)
signinItem = &SigninItem{
Name: "Agreement",
Visible: true,
CustomCss: ".login-agreement {}",
Placeholder: "",
Rule: "None",
}
application.SigninItems = append(application.SigninItems, signinItem)
signinItem = &SigninItem{
Name: "Forgot password?",
Visible: true,
CustomCss: ".login-forget-password {\n display: inline-flex;\n justify-content: space-between;\n width: 320px;\n margin-bottom: 25px;\n}",
Placeholder: "",
Rule: "None",
}
application.SigninItems = append(application.SigninItems, signinItem)
signinItem = &SigninItem{
Name: "Login button",
Visible: true,
CustomCss: ".login-button-box {\n margin-bottom: 5px;\n}\n.login-button {\n width: 100%;\n}",
Placeholder: "",
Rule: "None",
}
application.SigninItems = append(application.SigninItems, signinItem)
signinItem = &SigninItem{
Name: "Signup link",
Visible: true,
CustomCss: ".login-signup-link {\n margin-bottom: 24px;\n display: flex;\n justify-content: end;\n}",
Placeholder: "",
Rule: "None",
}
application.SigninItems = append(application.SigninItems, signinItem)
signinItem = &SigninItem{
Name: "Providers",
Visible: true,
CustomCss: ".provider-img {\n width: 30px;\n margin: 5px;\n}\n.provider-big-img {\n margin-bottom: 10px;\n}",
Placeholder: "",
Rule: "None",
}
application.SigninItems = append(application.SigninItems, signinItem)
}
for idx, item := range application.SigninItems {
if item.Label != "" && item.CustomCss == "" {
application.SigninItems[idx].CustomCss = item.Label
application.SigninItems[idx].Label = ""
}
}
return
}
func extendApplicationWithSigninMethods(application *Application) (err error) {
if len(application.SigninMethods) == 0 {
if application.EnablePassword {
signinMethod := &SigninMethod{Name: "Password", DisplayName: "Password", Rule: "All"}
application.SigninMethods = append(application.SigninMethods, signinMethod)
}
if application.EnableCodeSignin {
signinMethod := &SigninMethod{Name: "Verification code", DisplayName: "Verification code", Rule: "All"}
application.SigninMethods = append(application.SigninMethods, signinMethod)
}
if application.EnableWebAuthn {
signinMethod := &SigninMethod{Name: "WebAuthn", DisplayName: "WebAuthn", Rule: "None"}
application.SigninMethods = append(application.SigninMethods, signinMethod)
}
signinMethod := &SigninMethod{Name: "Face ID", DisplayName: "Face ID", Rule: "None"}
application.SigninMethods = append(application.SigninMethods, signinMethod)
}
if len(application.SigninMethods) == 0 {
signinMethod := &SigninMethod{Name: "Password", DisplayName: "Password", Rule: "All"}
application.SigninMethods = append(application.SigninMethods, signinMethod)
}
return
}
func extendApplicationWithSignupItems(application *Application) (err error) {
if len(application.SignupItems) == 0 {
application.SignupItems = []*SignupItem{
{Name: "ID", Visible: false, Required: true, Prompted: false, Rule: "Random"},
{Name: "Username", Visible: true, Required: true, Prompted: false, Rule: "None"},
{Name: "Display name", Visible: true, Required: true, Prompted: false, Rule: "None"},
{Name: "Password", Visible: true, Required: true, Prompted: false, Rule: "None"},
{Name: "Confirm password", Visible: true, Required: true, Prompted: false, Rule: "None"},
{Name: "Email", Visible: true, Required: true, Prompted: false, Rule: "None"},
{Name: "Phone", Visible: true, Required: true, Prompted: false, Rule: "None"},
{Name: "Agreement", Visible: true, Required: true, Prompted: false, Rule: "None"},
}
}
return
}
func GetMaskedApplication(application *Application, userId string) *Application {
if application == nil {
return nil
}
if application.TokenFields == nil {
application.TokenFields = []string{}
}
if application.FailedSigninLimit == 0 {
application.FailedSigninLimit = DefaultFailedSigninLimit
}
if application.FailedSigninFrozenTime == 0 {
application.FailedSigninFrozenTime = DefaultFailedSigninFrozenTime
}
isOrgUser := false
if userId != "" {
if isUserIdGlobalAdmin(userId) {
return application
}
user, err := GetUser(userId)
if err != nil {
panic(err)
}
if user != nil {
if user.IsApplicationAdmin(application) {
return application
}
if user.Owner == application.Organization {
isOrgUser = true
}
}
}
application.ClientSecret = "***"
application.Cert = "***"
application.EnablePassword = false
application.EnableSigninSession = false
application.EnableCodeSignin = false
application.EnableSamlCompress = false
application.EnableSamlC14n10 = false
application.EnableSamlPostBinding = false
application.DisableSamlAttributes = false
application.EnableWebAuthn = false
application.EnableLinkWithEmail = false
application.SamlReplyUrl = "***"
providerItems := []*ProviderItem{}
for _, providerItem := range application.Providers {
if providerItem.Provider != nil && (providerItem.Provider.Category == "OAuth" || providerItem.Provider.Category == "Web3" || providerItem.Provider.Category == "Captcha" || providerItem.Provider.Category == "SAML" || providerItem.Provider.Category == "Face ID") {
providerItems = append(providerItems, providerItem)
}
}
application.Providers = providerItems
application.GrantTypes = []string{}
application.RedirectUris = []string{}
application.TokenFormat = "***"
application.TokenFields = []string{}
application.ExpireInHours = -1
application.RefreshExpireInHours = -1
application.FailedSigninLimit = -1
application.FailedSigninFrozenTime = -1
if application.OrganizationObj != nil {
application.OrganizationObj.MasterPassword = "***"
application.OrganizationObj.DefaultPassword = "***"
application.OrganizationObj.MasterVerificationCode = "***"
application.OrganizationObj.PasswordType = "***"
application.OrganizationObj.PasswordSalt = "***"
application.OrganizationObj.InitScore = -1
application.OrganizationObj.EnableSoftDeletion = false
if !isOrgUser {
application.OrganizationObj.MfaItems = nil
if !application.OrganizationObj.IsProfilePublic {
application.OrganizationObj.AccountItems = nil
}
}
}
return application
}
func GetMaskedApplications(applications []*Application, userId string) []*Application {
if isUserIdGlobalAdmin(userId) {
return applications
}
for _, application := range applications {
application = GetMaskedApplication(application, userId)
}
return applications
}
func GetAllowedApplications(applications []*Application, userId string, lang string) ([]*Application, error) {
if userId == "" {
return nil, errors.New(i18n.Translate(lang, "auth:Unauthorized operation"))
}
if isUserIdGlobalAdmin(userId) {
return applications, nil
}
user, err := GetUser(userId)
if err != nil {
return nil, err
}
if user == nil {
return nil, errors.New(i18n.Translate(lang, "auth:Unauthorized operation"))
}
if user.IsAdmin {
return applications, nil
}
res := []*Application{}
for _, application := range applications {
var allowed bool
allowed, err = CheckLoginPermission(userId, application)
if err != nil {
return nil, err
}
if allowed {
res = append(res, application)
}
}
return res, nil
}
func checkMultipleCaptchaProviders(application *Application, lang string) error {
var captchaProviders []string
for _, providerItem := range application.Providers {
if providerItem.Provider != nil && providerItem.Provider.Category == "Captcha" {
captchaProviders = append(captchaProviders, providerItem.Name)
}
}
if len(captchaProviders) > 1 {
return fmt.Errorf(i18n.Translate(lang, "general:Multiple captcha providers are not allowed in the same application: %s"), strings.Join(captchaProviders, ", "))
}
return nil
}
func (application *Application) GetId() string {
return fmt.Sprintf("%s/%s", application.Owner, application.Name)
}
func (application *Application) IsRedirectUriValid(redirectUri string) bool {
isValid, err := util.IsValidOrigin(redirectUri)
if err != nil {
panic(err)
}
if isValid {
return true
}
for _, targetUri := range application.RedirectUris {
if redirectUriMatchesPattern(redirectUri, targetUri) {
return true
}
}
return false
}
func redirectUriMatchesPattern(redirectUri, targetUri string) bool {
if targetUri == "" {
return false
}
if redirectUri == targetUri {
return true
}
redirectUriObj, err := url.Parse(redirectUri)
if err != nil || redirectUriObj.Host == "" {
return false
}
targetUriObj, err := url.Parse(targetUri)
if err == nil && targetUriObj.Host != "" {
return redirectUriMatchesTarget(redirectUriObj, targetUriObj)
}
withScheme, parseErr := url.Parse("https://" + targetUri)
if parseErr == nil && withScheme.Host != "" {
redirectHost := redirectUriObj.Hostname()
targetHost := withScheme.Hostname()
var hostMatches bool
if strings.HasPrefix(targetHost, ".") {
hostMatches = strings.HasSuffix(redirectHost, targetHost)
} else {
hostMatches = redirectHost == targetHost || strings.HasSuffix(redirectHost, "."+targetHost)
}
schemeOk := redirectUriObj.Scheme == "http" || redirectUriObj.Scheme == "https"
pathMatches := withScheme.Path == "" || withScheme.Path == "/" || redirectUriObj.Path == withScheme.Path
return schemeOk && hostMatches && pathMatches
}
anchoredPattern := "^(?:" + targetUri + ")$"
targetUriRegex, err := regexp.Compile(anchoredPattern)
return err == nil && targetUriRegex.MatchString(redirectUri)
}
func redirectUriMatchesTarget(redirectUri, targetUri *url.URL) bool {
if redirectUri.Scheme != targetUri.Scheme {
return false
}
if redirectUri.Port() != targetUri.Port() {
return false
}
redirectHost := redirectUri.Hostname()
targetHost := targetUri.Hostname()
if redirectHost != targetHost && !strings.HasSuffix(redirectHost, "."+targetHost) {
return false
}
if redirectUri.Path != targetUri.Path {
return false
}
return true
}
func (application *Application) IsPasswordEnabled() bool {
if len(application.SigninMethods) == 0 {
return application.EnablePassword
} else {
for _, signinMethod := range application.SigninMethods {
if signinMethod.Name == "Password" {
return true
}
}
return false
}
}
func (application *Application) IsPasswordWithLdapEnabled() bool {
if len(application.SigninMethods) == 0 {
return application.EnablePassword
} else {
for _, signinMethod := range application.SigninMethods {
if signinMethod.Name == "Password" && signinMethod.Rule == "All" {
return true
}
}
return false
}
}
func (application *Application) IsCodeSigninViaEmailEnabled() bool {
if len(application.SigninMethods) == 0 {
return application.EnableCodeSignin
} else {
for _, signinMethod := range application.SigninMethods {
if signinMethod.Name == "Verification code" && signinMethod.Rule != "Phone only" {
return true
}
}
return false
}
}
func (application *Application) IsCodeSigninViaSmsEnabled() bool {
if len(application.SigninMethods) == 0 {
return application.EnableCodeSignin
} else {
for _, signinMethod := range application.SigninMethods {
if signinMethod.Name == "Verification code" && signinMethod.Rule != "Email only" {
return true
}
}
return false
}
}
func (application *Application) IsLdapEnabled() bool {
if len(application.SigninMethods) > 0 {
for _, signinMethod := range application.SigninMethods {
if signinMethod.Name == "LDAP" {
return true
}
}
}
return false
}
func (application *Application) IsFaceIdEnabled() bool {
if len(application.SigninMethods) > 0 {
for _, signinMethod := range application.SigninMethods {
if signinMethod.Name == "Face ID" {
return true
}
}
}
return false
}
func IsOriginAllowed(origin string) (bool, error) {
applications, err := GetApplications("")
if err != nil {
return false, err
}
for _, application := range applications {
if application.IsRedirectUriValid(origin) {
return true, nil
}
}
return false, nil
}
func getApplicationMap(organization string) (map[string]*Application, error) {
applicationMap := make(map[string]*Application)
applications, err := GetOrganizationApplications("admin", organization)
if err != nil {
return applicationMap, err
}
for _, application := range applications {
applicationMap[application.Name] = application
}
return applicationMap, nil
}
func ExtendManagedAccountsWithUser(user *User) (*User, error) {
if user.ManagedAccounts == nil || len(user.ManagedAccounts) == 0 {
return user, nil
}
applicationMap, err := getApplicationMap(user.Owner)
if err != nil {
return user, err
}
var managedAccounts []ManagedAccount
for _, managedAccount := range user.ManagedAccounts {
application := applicationMap[managedAccount.Application]
if application != nil {
managedAccount.SigninUrl = application.SigninUrl
managedAccounts = append(managedAccounts, managedAccount)
}
}
user.ManagedAccounts = managedAccounts
return user, nil
}
func applicationChangeTrigger(oldName string, newName string) error {
session := ormer.Engine.NewSession()
defer session.Close()
err := session.Begin()
if err != nil {
return err
}
organization := new(Organization)
organization.DefaultApplication = newName
_, err = session.Where("default_application=?", oldName).Update(organization)
if err != nil {
return err
}
user := new(User)
user.SignupApplication = newName
_, err = session.Where("signup_application=?", oldName).Update(user)
if err != nil {
return err
}
resource := new(Resource)
resource.Application = newName
_, err = session.Where("application=?", oldName).Update(resource)
if err != nil {
return err
}
var permissions []*Permission
err = ormer.Engine.Find(&permissions)
if err != nil {
return err
}
for i := 0; i < len(permissions); i++ {
permissionResoureces := permissions[i].Resources
for j := 0; j < len(permissionResoureces); j++ {
if permissionResoureces[j] == oldName {
permissionResoureces[j] = newName
}
}
permissions[i].Resources = permissionResoureces
_, err = session.Where("owner=?", permissions[i].Owner).Where("name=?", permissions[i].Name).Update(permissions[i])
if err != nil {
return err
}
}
return session.Commit()
}

View File

@@ -0,0 +1,79 @@
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package object
import "testing"
func TestRedirectUriMatchesPattern(t *testing.T) {
tests := []struct {
redirectUri string
targetUri string
want bool
}{
// Exact match
{"https://login.example.com/callback", "https://login.example.com/callback", true},
// Full URL pattern: exact host
{"https://login.example.com/callback", "https://login.example.com/callback", true},
{"https://login.example.com/other", "https://login.example.com/callback", false},
// Full URL pattern: subdomain of configured host
{"https://def.abc.com/callback", "abc.com", true},
{"https://def.abc.com/callback", ".abc.com", true},
{"https://def.abc.com/callback", ".abc.com/", true},
{"https://deep.app.example.com/callback", "https://example.com/callback", true},
// Full URL pattern: unrelated host must not match
{"https://evil.com/callback", "https://example.com/callback", false},
// Suffix collision: evilexample.com must not match example.com
{"https://evilexample.com/callback", "https://example.com/callback", false},
// Full URL pattern: scheme mismatch
{"http://app.example.com/callback", "https://example.com/callback", false},
// Full URL pattern: path mismatch
{"https://app.example.com/other", "https://example.com/callback", false},
// Scheme-less pattern: exact host
{"https://login.example.com/callback", "login.example.com/callback", true},
{"http://login.example.com/callback", "login.example.com/callback", true},
// Scheme-less pattern: subdomain of configured host
{"https://app.login.example.com/callback", "login.example.com/callback", true},
// Scheme-less pattern: unrelated host must not match
{"https://evil.com/callback", "login.example.com/callback", false},
// Scheme-less pattern: query-string injection must not match
{"https://evil.com/?r=https://login.example.com/callback", "login.example.com/callback", false},
{"https://evil.com/page?redirect=https://login.example.com/callback", "login.example.com/callback", false},
// Scheme-less pattern: path mismatch
{"https://login.example.com/other", "login.example.com/callback", false},
// Scheme-less pattern: non-http scheme must not match
{"ftp://login.example.com/callback", "login.example.com/callback", false},
// Empty target
{"https://login.example.com/callback", "", false},
}
for _, tt := range tests {
got := redirectUriMatchesPattern(tt.redirectUri, tt.targetUri)
if got != tt.want {
t.Errorf("redirectUriMatchesPattern(%q, %q) = %v, want %v", tt.redirectUri, tt.targetUri, got, tt.want)
}
}
}

View File

@@ -587,7 +587,12 @@ func CheckLoginPermission(userId string, application *Application) (bool, error)
return true, nil
}
permissions, err := GetPermissions(application.Organization)
permissionOrganization := application.Organization
if application.IsShared {
permissionOrganization = owner
}
permissions, err := GetPermissions(permissionOrganization)
if err != nil {
return false, err
}

122
object/entry.go Normal file
View File

@@ -0,0 +1,122 @@
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package object
import (
"fmt"
"github.com/casdoor/casdoor/util"
"github.com/xorm-io/core"
)
type Entry struct {
Owner string `xorm:"varchar(100) notnull pk" json:"owner"`
Name string `xorm:"varchar(100) notnull pk" json:"name"`
CreatedTime string `xorm:"varchar(100)" json:"createdTime"`
UpdatedTime string `xorm:"varchar(100)" json:"updatedTime"`
DisplayName string `xorm:"varchar(100)" json:"displayName"`
Provider string `xorm:"varchar(100)" json:"provider"`
Application string `xorm:"varchar(100)" json:"application"`
Type string `xorm:"varchar(100)" json:"type"`
ClientIp string `xorm:"varchar(100)" json:"clientIp"`
UserAgent string `xorm:"varchar(500)" json:"userAgent"`
Message string `xorm:"mediumtext" json:"message"`
}
func GetEntries(owner string) ([]*Entry, error) {
entries := []*Entry{}
err := ormer.Engine.Desc("created_time").Find(&entries, &Entry{Owner: owner})
if err != nil {
return nil, err
}
return entries, nil
}
func getEntry(owner string, name string) (*Entry, error) {
entry := Entry{Owner: owner, Name: name}
existed, err := ormer.Engine.Get(&entry)
if err != nil {
return nil, err
}
if existed {
return &entry, nil
}
return nil, nil
}
func GetEntry(id string) (*Entry, error) {
owner, name := util.GetOwnerAndNameFromIdNoCheck(id)
return getEntry(owner, name)
}
func UpdateEntry(id string, entry *Entry) (bool, error) {
owner, name := util.GetOwnerAndNameFromIdNoCheck(id)
if e, err := getEntry(owner, name); err != nil {
return false, err
} else if e == nil {
return false, nil
}
entry.UpdatedTime = util.GetCurrentTime()
_, err := ormer.Engine.ID(core.PK{owner, name}).AllCols().Update(entry)
if err != nil {
return false, err
}
return true, nil
}
func AddEntry(entry *Entry) (bool, error) {
affected, err := ormer.Engine.Insert(entry)
if err != nil {
return false, err
}
return affected != 0, nil
}
func DeleteEntry(entry *Entry) (bool, error) {
affected, err := ormer.Engine.ID(core.PK{entry.Owner, entry.Name}).Delete(&Entry{})
if err != nil {
return false, err
}
return affected != 0, nil
}
func (entry *Entry) GetId() string {
return fmt.Sprintf("%s/%s", entry.Owner, entry.Name)
}
func GetEntryCount(owner, field, value string) (int64, error) {
session := GetSession(owner, -1, -1, field, value, "", "")
return session.Count(&Entry{})
}
func GetPaginationEntries(owner string, offset, limit int, field, value, sortField, sortOrder string) ([]*Entry, error) {
entries := []*Entry{}
session := GetSession(owner, offset, limit, field, value, sortField, sortOrder)
err := session.Find(&entries)
if err != nil {
return entries, err
}
return entries, nil
}

View File

@@ -318,10 +318,12 @@ func GetGroupUserCount(groupId string, field, value string) (int64, error) {
return int64(len(names)), nil
} else {
tableNamePrefix := conf.GetConfigString("tableNamePrefix")
return ormer.Engine.Table(tableNamePrefix+"user").
Where("owner = ?", owner).In("name", names).
And(fmt.Sprintf("user.%s like ?", util.CamelToSnakeCase(field)), "%"+value+"%").
Count()
session := ormer.Engine.Table(tableNamePrefix+"user").
Where("owner = ?", owner).In("name", names)
if util.FilterField(field) {
session = session.And(fmt.Sprintf("user.%s like ?", util.CamelToSnakeCase(field)), "%"+value+"%")
}
return session.Count()
}
}
@@ -345,11 +347,11 @@ func GetPaginationGroupUsers(groupId string, offset, limit int, field, value, so
session.Limit(limit, offset)
}
if field != "" && value != "" {
if field != "" && value != "" && util.FilterField(field) {
session = session.And(fmt.Sprintf("%s.%s like ?", prefixedUserTable, util.CamelToSnakeCase(field)), "%"+value+"%")
}
if sortField == "" || sortOrder == "" {
if sortField == "" || sortOrder == "" || !util.FilterField(sortField) {
sortField = "created_time"
}

View File

@@ -90,7 +90,6 @@ func getBuiltInAccountItems() []*AccountItem {
{Name: "Signup application", Visible: true, ViewRule: "Public", ModifyRule: "Admin"},
{Name: "Register type", Visible: true, ViewRule: "Public", ModifyRule: "Admin"},
{Name: "Register source", Visible: true, ViewRule: "Public", ModifyRule: "Admin"},
{Name: "API key", Visible: true, ViewRule: "Self", ModifyRule: "Self"},
{Name: "Roles", Visible: true, ViewRule: "Public", ModifyRule: "Immutable"},
{Name: "Permissions", Visible: true, ViewRule: "Public", ModifyRule: "Immutable"},
{Name: "Groups", Visible: true, ViewRule: "Public", ModifyRule: "Admin"},
@@ -135,7 +134,7 @@ func initBuiltInOrganization() bool {
DefaultAvatar: fmt.Sprintf("%s/img/casbin.svg", conf.GetConfigString("staticBaseUrl")),
UserTypes: []string{},
Tags: []string{},
Languages: []string{"en", "es", "fr", "de", "ja", "zh", "vi", "pt", "tr", "pl", "uk"},
Languages: []string{"en", "es", "fr", "de", "ja", "zh", "vi", "pt", "tr", "pl", "ru", "uk"},
InitScore: 2000,
AccountItems: getBuiltInAccountItems(),
EnableSoftDeletion: false,

View File

@@ -17,37 +17,36 @@ package object
import (
"github.com/casdoor/casdoor/conf"
"github.com/casdoor/casdoor/util"
"github.com/casvisor/casvisor-go-sdk/casvisorsdk"
)
type InitData struct {
Organizations []*Organization `json:"organizations"`
Applications []*Application `json:"applications"`
Users []*User `json:"users"`
Certs []*Cert `json:"certs"`
Providers []*Provider `json:"providers"`
Ldaps []*Ldap `json:"ldaps"`
Models []*Model `json:"models"`
Permissions []*Permission `json:"permissions"`
Payments []*Payment `json:"payments"`
Products []*Product `json:"products"`
Resources []*Resource `json:"resources"`
Roles []*Role `json:"roles"`
Syncers []*Syncer `json:"syncers"`
Tokens []*Token `json:"tokens"`
Webhooks []*Webhook `json:"webhooks"`
Groups []*Group `json:"groups"`
Adapters []*Adapter `json:"adapters"`
Enforcers []*Enforcer `json:"enforcers"`
Plans []*Plan `json:"plans"`
Pricings []*Pricing `json:"pricings"`
Invitations []*Invitation `json:"invitations"`
Records []*casvisorsdk.Record `json:"records"`
Sessions []*Session `json:"sessions"`
Subscriptions []*Subscription `json:"subscriptions"`
Transactions []*Transaction `json:"transactions"`
Sites []*Site `json:"sites"`
Rules []*Rule `json:"rules"`
Organizations []*Organization `json:"organizations"`
Applications []*Application `json:"applications"`
Users []*User `json:"users"`
Certs []*Cert `json:"certs"`
Providers []*Provider `json:"providers"`
Ldaps []*Ldap `json:"ldaps"`
Models []*Model `json:"models"`
Permissions []*Permission `json:"permissions"`
Payments []*Payment `json:"payments"`
Products []*Product `json:"products"`
Resources []*Resource `json:"resources"`
Roles []*Role `json:"roles"`
Syncers []*Syncer `json:"syncers"`
Tokens []*Token `json:"tokens"`
Webhooks []*Webhook `json:"webhooks"`
Groups []*Group `json:"groups"`
Adapters []*Adapter `json:"adapters"`
Enforcers []*Enforcer `json:"enforcers"`
Plans []*Plan `json:"plans"`
Pricings []*Pricing `json:"pricings"`
Invitations []*Invitation `json:"invitations"`
Records []*Record `json:"records"`
Sessions []*Session `json:"sessions"`
Subscriptions []*Subscription `json:"subscriptions"`
Transactions []*Transaction `json:"transactions"`
Sites []*Site `json:"sites"`
Rules []*Rule `json:"rules"`
EnforcerPolicies map[string][][]string `json:"enforcerPolicies"`
}
@@ -182,7 +181,7 @@ func readInitDataFromFile(filePath string) (*InitData, error) {
Plans: []*Plan{},
Pricings: []*Pricing{},
Invitations: []*Invitation{},
Records: []*casvisorsdk.Record{},
Records: []*Record{},
Sessions: []*Session{},
Subscriptions: []*Subscription{},
Transactions: []*Transaction{},
@@ -826,7 +825,7 @@ func initDefinedInvitation(invitation *Invitation) {
}
}
func initDefinedRecord(record *casvisorsdk.Record) {
func initDefinedRecord(record *Record) {
record.Id = 0
record.CreatedTime = util.GetCurrentTime()
_ = AddRecord(record)

208
object/key.go Normal file
View File

@@ -0,0 +1,208 @@
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package object
import (
"fmt"
"github.com/casdoor/casdoor/util"
"github.com/xorm-io/core"
)
type Key struct {
Owner string `xorm:"varchar(100) notnull pk" json:"owner"`
Name string `xorm:"varchar(100) notnull pk" json:"name"`
CreatedTime string `xorm:"varchar(100)" json:"createdTime"`
UpdatedTime string `xorm:"varchar(100)" json:"updatedTime"`
DisplayName string `xorm:"varchar(100)" json:"displayName"`
// Type indicates the scope this key belongs to: "Organization", "Application", "User", or "General"
Type string `xorm:"varchar(100)" json:"type"`
Organization string `xorm:"varchar(100)" json:"organization"`
Application string `xorm:"varchar(100)" json:"application"`
User string `xorm:"varchar(100)" json:"user"`
AccessKey string `xorm:"varchar(100) index" json:"accessKey"`
AccessSecret string `xorm:"varchar(100)" json:"accessSecret"`
ExpireTime string `xorm:"varchar(100)" json:"expireTime"`
State string `xorm:"varchar(100)" json:"state"`
}
func GetKeyCount(owner, field, value string) (int64, error) {
session := GetSession(owner, -1, -1, field, value, "", "")
return session.Count(&Key{})
}
func GetGlobalKeyCount(field, value string) (int64, error) {
session := GetSession("", -1, -1, field, value, "", "")
return session.Count(&Key{})
}
func GetKeys(owner string) ([]*Key, error) {
keys := []*Key{}
err := ormer.Engine.Desc("created_time").Find(&keys, &Key{Owner: owner})
if err != nil {
return keys, err
}
return keys, nil
}
func GetPaginationKeys(owner string, offset, limit int, field, value, sortField, sortOrder string) ([]*Key, error) {
keys := []*Key{}
session := GetSession(owner, offset, limit, field, value, sortField, sortOrder)
err := session.Find(&keys)
if err != nil {
return keys, err
}
return keys, nil
}
func GetGlobalKeys() ([]*Key, error) {
keys := []*Key{}
err := ormer.Engine.Desc("created_time").Find(&keys)
if err != nil {
return keys, err
}
return keys, nil
}
func GetPaginationGlobalKeys(offset, limit int, field, value, sortField, sortOrder string) ([]*Key, error) {
keys := []*Key{}
session := GetSession("", offset, limit, field, value, sortField, sortOrder)
err := session.Find(&keys)
if err != nil {
return keys, err
}
return keys, nil
}
func getKey(owner, name string) (*Key, error) {
if owner == "" || name == "" {
return nil, nil
}
key := Key{Owner: owner, Name: name}
existed, err := ormer.Engine.Get(&key)
if err != nil {
return &key, err
}
if existed {
return &key, nil
}
return nil, nil
}
func GetKey(id string) (*Key, error) {
owner, name, err := util.GetOwnerAndNameFromIdWithError(id)
if err != nil {
return nil, err
}
return getKey(owner, name)
}
func GetMaskedKey(key *Key, isMaskEnabled bool) *Key {
if !isMaskEnabled {
return key
}
if key == nil {
return nil
}
if key.AccessSecret != "" {
key.AccessSecret = "***"
}
return key
}
func GetMaskedKeys(keys []*Key, isMaskEnabled bool, err error) ([]*Key, error) {
if err != nil {
return nil, err
}
for _, key := range keys {
GetMaskedKey(key, isMaskEnabled)
}
return keys, nil
}
func UpdateKey(id string, key *Key) (bool, error) {
owner, name, err := util.GetOwnerAndNameFromIdWithError(id)
if err != nil {
return false, err
}
if k, err := getKey(owner, name); err != nil {
return false, err
} else if k == nil {
return false, nil
}
key.UpdatedTime = util.GetCurrentTime()
affected, err := ormer.Engine.ID(core.PK{owner, name}).AllCols().Update(key)
if err != nil {
return false, err
}
return affected != 0, nil
}
func AddKey(key *Key) (bool, error) {
if key.AccessKey == "" {
key.AccessKey = util.GenerateId()
}
if key.AccessSecret == "" {
key.AccessSecret = util.GenerateId()
}
affected, err := ormer.Engine.Insert(key)
if err != nil {
return false, err
}
return affected != 0, nil
}
func DeleteKey(key *Key) (bool, error) {
affected, err := ormer.Engine.ID(core.PK{key.Owner, key.Name}).Delete(&Key{})
if err != nil {
return false, err
}
return affected != 0, nil
}
func (key *Key) GetId() string {
return fmt.Sprintf("%s/%s", key.Owner, key.Name)
}
func GetKeyByAccessKey(accessKey string) (*Key, error) {
if accessKey == "" {
return nil, nil
}
key := Key{AccessKey: accessKey}
existed, err := ormer.Engine.Get(&key)
if err != nil {
return nil, err
}
if existed {
return &key, nil
}
return nil, nil
}

View File

@@ -200,16 +200,25 @@ func (l *LdapConn) GetLdapUsers(ldapServer *Ldap) ([]LdapUser, error) {
SearchAttributes = append(SearchAttributes, attribute)
}
// Some LDAP servers/configs use "{}" as a placeholder (e.g. "(uid={})").
// Casdoor doesn't interpolate it. For listing users, interpret it as a wildcard.
filter := strings.TrimSpace(ldapServer.Filter)
if filter == "" {
filter = "(objectClass=*)"
} else if strings.Contains(filter, "{}") {
filter = strings.ReplaceAll(filter, "{}", "*")
}
searchReq := goldap.NewSearchRequest(ldapServer.BaseDn, goldap.ScopeWholeSubtree, goldap.NeverDerefAliases,
0, 0, false,
ldapServer.Filter, SearchAttributes, nil)
filter, SearchAttributes, nil)
searchResult, err := l.Conn.SearchWithPaging(searchReq, 100)
if err != nil {
return nil, err
}
if len(searchResult.Entries) == 0 {
return nil, errors.New("no result")
return []LdapUser{}, nil
}
var ldapUsers []LdapUser
@@ -859,13 +868,22 @@ func (ldapUser *LdapUser) GetLdapUuid() string {
}
func (ldap *Ldap) buildAuthFilterString(user *User) string {
if len(ldap.FilterFields) == 0 {
return fmt.Sprintf("(&%s(uid=%s))", ldap.Filter, user.Name)
// Tolerate configs that use "{}" as a placeholder, e.g. "(uid={})".
// Casdoor doesn't interpolate it; treat it as wildcard so the base filter remains valid.
baseFilter := strings.TrimSpace(ldap.Filter)
if baseFilter == "" {
baseFilter = "(objectClass=*)"
} else if strings.Contains(baseFilter, "{}") {
baseFilter = strings.ReplaceAll(baseFilter, "{}", "*")
}
filter := fmt.Sprintf("(&%s(|", ldap.Filter)
if len(ldap.FilterFields) == 0 {
return fmt.Sprintf("(&%s(uid=%s))", baseFilter, goldap.EscapeFilter(user.Name))
}
filter := fmt.Sprintf("(&%s(|", baseFilter)
for _, field := range ldap.FilterFields {
filter = fmt.Sprintf("%s(%s=%s)", filter, field, user.getFieldFromLdapAttribute(field))
filter = fmt.Sprintf("%s(%s=%s)", filter, field, goldap.EscapeFilter(user.getFieldFromLdapAttribute(field)))
}
filter = fmt.Sprintf("%s))", filter)

184
object/log_provider.go Normal file
View File

@@ -0,0 +1,184 @@
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package object
import (
"fmt"
"sync"
"github.com/casdoor/casdoor/log"
)
var (
runningCollectors = map[string]log.LogProvider{} // providerGetId() -> LogProvider
runningCollectorsMu sync.Mutex
)
// InitLogProviders scans all globally-configured Log providers and starts
// background collection for pull-based providers (e.g. System Log, SELinux Log)
// and registers passive providers (e.g. OpenClaw).
// It is called once from main() after the database is ready.
func InitLogProviders() {
providers, err := GetGlobalProviders()
if err != nil {
return
}
for _, p := range providers {
if p.Category != "Log" {
continue
}
if p.State == "Disabled" {
continue
}
switch p.Type {
case "System Log", "SELinux Log":
startLogCollector(p)
case "Agent":
if p.SubType == "OpenClaw" {
startOpenClawProvider(p)
startOpenClawTranscriptSync(p)
}
}
}
}
func stopCollector(id string) {
runningCollectorsMu.Lock()
defer runningCollectorsMu.Unlock()
if existing, ok := runningCollectors[id]; ok {
_ = existing.Stop()
delete(runningCollectors, id)
}
}
// startLogCollector starts a pull-based log collector (System Log / SELinux Log)
// for the given provider. If a collector for the same provider is already
// running it is stopped first.
func startLogCollector(provider *Provider) {
id := provider.GetId()
stopCollector(id)
lp, err := log.GetLogProvider(provider.Type, provider.Host, provider.Port, provider.Title)
if err != nil {
return
}
providerName := provider.Name
addEntry := func(owner, createdTime, _ string, message string) error {
name := log.GenerateEntryName()
entry := &Entry{
Owner: owner,
Name: name,
CreatedTime: createdTime,
UpdatedTime: createdTime,
DisplayName: name,
Provider: providerName,
Message: message,
}
_, err := AddEntry(entry)
return err
}
onError := func(err error) {
fmt.Printf("InitLogProviders: collector for provider %s stopped with error: %v\n", providerName, err)
}
if err := lp.Start(addEntry, onError); err != nil {
fmt.Printf("InitLogProviders: failed to start collector for provider %s: %v\n", providerName, err)
return
}
runningCollectorsMu.Lock()
defer runningCollectorsMu.Unlock()
runningCollectors[id] = lp
}
// startOpenClawProvider registers an OpenClaw provider in runningCollectors so
// that incoming OTLP requests can be routed to it by IP.
func startOpenClawProvider(provider *Provider) {
id := provider.GetId()
stopCollector(id)
lp, err := GetLogProviderFromProvider(provider)
if err != nil {
fmt.Printf("InitLogProviders: failed to create OpenClaw provider %s: %v\n", provider.Name, err)
return
}
runningCollectorsMu.Lock()
defer runningCollectorsMu.Unlock()
runningCollectors[id] = lp
}
func refreshLogProviderRuntime(oldID string, provider *Provider) {
if provider == nil {
if oldID != "" {
stopLogProviderRuntime(oldID)
}
return
}
if oldID != "" {
stopLogProviderRuntime(oldID)
}
if provider.Category != "Log" {
return
}
if provider.State == "Disabled" {
return
}
switch provider.Type {
case "System Log", "SELinux Log":
startLogCollector(provider)
case "Agent":
if provider.SubType == "OpenClaw" {
startOpenClawProvider(provider)
startOpenClawTranscriptSync(provider)
}
}
}
func stopLogProviderRuntime(providerID string) {
if providerID == "" {
return
}
stopCollector(providerID)
stopOpenClawTranscriptSync(providerID)
}
// GetOpenClawProviderByIP returns the running OpenClawProvider whose Host field
// matches clientIP, or whose Host is empty (meaning any IP is allowed).
// Returns nil if no matching provider is registered.
func GetOpenClawProviderByIP(clientIP string) (*log.OpenClawProvider, error) {
providers := []*Provider{}
err := ormer.Engine.Where("category = ? AND type = ? AND sub_type = ? AND (state = ? OR state = ?)", "Log", "Agent", "OpenClaw", "Enabled", "").Find(&providers)
if err != nil {
return nil, err
}
runningCollectorsMu.Lock()
defer runningCollectorsMu.Unlock()
for _, p := range providers {
if p.Host == "" || p.Host == clientIP {
if lp, ok := runningCollectors[p.GetId()]; ok {
if ocp, ok := lp.(*log.OpenClawProvider); ok {
return ocp, nil
}
}
}
}
return nil, nil
}

View File

@@ -21,7 +21,7 @@ import (
"time"
"github.com/casdoor/casdoor/notification"
"github.com/google/uuid"
"github.com/casdoor/casdoor/util"
)
type PushMfa struct {
@@ -111,7 +111,7 @@ func (mfa *PushMfa) sendPushNotification(title string, message string) error {
// Generate a unique challenge ID for this notification
// Note: In a full implementation, this would be stored in a cache/database
// to validate callbacks from the mobile app
mfa.challengeId = uuid.NewString()
mfa.challengeId = util.GenerateUUID()
mfa.challengeExp = time.Now().Add(5 * time.Minute) // Challenge expires in 5 minutes
// Get the notification provider

View File

@@ -0,0 +1,804 @@
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package object
import (
"encoding/json"
"fmt"
"sort"
"strings"
"github.com/casdoor/casdoor/util"
)
type OpenClawSessionGraph struct {
Nodes []*OpenClawSessionGraphNode `json:"nodes"`
Edges []*OpenClawSessionGraphEdge `json:"edges"`
Stats OpenClawSessionGraphStats `json:"stats"`
}
type OpenClawSessionGraphNode struct {
ID string `json:"id"`
ParentID string `json:"parentId,omitempty"`
OriginalParentID string `json:"originalParentId,omitempty"`
EntryID string `json:"entryId,omitempty"`
ToolCallID string `json:"toolCallId,omitempty"`
Kind string `json:"kind"`
Timestamp string `json:"timestamp"`
Summary string `json:"summary"`
Tool string `json:"tool,omitempty"`
Query string `json:"query,omitempty"`
URL string `json:"url,omitempty"`
Path string `json:"path,omitempty"`
OK *bool `json:"ok,omitempty"`
Error string `json:"error,omitempty"`
Text string `json:"text,omitempty"`
IsAnchor bool `json:"isAnchor"`
}
type OpenClawSessionGraphEdge struct {
Source string `json:"source"`
Target string `json:"target"`
}
type OpenClawSessionGraphStats struct {
TotalNodes int `json:"totalNodes"`
TaskCount int `json:"taskCount"`
ToolCallCount int `json:"toolCallCount"`
ToolResultCount int `json:"toolResultCount"`
FinalCount int `json:"finalCount"`
FailedCount int `json:"failedCount"`
}
type openClawSessionGraphBuilder struct {
graph *OpenClawSessionGraph
nodes map[string]*OpenClawSessionGraphNode
}
type openClawSessionGraphRecord struct {
Entry *Entry
Payload openClawBehaviorPayload
}
type openClawAssistantStepGroup struct {
ParentID string
Timestamp string
ToolNames []string
Text string
}
func GetOpenClawSessionGraph(id string) (*OpenClawSessionGraph, error) {
entry, err := GetEntry(id)
if err != nil {
return nil, err
}
if entry == nil {
return nil, nil
}
if strings.TrimSpace(entry.Type) != "session" {
return nil, fmt.Errorf("entry %s is not an OpenClaw session entry", id)
}
provider, err := GetProvider(util.GetId(entry.Owner, entry.Provider))
if err != nil {
return nil, err
}
if provider != nil && !isOpenClawLogProvider(provider) {
return nil, fmt.Errorf("entry %s is not an OpenClaw session entry", id)
}
anchorPayload, err := parseOpenClawSessionGraphPayload(entry)
if err != nil {
return nil, fmt.Errorf("failed to parse anchor entry %s: %w", entry.Name, err)
}
records, err := collectOpenClawSessionGraphRecords(entry, anchorPayload)
if err != nil {
return nil, fmt.Errorf("failed to load OpenClaw session entries from database: %w", err)
}
return buildOpenClawSessionGraphFromEntries(anchorPayload, entry.Name, records), nil
}
func parseOpenClawSessionGraphPayload(entry *Entry) (openClawBehaviorPayload, error) {
if entry == nil {
return openClawBehaviorPayload{}, fmt.Errorf("entry is nil")
}
message := strings.TrimSpace(entry.Message)
if message == "" {
return openClawBehaviorPayload{}, fmt.Errorf("message is empty")
}
var payload openClawBehaviorPayload
if err := json.Unmarshal([]byte(message), &payload); err != nil {
return openClawBehaviorPayload{}, err
}
payload.SessionID = strings.TrimSpace(payload.SessionID)
payload.EntryID = strings.TrimSpace(payload.EntryID)
payload.ToolCallID = strings.TrimSpace(payload.ToolCallID)
payload.ParentID = strings.TrimSpace(payload.ParentID)
payload.Kind = strings.TrimSpace(payload.Kind)
payload.Summary = strings.TrimSpace(payload.Summary)
payload.Tool = strings.TrimSpace(payload.Tool)
payload.Query = strings.TrimSpace(payload.Query)
payload.URL = strings.TrimSpace(payload.URL)
payload.Path = strings.TrimSpace(payload.Path)
payload.Error = strings.TrimSpace(payload.Error)
payload.AssistantText = strings.TrimSpace(payload.AssistantText)
payload.Text = strings.TrimSpace(payload.Text)
payload.Timestamp = strings.TrimSpace(firstNonEmpty(payload.Timestamp, entry.CreatedTime))
if payload.SessionID == "" {
return openClawBehaviorPayload{}, fmt.Errorf("sessionId is empty")
}
if payload.EntryID == "" {
return openClawBehaviorPayload{}, fmt.Errorf("entryId is empty")
}
if payload.Kind == "" {
return openClawBehaviorPayload{}, fmt.Errorf("kind is empty")
}
return payload, nil
}
func collectOpenClawSessionGraphRecords(anchorEntry *Entry, anchorPayload openClawBehaviorPayload) ([]openClawSessionGraphRecord, error) {
if anchorEntry == nil {
return nil, fmt.Errorf("anchor entry is nil")
}
entries := []*Entry{}
query := ormer.Engine.Where("owner = ? and type = ?", anchorEntry.Owner, "session")
if providerName := strings.TrimSpace(anchorEntry.Provider); providerName != "" {
query = query.And("provider = ?", providerName)
}
if err := query.
Asc("created_time").
Asc("name").
Find(&entries); err != nil {
return nil, err
}
return filterOpenClawSessionGraphRecords(anchorEntry, anchorPayload, entries), nil
}
func filterOpenClawSessionGraphRecords(anchorEntry *Entry, anchorPayload openClawBehaviorPayload, entries []*Entry) []openClawSessionGraphRecord {
targetSessionID := strings.TrimSpace(anchorPayload.SessionID)
records := make([]openClawSessionGraphRecord, 0, len(entries)+1)
hasAnchor := false
for _, candidate := range entries {
if candidate == nil {
continue
}
payload, err := parseOpenClawSessionGraphPayload(candidate)
if err != nil {
continue
}
if payload.SessionID != targetSessionID {
continue
}
records = append(records, openClawSessionGraphRecord{
Entry: candidate,
Payload: payload,
})
if candidate.Owner == anchorEntry.Owner && candidate.Name == anchorEntry.Name {
hasAnchor = true
}
}
if !hasAnchor && anchorEntry != nil {
records = append(records, openClawSessionGraphRecord{
Entry: anchorEntry,
Payload: anchorPayload,
})
}
sort.SliceStable(records, func(i, j int) bool {
leftPayload := records[i].Payload
rightPayload := records[j].Payload
leftTimestamp := strings.TrimSpace(firstNonEmpty(leftPayload.Timestamp, records[i].Entry.CreatedTime))
rightTimestamp := strings.TrimSpace(firstNonEmpty(rightPayload.Timestamp, records[j].Entry.CreatedTime))
if timestampOrder := compareOpenClawGraphTimestamps(leftTimestamp, rightTimestamp); timestampOrder != 0 {
return timestampOrder < 0
}
return records[i].Entry.Name < records[j].Entry.Name
})
return records
}
func buildOpenClawSessionGraphFromEntries(anchorPayload openClawBehaviorPayload, anchorEntryName string, records []openClawSessionGraphRecord) *OpenClawSessionGraph {
builder := newOpenClawSessionGraphBuilder()
nodeIDsByEntryName := map[string][]string{}
assistantGroups := map[string]*openClawAssistantStepGroup{}
toolCallNodesByAssistant := map[string][]*OpenClawSessionGraphNode{}
toolCallNodeIDByToolCallID := map[string]string{}
allToolCallNodes := []*OpenClawSessionGraphNode{}
toolResultRecords := []openClawSessionGraphRecord{}
for _, record := range records {
payload := record.Payload
switch payload.Kind {
case "task":
builder.addNode(&OpenClawSessionGraphNode{
ID: payload.EntryID,
ParentID: payload.ParentID,
EntryID: payload.EntryID,
Kind: "task",
Timestamp: payload.Timestamp,
Summary: payload.Summary,
Text: payload.Text,
})
appendGraphNodeEntryName(nodeIDsByEntryName, record.Entry, payload.EntryID)
case "tool_call":
nodeID := buildStoredToolCallNodeID(record.Entry, payload)
builder.addNode(&OpenClawSessionGraphNode{
ID: nodeID,
ParentID: payload.EntryID,
EntryID: payload.EntryID,
ToolCallID: payload.ToolCallID,
Kind: "tool_call",
Timestamp: payload.Timestamp,
Summary: payload.Summary,
Tool: payload.Tool,
Query: payload.Query,
URL: payload.URL,
Path: payload.Path,
Text: payload.Text,
})
storedNode := builder.nodes[nodeID]
appendGraphNodeEntryName(nodeIDsByEntryName, record.Entry, nodeID)
if storedNode != nil {
toolCallNodesByAssistant[payload.EntryID] = append(toolCallNodesByAssistant[payload.EntryID], storedNode)
allToolCallNodes = append(allToolCallNodes, storedNode)
}
if payload.ToolCallID != "" && toolCallNodeIDByToolCallID[payload.ToolCallID] == "" {
toolCallNodeIDByToolCallID[payload.ToolCallID] = nodeID
}
group := assistantGroups[payload.EntryID]
if group == nil {
group = &openClawAssistantStepGroup{
ParentID: payload.ParentID,
Timestamp: payload.Timestamp,
}
assistantGroups[payload.EntryID] = group
}
group.ParentID = firstNonEmpty(group.ParentID, payload.ParentID)
group.Timestamp = chooseEarlierTimestamp(group.Timestamp, payload.Timestamp)
group.ToolNames = append(group.ToolNames, payload.Tool)
group.Text = firstNonEmpty(group.Text, payload.AssistantText)
case "tool_result":
toolResultRecords = append(toolResultRecords, record)
case "final":
builder.addNode(&OpenClawSessionGraphNode{
ID: payload.EntryID,
ParentID: payload.ParentID,
EntryID: payload.EntryID,
Kind: "final",
Timestamp: payload.Timestamp,
Summary: payload.Summary,
Text: payload.Text,
})
appendGraphNodeEntryName(nodeIDsByEntryName, record.Entry, payload.EntryID)
}
}
assistantIDs := make([]string, 0, len(assistantGroups))
for entryID := range assistantGroups {
assistantIDs = append(assistantIDs, entryID)
}
sort.Strings(assistantIDs)
for _, assistantID := range assistantIDs {
group := assistantGroups[assistantID]
builder.addNode(&OpenClawSessionGraphNode{
ID: assistantID,
ParentID: strings.TrimSpace(group.ParentID),
EntryID: assistantID,
Kind: "assistant_step",
Timestamp: strings.TrimSpace(group.Timestamp),
Summary: buildAssistantStepSummary(group.ToolNames),
Text: strings.TrimSpace(group.Text),
})
}
for _, record := range toolResultRecords {
payload := record.Payload
parentID := strings.TrimSpace(payload.ParentID)
originalParentID := ""
if payload.ToolCallID != "" {
if matchedNodeID := strings.TrimSpace(toolCallNodeIDByToolCallID[payload.ToolCallID]); matchedNodeID != "" {
originalParentID = parentID
parentID = matchedNodeID
}
}
if parentID == strings.TrimSpace(payload.ParentID) {
if matchedNodeID := matchToolResultToolCallNodeID(payload, toolCallNodesByAssistant[payload.ParentID], allToolCallNodes); matchedNodeID != "" && matchedNodeID != parentID {
originalParentID = parentID
parentID = matchedNodeID
}
}
builder.addNode(&OpenClawSessionGraphNode{
ID: payload.EntryID,
ParentID: parentID,
OriginalParentID: originalParentID,
EntryID: payload.EntryID,
ToolCallID: payload.ToolCallID,
Kind: "tool_result",
Timestamp: payload.Timestamp,
Summary: payload.Summary,
Tool: payload.Tool,
Query: payload.Query,
URL: payload.URL,
Path: payload.Path,
OK: cloneBoolPointer(payload.OK),
Error: payload.Error,
Text: payload.Text,
})
appendGraphNodeEntryName(nodeIDsByEntryName, record.Entry, payload.EntryID)
}
markStoredGraphAnchor(builder, anchorPayload, anchorEntryName, nodeIDsByEntryName)
return builder.finalize()
}
func appendGraphNodeEntryName(index map[string][]string, entry *Entry, nodeID string) {
if index == nil || entry == nil {
return
}
entryName := strings.TrimSpace(entry.Name)
nodeID = strings.TrimSpace(nodeID)
if entryName == "" || nodeID == "" {
return
}
for _, existingNodeID := range index[entryName] {
if existingNodeID == nodeID {
return
}
}
index[entryName] = append(index[entryName], nodeID)
}
func matchToolResultToolCallNodeID(payload openClawBehaviorPayload, assistantToolCalls []*OpenClawSessionGraphNode, allToolCalls []*OpenClawSessionGraphNode) string {
if matchedNodeID := chooseMatchingToolCallNodeID(payload, assistantToolCalls); matchedNodeID != "" {
return matchedNodeID
}
if len(assistantToolCalls) != len(allToolCalls) {
return chooseMatchingToolCallNodeID(payload, allToolCalls)
}
return ""
}
func chooseMatchingToolCallNodeID(payload openClawBehaviorPayload, candidates []*OpenClawSessionGraphNode) string {
filtered := make([]*OpenClawSessionGraphNode, 0, len(candidates))
seenNodeIDs := map[string]struct{}{}
for _, candidate := range candidates {
if candidate == nil || candidate.Kind != "tool_call" {
continue
}
if _, ok := seenNodeIDs[candidate.ID]; ok {
continue
}
seenNodeIDs[candidate.ID] = struct{}{}
filtered = append(filtered, candidate)
}
if len(filtered) == 0 {
return ""
}
if len(filtered) == 1 {
return filtered[0].ID
}
filtered = refineToolCallCandidates(filtered, payload.Query, func(node *OpenClawSessionGraphNode) string { return node.Query })
if len(filtered) == 1 {
return filtered[0].ID
}
filtered = refineToolCallCandidates(filtered, payload.URL, func(node *OpenClawSessionGraphNode) string { return node.URL })
if len(filtered) == 1 {
return filtered[0].ID
}
filtered = refineToolCallCandidates(filtered, payload.Path, func(node *OpenClawSessionGraphNode) string { return node.Path })
if len(filtered) == 1 {
return filtered[0].ID
}
filtered = refineToolCallCandidates(filtered, payload.Tool, func(node *OpenClawSessionGraphNode) string { return node.Tool })
if len(filtered) == 1 {
return filtered[0].ID
}
return ""
}
func refineToolCallCandidates(candidates []*OpenClawSessionGraphNode, expected string, selector func(node *OpenClawSessionGraphNode) string) []*OpenClawSessionGraphNode {
expected = strings.TrimSpace(expected)
if expected == "" {
return candidates
}
filtered := make([]*OpenClawSessionGraphNode, 0, len(candidates))
for _, candidate := range candidates {
if strings.TrimSpace(selector(candidate)) == expected {
filtered = append(filtered, candidate)
}
}
if len(filtered) == 0 {
return candidates
}
return filtered
}
func markStoredGraphAnchor(builder *openClawSessionGraphBuilder, anchorPayload openClawBehaviorPayload, anchorEntryName string, nodeIDsByEntryName map[string][]string) {
anchorNodeID := ""
if nodeIDs := nodeIDsByEntryName[strings.TrimSpace(anchorEntryName)]; len(nodeIDs) == 1 {
anchorNodeID = nodeIDs[0]
}
if anchorNodeID == "" {
switch anchorPayload.Kind {
case "tool_call":
candidates := []string{}
for _, node := range builder.nodes {
if !toolCallPayloadMatchesNode(anchorPayload, node) {
continue
}
candidates = append(candidates, node.ID)
}
switch len(candidates) {
case 1:
anchorNodeID = candidates[0]
default:
anchorNodeID = anchorPayload.EntryID
}
default:
if node := builder.nodes[anchorPayload.EntryID]; node != nil && node.Kind == anchorPayload.Kind {
anchorNodeID = node.ID
}
}
}
if anchorNode := builder.nodes[anchorNodeID]; anchorNode != nil {
anchorNode.IsAnchor = true
}
}
func buildStoredToolCallNodeID(entry *Entry, payload openClawBehaviorPayload) string {
if payload.ToolCallID != "" {
return fmt.Sprintf("tool_call:%s", payload.ToolCallID)
}
if entry != nil && strings.TrimSpace(entry.Name) != "" {
return fmt.Sprintf("tool_call_row:%s", strings.TrimSpace(entry.Name))
}
return fmt.Sprintf("tool_call:%s", strings.TrimSpace(payload.EntryID))
}
func newOpenClawSessionGraphBuilder() *openClawSessionGraphBuilder {
return &openClawSessionGraphBuilder{
graph: &OpenClawSessionGraph{
Nodes: []*OpenClawSessionGraphNode{},
Edges: []*OpenClawSessionGraphEdge{},
},
nodes: map[string]*OpenClawSessionGraphNode{},
}
}
func (b *openClawSessionGraphBuilder) addNode(node *OpenClawSessionGraphNode) {
if b == nil || node == nil {
return
}
node.ID = strings.TrimSpace(node.ID)
if node.ID == "" {
return
}
if existing := b.nodes[node.ID]; existing != nil {
mergeOpenClawGraphNode(existing, node)
return
}
cloned := *node
cloned.ParentID = strings.TrimSpace(cloned.ParentID)
cloned.OriginalParentID = strings.TrimSpace(cloned.OriginalParentID)
cloned.EntryID = strings.TrimSpace(cloned.EntryID)
cloned.ToolCallID = strings.TrimSpace(cloned.ToolCallID)
cloned.Kind = strings.TrimSpace(cloned.Kind)
cloned.Timestamp = strings.TrimSpace(cloned.Timestamp)
cloned.Summary = strings.TrimSpace(cloned.Summary)
cloned.Tool = strings.TrimSpace(cloned.Tool)
cloned.Query = strings.TrimSpace(cloned.Query)
cloned.URL = strings.TrimSpace(cloned.URL)
cloned.Path = strings.TrimSpace(cloned.Path)
cloned.Error = strings.TrimSpace(cloned.Error)
cloned.Text = strings.TrimSpace(cloned.Text)
cloned.OK = cloneBoolPointer(cloned.OK)
b.nodes[cloned.ID] = &cloned
}
func (b *openClawSessionGraphBuilder) finalize() *OpenClawSessionGraph {
if b == nil || b.graph == nil {
return nil
}
nodeIDs := make([]string, 0, len(b.nodes))
for id := range b.nodes {
nodeIDs = append(nodeIDs, id)
}
sort.Slice(nodeIDs, func(i, j int) bool {
left := b.nodes[nodeIDs[i]]
right := b.nodes[nodeIDs[j]]
return compareGraphNodes(left, right) < 0
})
b.graph.Nodes = make([]*OpenClawSessionGraphNode, 0, len(nodeIDs))
b.graph.Stats = OpenClawSessionGraphStats{}
for _, id := range nodeIDs {
node := b.nodes[id]
b.graph.Nodes = append(b.graph.Nodes, node)
updateOpenClawSessionGraphStats(&b.graph.Stats, node)
}
edgeKeys := map[string]struct{}{}
b.graph.Edges = []*OpenClawSessionGraphEdge{}
for _, node := range b.graph.Nodes {
if node.ParentID == "" || b.nodes[node.ParentID] == nil {
continue
}
key := fmt.Sprintf("%s->%s", node.ParentID, node.ID)
if _, ok := edgeKeys[key]; ok {
continue
}
edgeKeys[key] = struct{}{}
b.graph.Edges = append(b.graph.Edges, &OpenClawSessionGraphEdge{
Source: node.ParentID,
Target: node.ID,
})
}
sort.Slice(b.graph.Edges, func(i, j int) bool {
left := b.graph.Edges[i]
right := b.graph.Edges[j]
if left.Source != right.Source {
return left.Source < right.Source
}
return left.Target < right.Target
})
return b.graph
}
func mergeOpenClawGraphNode(current, next *OpenClawSessionGraphNode) {
if current == nil || next == nil {
return
}
current.ParentID = firstNonEmpty(current.ParentID, next.ParentID)
current.OriginalParentID = firstNonEmpty(current.OriginalParentID, next.OriginalParentID)
current.EntryID = firstNonEmpty(current.EntryID, next.EntryID)
current.ToolCallID = firstNonEmpty(current.ToolCallID, next.ToolCallID)
current.Kind = firstNonEmpty(current.Kind, next.Kind)
current.Timestamp = chooseEarlierTimestamp(current.Timestamp, next.Timestamp)
current.Summary = firstNonEmpty(current.Summary, next.Summary)
current.Tool = firstNonEmpty(current.Tool, next.Tool)
current.Query = firstNonEmpty(current.Query, next.Query)
current.URL = firstNonEmpty(current.URL, next.URL)
current.Path = firstNonEmpty(current.Path, next.Path)
current.Error = firstNonEmpty(current.Error, next.Error)
current.Text = firstNonEmpty(current.Text, next.Text)
current.OK = mergeBoolPointers(current.OK, next.OK)
current.IsAnchor = current.IsAnchor || next.IsAnchor
}
func updateOpenClawSessionGraphStats(stats *OpenClawSessionGraphStats, node *OpenClawSessionGraphNode) {
if stats == nil || node == nil {
return
}
stats.TotalNodes++
switch node.Kind {
case "task":
stats.TaskCount++
case "tool_call":
stats.ToolCallCount++
case "tool_result":
stats.ToolResultCount++
if node.OK != nil && !*node.OK {
stats.FailedCount++
}
case "final":
stats.FinalCount++
}
}
func buildAssistantStepSummary(toolNames []string) string {
deduped := []string{}
seen := map[string]struct{}{}
for _, toolName := range toolNames {
toolName = strings.TrimSpace(toolName)
if toolName == "" {
continue
}
if _, ok := seen[toolName]; ok {
continue
}
seen[toolName] = struct{}{}
deduped = append(deduped, toolName)
}
if len(toolNames) == 0 {
return "assistant step"
}
if len(deduped) == 0 {
return fmt.Sprintf("%d tool calls", len(toolNames))
}
if len(deduped) <= 3 {
return fmt.Sprintf("%d tool calls: %s", len(toolNames), strings.Join(deduped, ", "))
}
return fmt.Sprintf("%d tool calls: %s, ...", len(toolNames), strings.Join(deduped[:3], ", "))
}
func toolCallPayloadMatchesNode(payload openClawBehaviorPayload, node *OpenClawSessionGraphNode) bool {
if node == nil || node.Kind != "tool_call" {
return false
}
if payload.ToolCallID != "" {
return strings.TrimSpace(node.ToolCallID) == strings.TrimSpace(payload.ToolCallID)
}
if strings.TrimSpace(node.EntryID) != strings.TrimSpace(payload.EntryID) {
return false
}
fields := []struct {
payload string
node string
}{
{payload.Tool, node.Tool},
{payload.Query, node.Query},
{payload.URL, node.URL},
{payload.Path, node.Path},
{payload.Text, node.Text},
}
matchedField := false
for _, field := range fields {
left := strings.TrimSpace(field.payload)
if left == "" {
continue
}
matchedField = true
if left != strings.TrimSpace(field.node) {
return false
}
}
return matchedField
}
func compareGraphNodes(left, right *OpenClawSessionGraphNode) int {
leftTimestamp := ""
rightTimestamp := ""
leftID := ""
rightID := ""
if left != nil {
leftTimestamp = left.Timestamp
leftID = left.ID
}
if right != nil {
rightTimestamp = right.Timestamp
rightID = right.ID
}
if timestampOrder := compareOpenClawGraphTimestamps(leftTimestamp, rightTimestamp); timestampOrder != 0 {
return timestampOrder
}
if leftID < rightID {
return -1
}
if leftID > rightID {
return 1
}
return 0
}
func chooseEarlierTimestamp(current, next string) string {
current = strings.TrimSpace(current)
next = strings.TrimSpace(next)
if current == "" {
return next
}
if next == "" {
return current
}
if compareOpenClawGraphTimestamps(next, current) < 0 {
return next
}
return current
}
func compareOpenClawGraphTimestamps(left, right string) int {
left = strings.TrimSpace(left)
right = strings.TrimSpace(right)
leftUnixNano, leftOK := parseOpenClawGraphTimestamp(left)
rightUnixNano, rightOK := parseOpenClawGraphTimestamp(right)
if leftOK && rightOK {
if leftUnixNano < rightUnixNano {
return -1
}
if leftUnixNano > rightUnixNano {
return 1
}
return 0
}
if left < right {
return -1
}
if left > right {
return 1
}
return 0
}
func parseOpenClawGraphTimestamp(timestamp string) (_ int64, ok bool) {
timestamp = strings.TrimSpace(timestamp)
if timestamp == "" {
return 0, false
}
defer func() {
if recover() != nil {
ok = false
}
}()
return util.String2Time(timestamp).UnixNano(), true
}
func mergeBoolPointers(current, next *bool) *bool {
if next == nil {
return current
}
if current == nil {
return cloneBoolPointer(next)
}
value := *current && *next
return &value
}
func cloneBoolPointer(value *bool) *bool {
if value == nil {
return nil
}
cloned := *value
return &cloned
}

View File

@@ -0,0 +1,733 @@
package object
import (
"bufio"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"runtime"
"strings"
"sync"
"time"
"github.com/casdoor/casdoor/util"
)
const openClawTranscriptSyncInterval = 10 * time.Second
var (
openClawTranscriptWorkers = map[string]*openClawTranscriptSyncWorker{}
openClawTranscriptWorkersMu sync.Mutex
writeSuccessPathPattern = regexp.MustCompile(`(?i)successfully wrote \d+ bytes to (.+)$`)
)
type openClawTranscriptSyncWorker struct {
provider *Provider
stopCh chan struct{}
doneCh chan struct{}
fileStates map[string]openClawTranscriptFileState
}
type openClawTranscriptFileState struct {
ModTimeUnixNano int64
Size int64
}
type openClawTranscriptEntry struct {
Type string `json:"type"`
ID string `json:"id"`
ParentID string `json:"parentId"`
Timestamp string `json:"timestamp"`
Message *openClawMessage `json:"message"`
Details map[string]interface{} `json:"details"`
}
type openClawMessage struct {
Role string `json:"role"`
Content json.RawMessage `json:"content"`
StopReason string `json:"stopReason"`
ToolCallID string `json:"toolCallId"`
ToolName string `json:"toolName"`
IsError bool `json:"isError"`
Timestamp int64 `json:"timestamp"`
}
type openClawContentItem struct {
Type string `json:"type"`
ID string `json:"id"`
Name string `json:"name"`
Text string `json:"text"`
Arguments map[string]interface{} `json:"arguments"`
}
type openClawBehaviorPayload struct {
Summary string `json:"summary"`
Kind string `json:"kind"`
SessionID string `json:"sessionId"`
EntryID string `json:"entryId"`
ToolCallID string `json:"toolCallId,omitempty"`
ParentID string `json:"parentId,omitempty"`
Timestamp string `json:"timestamp"`
Tool string `json:"tool,omitempty"`
Query string `json:"query,omitempty"`
URL string `json:"url,omitempty"`
Path string `json:"path,omitempty"`
OK *bool `json:"ok,omitempty"`
Error string `json:"error,omitempty"`
AssistantText string `json:"assistantText,omitempty"`
Text string `json:"text,omitempty"`
}
type openClawToolContext struct {
Tool string
Query string
URL string
Path string
Command string
}
func startOpenClawTranscriptSync(provider *Provider) {
if provider == nil || provider.Category != "Log" || provider.Type != "Agent" || provider.SubType != "OpenClaw" {
return
}
id := provider.GetId()
stopOpenClawTranscriptSync(id)
worker := &openClawTranscriptSyncWorker{
provider: provider,
stopCh: make(chan struct{}),
doneCh: make(chan struct{}),
fileStates: map[string]openClawTranscriptFileState{},
}
openClawTranscriptWorkersMu.Lock()
openClawTranscriptWorkers[id] = worker
openClawTranscriptWorkersMu.Unlock()
go worker.run()
}
func stopOpenClawTranscriptSync(providerID string) {
openClawTranscriptWorkersMu.Lock()
worker, ok := openClawTranscriptWorkers[providerID]
if ok {
delete(openClawTranscriptWorkers, providerID)
}
openClawTranscriptWorkersMu.Unlock()
if !ok {
return
}
close(worker.stopCh)
<-worker.doneCh
}
func (w *openClawTranscriptSyncWorker) run() {
defer close(w.doneCh)
w.syncOnce()
ticker := time.NewTicker(openClawTranscriptSyncInterval)
defer ticker.Stop()
for {
select {
case <-w.stopCh:
return
case <-ticker.C:
w.syncOnce()
}
}
}
func (w *openClawTranscriptSyncWorker) syncOnce() {
if w.isStopping() {
return
}
if err := w.scanTranscriptDir(); err != nil {
fmt.Printf("OpenClaw transcript sync failed for provider %s: %v\n", w.provider.Name, err)
}
}
func (w *openClawTranscriptSyncWorker) isStopping() bool {
select {
case <-w.stopCh:
return true
default:
return false
}
}
func (w *openClawTranscriptSyncWorker) scanTranscriptDir() error {
rootDir, err := resolveOpenClawTranscriptDir(w.provider)
if err != nil {
return err
}
entries, err := os.ReadDir(rootDir)
if err != nil {
return err
}
seenPaths := map[string]struct{}{}
for _, entry := range entries {
if w.isStopping() {
return nil
}
if entry.IsDir() {
continue
}
name := entry.Name()
if !strings.HasSuffix(name, ".jsonl") || strings.Contains(name, ".reset.") || name == "sessions.json" {
continue
}
path := filepath.Join(rootDir, name)
seenPaths[path] = struct{}{}
info, err := entry.Info()
if err != nil {
return err
}
nextState := openClawTranscriptFileState{
ModTimeUnixNano: info.ModTime().UnixNano(),
Size: info.Size(),
}
if w.shouldSkipTranscriptFile(path, nextState) {
continue
}
if err := w.scanTranscriptFile(path); err != nil {
return err
}
w.fileStates[path] = nextState
}
for path := range w.fileStates {
if w.isStopping() {
return nil
}
if _, ok := seenPaths[path]; !ok {
delete(w.fileStates, path)
}
}
return nil
}
func (w *openClawTranscriptSyncWorker) shouldSkipTranscriptFile(path string, nextState openClawTranscriptFileState) bool {
currentState, ok := w.fileStates[path]
if !ok {
return false
}
return currentState.ModTimeUnixNano == nextState.ModTimeUnixNano && currentState.Size == nextState.Size
}
func resolveOpenClawTranscriptDir(provider *Provider) (string, error) {
if provider == nil {
return "", fmt.Errorf("provider is nil")
}
if endpoint := strings.TrimSpace(provider.Endpoint); endpoint != "" {
return expandOpenClawPath(endpoint)
}
stateDir, err := resolveOpenClawStateDir()
if err != nil {
return "", err
}
agentID := strings.TrimSpace(provider.Title)
if agentID == "" {
agentID = "main"
}
return filepath.Join(stateDir, "agents", agentID, "sessions"), nil
}
func fillOpenClawProviderDefaults(provider *Provider) error {
if !isOpenClawLogProvider(provider) {
return nil
}
if strings.TrimSpace(provider.Title) == "" {
provider.Title = "main"
}
if strings.TrimSpace(provider.Endpoint) != "" {
resolved, err := expandOpenClawPath(provider.Endpoint)
if err != nil {
return err
}
provider.Endpoint = resolved
return nil
}
transcriptDir, err := resolveOpenClawTranscriptDir(provider)
if err != nil {
return err
}
provider.Endpoint = transcriptDir
return nil
}
func isOpenClawLogProvider(provider *Provider) bool {
return provider != nil && provider.Category == "Log" && provider.Type == "Agent" && provider.SubType == "OpenClaw"
}
func resolveOpenClawStateDir() (string, error) {
if override := strings.TrimSpace(os.Getenv("OPENCLAW_STATE_DIR")); override != "" {
return expandOpenClawPath(override)
}
homeDir, err := resolveOpenClawHomeDir()
if err != nil {
return "", err
}
if profile := strings.TrimSpace(os.Getenv("OPENCLAW_PROFILE")); profile != "" && !strings.EqualFold(profile, "default") {
return filepath.Join(homeDir, ".openclaw-"+profile), nil
}
return filepath.Join(homeDir, ".openclaw"), nil
}
func resolveOpenClawHomeDir() (string, error) {
if explicitHome := strings.TrimSpace(os.Getenv("OPENCLAW_HOME")); explicitHome != "" {
return expandOpenClawPath(explicitHome)
}
return resolveSystemHomeDir()
}
func resolveSystemHomeDir() (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Clean(homeDir), nil
}
func expandOpenClawPath(input string) (string, error) {
trimmed := strings.TrimSpace(input)
if trimmed == "" {
return "", nil
}
if trimmed == "~" || strings.HasPrefix(trimmed, "~/") || strings.HasPrefix(trimmed, "~\\") {
homeDir, err := resolveSystemHomeDir()
if err != nil {
return "", err
}
suffix := strings.TrimPrefix(strings.TrimPrefix(trimmed, "~"), string(filepath.Separator))
suffix = strings.TrimPrefix(strings.TrimPrefix(suffix, "/"), "\\")
if suffix == "" {
return filepath.Clean(homeDir), nil
}
return filepath.Clean(filepath.Join(homeDir, suffix)), nil
}
if runtime.GOOS == "windows" && len(trimmed) >= 2 && trimmed[0] == '%' {
if index := strings.Index(trimmed[1:], "%"); index >= 0 {
end := index + 1
envKey := trimmed[1:end]
if envValue := strings.TrimSpace(os.Getenv(envKey)); envValue != "" {
replaced := envValue + trimmed[end+1:]
return filepath.Clean(replaced), nil
}
}
}
return filepath.Clean(trimmed), nil
}
func (w *openClawTranscriptSyncWorker) scanTranscriptFile(path string) error {
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
sessionID := strings.TrimSuffix(filepath.Base(path), ".jsonl")
toolContexts := map[string]openClawToolContext{}
reader := bufio.NewReader(file)
for {
if w.isStopping() {
return nil
}
lineBytes, readErr := reader.ReadBytes('\n')
if readErr != nil && readErr != io.EOF {
return readErr
}
line := strings.TrimSpace(string(lineBytes))
if line == "" {
if readErr == io.EOF {
break
}
continue
}
var entry openClawTranscriptEntry
if err := json.Unmarshal([]byte(line), &entry); err != nil {
if readErr == io.EOF {
break
}
continue
}
results := buildOpenClawTranscriptEntries(w.provider, sessionID, entry, toolContexts)
for _, result := range results {
if result == nil {
continue
}
if err := addOpenClawTranscriptEntry(result); err != nil {
return err
}
}
if readErr == io.EOF {
break
}
}
return nil
}
func buildOpenClawTranscriptEntries(provider *Provider, sessionID string, entry openClawTranscriptEntry, toolContexts map[string]openClawToolContext) []*Entry {
if entry.Type != "message" || entry.Message == nil {
return nil
}
message := entry.Message
switch message.Role {
case "user":
text := normalizeUserText(extractMessageText(message.Content))
if text == "" {
return nil
}
if isHeartbeatText(text) {
return nil
}
return []*Entry{newOpenClawTranscriptEntry(provider, sessionID, "task", entry.ID, openClawBehaviorPayload{
Summary: truncateText(fmt.Sprintf("task: %s", text), 100),
Kind: "task",
SessionID: sessionID,
EntryID: entry.ID,
ParentID: entry.ParentID,
Timestamp: normalizeOpenClawTimestamp(entry.Timestamp, message.Timestamp),
Text: truncateText(text, 2000),
})}
case "assistant":
items := parseContentItems(message.Content)
assistantText := truncateText(extractMessageText(message.Content), 2000)
toolEntries := []*Entry{}
storedAssistantText := false
for _, item := range items {
if item.Type != "toolCall" {
continue
}
context := extractOpenClawToolContext(item)
toolContexts[item.ID] = context
payload := openClawBehaviorPayload{
Summary: truncateText(buildToolCallSummary(context), 100),
Kind: "tool_call",
SessionID: sessionID,
EntryID: entry.ID,
ToolCallID: item.ID,
ParentID: entry.ParentID,
Timestamp: normalizeOpenClawTimestamp(entry.Timestamp, message.Timestamp),
Tool: context.Tool,
Query: context.Query,
URL: context.URL,
Path: context.Path,
Text: truncateText(context.Command, 500),
}
if !storedAssistantText {
// Avoid duplicating the same assistant text on every tool-call row.
payload.AssistantText = assistantText
storedAssistantText = true
}
identity := fmt.Sprintf("%s/%s", entry.ID, item.ID)
toolEntries = append(toolEntries, newOpenClawTranscriptEntry(provider, sessionID, "tool_call", identity, payload))
}
if len(toolEntries) > 0 {
return toolEntries
}
if message.StopReason != "stop" {
return nil
}
text := extractMessageText(message.Content)
if text == "" {
return nil
}
return []*Entry{newOpenClawTranscriptEntry(provider, sessionID, "final", entry.ID, openClawBehaviorPayload{
Summary: truncateText(fmt.Sprintf("final: %s", text), 100),
Kind: "final",
SessionID: sessionID,
EntryID: entry.ID,
ParentID: entry.ParentID,
Timestamp: normalizeOpenClawTimestamp(entry.Timestamp, message.Timestamp),
Text: truncateText(text, 2000),
})}
case "toolResult":
payload, ok := buildToolResultPayload(sessionID, entry, toolContexts[message.ToolCallID])
if !ok {
return nil
}
return []*Entry{newOpenClawTranscriptEntry(provider, sessionID, "tool_result", entry.ID, payload)}
default:
return nil
}
}
func buildToolResultPayload(sessionID string, entry openClawTranscriptEntry, toolContext openClawToolContext) (openClawBehaviorPayload, bool) {
message := entry.Message
if message == nil {
return openClawBehaviorPayload{}, false
}
okValue, errorText := resolveToolResultStatus(entry)
text := summarizeToolResultText(extractMessageText(message.Content), okValue)
toolName := firstNonEmpty(toolContext.Tool, message.ToolName)
if toolName == "" && text == "" && errorText == "" {
return openClawBehaviorPayload{}, false
}
return openClawBehaviorPayload{
Summary: truncateText(buildToolResultSummary(toolName, toolContext, okValue, errorText, text), 100),
Kind: "tool_result",
SessionID: sessionID,
EntryID: entry.ID,
ToolCallID: message.ToolCallID,
ParentID: entry.ParentID,
Timestamp: normalizeOpenClawTimestamp(entry.Timestamp, message.Timestamp),
Tool: toolName,
Query: toolContext.Query,
URL: toolContext.URL,
Path: firstNonEmpty(toolContext.Path, extractWriteSuccessPath(text)),
OK: &okValue,
Error: truncateText(errorText, 500),
Text: truncateText(text, 2000),
}, true
}
func newOpenClawTranscriptEntry(provider *Provider, sessionID string, entryKind string, identity string, payload openClawBehaviorPayload) *Entry {
body, _ := json.Marshal(payload)
nameSource := fmt.Sprintf("%s|%s|%s", provider.Name, sessionID, identity)
createdTime := payload.Timestamp
if strings.TrimSpace(createdTime) == "" {
createdTime = util.GetCurrentTime()
}
return &Entry{
Owner: CasdoorOrganization,
Name: fmt.Sprintf("oc_%s", util.GetMd5Hash(nameSource)),
CreatedTime: createdTime,
UpdatedTime: createdTime,
DisplayName: truncateText(payload.Summary, 100),
Provider: provider.Name,
Type: "session",
Message: string(body),
}
}
func addOpenClawTranscriptEntry(entry *Entry) error {
if entry == nil {
return nil
}
_, err := AddEntry(entry)
if err == nil || isDuplicateEntryError(err) {
return nil
}
return err
}
func isDuplicateEntryError(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "duplicate") || strings.Contains(msg, "unique constraint") || strings.Contains(msg, "already exists")
}
func normalizeOpenClawTimestamp(raw string, fallbackMillis int64) string {
if trimmed := strings.TrimSpace(raw); trimmed != "" {
if _, err := time.Parse(time.RFC3339, trimmed); err == nil {
return trimmed
}
}
if fallbackMillis > 0 {
return time.UnixMilli(fallbackMillis).UTC().Format(time.RFC3339)
}
return util.GetCurrentTime()
}
func parseContentItems(raw json.RawMessage) []openClawContentItem {
if len(raw) == 0 {
return nil
}
var text string
if err := json.Unmarshal(raw, &text); err == nil {
if strings.TrimSpace(text) == "" {
return nil
}
return []openClawContentItem{{Type: "text", Text: text}}
}
var items []openClawContentItem
if err := json.Unmarshal(raw, &items); err == nil {
return items
}
return nil
}
func extractMessageText(raw json.RawMessage) string {
items := parseContentItems(raw)
if len(items) == 0 {
return ""
}
parts := []string{}
for _, item := range items {
if item.Type == "text" && strings.TrimSpace(item.Text) != "" {
parts = append(parts, strings.TrimSpace(item.Text))
}
}
return strings.TrimSpace(strings.Join(parts, "\n\n"))
}
func normalizeUserText(text string) string {
trimmed := strings.TrimSpace(text)
if !strings.HasPrefix(trimmed, "Sender (untrusted metadata):") {
return trimmed
}
if index := strings.LastIndex(trimmed, "\n\n"); index >= 0 && index+2 < len(trimmed) {
return strings.TrimSpace(trimmed[index+2:])
}
return trimmed
}
func extractOpenClawToolContext(item openClawContentItem) openClawToolContext {
context := openClawToolContext{Tool: item.Name}
if item.Arguments == nil {
return context
}
context.Query = stringifyOpenClawArg(item.Arguments["query"])
context.URL = stringifyOpenClawArg(item.Arguments["url"])
context.Path = stringifyOpenClawArg(item.Arguments["path"])
context.Command = stringifyOpenClawArg(item.Arguments["command"])
return context
}
func stringifyOpenClawArg(value interface{}) string {
text, ok := value.(string)
if !ok {
return ""
}
return strings.TrimSpace(text)
}
func buildToolCallSummary(context openClawToolContext) string {
target := firstNonEmpty(context.Query, context.URL, context.Path, context.Command)
if target == "" {
return fmt.Sprintf("%s called", context.Tool)
}
return fmt.Sprintf("%s: %s", context.Tool, target)
}
func resolveToolResultStatus(entry openClawTranscriptEntry) (bool, string) {
if entry.Message != nil && entry.Message.IsError {
return false, stringifyOpenClawArg(entry.Details["error"])
}
if status, ok := entry.Details["status"].(string); ok && strings.EqualFold(status, "error") {
return false, stringifyOpenClawArg(entry.Details["error"])
}
text := strings.TrimSpace(extractMessageText(entry.Message.Content))
if strings.HasPrefix(text, "{") {
var payload map[string]interface{}
if err := json.Unmarshal([]byte(text), &payload); err == nil {
if status, ok := payload["status"].(string); ok && strings.EqualFold(status, "error") {
return false, stringifyOpenClawArg(payload["error"])
}
}
}
return true, stringifyOpenClawArg(entry.Details["error"])
}
func summarizeToolResultText(text string, okValue bool) string {
trimmed := strings.TrimSpace(text)
if trimmed == "" {
return ""
}
if okValue {
if path := extractWriteSuccessPath(trimmed); path != "" {
return fmt.Sprintf("Successfully wrote %s", path)
}
return ""
}
return trimmed
}
func extractWriteSuccessPath(text string) string {
matches := writeSuccessPathPattern.FindStringSubmatch(strings.TrimSpace(text))
if len(matches) < 2 {
return ""
}
return strings.TrimSpace(matches[1])
}
func buildToolResultSummary(tool string, context openClawToolContext, okValue bool, errorText string, text string) string {
target := firstNonEmpty(context.Query, context.URL, context.Path, context.Command, extractWriteSuccessPath(text))
status := "ok"
details := target
if !okValue {
status = "failed"
details = firstNonEmpty(errorText, target)
}
if details == "" {
return fmt.Sprintf("%s %s", tool, status)
}
return fmt.Sprintf("%s %s: %s", tool, status, details)
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func isHeartbeatText(text string) bool {
return strings.HasPrefix(strings.TrimSpace(text), "Read HEARTBEAT.md")
}
func truncateText(text string, max int) string {
if max <= 0 {
return ""
}
runes := []rune(strings.TrimSpace(text))
if len(runes) <= max {
return string(runes)
}
return string(runes[:max-1]) + "…"
}

View File

@@ -67,6 +67,7 @@ type Organization struct {
PasswordExpireDays int `json:"passwordExpireDays"`
CountryCodes []string `xorm:"mediumtext" json:"countryCodes"`
DefaultAvatar string `xorm:"varchar(200)" json:"defaultAvatar"`
UsePermanentAvatar bool `xorm:"bool" json:"usePermanentAvatar"`
DefaultApplication string `xorm:"varchar(100)" json:"defaultApplication"`
UserTypes []string `xorm:"mediumtext" json:"userTypes"`
Tags []string `xorm:"mediumtext" json:"tags"`

View File

@@ -23,8 +23,6 @@ import (
"runtime"
"strings"
"github.com/casvisor/casvisor-go-sdk/casvisorsdk"
"github.com/beego/beego/v2/server/web"
"github.com/casdoor/casdoor/conf"
"github.com/casdoor/casdoor/util"
@@ -256,7 +254,11 @@ func (a *Ormer) open() error {
dataSourceName = a.dataSourceName
}
engine, err := xorm.NewEngine(a.driverName, dataSourceName)
driverName := a.driverName
if driverName == "sqlite3" {
driverName = "sqlite"
}
engine, err := xorm.NewEngine(driverName, dataSourceName)
if err != nil {
return err
}
@@ -280,7 +282,11 @@ func (a *Ormer) openFromDb(db *sql.DB) error {
xormDb := core.FromDB(db)
engine, err := xorm.NewEngineWithDB(a.driverName, dataSourceName, xormDb)
driverName := a.driverName
if driverName == "sqlite3" {
driverName = "sqlite"
}
engine, err := xorm.NewEngineWithDB(driverName, dataSourceName, xormDb)
if err != nil {
return err
}
@@ -345,6 +351,11 @@ func (a *Ormer) createTable() {
panic(err)
}
err = a.Engine.Sync2(new(Key))
if err != nil {
panic(err)
}
err = a.Engine.Sync2(new(Role))
if err != nil {
panic(err)
@@ -420,7 +431,7 @@ func (a *Ormer) createTable() {
panic(err)
}
err = a.Engine.Sync2(new(casvisorsdk.Record))
err = a.Engine.Sync2(new(Record))
if err != nil {
panic(err)
}
@@ -430,6 +441,11 @@ func (a *Ormer) createTable() {
panic(err)
}
err = a.Engine.Sync2(new(WebhookEvent))
if err != nil {
panic(err)
}
err = a.Engine.Sync2(new(VerificationRecord))
if err != nil {
panic(err)
@@ -460,6 +476,21 @@ func (a *Ormer) createTable() {
panic(err)
}
err = a.Engine.Sync2(new(Agent))
if err != nil {
panic(err)
}
err = a.Engine.Sync2(new(Server))
if err != nil {
panic(err)
}
err = a.Engine.Sync2(new(Entry))
if err != nil {
panic(err)
}
err = a.Engine.Sync2(new(Site))
if err != nil {
panic(err)
@@ -469,9 +500,4 @@ func (a *Ormer) createTable() {
if err != nil {
panic(err)
}
err = a.Engine.Sync2(new(Server))
if err != nil {
panic(err)
}
}

View File

@@ -35,7 +35,7 @@ func GetSession(owner string, offset, limit int, field, value, sortField, sortOr
session = session.And(fmt.Sprintf("%s like ?", util.SnakeString(field)), fmt.Sprintf("%%%s%%", value))
}
}
if sortField == "" || sortOrder == "" {
if sortField == "" || sortOrder == "" || !util.FilterField(sortField) {
sortField = "created_time"
}
if sortOrder == "ascend" {
@@ -66,7 +66,7 @@ func GetSessionForUser(owner string, offset, limit int, field, value, sortField,
session = session.And(fmt.Sprintf("%s like ?", util.SnakeString(field)), fmt.Sprintf("%%%s%%", value))
}
}
if sortField == "" || sortOrder == "" {
if sortField == "" || sortOrder == "" || !util.FilterField(sortField) {
sortField = "created_time"
}

View File

@@ -120,18 +120,6 @@ func checkPermissionValid(permission *Permission) error {
return nil
}
groupingPolicies, err := getGroupingPolicies(permission)
if err != nil {
return err
}
if len(groupingPolicies) > 0 {
_, err = enforcer.AddGroupingPolicies(groupingPolicies)
if err != nil {
return err
}
}
return nil
}
@@ -171,11 +159,6 @@ func UpdatePermission(id string, permission *Permission) (bool, error) {
}
if affected != 0 {
err = removeGroupingPolicies(oldPermission)
if err != nil {
return false, err
}
err = removePolicies(oldPermission)
if err != nil {
return false, err
@@ -191,11 +174,6 @@ func UpdatePermission(id string, permission *Permission) (bool, error) {
// }
// }
err = addGroupingPolicies(permission)
if err != nil {
return false, err
}
err = addPolicies(permission)
if err != nil {
return false, err
@@ -212,11 +190,6 @@ func AddPermission(permission *Permission) (bool, error) {
}
if affected != 0 {
err = addGroupingPolicies(permission)
if err != nil {
return false, err
}
err = addPolicies(permission)
if err != nil {
return false, err
@@ -241,11 +214,6 @@ func AddPermissions(permissions []*Permission) (bool, error) {
for _, permission := range permissions {
// add using for loop
if affected != 0 {
err = addGroupingPolicies(permission)
if err != nil {
return false, err
}
err = addPolicies(permission)
if err != nil {
return false, err
@@ -302,11 +270,6 @@ func DeletePermission(permission *Permission) (bool, error) {
}
if affected {
err = removeGroupingPolicies(permission)
if err != nil {
return false, err
}
err = removePolicies(permission)
if err != nil {
return false, err

View File

@@ -52,11 +52,9 @@ func getPermissionEnforcer(p *Permission, permissionIDs ...string) (*casbin.Enfo
}
policyFilter := xormadapter.Filter{
V5: policyFilterV5,
}
if !HasRoleDefinition(enforcer.GetModel()) {
policyFilter.Ptype = []string{"p"}
// Permission enforcers only persist p rules. Legacy g rows are rebuilt from roles at runtime.
Ptype: []string{"p"},
V5: policyFilterV5,
}
err = enforcer.LoadFilteredPolicy(policyFilter)
@@ -64,6 +62,12 @@ func getPermissionEnforcer(p *Permission, permissionIDs ...string) (*casbin.Enfo
return nil, err
}
// we can rebuild group policies in memory
err = loadRuntimeGroupingPolicies(enforcer, p, permissionIDs...)
if err != nil {
return nil, err
}
return enforcer, nil
}
@@ -141,13 +145,47 @@ func getPolicies(permission *Permission) [][]string {
return policies
}
func getRolesInRole(roleId string, visited map[string]struct{}) ([]*Role, error) {
type permissionRoleResolver struct {
rolesByOwner map[string][]*Role
roleByID map[string]*Role
}
func newPermissionRoleResolver() *permissionRoleResolver {
return &permissionRoleResolver{
rolesByOwner: map[string][]*Role{},
roleByID: map[string]*Role{},
}
}
func (r *permissionRoleResolver) getRoles(owner string) ([]*Role, error) {
if roles, ok := r.rolesByOwner[owner]; ok {
return roles, nil
}
roles, err := GetRoles(owner)
if err != nil {
return nil, err
}
r.rolesByOwner[owner] = roles
for _, role := range roles {
r.roleByID[role.GetId()] = role
}
return roles, nil
}
func (r *permissionRoleResolver) getRolesInRole(permissionOwner string, roleId string, visited map[string]struct{}) ([]*Role, error) {
if roleId == "*" {
roleId = util.GetId(permissionOwner, "*")
}
roleOwner, roleName, err := util.GetOwnerAndNameFromIdWithError(roleId)
if err != nil {
return []*Role{}, err
}
if roleName == "*" {
roles, err := GetRoles(roleOwner)
roles, err := r.getRoles(roleOwner)
if err != nil {
return []*Role{}, err
}
@@ -155,11 +193,13 @@ func getRolesInRole(roleId string, visited map[string]struct{}) ([]*Role, error)
return roles, nil
}
role, err := GetRole(roleId)
_, err = r.getRoles(roleOwner)
if err != nil {
return []*Role{}, err
}
role := r.roleByID[roleId]
if role == nil {
return []*Role{}, nil
}
@@ -168,55 +208,94 @@ func getRolesInRole(roleId string, visited map[string]struct{}) ([]*Role, error)
roles := []*Role{role}
for _, subRole := range role.Roles {
if _, ok := visited[subRole]; !ok {
r, err := getRolesInRole(subRole, visited)
subRoles, err := r.getRolesInRole(roleOwner, subRole, visited)
if err != nil {
return []*Role{}, err
}
roles = append(roles, r...)
roles = append(roles, subRoles...)
}
}
return roles, nil
}
func getGroupingPolicies(permission *Permission) ([][]string, error) {
var groupingPolicies [][]string
func getPermissionEnforcerTargets(permission *Permission, permissionIDs ...string) ([]*Permission, error) {
if len(permissionIDs) == 0 {
return []*Permission{permission}, nil
}
domainExist := len(permission.Domains) > 0
permissionId := permission.GetId()
for _, roleId := range permission.Roles {
visited := map[string]struct{}{}
if roleId == "*" {
roleId = util.GetId(permission.Owner, "*")
permissions := make([]*Permission, 0, len(permissionIDs))
visited := map[string]struct{}{}
for _, permissionID := range permissionIDs {
if _, ok := visited[permissionID]; ok {
continue
}
rolesInRole, err := getRolesInRole(roleId, visited)
targetPermission, err := GetPermission(permissionID)
if err != nil {
return nil, err
}
if targetPermission == nil {
return nil, fmt.Errorf("the permission: %s doesn't exist", permissionID)
}
for _, role := range rolesInRole {
roleId = role.GetId()
for _, subUser := range role.Users {
if domainExist {
for _, domain := range permission.Domains {
groupingPolicies = append(groupingPolicies, []string{subUser, roleId, domain, "", "", permissionId})
}
} else {
groupingPolicies = append(groupingPolicies, []string{subUser, roleId, "", "", "", permissionId})
}
permissions = append(permissions, targetPermission)
visited[permissionID] = struct{}{}
}
return permissions, nil
}
func newRuntimeGroupingPolicy(sub string, roleId string, domain string) []string {
return []string{sub, roleId, domain, "", "", ""}
}
func appendRuntimeGroupingPolicy(groupingPolicies *[][]string, visited map[string]struct{}, rule []string) {
// we can't use []string as key, so use null character
key := strings.Join(rule, "\x00")
if _, ok := visited[key]; ok {
return
}
*groupingPolicies = append(*groupingPolicies, rule)
visited[key] = struct{}{}
}
func getRuntimeGroupingPolicies(permissions []*Permission) ([][]string, error) {
var groupingPolicies [][]string
visitedPolicies := map[string]struct{}{}
roleResolver := newPermissionRoleResolver()
for _, permission := range permissions {
domainExist := len(permission.Domains) > 0
for _, roleId := range permission.Roles {
visited := map[string]struct{}{}
rolesInRole, err := roleResolver.getRolesInRole(permission.Owner, roleId, visited)
if err != nil {
return nil, err
}
for _, subRole := range role.Roles {
if domainExist {
for _, domain := range permission.Domains {
groupingPolicies = append(groupingPolicies, []string{subRole, roleId, domain, "", "", permissionId})
for _, role := range rolesInRole {
currentRoleID := role.GetId()
for _, subUser := range role.Users {
if domainExist {
for _, domain := range permission.Domains {
appendRuntimeGroupingPolicy(&groupingPolicies, visitedPolicies, newRuntimeGroupingPolicy(subUser, currentRoleID, domain))
}
} else {
appendRuntimeGroupingPolicy(&groupingPolicies, visitedPolicies, newRuntimeGroupingPolicy(subUser, currentRoleID, ""))
}
}
for _, subRole := range role.Roles {
if domainExist {
for _, domain := range permission.Domains {
appendRuntimeGroupingPolicy(&groupingPolicies, visitedPolicies, newRuntimeGroupingPolicy(subRole, currentRoleID, domain))
}
} else {
appendRuntimeGroupingPolicy(&groupingPolicies, visitedPolicies, newRuntimeGroupingPolicy(subRole, currentRoleID, ""))
}
} else {
groupingPolicies = append(groupingPolicies, []string{subRole, roleId, "", "", "", permissionId})
}
}
}
@@ -225,6 +304,35 @@ func getGroupingPolicies(permission *Permission) ([][]string, error) {
return groupingPolicies, nil
}
func loadRuntimeGroupingPolicies(enforcer *casbin.Enforcer, permission *Permission, permissionIDs ...string) error {
if !HasRoleDefinition(enforcer.GetModel()) {
return nil
}
targetPermissions, err := getPermissionEnforcerTargets(permission, permissionIDs...)
if err != nil {
return err
}
groupingPolicies, err := getRuntimeGroupingPolicies(targetPermissions)
if err != nil {
return err
}
if len(groupingPolicies) == 0 {
return nil
}
enforcer.EnableAutoSave(false)
defer enforcer.EnableAutoSave(true)
_, err = enforcer.AddGroupingPolicies(groupingPolicies)
if err != nil {
return err
}
return nil
}
func addPolicies(permission *Permission) error {
enforcer, err := getPermissionEnforcer(permission)
if err != nil {
@@ -249,68 +357,27 @@ func removePolicies(permission *Permission) error {
return err
}
func addGroupingPolicies(permission *Permission) error {
enforcer, err := getPermissionEnforcer(permission)
if err != nil {
return err
}
groupingPolicies, err := getGroupingPolicies(permission)
if err != nil {
return err
}
if len(groupingPolicies) > 0 {
_, err = enforcer.AddGroupingPolicies(groupingPolicies)
if err != nil {
return err
}
}
return nil
}
func removeGroupingPolicies(permission *Permission) error {
enforcer, err := getPermissionEnforcer(permission)
if err != nil {
return err
}
groupingPolicies, err := getGroupingPolicies(permission)
if err != nil {
return err
}
if len(groupingPolicies) > 0 {
_, err = enforcer.RemoveGroupingPolicies(groupingPolicies)
if err != nil {
return err
}
}
return nil
}
func Enforce(permission *Permission, request []string, permissionIds ...string) (bool, error) {
func Enforce(permission *Permission, request []interface{}, permissionIds ...string) (bool, error) {
enforcer, err := getPermissionEnforcer(permission, permissionIds...)
if err != nil {
return false, err
}
// type transformation
interfaceRequest := util.StringToInterfaceArray(request)
// Convert each element: JSON-object strings and maps become anonymous structs
// so Casbin can evaluate ABAC rules with dot-notation (e.g. r.sub.DivisionGuid).
interfaceRequest := util.InterfaceToEnforceArray(request)
return enforcer.Enforce(interfaceRequest...)
}
func BatchEnforce(permission *Permission, requests [][]string, permissionIds ...string) ([]bool, error) {
func BatchEnforce(permission *Permission, requests [][]interface{}, permissionIds ...string) ([]bool, error) {
enforcer, err := getPermissionEnforcer(permission, permissionIds...)
if err != nil {
return nil, err
}
// type transformation
interfaceRequests := util.StringToInterfaceArray2d(requests)
// Convert each element in every row for ABAC support.
interfaceRequests := util.InterfaceToEnforceArray2d(requests)
return enforcer.BatchEnforce(interfaceRequests)
}

View File

@@ -0,0 +1,314 @@
// Copyright 2024 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build !skipCi
package object
import (
"fmt"
"sync"
"testing"
"github.com/casdoor/casdoor/util"
)
type permissionRuleRecord struct {
Id int64 `xorm:"pk autoincr"`
Ptype string `xorm:"varchar(100) index not null default ''"`
V0 string `xorm:"varchar(100) index not null default ''"`
V1 string `xorm:"varchar(100) index not null default ''"`
V2 string `xorm:"varchar(100) index not null default ''"`
V3 string `xorm:"varchar(100) index not null default ''"`
V4 string `xorm:"varchar(100) index not null default ''"`
V5 string `xorm:"varchar(100) index not null default ''"`
}
func (permissionRuleRecord) TableName() string {
return "permission_rule"
}
var permissionRbacTestInit sync.Once
func initPermissionRbacTestDb(t *testing.T) {
t.Helper()
permissionRbacTestInit.Do(func() {
oldCreateDatabase := createDatabase
createDatabase = false
InitConfig()
createDatabase = oldCreateDatabase
})
}
func newPermissionRbacTestOwner(t *testing.T) string {
t.Helper()
initPermissionRbacTestDb(t)
owner := "rbac-dedup-" + util.GenerateId()
t.Cleanup(func() {
_, err := ormer.Engine.Where("v5 like ?", owner+"/%").Delete(&permissionRuleRecord{})
if err != nil {
t.Fatalf("failed to delete permission rules for owner %s: %v", owner, err)
}
_, err = ormer.Engine.Where("owner = ?", owner).Delete(&Permission{})
if err != nil {
t.Fatalf("failed to delete permissions for owner %s: %v", owner, err)
}
_, err = ormer.Engine.Where("owner = ?", owner).Delete(&Role{})
if err != nil {
t.Fatalf("failed to delete roles for owner %s: %v", owner, err)
}
})
return owner
}
func newTestPermission(owner string, name string, roleIDs ...string) *Permission {
return &Permission{
Owner: owner,
Name: name,
Roles: roleIDs,
Resources: []string{"data1"},
Actions: []string{"read"},
Effect: "Allow",
}
}
func getPermissionRulesByPermissionID(t *testing.T, permissionID string) []permissionRuleRecord {
t.Helper()
rules := make([]permissionRuleRecord, 0)
err := ormer.Engine.Where("v5 = ?", permissionID).Asc("id").Find(&rules)
if err != nil {
t.Fatalf("failed to query permission rules for %s: %v", permissionID, err)
}
return rules
}
func TestPermissionRuntimeGroupingIgnoresPersistedG(t *testing.T) {
owner := newPermissionRbacTestOwner(t)
role := &Role{
Owner: owner,
Name: "reader",
Users: []string{owner + "/alice"},
}
affected, err := AddRole(role)
if err != nil {
t.Fatalf("AddRole() error: %v", err)
}
if !affected {
t.Fatalf("expected AddRole to affect rows")
}
permission := newTestPermission(owner, "perm-reader", role.GetId())
affected, err = AddPermission(permission)
if err != nil {
t.Fatalf("AddPermission() error: %v", err)
}
if !affected {
t.Fatalf("expected AddPermission to affect rows")
}
rules := getPermissionRulesByPermissionID(t, permission.GetId())
if len(rules) != 1 || rules[0].Ptype != "p" {
t.Fatalf("expected exactly one persisted p rule, got %+v", rules)
}
allowed, err := Enforce(permission, []string{owner + "/alice", "data1", "read"})
if err != nil {
t.Fatalf("Enforce() for alice error: %v", err)
}
if !allowed {
t.Fatalf("expected alice to be allowed")
}
_, err = ormer.Engine.Insert(&permissionRuleRecord{
Ptype: "g",
V0: owner + "/mallory",
V1: role.GetId(),
V5: permission.GetId(),
})
if err != nil {
t.Fatalf("failed to insert legacy g rule: %v", err)
}
allowed, err = Enforce(permission, []string{owner + "/mallory", "data1", "read"})
if err != nil {
t.Fatalf("Enforce() for mallory error: %v", err)
}
if allowed {
t.Fatalf("expected legacy persisted g rule to be ignored")
}
}
func TestUpdateRoleUsesRuntimeGroupingAndOnlyRenameRewritesP(t *testing.T) {
owner := newPermissionRbacTestOwner(t)
role := &Role{
Owner: owner,
Name: "reader-old",
Users: []string{owner + "/alice"},
}
affected, err := AddRole(role)
if err != nil {
t.Fatalf("AddRole() error: %v", err)
}
if !affected {
t.Fatalf("expected AddRole to affect rows")
}
permission := newTestPermission(owner, "perm-reader", role.GetId())
affected, err = AddPermission(permission)
if err != nil {
t.Fatalf("AddPermission() error: %v", err)
}
if !affected {
t.Fatalf("expected AddPermission to affect rows")
}
rulesBefore := getPermissionRulesByPermissionID(t, permission.GetId())
updatedRole := *role
updatedRole.Users = []string{owner + "/bob"}
affected, err = UpdateRole(role.GetId(), &updatedRole)
if err != nil {
t.Fatalf("UpdateRole() for membership change error: %v", err)
}
if !affected {
t.Fatalf("expected UpdateRole membership change to affect rows")
}
rulesAfterMembershipChange := getPermissionRulesByPermissionID(t, permission.GetId())
if fmt.Sprintf("%#v", rulesBefore) != fmt.Sprintf("%#v", rulesAfterMembershipChange) {
t.Fatalf("expected membership change to keep persisted permission rules unchanged")
}
allowed, err := Enforce(permission, []string{owner + "/alice", "data1", "read"})
if err != nil {
t.Fatalf("Enforce() for alice after membership change error: %v", err)
}
if allowed {
t.Fatalf("expected alice to lose permission after membership change")
}
allowed, err = Enforce(permission, []string{owner + "/bob", "data1", "read"})
if err != nil {
t.Fatalf("Enforce() for bob after membership change error: %v", err)
}
if !allowed {
t.Fatalf("expected bob to gain permission after membership change")
}
renamedRole := updatedRole
renamedRole.Name = "reader-new"
affected, err = UpdateRole(updatedRole.GetId(), &renamedRole)
if err != nil {
t.Fatalf("UpdateRole() for rename error: %v", err)
}
if !affected {
t.Fatalf("expected UpdateRole rename to affect rows")
}
updatedPermission, err := GetPermission(permission.GetId())
if err != nil {
t.Fatalf("GetPermission() error: %v", err)
}
if len(updatedPermission.Roles) != 1 || updatedPermission.Roles[0] != renamedRole.GetId() {
t.Fatalf("expected permission role reference to be renamed")
}
rulesAfterRename := getPermissionRulesByPermissionID(t, permission.GetId())
if len(rulesAfterRename) != 1 || rulesAfterRename[0].Ptype != "p" || rulesAfterRename[0].V0 != renamedRole.GetId() {
t.Fatalf("expected rename to rebuild persisted p rule with new role id, got %+v", rulesAfterRename)
}
allowed, err = Enforce(updatedPermission, []string{owner + "/bob", "data1", "read"})
if err != nil {
t.Fatalf("Enforce() for bob after rename error: %v", err)
}
if !allowed {
t.Fatalf("expected bob to stay allowed after role rename")
}
}
// issue 5346
func TestPermissionEnforcerDeduplicatesRuntimeGroupingPoliciesAcross1000Permissions(t *testing.T) {
owner := newPermissionRbacTestOwner(t)
const (
permissionCount = 1000
userCount = 1000
)
users := make([]string, 0, userCount)
for i := range userCount {
users = append(users, fmt.Sprintf("%s/user-%04d", owner, i))
}
role := &Role{
Owner: owner,
Name: "shared-role",
Users: users,
}
affected, err := AddRole(role)
if err != nil {
t.Fatalf("AddRole() error: %v", err)
}
if !affected {
t.Fatalf("expected AddRole to affect rows")
}
permissions := make([]*Permission, 0, permissionCount)
permissionIDs := make([]string, 0, permissionCount)
for i := 0; i < permissionCount; i++ {
permission := newTestPermission(owner, fmt.Sprintf("perm-%04d", i), role.GetId())
permissions = append(permissions, permission)
permissionIDs = append(permissionIDs, permission.GetId())
}
affected, err = AddPermissions(permissions)
if err != nil {
t.Fatalf("AddPermissions() error: %v", err)
}
if !affected {
t.Fatalf("expected AddPermissions to affect rows")
}
enforcer, err := getPermissionEnforcer(permissions[0], permissionIDs...)
if err != nil {
t.Fatalf("getPermissionEnforcer() error: %v", err)
}
if len(enforcer.GetPolicy()) != permissionCount {
t.Fatalf("expected %d p rules in merged enforcer, got %d", permissionCount, len(enforcer.GetPolicy()))
}
if len(enforcer.GetGroupingPolicy()) != userCount {
t.Fatalf("expected deduplicated runtime g rules to stay at %d, got %d", userCount, len(enforcer.GetGroupingPolicy()))
}
allowed, err := enforcer.Enforce(users[userCount-1], "data1", "read")
if err != nil {
t.Fatalf("Enforce() in 1000x1000 scenario error: %v", err)
}
if !allowed {
t.Fatalf("expected last user to be allowed in 1000x1000 scenario")
}
}

View File

@@ -257,6 +257,26 @@ func (product *Product) getProvider(providerName string) (*Provider, error) {
return provider, nil
}
func BuyProduct(id string, user *User, providerName, pricingName, planName, host, paymentEnv string, customPrice float64, lang string) (payment *Payment, attachInfo map[string]interface{}, err error) {
owner, productName, err := util.GetOwnerAndNameFromIdWithError(id)
if err != nil {
return nil, nil, err
}
order, err := PlaceOrder(owner, []ProductInfo{{
Name: productName,
Price: customPrice,
Quantity: 1,
PricingName: pricingName,
PlanName: planName,
}}, user)
if err != nil {
return nil, nil, err
}
return PayOrder(providerName, host, paymentEnv, order, lang)
}
func ExtendProductWithProviders(product *Product) error {
if product == nil {
return nil

View File

@@ -17,6 +17,8 @@ package object
import (
"errors"
"fmt"
"net/http"
"net/url"
"regexp"
"strings"
@@ -24,6 +26,7 @@ import (
"github.com/casdoor/casdoor/i18n"
"github.com/casdoor/casdoor/idp"
"github.com/casdoor/casdoor/idv"
"github.com/casdoor/casdoor/log"
"github.com/casdoor/casdoor/pp"
"github.com/casdoor/casdoor/util"
"github.com/xorm-io/core"
@@ -47,6 +50,7 @@ type Provider struct {
CustomAuthUrl string `xorm:"varchar(200)" json:"customAuthUrl"`
CustomTokenUrl string `xorm:"varchar(200)" json:"customTokenUrl"`
CustomUserInfoUrl string `xorm:"varchar(200)" json:"customUserInfoUrl"`
CustomLogoutUrl string `xorm:"varchar(200)" json:"customLogoutUrl"`
CustomLogo string `xorm:"varchar(200)" json:"customLogo"`
Scopes string `xorm:"varchar(100)" json:"scopes"`
UserMapping map[string]string `xorm:"varchar(500)" json:"userMapping"`
@@ -80,6 +84,8 @@ type Provider struct {
ProviderUrl string `xorm:"varchar(200)" json:"providerUrl"`
EnableProxy bool `json:"enableProxy"`
EnablePkce bool `json:"enablePkce"`
State string `xorm:"varchar(100)" json:"state"`
}
func GetMaskedProvider(provider *Provider, isMaskEnabled bool) *Provider {
@@ -231,6 +237,10 @@ func UpdateProvider(id string, provider *Provider) (bool, error) {
}
}
if err := fillOpenClawProviderDefaults(provider); err != nil {
return false, err
}
if name != provider.Name {
err := providerChangeTrigger(name, provider.Name)
if err != nil {
@@ -256,6 +266,10 @@ func UpdateProvider(id string, provider *Provider) (bool, error) {
return false, err
}
if affected != 0 {
refreshLogProviderRuntime(util.GetId(owner, name), provider)
}
return affected != 0, nil
}
@@ -272,11 +286,19 @@ func AddProvider(provider *Provider) (bool, error) {
}
}
if err := fillOpenClawProviderDefaults(provider); err != nil {
return false, err
}
affected, err := ormer.Engine.Insert(provider)
if err != nil {
return false, err
}
if affected != 0 {
refreshLogProviderRuntime("", provider)
}
return affected != 0, nil
}
@@ -286,6 +308,10 @@ func DeleteProvider(provider *Provider) (bool, error) {
return false, err
}
if affected != 0 {
stopLogProviderRuntime(provider.GetId())
}
return affected != 0, nil
}
@@ -610,3 +636,96 @@ func GetIdvProviderFromProvider(provider *Provider) idv.IdvProvider {
}
return idv.GetIdvProvider(provider.Type, provider.ClientId, provider.ClientSecret, provider.Endpoint)
}
func GetLogProviderFromProvider(provider *Provider) (log.LogProvider, error) {
if provider.Category != "Log" {
return nil, fmt.Errorf("provider %s category is not Log", provider.Name)
}
if provider.Type == "Casdoor Permission Log" {
return log.NewPermissionLogProvider(provider.Name, func(owner, createdTime, providerName, message string) error {
name := log.GenerateEntryName()
entry := &Entry{
Owner: owner,
Name: name,
CreatedTime: createdTime,
UpdatedTime: createdTime,
DisplayName: name,
Provider: providerName,
Application: CasdoorApplication,
Message: message,
}
_, err := AddEntry(entry)
return err
}), nil
}
if provider.Type == "Agent" && provider.SubType == "OpenClaw" {
providerName := provider.Name
return log.NewOpenClawProvider(providerName, func(entryType, message, clientIp, userAgent string) error {
// Bypass: metrics entries are temporarily not persisted to the database.
if entryType == "metrics" {
return nil
}
name := log.GenerateEntryName()
currentTime := util.GetCurrentTime()
entry := &Entry{
Owner: CasdoorOrganization,
Name: name,
CreatedTime: currentTime,
UpdatedTime: currentTime,
DisplayName: name,
Provider: providerName,
Type: entryType,
ClientIp: clientIp,
UserAgent: userAgent,
Message: message,
}
_, err := AddEntry(entry)
return err
}), nil
}
return log.GetLogProvider(provider.Type, provider.Host, provider.Port, provider.Title)
}
// InvokeCustomProviderLogout iterates through the application's Custom OAuth2 providers
// and calls their logout endpoint (if configured) to terminate the upstream session.
func InvokeCustomProviderLogout(application *Application, accessToken string) {
if application == nil {
return
}
for _, providerItem := range application.Providers {
provider := providerItem.Provider
if provider == nil || provider.Category != "OAuth" || !strings.HasPrefix(provider.Type, "Custom") {
continue
}
if provider.CustomLogoutUrl == "" {
continue
}
go callProviderLogoutUrl(provider, accessToken)
}
}
// callProviderLogoutUrl sends a logout/token-revocation request to the provider's logout URL.
// Supports RFC 7009 token revocation and Keycloak-style end_session endpoints.
func callProviderLogoutUrl(provider *Provider, accessToken string) {
params := url.Values{}
params.Set("token", accessToken)
params.Set("client_id", provider.ClientId)
params.Set("client_secret", provider.ClientSecret)
resp, err := http.PostForm(provider.CustomLogoutUrl, params)
if err != nil {
util.LogWarning(nil, "InvokeCustomProviderLogout: failed to call logout URL %s for provider %s: %v", provider.CustomLogoutUrl, provider.Name, err)
return
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
util.LogWarning(nil, "InvokeCustomProviderLogout: logout URL %s returned status %d for provider %s", provider.CustomLogoutUrl, resp.StatusCode, provider.Name)
}
}

View File

@@ -24,7 +24,6 @@ import (
"github.com/beego/beego/v2/server/web/context"
"github.com/casdoor/casdoor/conf"
"github.com/casdoor/casdoor/util"
"github.com/casvisor/casvisor-go-sdk/casvisorsdk"
)
var (
@@ -38,7 +37,25 @@ func init() {
}
type Record struct {
casvisorsdk.Record
Id int `xorm:"int notnull pk autoincr" json:"id"`
Owner string `xorm:"varchar(100) index" json:"owner"`
Name string `xorm:"varchar(100) index" json:"name"`
CreatedTime string `xorm:"varchar(100)" json:"createdTime"`
Organization string `xorm:"varchar(100)" json:"organization"`
ClientIp string `xorm:"varchar(100)" json:"clientIp"`
User string `xorm:"varchar(100)" json:"user"`
Method string `xorm:"varchar(100)" json:"method"`
RequestUri string `xorm:"varchar(1000)" json:"requestUri"`
Action string `xorm:"varchar(1000)" json:"action"`
Language string `xorm:"varchar(100)" json:"language"`
Object string `xorm:"mediumtext" json:"object"`
Response string `xorm:"mediumtext" json:"response"`
StatusCode int `json:"statusCode"`
IsTriggered bool `json:"isTriggered"`
}
type Response struct {
@@ -52,7 +69,7 @@ func maskPassword(recordString string) string {
return passwordRegex.ReplaceAllString(recordString, "\"password\":\"***\"")
}
func NewRecord(ctx *context.Context) (*casvisorsdk.Record, error) {
func NewRecord(ctx *context.Context) (*Record, error) {
clientIp := strings.Replace(util.GetClientIpFromRequest(ctx.Request), ": ", "", -1)
action := strings.Replace(ctx.Request.URL.Path, "/api/", "", -1)
if strings.HasPrefix(action, "notify-payment") {
@@ -100,7 +117,7 @@ func NewRecord(ctx *context.Context) (*casvisorsdk.Record, error) {
}
languageCode := conf.GetLanguage(language)
record := casvisorsdk.Record{
record := Record{
Name: util.GenerateId(),
CreatedTime: util.GetCurrentTime(),
ClientIp: clientIp,
@@ -117,12 +134,12 @@ func NewRecord(ctx *context.Context) (*casvisorsdk.Record, error) {
return &record, nil
}
func addRecord(record *casvisorsdk.Record) (int64, error) {
func addRecord(record *Record) (int64, error) {
affected, err := ormer.Engine.Insert(record)
return affected, err
}
func AddRecord(record *casvisorsdk.Record) bool {
func AddRecord(record *Record) bool {
if logPostOnly {
if record.Method == "GET" {
return false
@@ -143,30 +160,21 @@ func AddRecord(record *casvisorsdk.Record) bool {
fmt.Println(errWebhook)
}
if casvisorsdk.GetClient() == nil {
affected, err := addRecord(record)
if err != nil {
panic(err)
}
return affected != 0
}
affected, err := casvisorsdk.AddRecord(record)
affected, err := addRecord(record)
if err != nil {
fmt.Printf("AddRecord() error: %s", err.Error())
panic(err)
}
return affected
return affected != 0
}
func GetRecordCount(field, value string, filterRecord *casvisorsdk.Record) (int64, error) {
func GetRecordCount(field, value string, filterRecord *Record) (int64, error) {
session := GetSession("", -1, -1, field, value, "", "")
return session.Count(filterRecord)
}
func GetRecords() ([]*casvisorsdk.Record, error) {
records := []*casvisorsdk.Record{}
func GetRecords() ([]*Record, error) {
records := []*Record{}
err := ormer.Engine.Desc("id").Find(&records)
if err != nil {
return records, err
@@ -175,8 +183,8 @@ func GetRecords() ([]*casvisorsdk.Record, error) {
return records, nil
}
func GetPaginationRecords(offset, limit int, field, value, sortField, sortOrder string, filterRecord *casvisorsdk.Record) ([]*casvisorsdk.Record, error) {
records := []*casvisorsdk.Record{}
func GetPaginationRecords(offset, limit int, field, value, sortField, sortOrder string, filterRecord *Record) ([]*Record, error) {
records := []*Record{}
if sortField == "" || sortOrder == "" {
sortField = "id"
@@ -192,8 +200,8 @@ func GetPaginationRecords(offset, limit int, field, value, sortField, sortOrder
return records, nil
}
func GetRecordsByField(record *casvisorsdk.Record) ([]*casvisorsdk.Record, error) {
records := []*casvisorsdk.Record{}
func GetRecordsByField(record *Record) ([]*Record, error) {
records := []*Record{}
err := ormer.Engine.Find(&records, record)
if err != nil {
return records, err
@@ -202,8 +210,8 @@ func GetRecordsByField(record *casvisorsdk.Record) ([]*casvisorsdk.Record, error
return records, nil
}
func CopyRecord(record *casvisorsdk.Record) *casvisorsdk.Record {
res := &casvisorsdk.Record{
func CopyRecord(record *Record) *Record {
res := &Record{
Owner: record.Owner,
Name: record.Name,
CreatedTime: record.CreatedTime,
@@ -249,7 +257,7 @@ func getFilteredWebhooks(webhooks []*Webhook, organization string, action string
return res
}
func addWebhookRecord(webhook *Webhook, record *casvisorsdk.Record, statusCode int, respBody string, sendError error) error {
func addWebhookRecord(webhook *Webhook, record *Record, statusCode int, respBody string, sendError error) error {
if statusCode == 200 {
return nil
}
@@ -258,7 +266,7 @@ func addWebhookRecord(webhook *Webhook, record *casvisorsdk.Record, statusCode i
respBody = respBody[0:300]
}
webhookRecord := &casvisorsdk.Record{
webhookRecord := &Record{
Owner: record.Owner,
Name: util.GenerateId(),
CreatedTime: util.GetCurrentTime(),
@@ -304,7 +312,7 @@ func filterRecordObject(object string, objectFields []string) string {
return util.StructToJson(filteredObject)
}
func SendWebhooks(record *casvisorsdk.Record) error {
func SendWebhooks(record *Record) error {
webhooks, err := getWebhooksByOrganization("")
if err != nil {
return err
@@ -324,27 +332,26 @@ func SendWebhooks(record *casvisorsdk.Record) error {
if webhook.IsUserExtended {
user, err = getUser(record.Organization, record.User)
if err != nil {
errs = append(errs, err)
errs = append(errs, fmt.Errorf("webhook %s: failed to get user: %w", webhook.GetId(), err))
continue
}
user, err = GetMaskedUser(user, false, err)
if err != nil {
errs = append(errs, err)
errs = append(errs, fmt.Errorf("webhook %s: failed to mask user: %w", webhook.GetId(), err))
continue
}
}
statusCode, respBody, err := sendWebhook(webhook, &record2, user)
// Create webhook event for tracking and retry
_, err = CreateWebhookEventFromRecord(webhook, &record2, user)
if err != nil {
errs = append(errs, err)
}
err = addWebhookRecord(webhook, &record2, statusCode, respBody, err)
if err != nil {
errs = append(errs, err)
errs = append(errs, fmt.Errorf("webhook %s: failed to create event: %w", webhook.GetId(), err))
continue
}
// The webhook will be delivered by the background worker
// This provides automatic retry and replay capability
}
if len(errs) > 0 {

View File

@@ -1,50 +0,0 @@
// Copyright 2023 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package object
import (
"strings"
"github.com/casvisor/casvisor-go-sdk/casvisorsdk"
)
func getCasvisorApplication() *Application {
applications, err := GetApplications("admin")
if err != nil {
panic(err)
}
for _, application := range applications {
if strings.Contains(strings.ToLower(application.Name), "casvisor-my") {
return application
}
}
return nil
}
func InitCasvisorConfig() {
application := getCasvisorApplication()
if application == nil {
return
}
casvisorEndpoint := application.HomepageUrl
clientId := application.ClientId
clientSecret := application.ClientSecret
casdoorOrganization := application.Organization
casdoorApplication := application.Name
casvisorsdk.InitConfig(casvisorEndpoint, clientId, clientSecret, casdoorOrganization, casdoorApplication)
}

View File

@@ -98,40 +98,23 @@ func UpdateRole(id string, role *Role) (bool, error) {
return false, nil
}
visited := map[string]struct{}{}
permissions, err := GetPermissionsByRole(id)
if err != nil {
return false, err
}
for _, permission := range permissions {
removeGroupingPolicies(permission)
removePolicies(permission)
visited[permission.GetId()] = struct{}{}
}
ancestorRoles, err := GetAncestorRoles(id)
if err != nil {
return false, err
}
for _, r := range ancestorRoles {
permissions, err := GetPermissionsByRole(r.GetId())
renameRole := name != role.Name
oldPermissions := []*Permission{}
if renameRole {
oldPermissions, err = GetPermissionsByRole(id)
if err != nil {
return false, err
}
for _, permission := range permissions {
permissionId := permission.GetId()
if _, ok := visited[permissionId]; !ok {
removeGroupingPolicies(permission)
visited[permissionId] = struct{}{}
for _, permission := range oldPermissions {
err = removePolicies(permission)
if err != nil {
return false, err
}
}
}
if name != role.Name {
if renameRole {
err := roleChangeTrigger(name, role.Name)
if err != nil {
return false, err
@@ -143,47 +126,16 @@ func UpdateRole(id string, role *Role) (bool, error) {
return false, err
}
visited = map[string]struct{}{}
newRoleID := role.GetId()
permissions, err = GetPermissionsByRole(newRoleID)
if err != nil {
return false, err
}
for _, permission := range permissions {
err = addGroupingPolicies(permission)
if err != nil {
return false, err
}
err = addPolicies(permission)
if err != nil {
return false, err
}
visited[permission.GetId()] = struct{}{}
}
ancestorRoles, err = GetAncestorRoles(newRoleID)
if err != nil {
return false, err
}
for _, r := range ancestorRoles {
permissions, err := GetPermissionsByRole(r.GetId())
if renameRole && affected != 0 {
permissions, err := GetPermissionsByRole(role.GetId())
if err != nil {
return false, err
}
for _, permission := range permissions {
permissionId := permission.GetId()
if _, ok := visited[permissionId]; !ok {
err = addGroupingPolicies(permission)
if err != nil {
return false, err
}
visited[permissionId] = struct{}{}
err = addPolicies(permission)
if err != nil {
return false, err
}
}
}

View File

@@ -31,8 +31,8 @@ import (
"time"
"github.com/beevik/etree"
"github.com/casdoor/casdoor/util"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
saml "github.com/russellhaering/gosaml2"
dsig "github.com/russellhaering/goxmldsig"
)
@@ -50,7 +50,7 @@ func NewSamlResponse(application *Application, user *User, host string, certific
samlResponse.CreateAttr("xmlns:saml", "urn:oasis:names:tc:SAML:2.0:assertion")
samlResponse.CreateAttr("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance")
samlResponse.CreateAttr("xmlns:xs", "http://www.w3.org/2001/XMLSchema")
arId := uuid.New()
arId := util.GenerateUUID()
samlResponse.CreateAttr("ID", fmt.Sprintf("_%s", arId))
samlResponse.CreateAttr("Version", "2.0")
@@ -65,7 +65,7 @@ func NewSamlResponse(application *Application, user *User, host string, certific
assertion.CreateAttr("xmlns:saml", "urn:oasis:names:tc:SAML:2.0:assertion")
assertion.CreateAttr("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance")
assertion.CreateAttr("xmlns:xs", "http://www.w3.org/2001/XMLSchema")
assertion.CreateAttr("ID", fmt.Sprintf("_%s", uuid.New()))
assertion.CreateAttr("ID", fmt.Sprintf("_%s", util.GenerateUUID()))
assertion.CreateAttr("Version", "2.0")
assertion.CreateAttr("IssueInstant", now)
assertion.CreateElement("saml:Issuer").SetText(host)
@@ -100,7 +100,7 @@ func NewSamlResponse(application *Application, user *User, host string, certific
}
authnStatement := assertion.CreateElement("saml:AuthnStatement")
authnStatement.CreateAttr("AuthnInstant", now)
authnStatement.CreateAttr("SessionIndex", fmt.Sprintf("_%s", uuid.New()))
authnStatement.CreateAttr("SessionIndex", fmt.Sprintf("_%s", util.GenerateUUID()))
authnStatement.CreateAttr("SessionNotOnOrAfter", expireTime)
authnStatement.CreateElement("saml:AuthnContext").CreateElement("saml:AuthnContextClassRef").SetText("urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport")
@@ -385,7 +385,7 @@ func GetSamlResponse(application *Application, user *User, samlRequest string, h
}
if application.EnableSamlC14n10 {
ctx.Canonicalizer = dsig.MakeC14N10ExclusiveCanonicalizerWithPrefixList("")
ctx.Canonicalizer = dsig.MakeC14N10ExclusiveCanonicalizerWithPrefixList("xs")
}
// signedXML, err := ctx.SignEnvelopedLimix(samlResponse)
@@ -460,7 +460,7 @@ func NewSamlResponse11(application *Application, user *User, requestID string, h
samlResponse.CreateAttr("MajorVersion", "1")
samlResponse.CreateAttr("MinorVersion", "1")
responseID := uuid.New()
responseID := util.GenerateUUID()
samlResponse.CreateAttr("ResponseID", fmt.Sprintf("_%s", responseID))
samlResponse.CreateAttr("InResponseTo", requestID)
@@ -476,7 +476,7 @@ func NewSamlResponse11(application *Application, user *User, requestID string, h
assertion.CreateAttr("xmlns:saml", "urn:oasis:names:tc:SAML:1.0:assertion")
assertion.CreateAttr("MajorVersion", "1")
assertion.CreateAttr("MinorVersion", "1")
assertion.CreateAttr("AssertionID", uuid.New().String())
assertion.CreateAttr("AssertionID", util.GenerateUUID())
assertion.CreateAttr("Issuer", host)
assertion.CreateAttr("IssueInstant", now)

View File

@@ -21,13 +21,10 @@ import (
"encoding/pem"
"fmt"
"net/url"
"regexp"
"strings"
"github.com/casdoor/casdoor/idp"
"github.com/mitchellh/mapstructure"
"github.com/casdoor/casdoor/i18n"
"github.com/casdoor/casdoor/idp"
"github.com/mitchellh/mapstructure"
saml2 "github.com/russellhaering/gosaml2"
dsig "github.com/russellhaering/goxmldsig"
)
@@ -113,7 +110,7 @@ func GenerateSamlRequest(id, relayState, host, lang string) (auth string, method
func buildSp(provider *Provider, samlResponse string, host string) (*saml2.SAMLServiceProvider, error) {
_, origin := getOriginFromHost(host)
certStore, err := buildSpCertificateStore(provider, samlResponse)
certStore, err := buildSpCertificateStore(provider)
if err != nil {
return nil, err
}
@@ -152,15 +149,10 @@ func buildSpKeyStore() (dsig.X509KeyStore, error) {
}, nil
}
func buildSpCertificateStore(provider *Provider, samlResponse string) (certStore dsig.MemoryX509CertificateStore, err error) {
certEncodedData := ""
if samlResponse != "" {
certEncodedData, err = getCertificateFromSamlResponse(samlResponse, provider.Type)
if err != nil {
return
}
} else if provider.IdP != "" {
certEncodedData = provider.IdP
func buildSpCertificateStore(provider *Provider) (certStore dsig.MemoryX509CertificateStore, err error) {
certEncodedData := provider.IdP
if certEncodedData == "" {
return dsig.MemoryX509CertificateStore{}, fmt.Errorf("the IdP certificate of provider: %s is empty", provider.Name)
}
var certData []byte
@@ -186,30 +178,3 @@ func buildSpCertificateStore(provider *Provider, samlResponse string) (certStore
}
return certStore, nil
}
func getCertificateFromSamlResponse(samlResponse string, providerType string) (string, error) {
de, err := base64.StdEncoding.DecodeString(samlResponse)
if err != nil {
return "", err
}
var (
expression string
deStr = strings.Replace(string(de), "\n", "", -1)
tagMap = map[string]string{
"Aliyun IDaaS": "ds",
"Keycloak": "dsig",
}
)
tag := tagMap[providerType]
if tag == "" {
// <ds:X509Certificate>...</ds:X509Certificate>
// <dsig:X509Certificate>...</dsig:X509Certificate>
// <X509Certificate>...</X509Certificate>
// ...
expression = "<[^>]*:?X509Certificate>([\\s\\S]*?)<[^>]*:?X509Certificate>"
} else {
expression = fmt.Sprintf("<%s:X509Certificate>([\\s\\S]*?)</%s:X509Certificate>", tag, tag)
}
res := regexp.MustCompile(expression).FindStringSubmatch(deStr)
return res[1], nil
}

View File

@@ -16,11 +16,19 @@ package object
import (
"fmt"
"slices"
"github.com/casdoor/casdoor/mcp"
"github.com/casdoor/casdoor/util"
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/xorm-io/core"
)
type Tool struct {
mcpsdk.Tool
IsAllowed bool `json:"isAllowed"`
}
type Server struct {
Owner string `xorm:"varchar(100) notnull pk" json:"owner"`
Name string `xorm:"varchar(100) notnull pk" json:"name"`
@@ -28,8 +36,10 @@ type Server struct {
UpdatedTime string `xorm:"varchar(100)" json:"updatedTime"`
DisplayName string `xorm:"varchar(100)" json:"displayName"`
Url string `xorm:"varchar(500)" json:"url"`
Application string `xorm:"varchar(100)" json:"application"`
Url string `xorm:"varchar(500)" json:"url"`
Token string `xorm:"varchar(500)" json:"-"`
Application string `xorm:"varchar(100)" json:"application"`
Tools []*Tool `xorm:"mediumtext" json:"tools"`
}
func GetServers(owner string) ([]*Server, error) {
@@ -62,15 +72,23 @@ func GetServer(id string) (*Server, error) {
func UpdateServer(id string, server *Server) (bool, error) {
owner, name := util.GetOwnerAndNameFromIdNoCheck(id)
if s, err := getServer(owner, name); err != nil {
oldServer, err := getServer(owner, name)
if err != nil {
return false, err
} else if s == nil {
}
if oldServer == nil {
return false, nil
}
if server.Token == "" {
server.Token = oldServer.Token
}
server.UpdatedTime = util.GetCurrentTime()
_, err := ormer.Engine.ID(core.PK{owner, name}).AllCols().Update(server)
_ = syncServerTools(server)
_, err = ormer.Engine.ID(core.PK{owner, name}).AllCols().Update(server)
if err != nil {
return false, err
}
@@ -78,6 +96,79 @@ func UpdateServer(id string, server *Server) (bool, error) {
return true, nil
}
func SyncMcpTool(id string, server *Server, isCleared bool) (bool, error) {
owner, name := util.GetOwnerAndNameFromIdNoCheck(id)
if isCleared {
server.Tools = nil
server.UpdatedTime = util.GetCurrentTime()
_, err := ormer.Engine.ID(core.PK{owner, name}).Cols("tools", "updated_time").Update(server)
if err != nil {
return false, err
}
return true, nil
}
oldServer, err := getServer(owner, name)
if err != nil {
return false, err
}
if oldServer == nil {
return false, nil
}
if server.Token == "" {
server.Token = oldServer.Token
}
server.UpdatedTime = util.GetCurrentTime()
err = syncServerTools(server)
if err != nil {
return false, err
}
_, err = ormer.Engine.ID(core.PK{owner, name}).AllCols().Update(server)
if err != nil {
return false, err
}
return true, nil
}
func syncServerTools(server *Server) error {
oldTools := server.Tools
if oldTools == nil {
oldTools = []*Tool{}
}
tools, err := mcp.GetServerTools(server.Owner, server.Name, server.Url, server.Token)
if err != nil {
return err
}
var newTools []*Tool
for _, tool := range tools {
oldToolIndex := slices.IndexFunc(oldTools, func(oldTool *Tool) bool {
return oldTool.Name == tool.Name
})
isAllowed := true
if oldToolIndex != -1 {
isAllowed = oldTools[oldToolIndex].IsAllowed
}
newTool := Tool{
Tool: *tool,
IsAllowed: isAllowed,
}
newTools = append(newTools, &newTool)
}
server.Tools = newTools
return nil
}
func AddServer(server *Server) (bool, error) {
affected, err := ormer.Engine.Insert(server)
if err != nil {

View File

@@ -16,9 +16,7 @@ package object
import (
"fmt"
"math/rand"
"strings"
"time"
"github.com/casdoor/casdoor/util"
"github.com/xorm-io/core"
@@ -234,8 +232,7 @@ func (site *Site) GetChallengeMap() map[string]string {
func (site *Site) GetHost() string {
if len(site.Hosts) != 0 {
rand.Seed(time.Now().UnixNano())
return site.Hosts[rand.Intn(len(site.Hosts))]
return site.Hosts[util.RandomIntn(len(site.Hosts))]
}
if site.Host != "" {

View File

@@ -21,6 +21,7 @@ import (
"fmt"
"strings"
"github.com/casdoor/casdoor/util"
"github.com/go-sql-driver/mysql"
"golang.org/x/crypto/ssh"
)
@@ -122,6 +123,10 @@ func (p *DatabaseSyncerProvider) AddUser(user *OriginalUser) (bool, error) {
// UpdateUser updates an existing user in the database
func (p *DatabaseSyncerProvider) UpdateUser(user *OriginalUser) (bool, error) {
key := p.Syncer.getTargetTablePrimaryKey()
if !util.FilterSQLIdentifier(key) {
return false, fmt.Errorf("object.UpdateUser: invalid primary key column name: %s", key)
}
m := p.Syncer.getMapFromOriginalUser(user)
pkValue := m[key]
delete(m, key)

View File

@@ -43,7 +43,8 @@ type Token struct {
CodeChallenge string `xorm:"varchar(100)" json:"codeChallenge"`
CodeIsUsed bool `json:"codeIsUsed"`
CodeExpireIn int64 `json:"codeExpireIn"`
Resource string `xorm:"varchar(255)" json:"resource"` // RFC 8707 Resource Indicator
Resource string `xorm:"varchar(255)" json:"resource"` // RFC 8707 Resource Indicator
DPoPJkt string `xorm:"varchar(255) 'dpop_jkt'" json:"dPoPJkt"` // RFC 9449 DPoP JWK thumbprint binding
}
func GetTokenCount(owner, organization, field, value string) (int64, error) {
@@ -235,3 +236,9 @@ func ExpireTokenByUser(owner, username string) (bool, error) {
return affected != 0, nil
}
// updateTokenDPoP updates the token_type and dpop_jkt columns for DPoP binding (RFC 9449).
func updateTokenDPoP(token *Token) error {
_, err := ormer.Engine.ID(core.PK{token.Owner, token.Name}).Cols("token_type", "dpop_jkt").Update(token)
return err
}

View File

@@ -21,7 +21,7 @@ import (
"encoding/pem"
"encoding/xml"
"fmt"
"math/rand"
"math"
"strings"
"sync"
"time"
@@ -273,7 +273,7 @@ func GenerateCasToken(userId string, service string) (string, error) {
}
}
st := fmt.Sprintf("ST-%d", rand.Int())
st := fmt.Sprintf("ST-%d", util.RandomIntn(math.MaxInt))
stToServiceResponse.Store(st, &CasAuthenticationSuccessWrapper{
AuthenticationSuccess: &authenticationSuccess,
Service: service,

157
object/token_dpop.go Normal file
View File

@@ -0,0 +1,157 @@
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package object
import (
"crypto"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/go-jose/go-jose/v4"
"github.com/golang-jwt/jwt/v5"
)
const dpopMaxAgeSeconds = 300
// DPoPProofClaims represents the payload claims of a DPoP proof JWT (RFC 9449).
type DPoPProofClaims struct {
Jti string `json:"jti"`
Htm string `json:"htm"`
Htu string `json:"htu"`
Ath string `json:"ath,omitempty"`
jwt.RegisteredClaims
}
// ValidateDPoPProof validates a DPoP proof JWT as specified in RFC 9449.
//
// - proofToken: the compact-serialized DPoP proof JWT from the DPoP HTTP header
// - method: the HTTP request method (e.g., "POST", "GET")
// - htu: the HTTP request URL without query string or fragment
// - accessToken: the access token string; empty at the token endpoint,
// non-empty at protected resource endpoints (enables ath claim validation)
//
// On success it returns the base64url-encoded SHA-256 JWK thumbprint (jkt) of
// the DPoP public key embedded in the proof header.
func ValidateDPoPProof(proofToken, method, htu, accessToken string) (string, error) {
parts := strings.Split(proofToken, ".")
if len(parts) != 3 {
return "", fmt.Errorf("invalid DPoP proof JWT format")
}
// Decode and inspect the JOSE header before signature verification.
headerBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return "", fmt.Errorf("failed to decode DPoP proof header: %w", err)
}
var header struct {
Typ string `json:"typ"`
Alg string `json:"alg"`
JWK json.RawMessage `json:"jwk"`
}
if err = json.Unmarshal(headerBytes, &header); err != nil {
return "", fmt.Errorf("failed to parse DPoP proof header: %w", err)
}
// typ MUST be exactly "dpop+jwt" (RFC 9449 §4.2).
if header.Typ != "dpop+jwt" {
return "", fmt.Errorf("DPoP proof typ must be \"dpop+jwt\", got %q", header.Typ)
}
// alg MUST identify an asymmetric digital signature algorithm;
// symmetric algorithms (HS*) are explicitly forbidden (RFC 9449 §4.2).
if header.Alg == "" || strings.HasPrefix(header.Alg, "HS") {
return "", fmt.Errorf("DPoP proof must use an asymmetric algorithm, got %q", header.Alg)
}
// jwk MUST be present (RFC 9449 §4.2).
if len(header.JWK) == 0 {
return "", fmt.Errorf("DPoP proof header must contain the jwk claim")
}
var jwkKey jose.JSONWebKey
if err = jwkKey.UnmarshalJSON(header.JWK); err != nil {
return "", fmt.Errorf("failed to parse DPoP JWK: %w", err)
}
// Compute the JWK SHA-256 thumbprint per RFC 7638.
thumbprintBytes, err := jwkKey.Thumbprint(crypto.SHA256)
if err != nil {
return "", fmt.Errorf("failed to compute DPoP JWK thumbprint: %w", err)
}
jkt := base64.RawURLEncoding.EncodeToString(thumbprintBytes)
// Verify the proof's signature using the public key embedded in the header.
// WithoutClaimsValidation is used so that we can perform all claim checks
// ourselves (jwt library exp/nbf validation is not appropriate here).
t, err := jwt.ParseWithClaims(proofToken, &DPoPProofClaims{}, func(token *jwt.Token) (interface{}, error) {
return jwkKey.Key, nil
}, jwt.WithoutClaimsValidation())
if err != nil || !t.Valid {
return "", fmt.Errorf("DPoP proof signature verification failed: %w", err)
}
claims, ok := t.Claims.(*DPoPProofClaims)
if !ok {
return "", fmt.Errorf("failed to parse DPoP proof claims")
}
// htm MUST match the HTTP request method (RFC 9449 §4.2).
if !strings.EqualFold(claims.Htm, method) {
return "", fmt.Errorf("DPoP proof htm %q does not match request method %q", claims.Htm, method)
}
// htu MUST match the request URL without query/fragment (RFC 9449 §4.2).
if !strings.EqualFold(claims.Htu, htu) {
return "", fmt.Errorf("DPoP proof htu %q does not match request URL %q", claims.Htu, htu)
}
// iat MUST be present and within the acceptable time window (RFC 9449 §4.2).
if claims.IssuedAt == nil {
return "", fmt.Errorf("DPoP proof missing iat claim")
}
age := time.Since(claims.IssuedAt.Time).Abs()
if age > time.Duration(dpopMaxAgeSeconds)*time.Second {
return "", fmt.Errorf("DPoP proof iat is outside the acceptable time window (%d seconds)", dpopMaxAgeSeconds)
}
// jti MUST be present to support replay detection (RFC 9449 §4.2).
if claims.Jti == "" {
return "", fmt.Errorf("DPoP proof missing jti claim")
}
// ath MUST be validated at protected resource endpoints (RFC 9449 §4.2).
// It is the base64url-encoded SHA-256 hash of the ASCII access token string.
if accessToken != "" {
hash := sha256.Sum256([]byte(accessToken))
expectedAth := base64.RawURLEncoding.EncodeToString(hash[:])
if claims.Ath != expectedAth {
return "", fmt.Errorf("DPoP proof ath claim does not match access token hash")
}
}
return jkt, nil
}
// GetDPoPHtu constructs the full DPoP htu URL for a given host and path.
// It uses the same origin-detection logic as the rest of the backend.
func GetDPoPHtu(host, path string) string {
_, originBackend := getOriginFromHost(host)
return originBackend + path
}

View File

@@ -112,8 +112,6 @@ type UserWithoutThirdIdp struct {
PreHash string `xorm:"varchar(100)" json:"preHash"`
RegisterType string `xorm:"varchar(100)" json:"registerType"`
RegisterSource string `xorm:"varchar(100)" json:"registerSource"`
AccessKey string `xorm:"varchar(100)" json:"accessKey"`
AccessSecret string `xorm:"varchar(100)" json:"accessSecret"`
GitHub string `xorm:"github varchar(100)" json:"github"`
Google string `xorm:"varchar(100)" json:"google"`
@@ -267,8 +265,6 @@ func getUserWithoutThirdIdp(user *User) *UserWithoutThirdIdp {
PreHash: user.PreHash,
RegisterType: user.RegisterType,
RegisterSource: user.RegisterSource,
AccessKey: user.AccessKey,
AccessSecret: user.AccessSecret,
GitHub: user.GitHub,
Google: user.Google,

File diff suppressed because it is too large Load Diff

800
object/token_oauth_util.go Normal file
View File

@@ -0,0 +1,800 @@
// Copyright 2024 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package object
import (
"crypto/sha256"
"encoding/base64"
"fmt"
"net/url"
"regexp"
"slices"
"strings"
"sync"
"time"
"github.com/casdoor/casdoor/i18n"
"github.com/casdoor/casdoor/util"
"github.com/xorm-io/core"
)
const (
hourSeconds = int(time.Hour / time.Second)
InvalidRequest = "invalid_request"
InvalidClient = "invalid_client"
InvalidGrant = "invalid_grant"
UnauthorizedClient = "unauthorized_client"
UnsupportedGrantType = "unsupported_grant_type"
InvalidScope = "invalid_scope"
EndpointError = "endpoint_error"
)
var DeviceAuthMap = sync.Map{}
type Code struct {
Message string `xorm:"varchar(100)" json:"message"`
Code string `xorm:"varchar(100)" json:"code"`
}
type TokenWrapper struct {
AccessToken string `json:"access_token"`
IdToken string `json:"id_token"`
RefreshToken string `json:"refresh_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
Scope string `json:"scope"`
}
type TokenError struct {
Error string `json:"error"`
ErrorDescription string `json:"error_description,omitempty"`
}
// DPoPConfirmation holds the DPoP key confirmation claim (RFC 9449).
type DPoPConfirmation struct {
JKT string `json:"jkt"`
}
type IntrospectionResponse struct {
Active bool `json:"active"`
Scope string `json:"scope,omitempty"`
ClientId string `json:"client_id,omitempty"`
Username string `json:"username,omitempty"`
TokenType string `json:"token_type,omitempty"`
Exp int64 `json:"exp,omitempty"`
Iat int64 `json:"iat,omitempty"`
Nbf int64 `json:"nbf,omitempty"`
Sub string `json:"sub,omitempty"`
Aud []string `json:"aud,omitempty"`
Iss string `json:"iss,omitempty"`
Jti string `json:"jti,omitempty"`
Cnf *DPoPConfirmation `json:"cnf,omitempty"` // RFC 9449 DPoP key binding
}
type DeviceAuthCache struct {
UserSignIn bool
UserName string
ApplicationId string
Scope string
RequestAt time.Time
}
type DeviceAuthResponse struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
VerificationUri string `json:"verification_uri"`
ExpiresIn int `json:"expires_in"`
Interval int `json:"interval"`
}
// validateResourceURI validates that the resource parameter is a valid absolute URI
// according to RFC 8707 Section 2
func validateResourceURI(resource string) error {
if resource == "" {
return nil // empty resource is allowed (backward compatibility)
}
parsedURL, err := url.Parse(resource)
if err != nil {
return fmt.Errorf("resource must be a valid URI")
}
// RFC 8707: The resource parameter must be an absolute URI
if !parsedURL.IsAbs() {
return fmt.Errorf("resource must be an absolute URI")
}
return nil
}
// pkceChallenge returns the base64-URL-encoded SHA256 hash of verifier, per RFC 7636
func pkceChallenge(verifier string) string {
sum := sha256.Sum256([]byte(verifier))
return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(sum[:])
}
// IsGrantTypeValid checks if grantType is allowed in the current application.
// authorization_code is allowed by default.
func IsGrantTypeValid(method string, grantTypes []string) bool {
if method == "authorization_code" {
return true
}
for _, m := range grantTypes {
if m == method {
return true
}
}
return false
}
// isRegexScope returns true if the scope string contains regex metacharacters.
func isRegexScope(scope string) bool {
return strings.ContainsAny(scope, ".*+?^${}()|[]\\")
}
// IsScopeValidAndExpand expands any regex patterns in the space-separated scope string
// against the application's configured scopes. Literal scopes are kept as-is
// after verifying they exist in the allowed list. Regex scopes are matched
// against every allowed scope name; all matches replace the pattern.
// If the application has no defined scopes, the original scope string is
// returned unchanged (backward-compatible behaviour).
// Returns the expanded scope string and whether the scope is valid.
func IsScopeValidAndExpand(scope string, application *Application) (string, bool) {
if len(application.Scopes) == 0 || scope == "" {
return scope, true
}
allowedNames := make([]string, 0, len(application.Scopes))
allowedSet := make(map[string]bool, len(application.Scopes))
for _, s := range application.Scopes {
allowedNames = append(allowedNames, s.Name)
allowedSet[s.Name] = true
}
seen := make(map[string]bool)
var expanded []string
for _, s := range strings.Fields(scope) {
// Try exact match first.
if allowedSet[s] {
if !seen[s] {
seen[s] = true
expanded = append(expanded, s)
}
continue
}
// Not an exact match if it looks like a regex, try pattern matching.
if !isRegexScope(s) {
return "", false
}
// Treat as regex pattern must be a valid regex and match ≥ 1 scope.
re, err := regexp.Compile("^" + s + "$")
if err != nil {
return "", false
}
matched := false
for _, name := range allowedNames {
if re.MatchString(name) {
matched = true
if !seen[name] {
seen[name] = true
expanded = append(expanded, name)
}
}
}
if !matched {
return "", false
}
}
return strings.Join(expanded, " "), true
}
// IsScopeValid checks whether all space-separated scopes in the scope string
// are defined in the application's Scopes list (including regex expansion).
// If the application has no defined scopes, every scope is considered valid
// (backward-compatible behaviour).
func IsScopeValid(scope string, application *Application) bool {
_, ok := IsScopeValidAndExpand(scope, application)
return ok
}
func ExpireTokenByAccessToken(accessToken string) (bool, *Application, *Token, error) {
token, err := GetTokenByAccessToken(accessToken)
if err != nil {
return false, nil, nil, err
}
if token == nil {
return false, nil, nil, nil
}
token.ExpiresIn = 0
affected, err := ormer.Engine.ID(core.PK{token.Owner, token.Name}).Cols("expires_in").Update(token)
if err != nil {
return false, nil, nil, err
}
application, err := getApplication(token.Owner, token.Application)
if err != nil {
return false, nil, nil, err
}
return affected != 0, application, token, nil
}
func CheckOAuthLogin(clientId string, responseType string, redirectUri string, scope string, state string, lang string) (string, *Application, error) {
if responseType != "code" && responseType != "token" && responseType != "id_token" {
return fmt.Sprintf(i18n.Translate(lang, "token:Grant_type: %s is not supported in this application"), responseType), nil, nil
}
application, err := GetApplicationByClientId(clientId)
if err != nil {
return "", nil, err
}
if application == nil {
return i18n.Translate(lang, "token:Invalid client_id"), nil, nil
}
if !application.IsRedirectUriValid(redirectUri) {
return fmt.Sprintf(i18n.Translate(lang, "token:Redirect URI: %s doesn't exist in the allowed Redirect URI list"), redirectUri), application, nil
}
if !IsScopeValid(scope, application) {
return i18n.Translate(lang, "token:Invalid scope"), application, nil
}
// Mask application for /api/get-app-login
application.ClientSecret = ""
return "", application, nil
}
func GetOAuthCode(userId string, clientId string, provider string, signinMethod string, responseType string, redirectUri string, scope string, state string, nonce string, challenge string, resource string, host string, lang string) (*Code, error) {
user, err := GetUser(userId)
if err != nil {
return nil, err
}
if user == nil {
return &Code{
Message: fmt.Sprintf("general:The user: %s doesn't exist", userId),
Code: "",
}, nil
}
if user.IsForbidden {
return &Code{
Message: "error: the user is forbidden to sign in, please contact the administrator",
Code: "",
}, nil
}
msg, application, err := CheckOAuthLogin(clientId, responseType, redirectUri, scope, state, lang)
if err != nil {
return nil, err
}
if msg != "" {
return &Code{
Message: msg,
Code: "",
}, nil
}
// Expand regex/wildcard scopes to concrete scope names.
expandedScope, ok := IsScopeValidAndExpand(scope, application)
if !ok {
return &Code{
Message: i18n.Translate(lang, "token:Invalid scope"),
Code: "",
}, nil
}
scope = expandedScope
// Validate resource parameter (RFC 8707)
if err := validateResourceURI(resource); err != nil {
return &Code{
Message: err.Error(),
Code: "",
}, nil
}
err = ExtendUserWithRolesAndPermissions(user)
if err != nil {
return nil, err
}
accessToken, refreshToken, tokenName, err := generateJwtToken(application, user, provider, signinMethod, nonce, scope, resource, host)
if err != nil {
return nil, err
}
if challenge == "null" {
challenge = ""
}
token := &Token{
Owner: application.Owner,
Name: tokenName,
CreatedTime: util.GetCurrentTime(),
Application: application.Name,
Organization: user.Owner,
User: user.Name,
Code: util.GenerateClientId(),
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresIn: int(application.ExpireInHours * float64(hourSeconds)),
Scope: scope,
TokenType: "Bearer",
CodeChallenge: challenge,
CodeIsUsed: false,
CodeExpireIn: time.Now().Add(time.Minute * 5).Unix(),
Resource: resource,
}
_, err = AddToken(token)
if err != nil {
return nil, err
}
return &Code{
Message: "",
Code: token.Code,
}, nil
}
func RefreshToken(application *Application, grantType string, refreshToken string, scope string, clientId string, clientSecret string, host string, dpopProof string) (interface{}, error) {
if grantType != "refresh_token" {
return &TokenError{
Error: UnsupportedGrantType,
ErrorDescription: "grant_type should be refresh_token",
}, nil
}
var err error
if application == nil {
application, err = GetApplicationByClientId(clientId)
if err != nil {
return nil, err
}
if application == nil {
return &TokenError{
Error: InvalidClient,
ErrorDescription: "client_id is invalid",
}, nil
}
}
if clientSecret != "" && application.ClientSecret != clientSecret {
return &TokenError{
Error: InvalidClient,
ErrorDescription: "client_secret is invalid",
}, nil
}
// check whether the refresh token is valid, and has not expired.
token, err := GetTokenByRefreshToken(refreshToken)
if err != nil || token == nil {
return &TokenError{
Error: InvalidGrant,
ErrorDescription: "refresh token is invalid or revoked",
}, nil
}
// check if the token has been invalidated (e.g., by SSO logout)
if token.ExpiresIn <= 0 {
return &TokenError{
Error: InvalidGrant,
ErrorDescription: "refresh token is expired",
}, nil
}
cert, err := getCertByApplication(application)
if err != nil {
return nil, err
}
if cert == nil {
return &TokenError{
Error: InvalidGrant,
ErrorDescription: fmt.Sprintf("cert: %s cannot be found", application.Cert),
}, nil
}
var oldTokenScope string
if application.TokenFormat == "JWT-Standard" {
oldToken, err := ParseStandardJwtToken(refreshToken, cert)
if err != nil {
return &TokenError{
Error: InvalidGrant,
ErrorDescription: fmt.Sprintf("parse refresh token error: %s", err.Error()),
}, nil
}
oldTokenScope = oldToken.Scope
} else {
oldToken, err := ParseJwtToken(refreshToken, cert)
if err != nil {
return &TokenError{
Error: InvalidGrant,
ErrorDescription: fmt.Sprintf("parse refresh token error: %s", err.Error()),
}, nil
}
oldTokenScope = oldToken.Scope
}
if scope == "" {
scope = oldTokenScope
}
// generate a new token
user, err := getUser(application.Organization, token.User)
if err != nil {
return nil, err
}
if user == nil {
return "", fmt.Errorf("The user: %s doesn't exist", util.GetId(application.Organization, token.User))
}
if user.IsForbidden {
return &TokenError{
Error: InvalidGrant,
ErrorDescription: "the user is forbidden to sign in, please contact the administrator",
}, nil
}
err = ExtendUserWithRolesAndPermissions(user)
if err != nil {
return nil, err
}
newAccessToken, newRefreshToken, tokenName, err := generateJwtToken(application, user, "", "", "", scope, "", host)
if err != nil {
return &TokenError{
Error: EndpointError,
ErrorDescription: fmt.Sprintf("generate jwt token error: %s", err.Error()),
}, nil
}
newToken := &Token{
Owner: application.Owner,
Name: tokenName,
CreatedTime: util.GetCurrentTime(),
Application: application.Name,
Organization: user.Owner,
User: user.Name,
Code: util.GenerateClientId(),
AccessToken: newAccessToken,
RefreshToken: newRefreshToken,
ExpiresIn: int(application.ExpireInHours * float64(hourSeconds)),
Scope: scope,
TokenType: "Bearer",
}
_, err = AddToken(newToken)
if err != nil {
return nil, err
}
// Apply DPoP binding to the refreshed token if a DPoP proof was provided.
if dpopProof != "" {
dpopHtu := GetDPoPHtu(host, "/api/login/oauth/access_token")
jkt, err := ValidateDPoPProof(dpopProof, "POST", dpopHtu, "")
if err != nil {
return &TokenError{
Error: "invalid_dpop_proof",
ErrorDescription: err.Error(),
}, nil
}
newToken.TokenType = "DPoP"
newToken.DPoPJkt = jkt
if err = updateTokenDPoP(newToken); err != nil {
return nil, err
}
}
_, err = DeleteToken(token)
if err != nil {
return nil, err
}
tokenWrapper := &TokenWrapper{
AccessToken: newToken.AccessToken,
IdToken: newToken.AccessToken,
RefreshToken: newToken.RefreshToken,
TokenType: newToken.TokenType,
ExpiresIn: newToken.ExpiresIn,
Scope: newToken.Scope,
}
return tokenWrapper, nil
}
func ValidateJwtAssertion(clientAssertion string, application *Application, host string) (bool, *Claims, error) {
_, originBackend := getOriginFromHost(host)
clientCert, err := getCert(application.Owner, application.ClientCert)
if err != nil {
return false, nil, err
}
if clientCert == nil {
return false, nil, fmt.Errorf("client certificate is not configured for application: [%s]", application.GetId())
}
claims, err := ParseJwtToken(clientAssertion, clientCert)
if err != nil {
return false, nil, err
}
if !slices.Contains(application.RedirectUris, claims.Issuer) {
return false, nil, nil
}
if !slices.Contains(claims.Audience, fmt.Sprintf("%s/api/login/oauth/access_token", originBackend)) {
return false, nil, nil
}
return true, claims, nil
}
func ValidateClientAssertion(clientAssertion string, host string) (bool, *Application, error) {
token, err := ParseJwtTokenWithoutValidation(clientAssertion)
if err != nil {
return false, nil, err
}
clientId, err := token.Claims.GetSubject()
if err != nil {
return false, nil, err
}
application, err := GetApplicationByClientId(clientId)
if err != nil {
return false, nil, err
}
if application == nil {
return false, nil, fmt.Errorf("application not found for client: [%s]", clientId)
}
ok, _, err := ValidateJwtAssertion(clientAssertion, application, host)
if err != nil {
return false, application, err
}
if !ok {
return false, application, nil
}
return true, application, nil
}
// mintImplicitToken mints a token for an already-authenticated user.
// Callers must verify user identity before calling this function.
func mintImplicitToken(application *Application, username string, scope string, nonce string, host string) (*Token, *TokenError, error) {
expandedScope, ok := IsScopeValidAndExpand(scope, application)
if !ok {
return nil, &TokenError{
Error: InvalidScope,
ErrorDescription: "the requested scope is invalid or not defined in the application",
}, nil
}
scope = expandedScope
user, err := GetUserByFields(application.Organization, username)
if err != nil {
return nil, nil, err
}
if user == nil {
return nil, &TokenError{
Error: InvalidGrant,
ErrorDescription: "the user does not exist",
}, nil
}
if user.IsForbidden {
return nil, &TokenError{
Error: InvalidGrant,
ErrorDescription: "the user is forbidden to sign in, please contact the administrator",
}, nil
}
token, err := GetTokenByUser(application, user, scope, nonce, host)
if err != nil {
return nil, nil, err
}
return token, nil, nil
}
// parseAndValidateSubjectToken validates a subject_token for RFC 8693 token exchange.
// It uses the ISSUING application's certificate (not the requesting client's) and
// enforces audience binding to prevent cross-client token reuse.
func parseAndValidateSubjectToken(subjectToken string, requestingClientId string) (owner, name, scope string, tokenErr *TokenError, err error) {
unverifiedToken, err := ParseJwtTokenWithoutValidation(subjectToken)
if err != nil {
return "", "", "", &TokenError{Error: InvalidGrant, ErrorDescription: fmt.Sprintf("invalid subject_token: %s", err.Error())}, nil
}
unverifiedClaims, ok := unverifiedToken.Claims.(*Claims)
if !ok || unverifiedClaims.Azp == "" {
return "", "", "", &TokenError{Error: InvalidGrant, ErrorDescription: "subject_token is missing the azp claim"}, nil
}
issuingApp, err := GetApplicationByClientId(unverifiedClaims.Azp)
if err != nil {
return "", "", "", nil, err
}
if issuingApp == nil {
return "", "", "", &TokenError{Error: InvalidGrant, ErrorDescription: fmt.Sprintf("subject_token issuing application not found: %s", unverifiedClaims.Azp)}, nil
}
cert, err := getCertByApplication(issuingApp)
if err != nil {
return "", "", "", nil, err
}
if cert == nil {
return "", "", "", &TokenError{Error: EndpointError, ErrorDescription: fmt.Sprintf("cert for issuing application %s cannot be found", unverifiedClaims.Azp)}, nil
}
if issuingApp.TokenFormat == "JWT-Standard" {
standardClaims, err := ParseStandardJwtToken(subjectToken, cert)
if err != nil {
return "", "", "", &TokenError{Error: InvalidGrant, ErrorDescription: fmt.Sprintf("invalid subject_token: %s", err.Error())}, nil
}
return standardClaims.Owner, standardClaims.Name, standardClaims.Scope, nil, nil
}
claims, err := ParseJwtToken(subjectToken, cert)
if err != nil {
return "", "", "", &TokenError{Error: InvalidGrant, ErrorDescription: fmt.Sprintf("invalid subject_token: %s", err.Error())}, nil
}
// Audience binding: requesting client must be the issuer itself or appear in token's aud.
// Prevents an attacker from exchanging App A's token to obtain an App B token (RFC 8693 §2.1).
if issuingApp.ClientId != requestingClientId {
audienceMatched := false
for _, aud := range claims.Audience {
if aud == requestingClientId {
audienceMatched = true
break
}
}
if !audienceMatched {
return "", "", "", &TokenError{Error: InvalidGrant, ErrorDescription: fmt.Sprintf("subject_token audience does not include the requesting client '%s'", requestingClientId)}, nil
}
}
return claims.Owner, claims.Name, claims.Scope, nil, nil
}
// createGuestUserToken creates a new guest user and returns a token for them.
func createGuestUserToken(application *Application, clientSecret string, verifier string) (*Token, *TokenError, error) {
if clientSecret != "" && application.ClientSecret != clientSecret {
return nil, &TokenError{
Error: InvalidClient,
ErrorDescription: "client_secret is invalid",
}, nil
}
guestUsername := generateGuestUsername()
guestPassword := util.GenerateId()
organization, err := GetOrganization(util.GetId("admin", application.Organization))
if err != nil {
return nil, &TokenError{
Error: EndpointError,
ErrorDescription: fmt.Sprintf("failed to get organization: %s", err.Error()),
}, nil
}
if organization == nil {
return nil, &TokenError{
Error: InvalidClient,
ErrorDescription: fmt.Sprintf("organization: %s does not exist", application.Organization),
}, nil
}
initScore, err := organization.GetInitScore()
if err != nil {
return nil, &TokenError{
Error: EndpointError,
ErrorDescription: fmt.Sprintf("failed to get init score: %s", err.Error()),
}, nil
}
newUserId, idErr := GenerateIdForNewUser(application)
if idErr != nil {
newUserId = util.GenerateId()
}
guestUser := &User{
Owner: application.Organization,
Name: guestUsername,
CreatedTime: util.GetCurrentTime(),
Id: newUserId,
Type: "normal-user",
Password: guestPassword,
Tag: "guest-user",
DisplayName: fmt.Sprintf("Guest_%s", guestUsername[:8]),
Avatar: "",
Address: []string{},
Email: "",
Phone: "",
Score: initScore,
IsAdmin: false,
IsForbidden: false,
IsDeleted: false,
SignupApplication: application.Name,
Properties: map[string]string{},
RegisterType: "Guest Signup",
RegisterSource: fmt.Sprintf("%s/%s", application.Organization, application.Name),
}
affected, err := AddUser(guestUser, "en")
if err != nil {
return nil, &TokenError{
Error: EndpointError,
ErrorDescription: fmt.Sprintf("failed to create guest user: %s", err.Error()),
}, nil
}
if !affected {
return nil, &TokenError{
Error: EndpointError,
ErrorDescription: "failed to create guest user",
}, nil
}
err = ExtendUserWithRolesAndPermissions(guestUser)
if err != nil {
return nil, &TokenError{
Error: EndpointError,
ErrorDescription: fmt.Sprintf("failed to extend user: %s", err.Error()),
}, nil
}
accessToken, refreshToken, tokenName, err := generateJwtToken(application, guestUser, "", "", "", "", "", "")
if err != nil {
return nil, &TokenError{
Error: EndpointError,
ErrorDescription: fmt.Sprintf("failed to generate token: %s", err.Error()),
}, nil
}
token := &Token{
Owner: application.Owner,
Name: tokenName,
CreatedTime: util.GetCurrentTime(),
Application: application.Name,
Organization: guestUser.Owner,
User: guestUser.Name,
Code: util.GenerateClientId(),
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresIn: int(application.ExpireInHours * float64(hourSeconds)),
Scope: "",
TokenType: "Bearer",
CodeChallenge: "",
CodeIsUsed: true,
CodeExpireIn: 0,
}
_, err = AddToken(token)
if err != nil {
return nil, &TokenError{
Error: EndpointError,
ErrorDescription: fmt.Sprintf("failed to add token: %s", err.Error()),
}, nil
}
return token, nil, nil
}
// generateGuestUsername generates a unique username for guest users.
func generateGuestUsername() string {
return fmt.Sprintf("guest_%s", util.GenerateUUID())
}

View File

@@ -109,8 +109,6 @@ type User struct {
PreHash string `xorm:"varchar(100)" json:"preHash"`
RegisterType string `xorm:"varchar(100)" json:"registerType"`
RegisterSource string `xorm:"varchar(100)" json:"registerSource"`
AccessKey string `xorm:"varchar(100)" json:"accessKey"`
AccessSecret string `xorm:"varchar(100)" json:"accessSecret"`
AccessToken string `xorm:"mediumtext" json:"accessToken"`
OriginalToken string `xorm:"mediumtext" json:"originalToken"`
OriginalRefreshToken string `xorm:"mediumtext" json:"originalRefreshToken"`
@@ -408,6 +406,9 @@ func GetUsersByTagWithFilter(owner string, tag string, cond builder.Cond) ([]*Us
func GetSortedUsers(owner string, sorter string, limit int) ([]*User, error) {
users := []*User{}
if !util.FilterSQLIdentifier(sorter) {
return nil, fmt.Errorf("object.GetSortedUsers() error: invalid sorter field: %s", sorter)
}
err := ormer.Engine.Desc(sorter).Limit(limit, 0).Find(&users, &User{Owner: owner})
if err != nil {
return nil, err
@@ -639,23 +640,6 @@ func GetUserByInvitationCode(owner string, invitationCode string) (*User, error)
}
}
func GetUserByAccessKey(accessKey string) (*User, error) {
if accessKey == "" {
return nil, nil
}
user := User{AccessKey: accessKey}
existed, err := ormer.Engine.Get(&user)
if err != nil {
return nil, err
}
if existed {
return &user, nil
} else {
return nil, nil
}
}
func GetUser(id string) (*User, error) {
owner, name, err := util.GetOwnerAndNameFromIdWithError(id)
if err != nil {
@@ -683,9 +667,6 @@ func GetMaskedUser(user *User, isAdminOrSelf bool, errs ...error) (*User, error)
}
if !isAdminOrSelf {
if user.AccessSecret != "" {
user.AccessSecret = "***"
}
if user.OriginalToken != "" {
user.OriginalToken = "***"
}
@@ -865,7 +846,7 @@ func UpdateUser(id string, user *User, columns []string, isAdmin bool) (bool, er
"owner", "display_name", "avatar", "first_name", "last_name",
"location", "address", "addresses", "country_code", "region", "language", "affiliation", "title", "id_card_type", "id_card", "homepage", "bio", "tag", "language", "gender", "birthday", "education", "score", "karma", "ranking", "signup_application", "register_type", "register_source",
"is_admin", "is_forbidden", "is_deleted", "hash", "is_default_avatar", "properties", "webauthnCredentials", "mfa_items", "last_change_password_time", "managedAccounts", "face_ids", "mfaAccounts",
"signin_wrong_times", "last_signin_wrong_time", "groups", "access_key", "access_secret", "mfa_phone_enabled", "mfa_email_enabled", "email_verified",
"signin_wrong_times", "last_signin_wrong_time", "groups", "mfa_phone_enabled", "mfa_email_enabled", "email_verified",
"github", "google", "qq", "wechat", "facebook", "dingtalk", "weibo", "gitee", "linkedin", "wecom", "lark", "gitlab", "adfs",
"baidu", "alipay", "casdoor", "infoflow", "apple", "azuread", "azureadb2c", "slack", "steam", "bilibili", "okta", "douyin", "kwai", "line", "amazon",
"auth0", "battlenet", "bitbucket", "box", "cloudfoundry", "dailymotion", "deezer", "digitalocean", "discord", "dropbox",
@@ -1396,17 +1377,6 @@ func (user *User) GetPreferredMfaProps(masked bool) *MfaProps {
return user.GetMfaProps(user.PreferredMfaType, masked)
}
func AddUserKeys(user *User, isAdmin bool) (bool, error) {
if user == nil {
return false, fmt.Errorf("the user is not found")
}
user.AccessKey = util.GenerateId()
user.AccessSecret = util.GenerateId()
return UpdateUser(user.GetId(), user, []string{}, isAdmin)
}
func (user *User) IsApplicationAdmin(application *Application) bool {
if user == nil {
return false

View File

@@ -26,7 +26,6 @@ import (
"github.com/casdoor/casdoor/i18n"
"github.com/casdoor/casdoor/idp"
"github.com/casdoor/casdoor/util"
"github.com/casvisor/casvisor-go-sdk/casvisorsdk"
"github.com/go-webauthn/webauthn/webauthn"
jsoniter "github.com/json-iterator/go"
"github.com/xorm-io/core"
@@ -38,6 +37,10 @@ func GetUserByField(organizationName string, field string, value string) (*User,
return nil, nil
}
if !util.FilterSQLIdentifier(field) {
return nil, nil
}
user := User{Owner: organizationName}
existed, err := ormer.Engine.Where(fmt.Sprintf("%s=?", strings.ToLower(field)), value).Get(&user)
if err != nil {
@@ -250,9 +253,17 @@ func SetUserOAuthProperties(organization *Organization, user *User, providerType
if userInfo.AvatarUrl != "" {
propertyName := fmt.Sprintf("oauth_%s_avatarUrl", providerType)
setUserProperty(user, propertyName, userInfo.AvatarUrl)
if user.Avatar == "" || user.Avatar == organization.DefaultAvatar {
user.Avatar = userInfo.AvatarUrl
if organization.UsePermanentAvatar {
err := syncOAuthAvatarToPermanentStorage(organization, user, propertyName, userInfo.AvatarUrl)
if err != nil {
return false, err
}
} else {
setUserProperty(user, propertyName, userInfo.AvatarUrl)
if user.Avatar == "" || user.Avatar == organization.DefaultAvatar {
user.Avatar = userInfo.AvatarUrl
}
}
}
@@ -285,6 +296,45 @@ func SetUserOAuthProperties(organization *Organization, user *User, providerType
return UpdateUserForAllFields(user.GetId(), user)
}
// syncOAuthAvatarToPermanentStorage ensures the user's avatar is stored in permanent storage.
// It checks whether a permanent avatar already exists for the given sourceAvatarURL.
// If not, it uploads the avatar and retrieves a permanent URL.
// Finally, it updates the user's avatar fields with the resolved permanent URL.
func syncOAuthAvatarToPermanentStorage(organization *Organization, user *User, propertyName, sourceAvatarUrl string) error {
oldAvatarUrl := getUserProperty(user, propertyName)
avatarUrl := sourceAvatarUrl
permanentAvatarUrl, err := getPermanentAvatarUrl(user.Owner, user.Name, sourceAvatarUrl, false)
if err != nil {
return err
}
if permanentAvatarUrl != "" {
avatarUrl = permanentAvatarUrl
if oldAvatarUrl != permanentAvatarUrl {
avatarUrl, err = getPermanentAvatarUrl(user.Owner, user.Name, sourceAvatarUrl, true)
if err != nil {
return err
}
if avatarUrl == "" {
avatarUrl = permanentAvatarUrl
}
}
}
setUserProperty(user, propertyName, avatarUrl)
if user.Avatar == "" ||
user.Avatar == organization.DefaultAvatar ||
user.Avatar == sourceAvatarUrl ||
(oldAvatarUrl != "" && user.Avatar == oldAvatarUrl) {
user.Avatar = avatarUrl
}
return nil
}
func applyUserMapping(user *User, extraClaims map[string]string, userMapping map[string]string) {
// Map of user fields that can be set from IDP claims
for userField, claimName := range userMapping {
@@ -1047,7 +1097,7 @@ func TriggerWebhookForUser(action string, user *User) {
return
}
record := &casvisorsdk.Record{
record := &Record{
Name: util.GenerateId(),
CreatedTime: util.GetCurrentTime(),
Organization: user.Owner,

View File

@@ -15,10 +15,11 @@
package object
import (
"crypto/rand"
"errors"
"fmt"
"math"
"math/rand"
"math/big"
"net/url"
"regexp"
"strconv"
@@ -476,10 +477,13 @@ func GetVerifyType(username string) (verificationCodeType string) {
var stdNums = []byte("0123456789")
func getRandomCode(length int) string {
var result []byte
r := rand.New(rand.NewSource(time.Now().UnixNano()))
result := make([]byte, length)
for i := 0; i < length; i++ {
result = append(result, stdNums[r.Intn(len(stdNums))])
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(stdNums))))
if err != nil {
panic(err)
}
result[i] = stdNums[n.Int64()]
}
return string(result)
}

152
object/verification_ip.go Normal file
View File

@@ -0,0 +1,152 @@
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package object
import (
"errors"
"fmt"
"strconv"
"sync"
"time"
"github.com/casdoor/casdoor/i18n"
)
// Hard-coded thresholds for OTP / verification-code brute force protection (per IP + dest).
// These can be made configurable later if needed.
const (
defaultVerifyCodeIpLimit = 5
defaultVerifyCodeIpFrozenMinute = 10
)
var (
verifyCodeIpErrorMap = map[string]*verifyCodeErrorInfo{}
verifyCodeIpErrorMapLock sync.Mutex
)
func getVerifyCodeIpErrorKey(remoteAddr, dest string) string {
return fmt.Sprintf("%s:%s", remoteAddr, dest)
}
func checkVerifyCodeIpErrorTimes(remoteAddr, dest, lang string) error {
if remoteAddr == "" {
return nil
}
key := getVerifyCodeIpErrorKey(remoteAddr, dest)
verifyCodeIpErrorMapLock.Lock()
defer verifyCodeIpErrorMapLock.Unlock()
errorInfo, ok := verifyCodeIpErrorMap[key]
if !ok || errorInfo == nil {
return nil
}
if errorInfo.wrongTimes < defaultVerifyCodeIpLimit {
return nil
}
minutesLeft := int64(defaultVerifyCodeIpFrozenMinute) - int64(time.Now().UTC().Sub(errorInfo.lastWrongTime).Minutes())
if minutesLeft > 0 {
return fmt.Errorf(i18n.Translate(lang, "check:You have entered the wrong password or code too many times, please wait for %d minutes and try again"), minutesLeft)
}
delete(verifyCodeIpErrorMap, key)
return nil
}
func recordVerifyCodeIpErrorInfo(remoteAddr, dest, lang string) error {
// If remoteAddr is missing, still return a normal "wrong code" error.
if remoteAddr == "" {
return errors.New(i18n.Translate(lang, "verification:Wrong verification code!"))
}
key := getVerifyCodeIpErrorKey(remoteAddr, dest)
verifyCodeIpErrorMapLock.Lock()
defer verifyCodeIpErrorMapLock.Unlock()
errorInfo, ok := verifyCodeIpErrorMap[key]
if !ok || errorInfo == nil {
errorInfo = &verifyCodeErrorInfo{}
verifyCodeIpErrorMap[key] = errorInfo
}
if errorInfo.wrongTimes < defaultVerifyCodeIpLimit {
errorInfo.wrongTimes++
}
if errorInfo.wrongTimes >= defaultVerifyCodeIpLimit {
errorInfo.lastWrongTime = time.Now().UTC()
}
leftChances := defaultVerifyCodeIpLimit - errorInfo.wrongTimes
if leftChances >= 0 {
return fmt.Errorf(i18n.Translate(lang, "check:password or code is incorrect, you have %s remaining chances"), strconv.Itoa(leftChances))
}
return fmt.Errorf(i18n.Translate(lang, "check:You have entered the wrong password or code too many times, please wait for %d minutes and try again"), defaultVerifyCodeIpFrozenMinute)
}
func resetVerifyCodeIpErrorTimes(remoteAddr, dest string) {
if remoteAddr == "" {
return
}
key := getVerifyCodeIpErrorKey(remoteAddr, dest)
verifyCodeIpErrorMapLock.Lock()
defer verifyCodeIpErrorMapLock.Unlock()
delete(verifyCodeIpErrorMap, key)
}
// CheckVerifyCodeWithLimitAndIp enforces both per-user and per-IP attempt limits for verification codes.
// It is intended for security-sensitive flows like password reset.
func CheckVerifyCodeWithLimitAndIp(user *User, remoteAddr, dest, code, lang string) error {
if err := checkVerifyCodeIpErrorTimes(remoteAddr, dest, lang); err != nil {
return err
}
if user != nil {
if err := checkVerifyCodeErrorTimes(user, dest, lang); err != nil {
return err
}
}
result, err := CheckVerificationCode(dest, code, lang)
if err != nil {
return err
}
switch result.Code {
case VerificationSuccess:
resetVerifyCodeIpErrorTimes(remoteAddr, dest)
if user != nil {
resetVerifyCodeErrorTimes(user, dest)
}
return nil
case wrongCodeError:
ipErr := recordVerifyCodeIpErrorInfo(remoteAddr, dest, lang)
if user != nil {
// Keep existing user-level error semantics when user is known.
return recordVerifyCodeErrorInfo(user, dest, lang)
}
return ipErr
default:
return errors.New(result.Msg)
}
}

View File

@@ -45,6 +45,11 @@ type Webhook struct {
IsUserExtended bool `json:"isUserExtended"`
SingleOrgOnly bool `json:"singleOrgOnly"`
IsEnabled bool `json:"isEnabled"`
// Retry configuration
MaxRetries int `xorm:"int default 3" json:"maxRetries"`
RetryInterval int `xorm:"int default 60" json:"retryInterval"` // seconds
UseExponentialBackoff bool `json:"useExponentialBackoff"`
}
func GetWebhookCount(owner, organization, field, value string) (int64, error) {

280
object/webhook_event.go Normal file
View File

@@ -0,0 +1,280 @@
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package object
import (
"fmt"
"github.com/casdoor/casdoor/util"
"github.com/xorm-io/core"
"github.com/xorm-io/xorm"
)
// WebhookEventStatus represents the delivery status of a webhook event
type WebhookEventStatus string
const (
WebhookEventStatusPending WebhookEventStatus = "pending"
WebhookEventStatusSuccess WebhookEventStatus = "success"
WebhookEventStatusFailed WebhookEventStatus = "failed"
WebhookEventStatusRetrying WebhookEventStatus = "retrying"
)
// WebhookEvent represents a webhook delivery event with retry and replay capability
type WebhookEvent struct {
Owner string `xorm:"varchar(100) notnull pk" json:"owner"`
Name string `xorm:"varchar(100) notnull pk" json:"name"`
CreatedTime string `xorm:"varchar(100)" json:"createdTime"`
UpdatedTime string `xorm:"varchar(100)" json:"updatedTime"`
WebhookName string `xorm:"varchar(200) index" json:"webhookName"`
Organization string `xorm:"varchar(100) index" json:"organization"`
EventType string `xorm:"varchar(100)" json:"eventType"`
Status WebhookEventStatus `xorm:"varchar(50) index" json:"status"`
// Payload stores the event data (Record)
Payload string `xorm:"mediumtext" json:"payload"`
// Extended user data if applicable
ExtendedUser string `xorm:"mediumtext" json:"extendedUser"`
// Delivery tracking
AttemptCount int `xorm:"int default 0" json:"attemptCount"`
MaxRetries int `xorm:"int default 3" json:"maxRetries"`
NextRetryTime string `xorm:"varchar(100)" json:"nextRetryTime"`
// Last delivery response
LastStatusCode int `xorm:"int" json:"lastStatusCode"`
LastResponse string `xorm:"mediumtext" json:"lastResponse"`
LastError string `xorm:"mediumtext" json:"lastError"`
}
func GetWebhookEvent(id string) (*WebhookEvent, error) {
owner, name, err := util.GetOwnerAndNameFromIdWithError(id)
if err != nil {
return nil, err
}
return getWebhookEvent(owner, name)
}
func getWebhookEvent(owner string, name string) (*WebhookEvent, error) {
if owner == "" || name == "" {
return nil, nil
}
event := WebhookEvent{Owner: owner, Name: name}
existed, err := ormer.Engine.Get(&event)
if err != nil {
return &event, err
}
if existed {
return &event, nil
}
return nil, nil
}
func getWebhookEventSession(owner, organization, webhookName string, status WebhookEventStatus, offset, limit int, sortField, sortOrder string) *xorm.Session {
session := ormer.Engine.Prepare()
if owner != "" {
session = session.Where("owner = ?", owner)
}
if organization != "" {
session = session.Where("organization = ?", organization)
}
if webhookName != "" {
session = session.Where("webhook_name = ?", webhookName)
}
if status != "" {
session = session.Where("status = ?", status)
}
if offset > 0 {
session = session.Limit(limit, offset)
} else if limit > 0 {
session = session.Limit(limit)
}
if sortField == "" || sortOrder == "" {
sortField = "created_time"
}
if sortOrder == "ascend" {
session = session.Asc(util.SnakeString(sortField))
} else {
session = session.Desc(util.SnakeString(sortField))
}
return session
}
func GetWebhookEvents(owner, organization, webhookName string, status WebhookEventStatus, offset, limit int, sortField, sortOrder string) ([]*WebhookEvent, error) {
events := []*WebhookEvent{}
session := getWebhookEventSession(owner, organization, webhookName, status, offset, limit, sortField, sortOrder)
err := session.Find(&events)
if err != nil {
return nil, err
}
return events, nil
}
func GetWebhookEventCount(owner, organization, webhookName string, status WebhookEventStatus) (int64, error) {
session := ormer.Engine.Where("1 = 1")
if owner != "" {
session = session.Where("owner = ?", owner)
}
if organization != "" {
session = session.Where("organization = ?", organization)
}
if webhookName != "" {
session = session.Where("webhook_name = ?", webhookName)
}
if status != "" {
session = session.Where("status = ?", status)
}
return session.Count(&WebhookEvent{})
}
func GetPendingWebhookEvents(limit int) ([]*WebhookEvent, error) {
events := []*WebhookEvent{}
currentTime := util.GetCurrentTime()
err := ormer.Engine.
Where("status = ? OR status = ?", WebhookEventStatusPending, WebhookEventStatusRetrying).
And("(next_retry_time = '' OR next_retry_time <= ?)", currentTime).
Asc("created_time").
Limit(limit).
Find(&events)
if err != nil {
return nil, err
}
return events, nil
}
func AddWebhookEvent(event *WebhookEvent) (bool, error) {
if event.Name == "" {
event.Name = util.GenerateId()
}
if event.CreatedTime == "" {
event.CreatedTime = util.GetCurrentTime()
}
if event.UpdatedTime == "" {
event.UpdatedTime = util.GetCurrentTime()
}
if event.Status == "" {
event.Status = WebhookEventStatusPending
}
affected, err := ormer.Engine.Insert(event)
if err != nil {
return false, err
}
return affected != 0, nil
}
func UpdateWebhookEvent(id string, event *WebhookEvent) (bool, error) {
owner, name, err := util.GetOwnerAndNameFromIdWithError(id)
if err != nil {
return false, err
}
event.UpdatedTime = util.GetCurrentTime()
affected, err := ormer.Engine.ID(core.PK{owner, name}).AllCols().Update(event)
if err != nil {
return false, err
}
return affected != 0, nil
}
func UpdateWebhookEventStatus(event *WebhookEvent, status WebhookEventStatus, statusCode int, response string, err error) (bool, error) {
event.Status = status
event.LastStatusCode = statusCode
event.LastResponse = response
event.UpdatedTime = util.GetCurrentTime()
if status != WebhookEventStatusRetrying {
event.NextRetryTime = ""
}
if err != nil {
event.LastError = err.Error()
} else {
event.LastError = ""
}
affected, dbErr := ormer.Engine.ID(core.PK{event.Owner, event.Name}).
Cols("status", "last_status_code", "last_response", "last_error", "updated_time", "attempt_count", "max_retries", "next_retry_time").
Update(event)
if dbErr != nil {
return false, dbErr
}
return affected != 0, nil
}
func DeleteWebhookEvent(event *WebhookEvent) (bool, error) {
affected, err := ormer.Engine.ID(core.PK{event.Owner, event.Name}).Delete(&WebhookEvent{})
if err != nil {
return false, err
}
return affected != 0, nil
}
func (e *WebhookEvent) GetId() string {
return fmt.Sprintf("%s/%s", e.Owner, e.Name)
}
// CreateWebhookEventFromRecord creates a webhook event from a record
func CreateWebhookEventFromRecord(webhook *Webhook, record *Record, extendedUser *User) (*WebhookEvent, error) {
maxRetries := webhook.MaxRetries
if maxRetries <= 0 {
maxRetries = 3
}
event := &WebhookEvent{
Owner: webhook.Owner,
Name: util.GenerateId(),
CreatedTime: util.GetCurrentTime(),
UpdatedTime: util.GetCurrentTime(),
WebhookName: webhook.GetId(),
Organization: record.Organization,
EventType: record.Action,
Status: WebhookEventStatusPending,
Payload: util.StructToJson(record),
AttemptCount: 0,
MaxRetries: maxRetries,
}
if extendedUser != nil {
event.ExtendedUser = util.StructToJson(extendedUser)
}
_, err := AddWebhookEvent(event)
if err != nil {
return nil, err
}
return event, nil
}

View File

@@ -19,13 +19,13 @@ import (
"net/http"
"reflect"
"strings"
"time"
"github.com/casdoor/casdoor/util"
"github.com/casvisor/casvisor-go-sdk/casvisorsdk"
)
func sendWebhook(webhook *Webhook, record *casvisorsdk.Record, extendedUser *User) (int, string, error) {
client := &http.Client{}
func sendWebhook(webhook *Webhook, record *Record, extendedUser *User) (int, string, error) {
client := &http.Client{Timeout: 30 * time.Second}
userMap := make(map[string]interface{})
var body io.Reader
@@ -41,7 +41,7 @@ func sendWebhook(webhook *Webhook, record *casvisorsdk.Record, extendedUser *Use
}
type RecordEx struct {
casvisorsdk.Record
Record
ExtendedUser map[string]interface{} `json:"extendedUser"`
}
@@ -53,7 +53,7 @@ func sendWebhook(webhook *Webhook, record *casvisorsdk.Record, extendedUser *Use
body = strings.NewReader(util.StructToJson(recordEx))
} else {
type RecordEx struct {
casvisorsdk.Record
Record
ExtendedUser *User `xorm:"-" json:"extendedUser"`
}
recordEx := &RecordEx{

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