Compare commits

...

47 Commits

Author SHA1 Message Date
e926a07c58 feat: add "User type" to user list page 2025-02-12 21:29:18 +08:00
9c46344e68 feat: improve default org passwordOptions handling 2025-02-12 21:20:32 +08:00
c0ec73dfd3 feat: fix tableNamePrefix doesn't work bug in /get-dashboard API (#3572) 2025-02-11 17:20:45 +08:00
b1b6ebe692 feat(jwt): add azp claim to ID token (#3570)
Added the `azp` (Authorized Party) claim to various JWT token structures
including Claims, ClaimsShort, ClaimsWithoutThirdIdp, and ClaimsStandard.
Updated the generateJwtToken and getClaimsCustom functions to handle the
new claim. This change aligns with the OpenID Connect specification.
2025-02-10 20:44:44 +08:00
a0931e4597 feat: add userTypes to Organization 2025-02-09 17:12:13 +08:00
c181006661 feat: cache theme in signup page (#3568) 2025-02-09 15:12:35 +08:00
2e83e49492 feat: fix bug due to null characters in descriptor when creating a payment intent (#3567) 2025-02-08 19:35:51 +08:00
5661942175 feat: add CLI version cache and proxy support (#3565)
* feat: add CLI version cache mechanism

* feat: add /api/refresh-engines to allowed endpoints in demo mode

* feat: add proxy support for cli downloader

* feat: add SafeGoroutine for CLIDownloader initialization

* refactor: optimize code structure
2025-02-08 19:34:19 +08:00
7f9f7c6468 feat: add CLI tools auto-downloader and updater (#3559)
* feat: add CLI downloader feature

* feat: add CLI refresh endpoint and scheduler

* feat: improve binary names mapping for different platforms and architectures

* fix: format binary names in getBinaryNames function

* fix: change file permission notation to octal in cli_downloader.go

* feat: add isDemoMode check for CLI downloader features
2025-02-07 19:22:56 +08:00
b7a818e2d3 feat: support AirWallex payment provider (#3558)
* feat: support AirWallex payment provider

* chore: add some information due to AirWallex's risk control policy
2025-02-07 19:19:30 +08:00
1a8cfe4ee6 feat: can fetch SAML metadata from URL (#3560) 2025-02-06 23:50:39 +08:00
b3526de675 feat: add checkOrgMasterVerificationCode() 2025-02-06 23:46:22 +08:00
3b9e08b70d feat: Fix reset password flow for shared application (#3556) 2025-02-06 18:03:23 +08:00
cfc6015aca feat: rename Casdoor app URL to authenticator (#3553) 2025-02-05 23:08:06 +08:00
1600a6799a feat: return error for updateUsedByCode() 2025-02-05 13:40:41 +08:00
ca60cc3a33 feat: show SAML cert parse error better in frontend (#3551) 2025-02-05 10:06:02 +08:00
df295717f0 feat: can define what Casdoor pages an org admin can see via Organization.NavItems (#3539)
* feat: support define what Casdoor pages an org admin can see

* feat: remove useless code

* fix: fix NavItemNodes i18next invalid

* fix: only global admin can edit navItems

* fix: move navItem tree to extra file
2025-02-03 00:40:21 +08:00
e3001671a2 feat: fix bug that can not delete user if user doesn't belong to any group (#3544) 2025-02-02 17:54:05 +08:00
bbe2162e27 feat: fix bug in GetTokenByTokenValue() (#3541) 2025-01-30 00:48:20 +08:00
92b5ce3722 feat: add identifier validation for security in RunCasbinCommand (#3535)
* feat: add identifier validation for security in RunCasbinCommand

* refactor: update identifier validation to use SHA-256 hash and timestamp
2025-01-29 18:30:06 +08:00
bad21fb6bb feat: check empty password in CheckPassword() 2025-01-28 21:13:59 +08:00
5a78dcf06d feat: fix Casbin Permissions Not Working When Auto-login is Enabled (#3537)
* fix: fix Casbin Permissions Not Working When Auto-login is Enabled

* fix: fix oauth fastLogin not support permission
2025-01-28 19:15:53 +08:00
558b168477 feat: can verify OTP during OAuth login (#3531)
* feat: support verify OTP during OAuth login

* fix: fail to login if mfa not enable

* fix: fail to login if mfa not enable

* fix: fix mfaRequired not valid in saml/auth
2025-01-27 19:37:26 +08:00
802b6812a9 feat: fix strange "Email is invalid" error in forget password page (#3527) 2025-01-23 14:35:11 +08:00
a5a627f92e feat: optimize get-groups API and GroupListPage (#3518)
* fix: optimize get-groups api and GroupListPage

* fix: fix linter issue
2025-01-23 09:47:39 +08:00
9701818a6e feat: delete groups for user while deleting user (#3525) 2025-01-23 09:46:33 +08:00
06986fbd41 feat: fix theme filter for other URLs like SAML (#3523)
* fix: fix error cause by theme filter

* fix: add saml url to theme filter and use getGetOwnerAndNameFromIdWithError instead of using GetOwnerAndNameFromId

* fix: fix code error

* fix: add support for cas and pack judgement into a function

* fix: fix linter err
2025-01-22 19:12:12 +08:00
3d12ac8dc2 feat: improve HandleScim() 2025-01-22 16:15:19 +08:00
f01839123f feat: fix missing param recoveryCodes in /mfa/setup/enable API (#3520) 2025-01-21 22:56:02 +08:00
e1b3b0ac6a feat: allow user use other mfaType in mfa step and skip redundant MFA verification (#3499)
* feat: allow user use other mfaType in mfa step and skip redundant MFA verification

* feat: improve format
2025-01-21 20:16:18 +08:00
4b0a2fdbfc feat: append HTML document title and favicon to cookie (#3519)
* feat: append HTML document title and favicon to cookie

* feat: remove useless cookie
2025-01-21 19:42:21 +08:00
db551eb24a feat: LDAP user can reset password with old password and new password (#3516)
* feat: support user reset password with old password and new password

* feat: merge similar code
2025-01-20 21:42:05 +08:00
18b49bb731 feat: can reset LDAP password with different password encryption methods (#3513) 2025-01-20 20:00:23 +08:00
17653888a3 feat: refactor the TestSmtpServer code 2025-01-20 03:17:09 +08:00
ee16616df4 feat: support socks5Proxy for AWS Email provider 2025-01-20 02:39:23 +08:00
ea450005e0 feat: fix "logo" bug in footer 2025-01-20 00:01:46 +08:00
4c5ad14f6b fix: spin will squeeze login panel (#3509) 2025-01-19 23:35:04 +08:00
49dda2aea5 feat: append footerHtml to cookie (#3508) 2025-01-19 23:34:43 +08:00
a74a004540 feat: append logo url to cookie (#3507) 2025-01-19 08:02:44 +08:00
2b89f6b37b feat: fix issue that application theme is ignored in appendThemeCookie() (#3506) 2025-01-18 21:28:39 +08:00
c699e35e6b feat: load theme from first HTML render cookie (#3505) 2025-01-18 19:04:16 +08:00
e28d90d0aa feat: support CUCloud SMN notification provider (#3502) 2025-01-17 08:35:31 +08:00
4fc7600865 feat: skip update user ranking if ranking not in accountItem (#3500) 2025-01-14 22:43:49 +08:00
19f62a461b feat: fix SAML's redirectUrl and POST ProtocolBinding (#3498) 2025-01-13 20:55:37 +08:00
7ddc2778c0 feat: show error message when organization doesn't have default application in invitation edit page (#3495)
* fix: inform user when organization haven't default application in signup page

* fix: include org name in the error message
2025-01-12 22:48:21 +08:00
b96fa2a995 feat: skip GetUserCount() if there is no quota limit (#3491) 2025-01-10 22:28:25 +08:00
fcfb73af6e feat: increase org password field length to 200 2025-01-09 20:07:49 +08:00
95 changed files with 2128 additions and 362 deletions

View File

@ -99,6 +99,7 @@ p, *, *, GET, /api/get-all-objects, *, *
p, *, *, GET, /api/get-all-actions, *, *
p, *, *, GET, /api/get-all-roles, *, *
p, *, *, GET, /api/run-casbin-command, *, *
p, *, *, POST, /api/refresh-engines, *, *
p, *, *, GET, /api/get-invitation-info, *, *
p, *, *, GET, /api/faceid-signin-begin, *, *
`
@ -156,7 +157,7 @@ func IsAllowed(subOwner string, subName string, method string, urlPath string, o
func isAllowedInDemoMode(subOwner string, subName string, method string, urlPath string, objOwner string, objName string) bool {
if method == "POST" {
if strings.HasPrefix(urlPath, "/api/login") || urlPath == "/api/logout" || urlPath == "/api/signup" || urlPath == "/api/callback" || urlPath == "/api/send-verification-code" || urlPath == "/api/send-email" || urlPath == "/api/verify-captcha" || urlPath == "/api/verify-code" || urlPath == "/api/check-user-password" || strings.HasPrefix(urlPath, "/api/mfa/") || urlPath == "/api/webhook" || urlPath == "/api/get-qrcode" {
if strings.HasPrefix(urlPath, "/api/login") || urlPath == "/api/logout" || urlPath == "/api/signup" || urlPath == "/api/callback" || urlPath == "/api/send-verification-code" || urlPath == "/api/send-email" || urlPath == "/api/verify-captcha" || urlPath == "/api/verify-code" || urlPath == "/api/check-user-password" || strings.HasPrefix(urlPath, "/api/mfa/") || urlPath == "/api/webhook" || urlPath == "/api/get-qrcode" || urlPath == "/api/refresh-engines" {
return true
} else if urlPath == "/api/update-user" {
// Allow ordinary users to update their own information

View File

@ -306,6 +306,35 @@ func isProxyProviderType(providerType string) bool {
return false
}
func checkMfaEnable(c *ApiController, user *object.User, organization *object.Organization, verificationType string) bool {
if object.IsNeedPromptMfa(organization, user) {
// The prompt page needs the user to be srigned in
c.SetSessionUsername(user.GetId())
c.ResponseOk(object.RequiredMfa)
return true
}
if user.IsMfaEnabled() {
c.setMfaUserSession(user.GetId())
mfaList := object.GetAllMfaProps(user, true)
mfaAllowList := []*object.MfaProps{}
for _, prop := range mfaList {
if prop.MfaType == verificationType || !prop.Enabled {
continue
}
mfaAllowList = append(mfaAllowList, prop)
}
if len(mfaAllowList) >= 1 {
c.SetSession("verificationCodeType", verificationType)
c.Ctx.Input.CruSession.SessionRelease(c.Ctx.ResponseWriter)
c.ResponseOk(object.NextMfa, mfaAllowList)
return true
}
}
return false
}
// Login ...
// @Title Login
// @Tag Login API
@ -331,6 +360,8 @@ func (c *ApiController) Login() {
return
}
verificationType := ""
if authForm.Username != "" {
if authForm.Type == ResponseTypeLogin {
if c.GetSessionUsername() != "" {
@ -425,6 +456,12 @@ func (c *ApiController) Login() {
c.ResponseError(err.Error(), nil)
return
}
if verificationCodeType == object.VerifyTypePhone {
verificationType = "sms"
} else {
verificationType = "email"
}
} else {
var application *object.Application
application, err = object.GetApplication(fmt.Sprintf("admin/%s", authForm.Application))
@ -515,16 +552,7 @@ func (c *ApiController) Login() {
c.ResponseError(err.Error())
}
if object.IsNeedPromptMfa(organization, user) {
// The prompt page needs the user to be signed in
c.SetSessionUsername(user.GetId())
c.ResponseOk(object.RequiredMfa)
return
}
if user.IsMfaEnabled() {
c.setMfaUserSession(user.GetId())
c.ResponseOk(object.NextMfa, user.GetPreferredMfaProps(true))
if checkMfaEnable(c, user, organization, verificationType) {
return
}
@ -660,6 +688,11 @@ func (c *ApiController) Login() {
c.ResponseError(err.Error())
return
}
if checkMfaEnable(c, user, organization, verificationType) {
return
}
resp = c.HandleLoggedIn(application, user, &authForm)
c.Ctx.Input.SetParam("recordUserId", user.GetId())
@ -866,18 +899,32 @@ func (c *ApiController) Login() {
}
if authForm.Passcode != "" {
if authForm.MfaType == c.GetSession("verificationCodeType") {
c.ResponseError("Invalid multi-factor authentication type")
return
}
user.CountryCode = user.GetCountryCode(user.CountryCode)
mfaUtil := object.GetMfaUtil(authForm.MfaType, user.GetPreferredMfaProps(false))
mfaUtil := object.GetMfaUtil(authForm.MfaType, user.GetMfaProps(authForm.MfaType, false))
if mfaUtil == nil {
c.ResponseError("Invalid multi-factor authentication type")
return
}
err = mfaUtil.Verify(authForm.Passcode)
passed, err := c.checkOrgMasterVerificationCode(user, authForm.Passcode)
if err != nil {
c.ResponseError(err.Error())
return
}
if !passed {
err = mfaUtil.Verify(authForm.Passcode)
if err != nil {
c.ResponseError(err.Error())
return
}
}
c.SetSession("verificationCodeType", "")
} else if authForm.RecoveryCode != "" {
err = object.MfaRecover(user, authForm.RecoveryCode)
if err != nil {
@ -890,7 +937,11 @@ func (c *ApiController) Login() {
}
var application *object.Application
application, err = object.GetApplication(fmt.Sprintf("admin/%s", authForm.Application))
if authForm.ClientId == "" {
application, err = object.GetApplication(fmt.Sprintf("admin/%s", authForm.Application))
} else {
application, err = object.GetApplicationByClientId(authForm.ClientId)
}
if err != nil {
c.ResponseError(err.Error())
return
@ -920,6 +971,10 @@ func (c *ApiController) Login() {
return
}
if authForm.Provider == "" {
authForm.Provider = authForm.ProviderBack
}
user := c.getCurrentUser()
resp = c.HandleLoggedIn(application, user, &authForm)

View File

@ -15,13 +15,76 @@
package controllers
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"os/exec"
"sort"
"strings"
"sync"
"time"
)
type CLIVersionInfo struct {
Version string
BinaryPath string
BinaryTime time.Time
}
var (
cliVersionCache = make(map[string]*CLIVersionInfo)
cliVersionMutex sync.RWMutex
)
// getCLIVersion
// @Title getCLIVersion
// @Description Get CLI version with cache mechanism
// @Param language string The language of CLI (go/java/rust etc.)
// @Return string The version string of CLI
// @Return error Error if CLI execution fails
func getCLIVersion(language string) (string, error) {
binaryName := fmt.Sprintf("casbin-%s-cli", language)
binaryPath, err := exec.LookPath(binaryName)
if err != nil {
return "", fmt.Errorf("executable file not found: %v", err)
}
fileInfo, err := os.Stat(binaryPath)
if err != nil {
return "", fmt.Errorf("failed to get binary info: %v", err)
}
cliVersionMutex.RLock()
if info, exists := cliVersionCache[language]; exists {
if info.BinaryPath == binaryPath && info.BinaryTime == fileInfo.ModTime() {
cliVersionMutex.RUnlock()
return info.Version, nil
}
}
cliVersionMutex.RUnlock()
cmd := exec.Command(binaryName, "--version")
output, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("failed to get CLI version: %v", err)
}
version := strings.TrimSpace(string(output))
cliVersionMutex.Lock()
cliVersionCache[language] = &CLIVersionInfo{
Version: version,
BinaryPath: binaryPath,
BinaryTime: fileInfo.ModTime(),
}
cliVersionMutex.Unlock()
return version, nil
}
func processArgsToTempFiles(args []string) ([]string, []string, error) {
tempFiles := []string{}
newArgs := []string{}
@ -57,6 +120,11 @@ func processArgsToTempFiles(args []string) ([]string, []string, error) {
// @Success 200 {object} controllers.Response The Response object
// @router /run-casbin-command [get]
func (c *ApiController) RunCasbinCommand() {
if err := validateIdentifier(c); err != nil {
c.ResponseError(err.Error())
return
}
language := c.Input().Get("language")
argString := c.Input().Get("args")
@ -84,6 +152,16 @@ func (c *ApiController) RunCasbinCommand() {
return
}
if len(args) > 0 && args[0] == "--version" {
version, err := getCLIVersion(language)
if err != nil {
c.ResponseError(err.Error())
return
}
c.ResponseOk(version)
return
}
tempFiles, processedArgs, err := processArgsToTempFiles(args)
defer func() {
for _, file := range tempFiles {
@ -112,3 +190,58 @@ func (c *ApiController) RunCasbinCommand() {
output = strings.TrimSuffix(output, "\n")
c.ResponseOk(output)
}
// validateIdentifier
// @Title validateIdentifier
// @Description Validate the request hash and timestamp
// @Param hash string The SHA-256 hash string
// @Return error Returns error if validation fails, nil if successful
func validateIdentifier(c *ApiController) error {
language := c.Input().Get("language")
args := c.Input().Get("args")
hash := c.Input().Get("m")
timestamp := c.Input().Get("t")
if hash == "" || timestamp == "" || language == "" || args == "" {
return fmt.Errorf("invalid identifier")
}
requestTime, err := time.Parse(time.RFC3339, timestamp)
if err != nil {
return fmt.Errorf("invalid identifier")
}
timeDiff := time.Since(requestTime)
if timeDiff > 5*time.Minute || timeDiff < -5*time.Minute {
return fmt.Errorf("invalid identifier")
}
params := map[string]string{
"language": language,
"args": args,
}
keys := make([]string, 0, len(params))
for k := range params {
keys = append(keys, k)
}
sort.Strings(keys)
var paramParts []string
for _, k := range keys {
paramParts = append(paramParts, fmt.Sprintf("%s=%s", k, params[k]))
}
paramString := strings.Join(paramParts, "&")
version := "casbin-editor-v1"
rawString := fmt.Sprintf("%s|%s|%s", version, timestamp, paramString)
hasher := sha256.New()
hasher.Write([]byte(rawString))
calculatedHash := strings.ToLower(hex.EncodeToString(hasher.Sum(nil)))
if calculatedHash != strings.ToLower(hash) {
return fmt.Errorf("invalid identifier")
}
return nil
}

View File

@ -0,0 +1,519 @@
package controllers
import (
"archive/tar"
"archive/zip"
"compress/gzip"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/beego/beego"
"github.com/casdoor/casdoor/proxy"
"github.com/casdoor/casdoor/util"
)
const (
javaCliRepo = "https://api.github.com/repos/jcasbin/casbin-java-cli/releases/latest"
goCliRepo = "https://api.github.com/repos/casbin/casbin-go-cli/releases/latest"
rustCliRepo = "https://api.github.com/repos/casbin-rs/casbin-rust-cli/releases/latest"
downloadFolder = "bin"
)
type ReleaseInfo struct {
TagName string `json:"tag_name"`
Assets []struct {
Name string `json:"name"`
URL string `json:"browser_download_url"`
} `json:"assets"`
}
// @Title getBinaryNames
// @Description Get binary names for different platforms and architectures
// @Success 200 {map[string]string} map[string]string "Binary names map"
func getBinaryNames() map[string]string {
const (
golang = "go"
java = "java"
rust = "rust"
)
arch := runtime.GOARCH
archMap := map[string]struct{ goArch, rustArch string }{
"amd64": {"x86_64", "x86_64"},
"arm64": {"arm64", "aarch64"},
}
archNames, ok := archMap[arch]
if !ok {
archNames = struct{ goArch, rustArch string }{arch, arch}
}
switch runtime.GOOS {
case "windows":
return map[string]string{
golang: fmt.Sprintf("casbin-go-cli_Windows_%s.zip", archNames.goArch),
java: "casbin-java-cli.jar",
rust: fmt.Sprintf("casbin-rust-cli-%s-pc-windows-gnu", archNames.rustArch),
}
case "darwin":
return map[string]string{
golang: fmt.Sprintf("casbin-go-cli_Darwin_%s.tar.gz", archNames.goArch),
java: "casbin-java-cli.jar",
rust: fmt.Sprintf("casbin-rust-cli-%s-apple-darwin", archNames.rustArch),
}
case "linux":
return map[string]string{
golang: fmt.Sprintf("casbin-go-cli_Linux_%s.tar.gz", archNames.goArch),
java: "casbin-java-cli.jar",
rust: fmt.Sprintf("casbin-rust-cli-%s-unknown-linux-gnu", archNames.rustArch),
}
default:
return nil
}
}
// @Title getFinalBinaryName
// @Description Get final binary name for specific language
// @Param lang string true "Language type (go/java/rust)"
// @Success 200 {string} string "Final binary name"
func getFinalBinaryName(lang string) string {
switch lang {
case "go":
if runtime.GOOS == "windows" {
return "casbin-go-cli.exe"
}
return "casbin-go-cli"
case "java":
return "casbin-java-cli.jar"
case "rust":
if runtime.GOOS == "windows" {
return "casbin-rust-cli.exe"
}
return "casbin-rust-cli"
default:
return ""
}
}
// @Title getLatestCLIURL
// @Description Get latest CLI download URL from GitHub
// @Param repoURL string true "GitHub repository URL"
// @Param language string true "Language type"
// @Success 200 {string} string "Download URL and version"
func getLatestCLIURL(repoURL string, language string) (string, string, error) {
client := proxy.GetHttpClient(repoURL)
resp, err := client.Get(repoURL)
if err != nil {
return "", "", fmt.Errorf("failed to fetch release info: %v", err)
}
defer resp.Body.Close()
var release ReleaseInfo
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return "", "", err
}
binaryNames := getBinaryNames()
if binaryNames == nil {
return "", "", fmt.Errorf("unsupported OS: %s", runtime.GOOS)
}
binaryName := binaryNames[language]
for _, asset := range release.Assets {
if asset.Name == binaryName {
return asset.URL, release.TagName, nil
}
}
return "", "", fmt.Errorf("no suitable binary found for OS: %s, language: %s", runtime.GOOS, language)
}
// @Title extractGoCliFile
// @Description Extract the Go CLI file
// @Param filePath string true "The file path"
// @Success 200 {string} string "The extracted file path"
// @router /extractGoCliFile [post]
func extractGoCliFile(filePath string) error {
tempDir := filepath.Join(downloadFolder, "temp")
if err := os.MkdirAll(tempDir, 0o755); err != nil {
return err
}
defer os.RemoveAll(tempDir)
if runtime.GOOS == "windows" {
if err := unzipFile(filePath, tempDir); err != nil {
return err
}
} else {
if err := untarFile(filePath, tempDir); err != nil {
return err
}
}
execName := "casbin-go-cli"
if runtime.GOOS == "windows" {
execName += ".exe"
}
var execPath string
err := filepath.Walk(tempDir, func(path string, info os.FileInfo, err error) error {
if info.Name() == execName {
execPath = path
return nil
}
return nil
})
if err != nil {
return err
}
finalPath := filepath.Join(downloadFolder, execName)
if err := os.Rename(execPath, finalPath); err != nil {
return err
}
return os.Remove(filePath)
}
// @Title unzipFile
// @Description Unzip the file
// @Param zipPath string true "The zip file path"
// @Param destDir string true "The destination directory"
// @Success 200 {string} string "The extracted file path"
// @router /unzipFile [post]
func unzipFile(zipPath, destDir string) error {
r, err := zip.OpenReader(zipPath)
if err != nil {
return err
}
defer r.Close()
for _, f := range r.File {
fpath := filepath.Join(destDir, f.Name)
if f.FileInfo().IsDir() {
os.MkdirAll(fpath, os.ModePerm)
continue
}
if err = os.MkdirAll(filepath.Dir(fpath), os.ModePerm); err != nil {
return err
}
outFile, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
if err != nil {
return err
}
rc, err := f.Open()
if err != nil {
outFile.Close()
return err
}
_, err = io.Copy(outFile, rc)
outFile.Close()
rc.Close()
if err != nil {
return err
}
}
return nil
}
// @Title untarFile
// @Description Untar the file
// @Param tarPath string true "The tar file path"
// @Param destDir string true "The destination directory"
// @Success 200 {string} string "The extracted file path"
// @router /untarFile [post]
func untarFile(tarPath, destDir string) error {
file, err := os.Open(tarPath)
if err != nil {
return err
}
defer file.Close()
gzr, err := gzip.NewReader(file)
if err != nil {
return err
}
defer gzr.Close()
tr := tar.NewReader(gzr)
for {
header, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return err
}
path := filepath.Join(destDir, header.Name)
switch header.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(path, 0o755); err != nil {
return err
}
case tar.TypeReg:
outFile, err := os.Create(path)
if err != nil {
return err
}
if _, err := io.Copy(outFile, tr); err != nil {
outFile.Close()
return err
}
outFile.Close()
}
}
return nil
}
// @Title createJavaCliWrapper
// @Description Create the Java CLI wrapper
// @Param binPath string true "The binary path"
// @Success 200 {string} string "The created file path"
// @router /createJavaCliWrapper [post]
func createJavaCliWrapper(binPath string) error {
if runtime.GOOS == "windows" {
// Create a Windows CMD file
cmdPath := filepath.Join(binPath, "casbin-java-cli.cmd")
cmdContent := fmt.Sprintf(`@echo off
java -jar "%s\casbin-java-cli.jar" %%*`, binPath)
err := os.WriteFile(cmdPath, []byte(cmdContent), 0o755)
if err != nil {
return fmt.Errorf("failed to create Java CLI wrapper: %v", err)
}
} else {
// Create Unix shell script
shPath := filepath.Join(binPath, "casbin-java-cli")
shContent := fmt.Sprintf(`#!/bin/sh
java -jar "%s/casbin-java-cli.jar" "$@"`, binPath)
err := os.WriteFile(shPath, []byte(shContent), 0o755)
if err != nil {
return fmt.Errorf("failed to create Java CLI wrapper: %v", err)
}
}
return nil
}
// @Title downloadCLI
// @Description Download and setup CLI tools
// @Success 200 {error} error "Error if any"
func downloadCLI() error {
pathEnv := os.Getenv("PATH")
binPath, err := filepath.Abs(downloadFolder)
if err != nil {
return fmt.Errorf("failed to get absolute path to download directory: %v", err)
}
if !strings.Contains(pathEnv, binPath) {
newPath := fmt.Sprintf("%s%s%s", binPath, string(os.PathListSeparator), pathEnv)
if err := os.Setenv("PATH", newPath); err != nil {
return fmt.Errorf("failed to update PATH environment variable: %v", err)
}
}
if err := os.MkdirAll(downloadFolder, 0o755); err != nil {
return fmt.Errorf("failed to create download directory: %v", err)
}
repos := map[string]string{
"java": javaCliRepo,
"go": goCliRepo,
"rust": rustCliRepo,
}
for lang, repo := range repos {
cliURL, version, err := getLatestCLIURL(repo, lang)
if err != nil {
fmt.Printf("failed to get %s CLI URL: %v\n", lang, err)
continue
}
originalPath := filepath.Join(downloadFolder, getBinaryNames()[lang])
fmt.Printf("downloading %s CLI: %s\n", lang, cliURL)
client := proxy.GetHttpClient(cliURL)
resp, err := client.Get(cliURL)
if err != nil {
fmt.Printf("failed to download %s CLI: %v\n", lang, err)
continue
}
func() {
defer resp.Body.Close()
if err := os.MkdirAll(filepath.Dir(originalPath), 0o755); err != nil {
fmt.Printf("failed to create directory for %s CLI: %v\n", lang, err)
return
}
tmpFile := originalPath + ".tmp"
out, err := os.Create(tmpFile)
if err != nil {
fmt.Printf("failed to create or write %s CLI: %v\n", lang, err)
return
}
defer func() {
out.Close()
os.Remove(tmpFile)
}()
if _, err = io.Copy(out, resp.Body); err != nil ||
out.Close() != nil ||
os.Rename(tmpFile, originalPath) != nil {
fmt.Printf("failed to download %s CLI: %v\n", lang, err)
return
}
}()
if lang == "go" {
if err := extractGoCliFile(originalPath); err != nil {
fmt.Printf("failed to extract Go CLI: %v\n", err)
continue
}
} else {
finalPath := filepath.Join(downloadFolder, getFinalBinaryName(lang))
if err := os.Rename(originalPath, finalPath); err != nil {
fmt.Printf("failed to rename %s CLI: %v\n", lang, err)
continue
}
}
if runtime.GOOS != "windows" {
execPath := filepath.Join(downloadFolder, getFinalBinaryName(lang))
if err := os.Chmod(execPath, 0o755); err != nil {
fmt.Printf("failed to set %s CLI execution permission: %v\n", lang, err)
continue
}
}
fmt.Printf("downloaded %s CLI version: %s\n", lang, version)
if lang == "java" {
if err := createJavaCliWrapper(binPath); err != nil {
fmt.Printf("failed to create Java CLI wrapper: %v\n", err)
continue
}
}
}
return nil
}
// @Title RefreshEngines
// @Tag CLI API
// @Description Refresh all CLI engines
// @Param m query string true "Hash for request validation"
// @Param t query string true "Timestamp for request validation"
// @Success 200 {object} controllers.Response The Response object
// @router /refresh-engines [post]
func (c *ApiController) RefreshEngines() {
if !beego.AppConfig.DefaultBool("isDemoMode", false) {
c.ResponseError("refresh engines is only available in demo mode")
return
}
hash := c.Input().Get("m")
timestamp := c.Input().Get("t")
if hash == "" || timestamp == "" {
c.ResponseError("invalid identifier")
return
}
requestTime, err := time.Parse(time.RFC3339, timestamp)
if err != nil {
c.ResponseError("invalid identifier")
return
}
timeDiff := time.Since(requestTime)
if timeDiff > 5*time.Minute || timeDiff < -5*time.Minute {
c.ResponseError("invalid identifier")
return
}
version := "casbin-editor-v1"
rawString := fmt.Sprintf("%s|%s", version, timestamp)
hasher := sha256.New()
hasher.Write([]byte(rawString))
calculatedHash := strings.ToLower(hex.EncodeToString(hasher.Sum(nil)))
if calculatedHash != strings.ToLower(hash) {
c.ResponseError("invalid identifier")
return
}
err = downloadCLI()
if err != nil {
c.ResponseError(fmt.Sprintf("failed to refresh engines: %v", err))
return
}
c.ResponseOk(map[string]string{
"status": "success",
"message": "CLI engines updated successfully",
})
}
// @Title ScheduleCLIUpdater
// @Description Start periodic CLI update scheduler
func ScheduleCLIUpdater() {
if !beego.AppConfig.DefaultBool("isDemoMode", false) {
return
}
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
for range ticker.C {
err := downloadCLI()
if err != nil {
fmt.Printf("failed to update CLI: %v\n", err)
} else {
fmt.Println("CLI updated successfully")
}
}
}
// @Title DownloadCLI
// @Description Download the CLI
// @Success 200 {string} string "The downloaded file path"
// @router /downloadCLI [post]
func DownloadCLI() error {
return downloadCLI()
}
// @Title InitCLIDownloader
// @Description Initialize CLI downloader and start update scheduler
func InitCLIDownloader() {
if !beego.AppConfig.DefaultBool("isDemoMode", false) {
return
}
util.SafeGoroutine(func() {
err := DownloadCLI()
if err != nil {
fmt.Printf("failed to initialize CLI downloader: %v\n", err)
}
ScheduleCLIUpdater()
})
}

View File

@ -70,15 +70,33 @@ func (c *ApiController) GetGroups() {
if err != nil {
c.ResponseError(err.Error())
return
} else {
err = object.ExtendGroupsWithUsers(groups)
if err != nil {
c.ResponseError(err.Error())
return
}
groupsHaveChildrenMap, err := object.GetGroupsHaveChildrenMap(groups)
if err != nil {
c.ResponseError(err.Error())
return
}
for _, group := range groups {
_, ok := groupsHaveChildrenMap[group.Name]
if ok {
group.HaveChildren = true
}
c.ResponseOk(groups, paginator.Nums())
parent, ok := groupsHaveChildrenMap[group.ParentId]
if ok {
group.ParentName = parent.DisplayName
}
}
err = object.ExtendGroupsWithUsers(groups)
if err != nil {
c.ResponseError(err.Error())
return
}
c.ResponseOk(groups, paginator.Nums())
}
}

View File

@ -124,7 +124,9 @@ func (c *ApiController) UpdateOrganization() {
return
}
c.Data["json"] = wrapActionResponse(object.UpdateOrganization(id, &organization))
isGlobalAdmin, _ := c.isGlobalAdmin()
c.Data["json"] = wrapActionResponse(object.UpdateOrganization(id, &organization, isGlobalAdmin))
c.ServeJSON()
}

View File

@ -21,6 +21,11 @@ import (
)
func (c *RootController) HandleScim() {
_, ok := c.RequireAdmin()
if !ok {
return
}
path := c.Ctx.Request.URL.Path
c.Ctx.Request.URL.Path = strings.TrimPrefix(path, "/scim")
scim.Server.ServeHTTP(c.Ctx.ResponseWriter, c.Ctx.Request)

View File

@ -93,7 +93,7 @@ func (c *ApiController) SendEmail() {
// when receiver is the reserved keyword: "TestSmtpServer", it means to test the SMTP server instead of sending a real Email
if len(emailForm.Receivers) == 1 && emailForm.Receivers[0] == "TestSmtpServer" {
err = object.DailSmtpServer(provider)
err = object.TestSmtpServer(provider)
if err != nil {
c.ResponseError(err.Error())
return

View File

@ -353,13 +353,7 @@ func (c *ApiController) AddUser() {
return
}
count, err := object.GetUserCount("", "", "", "")
if err != nil {
c.ResponseError(err.Error())
return
}
if err := checkQuotaForUser(int(count)); err != nil {
if err := checkQuotaForUser(); err != nil {
c.ResponseError(err.Error())
return
}
@ -580,7 +574,11 @@ func (c *ApiController) SetPassword() {
if user.Ldap == "" {
_, err = object.UpdateUser(userId, targetUser, []string{"password", "need_update_password", "password_type", "last_change_password_time"}, false)
} else {
err = object.ResetLdapPassword(targetUser, newPassword, c.GetAcceptLanguage())
if isAdmin {
err = object.ResetLdapPassword(targetUser, "", newPassword, c.GetAcceptLanguage())
} else {
err = object.ResetLdapPassword(targetUser, oldPassword, newPassword, c.GetAcceptLanguage())
}
}
if err != nil {

View File

@ -294,12 +294,18 @@ func checkQuotaForProvider(count int) error {
return nil
}
func checkQuotaForUser(count int) error {
func checkQuotaForUser() error {
quota := conf.GetConfigQuota().User
if quota == -1 {
return nil
}
if count >= quota {
count, err := object.GetUserCount("", "", "", "")
if err != nil {
return err
}
if int(count) >= quota {
return fmt.Errorf("user quota is exceeded")
}
return nil

View File

@ -510,20 +510,28 @@ func (c *ApiController) VerifyCode() {
}
}
result, err := object.CheckVerificationCode(checkDest, authForm.Code, c.GetAcceptLanguage())
passed, err := c.checkOrgMasterVerificationCode(user, authForm.Code)
if err != nil {
c.ResponseError(c.T(err.Error()))
return
}
if result.Code != object.VerificationSuccess {
c.ResponseError(result.Msg)
return
}
err = object.DisableVerificationCode(checkDest)
if err != nil {
c.ResponseError(err.Error())
return
if !passed {
result, err := object.CheckVerificationCode(checkDest, authForm.Code, c.GetAcceptLanguage())
if err != nil {
c.ResponseError(err.Error())
return
}
if result.Code != object.VerificationSuccess {
c.ResponseError(result.Msg)
return
}
err = object.DisableVerificationCode(checkDest)
if err != nil {
c.ResponseError(err.Error())
return
}
}
c.SetSession("verifiedCode", authForm.Code)

View File

@ -0,0 +1,36 @@
// Copyright 2025 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package controllers
import (
"fmt"
"github.com/casdoor/casdoor/object"
)
func (c *ApiController) checkOrgMasterVerificationCode(user *object.User, code string) (bool, error) {
organization, err := object.GetOrganizationByUser(user)
if err != nil {
return false, err
}
if organization == nil {
return false, fmt.Errorf("The organization: %s does not exist", user.Owner)
}
if organization.MasterVerificationCode != "" && organization.MasterVerificationCode == code {
return true, nil
}
return false, nil
}

View File

@ -16,7 +16,9 @@ package email
import (
"crypto/tls"
"strings"
"github.com/casdoor/casdoor/conf"
"github.com/casdoor/gomail/v2"
)
@ -33,6 +35,13 @@ func NewSmtpEmailProvider(userName string, password string, host string, port in
dialer.SSL = !disableSsl
if strings.HasSuffix(host, ".amazonaws.com") {
socks5Proxy := conf.GetConfigString("socks5Proxy")
if socks5Proxy != "" {
dialer.SetSocks5Proxy(socks5Proxy)
}
}
return &SmtpEmailProvider{Dialer: dialer}
}

View File

@ -37,13 +37,14 @@ type AuthForm struct {
Region string `json:"region"`
InvitationCode string `json:"invitationCode"`
Application string `json:"application"`
ClientId string `json:"clientId"`
Provider string `json:"provider"`
Code string `json:"code"`
State string `json:"state"`
RedirectUri string `json:"redirectUri"`
Method string `json:"method"`
Application string `json:"application"`
ClientId string `json:"clientId"`
Provider string `json:"provider"`
ProviderBack string `json:"providerBack"`
Code string `json:"code"`
State string `json:"state"`
RedirectUri string `json:"redirectUri"`
Method string `json:"method"`
EmailCode string `json:"emailCode"`
PhoneCode string `json:"phoneCode"`

10
go.mod
View File

@ -10,9 +10,9 @@ require (
github.com/beevik/etree v1.1.0
github.com/casbin/casbin/v2 v2.77.2
github.com/casdoor/go-sms-sender v0.25.0
github.com/casdoor/gomail/v2 v2.0.1
github.com/casdoor/gomail/v2 v2.1.0
github.com/casdoor/ldapserver v1.2.0
github.com/casdoor/notify v0.45.0
github.com/casdoor/notify v1.0.0
github.com/casdoor/oss v1.8.0
github.com/casdoor/xorm-adapter/v3 v3.1.0
github.com/casvisor/casvisor-go-sdk v1.4.0
@ -60,10 +60,10 @@ require (
github.com/xorm-io/core v0.7.4
github.com/xorm-io/xorm v1.1.6
github.com/yusufpapurcu/wmi v1.2.2 // indirect
golang.org/x/crypto v0.21.0
golang.org/x/net v0.21.0
golang.org/x/crypto v0.32.0
golang.org/x/net v0.34.0
golang.org/x/oauth2 v0.17.0
golang.org/x/text v0.14.0
golang.org/x/text v0.21.0
google.golang.org/api v0.150.0
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/square/go-jose.v2 v2.6.0

41
go.sum
View File

@ -1089,12 +1089,12 @@ github.com/casdoor/go-reddit/v2 v2.1.0 h1:kIbfdJ7AA7H0uTQ8s0q4GGZqSS5V9wVE74RrXy
github.com/casdoor/go-reddit/v2 v2.1.0/go.mod h1:eagkvwlZ4Hcsuc/uQsLHYEulz5jN65SVSwV/AIE7zsc=
github.com/casdoor/go-sms-sender v0.25.0 h1:eF4cOCSbjVg7+0uLlJQnna/FQ0BWW+Fp/x4cXhzQu1Y=
github.com/casdoor/go-sms-sender v0.25.0/go.mod h1:bOm4H8/YfJmEHjBatEVQFOnAf0OOn1B0Wi5B7zDhws0=
github.com/casdoor/gomail/v2 v2.0.1 h1:J+FG6x80s9e5lBHUn8Sv0Y56mud34KiWih5YdmudR/w=
github.com/casdoor/gomail/v2 v2.0.1/go.mod h1:VnGPslEAtpix5FjHisR/WKB1qvZDBaujbikxDe9d+2Q=
github.com/casdoor/gomail/v2 v2.1.0 h1:ua97E3CARnF1Ik8ga/Drz9uGZfaElXJumFexiErWUxM=
github.com/casdoor/gomail/v2 v2.1.0/go.mod h1:GFzOD9RhY0nODiiPaQiOa6DfoKtmO9aTesu5qrp26OI=
github.com/casdoor/ldapserver v1.2.0 h1:HdSYe+ULU6z9K+2BqgTrJKQRR4//ERAXB64ttOun6Ow=
github.com/casdoor/ldapserver v1.2.0/go.mod h1:VwYU2vqQ2pA8sa00PRekH71R2XmgfzMKhmp1XrrDu2s=
github.com/casdoor/notify v0.45.0 h1:OlaFvcQFjGOgA4mRx07M8AH1gvb5xNo21mcqrVGlLgk=
github.com/casdoor/notify v0.45.0/go.mod h1:wNHQu0tiDROMBIvz0j3Om3Lhd5yZ+AIfnFb8MYb8OLQ=
github.com/casdoor/notify v1.0.0 h1:oldsaaQFPrlufm/OA314z8DwFVE1Tc9Gt1z4ptRHhXw=
github.com/casdoor/notify v1.0.0/go.mod h1:wNHQu0tiDROMBIvz0j3Om3Lhd5yZ+AIfnFb8MYb8OLQ=
github.com/casdoor/oss v1.8.0 h1:uuyKhDIp7ydOtV4lpqhAY23Ban2Ln8La8+QT36CwylM=
github.com/casdoor/oss v1.8.0/go.mod h1:uaqO7KBI2lnZcnB8rF7O6C2bN7llIbfC5Ql8ex1yR1U=
github.com/casdoor/xorm-adapter/v3 v3.1.0 h1:NodWayRtSLVSeCvL9H3Hc61k0G17KhV9IymTCNfh3kk=
@ -2163,8 +2163,10 @@ golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20181106170214-d68db9428509/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
@ -2230,8 +2232,10 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.11.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20171115151908-9dfe39835686/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@ -2319,8 +2323,10 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@ -2375,8 +2381,11 @@ golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE=
golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@ -2503,8 +2512,11 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@ -2524,8 +2536,10 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8=
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@ -2546,8 +2560,10 @@ golang.org/x/text v0.10.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@ -2634,8 +2650,9 @@ golang.org/x/tools v0.8.0/go.mod h1:JxBZ99ISMI5ViVkT1tr6tdNmXeTrcpVSD3vZ1RsRdN4=
golang.org/x/tools v0.9.1/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc=
golang.org/x/tools v0.9.3/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc=
golang.org/x/tools v0.10.0/go.mod h1:UJwyiVBsOA2uwvK/e5OY3GTpDUJriEd+/YlqAwLPmyM=
golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

View File

@ -22,6 +22,7 @@ import (
_ "github.com/beego/beego/session/redis"
"github.com/casdoor/casdoor/authz"
"github.com/casdoor/casdoor/conf"
"github.com/casdoor/casdoor/controllers"
"github.com/casdoor/casdoor/ldap"
"github.com/casdoor/casdoor/object"
"github.com/casdoor/casdoor/proxy"
@ -45,6 +46,7 @@ func main() {
object.InitCasvisorConfig()
util.SafeGoroutine(func() { object.RunSyncUsersJob() })
util.SafeGoroutine(func() { controllers.InitCLIDownloader() })
// beego.DelStaticPath("/static")
// beego.SetStaticPath("/static", "web/build/static")

29
notification/cucloud.go Normal file
View File

@ -0,0 +1,29 @@
// Copyright 2025 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 notification
import (
"github.com/casdoor/notify"
"github.com/casdoor/notify/service/cucloud"
)
func NewCucloudProvider(accessKey, secretKey, topicName, messageTitle, cloudRegionCode, accountId, notifyType string) (notify.Notifier, error) {
cucloud := cucloud.New(accessKey, secretKey, topicName, messageTitle, cloudRegionCode, accountId, notifyType)
notifier := notify.New()
notifier.UseServices(cucloud)
return notifier, nil
}

View File

@ -16,7 +16,7 @@ package notification
import "github.com/casdoor/notify"
func GetNotificationProvider(typ string, clientId string, clientSecret string, clientId2 string, clientSecret2 string, appId string, receiver string, method string, title string, metaData string) (notify.Notifier, error) {
func GetNotificationProvider(typ string, clientId string, clientSecret string, clientId2 string, clientSecret2 string, appId string, receiver string, method string, title string, metaData string, regionId string) (notify.Notifier, error) {
if typ == "Telegram" {
return NewTelegramProvider(clientSecret, receiver)
} else if typ == "Custom HTTP" {
@ -53,6 +53,8 @@ func GetNotificationProvider(typ string, clientId string, clientSecret string, c
return NewRocketChatProvider(clientId, clientSecret, appId, receiver)
} else if typ == "Viber" {
return NewViberProvider(clientId, clientSecret, appId, receiver)
} else if typ == "CUCloud" {
return NewCucloudProvider(clientId, clientSecret, appId, title, regionId, clientId2, metaData)
}
return nil, nil

View File

@ -481,7 +481,10 @@ func GetApplicationByClientId(clientId string) (*Application, error) {
}
func GetApplication(id string) (*Application, error) {
owner, name := util.GetOwnerAndNameFromId(id)
owner, name, err := util.GetOwnerAndNameFromIdWithError(id)
if err != nil {
return nil, err
}
return getApplication(owner, name)
}

View File

@ -241,6 +241,10 @@ func CheckPassword(user *User, password string, lang string, options ...bool) er
return fmt.Errorf(i18n.Translate(lang, "check:Organization does not exist"))
}
if password == "" {
return fmt.Errorf(i18n.Translate(lang, "check:Password cannot be empty"))
}
passwordType := user.PasswordType
if passwordType == "" {
passwordType = organization.PasswordType

View File

@ -74,7 +74,7 @@ func checkPasswordComplexity(password string, options []string) string {
}
if len(options) == 0 {
options = []string{"AtLeast6"}
return ""
}
checkers := map[string]ValidatorFunc{

View File

@ -16,23 +16,18 @@
package object
import (
"crypto/tls"
import "github.com/casdoor/casdoor/email"
"github.com/casdoor/casdoor/email"
"github.com/casdoor/gomail/v2"
)
func getDialer(provider *Provider) *gomail.Dialer {
dialer := &gomail.Dialer{}
dialer = gomail.NewDialer(provider.Host, provider.Port, provider.ClientId, provider.ClientSecret)
if provider.Type == "SUBMAIL" {
dialer.TLSConfig = &tls.Config{InsecureSkipVerify: true}
// 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)
sender, err := smtpEmailProvider.Dialer.Dial()
if err != nil {
return err
}
defer sender.Close()
dialer.SSL = !provider.DisableSsl
return dialer
return nil
}
func SendEmail(provider *Provider, title string, content string, dest string, sender string) error {
@ -50,16 +45,3 @@ func SendEmail(provider *Provider, title string, content string, dest string, se
return emailProvider.Send(fromAddress, fromName, dest, title, content)
}
// DailSmtpServer Dail Smtp server
func DailSmtpServer(provider *Provider) error {
dialer := getDialer(provider)
sender, err := dialer.Dial()
if err != nil {
return err
}
defer sender.Close()
return nil
}

View File

@ -17,6 +17,8 @@ package object
import (
"sync"
"time"
"github.com/casdoor/casdoor/conf"
)
type DashboardDateItem struct {
@ -40,11 +42,12 @@ func GetDashboard(owner string) (*map[string][]int64, error) {
time30day := time.Now().AddDate(0, 0, -30)
var wg sync.WaitGroup
var err error
tableNamePrefix := conf.GetConfigString("tableNamePrefix")
wg.Add(len(tableNames))
ch := make(chan error, len(tableNames))
for _, tableName := range tableNames {
dashboard[tableName+"Counts"] = make([]int64, 31)
tableName := tableName
tableFullName := tableNamePrefix + tableName
go func(ch chan error) {
defer wg.Done()
dashboardDateItems := []DashboardDateItem{}
@ -58,16 +61,16 @@ func GetDashboard(owner string) (*map[string][]int64, error) {
dbQueryBefore = dbQueryBefore.And("owner = ?", owner)
}
if countResult, err = dbQueryBefore.And("created_time < ?", time30day).Table(tableName).Count(); err != nil {
if countResult, err = dbQueryBefore.And("created_time < ?", time30day).Table(tableFullName).Count(); err != nil {
ch <- err
return
}
if err = dbQueryAfter.And("created_time >= ?", time30day).Table(tableName).Find(&dashboardDateItems); err != nil {
if err = dbQueryAfter.And("created_time >= ?", time30day).Table(tableFullName).Find(&dashboardDateItems); err != nil {
ch <- err
return
}
dashboardMap.Store(tableName, DashboardMapItem{
dashboardMap.Store(tableFullName, DashboardMapItem{
dashboardDateItems: dashboardDateItems,
itemCount: countResult,
})

View File

@ -17,7 +17,6 @@ package object
import (
"errors"
"fmt"
"sync"
"github.com/casdoor/casdoor/conf"
"github.com/casdoor/casdoor/util"
@ -36,12 +35,14 @@ type Group struct {
ContactEmail string `xorm:"varchar(100)" json:"contactEmail"`
Type string `xorm:"varchar(100)" json:"type"`
ParentId string `xorm:"varchar(100)" json:"parentId"`
ParentName string `xorm:"-" json:"parentName"`
IsTopGroup bool `xorm:"bool" json:"isTopGroup"`
Users []string `xorm:"-" json:"users"`
Title string `json:"title,omitempty"`
Key string `json:"key,omitempty"`
Children []*Group `json:"children,omitempty"`
Title string `json:"title,omitempty"`
Key string `json:"key,omitempty"`
HaveChildren bool `xorm:"-" json:"haveChildren"`
Children []*Group `json:"children,omitempty"`
IsEnabled bool `json:"isEnabled"`
}
@ -79,6 +80,26 @@ func GetPaginationGroups(owner string, offset, limit int, field, value, sortFiel
return groups, nil
}
func GetGroupsHaveChildrenMap(groups []*Group) (map[string]*Group, error) {
groupsHaveChildren := []*Group{}
resultMap := make(map[string]*Group)
groupIds := []string{}
for _, group := range groups {
groupIds = append(groupIds, group.Name)
groupIds = append(groupIds, group.ParentId)
}
err := ormer.Engine.Cols("owner", "name", "parent_id", "display_name").Distinct("parent_id").In("parent_id", groupIds).Find(&groupsHaveChildren)
if err != nil {
return nil, err
}
for _, group := range groups {
resultMap[group.Name] = group
}
return resultMap, nil
}
func getGroup(owner string, name string) (*Group, error) {
if owner == "" || name == "" {
return nil, nil
@ -298,17 +319,11 @@ func ExtendGroupWithUsers(group *Group) error {
return nil
}
users, err := GetUsers(group.Owner)
if err != nil {
return err
}
groupId := group.GetId()
userIds := []string{}
for _, user := range users {
if util.InSlice(user.Groups, groupId) {
userIds = append(userIds, user.GetId())
}
userIds, err := userEnforcer.GetAllUsersByGroup(groupId)
if err != nil {
return err
}
group.Users = userIds
@ -316,29 +331,14 @@ func ExtendGroupWithUsers(group *Group) error {
}
func ExtendGroupsWithUsers(groups []*Group) error {
var wg sync.WaitGroup
errChan := make(chan error, len(groups))
for _, group := range groups {
wg.Add(1)
go func(group *Group) {
defer wg.Done()
err := ExtendGroupWithUsers(group)
if err != nil {
errChan <- err
}
}(group)
}
wg.Wait()
close(errChan)
for err := range errChan {
users, err := userEnforcer.GetAllUsersByGroup(group.GetId())
if err != nil {
return err
}
}
group.Users = users
}
return nil
}

View File

@ -103,6 +103,7 @@ func initBuiltInOrganization() bool {
PasswordOptions: []string{"AtLeast6"},
CountryCodes: []string{"US", "ES", "FR", "DE", "GB", "CN", "JP", "KR", "VN", "ID", "SG", "IN"},
DefaultAvatar: fmt.Sprintf("%s/img/casbin.svg", conf.GetConfigString("staticBaseUrl")),
UserTypes: []string{},
Tags: []string{},
Languages: []string{"en", "zh", "es", "fr", "de", "id", "ja", "ko", "ru", "vi", "pt"},
InitScore: 2000,

View File

@ -33,6 +33,7 @@ type Ldap struct {
Filter string `xorm:"varchar(200)" json:"filter"`
FilterFields []string `xorm:"varchar(100)" json:"filterFields"`
DefaultGroup string `xorm:"varchar(100)" json:"defaultGroup"`
PasswordType string `xorm:"varchar(100)" json:"passwordType"`
AutoSync int `json:"autoSync"`
LastSync string `xorm:"varchar(100)" json:"lastSync"`
@ -149,7 +150,7 @@ func UpdateLdap(ldap *Ldap) (bool, error) {
}
affected, err := ormer.Engine.ID(ldap.Id).Cols("owner", "server_name", "host",
"port", "enable_ssl", "username", "password", "base_dn", "filter", "filter_fields", "auto_sync", "default_group").Update(ldap)
"port", "enable_ssl", "username", "password", "base_dn", "filter", "filter_fields", "auto_sync", "default_group", "password_type").Update(ldap)
if err != nil {
return false, nil
}

View File

@ -15,6 +15,8 @@
package object
import (
"crypto/md5"
"encoding/base64"
"errors"
"fmt"
"strings"
@ -373,7 +375,7 @@ func GetExistUuids(owner string, uuids []string) ([]string, error) {
return existUuids, nil
}
func ResetLdapPassword(user *User, newPassword string, lang string) error {
func ResetLdapPassword(user *User, oldPassword string, newPassword string, lang string) error {
ldaps, err := GetLdaps(user.Owner)
if err != nil {
return err
@ -416,8 +418,32 @@ func ResetLdapPassword(user *User, newPassword string, lang string) error {
}
modifyPasswordRequest.Replace("unicodePwd", []string{pwdEncoded})
modifyPasswordRequest.Replace("userAccountControl", []string{"512"})
} else if oldPassword != "" {
modifyPasswordRequestWithOldPassword := goldap.NewPasswordModifyRequest(userDn, oldPassword, newPassword)
_, err = conn.Conn.PasswordModify(modifyPasswordRequestWithOldPassword)
if err != nil {
conn.Close()
return err
}
conn.Close()
return nil
} else {
pwdEncoded = newPassword
switch ldapServer.PasswordType {
case "SSHA":
pwdEncoded, err = generateSSHA(newPassword)
break
case "MD5":
md5Byte := md5.Sum([]byte(newPassword))
md5Password := base64.StdEncoding.EncodeToString(md5Byte[:])
pwdEncoded = "{MD5}" + md5Password
break
case "Plain":
pwdEncoded = newPassword
break
default:
pwdEncoded = newPassword
break
}
modifyPasswordRequest.Replace("userPassword", []string{pwdEncoded})
}

View File

@ -0,0 +1,36 @@
// Copyright 2025 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package object
import (
"crypto/rand"
"crypto/sha1"
"encoding/base64"
)
func generateSSHA(password string) (string, error) {
salt := make([]byte, 4)
_, err := rand.Read(salt)
if err != nil {
return "", err
}
combined := append([]byte(password), salt...)
hash := sha1.Sum(combined)
hashWithSalt := append(hash[:], salt...)
encoded := base64.StdEncoding.EncodeToString(hashWithSalt)
return "{SSHA}" + encoded, nil
}

View File

@ -23,7 +23,7 @@ import (
func getNotificationClient(provider *Provider) (notify.Notifier, error) {
var client notify.Notifier
client, err := notification.GetNotificationProvider(provider.Type, provider.ClientId, provider.ClientSecret, provider.ClientId2, provider.ClientSecret2, provider.AppId, provider.Receiver, provider.Method, provider.Title, provider.Metadata)
client, err := notification.GetNotificationProvider(provider.Type, provider.ClientId, provider.ClientSecret, provider.ClientId2, provider.ClientSecret2, provider.AppId, provider.Receiver, provider.Method, provider.Title, provider.Metadata, provider.RegionId)
if err != nil {
return nil, err
}

View File

@ -66,11 +66,12 @@ type Organization struct {
CountryCodes []string `xorm:"varchar(200)" json:"countryCodes"`
DefaultAvatar string `xorm:"varchar(200)" json:"defaultAvatar"`
DefaultApplication string `xorm:"varchar(100)" json:"defaultApplication"`
UserTypes []string `xorm:"mediumtext" json:"userTypes"`
Tags []string `xorm:"mediumtext" json:"tags"`
Languages []string `xorm:"varchar(255)" json:"languages"`
ThemeData *ThemeData `xorm:"json" json:"themeData"`
MasterPassword string `xorm:"varchar(100)" json:"masterPassword"`
DefaultPassword string `xorm:"varchar(100)" json:"defaultPassword"`
MasterPassword string `xorm:"varchar(200)" json:"masterPassword"`
DefaultPassword string `xorm:"varchar(200)" json:"defaultPassword"`
MasterVerificationCode string `xorm:"varchar(100)" json:"masterVerificationCode"`
IpWhitelist string `xorm:"varchar(200)" json:"ipWhitelist"`
InitScore int `json:"initScore"`
@ -79,6 +80,7 @@ type Organization struct {
UseEmailAsUsername bool `json:"useEmailAsUsername"`
EnableTour bool `json:"enableTour"`
IpRestriction string `json:"ipRestriction"`
NavItems []string `xorm:"varchar(500)" json:"navItems"`
MfaItems []*MfaItem `xorm:"varchar(300)" json:"mfaItems"`
AccountItems []*AccountItem `xorm:"varchar(5000)" json:"accountItems"`
@ -151,7 +153,10 @@ func getOrganization(owner string, name string) (*Organization, error) {
}
func GetOrganization(id string) (*Organization, error) {
owner, name := util.GetOwnerAndNameFromId(id)
owner, name, err := util.GetOwnerAndNameFromIdWithError(id)
if err != nil {
return nil, err
}
return getOrganization(owner, name)
}
@ -192,9 +197,10 @@ func GetMaskedOrganizations(organizations []*Organization, errs ...error) ([]*Or
return organizations, nil
}
func UpdateOrganization(id string, organization *Organization) (bool, error) {
func UpdateOrganization(id string, organization *Organization, isGlobalAdmin bool) (bool, error) {
owner, name := util.GetOwnerAndNameFromId(id)
if org, err := getOrganization(owner, name); err != nil {
org, err := getOrganization(owner, name)
if err != nil {
return false, err
} else if org == nil {
return false, nil
@ -219,6 +225,10 @@ func UpdateOrganization(id string, organization *Organization) (bool, error) {
}
}
if !isGlobalAdmin {
organization.NavItems = org.NavItems
}
session := ormer.Engine.ID(core.PK{owner, name}).AllCols()
if organization.MasterPassword == "***" {

View File

@ -219,8 +219,11 @@ func BuyProduct(id string, user *User, providerName, pricingName, planName, host
ProductName: product.Name,
PayerName: payerName,
PayerId: user.Id,
PayerEmail: user.Email,
PaymentName: paymentName,
ProductDisplayName: product.DisplayName,
ProductDescription: product.Description,
ProductImage: product.Image,
Price: product.Price,
Currency: product.Currency,
ReturnUrl: returnUrl,

View File

@ -325,6 +325,12 @@ func GetPaymentProvider(p *Provider) (pp.PaymentProvider, error) {
return nil, err
}
return pp, nil
} else if typ == "AirWallex" {
pp, err := pp.NewAirwallexPaymentProvider(p.ClientId, p.ClientSecret)
if err != nil {
return nil, err
}
return pp, nil
} else if typ == "Balance" {
pp, err := pp.NewBalancePaymentProvider()
if err != nil {

View File

@ -338,6 +338,9 @@ func GetSamlResponse(application *Application, user *User, samlRequest string, h
} else if authnRequest.AssertionConsumerServiceURL == "" {
return "", "", "", fmt.Errorf("err: SAML request don't has attribute 'AssertionConsumerServiceURL' in <samlp:AuthnRequest>")
}
if authnRequest.ProtocolBinding == "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" {
method = "POST"
}
_, originBackend := getOriginFromHost(host)

View File

@ -123,8 +123,7 @@ func GetTokenByRefreshToken(refreshToken string) (*Token, error) {
func GetTokenByTokenValue(tokenValue, tokenTypeHint string) (*Token, error) {
switch tokenTypeHint {
case "access_token":
case "access-token":
case "access_token", "access-token":
token, err := GetTokenByAccessToken(tokenValue)
if err != nil {
return nil, err
@ -132,8 +131,7 @@ func GetTokenByTokenValue(tokenValue, tokenTypeHint string) (*Token, error) {
if token != nil {
return token, nil
}
case "refresh_token":
case "refresh-token":
case "refresh_token", "refresh-token":
token, err := GetTokenByRefreshToken(tokenValue)
if err != nil {
return nil, err
@ -146,13 +144,13 @@ func GetTokenByTokenValue(tokenValue, tokenTypeHint string) (*Token, error) {
return nil, nil
}
func updateUsedByCode(token *Token) bool {
func updateUsedByCode(token *Token) (bool, error) {
affected, err := ormer.Engine.Where("code=?", token.Code).Cols("code_is_used").Update(token)
if err != nil {
panic(err)
return false, err
}
return affected != 0
return affected != 0, nil
}
func GetToken(id string) (*Token, error) {

View File

@ -30,6 +30,8 @@ type Claims struct {
Nonce string `json:"nonce,omitempty"`
Tag string `json:"tag"`
Scope string `json:"scope,omitempty"`
// the `azp` (Authorized Party) claim. Optional. See https://openid.net/specs/openid-connect-core-1_0.html#IDToken
Azp string `json:"azp,omitempty"`
jwt.RegisteredClaims
}
@ -137,6 +139,7 @@ type ClaimsShort struct {
TokenType string `json:"tokenType,omitempty"`
Nonce string `json:"nonce,omitempty"`
Scope string `json:"scope,omitempty"`
Azp string `json:"azp,omitempty"`
jwt.RegisteredClaims
}
@ -155,6 +158,7 @@ type ClaimsWithoutThirdIdp struct {
Nonce string `json:"nonce,omitempty"`
Tag string `json:"tag"`
Scope string `json:"scope,omitempty"`
Azp string `json:"azp,omitempty"`
jwt.RegisteredClaims
}
@ -269,6 +273,7 @@ func getShortClaims(claims Claims) ClaimsShort {
Nonce: claims.Nonce,
Scope: claims.Scope,
RegisteredClaims: claims.RegisteredClaims,
Azp: claims.Azp,
}
return res
}
@ -281,6 +286,7 @@ func getClaimsWithoutThirdIdp(claims Claims) ClaimsWithoutThirdIdp {
Tag: claims.Tag,
Scope: claims.Scope,
RegisteredClaims: claims.RegisteredClaims,
Azp: claims.Azp,
}
return res
}
@ -301,6 +307,7 @@ func getClaimsCustom(claims Claims, tokenField []string) jwt.MapClaims {
res["nonce"] = claims.Nonce
res["tag"] = claims.Tag
res["scope"] = claims.Scope
res["azp"] = claims.Azp
for _, field := range tokenField {
userField := userValue.FieldByName(field)
@ -357,6 +364,7 @@ func generateJwtToken(application *Application, user *User, nonce string, scope
// FIXME: A workaround for custom claim by reusing `tag` in user info
Tag: user.Tag,
Scope: scope,
Azp: application.ClientId,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: originBackend,
Subject: user.Id,

View File

@ -248,7 +248,10 @@ func GetOAuthToken(grantType string, clientId string, clientSecret string, code
token.CodeIsUsed = true
go updateUsedByCode(token)
_, err = updateUsedByCode(token)
if err != nil {
return nil, err
}
tokenWrapper := &TokenWrapper{
AccessToken: token.AccessToken,

View File

@ -32,6 +32,7 @@ type ClaimsStandard struct {
Nonce string `json:"nonce,omitempty"`
Scope string `json:"scope,omitempty"`
Address OIDCAddress `json:"address,omitempty"`
Azp string `json:"azp,omitempty"`
jwt.RegisteredClaims
}
@ -52,6 +53,7 @@ func getStandardClaims(claims Claims) ClaimsStandard {
Nonce: claims.Nonce,
Scope: claims.Scope,
RegisteredClaims: claims.RegisteredClaims,
Azp: claims.Azp,
}
res.Phone = ""

View File

@ -846,11 +846,14 @@ func AddUser(user *User) (bool, error) {
}
}
count, err := GetUserCount(user.Owner, "", "", "")
if err != nil {
return false, err
rankingItem := GetAccountItemByName("Ranking", organization)
if rankingItem != nil {
count, err := GetUserCount(user.Owner, "", "", "")
if err != nil {
return false, err
}
user.Ranking = int(count + 1)
}
user.Ranking = int(count + 1)
if user.Groups != nil && len(user.Groups) > 0 {
_, err = userEnforcer.UpdateGroupsForUser(user.GetId(), user.Groups)
@ -962,6 +965,11 @@ func DeleteUser(user *User) (bool, error) {
return false, err
}
_, err = userEnforcer.DeleteGroupsForUser(user.GetId())
if err != nil {
return false, err
}
organization, err := GetOrganizationByUser(user)
if err != nil {
return false, err

289
pp/airwallex.go Normal file
View File

@ -0,0 +1,289 @@
package pp
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/casdoor/casdoor/conf"
)
type AirwallexPaymentProvider struct {
Client *AirwallexClient
}
func NewAirwallexPaymentProvider(clientId string, apiKey string) (*AirwallexPaymentProvider, error) {
isProd := conf.GetConfigString("runmode") == "prod"
apiEndpoint := "https://api-demo.airwallex.com/api/v1"
apiCheckout := "https://checkout-demo.airwallex.com/#/standalone/checkout?"
if isProd {
apiEndpoint = "https://api.airwallex.com/api/v1"
apiCheckout = "https://checkout.airwallex.com/#/standalone/checkout?"
}
client := &AirwallexClient{
ClientId: clientId,
APIKey: apiKey,
APIEndpoint: apiEndpoint,
APICheckout: apiCheckout,
client: &http.Client{Timeout: 15 * time.Second},
}
pp := &AirwallexPaymentProvider{
Client: client,
}
return pp, nil
}
func (pp *AirwallexPaymentProvider) Pay(r *PayReq) (*PayResp, error) {
// Create a payment intent
intent, err := pp.Client.CreateIntent(r)
if err != nil {
return nil, err
}
payUrl, err := pp.Client.GetCheckoutUrl(intent, r)
if err != nil {
return nil, err
}
return &PayResp{
PayUrl: payUrl,
OrderId: intent.MerchantOrderId,
}, nil
}
func (pp *AirwallexPaymentProvider) Notify(body []byte, orderId string) (*NotifyResult, error) {
notifyResult := &NotifyResult{}
intent, err := pp.Client.GetIntentByOrderId(orderId)
if err != nil {
return nil, err
}
// Check intent status
switch intent.Status {
case "PENDING", "REQUIRES_PAYMENT_METHOD", "REQUIRES_CUSTOMER_ACTION", "REQUIRES_CAPTURE":
notifyResult.PaymentStatus = PaymentStateCreated
return notifyResult, nil
case "CANCELLED":
notifyResult.PaymentStatus = PaymentStateCanceled
return notifyResult, nil
case "EXPIRED":
notifyResult.PaymentStatus = PaymentStateTimeout
return notifyResult, nil
case "SUCCEEDED":
// Skip
default:
notifyResult.PaymentStatus = PaymentStateError
notifyResult.NotifyMessage = fmt.Sprintf("unexpected airwallex checkout status: %v", intent.Status)
return notifyResult, nil
}
// Check attempt status
if intent.PaymentStatus != "" {
switch intent.PaymentStatus {
case "CANCELLED", "EXPIRED", "RECEIVED", "AUTHENTICATION_REDIRECTED", "AUTHORIZED", "CAPTURE_REQUESTED":
notifyResult.PaymentStatus = PaymentStateCreated
return notifyResult, nil
case "PAID", "SETTLED":
// Skip
default:
notifyResult.PaymentStatus = PaymentStateError
notifyResult.NotifyMessage = fmt.Sprintf("unexpected airwallex checkout payment status: %v", intent.PaymentStatus)
return notifyResult, nil
}
}
// The Payment has succeeded.
var productDisplayName, productName, providerName string
if description, ok := intent.Metadata["description"]; ok {
productName, productDisplayName, providerName, _ = parseAttachString(description.(string))
}
orderId = intent.MerchantOrderId
return &NotifyResult{
PaymentName: orderId,
PaymentStatus: PaymentStatePaid,
ProductName: productName,
ProductDisplayName: productDisplayName,
ProviderName: providerName,
Price: priceStringToFloat64(intent.Amount.String()),
Currency: intent.Currency,
OrderId: orderId,
}, nil
}
func (pp *AirwallexPaymentProvider) GetInvoice(paymentName, personName, personIdCard, personEmail, personPhone, invoiceType, invoiceTitle, invoiceTaxId string) (string, error) {
return "", nil
}
func (pp *AirwallexPaymentProvider) GetResponseError(err error) string {
if err == nil {
return "success"
}
return "fail"
}
/*
* Airwallex Client implementation (to be removed upon official SDK release)
*/
type AirwallexClient struct {
ClientId string
APIKey string
APIEndpoint string
APICheckout string
client *http.Client
tokenCache *AirWallexTokenInfo
tokenMutex sync.RWMutex
}
type AirWallexTokenInfo struct {
Token string `json:"token"`
ExpiresAt string `json:"expires_at"`
parsedExpiresAt time.Time
}
type AirWallexIntentResp struct {
Id string `json:"id"`
ClientSecret string `json:"client_secret"`
MerchantOrderId string `json:"merchant_order_id"`
}
func (c *AirwallexClient) GetToken() (string, error) {
c.tokenMutex.Lock()
defer c.tokenMutex.Unlock()
if c.tokenCache != nil && time.Now().Before(c.tokenCache.parsedExpiresAt) {
return c.tokenCache.Token, nil
}
req, _ := http.NewRequest("POST", c.APIEndpoint+"/authentication/login", bytes.NewBuffer([]byte("{}")))
req.Header.Set("x-client-id", c.ClientId)
req.Header.Set("x-api-key", c.APIKey)
resp, err := c.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
var result AirWallexTokenInfo
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
if result.Token == "" {
return "", fmt.Errorf("invalid token response")
}
expiresAt := strings.Replace(result.ExpiresAt, "+0000", "+00:00", 1)
result.parsedExpiresAt, _ = time.Parse(time.RFC3339, expiresAt)
c.tokenCache = &result
return result.Token, nil
}
func (c *AirwallexClient) authRequest(method, url string, body interface{}) (map[string]interface{}, error) {
token, err := c.GetToken()
if err != nil {
return nil, err
}
b, _ := json.Marshal(body)
req, _ := http.NewRequest(method, url, bytes.NewBuffer(b))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result, nil
}
func (c *AirwallexClient) CreateIntent(r *PayReq) (*AirWallexIntentResp, error) {
description := joinAttachString([]string{r.ProductName, r.ProductDisplayName, r.ProviderName})
orderId := r.PaymentName
intentReq := map[string]interface{}{
"currency": r.Currency,
"amount": r.Price,
"merchant_order_id": orderId,
"request_id": orderId,
"descriptor": strings.ReplaceAll(string([]rune(description)[:32]), "\x00", ""),
"metadata": map[string]interface{}{"description": description},
"order": map[string]interface{}{"products": []map[string]interface{}{{"name": r.ProductDisplayName, "quantity": 1, "desc": r.ProductDescription, "image_url": r.ProductImage}}},
"customer": map[string]interface{}{"merchant_customer_id": r.PayerId, "email": r.PayerEmail, "first_name": r.PayerName, "last_name": r.PayerName},
}
intentUrl := fmt.Sprintf("%s/pa/payment_intents/create", c.APIEndpoint)
intentRes, err := c.authRequest("POST", intentUrl, intentReq)
if err != nil {
return nil, fmt.Errorf("failed to create payment intent: %v", err)
}
return &AirWallexIntentResp{
Id: intentRes["id"].(string),
ClientSecret: intentRes["client_secret"].(string),
MerchantOrderId: intentRes["merchant_order_id"].(string),
}, nil
}
type AirwallexIntent struct {
Amount json.Number `json:"amount"`
Currency string `json:"currency"`
Id string `json:"id"`
Status string `json:"status"`
Descriptor string `json:"descriptor"`
MerchantOrderId string `json:"merchant_order_id"`
LatestPaymentAttempt struct {
Status string `json:"status"`
} `json:"latest_payment_attempt"`
Metadata map[string]interface{} `json:"metadata"`
}
type AirwallexIntents struct {
Items []AirwallexIntent `json:"items"`
}
type AirWallexIntentInfo struct {
Amount json.Number
Currency string
Id string
Status string
Descriptor string
MerchantOrderId string
PaymentStatus string
Metadata map[string]interface{}
}
func (c *AirwallexClient) GetIntentByOrderId(orderId string) (*AirWallexIntentInfo, error) {
intentUrl := fmt.Sprintf("%s/pa/payment_intents/?merchant_order_id=%s", c.APIEndpoint, orderId)
intentRes, err := c.authRequest("GET", intentUrl, nil)
if err != nil {
return nil, fmt.Errorf("failed to get payment intent: %v", err)
}
items := intentRes["items"].([]interface{})
if len(items) == 0 {
return nil, fmt.Errorf("no payment intent found for order id: %s", orderId)
}
var intent AirwallexIntent
if b, err := json.Marshal(items[0]); err == nil {
json.Unmarshal(b, &intent)
}
return &AirWallexIntentInfo{
Id: intent.Id,
Amount: intent.Amount,
Currency: intent.Currency,
Status: intent.Status,
Descriptor: intent.Descriptor,
MerchantOrderId: intent.MerchantOrderId,
PaymentStatus: intent.LatestPaymentAttempt.Status,
Metadata: intent.Metadata,
}, nil
}
func (c *AirwallexClient) GetCheckoutUrl(intent *AirWallexIntentResp, r *PayReq) (string, error) {
return fmt.Sprintf("%sintent_id=%s&client_secret=%s&mode=payment&currency=%s&amount=%v&requiredBillingContactFields=%s&successUrl=%s&failUrl=%s&logoUrl=%s",
c.APICheckout,
intent.Id,
intent.ClientSecret,
r.Currency,
r.Price,
url.QueryEscape(`["address"]`),
r.ReturnUrl,
r.ReturnUrl,
"data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=", // replace default logo
), nil
}

View File

@ -33,8 +33,11 @@ type PayReq struct {
ProductName string
PayerName string
PayerId string
PayerEmail string
PaymentName string
ProductDisplayName string
ProductDescription string
ProductImage string
Price float64
Currency string

View File

@ -175,6 +175,7 @@ func initAPI() {
beego.Router("/api/get-all-roles", &controllers.ApiController{}, "GET:GetAllRoles")
beego.Router("/api/run-casbin-command", &controllers.ApiController{}, "GET:RunCasbinCommand")
beego.Router("/api/refresh-engines", &controllers.ApiController{}, "POST:RefreshEngines")
beego.Router("/api/get-sessions", &controllers.ApiController{}, "GET:GetSessions")
beego.Router("/api/get-session", &controllers.ApiController{}, "GET:GetSingleSession")

View File

@ -80,6 +80,15 @@ func fastAutoSignin(ctx *context.Context) (string, error) {
return "", nil
}
isAllowed, err := object.CheckLoginPermission(userId, application)
if err != nil {
return "", err
}
if !isAllowed {
return "", nil
}
code, err := object.GetOAuthCode(userId, clientId, responseType, redirectUri, scope, state, nonce, codeChallenge, ctx.Request.Host, getAcceptLanguage(ctx))
if err != nil {
return "", err
@ -133,6 +142,14 @@ func StaticFilter(ctx *context.Context) {
path += urlPath
}
// Preventing synchronization problems from concurrency
ctx.Input.CruSession = nil
organizationThemeCookie, err := appendThemeCookie(ctx, urlPath)
if err != nil {
fmt.Println(err)
}
if strings.Contains(path, "/../") || !util.FileExist(path) {
path = webBuildFolder + "/index.html"
}
@ -149,13 +166,13 @@ func StaticFilter(ctx *context.Context) {
}
if oldStaticBaseUrl == newStaticBaseUrl {
makeGzipResponse(ctx.ResponseWriter, ctx.Request, path)
makeGzipResponse(ctx.ResponseWriter, ctx.Request, path, organizationThemeCookie)
} else {
serveFileWithReplace(ctx.ResponseWriter, ctx.Request, path)
serveFileWithReplace(ctx.ResponseWriter, ctx.Request, path, organizationThemeCookie)
}
}
func serveFileWithReplace(w http.ResponseWriter, r *http.Request, name string) {
func serveFileWithReplace(w http.ResponseWriter, r *http.Request, name string, organizationThemeCookie *OrganizationThemeCookie) {
f, err := os.Open(filepath.Clean(name))
if err != nil {
panic(err)
@ -168,7 +185,13 @@ func serveFileWithReplace(w http.ResponseWriter, r *http.Request, name string) {
}
oldContent := util.ReadStringFromPath(name)
newContent := strings.ReplaceAll(oldContent, oldStaticBaseUrl, newStaticBaseUrl)
newContent := oldContent
if organizationThemeCookie != nil {
newContent = strings.ReplaceAll(newContent, "https://cdn.casbin.org/img/favicon.png", organizationThemeCookie.Favicon)
newContent = strings.ReplaceAll(newContent, "<title>Casdoor</title>", fmt.Sprintf("<title>%s</title>", organizationThemeCookie.DisplayName))
}
newContent = strings.ReplaceAll(newContent, oldStaticBaseUrl, newStaticBaseUrl)
http.ServeContent(w, r, d.Name(), d.ModTime(), strings.NewReader(newContent))
}
@ -182,14 +205,14 @@ func (w gzipResponseWriter) Write(b []byte) (int, error) {
return w.Writer.Write(b)
}
func makeGzipResponse(w http.ResponseWriter, r *http.Request, path string) {
func makeGzipResponse(w http.ResponseWriter, r *http.Request, path string, organizationThemeCookie *OrganizationThemeCookie) {
if !enableGzip || !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
serveFileWithReplace(w, r, path)
serveFileWithReplace(w, r, path, organizationThemeCookie)
return
}
w.Header().Set("Content-Encoding", "gzip")
gz := gzip.NewWriter(w)
defer gz.Close()
gzw := gzipResponseWriter{Writer: gz, ResponseWriter: w}
serveFileWithReplace(gzw, r, path)
serveFileWithReplace(gzw, r, path, organizationThemeCookie)
}

128
routers/theme_filter.go Normal file
View File

@ -0,0 +1,128 @@
// Copyright 2025 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package routers
import (
"encoding/json"
"fmt"
"strings"
"github.com/beego/beego/context"
"github.com/casdoor/casdoor/object"
)
type OrganizationThemeCookie struct {
ThemeData *object.ThemeData
LogoUrl string
FooterHtml string
Favicon string
DisplayName string
}
func appendThemeCookie(ctx *context.Context, urlPath string) (*OrganizationThemeCookie, error) {
organizationThemeCookie, err := getOrganizationThemeCookieFromUrlPath(ctx, urlPath)
if err != nil {
return nil, err
}
if organizationThemeCookie != nil {
return organizationThemeCookie, setThemeDataCookie(ctx, organizationThemeCookie)
}
return nil, nil
}
func getOrganizationThemeCookieFromUrlPath(ctx *context.Context, urlPath string) (*OrganizationThemeCookie, error) {
var application *object.Application
var organization *object.Organization
var err error
if urlPath == "/login" || urlPath == "/signup" {
application, err = object.GetDefaultApplication(fmt.Sprintf("admin/built-in"))
if err != nil {
return nil, err
}
} else if strings.HasSuffix(urlPath, "/oauth/authorize") {
clientId := ctx.Input.Query("client_id")
if clientId == "" {
return nil, nil
}
application, err = object.GetApplicationByClientId(clientId)
if err != nil {
return nil, err
}
} else if strings.HasPrefix(urlPath, "/login/saml") {
owner, _ := strings.CutPrefix(urlPath, "/login/saml/authorize/")
application, err = object.GetApplication(owner)
if err != nil {
return nil, err
}
} else if strings.HasPrefix(urlPath, "/login/") {
owner, _ := strings.CutPrefix(urlPath, "/login/")
if owner == "undefined" || strings.Count(owner, "/") > 0 {
return nil, nil
}
application, err = object.GetDefaultApplication(fmt.Sprintf("admin/%s", owner))
if err != nil {
return nil, err
}
} else if strings.HasPrefix(urlPath, "/signup/") {
owner, _ := strings.CutPrefix(urlPath, "/signup/")
if owner == "undefined" || strings.Count(owner, "/") > 0 {
return nil, nil
}
application, err = object.GetDefaultApplication(fmt.Sprintf("admin/%s", owner))
if err != nil {
return nil, err
}
} else if strings.HasPrefix(urlPath, "/cas/") && strings.HasSuffix(urlPath, "/login") {
owner, _ := strings.CutPrefix(urlPath, "/cas/")
owner, _ = strings.CutSuffix(owner, "/login")
application, err = object.GetApplication(owner)
if err != nil {
return nil, err
}
}
if application == nil {
return nil, nil
}
organization = application.OrganizationObj
if organization == nil {
organization, err = object.GetOrganization(fmt.Sprintf("admin/%s", application.Organization))
if err != nil {
return nil, err
}
}
organizationThemeCookie := &OrganizationThemeCookie{
application.ThemeData,
application.Logo,
application.FooterHtml,
organization.Favicon,
organization.DisplayName,
}
return organizationThemeCookie, nil
}
func setThemeDataCookie(ctx *context.Context, organizationThemeCookie *OrganizationThemeCookie) error {
themeDataString, err := json.Marshal(organizationThemeCookie.ThemeData)
if err != nil {
return err
}
ctx.SetCookie("organizationTheme", string(themeDataString))
ctx.SetCookie("organizationLogo", organizationThemeCookie.LogoUrl)
ctx.SetCookie("organizationFootHtml", organizationThemeCookie.FooterHtml)
return nil
}

View File

@ -118,6 +118,6 @@ func IsValidOrigin(origin string) (bool, error) {
originHostOnly = fmt.Sprintf("%s://%s", urlObj.Scheme, urlObj.Hostname())
}
res := originHostOnly == "http://localhost" || originHostOnly == "https://localhost" || originHostOnly == "http://127.0.0.1" || originHostOnly == "http://casdoor-app" || strings.HasSuffix(originHostOnly, ".chromiumapp.org")
res := originHostOnly == "http://localhost" || originHostOnly == "https://localhost" || originHostOnly == "http://127.0.0.1" || originHostOnly == "http://casdoor-authenticator" || strings.HasSuffix(originHostOnly, ".chromiumapp.org")
return res, nil
}

View File

@ -36,6 +36,7 @@ const {Footer, Content} = Layout;
import {setTwoToneColor} from "@ant-design/icons";
import * as ApplicationBackend from "./backend/ApplicationBackend";
import * as Cookie from "cookie";
setTwoToneColor("rgb(87,52,211)");
@ -269,7 +270,9 @@ class App extends Component {
});
}
renderFooter() {
renderFooter(logo, footerHtml) {
logo = logo ?? this.state.logo;
footerHtml = footerHtml ?? this.state.application?.footerHtml;
return (
<React.Fragment>
{!this.state.account ? null : <div style={{display: "none"}} id="CasdoorApplicationName" value={this.state.account.signupApplication} />}
@ -280,14 +283,14 @@ class App extends Component {
}
}>
{
this.state.application?.footerHtml && this.state.application.footerHtml !== "" ?
footerHtml && footerHtml !== "" ?
<React.Fragment>
<div dangerouslySetInnerHTML={{__html: this.state.application.footerHtml}} />
<div dangerouslySetInnerHTML={{__html: footerHtml}} />
</React.Fragment>
: (
Conf.CustomFooter !== null ? Conf.CustomFooter : (
<React.Fragment>
Powered by <a target="_blank" href="https://casdoor.org" rel="noreferrer"><img style={{paddingBottom: "3px"}} height={"20px"} alt={"Casdoor"} src={this.state.logo} /></a>
Powered by <a target="_blank" href="https://casdoor.org" rel="noreferrer"><img style={{paddingBottom: "3px"}} height={"20px"} alt={"Casdoor"} src={logo} /></a>
</React.Fragment>
)
)
@ -358,13 +361,37 @@ class App extends Component {
}
};
onLoginSuccess(redirectUrl) {
window.google?.accounts?.id?.cancel();
if (redirectUrl) {
localStorage.setItem("mfaRedirectUrl", redirectUrl);
}
this.getAccount();
}
renderPage() {
if (this.isDoorPages()) {
let themeData = this.state.themeData;
let logo = this.state.logo;
let footerHtml = null;
if (this.state.organization === undefined) {
const curCookie = Cookie.parse(document.cookie);
if (curCookie["organizationTheme"] && curCookie["organizationTheme"] !== "null") {
themeData = JSON.parse(curCookie["organizationTheme"]);
}
if (curCookie["organizationLogo"] && curCookie["organizationLogo"] !== "") {
logo = curCookie["organizationLogo"];
}
if (curCookie["organizationFootHtml"] && curCookie["organizationFootHtml"] !== "") {
footerHtml = curCookie["organizationFootHtml"];
}
}
return (
<ConfigProvider theme={{
token: {
colorPrimary: this.state.themeData.colorPrimary,
borderRadius: this.state.themeData.borderRadius,
colorPrimary: themeData.colorPrimary,
borderRadius: themeData.borderRadius,
},
algorithm: Setting.getAlgorithm(this.state.themeAlgorithm),
}}>
@ -382,26 +409,20 @@ class App extends Component {
application: application,
});
}}
onLoginSuccess={(redirectUrl) => {
window.google?.accounts?.id?.cancel();
if (redirectUrl) {
localStorage.setItem("mfaRedirectUrl", redirectUrl);
}
this.getAccount();
}}
onLoginSuccess={(redirectUrl) => {this.onLoginSuccess(redirectUrl);}}
onUpdateAccount={(account) => this.onUpdateAccount(account)}
updataThemeData={this.setTheme}
/> :
<Switch>
<Route exact path="/callback" component={AuthCallback} />
<Route exact path="/callback/saml" component={SamlCallback} />
<Route exact path="/callback" render={(props) => <AuthCallback {...props} {...this.props} application={this.state.application} onLoginSuccess={(redirectUrl) => {this.onLoginSuccess(redirectUrl);}} />} />
<Route exact path="/callback/saml" render={(props) => <SamlCallback {...props} {...this.props} application={this.state.application} onLoginSuccess={(redirectUrl) => {this.onLoginSuccess(redirectUrl);}} />} />
<Route path="" render={() => <Result status="404" title="404 NOT FOUND" subTitle={i18next.t("general:Sorry, the page you visited does not exist.")}
extra={<a href="/"><Button type="primary">{i18next.t("general:Back Home")}</Button></a>} />} />
</Switch>
}
</Content>
{
this.renderFooter()
this.renderFooter(logo, footerHtml)
}
{
this.renderAiAssistant()

View File

@ -111,7 +111,7 @@ class EntryPage extends React.Component {
<div className={`${isDarkMode ? "loginBackgroundDark" : "loginBackground"}`}
style={{backgroundImage: Setting.inIframe() || Setting.isMobile() ? null : `url(${this.state.application?.formBackgroundUrl})`}}>
<Spin size="large" spinning={this.state.application === undefined && this.state.pricing === undefined} tip={i18next.t("login:Loading")}
style={{margin: "0 auto"}} />
style={{width: "100%", margin: "0 auto", position: "absolute"}} />
<Switch>
<Route exact path="/signup" render={(props) => this.renderHomeIfLoggedIn(<SignupPage {...this.props} application={this.state.application} applicationName={authConfig.appName} onUpdateApplication={onUpdateApplication} {...props} />)} />
<Route exact path="/signup/:applicationName" render={(props) => this.renderHomeIfLoggedIn(<SignupPage {...this.props} application={this.state.application} onUpdateApplication={onUpdateApplication} {...props} />)} />

View File

@ -33,18 +33,6 @@ class GroupListPage extends BaseListPage {
}
UNSAFE_componentWillMount() {
super.UNSAFE_componentWillMount();
this.getGroups(this.state.owner);
}
getGroups(organizationName) {
GroupBackend.getGroups(organizationName)
.then((res) => {
if (res.status === "ok") {
this.setState({
groups: res.data,
});
}
});
}
newGroup() {
@ -188,12 +176,8 @@ class GroupListPage extends BaseListPage {
{record.parentId}
</Link>;
}
const parentGroup = this.state.groups.find((group) => group.name === text);
if (parentGroup === undefined) {
return "";
}
return <Link to={`/groups/${parentGroup.owner}/${parentGroup.name}`}>
{parentGroup?.displayName}
return <Link to={`/groups/${record.owner}/${record.parentId}`}>
{record?.parentName}
</Link>;
},
},
@ -215,12 +199,11 @@ class GroupListPage extends BaseListPage {
width: "180px",
fixed: (Setting.isMobile()) ? "false" : "right",
render: (text, record, index) => {
const haveChildren = this.state.groups.find((group) => group.parentId === record.id) !== undefined;
return (
<div>
<Button style={{marginTop: "10px", marginBottom: "10px", marginRight: "10px"}} type="primary" onClick={() => this.props.history.push(`/groups/${record.owner}/${record.name}`)}>{i18next.t("general:Edit")}</Button>
<PopconfirmModal
disabled={haveChildren}
disabled={record.haveChildren}
title={i18next.t("general:Sure to delete") + `: ${record.name} ?`}
onConfirm={() => this.deleteGroup(index)}
>

View File

@ -106,6 +106,22 @@ class InvitationEditPage extends React.Component {
});
}
copySignupLink() {
let defaultApplication;
if (this.state.invitation.owner === "built-in") {
defaultApplication = "app-built-in";
} else {
const selectedOrganization = Setting.getArrayItem(this.state.organizations, "name", this.state.invitation.owner);
defaultApplication = selectedOrganization.defaultApplication;
if (!defaultApplication) {
Setting.showMessage("error", i18next.t("invitation:You need to specify a default application for ") + selectedOrganization.name);
return;
}
}
copy(`${window.location.origin}/signup/${defaultApplication}?invitationCode=${this.state.invitation?.defaultCode}`);
Setting.showMessage("success", i18next.t("general:Copied to clipboard successfully"));
}
renderInvitation() {
const isCreatedByPlan = this.state.invitation.tag === "auto_created_invitation_for_plan";
return (
@ -114,16 +130,7 @@ class InvitationEditPage extends React.Component {
{this.state.mode === "add" ? i18next.t("invitation:New Invitation") : i18next.t("invitation:Edit Invitation")}&nbsp;&nbsp;&nbsp;&nbsp;
<Button onClick={() => this.submitInvitationEdit(false)}>{i18next.t("general:Save")}</Button>
<Button style={{marginLeft: "20px"}} type="primary" onClick={() => this.submitInvitationEdit(true)}>{i18next.t("general:Save & Exit")}</Button>
<Button style={{marginLeft: "20px"}} onClick={() => {
let defaultApplication;
if (this.state.invitation.owner === "built-in") {
defaultApplication = "app-built-in";
} else {
defaultApplication = Setting.getArrayItem(this.state.organizations, "name", this.state.invitation.owner).defaultApplication;
}
copy(`${window.location.origin}/signup/${defaultApplication}?invitationCode=${this.state.invitation?.defaultCode}`);
Setting.showMessage("success", i18next.t("general:Copied to clipboard successfully"));
}}>
<Button style={{marginLeft: "20px"}} onClick={_ => this.copySignupLink()}>
{i18next.t("application:Copy signup page URL")}
</Button>
{this.state.mode === "add" ? <Button style={{marginLeft: "20px"}} onClick={() => this.deleteInvitation()}>{i18next.t("general:Cancel")}</Button> : null}
@ -330,16 +337,7 @@ class InvitationEditPage extends React.Component {
<div style={{marginTop: "20px", marginLeft: "40px"}}>
<Button size="large" onClick={() => this.submitInvitationEdit(false)}>{i18next.t("general:Save")}</Button>
<Button style={{marginLeft: "20px"}} type="primary" size="large" onClick={() => this.submitInvitationEdit(true)}>{i18next.t("general:Save & Exit")}</Button>
<Button style={{marginLeft: "20px"}} size="large" onClick={() => {
let defaultApplication;
if (this.state.invitation.owner === "built-in") {
defaultApplication = "app-built-in";
} else {
defaultApplication = Setting.getArrayItem(this.state.organizations, "name", this.state.invitation.owner).defaultApplication;
}
copy(`${window.location.origin}/signup/${defaultApplication}?invitationCode=${this.state.invitation?.defaultCode}`);
Setting.showMessage("success", i18next.t("general:Copied to clipboard successfully"));
}}>
<Button style={{marginLeft: "20px"}} size="large" onClick={_ => this.copySignupLink()}>
{i18next.t("application:Copy signup page URL")}
</Button>
{this.state.mode === "add" ? <Button style={{marginLeft: "20px"}} size="large" onClick={() => this.deleteInvitation()}>{i18next.t("general:Cancel")}</Button> : null}

View File

@ -228,6 +228,21 @@ class LdapEditPage extends React.Component {
/>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{lineHeight: "32px", textAlign: "right", paddingRight: "25px"}} span={3}>
{Setting.getLabel(i18next.t("general:Password type"), i18next.t("general:Password type - Tooltip"))} :
</Col>
<Col span={21}>
<Select virtual={false} style={{width: "100%"}} value={this.state.ldap.passwordType ?? []} onChange={(value => {
this.updateLdapField("passwordType", value);
})}
>
<Option key={"Plain"} value={"Plain"}>{i18next.t("general:Plain")}</Option>
<Option key={"SSHA"} value={"SSHA"} >SSHA</Option>
<Option key={"MD5"} value={"MD5"} >MD5</Option>
</Select>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{lineHeight: "32px", textAlign: "right", paddingRight: "25px"}} span={3}>
{Setting.getLabel(i18next.t("ldap:Default group"), i18next.t("ldap:Default group - Tooltip"))} :

View File

@ -241,7 +241,7 @@ function ManagementPage(props) {
<Link to="/">
<img className="logo" src={logo ?? props.logo} alt="logo" />
</Link>,
disabled: true,
disabled: true, key: "logo",
style: {
padding: 0,
height: "auto",
@ -323,7 +323,35 @@ function ManagementPage(props) {
}
}
return res;
const navItems = props.account.organization.navItems;
if (!Array.isArray(navItems)) {
return res;
}
if (navItems.includes("all")) {
return res;
}
const resFiltered = res.map(item => {
if (!Array.isArray(item.children)) {
return item;
}
const filteredChildren = [];
item.children.forEach(itemChild => {
if (navItems.includes(itemChild.key)) {
filteredChildren.push(itemChild);
}
});
item.children = filteredChildren;
return item;
});
return resFiltered.filter(item => {
if (item.key === "#" || item.key === "logo") {return true;}
return Array.isArray(item.children) && item.children.length > 0;
});
}
function renderLoginIfNotLoggedIn(component) {

View File

@ -26,6 +26,7 @@ import LdapTable from "./table/LdapTable";
import AccountTable from "./table/AccountTable";
import ThemeEditor from "./common/theme/ThemeEditor";
import MfaTable from "./table/MfaTable";
import {NavItemTree} from "./common/NavItemTree";
const {Option} = Select;
@ -420,6 +421,18 @@ class OrganizationEditPage extends React.Component {
} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("organization:User types"), i18next.t("organization:User types - Tooltip"))} :
</Col>
<Col span={22} >
<Select virtual={false} mode="tags" style={{width: "100%"}} value={this.state.organization.userTypes} onChange={(value => {this.updateOrganizationField("userTypes", value);})}>
{
this.state.organization.userTypes?.map((item, index) => <Option key={index} value={item}>{item}</Option>)
}
</Select>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("organization:Tags"), i18next.t("organization:Tags - Tooltip"))} :
@ -522,6 +535,21 @@ class OrganizationEditPage extends React.Component {
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Navbar items"), i18next.t("general:Navbar items - Tooltip"))} :
</Col>
<Col span={22} >
<NavItemTree
disabled={!Setting.isAdminUser(this.props.account)}
checkedKeys={this.state.organization.navItems ?? ["all"]}
defaultExpandedKeys={["all"]}
onCheck={(checked, _) => {
this.updateOrganizationField("navItems", checked);
}}
/>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("organization:Account items"), i18next.t("organization:Account items - Tooltip"))} :

View File

@ -34,7 +34,7 @@ class OrganizationListPage extends BaseListPage {
favicon: `${Setting.StaticBaseUrl}/img/favicon.png`,
passwordType: "plain",
PasswordSalt: "",
passwordOptions: [],
passwordOptions: ["AtLeast6"],
passwordObfuscatorType: "Plain",
passwordObfuscatorKey: "",
passwordExpireDays: 0,

View File

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

View File

@ -238,6 +238,8 @@ class ProductBuyPage extends React.Component {
text = i18next.t("product:PayPal");
} else if (provider.type === "Stripe") {
text = i18next.t("product:Stripe");
} else if (provider.type === "AirWallex") {
text = i18next.t("product:AirWallex");
}
return (

View File

@ -297,6 +297,8 @@ class ProviderEditPage extends React.Component {
return Setting.getLabel(i18next.t("provider:Scene"), i18next.t("provider:Scene - Tooltip"));
} else if (provider.type === "WeChat Pay") {
return Setting.getLabel(i18next.t("provider:App ID"), i18next.t("provider:App ID - Tooltip"));
} else if (provider.type === "CUCloud") {
return Setting.getLabel(i18next.t("provider:Account ID"), i18next.t("provider:Account ID - Tooltip"));
} else {
return Setting.getLabel(i18next.t("provider:Client ID 2"), i18next.t("provider:Client ID 2 - Tooltip"));
}
@ -393,6 +395,9 @@ class ProviderEditPage extends React.Component {
} else if (provider.type === "Line" || provider.type === "Matrix" || provider.type === "Rocket Chat") {
text = i18next.t("provider:App Key");
tooltip = i18next.t("provider:App Key - Tooltip");
} else if (provider.type === "CUCloud") {
text = i18next.t("provider:Topic name");
tooltip = i18next.t("provider:Topic name - Tooltip");
}
}
@ -462,6 +467,39 @@ class ProviderEditPage extends React.Component {
this.updateProviderField("issuerUrl", issuerUrl);
}
fetchSamlMetadata() {
this.setState({
metadataLoading: true,
});
fetch(this.state.requestUrl, {
method: "GET",
}).then(res => {
if (!res.ok) {
return Promise.reject("error");
}
return res.text();
}).then(text => {
this.updateProviderField("metadata", text);
this.parseSamlMetadata();
Setting.showMessage("success", i18next.t("general:Successfully added"));
}).catch(err => {
Setting.showMessage("error", err.message);
}).finally(() => {
this.setState({
metadataLoading: false,
});
});
}
parseSamlMetadata() {
try {
this.loadSamlConfiguration();
Setting.showMessage("success", i18next.t("provider:Parse metadata successfully"));
} catch (err) {
Setting.showMessage("error", i18next.t("provider:Can not parse metadata"));
}
}
renderProvider() {
return (
<Card size="small" title={
@ -771,7 +809,7 @@ class ProviderEditPage extends React.Component {
)
}
{
this.state.provider.category !== "Email" && this.state.provider.type !== "WeChat" && this.state.provider.type !== "Apple" && this.state.provider.type !== "Aliyun Captcha" && this.state.provider.type !== "WeChat Pay" && this.state.provider.type !== "Twitter" && this.state.provider.type !== "Reddit" ? null : (
this.state.provider.category !== "Email" && this.state.provider.type !== "WeChat" && this.state.provider.type !== "Apple" && this.state.provider.type !== "Aliyun Captcha" && this.state.provider.type !== "WeChat Pay" && this.state.provider.type !== "Twitter" && this.state.provider.type !== "Reddit" && this.state.provider.type !== "CUCloud" ? null : (
<React.Fragment>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
@ -784,7 +822,7 @@ class ProviderEditPage extends React.Component {
</Col>
</Row>
{
(this.state.provider.type === "WeChat Pay") || (this.state.provider.category === "Email" && (this.state.provider.type === "Azure ACS" || this.state.provider.type === "SendGrid")) ? null : (
(this.state.provider.type === "WeChat Pay" || this.state.provider.type === "CUCloud") || (this.state.provider.category === "Email" && (this.state.provider.type === "Azure ACS" || this.state.provider.type === "SendGrid")) ? null : (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{this.getClientSecret2Label(this.state.provider)} :
@ -870,9 +908,9 @@ class ProviderEditPage extends React.Component {
</Row>
)
}
{this.state.provider.category === "Storage" || ["Custom HTTP SMS", "Custom HTTP Email"].includes(this.state.provider.type) ? (
{this.state.provider.category === "Storage" || ["Custom HTTP SMS", "Custom HTTP Email", "CUCloud"].includes(this.state.provider.type) ? (
<div>
{["Local File System"].includes(this.state.provider.type) ? null : (
{["Local File System", "CUCloud"].includes(this.state.provider.type) ? null : (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={2}>
{Setting.getLabel(i18next.t("provider:Endpoint"), i18next.t("provider:Region endpoint for Internet"))} :
@ -884,7 +922,7 @@ class ProviderEditPage extends React.Component {
</Col>
</Row>
)}
{["Custom HTTP SMS", "Local File System", "MinIO", "Tencent Cloud COS", "Google Cloud Storage", "Qiniu Cloud Kodo", "Synology", "Casdoor"].includes(this.state.provider.type) ? null : (
{["Custom HTTP SMS", "Local File System", "MinIO", "Tencent Cloud COS", "Google Cloud Storage", "Qiniu Cloud Kodo", "Synology", "Casdoor", "CUCloud"].includes(this.state.provider.type) ? null : (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={2}>
{Setting.getLabel(i18next.t("provider:Endpoint (Intranet)"), i18next.t("provider:Region endpoint for Intranet"))} :
@ -896,7 +934,7 @@ class ProviderEditPage extends React.Component {
</Col>
</Row>
)}
{["Custom HTTP SMS", "Local File System"].includes(this.state.provider.type) ? null : (
{["Custom HTTP SMS", "Local File System", "CUCloud"].includes(this.state.provider.type) ? null : (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={2}>
{["Casdoor"].includes(this.state.provider.type) ?
@ -910,7 +948,7 @@ class ProviderEditPage extends React.Component {
</Col>
</Row>
)}
{["Custom HTTP SMS"].includes(this.state.provider.type) ? null : (
{["Custom HTTP SMS", "CUCloud"].includes(this.state.provider.type) ? null : (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={2}>
{Setting.getLabel(i18next.t("provider:Path prefix"), i18next.t("provider:Path prefix - Tooltip"))} :
@ -922,7 +960,7 @@ class ProviderEditPage extends React.Component {
</Col>
</Row>
)}
{["Custom HTTP SMS", "Synology", "Casdoor"].includes(this.state.provider.type) ? null : (
{["Custom HTTP SMS", "Synology", "Casdoor", "CUCloud"].includes(this.state.provider.type) ? null : (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={2}>
{Setting.getLabel(i18next.t("provider:Domain"), i18next.t("provider:Domain - Tooltip"))} :
@ -946,7 +984,7 @@ class ProviderEditPage extends React.Component {
</Col>
</Row>
) : null}
{["AWS S3", "Tencent Cloud COS", "Qiniu Cloud Kodo", "Casdoor", "CUCloud OSS", "MinIO"].includes(this.state.provider.type) ? (
{["AWS S3", "Tencent Cloud COS", "Qiniu Cloud Kodo", "Casdoor", "CUCloud OSS", "MinIO", "CUCloud"].includes(this.state.provider.type) ? (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={2}>
{["Casdoor"].includes(this.state.provider.type) ?
@ -985,7 +1023,7 @@ class ProviderEditPage extends React.Component {
</Col>
</Row>
) : null}
{["Custom HTTP"].includes(this.state.provider.type) ? (
{["Custom HTTP", "CUCloud"].includes(this.state.provider.type) ? (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:Parameter"), i18next.t("provider:Parameter - Tooltip"))} :
@ -997,7 +1035,7 @@ class ProviderEditPage extends React.Component {
</Col>
</Row>
) : null}
{["Google Chat"].includes(this.state.provider.type) ? (
{["Google Chat", "CUCloud"].includes(this.state.provider.type) ? (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:Metadata"), i18next.t("provider:Metadata - Tooltip"))} :
@ -1237,6 +1275,21 @@ class ProviderEditPage extends React.Component {
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:Metadata url"), i18next.t("provider:Metadata url - Tooltip"))} :
</Col>
<Col span={6} >
<Input value={this.state.requestUrl} onChange={e => {
this.setState({
requestUrl: e.target.value,
});
}} />
</Col>
<Col span={16} >
<Button type="primary" loading={this.state.metadataLoading} onClick={() => {this.fetchSamlMetadata();}}>{i18next.t("general:Request")}</Button>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:Metadata"), i18next.t("provider:Metadata - Tooltip"))} :
@ -1250,14 +1303,7 @@ class ProviderEditPage extends React.Component {
<Row style={{marginTop: "20px"}}>
<Col style={{marginTop: "5px"}} span={2} />
<Col span={2}>
<Button type="primary" onClick={() => {
try {
this.loadSamlConfiguration();
Setting.showMessage("success", i18next.t("provider:Parse metadata successfully"));
} catch (err) {
Setting.showMessage("error", i18next.t("provider:Can not parse metadata"));
}
}}>
<Button type="primary" onClick={() => {this.parseSamlMetadata();}}>
{i18next.t("provider:Parse")}
</Button>
</Col>

View File

@ -14,7 +14,7 @@
import React from "react";
import {Link} from "react-router-dom";
import {Select, Tag, Tooltip, message, theme} from "antd";
import {Button, Select, Tag, Tooltip, message, theme} from "antd";
import {QuestionCircleTwoTone} from "@ant-design/icons";
import {isMobile as isMobileDevice} from "react-device-detect";
import "./i18n";
@ -25,6 +25,8 @@ import {Helmet} from "react-helmet";
import * as Conf from "./Conf";
import * as phoneNumber from "libphonenumber-js";
import moment from "moment";
import {MfaAuthVerifyForm, NextMfa, RequiredMfa} from "./auth/mfa/MfaAuthVerifyForm";
import {EmailMfaType, SmsMfaType, TotpMfaType} from "./auth/MfaSetupPage";
const {Option} = Select;
@ -277,6 +279,10 @@ export const OtherProviderInfo = {
logo: `${StaticBaseUrl}/img/social_stripe.png`,
url: "https://stripe.com/",
},
"AirWallex": {
logo: `${StaticBaseUrl}/img/payment_airwallex.svg`,
url: "https://airwallex.com/",
},
"GC": {
logo: `${StaticBaseUrl}/img/payment_gc.png`,
url: "https://gc.org",
@ -405,6 +411,10 @@ export const OtherProviderInfo = {
logo: `${StaticBaseUrl}/img/social_viber.png`,
url: "https://www.viber.com/",
},
"CUCloud": {
logo: `${StaticBaseUrl}/img/cucloud.png`,
url: "https://www.cucloud.cn/",
},
},
};
@ -1100,6 +1110,7 @@ export function getProviderTypeOptions(category) {
{id: "WeChat Pay", name: "WeChat Pay"},
{id: "PayPal", name: "PayPal"},
{id: "Stripe", name: "Stripe"},
{id: "AirWallex", name: "AirWallex"},
{id: "GC", name: "GC"},
]);
} else if (category === "Captcha") {
@ -1137,6 +1148,7 @@ export function getProviderTypeOptions(category) {
{id: "Reddit", name: "Reddit"},
{id: "Rocket Chat", name: "Rocket Chat"},
{id: "Viber", name: "Viber"},
{id: "CUCloud", name: "CUCloud"},
]);
} else {
return [];
@ -1391,7 +1403,13 @@ export function getTag(color, text, icon) {
}
export function getApplicationName(application) {
return `${application?.owner}/${application?.name}`;
let name = `${application?.owner}/${application?.name}`;
if (application?.isShared && application?.organization) {
name += `-org-${application.organization}`;
}
return name;
}
export function getApplicationDisplayName(application) {
@ -1583,3 +1601,114 @@ export function getCurrencyText(product) {
export function isDarkTheme(themeAlgorithm) {
return themeAlgorithm && themeAlgorithm.includes("dark");
}
function getPreferredMfaProp(mfaProps) {
for (const i in mfaProps) {
if (mfaProps[i].isPreffered) {
return mfaProps[i];
}
}
return mfaProps[0];
}
export function checkLoginMfa(res, body, params, handleLogin, componentThis, requireRedirect = null) {
if (res.data === RequiredMfa) {
if (!requireRedirect) {
componentThis.props.onLoginSuccess(window.location.href);
} else {
componentThis.props.onLoginSuccess(requireRedirect);
}
} else if (res.data === NextMfa) {
componentThis.setState({
mfaProps: res.data2,
selectedMfaProp: getPreferredMfaProp(res.data2),
}, () => {
body["providerBack"] = body["provider"];
body["provider"] = "";
componentThis.setState({
getVerifyTotp: () => renderMfaAuthVerifyForm(body, params, handleLogin, componentThis),
});
});
} else if (res.data === "SelectPlan") {
// paid-user does not have active or pending subscription, go to application default pricing page to select-plan
const pricing = res.data2;
goToLink(`/select-plan/${pricing.owner}/${pricing.name}?user=${body.username}`);
} else if (res.data === "BuyPlanResult") {
// paid-user has pending subscription, go to buy-plan/result apge to notify payment result
const sub = res.data2;
goToLink(`/buy-plan/${sub.owner}/${sub.pricing}/result?subscription=${sub.name}`);
} else {
handleLogin(res);
}
}
export function getApplicationObj(componentThis) {
return componentThis.props.application;
}
export function parseOffset(offset) {
if (offset === 2 || offset === 4 || inIframe() || isMobile()) {
return "0 auto";
}
if (offset === 1) {
return "0 10%";
}
if (offset === 3) {
return "0 60%";
}
}
function renderMfaAuthVerifyForm(values, authParams, onSuccess, componentThis) {
return (
<div>
<MfaAuthVerifyForm
mfaProps={componentThis.state.selectedMfaProp}
formValues={values}
authParams={authParams}
application={getApplicationObj(componentThis)}
onFail={(errorMessage) => {
showMessage("error", errorMessage);
}}
onSuccess={(res) => onSuccess(res)}
/>
<div>
{
componentThis.state.mfaProps.map((mfa) => {
if (componentThis.state.selectedMfaProp.mfaType === mfa.mfaType) {return null;}
let mfaI18n = "";
switch (mfa.mfaType) {
case SmsMfaType: mfaI18n = i18next.t("mfa:Use SMS"); break;
case TotpMfaType: mfaI18n = i18next.t("mfa:Use Authenticator App"); break ;
case EmailMfaType: mfaI18n = i18next.t("mfa:Use Email") ;break;
}
return <div key={mfa.mfaType}><Button type={"link"} onClick={() => {
componentThis.setState({
selectedMfaProp: mfa,
});
}}>{mfaI18n}</Button></div>;
})
}
</div>
</div>);
}
export function renderLoginPanel(application, getInnerComponent, componentThis) {
return (
<div className="login-content" style={{margin: componentThis.props.preview ?? parseOffset(application.formOffset)}}>
{inIframe() || isMobile() ? null : <div dangerouslySetInnerHTML={{__html: application.formCss}} />}
{inIframe() || !isMobile() ? null : <div dangerouslySetInnerHTML={{__html: application.formCssMobile}} />}
<div className={isDarkTheme(componentThis.props.themeAlgorithm) ? "login-panel-dark" : "login-panel"}>
<div className="side-image" style={{display: application.formOffset !== 4 ? "none" : null}}>
<div dangerouslySetInnerHTML={{__html: application.formSideHtml}} />
</div>
<div className="login-form">
<div>
{
getInnerComponent()
}
</div>
</div>
</div>
</div>
);
}

View File

@ -397,6 +397,12 @@ class UserEditPage extends React.Component {
</Row>
);
} else if (accountItem.name === "User type") {
let userTypes = ["normal-user", "paid-user"];
const organization = this.getUserOrganization();
if (organization && organization.userTypes && organization.userTypes.length > 0) {
userTypes = organization.userTypes;
}
return (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
@ -404,7 +410,7 @@ class UserEditPage extends React.Component {
</Col>
<Col span={22} >
<Select virtual={false} style={{width: "100%"}} value={this.state.user.type} onChange={(value => {this.updateUserField("type", value);})}
options={["normal-user", "paid-user"].map(item => Setting.getOption(item, item))}
options={userTypes.map(item => Setting.getOption(item, item))}
/>
</Col>
</Row>

View File

@ -318,6 +318,14 @@ class UserListPage extends BaseListPage {
return Setting.initCountries().getName(record.region, Setting.getLanguage(), {select: "official"});
},
},
{
title: i18next.t("general:User type"),
dataIndex: "type",
key: "type",
width: "120px",
sorter: true,
...this.getColumnSearchProps("type"),
},
{
title: i18next.t("user:Tag"),
dataIndex: "tag",
@ -343,7 +351,7 @@ class UserListPage extends BaseListPage {
title: i18next.t("user:Is admin"),
dataIndex: "isAdmin",
key: "isAdmin",
width: "110px",
width: "120px",
sorter: true,
render: (text, record, index) => {
return (

View File

@ -21,6 +21,7 @@ import {authConfig} from "./Auth";
import * as Setting from "../Setting";
import i18next from "i18next";
import RedirectForm from "../common/RedirectForm";
import {renderLoginPanel} from "../Setting";
class AuthCallback extends React.Component {
constructor(props) {
@ -131,19 +132,23 @@ class AuthCallback extends React.Component {
// user is using casdoor as cas sso server, and wants the ticket to be acquired
AuthBackend.loginCas(body, {"service": casService}).then((res) => {
if (res.status === "ok") {
let msg = "Logged in successfully.";
if (casService === "") {
// If service was not specified, Casdoor must display a message notifying the client that it has successfully initiated a single sign-on session.
msg += "Now you can visit apps protected by Casdoor.";
}
Setting.showMessage("success", msg);
const handleCasLogin = (res) => {
let msg = "Logged in successfully.";
if (casService === "") {
// If service was not specified, Casdoor must display a message notifying the client that it has successfully initiated a single sign-on session.
msg += "Now you can visit apps protected by Casdoor.";
}
Setting.showMessage("success", msg);
if (casService !== "") {
const st = res.data;
const newUrl = new URL(casService);
newUrl.searchParams.append("ticket", st);
window.location.href = newUrl.toString();
}
if (casService !== "") {
const st = res.data;
const newUrl = new URL(casService);
newUrl.searchParams.append("ticket", st);
window.location.href = newUrl.toString();
}
};
Setting.checkLoginMfa(res, body, {"service": casService}, handleCasLogin, this);
} else {
Setting.showMessage("error", `${i18next.t("application:Failed to sign in")}: ${res.msg}`);
}
@ -159,54 +164,58 @@ class AuthCallback extends React.Component {
.then((res) => {
if (res.status === "ok") {
const responseType = this.getResponseType();
if (responseType === "login") {
if (res.data2) {
sessionStorage.setItem("signinUrl", signinUrl);
Setting.goToLinkSoft(this, `/forget/${applicationName}`);
return;
}
Setting.showMessage("success", "Logged in successfully");
// Setting.goToLinkSoft(this, "/");
const link = Setting.getFromLink();
Setting.goToLink(link);
} else if (responseType === "code") {
if (res.data2) {
sessionStorage.setItem("signinUrl", signinUrl);
Setting.goToLinkSoft(this, `/forget/${applicationName}`);
return;
}
const code = res.data;
Setting.goToLink(`${oAuthParams.redirectUri}${concatChar}code=${code}&state=${oAuthParams.state}`);
// Setting.showMessage("success", `Authorization code: ${res.data}`);
} else if (responseType === "token" || responseType === "id_token") {
if (res.data2) {
sessionStorage.setItem("signinUrl", signinUrl);
Setting.goToLinkSoft(this, `/forget/${applicationName}`);
return;
}
const token = res.data;
Setting.goToLink(`${oAuthParams.redirectUri}${concatChar}${responseType}=${token}&state=${oAuthParams.state}&token_type=bearer`);
} else if (responseType === "link") {
const from = innerParams.get("from");
Setting.goToLinkSoftOrJumpSelf(this, from);
} else if (responseType === "saml") {
if (res.data2.method === "POST") {
this.setState({
samlResponse: res.data,
redirectUrl: res.data2.redirectUrl,
relayState: oAuthParams.relayState,
});
} else {
if (res.data2.needUpdatePassword) {
const handleLogin = (res) => {
if (responseType === "login") {
if (res.data2) {
sessionStorage.setItem("signinUrl", signinUrl);
Setting.goToLinkSoft(this, `/forget/${applicationName}`);
return;
}
const SAMLResponse = res.data;
const redirectUri = res.data2.redirectUrl;
Setting.goToLink(`${redirectUri}?SAMLResponse=${encodeURIComponent(SAMLResponse)}&RelayState=${oAuthParams.relayState}`);
Setting.showMessage("success", "Logged in successfully");
// Setting.goToLinkSoft(this, "/");
const link = Setting.getFromLink();
Setting.goToLink(link);
} else if (responseType === "code") {
if (res.data2) {
sessionStorage.setItem("signinUrl", signinUrl);
Setting.goToLinkSoft(this, `/forget/${applicationName}`);
return;
}
const code = res.data;
Setting.goToLink(`${oAuthParams.redirectUri}${concatChar}code=${code}&state=${oAuthParams.state}`);
// Setting.showMessage("success", `Authorization code: ${res.data}`);
} else if (responseType === "token" || responseType === "id_token") {
if (res.data2) {
sessionStorage.setItem("signinUrl", signinUrl);
Setting.goToLinkSoft(this, `/forget/${applicationName}`);
return;
}
const token = res.data;
Setting.goToLink(`${oAuthParams.redirectUri}${concatChar}${responseType}=${token}&state=${oAuthParams.state}&token_type=bearer`);
} else if (responseType === "link") {
const from = innerParams.get("from");
Setting.goToLinkSoftOrJumpSelf(this, from);
} else if (responseType === "saml") {
if (res.data2.method === "POST") {
this.setState({
samlResponse: res.data,
redirectUrl: res.data2.redirectUrl,
relayState: oAuthParams.relayState,
});
} else {
if (res.data2.needUpdatePassword) {
sessionStorage.setItem("signinUrl", signinUrl);
Setting.goToLinkSoft(this, `/forget/${applicationName}`);
return;
}
const SAMLResponse = res.data;
const redirectUri = res.data2.redirectUrl;
Setting.goToLink(`${redirectUri}${redirectUri.includes("?") ? "&" : "?"}SAMLResponse=${encodeURIComponent(SAMLResponse)}&RelayState=${oAuthParams.relayState}`);
}
}
}
};
Setting.checkLoginMfa(res, body, oAuthParams, handleLogin, this, window.location.origin);
} else {
this.setState({
msg: res.msg,
@ -220,6 +229,11 @@ class AuthCallback extends React.Component {
return <RedirectForm samlResponse={this.state.samlResponse} redirectUrl={this.state.redirectUrl} relayState={this.state.relayState} />;
}
if (this.state.getVerifyTotp !== undefined) {
const application = Setting.getApplicationObj(this);
return renderLoginPanel(application, this.state.getVerifyTotp, this);
}
return (
<div style={{display: "flex", justifyContent: "center", alignItems: "center"}}>
{

View File

@ -264,6 +264,9 @@ class ForgetPage extends React.Component {
)
}
onValuesChange={(changedValues, allValues) => {
if (!changedValues.dest) {
return;
}
const verifyType = changedValues.dest?.indexOf("@") === -1 ? "phone" : "email";
this.setState({
dest: changedValues.dest,

View File

@ -34,7 +34,7 @@ import {SendCodeInput} from "../common/SendCodeInput";
import LanguageSelect from "../common/select/LanguageSelect";
import {CaptchaModal, CaptchaRule} from "../common/modal/CaptchaModal";
import RedirectForm from "../common/RedirectForm";
import {MfaAuthVerifyForm, NextMfa, RequiredMfa} from "./mfa/MfaAuthVerifyForm";
import {RequiredMfa} from "./mfa/MfaAuthVerifyForm";
import {GoogleOneTapLoginVirtualButton} from "./GoogleLoginButton";
import * as ProviderButton from "./ProviderButton";
const FaceRecognitionModal = lazy(() => import("../common/modal/FaceRecognitionModal"));
@ -438,25 +438,7 @@ class LoginPage extends React.Component {
};
if (res.status === "ok") {
if (res.data === NextMfa) {
this.setState({
getVerifyTotp: () => {
return (
<MfaAuthVerifyForm
mfaProps={res.data2}
formValues={values}
authParams={casParams}
application={this.getApplicationObj()}
onFail={(errorMessage) => {
Setting.showMessage("error", errorMessage);
}}
onSuccess={(res) => loginHandler(res)}
/>);
},
});
} else {
loginHandler(res);
}
Setting.checkLoginMfa(res, values, casParams, loginHandler, this);
} else {
Setting.showMessage("error", `${i18next.t("application:Failed to sign in")}: ${res.msg}`);
}
@ -505,39 +487,13 @@ class LoginPage extends React.Component {
} else {
const SAMLResponse = res.data;
const redirectUri = res.data2.redirectUrl;
Setting.goToLink(`${redirectUri}?SAMLResponse=${encodeURIComponent(SAMLResponse)}&RelayState=${oAuthParams.relayState}`);
Setting.goToLink(`${redirectUri}${redirectUri.includes("?") ? "&" : "?"}SAMLResponse=${encodeURIComponent(SAMLResponse)}&RelayState=${oAuthParams.relayState}`);
}
}
};
if (res.status === "ok") {
if (res.data === NextMfa) {
this.setState({
getVerifyTotp: () => {
return (
<MfaAuthVerifyForm
mfaProps={res.data2}
formValues={values}
authParams={oAuthParams}
application={this.getApplicationObj()}
onFail={(errorMessage) => {
Setting.showMessage("error", errorMessage);
}}
onSuccess={(res) => loginHandler(res)}
/>);
},
});
} else if (res.data === "SelectPlan") {
// paid-user does not have active or pending subscription, go to application default pricing page to select-plan
const pricing = res.data2;
Setting.goToLink(`/select-plan/${pricing.owner}/${pricing.name}?user=${values.username}`);
} else if (res.data === "BuyPlanResult") {
// paid-user has pending subscription, go to buy-plan/result apge to notify payment result
const sub = res.data2;
Setting.goToLink(`/buy-plan/${sub.owner}/${sub.pricing}/result?subscription=${sub.name}`);
} else {
loginHandler(res);
}
Setting.checkLoginMfa(res, values, oAuthParams, loginHandler, this);
} else {
Setting.showMessage("error", `${i18next.t("application:Failed to sign in")}: ${res.msg}`);
}

View File

@ -44,6 +44,7 @@ import KwaiLoginButton from "./KwaiLoginButton";
import LoginButton from "./LoginButton";
import * as AuthBackend from "./AuthBackend";
import {WechatOfficialAccountModal} from "./Util";
import * as Setting from "../Setting";
function getSigninButton(provider) {
const text = i18next.t("login:Sign in with {type}").replace("{type}", provider.displayName !== "" ? provider.displayName : provider.type);
@ -114,10 +115,14 @@ function goToSamlUrl(provider, location) {
const relayState = `${clientId}&${state}&${providerName}&${realRedirectUri}&${redirectUri}`;
AuthBackend.getSamlLogin(`${provider.owner}/${providerName}`, btoa(relayState)).then((res) => {
if (res.data2 === "POST") {
document.write(res.data);
if (res.status === "ok") {
if (res.data2 === "POST") {
document.write(res.data);
} else {
window.location.href = res.data;
}
} else {
window.location.href = res.data;
Setting.showMessage("error", res.msg);
}
});
}

View File

@ -20,6 +20,7 @@ import * as Util from "./Util";
import * as Setting from "../Setting";
import i18next from "i18next";
import {authConfig} from "./Auth";
import {renderLoginPanel} from "../Setting";
class SamlCallback extends React.Component {
constructor(props) {
@ -81,13 +82,26 @@ class SamlCallback extends React.Component {
.then((res) => {
if (res.status === "ok") {
const responseType = this.getResponseType(redirectUri);
if (responseType === "login") {
Setting.showMessage("success", "Logged in successfully");
Setting.goToLink("/");
} else if (responseType === "code") {
const code = res.data;
Setting.goToLink(`${redirectUri}?code=${code}&state=${state}`);
}
const handleLogin = (res2) => {
if (responseType === "login") {
Setting.showMessage("success", "Logged in successfully");
Setting.goToLink("/");
} else if (responseType === "code") {
const code = res2.data;
Setting.goToLink(`${redirectUri}?code=${code}&state=${state}`);
}
};
Setting.checkLoginMfa(res, body, {
clientId: clientId,
responseType: responseType,
redirectUri: messages[3],
state: state,
nonce: "",
scope: "read",
challengeMethod: "",
codeChallenge: "",
type: "code",
}, handleLogin, this);
} else {
this.setState({
msg: res.msg,
@ -97,6 +111,11 @@ class SamlCallback extends React.Component {
}
render() {
if (this.state.getVerifyTotp !== undefined) {
const application = Setting.getApplicationObj(this);
return renderLoginPanel(application, this.state.getVerifyTotp, this, window.location.origin);
}
return (
<div style={{display: "flex", justifyContent: "center", alignItems: "center"}}>
{

View File

@ -33,7 +33,8 @@ export function MfaAuthVerifyForm({formValues, authParams, mfaProps, application
const verify = ({passcode}) => {
setLoading(true);
const values = {...formValues, passcode, mfaType};
const values = {...formValues, passcode};
values["mfaType"] = mfaProps.mfaType;
const loginFunction = formValues.type === "cas" ? AuthBackend.loginCas : AuthBackend.login;
loginFunction(values, authParams).then((res) => {
if (res.status === "ok") {
@ -71,7 +72,7 @@ export function MfaAuthVerifyForm({formValues, authParams, mfaProps, application
<div style={{marginBottom: 24, textAlign: "center", fontSize: "24px"}}>
{i18next.t("mfa:Multi-factor authentication")}
</div>
{mfaType === SmsMfaType || mfaType === EmailMfaType ? (
{mfaProps.mfaType === SmsMfaType || mfaProps.mfaType === EmailMfaType ? (
<Fragment>
<div style={{marginBottom: 24}}>
{i18next.t("mfa:You have enabled Multi-Factor Authentication, Please click 'Send Code' to continue")}

View File

@ -9,11 +9,11 @@ export function MfaEnableForm({user, mfaType, secret, recoveryCodes, dest, count
const data = {
mfaType,
secret,
recoveryCodes,
dest,
countryCode,
...user,
};
data["recoveryCodes"] = recoveryCodes[0];
setLoading(true);
MfaBackend.MfaSetupEnable(data).then(res => {
if (res.status === "ok") {

View File

@ -27,7 +27,7 @@ export const generateCasdoorAppUrl = (accessToken, forQrCode = true) => {
return {qrUrl, error};
}
qrUrl = `casdoor-app://login?serverUrl=${window.location.origin}&accessToken=${accessToken}`;
qrUrl = `casdoor-authenticator://login?serverUrl=${window.location.origin}&accessToken=${accessToken}`;
if (forQrCode && qrUrl.length >= 2000) {
qrUrl = "";

View File

@ -0,0 +1,97 @@
import i18next from "i18next";
import {Tree} from "antd";
import React from "react";
export const NavItemTree = ({disable, checkedKeys, defaultExpandedKeys, onCheck}) => {
const NavItemNodes = [
{
title: i18next.t("organization:All"),
key: "all",
children: [
{
title: i18next.t("general:Home"),
key: "/home-top",
children: [
{title: i18next.t("general:Dashboard"), key: "/"},
{title: i18next.t("general:Shortcuts"), key: "/shortcuts"},
{title: i18next.t("general:Apps"), key: "/apps"},
],
},
{
title: i18next.t("general:User Management"),
key: "/orgs-top",
children: [
{title: i18next.t("general:Organizations"), key: "/organizations"},
{title: i18next.t("general:Groups"), key: "/groups"},
{title: i18next.t("general:Users"), key: "/users"},
{title: i18next.t("general:Invitations"), key: "/invitations"},
],
},
{
title: i18next.t("general:Identity"),
key: "/applications-top",
children: [
{title: i18next.t("general:Applications"), key: "/applications"},
{title: i18next.t("general:Providers"), key: "/providers"},
{title: i18next.t("general:Resources"), key: "/resources"},
{title: i18next.t("general:Certs"), key: "/certs"},
],
},
{
title: i18next.t("general:Authorization"),
key: "/roles-top",
children: [
{title: i18next.t("general:Applications"), key: "/roles"},
{title: i18next.t("general:Permissions"), key: "/permissions"},
{title: i18next.t("general:Models"), key: "/models"},
{title: i18next.t("general:Adapters"), key: "/adapters"},
{title: i18next.t("general:Enforcers"), key: "/enforcers"},
],
},
{
title: i18next.t("general:Logging & Auditing"),
key: "/sessions-top",
children: [
{title: i18next.t("general:Sessions"), key: "/sessions"},
{title: i18next.t("general:Records"), key: "/records"},
{title: i18next.t("general:Tokens"), key: "/tokens"},
{title: i18next.t("general:Verifications"), key: "/verifications"},
],
},
{
title: i18next.t("general:Business & Payments"),
key: "/business-top",
children: [
{title: i18next.t("general:Products"), key: "/products"},
{title: i18next.t("general:Payments"), key: "/payments"},
{title: i18next.t("general:Plans"), key: "/plans"},
{title: i18next.t("general:Pricings"), key: "/pricings"},
{title: i18next.t("general:Subscriptions"), key: "/subscriptions"},
{title: i18next.t("general:Transactions"), key: "/transactions"},
],
},
{
title: i18next.t("general:Admin"),
key: "/admin-top",
children: [
{title: i18next.t("general:System Info"), key: "/sysinfo"},
{title: i18next.t("general:Syncers"), key: "/syncers"},
{title: i18next.t("general:Webhooks"), key: "/webhooks"},
{title: i18next.t("general:Swagger"), key: "/swagger"},
],
},
],
},
];
return (
<Tree
disabled={disable}
checkable
checkedKeys={checkedKeys}
defaultExpandedKeys={defaultExpandedKeys}
onCheck={onCheck}
treeData={NavItemNodes}
/>
);
};

View File

@ -58,7 +58,7 @@ export function checkPasswordComplexity(password, options) {
}
if (!options || options.length === 0) {
options = ["AtLeast6"];
return "";
}
const checkers = {

View File

@ -105,7 +105,7 @@ export const PasswordModal = (props) => {
});
};
const hasOldPassword = user.password !== "";
const hasOldPassword = (user.password !== "" || user.ldap !== "");
return (
<Row>

View File

@ -757,6 +757,7 @@
"Sold": "Sold",
"Sold - Tooltip": "Quantity sold",
"Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "Tag of product",
"Test buy page..": "Test buy page..",
"There is no payment channel for this product.": "There is no payment channel for this product.",

View File

@ -757,6 +757,7 @@
"Sold": "Prodáno",
"Sold - Tooltip": "Prodávané množství",
"Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "Štítek produktu",
"Test buy page..": "Testovací stránka nákupu..",
"There is no payment channel for this product.": "Pro tento produkt neexistuje žádný platební kanál.",

View File

@ -757,6 +757,7 @@
"Sold": "Verkauft",
"Sold - Tooltip": "Menge verkauft",
"Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "Tag des Produkts",
"Test buy page..": "Testkaufseite.",
"There is no payment channel for this product.": "Es gibt keinen Zahlungskanal für dieses Produkt.",

View File

@ -757,6 +757,7 @@
"Sold": "Sold",
"Sold - Tooltip": "Quantity sold",
"Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "Tag of product",
"Test buy page..": "Test buy page..",
"There is no payment channel for this product.": "There is no payment channel for this product.",

View File

@ -757,6 +757,7 @@
"Sold": "Vendido",
"Sold - Tooltip": "Cantidad vendida",
"Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "Etiqueta de producto",
"Test buy page..": "Página de compra de prueba.",
"There is no payment channel for this product.": "No hay canal de pago para este producto.",

View File

@ -757,6 +757,7 @@
"Sold": "فروخته شده",
"Sold - Tooltip": "تعداد فروخته شده",
"Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "برچسب محصول",
"Test buy page..": "صفحه تست خرید..",
"There is no payment channel for this product.": "برای این محصول کانال پرداختی وجود ندارد.",

View File

@ -757,6 +757,7 @@
"Sold": "Sold",
"Sold - Tooltip": "Quantity sold",
"Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "Tag of product",
"Test buy page..": "Test buy page..",
"There is no payment channel for this product.": "There is no payment channel for this product.",

View File

@ -757,6 +757,7 @@
"Sold": "Vendu",
"Sold - Tooltip": "Quantité vendue",
"Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "Étiquette de produit",
"Test buy page..": "Page d'achat de test.",
"There is no payment channel for this product.": "Il n'y a aucun canal de paiement pour ce produit.",

View File

@ -757,6 +757,7 @@
"Sold": "Sold",
"Sold - Tooltip": "Quantity sold",
"Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "Tag of product",
"Test buy page..": "Test buy page..",
"There is no payment channel for this product.": "There is no payment channel for this product.",

View File

@ -757,6 +757,7 @@
"Sold": "Terjual",
"Sold - Tooltip": "Jumlah terjual",
"Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "Tag produk",
"Test buy page..": "Halaman pembelian uji coba.",
"There is no payment channel for this product.": "Tidak ada saluran pembayaran untuk produk ini.",

View File

@ -757,6 +757,7 @@
"Sold": "Sold",
"Sold - Tooltip": "Quantity sold",
"Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "Tag of product",
"Test buy page..": "Test buy page..",
"There is no payment channel for this product.": "There is no payment channel for this product.",

View File

@ -757,6 +757,7 @@
"Sold": "売れました",
"Sold - Tooltip": "販売数量",
"Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "製品のタグ",
"Test buy page..": "テスト購入ページ。",
"There is no payment channel for this product.": "この製品には支払いチャネルがありません。",

View File

@ -757,6 +757,7 @@
"Sold": "Sold",
"Sold - Tooltip": "Quantity sold",
"Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "Tag of product",
"Test buy page..": "Test buy page..",
"There is no payment channel for this product.": "There is no payment channel for this product.",

View File

@ -757,6 +757,7 @@
"Sold": "팔렸습니다",
"Sold - Tooltip": "판매량",
"Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "제품 태그",
"Test buy page..": "시험 구매 페이지.",
"There is no payment channel for this product.": "이 제품에 대한 결제 채널이 없습니다.",

View File

@ -757,6 +757,7 @@
"Sold": "Sold",
"Sold - Tooltip": "Quantity sold",
"Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "Tag of product",
"Test buy page..": "Test buy page..",
"There is no payment channel for this product.": "There is no payment channel for this product.",

View File

@ -757,6 +757,7 @@
"Sold": "Sold",
"Sold - Tooltip": "Quantity sold",
"Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "Tag of product",
"Test buy page..": "Test buy page..",
"There is no payment channel for this product.": "There is no payment channel for this product.",

View File

@ -757,6 +757,7 @@
"Sold": "Sold",
"Sold - Tooltip": "Quantity sold",
"Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "Tag of product",
"Test buy page..": "Test buy page..",
"There is no payment channel for this product.": "There is no payment channel for this product.",

View File

@ -757,6 +757,7 @@
"Sold": "Vendido",
"Sold - Tooltip": "Quantidade vendida",
"Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "Tag do produto",
"Test buy page..": "Página de teste de compra...",
"There is no payment channel for this product.": "Não há canal de pagamento disponível para este produto.",

View File

@ -757,6 +757,7 @@
"Sold": "Продано",
"Sold - Tooltip": "Количество проданных",
"Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "Метка продукта",
"Test buy page..": "Страница для тестовой покупки.",
"There is no payment channel for this product.": "Для этого продукта нет канала оплаты.",

View File

@ -757,6 +757,7 @@
"Sold": "Predané",
"Sold - Tooltip": "Množstvo predaných kusov",
"Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "Štítok produktu",
"Test buy page..": "Testovať stránku nákupu..",
"There is no payment channel for this product.": "Pre tento produkt neexistuje platobný kanál.",

View File

@ -757,6 +757,7 @@
"Sold": "Sold",
"Sold - Tooltip": "Quantity sold",
"Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "Tag of product",
"Test buy page..": "Test buy page..",
"There is no payment channel for this product.": "There is no payment channel for this product.",

View File

@ -757,6 +757,7 @@
"Sold": "Sold",
"Sold - Tooltip": "Quantity sold",
"Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "Tag of product",
"Test buy page..": "Test buy page..",
"There is no payment channel for this product.": "There is no payment channel for this product.",

View File

@ -757,6 +757,7 @@
"Sold": "Продано",
"Sold - Tooltip": "Продана кількість",
"Stripe": "смужка",
"AirWallex": "AirWallex",
"Tag - Tooltip": "Тег товару",
"Test buy page..": "Сторінка тестової покупки..",
"There is no payment channel for this product.": "Для цього продукту немає платіжного каналу.",

View File

@ -757,6 +757,7 @@
"Sold": "Đã bán",
"Sold - Tooltip": "Số lượng bán ra",
"Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "Nhãn sản phẩm",
"Test buy page..": "Trang mua thử.",
"There is no payment channel for this product.": "Không có kênh thanh toán cho sản phẩm này.",

View File

@ -757,6 +757,7 @@
"Sold": "售出",
"Sold - Tooltip": "已售出的数量",
"Stripe": "Stripe",
"AirWallex": "AirWallex",
"Tag - Tooltip": "商品类别",
"Test buy page..": "测试购买页面..",
"There is no payment channel for this product.": "该商品没有付款方式。",