Compare commits

..

13 Commits

Author SHA1 Message Date
ca60cc3a33 feat: show SAML cert parse error better in frontend (#3551) 2025-02-05 10:06:02 +08:00
df295717f0 feat: can define what Casdoor pages an org admin can see via Organization.NavItems (#3539)
* feat: support define what Casdoor pages an org admin can see

* feat: remove useless code

* fix: fix NavItemNodes i18next invalid

* fix: only global admin can edit navItems

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

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

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

* fix: fail to login if mfa not enable

* fix: fail to login if mfa not enable

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

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

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

* fix: fix code error

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

* fix: fix linter err
2025-01-22 19:12:12 +08:00
3d12ac8dc2 feat: improve HandleScim() 2025-01-22 16:15:19 +08:00
25 changed files with 654 additions and 320 deletions

View File

@ -306,6 +306,35 @@ func isProxyProviderType(providerType string) bool {
return false
}
func checkMfaEnable(c *ApiController, user *object.User, organization *object.Organization, verificationType string) bool {
if object.IsNeedPromptMfa(organization, user) {
// The prompt page needs the user to be srigned in
c.SetSessionUsername(user.GetId())
c.ResponseOk(object.RequiredMfa)
return true
}
if user.IsMfaEnabled() {
c.setMfaUserSession(user.GetId())
mfaList := object.GetAllMfaProps(user, true)
mfaAllowList := []*object.MfaProps{}
for _, prop := range mfaList {
if prop.MfaType == verificationType || !prop.Enabled {
continue
}
mfaAllowList = append(mfaAllowList, prop)
}
if len(mfaAllowList) >= 1 {
c.SetSession("verificationCodeType", verificationType)
c.Ctx.Input.CruSession.SessionRelease(c.Ctx.ResponseWriter)
c.ResponseOk(object.NextMfa, mfaAllowList)
return true
}
}
return false
}
// Login ...
// @Title Login
// @Tag Login API
@ -523,30 +552,10 @@ func (c *ApiController) Login() {
c.ResponseError(err.Error())
}
if object.IsNeedPromptMfa(organization, user) {
// The prompt page needs the user to be signed in
c.SetSessionUsername(user.GetId())
c.ResponseOk(object.RequiredMfa)
if checkMfaEnable(c, user, organization, verificationType) {
return
}
if user.IsMfaEnabled() {
c.setMfaUserSession(user.GetId())
mfaList := object.GetAllMfaProps(user, true)
mfaAllowList := []*object.MfaProps{}
for _, prop := range mfaList {
if prop.MfaType == verificationType || !prop.Enabled {
continue
}
mfaAllowList = append(mfaAllowList, prop)
}
if len(mfaAllowList) >= 1 {
c.SetSession("verificationCodeType", verificationType)
c.ResponseOk(object.NextMfa, mfaAllowList)
return
}
}
resp = c.HandleLoggedIn(application, user, &authForm)
c.Ctx.Input.SetParam("recordUserId", user.GetId())
@ -679,6 +688,11 @@ func (c *ApiController) Login() {
c.ResponseError(err.Error())
return
}
if checkMfaEnable(c, user, organization, verificationType) {
return
}
resp = c.HandleLoggedIn(application, user, &authForm)
c.Ctx.Input.SetParam("recordUserId", user.GetId())
@ -914,7 +928,11 @@ func (c *ApiController) Login() {
}
var application *object.Application
application, err = object.GetApplication(fmt.Sprintf("admin/%s", authForm.Application))
if authForm.ClientId == "" {
application, err = object.GetApplication(fmt.Sprintf("admin/%s", authForm.Application))
} else {
application, err = object.GetApplicationByClientId(authForm.ClientId)
}
if err != nil {
c.ResponseError(err.Error())
return
@ -944,6 +962,10 @@ func (c *ApiController) Login() {
return
}
if authForm.Provider == "" {
authForm.Provider = authForm.ProviderBack
}
user := c.getCurrentUser()
resp = c.HandleLoggedIn(application, user, &authForm)

View File

@ -15,11 +15,15 @@
package controllers
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"os/exec"
"sort"
"strings"
"time"
)
func processArgsToTempFiles(args []string) ([]string, []string, error) {
@ -57,6 +61,11 @@ func processArgsToTempFiles(args []string) ([]string, []string, error) {
// @Success 200 {object} controllers.Response The Response object
// @router /run-casbin-command [get]
func (c *ApiController) RunCasbinCommand() {
if err := validateIdentifier(c); err != nil {
c.ResponseError(err.Error())
return
}
language := c.Input().Get("language")
argString := c.Input().Get("args")
@ -112,3 +121,58 @@ func (c *ApiController) RunCasbinCommand() {
output = strings.TrimSuffix(output, "\n")
c.ResponseOk(output)
}
// validateIdentifier
// @Title validateIdentifier
// @Description Validate the request hash and timestamp
// @Param hash string The SHA-256 hash string
// @Return error Returns error if validation fails, nil if successful
func validateIdentifier(c *ApiController) error {
language := c.Input().Get("language")
args := c.Input().Get("args")
hash := c.Input().Get("m")
timestamp := c.Input().Get("t")
if hash == "" || timestamp == "" || language == "" || args == "" {
return fmt.Errorf("invalid identifier")
}
requestTime, err := time.Parse(time.RFC3339, timestamp)
if err != nil {
return fmt.Errorf("invalid identifier")
}
timeDiff := time.Since(requestTime)
if timeDiff > 5*time.Minute || timeDiff < -5*time.Minute {
return fmt.Errorf("invalid identifier")
}
params := map[string]string{
"language": language,
"args": args,
}
keys := make([]string, 0, len(params))
for k := range params {
keys = append(keys, k)
}
sort.Strings(keys)
var paramParts []string
for _, k := range keys {
paramParts = append(paramParts, fmt.Sprintf("%s=%s", k, params[k]))
}
paramString := strings.Join(paramParts, "&")
version := "casbin-editor-v1"
rawString := fmt.Sprintf("%s|%s|%s", version, timestamp, paramString)
hasher := sha256.New()
hasher.Write([]byte(rawString))
calculatedHash := strings.ToLower(hex.EncodeToString(hasher.Sum(nil)))
if calculatedHash != strings.ToLower(hash) {
return fmt.Errorf("invalid identifier")
}
return nil
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -79,6 +79,7 @@ type Organization struct {
UseEmailAsUsername bool `json:"useEmailAsUsername"`
EnableTour bool `json:"enableTour"`
IpRestriction string `json:"ipRestriction"`
NavItems []string `xorm:"varchar(500)" json:"navItems"`
MfaItems []*MfaItem `xorm:"varchar(300)" json:"mfaItems"`
AccountItems []*AccountItem `xorm:"varchar(5000)" json:"accountItems"`
@ -151,7 +152,10 @@ func getOrganization(owner string, name string) (*Organization, error) {
}
func GetOrganization(id string) (*Organization, error) {
owner, name := util.GetOwnerAndNameFromId(id)
owner, name, err := util.GetOwnerAndNameFromIdWithError(id)
if err != nil {
return nil, err
}
return getOrganization(owner, name)
}
@ -192,9 +196,10 @@ func GetMaskedOrganizations(organizations []*Organization, errs ...error) ([]*Or
return organizations, nil
}
func UpdateOrganization(id string, organization *Organization) (bool, error) {
func UpdateOrganization(id string, organization *Organization, isGlobalAdmin bool) (bool, error) {
owner, name := util.GetOwnerAndNameFromId(id)
if org, err := getOrganization(owner, name); err != nil {
org, err := getOrganization(owner, name)
if err != nil {
return false, err
} else if org == nil {
return false, nil
@ -219,6 +224,10 @@ func UpdateOrganization(id string, organization *Organization) (bool, error) {
}
}
if !isGlobalAdmin {
organization.NavItems = org.NavItems
}
session := ormer.Engine.ID(core.PK{owner, name}).AllCols()
if organization.MasterPassword == "***" {

View File

@ -123,8 +123,7 @@ func GetTokenByRefreshToken(refreshToken string) (*Token, error) {
func GetTokenByTokenValue(tokenValue, tokenTypeHint string) (*Token, error) {
switch tokenTypeHint {
case "access_token":
case "access-token":
case "access_token", "access-token":
token, err := GetTokenByAccessToken(tokenValue)
if err != nil {
return nil, err
@ -132,8 +131,7 @@ func GetTokenByTokenValue(tokenValue, tokenTypeHint string) (*Token, error) {
if token != nil {
return token, nil
}
case "refresh_token":
case "refresh-token":
case "refresh_token", "refresh-token":
token, err := GetTokenByRefreshToken(tokenValue)
if err != nil {
return nil, err

View File

@ -965,6 +965,11 @@ func DeleteUser(user *User) (bool, error) {
return false, err
}
_, err = userEnforcer.DeleteGroupsForUser(user.GetId())
if err != nil {
return false, err
}
organization, err := GetOrganizationByUser(user)
if err != nil {
return false, err

View File

@ -80,6 +80,15 @@ func fastAutoSignin(ctx *context.Context) (string, error) {
return "", nil
}
isAllowed, err := object.CheckLoginPermission(userId, application)
if err != nil {
return "", err
}
if !isAllowed {
return "", nil
}
code, err := object.GetOAuthCode(userId, clientId, responseType, redirectUri, scope, state, nonce, codeChallenge, ctx.Request.Host, getAcceptLanguage(ctx))
if err != nil {
return "", err
@ -133,6 +142,9 @@ func StaticFilter(ctx *context.Context) {
path += urlPath
}
// Preventing synchronization problems from concurrency
ctx.Input.CruSession = nil
organizationThemeCookie, err := appendThemeCookie(ctx, urlPath)
if err != nil {
fmt.Println(err)

View File

@ -32,94 +32,79 @@ type OrganizationThemeCookie struct {
}
func appendThemeCookie(ctx *context.Context, urlPath string) (*OrganizationThemeCookie, error) {
organizationThemeCookie, err := getOrganizationThemeCookieFromUrlPath(ctx, urlPath)
if err != nil {
return nil, err
}
if organizationThemeCookie != nil {
return organizationThemeCookie, setThemeDataCookie(ctx, organizationThemeCookie)
}
return nil, nil
}
func getOrganizationThemeCookieFromUrlPath(ctx *context.Context, urlPath string) (*OrganizationThemeCookie, error) {
var application *object.Application
var organization *object.Organization
var err error
if urlPath == "/login" {
application, err := object.GetDefaultApplication(fmt.Sprintf("admin/built-in"))
application, err = object.GetDefaultApplication(fmt.Sprintf("admin/built-in"))
if err != nil {
return nil, err
}
organization := application.OrganizationObj
if organization == nil {
organization, err = object.GetOrganization(fmt.Sprintf("admin/built-in"))
if err != nil {
return nil, err
}
}
if organization != nil {
organizationThemeCookie := &OrganizationThemeCookie{
application.ThemeData,
application.Logo,
application.FooterHtml,
organization.Favicon,
organization.DisplayName,
}
if application.ThemeData != nil {
organizationThemeCookie.ThemeData = organization.ThemeData
}
return organizationThemeCookie, setThemeDataCookie(ctx, organizationThemeCookie)
}
} else if strings.HasPrefix(urlPath, "/login/oauth/authorize") {
clientId := ctx.Input.Query("client_id")
if clientId == "" {
return nil, nil
}
application, err := object.GetApplicationByClientId(clientId)
application, err = object.GetApplicationByClientId(clientId)
if err != nil {
return nil, err
}
if application != nil {
organization := application.OrganizationObj
if organization == nil {
organization, err = object.GetOrganization(fmt.Sprintf("admin/%s", application.Owner))
if err != nil {
return nil, err
}
}
organizationThemeCookie := &OrganizationThemeCookie{
application.ThemeData,
application.Logo,
application.FooterHtml,
organization.Favicon,
organization.DisplayName,
}
if application.ThemeData != nil {
organizationThemeCookie.ThemeData = organization.ThemeData
}
return organizationThemeCookie, setThemeDataCookie(ctx, organizationThemeCookie)
} else if strings.HasPrefix(urlPath, "/login/saml") {
owner, _ := strings.CutPrefix(urlPath, "/login/saml/authorize/")
application, err = object.GetApplication(owner)
if err != nil {
return nil, err
}
} else if strings.HasPrefix(urlPath, "/login/") {
owner := strings.Replace(urlPath, "/login/", "", -1)
if owner != "undefined" && owner != "oauth/undefined" {
application, err := object.GetDefaultApplication(fmt.Sprintf("admin/%s", owner))
if err != nil {
return nil, err
}
organization := application.OrganizationObj
if organization == nil {
organization, err = object.GetOrganization(fmt.Sprintf("admin/%s", owner))
if err != nil {
return nil, err
}
}
if organization != nil {
organizationThemeCookie := &OrganizationThemeCookie{
application.ThemeData,
application.Logo,
application.FooterHtml,
organization.Favicon,
organization.DisplayName,
}
if application.ThemeData != nil {
organizationThemeCookie.ThemeData = organization.ThemeData
}
return organizationThemeCookie, setThemeDataCookie(ctx, organizationThemeCookie)
}
owner, _ := strings.CutPrefix(urlPath, "/login/")
if owner == "undefined" || strings.Count(owner, "/") > 0 {
return nil, nil
}
application, err = object.GetDefaultApplication(fmt.Sprintf("admin/%s", owner))
if err != nil {
return nil, err
}
} else if strings.HasPrefix(urlPath, "/cas/") && strings.HasSuffix(urlPath, "/login") {
owner, _ := strings.CutPrefix(urlPath, "/cas/")
owner, _ = strings.CutSuffix(owner, "/login")
application, err = object.GetApplication(owner)
if err != nil {
return nil, err
}
}
return nil, nil
if application == nil {
return nil, nil
}
organization = application.OrganizationObj
if organization == nil {
organization, err = object.GetOrganization(fmt.Sprintf("admin/%s", application.Organization))
if err != nil {
return nil, err
}
}
organizationThemeCookie := &OrganizationThemeCookie{
application.ThemeData,
application.Logo,
application.FooterHtml,
organization.Favicon,
organization.DisplayName,
}
return organizationThemeCookie, nil
}
func setThemeDataCookie(ctx *context.Context, organizationThemeCookie *OrganizationThemeCookie) error {

View File

@ -361,6 +361,14 @@ class App extends Component {
}
};
onLoginSuccess(redirectUrl) {
window.google?.accounts?.id?.cancel();
if (redirectUrl) {
localStorage.setItem("mfaRedirectUrl", redirectUrl);
}
this.getAccount();
}
renderPage() {
if (this.isDoorPages()) {
let themeData = this.state.themeData;
@ -401,19 +409,13 @@ class App extends Component {
application: application,
});
}}
onLoginSuccess={(redirectUrl) => {
window.google?.accounts?.id?.cancel();
if (redirectUrl) {
localStorage.setItem("mfaRedirectUrl", redirectUrl);
}
this.getAccount();
}}
onLoginSuccess={(redirectUrl) => {this.onLoginSuccess(redirectUrl);}}
onUpdateAccount={(account) => this.onUpdateAccount(account)}
updataThemeData={this.setTheme}
/> :
<Switch>
<Route exact path="/callback" component={AuthCallback} />
<Route exact path="/callback/saml" component={SamlCallback} />
<Route exact path="/callback" render={(props) => <AuthCallback {...props} {...this.props} application={this.state.application} onLoginSuccess={(redirectUrl) => {this.onLoginSuccess(redirectUrl);}} />} />
<Route exact path="/callback/saml" render={(props) => <SamlCallback {...props} {...this.props} application={this.state.application} onLoginSuccess={(redirectUrl) => {this.onLoginSuccess(redirectUrl);}} />} />
<Route path="" render={() => <Result status="404" title="404 NOT FOUND" subTitle={i18next.t("general:Sorry, the page you visited does not exist.")}
extra={<a href="/"><Button type="primary">{i18next.t("general:Back Home")}</Button></a>} />} />
</Switch>

View File

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

View File

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

View File

@ -26,6 +26,7 @@ import LdapTable from "./table/LdapTable";
import AccountTable from "./table/AccountTable";
import ThemeEditor from "./common/theme/ThemeEditor";
import MfaTable from "./table/MfaTable";
import {NavItemTree} from "./common/NavItemTree";
const {Option} = Select;
@ -522,6 +523,21 @@ class OrganizationEditPage extends React.Component {
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Navbar items"), i18next.t("general:Navbar items - Tooltip"))} :
</Col>
<Col span={22} >
<NavItemTree
disabled={!Setting.isAdminUser(this.props.account)}
checkedKeys={this.state.organization.navItems ?? ["all"]}
defaultExpandedKeys={["all"]}
onCheck={(checked, _) => {
this.updateOrganizationField("navItems", checked);
}}
/>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("organization:Account items"), i18next.t("organization:Account items - Tooltip"))} :

View File

@ -14,7 +14,7 @@
import React from "react";
import {Link} from "react-router-dom";
import {Select, Tag, Tooltip, message, theme} from "antd";
import {Button, Select, Tag, Tooltip, message, theme} from "antd";
import {QuestionCircleTwoTone} from "@ant-design/icons";
import {isMobile as isMobileDevice} from "react-device-detect";
import "./i18n";
@ -25,6 +25,8 @@ import {Helmet} from "react-helmet";
import * as Conf from "./Conf";
import * as phoneNumber from "libphonenumber-js";
import moment from "moment";
import {MfaAuthVerifyForm, NextMfa, RequiredMfa} from "./auth/mfa/MfaAuthVerifyForm";
import {EmailMfaType, SmsMfaType, TotpMfaType} from "./auth/MfaSetupPage";
const {Option} = Select;
@ -1588,3 +1590,114 @@ export function getCurrencyText(product) {
export function isDarkTheme(themeAlgorithm) {
return themeAlgorithm && themeAlgorithm.includes("dark");
}
function getPreferredMfaProp(mfaProps) {
for (const i in mfaProps) {
if (mfaProps[i].isPreffered) {
return mfaProps[i];
}
}
return mfaProps[0];
}
export function checkLoginMfa(res, body, params, handleLogin, componentThis, requireRedirect = null) {
if (res.data === RequiredMfa) {
if (!requireRedirect) {
componentThis.props.onLoginSuccess(window.location.href);
} else {
componentThis.props.onLoginSuccess(requireRedirect);
}
} else if (res.data === NextMfa) {
componentThis.setState({
mfaProps: res.data2,
selectedMfaProp: getPreferredMfaProp(res.data2),
}, () => {
body["providerBack"] = body["provider"];
body["provider"] = "";
componentThis.setState({
getVerifyTotp: () => renderMfaAuthVerifyForm(body, params, handleLogin, componentThis),
});
});
} else if (res.data === "SelectPlan") {
// paid-user does not have active or pending subscription, go to application default pricing page to select-plan
const pricing = res.data2;
goToLink(`/select-plan/${pricing.owner}/${pricing.name}?user=${body.username}`);
} else if (res.data === "BuyPlanResult") {
// paid-user has pending subscription, go to buy-plan/result apge to notify payment result
const sub = res.data2;
goToLink(`/buy-plan/${sub.owner}/${sub.pricing}/result?subscription=${sub.name}`);
} else {
handleLogin(res);
}
}
export function getApplicationObj(componentThis) {
return componentThis.props.application;
}
export function parseOffset(offset) {
if (offset === 2 || offset === 4 || inIframe() || isMobile()) {
return "0 auto";
}
if (offset === 1) {
return "0 10%";
}
if (offset === 3) {
return "0 60%";
}
}
function renderMfaAuthVerifyForm(values, authParams, onSuccess, componentThis) {
return (
<div>
<MfaAuthVerifyForm
mfaProps={componentThis.state.selectedMfaProp}
formValues={values}
authParams={authParams}
application={getApplicationObj(componentThis)}
onFail={(errorMessage) => {
showMessage("error", errorMessage);
}}
onSuccess={(res) => onSuccess(res)}
/>
<div>
{
componentThis.state.mfaProps.map((mfa) => {
if (componentThis.state.selectedMfaProp.mfaType === mfa.mfaType) {return null;}
let mfaI18n = "";
switch (mfa.mfaType) {
case SmsMfaType: mfaI18n = i18next.t("mfa:Use SMS"); break;
case TotpMfaType: mfaI18n = i18next.t("mfa:Use Authenticator App"); break ;
case EmailMfaType: mfaI18n = i18next.t("mfa:Use Email") ;break;
}
return <div key={mfa.mfaType}><Button type={"link"} onClick={() => {
componentThis.setState({
selectedMfaProp: mfa,
});
}}>{mfaI18n}</Button></div>;
})
}
</div>
</div>);
}
export function renderLoginPanel(application, getInnerComponent, componentThis) {
return (
<div className="login-content" style={{margin: componentThis.props.preview ?? parseOffset(application.formOffset)}}>
{inIframe() || isMobile() ? null : <div dangerouslySetInnerHTML={{__html: application.formCss}} />}
{inIframe() || !isMobile() ? null : <div dangerouslySetInnerHTML={{__html: application.formCssMobile}} />}
<div className={isDarkTheme(componentThis.props.themeAlgorithm) ? "login-panel-dark" : "login-panel"}>
<div className="side-image" style={{display: application.formOffset !== 4 ? "none" : null}}>
<div dangerouslySetInnerHTML={{__html: application.formSideHtml}} />
</div>
<div className="login-form">
<div>
{
getInnerComponent()
}
</div>
</div>
</div>
</div>
);
}

View File

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

View File

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

View File

@ -34,10 +34,9 @@ import {SendCodeInput} from "../common/SendCodeInput";
import LanguageSelect from "../common/select/LanguageSelect";
import {CaptchaModal, CaptchaRule} from "../common/modal/CaptchaModal";
import RedirectForm from "../common/RedirectForm";
import {MfaAuthVerifyForm, NextMfa, RequiredMfa} from "./mfa/MfaAuthVerifyForm";
import {RequiredMfa} from "./mfa/MfaAuthVerifyForm";
import {GoogleOneTapLoginVirtualButton} from "./GoogleLoginButton";
import * as ProviderButton from "./ProviderButton";
import {EmailMfaType, SmsMfaType, TotpMfaType} from "./MfaSetupPage";
const FaceRecognitionModal = lazy(() => import("../common/modal/FaceRecognitionModal"));
class LoginPage extends React.Component {
@ -439,18 +438,7 @@ class LoginPage extends React.Component {
};
if (res.status === "ok") {
if (res.data === NextMfa) {
this.setState({
mfaProps: res.data2,
selectedMfaProp: this.getPreferredMfaProp(res.data2),
}, () => {
this.setState({
getVerifyTotp: () => this.renderMfaAuthVerifyForm(values, casParams, loginHandler),
});
});
} else {
loginHandler(res);
}
Setting.checkLoginMfa(res, values, casParams, loginHandler, this);
} else {
Setting.showMessage("error", `${i18next.t("application:Failed to sign in")}: ${res.msg}`);
}
@ -505,26 +493,7 @@ class LoginPage extends React.Component {
};
if (res.status === "ok") {
if (res.data === NextMfa) {
this.setState({
mfaProps: res.data2,
selectedMfaProp: this.getPreferredMfaProp(res.data2),
}, () => {
this.setState({
getVerifyTotp: () => this.renderMfaAuthVerifyForm(values, oAuthParams, loginHandler),
});
});
} else if (res.data === "SelectPlan") {
// paid-user does not have active or pending subscription, go to application default pricing page to select-plan
const pricing = res.data2;
Setting.goToLink(`/select-plan/${pricing.owner}/${pricing.name}?user=${values.username}`);
} else if (res.data === "BuyPlanResult") {
// paid-user has pending subscription, go to buy-plan/result apge to notify payment result
const sub = res.data2;
Setting.goToLink(`/buy-plan/${sub.owner}/${sub.pricing}/result?subscription=${sub.name}`);
} else {
loginHandler(res);
}
Setting.checkLoginMfa(res, values, oAuthParams, loginHandler, this);
} else {
Setting.showMessage("error", `${i18next.t("application:Failed to sign in")}: ${res.msg}`);
}
@ -532,49 +501,6 @@ class LoginPage extends React.Component {
}
}
renderMfaAuthVerifyForm(values, authParams, onSuccess) {
return (
<div>
<MfaAuthVerifyForm
mfaProps={this.state.selectedMfaProp}
formValues={values}
authParams={authParams}
application={this.getApplicationObj()}
onFail={(errorMessage) => {
Setting.showMessage("error", errorMessage);
}}
onSuccess={(res) => onSuccess(res)}
/>
<div>
{
this.state.mfaProps.map((mfa) => {
if (this.state.selectedMfaProp.mfaType === mfa.mfaType) {return null;}
let mfaI18n = "";
switch (mfa.mfaType) {
case SmsMfaType: mfaI18n = i18next.t("mfa:Use SMS"); break;
case TotpMfaType: mfaI18n = i18next.t("mfa:Use Authenticator App"); break ;
case EmailMfaType: mfaI18n = i18next.t("mfa:Use Email") ;break;
}
return <div key={mfa.mfaType}><Button type={"link"} onClick={() => {
this.setState({
selectedMfaProp: mfa,
});
}}>{mfaI18n}</Button></div>;
})
}
</div>
</div>);
}
getPreferredMfaProp(mfaProps) {
for (const i in mfaProps) {
if (mfaProps[i].isPreffered) {
return mfaProps[i];
}
}
return mfaProps[0];
}
isProviderVisible(providerItem) {
if (this.state.mode === "signup") {
return Setting.isProviderVisibleForSignUp(providerItem);

View File

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

View File

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

View File

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