feat: enforce single active session per account
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-03 17:43:03 +08:00
parent 90458511c1
commit 366e0e3e5c
11 changed files with 412 additions and 24 deletions

View File

@ -10,6 +10,13 @@ import { buildAuthConfig } from "./auth.config";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { loginToken, user as userTable } from "@/lib/db/schema"; import { loginToken, user as userTable } from "@/lib/db/schema";
import { eq, and, gt } from "drizzle-orm"; 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"; export type UserType = "guest" | "regular" | "admin" | "agent_admin";
@ -19,6 +26,7 @@ declare module "next-auth" {
id: string; id: string;
type: UserType; type: UserType;
name?: string | null; name?: string | null;
sessionKey?: string;
} & DefaultSession["user"]; } & DefaultSession["user"];
} }
@ -36,6 +44,7 @@ declare module "next-auth/jwt" {
id: string; id: string;
type: UserType; type: UserType;
name?: string | null; name?: string | null;
sessionKey?: string;
} }
} }
@ -191,12 +200,7 @@ async function resolveRuntimeAuthUrl(settingsAuthUrl?: string | null) {
return `${protocol}://${resolvedHost}`; return `${protocol}://${resolvedHost}`;
} }
export const { const nextAuth = NextAuth(async () => {
handlers: { GET, POST },
auth,
signIn,
signOut,
} = NextAuth(async () => {
const runtimeAuthSettings = await getAuthSettings().catch(() => null as any); const runtimeAuthSettings = await getAuthSettings().catch(() => null as any);
const resolvedAuthUrl = const resolvedAuthUrl =
(await resolveRuntimeAuthUrl(runtimeAuthSettings?.authUrl)) || undefined; (await resolveRuntimeAuthUrl(runtimeAuthSettings?.authUrl)) || undefined;
@ -233,10 +237,19 @@ export const {
return Boolean(dbUser?.isActive); return Boolean(dbUser?.isActive);
}, },
async jwt({ token, user, account, profile }) { 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) { if (user?.id && user?.type) {
token.id = user.id as string; token.id = user.id as string;
token.type = user.type; token.type = user.type;
token.name = user.name; token.name = user.name;
resolvedUserId = token.id;
resolvedUserType = token.type;
} }
if (account?.provider === "google") { if (account?.provider === "google") {
@ -257,10 +270,21 @@ export const {
token.type = (dbUser.role as UserType) || "regular"; token.type = (dbUser.role as UserType) || "regular";
token.name = dbUser.name; token.name = dbUser.name;
token.email = dbUser.email; 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; return token;
}, },
session({ session, token }) { session({ session, token }) {
@ -268,6 +292,8 @@ export const {
session.user.id = token.id; session.user.id = token.id;
session.user.type = token.type || "regular"; session.user.type = token.type || "regular";
session.user.name = token.name as string; session.user.name = token.name as string;
session.user.sessionKey =
typeof token.sessionKey === "string" ? token.sessionKey : undefined;
} }
return session; 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<typeof rawSignOut>[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);
}

View File

@ -39,6 +39,7 @@ export default function Page() {
const { setTheme, resolvedTheme } = useTheme(); const { setTheme, resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const [isHandlingToken, setIsHandlingToken] = useState(false); const [isHandlingToken, setIsHandlingToken] = useState(false);
const [isCheckingExistingSession, setIsCheckingExistingSession] = useState(true);
// 系统通知状态 // 系统通知状态
const [notification, setNotification] = useState<{ enabled: boolean; title: string; content: string } | null>(null); 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 完成钉钉登录 // 使用 token 完成钉钉登录
const completeDingTalkLogin = useCallback(async (token: string) => { const completeDingTalkLogin = useCallback(async (token: string) => {
console.log("[DingTalk] Completing login with token:", token.substring(0, 10) + "..."); 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)) || (isDingTalkEnabled && (Boolean(dingTalkAppKey) || dingTalkOrganizations.length > 0)) ||
(isGoogleEnabled && isGoogleConfigured); (isGoogleEnabled && isGoogleConfigured);
if (isCheckingExistingSession) {
return (
<div className="flex min-h-screen items-center justify-center text-muted-foreground text-sm">
正在检查登录状态...
</div>
);
}
return ( return (
<div className="flex h-dvh w-screen items-start justify-center bg-background pt-12 md:items-center md:pt-0 relative overflow-hidden"> <div className="flex h-dvh w-screen items-start justify-center bg-background pt-12 md:items-center md:pt-0 relative overflow-hidden">
{/* Antigravity 动画背景 */} {/* Antigravity 动画背景 */}

View File

@ -22,6 +22,7 @@ export default function Page() {
const [emailVerificationExpiresMinutes, setEmailVerificationExpiresMinutes] = useState(10); const [emailVerificationExpiresMinutes, setEmailVerificationExpiresMinutes] = useState(10);
const [isSendingCode, setIsSendingCode] = useState(false); const [isSendingCode, setIsSendingCode] = useState(false);
const [codeCooldown, setCodeCooldown] = useState(0); const [codeCooldown, setCodeCooldown] = useState(0);
const [isCheckingExistingSession, setIsCheckingExistingSession] = useState(true);
const redirectTimerRef = useRef<number | null>(null); const redirectTimerRef = useRef<number | null>(null);
useEffect(() => { useEffect(() => {
@ -43,10 +44,30 @@ export default function Page() {
fetch(`/api/site-settings?t=${Date.now()}`) fetch(`/api/site-settings?t=${Date.now()}`)
.then((res) => res.json()) .then((res) => res.json())
.then((data) => { .then((data) => {
if (data.siteName) setSiteName(data.siteName); if (data.siteName) {
if (data.siteLogo) setSiteLogo(data.siteLogo); setSiteName(data.siteName);
}
if (data.siteLogo) {
setSiteLogo(data.siteLogo);
}
}) })
.catch(console.error); .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(() => { useEffect(() => {
@ -119,6 +140,14 @@ export default function Page() {
formAction(formData); formAction(formData);
}; };
if (isCheckingExistingSession) {
return (
<div className="flex min-h-screen items-center justify-center text-muted-foreground text-sm">
正在检查登录状态...
</div>
);
}
const handleSendCode = async () => { const handleSendCode = async () => {
const normalizedEmail = email.trim().toLowerCase(); const normalizedEmail = email.trim().toLowerCase();
if (!normalizedEmail) { if (!normalizedEmail) {

View File

@ -1,11 +1,25 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { revokeSingleActiveSession } from "@/lib/auth/session-control";
export async function POST() { export async function POST() {
const isSecure = process.env.AUTH_URL?.startsWith("https://") ?? false; 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 的响应 // 创建清除 cookie 的响应
const response = NextResponse.json({ success: true }); 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 // 清除所有认证相关的 cookie
const cookiesToClear = [ const cookiesToClear = [
@ -32,16 +46,17 @@ export async function POST() {
response.cookies.set(cookieName, "", { response.cookies.set(cookieName, "", {
expires: new Date(0), expires: new Date(0),
path: "/", path: "/",
domain: domain, domain,
secure: isSecure, secure: isSecure,
httpOnly: cookieName.includes("session-token") || cookieName.includes("csrf-token"), httpOnly:
cookieName.includes("session-token") ||
cookieName.includes("csrf-token"),
sameSite: "lax", sameSite: "lax",
}); });
} }
} }
console.log("[Logout API] Cleared all auth cookies"); console.log("[Logout API] Cleared all auth cookies");
return response; return response;
} }

View File

@ -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",
},
}
);
}

149
lib/auth/session-control.ts Normal file
View File

@ -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;
}

View File

@ -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;

View File

@ -337,6 +337,13 @@
"when": 1774483200000, "when": 1774483200000,
"tag": "0061_add_product_planning_enabled_site_setting", "tag": "0061_add_product_planning_enabled_site_setting",
"breakpoints": true "breakpoints": true
},
{
"idx": 48,
"version": "7",
"when": 1775145600000,
"tag": "0062_add_single_active_session",
"breakpoints": true
} }
] ]
} }

View File

@ -26,6 +26,11 @@ export const user = pgTable("User", {
.default("regular"), .default("regular"),
isHighVip: boolean("isHighVip").notNull().default(false), isHighVip: boolean("isHighVip").notNull().default(false),
isActive: boolean("isActive").notNull().default(true), 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表示无限制 monthlyImageLimit: integer("monthlyImageLimit"), // 每月出图限额,NULL或0表示无限制
pointsBalance: integer("pointsBalance").notNull().default(0), // 当前剩余积分 pointsBalance: integer("pointsBalance").notNull().default(0), // 当前剩余积分
pointsGiftedTotal: integer("pointsGiftedTotal").notNull().default(0), // 管理员累计赠送积分 pointsGiftedTotal: integer("pointsGiftedTotal").notNull().default(0), // 管理员累计赠送积分
@ -622,6 +627,7 @@ export const canvasProject = pgTable("CanvasProject", {
fifthCategory?: string; fifthCategory?: string;
priceBand?: string; priceBand?: string;
}; };
appearanceRecognitionEnabled?: boolean;
materials?: { materials?: {
primary?: { primary?: {
result?: { result?: {
@ -641,6 +647,7 @@ export const canvasProject = pgTable("CanvasProject", {
imageUrl?: string; imageUrl?: string;
assetId?: string | null; assetId?: string | null;
mimeType?: string | null; mimeType?: string | null;
appearanceSummary?: string | null;
group?: "fixed" | "other"; group?: "fixed" | "other";
sortOrder?: number; sortOrder?: number;
}>; }>;
@ -665,6 +672,7 @@ export const canvasProject = pgTable("CanvasProject", {
imageUrl?: string; imageUrl?: string;
assetId?: string | null; assetId?: string | null;
mimeType?: string | null; mimeType?: string | null;
appearanceSummary?: string | null;
group?: "fixed" | "other"; group?: "fixed" | "other";
sortOrder?: number; sortOrder?: number;
}>; }>;

View File

@ -253,10 +253,6 @@ export async function proxy(request: NextRequest) {
const token = await resolveToken(); const token = await resolveToken();
const externalBase =
process.env.AUTH_URL ??
`${request.nextUrl.protocol}//${request.headers.get("host") ?? request.nextUrl.host}`;
const isPublicPage = const isPublicPage =
pathname === "/" || pathname === "/" ||
pathname === "/login" || pathname === "/login" ||
@ -275,17 +271,15 @@ export async function proxy(request: NextRequest) {
} }
// 对于页面路由(GET 请求),重定向到登录页面 // 对于页面路由(GET 请求),重定向到登录页面
const externalBase =
process.env.AUTH_URL ??
`${request.nextUrl.protocol}//${request.headers.get("host") ?? request.nextUrl.host}`;
const loginUrl = new URL("/login", externalBase); const loginUrl = new URL("/login", externalBase);
// 保存当前路径作为回调 URL // 保存当前路径作为回调 URL
loginUrl.searchParams.set("callbackUrl", pathname); loginUrl.searchParams.set("callbackUrl", pathname);
return NextResponse.redirect(loginUrl); return NextResponse.redirect(loginUrl);
} }
// 已登录用户访问 /login 或 /register 时重定向到首页
if (token && ["/login", "/register"].includes(pathname)) {
return NextResponse.redirect(new URL("/", externalBase));
}
return NextResponse.next(); return NextResponse.next();
} }

View File

@ -183,6 +183,41 @@ test.describe
await page.goto("/login"); await page.goto("/login");
await expect(page).toHaveURL("/"); 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", () => { test.describe("Entitlements", () => {