forked from casdoor/casdoor
Compare commits
24 Commits
v2.306.0
...
copilot/im
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed74d69fcc | ||
|
|
d898202dad | ||
|
|
b25f11ad9a | ||
|
|
103e2fef02 | ||
|
|
fa89321cb7 | ||
|
|
376fa9751f | ||
|
|
3cb9df3723 | ||
|
|
9d1e5c10d0 | ||
|
|
ef84c4b0b4 | ||
|
|
5a108bd921 | ||
|
|
ac671ec1ee | ||
|
|
7814caf2ab | ||
|
|
f966f4a0f9 | ||
|
|
a4b1a068a8 | ||
|
|
362797678d | ||
|
|
7879e1bf09 | ||
|
|
c246f102c9 | ||
|
|
37d1c4910c | ||
|
|
3bcde7cb7c | ||
|
|
6a90d21941 | ||
|
|
80b4c0b1a7 | ||
|
|
eb5a422026 | ||
|
|
f7bd70e0a3 | ||
|
|
5e7dbe4b56 |
@@ -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, *, *
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
74
controllers/oauth_dcr.go
Normal 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()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
45
controllers/wellknown_oauth_prm.go
Normal file
45
controllers/wellknown_oauth_prm.go
Normal 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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
5
go.mod
5
go.mod
@@ -30,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
|
||||
@@ -50,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
|
||||
@@ -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
24
go.sum
@@ -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=
|
||||
|
||||
@@ -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"},
|
||||
|
||||
56
mcp/auth.go
56
mcp/auth.go
@@ -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, " ")
|
||||
}
|
||||
|
||||
211
mcp/base.go
211
mcp/base.go
@@ -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, ¶ms)
|
||||
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, ¶ms)
|
||||
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
158
mcp/permission.go
Normal 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 ""
|
||||
}
|
||||
@@ -67,12 +67,22 @@ type JwtItem struct {
|
||||
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 {
|
||||
Owner string `xorm:"varchar(100) notnull pk" json:"owner"`
|
||||
Name string `xorm:"varchar(100) notnull pk" json:"name"`
|
||||
CreatedTime string `xorm:"varchar(100)" json:"createdTime"`
|
||||
|
||||
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"`
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
193
object/oauth_dcr.go
Normal file
193
object/oauth_dcr.go
Normal 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
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -509,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)))
|
||||
@@ -553,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}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
59
object/wellknown_oauth_prm.go
Normal file
59
object/wellknown_oauth_prm.go
Normal 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"},
|
||||
}
|
||||
}
|
||||
@@ -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"},
|
||||
41
pp/dummy.go
41
pp/dummy.go
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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}}>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -124,7 +124,7 @@ class CartListPage extends BaseListPage {
|
||||
|
||||
const index = user.cart.findIndex(item =>
|
||||
item.name === record.name &&
|
||||
(record.price !== null ? item.price === record.price : true) &&
|
||||
(record.isRecharge ? item.price === record.price : true) &&
|
||||
(item.pricingName || "") === (record.pricingName || "") &&
|
||||
(item.planName || "") === (record.planName || ""));
|
||||
if (index === -1) {
|
||||
@@ -445,6 +445,7 @@ class CartListPage extends BaseListPage {
|
||||
}
|
||||
return {
|
||||
...pRes.data,
|
||||
createdTime: item.createdTime,
|
||||
pricingName: item.pricingName,
|
||||
planName: item.planName,
|
||||
quantity: item.quantity,
|
||||
@@ -482,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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
@@ -207,6 +208,7 @@ class ProductBuyPage extends React.Component {
|
||||
} else {
|
||||
const newProductInfo = {
|
||||
name: product.name,
|
||||
createdTime: moment().format(),
|
||||
price: cartPrice,
|
||||
currency: product.currency,
|
||||
pricingName: pricingName,
|
||||
|
||||
@@ -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";
|
||||
@@ -140,6 +141,7 @@ class ProductStorePage extends React.Component {
|
||||
} else {
|
||||
const newCartProductInfo = {
|
||||
name: product.name,
|
||||
createdTime: moment().format(),
|
||||
currency: product.currency,
|
||||
pricingName: "",
|
||||
planName: "",
|
||||
|
||||
@@ -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());
|
||||
@@ -1294,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>
|
||||
)}
|
||||
|
||||
@@ -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")},
|
||||
|
||||
@@ -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"}} >
|
||||
|
||||
81
web/src/table/CartTable.js
Normal file
81
web/src/table/CartTable.js
Normal 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
166
web/src/table/ScopeTable.js
Normal 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}
|
||||
<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;
|
||||
Reference in New Issue
Block a user