Compare commits

...

42 Commits

Author SHA1 Message Date
Yang Luo
c1acb7a432 fix: fix go.sum 2026-03-07 00:02:33 +08:00
Yang Luo
c10b2c162f feat: fix Twilio SMS sending verification code bug (#5205) 2026-03-06 22:32:09 +08:00
ANormalDD
41ec8ba44f feat(web): add AutoComplete for SAML attributes value (#5215) 2026-03-06 20:27:40 +08:00
Yang Luo
7df722a103 fix: set organization.balanceCredit's max to 0 2026-03-06 14:10:07 +08:00
Yang Luo
04b1ca1157 fix: revert "feat: fix BalanceCredit to act as overdraft limit, not minimum balance floor" (#5214) 2026-03-06 13:36:21 +08:00
DacongDA
b0fecefeb7 feat: add Site and Rule to Casdoor (#5194) 2026-03-06 01:02:16 +08:00
Yang Luo
167d24fb1f fix: fix getOAuthGetParameters bug in Moodle 2026-03-05 23:05:20 +08:00
Yang Luo
dc58ac0503 feat: fix BalanceCredit to act as overdraft limit, not minimum balance floor (#5210) 2026-03-05 22:56:46 +08:00
Br1an
038d021797 fix: skip password columns in syncer when remote has no password data (#5183) 2026-03-05 22:35:27 +08:00
Yang Luo
7ba660fd7f feat: fix normal users blocked from /product-store (#5195) 2026-03-05 22:24:36 +08:00
Tomáš Karela Procházka
b1c31a4a9d feat: add Resend email provider (#5200) 2026-03-05 20:55:23 +08:00
Yang Luo
90d7add503 fix: remove useless returnUrl field from ProductEditPage (#5190) 2026-03-04 21:48:47 +08:00
Yang Luo
c961e75ad3 feat: fall back to English when unsupported Accept-Language locale is requested (#5177) 2026-03-04 21:41:10 +08:00
Br1an
547189a034 feat: add missing "min" param for Cloud PNVS SMS provider (#5180) 2026-03-03 09:08:31 +08:00
DacongDA
be725eda74 feat: merge CasWAF's cert related code into Casdoor's cert code (#5171) 2026-02-27 01:36:07 +08:00
Ke Wang
0765b352c9 fix: respect application's ID signup rule in WeChat Mini Program login (#5168) 2026-02-24 21:21:18 +08:00
Yang Luo
a2a8b582d9 feat: make DingTalk syncer respect TableColumns field mapping configuration (#5073) 2026-02-24 12:55:40 +08:00
Sriram-B-Srivatsa
0973652be4 fix: reduce code duplication in Logout logic (#5163) 2026-02-24 12:53:31 +08:00
Yang Luo
fef75715bf fix(web): prevent dashboard graph overlap when y-axis values increase 2026-02-23 15:24:05 +08:00
hikarukimi
4f78d56e31 feat: add OAuth consent page 2026-02-23 15:16:04 +08:00
hikarukimi
712bc756bc fix: improve code format 2026-02-23 15:09:57 +08:00
DacongDA
1c9952e3d9 feat: support JWT Profile for OAuth 2.0 Client Grants (RFC 7523) (#5124) 2026-02-23 14:44:34 +08:00
Yang Luo
bbaa28133f feat: apply application.DefaultGroup for OAuth signups (#5157) 2026-02-22 01:06:18 +08:00
Yang Luo
baef7680ea feat: validate OAuth scopes against Application config; return invalid_scope per RFC 6749 (#5153) 2026-02-21 17:44:26 +08:00
Yang Luo
d15b66177c feat: add missing Telegram field to User struct (#5151) 2026-02-21 17:21:31 +08:00
Yang Luo
5ce6bac529 fix: improve provider table links 2026-02-21 01:36:00 +08:00
Yang Luo
0621f35665 fix: improve tabs height UI in app edit page 2026-02-21 01:16:36 +08:00
Yang Luo
1ac2490419 fix: add OIDC and SAML tabs in application edit page 2026-02-21 01:13:54 +08:00
DacongDA
8c50ada494 feat: refactor provider edit page into different JS files (#5141) 2026-02-21 00:57:38 +08:00
Yang Luo
22da90576e feat: can free input in "Tag" in Addresses table 2026-02-20 16:49:50 +08:00
Yang Luo
b00404cb3a fix: fix RegionSelect cannot save value bug in Addresses table 2026-02-20 16:45:43 +08:00
Yang Luo
2ed27f4f0a fix: improve tables UI in my account page 2026-02-20 16:35:29 +08:00
Yang Luo
bf538d5260 fix: update UpdateUser() columns for missing User fields 2026-02-20 11:02:52 +08:00
Yang Luo
13ee5fd150 feat: sync newOrganization() accountItems with getBuiltInAccountItems() (#5146) 2026-02-20 10:47:02 +08:00
Yang Luo
04cdd5a012 feat: add missing user fields to GetTranslatedUserItems, getBuiltInAccountItems, init_data template, and UserFields (#5144) 2026-02-20 10:37:51 +08:00
Yang Luo
7b4873734b feat: fix "--config" flag to actually load specified configuration file (#5139) 2026-02-19 02:13:29 +08:00
Yang Luo
8d2290944a fix: add back Payment.ProductName and ProductDisplayName fields for backward compatibility 2026-02-18 19:28:14 +08:00
Yang Luo
6a2bba1627 feat: fix field visibility logic for provider types in ProviderEditPage (#5134) 2026-02-18 15:22:28 +08:00
Yang Luo
07554bbbe5 feat: fix Alipay OAuth provider by loading private key from cert object (#5119) 2026-02-17 14:42:21 +08:00
karatekaneen
a050403ee5 feat: fix bug that PKCE fails when multiple custom OAuth providers are configured (#5117) 2026-02-16 23:32:07 +08:00
IsAurora6
118eb0af80 feat: Optimize the display of payment products. (#5115) 2026-02-16 16:32:02 +08:00
Yang Luo
c16aebe642 fix: update README slogan 2026-02-16 02:33:45 +08:00
141 changed files with 10704 additions and 1514 deletions

View File

@@ -1,5 +1,5 @@
<h1 align="center" style="border-bottom: none;">📦⚡️ Casdoor</h1>
<h3 align="center">An open-source UI-first Identity and Access Management (IAM) / Single-Sign-On (SSO) platform with web UI supporting OAuth 2.0, OIDC, SAML, CAS, LDAP, SCIM, WebAuthn, TOTP, MFA and RADIUS</h3>
<h3 align="center">An open-source AI-first Identity and Access Management (IAM) /AI MCP gateway and auth server with web UI supporting MCP, A2A, OAuth 2.1, OIDC, SAML, CAS, LDAP, SCIM, WebAuthn, TOTP, MFA, Face ID, Google Workspace, Azure AD</h3>
<p align="center">
<a href="#badge">
<img alt="semantic-release" src="https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg">

View File

@@ -68,6 +68,7 @@ p, *, *, POST, /api/upload-users, *, *
p, *, *, GET, /api/get-resources, *, *
p, *, *, GET, /api/get-records, *, *
p, *, *, GET, /api/get-product, *, *
p, *, *, GET, /api/get-products, *, *
p, *, *, GET, /api/get-order, *, *
p, *, *, GET, /api/get-orders, *, *
p, *, *, GET, /api/get-user-orders, *, *

107
certificate/account.go Normal file
View File

@@ -0,0 +1,107 @@
// Copyright 2021 The casbin 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 certificate
import (
"crypto"
"github.com/casbin/lego/v4/acme"
"github.com/casbin/lego/v4/certcrypto"
"github.com/casbin/lego/v4/lego"
"github.com/casbin/lego/v4/registration"
"github.com/casdoor/casdoor/proxy"
)
type Account struct {
Email string
Registration *registration.Resource
Key crypto.PrivateKey
}
/** Implementation of the registration.User interface **/
// GetEmail returns the email address for the account.
func (a *Account) GetEmail() string {
return a.Email
}
// GetPrivateKey returns the private RSA account key.
func (a *Account) GetPrivateKey() crypto.PrivateKey {
return a.Key
}
// GetRegistration returns the server registration.
func (a *Account) GetRegistration() *registration.Resource {
return a.Registration
}
func getLegoClientAndAccount(email string, privateKey string, devMode bool) (*lego.Client, *Account, error) {
key, err := decodeEccKey(privateKey)
if err != nil {
return nil, nil, err
}
account := &Account{
Email: email,
Key: key,
}
config := lego.NewConfig(account)
if devMode {
config.CADirURL = lego.LEDirectoryStaging
} else {
config.CADirURL = lego.LEDirectoryProduction
}
config.Certificate.KeyType = certcrypto.RSA2048
config.HTTPClient = proxy.ProxyHttpClient
client, err := lego.NewClient(config)
if err != nil {
return nil, nil, err
}
return client, account, err
}
// GetAcmeClient Incoming an email ,a privatekey and a Boolean value that controls the opening of the test environment
// When this function is started for the first time, it will initialize the account-related configuration,
// After initializing the configuration, It will try to obtain an account based on the private key,
// if it fails, it will create an account based on the private key.
// This account will be used during the running of the program
func GetAcmeClient(email string, privateKey string, devMode bool) (*lego.Client, error) {
// Create a user. New accounts need an email and private key to start.
client, account, err := getLegoClientAndAccount(email, privateKey, devMode)
// try to obtain an account based on the private key
account.Registration, err = client.Registration.ResolveAccountByKey()
if err != nil {
acmeError, ok := err.(*acme.ProblemDetails)
if !ok {
return nil, err
}
if acmeError.Type != "urn:ietf:params:acme:error:accountDoesNotExist" {
return nil, acmeError
}
// Failed to get account, so create an account based on the private key.
account.Registration, err = client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
if err != nil {
return nil, err
}
}
return client, nil
}

View File

@@ -0,0 +1,47 @@
// Copyright 2021 The casbin Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build !skipCi
// +build !skipCi
package certificate
import (
"testing"
"github.com/beego/beego/v2/server/web"
"github.com/casdoor/casdoor/proxy"
"github.com/casdoor/casdoor/util"
"github.com/stretchr/testify/assert"
)
func TestGetClient(t *testing.T) {
err := web.LoadAppConfig("ini", "../conf/app.conf")
if err != nil {
panic(err)
}
proxy.InitHttpClient()
eccKey := util.ReadStringFromPath("acme_account.key")
println(eccKey)
client, err := GetAcmeClient("acme2@casbin.org", eccKey, false)
assert.Nil(t, err)
pem, key, err := ObtainCertificateAli(client, "casbin.com", accessKeyId, accessKeySecret)
assert.Nil(t, err)
println(pem)
println()
println(key)
}

20
certificate/conf.go Normal file
View File

@@ -0,0 +1,20 @@
// Copyright 2021 The casbin 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 certificate
var (
accessKeyId = ""
accessKeySecret = ""
)

151
certificate/dns.go Normal file
View File

@@ -0,0 +1,151 @@
// Copyright 2021 The casbin 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 certificate
import (
"fmt"
"time"
"github.com/casbin/lego/v4/certificate"
"github.com/casbin/lego/v4/challenge/dns01"
"github.com/casbin/lego/v4/cmd"
"github.com/casbin/lego/v4/lego"
"github.com/casbin/lego/v4/providers/dns/alidns"
"github.com/casbin/lego/v4/providers/dns/godaddy"
)
type AliConf struct {
Domains []string // The domain names for which you want to apply for a certificate
AccessKey string // Aliyun account's AccessKey, if this is not empty, Secret is required.
Secret string
RAMRole string // Use Ramrole to control aliyun account
SecurityToken string // Optional
Path string // The path to store cert file
Timeout int // Maximum waiting time for certificate application, in minutes
}
type GodaddyConf struct {
Domains []string // The domain names for which you want to apply for a certificate
APIKey string // GoDaddy account's API Key
APISecret string
Path string // The path to store cert file
Timeout int // Maximum waiting time for certificate application, in minutes
}
// getCert Verify domain ownership, then obtain a certificate, and finally store it locally.
// Need to pass in an AliConf struct, some parameters are required, other parameters can be left blank
func getAliCert(client *lego.Client, conf AliConf) (string, string, error) {
if conf.Timeout <= 0 {
conf.Timeout = 3
}
config := alidns.NewDefaultConfig()
config.PropagationTimeout = time.Duration(conf.Timeout) * time.Minute
config.APIKey = conf.AccessKey
config.SecretKey = conf.Secret
config.RAMRole = conf.RAMRole
config.SecurityToken = conf.SecurityToken
dnsProvider, err := alidns.NewDNSProvider(config)
if err != nil {
return "", "", err
}
// Choose a local DNS service provider to increase the authentication speed
servers := []string{"223.5.5.5:53"}
err = client.Challenge.SetDNS01Provider(dnsProvider, dns01.CondOption(len(servers) > 0, dns01.AddRecursiveNameservers(dns01.ParseNameservers(servers))), dns01.DisableCompletePropagationRequirement())
if err != nil {
return "", "", err
}
// Obtain the certificate
request := certificate.ObtainRequest{
Domains: conf.Domains,
Bundle: true,
}
cert, err := client.Certificate.Obtain(request)
if err != nil {
return "", "", err
}
return string(cert.Certificate), string(cert.PrivateKey), nil
}
func getGoDaddyCert(client *lego.Client, conf GodaddyConf) (string, string, error) {
if conf.Timeout <= 0 {
conf.Timeout = 3
}
config := godaddy.NewDefaultConfig()
config.PropagationTimeout = time.Duration(conf.Timeout) * time.Minute
config.PollingInterval = time.Duration(conf.Timeout) * time.Minute / 9
config.APIKey = conf.APIKey
config.APISecret = conf.APISecret
dnsProvider, err := godaddy.NewDNSProvider(config)
if err != nil {
return "", "", err
}
// Choose a local DNS service provider to increase the authentication speed
servers := []string{"223.5.5.5:53"}
err = client.Challenge.SetDNS01Provider(dnsProvider, dns01.CondOption(len(servers) > 0, dns01.AddRecursiveNameservers(dns01.ParseNameservers(servers))), dns01.DisableCompletePropagationRequirement())
if err != nil {
return "", "", err
}
// Obtain the certificate
request := certificate.ObtainRequest{
Domains: conf.Domains,
Bundle: true,
}
cert, err := client.Certificate.Obtain(request)
if err != nil {
return "", "", err
}
return string(cert.Certificate), string(cert.PrivateKey), nil
}
func ObtainCertificateAli(client *lego.Client, domain string, accessKey string, accessSecret string) (string, string, error) {
conf := AliConf{
Domains: []string{fmt.Sprintf("*.%s", domain), domain},
AccessKey: accessKey,
Secret: accessSecret,
RAMRole: "",
SecurityToken: "",
Path: "",
Timeout: 3,
}
return getAliCert(client, conf)
}
func ObtainCertificateGoDaddy(client *lego.Client, domain string, accessKey string, accessSecret string) (string, string, error) {
conf := GodaddyConf{
Domains: []string{fmt.Sprintf("*.%s", domain), domain},
APIKey: accessKey,
APISecret: accessSecret,
Path: "",
Timeout: 3,
}
return getGoDaddyCert(client, conf)
}
func SaveCert(path, filename string, cert *certificate.Resource) {
// Store the certificate file locally
certsStorage := cmd.NewCertificatesStorageLib(path, filename, true)
certsStorage.CreateRootFolder()
certsStorage.SaveResource(cert)
}

55
certificate/ecc.go Normal file
View File

@@ -0,0 +1,55 @@
// Copyright 2021 The casbin 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 certificate
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"encoding/pem"
"fmt"
)
// generateEccKey generates a public and private key pair.(NIST P-256)
func generateEccKey() (*ecdsa.PrivateKey, error) {
return ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
}
// encodeEccKey Return the input private key object as string type private key
func encodeEccKey(privateKey *ecdsa.PrivateKey) (string, error) {
x509Encoded, err := x509.MarshalECPrivateKey(privateKey)
if err != nil {
return "", err
}
pemEncoded := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: x509Encoded})
return string(pemEncoded), nil
}
// decodeEccKey Return the entered private key string as a private key object that can be used
func decodeEccKey(pemEncoded string) (*ecdsa.PrivateKey, error) {
block, _ := pem.Decode([]byte(pemEncoded))
if block == nil {
return nil, fmt.Errorf("invalid PEM-encoded EC private key")
}
x509Encoded := block.Bytes
privateKey, err := x509.ParseECPrivateKey(x509Encoded)
if err != nil {
return nil, err
}
return privateKey, nil
}

34
certificate/ecc_test.go Normal file
View File

@@ -0,0 +1,34 @@
// Copyright 2021 The casbin Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build !skipCi
// +build !skipCi
package certificate
import (
"testing"
"github.com/casdoor/casdoor/util"
"github.com/stretchr/testify/assert"
)
func TestGenerateEccKey(t *testing.T) {
eccKey, err := generateEccKey()
assert.Nil(t, err)
eccKeyStr, err := encodeEccKey(eccKey)
assert.Nil(t, err)
println(eccKeyStr)
util.WriteStringToPath(eccKeyStr, "acme_account.key")
}

View File

@@ -15,6 +15,7 @@
package conf
import (
_ "embed"
"fmt"
"os"
"runtime"
@@ -24,6 +25,9 @@ import (
"github.com/beego/beego/v2/server/web"
)
//go:embed waf.conf
var WafConf string
func init() {
// this array contains the beego configuration items that may be modified via env
presetConfigItems := []string{"httpport", "appname"}

246
conf/waf.conf Normal file
View File

@@ -0,0 +1,246 @@
# -- Rule engine initialization ----------------------------------------------
# Enable Coraza, attaching it to every transaction. Use detection
# only to start with, because that minimises the chances of post-installation
# disruption.
#
SecRuleEngine DetectionOnly
# -- Request body handling ---------------------------------------------------
# Allow Coraza to access request bodies. If you don't, Coraza
# won't be able to see any POST parameters, which opens a large security
# hole for attackers to exploit.
#
SecRequestBodyAccess On
# Enable XML request body parser.
# Initiate XML Processor in case of xml content-type
#
SecRule REQUEST_HEADERS:Content-Type "^(?:application(?:/soap\+|/)|text/)xml" \
"id:'200000',phase:1,t:none,t:lowercase,pass,nolog,ctl:requestBodyProcessor=XML"
# Enable JSON request body parser.
# Initiate JSON Processor in case of JSON content-type; change accordingly
# if your application does not use 'application/json'
#
SecRule REQUEST_HEADERS:Content-Type "^application/json" \
"id:'200001',phase:1,t:none,t:lowercase,pass,nolog,ctl:requestBodyProcessor=JSON"
# Sample rule to enable JSON request body parser for more subtypes.
# Uncomment or adapt this rule if you want to engage the JSON
# Processor for "+json" subtypes
#
#SecRule REQUEST_HEADERS:Content-Type "^application/[a-z0-9.-]+[+]json" \
# "id:'200006',phase:1,t:none,t:lowercase,pass,nolog,ctl:requestBodyProcessor=JSON"
# Maximum request body size we will accept for buffering. If you support
# file uploads then the value given on the first line has to be as large
# as the largest file you are willing to accept. The second value refers
# to the size of data, with files excluded. You want to keep that value as
# low as practical.
#
SecRequestBodyLimit 13107200
SecRequestBodyInMemoryLimit 131072
# SecRequestBodyNoFilesLimit is currently not supported by Coraza
# SecRequestBodyNoFilesLimit 131072
# What to do if the request body size is above our configured limit.
# Keep in mind that this setting will automatically be set to ProcessPartial
# when SecRuleEngine is set to DetectionOnly mode in order to minimize
# disruptions when initially deploying Coraza.
#
SecRequestBodyLimitAction Reject
# Verify that we've correctly processed the request body.
# As a rule of thumb, when failing to process a request body
# you should reject the request (when deployed in blocking mode)
# or log a high-severity alert (when deployed in detection-only mode).
#
SecRule REQBODY_ERROR "!@eq 0" \
"id:'200002', phase:2,t:none,log,deny,status:400,msg:'Failed to parse request body.',logdata:'%{reqbody_error_msg}',severity:2"
# By default be strict with what we accept in the multipart/form-data
# request body. If the rule below proves to be too strict for your
# environment consider changing it to detection-only. You are encouraged
# _not_ to remove it altogether.
#
SecRule MULTIPART_STRICT_ERROR "!@eq 0" \
"id:'200003',phase:2,t:none,log,deny,status:400, \
msg:'Multipart request body failed strict validation: \
PE %{REQBODY_PROCESSOR_ERROR}, \
BQ %{MULTIPART_BOUNDARY_QUOTED}, \
BW %{MULTIPART_BOUNDARY_WHITESPACE}, \
DB %{MULTIPART_DATA_BEFORE}, \
DA %{MULTIPART_DATA_AFTER}, \
HF %{MULTIPART_HEADER_FOLDING}, \
LF %{MULTIPART_LF_LINE}, \
SM %{MULTIPART_MISSING_SEMICOLON}, \
IQ %{MULTIPART_INVALID_QUOTING}, \
IP %{MULTIPART_INVALID_PART}, \
IH %{MULTIPART_INVALID_HEADER_FOLDING}, \
FL %{MULTIPART_FILE_LIMIT_EXCEEDED}'"
# Did we see anything that might be a boundary?
#
# Here is a short description about the Coraza Multipart parser: the
# parser returns with value 0, if all "boundary-like" line matches with
# the boundary string which given in MIME header. In any other cases it returns
# with different value, eg. 1 or 2.
#
# The RFC 1341 descript the multipart content-type and its syntax must contains
# only three mandatory lines (above the content):
# * Content-Type: multipart/mixed; boundary=BOUNDARY_STRING
# * --BOUNDARY_STRING
# * --BOUNDARY_STRING--
#
# First line indicates, that this is a multipart content, second shows that
# here starts a part of the multipart content, third shows the end of content.
#
# If there are any other lines, which starts with "--", then it should be
# another boundary id - or not.
#
# After 3.0.3, there are two kinds of types of boundary errors: strict and permissive.
#
# If multipart content contains the three necessary lines with correct order, but
# there are one or more lines with "--", then parser returns with value 2 (non-zero).
#
# If some of the necessary lines (usually the start or end) misses, or the order
# is wrong, then parser returns with value 1 (also a non-zero).
#
# You can choose, which one is what you need. The example below contains the
# 'strict' mode, which means if there are any lines with start of "--", then
# Coraza blocked the content. But the next, commented example contains
# the 'permissive' mode, then you check only if the necessary lines exists in
# correct order. Whit this, you can enable to upload PEM files (eg "----BEGIN.."),
# or other text files, which contains eg. HTTP headers.
#
# The difference is only the operator - in strict mode (first) the content blocked
# in case of any non-zero value. In permissive mode (second, commented) the
# content blocked only if the value is explicit 1. If it 0 or 2, the content will
# allowed.
#
#
# See #1747 and #1924 for further information on the possible values for
# MULTIPART_UNMATCHED_BOUNDARY.
#
SecRule MULTIPART_UNMATCHED_BOUNDARY "@eq 1" \
"id:'200004',phase:2,t:none,log,deny,msg:'Multipart parser detected a possible unmatched boundary.'"
# Some internal errors will set flags in TX and we will need to look for these.
# All of these are prefixed with "MSC_". The following flags currently exist:
#
# COR_PCRE_LIMITS_EXCEEDED: PCRE match limits were exceeded.
#
SecRule TX:/^COR_/ "!@streq 0" \
"id:'200005',phase:2,t:none,deny,msg:'Coraza internal error flagged: %{MATCHED_VAR_NAME}'"
# -- Response body handling --------------------------------------------------
# Allow Coraza to access response bodies.
# You should have this directive enabled in order to identify errors
# and data leakage issues.
#
# Do keep in mind that enabling this directive does increases both
# memory consumption and response latency.
#
SecResponseBodyAccess On
# Which response MIME types do you want to inspect? You should adjust the
# configuration below to catch documents but avoid static files
# (e.g., images and archives).
#
SecResponseBodyMimeType text/plain text/html text/xml
# Buffer response bodies of up to 512 KB in length.
SecResponseBodyLimit 524288
# What happens when we encounter a response body larger than the configured
# limit? By default, we process what we have and let the rest through.
# That's somewhat less secure, but does not break any legitimate pages.
#
SecResponseBodyLimitAction ProcessPartial
# -- Filesystem configuration ------------------------------------------------
# The location where Coraza will keep its persistent data. This default setting
# is chosen due to all systems have /tmp available however, it
# too should be updated to a place that other users can't access.
#
SecDataDir /tmp/
# -- File uploads handling configuration -------------------------------------
# The location where Coraza stores intercepted uploaded files. This
# location must be private to Coraza. You don't want other users on
# the server to access the files, do you?
#
#SecUploadDir /opt/coraza/var/upload/
# By default, only keep the files that were determined to be unusual
# in some way (by an external inspection script). For this to work you
# will also need at least one file inspection rule.
#
#SecUploadKeepFiles RelevantOnly
# Uploaded files are by default created with permissions that do not allow
# any other user to access them. You may need to relax that if you want to
# interface Coraza to an external program (e.g., an anti-virus).
#
#SecUploadFileMode 0600
# -- Debug log configuration -------------------------------------------------
# Default debug log path
# Debug levels:
# 0: No logging (least verbose)
# 1: Error
# 2: Warn
# 3: Info
# 4-8: Debug
# 9: Trace (most verbose)
# Most logging has not been implemented because it will be replaced with
# advanced rule profiling options
#SecDebugLog /opt/coraza/var/log/debug.log
#SecDebugLogLevel 3
# -- Audit log configuration -------------------------------------------------
# Log the transactions that are marked by a rule, as well as those that
# trigger a server error (determined by a 5xx or 4xx, excluding 404,
# level response status codes).
#
SecAuditEngine RelevantOnly
SecAuditLogRelevantStatus "^(?:(5|4)(0|1)[0-9])$"
# Log everything we know about a transaction.
SecAuditLogParts ABIJDEFHZ
# Use a single file for logging. This is much easier to look at, but
# assumes that you will use the audit log only occasionally.
#
SecAuditLogType Serial
# -- Miscellaneous -----------------------------------------------------------
# Use the most commonly used application/x-www-form-urlencoded parameter
# separator. There's probably only one application somewhere that uses
# something else so don't expect to change this value.
#
SecArgumentSeparator &
# Settle on version 0 (zero) cookies, as that is what most applications
# use. Using an incorrect cookie version may open your installation to
# evasion attacks (against the rules that examine named cookies).
#
SecCookieFormat 0

View File

@@ -323,6 +323,17 @@ func (c *ApiController) Signup() {
// If OAuth parameters are present, generate OAuth code and return it
if clientId != "" && responseType == ResponseTypeCode {
consentRequired, err := object.CheckConsentRequired(user, application, scope)
if err != nil {
c.ResponseError(err.Error())
return
}
if consentRequired {
c.ResponseOk(map[string]bool{"required": true})
return
}
code, err := object.GetOAuthCode(userId, clientId, "", "password", responseType, redirectUri, scope, state, nonce, codeChallenge, "", c.Ctx.Request.Host, c.GetAcceptLanguage())
if err != nil {
c.ResponseError(err.Error(), nil)
@@ -364,18 +375,11 @@ func (c *ApiController) Logout() {
c.ClearUserSession()
c.ClearTokenSession()
owner, username, err := util.GetOwnerAndNameFromIdWithError(user)
if err != nil {
c.ResponseError(err.Error())
return
}
_, err = object.DeleteSessionId(util.GetSessionId(owner, username, object.CasdoorApplication), c.Ctx.Input.CruSession.SessionID(context.Background()))
if err != nil {
c.ResponseError(err.Error())
return
}
util.LogInfo(c.Ctx, "API: [%s] logged out", user)
if err := c.deleteUserSession(user); err != nil {
c.ResponseError(err.Error())
return
}
application := c.GetSessionApplication()
if application == nil || application.Name == "app-built-in" || application.HomepageUrl == "" {
@@ -415,21 +419,13 @@ func (c *ApiController) Logout() {
c.ClearUserSession()
c.ClearTokenSession()
// TODO https://github.com/casdoor/casdoor/pull/1494#discussion_r1095675265
owner, username, err := util.GetOwnerAndNameFromIdWithError(user)
if err != nil {
if err := c.deleteUserSession(user); err != nil {
c.ResponseError(err.Error())
return
}
_, err = object.DeleteSessionId(util.GetSessionId(owner, username, object.CasdoorApplication), c.Ctx.Input.CruSession.SessionID(context.Background()))
if err != nil {
c.ResponseError(err.Error())
return
}
util.LogInfo(c.Ctx, "API: [%s] logged out", user)
if redirectUri == "" {
c.ResponseOk()
return
@@ -766,3 +762,24 @@ func (c *ApiController) GetCaptcha() {
c.ResponseOk(Captcha{Type: "none"})
}
func (c *ApiController) deleteUserSession(user string) error {
owner, username, err := util.GetOwnerAndNameFromIdWithError(user)
if err != nil {
return err
}
// Casdoor session ID derived from owner, username, and application
sessionId := util.GetSessionId(owner, username, object.CasdoorApplication)
// Explicitly get the Beego session ID from the context
beegoSessionId := c.Ctx.Input.CruSession.SessionID(context.Background())
_, err = object.DeleteSessionId(sessionId, beegoSessionId)
if err != nil {
return err
}
util.LogInfo(c.Ctx, "API: [%s] logged out", user)
return nil
}

View File

@@ -167,6 +167,19 @@ func (c *ApiController) HandleLoggedIn(application *object.Application, user *ob
c.ResponseError(c.T("auth:Challenge method should be S256"))
return
}
consentRequired, err := object.CheckConsentRequired(user, application, scope)
if err != nil {
c.ResponseError(err.Error())
return
}
if consentRequired {
resp = &Response{Status: "ok", Data: map[string]bool{"required": true}}
resp.Data3 = user.NeedUpdatePassword
return
}
code, err := object.GetOAuthCode(userId, clientId, form.Provider, form.SigninMethod, responseType, redirectUri, scope, state, nonce, codeChallenge, resource, c.Ctx.Request.Host, c.GetAcceptLanguage())
if err != nil {
c.ResponseError(err.Error(), nil)
@@ -185,10 +198,14 @@ func (c *ApiController) HandleLoggedIn(application *object.Application, user *ob
} else {
scope := c.Ctx.Input.Query("scope")
nonce := c.Ctx.Input.Query("nonce")
token, _ := object.GetTokenByUser(application, user, scope, nonce, c.Ctx.Request.Host)
resp = tokenToResponse(token)
if !object.IsScopeValid(scope, application) {
resp = &Response{Status: "error", Msg: "error: invalid_scope", Data: ""}
} else {
token, _ := object.GetTokenByUser(application, user, scope, nonce, c.Ctx.Request.Host)
resp = tokenToResponse(token)
resp.Data3 = user.NeedUpdatePassword
resp.Data3 = user.NeedUpdatePassword
}
}
} else if form.Type == ResponseTypeDevice {
authCache, ok := object.DeviceAuthMap.LoadAndDelete(form.UserCode)
@@ -739,7 +756,11 @@ func (c *ApiController) Login() {
}
} else if provider.Category == "OAuth" || provider.Category == "Web3" {
// OAuth
idpInfo := object.FromProviderToIdpInfo(c.Ctx, provider)
idpInfo, err := object.FromProviderToIdpInfo(c.Ctx, provider)
if err != nil {
c.ResponseError(err.Error())
return
}
idpInfo.CodeVerifier = authForm.CodeVerifier
var idProvider idp.IdProvider
idProvider, err = idp.GetIdProvider(idpInfo, authForm.RedirectUri)
@@ -950,11 +971,13 @@ func (c *ApiController) Login() {
RegisterSource: fmt.Sprintf("%s/%s", application.Organization, application.Name),
}
// Set group from invitation code if available, otherwise use provider's signup group
// Set group from invitation code if available, otherwise use provider's signup group or application's default group
if invitation != nil && invitation.SignupGroup != "" {
user.Groups = []string{invitation.SignupGroup}
} else if providerItem.SignupGroup != "" {
user.Groups = []string{providerItem.SignupGroup}
} else if application.DefaultGroup != "" {
user.Groups = []string{application.DefaultGroup}
}
var affected bool

View File

@@ -183,3 +183,40 @@ func (c *ApiController) DeleteCert() {
c.Data["json"] = wrapActionResponse(object.DeleteCert(&cert))
c.ServeJSON()
}
// UpdateCertDomainExpire
// @Title UpdateCertDomainExpire
// @Tag Cert API
// @Description update cert domain expire time
// @Param id query string true "The ID of the cert"
// @Success 200 {object} controllers.Response The Response object
// @router /update-cert-domain-expire [post]
func (c *ApiController) UpdateCertDomainExpire() {
if _, ok := c.RequireSignedIn(); !ok {
return
}
id := c.Ctx.Input.Query("id")
cert, err := object.GetCert(id)
if err != nil {
c.ResponseError(err.Error())
return
}
domainExpireTime, err := object.GetDomainExpireTime(cert.Name)
if err != nil {
c.ResponseError(err.Error())
return
}
if domainExpireTime == "" {
c.ResponseError("Failed to determine domain expiration time for domain " + cert.Name +
". Please verify that the domain is valid, publicly resolvable, and has a retrievable expiration date, " +
"or update the domain expiration time manually.")
return
}
cert.DomainExpireTime = domainExpireTime
c.Data["json"] = wrapActionResponse(object.UpdateCert(id, cert))
c.ServeJSON()
}

226
controllers/consent.go Normal file
View File

@@ -0,0 +1,226 @@
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package controllers
import (
"encoding/json"
"github.com/casdoor/casdoor/object"
)
// RevokeConsent revokes a consent record
// @Title RevokeConsent
// @Tag Consent API
// @Description revoke a consent record
// @Param body body object.ConsentRecord true "The consent object"
// @Success 200 {object} controllers.Response The Response object
// @router /revoke-consent [post]
func (c *ApiController) RevokeConsent() {
userId := c.GetSessionUsername()
if userId == "" {
c.ResponseError(c.T("general:Please login first"))
return
}
var consent object.ConsentRecord
err := json.Unmarshal(c.Ctx.Input.RequestBody, &consent)
if err != nil {
c.ResponseError(err.Error())
return
}
// Validate that consent.Application is not empty
if consent.Application == "" {
c.ResponseError(c.T("general:Application cannot be empty"))
return
}
// Validate that GrantedScopes is not empty when scope-specific revoke is requested
if len(consent.GrantedScopes) == 0 {
c.ResponseError(c.T("general:Granted scopes cannot be empty"))
return
}
userObj, err := object.GetUser(userId)
if err != nil {
c.ResponseError(err.Error())
return
}
if userObj == nil {
c.ResponseError(c.T("general:The user doesn't exist"))
return
}
newScopes := []object.ConsentRecord{}
for _, record := range userObj.ApplicationScopes {
if record.Application != consent.Application {
// skip other applications
newScopes = append(newScopes, record)
continue
}
// revoke specified scopes
revokeSet := make(map[string]bool)
for _, s := range consent.GrantedScopes {
revokeSet[s] = true
}
remaining := []string{}
for _, s := range record.GrantedScopes {
if !revokeSet[s] {
remaining = append(remaining, s)
}
}
if len(remaining) > 0 {
// still have remaining scopes, keep the record and update
record.GrantedScopes = remaining
newScopes = append(newScopes, record)
}
// otherwise the application authorization is revoked, delete the whole record
}
userObj.ApplicationScopes = newScopes
success, err := object.UpdateUser(userObj.GetId(), userObj, nil, false)
if err != nil {
c.ResponseError(err.Error())
return
}
c.ResponseOk(success)
}
// GrantConsent grants consent for an OAuth application and returns authorization code
// @Title GrantConsent
// @Tag Consent API
// @Description grant consent for an OAuth application and get authorization code
// @Param body body object.ConsentRecord true "The consent object with OAuth parameters"
// @Success 200 {object} controllers.Response The Response object
// @router /grant-consent [post]
func (c *ApiController) GrantConsent() {
userId := c.GetSessionUsername()
if userId == "" {
c.ResponseError(c.T("general:Please login first"))
return
}
var request struct {
Application string `json:"application"`
Scopes []string `json:"grantedScopes"`
ClientId string `json:"clientId"`
Provider string `json:"provider"`
SigninMethod string `json:"signinMethod"`
ResponseType string `json:"responseType"`
RedirectUri string `json:"redirectUri"`
Scope string `json:"scope"`
State string `json:"state"`
Nonce string `json:"nonce"`
Challenge string `json:"challenge"`
Resource string `json:"resource"`
}
err := json.Unmarshal(c.Ctx.Input.RequestBody, &request)
if err != nil {
c.ResponseError(err.Error())
return
}
// Validate application by clientId
application, err := object.GetApplicationByClientId(request.ClientId)
if err != nil {
c.ResponseError(err.Error())
return
}
if application == nil {
c.ResponseError(c.T("general:Invalid client_id"))
return
}
// Verify that request.Application matches the application's actual ID
if request.Application != application.GetId() {
c.ResponseError(c.T("general:Invalid application"))
return
}
// Update user's ApplicationScopes
userObj, err := object.GetUser(userId)
if err != nil {
c.ResponseError(err.Error())
return
}
if userObj == nil {
c.ResponseError(c.T("general:User not found"))
return
}
appId := application.GetId()
found := false
// Insert new scope into existing applicationScopes
for i, record := range userObj.ApplicationScopes {
if record.Application == appId {
existing := make(map[string]bool)
for _, s := range userObj.ApplicationScopes[i].GrantedScopes {
existing[s] = true
}
for _, s := range request.Scopes {
if !existing[s] {
userObj.ApplicationScopes[i].GrantedScopes = append(userObj.ApplicationScopes[i].GrantedScopes, s)
existing[s] = true
}
}
found = true
break
}
}
// create a new applicationScopes if not found
if !found {
uniqueScopes := []string{}
existing := make(map[string]bool)
for _, s := range request.Scopes {
if !existing[s] {
uniqueScopes = append(uniqueScopes, s)
existing[s] = true
}
}
userObj.ApplicationScopes = append(userObj.ApplicationScopes, object.ConsentRecord{
Application: appId,
GrantedScopes: uniqueScopes,
})
}
_, err = object.UpdateUser(userObj.GetId(), userObj, []string{"application_scopes"}, false)
if err != nil {
c.ResponseError(err.Error())
return
}
// Now get the OAuth code
code, err := object.GetOAuthCode(
userId,
request.ClientId,
request.Provider,
request.SigninMethod,
request.ResponseType,
request.RedirectUri,
request.Scope,
request.State,
request.Nonce,
request.Challenge,
request.Resource,
c.Ctx.Request.Host,
c.GetAcceptLanguage(),
)
if err != nil {
c.ResponseError(err.Error())
return
}
c.ResponseOk(code.Code)
}

193
controllers/rule.go Normal file
View File

@@ -0,0 +1,193 @@
// Copyright 2023 The casbin Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package controllers
import (
"encoding/json"
"errors"
"net"
"strings"
"github.com/beego/beego/v2/server/web/pagination"
"github.com/casdoor/casdoor/object"
"github.com/casdoor/casdoor/util"
"github.com/hsluoyz/modsecurity-go/seclang/parser"
)
func (c *ApiController) GetRules() {
owner := c.Ctx.Input.Query("owner")
if owner == "admin" {
owner = ""
}
limit := c.Ctx.Input.Query("pageSize")
page := c.Ctx.Input.Query("p")
field := c.Ctx.Input.Query("field")
value := c.Ctx.Input.Query("value")
sortField := c.Ctx.Input.Query("sortField")
sortOrder := c.Ctx.Input.Query("sortOrder")
if limit == "" || page == "" {
rules, err := object.GetRules(owner)
if err != nil {
c.ResponseError(err.Error())
return
}
c.ResponseOk(rules)
} else {
limit := util.ParseInt(limit)
count, err := object.GetRuleCount(owner, field, value)
if err != nil {
c.ResponseError(err.Error())
return
}
paginator := pagination.SetPaginator(c.Ctx, limit, count)
rules, err := object.GetPaginationRules(owner, paginator.Offset(), limit, field, value, sortField, sortOrder)
if err != nil {
c.ResponseError(err.Error())
return
}
c.ResponseOk(rules, paginator.Nums())
}
}
func (c *ApiController) GetRule() {
id := c.Ctx.Input.Query("id")
rule, err := object.GetRule(id)
if err != nil {
c.ResponseError(err.Error())
return
}
c.ResponseOk(rule)
}
func (c *ApiController) AddRule() {
currentTime := util.GetCurrentTime()
rule := object.Rule{
CreatedTime: currentTime,
UpdatedTime: currentTime,
}
err := json.Unmarshal(c.Ctx.Input.RequestBody, &rule)
if err != nil {
c.ResponseError(err.Error())
return
}
err = checkExpressions(rule.Expressions, rule.Type)
if err != nil {
c.ResponseError(err.Error())
return
}
c.Data["json"] = wrapActionResponse(object.AddRule(&rule))
c.ServeJSON()
}
func (c *ApiController) UpdateRule() {
var rule object.Rule
err := json.Unmarshal(c.Ctx.Input.RequestBody, &rule)
if err != nil {
c.ResponseError(err.Error())
return
}
err = checkExpressions(rule.Expressions, rule.Type)
if err != nil {
c.ResponseError(err.Error())
return
}
id := c.Ctx.Input.Query("id")
c.Data["json"] = wrapActionResponse(object.UpdateRule(id, &rule))
c.ServeJSON()
}
func (c *ApiController) DeleteRule() {
var rule object.Rule
err := json.Unmarshal(c.Ctx.Input.RequestBody, &rule)
if err != nil {
c.ResponseError(err.Error())
return
}
c.Data["json"] = wrapActionResponse(object.DeleteRule(&rule))
c.ServeJSON()
}
func checkExpressions(expressions []*object.Expression, ruleType string) error {
values := make([]string, len(expressions))
for i, expression := range expressions {
values[i] = expression.Value
}
switch ruleType {
case "WAF":
return checkWafRule(values)
case "IP":
return checkIpRule(values)
case "IP Rate Limiting":
return checkIpRateRule(expressions)
case "Compound":
return checkCompoundRules(values)
}
return nil
}
func checkWafRule(rules []string) error {
for _, rule := range rules {
scanner := parser.NewSecLangScannerFromString(rule)
_, err := scanner.AllDirective()
if err != nil {
return err
}
}
return nil
}
func checkIpRule(ipLists []string) error {
for _, ipList := range ipLists {
for _, ip := range strings.Split(ipList, ",") {
_, _, err := net.ParseCIDR(ip)
if net.ParseIP(ip) == nil && err != nil {
return errors.New("Invalid IP address: " + ip)
}
}
}
return nil
}
func checkIpRateRule(expressions []*object.Expression) error {
if len(expressions) != 1 {
return errors.New("IP Rate Limiting rule must have exactly one expression")
}
expression := expressions[0]
_, err := util.ParseIntWithError(expression.Operator)
if err != nil {
return err
}
_, err = util.ParseIntWithError(expression.Value)
if err != nil {
return err
}
return nil
}
func checkCompoundRules(rules []string) error {
_, err := object.GetRulesByRuleIds(rules)
if err != nil {
return err
}
return nil
}

123
controllers/site.go Normal file
View File

@@ -0,0 +1,123 @@
// Copyright 2023 The casbin Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package controllers
import (
"encoding/json"
"github.com/beego/beego/v2/server/web/pagination"
"github.com/casdoor/casdoor/object"
"github.com/casdoor/casdoor/util"
)
func (c *ApiController) GetGlobalSites() {
sites, err := object.GetGlobalSites()
if err != nil {
c.ResponseError(err.Error())
return
}
c.ResponseOk(object.GetMaskedSites(sites, util.GetHostname()))
}
func (c *ApiController) GetSites() {
owner := c.Ctx.Input.Query("owner")
if owner == "admin" {
owner = ""
}
limit := c.Ctx.Input.Query("pageSize")
page := c.Ctx.Input.Query("p")
field := c.Ctx.Input.Query("field")
value := c.Ctx.Input.Query("value")
sortField := c.Ctx.Input.Query("sortField")
sortOrder := c.Ctx.Input.Query("sortOrder")
if limit == "" || page == "" {
sites, err := object.GetSites(owner)
if err != nil {
c.ResponseError(err.Error())
return
}
c.ResponseOk(object.GetMaskedSites(sites, util.GetHostname()))
return
}
limitInt := util.ParseInt(limit)
count, err := object.GetSiteCount(owner, field, value)
if err != nil {
c.ResponseError(err.Error())
return
}
paginator := pagination.SetPaginator(c.Ctx, limitInt, count)
sites, err := object.GetPaginationSites(owner, paginator.Offset(), limitInt, field, value, sortField, sortOrder)
if err != nil {
c.ResponseError(err.Error())
return
}
c.ResponseOk(object.GetMaskedSites(sites, util.GetHostname()), paginator.Nums())
}
func (c *ApiController) GetSite() {
id := c.Ctx.Input.Query("id")
site, err := object.GetSite(id)
if err != nil {
c.ResponseError(err.Error())
return
}
c.ResponseOk(object.GetMaskedSite(site, util.GetHostname()))
}
func (c *ApiController) UpdateSite() {
id := c.Ctx.Input.Query("id")
var site object.Site
err := json.Unmarshal(c.Ctx.Input.RequestBody, &site)
if err != nil {
c.ResponseError(err.Error())
return
}
c.Data["json"] = wrapActionResponse(object.UpdateSite(id, &site))
c.ServeJSON()
}
func (c *ApiController) AddSite() {
var site object.Site
err := json.Unmarshal(c.Ctx.Input.RequestBody, &site)
if err != nil {
c.ResponseError(err.Error())
return
}
c.Data["json"] = wrapActionResponse(object.AddSite(&site))
c.ServeJSON()
}
func (c *ApiController) DeleteSite() {
var site object.Site
err := json.Unmarshal(c.Ctx.Input.RequestBody, &site)
if err != nil {
c.ResponseError(err.Error())
return
}
c.Data["json"] = wrapActionResponse(object.DeleteSite(&site))
c.ServeJSON()
}

View File

@@ -162,6 +162,9 @@ func (c *ApiController) DeleteToken() {
func (c *ApiController) GetOAuthToken() {
clientId := c.Ctx.Input.Query("client_id")
clientSecret := c.Ctx.Input.Query("client_secret")
assertion := c.Ctx.Input.Query("assertion")
clientAssertion := c.Ctx.Input.Query("client_assertion")
clientAssertionType := c.Ctx.Input.Query("client_assertion_type")
grantType := c.Ctx.Input.Query("grant_type")
code := c.Ctx.Input.Query("code")
verifier := c.Ctx.Input.Query("code_verifier")
@@ -193,6 +196,12 @@ func (c *ApiController) GetOAuthToken() {
if clientSecret == "" {
clientSecret = tokenRequest.ClientSecret
}
if clientAssertion == "" {
clientAssertion = tokenRequest.ClientAssertion
}
if clientAssertionType == "" {
clientAssertionType = tokenRequest.ClientAssertionType
}
if grantType == "" {
grantType = tokenRequest.GrantType
}
@@ -235,9 +244,13 @@ func (c *ApiController) GetOAuthToken() {
if resource == "" {
resource = tokenRequest.Resource
}
if assertion == "" {
assertion = tokenRequest.Assertion
}
}
}
host := c.Ctx.Request.Host
if deviceCode != "" {
deviceAuthCache, ok := object.DeviceAuthMap.Load(deviceCode)
if !ok {
@@ -278,8 +291,7 @@ func (c *ApiController) GetOAuthToken() {
username = deviceAuthCacheCast.UserName
}
host := c.Ctx.Request.Host
token, err := object.GetOAuthToken(grantType, clientId, clientSecret, code, verifier, scope, nonce, username, password, host, refreshToken, tag, avatar, c.GetAcceptLanguage(), subjectToken, subjectTokenType, audience, resource)
token, err := object.GetOAuthToken(grantType, clientId, clientSecret, code, verifier, scope, nonce, username, password, host, refreshToken, tag, avatar, c.GetAcceptLanguage(), subjectToken, subjectTokenType, assertion, clientAssertion, clientAssertionType, audience, resource)
if err != nil {
c.ResponseError(err.Error())
return
@@ -323,7 +335,12 @@ func (c *ApiController) RefreshToken() {
}
}
refreshToken2, err := object.RefreshToken(grantType, refreshToken, scope, clientId, clientSecret, host)
ok, application, clientId, _, err := c.ValidateOAuth(true)
if err != nil || !ok {
return
}
refreshToken2, err := object.RefreshToken(application, grantType, refreshToken, scope, clientId, clientSecret, host)
if err != nil {
c.ResponseError(err.Error())
return
@@ -334,14 +351,79 @@ func (c *ApiController) RefreshToken() {
c.ServeJSON()
}
func (c *ApiController) ResponseTokenError(errorMsg string) {
func (c *ApiController) ResponseTokenError(errorMsg string, errorDescription string) {
c.Data["json"] = &object.TokenError{
Error: errorMsg,
Error: errorMsg,
ErrorDescription: errorDescription,
}
c.SetTokenErrorHttpStatus()
c.ServeJSON()
}
func (c *ApiController) ValidateOAuth(ignoreValidSecret bool) (ok bool, application *object.Application, clientId, clientSecret string, err error) {
reqClientId := c.Ctx.Input.Query("client_id")
reqClientSecret := c.Ctx.Input.Query("client_secret")
clientAssertion := c.Ctx.Input.Query("client_assertion")
clientAssertionType := c.Ctx.Input.Query("client_assertion_type")
if reqClientId == "" && clientAssertionType == "" {
var tokenRequest TokenRequest
if err := json.Unmarshal(c.Ctx.Input.RequestBody, &tokenRequest); err == nil {
reqClientId = tokenRequest.ClientId
reqClientSecret = tokenRequest.ClientSecret
clientAssertion = tokenRequest.ClientAssertion
clientAssertionType = tokenRequest.ClientAssertionType
}
}
if clientAssertionType == "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" {
ok, application, err = object.ValidateClientAssertion(clientAssertion, c.Ctx.Request.Host)
if err != nil {
c.ResponseTokenError(object.InvalidClient, err.Error())
return
}
if !ok || application == nil {
c.ResponseTokenError(object.InvalidClient, "client_assertion is invalid")
return
}
clientSecret = application.ClientSecret
clientId = application.ClientId
ok = true
return
}
if reqClientId == "" && reqClientSecret == "" {
clientId, clientSecret, ok = c.Ctx.Request.BasicAuth()
if !ok {
clientId = c.Ctx.Input.Query("client_id")
clientSecret = c.Ctx.Input.Query("client_secret")
if clientId == "" || clientSecret == "" {
c.ResponseTokenError(object.InvalidRequest, "")
return
}
}
} else {
clientId = reqClientId
clientSecret = reqClientSecret
}
application, err = object.GetApplicationByClientId(clientId)
if err != nil {
c.ResponseTokenError(object.InvalidClient, err.Error())
return
}
if application == nil || (application.ClientSecret != clientSecret && !ignoreValidSecret) {
c.ResponseTokenError(object.InvalidClient, c.T("token:Invalid application or wrong clientSecret"))
return
}
ok = true
return
}
// IntrospectToken
// @Title IntrospectToken
// @Tag Login API
@@ -349,7 +431,7 @@ func (c *ApiController) ResponseTokenError(errorMsg string) {
// parameter representing an OAuth 2.0 token and returns a JSON document
// representing the meta information surrounding the
// token, including whether this token is currently active.
// This endpoint only support Basic Authorization.
// This endpoint support Basic Authorization and authorization defined in RFC 7523.
//
// @Param token formData string true "access_token's value or refresh_token's value"
// @Param token_type_hint formData string true "the token type access_token or refresh_token"
@@ -359,24 +441,9 @@ func (c *ApiController) ResponseTokenError(errorMsg string) {
// @router /login/oauth/introspect [post]
func (c *ApiController) IntrospectToken() {
tokenValue := c.Ctx.Input.Query("token")
clientId, clientSecret, ok := c.Ctx.Request.BasicAuth()
if !ok {
clientId = c.Ctx.Input.Query("client_id")
clientSecret = c.Ctx.Input.Query("client_secret")
if clientId == "" || clientSecret == "" {
c.ResponseTokenError(object.InvalidRequest)
return
}
}
application, err := object.GetApplicationByClientId(clientId)
if err != nil {
c.ResponseTokenError(err.Error())
return
}
if application == nil || application.ClientSecret != clientSecret {
c.ResponseTokenError(c.T("token:Invalid application or wrong clientSecret"))
ok, application, clientId, _, err := c.ValidateOAuth(false)
if err != nil || !ok {
return
}
@@ -390,7 +457,7 @@ func (c *ApiController) IntrospectToken() {
if tokenTypeHint != "" {
token, err = object.GetTokenByTokenValue(tokenValue, tokenTypeHint)
if err != nil {
c.ResponseTokenError(err.Error())
c.ResponseTokenError(object.InvalidRequest, err.Error())
return
}
if token == nil || token.ExpiresIn <= 0 {
@@ -467,7 +534,7 @@ func (c *ApiController) IntrospectToken() {
if tokenTypeHint == "" {
token, err = object.GetTokenByTokenValue(tokenValue, introspectionResponse.TokenType)
if err != nil {
c.ResponseTokenError(err.Error())
c.ResponseTokenError(object.InvalidRequest, err.Error())
return
}
if token == nil || token.ExpiresIn <= 0 {
@@ -479,7 +546,7 @@ func (c *ApiController) IntrospectToken() {
if token != nil {
application, err = object.GetApplication(fmt.Sprintf("%s/%s", token.Owner, token.Application))
if err != nil {
c.ResponseTokenError(err.Error())
c.ResponseTokenError(object.InvalidClient, err.Error())
return
}
if application == nil {

View File

@@ -15,20 +15,23 @@
package controllers
type TokenRequest struct {
ClientId string `json:"client_id"`
ClientSecret string `json:"client_secret"`
GrantType string `json:"grant_type"`
Code string `json:"code"`
Verifier string `json:"code_verifier"`
Scope string `json:"scope"`
Nonce string `json:"nonce"`
Username string `json:"username"`
Password string `json:"password"`
Tag string `json:"tag"`
Avatar string `json:"avatar"`
RefreshToken string `json:"refresh_token"`
SubjectToken string `json:"subject_token"`
SubjectTokenType string `json:"subject_token_type"`
Audience string `json:"audience"`
Resource string `json:"resource"` // RFC 8707 Resource Indicator
Assertion string `json:"assertion"`
ClientId string `json:"client_id"`
ClientSecret string `json:"client_secret"`
ClientAssertion string `json:"client_assertion"`
ClientAssertionType string `json:"client_assertion_type"`
GrantType string `json:"grant_type"`
Code string `json:"code"`
Verifier string `json:"code_verifier"`
Scope string `json:"scope"`
Nonce string `json:"nonce"`
Username string `json:"username"`
Password string `json:"password"`
Tag string `json:"tag"`
Avatar string `json:"avatar"`
RefreshToken string `json:"refresh_token"`
SubjectToken string `json:"subject_token"`
SubjectTokenType string `json:"subject_token_type"`
Audience string `json:"audience"`
Resource string `json:"resource"` // RFC 8707 Resource Indicator
}

View File

@@ -19,13 +19,16 @@ type EmailProvider interface {
}
func GetEmailProvider(typ string, clientId string, clientSecret string, host string, port int, sslMode string, endpoint string, method string, httpHeaders map[string]string, bodyMapping map[string]string, contentType string, enableProxy bool) EmailProvider {
if typ == "Azure ACS" {
switch typ {
case "Azure ACS":
return NewAzureACSEmailProvider(clientSecret, host)
} else if typ == "Custom HTTP Email" {
case "Custom HTTP Email":
return NewHttpEmailProvider(endpoint, method, httpHeaders, bodyMapping, contentType)
} else if typ == "SendGrid" {
case "SendGrid":
return NewSendgridEmailProvider(clientSecret, host, endpoint)
} else {
case "Resend":
return NewResendEmailProvider(clientSecret)
default:
return NewSmtpEmailProvider(clientId, clientSecret, host, port, typ, sslMode, enableProxy)
}
}

48
email/resend.go Normal file
View File

@@ -0,0 +1,48 @@
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package email
import (
"fmt"
"github.com/resend/resend-go/v3"
)
type ResendEmailProvider struct {
Client *resend.Client
}
func NewResendEmailProvider(apiKey string) *ResendEmailProvider {
client := resend.NewClient(apiKey)
client.UserAgent += " Casdoor"
return &ResendEmailProvider{Client: client}
}
func (s *ResendEmailProvider) Send(fromAddress string, fromName string, toAddresses []string, subject string, content string) error {
from := fromAddress
if fromName != "" {
from = fmt.Sprintf("%s <%s>", fromName, fromAddress)
}
params := &resend.SendEmailRequest{
From: from,
To: toAddresses,
Subject: subject,
Html: content,
}
if _, err := s.Client.Emails.Send(params); err != nil {
return err
}
return nil
}

27
go.mod
View File

@@ -22,6 +22,8 @@ require (
github.com/beego/beego/v2 v2.3.8
github.com/beevik/etree v1.1.0
github.com/casbin/casbin/v2 v2.77.2
github.com/casbin/lego/v4 v4.5.4
github.com/casdoor/casdoor-go-sdk v0.50.0
github.com/casdoor/go-sms-sender v0.25.0
github.com/casdoor/gomail/v2 v2.2.0
github.com/casdoor/ldapserver v1.2.0
@@ -29,6 +31,7 @@ require (
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
github.com/corazawaf/coraza/v3 v3.3.3
github.com/dchest/captcha v0.0.0-20200903113550-03f5f0333e1f
github.com/elimity-com/scim v0.0.0-20230426070224-941a5eac92f3
github.com/fogleman/gg v1.3.0
@@ -44,9 +47,12 @@ require (
github.com/go-webauthn/webauthn v0.10.2
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/google/uuid v1.6.0
github.com/hsluoyz/modsecurity-go v0.0.7
github.com/json-iterator/go v1.1.12
github.com/lestrrat-go/jwx v1.2.29
github.com/lib/pq v1.10.9
github.com/likexian/whois v1.15.1
github.com/likexian/whois-parser v1.24.9
github.com/lor00x/goldap v0.0.0-20180618054307-a546dffdd1a3
github.com/markbates/goth v1.82.0
github.com/microsoft/go-mssqldb v1.9.0
@@ -57,6 +63,7 @@ require (
github.com/prometheus/client_golang v1.19.0
github.com/prometheus/client_model v0.6.0
github.com/qiangmzsx/string-adapter/v2 v2.1.0
github.com/resend/resend-go/v3 v3.1.0
github.com/robfig/cron/v3 v3.0.1
github.com/russellhaering/gosaml2 v0.9.0
github.com/russellhaering/goxmldsig v1.2.0
@@ -74,6 +81,7 @@ require (
golang.org/x/net v0.41.0
golang.org/x/oauth2 v0.27.0
golang.org/x/text v0.27.0
golang.org/x/time v0.8.0
google.golang.org/api v0.215.0
layeh.com/radius v0.0.0-20231213012653-1006025d24f8
maunium.net/go/mautrix v0.22.1
@@ -122,16 +130,17 @@ require (
github.com/boombuler/barcode v1.0.1 // indirect
github.com/bwmarrin/discordgo v0.28.1 // indirect
github.com/caarlos0/go-reddit/v3 v3.0.1 // indirect
github.com/casdoor/casdoor-go-sdk v0.50.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/census-instrumentation/opencensus-proto v0.4.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/clbanning/mxj/v2 v2.7.0 // indirect
github.com/cloudflare/circl v1.6.1 // indirect
github.com/cncf/xds/go v0.0.0-20240905190251-b4127c9b8d78 // indirect
github.com/corazawaf/libinjection-go v0.2.2 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect
github.com/cschomburg/go-pushbullet v0.0.0-20171206132031-67759df45fbb // indirect
github.com/cyphar/filepath-securejoin v0.4.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/dghubble/oauth1 v0.7.3 // indirect
github.com/dghubble/sling v1.4.2 // indirect
@@ -188,37 +197,44 @@ require (
github.com/lestrrat-go/httpcc v1.0.1 // indirect
github.com/lestrrat-go/iter v1.0.2 // indirect
github.com/lestrrat-go/option v1.0.1 // indirect
github.com/likexian/gokit v0.25.13 // indirect
github.com/line/line-bot-sdk-go v7.8.0+incompatible // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/magefile/mage v1.15.1-0.20241126214340-bdc92f694516 // indirect
github.com/markbates/going v1.0.0 // indirect
github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-ieproxy v0.0.1 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/miekg/dns v1.1.57 // indirect
github.com/mileusna/viber v1.0.1 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mrjones/oauth v0.0.0-20180629183705-f4e24b6d100c // indirect
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b // indirect
github.com/petar-dambovaliev/aho-corasick v0.0.0-20240411101913-e07a1f0e8eb4 // indirect
github.com/pingcap/errors v0.11.5-0.20210425183316-da1aaba5fb63 // indirect
github.com/pingcap/log v0.0.0-20210625125904-98ed8e2eb1c7 // indirect
github.com/pingcap/tidb/parser v0.0.0-20221126021158-6b02a5d8ba7d // indirect
github.com/pjbgf/sha1cd v0.3.2 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/prometheus/common v0.48.0 // indirect
github.com/prometheus/procfs v0.12.0 // indirect
github.com/qiniu/go-sdk/v7 v7.12.1 // indirect
github.com/rainycape/memcache v0.0.0-20150622160815-1031fa0ce2f2 // indirect
github.com/redis/go-redis/v9 v9.5.5 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 // indirect
github.com/rs/zerolog v1.33.0 // indirect
github.com/russross/blackfriday/v2 v2.0.1 // indirect
github.com/scim2/filter-parser/v2 v2.2.0 // indirect
github.com/sendgrid/rest v2.6.9+incompatible // indirect
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
github.com/shiena/ansicolor v0.0.0-20200904210342-c7312218db18 // indirect
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24 // indirect
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
github.com/siddontang/go v0.0.0-20180604090527-bdc77568d726 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/skeema/knownhosts v1.3.1 // indirect
@@ -238,7 +254,9 @@ require (
github.com/tklauser/numcpus v0.10.0 // indirect
github.com/twilio/twilio-go v1.13.0 // indirect
github.com/ucloud/ucloud-sdk-go v0.22.5 // indirect
github.com/urfave/cli v1.22.5 // indirect
github.com/utahta/go-linenotify v0.5.0 // indirect
github.com/valllabh/ocsf-schema-golang v1.0.3 // indirect
github.com/volcengine/volc-sdk-golang v1.0.117 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
@@ -261,7 +279,6 @@ require (
golang.org/x/mod v0.25.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/time v0.8.0 // indirect
golang.org/x/tools v0.34.0 // indirect
google.golang.org/genproto v0.0.0-20241118233622-e639e219e697 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 // indirect
@@ -272,6 +289,7 @@ require (
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/square/go-jose.v2 v2.6.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
lukechampine.com/uint128 v1.2.0 // indirect
@@ -283,4 +301,5 @@ require (
modernc.org/opt v0.1.3 // indirect
modernc.org/strutil v1.1.3 // indirect
modernc.org/token v1.0.1 // indirect
rsc.io/binaryregexp v0.2.0 // indirect
)

58
go.sum
View File

@@ -778,6 +778,7 @@ github.com/alibabacloud-go/tea-xml v1.1.1/go.mod h1:Rq08vgCcCAjHyRi/M7xlHKUykZCE
github.com/alibabacloud-go/tea-xml v1.1.2/go.mod h1:Rq08vgCcCAjHyRi/M7xlHKUykZCEtyBy9+DPF6GgEu8=
github.com/alibabacloud-go/tea-xml v1.1.3 h1:7LYnm+JbOq2B+T/B0fHC4Ies4/FofC4zHzYtqw7dgt0=
github.com/alibabacloud-go/tea-xml v1.1.3/go.mod h1:Rq08vgCcCAjHyRi/M7xlHKUykZCEtyBy9+DPF6GgEu8=
github.com/aliyun/alibaba-cloud-sdk-go v1.61.1183/go.mod h1:pUKYbK5JQ+1Dfxk80P0qxGqe5dkxDoabbZS7zOcouyA=
github.com/aliyun/alibaba-cloud-sdk-go v1.63.107 h1:qagvUyrgOnBIlVRQWOyCZGVKUIYbMBdGdJ104vBpRFU=
github.com/aliyun/alibaba-cloud-sdk-go v1.63.107/go.mod h1:SOSDHfe1kX91v3W5QiBsWSLqeLxImobbMX1mxrFHsVQ=
github.com/aliyun/aliyun-oss-go-sdk v2.2.2+incompatible h1:9gWa46nstkJ9miBReJcN8Gq34cBFbzSpQZVVT9N09TM=
@@ -850,6 +851,8 @@ github.com/casbin/casbin/v2 v2.28.3/go.mod h1:vByNa/Fchek0KZUgG5wEsl7iFsiviAYKRt
github.com/casbin/casbin/v2 v2.37.0/go.mod h1:vByNa/Fchek0KZUgG5wEsl7iFsiviAYKRtgrQfcJqHg=
github.com/casbin/casbin/v2 v2.77.2 h1:yQinn/w9x8AswiwqwtrXz93VU48R1aYTXdHEx4RI3jM=
github.com/casbin/casbin/v2 v2.77.2/go.mod h1:mzGx0hYW9/ksOSpw3wNjk3NRAroq5VMFYUQ6G43iGPk=
github.com/casbin/lego/v4 v4.5.4 h1:WdVEj1A5KmKZheNuFNLF/5+UUkpXLt9mEOrLX3E81Vo=
github.com/casbin/lego/v4 v4.5.4/go.mod h1:JjTyJgN5pyrDPcg3+aAM1NtFQIXl8zDgsoSS1TnVpJ8=
github.com/casdoor/casdoor-go-sdk v0.50.0 h1:bUYbz/MzJuWfLKJbJM0+U0YpYewAur+THp5TKnufWZM=
github.com/casdoor/casdoor-go-sdk v0.50.0/go.mod h1:cMnkCQJgMYpgAlgEx8reSt1AVaDIQLcJ1zk5pzBaz+4=
github.com/casdoor/go-sms-sender v0.25.0 h1:eF4cOCSbjVg7+0uLlJQnna/FQ0BWW+Fp/x4cXhzQu1Y=
@@ -907,10 +910,18 @@ github.com/cncf/xds/go v0.0.0-20230105202645-06c439db220b/go.mod h1:eXthEFrGJvWH
github.com/cncf/xds/go v0.0.0-20230310173818-32f1caf87195/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20240905190251-b4127c9b8d78 h1:QVw89YDxXxEe+l8gU8ETbOasdwEV+avkR75ZzsVV9WI=
github.com/cncf/xds/go v0.0.0-20240905190251-b4127c9b8d78/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8=
github.com/corazawaf/coraza-coreruleset v0.0.0-20240226094324-415b1017abdc h1:OlJhrgI3I+FLUCTI3JJW8MoqyM78WbqJjecqMnqG+wc=
github.com/corazawaf/coraza-coreruleset v0.0.0-20240226094324-415b1017abdc/go.mod h1:7rsocqNDkTCira5T0M7buoKR2ehh7YZiPkzxRuAgvVU=
github.com/corazawaf/coraza/v3 v3.3.3 h1:kqjStHAgWqwP5dh7n0vhTOF0a3t+VikNS/EaMiG0Fhk=
github.com/corazawaf/coraza/v3 v3.3.3/go.mod h1:xSaXWOhFMSbrV8qOOfBKAyw3aOqfwaSaOy5BgSF8XlA=
github.com/corazawaf/libinjection-go v0.2.2 h1:Chzodvb6+NXh6wew5/yhD0Ggioif9ACrQGR4qjTCs1g=
github.com/corazawaf/libinjection-go v0.2.2/go.mod h1:OP4TM7xdJ2skyXqNX1AN1wN5nNZEmJNuWbNPOItn7aw=
github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng4PGlyM=
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/cschomburg/go-pushbullet v0.0.0-20171206132031-67759df45fbb h1:7X9nrm+LNWdxzQOiCjy0G51rNUxbH35IDHCjAMvogyM=
github.com/cschomburg/go-pushbullet v0.0.0-20171206132031-67759df45fbb/go.mod h1:RfQ9wji3fjcSEsQ+uFCtIh3+BXgcZum8Kt3JxvzYzlk=
@@ -920,8 +931,9 @@ github.com/cznic/mathutil v0.0.0-20181122101859-297441e03548/go.mod h1:e6NPNENfs
github.com/cznic/sortutil v0.0.0-20181122101858-f5f958428db8/go.mod h1:q2w6Bg5jeox1B+QkJ6Wp/+Vn0G/bo3f1uY7Fn3vivIQ=
github.com/cznic/strutil v0.0.0-20171016134553-529a34b1c186/go.mod h1:AHHPPPXTw0h6pVabbcbyGRK1DckRn7r/STdZEeIDzZc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dchest/captcha v0.0.0-20200903113550-03f5f0333e1f h1:q/DpyjJjZs94bziQ7YkBmIlpqbVP7yw179rnzoNVX1M=
github.com/dchest/captcha v0.0.0-20200903113550-03f5f0333e1f/go.mod h1:QGrK8vMWWHQYQ3QU9bw9Y9OPNfxccGzfb41qjvVeXtY=
github.com/deckarep/golang-set v1.7.1/go.mod h1:93vsz/8Wt4joVM7c2AVqh+YRMiUSc14yDtF28KmMOgQ=
@@ -990,6 +1002,8 @@ github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzP
github.com/form3tech-oss/jwt-go v3.2.2+incompatible h1:TcekIExNqud5crz4xD2pavyTgWiPvpYe4Xau31I0PRk=
github.com/form3tech-oss/jwt-go v3.2.2+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k=
github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
github.com/foxcpp/go-mockdns v1.1.0 h1:jI0rD8M0wuYAxL7r/ynTrCQQq0BVqfB99Vgk7DlmewI=
github.com/foxcpp/go-mockdns v1.1.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk=
github.com/franela/goblin v0.0.0-20210519012713-85d372ac71e2/go.mod h1:VzmDKDJVZI3aJmnRI9VjAn9nJ8qPPsN1fqzr9dqInIo=
github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8/go.mod h1:ZhphrRTfi2rbfLwlschooIH4+wKKDR4Pdxhh+TRoA20=
github.com/frankban/quicktest v1.11.3/go.mod h1:wRf/ReqHper53s+kmmSZizM8NamnL3IM0I9ntUbOk+k=
@@ -1291,6 +1305,8 @@ github.com/hashicorp/mdns v1.0.1/go.mod h1:4gW7WsVCke5TE7EPeYliwHlRUyBtfCwuFwuMg
github.com/hashicorp/memberlist v0.2.2/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE=
github.com/hashicorp/serf v0.9.5/go.mod h1:UWDWwZeL5cuWDJdl0C6wrvrUwEqtQ4ZKBKKENpqIUyk=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/hsluoyz/modsecurity-go v0.0.7 h1:W5ChaDrm4kM/UhHxoD2zyxQ+6s5kSj6cVftDFgdFzBM=
github.com/hsluoyz/modsecurity-go v0.0.7/go.mod h1:hi81ySzwvlQFd5pip9c3uwXHDAW9ayxwLbt8ufxRkdY=
github.com/hudl/fargo v1.4.0/go.mod h1:9Ai6uvFy5fQNq6VPKtg+Ceq1+eTY4nKUlR2JElEOcDo=
github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
@@ -1300,6 +1316,8 @@ github.com/jarcoal/httpmock v0.0.0-20180424175123-9c70cfe4a1da h1:FjHUJJ7oBW4G/9
github.com/jarcoal/httpmock v0.0.0-20180424175123-9c70cfe4a1da/go.mod h1:ks+b9deReOc7jgqp+e7LuFiCBH6Rm5hL32cLcEAArb4=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/jcchavezs/mergefs v0.1.0 h1:7oteO7Ocl/fnfFMkoVLJxTveCjrsd//UB0j89xmnpec=
github.com/jcchavezs/mergefs v0.1.0/go.mod h1:eRLTrsA+vFwQZ48hj8p8gki/5v9C2bFtHH5Mnn4bcGk=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
github.com/jcmturner/gofork v1.0.0/go.mod h1:MK8+TM0La+2rjBD4jE12Kj1pCCxK7d2LK/UM3ncEo0o=
@@ -1308,6 +1326,7 @@ github.com/jcmturner/gokrb5/v8 v8.4.2/go.mod h1:sb+Xq/fTY5yktf/VxLsE3wlfPqQjp0aW
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/jinzhu/configor v1.2.1 h1:OKk9dsR8i6HPOCZR8BcMtcEImAFjIhbJFZNyn5GCZko=
github.com/jinzhu/configor v1.2.1/go.mod h1:nX89/MOmDba7ZX7GCyU/VIaQ2Ar2aizBl2d3JLF/rDc=
github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
@@ -1321,7 +1340,9 @@ github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUB
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible h1:jdpOPRN1zP63Td1hDQbZW73xKmzDvZHzVdNYxhnTMDA=
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible/go.mod h1:1c7szIrayyPPB/987hsnvNzLushdWf4o/79s3P08L8A=
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
github.com/json-iterator/go v1.1.5/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
@@ -1390,6 +1411,12 @@ github.com/lib/pq v1.7.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lib/pq v1.8.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/likexian/gokit v0.25.13 h1:p2Uw3+6fGG53CwdU2Dz0T6bOycdb2+bAFAa3ymwWVkM=
github.com/likexian/gokit v0.25.13/go.mod h1:qQhEWFBEfqLCO3/vOEo2EDKd+EycekVtUK4tex+l2H4=
github.com/likexian/whois v1.15.1 h1:6vTMI8n9s1eJdmcO4R9h1x99aQWIZZX1CD3am68gApU=
github.com/likexian/whois v1.15.1/go.mod h1:/nxmQ6YXvLz+qTxC/QFtEJNAt0zLuRxJrKiWpBJX8X0=
github.com/likexian/whois-parser v1.24.9 h1:BT6fzO3lj3F07yzVv0YXoaj+K4Ush0/cF+Yp6tvJJgk=
github.com/likexian/whois-parser v1.24.9/go.mod h1:b6STMHHDaSKbd4PzGrP50wWE5NzeBUETa/hT9gI0G9I=
github.com/line/line-bot-sdk-go v7.8.0+incompatible h1:Uf9/OxV0zCVfqyvwZPH8CrdiHXXmMRa/L91G3btQblQ=
github.com/line/line-bot-sdk-go v7.8.0+incompatible/go.mod h1:0RjLjJEAU/3GIcHkC3av6O4jInAbt25nnZVmOFUgDBg=
github.com/localtunnel/go-localtunnel v0.0.0-20170326223115-8a804488f275 h1:IZycmTpoUtQK3PD60UYBwjaCUHUP7cML494ao9/O8+Q=
@@ -1401,6 +1428,8 @@ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2
github.com/lyft/protoc-gen-star v0.6.0/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuzJYeZuUPFPNwA=
github.com/lyft/protoc-gen-star v0.6.1/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuzJYeZuUPFPNwA=
github.com/lyft/protoc-gen-star/v2 v2.0.1/go.mod h1:RcCdONR2ScXaYnQC5tUzxzlpA3WVYF7/opLeUgcQs/o=
github.com/magefile/mage v1.15.1-0.20241126214340-bdc92f694516 h1:aAO0L0ulox6m/CLRYvJff+jWXYYCKGpEm3os7dM/Z+M=
github.com/magefile/mage v1.15.1-0.20241126214340-bdc92f694516/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
github.com/markbates/going v1.0.0 h1:DQw0ZP7NbNlFGcKbcE/IVSOAFzScxRtLpd0rLMzLhq0=
github.com/markbates/going v1.0.0/go.mod h1:I6mnB4BPnEeqo85ynXIx1ZFLLbtiLHNXVgWeFO9OGOA=
github.com/markbates/goth v1.82.0 h1:8j/c34AjBSTNzO7zTsOyP5IYCQCMBTRBHAbBt/PI0bQ=
@@ -1435,6 +1464,8 @@ github.com/microsoft/go-mssqldb v1.9.0/go.mod h1:GBbW9ASTiDC+mpgWDGKdm3FnFLTUsLY
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso=
github.com/miekg/dns v1.1.43/go.mod h1:+evo5L0630/F6ca/Z9+GAqzhjGyn8/c+TBaOyfEl0V4=
github.com/miekg/dns v1.1.57 h1:Jzi7ApEIzwEPLHWRcafCN9LZSBbqQpxjt/wpgvg7wcM=
github.com/miekg/dns v1.1.57/go.mod h1:uqRjCRUuEAA6qsOiJvDd+CFo/vW+y5WR6SNmHE55hZk=
github.com/mileusna/viber v1.0.1 h1:gWB6/lKoWYVxkH0Jb8jRnGIRZ/9DEM7RBZRJHRfdYWs=
github.com/mileusna/viber v1.0.1/go.mod h1:Pxu/iPMnYjnHgu+bEp3SiKWHWmlf/kDp/yOX8XUdYrQ=
github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY=
@@ -1497,6 +1528,8 @@ github.com/openzipkin/zipkin-go v0.2.5/go.mod h1:KpXfKdgRDnnhsxw4pNIH9Md5lyFqKUa
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/performancecopilot/speed/v4 v4.0.0/go.mod h1:qxrSyuDGrTOWfV+uKRFhfxw6h/4HXRGUiZiufxo49BM=
github.com/petar-dambovaliev/aho-corasick v0.0.0-20240411101913-e07a1f0e8eb4 h1:1Kw2vDBXmjop+LclnzCb/fFy+sgb3gYARwfmoUcQe6o=
github.com/petar-dambovaliev/aho-corasick v0.0.0-20240411101913-e07a1f0e8eb4/go.mod h1:EHPiTAKtiFmrMldLUNswFwfZ2eJIYBHktdaUTZxYWRw=
github.com/phpdave11/gofpdf v1.4.2/go.mod h1:zpO6xFn9yxo3YLyMvW8HcKWVdbNqgIfOOp2dXMnm1mY=
github.com/phpdave11/gofpdi v1.0.12/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
github.com/phpdave11/gofpdi v1.0.13/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
@@ -1527,8 +1560,9 @@ github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZ
github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/polarsource/polar-go v0.12.0 h1:um+6ftOPUMg2TQq9Kv/6fKGBOAl7dOc2YiDdx4Bb0y8=
github.com/polarsource/polar-go v0.12.0/go.mod h1:FB11Q4m2n3wIk6l/POOkz0MVOUx1o0Yt4Y97MnQfe0c=
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
@@ -1572,12 +1606,16 @@ github.com/qiniu/dyn v1.3.0/go.mod h1:E8oERcm8TtwJiZvkQPbcAh0RL8jO1G0VXJMW3FAWdk
github.com/qiniu/go-sdk/v7 v7.12.1 h1:FZG5dhs2MZBV/mHVhmHnsgsQ+j1gSE0RqIoA2WwEDwY=
github.com/qiniu/go-sdk/v7 v7.12.1/go.mod h1:btsaOc8CA3hdVloULfFdDgDc+g4f3TDZEFsDY0BLE+w=
github.com/qiniu/x v1.10.5/go.mod h1:03Ni9tj+N2h2aKnAz+6N0Xfl8FwMEDRC2PAlxekASDs=
github.com/rainycape/memcache v0.0.0-20150622160815-1031fa0ce2f2 h1:dq90+d51/hQRaHEqRAsQ1rE/pC1GUS4sc2rCbbFsAIY=
github.com/rainycape/memcache v0.0.0-20150622160815-1031fa0ce2f2/go.mod h1:7tZKcyumwBO6qip7RNQ5r77yrssm9bfCowcLEBcU5IA=
github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/redis/go-redis/v9 v9.5.5 h1:51VEyMF8eOO+NUHFm8fpg+IOc1xFuFOhxs3R+kPu1FM=
github.com/redis/go-redis/v9 v9.5.5/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 h1:OdAsTTz6OkFY5QxjkYwrChwuRruF69c169dPK26NUlk=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/resend/resend-go/v3 v3.1.0 h1:bJpU5gYCDcczLdhCo37oy9mOmdtSVlOzM6IfWX9zhMw=
github.com/resend/resend-go/v3 v3.1.0/go.mod h1:iI7VA0NoGjWvsNii5iNC5Dy0llsI3HncXPejhniYzwE=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
@@ -1595,6 +1633,7 @@ github.com/russellhaering/gosaml2 v0.9.0 h1:CNMnH42z/GirrKjdmNrSS6bAAs47F9bPdl4P
github.com/russellhaering/gosaml2 v0.9.0/go.mod h1:byViER/1YPUa0Puj9ROZblpoq2jsE7h/CJmitzX0geU=
github.com/russellhaering/goxmldsig v1.2.0 h1:Y6GTTc9Un5hCxSzVz4UIWQ/zuVwDvzJk80guqzwx6Vg=
github.com/russellhaering/goxmldsig v1.2.0/go.mod h1:gM4MDENBQf7M+V824SGfyIUVFWydB7n0KkEubVJl+Tw=
github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w=
github.com/ruudk/golang-pdf417 v0.0.0-20201230142125-a7e3863a1245/go.mod h1:pQAZKsJ8yyVxGRWYNEm9oFB8ieLgKFnamEyDmSA0BRk=
@@ -1616,6 +1655,7 @@ github.com/shirou/gopsutil/v4 v4.25.9 h1:JImNpf6gCVhKgZhtaAHJ0serfFGtlfIlSC08eaK
github.com/shirou/gopsutil/v4 v4.25.9/go.mod h1:gxIxoC+7nQRwUl/xNhutXlD8lq+jxTgpIkEf3rADHL8=
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24 h1:pntxY8Ary0t43dCZ5dqY4YTJCObLY1kIXl0uzMv+7DE=
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/siddontang/go v0.0.0-20180604090527-bdc77568d726 h1:xT+JlYxNGqyT+XcU8iUrN18JYed2TvG9yN5ULG2jATM=
github.com/siddontang/go v0.0.0-20180604090527-bdc77568d726/go.mod h1:3yhqj7WBBfRhbBlzyOC3gUxftwsU0u8gqevxwIHQpMw=
@@ -1636,7 +1676,9 @@ github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQ
github.com/slack-go/slack v0.15.0 h1:LE2lj2y9vqqiOf+qIIy0GvEoxgF1N5yLGZffmEZykt0=
github.com/slack-go/slack v0.15.0/go.mod h1:hlGi5oXA+Gt+yWTPP0plCdRKmjsDxecdHxYQdlMQKOw=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/assertions v1.0.1/go.mod h1:kHHU4qYBaI3q23Pp3VPrmWhuIUrLW/7eUrw0BU5VaoM=
github.com/smartystreets/assertions v1.1.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo=
github.com/smartystreets/goconvey v0.0.0-20190330032615-68dc04aab96a/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY=
github.com/sony/sonyflake v1.0.0 h1:MpU6Ro7tfXwgn2l5eluf9xQvQJDROTBImNCfRXn/YeM=
@@ -1653,6 +1695,7 @@ github.com/streadway/handy v0.0.0-20200128134331-0f66f006fb2e/go.mod h1:qNTQ5P5J
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/objx v0.3.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
@@ -1696,6 +1739,8 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/timtadh/data-structures v0.5.3/go.mod h1:9R4XODhJ8JdWFEI8P/HJKqxuJctfBQw6fDibMQny2oU=
github.com/timtadh/lexmachine v0.2.2/go.mod h1:GBJvD5OAfRn/gnp92zb9KTgHLB7akKyxmVivoYCcjQI=
github.com/tjfoc/gmsm v1.3.2/go.mod h1:HaUcFuY0auTiaHB9MHFGCPx5IaLhTUd2atbCFBQXn9w=
github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho=
github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE=
@@ -1712,9 +1757,13 @@ github.com/uber/jaeger-lib v2.4.1+incompatible h1:td4jdvLcExb4cBISKIpHuGoVXh+dVK
github.com/uber/jaeger-lib v2.4.1+incompatible/go.mod h1:ComeNDZlWwrWnDv8aPp0Ba6+uUTzImX/AauajbLI56U=
github.com/ucloud/ucloud-sdk-go v0.22.5 h1:GIltVwMDUqQj4iPL/emsZAMhEYWjLTwZqpOxdkdDrM8=
github.com/ucloud/ucloud-sdk-go v0.22.5/go.mod h1:dyLmFHmUfgb4RZKYQP9IArlvQ2pxzFthfhwxRzOEPIw=
github.com/urfave/cli v1.22.5 h1:lNq9sAHXK2qfdI8W+GRItjCEkI+2oR4d+MEHy1CKXoU=
github.com/urfave/cli v1.22.5/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
github.com/utahta/go-linenotify v0.5.0 h1:E1tJaB/XhqRY/iz203FD0MaHm10DjQPOq5/Mem2A3Gs=
github.com/utahta/go-linenotify v0.5.0/go.mod h1:KsvBXil2wx+ByaCR0e+IZKTbp4pDesc7yjzRigLf6pE=
github.com/valllabh/ocsf-schema-golang v1.0.3 h1:eR8k/3jP/OOqB8LRCtdJ4U+vlgd/gk5y3KMXoodrsrw=
github.com/valllabh/ocsf-schema-golang v1.0.3/go.mod h1:sZ3as9xqm1SSK5feFWIR2CuGeGRhsM7TR1MbpBctzPk=
github.com/volcengine/volc-sdk-golang v1.0.117 h1:ykFVSwsVq9qvIoWP9jeP+VKNAUjrblAdsZl46yVWiH8=
github.com/volcengine/volc-sdk-golang v1.0.117/go.mod h1:ojXSFvj404o2UKnZR9k9LUUWIUU+9XtlRlzk2+UFc/M=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
@@ -2632,13 +2681,17 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/gcfg.v1 v1.2.3/go.mod h1:yesOnuUOFQAhST5vPY4nbZsb/huCgGGXlipJsBn0b3o=
gopkg.in/ini.v1 v1.42.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/ini.v1 v1.56.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/ini.v1 v1.66.6/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI=
gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
@@ -2745,6 +2798,7 @@ modernc.org/z v1.0.1-0.20210308123920-1f282aa71362/go.mod h1:8/SRk5C/HgiQWCgXdfp
modernc.org/z v1.0.1/go.mod h1:8/SRk5C/HgiQWCgXdfpb+1RvhORdkz5sw72d3jjtyqA=
modernc.org/z v1.5.1 h1:RTNHdsrOpeoSeOF4FbzTo8gBYByaJ5xT7NgZ9ZqRiJM=
modernc.org/z v1.5.1/go.mod h1:eWFB510QWW5Th9YGZT81s+LwvaAs3Q2yr4sP0rmLkv8=
rsc.io/binaryregexp v0.2.0 h1:HfqmD5MEmC0zvwBuF187nq9mdnXjXsSivRiXN7SmRkE=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=

View File

@@ -98,15 +98,22 @@ func Translate(language string, errorText string) string {
if langMap[language] == nil {
file, err := f.ReadFile(fmt.Sprintf("locales/%s/data.json", language))
if err != nil {
return fmt.Sprintf("Translate error: the language \"%s\" is not supported, err = %s", language, err.Error())
originalLanguage := language
language = "en"
file, err = f.ReadFile(fmt.Sprintf("locales/%s/data.json", language))
if err != nil {
return fmt.Sprintf("Translate error: the language \"%s\" is not supported, err = %s", originalLanguage, err.Error())
}
}
data := I18nData{}
err = util.JsonToStruct(string(file), &data)
if err != nil {
panic(err)
if langMap[language] == nil {
data := I18nData{}
err = util.JsonToStruct(string(file), &data)
if err != nil {
panic(err)
}
langMap[language] = data
}
langMap[language] = data
}
res := langMap[language][tokens[0]][tokens[1]]

View File

@@ -264,27 +264,31 @@ func rsaSignWithRSA256(signContent string, privateKey string) (string, error) {
// privateKey in database is a string, format it to PEM style
func formatPrivateKey(privateKey string) string {
// each line length is 64
preFmtPrivateKey := ""
for i := 0; ; {
if i+64 <= len(privateKey) {
preFmtPrivateKey = preFmtPrivateKey + privateKey[i:i+64] + "\n"
i += 64
} else {
preFmtPrivateKey = preFmtPrivateKey + privateKey[i:]
break
// Check if the key is already in PEM format
if strings.HasPrefix(privateKey, "-----BEGIN PRIVATE KEY-----") ||
strings.HasPrefix(privateKey, "-----BEGIN RSA PRIVATE KEY-----") {
// Key is already in PEM format, return as is
return privateKey
}
// Remove any whitespace from the key
privateKey = strings.ReplaceAll(privateKey, "\n", "")
privateKey = strings.ReplaceAll(privateKey, "\r", "")
privateKey = strings.ReplaceAll(privateKey, " ", "")
// Format the key with line breaks every 64 characters using strings.Builder
var builder strings.Builder
for i := 0; i < len(privateKey); i += 64 {
end := i + 64
if end > len(privateKey) {
end = len(privateKey)
}
builder.WriteString(privateKey[i:end])
if end < len(privateKey) {
builder.WriteString("\n")
}
}
privateKey = strings.Trim(preFmtPrivateKey, "\n")
// add pkcs#8 BEGIN and END
PemBegin := "-----BEGIN PRIVATE KEY-----\n"
PemEnd := "\n-----END PRIVATE KEY-----"
if !strings.HasPrefix(privateKey, PemBegin) {
privateKey = PemBegin + privateKey
}
if !strings.HasSuffix(privateKey, PemEnd) {
privateKey = privateKey + PemEnd
}
return privateKey
return "-----BEGIN PRIVATE KEY-----\n" + builder.String() + "\n-----END PRIVATE KEY-----"
}

View File

@@ -67,6 +67,8 @@
{"name": "ID", "visible": true, "viewRule": "Public", "modifyRule": "Immutable"},
{"name": "Name", "visible": true, "viewRule": "Public", "modifyRule": "Admin"},
{"name": "Display name", "visible": true, "viewRule": "Public", "modifyRule": "Self"},
{"name": "First name", "visible": true, "viewRule": "Public", "modifyRule": "Self"},
{"name": "Last name", "visible": true, "viewRule": "Public", "modifyRule": "Self"},
{"name": "Avatar", "visible": true, "viewRule": "Public", "modifyRule": "Self"},
{"name": "User type", "visible": true, "viewRule": "Public", "modifyRule": "Admin"},
{"name": "Password", "visible": true, "viewRule": "Self", "modifyRule": "Self"},
@@ -81,6 +83,7 @@
{"name": "Title", "visible": true, "viewRule": "Public", "modifyRule": "Self"},
{"name": "ID card type", "visible": true, "viewRule": "Public", "modifyRule": "Self"},
{"name": "ID card", "visible": true, "viewRule": "Public", "modifyRule": "Self"},
{"name": "ID card info", "visible": true, "viewRule": "Self", "modifyRule": "Self"},
{"name": "Real name", "visible": true, "viewRule": "Public", "modifyRule": "Self"},
{"name": "ID verification", "visible": true, "viewRule": "Self", "modifyRule": "Self"},
{"name": "Homepage", "visible": true, "viewRule": "Public", "modifyRule": "Self"},
@@ -101,6 +104,7 @@
{"name": "Signup application", "visible": true, "viewRule": "Public", "modifyRule": "Admin"},
{"name": "Register type", "visible": true, "viewRule": "Public", "modifyRule": "Admin"},
{"name": "Register source", "visible": true, "viewRule": "Public", "modifyRule": "Admin"},
{"name": "API key", "visible": true, "viewRule": "Self", "modifyRule": "Self"},
{"name": "Roles", "visible": true, "viewRule": "Public", "modifyRule": "Immutable"},
{"name": "Permissions", "visible": true, "viewRule": "Public", "modifyRule": "Immutable"},
{"name": "Groups", "visible": true, "viewRule": "Public", "modifyRule": "Admin"},
@@ -110,9 +114,14 @@
{"name": "Is forbidden", "visible": true, "viewRule": "Admin", "modifyRule": "Admin"},
{"name": "Is deleted", "visible": true, "viewRule": "Admin", "modifyRule": "Admin"},
{"name": "Multi-factor authentication", "visible": true, "viewRule": "Self", "modifyRule": "Self"},
{"name": "MFA items", "visible": true, "viewRule": "Self", "modifyRule": "Self"},
{"name": "WebAuthn credentials", "visible": true, "viewRule": "Self", "modifyRule": "Self"},
{"name": "Last change password time", "visible": true, "viewRule": "Admin", "modifyRule": "Admin"},
{"name": "Managed accounts", "visible": true, "viewRule": "Self", "modifyRule": "Self"},
{"name": "MFA accounts", "visible": true, "viewRule": "Self", "modifyRule": "Self"}
{"name": "Face ID", "visible": true, "viewRule": "Self", "modifyRule": "Self"},
{"name": "MFA accounts", "visible": true, "viewRule": "Self", "modifyRule": "Self"},
{"name": "Need update password", "visible": true, "viewRule": "Admin", "modifyRule": "Admin"},
{"name": "IP whitelist", "visible": true, "viewRule": "Admin", "modifyRule": "Admin"}
]
}
],

43
ip/ip.go Normal file
View File

@@ -0,0 +1,43 @@
// Copyright 2024 The casbin 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 ip
import (
"fmt"
"github.com/casdoor/casdoor/util"
)
func InitIpDb() {
err := Init("ip/17monipdb.dat")
if err != nil {
panic(err)
}
}
func IsAbroadIp(ip string) bool {
// If it's an intranet IP, it's not abroad
if util.IsIntranetIp(ip) {
return false
}
info, err := Find(ip)
if err != nil {
fmt.Printf("error: ip = %s, error = %s\n", ip, err.Error())
return false
}
return info.Country != "中国"
}

199
ip/ip17mon.go Normal file
View File

@@ -0,0 +1,199 @@
// Copyright 2022 The casbin 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 ip
import (
"bytes"
"encoding/binary"
"errors"
"io/ioutil"
"net"
)
const Null = "N/A"
var (
ErrInvalidIp = errors.New("invalid ip format")
std *Locator
)
// Init default locator with dataFile
func Init(dataFile string) (err error) {
if std != nil {
return
}
std, err = NewLocator(dataFile)
return
}
// Init default locator with data
func InitWithData(data []byte) {
if std != nil {
return
}
std = NewLocatorWithData(data)
return
}
// Find locationInfo by ip string
// It will return err when ipstr is not a valid format
func Find(ipstr string) (*LocationInfo, error) {
return std.Find(ipstr)
}
// Find locationInfo by uint32
func FindByUint(ip uint32) *LocationInfo {
return std.FindByUint(ip)
}
//-----------------------------------------------------------------------------
// New locator with dataFile
func NewLocator(dataFile string) (loc *Locator, err error) {
data, err := ioutil.ReadFile(dataFile)
if err != nil {
return
}
loc = NewLocatorWithData(data)
return
}
// New locator with data
func NewLocatorWithData(data []byte) (loc *Locator) {
loc = new(Locator)
loc.init(data)
return
}
type Locator struct {
textData []byte
indexData1 []uint32
indexData2 []int
indexData3 []int
index []int
}
type LocationInfo struct {
Country string
Region string
City string
Isp string
}
// Find locationInfo by ip string
// It will return err when ipstr is not a valid format
func (loc *Locator) Find(ipstr string) (info *LocationInfo, err error) {
ip := net.ParseIP(ipstr).To4()
if ip == nil || ip.To4() == nil {
err = ErrInvalidIp
return
}
info = loc.FindByUint(binary.BigEndian.Uint32([]byte(ip)))
return
}
// Find locationInfo by uint32
func (loc *Locator) FindByUint(ip uint32) (info *LocationInfo) {
end := len(loc.indexData1) - 1
if ip>>24 != 0xff {
end = loc.index[(ip>>24)+1]
}
idx := loc.findIndexOffset(ip, loc.index[ip>>24], end)
off := loc.indexData2[idx]
return newLocationInfo(loc.textData[off : off+loc.indexData3[idx]])
}
// binary search
func (loc *Locator) findIndexOffset(ip uint32, start, end int) int {
for start < end {
mid := (start + end) / 2
if ip > loc.indexData1[mid] {
start = mid + 1
} else {
end = mid
}
}
if loc.indexData1[end] >= ip {
return end
}
return start
}
func (loc *Locator) init(data []byte) {
textoff := int(binary.BigEndian.Uint32(data[:4]))
loc.textData = data[textoff-1024:]
loc.index = make([]int, 256)
for i := 0; i < 256; i++ {
off := 4 + i*4
loc.index[i] = int(binary.LittleEndian.Uint32(data[off : off+4]))
}
nidx := (textoff - 4 - 1024 - 1024) / 8
loc.indexData1 = make([]uint32, nidx)
loc.indexData2 = make([]int, nidx)
loc.indexData3 = make([]int, nidx)
for i := 0; i < nidx; i++ {
off := 4 + 1024 + i*8
loc.indexData1[i] = binary.BigEndian.Uint32(data[off : off+4])
loc.indexData2[i] = int(uint32(data[off+4]) | uint32(data[off+5])<<8 | uint32(data[off+6])<<16)
loc.indexData3[i] = int(data[off+7])
}
return
}
func newLocationInfo(str []byte) *LocationInfo {
var info *LocationInfo
fields := bytes.Split(str, []byte("\t"))
switch len(fields) {
case 4:
// free version
info = &LocationInfo{
Country: string(fields[0]),
Region: string(fields[1]),
City: string(fields[2]),
}
case 5:
// pay version
info = &LocationInfo{
Country: string(fields[0]),
Region: string(fields[1]),
City: string(fields[2]),
Isp: string(fields[4]),
}
default:
panic("unexpected ip info:" + string(str))
}
if len(info.Country) == 0 {
info.Country = Null
}
if len(info.Region) == 0 {
info.Region = Null
}
if len(info.City) == 0 {
info.City = Null
}
if len(info.Isp) == 0 {
info.Isp = Null
}
return info
}

View File

@@ -29,6 +29,7 @@ import (
"github.com/casdoor/casdoor/proxy"
"github.com/casdoor/casdoor/radius"
"github.com/casdoor/casdoor/routers"
"github.com/casdoor/casdoor/service"
"github.com/casdoor/casdoor/util"
)
@@ -73,6 +74,10 @@ func main() {
object.InitCasvisorConfig()
object.InitCleanupTokens()
object.InitSiteMap()
object.InitRuleMap()
object.StartMonitorSitesLoop()
util.SafeGoroutine(func() { object.RunSyncUsersJob() })
util.SafeGoroutine(func() { controllers.InitCLIDownloader() })
@@ -126,5 +131,9 @@ func main() {
go radius.StartRadiusServer()
go object.ClearThroughputPerSecond()
if len(object.SiteMap) != 0 {
service.Start()
}
web.Run(fmt.Sprintf(":%v", port))
}

View File

@@ -125,6 +125,7 @@ type Application struct {
ClientId string `xorm:"varchar(100)" json:"clientId"`
ClientSecret string `xorm:"varchar(100)" json:"clientSecret"`
ClientCert string `xorm:"varchar(100)" json:"clientCert"`
RedirectUris []string `xorm:"varchar(1000)" json:"redirectUris"`
ForcedRedirectOrigin string `xorm:"varchar(100)" json:"forcedRedirectOrigin"`
TokenFormat string `xorm:"varchar(100)" json:"tokenFormat"`
@@ -155,12 +156,16 @@ type Application struct {
FailedSigninFrozenTime int `json:"failedSigninFrozenTime"`
CodeResendTimeout int `json:"codeResendTimeout"`
CustomScopes []*ScopeDescription `xorm:"mediumtext" json:"customScopes"`
// Reverse proxy fields
Domain string `xorm:"varchar(100)" json:"domain"`
OtherDomains []string `xorm:"varchar(1000)" json:"otherDomains"`
UpstreamHost string `xorm:"varchar(100)" json:"upstreamHost"`
SslMode string `xorm:"varchar(100)" json:"sslMode"`
SslCert string `xorm:"varchar(100)" json:"sslCert"`
CertObj *Cert `xorm:"-"`
}
func GetApplicationCount(owner, field, value string) (int64, error) {
@@ -745,6 +750,11 @@ func UpdateApplication(id string, application *Application, isGlobalAdmin bool,
return false, err
}
err = validateCustomScopes(application.CustomScopes, lang)
if err != nil {
return false, err
}
for _, providerItem := range application.Providers {
providerItem.Provider = nil
}
@@ -800,6 +810,11 @@ func AddApplication(application *Application) (bool, error) {
return false, err
}
err = validateCustomScopes(application.CustomScopes, "en")
if err != nil {
return false, err
}
for _, providerItem := range application.Providers {
providerItem.Provider = nil
}

View File

@@ -16,9 +16,12 @@ package object
import (
"fmt"
"time"
"github.com/casdoor/casdoor/certificate"
"github.com/casdoor/casdoor/util"
"github.com/xorm-io/core"
"golang.org/x/net/publicsuffix"
)
type Cert struct {
@@ -33,6 +36,13 @@ type Cert struct {
BitSize int `json:"bitSize"`
ExpireInYears int `json:"expireInYears"`
ExpireTime string `xorm:"varchar(100)" json:"expireTime"`
DomainExpireTime string `xorm:"varchar(100)" json:"domainExpireTime"`
Provider string `xorm:"varchar(100)" json:"provider"`
Account string `xorm:"varchar(100)" json:"account"`
AccessKey string `xorm:"varchar(100)" json:"accessKey"`
AccessSecret string `xorm:"varchar(100)" json:"accessSecret"`
Certificate string `xorm:"mediumtext" json:"certificate"`
PrivateKey string `xorm:"mediumtext" json:"privateKey"`
}
@@ -224,6 +234,20 @@ func (p *Cert) populateContent() error {
return nil
}
if p.Type == "SSL" {
if p.Certificate != "" {
expireTime, err := util.GetCertExpireTime(p.Certificate)
if err != nil {
return err
}
p.ExpireTime = expireTime
} else {
p.ExpireTime = ""
}
return nil
}
if len(p.CryptoAlgorithm) < 3 {
err := fmt.Errorf("populateContent() error, unsupported crypto algorithm: %s", p.CryptoAlgorithm)
return err
@@ -258,6 +282,42 @@ func (p *Cert) populateContent() error {
return nil
}
func RenewCert(cert *Cert) (bool, error) {
useProxy := false
if cert.Provider == "GoDaddy" {
useProxy = true
}
client, err := GetAcmeClient(useProxy)
if err != nil {
return false, err
}
var certStr, privateKey string
if cert.Provider == "Aliyun" {
certStr, privateKey, err = certificate.ObtainCertificateAli(client, cert.Name, cert.AccessKey, cert.AccessSecret)
} else if cert.Provider == "GoDaddy" {
certStr, privateKey, err = certificate.ObtainCertificateGoDaddy(client, cert.Name, cert.AccessKey, cert.AccessSecret)
} else {
return false, fmt.Errorf("unknown provider: %s", cert.Provider)
}
if err != nil {
return false, err
}
expireTime, err := util.GetCertExpireTime(certStr)
if err != nil {
return false, err
}
cert.ExpireTime = expireTime
cert.Certificate = certStr
cert.PrivateKey = privateKey
return UpdateCert(cert.GetId(), cert)
}
func getCertByApplication(application *Application) (*Cert, error) {
if application.Cert != "" {
return getCertByName(application.Cert)
@@ -288,3 +348,64 @@ func certChangeTrigger(oldName string, newName string) error {
return session.Commit()
}
func getBaseDomain(domain string) (string, error) {
// abc.com -> abc.com
// abc.com.it -> abc.com.it
// subdomain.abc.io -> abc.io
// subdomain.abc.org.us -> abc.org.us
return publicsuffix.EffectiveTLDPlusOne(domain)
}
func GetCertByDomain(domain string) (*Cert, error) {
if domain == "" {
return nil, fmt.Errorf("GetCertByDomain() error: domain should not be empty")
}
cert, ok := certMap[domain]
if ok {
return cert, nil
}
baseDomain, err := getBaseDomain(domain)
if err != nil {
return nil, err
}
cert, ok = certMap[baseDomain]
if ok {
return cert, nil
}
return nil, nil
}
func getCertMap() (map[string]*Cert, error) {
certs, err := GetGlobalCerts()
if err != nil {
return nil, err
}
res := map[string]*Cert{}
for _, cert := range certs {
res[cert.Name] = cert
}
return res, nil
}
func (p *Cert) isCertNearExpire() (bool, error) {
if p.ExpireTime == "" {
return true, nil
}
expireTime, err := time.Parse(time.RFC3339, p.ExpireTime)
if err != nil {
return false, err
}
now := time.Now()
duration := expireTime.Sub(now)
res := duration <= 7*24*time.Hour
return res, nil
}

75
object/cert_whois.go Normal file
View File

@@ -0,0 +1,75 @@
// Copyright 2023 The casbin 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 (
"strings"
"time"
"github.com/casdoor/casdoor/util"
"github.com/likexian/whois"
whoisparser "github.com/likexian/whois-parser"
)
func getDomainExpireTime(domainName string) (string, error) {
domainName, err := util.GetBaseDomain(domainName)
if err != nil {
return "", err
}
server := ""
if strings.HasSuffix(domainName, ".com") || strings.HasSuffix(domainName, ".net") {
server = "whois.verisign-grs.com"
} else if strings.HasSuffix(domainName, ".org") {
server = "whois.pir.org"
} else if strings.HasSuffix(domainName, ".io") {
server = "whois.nic.io"
} else if strings.HasSuffix(domainName, ".co") {
server = "whois.nic.co"
} else if strings.HasSuffix(domainName, ".cn") {
server = "whois.cnnic.cn"
} else if strings.HasSuffix(domainName, ".run") {
server = "whois.nic.run"
} else {
server = "grs-whois.hichina.com" // com, net, cc, tv
}
client := whois.NewClient()
//if server != "whois.cnnic.cn" && server != "grs-whois.hichina.com" {
// dialer := proxy.GetProxyDialer()
// if dialer != nil {
// client.SetDialer(dialer)
// }
//}
data, err := client.Whois(domainName, server)
if err != nil {
if !strings.HasSuffix(domainName, ".run") || data == "" {
return "", err
}
}
whoisInfo, err := whoisparser.Parse(data)
if err != nil {
return "", err
}
res := whoisInfo.Domain.ExpirationDateInTime.Local().Format(time.RFC3339)
return res, nil
}
func GetDomainExpireTime(domainName string) (string, error) {
return getDomainExpireTime(domainName)
}

View File

@@ -53,6 +53,8 @@ func getBuiltInAccountItems() []*AccountItem {
{Name: "ID", Visible: true, ViewRule: "Public", ModifyRule: "Immutable"},
{Name: "Name", Visible: true, ViewRule: "Public", ModifyRule: "Admin"},
{Name: "Display name", Visible: true, ViewRule: "Public", ModifyRule: "Self"},
{Name: "First name", Visible: true, ViewRule: "Public", ModifyRule: "Self"},
{Name: "Last name", Visible: true, ViewRule: "Public", ModifyRule: "Self"},
{Name: "Avatar", Visible: true, ViewRule: "Public", ModifyRule: "Self"},
{Name: "User type", Visible: true, ViewRule: "Public", ModifyRule: "Admin"},
{Name: "Password", Visible: true, ViewRule: "Self", ModifyRule: "Self"},
@@ -67,6 +69,7 @@ func getBuiltInAccountItems() []*AccountItem {
{Name: "Title", Visible: true, ViewRule: "Public", ModifyRule: "Self"},
{Name: "ID card type", Visible: true, ViewRule: "Public", ModifyRule: "Self"},
{Name: "ID card", Visible: true, ViewRule: "Public", ModifyRule: "Self"},
{Name: "ID card info", Visible: true, ViewRule: "Self", ModifyRule: "Self"},
{Name: "Real name", Visible: true, ViewRule: "Public", ModifyRule: "Self"},
{Name: "ID verification", Visible: true, ViewRule: "Self", ModifyRule: "Self"},
{Name: "Homepage", Visible: true, ViewRule: "Public", ModifyRule: "Self"},
@@ -87,18 +90,25 @@ func getBuiltInAccountItems() []*AccountItem {
{Name: "Signup application", Visible: true, ViewRule: "Public", ModifyRule: "Admin"},
{Name: "Register type", Visible: true, ViewRule: "Public", ModifyRule: "Admin"},
{Name: "Register source", Visible: true, ViewRule: "Public", ModifyRule: "Admin"},
{Name: "API key", Visible: true, ViewRule: "Self", ModifyRule: "Self"},
{Name: "Roles", Visible: true, ViewRule: "Public", ModifyRule: "Immutable"},
{Name: "Permissions", Visible: true, ViewRule: "Public", ModifyRule: "Immutable"},
{Name: "Groups", Visible: true, ViewRule: "Public", ModifyRule: "Admin"},
{Name: "Consents", Visible: true, ViewRule: "Self", ModifyRule: "Self"},
{Name: "3rd-party logins", Visible: true, ViewRule: "Self", ModifyRule: "Self"},
{Name: "Properties", Visible: true, ViewRule: "Admin", ModifyRule: "Admin"},
{Name: "Is admin", Visible: true, ViewRule: "Admin", ModifyRule: "Admin"},
{Name: "Is forbidden", Visible: true, ViewRule: "Admin", ModifyRule: "Admin"},
{Name: "Is deleted", Visible: true, ViewRule: "Admin", ModifyRule: "Admin"},
{Name: "Multi-factor authentication", Visible: true, ViewRule: "Self", ModifyRule: "Self"},
{Name: "MFA items", Visible: true, ViewRule: "Self", ModifyRule: "Self"},
{Name: "WebAuthn credentials", Visible: true, ViewRule: "Self", ModifyRule: "Self"},
{Name: "Last change password time", Visible: true, ViewRule: "Admin", ModifyRule: "Admin"},
{Name: "Managed accounts", Visible: true, ViewRule: "Self", ModifyRule: "Self"},
{Name: "Face ID", Visible: true, ViewRule: "Self", ModifyRule: "Self"},
{Name: "MFA accounts", Visible: true, ViewRule: "Self", ModifyRule: "Self"},
{Name: "Need update password", Visible: true, ViewRule: "Admin", ModifyRule: "Admin"},
{Name: "IP whitelist", Visible: true, ViewRule: "Admin", ModifyRule: "Admin"},
}
}

View File

@@ -46,6 +46,8 @@ type InitData struct {
Sessions []*Session `json:"sessions"`
Subscriptions []*Subscription `json:"subscriptions"`
Transactions []*Transaction `json:"transactions"`
Sites []*Site `json:"sites"`
Rules []*Rule `json:"rules"`
EnforcerPolicies map[string][][]string `json:"enforcerPolicies"`
}
@@ -142,6 +144,12 @@ func InitFromFile() {
for _, transaction := range initData.Transactions {
initDefinedTransaction(transaction)
}
for _, rule := range initData.Rules {
initDefinedRule(rule)
}
for _, site := range initData.Sites {
initDefinedSite(site)
}
}
}
@@ -178,6 +186,8 @@ func readInitDataFromFile(filePath string) (*InitData, error) {
Sessions: []*Session{},
Subscriptions: []*Subscription{},
Transactions: []*Transaction{},
Sites: []*Site{},
Rules: []*Rule{},
EnforcerPolicies: map[string][][]string{},
}
@@ -877,3 +887,51 @@ func initDefinedTransaction(transaction *Transaction) {
panic(err)
}
}
func initDefinedSite(site *Site) {
existed, err := getSite(site.Owner, site.Name)
if err != nil {
panic(err)
}
if existed != nil {
if initDataNewOnly {
return
}
affected, err := DeleteSite(site)
if err != nil {
panic(err)
}
if !affected {
panic("Fail to delete site")
}
}
site.CreatedTime = util.GetCurrentTime()
_, err = AddSite(site)
if err != nil {
panic(err)
}
}
func initDefinedRule(rule *Rule) {
existed, err := getRule(rule.Owner, rule.Name)
if err != nil {
panic(err)
}
if existed != nil {
if initDataNewOnly {
return
}
affected, err := DeleteRule(rule)
if err != nil {
panic(err)
}
if !affected {
panic("Fail to delete rule")
}
}
rule.CreatedTime = util.GetCurrentTime()
_, err = AddRule(rule)
if err != nil {
panic(err)
}
}

View File

@@ -62,6 +62,12 @@ func InitFlag() {
configPath = *configPathPtr
exportData = *exportDataPtr
exportFilePath = *exportFilePathPtr
// Load beego config from the specified config path
err := web.LoadAppConfig("ini", configPath)
if err != nil {
panic(fmt.Sprintf("failed to load config from %s: %v", configPath, err))
}
}
func ShouldExportData() bool {
@@ -453,4 +459,14 @@ func (a *Ormer) createTable() {
if err != nil {
panic(err)
}
err = a.Engine.Sync2(new(Site))
if err != nil {
panic(err)
}
err = a.Engine.Sync2(new(Rule))
if err != nil {
panic(err)
}
}

View File

@@ -33,6 +33,8 @@ type Payment struct {
// Product Info
Products []string `xorm:"varchar(1000)" json:"products"`
ProductsDisplayName string `xorm:"varchar(1000)" json:"productsDisplayName"`
ProductName string `xorm:"varchar(1000)" json:"productName"`
ProductDisplayName string `xorm:"varchar(1000)" json:"productDisplayName"`
Detail string `xorm:"varchar(255)" json:"detail"`
Currency string `xorm:"varchar(100)" json:"currency"`
Price float64 `json:"price"`

View File

@@ -564,7 +564,7 @@ func providerChangeTrigger(oldName string, newName string) error {
return session.Commit()
}
func FromProviderToIdpInfo(ctx *context.Context, provider *Provider) *idp.ProviderInfo {
func FromProviderToIdpInfo(ctx *context.Context, provider *Provider) (*idp.ProviderInfo, error) {
providerInfo := &idp.ProviderInfo{
Type: provider.Type,
SubType: provider.SubType,
@@ -588,9 +588,19 @@ func FromProviderToIdpInfo(ctx *context.Context, provider *Provider) *idp.Provid
}
} else if provider.Type == "ADFS" || provider.Type == "AzureAD" || provider.Type == "AzureADB2C" || provider.Type == "Casdoor" || provider.Type == "Okta" {
providerInfo.HostUrl = provider.Domain
} else if provider.Type == "Alipay" && provider.Cert != "" {
// For Alipay with certificate mode, load private key from certificate
cert, err := GetCert(util.GetId(provider.Owner, provider.Cert))
if err != nil {
return nil, fmt.Errorf("failed to load certificate for Alipay provider %s: %w", provider.Name, err)
}
if cert == nil {
return nil, fmt.Errorf("certificate not found for Alipay provider %s", provider.Name)
}
providerInfo.ClientSecret = cert.PrivateKey
}
return providerInfo
return providerInfo, nil
}
func GetIdvProviderFromProvider(provider *Provider) idv.IdvProvider {

139
object/rule.go Normal file
View File

@@ -0,0 +1,139 @@
// Copyright 2023 The casbin Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package object
import (
"fmt"
"github.com/casdoor/casdoor/util"
"github.com/xorm-io/core"
)
type Expression struct {
Name string `json:"name"`
Operator string `json:"operator"`
Value string `json:"value"`
}
type Rule struct {
Owner string `xorm:"varchar(100) notnull pk" json:"owner"`
Name string `xorm:"varchar(100) notnull pk" json:"name"`
CreatedTime string `xorm:"varchar(100) notnull" json:"createdTime"`
UpdatedTime string `xorm:"varchar(100) notnull" json:"updatedTime"`
Type string `xorm:"varchar(100) notnull" json:"type"`
Expressions []*Expression `xorm:"mediumtext" json:"expressions"`
Action string `xorm:"varchar(100) notnull" json:"action"`
StatusCode int `xorm:"int notnull" json:"statusCode"`
Reason string `xorm:"varchar(100) notnull" json:"reason"`
IsVerbose bool `xorm:"bool" json:"isVerbose"`
}
func GetGlobalRules() ([]*Rule, error) {
rules := []*Rule{}
err := ormer.Engine.Asc("owner").Desc("created_time").Find(&rules)
return rules, err
}
func GetRules(owner string) ([]*Rule, error) {
rules := []*Rule{}
err := ormer.Engine.Desc("updated_time").Find(&rules, &Rule{Owner: owner})
return rules, err
}
func getRule(owner string, name string) (*Rule, error) {
rule := Rule{Owner: owner, Name: name}
existed, err := ormer.Engine.Get(&rule)
if err != nil {
return nil, err
}
if existed {
return &rule, nil
} else {
return nil, nil
}
}
func GetRule(id string) (*Rule, error) {
owner, name := util.GetOwnerAndNameFromIdNoCheck(id)
return getRule(owner, name)
}
func UpdateRule(id string, rule *Rule) (bool, error) {
owner, name := util.GetOwnerAndNameFromIdNoCheck(id)
if s, err := getRule(owner, name); err != nil {
return false, err
} else if s == nil {
return false, nil
}
rule.UpdatedTime = util.GetCurrentTime()
_, err := ormer.Engine.ID(core.PK{owner, name}).AllCols().Update(rule)
if err != nil {
return false, err
}
err = refreshRuleMap()
if err != nil {
return false, err
}
return true, nil
}
func AddRule(rule *Rule) (bool, error) {
affected, err := ormer.Engine.Insert(rule)
if err != nil {
return false, err
}
if affected != 0 {
err = refreshRuleMap()
if err != nil {
return false, err
}
}
return affected != 0, nil
}
func DeleteRule(rule *Rule) (bool, error) {
affected, err := ormer.Engine.ID(core.PK{rule.Owner, rule.Name}).Delete(&Rule{})
if err != nil {
return false, err
}
if affected != 0 {
err = refreshRuleMap()
if err != nil {
return false, err
}
}
return affected != 0, nil
}
func (rule *Rule) GetId() string {
return fmt.Sprintf("%s/%s", rule.Owner, rule.Name)
}
func GetRuleCount(owner, field, value string) (int64, error) {
session := GetSession(owner, -1, -1, field, value, "", "")
return session.Count(&Rule{})
}
func GetPaginationRules(owner string, offset, limit int, field, value, sortField, sortOrder string) ([]*Rule, error) {
rules := []*Rule{}
session := GetSession(owner, offset, limit, field, value, sortField, sortOrder)
err := session.Where("owner = ? or owner = ?", "admin", owner).Find(&rules)
if err != nil {
return rules, err
}
return rules, nil
}

57
object/rule_cache.go Normal file
View File

@@ -0,0 +1,57 @@
// Copyright 2023 The casbin Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package object
import (
"fmt"
"github.com/casdoor/casdoor/util"
)
var ruleMap = map[string]*Rule{}
func InitRuleMap() {
err := refreshRuleMap()
if err != nil {
panic(err)
}
}
func refreshRuleMap() error {
newRuleMap := map[string]*Rule{}
rules, err := GetGlobalRules()
if err != nil {
return err
}
for _, rule := range rules {
newRuleMap[util.GetId(rule.Owner, rule.Name)] = rule
}
ruleMap = newRuleMap
return nil
}
func GetRulesByRuleIds(ids []string) ([]*Rule, error) {
var res []*Rule
for _, id := range ids {
rule, ok := ruleMap[id]
if !ok {
return nil, fmt.Errorf("rule: %s not found", id)
}
res = append(res, rule)
}
return res, nil
}

276
object/site.go Normal file
View File

@@ -0,0 +1,276 @@
// Copyright 2023 The casbin Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package object
import (
"fmt"
"math/rand"
"strings"
"time"
"github.com/casdoor/casdoor/util"
"github.com/xorm-io/core"
)
type NodeItem struct {
Name string `json:"name"`
Version string `json:"version"`
Diff string `json:"diff"`
Pid int `json:"pid"`
Status string `json:"status"`
Message string `json:"message"`
Provider string `json:"provider"`
}
type Site struct {
Owner string `xorm:"varchar(100) notnull pk" json:"owner"`
Name string `xorm:"varchar(100) notnull pk" json:"name"`
CreatedTime string `xorm:"varchar(100)" json:"createdTime"`
UpdatedTime string `xorm:"varchar(100)" json:"updatedTime"`
DisplayName string `xorm:"varchar(100)" json:"displayName"`
Tag string `xorm:"varchar(100)" json:"tag"`
Domain string `xorm:"varchar(100)" json:"domain"`
OtherDomains []string `xorm:"varchar(500)" json:"otherDomains"`
NeedRedirect bool `json:"needRedirect"`
DisableVerbose bool `json:"disableVerbose"`
Rules []string `xorm:"varchar(500)" json:"rules"`
EnableAlert bool `json:"enableAlert"`
AlertInterval int `json:"alertInterval"`
AlertTryTimes int `json:"alertTryTimes"`
AlertProviders []string `xorm:"varchar(500)" json:"alertProviders"`
Challenges []string `xorm:"mediumtext" json:"challenges"`
Host string `xorm:"varchar(100)" json:"host"`
Port int `json:"port"`
Hosts []string `xorm:"varchar(1000)" json:"hosts"`
SslMode string `xorm:"varchar(100)" json:"sslMode"`
SslCert string `xorm:"-" json:"sslCert"`
PublicIp string `xorm:"varchar(100)" json:"publicIp"`
Node string `xorm:"varchar(100)" json:"node"`
IsSelf bool `json:"isSelf"`
Status string `xorm:"varchar(100)" json:"status"`
Nodes []*NodeItem `xorm:"mediumtext" json:"nodes"`
CasdoorApplication string `xorm:"varchar(100)" json:"casdoorApplication"`
ApplicationObj *Application `xorm:"-" json:"applicationObj"`
}
func GetGlobalSites() ([]*Site, error) {
sites := []*Site{}
err := ormer.Engine.Desc("created_time").Find(&sites)
if err != nil {
return nil, err
}
return sites, nil
}
func GetSites(owner string) ([]*Site, error) {
sites := []*Site{}
err := ormer.Engine.Asc("tag").Asc("port").Desc("created_time").Find(&sites, &Site{Owner: owner})
if err != nil {
return nil, err
}
for _, site := range sites {
err = site.populateCert()
if err != nil {
return nil, err
}
}
return sites, nil
}
func getSite(owner string, name string) (*Site, error) {
site := Site{Owner: owner, Name: name}
existed, err := ormer.Engine.Get(&site)
if err != nil {
return nil, err
}
if existed {
return &site, nil
}
return nil, nil
}
func GetSite(id string) (*Site, error) {
owner, name := util.GetOwnerAndNameFromIdNoCheck(id)
site, err := getSite(owner, name)
if err != nil {
return nil, err
}
if site != nil {
err = site.populateCert()
if err != nil {
return nil, err
}
}
return site, nil
}
func GetMaskedSite(site *Site, node string) *Site {
if site == nil {
return nil
}
if site.PublicIp == "(empty)" {
site.PublicIp = ""
}
site.IsSelf = false
if site.Node == node {
site.IsSelf = true
}
return site
}
func GetMaskedSites(sites []*Site, node string) []*Site {
for _, site := range sites {
site = GetMaskedSite(site, node)
}
return sites
}
func UpdateSite(id string, site *Site) (bool, error) {
owner, name := util.GetOwnerAndNameFromIdNoCheck(id)
if s, err := getSite(owner, name); err != nil {
return false, err
} else if s == nil {
return false, nil
}
site.UpdatedTime = util.GetCurrentTime()
_, err := ormer.Engine.ID(core.PK{owner, name}).AllCols().Update(site)
if err != nil {
return false, err
}
err = refreshSiteMap()
if err != nil {
return false, err
}
return true, nil
}
func UpdateSiteNoRefresh(id string, site *Site) (bool, error) {
owner, name := util.GetOwnerAndNameFromIdNoCheck(id)
if s, err := getSite(owner, name); err != nil {
return false, err
} else if s == nil {
return false, nil
}
_, err := ormer.Engine.ID(core.PK{owner, name}).AllCols().Update(site)
if err != nil {
return false, err
}
return true, nil
}
func AddSite(site *Site) (bool, error) {
affected, err := ormer.Engine.Insert(site)
if err != nil {
return false, err
}
if affected != 0 {
err = refreshSiteMap()
if err != nil {
return false, err
}
}
return affected != 0, nil
}
func DeleteSite(site *Site) (bool, error) {
affected, err := ormer.Engine.ID(core.PK{site.Owner, site.Name}).Delete(&Site{})
if err != nil {
return false, err
}
if affected != 0 {
err = refreshSiteMap()
if err != nil {
return false, err
}
}
return affected != 0, nil
}
func (site *Site) GetId() string {
return fmt.Sprintf("%s/%s", site.Owner, site.Name)
}
func (site *Site) GetChallengeMap() map[string]string {
m := map[string]string{}
for _, challenge := range site.Challenges {
tokens := strings.Split(challenge, ":")
m[tokens[0]] = tokens[1]
}
return m
}
func (site *Site) GetHost() string {
if len(site.Hosts) != 0 {
rand.Seed(time.Now().UnixNano())
return site.Hosts[rand.Intn(len(site.Hosts))]
}
if site.Host != "" {
return site.Host
}
if site.Port == 0 {
return ""
}
res := fmt.Sprintf("http://localhost:%d", site.Port)
return res
}
func addErrorToMsg(msg string, function string, err error) string {
fmt.Printf("%s(): %s\n", function, err.Error())
if msg == "" {
return fmt.Sprintf("%s(): %s", function, err.Error())
} else {
return fmt.Sprintf("%s || %s(): %s", msg, function, err.Error())
}
}
func GetSiteCount(owner, field, value string) (int64, error) {
session := GetSession(owner, -1, -1, field, value, "", "")
return session.Count(&Site{})
}
func GetPaginationSites(owner string, offset, limit int, field, value, sortField, sortOrder string) ([]*Site, error) {
sites := []*Site{}
session := GetSession(owner, offset, limit, field, value, sortField, sortOrder)
err := session.Where("owner = ? or owner = ?", "admin", owner).Find(&sites)
if err != nil {
return sites, err
}
return sites, nil
}

133
object/site_cache.go Normal file
View File

@@ -0,0 +1,133 @@
// Copyright 2023 The casbin Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package object
import (
"fmt"
"strings"
"github.com/casdoor/casdoor/util"
)
var (
SiteMap = map[string]*Site{}
certMap = map[string]*Cert{}
healthCheckNeededDomains []string
)
func InitSiteMap() {
err := refreshSiteMap()
if err != nil {
panic(err)
}
}
func getCasdoorCertMap() (map[string]*Cert, error) {
certs, err := GetCerts("")
if err != nil {
return nil, fmt.Errorf("GetCerts() error: %s", err.Error())
}
res := map[string]*Cert{}
for _, cert := range certs {
res[cert.Name] = cert
}
return res, nil
}
func getCasdoorApplicationMap() (map[string]*Application, error) {
casdoorCertMap, err := getCasdoorCertMap()
if err != nil {
return nil, err
}
applications, err := GetApplications("")
if err != nil {
return nil, fmt.Errorf("GetOrganizationApplications() error: %s", err.Error())
}
res := map[string]*Application{}
for _, application := range applications {
if application.Cert != "" {
if cert, ok := casdoorCertMap[application.Cert]; ok {
application.CertObj = cert
}
}
res[application.Name] = application
}
return res, nil
}
func refreshSiteMap() error {
applicationMap, err := getCasdoorApplicationMap()
if err != nil {
fmt.Println(err)
}
newSiteMap := map[string]*Site{}
newHealthCheckNeededDomains := make([]string, 0)
sites, err := GetGlobalSites()
if err != nil {
return err
}
certMap, err = getCertMap()
if err != nil {
return err
}
for _, site := range sites {
if applicationMap != nil {
if site.CasdoorApplication != "" && site.ApplicationObj == nil {
if v, ok2 := applicationMap[site.CasdoorApplication]; ok2 {
site.ApplicationObj = v
}
}
}
if site.Domain != "" && site.PublicIp == "" {
go func(site *Site) {
site.PublicIp = util.ResolveDomainToIp(site.Domain)
_, err2 := UpdateSiteNoRefresh(site.GetId(), site)
if err2 != nil {
fmt.Printf("UpdateSiteNoRefresh() error: %v\n", err2)
}
}(site)
}
newSiteMap[strings.ToLower(site.Domain)] = site
if !shouldStopHealthCheck(site) {
newHealthCheckNeededDomains = append(newHealthCheckNeededDomains, strings.ToLower(site.Domain))
}
for _, domain := range site.OtherDomains {
if domain != "" {
newSiteMap[strings.ToLower(domain)] = site
}
}
}
SiteMap = newSiteMap
healthCheckNeededDomains = newHealthCheckNeededDomains
return nil
}
func GetSiteByDomain(domain string) *Site {
if site, ok := SiteMap[strings.ToLower(domain)]; ok {
return site
} else {
return nil
}
}

215
object/site_cert.go Normal file
View File

@@ -0,0 +1,215 @@
// Copyright 2023 The casbin Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package object
import (
"fmt"
"io/ioutil"
"net/http"
"strings"
"time"
"github.com/casdoor/casdoor/util"
)
func (site *Site) populateCert() error {
if site.Domain == "" {
return nil
}
cert, err := GetCertByDomain(site.Domain)
if err != nil {
return err
}
if cert == nil {
return nil
}
site.SslCert = cert.Name
return nil
}
func checkUrlToken(url string, keyAuth string) (bool, error) {
client := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
resp, err := client.Get(url)
if err != nil {
return false, err
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return false, err
}
if strings.TrimSpace(string(body)) == keyAuth {
return true, nil
}
return false, fmt.Errorf("checkUrlToken() error, response mismatch: expected %q, got %q", keyAuth, body)
}
func (site *Site) preCheckCertForDomain(domain string) (bool, error) {
token, keyAuth, err := util.GenerateTwoUniqueRandomStrings()
if err != nil {
return false, err
}
site.Challenges = []string{fmt.Sprintf("%s:%s", token, keyAuth)}
_, err = UpdateSiteNoRefresh(site.GetId(), site)
if err != nil {
return false, err
}
err = refreshSiteMap()
if err != nil {
return false, err
}
url := fmt.Sprintf("http://%s/.well-known/acme-challenge/%s", domain, token)
var ok bool
for i := 0; i < 10; i++ {
fmt.Printf("checkUrlToken(): try time: %d\n", i+1)
ok, err = checkUrlToken(url, keyAuth)
if err != nil {
fmt.Printf("preCheckCertForDomain() error: %v\n", err)
time.Sleep(time.Second)
}
if ok {
fmt.Printf("checkUrlToken(): try time: %d, succeed!\n", i+1)
break
}
}
site.Challenges = []string{}
_, err = UpdateSiteNoRefresh(site.GetId(), site)
if err != nil {
return false, err
}
err = refreshSiteMap()
if err != nil {
return false, err
}
return ok, nil
}
func (site *Site) updateCertForDomain(domain string) error {
ok, err := site.preCheckCertForDomain(domain)
if err != nil {
return err
}
if !ok {
fmt.Printf("preCheckCertForDomain(): not ok for domain: %s\n", domain)
return nil
}
certificate, privateKey, err := getHttp01Cert(site.GetId(), domain)
if err != nil {
return err
}
expireTime, err := util.GetCertExpireTime(certificate)
if err != nil {
fmt.Printf("getCertExpireTime() error: %v\n", err)
}
domainExpireTime, err := getDomainExpireTime(domain)
if err != nil {
fmt.Printf("getDomainExpireTime() error: %v\n", err)
}
cert := Cert{
Owner: site.Owner,
Name: domain,
CreatedTime: util.GetCurrentTime(),
DisplayName: domain,
Type: "SSL",
CryptoAlgorithm: "RSA",
ExpireTime: expireTime,
DomainExpireTime: domainExpireTime,
Provider: "",
Account: "",
AccessKey: "",
AccessSecret: "",
Certificate: certificate,
PrivateKey: privateKey,
}
_, err = DeleteCert(&cert)
if err != nil {
return err
}
_, err = AddCert(&cert)
if err != nil {
return err
}
err = refreshSiteMap()
if err != nil {
return err
}
return nil
}
func (site *Site) checkCerts() error {
domains := []string{}
if site.Domain != "" {
domains = append(domains, site.Domain)
}
for _, domain := range site.OtherDomains {
domains = append(domains, domain)
}
for _, domain := range domains {
if site.Owner == "admin" || strings.HasSuffix(domain, ".casdoor.com") {
continue
}
cert, err := GetCertByDomain(domain)
if err != nil {
return err
}
if cert != nil {
var nearExpire bool
nearExpire, err = cert.isCertNearExpire()
if err != nil {
return err
}
if !nearExpire {
continue
}
}
err = site.updateCertForDomain(domain)
if err != nil {
return err
}
}
return nil
}

103
object/site_cert_account.go Normal file
View File

@@ -0,0 +1,103 @@
// Copyright 2023 The casbin Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package object
import (
"fmt"
"github.com/casbin/lego/v4/acme"
"github.com/casbin/lego/v4/certcrypto"
"github.com/casbin/lego/v4/lego"
"github.com/casbin/lego/v4/registration"
"github.com/casdoor/casdoor/certificate"
"github.com/casdoor/casdoor/conf"
"github.com/casdoor/casdoor/proxy"
)
func getLegoClientAndAccount(email string, privateKey string, devMode bool, useProxy bool) (*lego.Client, *certificate.Account, error) {
eccKey, err := decodeEccKey(privateKey)
if err != nil {
return nil, nil, err
}
account := &certificate.Account{
Email: email,
Key: eccKey,
}
config := lego.NewConfig(account)
if devMode {
config.CADirURL = lego.LEDirectoryStaging
} else {
config.CADirURL = lego.LEDirectoryProduction
}
config.Certificate.KeyType = certcrypto.RSA2048
if useProxy {
config.HTTPClient = proxy.ProxyHttpClient
} else {
config.HTTPClient = proxy.DefaultHttpClient
}
client, err := lego.NewClient(config)
if err != nil {
return nil, nil, err
}
return client, account, nil
}
func getAcmeClient(email string, privateKey string, devMode bool, useProxy bool) (*lego.Client, error) {
// Create a user. New accounts need an email and private key to start.
client, account, err := getLegoClientAndAccount(email, privateKey, devMode, useProxy)
if err != nil {
return nil, err
}
// try to obtain an account based on the private key
account.Registration, err = client.Registration.ResolveAccountByKey()
if err != nil {
acmeError, ok := err.(*acme.ProblemDetails)
if !ok {
return nil, err
}
if acmeError.Type != "urn:ietf:params:acme:error:accountDoesNotExist" {
return nil, err
}
// Failed to get account, so create an account based on the private key.
account.Registration, err = client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
if err != nil {
return nil, err
}
}
return client, nil
}
func GetAcmeClient(useProxy bool) (*lego.Client, error) {
acmeEmail := conf.GetConfigString("acmeEmail")
acmePrivateKey := conf.GetConfigString("acmePrivateKey")
if acmeEmail == "" {
return nil, fmt.Errorf("acmeEmail should not be empty")
}
if acmePrivateKey == "" {
return nil, fmt.Errorf("acmePrivateKey should not be empty")
}
return getAcmeClient(acmeEmail, acmePrivateKey, false, useProxy)
}

59
object/site_cert_ecc.go Normal file
View File

@@ -0,0 +1,59 @@
// Copyright 2023 The casbin 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/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"encoding/pem"
"fmt"
"strings"
)
// generateEccKey generates a public and private key pair.(NIST P-256)
func generateEccKey() *ecdsa.PrivateKey {
privateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
return privateKey
}
// encodeEccKey Return the input private key object as string type private key
func encodeEccKey(privateKey *ecdsa.PrivateKey) string {
x509Encoded, err := x509.MarshalECPrivateKey(privateKey)
if err != nil {
panic(err)
}
pemEncoded := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: x509Encoded})
return string(pemEncoded)
}
// decodeEccKey Return the entered private key string as a private key object that can be used
func decodeEccKey(pemEncoded string) (*ecdsa.PrivateKey, error) {
pemEncoded = strings.ReplaceAll(pemEncoded, "\\n", "\n")
block, _ := pem.Decode([]byte(pemEncoded))
if block == nil {
return nil, fmt.Errorf("decodeEccKey() error, block should not be nil")
}
x509Encoded := block.Bytes
privateKey, err := x509.ParseECPrivateKey(x509Encoded)
if err != nil {
return nil, err
}
return privateKey, nil
}

90
object/site_cert_http.go Normal file
View File

@@ -0,0 +1,90 @@
// Copyright 2023 The casbin Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package object
import (
"fmt"
"github.com/casbin/lego/v4/certificate"
)
type HttpProvider struct {
siteId string
}
func (p *HttpProvider) Present(domain string, token string, keyAuth string) error {
site, err := GetSite(p.siteId)
if err != nil {
return err
}
site.Challenges = []string{fmt.Sprintf("%s:%s", token, keyAuth)}
_, err = UpdateSiteNoRefresh(site.GetId(), site)
if err != nil {
return err
}
err = refreshSiteMap()
if err != nil {
return err
}
return nil
}
func (p *HttpProvider) CleanUp(domain string, token string, keyAuth string) error {
site, err := GetSite(p.siteId)
if err != nil {
return err
}
site.Challenges = []string{}
_, err = UpdateSiteNoRefresh(site.GetId(), site)
if err != nil {
return err
}
err = refreshSiteMap()
if err != nil {
return err
}
return nil
}
func getHttp01Cert(siteId string, domain string) (string, string, error) {
client, err := GetAcmeClient(false)
if err != nil {
return "", "", err
}
provider := HttpProvider{siteId: siteId}
err = client.Challenge.SetHTTP01Provider(&provider)
if err != nil {
return "", "", err
}
request := certificate.ObtainRequest{
Domains: []string{domain},
Bundle: true,
}
resource, err := client.Certificate.Obtain(request)
if err != nil {
return "", "", err
}
return string(resource.Certificate), string(resource.PrivateKey), nil
}

87
object/site_timer.go Normal file
View File

@@ -0,0 +1,87 @@
// Copyright 2023 The casbin Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package object
import (
"fmt"
"sync"
"time"
"github.com/casdoor/casdoor/util"
)
var (
siteUpdateMap = map[string]string{}
lock = &sync.Mutex{}
)
func monitorSiteCerts() error {
sites, err := GetGlobalSites()
if err != nil {
return err
}
for _, site := range sites {
//updatedTime, ok := siteUpdateMap[site.GetId()]
//if ok && updatedTime != "" && updatedTime == site.UpdatedTime {
// continue
//}
lock.Lock()
err = site.checkCerts()
lock.Unlock()
if err != nil {
return err
}
siteUpdateMap[site.GetId()] = site.UpdatedTime
}
return err
}
func StartMonitorSitesLoop() {
fmt.Printf("StartMonitorSitesLoop() Start!\n\n")
go func() {
defer func() {
if r := recover(); r != nil {
fmt.Printf("[%s] Recovered from StartMonitorSitesLoop() panic: %v\n", util.GetCurrentTime(), r)
StartMonitorSitesLoop()
}
}()
for {
err := refreshSiteMap()
if err != nil {
fmt.Println(err)
continue
}
err = refreshRuleMap()
if err != nil {
fmt.Println(err)
continue
}
err = monitorSiteCerts()
if err != nil {
fmt.Println(err)
continue
}
time.Sleep(5 * time.Second)
}
}()
}

View File

@@ -0,0 +1,21 @@
// Copyright 2023 The casbin 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
var healthCheckTryTimesMap = map[string]int{}
func shouldStopHealthCheck(site *Site) bool {
return site == nil || !site.EnableAlert || site.Domain == "" || site.Status == "Inactive"
}

View File

@@ -15,8 +15,10 @@
package object
import (
"strconv"
"strings"
"github.com/casdoor/casdoor/conf"
sender "github.com/casdoor/go-sms-sender"
)
@@ -30,6 +32,10 @@ func getSmsClient(provider *Provider) (sender.SmsClient, error) {
client, err = newHttpSmsClient(provider.Endpoint, provider.Method, provider.Title, provider.TemplateCode, provider.HttpHeaders, provider.UserMapping, provider.IssuerUrl, provider.EnableProxy)
} else if provider.Type == "Alibaba Cloud PNVS SMS" {
client, err = newPnvsSmsClient(provider.ClientId, provider.ClientSecret, provider.SignName, provider.TemplateCode, provider.RegionId)
} else if provider.Type == sender.Twilio {
// For Twilio, the message body is pre-formatted in SendSms using the template.
// Pass "%s" as the template so go-sms-sender's fmt.Sprintf passes the content through unchanged.
client, err = sender.NewSmsClient(provider.Type, provider.ClientId, provider.ClientSecret, provider.SignName, "%s", provider.AppId)
} else {
client, err = sender.NewSmsClient(provider.Type, provider.ClientId, provider.ClientSecret, provider.SignName, provider.TemplateCode, provider.AppId)
}
@@ -50,6 +56,11 @@ func SendSms(provider *Provider, content string, phoneNumbers ...string) error {
if provider.AppId != "" {
phoneNumbers = append([]string{provider.AppId}, phoneNumbers...)
}
// Pre-format the message body using the provider's template.
// If the template contains "%s", substitute the code; otherwise use the content (code) directly.
if strings.Contains(provider.TemplateCode, "%s") {
content = strings.Replace(provider.TemplateCode, "%s", content, 1)
}
} else if provider.Type == sender.Aliyun || provider.Type == "Alibaba Cloud PNVS SMS" {
for i, number := range phoneNumbers {
phoneNumbers[i] = strings.TrimPrefix(number, "+86")
@@ -61,6 +72,13 @@ func SendSms(provider *Provider, content string, phoneNumbers ...string) error {
params["0"] = content
} else {
params["code"] = content
if provider.Type == "Alibaba Cloud PNVS SMS" {
timeoutInMinutes, err := conf.GetConfigInt64("verificationCodeTimeout")
if err != nil || timeoutInMinutes <= 0 {
timeoutInMinutes = 10
}
params["min"] = strconv.FormatInt(timeoutInMinutes, 10)
}
}
err = client.SendMessage(params, phoneNumbers...)

View File

@@ -406,27 +406,61 @@ func (p *DingtalkSyncerProvider) getDingtalkUsers() ([]*OriginalUser, error) {
return originalUsers, nil
}
// getDingtalkUserFieldValue extracts a field value from DingtalkUser by field name
func (p *DingtalkSyncerProvider) getDingtalkUserFieldValue(dingtalkUser *DingtalkUser, fieldName string) string {
switch fieldName {
case "userid":
return dingtalkUser.UserId
case "unionid":
return dingtalkUser.UnionId
case "name":
return dingtalkUser.Name
case "email":
return dingtalkUser.Email
case "mobile":
return dingtalkUser.Mobile
case "avatar":
return dingtalkUser.Avatar
case "title":
return dingtalkUser.Position
case "job_number":
return dingtalkUser.JobNumber
case "active":
// Invert the boolean because active=true means NOT forbidden
return util.BoolToString(!dingtalkUser.Active)
default:
return ""
}
}
// dingtalkUserToOriginalUser converts DingTalk user to Casdoor OriginalUser
func (p *DingtalkSyncerProvider) dingtalkUserToOriginalUser(dingtalkUser *DingtalkUser) *OriginalUser {
// Use unionid as name to be consistent with OAuth provider
// Fallback to userId if unionid is not available
userName := dingtalkUser.UserId
if dingtalkUser.UnionId != "" {
userName = dingtalkUser.UnionId
user := &OriginalUser{
Address: []string{},
Properties: map[string]string{},
Groups: []string{},
DingTalk: dingtalkUser.UserId, // Link DingTalk provider account
}
user := &OriginalUser{
Id: dingtalkUser.UserId,
Name: userName,
DisplayName: dingtalkUser.Name,
Email: dingtalkUser.Email,
Phone: dingtalkUser.Mobile,
Avatar: dingtalkUser.Avatar,
Title: dingtalkUser.Position,
Address: []string{},
Properties: map[string]string{},
Groups: []string{},
DingTalk: dingtalkUser.UserId, // Link DingTalk provider account
// Apply TableColumns mapping if configured
if len(p.Syncer.TableColumns) > 0 {
for _, tableColumn := range p.Syncer.TableColumns {
value := p.getDingtalkUserFieldValue(dingtalkUser, tableColumn.Name)
p.Syncer.setUserByKeyValue(user, tableColumn.CasdoorName, value)
}
} else {
// Fallback to default mapping for backward compatibility
user.Id = dingtalkUser.UserId
user.Name = dingtalkUser.UserId
if dingtalkUser.UnionId != "" {
user.Name = dingtalkUser.UnionId
}
user.DisplayName = dingtalkUser.Name
user.Email = dingtalkUser.Email
user.Phone = dingtalkUser.Mobile
user.Avatar = dingtalkUser.Avatar
user.Title = dingtalkUser.Position
user.IsForbidden = !dingtalkUser.Active
}
// Add department IDs to Groups field
@@ -434,9 +468,6 @@ func (p *DingtalkSyncerProvider) dingtalkUserToOriginalUser(dingtalkUser *Dingta
user.Groups = append(user.Groups, fmt.Sprintf("%d", deptId))
}
// Set IsForbidden based on active status (active=false means user is forbidden)
user.IsForbidden = !dingtalkUser.Active
// Set CreatedTime to current time if not set
if user.CreatedTime == "" {
user.CreatedTime = util.GetCurrentTime()

View File

@@ -71,6 +71,19 @@ func (syncer *Syncer) updateUserForOriginalFields(user *User, key string) (bool,
columns := syncer.getCasdoorColumns()
columns = append(columns, "affiliation", "hash", "pre_hash")
// Skip password-related columns when the incoming user has no password data.
// API-based syncers (DingTalk, WeCom, Lark, etc.) do not provide passwords,
// so updating these columns would wipe out locally set passwords.
if user.Password == "" {
filtered := make([]string, 0, len(columns))
for _, col := range columns {
if col != "password" && col != "password_salt" && col != "password_type" {
filtered = append(filtered, col)
}
}
columns = filtered
}
// Add provider-specific field for API-based syncers to enable login binding
// This allows synced users to login via their provider accounts
switch syncer.Type {

View File

@@ -660,6 +660,15 @@ func generateJwtToken(application *Application, user *User, provider string, sig
return tokenString, refreshTokenString, name, err
}
func ParseJwtTokenWithoutValidation(token string) (*jwt.Token, error) {
t, _, err := jwt.NewParser().ParseUnverified(token, &Claims{})
if err != nil {
return nil, err
}
return t, nil
}
func ParseJwtToken(token string, cert *Cert) (*Claims, error) {
t, err := jwt.ParseWithClaims(token, &Claims{}, func(token *jwt.Token) (interface{}, error) {
var (

View File

@@ -19,6 +19,7 @@ import (
"encoding/base64"
"fmt"
"net/url"
"slices"
"strings"
"sync"
"time"
@@ -154,6 +155,10 @@ func CheckOAuthLogin(clientId string, responseType string, redirectUri string, s
return fmt.Sprintf(i18n.Translate(lang, "token:Redirect URI: %s doesn't exist in the allowed Redirect URI list"), redirectUri), application, nil
}
if !IsScopeValid(scope, application) {
return i18n.Translate(lang, "token:Invalid scope"), application, nil
}
// Mask application for /api/get-app-login
application.ClientSecret = ""
return "", application, nil
@@ -240,10 +245,33 @@ func GetOAuthCode(userId string, clientId string, provider string, signinMethod
}, nil
}
func GetOAuthToken(grantType string, clientId string, clientSecret string, code string, verifier string, scope string, nonce string, username string, password string, host string, refreshToken string, tag string, avatar string, lang string, subjectToken string, subjectTokenType string, audience string, resource string) (interface{}, error) {
application, err := GetApplicationByClientId(clientId)
if err != nil {
return nil, err
func GetOAuthToken(grantType string, clientId string, clientSecret string, code string, verifier string, scope string, nonce string, username string, password string, host string, refreshToken string, tag string, avatar string, lang string, subjectToken string, subjectTokenType string, assertion string, clientAssertion string, clientAssertionType string, audience string, resource string) (interface{}, error) {
var (
application *Application
err error
ok bool
)
if clientAssertionType == "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" {
ok, application, err = ValidateClientAssertion(clientAssertion, host)
if err != nil {
return nil, err
}
if !ok || application == nil {
return &TokenError{
Error: InvalidClient,
ErrorDescription: "client_assertion is invalid",
}, nil
}
clientSecret = application.ClientSecret
clientId = application.ClientId
} else {
application, err = GetApplicationByClientId(clientId)
if err != nil {
return nil, err
}
}
if application == nil {
@@ -273,12 +301,14 @@ func GetOAuthToken(grantType string, clientId string, clientSecret string, code
token, tokenError, err = GetClientCredentialsToken(application, clientSecret, scope, host)
case "token", "id_token": // Implicit Grant
token, tokenError, err = GetImplicitToken(application, username, scope, nonce, host)
case "urn:ietf:params:oauth:grant-type:jwt-bearer":
token, tokenError, err = GetJwtBearerToken(application, assertion, scope, nonce, host)
case "urn:ietf:params:oauth:grant-type:device_code":
token, tokenError, err = GetImplicitToken(application, username, scope, nonce, host)
case "urn:ietf:params:oauth:grant-type:token-exchange": // Token Exchange Grant (RFC 8693)
token, tokenError, err = GetTokenExchangeToken(application, clientSecret, subjectToken, subjectTokenType, audience, scope, host)
case "refresh_token":
refreshToken2, err := RefreshToken(grantType, refreshToken, scope, clientId, clientSecret, host)
refreshToken2, err := RefreshToken(application, grantType, refreshToken, scope, clientId, clientSecret, host)
if err != nil {
return nil, err
}
@@ -320,7 +350,7 @@ func GetOAuthToken(grantType string, clientId string, clientSecret string, code
return tokenWrapper, nil
}
func RefreshToken(grantType string, refreshToken string, scope string, clientId string, clientSecret string, host string) (interface{}, error) {
func RefreshToken(application *Application, grantType string, refreshToken string, scope string, clientId string, clientSecret string, host string) (interface{}, error) {
// check parameters
if grantType != "refresh_token" {
return &TokenError{
@@ -328,16 +358,20 @@ func RefreshToken(grantType string, refreshToken string, scope string, clientId
ErrorDescription: "grant_type should be refresh_token",
}, nil
}
application, err := GetApplicationByClientId(clientId)
if err != nil {
return nil, err
}
var err error
if application == nil {
return &TokenError{
Error: InvalidClient,
ErrorDescription: "client_id is invalid",
}, nil
application, err = GetApplicationByClientId(clientId)
if err != nil {
return nil, err
}
if application == nil {
return &TokenError{
Error: InvalidClient,
ErrorDescription: "client_id is invalid",
}, nil
}
}
if clientSecret != "" && application.ClientSecret != clientSecret {
@@ -486,6 +520,28 @@ func IsGrantTypeValid(method string, grantTypes []string) bool {
return false
}
// IsScopeValid checks whether all space-separated scopes in the scope string
// are defined in the application's Scopes list.
// If the application has no defined scopes, every scope is considered valid
// (backward-compatible behaviour).
func IsScopeValid(scope string, application *Application) bool {
if len(application.Scopes) == 0 || scope == "" {
return true
}
allowed := make(map[string]bool, len(application.Scopes))
for _, s := range application.Scopes {
allowed[s.Name] = true
}
for _, s := range strings.Fields(scope) {
if !allowed[s] {
return false
}
}
return true
}
// createGuestUserToken creates a new guest user and returns a token for them
func createGuestUserToken(application *Application, clientSecret string, verifier string) (*Token, *TokenError, error) {
// Verify client secret if provided
@@ -526,12 +582,19 @@ func createGuestUserToken(application *Application, clientSecret string, verifie
}, nil
}
// Generate a unique user ID within the confines of the application
newUserId, idErr := GenerateIdForNewUser(application)
if idErr != nil {
// If we fail to generate a unique user ID, we can fallback to a random ID
newUserId = util.GenerateId()
}
// Create the guest user
guestUser := &User{
Owner: application.Organization,
Name: guestUsername,
CreatedTime: util.GetCurrentTime(),
Id: util.GenerateId(),
Id: newUserId,
Type: "normal-user",
Password: guestPassword,
Tag: "guest-user",
@@ -715,6 +778,13 @@ func GetAuthorizationCodeToken(application *Application, clientSecret string, co
// GetPasswordToken
// Resource Owner Password Credentials flow
func GetPasswordToken(application *Application, username string, password string, scope string, host string) (*Token, *TokenError, error) {
if !IsScopeValid(scope, application) {
return nil, &TokenError{
Error: InvalidScope,
ErrorDescription: "the requested scope is invalid or not defined in the application",
}, nil
}
user, err := GetUserByFields(application.Organization, username)
if err != nil {
return nil, nil, err
@@ -796,6 +866,12 @@ func GetClientCredentialsToken(application *Application, clientSecret string, sc
ErrorDescription: "client_secret is invalid",
}, nil
}
if !IsScopeValid(scope, application) {
return nil, &TokenError{
Error: InvalidScope,
ErrorDescription: "the requested scope is invalid or not defined in the application",
}, nil
}
nullUser := &User{
Owner: application.Owner,
Id: application.GetId(),
@@ -835,6 +911,13 @@ func GetClientCredentialsToken(application *Application, clientSecret string, sc
// GetImplicitToken
// Implicit flow
func GetImplicitToken(application *Application, username string, scope string, nonce string, host string) (*Token, *TokenError, error) {
if !IsScopeValid(scope, application) {
return nil, &TokenError{
Error: InvalidScope,
ErrorDescription: "the requested scope is invalid or not defined in the application",
}, nil
}
user, err := GetUserByFields(application.Organization, username)
if err != nil {
return nil, nil, err
@@ -859,6 +942,84 @@ func GetImplicitToken(application *Application, username string, scope string, n
return token, nil, nil
}
// GetJwtBearerToken
// RFC 7523
func GetJwtBearerToken(application *Application, assertion string, scope string, nonce string, host string) (*Token, *TokenError, error) {
ok, claims, err := ValidateJwtAssertion(assertion, application, host)
if err != nil || !ok {
if err != nil {
return nil, &TokenError{
Error: InvalidGrant,
ErrorDescription: err.Error(),
}, err
}
return nil, &TokenError{
Error: InvalidGrant,
ErrorDescription: fmt.Sprintf("assertion (JWT) is invalid for application: [%s]", application.GetId()),
}, nil
}
return GetImplicitToken(application, claims.Subject, scope, nonce, host)
}
func ValidateJwtAssertion(clientAssertion string, application *Application, host string) (bool, *Claims, error) {
_, originBackend := getOriginFromHost(host)
clientCert, err := getCert(application.Owner, application.ClientCert)
if err != nil {
return false, nil, err
}
if clientCert == nil {
return false, nil, fmt.Errorf("client certificate is not configured for application: [%s]", application.GetId())
}
claims, err := ParseJwtToken(clientAssertion, clientCert)
if err != nil {
return false, nil, err
}
if !slices.Contains(application.RedirectUris, claims.Issuer) {
return false, nil, nil
}
if !slices.Contains(claims.Audience, fmt.Sprintf("%s/api/login/oauth/access_token", originBackend)) {
return false, nil, nil
}
return true, claims, nil
}
func ValidateClientAssertion(clientAssertion string, host string) (bool, *Application, error) {
token, err := ParseJwtTokenWithoutValidation(clientAssertion)
if err != nil {
return false, nil, err
}
clientId, err := token.Claims.GetSubject()
if err != nil {
return false, nil, err
}
application, err := GetApplicationByClientId(clientId)
if err != nil {
return false, nil, err
}
if application == nil {
return false, nil, fmt.Errorf("application not found for client: [%s]", clientId)
}
ok, _, err := ValidateJwtAssertion(clientAssertion, application, host)
if err != nil {
return false, application, err
}
if !ok {
return false, application, nil
}
return true, application, nil
}
// GetTokenByUser
// Implicit flow
func GetTokenByUser(application *Application, user *User, scope string, nonce string, host string) (*Token, error) {
@@ -946,9 +1107,16 @@ func GetWechatMiniProgramToken(application *Application, code string, host strin
name = fmt.Sprintf("wechat-%s", openId)
}
// Generate a unique user ID within the confines of the application
newUserId, idErr := GenerateIdForNewUser(application)
if idErr != nil {
// If we fail to generate a unique user ID, we can fallback to a random ID
newUserId = util.GenerateId()
}
user = &User{
Owner: application.Organization,
Id: util.GenerateId(),
Id: newUserId,
Name: name,
Avatar: avatar,
SignupApplication: application.Name,

View File

@@ -180,6 +180,7 @@ type User struct {
Spotify string `xorm:"spotify varchar(100)" json:"spotify"`
Strava string `xorm:"strava varchar(100)" json:"strava"`
Stripe string `xorm:"stripe varchar(100)" json:"stripe"`
Telegram string `xorm:"telegram varchar(100)" json:"telegram"`
TikTok string `xorm:"tiktok varchar(100)" json:"tiktok"`
Tumblr string `xorm:"tumblr varchar(100)" json:"tumblr"`
Twitch string `xorm:"twitch varchar(100)" json:"twitch"`
@@ -241,6 +242,7 @@ type User struct {
MfaRememberDeadline string `xorm:"varchar(100)" json:"mfaRememberDeadline"`
NeedUpdatePassword bool `json:"needUpdatePassword"`
IpWhitelist string `xorm:"varchar(200)" json:"ipWhitelist"`
ApplicationScopes []ConsentRecord `xorm:"mediumtext" json:"applicationScopes"`
}
type Userinfo struct {
@@ -860,17 +862,17 @@ func UpdateUser(id string, user *User, columns []string, isAdmin bool) (bool, er
if len(columns) == 0 {
columns = []string{
"owner", "display_name", "avatar", "first_name", "last_name",
"location", "address", "country_code", "region", "language", "affiliation", "title", "id_card_type", "id_card", "homepage", "bio", "tag", "language", "gender", "birthday", "education", "score", "karma", "ranking", "signup_application",
"is_admin", "is_forbidden", "is_deleted", "hash", "is_default_avatar", "properties", "webauthnCredentials", "managedAccounts", "face_ids", "mfaAccounts",
"signin_wrong_times", "last_change_password_time", "last_signin_wrong_time", "groups", "access_key", "access_secret", "mfa_phone_enabled", "mfa_email_enabled", "email_verified",
"location", "address", "addresses", "country_code", "region", "language", "affiliation", "title", "id_card_type", "id_card", "homepage", "bio", "tag", "language", "gender", "birthday", "education", "score", "karma", "ranking", "signup_application", "register_type", "register_source",
"is_admin", "is_forbidden", "is_deleted", "hash", "is_default_avatar", "properties", "webauthnCredentials", "mfa_items", "last_change_password_time", "managedAccounts", "face_ids", "mfaAccounts",
"signin_wrong_times", "last_signin_wrong_time", "groups", "access_key", "access_secret", "mfa_phone_enabled", "mfa_email_enabled", "email_verified",
"github", "google", "qq", "wechat", "facebook", "dingtalk", "weibo", "gitee", "linkedin", "wecom", "lark", "gitlab", "adfs",
"baidu", "alipay", "casdoor", "infoflow", "apple", "azuread", "azureadb2c", "slack", "steam", "bilibili", "okta", "douyin", "kwai", "line", "amazon",
"auth0", "battlenet", "bitbucket", "box", "cloudfoundry", "dailymotion", "deezer", "digitalocean", "discord", "dropbox",
"eveonline", "fitbit", "gitea", "heroku", "influxcloud", "instagram", "intercom", "kakao", "lastfm", "mailru", "meetup",
"microsoftonline", "naver", "nextcloud", "onedrive", "oura", "patreon", "paypal", "salesforce", "shopify", "soundcloud",
"spotify", "strava", "stripe", "type", "tiktok", "tumblr", "twitch", "twitter", "typetalk", "uber", "vk", "wepay", "xero", "yahoo",
"yammer", "yandex", "zoom", "custom", "need_update_password", "ip_whitelist", "mfa_items", "mfa_remember_deadline",
"cart",
"spotify", "strava", "stripe", "type", "telegram", "tiktok", "tumblr", "twitch", "twitter", "typetalk", "uber", "vk", "wepay", "xero", "yahoo",
"yammer", "yandex", "zoom", "custom", "need_update_password", "ip_whitelist", "mfa_remember_deadline",
"cart", "application_scopes",
}
}
if isAdmin {
@@ -954,6 +956,13 @@ func UpdateUserForAllFields(id string, user *User) (bool, error) {
user.UpdatedTime = util.GetCurrentTime()
if len(user.Groups) > 0 {
_, err = userEnforcer.UpdateGroupsForUser(user.GetId(), user.Groups)
if err != nil {
return false, err
}
}
affected, err := ormer.Engine.ID(core.PK{owner, name}).AllCols().Update(user)
if err != nil {
return false, err

119
object/user_scope.go Normal file
View File

@@ -0,0 +1,119 @@
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package object
import (
"fmt"
"strings"
"github.com/casdoor/casdoor/i18n"
)
// ConsentRecord represents the data for OAuth consent API requests/responses
type ConsentRecord struct {
// owner/name
Application string `json:"application"`
GrantedScopes []string `json:"grantedScopes"`
}
// ScopeDescription represents a human-readable description of an OAuth scope
type ScopeDescription struct {
Scope string `json:"scope"`
DisplayName string `json:"displayName"`
Description string `json:"description"`
}
// parseScopes converts a space-separated scope string to a slice
func parseScopes(scopeStr string) []string {
if scopeStr == "" {
return []string{}
}
scopes := strings.Split(scopeStr, " ")
var result []string
for _, scope := range scopes {
trimmed := strings.TrimSpace(scope)
if trimmed != "" {
result = append(result, trimmed)
}
}
return result
}
// CheckConsentRequired checks if user consent is required for the OAuth flow
func CheckConsentRequired(userObj *User, application *Application, scopeStr string) (bool, error) {
// Skip consent when no custom scopes are configured
if len(application.CustomScopes) == 0 {
return false, nil
}
// Once policy: check if consent already granted
requestedScopes := parseScopes(scopeStr)
appId := application.GetId()
// Filter requestedScopes to only include scopes defined in application.CustomScopes
customScopesMap := make(map[string]bool)
for _, customScope := range application.CustomScopes {
if customScope.Scope != "" {
customScopesMap[customScope.Scope] = true
}
}
validRequestedScopes := []string{}
for _, scope := range requestedScopes {
if customScopesMap[scope] {
validRequestedScopes = append(validRequestedScopes, scope)
}
}
// If no valid requested scopes, no consent required
if len(validRequestedScopes) == 0 {
return false, nil
}
for _, record := range userObj.ApplicationScopes {
if record.Application == appId {
// Check if grantedScopes contains all validRequestedScopes
grantedMap := make(map[string]bool)
for _, scope := range record.GrantedScopes {
grantedMap[scope] = true
}
allGranted := true
for _, scope := range validRequestedScopes {
if !grantedMap[scope] {
allGranted = false
break
}
}
if allGranted {
// Consent already granted for all valid requested scopes
return false, nil
}
}
}
// Consent required
return true, nil
}
func validateCustomScopes(customScopes []*ScopeDescription, lang string) error {
for _, scope := range customScopes {
if scope == nil || strings.TrimSpace(scope.Scope) == "" {
return fmt.Errorf("%s: custom scope name", i18n.Translate(lang, "general:Missing parameter"))
}
}
return nil
}

View File

@@ -126,12 +126,26 @@ func InitAPI() {
web.Router("/api/delete-resource", &controllers.ApiController{}, "POST:DeleteResource")
web.Router("/api/upload-resource", &controllers.ApiController{}, "POST:UploadResource")
web.Router("/api/get-global-sites", &controllers.ApiController{}, "GET:GetGlobalSites")
web.Router("/api/get-sites", &controllers.ApiController{}, "GET:GetSites")
web.Router("/api/get-site", &controllers.ApiController{}, "GET:GetSite")
web.Router("/api/update-site", &controllers.ApiController{}, "POST:UpdateSite")
web.Router("/api/add-site", &controllers.ApiController{}, "POST:AddSite")
web.Router("/api/delete-site", &controllers.ApiController{}, "POST:DeleteSite")
web.Router("/api/get-rules", &controllers.ApiController{}, "GET:GetRules")
web.Router("/api/get-rule", &controllers.ApiController{}, "GET:GetRule")
web.Router("/api/add-rule", &controllers.ApiController{}, "POST:AddRule")
web.Router("/api/update-rule", &controllers.ApiController{}, "POST:UpdateRule")
web.Router("/api/delete-rule", &controllers.ApiController{}, "POST:DeleteRule")
web.Router("/api/get-certs", &controllers.ApiController{}, "GET:GetCerts")
web.Router("/api/get-global-certs", &controllers.ApiController{}, "GET:GetGlobalCerts")
web.Router("/api/get-cert", &controllers.ApiController{}, "GET:GetCert")
web.Router("/api/update-cert", &controllers.ApiController{}, "POST:UpdateCert")
web.Router("/api/add-cert", &controllers.ApiController{}, "POST:AddCert")
web.Router("/api/delete-cert", &controllers.ApiController{}, "POST:DeleteCert")
web.Router("/api/update-cert-domain-expire", &controllers.ApiController{}, "POST:UpdateCertDomainExpire")
web.Router("/api/get-roles", &controllers.ApiController{}, "GET:GetRoles")
web.Router("/api/get-role", &controllers.ApiController{}, "GET:GetRole")
@@ -319,6 +333,9 @@ func InitAPI() {
web.Router("/api/delete-mfa", &controllers.ApiController{}, "POST:DeleteMfa")
web.Router("/api/set-preferred-mfa", &controllers.ApiController{}, "POST:SetPreferredMfa")
web.Router("/api/grant-consent", &controllers.ApiController{}, "POST:GrantConsent")
web.Router("/api/revoke-consent", &controllers.ApiController{}, "POST:RevokeConsent")
web.Router("/.well-known/openid-configuration", &controllers.RootController{}, "GET:GetOidcDiscovery")
web.Router("/.well-known/:application/openid-configuration", &controllers.RootController{}, "GET:GetOidcDiscoveryByApplication")
web.Router("/.well-known/oauth-authorization-server", &controllers.RootController{}, "GET:GetOAuthServerMetadata")

View File

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

112
rule/rule.go Normal file
View File

@@ -0,0 +1,112 @@
// Copyright 2024 The casbin 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 rule
import (
"fmt"
"net/http"
"github.com/casdoor/casdoor/object"
"github.com/casdoor/casdoor/util"
)
type Rule interface {
checkRule(expressions []*object.Expression, req *http.Request) (*RuleResult, error)
}
type RuleResult struct {
Action string
StatusCode int
Reason string
}
func CheckRules(ruleIds []string, r *http.Request) (*RuleResult, error) {
rules, err := object.GetRulesByRuleIds(ruleIds)
if err != nil {
return nil, err
}
for i, rule := range rules {
var ruleObj Rule
switch rule.Type {
case "User-Agent":
ruleObj = &UaRule{}
case "IP":
ruleObj = &IpRule{}
case "WAF":
ruleObj = &WafRule{}
case "IP Rate Limiting":
ruleObj = &IpRateRule{
ruleName: rule.GetId(),
}
case "Compound":
ruleObj = &CompoundRule{}
default:
return nil, fmt.Errorf("unknown rule type: %s for rule: %s", rule.Type, rule.GetId())
}
result, err := ruleObj.checkRule(rule.Expressions, r)
if err != nil {
return nil, err
}
if result != nil {
// Use rule's action if no action specified by the rule check
if result.Action == "" {
result.Action = rule.Action
}
// Determine status code
if result.StatusCode == 0 {
if rule.StatusCode != 0 {
result.StatusCode = rule.StatusCode
} else {
// Set default status codes if not specified
switch result.Action {
case "Block":
result.StatusCode = 403
case "Drop":
result.StatusCode = 400
case "Allow":
result.StatusCode = 200
case "CAPTCHA":
result.StatusCode = 302
default:
return nil, fmt.Errorf("unknown rule action: %s for rule: %s", result.Action, rule.GetId())
}
}
}
// Update reason if rule has custom reason
if result.Action == "Block" || result.Action == "Drop" {
if rule.IsVerbose {
// Add verbose debug info with rule name and triggered expression
result.Reason = util.GenerateVerboseReason(rule.GetId(), result.Reason, rule.Reason)
} else if rule.Reason != "" {
result.Reason = rule.Reason
} else if result.Reason != "" {
result.Reason = fmt.Sprintf("hit rule %s: %s", ruleIds[i], result.Reason)
}
}
return result, nil
}
}
// Default action if no rule matched
return &RuleResult{
Action: "Allow",
StatusCode: 200,
}, nil
}

60
rule/rule_compound.go Normal file
View File

@@ -0,0 +1,60 @@
// Copyright 2024 The casbin 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 rule
import (
"fmt"
"net/http"
"github.com/casdoor/casdoor/object"
"github.com/casdoor/casdoor/util"
)
type CompoundRule struct{}
func (r *CompoundRule) checkRule(expressions []*object.Expression, req *http.Request) (*RuleResult, error) {
operators := util.NewStack()
res := true
for _, expression := range expressions {
isHit := true
result, err := CheckRules([]string{expression.Value}, req)
if err != nil {
return nil, err
}
if result == nil || result.Action == "" {
isHit = false
}
switch expression.Operator {
case "and", "begin":
res = res && isHit
case "or":
operators.Push(res)
res = isHit
default:
return nil, fmt.Errorf("unknown operator: %s", expression.Operator)
}
if operators.Size() > 0 {
last, ok := operators.Pop()
for ok {
res = last.(bool) || res
last, ok = operators.Pop()
}
}
}
if res {
return &RuleResult{}, nil
}
return nil, nil
}

94
rule/rule_ip.go Normal file
View File

@@ -0,0 +1,94 @@
// Copyright 2024 The casbin 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 rule
import (
"fmt"
"net"
"net/http"
"strings"
"github.com/casdoor/casdoor/ip"
"github.com/casdoor/casdoor/object"
"github.com/casdoor/casdoor/util"
)
type IpRule struct{}
func (r *IpRule) checkRule(expressions []*object.Expression, req *http.Request) (*RuleResult, error) {
clientIp := util.GetClientIp(req)
netIp, err := parseIp(clientIp)
if err != nil {
return nil, err
}
for _, expression := range expressions {
reason := fmt.Sprintf("expression matched: \"%s %s %s\"", clientIp, expression.Operator, expression.Value)
// Handle "is abroad" operator
if expression.Operator == "is abroad" {
if ip.IsAbroadIp(clientIp) {
return &RuleResult{Reason: reason}, nil
}
continue
}
ips := strings.Split(expression.Value, ",")
for _, ipStr := range ips {
if strings.Contains(ipStr, "/") {
_, ipNet, err := net.ParseCIDR(ipStr)
if err != nil {
return nil, err
}
switch expression.Operator {
case "is in":
if ipNet.Contains(netIp) {
return &RuleResult{Reason: reason}, nil
}
case "is not in":
if !ipNet.Contains(netIp) {
return &RuleResult{Reason: reason}, nil
}
default:
return nil, fmt.Errorf("unknown operator: %s", expression.Operator)
}
} else if strings.ContainsAny(ipStr, ".:") {
switch expression.Operator {
case "is in":
if ipStr == clientIp {
return &RuleResult{Reason: reason}, nil
}
case "is not in":
if ipStr != clientIp {
return &RuleResult{Reason: reason}, nil
}
default:
return nil, fmt.Errorf("unknown operator: %s", expression.Operator)
}
} else {
return nil, fmt.Errorf("unknown IP or CIDR format: %s", ipStr)
}
}
}
return nil, nil
}
func parseIp(ipStr string) (net.IP, error) {
ip := net.ParseIP(ipStr)
if ip == nil {
return nil, fmt.Errorf("unknown IP or CIDR format: %s", ipStr)
}
return ip, nil
}

134
rule/rule_ip_rate.go Normal file
View File

@@ -0,0 +1,134 @@
// Copyright 2024 The casbin 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 rule
import (
"net/http"
"sync"
"time"
"github.com/casdoor/casdoor/object"
"github.com/casdoor/casdoor/util"
"golang.org/x/time/rate"
)
type IpRateRule struct {
ruleName string
}
type IpRateLimiter struct {
ips map[string]*rate.Limiter
mu *sync.RWMutex
r rate.Limit
b int
}
var blackList = map[string]map[string]time.Time{}
var ipRateLimiters = map[string]*IpRateLimiter{}
// NewIpRateLimiter .
func NewIpRateLimiter(r rate.Limit, b int) *IpRateLimiter {
i := &IpRateLimiter{
ips: make(map[string]*rate.Limiter),
mu: &sync.RWMutex{},
r: r,
b: b,
}
return i
}
// AddIP creates a new rate limiter and adds it to the ips map,
// using the IP address as the key
func (i *IpRateLimiter) AddIP(ip string) *rate.Limiter {
i.mu.Lock()
defer i.mu.Unlock()
limiter := rate.NewLimiter(i.r, i.b)
i.ips[ip] = limiter
return limiter
}
// GetLimiter returns the rate limiter for the provided IP address if it exists.
// Otherwise, calls AddIP to add IP address to the map
func (i *IpRateLimiter) GetLimiter(ip string) *rate.Limiter {
i.mu.Lock()
limiter, exists := i.ips[ip]
if !exists {
i.mu.Unlock()
return i.AddIP(ip)
}
i.mu.Unlock()
return limiter
}
func (r *IpRateRule) checkRule(expressions []*object.Expression, req *http.Request) (*RuleResult, error) {
expression := expressions[0] // IpRate rule should have only one expression
clientIp := util.GetClientIp(req)
// If the client IP is in the blacklist, check the block time
createAt, ok := blackList[r.ruleName][clientIp]
if ok {
blockTime := util.ParseInt(expression.Value)
if time.Now().Sub(createAt) < time.Duration(blockTime)*time.Second {
return &RuleResult{
Action: "Block",
Reason: "Rate limit exceeded",
}, nil
} else {
delete(blackList[r.ruleName], clientIp)
}
}
// If the client IP is not in the blacklist, check the rate limit
ipRateLimiter := ipRateLimiters[r.ruleName]
parseInt := util.ParseInt(expression.Operator)
if ipRateLimiter == nil {
ipRateLimiter = NewIpRateLimiter(rate.Limit(parseInt), parseInt)
ipRateLimiters[r.ruleName] = ipRateLimiter
}
// If the rate limit has changed, update the rate limiter
limiter := ipRateLimiter.GetLimiter(clientIp)
if ipRateLimiter.r != rate.Limit(parseInt) {
ipRateLimiter.r = rate.Limit(parseInt)
ipRateLimiter.b = parseInt
limiter.SetLimit(ipRateLimiter.r)
limiter.SetBurst(ipRateLimiter.b)
err := limiter.Wait(req.Context())
if err != nil {
return nil, err
}
} else {
// If the rate limit is exceeded, add the client IP to the blacklist
allow := limiter.Allow()
if !allow {
blackList[r.ruleName] = map[string]time.Time{}
blackList[r.ruleName][clientIp] = time.Now()
return &RuleResult{
Action: "Block",
Reason: "Rate limit exceeded",
}, nil
}
}
return nil, nil
}

154
rule/rule_ip_rate_test.go Normal file
View File

@@ -0,0 +1,154 @@
// Copyright 2023 The casbin 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 rule
import (
"net/http"
"testing"
"github.com/casdoor/casdoor/object"
)
func TestIpRateRule_checkRule(t *testing.T) {
type fields struct {
ruleName string
}
type args struct {
args []struct {
expressions []*object.Expression
req *http.Request
}
}
tests := []struct {
name string
fields fields
args args
want []bool
want1 []string
want2 []string
wantErr []bool
}{
{
name: "Test 1",
fields: fields{
ruleName: "rule1",
},
args: args{
args: []struct {
expressions []*object.Expression
req *http.Request
}{
{
expressions: []*object.Expression{
{
Operator: "1",
Value: "1",
},
},
req: &http.Request{
RemoteAddr: "127.0.0.1",
},
},
{
expressions: []*object.Expression{
{
Operator: "1",
Value: "1",
},
},
req: &http.Request{
RemoteAddr: "127.0.0.1",
},
},
},
},
want: []bool{false, true},
want1: []string{"", "Block"},
want2: []string{"", "Rate limit exceeded"},
wantErr: []bool{false, false},
},
{
name: "Test 2",
fields: fields{
ruleName: "rule2",
},
args: args{
args: []struct {
expressions []*object.Expression
req *http.Request
}{
{
expressions: []*object.Expression{
{
Operator: "1",
Value: "1",
},
},
req: &http.Request{
RemoteAddr: "127.0.0.1",
},
},
{
expressions: []*object.Expression{
{
Operator: "10",
Value: "1",
},
},
req: &http.Request{
RemoteAddr: "127.0.0.1",
},
},
},
},
want: []bool{false, false},
want1: []string{"", ""},
want2: []string{"", ""},
wantErr: []bool{false, false},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := &IpRateRule{
ruleName: tt.fields.ruleName,
}
for i, arg := range tt.args.args {
result, err := r.checkRule(arg.expressions, arg.req)
if (err != nil) != tt.wantErr[i] {
t.Errorf("checkRule() error = %v, wantErr %v", err, tt.wantErr)
return
}
got := result != nil
got1 := ""
got2 := ""
if result != nil {
got1 = result.Action
got2 = result.Reason
}
if got != tt.want[i] {
t.Errorf("checkRule() got = %v, want %v", got, tt.want[i])
}
if got1 != tt.want1[i] {
t.Errorf("checkRule() got1 = %v, want %v", got1, tt.want1[i])
}
if got2 != tt.want2[i] {
t.Errorf("checkRule() got2 = %v, want %v", got2, tt.want2[i])
}
}
})
}
}

63
rule/rule_ua.go Normal file
View File

@@ -0,0 +1,63 @@
// Copyright 2024 The casbin 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 rule
import (
"fmt"
"net/http"
"regexp"
"strings"
"github.com/casdoor/casdoor/object"
)
type UaRule struct{}
func (r *UaRule) checkRule(expressions []*object.Expression, req *http.Request) (*RuleResult, error) {
userAgent := req.UserAgent()
for _, expression := range expressions {
ua := expression.Value
reason := fmt.Sprintf("expression matched: \"%s %s %s\"", userAgent, expression.Operator, expression.Value)
switch expression.Operator {
case "contains":
if strings.Contains(userAgent, ua) {
return &RuleResult{Reason: reason}, nil
}
case "does not contain":
if !strings.Contains(userAgent, ua) {
return &RuleResult{Reason: reason}, nil
}
case "equals":
if userAgent == ua {
return &RuleResult{Reason: reason}, nil
}
case "does not equal":
if strings.Compare(userAgent, ua) != 0 {
return &RuleResult{Reason: reason}, nil
}
case "match":
// regex match
isHit, err := regexp.MatchString(ua, userAgent)
if err != nil {
return nil, err
}
if isHit {
return &RuleResult{Reason: reason}, nil
}
}
}
return nil, nil
}

105
rule/rule_waf.go Normal file
View File

@@ -0,0 +1,105 @@
// Copyright 2024 The casbin 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 rule
import (
"fmt"
"net/http"
"github.com/casdoor/casdoor/conf"
"github.com/casdoor/casdoor/object"
"github.com/corazawaf/coraza/v3"
"github.com/corazawaf/coraza/v3/types"
"github.com/hsluoyz/modsecurity-go/seclang/parser"
)
type WafRule struct{}
func (r *WafRule) checkRule(expressions []*object.Expression, req *http.Request) (*RuleResult, error) {
var ruleStr string
for _, expression := range expressions {
ruleStr += expression.Value
}
waf, err := coraza.NewWAF(
coraza.NewWAFConfig().
WithErrorCallback(logError).
WithDirectives(conf.WafConf).
WithDirectives(ruleStr),
)
if err != nil {
return nil, fmt.Errorf("create WAF failed")
}
tx := waf.NewTransaction()
processRequest(tx, req)
matchedRules := tx.MatchedRules()
for _, matchedRule := range matchedRules {
rule := matchedRule.Rule()
directive, err := parser.NewSecLangScannerFromString(rule.Raw()).AllDirective()
if err != nil {
return nil, err
}
for _, d := range directive {
ruleDirective := d.(*parser.RuleDirective)
for _, action := range ruleDirective.Actions.Action {
switch action.Tk {
case parser.TkActionBlock, parser.TkActionDeny:
return &RuleResult{
Action: "Block",
Reason: fmt.Sprintf("blocked by WAF rule: %d", rule.ID()),
}, nil
case parser.TkActionAllow:
return &RuleResult{
Action: "Allow",
}, nil
case parser.TkActionDrop:
return &RuleResult{
Action: "Drop",
Reason: fmt.Sprintf("dropped by WAF rule: %d", rule.ID()),
}, nil
default:
// skip other actions
continue
}
}
}
}
return nil, nil
}
func processRequest(tx types.Transaction, req *http.Request) {
// Process URI and method
tx.ProcessURI(req.URL.String(), req.Method, req.Proto)
// Process request headers
for key, values := range req.Header {
for _, value := range values {
tx.AddRequestHeader(key, value)
}
}
tx.ProcessRequestHeaders()
// Process request body (if any)
if req.Body != nil {
_, err := tx.ProcessRequestBody()
if err != nil {
return
}
}
}
func logError(error types.MatchedRule) {
msg := error.ErrorLog()
fmt.Printf("[WAFlogError][%s] %s\n", error.Rule().Severity(), msg)
}

90
service/oauth.go Normal file
View File

@@ -0,0 +1,90 @@
// Copyright 2023 The casbin 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 service
import (
"fmt"
"net/http"
"net/url"
"github.com/casdoor/casdoor-go-sdk/casdoorsdk"
"github.com/casdoor/casdoor/object"
"github.com/casdoor/casdoor/util"
)
func getSigninUrl(casdoorClient *casdoorsdk.Client, callbackUrl string, originalPath string) string {
scope := "read"
return fmt.Sprintf("%s/login/oauth/authorize?client_id=%s&response_type=code&redirect_uri=%s&scope=%s&state=%s",
casdoorClient.Endpoint, casdoorClient.ClientId, url.QueryEscape(callbackUrl), scope, url.QueryEscape(originalPath))
}
func redirectToCasdoor(casdoorClient *casdoorsdk.Client, w http.ResponseWriter, r *http.Request) {
scheme := getScheme(r)
callbackUrl := fmt.Sprintf("%s://%s/caswaf-handler", scheme, r.Host)
originalPath := r.RequestURI
signinUrl := getSigninUrl(casdoorClient, callbackUrl, originalPath)
http.Redirect(w, r, signinUrl, http.StatusFound)
}
func handleAuthCallback(w http.ResponseWriter, r *http.Request) {
site := getSiteByDomainWithWww(r.Host)
if site == nil {
responseError(w, "CasWAF error: site not found for host: %s", r.Host)
return
}
code := r.URL.Query().Get("code")
state := r.URL.Query().Get("state")
if code == "" {
responseError(w, "CasWAF error: the code should not be empty")
return
} else if state == "" {
responseError(w, "CasWAF error: the state should not be empty")
return
}
application, err := object.GetApplication(util.GetId(site.Owner, site.CasdoorApplication))
if err != nil {
responseError(w, "CasWAF error: casdoorClient.GetOAuthToken() error: %s", err.Error())
return
}
//casdoorClient, err := getCasdoorClientFromSite(site)
//if err != nil {
// responseError(w, "CasWAF error: getCasdoorClientFromSite() error: %s", err.Error())
// return
//}
token, tokenError, err := object.GetAuthorizationCodeToken(application, application.ClientSecret, code, "", "")
if tokenError != nil {
responseError(w, "CasWAF error: casdoorClient.GetOAuthToken() error: %s", tokenError.Error)
return
}
if err != nil {
responseError(w, "CasWAF error: casdoorClient.GetOAuthToken() error: %s", err.Error())
return
}
cookie := &http.Cookie{
Name: "casdoor_access_token",
Value: token.AccessToken,
Path: "/",
}
http.SetCookie(w, cookie)
originalPath := state
http.Redirect(w, r, originalPath, http.StatusFound)
}

372
service/proxy.go Normal file
View File

@@ -0,0 +1,372 @@
// Copyright 2023 The casbin 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 service
import (
"crypto/tls"
"fmt"
"net"
"net/http"
"net/http/httputil"
"net/url"
"path/filepath"
"strings"
"github.com/casdoor/casdoor/conf"
"github.com/casdoor/casdoor/object"
"github.com/casdoor/casdoor/rule"
"github.com/casdoor/casdoor/util"
"github.com/casvisor/casvisor-go-sdk/casvisorsdk"
)
func forwardHandler(targetUrl string, writer http.ResponseWriter, request *http.Request) {
target, err := url.Parse(targetUrl)
if nil != err {
panic(err)
return
}
proxy := httputil.NewSingleHostReverseProxy(target)
proxy.Director = func(r *http.Request) {
r.URL = target
if clientIP, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" && xff != clientIP {
newXff := fmt.Sprintf("%s, %s", xff, clientIP)
// r.Header.Set("X-Forwarded-For", newXff)
r.Header.Set("X-Real-Ip", newXff)
} else {
// r.Header.Set("X-Forwarded-For", clientIP)
r.Header.Set("X-Real-Ip", clientIP)
}
}
}
proxy.ModifyResponse = func(resp *http.Response) error {
// Add Secure flag to all Set-Cookie headers in HTTPS responses
if request.TLS != nil {
// Add HSTS header for HTTPS responses if not already set by backend
if resp.Header.Get("Strict-Transport-Security") == "" {
resp.Header.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
}
cookies := resp.Header["Set-Cookie"]
if len(cookies) > 0 {
// Clear existing Set-Cookie headers
resp.Header.Del("Set-Cookie")
// Add them back with Secure flag if not already present
for _, cookie := range cookies {
// Check if Secure attribute is already present (case-insensitive)
cookieLower := strings.ToLower(cookie)
hasSecure := strings.Contains(cookieLower, ";secure;") ||
strings.Contains(cookieLower, "; secure;") ||
strings.HasSuffix(cookieLower, ";secure") ||
strings.HasSuffix(cookieLower, "; secure")
if !hasSecure {
cookie = cookie + "; Secure"
}
resp.Header.Add("Set-Cookie", cookie)
}
}
}
// Fix CORS issue: Remove CORS header combinations that allow credential theft from any origin
allowOrigin := resp.Header.Get("Access-Control-Allow-Origin")
allowCredentials := resp.Header.Get("Access-Control-Allow-Credentials")
// Remove CORS headers when the combination is present:
// 1. Access-Control-Allow-Credentials: true with Access-Control-Allow-Origin: *
// This is actually blocked by browsers but we sanitize it anyway
// 2. Access-Control-Allow-Credentials: true with any origin
// Without a configured allowlist, we cannot safely validate if the origin
// is trusted or if it's being reflected from the request, so we remove all
// CORS headers for credential-bearing responses to prevent theft
if strings.EqualFold(allowCredentials, "true") && allowOrigin != "" {
// Remove CORS headers to prevent credential theft
resp.Header.Del("Access-Control-Allow-Origin")
resp.Header.Del("Access-Control-Allow-Credentials")
resp.Header.Del("Access-Control-Allow-Methods")
resp.Header.Del("Access-Control-Allow-Headers")
resp.Header.Del("Access-Control-Expose-Headers")
resp.Header.Del("Access-Control-Max-Age")
}
return nil
}
proxy.ServeHTTP(writer, request)
}
func getHostNonWww(host string) string {
res := ""
tokens := strings.Split(host, ".")
if len(tokens) > 2 && tokens[0] == "www" {
res = strings.Join(tokens[1:], ".")
}
return res
}
func logRequest(clientIp string, r *http.Request) {
if !strings.Contains(r.UserAgent(), "Uptime-Kuma") {
fmt.Printf("handleRequest: %s\t%s\t%s\t%s\t%s\t%s\n", clientIp, r.Method, r.Host, r.RequestURI, r.UserAgent(), r.RemoteAddr)
record := casvisorsdk.Record{
Owner: "admin",
CreatedTime: util.GetCurrentTime(),
Method: r.Method,
RequestUri: r.RequestURI,
ClientIp: clientIp,
}
object.AddRecord(&record)
}
}
func redirectToHttps(w http.ResponseWriter, r *http.Request) {
targetUrl := fmt.Sprintf("https://%s", joinPath(r.Host, r.RequestURI))
http.Redirect(w, r, targetUrl, http.StatusMovedPermanently)
}
func redirectToHost(w http.ResponseWriter, r *http.Request, host string) {
protocol := "https"
if r.TLS == nil {
protocol = "http"
}
targetUrl := fmt.Sprintf("%s://%s", protocol, joinPath(host, r.RequestURI))
http.Redirect(w, r, targetUrl, http.StatusMovedPermanently)
}
func handleRequest(w http.ResponseWriter, r *http.Request) {
clientIp := util.GetClientIp(r)
logRequest(clientIp, r)
site := getSiteByDomainWithWww(r.Host)
if site == nil {
if isHostIp(r.Host) {
w.WriteHeader(http.StatusBadRequest)
return
}
if strings.HasSuffix(r.Host, ".casdoor.com") && r.RequestURI == "/health-ping" {
w.WriteHeader(http.StatusOK)
_, err := fmt.Fprintf(w, "OK")
if err != nil {
panic(err)
}
return
}
responseError(w, "CasWAF error: site not found for host: %s", r.Host)
return
}
hostNonWww := getHostNonWww(r.Host)
if hostNonWww != "" {
redirectToHost(w, r, hostNonWww)
return
}
if site.Domain != r.Host && site.NeedRedirect {
redirectToHost(w, r, site.Domain)
return
}
if strings.HasPrefix(r.RequestURI, "/.well-known/acme-challenge/") {
challengeMap := site.GetChallengeMap()
for token, keyAuth := range challengeMap {
if r.RequestURI == fmt.Sprintf("/.well-known/acme-challenge/%s", token) {
responseOk(w, keyAuth)
return
}
}
responseError(w, fmt.Sprintf("CasWAF error: ACME HTTP-01 challenge failed, requestUri cannot match with challengeMap, requestUri = %s, challengeMap = %v", r.RequestURI, challengeMap))
return
}
if strings.HasPrefix(r.RequestURI, "/MP_verify_") {
challengeMap := site.GetChallengeMap()
for path, value := range challengeMap {
if r.RequestURI == fmt.Sprintf("/%s", path) {
responseOk(w, value)
return
}
}
}
if site.SslMode == "HTTPS Only" {
// This domain only supports https but receive http request, redirect to https
if r.TLS == nil {
redirectToHttps(w, r)
return
}
}
// oAuth proxy
if site.CasdoorApplication != "" {
// handle oAuth proxy
cookie, err := r.Cookie("casdoor_access_token")
if err != nil && err.Error() != "http: named cookie not present" {
panic(err)
}
casdoorClient, err := getCasdoorClientFromSite(site)
if err != nil {
responseError(w, "CasWAF error: getCasdoorClientFromSite() error: %s", err.Error())
return
}
if cookie == nil {
// not logged in
redirectToCasdoor(casdoorClient, w, r)
return
} else {
_, err = casdoorClient.ParseJwtToken(cookie.Value)
if err != nil {
responseError(w, "CasWAF error: casdoorClient.ParseJwtToken() error: %s", err.Error())
return
}
}
}
host := site.GetHost()
if host == "" {
responseError(w, "CasWAF error: targetUrl should not be empty for host: %s, site = %v", r.Host, site)
return
}
if len(site.Rules) == 0 {
nextHandle(w, r)
return
}
result, err := rule.CheckRules(site.Rules, r)
if err != nil {
responseError(w, "Internal Server Error: %v", err)
return
}
reason := result.Reason
if reason != "" && site.DisableVerbose {
reason = "the rule has been hit"
}
switch result.Action {
case "", "Allow":
// Do not write header for Allow action, let the proxy handle it
case "Block":
w.WriteHeader(result.StatusCode)
responseErrorWithoutCode(w, "Blocked by CasWAF: %s", reason)
return
case "Drop":
w.WriteHeader(result.StatusCode)
responseErrorWithoutCode(w, "Dropped by CasWAF: %s", reason)
return
default:
responseError(w, "Error in CasWAF: %s", reason)
}
nextHandle(w, r)
}
func nextHandle(w http.ResponseWriter, r *http.Request) {
site := getSiteByDomainWithWww(r.Host)
host := site.GetHost()
if site.SslMode == "Static Folder" {
var path string
if r.RequestURI != "/" {
path = filepath.Join(host, r.RequestURI)
} else {
path = filepath.Join(host, "/index.htm")
if !util.FileExist(path) {
path = filepath.Join(host, "/index.html")
if !util.FileExist(path) {
path = filepath.Join(host, r.RequestURI)
}
}
}
http.ServeFile(w, r, path)
} else {
targetUrl := joinPath(site.GetHost(), r.RequestURI)
forwardHandler(targetUrl, w, r)
}
}
func Start() {
serverMux := http.NewServeMux()
serverMux.HandleFunc("/", handleRequest)
serverMux.HandleFunc("/caswaf-handler", handleAuthCallback)
gatewayHttpPort, err := conf.GetConfigInt64("gatewayHttpPort")
if err != nil {
gatewayHttpPort = 80
}
gatewayHttpsPort, err := conf.GetConfigInt64("gatewayHttpsPort")
if err != nil {
gatewayHttpsPort = 443
}
go func() {
fmt.Printf("CasWAF gateway running on: http://127.0.0.1:%d\n", gatewayHttpPort)
err := http.ListenAndServe(fmt.Sprintf(":%d", gatewayHttpPort), serverMux)
if err != nil {
panic(err)
}
}()
go func() {
fmt.Printf("CasWAF gateway running on: https://127.0.0.1:%d\n", gatewayHttpsPort)
server := &http.Server{
Handler: serverMux,
Addr: fmt.Sprintf(":%d", gatewayHttpsPort),
TLSConfig: &tls.Config{
// Minimum TLS version 1.2, TLS 1.3 is automatically supported
MinVersion: tls.VersionTLS12,
// Secure cipher suites for TLS 1.2 (excluding 3DES to prevent Sweet32 attack)
// TLS 1.3 cipher suites are automatically configured by Go
CipherSuites: []uint16{
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
},
// Prefer strong elliptic curves
CurvePreferences: []tls.CurveID{
tls.X25519,
tls.CurveP256,
tls.CurveP384,
},
},
}
// start https server and set how to get certificate
server.TLSConfig.GetCertificate = func(info *tls.ClientHelloInfo) (*tls.Certificate, error) {
domain := info.ServerName
cert, err := getX509CertByDomain(domain)
if err != nil {
return nil, err
}
return cert, nil
}
err := server.ListenAndServeTLS("", "")
if err != nil {
panic(err)
}
}()
}

142
service/util.go Normal file
View File

@@ -0,0 +1,142 @@
// Copyright 2023 The casbin 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 service
import (
"crypto/tls"
"fmt"
"net"
"net/http"
"strings"
"github.com/casdoor/casdoor-go-sdk/casdoorsdk"
"github.com/casdoor/casdoor/conf"
"github.com/casdoor/casdoor/object"
)
func joinPath(a string, b string) string {
if strings.HasSuffix(a, "/") && strings.HasPrefix(b, "/") {
b = b[1:]
} else if !strings.HasSuffix(a, "/") && !strings.HasPrefix(b, "/") {
b = "/" + b
}
res := a + b
return res
}
func isHostIp(host string) bool {
hostWithoutPort := strings.Split(host, ":")[0]
ip := net.ParseIP(hostWithoutPort)
return ip != nil
}
func responseOk(w http.ResponseWriter, format string, a ...interface{}) {
w.WriteHeader(http.StatusOK)
msg := fmt.Sprintf(format, a...)
fmt.Println(msg)
_, err := fmt.Fprintf(w, msg)
if err != nil {
panic(err)
}
}
func responseError(w http.ResponseWriter, format string, a ...interface{}) {
w.WriteHeader(http.StatusInternalServerError)
msg := fmt.Sprintf(format, a...)
fmt.Println(msg)
_, err := fmt.Fprintf(w, msg)
if err != nil {
panic(err)
}
}
func responseErrorWithoutCode(w http.ResponseWriter, format string, a ...interface{}) {
msg := fmt.Sprintf(format, a...)
fmt.Println(msg)
_, err := fmt.Fprintf(w, msg)
if err != nil {
panic(err)
}
}
func getDomainWithoutPort(domain string) string {
if !strings.Contains(domain, ":") {
return domain
}
tokens := strings.SplitN(domain, ":", 2)
if len(tokens) > 1 {
return tokens[0]
}
return domain
}
func getSiteByDomainWithWww(domain string) *object.Site {
hostNonWww := getHostNonWww(domain)
if hostNonWww != "" {
domain = hostNonWww
}
domainWithoutPort := getDomainWithoutPort(domain)
site := object.GetSiteByDomain(domainWithoutPort)
return site
}
func getX509CertByDomain(domain string) (*tls.Certificate, error) {
cert, err := object.GetCertByDomain(domain)
if err != nil {
return nil, fmt.Errorf("getX509CertByDomain() error: %v, domain: [%s]", err, domain)
}
if cert == nil {
return nil, fmt.Errorf("getX509CertByDomain() error: cert not found for domain: [%s]", domain)
}
tlsCert, certErr := tls.X509KeyPair([]byte(cert.Certificate), []byte(cert.PrivateKey))
return &tlsCert, certErr
}
func getCasdoorClientFromSite(site *object.Site) (*casdoorsdk.Client, error) {
if site.ApplicationObj == nil {
return nil, fmt.Errorf("site.ApplicationObj is empty")
}
casdoorEndpoint := conf.GetConfigString("origin")
if casdoorEndpoint == "" {
casdoorEndpoint = "http://localhost:8000"
}
clientId := site.ApplicationObj.ClientId
clientSecret := site.ApplicationObj.ClientSecret
certificate := ""
if site.ApplicationObj.CertObj != nil {
certificate = site.ApplicationObj.CertObj.Certificate
}
res := casdoorsdk.NewClient(casdoorEndpoint, clientId, clientSecret, certificate, site.ApplicationObj.Organization, site.CasdoorApplication)
return res, nil
}
func getScheme(r *http.Request) string {
scheme := r.URL.Scheme
if scheme == "" {
scheme = "http"
}
return scheme
}

50
util/cert.go Normal file
View File

@@ -0,0 +1,50 @@
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package util
import (
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"time"
"golang.org/x/net/publicsuffix"
)
func GetCertExpireTime(s string) (string, error) {
block, _ := pem.Decode([]byte(s))
if block == nil {
return "", errors.New("getCertExpireTime() error, block should not be nil")
} else if block.Type != "CERTIFICATE" {
return "", fmt.Errorf("getCertExpireTime() error, block.Type should be \"CERTIFICATE\" instead of %s", block.Type)
}
certificate, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return "", err
}
t := certificate.NotAfter
return t.Local().Format(time.RFC3339), nil
}
func GetBaseDomain(domain string) (string, error) {
// abc.com -> abc.com
// abc.com.it -> abc.com.it
// subdomain.abc.io -> abc.io
// subdomain.abc.org.us -> abc.org.us
return publicsuffix.EffectiveTLDPlusOne(domain)
}

View File

@@ -15,9 +15,23 @@
package util
import (
"fmt"
"net"
"net/http"
"os"
"strings"
"time"
)
func GetHostname() string {
name, err := os.Hostname()
if err != nil {
panic(err)
}
return name
}
func IsInternetIp(ip string) bool {
ipStr, _, err := net.SplitHostPort(ip)
if err != nil {
@@ -45,3 +59,55 @@ func IsHostIntranet(ip string) bool {
return parsedIP.IsPrivate() || parsedIP.IsLoopback() || parsedIP.IsLinkLocalUnicast() || parsedIP.IsLinkLocalMulticast()
}
func ResolveDomainToIp(domain string) string {
ips, err := net.LookupIP(domain)
if err != nil {
if strings.Contains(err.Error(), "no such host") {
return "(empty)"
}
fmt.Printf("resolveDomainToIp() error: %s\n", err.Error())
return err.Error()
}
for _, ip := range ips {
if ipv4 := ip.To4(); ipv4 != nil {
return ipv4.String()
}
}
return "(empty)"
}
func PingUrl(url string) (bool, string) {
client := http.Client{
Timeout: 5 * time.Second,
}
resp, err := client.Get(url)
if err != nil {
return false, err.Error()
}
defer resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode <= 299 {
return true, ""
}
return false, fmt.Sprintf("Status: %s", resp.Status)
}
func IsIntranetIp(ip string) bool {
ipStr, _, err := net.SplitHostPort(ip)
if err != nil {
ipStr = ip
}
parsedIP := net.ParseIP(ipStr)
if parsedIP == nil {
return false
}
return parsedIP.IsPrivate() ||
parsedIP.IsLoopback() ||
parsedIP.IsLinkLocalUnicast() ||
parsedIP.IsLinkLocalMulticast()
}

40
util/request.go Normal file
View File

@@ -0,0 +1,40 @@
// Copyright 2023 The casbin 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 util
import (
"net"
"net/http"
"strings"
)
func GetClientIp(r *http.Request) string {
forwarded := r.Header.Get("X-Forwarded-For")
if forwarded != "" {
clientIP := strings.Split(forwarded, ",")[0]
return strings.TrimSpace(clientIP)
}
realIP := r.Header.Get("X-Real-IP")
if realIP != "" {
return realIP
}
ip, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return ip
}

29
util/rule.go Normal file
View File

@@ -0,0 +1,29 @@
// Copyright 2024 The casbin 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 util
import "fmt"
// GenerateVerboseReason creates a detailed reason message for verbose mode
func GenerateVerboseReason(ruleId string, expressionReason string, customReason string) string {
verboseReason := fmt.Sprintf("Rule [%s] triggered", ruleId)
if expressionReason != "" {
verboseReason += fmt.Sprintf(" - %s", expressionReason)
}
if customReason != "" {
verboseReason += fmt.Sprintf(" - Custom reason: %s", customReason)
}
return verboseReason
}

59
util/stacks.go Normal file
View File

@@ -0,0 +1,59 @@
// Copyright 2023 The casbin 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 util
// Stack is a stack data structure implemented using a slice
type Stack struct {
items []interface{}
}
// Push adds an item to the stack
func (s *Stack) Push(item interface{}) {
s.items = append(s.items, item)
}
// Pop removes and returns the last item from the stack
func (s *Stack) Pop() (interface{}, bool) {
if len(s.items) == 0 {
return nil, false // Return a sentinel value or you could handle this more gracefully
}
lastIndex := len(s.items) - 1
item := s.items[lastIndex]
s.items = s.items[:lastIndex]
return item, true
}
// Peek returns the last item from the stack without removing it
func (s *Stack) Peek() interface{} {
if len(s.items) == 0 {
return -1
}
return s.items[len(s.items)-1]
}
// IsEmpty checks if the stack is empty
func (s *Stack) IsEmpty() bool {
return len(s.items) == 0
}
// Size returns the number of items in the stack
func (s *Stack) Size() int {
return len(s.items)
}
// NewStack creates a new stack
func NewStack() *Stack {
return &Stack{}
}

View File

@@ -20,6 +20,7 @@ import (
"encoding/hex"
"errors"
"fmt"
"math/big"
"math/rand"
"os"
"path/filepath"
@@ -393,3 +394,37 @@ func StringToInterfaceArray2d(arrays [][]string) [][]interface{} {
}
return interfaceArrays
}
func generateRandomString(length int) (string, error) {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, length)
for i := range b {
var c byte
index := rand.Intn(len(charset))
c = charset[index]
b[i] = c
}
return string(b), nil
}
func GenerateTwoUniqueRandomStrings() (string, string, error) {
len1 := 16 + int(big.NewInt(17).Int64())
len2 := 16 + int(big.NewInt(17).Int64())
str1, err := generateRandomString(len1)
if err != nil {
return "", "", err
}
str2, err := generateRandomString(len2)
if err != nil {
return "", "", err
}
for str1 == str2 {
str2, err = generateRandomString(len2)
if err != nil {
return "", "", err
}
}
return str1, str2, nil
}

View File

@@ -487,7 +487,7 @@ class App extends Component {
: (
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={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>
)
)
@@ -539,15 +539,16 @@ class App extends Component {
isEntryPages() {
return window.location.pathname.startsWith("/signup") ||
window.location.pathname.startsWith("/login") ||
window.location.pathname.startsWith("/forget") ||
window.location.pathname.startsWith("/prompt") ||
window.location.pathname.startsWith("/result") ||
window.location.pathname.startsWith("/cas") ||
window.location.pathname.startsWith("/select-plan") ||
window.location.pathname.startsWith("/buy-plan") ||
window.location.pathname.startsWith("/qrcode") ||
window.location.pathname.startsWith("/captcha");
window.location.pathname.startsWith("/login") ||
window.location.pathname.startsWith("/forget") ||
window.location.pathname.startsWith("/prompt") ||
window.location.pathname.startsWith("/result") ||
window.location.pathname.startsWith("/cas") ||
window.location.pathname.startsWith("/select-plan") ||
window.location.pathname.startsWith("/buy-plan") ||
window.location.pathname.startsWith("/qrcode") ||
window.location.pathname.startsWith("/consent") ||
window.location.pathname.startsWith("/captcha");
}
onClick = ({key}) => {
@@ -656,7 +657,7 @@ class App extends Component {
menuVisible={this.state.menuVisible}
logo={this.state.logo}
onChangeTheme={this.setTheme}
onClick = {this.onClick}
onClick={this.onClick}
onfinish={() => {
this.setState({requiredEnableMfa: false});
}}

View File

@@ -248,6 +248,33 @@ class ApplicationEditPage extends React.Component {
return value;
}
trimCustomScopes(customScopes) {
if (!Array.isArray(customScopes)) {
return [];
}
return customScopes.map((item) => {
const scope = (item?.scope || "").trim();
const displayName = (item?.displayName || "").trim();
const description = (item?.description || "").trim();
return {
...item,
scope: scope,
displayName: displayName,
description: description,
};
});
}
validateCustomScopes(customScopes) {
const trimmed = this.trimCustomScopes(customScopes);
for (const item of trimmed) {
if (!item || !item.scope || item.scope === "") {
return {ok: false, scopes: trimmed};
}
}
return {ok: true, scopes: trimmed};
}
updateApplicationField(key, value) {
value = this.parseApplicationField(key, value);
const application = this.state.application;
@@ -506,157 +533,6 @@ class ApplicationEditPage extends React.Component {
)}
{this.state.activeMenuKey === "authentication" && (
<React.Fragment>
<Row style={{marginTop: "10px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 3}>
{Setting.getLabel(i18next.t("provider:Client ID"), i18next.t("provider:Client ID - Tooltip"))} :
</Col>
<Col span={21} >
<Input value={this.state.application.clientId} onChange={e => {
this.updateApplicationField("clientId", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "10px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 3}>
{Setting.getLabel(i18next.t("provider:Client secret"), i18next.t("provider:Client secret - Tooltip"))} :
</Col>
<Col span={21} >
<Input value={this.state.application.clientSecret} onChange={e => {
this.updateApplicationField("clientSecret", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 3}>
{Setting.getLabel(i18next.t("application:Redirect URLs"), i18next.t("application:Redirect URLs - Tooltip"))} :
</Col>
<Col span={21} >
<UrlTable
title={i18next.t("application:Redirect URLs")}
table={this.state.application.redirectUris}
onUpdateTable={(value) => {this.updateApplicationField("redirectUris", value);}}
/>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 3}>
{Setting.getLabel(i18next.t("application:Forced redirect origin"), i18next.t("general:Forced redirect origin - Tooltip"))} :
</Col>
<Col span={21} >
<Input prefix={<LinkOutlined />} value={this.state.application.forcedRedirectOrigin} onChange={e => {
this.updateApplicationField("forcedRedirectOrigin", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 3}>
{Setting.getLabel(i18next.t("application:Grant types"), i18next.t("application:Grant types - Tooltip"))} :
</Col>
<Col span={21} >
<Select virtual={false} mode="multiple" style={{width: "100%"}}
value={this.state.application.grantTypes}
onChange={(value => {
this.updateApplicationField("grantTypes", value);
})} >
{
[
{id: "authorization_code", name: "Authorization Code"},
{id: "password", name: "Password"},
{id: "client_credentials", name: "Client Credentials"},
{id: "token", name: "Token"},
{id: "id_token", name: "ID Token"},
{id: "refresh_token", name: "Refresh Token"},
{id: "urn:ietf:params:oauth:grant-type:device_code", name: "Device Code"},
].map((item, index) => <Option key={index} value={item.id}>{item.name}</Option>)
}
</Select>
</Col>
</Row>
{
(this.state.application.category === "Agent") ? (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 3}>
{Setting.getLabel(i18next.t("general:Scopes"), i18next.t("general:Scopes - Tooltip"))} :
</Col>
<Col span={21} >
<ScopeTable
title={i18next.t("general:Scopes")}
table={this.state.application.scopes}
onUpdateTable={(value) => {this.updateApplicationField("scopes", value);}}
/>
</Col>
</Row>
) : null
}
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 3}>
{Setting.getLabel(i18next.t("application:Token format"), i18next.t("application:Token format - Tooltip"))} :
</Col>
<Col span={21} >
<Select virtual={false} style={{width: "100%"}} value={this.state.application.tokenFormat} onChange={(value => {this.updateApplicationField("tokenFormat", value);})}
options={["JWT", "JWT-Empty", "JWT-Custom", "JWT-Standard"].map((item) => Setting.getOption(item, item))}
/>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 3}>
{Setting.getLabel(i18next.t("application:Token signing method"), i18next.t("application:Token signing method - Tooltip"))} :
</Col>
<Col span={21} >
<Select virtual={false} style={{width: "100%"}} value={this.state.application.tokenSigningMethod === "" ? "RS256" : this.state.application.tokenSigningMethod} onChange={(value => {this.updateApplicationField("tokenSigningMethod", value);})}
options={["RS256", "RS512", "ES256", "ES512", "ES384"].map((item) => Setting.getOption(item, item))}
/>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 3}>
{Setting.getLabel(i18next.t("application:Token fields"), i18next.t("application:Token fields - Tooltip"))} :
</Col>
<Col span={21} >
<Select virtual={false} disabled={this.state.application.tokenFormat !== "JWT-Custom"} mode="tags" showSearch style={{width: "100%"}} value={this.state.application.tokenFields} onChange={(value => {this.updateApplicationField("tokenFields", value);})}>
<Option key={"signinMethod"} value={"signinMethod"}>{"SigninMethod"}</Option>
<Option key={"provider"} value={"provider"}>{"Provider"}</Option>
{
[...Setting.getUserCommonFields(), "permissionNames"].map((item, index) => <Option key={index} value={item}>{item}</Option>)
}
</Select>
</Col>
</Row>
{
this.state.application.tokenFormat === "JWT-Custom" ? (<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Token attributes"), i18next.t("general:Token attributes - Tooltip"))} :
</Col>
<Col span={22} >
<TokenAttributeTable
title={i18next.t("general:Token attributes")}
table={this.state.application.tokenAttributes}
application={this.state.application}
onUpdateTable={(value) => {this.updateApplicationField("tokenAttributes", value);}}
/>
</Col>
</Row>) : null
}
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 3}>
{Setting.getLabel(i18next.t("application:Token expire"), i18next.t("application:Token expire - Tooltip"))} :
</Col>
<Col span={21} >
<InputNumber style={{width: "150px"}} value={this.state.application.expireInHours} min={0.01} step={1} precision={2} addonAfter="Hours" onChange={value => {
this.updateApplicationField("expireInHours", value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 3}>
{Setting.getLabel(i18next.t("application:Refresh token expire"), i18next.t("application:Refresh token expire - Tooltip"))} :
</Col>
<Col span={21} >
<InputNumber style={{width: "150px"}} value={this.state.application.refreshExpireInHours} min={0.01} step={1} precision={2} addonAfter="Hours" onChange={value => {
this.updateApplicationField("refreshExpireInHours", value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 3}>
{Setting.getLabel(i18next.t("application:Cookie expire"), i18next.t("application:Cookie expire - Tooltip"))} :
@@ -807,7 +683,167 @@ class ApplicationEditPage extends React.Component {
}} />
</Col>
</Row>
</React.Fragment>
)}
{this.state.activeMenuKey === "oidc-oauth" && (
<React.Fragment>
<Row style={{marginTop: "10px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 3}>
{Setting.getLabel(i18next.t("provider:Client ID"), i18next.t("provider:Client ID - Tooltip"))} :
</Col>
<Col span={21} >
<Input value={this.state.application.clientId} onChange={e => {
this.updateApplicationField("clientId", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "10px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 3}>
{Setting.getLabel(i18next.t("provider:Client secret"), i18next.t("provider:Client secret - Tooltip"))} :
</Col>
<Col span={21} >
<Input value={this.state.application.clientSecret} onChange={e => {
this.updateApplicationField("clientSecret", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 3}>
{Setting.getLabel(i18next.t("application:Redirect URLs"), i18next.t("application:Redirect URLs - Tooltip"))} :
</Col>
<Col span={21} >
<UrlTable
title={i18next.t("application:Redirect URLs")}
table={this.state.application.redirectUris}
onUpdateTable={(value) => {this.updateApplicationField("redirectUris", value);}}
/>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 3}>
{Setting.getLabel(i18next.t("application:Forced redirect origin"), i18next.t("general:Forced redirect origin - Tooltip"))} :
</Col>
<Col span={21} >
<Input prefix={<LinkOutlined />} value={this.state.application.forcedRedirectOrigin} onChange={e => {
this.updateApplicationField("forcedRedirectOrigin", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 3}>
{Setting.getLabel(i18next.t("application:Grant types"), i18next.t("application:Grant types - Tooltip"))} :
</Col>
<Col span={21} >
<Select virtual={false} mode="multiple" style={{width: "100%"}}
value={this.state.application.grantTypes}
onChange={(value => {
this.updateApplicationField("grantTypes", value);
})} >
{
[
{id: "authorization_code", name: "Authorization Code"},
{id: "password", name: "Password"},
{id: "client_credentials", name: "Client Credentials"},
{id: "token", name: "Token"},
{id: "id_token", name: "ID Token"},
{id: "refresh_token", name: "Refresh Token"},
{id: "urn:ietf:params:oauth:grant-type:device_code", name: "Device Code"},
{id: "urn:ietf:params:oauth:grant-type:jwt-bearer", name: "JWT Bearer"},
].map((item, index) => <Option key={index} value={item.id}>{item.name}</Option>)
}
</Select>
</Col>
</Row>
{
(this.state.application.category === "Agent") ? (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 3}>
{Setting.getLabel(i18next.t("general:Scopes"), i18next.t("general:Scopes - Tooltip"))} :
</Col>
<Col span={21} >
<ScopeTable
title={i18next.t("general:Scopes")}
table={this.state.application.scopes}
onUpdateTable={(value) => {this.updateApplicationField("scopes", value);}}
/>
</Col>
</Row>
) : null
}
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 3}>
{Setting.getLabel(i18next.t("application:Token format"), i18next.t("application:Token format - Tooltip"))} :
</Col>
<Col span={21} >
<Select virtual={false} style={{width: "100%"}} value={this.state.application.tokenFormat} onChange={(value => {this.updateApplicationField("tokenFormat", value);})}
options={["JWT", "JWT-Empty", "JWT-Custom", "JWT-Standard"].map((item) => Setting.getOption(item, item))}
/>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 3}>
{Setting.getLabel(i18next.t("application:Token signing method"), i18next.t("application:Token signing method - Tooltip"))} :
</Col>
<Col span={21} >
<Select virtual={false} style={{width: "100%"}} value={this.state.application.tokenSigningMethod === "" ? "RS256" : this.state.application.tokenSigningMethod} onChange={(value => {this.updateApplicationField("tokenSigningMethod", value);})}
options={["RS256", "RS512", "ES256", "ES512", "ES384"].map((item) => Setting.getOption(item, item))}
/>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 3}>
{Setting.getLabel(i18next.t("application:Token fields"), i18next.t("application:Token fields - Tooltip"))} :
</Col>
<Col span={21} >
<Select virtual={false} disabled={this.state.application.tokenFormat !== "JWT-Custom"} mode="tags" showSearch style={{width: "100%"}} value={this.state.application.tokenFields} onChange={(value => {this.updateApplicationField("tokenFields", value);})}>
<Option key={"signinMethod"} value={"signinMethod"}>{"SigninMethod"}</Option>
<Option key={"provider"} value={"provider"}>{"Provider"}</Option>
{
[...Setting.getUserCommonFields(), "permissionNames"].map((item, index) => <Option key={index} value={item}>{item}</Option>)
}
</Select>
</Col>
</Row>
{
this.state.application.tokenFormat === "JWT-Custom" ? (<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Token attributes"), i18next.t("general:Token attributes - Tooltip"))} :
</Col>
<Col span={22} >
<TokenAttributeTable
title={i18next.t("general:Token attributes")}
table={this.state.application.tokenAttributes}
application={this.state.application}
onUpdateTable={(value) => {this.updateApplicationField("tokenAttributes", value);}}
/>
</Col>
</Row>) : null
}
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 3}>
{Setting.getLabel(i18next.t("application:Token expire"), i18next.t("application:Token expire - Tooltip"))} :
</Col>
<Col span={21} >
<InputNumber style={{width: "150px"}} value={this.state.application.expireInHours} min={0.01} step={1} precision={2} addonAfter="Hours" onChange={value => {
this.updateApplicationField("expireInHours", value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 3}>
{Setting.getLabel(i18next.t("application:Refresh token expire"), i18next.t("application:Refresh token expire - Tooltip"))} :
</Col>
<Col span={21} >
<InputNumber style={{width: "150px"}} value={this.state.application.refreshExpireInHours} min={0.01} step={1} precision={2} addonAfter="Hours" onChange={value => {
this.updateApplicationField("refreshExpireInHours", value);
}} />
</Col>
</Row>
</React.Fragment>
)}
{this.state.activeMenuKey === "saml" && (
<React.Fragment>
<Row style={{marginTop: "10px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 3}>
{Setting.getLabel(i18next.t("application:SAML reply URL"), i18next.t("application:Redirect URL (Assertion Consumer Service POST Binding URL) - Tooltip"))} :
</Col>
@@ -1308,7 +1344,7 @@ class ApplicationEditPage extends React.Component {
<React.Fragment>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 3}>
{Setting.getLabel(i18next.t("general:Cert"), i18next.t("general:Cert - Tooltip"))} :
{Setting.getLabel(i18next.t("application:Token cert"), i18next.t("application:Token cert - Tooltip"))} :
</Col>
<Col span={21} >
<Select virtual={false} style={{width: "100%"}} value={this.state.application.cert} onChange={(value => {this.updateApplicationField("cert", value);})}>
@@ -1318,6 +1354,18 @@ class ApplicationEditPage extends React.Component {
</Select>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 3}>
{Setting.getLabel(i18next.t("application:Client cert"), i18next.t("application:Client cert - Tooltip"))} :
</Col>
<Col span={21} >
<Select virtual={false} style={{width: "100%"}} value={this.state.application.clientCert} onChange={(value => {this.updateApplicationField("clientCert", value);})}>
{
this.state.certs.map((cert, index) => <Option key={index} value={cert.name}>{cert.name}</Option>)
}
</Select>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 3}>
{Setting.getLabel(i18next.t("application:Failed signin limit"), i18next.t("application:Failed signin limit - Tooltip"))} :
@@ -1353,7 +1401,7 @@ class ApplicationEditPage extends React.Component {
{Setting.getLabel(i18next.t("general:IP whitelist"), i18next.t("general:IP whitelist - Tooltip"))} :
</Col>
<Col span={21} >
<Input placeholder = {this.state.application.organizationObj?.ipWhitelist} value={this.state.application.ipWhitelist} onChange={e => {
<Input placeholder={this.state.application.organizationObj?.ipWhitelist} value={this.state.application.ipWhitelist} onChange={e => {
this.updateApplicationField("ipWhitelist", e.target.value);
}} />
</Col>
@@ -1452,7 +1500,7 @@ class ApplicationEditPage extends React.Component {
<Layout style={{background: "inherit", height: "100%", overflow: "auto"}}>
{
this.state.menuMode === "horizontal" || !this.state.menuMode ? (
<Header style={{background: "inherit", padding: "0px", position: "sticky", top: 0}}>
<Header style={{background: "inherit", padding: "0px", position: "sticky", top: 0, height: 38, minHeight: 38}}>
<div className="demo-logo" />
<Tabs
onChange={(key) => {
@@ -1461,9 +1509,12 @@ class ApplicationEditPage extends React.Component {
}}
type="card"
activeKey={this.state.activeMenuKey}
tabBarStyle={{marginBottom: 0}}
items={[
{label: i18next.t("application:Basic"), key: "basic"},
{label: i18next.t("application:Authentication"), key: "authentication"},
{label: "OIDC/OAuth", key: "oidc-oauth"},
{label: "SAML", key: "saml"},
{label: i18next.t("application:Providers"), key: "providers"},
{label: i18next.t("application:UI Customization"), key: "ui-customization"},
{label: i18next.t("application:Security"), key: "security"},
@@ -1488,6 +1539,8 @@ class ApplicationEditPage extends React.Component {
>
<Menu.Item key="basic">{i18next.t("application:Basic")}</Menu.Item>
<Menu.Item key="authentication">{i18next.t("application:Authentication")}</Menu.Item>
<Menu.Item key="oidc-oauth">OIDC/OAuth</Menu.Item>
<Menu.Item key="saml">SAML</Menu.Item>
<Menu.Item key="providers">{i18next.t("application:Providers")}</Menu.Item>
<Menu.Item key="ui-customization">{i18next.t("application:UI Customization")}</Menu.Item>
<Menu.Item key="security">{i18next.t("application:Security")}</Menu.Item>
@@ -1547,11 +1600,11 @@ class ApplicationEditPage extends React.Component {
{
Setting.isPasswordEnabled(this.state.application) ? (
<div className="loginBackground" style={{backgroundImage: `url(${this.state.application?.formBackgroundUrl})`, overflow: "auto"}}>
<SignupPage application={this.state.application} preview = "auto" />
<SignupPage application={this.state.application} preview="auto" />
</div>
) : (
<div className="loginBackground" style={{backgroundImage: `url(${this.state.application?.formBackgroundUrl})`, overflow: "auto"}}>
<LoginPage type={"login"} mode={"signup"} application={this.state.application} preview = "auto" />
<LoginPage type={"login"} mode={"signup"} application={this.state.application} preview="auto" />
</div>
)
}
@@ -1577,7 +1630,7 @@ class ApplicationEditPage extends React.Component {
}}>
<div style={{position: "relative", width: previewWidth, border: "1px solid rgb(217,217,217)", boxShadow: "10px 10px 5px #888888", overflow: "auto"}}>
<div className="loginBackground" style={{backgroundImage: `url(${this.state.application?.formBackgroundUrl})`, overflow: "auto"}}>
<LoginPage type={"login"} mode={"signin"} application={this.state.application} preview = "auto" />
<LoginPage type={"login"} mode={"signin"} application={this.state.application} preview="auto" />
</div>
<div style={{overflow: "auto", ...maskStyle}} />
</div>
@@ -1621,6 +1674,12 @@ class ApplicationEditPage extends React.Component {
const application = Setting.deepCopy(this.state.application);
application.providers = application.providers?.filter(provider => this.state.providers.map(provider => provider.name).includes(provider.name));
application.signinMethods = application.signinMethods?.filter(signinMethod => ["Password", "Verification code", "WebAuthn", "LDAP", "Face ID", "WeChat"].includes(signinMethod.name));
const customScopeValidation = this.validateCustomScopes(application.customScopes);
application.customScopes = customScopeValidation.scopes;
if (!customScopeValidation.ok) {
Setting.showMessage("error", `${i18next.t("general:Name")}: ${i18next.t("provider:This field is required")}`);
return;
}
ApplicationBackend.updateApplication("admin", this.state.applicationName, application)
.then((res) => {

View File

@@ -81,6 +81,24 @@ class CertEditPage extends React.Component {
value = this.parseCertField(key, value);
const cert = this.state.cert;
const previousType = cert.type;
if (key === "type") {
if (value === "SSL") {
cert.cryptoAlgorithm = "RSA";
cert.certificate = "";
cert.privateKey = "";
} else if (previousType === "SSL" && value !== "SSL") {
// Clear SSL-specific sensitive and derived fields when leaving SSL type
cert.provider = "";
cert.account = "";
cert.accessKey = "";
cert.accessSecret = "";
cert.certificate = "";
cert.privateKey = "";
cert.expireTime = "";
cert.domainExpireTime = "";
}
}
cert[key] = value;
this.setState({
cert: cert,
@@ -157,6 +175,7 @@ class CertEditPage extends React.Component {
})}>
{
[
{id: "SSL", name: "SSL"},
{id: "x509", name: "x509"},
{id: "Payment", name: "Payment"},
].map((item, index) => <Option key={index} value={item.id}>{item.name}</Option>)
@@ -184,7 +203,10 @@ class CertEditPage extends React.Component {
this.updateCertField("privateKey", "");
})}>
{
[
(this.state.cert.type === "SSL" ? [
{id: "RSA", name: "RSA"},
{id: "ECC", name: "ECC"},
] : [
{id: "RS256", name: "RS256 (RSA + SHA256)"},
{id: "RS384", name: "RS384 (RSA + SHA384)"},
{id: "RS512", name: "RS512 (RSA + SHA512)"},
@@ -194,13 +216,13 @@ class CertEditPage extends React.Component {
{id: "PS256", name: "PS256 (RSASSA-PSS using SHA256 and MGF1 with SHA256)"},
{id: "PS384", name: "PS384 (RSASSA-PSS using SHA384 and MGF1 with SHA384)"},
{id: "PS512", name: "PS512 (RSASSA-PSS using SHA512 and MGF1 with SHA512)"},
].map((item, index) => <Option key={index} value={item.id}>{item.name}</Option>)
]).map((item, index) => <Option key={index} value={item.id}>{item.name}</Option>)
}
</Select>
</Col>
</Row>
{
this.state.cert.cryptoAlgorithm.startsWith("ES") ? null : (
this.state.cert.cryptoAlgorithm.startsWith("ES") || this.state.cert.type === "SSL" ? null : (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("cert:Bit size"), i18next.t("cert:Bit size - Tooltip"))} :
@@ -219,16 +241,91 @@ class CertEditPage extends React.Component {
</Row>
)
}
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("cert:Expire in years"), i18next.t("cert:Expire in years - Tooltip"))} :
</Col>
<Col span={22} >
<InputNumber value={this.state.cert.expireInYears} onChange={value => {
this.updateCertField("expireInYears", value);
}} />
</Col>
</Row>
{
this.state.cert.type === "SSL" ? null : (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("cert:Expire in years"), i18next.t("cert:Expire in years - Tooltip"))} :
</Col>
<Col span={22} >
<InputNumber value={this.state.cert.expireInYears} onChange={value => {
this.updateCertField("expireInYears", value);
}} />
</Col>
</Row>
)
}
{
this.state.cert.type === "SSL" ? (
<React.Fragment>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={2}>
{i18next.t("cert:Expire time")}:
</Col>
<Col span={22} >
<Input disabled={true} value={Setting.getFormattedDate(this.state.cert.expireTime)} onChange={e => {
this.updateCertField("expireTime", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={2}>
{i18next.t("cert:Domain expire")}:
</Col>
<Col span={22} >
<Input disabled={true} value={Setting.getFormattedDate(this.state.cert.domainExpireTime)} onChange={e => {
this.updateCertField("domainExpireTime", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={2}>
{i18next.t("cert:Provider")}:
</Col>
<Col span={22} >
<Select virtual={false} style={{width: "100%"}} value={this.state.cert.provider} onChange={(value => {this.updateCertField("provider", value);})}>
{
[
{id: "GoDaddy", name: "GoDaddy"},
{id: "Aliyun", name: "Aliyun"},
].map((item, index) => <Option key={index} value={item.id}>{item.name}</Option>)
}
</Select>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={2}>
{i18next.t("cert:Account")}:
</Col>
<Col span={22} >
<Input value={this.state.cert.account} onChange={e => {
this.updateCertField("account", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={2}>
{i18next.t("cert:Access key")}:
</Col>
<Col span={22} >
<Input value={this.state.cert.accessKey} onChange={e => {
this.updateCertField("accessKey", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={2}>
{i18next.t("cert:Access secret")}:
</Col>
<Col span={22} >
<Input.Password value={this.state.cert.accessSecret} onChange={e => {
this.updateCertField("accessSecret", e.target.value);
}} />
</Col>
</Row>
</React.Fragment>
) : null
}
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("cert:Certificate"), i18next.t("cert:Certificate - Tooltip"))} :

View File

@@ -88,6 +88,28 @@ class CertListPage extends BaseListPage {
});
}
refreshCert(i) {
const cert = this.state.data[i];
CertBackend.refreshDomainExpire(cert.owner, cert.name)
.then((res) => {
if (res.status === "error") {
Setting.showMessage("error", `Failed to refresh domain expire: ${res.msg}`);
} else {
Setting.showMessage("success", "Domain expire refreshed successfully");
this.fetch({
pagination: {
...this.state.pagination,
current: this.state.pagination.current > 1 && this.state.data.length === 1 ? this.state.pagination.current - 1 : this.state.pagination.current,
},
});
}
}
)
.catch(error => {
Setting.showMessage("error", `Domain expire failed to refresh: ${error}`);
});
}
renderTable(certs) {
const columns = [
{
@@ -194,6 +216,12 @@ class CertListPage extends BaseListPage {
render: (text, record, index) => {
return (
<div>
{
record.type === "SSL" ? (
<Button disabled={!Setting.isAdminUser(this.props.account) && (record.owner !== this.props.account.owner)} style={{margin: "10px 10px 10px 0"}} type="default" onClick={() => this.refreshCert(index)}>{i18next.t("general:Refresh")}
</Button>
) : null
}
<Button disabled={!Setting.isAdminUser(this.props.account) && (record.owner !== this.props.account.owner)} style={{marginTop: "10px", marginBottom: "10px", marginRight: "10px"}} type="primary" onClick={() => this.props.history.push(`/certs/${record.owner}/${record.name}`)}>{i18next.t("general:Edit")}</Button>
<PopconfirmModal
disabled={!Setting.isAdminUser(this.props.account) && (record.owner !== this.props.account.owner)}

View File

@@ -26,6 +26,7 @@ import LoginPage from "./auth/LoginPage";
import SelfForgetPage from "./auth/SelfForgetPage";
import ForgetPage from "./auth/ForgetPage";
import PromptPage from "./auth/PromptPage";
import ConsentPage from "./auth/ConsentPage";
import ResultPage from "./auth/ResultPage";
import CasLogout from "./auth/CasLogout";
import {authConfig} from "./auth/Auth";
@@ -125,6 +126,7 @@ class EntryPage extends React.Component {
<Route exact path="/forget/:applicationName" render={(props) => <ForgetPage {...this.props} account={this.props.account} application={this.state.application} onUpdateApplication={onUpdateApplication} {...props} />} />
<Route exact path="/prompt" render={(props) => this.renderLoginIfNotLoggedIn(<PromptPage {...this.props} application={this.state.application} onUpdateApplication={onUpdateApplication} {...props} />)} />
<Route exact path="/prompt/:applicationName" render={(props) => this.renderLoginIfNotLoggedIn(<PromptPage {...this.props} application={this.state.application} onUpdateApplication={onUpdateApplication} {...props} />)} />
<Route exact path="/consent/:applicationName" render={(props) => this.renderLoginIfNotLoggedIn(<ConsentPage {...this.props} application={this.state.application} onUpdateApplication={onUpdateApplication} {...props} />)} />
<Route exact path="/result" render={(props) => this.renderHomeIfLoggedIn(<ResultPage {...this.props} application={this.state.application} onUpdateApplication={onUpdateApplication} {...props} />)} />
<Route exact path="/result/:applicationName" render={(props) => this.renderHomeIfLoggedIn(<ResultPage {...this.props} application={this.state.application} onUpdateApplication={onUpdateApplication} {...props} />)} />
<Route exact path="/cas/:owner/:casApplicationName/logout" render={(props) => this.renderHomeIfLoggedIn(<CasLogout {...this.props} application={this.state.application} onUpdateApplication={onUpdateApplication} {...props} />)} />

View File

@@ -20,7 +20,7 @@ import React, {useState} from "react";
import i18next from "i18next";
import {
AppstoreTwoTone,
BarsOutlined, DeploymentUnitOutlined, DollarTwoTone, DownOutlined,
BarsOutlined, CheckCircleTwoTone, DeploymentUnitOutlined, DollarTwoTone, DownOutlined,
HomeTwoTone,
LockTwoTone, LogoutOutlined,
SafetyCertificateTwoTone, SettingOutlined, SettingTwoTone,
@@ -104,6 +104,10 @@ import TicketListPage from "./TicketListPage";
import TicketEditPage from "./TicketEditPage";
import * as Cookie from "cookie";
import * as UserBackend from "./backend/UserBackend";
import SiteListPage from "./SiteListPage";
import SiteEditPage from "./SiteEditPage";
import RuleEditPage from "./RuleEditPage";
import RuleListPage from "./RuleListPage";
function ManagementPage(props) {
const [menuVisible, setMenuVisible] = useState(false);
@@ -193,12 +197,12 @@ function ManagementPage(props) {
{
renderAvatar()
}
&nbsp;
&nbsp;
&nbsp;
&nbsp;
{Setting.isMobile() ? null : Setting.getShortText(Setting.getNameAtLeast(props.account.displayName), 30)} &nbsp; <DownOutlined />
&nbsp;
&nbsp;
&nbsp;
&nbsp;
&nbsp;
&nbsp;
</div>
</Dropdown>
);
@@ -252,15 +256,15 @@ function ManagementPage(props) {
{renderRightDropdown()}
{renderWidgets()}
{Setting.isAdminUser(props.account) && (props.uri.indexOf("/trees") === -1) &&
<OrganizationSelect
initValue={Setting.getOrganization()}
withAll={true}
className="org-select"
style={{display: Setting.isMobile() ? "none" : "flex"}}
onChange={(value) => {
Setting.setOrganization(value);
}}
/>
<OrganizationSelect
initValue={Setting.getOrganization()}
withAll={true}
className="org-select"
style={{display: Setting.isMobile() ? "none" : "flex"}}
onChange={(value) => {
Setting.setOrganization(value);
}}
/>
}
</React.Fragment>
);
@@ -287,9 +291,9 @@ function ManagementPage(props) {
!Setting.isMobile() ? res.push({
label:
<Link to="/">
<img className="logo" src={logo ?? props.logo} alt="logo" />
</Link>,
<Link to="/">
<img className="logo" src={logo ?? props.logo} alt="logo" />
</Link>,
disabled: true, key: "logo",
style: {
padding: 0,
@@ -323,6 +327,8 @@ function ManagementPage(props) {
Setting.getItem(<Link to="/providers">{i18next.t("application:Providers")}</Link>, "/providers"),
Setting.getItem(<Link to="/resources">{i18next.t("general:Resources")}</Link>, "/resources"),
Setting.getItem(<Link to="/certs">{i18next.t("general:Certs")}</Link>, "/certs"),
Setting.getItem(<Link to="/sites">{i18next.t("general:Sites")}</Link>, "/sites"),
Setting.getItem(<Link to="/rules">{i18next.t("general:Rules")}</Link>, "/rules"),
]));
res.push(Setting.getItem(<Link style={{color: textColor}} to="/roles">{i18next.t("general:Authorization")}</Link>, "/auth", <SafetyCertificateTwoTone twoToneColor={twoToneColor} />, [
@@ -339,6 +345,12 @@ function ManagementPage(props) {
}
})));
res.push(Setting.getItem(<Link style={{color: textColor}} to="/sites">{i18next.t("general:Gateway")}</Link>, "/gateway", <CheckCircleTwoTone twoToneColor={twoToneColor} />, [
Setting.getItem(<Link to="/sites">{i18next.t("general:Sites")}</Link>, "/sites"),
Setting.getItem(<Link to="/certs">{i18next.t("general:Certs")}</Link>, "/certs"),
Setting.getItem(<Link to="/rules">{i18next.t("general:Rules")}</Link>, "/rules"),
]));
res.push(Setting.getItem(<Link style={{color: textColor}} to="/sessions">{i18next.t("general:Logging & Auditing")}</Link>, "/logs", <WalletTwoTone twoToneColor={twoToneColor} />, [
Setting.getItem(<Link to="/sessions">{i18next.t("general:Sessions")}</Link>, "/sessions"),
Conf.CasvisorUrl ? Setting.getItem(<a target="_blank" rel="noreferrer" href={Conf.CasvisorUrl}>{i18next.t("general:Records")}</a>, "/records")
@@ -473,6 +485,10 @@ function ManagementPage(props) {
<Route exact path="/resources" render={(props) => renderLoginIfNotLoggedIn(<ResourceListPage account={account} {...props} />)} />
<Route exact path="/certs" render={(props) => renderLoginIfNotLoggedIn(<CertListPage account={account} {...props} />)} />
<Route exact path="/certs/:organizationName/:certName" render={(props) => renderLoginIfNotLoggedIn(<CertEditPage account={account} {...props} />)} />
<Route exact path="/sites" render={(props) => renderLoginIfNotLoggedIn(<SiteListPage account={account} {...props} />)} />
<Route exact path="/sites/:organizationName/:siteName" render={(props) => renderLoginIfNotLoggedIn(<SiteEditPage account={account} {...props} />)} />
<Route exact path="/rules" render={(props) => renderLoginIfNotLoggedIn(<RuleListPage account={account} {...props} />)} />
<Route exact path="/rules/:organizationName/:ruleName" render={(props) => renderLoginIfNotLoggedIn(<RuleEditPage account={account} {...props} />)} />
<Route exact path="/verifications" render={(props) => renderLoginIfNotLoggedIn(<VerificationListPage account={account} {...props} />)} />
<Route exact path="/roles" render={(props) => renderLoginIfNotLoggedIn(<RoleListPage account={account} {...props} />)} />
<Route exact path="/roles/:organizationName/:roleName" render={(props) => renderLoginIfNotLoggedIn(<RoleEditPage account={account} {...props} />)} />

View File

@@ -564,7 +564,7 @@ class OrganizationEditPage extends React.Component {
{Setting.getLabel(i18next.t("organization:Balance credit"), i18next.t("organization:Balance credit - Tooltip"))} :
</Col>
<Col span={4} >
<InputNumber value={this.state.organization.balanceCredit ?? 0} onChange={value => {
<InputNumber value={this.state.organization.balanceCredit ?? 0} max={0} onChange={value => {
this.updateOrganizationField("balanceCredit", value);
}} />
</Col>

View File

@@ -57,6 +57,8 @@ class OrganizationListPage extends BaseListPage {
{name: "ID", visible: true, viewRule: "Public", modifyRule: "Immutable"},
{name: "Name", visible: true, viewRule: "Public", modifyRule: "Admin"},
{name: "Display name", visible: true, viewRule: "Public", modifyRule: "Self"},
{name: "First name", visible: true, viewRule: "Public", modifyRule: "Self"},
{name: "Last name", visible: true, viewRule: "Public", modifyRule: "Self"},
{name: "Avatar", visible: true, viewRule: "Public", modifyRule: "Self"},
{name: "User type", visible: true, viewRule: "Public", modifyRule: "Admin"},
{name: "Password", visible: true, viewRule: "Self", modifyRule: "Self"},
@@ -66,6 +68,7 @@ class OrganizationListPage extends BaseListPage {
{name: "Country/Region", visible: true, viewRule: "Public", modifyRule: "Self"},
{name: "Location", visible: true, viewRule: "Public", modifyRule: "Self"},
{name: "Address", visible: true, viewRule: "Public", modifyRule: "Self"},
{name: "Addresses", visible: true, viewRule: "Public", modifyRule: "Self"},
{name: "Affiliation", visible: true, viewRule: "Public", modifyRule: "Self"},
{name: "Title", visible: true, viewRule: "Public", modifyRule: "Self"},
{name: "ID card type", visible: true, viewRule: "Public", modifyRule: "Self"},
@@ -86,6 +89,8 @@ class OrganizationListPage extends BaseListPage {
{name: "Balance", visible: true, viewRule: "Public", modifyRule: "Admin"},
{name: "Balance credit", visible: true, viewRule: "Public", modifyRule: "Admin"},
{name: "Balance currency", visible: true, viewRule: "Public", modifyRule: "Admin"},
{name: "Cart", visible: true, viewRule: "Self", modifyRule: "Self"},
{name: "Transactions", visible: true, viewRule: "Self", modifyRule: "Self"},
{name: "Signup application", visible: true, viewRule: "Public", modifyRule: "Admin"},
{name: "Register type", visible: true, viewRule: "Public", modifyRule: "Admin"},
{name: "Register source", visible: true, viewRule: "Public", modifyRule: "Admin"},
@@ -93,16 +98,22 @@ class OrganizationListPage extends BaseListPage {
{name: "Groups", visible: true, viewRule: "Public", modifyRule: "Admin"},
{name: "Roles", visible: true, viewRule: "Public", modifyRule: "Immutable"},
{name: "Permissions", visible: true, viewRule: "Public", modifyRule: "Immutable"},
{name: "Consents", visible: true, viewRule: "Self", modifyRule: "Self"},
{name: "3rd-party logins", visible: true, viewRule: "Self", modifyRule: "Self"},
{name: "Properties", visible: false, viewRule: "Admin", modifyRule: "Admin"},
{name: "Is online", visible: true, viewRule: "Admin", modifyRule: "Admin"},
{name: "Is admin", visible: true, viewRule: "Admin", modifyRule: "Admin"},
{name: "Is forbidden", visible: true, viewRule: "Admin", modifyRule: "Admin"},
{name: "Is deleted", visible: true, viewRule: "Admin", modifyRule: "Admin"},
{Name: "Multi-factor authentication", Visible: true, ViewRule: "Self", ModifyRule: "Self"},
{Name: "WebAuthn credentials", Visible: true, ViewRule: "Self", ModifyRule: "Self"},
{Name: "Managed accounts", Visible: true, ViewRule: "Self", ModifyRule: "Self"},
{Name: "MFA accounts", Visible: true, ViewRule: "Self", ModifyRule: "Self"},
{name: "Multi-factor authentication", visible: true, viewRule: "Self", modifyRule: "Self"},
{name: "MFA items", visible: true, viewRule: "Self", modifyRule: "Self"},
{name: "WebAuthn credentials", visible: true, viewRule: "Self", modifyRule: "Self"},
{name: "Last change password time", visible: true, viewRule: "Admin", modifyRule: "Admin"},
{name: "Managed accounts", visible: true, viewRule: "Self", modifyRule: "Self"},
{name: "Face ID", visible: true, viewRule: "Self", modifyRule: "Self"},
{name: "MFA accounts", visible: true, viewRule: "Self", modifyRule: "Self"},
{name: "Need update password", visible: true, viewRule: "Admin", modifyRule: "Admin"},
{name: "IP whitelist", visible: true, viewRule: "Admin", modifyRule: "Admin"},
],
};
}

View File

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

View File

@@ -14,7 +14,7 @@
import React from "react";
import {Link} from "react-router-dom";
import {Button, List, Table, Tooltip} from "antd";
import {Button, Col, List, Row, Table, Tooltip} from "antd";
import moment from "moment";
import * as Setting from "./Setting";
import * as PaymentBackend from "./backend/PaymentBackend";
@@ -195,21 +195,31 @@ class PaymentListPage extends BaseListPage {
paddingBottom: 8,
}}
renderItem={(productInfo, i) => {
const price = productInfo.price * (productInfo.quantity || 1);
const price = productInfo.price || 0;
const number = productInfo.quantity || 1;
const currency = record.currency || "USD";
const productName = productInfo.displayName || productInfo.name;
return (
<List.Item>
<div style={{display: "inline"}}>
<Tooltip placement="topLeft" title={i18next.t("general:Edit")}>
<Button style={{marginRight: "5px"}} icon={<EditOutlined />} size="small" onClick={() => Setting.goToLinkSoft(this, `/products/${record.owner}/${productInfo.name}`)} />
</Tooltip>
<Link to={`/products/${record.owner}/${productInfo.name}`}>
{productInfo.displayName || productInfo.name}
</Link>
<span style={{marginLeft: "8px", color: "#666"}}>
{Setting.getPriceDisplay(price, currency)}
</span>
</div>
<Row style={{width: "100%"}} wrap={false} gutter={[12, 0]}>
<Col flex="auto" style={{minWidth: 0}}>
<div style={{display: "flex", alignItems: "center", minWidth: 0}}>
<Tooltip placement="topLeft" title={i18next.t("general:Edit")}>
<Button style={{marginRight: "5px"}} icon={<EditOutlined />} size="small" onClick={() => Setting.goToLinkSoft(this, `/products/${record.owner}/${productInfo.name}`)} />
</Tooltip>
<Tooltip placement="topLeft" title={productName}>
<Link to={`/products/${record.owner}/${productInfo.name}`} style={{display: "inline-block", maxWidth: "100%", minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap"}}>
{productName}
</Link>
</Tooltip>
</div>
</Col>
<Col flex="none" style={{whiteSpace: "nowrap"}}>
<span style={{color: "#666"}}>
{Setting.getCurrencySymbol(currency)}{price} ({Setting.getCurrencyText(currency)}) × {number}
</span>
</Col>
</Row>
</List.Item>
);
}}

View File

@@ -307,16 +307,6 @@ class ProductEditPage extends React.Component {
</Select>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("product:Return URL"), i18next.t("product:Return URL - Tooltip"))} :
</Col>
<Col span={22} >
<Input prefix={<LinkOutlined />} value={this.state.product.returnUrl} disabled={isViewMode} onChange={e => {
this.updateProductField("returnUrl", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("product:Success URL"), i18next.t("product:Success URL - Tooltip"))} :

File diff suppressed because it is too large Load Diff

View File

@@ -137,7 +137,7 @@ class RecordListPage extends BaseListPage {
title: i18next.t("record:Status code"),
dataIndex: "statusCode",
key: "statusCode",
width: "120px",
width: "140px",
sorter: true,
...this.getColumnSearchProps("statusCode"),
},

309
web/src/RuleEditPage.js Normal file
View File

@@ -0,0 +1,309 @@
// Copyright 2023 The casbin Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import React from "react";
import {Button, Card, Col, Input, InputNumber, Row, Select, Switch} from "antd";
import * as Setting from "./Setting";
import * as RuleBackend from "./backend/RuleBackend";
import i18next from "i18next";
import WafRuleTable from "./table/WafRuleTable";
import IpRuleTable from "./table/IpRuleTable";
import UaRuleTable from "./table/UaRuleTable";
import IpRateRuleTable from "./table/IpRateRuleTable";
import CompoundRule from "./common/CompoundRule";
import * as OrganizationBackend from "./backend/OrganizationBackend";
const {Option} = Select;
class RuleEditPage extends React.Component {
constructor(props) {
super(props);
this.state = {
classes: props,
owner: props.match.params.organizationName,
ruleName: props.match.params.ruleName,
rule: null,
organizations: [],
};
}
UNSAFE_componentWillMount() {
this.getRule();
this.getOrganizations();
}
getRule() {
RuleBackend.getRule(this.state.owner, this.state.ruleName).then((res) => {
this.setState({
rule: res.data,
});
});
}
updateRuleField(key, value) {
const rule = Setting.deepCopy(this.state.rule);
rule[key] = value;
if (key === "type") {
rule.expressions = [];
}
this.setState({
rule: rule,
});
}
updateRuleFieldInExpressions(index, key, value) {
const rule = Setting.deepCopy(this.state.rule);
rule.expressions[index][key] = value;
this.updateRuleField("expressions", rule.expressions);
this.setState({
rule: rule,
});
}
getOrganizations() {
if (Setting.isAdminUser(this.props.account)) {
OrganizationBackend.getOrganizations("admin")
.then((res) => {
this.setState({
organizations: res.data || [],
});
});
}
}
renderRule() {
return (
<Card size="small" title={
<div>
{i18next.t("rule:Edit Rule")}&nbsp;&nbsp;&nbsp;&nbsp;
<Button type="primary" onClick={this.submitRuleEdit.bind(this)}>{i18next.t("general:Save")}</Button>
</div>
} style={{marginTop: 10}} type="inner">
<Row style={{marginTop: "20px"}}>
<Col span={2} style={{marginTop: "5px"}}>
{Setting.getLabel(i18next.t("general:Organization"), i18next.t("general:Organization - Tooltip"))} :
</Col>
<Col span={22} >
<Select virtual={false} style={{width: "100%"}} disabled={!Setting.isAdminUser(this.props.account)} value={this.state.rule.owner} onChange={(value => {
this.updateRuleField("owner", value);
})}>
{
this.state.organizations.map((organization, index) => <Option key={index} value={organization.name}>{organization.name}</Option>)
}
</Select>
</Col>
</Row>
<Row style={{marginTop: "20px"}}>
<Col span={2} style={{marginTop: "5px"}}>
{i18next.t("general:Name")}:
</Col>
<Col span={22}>
<Input value={this.state.rule.name} onChange={e => {
this.updateRuleField("name", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}}>
<Col span={2} style={{marginTop: "5px"}}>
{i18next.t("rule:Type")}:
</Col>
<Col span={22}>
<Select virtual={false} value={this.state.rule.type} style={{width: "100%"}} onChange={value => {
this.updateRuleField("type", value);
}}>
{
[
{value: "WAF", text: "WAF"},
{value: "IP", text: "IP"},
{value: "User-Agent", text: "User-Agent"},
{value: "IP Rate Limiting", text: i18next.t("rule:IP Rate Limiting")},
{value: "Compound", text: i18next.t("rule:Compound")},
].map((item, index) => <Option key={index} value={item.value}>{item.text}</Option>)
}
</Select>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={2}>
{i18next.t("rule:Expressions")}:
</Col>
<Col span={22} >
{
this.state.rule.type === "WAF" ? (
<WafRuleTable
title={"Seclang"}
table={this.state.rule.expressions}
ruleName={this.state.rule.name}
account={this.props.account}
onUpdateTable={(value) => {this.updateRuleField("expressions", value);}}
/>
) : null
}
{
this.state.rule.type === "IP" ? (
<IpRuleTable
title={"IPs"}
table={this.state.rule.expressions}
ruleName={this.state.rule.name}
account={this.props.account}
onUpdateTable={(value) => {this.updateRuleField("expressions", value);}}
/>
) : null
}
{
this.state.rule.type === "User-Agent" ? (
<UaRuleTable
title={"User-Agents"}
table={this.state.rule.expressions}
ruleName={this.state.rule.name}
account={this.props.account}
onUpdateTable={(value) => {this.updateRuleField("expressions", value);}}
/>
) : null
}
{
this.state.rule.type === "IP Rate Limiting" ? (
<IpRateRuleTable
title={i18next.t("rule:IP Rate Limiting")}
table={this.state.rule.expressions}
ruleName={this.state.rule.name}
account={this.props.account}
onUpdateTable={(value) => {this.updateRuleField("expressions", value);}}
/>
) : null
}
{
this.state.rule.type === "Compound" ? (
<CompoundRule
title={i18next.t("rule:Compound")}
table={this.state.rule.expressions}
ruleName={this.state.rule.name}
owner={this.state.owner}
onUpdateTable={(value) => {this.updateRuleField("expressions", value);}} />
) : null
}
</Col>
</Row>
{
this.state.rule.type !== "WAF" && (
<Row style={{marginTop: "20px"}}>
<Col span={2} style={{marginTop: "5px"}}>
{i18next.t("general:Action")}:
</Col>
<Col span={22}>
<Select virtual={false} value={this.state.rule.action} defaultValue={"Block"} style={{width: "100%"}} onChange={(value) => {
this.updateRuleField("action", value);
}}>
{
[
{value: "Allow", text: i18next.t("rule:Allow")},
{value: "Block", text: i18next.t("rule:Block")},
].map((item, index) => <Option key={index} value={item.value}>{item.text}</Option>)
}
</Select>
</Col>
</Row>
)
}
{
this.state.rule.type !== "WAF" && (this.state.rule.action === "Allow" || this.state.rule.action === "Block") && (
<Row style={{marginTop: "20px"}}>
<Col span={2} style={{marginTop: "5px"}}>
{i18next.t("rule:Status code")}:
</Col>
<Col span={22}>
<InputNumber value={this.state.rule.statusCode} min={100} max={599} onChange={e => {
this.updateRuleField("statusCode", e);
}} />
</Col>
</Row>
)
}
{
<Row style={{marginTop: "20px"}}>
<Col span={2} style={{marginTop: "5px"}}>
{i18next.t("rule:Reason")}:
</Col>
<Col span={22}>
<Input value={this.state.rule.reason}
onChange={e => {
this.updateRuleField("reason", e.target.value);
}} />
</Col>
</Row>
}
{
<Row style={{marginTop: "20px"}}>
<Col span={2} style={{marginTop: "5px"}}>
{i18next.t("rule:Verbose mode")}:
</Col>
<Col span={22}>
<Switch checked={this.state.rule.isVerbose}
onChange={checked => {
this.updateRuleField("isVerbose", checked);
}} />
</Col>
</Row>
}
</Card>
);
}
render() {
return (
<div>
<Row style={{width: "100%"}}>
<Col span={1}>
</Col>
<Col span={22}>
{
this.state.rule !== null ? this.renderRule() : null
}
</Col>
<Col span={1}>
</Col>
</Row>
<Row style={{margin: 10}}>
<Col span={2}>
</Col>
<Col span={18}>
<Button type="primary" size="large" onClick={this.submitRuleEdit.bind(this)}>{i18next.t("general:Save")}</Button>
</Col>
</Row>
</div>
);
}
submitRuleEdit() {
const rule = Setting.deepCopy(this.state.rule);
RuleBackend.updateRule(this.state.owner, this.state.ruleName, rule)
.then((res) => {
if (res.status !== "error") {
Setting.showMessage("success", "Rule updated successfully");
this.setState({
rule: rule,
});
} else {
Setting.showMessage("error", `Rule failed to update: ${res.msg}`);
this.setState({
ruleName: this.state.rule.name,
});
this.props.history.push(`/rules/${this.state.rule.owner}/${this.state.rule.name}`);
this.getRule();
}
});
}
}
export default RuleEditPage;

236
web/src/RuleListPage.js Normal file
View File

@@ -0,0 +1,236 @@
// Copyright 2021 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import React from "react";
import {Button, Popconfirm, Table, Tag} from "antd";
import moment from "moment";
import * as Setting from "./Setting";
import * as RuleBackend from "./backend/RuleBackend";
import i18next from "i18next";
import BaseListPage from "./BaseListPage";
class RuleListPage extends BaseListPage {
UNSAFE_componentWillMount() {
this.setState({
pagination: {
...this.state.pagination,
current: 1,
pageSize: 10,
},
});
this.fetch({pagination: this.state.pagination});
}
fetch = (params = {}) => {
const sortField = params.sortField, sortOrder = params.sortOrder;
if (!params.pagination) {
params.pagination = {current: 1, pageSize: 10};
}
this.setState({
loading: true,
});
RuleBackend.getRules(this.props.account.owner, params.pagination.current, params.pagination.pageSize, sortField, sortOrder).then((res) => {
this.setState({
loading: false,
});
if (res.status === "ok") {
this.setState({
data: res.data,
pagination: {
...params.pagination,
total: res.data2,
},
});
} else {
this.setState({loading: false});
}
});
};
addRule() {
const newRule = this.newRule();
RuleBackend.addRule(newRule).then((res) => {
if (res.status === "error") {
Setting.showMessage("error", `Failed to add: ${res.msg}`);
} else {
Setting.showMessage("success", "Rule added successfully");
this.setState({
data: Setting.prependRow(this.state.data, newRule),
});
this.fetch();
}
});
}
deleteRule(i) {
RuleBackend.deleteRule(this.state.data[i]).then((res) => {
if (res.status === "error") {
Setting.showMessage("error", `Failed to delete: ${res.msg}`);
} else {
Setting.showMessage("success", "Deleted successfully");
this.fetch({
pagination: {
...this.state.pagination,
current: this.state.pagination.current > 1 && this.state.data.length === 1 ? this.state.pagination.current - 1 : this.state.pagination.current,
},
});
}
});
}
newRule() {
const randomName = Setting.getRandomName();
const owner = Setting.getRequestOrganization(this.props.account);
return {
owner: owner,
name: `rule_${randomName}`,
createdTime: moment().format(),
type: "User-Agent",
expressions: [],
action: "Block",
reason: "Your request is blocked.",
};
}
renderTable(data) {
const columns = [
{
title: i18next.t("general:Owner"),
dataIndex: "owner",
key: "owner",
width: "150px",
sorter: (a, b) => a.owner.localeCompare(b.owner),
},
{
title: i18next.t("general:Name"),
dataIndex: "name",
key: "name",
width: "200px",
sorter: (a, b) => a.name.localeCompare(b.name),
render: (text, rule, index) => {
return <a href={`/rules/${rule.owner}/${text}`}>{text}</a>;
},
},
{
title: i18next.t("general:Create time"),
dataIndex: "createdTime",
key: "createdTime",
width: "200px",
sorter: (a, b) => a.createdTime.localeCompare(b.createdTime),
render: (text, rule, index) => {
return Setting.getFormattedDate(text);
},
},
{
title: i18next.t("general:Update time"),
dataIndex: "updatedTime",
key: "updatedTime",
width: "200px",
sorter: (a, b) => a.updatedTime.localeCompare(b.updatedTime),
render: (text, rule, index) => {
return Setting.getFormattedDate(text);
},
},
{
title: i18next.t("rule:Type"),
dataIndex: "type",
key: "type",
width: "100px",
sorter: (a, b) => a.type.localeCompare(b.type),
render: (text, rule, index) => {
return (
<Tag color="blue">
{i18next.t(`rule:${text}`)}
</Tag>
);
},
},
{
title: i18next.t("rule:Expressions"),
dataIndex: "expressions",
key: "expressions",
sorter: (a, b) => a.expressions.localeCompare(b.expressions),
render: (text, rule, index) => {
return rule.expressions.map((expression, i) => {
return (
<Tag key={expression} color={"success"}>
{expression.operator + " " + expression.value.slice(0, 20)}
</Tag>
);
});
},
},
{
title: i18next.t("general:Action"),
dataIndex: "action",
key: "action",
width: "100px",
sorter: (a, b) => a.action.localeCompare(b.action),
},
{
title: i18next.t("rule:Status code"),
dataIndex: "statusCode",
key: "statusCode",
width: "120px",
sorter: (a, b) => a.statusCode.localeCompare(b.statusCode),
},
{
title: i18next.t("rule:Reason"),
dataIndex: "reason",
key: "reason",
width: "300px",
sorter: (a, b) => a.reason.localeCompare(b.reason),
},
{
title: i18next.t("general:Action"),
dataIndex: "",
key: "op",
render: (text, rule, index) => {
return (
<div>
<Popconfirm
title={`Sure to delete rule: ${rule.name} ?`}
onConfirm={() => this.deleteRule(index)}
>
<Button style={{marginTop: "10px", marginBottom: "10px", marginRight: "10px"}} type="primary" onClick={() => this.props.history.push(`/rules/${rule.owner}/${rule.name}`)}>{i18next.t("general:Edit")}</Button>
<Button type="danger">{i18next.t("general:Delete")}</Button>
</Popconfirm>
</div>
);
},
},
];
return (
<Table
dataSource={data}
columns={columns}
rowKey="name"
pagination={this.state.pagination}
loading={this.state.loading}
onChange={this.handleTableChange}
size="middle"
bordered
title={() => (
<div>
{i18next.t("general:Rules")}&nbsp;&nbsp;&nbsp;&nbsp;
<Button type="primary" size="small" onClick={() => this.addRule()}>{i18next.t("general:Add")}</Button>
</div>
)}
/>
);
}
}
export default RuleListPage;

View File

@@ -182,6 +182,10 @@ export const OtherProviderInfo = {
logo: `${StaticBaseUrl}/img/social_default.png`,
url: "https://casdoor.org/docs/provider/email/overview",
},
"Resend": {
logo: `${StaticBaseUrl}/img/email_resend.png`,
url: "https://resend.com/",
},
},
Storage: {
"Local File System": {
@@ -457,8 +461,8 @@ export const UserFields = ["owner", "name", "password", "display_name", "id", "t
"is_admin", "homepage", "birthday", "gender", "password_type", "password_salt", "external_id", "avatar", "first_name", "last_name",
"avatar_type", "permanent_avatar", "email_verified", "region", "location", "address",
"affiliation", "title", "id_card_type", "id_card", "real_name", "is_verified", "bio", "tag", "language",
"education", "score", "karma", "ranking", "balance", "currency", "is_default_avatar", "is_online",
"is_forbidden", "is_deleted", "signup_application", "hash", "pre_hash", "access_key", "access_secret", "access_token",
"education", "score", "karma", "ranking", "balance", "balance_credit", "balance_currency", "currency", "is_default_avatar", "is_online",
"is_forbidden", "is_deleted", "signup_application", "register_type", "register_source", "hash", "pre_hash", "access_key", "access_secret", "access_token",
"created_ip", "last_signin_time", "last_signin_ip", "github", "google", "qq", "wechat", "facebook", "dingtalk",
"weibo", "gitee", "linkedin", "wecom", "lark", "gitlab", "adfs", "baidu", "alipay", "casdoor", "infoflow", "apple",
"azuread", "azureadb2c", "slack", "steam", "bilibili", "okta", "douyin", "kwai", "line", "amazon", "auth0",
@@ -469,7 +473,7 @@ export const UserFields = ["owner", "name", "password", "display_name", "id", "t
"wepay", "xero", "yahoo", "yammer", "yandex", "zoom", "metamask", "web3onboard", "custom", "webauthnCredentials",
"preferred_mfa_type", "recovery_codes", "totp_secret", "mfa_phone_enabled", "mfa_email_enabled", "invitation",
"invitation_code", "face_ids", "ldap", "properties", "roles", "permissions", "groups", "last_change_password_time",
"last_signin_wrong_time", "signin_wrong_times", "managedAccounts", "mfaAccounts", "need_update_password",
"last_signin_wrong_time", "signin_wrong_times", "managedAccounts", "mfaAccounts", "mfaItems", "need_update_password",
"created_time", "updated_time", "deleted_time",
"ip_whitelist"];
@@ -500,6 +504,7 @@ export const GetTranslatedUserItems = () => {
{name: "Country/Region", label: i18next.t("user:Country/Region")},
{name: "Location", label: i18next.t("user:Location")},
{name: "Address", label: i18next.t("user:Address")},
{name: "Addresses", label: i18next.t("user:Addresses")},
{name: "Affiliation", label: i18next.t("user:Affiliation")},
{name: "Title", label: i18next.t("general:Title")},
{name: "ID card type", label: i18next.t("user:ID card type")},
@@ -523,6 +528,8 @@ export const GetTranslatedUserItems = () => {
{name: "Karma", label: i18next.t("user:Karma")},
{name: "Ranking", label: i18next.t("user:Ranking")},
{name: "Signup application", label: i18next.t("general:Signup application")},
{name: "Register type", label: i18next.t("user:Register type")},
{name: "Register source", label: i18next.t("user:Register source")},
{name: "API key", label: i18next.t("general:API key")},
{name: "Groups", label: i18next.t("general:Groups")},
{name: "Roles", label: i18next.t("general:Roles")},
@@ -537,6 +544,7 @@ export const GetTranslatedUserItems = () => {
{name: "IP whitelist", label: i18next.t("general:IP whitelist")},
{name: "Multi-factor authentication", label: i18next.t("mfa:Multi-factor authentication")},
{name: "WebAuthn credentials", label: i18next.t("user:WebAuthn credentials")},
{name: "Last change password time", label: i18next.t("user:Last change password time")},
{name: "Managed accounts", label: i18next.t("user:Managed accounts")},
{name: "Face ID", label: i18next.t("login:Face ID")},
{name: "MFA accounts", label: i18next.t("user:MFA accounts")},
@@ -554,6 +562,8 @@ export function getUserColumns() {
transField = "Country/Region";
} else if (field === "mfaAccounts") {
transField = "MFA accounts";
} else if (field === "mfaItems") {
transField = "MFA items";
} else if (field === "face_ids") {
transField = "Face ID";
} else if (field === "managedAccounts") {
@@ -1291,6 +1301,7 @@ export function getProviderTypeOptions(category) {
{id: "Azure ACS", name: "Azure ACS"},
{id: "SendGrid", name: "SendGrid"},
{id: "Custom HTTP Email", name: "Custom HTTP Email"},
{id: "Resend", name: "Resend"},
]
);
} else if (category === "SMS") {
@@ -2416,3 +2427,48 @@ export function getApiPaths() {
return res;
}
export function getItemId(item) {
return item.owner + "/" + item.name;
}
export function getVersionInfo(text, siteName) {
if (text === "") {
return null;
}
try {
const versionInfo = JSON.parse(text);
const link = versionInfo?.version !== "" ? `${getRepoUrl(siteName)}/releases/tag/${versionInfo?.version}` : "";
let versionText = versionInfo?.version !== "" ? versionInfo?.version : "Unknown version";
if (versionInfo?.commitOffset > 0) {
versionText += ` (ahead+${versionInfo?.commitOffset})`;
}
return {text: versionText, link: link};
} catch (e) {
return {text: "", link: ""};
}
}
export function prependRow(array, row) {
return [row, ...array];
}
function getOriginalName(name) {
const tokens = name.split("_");
if (tokens.length > 0) {
return tokens[0];
} else {
return name;
}
}
export function getRepoUrl(name) {
name = getOriginalName(name);
if (name === "casdoor") {
return "https://github.com/casdoor/casdoor";
} else {
return `https://github.com/casbin/${name}`;
}
}

483
web/src/SiteEditPage.js Normal file
View File

@@ -0,0 +1,483 @@
// Copyright 2023 The casbin Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import React from "react";
import {Button, Card, Col, Input, InputNumber, Row, Select, Switch} from "antd";
import {LinkOutlined} from "@ant-design/icons";
import * as ProviderBackend from "./backend/ProviderBackend";
import * as SiteBackend from "./backend/SiteBackend";
import * as CertBackend from "./backend/CertBackend";
import * as RuleBackend from "./backend/RuleBackend";
import * as ApplicationBackend from "./backend/ApplicationBackend";
import * as Setting from "./Setting";
import i18next from "i18next";
import RuleTable from "./table/RuleTable";
import * as OrganizationBackend from "./backend/OrganizationBackend";
const {Option} = Select;
class SiteEditPage extends React.Component {
constructor(props) {
super(props);
this.state = {
classes: props,
owner: props.match.params.organizationName,
siteName: props.match.params.siteName,
rules: [],
providers: [],
site: null,
certs: null,
applications: null,
organizations: [],
};
}
UNSAFE_componentWillMount() {
this.getOrganizations();
this.getSite();
this.getCerts();
this.getRules();
this.getApplications();
this.getAlertProviders();
}
getOrganizations() {
if (Setting.isAdminUser(this.props.account)) {
OrganizationBackend.getOrganizations("admin")
.then((res) => {
this.setState({
organizations: res.data || [],
});
});
}
}
getSite() {
SiteBackend.getSite(this.state.owner, this.state.siteName)
.then((res) => {
if (res.status === "ok") {
this.setState({
site: res.data,
});
} else {
Setting.showMessage("error", `Failed to get site: ${res.msg}`);
}
});
}
getCerts() {
CertBackend.getCerts(this.state.owner)
.then((res) => {
if (res.status === "ok") {
this.setState({
certs: res.data,
});
} else {
Setting.showMessage("error", `Failed to get certs: ${res.msg}`);
}
});
}
getRules() {
RuleBackend.getRules(this.state.owner)
.then((res) => {
if (res.status === "ok") {
this.setState({
rules: res.data,
});
} else {
Setting.showMessage("error", `Failed to get rules: ${res.msg}`);
}
});
}
getApplications(owner) {
ApplicationBackend.getApplicationsByOrganization("admin", owner || this.state.owner)
.then((res) => {
if (res.status === "ok") {
this.setState({
applications: res.data,
});
} else {
Setting.showMessage("error", `Failed to get applications: ${res.msg}`);
}
});
}
getAlertProviders() {
ProviderBackend.getProviders()
.then((res) => {
if (res.status === "ok") {
const data = [];
for (let i = 0; i < res.data.length; i++) {
const provider = res.data[i];
if (provider.category === "SMS" || provider.category === "Email") {
data.push(provider.category + "/" + provider.name);
}
}
this.setState({
providers: data,
});
} else {
Setting.showMessage("error", `Failed to get providers: ${res.msg}`);
}
});
}
parseSiteField(key, value) {
if (["score"].includes(key)) {
value = Setting.myParseInt(value);
}
return value;
}
updateSiteField(key, value) {
value = this.parseSiteField(key, value);
const site = this.state.site;
site[key] = value;
this.setState({
site: site,
});
}
renderSite() {
return (
<Card size="small" title={
<div>
{i18next.t("site:Edit Site")}&nbsp;&nbsp;&nbsp;&nbsp;
<Button type="primary" onClick={this.submitSiteEdit.bind(this)}>{i18next.t("general:Save")}</Button>
</div>
} style={{marginLeft: "5px"}} type="inner">
<Row style={{marginTop: "10px"}} >
<Col style={{marginTop: "5px"}} span={2}>
{Setting.getLabel(i18next.t("general:Organization"), i18next.t("general:Organization - Tooltip"))} :
</Col>
<Col span={22} >
<Select virtual={false} style={{width: "100%"}} disabled={!Setting.isAdminUser(this.props.account)} value={this.state.site.owner} onChange={(value => {
this.updateSiteField("owner", value);
this.getApplications(value);
})}>
{
this.state.organizations.map((organization, index) => <Option key={index} value={organization.name}>{organization.name}</Option>)
}
</Select>
</Col>
</Row>
<Row style={{marginTop: "10px"}} >
<Col style={{marginTop: "5px"}} span={2}>
{i18next.t("general:Name")}:
</Col>
<Col span={22} >
<Input value={this.state.site.name} onChange={e => {
this.updateSiteField("name", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={2}>
{i18next.t("general:Display name")}:
</Col>
<Col span={22} >
<Input value={this.state.site.displayName} onChange={e => {
this.updateSiteField("displayName", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={2}>
{i18next.t("general:Tag")}:
</Col>
<Col span={22} >
<Input value={this.state.site.tag} onChange={e => {
this.updateSiteField("tag", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={2}>
{i18next.t("site:Domain")}:
</Col>
<Col span={22} >
<Input value={this.state.site.domain} onChange={e => {
this.updateSiteField("domain", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={2}>
{i18next.t("site:Other domains")}:
</Col>
<Col span={22} >
<Select virtual={false} mode="tags" style={{width: "100%"}} value={this.state.site.otherDomains} onChange={(value => {this.updateSiteField("otherDomains", value);})}>
{
this.state.site.otherDomains?.map((item, index) => <Option key={index} value={item}>{item}</Option>)
}
</Select>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={2}>
{i18next.t("site:Need redirect")}:
</Col>
<Col span={1} >
<Switch checked={this.state.site.needRedirect} onChange={checked => {
this.updateSiteField("needRedirect", checked);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={2}>
{i18next.t("site:Disable verbose")}:
</Col>
<Col span={1} >
<Switch checked={this.state.site.disableVerbose} onChange={checked => {
this.updateSiteField("disableVerbose", checked);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={2}>
{i18next.t("site:Rules")}:
</Col>
<Col span={22} >
<RuleTable
title={"Rules"}
account={this.props.account}
sources={this.state.rules}
rules={this.state.site.rules}
onUpdateRules={(value) => this.updateSiteField("rules", value)}
/>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col span={2} style={{marginTop: "5px"}}>
{i18next.t("site:Enable alert")}:
</Col>
<Col span={22} >
<Switch checked={this.state.site.enableAlert} onChange={checked => {
this.updateSiteField("enableAlert", checked);
}} />
</Col>
</Row>
{
this.state.site.enableAlert ? (
<Row style={{marginTop: "20px"}} >
<Col span={2} style={{marginTop: "5px"}}>
{i18next.t("site:Alert interval")}:
</Col>
<Col span={22} >
<InputNumber min={1} value={this.state.site.alertInterval} addonAfter={i18next.t("usage:seconds")} onChange={value => {
this.updateSiteField("alertInterval", value);
}} />
</Col>
</Row>
) : null
}
{
this.state.site.enableAlert ? (
<Row style={{marginTop: "20px"}} >
<Col span={2} style={{marginTop: "5px"}}>
{i18next.t("site:Alert try times")}:
</Col>
<Col span={22} >
<InputNumber min={1} value={this.state.site.alertTryTimes} onChange={value => {
this.updateSiteField("alertTryTimes", value);
}} />
</Col>
</Row>
) : null
}
{
this.state.site.enableAlert ? (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={2}>
{i18next.t("site:Alert providers")}:
</Col>
<Col span={22} >
<Select virtual={false} mode="tags" style={{width: "100%"}} value={this.state.site.alertProviders} onChange={(value => {this.updateSiteField("alertProviders", value);})}>
{
this.state.providers.map((item, index) => <Option key={index} value={item}>{item}</Option>)
}
</Select>
</Col>
</Row>
) : null
}
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={2}>
{i18next.t("site:Challenges")}:
</Col>
<Col span={22} >
<Select virtual={false} mode="tags" style={{width: "100%"}} value={this.state.site.challenges} onChange={(value => {this.updateSiteField("challenges", value);})}>
{
this.state.site.challenges?.map((item, index) => <Option key={index} value={item}>{item}</Option>)
}
</Select>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={2}>
{i18next.t("site:Host")}:
</Col>
<Col span={22} >
<Input prefix={<LinkOutlined />} value={this.state.site.host} onChange={e => {
this.updateSiteField("host", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={2}>
{i18next.t("site:Port")}:
</Col>
<Col span={22} >
<InputNumber min={0} max={65535} value={this.state.site.port} onChange={value => {
this.updateSiteField("port", value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={2}>
{i18next.t("site:Hosts")}:
</Col>
<Col span={22} >
<Select virtual={false} mode="tags" style={{width: "100%"}} value={this.state.site.hosts} onChange={(value => {this.updateSiteField("hosts", value);})}>
{
this.state.site.hosts?.map((item, index) => <Option key={index} value={item}>{item}</Option>)
}
</Select>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={2}>
{i18next.t("site:Public IP")}:
</Col>
<Col span={22} >
<Input disabled={true} value={this.state.site.publicIp} onChange={e => {
this.updateSiteField("publicIp", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={2}>
{i18next.t("site:Mode")}:
</Col>
<Col span={22} >
<Select virtual={false} style={{width: "100%"}} value={this.state.site.sslMode} onChange={(value => {this.updateSiteField("sslMode", value);})}>
{
[
{id: "HTTP", name: "HTTP"},
{id: "HTTPS and HTTP", name: "HTTPS and HTTP"},
{id: "HTTPS Only", name: "HTTPS Only"},
{id: "Static Folder", name: "Static Folder"},
].map((item, index) => <Option key={index} value={item.id}>{item.name}</Option>)
}
</Select>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={2}>
{i18next.t("site:SSL cert")}:
</Col>
<Col span={22} >
<Select disabled={true} virtual={false} style={{width: "100%"}} showSearch value={this.state.site.sslCert} onChange={(value => {
this.updateSiteField("sslCert", value);
})}>
{
this.state.certs?.map((cert, index) => <Option key={index} value={cert.name}>{cert.name}</Option>)
}
</Select>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={2}>
{i18next.t("site:Casdoor app")}:
</Col>
<Col span={22} >
<Select virtual={false} style={{width: "100%"}} showSearch value={this.state.site.casdoorApplication} onChange={(value => {
this.updateSiteField("casdoorApplication", value);
})}>
{
this.state.applications?.map((application, index) => <Option key={index} value={application.name}>{application.name}</Option>)
}
</Select>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={2}>
{i18next.t("site:Status")}:
</Col>
<Col span={22} >
<Select virtual={false} style={{width: "100%"}} value={this.state.site.status} onChange={(value => {this.updateSiteField("status", value);})}>
{
[
{id: "Active", name: "Active"},
{id: "Inactive", name: "Inactive"},
].map((item, index) => <Option key={index} value={item.id}>{item.name}</Option>)
}
</Select>
</Col>
</Row>
</Card>
);
}
submitSiteEdit() {
const site = Setting.deepCopy(this.state.site);
SiteBackend.updateSite(this.state.site.owner, this.state.siteName, site)
.then((res) => {
if (res.status === "error") {
Setting.showMessage("error", `Failed to save: ${res.msg}`);
this.updateSiteField("name", this.state.siteName);
} else {
Setting.showMessage("success", "Successfully saved");
this.setState({
siteName: this.state.site.name,
});
this.props.history.push(`/sites/${this.state.site.owner}/${this.state.site.name}`);
this.getSite();
}
})
.catch(error => {
Setting.showMessage("error", `failed to save: ${error}`);
});
}
render() {
return (
<div>
<Row style={{width: "100%"}}>
<Col span={1}>
</Col>
<Col span={22}>
{
this.state.site !== null ? this.renderSite() : null
}
</Col>
<Col span={1}>
</Col>
</Row>
<Row style={{margin: 10}}>
<Col span={2}>
</Col>
<Col span={18}>
<Button type="primary" size="large" onClick={this.submitSiteEdit.bind(this)}>{i18next.t("general:Save")}</Button>
</Col>
</Row>
</div>
);
}
}
export default SiteEditPage;

459
web/src/SiteListPage.js Normal file
View File

@@ -0,0 +1,459 @@
// Copyright 2023 The casbin Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import React from "react";
import {Link} from "react-router-dom";
import {Button, Popconfirm, Table, Tag, Tooltip} from "antd";
import moment from "moment";
import * as Setting from "./Setting";
import * as SiteBackend from "./backend/SiteBackend";
import i18next from "i18next";
import BaseListPage from "./BaseListPage";
class SiteListPage extends BaseListPage {
constructor(props) {
super(props);
}
UNSAFE_componentWillMount() {
this.setState({
pagination: {
...this.state.pagination,
current: 1,
pageSize: 1000,
},
});
this.fetch({pagination: this.state.pagination});
}
newSite() {
const randomName = Setting.getRandomName();
const owner = Setting.getRequestOrganization(this.props.account);
return {
owner: owner,
name: `site_${randomName}`,
createdTime: moment().format(),
displayName: `New Site - ${randomName}`,
domain: "door.casdoor.com",
otherDomains: [],
needRedirect: false,
disableVerbose: false,
rules: [],
enableAlert: false,
alertInterval: 60,
alertTryTimes: 3,
alertProviders: [],
challenges: [],
host: "",
port: 8000,
hosts: [],
sslMode: "HTTPS Only",
sslCert: "",
publicIp: "8.131.81.162",
node: "",
isSelf: false,
nodes: [],
casdoorApplication: "",
organizations: [],
};
}
addSite() {
const newSite = this.newSite();
SiteBackend.addSite(newSite)
.then((res) => {
if (res.status === "error") {
Setting.showMessage("error", `Failed to add: ${res.msg}`);
} else {
Setting.showMessage("success", "Site added successfully");
this.setState({
data: Setting.prependRow(this.state.data, newSite),
});
this.fetch();
}
}
)
.catch(error => {
Setting.showMessage("error", `Site failed to add: ${error}`);
});
}
deleteSite(i) {
SiteBackend.deleteSite(this.state.data[i])
.then((res) => {
if (res.status === "error") {
Setting.showMessage("error", `Failed to delete: ${res.msg}`);
} else {
Setting.showMessage("success", "Site deleted successfully");
this.fetch({
pagination: {
...this.state.pagination,
current: this.state.pagination.current > 1 && this.state.data.length === 1 ? this.state.pagination.current - 1 : this.state.pagination.current,
},
});
}
}
)
.catch(error => {
Setting.showMessage("error", `Site failed to delete: ${error}`);
});
}
renderTable(data) {
// const renderExternalLink = () => {
// return (
// <svg style={{marginLeft: "5px"}} width="13.5" height="13.5" aria-hidden="true" viewBox="0 0 24 24" className="iconExternalLink_nPIU">
// <path fill="currentColor" d="M21 13v10h-21v-19h12v2h-10v15h17v-8h2zm3-12h-10.988l4.035 4-6.977 7.07 2.828 2.828 6.977-7.07 4.125 4.172v-11z"></path>
// </svg>
// );
// };
const columns = [
{
title: i18next.t("general:Owner"),
dataIndex: "owner",
key: "owner",
width: "90px",
sorter: (a, b) => a.owner.localeCompare(b.owner),
},
{
title: i18next.t("general:Tag"),
dataIndex: "tag",
key: "tag",
width: "140px",
sorter: (a, b) => a.tag.localeCompare(b.tag),
render: (text, record, index) => {
if (text === "") {
return null;
}
return (
<Link to={`/nodes/${record.owner}/${text}`}>
{text}
</Link>
);
},
},
{
title: i18next.t("general:Name"),
dataIndex: "name",
key: "name",
width: "120px",
sorter: (a, b) => a.name.localeCompare(b.name),
render: (text, record, index) => {
return (
<Link to={`/sites/${record.owner}/${record.name}`}>
{text}
</Link>
);
},
},
// {
// title: i18next.t("general:Create time"),
// dataIndex: "createdTime",
// key: "createdTime",
// width: "180px",
// sorter: (a, b) => a.createdTime.localeCompare(b.createdTime),
// render: (text, record, index) => {
// return Setting.getFormattedDate(text);
// },
// },
{
title: i18next.t("general:Display name"),
dataIndex: "displayName",
key: "displayName",
// width: "200px",
sorter: (a, b) => a.displayName.localeCompare(b.displayName),
},
{
title: i18next.t("site:Domain"),
dataIndex: "domain",
key: "domain",
width: "150px",
sorter: (a, b) => a.domain.localeCompare(b.domain),
render: (text, record, index) => {
if (record.publicIp === "") {
return text;
}
return (
<a target="_blank" rel="noreferrer" href={`https://${text}`}>
{text}
</a>
);
},
},
{
title: i18next.t("site:Other domains"),
dataIndex: "otherDomains",
key: "otherDomains",
width: "120px",
sorter: (a, b) => a.otherDomains.localeCompare(b.otherDomains),
render: (text, record, index) => {
return record.otherDomains.map(domain => {
return (
<a key={domain} target="_blank" rel="noreferrer" href={`https://${domain}`}>
<Tag color={record.needRedirect ? "default" : "processing"}>
{domain}
</Tag>
</a>
);
});
},
},
{
title: i18next.t("general:Rules"),
dataIndex: "rules",
key: "rules",
width: "120px",
sorter: (a, b) => a.rules.localeCompare(b.rules),
render: (text, record, index) => {
if (!record.rules) {
return null;
}
return record.rules.map(rule => {
return (
<a key={rule} target="_blank" rel="noreferrer" href={`/rules/${rule}`}>
<Tag color={"processing"}>
{rule}
</Tag>
</a>
);
});
},
},
{
title: i18next.t("site:Host"),
dataIndex: "host",
key: "host",
width: "80px",
sorter: (a, b) => a.host.localeCompare(b.host),
render: (text, record, index) => {
let host = record.port;
if (record.host !== "") {
host = `${record.host}:${record.port}`;
}
if (record.status === "Active") {
return host;
}
return (
<Tag color={"warning"}>
{host}
</Tag>
);
},
},
{
title: i18next.t("site:Hosts"),
dataIndex: "hosts",
key: "hosts",
width: "200px",
sorter: (a, b) => a.hosts.length - b.hosts.length,
render: (hosts) => {
if (!Array.isArray(hosts)) {
return null;
}
return hosts.map((host, index) => (
<Tag color="blue" key={index}>
{host}
</Tag>
));
},
},
{
title: i18next.t("site:Nodes"),
dataIndex: "nodes",
key: "nodes",
width: "180px",
sorter: (a, b) => a.nodes.length - b.nodes.length,
render: (text, record, index) => {
return record.nodes.map(node => {
const versionInfo = Setting.getVersionInfo(node.version, record.name);
let color = node.message === "" ? "processing" : "error";
if (color === "processing" && node.provider !== "") {
if (node.version === "") {
color = "warning";
} else if (node.provider !== "") {
color = "success";
}
}
const getTag = () => {
if (versionInfo === null) {
return (
<Tag key={node.name} color={color}>
{node.name}
</Tag>
);
} else {
return (
<a key={node.name} target="_blank" rel="noreferrer" href={versionInfo.link}>
<Tag color={color}>
{`${node.name} (${versionInfo.text})`}
</Tag>
</a>
);
}
};
if (node.message === "") {
return getTag();
} else {
return (
<Tooltip key={node.name} title={node.message}>
{getTag()}
</Tooltip>
);
}
});
},
},
// {
// title: i18next.t("site:Public IP"),
// dataIndex: "publicIp",
// key: "publicIp",
// width: "120px",
// sorter: (a, b) => a.publicIp.localeCompare(b.publicIp),
// },
// {
// title: i18next.t("site:Node"),
// dataIndex: "node",
// key: "node",
// width: "180px",
// sorter: (a, b) => a.node.localeCompare(b.node),
// render: (text, record, index) => {
// return (
// <div>
// {text}
// {
// !record.isSelf ? null : (
// <Tag style={{marginLeft: "10px"}} icon={<CheckCircleOutlined />} color="success">
// {i18next.t("general:Self")}
// </Tag>
// )
// }
// </div>
// );
// },
// },
// {
// title: i18next.t("site:Mode"),
// dataIndex: "sslMode",
// key: "sslMode",
// width: "100px",
// sorter: (a, b) => a.sslMode.localeCompare(b.sslMode),
// },
{
title: i18next.t("site:SSL cert"),
dataIndex: "sslCert",
key: "sslCert",
width: "130px",
sorter: (a, b) => a.sslCert.localeCompare(b.sslCert),
render: (text, record, index) => {
return (
<Link to={`/certs/admin/${text}`}>
{text}
</Link>
);
},
},
// {
// title: i18next.t("site:Casdoor app"),
// dataIndex: "casdoorApplication",
// key: "casdoorApplication",
// width: "140px",
// sorter: (a, b) => a.casdoorApplication.localeCompare(b.casdoorApplication),
// render: (text, record, index) => {
// if (text === "") {
// return null;
// }
//
// return (
// <a target="_blank" rel="noreferrer" href={Setting.getMyProfileUrl(this.state.account).replace("/account", `/applications/${this.props.account.owner}/${text}`)}>
// {text}
// {renderExternalLink()}
// </a>
// );
// },
// },
{
title: i18next.t("general:Action"),
dataIndex: "action",
key: "action",
width: "180px",
render: (text, record, index) => {
return (
<div>
<Button style={{marginTop: "10px", marginBottom: "10px", marginRight: "10px"}} type="primary" onClick={() => this.props.history.push(`/sites/${record.owner}/${record.name}`)}>{i18next.t("general:Edit")}</Button>
<Popconfirm
title={`Sure to delete site: ${record.name} ?`}
onConfirm={() => this.deleteSite(index)}
okText="OK"
cancelText="Cancel"
>
<Button style={{marginBottom: "10px"}} type="danger">{i18next.t("general:Delete")}</Button>
</Popconfirm>
</div>
);
},
},
];
return (
<div>
<Table columns={columns} dataSource={data} rowKey="name" size="middle" bordered pagination={this.state.pagination}
title={() => (
<div>
{i18next.t("general:Sites")}&nbsp;&nbsp;&nbsp;&nbsp;
<Button type="primary" size="small" onClick={this.addSite.bind(this)}>{i18next.t("general:Add")}</Button>
</div>
)}
loading={data === null}
onChange={this.handleTableChange}
/>
</div>
);
}
fetch = (params = {}) => {
const field = params.searchedColumn, value = params.searchText;
const sortField = params.sortField, sortOrder = params.sortOrder;
if (!params.pagination) {
params.pagination = {current: 1, pageSize: 10};
}
this.setState({loading: true});
// SiteBackend.getSites(this.props.account.name, params.pagination.current, params.pagination.pageSize, field, value, sortField, sortOrder)
SiteBackend.getSites(this.props.account.owner, "", "", field, value, sortField, sortOrder)
.then((res) => {
this.setState({
loading: false,
});
if (res.status === "ok") {
this.setState({
data: res.data,
pagination: {
...params.pagination,
total: res.data2,
},
});
} else {
Setting.showMessage("error", `Failed to get sites: ${res.msg}`);
}
});
};
}
export default SiteListPage;

View File

@@ -389,6 +389,13 @@ class SyncerEditPage extends React.Component {
"isHashed": true,
"values": [],
},
{
"name": "unionid",
"type": "string",
"casdoorName": "Name",
"isHashed": true,
"values": [],
},
{
"name": "name",
"type": "string",
@@ -424,13 +431,6 @@ class SyncerEditPage extends React.Component {
"isHashed": true,
"values": [],
},
{
"name": "job_number",
"type": "string",
"casdoorName": "Name",
"isHashed": true,
"values": [],
},
{
"name": "active",
"type": "boolean",

View File

@@ -50,6 +50,7 @@ import MfaTable from "./table/MfaTable";
import TransactionTable from "./table/TransactionTable";
import CartTable from "./table/CartTable";
import * as TransactionBackend from "./backend/TransactionBackend";
import ConsentTable from "./table/ConsentTable";
import {Content, Header} from "antd/es/layout/layout";
import Sider from "antd/es/layout/Sider";
@@ -73,6 +74,7 @@ class UserEditPage extends React.Component {
idCardInfo: ["ID card front", "ID card back", "ID card with person"],
openFaceRecognitionModal: false,
transactions: [],
consents: [],
activeMenuKey: window.location.hash?.slice(1) || "",
menuMode: "Horizontal",
};
@@ -110,6 +112,7 @@ class UserEditPage extends React.Component {
this.setState({
user: res.data,
multiFactorAuths: res.data?.multiFactorAuths ?? [],
consents: res.data?.applicationScopes ?? [],
loading: false,
});
@@ -274,7 +277,7 @@ class UserEditPage extends React.Component {
// Fallback to comparing by owner and name
return (this.state.user.owner === this.props.account.owner &&
this.state.user.name === this.props.account.name);
this.state.user.name === this.props.account.name);
}
isSelfOrAdmin() {
@@ -609,13 +612,20 @@ class UserEditPage extends React.Component {
);
} else if (accountItem.name === "Addresses") {
return (
<AddressTable
title={i18next.t("user:Addresses")}
table={this.state.user.addresses}
onUpdateTable={(value) => {
this.updateUserField("addresses", value);
}}
/>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("user:Addresses"), i18next.t("user:Addresses"))} :
</Col>
<Col span={22} >
<AddressTable
title={i18next.t("user:Addresses")}
table={this.state.user.addresses}
onUpdateTable={(value) => {
this.updateUserField("addresses", value);
}}
/>
</Col>
</Row>
);
} else if (accountItem.name === "Affiliation") {
return (
@@ -880,7 +890,7 @@ class UserEditPage extends React.Component {
{Setting.getLabel(i18next.t("general:Transactions"), i18next.t("general:Transactions"))} :
</Col>
<Col span={22}>
<TransactionTable transactions={this.state.transactions} hideTag={true} />
<TransactionTable title={i18next.t("general:Transactions")} transactions={this.state.transactions} hideTag={true} />
</Col>
</Row>
);
@@ -1122,6 +1132,21 @@ class UserEditPage extends React.Component {
/>
</Col>
</Row>);
} else if (accountItem.name === "Consents") {
return (
<Row style={{marginTop: "20px"}}>
<Col style={{marginTop: "5px"}} span={Setting.isMobile() ? 22 : 2}>
{Setting.getLabel(i18next.t("consent:Consents"), i18next.t("consent:Consents - Tooltip"))} :
</Col>
<Col span={22}>
<ConsentTable
title={i18next.t("consent:Consents")}
table={this.state.consents}
onUpdateTable={() => this.getUser()}
/>
</Col>
</Row>
);
} else if (accountItem.name === "Multi-factor authentication") {
return (
!this.isSelfOrAdmin() ? null : (
@@ -1130,15 +1155,21 @@ class UserEditPage extends React.Component {
{Setting.getLabel(i18next.t("mfa:Multi-factor authentication"), i18next.t("mfa:Multi-factor authentication - Tooltip "))} :
</Col>
<Col span={22} >
<Card size="small" title={i18next.t("mfa:Multi-factor methods")}
extra={this.state.multiFactorAuths?.some(mfaProps => mfaProps.enabled) ?
<PopconfirmModal
text={i18next.t("general:Disable")}
title={i18next.t("general:Sure to disable") + "?"}
onConfirm={() => this.deleteMfa()}
/> : null
}>
<Card size="small" title={
<div>
{i18next.t("mfa:Multi-factor methods")}&nbsp;&nbsp;&nbsp;&nbsp;
{this.state.multiFactorAuths?.some(mfaProps => mfaProps.enabled) ?
<PopconfirmModal
text={i18next.t("general:Disable")}
title={i18next.t("general:Sure to disable") + "?"}
onConfirm={() => this.deleteMfa()}
size="small"
/> : null
}
</div>
}>
<List
size="small"
rowKey="mfaType"
itemLayout="horizontal"
dataSource={this.state.multiFactorAuths}

260
web/src/auth/ConsentPage.js Normal file
View File

@@ -0,0 +1,260 @@
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import React from "react";
import {Button, Card, List, Result, Space} from "antd";
import {CheckOutlined, LockOutlined} from "@ant-design/icons";
import * as ApplicationBackend from "../backend/ApplicationBackend";
import * as ConsentBackend from "../backend/ConsentBackend";
import * as Setting from "../Setting";
import i18next from "i18next";
import {withRouter} from "react-router-dom";
import * as Util from "./Util";
class ConsentPage extends React.Component {
constructor(props) {
super(props);
const params = new URLSearchParams(window.location.search);
this.state = {
applicationName: props.match?.params?.applicationName || params.get("application"),
scopeDescriptions: [],
granting: false,
oAuthParams: Util.getOAuthGetParameters(),
};
}
getApplicationObj() {
return this.props.application;
}
componentDidMount() {
this.getApplication();
this.loadScopeDescriptions();
}
componentDidUpdate(prevProps) {
if (this.props.application !== prevProps.application) {
this.loadScopeDescriptions();
}
}
getApplication() {
if (!this.state.applicationName) {
return;
}
ApplicationBackend.getApplication("admin", this.state.applicationName)
.then((res) => {
if (res.status === "error") {
Setting.showMessage("error", res.msg);
return;
}
this.props.onUpdateApplication(res.data);
});
}
loadScopeDescriptions() {
const {oAuthParams} = this.state;
const application = this.getApplicationObj();
if (!oAuthParams?.scope || !application) {
return;
}
// Check if urlPar scope is within application scopes
const scopes = oAuthParams.scope.split(" ").map(s => s.trim()).filter(Boolean);
const customScopes = application.customScopes || [];
const customScopesMap = {};
customScopes.forEach(s => {
if (s?.scope) {
customScopesMap[s.scope] = s;
}
});
const scopeDescriptions = scopes
.map(scope => {
const item = customScopesMap[scope];
if (item) {
return {
...item,
displayName: item.displayName || item.scope,
};
}
return {
scope: scope,
displayName: scope,
description: i18next.t("consent:This scope is not defined in the application"),
};
})
.filter(Boolean);
this.setState({
scopeDescriptions: scopeDescriptions,
});
}
handleGrant() {
const {oAuthParams, scopeDescriptions} = this.state;
const application = this.getApplicationObj();
this.setState({granting: true});
const consent = {
owner: application.owner,
application: application.owner + "/" + application.name,
grantedScopes: scopeDescriptions.map(s => s.scope),
};
ConsentBackend.grantConsent(consent, oAuthParams)
.then((res) => {
if (res.status === "ok") {
// res.data contains the authorization code
const code = res.data;
const concatChar = oAuthParams?.redirectUri?.includes("?") ? "&" : "?";
const redirectUrl = `${oAuthParams.redirectUri}${concatChar}code=${code}&state=${oAuthParams.state}`;
Setting.goToLink(redirectUrl);
} else {
Setting.showMessage("error", res.msg);
this.setState({granting: false});
}
});
}
handleDeny() {
const {oAuthParams} = this.state;
const concatChar = oAuthParams?.redirectUri?.includes("?") ? "&" : "?";
Setting.goToLink(`${oAuthParams.redirectUri}${concatChar}error=access_denied&error_description=User denied consent&state=${oAuthParams.state}`);
}
render() {
const application = this.getApplicationObj();
if (application === undefined) {
return null;
}
if (!application) {
return (
<Result
status="error"
title={i18next.t("general:Invalid application")}
/>
);
}
const {scopeDescriptions, granting} = this.state;
const isScopeEmpty = scopeDescriptions.length === 0;
return (
<div className="login-content">
<div className={Setting.isDarkTheme(this.props.themeAlgorithm) ? "login-panel-dark" : "login-panel"}>
<div className="login-form">
<Card
style={{
padding: "32px",
width: 450,
borderRadius: "12px",
boxShadow: "0 10px 25px rgba(0, 0, 0, 0.05)",
border: "1px solid #f0f0f0",
}}
>
<div style={{textAlign: "center", marginBottom: 24}}>
{application.logo && (
<div style={{marginBottom: 16}}>
<img
src={application.logo}
alt={application.displayName || application.name}
style={{height: 56, objectFit: "contain"}}
/>
</div>
)}
<h2 style={{margin: 0, fontWeight: 600, fontSize: "24px"}}>
{i18next.t("consent:Authorization Request")}
</h2>
</div>
<div style={{marginBottom: 32}}>
<p style={{fontSize: 15, color: "#666", textAlign: "center", lineHeight: "1.6"}}>
<span style={{fontWeight: 600, color: "#000"}}>{application.displayName || application.name}</span>
{" "}{i18next.t("consent:wants to access your account")}
</p>
{application.homepageUrl && (
<div style={{textAlign: "center", marginTop: 4}}>
<a href={application.homepageUrl} target="_blank" rel="noopener noreferrer" style={{fontSize: 13, color: "#1890ff"}}>
{application.homepageUrl}
</a>
</div>
)}
</div>
<div style={{marginBottom: 32}}>
<div style={{fontSize: 14, color: "#8c8c8c", marginBottom: 16}}>
<LockOutlined style={{marginRight: 8}} /> {i18next.t("consent:This application is requesting")}
</div>
<div style={{display: "flex", justifyContent: "center"}}>
<List
size="small"
dataSource={scopeDescriptions}
style={{width: "100%"}}
renderItem={item => (
<List.Item style={{borderBottom: "none", width: "100%"}}>
<div style={{display: "inline-grid", gridTemplateColumns: "16px auto", columnGap: 8, alignItems: "start"}}>
<CheckOutlined style={{color: "#52c41a", fontSize: "14px", marginTop: "4px", justifySelf: "center"}} />
<div style={{fontWeight: 500, fontSize: "14px", lineHeight: "22px"}}>{item.displayName || item.scope}</div>
</div>
<div style={{fontSize: "12px", color: "#8c8c8c", marginTop: 2}}>{item.description}</div>
</List.Item>
)}
/>
</div>
</div>
<div style={{textAlign: "center", marginBottom: 24}}>
<Space size={16}>
<Button
type="primary"
size="large"
shape="round"
onClick={() => this.handleGrant()}
loading={granting}
disabled={granting || isScopeEmpty}
style={{minWidth: 120, height: 44, fontWeight: 500}}
>
{i18next.t("consent:Allow")}
</Button>
<Button
size="large"
shape="round"
onClick={() => this.handleDeny()}
disabled={granting || isScopeEmpty}
style={{minWidth: 120, height: 44, fontWeight: 500}}
>
{i18next.t("consent:Deny")}
</Button>
</Space>
</div>
<div style={{padding: "16px", backgroundColor: "#fafafa", borderRadius: "8px", border: "1px solid #f0f0f0"}}>
<p style={{margin: 0, fontSize: 12, color: "#8c8c8c", textAlign: "center", lineHeight: "1.5"}}>
{i18next.t("consent:By clicking Allow, you allow this app to use your information")}
</p>
</div>
</Card>
</div>
</div>
</div>
);
}
}
export default withRouter(ConsentPage);

View File

@@ -216,7 +216,7 @@ class LoginPage extends React.Component {
this.setState({
msg: res.msg,
});
return ;
return;
}
this.onUpdateApplication(res.data);
});
@@ -369,6 +369,13 @@ class LoginPage extends React.Component {
return;
}
// Check if consent is required
if (resp.data?.required === true) {
// Consent required, redirect to consent page
Setting.goToLinkSoft(ths, `/consent/${application.name}?${window.location.search.substring(1)}`);
return;
}
if (Setting.hasPromptPage(application)) {
AuthBackend.getAccount()
.then((res) => {
@@ -1141,9 +1148,11 @@ class LoginPage extends React.Component {
visible={this.state.openCaptchaModal}
noModal={noModal}
onUpdateToken={(captchaType, captchaToken, clientSecret) => {
this.setState({captchaValues: {
captchaType, captchaToken, clientSecret,
}});
this.setState({
captchaValues: {
captchaType, captchaToken, clientSecret,
},
});
}}
onOk={(captchaType, captchaToken, clientSecret) => {
const values = this.state.values;

View File

@@ -44,20 +44,15 @@ function generateCodeChallenge(verifier) {
}
function storeCodeVerifier(state, verifier) {
localStorage.setItem("pkce_verifier", `${state}#${verifier}`);
localStorage.setItem(`pkce_verifier_${state}`, verifier);
}
export function getCodeVerifier(state) {
const verifierStore = localStorage.getItem("pkce_verifier");
const [storedState, verifier] = verifierStore ? verifierStore.split("#") : [null, null];
if (storedState !== state) {
return null;
}
return verifier;
return localStorage.getItem(`pkce_verifier_${state}`);
}
export function clearCodeVerifier(state) {
localStorage.removeItem("pkce_verifier");
localStorage.removeItem(`pkce_verifier_${state}`);
}
const authInfo = {
@@ -407,24 +402,27 @@ export function getProviderUrl(provider) {
}
}
export function getProviderLogoWidget(provider) {
export function getProviderLogoWidget(provider, options = {}) {
if (provider === undefined) {
return null;
}
const url = getProviderUrl(provider);
if (url !== "") {
const disableLink = options.disableLink === true;
const imgEl = <img width={36} height={36} src={Setting.getProviderLogoURL(provider)} alt={provider.displayName} />;
if (url !== "" && !disableLink) {
return (
<Tooltip title={provider.type}>
<a target="_blank" rel="noreferrer" href={getProviderUrl(provider)}>
<img width={36} height={36} src={Setting.getProviderLogoURL(provider)} alt={provider.displayName} />
{imgEl}
</a>
</Tooltip>
);
} else {
return (
<Tooltip title={provider.type}>
<img width={36} height={36} src={Setting.getProviderLogoURL(provider)} alt={provider.displayName} />
{imgEl}
</Tooltip>
);
}

View File

@@ -293,8 +293,17 @@ class SignupPage extends React.Component {
return;
}
// Check if consent is required
if (oAuthParams && res.data && typeof res.data === "object" && res.data.required === true) {
// Consent required, redirect to consent page
Setting.goToLink(`/consent/${application.name}?${window.location.search.substring(1)}`);
return;
}
// the user's id will be returned by `signup()`, if user signup by phone, the `username` in `values` is undefined.
values.username = res.data.split("/")[1];
if (typeof res.data === "string") {
values.username = res.data.split("/")[1];
}
if (Setting.hasPromptPage(application) && (!values.plan || !values.pricing)) {
AuthBackend.getAccount("")
.then((res) => {

View File

@@ -130,7 +130,7 @@ export function getOAuthGetParameters(params) {
}
let state = getRefinedValue(queries.get("state"));
if (state.startsWith("/auth/oauth2/login.php?wantsurl=")) {
if (state.startsWith("/auth/oauth2/login.php?wantsurl")) {
// state contains URL param encoding for Moodle, URLSearchParams automatically decoded it, so here encode it again
state = encodeURIComponent(state);
}
@@ -213,17 +213,19 @@ export async function WechatOfficialAccountModal(application, provider, method)
}
const t1 = setInterval(await getEvent, 1000, application, provider, res.data2, method);
{Modal.info({
title: i18next.t("provider:Please use WeChat to scan the QR code and follow the official account for sign in"),
content: (
<div style={{marginRight: "34px"}}>
<QRCode style={{padding: "20px", margin: "auto"}} bordered={false} value={res.data} size={230} />
</div>
),
onOk() {
window.clearInterval(t1);
},
});}
{
Modal.info({
title: i18next.t("provider:Please use WeChat to scan the QR code and follow the official account for sign in"),
content: (
<div style={{marginRight: "34px"}}>
<QRCode style={{padding: "20px", margin: "auto"}} bordered={false} value={res.data} size={230} />
</div>
),
onOk() {
window.clearInterval(t1);
},
});
}
}
);
}

View File

@@ -79,3 +79,10 @@ export function deleteCert(cert) {
},
}).then(res => res.json());
}
export function refreshDomainExpire(owner, name) {
return fetch(`${Setting.ServerUrl}/api/update-cert-domain-expire?id=${owner}/${encodeURIComponent(name)}`, {
method: "POST",
credentials: "include",
}).then(res => res.json());
}

View File

@@ -0,0 +1,50 @@
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import * as Setting from "../Setting";
export function grantConsent(consent, oAuthParams) {
const request = {
...consent,
clientId: oAuthParams.clientId,
provider: "",
signinMethod: "",
responseType: oAuthParams.responseType || "code",
redirectUri: oAuthParams.redirectUri,
scope: oAuthParams.scope,
state: oAuthParams.state,
nonce: oAuthParams.nonce || "",
challenge: oAuthParams.codeChallenge || "",
resource: "",
};
return fetch(`${Setting.ServerUrl}/api/grant-consent`, {
method: "POST",
credentials: "include",
body: JSON.stringify(request),
headers: {
"Accept-Language": Setting.getAcceptLanguage(),
},
}).then(res => res.json());
}
export function revokeConsent(consent) {
return fetch(`${Setting.ServerUrl}/api/revoke-consent`, {
method: "POST",
credentials: "include",
body: JSON.stringify(consent),
headers: {
"Accept-Language": Setting.getAcceptLanguage(),
},
}).then(res => res.json());
}

View File

@@ -0,0 +1,53 @@
// Copyright 2023 The casbin Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import * as Setting from "../Setting";
export function getRules(owner, page = "", pageSize = "", sortField = "", sortOrder = "") {
return fetch(`${Setting.ServerUrl}/api/get-rules?owner=${owner}&p=${page}&pageSize=${pageSize}&sortField=${sortField}&sortOrder=${sortOrder}`, {
method: "GET",
credentials: "include",
}).then(res => res.json());
}
export function getRule(owner, name) {
return fetch(`${Setting.ServerUrl}/api/get-rule?id=${owner}/${encodeURIComponent(name)}`, {
method: "GET",
credentials: "include",
}).then(res => res.json());
}
export function addRule(rule) {
return fetch(`${Setting.ServerUrl}/api/add-rule`, {
method: "POST",
credentials: "include",
body: JSON.stringify(rule),
}).then(res => res.json());
}
export function updateRule(owner, name, rule) {
return fetch(`${Setting.ServerUrl}/api/update-rule?id=${owner}/${encodeURIComponent(name)}`, {
method: "POST",
credentials: "include",
body: JSON.stringify(rule),
}).then(res => res.json());
}
export function deleteRule(rule) {
return fetch(`${Setting.ServerUrl}/api/delete-rule`, {
method: "POST",
credentials: "include",
body: JSON.stringify(rule),
}).then(res => res.json());
}

View File

@@ -0,0 +1,63 @@
// Copyright 2023 The casbin Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import * as Setting from "../Setting";
export function getGlobalSites() {
return fetch(`${Setting.ServerUrl}/api/get-global-sites`, {
method: "GET",
credentials: "include",
}).then(res => res.json());
}
export function getSites(owner, page = "", pageSize = "", field = "", value = "", sortField = "", sortOrder = "") {
return fetch(`${Setting.ServerUrl}/api/get-sites?owner=${owner}&p=${page}&pageSize=${pageSize}&field=${field}&value=${value}&sortField=${sortField}&sortOrder=${sortOrder}`, {
method: "GET",
credentials: "include",
}).then(res => res.json());
}
export function getSite(owner, name) {
return fetch(`${Setting.ServerUrl}/api/get-site?id=${owner}/${encodeURIComponent(name)}`, {
method: "GET",
credentials: "include",
}).then(res => res.json());
}
export function updateSite(owner, name, site) {
const newSite = Setting.deepCopy(site);
return fetch(`${Setting.ServerUrl}/api/update-site?id=${owner}/${encodeURIComponent(name)}`, {
method: "POST",
credentials: "include",
body: JSON.stringify(newSite),
}).then(res => res.json());
}
export function addSite(site) {
const newSite = Setting.deepCopy(site);
return fetch(`${Setting.ServerUrl}/api/add-site`, {
method: "POST",
credentials: "include",
body: JSON.stringify(newSite),
}).then(res => res.json());
}
export function deleteSite(site) {
const newSite = Setting.deepCopy(site);
return fetch(`${Setting.ServerUrl}/api/delete-site`, {
method: "POST",
credentials: "include",
body: JSON.stringify(newSite),
}).then(res => res.json());
}

Some files were not shown because too many files have changed in this diff Show More