feat(user): per-group login IP whitelist

Group settings gain login_ip_whitelist (IPs/CIDRs). Enforced at password
sign-in and both SSO paths after credential validation; empty list keeps
the current behavior, malformed entries are ignored. Admin group editor
exposes a one-per-line textarea (en-US, zh-CN).

Authored By: TDvorak <info@tdvorak.dev>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
pull/3582/head
Tomas Dvorak 2 weeks ago
parent 7433c770db
commit 03d6d49243

@ -1269,6 +1269,8 @@
"initialStorageQuotaDes": "Max storage can used by single user under this group.", "initialStorageQuotaDes": "Max storage can used by single user under this group.",
"isAdmin": "Admin group", "isAdmin": "Admin group",
"isAdminDes": "When enabled, users under this group will have admin permissions.", "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", "share": "Share",
"allowCreateShareLink": "Create share link", "allowCreateShareLink": "Create share link",
"allowCreateShareLinkDes": "If disabled, users cannot create sharing links.", "allowCreateShareLinkDes": "If disabled, users cannot create sharing links.",

@ -1269,6 +1269,8 @@
"initialStorageQuotaDes": "用户组下的用户初始可用最大容量。", "initialStorageQuotaDes": "用户组下的用户初始可用最大容量。",
"isAdmin": "管理员用户组", "isAdmin": "管理员用户组",
"isAdminDes": "开启后,用户组下的用户将拥有管理员权限。", "isAdminDes": "开启后,用户组下的用户将拥有管理员权限。",
"loginIPWhitelist": "登录 IP 白名单",
"loginIPWhitelistDes": "仅允许以下 IP 或 CIDR 网段登录,每行一条。留空则不限制。",
"share": "分享", "share": "分享",
"allowCreateShareLink": "创建分享链接", "allowCreateShareLink": "创建分享链接",
"allowCreateShareLinkDes": "关闭后,用户无法创建分享链接。", "allowCreateShareLinkDes": "关闭后,用户无法创建分享链接。",

@ -61,6 +61,7 @@ export interface GroupSetting {
max_walked_files?: number; max_walked_files?: number;
trash_retention?: number; trash_retention?: number;
redirected_source?: boolean; redirected_source?: boolean;
login_ip_whitelist?: string[];
} }
export interface AdminListGroupResponse { export interface AdminListGroupResponse {

@ -57,6 +57,20 @@ const BasicInfoSection = () => {
[setGroup], [setGroup],
); );
const onWhitelistChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
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 ( return (
<SettingSection> <SettingSection>
<Typography variant="h6" gutterBottom> <Typography variant="h6" gutterBottom>
@ -105,6 +119,18 @@ const BasicInfoSection = () => {
<NoMarginHelperText>{t("group.isAdminDes")}</NoMarginHelperText> <NoMarginHelperText>{t("group.isAdminDes")}</NoMarginHelperText>
</FormControl> </FormControl>
</SettingForm> </SettingForm>
<SettingForm title={t("group.loginIPWhitelist")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField
multiline
minRows={2}
value={(values?.settings?.login_ip_whitelist ?? []).join("\n")}
onChange={onWhitelistChange}
placeholder={"10.0.0.0/8"}
/>
<NoMarginHelperText>{t("group.loginIPWhitelistDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
</> </>
)} )}
</SettingSectionContent> </SettingSectionContent>

@ -39,6 +39,9 @@ type (
MaxWalkedFiles int `json:"max_walked_files,omitempty"` MaxWalkedFiles int `json:"max_walked_files,omitempty"`
TrashRetention int `json:"trash_retention,omitempty"` TrashRetention int `json:"trash_retention,omitempty"`
RedirectedSource bool `json:"redirected_source,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 非公有的存储策略属性 // PolicySetting 非公有的存储策略属性

@ -3,6 +3,8 @@ package user
import ( import (
"context" "context"
"fmt" "fmt"
"net/netip"
"strings"
"github.com/cloudreve/Cloudreve/v4/application/dependency" "github.com/cloudreve/Cloudreve/v4/application/dependency"
"github.com/cloudreve/Cloudreve/v4/ent" "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") err = banError(expectedUser, "This account has been blocked")
} else if expectedUser.Status == user.StatusInactive { } else if expectedUser.Status == user.StatusInactive {
err = serializer.NewError(serializer.CodeUserNotActivated, "This account is not activated", nil) 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 { if err != nil {
@ -270,3 +274,35 @@ func (service *PrepareLoginService) Prepare(c *gin.Context) (*PrepareLoginRespon
PasswordEnabled: expectedUser.Password != "", PasswordEnabled: expectedUser.Password != "",
}, nil }, 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)
}

@ -1,6 +1,7 @@
package user package user
import ( import (
"context"
"crypto/sha256" "crypto/sha256"
"encoding/base64" "encoding/base64"
"encoding/hex" "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) 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 { if err != nil {
return nil, serializer.NewError(serializer.CodeUserNotFound, "User not found", err) return nil, serializer.NewError(serializer.CodeUserNotFound, "User not found", err)
} }
if u, err = dep.UserClient().LiftExpiredBan(c, u); err != nil { if u, err = dep.UserClient().LiftExpiredBan(c, u); err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to lift expired ban", err) 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 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) { func ssoResolveUser(c *gin.Context, dep dependency.Dep, sso *setting.SSO, email, name, preferred string) (*ent.User, error) {
userClient := dep.UserClient() userClient := dep.UserClient()
u, err := userClient.GetByEmail(c, email) u, err := userClient.GetByEmail(context.WithValue(c, inventory.LoadUserGroup{}, true), email)
if err == nil { if err == nil {
if u, err = userClient.LiftExpiredBan(c, u); err != nil { if u, err = userClient.LiftExpiredBan(c, u); err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to lift expired ban", err) 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 nil, err
} }
return u, nil return u, nil
@ -323,14 +325,14 @@ func banError(u *ent.User, fallback string) error {
return serializer.NewError(serializer.CodeUserBaned, msg, nil) 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 { switch u.Status {
case user.StatusSysBanned, user.StatusManualBanned: case user.StatusSysBanned, user.StatusManualBanned:
return banError(u, "User is banned") return banError(u, "User is banned")
case user.StatusInactive: case user.StatusInactive:
return serializer.NewError(serializer.CodeUserNotActivated, "User is not activated", nil) 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 // CheckEmailAllowed enforces the sign-up email filter. Shared by the classic

@ -3,6 +3,8 @@ package user
import ( import (
"testing" "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/serializer"
"github.com/cloudreve/Cloudreve/v4/pkg/setting" "github.com/cloudreve/Cloudreve/v4/pkg/setting"
"github.com/stretchr/testify/require" "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("", `CORP\user`, "not-an-email"))
require.Equal(t, "", firstEmailClaim()) 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")))
}

Loading…
Cancel
Save