perf(session): ttl
This commit is contained in:
@@ -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<string, string> = {
|
||||
"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,
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
96
components/login/libs/session-cache.ts
Normal file
96
components/login/libs/session-cache.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
34
components/login/libs/session-common.ts
Normal file
34
components/login/libs/session-common.ts
Normal 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)
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user