forked from casdoor/casdoor
Compare commits
77 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c38a99973 | |||
| 7c26dbb7d0 | |||
| 61bc75b12e | |||
| 18a8694d28 | |||
| 8478543c6b | |||
|
|
25d8595e66 | ||
|
|
3aafa91937 | ||
|
|
0077839549 | ||
|
|
e1ee2ddee8 | ||
|
|
b93be2d3e2 | ||
|
|
77b56a2e40 | ||
|
|
c0591f316e | ||
|
|
6749d46561 | ||
|
|
a4a50f182b | ||
|
|
221d10a172 | ||
|
|
5c051ba03d | ||
|
|
c16f4d2fb5 | ||
|
|
fe185f880c | ||
|
|
b3bed1992b | ||
|
|
be38d178fd | ||
|
|
3eb164e149 | ||
|
|
6c3cd8a74b | ||
|
|
c5ab4eec59 | ||
|
|
e8170884d7 | ||
|
|
729b21e8ae | ||
|
|
bed67a1ff2 | ||
|
|
df5f5def31 | ||
|
|
76c56e9b2d | ||
|
|
f46e229d5b | ||
|
|
112be9714b | ||
|
|
9d85362a24 | ||
|
|
37e2f13d99 | ||
|
|
f35398ea5c | ||
|
|
5a5470d5a3 | ||
|
|
948fc017e1 | ||
|
|
c63184fc67 | ||
|
|
f5f4032b3b | ||
|
|
7006041fa9 | ||
|
|
d7bc2bf052 | ||
|
|
29eeb03f85 | ||
|
|
14b4b557f9 | ||
|
|
49d35ac161 | ||
|
|
5ed9158368 | ||
|
|
2bb728ad7d | ||
|
|
f4665df477 | ||
|
|
12bbecb69d | ||
|
|
a5079cd0c5 | ||
|
|
e361044f86 | ||
|
|
91cdf56636 | ||
|
|
10daed237e | ||
|
|
315a6bb040 | ||
|
|
cef6b85389 | ||
|
|
14a802f2c5 | ||
|
|
40d1f63cd6 | ||
|
|
85c91c50d3 | ||
|
|
0e5f810f2f | ||
|
|
e9c2ec0d6c | ||
|
|
2a8ac578da | ||
|
|
31ce1512df | ||
|
|
bac824cb4f | ||
|
|
1637ca1dfb | ||
|
|
c7ad2052c9 | ||
|
|
117bf608ea | ||
|
|
13e0af4b0a | ||
|
|
e8a0b268dc | ||
|
|
2762390c32 | ||
|
|
a69c4454ca | ||
|
|
c76d0d17ed | ||
|
|
e10706cb6d | ||
|
|
d92b856868 | ||
|
|
d14674e60e | ||
|
|
284dde292a | ||
|
|
ea56cfec2b | ||
|
|
82d7f241bb | ||
|
|
56ac5cd221 | ||
|
|
203a61cfef | ||
|
|
b9500a27d9 |
31
.gitea/workflows/build.yml
Normal file
31
.gitea/workflows/build.yml
Normal 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 }}
|
||||
@@ -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
|
||||
|
||||
@@ -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, *, *
|
||||
@@ -91,9 +92,13 @@ 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, *, *
|
||||
@@ -174,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,43 +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"
|
||||
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"
|
||||
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
43
conf/app.conf.orig
Normal 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
37
conf/app.dev.conf
Normal 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"
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -88,6 +88,25 @@ func (c *ApiController) GetEntry() {
|
||||
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
|
||||
|
||||
@@ -32,7 +32,7 @@ import (
|
||||
// @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 [post]
|
||||
// @router /server/:owner/:name [get,post]
|
||||
func (c *ApiController) ProxyServer() {
|
||||
owner := c.Ctx.Input.Param(":owner")
|
||||
name := c.Ctx.Input.Param(":name")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -110,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
|
||||
|
||||
@@ -21,7 +21,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const onlineServerListUrl = "https://remotemcplist.com/api/servers.json"
|
||||
const onlineServerListUrl = "https://mcp.casdoor.org/registry.json"
|
||||
|
||||
// GetOnlineServers
|
||||
// @Title GetOnlineServers
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
20
docker-compose.dev.yml
Normal file
20
docker-compose.dev.yml
Normal 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:
|
||||
230
i18n/locales/ru/data.json
Normal file
230
i18n/locales/ru/data.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,7 @@
|
||||
"pt",
|
||||
"tr",
|
||||
"pl",
|
||||
"ru",
|
||||
"uk"
|
||||
],
|
||||
"masterPassword": "",
|
||||
|
||||
1
main.go
1
main.go
@@ -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)
|
||||
|
||||
18
mcp/util.go
18
mcp/util.go
@@ -43,11 +43,21 @@ func GetServerTools(owner, name, url, token string) ([]*mcpsdk.Tool, 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 token != "" {
|
||||
httpClient := oauth2.NewClient(ctx, oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}))
|
||||
session, err = client.Connect(ctx, &mcpsdk.StreamableClientTransport{Endpoint: url, HTTPClient: httpClient}, 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 {
|
||||
session, err = client.Connect(ctx, &mcpsdk.StreamableClientTransport{Endpoint: url}, nil)
|
||||
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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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
615
object/application_util.go
Normal 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()
|
||||
}
|
||||
79
object/application_util_test.go
Normal file
79
object/application_util_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,7 +347,7 @@ 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+"%")
|
||||
}
|
||||
|
||||
|
||||
@@ -134,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,
|
||||
|
||||
@@ -878,12 +878,12 @@ func (ldap *Ldap) buildAuthFilterString(user *User) string {
|
||||
}
|
||||
|
||||
if len(ldap.FilterFields) == 0 {
|
||||
return fmt.Sprintf("(&%s(uid=%s))", baseFilter, user.Name)
|
||||
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)
|
||||
|
||||
|
||||
@@ -39,37 +39,39 @@ func InitLogProviders() {
|
||||
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) {
|
||||
runningCollectorsMu.Lock()
|
||||
defer runningCollectorsMu.Unlock()
|
||||
|
||||
id := provider.GetId()
|
||||
stopCollector(id)
|
||||
|
||||
if existing, ok := runningCollectors[id]; ok {
|
||||
_ = existing.Stop()
|
||||
delete(runningCollectors, id)
|
||||
}
|
||||
|
||||
tag := provider.Title
|
||||
if tag == "" {
|
||||
tag = "casdoor"
|
||||
}
|
||||
|
||||
lp, err := log.NewSystemLogProvider(tag)
|
||||
lp, err := log.GetLogProvider(provider.Type, provider.Host, provider.Port, provider.Title)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -98,21 +100,16 @@ func startLogCollector(provider *Provider) {
|
||||
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) {
|
||||
runningCollectorsMu.Lock()
|
||||
defer runningCollectorsMu.Unlock()
|
||||
|
||||
id := provider.GetId()
|
||||
|
||||
if existing, ok := runningCollectors[id]; ok {
|
||||
_ = existing.Stop()
|
||||
delete(runningCollectors, id)
|
||||
}
|
||||
stopCollector(id)
|
||||
|
||||
lp, err := GetLogProviderFromProvider(provider)
|
||||
if err != nil {
|
||||
@@ -120,15 +117,53 @@ func startOpenClawProvider(provider *Provider) {
|
||||
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 = ?", "Log", "Agent", "OpenClaw").Find(&providers)
|
||||
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
|
||||
}
|
||||
|
||||
804
object/openclaw_session_graph.go
Normal file
804
object/openclaw_session_graph.go
Normal 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
|
||||
}
|
||||
733
object/openclaw_transcript_sync.go
Normal file
733
object/openclaw_transcript_sync.go
Normal 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]) + "…"
|
||||
}
|
||||
@@ -254,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
|
||||
}
|
||||
@@ -278,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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
314
object/permission_rbac_dedup_test.go
Normal file
314
object/permission_rbac_dedup_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -17,6 +17,8 @@ package object
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
@@ -48,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"`
|
||||
@@ -81,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 {
|
||||
@@ -232,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 {
|
||||
@@ -257,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
|
||||
}
|
||||
|
||||
@@ -273,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
|
||||
}
|
||||
|
||||
@@ -287,6 +308,10 @@ func DeleteProvider(provider *Provider) (bool, error) {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if affected != 0 {
|
||||
stopLogProviderRuntime(provider.GetId())
|
||||
}
|
||||
|
||||
return affected != 0, nil
|
||||
}
|
||||
|
||||
@@ -638,6 +663,11 @@ func GetLogProviderFromProvider(provider *Provider) (log.LogProvider, error) {
|
||||
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{
|
||||
@@ -659,3 +689,43 @@ func GetLogProviderFromProvider(provider *Provider) (log.LogProvider, error) {
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -72,16 +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()
|
||||
|
||||
syncServerTools(server)
|
||||
_, 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
|
||||
}
|
||||
@@ -89,25 +96,66 @@ func UpdateServer(id string, server *Server) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func syncServerTools(server *Server) {
|
||||
if server.Tools == nil {
|
||||
server.Tools = []*Tool{}
|
||||
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
|
||||
return err
|
||||
}
|
||||
|
||||
var newTools []*Tool
|
||||
for _, tool := range tools {
|
||||
oldToolIndex := slices.IndexFunc(server.Tools, func(oldTool *Tool) bool {
|
||||
oldToolIndex := slices.IndexFunc(oldTools, func(oldTool *Tool) bool {
|
||||
return oldTool.Name == tool.Name
|
||||
})
|
||||
|
||||
isAllowed := true
|
||||
if oldToolIndex != -1 {
|
||||
isAllowed = server.Tools[oldToolIndex].IsAllowed
|
||||
isAllowed = oldTools[oldToolIndex].IsAllowed
|
||||
}
|
||||
|
||||
newTool := Tool{
|
||||
@@ -118,6 +166,7 @@ func syncServerTools(server *Server) {
|
||||
}
|
||||
|
||||
server.Tools = newTools
|
||||
return nil
|
||||
}
|
||||
|
||||
func AddServer(server *Server) (bool, error) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
157
object/token_dpop.go
Normal file
157
object/token_dpop.go
Normal 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
|
||||
}
|
||||
@@ -15,247 +15,15 @@
|
||||
package object
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/casdoor/casdoor/i18n"
|
||||
"github.com/casdoor/casdoor/idp"
|
||||
"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"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 GetOAuthToken(grantType string, clientId string, clientSecret string, code string, verifier string, scope string, nonce string, username string, password string, host string, refreshToken string, tag string, avatar string, lang string, subjectToken string, subjectTokenType string, assertion string, clientAssertion string, clientAssertionType string, audience string, resource string) (interface{}, error) {
|
||||
func GetOAuthToken(grantType string, clientId string, clientSecret string, code string, verifier string, scope string, nonce string, username string, password string, host string, refreshToken string, tag string, avatar string, lang string, subjectToken string, subjectTokenType string, assertion string, clientAssertion string, clientAssertionType string, audience string, resource string, dpopProof string) (interface{}, error) {
|
||||
var (
|
||||
application *Application
|
||||
err error
|
||||
@@ -292,7 +60,6 @@ func GetOAuthToken(grantType string, clientId string, clientSecret string, code
|
||||
}
|
||||
|
||||
// Check if grantType is allowed in the current application
|
||||
|
||||
if !IsGrantTypeValid(grantType, application.GrantTypes) && tag == "" {
|
||||
return &TokenError{
|
||||
Error: UnsupportedGrantType,
|
||||
@@ -305,20 +72,20 @@ func GetOAuthToken(grantType string, clientId string, clientSecret string, code
|
||||
switch grantType {
|
||||
case "authorization_code": // Authorization Code Grant
|
||||
token, tokenError, err = GetAuthorizationCodeToken(application, clientSecret, code, verifier, resource)
|
||||
case "password": // Resource Owner Password Credentials Grant
|
||||
case "password": // Resource Owner Password Credentials Grant
|
||||
token, tokenError, err = GetPasswordToken(application, username, password, scope, host)
|
||||
case "client_credentials": // Client Credentials Grant
|
||||
token, tokenError, err = GetClientCredentialsToken(application, clientSecret, scope, host)
|
||||
case "token", "id_token": // Implicit Grant
|
||||
token, tokenError, err = GetImplicitToken(application, username, scope, nonce, host)
|
||||
token, tokenError, err = GetImplicitToken(application, username, password, scope, nonce, host)
|
||||
case "urn:ietf:params:oauth:grant-type:jwt-bearer":
|
||||
token, tokenError, err = GetJwtBearerToken(application, assertion, scope, nonce, host)
|
||||
case "urn:ietf:params:oauth:grant-type:device_code":
|
||||
token, tokenError, err = GetImplicitToken(application, username, scope, nonce, host)
|
||||
token, tokenError, err = GetImplicitToken(application, username, password, scope, nonce, host)
|
||||
case "urn:ietf:params:oauth:grant-type:token-exchange": // Token Exchange Grant (RFC 8693)
|
||||
token, tokenError, err = GetTokenExchangeToken(application, clientSecret, subjectToken, subjectTokenType, audience, scope, host)
|
||||
case "refresh_token":
|
||||
refreshToken2, err := RefreshToken(application, grantType, refreshToken, scope, clientId, clientSecret, host)
|
||||
refreshToken2, err := RefreshToken(application, grantType, refreshToken, scope, clientId, clientSecret, host, dpopProof)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -341,6 +108,23 @@ func GetOAuthToken(grantType string, clientId string, clientSecret string, code
|
||||
return tokenError, nil
|
||||
}
|
||||
|
||||
// Apply DPoP binding (RFC 9449) if a DPoP proof was supplied by the client.
|
||||
if dpopProof != "" {
|
||||
dpopHtu := GetDPoPHtu(host, "/api/login/oauth/access_token")
|
||||
jkt, dpopErr := ValidateDPoPProof(dpopProof, "POST", dpopHtu, "")
|
||||
if dpopErr != nil {
|
||||
return &TokenError{
|
||||
Error: "invalid_dpop_proof",
|
||||
ErrorDescription: dpopErr.Error(),
|
||||
}, nil
|
||||
}
|
||||
token.TokenType = "DPoP"
|
||||
token.DPoPJkt = jkt
|
||||
if err = updateTokenDPoP(token); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
token.CodeIsUsed = true
|
||||
|
||||
_, err = updateUsedByCode(token)
|
||||
@@ -360,392 +144,7 @@ func GetOAuthToken(grantType string, clientId string, clientSecret string, code
|
||||
return tokenWrapper, nil
|
||||
}
|
||||
|
||||
func RefreshToken(application *Application, grantType string, refreshToken string, scope string, clientId string, clientSecret string, host string) (interface{}, error) {
|
||||
// check parameters
|
||||
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
|
||||
}
|
||||
|
||||
_, 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
|
||||
}
|
||||
|
||||
// PkceChallenge: base64-URL-encoded SHA256 hash of verifier, per rfc 7636
|
||||
func pkceChallenge(verifier string) string {
|
||||
sum := sha256.Sum256([]byte(verifier))
|
||||
challenge := base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(sum[:])
|
||||
return challenge
|
||||
}
|
||||
|
||||
// IsGrantTypeValid
|
||||
// Check 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
|
||||
}
|
||||
|
||||
// createGuestUserToken creates a new guest user and returns a token for them
|
||||
func createGuestUserToken(application *Application, clientSecret string, verifier string) (*Token, *TokenError, error) {
|
||||
// Verify client secret if provided
|
||||
if clientSecret != "" && application.ClientSecret != clientSecret {
|
||||
return nil, &TokenError{
|
||||
Error: InvalidClient,
|
||||
ErrorDescription: "client_secret is invalid",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Generate a unique guest username
|
||||
guestUsername := generateGuestUsername()
|
||||
|
||||
// Generate a random password for the guest user
|
||||
guestPassword := util.GenerateId()
|
||||
|
||||
// Get organization
|
||||
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
|
||||
}
|
||||
|
||||
// Get initial score
|
||||
initScore, err := organization.GetInitScore()
|
||||
if err != nil {
|
||||
return nil, &TokenError{
|
||||
Error: EndpointError,
|
||||
ErrorDescription: fmt.Sprintf("failed to get init score: %s", err.Error()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Generate a unique user ID within the confines of the application
|
||||
newUserId, idErr := GenerateIdForNewUser(application)
|
||||
if idErr != nil {
|
||||
// If we fail to generate a unique user ID, we can fallback to a random ID
|
||||
newUserId = util.GenerateId()
|
||||
}
|
||||
|
||||
// Create the guest user
|
||||
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),
|
||||
}
|
||||
|
||||
// Add the user
|
||||
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
|
||||
}
|
||||
|
||||
// Extend user with roles and permissions
|
||||
err = ExtendUserWithRolesAndPermissions(guestUser)
|
||||
if err != nil {
|
||||
return nil, &TokenError{
|
||||
Error: EndpointError,
|
||||
ErrorDescription: fmt.Sprintf("failed to extend user: %s", err.Error()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Generate JWT token
|
||||
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
|
||||
}
|
||||
|
||||
// Create token object
|
||||
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())
|
||||
}
|
||||
|
||||
// GetAuthorizationCodeToken
|
||||
// Authorization code flow
|
||||
// GetAuthorizationCodeToken handles the Authorization Code Grant flow.
|
||||
func GetAuthorizationCodeToken(application *Application, clientSecret string, code string, verifier string, resource string) (*Token, *TokenError, error) {
|
||||
if code == "" {
|
||||
return nil, &TokenError{
|
||||
@@ -756,6 +155,24 @@ func GetAuthorizationCodeToken(application *Application, clientSecret string, co
|
||||
|
||||
// Handle guest user creation
|
||||
if code == "guest-user" {
|
||||
if application.Organization == "built-in" {
|
||||
return nil, &TokenError{
|
||||
Error: InvalidGrant,
|
||||
ErrorDescription: "guest signin is not allowed for built-in organization",
|
||||
}, nil
|
||||
}
|
||||
if !application.EnableGuestSignin {
|
||||
return nil, &TokenError{
|
||||
Error: InvalidGrant,
|
||||
ErrorDescription: "guest signin is not enabled for this application",
|
||||
}, nil
|
||||
}
|
||||
if !application.EnableSignUp {
|
||||
return nil, &TokenError{
|
||||
Error: InvalidGrant,
|
||||
ErrorDescription: "sign up is not enabled for this application",
|
||||
}, nil
|
||||
}
|
||||
return createGuestUserToken(application, clientSecret, verifier)
|
||||
}
|
||||
|
||||
@@ -833,8 +250,7 @@ func GetAuthorizationCodeToken(application *Application, clientSecret string, co
|
||||
return token, nil, nil
|
||||
}
|
||||
|
||||
// GetPasswordToken
|
||||
// Resource Owner Password Credentials flow
|
||||
// GetPasswordToken handles the Resource Owner Password Credentials Grant flow.
|
||||
func GetPasswordToken(application *Application, username string, password string, scope string, host string) (*Token, *TokenError, error) {
|
||||
expandedScope, ok := IsScopeValidAndExpand(scope, application)
|
||||
if !ok {
|
||||
@@ -917,8 +333,7 @@ func GetPasswordToken(application *Application, username string, password string
|
||||
return token, nil, nil
|
||||
}
|
||||
|
||||
// GetClientCredentialsToken
|
||||
// Client Credentials flow
|
||||
// GetClientCredentialsToken handles the Client Credentials Grant flow.
|
||||
func GetClientCredentialsToken(application *Application, clientSecret string, scope string, host string) (*Token, *TokenError, error) {
|
||||
if application.ClientSecret != clientSecret {
|
||||
return nil, &TokenError{
|
||||
@@ -970,18 +385,8 @@ func GetClientCredentialsToken(application *Application, clientSecret string, sc
|
||||
return token, nil, nil
|
||||
}
|
||||
|
||||
// GetImplicitToken
|
||||
// Implicit flow
|
||||
func GetImplicitToken(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
|
||||
|
||||
// GetImplicitToken handles the Implicit Grant flow (requires password verification).
|
||||
func GetImplicitToken(application *Application, username string, password string, scope string, nonce string, host string) (*Token, *TokenError, error) {
|
||||
user, err := GetUserByFields(application.Organization, username)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
@@ -992,22 +397,29 @@ func GetImplicitToken(application *Application, username string, scope string, n
|
||||
ErrorDescription: "the user does not exist",
|
||||
}, nil
|
||||
}
|
||||
if user.IsForbidden {
|
||||
|
||||
if user.Ldap != "" {
|
||||
err = CheckLdapUserPassword(user, password, "en")
|
||||
} else {
|
||||
if user.Password == "" {
|
||||
return nil, &TokenError{
|
||||
Error: InvalidGrant,
|
||||
ErrorDescription: "OAuth users cannot use implicit grant type, please use authorization code flow",
|
||||
}, nil
|
||||
}
|
||||
err = CheckPassword(user, password, "en")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, &TokenError{
|
||||
Error: InvalidGrant,
|
||||
ErrorDescription: "the user is forbidden to sign in, please contact the administrator",
|
||||
ErrorDescription: fmt.Sprintf("invalid username or password: %s", err.Error()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
token, err := GetTokenByUser(application, user, scope, nonce, host)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return token, nil, nil
|
||||
return mintImplicitToken(application, username, scope, nonce, host)
|
||||
}
|
||||
|
||||
// GetJwtBearerToken
|
||||
// RFC 7523
|
||||
// GetJwtBearerToken handles the JWT Bearer Grant flow (RFC 7523).
|
||||
func GetJwtBearerToken(application *Application, assertion string, scope string, nonce string, host string) (*Token, *TokenError, error) {
|
||||
ok, claims, err := ValidateJwtAssertion(assertion, application, host)
|
||||
if err != nil || !ok {
|
||||
@@ -1024,68 +436,11 @@ func GetJwtBearerToken(application *Application, assertion string, scope string,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return GetImplicitToken(application, claims.Subject, scope, nonce, host)
|
||||
// JWT assertion has already been validated above; skip password re-verification
|
||||
return mintImplicitToken(application, claims.Subject, scope, nonce, host)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// GetTokenByUser
|
||||
// Implicit flow
|
||||
// GetTokenByUser mints a token for the given user (Implicit flow helper).
|
||||
func GetTokenByUser(application *Application, user *User, scope string, nonce string, host string) (*Token, error) {
|
||||
err := ExtendUserWithRolesAndPermissions(user)
|
||||
if err != nil {
|
||||
@@ -1120,8 +475,7 @@ func GetTokenByUser(application *Application, user *User, scope string, nonce st
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// GetWechatMiniProgramToken
|
||||
// Wechat Mini Program flow
|
||||
// GetWechatMiniProgramToken handles the WeChat Mini Program flow.
|
||||
func GetWechatMiniProgramToken(application *Application, code string, host string, username string, avatar string, lang string) (*Token, *TokenError, error) {
|
||||
mpProvider := GetWechatMiniProgramProvider(application)
|
||||
if mpProvider == nil {
|
||||
@@ -1236,11 +590,9 @@ func GetWechatMiniProgramToken(application *Application, code string, host strin
|
||||
return token, nil, nil
|
||||
}
|
||||
|
||||
// GetTokenExchangeToken
|
||||
// Token Exchange Grant (RFC 8693)
|
||||
// Exchanges a subject token for a new token with different audience or scope
|
||||
// GetTokenExchangeToken handles the Token Exchange Grant flow (RFC 8693).
|
||||
// Exchanges a subject token for a new token with different audience or scope.
|
||||
func GetTokenExchangeToken(application *Application, clientSecret string, subjectToken string, subjectTokenType string, audience string, scope string, host string) (*Token, *TokenError, error) {
|
||||
// Verify client secret
|
||||
if application.ClientSecret != clientSecret {
|
||||
return nil, &TokenError{
|
||||
Error: InvalidClient,
|
||||
@@ -1248,7 +600,6 @@ func GetTokenExchangeToken(application *Application, clientSecret string, subjec
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Validate subject_token parameter
|
||||
if subjectToken == "" {
|
||||
return nil, &TokenError{
|
||||
Error: InvalidRequest,
|
||||
@@ -1256,13 +607,11 @@ func GetTokenExchangeToken(application *Application, clientSecret string, subjec
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Validate subject_token_type parameter
|
||||
// RFC 8693 defines standard token type identifiers
|
||||
if subjectTokenType == "" {
|
||||
subjectTokenType = "urn:ietf:params:oauth:token-type:access_token" // Default to access_token
|
||||
}
|
||||
|
||||
// Support common token types
|
||||
supportedTokenTypes := []string{
|
||||
"urn:ietf:params:oauth:token-type:access_token",
|
||||
"urn:ietf:params:oauth:token-type:jwt",
|
||||
@@ -1284,45 +633,14 @@ func GetTokenExchangeToken(application *Application, clientSecret string, subjec
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Get certificate for token validation
|
||||
cert, err := getCertByApplication(application)
|
||||
subjectOwner, subjectName, subjectScope, tokenError, err := parseAndValidateSubjectToken(subjectToken, application.ClientId)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if cert == nil {
|
||||
return nil, &TokenError{
|
||||
Error: EndpointError,
|
||||
ErrorDescription: fmt.Sprintf("cert: %s cannot be found", application.Cert),
|
||||
}, nil
|
||||
if tokenError != nil {
|
||||
return nil, tokenError, nil
|
||||
}
|
||||
|
||||
// Parse and validate the subject token
|
||||
var subjectOwner, subjectName, subjectScope string
|
||||
if application.TokenFormat == "JWT-Standard" {
|
||||
standardClaims, err := ParseStandardJwtToken(subjectToken, cert)
|
||||
if err != nil {
|
||||
return nil, &TokenError{
|
||||
Error: InvalidGrant,
|
||||
ErrorDescription: fmt.Sprintf("invalid subject_token: %s", err.Error()),
|
||||
}, nil
|
||||
}
|
||||
subjectOwner = standardClaims.Owner
|
||||
subjectName = standardClaims.Name
|
||||
subjectScope = standardClaims.Scope
|
||||
} else {
|
||||
claims, err := ParseJwtToken(subjectToken, cert)
|
||||
if err != nil {
|
||||
return nil, &TokenError{
|
||||
Error: InvalidGrant,
|
||||
ErrorDescription: fmt.Sprintf("invalid subject_token: %s", err.Error()),
|
||||
}, nil
|
||||
}
|
||||
subjectOwner = claims.Owner
|
||||
subjectName = claims.Name
|
||||
subjectScope = claims.Scope
|
||||
}
|
||||
|
||||
// Get the user from the subject token
|
||||
user, err := getUser(subjectOwner, subjectName)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
@@ -1341,20 +659,17 @@ func GetTokenExchangeToken(application *Application, clientSecret string, subjec
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Handle scope parameter
|
||||
// If scope is not provided, use the scope from the subject token
|
||||
// If scope is provided, it should be a subset of the subject token's scope (downscoping)
|
||||
// If scope is not provided, use the scope from the subject token.
|
||||
// If scope is provided, it should be a subset of the subject token's scope (downscoping).
|
||||
if scope == "" {
|
||||
scope = subjectScope
|
||||
} else {
|
||||
// Validate scope downscoping (basic implementation)
|
||||
// In a production environment, you would implement more sophisticated scope validation
|
||||
if subjectScope != "" {
|
||||
subjectScopes := strings.Split(subjectScope, " ")
|
||||
requestedScopes := strings.Split(scope, " ")
|
||||
for _, requestedScope := range requestedScopes {
|
||||
if requestedScope == "" {
|
||||
continue // Skip empty strings
|
||||
continue
|
||||
}
|
||||
found := false
|
||||
for _, existingScope := range subjectScopes {
|
||||
@@ -1373,13 +688,11 @@ func GetTokenExchangeToken(application *Application, clientSecret string, subjec
|
||||
}
|
||||
}
|
||||
|
||||
// Extend user with roles and permissions
|
||||
err = ExtendUserWithRolesAndPermissions(user)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Generate new JWT token
|
||||
accessToken, refreshToken, tokenName, err := generateJwtToken(application, user, "", "", "", scope, "", host)
|
||||
if err != nil {
|
||||
return nil, &TokenError{
|
||||
@@ -1388,7 +701,6 @@ func GetTokenExchangeToken(application *Application, clientSecret string, subjec
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Create token object
|
||||
token := &Token{
|
||||
Owner: application.Owner,
|
||||
Name: tokenName,
|
||||
|
||||
800
object/token_oauth_util.go
Normal file
800
object/token_oauth_util.go
Normal 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())
|
||||
}
|
||||
@@ -406,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
|
||||
|
||||
@@ -37,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 {
|
||||
|
||||
@@ -46,6 +46,7 @@ type OidcDiscovery struct {
|
||||
RequestParameterSupported bool `json:"request_parameter_supported"`
|
||||
RequestObjectSigningAlgValuesSupported []string `json:"request_object_signing_alg_values_supported"`
|
||||
EndSessionEndpoint string `json:"end_session_endpoint"`
|
||||
DPoPSigningAlgValuesSupported []string `json:"dpop_signing_alg_values_supported,omitempty"` // RFC 9449
|
||||
}
|
||||
|
||||
type WebFinger struct {
|
||||
@@ -167,6 +168,7 @@ func GetOidcDiscovery(host string, applicationName string) OidcDiscovery {
|
||||
RequestParameterSupported: true,
|
||||
RequestObjectSigningAlgValuesSupported: []string{"HS256", "HS384", "HS512"},
|
||||
EndSessionEndpoint: fmt.Sprintf("%s/api/logout", originBackend),
|
||||
DPoPSigningAlgValuesSupported: []string{"RS256", "RS512", "ES256", "ES384", "ES512", "PS256", "PS384", "PS512"},
|
||||
}
|
||||
|
||||
return oidcDiscovery
|
||||
|
||||
6
package-lock.json
generated
Normal file
6
package-lock.json
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "casdoor",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
@@ -141,6 +141,19 @@ func getObject(ctx *context.Context) (string, string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// For non-GET requests, if the `id` query param is present it is the
|
||||
// authoritative identifier of the object being operated on. Use it
|
||||
// instead of the request body so that an attacker cannot spoof the
|
||||
// object owner by injecting "owner":"admin" (or any other value) into
|
||||
// the request body while pointing the URL at a different organization's
|
||||
// resource.
|
||||
if id := ctx.Input.Query("id"); id != "" {
|
||||
owner, name, err := util.GetOwnerAndNameFromIdWithError(id)
|
||||
if err == nil {
|
||||
return owner, name, nil
|
||||
}
|
||||
}
|
||||
|
||||
body := ctx.Input.RequestBody
|
||||
if len(body) == 0 {
|
||||
return ctx.Request.Form.Get("owner"), ctx.Request.Form.Get("name"), nil
|
||||
@@ -347,6 +360,9 @@ func writePermissionLog(objOwner, subOwner, subName, method, urlPath string, all
|
||||
if provider.Type == "System Log" {
|
||||
continue
|
||||
}
|
||||
if provider.State == "Disabled" {
|
||||
continue
|
||||
}
|
||||
logProvider, err := object.GetLogProviderFromProvider(provider)
|
||||
if err != nil {
|
||||
continue
|
||||
|
||||
@@ -70,6 +70,25 @@ func AutoSigninFilter(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Validate DPoP proof for DPoP-bound tokens (RFC 9449).
|
||||
if token.TokenType == "DPoP" {
|
||||
dpopProof := ctx.Request.Header.Get("DPoP")
|
||||
if dpopProof == "" {
|
||||
responseError(ctx, "DPoP proof header required for DPoP-bound access token")
|
||||
return
|
||||
}
|
||||
htu := object.GetDPoPHtu(ctx.Request.Host, ctx.Request.URL.Path)
|
||||
jkt, dpopErr := object.ValidateDPoPProof(dpopProof, ctx.Request.Method, htu, accessToken)
|
||||
if dpopErr != nil {
|
||||
responseError(ctx, fmt.Sprintf("DPoP proof validation failed: %s", dpopErr.Error()))
|
||||
return
|
||||
}
|
||||
if jkt != token.DPoPJkt {
|
||||
responseError(ctx, "DPoP proof key binding mismatch")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
userId := util.GetId(token.Organization, token.User)
|
||||
application, err := object.GetApplicationByUserId(fmt.Sprintf("app/%s", token.Application))
|
||||
if err != nil {
|
||||
@@ -97,6 +116,17 @@ func AutoSigninFilter(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// "/page?accessKey=123&accessSecret=456"
|
||||
userId, err = getUsernameByAccessKey(ctx)
|
||||
if err != nil {
|
||||
responseError(ctx, err.Error())
|
||||
return
|
||||
}
|
||||
if userId != "" {
|
||||
setSessionUser(ctx, userId)
|
||||
return
|
||||
}
|
||||
|
||||
// "/page?username=built-in/admin&password=123"
|
||||
userId = ctx.Input.Query("username")
|
||||
password := ctx.Input.Query("password")
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/beego/beego/v2/server/web/context"
|
||||
"github.com/casdoor/casdoor/conf"
|
||||
@@ -110,8 +111,8 @@ func denyMcpRequest(ctx *context.Context) {
|
||||
}
|
||||
|
||||
func getUsernameByClientIdSecret(ctx *context.Context) (string, error) {
|
||||
clientId, clientSecret, ok := ctx.Request.BasicAuth()
|
||||
if !ok {
|
||||
clientId, clientSecret, fromBasicAuth := ctx.Request.BasicAuth()
|
||||
if !fromBasicAuth {
|
||||
clientId = ctx.Input.Query("clientId")
|
||||
clientSecret = ctx.Input.Query("clientSecret")
|
||||
}
|
||||
@@ -125,16 +126,71 @@ func getUsernameByClientIdSecret(ctx *context.Context) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
if application == nil {
|
||||
if fromBasicAuth {
|
||||
// The Basic Auth credentials may come from a reverse proxy protecting Casdoor with
|
||||
// HTTP Basic Auth. In that case, the username is not an OAuth client ID, so we
|
||||
// silently ignore it instead of returning an error that would break the whole system.
|
||||
return "", nil
|
||||
}
|
||||
return "", fmt.Errorf("Application not found for client ID: %s", clientId)
|
||||
}
|
||||
|
||||
if application.ClientSecret != clientSecret {
|
||||
if fromBasicAuth {
|
||||
// Same as above: the secret mismatch may be due to proxy-level Basic Auth credentials.
|
||||
return "", nil
|
||||
}
|
||||
return "", fmt.Errorf("Incorrect client secret for application: %s", application.Name)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("app/%s", application.Name), nil
|
||||
}
|
||||
|
||||
func getUsernameByAccessKey(ctx *context.Context) (string, error) {
|
||||
accessKey := ctx.Input.Query("accessKey")
|
||||
accessSecret := ctx.Input.Query("accessSecret")
|
||||
|
||||
if accessKey == "" || accessSecret == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
key, err := object.GetKeyByAccessKey(accessKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if key == nil {
|
||||
return "", fmt.Errorf("Access key not found: %s", accessKey)
|
||||
}
|
||||
|
||||
if key.AccessSecret != accessSecret {
|
||||
return "", fmt.Errorf("Incorrect access secret for key: %s", key.Name)
|
||||
}
|
||||
|
||||
if key.State != "Active" {
|
||||
return "", fmt.Errorf("Access key is not active: %s", key.Name)
|
||||
}
|
||||
|
||||
if key.ExpireTime != "" {
|
||||
expireTime, err := time.Parse(time.RFC3339, key.ExpireTime)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Invalid expire time format for key: %s", key.Name)
|
||||
}
|
||||
if time.Now().After(expireTime) {
|
||||
return "", fmt.Errorf("Access key has expired, expireTime = %s", key.ExpireTime)
|
||||
}
|
||||
}
|
||||
|
||||
if key.User != "" {
|
||||
return util.GetId(key.Organization, key.User), nil
|
||||
}
|
||||
|
||||
if key.Application != "" {
|
||||
return fmt.Sprintf("app/%s", key.Application), nil
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func getSessionUser(ctx *context.Context) string {
|
||||
user := ctx.Input.CruSession.Get(stdcontext.Background(), "username")
|
||||
if user == nil {
|
||||
@@ -182,8 +238,9 @@ func parseBearerToken(ctx *context.Context) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Accept both "Bearer" (RFC 6750) and "DPoP" (RFC 9449) authorization schemes.
|
||||
prefix := tokens[0]
|
||||
if prefix != "Bearer" {
|
||||
if prefix != "Bearer" && prefix != "DPoP" {
|
||||
return ""
|
||||
}
|
||||
|
||||
|
||||
48
routers/request_body_filter.go
Normal file
48
routers/request_body_filter.go
Normal file
@@ -0,0 +1,48 @@
|
||||
// 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 routers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/beego/beego/v2/server/web/context"
|
||||
)
|
||||
|
||||
// RequestBodyFilter reads the raw request body early (before Beego's CopyBody
|
||||
// and ParseForm) and caches it in Input.RequestBody. This prevents silent data
|
||||
// corruption when clients send requests without a Content-Type: application/json
|
||||
// header: without this filter, Beego's ParseForm may consume the body before
|
||||
// controllers can read it, causing json.Unmarshal to receive empty bytes and
|
||||
// produce zero-value structs that overwrite real data on AllCols().Update().
|
||||
func RequestBodyFilter(ctx *context.Context) {
|
||||
if ctx.Request.Method == http.MethodGet || ctx.Request.Method == http.MethodHead {
|
||||
return
|
||||
}
|
||||
if ctx.Request.Body == nil || ctx.Request.Body == http.NoBody {
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(ctx.Request.Body)
|
||||
if err != nil || len(body) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Restore Request.Body so Beego's subsequent CopyBody and ParseForm can read it.
|
||||
ctx.Request.Body = io.NopCloser(bytes.NewBuffer(body))
|
||||
// Cache the raw bytes directly so controllers always have access to them.
|
||||
ctx.Input.RequestBody = body
|
||||
}
|
||||
@@ -137,12 +137,15 @@ func InitAPI() {
|
||||
web.Router("/api/sync-intranet-servers", &controllers.ApiController{}, "POST:SyncIntranetServers")
|
||||
web.Router("/api/get-server", &controllers.ApiController{}, "GET:GetServer")
|
||||
web.Router("/api/update-server", &controllers.ApiController{}, "POST:UpdateServer")
|
||||
web.Router("/api/sync-mcp-tool", &controllers.ApiController{}, "POST:SyncMcpTool")
|
||||
web.Router("/api/add-server", &controllers.ApiController{}, "POST:AddServer")
|
||||
web.Router("/api/delete-server", &controllers.ApiController{}, "POST:DeleteServer")
|
||||
web.Router("/api/server/:owner/:name", &controllers.ApiController{}, "GET:ProxyServer")
|
||||
web.Router("/api/server/:owner/:name", &controllers.ApiController{}, "POST:ProxyServer")
|
||||
|
||||
web.Router("/api/get-entries", &controllers.ApiController{}, "GET:GetEntries")
|
||||
web.Router("/api/get-entry", &controllers.ApiController{}, "GET:GetEntry")
|
||||
web.Router("/api/get-openclaw-session-graph", &controllers.ApiController{}, "GET:GetOpenClawSessionGraph")
|
||||
web.Router("/api/update-entry", &controllers.ApiController{}, "POST:UpdateEntry")
|
||||
web.Router("/api/add-entry", &controllers.ApiController{}, "POST:AddEntry")
|
||||
web.Router("/api/delete-entry", &controllers.ApiController{}, "POST:DeleteEntry")
|
||||
@@ -245,6 +248,7 @@ func InitAPI() {
|
||||
web.Router("/api/update-product", &controllers.ApiController{}, "POST:UpdateProduct")
|
||||
web.Router("/api/add-product", &controllers.ApiController{}, "POST:AddProduct")
|
||||
web.Router("/api/delete-product", &controllers.ApiController{}, "POST:DeleteProduct")
|
||||
web.Router("/api/buy-product", &controllers.ApiController{}, "POST:BuyProduct")
|
||||
|
||||
web.Router("/api/get-orders", &controllers.ApiController{}, "GET:GetOrders")
|
||||
web.Router("/api/get-user-orders", &controllers.ApiController{}, "GET:GetUserOrders")
|
||||
|
||||
@@ -1138,6 +1138,70 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/buy-product": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Product API"
|
||||
],
|
||||
"description": "buy product using the deprecated compatibility endpoint, prefer place-order plus pay-order for new integrations",
|
||||
"operationId": "ApiController.BuyProduct",
|
||||
"deprecated": true,
|
||||
"parameters": [
|
||||
{
|
||||
"in": "query",
|
||||
"name": "id",
|
||||
"description": "The id ( owner/name ) of the product",
|
||||
"required": true,
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "providerName",
|
||||
"description": "The name of the provider",
|
||||
"required": true,
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "pricingName",
|
||||
"description": "The name of the pricing (for subscription)",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "planName",
|
||||
"description": "The name of the plan (for subscription)",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "userName",
|
||||
"description": "The username to buy product for (admin only)",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "paymentEnv",
|
||||
"description": "The payment environment",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "customPrice",
|
||||
"description": "Custom price for recharge products",
|
||||
"type": "number"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The Response object",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/controllers.Response"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/delete-adapter": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@@ -10488,4 +10552,4 @@
|
||||
"in": "header"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -734,6 +734,49 @@ paths:
|
||||
description: The Response object
|
||||
schema:
|
||||
$ref: '#/definitions/object.Userinfo'
|
||||
/api/buy-product:
|
||||
post:
|
||||
tags:
|
||||
- Product API
|
||||
description: buy product using the deprecated compatibility endpoint, prefer place-order plus pay-order for new integrations
|
||||
operationId: ApiController.BuyProduct
|
||||
deprecated: true
|
||||
parameters:
|
||||
- in: query
|
||||
name: id
|
||||
description: The id ( owner/name ) of the product
|
||||
required: true
|
||||
type: string
|
||||
- in: query
|
||||
name: providerName
|
||||
description: The name of the provider
|
||||
required: true
|
||||
type: string
|
||||
- in: query
|
||||
name: pricingName
|
||||
description: The name of the pricing (for subscription)
|
||||
type: string
|
||||
- in: query
|
||||
name: planName
|
||||
description: The name of the plan (for subscription)
|
||||
type: string
|
||||
- in: query
|
||||
name: userName
|
||||
description: The username to buy product for (admin only)
|
||||
type: string
|
||||
- in: query
|
||||
name: paymentEnv
|
||||
description: The payment environment
|
||||
type: string
|
||||
- in: query
|
||||
name: customPrice
|
||||
description: Custom price for recharge products
|
||||
type: number
|
||||
responses:
|
||||
"200":
|
||||
description: The Response object
|
||||
schema:
|
||||
$ref: '#/definitions/controllers.Response'
|
||||
/api/delete-adapter:
|
||||
post:
|
||||
tags:
|
||||
|
||||
32
util/json.go
32
util/json.go
@@ -67,3 +67,35 @@ func TryJsonToAnonymousStruct(j string) (interface{}, error) {
|
||||
}
|
||||
return i, nil
|
||||
}
|
||||
|
||||
// InterfaceToEnforceValue converts a single request value for use in Casbin ABAC enforcement.
|
||||
// - Strings that are valid JSON objects are converted to anonymous structs so Casbin can
|
||||
// access their fields (e.g. r.sub.DivisionGuid).
|
||||
// - Maps (map[string]interface{}) produced by direct JSON unmarshaling are re-marshaled and
|
||||
// then converted to anonymous structs in the same way.
|
||||
// - All other values are returned unchanged.
|
||||
func InterfaceToEnforceValue(v interface{}) interface{} {
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
jStruct, err := TryJsonToAnonymousStruct(val)
|
||||
if err == nil {
|
||||
return jStruct
|
||||
}
|
||||
return val
|
||||
case map[string]interface{}:
|
||||
// The value was already decoded as a JSON object; re-encode it so we
|
||||
// can reuse TryJsonToAnonymousStruct to produce a named-field struct
|
||||
// that Casbin can evaluate with dot-notation (r.sub.Field).
|
||||
jsonBytes, err := json.Marshal(val)
|
||||
if err != nil {
|
||||
return val
|
||||
}
|
||||
jStruct, err := TryJsonToAnonymousStruct(string(jsonBytes))
|
||||
if err == nil {
|
||||
return jStruct
|
||||
}
|
||||
return val
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
@@ -381,3 +381,25 @@ func StringToInterfaceArray2d(arrays [][]string) [][]interface{} {
|
||||
}
|
||||
return interfaceArrays
|
||||
}
|
||||
|
||||
// InterfaceToEnforceArray converts a []interface{} request for use in Casbin ABAC enforcement.
|
||||
// Each element is processed by InterfaceToEnforceValue: plain strings that are valid JSON
|
||||
// objects and map values decoded directly from JSON are both converted to anonymous structs
|
||||
// so Casbin can evaluate attribute-based rules with dot-notation (r.sub.Field).
|
||||
func InterfaceToEnforceArray(array []interface{}) []interface{} {
|
||||
result := make([]interface{}, len(array))
|
||||
for i, elem := range array {
|
||||
result[i] = InterfaceToEnforceValue(elem)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// InterfaceToEnforceArray2d applies InterfaceToEnforceArray to every row in a
|
||||
// two-dimensional slice, for use with Casbin BatchEnforce.
|
||||
func InterfaceToEnforceArray2d(arrays [][]interface{}) [][]interface{} {
|
||||
result := make([][]interface{}, len(arrays))
|
||||
for i, arr := range arrays {
|
||||
result[i] = InterfaceToEnforceArray(arr)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -25,17 +25,19 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
rePhone *regexp.Regexp
|
||||
ReWhiteSpace *regexp.Regexp
|
||||
ReFieldWhiteList *regexp.Regexp
|
||||
ReUserName *regexp.Regexp
|
||||
ReUserNameWithEmail *regexp.Regexp
|
||||
rePhone *regexp.Regexp
|
||||
ReWhiteSpace *regexp.Regexp
|
||||
ReFieldWhiteList *regexp.Regexp
|
||||
ReFieldWhiteListIdentifier *regexp.Regexp
|
||||
ReUserName *regexp.Regexp
|
||||
ReUserNameWithEmail *regexp.Regexp
|
||||
)
|
||||
|
||||
func init() {
|
||||
rePhone, _ = regexp.Compile(`(\d{3})\d*(\d{4})`)
|
||||
ReWhiteSpace, _ = regexp.Compile(`\s`)
|
||||
ReFieldWhiteList, _ = regexp.Compile(`^[A-Za-z0-9]+$`)
|
||||
ReFieldWhiteListIdentifier, _ = regexp.Compile(`^[A-Za-z][A-Za-z0-9_]*$`)
|
||||
ReUserName, _ = regexp.Compile("^[a-zA-Z0-9]+([-._][a-zA-Z0-9]+)*$")
|
||||
ReUserNameWithEmail, _ = regexp.Compile(`^([a-zA-Z0-9]+([-._][a-zA-Z0-9]+)*)|([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})$`) // Add support for email formats
|
||||
}
|
||||
@@ -104,6 +106,13 @@ func FilterField(field string) bool {
|
||||
return ReFieldWhiteList.MatchString(field)
|
||||
}
|
||||
|
||||
// FilterSQLIdentifier validates that field is a safe SQL column identifier.
|
||||
// It allows letters, digits, and underscores (e.g. "id_card", "created_time"),
|
||||
// and requires the name to start with a letter to block numeric/special-char attacks.
|
||||
func FilterSQLIdentifier(field string) bool {
|
||||
return ReFieldWhiteListIdentifier.MatchString(field)
|
||||
}
|
||||
|
||||
func IsValidOrigin(origin string) (bool, error) {
|
||||
urlObj, err := url.Parse(origin)
|
||||
if err != nil {
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@ant-design/cssinjs": "^1.23.0",
|
||||
"@ant-design/icons": "^5.6.1",
|
||||
"@ant-design/cssinjs": "^2.1.2",
|
||||
"@ant-design/icons": "^6.1.1",
|
||||
"@craco/craco": "^6.4.5",
|
||||
"@crowdin/cli": "^3.7.10",
|
||||
"@ctrl/tinycolor": "^3.5.0",
|
||||
@@ -24,11 +24,11 @@
|
||||
"@web3-onboard/sequence": "^2.0.8",
|
||||
"@web3-onboard/taho": "^2.0.5",
|
||||
"@web3-onboard/trust": "^2.0.4",
|
||||
"antd": "5.24.1",
|
||||
"antd-token-previewer": "^2.0.8",
|
||||
"antd": "6.3.5",
|
||||
"antd-token-previewer": "^3.0.0",
|
||||
"buffer": "^6.0.3",
|
||||
"cookie": "0.5.0",
|
||||
"codemirror": "^6.0.1",
|
||||
"cookie": "0.5.0",
|
||||
"copy-to-clipboard": "^3.3.1",
|
||||
"core-js": "^3.25.0",
|
||||
"craco-less": "^2.0.0",
|
||||
@@ -54,6 +54,7 @@
|
||||
"react-highlight-words": "^0.18.0",
|
||||
"react-i18next": "^11.8.7",
|
||||
"react-metamask-avatar": "^1.2.1",
|
||||
"reactflow": "^11.11.4",
|
||||
"react-router-dom": "^5.3.3",
|
||||
"react-scripts": "5.0.1",
|
||||
"react-social-login-buttons": "^3.4.0",
|
||||
@@ -106,6 +107,10 @@
|
||||
"stylelint-config-recommended-less": "^1.0.4",
|
||||
"stylelint-config-standard": "^28.0.0"
|
||||
},
|
||||
"resolutions": {
|
||||
"@ant-design/cssinjs": "^2.1.2",
|
||||
"rc-util": "^5.43.0"
|
||||
},
|
||||
"lint-staged": {
|
||||
"src/**/*.{css,less}": [
|
||||
"stylelint --fix"
|
||||
|
||||
@@ -125,7 +125,7 @@ class AdapterListPage extends BaseListPage {
|
||||
title: i18next.t("adapter:Use same DB"),
|
||||
dataIndex: "useSameDb",
|
||||
key: "useSameDb",
|
||||
width: "120px",
|
||||
width: "130px",
|
||||
sorter: true,
|
||||
render: (text, record, index) => {
|
||||
return (
|
||||
@@ -148,7 +148,7 @@ class AdapterListPage extends BaseListPage {
|
||||
title: i18next.t("syncer:Database type"),
|
||||
dataIndex: "databaseType",
|
||||
key: "databaseType",
|
||||
width: "120px",
|
||||
width: "140px",
|
||||
sorter: (a, b) => a.databaseType.localeCompare(b.databaseType),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -23,6 +23,7 @@ import {Alert, Button, ConfigProvider, Drawer, FloatButton, Layout, Result, Tool
|
||||
import {Route, Switch, withRouter} from "react-router-dom";
|
||||
import CustomGithubCorner from "./common/CustomGithubCorner";
|
||||
import * as Conf from "./Conf";
|
||||
import {shadcnThemeComponents, shadcnThemeToken} from "./shadcnTheme";
|
||||
|
||||
import * as Auth from "./auth/Auth";
|
||||
import EntryPage from "./EntryPage";
|
||||
@@ -177,7 +178,7 @@ class App extends Component {
|
||||
"/applications", "/providers", "/resources", "/certs", "/keys", // Identity
|
||||
"/roles", "/permissions", "/models", "/adapters", "/enforcers", // Authorization
|
||||
"/agents", "/servers", "/server-store", "/entries", "/sites", "/rules", // LLM AI
|
||||
"/sessions", "/records", "/tokens", "/verifications", // Logging & Auditing
|
||||
"/sessions", "/records", "/tokens", "/verifications", // Auditing
|
||||
"/products", "/orders", "/payments", "/plans", "/pricings", "/subscriptions", "/transactions", // Business
|
||||
"/sysinfo", "/forms", "/syncers", "/webhooks", "/webhook-events", "/tickets", "/swagger", // Admin
|
||||
];
|
||||
@@ -614,9 +615,11 @@ class App extends Component {
|
||||
locale={getAntdLocale(Setting.getLanguage())}
|
||||
theme={{
|
||||
token: {
|
||||
...shadcnThemeToken,
|
||||
colorPrimary: themeData.colorPrimary,
|
||||
borderRadius: themeData.borderRadius,
|
||||
},
|
||||
components: shadcnThemeComponents,
|
||||
algorithm: Setting.getAlgorithm(this.state.themeAlgorithm),
|
||||
}}>
|
||||
<StyleProvider hashPriority="high" transformers={[legacyLogicalPropertiesTransformer]}>
|
||||
@@ -756,10 +759,12 @@ class App extends Component {
|
||||
locale={getAntdLocale(Setting.getLanguage())}
|
||||
theme={{
|
||||
token: {
|
||||
...shadcnThemeToken,
|
||||
colorPrimary: this.state.themeData.colorPrimary,
|
||||
colorInfo: this.state.themeData.colorPrimary,
|
||||
borderRadius: this.state.themeData.borderRadius,
|
||||
},
|
||||
components: shadcnThemeComponents,
|
||||
algorithm: Setting.getAlgorithm(this.state.themeAlgorithm),
|
||||
}}>
|
||||
<StyleProvider hashPriority="high" transformers={[legacyLogicalPropertiesTransformer]}>
|
||||
|
||||
@@ -49,7 +49,7 @@ img {
|
||||
justify-content: center;
|
||||
border-radius: 5px;
|
||||
width: 45px;
|
||||
height: 64px;
|
||||
height: 52px;
|
||||
float: right;
|
||||
cursor: pointer;
|
||||
|
||||
@@ -58,14 +58,34 @@ img {
|
||||
}
|
||||
}
|
||||
|
||||
.saas-hosting-btn {
|
||||
font-weight: bold;
|
||||
background-color: rgba(87, 52, 211, 0.4);
|
||||
padding: 0 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 40px;
|
||||
border-radius: 5px;
|
||||
transition: background-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(87, 52, 211, 0.65);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(87, 52, 211, 0.35);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.org-select {
|
||||
display: flex;
|
||||
position: relative;
|
||||
transform: translateY(50%);
|
||||
margin: 0 10px !important;
|
||||
float: right;
|
||||
min-width: 120px;
|
||||
max-width: 180px;
|
||||
min-width: 200px;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.rightDropDown {
|
||||
@@ -85,6 +105,8 @@ img {
|
||||
box-shadow: 0 1px 5px 0 rgb(51 51 51 / 14%);
|
||||
flex: 1;
|
||||
align-items: stretch;
|
||||
margin: 0 3px 3px 3px !important;
|
||||
border-radius: 4px !important;
|
||||
}
|
||||
|
||||
.side-image {
|
||||
@@ -151,3 +173,12 @@ img {
|
||||
.ant-menu-horizontal {
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
.ant-layout-sider-children {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ant-menu-inline {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -594,6 +594,20 @@ class ApplicationEditPage extends React.Component {
|
||||
}} />
|
||||
</Col>
|
||||
</Row>
|
||||
{
|
||||
this.state.application.organization !== "built-in" ? (
|
||||
<Row style={{marginTop: "20px"}} >
|
||||
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 19 : 2}>
|
||||
{Setting.getLabel(i18next.t("application:Enable guest signin"), i18next.t("application:Enable guest signin - Tooltip"))} :
|
||||
</Col>
|
||||
<Col span={1} >
|
||||
<Switch checked={this.state.application.enableGuestSignin} onChange={checked => {
|
||||
this.updateApplicationField("enableGuestSignin", checked);
|
||||
}} />
|
||||
</Col>
|
||||
</Row>
|
||||
) : null
|
||||
}
|
||||
<Row style={{marginTop: "20px"}} >
|
||||
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 19 : 2}>
|
||||
{Setting.getLabel(i18next.t("application:Enable exclusive signin"), i18next.t("application:Enable exclusive signin - Tooltip"))} :
|
||||
|
||||
@@ -27,8 +27,8 @@ export let StaticBaseUrl = "https://cdn.casbin.org";
|
||||
export const InitThemeAlgorithm = true;
|
||||
export const ThemeDefault = {
|
||||
themeType: "default",
|
||||
colorPrimary: "#5734d3",
|
||||
borderRadius: 6,
|
||||
colorPrimary: "#262626",
|
||||
borderRadius: 10,
|
||||
isCompact: false,
|
||||
};
|
||||
|
||||
|
||||
@@ -18,12 +18,22 @@ import {Button, Popover, Table} from "antd";
|
||||
import moment from "moment";
|
||||
import * as Setting from "./Setting";
|
||||
import * as EntryBackend from "./backend/EntryBackend";
|
||||
import * as ProviderBackend from "./backend/ProviderBackend";
|
||||
import i18next from "i18next";
|
||||
import BaseListPage from "./BaseListPage";
|
||||
import PopconfirmModal from "./common/modal/PopconfirmModal";
|
||||
import Editor from "./common/Editor";
|
||||
import EntryMessageViewer from "./EntryMessageViewer";
|
||||
|
||||
class EntryListPage extends BaseListPage {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
...this.state,
|
||||
providerMap: {},
|
||||
providerOwner: "",
|
||||
};
|
||||
}
|
||||
|
||||
newEntry() {
|
||||
const randomHex = Math.random().toString(16).slice(2, 18);
|
||||
const owner = Setting.getRequestOrganization(this.props.account);
|
||||
@@ -77,31 +87,75 @@ class EntryListPage extends BaseListPage {
|
||||
});
|
||||
}
|
||||
|
||||
getProviders(owner) {
|
||||
if (!owner) {
|
||||
return Promise.resolve({});
|
||||
}
|
||||
|
||||
if (this.state.providerOwner === owner) {
|
||||
return Promise.resolve(this.state.providerMap);
|
||||
}
|
||||
|
||||
return ProviderBackend.getProviders(owner)
|
||||
.then((res) => {
|
||||
if (res.status !== "ok") {
|
||||
return {};
|
||||
}
|
||||
|
||||
const providerMap = {};
|
||||
(res.data || []).forEach((provider) => {
|
||||
if (provider?.category === "Log" && provider?.name) {
|
||||
providerMap[provider.name] = provider;
|
||||
}
|
||||
});
|
||||
|
||||
this.setState({
|
||||
providerMap,
|
||||
providerOwner: owner,
|
||||
});
|
||||
|
||||
return providerMap;
|
||||
})
|
||||
.catch(() => {
|
||||
this.setState({
|
||||
providerMap: {},
|
||||
providerOwner: "",
|
||||
});
|
||||
return {};
|
||||
});
|
||||
}
|
||||
|
||||
fetch = (params = {}) => {
|
||||
const field = params.searchedColumn, value = params.searchText;
|
||||
const sortField = params.sortField, sortOrder = params.sortOrder;
|
||||
const owner = Setting.getRequestOrganization(this.props.account);
|
||||
if (!params.pagination) {
|
||||
params.pagination = {current: 1, pageSize: 10};
|
||||
}
|
||||
|
||||
this.setState({loading: true});
|
||||
EntryBackend.getEntries(Setting.getRequestOrganization(this.props.account), params.pagination.current, params.pagination.pageSize, field, value, sortField, sortOrder)
|
||||
.then((res) => {
|
||||
this.setState({loading: false});
|
||||
if (res.status === "ok") {
|
||||
this.setState({
|
||||
data: res.data,
|
||||
pagination: {
|
||||
...params.pagination,
|
||||
total: res.data2,
|
||||
},
|
||||
searchText: params.searchText,
|
||||
searchedColumn: params.searchedColumn,
|
||||
});
|
||||
} else {
|
||||
Setting.showMessage("error", `${i18next.t("general:Failed to get")}: ${res.msg}`);
|
||||
}
|
||||
});
|
||||
Promise.all([
|
||||
EntryBackend.getEntries(owner, params.pagination.current, params.pagination.pageSize, field, value, sortField, sortOrder),
|
||||
this.getProviders(owner),
|
||||
]).then(([res]) => {
|
||||
this.setState({loading: false});
|
||||
if (res.status === "ok") {
|
||||
this.setState({
|
||||
data: res.data,
|
||||
pagination: {
|
||||
...params.pagination,
|
||||
total: res.data2,
|
||||
},
|
||||
searchText: params.searchText,
|
||||
searchedColumn: params.searchedColumn,
|
||||
});
|
||||
} else {
|
||||
Setting.showMessage("error", `${i18next.t("general:Failed to get")}: ${res.msg}`);
|
||||
}
|
||||
}).catch(error => {
|
||||
this.setState({loading: false});
|
||||
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
|
||||
});
|
||||
};
|
||||
|
||||
renderTable(entries) {
|
||||
@@ -197,14 +251,26 @@ class EntryListPage extends BaseListPage {
|
||||
key: "message",
|
||||
sorter: true,
|
||||
...this.getColumnSearchProps("message"),
|
||||
render: (text) => {
|
||||
render: (text, record) => {
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Popover placement="topRight" content={() => (
|
||||
<Editor value={text} readOnly={true} />
|
||||
)} title="" trigger="hover">
|
||||
<Popover
|
||||
placement="topRight"
|
||||
content={(
|
||||
<div style={{width: Setting.isMobile() ? Math.min(window.innerWidth - 40, 720) : 720}}>
|
||||
<EntryMessageViewer
|
||||
entry={record}
|
||||
provider={this.state.providerMap[record.provider] ?? null}
|
||||
labelSpan={24}
|
||||
contentSpan={24}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
title=""
|
||||
trigger="hover"
|
||||
>
|
||||
{Setting.getShortText(text, 60)}
|
||||
</Popover>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,10 @@ import {Alert, Button, Col, Descriptions, Drawer, Row, Table} from "antd";
|
||||
import * as Setting from "./Setting";
|
||||
import i18next from "i18next";
|
||||
import Editor from "./common/Editor";
|
||||
import SELinuxEntryViewer from "./SELinuxEntryViewer";
|
||||
import * as ProviderBackend from "./backend/ProviderBackend";
|
||||
import OpenClawSessionGraphViewer from "./OpenClawSessionGraphViewer";
|
||||
import {isOpenClawSessionEntry} from "./OpenClawSessionGraphUtils";
|
||||
|
||||
class EntryMessageViewer extends React.Component {
|
||||
constructor(props) {
|
||||
@@ -24,7 +28,80 @@ class EntryMessageViewer extends React.Component {
|
||||
this.state = {
|
||||
traceSpanDrawerVisible: false,
|
||||
selectedTraceSpan: null,
|
||||
provider: null,
|
||||
};
|
||||
this.pendingProviderRequestKey = "";
|
||||
this.isUnmounted = false;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.isUnmounted = false;
|
||||
this.getProvider();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (
|
||||
prevProps.entry?.provider !== this.props.entry?.provider
|
||||
|| prevProps.entry?.owner !== this.props.entry?.owner
|
||||
|| prevProps.provider !== this.props.provider
|
||||
) {
|
||||
this.getProvider();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.isUnmounted = true;
|
||||
this.pendingProviderRequestKey = "";
|
||||
}
|
||||
|
||||
getProvider() {
|
||||
if (this.props.provider) {
|
||||
this.pendingProviderRequestKey = "";
|
||||
if (this.state.provider !== null) {
|
||||
this.setState({provider: null});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const providerName = this.props.entry?.provider;
|
||||
const owner = this.props.entry?.owner;
|
||||
|
||||
if (!providerName || !owner) {
|
||||
this.pendingProviderRequestKey = "";
|
||||
if (this.state.provider !== null) {
|
||||
this.setState({provider: null});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const requestKey = `${owner}/${providerName}`;
|
||||
this.pendingProviderRequestKey = requestKey;
|
||||
|
||||
ProviderBackend.getProvider(owner, providerName)
|
||||
.then((res) => {
|
||||
if (this.isUnmounted || this.pendingProviderRequestKey !== requestKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.status === "ok") {
|
||||
this.setState({
|
||||
provider: res.data ?? null,
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
provider: null,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (this.isUnmounted || this.pendingProviderRequestKey !== requestKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
provider: null,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getEditorMaxWidth() {
|
||||
@@ -383,15 +460,54 @@ class EntryMessageViewer extends React.Component {
|
||||
}
|
||||
|
||||
renderMessageEditor() {
|
||||
const message = this.formatJsonValue(this.props.entry?.message) || "";
|
||||
const lang = this.shouldRenderTraceViewer() ? "json" : undefined;
|
||||
|
||||
return (
|
||||
<Editor
|
||||
value={this.formatJsonValue(this.props.entry?.message) || ""}
|
||||
lang="json"
|
||||
value={message}
|
||||
lang={lang}
|
||||
readOnly
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
shouldRenderTraceViewer() {
|
||||
return `${this.props.entry?.type ?? ""}`.trim().toLowerCase() === "trace";
|
||||
}
|
||||
|
||||
getProviderViewerType() {
|
||||
const provider = this.props.provider ?? this.state.provider;
|
||||
if (!provider) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const category = `${provider.category ?? ""}`.trim();
|
||||
const type = `${provider.type ?? ""}`.trim();
|
||||
|
||||
if (category === "Log" && type === "SELinux Log") {
|
||||
return "selinux";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
renderSpecializedViewer() {
|
||||
const provider = this.props.provider ?? this.state.provider;
|
||||
switch (this.getProviderViewerType()) {
|
||||
case "selinux":
|
||||
return <SELinuxEntryViewer entry={this.props.entry} labelSpan={this.getLabelSpan()} contentSpan={this.getContentSpan()} />;
|
||||
default:
|
||||
if (this.shouldRenderTraceViewer()) {
|
||||
return this.renderTraceSpans();
|
||||
}
|
||||
if (isOpenClawSessionEntry(this.props.entry, provider)) {
|
||||
return <OpenClawSessionGraphViewer entry={this.props.entry} provider={provider} labelSpan={this.getLabelSpan()} contentSpan={this.getContentSpan()} />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
renderTraceSpans() {
|
||||
if (this.props.entry?.type !== "trace") {
|
||||
return null;
|
||||
@@ -579,7 +695,7 @@ class EntryMessageViewer extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
<>
|
||||
{this.renderTraceSpans()}
|
||||
{this.renderSpecializedViewer()}
|
||||
<Row style={{marginTop: "20px"}} >
|
||||
<Col style={{marginTop: "5px"}} span={this.getLabelSpan()}>
|
||||
{i18next.t("payment:Message")}:
|
||||
|
||||
@@ -164,7 +164,7 @@ class InvitationListPage extends BaseListPage {
|
||||
title: i18next.t("invitation:Used count"),
|
||||
dataIndex: "usedCount",
|
||||
key: "usedCount",
|
||||
width: "130px",
|
||||
width: "140px",
|
||||
sorter: true,
|
||||
...this.getColumnSearchProps("usedCount"),
|
||||
},
|
||||
|
||||
@@ -14,17 +14,19 @@
|
||||
|
||||
import * as Setting from "./Setting";
|
||||
import {Avatar, Button, Card, Drawer, Dropdown, Menu, Result, Tooltip} from "antd";
|
||||
import Sider from "antd/es/layout/Sider";
|
||||
import EnableMfaNotification from "./common/notifaction/EnableMfaNotification";
|
||||
import {Link, Redirect, Route, Switch, withRouter} from "react-router-dom";
|
||||
import React, {useState} from "react";
|
||||
import React, {useEffect, useState} from "react";
|
||||
import i18next from "i18next";
|
||||
import {
|
||||
AppstoreTwoTone,
|
||||
BarsOutlined, CheckCircleTwoTone, DeploymentUnitOutlined, DollarTwoTone, DownOutlined,
|
||||
HomeTwoTone,
|
||||
LockTwoTone, LogoutOutlined,
|
||||
SafetyCertificateTwoTone, SettingOutlined, SettingTwoTone,
|
||||
WalletTwoTone
|
||||
AppstoreOutlined,
|
||||
BarsOutlined, CheckCircleOutlined, DeploymentUnitOutlined, DollarOutlined, DownOutlined,
|
||||
HomeOutlined,
|
||||
LockOutlined, LogoutOutlined,
|
||||
MenuFoldOutlined, MenuUnfoldOutlined,
|
||||
SafetyCertificateOutlined, SettingOutlined,
|
||||
WalletOutlined
|
||||
} from "@ant-design/icons";
|
||||
import Dashboard from "./basic/Dashboard";
|
||||
import AppListPage from "./basic/AppListPage";
|
||||
@@ -97,6 +99,7 @@ import ThemeSelect from "./common/select/ThemeSelect";
|
||||
import OpenTour from "./common/OpenTour";
|
||||
import OrganizationSelect from "./common/select/OrganizationSelect";
|
||||
import AccountAvatar from "./account/AccountAvatar";
|
||||
import BreadcrumbBar from "./common/BreadcrumbBar";
|
||||
import {Content, Header} from "antd/es/layout/layout";
|
||||
import * as AuthBackend from "./auth/AuthBackend";
|
||||
import {clearWeb3AuthToken} from "./auth/Web3Auth";
|
||||
@@ -119,11 +122,57 @@ import SiteEditPage from "./SiteEditPage";
|
||||
import RuleListPage from "./RuleListPage";
|
||||
import RuleEditPage from "./RuleEditPage";
|
||||
|
||||
function getMenuParentKey(uri) {
|
||||
if (!uri) {return null;}
|
||||
if (uri === "/" || uri.includes("/shortcuts") || uri.includes("/apps")) {return "/home";}
|
||||
if (uri.includes("/organizations") || uri.includes("/trees") || uri.includes("/groups") || uri.includes("/users") || uri.includes("/invitations")) {return "/orgs";}
|
||||
if (uri.includes("/applications") || uri.includes("/providers") || uri.includes("/resources") || uri.includes("/certs") || uri.includes("/keys")) {return "/identity";}
|
||||
if (uri.includes("/agents") || uri.includes("/servers") || uri.includes("/entries") || uri.includes("/sites") || uri.includes("/rules")) {return "/gateway";}
|
||||
if (uri.includes("/roles") || uri.includes("/permissions") || uri.includes("/models") || uri.includes("/adapters") || uri.includes("/enforcers")) {return "/auth";}
|
||||
if (uri.includes("/records") || uri.includes("/tokens") || uri.includes("/sessions") || uri.includes("/verifications")) {return "/logs";}
|
||||
if (uri.includes("/products") || uri.includes("/orders") || uri.includes("/payments") || uri.includes("/plans") || uri.includes("/pricings") || uri.includes("/subscriptions") || uri.includes("/transactions") || uri.includes("/cart")) {return "/business";}
|
||||
if (uri.includes("/sysinfo") || uri.includes("/forms") || uri.includes("/syncers") || uri.includes("/webhooks") || uri.includes("/tickets")) {return "/admin";}
|
||||
return null;
|
||||
}
|
||||
|
||||
function ManagementPage(props) {
|
||||
const [menuVisible, setMenuVisible] = useState(false);
|
||||
const [siderCollapsed, setSiderCollapsed] = useState(() => localStorage.getItem("siderCollapsed") === "true");
|
||||
const [menuOpenKeys, setMenuOpenKeys] = useState(() => {
|
||||
const parentKey = getMenuParentKey(props.uri || location.pathname);
|
||||
return parentKey ? [parentKey] : [];
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const parentKey = getMenuParentKey(props.uri);
|
||||
if (parentKey) {
|
||||
setMenuOpenKeys(prev =>
|
||||
prev.includes(parentKey) ? prev : [...prev, parentKey]
|
||||
);
|
||||
}
|
||||
}, [props.uri]);
|
||||
const organization = props.account?.organization;
|
||||
const navItems = Setting.isLocalAdminUser(props.account) ? organization?.navItems : (organization?.userNavItems ?? []);
|
||||
const widgetItems = organization?.widgetItems;
|
||||
const currentUri = props.uri || location.pathname;
|
||||
const selectedLeafKey = "/" + (currentUri.split("/").filter(Boolean)[0] || "");
|
||||
|
||||
const isDark = props.themeAlgorithm.includes("dark");
|
||||
const textColor = isDark ? "white" : "black";
|
||||
const siderLogo = (() => {
|
||||
if (!props.account?.organization) {return Setting.getLogo(props.themeAlgorithm);}
|
||||
let logo = props.account.organization.logo || Setting.getLogo(props.themeAlgorithm);
|
||||
if (isDark && props.account.organization.logoDark) {
|
||||
logo = props.account.organization.logoDark;
|
||||
}
|
||||
return logo;
|
||||
})();
|
||||
|
||||
const toggleSider = () => {
|
||||
const next = !siderCollapsed;
|
||||
setSiderCollapsed(next);
|
||||
localStorage.setItem("siderCollapsed", String(next));
|
||||
};
|
||||
|
||||
function logout() {
|
||||
AuthBackend.logout()
|
||||
@@ -150,13 +199,13 @@ function ManagementPage(props) {
|
||||
function renderAvatar() {
|
||||
if (props.account.avatar === "") {
|
||||
return (
|
||||
<Avatar style={{backgroundColor: Setting.getAvatarColor(props.account.name), verticalAlign: "middle"}} size="large">
|
||||
<Avatar style={{backgroundColor: Setting.getAvatarColor(props.account.name), verticalAlign: "middle", marginLeft: 8}} size="large">
|
||||
{Setting.getShortName(props.account.name)}
|
||||
</Avatar>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Avatar src={props.account.avatar} style={{verticalAlign: "middle"}} size="large"
|
||||
<Avatar src={props.account.avatar} style={{verticalAlign: "middle", marginLeft: 8}} size="large"
|
||||
icon={<AccountAvatar src={props.account.avatar} style={{verticalAlign: "middle"}} size={40} />}
|
||||
>
|
||||
{Setting.getShortName(props.account.name)}
|
||||
@@ -235,7 +284,7 @@ function ManagementPage(props) {
|
||||
Setting.getItem(<ThemeSelect themeAlgorithm={props.themeAlgorithm} onChange={props.setLogoAndThemeAlgorithm} />, "theme"),
|
||||
Setting.getItem(<LanguageSelect languages={props.account.organization.languages} />, "language"),
|
||||
Setting.getItem(Conf.AiAssistantUrl?.trim() && (
|
||||
<Tooltip title="Click to open AI assistant">
|
||||
<Tooltip title={i18next.t("general:Click to open AI assistant")}>
|
||||
<div className="select-box" onClick={props.openAiAssistant}>
|
||||
<DeploymentUnitOutlined style={{fontSize: "24px"}} />
|
||||
</div>
|
||||
@@ -245,10 +294,10 @@ function ManagementPage(props) {
|
||||
];
|
||||
|
||||
if (widgetItemsIsAll()) {
|
||||
return widgets.map(item => item.label);
|
||||
return widgets.reverse().map(item => item.label);
|
||||
}
|
||||
|
||||
return widgets.filter(item => widgetItems.includes(item.key)).map(item => item.label);
|
||||
return widgets.filter(item => widgetItems.includes(item.key)).reverse().map(item => item.label);
|
||||
}
|
||||
|
||||
function renderAccountMenu() {
|
||||
@@ -263,8 +312,13 @@ function ManagementPage(props) {
|
||||
} else {
|
||||
return (
|
||||
<React.Fragment>
|
||||
{renderRightDropdown()}
|
||||
{renderWidgets()}
|
||||
{Setting.isLocalAdminUser(props.account) && Conf.ShowGithubCorner && !Setting.isMobile() &&
|
||||
<a href={"https://casdoor.com"} target="_blank" rel="noreferrer" style={{marginRight: "8px"}}>
|
||||
<span className="saas-hosting-btn">
|
||||
🚀 SaaS Hosting 🔥
|
||||
</span>
|
||||
</a>
|
||||
}
|
||||
{Setting.isAdminUser(props.account) && (props.uri.indexOf("/trees") === -1) &&
|
||||
<OrganizationSelect
|
||||
initValue={Setting.getOrganization()}
|
||||
@@ -276,6 +330,8 @@ function ManagementPage(props) {
|
||||
}}
|
||||
/>
|
||||
}
|
||||
{renderWidgets()}
|
||||
{renderRightDropdown()}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
@@ -288,51 +344,20 @@ function ManagementPage(props) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let textColor = "black";
|
||||
const twoToneColor = props.themeData.colorPrimary;
|
||||
|
||||
let logo = props.account.organization.logo ? props.account.organization.logo : Setting.getLogo(props.themeAlgorithm);
|
||||
if (props.themeAlgorithm.includes("dark")) {
|
||||
if (props.account.organization.logoDark) {
|
||||
logo = props.account.organization.logoDark;
|
||||
}
|
||||
textColor = "white";
|
||||
}
|
||||
|
||||
!Setting.isMobile() ? res.push({
|
||||
label:
|
||||
<Link to="/">
|
||||
<img className="logo" src={logo ?? props.logo} alt="logo" />
|
||||
</Link>,
|
||||
disabled: true, key: "logo",
|
||||
style: {
|
||||
padding: 0,
|
||||
height: "auto",
|
||||
},
|
||||
}) : null;
|
||||
|
||||
res.push(Setting.getItem(<Link style={{color: textColor}} to="/">{i18next.t("general:Home")}</Link>, "/home", <HomeTwoTone twoToneColor={twoToneColor} />, [
|
||||
res.push(Setting.getItem(<Link style={{color: textColor}} to="/">{i18next.t("general:Home")}</Link>, "/home", <HomeOutlined />, [
|
||||
Setting.getItem(<Link to="/">{i18next.t("general:Dashboard")}</Link>, "/"),
|
||||
Setting.getItem(<Link to="/shortcuts">{i18next.t("general:Shortcuts")}</Link>, "/shortcuts"),
|
||||
Setting.getItem(<Link to="/apps">{i18next.t("general:Apps")}</Link>, "/apps"),
|
||||
]));
|
||||
|
||||
if (Setting.isLocalAdminUser(props.account) && Conf.ShowGithubCorner) {
|
||||
res.push(Setting.getItem(<a href={"https://casdoor.com"}>
|
||||
<span style={{fontWeight: "bold", backgroundColor: "rgba(87,52,211,0.4)", marginTop: "12px", paddingLeft: "5px", paddingRight: "5px", display: "flex", alignItems: "center", height: "40px", borderRadius: "5px"}}>
|
||||
🚀 SaaS Hosting 🔥
|
||||
</span>
|
||||
</a>, "#"));
|
||||
}
|
||||
|
||||
res.push(Setting.getItem(<Link style={{color: textColor}} to="/organizations">{i18next.t("general:User Management")}</Link>, "/orgs", <AppstoreTwoTone twoToneColor={twoToneColor} />, [
|
||||
res.push(Setting.getItem(<Link style={{color: textColor}} to="/organizations">{i18next.t("general:User Management")}</Link>, "/orgs", <AppstoreOutlined />, [
|
||||
Setting.getItem(<Link to="/organizations">{i18next.t("general:Organizations")}</Link>, "/organizations"),
|
||||
Setting.getItem(<Link to="/groups">{i18next.t("general:Groups")}</Link>, "/groups"),
|
||||
Setting.getItem(<Link to="/users">{i18next.t("general:Users")}</Link>, "/users"),
|
||||
Setting.getItem(<Link to="/invitations">{i18next.t("general:Invitations")}</Link>, "/invitations"),
|
||||
]));
|
||||
|
||||
res.push(Setting.getItem(<Link style={{color: textColor}} to="/applications">{i18next.t("general:Identity")}</Link>, "/identity", <LockTwoTone twoToneColor={twoToneColor} />, [
|
||||
res.push(Setting.getItem(<Link style={{color: textColor}} to="/applications">{i18next.t("general:Identity")}</Link>, "/identity", <LockOutlined />, [
|
||||
Setting.getItem(<Link to="/applications">{i18next.t("general:Applications")}</Link>, "/applications"),
|
||||
Setting.getItem(<Link to="/providers">{i18next.t("application:Providers")}</Link>, "/providers"),
|
||||
Setting.getItem(<Link to="/resources">{i18next.t("general:Resources")}</Link>, "/resources"),
|
||||
@@ -340,7 +365,7 @@ function ManagementPage(props) {
|
||||
Setting.getItem(<Link to="/keys">{i18next.t("general:Keys")}</Link>, "/keys"),
|
||||
]));
|
||||
|
||||
res.push(Setting.getItem(<Link style={{color: textColor}} to="/roles">{i18next.t("general:Authorization")}</Link>, "/auth", <SafetyCertificateTwoTone twoToneColor={twoToneColor} />, [
|
||||
res.push(Setting.getItem(<Link style={{color: textColor}} to="/roles">{i18next.t("general:Authorization")}</Link>, "/auth", <SafetyCertificateOutlined />, [
|
||||
Setting.getItem(<Link to="/roles">{i18next.t("general:Roles")}</Link>, "/roles"),
|
||||
Setting.getItem(<Link to="/permissions">{i18next.t("general:Permissions")}</Link>, "/permissions"),
|
||||
Setting.getItem(<Link to="/models">{i18next.t("general:Models")}</Link>, "/models"),
|
||||
@@ -354,7 +379,7 @@ function ManagementPage(props) {
|
||||
}
|
||||
})));
|
||||
|
||||
res.push(Setting.getItem(<Link style={{color: textColor}} to="/sites">{i18next.t("general:LLM AI")}</Link>, "/gateway", <CheckCircleTwoTone twoToneColor={twoToneColor} />, [
|
||||
res.push(Setting.getItem(<Link style={{color: textColor}} to="/sites">{i18next.t("general:LLM AI")}</Link>, "/gateway", <CheckCircleOutlined />, [
|
||||
Setting.getItem(<Link to="/agents">{i18next.t("general:Agents")}</Link>, "/agents"),
|
||||
Setting.getItem(<Link to="/servers">{i18next.t("general:MCP Servers")}</Link>, "/servers"),
|
||||
Setting.getItem(<Link to="/server-store">{i18next.t("general:MCP Store")}</Link>, "/server-store"),
|
||||
@@ -363,14 +388,14 @@ function ManagementPage(props) {
|
||||
Setting.getItem(<Link to="/rules">{i18next.t("general:Rules")}</Link>, "/rules"),
|
||||
]));
|
||||
|
||||
res.push(Setting.getItem(<Link style={{color: textColor}} to="/sessions">{i18next.t("general:Logging & Auditing")}</Link>, "/logs", <WalletTwoTone twoToneColor={twoToneColor} />, [
|
||||
res.push(Setting.getItem(<Link style={{color: textColor}} to="/sessions">{i18next.t("general:Auditing")}</Link>, "/logs", <WalletOutlined />, [
|
||||
Setting.getItem(<Link to="/sessions">{i18next.t("general:Sessions")}</Link>, "/sessions"),
|
||||
Setting.getItem(<Link to="/records">{i18next.t("general:Records")}</Link>, "/records"),
|
||||
Setting.getItem(<Link to="/tokens">{i18next.t("general:Tokens")}</Link>, "/tokens"),
|
||||
Setting.getItem(<Link to="/verifications">{i18next.t("general:Verifications")}</Link>, "/verifications"),
|
||||
]));
|
||||
|
||||
res.push(Setting.getItem(<Link style={{color: textColor}} to="/products">{i18next.t("general:Business & Payments")}</Link>, "/business", <DollarTwoTone twoToneColor={twoToneColor} />, [
|
||||
res.push(Setting.getItem(<Link style={{color: textColor}} to="/products">{i18next.t("general:Business")}</Link>, "/business", <DollarOutlined />, [
|
||||
Setting.getItem(<Link to="/product-store">{i18next.t("general:Product Store")}</Link>, "/product-store"),
|
||||
Setting.getItem(<Link to="/products">{i18next.t("general:Products")}</Link>, "/products"),
|
||||
Setting.getItem(<Link to="/cart">{i18next.t("general:Cart")}</Link>, "/cart"),
|
||||
@@ -383,7 +408,7 @@ function ManagementPage(props) {
|
||||
]));
|
||||
|
||||
if (Setting.isAdminUser(props.account)) {
|
||||
res.push(Setting.getItem(<Link style={{color: textColor}} to="/sysinfo">{i18next.t("general:Admin")}</Link>, "/admin", <SettingTwoTone twoToneColor={twoToneColor} />, [
|
||||
res.push(Setting.getItem(<Link style={{color: textColor}} to="/sysinfo">{i18next.t("general:Admin")}</Link>, "/admin", <SettingOutlined />, [
|
||||
Setting.getItem(<Link to="/sysinfo">{i18next.t("general:System Info")}</Link>, "/sysinfo"),
|
||||
Setting.getItem(<Link to="/forms">{i18next.t("general:Forms")}</Link>, "/forms"),
|
||||
Setting.getItem(<Link to="/syncers">{i18next.t("general:Syncers")}</Link>, "/syncers"),
|
||||
@@ -392,7 +417,7 @@ function ManagementPage(props) {
|
||||
Setting.getItem(<Link to="/tickets">{i18next.t("general:Tickets")}</Link>, "/tickets"),
|
||||
Setting.getItem(<a target="_blank" rel="noreferrer" href={Setting.isLocalhost() ? `${Setting.ServerUrl}/swagger` : "/swagger"}>{i18next.t("general:Swagger")}</a>, "/swagger")]));
|
||||
} else {
|
||||
res.push(Setting.getItem(<Link style={{color: textColor}} to="/syncers">{i18next.t("general:Admin")}</Link>, "/admin", <SettingTwoTone twoToneColor={twoToneColor} />, [
|
||||
res.push(Setting.getItem(<Link style={{color: textColor}} to="/syncers">{i18next.t("general:Admin")}</Link>, "/admin", <SettingOutlined />, [
|
||||
Setting.getItem(<Link to="/forms">{i18next.t("general:Forms")}</Link>, "/forms"),
|
||||
Setting.getItem(<Link to="/syncers">{i18next.t("general:Syncers")}</Link>, "/syncers"),
|
||||
Setting.getItem(<Link to="/webhooks">{i18next.t("general:Webhooks")}</Link>, "/webhooks"),
|
||||
@@ -580,52 +605,113 @@ function ManagementPage(props) {
|
||||
setMenuVisible(true);
|
||||
};
|
||||
|
||||
const siderWidth = 256;
|
||||
const siderCollapsedWidth = 80;
|
||||
const showSider = !Setting.isMobile() && !props.requiredEnableMfa;
|
||||
const contentMarginLeft = showSider ? (siderCollapsed ? siderCollapsedWidth : siderWidth) : 0;
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<EnableMfaNotification account={props.account} />
|
||||
<Header style={{display: "flex", justifyContent: "space-between", alignItems: "center", padding: "0", marginBottom: "4px", backgroundColor: props.themeAlgorithm.includes("dark") ? "black" : "white"}} >
|
||||
{
|
||||
props.requiredEnableMfa || (Setting.isMobile() ? (
|
||||
<React.Fragment>
|
||||
<Drawer title={i18next.t("general:Close")} placement="left" open={menuVisible} onClose={onClose}>
|
||||
<Menu
|
||||
items={getMenuItems()}
|
||||
mode={"inline"}
|
||||
selectedKeys={[props.selectedMenuKey]}
|
||||
style={{lineHeight: "64px"}}
|
||||
onClick={onClose}
|
||||
>
|
||||
</Menu>
|
||||
</Drawer>
|
||||
<Button icon={<BarsOutlined />} onClick={showMenu} type="text">
|
||||
{i18next.t("general:Menu")}
|
||||
</Button>
|
||||
</React.Fragment>
|
||||
) : (
|
||||
// Padding 1px for Menu Item Highlight border
|
||||
<div style={{flex: 1, overflow: "hidden", paddingBottom: "1px"}}>
|
||||
<Menu
|
||||
onClick={onClose}
|
||||
items={getMenuItems()}
|
||||
mode={"horizontal"}
|
||||
selectedKeys={[props.selectedMenuKey]}
|
||||
style={{backgroundColor: props.themeAlgorithm.includes("dark") ? "black" : "white"}}
|
||||
{showSider && (
|
||||
<Sider
|
||||
collapsed={siderCollapsed}
|
||||
collapsedWidth={siderCollapsedWidth}
|
||||
width={siderWidth}
|
||||
trigger={null}
|
||||
theme={isDark ? "dark" : "light"}
|
||||
style={{
|
||||
height: "100vh",
|
||||
position: "fixed",
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
zIndex: 100,
|
||||
boxShadow: "2px 0 8px rgba(0,0,0,0.08)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
height: 52,
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: siderCollapsed ? "center" : "flex-start",
|
||||
padding: siderCollapsed ? "0" : "0 16px 0 24px",
|
||||
overflow: "hidden",
|
||||
}}>
|
||||
<Link to="/">
|
||||
<img
|
||||
src={siderCollapsed ? (organization?.favicon || siderLogo || props.logo) : (siderLogo ?? props.logo)}
|
||||
alt="logo"
|
||||
style={{
|
||||
height: siderCollapsed ? 28 : 40,
|
||||
width: siderCollapsed ? 28 : undefined,
|
||||
maxWidth: siderCollapsed ? 28 : 160,
|
||||
objectFit: "contain",
|
||||
borderRadius: siderCollapsed ? 4 : 0,
|
||||
transition: "max-width 0.2s, height 0.2s, width 0.2s",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
<div style={{flexShrink: 0}}>
|
||||
{renderAccountMenu()}
|
||||
</div>
|
||||
</Header>
|
||||
<Content style={{display: "flex", flexDirection: "column"}} >
|
||||
{isWithoutCard() ?
|
||||
renderRouter() :
|
||||
<Card className="content-warp-card">
|
||||
{renderRouter()}
|
||||
</Card>
|
||||
}
|
||||
</Content>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="sider-menu-container" style={{flex: 1, overflow: "auto"}}>
|
||||
<Menu
|
||||
mode="inline"
|
||||
items={getMenuItems()}
|
||||
selectedKeys={[selectedLeafKey]}
|
||||
openKeys={menuOpenKeys}
|
||||
onOpenChange={setMenuOpenKeys}
|
||||
theme={isDark ? "dark" : "light"}
|
||||
style={{borderRight: 0}}
|
||||
/>
|
||||
</div>
|
||||
</Sider>
|
||||
)}
|
||||
<div style={{marginLeft: contentMarginLeft, transition: "margin-left 0.2s", display: "flex", flexDirection: "column", minHeight: "100vh"}}>
|
||||
<Header style={{display: "flex", justifyContent: "space-between", alignItems: "center", padding: "0", marginBottom: "4px", backgroundColor: isDark ? "black" : "white", position: "sticky", top: 0, zIndex: 99, boxShadow: "0 1px 4px rgba(0,0,0,0.08)", height: "52px", lineHeight: "52px"}}>
|
||||
<div style={{display: "flex", alignItems: "center"}}>
|
||||
{props.requiredEnableMfa ? null : (Setting.isMobile() ? (
|
||||
<React.Fragment>
|
||||
<Drawer title={i18next.t("general:Close")} placement="left" open={menuVisible} onClose={onClose}>
|
||||
<Menu
|
||||
items={getMenuItems()}
|
||||
mode={"inline"}
|
||||
selectedKeys={[selectedLeafKey]}
|
||||
openKeys={menuOpenKeys}
|
||||
onOpenChange={setMenuOpenKeys}
|
||||
style={{lineHeight: "48px"}}
|
||||
onClick={onClose}
|
||||
/>
|
||||
</Drawer>
|
||||
<Button icon={<BarsOutlined />} onClick={showMenu} type="text">
|
||||
{i18next.t("general:Menu")}
|
||||
</Button>
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<Button
|
||||
icon={siderCollapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||
onClick={toggleSider}
|
||||
type="text"
|
||||
style={{fontSize: 16, width: 40, height: 40}}
|
||||
/>
|
||||
))}
|
||||
<BreadcrumbBar uri={currentUri} />
|
||||
</div>
|
||||
<div style={{flexShrink: 0, display: "flex", alignItems: "center"}}>
|
||||
{renderAccountMenu()}
|
||||
</div>
|
||||
</Header>
|
||||
<Content style={{display: "flex", flexDirection: "column"}}>
|
||||
{isWithoutCard() ?
|
||||
renderRouter() :
|
||||
<Card className="content-warp-card" styles={{body: {padding: 0, margin: 0}}}>
|
||||
{renderRouter()}
|
||||
</Card>
|
||||
}
|
||||
</Content>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
351
web/src/OpenClawSessionGraphUtils.js
Normal file
351
web/src/OpenClawSessionGraphUtils.js
Normal file
@@ -0,0 +1,351 @@
|
||||
const openClawPayloadKinds = new Set(["task", "tool_call", "tool_result", "final"]);
|
||||
|
||||
export function isOpenClawSessionEntry(entry, provider) {
|
||||
if (!entry || `${entry.type ?? ""}`.trim().toLowerCase() !== "session") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (provider?.category === "Log" && provider?.type === "Agent" && provider?.subType === "OpenClaw") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (provider) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const payload = parseOpenClawBehaviorPayload(entry.message);
|
||||
return Boolean(payload?.sessionId && payload?.entryId && payload?.kind);
|
||||
}
|
||||
|
||||
function parseOpenClawBehaviorPayload(message) {
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const source = typeof message === "string" ? message : JSON.stringify(message);
|
||||
if (!source) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(source);
|
||||
const kind = `${payload?.kind ?? ""}`.trim();
|
||||
const sessionId = `${payload?.sessionId ?? ""}`.trim();
|
||||
const entryId = `${payload?.entryId ?? ""}`.trim();
|
||||
if (!kind || !sessionId || !entryId || !openClawPayloadKinds.has(kind)) {
|
||||
return null;
|
||||
}
|
||||
return payload;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getOpenClawNodeTarget(node) {
|
||||
return node?.query || node?.url || node?.path || node?.tool || "";
|
||||
}
|
||||
|
||||
export function getOpenClawNodeColor(node) {
|
||||
switch (node?.kind) {
|
||||
case "task":
|
||||
return "#4c6ef5";
|
||||
case "assistant_step":
|
||||
return "#0f766e";
|
||||
case "tool_call":
|
||||
return "#f08c00";
|
||||
case "tool_result":
|
||||
return node?.ok === false ? "#e03131" : "#2f9e44";
|
||||
case "final":
|
||||
return "#6c5ce7";
|
||||
default:
|
||||
return "#868e96";
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeText(value) {
|
||||
return `${value ?? ""}`.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function stripLeadingPrefix(text, prefix) {
|
||||
const normalizedText = normalizeText(text);
|
||||
const normalizedPrefix = normalizeText(prefix);
|
||||
if (!normalizedText || !normalizedPrefix) {
|
||||
return normalizedText;
|
||||
}
|
||||
|
||||
if (normalizedText.toLowerCase().startsWith(normalizedPrefix.toLowerCase())) {
|
||||
return normalizedText.slice(normalizedPrefix.length).trim();
|
||||
}
|
||||
|
||||
return normalizedText;
|
||||
}
|
||||
|
||||
function getAssistantStepTitle(node) {
|
||||
const summary = normalizeText(node?.summary);
|
||||
const match = summary.match(/^(\d+\s+tool calls?)(?:\s*:\s*.+)?$/i);
|
||||
if (match) {
|
||||
return match[1];
|
||||
}
|
||||
return summary || node?.id || "-";
|
||||
}
|
||||
|
||||
function getToolCallTitle(node) {
|
||||
const target = normalizeText(getOpenClawNodeTarget(node));
|
||||
if (target) {
|
||||
return target;
|
||||
}
|
||||
|
||||
const prefix = node?.tool ? `${node.tool}:` : "";
|
||||
return stripLeadingPrefix(node?.summary, prefix) || normalizeText(node?.summary) || node?.id || "-";
|
||||
}
|
||||
|
||||
function getToolResultTitle(node) {
|
||||
const target = normalizeText(getOpenClawNodeTarget(node));
|
||||
if (target) {
|
||||
return target;
|
||||
}
|
||||
if (node?.ok === false && node?.error) {
|
||||
return normalizeText(node.error);
|
||||
}
|
||||
|
||||
const prefix = node?.tool ? `${node.tool} ${node.ok === false ? "failed" : "ok"}:` : "";
|
||||
return stripLeadingPrefix(node?.summary, prefix) || normalizeText(node?.summary) || node?.id || "-";
|
||||
}
|
||||
|
||||
function getNodeTitle(node) {
|
||||
switch (node?.kind) {
|
||||
case "assistant_step":
|
||||
return getAssistantStepTitle(node);
|
||||
case "tool_call":
|
||||
return getToolCallTitle(node);
|
||||
case "tool_result":
|
||||
return getToolResultTitle(node);
|
||||
default:
|
||||
return normalizeText(node?.summary) || node?.id || "-";
|
||||
}
|
||||
}
|
||||
|
||||
function compareNodes(left, right) {
|
||||
const leftTimestamp = `${left?.timestamp ?? ""}`.trim();
|
||||
const rightTimestamp = `${right?.timestamp ?? ""}`.trim();
|
||||
const leftMillis = parseTimestampMillis(leftTimestamp);
|
||||
const rightMillis = parseTimestampMillis(rightTimestamp);
|
||||
if (leftMillis !== null && rightMillis !== null) {
|
||||
if (leftMillis !== rightMillis) {
|
||||
return leftMillis - rightMillis;
|
||||
}
|
||||
} else if (leftTimestamp !== rightTimestamp) {
|
||||
return leftTimestamp.localeCompare(rightTimestamp);
|
||||
}
|
||||
|
||||
return `${left?.id ?? ""}`.localeCompare(`${right?.id ?? ""}`);
|
||||
}
|
||||
|
||||
function parseTimestampMillis(timestamp) {
|
||||
if (!timestamp) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const milliseconds = Date.parse(timestamp);
|
||||
if (Number.isNaN(milliseconds)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return milliseconds;
|
||||
}
|
||||
|
||||
function buildTreeIndexes(graph) {
|
||||
const sourceNodes = Array.isArray(graph?.nodes) ? graph.nodes : [];
|
||||
const sourceEdges = Array.isArray(graph?.edges) ? graph.edges : [];
|
||||
const nodeMap = Object.fromEntries(sourceNodes.map(node => [node.id, node]));
|
||||
const childrenMap = new Map();
|
||||
const incomingCount = new Map();
|
||||
|
||||
sourceNodes.forEach((node) => {
|
||||
childrenMap.set(node.id, []);
|
||||
incomingCount.set(node.id, 0);
|
||||
});
|
||||
|
||||
sourceEdges.forEach((edge) => {
|
||||
if (!nodeMap[edge.source] || !nodeMap[edge.target]) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!childrenMap.has(edge.source)) {
|
||||
childrenMap.set(edge.source, []);
|
||||
}
|
||||
childrenMap.get(edge.source).push(edge.target);
|
||||
incomingCount.set(edge.target, (incomingCount.get(edge.target) || 0) + 1);
|
||||
});
|
||||
|
||||
childrenMap.forEach((childIds) => childIds.sort((left, right) => compareNodes(nodeMap[left], nodeMap[right])));
|
||||
const roots = sourceNodes
|
||||
.filter(node => !incomingCount.get(node.id))
|
||||
.sort(compareNodes)
|
||||
.map(node => node.id);
|
||||
|
||||
return {nodeMap, childrenMap, roots};
|
||||
}
|
||||
|
||||
function computeTreeLayout(graph) {
|
||||
const {nodeMap, childrenMap, roots} = buildTreeIndexes(graph);
|
||||
const positions = new Map();
|
||||
const visited = new Set();
|
||||
const verticalGap = 160;
|
||||
const horizontalGap = 320;
|
||||
let cursor = 0;
|
||||
|
||||
function placeNode(nodeId, depth, stack) {
|
||||
if (!nodeMap[nodeId]) {
|
||||
return {top: cursor * verticalGap, bottom: cursor * verticalGap, center: cursor * verticalGap};
|
||||
}
|
||||
if (positions.has(nodeId)) {
|
||||
const y = positions.get(nodeId).y;
|
||||
return {top: y, bottom: y, center: y};
|
||||
}
|
||||
if (stack.has(nodeId)) {
|
||||
const y = cursor * verticalGap;
|
||||
cursor += 1;
|
||||
positions.set(nodeId, {x: depth * horizontalGap, y});
|
||||
visited.add(nodeId);
|
||||
return {top: y, bottom: y, center: y};
|
||||
}
|
||||
|
||||
stack.add(nodeId);
|
||||
const childIds = (childrenMap.get(nodeId) || []).filter(childId => nodeMap[childId]);
|
||||
if (childIds.length === 0) {
|
||||
const y = cursor * verticalGap;
|
||||
cursor += 1;
|
||||
positions.set(nodeId, {x: depth * horizontalGap, y});
|
||||
visited.add(nodeId);
|
||||
stack.delete(nodeId);
|
||||
return {top: y, bottom: y, center: y};
|
||||
}
|
||||
|
||||
const childBoxes = childIds.map(childId => placeNode(childId, depth + 1, stack));
|
||||
const top = childBoxes[0].top;
|
||||
const bottom = childBoxes[childBoxes.length - 1].bottom;
|
||||
const center = childBoxes.length === 1 ? childBoxes[0].center : (top + bottom) / 2;
|
||||
positions.set(nodeId, {x: depth * horizontalGap, y: center});
|
||||
visited.add(nodeId);
|
||||
stack.delete(nodeId);
|
||||
return {top, bottom, center};
|
||||
}
|
||||
|
||||
roots.forEach(rootId => placeNode(rootId, 0, new Set()));
|
||||
|
||||
Object.values(nodeMap)
|
||||
.filter(node => !visited.has(node.id))
|
||||
.sort(compareNodes)
|
||||
.forEach((node) => {
|
||||
placeNode(node.id, 0, new Set());
|
||||
});
|
||||
|
||||
return positions;
|
||||
}
|
||||
|
||||
function getNodeSubtitle(node) {
|
||||
switch (node?.kind) {
|
||||
case "assistant_step": {
|
||||
const summary = normalizeText(node?.summary);
|
||||
const parts = summary.split(":");
|
||||
const detail = parts.length > 1 ? parts.slice(1).join(":").trim() : "";
|
||||
return detail || node?.timestamp || "-";
|
||||
}
|
||||
case "tool_call":
|
||||
return normalizeText(node?.tool) || node?.timestamp || "-";
|
||||
case "tool_result":
|
||||
if (node?.ok === false) {
|
||||
return normalizeText(node?.error) || `${normalizeText(node?.tool) || "tool"} failed`;
|
||||
}
|
||||
return `${normalizeText(node?.tool) || "tool"} ok`;
|
||||
default:
|
||||
return getOpenClawNodeTarget(node) || node?.timestamp || "-";
|
||||
}
|
||||
}
|
||||
|
||||
function getNodeBackground(node) {
|
||||
switch (node?.kind) {
|
||||
case "assistant_step":
|
||||
return "#f0fdfa";
|
||||
case "tool_call":
|
||||
return "#fff7ed";
|
||||
case "tool_result":
|
||||
return node?.ok === false ? "#fff5f5" : "#f3faf4";
|
||||
case "final":
|
||||
return "#f5f3ff";
|
||||
default:
|
||||
return "#ffffff";
|
||||
}
|
||||
}
|
||||
|
||||
function getEdgeStyle(edge, nodeMap) {
|
||||
const targetNode = nodeMap[edge.target];
|
||||
if (targetNode?.kind === "tool_result" && targetNode?.ok === false) {
|
||||
return {
|
||||
stroke: "#e03131",
|
||||
strokeWidth: 2.5,
|
||||
};
|
||||
}
|
||||
|
||||
if (targetNode?.originalParentId && targetNode.originalParentId !== targetNode.parentId) {
|
||||
return {
|
||||
stroke: "#0f766e",
|
||||
strokeWidth: 2.5,
|
||||
strokeDasharray: "6 4",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
stroke: "#94a3b8",
|
||||
strokeWidth: 2,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildOpenClawFlowElements(graph) {
|
||||
const sourceNodes = Array.isArray(graph?.nodes) ? graph.nodes : [];
|
||||
const sourceEdges = Array.isArray(graph?.edges) ? graph.edges : [];
|
||||
const nodeMap = Object.fromEntries(sourceNodes.map(node => [node.id, node]));
|
||||
const positions = computeTreeLayout(graph);
|
||||
|
||||
const flowNodes = sourceNodes
|
||||
.slice()
|
||||
.sort(compareNodes)
|
||||
.map((node) => {
|
||||
const color = getOpenClawNodeColor(node);
|
||||
const position = positions.get(node.id) || {x: 0, y: 0};
|
||||
return {
|
||||
id: node.id,
|
||||
position,
|
||||
data: {
|
||||
title: getNodeTitle(node),
|
||||
subtitle: getNodeSubtitle(node),
|
||||
rawNode: node,
|
||||
isAnchor: node.isAnchor,
|
||||
},
|
||||
draggable: false,
|
||||
selectable: true,
|
||||
style: {
|
||||
width: 250,
|
||||
minHeight: 76,
|
||||
padding: "12px 14px",
|
||||
borderRadius: 14,
|
||||
border: node.isAnchor ? `3px solid ${color}` : `1px solid ${color}`,
|
||||
boxShadow: node.isAnchor ? "0 8px 24px rgba(0, 0, 0, 0.12)" : "0 4px 14px rgba(0, 0, 0, 0.08)",
|
||||
background: getNodeBackground(node),
|
||||
color: "#1f2937",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const flowEdges = sourceEdges.map(edge => ({
|
||||
id: `${edge.source}-${edge.target}`,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
type: "smoothstep",
|
||||
animated: false,
|
||||
style: getEdgeStyle(edge, nodeMap),
|
||||
}));
|
||||
|
||||
return {nodes: flowNodes, edges: flowEdges};
|
||||
}
|
||||
390
web/src/OpenClawSessionGraphViewer.js
Normal file
390
web/src/OpenClawSessionGraphViewer.js
Normal file
@@ -0,0 +1,390 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Alert,
|
||||
Col,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Row,
|
||||
Spin,
|
||||
Tag,
|
||||
Typography
|
||||
} from "antd";
|
||||
import i18next from "i18next";
|
||||
import ReactFlow, {
|
||||
Background,
|
||||
Controls,
|
||||
MiniMap,
|
||||
ReactFlowProvider
|
||||
} from "reactflow";
|
||||
import "reactflow/dist/style.css";
|
||||
import * as EntryBackend from "./backend/EntryBackend";
|
||||
import * as Setting from "./Setting";
|
||||
import {
|
||||
buildOpenClawFlowElements,
|
||||
getOpenClawNodeColor,
|
||||
getOpenClawNodeTarget
|
||||
} from "./OpenClawSessionGraphUtils";
|
||||
|
||||
const {Text} = Typography;
|
||||
|
||||
function OpenClawNodeLabel({title, subtitle}) {
|
||||
return (
|
||||
<div style={{display: "flex", flexDirection: "column", gap: "6px"}}>
|
||||
<div style={{fontSize: 13, fontWeight: 600, lineHeight: 1.35}}>
|
||||
{title || "-"}
|
||||
</div>
|
||||
<div style={{fontSize: 12, color: "#64748b", lineHeight: 1.35}}>
|
||||
{subtitle || "-"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getStatusTag(node) {
|
||||
if (
|
||||
node?.kind !== "tool_result" ||
|
||||
node?.ok === undefined ||
|
||||
node?.ok === null
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return node.ok ? (
|
||||
<Tag color="success">{i18next.t("general:OK")}</Tag>
|
||||
) : (
|
||||
<Tag color="error">{i18next.t("entry:Failed", {defaultValue: "Failed"})}</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
function OpenClawSessionGraphCanvas(props) {
|
||||
const {graph, onNodeSelect} = props;
|
||||
const [reactFlowInstance, setReactFlowInstance] = React.useState(null);
|
||||
const elements = React.useMemo(() => {
|
||||
const flowElements = buildOpenClawFlowElements(graph);
|
||||
return {
|
||||
nodes: flowElements.nodes.map((node) => ({
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
label: (
|
||||
<OpenClawNodeLabel
|
||||
title={node.data.title}
|
||||
subtitle={node.data.subtitle}
|
||||
/>
|
||||
),
|
||||
},
|
||||
})),
|
||||
edges: flowElements.edges,
|
||||
};
|
||||
}, [graph]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!reactFlowInstance || elements.nodes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
reactFlowInstance.fitView({padding: 0.2, duration: 0});
|
||||
const anchorNode = elements.nodes.find((node) => node.data?.isAnchor);
|
||||
if (!anchorNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.setTimeout(() => {
|
||||
reactFlowInstance.setCenter(
|
||||
anchorNode.position.x + 125,
|
||||
anchorNode.position.y + 38,
|
||||
{zoom: 1.02, duration: 0}
|
||||
);
|
||||
}, 0);
|
||||
}, [elements.nodes, reactFlowInstance]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: 460,
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: 16,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<ReactFlow
|
||||
nodes={elements.nodes}
|
||||
edges={elements.edges}
|
||||
fitView
|
||||
nodesDraggable={false}
|
||||
nodesConnectable={false}
|
||||
onInit={setReactFlowInstance}
|
||||
onNodeClick={(_, node) => onNodeSelect(node.data?.rawNode ?? null)}
|
||||
>
|
||||
<MiniMap
|
||||
pannable
|
||||
zoomable
|
||||
nodeColor={(node) => getOpenClawNodeColor(node.data?.rawNode)}
|
||||
/>
|
||||
<Controls showInteractive={false} />
|
||||
<Background color="#f1f5f9" gap={16} />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
class OpenClawSessionGraphViewer extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
loading: false,
|
||||
error: "",
|
||||
graph: null,
|
||||
selectedNode: null,
|
||||
};
|
||||
this.requestKey = "";
|
||||
this.isUnmounted = false;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.isUnmounted = false;
|
||||
this.loadGraph();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (
|
||||
prevProps.entry?.owner !== this.props.entry?.owner ||
|
||||
prevProps.entry?.name !== this.props.entry?.name ||
|
||||
prevProps.provider !== this.props.provider
|
||||
) {
|
||||
this.loadGraph();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.isUnmounted = true;
|
||||
this.requestKey = "";
|
||||
}
|
||||
|
||||
getLabelSpan() {
|
||||
return this.props.labelSpan ?? (Setting.isMobile() ? 22 : 2);
|
||||
}
|
||||
|
||||
getContentSpan() {
|
||||
return this.props.contentSpan ?? 22;
|
||||
}
|
||||
|
||||
loadGraph() {
|
||||
if (!this.props.entry?.owner || !this.props.entry?.name) {
|
||||
this.requestKey = "";
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: "",
|
||||
graph: null,
|
||||
selectedNode: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const requestKey = `${this.props.entry.owner}/${this.props.entry.name}`;
|
||||
this.requestKey = requestKey;
|
||||
this.setState({loading: true, error: "", selectedNode: null});
|
||||
|
||||
EntryBackend.getOpenClawSessionGraph(
|
||||
this.props.entry.owner,
|
||||
this.props.entry.name
|
||||
)
|
||||
.then((res) => {
|
||||
if (this.isUnmounted || this.requestKey !== requestKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.status === "ok" && res.data) {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: "",
|
||||
graph: res.data,
|
||||
});
|
||||
} else if (res.status === "ok") {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: "",
|
||||
graph: null,
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: `${i18next.t("entry:Failed to load session graph", {defaultValue: "Failed to load session graph"})}: ${res.msg}`,
|
||||
graph: null,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (this.isUnmounted || this.requestKey !== requestKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: `${i18next.t("entry:Failed to load session graph", {defaultValue: "Failed to load session graph"})}: ${error}`,
|
||||
graph: null,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
renderStats() {
|
||||
const stats = this.state.graph?.stats;
|
||||
if (!stats) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{display: "flex", flexWrap: "wrap", gap: 8, marginBottom: 12}}
|
||||
>
|
||||
<Tag color="default">{i18next.t("entry:Nodes", {defaultValue: "Nodes"})}: {stats.totalNodes}</Tag>
|
||||
<Tag color="blue">{i18next.t("entry:Tasks", {defaultValue: "Tasks"})}: {stats.taskCount}</Tag>
|
||||
<Tag color="orange">{i18next.t("entry:Tool calls", {defaultValue: "Tool calls"})}: {stats.toolCallCount}</Tag>
|
||||
<Tag color="green">{i18next.t("entry:Results", {defaultValue: "Results"})}: {stats.toolResultCount}</Tag>
|
||||
<Tag color="purple">{i18next.t("entry:Finals", {defaultValue: "Finals"})}: {stats.finalCount}</Tag>
|
||||
{stats.failedCount > 0 ? (
|
||||
<Tag color="red">{i18next.t("entry:Failed", {defaultValue: "Failed"})}: {stats.failedCount}</Tag>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderNodeText(value) {
|
||||
if (!value) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{whiteSpace: "pre-wrap", wordBreak: "break-word"}}>
|
||||
{value}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderNodeDrawer() {
|
||||
const node = this.state.selectedNode;
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={node?.summary || i18next.t("entry:Session graph node", {defaultValue: "Session graph node"})}
|
||||
width={Setting.isMobile() ? "100%" : 720}
|
||||
placement="right"
|
||||
onClose={() => this.setState({selectedNode: null})}
|
||||
open={this.state.selectedNode !== null}
|
||||
destroyOnClose
|
||||
>
|
||||
{node ? (
|
||||
<Descriptions
|
||||
bordered
|
||||
size="small"
|
||||
column={1}
|
||||
layout={Setting.isMobile() ? "vertical" : "horizontal"}
|
||||
style={{padding: "12px", height: "100%", overflowY: "auto"}}
|
||||
>
|
||||
<Descriptions.Item label={i18next.t("general:Type")}>
|
||||
<div style={{display: "flex", alignItems: "center", gap: 8}}>
|
||||
<Text>{node.kind || "-"}</Text>
|
||||
{getStatusTag(node)}
|
||||
</div>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("entry:Summary", {defaultValue: "Summary"})}>
|
||||
{node.summary || "-"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("general:Timestamp")}>
|
||||
{node.timestamp || "-"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("entry:Entry ID", {defaultValue: "Entry ID"})}>
|
||||
{node.entryId || "-"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("entry:Tool Call ID", {defaultValue: "Tool Call ID"})}>
|
||||
{node.toolCallId || "-"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={`${i18next.t("general:Parent")} ${i18next.t("general:ID")}`}>
|
||||
{node.parentId || "-"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("entry:Original Parent ID", {defaultValue: "Original Parent ID"})}>
|
||||
{node.originalParentId || "-"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("entry:Target", {defaultValue: "Target"})}>
|
||||
{getOpenClawNodeTarget(node) || "-"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("entry:Tool", {defaultValue: "Tool"})}>
|
||||
{node.tool || "-"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("entry:Query", {defaultValue: "Query"})}>
|
||||
{this.renderNodeText(node.query)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("general:URL")}>
|
||||
{this.renderNodeText(node.url)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("entry:Path", {defaultValue: "Path"})}>
|
||||
{this.renderNodeText(node.path)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("general:Error")}>
|
||||
{this.renderNodeText(node.error)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("entry:Text", {defaultValue: "Text"})}>
|
||||
{this.renderNodeText(node.text)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
) : null}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
renderContent() {
|
||||
if (this.state.loading) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
padding: "48px 0",
|
||||
}}
|
||||
>
|
||||
<Spin />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (this.state.error) {
|
||||
return <Alert type="warning" showIcon message={this.state.error} />;
|
||||
}
|
||||
|
||||
if (!this.state.graph) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{this.renderStats()}
|
||||
<ReactFlowProvider>
|
||||
<OpenClawSessionGraphCanvas
|
||||
graph={this.state.graph}
|
||||
onNodeSelect={(selectedNode) => this.setState({selectedNode})}
|
||||
/>
|
||||
</ReactFlowProvider>
|
||||
{this.renderNodeDrawer()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.state.loading && !this.state.error && !this.state.graph) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Row style={{marginTop: "20px"}}>
|
||||
<Col style={{marginTop: "5px"}} span={this.getLabelSpan()}>
|
||||
{i18next.t("entry:Session graph", {defaultValue: "Session Graph"})}:
|
||||
</Col>
|
||||
<Col span={this.getContentSpan()}>
|
||||
<div data-testid="openclaw-session-graph">{this.renderContent()}</div>
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default OpenClawSessionGraphViewer;
|
||||
@@ -223,7 +223,7 @@ class OrganizationListPage extends BaseListPage {
|
||||
title: i18next.t("general:Password type"),
|
||||
dataIndex: "passwordType",
|
||||
key: "passwordType",
|
||||
width: "150px",
|
||||
width: "160px",
|
||||
sorter: true,
|
||||
filterMultiple: false,
|
||||
filters: [
|
||||
@@ -267,7 +267,7 @@ class OrganizationListPage extends BaseListPage {
|
||||
title: i18next.t("organization:User balance"),
|
||||
dataIndex: "userBalance",
|
||||
key: "userBalance",
|
||||
width: "120px",
|
||||
width: "130px",
|
||||
sorter: true,
|
||||
render: (text, record, index) => {
|
||||
return text ?? 0;
|
||||
@@ -277,7 +277,7 @@ class OrganizationListPage extends BaseListPage {
|
||||
title: i18next.t("organization:Balance credit"),
|
||||
dataIndex: "balanceCredit",
|
||||
key: "balanceCredit",
|
||||
width: "120px",
|
||||
width: "130px",
|
||||
sorter: true,
|
||||
render: (text, record, index) => {
|
||||
return text ?? 0;
|
||||
@@ -287,7 +287,7 @@ class OrganizationListPage extends BaseListPage {
|
||||
title: i18next.t("organization:Balance currency"),
|
||||
dataIndex: "balanceCurrency",
|
||||
key: "balanceCurrency",
|
||||
width: "140px",
|
||||
width: "160px",
|
||||
sorter: true,
|
||||
render: (text, record, index) => {
|
||||
return text || "USD";
|
||||
|
||||
@@ -415,7 +415,7 @@ class PermissionListPage extends BaseListPage {
|
||||
dataIndex: "approveTime",
|
||||
key: "approveTime",
|
||||
filterMultiple: false,
|
||||
width: "120px",
|
||||
width: "130px",
|
||||
sorter: true,
|
||||
render: (text, record, index) => {
|
||||
return Setting.getFormattedDate(text);
|
||||
|
||||
@@ -341,7 +341,7 @@ class ProductStorePage extends React.Component {
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<div style={{padding: "16px"}}>
|
||||
<FloatingCartButton
|
||||
itemCount={this.state.cartItemCount}
|
||||
onClick={() => this.props.history.push("/cart")}
|
||||
|
||||
@@ -114,6 +114,17 @@ class ProviderEditPage extends React.Component {
|
||||
}
|
||||
|
||||
getProvider() {
|
||||
if (this.state.mode === "add" && this.props.location.provider) {
|
||||
const provider = this.props.location.provider;
|
||||
provider.userMapping = provider.userMapping || defaultUserMapping;
|
||||
this.setState({
|
||||
provider: provider,
|
||||
nameNotUserEdited: isDefaultProviderName(provider.name),
|
||||
displayNameNotUserEdited: isDefaultProviderDisplayName(provider.displayName),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
ProviderBackend.getProvider(this.state.owner, this.state.providerName)
|
||||
.then((res) => {
|
||||
if (res.data === null) {
|
||||
@@ -770,6 +781,7 @@ class ProviderEditPage extends React.Component {
|
||||
this.updateProviderField("host", "");
|
||||
this.updateProviderField("port", 0);
|
||||
this.updateProviderField("title", "");
|
||||
this.updateProviderField("state", "Enabled");
|
||||
}
|
||||
if (defaultType) {
|
||||
if (this.state.nameNotUserEdited) {
|
||||
@@ -1079,13 +1091,18 @@ class ProviderEditPage extends React.Component {
|
||||
|
||||
submitProviderEdit(exitAfterSave) {
|
||||
const provider = Setting.deepCopy(this.state.provider);
|
||||
ProviderBackend.updateProvider(this.state.owner, this.state.providerName, provider)
|
||||
const isAdd = this.state.mode === "add";
|
||||
const apiCall = isAdd
|
||||
? ProviderBackend.addProvider(provider)
|
||||
: ProviderBackend.updateProvider(this.state.owner, this.state.providerName, provider);
|
||||
apiCall
|
||||
.then((res) => {
|
||||
if (res.status === "ok") {
|
||||
Setting.showMessage("success", i18next.t("general:Successfully saved"));
|
||||
this.setState({
|
||||
owner: this.state.provider.owner,
|
||||
providerName: this.state.provider.name,
|
||||
mode: "edit",
|
||||
});
|
||||
|
||||
if (exitAfterSave) {
|
||||
@@ -1095,7 +1112,9 @@ class ProviderEditPage extends React.Component {
|
||||
}
|
||||
} else {
|
||||
Setting.showMessage("error", `${i18next.t("general:Failed to save")}: ${res.msg}`);
|
||||
this.updateProviderField("name", this.state.providerName);
|
||||
if (!isAdd) {
|
||||
this.updateProviderField("name", this.state.providerName);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
@@ -1104,17 +1123,7 @@ class ProviderEditPage extends React.Component {
|
||||
}
|
||||
|
||||
deleteProvider() {
|
||||
ProviderBackend.deleteProvider(this.state.provider)
|
||||
.then((res) => {
|
||||
if (res.status === "ok") {
|
||||
this.props.history.push("/providers");
|
||||
} else {
|
||||
Setting.showMessage("error", `${i18next.t("general:Failed to delete")}: ${res.msg}`);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
|
||||
});
|
||||
this.props.history.push("/providers");
|
||||
}
|
||||
|
||||
render() {
|
||||
|
||||
@@ -57,18 +57,7 @@ class ProviderListPage extends BaseListPage {
|
||||
|
||||
addProvider() {
|
||||
const newProvider = this.newProvider();
|
||||
ProviderBackend.addProvider(newProvider)
|
||||
.then((res) => {
|
||||
if (res.status === "ok") {
|
||||
this.props.history.push({pathname: `/providers/${newProvider.owner}/${newProvider.name}`, mode: "add"});
|
||||
Setting.showMessage("success", i18next.t("general:Successfully added"));
|
||||
} else {
|
||||
Setting.showMessage("error", `${i18next.t("general:Failed to add")}: ${res.msg}`);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
|
||||
});
|
||||
this.props.history.push({pathname: `/providers/${newProvider.owner}/${newProvider.name}`, mode: "add", provider: newProvider});
|
||||
}
|
||||
|
||||
deleteProvider(i) {
|
||||
|
||||
@@ -72,7 +72,7 @@ class RecordListPage extends BaseListPage {
|
||||
title: i18next.t("general:Organization"),
|
||||
dataIndex: "organization",
|
||||
key: "organization",
|
||||
width: "110px",
|
||||
width: "140px",
|
||||
sorter: true,
|
||||
...this.getColumnSearchProps("organization"),
|
||||
render: (text, record, index) => {
|
||||
@@ -102,7 +102,7 @@ class RecordListPage extends BaseListPage {
|
||||
title: i18next.t("general:Method"),
|
||||
dataIndex: "method",
|
||||
key: "method",
|
||||
width: "100px",
|
||||
width: "110px",
|
||||
sorter: true,
|
||||
filterMultiple: false,
|
||||
filters: [
|
||||
@@ -129,7 +129,7 @@ class RecordListPage extends BaseListPage {
|
||||
title: i18next.t("user:Language"),
|
||||
dataIndex: "language",
|
||||
key: "language",
|
||||
width: "90px",
|
||||
width: "120px",
|
||||
sorter: true,
|
||||
...this.getColumnSearchProps("language"),
|
||||
},
|
||||
@@ -204,13 +204,13 @@ class RecordListPage extends BaseListPage {
|
||||
sorter: true,
|
||||
fixed: "right",
|
||||
render: (text, record, index) => (
|
||||
<Button type="link" onClick={() => {
|
||||
<Button onClick={() => {
|
||||
this.setState({
|
||||
detailRecord: record,
|
||||
detailShow: true,
|
||||
});
|
||||
}}>
|
||||
{i18next.t("general:Detail")}
|
||||
{i18next.t("general:View")}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
|
||||
164
web/src/SELinuxEntryViewer.js
Normal file
164
web/src/SELinuxEntryViewer.js
Normal file
@@ -0,0 +1,164 @@
|
||||
// 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.
|
||||
|
||||
import React from "react";
|
||||
import {Col, Descriptions, Row, Tag} from "antd";
|
||||
import * as Setting from "./Setting";
|
||||
import i18next from "i18next";
|
||||
|
||||
class SELinuxEntryViewer extends React.Component {
|
||||
getLabelSpan() {
|
||||
return this.props.labelSpan ?? (Setting.isMobile() ? 22 : 2);
|
||||
}
|
||||
|
||||
getContentSpan() {
|
||||
return this.props.contentSpan ?? 22;
|
||||
}
|
||||
|
||||
getMessage() {
|
||||
return `${this.props.entry?.message ?? ""}`.trim();
|
||||
}
|
||||
|
||||
getSeverityColor(severity) {
|
||||
switch ((severity || "").toLowerCase()) {
|
||||
case "warning":
|
||||
return "orange";
|
||||
case "error":
|
||||
return "red";
|
||||
case "info":
|
||||
return "blue";
|
||||
default:
|
||||
return "default";
|
||||
}
|
||||
}
|
||||
|
||||
escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
extractValue(message, key) {
|
||||
const escapedKey = this.escapeRegExp(key);
|
||||
const quotedMatch = message.match(new RegExp(`(?:^|\\s)${escapedKey}="([^"]*)"`, "i"));
|
||||
if (quotedMatch) {
|
||||
return quotedMatch[1];
|
||||
}
|
||||
|
||||
const plainMatch = message.match(new RegExp(`(?:^|\\s)${escapedKey}=([^\\s]+)`, "i"));
|
||||
return plainMatch ? plainMatch[1] : "";
|
||||
}
|
||||
|
||||
parseMessage() {
|
||||
const message = this.getMessage();
|
||||
const severityMatch = message.match(/^\[([^\]]+)\]\s*/);
|
||||
const severity = severityMatch ? severityMatch[1] : "";
|
||||
const body = severityMatch ? message.slice(severityMatch[0].length) : message;
|
||||
|
||||
const details = {
|
||||
severity,
|
||||
auditType: this.extractValue(body, "type"),
|
||||
auditStamp: (body.match(/msg=audit\(([^)]+)\)/) || [])[1] || "",
|
||||
decision: (body.match(/\bavc:\s+([a-z_]+)/i) || [])[1] || "",
|
||||
permission: (body.match(/\{\s*([^}]+?)\s*\}/) || [])[1] || "",
|
||||
pid: this.extractValue(body, "pid"),
|
||||
command: this.extractValue(body, "comm"),
|
||||
executable: this.extractValue(body, "exe"),
|
||||
path: this.extractValue(body, "path"),
|
||||
device: this.extractValue(body, "dev"),
|
||||
inode: this.extractValue(body, "ino"),
|
||||
sourceContext: this.extractValue(body, "scontext"),
|
||||
targetContext: this.extractValue(body, "tcontext"),
|
||||
targetClass: this.extractValue(body, "tclass"),
|
||||
permissive: this.extractValue(body, "permissive"),
|
||||
rawBody: body,
|
||||
};
|
||||
|
||||
return details;
|
||||
}
|
||||
|
||||
renderValue(value, render) {
|
||||
if (!value) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
return render ? render(value) : value;
|
||||
}
|
||||
|
||||
render() {
|
||||
const details = this.parseMessage();
|
||||
|
||||
return (
|
||||
<Row style={{marginTop: "20px"}}>
|
||||
<Col style={{marginTop: "5px"}} span={this.getLabelSpan()}>
|
||||
{i18next.t("entry:SELinux event", {defaultValue: "SELinux event"})}:
|
||||
</Col>
|
||||
<Col span={this.getContentSpan()}>
|
||||
<Descriptions
|
||||
bordered
|
||||
size="small"
|
||||
column={Setting.isMobile() ? 1 : 2}
|
||||
layout={Setting.isMobile() ? "vertical" : "horizontal"}
|
||||
>
|
||||
<Descriptions.Item label={i18next.t("general:Severity")}>
|
||||
{this.renderValue(details.severity, value => <Tag color={this.getSeverityColor(value)}>{value}</Tag>)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("general:Type")}>
|
||||
{this.renderValue(details.auditType)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("entry:Decision", {defaultValue: "Decision"})}>
|
||||
{this.renderValue(details.decision)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("entry:Permission", {defaultValue: "Permission"})}>
|
||||
{this.renderValue(details.permission)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("entry:Audit stamp", {defaultValue: "Audit stamp"})}>
|
||||
{this.renderValue(details.auditStamp)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("entry:Permissive", {defaultValue: "Permissive"})}>
|
||||
{this.renderValue(details.permissive)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("entry:Process ID", {defaultValue: "Process ID"})}>
|
||||
{this.renderValue(details.pid)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("entry:Command", {defaultValue: "Command"})}>
|
||||
{this.renderValue(details.command)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("entry:Executable", {defaultValue: "Executable"})}>
|
||||
{this.renderValue(details.executable)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("entry:Target class", {defaultValue: "Target class"})}>
|
||||
{this.renderValue(details.targetClass)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("general:Path")}>
|
||||
{this.renderValue(details.path)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("entry:Device", {defaultValue: "Device"})}>
|
||||
{this.renderValue(details.device)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("entry:Inode", {defaultValue: "Inode"})}>
|
||||
{this.renderValue(details.inode)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("entry:Source context", {defaultValue: "Source context"})} span={Setting.isMobile() ? 1 : 2}>
|
||||
{this.renderValue(details.sourceContext)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={i18next.t("entry:Target context", {defaultValue: "Target context"})} span={Setting.isMobile() ? 1 : 2}>
|
||||
{this.renderValue(details.targetContext)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default SELinuxEntryViewer;
|
||||
@@ -120,6 +120,38 @@ class ServerEditPage extends React.Component {
|
||||
});
|
||||
}
|
||||
|
||||
syncMcpTool() {
|
||||
const server = Setting.deepCopy(this.state.server);
|
||||
ServerBackend.syncMcpTool(this.state.owner, this.state.serverName, server)
|
||||
.then((res) => {
|
||||
if (res.status === "ok") {
|
||||
Setting.showMessage("success", i18next.t("general:Successfully modified"));
|
||||
this.getServer();
|
||||
} else {
|
||||
Setting.showMessage("error", `${i18next.t("general:Failed to update")}: ${res.msg}`);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
|
||||
});
|
||||
}
|
||||
|
||||
clearMcpTool() {
|
||||
const server = Setting.deepCopy(this.state.server);
|
||||
ServerBackend.syncMcpTool(this.state.owner, this.state.serverName, server, true)
|
||||
.then((res) => {
|
||||
if (res.status === "ok") {
|
||||
Setting.showMessage("success", i18next.t("general:Successfully modified"));
|
||||
this.getServer();
|
||||
} else {
|
||||
Setting.showMessage("error", `${i18next.t("general:Failed to update")}: ${res.msg}`);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
|
||||
});
|
||||
}
|
||||
|
||||
deleteServer() {
|
||||
ServerBackend.deleteServer(this.state.server)
|
||||
.then((res) => {
|
||||
@@ -214,6 +246,8 @@ class ServerEditPage extends React.Component {
|
||||
{Setting.getLabel(i18next.t("general:Tool"), i18next.t("general:Tool - Tooltip"))} :
|
||||
</Col>
|
||||
<Col span={22} >
|
||||
{this.state.mode !== "add" ? <Button type="primary" style={{marginBottom: "5px"}} onClick={() => this.syncMcpTool()}>{i18next.t("general:Sync")}</Button> : null}
|
||||
{this.state.mode !== "add" ? <Button style={{marginBottom: "5px", marginLeft: "10px"}} onClick={() => this.clearMcpTool()}>{i18next.t("general:Clear")}</Button> : null}
|
||||
<ToolTable
|
||||
tools={this.state.server?.tools || []}
|
||||
onUpdateTable={(value) => {this.updateServerField("tools", value);}}
|
||||
|
||||
@@ -29,7 +29,7 @@ class ServerStorePage extends React.Component {
|
||||
onlineServerList: [],
|
||||
creatingOnlineServerId: "",
|
||||
onlineNameFilter: "",
|
||||
onlineTagFilter: [],
|
||||
onlineCategoryFilter: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ class ServerStorePage extends React.Component {
|
||||
this.setState({
|
||||
onlineListLoading: true,
|
||||
onlineNameFilter: "",
|
||||
onlineTagFilter: [],
|
||||
onlineCategoryFilter: [],
|
||||
});
|
||||
|
||||
ServerBackend.getOnlineServers()
|
||||
@@ -72,16 +72,17 @@ class ServerStorePage extends React.Component {
|
||||
createServerFromOnline = (onlineServer) => {
|
||||
const owner = Setting.getRequestOrganization(this.props.account);
|
||||
const serverName = this.getOnlineServerName(onlineServer);
|
||||
const serverUrl = onlineServer.production;
|
||||
const serverUrl = onlineServer.endpoint;
|
||||
|
||||
if (!serverUrl) {
|
||||
Setting.showMessage("error", i18next.t("server:Production endpoint is empty"));
|
||||
return;
|
||||
}
|
||||
|
||||
const randomName = Setting.getRandomName();
|
||||
const newServer = {
|
||||
owner: owner,
|
||||
name: serverName,
|
||||
name: serverName + randomName,
|
||||
createdTime: moment().format(),
|
||||
displayName: onlineServer.name || serverName,
|
||||
url: serverUrl,
|
||||
@@ -107,20 +108,27 @@ class ServerStorePage extends React.Component {
|
||||
|
||||
normalizeOnlineServers = (onlineServers) => {
|
||||
return onlineServers.map((server, index) => {
|
||||
const rawTags = Array.isArray(server?.tags) ? server.tags : [];
|
||||
const categoriesRaw = [server?.category].filter((category) => typeof category === "string" && category.trim() !== "");
|
||||
|
||||
return {
|
||||
id: server.id ?? `${server.name ?? "server"}-${index}`,
|
||||
name: server.name ?? "",
|
||||
nameText: (server.name ?? "").toLowerCase(),
|
||||
tagsRaw: rawTags,
|
||||
tagsLower: rawTags.map((tag) => tag.toLowerCase()),
|
||||
production: server.endpoints?.production ?? "",
|
||||
categoriesRaw: categoriesRaw,
|
||||
categoriesLower: categoriesRaw.map((category) => category.toLowerCase()),
|
||||
endpoint: server.endpoints?.production ?? server.endpoint ?? "",
|
||||
description: server.description ?? "",
|
||||
authentication: server?.authentication?.type,
|
||||
website: server?.maintainer?.website,
|
||||
website: server?.maintainer?.website ?? server?.website,
|
||||
};
|
||||
}).filter(server => server.production.startsWith("http"));
|
||||
}).filter(server => server.endpoint.startsWith("http"));
|
||||
};
|
||||
|
||||
getWebsiteUrl = (website) => {
|
||||
if (!website) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return /^https?:\/\//i.test(website) ? website : `https://${website}`;
|
||||
};
|
||||
|
||||
getOnlineServersFromResponse = (data) => {
|
||||
@@ -139,19 +147,19 @@ class ServerStorePage extends React.Component {
|
||||
return [];
|
||||
};
|
||||
|
||||
getOnlineTagOptions = () => {
|
||||
const tags = this.state.onlineServerList.flatMap((server) => server.tagsRaw || []);
|
||||
return [...new Set(tags)].sort((a, b) => a.localeCompare(b)).map((tag) => ({label: tag, value: tag.toLowerCase()}));
|
||||
getOnlineCategoryOptions = () => {
|
||||
const categories = this.state.onlineServerList.flatMap((server) => server.categoriesRaw || []);
|
||||
return [...new Set(categories)].sort((a, b) => a.localeCompare(b)).map((category) => ({label: category, value: category.toLowerCase()}));
|
||||
};
|
||||
|
||||
getFilteredOnlineServers = () => {
|
||||
const nameFilter = this.state.onlineNameFilter.trim().toLowerCase();
|
||||
const tagFilter = this.state.onlineTagFilter;
|
||||
const categoryFilter = this.state.onlineCategoryFilter;
|
||||
|
||||
return this.state.onlineServerList.filter((server) => {
|
||||
const nameMatched = !nameFilter || server.nameText.includes(nameFilter);
|
||||
const tagMatched = tagFilter.length === 0 || tagFilter.some((tag) => server.tagsLower.includes(tag));
|
||||
return nameMatched && tagMatched;
|
||||
const categoryMatched = categoryFilter.length === 0 || categoryFilter.some((category) => server.categoriesLower.includes(category));
|
||||
return nameMatched && categoryMatched;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -180,19 +188,23 @@ class ServerStorePage extends React.Component {
|
||||
<Text type="secondary">{server.description || "-"}</Text>
|
||||
</div>
|
||||
<div style={{marginBottom: "8px"}}>
|
||||
<Text strong>{i18next.t("application:Authentication")}: </Text>
|
||||
<Text>{server.authentication || "-"}</Text>
|
||||
<Text strong>{i18next.t("general:Url")}: </Text>
|
||||
{server.website ? (
|
||||
<a target="_blank" rel="noreferrer" href={this.getWebsiteUrl(server.endpoint)}>{server.endpoint}</a>
|
||||
) : (
|
||||
<Text>-</Text>
|
||||
)}
|
||||
</div>
|
||||
<div style={{marginBottom: "8px"}}>
|
||||
<Text strong>{i18next.t("general:Website")}: </Text>
|
||||
{server.website ? (
|
||||
<a target="_blank" rel="noreferrer" href={`https://${server.website}`}>{server.website}</a>
|
||||
<a target="_blank" rel="noreferrer" href={this.getWebsiteUrl(server.website)}>{server.website}</a>
|
||||
) : (
|
||||
<Text>-</Text>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{(server.tagsRaw || []).map((tag) => <Tag key={`${server.id}-${tag}`}>{tag}</Tag>)}
|
||||
{(server.categoriesRaw || []).map((category) => <Tag key={`${server.id}-${category}`}>{category}</Tag>)}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
@@ -203,7 +215,7 @@ class ServerStorePage extends React.Component {
|
||||
const filteredServers = this.getFilteredOnlineServers();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{padding: "16px"}}>
|
||||
<div style={{display: "flex", gap: "8px", marginBottom: "12px"}}>
|
||||
<Input
|
||||
allowClear
|
||||
@@ -214,13 +226,13 @@ class ServerStorePage extends React.Component {
|
||||
<Select
|
||||
mode="multiple"
|
||||
allowClear
|
||||
placeholder={i18next.t("general:Tag")}
|
||||
value={this.state.onlineTagFilter}
|
||||
onChange={(values) => this.setState({onlineTagFilter: values})}
|
||||
options={this.getOnlineTagOptions()}
|
||||
placeholder={i18next.t("general:Category")}
|
||||
value={this.state.onlineCategoryFilter}
|
||||
onChange={(values) => this.setState({onlineCategoryFilter: values})}
|
||||
options={this.getOnlineCategoryOptions()}
|
||||
style={{minWidth: "260px"}}
|
||||
/>
|
||||
<Button onClick={() => this.setState({onlineNameFilter: "", onlineTagFilter: []})}>
|
||||
<Button onClick={() => this.setState({onlineNameFilter: "", onlineCategoryFilter: []})}>
|
||||
{i18next.t("general:Clear")}
|
||||
</Button>
|
||||
<Button onClick={this.fetchOnlineServers}>
|
||||
|
||||
@@ -140,6 +140,7 @@ class SessionListPage extends BaseListPage {
|
||||
|
||||
const paginationProps = {
|
||||
total: this.state.pagination.total,
|
||||
pageSize: this.state.pagination.pageSize,
|
||||
showQuickJumper: true,
|
||||
showSizeChanger: true,
|
||||
showTotal: () => i18next.t("general:{total} in total").replace("{total}", this.state.pagination.total),
|
||||
@@ -148,6 +149,11 @@ class SessionListPage extends BaseListPage {
|
||||
return (
|
||||
<div>
|
||||
<Table scroll={{x: "max-content"}} columns={columns} dataSource={sessions} rowKey={(record) => `${record.owner}/${record.name}`} size="middle" bordered pagination={paginationProps}
|
||||
title={() => (
|
||||
<div>
|
||||
{i18next.t("general:Sessions")}
|
||||
</div>
|
||||
)}
|
||||
loading={this.state.loading}
|
||||
onChange={this.handleTableChange}
|
||||
/>
|
||||
|
||||
@@ -48,6 +48,7 @@ export const Countries = [
|
||||
{label: "Português", key: "pt", country: "PT", alt: "Português"},
|
||||
{label: "Türkçe", key: "tr", country: "TR", alt: "Türkçe"},
|
||||
{label: "Polski", key: "pl", country: "PL", alt: "Polski"},
|
||||
{label: "Русский", key: "ru", country: "RU", alt: "Русский"},
|
||||
{label: "Українська", key: "uk", country: "UA", alt: "Українська"},
|
||||
];
|
||||
|
||||
@@ -1489,6 +1490,43 @@ function isSigninMethodEnabled(application, signinMethod) {
|
||||
}
|
||||
}
|
||||
|
||||
export const CaptchaRule = {
|
||||
Always: "Always",
|
||||
Never: "Never",
|
||||
Dynamic: "Dynamic",
|
||||
InternetOnly: "Internet-Only",
|
||||
};
|
||||
|
||||
export function getCaptchaProviderItems(application) {
|
||||
const providers = application?.providers;
|
||||
if (!providers) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return providers.filter(providerItem => providerItem?.provider?.category === "Captcha");
|
||||
}
|
||||
|
||||
export function getCaptchaRule(application) {
|
||||
const captchaProviderItems = getCaptchaProviderItems(application);
|
||||
if (captchaProviderItems.some(providerItem => providerItem.rule === CaptchaRule.Always)) {
|
||||
return CaptchaRule.Always;
|
||||
} else if (captchaProviderItems.some(providerItem => providerItem.rule === CaptchaRule.Dynamic)) {
|
||||
return CaptchaRule.Dynamic;
|
||||
} else if (captchaProviderItems.some(providerItem => providerItem.rule === CaptchaRule.InternetOnly)) {
|
||||
return CaptchaRule.InternetOnly;
|
||||
}
|
||||
|
||||
return CaptchaRule.Never;
|
||||
}
|
||||
|
||||
export function isInlineCaptchaEnabled(application) {
|
||||
return application?.signinItems?.some(signinItem => signinItem.name === "Captcha" && signinItem.rule === "inline") || false;
|
||||
}
|
||||
|
||||
export function isCaptchaEnabled(application) {
|
||||
return getCaptchaRule(application) !== CaptchaRule.Never;
|
||||
}
|
||||
|
||||
export function isPasswordEnabled(application) {
|
||||
return isSigninMethodEnabled(application, "Password");
|
||||
}
|
||||
|
||||
@@ -197,7 +197,7 @@ class SiteListPage extends BaseListPage {
|
||||
title: i18next.t("site:Other domains"),
|
||||
dataIndex: "otherDomains",
|
||||
key: "otherDomains",
|
||||
width: "120px",
|
||||
width: "140px",
|
||||
sorter: (a, b) => a.otherDomains.localeCompare(b.otherDomains),
|
||||
render: (text, record, index) => {
|
||||
return record.otherDomains.map(domain => {
|
||||
|
||||
@@ -162,7 +162,7 @@ class SyncerListPage extends BaseListPage {
|
||||
title: i18next.t("syncer:Database type"),
|
||||
dataIndex: "databaseType",
|
||||
key: "databaseType",
|
||||
width: "130px",
|
||||
width: "140px",
|
||||
sorter: (a, b) => a.databaseType.localeCompare(b.databaseType),
|
||||
},
|
||||
{
|
||||
@@ -215,7 +215,7 @@ class SyncerListPage extends BaseListPage {
|
||||
title: i18next.t("syncer:Sync interval"),
|
||||
dataIndex: "syncInterval",
|
||||
key: "syncInterval",
|
||||
width: "140px",
|
||||
width: "150px",
|
||||
sorter: true,
|
||||
...this.getColumnSearchProps("syncInterval"),
|
||||
},
|
||||
|
||||
@@ -109,7 +109,7 @@ class TokenListPage extends BaseListPage {
|
||||
title: i18next.t("general:Application"),
|
||||
dataIndex: "application",
|
||||
key: "application",
|
||||
width: "120px",
|
||||
width: "130px",
|
||||
sorter: true,
|
||||
...this.getColumnSearchProps("application"),
|
||||
render: (text, record, index) => {
|
||||
@@ -124,7 +124,7 @@ class TokenListPage extends BaseListPage {
|
||||
title: i18next.t("general:Organization"),
|
||||
dataIndex: "organization",
|
||||
key: "organization",
|
||||
width: "120px",
|
||||
width: "140px",
|
||||
sorter: true,
|
||||
...this.getColumnSearchProps("organization"),
|
||||
render: (text, record, index) => {
|
||||
|
||||
@@ -396,7 +396,7 @@ class UserListPage extends BaseListPage {
|
||||
title: i18next.t("application:Real name"),
|
||||
dataIndex: "realName",
|
||||
key: "realName",
|
||||
width: "120px",
|
||||
width: "130px",
|
||||
sorter: true,
|
||||
...this.getColumnSearchProps("realName"),
|
||||
},
|
||||
@@ -464,7 +464,7 @@ class UserListPage extends BaseListPage {
|
||||
title: i18next.t("user:Register source"),
|
||||
dataIndex: "registerSource",
|
||||
key: "registerSource",
|
||||
width: "150px",
|
||||
width: "160px",
|
||||
sorter: true,
|
||||
...this.getColumnSearchProps("registerSource"),
|
||||
},
|
||||
@@ -482,7 +482,7 @@ class UserListPage extends BaseListPage {
|
||||
title: i18next.t("organization:Balance credit"),
|
||||
dataIndex: "balanceCredit",
|
||||
key: "balanceCredit",
|
||||
width: "120px",
|
||||
width: "130px",
|
||||
sorter: true,
|
||||
render: (text, record, index) => {
|
||||
return text ?? 0;
|
||||
@@ -492,7 +492,7 @@ class UserListPage extends BaseListPage {
|
||||
title: i18next.t("organization:Balance currency"),
|
||||
dataIndex: "balanceCurrency",
|
||||
key: "balanceCurrency",
|
||||
width: "140px",
|
||||
width: "160px",
|
||||
sorter: true,
|
||||
render: (text, record, index) => {
|
||||
return text || "USD";
|
||||
@@ -514,7 +514,7 @@ class UserListPage extends BaseListPage {
|
||||
title: i18next.t("user:Is forbidden"),
|
||||
dataIndex: "isForbidden",
|
||||
key: "isForbidden",
|
||||
width: "110px",
|
||||
width: "120px",
|
||||
sorter: true,
|
||||
render: (text, record, index) => {
|
||||
return (
|
||||
@@ -545,9 +545,9 @@ class UserListPage extends BaseListPage {
|
||||
const disabled = (record.owner === this.props.account.owner && record.name === this.props.account.name) || (record.owner === "built-in" && record.name === "admin");
|
||||
return (
|
||||
<Space>
|
||||
<Button size={isTreePage ? "small" : "middle"} type="primary" onClick={() => {
|
||||
<Button size={isTreePage ? "small" : "middle"} onClick={() => {
|
||||
this.impersonateUser(`${record.owner}/${record.name}`);
|
||||
}}>{i18next.t("general:Impersonation")}
|
||||
}}>{i18next.t("general:Impersonate")}
|
||||
</Button>
|
||||
<Button size={isTreePage ? "small" : "middle"} type="primary" onClick={() => {
|
||||
sessionStorage.setItem("userListUrl", window.location.pathname);
|
||||
|
||||
@@ -142,7 +142,7 @@ class VerificationListPage extends BaseListPage {
|
||||
title: i18next.t("login:Verification code"),
|
||||
dataIndex: "code",
|
||||
key: "code",
|
||||
width: "150px",
|
||||
width: "160px",
|
||||
sorter: true,
|
||||
...this.getColumnSearchProps("code"),
|
||||
},
|
||||
|
||||
@@ -148,7 +148,7 @@ class WebhookListPage extends BaseListPage {
|
||||
title: i18next.t("webhook:Content type"),
|
||||
dataIndex: "contentType",
|
||||
key: "contentType",
|
||||
width: "140px",
|
||||
width: "150px",
|
||||
sorter: true,
|
||||
filterMultiple: false,
|
||||
filters: [
|
||||
@@ -171,7 +171,7 @@ class WebhookListPage extends BaseListPage {
|
||||
title: i18next.t("webhook:Is user extended"),
|
||||
dataIndex: "isUserExtended",
|
||||
key: "isUserExtended",
|
||||
width: "140px",
|
||||
width: "150px",
|
||||
sorter: true,
|
||||
render: (text, record, index) => {
|
||||
return (
|
||||
|
||||
@@ -32,7 +32,7 @@ import i18next from "i18next";
|
||||
import CustomGithubCorner from "../common/CustomGithubCorner";
|
||||
import {SendCodeInput} from "../common/SendCodeInput";
|
||||
import LanguageSelect from "../common/select/LanguageSelect";
|
||||
import {CaptchaModal, CaptchaRule} from "../common/modal/CaptchaModal";
|
||||
import {CaptchaModal} from "../common/modal/CaptchaModal";
|
||||
import RedirectForm from "../common/RedirectForm";
|
||||
import {RequiredMfa} from "./mfa/MfaAuthVerifyForm";
|
||||
import {GoogleOneTapLoginVirtualButton} from "./GoogleLoginButton";
|
||||
@@ -89,10 +89,6 @@ class LoginPage extends React.Component {
|
||||
this.captchaRef.current?.loadCaptcha?.();
|
||||
}
|
||||
|
||||
isInlineCaptchaEnabled(application = this.getApplicationObj()) {
|
||||
return application?.signinItems?.some(signinItem => signinItem.name === "Captcha" && signinItem.rule === "inline");
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.getApplicationObj() === undefined) {
|
||||
if (this.state.type === "login" || this.state.type === "saml") {
|
||||
@@ -144,21 +140,6 @@ class LoginPage extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
getCaptchaRule(application) {
|
||||
const captchaProviderItems = this.getCaptchaProviderItems(application);
|
||||
if (captchaProviderItems) {
|
||||
if (captchaProviderItems.some(providerItem => providerItem.rule === "Always")) {
|
||||
return CaptchaRule.Always;
|
||||
} else if (captchaProviderItems.some(providerItem => providerItem.rule === "Dynamic")) {
|
||||
return CaptchaRule.Dynamic;
|
||||
} else if (captchaProviderItems.some(providerItem => providerItem.rule === "Internet-Only")) {
|
||||
return CaptchaRule.InternetOnly;
|
||||
} else {
|
||||
return CaptchaRule.Never;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
checkCaptchaStatus(values) {
|
||||
AuthBackend.getCaptchaStatus(values)
|
||||
.then((res) => {
|
||||
@@ -459,20 +440,20 @@ class LoginPage extends React.Component {
|
||||
} else {
|
||||
values["password"] = passwordCipher;
|
||||
}
|
||||
const captchaRule = this.getCaptchaRule(this.getApplicationObj());
|
||||
const captchaRule = Setting.getCaptchaRule(this.getApplicationObj());
|
||||
const application = this.getApplicationObj();
|
||||
const inlineCaptchaEnabled = this.isInlineCaptchaEnabled(application);
|
||||
const inlineCaptchaEnabled = Setting.isInlineCaptchaEnabled(application);
|
||||
if (!inlineCaptchaEnabled) {
|
||||
if (captchaRule === CaptchaRule.Always) {
|
||||
if (captchaRule === Setting.CaptchaRule.Always) {
|
||||
this.setState({
|
||||
openCaptchaModal: true,
|
||||
values: values,
|
||||
});
|
||||
return;
|
||||
} else if (captchaRule === CaptchaRule.Dynamic) {
|
||||
} else if (captchaRule === Setting.CaptchaRule.Dynamic) {
|
||||
this.checkCaptchaStatus(values);
|
||||
return;
|
||||
} else if (captchaRule === CaptchaRule.InternetOnly) {
|
||||
} else if (captchaRule === Setting.CaptchaRule.InternetOnly) {
|
||||
this.checkCaptchaStatus(values);
|
||||
return;
|
||||
}
|
||||
@@ -489,7 +470,7 @@ class LoginPage extends React.Component {
|
||||
// here we are supposed to determine whether Casdoor is working as an OAuth server or CAS server
|
||||
values["language"] = this.state.userLang ?? "";
|
||||
const usedCaptcha = this.state.captchaValues !== undefined;
|
||||
const inlineCaptchaEnabled = this.isInlineCaptchaEnabled();
|
||||
const inlineCaptchaEnabled = Setting.isInlineCaptchaEnabled(this.getApplicationObj());
|
||||
const shouldRefreshCaptcha = usedCaptcha && inlineCaptchaEnabled && !this.state.loginMethod?.includes("verificationCode");
|
||||
if (this.state.type === "cas") {
|
||||
// CAS
|
||||
@@ -945,7 +926,7 @@ class LoginPage extends React.Component {
|
||||
{
|
||||
application.providers.filter(providerItem => this.isProviderVisible(providerItem)).map((providerItem, id) => {
|
||||
if (providerHint === providerItem.provider.name) {
|
||||
goToLink(Provider.getAuthUrl(application, providerItem.provider, "signup"));
|
||||
goToLink(Provider.getAuthUrl(application, providerItem.provider, this.state.mode ?? "signup"));
|
||||
return;
|
||||
}
|
||||
return (
|
||||
@@ -958,7 +939,7 @@ class LoginPage extends React.Component {
|
||||
}
|
||||
}}>
|
||||
{
|
||||
ProviderButton.renderProviderLogo(providerItem.provider, application, null, null, signinItem.rule, this.props.location)
|
||||
ProviderButton.renderProviderLogo(providerItem.provider, application, null, null, signinItem.rule, this.props.location, this.state.mode ?? "signup")
|
||||
}
|
||||
</span>
|
||||
);
|
||||
@@ -1108,40 +1089,24 @@ class LoginPage extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
getCaptchaProviderItems(application) {
|
||||
const providers = application?.providers;
|
||||
|
||||
if (providers === undefined || providers === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return providers.filter(providerItem => {
|
||||
if (providerItem.provider === undefined || providerItem.provider === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return providerItem.provider.category === "Captcha";
|
||||
});
|
||||
}
|
||||
|
||||
renderCaptchaModal(application, noModal) {
|
||||
if (this.getCaptchaRule(this.getApplicationObj()) === CaptchaRule.Never) {
|
||||
if (Setting.getCaptchaRule(this.getApplicationObj()) === Setting.CaptchaRule.Never) {
|
||||
return null;
|
||||
}
|
||||
const captchaProviderItems = this.getCaptchaProviderItems(application);
|
||||
const captchaProviderItems = Setting.getCaptchaProviderItems(application);
|
||||
const alwaysProviderItems = captchaProviderItems.filter(providerItem => providerItem.rule === "Always");
|
||||
const dynamicProviderItems = captchaProviderItems.filter(providerItem => providerItem.rule === "Dynamic");
|
||||
const internetOnlyProviderItems = captchaProviderItems.filter(providerItem => providerItem.rule === "Internet-Only");
|
||||
|
||||
// Select provider based on the active captcha rule, not fixed priority
|
||||
const captchaRule = this.getCaptchaRule(this.getApplicationObj());
|
||||
const captchaRule = Setting.getCaptchaRule(this.getApplicationObj());
|
||||
let provider = null;
|
||||
|
||||
if (captchaRule === CaptchaRule.Always && alwaysProviderItems.length > 0) {
|
||||
if (captchaRule === Setting.CaptchaRule.Always && alwaysProviderItems.length > 0) {
|
||||
provider = alwaysProviderItems[0].provider;
|
||||
} else if (captchaRule === CaptchaRule.Dynamic && dynamicProviderItems.length > 0) {
|
||||
} else if (captchaRule === Setting.CaptchaRule.Dynamic && dynamicProviderItems.length > 0) {
|
||||
provider = dynamicProviderItems[0].provider;
|
||||
} else if (captchaRule === CaptchaRule.InternetOnly && internetOnlyProviderItems.length > 0) {
|
||||
} else if (captchaRule === Setting.CaptchaRule.InternetOnly && internetOnlyProviderItems.length > 0) {
|
||||
provider = internetOnlyProviderItems[0].provider;
|
||||
}
|
||||
|
||||
@@ -1368,10 +1333,10 @@ class LoginPage extends React.Component {
|
||||
<SendCodeInput
|
||||
disabled={this.state.username?.length === 0 || !this.state.validEmailOrPhone}
|
||||
method={"login"}
|
||||
onButtonClickArgs={[this.state.username, this.state.validEmail ? "email" : "phone", Setting.getApplicationName(application)]}
|
||||
onButtonClickArgs={[this.state.username, this.state.validEmail ? "email" : "phone", Setting.getApplicationName(application), this.state.username]}
|
||||
application={application}
|
||||
captchaValue={this.state.captchaValues}
|
||||
useInlineCaptcha={this.isInlineCaptchaEnabled(application)}
|
||||
useInlineCaptcha={Setting.isInlineCaptchaEnabled(application)}
|
||||
refreshCaptcha={this.refreshInlineCaptcha}
|
||||
/>
|
||||
</Form.Item>
|
||||
@@ -1400,7 +1365,7 @@ class LoginPage extends React.Component {
|
||||
onButtonClickArgs={[this.state.username, this.state.validEmail ? "email" : "phone", Setting.getApplicationName(application)]}
|
||||
application={application}
|
||||
captchaValue={this.state.captchaValues}
|
||||
useInlineCaptcha={this.isInlineCaptchaEnabled(application)}
|
||||
useInlineCaptcha={Setting.isInlineCaptchaEnabled(application)}
|
||||
refreshCaptcha={this.refreshInlineCaptcha}
|
||||
/>
|
||||
</Form.Item>
|
||||
@@ -1585,7 +1550,7 @@ class LoginPage extends React.Component {
|
||||
|
||||
const visibleOAuthProviderItems = (application.providers === null) ? [] : application.providers.filter(providerItem => this.isProviderVisible(providerItem) && providerItem.provider?.category !== "SAML");
|
||||
if (this.props.preview !== "auto" && !Setting.isPasswordEnabled(application) && !Setting.isCodeSigninEnabled(application) && !Setting.isWebAuthnEnabled(application) && !Setting.isLdapEnabled(application) && visibleOAuthProviderItems.length === 1) {
|
||||
Setting.goToLink(Provider.getAuthUrl(application, visibleOAuthProviderItems[0].provider, "signup"));
|
||||
Setting.goToLink(Provider.getAuthUrl(application, visibleOAuthProviderItems[0].provider, this.state.mode ?? "signup"));
|
||||
return (
|
||||
<div style={{display: "flex", justifyContent: "center", alignItems: "center", width: "100%"}}>
|
||||
<Spin size="large" tip={i18next.t("login:Signing in...")} />
|
||||
|
||||
@@ -143,20 +143,20 @@ export function goToWeb3Url(application, provider, method) {
|
||||
}
|
||||
}
|
||||
|
||||
export function renderProviderLogo(provider, application, width, margin, size, location) {
|
||||
export function renderProviderLogo(provider, application, width, margin, size, location, method = "signup") {
|
||||
if (size === "small") {
|
||||
if (provider.category === "OAuth") {
|
||||
if (provider.type === "WeChat" && provider.clientId2 !== "" && provider.clientSecret2 !== "" && provider.disableSsl === true && !navigator.userAgent.includes("MicroMessenger")) {
|
||||
return (
|
||||
<a key={provider.displayName} >
|
||||
<img width={width} height={width} src={getProviderLogoURL(provider)} alt={provider.displayName} className="provider-img" style={{margin: margin}} onClick={() => {
|
||||
WechatOfficialAccountModal(application, provider, "signup");
|
||||
WechatOfficialAccountModal(application, provider, method);
|
||||
}} />
|
||||
</a>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<a key={provider.displayName} href={Provider.getAuthUrl(application, provider, "signup")}>
|
||||
<a key={provider.displayName} href={Provider.getAuthUrl(application, provider, method)}>
|
||||
<img width={width} height={width} src={getProviderLogoURL(provider)} alt={provider.displayName} className="provider-img" style={{margin: margin}} />
|
||||
</a>
|
||||
);
|
||||
@@ -169,7 +169,7 @@ export function renderProviderLogo(provider, application, width, margin, size, l
|
||||
);
|
||||
} else if (provider.category === "Web3") {
|
||||
return (
|
||||
<a key={provider.displayName} onClick={() => goToWeb3Url(application, provider, "signup")}>
|
||||
<a key={provider.displayName} onClick={() => goToWeb3Url(application, provider, method)}>
|
||||
<img width={width} height={width} src={getProviderLogoURL(provider)} alt={provider.displayName} className="provider-img" style={{margin: margin}} />
|
||||
</a>
|
||||
);
|
||||
@@ -183,7 +183,7 @@ export function renderProviderLogo(provider, application, width, margin, size, l
|
||||
const customSpanStyle = {textAlign: "center", width: "100%", fontSize: "19px"};
|
||||
if (provider.category === "OAuth") {
|
||||
return (
|
||||
<a key={provider.displayName} href={Provider.getAuthUrl(application, provider, "signup")} style={customAStyle}>
|
||||
<a key={provider.displayName} href={Provider.getAuthUrl(application, provider, method)} style={customAStyle}>
|
||||
<div style={customButtonStyle}>
|
||||
<img width={26} src={getProviderLogoURL(provider)} alt={provider.displayName} className="provider-img" style={customImgStyle} />
|
||||
<span style={customSpanStyle}>{text}</span>
|
||||
@@ -215,7 +215,7 @@ export function renderProviderLogo(provider, application, width, margin, size, l
|
||||
} else if (provider.category === "Web3") {
|
||||
return (
|
||||
<div key={provider.displayName} className="provider-big-img">
|
||||
<a onClick={() => goToWeb3Url(application, provider, "signup")}>
|
||||
<a onClick={() => goToWeb3Url(application, provider, method)}>
|
||||
{
|
||||
getSigninButton(provider)
|
||||
}
|
||||
@@ -225,7 +225,7 @@ export function renderProviderLogo(provider, application, width, margin, size, l
|
||||
} else {
|
||||
return (
|
||||
<div key={provider.displayName} className="provider-big-img">
|
||||
<a href={Provider.getAuthUrl(application, provider, "signup")}>
|
||||
<a href={Provider.getAuthUrl(application, provider, method)}>
|
||||
{
|
||||
getSigninButton(provider)
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ export const MfaVerifySmsForm = ({mfaProps, application, onFinish, method, user}
|
||||
<SendCodeInput
|
||||
countryCode={form.getFieldValue("countryCode")}
|
||||
method={method}
|
||||
onButtonClickArgs={[mfaProps.secret || dest, isEmail() ? "email" : "phone", Setting.getApplicationName(application)]}
|
||||
onButtonClickArgs={[mfaProps.secret || dest, isEmail() ? "email" : "phone", Setting.getApplicationName(application), user?.name]}
|
||||
application={application}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -28,6 +28,13 @@ export function getEntry(owner, name) {
|
||||
}).then(res => res.json());
|
||||
}
|
||||
|
||||
export function getOpenClawSessionGraph(owner, name) {
|
||||
return fetch(`${Setting.ServerUrl}/api/get-openclaw-session-graph?id=${owner}/${encodeURIComponent(name)}`, {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
}).then(res => res.json());
|
||||
}
|
||||
|
||||
export function updateEntry(owner, name, entry) {
|
||||
const newEntry = Setting.deepCopy(entry);
|
||||
return fetch(`${Setting.ServerUrl}/api/update-entry?id=${owner}/${encodeURIComponent(name)}`, {
|
||||
|
||||
@@ -44,6 +44,15 @@ export function updateServer(owner, name, server) {
|
||||
}).then(res => res.json());
|
||||
}
|
||||
|
||||
export function syncMcpTool(owner, name, server, isCleared = false) {
|
||||
const newServer = Setting.deepCopy(server);
|
||||
return fetch(`${Setting.ServerUrl}/api/sync-mcp-tool?id=${owner}/${encodeURIComponent(name)}&isCleared=${isCleared ? "1" : "0"}`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
body: JSON.stringify(newServer),
|
||||
}).then(res => res.json());
|
||||
}
|
||||
|
||||
export function addServer(server) {
|
||||
const newServer = Setting.deepCopy(server);
|
||||
return fetch(`${Setting.ServerUrl}/api/add-server`, {
|
||||
|
||||
@@ -122,8 +122,8 @@ const Dashboard = (props) => {
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div style={{display: "flex", justifyContent: "center", alignItems: "center"}}>
|
||||
<Spin size="large" tip={i18next.t("login:Loading")} style={{paddingTop: "10%"}} />
|
||||
<div style={{display: "flex", justifyContent: "center", alignItems: "center", height: "calc(100vh - 120px)"}}>
|
||||
<Spin size="large" tip={i18next.t("login:Loading")} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ const ShortcutsPage = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{display: "flex", justifyContent: "center", flexDirection: "column", alignItems: "center"}}>
|
||||
<div style={{display: "flex", justifyContent: "center", flexDirection: "column", alignItems: "center", padding: "16px"}}>
|
||||
<GridCards items={getItems()} />
|
||||
</div>
|
||||
);
|
||||
|
||||
109
web/src/common/BreadcrumbBar.js
Normal file
109
web/src/common/BreadcrumbBar.js
Normal file
@@ -0,0 +1,109 @@
|
||||
// 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.
|
||||
|
||||
import React from "react";
|
||||
import {Breadcrumb} from "antd";
|
||||
import {Link} from "react-router-dom";
|
||||
import i18next from "i18next";
|
||||
|
||||
const RESOURCE_LABELS = {
|
||||
"apps": "general:Apps",
|
||||
"shortcuts": "general:Shortcuts",
|
||||
"account": "account:My Account",
|
||||
"organizations": "general:Organizations",
|
||||
"users": "general:Users",
|
||||
"groups": "general:Groups",
|
||||
"trees": "general:Groups",
|
||||
"invitations": "general:Invitations",
|
||||
"applications": "general:Applications",
|
||||
"providers": "application:Providers",
|
||||
"resources": "general:Resources",
|
||||
"certs": "general:Certs",
|
||||
"keys": "general:Keys",
|
||||
"agents": "general:Agents",
|
||||
"servers": "general:MCP Servers",
|
||||
"server-store": "general:MCP Store",
|
||||
"entries": "general:Entries",
|
||||
"sites": "general:Sites",
|
||||
"rules": "general:Rules",
|
||||
"roles": "general:Roles",
|
||||
"permissions": "general:Permissions",
|
||||
"models": "general:Models",
|
||||
"adapters": "general:Adapters",
|
||||
"enforcers": "general:Enforcers",
|
||||
"sessions": "general:Sessions",
|
||||
"records": "general:Records",
|
||||
"tokens": "general:Tokens",
|
||||
"verifications": "general:Verifications",
|
||||
"product-store": "general:Product Store",
|
||||
"products": "general:Products",
|
||||
"cart": "general:Cart",
|
||||
"orders": "general:Orders",
|
||||
"payments": "general:Payments",
|
||||
"plans": "general:Plans",
|
||||
"pricings": "general:Pricings",
|
||||
"subscriptions": "general:Subscriptions",
|
||||
"transactions": "general:Transactions",
|
||||
"sysinfo": "general:System Info",
|
||||
"forms": "general:Forms",
|
||||
"syncers": "general:Syncers",
|
||||
"webhooks": "general:Webhooks",
|
||||
"webhook-events": "general:Webhook Events",
|
||||
"tickets": "general:Tickets",
|
||||
"ldap": "general:LDAP",
|
||||
"mfa": "general:MFA",
|
||||
};
|
||||
|
||||
function buildBreadcrumbItems(uri) {
|
||||
const pathSegments = (uri || "").split("/").filter(Boolean);
|
||||
|
||||
const homeItem = {title: <Link to="/">{i18next.t("general:Home")}</Link>};
|
||||
|
||||
if (pathSegments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rootSegment = pathSegments[0];
|
||||
const listLabelKey = RESOURCE_LABELS[rootSegment];
|
||||
if (!listLabelKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (pathSegments.length === 1) {
|
||||
return [
|
||||
homeItem,
|
||||
{title: i18next.t(listLabelKey)},
|
||||
];
|
||||
}
|
||||
|
||||
const lastSegment = pathSegments[pathSegments.length - 1];
|
||||
const lastLabelKey = RESOURCE_LABELS[lastSegment];
|
||||
const lastLabel = lastLabelKey ? i18next.t(lastLabelKey) : lastSegment;
|
||||
|
||||
return [
|
||||
homeItem,
|
||||
{title: <Link to={`/${rootSegment}`}>{i18next.t(listLabelKey)}</Link>},
|
||||
{title: lastLabel},
|
||||
];
|
||||
}
|
||||
|
||||
const BreadcrumbBar = ({uri}) => {
|
||||
const items = buildBreadcrumbItems(uri);
|
||||
if (!items) {
|
||||
return null;
|
||||
}
|
||||
return <Breadcrumb items={items} style={{marginLeft: 8}} />;
|
||||
};
|
||||
|
||||
export default BreadcrumbBar;
|
||||
@@ -61,7 +61,7 @@ export const NavItemTree = ({disabled, checkedKeys, defaultExpandedKeys, onCheck
|
||||
],
|
||||
},
|
||||
{
|
||||
title: i18next.t("general:Logging & Auditing"),
|
||||
title: i18next.t("general:Auditing"),
|
||||
key: "/sessions-top",
|
||||
children: [
|
||||
{title: i18next.t("general:Sessions"), key: "/sessions"},
|
||||
@@ -71,7 +71,7 @@ export const NavItemTree = ({disabled, checkedKeys, defaultExpandedKeys, onCheck
|
||||
],
|
||||
},
|
||||
{
|
||||
title: i18next.t("general:Business & Payments"),
|
||||
title: i18next.t("general:Business"),
|
||||
key: "/business-top",
|
||||
children: [
|
||||
{title: i18next.t("general:Products"), key: "/products"},
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import React from "react";
|
||||
import {Tooltip} from "antd";
|
||||
import {QuestionCircleOutlined} from "@ant-design/icons";
|
||||
import i18next from "i18next";
|
||||
import * as TourConfig from "../TourConfig";
|
||||
import * as Setting from "../Setting";
|
||||
|
||||
@@ -34,7 +35,7 @@ class OpenTour extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
this.canTour() ?
|
||||
<Tooltip title="Click to open tour">
|
||||
<Tooltip title={i18next.t("general:Click to open tour")}>
|
||||
<div className="select-box" style={{display: Setting.isMobile() ? "none" : null, ...this.props.style}} onClick={() => TourConfig.setIsTourVisible(true)} >
|
||||
<QuestionCircleOutlined style={{fontSize: "24px"}} />
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user