基于Redis失效标记,实现用户权限变更后强制重新登录
This commit is contained in:
@@ -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 已创建"
|
||||
|
||||
|
||||
@@ -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 <NextAuthSessionProvider>{children}</NextAuthSessionProvider>
|
||||
}
|
||||
return (
|
||||
<NextAuthSessionProvider>
|
||||
<SessionInvalidationGuard>
|
||||
{children}
|
||||
</SessionInvalidationGuard>
|
||||
</NextAuthSessionProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
|
||||
39
src/server/service/session.ts
Normal file
39
src/server/service/session.ts
Normal file
@@ -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<boolean> {
|
||||
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))
|
||||
}
|
||||
Reference in New Issue
Block a user