diff --git a/quickstart.sh b/quickstart.sh index b20d61b..a70bc95 100755 --- a/quickstart.sh +++ b/quickstart.sh @@ -316,7 +316,11 @@ EOF ## 项目说明 -本项目基于 Hair Keeper 模板构建(详见 @TEMPLATE_README.md),目前尚未实现业务功能 +本项目基于 Hair Keeper 模板构建(详见 @TEMPLATE_README.md),目前尚未实现业务功能。 + +## 重要提示 +- 完成用户的任务后,务必运行`pnpm run lint`检查代码错误并修复,不要运行build +- 尽量使用pnpm执行npm相关的命令 EOF print_success "新的 CLAUDE.md 已创建" diff --git a/src/components/providers/session-provider.tsx b/src/components/providers/session-provider.tsx index 73d3e11..86ab1e9 100644 --- a/src/components/providers/session-provider.tsx +++ b/src/components/providers/session-provider.tsx @@ -1,7 +1,32 @@ 'use client' -import { SessionProvider as NextAuthSessionProvider } from "next-auth/react" +import { SessionProvider as NextAuthSessionProvider, signOut, useSession } from "next-auth/react" +import { useEffect, useRef } from "react" +import { usePathname } from "next/navigation" + +/** 检测会话失效并自动登出 */ +function SessionInvalidationGuard({ children }: { children: React.ReactNode }) { + const { data: session, status } = useSession() + const pathname = usePathname() + const signingOut = useRef(false) + + useEffect(() => { + // 已认证但 session 中没有 user(会话被标记失效),自动登出清除 cookie + if (status === 'authenticated' && !(session as any)?.user?.id && !signingOut.current && pathname !== '/login') { + signingOut.current = true + signOut({ callbackUrl: '/login' }) + } + }, [status, session, pathname]) + + return <>{children} +} export function SessionProvider({ children }: { children: React.ReactNode }) { - return {children} -} \ No newline at end of file + return ( + + + {children} + + + ) +} diff --git a/src/middleware.ts b/src/middleware.ts index e2cc289..016ae03 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -9,8 +9,8 @@ export default withAuth( const token = req.nextauth.token const pathname = req.nextUrl.pathname - // 如果用户已登录且访问登录页面,重定向到首页 - if (pathname === "/login" && token) { + // 如果用户已登录且访问登录页面,重定向到首页(会话失效的用户除外,需留在登录页) + if (pathname === "/login" && token && !token.sessionInvalid) { return NextResponse.redirect(new URL("/", req.url)) } @@ -49,7 +49,12 @@ export default withAuth( if (req.nextUrl.pathname === "/login") { return true } - + + // 会话已被标记失效,强制重新登录 + if (token?.sessionInvalid) { + return false + } + // 其他路由需要有效的 token return !!token }, diff --git a/src/server/auth.ts b/src/server/auth.ts index ea43f49..5ff6d6e 100644 --- a/src/server/auth.ts +++ b/src/server/auth.ts @@ -4,6 +4,7 @@ import type { User as NextAuthUser } from "next-auth" import CredentialsProvider from "next-auth/providers/credentials" import bcrypt from "bcryptjs" import { db } from "./db" +import { clearSessionInvalidation, isSessionInvalidated } from "./service/session" export const authOptions: NextAuthOptions = { providers: [ @@ -49,6 +50,9 @@ export const authOptions: NextAuthOptions = { where: { id: user.id }, data: { lastLoginAt: new Date() } }) + + // 清除会话失效标记(用户已重新登录,获得最新权限) + await clearSessionInvalidation(user.id) // 返回用户信息、角色和权限 const roles = user.roles.map((r) => r.name) @@ -98,10 +102,20 @@ export const authOptions: NextAuthOptions = { permissions: u.permissions, isSuperAdmin: u.isSuperAdmin, } + } else if (token.id) { + // 后续请求:检查会话是否已被标记失效 + const invalidated = await isSessionInvalidated(token.id as string) + if (invalidated) { + token.sessionInvalid = true + } } return token }, async session({ session, token }) { + // 会话已被标记失效,返回不含用户信息的session + if (token.sessionInvalid) { + return { expires: session.expires } as any + } // 将JWT token中的信息传递给session if (session.user) { const t = token as any diff --git a/src/server/routers/users.ts b/src/server/routers/users.ts index 1b37d08..2e4938e 100644 --- a/src/server/routers/users.ts +++ b/src/server/routers/users.ts @@ -7,6 +7,7 @@ import bcrypt from 'bcryptjs' import { z } from 'zod' import { inferProcedureOutput, TRPCError } from '@trpc/server' import pLimit from 'p-limit' +import { invalidateUserSessions, invalidateSessionsByRoleId } from '@/server/service/session' // 从环境变量获取并发限制,默认为16 const dbParallelLimit = pLimit(parseInt(process.env.DB_PARALLEL_LIMIT || '16', 10)) @@ -116,7 +117,7 @@ export const usersRouter = createTRPCRouter({ throw new TRPCError({ code: 'BAD_REQUEST', message: '角色名称已被其他角色使用' }) } - return ctx.db.role.update({ + const updatedRole = await ctx.db.role.update({ where: { id }, data: { name, @@ -129,6 +130,11 @@ export const usersRouter = createTRPCRouter({ } } }) + + // 标记持有该角色的所有用户会话失效 + await invalidateSessionsByRoleId(id) + + return updatedRole }), deleteRole: permissionRequiredProcedure(Permissions.USER_MANAGE) @@ -205,7 +211,10 @@ export const usersRouter = createTRPCRouter({ processedCount += batch.length } - + + // 标记所有受影响用户的会话失效 + await invalidateUserSessions(users.map(u => u.id)) + return { count: processedCount } }), @@ -265,7 +274,7 @@ export const usersRouter = createTRPCRouter({ updateData.password = await bcrypt.hash(password, 12) } - return ctx.db.user.update({ + const updatedUser = await ctx.db.user.update({ where: { id }, data: updateData, include: { @@ -273,6 +282,11 @@ export const usersRouter = createTRPCRouter({ dept: true } }) + + // 标记用户会话失效,强制重新登录以获取最新权限 + await invalidateUserSessions([id]) + + return updatedUser }), getById: permissionRequiredProcedure(Permissions.USER_MANAGE).input(z.object({ id: z.string() })).query(async ({ ctx, input }) => { @@ -300,6 +314,9 @@ export const usersRouter = createTRPCRouter({ throw new TRPCError({ code: 'BAD_REQUEST', message: '不能删除自己的账户' }) } + // 标记用户会话失效 + await invalidateUserSessions([id]) + // 删除用户 return ctx.db.user.delete({ where: { id }, diff --git a/src/server/service/session.ts b/src/server/service/session.ts new file mode 100644 index 0000000..2f83277 --- /dev/null +++ b/src/server/service/session.ts @@ -0,0 +1,39 @@ +import 'server-only' +import { getRedisClient } from '@/server/redis' +import { db } from '@/server/db' + +const SESSION_INVALID_PREFIX = 'session:invalid:' +const SESSION_INVALID_TTL = 30 * 24 * 60 * 60 // 30天,与JWT maxAge一致 + +/** 标记指定用户的会话失效 */ +export async function invalidateUserSessions(userIds: string[]) { + if (userIds.length === 0) return + const redis = getRedisClient() + const pipeline = redis.pipeline() + for (const id of userIds) { + pipeline.set(`${SESSION_INVALID_PREFIX}${id}`, '1', 'EX', SESSION_INVALID_TTL) + } + await pipeline.exec() +} + +/** 检查用户会话是否已被标记失效 */ +export async function isSessionInvalidated(userId: string): Promise { + const redis = getRedisClient() + const result = await redis.exists(`${SESSION_INVALID_PREFIX}${userId}`) + return result === 1 +} + +/** 用户重新登录后清除失效标记 */ +export async function clearSessionInvalidation(userId: string) { + const redis = getRedisClient() + await redis.del(`${SESSION_INVALID_PREFIX}${userId}`) +} + +/** 标记某角色下所有用户的会话失效 */ +export async function invalidateSessionsByRoleId(roleId: number) { + const users = await db.user.findMany({ + where: { roles: { some: { id: roleId } } }, + select: { id: true }, + }) + await invalidateUserSessions(users.map(u => u.id)) +}