From c8716b3a1aebd804663e8ffdffa4b2325cf8f9a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B9=9B=E5=85=AE?= Date: Fri, 12 Jun 2026 10:20:10 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E9=89=B4=E6=9D=83=E5=92=8C=E8=B6=85=E7=BA=A7=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=91=98=E9=87=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 15 ++- package.json | 2 + prisma/seed.js | 10 +- src/agent/agent.service.spec.ts | 1 + src/auth/auth.constants.ts | 2 +- src/auth/auth.service.spec.ts | 30 +++-- src/auth/auth.service.ts | 12 +- src/auth/reset-super-admin-password.ts | 104 ++++++++++++++++++ .../deploy-execution.service.spec.ts | 4 +- src/deploy-runs/deploy-runs.service.spec.ts | 26 +++-- .../wecom/deploy-notification.service.spec.ts | 1 + src/projects/projects.service.spec.ts | 12 ++ src/settings/settings.service.ts | 21 ++++ src/settings/settings.types.ts | 2 + .../gitea/gitea-webhook.service.spec.ts | 1 + 15 files changed, 206 insertions(+), 37 deletions(-) create mode 100644 src/auth/reset-super-admin-password.ts diff --git a/README.md b/README.md index 088fd41..f6189ed 100644 --- a/README.md +++ b/README.md @@ -42,10 +42,18 @@ pnpm prisma:seed ## 内置账号 - 超级管理员账号:`zhanxi` -- 超级管理员初始密码:生产环境通过 `SUPER_ADMIN_INITIAL_PASSWORD` 注入;本地未配置时使用开发兜底口令。 +- 超级管理员初始密码:`zx123456@`,可通过服务器环境变量 `SUPER_ADMIN_INITIAL_PASSWORD` 覆盖。 - 普通成员初始密码:`111111` -超级管理员初始化配置集中在 `src/auth/auth.constants.ts` 和服务器环境变量。生产环境缺少 `SUPER_ADMIN_INITIAL_PASSWORD` 时 API 会拒绝初始化超级管理员,避免默认口令上线;如果超级管理员已修改密码,后续初始化不会覆盖密码。 +超级管理员初始化配置集中在 `src/auth/auth.constants.ts`、`prisma/seed.js` 和服务器环境变量。服务启动和种子数据只负责确保超级管理员账号存在并保持最高权限;如果超级管理员已修改密码,后续初始化不会覆盖密码。 + +如果数据库中已经存在旧密码哈希,或者超级管理员忘记密码,平台内不提供找回入口,只能在服务器执行受控重置命令: + +```bash +pnpm reset:super-admin-password +``` + +该命令面向已构建的服务器部署目录,会自动读取当前目录的 `.env.production`。默认把 `zhanxi` 的密码重置为 `zx123456@`。如需临时指定一次性重置密码,可在服务器环境中设置 `SUPER_ADMIN_RESET_PASSWORD` 后执行命令。脚本不会输出明文密码。源码开发调试时可先执行 `pnpm build`,或使用 `pnpm reset:super-admin-password:dev`。 ## 关键接口 @@ -98,7 +106,8 @@ pnpm prisma:seed - `AUTH_TOKEN_SECRET`:登录 token 签名密钥。 - `AUTH_TOKEN_TTL_SECONDS`:登录 token 有效期,默认 43200 秒。 -- `SUPER_ADMIN_INITIAL_PASSWORD`:生产环境初始化超级管理员时的初始密码。 +- `SUPER_ADMIN_INITIAL_PASSWORD`:覆盖超级管理员初始化密码;不设置时使用集中初始化配置中的 `zx123456@`。 +- `SUPER_ADMIN_RESET_PASSWORD`:仅服务器重置脚本使用的一次性超级管理员重置密码。 - `SECRET_ENCRYPTION_KEY`:服务端密钥加密密钥,生产环境保存 Agent 密钥时必须配置。 - `DATABASE_URL`:MySQL 连接地址。 - `USE_DATABASE_READS`:是否启用 Prisma 数据读写。 diff --git a/package.json b/package.json index 3e1424d..b549e74 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,8 @@ "prisma:migrate:dev": "prisma migrate dev", "prisma:seed": "node prisma/seed.js", "prisma:studio": "prisma studio", + "reset:super-admin-password": "node dist/auth/reset-super-admin-password.js", + "reset:super-admin-password:dev": "ts-node -r tsconfig-paths/register src/auth/reset-super-admin-password.ts", "start": "nest start", "start:dev": "nest start --watch", "start:prod": "node dist/main.js", diff --git a/prisma/seed.js b/prisma/seed.js index 63eca0d..d1f7cc2 100644 --- a/prisma/seed.js +++ b/prisma/seed.js @@ -9,7 +9,7 @@ const { UserStatus, } = require('@prisma/client'); const { - LOCAL_SUPER_ADMIN_INITIAL_PASSWORD, + SUPER_ADMIN_INITIAL_PASSWORD, SUPER_ADMIN_SEED, } = require('../dist/auth/auth.constants'); const { PasswordService } = require('../dist/auth/password.service'); @@ -92,13 +92,7 @@ function resolveSuperAdminInitialPassword() { return process.env.SUPER_ADMIN_INITIAL_PASSWORD; } - if (process.env.NODE_ENV === 'production') { - throw new Error( - 'Missing SUPER_ADMIN_INITIAL_PASSWORD in production seed run', - ); - } - - return LOCAL_SUPER_ADMIN_INITIAL_PASSWORD; + return SUPER_ADMIN_INITIAL_PASSWORD; } async function upsertEnvironment(projectId, environment) { diff --git a/src/agent/agent.service.spec.ts b/src/agent/agent.service.spec.ts index c40a18a..f9c40ff 100644 --- a/src/agent/agent.service.spec.ts +++ b/src/agent/agent.service.spec.ts @@ -57,6 +57,7 @@ describe('AgentService', () => { getRun: jest.fn((id: string) => Promise.resolve({ id, + version: 1, projectKey: 'role-user', environment: 'production' as const, ref: 'master', diff --git a/src/auth/auth.constants.ts b/src/auth/auth.constants.ts index 8652fd4..fc17a66 100644 --- a/src/auth/auth.constants.ts +++ b/src/auth/auth.constants.ts @@ -4,7 +4,7 @@ export const SUPER_ADMIN_SEED = { displayName: '超级管理员', } as const; -export const LOCAL_SUPER_ADMIN_INITIAL_PASSWORD = 'devops-local-admin'; +export const SUPER_ADMIN_INITIAL_PASSWORD = 'zx123456@'; export const MEMBER_INITIAL_PASSWORD = '111111'; diff --git a/src/auth/auth.service.spec.ts b/src/auth/auth.service.spec.ts index 5e945b1..0015a2f 100644 --- a/src/auth/auth.service.spec.ts +++ b/src/auth/auth.service.spec.ts @@ -4,19 +4,22 @@ import { AppError } from '../common/errors/app-error'; import { EnvConfig } from '../config/env.schema'; import { AuthTokenService } from './auth-token.service'; import { AuthService } from './auth.service'; -import { MEMBER_INITIAL_PASSWORD } from './auth.constants'; +import { + MEMBER_INITIAL_PASSWORD, + SUPER_ADMIN_INITIAL_PASSWORD, +} from './auth.constants'; import { PasswordService } from './password.service'; import { UserRepository } from './user.repository'; import { UserRecord } from './auth.types'; import { ConfigService } from '@nestjs/config'; -const TEST_ADMIN_PASSWORD = 'test-admin-password'; - describe('AuthService', () => { const passwordService = new PasswordService(); - async function createService() { - const adminHash = await passwordService.hashPassword(TEST_ADMIN_PASSWORD); + async function createService(options?: { superAdminPassword?: string }) { + const adminPassword = + options?.superAdminPassword ?? SUPER_ADMIN_INITIAL_PASSWORD; + const adminHash = await passwordService.hashPassword(adminPassword); const memberHash = await passwordService.hashPassword(MEMBER_INITIAL_PASSWORD); const users = new Map(); const admin: UserRecord = { @@ -106,7 +109,7 @@ describe('AuthService', () => { const config = { get: jest.fn((key: keyof EnvConfig) => { if (key === 'SUPER_ADMIN_INITIAL_PASSWORD') { - return TEST_ADMIN_PASSWORD; + return options?.superAdminPassword; } if (key === 'NODE_ENV') { @@ -139,7 +142,7 @@ describe('AuthService', () => { const result = await service.login({ account: 'zhanxi', - password: TEST_ADMIN_PASSWORD, + password: SUPER_ADMIN_INITIAL_PASSWORD, }); expect(result.token).toBe('token_001'); @@ -147,6 +150,19 @@ describe('AuthService', () => { expect(repository.markLogin).toHaveBeenCalledWith('user_admin'); }); + it('允许通过服务器环境变量覆盖超级管理员初始化密码', async () => { + const { service } = await createService({ + superAdminPassword: 'server-only-password', + }); + + const result = await service.login({ + account: 'zhanxi', + password: 'server-only-password', + }); + + expect(result.user.role).toBe('super_admin'); + }); + it('拒绝超级管理员使用忘记密码流程', async () => { const { service } = await createService(); diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index df5aa7a..3e7ab10 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -8,8 +8,8 @@ import { AppError } from '../common/errors/app-error'; import { EnvConfig } from '../config/env.schema'; import { MessagesService } from '../messages/messages.service'; import { - LOCAL_SUPER_ADMIN_INITIAL_PASSWORD, MEMBER_INITIAL_PASSWORD, + SUPER_ADMIN_INITIAL_PASSWORD, } from './auth.constants'; import { AuthTokenService } from './auth-token.service'; import { @@ -278,14 +278,6 @@ export class AuthService implements OnModuleInit { return password; } - if (this.config.get('NODE_ENV', { infer: true }) === 'production') { - throw new AppError( - 'CONFIG_MISSING', - '生产环境缺少 SUPER_ADMIN_INITIAL_PASSWORD,不能初始化超级管理员', - 500, - ); - } - - return LOCAL_SUPER_ADMIN_INITIAL_PASSWORD; + return SUPER_ADMIN_INITIAL_PASSWORD; } } diff --git a/src/auth/reset-super-admin-password.ts b/src/auth/reset-super-admin-password.ts new file mode 100644 index 0000000..7a1fdce --- /dev/null +++ b/src/auth/reset-super-admin-password.ts @@ -0,0 +1,104 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { PrismaClient, UserRole, UserStatus } from '@prisma/client'; +import { + SUPER_ADMIN_INITIAL_PASSWORD, + SUPER_ADMIN_SEED, +} from './auth.constants'; +import { PasswordService } from './password.service'; + +loadProductionEnv(); + +const prisma = new PrismaClient(); +const passwordService = new PasswordService(); + +async function main(): Promise { + const password = process.env.SUPER_ADMIN_RESET_PASSWORD?.trim() + ? process.env.SUPER_ADMIN_RESET_PASSWORD.trim() + : SUPER_ADMIN_INITIAL_PASSWORD; + const passwordHash = await passwordService.hashPassword(password); + + await prisma.user.upsert({ + where: { + account: SUPER_ADMIN_SEED.account, + }, + create: { + id: SUPER_ADMIN_SEED.id, + account: SUPER_ADMIN_SEED.account, + displayName: SUPER_ADMIN_SEED.displayName, + role: UserRole.SUPER_ADMIN, + status: UserStatus.ACTIVE, + passwordHash, + mustChangePassword: false, + passwordVersion: 1, + }, + update: { + displayName: SUPER_ADMIN_SEED.displayName, + role: UserRole.SUPER_ADMIN, + status: UserStatus.ACTIVE, + passwordHash, + mustChangePassword: false, + passwordVersion: { + increment: 1, + }, + }, + }); + + console.log( + `超级管理员 ${SUPER_ADMIN_SEED.account} 密码已通过服务器脚本安全重置。`, + ); +} + +main() + .catch((error: unknown) => { + const message = error instanceof Error ? error.message : '未知错误'; + console.error(`重置超级管理员密码失败:${message}`); + process.exitCode = 1; + }) + .finally(async () => { + await prisma.$disconnect(); + }); + +function loadProductionEnv(): void { + const envFile = resolve(process.cwd(), '.env.production'); + + if (!existsSync(envFile)) { + return; + } + + const lines = readFileSync(envFile, 'utf8').split(/\r?\n/); + + for (const line of lines) { + const trimmed = line.trim(); + + if (!trimmed || trimmed.startsWith('#')) { + continue; + } + + const separatorIndex = trimmed.indexOf('='); + + if (separatorIndex <= 0) { + continue; + } + + const key = trimmed.slice(0, separatorIndex).trim(); + const rawValue = trimmed.slice(separatorIndex + 1).trim(); + + if (process.env[key] !== undefined) { + continue; + } + + process.env[key] = unquoteEnvValue(rawValue); + } +} + +function unquoteEnvValue(value: string): string { + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + return value.slice(1, -1); + } + + return value; +} diff --git a/src/deploy-runs/deploy-execution.service.spec.ts b/src/deploy-runs/deploy-execution.service.spec.ts index 1a43960..858234c 100644 --- a/src/deploy-runs/deploy-execution.service.spec.ts +++ b/src/deploy-runs/deploy-execution.service.spec.ts @@ -125,10 +125,12 @@ describe('DeployExecutionService', () => { repository: DeployRunRepository, idempotencyKey: string, ): Promise { - return repository.create({ + const result = await repository.create({ ...input, idempotencyKey, }); + + return result.run; } function step(run: DeployRunSummary, bpmnNodeId: string) { diff --git a/src/deploy-runs/deploy-runs.service.spec.ts b/src/deploy-runs/deploy-runs.service.spec.ts index 3fa4f77..2cd5b6b 100644 --- a/src/deploy-runs/deploy-runs.service.spec.ts +++ b/src/deploy-runs/deploy-runs.service.spec.ts @@ -20,6 +20,7 @@ import { DeployRunsService } from './deploy-runs.service'; describe('DeployRunsService', () => { const baseRun: DeployRunSummary = { id: 'run_001', + version: 1, projectKey: 'access-manage', environment: 'test', ref: 'develop', @@ -61,13 +62,16 @@ describe('DeployRunsService', () => { ), create: jest.fn((input: CreateDeployRunInput) => Promise.resolve({ - ...baseRun, - id: input.idempotencyKey ?? 'generated_run', - projectKey: input.projectKey, - environment: input.environment, - ref: input.ref, - operator: input.operator, - trigger: input.trigger ?? 'manual', + run: { + ...baseRun, + id: input.idempotencyKey ?? 'generated_run', + projectKey: input.projectKey, + environment: input.environment, + ref: input.ref, + operator: input.operator, + trigger: input.trigger ?? 'manual', + }, + created: true, }), ), updateRun: jest.fn((id: string, patch: { status?: DeployRunStatus }) => @@ -270,6 +274,10 @@ describe('DeployRunsService', () => { environment: 'production', ref: 'master', operator: 'ops-admin', + productionConfirmation: { + confirmed: true, + summary: '测试已确认生产发布风险', + }, }), ).rejects.toBeInstanceOf(AppError); expect(repository.create).not.toHaveBeenCalled(); @@ -308,6 +316,10 @@ describe('DeployRunsService', () => { ref: 'master', operator: 'ops-admin', idempotencyKey: 'self-managed-master', + productionConfirmation: { + confirmed: true, + summary: '测试已确认生产发布风险', + }, }); expect(run.id).toBe('self-managed-master'); diff --git a/src/notifications/wecom/deploy-notification.service.spec.ts b/src/notifications/wecom/deploy-notification.service.spec.ts index 31de650..4443a1c 100644 --- a/src/notifications/wecom/deploy-notification.service.spec.ts +++ b/src/notifications/wecom/deploy-notification.service.spec.ts @@ -11,6 +11,7 @@ import { NotificationOutboxService } from '../outbox/notification-outbox.service describe('DeployNotificationService', () => { const run: DeployRunSummary = { id: 'run_001', + version: 1, projectKey: 'access-manage', environment: 'test', ref: 'develop', diff --git a/src/projects/projects.service.spec.ts b/src/projects/projects.service.spec.ts index ab2a34e..986df28 100644 --- a/src/projects/projects.service.spec.ts +++ b/src/projects/projects.service.spec.ts @@ -1,6 +1,9 @@ +import { ConfigService } from '@nestjs/config'; import { AuthService } from '../auth/auth.service'; import { AppError } from '../common/errors/app-error'; +import { EnvConfig } from '../config/env.schema'; import { GiteaClient } from '../integrations/gitea/gitea.client'; +import { JenkinsClient } from '../integrations/jenkins/jenkins.client'; import { ProjectRepository } from './project.repository'; import { ProjectSummary } from './project.types'; import { ProjectsService } from './projects.service'; @@ -73,15 +76,24 @@ describe('ProjectsService', () => { AuthService, 'listAllowedProjectKeys' | 'requireProjectPermission' >; + const jenkins = { + healthSummary: jest.fn(() => ({ status: 'not_configured' as const })), + } satisfies Pick; + const config = { + get: jest.fn(() => undefined), + } satisfies Pick, 'get'>; return { auth, gitea, + jenkins, repository, service: new ProjectsService( repository as unknown as ProjectRepository, gitea as unknown as GiteaClient, + jenkins as unknown as JenkinsClient, auth as unknown as AuthService, + config as unknown as ConfigService, ), }; } diff --git a/src/settings/settings.service.ts b/src/settings/settings.service.ts index b33e607..16babbd 100644 --- a/src/settings/settings.service.ts +++ b/src/settings/settings.service.ts @@ -238,6 +238,7 @@ export class SettingsService { configured, missing, ...(definition.optional ? { optional: [...definition.optional] } : {}), + ...this.booleanFlagGroups(definition), ...(definition.note ? { note: definition.note } : {}), ...(definition.variableHelp ? { variableHelp: this.pickVariableHelp(definition) } @@ -270,6 +271,7 @@ export class SettingsService { configured, missing, ...(definition.optional ? { optional: [...definition.optional] } : {}), + ...this.booleanFlagGroups(definition), note: provider && !providerWebhook ? `NOTIFICATION_PROVIDER=${provider} 暂不支持;可选 wecom、feishu、generic。` @@ -312,6 +314,25 @@ export class SettingsService { return !PLACEHOLDER_PATTERNS.some((pattern) => pattern.test(value)); } + private booleanFlagGroups( + definition: IntegrationDefinition, + ): Pick { + const booleanKeys = (definition.optional ?? []).filter( + (key) => typeof this.config.get(key, { infer: true }) === 'boolean', + ); + const enabled = booleanKeys.filter( + (key) => this.config.get(key, { infer: true }) === true, + ); + const disabled = booleanKeys.filter( + (key) => this.config.get(key, { infer: true }) === false, + ); + + return { + ...(enabled.length > 0 ? { enabled } : {}), + ...(disabled.length > 0 ? { disabled } : {}), + }; + } + private resolveStatus( configuredCount: number, missingCount: number, diff --git a/src/settings/settings.types.ts b/src/settings/settings.types.ts index 0e1b128..2b60126 100644 --- a/src/settings/settings.types.ts +++ b/src/settings/settings.types.ts @@ -24,6 +24,8 @@ export type IntegrationConfigStatus = { configured: string[]; missing: string[]; optional?: string[]; + enabled?: string[]; + disabled?: string[]; note?: string; variableHelp?: Record; }; diff --git a/src/webhooks/gitea/gitea-webhook.service.spec.ts b/src/webhooks/gitea/gitea-webhook.service.spec.ts index de645d8..456b409 100644 --- a/src/webhooks/gitea/gitea-webhook.service.spec.ts +++ b/src/webhooks/gitea/gitea-webhook.service.spec.ts @@ -51,6 +51,7 @@ describe('GiteaWebhookService', () => { createRun: jest.fn(() => Promise.resolve({ id: 'gitea:delivery-1:access-manage:develop:abc123', + version: 1, projectKey: 'access-manage', environment: 'test' as const, ref: 'develop',