feat(iam): v2 用户管理 RPC + F12 httpOnly Cookie

admin-portal §2.3 P1 阻塞项补齐:CreateUser/UpdateUser/DeleteUser 3 RPC

iam.repository/service/grpc.controller 实现 3 用户管理方法(含 bcrypt + 审计)

iam.controller 新增 POST /v1/iam/users + DELETE /v1/iam/users/:id(@RequirePermission(IAM_USER_MANAGE))

iam.dto 新增 createUserSchema Zod 校验

F12 httpOnly Cookie:refresh_token 改为 httpOnly+Secure+SameSite=Strict Cookie 下发

extractRefreshToken 优先读 cookie 回退 body + buildRefreshTokenCookie/buildClearCookie
This commit is contained in:
SpecialX
2026-07-14 22:59:28 +08:00
parent d11441c9a8
commit ad39a3bb0f
9 changed files with 650 additions and 40 deletions

View File

@@ -19,6 +19,13 @@ const envSchema = z.object({
ACCESS_TOKEN_TTL: z.string().default("15m"),
REFRESH_TOKEN_TTL_DAYS: z.string().default("7"),
// CookieF12 裁决refresh_token httpOnly cookie 模式)
COOKIE_SECURE: z
.string()
.default("auto")
.transform((v) => (v === "auto" ? undefined : v === "true")),
COOKIE_DOMAIN: z.string().optional(),
// KafkaOutbox 投递)
KAFKA_BROKERS: z.string(),
KAFKA_CLIENT_ID: z.string().default("iam-service"),

View File

@@ -4,10 +4,13 @@ import {
Get,
Patch,
Post,
Delete,
Query,
Req,
Res,
Param,
} from "@nestjs/common";
import type { Response } from "express";
import { IamService } from "./iam.service.js";
import type {
TokenPair,
@@ -24,6 +27,7 @@ import {
updateProfileSchema,
updateUserSchema,
updateUserStatusSchema,
createUserSchema,
listUsersQuerySchema,
} from "./iam.dto.js";
import { UnauthorizedError } from "../shared/errors/application-error.js";
@@ -35,6 +39,62 @@ import {
type AuthenticatedRequest,
extractAuditContext,
} from "../middleware/auth.middleware.js";
import { env } from "../config/env.js";
/**
* 解析请求中的 refresh_token优先从 httpOnly cookie 读取,回退到 bodyF12 裁决).
*/
function extractRefreshToken(req: AuthenticatedRequest): string | undefined {
// 1. 优先从 httpOnly cookie 读取
const cookieHeader = req.headers.cookie;
if (cookieHeader) {
const match = cookieHeader
.split(";")
.map((p) => p.trim())
.find((p) => p.startsWith("refresh_token="));
if (match) {
return decodeURIComponent(match.split("=")[1] ?? "");
}
}
return undefined;
}
/**
* 构建 Set-Cookie 值F12 裁决HttpOnly + Secure + SameSite=Strict.
*/
function buildRefreshTokenCookie(
refreshToken: string,
maxAgeSeconds: number,
): string {
const secure = env.COOKIE_SECURE ?? env.NODE_ENV === "production";
const parts = [
`refresh_token=${encodeURIComponent(refreshToken)}`,
"HttpOnly",
`SameSite=Strict`,
`Max-Age=${maxAgeSeconds}`,
`Path=/`,
];
if (secure) parts.push("Secure");
if (env.COOKIE_DOMAIN) parts.push(`Domain=${env.COOKIE_DOMAIN}`);
return parts.join("; ");
}
/**
* 构建清除 cookie 的 Set-Cookie 值.
*/
function buildClearCookie(): string {
const secure = env.COOKIE_SECURE ?? env.NODE_ENV === "production";
const parts = [
"refresh_token=",
"HttpOnly",
"SameSite=Strict",
"Max-Age=0",
"Path=/",
];
if (secure) parts.push("Secure");
if (env.COOKIE_DOMAIN) parts.push(`Domain=${env.COOKIE_DOMAIN}`);
return parts.join("; ");
}
/**
* IAM REST Controller双入口之 REST 侧)。
@@ -47,9 +107,16 @@ export class IamController {
async register(
@Body() body: unknown,
@Req() req: AuthenticatedRequest,
@Res({ passthrough: true }) res: Response,
): Promise<{ success: true; data: { user: UserInfo; tokens: TokenPair } }> {
const dto = registerSchema.parse(body);
const result = await this.service.register(dto, extractAuditContext(req));
// F12 裁决:注册即登录,同样设置 httpOnly cookie
const maxAgeSeconds = parseInt(env.REFRESH_TOKEN_TTL_DAYS, 10) * 86400;
res.setHeader(
"Set-Cookie",
buildRefreshTokenCookie(result.tokens.refreshToken, maxAgeSeconds),
);
return { success: true as const, data: result };
}
@@ -57,18 +124,40 @@ export class IamController {
async login(
@Body() body: unknown,
@Req() req: AuthenticatedRequest,
@Res({ passthrough: true }) res: Response,
): Promise<{ success: true; data: { user: UserInfo; tokens: TokenPair } }> {
const dto = loginSchema.parse(body);
const result = await this.service.login(dto, extractAuditContext(req));
// F12 裁决:设置 httpOnly cookie 携带 refresh_token
const maxAgeSeconds = parseInt(env.REFRESH_TOKEN_TTL_DAYS, 10) * 86400;
res.setHeader(
"Set-Cookie",
buildRefreshTokenCookie(result.tokens.refreshToken, maxAgeSeconds),
);
return { success: true as const, data: result };
}
@Post("refresh")
async refresh(
@Body() body: unknown,
@Req() req: AuthenticatedRequest,
@Res({ passthrough: true }) res: Response,
): Promise<{ success: true; data: TokenPair }> {
const dto = refreshTokenSchema.parse(body);
const tokens = await this.service.refresh(dto.refreshToken);
// F12 裁决:优先从 httpOnly cookie 读取 refresh_token,回退到 body
const cookieToken = extractRefreshToken(req);
const body_ = refreshTokenSchema.safeParse(body);
const refreshToken =
cookieToken ?? (body_.success ? body_.data.refreshToken : undefined);
if (!refreshToken) {
throw new UnauthorizedError("Missing refresh token");
}
const tokens = await this.service.refresh(refreshToken);
// 轮换 cookie 中的 refresh_token
const maxAgeSeconds = parseInt(env.REFRESH_TOKEN_TTL_DAYS, 10) * 86400;
res.setHeader(
"Set-Cookie",
buildRefreshTokenCookie(tokens.refreshToken, maxAgeSeconds),
);
return { success: true as const, data: tokens };
}
@@ -77,17 +166,22 @@ export class IamController {
async logout(
@Body() body: unknown,
@Req() req: AuthenticatedRequest,
@Res({ passthrough: true }) res: Response,
): Promise<{ success: true; data: { success: boolean } }> {
const dto = logoutSchema.parse(body);
// F12 裁决:优先从 cookie 读取 refresh_token回退到 body
const cookieToken = extractRefreshToken(req);
const body_ = logoutSchema.safeParse(body);
const refreshToken =
cookieToken ?? (body_.success ? body_.data.refreshToken : undefined);
const userId = req.userId;
if (!userId) {
throw new UnauthorizedError("Missing user identity");
}
await this.service.logout(
dto.refreshToken,
userId,
extractAuditContext(req),
);
if (refreshToken) {
await this.service.logout(refreshToken, userId, extractAuditContext(req));
}
// 清除 httpOnly cookie
res.setHeader("Set-Cookie", buildClearCookie());
return { success: true as const, data: { success: true } };
}
@@ -223,4 +317,25 @@ export class IamController {
);
return { success: true as const, data: user };
}
@Post("users")
@RequirePermission(Permissions.IAM_USER_MANAGE)
async createUser(
@Body() body: unknown,
@Req() req: AuthenticatedRequest,
): Promise<{ success: true; data: UserInfo }> {
const dto = createUserSchema.parse(body);
const user = await this.service.createUser(dto, extractAuditContext(req));
return { success: true as const, data: user };
}
@Delete("users/:id")
@RequirePermission(Permissions.IAM_USER_MANAGE)
async deleteUser(
@Param("id") id: string,
@Req() req: AuthenticatedRequest,
): Promise<{ success: true; data: { success: boolean } }> {
await this.service.deleteUser(id, extractAuditContext(req));
return { success: true as const, data: { success: true } };
}
}

View File

@@ -42,6 +42,16 @@ export const updateUserStatusSchema = z.object({
status: z.enum(["active", "inactive", "locked"]),
});
export const createUserSchema = z.object({
email: z.string().email(),
password: z.string().min(8).max(72),
name: z.string().min(1).max(100),
roleId: z.string().uuid().optional(),
dataScope: z
.enum(["self", "subject", "class", "grade", "school", "all"])
.optional(),
});
export const listUsersQuerySchema = z.object({
limit: z.coerce.number().min(1).max(100).default(20),
offset: z.coerce.number().min(0).default(0),
@@ -109,6 +119,7 @@ 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 CreateUserDto = z.infer<typeof createUserSchema>;
export type ListUsersQueryDto = z.infer<typeof listUsersQuerySchema>;
export type CreateRoleDto = z.infer<typeof createRoleSchema>;
export type UpdateRoleDto = z.infer<typeof updateRoleSchema>;

View File

@@ -125,6 +125,65 @@ export class IamGrpcController {
};
}
// ============ 管理员用户管理类 ============
@GrpcMethod("IamService", "CreateUser")
async createUser(data: {
email: string;
password: string;
name: string;
roleId?: string;
dataScope?: string;
}): Promise<unknown> {
const user = await this.service.createUser({
email: data.email,
password: data.password,
name: data.name,
roleId: data.roleId || undefined,
dataScope:
(data.dataScope as
| "self"
| "subject"
| "class"
| "grade"
| "school"
| "all"
| undefined) ?? undefined,
});
return this.toUserInfoProto(user);
}
@GrpcMethod("IamService", "UpdateUser")
async updateUser(data: {
userId: string;
name?: string;
email?: string;
status?: string;
dataScope?: string;
}): Promise<unknown> {
const user = await this.service.updateUser(data.userId, {
name: data.name || undefined,
email: data.email || undefined,
status: data.status || undefined,
dataScope:
(data.dataScope as
| "self"
| "subject"
| "class"
| "grade"
| "school"
| "all"
| undefined) ?? undefined,
});
return this.toUserInfoProto(user);
}
@GrpcMethod("IamService", "DeleteUser")
async deleteUser(data: { userId: string }): Promise<{ success: boolean }> {
await this.service.deleteUser(data.userId);
return { success: true };
}
private toUserInfoProto(user: {
id: string;
email: string;

View File

@@ -391,6 +391,29 @@ export class IamRepository {
return this.findUserById(userId);
}
/**
* 硬删除用户:清理所有关联数据(角色/密码历史/刷新令牌/TOTP/备份码).
* 审计日志保留以满足合规要求。
*/
async deleteUser(userId: string): Promise<void> {
const db = getDb();
// 清理关联数据
await db.delete(userRoles).where(eq(userRoles.userId, userId));
await db.delete(passwordHistory).where(eq(passwordHistory.userId, userId));
await db.delete(refreshTokens).where(eq(refreshTokens.userId, userId));
await db.delete(totpBackupCodes).where(eq(totpBackupCodes.userId, userId));
await db.delete(userTotp).where(eq(userTotp.userId, userId));
// 删除学生-家长关系(作为 guardian 或 student
await db
.delete(studentGuardians)
.where(eq(studentGuardians.guardianId, userId));
await db
.delete(studentGuardians)
.where(eq(studentGuardians.studentId, userId));
// 最后删除用户
await db.delete(users).where(eq(users.id, userId));
}
// ============ 角色 CRUD ============
async findRoleById(id: string): Promise<Role | undefined> {

View File

@@ -497,6 +497,125 @@ export class IamService {
return this.buildUserInfo(updated!);
}
/**
* 管理员创建用户:由 admin 直接创建账户(非自助注册),可指定角色与数据范围.
* 区别于 register():不发 token、不登录仅创建用户并分配角色.
*/
async createUser(
data: {
email: string;
password: string;
name: string;
roleId?: string;
dataScope?: DataScope;
},
context?: AuditContext,
): Promise<UserInfo> {
validatePasswordStrength(data.password);
const existing = await this.repository.findUserByEmail(data.email);
if (existing) {
throw new ConflictError("Email already registered");
}
const userId = crypto.randomUUID();
const passwordHash = await bcrypt.hash(data.password, 12);
const user = await this.repository.createUser({
id: userId,
email: data.email,
passwordHash,
name: data.name,
dataScope: data.dataScope,
});
const roleId = data.roleId ?? DEFAULT_ROLE_ID;
await this.repository.assignRole(userId, roleId);
await this.repository.addPasswordHistory(userId, passwordHash);
// Outbox: UserCreated event
await this.outbox.publish(
"UserCreated",
{
event_id: crypto.randomUUID(),
aggregate_id: userId,
event_type: "UserCreated",
occurred_at: Date.now(),
user_id: userId,
email: data.email,
name: data.name,
roles: [roleId],
data_scope: data.dataScope ?? "self",
action: "created",
metadata: { source: "admin_create" },
},
{ aggregateId: userId },
);
await this.writeAuditLog(
"system",
"create",
"user",
userId,
null,
{ id: userId, email: data.email, name: data.name, roleId },
context,
);
return this.buildUserInfo(user);
}
/**
* 管理员删除用户:硬删除 + 清理关联数据 + 撤销 token + 发布 UserDeleted 事件.
* 审计日志保留以满足合规要求.
*/
async deleteUser(userId: string, context?: AuditContext): Promise<void> {
const user = await this.repository.findUserById(userId);
if (!user) {
throw new NotFoundError("User", userId);
}
const beforeState = {
id: user.id,
email: user.email,
name: user.name,
status: user.status,
};
// 撤销所有 token + 清理权限缓存
await this.repository.revokeAllUserTokens(userId);
await this.permissionCache.invalidate(userId);
// 硬删除用户及关联数据
await this.repository.deleteUser(userId);
// Outbox: UserDeleted event
await this.outbox.publish(
"UserDeleted",
{
event_id: crypto.randomUUID(),
aggregate_id: userId,
event_type: "UserDeleted",
occurred_at: Date.now(),
user_id: userId,
email: user.email,
name: user.name,
action: "deleted",
metadata: { source: "admin_delete" },
},
{ aggregateId: userId },
);
await this.writeAuditLog(
"system",
"delete",
"user",
userId,
beforeState,
null,
context,
);
}
// ============ 权限与视口类 ============
async getEffectivePermissions(userId: string): Promise<string[]> {

View File

@@ -1,16 +1,16 @@
import pino from 'pino';
import { env } from '../../config/env.js';
import { pino } from "pino";
import { env } from "../../config/env.js";
export const logger = pino({
level: env.LOG_LEVEL,
base: {
service: 'iam',
version: '0.1.0',
service: "iam",
version: "0.1.0",
},
transport:
env.NODE_ENV === 'development'
env.NODE_ENV === "development"
? {
target: 'pino-pretty',
target: "pino-pretty",
options: { colorize: true },
}
: undefined,