Context 中的用户注入
import jwt from 'jsonwebtoken';
interface Context {
db: PrismaClient;
user: User | null;
loaders: DataLoaders;
}
async function getUserFromToken(token: string | undefined): Promise<User | null> {
if (!token?.startsWith('Bearer ')) return null;
try {
const decoded = jwt.verify(token.slice(7), process.env.JWT_SECRET!) as { userId: string };
return db.users.findUnique({ where: { id: decoded.userId } });
} catch {
return null;
}
}
await startStandaloneServer(server, {
context: async ({ req }) => ({
db,
user: await getUserFromToken(req.headers.authorization),
loaders: createLoaders(db),
}),
});
在 Resolver 中检查权限
import { GraphQLError } from 'graphql';
function requireAuth(user: User | null): User {
if (!user) {
throw new GraphQLError('Must be authenticated', {
extensions: { code: 'UNAUTHENTICATED' },
});
}
return user;
}
function requireRole(user: User | null, role: UserRole): User {
const authedUser = requireAuth(user);
if (authedUser.role !== role && authedUser.role !== 'ADMIN') {
throw new GraphQLError('Not authorized', {
extensions: { code: 'FORBIDDEN' },
});
}
return authedUser;
}
const resolvers = {
Query: {
// 公开查询
posts: (_, __, { db }) => db.posts.findMany({ where: { published: true } }),
// 需要认证
me: (_, __, { user }) => requireAuth(user),
// 需要特定角色
adminDashboard: (_, __, { user }) => {
requireRole(user, 'ADMIN');
return { /* ... */ };
},
},
Mutation: {
deleteUser: async (_, { id }, { user, db }) => {
const authed = requireRole(user, 'ADMIN');
await db.users.delete({ where: { id } });
return true;
},
},
};
graphql-shield:声明式权限规则
import { rule, shield, and, or, not } from 'graphql-shield';
// 定义规则
const isAuthenticated = rule({ cache: 'contextual' })(
async (parent, args, ctx) => ctx.user !== null
);
const isAdmin = rule({ cache: 'contextual' })(
async (parent, args, ctx) => ctx.user?.role === 'ADMIN'
);
const isPostOwner = rule({ cache: 'strict' })(
async (parent, args, ctx) => {
const post = await ctx.db.posts.findUnique({ where: { id: args.id } });
return post?.authorId === ctx.user?.id;
}
);
// 应用权限到 Schema
const permissions = shield({
Query: {
'*': isAuthenticated, // 默认所有 Query 需要认证
posts: not(isAuthenticated), // 覆盖:posts 公开
adminStats: isAdmin,
},
Mutation: {
createPost: isAuthenticated,
updatePost: and(isAuthenticated, isPostOwner),
deletePost: or(isAdmin, isPostOwner),
},
User: {
// 字段级权限:email 只有本人和 ADMIN 可见
email: or(isAdmin, rule()(async (parent, _, ctx) => parent.id === ctx.user?.id)),
},
});
// 将权限中间件应用到 Schema
const schemaWithPermissions = applyMiddleware(schema, permissions);
JWT 登录 Mutation
const resolvers = {
Mutation: {
login: async (_, { email, password }, { db }) => {
const user = await db.users.findUnique({ where: { email } });
if (!user) throw new GraphQLError('Invalid credentials', {
extensions: { code: 'UNAUTHENTICATED' }
});
const valid = await bcrypt.compare(password, user.passwordHash);
if (!valid) throw new GraphQLError('Invalid credentials', {
extensions: { code: 'UNAUTHENTICATED' }
});
const token = jwt.sign(
{ userId: user.id },
process.env.JWT_SECRET!,
{ expiresIn: '7d' }
);
return { token, user };
},
},
};
GraphQL 安全最佳实践
- 在 context 中验证 Token,不要在每个 Resolver 中重复验证
- 生产环境禁用 introspection(
introspection: false) - 设置查询深度和复杂度限制,防止恶意深层查询攻击
- 使用 Persisted Queries 白名单,只允许预定义的查询
- 对 Mutation 参数进行严格的输入验证(zod/joi)
认证 vs 授权:概念区分
认证(Authentication)
"你是谁?"——验证用户身份的过程。GraphQL 中通常在 context 函数中完成:解析 HTTP 请求头中的 JWT Token,验证签名和有效期,提取用户 ID 并从数据库加载用户信息,注入到 context.user 中。
授权(Authorization)
"你能做什么?"——检查已认证用户是否有权限执行某操作。GraphQL 中可以在 Resolver 内部(命令式)或通过 graphql-shield 中间件(声明式)实现,支持操作级和字段级两种粒度。
RBAC(基于角色的访问控制)
Role-Based Access Control:用户被分配角色(USER/EDITOR/ADMIN),权限绑定在角色上而非用户上。GraphQL 中通常将角色存入 context.user.role,Resolver 检查角色而非具体权限,便于维护。
查询复杂度限制(防 DoS 攻击)
import { createComplexityLimitRule } from 'graphql-validation-complexity';
import depthLimit from 'graphql-depth-limit';
// GraphQL 查询可以无限嵌套,恶意查询可能导致服务崩溃:
// query { users { posts { author { posts { author { posts { ... } } } } } } }
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [
// 限制查询深度(防止无限嵌套)
depthLimit(7),
// 限制查询复杂度(每个字段计分,超出阈值拒绝)
createComplexityLimitRule(1000, {
scalarCost: 1, // 标量字段:1分
objectCost: 2, // 对象字段:2分
listFactor: 10, // 列表字段乘以10(可能返回大量数据)
}),
],
// 生产环境禁用 introspection(防止 Schema 信息泄漏)
introspection: process.env.NODE_ENV !== 'production',
});
Resource-Level 权限:资源所有权校验
const resolvers = {
Mutation: {
updatePost: async (_, { id, data }, { user, db }) => {
const authed = requireAuth(user);
// 加载资源并校验所有权
const post = await db.posts.findUnique({ where: { id } });
if (!post) {
throw new GraphQLError('Post not found', {
extensions: { code: 'NOT_FOUND' }
});
}
// 只有作者或 ADMIN 才能修改
if (post.authorId !== authed.id && authed.role !== 'ADMIN') {
throw new GraphQLError('Not authorized to update this post', {
extensions: { code: 'FORBIDDEN' }
});
}
return db.posts.update({ where: { id }, data });
},
},
};
本章小结
本章核心要点
- 认证 vs 授权:认证("你是谁")在 context 函数中完成(JWT 验证);授权("你能做什么")在 Resolver 或 graphql-shield 中间件中实现——二者职责分离。
- Context 注入用户:JWT 解析放在 context 函数中,所有 Resolver 共享 ctx.user;优点:Token 只验证一次;缺点:即使用户不调用受保护字段,也会验证 Token(可优化为惰性验证)。
- 错误码规范:认证失败用
UNAUTHENTICATED,权限不足用FORBIDDEN,资源不存在用NOT_FOUND;这三个标准错误码与 HTTP 401/403/404 对应,便于客户端处理。 - graphql-shield 声明式权限:用 rule() 定义规则,shield() 将规则绑定到类型/字段,支持 and/or/not 逻辑组合;比在每个 Resolver 中写 if 判断更清晰、更易维护。
- 防 DoS 攻击:graphql-depth-limit 限制嵌套深度,graphql-validation-complexity 限制查询复杂度;生产环境禁用 introspection 防止 Schema 信息泄漏。
- 字段级权限:graphql-shield 支持将规则绑定到具体字段(如 User.email 只有本人和管理员可见),是 REST API 无法轻易实现的细粒度权限控制。