perf(session): ttl

This commit is contained in:
zhangheng
2026-04-23 14:47:14 +08:00
parent cd1bad6da9
commit 5b38f684c3
6 changed files with 488 additions and 222 deletions

View File

@@ -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)
}
}

View File

@@ -0,0 +1,96 @@
const SESSION_CACHE_TTL_MS = 5000
type SessionCacheSource = "memory" | "shared" | "redis"
interface SessionCacheEntry<T> {
data: T
expiresAt: number
}
const sessionCache = new Map<string, SessionCacheEntry<any>>()
const inflightSessionLoads = new Map<string, Promise<any | null>>()
const getNow = () => Date.now()
const getValidCachedSession = <T>(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 = <T>(redisKey: string) =>
getValidCachedSession<T>(redisKey)
export const setCachedSession = <T>(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 <T>(
redisKey: string,
loader: () => Promise<T | null>
): Promise<{
data: T | null
source: SessionCacheSource
}> => {
const cachedData = getValidCachedSession<T>(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)
}
}
}

View File

@@ -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)
}

View File

@@ -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<CryptoKey> {
return crypto.subtle.generateKey(
{
name: "AES-CBC",
length: 256,
},
true,
["encrypt", "decrypt"]
)
}
export async function generateKeyFromEnv(
envPassword: string
): Promise<CryptoKey> {
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<string> {
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<any> {
// 将加密数据从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,
},
}
}
}