feat: 完成登录session处理

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-04-24 22:22:21 +08:00
parent 5294528148
commit 81965595f0
15 changed files with 804 additions and 240 deletions
+77 -2
View File
@@ -1,6 +1,23 @@
/** 上游 NewAPI 服务的根地址 */
import type { H3Event } from "h3";
import { createError, getCookie } from "h3";
import {
NEWAPI_SESSION_COOKIE,
NEWAPI_USER_ID_COOKIE
} from "~~/server/utils/newApiAuthCookies";
/** 上游 NewAPI 服务的根地址,只在这里维护一次 */
const BASE_URL = "https://api.qflink.xyz";
type FetchOptions = Parameters<typeof $fetch>[1];
type FetchHeaders = HeadersInit | undefined;
interface INewApiRawResponse<T> {
/** 上游响应 bodyofetch raw response 使用 _data 保存解析后的 body */
_data?: T;
/** 上游响应头,登录时需要从 Set-Cookie 中提取 session */
headers: Headers;
}
/**
* 向上游 NewAPI 发起请求
*
@@ -16,10 +33,68 @@ const BASE_URL = "https://api.qflink.xyz";
*/
export const newApiFetch = <T>(
path: string,
options?: Parameters<typeof $fetch>[1]
options?: FetchOptions
): Promise<T> => {
return $fetch<T>(path, {
baseURL: BASE_URL,
...options
}) as Promise<T>;
};
/**
* 向上游 NewAPI 发起请求,并保留响应头。
*
* 仅在登录这类需要读取 Set-Cookie 的场景使用;普通业务请求继续用
* newApiFetch,保证上游请求入口仍然集中在本文件。
*/
export const newApiFetchRaw = async <T>(
path: string,
options?: FetchOptions
): Promise<INewApiRawResponse<T>> => {
const response = await $fetch.raw<T>(path, {
baseURL: BASE_URL,
...options
});
return {
_data: response._data as T | undefined,
headers: response.headers
};
};
/**
* 向上游 NewAPI 发起已登录请求。
*
* 前端不读取 NewAPI session;这里只在 Nuxt server 内从本站 httpOnly
* cookie 读取 session/userId,并按 NewAPI 要求补齐 Cookie 与 New-Api-User。
*/
export const newApiAuthedFetch = <T>(
event: H3Event,
path: string,
options?: FetchOptions
): Promise<T> => {
const session = getCookie(event, NEWAPI_SESSION_COOKIE);
const userId = getCookie(event, NEWAPI_USER_ID_COOKIE);
if (!session || !userId) {
throw createError({
statusCode: 401,
statusMessage: "未登录"
});
}
return newApiFetch<T>(path, {
...options,
headers: {
...normalizeHeaders(options?.headers),
Cookie: `session=${session}`,
"New-Api-User": userId
}
});
};
/** 归一化调用方 headers,追加鉴权头时不丢失已有 Content-Type */
const normalizeHeaders = (headers: FetchHeaders) => {
if (!headers) return {};
return Object.fromEntries(new Headers(headers).entries());
};
+1
View File
@@ -1,2 +1,3 @@
export * from "./createApiResponse";
export * from "./fetch";
export * from "./newApiAuthCookies";
+178
View File
@@ -0,0 +1,178 @@
import type { H3Event } from "h3";
import { deleteCookie, setCookie } from "h3";
/** 上游没有声明 session 过期时间时,本地登录态默认保留 3 天 */
const DEFAULT_AUTH_COOKIE_MAX_AGE = 60 * 60 * 24 * 3;
/** 本站保存 NewAPI session 的 httpOnly cookie 名称 */
export const NEWAPI_SESSION_COOKIE = "newapi_session";
/** 本站保存 NewAPI 用户 ID 的 httpOnly cookie 名称,用于服务端组装 New-Api-User */
export const NEWAPI_USER_ID_COOKIE = "newapi_user_id";
type CookieOptions = NonNullable<Parameters<typeof setCookie>[3]>;
export interface INewApiCookieMeta {
/** 上游 session cookie 的 Max-Age,单位秒 */
maxAge?: number;
/** 上游 session cookie 的 Expires 过期时间 */
expires?: Date;
}
/**
* 从上游 Set-Cookie 响应头中提取指定 cookie 的值。
*
* 只返回 cookie value,不返回 Path/Expires 等属性,避免把元数据写进
* 本站 newapi_session。
*/
export const extractCookieValueFromSetCookie = (
headers: Headers,
name: string
): string | null => {
const setCookie = findSetCookie(headers, name);
if (!setCookie) return null;
const firstPart = setCookie.split(";")[0] ?? "";
const equalsIndex = firstPart.indexOf("=");
if (equalsIndex === -1) return null;
return firstPart.slice(equalsIndex + 1).trim() || null;
};
/**
* 从上游 Set-Cookie 中读取过期时间元数据。
*
* 本站 cookie 优先沿用上游 Max-Age/Expires;没有则在写入时默认 3 天。
*/
export const extractCookieMetaFromSetCookie = (
headers: Headers,
name: string
): INewApiCookieMeta | undefined => {
const setCookie = findSetCookie(headers, name);
if (!setCookie) return undefined;
const meta: INewApiCookieMeta = {};
for (const part of setCookie.split(";").slice(1)) {
const [rawKey, ...rawValue] = part.trim().split("=");
const key = rawKey?.toLowerCase();
const value = rawValue.join("=");
if (key === "max-age") {
const maxAge = Number.parseInt(value, 10);
if (Number.isFinite(maxAge)) meta.maxAge = maxAge;
}
if (key === "expires") {
const expires = new Date(value);
if (!Number.isNaN(expires.getTime())) meta.expires = expires;
}
}
return meta.maxAge !== undefined || meta.expires ? meta : undefined;
};
/**
* 写入本站认证 cookie。
*
* session 与 userId 都设置为 httpOnly,前端只通过 /api/auth/me 恢复状态,
* 不直接接触 NewAPI session 明文。
*/
export const setNewApiAuthCookies = (
event: H3Event,
session: string,
userId: number | string,
upstreamCookieMeta?: INewApiCookieMeta
) => {
const options = buildAuthCookieOptions(upstreamCookieMeta);
setCookie(event, NEWAPI_SESSION_COOKIE, session, options);
setCookie(event, NEWAPI_USER_ID_COOKIE, String(userId), options);
};
/** 清理本站保存的 NewAPI 登录态,登出和鉴权失败时都调用 */
export const clearNewApiAuthCookies = (event: H3Event) => {
const options: CookieOptions = {
path: "/",
sameSite: "lax",
secure: process.env.NODE_ENV === "production"
};
deleteCookie(event, NEWAPI_SESSION_COOKIE, options);
deleteCookie(event, NEWAPI_USER_ID_COOKIE, options);
};
/** 统一生成本站认证 cookie 选项,保证安全属性在两个 cookie 上一致 */
const buildAuthCookieOptions = (meta?: INewApiCookieMeta): CookieOptions => {
const options: CookieOptions = {
httpOnly: true,
path: "/",
sameSite: "lax",
secure: process.env.NODE_ENV === "production"
};
if (typeof meta?.maxAge === "number") {
options.maxAge = meta.maxAge;
} else if (meta?.expires) {
options.expires = meta.expires;
} else {
options.maxAge = DEFAULT_AUTH_COOKIE_MAX_AGE;
}
return options;
};
/** 在多个 Set-Cookie 值中找到指定名称的 cookie */
const findSetCookie = (headers: Headers, name: string): string | null => {
const lowerName = name.toLowerCase();
for (const setCookie of getSetCookieValues(headers)) {
const firstPart = setCookie.split(";")[0] ?? "";
const equalsIndex = firstPart.indexOf("=");
if (equalsIndex === -1) continue;
const cookieName = firstPart.slice(0, equalsIndex).trim().toLowerCase();
if (cookieName === lowerName) return setCookie;
}
return null;
};
/**
* 读取 Set-Cookie 头。
*
* Nuxt 服务端运行时优先使用 getSetCookie;没有该方法时读取合并后的
* set-cookie 字符串再拆分。
*/
const getSetCookieValues = (headers: Headers): string[] => {
const headersWithSetCookie = headers as Headers & {
getSetCookie?: () => string[];
};
const values = headersWithSetCookie.getSetCookie?.();
if (values?.length) return values;
const headerValue = headers.get("set-cookie");
return headerValue ? splitSetCookieHeader(headerValue) : [];
};
/**
* 拆分被合并成一个字符串的 Set-Cookie。
*
* Expires 属性本身包含逗号,不能简单按逗号 split;这里只在后续片段
* 看起来像新 cookie 名称时才切分。
*/
const splitSetCookieHeader = (headerValue: string): string[] => {
const cookies: string[] = [];
let start = 0;
for (let index = 0; index < headerValue.length; index += 1) {
if (headerValue[index] !== ",") continue;
const rest = headerValue.slice(index + 1);
if (/^\s*[^=;,\s]+=/.test(rest)) {
cookies.push(headerValue.slice(start, index).trim());
start = index + 1;
}
}
cookies.push(headerValue.slice(start).trim());
return cookies.filter(Boolean);
};