feat: 完善登录鉴权和超级管理员重置

This commit is contained in:
湛兮
2026-06-12 10:20:10 +08:00
parent 125042f927
commit c8716b3a1a
15 changed files with 206 additions and 37 deletions
+12 -3
View File
@@ -42,10 +42,18 @@ pnpm prisma:seed
## 内置账号 ## 内置账号
- 超级管理员账号:`zhanxi` - 超级管理员账号:`zhanxi`
- 超级管理员初始密码:生产环境通过 `SUPER_ADMIN_INITIAL_PASSWORD` 注入;本地未配置时使用开发兜底口令 - 超级管理员初始密码:`zx123456@`,可通过服务器环境变量 `SUPER_ADMIN_INITIAL_PASSWORD` 覆盖
- 普通成员初始密码:`111111` - 普通成员初始密码:`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_SECRET`:登录 token 签名密钥。
- `AUTH_TOKEN_TTL_SECONDS`:登录 token 有效期,默认 43200 秒。 - `AUTH_TOKEN_TTL_SECONDS`:登录 token 有效期,默认 43200 秒。
- `SUPER_ADMIN_INITIAL_PASSWORD`生产环境初始化超级管理员时的初始密码。 - `SUPER_ADMIN_INITIAL_PASSWORD`覆盖超级管理员初始密码;不设置时使用集中初始化配置中的 `zx123456@`
- `SUPER_ADMIN_RESET_PASSWORD`:仅服务器重置脚本使用的一次性超级管理员重置密码。
- `SECRET_ENCRYPTION_KEY`:服务端密钥加密密钥,生产环境保存 Agent 密钥时必须配置。 - `SECRET_ENCRYPTION_KEY`:服务端密钥加密密钥,生产环境保存 Agent 密钥时必须配置。
- `DATABASE_URL`MySQL 连接地址。 - `DATABASE_URL`MySQL 连接地址。
- `USE_DATABASE_READS`:是否启用 Prisma 数据读写。 - `USE_DATABASE_READS`:是否启用 Prisma 数据读写。
+2
View File
@@ -18,6 +18,8 @@
"prisma:migrate:dev": "prisma migrate dev", "prisma:migrate:dev": "prisma migrate dev",
"prisma:seed": "node prisma/seed.js", "prisma:seed": "node prisma/seed.js",
"prisma:studio": "prisma studio", "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": "nest start",
"start:dev": "nest start --watch", "start:dev": "nest start --watch",
"start:prod": "node dist/main.js", "start:prod": "node dist/main.js",
+2 -8
View File
@@ -9,7 +9,7 @@ const {
UserStatus, UserStatus,
} = require('@prisma/client'); } = require('@prisma/client');
const { const {
LOCAL_SUPER_ADMIN_INITIAL_PASSWORD, SUPER_ADMIN_INITIAL_PASSWORD,
SUPER_ADMIN_SEED, SUPER_ADMIN_SEED,
} = require('../dist/auth/auth.constants'); } = require('../dist/auth/auth.constants');
const { PasswordService } = require('../dist/auth/password.service'); const { PasswordService } = require('../dist/auth/password.service');
@@ -92,13 +92,7 @@ function resolveSuperAdminInitialPassword() {
return process.env.SUPER_ADMIN_INITIAL_PASSWORD; return process.env.SUPER_ADMIN_INITIAL_PASSWORD;
} }
if (process.env.NODE_ENV === 'production') { return SUPER_ADMIN_INITIAL_PASSWORD;
throw new Error(
'Missing SUPER_ADMIN_INITIAL_PASSWORD in production seed run',
);
}
return LOCAL_SUPER_ADMIN_INITIAL_PASSWORD;
} }
async function upsertEnvironment(projectId, environment) { async function upsertEnvironment(projectId, environment) {
+1
View File
@@ -57,6 +57,7 @@ describe('AgentService', () => {
getRun: jest.fn((id: string) => getRun: jest.fn((id: string) =>
Promise.resolve({ Promise.resolve({
id, id,
version: 1,
projectKey: 'role-user', projectKey: 'role-user',
environment: 'production' as const, environment: 'production' as const,
ref: 'master', ref: 'master',
+1 -1
View File
@@ -4,7 +4,7 @@ export const SUPER_ADMIN_SEED = {
displayName: '超级管理员', displayName: '超级管理员',
} as const; } 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'; export const MEMBER_INITIAL_PASSWORD = '111111';
+23 -7
View File
@@ -4,19 +4,22 @@ import { AppError } from '../common/errors/app-error';
import { EnvConfig } from '../config/env.schema'; import { EnvConfig } from '../config/env.schema';
import { AuthTokenService } from './auth-token.service'; import { AuthTokenService } from './auth-token.service';
import { AuthService } from './auth.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 { PasswordService } from './password.service';
import { UserRepository } from './user.repository'; import { UserRepository } from './user.repository';
import { UserRecord } from './auth.types'; import { UserRecord } from './auth.types';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
const TEST_ADMIN_PASSWORD = 'test-admin-password';
describe('AuthService', () => { describe('AuthService', () => {
const passwordService = new PasswordService(); const passwordService = new PasswordService();
async function createService() { async function createService(options?: { superAdminPassword?: string }) {
const adminHash = await passwordService.hashPassword(TEST_ADMIN_PASSWORD); const adminPassword =
options?.superAdminPassword ?? SUPER_ADMIN_INITIAL_PASSWORD;
const adminHash = await passwordService.hashPassword(adminPassword);
const memberHash = await passwordService.hashPassword(MEMBER_INITIAL_PASSWORD); const memberHash = await passwordService.hashPassword(MEMBER_INITIAL_PASSWORD);
const users = new Map<string, UserRecord>(); const users = new Map<string, UserRecord>();
const admin: UserRecord = { const admin: UserRecord = {
@@ -106,7 +109,7 @@ describe('AuthService', () => {
const config = { const config = {
get: jest.fn((key: keyof EnvConfig) => { get: jest.fn((key: keyof EnvConfig) => {
if (key === 'SUPER_ADMIN_INITIAL_PASSWORD') { if (key === 'SUPER_ADMIN_INITIAL_PASSWORD') {
return TEST_ADMIN_PASSWORD; return options?.superAdminPassword;
} }
if (key === 'NODE_ENV') { if (key === 'NODE_ENV') {
@@ -139,7 +142,7 @@ describe('AuthService', () => {
const result = await service.login({ const result = await service.login({
account: 'zhanxi', account: 'zhanxi',
password: TEST_ADMIN_PASSWORD, password: SUPER_ADMIN_INITIAL_PASSWORD,
}); });
expect(result.token).toBe('token_001'); expect(result.token).toBe('token_001');
@@ -147,6 +150,19 @@ describe('AuthService', () => {
expect(repository.markLogin).toHaveBeenCalledWith('user_admin'); 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 () => { it('拒绝超级管理员使用忘记密码流程', async () => {
const { service } = await createService(); const { service } = await createService();
+2 -10
View File
@@ -8,8 +8,8 @@ import { AppError } from '../common/errors/app-error';
import { EnvConfig } from '../config/env.schema'; import { EnvConfig } from '../config/env.schema';
import { MessagesService } from '../messages/messages.service'; import { MessagesService } from '../messages/messages.service';
import { import {
LOCAL_SUPER_ADMIN_INITIAL_PASSWORD,
MEMBER_INITIAL_PASSWORD, MEMBER_INITIAL_PASSWORD,
SUPER_ADMIN_INITIAL_PASSWORD,
} from './auth.constants'; } from './auth.constants';
import { AuthTokenService } from './auth-token.service'; import { AuthTokenService } from './auth-token.service';
import { import {
@@ -278,14 +278,6 @@ export class AuthService implements OnModuleInit {
return password; return password;
} }
if (this.config.get('NODE_ENV', { infer: true }) === 'production') { return SUPER_ADMIN_INITIAL_PASSWORD;
throw new AppError(
'CONFIG_MISSING',
'生产环境缺少 SUPER_ADMIN_INITIAL_PASSWORD,不能初始化超级管理员',
500,
);
}
return LOCAL_SUPER_ADMIN_INITIAL_PASSWORD;
} }
} }
+104
View File
@@ -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<void> {
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;
}
@@ -125,10 +125,12 @@ describe('DeployExecutionService', () => {
repository: DeployRunRepository, repository: DeployRunRepository,
idempotencyKey: string, idempotencyKey: string,
): Promise<DeployRunSummary> { ): Promise<DeployRunSummary> {
return repository.create({ const result = await repository.create({
...input, ...input,
idempotencyKey, idempotencyKey,
}); });
return result.run;
} }
function step(run: DeployRunSummary, bpmnNodeId: string) { function step(run: DeployRunSummary, bpmnNodeId: string) {
+19 -7
View File
@@ -20,6 +20,7 @@ import { DeployRunsService } from './deploy-runs.service';
describe('DeployRunsService', () => { describe('DeployRunsService', () => {
const baseRun: DeployRunSummary = { const baseRun: DeployRunSummary = {
id: 'run_001', id: 'run_001',
version: 1,
projectKey: 'access-manage', projectKey: 'access-manage',
environment: 'test', environment: 'test',
ref: 'develop', ref: 'develop',
@@ -61,13 +62,16 @@ describe('DeployRunsService', () => {
), ),
create: jest.fn((input: CreateDeployRunInput) => create: jest.fn((input: CreateDeployRunInput) =>
Promise.resolve({ Promise.resolve({
...baseRun, run: {
id: input.idempotencyKey ?? 'generated_run', ...baseRun,
projectKey: input.projectKey, id: input.idempotencyKey ?? 'generated_run',
environment: input.environment, projectKey: input.projectKey,
ref: input.ref, environment: input.environment,
operator: input.operator, ref: input.ref,
trigger: input.trigger ?? 'manual', operator: input.operator,
trigger: input.trigger ?? 'manual',
},
created: true,
}), }),
), ),
updateRun: jest.fn((id: string, patch: { status?: DeployRunStatus }) => updateRun: jest.fn((id: string, patch: { status?: DeployRunStatus }) =>
@@ -270,6 +274,10 @@ describe('DeployRunsService', () => {
environment: 'production', environment: 'production',
ref: 'master', ref: 'master',
operator: 'ops-admin', operator: 'ops-admin',
productionConfirmation: {
confirmed: true,
summary: '测试已确认生产发布风险',
},
}), }),
).rejects.toBeInstanceOf(AppError); ).rejects.toBeInstanceOf(AppError);
expect(repository.create).not.toHaveBeenCalled(); expect(repository.create).not.toHaveBeenCalled();
@@ -308,6 +316,10 @@ describe('DeployRunsService', () => {
ref: 'master', ref: 'master',
operator: 'ops-admin', operator: 'ops-admin',
idempotencyKey: 'self-managed-master', idempotencyKey: 'self-managed-master',
productionConfirmation: {
confirmed: true,
summary: '测试已确认生产发布风险',
},
}); });
expect(run.id).toBe('self-managed-master'); expect(run.id).toBe('self-managed-master');
@@ -11,6 +11,7 @@ import { NotificationOutboxService } from '../outbox/notification-outbox.service
describe('DeployNotificationService', () => { describe('DeployNotificationService', () => {
const run: DeployRunSummary = { const run: DeployRunSummary = {
id: 'run_001', id: 'run_001',
version: 1,
projectKey: 'access-manage', projectKey: 'access-manage',
environment: 'test', environment: 'test',
ref: 'develop', ref: 'develop',
+12
View File
@@ -1,6 +1,9 @@
import { ConfigService } from '@nestjs/config';
import { AuthService } from '../auth/auth.service'; import { AuthService } from '../auth/auth.service';
import { AppError } from '../common/errors/app-error'; import { AppError } from '../common/errors/app-error';
import { EnvConfig } from '../config/env.schema';
import { GiteaClient } from '../integrations/gitea/gitea.client'; import { GiteaClient } from '../integrations/gitea/gitea.client';
import { JenkinsClient } from '../integrations/jenkins/jenkins.client';
import { ProjectRepository } from './project.repository'; import { ProjectRepository } from './project.repository';
import { ProjectSummary } from './project.types'; import { ProjectSummary } from './project.types';
import { ProjectsService } from './projects.service'; import { ProjectsService } from './projects.service';
@@ -73,15 +76,24 @@ describe('ProjectsService', () => {
AuthService, AuthService,
'listAllowedProjectKeys' | 'requireProjectPermission' 'listAllowedProjectKeys' | 'requireProjectPermission'
>; >;
const jenkins = {
healthSummary: jest.fn(() => ({ status: 'not_configured' as const })),
} satisfies Pick<JenkinsClient, 'healthSummary'>;
const config = {
get: jest.fn(() => undefined),
} satisfies Pick<ConfigService<EnvConfig, true>, 'get'>;
return { return {
auth, auth,
gitea, gitea,
jenkins,
repository, repository,
service: new ProjectsService( service: new ProjectsService(
repository as unknown as ProjectRepository, repository as unknown as ProjectRepository,
gitea as unknown as GiteaClient, gitea as unknown as GiteaClient,
jenkins as unknown as JenkinsClient,
auth as unknown as AuthService, auth as unknown as AuthService,
config as unknown as ConfigService<EnvConfig, true>,
), ),
}; };
} }
+21
View File
@@ -238,6 +238,7 @@ export class SettingsService {
configured, configured,
missing, missing,
...(definition.optional ? { optional: [...definition.optional] } : {}), ...(definition.optional ? { optional: [...definition.optional] } : {}),
...this.booleanFlagGroups(definition),
...(definition.note ? { note: definition.note } : {}), ...(definition.note ? { note: definition.note } : {}),
...(definition.variableHelp ...(definition.variableHelp
? { variableHelp: this.pickVariableHelp(definition) } ? { variableHelp: this.pickVariableHelp(definition) }
@@ -270,6 +271,7 @@ export class SettingsService {
configured, configured,
missing, missing,
...(definition.optional ? { optional: [...definition.optional] } : {}), ...(definition.optional ? { optional: [...definition.optional] } : {}),
...this.booleanFlagGroups(definition),
note: note:
provider && !providerWebhook provider && !providerWebhook
? `NOTIFICATION_PROVIDER=${provider} 暂不支持;可选 wecom、feishu、generic。` ? `NOTIFICATION_PROVIDER=${provider} 暂不支持;可选 wecom、feishu、generic。`
@@ -312,6 +314,25 @@ export class SettingsService {
return !PLACEHOLDER_PATTERNS.some((pattern) => pattern.test(value)); return !PLACEHOLDER_PATTERNS.some((pattern) => pattern.test(value));
} }
private booleanFlagGroups(
definition: IntegrationDefinition,
): Pick<IntegrationConfigStatus, 'enabled' | 'disabled'> {
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( private resolveStatus(
configuredCount: number, configuredCount: number,
missingCount: number, missingCount: number,
+2
View File
@@ -24,6 +24,8 @@ export type IntegrationConfigStatus = {
configured: string[]; configured: string[];
missing: string[]; missing: string[];
optional?: string[]; optional?: string[];
enabled?: string[];
disabled?: string[];
note?: string; note?: string;
variableHelp?: Record<string, IntegrationVariableHelp>; variableHelp?: Record<string, IntegrationVariableHelp>;
}; };
@@ -51,6 +51,7 @@ describe('GiteaWebhookService', () => {
createRun: jest.fn(() => createRun: jest.fn(() =>
Promise.resolve({ Promise.resolve({
id: 'gitea:delivery-1:access-manage:develop:abc123', id: 'gitea:delivery-1:access-manage:develop:abc123',
version: 1,
projectKey: 'access-manage', projectKey: 'access-manage',
environment: 'test' as const, environment: 'test' as const,
ref: 'develop', ref: 'develop',