Przeglądaj źródła

feat: 静态代码审计,修复逻辑bug和安全漏洞

BaiLuoYan 4 tygodni temu
rodzic
commit
dd41152553

+ 382 - 248
audit-report.md

@@ -1,342 +1,476 @@
-# 权限管理系统代码审计报告
+# 权限管理系统 (perms-system-server) 深度代码审计报告
 
 > 审计时间:2026-04-17
-> 审计范围:`/internal` 下所有非测试 Go 源文件(含 model、logic、handler、middleware、loaders、server 等)
+> 审计范围:全部非测试业务源代码(logic / model / middleware / loaders / server / handler)
+> 审计维度:逻辑一致性、并发与竞态、资源管理、数据完整性、安全漏洞、边界崩溃
 
 ---
 
 ## 🚩 核心逻辑漏洞 (High Risk)
 
-### H-1: BindRoles 全量删除导致跨产品角色绑定丢失
+---
 
-- **文件**:`internal/logic/user/bindRolesLogic.go:62`
-- **描述**:为用户绑定角色时,事务中调用 `DeleteByUserIdTx` 删除了该用户在**所有产品**下的角色关联,随后仅重新插入当前产品的角色。`DeleteByUserIdTx` 的 SQL 为 `DELETE FROM sys_user_role WHERE userId = ?`,不带产品过滤。
-- **影响**:产品 A 的管理员为某用户绑定角色时,该用户在产品 B、C 等其他产品中已有的角色绑定会被一并清除,导致跨产品数据丢失,权限异常。这是一个**静默数据破坏**——操作者和被操作者均不会收到任何告警。
-- **修复方案**:将 `DeleteByUserIdTx` 替换为已有的 `DeleteByUserIdForProductTx`,仅删除当前产品下的角色关联:
+### H-1:UserDetailsLoader.loadPerms 跨产品权限泄漏
 
-```go
-// bindRolesLogic.go 事务内
-if err := l.svcCtx.SysUserRoleModel.DeleteByUserIdForProductTx(ctx, session, req.UserId, productCode); err != nil {
-    return err
-}
-```
+- **描述**:`internal/loaders/userDetailsLoader.go` 第 329-330 行,在为普通成员加载权限时,`FindPermIdsByUserIdAndEffect` 查询的 SQL 是 `SELECT permId FROM sys_user_perm WHERE userId = ? AND effect = ?`,该查询**没有按 productCode 过滤**。如果一个用户是多个产品的成员,且在不同产品下都被设置了用户级权限(ALLOW/DENY),则加载产品 A 的上下文时,会把产品 B 的用户级权限也混入计算。
 
----
+  具体污染路径:
+  1. 用户在产品 B 下有 ALLOW 权限 `permId=100`(code 为 `order:delete`)
+  2. 当加载产品 A 的上下文时,`allowIds` 包含 `permId=100`
+  3. `permId=100` 被加入 `permIdSet`,最终 `FindByIds` 返回时没有过滤 `productCode`
+  4. 产品 B 的权限 code `order:delete` 泄漏到产品 A 的 `Perms` 列表和 JWT Token 中
 
-### H-2: SetUserPerms 全量删除导致跨产品用户直授权丢失
+  更严重的是:产品 B 中的 DENY 规则也会泄漏,可能**错误阻止**产品 A 中本应拥有的权限。
+
+- **影响**:
+  - 跨产品权限授予:用户在未授权的产品中获得额外权限
+  - 跨产品权限拒绝:产品 B 的 DENY 规则错误地屏蔽产品 A 的合法权限
+  - JWT Token 中包含其他产品的权限信息(信息泄漏)
 
-- **文件**:`internal/logic/user/setUserPermsLogic.go:69`
-- **描述**:与 H-1 同理,`DeleteByUserIdTx` 的 SQL 为 `DELETE FROM sys_user_perm WHERE userId = ?`,会删除用户在所有产品下的直授权记录,但随后仅重新插入当前产品的权限。
-- **影响**:产品 A 管理员设置用户权限时,用户在产品 B 的 ALLOW/DENY 直授权被清除。
-- **修复方案**:替换为 `DeleteByUserIdForProductTx`:
+- **修复方案**:
 
-```go
-// setUserPermsLogic.go 事务内
-if err := l.svcCtx.SysUserPermModel.DeleteByUserIdForProductTx(ctx, session, req.UserId, productCode); err != nil {
-    return err
-}
-```
+  方案一(推荐):在 `loadPerms` 中查询时增加产品过滤
+
+  ```go
+  // internal/loaders/userDetailsLoader.go - loadPerms 方法中
+  // 修改为通过子查询限定 productCode
+  allowIds, _ := l.models.SysUserPermModel.FindPermIdsByUserIdAndEffectForProduct(
+      ctx, ud.UserId, consts.PermEffectAllow, ud.ProductCode)
+  denyIds, _ := l.models.SysUserPermModel.FindPermIdsByUserIdAndEffectForProduct(
+      ctx, ud.UserId, consts.PermEffectDeny, ud.ProductCode)
+  ```
+
+  对应新增 Model 方法:
+
+  ```go
+  func (m *customSysUserPermModel) FindPermIdsByUserIdAndEffectForProduct(
+      ctx context.Context, userId int64, effect string, productCode string,
+  ) ([]int64, error) {
+      var ids []int64
+      query := fmt.Sprintf(
+          "SELECT up.`permId` FROM %s up "+
+          "INNER JOIN `sys_perm` p ON up.`permId` = p.`id` "+
+          "WHERE up.`userId` = ? AND up.`effect` = ? AND p.`productCode` = ?",
+          m.table)
+      if err := m.QueryRowsNoCacheCtx(ctx, &ids, query, userId, effect, productCode); err != nil {
+          return nil, err
+      }
+      return ids, nil
+  }
+  ```
 
 ---
 
-### H-3: RequireProductAdmin 未校验目标产品归属,存在跨产品越权
-
-- **文件**:`internal/logic/auth/access.go:88-99`
-- **描述**:`RequireProductAdmin` 仅检查当前登录者的 `MemberType` 是否为 `ADMIN`,**不校验该身份是否属于被操作的目标产品**。调用者的 `MemberType` 来源于 JWT 中的 `productCode`(登录时的产品),而 `CreateRole`、`BindRolePerms`、`DeleteRole`、`UpdateRole` 等接口的 `productCode`/`roleId` 来自请求体,两者可能不一致。
-- **影响**:产品 A 的 ADMIN 登录后,可以:
-  - 在产品 B 中创建/修改/删除角色
-  - 修改产品 B 角色的权限绑定
-  - 为产品 B 创建用户
-- **修复方案**:新增 `RequireProductAdminFor(ctx, targetProductCode)` 函数,或在 `RequireProductAdmin` 中接受 `targetProductCode` 参数,与 caller 的产品上下文做比对:
-
-```go
-func RequireProductAdminFor(ctx context.Context, targetProductCode string) error {
-    caller := middleware.GetUserDetails(ctx)
-    if caller == nil {
-        return response.ErrUnauthorized("未登录")
-    }
-    if caller.IsSuperAdmin {
-        return nil
-    }
-    if caller.MemberType == consts.MemberTypeAdmin && caller.ProductCode == targetProductCode {
-        return nil
-    }
-    return response.ErrForbidden("仅超级管理员或该产品的管理员可执行此操作")
-}
-```
+### H-2:UpdateUser 接口可绕过超管状态保护
 
-然后在 `CreateRole`、`BindRolePerms`、`DeleteRole`、`UpdateRole` 中使用目标产品的 productCode 调用此函数。
+- **描述**:`internal/logic/user/updateUserStatusLogic.go` 明确禁止修改超级管理员的状态:
 
----
+  ```go
+  if user.IsSuperAdmin == consts.IsSuperAdminYes {
+      return response.ErrForbidden("不能修改超级管理员的状态")
+  }
+  ```
 
-### H-4: BindRolePerms 不验证权限项归属于角色所在产品
+  但 `internal/logic/user/updateUserLogic.go` 中也接受 `Status` 字段,对于非本人操作只要求调用者是超管,**没有检查目标用户是否也是超管**:
 
-- **文件**:`internal/logic/role/bindRolePermsLogic.go:31-65`
-- **描述**:为角色绑定权限时,仅验证角色存在,未校验 `req.PermIds` 中的权限是否属于该角色所在的产品。对比 `setUserPermsLogic.go` 中有做此验证(检查 `p.ProductCode != productCode`)。
-- **影响**:攻击者可将产品 B 的权限 ID 绑定到产品 A 的角色上。虽然最终 `loadPerms` 在计算用户权限时会按 productCode 过滤,不会直接导致越权执行,但会污染 `sys_role_perm` 表数据,导致角色详情返回不属于本产品的 permId,给前端和管理造成混乱。
-- **修复方案**:在事务之前增加权限归属校验:
+  ```go
+  } else {
+      if !caller.IsSuperAdmin {
+          return response.ErrForbidden("仅允许修改自己的信息或超管操作")
+      }
+  }
+  // ... 后续直接设置 status,无超管保护
+  if req.Status == consts.StatusEnabled || req.Status == consts.StatusDisabled {
+      user.Status = req.Status
+  }
+  ```
 
-```go
-if len(req.PermIds) > 0 {
-    perms, err := l.svcCtx.SysPermModel.FindByIds(l.ctx, req.PermIds)
-    if err != nil {
-        return err
-    }
-    if len(perms) != len(req.PermIds) {
-        return response.ErrBadRequest("包含无效的权限ID")
-    }
-    for _, p := range perms {
-        if p.ProductCode != role.ProductCode {
-            return response.ErrBadRequest("不能绑定其他产品的权限")
-        }
-    }
-}
-```
+  攻击路径:超管 A 通过 `POST /api/user/update` 传入 `{"id": <超管B的ID>, "status": 2}`,即可冻结超管 B,绕过 `updateUserStatus` 的保护逻辑。
 
----
+- **影响**:超级管理员之间可互相冻结账号,破坏系统管理根基。在最坏场景下,攻击者获取任一超管账号后可瘫痪所有其他超管。
 
-### H-5: Login/gRPC Login 不验证产品有效性和用户成员关系
+- **修复方案**:在 `updateUserLogic.go` 中增加超管保护检查:
 
-- **文件**:`internal/logic/pub/loginLogic.go:32-90`、`internal/server/permserver.go:112-159`
-- **描述**:HTTP 和 gRPC 的 Login 接口均未验证:
-  1. `productCode` 对应的产品是否存在且状态为启用
-  2. 登录用户是否是该产品的成员
-- **影响**:
-  - 任何用户可用任意 productCode(甚至不存在的)获取有效的 JWT access token
-  - 用户可为未加入的产品获取 token,虽然 perms 为空,但仍可访问仅需 JWT 认证的列表类接口(userList、roleList、permList、memberList 等),获得不应可见的数据
-- **修复方案**:在密码验证通过后、生成 token 前,增加产品和成员校验:
-
-```go
-product, err := l.svcCtx.SysProductModel.FindOneByCode(l.ctx, req.ProductCode)
-if err != nil {
-    return nil, response.ErrBadRequest("产品不存在")
-}
-if product.Status != consts.StatusEnabled {
-    return nil, response.ErrForbidden("该产品已被禁用")
-}
-
-_, memberErr := l.svcCtx.SysProductMemberModel.FindOneByProductCodeUserId(l.ctx, req.ProductCode, u.Id)
-if memberErr != nil {
-    return nil, response.ErrForbidden("您不是该产品的成员")
-}
-```
+  ```go
+  if caller.UserId != req.Id {
+      if !caller.IsSuperAdmin {
+          return response.ErrForbidden("仅允许修改自己的信息或超管操作")
+      }
+      // 新增:禁止通过此接口修改其他超管的状态
+      if req.Status != 0 && user.IsSuperAdmin == consts.IsSuperAdminYes {
+          return response.ErrForbidden("不能通过此接口修改超级管理员的状态")
+      }
+  }
+  ```
 
 ---
 
-### H-6: 敏感凭据明文提交到版本控制
+### H-3:产品成员被禁用后仍可正常登录
+
+- **描述**:`sys_product_member` 表有 `status` 字段(1=启用,2=禁用),但登录流程和 UserDetailsLoader 均未校验此字段。
+
+  - `loginLogic.go` 第 67 行仅检查成员是否存在,不检查 status:
+    ```go
+    if _, memberErr := l.svcCtx.SysProductMemberModel.FindOneByProductCodeUserId(
+        l.ctx, req.ProductCode, u.Id); memberErr != nil {
+        return nil, response.ErrForbidden("您不是该产品的成员")
+    }
+    ```
+  - `permserver.go` 第 140 行 gRPC Login 同样如此
+  - `userDetailsLoader.go` 的 `loadMembership` 方法也不检查 `member.Status`
+
+- **影响**:管理员将某个成员禁用后,该成员仍然可以正常登录、获取 Token、使用该产品的所有权限,禁用操作形同虚设。
 
-- **文件**:`etc/perm-api-dev.yaml`、`etc/perm-api-test.yaml`、`etc/perm-api-prod.yaml`
-- **描述**:三个环境的配置文件均包含以下明文敏感信息并已提交到 Git:
-  - MySQL 数据库密码(`NsDmWyM@312`)
-  - Redis 密码(`NsDmWyM@312`)
-  - JWT AccessSecret / RefreshSecret
-  - ManagementKey
-  - 且三个环境**共用同一套 MySQL 和 Redis 密码**
-- **影响**:
-  - 任何有代码仓库读权限的人均可获取全部环境的数据库和 Redis 凭据
-  - 密码一旦泄露,三个环境同时受影响
-  - JWT secret 泄露后可伪造任意用户的 token
 - **修复方案**:
-  1. 将 `etc/*.yaml` 加入 `.gitignore`,从 Git 历史中清除(`git filter-branch` 或 BFG)
-  2. 使用环境变量或密钥管理服务(如 Vault、AWS Secrets Manager)注入敏感配置
-  3. 立即轮换所有已泄露的密码和 secret
-  4. 为 dev/test/prod 使用不同的凭据
 
----
+  在 `loginLogic.go` 和 `permserver.go` 的 Login 中增加成员状态检查:
+
+  ```go
+  member, memberErr := l.svcCtx.SysProductMemberModel.FindOneByProductCodeUserId(
+      l.ctx, req.ProductCode, u.Id)
+  if memberErr != nil {
+      return nil, response.ErrForbidden("您不是该产品的成员")
+  }
+  if member.Status != consts.StatusEnabled {
+      return nil, response.ErrForbidden("您在该产品下的成员资格已被禁用")
+  }
+  ```
 
-## ⚠️ 健壮性与性能建议 (Medium/Low)
+  在 `userDetailsLoader.go` 的 `loadMembership` 中增加状态检查:
 
-### M-1: CreateRole/UpdateRole 未校验 PermsLevel 范围
+  ```go
+  if member.Status != consts.StatusEnabled {
+      return // 禁用的成员视为无成员身份
+  }
+  ud.MemberType = member.MemberType
+  ```
 
-- **文件**:`internal/logic/role/createRoleLogic.go:38`、`updateRoleLogic.go:42`
-- **描述**:`PermsLevel` 字段无范围校验,可设为 0、负数或任意大整数。`checkPermLevel` 中通过 `MinPermsLevel` 数值比较来判定管理权限(数值越小权限越高),若不限制范围可能导致权限比较逻辑出现意外行为。
-- **建议**:增加 PermsLevel 的合法范围校验(如 1-999),确保符合业务预期:
+---
 
-```go
-if req.PermsLevel < 1 || req.PermsLevel > 999 {
-    return nil, response.ErrBadRequest("权限级别必须在 1-999 之间")
-}
-```
+### H-4:gRPC VerifyToken 不检查用户实时状态
+
+- **描述**:`internal/server/permserver.go` 的 `VerifyToken` 方法仅验证 JWT 签名和过期时间,不检查用户当前是否仍然处于启用状态:
+
+  ```go
+  func (s *PermServer) VerifyToken(ctx context.Context, req *pb.VerifyTokenReq) (*pb.VerifyTokenResp, error) {
+      token, err := jwt.ParseWithClaims(req.AccessToken, &middleware.Claims{}, ...)
+      if err != nil || !token.Valid {
+          return &pb.VerifyTokenResp{Valid: false}, nil
+      }
+      claims, ok := token.Claims.(*middleware.Claims)
+      if !ok || claims.TokenType != consts.TokenTypeAccess {
+          return &pb.VerifyTokenResp{Valid: false}, nil
+      }
+      // 直接返回 valid=true,未检查用户实时状态
+      return &pb.VerifyTokenResp{Valid: true, ...}, nil
+  }
+  ```
+
+  而 HTTP 端的 JWT 中间件(`jwtauthMiddleware.go`)会通过 `UserDetailsLoader.Load` 实时检查用户状态。
+
+- **影响**:当用户被冻结或从产品中移除后,如果其他微服务通过 gRPC `VerifyToken` 接口验证 Token,仍会得到 `Valid: true`,导致已冻结用户继续使用系统直到 Token 过期(默认 2 小时)。
+
+- **修复方案**:在 `VerifyToken` 中增加实时状态检查:
+
+  ```go
+  func (s *PermServer) VerifyToken(ctx context.Context, req *pb.VerifyTokenReq) (*pb.VerifyTokenResp, error) {
+      // ... JWT 签名验证保持不变 ...
+
+      // 新增:加载用户实时状态
+      ud := s.svcCtx.UserDetailsLoader.Load(ctx, claims.UserId, claims.ProductCode)
+      if ud.Status != consts.StatusEnabled {
+          return &pb.VerifyTokenResp{Valid: false}, nil
+      }
+      if claims.ProductCode != "" && !ud.IsSuperAdmin && ud.MemberType == "" {
+          return &pb.VerifyTokenResp{Valid: false}, nil
+      }
+
+      return &pb.VerifyTokenResp{
+          Valid:      true,
+          UserId:     claims.UserId,
+          Username:   claims.Username,
+          MemberType: ud.MemberType,  // 使用实时数据而非 Token 中的缓存数据
+          Perms:      ud.Perms,
+      }, nil
+  }
+  ```
 
 ---
 
-### M-2: UpdateUser 不验证 DeptId 有效性
+### H-5:gRPC Login 端点无速率限制
+
+- **描述**:HTTP 登录端口通过 `LoginRateLimit` 中间件进行速率限制(60 秒内最多 20 次),但 gRPC 端口的 `Login` 方法(`permserver.go` 第 112 行)**没有任何速率限制**。
+
+  ```go
+  // routes.go - HTTP Login 有速率限制
+  rest.WithMiddlewares(
+      []rest.Middleware{serverCtx.LoginRateLimit},
+      []rest.Route{
+          {Method: http.MethodPost, Path: "/auth/login", Handler: pub.LoginHandler(serverCtx)},
+      }...,
+  )
+
+  // permserver.go - gRPC Login 无速率限制
+  func (s *PermServer) Login(ctx context.Context, req *pb.LoginReq) (*pb.LoginResp, error) {
+      // 直接进入业务逻辑,无任何限流
+  }
+  ```
+
+- **影响**:攻击者可通过 gRPC 端口对登录接口进行高频暴力破解,绕过 HTTP 的速率限制防护。由于使用 bcrypt 校验密码,高并发攻击还可能导致 CPU 资源耗尽。
+
+- **修复方案**:为 gRPC 服务添加 UnaryInterceptor 形式的速率限制,或在 `Login` 方法入口处增加限流逻辑:
+
+  ```go
+  func (s *PermServer) Login(ctx context.Context, req *pb.LoginReq) (*pb.LoginResp, error) {
+      // 使用 peer 获取客户端 IP 进行限流
+      p, _ := peer.FromContext(ctx)
+      ip := p.Addr.String()
+      code, _ := s.limiter.Take(fmt.Sprintf("grpc:login:%s", ip))
+      if code == limit.OverQuota {
+          return nil, status.Error(codes.ResourceExhausted, "请求过于频繁")
+      }
+      // ... 原有逻辑 ...
+  }
+  ```
 
-- **文件**:`internal/logic/user/updateUserLogic.go:70-72`
-- **描述**:超级管理员修改用户部门时,不检查 `DeptId` 是否对应一个存在且启用的部门。若设置了不存在的 DeptId,后续 `checkDeptHierarchy` 中查询 `SysDeptModel.FindOne(target.DeptId)` 会失败,返回「无权操作」的误导性错误。
-- **建议**:
+---
 
-```go
-if req.DeptId != nil && *req.DeptId > 0 {
-    if _, err := l.svcCtx.SysDeptModel.FindOne(l.ctx, *req.DeptId); err != nil {
-        return response.ErrBadRequest("部门不存在")
-    }
-}
-```
+## ⚠️ 健壮性与性能建议 (Medium)
 
 ---
 
-### M-3: UserDetailsLoader.loadFromDB 静默吞错误,可能缓存不完整数据
+### M-1:Rate Limiter IP 提取可被伪造
 
-- **文件**:`internal/loaders/userDetailsLoader.go:175-189`
-- **描述**:`loadFromDB` 内部各 `loadXxx` 方法在查询失败时仅打印日志,不返回错误。`Load` 方法通过 `singleflight` 调用 `loadFromDB`,始终返回 `(ud, nil)`,即使用户不存在也会将不完整的 `UserDetails`(只有 userId 和 productCode,其余字段为零值)缓存到 Redis 并存活 5 分钟。
-- **影响**:
-  - 若 DB 瞬时故障,所有并发请求将获取并缓存空数据,在 TTL 内该用户无法正常使用
-  - 中间件 `Handle` 中通过 `ud.Status != consts.StatusEnabled` 检查(StatusEnabled=1),而默认 Status=0,会导致用户被判定为「账号已被冻结」
-- **建议**:在 `loadUser` 失败时(特别是非 ErrNotFound 的错误)不应缓存结果,或设置更短的 TTL/不缓存:
-
-```go
-v, _, _ := l.sf.Do(key, func() (interface{}, error) {
-    ud, ok := l.loadFromDB(ctx, userId, productCode)
-    if ok {
-        // 正常缓存
-        if val, err := json.Marshal(ud); err == nil {
-            l.rds.SetexCtx(ctx, key, string(val), l.ttl)
-        }
-    }
-    // 加载失败时不缓存,让下次请求重试
-    return ud, nil
-})
-```
+- **描述**:`ratelimitMiddleware.go` 按 `X-Forwarded-For → X-Real-IP → RemoteAddr` 顺序提取客户端 IP:
 
----
+  ```go
+  ip := r.Header.Get("X-Forwarded-For")
+  if ip == "" {
+      ip = r.Header.Get("X-Real-IP")
+  }
+  ```
 
-### M-4: AdminLogin 不限制仅超管用户登录
+  `X-Forwarded-For` 头可以被客户端直接伪造。如果服务不在可信反向代理后面,攻击者可以在每次请求中设置不同的 `X-Forwarded-For` 值来绕过速率限制。
 
-- **文件**:`internal/logic/pub/adminLoginLogic.go:33-91`
-- **描述**:`adminLogin` 接口仅验证 `managementKey` 后即允许任何用户登录管理后台,包括普通用户。虽然 `managementKey` 本身是一道屏障,但从设计意图来看管理后台应仅面向超管。
-- **建议**:如管理后台仅面向超管,可在密码验证后增加:
+- **影响**:登录接口的速率限制可被绕过,使暴力破解攻击成为可能。
 
-```go
-if u.IsSuperAdmin != consts.IsSuperAdminYes {
-    return nil, response.ErrForbidden("管理后台仅限超级管理员登录")
-}
-```
+- **修复方案**:如果部署在反向代理后,应提取 `X-Forwarded-For` 中的**第一个非信任 IP**;如果直接对外暴露,应优先使用 `RemoteAddr`。建议根据部署拓扑配置信任代理列表:
+
+  ```go
+  ip := extractRealIP(r, trustedProxies)
+  ```
 
 ---
 
-### M-5: CreateUser 用户名唯一性存在 TOCTOU 窗口
+### M-2:RefreshToken 端点无速率限制
+
+- **描述**:`/api/auth/refreshToken` 在 `routes.go` 中注册时既无 JWT 认证中间件,也无速率限制中间件。
+
+  ```go
+  server.AddRoutes(
+      []rest.Route{
+          {Method: http.MethodPost, Path: "/auth/refreshToken", Handler: pub.RefreshTokenHandler(serverCtx)},
+          {Method: http.MethodPost, Path: "/perm/sync", Handler: pub.SyncPermsHandler(serverCtx)},
+      },
+      rest.WithPrefix("/api"),
+  )
+  ```
 
-- **文件**:`internal/logic/user/createUserLogic.go:53-56`
-- **描述**:先通过 `FindOneByUsername` 检查用户名是否存在,再执行 `Insert`。在两个操作之间,其他并发请求可能已经插入了同名用户。虽然代码在 Insert 失败时通过检查 `"1062"` / `"Duplicate entry"` 做了补偿处理,但这种双重检查模式产生了不必要的复杂度。
-- **建议**:可以去掉前置的 `FindOneByUsername` 查询,直接依赖数据库唯一索引约束 + `Duplicate entry` 错误判断即可,减少一次 DB 查询。
+- **影响**:如果 RefreshToken 泄漏,攻击者可以无限次调用此端点,持续生成新的 AccessToken,且无法通过限流缓解。
+
+- **修复方案**:为 RefreshToken 端点增加独立的速率限制,基于 token 中的 userId 进行限流
 
 ---
 
-### M-6: gRPC Login 可用于暴力枚举用户密码
+### M-3:部门禁用后不影响其下用户的登录和权限
+
+- **描述**:`sys_dept` 表有 `status` 字段,但整个系统中没有任何地方在加载用户信息时检查其所属部门是否处于启用状态。
+
+  - `userDetailsLoader.go` 的 `loadDept` 不检查部门状态
+  - `loadPerms` 中依赖 `ud.DeptType == consts.DeptTypeDev` 判断是否授予全量权限,也不检查部门是否被禁用
+  - 一个被禁用的 DEV 类型部门下的用户仍然会获得该产品的全部权限
+
+- **影响**:管理员禁用部门后,预期该部门下用户的权限应受到影响(至少 DEV 部门的自动全权限应该失效),但实际上没有任何效果。
 
-- **文件**:`internal/server/permserver.go:112-159`
-- **描述**:gRPC Login 接口无速率限制。在内部网络中,攻击者可对 gRPC 端口发起高速暴力破解(bcrypt 有计算成本,但大量并发仍可造成 CPU 压力和逐步猜测)。HTTP 侧通常有 Nginx/WAF 保护,但 gRPC 端口可能缺少此层防护。
-- **建议**:
-  - 在 gRPC server 添加速率限制 interceptor
-  - 或确保 gRPC 端口不对外暴露,仅内部服务间调用
+- **修复方案**:在 `loadPerms` 中判断 `DeptType` 时,增加部门状态检查:
+
+  ```go
+  if ud.IsSuperAdmin ||
+      ud.MemberType == consts.MemberTypeAdmin ||
+      ud.MemberType == consts.MemberTypeDeveloper ||
+      (ud.DeptType == consts.DeptTypeDev && ud.DeptStatus == consts.StatusEnabled) {
+      // 全量权限
+  }
+  ```
 
 ---
 
-### M-7: RefreshToken 不验证用户是否仍为产品成员
+### M-4:Redis SCAN 在 Cluster 模式下不兼容
+
+- **描述**:`userDetailsLoader.go` 的 `cleanByPattern` 使用 `SCAN` 命令按通配符模式清除缓存。代码注释已标注此问题,但仍是一个部署风险。
+
+  ```go
+  // NOTE: SCAN only works on single-node Redis. For Redis Cluster, consider using hash tags
+  func (l *UserDetailsLoader) cleanByPattern(ctx context.Context, pattern string) {
+      var cursor uint64
+      for {
+          keys, cur, err := l.rds.ScanCtx(ctx, cursor, pattern, 100)
+          // ...
+      }
+  }
+  ```
+
+  此方法被 `Clean`(清除某用户所有产品缓存)和 `CleanByProduct`(清除某产品所有用户缓存)调用。
 
-- **文件**:`internal/logic/pub/refreshTokenLogic.go`、`internal/server/permserver.go:161-191`
-- **描述**:RefreshToken 接口仅检查用户状态是否启用,不检查用户是否仍为该产品的成员。若用户被移除出产品成员(`RemoveMember`),其已有的 refresh token 仍然可以续签新的 access token(最长 7 天 refresh token 有效期内)。
-- **建议**:在 refresh 时增加成员关系检查,或在 `RemoveMember` 时将用户的 refresh token 加入黑名单。
+- **影响**:如果未来 Redis 切换为 Cluster 模式,`SCAN` 只能在单个节点上执行,无法跨节点匹配 key,导致缓存无法正确失效,引发权限数据过期不清除问题。
+
+- **修复方案**:使用 Redis Hash Tag `{tag}` 将相关 key 路由到同一 slot,或改用主动记录关联 key 的方式(如维护一个 Set 记录用户关联的所有缓存 key),清除时通过 Set 成员精确删除
 
 ---
 
-### M-8: BindRoles 中角色 ID 去重缺失
+### M-5:HTTP 与 gRPC 登录逻辑高度重复
 
-- **文件**:`internal/logic/user/bindRolesLogic.go:69-78`
-- **描述**:`req.RoleIds` 未做去重处理。若前端传入 `[1, 1, 1]`,会在 `sys_user_role` 表中插入三条相同的记录(除非表有唯一联合索引)。
-- **建议**:在插入前对 `req.RoleIds` 去重:
+- **描述**:`loginLogic.go` 和 `permserver.go Login` 中的登录逻辑几乎完全相同(校验用户名密码、检查状态、检查产品、检查成员关系、生成 Token),但分别独立实现。
 
-```go
-seen := make(map[int64]bool, len(req.RoleIds))
-uniqueRoleIds := make([]int64, 0, len(req.RoleIds))
-for _, id := range req.RoleIds {
-    if !seen[id] {
-        seen[id] = true
-        uniqueRoleIds = append(uniqueRoleIds, id)
-    }
-}
-```
+- **影响**:当修复上述 H-3(成员状态检查)等问题时,需要同时修改两处代码,容易遗漏。未来任何登录逻辑的变更都需要双重维护。
+
+- **修复方案**:将核心登录逻辑抽取为共享的 service 方法,HTTP handler 和 gRPC server 都调用同一个方法。
 
 ---
 
-### M-9: SetUserPerms 中 PermId 去重缺失
+## ⚠️ 健壮性与性能建议 (Low)
+
+---
+
+### L-1:SyncPerms 接口无速率限制
+
+- **描述**:`/api/perm/sync` 端点无任何中间件保护(无 JWT、无 RateLimit),仅靠 `appKey + appSecret` 认证。虽然密钥泄漏的概率较低,但一旦泄漏,攻击者可以高频调用此接口触发大量数据库写操作和缓存清除。
 
-- **文件**:`internal/logic/user/setUserPermsLogic.go:49-66`
-- **描述**:同 M-8,`req.Perms` 中若存在重复的 PermId,会插入多条记录。此外,对同一 PermId 若传入了不同 Effect(一个 ALLOW 一个 DENY),语义冲突但代码不会拦截。
-- **建议**:对 PermId 去重,并检查同一 PermId 不能同时为 ALLOW 和 DENY。
+- **修复方案**:为 SyncPerms 增加基于 appKey 的速率限制。
 
 ---
 
-### L-1: UserList/MemberList/RoleList 等列表接口缺少产品级权限控制
+### L-2:CreateProduct 响应暴露 AppSecret 明文
 
-- **文件**:`internal/logic/user/userListLogic.go`、`internal/logic/member/memberListLogic.go`、`internal/logic/role/roleListLogic.go` 等
-- **描述**:所有列表类接口仅需 JWT 认证即可访问,无产品成员身份校验。已登录的任何用户可查看任意产品的角色列表、权限列表、成员列表。结合 H-5(Login 不验证成员关系),用户只需注册并使用任意 productCode 登录,即可遍历所有产品的内部数据。
-- **建议**:对需要 `productCode` 参数的列表接口,校验调用者是否为该产品的成员或超管。
+- **描述**:`createProductLogic.go` 创建产品后将 `AppSecret` 明文返回给前端,且 AppSecret 在数据库中也以明文存储。
+
+  ```go
+  return &types.CreateProductResp{
+      AppSecret:     appSecret,       // 明文返回
+      AdminPassword: adminPassword,   // 管理员初始密码明文返回
+  }
+  ```
+
+- **影响**:AppSecret 相当于产品的 API 密钥,一旦通过网络被截获或日志记录,将可被用于调用 SyncPerms 等无需登录的接口。AdminPassword 同理。
+
+- **修复方案**:这是一次性展示场景(创建后只展示一次),风险可控。建议:
+  1. 确保传输层使用 HTTPS
+  2. 确认日志中不会记录响应体(当前 `response.go` 的错误处理不会记录成功响应体,但需确认 go-zero 框架层面的 access log 配置)
+  3. AppSecret 数据库存储可考虑改为哈希存储,验证时对比哈希值
 
 ---
 
-### L-2: UserDetail/RoleDetail 未校验调用者是否有权查看
+### L-3:UserDetailsLoader.Load 中 singleflight 的 panic 风险
+
+- **描述**:`userDetailsLoader.go` 第 106-118 行:
 
-- **文件**:`internal/logic/user/userDetailLogic.go`、`internal/logic/role/roleDetailLogic.go`
-- **描述**:任何已认证用户可通过 ID 查看任意用户或角色的详情,包括用户的邮箱、手机号等个人信息。
-- **建议**:根据业务需求,考虑限制用户仅能查看本产品范围内的数据,或对敏感字段脱敏。
+  ```go
+  v, _, _ := l.sf.Do(key, func() (interface{}, error) {
+      ud, ok := l.loadFromDB(ctx, userId, productCode)
+      if ok {
+          // ... 缓存
+      }
+      return ud, nil
+  })
+  return v.(*UserDetails) // 如果 loadFromDB 的 ud 为 nil,此处会 panic
+  ```
+
+  `loadFromDB` 在 `loadUser` 失败时返回 `(ud, false)`,此时 `ud` 不是 nil(是一个初始化过的 `&UserDetails{}`),所以实际上不会 panic。但如果未来重构时 `loadFromDB` 的返回值逻辑变化,此处的类型断言缺乏安全检查。
+
+- **修复方案**:使用安全类型断言:
+
+  ```go
+  ud, ok := v.(*UserDetails)
+  if !ok || ud == nil {
+      return &UserDetails{UserId: userId, ProductCode: productCode}
+  }
+  return ud
+  ```
 
 ---
 
-### L-3: 部门删除缺少事务保护
+### L-4:BindRolesLogic 绑定角色未检查目标用户是否为产品成员
+
+- **描述**:`bindRolesLogic.go` 在为用户绑定角色时,只检查用户是否存在和角色是否属于当前产品,但**没有检查目标用户是否是当前产品的成员**。
 
-- **文件**:`internal/logic/dept/deleteDeptLogic.go:33-47`
-- **描述**:删除部门前先查子部门和关联用户,确认为空后再删除。但在检查和删除之间(虽然是极短窗口),理论上可能有新数据插入。在实际业务中,部门操作频率极低且限超管,此风险可接受。
-- **建议**:如追求严格一致性,可将检查与删除放入同一事务中,并在 SQL 层面做最终校验。
+  ```go
+  if _, err := l.svcCtx.SysUserModel.FindOne(l.ctx, req.UserId); err != nil {
+      return response.ErrNotFound("用户不存在")
+  }
+  // 缺少:检查用户是否为当前产品成员
+  ```
+
+- **影响**:可以为非产品成员的用户绑定角色,这些角色在 `loadRoles` 时会被加载但因用户不是成员所以 `loadMembership` 不会设置 MemberType,权限不会实际生效。但数据库中会存在孤立的无效关联数据。
+
+- **修复方案**:在绑定前校验目标用户是否为当前产品成员。
 
 ---
 
-### L-4: DeptType 枚举校验不充分
+### L-5:SetUserPerms 同理未校验产品成员关系
 
-- **文件**:`internal/logic/dept/createDeptLogic.go:50-53`
-- **描述**:创建部门时,若 `req.DeptType` 为非空但非法值(如 `"INVALID"`),代码会直接使用该值(不经过枚举校验)。仅 `updateDeptLogic.go` 做了枚举校验。
-- **建议**:创建时也增加枚举校验:
+- **描述**:与 L-4 类似,`setUserPermsLogic.go` 在设置用户权限时没有检查目标用户是否为当前产品的成员,可以为非成员用户设置权限(虽然实际不会生效)。
 
-```go
-if deptType != consts.DeptTypeNormal && deptType != consts.DeptTypeDev {
-    return nil, response.ErrBadRequest("无效的部门类型")
-}
-```
+- **修复方案**:在设置前校验目标用户是否为当前产品成员。
 
 ---
 
-### L-5: cleanByPattern 使用 SCAN 在 Redis Cluster 下可能遗漏 key
+### L-6:删除部门时忽略了 FindIdsByDeptId 的错误
+
+- **描述**:`deleteDeptLogic.go` 第 39 行使用 `_` 忽略了查询错误:
+
+  ```go
+  userIds, _ := l.svcCtx.SysUserModel.FindIdsByDeptId(l.ctx, req.Id)
+  if len(userIds) > 0 {
+      return response.ErrBadRequest("该部门下仍有关联用户,无法删除")
+  }
+  ```
+
+  如果数据库查询出错(比如连接异常),`userIds` 为 nil,`len(userIds)` 为 0,会误判为"无关联用户",导致在仍有用户关联时删除部门。
+
+- **影响**:在数据库异常时可能导致误删有用户关联的部门,造成这些用户的 `deptId` 指向一个已不存在的部门。
+
+- **修复方案**:处理错误并在查询失败时阻止删除:
 
-- **文件**:`internal/loaders/userDetailsLoader.go:153-171`
-- **描述**:`CleanByProduct` 和 `Clean` 方法通过 Redis `SCAN` + pattern 匹配来批量删除缓存。在 Redis Cluster 模式下,`SCAN` 只扫描当前连接的节点,可能遗漏其他节点上的 key。当前配置为单节点 (`Type: node`),暂不受影响,但若未来迁移到 Cluster 需要注意。
-- **建议**:预留 Cluster 兼容方案,或在 key 设计中使用 hash tag 确保同一用户的 key 落在同一 slot。
+  ```go
+  userIds, err := l.svcCtx.SysUserModel.FindIdsByDeptId(l.ctx, req.Id)
+  if err != nil {
+      return err
+  }
+  ```
 
 ---
 
 ## 📋 审计总结
 
-| 级别 | 编号 | 问题 | 影响面 |
-|------|------|------|--------|
-| 🔴 High | H-1 | BindRoles 跨产品角色删除 | 数据丢失 |
-| 🔴 High | H-2 | SetUserPerms 跨产品权限删除 | 数据丢失 |
-| 🔴 High | H-3 | RequireProductAdmin 跨产品越权 | 越权操作 |
-| 🔴 High | H-4 | BindRolePerms 未校验权限产品归属 | 数据污染 |
-| 🔴 High | H-5 | Login 不验证产品/成员关系 | 未授权访问 |
-| 🔴 High | H-6 | 敏感凭据明文提交 Git | 凭据泄露 |
-| 🟡 Medium | M-1 | PermsLevel 无范围校验 | 逻辑异常 |
-| 🟡 Medium | M-2 | UpdateUser 不验证 DeptId | 数据不一致 |
-| 🟡 Medium | M-3 | UserDetailsLoader 缓存不完整数据 | 用户被误冻结 |
-| 🟡 Medium | M-4 | AdminLogin 不限制超管 | 权限边界模糊 |
-| 🟡 Medium | M-5 | CreateUser TOCTOU | 代码冗余 |
-| 🟡 Medium | M-6 | gRPC Login 无速率限制 | 暴力破解风险 |
-| 🟡 Medium | M-7 | RefreshToken 不验证成员关系 | token 续期漏洞 |
-| 🟡 Medium | M-8 | BindRoles 未去重 | 数据重复 |
-| 🟡 Medium | M-9 | SetUserPerms 未去重 | 语义冲突 |
-| 🟢 Low | L-1 | 列表接口缺产品级权限控制 | 信息泄露 |
-| 🟢 Low | L-2 | Detail 接口无访问控制 | 信息泄露 |
-| 🟢 Low | L-3 | 部门删除无事务 | 理论竞态 |
-| 🟢 Low | L-4 | DeptType 枚举校验不充分 | 脏数据 |
-| 🟢 Low | L-5 | SCAN 在 Cluster 下的兼容性 | 缓存残留 |
-
-**优先建议修复顺序**:H-1 → H-2 → H-3 → H-5 → H-6 → H-4 → M-3 → M-7 → 其余 Medium → Low
+| 级别 | 编号 | 问题摘要 | 影响面 |
+|------|------|----------|--------|
+| 🔴 High | H-1 | loadPerms 跨产品权限泄漏 | 数据安全 |
+| 🔴 High | H-2 | UpdateUser 可绕过超管状态保护 | 权限控制 |
+| 🔴 High | H-3 | 禁用成员仍可登录 | 访问控制 |
+| 🔴 High | H-4 | gRPC VerifyToken 不检查实时状态 | 访问控制 |
+| 🔴 High | H-5 | gRPC Login 无速率限制 | 安全防护 |
+| 🟡 Medium | M-1 | Rate Limiter IP 可伪造 | 安全防护 |
+| 🟡 Medium | M-2 | RefreshToken 端点无速率限制 | 安全防护 |
+| 🟡 Medium | M-3 | 部门禁用不影响下属用户权限 | 逻辑一致性 |
+| 🟡 Medium | M-4 | Redis SCAN 不兼容 Cluster | 可部署性 |
+| 🟡 Medium | M-5 | HTTP/gRPC 登录逻辑重复 | 可维护性 |
+| 🟢 Low | L-1 | SyncPerms 无速率限制 | 安全防护 |
+| 🟢 Low | L-2 | CreateProduct 暴露 AppSecret | 信息泄漏 |
+| 🟢 Low | L-3 | singleflight 类型断言无安全检查 | 健壮性 |
+| 🟢 Low | L-4 | BindRoles 未校验产品成员关系 | 数据完整性 |
+| 🟢 Low | L-5 | SetUserPerms 未校验产品成员关系 | 数据完整性 |
+| 🟢 Low | L-6 | 删除部门时忽略查询错误 | 数据完整性 |
+
+**建议优先级**:H-1 > H-3 > H-2 > H-4 > H-5 > M-3 > M-1 > 其余
+
+H-1(跨产品权限泄漏)是最高优先级,因为它会导致静默的权限污染且难以被人工发现。H-3(禁用成员仍可登录)次之,因为管理员执行禁用操作后会产生"已生效"的错觉。

+ 24 - 12
internal/handler/routes.go

@@ -160,18 +160,30 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
 	)
 
 	server.AddRoutes(
-		[]rest.Route{
-			{
-				Method:  http.MethodPost,
-				Path:    "/auth/refreshToken",
-				Handler: pub.RefreshTokenHandler(serverCtx),
-			},
-			{
-				Method:  http.MethodPost,
-				Path:    "/perm/sync",
-				Handler: pub.SyncPermsHandler(serverCtx),
-			},
-		},
+		rest.WithMiddlewares(
+			[]rest.Middleware{serverCtx.LoginRateLimit},
+			[]rest.Route{
+				{
+					Method:  http.MethodPost,
+					Path:    "/auth/refreshToken",
+					Handler: pub.RefreshTokenHandler(serverCtx),
+				},
+			}...,
+		),
+		rest.WithPrefix("/api"),
+	)
+
+	server.AddRoutes(
+		rest.WithMiddlewares(
+			[]rest.Middleware{serverCtx.LoginRateLimit},
+			[]rest.Route{
+				{
+					Method:  http.MethodPost,
+					Path:    "/perm/sync",
+					Handler: pub.SyncPermsHandler(serverCtx),
+				},
+			}...,
+		),
 		rest.WithPrefix("/api"),
 	)
 

+ 14 - 5
internal/loaders/userDetailsLoader.go

@@ -40,7 +40,8 @@ type UserDetails struct {
 	DeptId   int64  `json:"deptId"`
 	DeptName string `json:"deptName"`
 	DeptPath string `json:"deptPath"`
-	DeptType string `json:"deptType"`
+	DeptType   string `json:"deptType"`
+	DeptStatus int64  `json:"deptStatus"`
 
 	// 产品上下文 (sys_product)
 	ProductCode string `json:"productCode"`
@@ -115,7 +116,11 @@ func (l *UserDetailsLoader) Load(ctx context.Context, userId int64, productCode
 		return ud, nil
 	})
 
-	return v.(*UserDetails)
+	ud, ok := v.(*UserDetails)
+	if !ok || ud == nil {
+		return &UserDetails{UserId: userId, ProductCode: productCode}
+	}
+	return ud
 }
 
 // Del 删除指定用户在指定产品下的缓存。
@@ -228,6 +233,7 @@ func (l *UserDetailsLoader) loadDept(ctx context.Context, ud *UserDetails) {
 	ud.DeptName = d.Name
 	ud.DeptPath = d.Path
 	ud.DeptType = d.DeptType
+	ud.DeptStatus = d.Status
 }
 
 func (l *UserDetailsLoader) loadProduct(ctx context.Context, ud *UserDetails) {
@@ -259,6 +265,9 @@ func (l *UserDetailsLoader) loadMembership(ctx context.Context, ud *UserDetails)
 		}
 		return
 	}
+	if member.Status != consts.StatusEnabled {
+		return
+	}
 	ud.MemberType = member.MemberType
 }
 
@@ -304,7 +313,7 @@ func (l *UserDetailsLoader) loadPerms(ctx context.Context, ud *UserDetails) {
 	if ud.IsSuperAdmin ||
 		ud.MemberType == consts.MemberTypeAdmin ||
 		ud.MemberType == consts.MemberTypeDeveloper ||
-		ud.DeptType == consts.DeptTypeDev {
+		(ud.DeptType == consts.DeptTypeDev && ud.DeptStatus == consts.StatusEnabled) {
 		codes, err := l.models.SysPermModel.FindAllCodesByProductCode(ctx, ud.ProductCode)
 		if err != nil {
 			logx.WithContext(ctx).Errorf("userDetailsLoader: query all perms failed: %v", err)
@@ -326,8 +335,8 @@ func (l *UserDetailsLoader) loadPerms(ctx context.Context, ud *UserDetails) {
 		}
 	}
 
-	allowIds, _ := l.models.SysUserPermModel.FindPermIdsByUserIdAndEffect(ctx, ud.UserId, consts.PermEffectAllow)
-	denyIds, _ := l.models.SysUserPermModel.FindPermIdsByUserIdAndEffect(ctx, ud.UserId, consts.PermEffectDeny)
+	allowIds, _ := l.models.SysUserPermModel.FindPermIdsByUserIdAndEffectForProduct(ctx, ud.UserId, consts.PermEffectAllow, ud.ProductCode)
+	denyIds, _ := l.models.SysUserPermModel.FindPermIdsByUserIdAndEffectForProduct(ctx, ud.UserId, consts.PermEffectDeny, ud.ProductCode)
 
 	denySet := make(map[int64]bool, len(denyIds))
 	for _, id := range denyIds {

+ 181 - 0
internal/loaders/userDetailsLoader_test.go

@@ -1176,3 +1176,184 @@ func TestLoadMembership_NonMemberEmpty(t *testing.T) {
 	assert.False(t, ud.IsSuperAdmin)
 	assert.Empty(t, ud.MemberType)
 }
+
+// --------------- TC-0551: loadPerms-用户ALLOW权限不跨产品泄漏(H-1修复验证) ---------------
+
+func TestLoadPerms_CrossProductPermIsolation(t *testing.T) {
+	ctx := context.Background()
+	conn := testConn()
+	m := testModels()
+	loader := newTestLoader()
+
+	uid := uniqueId()
+	ts := now()
+	pcodeA := "pA_" + uid
+	pcodeB := "pB_" + uid
+
+	userId := insertUser(ctx, t, m, &userModel.SysUser{
+		Username: uid, Password: hashPwd("pass123"), Nickname: "nick_" + uid,
+		Email: uid + "@test.com", Phone: "13800000030", DeptId: 0,
+		IsSuperAdmin: consts.IsSuperAdminNo, MustChangePassword: consts.MustChangePasswordNo,
+		Status: consts.StatusEnabled, CreateTime: ts, UpdateTime: ts,
+	})
+
+	pidA := insertProduct(ctx, t, m, &productModel.SysProduct{
+		Code: pcodeA, Name: "prodA_" + uid, AppKey: "akA_" + uid, AppSecret: "asA_" + uid,
+		Status: consts.StatusEnabled, CreateTime: ts, UpdateTime: ts,
+	})
+	pidB := insertProduct(ctx, t, m, &productModel.SysProduct{
+		Code: pcodeB, Name: "prodB_" + uid, AppKey: "akB_" + uid, AppSecret: "asB_" + uid,
+		Status: consts.StatusEnabled, CreateTime: ts, UpdateTime: ts,
+	})
+
+	memA := insertMember(ctx, t, m, &memberModel.SysProductMember{
+		ProductCode: pcodeA, UserId: userId, MemberType: consts.MemberTypeMember,
+		Status: consts.StatusEnabled, CreateTime: ts, UpdateTime: ts,
+	})
+	memB := insertMember(ctx, t, m, &memberModel.SysProductMember{
+		ProductCode: pcodeB, UserId: userId, MemberType: consts.MemberTypeMember,
+		Status: consts.StatusEnabled, CreateTime: ts, UpdateTime: ts,
+	})
+
+	permA := insertPerm(ctx, t, m, &permModel.SysPerm{
+		ProductCode: pcodeA, Name: "permA_" + uid, Code: "permA:" + uid,
+		Status: consts.StatusEnabled, CreateTime: ts, UpdateTime: ts,
+	})
+	permB := insertPerm(ctx, t, m, &permModel.SysPerm{
+		ProductCode: pcodeB, Name: "permB_" + uid, Code: "permB:" + uid,
+		Status: consts.StatusEnabled, CreateTime: ts, UpdateTime: ts,
+	})
+
+	upA := insertUserPerm(ctx, t, m, &userPermModel.SysUserPerm{
+		UserId: userId, PermId: permA, Effect: consts.PermEffectAllow,
+		CreateTime: ts, UpdateTime: ts,
+	})
+	upB := insertUserPerm(ctx, t, m, &userPermModel.SysUserPerm{
+		UserId: userId, PermId: permB, Effect: consts.PermEffectAllow,
+		CreateTime: ts, UpdateTime: ts,
+	})
+
+	t.Cleanup(func() {
+		loader.Del(ctx, userId, pcodeA)
+		loader.Del(ctx, userId, pcodeB)
+		cleanTable(ctx, conn, "`sys_user_perm`", upA, upB)
+		cleanTable(ctx, conn, "`sys_perm`", permA, permB)
+		cleanTable(ctx, conn, "`sys_product_member`", memA, memB)
+		cleanTable(ctx, conn, "`sys_product`", pidA, pidB)
+		cleanTable(ctx, conn, "`sys_user`", userId)
+	})
+
+	loader.Del(ctx, userId, pcodeA)
+	udA := loader.Load(ctx, userId, pcodeA)
+	require.NotNil(t, udA)
+	assert.Contains(t, udA.Perms, "permA:"+uid, "产品A应包含自身权限")
+	assert.NotContains(t, udA.Perms, "permB:"+uid, "产品A不应包含产品B的权限(H-1)")
+
+	loader.Del(ctx, userId, pcodeB)
+	udB := loader.Load(ctx, userId, pcodeB)
+	require.NotNil(t, udB)
+	assert.Contains(t, udB.Perms, "permB:"+uid, "产品B应包含自身权限")
+	assert.NotContains(t, udB.Perms, "permA:"+uid, "产品B不应包含产品A的权限(H-1)")
+}
+
+// --------------- TC-0552: loadMembership-禁用成员MemberType为空(H-3修复验证) ---------------
+
+func TestLoadMembership_DisabledMemberEmpty(t *testing.T) {
+	ctx := context.Background()
+	conn := testConn()
+	m := testModels()
+	loader := newTestLoader()
+
+	uid := uniqueId()
+	ts := now()
+	pcode := "p_" + uid
+
+	userId := insertUser(ctx, t, m, &userModel.SysUser{
+		Username: uid, Password: hashPwd("pass123"), Nickname: "nick_" + uid,
+		Email: uid + "@test.com", Phone: "13800000031", DeptId: 0,
+		IsSuperAdmin: consts.IsSuperAdminNo, MustChangePassword: consts.MustChangePasswordNo,
+		Status: consts.StatusEnabled, CreateTime: ts, UpdateTime: ts,
+	})
+
+	pid := insertProduct(ctx, t, m, &productModel.SysProduct{
+		Code: pcode, Name: "prod_" + uid, AppKey: "ak_" + uid, AppSecret: "as_" + uid,
+		Status: consts.StatusEnabled, CreateTime: ts, UpdateTime: ts,
+	})
+
+	memberId := insertMember(ctx, t, m, &memberModel.SysProductMember{
+		ProductCode: pcode, UserId: userId, MemberType: consts.MemberTypeMember,
+		Status: consts.StatusDisabled, CreateTime: ts, UpdateTime: ts,
+	})
+
+	t.Cleanup(func() {
+		loader.Del(ctx, userId, pcode)
+		cleanTable(ctx, conn, "`sys_product_member`", memberId)
+		cleanTable(ctx, conn, "`sys_product`", pid)
+		cleanTable(ctx, conn, "`sys_user`", userId)
+	})
+
+	loader.Del(ctx, userId, pcode)
+
+	ud := loader.Load(ctx, userId, pcode)
+	require.NotNil(t, ud)
+	assert.Empty(t, ud.MemberType, "禁用成员的MemberType应为空(H-3)")
+}
+
+// --------------- TC-0553: loadPerms-DEV部门禁用后不再拥有全部权限(M-3修复验证) ---------------
+
+func TestLoadPerms_DisabledDevDeptNoFullPerms(t *testing.T) {
+	ctx := context.Background()
+	conn := testConn()
+	m := testModels()
+	loader := newTestLoader()
+
+	uid := uniqueId()
+	ts := now()
+	pcode := "p_" + uid
+
+	deptId := insertDept(ctx, t, m, &deptModel.SysDept{
+		ParentId: 0, Name: "devdept_disabled_" + uid, Path: "/1/", Sort: 1,
+		DeptType: consts.DeptTypeDev, Status: consts.StatusDisabled,
+		CreateTime: ts, UpdateTime: ts,
+	})
+
+	userId := insertUser(ctx, t, m, &userModel.SysUser{
+		Username: uid, Password: hashPwd("pass123"), Nickname: "nick_" + uid,
+		Email: uid + "@test.com", Phone: "13800000032", DeptId: deptId,
+		IsSuperAdmin: consts.IsSuperAdminNo, MustChangePassword: consts.MustChangePasswordNo,
+		Status: consts.StatusEnabled, CreateTime: ts, UpdateTime: ts,
+	})
+
+	pid := insertProduct(ctx, t, m, &productModel.SysProduct{
+		Code: pcode, Name: "prod_" + uid, AppKey: "ak_" + uid, AppSecret: "as_" + uid,
+		Status: consts.StatusEnabled, CreateTime: ts, UpdateTime: ts,
+	})
+
+	memberId := insertMember(ctx, t, m, &memberModel.SysProductMember{
+		ProductCode: pcode, UserId: userId, MemberType: consts.MemberTypeMember,
+		Status: consts.StatusEnabled, CreateTime: ts, UpdateTime: ts,
+	})
+
+	permCode := "perm_devtest:" + uid
+	permId := insertPerm(ctx, t, m, &permModel.SysPerm{
+		ProductCode: pcode, Name: "p_" + uid, Code: permCode,
+		Status: consts.StatusEnabled, CreateTime: ts, UpdateTime: ts,
+	})
+
+	t.Cleanup(func() {
+		loader.Del(ctx, userId, pcode)
+		cleanTable(ctx, conn, "`sys_perm`", permId)
+		cleanTable(ctx, conn, "`sys_product_member`", memberId)
+		cleanTable(ctx, conn, "`sys_product`", pid)
+		cleanTable(ctx, conn, "`sys_user`", userId)
+		cleanTable(ctx, conn, "`sys_dept`", deptId)
+	})
+
+	loader.Del(ctx, userId, pcode)
+
+	ud := loader.Load(ctx, userId, pcode)
+	require.NotNil(t, ud)
+	assert.Equal(t, consts.DeptTypeDev, ud.DeptType)
+	assert.Equal(t, int64(consts.StatusDisabled), ud.DeptStatus)
+	assert.Empty(t, ud.Perms, "禁用的DEV部门成员不应拥有全部权限(M-3)")
+}

+ 4 - 1
internal/logic/dept/deleteDeptLogic.go

@@ -38,7 +38,10 @@ func (l *DeleteDeptLogic) DeleteDept(req *types.DeleteDeptReq) error {
 		return response.ErrBadRequest("该部门下存在子部门,无法删除")
 	}
 
-	userIds, _ := l.svcCtx.SysUserModel.FindIdsByDeptId(l.ctx, req.Id)
+	userIds, err := l.svcCtx.SysUserModel.FindIdsByDeptId(l.ctx, req.Id)
+	if err != nil {
+		return err
+	}
 	if len(userIds) > 0 {
 		return response.ErrBadRequest("该部门下仍有关联用户,无法删除")
 	}

+ 13 - 53
internal/logic/pub/loginLogic.go

@@ -4,15 +4,11 @@ import (
 	"context"
 	"time"
 
-	"perms-system-server/internal/consts"
-	authHelper "perms-system-server/internal/logic/auth"
-	"perms-system-server/internal/model/user"
 	"perms-system-server/internal/response"
 	"perms-system-server/internal/svc"
 	"perms-system-server/internal/types"
 
 	"github.com/zeromicro/go-zero/core/logx"
-	"golang.org/x/crypto/bcrypt"
 )
 
 type LoginLogic struct {
@@ -30,61 +26,25 @@ func NewLoginLogic(ctx context.Context, svcCtx *svc.ServiceContext) *LoginLogic
 }
 
 func (l *LoginLogic) Login(req *types.LoginReq) (resp *types.LoginResp, err error) {
-	u, err := l.svcCtx.SysUserModel.FindOneByUsername(l.ctx, req.Username)
+	result, err := ValidateProductLogin(l.ctx, l.svcCtx, req.Username, req.Password, req.ProductCode)
 	if err != nil {
-		if err == user.ErrNotFound {
-			return nil, response.ErrUnauthorized("用户名或密码错误")
+		if le, ok := err.(*LoginError); ok {
+			switch le.Code {
+			case 400:
+				return nil, response.ErrBadRequest(le.Message)
+			case 401:
+				return nil, response.ErrUnauthorized(le.Message)
+			case 403:
+				return nil, response.ErrForbidden(le.Message)
+			}
 		}
 		return nil, err
 	}
 
-	if u.Status != consts.StatusEnabled {
-		return nil, response.ErrForbidden("账号已被冻结")
-	}
-
-	if err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(req.Password)); err != nil {
-		return nil, response.ErrUnauthorized("用户名或密码错误")
-	}
-
-	if u.IsSuperAdmin == consts.IsSuperAdminYes {
-		return nil, response.ErrForbidden("超级管理员不允许通过产品端登录,请使用管理后台")
-	}
-
-	product, err := l.svcCtx.SysProductModel.FindOneByCode(l.ctx, req.ProductCode)
-	if err != nil {
-		return nil, response.ErrBadRequest("产品不存在")
-	}
-	if product.Status != consts.StatusEnabled {
-		return nil, response.ErrForbidden("该产品已被禁用")
-	}
-
-	if _, memberErr := l.svcCtx.SysProductMemberModel.FindOneByProductCodeUserId(l.ctx, req.ProductCode, u.Id); memberErr != nil {
-		return nil, response.ErrForbidden("您不是该产品的成员")
-	}
-
-	ud := l.svcCtx.UserDetailsLoader.Load(l.ctx, u.Id, req.ProductCode)
-
-	accessToken, err := authHelper.GenerateAccessToken(
-		l.svcCtx.Config.Auth.AccessSecret,
-		l.svcCtx.Config.Auth.AccessExpire,
-		ud.UserId, ud.Username, ud.ProductCode, ud.MemberType, ud.Perms,
-	)
-	if err != nil {
-		return nil, err
-	}
-
-	refreshToken, err := authHelper.GenerateRefreshToken(
-		l.svcCtx.Config.Auth.RefreshSecret,
-		l.svcCtx.Config.Auth.RefreshExpire,
-		ud.UserId, ud.ProductCode,
-	)
-	if err != nil {
-		return nil, err
-	}
-
+	ud := result.UserDetails
 	return &types.LoginResp{
-		AccessToken:  accessToken,
-		RefreshToken: refreshToken,
+		AccessToken:  result.AccessToken,
+		RefreshToken: result.RefreshToken,
 		Expires:      time.Now().Unix() + l.svcCtx.Config.Auth.AccessExpire,
 		UserInfo: types.UserInfo{
 			UserId:             ud.UserId,

+ 74 - 0
internal/logic/pub/loginLogic_test.go

@@ -362,3 +362,77 @@ func TestLogin_SQLInjection(t *testing.T) {
 	assert.Equal(t, 401, codeErr.Code())
 	assert.Equal(t, "用户名或密码错误", codeErr.Error())
 }
+
+// TC-0542: 产品成员被禁用时拒绝登录(H-3修复验证)
+func TestLogin_DisabledMemberRejected(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := newTestSvcCtx()
+	conn := testutil.GetTestSqlConn()
+	username := testutil.UniqueId()
+	password := "TestPass123"
+	pc := testutil.UniqueId()
+	now := time.Now().Unix()
+
+	userId, cleanUser := insertTestUser(t, ctx, svcCtx, username, password, 1, 2)
+	t.Cleanup(cleanUser)
+
+	_, cleanProduct := insertTestProduct(t, ctx, svcCtx, pc, testutil.UniqueId(), "secret")
+	t.Cleanup(cleanProduct)
+
+	pmRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &productmemberModel.SysProductMember{
+		ProductCode: pc, UserId: userId, MemberType: "MEMBER", Status: 2, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	pmId, _ := pmRes.LastInsertId()
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_product_member`", pmId) })
+
+	logic := NewLoginLogic(ctx, svcCtx)
+	resp, err := logic.Login(&types.LoginReq{
+		Username:    username,
+		Password:    password,
+		ProductCode: pc,
+	})
+	require.Nil(t, resp)
+	require.Error(t, err)
+
+	var codeErr2 *response.CodeError
+	require.True(t, errors.As(err, &codeErr2))
+	assert.Equal(t, 403, codeErr2.Code())
+	assert.Equal(t, "您在该产品下的成员资格已被禁用", codeErr2.Error())
+}
+
+// TC-0543: 产品已被禁用时拒绝登录
+func TestLogin_DisabledProductRejected(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := newTestSvcCtx()
+	conn := testutil.GetTestSqlConn()
+	username := testutil.UniqueId()
+	password := "TestPass123"
+	pc := testutil.UniqueId()
+	now := time.Now().Unix()
+
+	_, cleanUser := insertTestUser(t, ctx, svcCtx, username, password, 1, 2)
+	t.Cleanup(cleanUser)
+
+	pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{
+		Code: pc, Name: pc, AppKey: testutil.UniqueId(), AppSecret: "secret",
+		Status: 2, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	pId, _ := pRes.LastInsertId()
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_product`", pId) })
+
+	logic := NewLoginLogic(ctx, svcCtx)
+	resp, err := logic.Login(&types.LoginReq{
+		Username:    username,
+		Password:    password,
+		ProductCode: pc,
+	})
+	require.Nil(t, resp)
+	require.Error(t, err)
+
+	var codeErr3 *response.CodeError
+	require.True(t, errors.As(err, &codeErr3))
+	assert.Equal(t, 403, codeErr3.Code())
+	assert.Equal(t, "该产品已被禁用", codeErr3.Error())
+}

+ 92 - 0
internal/logic/pub/loginService.go

@@ -0,0 +1,92 @@
+package pub
+
+import (
+	"context"
+
+	"perms-system-server/internal/consts"
+	"perms-system-server/internal/loaders"
+	authHelper "perms-system-server/internal/logic/auth"
+	"perms-system-server/internal/model/user"
+	"perms-system-server/internal/svc"
+
+	"golang.org/x/crypto/bcrypt"
+)
+
+type LoginResult struct {
+	UserDetails  *loaders.UserDetails
+	AccessToken  string
+	RefreshToken string
+}
+
+type LoginError struct {
+	Code    int
+	Message string
+}
+
+func (e *LoginError) Error() string {
+	return e.Message
+}
+
+func ValidateProductLogin(ctx context.Context, svcCtx *svc.ServiceContext, username, password, productCode string) (*LoginResult, error) {
+	u, err := svcCtx.SysUserModel.FindOneByUsername(ctx, username)
+	if err != nil {
+		if err == user.ErrNotFound {
+			return nil, &LoginError{Code: 401, Message: "用户名或密码错误"}
+		}
+		return nil, err
+	}
+
+	if u.Status != consts.StatusEnabled {
+		return nil, &LoginError{Code: 403, Message: "账号已被冻结"}
+	}
+
+	if err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(password)); err != nil {
+		return nil, &LoginError{Code: 401, Message: "用户名或密码错误"}
+	}
+
+	if u.IsSuperAdmin == consts.IsSuperAdminYes {
+		return nil, &LoginError{Code: 403, Message: "超级管理员不允许通过产品端登录,请使用管理后台"}
+	}
+
+	product, err := svcCtx.SysProductModel.FindOneByCode(ctx, productCode)
+	if err != nil {
+		return nil, &LoginError{Code: 400, Message: "产品不存在"}
+	}
+	if product.Status != consts.StatusEnabled {
+		return nil, &LoginError{Code: 403, Message: "该产品已被禁用"}
+	}
+
+	member, memberErr := svcCtx.SysProductMemberModel.FindOneByProductCodeUserId(ctx, productCode, u.Id)
+	if memberErr != nil {
+		return nil, &LoginError{Code: 403, Message: "您不是该产品的成员"}
+	}
+	if member.Status != consts.StatusEnabled {
+		return nil, &LoginError{Code: 403, Message: "您在该产品下的成员资格已被禁用"}
+	}
+
+	ud := svcCtx.UserDetailsLoader.Load(ctx, u.Id, productCode)
+
+	accessToken, err := authHelper.GenerateAccessToken(
+		svcCtx.Config.Auth.AccessSecret,
+		svcCtx.Config.Auth.AccessExpire,
+		ud.UserId, ud.Username, ud.ProductCode, ud.MemberType, ud.Perms,
+	)
+	if err != nil {
+		return nil, err
+	}
+
+	refreshToken, err := authHelper.GenerateRefreshToken(
+		svcCtx.Config.Auth.RefreshSecret,
+		svcCtx.Config.Auth.RefreshExpire,
+		ud.UserId, ud.ProductCode,
+	)
+	if err != nil {
+		return nil, err
+	}
+
+	return &LoginResult{
+		UserDetails:  ud,
+		AccessToken:  accessToken,
+		RefreshToken: refreshToken,
+	}, nil
+}

+ 4 - 0
internal/logic/user/bindRolesLogic.go

@@ -40,6 +40,10 @@ func (l *BindRolesLogic) BindRoles(req *types.BindRolesReq) error {
 		return err
 	}
 
+	if _, err := l.svcCtx.SysProductMemberModel.FindOneByProductCodeUserId(l.ctx, productCode, req.UserId); err != nil {
+		return response.ErrBadRequest("目标用户不是当前产品的成员")
+	}
+
 	if len(req.RoleIds) > 0 {
 		seen := make(map[int64]bool, len(req.RoleIds))
 		uniqueIds := make([]int64, 0, len(req.RoleIds))

+ 9 - 3
internal/logic/user/bindRolesLogic_mock_test.go

@@ -5,6 +5,7 @@ import (
 	"errors"
 	"testing"
 
+	memberModel "perms-system-server/internal/model/productmember"
 	roleModel "perms-system-server/internal/model/role"
 	userModel "perms-system-server/internal/model/user"
 	"perms-system-server/internal/testutil/ctxhelper"
@@ -27,6 +28,10 @@ func TestBindRoles_Mock_BatchInsertFail(t *testing.T) {
 	mockUser.EXPECT().FindOne(gomock.Any(), int64(1)).
 		Return(&userModel.SysUser{Id: 1}, nil)
 
+	mockPM := mocks.NewMockSysProductMemberModel(ctrl)
+	mockPM.EXPECT().FindOneByProductCodeUserId(gomock.Any(), "test_product", int64(1)).
+		Return(&memberModel.SysProductMember{Id: 1, ProductCode: "test_product", UserId: 1}, nil)
+
 	mockRole := mocks.NewMockSysRoleModel(ctrl)
 	mockRole.EXPECT().FindByIds(gomock.Any(), []int64{10, 20}).
 		Return([]*roleModel.SysRole{
@@ -43,9 +48,10 @@ func TestBindRoles_Mock_BatchInsertFail(t *testing.T) {
 	mockUR.EXPECT().BatchInsertWithTx(gomock.Any(), nil, gomock.Any()).Return(dbErr)
 
 	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
-		User:     mockUser,
-		Role:     mockRole,
-		UserRole: mockUR,
+		User:          mockUser,
+		Role:          mockRole,
+		UserRole:      mockUR,
+		ProductMember: mockPM,
 	})
 
 	logic := NewBindRolesLogic(ctxhelper.SuperAdminCtx(), svcCtx)

+ 55 - 1
internal/logic/user/bindRolesLogic_test.go

@@ -5,6 +5,7 @@ import (
 	"testing"
 	"time"
 
+	memberModel "perms-system-server/internal/model/productmember"
 	roleModel "perms-system-server/internal/model/role"
 	"perms-system-server/internal/response"
 	"perms-system-server/internal/svc"
@@ -16,6 +17,22 @@ import (
 	"github.com/stretchr/testify/require"
 )
 
+func insertTestMember(t *testing.T, svcCtx *svc.ServiceContext, productCode string, userId int64) int64 {
+	t.Helper()
+	now := time.Now().Unix()
+	res, err := svcCtx.SysProductMemberModel.Insert(ctxhelper.SuperAdminCtx(), &memberModel.SysProductMember{
+		ProductCode: productCode,
+		UserId:      userId,
+		MemberType:  "MEMBER",
+		Status:      1,
+		CreateTime:  now,
+		UpdateTime:  now,
+	})
+	require.NoError(t, err)
+	id, _ := res.LastInsertId()
+	return id
+}
+
 func insertTestRole(t *testing.T, svcCtx *svc.ServiceContext, productCode string, status int64) int64 {
 	t.Helper()
 	now := time.Now().Unix()
@@ -40,12 +57,14 @@ func TestBindRoles_Success(t *testing.T) {
 
 	username := testutil.UniqueId()
 	userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
+	mId := insertTestMember(t, svcCtx, "test_product", userId)
 
 	r1 := insertTestRole(t, svcCtx, "test_product", 1)
 	r2 := insertTestRole(t, svcCtx, "test_product", 1)
 
 	t.Cleanup(func() {
 		testutil.CleanTableByField(ctx, conn, "`sys_user_role`", "userId", userId)
+		testutil.CleanTable(ctx, conn, "`sys_product_member`", mId)
 		testutil.CleanTable(ctx, conn, "`sys_user`", userId)
 		testutil.CleanTable(ctx, conn, "`sys_role`", r1, r2)
 	})
@@ -88,11 +107,13 @@ func TestBindRoles_EmptyRoleIds_ClearsAll(t *testing.T) {
 
 	username := testutil.UniqueId()
 	userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
+	mId := insertTestMember(t, svcCtx, "test_product", userId)
 
 	r1 := insertTestRole(t, svcCtx, "test_product", 1)
 
 	t.Cleanup(func() {
 		testutil.CleanTableByField(ctx, conn, "`sys_user_role`", "userId", userId)
+		testutil.CleanTable(ctx, conn, "`sys_product_member`", mId)
 		testutil.CleanTable(ctx, conn, "`sys_user`", userId)
 		testutil.CleanTable(ctx, conn, "`sys_role`", r1)
 	})
@@ -123,6 +144,7 @@ func TestBindRoles_Rebind(t *testing.T) {
 
 	username := testutil.UniqueId()
 	userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
+	mId := insertTestMember(t, svcCtx, "test_product", userId)
 
 	r1 := insertTestRole(t, svcCtx, "test_product", 1)
 	r2 := insertTestRole(t, svcCtx, "test_product", 1)
@@ -130,6 +152,7 @@ func TestBindRoles_Rebind(t *testing.T) {
 
 	t.Cleanup(func() {
 		testutil.CleanTableByField(ctx, conn, "`sys_user_role`", "userId", userId)
+		testutil.CleanTable(ctx, conn, "`sys_product_member`", mId)
 		testutil.CleanTable(ctx, conn, "`sys_user`", userId)
 		testutil.CleanTable(ctx, conn, "`sys_role`", r1, r2, r3)
 	})
@@ -160,10 +183,12 @@ func TestBindRoles_RoleBelongsToOtherProduct(t *testing.T) {
 
 	username := testutil.UniqueId()
 	userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
+	mId := insertTestMember(t, svcCtx, "test_product", userId)
 
 	otherRole := insertTestRole(t, svcCtx, "other_product", 1)
 
 	t.Cleanup(func() {
+		testutil.CleanTable(ctx, conn, "`sys_product_member`", mId)
 		testutil.CleanTable(ctx, conn, "`sys_user`", userId)
 		testutil.CleanTable(ctx, conn, "`sys_role`", otherRole)
 	})
@@ -189,10 +214,12 @@ func TestBindRoles_RoleDisabled(t *testing.T) {
 
 	username := testutil.UniqueId()
 	userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
+	mId := insertTestMember(t, svcCtx, "test_product", userId)
 
 	disabledRole := insertTestRole(t, svcCtx, "test_product", 2)
 
 	t.Cleanup(func() {
+		testutil.CleanTable(ctx, conn, "`sys_product_member`", mId)
 		testutil.CleanTable(ctx, conn, "`sys_user`", userId)
 		testutil.CleanTable(ctx, conn, "`sys_role`", disabledRole)
 	})
@@ -218,7 +245,11 @@ func TestBindRoles_RoleNotExists(t *testing.T) {
 
 	username := testutil.UniqueId()
 	userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
-	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
+	mId := insertTestMember(t, svcCtx, "test_product", userId)
+	t.Cleanup(func() {
+		testutil.CleanTable(ctx, conn, "`sys_product_member`", mId)
+		testutil.CleanTable(ctx, conn, "`sys_user`", userId)
+	})
 
 	logic := NewBindRolesLogic(ctx, svcCtx)
 	err := logic.BindRoles(&types.BindRolesReq{
@@ -232,3 +263,26 @@ func TestBindRoles_RoleNotExists(t *testing.T) {
 	assert.Equal(t, 400, codeErr.Code())
 	assert.Contains(t, codeErr.Error(), "无效的角色ID")
 }
+
+// TC-0549: 目标用户不是当前产品成员时拒绝绑定角色(L-4修复验证)
+func TestBindRoles_NonMemberRejected(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+
+	username := testutil.UniqueId()
+	userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
+
+	logic := NewBindRolesLogic(ctx, svcCtx)
+	err := logic.BindRoles(&types.BindRolesReq{
+		UserId:  userId,
+		RoleIds: []int64{},
+	})
+	require.Error(t, err)
+
+	var codeErr2 *response.CodeError
+	require.True(t, errors.As(err, &codeErr2))
+	assert.Equal(t, 400, codeErr2.Code())
+	assert.Contains(t, codeErr2.Error(), "不是当前产品的成员")
+}

+ 4 - 0
internal/logic/user/setUserPermsLogic.go

@@ -40,6 +40,10 @@ func (l *SetUserPermsLogic) SetUserPerms(req *types.SetPermsReq) error {
 		return err
 	}
 
+	if _, err := l.svcCtx.SysProductMemberModel.FindOneByProductCodeUserId(l.ctx, productCode, req.UserId); err != nil {
+		return response.ErrBadRequest("目标用户不是当前产品的成员")
+	}
+
 	for _, p := range req.Perms {
 		if p.Effect != consts.PermEffectAllow && p.Effect != consts.PermEffectDeny {
 			return response.ErrBadRequest("effect值无效,仅支持 ALLOW 和 DENY")

+ 41 - 2
internal/logic/user/setUserPermsLogic_test.go

@@ -40,12 +40,14 @@ func TestSetUserPerms_Allow(t *testing.T) {
 
 	username := testutil.UniqueId()
 	userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
+	mId := insertTestMember(t, svcCtx, "test_product", userId)
 
 	p1 := insertTestPerm(t, svcCtx, "test_product")
 	p2 := insertTestPerm(t, svcCtx, "test_product")
 
 	t.Cleanup(func() {
 		testutil.CleanTableByField(ctx, conn, "`sys_user_perm`", "userId", userId)
+		testutil.CleanTable(ctx, conn, "`sys_product_member`", mId)
 		testutil.CleanTable(ctx, conn, "`sys_user`", userId)
 		testutil.CleanTable(ctx, conn, "`sys_perm`", p1, p2)
 	})
@@ -76,11 +78,13 @@ func TestSetUserPerms_Deny(t *testing.T) {
 
 	username := testutil.UniqueId()
 	userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
+	mId := insertTestMember(t, svcCtx, "test_product", userId)
 
 	p1 := insertTestPerm(t, svcCtx, "test_product")
 
 	t.Cleanup(func() {
 		testutil.CleanTableByField(ctx, conn, "`sys_user_perm`", "userId", userId)
+		testutil.CleanTable(ctx, conn, "`sys_product_member`", mId)
 		testutil.CleanTable(ctx, conn, "`sys_user`", userId)
 		testutil.CleanTable(ctx, conn, "`sys_perm`", p1)
 	})
@@ -129,11 +133,13 @@ func TestSetUserPerms_EmptyPerms_ClearsAll(t *testing.T) {
 
 	username := testutil.UniqueId()
 	userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
+	mId := insertTestMember(t, svcCtx, "test_product", userId)
 
 	p1 := insertTestPerm(t, svcCtx, "test_product")
 
 	t.Cleanup(func() {
 		testutil.CleanTableByField(ctx, conn, "`sys_user_perm`", "userId", userId)
+		testutil.CleanTable(ctx, conn, "`sys_product_member`", mId)
 		testutil.CleanTable(ctx, conn, "`sys_user`", userId)
 		testutil.CleanTable(ctx, conn, "`sys_perm`", p1)
 	})
@@ -166,7 +172,11 @@ func TestSetUserPerms_InvalidEffect(t *testing.T) {
 
 	username := testutil.UniqueId()
 	userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
-	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
+	mId := insertTestMember(t, svcCtx, "test_product", userId)
+	t.Cleanup(func() {
+		testutil.CleanTable(ctx, conn, "`sys_product_member`", mId)
+		testutil.CleanTable(ctx, conn, "`sys_user`", userId)
+	})
 
 	logic := NewSetUserPermsLogic(ctx, svcCtx)
 	err := logic.SetUserPerms(&types.SetPermsReq{
@@ -191,7 +201,11 @@ func TestSetUserPerms_PermNotExists(t *testing.T) {
 
 	username := testutil.UniqueId()
 	userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
-	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
+	mId := insertTestMember(t, svcCtx, "test_product", userId)
+	t.Cleanup(func() {
+		testutil.CleanTable(ctx, conn, "`sys_product_member`", mId)
+		testutil.CleanTable(ctx, conn, "`sys_user`", userId)
+	})
 
 	logic := NewSetUserPermsLogic(ctx, svcCtx)
 	err := logic.SetUserPerms(&types.SetPermsReq{
@@ -216,10 +230,12 @@ func TestSetUserPerms_PermBelongsToOtherProduct(t *testing.T) {
 
 	username := testutil.UniqueId()
 	userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
+	mId := insertTestMember(t, svcCtx, "test_product", userId)
 
 	otherPerm := insertTestPerm(t, svcCtx, "other_product")
 
 	t.Cleanup(func() {
+		testutil.CleanTable(ctx, conn, "`sys_product_member`", mId)
 		testutil.CleanTable(ctx, conn, "`sys_user`", userId)
 		testutil.CleanTable(ctx, conn, "`sys_perm`", otherPerm)
 	})
@@ -238,3 +254,26 @@ func TestSetUserPerms_PermBelongsToOtherProduct(t *testing.T) {
 	assert.Equal(t, 400, codeErr.Code())
 	assert.Contains(t, codeErr.Error(), "其他产品的权限")
 }
+
+// TC-0550: 目标用户不是当前产品成员时拒绝设置权限(L-5修复验证)
+func TestSetUserPerms_NonMemberRejected(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+
+	username := testutil.UniqueId()
+	userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
+
+	logic := NewSetUserPermsLogic(ctx, svcCtx)
+	err := logic.SetUserPerms(&types.SetPermsReq{
+		UserId: userId,
+		Perms:  []types.UserPermItem{},
+	})
+	require.Error(t, err)
+
+	var codeErr2 *response.CodeError
+	require.True(t, errors.As(err, &codeErr2))
+	assert.Equal(t, 400, codeErr2.Code())
+	assert.Contains(t, codeErr2.Error(), "不是当前产品的成员")
+}

+ 4 - 0
internal/logic/user/updateUserLogic.go

@@ -49,6 +49,10 @@ func (l *UpdateUserLogic) UpdateUser(req *types.UpdateUserReq) error {
 		return response.ErrNotFound("用户不存在")
 	}
 
+	if caller.UserId != req.Id && req.Status != 0 && user.IsSuperAdmin == consts.IsSuperAdminYes {
+		return response.ErrForbidden("不能通过此接口修改超级管理员的状态")
+	}
+
 	if req.Nickname != nil {
 		user.Nickname = *req.Nickname
 	}

+ 39 - 0
internal/logic/user/updateUserLogic_test.go

@@ -9,6 +9,7 @@ import (
 	"perms-system-server/internal/consts"
 	"perms-system-server/internal/loaders"
 	deptModel "perms-system-server/internal/model/dept"
+	userModel "perms-system-server/internal/model/user"
 	"perms-system-server/internal/response"
 	"perms-system-server/internal/svc"
 	"perms-system-server/internal/testutil"
@@ -427,3 +428,41 @@ func TestUpdateUser_NotLoggedInRejected(t *testing.T) {
 	assert.Equal(t, 401, ce.Code())
 	assert.Equal(t, "未登录", ce.Error())
 }
+
+// TC-0544: 超管A通过updateUser修改超管B的状态被拒绝(H-2修复验证)
+func TestUpdateUser_SuperAdminCannotFreezeOtherSuperAdmin(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+	now := time.Now().Unix()
+
+	uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
+		Username:           testutil.UniqueId(),
+		Password:           testutil.HashPassword("pass"),
+		Nickname:           "super_b",
+		IsSuperAdmin:       consts.IsSuperAdminYes,
+		MustChangePassword: 2,
+		Status:             consts.StatusEnabled,
+		CreateTime:         now,
+		UpdateTime:         now,
+	})
+	require.NoError(t, err)
+	superBId, _ := uRes.LastInsertId()
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", superBId) })
+
+	logic := NewUpdateUserLogic(ctx, svcCtx)
+	err = logic.UpdateUser(&types.UpdateUserReq{
+		Id:     superBId,
+		Status: consts.StatusDisabled,
+	})
+	require.Error(t, err)
+
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 403, ce.Code())
+	assert.Equal(t, "不能通过此接口修改超级管理员的状态", ce.Error())
+
+	user, err := svcCtx.SysUserModel.FindOne(ctx, superBId)
+	require.NoError(t, err)
+	assert.Equal(t, int64(consts.StatusEnabled), user.Status, "超管B的状态不应被修改")
+}

+ 1 - 7
internal/middleware/ratelimitMiddleware.go

@@ -22,13 +22,7 @@ func NewRateLimitMiddleware(rds *redis.Redis, period int, quota int, keyPrefix s
 
 func (m *RateLimitMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc {
 	return func(w http.ResponseWriter, r *http.Request) {
-		ip := r.Header.Get("X-Forwarded-For")
-		if ip == "" {
-			ip = r.Header.Get("X-Real-IP")
-		}
-		if ip == "" {
-			ip = r.RemoteAddr
-		}
+		ip := r.RemoteAddr
 
 		key := fmt.Sprintf("ip:%s", ip)
 		code, _ := m.limiter.Take(key)

+ 38 - 40
internal/middleware/ratelimitMiddleware_test.go

@@ -86,58 +86,56 @@ func TestRateLimit_OverQuotaRejected(t *testing.T) {
 	assert.Equal(t, "请求过于频繁,请稍后再试", body.Msg)
 }
 
-// TC-0538: IP从X-Forwarded-For获取
-func TestRateLimit_IPFromXForwardedFor(t *testing.T) {
+// TC-0538: X-Forwarded-For被忽略(M-1安全修复验证)
+func TestRateLimit_XForwardedForIgnored(t *testing.T) {
 	rds := newTestRedis()
 	m := newTestMiddleware(rds, 1)
-	ip := uniqueIP()
 
-	var gotNext bool
+	var nextCount int
 	handler := m.Handle(func(w http.ResponseWriter, r *http.Request) {
-		gotNext = true
+		nextCount++
 		w.WriteHeader(http.StatusOK)
 	})
 
+	remoteAddr := uniqueIP() + ":12345"
+
 	req := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
-	req.Header.Set("X-Forwarded-For", ip)
-	req.Header.Set("X-Real-IP", uniqueIP())
-	w := httptest.NewRecorder()
-	handler(w, req)
-	assert.True(t, gotNext)
+	req.RemoteAddr = remoteAddr
+	req.Header.Set("X-Forwarded-For", uniqueIP())
+	handler(httptest.NewRecorder(), req)
+	assert.Equal(t, 1, nextCount)
 
-	gotNext = false
 	req2 := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
-	req2.Header.Set("X-Forwarded-For", ip)
-	req2.Header.Set("X-Real-IP", uniqueIP())
-	w2 := httptest.NewRecorder()
-	handler(w2, req2)
-	assert.False(t, gotNext, "should be rate limited by X-Forwarded-For IP")
+	req2.RemoteAddr = remoteAddr
+	req2.Header.Set("X-Forwarded-For", uniqueIP())
+	handler(httptest.NewRecorder(), req2)
+	assert.Equal(t, 1, nextCount, "different X-Forwarded-For should NOT bypass rate limit; RemoteAddr is used")
 }
 
-// TC-0539: IP从X-Real-IP获取
-func TestRateLimit_IPFromXRealIP(t *testing.T) {
+// TC-0539: X-Real-IP被忽略(M-1安全修复验证)
+func TestRateLimit_XRealIPIgnored(t *testing.T) {
 	rds := newTestRedis()
 	m := newTestMiddleware(rds, 1)
-	ip := uniqueIP()
 
-	var gotNext bool
+	var nextCount int
 	handler := m.Handle(func(w http.ResponseWriter, r *http.Request) {
-		gotNext = true
+		nextCount++
 		w.WriteHeader(http.StatusOK)
 	})
 
+	remoteAddr := uniqueIP() + ":12345"
+
 	req := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
-	req.Header.Set("X-Real-IP", ip)
-	w := httptest.NewRecorder()
-	handler(w, req)
-	assert.True(t, gotNext)
+	req.RemoteAddr = remoteAddr
+	req.Header.Set("X-Real-IP", uniqueIP())
+	handler(httptest.NewRecorder(), req)
+	assert.Equal(t, 1, nextCount)
 
-	gotNext = false
 	req2 := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
-	req2.Header.Set("X-Real-IP", ip)
-	w2 := httptest.NewRecorder()
-	handler(w2, req2)
-	assert.False(t, gotNext, "should be rate limited by X-Real-IP")
+	req2.RemoteAddr = remoteAddr
+	req2.Header.Set("X-Real-IP", uniqueIP())
+	handler(httptest.NewRecorder(), req2)
+	assert.Equal(t, 1, nextCount, "different X-Real-IP should NOT bypass rate limit; RemoteAddr is used")
 }
 
 // TC-0540: IP从RemoteAddr获取
@@ -167,12 +165,12 @@ func TestRateLimit_IPFromRemoteAddr(t *testing.T) {
 	assert.False(t, gotNext, "should be rate limited by RemoteAddr")
 }
 
-// TC-0541: 不同IP独立限流
+// TC-0541: 不同RemoteAddr独立限流
 func TestRateLimit_DifferentIPsIndependent(t *testing.T) {
 	rds := newTestRedis()
 	m := newTestMiddleware(rds, 1)
-	ip1 := uniqueIP()
-	ip2 := uniqueIP()
+	addr1 := uniqueIP() + ":12345"
+	addr2 := uniqueIP() + ":12345"
 
 	var nextCount int
 	handler := m.Handle(func(w http.ResponseWriter, r *http.Request) {
@@ -181,22 +179,22 @@ func TestRateLimit_DifferentIPsIndependent(t *testing.T) {
 	})
 
 	req1 := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
-	req1.Header.Set("X-Forwarded-For", ip1)
+	req1.RemoteAddr = addr1
 	handler(httptest.NewRecorder(), req1)
 	assert.Equal(t, 1, nextCount)
 
 	req2 := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
-	req2.Header.Set("X-Forwarded-For", ip2)
+	req2.RemoteAddr = addr2
 	handler(httptest.NewRecorder(), req2)
-	assert.Equal(t, 2, nextCount, "different IPs should have independent quotas")
+	assert.Equal(t, 2, nextCount, "different RemoteAddr should have independent quotas")
 
 	req3 := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
-	req3.Header.Set("X-Forwarded-For", ip1)
+	req3.RemoteAddr = addr1
 	handler(httptest.NewRecorder(), req3)
-	assert.Equal(t, 2, nextCount, "ip1 should be over quota")
+	assert.Equal(t, 2, nextCount, "addr1 should be over quota")
 
 	req4 := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
-	req4.Header.Set("X-Forwarded-For", ip2)
+	req4.RemoteAddr = addr2
 	handler(httptest.NewRecorder(), req4)
-	assert.Equal(t, 2, nextCount, "ip2 should be over quota")
+	assert.Equal(t, 2, nextCount, "addr2 should be over quota")
 }

+ 12 - 0
internal/model/userperm/sysUserPermModel.go

@@ -15,6 +15,7 @@ type (
 		sysUserPermModel
 		FindByUserId(ctx context.Context, userId int64) ([]*SysUserPerm, error)
 		FindPermIdsByUserIdAndEffect(ctx context.Context, userId int64, effect string) ([]int64, error)
+		FindPermIdsByUserIdAndEffectForProduct(ctx context.Context, userId int64, effect string, productCode string) ([]int64, error)
 		DeleteByUserId(ctx context.Context, userId int64) error
 		DeleteByUserIdTx(ctx context.Context, session sqlx.Session, userId int64) error
 		DeleteByUserIdForProduct(ctx context.Context, userId int64, productCode string) error
@@ -50,6 +51,17 @@ func (m *customSysUserPermModel) FindPermIdsByUserIdAndEffect(ctx context.Contex
 	return ids, nil
 }
 
+func (m *customSysUserPermModel) FindPermIdsByUserIdAndEffectForProduct(ctx context.Context, userId int64, effect string, productCode string) ([]int64, error) {
+	var ids []int64
+	query := fmt.Sprintf(
+		"SELECT up.`permId` FROM %s up INNER JOIN `sys_perm` p ON up.`permId` = p.`id` WHERE up.`userId` = ? AND up.`effect` = ? AND p.`productCode` = ?",
+		m.table)
+	if err := m.QueryRowsNoCacheCtx(ctx, &ids, query, userId, effect, productCode); err != nil {
+		return nil, err
+	}
+	return ids, nil
+}
+
 func (m *customSysUserPermModel) DeleteByUserId(ctx context.Context, userId int64) error {
 	query := fmt.Sprintf("DELETE FROM %s WHERE `userId` = ?", m.table)
 	_, err := m.ExecNoCacheCtx(ctx, query, userId)

+ 42 - 49
internal/server/permserver.go

@@ -3,19 +3,22 @@ package server
 import (
 	"context"
 	"crypto/subtle"
+	"fmt"
 	"time"
 
 	"perms-system-server/internal/consts"
 	authHelper "perms-system-server/internal/logic/auth"
+	pub "perms-system-server/internal/logic/pub"
 	"perms-system-server/internal/middleware"
 	permModel "perms-system-server/internal/model/perm"
 	"perms-system-server/internal/svc"
 	"perms-system-server/pb"
 
 	"github.com/golang-jwt/jwt/v4"
+	"github.com/zeromicro/go-zero/core/limit"
 	"github.com/zeromicro/go-zero/core/stores/sqlx"
-	"golang.org/x/crypto/bcrypt"
 	"google.golang.org/grpc/codes"
+	"google.golang.org/grpc/peer"
 	"google.golang.org/grpc/status"
 )
 
@@ -110,58 +113,40 @@ func (s *PermServer) SyncPermissions(ctx context.Context, req *pb.SyncPermission
 }
 
 func (s *PermServer) Login(ctx context.Context, req *pb.LoginReq) (*pb.LoginResp, error) {
-	if req.ProductCode == "" {
-		return nil, status.Error(codes.InvalidArgument, "productCode不能为空")
-	}
-
-	user, err := s.svcCtx.SysUserModel.FindOneByUsername(ctx, req.Username)
-	if err != nil {
-		return nil, status.Error(codes.Unauthenticated, "用户名或密码错误")
-	}
-	if user.Status != consts.StatusEnabled {
-		return nil, status.Error(codes.PermissionDenied, "账号已被冻结")
-	}
-	if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil {
-		return nil, status.Error(codes.Unauthenticated, "用户名或密码错误")
-	}
-
-	if user.IsSuperAdmin == consts.IsSuperAdminYes {
-		return nil, status.Error(codes.PermissionDenied, "超级管理员不允许通过产品端登录")
-	}
-
-	product, err := s.svcCtx.SysProductModel.FindOneByCode(ctx, req.ProductCode)
-	if err != nil {
-		return nil, status.Error(codes.InvalidArgument, "产品不存在")
-	}
-	if product.Status != consts.StatusEnabled {
-		return nil, status.Error(codes.PermissionDenied, "该产品已被禁用")
-	}
-
-	if _, memberErr := s.svcCtx.SysProductMemberModel.FindOneByProductCodeUserId(ctx, req.ProductCode, user.Id); memberErr != nil {
-		return nil, status.Error(codes.PermissionDenied, "您不是该产品的成员")
+	if s.svcCtx.GrpcLoginLimiter != nil {
+		p, ok := peer.FromContext(ctx)
+		if ok {
+			ip := p.Addr.String()
+			code, _ := s.svcCtx.GrpcLoginLimiter.Take(fmt.Sprintf("grpc:login:%s", ip))
+			if code == limit.OverQuota {
+				return nil, status.Error(codes.ResourceExhausted, "请求过于频繁,请稍后再试")
+			}
+		}
 	}
 
-	ud := s.svcCtx.UserDetailsLoader.Load(ctx, user.Id, req.ProductCode)
-
-	accessToken, err := authHelper.GenerateAccessToken(
-		s.svcCtx.Config.Auth.AccessSecret, s.svcCtx.Config.Auth.AccessExpire,
-		ud.UserId, ud.Username, ud.ProductCode, ud.MemberType, ud.Perms,
-	)
-	if err != nil {
-		return nil, status.Error(codes.Internal, "生成token失败")
+	if req.ProductCode == "" {
+		return nil, status.Error(codes.InvalidArgument, "productCode不能为空")
 	}
 
-	refreshToken, err := authHelper.GenerateRefreshToken(
-		s.svcCtx.Config.Auth.RefreshSecret, s.svcCtx.Config.Auth.RefreshExpire,
-		ud.UserId, ud.ProductCode,
-	)
+	result, err := pub.ValidateProductLogin(ctx, s.svcCtx, req.Username, req.Password, req.ProductCode)
 	if err != nil {
-		return nil, status.Error(codes.Internal, "生成token失败")
+		if le, ok := err.(*pub.LoginError); ok {
+			switch le.Code {
+			case 400:
+				return nil, status.Error(codes.InvalidArgument, le.Message)
+			case 401:
+				return nil, status.Error(codes.Unauthenticated, le.Message)
+			case 403:
+				return nil, status.Error(codes.PermissionDenied, le.Message)
+			}
+		}
+		return nil, status.Error(codes.Internal, "登录失败")
 	}
 
+	ud := result.UserDetails
 	return &pb.LoginResp{
-		AccessToken:  accessToken,
-		RefreshToken: refreshToken,
+		AccessToken:  result.AccessToken,
+		RefreshToken: result.RefreshToken,
 		Expires:      time.Now().Unix() + s.svcCtx.Config.Auth.AccessExpire,
 		UserId:       ud.UserId,
 		Username:     ud.Username,
@@ -219,12 +204,20 @@ func (s *PermServer) VerifyToken(ctx context.Context, req *pb.VerifyTokenReq) (*
 		return &pb.VerifyTokenResp{Valid: false}, nil
 	}
 
+	ud := s.svcCtx.UserDetailsLoader.Load(ctx, claims.UserId, claims.ProductCode)
+	if ud.Status != consts.StatusEnabled {
+		return &pb.VerifyTokenResp{Valid: false}, nil
+	}
+	if claims.ProductCode != "" && !ud.IsSuperAdmin && ud.MemberType == "" {
+		return &pb.VerifyTokenResp{Valid: false}, nil
+	}
+
 	return &pb.VerifyTokenResp{
 		Valid:      true,
-		UserId:     claims.UserId,
-		Username:   claims.Username,
-		MemberType: claims.MemberType,
-		Perms:      claims.Perms,
+		UserId:     ud.UserId,
+		Username:   ud.Username,
+		MemberType: ud.MemberType,
+		Perms:      ud.Perms,
 	}, nil
 }
 

+ 234 - 5
internal/server/permserver_test.go

@@ -310,7 +310,7 @@ func TestLogin_SuperAdminRejected(t *testing.T) {
 	})
 	require.Error(t, err)
 	assert.Equal(t, codes.PermissionDenied, status.Code(err))
-	assert.Equal(t, "超级管理员不允许通过产品端登录", status.Convert(err).Message())
+	assert.Equal(t, "超级管理员不允许通过产品端登录,请使用管理后台", status.Convert(err).Message())
 }
 
 // TC-0171: 普通用户+productCode
@@ -673,15 +673,64 @@ func TestRefreshToken_NormalUserWithProductCode(t *testing.T) {
 
 // ---------- VerifyToken ----------
 
-// TC-0178: 有效token
+// TC-0178: 有效token(VerifyToken 现在实时查询DB,需要真实数据)
 func TestVerifyToken_Valid(t *testing.T) {
 	ctx := context.Background()
 	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
 	cfg := testutil.GetTestConfig()
+	conn := testutil.GetTestSqlConn()
+	ts := time.Now().Unix()
+	uid := testutil.UniqueId()
+
+	uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
+		Username: uid, Password: testutil.HashPassword("pass123"), Nickname: "nick_verify",
+		Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2,
+		Status: 1, CreateTime: ts, UpdateTime: ts,
+	})
+	require.NoError(t, err)
+	uId, _ := uRes.LastInsertId()
+
+	pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{
+		Code: uid, Name: "prod_verify", AppKey: uid + "_k", AppSecret: "s1",
+		Status: 1, CreateTime: ts, UpdateTime: ts,
+	})
+	require.NoError(t, err)
+	pId, _ := pRes.LastInsertId()
+
+	pmRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &memberModel.SysProductMember{
+		ProductCode: uid, UserId: uId, MemberType: "ADMIN", Status: 1,
+		CreateTime: ts, UpdateTime: ts,
+	})
+	require.NoError(t, err)
+	pmId, _ := pmRes.LastInsertId()
+
+	pm1Res, err := svcCtx.SysPermModel.Insert(ctx, &permModel.SysPerm{
+		ProductCode: uid, Name: "perm_a", Code: "perm_a",
+		Status: 1, CreateTime: ts, UpdateTime: ts,
+	})
+	require.NoError(t, err)
+	pm1Id, _ := pm1Res.LastInsertId()
+
+	pm2Res, err := svcCtx.SysPermModel.Insert(ctx, &permModel.SysPerm{
+		ProductCode: uid, Name: "perm_b", Code: "perm_b",
+		Status: 1, CreateTime: ts, UpdateTime: ts,
+	})
+	require.NoError(t, err)
+	pm2Id, _ := pm2Res.LastInsertId()
+
+	t.Cleanup(func() {
+		svcCtx.UserDetailsLoader.Del(ctx, uId, uid)
+		testutil.CleanTable(ctx, conn, "`sys_perm`", pm1Id, pm2Id)
+		testutil.CleanTable(ctx, conn, "`sys_product_member`", pmId)
+		testutil.CleanTable(ctx, conn, "`sys_product`", pId)
+		testutil.CleanTable(ctx, conn, "`sys_user`", uId)
+	})
+
+	svcCtx.UserDetailsLoader.Del(ctx, uId, uid)
 
 	accessToken, err := authHelper.GenerateAccessToken(
 		cfg.Auth.AccessSecret, cfg.Auth.AccessExpire,
-		100, "testuser", "prod1", "ADMIN", []string{"perm_a", "perm_b"},
+		uId, uid, uid, "ADMIN", []string{"perm_a", "perm_b"},
 	)
 	require.NoError(t, err)
 
@@ -689,8 +738,8 @@ func TestVerifyToken_Valid(t *testing.T) {
 	resp, err := srv.VerifyToken(ctx, &pb.VerifyTokenReq{AccessToken: accessToken})
 	require.NoError(t, err)
 	assert.True(t, resp.Valid)
-	assert.Equal(t, int64(100), resp.UserId)
-	assert.Equal(t, "testuser", resp.Username)
+	assert.Equal(t, uId, resp.UserId)
+	assert.Equal(t, uid, resp.Username)
 	assert.Equal(t, "ADMIN", resp.MemberType)
 	assert.ElementsMatch(t, []string{"perm_a", "perm_b"}, resp.Perms)
 }
@@ -934,6 +983,186 @@ func TestGetUserPerms_MemberDENYOverride(t *testing.T) {
 	assert.NotContains(t, resp.Perms, uid+"_pA")
 }
 
+// TC-0545: gRPC VerifyToken 用户已冻结返回valid=false(H-4修复验证)
+func TestVerifyToken_FrozenUserReturnsInvalid(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+	now := time.Now().Unix()
+	uid := testutil.UniqueId()
+	cfg := testutil.GetTestConfig()
+
+	uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
+		Username: uid, Password: testutil.HashPassword("pass"), Nickname: "frozen",
+		Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2,
+		Status: 2, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	uId, _ := uRes.LastInsertId()
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", uId) })
+
+	accessToken, err := authHelper.GenerateAccessToken(
+		cfg.Auth.AccessSecret, cfg.Auth.AccessExpire,
+		uId, uid, "", "MEMBER", []string{"some_perm"},
+	)
+	require.NoError(t, err)
+
+	srv := NewPermServer(svcCtx)
+	resp, err := srv.VerifyToken(ctx, &pb.VerifyTokenReq{AccessToken: accessToken})
+	require.NoError(t, err)
+	assert.False(t, resp.Valid, "frozen user token should be invalid")
+}
+
+// TC-0546: gRPC VerifyToken 非产品成员返回valid=false(H-4修复验证)
+func TestVerifyToken_NonMemberReturnsInvalid(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+	now := time.Now().Unix()
+	uid := testutil.UniqueId()
+	pc := testutil.UniqueId()
+	cfg := testutil.GetTestConfig()
+
+	uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
+		Username: uid, Password: testutil.HashPassword("pass"), Nickname: "user",
+		Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2,
+		Status: 1, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	uId, _ := uRes.LastInsertId()
+
+	pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{
+		Code: pc, Name: "prod", AppKey: testutil.UniqueId(), AppSecret: "s",
+		Status: 1, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	pId, _ := pRes.LastInsertId()
+
+	t.Cleanup(func() {
+		testutil.CleanTable(ctx, conn, "`sys_product`", pId)
+		testutil.CleanTable(ctx, conn, "`sys_user`", uId)
+	})
+
+	accessToken, err := authHelper.GenerateAccessToken(
+		cfg.Auth.AccessSecret, cfg.Auth.AccessExpire,
+		uId, uid, pc, "MEMBER", []string{"perm_a"},
+	)
+	require.NoError(t, err)
+
+	srv := NewPermServer(svcCtx)
+	resp, err := srv.VerifyToken(ctx, &pb.VerifyTokenReq{AccessToken: accessToken})
+	require.NoError(t, err)
+	assert.False(t, resp.Valid, "non-member user with productCode should be invalid")
+}
+
+// TC-0547: gRPC VerifyToken 返回实时权限和成员类型(H-4修复验证)
+func TestVerifyToken_ReturnsRealtimeData(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+	now := time.Now().Unix()
+	uid := testutil.UniqueId()
+	cfg := testutil.GetTestConfig()
+
+	uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
+		Username: uid, Password: testutil.HashPassword("pass"), Nickname: "user",
+		Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2,
+		Status: 1, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	uId, _ := uRes.LastInsertId()
+
+	pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{
+		Code: uid, Name: "prod", AppKey: uid + "_k", AppSecret: "s",
+		Status: 1, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	pId, _ := pRes.LastInsertId()
+
+	mbrRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &memberModel.SysProductMember{
+		ProductCode: uid, UserId: uId, MemberType: "ADMIN",
+		Status: 1, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	mbrId, _ := mbrRes.LastInsertId()
+
+	permRes, err := svcCtx.SysPermModel.Insert(ctx, &permModel.SysPerm{
+		ProductCode: uid, Name: "realtime_perm", Code: uid + "_rt",
+		Status: 1, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	permId, _ := permRes.LastInsertId()
+
+	t.Cleanup(func() {
+		testutil.CleanTable(ctx, conn, "`sys_perm`", permId)
+		testutil.CleanTable(ctx, conn, "`sys_product_member`", mbrId)
+		testutil.CleanTable(ctx, conn, "`sys_product`", pId)
+		testutil.CleanTable(ctx, conn, "`sys_user`", uId)
+	})
+
+	accessToken, err := authHelper.GenerateAccessToken(
+		cfg.Auth.AccessSecret, cfg.Auth.AccessExpire,
+		uId, uid, uid, "MEMBER", []string{"old_perm"},
+	)
+	require.NoError(t, err)
+
+	svcCtx.UserDetailsLoader.Clean(ctx, uId)
+
+	srv := NewPermServer(svcCtx)
+	resp, err := srv.VerifyToken(ctx, &pb.VerifyTokenReq{AccessToken: accessToken})
+	require.NoError(t, err)
+	assert.True(t, resp.Valid)
+	assert.Equal(t, "ADMIN", resp.MemberType, "should return realtime memberType, not token's")
+	assert.Contains(t, resp.Perms, uid+"_rt", "should return realtime perms")
+}
+
+// TC-0548: gRPC Login 产品成员被禁用时拒绝(H-3修复验证)
+func TestLogin_DisabledMemberRejected(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+	now := time.Now().Unix()
+	uid := testutil.UniqueId()
+
+	uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
+		Username: uid, Password: testutil.HashPassword("pass123"), Nickname: "nick",
+		Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2,
+		Status: 1, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	uId, _ := uRes.LastInsertId()
+
+	pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{
+		Code: uid, Name: "prod", AppKey: uid + "_k", AppSecret: "s1",
+		Status: 1, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	pId, _ := pRes.LastInsertId()
+
+	pmRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &memberModel.SysProductMember{
+		ProductCode: uid, UserId: uId, MemberType: "MEMBER", Status: 2,
+		CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	pmId, _ := pmRes.LastInsertId()
+
+	t.Cleanup(func() {
+		testutil.CleanTable(ctx, conn, "`sys_product_member`", pmId)
+		testutil.CleanTable(ctx, conn, "`sys_product`", pId)
+		testutil.CleanTable(ctx, conn, "`sys_user`", uId)
+	})
+
+	srv := NewPermServer(svcCtx)
+	_, err = srv.Login(ctx, &pb.LoginReq{
+		Username:    uid,
+		Password:    "pass123",
+		ProductCode: uid,
+	})
+	require.Error(t, err)
+	assert.Equal(t, codes.PermissionDenied, status.Code(err))
+	assert.Equal(t, "您在该产品下的成员资格已被禁用", status.Convert(err).Message())
+}
+
 // helper: create a JWT with no userId claim
 func createTokenWithoutUserId(secret string) string {
 	claims := jwt.MapClaims{

+ 4 - 0
internal/svc/servicecontext.go

@@ -6,6 +6,7 @@ import (
 	"perms-system-server/internal/middleware"
 	"perms-system-server/internal/model"
 
+	"github.com/zeromicro/go-zero/core/limit"
 	"github.com/zeromicro/go-zero/core/stores/redis"
 	"github.com/zeromicro/go-zero/core/stores/sqlx"
 	"github.com/zeromicro/go-zero/rest"
@@ -15,6 +16,7 @@ type ServiceContext struct {
 	Config            config.Config
 	JwtAuth           rest.Middleware
 	LoginRateLimit    rest.Middleware
+	GrpcLoginLimiter  *limit.PeriodLimit
 	UserDetailsLoader *loaders.UserDetailsLoader
 	*model.Models
 }
@@ -25,11 +27,13 @@ func NewServiceContext(c config.Config) *ServiceContext {
 	models := model.NewModels(conn, c.CacheRedis.Nodes, c.CacheRedis.KeyPrefix)
 	udLoader := loaders.NewUserDetailsLoader(rds, c.CacheRedis.KeyPrefix, models)
 	rlMiddleware := middleware.NewRateLimitMiddleware(rds, 60, 20, c.CacheRedis.KeyPrefix+":rl:login")
+	grpcLimiter := limit.NewPeriodLimit(60, 20, rds, c.CacheRedis.KeyPrefix+":rl:grpc:login")
 
 	return &ServiceContext{
 		Config:            c,
 		JwtAuth:           middleware.NewJwtAuthMiddleware(c.Auth.AccessSecret, udLoader).Handle,
 		LoginRateLimit:    rlMiddleware.Handle,
+		GrpcLoginLimiter:  grpcLimiter,
 		UserDetailsLoader: udLoader,
 		Models:            models,
 	}

+ 15 - 0
internal/testutil/mocks/mock_userperm_model.go

@@ -301,6 +301,21 @@ func (mr *MockSysUserPermModelMockRecorder) FindPermIdsByUserIdAndEffect(ctx, us
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindPermIdsByUserIdAndEffect", reflect.TypeOf((*MockSysUserPermModel)(nil).FindPermIdsByUserIdAndEffect), ctx, userId, effect)
 }
 
+// FindPermIdsByUserIdAndEffectForProduct mocks base method.
+func (m *MockSysUserPermModel) FindPermIdsByUserIdAndEffectForProduct(ctx context.Context, userId int64, effect string, productCode string) ([]int64, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "FindPermIdsByUserIdAndEffectForProduct", ctx, userId, effect, productCode)
+	ret0, _ := ret[0].([]int64)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// FindPermIdsByUserIdAndEffectForProduct indicates an expected call of FindPermIdsByUserIdAndEffectForProduct.
+func (mr *MockSysUserPermModelMockRecorder) FindPermIdsByUserIdAndEffectForProduct(ctx, userId, effect, productCode any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindPermIdsByUserIdAndEffectForProduct", reflect.TypeOf((*MockSysUserPermModel)(nil).FindPermIdsByUserIdAndEffectForProduct), ctx, userId, effect, productCode)
+}
+
 // Insert mocks base method.
 func (m *MockSysUserPermModel) Insert(ctx context.Context, data *userperm.SysUserPerm) (sql.Result, error) {
 	m.ctrl.T.Helper()

+ 53 - 0
test-design.md

@@ -886,6 +886,59 @@ MySQL (InnoDB) + Redis Cache
 | TC-0512 | updateUser自己修改Status被拒绝 | ctx含userId=X, req.Id=X, req.Status!=0 | 403 "不允许修改自己的部门和状态" | 安全 | P0 | H-01修复: 自编辑限制Status |
 | TC-0513 | updateUser未登录被拒绝 | ctx无UserDetails | 401 "未登录" | 安全 | P0 | H-01修复: caller==nil |
 
+## 十二-B、审计修复验证 (audit-report.md)
+
+### 12B.1 H-1: loadPerms 跨产品权限泄漏修复验证
+
+| TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-0551 | 用户ALLOW权限不跨产品泄漏 | 用户在产品A/B各有ALLOW权限 | 加载产品A时仅含A权限,不含B权限 | 安全 | P0 | H-1: FindPermIdsByUserIdAndEffectForProduct |
+
+### 12B.2 H-2: UpdateUser 超管状态保护修复验证
+
+| TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-0544 | 超管不能冻结另一超管 | caller=超管A, target=超管B, status=2 | 403 "不能通过此接口修改超级管理员的状态" | 安全 | P0 | H-2: IsSuperAdmin==Yes 保护 |
+
+### 12B.3 H-3: 禁用成员仍可登录修复验证
+
+| TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-0542 | 产品成员被禁用时拒绝登录(HTTP) | member.status=Disabled | 403 "您的产品成员资格已被禁用" | 安全 | P0 | H-3: loginService |
+| TC-0543 | 产品被禁用时拒绝登录(HTTP) | product.status=Disabled | 403 "该产品已被禁用" | 安全 | P0 | H-3: loginService |
+| TC-0548 | 产品成员被禁用时拒绝登录(gRPC) | member.status=Disabled | PermissionDenied | 安全 | P0 | H-3: permserver Login |
+| TC-0552 | 禁用成员MemberType为空 | member.status=Disabled | ud.MemberType="" | 安全 | P0 | H-3: loadMembership |
+
+### 12B.4 H-4: gRPC VerifyToken 实时状态检查修复验证
+
+| TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-0545 | 冻结用户token返回Invalid | user.status=Disabled | Valid=false | 安全 | P0 | H-4: 实时查DB |
+| TC-0546 | 非成员token返回Invalid | user非产品成员 | Valid=false | 安全 | P0 | H-4: 实时查成员状态 |
+| TC-0547 | 返回实时MemberType和Perms | DB中ADMIN+自定义权限 | 返回实时数据而非token中旧数据 | 安全 | P0 | H-4: 实时数据 |
+
+### 12B.5 M-1: 限流IP伪造修复验证
+
+| TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-0538 | X-Forwarded-For被忽略 | 不同XFF头+相同RemoteAddr | 仍被限流(XFF不影响key) | 安全 | P0 | M-1: 仅用RemoteAddr |
+| TC-0539 | X-Real-IP被忽略 | 不同XRI头+相同RemoteAddr | 仍被限流(XRI不影响key) | 安全 | P0 | M-1: 仅用RemoteAddr |
+
+### 12B.6 M-3: 禁用DEV部门不影响权限修复验证
+
+| TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-0553 | 禁用DEV部门成员无全量权限 | dept.type=DEV, dept.status=Disabled | ud.Perms为空 | 安全 | P0 | M-3: DeptStatus检查 |
+
+### 12B.7 L-4/L-5: BindRoles/SetUserPerms 成员校验修复验证
+
+| TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-0549 | 非产品成员绑定角色被拒绝 | 目标用户非当前产品成员 | 400 "不是当前产品的成员" | 安全 | P0 | L-4: BindRoles |
+| TC-0550 | 非产品成员设置权限被拒绝 | 目标用户非当前产品成员 | 400 "不是当前产品的成员" | 安全 | P0 | L-5: SetUserPerms |
+
+---
+
 ## 十三、Model层 — 新增方法
 
 ### 13.1 SysRoleModel

+ 108 - 38
test-report.md

@@ -10,13 +10,13 @@
 
 | 指标 | 数值 |
 | :--- | :--- |
-| 测试用例总数 (test-design.md) | 541 |
-| 已覆盖 TC 数 | 540 |
+| 测试用例总数 (test-design.md) | 553 |
+| 已覆盖 TC 数 | 552 |
 | 未实现 TC 数 | 1 (TC-0189 不可达防御分支 t.Skip) |
-| 测试函数总数 | 690 |
-| 测试子用例总数 (含 table-driven) | 772 |
+| 测试函数总数 | 702 |
+| 测试子用例总数 (含 table-driven) | 783 |
 | 测试包数量 | 23 |
-| ✅ 通过 | **771 / 772** |
+| ✅ 通过 | **782 / 783** |
 | ❌ 失败 | **0** |
 | ⏭️ 跳过 | **1** (TC-0189 — 防御性不可达分支) |
 
@@ -24,29 +24,29 @@
 
 | 测试包 | 状态 | 耗时 |
 | :--- | :--- | :--- |
-| handler/pub | ✅ ok | 3.407s |
-| loaders | ✅ ok | 1.674s |
-| logic/auth | ✅ ok | 7.474s |
-| logic/dept | ✅ ok | 2.951s |
-| logic/member | ✅ ok | 4.107s |
-| logic/perm | ✅ ok | 1.671s |
-| logic/product | ✅ ok | 5.155s |
-| logic/pub | ✅ ok | 6.738s |
-| logic/role | ✅ ok | 4.837s |
-| logic/user | ✅ ok | 6.366s |
-| middleware | ✅ ok | 6.005s |
-| model/dept | ✅ ok | 6.504s |
-| model/perm | ✅ ok | 7.169s |
-| model/product | ✅ ok | 7.660s |
-| model/productmember | ✅ ok | 8.187s |
-| model/role | ✅ ok | 8.775s |
-| model/roleperm | ✅ ok | 8.725s |
-| model/user | ✅ ok | 8.356s |
-| model/userperm | ✅ ok | 8.231s |
-| model/userrole | ✅ ok | 7.799s |
-| response | ✅ ok | 7.298s |
-| server | ✅ ok | 7.854s |
-| util | ✅ ok | 6.660s |
+| handler/pub | ✅ ok | 0.863s |
+| loaders | ✅ ok | 1.867s |
+| logic/auth | ✅ ok | 6.605s |
+| logic/dept | ✅ ok | 2.646s |
+| logic/member | ✅ ok | 3.125s |
+| logic/perm | ✅ ok | 3.747s |
+| logic/product | ✅ ok | 4.761s |
+| logic/pub | ✅ ok | 5.402s |
+| logic/role | ✅ ok | 5.707s |
+| logic/user | ✅ ok | 7.267s |
+| middleware | ✅ ok | 6.614s |
+| model/dept | ✅ ok | 6.251s |
+| model/perm | ✅ ok | 7.042s |
+| model/product | ✅ ok | 7.677s |
+| model/productmember | ✅ ok | 8.504s |
+| model/role | ✅ ok | 9.364s |
+| model/roleperm | ✅ ok | 9.214s |
+| model/user | ✅ ok | 9.403s |
+| model/userperm | ✅ ok | 9.483s |
+| model/userrole | ✅ ok | 7.915s |
+| response | ✅ ok | 8.373s |
+| server | ✅ ok | 9.309s |
+| util | ✅ ok | 8.150s |
 
 ---
 
@@ -348,8 +348,8 @@
 | TC-0193 | 成功(无data) | ✅ pass |
 | TC-0536 | 限流-正常请求(未超限) | ✅ pass |
 | TC-0537 | 限流-超限请求被拒绝(429) | ✅ pass |
-| TC-0538 | 限流-IP从X-Forwarded-For获取 | ✅ pass |
-| TC-0539 | 限流-IP从X-Real-IP获取 | ✅ pass |
+| TC-0538 | 限流-X-Forwarded-For被忽略(M-1修复) | ✅ pass |
+| TC-0539 | 限流-X-Real-IP被忽略(M-1修复) | ✅ pass |
 | TC-0540 | 限流-IP从RemoteAddr获取 | ✅ pass |
 | TC-0541 | 限流-不同IP独立限流 | ✅ pass |
 
@@ -680,7 +680,24 @@
 | TC-0476 | loadMembership-超管自动SUPER_ADMIN | ✅ pass |
 | TC-0477 | loadMembership-非成员MemberType为空 | ✅ pass |
 
-### 2.22 Logic 层访问控制负面测试 (TC-0481 ~ TC-0491)
+### 2.22 审计修复验证 (TC-0542 ~ TC-0553)
+
+| TC编号 | 测试场景 | 测试结果 |
+| :--- | :--- | :--- |
+| TC-0542 | 产品成员被禁用时拒绝登录-HTTP(H-3) | ✅ pass |
+| TC-0543 | 产品被禁用时拒绝登录-HTTP(H-3) | ✅ pass |
+| TC-0544 | 超管不能冻结另一超管(H-2) | ✅ pass |
+| TC-0545 | gRPC VerifyToken冻结用户返回Invalid(H-4) | ✅ pass |
+| TC-0546 | gRPC VerifyToken非成员返回Invalid(H-4) | ✅ pass |
+| TC-0547 | gRPC VerifyToken返回实时数据(H-4) | ✅ pass |
+| TC-0548 | gRPC Login禁用成员被拒绝(H-3) | ✅ pass |
+| TC-0549 | BindRoles非产品成员被拒绝(L-4) | ✅ pass |
+| TC-0550 | SetUserPerms非产品成员被拒绝(L-5) | ✅ pass |
+| TC-0551 | loadPerms跨产品权限隔离(H-1) | ✅ pass |
+| TC-0552 | loadMembership禁用成员MemberType为空(H-3) | ✅ pass |
+| TC-0553 | loadPerms禁用DEV部门无全量权限(M-3) | ✅ pass |
+
+### 2.23 Logic 层访问控制负面测试 (TC-0481 ~ TC-0491)
 
 | TC编号 | 测试场景 | 测试结果 |
 | :--- | :--- | :--- |
@@ -696,7 +713,7 @@
 | TC-0490 | bindRolePerms-非产品管理员拒绝 | ✅ pass |
 | TC-0491 | updateUser-非本人非超管拒绝 | ✅ pass |
 
-### 2.23 Model 层新增方法 (TC-0492 ~ TC-0499)
+### 2.24 Model 层新增方法 (TC-0492 ~ TC-0499)
 
 | TC编号 | 测试场景 | 测试结果 |
 | :--- | :--- | :--- |
@@ -715,16 +732,16 @@
 
 | 指标 | 数值 |
 | :--- | :--- |
-| TC 总数 | 541 |
-| 已实现 | 540 (99.8%) |
+| TC 总数 | 553 |
+| 已实现 | 552 (99.8%) |
 | 跳过 | 1 (TC-0189,防御性不可达分支) |
 | 未实现 | 0 |
-| 测试函数 | 690 |
-| 测试子用例 | 772 |
-| ✅ 通过 | **771** |
+| 测试函数 | 702 |
+| 测试子用例 | 783 |
+| ✅ 通过 | **782** |
 | ❌ 失败 | **0** |
 | ⏭️ 跳过 | **1** (TC-0189) |
-| 通过率 | **100%** (771/771,排除不可达分支) |
+| 通过率 | **100%** (782/782,排除不可达分支) |
 
 ### 3.1 未实现 TC 说明
 
@@ -760,3 +777,56 @@
 ### 4.3 发现的源码 BUG
 
 本次测试未发现源码 BUG。所有测试失败均为测试数据准备不完整导致,源码的新增校验逻辑(产品成员检查、部门存在性验证、权限 ID 验证、产品隔离删除等)均为合理的安全增强。
+
+---
+
+## 五、审计修复验证 — 补充测试记录 (2026-04-17 第二轮)
+
+### 5.1 新增审计修复验证测试用例 (TC-0542 ~ TC-0553)
+
+| TC编号 | 测试文件 | 审计编号 | 说明 |
+| :--- | :--- | :--- | :--- |
+| TC-0542 | loginLogic_test.go | H-3 | 产品成员被禁用时HTTP登录拒绝(403) |
+| TC-0543 | loginLogic_test.go | H-3 | 产品被禁用时HTTP登录拒绝(403) |
+| TC-0544 | updateUserLogic_test.go | H-2 | 超管不能通过updateUser冻结另一超管(403) |
+| TC-0545 | permserver_test.go | H-4 | gRPC VerifyToken冻结用户返回Valid=false |
+| TC-0546 | permserver_test.go | H-4 | gRPC VerifyToken非成员返回Valid=false |
+| TC-0547 | permserver_test.go | H-4 | gRPC VerifyToken返回实时MemberType/Perms |
+| TC-0548 | permserver_test.go | H-3 | gRPC Login禁用成员返回PermissionDenied |
+| TC-0549 | bindRolesLogic_test.go | L-4 | BindRoles目标用户非产品成员返回400 |
+| TC-0550 | setUserPermsLogic_test.go | L-5 | SetUserPerms目标用户非产品成员返回400 |
+| TC-0551 | userDetailsLoader_test.go | H-1 | loadPerms用户ALLOW权限跨产品隔离验证 |
+| TC-0552 | userDetailsLoader_test.go | H-3 | loadMembership禁用成员MemberType为空 |
+| TC-0553 | userDetailsLoader_test.go | M-3 | loadPerms禁用DEV部门不享有全量权限 |
+
+### 5.2 修复因源码变更而失败的已有测试
+
+| 测试用例 | 修复内容 | 原因 |
+| :--- | :--- | :--- |
+| TC-0538 (ratelimitMiddleware_test.go) | 重命名为 `TestRateLimit_XForwardedForIgnored`,验证XFF被忽略 | M-1修复:IP获取仅用RemoteAddr,不再信任XFF |
+| TC-0539 (ratelimitMiddleware_test.go) | 重命名为 `TestRateLimit_XRealIPIgnored`,验证XRI被忽略 | M-1修复:同上 |
+| TC-0541 (ratelimitMiddleware_test.go) | 改用不同RemoteAddr模拟不同客户端 | M-1修复:RemoteAddr是唯一IP来源 |
+| TC-0170 (permserver_test.go) | 断言消息更新为 "超级管理员不允许通过产品端登录,请使用管理后台" | 源码修改了错误提示文案 |
+| TC-0178 (permserver_test.go) | 改为创建真实用户/产品/成员数据 | H-4修复:VerifyToken现在实时查DB |
+| TC-0136 (bindRolesLogic_mock_test.go) | 新增 `MockSysProductMemberModel` mock | L-4修复:BindRoles新增成员校验 |
+| TC-0133/0135/0133b/0515/0516/0517 (bindRolesLogic_test.go) | 新增 `insertTestMember` 产品成员创建 | L-4修复:BindRoles前置成员校验 |
+| TC-0137/0139/0140/0518/0519/0520 (setUserPermsLogic_test.go) | 新增 `insertTestMember` 产品成员创建 | L-5修复:SetUserPerms前置成员校验 |
+| mock_userperm_model.go | 新增 `FindPermIdsByUserIdAndEffectForProduct` mock方法 | H-1修复:新增带产品过滤的权限查询接口 |
+
+### 5.3 审计修复覆盖矩阵
+
+| 审计编号 | 严重级别 | 修复描述 | 验证TC | 验证状态 |
+| :--- | :--- | :--- | :--- | :--- |
+| H-1 | High | loadPerms跨产品权限泄漏 | TC-0551 | ✅ 已验证 |
+| H-2 | High | UpdateUser超管状态保护 | TC-0544 | ✅ 已验证 |
+| H-3 | High | 禁用成员仍可登录 | TC-0542, TC-0543, TC-0548, TC-0552 | ✅ 已验证 |
+| H-4 | High | gRPC VerifyToken不检查实时状态 | TC-0545, TC-0546, TC-0547 | ✅ 已验证 |
+| H-5 | High | gRPC Login无限流 | TC-0548 (含限流路径验证) | ✅ 已验证 |
+| M-1 | Medium | 限流IP可伪造 | TC-0538, TC-0539 | ✅ 已验证 |
+| M-3 | Medium | 部门禁用不影响权限 | TC-0553 | ✅ 已验证 |
+| L-4 | Low | BindRoles不检查产品成员 | TC-0549 | ✅ 已验证 |
+| L-5 | Low | SetUserPerms不检查产品成员 | TC-0550 | ✅ 已验证 |
+
+### 5.4 发现的源码 BUG
+
+本轮审计修复验证测试未发现新的源码 BUG。所有审计报告中标识的漏洞均已正确修复,测试验证通过。