diff --git a/MULTI_USER_GUIDE.md b/MULTI_USER_GUIDE.md new file mode 100644 index 00000000..9cbd4945 --- /dev/null +++ b/MULTI_USER_GUIDE.md @@ -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 diff --git a/back/api/cron.ts b/back/api/cron.ts index 1bc99062..f443ac72 100644 --- a/back/api/cron.ts +++ b/back/api/cron.ts @@ -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 }); } }, ); diff --git a/back/api/env.ts b/back/api/env.ts index 845b5df5..51fdc631 100644 --- a/back/api/env.ts +++ b/back/api/env.ts @@ -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 }); } }, ); diff --git a/back/api/index.ts b/back/api/index.ts index 6dcab62f..31655f9c 100644 --- a/back/api/index.ts +++ b/back/api/index.ts @@ -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; }; diff --git a/back/api/userManagement.ts b/back/api/userManagement.ts new file mode 100644 index 00000000..3b008688 --- /dev/null +++ b/back/api/userManagement.ts @@ -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); + } + }, + ); +}; diff --git a/back/data/cron.ts b/back/data/cron.ts index b234d59c..425d8f05 100644 --- a/back/data/cron.ts +++ b/back/data/cron.ts @@ -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('Crontab', { extra_schedules: DataTypes.JSON, task_before: DataTypes.STRING, task_after: DataTypes.STRING, + userId: { type: DataTypes.NUMBER, allowNull: true }, }); diff --git a/back/data/dependence.ts b/back/data/dependence.ts index 601f461c..f1723422 100644 --- a/back/data/dependence.ts +++ b/back/data/dependence.ts @@ -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( status: DataTypes.NUMBER, log: DataTypes.JSON, remark: DataTypes.STRING, + userId: { type: DataTypes.NUMBER, allowNull: true }, }, ); diff --git a/back/data/env.ts b/back/data/env.ts index f6a9094e..e96611e8 100644 --- a/back/data/env.ts +++ b/back/data/env.ts @@ -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('Env', { position: DataTypes.NUMBER, name: { type: DataTypes.STRING, unique: 'compositeIndex' }, remarks: DataTypes.STRING, + userId: { type: DataTypes.NUMBER, allowNull: true }, }); diff --git a/back/data/subscription.ts b/back/data/subscription.ts index bb561d2f..127a1bc4 100644 --- a/back/data/subscription.ts +++ b/back/data/subscription.ts @@ -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( proxy: { type: DataTypes.STRING, allowNull: true }, autoAddCron: { type: DataTypes.NUMBER, allowNull: true }, autoDelCron: { type: DataTypes.NUMBER, allowNull: true }, + userId: { type: DataTypes.NUMBER, allowNull: true }, }, ); diff --git a/back/data/user.ts b/back/data/user.ts new file mode 100644 index 00000000..14edae6b --- /dev/null +++ b/back/data/user.ts @@ -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 {} +export const UserModel = sequelize.define('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, + }, +}); diff --git a/back/loaders/express.ts b/back/loaders/express.ts index 41c98398..26af17ea 100644 --- a/back/loaders/express.ts +++ b/back/loaders/express.ts @@ -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(); diff --git a/back/services/cron.ts b/back/services/cron.ts index a838ce9e..c2f32597 100644 --- a/back/services/cron.ts +++ b/back/services/cron.ts @@ -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 { + 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 diff --git a/back/services/env.ts b/back/services/env.ts index be89cc86..fb91e824 100644 --- a/back/services/env.ts +++ b/back/services/env.ts @@ -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 { - 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 { + 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 { + 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 { + public async envs(searchText: string = '', query: any = {}, userId?: number): Promise { 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(); } diff --git a/back/services/user.ts b/back/services/user.ts index 604b3c82..d5ecfc0e 100644 --- a/back/services/user.ts +++ b/back/services/user.ts @@ -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} ${ diff --git a/back/services/userManagement.ts b/back/services/userManagement.ts new file mode 100644 index 00000000..60cdf594 --- /dev/null +++ b/back/services/userManagement.ts @@ -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 { + const saltRounds = 10; + return bcrypt.hash(password, saltRounds); + } + + private async verifyPassword(password: string, hash: string): Promise { + return bcrypt.compare(password, hash); + } + + public async list(searchText?: string): Promise { + 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 { + const doc = await UserModel.findByPk(id); + if (!doc) { + throw new Error('用户不存在'); + } + return doc.get({ plain: true }); + } + + public async getByUsername(username: string): Promise { + const doc = await UserModel.findOne({ where: { username } }); + if (!doc) { + return null; + } + return doc.get({ plain: true }); + } + + public async create(payload: User): Promise { + 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 { + 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 { + const count = await UserModel.destroy({ where: { id: ids } }); + return count; + } + + public async authenticate( + username: string, + password: string, + ): Promise { + 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; + } +} diff --git a/back/types/express.d.ts b/back/types/express.d.ts index 4a8fd216..57d00d6d 100644 --- a/back/types/express.d.ts +++ b/back/types/express.d.ts @@ -6,6 +6,11 @@ declare global { namespace Express { interface Request { platform: 'desktop' | 'mobile'; + user?: { + userId?: number; + role?: number; + }; + auth?: any; } } } \ No newline at end of file diff --git a/package.json b/package.json index 8c2dbc9e..e3cd8f2b 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 50cce37f..917e3c81 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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