mirror of
https://github.com/whyour/qinglong.git
synced 2025-11-08 15:06:08 +08:00
Add multi-user backend infrastructure: User model, management service and API
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
This commit is contained in:
parent
4758400df6
commit
db93ca9aa9
|
|
@ -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
114
back/api/userManagement.ts
Normal 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 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);
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
|
|
@ -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 },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
56
back/data/user.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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} ${
|
||||
|
|
|
|||
104
back/services/userManagement.ts
Normal file
104
back/services/userManagement.ts
Normal file
|
|
@ -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<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 === 'admin') {
|
||||
throw new Error('密码不能设置为admin');
|
||||
}
|
||||
|
||||
const doc = await UserModel.create(payload);
|
||||
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 === '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<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;
|
||||
}
|
||||
|
||||
if (user.password !== password) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (user.status === UserStatus.disabled) {
|
||||
throw new Error('用户已被禁用');
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
}
|
||||
5
back/types/express.d.ts
vendored
5
back/types/express.d.ts
vendored
|
|
@ -6,6 +6,11 @@ declare global {
|
|||
namespace Express {
|
||||
interface Request {
|
||||
platform: 'desktop' | 'mobile';
|
||||
user?: {
|
||||
userId?: number;
|
||||
role?: number;
|
||||
};
|
||||
auth?: any;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user