From 7cd8f030ee198eea3108de9488b1033fbd7e8725 Mon Sep 17 00:00:00 2001 From: ZhaoYP 2001 <75831053+ZhaoYP-2001@users.noreply.github.com> Date: Tue, 15 Oct 2024 20:40:14 +0800 Subject: [PATCH] feat: support IP limitation for user entry pages (#3267) * feat: support IP limitation for user entry pages * fix: error message, ip whiteList, check_entry_ip * fix: perform checks on the backend * fix: change the implementation of checking IpWhitelist * fix: add entryIpCheck in SetPassword and remove it from VerifyCode * fix: remove additional error message pop-ups * fix: add isRestricted and show ip error in EntryPage.js * fix: error message * Update auth.go * Update check_ip.go * Update check_ip.go * fix: update return value of the check function from string to error * fix: remoteAddress position * fix: IP whitelist * fix: clientIp * fix:add util.GetClientIpFromRequest * fix: remove duplicate IP and port separation codes and remove extra special characters after clientIp * fix: gofumpt * fix: getIpInfo and localhost --------- Co-authored-by: Yang Luo --- controllers/account.go | 7 +++ controllers/application.go | 13 +++++ controllers/auth.go | 10 ++++ controllers/organization.go | 10 ++++ controllers/user.go | 22 +++++++ object/application.go | 2 + object/check.go | 5 ++ object/check_ip.go | 100 ++++++++++++++++++++++++++++++++ object/organization.go | 2 + object/user.go | 3 +- object/user_util.go | 8 +++ util/log.go | 21 +++---- web/src/ApplicationEditPage.js | 10 ++++ web/src/EntryPage.js | 9 +++ web/src/OrganizationEditPage.js | 10 ++++ web/src/UserEditPage.js | 13 +++++ web/src/table/AccountTable.js | 1 + 17 files changed, 235 insertions(+), 11 deletions(-) create mode 100644 object/check_ip.go 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")},