mirror of
https://github.com/whyour/qinglong.git
synced 2026-11-03 16:19:11 +08:00
Compare commits
@@ -0,0 +1,17 @@
|
||||
# Assign existing resources to accounts
|
||||
|
||||
The panel adds ownership columns automatically while preserving existing data. Unassigned historical data is visible only to administrators. Ordinary users can read their assigned environment variables, task metadata and subscription metadata; dependencies and execution remain administrative operations.
|
||||
|
||||
Prefer the authenticated administrator endpoint `PUT /api/user-management/:id/resources` with a body such as `{"resource":"envs","ids":[1,2]}`. Supported resources are `envs`, `crons` and `subscriptions`. Assignment does not run tasks or move files.
|
||||
|
||||
The optional local migration helper assigns every unassigned row to one existing account. Stop the panel and back up `data/db/database.sqlite` before using it; set `QL_DIR` or `QL_DATA_DIR` when the data directory differs from the repository default. The helper refuses to create a new empty database.
|
||||
|
||||
```bash
|
||||
node migrate-to-multiuser.js --list-users
|
||||
node migrate-to-multiuser.js --username=alice --dry-run
|
||||
node migrate-to-multiuser.js --username=alice
|
||||
```
|
||||
|
||||
Already assigned rows are preserved and writes run in a transaction. Dependencies may receive an owner for bookkeeping, but remain inaccessible to ordinary users. To allocate only selected rows, use the administrator resource endpoint instead of the bulk helper.
|
||||
|
||||
See [the permission contract](docs/development/multiuser-permissions.md).
|
||||
@@ -0,0 +1,7 @@
|
||||
# Multi-user accounts
|
||||
|
||||
Administrators can create, disable and delete accounts in Settings → User Management. New administrator accounts have full access to the shared instance. Ordinary accounts can only read their assigned environment variables, task metadata and subscription metadata. They cannot execute tasks, read shared files or logs, install dependencies, or modify system settings.
|
||||
|
||||
Existing data with no owner remains visible only to administrators. The original administrator and its password migration, MFA and sessions are preserved. Accounts from older drafts that used bcrypt require an administrator password reset.
|
||||
|
||||
See [the permission contract](docs/development/multiuser-permissions.md) and [resource migration](MIGRATION_GUIDE.md). This draft does not provide isolated execution for untrusted users.
|
||||
@@ -12,6 +12,7 @@ import subscription from './subscription';
|
||||
import update from './update';
|
||||
import dashboard from './dashboard';
|
||||
import health from './health';
|
||||
import userManagement from './userManagement';
|
||||
import clientIp from './clientIp';
|
||||
import retention from './retention';
|
||||
|
||||
@@ -30,6 +31,7 @@ export default () => {
|
||||
update(app);
|
||||
dashboard(app);
|
||||
health(app);
|
||||
userManagement(app);
|
||||
clientIp(app);
|
||||
retention(app);
|
||||
|
||||
|
||||
+39
-1
@@ -76,7 +76,17 @@ export default (app: Router) => {
|
||||
try {
|
||||
const userService = Container.get(UserService);
|
||||
const token = getToken(req);
|
||||
await userService.logout(req.platform, token);
|
||||
if (req.user?.userId) {
|
||||
const { default: UserManagementService } = await import(
|
||||
'../services/userManagement'
|
||||
);
|
||||
await Container.get(UserManagementService).revokeToken(
|
||||
req.user.userId,
|
||||
token,
|
||||
);
|
||||
} else {
|
||||
await userService.logout(req.platform, token);
|
||||
}
|
||||
res.send({ code: 200 });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
@@ -98,6 +108,16 @@ export default (app: Router) => {
|
||||
return res.send({ code: 450, message: t('未知错误') });
|
||||
}
|
||||
const userService = Container.get(UserService);
|
||||
if (req.user?.userId) {
|
||||
const { default: UserManagementService } = await import(
|
||||
'../services/userManagement'
|
||||
);
|
||||
await Container.get(UserManagementService).update({
|
||||
id: req.user.userId,
|
||||
...req.body,
|
||||
});
|
||||
return res.send({ code: 200 });
|
||||
}
|
||||
const result = await userService.updateUsernameAndPassword(req.body);
|
||||
res.send(result);
|
||||
} catch (e) {
|
||||
@@ -110,10 +130,28 @@ export default (app: Router) => {
|
||||
const logger: Logger = Container.get('logger');
|
||||
try {
|
||||
const userService = Container.get(UserService);
|
||||
if (req.user?.userId) {
|
||||
const { default: UserManagementService } = await import(
|
||||
'../services/userManagement'
|
||||
);
|
||||
const account = await Container.get(UserManagementService).get(
|
||||
req.user.userId,
|
||||
);
|
||||
return res.send({
|
||||
code: 200,
|
||||
data: {
|
||||
username: account.username,
|
||||
role: account.role,
|
||||
id: account.id,
|
||||
readOnly: account.role === 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
const authInfo = await userService.getAuthInfo();
|
||||
res.send({
|
||||
code: 200,
|
||||
data: {
|
||||
role: 0,
|
||||
username: authInfo.username,
|
||||
avatar: authInfo.avatar,
|
||||
twoFactorActivated: authInfo.twoFactorActivated,
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { Container } from 'typedi';
|
||||
import { celebrate, Joi } from 'celebrate';
|
||||
import UserManagementService from '../services/userManagement';
|
||||
import { UserRole, UserStatus } from '../data/user';
|
||||
import { EnvModel } from '../data/env';
|
||||
import { CrontabModel } from '../data/cron';
|
||||
import { SubscriptionModel } from '../data/subscription';
|
||||
|
||||
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);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.put(
|
||||
'/:id/resources',
|
||||
requireAdmin,
|
||||
celebrate({
|
||||
params: Joi.object({ id: Joi.number().integer().positive().required() }),
|
||||
body: Joi.object({
|
||||
resource: Joi.string()
|
||||
.valid('envs', 'crons', 'subscriptions')
|
||||
.required(),
|
||||
ids: Joi.array()
|
||||
.min(1)
|
||||
.max(200)
|
||||
.unique()
|
||||
.items(Joi.number().integer().positive())
|
||||
.required(),
|
||||
}),
|
||||
}),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
await Container.get(UserManagementService).get(id);
|
||||
const models: Record<string, any> = {
|
||||
envs: EnvModel,
|
||||
crons: CrontabModel,
|
||||
subscriptions: SubscriptionModel,
|
||||
};
|
||||
const [count] = await models[req.body.resource].update(
|
||||
{ userId: id },
|
||||
{ where: { id: req.body.ids } },
|
||||
);
|
||||
return res.json({ code: 200, data: { assigned: count } });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Get a specific user (admin only)
|
||||
route.get(
|
||||
'/:id',
|
||||
requireAdmin,
|
||||
celebrate({ params: Joi.object({ id: Joi.number().integer().positive().required() }) }),
|
||||
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().trim().max(255).required(),
|
||||
password: Joi.string().min(6).max(1024).required(),
|
||||
role: Joi.number()
|
||||
.valid(UserRole.admin, UserRole.user)
|
||||
.default(UserRole.user),
|
||||
status: Joi.number()
|
||||
.valid(UserStatus.active, UserStatus.disabled)
|
||||
.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().integer().positive().required(),
|
||||
username: Joi.string().trim().max(255).required(),
|
||||
password: Joi.string().min(6).max(1024).optional().allow(''),
|
||||
role: Joi.number().valid(UserRole.admin, UserRole.user),
|
||||
status: Joi.number()
|
||||
.valid(UserStatus.active, UserStatus.disabled)
|
||||
.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()
|
||||
.min(1)
|
||||
.max(200)
|
||||
.unique()
|
||||
.items(Joi.number().integer().positive())
|
||||
.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);
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -25,6 +25,7 @@ export class Crontab {
|
||||
log_name?: string;
|
||||
allow_multiple_instances?: 1 | 0;
|
||||
work_dir?: string;
|
||||
userId?: number;
|
||||
|
||||
constructor(options: Crontab) {
|
||||
this.name = options.name;
|
||||
@@ -52,6 +53,7 @@ export class Crontab {
|
||||
this.log_name = options.log_name;
|
||||
this.allow_multiple_instances = options.allow_multiple_instances || 0;
|
||||
this.work_dir = options.work_dir;
|
||||
this.userId = options.userId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,4 +97,5 @@ export const CrontabModel = sequelize.define<CronInstance>('Crontab', {
|
||||
log_name: DataTypes.STRING,
|
||||
allow_multiple_instances: DataTypes.NUMBER,
|
||||
work_dir: DataTypes.STRING,
|
||||
userId: { type: DataTypes.INTEGER, 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;
|
||||
isPinned?: 1 | 0;
|
||||
labels?: string[];
|
||||
|
||||
@@ -23,6 +24,7 @@ export class Env {
|
||||
this.position = options.position;
|
||||
this.name = options.name;
|
||||
this.remarks = options.remarks || '';
|
||||
this.userId = options.userId;
|
||||
this.isPinned = options.isPinned || 0;
|
||||
this.labels = options.labels || [];
|
||||
}
|
||||
@@ -46,6 +48,7 @@ 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 },
|
||||
isPinned: DataTypes.NUMBER,
|
||||
labels: DataTypes.JSON,
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
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;
|
||||
sessions?: { digest: string; platform: string }[];
|
||||
|
||||
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;
|
||||
this.sessions = options.sessions || [];
|
||||
}
|
||||
}
|
||||
|
||||
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', {
|
||||
sessions: { type: DataTypes.JSON, allowNull: false, defaultValue: [] },
|
||||
username: {
|
||||
type: DataTypes.STRING,
|
||||
unique: true,
|
||||
allowNull: false,
|
||||
},
|
||||
password: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
role: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: UserRole.user,
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: UserStatus.active,
|
||||
},
|
||||
});
|
||||
@@ -9,6 +9,8 @@ import { CrontabViewModel } from '../data/cronView';
|
||||
import { CrontabStatModel } from '../data/cronStats';
|
||||
import { RunningInstanceModel } from '../data/runningInstance';
|
||||
import { sequelize } from '../data';
|
||||
import { UserModel } from '../data/user';
|
||||
import { migrateUserSchema } from '../shared/userSchemaMigrations';
|
||||
import { migrateSchema } from '../shared/schemaMigrations';
|
||||
|
||||
export default async () => {
|
||||
@@ -23,7 +25,9 @@ export default async () => {
|
||||
await CrontabStatModel.sync();
|
||||
await RunningInstanceModel.sync();
|
||||
|
||||
await UserModel.sync();
|
||||
await migrateSchema(sequelize);
|
||||
await migrateUserSchema(sequelize);
|
||||
|
||||
Logger.info('[boot] DB loaded');
|
||||
} catch (error) {
|
||||
|
||||
+31
-1
@@ -13,6 +13,8 @@ import { isValidToken, isDefaultAuthInfo } from '../shared/auth';
|
||||
import { AuthInfo } from '../data/system';
|
||||
import path from 'path';
|
||||
import { t } from '../shared/i18n';
|
||||
import { Container } from 'typedi';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { AppScope } from '../data/open';
|
||||
import uniqueConstraintError from '../middlewares/uniqueConstraintError';
|
||||
import protectedPathCase from '../middlewares/protectedPathCase';
|
||||
@@ -144,6 +146,26 @@ export default ({ app }: { app: Application }) => {
|
||||
|
||||
const authInfo = await shareStore.getAuthInfo();
|
||||
if (isValidToken(authInfo, headerToken, req.platform, config.jwt.secret)) {
|
||||
req.user = { role: 0 };
|
||||
return next();
|
||||
}
|
||||
const claims = jwt.decode(headerToken);
|
||||
let account = null;
|
||||
if (
|
||||
typeof claims === 'object' &&
|
||||
Number.isSafeInteger(claims?.userId) &&
|
||||
claims!.userId > 0
|
||||
) {
|
||||
const { default: UserManagementService } = await import(
|
||||
'../services/userManagement'
|
||||
);
|
||||
account = await Container.get(UserManagementService).validateToken(
|
||||
headerToken,
|
||||
req.platform,
|
||||
);
|
||||
}
|
||||
if (account) {
|
||||
req.user = { userId: account.id, role: account.role };
|
||||
return next();
|
||||
}
|
||||
|
||||
@@ -177,7 +199,15 @@ export default ({ app }: { app: Application }) => {
|
||||
});
|
||||
|
||||
app.use(rewrite('/open/*', '/api/$1'));
|
||||
app.use(config.api.prefix, routes());
|
||||
app.use(
|
||||
config.api.prefix,
|
||||
async (req, res, next) => {
|
||||
if (!req.user || req.user.role === 0) return next();
|
||||
const { userAccess } = await import('../middlewares/userAccess');
|
||||
return userAccess(req, res, next);
|
||||
},
|
||||
routes(),
|
||||
);
|
||||
|
||||
app.get('*', (_, res, next) => {
|
||||
const indexPath = path.join(frontendPath, 'index.html');
|
||||
|
||||
+28
-2
@@ -6,6 +6,7 @@ import { getPlatform } from '../config/util';
|
||||
import { shareStore } from '../shared/store';
|
||||
import { isValidToken } from '../shared/auth';
|
||||
import config from '../config';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
export default async ({ server }: { server: Server }) => {
|
||||
const echo = sockJs.createServer({
|
||||
@@ -17,6 +18,30 @@ export default async ({ server }: { server: Server }) => {
|
||||
let sessionTimer: ReturnType<typeof setInterval> | undefined;
|
||||
let checking = false;
|
||||
|
||||
const validateSession = async (
|
||||
authInfo: any,
|
||||
token: string,
|
||||
platform: string,
|
||||
) => {
|
||||
if (isValidToken(authInfo, token, platform, config.jwt.secret)) return true;
|
||||
const claims = jwt.decode(token);
|
||||
if (
|
||||
!claims ||
|
||||
typeof claims === 'string' ||
|
||||
!Number.isSafeInteger(claims.userId) ||
|
||||
claims.userId <= 0
|
||||
)
|
||||
return false;
|
||||
const { default: UserManagementService } = await import(
|
||||
'../services/userManagement'
|
||||
);
|
||||
const account = await Container.get(UserManagementService).validateToken(
|
||||
token,
|
||||
platform,
|
||||
);
|
||||
return account?.role === 0;
|
||||
};
|
||||
|
||||
const checkSessions = async () => {
|
||||
if (checking || sessions.size === 0) return;
|
||||
checking = true;
|
||||
@@ -35,7 +60,7 @@ export default async ({ server }: { server: Server }) => {
|
||||
}
|
||||
let valid = platforms.get(platform);
|
||||
if (valid === undefined) {
|
||||
valid = isValidToken(current, token, platform, config.jwt.secret);
|
||||
valid = await validateSession(current, token, platform);
|
||||
platforms.set(platform, valid);
|
||||
}
|
||||
if (!valid) conn.close('401');
|
||||
@@ -77,7 +102,8 @@ export default async ({ server }: { server: Server }) => {
|
||||
const platform = getPlatform(conn.headers['user-agent'] || '') || 'desktop';
|
||||
const headerToken = conn.url.replace(`${conn.pathname}?token=`, '');
|
||||
|
||||
if (isValidToken(authInfo, headerToken, platform, config.jwt.secret)) {
|
||||
if (await validateSession(authInfo, headerToken, platform)) {
|
||||
if (closed) return;
|
||||
sockService.addClient(conn);
|
||||
sessions.set(conn, { token: headerToken, platform });
|
||||
if (!sessionTimer) {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { Op } from 'sequelize';
|
||||
import { EnvModel } from '../data/env';
|
||||
import { CrontabModel } from '../data/cron';
|
||||
import { SubscriptionModel } from '../data/subscription';
|
||||
|
||||
// Ordinary users are read-only until task workers and shared files are isolated.
|
||||
export async function userAccess(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) {
|
||||
if (!req.user || req.user.role === 0) return next();
|
||||
const userId = req.user.userId;
|
||||
if (!Number.isSafeInteger(userId) || userId! <= 0)
|
||||
return res.status(403).json({ code: 403, message: '需要有效用户会话' });
|
||||
const pathname = req.path.replace(/\/$/, '') || '/';
|
||||
if (
|
||||
(req.method === 'GET' &&
|
||||
['/user', '/system', '/health'].includes(pathname)) ||
|
||||
(req.method === 'POST' && pathname === '/user/logout')
|
||||
)
|
||||
return next();
|
||||
if (req.method === 'GET' && pathname === '/crons/views')
|
||||
return res.json({ code: 200, data: [] });
|
||||
const match = pathname.match(
|
||||
/^\/(envs|crons|subscriptions)(?:\/([1-9][0-9]*))?$/,
|
||||
);
|
||||
if (req.method !== 'GET' || !match)
|
||||
return res
|
||||
.status(403)
|
||||
.json({
|
||||
code: 403,
|
||||
message: '普通用户只读自己的数据,执行和系统修改由管理员负责',
|
||||
});
|
||||
try {
|
||||
const models: Record<string, any> = {
|
||||
envs: EnvModel,
|
||||
crons: CrontabModel,
|
||||
subscriptions: SubscriptionModel,
|
||||
};
|
||||
const model = models[match[1]];
|
||||
const where: any = { userId };
|
||||
if (match[2]) {
|
||||
const id = Number(match[2]);
|
||||
if (!Number.isSafeInteger(id))
|
||||
return res.status(400).json({ code: 400, message: '无效 ID' });
|
||||
where.id = id;
|
||||
const doc = await model.findOne({ where });
|
||||
return doc
|
||||
? res.json({ code: 200, data: doc.get({ plain: true }) })
|
||||
: res.status(404).json({ code: 404, message: '数据不存在' });
|
||||
}
|
||||
const search =
|
||||
typeof req.query.searchValue === 'string'
|
||||
? req.query.searchValue.slice(0, 1024)
|
||||
: '';
|
||||
if (search) where.name = { [Op.like]: `%${search}%` };
|
||||
const docs = await model.findAll({ where, order: [['createdAt', 'DESC']] });
|
||||
const data = docs.map((doc: any) => doc.get({ plain: true }));
|
||||
return res.json({
|
||||
code: 200,
|
||||
data: match[1] === 'crons' ? { data, total: data.length } : data,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
@@ -34,10 +34,13 @@ import {
|
||||
} from '../shared/password';
|
||||
import { serializeAuthMutation } from '../shared/authMutation';
|
||||
import { LoginThrottle } from '../shared/loginThrottle';
|
||||
import UserManagementService from './userManagement';
|
||||
|
||||
@Service()
|
||||
export default class UserService {
|
||||
private loginThrottle = new LoginThrottle();
|
||||
@Inject((type) => UserManagementService)
|
||||
private userManagementService!: UserManagementService;
|
||||
@Inject((type) => NotificationService)
|
||||
private notificationService!: NotificationService;
|
||||
|
||||
@@ -117,6 +120,24 @@ export default class UserService {
|
||||
};
|
||||
}
|
||||
|
||||
if (username !== cUsername) {
|
||||
const account = await this.userManagementService.authenticate(
|
||||
username,
|
||||
password,
|
||||
);
|
||||
if (account) {
|
||||
this.loginThrottle.reset(ip);
|
||||
const token = await this.userManagementService.issueToken(
|
||||
account,
|
||||
req.platform,
|
||||
);
|
||||
return {
|
||||
code: 200,
|
||||
data: { token, username: account.username, role: account.role },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const passwordMatches =
|
||||
username === cUsername && (await verifyPassword(password, cPassword));
|
||||
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { Service, Inject } from 'typedi';
|
||||
import winston from 'winston';
|
||||
import { User, UserModel, UserStatus } from '../data/user';
|
||||
import { Op } from 'sequelize';
|
||||
import { createHash, randomBytes } from 'crypto';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import config from '../config';
|
||||
import {
|
||||
hashPassword,
|
||||
verifyPassword,
|
||||
isPasswordHash,
|
||||
} from '../shared/password';
|
||||
import { serializeAuthMutation } from '../shared/authMutation';
|
||||
import { shareStore } from '../shared/store';
|
||||
|
||||
type PublicUser = Omit<User, 'password' | 'sessions'>;
|
||||
function publicUser(user: User): PublicUser {
|
||||
const { password, sessions, ...result } = user;
|
||||
return result;
|
||||
}
|
||||
function digest(token: string) {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
@Service()
|
||||
export default class UserManagementService {
|
||||
constructor(@Inject('logger') private logger: winston.Logger) {}
|
||||
|
||||
public async list(searchText?: string): Promise<PublicUser[]> {
|
||||
const where = searchText
|
||||
? { username: { [Op.like]: `%${searchText}%` } }
|
||||
: {};
|
||||
return (await UserModel.findAll({ where })).map((x) =>
|
||||
publicUser(x.get({ plain: true })),
|
||||
);
|
||||
}
|
||||
public async get(id: number): Promise<PublicUser> {
|
||||
const doc = await UserModel.findByPk(id);
|
||||
if (!doc) throw Object.assign(new Error('用户不存在'), { status: 404 });
|
||||
return publicUser(doc.get({ plain: true }));
|
||||
}
|
||||
public async getByUsername(username: string): Promise<User | null> {
|
||||
return (
|
||||
(await UserModel.findOne({ where: { username } }))?.get({
|
||||
plain: true,
|
||||
}) || null
|
||||
);
|
||||
}
|
||||
@serializeAuthMutation
|
||||
public async create(payload: User): Promise<PublicUser> {
|
||||
const auth = await shareStore.getAuthInfo();
|
||||
if (
|
||||
auth?.username === payload.username ||
|
||||
(await this.getByUsername(payload.username))
|
||||
)
|
||||
throw Object.assign(new Error('用户名已存在'), { status: 400 });
|
||||
if (!payload.password || payload.password.length < 6)
|
||||
throw Object.assign(new Error('密码长度至少为6位'), { status: 400 });
|
||||
const user = new User({
|
||||
...payload,
|
||||
password: await hashPassword(payload.password),
|
||||
sessions: [],
|
||||
});
|
||||
return publicUser((await UserModel.create(user)).get({ plain: true }));
|
||||
}
|
||||
@serializeAuthMutation
|
||||
public async update(
|
||||
payload: Partial<User> & { id: number },
|
||||
): Promise<PublicUser> {
|
||||
const existing = await this.get(payload.id);
|
||||
if (payload.username && payload.username !== existing.username) {
|
||||
const auth = await shareStore.getAuthInfo();
|
||||
if (
|
||||
auth?.username === payload.username ||
|
||||
(await this.getByUsername(payload.username))
|
||||
)
|
||||
throw Object.assign(new Error('用户名已存在'), { status: 400 });
|
||||
}
|
||||
const changes: Partial<User> = {};
|
||||
for (const key of ['username', 'role', 'status'] as const)
|
||||
if (payload[key] !== undefined) (changes as any)[key] = payload[key];
|
||||
if (payload.password) {
|
||||
if (payload.password.length < 6)
|
||||
throw Object.assign(new Error('密码长度至少为6位'), { status: 400 });
|
||||
changes.password = await hashPassword(payload.password);
|
||||
}
|
||||
if (Object.keys(changes).length) changes.sessions = [];
|
||||
await UserModel.update(changes, { where: { id: payload.id } });
|
||||
return this.get(payload.id);
|
||||
}
|
||||
@serializeAuthMutation
|
||||
public async delete(ids: number[]): Promise<number> {
|
||||
return UserModel.destroy({ where: { id: ids } });
|
||||
}
|
||||
public async authenticate(
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<User | null> {
|
||||
const user = await this.getByUsername(username);
|
||||
if (
|
||||
!user ||
|
||||
user.status !== UserStatus.active ||
|
||||
!isPasswordHash(user.password)
|
||||
)
|
||||
return null;
|
||||
return (await verifyPassword(password, user.password)) ? user : null;
|
||||
}
|
||||
// Caller holds the shared account mutation queue during authentication and issuance.
|
||||
public async issueToken(user: User, platform: string): Promise<string> {
|
||||
const token = jwt.sign(
|
||||
{ userId: user.id, nonce: randomBytes(32).toString('hex') },
|
||||
config.jwt.secret,
|
||||
{ expiresIn: config.jwt.expiresIn || '20d', algorithm: 'HS384' },
|
||||
);
|
||||
const sessions = [
|
||||
...(user.sessions || []).filter((x) => x.platform !== platform),
|
||||
{ digest: digest(token), platform },
|
||||
].slice(-10);
|
||||
await UserModel.update({ sessions }, { where: { id: user.id } });
|
||||
return token;
|
||||
}
|
||||
public async validateToken(
|
||||
token: string,
|
||||
platform: string,
|
||||
): Promise<PublicUser | null> {
|
||||
try {
|
||||
const payload = jwt.verify(token, config.jwt.secret, {
|
||||
algorithms: ['HS384'],
|
||||
});
|
||||
if (
|
||||
typeof payload === 'string' ||
|
||||
!Number.isSafeInteger(payload.userId) ||
|
||||
payload.userId <= 0
|
||||
)
|
||||
return null;
|
||||
const user = (await UserModel.findByPk(payload.userId))?.get({
|
||||
plain: true,
|
||||
});
|
||||
if (
|
||||
!user ||
|
||||
user.status !== UserStatus.active ||
|
||||
!user.sessions?.some(
|
||||
(x) => x.digest === digest(token) && x.platform === platform,
|
||||
)
|
||||
)
|
||||
return null;
|
||||
return publicUser(user);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@serializeAuthMutation
|
||||
public async revokeToken(id: number, token: string): Promise<void> {
|
||||
const user = await UserModel.findByPk(id);
|
||||
if (!user) return;
|
||||
await user.update({
|
||||
sessions: (user.sessions || []).filter((x) => x.digest !== digest(token)),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { QueryTypes, Sequelize } from 'sequelize';
|
||||
export async function migrateUserSchema(database: Sequelize): Promise<void> {
|
||||
const columns = [
|
||||
{ table: 'Users', column: 'sessions', type: "JSON NOT NULL DEFAULT '[]'" },
|
||||
...['Envs', 'Crontabs', 'Subscriptions', 'Dependences'].map((table) => ({
|
||||
table,
|
||||
column: 'userId',
|
||||
type: 'INTEGER',
|
||||
})),
|
||||
];
|
||||
await database.transaction(async (transaction) => {
|
||||
for (const { table, column, type } of columns) {
|
||||
const fields = await database.query<{ name: string }>(
|
||||
`PRAGMA table_info("${table}")`,
|
||||
{ type: QueryTypes.SELECT, transaction },
|
||||
);
|
||||
if (!fields.length)
|
||||
throw new Error(`User migration table is missing: ${table}`);
|
||||
if (!fields.some((field) => field.name === column))
|
||||
await database.query(
|
||||
`ALTER TABLE "${table}" ADD COLUMN "${column}" ${type}`,
|
||||
{ transaction },
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
Vendored
+5
@@ -6,6 +6,11 @@ declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
platform: 'desktop' | 'mobile';
|
||||
user?: {
|
||||
userId?: number;
|
||||
role?: number;
|
||||
};
|
||||
auth?: any;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,39 @@ export interface OpenOperation {
|
||||
anonymous?: boolean;
|
||||
}
|
||||
export const openOperations: readonly OpenOperation[] = [
|
||||
{ name: 'account list', method: 'GET', path: 'user-management' },
|
||||
{
|
||||
name: 'account get',
|
||||
method: 'GET',
|
||||
path: 'user-management/:id',
|
||||
params: ['id'],
|
||||
},
|
||||
{
|
||||
name: 'account create',
|
||||
method: 'POST',
|
||||
path: 'user-management',
|
||||
body: 'json',
|
||||
},
|
||||
{
|
||||
name: 'account update',
|
||||
method: 'PUT',
|
||||
path: 'user-management',
|
||||
body: 'json',
|
||||
},
|
||||
{
|
||||
name: 'account delete',
|
||||
method: 'DELETE',
|
||||
path: 'user-management',
|
||||
body: 'ids',
|
||||
},
|
||||
{
|
||||
name: 'account assign',
|
||||
method: 'PUT',
|
||||
path: 'user-management/:id/resources',
|
||||
params: ['id'],
|
||||
body: 'json',
|
||||
},
|
||||
|
||||
{ name: 'task view-list', method: 'GET', path: 'crons/views' },
|
||||
{
|
||||
name: 'task view-create',
|
||||
@@ -478,7 +511,12 @@ export const openOperations: readonly OpenOperation[] = [
|
||||
path: 'system/config/dependence-clean',
|
||||
body: 'json',
|
||||
},
|
||||
{ name: 'dashboard record', method: 'POST', path: 'dashboard/record', body: 'json' },
|
||||
{
|
||||
name: 'dashboard record',
|
||||
method: 'POST',
|
||||
path: 'dashboard/record',
|
||||
body: 'json',
|
||||
},
|
||||
{ name: 'dashboard overview', method: 'GET', path: 'dashboard/overview' },
|
||||
{ name: 'dashboard trend', method: 'GET', path: 'dashboard/trend' },
|
||||
{ name: 'dashboard top-time', method: 'GET', path: 'dashboard/top-time' },
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# 多用户权限范围
|
||||
|
||||
系统原有管理员仍使用现有初始化、密码迁移、双重验证与会话校验。新增账号使用 scrypt 密码哈希;用户接口不返回密码哈希和会话记录。密码、角色、状态或用户名修改会撤销该账号的已有会话;退出、禁用和删除账号也会使令牌失效。仅签名有效而未登记的 JWT 不能作为用户会话。
|
||||
|
||||
普通用户只读自己名下的环境变量、任务元数据和订阅元数据,可查看自己的账号信息并退出。无归属的历史数据属于管理员,普通用户不能访问。共享脚本、配置、日志、依赖安装、任务执行、终端命令、系统设置及账号管理均需管理员权限。前端显示只读提示,并仅显示任务、环境变量和订阅菜单。
|
||||
|
||||
管理员可在系统设置中创建、修改、禁用、删除账号。新增管理员角色属于完全可信的系统管理员,具有全局操作权限。原有管理员账号保留在原有认证配置中,不复制到新用户表。
|
||||
|
||||
管理员可通过 `PUT /api/user-management/:id/resources` 分配资源,请求体例如 `{"resource":"envs","ids":[1,2]}`。`resource` 支持 `envs`、`crons`、`subscriptions`;用户 ID 和资源 ID 必须为正整数。分配接口不会移动文件或执行任务。
|
||||
|
||||
脚本目录、环境变量导出文件和系统执行身份仍为实例共用。当前草稿不向普通用户开放执行;若要向不可信用户开放执行,必须增加独立执行环境、独立凭据和工作目录,以及相应的越权测试。
|
||||
|
||||
旧草稿曾使用 bcrypt 的账号需要由管理员重置密码后登录;bcrypt 哈希不会按明文密码比较。正式合并前应在 Linux 容器中验收账号管理与资源分配界面。
|
||||
Executable
+331
@@ -0,0 +1,331 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Multi-User Data Migration Script
|
||||
*
|
||||
* This script migrates existing data (Cron, Env, Subscription, Dependence)
|
||||
* to be associated with specific users.
|
||||
*
|
||||
* Usage:
|
||||
* node migrate-to-multiuser.js --userId=1
|
||||
* node migrate-to-multiuser.js --username=admin
|
||||
* node migrate-to-multiuser.js --list-users
|
||||
*
|
||||
* Options:
|
||||
* --userId=<id> Assign all legacy data to user with this ID
|
||||
* --username=<name> Assign all legacy data to user with this username
|
||||
* --list-users List all users in the system
|
||||
* --dry-run Show what would be changed without making changes
|
||||
* --help Show this help message
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const Sequelize = require('sequelize');
|
||||
|
||||
// Load environment variables
|
||||
require('dotenv').config();
|
||||
|
||||
// Configuration
|
||||
const config = {
|
||||
dbPath: path.join(
|
||||
process.env.QL_DATA_DIR ||
|
||||
path.join(process.env.QL_DIR || __dirname, 'data'),
|
||||
'db',
|
||||
),
|
||||
rootPath: __dirname,
|
||||
};
|
||||
|
||||
if (!fs.existsSync(path.join(config.dbPath, 'database.sqlite'))) {
|
||||
throw new Error(
|
||||
'Existing database not found; start the panel and verify QL_DATA_DIR before migration.',
|
||||
);
|
||||
}
|
||||
|
||||
// Initialize Sequelize
|
||||
const sequelize = new Sequelize({
|
||||
dialect: 'sqlite',
|
||||
storage: path.join(config.dbPath, 'database.sqlite'),
|
||||
logging: false,
|
||||
});
|
||||
|
||||
// Define models
|
||||
const UserModel = sequelize.define('User', {
|
||||
username: Sequelize.STRING,
|
||||
password: Sequelize.STRING,
|
||||
role: Sequelize.INTEGER,
|
||||
status: Sequelize.INTEGER,
|
||||
});
|
||||
|
||||
const CrontabModel = sequelize.define('Crontab', {
|
||||
name: Sequelize.STRING,
|
||||
command: Sequelize.STRING,
|
||||
schedule: Sequelize.STRING,
|
||||
userId: Sequelize.INTEGER,
|
||||
});
|
||||
|
||||
const EnvModel = sequelize.define('Env', {
|
||||
name: Sequelize.STRING,
|
||||
value: Sequelize.STRING,
|
||||
userId: Sequelize.INTEGER,
|
||||
});
|
||||
|
||||
const SubscriptionModel = sequelize.define('Subscription', {
|
||||
name: Sequelize.STRING,
|
||||
url: Sequelize.STRING,
|
||||
userId: Sequelize.INTEGER,
|
||||
});
|
||||
|
||||
const DependenceModel = sequelize.define('Dependence', {
|
||||
name: Sequelize.STRING,
|
||||
type: Sequelize.INTEGER,
|
||||
userId: Sequelize.INTEGER,
|
||||
});
|
||||
|
||||
// Parse command line arguments
|
||||
function parseArgs() {
|
||||
const args = {
|
||||
userId: null,
|
||||
username: null,
|
||||
listUsers: false,
|
||||
dryRun: false,
|
||||
help: false,
|
||||
};
|
||||
|
||||
process.argv.slice(2).forEach((arg) => {
|
||||
if (arg.startsWith('--userId=')) {
|
||||
args.userId = parseInt(arg.split('=')[1]);
|
||||
} else if (arg.startsWith('--username=')) {
|
||||
args.username = arg.split('=')[1];
|
||||
} else if (arg === '--list-users') {
|
||||
args.listUsers = true;
|
||||
} else if (arg === '--dry-run') {
|
||||
args.dryRun = true;
|
||||
} else if (arg === '--help' || arg === '-h') {
|
||||
args.help = true;
|
||||
}
|
||||
});
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
// Show help
|
||||
function showHelp() {
|
||||
console.log(`
|
||||
Multi-User Data Migration Script
|
||||
|
||||
This script migrates existing data (Cron, Env, Subscription, Dependence)
|
||||
to be associated with specific users.
|
||||
|
||||
Usage:
|
||||
node migrate-to-multiuser.js --userId=1
|
||||
node migrate-to-multiuser.js --username=admin
|
||||
node migrate-to-multiuser.js --list-users
|
||||
|
||||
Options:
|
||||
--userId=<id> Assign all legacy data to user with this ID
|
||||
--username=<name> Assign all legacy data to user with this username
|
||||
--list-users List all users in the system
|
||||
--dry-run Show what would be changed without making changes
|
||||
--help Show this help message
|
||||
|
||||
Examples:
|
||||
# List all users
|
||||
node migrate-to-multiuser.js --list-users
|
||||
|
||||
# Migrate all data to user ID 1 (dry run)
|
||||
node migrate-to-multiuser.js --userId=1 --dry-run
|
||||
|
||||
# Migrate all data to user 'admin'
|
||||
node migrate-to-multiuser.js --username=admin
|
||||
|
||||
Note: This script will only migrate data where userId is NULL or undefined.
|
||||
Data already assigned to users will not be changed.
|
||||
`);
|
||||
}
|
||||
|
||||
// List all users
|
||||
async function listUsers() {
|
||||
const users = await UserModel.findAll();
|
||||
|
||||
if (users.length === 0) {
|
||||
console.log('\nNo users found in the database.');
|
||||
console.log(
|
||||
'Please create users first using the User Management interface.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('\nUsers in the system:');
|
||||
console.log('ID\tUsername\tRole\t\tStatus');
|
||||
console.log('--\t--------\t----\t\t------');
|
||||
|
||||
users.forEach((user) => {
|
||||
const role = user.role === 0 ? 'Admin' : 'User';
|
||||
const status = user.status === 0 ? 'Enabled' : 'Disabled';
|
||||
console.log(`${user.id}\t${user.username}\t\t${role}\t\t${status}`);
|
||||
});
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// Get statistics of legacy data
|
||||
async function getStatistics() {
|
||||
const stats = {
|
||||
crons: await CrontabModel.count({ where: { userId: null } }),
|
||||
envs: await EnvModel.count({ where: { userId: null } }),
|
||||
subscriptions: await SubscriptionModel.count({ where: { userId: null } }),
|
||||
dependences: await DependenceModel.count({ where: { userId: null } }),
|
||||
};
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
// Migrate data to a specific user
|
||||
async function migrateData(userId, dryRun = false) {
|
||||
const stats = await getStatistics();
|
||||
|
||||
console.log('\nLegacy Data Statistics:');
|
||||
console.log(` Cron tasks: ${stats.crons}`);
|
||||
console.log(` Environment variables: ${stats.envs}`);
|
||||
console.log(` Subscriptions: ${stats.subscriptions}`);
|
||||
console.log(` Dependencies: ${stats.dependences}`);
|
||||
console.log('');
|
||||
|
||||
if (
|
||||
stats.crons + stats.envs + stats.subscriptions + stats.dependences ===
|
||||
0
|
||||
) {
|
||||
console.log('No legacy data found. All data is already assigned to users.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
console.log('DRY RUN: No changes will be made.\n');
|
||||
console.log(`Would assign all legacy data to user ID ${userId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Migrating data to user ID ${userId}...`);
|
||||
|
||||
const transaction = await sequelize.transaction();
|
||||
|
||||
try {
|
||||
// Migrate crons
|
||||
if (stats.crons > 0) {
|
||||
await CrontabModel.update(
|
||||
{ userId },
|
||||
{ where: { userId: null }, transaction },
|
||||
);
|
||||
console.log(`✓ Migrated ${stats.crons} cron tasks`);
|
||||
}
|
||||
|
||||
// Migrate envs
|
||||
if (stats.envs > 0) {
|
||||
await EnvModel.update(
|
||||
{ userId },
|
||||
{ where: { userId: null }, transaction },
|
||||
);
|
||||
console.log(`✓ Migrated ${stats.envs} environment variables`);
|
||||
}
|
||||
|
||||
// Migrate subscriptions
|
||||
if (stats.subscriptions > 0) {
|
||||
await SubscriptionModel.update(
|
||||
{ userId },
|
||||
{ where: { userId: null }, transaction },
|
||||
);
|
||||
console.log(`✓ Migrated ${stats.subscriptions} subscriptions`);
|
||||
}
|
||||
|
||||
// Migrate dependences
|
||||
if (stats.dependences > 0) {
|
||||
await DependenceModel.update(
|
||||
{ userId },
|
||||
{ where: { userId: null }, transaction },
|
||||
);
|
||||
console.log(`✓ Migrated ${stats.dependences} dependencies`);
|
||||
}
|
||||
|
||||
await transaction.commit();
|
||||
console.log('\n✓ Migration completed successfully!');
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
console.error('\n✗ Migration failed:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Main function
|
||||
async function main() {
|
||||
const args = parseArgs();
|
||||
|
||||
if (args.help) {
|
||||
showHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Test database connection
|
||||
await sequelize.authenticate();
|
||||
console.log('Database connection established.');
|
||||
|
||||
if (args.listUsers) {
|
||||
await listUsers();
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate arguments
|
||||
if (!args.userId && !args.username) {
|
||||
console.error('\nError: You must specify either --userId or --username');
|
||||
console.log('Use --help for usage information.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Get user ID
|
||||
let userId = args.userId;
|
||||
|
||||
if (args.username) {
|
||||
const user = await UserModel.findOne({
|
||||
where: { username: args.username },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
console.error(`\nError: User '${args.username}' not found.`);
|
||||
console.log('Use --list-users to see available users.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
userId = user.id;
|
||||
console.log(`Found user '${args.username}' with ID ${userId}`);
|
||||
} else {
|
||||
// Verify user exists
|
||||
const user = await UserModel.findByPk(userId);
|
||||
|
||||
if (!user) {
|
||||
console.error(`\nError: User with ID ${userId} not found.`);
|
||||
console.log('Use --list-users to see available users.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Found user '${user.username}' with ID ${userId}`);
|
||||
}
|
||||
|
||||
// Perform migration
|
||||
await migrateData(userId, args.dryRun);
|
||||
} catch (error) {
|
||||
console.error('\nError:', error.message);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await sequelize.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Run the script
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
console.error('Fatal error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { main, listUsers, migrateData, getStatistics };
|
||||
+33
-4
@@ -10,7 +10,15 @@ import {
|
||||
import ProLayout, { PageLoading } from '@ant-design/pro-layout';
|
||||
import { history, Link, Outlet, useLocation } from '@umijs/max';
|
||||
import * as DarkReader from '@umijs/ssr-darkreader';
|
||||
import { Avatar, Badge, Dropdown, Image, MenuProps, Tooltip } from 'antd';
|
||||
import {
|
||||
Alert,
|
||||
Avatar,
|
||||
Badge,
|
||||
Dropdown,
|
||||
Image,
|
||||
MenuProps,
|
||||
Tooltip,
|
||||
} from 'antd';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import intl from 'react-intl-universal';
|
||||
import vhCheck from 'vh-check';
|
||||
@@ -48,7 +56,8 @@ export default function () {
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [systemInfo, setSystemInfo] = useState<TSystemInfo>();
|
||||
const [siteTitle, setSiteTitle] = useState(
|
||||
() => localStorage.getItem('qinglong_panel_title')?.trim() || intl.get('青龙'),
|
||||
() =>
|
||||
localStorage.getItem('qinglong_panel_title')?.trim() || intl.get('青龙'),
|
||||
);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [initLoading, setInitLoading] = useState<boolean>(true);
|
||||
@@ -94,7 +103,7 @@ export default function () {
|
||||
if (code === 200 && data.username) {
|
||||
setUser(data);
|
||||
if (location.pathname === '/') {
|
||||
history.push('/dashboard');
|
||||
history.push(data.role === 1 ? '/env' : '/dashboard');
|
||||
}
|
||||
}
|
||||
needLoading && setLoading(false);
|
||||
@@ -206,7 +215,7 @@ export default function () {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || !user.username) return;
|
||||
if (!user || !user.username || user.role === 1) return;
|
||||
const ws = WebSocketManager.getInstance(
|
||||
`${window.location.origin}${
|
||||
config.apiPrefix
|
||||
@@ -390,7 +399,27 @@ export default function () {
|
||||
</span>
|
||||
)}
|
||||
{...defaultProps}
|
||||
{...(user.role === 1
|
||||
? {
|
||||
route: {
|
||||
routes: defaultProps.route!.routes!.filter((route) =>
|
||||
['/crontab', '/env', '/subscription'].includes(
|
||||
route.path || '',
|
||||
),
|
||||
),
|
||||
},
|
||||
}
|
||||
: {})}
|
||||
>
|
||||
{user.role === 1 && (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={intl.get(
|
||||
'普通用户只读自己的数据,执行和系统修改由管理员负责',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Outlet
|
||||
context={{
|
||||
...ctx,
|
||||
|
||||
+13
-3
@@ -395,7 +395,7 @@
|
||||
"父目录": "Parent Directory",
|
||||
"版本": "Version",
|
||||
"状态": "Status",
|
||||
"环境变量": "Environment Variables",
|
||||
"环境变量": "Environment variable",
|
||||
"生成数据中...": "Generating data...",
|
||||
"用于访问所有私有仓库的全局SSH私钥": "Global SSH private key for accessing all private repositories",
|
||||
"用户令牌,扫描登录后 我的—>设置->令牌 中获取,参考 https://www.weplusbot.com/": "Token, which can be obtained after scanning and logging in, is available under 'My Account' -> 'Settings' -> 'Tokens'. Please refer to the instructions for detailed steps: https://www.weplusbot.com/",
|
||||
@@ -634,7 +634,6 @@
|
||||
"保存配置": "Save",
|
||||
"当前生效值": "Effective value",
|
||||
"默认配置": "Default",
|
||||
"环境变量": "Environment variable",
|
||||
"客户端 IP 诊断": "Client IP diagnostics",
|
||||
"重新诊断": "Run again",
|
||||
"Socket 地址": "Socket address",
|
||||
@@ -699,5 +698,16 @@
|
||||
"仍然保存": "Save anyway",
|
||||
"已保存,本次未记录历史版本": "Saved without a history snapshot",
|
||||
"已保存,但旧历史清理失败,请检查存储权限和空间": "Saved, but old history could not be removed. Check storage permissions and free space.",
|
||||
"保存失败": "Unable to save"
|
||||
"保存失败": "Unable to save",
|
||||
"用户管理": "User Management",
|
||||
"角色": "Role",
|
||||
"管理员": "Admin",
|
||||
"普通用户": "User",
|
||||
"确认删除选中的用户吗": "Are you sure to delete selected users?",
|
||||
"请输入用户名": "Please enter username",
|
||||
"请输入密码": "Please enter password",
|
||||
"密码长度至少为6位": "Password must be at least 6 characters",
|
||||
"新增用户": "Add User",
|
||||
"编辑用户": "Edit User",
|
||||
"普通用户只读自己的数据,执行和系统修改由管理员负责": "You can view your own data. Administrators run tasks and change system settings."
|
||||
}
|
||||
|
||||
+12
-2
@@ -634,7 +634,6 @@
|
||||
"保存配置": "保存配置",
|
||||
"当前生效值": "当前生效值",
|
||||
"默认配置": "默认配置",
|
||||
"环境变量": "环境变量",
|
||||
"客户端 IP 诊断": "客户端 IP 诊断",
|
||||
"重新诊断": "重新诊断",
|
||||
"Socket 地址": "Socket 地址",
|
||||
@@ -699,5 +698,16 @@
|
||||
"仍然保存": "仍然保存",
|
||||
"已保存,本次未记录历史版本": "已保存,本次未记录历史版本",
|
||||
"已保存,但旧历史清理失败,请检查存储权限和空间": "已保存,但旧历史清理失败,请检查存储权限和空间",
|
||||
"保存失败": "保存失败"
|
||||
"保存失败": "保存失败",
|
||||
"用户管理": "用户管理",
|
||||
"角色": "角色",
|
||||
"管理员": "管理员",
|
||||
"普通用户": "普通用户",
|
||||
"确认删除选中的用户吗": "确认删除选中的用户吗",
|
||||
"请输入用户名": "请输入用户名",
|
||||
"请输入密码": "请输入密码",
|
||||
"密码长度至少为6位": "密码长度至少为6位",
|
||||
"新增用户": "新增用户",
|
||||
"编辑用户": "编辑用户",
|
||||
"普通用户只读自己的数据,执行和系统修改由管理员负责": "普通用户只读自己的数据,执行和系统修改由管理员负责"
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ import useResizeObserver from '@react-hook/resize-observer';
|
||||
import SystemLog from './systemLog';
|
||||
import Dependence from './dependence';
|
||||
import ClientIp from './clientIp';
|
||||
import UserManagement from './userManagement';
|
||||
|
||||
const { Text } = Typography;
|
||||
const isDemoEnv = window.__ENV__DeployEnv === 'demo';
|
||||
@@ -345,6 +346,11 @@ const Setting = () => {
|
||||
label: intl.get('登录日志'),
|
||||
children: <LoginLog height={height} data={loginLogData} />,
|
||||
},
|
||||
{
|
||||
key: 'user-management',
|
||||
label: intl.get('用户管理'),
|
||||
children: <UserManagement height={height} />,
|
||||
},
|
||||
{
|
||||
key: 'client-ip',
|
||||
label: intl.get('客户端 IP'),
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
import intl from 'react-intl-universal';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Table,
|
||||
Space,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
message,
|
||||
Tag,
|
||||
} from 'antd';
|
||||
import { EditOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import { request } from '@/utils/http';
|
||||
import config from '@/utils/config';
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
password?: string;
|
||||
role: number;
|
||||
status: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const UserManagement: React.FC<{ height: number }> = ({ height }) => {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: intl.get('用户名'),
|
||||
dataIndex: 'username',
|
||||
key: 'username',
|
||||
},
|
||||
{
|
||||
title: intl.get('角色'),
|
||||
dataIndex: 'role',
|
||||
key: 'role',
|
||||
render: (role: number) => (
|
||||
<Tag color={role === 0 ? 'red' : 'blue'}>
|
||||
{role === 0 ? intl.get('管理员') : intl.get('普通用户')}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: intl.get('状态'),
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (status: number) => (
|
||||
<Tag color={status === 0 ? 'green' : 'default'}>
|
||||
{status === 0 ? intl.get('启用') : intl.get('禁用')}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: intl.get('创建时间'),
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
render: (text: string) => (text ? new Date(text).toLocaleString() : '-'),
|
||||
},
|
||||
{
|
||||
title: intl.get('操作'),
|
||||
key: 'action',
|
||||
render: (_: any, record: User) => (
|
||||
<Space size="middle">
|
||||
<Button
|
||||
type="link"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleEdit(record)}
|
||||
>
|
||||
{intl.get('编辑')}
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => handleDelete([record.id])}
|
||||
>
|
||||
{intl.get('删除')}
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const fetchUsers = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { code, data } = await request.get(
|
||||
`${config.apiPrefix}user-management`,
|
||||
);
|
||||
if (code === 200) {
|
||||
setUsers(data);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('Failed to fetch users');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, []);
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingUser(null);
|
||||
form.resetFields();
|
||||
setIsModalVisible(true);
|
||||
};
|
||||
|
||||
const handleEdit = (record: User) => {
|
||||
setEditingUser(record);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
username: record.username,
|
||||
role: record.role,
|
||||
status: record.status,
|
||||
});
|
||||
setIsModalVisible(true);
|
||||
};
|
||||
|
||||
const handleDelete = (ids: number[]) => {
|
||||
Modal.confirm({
|
||||
title: intl.get('确认删除'),
|
||||
content: intl.get('确认删除选中的用户吗'),
|
||||
onOk: async () => {
|
||||
try {
|
||||
const { code, message: msg } = await request.delete(
|
||||
`${config.apiPrefix}user-management`,
|
||||
{ data: ids },
|
||||
);
|
||||
if (code === 200) {
|
||||
message.success(msg || intl.get('删除成功'));
|
||||
fetchUsers();
|
||||
} else {
|
||||
message.error(msg || intl.get('删除失败'));
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || intl.get('删除失败'));
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
|
||||
if (editingUser) {
|
||||
// Update user
|
||||
const { code, message: msg } = await request.put(
|
||||
`${config.apiPrefix}user-management`,
|
||||
{ ...values, id: editingUser.id },
|
||||
);
|
||||
if (code === 200) {
|
||||
message.success(msg || intl.get('更新成功'));
|
||||
setIsModalVisible(false);
|
||||
fetchUsers();
|
||||
} else {
|
||||
message.error(msg || intl.get('更新失败'));
|
||||
}
|
||||
} else {
|
||||
// Create user
|
||||
const { code, message: msg } = await request.post(
|
||||
`${config.apiPrefix}user-management`,
|
||||
values,
|
||||
);
|
||||
if (code === 200) {
|
||||
message.success(msg || intl.get('创建成功'));
|
||||
setIsModalVisible(false);
|
||||
fetchUsers();
|
||||
} else {
|
||||
message.error(msg || intl.get('创建失败'));
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || intl.get('操作失败'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>
|
||||
{intl.get('新增用户')}
|
||||
</Button>
|
||||
</div>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={users}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
scroll={{ y: height - 120 }}
|
||||
pagination={false}
|
||||
/>
|
||||
<Modal
|
||||
title={editingUser ? intl.get('编辑用户') : intl.get('新增用户')}
|
||||
open={isModalVisible}
|
||||
onOk={handleSubmit}
|
||||
onCancel={() => setIsModalVisible(false)}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
name="username"
|
||||
label={intl.get('用户名')}
|
||||
rules={[{ required: true, message: intl.get('请输入用户名') }]}
|
||||
>
|
||||
<Input placeholder={intl.get('请输入用户名')} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="password"
|
||||
label={intl.get('密码')}
|
||||
rules={[
|
||||
{ required: !editingUser, message: intl.get('请输入密码') },
|
||||
{ min: 6, message: intl.get('密码长度至少为6位') },
|
||||
]}
|
||||
>
|
||||
<Input.Password placeholder={intl.get('请输入密码')} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="role"
|
||||
label={intl.get('角色')}
|
||||
rules={[{ required: true, message: intl.get('请选择角色') }]}
|
||||
initialValue={1}
|
||||
>
|
||||
<Select>
|
||||
<Option value={0}>{intl.get('管理员')}</Option>
|
||||
<Option value={1}>{intl.get('普通用户')}</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="status"
|
||||
label={intl.get('状态')}
|
||||
rules={[{ required: true, message: intl.get('请选择状态') }]}
|
||||
initialValue={0}
|
||||
>
|
||||
<Select>
|
||||
<Option value={0}>{intl.get('启用')}</Option>
|
||||
<Option value={1}>{intl.get('禁用')}</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserManagement;
|
||||
@@ -0,0 +1,292 @@
|
||||
const test = require('node:test'),
|
||||
assert = require('node:assert/strict');
|
||||
const { Sequelize } = require('sequelize');
|
||||
const jwt = require('jsonwebtoken'),
|
||||
express = require('express');
|
||||
const fs = require('node:fs'),
|
||||
os = require('node:os'),
|
||||
path = require('node:path');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
|
||||
test('user accounts keep credentials private, update on SQLite and revoke changed sessions', async (t) => {
|
||||
const database = new Sequelize({
|
||||
dialect: 'sqlite',
|
||||
storage: ':memory:',
|
||||
logging: false,
|
||||
});
|
||||
t.after(() => database.close());
|
||||
const cache = new Map();
|
||||
const config = {
|
||||
jwt: { secret: 'synthetic-multiuser-test' },
|
||||
api: { prefix: '/api' },
|
||||
};
|
||||
const mocks = {
|
||||
'.': { sequelize: database },
|
||||
'../config': config,
|
||||
'../shared/store': {
|
||||
shareStore: { getAuthInfo: async () => ({ username: 'owner' }) },
|
||||
},
|
||||
typedi: { Service: () => (x) => x, Inject: () => () => {} },
|
||||
};
|
||||
const { UserModel } = load('back/data/user.ts', mocks, cache);
|
||||
await UserModel.sync();
|
||||
const Account = load('back/services/userManagement.ts', mocks, cache).default;
|
||||
const account = new Account({});
|
||||
const alice = await account.create({
|
||||
username: 'alice',
|
||||
password: 'password1',
|
||||
role: 1,
|
||||
status: 0,
|
||||
});
|
||||
assert.ok(!Object.hasOwn(alice, 'password'));
|
||||
assert.ok(!Object.hasOwn(alice, 'sessions'));
|
||||
const admin = await account.create({
|
||||
username: 'manager',
|
||||
password: 'password2',
|
||||
role: 0,
|
||||
status: 0,
|
||||
});
|
||||
assert.equal(admin.role, 0);
|
||||
const raw = await UserModel.findByPk(alice.id);
|
||||
assert.match(raw.password, /^scrypt\$/);
|
||||
assert.notEqual(raw.password, 'password1');
|
||||
assert.equal(await account.authenticate('alice', 'wrong'), null);
|
||||
let token = await account.issueToken(
|
||||
await account.authenticate('alice', 'password1'),
|
||||
'desktop',
|
||||
);
|
||||
assert.equal((await account.validateToken(token, 'desktop')).id, alice.id);
|
||||
assert.equal(await account.validateToken(token, 'mobile'), null);
|
||||
const forged = jwt.sign({ userId: alice.id, role: 0 }, config.jwt.secret, {
|
||||
algorithm: 'HS384',
|
||||
expiresIn: '1h',
|
||||
});
|
||||
assert.equal(await account.validateToken(forged, 'desktop'), null);
|
||||
const updated = await account.update({
|
||||
id: alice.id,
|
||||
username: 'alice',
|
||||
password: '',
|
||||
});
|
||||
assert.equal(updated.username, 'alice');
|
||||
assert.ok(!Object.hasOwn(updated, 'password'));
|
||||
assert.equal(await account.validateToken(token, 'desktop'), null);
|
||||
token = await account.issueToken(
|
||||
await account.authenticate('alice', 'password1'),
|
||||
'desktop',
|
||||
);
|
||||
await account.revokeToken(alice.id, token);
|
||||
assert.equal(await account.validateToken(token, 'desktop'), null);
|
||||
token = await account.issueToken(
|
||||
await account.authenticate('alice', 'password1'),
|
||||
'desktop',
|
||||
);
|
||||
await account.update({ id: alice.id, status: 1 });
|
||||
assert.equal(await account.validateToken(token, 'desktop'), null);
|
||||
assert.equal(await account.authenticate('alice', 'password1'), null);
|
||||
assert.ok(
|
||||
(await account.list()).every(
|
||||
(x) => !Object.hasOwn(x, 'password') && !Object.hasOwn(x, 'sessions'),
|
||||
),
|
||||
);
|
||||
await assert.rejects(
|
||||
account.create({
|
||||
username: 'owner',
|
||||
password: 'password3',
|
||||
role: 1,
|
||||
status: 0,
|
||||
}),
|
||||
);
|
||||
await account.update({ id: alice.id, status: 0, password: 'changed123' });
|
||||
assert.equal(await account.authenticate('alice', 'password1'), null);
|
||||
await account.delete([alice.id]);
|
||||
assert.equal(await account.validateToken(token, 'desktop'), null);
|
||||
});
|
||||
|
||||
test('HTTP ordinary users see only their own data and cannot invoke global or executable APIs', async (t) => {
|
||||
const database = new Sequelize({
|
||||
dialect: 'sqlite',
|
||||
storage: ':memory:',
|
||||
logging: false,
|
||||
});
|
||||
t.after(() => database.close());
|
||||
const cache = new Map();
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-user-http-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const secret = 'synthetic-http-multiuser',
|
||||
adminToken = jwt.sign({}, secret, { algorithm: 'HS384', expiresIn: '1h' });
|
||||
const config = {
|
||||
jwt: { secret },
|
||||
api: { prefix: '/api' },
|
||||
apiWhiteList: ['/api/user/login', '/api/health', '/api/system'],
|
||||
baseUrl: '',
|
||||
rootPath: root,
|
||||
uploadPath: root,
|
||||
};
|
||||
const mocks = {
|
||||
'.': { sequelize: database },
|
||||
'../config': config,
|
||||
typedi: { Service: () => (x) => x, Inject: () => () => {} },
|
||||
'../shared/i18n': { t: (x) => x },
|
||||
'../data/open': {},
|
||||
'../config/serverEnv': { serveEnv: (req, res) => res.end() },
|
||||
'../config/util': {
|
||||
getToken: (req) =>
|
||||
(req.headers.authorization || '').replace(/^Bearer /, ''),
|
||||
getPlatform: () => 'desktop',
|
||||
},
|
||||
'../shared/store': {
|
||||
shareStore: {
|
||||
getAuthInfo: async () => ({
|
||||
username: 'owner',
|
||||
password: 'configured',
|
||||
token: adminToken,
|
||||
}),
|
||||
getApps: async () => [],
|
||||
},
|
||||
},
|
||||
};
|
||||
const userData = load('back/data/user.ts', mocks, cache),
|
||||
envData = load('back/data/env.ts', mocks, cache),
|
||||
cronData = load('back/data/cron.ts', mocks, cache),
|
||||
subData = load('back/data/subscription.ts', mocks, cache);
|
||||
for (const model of [
|
||||
userData.UserModel,
|
||||
envData.EnvModel,
|
||||
cronData.CrontabModel,
|
||||
subData.SubscriptionModel,
|
||||
])
|
||||
await model.sync();
|
||||
mocks['../data/env'] = envData;
|
||||
mocks['../data/cron'] = cronData;
|
||||
mocks['../data/subscription'] = subData;
|
||||
const Account = load('back/services/userManagement.ts', mocks, cache).default;
|
||||
const account = new Account({});
|
||||
mocks['../services/userManagement'] = { default: Account };
|
||||
mocks.typedi.Container = { get: () => account };
|
||||
const alice = await account.create({
|
||||
username: 'alice',
|
||||
password: 'password1',
|
||||
role: 1,
|
||||
status: 0,
|
||||
}),
|
||||
bob = await account.create({
|
||||
username: 'bob',
|
||||
password: 'password2',
|
||||
role: 1,
|
||||
status: 0,
|
||||
});
|
||||
await envData.EnvModel.bulkCreate([
|
||||
{ name: 'mine', value: 'alice-only', userId: alice.id },
|
||||
{ name: 'other', value: 'bob-secret', userId: bob.id },
|
||||
{ name: 'legacy', value: 'owner-secret', userId: null },
|
||||
]);
|
||||
let commands = 0;
|
||||
mocks['../api'] = () => {
|
||||
const router = express.Router();
|
||||
router.get('/system', (req, res) => res.json({ code: 200 }));
|
||||
router.get('/user', async (req, res) =>
|
||||
res.json({ code: 200, data: await account.get(req.user.userId) }),
|
||||
);
|
||||
router.post('/user/logout', async (req, res) => {
|
||||
await account.revokeToken(
|
||||
req.user.userId,
|
||||
mocks['../config/util'].getToken(req),
|
||||
);
|
||||
res.json({ code: 200 });
|
||||
});
|
||||
router.post('/system/command', (req, res) => {
|
||||
commands++;
|
||||
res.json({ code: 200 });
|
||||
});
|
||||
router.get('/envs', (req, res) =>
|
||||
res.json({ code: 200, data: 'admin-all' }),
|
||||
);
|
||||
return router;
|
||||
};
|
||||
const app = express();
|
||||
load('back/loaders/express.ts', mocks, cache).default({ app });
|
||||
const server = app.listen(0, '127.0.0.1');
|
||||
await new Promise((r) => server.once('listening', r));
|
||||
t.after(() => new Promise((r) => server.close(r)));
|
||||
let token = await account.issueToken(
|
||||
await account.authenticate('alice', 'password1'),
|
||||
'desktop',
|
||||
);
|
||||
async function request(url, method = 'GET', current = token) {
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${server.address().port}${url}`,
|
||||
{ method, headers: { Authorization: 'Bearer ' + current } },
|
||||
);
|
||||
return { status: response.status, body: await response.json() };
|
||||
}
|
||||
assert.equal((await request('/api/system')).status, 200);
|
||||
assert.equal((await request('/api/user')).body.data.username, 'alice');
|
||||
const mine = await request(`/api/envs?userId=${bob.id}`);
|
||||
assert.deepEqual(
|
||||
mine.body.data.map((x) => x.value),
|
||||
['alice-only'],
|
||||
);
|
||||
assert.equal((await request('/api/envs/2')).status, 404);
|
||||
assert.equal((await request('/api/envs/3')).status, 404);
|
||||
for (const [url, method] of [
|
||||
['/api/system/command', 'POST'],
|
||||
['/api/crons/run', 'PUT'],
|
||||
['/api/envs', 'PUT'],
|
||||
['/api/configs', 'GET'],
|
||||
['/api/logs', 'GET'],
|
||||
['/api/scripts', 'GET'],
|
||||
['/api/dependencies', 'POST'],
|
||||
['/api/user-management', 'GET'],
|
||||
['/api/system/config', 'GET'],
|
||||
['/api/user/two-factor/init', 'GET'],
|
||||
])
|
||||
assert.equal((await request(url, method)).status, 403, url);
|
||||
assert.equal(commands, 0);
|
||||
assert.equal(
|
||||
(await request('/api/envs', 'GET', adminToken)).body.data,
|
||||
'admin-all',
|
||||
);
|
||||
const forged = jwt.sign({ userId: alice.id, role: 0 }, secret, {
|
||||
algorithm: 'HS384',
|
||||
expiresIn: '1h',
|
||||
});
|
||||
assert.equal((await request('/api/envs', 'GET', forged)).status, 401);
|
||||
await request('/api/user/logout', 'POST');
|
||||
assert.equal((await request('/api/envs')).status, 401);
|
||||
token = await account.issueToken(
|
||||
await account.authenticate('alice', 'password1'),
|
||||
'desktop',
|
||||
);
|
||||
await account.update({ id: alice.id, status: 1 });
|
||||
assert.equal((await request('/api/envs')).status, 401);
|
||||
});
|
||||
|
||||
test('user ownership migration preserves existing records and is idempotent', async (t) => {
|
||||
const database = new Sequelize({
|
||||
dialect: 'sqlite',
|
||||
storage: ':memory:',
|
||||
logging: false,
|
||||
});
|
||||
t.after(() => database.close());
|
||||
for (const table of [
|
||||
'Users',
|
||||
'Envs',
|
||||
'Crontabs',
|
||||
'Subscriptions',
|
||||
'Dependences',
|
||||
])
|
||||
await database.query(
|
||||
`CREATE TABLE "${table}" (id INTEGER PRIMARY KEY, name TEXT)`,
|
||||
);
|
||||
await database.query(`INSERT INTO Envs (id,name) VALUES (1,'preserved')`);
|
||||
const {
|
||||
migrateUserSchema,
|
||||
} = require('../../back/shared/userSchemaMigrations');
|
||||
await migrateUserSchema(database);
|
||||
await migrateUserSchema(database);
|
||||
const [rows] = await database.query('SELECT * FROM Envs');
|
||||
assert.equal(rows[0].name, 'preserved');
|
||||
assert.equal(rows[0].userId, null);
|
||||
const columns = await database.getQueryInterface().describeTable('Users');
|
||||
assert.ok(columns.sessions);
|
||||
});
|
||||
@@ -106,6 +106,7 @@ test('database loader rejects initialization failure rather than allowing worker
|
||||
const mocks = { './logger': { error() {}, info() {} } };
|
||||
for (const [file, model] of [
|
||||
['env', 'EnvModel'],
|
||||
['user', 'UserModel'],
|
||||
['cron', 'CrontabModel'],
|
||||
['dependence', 'DependenceModel'],
|
||||
['open', 'AppModel'],
|
||||
|
||||
@@ -18,6 +18,7 @@ async function setup() {
|
||||
const clients = new Set();
|
||||
const timers = new Set();
|
||||
const mocks = {
|
||||
jsonwebtoken: jwt,
|
||||
sockjs: {
|
||||
createServer: () => ({
|
||||
on: (_, fn) => {
|
||||
|
||||
@@ -70,3 +70,75 @@ test('WebSocket connections reject expired tokens and close when sessions are re
|
||||
});
|
||||
assert.equal(clients.size, 0);
|
||||
});
|
||||
|
||||
test('database administrators receive live updates while read-only users are refused and revoked admins disconnect', async () => {
|
||||
const secret = 'account-sock-test';
|
||||
const adminToken = jwt.sign({ userId: 2 }, secret, { algorithm: 'HS384' });
|
||||
const userToken = jwt.sign({ userId: 3 }, secret, { algorithm: 'HS384' });
|
||||
let revoked = false,
|
||||
onConnection;
|
||||
const clients = new Set();
|
||||
const Sock = class {},
|
||||
Account = class {};
|
||||
const loader = load(path.join(__dirname, '../../back/loaders/sock.ts'), {
|
||||
sockjs: {
|
||||
createServer: () => ({
|
||||
on: (_event, fn) => {
|
||||
onConnection = fn;
|
||||
},
|
||||
installHandlers() {},
|
||||
}),
|
||||
},
|
||||
typedi: {
|
||||
Container: {
|
||||
get: (kind) =>
|
||||
kind === Account
|
||||
? {
|
||||
validateToken: async (token) =>
|
||||
revoked ? null : { role: token === adminToken ? 0 : 1 },
|
||||
}
|
||||
: {
|
||||
addClient: (c) => clients.add(c),
|
||||
removeClient: (c) => clients.delete(c),
|
||||
},
|
||||
},
|
||||
},
|
||||
'../services/sock': Sock,
|
||||
'../services/userManagement': Account,
|
||||
'../config': { jwt: { secret }, baseUrl: '' },
|
||||
'../config/util': { getPlatform: () => 'desktop' },
|
||||
'../shared/store': {
|
||||
shareStore: { getAuthInfo: async () => ({ token: '', tokens: {} }) },
|
||||
},
|
||||
}).default;
|
||||
await loader({ server: {} });
|
||||
const connection = (token) =>
|
||||
Object.assign(new EventEmitter(), {
|
||||
headers: {},
|
||||
pathname: '/api/ws/a/b/websocket',
|
||||
url: `/api/ws/a/b/websocket?token=${token}`,
|
||||
close() {
|
||||
this.closed = true;
|
||||
this.emit('close');
|
||||
},
|
||||
write() {},
|
||||
});
|
||||
const ordinary = connection(userToken);
|
||||
await onConnection(ordinary);
|
||||
assert.equal(ordinary.closed, true);
|
||||
const admin = connection(adminToken);
|
||||
await onConnection(admin);
|
||||
assert.equal(clients.has(admin), true);
|
||||
revoked = true;
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
admin.close();
|
||||
reject(new Error('revoked administrator remained connected'));
|
||||
}, 3000);
|
||||
admin.once('close', () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
assert.equal(clients.size, 0);
|
||||
});
|
||||
|
||||
@@ -36,6 +36,7 @@ function fixture(initial) {
|
||||
require('node:crypto').randomBytes(32).toString('hex'),
|
||||
},
|
||||
'./notify': class {},
|
||||
'./userManagement': class {},
|
||||
'./schedule': class {},
|
||||
'./sock': class {},
|
||||
'../shared/store': {
|
||||
@@ -67,6 +68,7 @@ function fixture(initial) {
|
||||
{ getClients: () => [{ close: () => closed++ }] },
|
||||
);
|
||||
user.notificationService = { notify() {} };
|
||||
user.userManagementService = { authenticate: async () => null };
|
||||
return {
|
||||
user,
|
||||
get auth() {
|
||||
|
||||
Reference in New Issue
Block a user