【免费下载链接】howtographql
The Fullstack Tutorial for GraphQL
本篇指南基于 HowToGraphQL 教程中的 TypeScript 后端路线(Node.js + Fastify + GraphQL-Helix + Prisma),完整讲解如何为 GraphQL 服务器实现用户注册(signup)、登录(login)与当前用户识别(me)的完整认证体系。读完本文,你将掌握 JWT 签发与校验、密码的 bcrypt 哈希存储、GraphQL context 中注入认证用户,以及通过 resolver 权限守卫保护敏感操作的全部实现细节,最终得到一个可通过Authorization: Bearer头完成鉴权的全栈 GraphQL API。
背景与整体思路
在 HowToGraphQL 的 TypeScript 后端教程中,项目基于以下技术栈构建:Node.js 作为运行时、TypeScript 作为开发语言、fastify作为 HTTP 服务器、graphql-helix作为 GraphQL 请求处理库、Prisma(配合 SQLite)作为数据库访问层。在认证章节之前,项目已完成 GraphQL 服务器的搭建(POST /graphql端点 + GraphiQL 界面)和 Prisma Client 与数据库的连接(context.prisma可用)。
认证章节要解决的核心问题是:如何让用户对 GraphQL 服务器进行身份认证,并让每个 resolver 都知道当前请求的用户是谁。整体方案分为五个部分:
- 在 Prisma 数据模型中新增
User模型,并建立User与Link的一对多关系; - 在 GraphQL Schema 中新增
signup、login两个 mutation 和AuthPayload、User类型; - 使用
jsonwebtoken签发/校验 JWT,使用bcryptjs对密码做哈希,实现两个 mutation 的 resolver; - 通过 HTTP
Authorization头携带 token,在构建 GraphQL context 时解析 token 并把currentUser注入所有 resolver; - 用
context.currentUser保护postmutation,并实现Link.postedBy、User.links关系字段 resolver,验证me查询。
这套方案刻意选择了"简单但完整"的 JWT 方案:无状态、不依赖服务端 session,token 通过标准 HTTP 头传递而不污染 GraphQL 契约本身。
第一步:在 Prisma 数据模型中添加 User 模型
首先需要在数据库层面表达用户数据。打开prisma/schema.prisma,新增User模型,并更新已有的Link模型以表达"Link 由某个 User 发布"这一关系:
model Link { id Int @id @default(autoincrement()) createdAt DateTime @default(now()) description String url String postedBy User? @relation(fields: [postedById], references: [id]) postedById Int? } model User { id Int @id @default(autoincrement()) name String email String @unique password String links Link[] }理解关系字段(relation fields)
这里有两个关键点值得展开:
Link模型上新增了一个关系字段postedBy,类型为可空的User?,并通过@relation属性注解声明:fields: [postedById]指定本表的外键列是postedById,references: [id]指定它指向User表的id列。因此还需要一个显式的postedById Int?列来实际存储外键值。对熟悉 SQL 的读者来说,这就是典型的一对多关系(一个 User 发布多条 Link,一条 Link 属于一个 User)。postedById可空,意味着系统中允许存在未关联发布者的历史链接。User模型上新增links Link[]字段,这是关系的反向(一对多的"多"侧),表示该用户发布的所有链接列表。email字段带@unique约束,保证邮箱唯一——这正是后续loginresolver 使用findUnique({ where: { email } })精确查询的依据。
Prisma 数据模型的价值在于:数据模型与底层数据库表的映射关系被显式声明在 schema 中,开发者无需手写 JOIN 或外键 SQL,就能以贴近数据库语义的方式推理数据结构。
执行迁移并重新生成 Prisma Client
按照本教程在 数据库章节建立的固定工作流:每次修改数据模型后,都必须先迁移数据库,再重新生成 Prisma Client。在项目根目录运行:
npx prisma migrate dev --name "add-user-model"该命令会生成第二份迁移脚本并放入prisma/migrations目录——这个目录随时间推移成为数据库演化的历史记录;同时命令会实际执行迁移,使新的User表就绪。迁移完成后,Prisma Client 也自动重新生成,暴露出针对User模型的全部 CRUD 方法(user.create、user.findUnique等),可以直接在 resolver 中使用。
第二步:扩展 GraphQL Schema
沿用教程中的 schema-driven 开发方式:先把新增操作写进 SDL(Schema Definition Language),再实现对应的 resolver。打开src/schema.graphql,更新为:
type Query { info: String! feed: [Link!]! } type Mutation { post(url: String!, description: String!): Link! signup(email: String!, password: String!, name: String!): AuthPayload login(email: String!, password: String!): AuthPayload } type Link { id: ID! description: String! url: String! } type AuthPayload { token: String user: User } type User { id: ID! name: String! email: String! links: [Link!]! }几个设计细节:
signup和login的行为高度相似:两者都返回正在注册(或登录)的User信息以及一个可用于后续请求认证的token,这些信息被打包进AuthPayload类型统一返回。两个字段声明为可空(token: String、user: User),因为AuthPayload作为通用认证结果类型,设计上允许字段缺省。User类型中没有暴露password字段——认证凭证不应通过 GraphQL 契约外泄,这是 schema 层面就做出的安全决策。
由于User与Link的关系是双向的,还需要在Link类型中补上postedBy字段,使 GraphQL 契约与 Prisma 模型的关系保持对称:
type Link { id: ID! description: String! url: String! postedBy: User }第三步:实现认证基础设施
安装依赖
认证实现依赖两个库:jsonwebtoken用于签发和校验 JWT,bcryptjs用于密码哈希:
npm install --save jsonwebtoken bcryptjs为了获得完整的 TypeScript 类型支持,还需要安装对应的类型声明包:
npm install --save-dev @types/jsonwebtoken @types/bcryptjs创建 src/auth.ts 与签名密钥
新建src/auth.ts,先放入一个签名密钥常量(后续作为 JWT 签名与密码加密的基础):
export const APP_SECRET = 'this is my secret';安全提示:这里硬编码密钥仅适用于教程环境。生产环境中
APP_SECRET应通过环境变量注入(例如process.env.APP_SECRET),且不应提交到版本库。
实现 signup resolver
打开src/schema.ts,在Mutation下新增signupresolver:
// ... other imports ... import { APP_SECRET } from "./auth"; import { hash } from "bcryptjs"; import { sign } from "jsonwebtoken"; const resolvers = { // ... other resolvers ... Mutation: { signup: async ( parent: unknown, args: { email: string; password: string; name: string }, context: GraphQLContext ) => { // 1. 对明文密码做 bcrypt 哈希 const password = await hash(args.password, 10); // 2. 通过 PrismaClient 写入新的 User 记录 const user = await context.prisma.user.create({ data: { ...args, password }, }); // 3. 用 APP_SECRET 签发 JWT,payload 中只携带 userId const token = sign({ userId: user.id }, APP_SECRET); // 4. 按 AuthPayload 的形状返回 token 和 user return { token, user, }; }, } }逐步拆解这四步:
- 密码哈希:
bcryptjs的hash以 cost factor 10 对明文密码加盐哈希。数据库中永远只存哈希值,即使数据库泄露也无法直接还原密码。注意hash是异步操作,必须await。 - 持久化用户:通过
context.prisma(即 前一章挂在 GraphQL context 上的PrismaClient单例)调用user.create插入新记录。data: { ...args, password }把email、name两个入参展开,同时用哈希后的password覆盖原明文。 - 签发 JWT:
jsonwebtoken的sign以{ userId: user.id }作为 payload、APP_SECRET作为密钥生成签名令牌。注意 payload 中只放用户 ID,不放大段用户信息——这样用户改名等场景下 token 依然有效,后续请求只需按 ID 查库。 - 组装返回值:返回的
{ token, user }对象与 schema 中AuthPayload类型一一对应,GraphQL 引擎会按选择集裁剪字段后返回给客户端。
验证 signup
启动服务器(npm run dev)后,打开http://localhost:3000/graphql的 GraphiQL,执行:
mutation { signup(email: "test@mail.com", name: "Dotan Simha", password: "123456") { token user { id name email } } }执行成功后会返回一个 JWT 字符串与用户信息。请务必保存这个 token,后续验证me查询时会用到。
实现 login resolver
在signup下方继续添加loginresolver:
// ... other imports ... import { hash, compare } from "bcryptjs"; const resolvers = { // ... other resolvers ... Mutation: { login: async ( parent: unknown, args: { email: string; password: string }, context: GraphQLContext ) => { // 1. 按邮箱查找用户,不存在则报错 const user = await context.prisma.user.findUnique({ where: { email: args.email }, }); if (!user) { throw new Error("No such user found"); } // 2. 将入参明文密码与库中哈希比对,不一致则报错 const valid = await compare(args.password, user.password); if (!valid) { throw new Error("Invalid password"); } const token = sign({ userId: user.id }, APP_SECRET); // 3. 同样以 AuthPayload 形状返回 return { token, user, }; }, } }与signup的对照:
- 不创建新记录,而是用
findUnique按email精确查找现有用户(依赖User.email上的@unique约束)。查不到即抛出No such user found。 - 用
bcryptjs的compare把客户端提交的明文密码与数据库中存储的哈希进行恒定时间比对,不匹配则抛出Invalid password。 - 校验通过后签发与
signup完全相同结构的 JWT,并返回{ token, user }。
两个错误分支都用throw new Error表达:GraphQL 引擎会把 resolver 抛出的异常收集进响应的errors字段,data中对应字段则为null——这是 GraphQL 标准的错误传播机制,客户端能明确感知登录失败。
在 GraphiQL 中用刚注册的账号验证登录:
mutation { login(email: "test@mail.com", password: "123456") { token user { id name email } } }第四步:通过 HTTP 头识别当前用户
有了签发 token 的能力,下一步是识别每次请求的发起者是谁。关键设计决策是:不走 GraphQL schema 传 token(比如不把它设计成 mutation 的参数),而是使用标准 HTTP 头,避免认证流程污染 GraphQL 契约:
Authorization: "Bearer MY_TOKEN_HERE"为此,服务器需要能访问原始 HTTP 请求、验证 token、解析出当前用户,并把该用户注入 GraphQLcontext——这样每个 resolver 都能通过第三个参数读到context.currentUser。
authenticateUser 函数
在src/auth.ts中新增认证函数:
import { PrismaClient, User } from "@prisma/client"; import { FastifyRequest } from "fastify"; import { JwtPayload, verify } from "jsonwebtoken"; export const APP_SECRET = "this is my secret"; export async function authenticateUser(prisma: PrismaClient, request: FastifyRequest): Promise<User | null> { if (request?.headers?.authorization) { // 1. 从 Authorization 头中拆出 Bearer 后的 token const token = request.headers.authorization.split(" ")[1]; // 2. 用 jsonwebtoken 的 verify 校验签名,解析出 payload const tokenPayload = verify(token, APP_SECRET) as JwtPayload; // 3. 取出 payload 中的 userId const userId = tokenPayload.userId; // 4. 按 ID 从数据库查出用户 return await prisma.user.findUnique({ where: { id: userId } }); } return null; }流程梳理:
- 读取入站 HTTP 请求头中的
Authorization值,按空格切分取第二段得到 token(第一段是Bearer前缀)。 verify用APP_SECRET校验签名与时效,失败会直接抛出异常;成功则得到 payload,从中取出userId。- 用 Prisma 按
id查库,返回完整的User记录(而不是仅凭 token payload 构造用户,保证数据实时准确)。 - 请求头缺失或 token 无效时返回
null,让上层 resolver 自行决定是拒绝访问还是按匿名处理。
改造 contextFactory
修改src/context.ts,让 context 构建阶段调用上述函数:
import { PrismaClient, User } from "@prisma/client"; import { FastifyRequest } from "fastify"; import { authenticateUser } from "./auth"; const prisma = new PrismaClient(); export type GraphQLContext = { prisma: PrismaClient; currentUser: User | null; }; export async function contextFactory( request: FastifyRequest ): Promise<GraphQLContext> { return { prisma, currentUser: await authenticateUser(prisma, request), }; }GraphQLContext类型新增了currentUser: User | null字段——类型层面的声明让所有 resolver 都能获得currentUser的自动补全与严格空值检查。
同时,必须确保contextFactory能拿到入站 HTTP 请求。在src/index.ts的 GraphQL 处理器中把 Fastify 的req传进去:
const result = await processRequest({ request, schema, operationName, contextFactory: () => contextFactory(req), query, variables, });注意这里contextFactory从 第七章中直接传函数引用,改成了每次调用时执行() => contextFactory(req)的闭包,以便把当前请求req注入进去。这一步正是"识别当前用户"能落地的关键:graphql-helix的processRequest在执行每个 GraphQL 请求前调用contextFactory构建 context,认证逻辑就嵌入了请求处理管线。
至此,每个携带有效 token 的 GraphQL 请求都会在context.currentUser中拿到认证用户;没有 token 或 token 无效时,context.currentUser为null。
添加 me 查询验证
为验证 context 注入生效,在 schema 中新增Query.me字段:
type Query { info: String! feed: [Link!]! me: User! }并实现其 resolver:
const resolvers = { Query: { me: (parent: unknown, args: {}, context: GraphQLContext) => { if (context.currentUser === null) { throw new Error("Unauthenticated!"); } return context.currentUser; }, } }resolver 的权限检查模式很简单直白:context.currentUser为null时直接抛错。在 GraphiQL 中执行查询:
query { me { id name } }并在 GraphiQL 的HEADERS区域填入之前保存的 token:
{ "Authorization": "Bearer YOUR_TOKEN_HERE" }执行后服务器即可基于 token 识别并返回当前用户信息——认证闭环打通。
第五步:把认证接入其余 resolver
保护 post mutation
此前postmutation 对所有人开放。现在要求只有认证用户才能发布链接,并且发布时自动关联当前用户。修改Mutation.post:
const resolvers = { Mutation: { post: async (parent: unknown, args: { url: string; description: string }, context: GraphQLContext) => { if (context.currentUser === null) { throw new Error("Unauthenticated!"); } const newLink = await context.prisma.link.create({ data: { url: args.url, description: args.description, postedBy: { connect: { id: context.currentUser.id } }, }, }); return newLink; }, } }两处变化:
- 函数开头做认证守卫:
context.currentUser === null时抛Unauthenticated!。没有携带 token 或 token 失效的客户端从此无法再调用post。 - 创建
Link时通过 Prisma 的关系连接语法postedBy: { connect: { id: context.currentUser.id } }把新链接关联到当前用户。connect是 Prisma 在写操作时建立外键关系的标准方式,等价于在插入时填充postedById外键列,无需二次更新。
在 GraphiQL 中携带Authorization头再次执行:
mutation { post(url: "www.graphqlconf.org", description: "An awesome GraphQL conference") { id } }实现关系字段 resolver
还有最后一块拼图:让User与Link之间新增的关系字段真正可查询。
Link.postedByresolver——在src/schema.ts的 resolvers 中为Link类型补充:
const resolvers = { Link: { id: (parent: Link) => parent.id, description: (parent: Link) => parent.description, url: (parent: Link) => parent.url, postedBy: async (parent: Link, args: {}, context: GraphQLContext) => { if (!parent.postedById) { return null; } return context.prisma.link .findUnique({ where: { id: parent.id } }) .postedBy(); }, }, }这里利用了 Prisma Client 的关系查询 API:先findUnique拿到Link的记录(类型上带有关系方法),再链式调用.postedBy()加载其发布者的User。开头对parent.postedById判空,是因为postedById在数据模型中可空——历史数据允许存在无发布者的链接。注意 resolver 名必须与 GraphQL 类型定义中的字段名postedBy一致,这是 GraphQL 字段级 resolver 的命名约定。
User.linksresolver——同理实现反向关系:
// ... other imports ... import { Link, User } from "@prisma/client"; // ... other resolvers ... const resolvers = { User: { links: (parent: User, args: {}, context: GraphQLContext) => context.prisma.user.findUnique({ where: { id: parent.id } }).links(), }, }两个方向都通过 Prisma Client 生成的关系方法(.postedBy()/.links())解析,避免了手写关联查询。
端到端验证
现在所有字段都已有 resolver,可以在 GraphiQL 中运行组合查询,一次验证 feed 列表、关系解析与认证状态:
query { feed { id description url postedBy { id name } } }返回结果中每条Link都会带上其发布者的User信息,postedBy为null的则是认证功能上线前创建的链接。
方案总结与安全要点
把本章节的实现串起来,完整的认证调用链是:
- 写路径:
signup/loginmutation 校验身份 → 签发 JWT(payload 只含userId)→ 随AuthPayload返回客户端; - 读路径:客户端每个请求在
Authorization: Bearer <token>头中携带 token →contextFactory调authenticateUser解析并查库 →context.currentUser注入所有 resolver →me、post等 resolver 据此授权与取数。
几个值得记住的工程要点:
- 密钥管理:
APP_SECRET硬编码仅适合学习,生产环境务必改用环境变量,且 JWT 密钥一旦泄露应立即轮换。 - 无状态与时效:JWT 是无状态的,
verify只校验签名。sign未设置过期时间意味着 token 永久有效,生产实现应通过expiresIn加上合理的有效期。 - 错误语义:认证失败统一用
throw new Error表达,经 GraphQL 的errors字段返回,客户端逻辑与 REST 的 401 语义不同,需要在客户端做相应处理。 - 密码安全:明文密码只存在于
signup/login的入参中,数据库仅存 bcrypt 哈希,GraphQL 契约(User类型)从不暴露password字段。 - 授权粒度:本教程演示的是"认证"(你是谁)与最基础的"授权"(
post只允许登录用户)。更细粒度的权限(例如只有发布者能编辑自己的链接)可以在 resolver 中基于context.currentUser进一步判断。
至此,HackerNews 克隆的 GraphQL 服务器具备了完整的用户认证能力:注册、登录、识别当前用户、保护写操作、解析用户与内容的双向关系。后续章节(订阅与过滤、分页、排序)将在此基础上继续扩展 API 能力。
【免费下载链接】howtographql
The Fullstack Tutorial for GraphQL
相关推荐
HowToGraphQL TypeScript + Apollo Server 实战:用 JWT、bcrypt 与 Prisma 实现 GraphQL 用户认证
HowToGraphQL TypeScript + Apollo Server 实战:用 JWT、bcrypt 与 Prisma 实现 GraphQL 用户认证
RedisInsight批量操作深度解析:5个提升Redis管理效率的关键技巧
RedisInsight批量操作深度解析:5个提升Redis管理效率的关键技巧 RedisInsight作为Redis官方推出的图形化管理工具,其批量操作功能是
数据库客户端桌面应用后端前端数据可视化Forge中的上下文压缩:处理长对话的高效方法
Forge中的上下文压缩:处理长对话的高效方法 在构建自托管LLM应用时,长对话管理是开发者面临的核心挑战之一。 Forge 作为专注于本地部署LLM工具调用和
人工智能LLM 网关工具调用本地部署
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考