feat(iam): 角色权限管理 + 权限缓存 + 指标 + 鉴权中间件增强 + nextstep 文档
This commit is contained in:
@@ -74,12 +74,15 @@ export class AppModule implements NestModule, OnModuleInit {
|
||||
.forRoutes(
|
||||
"v1/iam/me",
|
||||
"v1/iam/logout",
|
||||
"v1/iam/change-password",
|
||||
"v1/iam/viewports",
|
||||
"v1/iam/permissions/effective",
|
||||
"v1/iam/children",
|
||||
"v1/iam/roles",
|
||||
"v1/iam/permissions",
|
||||
"v1/iam/users",
|
||||
"v1/iam/audit",
|
||||
"v1/iam/totp",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
import { Body, Controller, Get, Post, Req } from "@nestjs/common";
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
Param,
|
||||
} from "@nestjs/common";
|
||||
import { IamService } from "./iam.service.js";
|
||||
import type {
|
||||
TokenPair,
|
||||
@@ -11,22 +20,24 @@ import {
|
||||
loginSchema,
|
||||
refreshTokenSchema,
|
||||
logoutSchema,
|
||||
changePasswordSchema,
|
||||
updateProfileSchema,
|
||||
updateUserSchema,
|
||||
updateUserStatusSchema,
|
||||
listUsersQuerySchema,
|
||||
} from "./iam.dto.js";
|
||||
import { UnauthorizedError } from "../shared/errors/application-error.js";
|
||||
import {
|
||||
Permissions,
|
||||
RequirePermission,
|
||||
} from "../middleware/permission.guard.js";
|
||||
import type { AuthenticatedRequest } from "../middleware/auth.middleware.js";
|
||||
import {
|
||||
type AuthenticatedRequest,
|
||||
extractAuditContext,
|
||||
} from "../middleware/auth.middleware.js";
|
||||
|
||||
/**
|
||||
* IAM REST Controller(双入口之 REST 侧)。
|
||||
*
|
||||
* 路径前缀:/v1/iam(I7 裁决:REST 路径统一加 /v1 前缀)
|
||||
* gateway 路由:/iam/v1/* → iam /v1/iam/*(透传不改路径)
|
||||
*
|
||||
* 公开端点:register / login / refresh / jwks(JwksController)
|
||||
* 鉴权端点:me / viewports / permissions/effective / children / logout
|
||||
*/
|
||||
@Controller("v1/iam")
|
||||
export class IamController {
|
||||
@@ -35,18 +46,20 @@ export class IamController {
|
||||
@Post("register")
|
||||
async register(
|
||||
@Body() body: unknown,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
): Promise<{ success: true; data: { user: UserInfo; tokens: TokenPair } }> {
|
||||
const dto = registerSchema.parse(body);
|
||||
const result = await this.service.register(dto);
|
||||
const result = await this.service.register(dto, extractAuditContext(req));
|
||||
return { success: true as const, data: result };
|
||||
}
|
||||
|
||||
@Post("login")
|
||||
async login(
|
||||
@Body() body: unknown,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
): Promise<{ success: true; data: { user: UserInfo; tokens: TokenPair } }> {
|
||||
const dto = loginSchema.parse(body);
|
||||
const result = await this.service.login(dto);
|
||||
const result = await this.service.login(dto, extractAuditContext(req));
|
||||
return { success: true as const, data: result };
|
||||
}
|
||||
|
||||
@@ -70,7 +83,11 @@ export class IamController {
|
||||
if (!userId) {
|
||||
throw new UnauthorizedError("Missing user identity");
|
||||
}
|
||||
await this.service.logout(dto.refreshToken, userId);
|
||||
await this.service.logout(
|
||||
dto.refreshToken,
|
||||
userId,
|
||||
extractAuditContext(req),
|
||||
);
|
||||
return { success: true as const, data: { success: true } };
|
||||
}
|
||||
|
||||
@@ -87,6 +104,45 @@ export class IamController {
|
||||
return { success: true as const, data: user };
|
||||
}
|
||||
|
||||
@Patch("me")
|
||||
@RequirePermission(Permissions.IAM_USER_READ)
|
||||
async updateProfile(
|
||||
@Body() body: unknown,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
): Promise<{ success: true; data: UserInfo }> {
|
||||
const userId = req.userId;
|
||||
if (!userId) {
|
||||
throw new UnauthorizedError("Missing user identity");
|
||||
}
|
||||
const dto = updateProfileSchema.parse(body);
|
||||
const user = await this.service.updateProfile(
|
||||
userId,
|
||||
dto,
|
||||
extractAuditContext(req),
|
||||
);
|
||||
return { success: true as const, data: user };
|
||||
}
|
||||
|
||||
@Post("change-password")
|
||||
@RequirePermission(Permissions.IAM_USER_READ)
|
||||
async changePassword(
|
||||
@Body() body: unknown,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
): Promise<{ success: true; data: { success: boolean } }> {
|
||||
const userId = req.userId;
|
||||
if (!userId) {
|
||||
throw new UnauthorizedError("Missing user identity");
|
||||
}
|
||||
const dto = changePasswordSchema.parse(body);
|
||||
await this.service.changePassword(
|
||||
userId,
|
||||
dto.currentPassword,
|
||||
dto.newPassword,
|
||||
extractAuditContext(req),
|
||||
);
|
||||
return { success: true as const, data: { success: true } };
|
||||
}
|
||||
|
||||
@Get("viewports")
|
||||
@RequirePermission(Permissions.IAM_USER_READ)
|
||||
async viewports(
|
||||
@@ -125,4 +181,46 @@ export class IamController {
|
||||
const data = await this.service.getChildrenByParent(userId);
|
||||
return { success: true as const, data };
|
||||
}
|
||||
|
||||
@Get("users")
|
||||
@RequirePermission(Permissions.IAM_ROLE_MANAGE)
|
||||
async listUsers(
|
||||
@Query() query: unknown,
|
||||
): Promise<{ success: true; data: { users: UserInfo[]; total: number } }> {
|
||||
const dto = listUsersQuerySchema.parse(query);
|
||||
const data = await this.service.listUsers(dto);
|
||||
return { success: true as const, data };
|
||||
}
|
||||
|
||||
@Patch("users/:id")
|
||||
@RequirePermission(Permissions.IAM_ROLE_MANAGE)
|
||||
async updateUser(
|
||||
@Param("id") id: string,
|
||||
@Body() body: unknown,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
): Promise<{ success: true; data: UserInfo }> {
|
||||
const dto = updateUserSchema.parse(body);
|
||||
const user = await this.service.updateUser(
|
||||
id,
|
||||
dto,
|
||||
extractAuditContext(req),
|
||||
);
|
||||
return { success: true as const, data: user };
|
||||
}
|
||||
|
||||
@Patch("users/:id/status")
|
||||
@RequirePermission(Permissions.IAM_ROLE_MANAGE)
|
||||
async updateUserStatus(
|
||||
@Param("id") id: string,
|
||||
@Body() body: unknown,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
): Promise<{ success: true; data: UserInfo }> {
|
||||
const dto = updateUserStatusSchema.parse(body);
|
||||
const user = await this.service.setUserStatus(
|
||||
id,
|
||||
dto.status,
|
||||
extractAuditContext(req),
|
||||
);
|
||||
return { success: true as const, data: user };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,104 @@ export const logoutSchema = z.object({
|
||||
refreshToken: z.string(),
|
||||
});
|
||||
|
||||
export const changePasswordSchema = z.object({
|
||||
currentPassword: z.string(),
|
||||
newPassword: z.string().min(8).max(72),
|
||||
});
|
||||
|
||||
export const updateProfileSchema = z.object({
|
||||
name: z.string().min(1).max(100).optional(),
|
||||
email: z.string().email().optional(),
|
||||
});
|
||||
|
||||
export const updateUserSchema = z.object({
|
||||
name: z.string().min(1).max(100).optional(),
|
||||
email: z.string().email().optional(),
|
||||
status: z.enum(["active", "inactive", "locked"]).optional(),
|
||||
dataScope: z
|
||||
.enum(["self", "subject", "class", "grade", "school", "all"])
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const updateUserStatusSchema = z.object({
|
||||
status: z.enum(["active", "inactive", "locked"]),
|
||||
});
|
||||
|
||||
export const listUsersQuerySchema = z.object({
|
||||
limit: z.coerce.number().min(1).max(100).default(20),
|
||||
offset: z.coerce.number().min(0).default(0),
|
||||
search: z.string().optional(),
|
||||
status: z.enum(["active", "inactive", "locked"]).optional(),
|
||||
});
|
||||
|
||||
export const createRoleSchema = z.object({
|
||||
name: z.string().min(1).max(50),
|
||||
description: z.string().max(255).optional(),
|
||||
roleType: z.enum(["system", "organization", "temporary"]).optional(),
|
||||
});
|
||||
|
||||
export const updateRoleSchema = z.object({
|
||||
name: z.string().min(1).max(50).optional(),
|
||||
description: z.string().max(255).optional(),
|
||||
});
|
||||
|
||||
export const updateRolePermissionsSchema = z.object({
|
||||
permissionIds: z.array(z.string().uuid()),
|
||||
});
|
||||
|
||||
export const createPermissionSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
resource: z.string().min(1).max(50),
|
||||
action: z.string().min(1).max(50),
|
||||
});
|
||||
|
||||
export const updatePermissionSchema = z.object({
|
||||
name: z.string().min(1).max(100).optional(),
|
||||
resource: z.string().min(1).max(50).optional(),
|
||||
action: z.string().min(1).max(50).optional(),
|
||||
});
|
||||
|
||||
export const createViewportSchema = z.object({
|
||||
roleId: z.string().uuid(),
|
||||
viewportKey: z.string().min(1).max(50),
|
||||
label: z.string().min(1).max(100),
|
||||
route: z.string().min(1).max(200),
|
||||
icon: z.string().max(50).optional(),
|
||||
sortOrder: z.string().max(10).optional(),
|
||||
requiredPermission: z.string().max(100).optional(),
|
||||
level: z.enum(["admin", "teacher", "student", "parent"]).optional(),
|
||||
componentConfig: z.string().optional(),
|
||||
});
|
||||
|
||||
export const updateViewportSchema = z.object({
|
||||
label: z.string().min(1).max(100).optional(),
|
||||
route: z.string().min(1).max(200).optional(),
|
||||
icon: z.string().max(50).optional(),
|
||||
sortOrder: z.string().max(10).optional(),
|
||||
requiredPermission: z.string().max(100).optional(),
|
||||
level: z.enum(["admin", "teacher", "student", "parent"]).optional(),
|
||||
componentConfig: z.string().optional(),
|
||||
});
|
||||
|
||||
export const verifyTotpSchema = z.object({
|
||||
code: z.string().length(6),
|
||||
});
|
||||
|
||||
export type RegisterDto = z.infer<typeof registerSchema>;
|
||||
export type LoginDto = z.infer<typeof loginSchema>;
|
||||
export type LogoutDto = z.infer<typeof logoutSchema>;
|
||||
export type ChangePasswordDto = z.infer<typeof changePasswordSchema>;
|
||||
export type UpdateProfileDto = z.infer<typeof updateProfileSchema>;
|
||||
export type UpdateUserDto = z.infer<typeof updateUserSchema>;
|
||||
export type UpdateUserStatusDto = z.infer<typeof updateUserStatusSchema>;
|
||||
export type ListUsersQueryDto = z.infer<typeof listUsersQuerySchema>;
|
||||
export type CreateRoleDto = z.infer<typeof createRoleSchema>;
|
||||
export type UpdateRoleDto = z.infer<typeof updateRoleSchema>;
|
||||
export type UpdateRolePermissionsDto = z.infer<
|
||||
typeof updateRolePermissionsSchema
|
||||
>;
|
||||
export type CreatePermissionDto = z.infer<typeof createPermissionSchema>;
|
||||
export type UpdatePermissionDto = z.infer<typeof updatePermissionSchema>;
|
||||
export type CreateViewportDto = z.infer<typeof createViewportSchema>;
|
||||
export type UpdateViewportDto = z.infer<typeof updateViewportSchema>;
|
||||
export type VerifyTotpDto = z.infer<typeof verifyTotpSchema>;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { eq, inArray, and } from "drizzle-orm";
|
||||
import { eq, inArray, and, like, or, sql } from "drizzle-orm";
|
||||
import { getDb } from "../config/database.js";
|
||||
import {
|
||||
users,
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
studentGuardians,
|
||||
userAuditLog,
|
||||
passwordHistory,
|
||||
userTotp,
|
||||
totpBackupCodes,
|
||||
} from "./iam.schema.js";
|
||||
import type {
|
||||
User,
|
||||
@@ -315,4 +317,351 @@ export class IamRepository {
|
||||
const id = crypto.randomUUID();
|
||||
await db.insert(passwordHistory).values({ id, userId, passwordHash });
|
||||
}
|
||||
|
||||
// ============ 用户列表与更新(admin) ============
|
||||
|
||||
async listUsers(options: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
search?: string;
|
||||
status?: string;
|
||||
}): Promise<User[]> {
|
||||
const db = getDb();
|
||||
let query = db.select().from(users).$dynamic();
|
||||
|
||||
if (options.search) {
|
||||
const pattern = `%${options.search}%`;
|
||||
query = query.where(
|
||||
or(like(users.email, pattern), like(users.name, pattern))!,
|
||||
);
|
||||
}
|
||||
if (options.status) {
|
||||
query = query.where(eq(users.status, options.status));
|
||||
}
|
||||
|
||||
const limit = options.limit ?? 20;
|
||||
const offset = options.offset ?? 0;
|
||||
return query.limit(limit).offset(offset);
|
||||
}
|
||||
|
||||
async countUsers(options: {
|
||||
search?: string;
|
||||
status?: string;
|
||||
}): Promise<number> {
|
||||
const db = getDb();
|
||||
const conditions = [];
|
||||
if (options.search) {
|
||||
const pattern = `%${options.search}%`;
|
||||
conditions.push(
|
||||
or(like(users.email, pattern), like(users.name, pattern)),
|
||||
);
|
||||
}
|
||||
if (options.status) {
|
||||
conditions.push(eq(users.status, options.status));
|
||||
}
|
||||
|
||||
const [result] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(users)
|
||||
.where(conditions.length > 0 ? and(...conditions) : undefined);
|
||||
return result?.count ?? 0;
|
||||
}
|
||||
|
||||
async updateUser(
|
||||
userId: string,
|
||||
data: {
|
||||
name?: string;
|
||||
email?: string;
|
||||
status?: string;
|
||||
dataScope?: DataScope;
|
||||
},
|
||||
): Promise<User | undefined> {
|
||||
const db = getDb();
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (data.name !== undefined) updateData.name = data.name;
|
||||
if (data.email !== undefined) updateData.email = data.email;
|
||||
if (data.status !== undefined) updateData.status = data.status;
|
||||
if (data.dataScope !== undefined) updateData.dataScope = data.dataScope;
|
||||
|
||||
if (Object.keys(updateData).length === 0) {
|
||||
return this.findUserById(userId);
|
||||
}
|
||||
|
||||
await db.update(users).set(updateData).where(eq(users.id, userId));
|
||||
return this.findUserById(userId);
|
||||
}
|
||||
|
||||
// ============ 角色 CRUD ============
|
||||
|
||||
async findRoleById(id: string): Promise<Role | undefined> {
|
||||
const db = getDb();
|
||||
const [result] = await db.select().from(roles).where(eq(roles.id, id));
|
||||
return result;
|
||||
}
|
||||
|
||||
async createRole(data: {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
roleType?: "system" | "organization" | "temporary";
|
||||
level?: number;
|
||||
}): Promise<Role> {
|
||||
const db = getDb();
|
||||
await db.insert(roles).values(data);
|
||||
const [result] = await db.select().from(roles).where(eq(roles.id, data.id));
|
||||
if (!result) {
|
||||
throw new DatabaseError("Failed to create role");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async updateRole(
|
||||
roleId: string,
|
||||
data: { name?: string; description?: string },
|
||||
): Promise<Role | undefined> {
|
||||
const db = getDb();
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (data.name !== undefined) updateData.name = data.name;
|
||||
if (data.description !== undefined)
|
||||
updateData.description = data.description;
|
||||
|
||||
if (Object.keys(updateData).length === 0) {
|
||||
return this.findRoleById(roleId);
|
||||
}
|
||||
|
||||
await db.update(roles).set(updateData).where(eq(roles.id, roleId));
|
||||
return this.findRoleById(roleId);
|
||||
}
|
||||
|
||||
async getUserIdsByRole(roleId: string): Promise<string[]> {
|
||||
const db = getDb();
|
||||
const rows = await db
|
||||
.select({ userId: userRoles.userId })
|
||||
.from(userRoles)
|
||||
.where(eq(userRoles.roleId, roleId));
|
||||
return rows.map((r) => r.userId);
|
||||
}
|
||||
|
||||
// ============ 权限 CRUD ============
|
||||
|
||||
async findPermissionByName(name: string): Promise<Permission | undefined> {
|
||||
const db = getDb();
|
||||
const [result] = await db
|
||||
.select()
|
||||
.from(permissions)
|
||||
.where(eq(permissions.name, name));
|
||||
return result;
|
||||
}
|
||||
|
||||
async findPermissionById(id: string): Promise<Permission | undefined> {
|
||||
const db = getDb();
|
||||
const [result] = await db
|
||||
.select()
|
||||
.from(permissions)
|
||||
.where(eq(permissions.id, id));
|
||||
return result;
|
||||
}
|
||||
|
||||
async createPermission(data: {
|
||||
id: string;
|
||||
name: string;
|
||||
resource: string;
|
||||
action: string;
|
||||
}): Promise<Permission> {
|
||||
const db = getDb();
|
||||
await db.insert(permissions).values(data);
|
||||
const [result] = await db
|
||||
.select()
|
||||
.from(permissions)
|
||||
.where(eq(permissions.id, data.id));
|
||||
if (!result) {
|
||||
throw new DatabaseError("Failed to create permission");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async updatePermission(
|
||||
id: string,
|
||||
data: { name?: string; resource?: string; action?: string },
|
||||
): Promise<Permission | undefined> {
|
||||
const db = getDb();
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (data.name !== undefined) updateData.name = data.name;
|
||||
if (data.resource !== undefined) updateData.resource = data.resource;
|
||||
if (data.action !== undefined) updateData.action = data.action;
|
||||
|
||||
if (Object.keys(updateData).length === 0) {
|
||||
return this.findPermissionById(id);
|
||||
}
|
||||
|
||||
await db.update(permissions).set(updateData).where(eq(permissions.id, id));
|
||||
return this.findPermissionById(id);
|
||||
}
|
||||
|
||||
async deletePermission(id: string): Promise<void> {
|
||||
const db = getDb();
|
||||
// 先删除角色-权限关联
|
||||
await db
|
||||
.delete(rolePermissions)
|
||||
.where(eq(rolePermissions.permissionId, id));
|
||||
await db.delete(permissions).where(eq(permissions.id, id));
|
||||
}
|
||||
|
||||
async grantPermission(roleId: string, permissionId: string): Promise<void> {
|
||||
const db = getDb();
|
||||
// 检查是否已存在,避免唯一键冲突
|
||||
const [existing] = await db
|
||||
.select({ roleId: rolePermissions.roleId })
|
||||
.from(rolePermissions)
|
||||
.where(
|
||||
and(
|
||||
eq(rolePermissions.roleId, roleId),
|
||||
eq(rolePermissions.permissionId, permissionId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (existing) {
|
||||
return;
|
||||
}
|
||||
await db.insert(rolePermissions).values({ roleId, permissionId });
|
||||
}
|
||||
|
||||
async revokePermission(roleId: string, permissionId: string): Promise<void> {
|
||||
const db = getDb();
|
||||
await db
|
||||
.delete(rolePermissions)
|
||||
.where(
|
||||
and(
|
||||
eq(rolePermissions.roleId, roleId),
|
||||
eq(rolePermissions.permissionId, permissionId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ============ 视口 CRUD ============
|
||||
|
||||
async findViewportById(id: string): Promise<RoleViewport | undefined> {
|
||||
const db = getDb();
|
||||
const [result] = await db
|
||||
.select()
|
||||
.from(roleViewports)
|
||||
.where(eq(roleViewports.id, id));
|
||||
return result;
|
||||
}
|
||||
|
||||
async createViewport(data: {
|
||||
id: string;
|
||||
roleId: string;
|
||||
viewportKey: string;
|
||||
label: string;
|
||||
route: string;
|
||||
icon?: string;
|
||||
sortOrder?: string;
|
||||
requiredPermission?: string;
|
||||
level?: "admin" | "teacher" | "student" | "parent";
|
||||
componentConfig?: string;
|
||||
}): Promise<RoleViewport> {
|
||||
const db = getDb();
|
||||
await db.insert(roleViewports).values(data);
|
||||
const [result] = await db
|
||||
.select()
|
||||
.from(roleViewports)
|
||||
.where(eq(roleViewports.id, data.id));
|
||||
if (!result) {
|
||||
throw new DatabaseError("Failed to create viewport");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async updateViewport(
|
||||
id: string,
|
||||
data: {
|
||||
label?: string;
|
||||
route?: string;
|
||||
icon?: string;
|
||||
sortOrder?: string;
|
||||
requiredPermission?: string;
|
||||
level?: "admin" | "teacher" | "student" | "parent";
|
||||
componentConfig?: string;
|
||||
},
|
||||
): Promise<RoleViewport | undefined> {
|
||||
const db = getDb();
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (data.label !== undefined) updateData.label = data.label;
|
||||
if (data.route !== undefined) updateData.route = data.route;
|
||||
if (data.icon !== undefined) updateData.icon = data.icon;
|
||||
if (data.sortOrder !== undefined) updateData.sortOrder = data.sortOrder;
|
||||
if (data.requiredPermission !== undefined)
|
||||
updateData.requiredPermission = data.requiredPermission;
|
||||
if (data.level !== undefined) updateData.level = data.level;
|
||||
if (data.componentConfig !== undefined)
|
||||
updateData.componentConfig = data.componentConfig;
|
||||
|
||||
if (Object.keys(updateData).length === 0) {
|
||||
return this.findViewportById(id);
|
||||
}
|
||||
|
||||
await db
|
||||
.update(roleViewports)
|
||||
.set(updateData)
|
||||
.where(eq(roleViewports.id, id));
|
||||
return this.findViewportById(id);
|
||||
}
|
||||
|
||||
async deleteViewport(id: string): Promise<void> {
|
||||
const db = getDb();
|
||||
await db.delete(roleViewports).where(eq(roleViewports.id, id));
|
||||
}
|
||||
|
||||
// ============ TOTP 2FA ============
|
||||
|
||||
async upsertTotpSecret(
|
||||
userId: string,
|
||||
secret: string,
|
||||
status: "pending" | "active",
|
||||
): Promise<void> {
|
||||
const db = getDb();
|
||||
const id = crypto.randomUUID();
|
||||
// 使用 onDuplicateKeyUpdate 处理 upsert
|
||||
await db
|
||||
.insert(userTotp)
|
||||
.values({ id, userId, secret, status })
|
||||
.onDuplicateKeyUpdate({
|
||||
set: { secret, status, updatedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
async getTotpSecret(
|
||||
userId: string,
|
||||
): Promise<{ secret: string; status: string } | undefined> {
|
||||
const db = getDb();
|
||||
const [result] = await db
|
||||
.select({ secret: userTotp.secret, status: userTotp.status })
|
||||
.from(userTotp)
|
||||
.where(eq(userTotp.userId, userId));
|
||||
return result;
|
||||
}
|
||||
|
||||
async deleteTotpSecret(userId: string): Promise<void> {
|
||||
const db = getDb();
|
||||
await db.delete(userTotp).where(eq(userTotp.userId, userId));
|
||||
}
|
||||
|
||||
async setTotpBackupCodes(
|
||||
userId: string,
|
||||
codeHashes: string[],
|
||||
): Promise<void> {
|
||||
const db = getDb();
|
||||
// 先删除旧备份码
|
||||
await db.delete(totpBackupCodes).where(eq(totpBackupCodes.userId, userId));
|
||||
// 插入新备份码
|
||||
const rows = codeHashes.map((codeHash) => ({
|
||||
id: crypto.randomUUID(),
|
||||
userId,
|
||||
codeHash,
|
||||
}));
|
||||
if (rows.length > 0) {
|
||||
await db.insert(totpBackupCodes).values(rows);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,6 +203,40 @@ export const passwordHistory = mysqlTable(
|
||||
}),
|
||||
);
|
||||
|
||||
// TOTP 2FA 密钥表(RFC 6238)
|
||||
export const TOTP_STATUSES = ["pending", "active"] as const;
|
||||
export type TotpStatus = (typeof TOTP_STATUSES)[number];
|
||||
|
||||
export const userTotp = mysqlTable(
|
||||
"iam_user_totp",
|
||||
{
|
||||
id: char("id", { length: 36 }).notNull().primaryKey(),
|
||||
userId: char("user_id", { length: 36 }).notNull().unique(),
|
||||
secret: varchar("secret", { length: 128 }).notNull(),
|
||||
status: mysqlEnum("status", TOTP_STATUSES).notNull().default("pending"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(),
|
||||
},
|
||||
(table) => ({
|
||||
userIdx: uniqueIndex("uniq_iam_user_totp_user").on(table.userId),
|
||||
}),
|
||||
);
|
||||
|
||||
// TOTP 备份码表(10 个一次性使用)
|
||||
export const totpBackupCodes = mysqlTable(
|
||||
"iam_totp_backup_codes",
|
||||
{
|
||||
id: char("id", { length: 36 }).notNull().primaryKey(),
|
||||
userId: char("user_id", { length: 36 }).notNull(),
|
||||
codeHash: varchar("code_hash", { length: 255 }).notNull(),
|
||||
usedAt: timestamp("used_at"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
userIdx: index("idx_iam_totp_backup_codes_user").on(table.userId),
|
||||
}),
|
||||
);
|
||||
|
||||
export type User = typeof users.$inferSelect;
|
||||
export type Role = typeof roles.$inferSelect;
|
||||
export type Permission = typeof permissions.$inferSelect;
|
||||
@@ -210,3 +244,5 @@ export type RoleViewport = typeof roleViewports.$inferSelect;
|
||||
export type StudentGuardian = typeof studentGuardians.$inferSelect;
|
||||
export type AuditLog = typeof userAuditLog.$inferSelect;
|
||||
export type PasswordHistoryEntry = typeof passwordHistory.$inferSelect;
|
||||
export type UserTotp = typeof userTotp.$inferSelect;
|
||||
export type TotpBackupCode = typeof totpBackupCodes.$inferSelect;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import bcrypt from "bcrypt";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { createHmac, randomBytes, randomInt } from "node:crypto";
|
||||
import { Inject, Injectable } from "@nestjs/common";
|
||||
import { IamRepository } from "./iam.repository.js";
|
||||
import { JwksService } from "./jwks.service.js";
|
||||
@@ -7,6 +8,7 @@ import {
|
||||
ConflictError,
|
||||
UnauthorizedError,
|
||||
NotFoundError,
|
||||
ValidationError,
|
||||
} from "../shared/errors/application-error.js";
|
||||
import { env } from "../config/env.js";
|
||||
import { getJwtKeyPair, ttlToSeconds } from "../config/jwt.js";
|
||||
@@ -14,7 +16,7 @@ import { OutboxService } from "@edu/shared-ts/outbox";
|
||||
import { TokenBlacklistService } from "../shared/cache/token-blacklist.service.js";
|
||||
import { PermissionCacheService } from "../shared/cache/permission-cache.service.js";
|
||||
import type { RegisterDto, LoginDto } from "./iam.dto.js";
|
||||
import type { User } from "./iam.schema.js";
|
||||
import type { User, DataScope } from "./iam.schema.js";
|
||||
|
||||
// 默认角色(种子数据 role_id)
|
||||
const DEFAULT_ROLE_ID = "00000000-0000-0000-0000-000000000002"; // teacher
|
||||
@@ -42,6 +44,8 @@ export interface ViewportItem {
|
||||
icon: string | null;
|
||||
sortOrder: string;
|
||||
requiredPermission: string | null;
|
||||
level?: string;
|
||||
componentConfig?: string | null;
|
||||
}
|
||||
|
||||
export interface ChildInfo {
|
||||
@@ -50,6 +54,15 @@ export interface ChildInfo {
|
||||
relation: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 审计上下文:从 HTTP 请求头中提取的客户端信息(president §5.5).
|
||||
*/
|
||||
export interface AuditContext {
|
||||
ip?: string | null;
|
||||
userAgent?: string | null;
|
||||
traceId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* IAM Application Service(双入口:REST Controller + gRPC Controller 共用)。
|
||||
*
|
||||
@@ -79,7 +92,10 @@ export class IamService {
|
||||
|
||||
async register(
|
||||
dto: RegisterDto,
|
||||
context?: AuditContext,
|
||||
): Promise<{ user: UserInfo; tokens: TokenPair }> {
|
||||
validatePasswordStrength(dto.password);
|
||||
|
||||
const existing = await this.repository.findUserByEmail(dto.email);
|
||||
if (existing) {
|
||||
throw new ConflictError("Email already registered");
|
||||
@@ -95,6 +111,7 @@ export class IamService {
|
||||
});
|
||||
|
||||
await this.repository.assignRole(userId, DEFAULT_ROLE_ID);
|
||||
await this.repository.addPasswordHistory(userId, passwordHash);
|
||||
|
||||
const { tokens } = await this.issueTokens(user);
|
||||
|
||||
@@ -118,17 +135,28 @@ export class IamService {
|
||||
);
|
||||
|
||||
// 审计日志
|
||||
await this.writeAuditLog(userId, "create", "user", userId, null, {
|
||||
id: userId,
|
||||
email: dto.email,
|
||||
name: dto.name,
|
||||
});
|
||||
await this.writeAuditLog(
|
||||
userId,
|
||||
"create",
|
||||
"user",
|
||||
userId,
|
||||
null,
|
||||
{
|
||||
id: userId,
|
||||
email: dto.email,
|
||||
name: dto.name,
|
||||
},
|
||||
context,
|
||||
);
|
||||
|
||||
const info = await this.buildUserInfo(user);
|
||||
return { user: info, tokens };
|
||||
}
|
||||
|
||||
async login(dto: LoginDto): Promise<{ user: UserInfo; tokens: TokenPair }> {
|
||||
async login(
|
||||
dto: LoginDto,
|
||||
context?: AuditContext,
|
||||
): Promise<{ user: UserInfo; tokens: TokenPair }> {
|
||||
const user = await this.repository.findUserByEmail(dto.email);
|
||||
if (!user) {
|
||||
throw new UnauthorizedError("Invalid credentials");
|
||||
@@ -146,7 +174,15 @@ export class IamService {
|
||||
const { tokens } = await this.issueTokens(user);
|
||||
|
||||
// 审计日志
|
||||
await this.writeAuditLog(user.id, "login", "user", user.id, null, null);
|
||||
await this.writeAuditLog(
|
||||
user.id,
|
||||
"login",
|
||||
"user",
|
||||
user.id,
|
||||
null,
|
||||
null,
|
||||
context,
|
||||
);
|
||||
|
||||
const info = await this.buildUserInfo(user);
|
||||
return { user: info, tokens };
|
||||
@@ -201,7 +237,11 @@ export class IamService {
|
||||
return this.issueTokens(user).then((r) => r.tokens);
|
||||
}
|
||||
|
||||
async logout(refreshToken: string, userId: string): Promise<void> {
|
||||
async logout(
|
||||
refreshToken: string,
|
||||
userId: string,
|
||||
context?: AuditContext,
|
||||
): Promise<void> {
|
||||
const keyPair = getJwtKeyPair();
|
||||
try {
|
||||
const decoded = jwt.verify(refreshToken, keyPair.publicKey, {
|
||||
@@ -222,7 +262,15 @@ export class IamService {
|
||||
}
|
||||
|
||||
await this.repository.revokeAllUserTokens(userId);
|
||||
await this.writeAuditLog(userId, "logout", "user", userId, null, null);
|
||||
await this.writeAuditLog(
|
||||
userId,
|
||||
"logout",
|
||||
"user",
|
||||
userId,
|
||||
null,
|
||||
null,
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
// ============ 用户信息类 ============
|
||||
@@ -235,11 +283,220 @@ export class IamService {
|
||||
return this.buildUserInfo(user);
|
||||
}
|
||||
|
||||
/**
|
||||
* GetUserProfile:GetUserInfo 的语义别名(student-bff 期望的命名).
|
||||
*/
|
||||
async getUserProfile(userId: string): Promise<UserInfo> {
|
||||
return this.getUserInfo(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 自助修改个人资料.
|
||||
*/
|
||||
async updateProfile(
|
||||
userId: string,
|
||||
data: { name?: string; email?: string },
|
||||
context?: AuditContext,
|
||||
): Promise<UserInfo> {
|
||||
const user = await this.repository.findUserById(userId);
|
||||
if (!user) {
|
||||
throw new NotFoundError("User", userId);
|
||||
}
|
||||
|
||||
if (data.email && data.email !== user.email) {
|
||||
const existing = await this.repository.findUserByEmail(data.email);
|
||||
if (existing) {
|
||||
throw new ConflictError("Email already registered");
|
||||
}
|
||||
}
|
||||
|
||||
const beforeState = { name: user.name, email: user.email };
|
||||
const updated = await this.repository.updateUser(userId, data);
|
||||
if (!updated) {
|
||||
throw new NotFoundError("User", userId);
|
||||
}
|
||||
|
||||
await this.writeAuditLog(
|
||||
userId,
|
||||
"update",
|
||||
"user",
|
||||
userId,
|
||||
beforeState,
|
||||
{ name: updated.name, email: updated.email },
|
||||
context,
|
||||
);
|
||||
|
||||
return this.buildUserInfo(updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* 自助修改密码:校验当前密码 + 强度 + 重用限制 + 撤销所有 token.
|
||||
*/
|
||||
async changePassword(
|
||||
userId: string,
|
||||
currentPassword: string,
|
||||
newPassword: string,
|
||||
context?: AuditContext,
|
||||
): Promise<void> {
|
||||
validatePasswordStrength(newPassword);
|
||||
|
||||
const user = await this.repository.findUserById(userId);
|
||||
if (!user) {
|
||||
throw new NotFoundError("User", userId);
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(currentPassword, user.passwordHash);
|
||||
if (!valid) {
|
||||
throw new UnauthorizedError("Current password is incorrect");
|
||||
}
|
||||
|
||||
// 检查密码重用(最近 5 次)
|
||||
const history = await this.repository.getPasswordHistory(userId, 5);
|
||||
for (const oldHash of history) {
|
||||
if (await bcrypt.compare(newPassword, oldHash)) {
|
||||
throw new ValidationError(
|
||||
"Password has been used recently. Please choose a different one.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const newHash = await bcrypt.hash(newPassword, 12);
|
||||
await this.repository.updatePassword(userId, newHash);
|
||||
await this.repository.addPasswordHistory(userId, newHash);
|
||||
await this.repository.revokeAllUserTokens(userId);
|
||||
|
||||
await this.writeAuditLog(
|
||||
userId,
|
||||
"change_password",
|
||||
"user",
|
||||
userId,
|
||||
null,
|
||||
null,
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
async batchGetUsers(userIds: string[]): Promise<UserInfo[]> {
|
||||
const users = await this.repository.batchFindUsers(userIds);
|
||||
return Promise.all(users.map((u) => this.buildUserInfo(u)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户列表查询(admin 使用,支持分页 + 搜索 + 状态过滤).
|
||||
*/
|
||||
async listUsers(options: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
search?: string;
|
||||
status?: string;
|
||||
}): Promise<{ users: UserInfo[]; total: number }> {
|
||||
const [users, total] = await Promise.all([
|
||||
this.repository.listUsers(options),
|
||||
this.repository.countUsers({
|
||||
search: options.search,
|
||||
status: options.status,
|
||||
}),
|
||||
]);
|
||||
const userInfos = await Promise.all(
|
||||
users.map((u) => this.buildUserInfo(u)),
|
||||
);
|
||||
return { users: userInfos, total };
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员更新用户.
|
||||
*/
|
||||
async updateUser(
|
||||
userId: string,
|
||||
data: {
|
||||
name?: string;
|
||||
email?: string;
|
||||
status?: string;
|
||||
dataScope?: DataScope;
|
||||
},
|
||||
context?: AuditContext,
|
||||
): Promise<UserInfo> {
|
||||
const user = await this.repository.findUserById(userId);
|
||||
if (!user) {
|
||||
throw new NotFoundError("User", userId);
|
||||
}
|
||||
|
||||
if (data.email && data.email !== user.email) {
|
||||
const existing = await this.repository.findUserByEmail(data.email);
|
||||
if (existing) {
|
||||
throw new ConflictError("Email already registered");
|
||||
}
|
||||
}
|
||||
|
||||
const beforeState = {
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
status: user.status,
|
||||
dataScope: user.dataScope,
|
||||
};
|
||||
|
||||
const updated = await this.repository.updateUser(userId, data);
|
||||
if (!updated) {
|
||||
throw new NotFoundError("User", userId);
|
||||
}
|
||||
|
||||
if (data.dataScope && data.dataScope !== user.dataScope) {
|
||||
await this.permissionCache.invalidate(userId);
|
||||
}
|
||||
|
||||
await this.writeAuditLog(
|
||||
userId,
|
||||
"update",
|
||||
"user",
|
||||
userId,
|
||||
beforeState,
|
||||
{
|
||||
name: updated.name,
|
||||
email: updated.email,
|
||||
status: updated.status,
|
||||
dataScope: updated.dataScope,
|
||||
},
|
||||
context,
|
||||
);
|
||||
|
||||
return this.buildUserInfo(updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换用户状态.
|
||||
*/
|
||||
async setUserStatus(
|
||||
userId: string,
|
||||
status: string,
|
||||
context?: AuditContext,
|
||||
): Promise<UserInfo> {
|
||||
const validStatuses = ["active", "inactive", "locked"];
|
||||
if (!validStatuses.includes(status)) {
|
||||
throw new ValidationError(`Invalid status: ${status}`);
|
||||
}
|
||||
|
||||
const user = await this.repository.findUserById(userId);
|
||||
if (!user) {
|
||||
throw new NotFoundError("User", userId);
|
||||
}
|
||||
|
||||
const beforeState = { status: user.status };
|
||||
await this.repository.updateUserStatus(userId, status);
|
||||
const updated = await this.repository.findUserById(userId);
|
||||
|
||||
await this.writeAuditLog(
|
||||
userId,
|
||||
"update_status",
|
||||
"user",
|
||||
userId,
|
||||
beforeState,
|
||||
{ status },
|
||||
context,
|
||||
);
|
||||
|
||||
return this.buildUserInfo(updated!);
|
||||
}
|
||||
|
||||
// ============ 权限与视口类 ============
|
||||
|
||||
async getEffectivePermissions(userId: string): Promise<string[]> {
|
||||
@@ -291,6 +548,8 @@ export class IamService {
|
||||
icon: vp.icon,
|
||||
sortOrder: vp.sortOrder,
|
||||
requiredPermission: vp.requiredPermission,
|
||||
level: vp.level,
|
||||
componentConfig: vp.componentConfig,
|
||||
}))
|
||||
.sort((a, b) => a.sortOrder.localeCompare(b.sortOrder));
|
||||
}
|
||||
@@ -320,6 +579,442 @@ export class IamService {
|
||||
return this.repository.getAllPermissions();
|
||||
}
|
||||
|
||||
// ============ 角色 CRUD ============
|
||||
|
||||
async createRole(
|
||||
data: {
|
||||
name: string;
|
||||
description?: string;
|
||||
roleType?: "system" | "organization" | "temporary";
|
||||
},
|
||||
context?: AuditContext,
|
||||
) {
|
||||
const existing = await this.repository.findRoleByName(data.name);
|
||||
if (existing) {
|
||||
throw new ConflictError(`Role ${data.name} already exists`);
|
||||
}
|
||||
|
||||
const role = await this.repository.createRole({
|
||||
id: crypto.randomUUID(),
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
roleType: data.roleType ?? "organization",
|
||||
level:
|
||||
data.roleType === "system" ? 0 : data.roleType === "temporary" ? 2 : 1,
|
||||
});
|
||||
|
||||
await this.writeAuditLog(
|
||||
"system",
|
||||
"create",
|
||||
"role",
|
||||
role.id,
|
||||
null,
|
||||
role,
|
||||
context,
|
||||
);
|
||||
return role;
|
||||
}
|
||||
|
||||
async updateRole(
|
||||
roleId: string,
|
||||
data: { name?: string; description?: string },
|
||||
context?: AuditContext,
|
||||
) {
|
||||
const role = await this.repository.findRoleById(roleId);
|
||||
if (!role) {
|
||||
throw new NotFoundError("Role", roleId);
|
||||
}
|
||||
|
||||
const beforeState = { ...role };
|
||||
const updated = await this.repository.updateRole(roleId, data);
|
||||
if (!updated) {
|
||||
throw new NotFoundError("Role", roleId);
|
||||
}
|
||||
|
||||
if (data.name && data.name !== role.name) {
|
||||
const userIds = await this.repository.getUserIdsByRole(roleId);
|
||||
await Promise.all(
|
||||
userIds.map((uid) => this.permissionCache.invalidate(uid)),
|
||||
);
|
||||
}
|
||||
|
||||
await this.writeAuditLog(
|
||||
"system",
|
||||
"update",
|
||||
"role",
|
||||
roleId,
|
||||
beforeState,
|
||||
updated,
|
||||
context,
|
||||
);
|
||||
return updated;
|
||||
}
|
||||
|
||||
async updateRolePermissions(
|
||||
roleId: string,
|
||||
permissionIds: string[],
|
||||
context?: AuditContext,
|
||||
): Promise<void> {
|
||||
const role = await this.repository.findRoleById(roleId);
|
||||
if (!role) {
|
||||
throw new NotFoundError("Role", roleId);
|
||||
}
|
||||
|
||||
const existingUserIds = await this.repository.getUserIdsByRole(roleId);
|
||||
|
||||
for (const permId of permissionIds) {
|
||||
try {
|
||||
await this.repository.grantPermission(roleId, permId);
|
||||
} catch {
|
||||
// 已存在的关联会因唯一约束失败,忽略
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
existingUserIds.map((uid) => this.permissionCache.invalidate(uid)),
|
||||
);
|
||||
|
||||
await this.writeAuditLog(
|
||||
"system",
|
||||
"update_permissions",
|
||||
"role",
|
||||
roleId,
|
||||
null,
|
||||
{ permissionIds },
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
// ============ 权限 CRUD ============
|
||||
|
||||
async createPermission(
|
||||
data: { name: string; resource: string; action: string },
|
||||
context?: AuditContext,
|
||||
) {
|
||||
const existing = await this.repository.findPermissionByName(data.name);
|
||||
if (existing) {
|
||||
throw new ConflictError(`Permission ${data.name} already exists`);
|
||||
}
|
||||
|
||||
const permission = await this.repository.createPermission({
|
||||
id: crypto.randomUUID(),
|
||||
name: data.name,
|
||||
resource: data.resource,
|
||||
action: data.action,
|
||||
});
|
||||
|
||||
await this.writeAuditLog(
|
||||
"system",
|
||||
"create",
|
||||
"permission",
|
||||
permission.id,
|
||||
null,
|
||||
permission,
|
||||
context,
|
||||
);
|
||||
return permission;
|
||||
}
|
||||
|
||||
async updatePermission(
|
||||
permissionId: string,
|
||||
data: { name?: string; resource?: string; action?: string },
|
||||
context?: AuditContext,
|
||||
) {
|
||||
const permission = await this.repository.findPermissionById(permissionId);
|
||||
if (!permission) {
|
||||
throw new NotFoundError("Permission", permissionId);
|
||||
}
|
||||
|
||||
const beforeState = { ...permission };
|
||||
const updated = await this.repository.updatePermission(permissionId, data);
|
||||
if (!updated) {
|
||||
throw new NotFoundError("Permission", permissionId);
|
||||
}
|
||||
|
||||
await this.writeAuditLog(
|
||||
"system",
|
||||
"update",
|
||||
"permission",
|
||||
permissionId,
|
||||
beforeState,
|
||||
updated,
|
||||
context,
|
||||
);
|
||||
return updated;
|
||||
}
|
||||
|
||||
async deletePermission(
|
||||
permissionId: string,
|
||||
context?: AuditContext,
|
||||
): Promise<void> {
|
||||
const permission = await this.repository.findPermissionById(permissionId);
|
||||
if (!permission) {
|
||||
throw new NotFoundError("Permission", permissionId);
|
||||
}
|
||||
|
||||
await this.repository.deletePermission(permissionId);
|
||||
await this.writeAuditLog(
|
||||
"system",
|
||||
"delete",
|
||||
"permission",
|
||||
permissionId,
|
||||
permission,
|
||||
null,
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
async grantPermissionToRole(
|
||||
roleId: string,
|
||||
permissionId: string,
|
||||
context?: AuditContext,
|
||||
): Promise<void> {
|
||||
const role = await this.repository.findRoleById(roleId);
|
||||
if (!role) {
|
||||
throw new NotFoundError("Role", roleId);
|
||||
}
|
||||
const permission = await this.repository.findPermissionById(permissionId);
|
||||
if (!permission) {
|
||||
throw new NotFoundError("Permission", permissionId);
|
||||
}
|
||||
|
||||
await this.repository.grantPermission(roleId, permissionId);
|
||||
const userIds = await this.repository.getUserIdsByRole(roleId);
|
||||
await Promise.all(
|
||||
userIds.map((uid) => this.permissionCache.invalidate(uid)),
|
||||
);
|
||||
|
||||
await this.writeAuditLog(
|
||||
"system",
|
||||
"grant_permission",
|
||||
"role",
|
||||
roleId,
|
||||
null,
|
||||
{ permissionId, permissionName: permission.name },
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
async revokePermissionFromRole(
|
||||
roleId: string,
|
||||
permissionId: string,
|
||||
context?: AuditContext,
|
||||
): Promise<void> {
|
||||
const role = await this.repository.findRoleById(roleId);
|
||||
if (!role) {
|
||||
throw new NotFoundError("Role", roleId);
|
||||
}
|
||||
const permission = await this.repository.findPermissionById(permissionId);
|
||||
if (!permission) {
|
||||
throw new NotFoundError("Permission", permissionId);
|
||||
}
|
||||
|
||||
await this.repository.revokePermission(roleId, permissionId);
|
||||
const userIds = await this.repository.getUserIdsByRole(roleId);
|
||||
await Promise.all(
|
||||
userIds.map((uid) => this.permissionCache.invalidate(uid)),
|
||||
);
|
||||
|
||||
await this.writeAuditLog(
|
||||
"system",
|
||||
"revoke_permission",
|
||||
"role",
|
||||
roleId,
|
||||
null,
|
||||
{ permissionId, permissionName: permission.name },
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
// ============ 视口 CRUD ============
|
||||
|
||||
async createViewport(
|
||||
data: {
|
||||
roleId: string;
|
||||
viewportKey: string;
|
||||
label: string;
|
||||
route: string;
|
||||
icon?: string;
|
||||
sortOrder?: string;
|
||||
requiredPermission?: string;
|
||||
level?: "admin" | "teacher" | "student" | "parent";
|
||||
componentConfig?: string;
|
||||
},
|
||||
context?: AuditContext,
|
||||
) {
|
||||
const role = await this.repository.findRoleById(data.roleId);
|
||||
if (!role) {
|
||||
throw new NotFoundError("Role", data.roleId);
|
||||
}
|
||||
|
||||
const viewport = await this.repository.createViewport({
|
||||
id: crypto.randomUUID(),
|
||||
...data,
|
||||
});
|
||||
|
||||
const userIds = await this.repository.getUserIdsByRole(data.roleId);
|
||||
await Promise.all(
|
||||
userIds.map((uid) => this.permissionCache.invalidate(uid)),
|
||||
);
|
||||
|
||||
await this.writeAuditLog(
|
||||
"system",
|
||||
"create",
|
||||
"viewport",
|
||||
viewport.id,
|
||||
null,
|
||||
viewport,
|
||||
context,
|
||||
);
|
||||
return viewport;
|
||||
}
|
||||
|
||||
async updateViewport(
|
||||
viewportId: string,
|
||||
data: {
|
||||
label?: string;
|
||||
route?: string;
|
||||
icon?: string;
|
||||
sortOrder?: string;
|
||||
requiredPermission?: string;
|
||||
level?: "admin" | "teacher" | "student" | "parent";
|
||||
componentConfig?: string;
|
||||
},
|
||||
context?: AuditContext,
|
||||
) {
|
||||
const viewport = await this.repository.findViewportById(viewportId);
|
||||
if (!viewport) {
|
||||
throw new NotFoundError("Viewport", viewportId);
|
||||
}
|
||||
|
||||
const beforeState = { ...viewport };
|
||||
const updated = await this.repository.updateViewport(viewportId, data);
|
||||
if (!updated) {
|
||||
throw new NotFoundError("Viewport", viewportId);
|
||||
}
|
||||
|
||||
const userIds = await this.repository.getUserIdsByRole(viewport.roleId);
|
||||
await Promise.all(
|
||||
userIds.map((uid) => this.permissionCache.invalidate(uid)),
|
||||
);
|
||||
|
||||
await this.writeAuditLog(
|
||||
"system",
|
||||
"update",
|
||||
"viewport",
|
||||
viewportId,
|
||||
beforeState,
|
||||
updated,
|
||||
context,
|
||||
);
|
||||
return updated;
|
||||
}
|
||||
|
||||
async deleteViewport(
|
||||
viewportId: string,
|
||||
context?: AuditContext,
|
||||
): Promise<void> {
|
||||
const viewport = await this.repository.findViewportById(viewportId);
|
||||
if (!viewport) {
|
||||
throw new NotFoundError("Viewport", viewportId);
|
||||
}
|
||||
|
||||
await this.repository.deleteViewport(viewportId);
|
||||
const userIds = await this.repository.getUserIdsByRole(viewport.roleId);
|
||||
await Promise.all(
|
||||
userIds.map((uid) => this.permissionCache.invalidate(uid)),
|
||||
);
|
||||
|
||||
await this.writeAuditLog(
|
||||
"system",
|
||||
"delete",
|
||||
"viewport",
|
||||
viewportId,
|
||||
viewport,
|
||||
null,
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
// ============ TOTP 2FA ============
|
||||
|
||||
async enableTotp(
|
||||
userId: string,
|
||||
): Promise<{ secret: string; qrUrl: string; backupCodes: string[] }> {
|
||||
const user = await this.repository.findUserById(userId);
|
||||
if (!user) {
|
||||
throw new NotFoundError("User", userId);
|
||||
}
|
||||
|
||||
const secret = generateTotpSecret();
|
||||
await this.repository.upsertTotpSecret(userId, secret, "pending");
|
||||
|
||||
const backupCodes = generateBackupCodes();
|
||||
const codeHashes = await Promise.all(
|
||||
backupCodes.map((code) => bcrypt.hash(code, 10)),
|
||||
);
|
||||
await this.repository.setTotpBackupCodes(userId, codeHashes);
|
||||
|
||||
const issuer = encodeURIComponent("NextEduCloud");
|
||||
const account = encodeURIComponent(user.email);
|
||||
const qrUrl = `otpauth://totp/${issuer}:${account}?secret=${secret}&issuer=${issuer}&algorithm=SHA1&digits=6&period=30`;
|
||||
|
||||
return { secret, qrUrl, backupCodes };
|
||||
}
|
||||
|
||||
async verifyTotp(
|
||||
userId: string,
|
||||
code: string,
|
||||
context?: AuditContext,
|
||||
): Promise<{ verified: boolean }> {
|
||||
const totpRecord = await this.repository.getTotpSecret(userId);
|
||||
if (!totpRecord) {
|
||||
throw new NotFoundError("TOTP setup", userId);
|
||||
}
|
||||
|
||||
const expectedCode = generateTotp(totpRecord.secret, 30, 6);
|
||||
if (code !== expectedCode) {
|
||||
return { verified: false };
|
||||
}
|
||||
|
||||
if (totpRecord.status === "pending") {
|
||||
await this.repository.upsertTotpSecret(
|
||||
userId,
|
||||
totpRecord.secret,
|
||||
"active",
|
||||
);
|
||||
await this.writeAuditLog(
|
||||
userId,
|
||||
"enable_totp",
|
||||
"user",
|
||||
userId,
|
||||
null,
|
||||
{ status: "active" },
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
return { verified: true };
|
||||
}
|
||||
|
||||
async disableTotp(userId: string, context?: AuditContext): Promise<void> {
|
||||
const totpRecord = await this.repository.getTotpSecret(userId);
|
||||
if (!totpRecord) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.repository.deleteTotpSecret(userId);
|
||||
await this.writeAuditLog(
|
||||
userId,
|
||||
"disable_totp",
|
||||
"user",
|
||||
userId,
|
||||
{ status: totpRecord.status },
|
||||
null,
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
// ============ 私有方法 ============
|
||||
|
||||
private async issueTokens(user: User): Promise<{ tokens: TokenPair }> {
|
||||
@@ -402,7 +1097,12 @@ export class IamService {
|
||||
resourceId: string,
|
||||
beforeState: unknown,
|
||||
afterState: unknown,
|
||||
context?: AuditContext,
|
||||
): Promise<void> {
|
||||
const ip = context?.ip ?? null;
|
||||
const userAgent = context?.userAgent ?? null;
|
||||
const traceId = context?.traceId ?? null;
|
||||
|
||||
await this.repository.createAuditLog({
|
||||
id: crypto.randomUUID(),
|
||||
actorUserId,
|
||||
@@ -411,9 +1111,9 @@ export class IamService {
|
||||
resourceId,
|
||||
beforeState: beforeState ? JSON.stringify(beforeState) : null,
|
||||
afterState: afterState ? JSON.stringify(afterState) : null,
|
||||
ip: null,
|
||||
userAgent: null,
|
||||
traceId: null,
|
||||
ip,
|
||||
userAgent,
|
||||
traceId,
|
||||
});
|
||||
|
||||
// Outbox: AuditEvent
|
||||
@@ -430,12 +1130,127 @@ export class IamService {
|
||||
resource_id: resourceId,
|
||||
before_state: beforeState ? JSON.stringify(beforeState) : "",
|
||||
after_state: afterState ? JSON.stringify(afterState) : "",
|
||||
ip: "",
|
||||
user_agent: "",
|
||||
trace_id: "",
|
||||
ip: ip ?? "",
|
||||
user_agent: userAgent ?? "",
|
||||
trace_id: traceId ?? "",
|
||||
metadata: {},
|
||||
},
|
||||
{ aggregateId: resourceId },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 模块级辅助函数 ============
|
||||
|
||||
/**
|
||||
* 密码强度校验:≥8 字符 + 大写 + 小写 + 数字 + 特殊字符.
|
||||
*/
|
||||
export function validatePasswordStrength(password: string): void {
|
||||
if (password.length < 8) {
|
||||
throw new ValidationError("Password must be at least 8 characters long");
|
||||
}
|
||||
if (!/[A-Z]/.test(password)) {
|
||||
throw new ValidationError(
|
||||
"Password must contain at least one uppercase letter",
|
||||
);
|
||||
}
|
||||
if (!/[a-z]/.test(password)) {
|
||||
throw new ValidationError(
|
||||
"Password must contain at least one lowercase letter",
|
||||
);
|
||||
}
|
||||
if (!/\d/.test(password)) {
|
||||
throw new ValidationError("Password must contain at least one digit");
|
||||
}
|
||||
if (!/[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?`~]/.test(password)) {
|
||||
throw new ValidationError(
|
||||
"Password must contain at least one special character",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 TOTP 密钥(Base32 编码,32 字节熵).
|
||||
*/
|
||||
export function generateTotpSecret(): string {
|
||||
const bytes = randomBytes(32);
|
||||
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
let bits = 0;
|
||||
let value = 0;
|
||||
let output = "";
|
||||
for (const byte of bytes) {
|
||||
value = (value << 8) | byte;
|
||||
bits += 8;
|
||||
while (bits >= 5) {
|
||||
output += alphabet[(value >>> (bits - 5)) & 31];
|
||||
bits -= 5;
|
||||
}
|
||||
}
|
||||
if (bits > 0) {
|
||||
output += alphabet[(value << (5 - bits)) & 31];
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 10 个 8 位备份码.
|
||||
*/
|
||||
export function generateBackupCodes(): string[] {
|
||||
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
const codes: string[] = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
let code = "";
|
||||
for (let j = 0; j < 8; j++) {
|
||||
code += chars[randomInt(chars.length)];
|
||||
}
|
||||
codes.push(code);
|
||||
}
|
||||
return codes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 TOTP(RFC 6238 HMAC-SHA1).
|
||||
*/
|
||||
export function generateTotp(
|
||||
secret: string,
|
||||
period = 30,
|
||||
digits = 6,
|
||||
window = 0,
|
||||
): string {
|
||||
const counter = Math.floor(Date.now() / 1000 / period) + window;
|
||||
const buffer = Buffer.alloc(8);
|
||||
buffer.writeBigUInt64BE(BigInt(counter));
|
||||
|
||||
const key = base32Decode(secret);
|
||||
const hmac = createHmac("sha1", key).update(buffer).digest();
|
||||
const offset = hmac[hmac.length - 1]! & 0x0f;
|
||||
const truncated =
|
||||
((hmac[offset]! & 0x7f) << 24) |
|
||||
((hmac[offset + 1]! & 0xff) << 16) |
|
||||
((hmac[offset + 2]! & 0xff) << 8) |
|
||||
(hmac[offset + 3]! & 0xff);
|
||||
const code = truncated % 10 ** digits;
|
||||
return code.toString().padStart(digits, "0");
|
||||
}
|
||||
|
||||
/**
|
||||
* Base32 解码(RFC 4648).
|
||||
*/
|
||||
function base32Decode(encoded: string): Buffer {
|
||||
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
const cleaned = encoded.toUpperCase().replace(/=+$/, "");
|
||||
let bits = 0;
|
||||
let value = 0;
|
||||
const output: number[] = [];
|
||||
for (const char of cleaned) {
|
||||
const idx = alphabet.indexOf(char);
|
||||
if (idx === -1) continue;
|
||||
value = (value << 5) | idx;
|
||||
bits += 5;
|
||||
if (bits >= 8) {
|
||||
output.push((value >>> (bits - 8)) & 0xff);
|
||||
bits -= 8;
|
||||
}
|
||||
}
|
||||
return Buffer.from(output);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,46 @@
|
||||
import { Controller, Get } from "@nestjs/common";
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Req,
|
||||
} from "@nestjs/common";
|
||||
import { IamService } from "./iam.service.js";
|
||||
import {
|
||||
Permissions,
|
||||
RequirePermission,
|
||||
} from "../middleware/permission.guard.js";
|
||||
import {
|
||||
type AuthenticatedRequest,
|
||||
extractAuditContext,
|
||||
} from "../middleware/auth.middleware.js";
|
||||
import {
|
||||
createRoleSchema,
|
||||
updateRoleSchema,
|
||||
updateRolePermissionsSchema,
|
||||
createPermissionSchema,
|
||||
updatePermissionSchema,
|
||||
createViewportSchema,
|
||||
updateViewportSchema,
|
||||
verifyTotpSchema,
|
||||
} from "./iam.dto.js";
|
||||
|
||||
/**
|
||||
* RBAC 管理端点:角色/权限查询(admin-portal 使用)。
|
||||
*
|
||||
* 路径前缀:/v1/iam(I7 裁决)
|
||||
* RBAC 管理端点:角色/权限/视口 CRUD + TOTP 2FA(admin-portal 使用)。
|
||||
*/
|
||||
@Controller("v1/iam")
|
||||
export class RbacController {
|
||||
constructor(private readonly service: IamService) {}
|
||||
|
||||
private maybeContext(req: AuthenticatedRequest) {
|
||||
return extractAuditContext(req);
|
||||
}
|
||||
|
||||
// ============ 角色 ============
|
||||
|
||||
@Get("roles")
|
||||
@RequirePermission(Permissions.IAM_ROLE_MANAGE)
|
||||
async roles(): Promise<{ success: true; data: unknown[] }> {
|
||||
@@ -21,10 +48,208 @@ export class RbacController {
|
||||
return { success: true as const, data };
|
||||
}
|
||||
|
||||
@Post("roles")
|
||||
@RequirePermission(Permissions.IAM_ROLE_MANAGE)
|
||||
async createRole(
|
||||
@Body() body: unknown,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
): Promise<{ success: true; data: unknown }> {
|
||||
const dto = createRoleSchema.parse(body);
|
||||
const data = await this.service.createRole(dto, this.maybeContext(req));
|
||||
return { success: true as const, data };
|
||||
}
|
||||
|
||||
@Patch("roles/:id")
|
||||
@RequirePermission(Permissions.IAM_ROLE_MANAGE)
|
||||
async updateRole(
|
||||
@Param("id") id: string,
|
||||
@Body() body: unknown,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
): Promise<{ success: true; data: unknown }> {
|
||||
const dto = updateRoleSchema.parse(body);
|
||||
const data = await this.service.updateRole(id, dto, this.maybeContext(req));
|
||||
return { success: true as const, data };
|
||||
}
|
||||
|
||||
@Patch("roles/:id/permissions")
|
||||
@RequirePermission(Permissions.IAM_ROLE_MANAGE)
|
||||
async updateRolePermissions(
|
||||
@Param("id") id: string,
|
||||
@Body() body: unknown,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
): Promise<{ success: true; data: { success: boolean } }> {
|
||||
const dto = updateRolePermissionsSchema.parse(body);
|
||||
await this.service.updateRolePermissions(
|
||||
id,
|
||||
dto.permissionIds,
|
||||
this.maybeContext(req),
|
||||
);
|
||||
return { success: true as const, data: { success: true } };
|
||||
}
|
||||
|
||||
@Post("roles/:roleId/permissions/:permissionId")
|
||||
@RequirePermission(Permissions.IAM_ROLE_MANAGE)
|
||||
async grantPermission(
|
||||
@Param("roleId") roleId: string,
|
||||
@Param("permissionId") permissionId: string,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
): Promise<{ success: true; data: { success: boolean } }> {
|
||||
await this.service.grantPermissionToRole(
|
||||
roleId,
|
||||
permissionId,
|
||||
this.maybeContext(req),
|
||||
);
|
||||
return { success: true as const, data: { success: true } };
|
||||
}
|
||||
|
||||
@Delete("roles/:roleId/permissions/:permissionId")
|
||||
@RequirePermission(Permissions.IAM_ROLE_MANAGE)
|
||||
async revokePermission(
|
||||
@Param("roleId") roleId: string,
|
||||
@Param("permissionId") permissionId: string,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
): Promise<{ success: true; data: { success: boolean } }> {
|
||||
await this.service.revokePermissionFromRole(
|
||||
roleId,
|
||||
permissionId,
|
||||
this.maybeContext(req),
|
||||
);
|
||||
return { success: true as const, data: { success: true } };
|
||||
}
|
||||
|
||||
// ============ 权限 ============
|
||||
|
||||
@Get("permissions")
|
||||
@RequirePermission(Permissions.IAM_ROLE_MANAGE)
|
||||
async permissions(): Promise<{ success: true; data: unknown[] }> {
|
||||
const data = await this.service.getAllPermissions();
|
||||
return { success: true as const, data };
|
||||
}
|
||||
|
||||
@Post("permissions")
|
||||
@RequirePermission(Permissions.IAM_ROLE_MANAGE)
|
||||
async createPermission(
|
||||
@Body() body: unknown,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
): Promise<{ success: true; data: unknown }> {
|
||||
const dto = createPermissionSchema.parse(body);
|
||||
const data = await this.service.createPermission(
|
||||
dto,
|
||||
this.maybeContext(req),
|
||||
);
|
||||
return { success: true as const, data };
|
||||
}
|
||||
|
||||
@Patch("permissions/:id")
|
||||
@RequirePermission(Permissions.IAM_ROLE_MANAGE)
|
||||
async updatePermission(
|
||||
@Param("id") id: string,
|
||||
@Body() body: unknown,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
): Promise<{ success: true; data: unknown }> {
|
||||
const dto = updatePermissionSchema.parse(body);
|
||||
const data = await this.service.updatePermission(
|
||||
id,
|
||||
dto,
|
||||
this.maybeContext(req),
|
||||
);
|
||||
return { success: true as const, data };
|
||||
}
|
||||
|
||||
@Delete("permissions/:id")
|
||||
@RequirePermission(Permissions.IAM_ROLE_MANAGE)
|
||||
async deletePermission(
|
||||
@Param("id") id: string,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
): Promise<{ success: true; data: { success: boolean } }> {
|
||||
await this.service.deletePermission(id, this.maybeContext(req));
|
||||
return { success: true as const, data: { success: true } };
|
||||
}
|
||||
|
||||
// ============ 视口 ============
|
||||
|
||||
@Post("viewports")
|
||||
@RequirePermission(Permissions.IAM_ROLE_MANAGE)
|
||||
async createViewport(
|
||||
@Body() body: unknown,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
): Promise<{ success: true; data: unknown }> {
|
||||
const dto = createViewportSchema.parse(body);
|
||||
const data = await this.service.createViewport(dto, this.maybeContext(req));
|
||||
return { success: true as const, data };
|
||||
}
|
||||
|
||||
@Patch("viewports/:id")
|
||||
@RequirePermission(Permissions.IAM_ROLE_MANAGE)
|
||||
async updateViewport(
|
||||
@Param("id") id: string,
|
||||
@Body() body: unknown,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
): Promise<{ success: true; data: unknown }> {
|
||||
const dto = updateViewportSchema.parse(body);
|
||||
const data = await this.service.updateViewport(
|
||||
id,
|
||||
dto,
|
||||
this.maybeContext(req),
|
||||
);
|
||||
return { success: true as const, data };
|
||||
}
|
||||
|
||||
@Delete("viewports/:id")
|
||||
@RequirePermission(Permissions.IAM_ROLE_MANAGE)
|
||||
async deleteViewport(
|
||||
@Param("id") id: string,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
): Promise<{ success: true; data: { success: boolean } }> {
|
||||
await this.service.deleteViewport(id, this.maybeContext(req));
|
||||
return { success: true as const, data: { success: true } };
|
||||
}
|
||||
|
||||
// ============ TOTP 2FA ============
|
||||
|
||||
@Post("totp/enable")
|
||||
@RequirePermission(Permissions.IAM_USER_READ)
|
||||
async enableTotp(@Req() req: AuthenticatedRequest): Promise<{
|
||||
success: true;
|
||||
data: { secret: string; qrUrl: string; backupCodes: string[] };
|
||||
}> {
|
||||
const userId = req.userId;
|
||||
if (!userId) {
|
||||
throw new Error("Missing user identity");
|
||||
}
|
||||
const data = await this.service.enableTotp(userId);
|
||||
return { success: true as const, data };
|
||||
}
|
||||
|
||||
@Post("totp/verify")
|
||||
@RequirePermission(Permissions.IAM_USER_READ)
|
||||
async verifyTotp(
|
||||
@Body() body: unknown,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
): Promise<{ success: true; data: { verified: boolean } }> {
|
||||
const userId = req.userId;
|
||||
if (!userId) {
|
||||
throw new Error("Missing user identity");
|
||||
}
|
||||
const dto = verifyTotpSchema.parse(body);
|
||||
const data = await this.service.verifyTotp(
|
||||
userId,
|
||||
dto.code,
|
||||
this.maybeContext(req),
|
||||
);
|
||||
return { success: true as const, data };
|
||||
}
|
||||
|
||||
@Post("totp/disable")
|
||||
@RequirePermission(Permissions.IAM_USER_READ)
|
||||
async disableTotp(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
): Promise<{ success: true; data: { success: boolean } }> {
|
||||
const userId = req.userId;
|
||||
if (!userId) {
|
||||
throw new Error("Missing user identity");
|
||||
}
|
||||
await this.service.disableTotp(userId, this.maybeContext(req));
|
||||
return { success: true as const, data: { success: true } };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,42 @@ export interface AuthenticatedRequest extends Request {
|
||||
userDataScope?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 审计上下文:从 HTTP 请求头中提取的客户端信息(president §5.5).
|
||||
* ip: 客户端 IP(X-Forwarded-For 首个 IP)
|
||||
* userAgent: User-Agent
|
||||
* traceId: 链路追踪 ID(X-Request-Id / X-Trace-Id)
|
||||
*/
|
||||
export interface AuditContext {
|
||||
ip?: string | null;
|
||||
userAgent?: string | null;
|
||||
traceId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Express Request 中提取审计上下文(ip / userAgent / traceId).
|
||||
* 供 Controller 调用并传递给 Service 的审计日志方法.
|
||||
*/
|
||||
export function extractAuditContext(req: Request): AuditContext {
|
||||
const forwardedFor = req.headers["x-forwarded-for"];
|
||||
const ip =
|
||||
(typeof forwardedFor === "string"
|
||||
? forwardedFor.split(",")[0]?.trim()
|
||||
: undefined) ??
|
||||
req.ip ??
|
||||
null;
|
||||
const userAgentHeader = req.headers["user-agent"];
|
||||
const userAgent =
|
||||
typeof userAgentHeader === "string" ? userAgentHeader : null;
|
||||
const requestIdHeader = req.headers["x-request-id"];
|
||||
const traceIdHeader = req.headers["x-trace-id"];
|
||||
const traceId =
|
||||
(typeof requestIdHeader === "string" && requestIdHeader) ||
|
||||
(typeof traceIdHeader === "string" && traceIdHeader) ||
|
||||
null;
|
||||
return { ip, userAgent, traceId };
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthMiddleware implements NestMiddleware {
|
||||
use(req: AuthenticatedRequest, _res: Response, next: NextFunction): void {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { getRedis } from "../../config/redis.js";
|
||||
import { cacheMetrics } from "../observability/metrics.js";
|
||||
|
||||
const PERMISSION_CACHE_TTL_SECONDS = 300; // 5 分钟
|
||||
|
||||
@@ -22,14 +23,20 @@ export class PermissionCacheService {
|
||||
async getPermissions(userId: string): Promise<string[] | null> {
|
||||
const redis = getRedis();
|
||||
const raw = await redis.get(PermissionCacheService.buildKey(userId));
|
||||
if (!raw) return null;
|
||||
if (!raw) {
|
||||
cacheMetrics.recordMiss();
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (Array.isArray(parsed) && parsed.every((p) => typeof p === "string")) {
|
||||
cacheMetrics.recordHit();
|
||||
return parsed as string[];
|
||||
}
|
||||
cacheMetrics.recordMiss();
|
||||
return null;
|
||||
} catch {
|
||||
cacheMetrics.recordMiss();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -44,8 +51,9 @@ export class PermissionCacheService {
|
||||
);
|
||||
}
|
||||
|
||||
async invalidate(userId: string): Promise<void> {
|
||||
async invalidate(userId: string, reason = "manual"): Promise<void> {
|
||||
const redis = getRedis();
|
||||
await redis.del(PermissionCacheService.buildKey(userId));
|
||||
cacheMetrics.recordInvalidation(reason);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,4 +24,56 @@ registry.registerMetric(
|
||||
// 这些指标无需业务代码埋点,prom-client 自动采集
|
||||
promClient.collectDefaultMetrics({ register: registry });
|
||||
|
||||
// Redis 权限缓存指标(I3 裁决:DB 驱动 + Redis 缓存可观测性)
|
||||
registry.registerMetric(
|
||||
new promClient.Counter({
|
||||
name: "iam_permission_cache_hits_total",
|
||||
help: "Total number of permission cache hits (Redis)",
|
||||
}),
|
||||
);
|
||||
|
||||
registry.registerMetric(
|
||||
new promClient.Counter({
|
||||
name: "iam_permission_cache_misses_total",
|
||||
help: "Total number of permission cache misses (Redis)",
|
||||
}),
|
||||
);
|
||||
|
||||
registry.registerMetric(
|
||||
new promClient.Counter({
|
||||
name: "iam_permission_cache_invalidations_total",
|
||||
help: "Total number of permission cache invalidations (Redis)",
|
||||
labelNames: ["reason"],
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* 缓存指标访问器:供 PermissionCacheService 使用.
|
||||
* 避免在业务代码中直接操作 registry,统一通过此门面.
|
||||
*/
|
||||
export const cacheMetrics = {
|
||||
recordHit(): void {
|
||||
const metric = registry.getSingleMetric("iam_permission_cache_hits_total");
|
||||
if (metric && "inc" in metric) {
|
||||
(metric as promClient.Counter).inc();
|
||||
}
|
||||
},
|
||||
recordMiss(): void {
|
||||
const metric = registry.getSingleMetric(
|
||||
"iam_permission_cache_misses_total",
|
||||
);
|
||||
if (metric && "inc" in metric) {
|
||||
(metric as promClient.Counter).inc();
|
||||
}
|
||||
},
|
||||
recordInvalidation(reason: string): void {
|
||||
const metric = registry.getSingleMetric(
|
||||
"iam_permission_cache_invalidations_total",
|
||||
);
|
||||
if (metric && "inc" in metric) {
|
||||
(metric as promClient.Counter).inc({ reason });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export { registry as metricsRegistry };
|
||||
|
||||
Reference in New Issue
Block a user