Apollo Server 4登录成功后context.user仍为空的问题求助
我正在使用Apollo Server 4开发,基于JWT和Cookie实现了登录系统,但登录成功后context.user始终为空,无法在请求间持久化。登录解析器已将JWT设置到Cookie中,浏览器网络面板能看到该Cookie,但后续请求的context中仍无用户信息。
我已做以下排查:
- 实现了
createContext函数,从Cookie提取并验证JWT,根据载荷设置context.user - 服务器已发送HttpOnly Cookie,浏览器可见,但后续请求似乎未携带该Cookie
- 检查Apollo Server配置,确认context设置正确
- 尝试不同浏览器及调整隐私设置,确保未拦截Cookie
相关代码
server.ts
import {ApolloServer} from '@apollo/server'; import {startStandaloneServer} from '@apollo/server/standalone'; import mongoose from 'mongoose'; import {typeDefs} from './application/graphql/typeDefs'; import {resolvers} from './application/graphql/resolvers'; import UserInterface from "./domain/interface/model/user.interface"; import jwt, {JwtPayload} from "jsonwebtoken"; import {IncomingMessage, ServerResponse} from "http"; import {User} from "./infrastructure/model/user.model"; mongoose.connect('mongodb://user-db:27017/user-service').then(() => { console.log('Connected to MongoDB'); }); require('./domain/config/passport'); interface ContextType { req: IncomingMessage; res: ServerResponse; user?: string | null; } const server = new ApolloServer<ContextType>({ typeDefs, resolvers, }); async function findUserById(id: string): Promise<UserInterface | null> { try { console.log("Find user by id:", id); return await User.findById(id); } catch (error) { console.error("Find user by id error:", error); return null; } } async function createContext({ req, res }: { req: IncomingMessage, res: ServerResponse }): Promise<ContextType> { let user: string | null = null; const token = req.headers.cookie?.split('; ') .find(cookie => cookie.startsWith('jwt=')) ?.split('=')[1]; if (token) { try { const decoded = jwt.verify(token, process.env.SECRET ?? 'SECRET_KEY'); if (typeof decoded === 'object' && decoded.id) { const userData = await findUserById(decoded.id); user = userData ? userData.id : null; console.log("User:", user); } } catch (error) { console.error("JWT Error:", error); res.setHeader('Set-Cookie', 'jwt=; HttpOnly; Path=/; Max-Age=0'); } } return { req, res, user }; } startStandaloneServer(server, { listen: { port: 4001 }, context: createContext }).then(({ url }) => { console.log(`🚀 Server ready at ${url}`); });
resolver.ts
import {User} from '../../infrastructure/model/user.model'; import bcrypt from 'bcryptjs'; import jwt, {JwtPayload} from 'jsonwebtoken'; import { loginValidationSchema, signUpValidationSchema, updateEmailValidationSchema, updateLocationValidationSchema, updatePasswordValidationSchema, updateValidationSchema } from "../../infrastructure/dataValidation/mutation.validation"; import { ContextType, QueryArgs, UpdateLocation, UpdateUserInput, UserInput } from "../../domain/interface/mutation/utils.mutation.interface"; function updateUserFields(userId: string, updateFields: object) { return User.findByIdAndUpdate( userId, {...updateFields, updated: new Date().toISOString()}, {new: true} ); } function verifyAuthenticatedUser(context: ContextType) { if (!context.user) throw new Error('You are not authenticated!'); return context.user; } export const resolvers = { Query: { me: async (_: any, __: any, context: ContextType) => { const userId = verifyAuthenticatedUser(context); return User.findById(userId); }, }, Mutation: { signUp: async (_: any, args: { user: UserInput } , context: ContextType) => { const { error } = signUpValidationSchema.validate(args.user); if (error) throw new Error(`Validation error: ${error.details.map(x => x.message).join(', ')}`); const newUser = new User({ ...args.user, created: new Date().toISOString(), updated: new Date().toISOString(), }); return await newUser.save(); }, login: async (_: any, {username, email, password}: QueryArgs, context: ContextType) => { const { error } = loginValidationSchema.validate({username, email, password}); if (error) throw new Error(`Validation error: ${error.details.map(x => x.message).join(', ')}`); if (!username && !email) throw new Error('Username or email is required!'); const user = await User.findOne({$or: [{username}, {email}]}); if (!user) throw new Error('Invalid credentials!'); const isMatch = await bcrypt.compare(password, user.password); if (!isMatch) throw new Error('Invalid credentials!'); const token = jwt.sign({id: user.id}, process.env.SECRET ?? 'SECRET_KEY', {expiresIn: '1h'}); context.res.setHeader('Set-Cookie', `jwt=${token}; HttpOnly; Path=/; Max-Age=${60 * 60}`); context.user = user.id; return token; }, logout: (_: any, __: any, context : ContextType) => { const userId = verifyAuthenticatedUser(context); const token = context.req.headers.cookie?.split('=')[1]; if (!token || !userId) throw new Error('You are not authenticated!'); try { const decoded = jwt.verify(token, process.env.SECRET ?? 'SECRET_KEY') as JwtPayload; if (decoded.id === userId) { context.res.setHeader('Set-Cookie', 'jwt=; HttpOnly; Path=/; Max-Age=0'); return true; } } catch (error) { throw new Error('You are not authenticated!'); } }, updateUser: async (_: any, {updateFields}: { updateFields: UpdateUserInput }, context: ContextType) => { const { error } = updateValidationSchema.validate(updateFields); if (error) throw new Error(`Validation error: ${error.details.map(x => x.message).join(', ')}`); const userId = verifyAuthenticatedUser(context); return await updateUserFields(userId, updateFields); }, updatePassword: async (_: any, {password}: { password: string }, context: ContextType) => { const { error } = updatePasswordValidationSchema.validate({password}); if (error) throw new Error(`Validation error: ${error.details.map(x => x.message).join(', ')}`); const userId = verifyAuthenticatedUser(context); if (!password) throw new Error('Password cannot be empty!'); const salt = await bcrypt.genSalt(10); const hashedPassword = await bcrypt.hash(password, salt); return await updateUserFields(userId, {password: hashedPassword}); }, updateLocation: async (_: any, {location}: { location: UpdateLocation }, context: ContextType) => { const { error } = updateLocationValidationSchema.validate({location}); if (error) throw new Error(`Validation error: ${error.details.map(x => x.message).join(', ')}`); const userId = verifyAuthenticatedUser(context); return await updateUserFields(userId, {location: location}); }, updateEmail: async (_: any, {email}: { email: string }, context: ContextType) => { const { error } = updateEmailValidationSchema.validate({email}); if (error) throw new Error(`Validation error: ${error.details.map(x => x.message).join(', ')}`); const userId = verifyAuthenticatedUser(context); return await updateUserFields(userId, {email: email}); }, deleteUser: async (_: any, __: any, context: ContextType) => { const userId = verifyAuthenticatedUser(context); return await User.findByIdAndDelete(userId); }, } };
1. 补充Cookie的SameSite属性
当前设置Cookie时未指定SameSite属性,现代浏览器默认按Lax规则处理,若客户端和服务端存在跨域(如客户端在localhost:3000,服务端在localhost:4001),Cookie可能无法被携带到后续请求。
修改登录和注销时的Cookie设置:
// 登录场景(本地开发用Lax,跨域生产环境用None+Secure) context.res.setHeader('Set-Cookie', `jwt=${token}; HttpOnly; Path=/; Max-Age=${60 * 60}; SameSite=Lax`); // 注销场景 context.res.setHeader('Set-Cookie', 'jwt=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax');
跨域场景必须使用SameSite=None; Secure,同时确保服务端开启HTTPS(本地开发可借助ngrok或自签证书)。
2. 确保客户端请求携带Cookie
如果使用Apollo Client调用接口,需在客户端配置中开启凭证携带:
const client = new ApolloClient({ uri: 'http://localhost:4001', cache: new InMemoryCache(), credentials: 'include', // 关键配置 });
用GraphQL Playground测试时,需在设置中把"Request credentials"改为"include"。
3. 调试Cookie提取逻辑
在createContext函数中添加日志,确认请求是否携带Cookie:
async function createContext({ req, res }: { req: IncomingMessage, res: ServerResponse }): Promise<ContextType> { let user: string | null = null; console.log('Request Cookies:', req.headers.cookie); // 新增日志 const token = req.headers.cookie?.split('; ') .find(cookie => cookie.startsWith('jwt=')) ?.split('=')[1]; // ... 剩余代码 }
若日志显示req.headers.cookie为空,说明浏览器未携带Cookie,回到前两步排查跨域和客户端配置。
4. 验证JWT密钥一致性
确保登录签名和createContext验证使用的密钥完全一致,检查process.env.SECRET是否正确加载,避免环境间密钥不一致导致验证失败。
5. 检查Cookie路径与域名
当前Path=/配置正确,但如果服务端使用子域名,需补充Domain属性(如Domain=.yourdomain.com),确保Cookie在整个域名下有效,本地开发一般无需设置。
内容的提问来源于stack exchange,提问作者Ymir_the_Giant

