feat: add admin user management and disable public registration
This commit is contained in:
1 parent
cfbba73791
commit
312a5ccb86
32 files changed
+1120
-69
No files matched your search
@@ -0,0 +1,123 @@
|
||||
import {
|
||||
Injectable,
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Req,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
ForbiddenException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { hash } from 'bcryptjs';
|
||||
import { z } from 'zod';
|
||||
import { Database } from './database';
|
||||
import { UserRequest } from './auth';
|
||||
import { adminCreateInput, adminUpdateInput } from './user-access';
|
||||
|
||||
const summary = {
|
||||
id: true,
|
||||
username: true,
|
||||
role: true,
|
||||
banned: true,
|
||||
mustChangePassword: true,
|
||||
createdAt: true,
|
||||
} as const;
|
||||
@Injectable()
|
||||
export class AdminService {
|
||||
constructor(private db: Database) {}
|
||||
private async requireAdmin(userId: string) {
|
||||
const u = await this.db.user.findUnique({ where: { id: userId } });
|
||||
if (!u || u.role !== 'admin' || u.banned || u.mustChangePassword)
|
||||
throw new ForbiddenException('需要已完成改密的管理员账号');
|
||||
}
|
||||
async list(r: UserRequest, query: unknown) {
|
||||
await this.requireAdmin(r.userId);
|
||||
const p = z
|
||||
.object({
|
||||
offset: z.coerce.number().int().min(0).default(0),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(50),
|
||||
})
|
||||
.parse(query);
|
||||
const [items, total] = await Promise.all([
|
||||
this.db.user.findMany({
|
||||
select: summary,
|
||||
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
|
||||
skip: p.offset,
|
||||
take: p.limit,
|
||||
}),
|
||||
this.db.user.count(),
|
||||
]);
|
||||
return { items, total, offset: p.offset, limit: p.limit };
|
||||
}
|
||||
async create(r: UserRequest, body: unknown) {
|
||||
await this.requireAdmin(r.userId);
|
||||
const v = adminCreateInput.parse(body);
|
||||
const passwordHash = await hash(v.password, 12);
|
||||
return this.db.serial(async (tx) => {
|
||||
await tx.$queryRaw(Prisma.sql`SELECT id FROM User WHERE id=${r.userId} FOR UPDATE`);
|
||||
const actor = await tx.user.findUniqueOrThrow({ where: { id: r.userId } });
|
||||
if (actor.role !== 'admin' || actor.banned || actor.mustChangePassword)
|
||||
throw new ForbiddenException('管理员权限已变更');
|
||||
return tx.user.create({
|
||||
data: { username: v.username, passwordHash, role: v.role, mustChangePassword: true },
|
||||
select: summary,
|
||||
});
|
||||
});
|
||||
}
|
||||
async update(r: UserRequest, id: string, body: unknown) {
|
||||
z.string().uuid().parse(id);
|
||||
const v = adminUpdateInput.parse(body);
|
||||
await this.requireAdmin(r.userId);
|
||||
if (id === r.userId) throw new ForbiddenException('不能修改自己的系统权限或封禁自己');
|
||||
return this.db.serial(async (tx) => {
|
||||
// Serialize administrator changes, including two administrators changing each other.
|
||||
await tx.$queryRaw(Prisma.sql`SELECT id FROM User WHERE role='admin' ORDER BY id FOR UPDATE`);
|
||||
const actor = await tx.user.findUniqueOrThrow({ where: { id: r.userId } });
|
||||
if (actor.role !== 'admin' || actor.banned || actor.mustChangePassword)
|
||||
throw new ForbiddenException('管理员权限已变更');
|
||||
const target = await tx.user.findUnique({ where: { id } });
|
||||
if (!target) throw new NotFoundException('账号不存在');
|
||||
if (
|
||||
target.role === 'admin' &&
|
||||
!target.banned &&
|
||||
(v.banned || (v.role && v.role !== 'admin')) &&
|
||||
(await tx.user.count({ where: { role: 'admin', banned: false } })) <= 1
|
||||
)
|
||||
throw new ForbiddenException('必须保留至少一个可用管理员');
|
||||
const result = await tx.user.update({ where: { id }, data: v, select: summary });
|
||||
if (result.role !== target.role || result.banned !== target.banned) {
|
||||
await tx.session.deleteMany({ where: { userId: id } });
|
||||
await tx.agentGrant.updateMany({
|
||||
where: { userId: id, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
await tx.agentAuthorization.updateMany({
|
||||
where: { userId: id, status: { in: ['pending', 'approved'] } },
|
||||
data: { status: 'cancelled' },
|
||||
});
|
||||
await tx.agentOperation.updateMany({
|
||||
where: { userId: id, status: 'pending' },
|
||||
data: { status: 'cancelled', completedAt: new Date() },
|
||||
});
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
}
|
||||
@Controller('api/admin/users')
|
||||
export class AdminController {
|
||||
constructor(private service: AdminService) {}
|
||||
@Get() list(@Req() r: UserRequest, @Query() q: unknown) {
|
||||
return this.service.list(r, q);
|
||||
}
|
||||
@Post() create(@Req() r: UserRequest, @Body() b: unknown) {
|
||||
return this.service.create(r, b);
|
||||
}
|
||||
@Patch(':id') update(@Req() r: UserRequest, @Param('id') id: string, @Body() b: unknown) {
|
||||
return this.service.update(r, id, b);
|
||||
}
|
||||
}
|
||||
+78
-13
@@ -15,6 +15,7 @@ import {
|
||||
ForbiddenException,
|
||||
HttpException,
|
||||
SetMetadata,
|
||||
OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { Request, Response } from 'express';
|
||||
@@ -23,6 +24,7 @@ import { hash, compare } from 'bcryptjs';
|
||||
import { Database } from './database';
|
||||
import { credentials, credentialChange, defaultOverviewCards } from './validation';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { loginInput } from './user-access';
|
||||
export type UserRequest = Request & {
|
||||
userId: string;
|
||||
sessionId: string;
|
||||
@@ -40,9 +42,26 @@ export function allowedOrigin(
|
||||
return isNetworkOriginAllowed(origin, configured, !production);
|
||||
}
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
export class AuthService implements OnModuleInit {
|
||||
private attempts = new Map<string, { count: number; until: number }>();
|
||||
constructor(private db: Database) {}
|
||||
async onModuleInit() {
|
||||
// Never reset or promote an existing account based on environment defaults.
|
||||
const username = credentials.shape.username.parse(process.env.ADMIN_USERNAME ?? 'admin');
|
||||
const password = loginInput.shape.password.parse(process.env.ADMIN_PASSWORD ?? 'admin');
|
||||
if (await this.db.user.count({ where: { role: 'admin' } })) return;
|
||||
const passwordHash = await hash(password, 12);
|
||||
await this.db.serial(async (tx) => {
|
||||
if (await tx.user.count({ where: { role: 'admin' } })) return;
|
||||
if (await tx.user.findUnique({ where: { username } }))
|
||||
throw new Error(
|
||||
'Default administrator username already exists; configure a different ADMIN_USERNAME',
|
||||
);
|
||||
await tx.user.create({
|
||||
data: { username, passwordHash, role: 'admin', mustChangePassword: true },
|
||||
});
|
||||
});
|
||||
}
|
||||
limit(req: Request) {
|
||||
const network = networkConfig();
|
||||
if (!network.rateLimitEnabled) return;
|
||||
@@ -84,8 +103,9 @@ export class AuthService {
|
||||
if (!s || s.expiresAt <= new Date()) return null;
|
||||
const u = await this.db.user.findUniqueOrThrow({
|
||||
where: { id: s.userId },
|
||||
select: { idleMinutes: true },
|
||||
select: { idleMinutes: true, banned: true },
|
||||
});
|
||||
if (u.banned) throw new UnauthorizedException('账号已被封禁');
|
||||
if (u.idleMinutes && Date.now() - +s.lastActivity >= u.idleMinutes * 60000) {
|
||||
await this.db.session.deleteMany({ where: { id: s.id } });
|
||||
throw new UnauthorizedException('长时间无操作,已自动退出登录');
|
||||
@@ -102,6 +122,29 @@ export class AuthService {
|
||||
httpOnly: true,
|
||||
});
|
||||
}
|
||||
async access(req: UserRequest) {
|
||||
const u = await this.db.user.findUniqueOrThrow({ where: { id: req.userId } });
|
||||
if (u.banned) throw new UnauthorizedException('账号已被封禁');
|
||||
const path = req.path.replace(/\/$/, '');
|
||||
const passwordAllowed = [
|
||||
'/api/auth/me',
|
||||
'/api/auth/credentials',
|
||||
'/api/auth/logout',
|
||||
'/api/auth/activity',
|
||||
];
|
||||
if (u.mustChangePassword && !passwordAllowed.includes(path))
|
||||
throw new ForbiddenException('首次登录必须修改密码');
|
||||
const connectionManagement =
|
||||
path === '/api/agent/tokens' ||
|
||||
/^\/api\/agent\/(authorizations|connections)\/[a-f0-9-]{36}$/.test(path);
|
||||
if (
|
||||
u.role === 'readonly' &&
|
||||
!['GET', 'HEAD', 'OPTIONS'].includes(req.method) &&
|
||||
!path.startsWith('/api/auth/') &&
|
||||
!connectionManagement
|
||||
)
|
||||
throw new ForbiddenException('只读账号不能修改数据');
|
||||
}
|
||||
}
|
||||
@Injectable()
|
||||
export class AuthGuard implements CanActivate {
|
||||
@@ -126,6 +169,7 @@ export class AuthGuard implements CanActivate {
|
||||
req.userId = id.userId;
|
||||
req.sessionId = id.id;
|
||||
req.revealed = !!id.revealUntil && +id.revealUntil > Date.now();
|
||||
await this.auth.access(req);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -140,30 +184,34 @@ export class AuthBusinessService {
|
||||
return { status: 'ok' };
|
||||
}
|
||||
async register(body: unknown, req: Request, res: Response) {
|
||||
this.auth.limit(req);
|
||||
const v = credentials.parse(body),
|
||||
user = await this.db.user.create({
|
||||
data: { username: v.username, passwordHash: await hash(v.password, 12) },
|
||||
});
|
||||
await this.auth.issue(user.id, res);
|
||||
return { username: user.username, baseCurrency: user.baseCurrency };
|
||||
throw new ForbiddenException('公开注册已关闭,请联系管理员创建账号');
|
||||
}
|
||||
async login(body: unknown, req: Request, res: Response) {
|
||||
this.auth.limit(req);
|
||||
const v = credentials.parse(body),
|
||||
const v = loginInput.parse(body),
|
||||
user = await this.db.user.findUnique({ where: { username: v.username } });
|
||||
const ok = await compare(
|
||||
v.password,
|
||||
user?.passwordHash || '$2b$12$JZKvzAzfqM3obKxMQTFBjOzqIIKG97kn96/xUMbgTuWUnlMAT2bSi',
|
||||
);
|
||||
if (!user || !ok) throw new UnauthorizedException('账号或密码错误');
|
||||
if (user.banned) throw new ForbiddenException('账号已被封禁');
|
||||
await this.auth.issue(user.id, res);
|
||||
return { username: user.username, baseCurrency: user.baseCurrency };
|
||||
return {
|
||||
username: user.username,
|
||||
baseCurrency: user.baseCurrency,
|
||||
role: user.role,
|
||||
mustChangePassword: user.mustChangePassword,
|
||||
hiddenMenus: [],
|
||||
};
|
||||
}
|
||||
async me(req: UserRequest) {
|
||||
const user = await this.db.user.findUniqueOrThrow({
|
||||
where: { id: req.userId },
|
||||
select: {
|
||||
id: true,
|
||||
role: true,
|
||||
mustChangePassword: true,
|
||||
username: true,
|
||||
baseCurrency: true,
|
||||
hiddenMenus: true,
|
||||
@@ -195,6 +243,11 @@ export class AuthBusinessService {
|
||||
const user = await this.db.user.findUniqueOrThrow({ where: { id: r.userId } });
|
||||
if (!(await compare(v.currentPassword, user.passwordHash)))
|
||||
throw new ForbiddenException('当前密码错误');
|
||||
if (
|
||||
user.mustChangePassword &&
|
||||
(!v.newPassword || (await compare(v.newPassword, user.passwordHash)))
|
||||
)
|
||||
throw new BadRequestException('请设置与初始密码不同的新密码(至少 10 个字符)');
|
||||
if ((!v.username || v.username === user.username) && !v.newPassword)
|
||||
throw new BadRequestException('请填写新的账号或密码');
|
||||
const passwordHash = v.newPassword ? await hash(v.newPassword, 12) : user.passwordHash;
|
||||
@@ -203,11 +256,23 @@ export class AuthBusinessService {
|
||||
await this.db.serial(async (tx) => {
|
||||
await tx.$queryRaw(Prisma.sql`SELECT id FROM User WHERE id = ${r.userId} FOR UPDATE`);
|
||||
const current = await tx.user.findUniqueOrThrow({ where: { id: r.userId } });
|
||||
if (current.passwordHash !== user.passwordHash || current.username !== user.username)
|
||||
if (
|
||||
current.banned ||
|
||||
current.passwordHash !== user.passwordHash ||
|
||||
current.username !== user.username
|
||||
)
|
||||
throw new ForbiddenException('账号已变更,请重新登录后操作');
|
||||
await tx.user.update({
|
||||
where: { id: r.userId },
|
||||
data: { username: v.username, passwordHash },
|
||||
data: {
|
||||
username: v.username,
|
||||
passwordHash,
|
||||
...(v.newPassword ? { mustChangePassword: false } : {}),
|
||||
},
|
||||
});
|
||||
await tx.agentGrant.updateMany({
|
||||
where: { userId: r.userId, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
await tx.session.deleteMany({ where: { userId: r.userId } });
|
||||
await tx.session.create({ data: { id: digest(token), userId: r.userId, expiresAt } });
|
||||
|
||||
@@ -8,6 +8,7 @@ import cookieParser from 'cookie-parser';
|
||||
import helmet from 'helmet';
|
||||
import { json } from 'express';
|
||||
import { AuthController, AuthBusinessService, AuthGuard, AuthService } from './auth';
|
||||
import { AdminController, AdminService } from './admin';
|
||||
import { CalendarController, CalendarBusinessService } from './calendar';
|
||||
import { SchedulesController, SchedulesBusinessService } from './schedules';
|
||||
import { TransfersController, TransfersBusinessService } from './transfers';
|
||||
@@ -57,6 +58,7 @@ class SafeErrors implements ExceptionFilter {
|
||||
providers: [
|
||||
Database,
|
||||
AuthService,
|
||||
AdminService,
|
||||
RatesService,
|
||||
MetalsService,
|
||||
IconsService,
|
||||
@@ -82,6 +84,7 @@ class SafeErrors implements ExceptionFilter {
|
||||
CalendarController,
|
||||
IconsController,
|
||||
AuthController,
|
||||
AdminController,
|
||||
PortfolioController,
|
||||
MetalsController,
|
||||
SettingsController,
|
||||
|
||||
@@ -178,6 +178,15 @@ export class AgentOAuth implements OAuthServerProvider {
|
||||
const authorizationDays = oauthDays.parse(days);
|
||||
const allowed = selected || ['read'];
|
||||
scopeInput.parse(allowed);
|
||||
const user = await this.db.user.findUnique({ where: { id: userId } });
|
||||
if (!user || user.banned || user.mustChangePassword)
|
||||
throw new ForbiddenException('账号不可用');
|
||||
if (
|
||||
approved &&
|
||||
user.role === 'readonly' &&
|
||||
allowed.some((s: string) => ['draft', 'write', 'hidden_write'].includes(s))
|
||||
)
|
||||
throw new ForbiddenException('只读账号只能授予查询权限');
|
||||
if (allowed.some((scope: string) => !parameters.scopes.includes(scope)))
|
||||
throw new BadRequestException('不能授予客户端未请求的权限');
|
||||
const code = secret();
|
||||
@@ -220,6 +229,14 @@ export class AgentOAuth implements OAuthServerProvider {
|
||||
authorizationDays: number | null = 30,
|
||||
) {
|
||||
if (clientId && days === null) throw new BadRequestException('OAuth 连接必须有期限');
|
||||
const user = await this.db.user.findUnique({ where: { id: userId } });
|
||||
if (!user || user.banned || user.mustChangePassword)
|
||||
throw new ForbiddenException('账号已封禁或需要首次改密');
|
||||
if (
|
||||
user.role === 'readonly' &&
|
||||
selected.some((s) => ['draft', 'write', 'hidden_write'].includes(s))
|
||||
)
|
||||
throw new ForbiddenException('只读账号只能授予查询权限');
|
||||
const access = secret(),
|
||||
refresh = clientId ? secret() : undefined,
|
||||
sessionId = digest(secret());
|
||||
@@ -315,6 +332,9 @@ export class AgentOAuth implements OAuthServerProvider {
|
||||
(row.refreshExpiresAt && row.refreshExpiresAt <= new Date())
|
||||
)
|
||||
throw new InvalidGrantError('Invalid refresh token');
|
||||
const user = await this.db.user.findUnique({ where: { id: row.userId } });
|
||||
if (!user || user.banned || user.mustChangePassword)
|
||||
throw new InvalidGrantError('Account unavailable');
|
||||
const current = row.scopes as string[];
|
||||
if (
|
||||
selected &&
|
||||
@@ -368,6 +388,9 @@ export class AgentOAuth implements OAuthServerProvider {
|
||||
row.resource !== urls().resource.toString()
|
||||
)
|
||||
throw new InvalidTokenError('Expired, revoked or invalid resource token');
|
||||
const user = await this.db.user.findUnique({ where: { id: row.userId } });
|
||||
if (!user || user.banned || user.mustChangePassword)
|
||||
throw new InvalidTokenError('Account unavailable');
|
||||
return {
|
||||
token,
|
||||
clientId: row.clientId || row.id,
|
||||
@@ -407,6 +430,9 @@ export class AgentOAuth implements OAuthServerProvider {
|
||||
},
|
||||
});
|
||||
if (!row) throw new ForbiddenException('Agent 连接已过期或撤销');
|
||||
const user = await this.db.user.findUnique({ where: { id: row.userId } });
|
||||
if (!user || user.banned || user.mustChangePassword)
|
||||
throw new ForbiddenException('账号已封禁或需要首次改密');
|
||||
return row;
|
||||
}
|
||||
}
|
||||
@@ -134,6 +134,10 @@ export class AgentOperations {
|
||||
return digest(stable(plain(data)));
|
||||
}
|
||||
private async permission(grant: AgentGrant, t: ToolDefinition, p: any) {
|
||||
const user = await this.db.user.findUniqueOrThrow({ where: { id: grant.userId } });
|
||||
if (user.banned || user.mustChangePassword) throw new ForbiddenException('账号不可用');
|
||||
if (user.role === 'readonly' && t.scope !== 'read')
|
||||
throw new ForbiddenException('只读账号不能提交写入或草稿');
|
||||
const selected = grant.scopes as string[];
|
||||
if (!selected.includes('read')) throw new ForbiddenException('缺少 read 权限');
|
||||
const mode = selected.includes('write')
|
||||
|
||||
@@ -3,6 +3,7 @@ import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import { metalConfig, metalHoldingInput } from './metals';
|
||||
import { scheduleInput } from './schedules';
|
||||
import { z } from 'zod';
|
||||
import { loginInput, adminCreateInput, adminUpdateInput } from './user-access';
|
||||
import {
|
||||
credentials,
|
||||
credentialChange,
|
||||
@@ -32,8 +33,9 @@ export function setupOpenApi(app: INestApplication) {
|
||||
.build(),
|
||||
);
|
||||
const bodies: Record<string, z.ZodType> = {
|
||||
'POST /api/auth/register': credentials,
|
||||
'POST /api/auth/login': credentials,
|
||||
'POST /api/auth/login': loginInput,
|
||||
'POST /api/admin/users': adminCreateInput,
|
||||
'PATCH /api/admin/users/{id}': adminUpdateInput,
|
||||
'PATCH /api/auth/credentials': credentialChange,
|
||||
'POST /api/auth/reveal': z.object({ password: credentials.shape.password.optional() }).strict(),
|
||||
'POST /api/positions': positionInput,
|
||||
|
||||
@@ -183,6 +183,9 @@ export class SettingsBusinessService {
|
||||
where: { id: r.userId },
|
||||
select: {
|
||||
username: true,
|
||||
id: true,
|
||||
role: true,
|
||||
mustChangePassword: true,
|
||||
baseCurrency: true,
|
||||
hiddenMenus: true,
|
||||
showNotes: true,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { z } from 'zod';
|
||||
import { credentials } from './validation';
|
||||
|
||||
export const roles = z.enum(['admin', 'user', 'readonly']);
|
||||
export const loginInput = credentials.extend({
|
||||
password: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(72)
|
||||
.refine((s) => Buffer.byteLength(s, 'utf8') <= 72, '密码最多 72 字节'),
|
||||
});
|
||||
export const adminCreateInput = credentials.extend({ role: roles.default('user') }).strict();
|
||||
export const adminUpdateInput = z
|
||||
.object({ role: roles.optional(), banned: z.boolean().optional() })
|
||||
.strict()
|
||||
.refine((v) => v.role !== undefined || v.banned !== undefined, '请选择权限或封禁状态');
|
||||
@@ -177,7 +177,11 @@ export const pairedReasons = [
|
||||
|
||||
export const credentialChange = z
|
||||
.object({
|
||||
currentPassword: credentials.shape.password,
|
||||
currentPassword: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(72)
|
||||
.refine((s) => Buffer.byteLength(s, 'utf8') <= 72, '密码最多 72 字节'),
|
||||
username: credentials.shape.username.optional(),
|
||||
newPassword: credentials.shape.password.optional(),
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user