diff --git a/controllers/account.go b/controllers/account.go index 18821235..1eadae73 100644 --- a/controllers/account.go +++ b/controllers/account.go @@ -116,6 +116,13 @@ func (c *ApiController) Signup() { return } + clientIp := util.GetClientIpFromRequest(c.Ctx.Request) + err = object.CheckEntryIp(clientIp, nil, application, organization, c.GetAcceptLanguage()) + if err != nil { + c.ResponseError(err.Error()) + return + } + msg := object.CheckUserSignup(application, organization, &authForm, c.GetAcceptLanguage()) if msg != "" { c.ResponseError(msg) diff --git a/controllers/application.go b/controllers/application.go index 755cf735..6514d8cc 100644 --- a/controllers/application.go +++ b/controllers/application.go @@ -110,6 +110,9 @@ func (c *ApiController) GetApplication() { } } + clientIp := util.GetClientIpFromRequest(c.Ctx.Request) + object.CheckEntryIp(clientIp, nil, application, nil, c.GetAcceptLanguage()) + c.ResponseOk(object.GetMaskedApplication(application, userId)) } @@ -229,6 +232,11 @@ func (c *ApiController) UpdateApplication() { return } + if err = object.CheckIpWhitelist(application.IpWhitelist, c.GetAcceptLanguage()); err != nil { + c.ResponseError(err.Error()) + return + } + c.Data["json"] = wrapActionResponse(object.UpdateApplication(id, &application)) c.ServeJSON() } @@ -259,6 +267,11 @@ func (c *ApiController) AddApplication() { return } + if err = object.CheckIpWhitelist(application.IpWhitelist, c.GetAcceptLanguage()); err != nil { + c.ResponseError(err.Error()) + return + } + c.Data["json"] = wrapActionResponse(object.AddApplication(&application)) c.ServeJSON() } diff --git a/controllers/auth.go b/controllers/auth.go index 900cfd5b..1e8b1939 100644 --- a/controllers/auth.go +++ b/controllers/auth.go @@ -55,6 +55,13 @@ func tokenToResponse(token *object.Token) *Response { func (c *ApiController) HandleLoggedIn(application *object.Application, user *object.User, form *form.AuthForm) (resp *Response) { userId := user.GetId() + clientIp := util.GetClientIpFromRequest(c.Ctx.Request) + err := object.CheckEntryIp(clientIp, user, application, application.OrganizationObj, c.GetAcceptLanguage()) + if err != nil { + c.ResponseError(err.Error()) + return + } + allowed, err := object.CheckLoginPermission(userId, application) if err != nil { c.ResponseError(err.Error(), nil) @@ -256,6 +263,9 @@ func (c *ApiController) GetApplicationLogin() { } } + clientIp := util.GetClientIpFromRequest(c.Ctx.Request) + object.CheckEntryIp(clientIp, nil, application, nil, c.GetAcceptLanguage()) + application = object.GetMaskedApplication(application, "") if msg != "" { c.ResponseError(msg, application) diff --git a/controllers/organization.go b/controllers/organization.go index 5f117366..8e52d646 100644 --- a/controllers/organization.go +++ b/controllers/organization.go @@ -119,6 +119,11 @@ func (c *ApiController) UpdateOrganization() { return } + if err = object.CheckIpWhitelist(organization.IpWhitelist, c.GetAcceptLanguage()); err != nil { + c.ResponseError(err.Error()) + return + } + c.Data["json"] = wrapActionResponse(object.UpdateOrganization(id, &organization)) c.ServeJSON() } @@ -149,6 +154,11 @@ func (c *ApiController) AddOrganization() { return } + if err = object.CheckIpWhitelist(organization.IpWhitelist, c.GetAcceptLanguage()); err != nil { + c.ResponseError(err.Error()) + return + } + c.Data["json"] = wrapActionResponse(object.AddOrganization(&organization)) c.ServeJSON() } diff --git a/controllers/user.go b/controllers/user.go index 02beb544..8e992680 100644 --- a/controllers/user.go +++ b/controllers/user.go @@ -370,6 +370,11 @@ func (c *ApiController) AddUser() { return } + if err = object.CheckIpWhitelist(user.IpWhitelist, c.GetAcceptLanguage()); err != nil { + c.ResponseError(err.Error()) + return + } + c.Data["json"] = wrapActionResponse(object.AddUser(&user)) c.ServeJSON() } @@ -535,6 +540,23 @@ func (c *ApiController) SetPassword() { return } + application, err := object.GetApplicationByUser(targetUser) + if err != nil { + c.ResponseError(err.Error()) + return + } + if application == nil { + c.ResponseError(fmt.Sprintf(c.T("auth:the application for user %s is not found"), userId)) + return + } + + clientIp := util.GetClientIpFromRequest(c.Ctx.Request) + err = object.CheckEntryIp(clientIp, targetUser, application, organization, c.GetAcceptLanguage()) + if err != nil { + c.ResponseError(err.Error()) + return + } + targetUser.Password = newPassword targetUser.UpdateUserPassword(organization) targetUser.NeedUpdatePassword = false diff --git a/object/application.go b/object/application.go index 8915886d..1717c912 100644 --- a/object/application.go +++ b/object/application.go @@ -95,6 +95,7 @@ type Application struct { Tags []string `xorm:"mediumtext" json:"tags"` SamlAttributes []*SamlItem `xorm:"varchar(1000)" json:"samlAttributes"` IsShared bool `json:"isShared"` + IpRestriction string `json:"ipRestriction"` ClientId string `xorm:"varchar(100)" json:"clientId"` ClientSecret string `xorm:"varchar(100)" json:"clientSecret"` @@ -108,6 +109,7 @@ type Application struct { SigninUrl string `xorm:"varchar(200)" json:"signinUrl"` ForgetUrl string `xorm:"varchar(200)" json:"forgetUrl"` AffiliationUrl string `xorm:"varchar(100)" json:"affiliationUrl"` + IpWhitelist string `xorm:"varchar(200)" json:"ipWhitelist"` TermsOfUse string `xorm:"varchar(100)" json:"termsOfUse"` SignupHtml string `xorm:"mediumtext" json:"signupHtml"` SigninHtml string `xorm:"mediumtext" json:"signinHtml"` diff --git a/object/check.go b/object/check.go index 5200cf1f..f414cbe7 100644 --- a/object/check.go +++ b/object/check.go @@ -539,6 +539,11 @@ func CheckUpdateUser(oldUser, user *User, lang string) string { return i18n.Translate(lang, "check:Phone already exists") } } + if oldUser.IpWhitelist != user.IpWhitelist { + if err := CheckIpWhitelist(user.IpWhitelist, lang); err != nil { + return err.Error() + } + } return "" } diff --git a/object/check_ip.go b/object/check_ip.go new file mode 100644 index 00000000..4a4c4a71 --- /dev/null +++ b/object/check_ip.go @@ -0,0 +1,100 @@ +// Copyright 2024 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" + "net" + "strings" + + "github.com/casdoor/casdoor/i18n" +) + +func CheckEntryIp(clientIp string, user *User, application *Application, organization *Organization, lang string) error { + entryIp := net.ParseIP(clientIp) + if entryIp == nil { + return fmt.Errorf(i18n.Translate(lang, "check:Failed to parse client IP: %s"), clientIp) + } else if entryIp.IsLoopback() { + return nil + } + + var err error + if user != nil { + err = isEntryIpAllowd(user.IpWhitelist, entryIp, lang) + if err != nil { + return fmt.Errorf(err.Error() + user.Name) + } + } + + if application != nil { + err = isEntryIpAllowd(application.IpWhitelist, entryIp, lang) + if err != nil { + application.IpRestriction = err.Error() + application.Name + return fmt.Errorf(err.Error() + application.Name) + } + } + + if organization == nil && application.OrganizationObj != nil { + organization = application.OrganizationObj + } + + if organization != nil { + err = isEntryIpAllowd(organization.IpWhitelist, entryIp, lang) + if err != nil { + organization.IpRestriction = err.Error() + organization.Name + return fmt.Errorf(err.Error() + organization.Name) + } + } + + return nil +} + +func isEntryIpAllowd(ipWhitelistStr string, entryIp net.IP, lang string) error { + if ipWhitelistStr == "" { + return nil + } + + ipWhitelist := strings.Split(ipWhitelistStr, ",") + for _, ip := range ipWhitelist { + _, ipNet, err := net.ParseCIDR(ip) + if err != nil { + return err + } + if ipNet == nil { + return fmt.Errorf(i18n.Translate(lang, "check:CIDR for IP: %s should not be empty"), entryIp.String()) + } + + if ipNet.Contains(entryIp) { + return nil + } + } + + return fmt.Errorf(i18n.Translate(lang, "check:Your IP address: %s has been banned according to the configuration of: "), entryIp.String()) +} + +func CheckIpWhitelist(ipWhitelistStr string, lang string) error { + if ipWhitelistStr == "" { + return nil + } + + ipWhiteList := strings.Split(ipWhitelistStr, ",") + for _, ip := range ipWhiteList { + if _, _, err := net.ParseCIDR(ip); err != nil { + return fmt.Errorf(i18n.Translate(lang, "check:%s does not meet the CIDR format requirements: %s"), ip, err.Error()) + } + } + + return nil +} diff --git a/object/organization.go b/object/organization.go index b54885e7..46d239ef 100644 --- a/object/organization.go +++ b/object/organization.go @@ -71,11 +71,13 @@ type Organization struct { MasterPassword string `xorm:"varchar(100)" json:"masterPassword"` DefaultPassword string `xorm:"varchar(100)" json:"defaultPassword"` MasterVerificationCode string `xorm:"varchar(100)" json:"masterVerificationCode"` + IpWhitelist string `xorm:"varchar(200)" json:"ipWhitelist"` InitScore int `json:"initScore"` EnableSoftDeletion bool `json:"enableSoftDeletion"` IsProfilePublic bool `json:"isProfilePublic"` UseEmailAsUsername bool `json:"useEmailAsUsername"` EnableTour bool `json:"enableTour"` + IpRestriction string `json:"ipRestriction"` MfaItems []*MfaItem `xorm:"varchar(300)" json:"mfaItems"` AccountItems []*AccountItem `xorm:"varchar(5000)" json:"accountItems"` diff --git a/object/user.go b/object/user.go index 616f1a30..3d488186 100644 --- a/object/user.go +++ b/object/user.go @@ -206,6 +206,7 @@ type User struct { ManagedAccounts []ManagedAccount `xorm:"managedAccounts blob" json:"managedAccounts"` MfaAccounts []MfaAccount `xorm:"mfaAccounts blob" json:"mfaAccounts"` NeedUpdatePassword bool `json:"needUpdatePassword"` + IpWhitelist string `xorm:"varchar(200)" json:"ipWhitelist"` } type Userinfo struct { @@ -696,7 +697,7 @@ func UpdateUser(id string, user *User, columns []string, isAdmin bool) (bool, er "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", + "yammer", "yandex", "zoom", "custom", "need_update_password", "ip_whitelist", } } if isAdmin { diff --git a/object/user_util.go b/object/user_util.go index 55e09962..f55f9715 100644 --- a/object/user_util.go +++ b/object/user_util.go @@ -557,6 +557,14 @@ func CheckPermissionForUpdateUser(oldUser, newUser *User, isAdmin bool, lang str itemsChanged = append(itemsChanged, item) } } + if oldUser.IpWhitelist != newUser.IpWhitelist { + item := GetAccountItemByName("IP whitelist", organization) + if item == nil { + newUser.IpWhitelist = oldUser.IpWhitelist + } else { + itemsChanged = append(itemsChanged, item) + } + } if oldUser.Balance != newUser.Balance { item := GetAccountItemByName("Balance", organization) diff --git a/util/log.go b/util/log.go index e2bc536d..88e6a475 100644 --- a/util/log.go +++ b/util/log.go @@ -29,16 +29,17 @@ func getIpInfo(clientIp string) string { } ips := strings.Split(clientIp, ",") - res := "" - for i := range ips { - ip := strings.TrimSpace(ips[i]) - ipstr := fmt.Sprintf("%s: %s", ip, "") - if i != len(ips)-1 { - res += ipstr + " -> " - } else { - res += ipstr - } - } + res := strings.TrimSpace(ips[0]) + //res := "" + //for i := range ips { + // ip := strings.TrimSpace(ips[i]) + // ipstr := fmt.Sprintf("%s: %s", ip, "") + // if i != len(ips)-1 { + // res += ipstr + " -> " + // } else { + // res += ipstr + // } + //} return res } diff --git a/web/src/ApplicationEditPage.js b/web/src/ApplicationEditPage.js index b00cd0bf..a84f38fc 100644 --- a/web/src/ApplicationEditPage.js +++ b/web/src/ApplicationEditPage.js @@ -598,6 +598,16 @@ class ApplicationEditPage extends React.Component { }} /> + + + {Setting.getLabel(i18next.t("general:IP whitelist"), i18next.t("general:IP whitelist - Tooltip"))} : + + + { + this.updateApplicationField("ipWhitelist", e.target.value); + }} /> + + {Setting.getLabel(i18next.t("signup:Terms of Use"), i18next.t("signup:Terms of Use - Tooltip"))} : diff --git a/web/src/EntryPage.js b/web/src/EntryPage.js index 1e342e9e..bd66a879 100644 --- a/web/src/EntryPage.js +++ b/web/src/EntryPage.js @@ -34,6 +34,7 @@ import PaymentResultPage from "./PaymentResultPage"; import QrCodePage from "./QrCodePage"; import CaptchaPage from "./CaptchaPage"; import CustomHead from "./basic/CustomHead"; +import * as Util from "./auth/Util"; class EntryPage extends React.Component { constructor(props) { @@ -94,6 +95,14 @@ class EntryPage extends React.Component { }); }; + if (this.state.application?.ipRestriction) { + return Util.renderMessageLarge(this, this.state.application.ipRestriction); + } + + if (this.state.application?.organizationObj?.ipRestriction) { + return Util.renderMessageLarge(this, this.state.application.organizationObj.ipRestriction); + } + const isDarkMode = this.props.themeAlgorithm.includes("dark"); return ( diff --git a/web/src/OrganizationEditPage.js b/web/src/OrganizationEditPage.js index 9c8c55b3..837ec63c 100644 --- a/web/src/OrganizationEditPage.js +++ b/web/src/OrganizationEditPage.js @@ -452,6 +452,16 @@ class OrganizationEditPage extends React.Component { }} /> + + + {Setting.getLabel(i18next.t("general:IP whitelist"), i18next.t("general:IP whitelist - Tooltip"))} : + + + { + this.updateOrganizationField("ipWhitelist", e.target.value); + }} /> + + {Setting.getLabel(i18next.t("organization:Init score"), i18next.t("organization:Init score - Tooltip"))} : diff --git a/web/src/UserEditPage.js b/web/src/UserEditPage.js index f7e0393a..47e855e3 100644 --- a/web/src/UserEditPage.js +++ b/web/src/UserEditPage.js @@ -1070,6 +1070,19 @@ class UserEditPage extends React.Component { ); + } else if (accountItem.name === "IP whitelist") { + return ( + + + {Setting.getLabel(i18next.t("general:IP whitelist"), i18next.t("general:IP whitelist - Tooltip"))} : + + + { + this.updateUserField("ipWhitelist", e.target.value); + }} /> + + + ); } } diff --git a/web/src/table/AccountTable.js b/web/src/table/AccountTable.js index fd98a90e..3966f0f5 100644 --- a/web/src/table/AccountTable.js +++ b/web/src/table/AccountTable.js @@ -104,6 +104,7 @@ class AccountTable extends React.Component { {name: "Is forbidden", label: i18next.t("user:Is forbidden")}, {name: "Is deleted", label: i18next.t("user:Is deleted")}, {name: "Need update password", label: i18next.t("user:Need update password")}, + {name: "IP whitelist", label: i18next.t("general:IP whitelist")}, {name: "Multi-factor authentication", label: i18next.t("user:Multi-factor authentication")}, {name: "WebAuthn credentials", label: i18next.t("user:WebAuthn credentials")}, {name: "Managed accounts", label: i18next.t("user:Managed accounts")},