基于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
|
EOF
|
||||||
print_success "新的 CLAUDE.md 已创建"
|
print_success "新的 CLAUDE.md 已创建"
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,32 @@
|
|||||||
'use client'
|
'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 }) {
|
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 token = req.nextauth.token
|
||||||
const pathname = req.nextUrl.pathname
|
const pathname = req.nextUrl.pathname
|
||||||
|
|
||||||
// 如果用户已登录且访问登录页面,重定向到首页
|
// 如果用户已登录且访问登录页面,重定向到首页(会话失效的用户除外,需留在登录页)
|
||||||
if (pathname === "/login" && token) {
|
if (pathname === "/login" && token && !token.sessionInvalid) {
|
||||||
return NextResponse.redirect(new URL("/", req.url))
|
return NextResponse.redirect(new URL("/", req.url))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +49,12 @@ export default withAuth(
|
|||||||
if (req.nextUrl.pathname === "/login") {
|
if (req.nextUrl.pathname === "/login") {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 会话已被标记失效,强制重新登录
|
||||||
|
if (token?.sessionInvalid) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// 其他路由需要有效的 token
|
// 其他路由需要有效的 token
|
||||||
return !!token
|
return !!token
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { User as NextAuthUser } from "next-auth"
|
|||||||
import CredentialsProvider from "next-auth/providers/credentials"
|
import CredentialsProvider from "next-auth/providers/credentials"
|
||||||
import bcrypt from "bcryptjs"
|
import bcrypt from "bcryptjs"
|
||||||
import { db } from "./db"
|
import { db } from "./db"
|
||||||
|
import { clearSessionInvalidation, isSessionInvalidated } from "./service/session"
|
||||||
|
|
||||||
export const authOptions: NextAuthOptions = {
|
export const authOptions: NextAuthOptions = {
|
||||||
providers: [
|
providers: [
|
||||||
@@ -49,6 +50,9 @@ export const authOptions: NextAuthOptions = {
|
|||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
data: { lastLoginAt: new Date() }
|
data: { lastLoginAt: new Date() }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 清除会话失效标记(用户已重新登录,获得最新权限)
|
||||||
|
await clearSessionInvalidation(user.id)
|
||||||
|
|
||||||
// 返回用户信息、角色和权限
|
// 返回用户信息、角色和权限
|
||||||
const roles = user.roles.map((r) => r.name)
|
const roles = user.roles.map((r) => r.name)
|
||||||
@@ -98,10 +102,20 @@ export const authOptions: NextAuthOptions = {
|
|||||||
permissions: u.permissions,
|
permissions: u.permissions,
|
||||||
isSuperAdmin: u.isSuperAdmin,
|
isSuperAdmin: u.isSuperAdmin,
|
||||||
}
|
}
|
||||||
|
} else if (token.id) {
|
||||||
|
// 后续请求:检查会话是否已被标记失效
|
||||||
|
const invalidated = await isSessionInvalidated(token.id as string)
|
||||||
|
if (invalidated) {
|
||||||
|
token.sessionInvalid = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return token
|
return token
|
||||||
},
|
},
|
||||||
async session({ session, token }) {
|
async session({ session, token }) {
|
||||||
|
// 会话已被标记失效,返回不含用户信息的session
|
||||||
|
if (token.sessionInvalid) {
|
||||||
|
return { expires: session.expires } as any
|
||||||
|
}
|
||||||
// 将JWT token中的信息传递给session
|
// 将JWT token中的信息传递给session
|
||||||
if (session.user) {
|
if (session.user) {
|
||||||
const t = token as any
|
const t = token as any
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import bcrypt from 'bcryptjs'
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { inferProcedureOutput, TRPCError } from '@trpc/server'
|
import { inferProcedureOutput, TRPCError } from '@trpc/server'
|
||||||
import pLimit from 'p-limit'
|
import pLimit from 'p-limit'
|
||||||
|
import { invalidateUserSessions, invalidateSessionsByRoleId } from '@/server/service/session'
|
||||||
|
|
||||||
// 从环境变量获取并发限制,默认为16
|
// 从环境变量获取并发限制,默认为16
|
||||||
const dbParallelLimit = pLimit(parseInt(process.env.DB_PARALLEL_LIMIT || '16', 10))
|
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: '角色名称已被其他角色使用' })
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '角色名称已被其他角色使用' })
|
||||||
}
|
}
|
||||||
|
|
||||||
return ctx.db.role.update({
|
const updatedRole = await ctx.db.role.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
name,
|
name,
|
||||||
@@ -129,6 +130,11 @@ export const usersRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 标记持有该角色的所有用户会话失效
|
||||||
|
await invalidateSessionsByRoleId(id)
|
||||||
|
|
||||||
|
return updatedRole
|
||||||
}),
|
}),
|
||||||
|
|
||||||
deleteRole: permissionRequiredProcedure(Permissions.USER_MANAGE)
|
deleteRole: permissionRequiredProcedure(Permissions.USER_MANAGE)
|
||||||
@@ -205,7 +211,10 @@ export const usersRouter = createTRPCRouter({
|
|||||||
|
|
||||||
processedCount += batch.length
|
processedCount += batch.length
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 标记所有受影响用户的会话失效
|
||||||
|
await invalidateUserSessions(users.map(u => u.id))
|
||||||
|
|
||||||
return { count: processedCount }
|
return { count: processedCount }
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -265,7 +274,7 @@ export const usersRouter = createTRPCRouter({
|
|||||||
updateData.password = await bcrypt.hash(password, 12)
|
updateData.password = await bcrypt.hash(password, 12)
|
||||||
}
|
}
|
||||||
|
|
||||||
return ctx.db.user.update({
|
const updatedUser = await ctx.db.user.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: updateData,
|
data: updateData,
|
||||||
include: {
|
include: {
|
||||||
@@ -273,6 +282,11 @@ export const usersRouter = createTRPCRouter({
|
|||||||
dept: true
|
dept: true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 标记用户会话失效,强制重新登录以获取最新权限
|
||||||
|
await invalidateUserSessions([id])
|
||||||
|
|
||||||
|
return updatedUser
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getById: permissionRequiredProcedure(Permissions.USER_MANAGE).input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
|
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: '不能删除自己的账户' })
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '不能删除自己的账户' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 标记用户会话失效
|
||||||
|
await invalidateUserSessions([id])
|
||||||
|
|
||||||
// 删除用户
|
// 删除用户
|
||||||
return ctx.db.user.delete({
|
return ctx.db.user.delete({
|
||||||
where: { id },
|
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