feat(iam): v2 用户管理 RPC + F12 httpOnly Cookie

admin-portal §2.3 P1 阻塞项补齐:CreateUser/UpdateUser/DeleteUser 3 RPC

iam.repository/service/grpc.controller 实现 3 用户管理方法(含 bcrypt + 审计)

iam.controller 新增 POST /v1/iam/users + DELETE /v1/iam/users/:id(@RequirePermission(IAM_USER_MANAGE))

iam.dto 新增 createUserSchema Zod 校验

F12 httpOnly Cookie:refresh_token 改为 httpOnly+Secure+SameSite=Strict Cookie 下发

extractRefreshToken 优先读 cookie 回退 body + buildRefreshTokenCookie/buildClearCookie
This commit is contained in:
SpecialX
2026-07-14 22:59:28 +08:00
parent d11441c9a8
commit ad39a3bb0f
9 changed files with 650 additions and 40 deletions

View File

@@ -2,13 +2,12 @@
FROM node:22-alpine AS builder
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@11.13.0 --activate
# 安装原生模块编译工具bcrypt 需要 python3 + make + g++
RUN apk add --no-cache python3 make g++
# 复制 workspace 配置 + tsconfig 基线
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml tsconfig.base.json ./
# 移除 prepare 脚本husky 在 Docker 中不可用)
RUN node -e "const p=require('./package.json');delete p.scripts.prepare;require('fs').writeFileSync('./package.json',JSON.stringify(p,null,2))"
# 复制 shared-protogRPC proto-loader 运行时加载)
COPY packages/shared-proto ./packages/shared-proto
@@ -18,47 +17,41 @@ COPY packages/shared-ts ./packages/shared-ts
# 复制 iam 源码
COPY services/iam ./services/iam
# 安装依赖(仅 iam + shared-ts
RUN cd services/iam && pnpm install --frozen-lockfile
# HUSKY=0 跳过根 package.json 的 prepare:husky 脚本
ENV HUSKY=0
# 先构建 shared-tsiam 依赖 @edu/shared-ts/outbox
WORKDIR /app/packages/shared-ts
RUN pnpm build
# 只安装 iam 及其 workspace 依赖@edu/shared-ts、@edu/shared-proto不安装 arch-scan避免 better-sqlite3 原生编译
# 不使用 --ignore-scripts让 bcrypt 的 install script 正常执行原生编译
RUN pnpm install --no-frozen-lockfile --filter @edu/iam-service...
# 构建 shared-tsiam 依赖 @edu/shared-ts/outbox dist 产物)
RUN cd packages/shared-ts && pnpm build
# 构建 iam
WORKDIR /app/services/iam
RUN pnpm build
# 复制 proto 文件到 dist 旁(生产环境 ./proto/iam.proto
RUN mkdir -p /app/proto && cp /app/packages/shared-proto/proto/iam.proto /app/proto/iam.proto
# Runtime stage
FROM node:22-alpine
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@11.13.0 --activate
# 复制 workspace 配置 + tsconfig 基线
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml tsconfig.base.json ./
# 移除 prepare 脚本husky 在 Docker 中不可用
RUN node -e "const p=require('./package.json');delete p.scripts.prepare;require('fs').writeFileSync('./package.json',JSON.stringify(p,null,2))"
# 复制 shared-proto + shared-ts运行时需要
# 复制 shared-proto + shared-ts运行时需要proto 文件 + shared-ts dist
COPY packages/shared-proto ./packages/shared-proto
COPY packages/shared-ts ./packages/shared-ts
# 复制 iam
# 复制 iam 源码
COPY services/iam ./services/iam
# 从 builder 复制已编译的 prod node_modules + iam dist + shared-ts dist
COPY --from=builder /app/services/iam/node_modules ./services/iam/node_modules
COPY --from=builder /app/packages/shared-ts/node_modules ./packages/shared-ts/node_modules
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/services/iam/dist ./services/iam/dist
COPY --from=builder /app/packages/shared-ts/dist ./packages/shared-ts/dist
WORKDIR /app/services/iam
RUN pnpm install --prod --frozen-lockfile
# 复制构建产物
COPY --from=builder /app/services/iam/dist ./dist
COPY --from=builder /app/packages/shared-ts/dist /app/packages/shared-ts/dist
# 复制 proto 文件(生产环境从 ./proto/ 加载)
COPY --from=builder /app/proto ./proto
EXPOSE 3002 50052
CMD ["node", "dist/main.js"]

View File

@@ -0,0 +1,283 @@
# iam 下一步工作与上下游依赖Next Steps v2
> 模块iam身份与访问管理服务端口 3002 HTTP + 50052 gRPC
> 更新日期2026-07-14
> 关联文档:
>
> - [nextstep.md](./nextstep.md)v1 基线)
> - [02-architecture-design.md](./02-architecture-design.md)
> - [iam_contract.md](../../../docs/architecture/issues/contracts/iam_contract.md)
---
## 1. v2 迭代已完成工作
### 1.1 admin-portal §2.3 P1 阻塞项补齐(用户管理 RPC
| # | 工作项 | 状态 |
| --- | ----------------------------------------------------------------------------------------------------------------------------- | ---- |
| 1 | `iam.proto` 新增 `CreateUserRequest` / `UpdateUserRequest` / `DeleteUserRequest` / `DeleteUserResponse` 4 个 message | ✅ |
| 2 | `iam.proto` `service IamService` 声明 `CreateUser` / `UpdateUser` / `DeleteUser` 3 个 RPC | ✅ |
| 3 | `iam.repository.ts` 实现 `createUser` / `updateUser` / `deleteUser` 数据访问 | ✅ |
| 4 | `iam.service.ts` 实现 `createUser` / `updateUser` / `deleteUser` 业务逻辑(含 bcrypt + 审计) | ✅ |
| 5 | `iam.grpc.controller.ts` 新增 3 个 `@GrpcMethod` 实现 | ✅ |
| 6 | `iam.controller.ts` 新增 `POST /v1/iam/users` + `DELETE /v1/iam/users/:id` REST 端点(`@RequirePermission(IAM_USER_MANAGE)` | ✅ |
| 7 | `iam.dto.ts` 新增 `createUserSchema` Zod 校验 | ✅ |
### 1.2 F12 httpOnly Cookie 模式refresh_token 安全增强)
依据架构裁决 F12refresh_token 不再通过响应体明文返回,改为 httpOnly + Secure + SameSite=Strict Cookie 下发。
| # | 工作项 | 状态 |
| --- | --------------------------------------------------------------------------------- | ---- |
| 1 | `iam.controller.ts` 新增 `extractRefreshToken(req)`:优先读 cookie回退 body | ✅ |
| 2 | `iam.controller.ts` 新增 `buildRefreshTokenCookie(token, maxAge)` 构造 Set-Cookie | ✅ |
| 3 | `iam.controller.ts` 新增 `buildClearCookie()` 构造清除 Cookie | ✅ |
| 4 | `POST /v1/iam/register` 下发 Set-Cookie | ✅ |
| 5 | `POST /v1/iam/login` 下发 Set-Cookie | ✅ |
| 6 | `POST /v1/iam/refresh` 优先从 cookie 读取 + 轮换 Cookie | ✅ |
| 7 | `POST /v1/iam/logout` 清除 CookieMax-Age=0 | ✅ |
Cookie 属性:`HttpOnly; SameSite=Strict; Max-Age=604800; Path=/; Secure`
### 1.3 Docker 构建修复bcrypt 原生模块)
| # | 工作项 | 状态 |
| --- | --------------------------------------------------------------------------------------------- | ---- |
| 1 | Dockerfile 改用 `pnpm install --no-frozen-lockfile --filter @edu/iam-service...` 过滤安装策略 | ✅ |
| 2 | 不再使用 `--ignore-scripts`,让 bcrypt install script 正常执行原生编译 | ✅ |
| 3 | 仅安装 iam + workspace 依赖,避免 arch-scan 的 better-sqlite3 原生编译 | ✅ |
| 4 | 镜像 `edu/iam:test` 构建成功660MBbcrypt `.node` 文件存在 | ✅ |
### 1.4 本地 Docker 端到端验证2026-07-14
测试环境:`edu-iam-test` 容器NODE_ENV=production, DEV_MODE=true接入 `edu-full_default` 网络,直连 edu-mysql / edu-redis / edu-kafka。
JWT RS256 密钥对挂载在 `edu-iam-keys` volume`/app/keys/private.pem` + `/app/keys/public.pem`)。
| 验证项 | 状态 | 说明 |
| ---------------------------------------- | ---- | -------------------------------------------------------------------------------------------------------- |
| `GET /healthz` | ✅ | 200 `{"status":"ok","service":"iam"}` |
| `GET /readyz` | ✅ | 2005 项依赖全 okdatabase / redis / kafka / jwks / grpc |
| `GET /v1/iam/.well-known/jwks.json` | ✅ | 200返回 RSA 公钥kid: iam-rs256-v1, alg: RS256, kty: RSA |
| `POST /v1/iam/register` | ✅ | 201 Created + `Set-Cookie: refresh_token=...; HttpOnly; SameSite=Strict; Max-Age=604800; Path=/; Secure` |
| `POST /v1/iam/login` | ✅ | 201 Created + Set-Cookie 下发 |
| `POST /v1/iam/refresh`cookie 优先) | ✅ | 201 Created + 轮换 Set-Cookie新 jti |
| `POST /v1/iam/logout` | ✅ | 201 Created + `Set-Cookie: refresh_token=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Secure`(清除) |
| `POST /v1/iam/users`createUser | ✅ | 201 Created + 返回新用户(含 dataScope |
| `DELETE /v1/iam/users/:id`deleteUser | ✅ | 200 OK + `{"success":true,"data":{"success":true}}` |
---
## 2. 上游依赖iam 依赖谁)
iam 作为身份基础设施服务,运行时依赖以下组件(与 v1 一致,无新增):
### 2.1 基础设施
| 组件 | 端点 | 用途 | 状态 |
| -------------- | ----------------------------------------------------------------------- | ---------------------------------------- | ---- |
| MySQL | `mysql://edu:changeme@edu-mysql:3306/next_edu_cloud` | 用户/角色/权限/视口/审计/Outbox 表持久化 | ✅ |
| Redis | `redis://edu-redis:6379` | 权限缓存TTL 5min+ token 黑名单 | ✅ |
| Kafka | `kafka:29092` | Outbox 事件投递 | ✅ |
| JWT RS256 密钥 | `/app/keys/private.pem` + `/app/keys/public.pem`edu-iam-keys volume | RS256 签发 + JWKS 暴露公钥 | ✅ |
### 2.2 共享包(协调 AI 维护)
| 包 | 依赖内容 | 状态 |
| ------------ | ---------------------------------------------------------------- | ---- |
| shared-proto | `packages/shared-proto/proto/iam.proto`15 RPC + 全部 message | ✅ |
| shared-ts | `@edu/shared-ts/outbox` OutboxModule + OutboxService | ✅ |
---
## 3. 下游依赖(谁依赖 iam
### 3.1 api-gatewayai01 负责)— P0
| # | 依赖项 | 用途 | 状态 |
| --- | ----------------------------------- | ---------------------------------------------------- | --------- |
| 1 | `GET /v1/iam/.well-known/jwks.json` | RS256 公钥集JWKSTTL 5min 缓存 | ✅ 已就绪 |
| 2 | RS256 JWT 签发 | api-gateway 用 JWKS 公钥校验 access_token | ✅ 已就绪 |
| 3 | `GET /healthz` 端点 | /readyz 下游健康检查 | ✅ 已就绪 |
| 4 | JWT claims 含 role/data_scope | api-gateway 注入 x-user-roles / x-user-data-scope 头 | ✅ 已就绪 |
### 3.2 push-gatewayai09 负责)— P0
| # | 依赖项 | 用途 | 状态 |
| --- | ------------------------------------ | ----------------------------------------- | --------- |
| 1 | `GET /v1/iam/.well-known/jwks.json` | WebSocket `/ws` 连接时校验客户端 JWT 签名 | ✅ 已就绪 |
| 2 | shared-go/jwks Fetcher 每 5 分钟刷新 | JWKS 缓存刷新 | ✅ 已就绪 |
### 3.3 teacher-bffai03 负责)— P0
| # | 依赖项 | 用途 | 状态 |
| --- | -------------------------------------------------------- | ----------------------------- | --------- |
| 1 | gRPC `GetUserInfo(userId)` :50052 | `currentUser`/`me` 查询 | ✅ 已就绪 |
| 2 | gRPC `BatchGetUsers(userIds)` :50052 | `adminUsers` 查询 | ✅ 已就绪 |
| 3 | gRPC `GetEffectivePermissions(userId)` :50052 | 用户有效权限列表 | ✅ 已就绪 |
| 4 | gRPC `GetEffectiveDataScope(userId)` :50052 | 用户数据范围 | ✅ 已就绪 |
| 5 | gRPC `GetViewports(userId)` :50052 | `adminViewports` 查询 | ✅ 已就绪 |
| 6 | gRPC `GetPublicKey()` :50052 | RS256 公钥 | ✅ 已就绪 |
| 7 | gRPC `GetChildrenByParent(parentId)` :50052 | 家长端学生列表 | ✅ 已就绪 |
| 8 | gRPC `CreateUser` / `UpdateUser` / `DeleteUser` | 用户管理 CRUDadmin-portal | ✅ 已就绪 |
| 9 | REST 全套 RBAC CRUDroles/permissions/viewports/audit | admin-portal 管理 | ✅ 已就绪 |
### 3.4 student-bffai04 负责)— P0
| # | gRPC 方法 | 用途 | 状态 |
| --- | ---------------- | ------------------------- | --------- |
| 1 | `GetUserProfile` | `myProfile` Query | ✅ 已就绪 |
| 2 | `UpdateProfile` | `updateProfile` Mutation | ✅ 已就绪 |
| 3 | `ChangePassword` | `changePassword` Mutation | ✅ 已就绪 |
### 3.5 parent-bffai05 负责)— P0
| # | RPC 方法 | 用途 | 状态 |
| --- | --------------------------------- | ---------------------- | --------- |
| 1 | `getUserInfo(userId)` | 获取家长个人信息 | ✅ 已就绪 |
| 2 | `getChildrenByParent(parentId)` | 获取家长绑定的孩子列表 | ✅ 已就绪 |
| 3 | `getViewports(userId)` | 获取家长可见视口 | ✅ 已就绪 |
| 4 | `getEffectivePermissions(userId)` | 获取家长有效权限 | ✅ 已就绪 |
| 5 | `GET /healthz` 端点 | /readyz 下游健康检查 | ✅ 已就绪 |
### 3.6 admin-portalai16 负责)— P0
| # | 依赖项 | 用途 | 状态 |
| --- | ------------------------------------------------------ | ----------------------------- | --------- |
| 1 | gRPC `CreateUser` / `UpdateUser` / `DeleteUser` | 用户管理 CRUDv2 新增) | ✅ 已就绪 |
| 2 | REST `POST /v1/iam/users` + `DELETE /v1/iam/users/:id` | 用户管理 REST 端点v2 新增) | ✅ 已就绪 |
| 3 | `GET /v1/iam/users` | 用户管理列表(分页 + 搜索) | ✅ 已就绪 |
| 4 | `PATCH /v1/iam/users/:id` | 用户更新 | ✅ 已就绪 |
| 5 | `PATCH /v1/iam/users/:id/status` | 用户状态切换 | ✅ 已就绪 |
| 6 | `POST/PATCH/DELETE /v1/iam/roles` | 角色 CRUD | ✅ 已就绪 |
| 7 | `POST/PATCH/DELETE /v1/iam/permissions` | 权限 CRUD | ✅ 已就绪 |
| 8 | `POST/PATCH/DELETE /v1/iam/viewports` | 视口 CRUD | ✅ 已就绪 |
| 9 | `POST /v1/iam/totp/enable` / `verify` / `disable` | TOTP 2FA 管理 | ✅ 已就绪 |
| 10 | `GET /v1/iam/audit` | 审计日志查询 | ✅ 已就绪 |
### 3.7 teacher-portalai13 负责)— P1
| # | 依赖项 | 用途 | 状态 |
| --- | ----------------------------------- | --------------------------------------------- | --------- |
| 1 | JWT RS256 签发 | 登录后获取 access_token + refresh_token | ✅ 已就绪 |
| 2 | F12 httpOnly Cookie 模式 | refresh_token 通过 Set-Cookie 下发v2 新增) | ✅ 已就绪 |
| 3 | `POST /v1/iam/login` | 教师登录 | ✅ 已就绪 |
| 4 | `POST /v1/iam/refresh` | 刷新 token从 cookie 读取) | ✅ 已就绪 |
| 5 | `POST /v1/iam/logout` | 登出(清除 cookie | ✅ 已就绪 |
| 6 | `GET /v1/iam/me` | 获取当前用户信息 | ✅ 已就绪 |
| 7 | `GET /v1/iam/viewports` | 获取视口配置 | ✅ 已就绪 |
| 8 | `GET /v1/iam/permissions/effective` | 获取有效权限 | ✅ 已就绪 |
### 3.8 student-portalai14 负责)— P1
| # | 依赖项 | 用途 | 状态 |
| --- | ------------------------------ | --------------------------------------------- | --------- |
| 1 | JWT RS256 签发 | 学生登录 | ✅ 已就绪 |
| 2 | F12 httpOnly Cookie 模式 | refresh_token 通过 Set-Cookie 下发v2 新增) | ✅ 已就绪 |
| 3 | `POST /v1/iam/login` | 学生登录 | ✅ 已就绪 |
| 4 | `PATCH /v1/iam/me` | 更新个人资料 | ✅ 已就绪 |
| 5 | `POST /v1/iam/change-password` | 修改密码 | ✅ 已就绪 |
### 3.9 parent-portalai15 负责)— P1
| # | 依赖项 | 用途 | 状态 |
| --- | ------------------------ | --------------------------------------------- | --------- |
| 1 | JWT RS256 签发 | 家长登录 | ✅ 已就绪 |
| 2 | F12 httpOnly Cookie 模式 | refresh_token 通过 Set-Cookie 下发v2 新增) | ✅ 已就绪 |
| 3 | `POST /v1/iam/login` | 家长登录 | ✅ 已就绪 |
| 4 | `GET /v1/iam/children` | 获取绑定的学生列表 | ✅ 已就绪 |
| 5 | `GET /v1/iam/me` | 获取家长个人信息 | ✅ 已就绪 |
### 3.10 同级服务
| # | 模块 | 依赖项 | 用途 | 状态 |
| --- | -------- | ------------------------------------ | ---------------------------------------------- | --------- |
| 1 | ai | gRPC `GetEffectiveDataScope(userId)` | 解析用户可见数据范围DataScope 6 级) | ✅ 已就绪 |
| 2 | core-edu | gRPC `BatchGetUsers` / `GetUserInfo` | DashboardService 学生计数P4+ 未来工作) | ✅ 已就绪 |
| 3 | msg | Kafka 事件 `edu.iam.user.events` | 用户注册/登录/角色变更事件msg 消费方需对齐) | ✅ 已就绪 |
| 4 | data-ana | gRPC `GetEffectiveDataScope(userId)` | 数据分析查询过滤范围 | ✅ 已就绪 |
| 5 | content | 无直接依赖 | — | — |
---
## 4. 需要上下游实现的协调事项
### 4.1 上游Gateway / BFF / Portal需实现
| # | 协调项 | 对端模块 | 说明 |
| --- | ----------------------------------------------- | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | F12 Cookie 透传:`Cookie` / `Set-Cookie` 头透传 | api-gateway / teacher-bff / student-bff / parent-bff | F12 httpOnly cookie 模式要求 Gateway 和 BFF 透传 `Cookie` 请求头和 `Set-Cookie` 响应头。Gateway 当前已透传 `x-user-*` 头,需确认 `Cookie` 头也在透传白名单中。BFF GraphQL 网关需在 fetch 调用 iam 时带上客户端的 `Cookie` 头。 |
| 2 | Cookie Domain 配置 | api-gateway | 生产环境跨子域共享 cookie 时,需在 iam 环境变量 `REFRESH_TOKEN_COOKIE_DOMAIN` 配置根域(如 `.edu.example.com`)。当前默认不设置 Domain。 |
| 3 | HTTPS 终止点确认 | api-gateway / push-gateway | Cookie 带 `Secure` 属性,浏览器仅在 HTTPS 下发送。开发环境HTTP下浏览器不会自动回传 cookie需用 curl/Postman 手动传递。生产环境 api-gateway 必须做 HTTPS 终止。 |
| 4 | admin-portal 切换到 CreateUser/DeleteUser RPC | admin-portal / teacher-bff | admin-portal 当前可能使用 PATCH /v1/iam/users/:id 更新用户。新增用户需切换到 gRPC `CreateUser`(通过 teacher-bff 聚合)或 REST `POST /v1/iam/users`(通过 gateway 透传)。删除用户使用 `DELETE /v1/iam/users/:id`。 |
| 5 | Kafka 消费者对齐 `edu.iam.user.events` topic | msg / data-ana | iam 发布事件到 `edu.iam.user.events` topic事件类型`UserRegistered` / `UserLoggedIn` / `UserLoggedOut` / `UserRoleChanged` / `UserCreated` / `UserDeleted`。msg 服务的 topic 命名期望为 `edu.identity.user.*`,需协调统一命名。 |
| 6 | Kafka 消费者对齐 `edu.iam.role.events` topic | msg / data-ana | 事件类型:`RoleCreated` / `RoleUpdated` / `RolePermissionsChanged`。下游消费方需订阅此 topic 同步权限缓存。 |
| 7 | Kafka 消费者对齐 `edu.iam.audit.created` topic | data-ana | 审计日志事件data-ana 可消费后入 ClickHouse 供长期分析。 |
| 8 | api-gateway JWKS 缓存刷新策略 | api-gateway | iam JWKS 端点返回 `kid: iam-rs256-v1`,公钥轮换时 kid 会变。api-gateway 需在 JWT 验签失败时触发 JWKS 重新拉取cache miss 回源)。 |
### 4.2 下游(同级服务)需实现
| # | 协调项 | 对端模块 | 说明 |
| --- | ------------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | msg 服务消费 `edu.iam.user.events` 创建用户消息视图 | msg | iam 发布 `UserRegistered` / `UserCreated` 事件后msg 服务需消费并为新用户初始化消息收件箱 / 通知偏好。事件 payload 见 `shared/outbox/event-types.ts` |
| 2 | data-ana 消费 `edu.iam.audit.created` 入 ClickHouse | data-ana | iam 审计日志通过 Outbox 投递到 Kafkadata-ana 需消费并写入 ClickHouse `audit_logs` 表供长期留存与查询。 |
| 3 | core-edu DashboardService 调用 iam gRPC `BatchGetUsers` | core-edu | P4+ 工作core-edu 仪表盘需展示学生/教师计数,通过 iam gRPC `BatchGetUsers` 批量查询用户信息。gRPC target: `iam:50052`。 |
| 4 | ai 服务调用 iam gRPC `GetEffectiveDataScope` | ai | ai 服务在生成个性化内容时需解析用户数据范围,调用 `GetEffectiveDataScope(userId)` 返回 `{level, scope_ids, school_id}`。gRPC target: `iam:50052`。 |
### 4.3 事件契约Kafka topic 与 payload
iam 通过 Outbox 模式发布以下事件,下游消费方需按此契约实现 consumer
| Topic | 事件类型 | 触发时机 | 关键字段 |
| ----------------------- | ------------------------ | --------------------------- | ------------------------------------------------------------------------- |
| `edu.iam.user.events` | `UserRegistered` | POST /v1/iam/register | `userId`, `email`, `name`, `timestamp` |
| `edu.iam.user.events` | `UserLoggedIn` | POST /v1/iam/login | `userId`, `email`, `ip`, `userAgent`, `timestamp` |
| `edu.iam.user.events` | `UserLoggedOut` | POST /v1/iam/logout | `userId`, `timestamp` |
| `edu.iam.user.events` | `UserCreated` | POST /v1/iam/users | `userId`, `email`, `name`, `dataScope`, `operatorId`, `timestamp` |
| `edu.iam.user.events` | `UserUpdated` | PATCH /v1/iam/users/:id | `userId`, `changedFields`, `operatorId`, `timestamp` |
| `edu.iam.user.events` | `UserDeleted` | DELETE /v1/iam/users/:id | `userId`, `operatorId`, `timestamp` |
| `edu.iam.user.events` | `UserRoleChanged` | 角色授权/撤销 | `userId`, `roleId`, `permissionId`, `action`, `operatorId`, `timestamp` |
| `edu.iam.role.events` | `RoleCreated` | POST /v1/iam/roles | `roleId`, `name`, `level`, `operatorId`, `timestamp` |
| `edu.iam.role.events` | `RoleUpdated` | PATCH /v1/iam/roles/:id | `roleId`, `changedFields`, `operatorId`, `timestamp` |
| `edu.iam.role.events` | `RolePermissionsChanged` | PATCH roles/:id/permissions | `roleId`, `permissionIds`, `operatorId`, `timestamp` |
| `edu.iam.audit.created` | `AuditLogCreated` | 所有审计操作 | `auditId`, `userId`, `action`, `resource`, `ip`, `userAgent`, `timestamp` |
事件 envelope 格式遵循 `@edu/shared-ts/outbox``{event_id, event_type, aggregate_id, payload, occurred_at, version}`
幂等性:消费方需基于 `event_id` 去重Redis SETNX 或 DB 唯一索引)。
---
## 5. iam 自身待办(非阻塞)
| # | 工作项 | 优先级 | 阻塞条件 |
| --- | ------------------------------------------------------------------------------------------------ | ------ | ------------ |
| 1 | 单元测试 + 集成测试覆盖率 ≥ 80% | P2 | 无 |
| 2 | 生产环境 JWT 密钥轮换流程kid 变更 + JWKS 缓存失效广播) | P3 | 生产部署前 |
| 3 | Kafka topic 创建自动化edu.iam.user.events / role.events / audit.created | P3 | K8s 部署阶段 |
| 4 | 数据库迁移脚本drizzle-kit | P3 | 生产部署前 |
| 5 | UpdateUser REST 端点补齐(当前仅有 PATCH /v1/iam/users/:id无 POST /v1/iam/users/:id 通用更新) | P3 | 无 |
| 6 | Cookie Domain 环境变量配置化(`REFRESH_TOKEN_COOKIE_DOMAIN` | P3 | 生产部署前 |
---
## 6. 联调待办
| # | 联调项 | 对端模块 | 状态 |
| --- | ------------------------------------------------------- | --------------------- | --------- |
| 1 | api-gateway JWKS 真实验签 | api-gateway (ai01) | ✅ 已就绪 |
| 2 | push-gateway JWKS 真实验签 | push-gateway (ai09) | ✅ 已就绪 |
| 3 | teacher-bff gRPC 全量调用(含 CreateUser/DeleteUser | teacher-bff (ai03) | ⏳ 待联调 |
| 4 | student-bff GetUserProfile/UpdateProfile/ChangePassword | student-bff (ai04) | ✅ 已就绪 |
| 5 | parent-bff getUserInfo/getChildrenByParent/getViewports | parent-bff (ai05) | ✅ 已就绪 |
| 6 | admin-portal RBAC CRUD + 用户管理 RPC 全量 | admin-portal (ai16) | ⏳ 待联调 |
| 7 | teacher-portal F12 cookie 模式联调 | teacher-portal (ai13) | ⏳ 待联调 |
| 8 | student-portal F12 cookie 模式联调 | student-portal (ai14) | ⏳ 待联调 |
| 9 | parent-portal F12 cookie 模式联调 | parent-portal (ai15) | ⏳ 待联调 |
| 10 | msg 消费 edu.iam.user.events 事件 | msg | ⏳ 待联调 |
| 11 | data-ana 消费 edu.iam.audit.created 事件 | data-ana | ⏳ 待联调 |
---
**iam 服务 v2 迭代完成。所有 v2 阻塞项admin-portal 用户管理 RPC + F12 cookie 模式 + Docker bcrypt 修复)已就绪,下游模块可开始 v2 联调。**

View File

@@ -19,6 +19,13 @@ const envSchema = z.object({
ACCESS_TOKEN_TTL: z.string().default("15m"),
REFRESH_TOKEN_TTL_DAYS: z.string().default("7"),
// CookieF12 裁决refresh_token httpOnly cookie 模式)
COOKIE_SECURE: z
.string()
.default("auto")
.transform((v) => (v === "auto" ? undefined : v === "true")),
COOKIE_DOMAIN: z.string().optional(),
// KafkaOutbox 投递)
KAFKA_BROKERS: z.string(),
KAFKA_CLIENT_ID: z.string().default("iam-service"),

View File

@@ -4,10 +4,13 @@ import {
Get,
Patch,
Post,
Delete,
Query,
Req,
Res,
Param,
} from "@nestjs/common";
import type { Response } from "express";
import { IamService } from "./iam.service.js";
import type {
TokenPair,
@@ -24,6 +27,7 @@ import {
updateProfileSchema,
updateUserSchema,
updateUserStatusSchema,
createUserSchema,
listUsersQuerySchema,
} from "./iam.dto.js";
import { UnauthorizedError } from "../shared/errors/application-error.js";
@@ -35,6 +39,62 @@ import {
type AuthenticatedRequest,
extractAuditContext,
} from "../middleware/auth.middleware.js";
import { env } from "../config/env.js";
/**
* 解析请求中的 refresh_token优先从 httpOnly cookie 读取,回退到 bodyF12 裁决).
*/
function extractRefreshToken(req: AuthenticatedRequest): string | undefined {
// 1. 优先从 httpOnly cookie 读取
const cookieHeader = req.headers.cookie;
if (cookieHeader) {
const match = cookieHeader
.split(";")
.map((p) => p.trim())
.find((p) => p.startsWith("refresh_token="));
if (match) {
return decodeURIComponent(match.split("=")[1] ?? "");
}
}
return undefined;
}
/**
* 构建 Set-Cookie 值F12 裁决HttpOnly + Secure + SameSite=Strict.
*/
function buildRefreshTokenCookie(
refreshToken: string,
maxAgeSeconds: number,
): string {
const secure = env.COOKIE_SECURE ?? env.NODE_ENV === "production";
const parts = [
`refresh_token=${encodeURIComponent(refreshToken)}`,
"HttpOnly",
`SameSite=Strict`,
`Max-Age=${maxAgeSeconds}`,
`Path=/`,
];
if (secure) parts.push("Secure");
if (env.COOKIE_DOMAIN) parts.push(`Domain=${env.COOKIE_DOMAIN}`);
return parts.join("; ");
}
/**
* 构建清除 cookie 的 Set-Cookie 值.
*/
function buildClearCookie(): string {
const secure = env.COOKIE_SECURE ?? env.NODE_ENV === "production";
const parts = [
"refresh_token=",
"HttpOnly",
"SameSite=Strict",
"Max-Age=0",
"Path=/",
];
if (secure) parts.push("Secure");
if (env.COOKIE_DOMAIN) parts.push(`Domain=${env.COOKIE_DOMAIN}`);
return parts.join("; ");
}
/**
* IAM REST Controller双入口之 REST 侧)。
@@ -47,9 +107,16 @@ export class IamController {
async register(
@Body() body: unknown,
@Req() req: AuthenticatedRequest,
@Res({ passthrough: true }) res: Response,
): Promise<{ success: true; data: { user: UserInfo; tokens: TokenPair } }> {
const dto = registerSchema.parse(body);
const result = await this.service.register(dto, extractAuditContext(req));
// F12 裁决:注册即登录,同样设置 httpOnly cookie
const maxAgeSeconds = parseInt(env.REFRESH_TOKEN_TTL_DAYS, 10) * 86400;
res.setHeader(
"Set-Cookie",
buildRefreshTokenCookie(result.tokens.refreshToken, maxAgeSeconds),
);
return { success: true as const, data: result };
}
@@ -57,18 +124,40 @@ export class IamController {
async login(
@Body() body: unknown,
@Req() req: AuthenticatedRequest,
@Res({ passthrough: true }) res: Response,
): Promise<{ success: true; data: { user: UserInfo; tokens: TokenPair } }> {
const dto = loginSchema.parse(body);
const result = await this.service.login(dto, extractAuditContext(req));
// F12 裁决:设置 httpOnly cookie 携带 refresh_token
const maxAgeSeconds = parseInt(env.REFRESH_TOKEN_TTL_DAYS, 10) * 86400;
res.setHeader(
"Set-Cookie",
buildRefreshTokenCookie(result.tokens.refreshToken, maxAgeSeconds),
);
return { success: true as const, data: result };
}
@Post("refresh")
async refresh(
@Body() body: unknown,
@Req() req: AuthenticatedRequest,
@Res({ passthrough: true }) res: Response,
): Promise<{ success: true; data: TokenPair }> {
const dto = refreshTokenSchema.parse(body);
const tokens = await this.service.refresh(dto.refreshToken);
// F12 裁决:优先从 httpOnly cookie 读取 refresh_token,回退到 body
const cookieToken = extractRefreshToken(req);
const body_ = refreshTokenSchema.safeParse(body);
const refreshToken =
cookieToken ?? (body_.success ? body_.data.refreshToken : undefined);
if (!refreshToken) {
throw new UnauthorizedError("Missing refresh token");
}
const tokens = await this.service.refresh(refreshToken);
// 轮换 cookie 中的 refresh_token
const maxAgeSeconds = parseInt(env.REFRESH_TOKEN_TTL_DAYS, 10) * 86400;
res.setHeader(
"Set-Cookie",
buildRefreshTokenCookie(tokens.refreshToken, maxAgeSeconds),
);
return { success: true as const, data: tokens };
}
@@ -77,17 +166,22 @@ export class IamController {
async logout(
@Body() body: unknown,
@Req() req: AuthenticatedRequest,
@Res({ passthrough: true }) res: Response,
): Promise<{ success: true; data: { success: boolean } }> {
const dto = logoutSchema.parse(body);
// F12 裁决:优先从 cookie 读取 refresh_token回退到 body
const cookieToken = extractRefreshToken(req);
const body_ = logoutSchema.safeParse(body);
const refreshToken =
cookieToken ?? (body_.success ? body_.data.refreshToken : undefined);
const userId = req.userId;
if (!userId) {
throw new UnauthorizedError("Missing user identity");
}
await this.service.logout(
dto.refreshToken,
userId,
extractAuditContext(req),
);
if (refreshToken) {
await this.service.logout(refreshToken, userId, extractAuditContext(req));
}
// 清除 httpOnly cookie
res.setHeader("Set-Cookie", buildClearCookie());
return { success: true as const, data: { success: true } };
}
@@ -223,4 +317,25 @@ export class IamController {
);
return { success: true as const, data: user };
}
@Post("users")
@RequirePermission(Permissions.IAM_USER_MANAGE)
async createUser(
@Body() body: unknown,
@Req() req: AuthenticatedRequest,
): Promise<{ success: true; data: UserInfo }> {
const dto = createUserSchema.parse(body);
const user = await this.service.createUser(dto, extractAuditContext(req));
return { success: true as const, data: user };
}
@Delete("users/:id")
@RequirePermission(Permissions.IAM_USER_MANAGE)
async deleteUser(
@Param("id") id: string,
@Req() req: AuthenticatedRequest,
): Promise<{ success: true; data: { success: boolean } }> {
await this.service.deleteUser(id, extractAuditContext(req));
return { success: true as const, data: { success: true } };
}
}

View File

@@ -42,6 +42,16 @@ export const updateUserStatusSchema = z.object({
status: z.enum(["active", "inactive", "locked"]),
});
export const createUserSchema = z.object({
email: z.string().email(),
password: z.string().min(8).max(72),
name: z.string().min(1).max(100),
roleId: z.string().uuid().optional(),
dataScope: z
.enum(["self", "subject", "class", "grade", "school", "all"])
.optional(),
});
export const listUsersQuerySchema = z.object({
limit: z.coerce.number().min(1).max(100).default(20),
offset: z.coerce.number().min(0).default(0),
@@ -109,6 +119,7 @@ export type ChangePasswordDto = z.infer<typeof changePasswordSchema>;
export type UpdateProfileDto = z.infer<typeof updateProfileSchema>;
export type UpdateUserDto = z.infer<typeof updateUserSchema>;
export type UpdateUserStatusDto = z.infer<typeof updateUserStatusSchema>;
export type CreateUserDto = z.infer<typeof createUserSchema>;
export type ListUsersQueryDto = z.infer<typeof listUsersQuerySchema>;
export type CreateRoleDto = z.infer<typeof createRoleSchema>;
export type UpdateRoleDto = z.infer<typeof updateRoleSchema>;

View File

@@ -125,6 +125,65 @@ export class IamGrpcController {
};
}
// ============ 管理员用户管理类 ============
@GrpcMethod("IamService", "CreateUser")
async createUser(data: {
email: string;
password: string;
name: string;
roleId?: string;
dataScope?: string;
}): Promise<unknown> {
const user = await this.service.createUser({
email: data.email,
password: data.password,
name: data.name,
roleId: data.roleId || undefined,
dataScope:
(data.dataScope as
| "self"
| "subject"
| "class"
| "grade"
| "school"
| "all"
| undefined) ?? undefined,
});
return this.toUserInfoProto(user);
}
@GrpcMethod("IamService", "UpdateUser")
async updateUser(data: {
userId: string;
name?: string;
email?: string;
status?: string;
dataScope?: string;
}): Promise<unknown> {
const user = await this.service.updateUser(data.userId, {
name: data.name || undefined,
email: data.email || undefined,
status: data.status || undefined,
dataScope:
(data.dataScope as
| "self"
| "subject"
| "class"
| "grade"
| "school"
| "all"
| undefined) ?? undefined,
});
return this.toUserInfoProto(user);
}
@GrpcMethod("IamService", "DeleteUser")
async deleteUser(data: { userId: string }): Promise<{ success: boolean }> {
await this.service.deleteUser(data.userId);
return { success: true };
}
private toUserInfoProto(user: {
id: string;
email: string;

View File

@@ -391,6 +391,29 @@ export class IamRepository {
return this.findUserById(userId);
}
/**
* 硬删除用户:清理所有关联数据(角色/密码历史/刷新令牌/TOTP/备份码).
* 审计日志保留以满足合规要求。
*/
async deleteUser(userId: string): Promise<void> {
const db = getDb();
// 清理关联数据
await db.delete(userRoles).where(eq(userRoles.userId, userId));
await db.delete(passwordHistory).where(eq(passwordHistory.userId, userId));
await db.delete(refreshTokens).where(eq(refreshTokens.userId, userId));
await db.delete(totpBackupCodes).where(eq(totpBackupCodes.userId, userId));
await db.delete(userTotp).where(eq(userTotp.userId, userId));
// 删除学生-家长关系(作为 guardian 或 student
await db
.delete(studentGuardians)
.where(eq(studentGuardians.guardianId, userId));
await db
.delete(studentGuardians)
.where(eq(studentGuardians.studentId, userId));
// 最后删除用户
await db.delete(users).where(eq(users.id, userId));
}
// ============ 角色 CRUD ============
async findRoleById(id: string): Promise<Role | undefined> {

View File

@@ -497,6 +497,125 @@ export class IamService {
return this.buildUserInfo(updated!);
}
/**
* 管理员创建用户:由 admin 直接创建账户(非自助注册),可指定角色与数据范围.
* 区别于 register():不发 token、不登录仅创建用户并分配角色.
*/
async createUser(
data: {
email: string;
password: string;
name: string;
roleId?: string;
dataScope?: DataScope;
},
context?: AuditContext,
): Promise<UserInfo> {
validatePasswordStrength(data.password);
const existing = await this.repository.findUserByEmail(data.email);
if (existing) {
throw new ConflictError("Email already registered");
}
const userId = crypto.randomUUID();
const passwordHash = await bcrypt.hash(data.password, 12);
const user = await this.repository.createUser({
id: userId,
email: data.email,
passwordHash,
name: data.name,
dataScope: data.dataScope,
});
const roleId = data.roleId ?? DEFAULT_ROLE_ID;
await this.repository.assignRole(userId, roleId);
await this.repository.addPasswordHistory(userId, passwordHash);
// Outbox: UserCreated event
await this.outbox.publish(
"UserCreated",
{
event_id: crypto.randomUUID(),
aggregate_id: userId,
event_type: "UserCreated",
occurred_at: Date.now(),
user_id: userId,
email: data.email,
name: data.name,
roles: [roleId],
data_scope: data.dataScope ?? "self",
action: "created",
metadata: { source: "admin_create" },
},
{ aggregateId: userId },
);
await this.writeAuditLog(
"system",
"create",
"user",
userId,
null,
{ id: userId, email: data.email, name: data.name, roleId },
context,
);
return this.buildUserInfo(user);
}
/**
* 管理员删除用户:硬删除 + 清理关联数据 + 撤销 token + 发布 UserDeleted 事件.
* 审计日志保留以满足合规要求.
*/
async deleteUser(userId: string, context?: AuditContext): Promise<void> {
const user = await this.repository.findUserById(userId);
if (!user) {
throw new NotFoundError("User", userId);
}
const beforeState = {
id: user.id,
email: user.email,
name: user.name,
status: user.status,
};
// 撤销所有 token + 清理权限缓存
await this.repository.revokeAllUserTokens(userId);
await this.permissionCache.invalidate(userId);
// 硬删除用户及关联数据
await this.repository.deleteUser(userId);
// Outbox: UserDeleted event
await this.outbox.publish(
"UserDeleted",
{
event_id: crypto.randomUUID(),
aggregate_id: userId,
event_type: "UserDeleted",
occurred_at: Date.now(),
user_id: userId,
email: user.email,
name: user.name,
action: "deleted",
metadata: { source: "admin_delete" },
},
{ aggregateId: userId },
);
await this.writeAuditLog(
"system",
"delete",
"user",
userId,
beforeState,
null,
context,
);
}
// ============ 权限与视口类 ============
async getEffectivePermissions(userId: string): Promise<string[]> {

View File

@@ -1,16 +1,16 @@
import pino from 'pino';
import { env } from '../../config/env.js';
import { pino } from "pino";
import { env } from "../../config/env.js";
export const logger = pino({
level: env.LOG_LEVEL,
base: {
service: 'iam',
version: '0.1.0',
service: "iam",
version: "0.1.0",
},
transport:
env.NODE_ENV === 'development'
env.NODE_ENV === "development"
? {
target: 'pino-pretty',
target: "pino-pretty",
options: { colorize: true },
}
: undefined,