Compare commits

...

33 Commits

Author SHA1 Message Date
Yang Luo
ed74d69fcc Delete mcp/scope_registry_test.go 2026-02-15 22:02:28 +08:00
Yang Luo
d898202dad Rename scope_registry.go to permission.go 2026-02-15 22:02:18 +08:00
copilot-swe-agent[bot]
b25f11ad9a Refactor code to address review feedback: extract GetRequiredScopeForTool helper
Co-authored-by: hsluoyz <3787410+hsluoyz@users.noreply.github.com>
2026-02-15 13:39:34 +00:00
copilot-swe-agent[bot]
103e2fef02 Add comprehensive tests for scope-to-tool mapping
Co-authored-by: hsluoyz <3787410+hsluoyz@users.noreply.github.com>
2026-02-15 13:37:49 +00:00
copilot-swe-agent[bot]
fa89321cb7 Add scope-to-tool permission mapping infrastructure
Co-authored-by: hsluoyz <3787410+hsluoyz@users.noreply.github.com>
2026-02-15 13:36:12 +00:00
copilot-swe-agent[bot]
376fa9751f Initial plan 2026-02-15 13:31:23 +00:00
Yang Luo
3cb9df3723 feat: [mcp-5] add Application.Category and Application.Type fields for agent applications (MCP, A2A) (#5102) 2026-02-15 21:28:00 +08:00
Yang Luo
9d1e5c10d0 feat: [mcp-4] implement RFC 8707 Resource Indicators for OAuth 2.0 (#5098) 2026-02-15 18:03:22 +08:00
Yang Luo
ef84c4b0b4 feat: [mcp-3] implement OAuth 2.0 Dynamic Client Registration (RFC 7591) (#5097) 2026-02-15 17:25:44 +08:00
Yang Luo
5a108bd921 fix: [mcp-2] add OAuth 2.0 Authorization Server Metadata endpoints (RFC 8414) (#5094) 2026-02-15 17:00:40 +08:00
Yang Luo
ac671ec1ee fix: rename to wellknown_oidc_discovery.go 2026-02-15 16:42:00 +08:00
Yang Luo
7814caf2ab feat: implement RFC 9728 OAuth 2.0 Protected Resource Metadata for MCP server discovery (#5092) 2026-02-15 16:40:48 +08:00
Yang Luo
f966f4a0f9 feat: fix Dummy payment provider returning zero price in NotifyResult (#5090) 2026-02-15 02:31:56 +08:00
Yang Luo
a4b1a068a8 feat: fix Azure SQL DB panic by migrating to the official go-mssqldb fork (#5082) 2026-02-15 01:52:06 +08:00
Yang Luo
362797678d feat: fix nil pointer panic in update-user API for non-existent account items (#5084) 2026-02-15 01:44:26 +08:00
Yang Luo
7879e1bf09 fix: fix Dummy payment provider to simulate external callback flow (#5080) 2026-02-15 00:18:09 +08:00
IsAurora6
c246f102c9 feat: fix issue that User.Cart cannot be updated without org account items (#5076) 2026-02-14 22:32:58 +08:00
IsAurora6
37d1c4910c feat: Fixed an error when clicking the “delete” button on the cart list page. (#5075) 2026-02-13 20:49:08 +08:00
Yang Luo
3bcde7cb7c feat: add Cart and payment fields to organization account items (#5070) 2026-02-13 10:40:37 +08:00
Yang Luo
6a90d21941 fix: add CreatedTime field to cart items and sort by timestamp (#5066) 2026-02-13 10:36:20 +08:00
Yang Luo
80b4c0b1a7 feat: remove special handling for Dummy payment provider (#5068) 2026-02-13 10:06:14 +08:00
Yang Luo
eb5a422026 feat: replace DisableSsl boolean with SslMode enum for Email providers (#5063) 2026-02-13 02:15:20 +08:00
DacongDA
f7bd70e0a3 feat: improve tab height UI in application edit page (#5055) 2026-02-12 21:57:57 +08:00
Copilot
5e7dbe4b56 feat: fix CAPTCHA rule enforcement in verification code flow (#5009) 2026-02-12 21:22:47 +08:00
Yang Luo
bd1fca2f32 feat: Add LDAP group/OU hierarchy syncing with automatic user membership (#5052) 2026-02-12 17:11:20 +08:00
IsAurora6
3d4cc42f1f feat: mark cart items as invalid when product is removed, renamed, or currency is changed. (#5050) 2026-02-12 00:46:54 +08:00
Yang Luo
1836cab44d feat: fix icons for 5 payment providers 2026-02-11 01:42:37 +08:00
Yang Luo
75b18635f7 feat: fix issue that Webhook records for set-password API were missing user context (#5008) 2026-02-11 01:32:11 +08:00
Yang Luo
47cd44c7ce feat: support "snsapi_privateinfo" scope in WeCom OAuth provider to support fetching Emails (#5034) 2026-02-11 01:21:29 +08:00
Yang Luo
090ca97dcd feat: bind provider IDs in WeCom/DingTalk/Lark syncers (#5033) 2026-02-11 01:04:26 +08:00
Yang Luo
bed01b31f1 feat: add AWS IAM syncer (#5043) 2026-02-11 01:00:41 +08:00
Yang Luo
c8f8f88d85 feat: add "Existing Field" category for token attributes table in application edit page (#5041) 2026-02-11 00:58:50 +08:00
IsAurora6
7acb303995 feat: Fixed cart anomalies when updating product information. (#5039) 2026-02-10 20:58:18 +08:00
62 changed files with 2690 additions and 317 deletions

View File

@@ -59,6 +59,7 @@ p, *, *, GET, /api/get-qrcode, *, *
p, *, *, GET, /api/get-webhook-event, *, *
p, *, *, GET, /api/get-captcha-status, *, *
p, *, *, *, /api/login/oauth, *, *
p, *, *, POST, /api/oauth/register, *, *
p, *, *, GET, /api/get-application, *, *
p, *, *, GET, /api/get-organization-applications, *, *
p, *, *, GET, /api/get-user, *, *

View File

@@ -323,7 +323,7 @@ func (c *ApiController) Signup() {
// If OAuth parameters are present, generate OAuth code and return it
if clientId != "" && responseType == ResponseTypeCode {
code, err := object.GetOAuthCode(userId, clientId, "", "password", responseType, redirectUri, scope, state, nonce, codeChallenge, c.Ctx.Request.Host, c.GetAcceptLanguage())
code, err := object.GetOAuthCode(userId, clientId, "", "password", responseType, redirectUri, scope, state, nonce, codeChallenge, "", c.Ctx.Request.Host, c.GetAcceptLanguage())
if err != nil {
c.ResponseError(err.Error(), nil)
return
@@ -688,6 +688,51 @@ func (c *ApiController) GetCaptcha() {
applicationId := c.Ctx.Input.Query("applicationId")
isCurrentProvider := c.Ctx.Input.Query("isCurrentProvider")
// When isCurrentProvider == "true", the frontend passes a provider ID instead of an application ID.
// In that case, skip application lookup and rule evaluation, and just return the provider config.
shouldSkipCaptcha := false
if isCurrentProvider != "true" {
application, err := object.GetApplication(applicationId)
if err != nil {
c.ResponseError(err.Error())
return
}
if application == nil {
c.ResponseError(fmt.Sprintf(c.T("auth:The application: %s does not exist"), applicationId))
return
}
// Check the CAPTCHA rule to determine if CAPTCHA should be shown
clientIp := util.GetClientIpFromRequest(c.Ctx.Request)
// For Internet-Only rule, we can determine on the backend if CAPTCHA should be shown
// For other rules (Dynamic, Always), we need to return the CAPTCHA config
for _, providerItem := range application.Providers {
if providerItem.Provider == nil || providerItem.Provider.Category != "Captcha" {
continue
}
// For "None" rule, skip CAPTCHA
if providerItem.Rule == "None" || providerItem.Rule == "" {
shouldSkipCaptcha = true
} else if providerItem.Rule == "Internet-Only" {
// For Internet-Only rule, check if the client is from intranet
if !util.IsInternetIp(clientIp) {
// Client is from intranet, skip CAPTCHA
shouldSkipCaptcha = true
}
}
break // Only check the first CAPTCHA provider
}
if shouldSkipCaptcha {
c.ResponseOk(Captcha{Type: "none"})
return
}
}
captchaProvider, err := object.GetCaptchaProviderByApplication(applicationId, isCurrentProvider, c.GetAcceptLanguage())
if err != nil {
c.ResponseError(err.Error())

View File

@@ -161,12 +161,13 @@ func (c *ApiController) HandleLoggedIn(application *object.Application, user *ob
nonce := c.Ctx.Input.Query("nonce")
challengeMethod := c.Ctx.Input.Query("code_challenge_method")
codeChallenge := c.Ctx.Input.Query("code_challenge")
resource := c.Ctx.Input.Query("resource")
if challengeMethod != "S256" && challengeMethod != "null" && challengeMethod != "" {
c.ResponseError(c.T("auth:Challenge method should be S256"))
return
}
code, err := object.GetOAuthCode(userId, clientId, form.Provider, form.SigninMethod, responseType, redirectUri, scope, state, nonce, codeChallenge, c.Ctx.Request.Host, c.GetAcceptLanguage())
code, err := object.GetOAuthCode(userId, clientId, form.Provider, form.SigninMethod, responseType, redirectUri, scope, state, nonce, codeChallenge, resource, c.Ctx.Request.Host, c.GetAcceptLanguage())
if err != nil {
c.ResponseError(err.Error(), nil)
return

74
controllers/oauth_dcr.go Normal file
View File

@@ -0,0 +1,74 @@
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package controllers
import (
"encoding/json"
"net/http"
"github.com/casdoor/casdoor/object"
)
// DynamicClientRegister
// @Title DynamicClientRegister
// @Tag OAuth API
// @Description Register a new OAuth 2.0 client dynamically (RFC 7591)
// @Param organization query string false "The organization name (defaults to built-in)"
// @Param body body object.DynamicClientRegistrationRequest true "Client registration request"
// @Success 201 {object} object.DynamicClientRegistrationResponse
// @Failure 400 {object} object.DcrError
// @router /api/oauth/register [post]
func (c *ApiController) DynamicClientRegister() {
var req object.DynamicClientRegistrationRequest
err := json.Unmarshal(c.Ctx.Input.RequestBody, &req)
if err != nil {
c.Ctx.Output.Status = http.StatusBadRequest
c.Data["json"] = object.DcrError{
Error: "invalid_client_metadata",
ErrorDescription: "invalid request body: " + err.Error(),
}
c.ServeJSON()
return
}
// Get organization from query parameter or default to built-in
organization := c.Ctx.Input.Query("organization")
if organization == "" {
organization = "built-in"
}
// Register the client
response, dcrErr, err := object.RegisterDynamicClient(&req, organization)
if err != nil {
c.Ctx.Output.Status = http.StatusInternalServerError
c.Data["json"] = object.DcrError{
Error: "server_error",
ErrorDescription: err.Error(),
}
c.ServeJSON()
return
}
if dcrErr != nil {
c.Ctx.Output.Status = http.StatusBadRequest
c.Data["json"] = dcrErr
c.ServeJSON()
return
}
// Return 201 Created
c.Ctx.Output.Status = http.StatusCreated
c.Data["json"] = response
c.ServeJSON()
}

View File

@@ -176,6 +176,7 @@ func (c *ApiController) GetOAuthToken() {
subjectToken := c.Ctx.Input.Query("subject_token")
subjectTokenType := c.Ctx.Input.Query("subject_token_type")
audience := c.Ctx.Input.Query("audience")
resource := c.Ctx.Input.Query("resource")
if clientId == "" && clientSecret == "" {
clientId, clientSecret, _ = c.Ctx.Request.BasicAuth()
@@ -231,6 +232,9 @@ func (c *ApiController) GetOAuthToken() {
if audience == "" {
audience = tokenRequest.Audience
}
if resource == "" {
resource = tokenRequest.Resource
}
}
}
@@ -275,7 +279,7 @@ func (c *ApiController) GetOAuthToken() {
}
host := c.Ctx.Request.Host
token, err := object.GetOAuthToken(grantType, clientId, clientSecret, code, verifier, scope, nonce, username, password, host, refreshToken, tag, avatar, c.GetAcceptLanguage(), subjectToken, subjectTokenType, audience)
token, err := object.GetOAuthToken(grantType, clientId, clientSecret, code, verifier, scope, nonce, username, password, host, refreshToken, tag, avatar, c.GetAcceptLanguage(), subjectToken, subjectTokenType, audience, resource)
if err != nil {
c.ResponseError(err.Error())
return

View File

@@ -30,4 +30,5 @@ type TokenRequest struct {
SubjectToken string `json:"subject_token"`
SubjectTokenType string `json:"subject_token_type"`
Audience string `json:"audience"`
Resource string `json:"resource"` // RFC 8707 Resource Indicator
}

View File

@@ -151,39 +151,14 @@ func (c *ApiController) SendVerificationCode() {
return
}
provider, err := object.GetCaptchaProviderByApplication(vform.ApplicationId, "false", c.GetAcceptLanguage())
application, err := object.GetApplication(vform.ApplicationId)
if err != nil {
c.ResponseError(err.Error())
return
}
if provider != nil {
if vform.CaptchaType != provider.Type {
c.ResponseError(c.T("verification:Turing test failed."))
return
}
if provider.Type != "Default" {
vform.ClientSecret = provider.ClientSecret
}
if vform.CaptchaType != "none" {
if captchaProvider := captcha.GetCaptchaProvider(vform.CaptchaType); captchaProvider == nil {
c.ResponseError(c.T("general:don't support captchaProvider: ") + vform.CaptchaType)
return
} else if isHuman, err := captchaProvider.VerifyCaptcha(vform.CaptchaToken, provider.ClientId, vform.ClientSecret, provider.ClientId2); err != nil {
c.ResponseError(err.Error())
return
} else if !isHuman {
c.ResponseError(c.T("verification:Turing test failed."))
return
}
}
}
application, err := object.GetApplication(vform.ApplicationId)
if err != nil {
c.ResponseError(err.Error())
if application == nil {
c.ResponseError(fmt.Sprintf(c.T("auth:The application: %s does not exist"), vform.ApplicationId))
return
}
@@ -214,6 +189,7 @@ func (c *ApiController) SendVerificationCode() {
}
var user *object.User
// Try to resolve user for CAPTCHA rule checking
// checkUser != "", means method is ForgetVerification
if vform.CheckUser != "" {
owner := application.Organization
@@ -231,18 +207,86 @@ func (c *ApiController) SendVerificationCode() {
c.ResponseError(c.T("check:The user is forbidden to sign in, please contact the administrator"))
return
}
}
// mfaUserSession != "", means method is MfaAuthVerification
if mfaUserSession := c.getMfaUserSession(); mfaUserSession != "" {
} else if mfaUserSession := c.getMfaUserSession(); mfaUserSession != "" {
// mfaUserSession != "", means method is MfaAuthVerification
user, err = object.GetUser(mfaUserSession)
if err != nil {
c.ResponseError(err.Error())
return
}
} else if vform.Method == ResetVerification {
// For reset verification, get the current logged-in user
user = c.getCurrentUser()
} else if vform.Method == LoginVerification {
// For login verification, try to find user by email/phone for CAPTCHA check
// This is a preliminary lookup; the actual validation happens later in the switch statement
if vform.Type == object.VerifyTypeEmail && util.IsEmailValid(vform.Dest) {
user, err = object.GetUserByEmail(organization.Name, vform.Dest)
if err != nil {
c.ResponseError(err.Error())
return
}
} else if vform.Type == object.VerifyTypePhone {
// Prefer resolving the user directly by phone, consistent with the later login switch,
// so that Dynamic CAPTCHA is not skipped due to missing/invalid country code.
user, err = object.GetUserByPhone(organization.Name, vform.Dest)
if err != nil {
c.ResponseError(err.Error())
return
}
}
}
// Determine username for CAPTCHA check
username := ""
if user != nil {
username = user.Name
} else if vform.CheckUser != "" {
username = vform.CheckUser
}
// Check if CAPTCHA should be enabled based on the rule (Dynamic/Always/Internet-Only)
enableCaptcha, err := object.CheckToEnableCaptcha(application, organization.Name, username, clientIp)
if err != nil {
c.ResponseError(err.Error())
return
}
// Only verify CAPTCHA if it should be enabled
if enableCaptcha {
captchaProvider, err := object.GetCaptchaProviderByApplication(vform.ApplicationId, "false", c.GetAcceptLanguage())
if err != nil {
c.ResponseError(err.Error())
return
}
if captchaProvider != nil {
if vform.CaptchaType != captchaProvider.Type {
c.ResponseError(c.T("verification:Turing test failed."))
return
}
if captchaProvider.Type != "Default" {
vform.ClientSecret = captchaProvider.ClientSecret
}
if vform.CaptchaType != "none" {
if captchaService := captcha.GetCaptchaProvider(vform.CaptchaType); captchaService == nil {
c.ResponseError(c.T("general:don't support captchaProvider: ") + vform.CaptchaType)
return
} else if isHuman, err := captchaService.VerifyCaptcha(vform.CaptchaToken, captchaProvider.ClientId, vform.ClientSecret, captchaProvider.ClientId2); err != nil {
c.ResponseError(err.Error())
return
} else if !isHuman {
c.ResponseError(c.T("verification:Turing test failed."))
return
}
}
}
}
sendResp := errors.New("invalid dest type")
var provider *object.Provider
switch vform.Type {
case object.VerifyTypeEmail:

View File

@@ -0,0 +1,45 @@
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package controllers
import (
"github.com/casdoor/casdoor/object"
)
// GetOauthProtectedResourceMetadata
// @Title GetOauthProtectedResourceMetadata
// @Tag OAuth 2.0 API
// @Description Get OAuth 2.0 Protected Resource Metadata (RFC 9728)
// @Success 200 {object} object.OauthProtectedResourceMetadata
// @router /.well-known/oauth-protected-resource [get]
func (c *RootController) GetOauthProtectedResourceMetadata() {
host := c.Ctx.Request.Host
c.Data["json"] = object.GetOauthProtectedResourceMetadata(host)
c.ServeJSON()
}
// GetOauthProtectedResourceMetadataByApplication
// @Title GetOauthProtectedResourceMetadataByApplication
// @Tag OAuth 2.0 API
// @Description Get OAuth 2.0 Protected Resource Metadata for specific application (RFC 9728)
// @Param application path string true "application name"
// @Success 200 {object} object.OauthProtectedResourceMetadata
// @router /.well-known/:application/oauth-protected-resource [get]
func (c *RootController) GetOauthProtectedResourceMetadataByApplication() {
application := c.Ctx.Input.Param(":application")
host := c.Ctx.Request.Host
c.Data["json"] = object.GetOauthProtectedResourceMetadataByApplication(host, application)
c.ServeJSON()
}

View File

@@ -137,3 +137,29 @@ func (c *RootController) GetWebFingerByApplication() {
c.Ctx.Output.ContentType("application/jrd+json")
c.ServeJSON()
}
// GetOAuthServerMetadata
// @Title GetOAuthServerMetadata
// @Tag OAuth API
// @Description Get OAuth 2.0 Authorization Server Metadata (RFC 8414)
// @Success 200 {object} object.OidcDiscovery
// @router /.well-known/oauth-authorization-server [get]
func (c *RootController) GetOAuthServerMetadata() {
host := c.Ctx.Request.Host
c.Data["json"] = object.GetOidcDiscovery(host, "")
c.ServeJSON()
}
// GetOAuthServerMetadataByApplication
// @Title GetOAuthServerMetadataByApplication
// @Tag OAuth API
// @Description Get OAuth 2.0 Authorization Server Metadata for specific application (RFC 8414)
// @Param application path string true "application name"
// @Success 200 {object} object.OidcDiscovery
// @router /.well-known/:application/oauth-authorization-server [get]
func (c *RootController) GetOAuthServerMetadataByApplication() {
application := c.Ctx.Input.Param(":application")
host := c.Ctx.Request.Host
c.Data["json"] = object.GetOidcDiscovery(host, application)
c.ServeJSON()
}

View File

@@ -18,7 +18,7 @@ type EmailProvider interface {
Send(fromAddress string, fromName string, toAddress []string, subject string, content string) error
}
func GetEmailProvider(typ string, clientId string, clientSecret string, host string, port int, disableSsl bool, endpoint string, method string, httpHeaders map[string]string, bodyMapping map[string]string, contentType string, enableProxy bool) EmailProvider {
func GetEmailProvider(typ string, clientId string, clientSecret string, host string, port int, sslMode string, endpoint string, method string, httpHeaders map[string]string, bodyMapping map[string]string, contentType string, enableProxy bool) EmailProvider {
if typ == "Azure ACS" {
return NewAzureACSEmailProvider(clientSecret, host)
} else if typ == "Custom HTTP Email" {
@@ -26,6 +26,6 @@ func GetEmailProvider(typ string, clientId string, clientSecret string, host str
} else if typ == "SendGrid" {
return NewSendgridEmailProvider(clientSecret, host, endpoint)
} else {
return NewSmtpEmailProvider(clientId, clientSecret, host, port, typ, disableSsl, enableProxy)
return NewSmtpEmailProvider(clientId, clientSecret, host, port, typ, sslMode, enableProxy)
}
}

View File

@@ -25,13 +25,20 @@ type SmtpEmailProvider struct {
Dialer *gomail.Dialer
}
func NewSmtpEmailProvider(userName string, password string, host string, port int, typ string, disableSsl bool, enableProxy bool) *SmtpEmailProvider {
func NewSmtpEmailProvider(userName string, password string, host string, port int, typ string, sslMode string, enableProxy bool) *SmtpEmailProvider {
dialer := gomail.NewDialer(host, port, userName, password)
if typ == "SUBMAIL" {
dialer.TLSConfig = &tls.Config{InsecureSkipVerify: true}
}
dialer.SSL = !disableSsl
// Handle SSL mode: "Auto" (or empty) means don't override gomail's default behavior
// "Enable" means force SSL on, "Disable" means force SSL off
if sslMode == "Enable" {
dialer.SSL = true
} else if sslMode == "Disable" {
dialer.SSL = false
}
// If sslMode is "Auto" or empty, don't set dialer.SSL - let gomail decide based on port
if enableProxy {
socks5Proxy := conf.GetConfigString("socks5Proxy")

7
go.mod
View File

@@ -17,6 +17,7 @@ require (
github.com/aliyun/alibaba-cloud-sdk-go v1.63.107
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 v1.45.5
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
@@ -29,7 +30,6 @@ require (
github.com/casdoor/xorm-adapter/v3 v3.1.0
github.com/casvisor/casvisor-go-sdk v1.4.0
github.com/dchest/captcha v0.0.0-20200903113550-03f5f0333e1f
github.com/denisenkom/go-mssqldb v0.9.0
github.com/elimity-com/scim v0.0.0-20230426070224-941a5eac92f3
github.com/fogleman/gg v1.3.0
github.com/go-asn1-ber/asn1-ber v1.5.5
@@ -49,6 +49,7 @@ require (
github.com/lib/pq v1.10.9
github.com/lor00x/goldap v0.0.0-20180618054307-a546dffdd1a3
github.com/markbates/goth v1.82.0
github.com/microsoft/go-mssqldb v1.9.0
github.com/mitchellh/mapstructure v1.5.0
github.com/nyaruka/phonenumbers v1.2.2
github.com/polarsource/polar-go v0.12.0
@@ -114,7 +115,6 @@ require (
github.com/alibabacloud-go/tea-xml v1.1.3 // 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
github.com/aws/smithy-go v1.24.0 // indirect
github.com/baidubce/bce-sdk-go v0.9.156 // indirect
github.com/beorn7/perks v1.0.1 // indirect
@@ -161,7 +161,8 @@ require (
github.com/go-webauthn/x v0.1.9 // indirect
github.com/goccy/go-json v0.10.3 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe // indirect
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
github.com/golang-sql/sqlexp v0.1.0 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/golang/mock v1.6.0 // indirect

24
go.sum
View File

@@ -627,6 +627,16 @@ gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a h1:lSA0F4e9A2NcQSqGq
gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:EXuID2Zs0pAQhH8yz+DNjUbjppKQzKFAn28TMYPB6IU=
github.com/Azure/azure-pipeline-go v0.2.3 h1:7U9HBg1JFK3jHl5qmo4CTZKFTVgMwdFHMVtCdfBE21U=
github.com/Azure/azure-pipeline-go v0.2.3/go.mod h1:x841ezTBIMG6O3lAcl8ATHnsOPVl2bqk7S3ta6S6u4k=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1/go.mod h1:JdM5psgjfBf5fo2uWOZhflPWyDBZ/O/CNAH9CtsuZE4=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 h1:FPKJS1T+clwv+OLGt13a8UjqeRuh0O4SJ3lUriThc+4=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1/go.mod h1:j2chePtV91HrC22tGoRX3sGY42uF13WzmmV80/OdVAA=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1 h1:Wgf5rZba3YZqeTNJPtvqZoBu1sBN/L4sry+u2U3Y75w=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1/go.mod h1:xxCBG/f/4Vbmh2XQJBsOmNdxWUY5j/s27jujKPbQf14=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1 h1:bFWuoEKg+gImo7pvkiQEFAc8ocibADgXeiLAxWhWmkI=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1/go.mod h1:Vih/3yc6yac2JzU4hzpaDupBJP0Flaia9rXXrU8xyww=
github.com/Azure/azure-storage-blob-go v0.15.0 h1:rXtgp8tN1p29GvpGgfJetavIG0V7OgcSXPpwp3tx6qk=
github.com/Azure/azure-storage-blob-go v0.15.0/go.mod h1:vbjsVbX0dlxnRc4FFMPsS9BsJWPcne7GB7onqlPvz58=
github.com/Azure/go-autorest v14.2.0+incompatible h1:V5VMDjClD3GiElqLWO7mz2MxNAK/vTfRHdAubSIPRgs=
@@ -642,6 +652,8 @@ github.com/Azure/go-autorest/tracing v0.6.0 h1:TYi4+3m5t6K48TGI9AUdb+IzbnSxvnvUM
github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU=
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs=
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
@@ -917,7 +929,6 @@ github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPc
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/denisenkom/go-mssqldb v0.9.0 h1:RSohk2RsiZqLZ0zCjtfn3S4Gp4exhpBWHyQ7D0yGjAk=
github.com/denisenkom/go-mssqldb v0.9.0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=
github.com/dghubble/oauth1 v0.7.3 h1:EkEM/zMDMp3zOsX2DC/ZQ2vnEX3ELK0/l9kb+vs4ptE=
github.com/dghubble/oauth1 v0.7.3/go.mod h1:oxTe+az9NSMIucDPDCCtzJGsPhciJV33xocHfcR2sVY=
@@ -1090,8 +1101,11 @@ github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
@@ -1350,6 +1364,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw=
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o=
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk=
@@ -1414,6 +1430,8 @@ github.com/mattn/go-sqlite3 v1.14.14/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4
github.com/mattn/go-sqlite3 v1.14.27 h1:drZCnuvf37yPfs95E5jd9s3XhdVWLal+6BOK6qrv6IU=
github.com/mattn/go-sqlite3 v1.14.27/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/microsoft/go-mssqldb v1.9.0 h1:5Vq+u2f4LDujJNeZn62Z4kBDEC9MjLv0ukRzOuEuvdA=
github.com/microsoft/go-mssqldb v1.9.0/go.mod h1:GBbW9ASTiDC+mpgWDGKdm3FnFLTUsLYN3iFL90lQ+PA=
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso=
github.com/miekg/dns v1.1.43/go.mod h1:+evo5L0630/F6ca/Z9+GAqzhjGyn8/c+TBaOyfEl0V4=
@@ -1496,6 +1514,8 @@ github.com/pingcap/tidb/parser v0.0.0-20221126021158-6b02a5d8ba7d h1:1DyyRrgYeNj
github.com/pingcap/tidb/parser v0.0.0-20221126021158-6b02a5d8ba7d/go.mod h1:ElJiub4lRy6UZDb+0JHDkGEdr6aOli+ykhyej7VCLoI=
github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4=
github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1-0.20161029093637-248dadf4e906/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=

View File

@@ -86,6 +86,18 @@
{"name": "Homepage", "visible": true, "viewRule": "Public", "modifyRule": "Self"},
{"name": "Bio", "visible": true, "viewRule": "Public", "modifyRule": "Self"},
{"name": "Tag", "visible": true, "viewRule": "Public", "modifyRule": "Admin"},
{"name": "Language", "visible": true, "viewRule": "Public", "modifyRule": "Self"},
{"name": "Gender", "visible": true, "viewRule": "Public", "modifyRule": "Self"},
{"name": "Birthday", "visible": true, "viewRule": "Public", "modifyRule": "Self"},
{"name": "Education", "visible": true, "viewRule": "Public", "modifyRule": "Self"},
{"name": "Balance", "visible": true, "viewRule": "Self", "modifyRule": "Self"},
{"name": "Balance credit", "visible": true, "viewRule": "Self", "modifyRule": "Self"},
{"name": "Balance currency", "visible": true, "viewRule": "Self", "modifyRule": "Self"},
{"name": "Cart", "visible": true, "viewRule": "Self", "modifyRule": "Self"},
{"name": "Transactions", "visible": true, "viewRule": "Self", "modifyRule": "Self"},
{"name": "Score", "visible": true, "viewRule": "Public", "modifyRule": "Self"},
{"name": "Karma", "visible": true, "viewRule": "Public", "modifyRule": "Self"},
{"name": "Ranking", "visible": true, "viewRule": "Public", "modifyRule": "Self"},
{"name": "Signup application", "visible": true, "viewRule": "Public", "modifyRule": "Admin"},
{"name": "Register type", "visible": true, "viewRule": "Public", "modifyRule": "Admin"},
{"name": "Register source", "visible": true, "viewRule": "Public", "modifyRule": "Admin"},

View File

@@ -15,6 +15,7 @@
package mcp
import (
"strings"
"time"
"github.com/casdoor/casdoor/object"
@@ -120,3 +121,58 @@ func (c *McpController) GetAcceptLanguage() string {
}
return language
}
// GetTokenFromRequest extracts the Bearer token from the Authorization header
func (c *McpController) GetTokenFromRequest() string {
authHeader := c.Ctx.Request.Header.Get("Authorization")
if authHeader == "" {
return ""
}
// Extract Bearer token
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
return ""
}
return parts[1]
}
// GetClaimsFromToken parses and validates the JWT token and returns the claims
// Returns nil if no token is present or if token is invalid
func (c *McpController) GetClaimsFromToken() *object.Claims {
tokenString := c.GetTokenFromRequest()
if tokenString == "" {
return nil
}
// Try to find the application for this token
// For MCP, we'll try to parse using the first available application's certificate
// In a production scenario, you might want to use a specific MCP application
token, err := object.GetTokenByAccessToken(tokenString)
if err != nil || token == nil {
return nil
}
application, err := object.GetApplication(token.Application)
if err != nil || application == nil {
return nil
}
claims, err := object.ParseJwtTokenByApplication(tokenString, application)
if err != nil {
return nil
}
return claims
}
// GetScopesFromClaims extracts the scopes from JWT claims and returns them as a slice
func GetScopesFromClaims(claims *object.Claims) []string {
if claims == nil || claims.Scope == "" {
return []string{}
}
// Scopes are space-separated in OAuth 2.0
return strings.Split(claims.Scope, " ")
}

View File

@@ -268,7 +268,160 @@ func (c *McpController) handlePing(req McpRequest) {
}
func (c *McpController) handleToolsList(req McpRequest) {
tools := []McpTool{
allTools := c.getAllTools()
// Get JWT claims from the request
claims := c.GetClaimsFromToken()
// If no token is present, check session authentication
if claims == nil {
username := c.GetSessionUsername()
// If user is authenticated via session, return all tools (backward compatibility)
if username != "" {
result := McpListToolsResult{
Tools: allTools,
}
c.McpResponseOk(req.ID, result)
return
}
// Unauthenticated request - return all tools for discovery
// This allows clients to see what tools are available before authenticating
result := McpListToolsResult{
Tools: allTools,
}
c.McpResponseOk(req.ID, result)
return
}
// Token-based authentication - filter tools by scopes
grantedScopes := GetScopesFromClaims(claims)
allowedTools := GetToolsForScopes(grantedScopes, BuiltinScopes)
// Filter tools based on allowed scopes
var filteredTools []McpTool
for _, tool := range allTools {
if allowedTools[tool.Name] {
filteredTools = append(filteredTools, tool)
}
}
result := McpListToolsResult{
Tools: filteredTools,
}
c.McpResponseOk(req.ID, result)
}
func (c *McpController) handleToolsCall(req McpRequest) {
var params McpCallToolParams
err := json.Unmarshal(req.Params, &params)
if err != nil {
c.sendInvalidParamsError(req.ID, err.Error())
return
}
// Check scope-tool permission
if !c.checkToolPermission(req.ID, params.Name) {
return // Error already sent by checkToolPermission
}
// Route to the appropriate tool handler
switch params.Name {
case "get_applications":
var args GetApplicationsArgs
if err := json.Unmarshal(params.Arguments, &args); err != nil {
c.sendInvalidParamsError(req.ID, err.Error())
return
}
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(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(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(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(req.ID, args)
default:
c.McpResponseError(req.ID, -32602, "Invalid tool name", fmt.Sprintf("Tool '%s' not found", params.Name))
}
}
// checkToolPermission validates that the current token has the required scope for the tool
// Returns false and sends an error response if permission is denied
func (c *McpController) checkToolPermission(id interface{}, toolName string) bool {
// Get JWT claims from the request
claims := c.GetClaimsFromToken()
// If no token is present, check if the user is authenticated via session
if claims == nil {
username := c.GetSessionUsername()
// If user is authenticated via session (e.g., session cookie), allow access
// This maintains backward compatibility with existing session-based auth
if username != "" {
return true
}
// No authentication present - deny access
c.sendInsufficientScopeError(id, toolName, []string{})
return false
}
// Extract scopes from claims
grantedScopes := GetScopesFromClaims(claims)
// Get allowed tools for the granted scopes
allowedTools := GetToolsForScopes(grantedScopes, BuiltinScopes)
// Check if the requested tool is allowed
if !allowedTools[toolName] {
c.sendInsufficientScopeError(id, toolName, grantedScopes)
return false
}
return true
}
// sendInsufficientScopeError sends an error response for insufficient scope
func (c *McpController) sendInsufficientScopeError(id interface{}, toolName string, grantedScopes []string) {
// Find required scope for this tool
requiredScope := GetRequiredScopeForTool(toolName, BuiltinScopes)
errorData := map[string]interface{}{
"tool": toolName,
"granted_scopes": grantedScopes,
}
if requiredScope != "" {
errorData["required_scope"] = requiredScope
}
c.McpResponseError(id, -32001, "insufficient_scope", errorData)
}
// getAllTools returns all available MCP tools
func (c *McpController) getAllTools() []McpTool {
return []McpTool{
{
Name: "get_applications",
Description: "Get all applications for a specific owner",
@@ -344,60 +497,4 @@ func (c *McpController) handleToolsList(req McpRequest) {
},
},
}
result := McpListToolsResult{
Tools: tools,
}
c.McpResponseOk(req.ID, result)
}
func (c *McpController) handleToolsCall(req McpRequest) {
var params McpCallToolParams
err := json.Unmarshal(req.Params, &params)
if err != nil {
c.sendInvalidParamsError(req.ID, err.Error())
return
}
// Route to the appropriate tool handler
switch params.Name {
case "get_applications":
var args GetApplicationsArgs
if err := json.Unmarshal(params.Arguments, &args); err != nil {
c.sendInvalidParamsError(req.ID, err.Error())
return
}
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(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(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(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(req.ID, args)
default:
c.McpResponseError(req.ID, -32602, "Invalid tool name", fmt.Sprintf("Tool '%s' not found", params.Name))
}
}

158
mcp/permission.go Normal file
View File

@@ -0,0 +1,158 @@
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package mcp
import (
"github.com/casdoor/casdoor/object"
)
// BuiltinScopes defines the default scope-to-tool mappings for Casdoor's MCP server
var BuiltinScopes = []*object.ScopeItem{
{
Name: "application:read",
DisplayName: "Read Applications",
Description: "View application list and details",
Tools: []string{"get_applications", "get_application"},
},
{
Name: "application:write",
DisplayName: "Manage Applications",
Description: "Create, update, and delete applications",
Tools: []string{"add_application", "update_application", "delete_application"},
},
{
Name: "user:read",
DisplayName: "Read Users",
Description: "View user list and details",
Tools: []string{"get_users", "get_user"},
},
{
Name: "user:write",
DisplayName: "Manage Users",
Description: "Create, update, and delete users",
Tools: []string{"add_user", "update_user", "delete_user"},
},
{
Name: "organization:read",
DisplayName: "Read Organizations",
Description: "View organization list and details",
Tools: []string{"get_organizations", "get_organization"},
},
{
Name: "organization:write",
DisplayName: "Manage Organizations",
Description: "Create, update, and delete organizations",
Tools: []string{"add_organization", "update_organization", "delete_organization"},
},
{
Name: "permission:read",
DisplayName: "Read Permissions",
Description: "View permission list and details",
Tools: []string{"get_permissions", "get_permission"},
},
{
Name: "permission:write",
DisplayName: "Manage Permissions",
Description: "Create, update, and delete permissions",
Tools: []string{"add_permission", "update_permission", "delete_permission"},
},
{
Name: "role:read",
DisplayName: "Read Roles",
Description: "View role list and details",
Tools: []string{"get_roles", "get_role"},
},
{
Name: "role:write",
DisplayName: "Manage Roles",
Description: "Create, update, and delete roles",
Tools: []string{"add_role", "update_role", "delete_role"},
},
{
Name: "provider:read",
DisplayName: "Read Providers",
Description: "View provider list and details",
Tools: []string{"get_providers", "get_provider"},
},
{
Name: "provider:write",
DisplayName: "Manage Providers",
Description: "Create, update, and delete providers",
Tools: []string{"add_provider", "update_provider", "delete_provider"},
},
{
Name: "token:read",
DisplayName: "Read Tokens",
Description: "View token list and details",
Tools: []string{"get_tokens", "get_token"},
},
{
Name: "token:write",
DisplayName: "Manage Tokens",
Description: "Delete tokens",
Tools: []string{"delete_token"},
},
}
// ConvenienceScopes defines alias scopes that expand to multiple resource scopes
var ConvenienceScopes = map[string][]string{
"read": {"application:read", "user:read", "organization:read", "permission:read", "role:read", "provider:read", "token:read"},
"write": {"application:write", "user:write", "organization:write", "permission:write", "role:write", "provider:write", "token:write"},
"admin": {"application:read", "application:write", "user:read", "user:write", "organization:read", "organization:write", "permission:read", "permission:write", "role:read", "role:write", "provider:read", "provider:write", "token:read", "token:write"},
}
// GetToolsForScopes returns a map of tools allowed by the given scopes
// The grantedScopes are the scopes present in the token
// The registry contains the scope-to-tool mappings (either BuiltinScopes or Application.Scopes)
func GetToolsForScopes(grantedScopes []string, registry []*object.ScopeItem) map[string]bool {
allowed := make(map[string]bool)
// Expand convenience scopes first
expandedScopes := make([]string, 0)
for _, scopeName := range grantedScopes {
if expansion, isConvenience := ConvenienceScopes[scopeName]; isConvenience {
expandedScopes = append(expandedScopes, expansion...)
} else {
expandedScopes = append(expandedScopes, scopeName)
}
}
// Map scopes to tools
for _, scopeName := range expandedScopes {
for _, item := range registry {
if item.Name == scopeName {
for _, tool := range item.Tools {
allowed[tool] = true
}
break
}
}
}
return allowed
}
// GetRequiredScopeForTool returns the first scope that provides access to the given tool
// Returns an empty string if no scope is found for the tool
func GetRequiredScopeForTool(toolName string, registry []*object.ScopeItem) string {
for _, scopeItem := range registry {
for _, tool := range scopeItem.Tools {
if tool == toolName {
return scopeItem.Name
}
}
}
return ""
}

View File

@@ -61,9 +61,17 @@ type SamlItem struct {
}
type JwtItem struct {
Name string `json:"name"`
Value string `json:"value"`
Type string `json:"type"`
Name string `json:"name"`
Category string `json:"category"`
Value string `json:"value"`
Type string `json:"type"`
}
type ScopeItem struct {
Name string `json:"name"`
DisplayName string `json:"displayName"`
Description string `json:"description"`
Tools []string `json:"tools"` // MCP tools allowed by this scope
}
type Application struct {
@@ -72,6 +80,9 @@ type Application struct {
CreatedTime string `xorm:"varchar(100)" json:"createdTime"`
DisplayName string `xorm:"varchar(100)" json:"displayName"`
Category string `xorm:"varchar(20)" json:"category"`
Type string `xorm:"varchar(20)" json:"type"`
Scopes []*ScopeItem `xorm:"mediumtext" json:"scopes"`
Logo string `xorm:"varchar(200)" json:"logo"`
Title string `xorm:"varchar(100)" json:"title"`
Favicon string `xorm:"varchar(200)" json:"favicon"`

View File

@@ -20,7 +20,8 @@ import "github.com/casdoor/casdoor/email"
// TestSmtpServer Test the SMTP server
func TestSmtpServer(provider *Provider) error {
smtpEmailProvider := email.NewSmtpEmailProvider(provider.ClientId, provider.ClientSecret, provider.Host, provider.Port, provider.Type, provider.DisableSsl, provider.EnableProxy)
sslMode := getSslMode(provider)
smtpEmailProvider := email.NewSmtpEmailProvider(provider.ClientId, provider.ClientSecret, provider.Host, provider.Port, provider.Type, sslMode, provider.EnableProxy)
sender, err := smtpEmailProvider.Dialer.Dial()
if err != nil {
return err
@@ -31,7 +32,8 @@ func TestSmtpServer(provider *Provider) error {
}
func SendEmail(provider *Provider, title string, content string, dest []string, sender string) error {
emailProvider := email.GetEmailProvider(provider.Type, provider.ClientId, provider.ClientSecret, provider.Host, provider.Port, provider.DisableSsl, provider.Endpoint, provider.Method, provider.HttpHeaders, provider.UserMapping, provider.IssuerUrl, provider.EnableProxy)
sslMode := getSslMode(provider)
emailProvider := email.GetEmailProvider(provider.Type, provider.ClientId, provider.ClientSecret, provider.Host, provider.Port, sslMode, provider.Endpoint, provider.Method, provider.HttpHeaders, provider.UserMapping, provider.IssuerUrl, provider.EnableProxy)
fromAddress := provider.ClientId2
if fromAddress == "" {
@@ -45,3 +47,19 @@ func SendEmail(provider *Provider, title string, content string, dest []string,
return emailProvider.Send(fromAddress, fromName, dest, title, content)
}
// getSslMode returns the SSL mode for the provider, with backward compatibility for DisableSsl
func getSslMode(provider *Provider) string {
// If SslMode is set, use it
if provider.SslMode != "" {
return provider.SslMode
}
// Backward compatibility: convert DisableSsl to SslMode
if provider.DisableSsl {
return "Disable"
}
// Default to "Auto" for new configurations or when DisableSsl is false
return "Auto"
}

View File

@@ -15,6 +15,7 @@
package object
import (
"fmt"
"sync"
"time"
@@ -49,7 +50,12 @@ func GetDashboard(owner string) (*map[string][]int64, error) {
dashboard[tableName+"Counts"] = make([]int64, 31)
tableFullName := tableNamePrefix + tableName
go func(ch chan error) {
defer wg.Done()
defer func() {
if r := recover(); r != nil {
ch <- fmt.Errorf("panic in dashboard goroutine: %v", r)
}
wg.Done()
}()
dashboardDateItems := []DashboardDateItem{}
var countResult int64

View File

@@ -72,6 +72,18 @@ func getBuiltInAccountItems() []*AccountItem {
{Name: "Homepage", Visible: true, ViewRule: "Public", ModifyRule: "Self"},
{Name: "Bio", Visible: true, ViewRule: "Public", ModifyRule: "Self"},
{Name: "Tag", Visible: true, ViewRule: "Public", ModifyRule: "Admin"},
{Name: "Language", Visible: true, ViewRule: "Public", ModifyRule: "Self"},
{Name: "Gender", Visible: true, ViewRule: "Public", ModifyRule: "Self"},
{Name: "Birthday", Visible: true, ViewRule: "Public", ModifyRule: "Self"},
{Name: "Education", Visible: true, ViewRule: "Public", ModifyRule: "Self"},
{Name: "Balance", Visible: true, ViewRule: "Self", ModifyRule: "Self"},
{Name: "Balance credit", Visible: true, ViewRule: "Self", ModifyRule: "Self"},
{Name: "Balance currency", Visible: true, ViewRule: "Self", ModifyRule: "Self"},
{Name: "Cart", Visible: true, ViewRule: "Self", ModifyRule: "Self"},
{Name: "Transactions", Visible: true, ViewRule: "Self", ModifyRule: "Self"},
{Name: "Score", Visible: true, ViewRule: "Public", ModifyRule: "Self"},
{Name: "Karma", Visible: true, ViewRule: "Public", ModifyRule: "Self"},
{Name: "Ranking", Visible: true, ViewRule: "Public", ModifyRule: "Self"},
{Name: "Signup application", Visible: true, ViewRule: "Public", ModifyRule: "Admin"},
{Name: "Register type", Visible: true, ViewRule: "Public", ModifyRule: "Admin"},
{Name: "Register source", Visible: true, ViewRule: "Public", ModifyRule: "Admin"},
@@ -120,6 +132,7 @@ func initBuiltInOrganization() bool {
IsProfilePublic: false,
UseEmailAsUsername: false,
EnableTour: true,
DcrPolicy: "open",
}
_, err = AddOrganization(organization)
if err != nil {
@@ -185,6 +198,9 @@ func initBuiltInApplication() {
Name: "app-built-in",
CreatedTime: util.GetCurrentTime(),
DisplayName: "Casdoor",
Category: "Default",
Type: "All",
Scopes: []*ScopeItem{},
Logo: fmt.Sprintf("%s/img/casdoor-logo_1185x256.png", conf.GetConfigString("staticBaseUrl")),
HomepageUrl: "https://casdoor.org",
Organization: "built-in",

View File

@@ -37,8 +37,9 @@ type Ldap struct {
PasswordType string `xorm:"varchar(100)" json:"passwordType"`
CustomAttributes map[string]string `json:"customAttributes"`
AutoSync int `json:"autoSync"`
LastSync string `xorm:"varchar(100)" json:"lastSync"`
AutoSync int `json:"autoSync"`
LastSync string `xorm:"varchar(100)" json:"lastSync"`
EnableGroups bool `xorm:"bool" json:"enableGroups"`
}
func AddLdap(ldap *Ldap) (bool, error) {
@@ -152,7 +153,7 @@ func UpdateLdap(ldap *Ldap) (bool, error) {
}
affected, err := ormer.Engine.ID(ldap.Id).Cols("owner", "server_name", "host",
"port", "enable_ssl", "username", "password", "base_dn", "filter", "filter_fields", "auto_sync", "default_group", "password_type", "allow_self_signed_cert", "custom_attributes").Update(ldap)
"port", "enable_ssl", "username", "password", "base_dn", "filter", "filter_fields", "auto_sync", "default_group", "password_type", "allow_self_signed_cert", "custom_attributes", "enable_groups").Update(ldap)
if err != nil {
return false, nil
}

View File

@@ -91,13 +91,28 @@ func (l *LdapAutoSynchronizer) syncRoutine(ldap *Ldap, stopChan chan struct{}) e
return err
}
// fetch all users
// fetch all users and groups
conn, err := ldap.GetLdapConn()
if err != nil {
logs.Warning(fmt.Sprintf("autoSync failed for %s, error %s", ldap.Id, err))
continue
}
// Sync groups first if enabled (so they exist before assigning users)
if ldap.EnableGroups {
groups, err := conn.GetLdapGroups(ldap)
if err != nil {
logs.Warning(fmt.Sprintf("autoSync failed to fetch groups for %s, error %s", ldap.Id, err))
} else {
newGroups, updatedGroups, err := SyncLdapGroups(ldap.Owner, groups, ldap.Id)
if err != nil {
logs.Warning(fmt.Sprintf("autoSync failed to sync groups for %s, error %s", ldap.Id, err))
} else {
logs.Info(fmt.Sprintf("ldap group sync success for %s, %d new groups, %d updated groups", ldap.Id, newGroups, updatedGroups))
}
}
}
users, err := conn.GetLdapUsers(ldap)
if err != nil {
conn.Close()

View File

@@ -87,10 +87,19 @@ type LdapUser struct {
GroupId string `json:"groupId"`
Address string `json:"address"`
MemberOf string `json:"memberOf"`
MemberOf []string `json:"memberOf"`
Attributes map[string]string `json:"attributes"`
}
type LdapGroup struct {
Dn string `json:"dn"`
Cn string `json:"cn"`
Name string `json:"name"`
Description string `json:"description"`
Member []string `json:"member"`
ParentDn string `json:"parentDn"`
}
func (ldap *Ldap) GetLdapConn() (c *LdapConn, err error) {
var conn *goldap.Conn
tlsConfig := tls.Config{
@@ -179,7 +188,7 @@ func (l *LdapConn) GetLdapUsers(ldapServer *Ldap) ([]LdapUser, error) {
SearchAttributes := []string{
"uidNumber", "cn", "sn", "gidNumber", "entryUUID", "displayName", "mail", "email",
"emailAddress", "telephoneNumber", "mobile", "mobileTelephoneNumber", "registeredAddress", "postalAddress",
"c", "co",
"c", "co", "memberOf",
}
if l.IsAD {
SearchAttributes = append(SearchAttributes, "sAMAccountName")
@@ -247,7 +256,7 @@ func (l *LdapConn) GetLdapUsers(ldapServer *Ldap) ([]LdapUser, error) {
case "co":
user.CountryName = attribute.Values[0]
case "memberOf":
user.MemberOf = attribute.Values[0]
user.MemberOf = attribute.Values
default:
if propName, ok := ldapServer.CustomAttributes[attribute.Name]; ok {
if user.Attributes == nil {
@@ -263,42 +272,135 @@ func (l *LdapConn) GetLdapUsers(ldapServer *Ldap) ([]LdapUser, error) {
return ldapUsers, nil
}
// FIXME: The Base DN does not necessarily contain the Group
//
// func (l *ldapConn) GetLdapGroups(baseDn string) (map[string]ldapGroup, error) {
// SearchFilter := "(objectClass=posixGroup)"
// SearchAttributes := []string{"cn", "gidNumber"}
// groupMap := make(map[string]ldapGroup)
//
// searchReq := goldap.NewSearchRequest(baseDn,
// goldap.ScopeWholeSubtree, goldap.NeverDerefAliases, 0, 0, false,
// SearchFilter, SearchAttributes, nil)
// searchResult, err := l.Conn.Search(searchReq)
// if err != nil {
// return nil, err
// }
//
// if len(searchResult.Entries) == 0 {
// return nil, errors.New("no result")
// }
//
// for _, entry := range searchResult.Entries {
// var ldapGroupItem ldapGroup
// for _, attribute := range entry.Attributes {
// switch attribute.Name {
// case "gidNumber":
// ldapGroupItem.GidNumber = attribute.Values[0]
// break
// case "cn":
// ldapGroupItem.Cn = attribute.Values[0]
// break
// }
// }
// groupMap[ldapGroupItem.GidNumber] = ldapGroupItem
// }
//
// return groupMap, nil
// }
// GetLdapGroups fetches LDAP groups and organizational units
func (l *LdapConn) GetLdapGroups(ldapServer *Ldap) ([]LdapGroup, error) {
var allGroups []LdapGroup
// Search for LDAP groups (groupOfNames, groupOfUniqueNames, posixGroup)
groupFilters := []string{
"(objectClass=groupOfNames)",
"(objectClass=groupOfUniqueNames)",
"(objectClass=posixGroup)",
}
// Add Active Directory group filter
if l.IsAD {
groupFilters = append(groupFilters, "(objectClass=group)")
}
// Build combined filter
var filterBuilder strings.Builder
filterBuilder.WriteString("(|")
for _, filter := range groupFilters {
filterBuilder.WriteString(filter)
}
filterBuilder.WriteString(")")
SearchAttributes := []string{"cn", "name", "description", "member", "uniqueMember", "memberUid"}
searchReq := goldap.NewSearchRequest(ldapServer.BaseDn,
goldap.ScopeWholeSubtree, goldap.NeverDerefAliases, 0, 0, false,
filterBuilder.String(), SearchAttributes, nil)
searchResult, err := l.Conn.SearchWithPaging(searchReq, 100)
if err != nil {
// Groups might not exist, which is okay
return allGroups, nil
}
for _, entry := range searchResult.Entries {
group := LdapGroup{
Dn: entry.DN,
}
for _, attribute := range entry.Attributes {
switch attribute.Name {
case "cn":
group.Cn = attribute.Values[0]
case "name":
group.Name = attribute.Values[0]
case "description":
if len(attribute.Values) > 0 {
group.Description = attribute.Values[0]
}
case "member", "uniqueMember", "memberUid":
group.Member = append(group.Member, attribute.Values...)
}
}
// Use cn as name if name is not set
if group.Name == "" {
group.Name = group.Cn
}
// Parse parent DN from the entry DN
group.ParentDn = getParentDn(entry.DN)
allGroups = append(allGroups, group)
}
// Also fetch organizational units as groups
ouFilter := "(objectClass=organizationalUnit)"
ouSearchReq := goldap.NewSearchRequest(ldapServer.BaseDn,
goldap.ScopeWholeSubtree, goldap.NeverDerefAliases, 0, 0, false,
ouFilter, []string{"ou", "description"}, nil)
ouSearchResult, err := l.Conn.SearchWithPaging(ouSearchReq, 100)
if err == nil {
for _, entry := range ouSearchResult.Entries {
ou := LdapGroup{
Dn: entry.DN,
}
for _, attribute := range entry.Attributes {
switch attribute.Name {
case "ou":
ou.Name = attribute.Values[0]
ou.Cn = attribute.Values[0]
case "description":
if len(attribute.Values) > 0 {
ou.Description = attribute.Values[0]
}
}
}
// Parse parent DN from the entry DN
ou.ParentDn = getParentDn(entry.DN)
allGroups = append(allGroups, ou)
}
}
return allGroups, nil
}
// getParentDn extracts the parent DN from a full DN
func getParentDn(dn string) string {
// Split DN by comma
parts := strings.Split(dn, ",")
if len(parts) <= 1 {
return ""
}
// Remove the first component (the current node) and rejoin
return strings.Join(parts[1:], ",")
}
// parseDnToGroupName converts a DN to a group name
func parseDnToGroupName(dn string) string {
// Extract the CN or OU from the DN
parts := strings.Split(dn, ",")
if len(parts) == 0 {
return ""
}
firstPart := parts[0]
// Extract value after = sign
if idx := strings.Index(firstPart, "="); idx != -1 {
return firstPart[idx+1:]
}
return firstPart
}
func AutoAdjustLdapUser(users []LdapUser) []LdapUser {
res := make([]LdapUser, len(users))
@@ -315,6 +417,7 @@ func AutoAdjustLdapUser(users []LdapUser) []LdapUser {
Address: util.ReturnAnyNotEmpty(user.Address, user.PostalAddress, user.RegisteredAddress),
Country: util.ReturnAnyNotEmpty(user.Country, user.CountryName),
CountryName: user.CountryName,
MemberOf: user.MemberOf,
Attributes: user.Attributes,
}
}
@@ -398,8 +501,22 @@ func SyncLdapUsers(owner string, syncUsers []LdapUser, ldapId string) (existUser
}
formatUserPhone(newUser)
// Assign user to groups based on memberOf attribute
userGroups := []string{}
if ldap.DefaultGroup != "" {
newUser.Groups = []string{ldap.DefaultGroup}
userGroups = append(userGroups, ldap.DefaultGroup)
}
// Extract group names from memberOf DNs
for _, memberDn := range syncUser.MemberOf {
groupName := dnToGroupName(owner, memberDn)
if groupName != "" {
userGroups = append(userGroups, groupName)
}
}
if len(userGroups) > 0 {
newUser.Groups = userGroups
}
affected, err := AddUser(newUser, "en")
@@ -420,6 +537,179 @@ func SyncLdapUsers(owner string, syncUsers []LdapUser, ldapId string) (existUser
return existUsers, failedUsers, err
}
// SyncLdapGroups syncs LDAP groups/OUs to Casdoor groups with hierarchy
func SyncLdapGroups(owner string, ldapGroups []LdapGroup, ldapId string) (newGroups int, updatedGroups int, err error) {
if len(ldapGroups) == 0 {
return 0, 0, nil
}
// Create a map of DN to group for quick lookup
dnToGroup := make(map[string]*LdapGroup)
for i := range ldapGroups {
dnToGroup[ldapGroups[i].Dn] = &ldapGroups[i]
}
// Get existing groups for this organization
existingGroups, err := GetGroups(owner)
if err != nil {
return 0, 0, err
}
existingGroupMap := make(map[string]*Group)
for _, group := range existingGroups {
existingGroupMap[group.Name] = group
}
ldap, err := GetLdap(ldapId)
if err != nil {
return 0, 0, err
}
// Process groups in hierarchical order (parents before children)
processedGroups := make(map[string]bool)
var processGroup func(ldapGroup *LdapGroup) error
processGroup = func(ldapGroup *LdapGroup) error {
if processedGroups[ldapGroup.Dn] {
return nil
}
// Generate group name from DN
groupName := dnToGroupName(owner, ldapGroup.Dn)
if groupName == "" {
return nil
}
// Determine parent
var parentId string
var isTopGroup bool
if ldapGroup.ParentDn == "" || ldapGroup.ParentDn == ldap.BaseDn {
isTopGroup = true
parentId = ""
} else {
// Process parent first
if parentGroup, exists := dnToGroup[ldapGroup.ParentDn]; exists {
err := processGroup(parentGroup)
if err != nil {
return err
}
parentId = dnToGroupName(owner, ldapGroup.ParentDn)
} else {
isTopGroup = true
}
}
// Check if group already exists
if existingGroup, exists := existingGroupMap[groupName]; exists {
// Update existing group
existingGroup.DisplayName = ldapGroup.Name
existingGroup.ParentId = parentId
existingGroup.IsTopGroup = isTopGroup
existingGroup.Type = "ldap-synced"
existingGroup.UpdatedTime = util.GetCurrentTime()
_, err := UpdateGroup(existingGroup.GetId(), existingGroup)
if err == nil {
updatedGroups++
}
} else {
// Create new group
newGroup := &Group{
Owner: owner,
Name: groupName,
CreatedTime: util.GetCurrentTime(),
UpdatedTime: util.GetCurrentTime(),
DisplayName: ldapGroup.Name,
ParentId: parentId,
IsTopGroup: isTopGroup,
Type: "ldap-synced",
IsEnabled: true,
}
_, err := AddGroup(newGroup)
if err == nil {
newGroups++
existingGroupMap[groupName] = newGroup
}
}
processedGroups[ldapGroup.Dn] = true
return nil
}
// Process all groups
for i := range ldapGroups {
err := processGroup(&ldapGroups[i])
if err != nil {
// Log error but continue processing other groups
continue
}
}
return newGroups, updatedGroups, nil
}
// dnToGroupName converts an LDAP DN to a Casdoor group name
func dnToGroupName(owner, dn string) string {
if dn == "" {
return ""
}
// Parse DN to extract meaningful components
parts := strings.Split(dn, ",")
// Build a hierarchical name from DN components (excluding DC parts)
var nameComponents []string
for _, part := range parts {
part = strings.TrimSpace(part)
lowerPart := strings.ToLower(part)
// Skip DC (domain component) parts
if strings.HasPrefix(lowerPart, "dc=") {
continue
}
// Extract value after = sign
if idx := strings.Index(part, "="); idx != -1 {
value := part[idx+1:]
nameComponents = append(nameComponents, value)
}
}
if len(nameComponents) == 0 {
return ""
}
// Reverse to get top-down hierarchy
for i, j := 0, len(nameComponents)-1; i < j; i, j = i+1, j-1 {
nameComponents[i], nameComponents[j] = nameComponents[j], nameComponents[i]
}
// Join with underscore to create a unique group name
groupName := strings.Join(nameComponents, "_")
// Sanitize group name - replace invalid characters with underscores
// Keep only alphanumeric characters, underscores, and hyphens
var sanitized strings.Builder
for _, r := range groupName {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' {
sanitized.WriteRune(r)
} else {
sanitized.WriteRune('_')
}
}
groupName = sanitized.String()
// Remove consecutive underscores and trim
for strings.Contains(groupName, "__") {
groupName = strings.ReplaceAll(groupName, "__", "_")
}
groupName = strings.Trim(groupName, "_")
return groupName
}
func GetExistUuids(owner string, uuids []string) ([]string, error) {
var existUuids []string

193
object/oauth_dcr.go Normal file
View File

@@ -0,0 +1,193 @@
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package object
import (
"fmt"
"time"
"github.com/casdoor/casdoor/util"
)
// DynamicClientRegistrationRequest represents an RFC 7591 client registration request
type DynamicClientRegistrationRequest struct {
ClientName string `json:"client_name,omitempty"`
RedirectUris []string `json:"redirect_uris,omitempty"`
GrantTypes []string `json:"grant_types,omitempty"`
ResponseTypes []string `json:"response_types,omitempty"`
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`
ApplicationType string `json:"application_type,omitempty"`
Contacts []string `json:"contacts,omitempty"`
LogoUri string `json:"logo_uri,omitempty"`
ClientUri string `json:"client_uri,omitempty"`
PolicyUri string `json:"policy_uri,omitempty"`
TosUri string `json:"tos_uri,omitempty"`
Scope string `json:"scope,omitempty"`
}
// DynamicClientRegistrationResponse represents an RFC 7591 client registration response
type DynamicClientRegistrationResponse struct {
ClientId string `json:"client_id"`
ClientSecret string `json:"client_secret,omitempty"`
ClientIdIssuedAt int64 `json:"client_id_issued_at,omitempty"`
ClientSecretExpiresAt int64 `json:"client_secret_expires_at,omitempty"`
ClientName string `json:"client_name,omitempty"`
RedirectUris []string `json:"redirect_uris,omitempty"`
GrantTypes []string `json:"grant_types,omitempty"`
ResponseTypes []string `json:"response_types,omitempty"`
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`
ApplicationType string `json:"application_type,omitempty"`
Contacts []string `json:"contacts,omitempty"`
LogoUri string `json:"logo_uri,omitempty"`
ClientUri string `json:"client_uri,omitempty"`
PolicyUri string `json:"policy_uri,omitempty"`
TosUri string `json:"tos_uri,omitempty"`
Scope string `json:"scope,omitempty"`
RegistrationClientUri string `json:"registration_client_uri,omitempty"`
RegistrationAccessToken string `json:"registration_access_token,omitempty"`
}
// DcrError represents an RFC 7591 error response
type DcrError struct {
Error string `json:"error"`
ErrorDescription string `json:"error_description,omitempty"`
}
// RegisterDynamicClient creates a new application based on DCR request
func RegisterDynamicClient(req *DynamicClientRegistrationRequest, organization string) (*DynamicClientRegistrationResponse, *DcrError, error) {
// Validate organization exists and has DCR enabled
org, err := GetOrganization(util.GetId("admin", organization))
if err != nil {
return nil, nil, err
}
if org == nil {
return nil, &DcrError{
Error: "invalid_client_metadata",
ErrorDescription: "organization not found",
}, nil
}
// Check if DCR is enabled for this organization
if org.DcrPolicy == "" || org.DcrPolicy == "disabled" {
return nil, &DcrError{
Error: "invalid_client_metadata",
ErrorDescription: "dynamic client registration is disabled for this organization",
}, nil
}
// Validate required fields
if len(req.RedirectUris) == 0 {
return nil, &DcrError{
Error: "invalid_redirect_uri",
ErrorDescription: "redirect_uris is required and must contain at least one URI",
}, nil
}
// Set defaults
if req.ClientName == "" {
clientIdPrefix := util.GenerateClientId()
if len(clientIdPrefix) > 8 {
clientIdPrefix = clientIdPrefix[:8]
}
req.ClientName = fmt.Sprintf("DCR Client %s", clientIdPrefix)
}
if len(req.GrantTypes) == 0 {
req.GrantTypes = []string{"authorization_code"}
}
if len(req.ResponseTypes) == 0 {
req.ResponseTypes = []string{"code"}
}
if req.TokenEndpointAuthMethod == "" {
req.TokenEndpointAuthMethod = "client_secret_basic"
}
if req.ApplicationType == "" {
req.ApplicationType = "web"
}
// Generate unique application name
randomName := util.GetRandomName()
appName := fmt.Sprintf("dcr_%s", randomName)
// Create Application object
// Note: DCR applications are created under "admin" owner by default
// This can be made configurable in future versions
clientId := util.GenerateClientId()
clientSecret := util.GenerateClientSecret()
createdTime := util.GetCurrentTime()
application := &Application{
Owner: "admin",
Name: appName,
Organization: organization,
CreatedTime: createdTime,
DisplayName: req.ClientName,
Category: "Agent",
Type: "MCP",
Scopes: []*ScopeItem{},
Logo: req.LogoUri,
HomepageUrl: req.ClientUri,
ClientId: clientId,
ClientSecret: clientSecret,
RedirectUris: req.RedirectUris,
GrantTypes: req.GrantTypes,
EnablePassword: false,
EnableSignUp: false,
DisableSignin: false,
EnableSigninSession: false,
EnableCodeSignin: true,
EnableAutoSignin: false,
TokenFormat: "JWT",
ExpireInHours: 168,
RefreshExpireInHours: 168,
CookieExpireInHours: 720,
FormOffset: 2,
Tags: []string{"dcr"},
TermsOfUse: req.TosUri,
}
// Add the application
affected, err := AddApplication(application)
if err != nil {
return nil, nil, err
}
if !affected {
return nil, &DcrError{
Error: "invalid_client_metadata",
ErrorDescription: "failed to create client application",
}, nil
}
// Build response
response := &DynamicClientRegistrationResponse{
ClientId: clientId,
ClientSecret: clientSecret,
ClientIdIssuedAt: time.Now().Unix(),
ClientSecretExpiresAt: 0, // Never expires
ClientName: req.ClientName,
RedirectUris: req.RedirectUris,
GrantTypes: req.GrantTypes,
ResponseTypes: req.ResponseTypes,
TokenEndpointAuthMethod: req.TokenEndpointAuthMethod,
ApplicationType: req.ApplicationType,
Contacts: req.Contacts,
LogoUri: req.LogoUri,
ClientUri: req.ClientUri,
PolicyUri: req.PolicyUri,
TosUri: req.TosUri,
Scope: req.Scope,
}
return response, nil, nil
}

View File

@@ -49,6 +49,7 @@ type Order struct {
type ProductInfo struct {
Owner string `json:"owner"`
Name string `json:"name"`
CreatedTime string `json:"createdTime,omitempty"`
DisplayName string `json:"displayName"`
Image string `json:"image,omitempty"`
Detail string `json:"detail,omitempty"`

View File

@@ -276,7 +276,7 @@ func PayOrder(providerName, host, paymentEnv string, order *Order, lang string)
OutOrderId: payResp.OrderId,
}
if provider.Type == "Dummy" || provider.Type == "Balance" {
if provider.Type == "Balance" {
payment.State = pp.PaymentStatePaid
}
@@ -351,7 +351,7 @@ func PayOrder(providerName, host, paymentEnv string, order *Order, lang string)
}
order.Payment = payment.Name
if provider.Type == "Dummy" || provider.Type == "Balance" {
if provider.Type == "Balance" {
order.State = "Paid"
order.Message = "Payment successful"
order.UpdateTime = util.GetCurrentTime()
@@ -364,7 +364,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" {
if provider.Type == "Balance" {
err = UpdateProductStock(orderProductInfos)
if err != nil {
return nil, nil, err

View File

@@ -92,6 +92,8 @@ type Organization struct {
AccountMenu string `xorm:"varchar(20)" json:"accountMenu"`
AccountItems []*AccountItem `xorm:"mediumtext" json:"accountItems"`
DcrPolicy string `xorm:"varchar(100)" json:"dcrPolicy"`
OrgBalance float64 `json:"orgBalance"`
UserBalance float64 `json:"userBalance"`
BalanceCredit float64 `json:"balanceCredit"`

View File

@@ -29,9 +29,9 @@ import (
"github.com/casdoor/casdoor/conf"
"github.com/casdoor/casdoor/util"
xormadapter "github.com/casdoor/xorm-adapter/v3"
_ "github.com/denisenkom/go-mssqldb" // db = mssql
_ "github.com/go-sql-driver/mysql" // db = mysql
_ "github.com/lib/pq" // db = postgres
_ "github.com/go-sql-driver/mysql" // db = mysql
_ "github.com/lib/pq" // db = postgres
_ "github.com/microsoft/go-mssqldb" // db = mssql
"github.com/xorm-io/xorm"
"github.com/xorm-io/xorm/core"
"github.com/xorm-io/xorm/names"

View File

@@ -53,7 +53,8 @@ type Provider struct {
Host string `xorm:"varchar(100)" json:"host"`
Port int `json:"port"`
DisableSsl bool `json:"disableSsl"` // If the provider type is WeChat, DisableSsl means EnableQRCode, if type is Google, it means sync phone number
DisableSsl bool `json:"disableSsl"` // Deprecated: Use SslMode instead. If the provider type is WeChat, DisableSsl means EnableQRCode, if type is Google, it means sync phone number
SslMode string `xorm:"varchar(100)" json:"sslMode"` // "Auto" (empty means Auto), "Enable", "Disable"
Title string `xorm:"varchar(100)" json:"title"`
Content string `xorm:"varchar(2000)" json:"content"` // If provider type is WeChat, Content means QRCode string by Base64 encoding
Receiver string `xorm:"varchar(100)" json:"receiver"`

327
object/syncer_awsiam.go Normal file
View File

@@ -0,0 +1,327 @@
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package object
import (
"context"
"fmt"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/iam"
"github.com/casdoor/casdoor/util"
)
// AwsIamSyncerProvider implements SyncerProvider for AWS IAM API-based syncers
type AwsIamSyncerProvider struct {
Syncer *Syncer
iamClient *iam.IAM
}
// InitAdapter initializes the AWS IAM syncer
func (p *AwsIamSyncerProvider) InitAdapter() error {
// syncer.Host should be the AWS region (e.g., "us-east-1")
// syncer.User should be the AWS Access Key ID
// syncer.Password should be the AWS Secret Access Key
region := p.Syncer.Host
if region == "" {
return fmt.Errorf("AWS region (host field) is required for AWS IAM syncer")
}
accessKeyId := p.Syncer.User
if accessKeyId == "" {
return fmt.Errorf("AWS Access Key ID (user field) is required for AWS IAM syncer")
}
secretAccessKey := p.Syncer.Password
if secretAccessKey == "" {
return fmt.Errorf("AWS Secret Access Key (password field) is required for AWS IAM syncer")
}
// Create AWS session
sess, err := session.NewSession(&aws.Config{
Region: aws.String(region),
Credentials: credentials.NewStaticCredentials(accessKeyId, secretAccessKey, ""),
})
if err != nil {
return fmt.Errorf("failed to create AWS session: %w", err)
}
// Create IAM client
p.iamClient = iam.New(sess)
return nil
}
// GetOriginalUsers retrieves all users from AWS IAM API
func (p *AwsIamSyncerProvider) GetOriginalUsers() ([]*OriginalUser, error) {
if p.iamClient == nil {
if err := p.InitAdapter(); err != nil {
return nil, err
}
}
return p.getAwsIamUsers()
}
// AddUser adds a new user to AWS IAM (not supported for read-only API)
func (p *AwsIamSyncerProvider) AddUser(user *OriginalUser) (bool, error) {
// AWS IAM syncer is typically read-only
return false, fmt.Errorf("adding users to AWS IAM is not supported")
}
// UpdateUser updates an existing user in AWS IAM (not supported for read-only API)
func (p *AwsIamSyncerProvider) UpdateUser(user *OriginalUser) (bool, error) {
// AWS IAM syncer is typically read-only
return false, fmt.Errorf("updating users in AWS IAM is not supported")
}
// TestConnection tests the AWS IAM API connection
func (p *AwsIamSyncerProvider) TestConnection() error {
if p.iamClient == nil {
if err := p.InitAdapter(); err != nil {
return err
}
}
// Try to list users with a limit of 1 to test the connection
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
input := &iam.ListUsersInput{
MaxItems: aws.Int64(1),
}
_, err := p.iamClient.ListUsersWithContext(ctx, input)
return err
}
// Close closes any open connections
func (p *AwsIamSyncerProvider) Close() error {
// AWS IAM client doesn't require explicit cleanup
p.iamClient = nil
return nil
}
// getAwsIamUsers gets all users from AWS IAM API
func (p *AwsIamSyncerProvider) getAwsIamUsers() ([]*OriginalUser, error) {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
allUsers := []*iam.User{}
var marker *string
// Paginate through all users
for {
input := &iam.ListUsersInput{
Marker: marker,
}
result, err := p.iamClient.ListUsersWithContext(ctx, input)
if err != nil {
return nil, fmt.Errorf("failed to list IAM users: %w", err)
}
allUsers = append(allUsers, result.Users...)
if result.IsTruncated == nil || !*result.IsTruncated {
break
}
marker = result.Marker
}
// Convert AWS IAM users to Casdoor OriginalUser
originalUsers := []*OriginalUser{}
for _, iamUser := range allUsers {
originalUser, err := p.awsIamUserToOriginalUser(iamUser)
if err != nil {
// Log error but continue processing other users
userName := "unknown"
if iamUser.UserName != nil {
userName = *iamUser.UserName
}
fmt.Printf("Warning: Failed to convert IAM user %s: %v\n", userName, err)
continue
}
originalUsers = append(originalUsers, originalUser)
}
return originalUsers, nil
}
// awsIamUserToOriginalUser converts AWS IAM user to Casdoor OriginalUser
func (p *AwsIamSyncerProvider) awsIamUserToOriginalUser(iamUser *iam.User) (*OriginalUser, error) {
if iamUser == nil {
return nil, fmt.Errorf("IAM user is nil")
}
user := &OriginalUser{
Address: []string{},
Properties: map[string]string{},
Groups: []string{},
}
// Set ID from UserId (unique identifier)
if iamUser.UserId != nil {
user.Id = *iamUser.UserId
}
// Set Name from UserName
if iamUser.UserName != nil {
user.Name = *iamUser.UserName
}
// Set DisplayName (use UserName if not available separately)
if iamUser.UserName != nil {
user.DisplayName = *iamUser.UserName
}
// Set CreatedTime from CreateDate
if iamUser.CreateDate != nil {
user.CreatedTime = iamUser.CreateDate.Format(time.RFC3339)
} else {
user.CreatedTime = util.GetCurrentTime()
}
// Get user tags which might contain additional information
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
tagsInput := &iam.ListUserTagsInput{
UserName: iamUser.UserName,
}
tagsResult, err := p.iamClient.ListUserTagsWithContext(ctx, tagsInput)
if err == nil && tagsResult != nil {
// Process tags to extract additional user information
for _, tag := range tagsResult.Tags {
if tag.Key != nil && tag.Value != nil {
key := *tag.Key
value := *tag.Value
switch key {
case "Email", "email":
user.Email = value
case "Phone", "phone":
user.Phone = value
case "DisplayName", "displayName":
user.DisplayName = value
case "FirstName", "firstName":
user.FirstName = value
case "LastName", "lastName":
user.LastName = value
case "Title", "title":
user.Title = value
case "Department", "department":
user.Affiliation = value
default:
// Store other tags in Properties
user.Properties[key] = value
}
}
}
}
// AWS IAM users are active by default unless specified in tags
// Check if there's a "Status" or "Active" tag
if status, ok := user.Properties["Status"]; ok {
if status == "Inactive" || status == "Disabled" {
user.IsForbidden = true
}
}
if active, ok := user.Properties["Active"]; ok {
if active == "false" || active == "False" || active == "0" {
user.IsForbidden = true
}
}
return user, nil
}
// GetOriginalGroups retrieves all groups from AWS IAM
func (p *AwsIamSyncerProvider) GetOriginalGroups() ([]*OriginalGroup, error) {
if p.iamClient == nil {
if err := p.InitAdapter(); err != nil {
return nil, err
}
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
allGroups := []*iam.Group{}
var marker *string
// Paginate through all groups
for {
input := &iam.ListGroupsInput{
Marker: marker,
}
result, err := p.iamClient.ListGroupsWithContext(ctx, input)
if err != nil {
return nil, fmt.Errorf("failed to list IAM groups: %w", err)
}
allGroups = append(allGroups, result.Groups...)
if result.IsTruncated == nil || !*result.IsTruncated {
break
}
marker = result.Marker
}
// Convert AWS IAM groups to Casdoor OriginalGroup
originalGroups := []*OriginalGroup{}
for _, iamGroup := range allGroups {
if iamGroup.GroupId != nil && iamGroup.GroupName != nil {
group := &OriginalGroup{
Id: *iamGroup.GroupId,
Name: *iamGroup.GroupName,
}
if iamGroup.GroupName != nil {
group.DisplayName = *iamGroup.GroupName
}
originalGroups = append(originalGroups, group)
}
}
return originalGroups, nil
}
// GetOriginalUserGroups retrieves the group IDs that a user belongs to
func (p *AwsIamSyncerProvider) GetOriginalUserGroups(userId string) ([]string, error) {
if p.iamClient == nil {
if err := p.InitAdapter(); err != nil {
return nil, err
}
}
// Note: AWS IAM API requires UserName to query groups, but this interface provides UserId.
// This is a known limitation. To properly implement this, we would need to:
// 1. Maintain a mapping cache from UserId to UserName, or
// 2. Modify the interface to accept both UserId and UserName
// For now, returning empty groups to maintain interface compatibility.
// TODO: Implement user group synchronization by maintaining a UserId->UserName mapping
return []string{}, nil
}

View File

@@ -426,6 +426,7 @@ func (p *DingtalkSyncerProvider) dingtalkUserToOriginalUser(dingtalkUser *Dingta
Address: []string{},
Properties: map[string]string{},
Groups: []string{},
DingTalk: dingtalkUser.UserId, // Link DingTalk provider account
}
// Add department IDs to Groups field

View File

@@ -72,6 +72,8 @@ func GetSyncerProvider(syncer *Syncer) SyncerProvider {
return &OktaSyncerProvider{Syncer: syncer}
case "SCIM":
return &SCIMSyncerProvider{Syncer: syncer}
case "AWS IAM":
return &AwsIamSyncerProvider{Syncer: syncer}
case "Keycloak":
return &KeycloakSyncerProvider{
DatabaseSyncerProvider: DatabaseSyncerProvider{Syncer: syncer},

View File

@@ -376,6 +376,7 @@ func (p *LarkSyncerProvider) larkUserToOriginalUser(larkUser *LarkUser) *Origina
Address: []string{},
Properties: map[string]string{},
Groups: []string{},
Lark: larkUser.UserId, // Link Lark provider account
}
// Set avatar if available

View File

@@ -70,6 +70,18 @@ func (syncer *Syncer) updateUserForOriginalFields(user *User, key string) (bool,
columns := syncer.getCasdoorColumns()
columns = append(columns, "affiliation", "hash", "pre_hash")
// Add provider-specific field for API-based syncers to enable login binding
// This allows synced users to login via their provider accounts
switch syncer.Type {
case "WeCom":
columns = append(columns, "wecom")
case "DingTalk":
columns = append(columns, "dingtalk")
case "Lark":
columns = append(columns, "lark")
}
affected, err := ormer.Engine.Where(key+" = ? and owner = ?", syncer.getUserValue(&oldUser, key), oldUser.Owner).Cols(columns...).Update(user)
if err != nil {
return false, err

View File

@@ -275,6 +275,7 @@ func (p *WecomSyncerProvider) wecomUserToOriginalUser(wecomUser *WecomUser) *Ori
Address: []string{},
Properties: map[string]string{},
Groups: []string{},
Wecom: wecomUser.UserId, // Link WeCom provider account
}
// Set gender

View File

@@ -43,6 +43,7 @@ type Token struct {
CodeChallenge string `xorm:"varchar(100)" json:"codeChallenge"`
CodeIsUsed bool `json:"codeIsUsed"`
CodeExpireIn int64 `json:"codeExpireIn"`
Resource string `xorm:"varchar(255)" json:"resource"` // RFC 8707 Resource Indicator
}
func GetTokenCount(owner, organization, field, value string) (int64, error) {

View File

@@ -338,6 +338,50 @@ func getClaimsWithoutThirdIdp(claims Claims) ClaimsWithoutThirdIdp {
return res
}
// getUserFieldValue gets the value of a user field by name, handling special cases like Roles and Permissions
func getUserFieldValue(user *User, fieldName string) (interface{}, bool) {
if user == nil {
return nil, false
}
// Handle special fields that need conversion
switch fieldName {
case "Roles":
return getUserRoleNames(user), true
case "Permissions":
return getUserPermissionNames(user), true
case "permissionNames":
permissionNames := []string{}
for _, val := range user.Permissions {
permissionNames = append(permissionNames, val.Name)
}
return permissionNames, true
}
// Handle Properties fields (e.g., Properties.my_field)
if strings.HasPrefix(fieldName, "Properties.") {
parts := strings.Split(fieldName, ".")
if len(parts) == 2 {
propName := parts[1]
if user.Properties != nil {
if value, exists := user.Properties[propName]; exists {
return value, true
}
}
}
return nil, false
}
// Use reflection to get the field value
userValue := reflect.ValueOf(user).Elem()
userField := userValue.FieldByName(fieldName)
if userField.IsValid() {
return userField.Interface(), true
}
return nil, false
}
func getClaimsCustom(claims Claims, tokenField []string, tokenAttributes []*JwtItem) jwt.MapClaims {
res := make(jwt.MapClaims)
@@ -414,16 +458,30 @@ func getClaimsCustom(claims Claims, tokenField []string, tokenAttributes []*JwtI
}
for _, item := range tokenAttributes {
valueList := replaceAttributeValue(claims.User, item.Value)
if len(valueList) == 0 {
continue
var value interface{}
// If Category is "Existing Field", get the actual field value from the user
if item.Category == "Existing Field" {
fieldValue, found := getUserFieldValue(claims.User, item.Value)
if !found {
continue
}
value = fieldValue
} else {
// Default behavior: use replaceAttributeValue for "Static Value" or empty category
valueList := replaceAttributeValue(claims.User, item.Value)
if len(valueList) == 0 {
continue
}
if item.Type == "String" {
value = valueList[0]
} else {
value = valueList
}
}
if item.Type == "String" {
res[item.Name] = valueList[0]
} else {
res[item.Name] = valueList
}
res[item.Name] = value
}
return res
@@ -451,7 +509,7 @@ func refineUser(user *User) *User {
return user
}
func generateJwtToken(application *Application, user *User, provider string, signinMethod string, nonce string, scope string, host string) (string, string, string, error) {
func generateJwtToken(application *Application, user *User, provider string, signinMethod string, nonce string, scope string, resource string, host string) (string, string, string, error) {
nowTime := time.Now()
expireTime := nowTime.Add(time.Duration(application.ExpireInHours * float64(time.Hour)))
refreshExpireTime := nowTime.Add(time.Duration(application.RefreshExpireInHours * float64(time.Hour)))
@@ -495,7 +553,10 @@ func generateJwtToken(application *Application, user *User, provider string, sig
},
}
if application.IsShared {
// RFC 8707: Use resource as audience when provided
if resource != "" {
claims.Audience = []string{resource}
} else if application.IsShared {
claims.Audience = []string{application.ClientId + "-org-" + user.Owner}
}

View File

@@ -18,6 +18,7 @@ import (
"crypto/sha256"
"encoding/base64"
"fmt"
"net/url"
"strings"
"sync"
"time"
@@ -92,6 +93,26 @@ type DeviceAuthResponse struct {
Interval int `json:"interval"`
}
// validateResourceURI validates that the resource parameter is a valid absolute URI
// according to RFC 8707 Section 2
func validateResourceURI(resource string) error {
if resource == "" {
return nil // empty resource is allowed (backward compatibility)
}
parsedURL, err := url.Parse(resource)
if err != nil {
return fmt.Errorf("resource must be a valid URI")
}
// RFC 8707: The resource parameter must be an absolute URI
if !parsedURL.IsAbs() {
return fmt.Errorf("resource must be an absolute URI")
}
return nil
}
func ExpireTokenByAccessToken(accessToken string) (bool, *Application, *Token, error) {
token, err := GetTokenByAccessToken(accessToken)
if err != nil {
@@ -138,7 +159,7 @@ func CheckOAuthLogin(clientId string, responseType string, redirectUri string, s
return "", application, nil
}
func GetOAuthCode(userId string, clientId string, provider string, signinMethod string, responseType string, redirectUri string, scope string, state string, nonce string, challenge string, host string, lang string) (*Code, error) {
func GetOAuthCode(userId string, clientId string, provider string, signinMethod string, responseType string, redirectUri string, scope string, state string, nonce string, challenge string, resource string, host string, lang string) (*Code, error) {
user, err := GetUser(userId)
if err != nil {
return nil, err
@@ -169,11 +190,19 @@ func GetOAuthCode(userId string, clientId string, provider string, signinMethod
}, nil
}
// Validate resource parameter (RFC 8707)
if err := validateResourceURI(resource); err != nil {
return &Code{
Message: err.Error(),
Code: "",
}, nil
}
err = ExtendUserWithRolesAndPermissions(user)
if err != nil {
return nil, err
}
accessToken, refreshToken, tokenName, err := generateJwtToken(application, user, provider, signinMethod, nonce, scope, host)
accessToken, refreshToken, tokenName, err := generateJwtToken(application, user, provider, signinMethod, nonce, scope, resource, host)
if err != nil {
return nil, err
}
@@ -198,6 +227,7 @@ func GetOAuthCode(userId string, clientId string, provider string, signinMethod
CodeChallenge: challenge,
CodeIsUsed: false,
CodeExpireIn: time.Now().Add(time.Minute * 5).Unix(),
Resource: resource,
}
_, err = AddToken(token)
if err != nil {
@@ -210,7 +240,7 @@ func GetOAuthCode(userId string, clientId string, provider string, signinMethod
}, nil
}
func GetOAuthToken(grantType string, clientId string, clientSecret string, code string, verifier string, scope string, nonce string, username string, password string, host string, refreshToken string, tag string, avatar string, lang string, subjectToken string, subjectTokenType string, audience string) (interface{}, error) {
func GetOAuthToken(grantType string, clientId string, clientSecret string, code string, verifier string, scope string, nonce string, username string, password string, host string, refreshToken string, tag string, avatar string, lang string, subjectToken string, subjectTokenType string, audience string, resource string) (interface{}, error) {
application, err := GetApplicationByClientId(clientId)
if err != nil {
return nil, err
@@ -236,7 +266,7 @@ func GetOAuthToken(grantType string, clientId string, clientSecret string, code
var tokenError *TokenError
switch grantType {
case "authorization_code": // Authorization Code Grant
token, tokenError, err = GetAuthorizationCodeToken(application, clientSecret, code, verifier)
token, tokenError, err = GetAuthorizationCodeToken(application, clientSecret, code, verifier, resource)
case "password": // Resource Owner Password Credentials Grant
token, tokenError, err = GetPasswordToken(application, username, password, scope, host)
case "client_credentials": // Client Credentials Grant
@@ -391,7 +421,7 @@ func RefreshToken(grantType string, refreshToken string, scope string, clientId
return nil, err
}
newAccessToken, newRefreshToken, tokenName, err := generateJwtToken(application, user, "", "", "", scope, host)
newAccessToken, newRefreshToken, tokenName, err := generateJwtToken(application, user, "", "", "", scope, "", host)
if err != nil {
return &TokenError{
Error: EndpointError,
@@ -545,7 +575,7 @@ func createGuestUserToken(application *Application, clientSecret string, verifie
}
// Generate JWT token
accessToken, refreshToken, tokenName, err := generateJwtToken(application, guestUser, "", "", "", "", "")
accessToken, refreshToken, tokenName, err := generateJwtToken(application, guestUser, "", "", "", "", "", "")
if err != nil {
return nil, &TokenError{
Error: EndpointError,
@@ -595,7 +625,7 @@ func generateGuestUsername() string {
// GetAuthorizationCodeToken
// Authorization code flow
func GetAuthorizationCodeToken(application *Application, clientSecret string, code string, verifier string) (*Token, *TokenError, error) {
func GetAuthorizationCodeToken(application *Application, clientSecret string, code string, verifier string, resource string) (*Token, *TokenError, error) {
if code == "" {
return nil, &TokenError{
Error: InvalidRequest,
@@ -663,6 +693,14 @@ func GetAuthorizationCodeToken(application *Application, clientSecret string, co
}, nil
}
// RFC 8707: Validate resource parameter matches the one in the authorization request
if resource != token.Resource {
return nil, &TokenError{
Error: InvalidGrant,
ErrorDescription: fmt.Sprintf("resource parameter does not match authorization request, expected: [%s], got: [%s]", token.Resource, resource),
}, nil
}
nowUnix := time.Now().Unix()
if nowUnix > token.CodeExpireIn {
// code must be used within 5 minutes
@@ -719,7 +757,7 @@ func GetPasswordToken(application *Application, username string, password string
return nil, nil, err
}
accessToken, refreshToken, tokenName, err := generateJwtToken(application, user, "", "", "", scope, host)
accessToken, refreshToken, tokenName, err := generateJwtToken(application, user, "", "", "", scope, "", host)
if err != nil {
return nil, &TokenError{
Error: EndpointError,
@@ -765,7 +803,7 @@ func GetClientCredentialsToken(application *Application, clientSecret string, sc
Type: "application",
}
accessToken, _, tokenName, err := generateJwtToken(application, nullUser, "", "", "", scope, host)
accessToken, _, tokenName, err := generateJwtToken(application, nullUser, "", "", "", scope, "", host)
if err != nil {
return nil, &TokenError{
Error: EndpointError,
@@ -829,7 +867,7 @@ func GetTokenByUser(application *Application, user *User, scope string, nonce st
return nil, err
}
accessToken, refreshToken, tokenName, err := generateJwtToken(application, user, "", "", nonce, scope, host)
accessToken, refreshToken, tokenName, err := generateJwtToken(application, user, "", "", nonce, scope, "", host)
if err != nil {
return nil, err
}
@@ -936,7 +974,7 @@ func GetWechatMiniProgramToken(application *Application, code string, host strin
return nil, nil, err
}
accessToken, refreshToken, tokenName, err := generateJwtToken(application, user, "", "", "", "", host)
accessToken, refreshToken, tokenName, err := generateJwtToken(application, user, "", "", "", "", "", host)
if err != nil {
return nil, &TokenError{
Error: EndpointError,
@@ -1110,7 +1148,7 @@ func GetTokenExchangeToken(application *Application, clientSecret string, subjec
}
// Generate new JWT token
accessToken, refreshToken, tokenName, err := generateJwtToken(application, user, "", "", "", scope, host)
accessToken, refreshToken, tokenName, err := generateJwtToken(application, user, "", "", "", scope, "", host)
if err != nil {
return nil, &TokenError{
Error: EndpointError,

View File

@@ -405,7 +405,7 @@ func ClearUserOAuthProperties(user *User, providerType string) (bool, error) {
func userVisible(isAdmin bool, item *AccountItem) bool {
if item == nil {
return false
return true
}
if item.ViewRule == "Admin" && !isAdmin {
@@ -564,10 +564,11 @@ func CheckPermissionForUpdateUser(oldUser, newUser *User, isAdmin bool, allowDis
itemsChanged = append(itemsChanged, item)
}
}
if oldUser.SignupApplication != newUser.SignupApplication {
item := GetAccountItemByName("Signup application", organization)
if oldUser.Language != newUser.Language {
item := GetAccountItemByName("Language", organization)
if !userVisible(isAdmin, item) {
newUser.SignupApplication = oldUser.SignupApplication
newUser.Language = oldUser.Language
} else {
itemsChanged = append(itemsChanged, item)
}
@@ -600,6 +601,83 @@ func CheckPermissionForUpdateUser(oldUser, newUser *User, isAdmin bool, allowDis
}
}
if oldUser.Balance != newUser.Balance {
item := GetAccountItemByName("Balance", organization)
if !userVisible(isAdmin, item) {
newUser.Balance = oldUser.Balance
} else {
itemsChanged = append(itemsChanged, item)
}
}
if oldUser.BalanceCredit != newUser.BalanceCredit {
item := GetAccountItemByName("Balance credit", organization)
if !userVisible(isAdmin, item) {
newUser.BalanceCredit = oldUser.BalanceCredit
} else {
itemsChanged = append(itemsChanged, item)
}
}
if oldUser.BalanceCurrency != newUser.BalanceCurrency {
item := GetAccountItemByName("Balance currency", organization)
if !userVisible(isAdmin, item) {
newUser.BalanceCurrency = oldUser.BalanceCurrency
} else {
itemsChanged = append(itemsChanged, item)
}
}
oldUserCartJson, _ := json.Marshal(oldUser.Cart)
if newUser.Cart == nil {
newUser.Cart = []ProductInfo{}
}
newUserCartJson, _ := json.Marshal(newUser.Cart)
if string(oldUserCartJson) != string(newUserCartJson) {
item := GetAccountItemByName("Cart", organization)
if !userVisible(isAdmin, item) {
newUser.Cart = oldUser.Cart
} else {
itemsChanged = append(itemsChanged, item)
}
}
if oldUser.Score != newUser.Score {
item := GetAccountItemByName("Score", organization)
if !userVisible(isAdmin, item) {
newUser.Score = oldUser.Score
} else {
itemsChanged = append(itemsChanged, item)
}
}
if oldUser.Karma != newUser.Karma {
item := GetAccountItemByName("Karma", organization)
if !userVisible(isAdmin, item) {
newUser.Karma = oldUser.Karma
} else {
itemsChanged = append(itemsChanged, item)
}
}
if oldUser.Ranking != newUser.Ranking {
item := GetAccountItemByName("Ranking", organization)
if !userVisible(isAdmin, item) {
newUser.Ranking = oldUser.Ranking
} else {
itemsChanged = append(itemsChanged, item)
}
}
if oldUser.SignupApplication != newUser.SignupApplication {
item := GetAccountItemByName("Signup application", organization)
if !userVisible(isAdmin, item) {
newUser.SignupApplication = oldUser.SignupApplication
} else {
itemsChanged = append(itemsChanged, item)
}
}
if oldUser.IdCard != newUser.IdCard {
item := GetAccountItemByName("ID card", organization)
if !userVisible(isAdmin, item) {
@@ -728,51 +806,6 @@ func CheckPermissionForUpdateUser(oldUser, newUser *User, isAdmin bool, allowDis
}
}
if oldUser.Balance != newUser.Balance {
item := GetAccountItemByName("Balance", organization)
if !userVisible(isAdmin, item) {
newUser.Balance = oldUser.Balance
} else {
itemsChanged = append(itemsChanged, item)
}
}
if oldUser.Score != newUser.Score {
item := GetAccountItemByName("Score", organization)
if !userVisible(isAdmin, item) {
newUser.Score = oldUser.Score
} else {
itemsChanged = append(itemsChanged, item)
}
}
if oldUser.Karma != newUser.Karma {
item := GetAccountItemByName("Karma", organization)
if !userVisible(isAdmin, item) {
newUser.Karma = oldUser.Karma
} else {
itemsChanged = append(itemsChanged, item)
}
}
if oldUser.Language != newUser.Language {
item := GetAccountItemByName("Language", organization)
if !userVisible(isAdmin, item) {
newUser.Language = oldUser.Language
} else {
itemsChanged = append(itemsChanged, item)
}
}
if oldUser.Ranking != newUser.Ranking {
item := GetAccountItemByName("Ranking", organization)
if !userVisible(isAdmin, item) {
newUser.Ranking = oldUser.Ranking
} else {
itemsChanged = append(itemsChanged, item)
}
}
if oldUser.Currency != newUser.Currency {
item := GetAccountItemByName("Currency", organization)
if !userVisible(isAdmin, item) {
@@ -792,6 +825,11 @@ func CheckPermissionForUpdateUser(oldUser, newUser *User, isAdmin bool, allowDis
}
for _, accountItem := range itemsChanged {
// Skip nil items - these occur when a field doesn't have a corresponding
// account item configuration, meaning no validation rules apply
if accountItem == nil {
continue
}
if pass, err := CheckAccountItemModifyRule(accountItem, isAdmin, lang); !pass {
return pass, err

View File

@@ -21,7 +21,7 @@ import (
"net"
"time"
mssql "github.com/denisenkom/go-mssqldb"
mssql "github.com/microsoft/go-mssqldb"
"github.com/lib/pq"
"golang.org/x/crypto/ssh"

View File

@@ -0,0 +1,59 @@
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package object
import (
"fmt"
)
// OauthProtectedResourceMetadata represents RFC 9728 OAuth 2.0 Protected Resource Metadata
type OauthProtectedResourceMetadata struct {
Resource string `json:"resource"`
AuthorizationServers []string `json:"authorization_servers"`
BearerMethodsSupported []string `json:"bearer_methods_supported,omitempty"`
ScopesSupported []string `json:"scopes_supported,omitempty"`
ResourceSigningAlg []string `json:"resource_signing_alg_values_supported,omitempty"`
ResourceDocumentation string `json:"resource_documentation,omitempty"`
}
// GetOauthProtectedResourceMetadata returns RFC 9728 Protected Resource Metadata for global discovery
func GetOauthProtectedResourceMetadata(host string) OauthProtectedResourceMetadata {
_, originBackend := getOriginFromHost(host)
return OauthProtectedResourceMetadata{
Resource: originBackend,
AuthorizationServers: []string{originBackend},
BearerMethodsSupported: []string{"header"},
ScopesSupported: []string{"openid", "profile", "email", "read", "write"},
ResourceSigningAlg: []string{"RS256"},
}
}
// GetOauthProtectedResourceMetadataByApplication returns RFC 9728 Protected Resource Metadata for application-specific discovery
func GetOauthProtectedResourceMetadataByApplication(host string, applicationName string) OauthProtectedResourceMetadata {
_, originBackend := getOriginFromHost(host)
// For application-specific discovery, the resource identifier includes the application name
resourceIdentifier := fmt.Sprintf("%s/.well-known/%s", originBackend, applicationName)
authServer := fmt.Sprintf("%s/.well-known/%s", originBackend, applicationName)
return OauthProtectedResourceMetadata{
Resource: resourceIdentifier,
AuthorizationServers: []string{authServer},
BearerMethodsSupported: []string{"header"},
ScopesSupported: []string{"openid", "profile", "email", "read", "write"},
ResourceSigningAlg: []string{"RS256"},
}
}

View File

@@ -32,6 +32,7 @@ type OidcDiscovery struct {
TokenEndpoint string `json:"token_endpoint"`
UserinfoEndpoint string `json:"userinfo_endpoint"`
DeviceAuthorizationEndpoint string `json:"device_authorization_endpoint"`
RegistrationEndpoint string `json:"registration_endpoint,omitempty"`
JwksUri string `json:"jwks_uri"`
IntrospectionEndpoint string `json:"introspection_endpoint"`
ResponseTypesSupported []string `json:"response_types_supported"`
@@ -40,6 +41,7 @@ type OidcDiscovery struct {
SubjectTypesSupported []string `json:"subject_types_supported"`
IdTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported"`
ScopesSupported []string `json:"scopes_supported"`
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"`
ClaimsSupported []string `json:"claims_supported"`
RequestParameterSupported bool `json:"request_parameter_supported"`
RequestObjectSigningAlgValuesSupported []string `json:"request_object_signing_alg_values_supported"`
@@ -123,6 +125,23 @@ func GetOidcDiscovery(host string, applicationName string) OidcDiscovery {
jwksUri = fmt.Sprintf("%s/.well-known/jwks", originBackend)
}
// Default OIDC scopes
scopes := []string{"openid", "email", "profile", "address", "phone", "offline_access"}
// Merge application-specific custom scopes if application is provided
if applicationName != "" {
applicationId := util.GetId("admin", applicationName)
application, err := GetApplication(applicationId)
if err == nil && application != nil && len(application.Scopes) > 0 {
for _, scope := range application.Scopes {
// Add custom scope names to the scopes list
if scope.Name != "" {
scopes = append(scopes, scope.Name)
}
}
}
}
// Examples:
// https://login.okta.com/.well-known/openid-configuration
// https://auth0.auth0.com/.well-known/openid-configuration
@@ -134,6 +153,7 @@ func GetOidcDiscovery(host string, applicationName string) OidcDiscovery {
TokenEndpoint: fmt.Sprintf("%s/api/login/oauth/access_token", originBackend),
UserinfoEndpoint: fmt.Sprintf("%s/api/userinfo", originBackend),
DeviceAuthorizationEndpoint: fmt.Sprintf("%s/api/device-auth", originBackend),
RegistrationEndpoint: fmt.Sprintf("%s/api/oauth/register", originBackend),
JwksUri: jwksUri,
IntrospectionEndpoint: fmt.Sprintf("%s/api/login/oauth/introspect", originBackend),
ResponseTypesSupported: []string{"code", "token", "id_token", "code token", "code id_token", "token id_token", "code token id_token", "none"},
@@ -141,7 +161,8 @@ func GetOidcDiscovery(host string, applicationName string) OidcDiscovery {
GrantTypesSupported: []string{"authorization_code", "implicit", "password", "client_credentials", "refresh_token", "urn:ietf:params:oauth:grant-type:device_code", "urn:ietf:params:oauth:grant-type:token-exchange"},
SubjectTypesSupported: []string{"public"},
IdTokenSigningAlgValuesSupported: []string{"RS256", "RS512", "ES256", "ES384", "ES512"},
ScopesSupported: []string{"openid", "email", "profile", "address", "phone", "offline_access"},
ScopesSupported: scopes,
CodeChallengeMethodsSupported: []string{"S256"},
ClaimsSupported: []string{"iss", "ver", "sub", "aud", "iat", "exp", "id", "type", "displayName", "avatar", "permanentAvatar", "email", "phone", "location", "affiliation", "title", "homepage", "bio", "tag", "region", "language", "score", "ranking", "isOnline", "isAdmin", "isForbidden", "signupApplication", "ldap"},
RequestParameterSupported: true,
RequestObjectSigningAlgValuesSupported: []string{"HS256", "HS384", "HS512"},

View File

@@ -14,22 +14,59 @@
package pp
import (
"encoding/json"
"fmt"
)
type DummyPaymentProvider struct{}
type DummyOrderInfo struct {
Price float64 `json:"price"`
Currency string `json:"currency"`
ProductDisplayName string `json:"productDisplayName"`
}
func NewDummyPaymentProvider() (*DummyPaymentProvider, error) {
pp := &DummyPaymentProvider{}
return pp, nil
}
func (pp *DummyPaymentProvider) Pay(r *PayReq) (*PayResp, error) {
// Encode payment information in OrderId for later retrieval in Notify.
// Note: This is a test/mock provider and the OrderId is only used internally for testing.
// Real payment providers would receive this information from their external payment gateway.
orderInfo := DummyOrderInfo{
Price: r.Price,
Currency: r.Currency,
ProductDisplayName: r.ProductDisplayName,
}
orderInfoBytes, err := json.Marshal(orderInfo)
if err != nil {
return nil, fmt.Errorf("failed to encode order info: %w", err)
}
return &PayResp{
PayUrl: r.ReturnUrl,
PayUrl: r.ReturnUrl,
OrderId: string(orderInfoBytes),
}, nil
}
func (pp *DummyPaymentProvider) Notify(body []byte, orderId string) (*NotifyResult, error) {
// Decode payment information from OrderId
var orderInfo DummyOrderInfo
if orderId != "" {
err := json.Unmarshal([]byte(orderId), &orderInfo)
if err != nil {
return nil, fmt.Errorf("failed to decode order info: %w", err)
}
}
return &NotifyResult{
PaymentStatus: PaymentStatePaid,
PaymentStatus: PaymentStatePaid,
Price: orderInfo.Price,
Currency: orderInfo.Currency,
ProductDisplayName: orderInfo.ProductDisplayName,
}, nil
}

View File

@@ -94,6 +94,17 @@ func denyMcpRequest(ctx *context.Context) {
Data: T(ctx, "auth:Unauthorized operation"),
})
// Add WWW-Authenticate header per MCP Authorization spec (RFC 9728)
// Use the same logic as getOriginFromHost to determine the scheme
host := ctx.Request.Host
scheme := "https"
if !strings.Contains(host, ".") {
// localhost:8000 or computer-name:80
scheme = "http"
}
resourceMetadataUrl := fmt.Sprintf("%s://%s/.well-known/oauth-protected-resource", scheme, host)
ctx.Output.Header("WWW-Authenticate", fmt.Sprintf("Bearer realm=\"casdoor\", resource_metadata=\"%s\"", resourceMetadataUrl))
ctx.Output.SetStatus(http.StatusUnauthorized)
_ = ctx.Output.JSON(resp, true, false)
}

View File

@@ -65,6 +65,22 @@ func RecordMessage(ctx *context.Context) {
userId := getUser(ctx)
// Special handling for set-password endpoint to capture target user
if ctx.Request.URL.Path == "/api/set-password" {
// Parse form if not already parsed
if err := ctx.Request.ParseForm(); err != nil {
fmt.Printf("RecordMessage() error parsing form: %s\n", err.Error())
} else {
userOwner := ctx.Request.Form.Get("userOwner")
userName := ctx.Request.Form.Get("userName")
if userOwner != "" && userName != "" {
targetUserId := util.GetId(userOwner, userName)
ctx.Input.SetParam("recordTargetUserId", targetUserId)
}
}
}
ctx.Input.SetParam("recordUserId", userId)
}
@@ -76,7 +92,20 @@ func AfterRecordMessage(ctx *context.Context) {
}
userId := ctx.Input.Params()["recordUserId"]
if userId != "" {
targetUserId := ctx.Input.Params()["recordTargetUserId"]
// For set-password endpoint, use target user if available
// We use defensive error handling here (log instead of panic) because target user
// parsing is a new feature. If it fails, we gracefully fall back to the regular
// userId flow or empty user/org fields, maintaining backward compatibility.
if record.Action == "set-password" && targetUserId != "" {
owner, user, err := util.GetOwnerAndNameFromIdWithError(targetUserId)
if err != nil {
fmt.Printf("AfterRecordMessage() error parsing target user %s: %s\n", targetUserId, err.Error())
} else {
record.Organization, record.User = owner, user
}
} else if userId != "" {
owner, user, err := util.GetOwnerAndNameFromIdWithError(userId)
if err != nil {
panic(err)

View File

@@ -298,6 +298,7 @@ func InitAPI() {
web.Router("/api/login/oauth/access_token", &controllers.ApiController{}, "POST:GetOAuthToken")
web.Router("/api/login/oauth/refresh_token", &controllers.ApiController{}, "POST:RefreshToken")
web.Router("/api/login/oauth/introspect", &controllers.ApiController{}, "POST:IntrospectToken")
web.Router("/api/oauth/register", &controllers.ApiController{}, "POST:DynamicClientRegister")
web.Router("/api/get-records", &controllers.ApiController{}, "GET:GetRecords")
web.Router("/api/get-records-filter", &controllers.ApiController{}, "POST:GetRecordsByFilter")
@@ -320,10 +321,14 @@ func InitAPI() {
web.Router("/.well-known/openid-configuration", &controllers.RootController{}, "GET:GetOidcDiscovery")
web.Router("/.well-known/:application/openid-configuration", &controllers.RootController{}, "GET:GetOidcDiscoveryByApplication")
web.Router("/.well-known/oauth-authorization-server", &controllers.RootController{}, "GET:GetOAuthServerMetadata")
web.Router("/.well-known/:application/oauth-authorization-server", &controllers.RootController{}, "GET:GetOAuthServerMetadataByApplication")
web.Router("/.well-known/jwks", &controllers.RootController{}, "*:GetJwks")
web.Router("/.well-known/:application/jwks", &controllers.RootController{}, "*:GetJwksByApplication")
web.Router("/.well-known/webfinger", &controllers.RootController{}, "GET:GetWebFinger")
web.Router("/.well-known/:application/webfinger", &controllers.RootController{}, "GET:GetWebFingerByApplication")
web.Router("/.well-known/oauth-protected-resource", &controllers.RootController{}, "GET:GetOauthProtectedResourceMetadata")
web.Router("/.well-known/:application/oauth-protected-resource", &controllers.RootController{}, "GET:GetOauthProtectedResourceMetadataByApplication")
web.Router("/cas/:organization/:application/serviceValidate", &controllers.RootController{}, "GET:CasServiceValidate")
web.Router("/cas/:organization/:application/proxyValidate", &controllers.RootController{}, "GET:CasProxyValidate")

View File

@@ -89,7 +89,7 @@ func fastAutoSignin(ctx *context.Context) (string, error) {
return "", nil
}
code, err := object.GetOAuthCode(userId, clientId, "", "autoSignin", responseType, redirectUri, scope, state, nonce, codeChallenge, ctx.Request.Host, getAcceptLanguage(ctx))
code, err := object.GetOAuthCode(userId, clientId, "", "autoSignin", responseType, redirectUri, scope, state, nonce, codeChallenge, "", ctx.Request.Host, getAcceptLanguage(ctx))
if err != nil {
return "", err
} else if code.Message != "" {

View File

@@ -48,6 +48,7 @@ import ProviderTable from "./table/ProviderTable";
import SigninMethodTable from "./table/SigninMethodTable";
import SignupTable from "./table/SignupTable";
import SamlAttributeTable from "./table/SamlAttributeTable";
import ScopeTable from "./table/ScopeTable";
import PromptPage from "./auth/PromptPage";
import copy from "copy-to-clipboard";
import ThemeEditor from "./common/theme/ThemeEditor";
@@ -307,6 +308,61 @@ class ApplicationEditPage extends React.Component {
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 3}>
{Setting.getLabel(i18next.t("general:Category"), i18next.t("general:Category - Tooltip"))} :
</Col>
<Col span={21} >
<Select
virtual={false}
style={{width: "100%"}}
value={this.state.application.category}
onChange={(value) => {
this.updateApplicationField("category", value);
if (value === "Agent") {
this.updateApplicationField("type", "MCP");
} else {
this.updateApplicationField("type", "All");
}
}}
>
<Option value="Default">Default</Option>
<Option value="Agent">Agent</Option>
</Select>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 3}>
{Setting.getLabel(i18next.t("general:Type"), i18next.t("general:Type - Tooltip"))} :
</Col>
<Col span={21} >
<Select
virtual={false}
style={{width: "100%"}}
value={this.state.application.type}
onChange={(value) => {
this.updateApplicationField("type", value);
}}
>
{
(this.state.application.category === "Agent") ? (
<>
<Option value="MCP">MCP</Option>
<Option value="A2A">A2A</Option>
</>
) : (
<>
<Option value="All">All</Option>
<Option value="OIDC">OIDC</Option>
<Option value="OAuth">OAuth</Option>
<Option value="SAML">SAML</Option>
<Option value="CAS">CAS</Option>
</>
)
}
</Select>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 3}>
{Setting.getLabel(i18next.t("general:Is shared"), i18next.t("general:Is shared - Tooltip"))} :
@@ -516,6 +572,22 @@ class ApplicationEditPage extends React.Component {
</Select>
</Col>
</Row>
{
(this.state.application.category === "Agent") ? (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 3}>
{Setting.getLabel(i18next.t("general:Scopes"), i18next.t("general:Scopes - Tooltip"))} :
</Col>
<Col span={21} >
<ScopeTable
title={i18next.t("general:Scopes")}
table={this.state.application.scopes}
onUpdateTable={(value) => {this.updateApplicationField("scopes", value);}}
/>
</Col>
</Row>
) : null
}
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 3}>
{Setting.getLabel(i18next.t("application:Token format"), i18next.t("application:Token format - Tooltip"))} :
@@ -1313,11 +1385,12 @@ class ApplicationEditPage extends React.Component {
<Button style={{marginLeft: "20px"}} type="primary" onClick={() => this.submitApplicationEdit(true)}>{i18next.t("general:Save & Exit")}</Button>
{this.state.mode === "add" ? <Button style={{marginLeft: "20px"}} onClick={() => this.deleteApplication()}>{i18next.t("general:Cancel")}</Button> : null}
</div>
} style={(Setting.isMobile()) ? {margin: "5px"} : {}} type="inner">
<Layout style={{background: "inherit"}}>
} style={{margin: (Setting.isMobile()) ? "5px" : {}, height: "calc(100vh - 145px - 48px)", overflow: "hidden"}}
styles={{body: {height: "100%"}}} type="inner">
<Layout style={{background: "inherit", height: "100%", overflow: "auto"}}>
{
this.state.menuMode === "horizontal" || !this.state.menuMode ? (
<Header style={{background: "inherit", padding: "0px"}}>
<Header style={{background: "inherit", padding: "0px", position: "sticky", top: 0}}>
<div className="demo-logo" />
<Tabs
onChange={(key) => {
@@ -1337,7 +1410,7 @@ class ApplicationEditPage extends React.Component {
</Header>
) : null
}
<Layout style={{background: "inherit", maxHeight: "calc(70vh - 70px)", overflow: "auto"}}>
<Layout style={{background: "inherit", overflow: "auto"}}>
{
this.state.menuMode === "vertical" ? (
<Sider width={200} style={{background: "inherit", position: "sticky", top: 0}}>

View File

@@ -38,6 +38,9 @@ class ApplicationListPage extends BaseListPage {
organization: organizationName,
createdTime: moment().format(),
displayName: `New Application - ${randomName}`,
category: "Default",
type: "All",
scopes: [],
logo: `${Setting.StaticBaseUrl}/img/casdoor-logo_1185x256.png`,
enablePassword: true,
enableSignUp: true,
@@ -179,6 +182,40 @@ class ApplicationListPage extends BaseListPage {
sorter: true,
...this.getColumnSearchProps("displayName"),
},
{
title: i18next.t("general:Category"),
dataIndex: "category",
key: "category",
width: "120px",
sorter: true,
...this.getColumnSearchProps("category"),
render: (text, record, index) => {
const category = text;
const tagColor = category === "Agent" ? "green" : "blue";
return (
<span style={{
padding: "4px 8px",
borderRadius: "4px",
backgroundColor: tagColor,
color: "white",
fontWeight: "500",
}}>
{category}
</span>
);
},
},
{
title: i18next.t("general:Type"),
dataIndex: "type",
key: "type",
width: "100px",
sorter: true,
...this.getColumnSearchProps("type"),
render: (text, record, index) => {
return text;
},
},
{
title: "Logo",
dataIndex: "logo",

View File

@@ -75,6 +75,11 @@ class CartListPage extends BaseListPage {
const owner = this.state.user?.owner || this.props.account.owner;
const carts = this.state.data || [];
const invalidCarts = carts.filter(item => item.isInvalid);
if (invalidCarts.length > 0) {
Setting.showMessage("error", i18next.t("product:Cart contains invalid products, please delete them before placing an order"));
return;
}
if (carts.length === 0) {
Setting.showMessage("error", i18next.t("product:Product list cannot be empty"));
return;
@@ -117,7 +122,11 @@ class CartListPage extends BaseListPage {
return;
}
const index = user.cart.findIndex(item => item.name === record.name && item.price === record.price && (item.pricingName || "") === (record.pricingName || "") && (item.planName || "") === (record.planName || ""));
const index = user.cart.findIndex(item =>
item.name === record.name &&
(record.isRecharge ? item.price === record.price : true) &&
(item.pricingName || "") === (record.pricingName || "") &&
(item.planName || "") === (record.planName || ""));
if (index === -1) {
Setting.showMessage("error", i18next.t("general:Failed to delete"));
return;
@@ -144,7 +153,7 @@ class CartListPage extends BaseListPage {
return;
}
const itemKey = `${record.name}-${record.price}-${record.pricingName || ""}-${record.planName || ""}`;
const itemKey = `${record.name}-${record.price !== null ? record.price : "null"}-${record.pricingName || ""}-${record.planName || ""}`;
if (this.updatingCartItemsRef?.[itemKey]) {
return;
}
@@ -152,64 +161,71 @@ class CartListPage extends BaseListPage {
this.updatingCartItemsRef[itemKey] = true;
const user = Setting.deepCopy(this.state.user);
const index = user.cart.findIndex(item => item.name === record.name && item.price === record.price && (item.pricingName || "") === (record.pricingName || "") && (item.planName || "") === (record.planName || ""));
const index = user.cart.findIndex(item =>
item.name === record.name &&
(record.isRecharge ? item.price === record.price : true) &&
(item.pricingName || "") === (record.pricingName || "") &&
(item.planName || "") === (record.planName || ""));
if (index === -1) {
delete this.updatingCartItemsRef[itemKey];
return;
}
if (index !== -1) {
user.cart[index].quantity = newQuantity;
const newData = [...this.state.data];
const dataIndex = newData.findIndex(item => item.name === record.name && item.price === record.price && (item.pricingName || "") === (record.pricingName || "") && (item.planName || "") === (record.planName || ""));
if (dataIndex !== -1) {
newData[dataIndex].quantity = newQuantity;
this.setState({data: newData});
}
this.setState(prevState => ({
updatingCartItems: {
...(prevState.updatingCartItems || {}),
[itemKey]: true,
},
}));
UserBackend.updateUser(user.owner, user.name, user)
.then((res) => {
if (res.status === "ok") {
this.setState({user: user});
} else {
Setting.showMessage("error", res.msg);
this.fetch();
}
})
.catch(error => {
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
this.fetch();
})
.finally(() => {
delete this.updatingCartItemsRef[itemKey];
this.setState(prevState => {
const updatingCartItems = {...(prevState.updatingCartItems || {})};
delete updatingCartItems[itemKey];
return {updatingCartItems};
});
});
user.cart[index].quantity = newQuantity;
const newData = [...this.state.data];
const dataIndex = newData.findIndex(item =>
item.name === record.name &&
(record.price !== null ? item.price === record.price : true) &&
(item.pricingName || "") === (record.pricingName || "") &&
(item.planName || "") === (record.planName || ""));
if (dataIndex !== -1) {
newData[dataIndex].quantity = newQuantity;
this.setState({data: newData});
}
this.setState(prevState => ({
updatingCartItems: {
...(prevState.updatingCartItems || {}),
[itemKey]: true,
},
}));
UserBackend.updateUser(user.owner, user.name, user)
.then((res) => {
if (res.status === "ok") {
this.setState({user: user});
} else {
Setting.showMessage("error", res.msg);
this.fetch();
}
})
.catch(error => {
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
this.fetch();
})
.finally(() => {
delete this.updatingCartItemsRef[itemKey];
this.setState(prevState => {
const updatingCartItems = {...(prevState.updatingCartItems || {})};
delete updatingCartItems[itemKey];
return {updatingCartItems};
});
});
}
renderTable(carts) {
const isEmpty = carts === undefined || carts === null || carts.length === 0;
const hasInvalidItems = carts && carts.some(item => item.isInvalid);
const owner = this.state.user?.owner || this.props.account.owner;
let total = 0;
let currency = "";
if (carts && carts.length > 0) {
carts.forEach(item => {
const validCarts = carts.filter(item => !item.isInvalid);
validCarts.forEach(item => {
total += item.price * item.quantity;
});
currency = carts[0].currency;
currency = validCarts.length > 0 ? validCarts[0].currency : (carts[0].currency || "USD");
}
const columns = [
@@ -222,6 +238,9 @@ class CartListPage extends BaseListPage {
sorter: true,
...this.getColumnSearchProps("name"),
render: (text, record, index) => {
if (record.isInvalid) {
return <span style={{color: "red"}}>{text}</span>;
}
return (
<Link to={`/products/${owner}/${text}`}>
{text}
@@ -235,6 +254,12 @@ class CartListPage extends BaseListPage {
key: "displayName",
width: "170px",
sorter: true,
render: (text, record) => {
if (record.isInvalid) {
return <span style={{color: "red"}}>{i18next.t("product:Invalid product")}</span>;
}
return text;
},
},
{
title: i18next.t("product:Image"),
@@ -268,6 +293,9 @@ class CartListPage extends BaseListPage {
sorter: true,
render: (text, record) => {
if (!text) {return null;}
if (record.isInvalid) {
return <span style={{color: "red"}}>{text}</span>;
}
return (
<Link to={`/pricings/${owner}/${text}`}>
{text}
@@ -283,6 +311,9 @@ class CartListPage extends BaseListPage {
sorter: true,
render: (text, record) => {
if (!text) {return null;}
if (record.isInvalid) {
return <span style={{color: "red"}}>{text}</span>;
}
return (
<Link to={`/plans/${owner}/${text}`}>
{text}
@@ -297,7 +328,7 @@ class CartListPage extends BaseListPage {
width: "100px",
sorter: true,
render: (text, record) => {
const itemKey = `${record.name}-${record.price}-${record.pricingName || ""}-${record.planName || ""}`;
const itemKey = `${record.name}-${record.price !== null ? record.price : "null"}-${record.pricingName || ""}-${record.planName || ""}`;
const isUpdating = this.state.updatingCartItems?.[itemKey] === true;
return (
<QuantityStepper
@@ -306,7 +337,7 @@ class CartListPage extends BaseListPage {
onIncrease={() => this.updateCartItemQuantity(record, text + 1)}
onDecrease={() => this.updateCartItemQuantity(record, text - 1)}
onChange={null}
disabled={isUpdating}
disabled={isUpdating || record.isInvalid}
/>
);
},
@@ -320,7 +351,11 @@ class CartListPage extends BaseListPage {
render: (text, record, index) => {
return (
<div style={{display: "flex", flexWrap: "wrap", gap: "8px"}}>
<Button type="primary" onClick={() => this.props.history.push(`/products/${owner}/${record.name}/buy`)}>
<Button
type="primary"
onClick={() => this.props.history.push(`/products/${owner}/${record.name}/buy`)}
disabled={record.isInvalid}
>
{i18next.t("general:Detail")}
</Button>
<PopconfirmModal
@@ -358,7 +393,7 @@ class CartListPage extends BaseListPage {
onConfirm={() => this.clearCart()}
disabled={isEmpty}
/>
<Button type="primary" size="small" onClick={() => this.placeOrder()} disabled={isEmpty || this.state.isPlacingOrder} loading={this.state.isPlacingOrder}>{i18next.t("general:Place Order")}</Button>
<Button type="primary" size="small" onClick={() => this.placeOrder()} disabled={isEmpty || hasInvalidItems || this.state.isPlacingOrder} loading={this.state.isPlacingOrder}>{i18next.t("general:Place Order")}</Button>
</div>
);
}}
@@ -379,7 +414,7 @@ class CartListPage extends BaseListPage {
size="large"
style={{height: "50px", fontSize: "20px", padding: "0 40px", borderRadius: "5px"}}
onClick={() => this.placeOrder()}
disabled={this.state.isPlacingOrder}
disabled={hasInvalidItems || this.state.isPlacingOrder}
loading={this.state.isPlacingOrder}
>
{i18next.t("general:Place Order")}
@@ -404,17 +439,33 @@ class CartListPage extends BaseListPage {
ProductBackend.getProduct(organizationName, item.name)
.then(pRes => {
if (pRes.status === "ok" && pRes.data) {
const isCurrencyChanged = item.currency && pRes.data.currency && item.currency !== pRes.data.currency;
if (isCurrencyChanged) {
Setting.showMessage("warning", i18next.t("product:Product not found or invalid") + `: ${item.name}`);
}
return {
...pRes.data,
createdTime: item.createdTime,
pricingName: item.pricingName,
planName: item.planName,
quantity: item.quantity,
price: pRes.data.isRecharge ? item.price : pRes.data.price,
isInvalid: isCurrencyChanged,
};
}
return item;
Setting.showMessage("warning", i18next.t("product:Product not found or invalid") + `: ${item.name}`);
return {
...item,
isInvalid: true,
};
})
.catch(() => {
Setting.showMessage("warning", i18next.t("product:Product not found or invalid") + `: ${item.name}`);
return {
...item,
isInvalid: true,
};
})
.catch(() => item)
);
const fullCartData = await Promise.all(productPromises);
@@ -432,6 +483,10 @@ class CartListPage extends BaseListPage {
const comparison = aValue > bValue ? 1 : -1;
return params.sortOrder === "ascend" ? comparison : -comparison;
});
} else {
sortedData.sort((a, b) => {
return b.createdTime - a.createdTime;
});
}
this.setState({
@@ -445,6 +500,11 @@ class CartListPage extends BaseListPage {
searchText: params.searchText,
searchedColumn: params.searchedColumn,
});
const invalidProducts = sortedData.filter(item => item.isInvalid);
invalidProducts.forEach(item => {
Setting.showMessage("error", i18next.t("product:Product not found or invalid") + `: ${item.name}`);
});
} else {
this.setState({loading: false});
Setting.showMessage("error", res.msg);

View File

@@ -122,7 +122,7 @@ class PaymentResultPage extends React.Component {
payment: payment,
});
if (payment.state === "Created") {
if (["PayPal", "Stripe", "AirWallex", "Alipay", "WeChat Pay", "Balance"].includes(payment.type)) {
if (["PayPal", "Stripe", "AirWallex", "Alipay", "WeChat Pay", "Balance", "Dummy"].includes(payment.type)) {
this.setState({
timeout: setTimeout(async() => {
await PaymentBackend.notifyPayment(this.state.owner, this.state.paymentName);

View File

@@ -14,6 +14,7 @@
import React from "react";
import {Button, Descriptions, Divider, InputNumber, Radio, Space, Spin, Typography} from "antd";
import moment from "moment";
import i18next from "i18next";
import * as ProductBackend from "./backend/ProductBackend";
import * as PlanBackend from "./backend/PlanBackend";
@@ -193,7 +194,13 @@ class ProductBuyPage extends React.Component {
}
}
const existingItemIndex = cart.findIndex(item => item.name === product.name && item.price === actualPrice && (item.pricingName || "") === pricingName && (item.planName || "") === planName);
const cartPrice = product.isRecharge ? actualPrice : null;
const existingItemIndex = cart.findIndex(item =>
item.name === product.name &&
(product.isRecharge ? item.price === actualPrice : true) &&
(item.pricingName || "") === pricingName &&
(item.planName || "") === planName
);
const quantityToAdd = this.state.buyQuantity;
if (existingItemIndex !== -1) {
@@ -201,7 +208,8 @@ class ProductBuyPage extends React.Component {
} else {
const newProductInfo = {
name: product.name,
price: actualPrice,
createdTime: moment().format(),
price: cartPrice,
currency: product.currency,
pricingName: pricingName,
planName: planName,

View File

@@ -14,6 +14,7 @@
import React from "react";
import {Button, Card, Col, Row, Tag, Typography} from "antd";
import moment from "moment";
import * as Setting from "./Setting";
import * as ProductBackend from "./backend/ProductBackend";
import * as UserBackend from "./backend/UserBackend";
@@ -126,7 +127,13 @@ class ProductStorePage extends React.Component {
}
}
const existingItemIndex = cart.findIndex(item => item.name === product.name && item.price === product.price);
if (product.isRecharge) {
Setting.showMessage("error", i18next.t("product:Recharge products need to go to the product detail page to set custom amount"));
this.setState(prevState => ({addingToCartProducts: prevState.addingToCartProducts.filter(name => name !== product.name)}));
return;
}
const existingItemIndex = cart.findIndex(item => item.name === product.name);
const quantityToAdd = this.state.productQuantities[product.name] || 1;
if (existingItemIndex !== -1) {
@@ -134,7 +141,7 @@ class ProductStorePage extends React.Component {
} else {
const newCartProductInfo = {
name: product.name,
price: product.price,
createdTime: moment().format(),
currency: product.currency,
pricingName: "",
planName: "",

View File

@@ -698,7 +698,7 @@ class ProviderEditPage extends React.Component {
this.updateProviderField("type", "Default");
this.updateProviderField("host", "smtp.example.com");
this.updateProviderField("port", 465);
this.updateProviderField("disableSsl", false);
this.updateProviderField("sslMode", "Auto");
this.updateProviderField("title", "Casdoor Verification Code");
this.updateProviderField("content", Setting.getDefaultHtmlEmailContent());
this.updateProviderField("metadata", Setting.getDefaultInvitationHtmlEmailContent());
@@ -823,6 +823,19 @@ class ProviderEditPage extends React.Component {
</Select>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={2}>
{Setting.getLabel(i18next.t("provider:Scope"), i18next.t("provider:Scope - Tooltip"))} :
</Col>
<Col span={22} >
<Select virtual={false} style={{width: "100%"}} value={this.state.provider.scopes} onChange={value => {
this.updateProviderField("scopes", value);
}}>
<Option key="snsapi_userinfo" value="snsapi_userinfo">snsapi_userinfo</Option>
<Option key="snsapi_privateinfo" value="snsapi_privateinfo">snsapi_privateinfo</Option>
</Select>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={2}>
{Setting.getLabel(i18next.t("provider:Use id as name"), i18next.t("provider:Use id as name - Tooltip"))} :
@@ -1281,12 +1294,16 @@ class ProviderEditPage extends React.Component {
{["Azure ACS", "SendGrid"].includes(this.state.provider.type) ? null : (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:Disable SSL"), i18next.t("provider:Disable SSL - Tooltip"))} :
{Setting.getLabel(i18next.t("provider:SSL mode"), i18next.t("provider:SSL mode - Tooltip"))} :
</Col>
<Col span={1} >
<Switch checked={this.state.provider.disableSsl} onChange={checked => {
this.updateProviderField("disableSsl", checked);
}} />
<Col span={22} >
<Select virtual={false} style={{width: "200px"}} value={this.state.provider.sslMode || "Auto"} onChange={value => {
this.updateProviderField("sslMode", value);
}}>
<Option value="Auto">{i18next.t("provider:Auto")}</Option>
<Option value="Enable">{i18next.t("provider:Enable")}</Option>
<Option value="Disable">{i18next.t("provider:Disable")}</Option>
</Select>
</Col>
</Row>
)}

View File

@@ -289,11 +289,11 @@ export const OtherProviderInfo = {
url: "https://fastspring.com/",
},
"Lemon Squeezy": {
logo: `${StaticBaseUrl}/img/payment_lemonsqueezy.png`,
logo: `${StaticBaseUrl}/img/payment_lemonsqueezy.jpg`,
url: "https://www.lemonsqueezy.com/",
},
"Adyen": {
logo: `${StaticBaseUrl}/img/payment_adyen.png`,
logo: `${StaticBaseUrl}/img/payment_adyen.svg`,
url: "https://www.adyen.com/",
},
},
@@ -517,6 +517,7 @@ export const GetTranslatedUserItems = () => {
{name: "Balance", label: i18next.t("user:Balance")},
{name: "Balance currency", label: i18next.t("organization:Balance currency")},
{name: "Balance credit", label: i18next.t("organization:Balance credit")},
{name: "Cart", label: i18next.t("general:Cart")},
{name: "Transactions", label: i18next.t("general:Transactions")},
{name: "Score", label: i18next.t("user:Score")},
{name: "Karma", label: i18next.t("user:Karma")},

View File

@@ -710,6 +710,79 @@ class SyncerEditPage extends React.Component {
"values": [],
},
];
case "AWS IAM":
return [
{
"name": "UserId",
"type": "string",
"casdoorName": "Id",
"isHashed": true,
"values": [],
},
{
"name": "UserName",
"type": "string",
"casdoorName": "Name",
"isHashed": true,
"values": [],
},
{
"name": "UserName",
"type": "string",
"casdoorName": "DisplayName",
"isHashed": true,
"values": [],
},
{
"name": "Tags.Email",
"type": "string",
"casdoorName": "Email",
"isHashed": true,
"values": [],
},
{
"name": "Tags.Phone",
"type": "string",
"casdoorName": "Phone",
"isHashed": true,
"values": [],
},
{
"name": "Tags.FirstName",
"type": "string",
"casdoorName": "FirstName",
"isHashed": true,
"values": [],
},
{
"name": "Tags.LastName",
"type": "string",
"casdoorName": "LastName",
"isHashed": true,
"values": [],
},
{
"name": "Tags.Title",
"type": "string",
"casdoorName": "Title",
"isHashed": true,
"values": [],
},
{
"name": "Tags.Department",
"type": "string",
"casdoorName": "Affiliation",
"isHashed": true,
"values": [],
},
{
"name": "CreateDate",
"type": "string",
"casdoorName": "CreatedTime",
"isHashed": true,
"values": [],
},
];
default:
return [];
}
@@ -766,14 +839,14 @@ class SyncerEditPage extends React.Component {
});
})}>
{
["Database", "Keycloak", "WeCom", "Azure AD", "Active Directory", "Google Workspace", "DingTalk", "Lark", "Okta", "SCIM"]
["Database", "Keycloak", "WeCom", "Azure AD", "Active Directory", "Google Workspace", "DingTalk", "Lark", "Okta", "SCIM", "AWS IAM"]
.map((item, index) => <Option key={index} value={item}>{item}</Option>)
}
</Select>
</Col>
</Row>
{
this.state.syncer.type === "WeCom" || this.state.syncer.type === "Azure AD" || this.state.syncer.type === "Active Directory" || this.state.syncer.type === "Google Workspace" || this.state.syncer.type === "DingTalk" || this.state.syncer.type === "Lark" || this.state.syncer.type === "Okta" || this.state.syncer.type === "SCIM" ? null : (
this.state.syncer.type === "WeCom" || this.state.syncer.type === "Azure AD" || this.state.syncer.type === "Active Directory" || this.state.syncer.type === "Google Workspace" || this.state.syncer.type === "DingTalk" || this.state.syncer.type === "Lark" || this.state.syncer.type === "Okta" || this.state.syncer.type === "SCIM" || this.state.syncer.type === "AWS IAM" ? null : (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("syncer:Database type"), i18next.t("syncer:Database type - Tooltip"))} :
@@ -828,7 +901,7 @@ class SyncerEditPage extends React.Component {
this.state.syncer.type === "WeCom" || this.state.syncer.type === "DingTalk" || this.state.syncer.type === "Lark" ? null : (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(this.state.syncer.type === "Azure AD" ? i18next.t("provider:Tenant ID") : this.state.syncer.type === "Google Workspace" ? i18next.t("syncer:Admin Email") : this.state.syncer.type === "Active Directory" ? i18next.t("ldap:Server") : this.state.syncer.type === "SCIM" ? i18next.t("syncer:SCIM Server URL") : i18next.t("provider:Host"), i18next.t("provider:Host - Tooltip"))} :
{Setting.getLabel(this.state.syncer.type === "Azure AD" ? i18next.t("provider:Tenant ID") : this.state.syncer.type === "Google Workspace" ? i18next.t("syncer:Admin Email") : this.state.syncer.type === "Active Directory" ? i18next.t("ldap:Server") : this.state.syncer.type === "SCIM" ? i18next.t("syncer:SCIM Server URL") : this.state.syncer.type === "AWS IAM" ? i18next.t("syncer:AWS Region") : i18next.t("provider:Host"), i18next.t("provider:Host - Tooltip"))} :
</Col>
<Col span={22} >
<Input prefix={<LinkOutlined />} value={this.state.syncer.host} onChange={e => {
@@ -839,7 +912,7 @@ class SyncerEditPage extends React.Component {
)
}
{
this.state.syncer.type === "WeCom" || this.state.syncer.type === "Azure AD" || this.state.syncer.type === "Google Workspace" || this.state.syncer.type === "DingTalk" || this.state.syncer.type === "Lark" || this.state.syncer.type === "Okta" || this.state.syncer.type === "SCIM" ? null : (
this.state.syncer.type === "WeCom" || this.state.syncer.type === "Azure AD" || this.state.syncer.type === "Google Workspace" || this.state.syncer.type === "DingTalk" || this.state.syncer.type === "Lark" || this.state.syncer.type === "Okta" || this.state.syncer.type === "SCIM" || this.state.syncer.type === "AWS IAM" ? null : (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(this.state.syncer.type === "Active Directory" ? i18next.t("provider:LDAP port") : i18next.t("provider:Port"), i18next.t("provider:Port - Tooltip"))} :
@@ -863,7 +936,8 @@ class SyncerEditPage extends React.Component {
this.state.syncer.type === "Azure AD" ? i18next.t("provider:Client ID") :
this.state.syncer.type === "Active Directory" ? i18next.t("syncer:Bind DN") :
this.state.syncer.type === "SCIM" ? i18next.t("syncer:Username (optional)") :
i18next.t("general:User"),
this.state.syncer.type === "AWS IAM" ? i18next.t("syncer:AWS Access Key ID") :
i18next.t("general:User"),
i18next.t("general:User - Tooltip")
)} :
</Col>
@@ -884,7 +958,8 @@ class SyncerEditPage extends React.Component {
this.state.syncer.type === "Azure AD" ? i18next.t("provider:Client secret") :
this.state.syncer.type === "Google Workspace" ? i18next.t("syncer:Service account key") :
this.state.syncer.type === "SCIM" ? i18next.t("syncer:API Token / Password") :
i18next.t("general:Password"),
this.state.syncer.type === "AWS IAM" ? i18next.t("syncer:AWS Secret Access Key") :
i18next.t("general:Password"),
i18next.t("general:Password - Tooltip")
)} :
</Col>
@@ -903,7 +978,7 @@ class SyncerEditPage extends React.Component {
</Col>
</Row>
{
this.state.syncer.type === "WeCom" || this.state.syncer.type === "Azure AD" || this.state.syncer.type === "Google Workspace" || this.state.syncer.type === "DingTalk" || this.state.syncer.type === "Lark" || this.state.syncer.type === "Okta" || this.state.syncer.type === "SCIM" ? null : (
this.state.syncer.type === "WeCom" || this.state.syncer.type === "Azure AD" || this.state.syncer.type === "Google Workspace" || this.state.syncer.type === "DingTalk" || this.state.syncer.type === "Lark" || this.state.syncer.type === "Okta" || this.state.syncer.type === "SCIM" || this.state.syncer.type === "AWS IAM" ? null : (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(this.state.syncer.type === "Active Directory" ? i18next.t("ldap:Base DN") : i18next.t("syncer:Database"), i18next.t("syncer:Database - Tooltip"))} :
@@ -999,7 +1074,7 @@ class SyncerEditPage extends React.Component {
) : null
}
{
this.state.syncer.type === "WeCom" || this.state.syncer.type === "Azure AD" || this.state.syncer.type === "Google Workspace" || this.state.syncer.type === "DingTalk" || this.state.syncer.type === "Lark" || this.state.syncer.type === "Okta" || this.state.syncer.type === "SCIM" ? null : (
this.state.syncer.type === "WeCom" || this.state.syncer.type === "Azure AD" || this.state.syncer.type === "Google Workspace" || this.state.syncer.type === "DingTalk" || this.state.syncer.type === "Lark" || this.state.syncer.type === "Okta" || this.state.syncer.type === "SCIM" || this.state.syncer.type === "AWS IAM" ? null : (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("syncer:Table"), i18next.t("syncer:Table - Tooltip"))} :

View File

@@ -48,6 +48,7 @@ import FaceIdTable from "./table/FaceIdTable";
import MfaAccountTable from "./table/MfaAccountTable";
import MfaTable from "./table/MfaTable";
import TransactionTable from "./table/TransactionTable";
import CartTable from "./table/CartTable";
import * as TransactionBackend from "./backend/TransactionBackend";
import {Content, Header} from "antd/es/layout/layout";
import Sider from "antd/es/layout/Sider";
@@ -861,6 +862,17 @@ class UserEditPage extends React.Component {
</Col>
</Row>
);
} else if (accountItem.name === "Cart") {
return (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Cart"), i18next.t("general:Cart"))} :
</Col>
<Col span={22}>
<CartTable cart={this.state.user.cart} />
</Col>
</Row>
);
} else if (accountItem.name === "Transactions") {
return (
<Row style={{marginTop: "20px"}} >

View File

@@ -439,6 +439,10 @@ export function getAuthUrl(application, provider, method, code) {
const redirectOrigin = application.forcedRedirectOrigin ? application.forcedRedirectOrigin : window.location.origin;
let redirectUri = `${redirectOrigin}/callback`;
let scope = authInfo[type].scope;
// Allow provider.scopes to override default scope if specified
if (provider.scopes && provider.scopes.trim() !== "") {
scope = provider.scopes;
}
const isShortState = (provider.type === "WeChat" && navigator.userAgent.includes("MicroMessenger")) || (provider.type === "Twitter");
let applicationName = application.name;
if (application?.isShared) {

View File

@@ -0,0 +1,81 @@
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import React from "react";
import {Table} from "antd";
import i18next from "i18next";
import * as Setting from "../Setting";
class CartTable extends React.Component {
render() {
const columns = [
{
title: i18next.t("product:Name"),
dataIndex: "displayName",
key: "displayName",
width: "200px",
},
{
title: i18next.t("product:Image"),
dataIndex: "image",
key: "image",
width: "80px",
render: (text, record, index) => {
if (!text) {
return null;
}
return (
<a target="_blank" rel="noreferrer" href={text}>
<img src={text} alt={record.displayName} width={40} />
</a>
);
},
},
{
title: i18next.t("product:Price"),
dataIndex: "price",
key: "price",
width: "120px",
render: (text, record, index) => {
return Setting.getCurrencySymbol(record.currency) + text;
},
},
{
title: i18next.t("product:Quantity"),
dataIndex: "quantity",
key: "quantity",
width: "100px",
},
{
title: i18next.t("general:Detail"),
dataIndex: "detail",
key: "detail",
},
];
const cart = this.props.cart || [];
return (
<Table
columns={columns}
dataSource={cart}
rowKey={(record) => `${record.owner}/${record.name}`}
size="small"
pagination={false}
/>
);
}
}
export default CartTable;

166
web/src/table/ScopeTable.js Normal file
View File

@@ -0,0 +1,166 @@
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import React from "react";
import {DeleteOutlined, DownOutlined, UpOutlined} from "@ant-design/icons";
import {Button, Input, Table, Tooltip} from "antd";
import * as Setting from "../Setting";
import i18next from "i18next";
class ScopeTable extends React.Component {
constructor(props) {
super(props);
this.state = {
classes: props,
};
}
updateTable(table) {
this.props.onUpdateTable(table);
}
updateField(table, index, key, value) {
table[index][key] = value;
this.updateTable(table);
}
addRow(table) {
const row = {name: "", displayName: "", description: ""};
if (table === undefined) {
table = [];
}
table = Setting.addRow(table, row);
this.updateTable(table);
}
deleteRow(table, i) {
table = Setting.deleteRow(table, i);
this.updateTable(table);
}
upRow(table, i) {
table = Setting.swapRow(table, i - 1, i);
this.updateTable(table);
}
downRow(table, i) {
table = Setting.swapRow(table, i, i + 1);
this.updateTable(table);
}
renderTable(table) {
if (table === null) {
return null;
}
const columns = [
{
title: i18next.t("general:Name"),
dataIndex: "name",
key: "name",
width: "25%",
render: (text, record, index) => {
return (
<Input
value={text}
placeholder="e.g., files:read"
onChange={e => {
this.updateField(table, index, "name", e.target.value);
}}
/>
);
},
},
{
title: i18next.t("general:Display name"),
dataIndex: "displayName",
key: "displayName",
width: "25%",
render: (text, record, index) => {
return (
<Input
value={text}
placeholder="e.g., Read Files"
onChange={e => {
this.updateField(table, index, "displayName", e.target.value);
}}
/>
);
},
},
{
title: i18next.t("general:Description"),
dataIndex: "description",
key: "description",
width: "40%",
render: (text, record, index) => {
return (
<Input
value={text}
placeholder="e.g., Allow reading your files and documents"
onChange={e => {
this.updateField(table, index, "description", e.target.value);
}}
/>
);
},
},
{
title: i18next.t("general:Action"),
key: "action",
width: "10%",
render: (text, record, index) => {
return (
<div>
<Tooltip placement="bottomLeft" title={i18next.t("general:Up")}>
<Button style={{marginRight: "5px"}} disabled={index === 0} icon={<UpOutlined />} size="small" onClick={() => this.upRow(table, index)} />
</Tooltip>
<Tooltip placement="topLeft" title={i18next.t("general:Down")}>
<Button style={{marginRight: "5px"}} disabled={index === table.length - 1} icon={<DownOutlined />} size="small" onClick={() => this.downRow(table, index)} />
</Tooltip>
<Tooltip placement="topLeft" title={i18next.t("general:Delete")}>
<Button icon={<DeleteOutlined />} size="small" onClick={() => this.deleteRow(table, index)} />
</Tooltip>
</div>
);
},
},
];
return (
<div>
<Table scroll={{x: "max-content"}} rowKey={(record, index) => index} columns={columns} dataSource={table} size="middle" bordered pagination={false}
title={() => (
<div>
{this.props.title}&nbsp;&nbsp;&nbsp;&nbsp;
<Button style={{marginRight: "5px"}} type="primary" size="small" onClick={() => this.addRow(table)}>{i18next.t("general:Add")}</Button>
</div>
)}
/>
</div>
);
}
render() {
return (
<div>
{
this.renderTable(this.props.table)
}
</div>
);
}
}
export default ScopeTable;

View File

@@ -24,6 +24,8 @@ class TokenAttributeTable extends React.Component {
this.state = {
classes: props,
};
// List of available user fields for "Existing Field" category
this.userFields = ["Owner", "Name", "Id", "DisplayName", "Email", "Phone", "Tag", "Roles", "Permissions", "permissionNames", "Groups"];
}
updateTable(table) {
@@ -36,7 +38,8 @@ class TokenAttributeTable extends React.Component {
}
addRow(table) {
const row = {Name: "", nameFormat: "", value: ""};
// Note: Field names use lowercase to match JSON serialization from backend (json:"name", json:"value", json:"type", json:"category")
const row = {name: "", value: "", type: "Array", category: "Static Value"};
if (table === undefined || table === null) {
table = [];
}
@@ -74,24 +77,63 @@ class TokenAttributeTable extends React.Component {
);
},
},
{
title: i18next.t("general:Category"),
dataIndex: "category",
key: "category",
width: "150px",
render: (text, record, index) => {
return (
<Select virtual={false} style={{width: "100%"}}
value={text ?? "Static Value"}
options={[
{value: "Static Value", label: i18next.t("application:Static Value")},
{value: "Existing Field", label: i18next.t("application:Existing Field")},
].map((item) =>
Setting.getOption(item.label, item.value))
}
onChange={value => {
this.updateField(table, index, "category", value);
}} >
</Select>
);
},
},
{
title: i18next.t("webhook:Value"),
dataIndex: "value",
key: "value",
width: "200px",
render: (text, record, index) => {
return (
<Input value={text} onChange={e => {
this.updateField(table, index, "value", e.target.value);
}} />
);
const category = record.category ?? "Static Value";
if (category === "Existing Field") {
// Show dropdown for existing fields
return (
<Select virtual={false} style={{width: "100%"}}
value={text}
options={this.userFields.map((field) =>
Setting.getOption(field, field))
}
onChange={value => {
this.updateField(table, index, "value", value);
}} >
</Select>
);
} else {
// Show text input for static values
return (
<Input value={text} onChange={e => {
this.updateField(table, index, "value", e.target.value);
}} />
);
}
},
},
{
title: i18next.t("general:Type"),
dataIndex: "type",
key: "type",
width: "200px",
width: "150px",
render: (text, record, index) => {
return (
<Select virtual={false} style={{width: "100%"}}