create-bubbles 0.1.22 → 0.1.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.mjs +5 -0
- package/index.js +1 -1
- package/package.json +25 -24
- package/template-taro-react-oxc/.env.development +1 -1
- package/template-taro-react-oxc/.env.production +1 -1
- package/template-taro-react-oxc/project.config.json +1 -1
- package/template-taro-vue-eslint/project.config.json +2 -2
- package/{template-vp-monorepo-react-nestjs → template-vp-monorepo-react-hono}/.spaces/server//347/231/273/345/275/225/JWT/344/270/216RefreshToken/350/275/256/346/215/242/351/211/264/346/235/203/346/236/266/346/236/204/345/256/236/347/216/260/346/214/207/345/215/227.md +588 -1133
- package/template-vp-monorepo-react-hono/.vite-hooks/commit-msg +2 -0
- package/template-vp-monorepo-react-hono/.vite-hooks/pre-commit +2 -0
- package/template-vp-monorepo-react-hono/.vscode/extensions.json +3 -0
- package/template-vp-monorepo-react-hono/.vscode/settings.json +23 -0
- package/template-vp-monorepo-react-hono/AGENTS.md +16 -0
- package/template-vp-monorepo-react-hono/README.md +54 -0
- package/template-vp-monorepo-react-hono/apps/server/.env.example +13 -0
- package/template-vp-monorepo-react-hono/apps/server/README.md +75 -0
- package/template-vp-monorepo-react-hono/apps/server/drizzle/0000_flaky_madame_masque.sql +18 -0
- package/template-vp-monorepo-react-hono/apps/server/drizzle/meta/0000_snapshot.json +153 -0
- package/template-vp-monorepo-react-hono/apps/server/drizzle/meta/_journal.json +13 -0
- package/template-vp-monorepo-react-hono/apps/server/drizzle.config.ts +19 -0
- package/template-vp-monorepo-react-hono/apps/server/package.json +46 -0
- package/template-vp-monorepo-react-hono/apps/server/src/app.test.ts +57 -0
- package/template-vp-monorepo-react-hono/apps/server/src/app.ts +68 -0
- package/template-vp-monorepo-react-hono/apps/server/src/common/errors/app-error.ts +23 -0
- package/template-vp-monorepo-react-hono/apps/server/src/common/http/response.ts +21 -0
- package/template-vp-monorepo-react-hono/apps/server/src/common/logger.ts +30 -0
- package/template-vp-monorepo-react-hono/apps/server/src/config/env.ts +54 -0
- package/template-vp-monorepo-react-hono/apps/server/src/index.bun.ts +60 -0
- package/template-vp-monorepo-react-hono/apps/server/src/index.ts +73 -0
- package/template-vp-monorepo-react-hono/apps/server/src/infrastructure/database/client.ts +46 -0
- package/template-vp-monorepo-react-hono/apps/server/src/infrastructure/database/schema/index.ts +1 -0
- package/template-vp-monorepo-react-hono/apps/server/src/infrastructure/database/schema/job-runs.ts +42 -0
- package/template-vp-monorepo-react-hono/apps/server/src/infrastructure/queue/connection.ts +6 -0
- package/template-vp-monorepo-react-hono/apps/server/src/infrastructure/redis/client.ts +39 -0
- package/template-vp-monorepo-react-hono/apps/server/src/infrastructure/redis/options.ts +28 -0
- package/template-vp-monorepo-react-hono/apps/server/src/modules/health/health.routes.ts +17 -0
- package/template-vp-monorepo-react-hono/apps/server/src/modules/health/health.service.ts +59 -0
- package/template-vp-monorepo-react-hono/apps/server/src/modules/jobs/jobs.queue.ts +46 -0
- package/template-vp-monorepo-react-hono/apps/server/src/modules/jobs/jobs.repository.ts +101 -0
- package/template-vp-monorepo-react-hono/apps/server/src/modules/jobs/jobs.routes.ts +46 -0
- package/template-vp-monorepo-react-hono/apps/server/src/modules/jobs/jobs.schemas.ts +9 -0
- package/template-vp-monorepo-react-hono/apps/server/src/modules/jobs/jobs.service.ts +57 -0
- package/template-vp-monorepo-react-hono/apps/server/src/modules/jobs/jobs.types.ts +11 -0
- package/template-vp-monorepo-react-hono/apps/server/src/modules/jobs/jobs.worker.ts +108 -0
- package/template-vp-monorepo-react-hono/apps/server/src/runtime/shutdown.ts +38 -0
- package/template-vp-monorepo-react-hono/apps/server/src/worker.ts +48 -0
- package/template-vp-monorepo-react-hono/apps/server/tsconfig.json +15 -0
- package/template-vp-monorepo-react-hono/apps/server/vite.config.ts +15 -0
- package/template-vp-monorepo-react-hono/apps/web/.env +2 -0
- package/template-vp-monorepo-react-hono/apps/web/.env.dev +1 -0
- package/template-vp-monorepo-react-hono/apps/web/.env.prod +1 -0
- package/template-vp-monorepo-react-hono/apps/web/index.html +13 -0
- package/template-vp-monorepo-react-hono/apps/web/package.json +35 -0
- package/template-vp-monorepo-react-hono/apps/web/public/favicon.svg +1 -0
- package/template-vp-monorepo-react-hono/apps/web/public/icons.svg +24 -0
- package/template-vp-monorepo-react-hono/apps/web/src/App.tsx +25 -0
- package/template-vp-monorepo-react-hono/apps/web/src/assets/hero.png +0 -0
- package/template-vp-monorepo-react-hono/apps/web/src/assets/react.svg +1 -0
- package/template-vp-monorepo-react-hono/apps/web/src/assets/vite.svg +1 -0
- package/template-vp-monorepo-react-hono/apps/web/src/components/Loading/PageLoading.tsx +11 -0
- package/template-vp-monorepo-react-hono/apps/web/src/locales/en_US.json +4 -0
- package/template-vp-monorepo-react-hono/apps/web/src/locales/zh_CN.json +4 -0
- package/template-vp-monorepo-react-hono/apps/web/src/main.tsx +5 -0
- package/template-vp-monorepo-react-hono/apps/web/src/pages/home/index.tsx +47 -0
- package/template-vp-monorepo-react-hono/apps/web/src/router/index.tsx +25 -0
- package/template-vp-monorepo-react-hono/apps/web/src/router/modules/.gitkeep +0 -0
- package/template-vp-monorepo-react-hono/apps/web/src/styles/index.css +7 -0
- package/template-vp-monorepo-react-hono/apps/web/src/types/auto-imports.d.ts +58 -0
- package/template-vp-monorepo-react-hono/apps/web/src/utils/env/index.ts +6 -0
- package/template-vp-monorepo-react-hono/apps/web/src/utils/request/core/index.ts +420 -0
- package/template-vp-monorepo-react-hono/apps/web/src/utils/request/core/utils.ts +39 -0
- package/template-vp-monorepo-react-hono/apps/web/src/utils/request/index.ts +61 -0
- package/template-vp-monorepo-react-hono/apps/web/tsconfig.json +18 -0
- package/template-vp-monorepo-react-hono/apps/web/vite.config.ts +45 -0
- package/template-vp-monorepo-react-hono/commitlint.config.js +1 -0
- package/template-vp-monorepo-react-hono/docker-compose.yml +41 -0
- package/template-vp-monorepo-react-hono/mise.toml +4 -0
- package/template-vp-monorepo-react-hono/package.json +37 -0
- package/template-vp-monorepo-react-hono/packages/i18n/core/package.json +20 -0
- package/template-vp-monorepo-react-hono/packages/i18n/core/src/format.ts +9 -0
- package/template-vp-monorepo-react-hono/packages/i18n/core/src/i18n.ts +73 -0
- package/template-vp-monorepo-react-hono/packages/i18n/core/src/index.ts +24 -0
- package/template-vp-monorepo-react-hono/packages/i18n/core/src/storage.ts +47 -0
- package/template-vp-monorepo-react-hono/packages/i18n/core/src/store.ts +48 -0
- package/template-vp-monorepo-react-hono/packages/i18n/core/src/types.ts +36 -0
- package/template-vp-monorepo-react-hono/packages/i18n/core/tsconfig.json +4 -0
- package/template-vp-monorepo-react-hono/packages/i18n/core/vite.config.ts +13 -0
- package/template-vp-monorepo-react-hono/packages/i18n/react/package.json +28 -0
- package/template-vp-monorepo-react-hono/packages/i18n/react/src/index.tsx +26 -0
- package/template-vp-monorepo-react-hono/packages/i18n/react/src/use-store.ts +14 -0
- package/template-vp-monorepo-react-hono/packages/i18n/react/tsconfig.json +9 -0
- package/template-vp-monorepo-react-hono/packages/i18n/react/vite.config.ts +16 -0
- package/template-vp-monorepo-react-hono/packages/i18n/vue/package.json +27 -0
- package/template-vp-monorepo-react-hono/packages/i18n/vue/src/index.ts +52 -0
- package/template-vp-monorepo-react-hono/packages/i18n/vue/src/use-store.ts +19 -0
- package/template-vp-monorepo-react-hono/packages/i18n/vue/tsconfig.json +8 -0
- package/template-vp-monorepo-react-hono/packages/i18n/vue/vite.config.ts +16 -0
- package/template-vp-monorepo-react-hono/pnpm-workspace.yaml +57 -0
- package/template-vp-monorepo-react-hono/scripts/clean.mjs +48 -0
- package/template-vp-monorepo-react-hono/tsconfig.base.json +10 -0
- package/template-vp-monorepo-react-hono/tsconfig.bundler.json +12 -0
- package/template-vp-monorepo-react-hono/tsconfig.json +11 -0
- package/template-vp-monorepo-react-hono/tsconfig.lib.json +10 -0
- package/template-vp-monorepo-react-hono/vite.config.ts +20 -0
- package/template-vp-monorepo-react-nestjs/.spaces/server/todo.md +991 -0
- package/template-vp-monorepo-react-nestjs/.vscode/launch.json +16 -0
- package/template-vp-monorepo-react-nestjs/.vscode/settings.json +21 -17
- package/template-vp-monorepo-react-nestjs/apps/server/.env.development +3 -39
- package/template-vp-monorepo-react-nestjs/apps/server/.swcrc +8 -0
- package/template-vp-monorepo-react-nestjs/apps/server/drizzle.config.ts +8 -3
- package/template-vp-monorepo-react-nestjs/apps/server/nest-cli.json +8 -1
- package/template-vp-monorepo-react-nestjs/apps/server/package.json +11 -6
- package/template-vp-monorepo-react-nestjs/apps/server/src/app.controller.ts +2 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/app.module.ts +20 -15
- package/template-vp-monorepo-react-nestjs/apps/server/src/common/adapters/fastify.adapter.ts +16 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/common/constants/auth.ts +2 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/common/constants/response.ts +0 -4
- package/template-vp-monorepo-react-nestjs/apps/server/src/common/constants/session.constants.ts +28 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/common/decorators/current-auth.decorator.ts +16 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/common/decorators/public.decorator.ts +4 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/common/dto/api-failure.dto.ts +25 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/common/error/common.error.ts +40 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/common/exceptions/app.exception.ts +55 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/common/filters/global-exception.filter.ts +384 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/common/openapi/response-headers.ts +9 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/config/index.ts +1 -1
- package/template-vp-monorepo-react-nestjs/apps/server/src/config/session.config.ts +36 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/database/schema.ts +7 -8
- package/template-vp-monorepo-react-nestjs/apps/server/src/main.ts +29 -8
- package/template-vp-monorepo-react-nestjs/apps/server/src/modules/auth/auth.controller.ts +55 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/modules/auth/auth.errors.ts +42 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/modules/auth/auth.module.ts +26 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/modules/auth/auth.repository.ts +40 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/modules/auth/auth.service.ts +129 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/modules/auth/dto/auth-repsponse.dto.ts +31 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/modules/auth/dto/login.dto.ts +11 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/modules/auth/dto/register.dto.ts +17 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/modules/auth/guards/session-auth.guard.ts +50 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/modules/auth/password.service.ts +24 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/modules/auth/session/session-store.service.ts +198 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/modules/auth/session/session-token.service.ts +29 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/modules/auth/session/session.script.ts +221 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/modules/auth/session/session.types.ts +24 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/modules/test/db/db.controller.ts +1 -1
- package/template-vp-monorepo-react-nestjs/apps/server/src/modules/test/db/dto/create-user.dto.ts +3 -5
- package/template-vp-monorepo-react-nestjs/apps/server/src/modules/test/redis/redis.service.ts +1 -1
- package/template-vp-monorepo-react-nestjs/apps/server/src/utils/env-arr.ts +3 -0
- package/template-vp-monorepo-react-nestjs/apps/server/test/error-catalog.spec.ts +30 -0
- package/template-vp-monorepo-react-nestjs/apps/server/tsconfig.build.json +11 -1
- package/template-vp-monorepo-react-nestjs/apps/server/tsconfig.json +12 -20
- package/template-vp-monorepo-react-nestjs/apps/server/vite.config.ts +15 -0
- package/template-vp-monorepo-react-nestjs/apps/web/package.json +1 -0
- package/template-vp-monorepo-react-nestjs/apps/web/tsconfig.json +5 -15
- package/template-vp-monorepo-react-nestjs/apps/web-vue/commitlint.config.js +1 -1
- package/template-vp-monorepo-react-nestjs/apps/web-vue/lefthook.yml +1 -1
- package/template-vp-monorepo-react-nestjs/apps/web-vue/package.json +1 -3
- package/template-vp-monorepo-react-nestjs/apps/web-vue/src/api/index.ts +5 -5
- package/template-vp-monorepo-react-nestjs/apps/web-vue/src/components/Icon/svg-icon.vue +3 -3
- package/template-vp-monorepo-react-nestjs/apps/web-vue/src/hooks/chart/lib.ts +6 -6
- package/template-vp-monorepo-react-nestjs/apps/web-vue/src/hooks/chart/useEcharts.ts +34 -29
- package/template-vp-monorepo-react-nestjs/apps/web-vue/src/i18n/instance.ts +2 -0
- package/template-vp-monorepo-react-nestjs/apps/web-vue/src/layout/default/index.vue +17 -32
- package/template-vp-monorepo-react-nestjs/apps/web-vue/src/locales/en_US.json +4 -1
- package/template-vp-monorepo-react-nestjs/apps/web-vue/src/locales/zh_CN.json +4 -1
- package/template-vp-monorepo-react-nestjs/apps/web-vue/src/router/guard/index.ts +3 -3
- package/template-vp-monorepo-react-nestjs/apps/web-vue/src/router/guard/permissionGuard.ts +7 -7
- package/template-vp-monorepo-react-nestjs/apps/web-vue/src/router/index.ts +7 -7
- package/template-vp-monorepo-react-nestjs/apps/web-vue/src/router/interface.ts +6 -6
- package/template-vp-monorepo-react-nestjs/apps/web-vue/src/store/index.ts +6 -6
- package/template-vp-monorepo-react-nestjs/apps/web-vue/src/store/modules/user.ts +9 -9
- package/template-vp-monorepo-react-nestjs/apps/web-vue/src/styles/index.scss +3 -3
- package/template-vp-monorepo-react-nestjs/apps/web-vue/src/types/antdv-next.d.ts +1 -0
- package/template-vp-monorepo-react-nestjs/apps/web-vue/src/utils/env.ts +1 -1
- package/template-vp-monorepo-react-nestjs/apps/web-vue/src/utils/request/core/index.ts +63 -45
- package/template-vp-monorepo-react-nestjs/apps/web-vue/src/utils/request/core/utils.ts +12 -6
- package/template-vp-monorepo-react-nestjs/apps/web-vue/src/utils/request/index.ts +1 -1
- package/template-vp-monorepo-react-nestjs/apps/web-vue/src/views/example/echart/config.ts +687 -687
- package/template-vp-monorepo-react-nestjs/apps/web-vue/src/views/example/echart/index.vue +7 -7
- package/template-vp-monorepo-react-nestjs/apps/web-vue/src/views/example/tree-chart.vue +2 -7
- package/template-vp-monorepo-react-nestjs/apps/web-vue/src/views/home/index.vue +13 -3
- package/template-vp-monorepo-react-nestjs/apps/web-vue/tsconfig.json +5 -17
- package/template-vp-monorepo-react-nestjs/apps/web-vue/uno.config.ts +5 -5
- package/template-vp-monorepo-react-nestjs/i18n.config.ts +22 -0
- package/template-vp-monorepo-react-nestjs/package.json +12 -1
- package/template-vp-monorepo-react-nestjs/packages/i18n/cli/README.md +113 -0
- package/template-vp-monorepo-react-nestjs/packages/i18n/cli/docs/excel-sync-plan.md +618 -0
- package/template-vp-monorepo-react-nestjs/packages/i18n/cli/package.json +31 -0
- package/template-vp-monorepo-react-nestjs/packages/i18n/cli/src/cli.ts +5 -0
- package/template-vp-monorepo-react-nestjs/packages/i18n/cli/src/command.ts +512 -0
- package/template-vp-monorepo-react-nestjs/packages/i18n/cli/src/config.ts +15 -0
- package/template-vp-monorepo-react-nestjs/packages/i18n/cli/src/files.ts +67 -0
- package/template-vp-monorepo-react-nestjs/packages/i18n/cli/src/index.ts +19 -0
- package/template-vp-monorepo-react-nestjs/packages/i18n/cli/src/load-config.ts +228 -0
- package/template-vp-monorepo-react-nestjs/packages/i18n/cli/src/report.ts +57 -0
- package/template-vp-monorepo-react-nestjs/packages/i18n/cli/src/scanner.test.ts +81 -0
- package/template-vp-monorepo-react-nestjs/packages/i18n/cli/src/scanner.ts +290 -0
- package/template-vp-monorepo-react-nestjs/packages/i18n/cli/src/sync.ts +274 -0
- package/template-vp-monorepo-react-nestjs/packages/i18n/cli/test/command.test.ts +209 -0
- package/template-vp-monorepo-react-nestjs/packages/i18n/cli/test/config.test.ts +19 -0
- package/template-vp-monorepo-react-nestjs/packages/i18n/cli/test/load-config.test.ts +194 -0
- package/template-vp-monorepo-react-nestjs/packages/i18n/cli/test/report.test.ts +63 -0
- package/template-vp-monorepo-react-nestjs/packages/i18n/cli/test/sync.test.ts +203 -0
- package/template-vp-monorepo-react-nestjs/packages/i18n/cli/test/temporary-directory.ts +25 -0
- package/template-vp-monorepo-react-nestjs/packages/i18n/cli/tsconfig.json +9 -0
- package/template-vp-monorepo-react-nestjs/packages/i18n/cli/vite.config.ts +18 -0
- package/template-vp-monorepo-react-nestjs/packages/i18n/core/tsconfig.json +1 -12
- package/template-vp-monorepo-react-nestjs/packages/i18n/react/tsconfig.json +3 -10
- package/template-vp-monorepo-react-nestjs/packages/i18n/vue/tsconfig.json +3 -10
- package/template-vp-monorepo-react-nestjs/packages/shared/package.json +40 -0
- package/template-vp-monorepo-react-nestjs/packages/shared/src/index.ts +13 -0
- package/template-vp-monorepo-react-nestjs/packages/shared/src/types/auth.ts +35 -0
- package/template-vp-monorepo-react-nestjs/packages/shared/src/types/common.ts +11 -0
- package/template-vp-monorepo-react-nestjs/packages/shared/src/types/index.ts +11 -0
- package/template-vp-monorepo-react-nestjs/packages/shared/src/utils/account.ts +5 -0
- package/template-vp-monorepo-react-nestjs/packages/shared/src/utils/index.ts +2 -0
- package/template-vp-monorepo-react-nestjs/packages/shared/src/utils/session-terminal.ts +11 -0
- package/template-vp-monorepo-react-nestjs/packages/shared/tsconfig.json +4 -0
- package/template-vp-monorepo-react-nestjs/packages/shared/vite.config.ts +17 -0
- package/template-vp-monorepo-react-nestjs/pnpm-workspace.yaml +6 -6
- package/template-vp-monorepo-react-nestjs/scripts/clean.mjs +48 -0
- package/template-vp-monorepo-react-nestjs/tsconfig.base.json +10 -0
- package/template-vp-monorepo-react-nestjs/tsconfig.bundler.json +12 -0
- package/template-vp-monorepo-react-nestjs/tsconfig.json +8 -6
- package/template-vp-monorepo-react-nestjs/tsconfig.lib.json +10 -0
- package/template-vp-monorepo-react-nestjs/vite.config.ts +2 -1
- package/template-vp-vue-eslint-vapor/.env +6 -0
- package/template-vp-vue-eslint-vapor/.env.development +2 -0
- package/template-vp-vue-eslint-vapor/.env.production +2 -0
- package/template-vp-vue-eslint-vapor/.vite-hooks/commit-msg +1 -0
- package/template-vp-vue-eslint-vapor/.vite-hooks/pre-commit +1 -0
- package/template-vp-vue-eslint-vapor/.vscode/extensions.json +3 -0
- package/template-vp-vue-eslint-vapor/AGENTS.md +16 -0
- package/template-vp-vue-eslint-vapor/README.md +93 -0
- package/template-vp-vue-eslint-vapor/commitlint.config.js +1 -0
- package/template-vp-vue-eslint-vapor/eslint.config.js +13 -0
- package/template-vp-vue-eslint-vapor/index.html +13 -0
- package/template-vp-vue-eslint-vapor/package.json +64 -0
- package/template-vp-vue-eslint-vapor/pnpm-workspace.yaml +2 -0
- package/template-vp-vue-eslint-vapor/public/vite.svg +1 -0
- package/template-vp-vue-eslint-vapor/src/App.vue +7 -0
- package/template-vp-vue-eslint-vapor/src/api/index.ts +12 -0
- package/template-vp-vue-eslint-vapor/src/assets/icon/home.svg +3 -0
- package/template-vp-vue-eslint-vapor/src/assets/icon/logo.svg +1 -0
- package/template-vp-vue-eslint-vapor/src/assets/image/.gitkeep +0 -0
- package/template-vp-vue-eslint-vapor/src/components/Icon/svg-icon.vue +29 -0
- package/template-vp-vue-eslint-vapor/src/hooks/chart/lib.ts +57 -0
- package/template-vp-vue-eslint-vapor/src/hooks/chart/useEcharts.ts +65 -0
- package/template-vp-vue-eslint-vapor/src/layout/default/header/index.vue +57 -0
- package/template-vp-vue-eslint-vapor/src/layout/default/index.vue +138 -0
- package/template-vp-vue-eslint-vapor/src/main.ts +19 -0
- package/template-vp-vue-eslint-vapor/src/router/guard/index.ts +7 -0
- package/template-vp-vue-eslint-vapor/src/router/guard/permissionGuard.ts +13 -0
- package/template-vp-vue-eslint-vapor/src/router/index.ts +18 -0
- package/template-vp-vue-eslint-vapor/src/router/interface.ts +9 -0
- package/template-vp-vue-eslint-vapor/src/router/modules/index.ts +33 -0
- package/template-vp-vue-eslint-vapor/src/store/index.ts +11 -0
- package/template-vp-vue-eslint-vapor/src/store/modules/user.ts +17 -0
- package/template-vp-vue-eslint-vapor/src/styles/font.scss +0 -0
- package/template-vp-vue-eslint-vapor/src/styles/index.scss +29 -0
- package/template-vp-vue-eslint-vapor/src/styles/variables.scss +3 -0
- package/template-vp-vue-eslint-vapor/src/types/vite-env.d.ts +15 -0
- package/template-vp-vue-eslint-vapor/src/utils/env.ts +11 -0
- package/template-vp-vue-eslint-vapor/src/utils/request/core/index.ts +425 -0
- package/template-vp-vue-eslint-vapor/src/utils/request/core/utils.ts +39 -0
- package/template-vp-vue-eslint-vapor/src/utils/request/index.ts +44 -0
- package/template-vp-vue-eslint-vapor/src/views/example/echart/config.ts +1794 -0
- package/template-vp-vue-eslint-vapor/src/views/example/echart/index.vue +20 -0
- package/template-vp-vue-eslint-vapor/src/views/example/tree-chart.vue +16 -0
- package/template-vp-vue-eslint-vapor/src/views/home/index.vue +16 -0
- package/template-vp-vue-eslint-vapor/src/views/login/index.vue +9 -0
- package/template-vp-vue-eslint-vapor/tsconfig.json +32 -0
- package/template-vp-vue-eslint-vapor/tsconfig.tsbuildinfo +1 -0
- package/template-vp-vue-eslint-vapor/uno.config.ts +17 -0
- package/template-vp-vue-eslint-vapor/vite.config.ts +66 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/common/constants/decorator.ts +0 -2
- package/template-vp-monorepo-react-nestjs/apps/server/src/common/decorators/bypass.ts +0 -12
- package/template-vp-monorepo-react-nestjs/apps/server/src/common/interceptors/transform.ts +0 -34
- package/template-vp-monorepo-react-nestjs/apps/server/src/common/model/response.ts +0 -23
- package/template-vp-monorepo-react-nestjs/apps/server/src/config/auth.config.ts +0 -64
- package/template-vp-monorepo-react-nestjs/apps/web-vue/pnpm-workspace.yaml +0 -16
- /package/{template-vp-monorepo-react-nestjs/apps/server/src/config/security.config.ts → template-vp-monorepo-react-hono/.agents/auth.config.ts} +0 -0
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
完成步骤 0~15 后,请求会按下面的方式工作。附录 A、B 是后续生产化参考,不属于本教程的强制施工步骤:
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
```mermaid
|
|
14
14
|
flowchart LR
|
|
15
15
|
A["浏览器"] --> B["登录或刷新"]
|
|
16
16
|
B --> C["短期 Access JWT"]
|
|
@@ -23,7 +23,7 @@ flowchart LR
|
|
|
23
23
|
H --> J["JWT 版本与 Auth State 比较"]
|
|
24
24
|
I --> J
|
|
25
25
|
J --> K["业务 Controller"]
|
|
26
|
-
|
|
26
|
+
```
|
|
27
27
|
|
|
28
28
|
最终具备以下能力:
|
|
29
29
|
|
|
@@ -43,17 +43,17 @@ flowchart LR
|
|
|
43
43
|
|
|
44
44
|
不要只设计一个 version。
|
|
45
45
|
|
|
46
|
-
| 字段
|
|
47
|
-
|
|
|
48
|
-
| accountAuthEpoch
|
|
49
|
-
| sessionVersion
|
|
50
|
-
| refreshGeneration | 每次成功刷新
|
|
46
|
+
| 字段 | 什么时候增加 | 影响范围 |
|
|
47
|
+
| ----------------- | -------------------------------------------- | ------------------------------------------- |
|
|
48
|
+
| accountAuthEpoch | 修改密码、冻结账号、退出全部设备 | 该账号的全部旧 Access JWT 和 Refresh Family |
|
|
49
|
+
| sessionVersion | 当前设备退出、管理员踢某个设备、Refresh 重放 | 仅一个登录 Session |
|
|
50
|
+
| refreshGeneration | 每次成功刷新 | 仅 Refresh Token 轮换链 |
|
|
51
51
|
|
|
52
52
|
Refresh Token 正常轮换时,只增加 refreshGeneration,不增加前两个版本。
|
|
53
53
|
|
|
54
54
|
Access JWT 的载荷使用:
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
```json
|
|
57
57
|
{
|
|
58
58
|
"sub": "123",
|
|
59
59
|
"sid": "session-uuid",
|
|
@@ -65,7 +65,7 @@ Access JWT 的载荷使用:
|
|
|
65
65
|
"iat": 1,
|
|
66
66
|
"exp": 2
|
|
67
67
|
}
|
|
68
|
-
|
|
68
|
+
```
|
|
69
69
|
|
|
70
70
|
版本比较不是简单的不相等:
|
|
71
71
|
|
|
@@ -85,9 +85,9 @@ Access JWT 的载荷使用:
|
|
|
85
85
|
|
|
86
86
|
下面所有命令都从仓库根目录执行:
|
|
87
87
|
|
|
88
|
-
|
|
88
|
+
```text
|
|
89
89
|
C:\malei\stu\bubbles\packages\create-bubbles\template-vp-monorepo-react-nestjs
|
|
90
|
-
|
|
90
|
+
```
|
|
91
91
|
|
|
92
92
|
项目真实技术栈是 NestJS 11、Fastify、Drizzle、PostgreSQL、ioredis 和 Vite+。
|
|
93
93
|
|
|
@@ -95,7 +95,7 @@ C:\malei\stu\bubbles\packages\create-bubbles\template-vp-monorepo-react-nestjs
|
|
|
95
95
|
|
|
96
96
|
- 使用 vp,不把教程改写成 npm 或 pnpm 工作流。
|
|
97
97
|
- Fastify Cookie 使用 @fastify/cookie,不使用 Express 的 res.cookie。
|
|
98
|
-
- 代码使用单引号、无分号和
|
|
98
|
+
- 代码使用单引号、无分号和 @/\* 路径别名。
|
|
99
99
|
- Swagger 的 addBearerAuth 只是文档声明,真正鉴权来自 APP_GUARD。
|
|
100
100
|
- 现有 Bypass 装饰器只控制 ResponseInterceptor,不能代替 Public。
|
|
101
101
|
- Refresh Token 原文不能进入数据库、日志和异常。
|
|
@@ -105,7 +105,7 @@ C:\malei\stu\bubbles\packages\create-bubbles\template-vp-monorepo-react-nestjs
|
|
|
105
105
|
|
|
106
106
|
施工完成后,Auth 相关目录大致如下:
|
|
107
107
|
|
|
108
|
-
|
|
108
|
+
```text
|
|
109
109
|
apps/server/src/
|
|
110
110
|
├─ common/
|
|
111
111
|
│ ├─ constants/
|
|
@@ -142,7 +142,7 @@ apps/server/src/
|
|
|
142
142
|
├─ auth.service.ts
|
|
143
143
|
├─ auth.controller.ts
|
|
144
144
|
└─ auth.module.ts
|
|
145
|
-
|
|
145
|
+
```
|
|
146
146
|
|
|
147
147
|
# 步骤 0:先确认原项目能正常构建
|
|
148
148
|
|
|
@@ -152,17 +152,17 @@ apps/server/src/
|
|
|
152
152
|
|
|
153
153
|
## 执行命令
|
|
154
154
|
|
|
155
|
-
|
|
155
|
+
```powershell
|
|
156
156
|
git status --short
|
|
157
157
|
vp install
|
|
158
158
|
vp run "server#build"
|
|
159
|
-
|
|
159
|
+
```
|
|
160
160
|
|
|
161
161
|
如果 vp 本身表现异常,再执行:
|
|
162
162
|
|
|
163
|
-
|
|
163
|
+
```powershell
|
|
164
164
|
vp env doctor
|
|
165
|
-
|
|
165
|
+
```
|
|
166
166
|
|
|
167
167
|
## 完成检查点
|
|
168
168
|
|
|
@@ -180,22 +180,22 @@ vp env doctor
|
|
|
180
180
|
|
|
181
181
|
## 执行命令
|
|
182
182
|
|
|
183
|
-
|
|
183
|
+
```powershell
|
|
184
184
|
vp add @nestjs/jwt @fastify/cookie fastify@5.8.5 argon2 --filter server --save-catalog --allow-build argon2
|
|
185
|
-
|
|
185
|
+
```
|
|
186
186
|
|
|
187
187
|
## 检查文件
|
|
188
188
|
|
|
189
189
|
打开 apps/server/package.json,确认 dependencies 中出现:
|
|
190
190
|
|
|
191
|
-
|
|
191
|
+
```json
|
|
192
192
|
{
|
|
193
193
|
"@fastify/cookie": "catalog:",
|
|
194
194
|
"@nestjs/jwt": "catalog:",
|
|
195
195
|
"argon2": "catalog:",
|
|
196
196
|
"fastify": "catalog:"
|
|
197
197
|
}
|
|
198
|
-
|
|
198
|
+
```
|
|
199
199
|
|
|
200
200
|
同时确认 pnpm-workspace.yaml 的 catalog 中出现对应版本。
|
|
201
201
|
|
|
@@ -207,9 +207,9 @@ vp add @nestjs/jwt @fastify/cookie fastify@5.8.5 argon2 --filter server --save-c
|
|
|
207
207
|
|
|
208
208
|
## 验收命令
|
|
209
209
|
|
|
210
|
-
|
|
210
|
+
```powershell
|
|
211
211
|
vp run "server#build"
|
|
212
|
-
|
|
212
|
+
```
|
|
213
213
|
|
|
214
214
|
## 完成检查点
|
|
215
215
|
|
|
@@ -228,13 +228,13 @@ vp run "server#build"
|
|
|
228
228
|
|
|
229
229
|
新增文件:
|
|
230
230
|
|
|
231
|
-
|
|
231
|
+
```text
|
|
232
232
|
apps/server/src/config/auth.config.ts
|
|
233
|
-
|
|
233
|
+
```
|
|
234
234
|
|
|
235
235
|
粘贴完整代码:
|
|
236
236
|
|
|
237
|
-
|
|
237
|
+
```ts
|
|
238
238
|
import { registerAs } from '@nestjs/config'
|
|
239
239
|
|
|
240
240
|
export interface AuthConfig {
|
|
@@ -295,58 +295,57 @@ export default registerAs(
|
|
|
295
295
|
cookieName: process.env.REFRESH_COOKIE_NAME ?? 'refresh_token',
|
|
296
296
|
cookiePath: process.env.REFRESH_COOKIE_PATH ?? '/auth',
|
|
297
297
|
cookieSecure:
|
|
298
|
-
process.env.REFRESH_COOKIE_SECURE === 'true' ||
|
|
299
|
-
process.env.NODE_ENV === 'production',
|
|
298
|
+
process.env.REFRESH_COOKIE_SECURE === 'true' || process.env.NODE_ENV === 'production',
|
|
300
299
|
cookieDomain: process.env.REFRESH_COOKIE_DOMAIN || undefined,
|
|
301
300
|
eventChannel: process.env.AUTH_EVENT_CHANNEL ?? 'auth-state:invalidate',
|
|
302
301
|
}),
|
|
303
302
|
)
|
|
304
|
-
|
|
303
|
+
```
|
|
305
304
|
|
|
306
305
|
这里的 15 分钟 tombstone 大于:
|
|
307
306
|
|
|
308
|
-
|
|
307
|
+
```text
|
|
309
308
|
10 分钟 Access JWT
|
|
310
309
|
+ 30 秒 L1 陈旧窗口
|
|
311
310
|
+ 30 秒时钟误差
|
|
312
|
-
|
|
311
|
+
```
|
|
313
312
|
|
|
314
313
|
## 2.2 导出 authConfig
|
|
315
314
|
|
|
316
315
|
修改文件:
|
|
317
316
|
|
|
318
|
-
|
|
317
|
+
```text
|
|
319
318
|
apps/server/src/config/index.ts
|
|
320
|
-
|
|
319
|
+
```
|
|
321
320
|
|
|
322
321
|
替换成:
|
|
323
322
|
|
|
324
|
-
|
|
323
|
+
```ts
|
|
325
324
|
export { default as appConfig } from './app.config'
|
|
326
325
|
export { default as authConfig } from './auth.config'
|
|
327
326
|
export { default as databaseConfig } from './database.config'
|
|
328
327
|
export { default as llmConfig } from './llm.config'
|
|
329
328
|
export { default as redisConfig } from './redis.config'
|
|
330
|
-
|
|
329
|
+
```
|
|
331
330
|
|
|
332
331
|
## 2.3 删除旧的 JWT_SECRET 配置入口
|
|
333
332
|
|
|
334
333
|
修改文件:
|
|
335
334
|
|
|
336
|
-
|
|
335
|
+
```text
|
|
337
336
|
apps/server/src/config/app.config.ts
|
|
338
|
-
|
|
337
|
+
```
|
|
339
338
|
|
|
340
339
|
替换成:
|
|
341
340
|
|
|
342
|
-
|
|
341
|
+
```ts
|
|
343
342
|
import { registerAs } from '@nestjs/config'
|
|
344
343
|
|
|
345
344
|
export default registerAs('app', () => ({
|
|
346
345
|
port: Number.parseInt(process.env.PORT ?? '3000', 10),
|
|
347
346
|
env: process.env.NODE_ENV ?? 'development',
|
|
348
347
|
}))
|
|
349
|
-
|
|
348
|
+
```
|
|
350
349
|
|
|
351
350
|
以后只使用 JWT_ACCESS_SECRET,不再同时维护 JWT_SECRET 和 JWT_ACCESS_SECRET 两套名字。
|
|
352
351
|
|
|
@@ -354,30 +353,25 @@ export default registerAs('app', () => ({
|
|
|
354
353
|
|
|
355
354
|
打开:
|
|
356
355
|
|
|
357
|
-
|
|
356
|
+
```text
|
|
358
357
|
apps/server/src/app.module.ts
|
|
359
|
-
|
|
358
|
+
```
|
|
360
359
|
|
|
361
360
|
把 config import 改成:
|
|
362
361
|
|
|
363
|
-
|
|
362
|
+
```ts
|
|
364
363
|
import { appConfig, authConfig, databaseConfig, llmConfig, redisConfig } from '@/config'
|
|
365
|
-
|
|
364
|
+
```
|
|
366
365
|
|
|
367
366
|
把 ConfigModule.forRoot 中的 envFilePath 和 load 改成:
|
|
368
367
|
|
|
369
|
-
|
|
368
|
+
```ts
|
|
370
369
|
ConfigModule.forRoot({
|
|
371
370
|
isGlobal: true,
|
|
372
|
-
envFilePath: [
|
|
373
|
-
'.env.' + nodeEnv + '.local',
|
|
374
|
-
'.env.local',
|
|
375
|
-
'.env.' + nodeEnv,
|
|
376
|
-
'.env',
|
|
377
|
-
],
|
|
371
|
+
envFilePath: ['.env.' + nodeEnv + '.local', '.env.local', '.env.' + nodeEnv, '.env'],
|
|
378
372
|
load: [appConfig, authConfig, databaseConfig, llmConfig, redisConfig],
|
|
379
373
|
})
|
|
380
|
-
|
|
374
|
+
```
|
|
381
375
|
|
|
382
376
|
local 文件排在前面。dotenv 在不启用 override 时保留先读到的值,因此真实本地密钥会优先生效。
|
|
383
377
|
|
|
@@ -385,20 +379,20 @@ local 文件排在前面。dotenv 在不启用 override 时保留先读到的值
|
|
|
385
379
|
|
|
386
380
|
先生成两个不同的随机值:
|
|
387
381
|
|
|
388
|
-
|
|
382
|
+
```powershell
|
|
389
383
|
node -e "console.log(require('node:crypto').randomBytes(48).toString('base64url'))"
|
|
390
384
|
node -e "console.log(require('node:crypto').randomBytes(48).toString('base64url'))"
|
|
391
|
-
|
|
385
|
+
```
|
|
392
386
|
|
|
393
387
|
新增文件:
|
|
394
388
|
|
|
395
|
-
|
|
389
|
+
```text
|
|
396
390
|
apps/server/.env.development.local
|
|
397
|
-
|
|
391
|
+
```
|
|
398
392
|
|
|
399
393
|
写入下面内容,把两个占位值替换成刚生成的两个不同随机值:
|
|
400
394
|
|
|
401
|
-
|
|
395
|
+
```dotenv
|
|
402
396
|
JWT_ACCESS_SECRET=替换成第一个随机值
|
|
403
397
|
REFRESH_TOKEN_PEPPER=替换成第二个随机值
|
|
404
398
|
JWT_ISSUER=bubbles-auth
|
|
@@ -414,15 +408,15 @@ REFRESH_COOKIE_SECURE=false
|
|
|
414
408
|
AUTH_LOCAL_CACHE_TTL_MS=30000
|
|
415
409
|
AUTH_TOMBSTONE_TTL_SECONDS=900
|
|
416
410
|
AUTH_EVENT_CHANNEL=auth-state:invalidate
|
|
417
|
-
|
|
411
|
+
```
|
|
418
412
|
|
|
419
|
-
注意:生产 HTTPS 可以使用
|
|
413
|
+
注意:生产 HTTPS 可以使用 **Host-refresh_token,但 **Host- 前缀不能设置 Domain,Path 必须是 /。本教程开发阶段使用 refresh_token 和 /auth,避免浏览器因前缀规则拒收 Cookie。
|
|
420
414
|
|
|
421
415
|
## 验收命令
|
|
422
416
|
|
|
423
|
-
|
|
417
|
+
```powershell
|
|
424
418
|
vp run "server#build"
|
|
425
|
-
|
|
419
|
+
```
|
|
426
420
|
|
|
427
421
|
如果启动时报 JWT_ACCESS_SECRET 未配置,说明 local 文件没有按上述路径创建,或 ConfigModule 的 envFilePath 没改对。
|
|
428
422
|
|
|
@@ -441,13 +435,13 @@ vp run "server#build"
|
|
|
441
435
|
|
|
442
436
|
## 修改文件
|
|
443
437
|
|
|
444
|
-
|
|
438
|
+
```text
|
|
445
439
|
apps/server/src/main.ts
|
|
446
|
-
|
|
440
|
+
```
|
|
447
441
|
|
|
448
442
|
替换为下面的完整内容:
|
|
449
443
|
|
|
450
|
-
|
|
444
|
+
```ts
|
|
451
445
|
import fastifyCookie from '@fastify/cookie'
|
|
452
446
|
import { NestFactory } from '@nestjs/core'
|
|
453
447
|
import { AppModule } from '@/app.module'
|
|
@@ -457,10 +451,7 @@ import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'
|
|
|
457
451
|
import { cleanupOpenApiDoc } from 'nestjs-zod'
|
|
458
452
|
|
|
459
453
|
async function bootstrap() {
|
|
460
|
-
const app = await NestFactory.create<NestFastifyApplication>(
|
|
461
|
-
AppModule,
|
|
462
|
-
new FastifyAdapter(),
|
|
463
|
-
)
|
|
454
|
+
const app = await NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter())
|
|
464
455
|
|
|
465
456
|
await app.register(fastifyCookie)
|
|
466
457
|
|
|
@@ -490,7 +481,7 @@ async function bootstrap() {
|
|
|
490
481
|
}
|
|
491
482
|
|
|
492
483
|
void bootstrap()
|
|
493
|
-
|
|
484
|
+
```
|
|
494
485
|
|
|
495
486
|
不要写 Express 风格的 res.cookie。后续 Controller 会使用 FastifyReply.setCookie。
|
|
496
487
|
|
|
@@ -503,9 +494,9 @@ Swagger 的 addBearerAuth 只定义了文档里的认证方案:
|
|
|
503
494
|
|
|
504
495
|
## 验收命令
|
|
505
496
|
|
|
506
|
-
|
|
497
|
+
```powershell
|
|
507
498
|
vp run "server#build"
|
|
508
|
-
|
|
499
|
+
```
|
|
509
500
|
|
|
510
501
|
## 完成检查点
|
|
511
502
|
|
|
@@ -525,13 +516,13 @@ vp run "server#build"
|
|
|
525
516
|
|
|
526
517
|
修改文件:
|
|
527
518
|
|
|
528
|
-
|
|
519
|
+
```text
|
|
529
520
|
apps/server/src/database/schema.ts
|
|
530
|
-
|
|
521
|
+
```
|
|
531
522
|
|
|
532
523
|
替换为下面的完整内容:
|
|
533
524
|
|
|
534
|
-
|
|
525
|
+
```ts
|
|
535
526
|
import {
|
|
536
527
|
index,
|
|
537
528
|
integer,
|
|
@@ -545,16 +536,9 @@ import {
|
|
|
545
536
|
varchar,
|
|
546
537
|
} from 'drizzle-orm/pg-core'
|
|
547
538
|
|
|
548
|
-
export const userStatusEnum = pgEnum('user_status', [
|
|
549
|
-
'active',
|
|
550
|
-
'locked',
|
|
551
|
-
'disabled',
|
|
552
|
-
])
|
|
539
|
+
export const userStatusEnum = pgEnum('user_status', ['active', 'locked', 'disabled'])
|
|
553
540
|
|
|
554
|
-
export const authSessionStatusEnum = pgEnum('auth_session_status', [
|
|
555
|
-
'active',
|
|
556
|
-
'revoked',
|
|
557
|
-
])
|
|
541
|
+
export const authSessionStatusEnum = pgEnum('auth_session_status', ['active', 'revoked'])
|
|
558
542
|
|
|
559
543
|
export const refreshFamilyStatusEnum = pgEnum('refresh_family_status', [
|
|
560
544
|
'active',
|
|
@@ -648,10 +632,7 @@ export const refreshTokens = pgTable(
|
|
|
648
632
|
},
|
|
649
633
|
(table) => [
|
|
650
634
|
uniqueIndex('refresh_tokens_token_hash_uidx').on(table.tokenHash),
|
|
651
|
-
uniqueIndex('refresh_tokens_family_generation_uidx').on(
|
|
652
|
-
table.familyId,
|
|
653
|
-
table.generation,
|
|
654
|
-
),
|
|
635
|
+
uniqueIndex('refresh_tokens_family_generation_uidx').on(table.familyId, table.generation),
|
|
655
636
|
index('refresh_tokens_family_status_idx').on(table.familyId, table.status),
|
|
656
637
|
],
|
|
657
638
|
)
|
|
@@ -693,7 +674,7 @@ export const authOutbox = pgTable(
|
|
|
693
674
|
),
|
|
694
675
|
],
|
|
695
676
|
)
|
|
696
|
-
|
|
677
|
+
```
|
|
697
678
|
|
|
698
679
|
## 4.2 修正 Drizzle 环境文件加载
|
|
699
680
|
|
|
@@ -701,24 +682,19 @@ export const authOutbox = pgTable(
|
|
|
701
682
|
|
|
702
683
|
修改文件:
|
|
703
684
|
|
|
704
|
-
|
|
685
|
+
```text
|
|
705
686
|
apps/server/drizzle.config.ts
|
|
706
|
-
|
|
687
|
+
```
|
|
707
688
|
|
|
708
689
|
替换为:
|
|
709
690
|
|
|
710
|
-
|
|
691
|
+
```ts
|
|
711
692
|
import { config } from 'dotenv'
|
|
712
693
|
import { defineConfig } from 'drizzle-kit'
|
|
713
694
|
|
|
714
695
|
const nodeEnv = process.env.NODE_ENV ?? 'development'
|
|
715
696
|
|
|
716
|
-
for (const path of [
|
|
717
|
-
'.env.' + nodeEnv + '.local',
|
|
718
|
-
'.env.local',
|
|
719
|
-
'.env.' + nodeEnv,
|
|
720
|
-
'.env',
|
|
721
|
-
]) {
|
|
697
|
+
for (const path of ['.env.' + nodeEnv + '.local', '.env.local', '.env.' + nodeEnv, '.env']) {
|
|
722
698
|
config({ path })
|
|
723
699
|
}
|
|
724
700
|
|
|
@@ -734,7 +710,7 @@ export default defineConfig({
|
|
|
734
710
|
url: process.env.DATABASE_URL,
|
|
735
711
|
},
|
|
736
712
|
})
|
|
737
|
-
|
|
713
|
+
```
|
|
738
714
|
|
|
739
715
|
## 4.3 修正现有测试 DTO
|
|
740
716
|
|
|
@@ -742,13 +718,13 @@ export default defineConfig({
|
|
|
742
718
|
|
|
743
719
|
修改:
|
|
744
720
|
|
|
745
|
-
|
|
721
|
+
```text
|
|
746
722
|
apps/server/src/modules/test/db/dto/create-user.dto.ts
|
|
747
|
-
|
|
723
|
+
```
|
|
748
724
|
|
|
749
725
|
把 omit 改为:
|
|
750
726
|
|
|
751
|
-
|
|
727
|
+
```ts
|
|
752
728
|
export const createUserSchema = baseSchema.omit({
|
|
753
729
|
id: true,
|
|
754
730
|
passwordHash: true,
|
|
@@ -758,7 +734,7 @@ export const createUserSchema = baseSchema.omit({
|
|
|
758
734
|
createdAt: true,
|
|
759
735
|
updatedAt: true,
|
|
760
736
|
})
|
|
761
|
-
|
|
737
|
+
```
|
|
762
738
|
|
|
763
739
|
这个 TestDbModule 只能当数据库示例,不能当正式注册接口。正式注册接口接收 password,由服务端生成 passwordHash,绝不能让客户端上传密码哈希。
|
|
764
740
|
|
|
@@ -768,9 +744,9 @@ export const createUserSchema = baseSchema.omit({
|
|
|
768
744
|
|
|
769
745
|
执行:
|
|
770
746
|
|
|
771
|
-
|
|
747
|
+
```powershell
|
|
772
748
|
vp run "server#db:generate"
|
|
773
|
-
|
|
749
|
+
```
|
|
774
750
|
|
|
775
751
|
不要马上 migrate。先打开 apps/server/drizzle 下新生成的 SQL,人工确认:
|
|
776
752
|
|
|
@@ -783,17 +759,17 @@ vp run "server#db:generate"
|
|
|
783
759
|
|
|
784
760
|
确认 SQL 后再执行:
|
|
785
761
|
|
|
786
|
-
|
|
762
|
+
```powershell
|
|
787
763
|
vp run "server#db:migrate"
|
|
788
764
|
vp run "server#build"
|
|
789
|
-
|
|
765
|
+
```
|
|
790
766
|
|
|
791
767
|
## 完成检查点
|
|
792
768
|
|
|
793
769
|
- 五张 Auth 相关表已经存在。
|
|
794
770
|
- 旧 users 数据没有因为 password_hash 而迁移失败。
|
|
795
771
|
- 两个 Refresh Token 唯一约束已存在。
|
|
796
|
-
- 迁移所用
|
|
772
|
+
- 迁移所用 DATABASE*URL 与服务运行时 DB*\* 指向同一数据库。
|
|
797
773
|
- 构建成功。
|
|
798
774
|
|
|
799
775
|
# 步骤 5:实现密码和 Token 安全原语
|
|
@@ -811,30 +787,30 @@ vp run "server#build"
|
|
|
811
787
|
|
|
812
788
|
新增:
|
|
813
789
|
|
|
814
|
-
|
|
790
|
+
```text
|
|
815
791
|
apps/server/src/common/constants/auth.ts
|
|
816
|
-
|
|
792
|
+
```
|
|
817
793
|
|
|
818
794
|
粘贴:
|
|
819
795
|
|
|
820
|
-
|
|
796
|
+
```ts
|
|
821
797
|
export const IS_PUBLIC_KEY = Symbol('is_public')
|
|
822
798
|
export const AUTH_CONSISTENCY_KEY = Symbol('auth_consistency')
|
|
823
799
|
|
|
824
800
|
export type AuthConsistency = 'bounded' | 'strong'
|
|
825
|
-
|
|
801
|
+
```
|
|
826
802
|
|
|
827
803
|
## 5.2 新增 Auth 类型
|
|
828
804
|
|
|
829
805
|
新增:
|
|
830
806
|
|
|
831
|
-
|
|
807
|
+
```text
|
|
832
808
|
apps/server/src/modules/auth/auth.types.ts
|
|
833
|
-
|
|
809
|
+
```
|
|
834
810
|
|
|
835
811
|
粘贴:
|
|
836
812
|
|
|
837
|
-
|
|
813
|
+
```ts
|
|
838
814
|
export interface AccessTokenClaims {
|
|
839
815
|
sub: string
|
|
840
816
|
sid: string
|
|
@@ -879,19 +855,19 @@ export interface IssuedTokenPair {
|
|
|
879
855
|
accessExpiresIn: number
|
|
880
856
|
refreshToken: string
|
|
881
857
|
}
|
|
882
|
-
|
|
858
|
+
```
|
|
883
859
|
|
|
884
860
|
## 5.3 新增 PasswordService
|
|
885
861
|
|
|
886
862
|
新增:
|
|
887
863
|
|
|
888
|
-
|
|
864
|
+
```text
|
|
889
865
|
apps/server/src/modules/auth/password.service.ts
|
|
890
|
-
|
|
866
|
+
```
|
|
891
867
|
|
|
892
868
|
粘贴:
|
|
893
869
|
|
|
894
|
-
|
|
870
|
+
```ts
|
|
895
871
|
import { Injectable } from '@nestjs/common'
|
|
896
872
|
import * as argon2 from 'argon2'
|
|
897
873
|
|
|
@@ -924,7 +900,7 @@ export class PasswordService {
|
|
|
924
900
|
}
|
|
925
901
|
}
|
|
926
902
|
}
|
|
927
|
-
|
|
903
|
+
```
|
|
928
904
|
|
|
929
905
|
当邮箱不存在时仍执行一次 Argon2 验证,减少通过响应时间枚举邮箱的差异。
|
|
930
906
|
|
|
@@ -932,13 +908,13 @@ export class PasswordService {
|
|
|
932
908
|
|
|
933
909
|
新增:
|
|
934
910
|
|
|
935
|
-
|
|
911
|
+
```text
|
|
936
912
|
apps/server/src/modules/auth/token.service.ts
|
|
937
|
-
|
|
913
|
+
```
|
|
938
914
|
|
|
939
915
|
粘贴:
|
|
940
916
|
|
|
941
|
-
|
|
917
|
+
```ts
|
|
942
918
|
import { Injectable, UnauthorizedException } from '@nestjs/common'
|
|
943
919
|
import { ConfigService } from '@nestjs/config'
|
|
944
920
|
import { JwtService } from '@nestjs/jwt'
|
|
@@ -962,9 +938,7 @@ export class RefreshTokenCodec {
|
|
|
962
938
|
hash(raw: string) {
|
|
963
939
|
const auth = this.config.getOrThrow<AuthConfig>('auth')
|
|
964
940
|
|
|
965
|
-
return createHmac('sha256', auth.refreshPepper)
|
|
966
|
-
.update(raw)
|
|
967
|
-
.digest('hex')
|
|
941
|
+
return createHmac('sha256', auth.refreshPepper).update(raw).digest('hex')
|
|
968
942
|
}
|
|
969
943
|
}
|
|
970
944
|
|
|
@@ -1021,8 +995,7 @@ export class AccessTokenService {
|
|
|
1021
995
|
Number.isSafeInteger(claims.iat) &&
|
|
1022
996
|
Number.isSafeInteger(claims.exp) &&
|
|
1023
997
|
claims.exp > claims.iat &&
|
|
1024
|
-
claims.exp - claims.iat <=
|
|
1025
|
-
auth.accessTtlSeconds + auth.clockToleranceSeconds
|
|
998
|
+
claims.exp - claims.iat <= auth.accessTtlSeconds + auth.clockToleranceSeconds
|
|
1026
999
|
|
|
1027
1000
|
if (!validShape) {
|
|
1028
1001
|
throw new UnauthorizedException('访问令牌载荷非法')
|
|
@@ -1031,7 +1004,7 @@ export class AccessTokenService {
|
|
|
1031
1004
|
return claims
|
|
1032
1005
|
}
|
|
1033
1006
|
}
|
|
1034
|
-
|
|
1007
|
+
```
|
|
1035
1008
|
|
|
1036
1009
|
Access JWT 的 iss、aud、exp 和签名算法稍后由 JwtModule 统一配置。不要手工从请求载荷信任这些字段。
|
|
1037
1010
|
|
|
@@ -1039,9 +1012,9 @@ Refresh Token 数据库只保存 hash。raw 只在创建时返回给 Controller
|
|
|
1039
1012
|
|
|
1040
1013
|
## 验收命令
|
|
1041
1014
|
|
|
1042
|
-
|
|
1015
|
+
```powershell
|
|
1043
1016
|
vp run "server#build"
|
|
1044
|
-
|
|
1017
|
+
```
|
|
1045
1018
|
|
|
1046
1019
|
## 完成检查点
|
|
1047
1020
|
|
|
@@ -1057,7 +1030,7 @@ vp run "server#build"
|
|
|
1057
1030
|
|
|
1058
1031
|
此时不要急着加入 30 秒缓存。先做一版所有保护接口都实时读取 Redis 的实现,验证下面的闭环:
|
|
1059
1032
|
|
|
1060
|
-
|
|
1033
|
+
```text
|
|
1061
1034
|
登录写入 Auth State
|
|
1062
1035
|
-> JWT 带 ae 和 sv
|
|
1063
1036
|
-> Guard 用一个 Lua 原子读取 Account + Session
|
|
@@ -1065,7 +1038,7 @@ vp run "server#build"
|
|
|
1065
1038
|
-> Redis 缺失或落后时,用一条 PostgreSQL JOIN 回源
|
|
1066
1039
|
-> 踢人或改密写入更高版本
|
|
1067
1040
|
-> 下一次强校验拒绝旧 JWT
|
|
1068
|
-
|
|
1041
|
+
```
|
|
1069
1042
|
|
|
1070
1043
|
这里有四条硬性不变量:
|
|
1071
1044
|
|
|
@@ -1078,13 +1051,13 @@ vp run "server#build"
|
|
|
1078
1051
|
|
|
1079
1052
|
新增:
|
|
1080
1053
|
|
|
1081
|
-
|
|
1054
|
+
```text
|
|
1082
1055
|
apps/server/src/modules/auth/state/auth-state.service.ts
|
|
1083
|
-
|
|
1056
|
+
```
|
|
1084
1057
|
|
|
1085
1058
|
粘贴完整代码:
|
|
1086
1059
|
|
|
1087
|
-
|
|
1060
|
+
```ts
|
|
1088
1061
|
import { InjectRedis } from '@nestjs-modules/ioredis'
|
|
1089
1062
|
import {
|
|
1090
1063
|
Inject,
|
|
@@ -1253,10 +1226,7 @@ export class AuthStateService {
|
|
|
1253
1226
|
private readonly config: ConfigService,
|
|
1254
1227
|
) {}
|
|
1255
1228
|
|
|
1256
|
-
async verify(
|
|
1257
|
-
claims: AccessTokenClaims,
|
|
1258
|
-
_consistency: AuthConsistency = 'strong',
|
|
1259
|
-
) {
|
|
1229
|
+
async verify(claims: AccessTokenClaims, _consistency: AuthConsistency = 'strong') {
|
|
1260
1230
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
1261
1231
|
const shared = await this.readShared(claims.sub, claims.sid)
|
|
1262
1232
|
|
|
@@ -1273,41 +1243,26 @@ export class AuthStateService {
|
|
|
1273
1243
|
return
|
|
1274
1244
|
}
|
|
1275
1245
|
|
|
1276
|
-
const database = await this.loadDatabaseSnapshot(
|
|
1277
|
-
claims.sub,
|
|
1278
|
-
claims.sid,
|
|
1279
|
-
)
|
|
1246
|
+
const database = await this.loadDatabaseSnapshot(claims.sub, claims.sid)
|
|
1280
1247
|
|
|
1281
1248
|
if (!database) {
|
|
1282
1249
|
throw new UnauthorizedException('登录状态不存在')
|
|
1283
1250
|
}
|
|
1284
1251
|
|
|
1285
|
-
if (
|
|
1286
|
-
|
|
1287
|
-
database.session.version < claims.sv
|
|
1288
|
-
) {
|
|
1289
|
-
throw new ServiceUnavailableException(
|
|
1290
|
-
'鉴权事实库版本落后于 JWT',
|
|
1291
|
-
)
|
|
1252
|
+
if (database.account.epoch < claims.ae || database.session.version < claims.sv) {
|
|
1253
|
+
throw new ServiceUnavailableException('鉴权事实库版本落后于 JWT')
|
|
1292
1254
|
}
|
|
1293
1255
|
|
|
1294
|
-
const [accountProjection, sessionProjection] =
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
])
|
|
1256
|
+
const [accountProjection, sessionProjection] = await Promise.all([
|
|
1257
|
+
this.projectAccount(database.account),
|
|
1258
|
+
this.projectSession(database.session),
|
|
1259
|
+
])
|
|
1299
1260
|
|
|
1300
|
-
if (
|
|
1301
|
-
accountProjection === 'stale' ||
|
|
1302
|
-
sessionProjection === 'stale'
|
|
1303
|
-
) {
|
|
1261
|
+
if (accountProjection === 'stale' || sessionProjection === 'stale') {
|
|
1304
1262
|
continue
|
|
1305
1263
|
}
|
|
1306
1264
|
|
|
1307
|
-
const repaired = await this.readShared(
|
|
1308
|
-
claims.sub,
|
|
1309
|
-
claims.sid,
|
|
1310
|
-
)
|
|
1265
|
+
const repaired = await this.readShared(claims.sub, claims.sid)
|
|
1311
1266
|
|
|
1312
1267
|
this.throwIfSharedIsNewer(repaired, claims)
|
|
1313
1268
|
|
|
@@ -1323,18 +1278,14 @@ export class AuthStateService {
|
|
|
1323
1278
|
}
|
|
1324
1279
|
}
|
|
1325
1280
|
|
|
1326
|
-
throw new ServiceUnavailableException(
|
|
1327
|
-
'鉴权状态正在变化,请稍后重试',
|
|
1328
|
-
)
|
|
1281
|
+
throw new ServiceUnavailableException('鉴权状态正在变化,请稍后重试')
|
|
1329
1282
|
}
|
|
1330
1283
|
|
|
1331
1284
|
async writeThroughAccount(state: AccountAuthState) {
|
|
1332
1285
|
const result = await this.projectAccount(state)
|
|
1333
1286
|
|
|
1334
1287
|
if (result === 'stale') {
|
|
1335
|
-
throw new ServiceUnavailableException(
|
|
1336
|
-
'账号鉴权状态已经存在更高版本',
|
|
1337
|
-
)
|
|
1288
|
+
throw new ServiceUnavailableException('账号鉴权状态已经存在更高版本')
|
|
1338
1289
|
}
|
|
1339
1290
|
}
|
|
1340
1291
|
|
|
@@ -1342,9 +1293,7 @@ export class AuthStateService {
|
|
|
1342
1293
|
const result = await this.projectSession(state)
|
|
1343
1294
|
|
|
1344
1295
|
if (result === 'stale') {
|
|
1345
|
-
throw new ServiceUnavailableException(
|
|
1346
|
-
'Session 鉴权状态已经存在更高版本',
|
|
1347
|
-
)
|
|
1296
|
+
throw new ServiceUnavailableException('Session 鉴权状态已经存在更高版本')
|
|
1348
1297
|
}
|
|
1349
1298
|
}
|
|
1350
1299
|
|
|
@@ -1377,11 +1326,7 @@ export class AuthStateService {
|
|
|
1377
1326
|
] = raw.map((value) => String(value ?? ''))
|
|
1378
1327
|
|
|
1379
1328
|
return {
|
|
1380
|
-
account: this.parseAccount(
|
|
1381
|
-
userId,
|
|
1382
|
-
accountEpoch,
|
|
1383
|
-
accountStatus,
|
|
1384
|
-
),
|
|
1329
|
+
account: this.parseAccount(userId, accountEpoch, accountStatus),
|
|
1385
1330
|
session: this.parseSession(
|
|
1386
1331
|
userId,
|
|
1387
1332
|
sessionId,
|
|
@@ -1392,9 +1337,7 @@ export class AuthStateService {
|
|
|
1392
1337
|
),
|
|
1393
1338
|
}
|
|
1394
1339
|
} catch {
|
|
1395
|
-
throw new ServiceUnavailableException(
|
|
1396
|
-
'共享鉴权状态暂时不可用',
|
|
1397
|
-
)
|
|
1340
|
+
throw new ServiceUnavailableException('共享鉴权状态暂时不可用')
|
|
1398
1341
|
}
|
|
1399
1342
|
}
|
|
1400
1343
|
|
|
@@ -1404,10 +1347,7 @@ export class AuthStateService {
|
|
|
1404
1347
|
): Promise<AuthStateSnapshot | null> {
|
|
1405
1348
|
const numericUserId = Number(userId)
|
|
1406
1349
|
|
|
1407
|
-
if (
|
|
1408
|
-
!Number.isSafeInteger(numericUserId) ||
|
|
1409
|
-
numericUserId <= 0
|
|
1410
|
-
) {
|
|
1350
|
+
if (!Number.isSafeInteger(numericUserId) || numericUserId <= 0) {
|
|
1411
1351
|
return null
|
|
1412
1352
|
}
|
|
1413
1353
|
|
|
@@ -1425,10 +1365,7 @@ export class AuthStateService {
|
|
|
1425
1365
|
.from(users)
|
|
1426
1366
|
.innerJoin(
|
|
1427
1367
|
authSessions,
|
|
1428
|
-
and(
|
|
1429
|
-
eq(authSessions.userId, users.id),
|
|
1430
|
-
eq(authSessions.id, sessionId),
|
|
1431
|
-
),
|
|
1368
|
+
and(eq(authSessions.userId, users.id), eq(authSessions.id, sessionId)),
|
|
1432
1369
|
)
|
|
1433
1370
|
.where(eq(users.id, numericUserId))
|
|
1434
1371
|
.limit(1)
|
|
@@ -1452,15 +1389,11 @@ export class AuthStateService {
|
|
|
1452
1389
|
},
|
|
1453
1390
|
}
|
|
1454
1391
|
} catch {
|
|
1455
|
-
throw new ServiceUnavailableException(
|
|
1456
|
-
'鉴权事实库暂时不可用',
|
|
1457
|
-
)
|
|
1392
|
+
throw new ServiceUnavailableException('鉴权事实库暂时不可用')
|
|
1458
1393
|
}
|
|
1459
1394
|
}
|
|
1460
1395
|
|
|
1461
|
-
private async projectAccount(
|
|
1462
|
-
state: AccountAuthState,
|
|
1463
|
-
): Promise<ProjectionWriteResult> {
|
|
1396
|
+
private async projectAccount(state: AccountAuthState): Promise<ProjectionWriteResult> {
|
|
1464
1397
|
let raw: unknown
|
|
1465
1398
|
|
|
1466
1399
|
try {
|
|
@@ -1472,21 +1405,15 @@ export class AuthStateService {
|
|
|
1472
1405
|
state.status,
|
|
1473
1406
|
)
|
|
1474
1407
|
} catch {
|
|
1475
|
-
throw new ServiceUnavailableException(
|
|
1476
|
-
'账号鉴权状态暂时不可用',
|
|
1477
|
-
)
|
|
1408
|
+
throw new ServiceUnavailableException('账号鉴权状态暂时不可用')
|
|
1478
1409
|
}
|
|
1479
1410
|
|
|
1480
1411
|
return this.decodeProjectionResult(raw, '账号')
|
|
1481
1412
|
}
|
|
1482
1413
|
|
|
1483
|
-
private async projectSession(
|
|
1484
|
-
state: SessionAuthState,
|
|
1485
|
-
): Promise<ProjectionWriteResult> {
|
|
1414
|
+
private async projectSession(state: SessionAuthState): Promise<ProjectionWriteResult> {
|
|
1486
1415
|
const auth = this.config.getOrThrow<AuthConfig>('auth')
|
|
1487
|
-
const activeTtl = Math.ceil(
|
|
1488
|
-
(state.expiresAtMs - Date.now()) / 1000,
|
|
1489
|
-
)
|
|
1416
|
+
const activeTtl = Math.ceil((state.expiresAtMs - Date.now()) / 1000)
|
|
1490
1417
|
const ttlSeconds =
|
|
1491
1418
|
state.status === 'revoked'
|
|
1492
1419
|
? auth.tombstoneTtlSeconds
|
|
@@ -1506,18 +1433,13 @@ export class AuthStateService {
|
|
|
1506
1433
|
String(ttlSeconds),
|
|
1507
1434
|
)
|
|
1508
1435
|
} catch {
|
|
1509
|
-
throw new ServiceUnavailableException(
|
|
1510
|
-
'Session 鉴权状态暂时不可用',
|
|
1511
|
-
)
|
|
1436
|
+
throw new ServiceUnavailableException('Session 鉴权状态暂时不可用')
|
|
1512
1437
|
}
|
|
1513
1438
|
|
|
1514
1439
|
return this.decodeProjectionResult(raw, 'Session')
|
|
1515
1440
|
}
|
|
1516
1441
|
|
|
1517
|
-
private decodeProjectionResult(
|
|
1518
|
-
raw: unknown,
|
|
1519
|
-
aggregateName: string,
|
|
1520
|
-
): ProjectionWriteResult {
|
|
1442
|
+
private decodeProjectionResult(raw: unknown, aggregateName: string): ProjectionWriteResult {
|
|
1521
1443
|
const code = Number(raw)
|
|
1522
1444
|
|
|
1523
1445
|
if (code === 1) {
|
|
@@ -1532,9 +1454,7 @@ export class AuthStateService {
|
|
|
1532
1454
|
return 'stale'
|
|
1533
1455
|
}
|
|
1534
1456
|
|
|
1535
|
-
throw new ServiceUnavailableException(
|
|
1536
|
-
aggregateName + '鉴权状态存在版本冲突',
|
|
1537
|
-
)
|
|
1457
|
+
throw new ServiceUnavailableException(aggregateName + '鉴权状态存在版本冲突')
|
|
1538
1458
|
}
|
|
1539
1459
|
|
|
1540
1460
|
private throwIfSharedIsNewer(
|
|
@@ -1605,17 +1525,9 @@ export class AuthStateService {
|
|
|
1605
1525
|
}
|
|
1606
1526
|
}
|
|
1607
1527
|
|
|
1608
|
-
private assertAllowed(
|
|
1609
|
-
snapshot
|
|
1610
|
-
|
|
1611
|
-
) {
|
|
1612
|
-
if (
|
|
1613
|
-
snapshot.account.epoch < claims.ae ||
|
|
1614
|
-
snapshot.session.version < claims.sv
|
|
1615
|
-
) {
|
|
1616
|
-
throw new ServiceUnavailableException(
|
|
1617
|
-
'鉴权状态投影落后',
|
|
1618
|
-
)
|
|
1528
|
+
private assertAllowed(snapshot: AuthStateSnapshot, claims: AccessTokenClaims) {
|
|
1529
|
+
if (snapshot.account.epoch < claims.ae || snapshot.session.version < claims.sv) {
|
|
1530
|
+
throw new ServiceUnavailableException('鉴权状态投影落后')
|
|
1619
1531
|
}
|
|
1620
1532
|
|
|
1621
1533
|
const accepted =
|
|
@@ -1631,18 +1543,18 @@ export class AuthStateService {
|
|
|
1631
1543
|
}
|
|
1632
1544
|
}
|
|
1633
1545
|
}
|
|
1634
|
-
|
|
1546
|
+
```
|
|
1635
1547
|
|
|
1636
1548
|
两个 Redis key 都带相同的 {userId} hash tag,所以即使以后使用 Redis Cluster,也会落在同一个 slot;READ_AUTH_STATE_LUA 可以一次看到账号和 Session 的同一 Redis 执行时点。
|
|
1637
1549
|
|
|
1638
1550
|
ACCOUNT_PROJECTION_LUA 和 SESSION_PROJECTION_LUA 的返回值含义是:
|
|
1639
1551
|
|
|
1640
|
-
| 返回值
|
|
1641
|
-
|
|
|
1642
|
-
| 1
|
|
1643
|
-
| 2
|
|
1644
|
-
| 0
|
|
1645
|
-
| -1 / -2 | 同版本字段冲突、revoked 复活、参数或投影损坏 | 返回 503 并报警
|
|
1552
|
+
| 返回值 | 含义 | 处理 |
|
|
1553
|
+
| ------- | -------------------------------------------- | ------------------- |
|
|
1554
|
+
| 1 | 新版本已写入 | 继续 |
|
|
1555
|
+
| 2 | 相同版本、所有字段完全相同 | 幂等成功 |
|
|
1556
|
+
| 0 | Redis 已有更高版本 | 重新 Pull,不能覆盖 |
|
|
1557
|
+
| -1 / -2 | 同版本字段冲突、revoked 复活、参数或投影损坏 | 返回 503 并报警 |
|
|
1646
1558
|
|
|
1647
1559
|
不要把“相同 version、不同 status”当作可覆盖更新。状态变化而版本不变,是写路径违反不变量,应立即暴露。
|
|
1648
1560
|
|
|
@@ -1650,9 +1562,9 @@ ACCOUNT_PROJECTION_LUA 和 SESSION_PROJECTION_LUA 的返回值含义是:
|
|
|
1650
1562
|
|
|
1651
1563
|
执行:
|
|
1652
1564
|
|
|
1653
|
-
|
|
1565
|
+
```powershell
|
|
1654
1566
|
vp run "server#build"
|
|
1655
|
-
|
|
1567
|
+
```
|
|
1656
1568
|
|
|
1657
1569
|
此时 AuthModule 还没有在后续步骤完整接入,所以这里只做类型和编译验收。
|
|
1658
1570
|
|
|
@@ -1671,7 +1583,7 @@ vp run "server#build"
|
|
|
1671
1583
|
|
|
1672
1584
|
本步骤完成:
|
|
1673
1585
|
|
|
1674
|
-
|
|
1586
|
+
```text
|
|
1675
1587
|
注册账号
|
|
1676
1588
|
-> 服务端 Argon2id 哈希密码
|
|
1677
1589
|
-> 登录校验密码
|
|
@@ -1679,7 +1591,7 @@ vp run "server#build"
|
|
|
1679
1591
|
-> Redis 写入账号和 Session 状态
|
|
1680
1592
|
-> 返回 Access JWT
|
|
1681
1593
|
-> Refresh Token 写入 HttpOnly Cookie
|
|
1682
|
-
|
|
1594
|
+
```
|
|
1683
1595
|
|
|
1684
1596
|
教程增加一个公开注册接口只是为了让你能独立完成验收。如果你的产品不允许用户自注册,完成测试后删除 register Controller 路由,把注册能力放到管理员或邀请流程中。
|
|
1685
1597
|
|
|
@@ -1687,18 +1599,18 @@ vp run "server#build"
|
|
|
1687
1599
|
|
|
1688
1600
|
新增:
|
|
1689
1601
|
|
|
1690
|
-
|
|
1602
|
+
```text
|
|
1691
1603
|
apps/server/src/common/decorators/public.decorator.ts
|
|
1692
|
-
|
|
1604
|
+
```
|
|
1693
1605
|
|
|
1694
1606
|
粘贴:
|
|
1695
1607
|
|
|
1696
|
-
|
|
1608
|
+
```ts
|
|
1697
1609
|
import { SetMetadata } from '@nestjs/common'
|
|
1698
1610
|
import { IS_PUBLIC_KEY } from '@/common/constants/auth'
|
|
1699
1611
|
|
|
1700
1612
|
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true)
|
|
1701
|
-
|
|
1613
|
+
```
|
|
1702
1614
|
|
|
1703
1615
|
它和现有 Bypass 的职责完全不同:
|
|
1704
1616
|
|
|
@@ -1711,13 +1623,13 @@ export const Public = () => SetMetadata(IS_PUBLIC_KEY, true)
|
|
|
1711
1623
|
|
|
1712
1624
|
新增:
|
|
1713
1625
|
|
|
1714
|
-
|
|
1626
|
+
```text
|
|
1715
1627
|
apps/server/src/modules/auth/dto/register.dto.ts
|
|
1716
|
-
|
|
1628
|
+
```
|
|
1717
1629
|
|
|
1718
1630
|
粘贴:
|
|
1719
1631
|
|
|
1720
|
-
|
|
1632
|
+
```ts
|
|
1721
1633
|
import { createZodDto } from 'nestjs-zod'
|
|
1722
1634
|
import { z } from 'zod'
|
|
1723
1635
|
|
|
@@ -1728,17 +1640,17 @@ const RegisterSchema = z.object({
|
|
|
1728
1640
|
})
|
|
1729
1641
|
|
|
1730
1642
|
export class RegisterDto extends createZodDto(RegisterSchema) {}
|
|
1731
|
-
|
|
1643
|
+
```
|
|
1732
1644
|
|
|
1733
1645
|
新增:
|
|
1734
1646
|
|
|
1735
|
-
|
|
1647
|
+
```text
|
|
1736
1648
|
apps/server/src/modules/auth/dto/login.dto.ts
|
|
1737
|
-
|
|
1649
|
+
```
|
|
1738
1650
|
|
|
1739
1651
|
粘贴:
|
|
1740
1652
|
|
|
1741
|
-
|
|
1653
|
+
```ts
|
|
1742
1654
|
import { createZodDto } from 'nestjs-zod'
|
|
1743
1655
|
import { z } from 'zod'
|
|
1744
1656
|
|
|
@@ -1748,27 +1660,23 @@ const LoginSchema = z.object({
|
|
|
1748
1660
|
})
|
|
1749
1661
|
|
|
1750
1662
|
export class LoginDto extends createZodDto(LoginSchema) {}
|
|
1751
|
-
|
|
1663
|
+
```
|
|
1752
1664
|
|
|
1753
1665
|
## 7.3 新增 Refresh Cookie Helper
|
|
1754
1666
|
|
|
1755
1667
|
新增:
|
|
1756
1668
|
|
|
1757
|
-
|
|
1669
|
+
```text
|
|
1758
1670
|
apps/server/src/modules/auth/http/refresh-cookie.ts
|
|
1759
|
-
|
|
1671
|
+
```
|
|
1760
1672
|
|
|
1761
1673
|
粘贴:
|
|
1762
1674
|
|
|
1763
|
-
|
|
1675
|
+
```ts
|
|
1764
1676
|
import type { FastifyReply } from 'fastify'
|
|
1765
1677
|
import type { AuthConfig } from '@/config/auth.config'
|
|
1766
1678
|
|
|
1767
|
-
export function setRefreshCookie(
|
|
1768
|
-
reply: FastifyReply,
|
|
1769
|
-
token: string,
|
|
1770
|
-
config: AuthConfig,
|
|
1771
|
-
) {
|
|
1679
|
+
export function setRefreshCookie(reply: FastifyReply, token: string, config: AuthConfig) {
|
|
1772
1680
|
reply.setCookie(config.cookieName, token, {
|
|
1773
1681
|
httpOnly: true,
|
|
1774
1682
|
secure: config.cookieSecure,
|
|
@@ -1779,10 +1687,7 @@ export function setRefreshCookie(
|
|
|
1779
1687
|
})
|
|
1780
1688
|
}
|
|
1781
1689
|
|
|
1782
|
-
export function clearRefreshCookie(
|
|
1783
|
-
reply: FastifyReply,
|
|
1784
|
-
config: AuthConfig,
|
|
1785
|
-
) {
|
|
1690
|
+
export function clearRefreshCookie(reply: FastifyReply, config: AuthConfig) {
|
|
1786
1691
|
reply.clearCookie(config.cookieName, {
|
|
1787
1692
|
httpOnly: true,
|
|
1788
1693
|
secure: config.cookieSecure,
|
|
@@ -1791,7 +1696,7 @@ export function clearRefreshCookie(
|
|
|
1791
1696
|
domain: config.cookieDomain,
|
|
1792
1697
|
})
|
|
1793
1698
|
}
|
|
1794
|
-
|
|
1699
|
+
```
|
|
1795
1700
|
|
|
1796
1701
|
如果前端和 API 跨站部署,SameSite、Secure、CORS 和 CSRF 需要一起设计,不能只把 sameSite 改成 none 就结束。当前教程按同站部署处理。
|
|
1797
1702
|
|
|
@@ -1799,22 +1704,17 @@ export function clearRefreshCookie(
|
|
|
1799
1704
|
|
|
1800
1705
|
新增:
|
|
1801
1706
|
|
|
1802
|
-
|
|
1707
|
+
```text
|
|
1803
1708
|
apps/server/src/modules/auth/auth.repository.ts
|
|
1804
|
-
|
|
1709
|
+
```
|
|
1805
1710
|
|
|
1806
1711
|
粘贴:
|
|
1807
1712
|
|
|
1808
|
-
|
|
1713
|
+
```ts
|
|
1809
1714
|
import { Inject, Injectable } from '@nestjs/common'
|
|
1810
1715
|
import { eq } from 'drizzle-orm'
|
|
1811
1716
|
import { DRIZZLE, type DrizzleDB } from '@/database/db.module'
|
|
1812
|
-
import {
|
|
1813
|
-
authSessions,
|
|
1814
|
-
refreshTokenFamilies,
|
|
1815
|
-
refreshTokens,
|
|
1816
|
-
users,
|
|
1817
|
-
} from '@/database/schema'
|
|
1717
|
+
import { authSessions, refreshTokenFamilies, refreshTokens, users } from '@/database/schema'
|
|
1818
1718
|
|
|
1819
1719
|
@Injectable()
|
|
1820
1720
|
export class AuthRepository {
|
|
@@ -1832,11 +1732,7 @@ export class AuthRepository {
|
|
|
1832
1732
|
})
|
|
1833
1733
|
}
|
|
1834
1734
|
|
|
1835
|
-
async createUser(input: {
|
|
1836
|
-
name: string
|
|
1837
|
-
email: string
|
|
1838
|
-
passwordHash: string
|
|
1839
|
-
}) {
|
|
1735
|
+
async createUser(input: { name: string; email: string; passwordHash: string }) {
|
|
1840
1736
|
const [user] = await this.db
|
|
1841
1737
|
.insert(users)
|
|
1842
1738
|
.values({
|
|
@@ -1889,24 +1785,20 @@ export class AuthRepository {
|
|
|
1889
1785
|
})
|
|
1890
1786
|
}
|
|
1891
1787
|
}
|
|
1892
|
-
|
|
1788
|
+
```
|
|
1893
1789
|
|
|
1894
1790
|
## 7.5 新增 AuthService
|
|
1895
1791
|
|
|
1896
1792
|
新增:
|
|
1897
1793
|
|
|
1898
|
-
|
|
1794
|
+
```text
|
|
1899
1795
|
apps/server/src/modules/auth/auth.service.ts
|
|
1900
|
-
|
|
1796
|
+
```
|
|
1901
1797
|
|
|
1902
1798
|
粘贴:
|
|
1903
1799
|
|
|
1904
|
-
|
|
1905
|
-
import {
|
|
1906
|
-
ConflictException,
|
|
1907
|
-
Injectable,
|
|
1908
|
-
UnauthorizedException,
|
|
1909
|
-
} from '@nestjs/common'
|
|
1800
|
+
```ts
|
|
1801
|
+
import { ConflictException, Injectable, UnauthorizedException } from '@nestjs/common'
|
|
1910
1802
|
import { ConfigService } from '@nestjs/config'
|
|
1911
1803
|
import type { AuthConfig } from '@/config/auth.config'
|
|
1912
1804
|
import { AuthRepository } from './auth.repository'
|
|
@@ -1926,11 +1818,7 @@ export class AuthService {
|
|
|
1926
1818
|
private readonly config: ConfigService,
|
|
1927
1819
|
) {}
|
|
1928
1820
|
|
|
1929
|
-
async register(input: {
|
|
1930
|
-
name: string
|
|
1931
|
-
email: string
|
|
1932
|
-
password: string
|
|
1933
|
-
}) {
|
|
1821
|
+
async register(input: { name: string; email: string; password: string }) {
|
|
1934
1822
|
const email = input.email.trim().toLowerCase()
|
|
1935
1823
|
const existing = await this.repository.findUserByEmail(email)
|
|
1936
1824
|
|
|
@@ -1973,17 +1861,9 @@ export class AuthService {
|
|
|
1973
1861
|
): Promise<IssuedTokenPair> {
|
|
1974
1862
|
const email = input.email.trim().toLowerCase()
|
|
1975
1863
|
const user = await this.repository.findUserByEmail(email)
|
|
1976
|
-
const passwordMatched = await this.passwords.verify(
|
|
1977
|
-
user?.passwordHash ?? null,
|
|
1978
|
-
input.password,
|
|
1979
|
-
)
|
|
1864
|
+
const passwordMatched = await this.passwords.verify(user?.passwordHash ?? null, input.password)
|
|
1980
1865
|
|
|
1981
|
-
if (
|
|
1982
|
-
!user ||
|
|
1983
|
-
!passwordMatched ||
|
|
1984
|
-
!user.passwordHash ||
|
|
1985
|
-
user.status !== 'active'
|
|
1986
|
-
) {
|
|
1866
|
+
if (!user || !passwordMatched || !user.passwordHash || user.status !== 'active') {
|
|
1987
1867
|
throw new UnauthorizedException('邮箱或密码错误')
|
|
1988
1868
|
}
|
|
1989
1869
|
|
|
@@ -2028,7 +1908,7 @@ export class AuthService {
|
|
|
2028
1908
|
}
|
|
2029
1909
|
}
|
|
2030
1910
|
}
|
|
2031
|
-
|
|
1911
|
+
```
|
|
2032
1912
|
|
|
2033
1913
|
登录事务提交后才写 Redis。如果 Redis 此时不可用,接口返回 503,客户端拿不到 Token;数据库里可能留下一个不会被使用的 Session,它会在 expiresAt 后由清理任务删除。步骤 13 的 Outbox 只保障撤销、禁用和版本提升类状态,不恢复登录创建投影。
|
|
2034
1914
|
|
|
@@ -2036,13 +1916,13 @@ export class AuthService {
|
|
|
2036
1916
|
|
|
2037
1917
|
新增:
|
|
2038
1918
|
|
|
2039
|
-
|
|
1919
|
+
```text
|
|
2040
1920
|
apps/server/src/modules/auth/auth.controller.ts
|
|
2041
|
-
|
|
1921
|
+
```
|
|
2042
1922
|
|
|
2043
1923
|
粘贴:
|
|
2044
1924
|
|
|
2045
|
-
|
|
1925
|
+
```ts
|
|
2046
1926
|
import { Body, Controller, Post, Req, Res } from '@nestjs/common'
|
|
2047
1927
|
import { ConfigService } from '@nestjs/config'
|
|
2048
1928
|
import { ApiTags } from '@nestjs/swagger'
|
|
@@ -2089,7 +1969,7 @@ export class AuthController {
|
|
|
2089
1969
|
}
|
|
2090
1970
|
}
|
|
2091
1971
|
}
|
|
2092
|
-
|
|
1972
|
+
```
|
|
2093
1973
|
|
|
2094
1974
|
Refresh Token 不出现在 JSON 响应,只通过 Set-Cookie 返回。
|
|
2095
1975
|
|
|
@@ -2097,13 +1977,13 @@ Refresh Token 不出现在 JSON 响应,只通过 Set-Cookie 返回。
|
|
|
2097
1977
|
|
|
2098
1978
|
新增:
|
|
2099
1979
|
|
|
2100
|
-
|
|
1980
|
+
```text
|
|
2101
1981
|
apps/server/src/modules/auth/auth.module.ts
|
|
2102
|
-
|
|
1982
|
+
```
|
|
2103
1983
|
|
|
2104
1984
|
粘贴:
|
|
2105
1985
|
|
|
2106
|
-
|
|
1986
|
+
```ts
|
|
2107
1987
|
import { Module } from '@nestjs/common'
|
|
2108
1988
|
import { ConfigService } from '@nestjs/config'
|
|
2109
1989
|
import { JwtModule, type JwtModuleOptions } from '@nestjs/jwt'
|
|
@@ -2152,7 +2032,7 @@ import { AccessTokenService, RefreshTokenCodec } from './token.service'
|
|
|
2152
2032
|
exports: [AuthService, AuthStateService],
|
|
2153
2033
|
})
|
|
2154
2034
|
export class AuthModule {}
|
|
2155
|
-
|
|
2035
|
+
```
|
|
2156
2036
|
|
|
2157
2037
|
HS256 只用于当前单体施工。多微服务生产环境不要把同一个对称密钥发给所有服务,最后一章会说明如何改成私钥签发、公钥验签。
|
|
2158
2038
|
|
|
@@ -2160,26 +2040,20 @@ HS256 只用于当前单体施工。多微服务生产环境不要把同一个
|
|
|
2160
2040
|
|
|
2161
2041
|
修改:
|
|
2162
2042
|
|
|
2163
|
-
|
|
2043
|
+
```text
|
|
2164
2044
|
apps/server/src/app.module.ts
|
|
2165
|
-
|
|
2045
|
+
```
|
|
2166
2046
|
|
|
2167
2047
|
替换为以下完整内容:
|
|
2168
2048
|
|
|
2169
|
-
|
|
2049
|
+
```ts
|
|
2170
2050
|
import { RedisModule } from '@nestjs-modules/ioredis'
|
|
2171
2051
|
import { Module } from '@nestjs/common'
|
|
2172
2052
|
import { ConfigModule, ConfigService } from '@nestjs/config'
|
|
2173
2053
|
import { APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core'
|
|
2174
2054
|
import { ZodSerializerInterceptor, ZodValidationPipe } from 'nestjs-zod'
|
|
2175
2055
|
import { ResponseInterceptor } from '@/common/interceptors/transform'
|
|
2176
|
-
import {
|
|
2177
|
-
appConfig,
|
|
2178
|
-
authConfig,
|
|
2179
|
-
databaseConfig,
|
|
2180
|
-
llmConfig,
|
|
2181
|
-
redisConfig,
|
|
2182
|
-
} from '@/config'
|
|
2056
|
+
import { appConfig, authConfig, databaseConfig, llmConfig, redisConfig } from '@/config'
|
|
2183
2057
|
import { DatabaseModule } from './database/db.module'
|
|
2184
2058
|
import { AppController } from './app.controller'
|
|
2185
2059
|
import { AppService } from './app.service'
|
|
@@ -2193,12 +2067,7 @@ const nodeEnv = process.env.NODE_ENV ?? 'development'
|
|
|
2193
2067
|
imports: [
|
|
2194
2068
|
ConfigModule.forRoot({
|
|
2195
2069
|
isGlobal: true,
|
|
2196
|
-
envFilePath: [
|
|
2197
|
-
'.env.' + nodeEnv + '.local',
|
|
2198
|
-
'.env.local',
|
|
2199
|
-
'.env.' + nodeEnv,
|
|
2200
|
-
'.env',
|
|
2201
|
-
],
|
|
2070
|
+
envFilePath: ['.env.' + nodeEnv + '.local', '.env.local', '.env.' + nodeEnv, '.env'],
|
|
2202
2071
|
load: [appConfig, authConfig, databaseConfig, llmConfig, redisConfig],
|
|
2203
2072
|
}),
|
|
2204
2073
|
DatabaseModule,
|
|
@@ -2238,22 +2107,22 @@ const nodeEnv = process.env.NODE_ENV ?? 'development'
|
|
|
2238
2107
|
],
|
|
2239
2108
|
})
|
|
2240
2109
|
export class AppModule {}
|
|
2241
|
-
|
|
2110
|
+
```
|
|
2242
2111
|
|
|
2243
2112
|
## 7.9 构建并启动
|
|
2244
2113
|
|
|
2245
2114
|
执行:
|
|
2246
2115
|
|
|
2247
|
-
|
|
2116
|
+
```powershell
|
|
2248
2117
|
vp run "server#build"
|
|
2249
2118
|
vp run "server#dev"
|
|
2250
|
-
|
|
2119
|
+
```
|
|
2251
2120
|
|
|
2252
2121
|
## 7.10 手工验收注册
|
|
2253
2122
|
|
|
2254
2123
|
另开 PowerShell:
|
|
2255
2124
|
|
|
2256
|
-
|
|
2125
|
+
```powershell
|
|
2257
2126
|
$body = @{
|
|
2258
2127
|
name = "测试用户"
|
|
2259
2128
|
email = "auth-test@example.com"
|
|
@@ -2261,11 +2130,11 @@ $body = @{
|
|
|
2261
2130
|
} | ConvertTo-Json
|
|
2262
2131
|
|
|
2263
2132
|
Invoke-RestMethod -Method Post -Uri "http://localhost:3000/auth/register" -ContentType "application/json" -Body $body
|
|
2264
|
-
|
|
2133
|
+
```
|
|
2265
2134
|
|
|
2266
2135
|
由于项目已有 ResponseInterceptor,成功响应不是裸数据,而是:
|
|
2267
2136
|
|
|
2268
|
-
|
|
2137
|
+
```json
|
|
2269
2138
|
{
|
|
2270
2139
|
"code": 200,
|
|
2271
2140
|
"data": {
|
|
@@ -2275,11 +2144,11 @@ Invoke-RestMethod -Method Post -Uri "http://localhost:3000/auth/register" -Conte
|
|
|
2275
2144
|
},
|
|
2276
2145
|
"message": "success"
|
|
2277
2146
|
}
|
|
2278
|
-
|
|
2147
|
+
```
|
|
2279
2148
|
|
|
2280
2149
|
## 7.11 手工验收登录和 Cookie
|
|
2281
2150
|
|
|
2282
|
-
|
|
2151
|
+
```powershell
|
|
2283
2152
|
$web = New-Object Microsoft.PowerShell.Commands.WebRequestSession
|
|
2284
2153
|
|
|
2285
2154
|
$body = @{
|
|
@@ -2292,7 +2161,7 @@ $login = Invoke-RestMethod -Method Post -Uri "http://localhost:3000/auth/login"
|
|
|
2292
2161
|
$accessToken = $login.data.accessToken
|
|
2293
2162
|
$login.data
|
|
2294
2163
|
$web.Cookies.GetCookies("http://localhost:3000")
|
|
2295
|
-
|
|
2164
|
+
```
|
|
2296
2165
|
|
|
2297
2166
|
你应该看到:
|
|
2298
2167
|
|
|
@@ -2321,18 +2190,18 @@ $web.Cookies.GetCookies("http://localhost:3000")
|
|
|
2321
2190
|
|
|
2322
2191
|
新增:
|
|
2323
2192
|
|
|
2324
|
-
|
|
2193
|
+
```text
|
|
2325
2194
|
apps/server/src/common/decorators/strong-auth.decorator.ts
|
|
2326
|
-
|
|
2195
|
+
```
|
|
2327
2196
|
|
|
2328
2197
|
粘贴:
|
|
2329
2198
|
|
|
2330
|
-
|
|
2199
|
+
```ts
|
|
2331
2200
|
import { SetMetadata } from '@nestjs/common'
|
|
2332
2201
|
import { AUTH_CONSISTENCY_KEY } from '@/common/constants/auth'
|
|
2333
2202
|
|
|
2334
2203
|
export const StrongAuth = () => SetMetadata(AUTH_CONSISTENCY_KEY, 'strong')
|
|
2335
|
-
|
|
2204
|
+
```
|
|
2336
2205
|
|
|
2337
2206
|
此时所有接口本来就会强校验。这个装饰器先建立语义,等步骤 11 把默认值改成 bounded 后,它才真正负责绕过 L1。
|
|
2338
2207
|
|
|
@@ -2340,13 +2209,13 @@ export const StrongAuth = () => SetMetadata(AUTH_CONSISTENCY_KEY, 'strong')
|
|
|
2340
2209
|
|
|
2341
2210
|
新增:
|
|
2342
2211
|
|
|
2343
|
-
|
|
2212
|
+
```text
|
|
2344
2213
|
apps/server/src/common/decorators/current-auth.decorator.ts
|
|
2345
|
-
|
|
2214
|
+
```
|
|
2346
2215
|
|
|
2347
2216
|
粘贴:
|
|
2348
2217
|
|
|
2349
|
-
|
|
2218
|
+
```ts
|
|
2350
2219
|
import { createParamDecorator, type ExecutionContext } from '@nestjs/common'
|
|
2351
2220
|
import type { FastifyRequest } from 'fastify'
|
|
2352
2221
|
import type { AuthPrincipal } from '@/modules/auth/auth.types'
|
|
@@ -2359,19 +2228,19 @@ export const CurrentAuth = createParamDecorator(
|
|
|
2359
2228
|
(_data: unknown, context: ExecutionContext) =>
|
|
2360
2229
|
context.switchToHttp().getRequest<AuthenticatedRequest>().auth,
|
|
2361
2230
|
)
|
|
2362
|
-
|
|
2231
|
+
```
|
|
2363
2232
|
|
|
2364
2233
|
## 8.3 新增全局 HybridAuthGuard
|
|
2365
2234
|
|
|
2366
2235
|
新增:
|
|
2367
2236
|
|
|
2368
|
-
|
|
2237
|
+
```text
|
|
2369
2238
|
apps/server/src/modules/auth/guards/hybrid-auth.guard.ts
|
|
2370
|
-
|
|
2239
|
+
```
|
|
2371
2240
|
|
|
2372
2241
|
粘贴:
|
|
2373
2242
|
|
|
2374
|
-
|
|
2243
|
+
```ts
|
|
2375
2244
|
import {
|
|
2376
2245
|
type CanActivate,
|
|
2377
2246
|
type ExecutionContext,
|
|
@@ -2380,11 +2249,7 @@ import {
|
|
|
2380
2249
|
} from '@nestjs/common'
|
|
2381
2250
|
import { Reflector } from '@nestjs/core'
|
|
2382
2251
|
import type { FastifyRequest } from 'fastify'
|
|
2383
|
-
import {
|
|
2384
|
-
AUTH_CONSISTENCY_KEY,
|
|
2385
|
-
type AuthConsistency,
|
|
2386
|
-
IS_PUBLIC_KEY,
|
|
2387
|
-
} from '@/common/constants/auth'
|
|
2252
|
+
import { AUTH_CONSISTENCY_KEY, type AuthConsistency, IS_PUBLIC_KEY } from '@/common/constants/auth'
|
|
2388
2253
|
import type { AuthPrincipal } from '../auth.types'
|
|
2389
2254
|
import { AuthStateService } from '../state/auth-state.service'
|
|
2390
2255
|
import { AccessTokenService } from '../token.service'
|
|
@@ -2404,28 +2269,20 @@ export class HybridAuthGuard implements CanActivate {
|
|
|
2404
2269
|
async canActivate(context: ExecutionContext) {
|
|
2405
2270
|
const targets = [context.getHandler(), context.getClass()]
|
|
2406
2271
|
|
|
2407
|
-
const isPublic = this.reflector.getAllAndOverride<boolean>(
|
|
2408
|
-
IS_PUBLIC_KEY,
|
|
2409
|
-
targets,
|
|
2410
|
-
)
|
|
2272
|
+
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, targets)
|
|
2411
2273
|
|
|
2412
2274
|
if (isPublic) {
|
|
2413
2275
|
return true
|
|
2414
2276
|
}
|
|
2415
2277
|
|
|
2416
|
-
const request = context
|
|
2417
|
-
.switchToHttp()
|
|
2418
|
-
.getRequest<AuthenticatedRequest>()
|
|
2278
|
+
const request = context.switchToHttp().getRequest<AuthenticatedRequest>()
|
|
2419
2279
|
|
|
2420
2280
|
const rawToken = this.extractBearer(request.headers.authorization)
|
|
2421
2281
|
const claims = await this.accessTokens.verify(rawToken)
|
|
2422
2282
|
|
|
2423
2283
|
// 第一阶段故意默认 strong;步骤 11 再改成 bounded
|
|
2424
2284
|
const consistency =
|
|
2425
|
-
this.reflector.getAllAndOverride<AuthConsistency>(
|
|
2426
|
-
AUTH_CONSISTENCY_KEY,
|
|
2427
|
-
targets,
|
|
2428
|
-
) ?? 'strong'
|
|
2285
|
+
this.reflector.getAllAndOverride<AuthConsistency>(AUTH_CONSISTENCY_KEY, targets) ?? 'strong'
|
|
2429
2286
|
|
|
2430
2287
|
await this.authState.verify(claims, consistency)
|
|
2431
2288
|
|
|
@@ -2447,42 +2304,38 @@ export class HybridAuthGuard implements CanActivate {
|
|
|
2447
2304
|
|
|
2448
2305
|
const [scheme, token, extra] = header.trim().split(/\s+/)
|
|
2449
2306
|
|
|
2450
|
-
if (
|
|
2451
|
-
extra ||
|
|
2452
|
-
scheme?.toLowerCase() !== 'bearer' ||
|
|
2453
|
-
!token
|
|
2454
|
-
) {
|
|
2307
|
+
if (extra || scheme?.toLowerCase() !== 'bearer' || !token) {
|
|
2455
2308
|
throw new UnauthorizedException('Authorization 格式错误')
|
|
2456
2309
|
}
|
|
2457
2310
|
|
|
2458
2311
|
return token
|
|
2459
2312
|
}
|
|
2460
2313
|
}
|
|
2461
|
-
|
|
2314
|
+
```
|
|
2462
2315
|
|
|
2463
2316
|
## 8.4 注册 APP_GUARD
|
|
2464
2317
|
|
|
2465
2318
|
修改:
|
|
2466
2319
|
|
|
2467
|
-
|
|
2320
|
+
```text
|
|
2468
2321
|
apps/server/src/modules/auth/auth.module.ts
|
|
2469
|
-
|
|
2322
|
+
```
|
|
2470
2323
|
|
|
2471
2324
|
在 import 中增加:
|
|
2472
2325
|
|
|
2473
|
-
|
|
2326
|
+
```ts
|
|
2474
2327
|
import { APP_GUARD } from '@nestjs/core'
|
|
2475
2328
|
import { HybridAuthGuard } from './guards/hybrid-auth.guard'
|
|
2476
|
-
|
|
2329
|
+
```
|
|
2477
2330
|
|
|
2478
2331
|
在 providers 最后增加:
|
|
2479
2332
|
|
|
2480
|
-
|
|
2333
|
+
```ts
|
|
2481
2334
|
{
|
|
2482
2335
|
provide: APP_GUARD,
|
|
2483
2336
|
useClass: HybridAuthGuard,
|
|
2484
2337
|
},
|
|
2485
|
-
|
|
2338
|
+
```
|
|
2486
2339
|
|
|
2487
2340
|
不要在 AppModule 再注册第二次相同 Guard。
|
|
2488
2341
|
|
|
@@ -2490,38 +2343,38 @@ import { HybridAuthGuard } from './guards/hybrid-auth.guard'
|
|
|
2490
2343
|
|
|
2491
2344
|
修改:
|
|
2492
2345
|
|
|
2493
|
-
|
|
2346
|
+
```text
|
|
2494
2347
|
apps/server/src/modules/auth/auth.controller.ts
|
|
2495
|
-
|
|
2348
|
+
```
|
|
2496
2349
|
|
|
2497
2350
|
把 Nest import 增加 Get:
|
|
2498
2351
|
|
|
2499
|
-
|
|
2352
|
+
```ts
|
|
2500
2353
|
import { Body, Controller, Get, Post, Req, Res } from '@nestjs/common'
|
|
2501
|
-
|
|
2354
|
+
```
|
|
2502
2355
|
|
|
2503
2356
|
把 Swagger import 改成:
|
|
2504
2357
|
|
|
2505
|
-
|
|
2358
|
+
```ts
|
|
2506
2359
|
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'
|
|
2507
|
-
|
|
2360
|
+
```
|
|
2508
2361
|
|
|
2509
2362
|
增加两个 import:
|
|
2510
2363
|
|
|
2511
|
-
|
|
2364
|
+
```ts
|
|
2512
2365
|
import { CurrentAuth } from '@/common/decorators/current-auth.decorator'
|
|
2513
2366
|
import type { AuthPrincipal } from './auth.types'
|
|
2514
|
-
|
|
2367
|
+
```
|
|
2515
2368
|
|
|
2516
2369
|
在 Controller 类中增加:
|
|
2517
2370
|
|
|
2518
|
-
|
|
2371
|
+
```ts
|
|
2519
2372
|
@ApiBearerAuth('access-token')
|
|
2520
2373
|
@Get('me')
|
|
2521
2374
|
me(@CurrentAuth() auth: AuthPrincipal) {
|
|
2522
2375
|
return auth
|
|
2523
2376
|
}
|
|
2524
|
-
|
|
2377
|
+
```
|
|
2525
2378
|
|
|
2526
2379
|
ApiBearerAuth 只控制 Swagger operation 的锁图标和 Authorization 输入。真正校验仍然是 HybridAuthGuard。
|
|
2527
2380
|
|
|
@@ -2529,27 +2382,27 @@ ApiBearerAuth 只控制 Swagger operation 的锁图标和 Authorization 输入
|
|
|
2529
2382
|
|
|
2530
2383
|
执行:
|
|
2531
2384
|
|
|
2532
|
-
|
|
2385
|
+
```powershell
|
|
2533
2386
|
vp run "server#build"
|
|
2534
|
-
|
|
2387
|
+
```
|
|
2535
2388
|
|
|
2536
2389
|
保持 dev server 运行,然后测试无 Token:
|
|
2537
2390
|
|
|
2538
|
-
|
|
2391
|
+
```powershell
|
|
2539
2392
|
try {
|
|
2540
2393
|
Invoke-RestMethod -Uri "http://localhost:3000/auth/me"
|
|
2541
2394
|
} catch {
|
|
2542
2395
|
$_.Exception.Response.StatusCode.value__
|
|
2543
2396
|
}
|
|
2544
|
-
|
|
2397
|
+
```
|
|
2545
2398
|
|
|
2546
2399
|
应为 401。
|
|
2547
2400
|
|
|
2548
2401
|
再使用步骤 7 保存的 Access Token:
|
|
2549
2402
|
|
|
2550
|
-
|
|
2403
|
+
```powershell
|
|
2551
2404
|
Invoke-RestMethod -Uri "http://localhost:3000/auth/me" -Headers @{ Authorization = "Bearer $accessToken" }
|
|
2552
|
-
|
|
2405
|
+
```
|
|
2553
2406
|
|
|
2554
2407
|
应返回当前 userId、sessionId、accountEpoch、sessionVersion 和 tokenId,并经过统一响应包装。
|
|
2555
2408
|
|
|
@@ -2569,7 +2422,7 @@ Invoke-RestMethod -Uri "http://localhost:3000/auth/me" -Headers @{ Authorization
|
|
|
2569
2422
|
|
|
2570
2423
|
每次调用 refresh 时:
|
|
2571
2424
|
|
|
2572
|
-
|
|
2425
|
+
```text
|
|
2573
2426
|
旧 Refresh Token active
|
|
2574
2427
|
-> 数据库事务锁定 Token、Family、Session、User
|
|
2575
2428
|
-> 旧 Token 改成 consumed
|
|
@@ -2577,11 +2430,11 @@ Invoke-RestMethod -Uri "http://localhost:3000/auth/me" -Headers @{ Authorization
|
|
|
2577
2430
|
-> 插入新的 Refresh Token hash
|
|
2578
2431
|
-> 返回新 Access JWT
|
|
2579
2432
|
-> Cookie 替换成新的 Refresh Token
|
|
2580
|
-
|
|
2433
|
+
```
|
|
2581
2434
|
|
|
2582
2435
|
如果再次使用已经 consumed 的旧 Token:
|
|
2583
2436
|
|
|
2584
|
-
|
|
2437
|
+
```text
|
|
2585
2438
|
Family -> compromised
|
|
2586
2439
|
Session -> revoked
|
|
2587
2440
|
Session version + 1
|
|
@@ -2589,7 +2442,7 @@ Session version + 1
|
|
|
2589
2442
|
事务提交
|
|
2590
2443
|
写 Redis revoked tombstone
|
|
2591
2444
|
最后才返回 401
|
|
2592
|
-
|
|
2445
|
+
```
|
|
2593
2446
|
|
|
2594
2447
|
特别注意:发现重放以后,不能在数据库事务内部直接 throw。直接 throw 会让刚执行的撤销更新一起回滚。
|
|
2595
2448
|
|
|
@@ -2597,12 +2450,12 @@ Session version + 1
|
|
|
2597
2450
|
|
|
2598
2451
|
只给 Refresh Token 行加 `FOR UPDATE` 还不够。Refresh 通常从 Token、Family 开始查,logout 或管理员踢人通常从 Session 开始查。如果各条命令直接按自己的查询入口加行锁,就可能形成相反的锁顺序:
|
|
2599
2452
|
|
|
2600
|
-
|
|
2453
|
+
```text
|
|
2601
2454
|
Refresh:已经锁住 Token,等待 Session
|
|
2602
2455
|
logout: 已经锁住 Session,等待 Token
|
|
2603
2456
|
|
|
2604
2457
|
结果:死锁;或者依赖数据库偶然的加锁顺序,无法清楚定义谁先发生
|
|
2605
|
-
|
|
2458
|
+
```
|
|
2606
2459
|
|
|
2607
2460
|
所以 advisory lock 的作用不是代替数据库行锁,也不是解决“最后提交覆盖”,而是让所有改变同一个 Session 的事务先经过同一个入口,统一锁顺序并明确事务先后。
|
|
2608
2461
|
|
|
@@ -2610,16 +2463,16 @@ logout: 已经锁住 Session,等待 Token
|
|
|
2610
2463
|
|
|
2611
2464
|
第一次通过 Token hash 查询只能用来定位 sid。下面两条映射从创建到过期必须保持不可修改:
|
|
2612
2465
|
|
|
2613
|
-
|
|
2466
|
+
```text
|
|
2614
2467
|
refresh_tokens.family_id
|
|
2615
2468
|
refresh_token_families.session_id
|
|
2616
|
-
|
|
2469
|
+
```
|
|
2617
2470
|
|
|
2618
2471
|
不要通过 UPDATE 把已有 Token 或 Family 迁移到另一个 Session。确实需要重新绑定时,创建新的 Session、Family 和 Token,再撤销旧记录。否则第一次定位出来的 sid 可能在等待锁期间变化,事务就会锁错对象。
|
|
2619
2472
|
|
|
2620
2473
|
统一协议如下:
|
|
2621
2474
|
|
|
2622
|
-
|
|
2475
|
+
```text
|
|
2623
2476
|
锁键:auth-session:<sid>
|
|
2624
2477
|
|
|
2625
2478
|
Refresh:
|
|
@@ -2633,7 +2486,7 @@ logout / 管理员踢人:
|
|
|
2633
2486
|
-> advisory lock(sid)
|
|
2634
2487
|
-> 重新读取 Session,并 FOR UPDATE
|
|
2635
2488
|
-> 撤销 Session + Family + active Refresh Token
|
|
2636
|
-
|
|
2489
|
+
```
|
|
2637
2490
|
|
|
2638
2491
|
这里使用 `auth-session:` 前缀隔离 advisory-lock 命名空间。锁会随 PostgreSQL 事务提交或回滚自动释放,不要手动 unlock。
|
|
2639
2492
|
|
|
@@ -2645,22 +2498,17 @@ advisory lock 只协调所有**遵守这套协议**的代码,所以后面的 `
|
|
|
2645
2498
|
|
|
2646
2499
|
将:
|
|
2647
2500
|
|
|
2648
|
-
|
|
2501
|
+
```text
|
|
2649
2502
|
apps/server/src/modules/auth/auth.repository.ts
|
|
2650
|
-
|
|
2503
|
+
```
|
|
2651
2504
|
|
|
2652
2505
|
替换成下面的完整版本。这个版本同时提前加入后续退出和改密需要的方法,避免连续几步反复重写 Repository。
|
|
2653
2506
|
|
|
2654
|
-
|
|
2507
|
+
```ts
|
|
2655
2508
|
import { Inject, Injectable } from '@nestjs/common'
|
|
2656
2509
|
import { and, eq, inArray, sql } from 'drizzle-orm'
|
|
2657
2510
|
import { DRIZZLE, type DrizzleDB } from '@/database/db.module'
|
|
2658
|
-
import {
|
|
2659
|
-
authSessions,
|
|
2660
|
-
refreshTokenFamilies,
|
|
2661
|
-
refreshTokens,
|
|
2662
|
-
users,
|
|
2663
|
-
} from '@/database/schema'
|
|
2511
|
+
import { authSessions, refreshTokenFamilies, refreshTokens, users } from '@/database/schema'
|
|
2664
2512
|
|
|
2665
2513
|
@Injectable()
|
|
2666
2514
|
export class AuthRepository {
|
|
@@ -2678,11 +2526,7 @@ export class AuthRepository {
|
|
|
2678
2526
|
})
|
|
2679
2527
|
}
|
|
2680
2528
|
|
|
2681
|
-
async createUser(input: {
|
|
2682
|
-
name: string
|
|
2683
|
-
email: string
|
|
2684
|
-
passwordHash: string
|
|
2685
|
-
}) {
|
|
2529
|
+
async createUser(input: { name: string; email: string; passwordHash: string }) {
|
|
2686
2530
|
const [user] = await this.db
|
|
2687
2531
|
.insert(users)
|
|
2688
2532
|
.values({
|
|
@@ -2735,11 +2579,7 @@ export class AuthRepository {
|
|
|
2735
2579
|
})
|
|
2736
2580
|
}
|
|
2737
2581
|
|
|
2738
|
-
rotateRefreshToken(input: {
|
|
2739
|
-
currentTokenHash: string
|
|
2740
|
-
nextTokenHash: string
|
|
2741
|
-
now: Date
|
|
2742
|
-
}) {
|
|
2582
|
+
rotateRefreshToken(input: { currentTokenHash: string; nextTokenHash: string; now: Date }) {
|
|
2743
2583
|
return this.db.transaction(async (tx) => {
|
|
2744
2584
|
// 第一次查询只把不可变的 Token -> Family -> sid 映射找出来。
|
|
2745
2585
|
// 这里读到的 status、version 等状态一律不能用于后续判断。
|
|
@@ -2748,10 +2588,7 @@ export class AuthRepository {
|
|
|
2748
2588
|
sessionId: refreshTokenFamilies.sessionId,
|
|
2749
2589
|
})
|
|
2750
2590
|
.from(refreshTokens)
|
|
2751
|
-
.innerJoin(
|
|
2752
|
-
refreshTokenFamilies,
|
|
2753
|
-
eq(refreshTokenFamilies.id, refreshTokens.familyId),
|
|
2754
|
-
)
|
|
2591
|
+
.innerJoin(refreshTokenFamilies, eq(refreshTokenFamilies.id, refreshTokens.familyId))
|
|
2755
2592
|
.where(eq(refreshTokens.tokenHash, input.currentTokenHash))
|
|
2756
2593
|
.limit(1)
|
|
2757
2594
|
|
|
@@ -2790,14 +2627,8 @@ export class AuthRepository {
|
|
|
2790
2627
|
accountEpoch: users.authEpoch,
|
|
2791
2628
|
})
|
|
2792
2629
|
.from(refreshTokens)
|
|
2793
|
-
.innerJoin(
|
|
2794
|
-
|
|
2795
|
-
eq(refreshTokenFamilies.id, refreshTokens.familyId),
|
|
2796
|
-
)
|
|
2797
|
-
.innerJoin(
|
|
2798
|
-
authSessions,
|
|
2799
|
-
eq(authSessions.id, refreshTokenFamilies.sessionId),
|
|
2800
|
-
)
|
|
2630
|
+
.innerJoin(refreshTokenFamilies, eq(refreshTokenFamilies.id, refreshTokens.familyId))
|
|
2631
|
+
.innerJoin(authSessions, eq(authSessions.id, refreshTokenFamilies.sessionId))
|
|
2801
2632
|
.innerJoin(users, eq(users.id, authSessions.userId))
|
|
2802
2633
|
.where(
|
|
2803
2634
|
and(
|
|
@@ -2827,12 +2658,7 @@ export class AuthRepository {
|
|
|
2827
2658
|
.set({
|
|
2828
2659
|
status: 'revoked',
|
|
2829
2660
|
})
|
|
2830
|
-
.where(
|
|
2831
|
-
and(
|
|
2832
|
-
eq(refreshTokens.familyId, row.familyId),
|
|
2833
|
-
eq(refreshTokens.status, 'active'),
|
|
2834
|
-
),
|
|
2835
|
-
)
|
|
2661
|
+
.where(and(eq(refreshTokens.familyId, row.familyId), eq(refreshTokens.status, 'active')))
|
|
2836
2662
|
|
|
2837
2663
|
const [updatedSession] = await tx
|
|
2838
2664
|
.update(authSessions)
|
|
@@ -2885,12 +2711,7 @@ export class AuthRepository {
|
|
|
2885
2711
|
status: 'consumed',
|
|
2886
2712
|
consumedAt: input.now,
|
|
2887
2713
|
})
|
|
2888
|
-
.where(
|
|
2889
|
-
and(
|
|
2890
|
-
eq(refreshTokens.id, row.tokenId),
|
|
2891
|
-
eq(refreshTokens.status, 'active'),
|
|
2892
|
-
),
|
|
2893
|
-
)
|
|
2714
|
+
.where(and(eq(refreshTokens.id, row.tokenId), eq(refreshTokens.status, 'active')))
|
|
2894
2715
|
.returning()
|
|
2895
2716
|
|
|
2896
2717
|
if (!consumed) {
|
|
@@ -2909,10 +2730,7 @@ export class AuthRepository {
|
|
|
2909
2730
|
and(
|
|
2910
2731
|
eq(refreshTokenFamilies.id, row.familyId),
|
|
2911
2732
|
eq(refreshTokenFamilies.status, 'active'),
|
|
2912
|
-
eq(
|
|
2913
|
-
refreshTokenFamilies.currentGeneration,
|
|
2914
|
-
row.tokenGeneration,
|
|
2915
|
-
),
|
|
2733
|
+
eq(refreshTokenFamilies.currentGeneration, row.tokenGeneration),
|
|
2916
2734
|
),
|
|
2917
2735
|
)
|
|
2918
2736
|
.returning()
|
|
@@ -2946,11 +2764,7 @@ export class AuthRepository {
|
|
|
2946
2764
|
})
|
|
2947
2765
|
}
|
|
2948
2766
|
|
|
2949
|
-
revokeSession(input: {
|
|
2950
|
-
sessionId: string
|
|
2951
|
-
userId?: number
|
|
2952
|
-
reason: string
|
|
2953
|
-
}) {
|
|
2767
|
+
revokeSession(input: { sessionId: string; userId?: number; reason: string }) {
|
|
2954
2768
|
return this.db.transaction(async (tx) => {
|
|
2955
2769
|
const now = new Date()
|
|
2956
2770
|
|
|
@@ -2962,17 +2776,10 @@ export class AuthRepository {
|
|
|
2962
2776
|
`)
|
|
2963
2777
|
|
|
2964
2778
|
const condition = input.userId
|
|
2965
|
-
? and(
|
|
2966
|
-
eq(authSessions.id, input.sessionId),
|
|
2967
|
-
eq(authSessions.userId, input.userId),
|
|
2968
|
-
)
|
|
2779
|
+
? and(eq(authSessions.id, input.sessionId), eq(authSessions.userId, input.userId))
|
|
2969
2780
|
: eq(authSessions.id, input.sessionId)
|
|
2970
2781
|
|
|
2971
|
-
const [session] = await tx
|
|
2972
|
-
.select()
|
|
2973
|
-
.from(authSessions)
|
|
2974
|
-
.where(condition)
|
|
2975
|
-
.for('update')
|
|
2782
|
+
const [session] = await tx.select().from(authSessions).where(condition).for('update')
|
|
2976
2783
|
|
|
2977
2784
|
if (!session) {
|
|
2978
2785
|
return null
|
|
@@ -3029,10 +2836,7 @@ export class AuthRepository {
|
|
|
3029
2836
|
.update(refreshTokens)
|
|
3030
2837
|
.set({ status: 'revoked' })
|
|
3031
2838
|
.where(
|
|
3032
|
-
and(
|
|
3033
|
-
inArray(refreshTokens.familyId, familyIds),
|
|
3034
|
-
eq(refreshTokens.status, 'active'),
|
|
3035
|
-
),
|
|
2839
|
+
and(inArray(refreshTokens.familyId, familyIds), eq(refreshTokens.status, 'active')),
|
|
3036
2840
|
)
|
|
3037
2841
|
}
|
|
3038
2842
|
|
|
@@ -3048,11 +2852,7 @@ export class AuthRepository {
|
|
|
3048
2852
|
|
|
3049
2853
|
bumpAccountEpoch(userId: number) {
|
|
3050
2854
|
return this.db.transaction(async (tx) => {
|
|
3051
|
-
const [account] = await tx
|
|
3052
|
-
.select()
|
|
3053
|
-
.from(users)
|
|
3054
|
-
.where(eq(users.id, userId))
|
|
3055
|
-
.for('update')
|
|
2855
|
+
const [account] = await tx.select().from(users).where(eq(users.id, userId)).for('update')
|
|
3056
2856
|
|
|
3057
2857
|
if (!account) {
|
|
3058
2858
|
return null
|
|
@@ -3106,19 +2906,19 @@ export class AuthRepository {
|
|
|
3106
2906
|
})
|
|
3107
2907
|
}
|
|
3108
2908
|
}
|
|
3109
|
-
|
|
2909
|
+
```
|
|
3110
2910
|
|
|
3111
2911
|
## 9.2 在 AuthService 增加 refresh
|
|
3112
2912
|
|
|
3113
2913
|
修改:
|
|
3114
2914
|
|
|
3115
|
-
|
|
2915
|
+
```text
|
|
3116
2916
|
apps/server/src/modules/auth/auth.service.ts
|
|
3117
|
-
|
|
2917
|
+
```
|
|
3118
2918
|
|
|
3119
2919
|
把 UnauthorizedException 保留在 import 中,然后在 AuthService 类内增加:
|
|
3120
2920
|
|
|
3121
|
-
|
|
2921
|
+
```ts
|
|
3122
2922
|
async refresh(rawToken: string | undefined): Promise<IssuedTokenPair> {
|
|
3123
2923
|
if (!rawToken) {
|
|
3124
2924
|
throw new UnauthorizedException('缺少 Refresh Token')
|
|
@@ -3158,19 +2958,19 @@ async refresh(rawToken: string | undefined): Promise<IssuedTokenPair> {
|
|
|
3158
2958
|
refreshToken: nextToken.raw,
|
|
3159
2959
|
}
|
|
3160
2960
|
}
|
|
3161
|
-
|
|
2961
|
+
```
|
|
3162
2962
|
|
|
3163
2963
|
## 9.3 在 Controller 增加 refresh 接口
|
|
3164
2964
|
|
|
3165
2965
|
修改:
|
|
3166
2966
|
|
|
3167
|
-
|
|
2967
|
+
```text
|
|
3168
2968
|
apps/server/src/modules/auth/auth.controller.ts
|
|
3169
|
-
|
|
2969
|
+
```
|
|
3170
2970
|
|
|
3171
2971
|
在 Controller 类内增加:
|
|
3172
2972
|
|
|
3173
|
-
|
|
2973
|
+
```ts
|
|
3174
2974
|
@Public()
|
|
3175
2975
|
@Post('refresh')
|
|
3176
2976
|
async refresh(
|
|
@@ -3188,21 +2988,21 @@ async refresh(
|
|
|
3188
2988
|
expiresIn: result.accessExpiresIn,
|
|
3189
2989
|
}
|
|
3190
2990
|
}
|
|
3191
|
-
|
|
2991
|
+
```
|
|
3192
2992
|
|
|
3193
2993
|
request.cookies 的类型由 @fastify/cookie 增强。如果 TypeScript 提示 cookies 不存在,先确认 main.ts 已注册插件并且依赖已正确安装。
|
|
3194
2994
|
|
|
3195
2995
|
## 9.4 构建
|
|
3196
2996
|
|
|
3197
|
-
|
|
2997
|
+
```powershell
|
|
3198
2998
|
vp run "server#build"
|
|
3199
|
-
|
|
2999
|
+
```
|
|
3200
3000
|
|
|
3201
3001
|
## 9.5 验收正常轮换
|
|
3202
3002
|
|
|
3203
3003
|
重新登录并保存旧 Cookie:
|
|
3204
3004
|
|
|
3205
|
-
|
|
3005
|
+
```powershell
|
|
3206
3006
|
$web = New-Object Microsoft.PowerShell.Commands.WebRequestSession
|
|
3207
3007
|
$body = @{ email = "auth-test@example.com"; password = "Correct-Horse-Battery-123!" } | ConvertTo-Json
|
|
3208
3008
|
$login = Invoke-RestMethod -Method Post -Uri "http://localhost:3000/auth/login" -ContentType "application/json" -Body $body -WebSession $web
|
|
@@ -3213,7 +3013,7 @@ $newAccessToken = $refresh.data.accessToken
|
|
|
3213
3013
|
$newCookie = $web.Cookies.GetCookies("http://localhost:3000")["refresh_token"].Value
|
|
3214
3014
|
|
|
3215
3015
|
$oldCookie -eq $newCookie
|
|
3216
|
-
|
|
3016
|
+
```
|
|
3217
3017
|
|
|
3218
3018
|
最后一行必须是 False。
|
|
3219
3019
|
|
|
@@ -3228,7 +3028,7 @@ $oldCookie -eq $newCookie
|
|
|
3228
3028
|
|
|
3229
3029
|
创建一个只携带旧 Cookie 的新 WebSession:
|
|
3230
3030
|
|
|
3231
|
-
|
|
3031
|
+
```powershell
|
|
3232
3032
|
$replay = New-Object Microsoft.PowerShell.Commands.WebRequestSession
|
|
3233
3033
|
$cookie = New-Object System.Net.Cookie("refresh_token", $oldCookie, "/auth", "localhost")
|
|
3234
3034
|
$replay.Cookies.Add($cookie)
|
|
@@ -3238,7 +3038,7 @@ try {
|
|
|
3238
3038
|
} catch {
|
|
3239
3039
|
$_.Exception.Response.StatusCode.value__
|
|
3240
3040
|
}
|
|
3241
|
-
|
|
3041
|
+
```
|
|
3242
3042
|
|
|
3243
3043
|
应返回 401。
|
|
3244
3044
|
|
|
@@ -3283,14 +3083,14 @@ try {
|
|
|
3283
3083
|
|
|
3284
3084
|
实现两个不同的失效维度:
|
|
3285
3085
|
|
|
3286
|
-
|
|
3086
|
+
```text
|
|
3287
3087
|
单设备失效
|
|
3288
3088
|
Session status -> revoked
|
|
3289
3089
|
sessionVersion + 1
|
|
3290
3090
|
|
|
3291
3091
|
全部设备失效
|
|
3292
3092
|
accountAuthEpoch + 1
|
|
3293
|
-
|
|
3093
|
+
```
|
|
3294
3094
|
|
|
3295
3095
|
退出全部设备和改密不扫描每一个 Session,也不向每个微服务广播每一个 Session。只更新一次账号 epoch,并发送一个账号级事件。
|
|
3296
3096
|
|
|
@@ -3298,13 +3098,13 @@ try {
|
|
|
3298
3098
|
|
|
3299
3099
|
新增:
|
|
3300
3100
|
|
|
3301
|
-
|
|
3101
|
+
```text
|
|
3302
3102
|
apps/server/src/modules/auth/dto/change-password.dto.ts
|
|
3303
|
-
|
|
3103
|
+
```
|
|
3304
3104
|
|
|
3305
3105
|
粘贴:
|
|
3306
3106
|
|
|
3307
|
-
|
|
3107
|
+
```ts
|
|
3308
3108
|
import { createZodDto } from 'nestjs-zod'
|
|
3309
3109
|
import { z } from 'zod'
|
|
3310
3110
|
|
|
@@ -3318,34 +3118,28 @@ const ChangePasswordSchema = z
|
|
|
3318
3118
|
path: ['newPassword'],
|
|
3319
3119
|
})
|
|
3320
3120
|
|
|
3321
|
-
export class ChangePasswordDto extends createZodDto(
|
|
3322
|
-
|
|
3323
|
-
) {}
|
|
3324
|
-
~~~
|
|
3121
|
+
export class ChangePasswordDto extends createZodDto(ChangePasswordSchema) {}
|
|
3122
|
+
```
|
|
3325
3123
|
|
|
3326
3124
|
## 10.2 扩展 AuthService
|
|
3327
3125
|
|
|
3328
3126
|
打开:
|
|
3329
3127
|
|
|
3330
|
-
|
|
3128
|
+
```text
|
|
3331
3129
|
apps/server/src/modules/auth/auth.service.ts
|
|
3332
|
-
|
|
3130
|
+
```
|
|
3333
3131
|
|
|
3334
3132
|
在 Nest import 中加入 ConflictException:
|
|
3335
3133
|
|
|
3336
|
-
|
|
3337
|
-
import {
|
|
3338
|
-
|
|
3339
|
-
Injectable,
|
|
3340
|
-
UnauthorizedException,
|
|
3341
|
-
} from '@nestjs/common'
|
|
3342
|
-
~~~
|
|
3134
|
+
```ts
|
|
3135
|
+
import { ConflictException, Injectable, UnauthorizedException } from '@nestjs/common'
|
|
3136
|
+
```
|
|
3343
3137
|
|
|
3344
3138
|
如果之前已经有 ConflictException,不要重复导入。
|
|
3345
3139
|
|
|
3346
3140
|
在 AuthService 类内增加:
|
|
3347
3141
|
|
|
3348
|
-
|
|
3142
|
+
```ts
|
|
3349
3143
|
async logoutCurrent(input: {
|
|
3350
3144
|
userId: string
|
|
3351
3145
|
sessionId: string
|
|
@@ -3426,7 +3220,7 @@ async changePassword(input: {
|
|
|
3426
3220
|
epoch: updated.authEpoch,
|
|
3427
3221
|
})
|
|
3428
3222
|
}
|
|
3429
|
-
|
|
3223
|
+
```
|
|
3430
3224
|
|
|
3431
3225
|
revokeSessionByAdmin 只是领域方法。当前项目没有 RBAC,所以不要直接创建一个“任何登录用户都能踢任意 sid”的 Controller。等你有管理员权限 Guard 后,再把这个方法接到管理员接口,并同时使用 StrongAuth。
|
|
3432
3226
|
|
|
@@ -3434,28 +3228,25 @@ revokeSessionByAdmin 只是领域方法。当前项目没有 RBAC,所以不要
|
|
|
3434
3228
|
|
|
3435
3229
|
打开:
|
|
3436
3230
|
|
|
3437
|
-
|
|
3231
|
+
```text
|
|
3438
3232
|
apps/server/src/modules/auth/auth.controller.ts
|
|
3439
|
-
|
|
3233
|
+
```
|
|
3440
3234
|
|
|
3441
3235
|
增加 import:
|
|
3442
3236
|
|
|
3443
|
-
|
|
3237
|
+
```ts
|
|
3444
3238
|
import { CurrentAuth } from '@/common/decorators/current-auth.decorator'
|
|
3445
3239
|
import { StrongAuth } from '@/common/decorators/strong-auth.decorator'
|
|
3446
3240
|
import type { AuthPrincipal } from './auth.types'
|
|
3447
3241
|
import { ChangePasswordDto } from './dto/change-password.dto'
|
|
3448
|
-
import {
|
|
3449
|
-
|
|
3450
|
-
setRefreshCookie,
|
|
3451
|
-
} from './http/refresh-cookie'
|
|
3452
|
-
~~~
|
|
3242
|
+
import { clearRefreshCookie, setRefreshCookie } from './http/refresh-cookie'
|
|
3243
|
+
```
|
|
3453
3244
|
|
|
3454
3245
|
如果已有 CurrentAuth、AuthPrincipal 或 setRefreshCookie import,请合并,不要重复。
|
|
3455
3246
|
|
|
3456
3247
|
在 Controller 类内增加:
|
|
3457
3248
|
|
|
3458
|
-
|
|
3249
|
+
```ts
|
|
3459
3250
|
@StrongAuth()
|
|
3460
3251
|
@ApiBearerAuth('access-token')
|
|
3461
3252
|
@Post('logout')
|
|
@@ -3505,7 +3296,7 @@ async changePassword(
|
|
|
3505
3296
|
|
|
3506
3297
|
return null
|
|
3507
3298
|
}
|
|
3508
|
-
|
|
3299
|
+
```
|
|
3509
3300
|
|
|
3510
3301
|
改密后当前设备也会失效,因为 accountAuthEpoch 已增加。前端收到成功响应后应清空内存 Access Token,并跳转登录页。
|
|
3511
3302
|
|
|
@@ -3513,15 +3304,15 @@ async changePassword(
|
|
|
3513
3304
|
|
|
3514
3305
|
## 10.4 构建
|
|
3515
3306
|
|
|
3516
|
-
|
|
3307
|
+
```powershell
|
|
3517
3308
|
vp run "server#build"
|
|
3518
|
-
|
|
3309
|
+
```
|
|
3519
3310
|
|
|
3520
3311
|
## 10.5 验收单设备退出
|
|
3521
3312
|
|
|
3522
3313
|
分别创建两个 WebSession 并登录同一个账号:
|
|
3523
3314
|
|
|
3524
|
-
|
|
3315
|
+
```powershell
|
|
3525
3316
|
$web1 = New-Object Microsoft.PowerShell.Commands.WebRequestSession
|
|
3526
3317
|
$web2 = New-Object Microsoft.PowerShell.Commands.WebRequestSession
|
|
3527
3318
|
$body = @{ email = "auth-test@example.com"; password = "Correct-Horse-Battery-123!" } | ConvertTo-Json
|
|
@@ -3531,17 +3322,17 @@ $login2 = Invoke-RestMethod -Method Post -Uri "http://localhost:3000/auth/login"
|
|
|
3531
3322
|
|
|
3532
3323
|
$token1 = $login1.data.accessToken
|
|
3533
3324
|
$token2 = $login2.data.accessToken
|
|
3534
|
-
|
|
3325
|
+
```
|
|
3535
3326
|
|
|
3536
3327
|
退出第一个 Session:
|
|
3537
3328
|
|
|
3538
|
-
|
|
3329
|
+
```powershell
|
|
3539
3330
|
Invoke-RestMethod -Method Post -Uri "http://localhost:3000/auth/logout" -Headers @{ Authorization = "Bearer $token1" } -WebSession $web1
|
|
3540
|
-
|
|
3331
|
+
```
|
|
3541
3332
|
|
|
3542
3333
|
然后分别调用 me:
|
|
3543
3334
|
|
|
3544
|
-
|
|
3335
|
+
```powershell
|
|
3545
3336
|
try {
|
|
3546
3337
|
Invoke-RestMethod -Uri "http://localhost:3000/auth/me" -Headers @{ Authorization = "Bearer $token1" }
|
|
3547
3338
|
} catch {
|
|
@@ -3549,7 +3340,7 @@ try {
|
|
|
3549
3340
|
}
|
|
3550
3341
|
|
|
3551
3342
|
Invoke-RestMethod -Uri "http://localhost:3000/auth/me" -Headers @{ Authorization = "Bearer $token2" }
|
|
3552
|
-
|
|
3343
|
+
```
|
|
3553
3344
|
|
|
3554
3345
|
预期:
|
|
3555
3346
|
|
|
@@ -3562,9 +3353,9 @@ Invoke-RestMethod -Uri "http://localhost:3000/auth/me" -Headers @{ Authorization
|
|
|
3562
3353
|
|
|
3563
3354
|
使用仍有效的 token2:
|
|
3564
3355
|
|
|
3565
|
-
|
|
3356
|
+
```powershell
|
|
3566
3357
|
Invoke-RestMethod -Method Post -Uri "http://localhost:3000/auth/logout-all" -Headers @{ Authorization = "Bearer $token2" } -WebSession $web2
|
|
3567
|
-
|
|
3358
|
+
```
|
|
3568
3359
|
|
|
3569
3360
|
预期:
|
|
3570
3361
|
|
|
@@ -3579,7 +3370,7 @@ Invoke-RestMethod -Method Post -Uri "http://localhost:3000/auth/logout-all" -Hea
|
|
|
3579
3370
|
|
|
3580
3371
|
先重新登录并明确取得一枚新的 Access Token。不要继续复用步骤 9 或步骤 10.6 中已经被撤销的变量:
|
|
3581
3372
|
|
|
3582
|
-
|
|
3373
|
+
```powershell
|
|
3583
3374
|
$webChange = New-Object Microsoft.PowerShell.Commands.WebRequestSession
|
|
3584
3375
|
$loginBody = @{
|
|
3585
3376
|
email = "auth-test@example.com"
|
|
@@ -3595,7 +3386,7 @@ $changeBody = @{
|
|
|
3595
3386
|
} | ConvertTo-Json
|
|
3596
3387
|
|
|
3597
3388
|
Invoke-RestMethod -Method Post -Uri "http://localhost:3000/auth/change-password" -ContentType "application/json" -Body $changeBody -Headers @{ Authorization = "Bearer $accessTokenForChange" } -WebSession $webChange
|
|
3598
|
-
|
|
3389
|
+
```
|
|
3599
3390
|
|
|
3600
3391
|
预期:
|
|
3601
3392
|
|
|
@@ -3661,9 +3452,9 @@ Invoke-RestMethod -Method Post -Uri "http://localhost:3000/auth/change-password"
|
|
|
3661
3452
|
|
|
3662
3453
|
执行:
|
|
3663
3454
|
|
|
3664
|
-
|
|
3455
|
+
```powershell
|
|
3665
3456
|
vp add lru-cache --filter server --save-catalog
|
|
3666
|
-
|
|
3457
|
+
```
|
|
3667
3458
|
|
|
3668
3459
|
不要使用无限增长的 Map 作为用户缓存。
|
|
3669
3460
|
|
|
@@ -3671,13 +3462,13 @@ vp add lru-cache --filter server --save-catalog
|
|
|
3671
3462
|
|
|
3672
3463
|
把:
|
|
3673
3464
|
|
|
3674
|
-
|
|
3465
|
+
```text
|
|
3675
3466
|
apps/server/src/modules/auth/state/auth-state.service.ts
|
|
3676
|
-
|
|
3467
|
+
```
|
|
3677
3468
|
|
|
3678
3469
|
替换成下面完整代码:
|
|
3679
3470
|
|
|
3680
|
-
|
|
3471
|
+
```ts
|
|
3681
3472
|
import { InjectRedis } from '@nestjs-modules/ioredis'
|
|
3682
3473
|
import {
|
|
3683
3474
|
Inject,
|
|
@@ -3855,30 +3646,16 @@ function sessionCacheKey(userId: string, sessionId: string) {
|
|
|
3855
3646
|
}
|
|
3856
3647
|
|
|
3857
3648
|
function singleFlightKey(claims: AccessTokenClaims) {
|
|
3858
|
-
return [
|
|
3859
|
-
claims.sub,
|
|
3860
|
-
claims.sid,
|
|
3861
|
-
String(claims.ae),
|
|
3862
|
-
String(claims.sv),
|
|
3863
|
-
].join(':')
|
|
3649
|
+
return [claims.sub, claims.sid, String(claims.ae), String(claims.sv)].join(':')
|
|
3864
3650
|
}
|
|
3865
3651
|
|
|
3866
3652
|
@Injectable()
|
|
3867
3653
|
export class AuthStateService {
|
|
3868
|
-
private readonly accountCache: LRUCache<
|
|
3869
|
-
string,
|
|
3870
|
-
CacheEntry<AccountAuthState>
|
|
3871
|
-
>
|
|
3654
|
+
private readonly accountCache: LRUCache<string, CacheEntry<AccountAuthState>>
|
|
3872
3655
|
|
|
3873
|
-
private readonly sessionCache: LRUCache<
|
|
3874
|
-
string,
|
|
3875
|
-
CacheEntry<SessionAuthState>
|
|
3876
|
-
>
|
|
3656
|
+
private readonly sessionCache: LRUCache<string, CacheEntry<SessionAuthState>>
|
|
3877
3657
|
|
|
3878
|
-
private readonly inFlight = new Map<
|
|
3879
|
-
string,
|
|
3880
|
-
Promise<AuthStateSnapshot>
|
|
3881
|
-
>()
|
|
3658
|
+
private readonly inFlight = new Map<string, Promise<AuthStateSnapshot>>()
|
|
3882
3659
|
|
|
3883
3660
|
private readonly accountPullFences = new Map<string, PullFence>()
|
|
3884
3661
|
private readonly sessionPullFences = new Map<string, PullFence>()
|
|
@@ -3903,10 +3680,7 @@ export class AuthStateService {
|
|
|
3903
3680
|
})
|
|
3904
3681
|
}
|
|
3905
3682
|
|
|
3906
|
-
async verify(
|
|
3907
|
-
claims: AccessTokenClaims,
|
|
3908
|
-
consistency: AuthConsistency,
|
|
3909
|
-
) {
|
|
3683
|
+
async verify(claims: AccessTokenClaims, consistency: AuthConsistency) {
|
|
3910
3684
|
const snapshot =
|
|
3911
3685
|
consistency === 'strong'
|
|
3912
3686
|
? await this.pullSnapshot(claims)
|
|
@@ -3920,17 +3694,13 @@ export class AuthStateService {
|
|
|
3920
3694
|
const result = await this.projectAccount(state)
|
|
3921
3695
|
|
|
3922
3696
|
if (result === 'stale') {
|
|
3923
|
-
throw new ServiceUnavailableException(
|
|
3924
|
-
'账号鉴权状态已经存在更高版本',
|
|
3925
|
-
)
|
|
3697
|
+
throw new ServiceUnavailableException('账号鉴权状态已经存在更高版本')
|
|
3926
3698
|
}
|
|
3927
3699
|
|
|
3928
3700
|
const entry = this.mergeAccountCache(state)
|
|
3929
3701
|
|
|
3930
3702
|
if (entry.versionFloor > state.epoch) {
|
|
3931
|
-
throw new ServiceUnavailableException(
|
|
3932
|
-
'账号鉴权状态正在发生并发变化',
|
|
3933
|
-
)
|
|
3703
|
+
throw new ServiceUnavailableException('账号鉴权状态正在发生并发变化')
|
|
3934
3704
|
}
|
|
3935
3705
|
} catch (error) {
|
|
3936
3706
|
this.accountCache.delete(state.userId)
|
|
@@ -3945,17 +3715,13 @@ export class AuthStateService {
|
|
|
3945
3715
|
const result = await this.projectSession(state)
|
|
3946
3716
|
|
|
3947
3717
|
if (result === 'stale') {
|
|
3948
|
-
throw new ServiceUnavailableException(
|
|
3949
|
-
'Session 鉴权状态已经存在更高版本',
|
|
3950
|
-
)
|
|
3718
|
+
throw new ServiceUnavailableException('Session 鉴权状态已经存在更高版本')
|
|
3951
3719
|
}
|
|
3952
3720
|
|
|
3953
3721
|
const entry = this.mergeSessionCache(state)
|
|
3954
3722
|
|
|
3955
3723
|
if (entry.versionFloor > state.version) {
|
|
3956
|
-
throw new ServiceUnavailableException(
|
|
3957
|
-
'Session 鉴权状态正在发生并发变化',
|
|
3958
|
-
)
|
|
3724
|
+
throw new ServiceUnavailableException('Session 鉴权状态正在发生并发变化')
|
|
3959
3725
|
}
|
|
3960
3726
|
} catch (error) {
|
|
3961
3727
|
this.sessionCache.delete(key)
|
|
@@ -3980,11 +3746,7 @@ export class AuthStateService {
|
|
|
3980
3746
|
entry.dirty = entry.versionFloor > entry.value.epoch
|
|
3981
3747
|
}
|
|
3982
3748
|
|
|
3983
|
-
markSessionDirty(
|
|
3984
|
-
userId: string,
|
|
3985
|
-
sessionId: string,
|
|
3986
|
-
version: number,
|
|
3987
|
-
) {
|
|
3749
|
+
markSessionDirty(userId: string, sessionId: string, version: number) {
|
|
3988
3750
|
const key = sessionCacheKey(userId, sessionId)
|
|
3989
3751
|
const fence = this.sessionPullFences.get(key)
|
|
3990
3752
|
|
|
@@ -4002,27 +3764,15 @@ export class AuthStateService {
|
|
|
4002
3764
|
entry.dirty = entry.versionFloor > entry.value.version
|
|
4003
3765
|
}
|
|
4004
3766
|
|
|
4005
|
-
private async getBoundedSnapshot(
|
|
4006
|
-
claims: AccessTokenClaims,
|
|
4007
|
-
): Promise<AuthStateSnapshot> {
|
|
3767
|
+
private async getBoundedSnapshot(claims: AccessTokenClaims): Promise<AuthStateSnapshot> {
|
|
4008
3768
|
const account = this.accountCache.get(claims.sub)
|
|
4009
|
-
const session = this.sessionCache.get(
|
|
4010
|
-
sessionCacheKey(claims.sub, claims.sid),
|
|
4011
|
-
)
|
|
3769
|
+
const session = this.sessionCache.get(sessionCacheKey(claims.sub, claims.sid))
|
|
4012
3770
|
|
|
4013
|
-
if (
|
|
4014
|
-
account &&
|
|
4015
|
-
(account.value.epoch > claims.ae ||
|
|
4016
|
-
account.versionFloor > claims.ae)
|
|
4017
|
-
) {
|
|
3771
|
+
if (account && (account.value.epoch > claims.ae || account.versionFloor > claims.ae)) {
|
|
4018
3772
|
throw new UnauthorizedException('登录状态已失效')
|
|
4019
3773
|
}
|
|
4020
3774
|
|
|
4021
|
-
if (
|
|
4022
|
-
session &&
|
|
4023
|
-
(session.value.version > claims.sv ||
|
|
4024
|
-
session.versionFloor > claims.sv)
|
|
4025
|
-
) {
|
|
3775
|
+
if (session && (session.value.version > claims.sv || session.versionFloor > claims.sv)) {
|
|
4026
3776
|
throw new UnauthorizedException('登录状态已失效')
|
|
4027
3777
|
}
|
|
4028
3778
|
|
|
@@ -4043,9 +3793,7 @@ export class AuthStateService {
|
|
|
4043
3793
|
return this.pullSnapshotSingleFlight(claims)
|
|
4044
3794
|
}
|
|
4045
3795
|
|
|
4046
|
-
private pullSnapshotSingleFlight(
|
|
4047
|
-
claims: AccessTokenClaims,
|
|
4048
|
-
): Promise<AuthStateSnapshot> {
|
|
3796
|
+
private pullSnapshotSingleFlight(claims: AccessTokenClaims): Promise<AuthStateSnapshot> {
|
|
4049
3797
|
const key = singleFlightKey(claims)
|
|
4050
3798
|
const existing = this.inFlight.get(key)
|
|
4051
3799
|
|
|
@@ -4061,13 +3809,8 @@ export class AuthStateService {
|
|
|
4061
3809
|
return task
|
|
4062
3810
|
}
|
|
4063
3811
|
|
|
4064
|
-
private async pullSnapshot(
|
|
4065
|
-
claims
|
|
4066
|
-
): Promise<AuthStateSnapshot> {
|
|
4067
|
-
const sessionCacheId = sessionCacheKey(
|
|
4068
|
-
claims.sub,
|
|
4069
|
-
claims.sid,
|
|
4070
|
-
)
|
|
3812
|
+
private async pullSnapshot(claims: AccessTokenClaims): Promise<AuthStateSnapshot> {
|
|
3813
|
+
const sessionCacheId = sessionCacheKey(claims.sub, claims.sid)
|
|
4071
3814
|
|
|
4072
3815
|
const accountFence = this.acquireFence(
|
|
4073
3816
|
this.accountPullFences,
|
|
@@ -4083,10 +3826,7 @@ export class AuthStateService {
|
|
|
4083
3826
|
|
|
4084
3827
|
try {
|
|
4085
3828
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
4086
|
-
const shared = await this.readShared(
|
|
4087
|
-
claims.sub,
|
|
4088
|
-
claims.sid,
|
|
4089
|
-
)
|
|
3829
|
+
const shared = await this.readShared(claims.sub, claims.sid)
|
|
4090
3830
|
|
|
4091
3831
|
const accountEntry = shared.account
|
|
4092
3832
|
? this.mergeAccountCache(shared.account)
|
|
@@ -4125,43 +3865,31 @@ export class AuthStateService {
|
|
|
4125
3865
|
}
|
|
4126
3866
|
}
|
|
4127
3867
|
|
|
4128
|
-
const database = await this.loadDatabaseSnapshot(
|
|
4129
|
-
claims.sub,
|
|
4130
|
-
claims.sid,
|
|
4131
|
-
)
|
|
3868
|
+
const database = await this.loadDatabaseSnapshot(claims.sub, claims.sid)
|
|
4132
3869
|
|
|
4133
3870
|
if (!database) {
|
|
4134
3871
|
throw new UnauthorizedException('登录状态不存在')
|
|
4135
3872
|
}
|
|
4136
3873
|
|
|
4137
|
-
if (
|
|
4138
|
-
|
|
4139
|
-
database.session.version < claims.sv
|
|
4140
|
-
) {
|
|
4141
|
-
throw new ServiceUnavailableException(
|
|
4142
|
-
'鉴权事实库版本落后于 JWT',
|
|
4143
|
-
)
|
|
3874
|
+
if (database.account.epoch < claims.ae || database.session.version < claims.sv) {
|
|
3875
|
+
throw new ServiceUnavailableException('鉴权事实库版本落后于 JWT')
|
|
4144
3876
|
}
|
|
4145
3877
|
|
|
4146
3878
|
let accountProjection: ProjectionWriteResult
|
|
4147
3879
|
let sessionProjection: ProjectionWriteResult
|
|
4148
3880
|
|
|
4149
3881
|
try {
|
|
4150
|
-
;[accountProjection, sessionProjection] =
|
|
4151
|
-
|
|
4152
|
-
|
|
4153
|
-
|
|
4154
|
-
])
|
|
3882
|
+
;[accountProjection, sessionProjection] = await Promise.all([
|
|
3883
|
+
this.projectAccount(database.account),
|
|
3884
|
+
this.projectSession(database.session),
|
|
3885
|
+
])
|
|
4155
3886
|
} catch (error) {
|
|
4156
3887
|
this.accountCache.delete(claims.sub)
|
|
4157
3888
|
this.sessionCache.delete(sessionCacheId)
|
|
4158
3889
|
throw error
|
|
4159
3890
|
}
|
|
4160
3891
|
|
|
4161
|
-
if (
|
|
4162
|
-
accountProjection === 'stale' ||
|
|
4163
|
-
sessionProjection === 'stale'
|
|
4164
|
-
) {
|
|
3892
|
+
if (accountProjection === 'stale' || sessionProjection === 'stale') {
|
|
4165
3893
|
continue
|
|
4166
3894
|
}
|
|
4167
3895
|
|
|
@@ -4189,21 +3917,11 @@ export class AuthStateService {
|
|
|
4189
3917
|
}
|
|
4190
3918
|
}
|
|
4191
3919
|
|
|
4192
|
-
throw new ServiceUnavailableException(
|
|
4193
|
-
'鉴权状态正在变化,请稍后重试',
|
|
4194
|
-
)
|
|
3920
|
+
throw new ServiceUnavailableException('鉴权状态正在变化,请稍后重试')
|
|
4195
3921
|
} finally {
|
|
4196
|
-
this.releaseFence(
|
|
4197
|
-
this.accountPullFences,
|
|
4198
|
-
claims.sub,
|
|
4199
|
-
accountFence,
|
|
4200
|
-
)
|
|
3922
|
+
this.releaseFence(this.accountPullFences, claims.sub, accountFence)
|
|
4201
3923
|
|
|
4202
|
-
this.releaseFence(
|
|
4203
|
-
this.sessionPullFences,
|
|
4204
|
-
sessionCacheId,
|
|
4205
|
-
sessionFence,
|
|
4206
|
-
)
|
|
3924
|
+
this.releaseFence(this.sessionPullFences, sessionCacheId, sessionFence)
|
|
4207
3925
|
}
|
|
4208
3926
|
}
|
|
4209
3927
|
|
|
@@ -4236,11 +3954,7 @@ export class AuthStateService {
|
|
|
4236
3954
|
] = raw.map((value) => String(value ?? ''))
|
|
4237
3955
|
|
|
4238
3956
|
return {
|
|
4239
|
-
account: this.parseAccount(
|
|
4240
|
-
userId,
|
|
4241
|
-
accountEpoch,
|
|
4242
|
-
accountStatus,
|
|
4243
|
-
),
|
|
3957
|
+
account: this.parseAccount(userId, accountEpoch, accountStatus),
|
|
4244
3958
|
session: this.parseSession(
|
|
4245
3959
|
userId,
|
|
4246
3960
|
sessionId,
|
|
@@ -4251,9 +3965,7 @@ export class AuthStateService {
|
|
|
4251
3965
|
),
|
|
4252
3966
|
}
|
|
4253
3967
|
} catch {
|
|
4254
|
-
throw new ServiceUnavailableException(
|
|
4255
|
-
'共享鉴权状态暂时不可用',
|
|
4256
|
-
)
|
|
3968
|
+
throw new ServiceUnavailableException('共享鉴权状态暂时不可用')
|
|
4257
3969
|
}
|
|
4258
3970
|
}
|
|
4259
3971
|
|
|
@@ -4263,10 +3975,7 @@ export class AuthStateService {
|
|
|
4263
3975
|
): Promise<AuthStateSnapshot | null> {
|
|
4264
3976
|
const numericUserId = Number(userId)
|
|
4265
3977
|
|
|
4266
|
-
if (
|
|
4267
|
-
!Number.isSafeInteger(numericUserId) ||
|
|
4268
|
-
numericUserId <= 0
|
|
4269
|
-
) {
|
|
3978
|
+
if (!Number.isSafeInteger(numericUserId) || numericUserId <= 0) {
|
|
4270
3979
|
return null
|
|
4271
3980
|
}
|
|
4272
3981
|
|
|
@@ -4284,10 +3993,7 @@ export class AuthStateService {
|
|
|
4284
3993
|
.from(users)
|
|
4285
3994
|
.innerJoin(
|
|
4286
3995
|
authSessions,
|
|
4287
|
-
and(
|
|
4288
|
-
eq(authSessions.userId, users.id),
|
|
4289
|
-
eq(authSessions.id, sessionId),
|
|
4290
|
-
),
|
|
3996
|
+
and(eq(authSessions.userId, users.id), eq(authSessions.id, sessionId)),
|
|
4291
3997
|
)
|
|
4292
3998
|
.where(eq(users.id, numericUserId))
|
|
4293
3999
|
.limit(1)
|
|
@@ -4311,15 +4017,11 @@ export class AuthStateService {
|
|
|
4311
4017
|
},
|
|
4312
4018
|
}
|
|
4313
4019
|
} catch {
|
|
4314
|
-
throw new ServiceUnavailableException(
|
|
4315
|
-
'鉴权事实库暂时不可用',
|
|
4316
|
-
)
|
|
4020
|
+
throw new ServiceUnavailableException('鉴权事实库暂时不可用')
|
|
4317
4021
|
}
|
|
4318
4022
|
}
|
|
4319
4023
|
|
|
4320
|
-
private async projectAccount(
|
|
4321
|
-
state: AccountAuthState,
|
|
4322
|
-
): Promise<ProjectionWriteResult> {
|
|
4024
|
+
private async projectAccount(state: AccountAuthState): Promise<ProjectionWriteResult> {
|
|
4323
4025
|
let raw: unknown
|
|
4324
4026
|
|
|
4325
4027
|
try {
|
|
@@ -4331,29 +4033,20 @@ export class AuthStateService {
|
|
|
4331
4033
|
state.status,
|
|
4332
4034
|
)
|
|
4333
4035
|
} catch {
|
|
4334
|
-
throw new ServiceUnavailableException(
|
|
4335
|
-
'账号鉴权状态暂时不可用',
|
|
4336
|
-
)
|
|
4036
|
+
throw new ServiceUnavailableException('账号鉴权状态暂时不可用')
|
|
4337
4037
|
}
|
|
4338
4038
|
|
|
4339
4039
|
return this.decodeProjectionResult(raw, '账号')
|
|
4340
4040
|
}
|
|
4341
4041
|
|
|
4342
|
-
private async projectSession(
|
|
4343
|
-
state: SessionAuthState,
|
|
4344
|
-
): Promise<ProjectionWriteResult> {
|
|
4042
|
+
private async projectSession(state: SessionAuthState): Promise<ProjectionWriteResult> {
|
|
4345
4043
|
const auth = this.config.getOrThrow<AuthConfig>('auth')
|
|
4346
|
-
const activeTtl = Math.ceil(
|
|
4347
|
-
(state.expiresAtMs - Date.now()) / 1000,
|
|
4348
|
-
)
|
|
4044
|
+
const activeTtl = Math.ceil((state.expiresAtMs - Date.now()) / 1000)
|
|
4349
4045
|
|
|
4350
4046
|
const ttlSeconds =
|
|
4351
4047
|
state.status === 'revoked'
|
|
4352
4048
|
? auth.tombstoneTtlSeconds
|
|
4353
|
-
: Math.max(
|
|
4354
|
-
activeTtl,
|
|
4355
|
-
auth.tombstoneTtlSeconds,
|
|
4356
|
-
)
|
|
4049
|
+
: Math.max(activeTtl, auth.tombstoneTtlSeconds)
|
|
4357
4050
|
|
|
4358
4051
|
let raw: unknown
|
|
4359
4052
|
|
|
@@ -4369,18 +4062,13 @@ export class AuthStateService {
|
|
|
4369
4062
|
String(ttlSeconds),
|
|
4370
4063
|
)
|
|
4371
4064
|
} catch {
|
|
4372
|
-
throw new ServiceUnavailableException(
|
|
4373
|
-
'Session 鉴权状态暂时不可用',
|
|
4374
|
-
)
|
|
4065
|
+
throw new ServiceUnavailableException('Session 鉴权状态暂时不可用')
|
|
4375
4066
|
}
|
|
4376
4067
|
|
|
4377
4068
|
return this.decodeProjectionResult(raw, 'Session')
|
|
4378
4069
|
}
|
|
4379
4070
|
|
|
4380
|
-
private decodeProjectionResult(
|
|
4381
|
-
raw: unknown,
|
|
4382
|
-
aggregateName: string,
|
|
4383
|
-
): ProjectionWriteResult {
|
|
4071
|
+
private decodeProjectionResult(raw: unknown, aggregateName: string): ProjectionWriteResult {
|
|
4384
4072
|
const code = Number(raw)
|
|
4385
4073
|
|
|
4386
4074
|
if (code === 1) {
|
|
@@ -4395,9 +4083,7 @@ export class AuthStateService {
|
|
|
4395
4083
|
return 'stale'
|
|
4396
4084
|
}
|
|
4397
4085
|
|
|
4398
|
-
throw new ServiceUnavailableException(
|
|
4399
|
-
aggregateName + '鉴权状态存在版本冲突',
|
|
4400
|
-
)
|
|
4086
|
+
throw new ServiceUnavailableException(aggregateName + '鉴权状态存在版本冲突')
|
|
4401
4087
|
}
|
|
4402
4088
|
|
|
4403
4089
|
private mergeSnapshot(snapshot: AuthStateSnapshot) {
|
|
@@ -4407,21 +4093,13 @@ export class AuthStateService {
|
|
|
4407
4093
|
}
|
|
4408
4094
|
}
|
|
4409
4095
|
|
|
4410
|
-
private mergeAccountCache(
|
|
4411
|
-
state: AccountAuthState,
|
|
4412
|
-
): CacheEntry<AccountAuthState> {
|
|
4096
|
+
private mergeAccountCache(state: AccountAuthState): CacheEntry<AccountAuthState> {
|
|
4413
4097
|
const previous = this.accountCache.peek(state.userId)
|
|
4414
|
-
const fenceFloor =
|
|
4415
|
-
this.accountPullFences.get(state.userId)?.versionFloor ?? 0
|
|
4098
|
+
const fenceFloor = this.accountPullFences.get(state.userId)?.versionFloor ?? 0
|
|
4416
4099
|
|
|
4417
4100
|
if (previous && previous.value.epoch > state.epoch) {
|
|
4418
|
-
previous.versionFloor = Math.max(
|
|
4419
|
-
|
|
4420
|
-
previous.value.epoch,
|
|
4421
|
-
fenceFloor,
|
|
4422
|
-
)
|
|
4423
|
-
previous.dirty =
|
|
4424
|
-
previous.versionFloor > previous.value.epoch
|
|
4101
|
+
previous.versionFloor = Math.max(previous.versionFloor, previous.value.epoch, fenceFloor)
|
|
4102
|
+
previous.dirty = previous.versionFloor > previous.value.epoch
|
|
4425
4103
|
|
|
4426
4104
|
return previous
|
|
4427
4105
|
}
|
|
@@ -4429,21 +4107,14 @@ export class AuthStateService {
|
|
|
4429
4107
|
if (
|
|
4430
4108
|
previous &&
|
|
4431
4109
|
previous.value.epoch === state.epoch &&
|
|
4432
|
-
(previous.value.userId !== state.userId ||
|
|
4433
|
-
previous.value.status !== state.status)
|
|
4110
|
+
(previous.value.userId !== state.userId || previous.value.status !== state.status)
|
|
4434
4111
|
) {
|
|
4435
4112
|
this.accountCache.delete(state.userId)
|
|
4436
4113
|
|
|
4437
|
-
throw new ServiceUnavailableException(
|
|
4438
|
-
'L1 账号状态出现同版本冲突',
|
|
4439
|
-
)
|
|
4114
|
+
throw new ServiceUnavailableException('L1 账号状态出现同版本冲突')
|
|
4440
4115
|
}
|
|
4441
4116
|
|
|
4442
|
-
const versionFloor = Math.max(
|
|
4443
|
-
previous?.versionFloor ?? 0,
|
|
4444
|
-
fenceFloor,
|
|
4445
|
-
state.epoch,
|
|
4446
|
-
)
|
|
4117
|
+
const versionFloor = Math.max(previous?.versionFloor ?? 0, fenceFloor, state.epoch)
|
|
4447
4118
|
|
|
4448
4119
|
const entry: CacheEntry<AccountAuthState> = {
|
|
4449
4120
|
value: state,
|
|
@@ -4455,26 +4126,15 @@ export class AuthStateService {
|
|
|
4455
4126
|
return entry
|
|
4456
4127
|
}
|
|
4457
4128
|
|
|
4458
|
-
private mergeSessionCache(
|
|
4459
|
-
state
|
|
4460
|
-
): CacheEntry<SessionAuthState> {
|
|
4461
|
-
const key = sessionCacheKey(
|
|
4462
|
-
state.userId,
|
|
4463
|
-
state.sessionId,
|
|
4464
|
-
)
|
|
4129
|
+
private mergeSessionCache(state: SessionAuthState): CacheEntry<SessionAuthState> {
|
|
4130
|
+
const key = sessionCacheKey(state.userId, state.sessionId)
|
|
4465
4131
|
|
|
4466
4132
|
const previous = this.sessionCache.peek(key)
|
|
4467
|
-
const fenceFloor =
|
|
4468
|
-
this.sessionPullFences.get(key)?.versionFloor ?? 0
|
|
4133
|
+
const fenceFloor = this.sessionPullFences.get(key)?.versionFloor ?? 0
|
|
4469
4134
|
|
|
4470
4135
|
if (previous && previous.value.version > state.version) {
|
|
4471
|
-
previous.versionFloor = Math.max(
|
|
4472
|
-
|
|
4473
|
-
previous.value.version,
|
|
4474
|
-
fenceFloor,
|
|
4475
|
-
)
|
|
4476
|
-
previous.dirty =
|
|
4477
|
-
previous.versionFloor > previous.value.version
|
|
4136
|
+
previous.versionFloor = Math.max(previous.versionFloor, previous.value.version, fenceFloor)
|
|
4137
|
+
previous.dirty = previous.versionFloor > previous.value.version
|
|
4478
4138
|
|
|
4479
4139
|
return previous
|
|
4480
4140
|
}
|
|
@@ -4489,27 +4149,16 @@ export class AuthStateService {
|
|
|
4489
4149
|
) {
|
|
4490
4150
|
this.sessionCache.delete(key)
|
|
4491
4151
|
|
|
4492
|
-
throw new ServiceUnavailableException(
|
|
4493
|
-
'L1 Session 状态出现同版本冲突',
|
|
4494
|
-
)
|
|
4152
|
+
throw new ServiceUnavailableException('L1 Session 状态出现同版本冲突')
|
|
4495
4153
|
}
|
|
4496
4154
|
|
|
4497
|
-
if (
|
|
4498
|
-
previous?.value.status === 'revoked' &&
|
|
4499
|
-
state.status === 'active'
|
|
4500
|
-
) {
|
|
4155
|
+
if (previous?.value.status === 'revoked' && state.status === 'active') {
|
|
4501
4156
|
this.sessionCache.delete(key)
|
|
4502
4157
|
|
|
4503
|
-
throw new ServiceUnavailableException(
|
|
4504
|
-
'禁止恢复已经撤销的 Session',
|
|
4505
|
-
)
|
|
4158
|
+
throw new ServiceUnavailableException('禁止恢复已经撤销的 Session')
|
|
4506
4159
|
}
|
|
4507
4160
|
|
|
4508
|
-
const versionFloor = Math.max(
|
|
4509
|
-
previous?.versionFloor ?? 0,
|
|
4510
|
-
fenceFloor,
|
|
4511
|
-
state.version,
|
|
4512
|
-
)
|
|
4161
|
+
const versionFloor = Math.max(previous?.versionFloor ?? 0, fenceFloor, state.version)
|
|
4513
4162
|
|
|
4514
4163
|
const entry: CacheEntry<SessionAuthState> = {
|
|
4515
4164
|
value: state,
|
|
@@ -4528,20 +4177,11 @@ export class AuthStateService {
|
|
|
4528
4177
|
accountFence: PullFence,
|
|
4529
4178
|
sessionFence: PullFence,
|
|
4530
4179
|
) {
|
|
4531
|
-
const accountFloor = Math.max(
|
|
4532
|
-
account?.versionFloor ?? 0,
|
|
4533
|
-
accountFence.versionFloor,
|
|
4534
|
-
)
|
|
4180
|
+
const accountFloor = Math.max(account?.versionFloor ?? 0, accountFence.versionFloor)
|
|
4535
4181
|
|
|
4536
|
-
const sessionFloor = Math.max(
|
|
4537
|
-
session?.versionFloor ?? 0,
|
|
4538
|
-
sessionFence.versionFloor,
|
|
4539
|
-
)
|
|
4182
|
+
const sessionFloor = Math.max(session?.versionFloor ?? 0, sessionFence.versionFloor)
|
|
4540
4183
|
|
|
4541
|
-
if (
|
|
4542
|
-
accountFloor > claims.ae ||
|
|
4543
|
-
sessionFloor > claims.sv
|
|
4544
|
-
) {
|
|
4184
|
+
if (accountFloor > claims.ae || sessionFloor > claims.sv) {
|
|
4545
4185
|
throw new UnauthorizedException('登录状态已失效')
|
|
4546
4186
|
}
|
|
4547
4187
|
}
|
|
@@ -4554,15 +4194,9 @@ export class AuthStateService {
|
|
|
4554
4194
|
accountFence: PullFence,
|
|
4555
4195
|
sessionFence: PullFence,
|
|
4556
4196
|
) {
|
|
4557
|
-
const accountFloor = Math.max(
|
|
4558
|
-
account?.versionFloor ?? 0,
|
|
4559
|
-
accountFence.versionFloor,
|
|
4560
|
-
)
|
|
4197
|
+
const accountFloor = Math.max(account?.versionFloor ?? 0, accountFence.versionFloor)
|
|
4561
4198
|
|
|
4562
|
-
const sessionFloor = Math.max(
|
|
4563
|
-
session?.versionFloor ?? 0,
|
|
4564
|
-
sessionFence.versionFloor,
|
|
4565
|
-
)
|
|
4199
|
+
const sessionFloor = Math.max(session?.versionFloor ?? 0, sessionFence.versionFloor)
|
|
4566
4200
|
|
|
4567
4201
|
return (
|
|
4568
4202
|
snapshot.account.epoch >= claims.ae &&
|
|
@@ -4572,19 +4206,12 @@ export class AuthStateService {
|
|
|
4572
4206
|
)
|
|
4573
4207
|
}
|
|
4574
4208
|
|
|
4575
|
-
private acquireFence(
|
|
4576
|
-
map: Map<string, PullFence>,
|
|
4577
|
-
key: string,
|
|
4578
|
-
initialFloor: number,
|
|
4579
|
-
) {
|
|
4209
|
+
private acquireFence(map: Map<string, PullFence>, key: string, initialFloor: number) {
|
|
4580
4210
|
const existing = map.get(key)
|
|
4581
4211
|
|
|
4582
4212
|
if (existing) {
|
|
4583
4213
|
existing.references += 1
|
|
4584
|
-
existing.versionFloor = Math.max(
|
|
4585
|
-
existing.versionFloor,
|
|
4586
|
-
initialFloor,
|
|
4587
|
-
)
|
|
4214
|
+
existing.versionFloor = Math.max(existing.versionFloor, initialFloor)
|
|
4588
4215
|
|
|
4589
4216
|
return existing
|
|
4590
4217
|
}
|
|
@@ -4598,11 +4225,7 @@ export class AuthStateService {
|
|
|
4598
4225
|
return fence
|
|
4599
4226
|
}
|
|
4600
4227
|
|
|
4601
|
-
private releaseFence(
|
|
4602
|
-
map: Map<string, PullFence>,
|
|
4603
|
-
key: string,
|
|
4604
|
-
fence: PullFence,
|
|
4605
|
-
) {
|
|
4228
|
+
private releaseFence(map: Map<string, PullFence>, key: string, fence: PullFence) {
|
|
4606
4229
|
const current = map.get(key)
|
|
4607
4230
|
|
|
4608
4231
|
if (current !== fence) {
|
|
@@ -4669,17 +4292,9 @@ export class AuthStateService {
|
|
|
4669
4292
|
}
|
|
4670
4293
|
}
|
|
4671
4294
|
|
|
4672
|
-
private assertAllowed(
|
|
4673
|
-
snapshot
|
|
4674
|
-
|
|
4675
|
-
) {
|
|
4676
|
-
if (
|
|
4677
|
-
snapshot.account.epoch < claims.ae ||
|
|
4678
|
-
snapshot.session.version < claims.sv
|
|
4679
|
-
) {
|
|
4680
|
-
throw new ServiceUnavailableException(
|
|
4681
|
-
'鉴权状态投影落后',
|
|
4682
|
-
)
|
|
4295
|
+
private assertAllowed(snapshot: AuthStateSnapshot, claims: AccessTokenClaims) {
|
|
4296
|
+
if (snapshot.account.epoch < claims.ae || snapshot.session.version < claims.sv) {
|
|
4297
|
+
throw new ServiceUnavailableException('鉴权状态投影落后')
|
|
4683
4298
|
}
|
|
4684
4299
|
|
|
4685
4300
|
const accepted =
|
|
@@ -4695,7 +4310,7 @@ export class AuthStateService {
|
|
|
4695
4310
|
}
|
|
4696
4311
|
}
|
|
4697
4312
|
}
|
|
4698
|
-
|
|
4313
|
+
```
|
|
4699
4314
|
|
|
4700
4315
|
这版缓存代码必须保留以下行为:
|
|
4701
4316
|
|
|
@@ -4721,21 +4336,21 @@ export class AuthStateService {
|
|
|
4721
4336
|
|
|
4722
4337
|
修改:
|
|
4723
4338
|
|
|
4724
|
-
|
|
4339
|
+
```text
|
|
4725
4340
|
apps/server/src/modules/auth/guards/hybrid-auth.guard.ts
|
|
4726
|
-
|
|
4341
|
+
```
|
|
4727
4342
|
|
|
4728
4343
|
找到:
|
|
4729
4344
|
|
|
4730
|
-
|
|
4345
|
+
```ts
|
|
4731
4346
|
) ?? 'strong'
|
|
4732
|
-
|
|
4347
|
+
```
|
|
4733
4348
|
|
|
4734
4349
|
改成:
|
|
4735
4350
|
|
|
4736
|
-
|
|
4351
|
+
```ts
|
|
4737
4352
|
) ?? 'bounded'
|
|
4738
|
-
|
|
4353
|
+
```
|
|
4739
4354
|
|
|
4740
4355
|
现在规则变成:
|
|
4741
4356
|
|
|
@@ -4745,22 +4360,22 @@ apps/server/src/modules/auth/guards/hybrid-auth.guard.ts
|
|
|
4745
4360
|
|
|
4746
4361
|
建议:
|
|
4747
4362
|
|
|
4748
|
-
| 接口
|
|
4749
|
-
|
|
|
4750
|
-
| 普通列表、详情、搜索 | bounded
|
|
4751
|
-
| 修改密码
|
|
4752
|
-
| 退出全部设备
|
|
4753
|
-
| 管理员踢人
|
|
4363
|
+
| 接口 | 模式 |
|
|
4364
|
+
| -------------------- | -------------------------- |
|
|
4365
|
+
| 普通列表、详情、搜索 | bounded |
|
|
4366
|
+
| 修改密码 | strong |
|
|
4367
|
+
| 退出全部设备 | strong |
|
|
4368
|
+
| 管理员踢人 | strong |
|
|
4754
4369
|
| 付款、提现、绑定密钥 | strong,并考虑 Recent Auth |
|
|
4755
|
-
| 登录、注册、刷新
|
|
4370
|
+
| 登录、注册、刷新 | Public |
|
|
4756
4371
|
|
|
4757
4372
|
写接口不一定全部 strong,但只要操作会扩大权限、转移资产或修改认证状态,就应该 strong。
|
|
4758
4373
|
|
|
4759
4374
|
## 11.4 构建
|
|
4760
4375
|
|
|
4761
|
-
|
|
4376
|
+
```powershell
|
|
4762
4377
|
vp run "server#build"
|
|
4763
|
-
|
|
4378
|
+
```
|
|
4764
4379
|
|
|
4765
4380
|
## 11.5 验收 bounded 延迟
|
|
4766
4381
|
|
|
@@ -4768,21 +4383,21 @@ vp run "server#build"
|
|
|
4768
4383
|
|
|
4769
4384
|
终端 A:
|
|
4770
4385
|
|
|
4771
|
-
|
|
4386
|
+
```powershell
|
|
4772
4387
|
$env:PORT = "3000"
|
|
4773
4388
|
vp run "server#dev"
|
|
4774
|
-
|
|
4389
|
+
```
|
|
4775
4390
|
|
|
4776
4391
|
终端 B:
|
|
4777
4392
|
|
|
4778
|
-
|
|
4393
|
+
```powershell
|
|
4779
4394
|
$env:PORT = "3001"
|
|
4780
4395
|
vp run "server#dev"
|
|
4781
|
-
|
|
4396
|
+
```
|
|
4782
4397
|
|
|
4783
4398
|
另开第三个 PowerShell,按下面的命令创建 Session,并先调用 A 的普通 me,让 A 写入 L1:
|
|
4784
4399
|
|
|
4785
|
-
|
|
4400
|
+
```powershell
|
|
4786
4401
|
$boundedWeb = New-Object Microsoft.PowerShell.Commands.WebRequestSession
|
|
4787
4402
|
$boundedBody = @{
|
|
4788
4403
|
email = "auth-test@example.com"
|
|
@@ -4793,27 +4408,27 @@ $boundedLogin = Invoke-RestMethod -Method Post -Uri "http://localhost:3000/auth/
|
|
|
4793
4408
|
$boundedToken = $boundedLogin.data.accessToken
|
|
4794
4409
|
|
|
4795
4410
|
Invoke-RestMethod -Uri "http://localhost:3000/auth/me" -Headers @{ Authorization = "Bearer $boundedToken" }
|
|
4796
|
-
|
|
4411
|
+
```
|
|
4797
4412
|
|
|
4798
4413
|
然后让实例 B 使用同一枚 JWT 撤销这个 Session:
|
|
4799
4414
|
|
|
4800
|
-
|
|
4415
|
+
```powershell
|
|
4801
4416
|
Invoke-RestMethod -Method Post -Uri "http://localhost:3001/auth/logout" -Headers @{ Authorization = "Bearer $boundedToken" } -WebSession $boundedWeb
|
|
4802
|
-
|
|
4417
|
+
```
|
|
4803
4418
|
|
|
4804
4419
|
因为此时还没有 Pub/Sub,立刻请求 A 时可能仍然成功:
|
|
4805
4420
|
|
|
4806
|
-
|
|
4421
|
+
```powershell
|
|
4807
4422
|
try {
|
|
4808
4423
|
Invoke-RestMethod -Uri "http://localhost:3000/auth/me" -Headers @{ Authorization = "Bearer $boundedToken" }
|
|
4809
4424
|
} catch {
|
|
4810
4425
|
$_.Exception.Response.StatusCode.value__
|
|
4811
4426
|
}
|
|
4812
|
-
|
|
4427
|
+
```
|
|
4813
4428
|
|
|
4814
4429
|
等待一个完整 TTL 加少量余量后再次请求:
|
|
4815
4430
|
|
|
4816
|
-
|
|
4431
|
+
```powershell
|
|
4817
4432
|
Start-Sleep -Seconds 32
|
|
4818
4433
|
|
|
4819
4434
|
try {
|
|
@@ -4821,7 +4436,7 @@ try {
|
|
|
4821
4436
|
} catch {
|
|
4822
4437
|
$_.Exception.Response.StatusCode.value__
|
|
4823
4438
|
}
|
|
4824
|
-
|
|
4439
|
+
```
|
|
4825
4440
|
|
|
4826
4441
|
第二次必须得到 401。如果 Redis 写入失败、Outbox 尚在恢复或本机调度发生明显暂停,收敛时间还会叠加对应延迟,因此生产监控应记录实际撤销传播时间,不能只观察配置里的 30_000。
|
|
4827
4442
|
|
|
@@ -4855,7 +4470,7 @@ try {
|
|
|
4855
4470
|
|
|
4856
4471
|
流程是:
|
|
4857
4472
|
|
|
4858
|
-
|
|
4473
|
+
```text
|
|
4859
4474
|
状态修改服务
|
|
4860
4475
|
-> PostgreSQL 提交
|
|
4861
4476
|
-> 同步写 Redis Auth State
|
|
@@ -4866,7 +4481,7 @@ try {
|
|
|
4866
4481
|
-> 如果本地根本没缓存该用户,忽略
|
|
4867
4482
|
-> 如果本地有旧缓存,标 dirty 和 versionFloor
|
|
4868
4483
|
-> 下一个请求再 Pull
|
|
4869
|
-
|
|
4484
|
+
```
|
|
4870
4485
|
|
|
4871
4486
|
这就是推拉结合。Push 只传失效提示,Pull 才拿真实状态。
|
|
4872
4487
|
|
|
@@ -4874,13 +4489,13 @@ try {
|
|
|
4874
4489
|
|
|
4875
4490
|
新增:
|
|
4876
4491
|
|
|
4877
|
-
|
|
4492
|
+
```text
|
|
4878
4493
|
apps/server/src/modules/auth/state/auth-state.events.ts
|
|
4879
|
-
|
|
4494
|
+
```
|
|
4880
4495
|
|
|
4881
4496
|
粘贴:
|
|
4882
4497
|
|
|
4883
|
-
|
|
4498
|
+
```ts
|
|
4884
4499
|
export type AuthStateInvalidationEvent =
|
|
4885
4500
|
| {
|
|
4886
4501
|
type: 'account.changed'
|
|
@@ -4893,7 +4508,7 @@ export type AuthStateInvalidationEvent =
|
|
|
4893
4508
|
sessionId: string
|
|
4894
4509
|
version: number
|
|
4895
4510
|
}
|
|
4896
|
-
|
|
4511
|
+
```
|
|
4897
4512
|
|
|
4898
4513
|
事件不携带密码、Token、完整用户资料或权限列表。
|
|
4899
4514
|
|
|
@@ -4901,22 +4516,19 @@ export type AuthStateInvalidationEvent =
|
|
|
4901
4516
|
|
|
4902
4517
|
新增:
|
|
4903
4518
|
|
|
4904
|
-
|
|
4519
|
+
```text
|
|
4905
4520
|
apps/server/src/modules/auth/state/auth-state.publisher.ts
|
|
4906
|
-
|
|
4521
|
+
```
|
|
4907
4522
|
|
|
4908
4523
|
粘贴:
|
|
4909
4524
|
|
|
4910
|
-
|
|
4525
|
+
```ts
|
|
4911
4526
|
import { InjectRedis } from '@nestjs-modules/ioredis'
|
|
4912
4527
|
import { Injectable, Logger } from '@nestjs/common'
|
|
4913
4528
|
import { ConfigService } from '@nestjs/config'
|
|
4914
4529
|
import type Redis from 'ioredis'
|
|
4915
4530
|
import type { AuthConfig } from '@/config/auth.config'
|
|
4916
|
-
import type {
|
|
4917
|
-
AccountAuthState,
|
|
4918
|
-
SessionAuthState,
|
|
4919
|
-
} from '../auth.types'
|
|
4531
|
+
import type { AccountAuthState, SessionAuthState } from '../auth.types'
|
|
4920
4532
|
import type { AuthStateInvalidationEvent } from './auth-state.events'
|
|
4921
4533
|
|
|
4922
4534
|
@Injectable()
|
|
@@ -4949,39 +4561,29 @@ export class AuthStatePublisher {
|
|
|
4949
4561
|
const auth = this.config.getOrThrow<AuthConfig>('auth')
|
|
4950
4562
|
|
|
4951
4563
|
try {
|
|
4952
|
-
await this.redis.publish(
|
|
4953
|
-
auth.eventChannel,
|
|
4954
|
-
JSON.stringify(event),
|
|
4955
|
-
)
|
|
4564
|
+
await this.redis.publish(auth.eventChannel, JSON.stringify(event))
|
|
4956
4565
|
} catch (error) {
|
|
4957
4566
|
// Redis Auth State 已经同步写入,所以 Pub/Sub 失败不回滚业务。
|
|
4958
4567
|
// 步骤 13 的 Outbox Worker 会负责重试发布。
|
|
4959
|
-
this.logger.warn(
|
|
4960
|
-
'Auth State 失效事件发布失败:' + String(error),
|
|
4961
|
-
)
|
|
4568
|
+
this.logger.warn('Auth State 失效事件发布失败:' + String(error))
|
|
4962
4569
|
}
|
|
4963
4570
|
}
|
|
4964
4571
|
}
|
|
4965
|
-
|
|
4572
|
+
```
|
|
4966
4573
|
|
|
4967
4574
|
## 12.3 新增 Subscriber
|
|
4968
4575
|
|
|
4969
4576
|
新增:
|
|
4970
4577
|
|
|
4971
|
-
|
|
4578
|
+
```text
|
|
4972
4579
|
apps/server/src/modules/auth/state/auth-state.subscriber.ts
|
|
4973
|
-
|
|
4580
|
+
```
|
|
4974
4581
|
|
|
4975
4582
|
粘贴:
|
|
4976
4583
|
|
|
4977
|
-
|
|
4584
|
+
```ts
|
|
4978
4585
|
import { InjectRedis } from '@nestjs-modules/ioredis'
|
|
4979
|
-
import {
|
|
4980
|
-
Injectable,
|
|
4981
|
-
Logger,
|
|
4982
|
-
type OnModuleDestroy,
|
|
4983
|
-
type OnModuleInit,
|
|
4984
|
-
} from '@nestjs/common'
|
|
4586
|
+
import { Injectable, Logger, type OnModuleDestroy, type OnModuleInit } from '@nestjs/common'
|
|
4985
4587
|
import { ConfigService } from '@nestjs/config'
|
|
4986
4588
|
import type Redis from 'ioredis'
|
|
4987
4589
|
import type { AuthConfig } from '@/config/auth.config'
|
|
@@ -4989,9 +4591,7 @@ import type { AuthStateInvalidationEvent } from './auth-state.events'
|
|
|
4989
4591
|
import { AuthStateService } from './auth-state.service'
|
|
4990
4592
|
|
|
4991
4593
|
@Injectable()
|
|
4992
|
-
export class AuthStateSubscriber
|
|
4993
|
-
implements OnModuleInit, OnModuleDestroy
|
|
4994
|
-
{
|
|
4594
|
+
export class AuthStateSubscriber implements OnModuleInit, OnModuleDestroy {
|
|
4995
4595
|
private readonly logger = new Logger(AuthStateSubscriber.name)
|
|
4996
4596
|
private readonly subscriber: Redis
|
|
4997
4597
|
|
|
@@ -5031,10 +4631,7 @@ export class AuthStateSubscriber
|
|
|
5031
4631
|
typeof event.userId === 'string' &&
|
|
5032
4632
|
Number.isSafeInteger(event.version)
|
|
5033
4633
|
) {
|
|
5034
|
-
this.authState.markAccountDirty(
|
|
5035
|
-
event.userId,
|
|
5036
|
-
event.version,
|
|
5037
|
-
)
|
|
4634
|
+
this.authState.markAccountDirty(event.userId, event.version)
|
|
5038
4635
|
return
|
|
5039
4636
|
}
|
|
5040
4637
|
|
|
@@ -5044,18 +4641,14 @@ export class AuthStateSubscriber
|
|
|
5044
4641
|
typeof event.sessionId === 'string' &&
|
|
5045
4642
|
Number.isSafeInteger(event.version)
|
|
5046
4643
|
) {
|
|
5047
|
-
this.authState.markSessionDirty(
|
|
5048
|
-
event.userId,
|
|
5049
|
-
event.sessionId,
|
|
5050
|
-
event.version,
|
|
5051
|
-
)
|
|
4644
|
+
this.authState.markSessionDirty(event.userId, event.sessionId, event.version)
|
|
5052
4645
|
}
|
|
5053
4646
|
} catch (error) {
|
|
5054
4647
|
this.logger.warn('忽略非法 Auth State 事件:' + String(error))
|
|
5055
4648
|
}
|
|
5056
4649
|
}
|
|
5057
4650
|
}
|
|
5058
|
-
|
|
4651
|
+
```
|
|
5059
4652
|
|
|
5060
4653
|
Subscriber 必须使用 redis.duplicate。不能把普通命令连接直接拿去 subscribe。
|
|
5061
4654
|
|
|
@@ -5063,97 +4656,97 @@ Subscriber 必须使用 redis.duplicate。不能把普通命令连接直接拿
|
|
|
5063
4656
|
|
|
5064
4657
|
修改:
|
|
5065
4658
|
|
|
5066
|
-
|
|
4659
|
+
```text
|
|
5067
4660
|
apps/server/src/modules/auth/auth.module.ts
|
|
5068
|
-
|
|
4661
|
+
```
|
|
5069
4662
|
|
|
5070
4663
|
增加 import:
|
|
5071
4664
|
|
|
5072
|
-
|
|
4665
|
+
```ts
|
|
5073
4666
|
import { AuthStatePublisher } from './state/auth-state.publisher'
|
|
5074
4667
|
import { AuthStateSubscriber } from './state/auth-state.subscriber'
|
|
5075
|
-
|
|
4668
|
+
```
|
|
5076
4669
|
|
|
5077
4670
|
在 providers 中增加:
|
|
5078
4671
|
|
|
5079
|
-
|
|
4672
|
+
```ts
|
|
5080
4673
|
AuthStatePublisher,
|
|
5081
4674
|
AuthStateSubscriber,
|
|
5082
|
-
|
|
4675
|
+
```
|
|
5083
4676
|
|
|
5084
4677
|
## 12.5 让状态修改后发布事件
|
|
5085
4678
|
|
|
5086
4679
|
修改:
|
|
5087
4680
|
|
|
5088
|
-
|
|
4681
|
+
```text
|
|
5089
4682
|
apps/server/src/modules/auth/auth.service.ts
|
|
5090
|
-
|
|
4683
|
+
```
|
|
5091
4684
|
|
|
5092
4685
|
增加 import:
|
|
5093
4686
|
|
|
5094
|
-
|
|
4687
|
+
```ts
|
|
5095
4688
|
import { AuthStatePublisher } from './state/auth-state.publisher'
|
|
5096
|
-
|
|
4689
|
+
```
|
|
5097
4690
|
|
|
5098
4691
|
在 constructor 增加依赖:
|
|
5099
4692
|
|
|
5100
|
-
|
|
4693
|
+
```ts
|
|
5101
4694
|
private readonly authEvents: AuthStatePublisher,
|
|
5102
|
-
|
|
4695
|
+
```
|
|
5103
4696
|
|
|
5104
4697
|
然后完成以下机械修改。
|
|
5105
4698
|
|
|
5106
4699
|
Refresh 重放分支中,在 writeThroughSession 后增加:
|
|
5107
4700
|
|
|
5108
|
-
|
|
4701
|
+
```ts
|
|
5109
4702
|
await this.authEvents.sessionChanged(outcome.session)
|
|
5110
|
-
|
|
4703
|
+
```
|
|
5111
4704
|
|
|
5112
4705
|
logoutCurrent 中改为:
|
|
5113
4706
|
|
|
5114
|
-
|
|
4707
|
+
```ts
|
|
5115
4708
|
if (state) {
|
|
5116
4709
|
await this.authState.writeThroughSession(state)
|
|
5117
4710
|
await this.authEvents.sessionChanged(state)
|
|
5118
4711
|
}
|
|
5119
|
-
|
|
4712
|
+
```
|
|
5120
4713
|
|
|
5121
4714
|
revokeSessionByAdmin 中改为:
|
|
5122
4715
|
|
|
5123
|
-
|
|
4716
|
+
```ts
|
|
5124
4717
|
if (state) {
|
|
5125
4718
|
await this.authState.writeThroughSession(state)
|
|
5126
4719
|
await this.authEvents.sessionChanged(state)
|
|
5127
4720
|
}
|
|
5128
|
-
|
|
4721
|
+
```
|
|
5129
4722
|
|
|
5130
4723
|
logoutAll 在 writeThroughAccount 后增加:
|
|
5131
4724
|
|
|
5132
|
-
|
|
4725
|
+
```ts
|
|
5133
4726
|
await this.authEvents.accountChanged({
|
|
5134
4727
|
userId,
|
|
5135
4728
|
status: account.status,
|
|
5136
4729
|
epoch: account.authEpoch,
|
|
5137
4730
|
})
|
|
5138
|
-
|
|
4731
|
+
```
|
|
5139
4732
|
|
|
5140
4733
|
changePassword 在 writeThroughAccount 后增加:
|
|
5141
4734
|
|
|
5142
|
-
|
|
4735
|
+
```ts
|
|
5143
4736
|
await this.authEvents.accountChanged({
|
|
5144
4737
|
userId: input.userId,
|
|
5145
4738
|
status: updated.status,
|
|
5146
4739
|
epoch: updated.authEpoch,
|
|
5147
4740
|
})
|
|
5148
|
-
|
|
4741
|
+
```
|
|
5149
4742
|
|
|
5150
4743
|
登录创建新 Session 时可以不发布,因为其他实例通常没有这个新 sid 的缓存。即使发布,没有缓存的实例也会忽略。
|
|
5151
4744
|
|
|
5152
4745
|
## 12.6 构建
|
|
5153
4746
|
|
|
5154
|
-
|
|
4747
|
+
```powershell
|
|
5155
4748
|
vp run "server#build"
|
|
5156
|
-
|
|
4749
|
+
```
|
|
5157
4750
|
|
|
5158
4751
|
## 12.7 两实例验收 Push/Pull
|
|
5159
4752
|
|
|
@@ -5198,11 +4791,11 @@ vp run "server#build"
|
|
|
5198
4791
|
|
|
5199
4792
|
步骤 12 的快速路径是:
|
|
5200
4793
|
|
|
5201
|
-
|
|
4794
|
+
```text
|
|
5202
4795
|
PostgreSQL COMMIT
|
|
5203
4796
|
-> 写 Redis
|
|
5204
4797
|
-> publish
|
|
5205
|
-
|
|
4798
|
+
```
|
|
5206
4799
|
|
|
5207
4800
|
如果进程恰好在 COMMIT 后、写 Redis 前崩溃,数据库已经撤销 Session,但 Redis 仍可能是旧状态。
|
|
5208
4801
|
|
|
@@ -5212,19 +4805,19 @@ Outbox 保证最终恢复,不代表魔法般的跨 PostgreSQL 和 Redis 单事
|
|
|
5212
4805
|
|
|
5213
4806
|
安全命令仍遵循:
|
|
5214
4807
|
|
|
5215
|
-
|
|
4808
|
+
```text
|
|
5216
4809
|
1. PostgreSQL 事务更新状态并写 Outbox
|
|
5217
4810
|
2. 提交后同步投影 Redis
|
|
5218
4811
|
3. Redis 确认后,安全命令才向调用方报告成功
|
|
5219
4812
|
4. Pub/Sub 可以 best effort
|
|
5220
4813
|
5. Outbox Worker 负责崩溃恢复和重试
|
|
5221
|
-
|
|
4814
|
+
```
|
|
5222
4815
|
|
|
5223
4816
|
## 13.1 安装调度依赖
|
|
5224
4817
|
|
|
5225
|
-
|
|
4818
|
+
```powershell
|
|
5226
4819
|
vp add @nestjs/schedule --filter server --save-catalog
|
|
5227
|
-
|
|
4820
|
+
```
|
|
5228
4821
|
|
|
5229
4822
|
auth_outbox 表已经在步骤 4 创建,不需要再新增表。
|
|
5230
4823
|
|
|
@@ -5236,13 +4829,13 @@ Outbox 不能只保存 aggregateId。否则数据库行以后被物理删除,W
|
|
|
5236
4829
|
|
|
5237
4830
|
修改:
|
|
5238
4831
|
|
|
5239
|
-
|
|
4832
|
+
```text
|
|
5240
4833
|
apps/server/src/modules/auth/auth.types.ts
|
|
5241
|
-
|
|
4834
|
+
```
|
|
5242
4835
|
|
|
5243
4836
|
在文件末尾增加:
|
|
5244
4837
|
|
|
5245
|
-
|
|
4838
|
+
```ts
|
|
5246
4839
|
export interface AccountChangedOutboxPayload {
|
|
5247
4840
|
schemaVersion: 1
|
|
5248
4841
|
state: AccountAuthState
|
|
@@ -5252,7 +4845,7 @@ export interface SessionChangedOutboxPayload {
|
|
|
5252
4845
|
schemaVersion: 1
|
|
5253
4846
|
state: SessionAuthState
|
|
5254
4847
|
}
|
|
5255
|
-
|
|
4848
|
+
```
|
|
5256
4849
|
|
|
5257
4850
|
payload 字段在 schema 中仍然是 unknown。上面的接口用于写入端检查;Worker 读取 JSONB 时仍必须做运行时校验。
|
|
5258
4851
|
|
|
@@ -5260,13 +4853,13 @@ payload 字段在 schema 中仍然是 unknown。上面的接口用于写入端
|
|
|
5260
4853
|
|
|
5261
4854
|
修改:
|
|
5262
4855
|
|
|
5263
|
-
|
|
4856
|
+
```text
|
|
5264
4857
|
apps/server/src/modules/auth/auth.repository.ts
|
|
5265
|
-
|
|
4858
|
+
```
|
|
5266
4859
|
|
|
5267
4860
|
在 schema import 中加入 authOutbox:
|
|
5268
4861
|
|
|
5269
|
-
|
|
4862
|
+
```ts
|
|
5270
4863
|
import {
|
|
5271
4864
|
authOutbox,
|
|
5272
4865
|
authSessions,
|
|
@@ -5274,24 +4867,24 @@ import {
|
|
|
5274
4867
|
refreshTokens,
|
|
5275
4868
|
users,
|
|
5276
4869
|
} from '@/database/schema'
|
|
5277
|
-
|
|
4870
|
+
```
|
|
5278
4871
|
|
|
5279
4872
|
再增加类型 import:
|
|
5280
4873
|
|
|
5281
|
-
|
|
4874
|
+
```ts
|
|
5282
4875
|
import type {
|
|
5283
4876
|
AccountAuthState,
|
|
5284
4877
|
AccountChangedOutboxPayload,
|
|
5285
4878
|
SessionAuthState,
|
|
5286
4879
|
SessionChangedOutboxPayload,
|
|
5287
4880
|
} from './auth.types'
|
|
5288
|
-
|
|
4881
|
+
```
|
|
5289
4882
|
|
|
5290
4883
|
### 13.2.3 Refresh 重放事务写 Session tombstone
|
|
5291
4884
|
|
|
5292
4885
|
在 rotateRefreshToken 的 consumed 分支中,updatedSession 更新成功后、return reuse 之前增加:
|
|
5293
4886
|
|
|
5294
|
-
|
|
4887
|
+
```ts
|
|
5295
4888
|
if (updatedSession) {
|
|
5296
4889
|
const state: SessionAuthState = {
|
|
5297
4890
|
sessionId: updatedSession.id,
|
|
@@ -5312,7 +4905,7 @@ if (updatedSession) {
|
|
|
5312
4905
|
} satisfies SessionChangedOutboxPayload,
|
|
5313
4906
|
})
|
|
5314
4907
|
}
|
|
5315
|
-
|
|
4908
|
+
```
|
|
5316
4909
|
|
|
5317
4910
|
只有本事务真的完成 active -> revoked 时才写 Outbox。Session 之前已经 revoked 时,不增加 version,也不制造重复状态事件。
|
|
5318
4911
|
|
|
@@ -5320,7 +4913,7 @@ if (updatedSession) {
|
|
|
5320
4913
|
|
|
5321
4914
|
在 revokeSession 的 session.status === active 分支中,得到 updatedSession 并把 version 赋值后增加:
|
|
5322
4915
|
|
|
5323
|
-
|
|
4916
|
+
```ts
|
|
5324
4917
|
const state: SessionAuthState = {
|
|
5325
4918
|
sessionId: updatedSession.id,
|
|
5326
4919
|
userId: String(updatedSession.userId),
|
|
@@ -5339,7 +4932,7 @@ await tx.insert(authOutbox).values({
|
|
|
5339
4932
|
state,
|
|
5340
4933
|
} satisfies SessionChangedOutboxPayload,
|
|
5341
4934
|
})
|
|
5342
|
-
|
|
4935
|
+
```
|
|
5343
4936
|
|
|
5344
4937
|
这段 insert 和 Session 撤销必须在同一个 tx 回调内。
|
|
5345
4938
|
|
|
@@ -5347,7 +4940,7 @@ await tx.insert(authOutbox).values({
|
|
|
5347
4940
|
|
|
5348
4941
|
在 bumpAccountEpoch 中得到 updated 后、return updated 前增加:
|
|
5349
4942
|
|
|
5350
|
-
|
|
4943
|
+
```ts
|
|
5351
4944
|
const state: AccountAuthState = {
|
|
5352
4945
|
userId: String(updated.id),
|
|
5353
4946
|
status: updated.status,
|
|
@@ -5364,13 +4957,13 @@ await tx.insert(authOutbox).values({
|
|
|
5364
4957
|
state,
|
|
5365
4958
|
} satisfies AccountChangedOutboxPayload,
|
|
5366
4959
|
})
|
|
5367
|
-
|
|
4960
|
+
```
|
|
5368
4961
|
|
|
5369
4962
|
### 13.2.6 改密写 Account 快照
|
|
5370
4963
|
|
|
5371
4964
|
在 changePassword 中得到 updated 后、return updated 前增加完全相同的账号事件:
|
|
5372
4965
|
|
|
5373
|
-
|
|
4966
|
+
```ts
|
|
5374
4967
|
const state: AccountAuthState = {
|
|
5375
4968
|
userId: String(updated.id),
|
|
5376
4969
|
status: updated.status,
|
|
@@ -5387,7 +4980,7 @@ await tx.insert(authOutbox).values({
|
|
|
5387
4980
|
state,
|
|
5388
4981
|
} satisfies AccountChangedOutboxPayload,
|
|
5389
4982
|
})
|
|
5390
|
-
|
|
4983
|
+
```
|
|
5391
4984
|
|
|
5392
4985
|
正常 Refresh 只增加 refreshGeneration,不让 Access JWT 失效,所以不写 Auth State Outbox。
|
|
5393
4986
|
|
|
@@ -5397,29 +4990,19 @@ await tx.insert(authOutbox).values({
|
|
|
5397
4990
|
|
|
5398
4991
|
修改:
|
|
5399
4992
|
|
|
5400
|
-
|
|
4993
|
+
```text
|
|
5401
4994
|
apps/server/src/modules/auth/auth.repository.ts
|
|
5402
|
-
|
|
4995
|
+
```
|
|
5403
4996
|
|
|
5404
4997
|
把 drizzle-orm import 扩展为下面这个完整列表。不要漏掉步骤 9 advisory lock 使用的 sql:
|
|
5405
4998
|
|
|
5406
|
-
|
|
5407
|
-
import {
|
|
5408
|
-
|
|
5409
|
-
asc,
|
|
5410
|
-
eq,
|
|
5411
|
-
inArray,
|
|
5412
|
-
isNull,
|
|
5413
|
-
lt,
|
|
5414
|
-
lte,
|
|
5415
|
-
or,
|
|
5416
|
-
sql,
|
|
5417
|
-
} from 'drizzle-orm'
|
|
5418
|
-
~~~
|
|
4999
|
+
```ts
|
|
5000
|
+
import { and, asc, eq, inArray, isNull, lt, lte, or, sql } from 'drizzle-orm'
|
|
5001
|
+
```
|
|
5419
5002
|
|
|
5420
5003
|
在 AuthRepository 类中增加:
|
|
5421
5004
|
|
|
5422
|
-
|
|
5005
|
+
```ts
|
|
5423
5006
|
findSessionById(sessionId: string) {
|
|
5424
5007
|
return this.db.query.authSessions.findFirst({
|
|
5425
5008
|
where: eq(authSessions.id, sessionId),
|
|
@@ -5541,7 +5124,7 @@ async releaseOutboxForRetry(input: {
|
|
|
5541
5124
|
)
|
|
5542
5125
|
}
|
|
5543
5126
|
}
|
|
5544
|
-
|
|
5127
|
+
```
|
|
5545
5128
|
|
|
5546
5129
|
这里的并发协议是:
|
|
5547
5130
|
|
|
@@ -5558,13 +5141,13 @@ Outbox 仍按“至少一次”处理。投影和 publish 都必须幂等,不
|
|
|
5558
5141
|
|
|
5559
5142
|
修改:
|
|
5560
5143
|
|
|
5561
|
-
|
|
5144
|
+
```text
|
|
5562
5145
|
apps/server/src/modules/auth/state/auth-state.publisher.ts
|
|
5563
|
-
|
|
5146
|
+
```
|
|
5564
5147
|
|
|
5565
5148
|
把 accountChanged、sessionChanged 和 publish 三个方法替换成:
|
|
5566
5149
|
|
|
5567
|
-
|
|
5150
|
+
```ts
|
|
5568
5151
|
accountChanged(
|
|
5569
5152
|
state: AccountAuthState,
|
|
5570
5153
|
bestEffort = true,
|
|
@@ -5615,7 +5198,7 @@ private async publish(
|
|
|
5615
5198
|
}
|
|
5616
5199
|
}
|
|
5617
5200
|
}
|
|
5618
|
-
|
|
5201
|
+
```
|
|
5619
5202
|
|
|
5620
5203
|
AuthService 仍使用默认 bestEffort。Outbox Worker 会传 false,只有 publish 成功后才标记 published。
|
|
5621
5204
|
|
|
@@ -5623,116 +5206,67 @@ AuthService 仍使用默认 bestEffort。Outbox Worker 会传 false,只有 pub
|
|
|
5623
5206
|
|
|
5624
5207
|
新增:
|
|
5625
5208
|
|
|
5626
|
-
|
|
5209
|
+
```text
|
|
5627
5210
|
apps/server/src/modules/auth/state/auth-outbox.worker.ts
|
|
5628
|
-
|
|
5211
|
+
```
|
|
5629
5212
|
|
|
5630
5213
|
粘贴完整代码:
|
|
5631
5214
|
|
|
5632
|
-
|
|
5215
|
+
```ts
|
|
5633
5216
|
import { randomUUID } from 'node:crypto'
|
|
5634
5217
|
import { Injectable, Logger } from '@nestjs/common'
|
|
5635
5218
|
import { Interval } from '@nestjs/schedule'
|
|
5636
5219
|
import { AuthRepository } from '../auth.repository'
|
|
5637
|
-
import type {
|
|
5638
|
-
AccountAuthState,
|
|
5639
|
-
SessionAuthState,
|
|
5640
|
-
} from '../auth.types'
|
|
5220
|
+
import type { AccountAuthState, SessionAuthState } from '../auth.types'
|
|
5641
5221
|
import { AuthStatePublisher } from './auth-state.publisher'
|
|
5642
5222
|
import { AuthStateService } from './auth-state.service'
|
|
5643
5223
|
|
|
5644
|
-
type ClaimedOutboxRow =
|
|
5645
|
-
|
|
5646
|
-
|
|
5647
|
-
|
|
5648
|
-
|
|
5649
|
-
function invalid(
|
|
5650
|
-
row: ClaimedOutboxRow,
|
|
5651
|
-
message: string,
|
|
5652
|
-
): never {
|
|
5653
|
-
throw new Error(
|
|
5654
|
-
'Auth Outbox ' + row.id + ' 非法:' + message,
|
|
5655
|
-
)
|
|
5224
|
+
type ClaimedOutboxRow = Awaited<ReturnType<AuthRepository['claimAuthOutbox']>>[number]
|
|
5225
|
+
|
|
5226
|
+
function invalid(row: ClaimedOutboxRow, message: string): never {
|
|
5227
|
+
throw new Error('Auth Outbox ' + row.id + ' 非法:' + message)
|
|
5656
5228
|
}
|
|
5657
5229
|
|
|
5658
|
-
function readRecord(
|
|
5659
|
-
|
|
5660
|
-
value: unknown,
|
|
5661
|
-
field: string,
|
|
5662
|
-
): Record<string, unknown> {
|
|
5663
|
-
if (
|
|
5664
|
-
typeof value !== 'object' ||
|
|
5665
|
-
value === null ||
|
|
5666
|
-
Array.isArray(value)
|
|
5667
|
-
) {
|
|
5230
|
+
function readRecord(row: ClaimedOutboxRow, value: unknown, field: string): Record<string, unknown> {
|
|
5231
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
5668
5232
|
invalid(row, field + ' 必须是对象')
|
|
5669
5233
|
}
|
|
5670
5234
|
|
|
5671
5235
|
return value as Record<string, unknown>
|
|
5672
5236
|
}
|
|
5673
5237
|
|
|
5674
|
-
function readString(
|
|
5675
|
-
row: ClaimedOutboxRow,
|
|
5676
|
-
value: Record<string, unknown>,
|
|
5677
|
-
field: string,
|
|
5678
|
-
) {
|
|
5238
|
+
function readString(row: ClaimedOutboxRow, value: Record<string, unknown>, field: string) {
|
|
5679
5239
|
const result = value[field]
|
|
5680
5240
|
|
|
5681
|
-
if (
|
|
5682
|
-
|
|
5683
|
-
result.length === 0 ||
|
|
5684
|
-
result.trim() !== result
|
|
5685
|
-
) {
|
|
5686
|
-
invalid(
|
|
5687
|
-
row,
|
|
5688
|
-
field + ' 必须是非空且无首尾空格的字符串',
|
|
5689
|
-
)
|
|
5241
|
+
if (typeof result !== 'string' || result.length === 0 || result.trim() !== result) {
|
|
5242
|
+
invalid(row, field + ' 必须是非空且无首尾空格的字符串')
|
|
5690
5243
|
}
|
|
5691
5244
|
|
|
5692
5245
|
return result
|
|
5693
5246
|
}
|
|
5694
5247
|
|
|
5695
|
-
function readPositiveInteger(
|
|
5696
|
-
row: ClaimedOutboxRow,
|
|
5697
|
-
value: Record<string, unknown>,
|
|
5698
|
-
field: string,
|
|
5699
|
-
) {
|
|
5248
|
+
function readPositiveInteger(row: ClaimedOutboxRow, value: Record<string, unknown>, field: string) {
|
|
5700
5249
|
const result = value[field]
|
|
5701
5250
|
|
|
5702
|
-
if (
|
|
5703
|
-
typeof result !== 'number' ||
|
|
5704
|
-
!Number.isSafeInteger(result) ||
|
|
5705
|
-
result < 1
|
|
5706
|
-
) {
|
|
5251
|
+
if (typeof result !== 'number' || !Number.isSafeInteger(result) || result < 1) {
|
|
5707
5252
|
invalid(row, field + ' 必须是正安全整数')
|
|
5708
5253
|
}
|
|
5709
5254
|
|
|
5710
5255
|
return result
|
|
5711
5256
|
}
|
|
5712
5257
|
|
|
5713
|
-
function assertUserId(
|
|
5714
|
-
row: ClaimedOutboxRow,
|
|
5715
|
-
userId: string,
|
|
5716
|
-
) {
|
|
5258
|
+
function assertUserId(row: ClaimedOutboxRow, userId: string) {
|
|
5717
5259
|
const numericId = Number(userId)
|
|
5718
5260
|
|
|
5719
|
-
if (
|
|
5720
|
-
!/^[1-9][0-9]*$/.test(userId) ||
|
|
5721
|
-
!Number.isSafeInteger(numericId)
|
|
5722
|
-
) {
|
|
5261
|
+
if (!/^[1-9][0-9]*$/.test(userId) || !Number.isSafeInteger(numericId)) {
|
|
5723
5262
|
invalid(row, 'userId 格式非法')
|
|
5724
5263
|
}
|
|
5725
5264
|
|
|
5726
5265
|
return numericId
|
|
5727
5266
|
}
|
|
5728
5267
|
|
|
5729
|
-
function parseAccountPayload(
|
|
5730
|
-
row
|
|
5731
|
-
): AccountAuthState {
|
|
5732
|
-
if (
|
|
5733
|
-
row.aggregateType !== 'account' ||
|
|
5734
|
-
row.eventType !== 'account.changed'
|
|
5735
|
-
) {
|
|
5268
|
+
function parseAccountPayload(row: ClaimedOutboxRow): AccountAuthState {
|
|
5269
|
+
if (row.aggregateType !== 'account' || row.eventType !== 'account.changed') {
|
|
5736
5270
|
invalid(row, '账号事件类型与聚合类型不匹配')
|
|
5737
5271
|
}
|
|
5738
5272
|
|
|
@@ -5742,22 +5276,14 @@ function parseAccountPayload(
|
|
|
5742
5276
|
invalid(row, '不支持的账号 payload schemaVersion')
|
|
5743
5277
|
}
|
|
5744
5278
|
|
|
5745
|
-
const source = readRecord(
|
|
5746
|
-
row,
|
|
5747
|
-
payload.state,
|
|
5748
|
-
'payload.state',
|
|
5749
|
-
)
|
|
5279
|
+
const source = readRecord(row, payload.state, 'payload.state')
|
|
5750
5280
|
const userId = readString(row, source, 'userId')
|
|
5751
5281
|
const status = readString(row, source, 'status')
|
|
5752
5282
|
const epoch = readPositiveInteger(row, source, 'epoch')
|
|
5753
5283
|
|
|
5754
5284
|
assertUserId(row, userId)
|
|
5755
5285
|
|
|
5756
|
-
if (
|
|
5757
|
-
status !== 'active' &&
|
|
5758
|
-
status !== 'locked' &&
|
|
5759
|
-
status !== 'disabled'
|
|
5760
|
-
) {
|
|
5286
|
+
if (status !== 'active' && status !== 'locked' && status !== 'disabled') {
|
|
5761
5287
|
invalid(row, '账号 status 非法')
|
|
5762
5288
|
}
|
|
5763
5289
|
|
|
@@ -5772,13 +5298,8 @@ function parseAccountPayload(
|
|
|
5772
5298
|
return { userId, status, epoch }
|
|
5773
5299
|
}
|
|
5774
5300
|
|
|
5775
|
-
function parseSessionPayload(
|
|
5776
|
-
row
|
|
5777
|
-
): SessionAuthState {
|
|
5778
|
-
if (
|
|
5779
|
-
row.aggregateType !== 'session' ||
|
|
5780
|
-
row.eventType !== 'session.changed'
|
|
5781
|
-
) {
|
|
5301
|
+
function parseSessionPayload(row: ClaimedOutboxRow): SessionAuthState {
|
|
5302
|
+
if (row.aggregateType !== 'session' || row.eventType !== 'session.changed') {
|
|
5782
5303
|
invalid(row, 'Session 事件类型与聚合类型不匹配')
|
|
5783
5304
|
}
|
|
5784
5305
|
|
|
@@ -5788,24 +5309,12 @@ function parseSessionPayload(
|
|
|
5788
5309
|
invalid(row, '不支持的 Session payload schemaVersion')
|
|
5789
5310
|
}
|
|
5790
5311
|
|
|
5791
|
-
const source = readRecord(
|
|
5792
|
-
row,
|
|
5793
|
-
payload.state,
|
|
5794
|
-
'payload.state',
|
|
5795
|
-
)
|
|
5312
|
+
const source = readRecord(row, payload.state, 'payload.state')
|
|
5796
5313
|
const sessionId = readString(row, source, 'sessionId')
|
|
5797
5314
|
const userId = readString(row, source, 'userId')
|
|
5798
5315
|
const status = readString(row, source, 'status')
|
|
5799
|
-
const version = readPositiveInteger(
|
|
5800
|
-
|
|
5801
|
-
source,
|
|
5802
|
-
'version',
|
|
5803
|
-
)
|
|
5804
|
-
const expiresAtMs = readPositiveInteger(
|
|
5805
|
-
row,
|
|
5806
|
-
source,
|
|
5807
|
-
'expiresAtMs',
|
|
5808
|
-
)
|
|
5316
|
+
const version = readPositiveInteger(row, source, 'version')
|
|
5317
|
+
const expiresAtMs = readPositiveInteger(row, source, 'expiresAtMs')
|
|
5809
5318
|
|
|
5810
5319
|
assertUserId(row, userId)
|
|
5811
5320
|
|
|
@@ -5855,20 +5364,13 @@ export class AuthOutboxWorker {
|
|
|
5855
5364
|
this.running = true
|
|
5856
5365
|
|
|
5857
5366
|
try {
|
|
5858
|
-
const rows = await this.repository.claimAuthOutbox(
|
|
5859
|
-
this.workerId,
|
|
5860
|
-
20,
|
|
5861
|
-
60_000,
|
|
5862
|
-
)
|
|
5367
|
+
const rows = await this.repository.claimAuthOutbox(this.workerId, 20, 60_000)
|
|
5863
5368
|
|
|
5864
5369
|
for (const row of rows) {
|
|
5865
5370
|
try {
|
|
5866
5371
|
await this.process(row)
|
|
5867
5372
|
|
|
5868
|
-
await this.repository.markOutboxPublished(
|
|
5869
|
-
row.id,
|
|
5870
|
-
this.workerId,
|
|
5871
|
-
)
|
|
5373
|
+
await this.repository.markOutboxPublished(row.id, this.workerId)
|
|
5872
5374
|
} catch (error) {
|
|
5873
5375
|
try {
|
|
5874
5376
|
await this.repository.releaseOutboxForRetry({
|
|
@@ -5878,12 +5380,7 @@ export class AuthOutboxWorker {
|
|
|
5878
5380
|
error,
|
|
5879
5381
|
})
|
|
5880
5382
|
} catch (releaseError) {
|
|
5881
|
-
this.logger.error(
|
|
5882
|
-
'Auth Outbox ' +
|
|
5883
|
-
row.id +
|
|
5884
|
-
' 释放失败:' +
|
|
5885
|
-
String(releaseError),
|
|
5886
|
-
)
|
|
5383
|
+
this.logger.error('Auth Outbox ' + row.id + ' 释放失败:' + String(releaseError))
|
|
5887
5384
|
}
|
|
5888
5385
|
|
|
5889
5386
|
if (row.attempts + 1 >= 10) {
|
|
@@ -5896,12 +5393,7 @@ export class AuthOutboxWorker {
|
|
|
5896
5393
|
String(error),
|
|
5897
5394
|
)
|
|
5898
5395
|
} else {
|
|
5899
|
-
this.logger.warn(
|
|
5900
|
-
'Auth Outbox ' +
|
|
5901
|
-
row.id +
|
|
5902
|
-
' 处理失败:' +
|
|
5903
|
-
String(error),
|
|
5904
|
-
)
|
|
5396
|
+
this.logger.warn('Auth Outbox ' + row.id + ' 处理失败:' + String(error))
|
|
5905
5397
|
}
|
|
5906
5398
|
}
|
|
5907
5399
|
}
|
|
@@ -5917,18 +5409,13 @@ export class AuthOutboxWorker {
|
|
|
5917
5409
|
case 'session':
|
|
5918
5410
|
return this.processSession(row)
|
|
5919
5411
|
default:
|
|
5920
|
-
invalid(
|
|
5921
|
-
row,
|
|
5922
|
-
'未知 aggregateType:' + row.aggregateType,
|
|
5923
|
-
)
|
|
5412
|
+
invalid(row, '未知 aggregateType:' + row.aggregateType)
|
|
5924
5413
|
}
|
|
5925
5414
|
}
|
|
5926
5415
|
|
|
5927
5416
|
private async processAccount(row: ClaimedOutboxRow) {
|
|
5928
5417
|
const eventState = parseAccountPayload(row)
|
|
5929
|
-
const account = await this.repository.findUserById(
|
|
5930
|
-
assertUserId(row, eventState.userId),
|
|
5931
|
-
)
|
|
5418
|
+
const account = await this.repository.findUserById(assertUserId(row, eventState.userId))
|
|
5932
5419
|
|
|
5933
5420
|
let state: AccountAuthState
|
|
5934
5421
|
|
|
@@ -5945,17 +5432,13 @@ export class AuthOutboxWorker {
|
|
|
5945
5432
|
|
|
5946
5433
|
if (
|
|
5947
5434
|
state.epoch === eventState.epoch &&
|
|
5948
|
-
(state.userId !== eventState.userId ||
|
|
5949
|
-
state.status !== eventState.status)
|
|
5435
|
+
(state.userId !== eventState.userId || state.status !== eventState.status)
|
|
5950
5436
|
) {
|
|
5951
5437
|
invalid(row, '账号同版本状态不一致')
|
|
5952
5438
|
}
|
|
5953
5439
|
} else {
|
|
5954
5440
|
if (eventState.status !== 'disabled') {
|
|
5955
|
-
invalid(
|
|
5956
|
-
row,
|
|
5957
|
-
'账号行缺失时只允许 disabled tombstone',
|
|
5958
|
-
)
|
|
5441
|
+
invalid(row, '账号行缺失时只允许 disabled tombstone')
|
|
5959
5442
|
}
|
|
5960
5443
|
|
|
5961
5444
|
state = eventState
|
|
@@ -5967,9 +5450,7 @@ export class AuthOutboxWorker {
|
|
|
5967
5450
|
|
|
5968
5451
|
private async processSession(row: ClaimedOutboxRow) {
|
|
5969
5452
|
const eventState = parseSessionPayload(row)
|
|
5970
|
-
const session = await this.repository.findSessionById(
|
|
5971
|
-
eventState.sessionId,
|
|
5972
|
-
)
|
|
5453
|
+
const session = await this.repository.findSessionById(eventState.sessionId)
|
|
5973
5454
|
|
|
5974
5455
|
let state: SessionAuthState
|
|
5975
5456
|
|
|
@@ -5996,18 +5477,12 @@ export class AuthOutboxWorker {
|
|
|
5996
5477
|
invalid(row, 'Session 同版本状态不一致')
|
|
5997
5478
|
}
|
|
5998
5479
|
|
|
5999
|
-
if (
|
|
6000
|
-
eventState.status === 'revoked' &&
|
|
6001
|
-
state.status === 'active'
|
|
6002
|
-
) {
|
|
5480
|
+
if (eventState.status === 'revoked' && state.status === 'active') {
|
|
6003
5481
|
invalid(row, '已撤销 Session 不允许恢复为 active')
|
|
6004
5482
|
}
|
|
6005
5483
|
} else {
|
|
6006
5484
|
if (eventState.status !== 'revoked') {
|
|
6007
|
-
invalid(
|
|
6008
|
-
row,
|
|
6009
|
-
'Session 行缺失时只允许 revoked tombstone',
|
|
6010
|
-
)
|
|
5485
|
+
invalid(row, 'Session 行缺失时只允许 revoked tombstone')
|
|
6011
5486
|
}
|
|
6012
5487
|
|
|
6013
5488
|
state = eventState
|
|
@@ -6017,11 +5492,11 @@ export class AuthOutboxWorker {
|
|
|
6017
5492
|
await this.publisher.sessionChanged(state, false)
|
|
6018
5493
|
}
|
|
6019
5494
|
}
|
|
6020
|
-
|
|
5495
|
+
```
|
|
6021
5496
|
|
|
6022
5497
|
处理顺序必须是:
|
|
6023
5498
|
|
|
6024
|
-
|
|
5499
|
+
```text
|
|
6025
5500
|
领取租约
|
|
6026
5501
|
-> 校验 eventType、aggregateType、aggregateVersion 和完整 payload
|
|
6027
5502
|
-> 有数据库行时读取当前更高版本
|
|
@@ -6029,7 +5504,7 @@ export class AuthOutboxWorker {
|
|
|
6029
5504
|
-> 单调投影 Redis
|
|
6030
5505
|
-> 严格 publish
|
|
6031
5506
|
-> 最后标记 published
|
|
6032
|
-
|
|
5507
|
+
```
|
|
6033
5508
|
|
|
6034
5509
|
以下情况必须抛错、释放租约并保持 publishedAt 为 null:
|
|
6035
5510
|
|
|
@@ -6050,11 +5525,11 @@ export class AuthOutboxWorker {
|
|
|
6050
5525
|
|
|
6051
5526
|
至少保留到对应 Refresh Family 的绝对 `expiresAt` 之后,再加上允许的时钟偏差和运维缓冲。可以把最早删除时间理解为:
|
|
6052
5527
|
|
|
6053
|
-
|
|
5528
|
+
```text
|
|
6054
5529
|
deleteAfter = family.expiresAt
|
|
6055
5530
|
+ clockToleranceSeconds
|
|
6056
5531
|
+ 运维缓冲时间
|
|
6057
|
-
|
|
5532
|
+
```
|
|
6058
5533
|
|
|
6059
5534
|
运维缓冲建议至少 24 小时;有审计要求时可以更久。`active`、`consumed`、`revoked` 三种 Token 在保留期内都不能因为“节省表空间”而提前物理删除。
|
|
6060
5535
|
|
|
@@ -6074,11 +5549,11 @@ deleteAfter = family.expiresAt
|
|
|
6074
5549
|
|
|
6075
5550
|
真正执行物理删除前,先运行:
|
|
6076
5551
|
|
|
6077
|
-
|
|
5552
|
+
```sql
|
|
6078
5553
|
SELECT COUNT(*) AS unpublished_count
|
|
6079
5554
|
FROM auth_outbox
|
|
6080
5555
|
WHERE published_at IS NULL;
|
|
6081
|
-
|
|
5556
|
+
```
|
|
6082
5557
|
|
|
6083
5558
|
只要待删除聚合仍有未发布事件,就必须停止清理。普通清理任务永远不能删除 `published_at IS NULL` 的 Outbox;已发布 Outbox 建议继续保留 7~30 天用于审计和排障。
|
|
6084
5559
|
|
|
@@ -6090,34 +5565,34 @@ WHERE published_at IS NULL;
|
|
|
6090
5565
|
|
|
6091
5566
|
修改:
|
|
6092
5567
|
|
|
6093
|
-
|
|
5568
|
+
```text
|
|
6094
5569
|
apps/server/src/modules/auth/auth.module.ts
|
|
6095
|
-
|
|
5570
|
+
```
|
|
6096
5571
|
|
|
6097
5572
|
增加 import:
|
|
6098
5573
|
|
|
6099
|
-
|
|
5574
|
+
```ts
|
|
6100
5575
|
import { ScheduleModule } from '@nestjs/schedule'
|
|
6101
5576
|
import { AuthOutboxWorker } from './state/auth-outbox.worker'
|
|
6102
|
-
|
|
5577
|
+
```
|
|
6103
5578
|
|
|
6104
5579
|
在 imports 数组中增加:
|
|
6105
5580
|
|
|
6106
|
-
|
|
5581
|
+
```ts
|
|
6107
5582
|
ScheduleModule.forRoot(),
|
|
6108
|
-
|
|
5583
|
+
```
|
|
6109
5584
|
|
|
6110
5585
|
在 providers 中增加:
|
|
6111
5586
|
|
|
6112
|
-
|
|
5587
|
+
```ts
|
|
6113
5588
|
AuthOutboxWorker,
|
|
6114
|
-
|
|
5589
|
+
```
|
|
6115
5590
|
|
|
6116
5591
|
## 13.8 构建和验收
|
|
6117
5592
|
|
|
6118
|
-
|
|
5593
|
+
```powershell
|
|
6119
5594
|
vp run "server#build"
|
|
6120
|
-
|
|
5595
|
+
```
|
|
6121
5596
|
|
|
6122
5597
|
验收场景:
|
|
6123
5598
|
|
|
@@ -6171,64 +5646,54 @@ Outbox 仍不能让 PostgreSQL 和 Redis 成为一个 ACID 事务。
|
|
|
6171
5646
|
|
|
6172
5647
|
修改:
|
|
6173
5648
|
|
|
6174
|
-
|
|
5649
|
+
```text
|
|
6175
5650
|
apps/server/package.json
|
|
6176
|
-
|
|
5651
|
+
```
|
|
6177
5652
|
|
|
6178
5653
|
在 scripts 中增加:
|
|
6179
5654
|
|
|
6180
|
-
|
|
5655
|
+
```json
|
|
6181
5656
|
{
|
|
6182
5657
|
"test": "vp test"
|
|
6183
5658
|
}
|
|
6184
|
-
|
|
5659
|
+
```
|
|
6185
5660
|
|
|
6186
5661
|
Vite+ 的测试 import 使用:
|
|
6187
5662
|
|
|
6188
|
-
|
|
6189
|
-
import {
|
|
6190
|
-
|
|
6191
|
-
expect,
|
|
6192
|
-
it,
|
|
6193
|
-
vi,
|
|
6194
|
-
} from 'vite-plus/test'
|
|
6195
|
-
~~~
|
|
5663
|
+
```ts
|
|
5664
|
+
import { describe, expect, it, vi } from 'vite-plus/test'
|
|
5665
|
+
```
|
|
6196
5666
|
|
|
6197
5667
|
不要再单独安装 Vitest,也不要从 vitest import。
|
|
6198
5668
|
|
|
6199
5669
|
## 14.2 后续必须落地的自动测试清单
|
|
6200
5670
|
|
|
6201
|
-
下面是验收矩阵,不代表测试已经实现。只有创建对应的
|
|
5671
|
+
下面是验收矩阵,不代表测试已经实现。只有创建对应的 \*.spec.ts 文件并实际通过后,才能声明自动测试完成。如果仓库里还没有任何测试文件,不能因为 vp run "server#test" 没报错就认为鉴权已经验证完成。
|
|
6202
5672
|
|
|
6203
5673
|
先落地一个不依赖 PostgreSQL 和 Redis 的最小测试,证明 Vite+ 测试入口、JWT 配置和 Refresh HMAC 原语能真正运行。
|
|
6204
5674
|
|
|
6205
5675
|
新增:
|
|
6206
5676
|
|
|
6207
|
-
|
|
5677
|
+
```text
|
|
6208
5678
|
apps/server/src/modules/auth/token.service.spec.ts
|
|
6209
|
-
|
|
5679
|
+
```
|
|
6210
5680
|
|
|
6211
5681
|
粘贴:
|
|
6212
5682
|
|
|
6213
|
-
|
|
5683
|
+
```ts
|
|
6214
5684
|
import { ConfigService } from '@nestjs/config'
|
|
6215
5685
|
import { JwtService } from '@nestjs/jwt'
|
|
6216
5686
|
import { describe, expect, it } from 'vite-plus/test'
|
|
6217
5687
|
import type { AuthConfig } from '@/config/auth.config'
|
|
6218
|
-
import {
|
|
6219
|
-
AccessTokenService,
|
|
6220
|
-
RefreshTokenCodec,
|
|
6221
|
-
} from './token.service'
|
|
5688
|
+
import { AccessTokenService, RefreshTokenCodec } from './token.service'
|
|
6222
5689
|
|
|
6223
5690
|
const auth: AuthConfig = {
|
|
6224
|
-
accessSecret:
|
|
6225
|
-
'test-only-access-secret-please-never-use-this-value-in-production',
|
|
5691
|
+
accessSecret: 'test-only-access-secret-please-never-use-this-value-in-production',
|
|
6226
5692
|
issuer: 'bubbles-auth-test',
|
|
6227
5693
|
audience: 'bubbles-api-test',
|
|
6228
5694
|
accessTtlSeconds: 600,
|
|
6229
5695
|
refreshTtlSeconds: 30 * 24 * 60 * 60,
|
|
6230
|
-
refreshPepper:
|
|
6231
|
-
'test-only-refresh-pepper-please-never-use-this-value-in-production',
|
|
5696
|
+
refreshPepper: 'test-only-refresh-pepper-please-never-use-this-value-in-production',
|
|
6232
5697
|
clockToleranceSeconds: 30,
|
|
6233
5698
|
localCacheTtlMs: 30_000,
|
|
6234
5699
|
tombstoneTtlSeconds: 900,
|
|
@@ -6270,10 +5735,7 @@ function createJwtService() {
|
|
|
6270
5735
|
|
|
6271
5736
|
describe('AccessTokenService', () => {
|
|
6272
5737
|
it('签发的 JWT 可以恢复身份和两个版本号', async () => {
|
|
6273
|
-
const service = new AccessTokenService(
|
|
6274
|
-
createJwtService(),
|
|
6275
|
-
createConfigService(),
|
|
6276
|
-
)
|
|
5738
|
+
const service = new AccessTokenService(createJwtService(), createConfigService())
|
|
6277
5739
|
|
|
6278
5740
|
const issued = await service.sign({
|
|
6279
5741
|
userId: 42,
|
|
@@ -6298,10 +5760,7 @@ describe('AccessTokenService', () => {
|
|
|
6298
5760
|
})
|
|
6299
5761
|
|
|
6300
5762
|
it('拒绝签名被篡改的 JWT', async () => {
|
|
6301
|
-
const service = new AccessTokenService(
|
|
6302
|
-
createJwtService(),
|
|
6303
|
-
createConfigService(),
|
|
6304
|
-
)
|
|
5763
|
+
const service = new AccessTokenService(createJwtService(), createConfigService())
|
|
6305
5764
|
|
|
6306
5765
|
const issued = await service.sign({
|
|
6307
5766
|
userId: 42,
|
|
@@ -6313,13 +5772,9 @@ describe('AccessTokenService', () => {
|
|
|
6313
5772
|
const parts = issued.accessToken.split('.')
|
|
6314
5773
|
const signature = parts[2]
|
|
6315
5774
|
|
|
6316
|
-
parts[2] =
|
|
6317
|
-
(signature.startsWith('a') ? 'b' : 'a') +
|
|
6318
|
-
signature.slice(1)
|
|
5775
|
+
parts[2] = (signature.startsWith('a') ? 'b' : 'a') + signature.slice(1)
|
|
6319
5776
|
|
|
6320
|
-
await expect(
|
|
6321
|
-
service.verify(parts.join('.')),
|
|
6322
|
-
).rejects.toThrow('访问令牌无效或已过期')
|
|
5777
|
+
await expect(service.verify(parts.join('.'))).rejects.toThrow('访问令牌无效或已过期')
|
|
6323
5778
|
})
|
|
6324
5779
|
|
|
6325
5780
|
it('Refresh Token 只保存可重复计算的 HMAC 摘要', () => {
|
|
@@ -6332,13 +5787,13 @@ describe('AccessTokenService', () => {
|
|
|
6332
5787
|
expect(codec.hash(token.raw)).toBe(token.hash)
|
|
6333
5788
|
})
|
|
6334
5789
|
})
|
|
6335
|
-
|
|
5790
|
+
```
|
|
6336
5791
|
|
|
6337
5792
|
立即执行:
|
|
6338
5793
|
|
|
6339
|
-
|
|
5794
|
+
```powershell
|
|
6340
5795
|
vp run "server#test"
|
|
6341
|
-
|
|
5796
|
+
```
|
|
6342
5797
|
|
|
6343
5798
|
必须看到 3 个测试通过。这个最小测试通过只代表测试入口和 Token 原语正确,不能代替下面依赖 PostgreSQL、Redis 和 HTTP 的集成验收。
|
|
6344
5799
|
|
|
@@ -6414,12 +5869,12 @@ vp run "server#test"
|
|
|
6414
5869
|
|
|
6415
5870
|
先保证 PostgreSQL 和 Redis 测试实例可用,然后执行:
|
|
6416
5871
|
|
|
6417
|
-
|
|
5872
|
+
```powershell
|
|
6418
5873
|
vp check
|
|
6419
5874
|
vp run "server#test"
|
|
6420
5875
|
vp run "server#build"
|
|
6421
5876
|
vp run ready
|
|
6422
|
-
|
|
5877
|
+
```
|
|
6423
5878
|
|
|
6424
5879
|
vp check 会执行格式、Lint 和类型检查。不要只看 nest build。
|
|
6425
5880
|
|
|
@@ -6453,33 +5908,33 @@ vp check 会执行格式、Lint 和类型检查。不要只看 nest build。
|
|
|
6453
5908
|
|
|
6454
5909
|
先查询:
|
|
6455
5910
|
|
|
6456
|
-
|
|
5911
|
+
```sql
|
|
6457
5912
|
SELECT COUNT(*)
|
|
6458
5913
|
FROM users
|
|
6459
5914
|
WHERE password_hash IS NULL;
|
|
6460
|
-
|
|
5915
|
+
```
|
|
6461
5916
|
|
|
6462
5917
|
结果必须为 0。
|
|
6463
5918
|
|
|
6464
5919
|
然后把 schema 中:
|
|
6465
5920
|
|
|
6466
|
-
|
|
5921
|
+
```ts
|
|
6467
5922
|
passwordHash: varchar('password_hash', { length: 255 }),
|
|
6468
|
-
|
|
5923
|
+
```
|
|
6469
5924
|
|
|
6470
5925
|
改成:
|
|
6471
5926
|
|
|
6472
|
-
|
|
5927
|
+
```ts
|
|
6473
5928
|
passwordHash: varchar('password_hash', { length: 255 }).notNull(),
|
|
6474
|
-
|
|
5929
|
+
```
|
|
6475
5930
|
|
|
6476
5931
|
生成并审查第二次迁移:
|
|
6477
5932
|
|
|
6478
|
-
|
|
5933
|
+
```powershell
|
|
6479
5934
|
vp run "server#db:generate"
|
|
6480
5935
|
vp run "server#db:migrate"
|
|
6481
5936
|
vp run "server#build"
|
|
6482
|
-
|
|
5937
|
+
```
|
|
6483
5938
|
|
|
6484
5939
|
不要给旧账号填一个所有人共用的假密码。旧账号应通过安全的密码重置、管理员邀请或导入流程生成各自的真实 hash。
|
|
6485
5940
|
|
|
@@ -6491,7 +5946,7 @@ vp run "server#build"
|
|
|
6491
5946
|
|
|
6492
5947
|
真正拆成多个微服务以后:
|
|
6493
5948
|
|
|
6494
|
-
|
|
5949
|
+
```text
|
|
6495
5950
|
认证服务
|
|
6496
5951
|
持有私钥
|
|
6497
5952
|
负责签发 JWT
|
|
@@ -6499,7 +5954,7 @@ vp run "server#build"
|
|
|
6499
5954
|
业务微服务
|
|
6500
5955
|
只持有公钥或读取 JWKS
|
|
6501
5956
|
只能验签,不能伪造 JWT
|
|
6502
|
-
|
|
5957
|
+
```
|
|
6503
5958
|
|
|
6504
5959
|
推荐步骤:
|
|
6505
5960
|
|
|
@@ -6550,7 +6005,7 @@ vp run "server#build"
|
|
|
6550
6005
|
|
|
6551
6006
|
这套设计不是“JWT 完全无状态”,而是把工作分层:
|
|
6552
6007
|
|
|
6553
|
-
|
|
6008
|
+
```text
|
|
6554
6009
|
JWT
|
|
6555
6010
|
证明这个身份曾被认证服务合法签发
|
|
6556
6011
|
|
|
@@ -6571,17 +6026,17 @@ Pub/Sub Push
|
|
|
6571
6026
|
|
|
6572
6027
|
Outbox
|
|
6573
6028
|
修复数据库提交后进程崩溃导致的传播缺口
|
|
6574
|
-
|
|
6029
|
+
```
|
|
6575
6030
|
|
|
6576
6031
|
你最开始担心的“多个用户同时改密会让所有微服务疯狂查询用户中心”,在这里被拆成:
|
|
6577
6032
|
|
|
6578
|
-
|
|
6033
|
+
```text
|
|
6579
6034
|
每个改密用户只产生一个 account epoch 事件
|
|
6580
6035
|
-> 微服务没有该用户缓存就忽略
|
|
6581
6036
|
-> 有缓存只标脏
|
|
6582
6037
|
-> 该用户真正再次访问时才 Pull
|
|
6583
6038
|
-> 同一用户并发 Pull 由 single-flight 合并
|
|
6584
|
-
|
|
6039
|
+
```
|
|
6585
6040
|
|
|
6586
6041
|
因此它保留了 JWT 的本地验签优势,又提供了可控的踢人实时性。
|
|
6587
6042
|
|