chore: 准备后端开源安全状态
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Wang Yuanyou
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -40,12 +40,11 @@ pnpm prisma:seed
|
||||
- 如需紧急跳过迁移,可在服务器环境中设置 `PRISMA_MIGRATE_DEPLOY_ENABLED=false`,但不能在存在 schema 变更的版本长期使用该开关。
|
||||
- 新增表或字段时必须提交新的 `prisma/migrations/<timestamp>_<name>/migration.sql`,不要再用手工 SQL 直接改生产库。
|
||||
|
||||
## 内置账号
|
||||
## 账号初始化
|
||||
|
||||
- 超级管理员账号:`zhanxi`
|
||||
- 本地开发默认超级管理员初始密码:`zx123456@`。
|
||||
- 生产环境必须通过服务器环境变量 `SUPER_ADMIN_INITIAL_PASSWORD` 提供超级管理员初始密码;未配置时服务会拒绝使用公开默认密码初始化。
|
||||
- 普通成员初始密码:`111111`
|
||||
- 超级管理员初始密码必须通过环境变量 `SUPER_ADMIN_INITIAL_PASSWORD` 提供;源码不保存固定默认口令。
|
||||
- 普通成员创建或重置密码时,后端会生成一次性临时密码并仅在本次接口响应中返回给超级管理员。
|
||||
|
||||
超级管理员初始化配置集中在 `src/auth/auth.constants.ts`、`prisma/seed.js` 和服务器环境变量。服务启动和种子数据只负责确保超级管理员账号存在并保持最高权限;如果超级管理员已修改密码,后续初始化不会覆盖密码。
|
||||
|
||||
@@ -55,7 +54,7 @@ pnpm prisma:seed
|
||||
pnpm reset:super-admin-password
|
||||
```
|
||||
|
||||
该命令面向已构建的服务器部署目录,会自动读取当前目录的 `.env.production`。生产环境必须临时设置 `SUPER_ADMIN_RESET_PASSWORD` 后执行命令,脚本不会输出明文密码。本地开发未设置该变量时仍可回退到开发默认密码,源码调试可先执行 `pnpm build`,或使用 `pnpm reset:super-admin-password:dev`。
|
||||
该命令面向已构建的服务器部署目录,会自动读取当前目录的 `.env.production`。执行前必须临时设置 `SUPER_ADMIN_RESET_PASSWORD`,脚本不会输出明文密码。源码调试可先执行 `pnpm build`,或使用 `pnpm reset:super-admin-password:dev`。
|
||||
|
||||
## 关键接口
|
||||
|
||||
@@ -64,9 +63,9 @@ pnpm reset:super-admin-password
|
||||
- `POST /auth/change-password`:修改当前登录账号密码。
|
||||
- `POST /auth/forgot-password`:普通成员忘记密码,通知超级管理员处理。
|
||||
- `GET /members`:超级管理员查询成员列表和项目权限。
|
||||
- `POST /members`:超级管理员创建普通成员,初始密码固定为 `111111`。
|
||||
- `POST /members`:超级管理员创建普通成员,返回一次性临时密码。
|
||||
- `PATCH /members/:id/permissions`:超级管理员更新普通成员项目权限。
|
||||
- `POST /members/:id/reset-password`:超级管理员将普通成员密码重置为 `111111`。
|
||||
- `POST /members/:id/reset-password`:超级管理员为普通成员生成一次性临时密码。
|
||||
- `PATCH /members/:id/status`:超级管理员启用或禁用普通成员。
|
||||
- `GET /messages`:超级管理员查询消息通知。
|
||||
- `POST /messages/:id/handled`:超级管理员将消息标记为已处理。
|
||||
@@ -109,8 +108,8 @@ pnpm reset:super-admin-password
|
||||
|
||||
- `AUTH_TOKEN_SECRET`:登录 token 签名密钥。
|
||||
- `AUTH_TOKEN_TTL_SECONDS`:登录 token 有效期,默认 43200 秒。
|
||||
- `SUPER_ADMIN_INITIAL_PASSWORD`:生产环境必填的超级管理员初始化密码;本地开发不设置时才使用默认值。
|
||||
- `SUPER_ADMIN_RESET_PASSWORD`:服务器重置脚本使用的一次性超级管理员重置密码;生产环境执行重置时必填。
|
||||
- `SUPER_ADMIN_INITIAL_PASSWORD`:超级管理员初始化密码,服务启动或执行种子数据时必填。
|
||||
- `SUPER_ADMIN_RESET_PASSWORD`:服务器重置脚本使用的一次性超级管理员重置密码,执行重置时必填。
|
||||
- `SECRET_ENCRYPTION_KEY`:服务端密钥加密密钥,生产环境保存 Agent 密钥时必须配置。
|
||||
- `DATABASE_URL`:MySQL 连接地址。
|
||||
- `USE_DATABASE_READS`:是否启用 Prisma 数据读写。
|
||||
@@ -134,3 +133,7 @@ Agent 不是通用聊天入口,只支持运维工作流:
|
||||
- 事故复盘草稿
|
||||
|
||||
普通成员使用 Agent 时必须绑定自己有权限访问的项目。Agent 调用只读,不会触发发布、修改 Jenkins、修改 Gitea 或暴露密钥。
|
||||
|
||||
## 开源许可
|
||||
|
||||
本仓库使用 MIT License。提交公开仓库前请确认 `.env`、部署密钥和真实第三方 token 未进入 Git 历史或提交内容。
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "运维平台后端 API,提供 Jenkins、Gitea、通知、发布流程、账号权限和 Agent 运维能力。",
|
||||
"license": "UNLICENSED",
|
||||
"license": "MIT",
|
||||
"packageManager": "pnpm@9.15.4",
|
||||
"engines": {
|
||||
"node": ">=20.11.0",
|
||||
|
||||
+2
-11
@@ -8,10 +8,7 @@ const {
|
||||
UserRole,
|
||||
UserStatus,
|
||||
} = require('@prisma/client');
|
||||
const {
|
||||
SUPER_ADMIN_INITIAL_PASSWORD,
|
||||
SUPER_ADMIN_SEED,
|
||||
} = require('../dist/auth/auth.constants');
|
||||
const { SUPER_ADMIN_SEED } = require('../dist/auth/auth.constants');
|
||||
const { PasswordService } = require('../dist/auth/password.service');
|
||||
const { seedProjects } = require('../dist/projects/project.seed');
|
||||
|
||||
@@ -92,13 +89,7 @@ function resolveSuperAdminInitialPassword() {
|
||||
return process.env.SUPER_ADMIN_INITIAL_PASSWORD;
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
throw new Error(
|
||||
'生产环境缺少 SUPER_ADMIN_INITIAL_PASSWORD,不能使用公开默认密码初始化超级管理员',
|
||||
);
|
||||
}
|
||||
|
||||
return SUPER_ADMIN_INITIAL_PASSWORD;
|
||||
throw new Error('缺少 SUPER_ADMIN_INITIAL_PASSWORD,不能初始化超级管理员');
|
||||
}
|
||||
|
||||
async function upsertEnvironment(projectId, environment) {
|
||||
|
||||
@@ -4,10 +4,6 @@ export const SUPER_ADMIN_SEED = {
|
||||
displayName: '超级管理员',
|
||||
} as const;
|
||||
|
||||
export const SUPER_ADMIN_INITIAL_PASSWORD = 'zx123456@';
|
||||
|
||||
export const MEMBER_INITIAL_PASSWORD = '111111';
|
||||
|
||||
export const AUTH_COOKIE_NAME = 'devops_auth_token';
|
||||
|
||||
export const AUTH_TOKEN_VERSION = 'v1';
|
||||
|
||||
@@ -4,10 +4,6 @@ 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,
|
||||
SUPER_ADMIN_INITIAL_PASSWORD,
|
||||
} from './auth.constants';
|
||||
import { PasswordService } from './password.service';
|
||||
import { UserRepository } from './user.repository';
|
||||
import { UserRecord } from './auth.types';
|
||||
@@ -15,15 +11,18 @@ import { ConfigService } from '@nestjs/config';
|
||||
|
||||
describe('AuthService', () => {
|
||||
const passwordService = new PasswordService();
|
||||
const testSuperAdminPassword = 'test-super-admin-password';
|
||||
const testMemberTemporaryPassword = 'test-member-temporary-password';
|
||||
|
||||
async function createService(options?: {
|
||||
superAdminPassword?: string;
|
||||
nodeEnv?: EnvConfig['NODE_ENV'];
|
||||
}) {
|
||||
const adminPassword =
|
||||
options?.superAdminPassword ?? SUPER_ADMIN_INITIAL_PASSWORD;
|
||||
const adminPassword = options?.superAdminPassword ?? testSuperAdminPassword;
|
||||
const adminHash = await passwordService.hashPassword(adminPassword);
|
||||
const memberHash = await passwordService.hashPassword(MEMBER_INITIAL_PASSWORD);
|
||||
const memberHash = await passwordService.hashPassword(
|
||||
testMemberTemporaryPassword,
|
||||
);
|
||||
const users = new Map<string, UserRecord>();
|
||||
const admin: UserRecord = {
|
||||
id: 'user_admin',
|
||||
@@ -145,7 +144,7 @@ describe('AuthService', () => {
|
||||
|
||||
const result = await service.login({
|
||||
account: 'zhanxi',
|
||||
password: SUPER_ADMIN_INITIAL_PASSWORD,
|
||||
password: testSuperAdminPassword,
|
||||
});
|
||||
|
||||
expect(result.token).toBe('token_001');
|
||||
@@ -166,7 +165,7 @@ describe('AuthService', () => {
|
||||
expect(result.user.role).toBe('super_admin');
|
||||
});
|
||||
|
||||
it('生产环境拒绝使用公开默认密码初始化超级管理员', async () => {
|
||||
it('缺少环境变量时拒绝初始化超级管理员', async () => {
|
||||
const { repository, service } = await createService({
|
||||
nodeEnv: 'production',
|
||||
});
|
||||
@@ -196,13 +195,13 @@ describe('AuthService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('普通成员改密不能继续使用初始密码', async () => {
|
||||
it('普通成员改密不能继续使用当前临时密码', async () => {
|
||||
const { member, service } = await createService();
|
||||
|
||||
await expect(
|
||||
service.changePassword(member, {
|
||||
currentPassword: MEMBER_INITIAL_PASSWORD,
|
||||
newPassword: MEMBER_INITIAL_PASSWORD,
|
||||
currentPassword: testMemberTemporaryPassword,
|
||||
newPassword: testMemberTemporaryPassword,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(AppError);
|
||||
});
|
||||
|
||||
@@ -7,10 +7,6 @@ import { AuditService } from '../audit/audit.service';
|
||||
import { AppError } from '../common/errors/app-error';
|
||||
import { EnvConfig } from '../config/env.schema';
|
||||
import { MessagesService } from '../messages/messages.service';
|
||||
import {
|
||||
MEMBER_INITIAL_PASSWORD,
|
||||
SUPER_ADMIN_INITIAL_PASSWORD,
|
||||
} from './auth.constants';
|
||||
import { AuthTokenService } from './auth-token.service';
|
||||
import {
|
||||
ChangePasswordInput,
|
||||
@@ -115,14 +111,6 @@ export class AuthService implements OnModuleInit {
|
||||
throw new AppError('VALIDATION_FAILED', '新密码不能与当前密码相同', 400);
|
||||
}
|
||||
|
||||
if (user.role === 'member' && input.newPassword === MEMBER_INITIAL_PASSWORD) {
|
||||
throw new AppError(
|
||||
'VALIDATION_FAILED',
|
||||
'新密码不能继续使用初始密码',
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const passwordHash = await this.passwordService.hashPassword(
|
||||
input.newPassword,
|
||||
);
|
||||
@@ -285,14 +273,10 @@ 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 SUPER_ADMIN_INITIAL_PASSWORD;
|
||||
throw new AppError(
|
||||
'CONFIG_MISSING',
|
||||
'缺少 SUPER_ADMIN_INITIAL_PASSWORD,不能初始化超级管理员',
|
||||
500,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
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 { SUPER_ADMIN_SEED } from './auth.constants';
|
||||
import { PasswordService } from './password.service';
|
||||
|
||||
loadProductionEnv();
|
||||
@@ -14,19 +11,14 @@ const passwordService = new PasswordService();
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const configuredPassword = process.env.SUPER_ADMIN_RESET_PASSWORD?.trim();
|
||||
const password =
|
||||
configuredPassword ||
|
||||
(process.env.NODE_ENV === 'production'
|
||||
? undefined
|
||||
: SUPER_ADMIN_INITIAL_PASSWORD);
|
||||
|
||||
if (!password) {
|
||||
if (!configuredPassword) {
|
||||
throw new Error(
|
||||
'生产环境缺少 SUPER_ADMIN_RESET_PASSWORD,不能使用公开默认密码重置超级管理员',
|
||||
'缺少 SUPER_ADMIN_RESET_PASSWORD,不能重置超级管理员密码',
|
||||
);
|
||||
}
|
||||
|
||||
const passwordHash = await passwordService.hashPassword(password);
|
||||
const passwordHash = await passwordService.hashPassword(configuredPassword);
|
||||
|
||||
await prisma.user.upsert({
|
||||
where: {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* 生成只在创建或重置账号时返回一次的临时密码,避免源码保存固定默认口令。
|
||||
*/
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
const TEMPORARY_PASSWORD_RANDOM_BYTES = 14;
|
||||
|
||||
export function generateTemporaryPassword(): string {
|
||||
const randomSegment = randomBytes(TEMPORARY_PASSWORD_RANDOM_BYTES)
|
||||
.toString('base64url')
|
||||
.replace(/[-_]/g, '')
|
||||
.slice(0, 16);
|
||||
|
||||
return `Dvp${randomSegment}9`;
|
||||
}
|
||||
@@ -23,3 +23,7 @@ export type MemberSummary = {
|
||||
updatedAt: string;
|
||||
projectPermissions: MemberProjectPermissionSummary[];
|
||||
};
|
||||
|
||||
export type MemberCredentialResult = MemberSummary & {
|
||||
temporaryPassword: string;
|
||||
};
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
updateMemberPermissionsSchema,
|
||||
updateMemberStatusSchema,
|
||||
} from './member.dto';
|
||||
import { MemberSummary } from './member.types';
|
||||
import { MemberCredentialResult, MemberSummary } from './member.types';
|
||||
import { MembersService } from './members.service';
|
||||
|
||||
@ApiTags('成员管理')
|
||||
@@ -50,12 +50,14 @@ export class MembersController {
|
||||
|
||||
@Post()
|
||||
@ApiBody({ type: CreateMemberDto })
|
||||
@ApiCreatedResponse({ description: '创建普通成员,初始密码固定为 111111。' })
|
||||
@ApiCreatedResponse({
|
||||
description: '创建普通成员,并返回仅本次可见的一次性临时密码。',
|
||||
})
|
||||
async createMember(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Body(new ZodValidationPipe(createMemberSchema))
|
||||
body: CreateMemberDtoInput,
|
||||
): Promise<MemberSummary> {
|
||||
): Promise<MemberCredentialResult> {
|
||||
return this.membersService.createMember(body, user);
|
||||
}
|
||||
|
||||
@@ -73,12 +75,12 @@ export class MembersController {
|
||||
|
||||
@Post(':id/reset-password')
|
||||
@ApiOkResponse({
|
||||
description: '将普通成员密码重置为 111111,并要求下次登录改密。',
|
||||
description: '生成普通成员一次性临时密码,并要求下次登录改密。',
|
||||
})
|
||||
async resetPassword(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
): Promise<MemberSummary> {
|
||||
): Promise<MemberCredentialResult> {
|
||||
return this.membersService.resetPassword(id, user);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { MEMBER_INITIAL_PASSWORD, SUPER_ADMIN_SEED } from '../auth/auth.constants';
|
||||
import { SUPER_ADMIN_SEED } from '../auth/auth.constants';
|
||||
import {
|
||||
AuthenticatedUser,
|
||||
ProjectPermissionInput,
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
UserRecord,
|
||||
} from '../auth/auth.types';
|
||||
import { PasswordService } from '../auth/password.service';
|
||||
import { generateTemporaryPassword } from '../auth/temporary-password';
|
||||
import { UserRepository } from '../auth/user.repository';
|
||||
import { AppError } from '../common/errors/app-error';
|
||||
import { MessagesService } from '../messages/messages.service';
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
UpdateMemberStatusInput,
|
||||
} from './member.dto';
|
||||
import {
|
||||
MemberCredentialResult,
|
||||
MemberProjectPermissionSummary,
|
||||
MemberSummary,
|
||||
} from './member.types';
|
||||
@@ -41,7 +43,7 @@ export class MembersService {
|
||||
async createMember(
|
||||
input: CreateMemberDtoInput,
|
||||
operator: AuthenticatedUser,
|
||||
): Promise<MemberSummary> {
|
||||
): Promise<MemberCredentialResult> {
|
||||
const account = input.account.trim();
|
||||
|
||||
if (account === SUPER_ADMIN_SEED.account) {
|
||||
@@ -58,9 +60,9 @@ export class MembersService {
|
||||
throw new AppError('CONFLICT', '成员账号已存在', 409, { account });
|
||||
}
|
||||
|
||||
const passwordHash = await this.passwordService.hashPassword(
|
||||
MEMBER_INITIAL_PASSWORD,
|
||||
);
|
||||
const temporaryPassword = generateTemporaryPassword();
|
||||
const passwordHash =
|
||||
await this.passwordService.hashPassword(temporaryPassword);
|
||||
const user = await this.userRepository.createUser({
|
||||
account,
|
||||
displayName: input.displayName,
|
||||
@@ -86,11 +88,15 @@ export class MembersService {
|
||||
after: {
|
||||
account: updatedUser.account,
|
||||
permissions: this.safePermissionDigest(permissions),
|
||||
temporaryPasswordIssued: true,
|
||||
mustChangePassword: true,
|
||||
},
|
||||
});
|
||||
|
||||
return this.toMemberSummary(updatedUser);
|
||||
return {
|
||||
...(await this.toMemberSummary(updatedUser)),
|
||||
temporaryPassword,
|
||||
};
|
||||
}
|
||||
|
||||
async updatePermissions(
|
||||
@@ -125,11 +131,11 @@ export class MembersService {
|
||||
async resetPassword(
|
||||
memberId: string,
|
||||
operator: AuthenticatedUser,
|
||||
): Promise<MemberSummary> {
|
||||
): Promise<MemberCredentialResult> {
|
||||
const user = await this.requireMutableMember(memberId);
|
||||
const passwordHash = await this.passwordService.hashPassword(
|
||||
MEMBER_INITIAL_PASSWORD,
|
||||
);
|
||||
const temporaryPassword = generateTemporaryPassword();
|
||||
const passwordHash =
|
||||
await this.passwordService.hashPassword(temporaryPassword);
|
||||
const updatedUser = await this.userRepository.updatePassword(
|
||||
user.id,
|
||||
passwordHash,
|
||||
@@ -147,12 +153,15 @@ export class MembersService {
|
||||
actorId: operator.id,
|
||||
actorName: operator.account,
|
||||
after: {
|
||||
resetToInitialPassword: true,
|
||||
temporaryPasswordIssued: true,
|
||||
mustChangePassword: true,
|
||||
},
|
||||
});
|
||||
|
||||
return this.toMemberSummary(updatedUser);
|
||||
return {
|
||||
...(await this.toMemberSummary(updatedUser)),
|
||||
temporaryPassword,
|
||||
};
|
||||
}
|
||||
|
||||
async updateStatus(
|
||||
@@ -215,7 +224,7 @@ export class MembersService {
|
||||
if (user.role === 'super_admin') {
|
||||
throw new AppError(
|
||||
'FORBIDDEN',
|
||||
'超级管理员不可删除、不可禁用、不可降级、不可重置为普通成员初始密码',
|
||||
'超级管理员不可删除、不可禁用、不可降级、不可重置为普通成员临时密码',
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user