| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388 |
- import { cookies, headers } from "next/headers";
- import type { NextResponse } from "next/server";
- import { config } from "@/lib/config/config";
- import { getEnvConfig } from "@/lib/config/env.schema";
- import { logger } from "@/lib/logger";
- import { findKeyList, validateApiKeyAndGetUser } from "@/repository/key";
- import type { Key } from "@/types/key";
- import type { User } from "@/types/user";
- /**
- * Apply no-store / cache-busting headers to auth responses that mutate session state.
- * Prevents browsers and intermediary caches from storing sensitive auth responses.
- */
- export function withNoStoreHeaders<T extends NextResponse>(response: T): T {
- response.headers.set("Cache-Control", "no-store, no-cache, must-revalidate");
- response.headers.set("Pragma", "no-cache");
- return response;
- }
- export type ScopedAuthContext = {
- session: AuthSession;
- /**
- * 本次请求在 adapter 层 validateKey 时使用的 allowReadOnlyAccess 参数。
- * - true:允许 canLoginWebUi=false 的 key 作为“只读会话”使用
- * - false:严格要求 canLoginWebUi=true
- */
- allowReadOnlyAccess: boolean;
- };
- export type AuthSessionStorage = {
- run<T>(store: ScopedAuthContext, callback: () => T): T;
- getStore(): ScopedAuthContext | undefined;
- };
- declare global {
- // eslint-disable-next-line no-var
- var __cchAuthSessionStorage: AuthSessionStorage | undefined;
- }
- export const AUTH_COOKIE_NAME = "auth-token";
- const AUTH_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; // 7 days
- export interface AuthSession {
- user: User;
- key: Key;
- }
- export type SessionTokenMode = "legacy" | "dual" | "opaque";
- export type SessionTokenKind = "legacy" | "opaque";
- export function getSessionTokenMode(): SessionTokenMode {
- return getEnvConfig().SESSION_TOKEN_MODE;
- }
- // Session contract: opaque token is a random string, not the API key
- export interface OpaqueSessionContract {
- sessionId: string; // random opaque token
- keyFingerprint: string; // hash of the API key (for audit, not auth)
- createdAt: number; // unix timestamp
- expiresAt: number; // unix timestamp
- userId: number;
- userRole: string;
- }
- export interface SessionTokenMigrationFlags {
- dualReadWindowEnabled: boolean;
- hardCutoverEnabled: boolean;
- emergencyRollbackEnabled: boolean;
- }
- export const SESSION_TOKEN_SEMANTICS = {
- expiry: "hard_expiry_at_expires_at",
- rotation: "rotate_before_expiry_and_revoke_previous_session_id",
- revocation: "server_side_revocation_invalidates_session_immediately",
- compatibility: {
- legacy: "accept_legacy_only",
- dual: "accept_legacy_and_opaque",
- opaque: "accept_opaque_only",
- },
- } as const;
- export function getSessionTokenMigrationFlags(
- mode: SessionTokenMode = getSessionTokenMode()
- ): SessionTokenMigrationFlags {
- return {
- dualReadWindowEnabled: mode === "dual",
- hardCutoverEnabled: mode === "opaque",
- emergencyRollbackEnabled: mode === "legacy",
- };
- }
- export function isSessionTokenKindAccepted(
- mode: SessionTokenMode,
- kind: SessionTokenKind
- ): boolean {
- if (mode === "dual") return true;
- if (mode === "legacy") return kind === "legacy";
- return kind === "opaque";
- }
- export function isOpaqueSessionContract(value: unknown): value is OpaqueSessionContract {
- if (!value || typeof value !== "object") return false;
- const candidate = value as Record<string, unknown>;
- return (
- typeof candidate.sessionId === "string" &&
- candidate.sessionId.length > 0 &&
- typeof candidate.keyFingerprint === "string" &&
- candidate.keyFingerprint.length > 0 &&
- typeof candidate.createdAt === "number" &&
- Number.isFinite(candidate.createdAt) &&
- typeof candidate.expiresAt === "number" &&
- Number.isFinite(candidate.expiresAt) &&
- candidate.expiresAt > candidate.createdAt &&
- typeof candidate.userId === "number" &&
- Number.isInteger(candidate.userId) &&
- typeof candidate.userRole === "string" &&
- candidate.userRole.length > 0
- );
- }
- const OPAQUE_SESSION_ID_PREFIX = "sid_";
- export function detectSessionTokenKind(token: string): SessionTokenKind {
- const trimmed = token.trim();
- if (!trimmed) return "legacy";
- return trimmed.startsWith(OPAQUE_SESSION_ID_PREFIX) ? "opaque" : "legacy";
- }
- export function isSessionTokenAccepted(
- token: string,
- mode: SessionTokenMode = getSessionTokenMode()
- ): boolean {
- return isSessionTokenKindAccepted(mode, detectSessionTokenKind(token));
- }
- export function runWithAuthSession<T>(
- session: AuthSession,
- fn: () => T,
- options?: { allowReadOnlyAccess?: boolean }
- ): T {
- const storage = globalThis.__cchAuthSessionStorage;
- if (!storage) return fn();
- return storage.run({ session, allowReadOnlyAccess: options?.allowReadOnlyAccess ?? false }, fn);
- }
- export function getScopedAuthSession(): AuthSession | null {
- const storage = globalThis.__cchAuthSessionStorage;
- return storage?.getStore()?.session ?? null;
- }
- export function getScopedAuthContext(): ScopedAuthContext | null {
- const storage = globalThis.__cchAuthSessionStorage;
- return storage?.getStore() ?? null;
- }
- export async function validateKey(
- keyString: string,
- options?: {
- /**
- * 允许仅访问只读页面(如 my-usage),跳过 canLoginWebUi 校验
- */
- allowReadOnlyAccess?: boolean;
- }
- ): Promise<AuthSession | null> {
- const allowReadOnlyAccess = options?.allowReadOnlyAccess ?? false;
- const adminToken = config.auth.adminToken;
- if (adminToken && keyString === adminToken) {
- const now = new Date();
- const adminUser: User = {
- id: -1,
- name: "Admin Token",
- description: "Environment admin session",
- role: "admin",
- rpm: 0,
- dailyQuota: 0,
- providerGroup: null,
- isEnabled: true,
- expiresAt: null,
- dailyResetMode: "fixed",
- dailyResetTime: "00:00",
- createdAt: now,
- updatedAt: now,
- };
- const adminKey: Key = {
- id: -1,
- userId: adminUser.id,
- name: "ADMIN_TOKEN",
- key: keyString,
- isEnabled: true,
- canLoginWebUi: true, // Admin Token
- providerGroup: null,
- limit5hUsd: null,
- limitDailyUsd: null,
- dailyResetMode: "fixed",
- dailyResetTime: "00:00",
- limitWeeklyUsd: null,
- limitMonthlyUsd: null,
- limitConcurrentSessions: 0,
- cacheTtlPreference: null,
- createdAt: now,
- updatedAt: now,
- };
- return { user: adminUser, key: adminKey };
- }
- // 默认鉴权链路:Vacuum Filter(仅负向短路) → Redis(key/user 缓存) → DB(权威校验)
- const authResult = await validateApiKeyAndGetUser(keyString);
- if (!authResult) {
- return null;
- }
- const { user, key } = authResult;
- // 用户状态校验:与 v1 proxy 侧保持一致,避免禁用/过期用户继续登录或持有会话
- if (!user.isEnabled) {
- return null;
- }
- if (user.expiresAt && user.expiresAt.getTime() <= Date.now()) {
- return null;
- }
- // 检查 Web UI 登录权限
- if (!allowReadOnlyAccess && !key.canLoginWebUi) {
- return null;
- }
- return { user, key };
- }
- export function getLoginRedirectTarget(session: AuthSession): string {
- if (session.user.role === "admin") return "/dashboard";
- if (session.key.canLoginWebUi) return "/dashboard";
- return "/my-usage";
- }
- export async function setAuthCookie(keyString: string) {
- const cookieStore = await cookies();
- const env = getEnvConfig();
- cookieStore.set(AUTH_COOKIE_NAME, keyString, {
- httpOnly: true,
- secure: env.ENABLE_SECURE_COOKIES,
- sameSite: "lax",
- maxAge: AUTH_COOKIE_MAX_AGE,
- path: "/",
- });
- }
- export async function getAuthCookie(): Promise<string | undefined> {
- const cookieStore = await cookies();
- return cookieStore.get(AUTH_COOKIE_NAME)?.value;
- }
- export async function clearAuthCookie() {
- const cookieStore = await cookies();
- cookieStore.delete(AUTH_COOKIE_NAME);
- }
- export async function getSession(options?: {
- /**
- * 允许仅访问只读页面(如 my-usage),跳过 canLoginWebUi 校验
- */
- allowReadOnlyAccess?: boolean;
- }): Promise<AuthSession | null> {
- // 优先读取 adapter 注入的请求级会话(适配 /api/actions 等非 Next 原生上下文场景)
- const scoped = getScopedAuthContext();
- if (scoped) {
- // 关键:scoped 会话必须遵循其"创建时语义",仅允许内部显式降权(不允许提权)
- const effectiveAllowReadOnlyAccess =
- scoped.allowReadOnlyAccess && (options?.allowReadOnlyAccess ?? true);
- if (!effectiveAllowReadOnlyAccess && !scoped.session.key.canLoginWebUi) {
- return null;
- }
- return scoped.session;
- }
- const keyString = await getAuthToken();
- if (!keyString) {
- return null;
- }
- return validateKey(keyString, options);
- }
- type SessionStoreReader = {
- read(sessionId: string): Promise<OpaqueSessionContract | null>;
- };
- let sessionStorePromise: Promise<SessionStoreReader> | null = null;
- async function getSessionStore(): Promise<SessionStoreReader> {
- if (!sessionStorePromise) {
- sessionStorePromise = import("@/lib/auth-session-store/redis-session-store").then(
- ({ RedisSessionStore }) => new RedisSessionStore()
- );
- }
- return sessionStorePromise;
- }
- async function toKeyFingerprint(keyString: string): Promise<string> {
- const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(keyString));
- const hex = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(
- ""
- );
- return `sha256:${hex}`;
- }
- function normalizeKeyFingerprint(fingerprint: string): string {
- return fingerprint.startsWith("sha256:") ? fingerprint : `sha256:${fingerprint}`;
- }
- async function convertToAuthSession(
- sessionData: OpaqueSessionContract,
- options?: { allowReadOnlyAccess?: boolean }
- ): Promise<AuthSession | null> {
- const keyList = await findKeyList(sessionData.userId);
- const expectedFingerprint = normalizeKeyFingerprint(sessionData.keyFingerprint);
- for (const key of keyList) {
- const keyFingerprint = await toKeyFingerprint(key.key);
- if (keyFingerprint === expectedFingerprint) {
- return validateKey(key.key, options);
- }
- }
- return null;
- }
- export async function getSessionWithDualRead(options?: {
- allowReadOnlyAccess?: boolean;
- }): Promise<AuthSession | null> {
- const mode = getSessionTokenMode();
- if (mode === "opaque" || mode === "dual") {
- const sessionId = await getAuthToken();
- if (sessionId) {
- try {
- const sessionStore = await getSessionStore();
- const sessionData = await sessionStore.read(sessionId);
- if (sessionData) {
- const session = await convertToAuthSession(sessionData, options);
- if (session) {
- return session;
- }
- }
- } catch (error) {
- logger.warn("Opaque session read failed", {
- error: error instanceof Error ? error.message : String(error),
- });
- }
- }
- }
- if (mode === "legacy" || mode === "dual") {
- return getSession(options);
- }
- return null;
- }
- export async function validateSession(options?: {
- allowReadOnlyAccess?: boolean;
- }): Promise<AuthSession | null> {
- return getSessionWithDualRead(options);
- }
- function parseBearerToken(raw: string | null | undefined): string | undefined {
- const trimmed = raw?.trim();
- if (!trimmed) return undefined;
- const match = /^Bearer\s+(.+)$/i.exec(trimmed);
- const token = match?.[1]?.trim();
- return token || undefined;
- }
- async function getAuthToken(): Promise<string | undefined> {
- // 优先使用 Cookie(兼容现有 Web UI 的登录态)
- const cookieToken = await getAuthCookie();
- if (cookieToken) return cookieToken;
- // Cookie 缺失时,允许通过 Authorization: Bearer <token> 自助调用只读接口
- const headersStore = await headers();
- return parseBearerToken(headersStore.get("authorization"));
- }
|