diff --git a/services/iam/Dockerfile b/services/iam/Dockerfile index 95a328c..f4da3ae 100644 --- a/services/iam/Dockerfile +++ b/services/iam/Dockerfile @@ -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-proto(gRPC 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-ts(iam 依赖 @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-ts(iam 依赖 @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"] diff --git a/services/iam/docs/nextstep-v2.md b/services/iam/docs/nextstep-v2.md new file mode 100644 index 0000000..9e37243 --- /dev/null +++ b/services/iam/docs/nextstep-v2.md @@ -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 安全增强) + +依据架构裁决 F12:refresh_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` 清除 Cookie(Max-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` 构建成功(660MB),bcrypt `.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` | ✅ | 200,5 项依赖全 ok(database / 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-gateway(ai01 负责)— P0 + +| # | 依赖项 | 用途 | 状态 | +| --- | ----------------------------------- | ---------------------------------------------------- | --------- | +| 1 | `GET /v1/iam/.well-known/jwks.json` | RS256 公钥集(JWKS),TTL 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-gateway(ai09 负责)— P0 + +| # | 依赖项 | 用途 | 状态 | +| --- | ------------------------------------ | ----------------------------------------- | --------- | +| 1 | `GET /v1/iam/.well-known/jwks.json` | WebSocket `/ws` 连接时校验客户端 JWT 签名 | ✅ 已就绪 | +| 2 | shared-go/jwks Fetcher 每 5 分钟刷新 | JWKS 缓存刷新 | ✅ 已就绪 | + +### 3.3 teacher-bff(ai03 负责)— 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` | 用户管理 CRUD(admin-portal) | ✅ 已就绪 | +| 9 | REST 全套 RBAC CRUD(roles/permissions/viewports/audit) | admin-portal 管理 | ✅ 已就绪 | + +### 3.4 student-bff(ai04 负责)— P0 + +| # | gRPC 方法 | 用途 | 状态 | +| --- | ---------------- | ------------------------- | --------- | +| 1 | `GetUserProfile` | `myProfile` Query | ✅ 已就绪 | +| 2 | `UpdateProfile` | `updateProfile` Mutation | ✅ 已就绪 | +| 3 | `ChangePassword` | `changePassword` Mutation | ✅ 已就绪 | + +### 3.5 parent-bff(ai05 负责)— P0 + +| # | RPC 方法 | 用途 | 状态 | +| --- | --------------------------------- | ---------------------- | --------- | +| 1 | `getUserInfo(userId)` | 获取家长个人信息 | ✅ 已就绪 | +| 2 | `getChildrenByParent(parentId)` | 获取家长绑定的孩子列表 | ✅ 已就绪 | +| 3 | `getViewports(userId)` | 获取家长可见视口 | ✅ 已就绪 | +| 4 | `getEffectivePermissions(userId)` | 获取家长有效权限 | ✅ 已就绪 | +| 5 | `GET /healthz` 端点 | /readyz 下游健康检查 | ✅ 已就绪 | + +### 3.6 admin-portal(ai16 负责)— P0 + +| # | 依赖项 | 用途 | 状态 | +| --- | ------------------------------------------------------ | ----------------------------- | --------- | +| 1 | gRPC `CreateUser` / `UpdateUser` / `DeleteUser` | 用户管理 CRUD(v2 新增) | ✅ 已就绪 | +| 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-portal(ai13 负责)— 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-portal(ai14 负责)— 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-portal(ai15 负责)— 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 投递到 Kafka,data-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 联调。** diff --git a/services/iam/src/config/env.ts b/services/iam/src/config/env.ts index 8e48295..6ed85f2 100644 --- a/services/iam/src/config/env.ts +++ b/services/iam/src/config/env.ts @@ -19,6 +19,13 @@ const envSchema = z.object({ ACCESS_TOKEN_TTL: z.string().default("15m"), REFRESH_TOKEN_TTL_DAYS: z.string().default("7"), + // Cookie(F12 裁决:refresh_token httpOnly cookie 模式) + COOKIE_SECURE: z + .string() + .default("auto") + .transform((v) => (v === "auto" ? undefined : v === "true")), + COOKIE_DOMAIN: z.string().optional(), + // Kafka(Outbox 投递) KAFKA_BROKERS: z.string(), KAFKA_CLIENT_ID: z.string().default("iam-service"), diff --git a/services/iam/src/iam/iam.controller.ts b/services/iam/src/iam/iam.controller.ts index f975834..9729012 100644 --- a/services/iam/src/iam/iam.controller.ts +++ b/services/iam/src/iam/iam.controller.ts @@ -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 读取,回退到 body(F12 裁决). + */ +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 } }; + } } diff --git a/services/iam/src/iam/iam.dto.ts b/services/iam/src/iam/iam.dto.ts index b6cd82d..940dedd 100644 --- a/services/iam/src/iam/iam.dto.ts +++ b/services/iam/src/iam/iam.dto.ts @@ -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; export type UpdateProfileDto = z.infer; export type UpdateUserDto = z.infer; export type UpdateUserStatusDto = z.infer; +export type CreateUserDto = z.infer; export type ListUsersQueryDto = z.infer; export type CreateRoleDto = z.infer; export type UpdateRoleDto = z.infer; diff --git a/services/iam/src/iam/iam.grpc.controller.ts b/services/iam/src/iam/iam.grpc.controller.ts index 1318116..4d0a055 100644 --- a/services/iam/src/iam/iam.grpc.controller.ts +++ b/services/iam/src/iam/iam.grpc.controller.ts @@ -125,6 +125,65 @@ export class IamGrpcController { }; } + // ============ 管理员用户管理类 ============ + + @GrpcMethod("IamService", "CreateUser") + async createUser(data: { + email: string; + password: string; + name: string; + roleId?: string; + dataScope?: string; + }): Promise { + 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 { + 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; diff --git a/services/iam/src/iam/iam.repository.ts b/services/iam/src/iam/iam.repository.ts index 2951022..24b880c 100644 --- a/services/iam/src/iam/iam.repository.ts +++ b/services/iam/src/iam/iam.repository.ts @@ -391,6 +391,29 @@ export class IamRepository { return this.findUserById(userId); } + /** + * 硬删除用户:清理所有关联数据(角色/密码历史/刷新令牌/TOTP/备份码). + * 审计日志保留以满足合规要求。 + */ + async deleteUser(userId: string): Promise { + 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 { diff --git a/services/iam/src/iam/iam.service.ts b/services/iam/src/iam/iam.service.ts index 5c64608..3ba5e62 100644 --- a/services/iam/src/iam/iam.service.ts +++ b/services/iam/src/iam/iam.service.ts @@ -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 { + 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 { + 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 { diff --git a/services/iam/src/shared/observability/logger.ts b/services/iam/src/shared/observability/logger.ts index 6dc81f2..0e277f9 100644 --- a/services/iam/src/shared/observability/logger.ts +++ b/services/iam/src/shared/observability/logger.ts @@ -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,