197 lines
5.9 KiB
TypeScript
197 lines
5.9 KiB
TypeScript
// server/utils/newApiAuthCookies.ts - NewAPI 登录态 cookie 工具:提取上游 session 并用 httpOnly 保存。
|
|
import type { H3Event } from "h3";
|
|
import { createError, deleteCookie, getCookie, 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);
|
|
};
|
|
|
|
/** 从本项目 httpOnly cookie 中读取 NewAPI 用户 ID,缺失或非法时统一视为未登录 */
|
|
export const getNewApiUserIdFromCookie = (event: H3Event): number => {
|
|
const userId = Number.parseInt(
|
|
getCookie(event, NEWAPI_USER_ID_COOKIE) ?? "",
|
|
10
|
|
);
|
|
|
|
if (!Number.isInteger(userId) || userId <= 0) {
|
|
throw createError({
|
|
statusCode: 401,
|
|
statusMessage: "未登录"
|
|
});
|
|
}
|
|
|
|
return userId;
|
|
};
|
|
|
|
/** 统一生成本站认证 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);
|
|
};
|