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
342 lines
10 KiB
TypeScript
342 lines
10 KiB
TypeScript
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 读取,回退到 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 侧)。
|
||
*/
|
||
@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 } };
|
||
}
|
||
}
|