feat: enforce single active session per account
This commit is contained in:
parent
90458511c1
commit
366e0e3e5c
@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@ -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 动画背景 */}
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
18
app/api/auth/validate-session/route.ts
Normal file
18
app/api/auth/validate-session/route.ts
Normal 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
149
lib/auth/session-control.ts
Normal 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;
|
||||||
|
}
|
||||||
6
lib/db/migrations/0062_add_single_active_session.sql
Normal file
6
lib/db/migrations/0062_add_single_active_session.sql
Normal 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;
|
||||||
@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}>;
|
}>;
|
||||||
|
|||||||
12
proxy.ts
12
proxy.ts
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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", () => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user