Files
Edu/services/iam/src/iam/iam.controller.ts
SpecialX ad39a3bb0f 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
2026-07-14 22:59:28 +08:00

342 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
Body,
Controller,
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,
UserInfo,
ViewportItem,
ChildInfo,
} from "./iam.service.js";
import {
registerSchema,
loginSchema,
refreshTokenSchema,
logoutSchema,
changePasswordSchema,
updateProfileSchema,
updateUserSchema,
updateUserStatusSchema,
createUserSchema,
listUsersQuerySchema,
} from "./iam.dto.js";
import { UnauthorizedError } from "../shared/errors/application-error.js";
import {
Permissions,
RequirePermission,
} from "../middleware/permission.guard.js";
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 侧)。
*/
@Controller("v1/iam")
export class IamController {
constructor(private readonly service: IamService) {}
@Post("register")
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 };
}
@Post("login")
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 }> {
// 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 };
}
@Post("logout")
@RequirePermission(Permissions.IAM_USER_READ)
async logout(
@Body() body: unknown,
@Req() req: AuthenticatedRequest,
@Res({ passthrough: true }) res: Response,
): Promise<{ success: true; data: { success: boolean } }> {
// 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");
}
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 } };
}
@Get("me")
@RequirePermission(Permissions.IAM_USER_READ)
async me(
@Req() req: AuthenticatedRequest,
): Promise<{ success: true; data: UserInfo }> {
const userId = req.userId;
if (!userId) {
throw new UnauthorizedError("Missing user identity");
}
const user = await this.service.getUserInfo(userId);
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(
@Req() req: AuthenticatedRequest,
): Promise<{ success: true; data: ViewportItem[] }> {
const userId = req.userId;
if (!userId) {
throw new UnauthorizedError("Missing user identity");
}
const data = await this.service.getViewports(userId);
return { success: true as const, data };
}
@Get("permissions/effective")
@RequirePermission(Permissions.IAM_USER_READ)
async effectivePermissions(
@Req() req: AuthenticatedRequest,
): Promise<{ success: true; data: { permissions: string[] } }> {
const userId = req.userId;
if (!userId) {
throw new UnauthorizedError("Missing user identity");
}
const permissions = await this.service.getEffectivePermissions(userId);
return { success: true as const, data: { permissions } };
}
@Get("children")
@RequirePermission(Permissions.IAM_USER_READ)
async children(
@Req() req: AuthenticatedRequest,
): Promise<{ success: true; data: ChildInfo[] }> {
const userId = req.userId;
if (!userId) {
throw new UnauthorizedError("Missing user identity");
}
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 };
}
@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 } };
}
}