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:
@@ -19,6 +19,13 @@ const envSchema = z.object({
|
||||
ACCESS_TOKEN_TTL: z.string().default("15m"),
|
||||
REFRESH_TOKEN_TTL_DAYS: z.string().default("7"),
|
||||
|
||||
// Cookie(F12 裁决:refresh_token httpOnly cookie 模式)
|
||||
COOKIE_SECURE: z
|
||||
.string()
|
||||
.default("auto")
|
||||
.transform((v) => (v === "auto" ? undefined : v === "true")),
|
||||
COOKIE_DOMAIN: z.string().optional(),
|
||||
|
||||
// Kafka(Outbox 投递)
|
||||
KAFKA_BROKERS: z.string(),
|
||||
KAFKA_CLIENT_ID: z.string().default("iam-service"),
|
||||
|
||||
@@ -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 读取,回退到 body(F12 裁决).
|
||||
*/
|
||||
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 } };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user