diff --git a/frontend/public/locales/en-US/dashboard.json b/frontend/public/locales/en-US/dashboard.json index 420976dc..2a3f3ce6 100644 --- a/frontend/public/locales/en-US/dashboard.json +++ b/frontend/public/locales/en-US/dashboard.json @@ -1269,6 +1269,8 @@ "initialStorageQuotaDes": "Max storage can used by single user under this group.", "isAdmin": "Admin group", "isAdminDes": "When enabled, users under this group will have admin permissions.", + "loginIPWhitelist": "Login IP whitelist", + "loginIPWhitelistDes": "Restrict sign-in to these IPs or CIDR ranges, one per line. Leave empty to allow all.", "share": "Share", "allowCreateShareLink": "Create share link", "allowCreateShareLinkDes": "If disabled, users cannot create sharing links.", diff --git a/frontend/public/locales/zh-CN/dashboard.json b/frontend/public/locales/zh-CN/dashboard.json index 5f5cda26..c6f9bdcb 100644 --- a/frontend/public/locales/zh-CN/dashboard.json +++ b/frontend/public/locales/zh-CN/dashboard.json @@ -1269,6 +1269,8 @@ "initialStorageQuotaDes": "用户组下的用户初始可用最大容量。", "isAdmin": "管理员用户组", "isAdminDes": "开启后,用户组下的用户将拥有管理员权限。", + "loginIPWhitelist": "登录 IP 白名单", + "loginIPWhitelistDes": "仅允许以下 IP 或 CIDR 网段登录,每行一条。留空则不限制。", "share": "分享", "allowCreateShareLink": "创建分享链接", "allowCreateShareLinkDes": "关闭后,用户无法创建分享链接。", diff --git a/frontend/src/api/dashboard.ts b/frontend/src/api/dashboard.ts index 0e41f7ee..77b6c377 100644 --- a/frontend/src/api/dashboard.ts +++ b/frontend/src/api/dashboard.ts @@ -61,6 +61,7 @@ export interface GroupSetting { max_walked_files?: number; trash_retention?: number; redirected_source?: boolean; + login_ip_whitelist?: string[]; } export interface AdminListGroupResponse { diff --git a/frontend/src/component/Admin/Group/EditGroup/BasicInfoSection.tsx b/frontend/src/component/Admin/Group/EditGroup/BasicInfoSection.tsx index 27be3323..35691c63 100644 --- a/frontend/src/component/Admin/Group/EditGroup/BasicInfoSection.tsx +++ b/frontend/src/component/Admin/Group/EditGroup/BasicInfoSection.tsx @@ -57,6 +57,20 @@ const BasicInfoSection = () => { [setGroup], ); + const onWhitelistChange = useCallback( + (e: React.ChangeEvent) => { + const list = e.target.value + .split("\n") + .map((l) => l.trim()) + .filter((l) => l != ""); + setGroup((p: GroupEnt) => ({ + ...p, + settings: { ...p.settings, login_ip_whitelist: list.length > 0 ? list : undefined }, + })); + }, + [setGroup], + ); + return ( @@ -105,6 +119,18 @@ const BasicInfoSection = () => { {t("group.isAdminDes")} + + + + {t("group.loginIPWhitelistDes")} + + )} diff --git a/inventory/types/types.go b/inventory/types/types.go index b71de0b4..a470ba47 100644 --- a/inventory/types/types.go +++ b/inventory/types/types.go @@ -39,6 +39,9 @@ type ( MaxWalkedFiles int `json:"max_walked_files,omitempty"` TrashRetention int `json:"trash_retention,omitempty"` RedirectedSource bool `json:"redirected_source,omitempty"` + // LoginIPWhitelist restricts sign-in to the given IPs/CIDR ranges. + // Empty means no restriction. + LoginIPWhitelist []string `json:"login_ip_whitelist,omitempty"` } // PolicySetting 非公有的存储策略属性 diff --git a/service/user/login.go b/service/user/login.go index 9ff68f6c..899afba9 100644 --- a/service/user/login.go +++ b/service/user/login.go @@ -3,6 +3,8 @@ package user import ( "context" "fmt" + "net/netip" + "strings" "github.com/cloudreve/Cloudreve/v4/application/dependency" "github.com/cloudreve/Cloudreve/v4/ent" @@ -144,6 +146,8 @@ func (service *UserLoginService) Login(c *gin.Context) (*ent.User, string, error err = banError(expectedUser, "This account has been blocked") } else if expectedUser.Status == user.StatusInactive { err = serializer.NewError(serializer.CodeUserNotActivated, "This account is not activated", nil) + } else if ipErr := checkLoginIPWhitelist(c.ClientIP(), expectedUser.Edges.Group); ipErr != nil { + err = ipErr } if err != nil { @@ -270,3 +274,35 @@ func (service *PrepareLoginService) Prepare(c *gin.Context) (*PrepareLoginRespon PasswordEnabled: expectedUser.Password != "", }, nil } + +// checkLoginIPWhitelist enforces a group's login IP whitelist. Entries are +// exact IPs or CIDR ranges; an empty or nil list allows all addresses. +// Malformed entries are ignored so a bad admin value cannot lock everyone out. +func checkLoginIPWhitelist(clientIP string, group *ent.Group) error { + if group == nil || group.Settings == nil || len(group.Settings.LoginIPWhitelist) == 0 { + return nil + } + + addr, err := netip.ParseAddr(clientIP) + if err != nil { + return serializer.NewError(serializer.CodeNoPermissionErr, "Cannot determine client IP", err) + } + + for _, entry := range group.Settings.LoginIPWhitelist { + entry = strings.TrimSpace(entry) + if entry == "" { + continue + } + if prefix, perr := netip.ParsePrefix(entry); perr == nil { + if prefix.Contains(addr) { + return nil + } + continue + } + if entryAddr, aerr := netip.ParseAddr(entry); aerr == nil && entryAddr == addr { + return nil + } + } + + return serializer.NewError(serializer.CodeNoPermissionErr, "Login from this IP address is not allowed", nil) +} diff --git a/service/user/sso.go b/service/user/sso.go index 4bcc05e9..4eebf411 100644 --- a/service/user/sso.go +++ b/service/user/sso.go @@ -1,6 +1,7 @@ package user import ( + "context" "crypto/sha256" "encoding/base64" "encoding/hex" @@ -254,14 +255,15 @@ func (service *SSOExchangeService) SSOExchange(c *gin.Context) (any, error) { return nil, serializer.NewError(serializer.CodeCredentialInvalid, "Invalid SSO ticket", nil) } - u, err := dep.UserClient().GetByID(c, uid) + ctx := context.WithValue(c, inventory.LoadUserGroup{}, true) + u, err := dep.UserClient().GetByID(ctx, uid) if err != nil { return nil, serializer.NewError(serializer.CodeUserNotFound, "User not found", err) } if u, err = dep.UserClient().LiftExpiredBan(c, u); err != nil { return nil, serializer.NewError(serializer.CodeDBError, "Failed to lift expired ban", err) } - if err := checkUserStatus(u); err != nil { + if err := checkUserStatus(c, u); err != nil { return nil, err } @@ -273,12 +275,12 @@ func (service *SSOExchangeService) SSOExchange(c *gin.Context) (any, error) { func ssoResolveUser(c *gin.Context, dep dependency.Dep, sso *setting.SSO, email, name, preferred string) (*ent.User, error) { userClient := dep.UserClient() - u, err := userClient.GetByEmail(c, email) + u, err := userClient.GetByEmail(context.WithValue(c, inventory.LoadUserGroup{}, true), email) if err == nil { if u, err = userClient.LiftExpiredBan(c, u); err != nil { return nil, serializer.NewError(serializer.CodeDBError, "Failed to lift expired ban", err) } - if err := checkUserStatus(u); err != nil { + if err := checkUserStatus(c, u); err != nil { return nil, err } return u, nil @@ -323,14 +325,14 @@ func banError(u *ent.User, fallback string) error { return serializer.NewError(serializer.CodeUserBaned, msg, nil) } -func checkUserStatus(u *ent.User) error { +func checkUserStatus(c *gin.Context, u *ent.User) error { switch u.Status { case user.StatusSysBanned, user.StatusManualBanned: return banError(u, "User is banned") case user.StatusInactive: return serializer.NewError(serializer.CodeUserNotActivated, "User is not activated", nil) } - return nil + return checkLoginIPWhitelist(c.ClientIP(), u.Edges.Group) } // CheckEmailAllowed enforces the sign-up email filter. Shared by the classic diff --git a/service/user/sso_test.go b/service/user/sso_test.go index d9534981..7450294b 100644 --- a/service/user/sso_test.go +++ b/service/user/sso_test.go @@ -3,6 +3,8 @@ package user import ( "testing" + "github.com/cloudreve/Cloudreve/v4/ent" + "github.com/cloudreve/Cloudreve/v4/inventory/types" "github.com/cloudreve/Cloudreve/v4/pkg/serializer" "github.com/cloudreve/Cloudreve/v4/pkg/setting" "github.com/stretchr/testify/require" @@ -172,3 +174,25 @@ func TestFirstEmailClaim(t *testing.T) { require.Equal(t, "", firstEmailClaim("", `CORP\user`, "not-an-email")) require.Equal(t, "", firstEmailClaim()) } + +func TestCheckLoginIPWhitelist(t *testing.T) { + group := func(list ...string) *ent.Group { + return &ent.Group{Settings: &types.GroupSetting{LoginIPWhitelist: list}} + } + + // Empty whitelist allows everything. + require.NoError(t, checkLoginIPWhitelist("1.2.3.4", nil)) + require.NoError(t, checkLoginIPWhitelist("1.2.3.4", group())) + + // Exact IP match. + require.NoError(t, checkLoginIPWhitelist("10.0.0.5", group("10.0.0.5", "192.168.0.0/16"))) + require.Error(t, checkLoginIPWhitelist("10.0.0.6", group("10.0.0.5"))) + + // CIDR ranges, v4 and v6. + require.NoError(t, checkLoginIPWhitelist("192.168.1.9", group("192.168.0.0/16"))) + require.NoError(t, checkLoginIPWhitelist("fd00::42", group("fd00::/8"))) + require.Error(t, checkLoginIPWhitelist("8.8.8.8", group("192.168.0.0/16", "fd00::/8"))) + + // Malformed entries are skipped rather than locking everyone out. + require.NoError(t, checkLoginIPWhitelist("1.2.3.4", group("not-an-ip", "1.2.3.4"))) +}