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..3799ad1c --- /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 as any).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/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..5653399a --- /dev/null +++ b/back/services/userManagement.ts @@ -0,0 +1,104 @@ +import { Service, Inject } from 'typedi'; +import winston from 'winston'; +import { User, UserModel, UserRole, UserStatus } from '../data/user'; +import { Op } from 'sequelize'; + +@Service() +export default class UserManagementService { + constructor(@Inject('logger') private logger: winston.Logger) {} + + 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 === 'admin') { + throw new Error('密码不能设置为admin'); + } + + const doc = await UserModel.create(payload); + 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 === 'admin') { + throw new Error('密码不能设置为admin'); + } + + // 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('用户名已存在'); + } + } + + const [, [updated]] = await UserModel.update(payload, { + 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; + } + + if (user.password !== password) { + 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