diff --git a/server/api/admin/imconfiginternal/im_config.go b/server/api/admin/imconfiginternal/im_config.go index 4b01c5c..9acd7e7 100644 --- a/server/api/admin/imconfiginternal/im_config.go +++ b/server/api/admin/imconfiginternal/im_config.go @@ -57,3 +57,14 @@ type ThirdPartyReq struct { type ThirdPartyRes struct { Values map[string]string `json:"values" dc:"配置值"` } + +type TenantLoginIPWhitelistReq struct { + g.Meta `path:"/internal/im-config/tenants/by-id/{id}/login-ip-whitelist" method:"get" tags:"IM内部配置" summary:"获取租户渠道登录IP白名单"` + Id int64 `json:"id" in:"path" dc:"租户ID"` +} + +type TenantLoginIPWhitelistRes struct { + TenantId int64 `json:"tenant_id" dc:"租户ID"` + AppList []string `json:"app_list" dc:"APP登录IP白名单"` + WebList []string `json:"web_list" dc:"Web登录IP白名单"` +} diff --git a/server/api/admin/imuserpolicygroup/im_user_policy_group.go b/server/api/admin/imuserpolicygroup/im_user_policy_group.go index fc6b0ac..9398965 100644 --- a/server/api/admin/imuserpolicygroup/im_user_policy_group.go +++ b/server/api/admin/imuserpolicygroup/im_user_policy_group.go @@ -55,3 +55,16 @@ type MemberRemoveReq struct { sysin.ImUserPolicyGroupMemberRemoveInp } type MemberRemoveRes struct{} + +type LoginIPWhitelistReq struct { + g.Meta `path:"/imUserPolicyGroup/loginIPWhitelist" method:"get" tags:"IM用户策略分组" summary:"获取租户登录IP白名单"` + sysin.ImTenantLoginIPWhitelistInp +} +type LoginIPWhitelistRes struct { + *sysin.ImTenantLoginIPWhitelistModel +} +type LoginIPWhitelistSaveReq struct { + g.Meta `path:"/imUserPolicyGroup/loginIPWhitelistSave" method:"post" tags:"IM用户策略分组" summary:"保存租户登录IP白名单"` + sysin.ImTenantLoginIPWhitelistSaveInp +} +type LoginIPWhitelistSaveRes struct{} diff --git a/server/internal/consts/im_user.go b/server/internal/consts/im_user.go index b9f1064..96aab16 100644 --- a/server/internal/consts/im_user.go +++ b/server/internal/consts/im_user.go @@ -20,6 +20,8 @@ const ( ImUserRegisterTypeAdmin = "admin" ImUserRegisterTypeMobile = "mobile" ImUserRegisterTypeInviteCode = "invite_code" + ImLoginChannelApp = "app" + ImLoginChannelWeb = "web" ) var ImUserRegisterTypeOptions = []*model.Option{ diff --git a/server/internal/controller/admin/sys/im_user_policy_group.go b/server/internal/controller/admin/sys/im_user_policy_group.go index 5e70617..baa0e85 100644 --- a/server/internal/controller/admin/sys/im_user_policy_group.go +++ b/server/internal/controller/admin/sys/im_user_policy_group.go @@ -64,3 +64,16 @@ func (c *cImUserPolicyGroup) MemberRemove(ctx context.Context, req *imuserpolicy err = service.SysImUserPolicyGroup().MemberRemove(ctx, &req.ImUserPolicyGroupMemberRemoveInp) return } + +func (c *cImUserPolicyGroup) LoginIPWhitelist(ctx context.Context, req *imuserpolicygroup.LoginIPWhitelistReq) (res *imuserpolicygroup.LoginIPWhitelistRes, err error) { + data, err := service.SysImUserPolicyGroup().LoginIPWhitelist(ctx, &req.ImTenantLoginIPWhitelistInp) + if err != nil { + return nil, err + } + return &imuserpolicygroup.LoginIPWhitelistRes{ImTenantLoginIPWhitelistModel: data}, nil +} + +func (c *cImUserPolicyGroup) LoginIPWhitelistSave(ctx context.Context, req *imuserpolicygroup.LoginIPWhitelistSaveReq) (res *imuserpolicygroup.LoginIPWhitelistSaveRes, err error) { + err = service.SysImUserPolicyGroup().LoginIPWhitelistSave(ctx, &req.ImTenantLoginIPWhitelistSaveInp) + return +} diff --git a/server/internal/controller/internalapi/imconfig/im_config.go b/server/internal/controller/internalapi/imconfig/im_config.go index bbe9c1f..342ca5d 100644 --- a/server/internal/controller/internalapi/imconfig/im_config.go +++ b/server/internal/controller/internalapi/imconfig/im_config.go @@ -6,6 +6,7 @@ import ( "hotgo/internal/dao" "hotgo/internal/library/imtenant" "hotgo/internal/model/entity" + "hotgo/internal/service" "strings" "github.com/gogf/gf/v2/errors/gerror" @@ -90,6 +91,17 @@ func (c *cConfig) ThirdParty(ctx context.Context, req *api.ThirdPartyReq) (res * return &api.ThirdPartyRes{Values: values}, nil } +func (c *cConfig) TenantLoginIPWhitelist(ctx context.Context, req *api.TenantLoginIPWhitelistReq) (res *api.TenantLoginIPWhitelistRes, err error) { + if _, err = getTenant(ctx, dao.ImTenant.Columns().Id, req.Id); err != nil { + return nil, err + } + data, err := service.SysImUserPolicyGroup().LoginIPWhitelistByTenant(ctx, req.Id) + if err != nil { + return nil, err + } + return &api.TenantLoginIPWhitelistRes{TenantId: data.TenantId, AppList: data.AppList, WebList: data.WebList}, nil +} + func getTenant(ctx context.Context, field string, value any) (*entity.ImTenant, error) { var tenant *entity.ImTenant if err := imtenant.RegistryDB().Model(dao.ImTenant.Table()).Safe().Ctx(ctx).Where(field, value).Scan(&tenant); err != nil { diff --git a/server/internal/logic/sys/im_user_policy_group.go b/server/internal/logic/sys/im_user_policy_group.go index e1daf71..7647a27 100644 --- a/server/internal/logic/sys/im_user_policy_group.go +++ b/server/internal/logic/sys/im_user_policy_group.go @@ -18,6 +18,8 @@ import ( type sSysImUserPolicyGroup struct{} +const imTenantLoginIPWhitelistTable = "im_tenant_login_ip_whitelists" + func NewSysImUserPolicyGroup() *sSysImUserPolicyGroup { return &sSysImUserPolicyGroup{} } func init() { service.RegisterSysImUserPolicyGroup(NewSysImUserPolicyGroup()) } @@ -288,3 +290,85 @@ func (s *sSysImUserPolicyGroup) MemberRemove(ctx context.Context, in *sysin.ImUs _ = writeImAdminAudit(ctx, tenantID, "im_user_policy_group_member_remove", "im_user_policy_group", groupID, "success", g.Map{"user_id": row[mc.UserId].Int64()}) return publishImPolicyEvent(ctx, tenantID, tenantCode, "user_policy_group.updated", "user_policy_groups", uint(groupID)) } + +func (s *sSysImUserPolicyGroup) LoginIPWhitelist(ctx context.Context, in *sysin.ImTenantLoginIPWhitelistInp) (res *sysin.ImTenantLoginIPWhitelistModel, err error) { + if err = in.Filter(ctx); err != nil { + return nil, err + } + tenantID, _, err := resolveImPolicyTenant(ctx, in.TenantId) + if err != nil { + return nil, err + } + return s.LoginIPWhitelistByTenant(ctx, tenantID) +} + +func (s *sSysImUserPolicyGroup) LoginIPWhitelistByTenant(ctx context.Context, tenantID int64) (res *sysin.ImTenantLoginIPWhitelistModel, err error) { + if tenantID <= 0 { + return nil, gerror.New("租户ID不合法") + } + rows, err := imtenant.RegistryDB().Model(imTenantLoginIPWhitelistTable).Safe().Ctx(ctx). + Fields("channel", "ip_cidr"). + Where("tenant_id", tenantID). + Where("status", consts.StatusEnabled). + WhereNull("deleted_at"). + OrderAsc("channel").OrderAsc("id").All() + if err != nil { + return nil, gerror.Wrap(err, "获取租户登录IP白名单失败") + } + res = &sysin.ImTenantLoginIPWhitelistModel{ + TenantId: tenantID, + AppList: make([]string, 0), + WebList: make([]string, 0), + } + for _, row := range rows { + switch row["channel"].String() { + case consts.ImLoginChannelApp: + res.AppList = append(res.AppList, row["ip_cidr"].String()) + case consts.ImLoginChannelWeb: + res.WebList = append(res.WebList, row["ip_cidr"].String()) + } + } + return res, nil +} + +func (s *sSysImUserPolicyGroup) LoginIPWhitelistSave(ctx context.Context, in *sysin.ImTenantLoginIPWhitelistSaveInp) (err error) { + if err = in.Filter(ctx); err != nil { + return err + } + tenantID, _, err := resolveImPolicyTenant(ctx, in.TenantId) + if err != nil { + return err + } + now := gtime.Now() + operatorID := contexts.GetUserId(ctx) + db := imtenant.RegistryDB() + err = db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + if _, txErr := tx.Model(imTenantLoginIPWhitelistTable).Safe().Ctx(ctx). + Where("tenant_id", tenantID).WhereNull("deleted_at"). + Data(g.Map{"status": consts.StatusDisable, "deleted_at": now, "deleted_by": operatorID, "updated_at": now, "updated_by": operatorID}).Update(); txErr != nil { + return txErr + } + for channel, values := range map[string][]string{ + consts.ImLoginChannelApp: in.AppList, + consts.ImLoginChannelWeb: in.WebList, + } { + for _, value := range values { + if _, txErr := tx.Exec(`INSERT INTO im_tenant_login_ip_whitelists + (tenant_id,channel,ip_cidr,status,created_by,updated_by) + VALUES(?,?,?,?,?,?) + ON DUPLICATE KEY UPDATE status=VALUES(status),deleted_at=NULL,deleted_by=NULL,updated_at=CURRENT_TIMESTAMP(3),updated_by=VALUES(updated_by)`, + tenantID, channel, value, consts.StatusEnabled, operatorID, operatorID); txErr != nil { + return txErr + } + } + } + return nil + }) + if err != nil { + return gerror.Wrap(err, "保存租户登录IP白名单失败") + } + _ = writeImAdminAudit(ctx, tenantID, "im_tenant_login_ip_whitelist_save", "im_tenant", tenantID, "success", g.Map{ + "app_count": len(in.AppList), "web_count": len(in.WebList), + }) + return nil +} diff --git a/server/internal/model/input/sysin/im_policy_test.go b/server/internal/model/input/sysin/im_policy_test.go index c1c21b4..1cbbd13 100644 --- a/server/internal/model/input/sysin/im_policy_test.go +++ b/server/internal/model/input/sysin/im_policy_test.go @@ -143,3 +143,21 @@ func TestImUserPolicyGroupInputFilter(t *testing.T) { t.Fatal("无效用户ID应被拒绝") } } + +func TestImTenantLoginIPWhitelistSaveFilter(t *testing.T) { + in := &ImTenantLoginIPWhitelistSaveInp{ + TenantId: 1, + AppList: []string{" 203.0.113.10 ", "2001:db8::/32", "203.0.113.10"}, + WebList: []string{"198.51.100.7/24"}, + } + if err := in.Filter(context.Background()); err != nil { + t.Fatalf("合法租户登录白名单不应报错: %v", err) + } + if len(in.AppList) != 2 || in.AppList[0] != "203.0.113.10" || in.WebList[0] != "198.51.100.0/24" { + t.Fatalf("IP白名单未正确规范化: app=%v web=%v", in.AppList, in.WebList) + } + in.AppList = []string{"not-an-ip"} + if err := in.Filter(context.Background()); err == nil { + t.Fatal("非法IP地址应被拒绝") + } +} diff --git a/server/internal/model/input/sysin/im_user_policy_group.go b/server/internal/model/input/sysin/im_user_policy_group.go index 6fb35cd..a2ec573 100644 --- a/server/internal/model/input/sysin/im_user_policy_group.go +++ b/server/internal/model/input/sysin/im_user_policy_group.go @@ -2,6 +2,7 @@ package sysin import ( "context" + "net/netip" "regexp" "strings" @@ -155,3 +156,66 @@ type ImUserPolicyGroupMemberRemoveInp struct { } func (in *ImUserPolicyGroupMemberRemoveInp) Filter(ctx context.Context) error { return nil } + +type ImTenantLoginIPWhitelistInp struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` +} + +func (in *ImTenantLoginIPWhitelistInp) Filter(ctx context.Context) error { + if in.TenantId < 0 { + return gerror.New("租户ID不合法") + } + return nil +} + +type ImTenantLoginIPWhitelistSaveInp struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + AppList []string `json:"appList" dc:"APP登录IP白名单"` + WebList []string `json:"webList" dc:"Web登录IP白名单"` +} + +func (in *ImTenantLoginIPWhitelistSaveInp) Filter(ctx context.Context) (err error) { + if in.TenantId < 0 { + return gerror.New("租户ID不合法") + } + if in.AppList, err = normalizeTenantLoginIPList(in.AppList); err != nil { + return gerror.Wrap(err, "APP登录白名单不合法") + } + if in.WebList, err = normalizeTenantLoginIPList(in.WebList); err != nil { + return gerror.Wrap(err, "Web登录白名单不合法") + } + return nil +} + +type ImTenantLoginIPWhitelistModel struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + AppList []string `json:"appList" dc:"APP登录IP白名单"` + WebList []string `json:"webList" dc:"Web登录IP白名单"` +} + +func normalizeTenantLoginIPList(values []string) ([]string, error) { + if len(values) > 500 { + return nil, gerror.New("单个渠道最多配置500条IP或CIDR") + } + result := make([]string, 0, len(values)) + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + if prefix, err := netip.ParsePrefix(value); err == nil { + value = prefix.Masked().String() + } else if address, addressErr := netip.ParseAddr(value); addressErr == nil { + value = address.String() + } else { + return nil, gerror.Newf("%s 不是有效的IPv4、IPv6或CIDR", value) + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + result = append(result, value) + } + return result, nil +} diff --git a/server/internal/service/sys.go b/server/internal/service/sys.go index fc01d35..968c92e 100644 --- a/server/internal/service/sys.go +++ b/server/internal/service/sys.go @@ -377,6 +377,9 @@ type ( MemberList(ctx context.Context, in *sysin.ImUserPolicyGroupMemberListInp) (list []*sysin.ImUserPolicyGroupMemberModel, totalCount int, err error) MemberAdd(ctx context.Context, in *sysin.ImUserPolicyGroupMemberAddInp) (err error) MemberRemove(ctx context.Context, in *sysin.ImUserPolicyGroupMemberRemoveInp) (err error) + LoginIPWhitelist(ctx context.Context, in *sysin.ImTenantLoginIPWhitelistInp) (res *sysin.ImTenantLoginIPWhitelistModel, err error) + LoginIPWhitelistByTenant(ctx context.Context, tenantId int64) (res *sysin.ImTenantLoginIPWhitelistModel, err error) + LoginIPWhitelistSave(ctx context.Context, in *sysin.ImTenantLoginIPWhitelistSaveInp) (err error) } ISysImSensitiveWord interface { List(ctx context.Context, in *sysin.ImSensitiveWordListInp) (list []*sysin.ImSensitiveWordListModel, totalCount int, err error) diff --git a/server/storage/data/generate/im_tenant_login_ip_whitelist.sql b/server/storage/data/generate/im_tenant_login_ip_whitelist.sql new file mode 100644 index 0000000..2e7ab0e --- /dev/null +++ b/server/storage/data/generate/im_tenant_login_ip_whitelist.sql @@ -0,0 +1,42 @@ +-- Tenant-scoped APP/Web login IP whitelist and admin permissions. +-- Empty whitelist for a channel means unrestricted access. +SET NAMES utf8mb4; +USE `hotgo`; + +CREATE TABLE IF NOT EXISTS `im_tenant_login_ip_whitelists` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `tenant_id` bigint NOT NULL COMMENT 'IM tenant ID', + `channel` varchar(16) NOT NULL COMMENT 'app or web', + `ip_cidr` varchar(64) NOT NULL COMMENT 'IPv4, IPv6 or CIDR', + `status` tinyint NOT NULL DEFAULT 1 COMMENT '1 enabled, 2 disabled', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `created_by` bigint NOT NULL DEFAULT 0, + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `updated_by` bigint NOT NULL DEFAULT 0, + `deleted_at` datetime(3) DEFAULT NULL, + `deleted_by` bigint DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_tenant_login_ip` (`tenant_id`, `channel`, `ip_cidr`), + KEY `idx_im_tenant_login_ip_active` (`tenant_id`, `channel`, `status`, `deleted_at`), + CONSTRAINT `chk_im_tenant_login_ip_channel` CHECK (`channel` IN ('app', 'web')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Tenant APP/Web login IP whitelist'; + +SET @now = NOW(); +SELECT @groupMenuId := `id` FROM `hg_admin_menu` WHERE `name` = 'imUserPolicyGroupIndex' LIMIT 1; + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @groupMenuId, '查看租户登录IP白名单', 'imTenantLoginIPWhitelist', '', '', '3', '', '/imUserPolicyGroup/loginIPWhitelist', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT(`tree`, 'tr_', `id`, ' '), '9', '', '1', @now, @now +FROM `hg_admin_menu` WHERE `id` = @groupMenuId + AND NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imTenantLoginIPWhitelist'); + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @groupMenuId, '保存租户登录IP白名单', 'imTenantLoginIPWhitelistSave', '', '', '3', '', '/imUserPolicyGroup/loginIPWhitelistSave', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT(`tree`, 'tr_', `id`, ' '), '10', '', '1', @now, @now +FROM `hg_admin_menu` WHERE `id` = @groupMenuId + AND NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imTenantLoginIPWhitelistSave'); + +INSERT IGNORE INTO `hg_admin_role_menu` (`role_id`, `menu_id`) +SELECT parent_role.`role_id`, child.`id` +FROM `hg_admin_role_menu` parent_role +JOIN `hg_admin_menu` parent ON parent.`id` = parent_role.`menu_id` AND parent.`name` = 'imUserPolicyGroupIndex' +JOIN `hg_admin_menu` child ON child.`pid` = parent.`id` +WHERE child.`name` IN ('imTenantLoginIPWhitelist', 'imTenantLoginIPWhitelistSave'); diff --git a/web/src/api/imUserPolicyGroup/index.ts b/web/src/api/imUserPolicyGroup/index.ts index 82807d6..56f7045 100644 --- a/web/src/api/imUserPolicyGroup/index.ts +++ b/web/src/api/imUserPolicyGroup/index.ts @@ -16,3 +16,7 @@ export const MemberAdd = (params) => http.request({ url: '/imUserPolicyGroup/memberAdd', method: 'POST', params }); export const MemberRemove = (params) => http.request({ url: '/imUserPolicyGroup/memberRemove', method: 'POST', params }); +export const LoginIPWhitelist = (params) => + http.request({ url: '/imUserPolicyGroup/loginIPWhitelist', method: 'GET', params }); +export const LoginIPWhitelistSave = (params) => + http.request({ url: '/imUserPolicyGroup/loginIPWhitelistSave', method: 'POST', params }); diff --git a/web/src/views/imUserPolicyGroup/index.vue b/web/src/views/imUserPolicyGroup/index.vue index 834ea62..4216155 100644 --- a/web/src/views/imUserPolicyGroup/index.vue +++ b/web/src/views/imUserPolicyGroup/index.vue @@ -9,6 +9,17 @@ @reset="reloadTable" @keyup.enter="reloadTable" /> + + 租户渠道 IP 白名单 APP:{{ + whitelistSummary.appList.length + ? `已配置 ${whitelistSummary.appList.length} 条` + : '未配置' + }} Web:{{ + whitelistSummary.webList.length + ? `已配置 ${whitelistSummary.webList.length} 条` + : '未配置' + }} + - + + + + + + + + + + + + + (null), activeGroupId = ref(0); const dialogWidth = computed(() => adaModalWidth(820)); + const whitelistDialogWidth = computed(() => adaModalWidth(760)); + const whitelistSummary = reactive({ + tenantId: 0, + appList: [] as string[], + webList: [] as string[], + }); + const loginIPForm = reactive({ appText: '', webText: '' }); const statusOptions = dict.getOption('sys_normal_disable'); const emptyForm = () => ({ id: 0, @@ -214,7 +286,11 @@ schemas, }); const loadDataTable = (res) => List({ ...searchFormRef.value?.formModel, ...res }); - const reloadTable = () => actionRef.value?.reload(); + const currentTenantId = () => Number(searchFormRef.value?.formModel?.tenantId || 0); + const reloadTable = () => { + actionRef.value?.reload(); + refreshLoginIPSummary(); + }; const actionColumn = reactive({ width: 300, title: '操作', @@ -252,6 +328,52 @@ .map((v) => v.trim()) .filter(Boolean); } + async function fetchLoginIPWhitelist() { + const tenantId = currentTenantId(); + if (userStore.isCompanyDept && tenantId <= 0) { + return null; + } + return await LoginIPWhitelist({ tenantId }); + } + async function refreshLoginIPSummary() { + const result = await fetchLoginIPWhitelist(); + if (!result) { + Object.assign(whitelistSummary, { tenantId: 0, appList: [], webList: [] }); + return; + } + Object.assign(whitelistSummary, { + tenantId: Number(result.tenantId || 0), + appList: result.appList || [], + webList: result.webList || [], + }); + } + async function openLoginIPWhitelist() { + const result = await fetchLoginIPWhitelist(); + if (!result) { + message.warning('请先选择具体租户'); + return; + } + Object.assign(loginIPForm, { + appText: (result.appList || []).join('\n'), + webText: (result.webList || []).join('\n'), + }); + loginIPVisible.value = true; + } + async function saveLoginIPWhitelist() { + submitting.value = true; + try { + await LoginIPWhitelistSave({ + tenantId: currentTenantId(), + appList: lines(loginIPForm.appText), + webList: lines(loginIPForm.webText), + }); + message.success('租户渠道 IP 白名单保存成功'); + loginIPVisible.value = false; + await refreshLoginIPSummary(); + } finally { + submitting.value = false; + } + } function openEdit(row?: any) { editForm.value = row ? { @@ -361,4 +483,7 @@ .member-toolbar { margin-bottom: 16px; } + .whitelist-summary { + margin-bottom: 16px; + }