From b89853bc94dcc06024704affd4acecef84e673f1 Mon Sep 17 00:00:00 2001 From: que01 Date: Fri, 18 Sep 2026 16:37:48 +0800 Subject: [PATCH] fix: harden api authentication and validation --- .env.example | 1 + apps/api/src/admin.dto.ts | 10 +++++++ apps/api/src/auth.ts | 34 +++++++++++++++++++++++ apps/api/src/booking.service.ts | 10 +++++++ apps/api/src/main.ts | 48 ++++++++++++++------------------- 5 files changed, 75 insertions(+), 28 deletions(-) create mode 100644 apps/api/src/admin.dto.ts create mode 100644 apps/api/src/auth.ts diff --git a/.env.example b/.env.example index 29085bd..2aa78dc 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,7 @@ DATABASE_URL=postgresql://postgres:postgres@localhost:5432/personal_brand API_PORT=3001 ADMIN_PORT=3000 SESSION_SECRET=replace-with-a-long-random-value +DEFAULT_BRAND_ID=00000000-0000-0000-0000-000000000001 OBJECT_STORAGE_ENDPOINT= OBJECT_STORAGE_BUCKET= OBJECT_STORAGE_ACCESS_KEY= diff --git a/apps/api/src/admin.dto.ts b/apps/api/src/admin.dto.ts new file mode 100644 index 0000000..64b7b77 --- /dev/null +++ b/apps/api/src/admin.dto.ts @@ -0,0 +1,10 @@ +import { IsIn, IsOptional, IsString } from "class-validator"; + +export class UpdateBookingStatusDto { + @IsIn(["received", "contacting", "confirmed", "completed", "cancelled"]) + status!: "received" | "contacting" | "confirmed" | "completed" | "cancelled"; + + @IsOptional() + @IsString() + adminNote?: string; +} diff --git a/apps/api/src/auth.ts b/apps/api/src/auth.ts new file mode 100644 index 0000000..f9a7e68 --- /dev/null +++ b/apps/api/src/auth.ts @@ -0,0 +1,34 @@ +import { UnauthorizedException } from "@nestjs/common"; +import { createHmac, timingSafeEqual } from "node:crypto"; + +type Identity = { subject: string; brandId: string; role: "customer" | "admin"; exp: number }; + +function secret(): string { + const value = process.env.SESSION_SECRET; + if (!value || value.length < 32) throw new UnauthorizedException("服务端会话密钥未正确配置"); + return value; +} + +export function signIdentity( + identity: Omit, + ttlSeconds = 60 * 60 * 24 * 7, +): string { + const payload = Buffer.from( + JSON.stringify({ ...identity, exp: Math.floor(Date.now() / 1000) + ttlSeconds }), + ).toString("base64url"); + const signature = createHmac("sha256", secret()).update(payload).digest("base64url"); + return `${payload}.${signature}`; +} + +export function readIdentity(token: string): Identity { + const [payload, signature] = token.split("."); + if (!payload || !signature) throw new UnauthorizedException("登录态无效"); + const expected = createHmac("sha256", secret()).update(payload).digest(); + const actual = Buffer.from(signature, "base64url"); + if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) + throw new UnauthorizedException("登录态无效"); + const identity = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as Identity; + if (!identity.exp || identity.exp < Math.floor(Date.now() / 1000)) + throw new UnauthorizedException("登录态已过期"); + return identity; +} diff --git a/apps/api/src/booking.service.ts b/apps/api/src/booking.service.ts index a80e95f..35fbdb0 100644 --- a/apps/api/src/booking.service.ts +++ b/apps/api/src/booking.service.ts @@ -33,6 +33,16 @@ export class BookingService { throw new BadRequestException("普通套餐至少选择一份有效套餐"); } const extraData = input.extraData as { people?: unknown; location?: unknown } | undefined; + const allowedExtraKeys = new Set([ + "people", + "location", + "budgetRange", + "tastePreferences", + "allergyNotes", + "dishPreferences", + ]); + if (input.extraData && Object.keys(input.extraData).some((key) => !allowedExtraKeys.has(key))) + throw new BadRequestException("存在不支持的行业字段"); if ( input.requestType === "private_chef" && (!input.requestedDate || !extraData?.people || !extraData.location) diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 7e5c486..b942dec 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -18,28 +18,9 @@ import { PrismaService } from "./prisma.service.js"; import { WechatAuthService } from "./wechat-auth.js"; import { BookingService } from "./booking.service.js"; import { CreateBookingDto } from "./booking.dto.js"; +import { UpdateBookingStatusDto } from "./admin.dto.js"; import type { FastifyRequest } from "fastify"; -import { createHmac } from "node:crypto"; - -function signIdentity(identity: { brandId: string; openid: string }): string { - const payload = Buffer.from(JSON.stringify(identity)).toString("base64url"); - const signature = createHmac("sha256", process.env.SESSION_SECRET ?? "development-only-secret") - .update(payload) - .digest("base64url"); - return `${payload}.${signature}`; -} - -function readIdentity(token: string): { brandId: string; openid: string } { - const [payload, signature] = token.split("."); - const expected = createHmac("sha256", process.env.SESSION_SECRET ?? "development-only-secret") - .update(payload ?? "") - .digest("base64url"); - if (!payload || signature !== expected) throw new BadRequestException("客户登录态无效"); - return JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as { - brandId: string; - openid: string; - }; -} +import { readIdentity, signIdentity } from "./auth.js"; @Controller("health") export class HealthController { @@ -105,7 +86,9 @@ export class CustomerSessionController { update: { lastLoginAt: new Date() }, create: { brandId: brand.id, openid: session.openid }, }); - return { token: signIdentity({ brandId: brand.id, openid: session.openid }) }; + return { + token: signIdentity({ subject: session.openid, brandId: brand.id, role: "customer" }), + }; } } @@ -153,7 +136,9 @@ export class CustomerBookingController { const token = request.headers.authorization?.replace(/^Bearer\s+/i, ""); if (!token) throw new BadRequestException("缺少客户登录态"); try { - return readIdentity(token); + const identity = readIdentity(token); + if (identity.role !== "customer") throw new BadRequestException("客户登录态无效"); + return { brandId: identity.brandId, openid: identity.subject }; } catch (error) { if (error instanceof BadRequestException) throw error; throw new BadRequestException("客户登录态无效"); @@ -165,20 +150,20 @@ export class CustomerBookingController { export class AdminBookingController { constructor(private readonly bookings: BookingService) {} @Get() - list(): Promise { + list(@Req() request: FastifyRequest): Promise { + this.requireAdmin(request); return this.bookings.listForAdmin( process.env.DEFAULT_BRAND_ID ?? "00000000-0000-0000-0000-000000000001", ); } @Post(":id/status") update( + @Req() request: FastifyRequest, @Param("id") id: string, @Body() - body: { - status: "received" | "contacting" | "confirmed" | "completed" | "cancelled"; - adminNote?: string; - }, + body: UpdateBookingStatusDto, ): Promise { + this.requireAdmin(request); return this.bookings.updateStatus( process.env.DEFAULT_BRAND_ID ?? "00000000-0000-0000-0000-000000000001", id, @@ -186,6 +171,13 @@ export class AdminBookingController { body.adminNote, ); } + + private requireAdmin(request: FastifyRequest): void { + const token = request.headers.authorization?.replace(/^Bearer\s+/i, ""); + if (!token) throw new BadRequestException("缺少管理员登录态"); + const identity = readIdentity(token); + if (identity.role !== "admin") throw new BadRequestException("无管理员权限"); + } } @Module({