diff --git a/app/api/transfer/[path]/route.ts b/app/api/transfer/[path]/route.ts index 16b77ed..b90e97e 100644 --- a/app/api/transfer/[path]/route.ts +++ b/app/api/transfer/[path]/route.ts @@ -3,8 +3,9 @@ import { handleLoginData, handleRefreshToken, } from "@/components/login/libs/cookie" +import { getSessionCacheTtlMs } from "@/components/login/libs/session-cache" +import { getClientIpFromRequest } from "@/components/login/libs/session-common" import { genAccessToken } from "@/components/login/libs/session" -import { headers } from "next/headers" import { // NextRequest, NextResponse, @@ -16,7 +17,66 @@ const isParamsValid = (params: {} | null) => !Array.isArray(params) && // 排除数组(可选) Object.keys(params).length > 0 // 检查是否有自身可枚举属性 +const formatDurationMs = (durationMs: number) => + Number.isFinite(durationMs) ? durationMs.toFixed(2) : "0.00" + +const buildServerTimingHeader = (props: { + sessionLookupMs: number + redisLookupMs: number + sessionSource: "memory" | "shared" | "redis" | "skip" + upstreamFetchMs: number + totalMs: number +}) => { + const { + sessionLookupMs, + redisLookupMs, + sessionSource, + upstreamFetchMs, + totalMs, + } = props + const cacheTtlMs = getSessionCacheTtlMs() + + return [ + `session-lookup;dur=${formatDurationMs(sessionLookupMs)};desc="${sessionSource}"`, + `redis;dur=${formatDurationMs(redisLookupMs)};desc="${sessionSource === "redis" ? "get" : sessionSource}"`, + `upstream;dur=${formatDurationMs(upstreamFetchMs)}`, + `total;dur=${formatDurationMs(totalMs)}`, + `session-cache;dur=${sessionSource === "memory" ? "1" : "0"};desc="ttl=${cacheTtlMs}ms"`, + ].join(", ") +} + +const attachServerTiming = ( + response: Response | NextResponse, + props: { + sessionLookupMs: number + redisLookupMs: number + sessionSource: "memory" | "shared" | "redis" | "skip" + upstreamFetchMs: number + totalMs: number + } +) => { + const nextResponse = + response instanceof NextResponse + ? response + : new NextResponse(response.body, { + status: response.status, + headers: response.headers, + }) + + nextResponse.headers.set("Server-Timing", buildServerTimingHeader(props)) + + return nextResponse +} + export async function POST(req: any) { + const requestStart = performance.now() + const timing = { + sessionLookupMs: 0, + redisLookupMs: 0, + sessionSource: "skip" as "memory" | "shared" | "redis" | "skip", + upstreamFetchMs: 0, + } + try { const body = await req.json() @@ -33,28 +93,62 @@ export async function POST(req: any) { } = body if (isDeleteCookie) { await handleDeleteCookie() - return new NextResponse( - JSON.stringify({ - message: "Successfully delete cookie!", - code: 200, - status: true, - }), + return attachServerTiming( + new NextResponse( + JSON.stringify({ + message: "Successfully delete cookie!", + code: 200, + status: true, + }), + { + headers: { + "Content-Type": "application/json", + }, + } + ), { - headers: { - "Content-Type": "application/json", - }, + ...timing, + totalMs: performance.now() - requestStart, } ) } - const userData = await genAccessToken(req) + + let userData: any = null + if (!isLogin) { + const sessionResult = await genAccessToken(req) + timing.sessionLookupMs = sessionResult.timing.sessionLookupMs + timing.redisLookupMs = sessionResult.timing.redisLookupMs + timing.sessionSource = sessionResult.timing.sessionSource + + if (!sessionResult.ok) { + return attachServerTiming( + NextResponse.json( + { message: sessionResult.message || "未获取到用户信息", code: 401 }, + { status: 401 } + ), + { + ...timing, + totalMs: performance.now() - requestStart, + } + ) + } + + userData = sessionResult.userData + } let fetchOptions: any = {} if (isRefresh) { if (!userData?.refresh_token) { - return NextResponse.json( - { message: "Refresh token is empty", code: 406 }, - { status: 200 } + return attachServerTiming( + NextResponse.json( + { message: "Refresh token is empty", code: 406 }, + { status: 200 } + ), + { + ...timing, + totalMs: performance.now() - requestStart, + } ) } fetchOptions = { @@ -69,24 +163,30 @@ export async function POST(req: any) { // }), } } else { - const access_token = `${userData?.access_token}` + const requestHeaders: Record = { + "content-type": "application/json", + } + + if (!isLogin) { + requestHeaders.Token = `${userData?.access_token || ""}` + requestHeaders["Refresh-Token"] = `${userData?.refresh_token || ""}` + } + fetchOptions = { method, - headers: { - "content-type": "application/json", - ["Token"]: access_token, - ["Refresh-Token"]: userData?.refresh_token, - }, + headers: requestHeaders, } if (method === "POST" || method === "PUT" || method === "DELETE") { fetchOptions.body = data } else { if (isParamsValid(data)) { - let test = "" - for (const [key, value] of Object.entries(data)) { - test += `&${key}=${value}` + const queryString = new URLSearchParams( + Object.entries(data).map(([key, value]) => [key, String(value)]) + ).toString() + + if (queryString) { + url += url.includes("?") ? `&${queryString}` : `?${queryString}` } - url += `?${test}` } } } @@ -95,7 +195,9 @@ export async function POST(req: any) { const fetchUrl = isPrefixUrl ? url : `${process.env.NEXT_PUBLIC_URL}${url}` + const upstreamFetchStart = performance.now() const res = await fetch(fetchUrl, fetchOptions) + timing.upstreamFetchMs = performance.now() - upstreamFetchStart if (res?.status !== 200) { let error = "" @@ -105,7 +207,13 @@ export async function POST(req: any) { } else { error = await res.text() } - return NextResponse.json({ message: error }, { status: res?.status }) + return attachServerTiming( + NextResponse.json({ message: error }, { status: res?.status }), + { + ...timing, + totalMs: performance.now() - requestStart, + } + ) } if ( @@ -113,15 +221,37 @@ export async function POST(req: any) { options?.responseType === "arraybuffer" || isDownload ) { - const blob = await res.blob() - const buffer = await blob.arrayBuffer() - return new NextResponse(buffer, { - headers: { - "Content-Type": blob.type, - "x-real-name": res?.headers?.get("content-disposition") || "", - }, - }) + return attachServerTiming( + new NextResponse(res.body, { + status: res.status, + headers: { + "Content-Type": + res.headers.get("Content-Type") || "application/octet-stream", + "x-real-name": res?.headers?.get("content-disposition") || "", + }, + }), + { + ...timing, + totalMs: performance.now() - requestStart, + } + ) } else if (!notJson) { + if (!isLogin && !isRefresh) { + return attachServerTiming( + new NextResponse(res.body, { + status: res.status, + headers: { + "Content-Type": + res.headers.get("Content-Type") || "application/json", + }, + }), + { + ...timing, + totalMs: performance.now() - requestStart, + } + ) + } + try { const data = await (res.headers .get("Content-Type") @@ -130,48 +260,73 @@ export async function POST(req: any) { : res.text()) if (isLogin) { - const header = await headers() - let clientIP = - header.get("x-forwarded-for")?.split(",")[0] || - req?.socket?.remoteAddress - // ip v6地址 - if (clientIP.startsWith("::")) { - clientIP = clientIP.slice(7) - } const newData = await handleLoginData({ - clientIP: clientIP, + clientIP: getClientIpFromRequest(req), fingerprint: body.fingerprint, data, }) - return new NextResponse(JSON.stringify(newData), { - headers: { - "Content-Type": "application/json", - }, - }) + return attachServerTiming( + new NextResponse(JSON.stringify(newData), { + headers: { + "Content-Type": "application/json", + }, + }), + { + ...timing, + totalMs: performance.now() - requestStart, + } + ) } if (isRefresh) { const newData = await handleRefreshToken({ userData, data, }) - return new NextResponse(JSON.stringify(newData), { + return attachServerTiming( + new NextResponse(JSON.stringify(newData), { + headers: { + "Content-Type": "application/json", + }, + }), + { + ...timing, + totalMs: performance.now() - requestStart, + } + ) + } + return attachServerTiming( + new NextResponse(JSON.stringify(data), { headers: { "Content-Type": "application/json", }, - }) - } - return new NextResponse(JSON.stringify(data), { - headers: { - "Content-Type": "application/json", - }, - }) + }), + { + ...timing, + totalMs: performance.now() - requestStart, + } + ) } catch (err) { - return NextResponse.json({ error: err }, { status: 500 }) + return attachServerTiming( + NextResponse.json({ error: err }, { status: 500 }), + { + ...timing, + totalMs: performance.now() - requestStart, + } + ) } } else { - return res + return attachServerTiming(res, { + ...timing, + totalMs: performance.now() - requestStart, + }) } } catch (err) { - return NextResponse.json({ error: err }, { status: 500 }) + return attachServerTiming( + NextResponse.json({ error: err }, { status: 500 }), + { + ...timing, + totalMs: performance.now() - requestStart, + } + ) } } diff --git a/components/login/libs/cookie.ts b/components/login/libs/cookie.ts index 508453f..e4a39ae 100644 --- a/components/login/libs/cookie.ts +++ b/components/login/libs/cookie.ts @@ -1,6 +1,14 @@ import { Login } from "@/components/login/api/types" import redis from "@/components/login/libs/redis" -import { encrypt, generateKeyFromEnv } from "@/components/login/libs/session" +import { + deleteCachedSession, + setCachedSession, +} from "@/components/login/libs/session-cache" +import { + createSessionRedisKey, + getSessionRedisKeyFromCookieValue, + SESSION_COOKIE_NAME, +} from "@/components/login/libs/session-common" import { cookies } from "next/headers" export const handleLoginData = async (props: { @@ -10,36 +18,36 @@ export const handleLoginData = async (props: { }) => { const { clientIP, fingerprint, data } = props - const sessionKey = { - clientIP, - fingerprint: fingerprint, - } const cookieStore = await cookies() - const oldSession = cookieStore.get("session")?.value || "" - if (oldSession) { - redis.del(JSON.parse(oldSession) as any) + const oldSessionKey = getSessionRedisKeyFromCookieValue( + cookieStore.get(SESSION_COOKIE_NAME)?.value + ) + if (oldSessionKey) { + await redis.del(oldSessionKey) + deleteCachedSession(oldSessionKey) } - const key = await generateKeyFromEnv(process.env.ENCRYPTION_KEY || "") - const encryptedSessionData = await encrypt(JSON.stringify(sessionKey), key) + const sessionRedisKey = createSessionRedisKey() + const sessionUserData = { + clientIP, + fingerprint: fingerprint, + user_name: data?.name, + access_token: data.token, + refresh_token: data.refresh_token, + } // save redis await redis.set( - encryptedSessionData, - JSON.stringify({ - clientIP, - fingerprint: fingerprint, - user_name: data?.name, - access_token: data.token, - refresh_token: data.refresh_token, - }), + sessionRedisKey, + JSON.stringify(sessionUserData), "EX", 172800 ) + setCachedSession(sessionRedisKey, sessionUserData) // set cookie - cookieStore.set("session", JSON.stringify(encryptedSessionData), { + cookieStore.set(SESSION_COOKIE_NAME, sessionRedisKey, { httpOnly: true, secure: process.env.NEXT_PUBLIC_COOKIE_ENV === "production", maxAge: 60 * 60 * 24 * 30, // 30天 @@ -59,17 +67,26 @@ export const handleRefreshToken = async (props: { }) => { const { userData, data } = props const { redisKey, ...rest } = userData + const sessionRedisKey = getSessionRedisKeyFromCookieValue(redisKey) + + if (!sessionRedisKey) { + throw new Error("未获取到有效的 session key") + } + + const sessionUserData = { + ...rest, + user_name: data?.name, + access_token: data.token, + refresh_token: data.refresh_token, + } + await redis.set( - JSON.parse(redisKey), - JSON.stringify({ - ...rest, - user_name: data?.name, - access_token: data.token, - refresh_token: data.refresh_token, - }), + sessionRedisKey, + JSON.stringify(sessionUserData), "EX", 172800 ) + setCachedSession(sessionRedisKey, sessionUserData) return { ...data, token: "", @@ -80,11 +97,16 @@ export const handleRefreshToken = async (props: { export const handleDeleteCookie = async () => { const cookieStore = await cookies() - const sessionData = cookieStore.get("session")?.value || "" + const sessionData = cookieStore.get(SESSION_COOKIE_NAME)?.value || "" + const sessionRedisKey = getSessionRedisKeyFromCookieValue(sessionData) // 删cookie和redis中用户 + if (sessionRedisKey) { + await redis.del(sessionRedisKey) + deleteCachedSession(sessionRedisKey) + } + if (sessionData) { - await redis.del(JSON.parse(sessionData) as any) - cookieStore.delete("session") + cookieStore.delete(SESSION_COOKIE_NAME) } } diff --git a/components/login/libs/session-cache.ts b/components/login/libs/session-cache.ts new file mode 100644 index 0000000..3aa017b --- /dev/null +++ b/components/login/libs/session-cache.ts @@ -0,0 +1,96 @@ +const SESSION_CACHE_TTL_MS = 5000 + +type SessionCacheSource = "memory" | "shared" | "redis" + +interface SessionCacheEntry { + data: T + expiresAt: number +} + +const sessionCache = new Map>() +const inflightSessionLoads = new Map>() + +const getNow = () => Date.now() + +const getValidCachedSession = (redisKey: string) => { + const cacheEntry = sessionCache.get(redisKey) + + if (!cacheEntry) { + return null + } + + if (cacheEntry.expiresAt <= getNow()) { + sessionCache.delete(redisKey) + return null + } + + return cacheEntry.data as T +} + +export const getCachedSession = (redisKey: string) => + getValidCachedSession(redisKey) + +export const setCachedSession = (redisKey: string, data: T) => { + sessionCache.set(redisKey, { + data, + expiresAt: getNow() + SESSION_CACHE_TTL_MS, + }) +} + +export const deleteCachedSession = (redisKey: string) => { + sessionCache.delete(redisKey) + inflightSessionLoads.delete(redisKey) +} + +export const getSessionCacheTtlMs = () => SESSION_CACHE_TTL_MS + +export const getOrLoadSession = async ( + redisKey: string, + loader: () => Promise +): Promise<{ + data: T | null + source: SessionCacheSource +}> => { + const cachedData = getValidCachedSession(redisKey) + + if (cachedData) { + return { + data: cachedData, + source: "memory", + } + } + + const inflightLoad = inflightSessionLoads.get(redisKey) + + if (inflightLoad) { + return { + data: (await inflightLoad) as T | null, + source: "shared", + } + } + + const loadPromise = (async () => { + const loadedData = await loader() + + if (loadedData) { + setCachedSession(redisKey, loadedData) + } else { + deleteCachedSession(redisKey) + } + + return loadedData + })() + + inflightSessionLoads.set(redisKey, loadPromise) + + try { + return { + data: await loadPromise, + source: "redis", + } + } finally { + if (inflightSessionLoads.get(redisKey) === loadPromise) { + inflightSessionLoads.delete(redisKey) + } + } +} diff --git a/components/login/libs/session-common.ts b/components/login/libs/session-common.ts new file mode 100644 index 0000000..8affe13 --- /dev/null +++ b/components/login/libs/session-common.ts @@ -0,0 +1,34 @@ +export const SESSION_COOKIE_NAME = "session" + +const SESSION_KEY_PREFIX = "label:session:" + +const normalizeClientIp = (ip: string) => ip.replace(/^::ffff:/, "") + +export const createSessionRedisKey = () => + `${SESSION_KEY_PREFIX}${crypto.randomUUID()}` + +export const getSessionRedisKeyFromCookieValue = ( + sessionCookieValue?: string | null +) => { + if (!sessionCookieValue) { + return "" + } + + try { + const parsedValue = JSON.parse(sessionCookieValue) + return typeof parsedValue === "string" ? parsedValue : "" + } catch { + return sessionCookieValue + } +} + +export const getClientIpFromRequest = (req: any) => { + const forwardedFor = + req?.headers?.get?.("x-forwarded-for") || + req?.headers?.["x-forwarded-for"] || + "" + const clientIP = + forwardedFor?.split(",")[0]?.trim() || req?.socket?.remoteAddress || "" + + return normalizeClientIp(clientIP) +} diff --git a/components/login/libs/session.ts b/components/login/libs/session.ts index f220a0a..fc3616f 100644 --- a/components/login/libs/session.ts +++ b/components/login/libs/session.ts @@ -1,156 +1,113 @@ "use server" -import { Base64 } from "js-base64" -import { headers } from "next/headers" import redis from "./redis" +import { deleteCachedSession, getOrLoadSession } from "./session-cache" +import { + getClientIpFromRequest, + getSessionRedisKeyFromCookieValue, + SESSION_COOKIE_NAME, +} from "./session-common" -// 生成加密密钥 -export async function generateKey(): Promise { - return crypto.subtle.generateKey( - { - name: "AES-CBC", - length: 256, - }, - true, - ["encrypt", "decrypt"] - ) -} - -export async function generateKeyFromEnv( - envPassword: string -): Promise { - const encoder = new TextEncoder() - const password = encoder.encode(envPassword) - const keyMaterial = await crypto.subtle.importKey( - "raw", - password, - { - name: "PBKDF2", - }, - false, - ["deriveKey"] - ) - return crypto.subtle.deriveKey( - { - name: "PBKDF2", - salt: new Uint8Array(16), // 随机盐值 - iterations: 100000, // 迭代次数 - hash: "SHA-256", - }, - keyMaterial, - { - name: "AES-CBC", - length: 256, - }, - true, - ["encrypt", "decrypt"] - ) -} - -// 将ArrayBuffer转换为Uint8Array -function arrayBufferToUint8Array(buffer: ArrayBuffer) { - return new Uint8Array(buffer) -} - -// 加密函数 -export async function encrypt(data: any, key: CryptoKey): Promise { - const encodedData = new TextEncoder().encode(JSON.stringify(data)) - const iv = crypto.getRandomValues(new Uint8Array(16)) // 16 bytes for AES block size - const encrypted = await crypto.subtle.encrypt( - { - name: "AES-CBC", - iv: iv, - }, - key, - encodedData - ) - - // 将Uint8Array转换为Base64字符串,以便存储和传输 - const encryptedBase64 = Base64.fromUint8Array( - arrayBufferToUint8Array(encrypted) - ) - const ivBase64 = Base64.fromUint8Array(new Uint8Array(iv)) // 使用js-base64进行编码 - - // 将IV和加密数据拼接在一起,以便解密时使用 - return `${ivBase64}::${encryptedBase64}` -} - -// 解密函数 -export async function decrypt( - encryptedData: string, - key: CryptoKey -): Promise { - // 将加密数据从Base64字符串转换回Uint8Array - const [ivBase64, encryptedBase64] = encryptedData.split("::") - - const ivArray = Buffer.from(ivBase64, "base64") - - const encrypted = Buffer.from(encryptedBase64, "base64") - - // 使用AES-CBC算法进行解密 - const decrypted = await crypto.subtle.decrypt( - { - name: "AES-CBC", - iv: ivArray, - }, - key, - encrypted - ) - - // 将ArrayBuffer转换为字符串 - return new TextDecoder().decode(decrypted) -} +const roundDurationMs = (durationMs: number) => + Math.round(durationMs * 100) / 100 // 校验用户信息 export async function genAccessToken(req: any) { + const sessionLookupStart = performance.now() + let redisLookupMs = 0 + let sessionSource: "memory" | "shared" | "redis" | "skip" = "skip" + try { - // 校验用户信息 - // const fingerprint = req.headers.get("Fingerprint") - const sessionData = req.cookies.get("session")?.value + const sessionData = req?.cookies?.get?.(SESSION_COOKIE_NAME)?.value + const redisKey = getSessionRedisKeyFromCookieValue(sessionData) // 无session 直接重定向到登录页面 - if (!sessionData) { - return "客户端请求中,未包含用户标识" + if (!redisKey) { + return { + ok: false as const, + message: "客户端请求中,未包含用户标识", + timing: { + sessionLookupMs: roundDurationMs( + performance.now() - sessionLookupStart + ), + redisLookupMs: 0, + sessionSource, + }, + } } - let redisKey = JSON.parse(sessionData) + const sessionResult = await getOrLoadSession(redisKey, async () => { + const redisLookupStart = performance.now() + const userDataString = await redis.get(redisKey) + redisLookupMs = roundDurationMs(performance.now() - redisLookupStart) - const key = await generateKeyFromEnv(process.env.ENCRYPTION_KEY || "") - const decryptedSessionData = JSON.parse(await decrypt(redisKey, key)) - const fingerprint = JSON.parse(decryptedSessionData)?.fingerprint + if (!userDataString) { + return null + } - // 获取用户信息 - let userData: any = await redis.get(redisKey) + return JSON.parse(userDataString) + }) - userData = JSON.parse(userData) + sessionSource = sessionResult.source // redis中无用户信息 直接重定向到登录页面 - if (!userData) { - return "未获取到用户信息" + if (!sessionResult.data) { + deleteCachedSession(redisKey) + return { + ok: false as const, + message: "未获取到用户信息", + timing: { + sessionLookupMs: roundDurationMs( + performance.now() - sessionLookupStart + ), + redisLookupMs, + sessionSource, + }, + } } - const header = headers() - let clientIP = - (await header).get("x-forwarded-for")?.split(",")[0] || - req?.socket?.remoteAddress - // ip v6地址 - if (clientIP.startsWith("::")) { - clientIP = clientIP.slice(7) - } - // 登录ip与用户请求ip不匹配 + const userData = sessionResult.data + const clientIP = getClientIpFromRequest(req) - if ( - clientIP !== userData?.clientIP || - fingerprint !== userData?.fingerprint - ) { - return `客户端信息与缓存用户信息不一致 \n 客户端: ${clientIP}, ${fingerprint} | redis存储: ${userData?.clientIP}, ${userData?.fingerprint}` + if (clientIP !== userData?.clientIP) { + return { + ok: false as const, + message: `客户端信息与缓存用户信息不一致 \n 客户端: ${clientIP} | redis存储: ${userData?.clientIP}`, + timing: { + sessionLookupMs: roundDurationMs( + performance.now() - sessionLookupStart + ), + redisLookupMs, + sessionSource, + }, + } } return { - ...userData, - redisKey: sessionData, + ok: true as const, + userData: { + ...userData, + redisKey, + }, + timing: { + sessionLookupMs: roundDurationMs( + performance.now() - sessionLookupStart + ), + redisLookupMs, + sessionSource, + }, } } catch (err: any) { - return `获取用户信息报错, 报错原因:${err?.message}` + return { + ok: false as const, + message: `获取用户信息报错, 报错原因:${err?.message}`, + timing: { + sessionLookupMs: roundDurationMs( + performance.now() - sessionLookupStart + ), + redisLookupMs, + sessionSource, + }, + } } } diff --git a/middleware.ts b/middleware.ts index 269dc8e..270c1e7 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,4 +1,7 @@ -import { cookies } from "next/headers" +import { + getSessionRedisKeyFromCookieValue, + SESSION_COOKIE_NAME, +} from "@/components/login/libs/session-common" import { NextRequest, NextResponse } from "next/server" const publicRoutes = ["/login", "/"] @@ -22,11 +25,10 @@ export default async function middleware(req: NextRequest) { // const isProtectedRoute = protectedRoutes.includes(path) const isPublicRoute = publicRoutes.includes(path) - // 3. Decrypt the session from the cookie - - const cookie = (await cookies()).get("session")?.value - - const session = cookie ? JSON.parse(cookie) : "" + // 3. Resolve the session key from the cookie + const session = getSessionRedisKeyFromCookieValue( + req.cookies.get(SESSION_COOKIE_NAME)?.value + ) // 4. Redirect to /login if the user is not authenticated if (!isPublicRoute && !session) {