Compare commits

...

28 Commits

Author SHA1 Message Date
Yang Luo
23c86e9018 feat: add application.EnableSamlAssertionSignature to allow disabling SAML assertion signatures (#4850) 2026-01-16 14:30:48 +08:00
DacongDA
f088827a50 feat: redirect user to last login org's login page while cookie expired (#4844) 2026-01-15 18:17:12 +08:00
IsAurora6
663815fefe feat: The frontend supports payment logic for multi-item orders. (#4843) 2026-01-15 18:16:28 +08:00
DacongDA
0d003d347e fix: improve error handling in the syncer (#4845) 2026-01-15 15:02:24 +08:00
IsAurora6
7d495ca5f2 feat: The backend supports payment logic for multi-item orders. (#4842) 2026-01-14 21:57:09 +08:00
Jiachen Ren
f89495b35c fix: use unionid instead of job_number as user name in the OAuth provider (#4841) 2026-01-14 20:02:35 +08:00
IsAurora6
4a3aefc5f5 feat: improve filter logic in order, payment, subscription get APIs (#4839) 2026-01-14 12:08:29 +08:00
Yang Luo
15646b23ff feat: support ES/ECDSA signing method in ParseStandardJwtToken() (#4837) 2026-01-14 00:47:31 +08:00
gufeiyan1215
4b663a437f feat: add RRSA (RAM roles) support for the OSS storage provider (#4831) 2026-01-13 23:01:04 +08:00
DacongDA
9fb90fbb95 feat: support user impersonation (#4817) 2026-01-13 20:47:35 +08:00
Yang Luo
65eeaef8a7 feat: fix payment currency display to use product currency instead of user balance currency (#4822) 2026-01-13 20:47:31 +08:00
IsAurora6
ecf8e2eb32 feat: add supported currency validation for payment providers (#4818) 2026-01-13 20:47:28 +08:00
soliujing
e49e678d16 feat: improve build performance, separate build dependency to allow docker cache (#4815) 2026-01-13 20:47:24 +08:00
DacongDA
623ee23285 feat: in some case, saml replay state will include special character (#4814) 2026-01-13 20:47:09 +08:00
soliujing
0901a1d5a0 feat: handle default organization in get-orders API (#4790) 2026-01-13 20:46:50 +08:00
Yang Luo
58ff2fe69c feat: include access tokens in session-level (logoutAll=false) sso-logout notifications for Single Logout (SLO) (#4804) 2026-01-13 20:46:27 +08:00
IsAurora6
737f44a059 feat: optimize authentication handling in MCP (#4801) 2026-01-09 21:27:21 +08:00
soliujing
32cef8e828 feat: add permissions for get-order and get-orders APIs (#4788) 2026-01-09 17:33:29 +08:00
Yang Luo
9e854abc77 feat: don't auto-login for single SAML provider (#4795) 2026-01-09 17:03:16 +08:00
Yang Luo
9b3343d3db feat: fix multiple webhooks don't work bug (#4798) 2026-01-08 23:41:40 +08:00
Yang Luo
5b71725c94 feat: add OIDC-compliant email_verified claim to all JWT token formats (#4797) 2026-01-08 21:12:34 +08:00
IsAurora6
59b6854ccc feat: Optimize the notifications/initialized request and authentication failure handling in MCP. (#4781) 2026-01-08 17:42:36 +08:00
Yang Luo
0daf67c52c feat: fix UTF-8 encoding error in Active Directory syncer (#4783) 2026-01-08 01:50:47 +08:00
Yang Luo
4b612269ea feat: check whether refresh token is expired after SSO logout (#4771) 2026-01-07 19:42:35 +08:00
0xkrypton
f438d39720 feat: fix Telegram OAuth login error: "failed to verify Telegram auth data: data verification failed." (#4776) 2026-01-07 19:41:43 +08:00
Eng Zer Jun
f8df200dbf feat: update github.com/shirou/gopsutil to v4 (#4773) 2026-01-07 00:51:37 +08:00
IsAurora6
cb1b3b767e feat: improve "/api/mcp" check with demo mode (#4772) 2026-01-06 14:48:24 +08:00
IsAurora6
3bec49f16c feat: enhance MCP Permissions and Response Workflow, fix bugs (#4767) 2026-01-05 22:54:12 +08:00
51 changed files with 1381 additions and 445 deletions

View File

@@ -1,12 +1,24 @@
FROM --platform=$BUILDPLATFORM node:18.19.0 AS FRONT
WORKDIR /web
COPY ./web .
RUN yarn install --frozen-lockfile --network-timeout 1000000 && NODE_OPTIONS="--max-old-space-size=4096" yarn run build
# Copy only dependency files first for better caching
COPY ./web/package.json ./web/yarn.lock ./
RUN yarn install --frozen-lockfile --network-timeout 1000000
# Copy source files and build
COPY ./web .
RUN NODE_OPTIONS="--max-old-space-size=4096" yarn run build
FROM --platform=$BUILDPLATFORM golang:1.23.12 AS BACK
WORKDIR /go/src/casdoor
# Copy only go.mod and go.sum first for dependency caching
COPY go.mod go.sum ./
RUN go mod download
# Copy source files
COPY . .
RUN ./build.sh
RUN go test -v -run TestGetVersionInfo ./util/system_test.go ./util/system.go > version_info.txt

View File

@@ -67,6 +67,9 @@ p, *, *, POST, /api/upload-users, *, *
p, *, *, GET, /api/get-resources, *, *
p, *, *, GET, /api/get-records, *, *
p, *, *, GET, /api/get-product, *, *
p, *, *, GET, /api/get-order, *, *
p, *, *, GET, /api/get-orders, *, *
p, *, *, GET, /api/get-user-orders, *, *
p, *, *, GET, /api/get-payment, *, *
p, *, *, POST, /api/update-payment, *, *
p, *, *, POST, /api/invoice-payment, *, *
@@ -132,7 +135,15 @@ p, *, *, GET, /api/faceid-signin-begin, *, *
}
}
func IsAllowed(subOwner string, subName string, method string, urlPath string, objOwner string, objName string) bool {
func IsAllowed(subOwner string, subName string, method string, urlPath string, objOwner string, objName string, extraInfo map[string]interface{}) bool {
if urlPath == "/api/mcp" {
if detailPath, ok := extraInfo["detailPathUrl"].(string); ok {
if detailPath == "initialize" || detailPath == "notifications/initialized" || detailPath == "ping" || detailPath == "tools/list" {
return true
}
}
}
if conf.IsDemoMode() {
if !isAllowedInDemoMode(subOwner, subName, method, urlPath, objOwner, objName) {
return false

View File

@@ -467,15 +467,17 @@ func (c *ApiController) SsoLogout() {
var tokens []*object.Token
var sessionIds []string
// Get tokens for notification (needed for both session-level and full logout)
// This enables subsystems to identify and invalidate corresponding access tokens
// Note: Tokens must be retrieved BEFORE expiration to include their hashes in the notification
tokens, err = object.GetTokensByUser(owner, username)
if err != nil {
c.ResponseError(err.Error())
return
}
if logoutAllSessions {
// Logout from all sessions: expire all tokens and delete all sessions
// Get tokens before expiring them (for session-level logout notification)
tokens, err = object.GetTokensByUser(owner, username)
if err != nil {
c.ResponseError(err.Error())
return
}
_, err = object.ExpireTokenByUser(owner, username)
if err != nil {
c.ResponseError(err.Error())

View File

@@ -105,6 +105,13 @@ func (c *ApiController) getCurrentUser() *object.User {
// GetSessionUsername ...
func (c *ApiController) GetSessionUsername() string {
// prefer username stored in Beego context by ApiFilter
if ctxUser := c.Ctx.Input.GetData("currentUserId"); ctxUser != nil {
if username, ok := ctxUser.(string); ok {
return username
}
}
// check if user session expired
sessionData := c.GetSessionData()

View File

@@ -39,7 +39,26 @@ func (c *ApiController) GetOrders() {
sortOrder := c.Ctx.Input.Query("sortOrder")
if limit == "" || page == "" {
orders, err := object.GetOrders(owner)
var orders []*object.Order
var err error
if c.IsAdmin() {
// If field is "user", filter by that user even for admins
if field == "user" && value != "" {
orders, err = object.GetUserOrders(owner, value)
} else {
orders, err = object.GetOrders(owner)
}
} else {
user := c.GetSessionUsername()
_, userName, userErr := util.GetOwnerAndNameFromIdWithError(user)
if userErr != nil {
c.ResponseError(userErr.Error())
return
}
orders, err = object.GetUserOrders(owner, userName)
}
if err != nil {
c.ResponseError(err.Error())
return
@@ -48,6 +67,16 @@ func (c *ApiController) GetOrders() {
c.ResponseOk(orders)
} else {
limit := util.ParseInt(limit)
if !c.IsAdmin() {
user := c.GetSessionUsername()
_, userName, userErr := util.GetOwnerAndNameFromIdWithError(user)
if userErr != nil {
c.ResponseError(userErr.Error())
return
}
field = "user"
value = userName
}
count, err := object.GetOrderCount(owner, field, value)
if err != nil {
c.ResponseError(err.Error())

View File

@@ -39,7 +39,26 @@ func (c *ApiController) GetPayments() {
sortOrder := c.Ctx.Input.Query("sortOrder")
if limit == "" || page == "" {
payments, err := object.GetPayments(owner)
var payments []*object.Payment
var err error
if c.IsAdmin() {
// If field is "user", filter by that user even for admins
if field == "user" && value != "" {
payments, err = object.GetUserPayments(owner, value)
} else {
payments, err = object.GetPayments(owner)
}
} else {
user := c.GetSessionUsername()
_, userName, userErr := util.GetOwnerAndNameFromIdWithError(user)
if userErr != nil {
c.ResponseError(userErr.Error())
return
}
payments, err = object.GetUserPayments(owner, userName)
}
if err != nil {
c.ResponseError(err.Error())
return
@@ -48,6 +67,16 @@ func (c *ApiController) GetPayments() {
c.ResponseOk(payments)
} else {
limit := util.ParseInt(limit)
if !c.IsAdmin() {
user := c.GetSessionUsername()
_, userName, userErr := util.GetOwnerAndNameFromIdWithError(user)
if userErr != nil {
c.ResponseError(userErr.Error())
return
}
field = "user"
value = userName
}
count, err := object.GetPaymentCount(owner, field, value)
if err != nil {
c.ResponseError(err.Error())

View File

@@ -17,6 +17,7 @@ package controllers
import (
"fmt"
"net/http"
"net/url"
"github.com/casdoor/casdoor/object"
)
@@ -62,6 +63,7 @@ func (c *ApiController) HandleSamlRedirect() {
username := c.Ctx.Input.Query("username")
loginHint := c.Ctx.Input.Query("login_hint")
relayState = url.QueryEscape(relayState)
targetURL := object.GetSamlRedirectAddress(owner, application, relayState, samlRequest, host, username, loginHint)
c.Redirect(targetURL, http.StatusSeeOther)

View File

@@ -39,7 +39,26 @@ func (c *ApiController) GetSubscriptions() {
sortOrder := c.Ctx.Input.Query("sortOrder")
if limit == "" || page == "" {
subscriptions, err := object.GetSubscriptions(owner)
var subscriptions []*object.Subscription
var err error
if c.IsAdmin() {
// If field is "user", filter by that user even for admins
if field == "user" && value != "" {
subscriptions, err = object.GetSubscriptionsByUser(owner, value)
} else {
subscriptions, err = object.GetSubscriptions(owner)
}
} else {
user := c.GetSessionUsername()
_, userName, userErr := util.GetOwnerAndNameFromIdWithError(user)
if userErr != nil {
c.ResponseError(userErr.Error())
return
}
subscriptions, err = object.GetSubscriptionsByUser(owner, userName)
}
if err != nil {
c.ResponseError(err.Error())
return
@@ -48,6 +67,16 @@ func (c *ApiController) GetSubscriptions() {
c.ResponseOk(subscriptions)
} else {
limit := util.ParseInt(limit)
if !c.IsAdmin() {
user := c.GetSessionUsername()
_, userName, userErr := util.GetOwnerAndNameFromIdWithError(user)
if userErr != nil {
c.ResponseError(userErr.Error())
return
}
field = "user"
value = userName
}
count, err := object.GetSubscriptionCount(owner, field, value)
if err != nil {
c.ResponseError(err.Error())

View File

@@ -778,6 +778,78 @@ func (c *ApiController) RemoveUserFromGroup() {
c.ResponseOk(affected)
}
// ImpersonateUser
// @Title ImpersonateUser
// @Tag User API
// @Description set impersonation user for current admin session
// @Param username formData string true "The username to impersonate (owner/name)"
// @Success 200 {object} controllers.Response The Response object
// @router /impersonation-user [post]
func (c *ApiController) ImpersonateUser() {
org, ok := c.RequireAdmin()
if !ok {
return
}
username := c.Ctx.Request.Form.Get("username")
if username == "" {
c.ResponseError(c.T("general:Missing parameter"))
return
}
owner, _, err := util.GetOwnerAndNameFromIdWithError(username)
if err != nil {
c.ResponseError(err.Error())
return
}
if !(owner == org || org == "") {
c.ResponseError(c.T("auth:Unauthorized operation"))
return
}
targetUser, err := object.GetUser(username)
if err != nil {
c.ResponseError(err.Error())
return
}
if targetUser == nil {
c.ResponseError(fmt.Sprintf(c.T("general:The user: %s doesn't exist"), username))
return
}
err = c.SetSession("impersonateUser", username)
if err != nil {
c.ResponseError(err.Error())
return
}
c.Ctx.SetCookie("impersonateUser", username, 0, "/")
c.ResponseOk()
}
// ExitImpersonateUser
// @Title ExitImpersonateUser
// @Tag User API
// @Description clear impersonation info for current session
// @Success 200 {object} controllers.Response The Response object
// @router /exit-impersonation-user [post]
func (c *ApiController) ExitImpersonateUser() {
_, ok := c.Ctx.Input.GetData("impersonating").(bool)
if !ok {
c.ResponseError(c.T("auth:Unauthorized operation"))
return
}
err := c.SetSession("impersonateUser", "")
if err != nil {
c.ResponseError(err.Error())
return
}
c.Ctx.SetCookie("impersonateUser", "", -1, "/")
c.ResponseOk()
}
// VerifyIdentification
// @Title VerifyIdentification
// @Tag User API

17
go.mod
View File

@@ -14,6 +14,8 @@ require (
github.com/alibabacloud-go/openapi-util v0.1.0
github.com/alibabacloud-go/tea v1.3.2
github.com/alibabacloud-go/tea-utils/v2 v2.0.7
github.com/aliyun/aliyun-oss-go-sdk v2.2.2+incompatible
github.com/aliyun/credentials-go v1.3.10
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0
github.com/beego/beego/v2 v2.3.8
github.com/beevik/etree v1.1.0
@@ -57,7 +59,7 @@ require (
github.com/russellhaering/gosaml2 v0.9.0
github.com/russellhaering/goxmldsig v1.2.0
github.com/sendgrid/sendgrid-go v3.16.0+incompatible
github.com/shirou/gopsutil v3.21.11+incompatible
github.com/shirou/gopsutil/v4 v4.25.9
github.com/siddontang/go-log v0.0.0-20190221022429-1e957dd83bed
github.com/stretchr/testify v1.11.1
github.com/stripe/stripe-go/v74 v74.29.0
@@ -110,8 +112,6 @@ require (
github.com/alibabacloud-go/tea-utils v1.3.6 // indirect
github.com/alibabacloud-go/tea-xml v1.1.3 // indirect
github.com/aliyun/alibaba-cloud-sdk-go v1.62.545 // indirect
github.com/aliyun/aliyun-oss-go-sdk v2.2.2+incompatible // indirect
github.com/aliyun/credentials-go v1.3.10 // indirect
github.com/apistd/uni-go-sdk v0.0.2 // indirect
github.com/atc0005/go-teams-notify/v2 v2.13.0 // indirect
github.com/aws/aws-sdk-go v1.45.5 // indirect
@@ -139,6 +139,7 @@ require (
github.com/di-wu/parser v0.2.2 // indirect
github.com/di-wu/xsd-datetime v1.0.0 // indirect
github.com/drswork/go-twitter v0.0.0-20221107160839-dea1b6ed53d7 // indirect
github.com/ebitengine/purego v0.9.0 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/envoyproxy/go-control-plane v0.13.1 // indirect
github.com/envoyproxy/protoc-gen-validate v1.1.0 // indirect
@@ -187,6 +188,7 @@ require (
github.com/lestrrat-go/iter v1.0.2 // indirect
github.com/lestrrat-go/option v1.0.1 // indirect
github.com/line/line-bot-sdk-go v7.8.0+incompatible // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/markbates/going v1.0.0 // indirect
github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
@@ -204,6 +206,7 @@ require (
github.com/pkg/errors v0.9.1 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/prometheus/common v0.48.0 // indirect
github.com/prometheus/procfs v0.12.0 // indirect
github.com/qiniu/go-sdk/v7 v7.12.1 // indirect
@@ -230,15 +233,15 @@ require (
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/tjfoc/gmsm v1.4.1 // indirect
github.com/tklauser/go-sysconf v0.3.10 // indirect
github.com/tklauser/numcpus v0.4.0 // indirect
github.com/tklauser/go-sysconf v0.3.15 // indirect
github.com/tklauser/numcpus v0.10.0 // indirect
github.com/twilio/twilio-go v1.13.0 // indirect
github.com/ucloud/ucloud-sdk-go v0.22.5 // indirect
github.com/utahta/go-linenotify v0.5.0 // indirect
github.com/volcengine/volc-sdk-golang v1.0.117 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/yusufpapurcu/wmi v1.2.2 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.mau.fi/util v0.8.3 // indirect
go.opencensus.io v0.24.0 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.32.0 // indirect
@@ -256,7 +259,7 @@ require (
golang.org/x/image v0.0.0-20220302094943-723b81ca9867 // indirect
golang.org/x/mod v0.25.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.34.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/time v0.8.0 // indirect
golang.org/x/tools v0.34.0 // indirect
google.golang.org/genproto v0.0.0-20241118233622-e639e219e697 // indirect

27
go.sum
View File

@@ -938,6 +938,8 @@ github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5m
github.com/eapache/go-resiliency v1.2.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs=
github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU=
github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=
github.com/ebitengine/purego v0.9.0 h1:mh0zpKBIXDceC63hpvPuGLiJ8ZAa3DfrFTudmfi8A4k=
github.com/ebitengine/purego v0.9.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M=
github.com/elazarl/go-bindata-assetfs v1.0.1 h1:m0kkaHRKEu7tUIUFVwhGGGYClXvyl4RE03qmvRTNfbw=
github.com/elazarl/go-bindata-assetfs v1.0.1/go.mod h1:v+YaWX3bdea5J/mo8dSETolEo7R71Vk1u8bnjau5yw4=
@@ -1380,6 +1382,8 @@ github.com/localtunnel/go-localtunnel v0.0.0-20170326223115-8a804488f275 h1:IZyc
github.com/localtunnel/go-localtunnel v0.0.0-20170326223115-8a804488f275/go.mod h1:zt6UU74K6Z6oMOYJbJzYpYucqdcQwSMPBEdSvGiaUMw=
github.com/lor00x/goldap v0.0.0-20180618054307-a546dffdd1a3 h1:wIONC+HMNRqmWBjuMxhatuSzHaljStc4gjDeKycxy0A=
github.com/lor00x/goldap v0.0.0-20180618054307-a546dffdd1a3/go.mod h1:37YR9jabpiIxsb8X9VCIx8qFOjTDIIrIHHODa8C4gz0=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/lyft/protoc-gen-star v0.6.0/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuzJYeZuUPFPNwA=
github.com/lyft/protoc-gen-star v0.6.1/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuzJYeZuUPFPNwA=
github.com/lyft/protoc-gen-star/v2 v2.0.1/go.mod h1:RcCdONR2ScXaYnQC5tUzxzlpA3WVYF7/opLeUgcQs/o=
@@ -1511,6 +1515,8 @@ github.com/polarsource/polar-go v0.12.0 h1:um+6ftOPUMg2TQq9Kv/6fKGBOAl7dOc2YiDdx
github.com/polarsource/polar-go v0.12.0/go.mod h1:FB11Q4m2n3wIk6l/POOkz0MVOUx1o0Yt4Y97MnQfe0c=
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg=
github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
@@ -1588,8 +1594,8 @@ github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/shiena/ansicolor v0.0.0-20200904210342-c7312218db18 h1:DAYUYH5869yV94zvCES9F51oYtN5oGlwjxJJz7ZCnik=
github.com/shiena/ansicolor v0.0.0-20200904210342-c7312218db18/go.mod h1:nkxAfR/5quYxwPZhyDxgasBMnRtBZd0FCEpawpjMUFg=
github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI=
github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA=
github.com/shirou/gopsutil/v4 v4.25.9 h1:JImNpf6gCVhKgZhtaAHJ0serfFGtlfIlSC08eaKdTrU=
github.com/shirou/gopsutil/v4 v4.25.9/go.mod h1:gxIxoC+7nQRwUl/xNhutXlD8lq+jxTgpIkEf3rADHL8=
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24 h1:pntxY8Ary0t43dCZ5dqY4YTJCObLY1kIXl0uzMv+7DE=
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
@@ -1675,10 +1681,10 @@ github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6
github.com/tjfoc/gmsm v1.3.2/go.mod h1:HaUcFuY0auTiaHB9MHFGCPx5IaLhTUd2atbCFBQXn9w=
github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho=
github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE=
github.com/tklauser/go-sysconf v0.3.10 h1:IJ1AZGZRWbY8T5Vfk04D9WOA5WSejdflXxP03OUqALw=
github.com/tklauser/go-sysconf v0.3.10/go.mod h1:C8XykCvCb+Gn0oNCWPIlcb0RuglQTYaQ2hGm7jmxEFk=
github.com/tklauser/numcpus v0.4.0 h1:E53Dm1HjH1/R2/aoCtXtPgzmElmn51aOkhCFSuZq//o=
github.com/tklauser/numcpus v0.4.0/go.mod h1:1+UI3pD8NW14VMwdgJNJ1ESk2UnwhAnz5hMwiKKqXCQ=
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
github.com/twilio/twilio-go v1.13.0 h1:8uKXSWAgCvO9Kn12iboy3x/Pw7oxPBufs94fTWQGhLk=
github.com/twilio/twilio-go v1.13.0/go.mod h1:tdnfQ5TjbewoAu4lf9bMsGvfuJ/QU9gYuv9yx3TSIXU=
@@ -1714,8 +1720,8 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.2 h1:KBNDSne4vP5mbSWnJbO+51IMOXJB67QiYCSBrubbPRg=
github.com/yusufpapurcu/wmi v1.2.2/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0=
@@ -2085,6 +2091,7 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201126233918-771906719818/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -2155,8 +2162,8 @@ golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=

View File

@@ -183,7 +183,7 @@ func (idp *DingTalkIdProvider) GetUserInfo(token *oauth2.Token) (*UserInfo, erro
return nil, err
}
corpMobile, corpEmail, jobNumber, err := idp.getUserCorpEmail(userId, corpAccessToken)
corpMobile, corpEmail, unionId, err := idp.getUserCorpEmail(userId, corpAccessToken)
if err == nil {
if corpMobile != "" {
userInfo.Phone = corpMobile
@@ -193,8 +193,8 @@ func (idp *DingTalkIdProvider) GetUserInfo(token *oauth2.Token) (*UserInfo, erro
userInfo.Email = corpEmail
}
if jobNumber != "" {
userInfo.Username = jobNumber
if unionId != "" {
userInfo.Username = unionId
}
}
@@ -284,9 +284,9 @@ func (idp *DingTalkIdProvider) getUserCorpEmail(userId string, accessToken strin
var data struct {
ErrMessage string `json:"errmsg"`
Result struct {
Mobile string `json:"mobile"`
Email string `json:"email"`
JobNumber string `json:"job_number"`
Mobile string `json:"mobile"`
Email string `json:"email"`
UnionId string `json:"unionid"`
} `json:"result"`
}
err = json.Unmarshal(respBytes, &data)
@@ -296,5 +296,5 @@ func (idp *DingTalkIdProvider) getUserCorpEmail(userId string, accessToken strin
if data.ErrMessage != "ok" {
return "", "", "", fmt.Errorf(data.ErrMessage)
}
return data.Result.Mobile, data.Result.Email, data.Result.JobNumber, nil
return data.Result.Mobile, data.Result.Email, data.Result.UnionId, nil
}

View File

@@ -20,6 +20,7 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"math"
"net/http"
"sort"
"strconv"
@@ -65,7 +66,7 @@ func (idp *TelegramIdProvider) GetToken(code string) (*oauth2.Token, error) {
}
// Create a token with the user ID as access token
userId, ok := authData["id"].(float64)
userId, ok := telegramAsInt64(authData["id"])
if !ok {
return nil, fmt.Errorf("invalid user id in auth data")
}
@@ -77,7 +78,7 @@ func (idp *TelegramIdProvider) GetToken(code string) (*oauth2.Token, error) {
}
token := &oauth2.Token{
AccessToken: fmt.Sprintf("telegram_%d", int64(userId)),
AccessToken: fmt.Sprintf("telegram_%d", userId),
TokenType: "Bearer",
}
@@ -97,6 +98,7 @@ func (idp *TelegramIdProvider) verifyTelegramAuth(authData map[string]interface{
if !ok {
return fmt.Errorf("hash not found in auth data")
}
hash = strings.TrimSpace(hash)
// Prepare data check string
var dataCheckArr []string
@@ -104,13 +106,14 @@ func (idp *TelegramIdProvider) verifyTelegramAuth(authData map[string]interface{
if key == "hash" {
continue
}
dataCheckArr = append(dataCheckArr, fmt.Sprintf("%s=%v", key, value))
dataCheckArr = append(dataCheckArr, fmt.Sprintf("%s=%s", key, telegramAsString(value)))
}
sort.Strings(dataCheckArr)
dataCheckString := strings.Join(dataCheckArr, "\n")
// Calculate secret key
secretKey := sha256.Sum256([]byte(idp.ClientSecret))
clientSecret := strings.TrimSpace(idp.ClientSecret)
secretKey := sha256.Sum256([]byte(clientSecret))
// Calculate hash
h := hmac.New(sha256.New, secretKey[:])
@@ -139,7 +142,7 @@ func (idp *TelegramIdProvider) GetUserInfo(token *oauth2.Token) (*UserInfo, erro
}
// Extract user information from auth data
userId, ok := authData["id"].(float64)
userId, ok := telegramAsInt64(authData["id"])
if !ok {
return nil, fmt.Errorf("invalid user id in auth data")
}
@@ -155,11 +158,11 @@ func (idp *TelegramIdProvider) GetUserInfo(token *oauth2.Token) (*UserInfo, erro
displayName = username
}
if displayName == "" {
displayName = strconv.FormatInt(int64(userId), 10)
displayName = strconv.FormatInt(userId, 10)
}
userInfo := UserInfo{
Id: strconv.FormatInt(int64(userId), 10),
Id: strconv.FormatInt(userId, 10),
Username: username,
DisplayName: displayName,
AvatarUrl: photoUrl,
@@ -167,3 +170,38 @@ func (idp *TelegramIdProvider) GetUserInfo(token *oauth2.Token) (*UserInfo, erro
return &userInfo, nil
}
func telegramAsInt64(v interface{}) (int64, bool) {
switch t := v.(type) {
case float64:
if t != math.Trunc(t) {
return 0, false
}
if t > float64(math.MaxInt64) || t < float64(math.MinInt64) {
return 0, false
}
return int64(t), true
case string:
i, err := strconv.ParseInt(t, 10, 64)
if err != nil {
return 0, false
}
return i, true
default:
return 0, false
}
}
func telegramAsString(v interface{}) string {
switch t := v.(type) {
case string:
return t
case float64:
if t == math.Trunc(t) && t <= float64(math.MaxInt64) && t >= float64(math.MinInt64) {
return strconv.FormatInt(int64(t), 10)
}
return strconv.FormatFloat(t, 'g', -1, 64)
default:
return fmt.Sprint(v)
}
}

View File

@@ -23,7 +23,7 @@ import (
)
// handleGetApplicationsTool handles the get_applications MCP tool
func (c *McpController) handleGetApplicationsTool(id string, args GetApplicationsArgs) {
func (c *McpController) handleGetApplicationsTool(id interface{}, args GetApplicationsArgs) {
userId := c.GetSessionUsername()
applications, err := object.GetApplications(args.Owner)
@@ -37,7 +37,7 @@ func (c *McpController) handleGetApplicationsTool(id string, args GetApplication
}
// handleGetApplicationTool handles the get_application MCP tool
func (c *McpController) handleGetApplicationTool(id string, args GetApplicationArgs) {
func (c *McpController) handleGetApplicationTool(id interface{}, args GetApplicationArgs) {
userId := c.GetSessionUsername()
application, err := object.GetApplication(args.Id)
@@ -51,7 +51,7 @@ func (c *McpController) handleGetApplicationTool(id string, args GetApplicationA
}
// handleAddApplicationTool handles the add_application MCP tool
func (c *McpController) handleAddApplicationTool(id string, args AddApplicationArgs) {
func (c *McpController) handleAddApplicationTool(id interface{}, args AddApplicationArgs) {
count, err := object.GetApplicationCount("", "", "")
if err != nil {
c.SendToolErrorResult(id, err.Error())
@@ -78,7 +78,7 @@ func (c *McpController) handleAddApplicationTool(id string, args AddApplicationA
}
// handleUpdateApplicationTool handles the update_application MCP tool
func (c *McpController) handleUpdateApplicationTool(id string, args UpdateApplicationArgs) {
func (c *McpController) handleUpdateApplicationTool(id interface{}, args UpdateApplicationArgs) {
if err := object.CheckIpWhitelist(args.Application.IpWhitelist, c.GetAcceptLanguage()); err != nil {
c.SendToolErrorResult(id, err.Error())
return
@@ -94,7 +94,7 @@ func (c *McpController) handleUpdateApplicationTool(id string, args UpdateApplic
}
// handleDeleteApplicationTool handles the delete_application MCP tool
func (c *McpController) handleDeleteApplicationTool(id string, args DeleteApplicationArgs) {
func (c *McpController) handleDeleteApplicationTool(id interface{}, args DeleteApplicationArgs) {
affected, err := object.DeleteApplication(&args.Application)
if err != nil {
c.SendToolErrorResult(id, err.Error())

View File

@@ -26,11 +26,17 @@ type SessionData struct {
ExpireTime int64
}
// GetSessionUsername returns the username from session
// GetSessionUsername returns the username from session or ctx
func (c *McpController) GetSessionUsername() string {
// check if user session expired
sessionData := c.GetSessionData()
// prefer username stored in Beego context by ApiFilter
if ctxUser := c.Ctx.Input.GetData("currentUserId"); ctxUser != nil {
if username, ok := ctxUser.(string); ok {
return username
}
}
// fallback to previous session-based logic with expiry check
sessionData := c.GetSessionData()
if sessionData != nil &&
sessionData.ExpireTime != 0 &&
sessionData.ExpireTime < time.Now().Unix() {
@@ -77,6 +83,7 @@ func (c *McpController) IsGlobalAdmin() bool {
func (c *McpController) isGlobalAdmin() (bool, *object.User) {
username := c.GetSessionUsername()
if object.IsAppUser(username) {
// e.g., "app/app-casnode"
return true, nil

View File

@@ -17,6 +17,7 @@ package mcp
import (
"encoding/json"
"fmt"
"net/http"
"github.com/beego/beego/v2/server/web"
"github.com/casdoor/casdoor/object"
@@ -102,11 +103,11 @@ type DeleteApplicationArgs struct {
}
type McpCallToolResult struct {
Content []McpContent `json:"content"`
IsError bool `json:"isError,omitempty"`
Content []TextContent `json:"content"`
IsError bool `json:"isError,omitempty"`
}
type McpContent struct {
type TextContent struct {
Type string `json:"type"`
Text string `json:"text"`
}
@@ -116,55 +117,61 @@ type McpController struct {
web.Controller
}
// SendMcpResponse sends a successful MCP response
func (c *McpController) SendMcpResponse(id interface{}, result interface{}) {
func (c *McpController) Prepare() {
c.EnableRender = false
}
func (c *McpController) McpResponseOk(id interface{}, result interface{}) {
resp := BuildMcpResponse(id, result, nil)
c.Ctx.Output.Header("Content-Type", "application/json")
c.Data["json"] = resp
c.ServeJSON()
}
func (c *McpController) McpResponseError(id interface{}, code int, message string, data interface{}) {
resp := BuildMcpResponse(id, nil, &McpError{
Code: code,
Message: message,
Data: data,
})
c.Ctx.Output.Header("Content-Type", "application/json")
c.Data["json"] = resp
c.ServeJSON()
}
// GetMcpResponse returns a McpResponse object
func BuildMcpResponse(id interface{}, result interface{}, err *McpError) McpResponse {
resp := McpResponse{
JSONRPC: "2.0",
ID: id,
Result: result,
Error: err,
}
c.Data["json"] = resp
c.ServeJSON()
}
// SendMcpError sends an MCP error response
func (c *McpController) SendMcpError(id interface{}, code int, message string, data interface{}) {
resp := McpResponse{
JSONRPC: "2.0",
ID: id,
Error: &McpError{
Code: code,
Message: message,
Data: data,
},
}
c.Data["json"] = resp
c.ServeJSON()
return resp
}
// sendInvalidParamsError sends an invalid params error
func (c *McpController) sendInvalidParamsError(id interface{}, details string) {
c.SendMcpError(id, -32602, "Invalid params", details)
c.McpResponseError(id, -32602, "Invalid params", details)
}
// SendToolResult sends a successful tool execution result
func (c *McpController) SendToolResult(id interface{}, text string) {
result := McpCallToolResult{
Content: []McpContent{
Content: []TextContent{
{
Type: "text",
Text: text,
},
},
IsError: false,
}
c.SendMcpResponse(id, result)
c.McpResponseOk(id, result)
}
// SendToolErrorResult sends a tool execution error result
func (c *McpController) SendToolErrorResult(id interface{}, errorMsg string) {
result := McpCallToolResult{
Content: []McpContent{
Content: []TextContent{
{
Type: "text",
Text: errorMsg,
@@ -172,7 +179,7 @@ func (c *McpController) SendToolErrorResult(id interface{}, errorMsg string) {
},
IsError: true,
}
c.SendMcpResponse(id, result)
c.McpResponseOk(id, result)
}
// FormatOperationResult formats the result of CRUD operations in a clear, descriptive way
@@ -202,7 +209,7 @@ func (c *McpController) HandleMcp() {
var req McpRequest
err := json.Unmarshal(c.Ctx.Input.RequestBody, &req)
if err != nil {
c.SendMcpError(nil, -32700, "Parse error", err.Error())
c.McpResponseError(nil, -32700, "Parse error", err.Error())
return
}
@@ -210,12 +217,16 @@ func (c *McpController) HandleMcp() {
switch req.Method {
case "initialize":
c.handleInitialize(req)
case "notifications/initialized":
c.handleNotificationsInitialized(req)
case "ping":
c.handlePing(req)
case "tools/list":
c.handleToolsList(req)
case "tools/call":
c.handleToolsCall(req)
default:
c.SendMcpError(req.ID, -32601, "Method not found", fmt.Sprintf("Method '%s' not found", req.Method))
c.McpResponseError(req.ID, -32601, "Method not found", fmt.Sprintf("Method '%s' not found", req.Method))
}
}
@@ -232,7 +243,9 @@ func (c *McpController) handleInitialize(req McpRequest) {
result := McpInitializeResult{
ProtocolVersion: "2024-11-05",
Capabilities: McpServerCapabilities{
Tools: map[string]interface{}{},
Tools: map[string]interface{}{
"listChanged": true,
},
},
ServerInfo: McpImplementation{
Name: "Casdoor MCP Server",
@@ -240,7 +253,18 @@ func (c *McpController) handleInitialize(req McpRequest) {
},
}
c.SendMcpResponse(req.ID, result)
c.McpResponseOk(req.ID, result)
}
func (c *McpController) handleNotificationsInitialized(req McpRequest) {
c.Ctx.Output.SetStatus(http.StatusAccepted)
c.Ctx.Output.Body([]byte{})
}
func (c *McpController) handlePing(req McpRequest) {
// ping method is used to check if the server is alive and responsive
// Return an empty object as result to indicate server is active
c.McpResponseOk(req.ID, map[string]interface{}{})
}
func (c *McpController) handleToolsList(req McpRequest) {
@@ -325,7 +349,7 @@ func (c *McpController) handleToolsList(req McpRequest) {
Tools: tools,
}
c.SendMcpResponse(req.ID, result)
c.McpResponseOk(req.ID, result)
}
func (c *McpController) handleToolsCall(req McpRequest) {
@@ -336,9 +360,6 @@ func (c *McpController) handleToolsCall(req McpRequest) {
return
}
// Convert ID to string for tool handlers
idStr := fmt.Sprintf("%v", req.ID)
// Route to the appropriate tool handler
switch params.Name {
case "get_applications":
@@ -347,36 +368,36 @@ func (c *McpController) handleToolsCall(req McpRequest) {
c.sendInvalidParamsError(req.ID, err.Error())
return
}
c.handleGetApplicationsTool(idStr, args)
c.handleGetApplicationsTool(req.ID, args)
case "get_application":
var args GetApplicationArgs
if err := json.Unmarshal(params.Arguments, &args); err != nil {
c.sendInvalidParamsError(req.ID, err.Error())
return
}
c.handleGetApplicationTool(idStr, args)
c.handleGetApplicationTool(req.ID, args)
case "add_application":
var args AddApplicationArgs
if err := json.Unmarshal(params.Arguments, &args); err != nil {
c.sendInvalidParamsError(req.ID, err.Error())
return
}
c.handleAddApplicationTool(idStr, args)
c.handleAddApplicationTool(req.ID, args)
case "update_application":
var args UpdateApplicationArgs
if err := json.Unmarshal(params.Arguments, &args); err != nil {
c.sendInvalidParamsError(req.ID, err.Error())
return
}
c.handleUpdateApplicationTool(idStr, args)
c.handleUpdateApplicationTool(req.ID, args)
case "delete_application":
var args DeleteApplicationArgs
if err := json.Unmarshal(params.Arguments, &args); err != nil {
c.sendInvalidParamsError(req.ID, err.Error())
return
}
c.handleDeleteApplicationTool(idStr, args)
c.handleDeleteApplicationTool(req.ID, args)
default:
c.SendMcpError(req.ID, -32602, "Invalid tool name", fmt.Sprintf("Tool '%s' not found", params.Name))
c.McpResponseError(req.ID, -32602, "Invalid tool name", fmt.Sprintf("Tool '%s' not found", params.Name))
}
}

View File

@@ -71,45 +71,46 @@ type Application struct {
Name string `xorm:"varchar(100) notnull pk" json:"name"`
CreatedTime string `xorm:"varchar(100)" json:"createdTime"`
DisplayName string `xorm:"varchar(100)" json:"displayName"`
Logo string `xorm:"varchar(200)" json:"logo"`
Title string `xorm:"varchar(100)" json:"title"`
Favicon string `xorm:"varchar(200)" json:"favicon"`
Order int `json:"order"`
HomepageUrl string `xorm:"varchar(100)" json:"homepageUrl"`
Description string `xorm:"varchar(100)" json:"description"`
Organization string `xorm:"varchar(100)" json:"organization"`
Cert string `xorm:"varchar(100)" json:"cert"`
DefaultGroup string `xorm:"varchar(100)" json:"defaultGroup"`
HeaderHtml string `xorm:"mediumtext" json:"headerHtml"`
EnablePassword bool `json:"enablePassword"`
EnableSignUp bool `json:"enableSignUp"`
DisableSignin bool `json:"disableSignin"`
EnableSigninSession bool `json:"enableSigninSession"`
EnableAutoSignin bool `json:"enableAutoSignin"`
EnableCodeSignin bool `json:"enableCodeSignin"`
EnableExclusiveSignin bool `json:"enableExclusiveSignin"`
EnableSamlCompress bool `json:"enableSamlCompress"`
EnableSamlC14n10 bool `json:"enableSamlC14n10"`
EnableSamlPostBinding bool `json:"enableSamlPostBinding"`
DisableSamlAttributes bool `json:"disableSamlAttributes"`
UseEmailAsSamlNameId bool `json:"useEmailAsSamlNameId"`
EnableWebAuthn bool `json:"enableWebAuthn"`
EnableLinkWithEmail bool `json:"enableLinkWithEmail"`
OrgChoiceMode string `json:"orgChoiceMode"`
SamlReplyUrl string `xorm:"varchar(500)" json:"samlReplyUrl"`
Providers []*ProviderItem `xorm:"mediumtext" json:"providers"`
SigninMethods []*SigninMethod `xorm:"varchar(2000)" json:"signinMethods"`
SignupItems []*SignupItem `xorm:"varchar(3000)" json:"signupItems"`
SigninItems []*SigninItem `xorm:"mediumtext" json:"signinItems"`
GrantTypes []string `xorm:"varchar(1000)" json:"grantTypes"`
OrganizationObj *Organization `xorm:"-" json:"organizationObj"`
CertPublicKey string `xorm:"-" json:"certPublicKey"`
Tags []string `xorm:"mediumtext" json:"tags"`
SamlAttributes []*SamlItem `xorm:"varchar(1000)" json:"samlAttributes"`
SamlHashAlgorithm string `xorm:"varchar(20)" json:"samlHashAlgorithm"`
IsShared bool `json:"isShared"`
IpRestriction string `json:"ipRestriction"`
DisplayName string `xorm:"varchar(100)" json:"displayName"`
Logo string `xorm:"varchar(200)" json:"logo"`
Title string `xorm:"varchar(100)" json:"title"`
Favicon string `xorm:"varchar(200)" json:"favicon"`
Order int `json:"order"`
HomepageUrl string `xorm:"varchar(100)" json:"homepageUrl"`
Description string `xorm:"varchar(100)" json:"description"`
Organization string `xorm:"varchar(100)" json:"organization"`
Cert string `xorm:"varchar(100)" json:"cert"`
DefaultGroup string `xorm:"varchar(100)" json:"defaultGroup"`
HeaderHtml string `xorm:"mediumtext" json:"headerHtml"`
EnablePassword bool `json:"enablePassword"`
EnableSignUp bool `json:"enableSignUp"`
DisableSignin bool `json:"disableSignin"`
EnableSigninSession bool `json:"enableSigninSession"`
EnableAutoSignin bool `json:"enableAutoSignin"`
EnableCodeSignin bool `json:"enableCodeSignin"`
EnableExclusiveSignin bool `json:"enableExclusiveSignin"`
EnableSamlCompress bool `json:"enableSamlCompress"`
EnableSamlC14n10 bool `json:"enableSamlC14n10"`
EnableSamlPostBinding bool `json:"enableSamlPostBinding"`
DisableSamlAttributes bool `json:"disableSamlAttributes"`
EnableSamlAssertionSignature bool `json:"enableSamlAssertionSignature"`
UseEmailAsSamlNameId bool `json:"useEmailAsSamlNameId"`
EnableWebAuthn bool `json:"enableWebAuthn"`
EnableLinkWithEmail bool `json:"enableLinkWithEmail"`
OrgChoiceMode string `json:"orgChoiceMode"`
SamlReplyUrl string `xorm:"varchar(500)" json:"samlReplyUrl"`
Providers []*ProviderItem `xorm:"mediumtext" json:"providers"`
SigninMethods []*SigninMethod `xorm:"varchar(2000)" json:"signinMethods"`
SignupItems []*SignupItem `xorm:"varchar(3000)" json:"signupItems"`
SigninItems []*SigninItem `xorm:"mediumtext" json:"signinItems"`
GrantTypes []string `xorm:"varchar(1000)" json:"grantTypes"`
OrganizationObj *Organization `xorm:"-" json:"organizationObj"`
CertPublicKey string `xorm:"-" json:"certPublicKey"`
Tags []string `xorm:"mediumtext" json:"tags"`
SamlAttributes []*SamlItem `xorm:"varchar(1000)" json:"samlAttributes"`
SamlHashAlgorithm string `xorm:"varchar(20)" json:"samlHashAlgorithm"`
IsShared bool `json:"isShared"`
IpRestriction string `json:"ipRestriction"`
ClientId string `xorm:"varchar(100)" json:"clientId"`
ClientSecret string `xorm:"varchar(100)" json:"clientSecret"`

View File

@@ -16,6 +16,7 @@ package object
import (
"fmt"
"slices"
"github.com/casdoor/casdoor/util"
"github.com/xorm-io/core"
@@ -28,8 +29,8 @@ type Order struct {
DisplayName string `xorm:"varchar(100)" json:"displayName"`
// Product Info
ProductName string `xorm:"varchar(100)" json:"productName"`
Products []string `xorm:"varchar(1000)" json:"products"` // Future support for multiple products per order. Using varchar(1000) for simple JSON array storage; can be refactored to separate table if needed
Products []string `xorm:"varchar(1000)" json:"products"` // Support for multiple products per order. Using varchar(1000) for simple JSON array storage; can be refactored to separate table if needed
ProductInfos []ProductInfo `xorm:"varchar(2000)" json:"productInfos"`
// Subscription Info (for subscription orders)
PricingName string `xorm:"varchar(100)" json:"pricingName"`
@@ -52,6 +53,15 @@ type Order struct {
EndTime string `xorm:"varchar(100)" json:"endTime"`
}
type ProductInfo struct {
Name string `json:"name"`
DisplayName string `json:"displayName"`
Image string `json:"image"`
Detail string `json:"detail"`
Price float64 `json:"price"`
IsRecharge bool `json:"isRecharge"`
}
func GetOrderCount(owner, field, value string) (int64, error) {
session := GetSession(owner, -1, -1, field, value, "", "")
return session.Count(&Order{Owner: owner})
@@ -119,12 +129,46 @@ func UpdateOrder(id string, order *Order) (bool, error) {
if err != nil {
return false, err
}
if o, err := getOrder(owner, name); err != nil {
var o *Order
if o, err = getOrder(owner, name); err != nil {
return false, err
} else if o == nil {
return false, nil
}
if !slices.Equal(o.Products, order.Products) {
existingInfos := make(map[string]ProductInfo, len(o.ProductInfos))
for _, info := range o.ProductInfos {
existingInfos[info.Name] = info
}
productInfos := make([]ProductInfo, 0, len(order.Products))
products, err := getOrderProducts(owner, order.Products)
if err != nil {
return false, err
}
price := 0.0
for _, product := range products {
productInfo := ProductInfo{
Name: product.Name,
DisplayName: product.DisplayName,
Image: product.Image,
Detail: product.Detail,
Price: product.Price,
IsRecharge: product.IsRecharge,
}
if existingInfo, ok := existingInfos[product.Name]; ok {
// Keep historical product info; do not overwrite with current product.
productInfo = existingInfo
}
price += productInfo.Price
productInfos = append(productInfos, productInfo)
}
order.ProductInfos = productInfos
order.Price = price
}
affected, err := ormer.Engine.ID(core.PK{owner, name}).AllCols().Update(order)
if err != nil {
return false, err

View File

@@ -16,6 +16,7 @@ package object
import (
"fmt"
"strings"
"github.com/casdoor/casdoor/idp"
"github.com/casdoor/casdoor/pp"
@@ -35,16 +36,6 @@ func PlaceOrder(productId string, user *User, pricingName string, planName strin
return nil, fmt.Errorf("the product: %s is out of stock", product.Name)
}
userBalanceCurrency := user.BalanceCurrency
if userBalanceCurrency == "" {
org, err := getOrganization("admin", user.Owner)
if err == nil && org != nil && org.BalanceCurrency != "" {
userBalanceCurrency = org.BalanceCurrency
} else {
userBalanceCurrency = "USD"
}
}
productCurrency := product.Currency
if productCurrency == "" {
productCurrency = "USD"
@@ -59,26 +50,37 @@ func PlaceOrder(productId string, user *User, pricingName string, planName strin
} else {
productPrice = product.Price
}
price := ConvertCurrency(productPrice, productCurrency, userBalanceCurrency)
orderName := fmt.Sprintf("order_%v", util.GenerateTimeId())
productNames := []string{product.Name}
productInfos := []ProductInfo{
{
Name: product.Name,
DisplayName: product.DisplayName,
Image: product.Image,
Detail: product.Detail,
Price: productPrice,
IsRecharge: product.IsRecharge,
},
}
order := &Order{
Owner: product.Owner,
Name: orderName,
CreatedTime: util.GetCurrentTime(),
DisplayName: fmt.Sprintf("Order for %s", product.DisplayName),
ProductName: product.Name,
Products: []string{product.Name},
PricingName: pricingName,
PlanName: planName,
User: user.Name,
Payment: "", // Payment will be set when user pays
Price: price,
Currency: userBalanceCurrency,
State: "Created",
Message: "",
StartTime: util.GetCurrentTime(),
EndTime: "",
Owner: product.Owner,
Name: orderName,
DisplayName: orderName,
CreatedTime: util.GetCurrentTime(),
Products: productNames,
ProductInfos: productInfos,
PricingName: pricingName,
PlanName: planName,
User: user.Name,
Payment: "", // Payment will be set when user pays
Price: productPrice,
Currency: productCurrency,
State: "Created",
Message: "",
StartTime: util.GetCurrentTime(),
EndTime: "",
}
affected, err := AddOrder(order)
@@ -96,18 +98,18 @@ func PayOrder(providerName, host, paymentEnv string, order *Order, lang string)
if order.State != "Created" {
return nil, nil, fmt.Errorf("cannot pay for order: %s, current state is %s", order.GetId(), order.State)
}
productId := util.GetId(order.Owner, order.ProductName)
product, err := GetProduct(productId)
productNames := order.Products
products, err := getOrderProducts(order.Owner, productNames)
if err != nil {
return nil, nil, err
}
if product == nil {
return nil, nil, fmt.Errorf("the product: %s does not exist", productId)
if len(products) == 0 {
return nil, nil, fmt.Errorf("order has no products")
}
if !product.IsRecharge && product.Quantity <= 0 {
return nil, nil, fmt.Errorf("the product: %s is out of stock", product.Name)
for _, product := range products {
if !product.IsRecharge && product.Quantity <= 0 {
return nil, nil, fmt.Errorf("the product: %s is out of stock", product.Name)
}
}
user, err := GetUser(util.GetId(order.Owner, order.User))
@@ -118,7 +120,9 @@ func PayOrder(providerName, host, paymentEnv string, order *Order, lang string)
return nil, nil, fmt.Errorf("the user: %s does not exist", order.User)
}
provider, err := product.getProvider(providerName)
// For multi-product orders, the payment provider is determined by the first product
baseProduct := products[0]
provider, err := baseProduct.getProvider(providerName)
if err != nil {
return nil, nil, err
}
@@ -128,7 +132,7 @@ func PayOrder(providerName, host, paymentEnv string, order *Order, lang string)
return nil, nil, err
}
owner := product.Owner
owner := baseProduct.Owner
payerName := fmt.Sprintf("%s | %s", user.Name, user.DisplayName)
paymentName := fmt.Sprintf("payment_%v", util.GenerateTimeId())
@@ -163,20 +167,30 @@ func PayOrder(providerName, host, paymentEnv string, order *Order, lang string)
returnUrl = fmt.Sprintf("%s/buy-plan/%s/%s/result?subscription=%s", originFrontend, owner, order.PricingName, sub.Name)
}
if product.SuccessUrl != "" {
returnUrl = fmt.Sprintf("%s?transactionOwner=%s&transactionName=%s", product.SuccessUrl, owner, paymentName)
if baseProduct.SuccessUrl != "" {
returnUrl = fmt.Sprintf("%s?transactionOwner=%s&transactionName=%s", baseProduct.SuccessUrl, owner, paymentName)
}
displayNames := make([]string, len(products))
descriptions := make([]string, len(products))
for i, p := range products {
displayNames[i] = p.DisplayName
descriptions[i] = p.Description
}
reqProductName := strings.Join(productNames, ", ")
reqProductDisplayName := strings.Join(displayNames, ", ")
reqProductDescription := strings.Join(descriptions, ", ")
payReq := &pp.PayReq{
ProviderName: providerName,
ProductName: product.Name,
ProductName: reqProductName,
PayerName: payerName,
PayerId: user.Id,
PayerEmail: user.Email,
PaymentName: paymentName,
ProductDisplayName: product.DisplayName,
ProductDescription: product.Description,
ProductImage: product.Image,
ProductDisplayName: reqProductDisplayName,
ProductDescription: reqProductDescription,
ProductImage: baseProduct.Image,
Price: order.Price,
Currency: order.Currency,
ReturnUrl: returnUrl,
@@ -199,7 +213,7 @@ func PayOrder(providerName, host, paymentEnv string, order *Order, lang string)
}
payment = &Payment{
Owner: product.Owner,
Owner: baseProduct.Owner,
Name: paymentName,
CreatedTime: util.GetCurrentTime(),
DisplayName: paymentName,
@@ -207,12 +221,11 @@ func PayOrder(providerName, host, paymentEnv string, order *Order, lang string)
Provider: provider.Name,
Type: provider.Type,
ProductName: product.Name,
ProductDisplayName: product.DisplayName,
Detail: product.Detail,
Currency: order.Currency,
Price: order.Price,
IsRecharge: product.IsRecharge,
Products: productNames,
ProductsDisplayName: reqProductDisplayName,
Detail: reqProductDescription,
Currency: order.Currency,
Price: order.Price,
User: user.Name,
Order: order.Name,
@@ -260,12 +273,21 @@ func PayOrder(providerName, host, paymentEnv string, order *Order, lang string)
return nil, nil, fmt.Errorf("failed to add transaction: %s", util.StructToJson(transaction))
}
if product.IsRecharge {
hasRecharge := false
rechargeAmount := 0.0
for _, productInfo := range order.ProductInfos {
if productInfo.IsRecharge {
hasRecharge = true
rechargeAmount += productInfo.Price
}
}
if hasRecharge {
rechargeTransaction := &Transaction{
Owner: payment.Owner,
CreatedTime: util.GetCurrentTime(),
Application: user.SignupApplication,
Amount: payment.Price,
Amount: rechargeAmount,
Currency: order.Currency,
Payment: payment.Name,
Category: TransactionCategoryRecharge,
@@ -302,7 +324,7 @@ func PayOrder(providerName, host, paymentEnv string, order *Order, lang string)
// Update product stock after order state is persisted (for instant payment methods)
if provider.Type == "Dummy" || provider.Type == "Balance" {
err = UpdateProductStock(product)
err = UpdateProductStock(products)
if err != nil {
return nil, nil, err
}

View File

@@ -18,7 +18,6 @@ import (
"fmt"
"github.com/casdoor/casdoor/pp"
"github.com/casdoor/casdoor/util"
"github.com/xorm-io/core"
)
@@ -32,12 +31,11 @@ type Payment struct {
Provider string `xorm:"varchar(100)" json:"provider"`
Type string `xorm:"varchar(100)" json:"type"`
// Product Info
ProductName string `xorm:"varchar(100)" json:"productName"`
ProductDisplayName string `xorm:"varchar(100)" json:"productDisplayName"`
Detail string `xorm:"varchar(255)" json:"detail"`
Currency string `xorm:"varchar(100)" json:"currency"`
Price float64 `json:"price"`
IsRecharge bool `xorm:"bool" json:"isRecharge"`
Products []string `xorm:"varchar(1000)" json:"products"`
ProductsDisplayName string `xorm:"varchar(1000)" json:"productsDisplayName"`
Detail string `xorm:"varchar(255)" json:"detail"`
Currency string `xorm:"varchar(100)" json:"currency"`
Price float64 `json:"price"`
// Payer Info
User string `xorm:"varchar(100)" json:"user"`
@@ -178,14 +176,11 @@ func notifyPayment(body []byte, owner string, paymentName string) (*Payment, *pp
return nil, nil, err
}
product, err := getProduct(owner, payment.ProductName)
// Check if the order products exist
_, err = getOrderProducts(owner, payment.Products)
if err != nil {
return nil, nil, err
}
if product == nil {
err = fmt.Errorf("the product: %s does not exist", payment.ProductName)
return nil, nil, err
}
notifyResult, err := pProvider.Notify(body, payment.OutOrderId)
if err != nil {
@@ -195,13 +190,13 @@ func notifyPayment(body []byte, owner string, paymentName string) (*Payment, *pp
return payment, notifyResult, nil
}
// Only check paid payment
if notifyResult.ProductDisplayName != "" && notifyResult.ProductDisplayName != product.DisplayName {
err = fmt.Errorf("the payment's product name: %s doesn't equal to the expected product name: %s", notifyResult.ProductDisplayName, product.DisplayName)
if notifyResult.ProductDisplayName != "" && notifyResult.ProductDisplayName != payment.ProductsDisplayName {
err = fmt.Errorf("the payment's product name: %s doesn't equal to the expected product name: %s", notifyResult.ProductDisplayName, payment.ProductsDisplayName)
return payment, nil, err
}
if notifyResult.Price != product.Price && !product.IsRecharge {
err = fmt.Errorf("the payment's price: %f doesn't equal to the expected price: %f", notifyResult.Price, product.Price)
if notifyResult.Price != payment.Price {
err = fmt.Errorf("the payment's price: %f doesn't equal to the expected price: %f", notifyResult.Price, payment.Price)
return payment, nil, err
}
@@ -270,12 +265,17 @@ func NotifyPayment(body []byte, owner string, paymentName string, lang string) (
return nil, fmt.Errorf("the provider: %s does not exist", payment.Provider)
}
product, err := getProduct(payment.Owner, payment.ProductName)
products, err := getOrderProducts(payment.Owner, order.Products)
if err != nil {
return nil, err
}
if product == nil {
return nil, fmt.Errorf("the product: %s does not exist", payment.ProductName)
if len(products) == 0 {
return nil, fmt.Errorf("order has no products")
}
for _, product := range products {
if !product.IsRecharge && product.Quantity <= 0 {
return nil, fmt.Errorf("the product: %s is out of stock", product.Name)
}
}
user, err := getUser(payment.Owner, payment.User)
@@ -311,7 +311,16 @@ func NotifyPayment(body []byte, owner string, paymentName string, lang string) (
return nil, fmt.Errorf("failed to add transaction: %s", util.StructToJson(transaction))
}
if product.IsRecharge {
hasRecharge := false
rechargeAmount := 0.0
for _, productInfo := range order.ProductInfos {
if productInfo.IsRecharge {
hasRecharge = true
rechargeAmount += productInfo.Price
}
}
if hasRecharge {
rechargeTransaction := &Transaction{
Owner: payment.Owner,
CreatedTime: util.GetCurrentTime(),
@@ -337,7 +346,7 @@ func NotifyPayment(body []byte, owner string, paymentName string, lang string) (
}
}
err = UpdateProductStock(product)
err = UpdateProductStock(products)
if err != nil {
return nil, err
}

View File

@@ -98,31 +98,33 @@ func GetProduct(id string) (*Product, error) {
return getProduct(owner, name)
}
func UpdateProductStock(product *Product) error {
func UpdateProductStock(products []Product) error {
var (
affected int64
err error
)
if product.IsRecharge {
affected, err = ormer.Engine.ID(core.PK{product.Owner, product.Name}).
Incr("sold", 1).
Update(&Product{})
} else {
affected, err = ormer.Engine.ID(core.PK{product.Owner, product.Name}).
Where("quantity > 0").
Decr("quantity", 1).
Incr("sold", 1).
Update(&Product{})
}
if err != nil {
return err
}
if affected == 0 {
for _, product := range products {
if product.IsRecharge {
return fmt.Errorf("failed to update stock for product: %s", product.Name)
affected, err = ormer.Engine.ID(core.PK{product.Owner, product.Name}).
Incr("sold", 1).
Update(&Product{})
} else {
affected, err = ormer.Engine.ID(core.PK{product.Owner, product.Name}).
Where("quantity > 0").
Decr("quantity", 1).
Incr("sold", 1).
Update(&Product{})
}
if err != nil {
return err
}
if affected == 0 {
if product.IsRecharge {
return fmt.Errorf("failed to update stock for product: %s", product.Name)
}
return fmt.Errorf("insufficient stock for product: %s", product.Name)
}
return fmt.Errorf("insufficient stock for product: %s", product.Name)
}
return nil
}
@@ -137,6 +139,12 @@ func UpdateProduct(id string, product *Product) (bool, error) {
} else if p == nil {
return false, nil
}
err = checkProduct(product)
if err != nil {
return false, err
}
affected, err := ormer.Engine.ID(core.PK{owner, name}).AllCols().Update(product)
if err != nil {
return false, err
@@ -146,6 +154,11 @@ func UpdateProduct(id string, product *Product) (bool, error) {
}
func AddProduct(product *Product) (bool, error) {
err := checkProduct(product)
if err != nil {
return false, err
}
affected, err := ormer.Engine.Insert(product)
if err != nil {
return false, err
@@ -154,6 +167,23 @@ func AddProduct(product *Product) (bool, error) {
return affected != 0, nil
}
func checkProduct(product *Product) error {
if product == nil {
return fmt.Errorf("the product not exist")
}
for _, providerName := range product.Providers {
provider, err := getProvider(product.Owner, providerName)
if err != nil {
return err
}
if provider != nil && provider.Type == "Alipay" && product.Currency != "CNY" {
return fmt.Errorf("alipay provider only supports CNY, got: %s", product.Currency)
}
}
return nil
}
func DeleteProduct(product *Product) (bool, error) {
affected, err := ormer.Engine.ID(core.PK{product.Owner, product.Name}).Delete(&Product{})
if err != nil {
@@ -167,13 +197,23 @@ func (product *Product) GetId() string {
return fmt.Sprintf("%s/%s", product.Owner, product.Name)
}
func (product *Product) isValidProvider(provider *Provider) bool {
func (product *Product) isValidProvider(provider *Provider) error {
if provider.Type == "Alipay" && product.Currency != "CNY" {
return fmt.Errorf("alipay provider only supports CNY, got: %s", product.Currency)
}
providerMatched := false
for _, providerName := range product.Providers {
if providerName == provider.Name {
return true
providerMatched = true
break
}
}
return false
if !providerMatched {
return fmt.Errorf("the payment provider: %s is not valid for the product: %s", provider.Name, product.Name)
}
return nil
}
func (product *Product) getProvider(providerName string) (*Provider, error) {
@@ -186,8 +226,8 @@ func (product *Product) getProvider(providerName string) (*Provider, error) {
return nil, fmt.Errorf("the payment provider: %s does not exist", providerName)
}
if !product.isValidProvider(provider) {
return nil, fmt.Errorf("the payment provider: %s is not valid for the product: %s", providerName, product.Name)
if err := product.isValidProvider(provider); err != nil {
return nil, err
}
return provider, nil
@@ -249,3 +289,33 @@ func UpdateProductForPlan(plan *Plan, product *Product) {
product.Currency = plan.Currency
product.Providers = plan.PaymentProviders
}
func getOrderProducts(owner string, productNames []string) ([]Product, error) {
if len(productNames) == 0 {
return []Product{}, nil
}
var products []Product
err := ormer.Engine.
Where("owner = ?", owner).
In("name", productNames).
Find(&products)
if err != nil {
return nil, err
}
productMap := make(map[string]Product, len(products))
for _, product := range products {
productMap[product.Name] = product
}
orderedProducts := make([]Product, 0, len(productNames))
for _, productName := range productNames {
product, ok := productMap[productName]
if !ok {
return nil, fmt.Errorf("the product: %s does not exist", productName)
}
orderedProducts = append(orderedProducts, product)
}
return orderedProducts, nil
}

View File

@@ -312,8 +312,8 @@ func SendWebhooks(record *casvisorsdk.Record) error {
errs := []error{}
webhooks = getFilteredWebhooks(webhooks, record.Organization, record.Action)
record2 := *record
for _, webhook := range webhooks {
record2 := *record
if len(webhook.ObjectFields) != 0 && webhook.ObjectFields[0] != "All" {
record2.Object = filterRecordObject(record.Object, webhook.ObjectFields)

View File

@@ -394,14 +394,17 @@ func GetSamlResponse(application *Application, user *User, samlRequest string, h
// }
// Sign the assertion (SAML 2.0 best practice)
assertion := samlResponse.FindElement("./Assertion")
if assertion != nil {
assertionSig, err := ctx.ConstructSignature(assertion, true)
if err != nil {
return "", "", "", fmt.Errorf("err: Failed to sign SAML assertion, %s", err.Error())
// Only sign if EnableSamlAssertionSignature is true
if application.EnableSamlAssertionSignature {
assertion := samlResponse.FindElement("./Assertion")
if assertion != nil {
assertionSig, err := ctx.ConstructSignature(assertion, true)
if err != nil {
return "", "", "", fmt.Errorf("err: Failed to sign SAML assertion, %s", err.Error())
}
// Insert signature as the second child of assertion (after Issuer)
assertion.InsertChildAt(1, assertionSig)
}
// Insert signature as the second child of assertion (after Issuer)
assertion.InsertChildAt(1, assertionSig)
}
// Sign the response

View File

@@ -18,11 +18,58 @@ import (
"crypto/tls"
"fmt"
"strings"
"unicode/utf8"
"github.com/casdoor/casdoor/util"
goldap "github.com/go-ldap/ldap/v3"
)
// convertGUIDToString converts a binary GUID byte array to a standard UUID string format
// Active Directory GUIDs are 16 bytes in a specific byte order
func convertGUIDToString(guidBytes []byte) string {
if len(guidBytes) != 16 {
return ""
}
// Active Directory GUID format is:
// Data1 (4 bytes, little-endian) - Data2 (2 bytes, little-endian) - Data3 (2 bytes, little-endian) - Data4 (2 bytes, big-endian) - Data5 (6 bytes, big-endian)
// Convert to standard UUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
return fmt.Sprintf("%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x",
guidBytes[3], guidBytes[2], guidBytes[1], guidBytes[0], // Data1 (reverse byte order)
guidBytes[5], guidBytes[4], // Data2 (reverse byte order)
guidBytes[7], guidBytes[6], // Data3 (reverse byte order)
guidBytes[8], guidBytes[9], // Data4 (big-endian)
guidBytes[10], guidBytes[11], guidBytes[12], guidBytes[13], guidBytes[14], guidBytes[15]) // Data5 (big-endian)
}
// sanitizeUTF8 ensures the string contains only valid UTF-8 characters
// Invalid UTF-8 sequences are replaced with the Unicode replacement character
func sanitizeUTF8(s string) string {
if utf8.ValidString(s) {
return s
}
// Build a new string with only valid UTF-8
var builder strings.Builder
builder.Grow(len(s))
for _, r := range s {
if r == utf8.RuneError {
// Skip invalid runes
continue
}
builder.WriteRune(r)
}
return builder.String()
}
// getAttributeValueSafe safely retrieves an LDAP attribute value and ensures it's valid UTF-8
func getAttributeValueSafe(entry *goldap.Entry, attributeName string) string {
value := entry.GetAttributeValue(attributeName)
return sanitizeUTF8(value)
}
// ActiveDirectorySyncerProvider implements SyncerProvider for Active Directory LDAP-based syncers
type ActiveDirectorySyncerProvider struct {
Syncer *Syncer
@@ -197,26 +244,32 @@ func (p *ActiveDirectorySyncerProvider) adEntryToOriginalUser(entry *goldap.Entr
Groups: []string{},
}
// Get basic attributes
sAMAccountName := entry.GetAttributeValue("sAMAccountName")
userPrincipalName := entry.GetAttributeValue("userPrincipalName")
displayName := entry.GetAttributeValue("displayName")
givenName := entry.GetAttributeValue("givenName")
sn := entry.GetAttributeValue("sn")
mail := entry.GetAttributeValue("mail")
telephoneNumber := entry.GetAttributeValue("telephoneNumber")
mobile := entry.GetAttributeValue("mobile")
title := entry.GetAttributeValue("title")
department := entry.GetAttributeValue("department")
company := entry.GetAttributeValue("company")
streetAddress := entry.GetAttributeValue("streetAddress")
city := entry.GetAttributeValue("l")
state := entry.GetAttributeValue("st")
postalCode := entry.GetAttributeValue("postalCode")
country := entry.GetAttributeValue("co")
objectGUID := entry.GetAttributeValue("objectGUID")
whenCreated := entry.GetAttributeValue("whenCreated")
userAccountControlStr := entry.GetAttributeValue("userAccountControl")
// Get basic attributes with UTF-8 sanitization
sAMAccountName := getAttributeValueSafe(entry, "sAMAccountName")
userPrincipalName := getAttributeValueSafe(entry, "userPrincipalName")
displayName := getAttributeValueSafe(entry, "displayName")
givenName := getAttributeValueSafe(entry, "givenName")
sn := getAttributeValueSafe(entry, "sn")
mail := getAttributeValueSafe(entry, "mail")
telephoneNumber := getAttributeValueSafe(entry, "telephoneNumber")
mobile := getAttributeValueSafe(entry, "mobile")
title := getAttributeValueSafe(entry, "title")
department := getAttributeValueSafe(entry, "department")
company := getAttributeValueSafe(entry, "company")
streetAddress := getAttributeValueSafe(entry, "streetAddress")
city := getAttributeValueSafe(entry, "l")
state := getAttributeValueSafe(entry, "st")
postalCode := getAttributeValueSafe(entry, "postalCode")
country := getAttributeValueSafe(entry, "co")
whenCreated := getAttributeValueSafe(entry, "whenCreated")
userAccountControlStr := getAttributeValueSafe(entry, "userAccountControl")
// Handle objectGUID specially - it's a binary attribute
var objectGUID string
objectGUIDBytes := entry.GetRawAttributeValue("objectGUID")
if len(objectGUIDBytes) == 16 {
objectGUID = convertGUIDToString(objectGUIDBytes)
}
// Set user fields
// Use sAMAccountName as the primary username

View File

@@ -323,8 +323,7 @@ func (p *DingtalkSyncerProvider) getDingtalkUsers() ([]*OriginalUser, error) {
for _, deptId := range deptIds {
users, err := p.getDingtalkUsersFromDept(accessToken, deptId)
if err != nil {
// Continue even if one department fails
continue
return nil, err
}
for _, user := range users {

View File

@@ -43,22 +43,24 @@ type UserShort struct {
Owner string `xorm:"varchar(100) notnull pk" json:"owner"`
Name string `xorm:"varchar(100) notnull pk" json:"name"`
Id string `xorm:"varchar(100) index" json:"id"`
DisplayName string `xorm:"varchar(100)" json:"displayName"`
Avatar string `xorm:"varchar(500)" json:"avatar"`
Email string `xorm:"varchar(100) index" json:"email"`
Phone string `xorm:"varchar(100) index" json:"phone"`
Id string `xorm:"varchar(100) index" json:"id"`
DisplayName string `xorm:"varchar(100)" json:"displayName"`
Avatar string `xorm:"varchar(500)" json:"avatar"`
Email string `xorm:"varchar(100) index" json:"email"`
EmailVerified bool `json:"email_verified,omitempty"`
Phone string `xorm:"varchar(100) index" json:"phone"`
}
type UserStandard struct {
Owner string `xorm:"varchar(100) notnull pk" json:"owner"`
Name string `xorm:"varchar(100) notnull pk" json:"preferred_username,omitempty"`
Id string `xorm:"varchar(100) index" json:"id"`
DisplayName string `xorm:"varchar(100)" json:"name,omitempty"`
Avatar string `xorm:"varchar(500)" json:"picture,omitempty"`
Email string `xorm:"varchar(100) index" json:"email,omitempty"`
Phone string `xorm:"varchar(100) index" json:"phone,omitempty"`
Id string `xorm:"varchar(100) index" json:"id"`
DisplayName string `xorm:"varchar(100)" json:"name,omitempty"`
Avatar string `xorm:"varchar(500)" json:"picture,omitempty"`
Email string `xorm:"varchar(100) index" json:"email,omitempty"`
EmailVerified bool `json:"email_verified,omitempty"`
Phone string `xorm:"varchar(100) index" json:"phone,omitempty"`
}
type UserWithoutThirdIdp struct {
@@ -80,7 +82,7 @@ type UserWithoutThirdIdp struct {
AvatarType string `xorm:"varchar(100)" json:"avatarType"`
PermanentAvatar string `xorm:"varchar(500)" json:"permanentAvatar"`
Email string `xorm:"varchar(100) index" json:"email"`
EmailVerified bool `json:"emailVerified"`
EmailVerified bool `json:"email_verified"`
Phone string `xorm:"varchar(100) index" json:"phone"`
CountryCode string `xorm:"varchar(6)" json:"countryCode"`
Region string `xorm:"varchar(100)" json:"region"`
@@ -190,11 +192,12 @@ func getShortUser(user *User) *UserShort {
Owner: user.Owner,
Name: user.Name,
Id: user.Id,
DisplayName: user.DisplayName,
Avatar: user.Avatar,
Email: user.Email,
Phone: user.Phone,
Id: user.Id,
DisplayName: user.DisplayName,
Avatar: user.Avatar,
Email: user.Email,
EmailVerified: user.EmailVerified,
Phone: user.Phone,
}
return res
}
@@ -204,11 +207,12 @@ func getStandardUser(user *User) *UserStandard {
Owner: user.Owner,
Name: user.Name,
Id: user.Id,
DisplayName: user.DisplayName,
Avatar: user.Avatar,
Email: user.Email,
Phone: user.Phone,
Id: user.Id,
DisplayName: user.DisplayName,
Avatar: user.Avatar,
Email: user.Email,
EmailVerified: user.EmailVerified,
Phone: user.Phone,
}
return res
}

View File

@@ -319,7 +319,15 @@ func RefreshToken(grantType string, refreshToken string, scope string, clientId
if err != nil || token == nil {
return &TokenError{
Error: InvalidGrant,
ErrorDescription: "refresh token is invalid, expired or revoked",
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
}

View File

@@ -89,16 +89,25 @@ func getStandardClaims(claims Claims) ClaimsStandard {
func ParseStandardJwtToken(token string, cert *Cert) (*ClaimsStandard, error) {
t, err := jwt.ParseWithClaims(token, &ClaimsStandard{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
var (
certificate interface{}
err error
)
if cert.Certificate == "" {
return nil, fmt.Errorf("the certificate field should not be empty for the cert: %v", cert)
}
// RSA certificate
certificate, err := jwt.ParseRSAPublicKeyFromPEM([]byte(cert.Certificate))
if _, ok := token.Method.(*jwt.SigningMethodRSA); ok {
// RSA certificate
certificate, err = jwt.ParseRSAPublicKeyFromPEM([]byte(cert.Certificate))
} else if _, ok := token.Method.(*jwt.SigningMethodECDSA); ok {
// ES certificate
certificate, err = jwt.ParseECPublicKeyFromPEM([]byte(cert.Certificate))
} else {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
if err != nil {
return nil, err
}

View File

@@ -212,11 +212,8 @@ func willLog(subOwner string, subName string, method string, urlPath string, obj
return true
}
func getUrlPath(urlPath string, ctx *context.Context) string {
// Special handling for MCP requests
if urlPath == "/api/mcp" {
return getMcpUrlPath(ctx)
}
func getUrlPath(ctx *context.Context) string {
urlPath := ctx.Request.URL.Path
if strings.HasPrefix(urlPath, "/cas") && (strings.HasSuffix(urlPath, "/serviceValidate") || strings.HasSuffix(urlPath, "/proxy") || strings.HasSuffix(urlPath, "/proxyValidate") || strings.HasSuffix(urlPath, "/validate") || strings.HasSuffix(urlPath, "/p3/serviceValidate") || strings.HasSuffix(urlPath, "/p3/proxyValidate") || strings.HasSuffix(urlPath, "/samlValidate")) {
return "/cas"
@@ -241,10 +238,64 @@ func getUrlPath(urlPath string, ctx *context.Context) string {
return urlPath
}
func getExtraInfo(ctx *context.Context, urlPath string) map[string]interface{} {
var extra map[string]interface{}
if urlPath == "/api/mcp" {
var m map[string]interface{}
if err := json.Unmarshal(ctx.Input.RequestBody, &m); err != nil {
return nil
}
method, ok := m["method"].(string)
if !ok {
return nil
}
return map[string]interface{}{
"detailPathUrl": method,
}
}
return extra
}
func getImpersonateUser(ctx *context.Context, subOwner, subName, username string) (string, string, string) {
impersonateUser, ok := ctx.Input.Session("impersonateUser").(string)
impersonateUserCookie := ctx.GetCookie("impersonateUser")
if ok && impersonateUser != "" && impersonateUserCookie != "" {
user, err := object.GetUser(util.GetId(subOwner, subName))
if err != nil {
panic(err)
}
if user != nil {
impUserOwner, impUserName, err := util.GetOwnerAndNameFromIdWithError(impersonateUser)
if err != nil {
panic(err)
}
if user.IsAdmin && impUserOwner == user.Owner {
ctx.Input.SetData("impersonating", true)
return impUserOwner, impUserName, impersonateUser
}
}
}
return subOwner, subName, username
}
func ApiFilter(ctx *context.Context) {
subOwner, subName := getSubject(ctx)
// stash current user info into request context for controllers
username := ""
if !(subOwner == "anonymous" && subName == "anonymous") {
username = fmt.Sprintf("%s/%s", subOwner, subName)
subOwner, subName, username = getImpersonateUser(ctx, subOwner, subName, username)
}
ctx.Input.SetData("currentUserId", username)
method := ctx.Request.Method
urlPath := getUrlPath(ctx.Request.URL.Path, ctx)
urlPath := getUrlPath(ctx)
extraInfo := getExtraInfo(ctx, urlPath)
objOwner, objName := "", ""
if urlPath != "/api/get-app-login" && urlPath != "/api/get-resource" {
@@ -260,7 +311,7 @@ func ApiFilter(ctx *context.Context) {
urlPath = "/api/notify-payment"
}
isAllowed := authz.IsAllowed(subOwner, subName, method, urlPath, objOwner, objName)
isAllowed := authz.IsAllowed(subOwner, subName, method, urlPath, objOwner, objName, extraInfo)
result := "deny"
if isAllowed {
@@ -270,12 +321,20 @@ func ApiFilter(ctx *context.Context) {
if willLog(subOwner, subName, method, urlPath, objOwner, objName) {
logLine := fmt.Sprintf("subOwner = %s, subName = %s, method = %s, urlPath = %s, obj.Owner = %s, obj.Name = %s, result = %s",
subOwner, subName, method, urlPath, objOwner, objName, result)
extra := formatExtraInfo(extraInfo)
if extra != "" {
logLine += fmt.Sprintf(", extraInfo = %s", extra)
}
fmt.Println(logLine)
util.LogInfo(ctx, logLine)
}
if !isAllowed {
denyRequest(ctx)
if urlPath == "/api/mcp" {
denyMcpRequest(ctx)
} else {
denyRequest(ctx)
}
record, err := object.NewRecord(ctx)
if err != nil {
return
@@ -290,3 +349,14 @@ func ApiFilter(ctx *context.Context) {
})
}
}
func formatExtraInfo(extra map[string]interface{}) string {
if extra == nil {
return ""
}
b, err := json.Marshal(extra)
if err != nil {
return ""
}
return string(b)
}

View File

@@ -15,10 +15,12 @@
package routers
import (
"encoding/json"
"fmt"
"strings"
"github.com/beego/beego/v2/server/web/context"
"github.com/casdoor/casdoor/mcp"
"github.com/casdoor/casdoor/object"
"github.com/casdoor/casdoor/util"
)
@@ -28,6 +30,14 @@ func AutoSigninFilter(ctx *context.Context) {
if strings.HasPrefix(urlPath, "/api/login/oauth/access_token") {
return
}
if urlPath == "/api/mcp" {
var req mcp.McpRequest
if err := json.Unmarshal(ctx.Input.RequestBody, &req); err == nil {
if req.Method == "initialize" || req.Method == "notifications/initialized" || req.Method == "ping" || req.Method == "tools/list" {
return
}
}
}
//if getSessionUser(ctx) != "" {
// return
//}

View File

@@ -16,14 +16,17 @@ package routers
import (
stdcontext "context"
"encoding/json"
"fmt"
"net"
"net/http"
"net/url"
"strings"
"github.com/beego/beego/v2/server/web/context"
"github.com/casdoor/casdoor/conf"
"github.com/casdoor/casdoor/i18n"
"github.com/casdoor/casdoor/mcp"
"github.com/casdoor/casdoor/object"
"github.com/casdoor/casdoor/util"
)
@@ -37,6 +40,11 @@ type Response struct {
func responseError(ctx *context.Context, error string, data ...interface{}) {
// ctx.ResponseWriter.WriteHeader(http.StatusForbidden)
urlPath := ctx.Request.URL.Path
if urlPath == "/api/mcp" {
denyMcpRequest(ctx)
return
}
resp := Response{Status: "error", Msg: error}
switch len(data) {
@@ -66,6 +74,30 @@ func denyRequest(ctx *context.Context) {
responseError(ctx, T(ctx, "auth:Unauthorized operation"))
}
func denyMcpRequest(ctx *context.Context) {
req := mcp.McpRequest{}
err := json.Unmarshal(ctx.Input.RequestBody, &req)
if err != nil {
ctx.Output.SetStatus(http.StatusBadRequest)
return
}
if req.ID == nil {
ctx.Output.SetStatus(http.StatusAccepted)
ctx.Output.Body([]byte{})
return
}
resp := mcp.BuildMcpResponse(req.ID, nil, &mcp.McpError{
Code: -32001,
Message: "Unauthorized",
Data: T(ctx, "auth:Unauthorized operation"),
})
ctx.Output.SetStatus(http.StatusUnauthorized)
_ = ctx.Output.JSON(resp, true, false)
}
func getUsernameByClientIdSecret(ctx *context.Context) (string, error) {
clientId, clientSecret, ok := ctx.Request.BasicAuth()
if !ok {

View File

@@ -127,58 +127,3 @@ func extractOwnerNameFromAppStub(app applicationStub) (string, string, error) {
}
return "", "", nil
}
func getMcpUrlPath(ctx *context.Context) string {
body := ctx.Input.RequestBody
if len(body) == 0 {
return "/api/mcp"
}
type mcpRequest struct {
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
type mcpCallToolParams struct {
Name string `json:"name"`
}
var mcpReq mcpRequest
err := json.Unmarshal(body, &mcpReq)
if err != nil {
return "/api/mcp"
}
// Map initialize and tools/list to public endpoints
// These operations don't require special permissions beyond authentication
// We use /api/get-application as it's a read-only operation that authenticated users can access
if mcpReq.Method == "initialize" || mcpReq.Method == "tools/list" {
return "/api/get-application"
}
if mcpReq.Method != "tools/call" {
return "/api/mcp"
}
var params mcpCallToolParams
err = json.Unmarshal(mcpReq.Params, &params)
if err != nil {
return "/api/mcp"
}
// Map MCP tool names to corresponding API paths
switch params.Name {
case "get_applications":
return "/api/get-applications"
case "get_application":
return "/api/get-application"
case "add_application":
return "/api/add-application"
case "update_application":
return "/api/update-application"
case "delete_application":
return "/api/delete-application"
default:
return "/api/mcp"
}
}

View File

@@ -92,6 +92,8 @@ func InitAPI() {
web.Router("/api/upload-users", &controllers.ApiController{}, "POST:UploadUsers")
web.Router("/api/remove-user-from-group", &controllers.ApiController{}, "POST:RemoveUserFromGroup")
web.Router("/api/verify-identification", &controllers.ApiController{}, "POST:VerifyIdentification")
web.Router("/api/impersonate-user", &controllers.ApiController{}, "POST:ImpersonateUser")
web.Router("/api/exit-impersonate-user", &controllers.ApiController{}, "POST:ExitImpersonateUser")
web.Router("/api/get-invitations", &controllers.ApiController{}, "GET:GetInvitations")
web.Router("/api/get-invitation", &controllers.ApiController{}, "GET:GetInvitation")

View File

@@ -15,11 +15,55 @@
package storage
import (
"github.com/casdoor/oss"
"os"
"github.com/aliyun/aliyun-oss-go-sdk/oss"
"github.com/aliyun/credentials-go/credentials"
casdoorOss "github.com/casdoor/oss"
"github.com/casdoor/oss/aliyun"
)
func NewAliyunOssStorageProvider(clientId string, clientSecret string, region string, bucket string, endpoint string) oss.StorageInterface {
func NewAliyunOssStorageProvider(clientId string, clientSecret string, region string, bucket string, endpoint string) casdoorOss.StorageInterface {
// Check if RRSA is available (empty credentials + environment variables set)
if (clientId == "" || clientId == "rrsa") &&
(clientSecret == "" || clientSecret == "rrsa") &&
os.Getenv("ALIBABA_CLOUD_ROLE_ARN") != "" {
// Use RRSA to get temporary credentials
config := &credentials.Config{}
config.SetType("oidc_role_arn")
config.SetRoleArn(os.Getenv("ALIBABA_CLOUD_ROLE_ARN"))
config.SetOIDCProviderArn(os.Getenv("ALIBABA_CLOUD_OIDC_PROVIDER_ARN"))
config.SetOIDCTokenFilePath(os.Getenv("ALIBABA_CLOUD_OIDC_TOKEN_FILE"))
config.SetRoleSessionName("casdoor-oss")
// Set STS endpoint if provided
if stsEndpoint := os.Getenv("ALIBABA_CLOUD_STS_ENDPOINT"); stsEndpoint != "" {
config.SetSTSEndpoint(stsEndpoint)
}
credential, err := credentials.NewCredential(config)
if err == nil {
accessKeyId, errId := credential.GetAccessKeyId()
accessKeySecret, errSecret := credential.GetAccessKeySecret()
securityToken, errToken := credential.GetSecurityToken()
if errId == nil && errSecret == nil && errToken == nil &&
accessKeyId != nil && accessKeySecret != nil && securityToken != nil {
// Successfully obtained RRSA credentials
sp := aliyun.New(&aliyun.Config{
AccessID: *accessKeyId,
AccessKey: *accessKeySecret,
Bucket: bucket,
Endpoint: endpoint,
ClientOptions: []oss.ClientOption{oss.SecurityToken(*securityToken)},
})
return sp
}
}
// If RRSA fails, fall through to static credentials (which will fail if empty)
}
// Use static credentials (existing behavior)
sp := aliyun.New(&aliyun.Config{
AccessID: clientId,
AccessKey: clientSecret,

View File

@@ -28,9 +28,9 @@ import (
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/shirou/gopsutil/cpu"
"github.com/shirou/gopsutil/mem"
"github.com/shirou/gopsutil/process"
"github.com/shirou/gopsutil/v4/cpu"
"github.com/shirou/gopsutil/v4/mem"
"github.com/shirou/gopsutil/v4/process"
)
type SystemInfo struct {

View File

@@ -29,6 +29,7 @@ import EntryPage from "./EntryPage";
import * as AuthBackend from "./auth/AuthBackend";
import AuthCallback from "./auth/AuthCallback";
import SamlCallback from "./auth/SamlCallback";
import TelegramLogin from "./auth/TelegramLogin";
import i18next from "i18next";
import {withTranslation} from "react-i18next";
const ManagementPage = lazy(() => import("./ManagementPage"));
@@ -521,7 +522,9 @@ class App extends Component {
}
isDoorPages() {
return this.isEntryPages() || window.location.pathname.startsWith("/callback");
return this.isEntryPages() ||
window.location.pathname.startsWith("/callback") ||
window.location.pathname.startsWith("/telegram-login");
}
isEntryPages() {
@@ -605,6 +608,7 @@ class App extends Component {
<Switch>
<Route exact path="/callback" render={(props) => <AuthCallback {...props} {...this.props} application={this.state.application} onLoginSuccess={(redirectUrl) => {this.onLoginSuccess(redirectUrl);}} />} />
<Route exact path="/callback/saml" render={(props) => <SamlCallback {...props} {...this.props} application={this.state.application} onLoginSuccess={(redirectUrl) => {this.onLoginSuccess(redirectUrl);}} />} />
<Route exact path="/telegram-login" render={(props) => <TelegramLogin {...props} {...this.props} />} />
<Route path="" render={() => <Result status="404" title="404 NOT FOUND" subTitle={i18next.t("general:Sorry, the page you visited does not exist.")}
extra={<a href="/"><Button type="primary">{i18next.t("general:Back Home")}</Button></a>} />} />
</Switch>

View File

@@ -926,6 +926,16 @@ class ApplicationEditPage extends React.Component {
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 19 : 2}>
{Setting.getLabel(i18next.t("application:Enable SAML assertion signature"), i18next.t("application:Enable SAML assertion signature - Tooltip"))} :
</Col>
<Col span={1} >
<Switch checked={this.state.application.enableSamlAssertionSignature} onChange={checked => {
this.updateApplicationField("enableSamlAssertionSignature", checked);
}} />
</Col>
</Row>
{
!this.state.application.disableSamlAttributes ? (
<Row style={{marginTop: "20px"}} >

View File

@@ -101,6 +101,8 @@ import TransactionEditPage from "./TransactionEditPage";
import VerificationListPage from "./VerificationListPage";
import TicketListPage from "./TicketListPage";
import TicketEditPage from "./TicketEditPage";
import * as Cookie from "cookie";
import * as UserBackend from "./backend/UserBackend";
function ManagementPage(props) {
const [menuVisible, setMenuVisible] = useState(false);
@@ -155,8 +157,14 @@ function ManagementPage(props) {
"/account"
));
}
items.push(Setting.getItem(<><LogoutOutlined />&nbsp;&nbsp;{i18next.t("account:Logout")}</>,
"/logout"));
const curCookie = Cookie.parse(document.cookie);
if (curCookie["impersonateUser"]) {
items.push(Setting.getItem(<><LogoutOutlined />&nbsp;&nbsp;{i18next.t("account:Exit impersonation")}</>,
"/exit-impersonation"));
} else {
items.push(Setting.getItem(<><LogoutOutlined />&nbsp;&nbsp;{i18next.t("account:Logout")}</>,
"/logout"));
}
const onClick = (e) => {
if (e.key === "/account") {
@@ -165,6 +173,16 @@ function ManagementPage(props) {
props.history.push("/subscription");
} else if (e.key === "/logout") {
logout();
} else if (e.key === "/exit-impersonation") {
UserBackend.exitImpersonateUser().then((res) => {
if (res.status === "ok") {
Setting.showMessage("success", i18next.t("account:Exit impersonation"));
Setting.goToLinkSoft({props}, "/");
window.location.reload();
} else {
Setting.showMessage("error", res.msg);
}
});
}
};
@@ -408,8 +426,13 @@ function ManagementPage(props) {
function renderLoginIfNotLoggedIn(component) {
if (props.account === null) {
const lastLoginOrg = localStorage.getItem("lastLoginOrg");
sessionStorage.setItem("from", window.location.pathname);
return <Redirect to="/login" />;
if (lastLoginOrg) {
return <Redirect to={`/login/${lastLoginOrg}`} />;
} else {
return <Redirect to="/login" />;
}
} else if (props.account === undefined) {
return null;
} else if (props.account.needUpdatePassword) {

View File

@@ -158,16 +158,25 @@ class OrderEditPage extends React.Component {
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{i18next.t("order:Product")}:
{i18next.t("order:Products")}:
</Col>
<Col span={22} >
<Select virtual={false} style={{width: "100%"}} value={this.state.order.productName} disabled={isViewMode} onChange={(value) => {
this.updateOrderField("productName", value);
}}>
{
this.state.products?.map((product, index) => <Option key={index} value={product.name}>{product.displayName}</Option>)
}
</Select>
<Select
mode="multiple"
style={{width: "100%"}}
value={this.state.order?.products || []}
disabled={isViewMode}
allowClear
options={(this.state.products || [])
.map((p) => ({
label: Setting.getLanguageText(p?.displayName) || p?.name,
value: p?.name,
}))
.filter((o) => o.value)}
onChange={(value) => {
this.updateOrderField("products", value);
}}
/>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >

View File

@@ -14,13 +14,14 @@
import React from "react";
import {Link} from "react-router-dom";
import {Button, Table} from "antd";
import {Button, List, Table, Tooltip} from "antd";
import moment from "moment";
import * as Setting from "./Setting";
import * as OrderBackend from "./backend/OrderBackend";
import i18next from "i18next";
import BaseListPage from "./BaseListPage";
import PopconfirmModal from "./common/modal/PopconfirmModal";
import {EditOutlined} from "@ant-design/icons";
class OrderListPage extends BaseListPage {
newOrder() {
@@ -31,7 +32,7 @@ class OrderListPage extends BaseListPage {
name: `order_${randomName}`,
createdTime: moment().format(),
displayName: `New Order - ${randomName}`,
productName: "",
products: [],
user: "",
payment: "",
state: "Created",
@@ -146,20 +147,41 @@ class OrderListPage extends BaseListPage {
...this.getColumnSearchProps("displayName"),
},
{
title: i18next.t("order:Product"),
dataIndex: "productName",
key: "productName",
width: "170px",
sorter: true,
...this.getColumnSearchProps("productName"),
title: i18next.t("order:Products"),
dataIndex: "products",
key: "products",
...this.getColumnSearchProps("products"),
render: (text, record, index) => {
if (text === "") {
return "(empty)";
const products = record?.products || [];
if (products.length === 0) {
return `(${i18next.t("general:empty")})`;
}
return (
<Link to={`/products/${record.owner}/${text}`}>
{text}
</Link>
<div>
<List
size="small"
locale={{emptyText: " "}}
dataSource={products}
style={{
paddingTop: 8,
paddingBottom: 8,
}}
renderItem={(productName, i) => {
return (
<List.Item>
<div style={{display: "inline"}}>
<Tooltip placement="topLeft" title={i18next.t("general:Edit")}>
<Button style={{marginRight: "5px"}} icon={<EditOutlined />} size="small" onClick={() => Setting.goToLinkSoft(this, `/products/${record.owner}/${productName}`)} />
</Tooltip>
<Link to={`/products/${record.owner}/${productName}`}>
{productName}
</Link>
</div>
</List.Item>
);
}}
/>
</div>
);
},
},
@@ -208,7 +230,7 @@ class OrderListPage extends BaseListPage {
...this.getColumnSearchProps("state"),
},
{
title: i18next.t("order:Start time"),
title: i18next.t("general:Start time"),
dataIndex: "startTime",
key: "startTime",
width: "160px",
@@ -218,7 +240,7 @@ class OrderListPage extends BaseListPage {
},
},
{
title: i18next.t("order:End time"),
title: i18next.t("general:End time"),
dataIndex: "endTime",
key: "endTime",
width: "160px",
@@ -234,7 +256,7 @@ class OrderListPage extends BaseListPage {
title: i18next.t("general:Action"),
dataIndex: "",
key: "op",
width: "240px",
width: "300px",
fixed: (Setting.isMobile()) ? "false" : "right",
render: (text, record, index) => {
const isAdmin = Setting.isLocalAdminUser(this.props.account);
@@ -289,7 +311,7 @@ class OrderListPage extends BaseListPage {
const field = params.searchedColumn, value = params.searchText;
const sortField = params.sortField, sortOrder = params.sortOrder;
this.setState({loading: true});
OrderBackend.getOrders(Setting.getRequestOrganization(this.props.account), params.pagination.current, params.pagination.pageSize, field, value, sortField, sortOrder)
OrderBackend.getOrders(Setting.isDefaultOrganizationSelected(this.props.account) ? "" : Setting.getRequestOrganization(this.props.account), params.pagination.current, params.pagination.pageSize, field, value, sortField, sortOrder)
.then((res) => {
this.setState({
loading: false,

View File

@@ -27,7 +27,8 @@ class OrderPayPage extends React.Component {
owner: props?.match?.params?.organizationName ?? props?.match?.params?.owner ?? null,
orderName: props?.match?.params?.orderName ?? null,
order: null,
product: null,
firstProduct: null,
productInfos: [],
paymentEnv: "",
isProcessingPayment: false,
isViewMode: params.get("view") === "true",
@@ -59,6 +60,7 @@ class OrderPayPage extends React.Component {
if (res.status === "ok") {
this.setState({
order: res.data,
productInfos: res.data?.productInfos,
}, () => {
this.getProduct();
});
@@ -68,13 +70,19 @@ class OrderPayPage extends React.Component {
}
async getProduct() {
if (!this.state.order || !this.state.order.productName) {
if (!this.state.order) {
return;
}
const res = await ProductBackend.getProduct(this.state.order.owner, this.state.order.productName);
const firstProductName = this.state.order?.products?.[0] ?? this.state.order?.productInfos?.[0]?.name;
if (!firstProductName) {
return;
}
const res = await ProductBackend.getProduct(this.state.order.owner, firstProductName);
if (res.status === "ok") {
this.setState({
product: res.data,
firstProduct: res.data,
});
} else {
Setting.showMessage("error", res.msg);
@@ -86,7 +94,7 @@ class OrderPayPage extends React.Component {
}
getProductPrice(product) {
return `${Setting.getCurrencySymbol(product?.currency)}${product?.price} (${Setting.getCurrencyText(product)})`;
return `${Setting.getCurrencySymbol(this.state.order?.currency)}${product.price} (${Setting.getCurrencyText(this.state.order)})`;
}
// Call Wechat Pay via jsapi
@@ -129,8 +137,8 @@ class OrderPayPage extends React.Component {
}
payOrder(provider) {
const {product, order} = this.state;
if (!product || !order) {
const {firstProduct, order} = this.state;
if (!firstProduct || !order) {
return;
}
@@ -198,20 +206,43 @@ class OrderPayPage extends React.Component {
}
renderPaymentMethods() {
const {product} = this.state;
if (!product || !product.providerObjs || product.providerObjs.length === 0) {
const {firstProduct} = this.state;
if (!firstProduct || !firstProduct.providerObjs || firstProduct.providerObjs.length === 0) {
return <div>{i18next.t("product:There is no payment channel for this product.")}</div>;
}
return product.providerObjs.map(provider => {
return firstProduct.providerObjs.map(provider => {
return this.renderProviderButton(provider);
});
}
render() {
const {order, product} = this.state;
renderProduct(product) {
return (
<React.Fragment key={product.name}>
<Descriptions.Item label={i18next.t("general:Name")} span={3}>
<span style={{fontSize: 20}}>
{Setting.getLanguageText(product?.displayName)}
</span>
</Descriptions.Item>
<Descriptions.Item label={i18next.t("product:Image")} span={3}>
<img src={product?.image} alt={Setting.getLanguageText(product?.displayName)} height={90} style={{marginBottom: "20px"}} />
</Descriptions.Item>
<Descriptions.Item label={i18next.t("product:Price")} span={3}>
<span style={{fontSize: 18, fontWeight: "bold"}}>
{this.getProductPrice(product)}
</span>
</Descriptions.Item>
<Descriptions.Item label={i18next.t("product:Detail")} span={3}>
<span style={{fontSize: 16}}>{Setting.getLanguageText(product?.detail)}</span>
</Descriptions.Item>
</React.Fragment>
);
}
if (!order || !product) {
render() {
const {order, productInfos} = this.state;
if (!order || !productInfos) {
return null;
}
@@ -247,22 +278,7 @@ class OrderPayPage extends React.Component {
<div style={{marginBottom: "20px"}}>
<Descriptions title={<span style={Setting.isMobile() ? {fontSize: 18} : {fontSize: 24}}>{i18next.t("product:Product Information")}</span>} bordered column={3}>
<Descriptions.Item label={i18next.t("general:Name")} span={3}>
<span style={{fontSize: 20}}>
{Setting.getLanguageText(product?.displayName)}
</span>
</Descriptions.Item>
<Descriptions.Item label={i18next.t("product:Image")} span={3}>
<img src={product?.image} alt={Setting.getLanguageText(product?.displayName)} height={90} style={{marginBottom: "20px"}} />
</Descriptions.Item>
<Descriptions.Item label={i18next.t("product:Price")} span={3}>
<span style={{fontSize: 18, fontWeight: "bold"}}>
{this.getProductPrice(product)}
</span>
</Descriptions.Item>
<Descriptions.Item label={i18next.t("product:Detail")} span={3}>
<span style={{fontSize: 16}}>{Setting.getLanguageText(product?.detail)}</span>
</Descriptions.Item>
{productInfos.map(product => this.renderProduct(product))}
</Descriptions>
</div>

View File

@@ -18,6 +18,7 @@ import {InfoCircleTwoTone} from "@ant-design/icons";
import * as PaymentBackend from "./backend/PaymentBackend";
import * as Setting from "./Setting";
import i18next from "i18next";
import * as ProductBackend from "./backend/ProductBackend";
const {Option} = Select;
@@ -29,6 +30,7 @@ class PaymentEditPage extends React.Component {
organizationName: props.organizationName !== undefined ? props.organizationName : props.match.params.organizationName,
paymentName: props.match.params.paymentName,
payment: null,
products: [],
isModalVisible: false,
isInvoiceLoading: false,
mode: props.location.mode !== undefined ? props.location.mode : "edit",
@@ -37,6 +39,7 @@ class PaymentEditPage extends React.Component {
UNSAFE_componentWillMount() {
this.getPayment();
this.getProducts();
}
getPayment() {
@@ -55,6 +58,19 @@ class PaymentEditPage extends React.Component {
});
}
getProducts() {
ProductBackend.getProducts(this.state.organizationName)
.then((res) => {
if (res.status === "ok") {
this.setState({
products: res.data,
});
} else {
Setting.showMessage("error", `Failed to get products: ${res.msg}`);
}
});
}
goToViewOrder() {
const payment = this.state.payment;
if (payment && payment.order) {
@@ -227,12 +243,25 @@ class PaymentEditPage extends React.Component {
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("payment:Product"), i18next.t("payment:Product - Tooltip"))} :
{Setting.getLabel(i18next.t("payment:Products"), i18next.t("payment:Products - Tooltip"))} :
</Col>
<Col span={22} >
<Input disabled={true} value={this.state.payment.productName} onChange={e => {
// this.updatePaymentField('productName', e.target.value);
}} />
<Select
mode="multiple"
style={{width: "100%"}}
value={this.state.payment?.products || []}
disabled={isViewMode}
allowClear
options={(this.state.products || [])
.map((p) => ({
label: Setting.getLanguageText(p?.displayName) || p?.name,
value: p?.name,
}))
.filter((o) => o.value)}
onChange={(value) => {
this.updatePaymentField("products", value);
}}
/>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >

View File

@@ -14,7 +14,7 @@
import React from "react";
import {Link} from "react-router-dom";
import {Button, Table} from "antd";
import {Button, List, Table, Tooltip} from "antd";
import moment from "moment";
import * as Setting from "./Setting";
import * as PaymentBackend from "./backend/PaymentBackend";
@@ -22,6 +22,7 @@ import i18next from "i18next";
import BaseListPage from "./BaseListPage";
import * as Provider from "./auth/Provider";
import PopconfirmModal from "./common/modal/PopconfirmModal";
import {EditOutlined} from "@ant-design/icons";
class PaymentListPage extends BaseListPage {
newPayment() {
@@ -35,9 +36,9 @@ class PaymentListPage extends BaseListPage {
provider: "provider_pay_paypal",
type: "PayPal",
user: "admin",
productName: "computer-1",
productDisplayName: "A notebook computer",
detail: "This is a computer with excellent CPU, memory and disk",
products: [],
productsDisplayName: "",
detail: "This is a payment",
tag: "Promotion-1",
currency: "USD",
price: 300.00,
@@ -174,17 +175,41 @@ class PaymentListPage extends BaseListPage {
},
},
{
title: i18next.t("payment:Product"),
dataIndex: "productDisplayName",
key: "productDisplayName",
// width: '160px',
sorter: true,
...this.getColumnSearchProps("productDisplayName"),
title: i18next.t("order:Products"),
dataIndex: "products",
key: "products",
...this.getColumnSearchProps("products"),
render: (text, record, index) => {
const products = record?.products || [];
if (products.length === 0) {
return `(${i18next.t("general:empty")})`;
}
return (
<Link to={`/products/${record.owner}/${record.productName}`}>
{text}
</Link>
<div>
<List
size="small"
locale={{emptyText: " "}}
dataSource={products}
style={{
paddingTop: 8,
paddingBottom: 8,
}}
renderItem={(productName, i) => {
return (
<List.Item>
<div style={{display: "inline"}}>
<Tooltip placement="topLeft" title={i18next.t("general:Edit")}>
<Button style={{marginRight: "5px"}} icon={<EditOutlined />} size="small" onClick={() => Setting.goToLinkSoft(this, `/products/${record.owner}/${productName}`)} />
</Tooltip>
<Link to={`/products/${record.owner}/${productName}`}>
{productName}
</Link>
</div>
</List.Item>
);
}}
/>
</div>
);
},
},

View File

@@ -202,7 +202,7 @@ class PaymentResultPage extends React.Component {
}
<Result
status="success"
title={`${i18next.t("payment:You have successfully completed the payment")}: ${payment.productDisplayName}`}
title={`${i18next.t("payment:You have successfully completed the payment")}: ${payment.productsDisplayName}`}
subTitle={i18next.t("payment:You can view your order details or return to the order list")}
extra={[
<Button type="primary" key="viewOrder" onClick={() => {
@@ -227,7 +227,7 @@ class PaymentResultPage extends React.Component {
}
<Result
status="info"
title={`${i18next.t("payment:The payment is still under processing")}: ${payment.productDisplayName}, ${i18next.t("payment:the current state is")}: ${payment.state}, ${i18next.t("payment:please wait for a few seconds...")}`}
title={`${i18next.t("payment:The payment is still under processing")}: ${payment.productsDisplayName}, ${i18next.t("payment:the current state is")}: ${payment.state}, ${i18next.t("payment:please wait for a few seconds...")}`}
subTitle={i18next.t("payment:You can view your order details or return to the order list")}
extra={[
<Spin key="returnUrl" size="large" tip={i18next.t("payment:Processing...")} />,
@@ -243,7 +243,7 @@ class PaymentResultPage extends React.Component {
}
<Result
status="warning"
title={`${i18next.t("payment:The payment has been canceled")}: ${payment.productDisplayName}, ${i18next.t("payment:the current state is")}: ${payment.state}`}
title={`${i18next.t("payment:The payment has been canceled")}: ${payment.productsDisplayName}, ${i18next.t("payment:the current state is")}: ${payment.state}`}
subTitle={i18next.t("payment:You can view your order details or return to the order list")}
extra={[
<Button type="primary" key="viewOrder" onClick={() => {
@@ -268,7 +268,7 @@ class PaymentResultPage extends React.Component {
}
<Result
status="warning"
title={`${i18next.t("payment:The payment has time out")}: ${payment.productDisplayName}, ${i18next.t("payment:the current state is")}: ${payment.state}`}
title={`${i18next.t("payment:The payment has timed out")}: ${payment.productsDisplayName}, ${i18next.t("payment:the current state is")}: ${payment.state}`}
subTitle={i18next.t("payment:You can view your order details or return to the order list")}
extra={[
<Button type="primary" key="viewOrder" onClick={() => {
@@ -293,7 +293,7 @@ class PaymentResultPage extends React.Component {
}
<Result
status="error"
title={`${i18next.t("payment:The payment has failed")}: ${payment.productDisplayName}, ${i18next.t("payment:the current state is")}: ${payment.state}`}
title={`${i18next.t("payment:The payment has failed")}: ${payment.productsDisplayName}, ${i18next.t("payment:the current state is")}: ${payment.state}`}
subTitle={`${i18next.t("payment:Failed reason")}: ${payment.message}`}
extra={[
<Button type="primary" key="viewOrder" onClick={() => {

View File

@@ -138,7 +138,7 @@ class SubscriptionListPage extends BaseListPage {
...this.getColumnSearchProps("period"),
},
{
title: i18next.t("subscription:Start time"),
title: i18next.t("general:Start time"),
dataIndex: "startTime",
key: "startTime",
width: "140px",
@@ -148,7 +148,7 @@ class SubscriptionListPage extends BaseListPage {
},
},
{
title: i18next.t("subscription:End time"),
title: i18next.t("general:End time"),
dataIndex: "endTime",
key: "endTime",
width: "140px",

View File

@@ -188,6 +188,18 @@ class UserListPage extends BaseListPage {
});
}
impersonateUser(user) {
UserBackend.impersonateUser(user).then((res) => {
if (res.status === "ok") {
Setting.showMessage("success", i18next.t("general:Success"));
Setting.goToLinkSoft(this, "/");
window.location.reload();
} else {
Setting.showMessage("error", res.msg);
}
});
}
renderUpload() {
const uploadThis = this;
const props = {
@@ -533,6 +545,10 @@ 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={() => {
this.impersonateUser(`${record.owner}/${record.name}`);
}}>{i18next.t("general:Impersonation")}
</Button>
<Button size={isTreePage ? "small" : "middle"} type="primary" onClick={() => {
sessionStorage.setItem("userListUrl", window.location.pathname);
this.props.history.push(`/users/${record.owner}/${record.name}`);

View File

@@ -114,6 +114,36 @@ class AuthCallback extends React.Component {
const samlRequest = innerParams.get("SAMLRequest");
const casService = innerParams.get("service");
// Telegram sends auth data as individual URL parameters
// Collect them and convert to JSON for backend processing
const telegramId = params.get("id");
if (telegramId !== null && (code === null || code === "")) {
const telegramAuthData = {
id: parseInt(telegramId, 10),
};
// Required fields
const hash = params.get("hash");
const authDate = params.get("auth_date");
if (hash) {
telegramAuthData.hash = hash;
}
if (authDate) {
telegramAuthData.auth_date = authDate;
}
// Optional fields - only include if present
const optionalFields = ["first_name", "last_name", "username", "photo_url"];
optionalFields.forEach(field => {
const value = params.get(field);
if (value !== null && value !== "") {
telegramAuthData[field] = value;
}
});
code = JSON.stringify(telegramAuthData);
}
const redirectUri = `${window.location.origin}/callback`;
const body = {

View File

@@ -577,7 +577,7 @@ class LoginPage extends React.Component {
} else {
const SAMLResponse = res.data;
const redirectUri = res.data2.redirectUrl;
Setting.goToLink(`${redirectUri}${redirectUri.includes("?") ? "&" : "?"}SAMLResponse=${encodeURIComponent(SAMLResponse)}&RelayState=${oAuthParams.relayState}`);
Setting.goToLink(`${redirectUri}${redirectUri.includes("?") ? "&" : "?"}SAMLResponse=${encodeURIComponent(SAMLResponse)}&RelayState=${encodeURIComponent(oAuthParams.relayState)}`);
}
}
};
@@ -591,6 +591,7 @@ class LoginPage extends React.Component {
}
}
}).finally(() => {
localStorage.setItem("lastLoginOrg", values?.organization || "");
this.setState({loginLoading: false});
});
}
@@ -1563,7 +1564,7 @@ class LoginPage extends React.Component {
);
}
const visibleOAuthProviderItems = (application.providers === null) ? [] : application.providers.filter(providerItem => this.isProviderVisible(providerItem));
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"));
return (

View File

@@ -510,8 +510,8 @@ export function getAuthUrl(application, provider, method, code) {
return `${endpoint}?client_id=${provider.clientId}&redirect_uri=${redirectUri}&state=${state}&response_type=code&scope=${scope}&code_challenge=${codeChallenge}&code_challenge_method=S256`;
} else if (provider.type === "Telegram") {
// Telegram uses widget-based authentication
// The actual login is handled by Telegram widget on the frontend
return `${redirectUri}?state=${state}`;
// Redirect to a page that displays the Telegram login widget
return `${redirectOrigin}/telegram-login?state=${state}`;
} else if (provider.type === "MetaMask") {
return `${redirectUri}?state=${state}`;
} else if (provider.type === "Web3Onboard") {

View File

@@ -0,0 +1,134 @@
// 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.
import React from "react";
import {Card} from "antd";
import {withRouter} from "react-router-dom";
import * as Util from "./Util";
import * as Setting from "../Setting";
import * as ProviderBackend from "../backend/ProviderBackend";
import i18next from "i18next";
class TelegramLogin extends React.Component {
constructor(props) {
super(props);
this.state = {
applicationName: "",
providerName: "",
botUsername: "",
authUrl: "",
};
}
componentDidMount() {
const params = new URLSearchParams(this.props.location.search);
const state = params.get("state");
const queryString = Util.getQueryParamsFromState(state);
const innerParams = new URLSearchParams(queryString);
const applicationName = innerParams.get("application");
const providerName = innerParams.get("provider");
// Get provider info to retrieve bot username
ProviderBackend.getProvider("admin", providerName).then((res) => {
if (res.status === "ok") {
const provider = res.data;
const redirectOrigin = window.location.origin;
const redirectUri = `${redirectOrigin}/callback`;
this.setState({
applicationName: applicationName,
providerName: providerName,
botUsername: provider.clientId,
authUrl: `${redirectUri}?state=${state}`,
}, () => {
this.loadTelegramWidget();
});
} else {
Setting.showMessage("error", `Failed to get provider: ${res.msg}`);
}
});
}
loadTelegramWidget() {
if (!this.state.botUsername || !this.state.authUrl) {
return;
}
// Remove any existing Telegram script
const existingScript = document.querySelector("script[src*='telegram-widget']");
if (existingScript) {
existingScript.remove();
}
// Create and load the Telegram widget script
// Note: We load from official Telegram domain over HTTPS for security.
// SRI is not used as Telegram doesn't provide integrity hashes and the script version may change.
const script = document.createElement("script");
script.src = "https://telegram.org/js/telegram-widget.js";
script.setAttribute("data-telegram-login", this.state.botUsername);
script.setAttribute("data-size", "large");
script.setAttribute("data-auth-url", this.state.authUrl);
script.setAttribute("data-request-access", "write");
script.async = true;
const container = document.getElementById("telegram-login-container");
if (container) {
container.innerHTML = "";
container.appendChild(script);
}
}
render() {
return (
<div className="login-content" style={{margin: "auto"}}>
<div style={{marginBottom: "10px", textAlign: "center"}}>
<Card
style={{
width: "400px",
margin: "0 auto",
marginTop: "100px",
}}
title={
<div>
<img
width={40}
height={40}
src={Setting.getProviderLogoURL({type: "Telegram", category: "OAuth"})}
alt="Telegram"
style={{marginRight: "10px"}}
/>
{i18next.t("login:Sign in with Telegram")}
</div>
}
>
<div style={{textAlign: "center", padding: "20px"}}>
<p>{i18next.t("login:Click the button below to sign in with Telegram")}</p>
<div
id="telegram-login-container"
style={{
display: "flex",
justifyContent: "center",
marginTop: "20px",
}}
/>
</div>
</Card>
</div>
</div>
);
}
}
export default withRouter(TelegramLogin);

View File

@@ -200,6 +200,29 @@ export function resetEmailOrPhone(dest, type, code) {
}).then(res => res.json());
}
export function impersonateUser(username) {
const formData = new FormData();
formData.append("username", username);
return fetch(`${Setting.ServerUrl}/api/impersonate-user`, {
method: "POST",
credentials: "include",
body: formData,
headers: {
"Accept-Language": Setting.getAcceptLanguage(),
},
}).then(res => res.json());
}
export function exitImpersonateUser() {
return fetch(`${Setting.ServerUrl}/api/exit-impersonate-user`, {
method: "POST",
credentials: "include",
headers: {
"Accept-Language": Setting.getAcceptLanguage(),
},
}).then(res => res.json());
}
export function getCaptcha(owner, name, isCurrentProvider) {
return fetch(`${Setting.ServerUrl}/api/get-captcha?applicationId=${owner}/${encodeURIComponent(name)}&isCurrentProvider=${isCurrentProvider}`, {
method: "GET",