From 366e0e3e5cf542302617477677c3d1f9c6a7f11f Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 3 Apr 2026 17:43:03 +0800 Subject: [PATCH] feat: enforce single active session per account --- app/(auth)/auth.ts | 103 +++++++++++- app/(auth)/login/page.tsx | 36 +++++ app/(auth)/register/page.tsx | 33 +++- app/api/auth/logout/route.ts | 29 +++- app/api/auth/validate-session/route.ts | 18 +++ lib/auth/session-control.ts | 149 ++++++++++++++++++ .../0062_add_single_active_session.sql | 6 + lib/db/migrations/meta/_journal.json | 7 + lib/db/schema.ts | 8 + proxy.ts | 12 +- tests/e2e/session.test.ts | 35 ++++ 11 files changed, 412 insertions(+), 24 deletions(-) create mode 100644 app/api/auth/validate-session/route.ts create mode 100644 lib/auth/session-control.ts create mode 100644 lib/db/migrations/0062_add_single_active_session.sql diff --git a/app/(auth)/auth.ts b/app/(auth)/auth.ts index da3711193..ee6c9eaaa 100644 --- a/app/(auth)/auth.ts +++ b/app/(auth)/auth.ts @@ -10,6 +10,13 @@ import { buildAuthConfig } from "./auth.config"; import { db } from "@/lib/db"; import { loginToken, user as userTable } from "@/lib/db/schema"; import { eq, and, gt } from "drizzle-orm"; +import { + establishSingleActiveSession, + getValidatedSessionUser, + isManagedSessionUserType, + requiresSingleActiveSession, + revokeSingleActiveSession, +} from "@/lib/auth/session-control"; export type UserType = "guest" | "regular" | "admin" | "agent_admin"; @@ -19,6 +26,7 @@ declare module "next-auth" { id: string; type: UserType; name?: string | null; + sessionKey?: string; } & DefaultSession["user"]; } @@ -36,6 +44,7 @@ declare module "next-auth/jwt" { id: string; type: UserType; name?: string | null; + sessionKey?: string; } } @@ -191,12 +200,7 @@ async function resolveRuntimeAuthUrl(settingsAuthUrl?: string | null) { return `${protocol}://${resolvedHost}`; } -export const { - handlers: { GET, POST }, - auth, - signIn, - signOut, -} = NextAuth(async () => { +const nextAuth = NextAuth(async () => { const runtimeAuthSettings = await getAuthSettings().catch(() => null as any); const resolvedAuthUrl = (await resolveRuntimeAuthUrl(runtimeAuthSettings?.authUrl)) || undefined; @@ -233,10 +237,19 @@ export const { return Boolean(dbUser?.isActive); }, async jwt({ token, user, account, profile }) { + let resolvedUserId = + typeof token.id === "string" && token.id.length > 0 ? token.id : undefined; + let resolvedUserType = + typeof token.type === "string" && isManagedSessionUserType(token.type) + ? token.type + : undefined; + if (user?.id && user?.type) { token.id = user.id as string; token.type = user.type; token.name = user.name; + resolvedUserId = token.id; + resolvedUserType = token.type; } if (account?.provider === "google") { @@ -257,10 +270,21 @@ export const { token.type = (dbUser.role as UserType) || "regular"; token.name = dbUser.name; token.email = dbUser.email; + resolvedUserId = dbUser.id; + resolvedUserType = (dbUser.role as UserType) || "regular"; } } } + if (account && resolvedUserId && requiresSingleActiveSession(resolvedUserType)) { + const { sessionKey } = await establishSingleActiveSession({ + userId: resolvedUserId, + }); + token.sessionKey = sessionKey; + } else if (!requiresSingleActiveSession(resolvedUserType)) { + delete token.sessionKey; + } + return token; }, session({ session, token }) { @@ -268,6 +292,8 @@ export const { session.user.id = token.id; session.user.type = token.type || "regular"; session.user.name = token.name as string; + session.user.sessionKey = + typeof token.sessionKey === "string" ? token.sessionKey : undefined; } return session; @@ -275,3 +301,68 @@ export const { }, }; }); + +const { + handlers: { GET, POST }, + auth: rawAuth, + signIn, + signOut: rawSignOut, +} = nextAuth; + +export { GET, POST, signIn }; + +export async function auth() { + const session = await rawAuth(); + if (!session?.user?.id) { + return session; + } + + if (!requiresSingleActiveSession(session.user.type)) { + return session; + } + + const sessionKey = session.user.sessionKey; + if (!sessionKey) { + return null; + } + + try { + const validatedUser = await getValidatedSessionUser({ + userId: session.user.id, + sessionKey, + }); + + if (!validatedUser) { + return null; + } + + return { + ...session, + user: { + ...session.user, + email: validatedUser.email, + name: validatedUser.name ?? session.user.name, + type: validatedUser.role, + sessionKey, + }, + }; + } catch (error) { + console.error("[Auth] Failed to validate active session:", error); + return null; + } +} + +export async function signOut(options?: Parameters[0]) { + const session = await auth().catch(() => null); + + if (session?.user?.id && requiresSingleActiveSession(session.user.type)) { + await revokeSingleActiveSession({ + userId: session.user.id, + sessionKey: session.user.sessionKey, + }).catch((error) => { + console.error("[Auth] Failed to revoke active session during sign out:", error); + }); + } + + return rawSignOut(options); +} diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index 22293503f..9d8c852ce 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -39,6 +39,7 @@ export default function Page() { const { setTheme, resolvedTheme } = useTheme(); const [mounted, setMounted] = useState(false); const [isHandlingToken, setIsHandlingToken] = useState(false); + const [isCheckingExistingSession, setIsCheckingExistingSession] = useState(true); // 系统通知状态 const [notification, setNotification] = useState<{ enabled: boolean; title: string; content: string } | null>(null); @@ -56,6 +57,33 @@ export default function Page() { } ); + useEffect(() => { + let cancelled = false; + + fetch("/api/auth/validate-session", { + cache: "no-store", + credentials: "include", + }) + .then((res) => res.json()) + .then((data) => { + if (cancelled) return; + if (data?.valid === true && data?.userType !== "guest") { + window.location.replace("/"); + return; + } + setIsCheckingExistingSession(false); + }) + .catch(() => { + if (!cancelled) { + setIsCheckingExistingSession(false); + } + }); + + return () => { + cancelled = true; + }; + }, []); + // 使用 token 完成钉钉登录 const completeDingTalkLogin = useCallback(async (token: string) => { console.log("[DingTalk] Completing login with token:", token.substring(0, 10) + "..."); @@ -435,6 +463,14 @@ export default function Page() { (isDingTalkEnabled && (Boolean(dingTalkAppKey) || dingTalkOrganizations.length > 0)) || (isGoogleEnabled && isGoogleConfigured); + if (isCheckingExistingSession) { + return ( +
+ 正在检查登录状态... +
+ ); + } + return (
{/* Antigravity 动画背景 */} diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx index 76aedb4b6..900d90c0b 100644 --- a/app/(auth)/register/page.tsx +++ b/app/(auth)/register/page.tsx @@ -22,6 +22,7 @@ export default function Page() { const [emailVerificationExpiresMinutes, setEmailVerificationExpiresMinutes] = useState(10); const [isSendingCode, setIsSendingCode] = useState(false); const [codeCooldown, setCodeCooldown] = useState(0); + const [isCheckingExistingSession, setIsCheckingExistingSession] = useState(true); const redirectTimerRef = useRef(null); useEffect(() => { @@ -43,10 +44,30 @@ export default function Page() { fetch(`/api/site-settings?t=${Date.now()}`) .then((res) => res.json()) .then((data) => { - if (data.siteName) setSiteName(data.siteName); - if (data.siteLogo) setSiteLogo(data.siteLogo); + if (data.siteName) { + setSiteName(data.siteName); + } + if (data.siteLogo) { + setSiteLogo(data.siteLogo); + } }) .catch(console.error); + + fetch("/api/auth/validate-session", { + cache: "no-store", + credentials: "include", + }) + .then((res) => res.json()) + .then((data) => { + if (data?.valid === true && data?.userType !== "guest") { + window.location.replace("/"); + return; + } + setIsCheckingExistingSession(false); + }) + .catch(() => { + setIsCheckingExistingSession(false); + }); }, []); useEffect(() => { @@ -119,6 +140,14 @@ export default function Page() { formAction(formData); }; + if (isCheckingExistingSession) { + return ( +
+ 正在检查登录状态... +
+ ); + } + const handleSendCode = async () => { const normalizedEmail = email.trim().toLowerCase(); if (!normalizedEmail) { diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts index 9db0d823f..710b71a09 100644 --- a/app/api/auth/logout/route.ts +++ b/app/api/auth/logout/route.ts @@ -1,11 +1,25 @@ import { NextResponse } from "next/server"; +import { auth } from "@/app/(auth)/auth"; +import { revokeSingleActiveSession } from "@/lib/auth/session-control"; export async function POST() { const isSecure = process.env.AUTH_URL?.startsWith("https://") ?? false; - const domain = process.env.AUTH_URL ? new URL(process.env.AUTH_URL).hostname : undefined; - + const domain = process.env.AUTH_URL + ? new URL(process.env.AUTH_URL).hostname + : undefined; + const session = await auth().catch(() => null); + // 创建清除 cookie 的响应 const response = NextResponse.json({ success: true }); + + if (session?.user?.id && session.user.type !== "guest") { + await revokeSingleActiveSession({ + userId: session.user.id, + sessionKey: session.user.sessionKey, + }).catch((error) => { + console.error("[Logout API] Failed to revoke active session:", error); + }); + } // 清除所有认证相关的 cookie const cookiesToClear = [ @@ -32,16 +46,17 @@ export async function POST() { response.cookies.set(cookieName, "", { expires: new Date(0), path: "/", - domain: domain, + domain, secure: isSecure, - httpOnly: cookieName.includes("session-token") || cookieName.includes("csrf-token"), + httpOnly: + cookieName.includes("session-token") || + cookieName.includes("csrf-token"), sameSite: "lax", }); } } - + console.log("[Logout API] Cleared all auth cookies"); - + return response; } - diff --git a/app/api/auth/validate-session/route.ts b/app/api/auth/validate-session/route.ts new file mode 100644 index 000000000..c2cea4dc0 --- /dev/null +++ b/app/api/auth/validate-session/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/(auth)/auth"; + +export async function GET() { + const session = await auth().catch(() => null); + + return NextResponse.json( + { + valid: Boolean(session?.user?.id), + userType: session?.user?.type ?? null, + }, + { + headers: { + "Cache-Control": "no-store, max-age=0", + }, + } + ); +} diff --git a/lib/auth/session-control.ts b/lib/auth/session-control.ts new file mode 100644 index 000000000..8d0befcb6 --- /dev/null +++ b/lib/auth/session-control.ts @@ -0,0 +1,149 @@ +import "server-only"; + +import { and, eq } from "drizzle-orm"; +import { headers } from "next/headers"; +import { db } from "@/lib/db"; +import { user } from "@/lib/db/schema"; + +type ManagedUserType = "guest" | "regular" | "admin" | "agent_admin"; + +type ManagedSessionUser = { + id: string; + email: string; + name: string | null; + role: ManagedUserType; + isActive: boolean; + activeSessionKey: string | null; +}; + +function normalizeClientIp(value: string | null | undefined) { + if (!value) { + return null; + } + + const firstHop = value.split(",")[0]?.trim(); + return firstHop || null; +} + +function normalizeUserAgent(value: string | null | undefined) { + const normalized = String(value || "").trim(); + if (!normalized) { + return null; + } + + return normalized.slice(0, 512); +} + +async function readRequestSessionMetadata() { + const requestHeaders = await headers(); + const forwardedFor = requestHeaders.get("x-forwarded-for"); + const realIp = requestHeaders.get("x-real-ip"); + + return { + ip: normalizeClientIp(forwardedFor) || normalizeClientIp(realIp), + userAgent: normalizeUserAgent(requestHeaders.get("user-agent")), + }; +} + +export function isManagedSessionUserType( + type: string | null | undefined +): type is ManagedUserType { + return ( + type === "guest" || + type === "regular" || + type === "admin" || + type === "agent_admin" + ); +} + +export function requiresSingleActiveSession(type: string | null | undefined) { + return type === "regular" || type === "admin" || type === "agent_admin"; +} + +export async function establishSingleActiveSession(params: { userId: string }) { + const { userId } = params; + const sessionKey = crypto.randomUUID(); + const metadata = await readRequestSessionMetadata(); + const now = new Date(); + + await db + .update(user) + .set({ + activeSessionKey: sessionKey, + activeSessionCreatedAt: now, + activeSessionIp: metadata.ip, + activeSessionUserAgent: metadata.userAgent, + activeSessionLastSeenAt: now, + updatedAt: now, + }) + .where(eq(user.id, userId)); + + return { sessionKey }; +} + +export async function getValidatedSessionUser(params: { + userId: string; + sessionKey: string; +}) { + const { userId, sessionKey } = params; + const rows = await db + .select({ + id: user.id, + email: user.email, + name: user.name, + role: user.role, + isActive: user.isActive, + activeSessionKey: user.activeSessionKey, + }) + .from(user) + .where(eq(user.id, userId)) + .limit(1); + + const currentUser = rows[0] as ManagedSessionUser | undefined; + if (!currentUser) { + return null; + } + + if (!currentUser.isActive) { + return null; + } + + if (!requiresSingleActiveSession(currentUser.role)) { + return currentUser; + } + + if ( + !currentUser.activeSessionKey || + currentUser.activeSessionKey !== sessionKey + ) { + return null; + } + + return currentUser; +} + +export async function revokeSingleActiveSession(params: { + userId: string; + sessionKey?: string | null; +}) { + const { userId, sessionKey } = params; + const now = new Date(); + const whereClause = sessionKey + ? and(eq(user.id, userId), eq(user.activeSessionKey, sessionKey)) + : eq(user.id, userId); + + const revokedUsers = await db + .update(user) + .set({ + activeSessionKey: null, + activeSessionCreatedAt: null, + activeSessionIp: null, + activeSessionUserAgent: null, + activeSessionLastSeenAt: null, + updatedAt: now, + }) + .where(whereClause) + .returning({ id: user.id }); + + return revokedUsers.length > 0; +} diff --git a/lib/db/migrations/0062_add_single_active_session.sql b/lib/db/migrations/0062_add_single_active_session.sql new file mode 100644 index 000000000..2d9b051cc --- /dev/null +++ b/lib/db/migrations/0062_add_single_active_session.sql @@ -0,0 +1,6 @@ +ALTER TABLE "User" +ADD COLUMN IF NOT EXISTS "activeSessionKey" varchar(64), +ADD COLUMN IF NOT EXISTS "activeSessionCreatedAt" timestamp, +ADD COLUMN IF NOT EXISTS "activeSessionIp" varchar(64), +ADD COLUMN IF NOT EXISTS "activeSessionUserAgent" text, +ADD COLUMN IF NOT EXISTS "activeSessionLastSeenAt" timestamp; diff --git a/lib/db/migrations/meta/_journal.json b/lib/db/migrations/meta/_journal.json index b07bd4c4a..71874efdd 100644 --- a/lib/db/migrations/meta/_journal.json +++ b/lib/db/migrations/meta/_journal.json @@ -337,6 +337,13 @@ "when": 1774483200000, "tag": "0061_add_product_planning_enabled_site_setting", "breakpoints": true + }, + { + "idx": 48, + "version": "7", + "when": 1775145600000, + "tag": "0062_add_single_active_session", + "breakpoints": true } ] } diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 96b12fbc7..f2ee45a44 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -26,6 +26,11 @@ export const user = pgTable("User", { .default("regular"), isHighVip: boolean("isHighVip").notNull().default(false), isActive: boolean("isActive").notNull().default(true), + activeSessionKey: varchar("activeSessionKey", { length: 64 }), + activeSessionCreatedAt: timestamp("activeSessionCreatedAt"), + activeSessionIp: varchar("activeSessionIp", { length: 64 }), + activeSessionUserAgent: text("activeSessionUserAgent"), + activeSessionLastSeenAt: timestamp("activeSessionLastSeenAt"), monthlyImageLimit: integer("monthlyImageLimit"), // 每月出图限额,NULL或0表示无限制 pointsBalance: integer("pointsBalance").notNull().default(0), // 当前剩余积分 pointsGiftedTotal: integer("pointsGiftedTotal").notNull().default(0), // 管理员累计赠送积分 @@ -622,6 +627,7 @@ export const canvasProject = pgTable("CanvasProject", { fifthCategory?: string; priceBand?: string; }; + appearanceRecognitionEnabled?: boolean; materials?: { primary?: { result?: { @@ -641,6 +647,7 @@ export const canvasProject = pgTable("CanvasProject", { imageUrl?: string; assetId?: string | null; mimeType?: string | null; + appearanceSummary?: string | null; group?: "fixed" | "other"; sortOrder?: number; }>; @@ -665,6 +672,7 @@ export const canvasProject = pgTable("CanvasProject", { imageUrl?: string; assetId?: string | null; mimeType?: string | null; + appearanceSummary?: string | null; group?: "fixed" | "other"; sortOrder?: number; }>; diff --git a/proxy.ts b/proxy.ts index 661cddb1e..e741b2eb9 100644 --- a/proxy.ts +++ b/proxy.ts @@ -253,10 +253,6 @@ export async function proxy(request: NextRequest) { const token = await resolveToken(); - const externalBase = - process.env.AUTH_URL ?? - `${request.nextUrl.protocol}//${request.headers.get("host") ?? request.nextUrl.host}`; - const isPublicPage = pathname === "/" || pathname === "/login" || @@ -275,17 +271,15 @@ export async function proxy(request: NextRequest) { } // 对于页面路由(GET 请求),重定向到登录页面 + const externalBase = + process.env.AUTH_URL ?? + `${request.nextUrl.protocol}//${request.headers.get("host") ?? request.nextUrl.host}`; const loginUrl = new URL("/login", externalBase); // 保存当前路径作为回调 URL loginUrl.searchParams.set("callbackUrl", pathname); return NextResponse.redirect(loginUrl); } - // 已登录用户访问 /login 或 /register 时重定向到首页 - if (token && ["/login", "/register"].includes(pathname)) { - return NextResponse.redirect(new URL("/", externalBase)); - } - return NextResponse.next(); } diff --git a/tests/e2e/session.test.ts b/tests/e2e/session.test.ts index a1dbf08d2..74e96cfc5 100644 --- a/tests/e2e/session.test.ts +++ b/tests/e2e/session.test.ts @@ -183,6 +183,41 @@ test.describe await page.goto("/login"); await expect(page).toHaveURL("/"); }); + + test("Invalidate the previous browser session after a second login", async ({ + browser, + page, + }) => { + const isolatedUser = generateRandomTestUser(); + const secondaryContext = await browser.newContext(); + const secondaryPage = await secondaryContext.newPage(); + const primaryAuthPage = new AuthPage(page); + const secondaryAuthPage = new AuthPage(secondaryPage); + + try { + await primaryAuthPage.register(isolatedUser.email, isolatedUser.password); + await primaryAuthPage.expectToastToContain("Account created successfully!"); + await page.waitForURL("/"); + + const beforeInvalidation = await page.context().request.get( + "/api/user/account-center" + ); + expect(beforeInvalidation.status()).toBe(200); + + await secondaryAuthPage.login(isolatedUser.email, isolatedUser.password); + await secondaryPage.waitForURL("/"); + + const afterInvalidation = await page.context().request.get( + "/api/user/account-center" + ); + expect(afterInvalidation.status()).toBe(401); + + await page.goto("/login"); + await expect(page).toHaveURL("/login"); + } finally { + await secondaryContext.close(); + } + }); }); test.describe("Entitlements", () => {