基于Redis失效标记,实现用户权限变更后强制重新登录

This commit is contained in:
2026-03-10 11:18:30 +08:00
parent d34bff2f79
commit fee430438c
6 changed files with 114 additions and 10 deletions

View File

@@ -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 已创建"

View File

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

View File

@@ -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))
}
@@ -50,6 +50,11 @@ export default withAuth(
return true
}
// 会话已被标记失效,强制重新登录
if (token?.sessionInvalid) {
return false
}
// 其他路由需要有效的 token
return !!token
},

View File

@@ -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: [
@@ -50,6 +51,9 @@ export const authOptions: NextAuthOptions = {
data: { lastLoginAt: new Date() }
})
// 清除会话失效标记(用户已重新登录,获得最新权限)
await clearSessionInvalidation(user.id)
// 返回用户信息、角色和权限
const roles = user.roles.map((r) => r.name)
const permissions = Array.from(
@@ -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

View File

@@ -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)
@@ -206,6 +212,9 @@ 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 },

View 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))
}