2
0

auth.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388
  1. import { cookies, headers } from "next/headers";
  2. import type { NextResponse } from "next/server";
  3. import { config } from "@/lib/config/config";
  4. import { getEnvConfig } from "@/lib/config/env.schema";
  5. import { logger } from "@/lib/logger";
  6. import { findKeyList, validateApiKeyAndGetUser } from "@/repository/key";
  7. import type { Key } from "@/types/key";
  8. import type { User } from "@/types/user";
  9. /**
  10. * Apply no-store / cache-busting headers to auth responses that mutate session state.
  11. * Prevents browsers and intermediary caches from storing sensitive auth responses.
  12. */
  13. export function withNoStoreHeaders<T extends NextResponse>(response: T): T {
  14. response.headers.set("Cache-Control", "no-store, no-cache, must-revalidate");
  15. response.headers.set("Pragma", "no-cache");
  16. return response;
  17. }
  18. export type ScopedAuthContext = {
  19. session: AuthSession;
  20. /**
  21. * 本次请求在 adapter 层 validateKey 时使用的 allowReadOnlyAccess 参数。
  22. * - true:允许 canLoginWebUi=false 的 key 作为“只读会话”使用
  23. * - false:严格要求 canLoginWebUi=true
  24. */
  25. allowReadOnlyAccess: boolean;
  26. };
  27. export type AuthSessionStorage = {
  28. run<T>(store: ScopedAuthContext, callback: () => T): T;
  29. getStore(): ScopedAuthContext | undefined;
  30. };
  31. declare global {
  32. // eslint-disable-next-line no-var
  33. var __cchAuthSessionStorage: AuthSessionStorage | undefined;
  34. }
  35. export const AUTH_COOKIE_NAME = "auth-token";
  36. const AUTH_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; // 7 days
  37. export interface AuthSession {
  38. user: User;
  39. key: Key;
  40. }
  41. export type SessionTokenMode = "legacy" | "dual" | "opaque";
  42. export type SessionTokenKind = "legacy" | "opaque";
  43. export function getSessionTokenMode(): SessionTokenMode {
  44. return getEnvConfig().SESSION_TOKEN_MODE;
  45. }
  46. // Session contract: opaque token is a random string, not the API key
  47. export interface OpaqueSessionContract {
  48. sessionId: string; // random opaque token
  49. keyFingerprint: string; // hash of the API key (for audit, not auth)
  50. createdAt: number; // unix timestamp
  51. expiresAt: number; // unix timestamp
  52. userId: number;
  53. userRole: string;
  54. }
  55. export interface SessionTokenMigrationFlags {
  56. dualReadWindowEnabled: boolean;
  57. hardCutoverEnabled: boolean;
  58. emergencyRollbackEnabled: boolean;
  59. }
  60. export const SESSION_TOKEN_SEMANTICS = {
  61. expiry: "hard_expiry_at_expires_at",
  62. rotation: "rotate_before_expiry_and_revoke_previous_session_id",
  63. revocation: "server_side_revocation_invalidates_session_immediately",
  64. compatibility: {
  65. legacy: "accept_legacy_only",
  66. dual: "accept_legacy_and_opaque",
  67. opaque: "accept_opaque_only",
  68. },
  69. } as const;
  70. export function getSessionTokenMigrationFlags(
  71. mode: SessionTokenMode = getSessionTokenMode()
  72. ): SessionTokenMigrationFlags {
  73. return {
  74. dualReadWindowEnabled: mode === "dual",
  75. hardCutoverEnabled: mode === "opaque",
  76. emergencyRollbackEnabled: mode === "legacy",
  77. };
  78. }
  79. export function isSessionTokenKindAccepted(
  80. mode: SessionTokenMode,
  81. kind: SessionTokenKind
  82. ): boolean {
  83. if (mode === "dual") return true;
  84. if (mode === "legacy") return kind === "legacy";
  85. return kind === "opaque";
  86. }
  87. export function isOpaqueSessionContract(value: unknown): value is OpaqueSessionContract {
  88. if (!value || typeof value !== "object") return false;
  89. const candidate = value as Record<string, unknown>;
  90. return (
  91. typeof candidate.sessionId === "string" &&
  92. candidate.sessionId.length > 0 &&
  93. typeof candidate.keyFingerprint === "string" &&
  94. candidate.keyFingerprint.length > 0 &&
  95. typeof candidate.createdAt === "number" &&
  96. Number.isFinite(candidate.createdAt) &&
  97. typeof candidate.expiresAt === "number" &&
  98. Number.isFinite(candidate.expiresAt) &&
  99. candidate.expiresAt > candidate.createdAt &&
  100. typeof candidate.userId === "number" &&
  101. Number.isInteger(candidate.userId) &&
  102. typeof candidate.userRole === "string" &&
  103. candidate.userRole.length > 0
  104. );
  105. }
  106. const OPAQUE_SESSION_ID_PREFIX = "sid_";
  107. export function detectSessionTokenKind(token: string): SessionTokenKind {
  108. const trimmed = token.trim();
  109. if (!trimmed) return "legacy";
  110. return trimmed.startsWith(OPAQUE_SESSION_ID_PREFIX) ? "opaque" : "legacy";
  111. }
  112. export function isSessionTokenAccepted(
  113. token: string,
  114. mode: SessionTokenMode = getSessionTokenMode()
  115. ): boolean {
  116. return isSessionTokenKindAccepted(mode, detectSessionTokenKind(token));
  117. }
  118. export function runWithAuthSession<T>(
  119. session: AuthSession,
  120. fn: () => T,
  121. options?: { allowReadOnlyAccess?: boolean }
  122. ): T {
  123. const storage = globalThis.__cchAuthSessionStorage;
  124. if (!storage) return fn();
  125. return storage.run({ session, allowReadOnlyAccess: options?.allowReadOnlyAccess ?? false }, fn);
  126. }
  127. export function getScopedAuthSession(): AuthSession | null {
  128. const storage = globalThis.__cchAuthSessionStorage;
  129. return storage?.getStore()?.session ?? null;
  130. }
  131. export function getScopedAuthContext(): ScopedAuthContext | null {
  132. const storage = globalThis.__cchAuthSessionStorage;
  133. return storage?.getStore() ?? null;
  134. }
  135. export async function validateKey(
  136. keyString: string,
  137. options?: {
  138. /**
  139. * 允许仅访问只读页面(如 my-usage),跳过 canLoginWebUi 校验
  140. */
  141. allowReadOnlyAccess?: boolean;
  142. }
  143. ): Promise<AuthSession | null> {
  144. const allowReadOnlyAccess = options?.allowReadOnlyAccess ?? false;
  145. const adminToken = config.auth.adminToken;
  146. if (adminToken && keyString === adminToken) {
  147. const now = new Date();
  148. const adminUser: User = {
  149. id: -1,
  150. name: "Admin Token",
  151. description: "Environment admin session",
  152. role: "admin",
  153. rpm: 0,
  154. dailyQuota: 0,
  155. providerGroup: null,
  156. isEnabled: true,
  157. expiresAt: null,
  158. dailyResetMode: "fixed",
  159. dailyResetTime: "00:00",
  160. createdAt: now,
  161. updatedAt: now,
  162. };
  163. const adminKey: Key = {
  164. id: -1,
  165. userId: adminUser.id,
  166. name: "ADMIN_TOKEN",
  167. key: keyString,
  168. isEnabled: true,
  169. canLoginWebUi: true, // Admin Token
  170. providerGroup: null,
  171. limit5hUsd: null,
  172. limitDailyUsd: null,
  173. dailyResetMode: "fixed",
  174. dailyResetTime: "00:00",
  175. limitWeeklyUsd: null,
  176. limitMonthlyUsd: null,
  177. limitConcurrentSessions: 0,
  178. cacheTtlPreference: null,
  179. createdAt: now,
  180. updatedAt: now,
  181. };
  182. return { user: adminUser, key: adminKey };
  183. }
  184. // 默认鉴权链路:Vacuum Filter(仅负向短路) → Redis(key/user 缓存) → DB(权威校验)
  185. const authResult = await validateApiKeyAndGetUser(keyString);
  186. if (!authResult) {
  187. return null;
  188. }
  189. const { user, key } = authResult;
  190. // 用户状态校验:与 v1 proxy 侧保持一致,避免禁用/过期用户继续登录或持有会话
  191. if (!user.isEnabled) {
  192. return null;
  193. }
  194. if (user.expiresAt && user.expiresAt.getTime() <= Date.now()) {
  195. return null;
  196. }
  197. // 检查 Web UI 登录权限
  198. if (!allowReadOnlyAccess && !key.canLoginWebUi) {
  199. return null;
  200. }
  201. return { user, key };
  202. }
  203. export function getLoginRedirectTarget(session: AuthSession): string {
  204. if (session.user.role === "admin") return "/dashboard";
  205. if (session.key.canLoginWebUi) return "/dashboard";
  206. return "/my-usage";
  207. }
  208. export async function setAuthCookie(keyString: string) {
  209. const cookieStore = await cookies();
  210. const env = getEnvConfig();
  211. cookieStore.set(AUTH_COOKIE_NAME, keyString, {
  212. httpOnly: true,
  213. secure: env.ENABLE_SECURE_COOKIES,
  214. sameSite: "lax",
  215. maxAge: AUTH_COOKIE_MAX_AGE,
  216. path: "/",
  217. });
  218. }
  219. export async function getAuthCookie(): Promise<string | undefined> {
  220. const cookieStore = await cookies();
  221. return cookieStore.get(AUTH_COOKIE_NAME)?.value;
  222. }
  223. export async function clearAuthCookie() {
  224. const cookieStore = await cookies();
  225. cookieStore.delete(AUTH_COOKIE_NAME);
  226. }
  227. export async function getSession(options?: {
  228. /**
  229. * 允许仅访问只读页面(如 my-usage),跳过 canLoginWebUi 校验
  230. */
  231. allowReadOnlyAccess?: boolean;
  232. }): Promise<AuthSession | null> {
  233. // 优先读取 adapter 注入的请求级会话(适配 /api/actions 等非 Next 原生上下文场景)
  234. const scoped = getScopedAuthContext();
  235. if (scoped) {
  236. // 关键:scoped 会话必须遵循其"创建时语义",仅允许内部显式降权(不允许提权)
  237. const effectiveAllowReadOnlyAccess =
  238. scoped.allowReadOnlyAccess && (options?.allowReadOnlyAccess ?? true);
  239. if (!effectiveAllowReadOnlyAccess && !scoped.session.key.canLoginWebUi) {
  240. return null;
  241. }
  242. return scoped.session;
  243. }
  244. const keyString = await getAuthToken();
  245. if (!keyString) {
  246. return null;
  247. }
  248. return validateKey(keyString, options);
  249. }
  250. type SessionStoreReader = {
  251. read(sessionId: string): Promise<OpaqueSessionContract | null>;
  252. };
  253. let sessionStorePromise: Promise<SessionStoreReader> | null = null;
  254. async function getSessionStore(): Promise<SessionStoreReader> {
  255. if (!sessionStorePromise) {
  256. sessionStorePromise = import("@/lib/auth-session-store/redis-session-store").then(
  257. ({ RedisSessionStore }) => new RedisSessionStore()
  258. );
  259. }
  260. return sessionStorePromise;
  261. }
  262. async function toKeyFingerprint(keyString: string): Promise<string> {
  263. const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(keyString));
  264. const hex = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(
  265. ""
  266. );
  267. return `sha256:${hex}`;
  268. }
  269. function normalizeKeyFingerprint(fingerprint: string): string {
  270. return fingerprint.startsWith("sha256:") ? fingerprint : `sha256:${fingerprint}`;
  271. }
  272. async function convertToAuthSession(
  273. sessionData: OpaqueSessionContract,
  274. options?: { allowReadOnlyAccess?: boolean }
  275. ): Promise<AuthSession | null> {
  276. const keyList = await findKeyList(sessionData.userId);
  277. const expectedFingerprint = normalizeKeyFingerprint(sessionData.keyFingerprint);
  278. for (const key of keyList) {
  279. const keyFingerprint = await toKeyFingerprint(key.key);
  280. if (keyFingerprint === expectedFingerprint) {
  281. return validateKey(key.key, options);
  282. }
  283. }
  284. return null;
  285. }
  286. export async function getSessionWithDualRead(options?: {
  287. allowReadOnlyAccess?: boolean;
  288. }): Promise<AuthSession | null> {
  289. const mode = getSessionTokenMode();
  290. if (mode === "opaque" || mode === "dual") {
  291. const sessionId = await getAuthToken();
  292. if (sessionId) {
  293. try {
  294. const sessionStore = await getSessionStore();
  295. const sessionData = await sessionStore.read(sessionId);
  296. if (sessionData) {
  297. const session = await convertToAuthSession(sessionData, options);
  298. if (session) {
  299. return session;
  300. }
  301. }
  302. } catch (error) {
  303. logger.warn("Opaque session read failed", {
  304. error: error instanceof Error ? error.message : String(error),
  305. });
  306. }
  307. }
  308. }
  309. if (mode === "legacy" || mode === "dual") {
  310. return getSession(options);
  311. }
  312. return null;
  313. }
  314. export async function validateSession(options?: {
  315. allowReadOnlyAccess?: boolean;
  316. }): Promise<AuthSession | null> {
  317. return getSessionWithDualRead(options);
  318. }
  319. function parseBearerToken(raw: string | null | undefined): string | undefined {
  320. const trimmed = raw?.trim();
  321. if (!trimmed) return undefined;
  322. const match = /^Bearer\s+(.+)$/i.exec(trimmed);
  323. const token = match?.[1]?.trim();
  324. return token || undefined;
  325. }
  326. async function getAuthToken(): Promise<string | undefined> {
  327. // 优先使用 Cookie(兼容现有 Web UI 的登录态)
  328. const cookieToken = await getAuthCookie();
  329. if (cookieToken) return cookieToken;
  330. // Cookie 缺失时,允许通过 Authorization: Bearer <token> 自助调用只读接口
  331. const headersStore = await headers();
  332. return parseBearerToken(headersStore.get("authorization"));
  333. }