This commit is contained in:
Copilot 2025-11-07 16:38:25 +00:00 committed by GitHub
commit e35d5eb52d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 678 additions and 99 deletions

112
MULTI_USER_GUIDE.md Normal file
View File

@ -0,0 +1,112 @@
# 多用户管理功能说明 (Multi-User Management Guide)
## 功能概述 (Overview)
青龙面板现已支持多用户管理和数据隔离功能。管理员可以创建多个用户账号,每个用户只能看到和操作自己的数据。
Qinglong now supports multi-user management with data isolation. Administrators can create multiple user accounts, and each user can only see and operate their own data.
## 用户角色 (User Roles)
### 管理员 (Admin)
- 可以访问所有用户的数据
- 可以创建、编辑、删除用户
- 可以管理系统设置
- Can access all users' data
- Can create, edit, and delete users
- Can manage system settings
### 普通用户 (Regular User)
- 只能访问自己创建的数据
- 可以管理自己的定时任务、环境变量、订阅和依赖
- 无法访问其他用户的数据
- Can only access their own data
- Can manage their own cron jobs, environment variables, subscriptions, and dependencies
- Cannot access other users' data
## API 使用 (API Usage)
### 用户管理接口 (User Management Endpoints)
所有用户管理接口需要管理员权限。
All user management endpoints require admin privileges.
#### 获取用户列表 (Get User List)
```
GET /api/user-management?searchValue=keyword
```
#### 创建用户 (Create User)
```
POST /api/user-management
{
"username": "user1",
"password": "password123",
"role": 1, // 0: admin, 1: user
"status": 0 // 0: active, 1: disabled
}
```
#### 更新用户 (Update User)
```
PUT /api/user-management
{
"id": 1,
"username": "user1",
"password": "newpassword",
"role": 1,
"status": 0
}
```
#### 删除用户 (Delete Users)
```
DELETE /api/user-management
[1, 2, 3] // User IDs to delete
```
## 数据隔离 (Data Isolation)
### 定时任务 (Cron Jobs)
- 每个用户创建的定时任务会自动关联到该用户
- 用户只能查看、编辑、运行、删除自己的定时任务
- 管理员可以查看所有用户的定时任务
### 环境变量 (Environment Variables)
- 每个用户的环境变量相互隔离
- 用户只能查看和修改自己的环境变量
- 管理员可以查看所有环境变量
### 订阅和依赖 (Subscriptions and Dependencies)
- 用户数据完全隔离
- Only accessible by the owning user and admins
## 密码安全 (Password Security)
- 所有密码使用 bcrypt 加密存储
- 密码长度最少为 6 位
- 建议使用强密码
- All passwords are hashed with bcrypt
- Minimum password length is 6 characters
- Strong passwords are recommended
## 向后兼容 (Backward Compatibility)
- 原有的单用户系统管理员账号继续有效
- 已存在的数据可以被所有用户访问(遗留数据)
- 新创建的数据会自动关联到创建者
- The original system admin account remains valid
- Existing data is accessible by all users (legacy data)
- Newly created data is automatically associated with the creator
## 注意事项 (Notes)
1. **首次使用**:首次使用多用户功能时,建议先创建一个管理员账号作为备份
2. **密码管理**:请妥善保管用户密码,忘记密码需要管理员重置
3. **数据迁移**:如需将现有数据分配给特定用户,请联系管理员手动更新数据库
4. **权限控制**:删除用户不会删除该用户的数据,数据会变为遗留数据
1. **First Use**: When first using multi-user functionality, it's recommended to create an admin account as a backup
2. **Password Management**: Please keep user passwords safe; forgotten passwords need admin reset
3. **Data Migration**: To assign existing data to specific users, contact admin for manual database update
4. **Permission Control**: Deleting a user doesn't delete their data; the data becomes legacy data

View File

@ -145,7 +145,10 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger');
try {
const cronService = Container.get(CronService);
const data = await cronService.crontabs(req.query as any);
const data = await cronService.crontabs({
...req.query as any,
userId: req.user?.userId
});
return res.send({ code: 200, data });
} catch (e) {
logger.error('🔥 error: %o', e);
@ -177,7 +180,10 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger');
try {
const cronService = Container.get(CronService);
const data = await cronService.create(req.body);
const data = await cronService.create({
...req.body,
userId: req.user?.userId
});
return res.send({ code: 200, data });
} catch (e) {
return next(e);
@ -194,10 +200,10 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger');
try {
const cronService = Container.get(CronService);
const data = await cronService.run(req.body);
const data = await cronService.run(req.body, req.user?.userId);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
} catch (e: any) {
return res.send({ code: 400, message: e.message });
}
},
);
@ -211,10 +217,10 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger');
try {
const cronService = Container.get(CronService);
const data = await cronService.stop(req.body);
const data = await cronService.stop(req.body, req.user?.userId);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
} catch (e: any) {
return res.send({ code: 400, message: e.message });
}
},
);
@ -234,10 +240,11 @@ export default (app: Router) => {
const data = await cronService.removeLabels(
req.body.ids,
req.body.labels,
req.user?.userId,
);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
} catch (e: any) {
return res.send({ code: 400, message: e.message });
}
},
);
@ -254,10 +261,10 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger');
try {
const cronService = Container.get(CronService);
const data = await cronService.addLabels(req.body.ids, req.body.labels);
const data = await cronService.addLabels(req.body.ids, req.body.labels, req.user?.userId);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
} catch (e: any) {
return res.send({ code: 400, message: e.message });
}
},
);
@ -271,10 +278,10 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger');
try {
const cronService = Container.get(CronService);
const data = await cronService.disabled(req.body);
const data = await cronService.disabled(req.body, req.user?.userId);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
} catch (e: any) {
return res.send({ code: 400, message: e.message });
}
},
);
@ -288,10 +295,10 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger');
try {
const cronService = Container.get(CronService);
const data = await cronService.enabled(req.body);
const data = await cronService.enabled(req.body, req.user?.userId);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
} catch (e: any) {
return res.send({ code: 400, message: e.message });
}
},
);
@ -344,10 +351,10 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger');
try {
const cronService = Container.get(CronService);
const data = await cronService.remove(req.body);
const data = await cronService.remove(req.body, req.user?.userId);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
} catch (e: any) {
return res.send({ code: 400, message: e.message });
}
},
);
@ -361,10 +368,10 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger');
try {
const cronService = Container.get(CronService);
const data = await cronService.pin(req.body);
const data = await cronService.pin(req.body, req.user?.userId);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
} catch (e: any) {
return res.send({ code: 400, message: e.message });
}
},
);
@ -378,10 +385,10 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger');
try {
const cronService = Container.get(CronService);
const data = await cronService.unPin(req.body);
const data = await cronService.unPin(req.body, req.user?.userId);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
} catch (e: any) {
return res.send({ code: 400, message: e.message });
}
},
);

View File

@ -26,7 +26,7 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger');
try {
const envService = Container.get(EnvService);
const data = await envService.envs(req.query.searchValue as string);
const data = await envService.envs(req.query.searchValue as string, {}, req.user?.userId);
return res.send({ code: 200, data });
} catch (e) {
logger.error('🔥 error: %o', e);
@ -54,7 +54,7 @@ export default (app: Router) => {
if (!req.body?.length) {
return res.send({ code: 400, message: '参数不正确' });
}
const data = await envService.create(req.body);
const data = await envService.create(req.body, req.user?.userId);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
@ -93,10 +93,10 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger');
try {
const envService = Container.get(EnvService);
const data = await envService.remove(req.body);
const data = await envService.remove(req.body, req.user?.userId);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
} catch (e: any) {
return res.send({ code: 400, message: e.message });
}
},
);
@ -132,10 +132,10 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger');
try {
const envService = Container.get(EnvService);
const data = await envService.disabled(req.body);
const data = await envService.disabled(req.body, req.user?.userId);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
} catch (e: any) {
return res.send({ code: 400, message: e.message });
}
},
);
@ -149,10 +149,10 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger');
try {
const envService = Container.get(EnvService);
const data = await envService.enabled(req.body);
const data = await envService.enabled(req.body, req.user?.userId);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
} catch (e: any) {
return res.send({ code: 400, message: e.message });
}
},
);
@ -169,10 +169,10 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger');
try {
const envService = Container.get(EnvService);
const data = await envService.updateNames(req.body);
const data = await envService.updateNames(req.body, req.user?.userId);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
} catch (e: any) {
return res.send({ code: 400, message: e.message });
}
},
);

View File

@ -11,6 +11,7 @@ import system from './system';
import subscription from './subscription';
import update from './update';
import health from './health';
import userManagement from './userManagement';
export default () => {
const app = Router();
@ -26,6 +27,7 @@ export default () => {
subscription(app);
update(app);
health(app);
userManagement(app);
return app;
};

114
back/api/userManagement.ts Normal file
View File

@ -0,0 +1,114 @@
import { Router, Request, Response, NextFunction } from 'express';
import { Container } from 'typedi';
import { celebrate, Joi } from 'celebrate';
import UserManagementService from '../services/userManagement';
import { UserRole } from '../data/user';
const route = Router();
// Middleware to check if user is admin
const requireAdmin = (req: Request, res: Response, next: NextFunction) => {
if (req.user && req.user.role === UserRole.admin) {
return next();
}
return res.status(403).send({ code: 403, message: '需要管理员权限' });
};
export default (app: Router) => {
app.use('/user-management', route);
// List all users (admin only)
route.get(
'/',
requireAdmin,
async (req: Request, res: Response, next: NextFunction) => {
try {
const userManagementService = Container.get(UserManagementService);
const data = await userManagementService.list(req.query.searchValue as string);
res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
// Get a specific user (admin only)
route.get(
'/:id',
requireAdmin,
async (req: Request, res: Response, next: NextFunction) => {
try {
const userManagementService = Container.get(UserManagementService);
const data = await userManagementService.get(Number(req.params.id));
res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
// Create a new user (admin only)
route.post(
'/',
requireAdmin,
celebrate({
body: Joi.object({
username: Joi.string().required(),
password: Joi.string().required(),
role: Joi.number().valid(UserRole.admin, UserRole.user).default(UserRole.user),
status: Joi.number().optional(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const userManagementService = Container.get(UserManagementService);
const data = await userManagementService.create(req.body);
res.send({ code: 200, data, message: '创建用户成功' });
} catch (e: any) {
return res.send({ code: 400, message: e.message });
}
},
);
// Update a user (admin only)
route.put(
'/',
requireAdmin,
celebrate({
body: Joi.object({
id: Joi.number().required(),
username: Joi.string().required(),
password: Joi.string().required(),
role: Joi.number().valid(UserRole.admin, UserRole.user),
status: Joi.number().optional(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const userManagementService = Container.get(UserManagementService);
const data = await userManagementService.update(req.body);
res.send({ code: 200, data, message: '更新用户成功' });
} catch (e: any) {
return res.send({ code: 400, message: e.message });
}
},
);
// Delete users (admin only)
route.delete(
'/',
requireAdmin,
celebrate({
body: Joi.array().items(Joi.number()).required(),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const userManagementService = Container.get(UserManagementService);
const count = await userManagementService.delete(req.body);
res.send({ code: 200, data: count, message: '删除用户成功' });
} catch (e) {
return next(e);
}
},
);
};

View File

@ -21,6 +21,7 @@ export class Crontab {
extra_schedules?: Array<{ schedule: string }>;
task_before?: string;
task_after?: string;
userId?: number;
constructor(options: Crontab) {
this.name = options.name;
@ -45,6 +46,7 @@ export class Crontab {
this.extra_schedules = options.extra_schedules;
this.task_before = options.task_before;
this.task_after = options.task_after;
this.userId = options.userId;
}
}
@ -84,4 +86,5 @@ export const CrontabModel = sequelize.define<CronInstance>('Crontab', {
extra_schedules: DataTypes.JSON,
task_before: DataTypes.STRING,
task_after: DataTypes.STRING,
userId: { type: DataTypes.NUMBER, allowNull: true },
});

View File

@ -9,6 +9,7 @@ export class Dependence {
name: string;
log?: string[];
remark?: string;
userId?: number;
constructor(options: Dependence) {
this.id = options.id;
@ -21,6 +22,7 @@ export class Dependence {
this.name = options.name.trim();
this.log = options.log || [];
this.remark = options.remark || '';
this.userId = options.userId;
}
}
@ -59,5 +61,6 @@ export const DependenceModel = sequelize.define<DependenceInstance>(
status: DataTypes.NUMBER,
log: DataTypes.JSON,
remark: DataTypes.STRING,
userId: { type: DataTypes.NUMBER, allowNull: true },
},
);

View File

@ -9,6 +9,7 @@ export class Env {
position?: number;
name?: string;
remarks?: string;
userId?: number;
constructor(options: Env) {
this.value = options.value;
@ -21,6 +22,7 @@ export class Env {
this.position = options.position;
this.name = options.name;
this.remarks = options.remarks || '';
this.userId = options.userId;
}
}
@ -42,4 +44,5 @@ export const EnvModel = sequelize.define<EnvInstance>('Env', {
position: DataTypes.NUMBER,
name: { type: DataTypes.STRING, unique: 'compositeIndex' },
remarks: DataTypes.STRING,
userId: { type: DataTypes.NUMBER, allowNull: true },
});

View File

@ -31,6 +31,7 @@ export class Subscription {
proxy?: string;
autoAddCron?: 1 | 0;
autoDelCron?: 1 | 0;
userId?: number;
constructor(options: Subscription) {
this.id = options.id;
@ -60,6 +61,7 @@ export class Subscription {
this.proxy = options.proxy;
this.autoAddCron = options.autoAddCron ? 1 : 0;
this.autoDelCron = options.autoDelCron ? 1 : 0;
this.userId = options.userId;
}
}
@ -111,5 +113,6 @@ export const SubscriptionModel = sequelize.define<SubscriptionInstance>(
proxy: { type: DataTypes.STRING, allowNull: true },
autoAddCron: { type: DataTypes.NUMBER, allowNull: true },
autoDelCron: { type: DataTypes.NUMBER, allowNull: true },
userId: { type: DataTypes.NUMBER, allowNull: true },
},
);

56
back/data/user.ts Normal file
View File

@ -0,0 +1,56 @@
import { sequelize } from '.';
import { DataTypes, Model } from 'sequelize';
export class User {
id?: number;
username: string;
password: string;
role: UserRole;
status: UserStatus;
createdAt?: Date;
updatedAt?: Date;
constructor(options: User) {
this.id = options.id;
this.username = options.username;
this.password = options.password;
this.role = options.role || UserRole.user;
this.status =
typeof options.status === 'number' && UserStatus[options.status]
? options.status
: UserStatus.active;
this.createdAt = options.createdAt;
this.updatedAt = options.updatedAt;
}
}
export enum UserRole {
'admin' = 0,
'user' = 1,
}
export enum UserStatus {
'active' = 0,
'disabled' = 1,
}
export interface UserInstance extends Model<User, User>, User {}
export const UserModel = sequelize.define<UserInstance>('User', {
username: {
type: DataTypes.STRING,
unique: true,
allowNull: false,
},
password: {
type: DataTypes.STRING,
allowNull: false,
},
role: {
type: DataTypes.NUMBER,
defaultValue: UserRole.user,
},
status: {
type: DataTypes.NUMBER,
defaultValue: UserStatus.active,
},
});

View File

@ -42,6 +42,18 @@ export default ({ app }: { app: Application }) => {
return next();
});
// Extract userId and role from JWT
app.use((req: Request, res, next) => {
if (req.auth) {
const payload = req.auth as any;
req.user = {
userId: payload.userId,
role: payload.role,
};
}
return next();
});
app.use(async (req: Request, res, next) => {
if (!['/open/', '/api/'].some((x) => req.path.startsWith(x))) {
return next();

View File

@ -29,6 +29,30 @@ import { ScheduleType } from '../interface/schedule';
export default class CronService {
constructor(@Inject('logger') private logger: winston.Logger) {}
private addUserIdFilter(query: any, userId?: number) {
if (userId !== undefined) {
query.userId = userId;
}
return query;
}
private async checkOwnership(ids: number[], userId?: number): Promise<void> {
if (userId === undefined) {
// Admin can access all crons
return;
}
const crons = await CrontabModel.findAll({
where: { id: ids },
attributes: ['id', 'userId'],
});
const unauthorized = crons.filter(
(cron) => cron.userId !== undefined && cron.userId !== userId
);
if (unauthorized.length > 0) {
throw new Error('无权限操作该定时任务');
}
}
private isNodeCron(cron: Crontab) {
const { schedule, extra_schedules } = cron;
if (Number(schedule?.split(/ +/).length) > 5 || extra_schedules?.length) {
@ -156,21 +180,26 @@ export default class CronService {
}
}
public async remove(ids: number[]) {
public async remove(ids: number[], userId?: number) {
await this.checkOwnership(ids, userId);
await CrontabModel.destroy({ where: { id: ids } });
await cronClient.delCron(ids.map(String));
await this.setCrontab();
}
public async pin(ids: number[]) {
public async pin(ids: number[], userId?: number) {
await this.checkOwnership(ids, userId);
await CrontabModel.update({ isPinned: 1 }, { where: { id: ids } });
}
public async unPin(ids: number[]) {
public async unPin(ids: number[], userId?: number) {
await this.checkOwnership(ids, userId);
await CrontabModel.update({ isPinned: 0 }, { where: { id: ids } });
}
public async addLabels(ids: string[], labels: string[]) {
public async addLabels(ids: string[], labels: string[], userId?: number) {
const numIds = ids.map(Number);
await this.checkOwnership(numIds, userId);
const docs = await CrontabModel.findAll({ where: { id: ids } });
for (const doc of docs) {
await CrontabModel.update(
@ -182,7 +211,9 @@ export default class CronService {
}
}
public async removeLabels(ids: string[], labels: string[]) {
public async removeLabels(ids: string[], labels: string[], userId?: number) {
const numIds = ids.map(Number);
await this.checkOwnership(numIds, userId);
const docs = await CrontabModel.findAll({ where: { id: ids } });
for (const doc of docs) {
await CrontabModel.update(
@ -377,6 +408,7 @@ export default class CronService {
sorter: string;
filters: string;
queryString: string;
userId?: number;
}): Promise<{ data: Crontab[]; total: number }> {
const searchText = params?.searchValue;
const page = Number(params?.page || '0');
@ -397,6 +429,7 @@ export default class CronService {
this.formatSearchText(query, searchText);
this.formatFilterQuery(query, filterQuery);
this.formatViewSort(order, viewQuery);
this.addUserIdFilter(query, params?.userId);
if (sorterQuery) {
const { field, type } = sorterQuery;
@ -429,7 +462,8 @@ export default class CronService {
return doc.get({ plain: true });
}
public async run(ids: number[]) {
public async run(ids: number[], userId?: number) {
await this.checkOwnership(ids, userId);
await CrontabModel.update(
{ status: CrontabStatus.queued },
{ where: { id: ids } },
@ -439,7 +473,8 @@ export default class CronService {
});
}
public async stop(ids: number[]) {
public async stop(ids: number[], userId?: number) {
await this.checkOwnership(ids, userId);
const docs = await CrontabModel.findAll({ where: { id: ids } });
for (const doc of docs) {
if (doc.pid) {
@ -533,13 +568,15 @@ export default class CronService {
});
}
public async disabled(ids: number[]) {
public async disabled(ids: number[], userId?: number) {
await this.checkOwnership(ids, userId);
await CrontabModel.update({ isDisabled: 1 }, { where: { id: ids } });
await cronClient.delCron(ids.map(String));
await this.setCrontab();
}
public async enabled(ids: number[]) {
public async enabled(ids: number[], userId?: number) {
await this.checkOwnership(ids, userId);
await CrontabModel.update({ isDisabled: 0 }, { where: { id: ids } });
const docs = await CrontabModel.findAll({ where: { id: ids } });
const sixCron = docs

View File

@ -19,8 +19,32 @@ import { writeFileWithLock } from '../shared/utils';
export default class EnvService {
constructor(@Inject('logger') private logger: winston.Logger) {}
public async create(payloads: Env[]): Promise<Env[]> {
const envs = await this.envs();
private addUserIdFilter(query: any, userId?: number) {
if (userId !== undefined) {
query.userId = userId;
}
return query;
}
private async checkOwnership(ids: number[], userId?: number): Promise<void> {
if (userId === undefined) {
// Admin can access all envs
return;
}
const envs = await EnvModel.findAll({
where: { id: ids },
attributes: ['id', 'userId'],
});
const unauthorized = envs.filter(
(env) => env.userId !== undefined && env.userId !== userId
);
if (unauthorized.length > 0) {
throw new Error('无权限操作该环境变量');
}
}
public async create(payloads: Env[], userId?: number): Promise<Env[]> {
const envs = await this.envs('', {}, userId);
let position = initPosition;
if (
envs &&
@ -31,7 +55,7 @@ export default class EnvService {
}
const tabs = payloads.map((x) => {
position = position - stepPosition;
const tab = new Env({ ...x, position });
const tab = new Env({ ...x, position, userId });
return tab;
});
const docs = await this.insert(tabs);
@ -62,7 +86,8 @@ export default class EnvService {
return await this.getDb({ id: payload.id });
}
public async remove(ids: number[]) {
public async remove(ids: number[], userId?: number) {
await this.checkOwnership(ids, userId);
await EnvModel.destroy({ where: { id: ids } });
await this.set_envs();
}
@ -119,8 +144,9 @@ export default class EnvService {
return parseFloat(position.toPrecision(16));
}
public async envs(searchText: string = '', query: any = {}): Promise<Env[]> {
public async envs(searchText: string = '', query: any = {}, userId?: number): Promise<Env[]> {
let condition = { ...query };
this.addUserIdFilter(condition, userId);
if (searchText) {
const encodeText = encodeURI(searchText);
const reg = {
@ -172,7 +198,8 @@ export default class EnvService {
return doc.get({ plain: true });
}
public async disabled(ids: number[]) {
public async disabled(ids: number[], userId?: number) {
await this.checkOwnership(ids, userId);
await EnvModel.update(
{ status: EnvStatus.disabled },
{ where: { id: ids } },
@ -180,12 +207,14 @@ export default class EnvService {
await this.set_envs();
}
public async enabled(ids: number[]) {
public async enabled(ids: number[], userId?: number) {
await this.checkOwnership(ids, userId);
await EnvModel.update({ status: EnvStatus.normal }, { where: { id: ids } });
await this.set_envs();
}
public async updateNames({ ids, name }: { ids: number[]; name: string }) {
public async updateNames({ ids, name }: { ids: number[]; name: string }, userId?: number) {
await this.checkOwnership(ids, userId);
await EnvModel.update({ name }, { where: { id: ids } });
await this.set_envs();
}

View File

@ -24,12 +24,17 @@ import uniq from 'lodash/uniq';
import pickBy from 'lodash/pickBy';
import isNil from 'lodash/isNil';
import { shareStore } from '../shared/store';
import UserManagementService from './userManagement';
import { UserRole } from '../data/user';
@Service()
export default class UserService {
@Inject((type) => NotificationService)
private notificationService!: NotificationService;
@Inject((type) => UserManagementService)
private userManagementService!: UserManagementService;
constructor(
@Inject('logger') private logger: winston.Logger,
private scheduleService: ScheduleService,
@ -93,27 +98,57 @@ export default class UserService {
const { country, province, city, isp } = ipAddress;
address = uniq([country, province, city, isp]).filter(Boolean).join(' ');
}
if (username === cUsername && password === cPassword) {
// Check if this is a regular user (not admin) trying to login
let authenticatedUser = null;
let userId: number | undefined = undefined;
let userRole = UserRole.admin;
// First check if it's the system admin
const isSystemAdmin = username === cUsername && password === cPassword;
if (!isSystemAdmin) {
// Try to authenticate as a regular user
try {
authenticatedUser = await this.userManagementService.authenticate(username, password);
if (authenticatedUser) {
userId = authenticatedUser.id;
userRole = authenticatedUser.role;
}
} catch (e: any) {
// User disabled or other error
return { code: 400, message: e.message };
}
}
if (isSystemAdmin || authenticatedUser) {
const data = createRandomString(50, 100);
const expiration = twoFactorActivated ? '60d' : '20d';
let token = jwt.sign({ data }, config.jwt.secret, {
const expiration = (isSystemAdmin && twoFactorActivated) ? '60d' : '20d';
let token = jwt.sign(
{ data, userId, role: userRole },
config.jwt.secret,
{
expiresIn: config.jwt.expiresIn || expiration,
algorithm: 'HS384',
});
await this.updateAuthInfo(content, {
token,
tokens: {
...tokens,
[req.platform]: token,
},
lastlogon: timestamp,
retries: 0,
lastip: ip,
lastaddr: address,
platform: req.platform,
isTwoFactorChecking: false,
});
// Only update authInfo for system admin
if (isSystemAdmin) {
await this.updateAuthInfo(content, {
token,
tokens: {
...tokens,
[req.platform]: token,
},
lastlogon: timestamp,
retries: 0,
lastip: ip,
lastaddr: address,
platform: req.platform,
isTwoFactorChecking: false,
});
}
this.notificationService.notify(
'登录通知',
`你于${dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss')}${address} ${

View File

@ -0,0 +1,125 @@
import { Service, Inject } from 'typedi';
import winston from 'winston';
import { User, UserModel, UserRole, UserStatus } from '../data/user';
import { Op } from 'sequelize';
import bcrypt from 'bcrypt';
@Service()
export default class UserManagementService {
constructor(@Inject('logger') private logger: winston.Logger) {}
private async hashPassword(password: string): Promise<string> {
const saltRounds = 10;
return bcrypt.hash(password, saltRounds);
}
private async verifyPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}
public async list(searchText?: string): Promise<User[]> {
let query: any = {};
if (searchText) {
query = {
username: { [Op.like]: `%${searchText}%` },
};
}
const docs = await UserModel.findAll({ where: query });
return docs.map((x) => x.get({ plain: true }));
}
public async get(id: number): Promise<User> {
const doc = await UserModel.findByPk(id);
if (!doc) {
throw new Error('用户不存在');
}
return doc.get({ plain: true });
}
public async getByUsername(username: string): Promise<User | null> {
const doc = await UserModel.findOne({ where: { username } });
if (!doc) {
return null;
}
return doc.get({ plain: true });
}
public async create(payload: User): Promise<User> {
const existingUser = await this.getByUsername(payload.username);
if (existingUser) {
throw new Error('用户名已存在');
}
if (payload.password.length < 6) {
throw new Error('密码长度至少为6位');
}
// Hash the password before storing
const hashedPassword = await this.hashPassword(payload.password);
const userWithHashedPassword = { ...payload, password: hashedPassword };
const doc = await UserModel.create(userWithHashedPassword);
return doc.get({ plain: true });
}
public async update(payload: User): Promise<User> {
if (!payload.id) {
throw new Error('缺少用户ID');
}
const existingUser = await this.get(payload.id);
if (!existingUser) {
throw new Error('用户不存在');
}
if (payload.password && payload.password.length < 6) {
throw new Error('密码长度至少为6位');
}
// Check if username is being changed and if new username already exists
if (payload.username !== existingUser.username) {
const userWithSameUsername = await this.getByUsername(payload.username);
if (userWithSameUsername && userWithSameUsername.id !== payload.id) {
throw new Error('用户名已存在');
}
}
// Hash the password if it's being updated
const updatePayload = { ...payload };
if (payload.password) {
updatePayload.password = await this.hashPassword(payload.password);
}
const [, [updated]] = await UserModel.update(updatePayload, {
where: { id: payload.id },
returning: true,
});
return updated.get({ plain: true });
}
public async delete(ids: number[]): Promise<number> {
const count = await UserModel.destroy({ where: { id: ids } });
return count;
}
public async authenticate(
username: string,
password: string,
): Promise<User | null> {
const user = await this.getByUsername(username);
if (!user) {
return null;
}
const isPasswordValid = await this.verifyPassword(password, user.password);
if (!isPasswordValid) {
return null;
}
if (user.status === UserStatus.disabled) {
throw new Error('用户已被禁用');
}
return user;
}
}

View File

@ -6,6 +6,11 @@ declare global {
namespace Express {
interface Request {
platform: 'desktop' | 'mobile';
user?: {
userId?: number;
role?: number;
};
auth?: any;
}
}
}

View File

@ -54,12 +54,16 @@
}
},
"dependencies": {
"@bufbuild/protobuf": "^2.10.0",
"@grpc/grpc-js": "^1.14.0",
"@grpc/proto-loader": "^0.8.0",
"@keyv/sqlite": "^4.0.1",
"@otplib/preset-default": "^12.0.1",
"bcrypt": "^6.0.0",
"body-parser": "^1.20.3",
"celebrate": "^15.0.3",
"chokidar": "^4.0.1",
"compression": "^1.7.4",
"cors": "^2.8.5",
"cron-parser": "^4.9.0",
"cross-spawn": "^7.0.6",
@ -69,51 +73,49 @@
"express-jwt": "^8.4.1",
"express-rate-limit": "^7.4.1",
"express-urlrewrite": "^2.0.3",
"undici": "^7.9.0",
"helmet": "^8.1.0",
"hpagent": "^1.2.0",
"http-proxy-middleware": "^3.0.3",
"iconv-lite": "^0.6.3",
"ip2region": "2.3.0",
"js-yaml": "^4.1.0",
"jsonwebtoken": "^9.0.2",
"keyv": "^5.2.3",
"lodash": "^4.17.21",
"multer": "1.4.5-lts.1",
"node-schedule": "^2.1.0",
"nodemailer": "^6.9.16",
"p-queue-cjs": "7.3.4",
"@bufbuild/protobuf": "^2.10.0",
"proper-lockfile": "^4.1.2",
"ps-tree": "^1.2.0",
"reflect-metadata": "^0.2.2",
"request-ip": "3.3.0",
"sequelize": "^6.37.5",
"sockjs": "^0.3.24",
"sqlite3": "git+https://github.com/whyour/node-sqlite3.git#v1.0.3",
"toad-scheduler": "^3.0.1",
"typedi": "^0.10.0",
"undici": "^7.9.0",
"uuid": "^11.0.3",
"winston": "^3.17.0",
"winston-daily-rotate-file": "^5.0.0",
"request-ip": "3.3.0",
"ip2region": "2.3.0",
"keyv": "^5.2.3",
"@keyv/sqlite": "^4.0.1",
"proper-lockfile": "^4.1.2",
"compression": "^1.7.4",
"helmet": "^8.1.0"
"winston-daily-rotate-file": "^5.0.0"
},
"devDependencies": {
"moment": "2.30.1",
"@ant-design/icons": "^5.0.1",
"@ant-design/pro-layout": "6.38.22",
"@codemirror/view": "^6.34.1",
"@codemirror/state": "^6.4.1",
"@codemirror/view": "^6.34.1",
"@monaco-editor/react": "4.2.1",
"@react-hook/resize-observer": "^2.0.2",
"react-router-dom": "6.26.1",
"@types/bcrypt": "^6.0.0",
"@types/body-parser": "^1.19.2",
"@types/compression": "^1.7.2",
"@types/cors": "^2.8.12",
"@types/cross-spawn": "^6.0.2",
"@types/express": "^4.17.13",
"@types/express-jwt": "^6.0.4",
"@types/file-saver": "2.0.2",
"@types/helmet": "^4.0.0",
"@types/js-yaml": "^4.0.5",
"@types/jsonwebtoken": "^8.5.8",
"@types/lodash": "^4.14.185",
@ -121,17 +123,17 @@
"@types/node": "^17.0.21",
"@types/node-schedule": "^1.3.2",
"@types/nodemailer": "^6.4.4",
"@types/proper-lockfile": "^4.1.4",
"@types/ps-tree": "^1.1.6",
"@types/qrcode.react": "^1.0.2",
"@types/react": "^18.0.20",
"@types/react-copy-to-clipboard": "^5.0.4",
"@types/react-dom": "^18.0.6",
"@types/request-ip": "0.0.41",
"@types/serve-handler": "^6.1.1",
"@types/sockjs": "^0.3.33",
"@types/sockjs-client": "^1.5.1",
"@types/uuid": "^8.3.4",
"@types/request-ip": "0.0.41",
"@types/proper-lockfile": "^4.1.4",
"@types/ps-tree": "^1.1.6",
"@uiw/codemirror-extensions-langs": "^4.21.9",
"@uiw/react-codemirror": "^4.21.9",
"@umijs/max": "^4.4.4",
@ -143,9 +145,9 @@
"axios": "^1.4.0",
"compression-webpack-plugin": "9.2.0",
"concurrently": "^7.0.0",
"react-hotkeys-hook": "^4.6.1",
"file-saver": "2.0.2",
"lint-staged": "^13.0.3",
"moment": "2.30.1",
"monaco-editor": "0.33.0",
"nodemon": "^3.0.1",
"prettier": "^2.5.1",
@ -161,7 +163,9 @@
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "18.3.1",
"react-hotkeys-hook": "^4.6.1",
"react-intl-universal": "^2.12.0",
"react-router-dom": "6.26.1",
"react-split-pane": "^0.1.92",
"sockjs-client": "^1.6.0",
"ts-node": "^10.9.2",
@ -169,8 +173,6 @@
"tslib": "^2.4.0",
"typescript": "5.2.2",
"vh-check": "^2.0.5",
"virtualizedtableforantd4": "1.3.0",
"@types/compression": "^1.7.2",
"@types/helmet": "^4.0.0"
"virtualizedtableforantd4": "1.3.0"
}
}

View File

@ -1,9 +1,5 @@
lockfileVersion: '6.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
overrides:
sqlite3: git+https://github.com/whyour/node-sqlite3.git#v1.0.3
@ -23,6 +19,9 @@ dependencies:
'@otplib/preset-default':
specifier: ^12.0.1
version: 12.0.1
bcrypt:
specifier: ^6.0.0
version: 6.0.0
body-parser:
specifier: ^1.20.3
version: 1.20.3
@ -160,6 +159,9 @@ devDependencies:
'@react-hook/resize-observer':
specifier: ^2.0.2
version: 2.0.2(react@18.3.1)
'@types/bcrypt':
specifier: ^6.0.0
version: 6.0.0
'@types/body-parser':
specifier: ^1.19.2
version: 1.19.5
@ -3786,6 +3788,12 @@ packages:
'@babel/types': 7.26.0
dev: true
/@types/bcrypt@6.0.0:
resolution: {integrity: sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==}
dependencies:
'@types/node': 17.0.45
dev: true
/@types/body-parser@1.19.5:
resolution: {integrity: sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==}
dependencies:
@ -5747,6 +5755,15 @@ packages:
/base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
/bcrypt@6.0.0:
resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==}
engines: {node: '>= 18'}
requiresBuild: true
dependencies:
node-addon-api: 8.5.0
node-gyp-build: 4.8.4
dev: false
/before@0.0.1:
resolution: {integrity: sha512-1J5SWbkoVJH9DTALN8igB4p+nPKZzPrJ/HomqBDLpfUvDXCdjdBmBUcH5McZfur0lftVssVU6BZug5NYh87zTw==}
dev: true
@ -10212,6 +10229,11 @@ packages:
resolution: {integrity: sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==}
dev: false
/node-addon-api@8.5.0:
resolution: {integrity: sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==}
engines: {node: ^18 || ^20 || >= 21}
dev: false
/node-domexception@1.0.0:
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
engines: {node: '>=10.5.0'}
@ -10238,6 +10260,11 @@ packages:
formdata-polyfill: 4.0.10
dev: true
/node-gyp-build@4.8.4:
resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
hasBin: true
dev: false
/node-gyp@8.4.1:
resolution: {integrity: sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w==}
engines: {node: '>= 10.12.0'}
@ -15213,3 +15240,7 @@ packages:
- encoding
- supports-color
dev: false
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false