16. Authentication

Аутентификация (кто ты?) и авторизация (что ты можешь делать?) — критически важные части любого API.
Аутентификация через JWT
Заголовок раздела «Аутентификация через JWT»npm install jsonwebtoken bcryptimport jwt from 'jsonwebtoken';import bcrypt from 'bcrypt';
const JWT_SECRET = process.env.JWT_SECRET;const JWT_EXPIRES_IN = '7d';
const typeDefs = `#graphql type AuthPayload { token: String! user: User! }
type Mutation { register(name: String!, email: String!, password: String!): AuthPayload! login(email: String!, password: String!): AuthPayload! refreshToken(refreshToken: String!): AuthPayload! logout: Boolean! }`;
const resolvers = { Mutation: { register: async (_, { name, email, password }, { db }) => { // Проверяем уникальность email const existing = await db.users.findByEmail(email); if (existing) { throw new GraphQLError('Email уже используется', { extensions: { code: 'BAD_USER_INPUT', field: 'email' }, }); }
// Хешируем пароль const passwordHash = await bcrypt.hash(password, 12);
// Создаём пользователя const user = await db.users.create({ name, email, passwordHash });
// Генерируем токен const token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN, });
return { token, user }; },
login: async (_, { email, password }, { db }) => { const user = await db.users.findByEmail(email); if (!user) { throw new GraphQLError('Неверный email или пароль', { extensions: { code: 'UNAUTHENTICATED' }, }); }
const valid = await bcrypt.compare(password, user.passwordHash); if (!valid) { throw new GraphQLError('Неверный email или пароль', { extensions: { code: 'UNAUTHENTICATED' }, }); }
const token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN, });
return { token, user }; }, },};Context с текущим пользователем
Заголовок раздела «Context с текущим пользователем»import { startStandaloneServer } from '@apollo/server/standalone';
const { url } = await startStandaloneServer(server, { context: async ({ req }) => { const authHeader = req.headers.authorization; let user = null;
if (authHeader) { const token = authHeader.replace('Bearer ', ''); try { const decoded = jwt.verify(token, JWT_SECRET); user = await db.users.findById(decoded.userId); } catch (error) { // Токен невалиден — user остаётся null // Не бросаем ошибку здесь — resolver сам решит } }
return { db, user, loaders: createLoaders(db) }; },});Защита resolver’ов
Заголовок раздела «Защита resolver’ов»Простая проверка
Заголовок раздела «Простая проверка»const resolvers = { Query: { me: (_, __, { user }) => { if (!user) { throw new GraphQLError('Необходима авторизация', { extensions: { code: 'UNAUTHENTICATED' }, }); } return user; }, },};Хелперы авторизации
Заголовок раздела «Хелперы авторизации»import { GraphQLError } from 'graphql';
export function assertAuthenticated(user) { if (!user) { throw new GraphQLError('Необходима авторизация', { extensions: { code: 'UNAUTHENTICATED', http: { status: 401 }, }, }); }}
export function assertRole(user, ...roles) { assertAuthenticated(user); if (!roles.includes(user.role)) { throw new GraphQLError('Недостаточно прав', { extensions: { code: 'FORBIDDEN', http: { status: 403 }, }, }); }}
export function assertOwner(user, resourceUserId) { assertAuthenticated(user); if (user.id !== resourceUserId && user.role !== 'ADMIN') { throw new GraphQLError('Нет доступа к этому ресурсу', { extensions: { code: 'FORBIDDEN' }, }); }}
// Использование:const resolvers = { Mutation: { createPost: (_, { input }, { user }) => { assertAuthenticated(user); return db.posts.create({ ...input, authorId: user.id }); },
deleteUser: (_, { id }, { user }) => { assertRole(user, 'ADMIN'); return db.users.delete(id); },
updatePost: async (_, { id, input }, { user }) => { const post = await db.posts.findById(id); assertOwner(user, post.authorId); return db.posts.update(id, input); }, },};Shield — middleware авторизации
Заголовок раздела «Shield — middleware авторизации»npm install graphql-shieldimport { shield, rule, and, or, not } from 'graphql-shield';import { makeExecutableSchema } from '@graphql-tools/schema';import { applyMiddleware } from 'graphql-middleware';
// Правилаconst isAuthenticated = rule()(async (parent, args, { user }) => { return !!user;});
const isAdmin = rule()(async (parent, args, { user }) => { return user?.role === 'ADMIN';});
const isPostOwner = rule()(async (parent, args, { user, db }) => { const post = await db.posts.findById(args.id); return post?.authorId === user?.id;});
// Матрица правconst permissions = shield({ Query: { me: isAuthenticated, adminUsers: isAdmin, posts: not(isAuthenticated), // Только публичные }, Mutation: { createPost: isAuthenticated, updatePost: or(isAdmin, isPostOwner), deletePost: or(isAdmin, isPostOwner), deleteUser: isAdmin, }, Post: { // Поле доступно только автору или админу draftContent: or(isAdmin, isPostOwner), },}, { allowExternalErrors: true, // Показывать оригинальные ошибки fallbackError: 'Нет доступа',});
// Применяем middleware к схемеlet schema = makeExecutableSchema({ typeDefs, resolvers });schema = applyMiddleware(schema, permissions);Авторизация на уровне данных (Row-level security)
Заголовок раздела «Авторизация на уровне данных (Row-level security)»const resolvers = { Query: { posts: async (_, { status }, { user, db }) => { // Без авторизации — только опубликованные if (!user) { return db.posts.findMany({ where: { status: 'PUBLISHED' } }); }
// Адмиин — видит всё if (user.role === 'ADMIN') { return db.posts.findMany({ where: { status } }); }
// Обычный пользователь — свои черновики + чужие опубликованные return db.posts.findMany({ where: { OR: [ { status: 'PUBLISHED' }, { authorId: user.id }, ], ...(status ? { status } : {}), }, }); }, },};Refresh Tokens
Заголовок раздела «Refresh Tokens»const typeDefs = `#graphql type Mutation { login(email: String!, password: String!): LoginPayload! refreshToken(refreshToken: String!): LoginPayload! logout: Boolean! }
type LoginPayload { accessToken: String! refreshToken: String! user: User! }`;
const resolvers = { Mutation: { login: async (_, { email, password }, { db }) => { // ... проверка пароля
// Короткий access token const accessToken = jwt.sign( { userId: user.id }, process.env.JWT_ACCESS_SECRET, { expiresIn: '15m' } );
// Длинный refresh token const refreshToken = jwt.sign( { userId: user.id }, process.env.JWT_REFRESH_SECRET, { expiresIn: '30d' } );
// Сохраняем refresh token в БД await db.refreshTokens.create({ token: refreshToken, userId: user.id, expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), });
return { accessToken, refreshToken, user }; },
refreshToken: async (_, { refreshToken }, { db }) => { let decoded; try { decoded = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET); } catch { throw new GraphQLError('Невалидный refresh token'); }
// Проверяем в БД (токен не был отозван?) const stored = await db.refreshTokens.findOne({ token: refreshToken }); if (!stored) throw new GraphQLError('Токен отозван');
const user = await db.users.findById(decoded.userId); const accessToken = jwt.sign( { userId: user.id }, process.env.JWT_ACCESS_SECRET, { expiresIn: '15m' } );
return { accessToken, refreshToken, user }; },
logout: async (_, __, { db, req }) => { const token = req.headers.authorization?.replace('Bearer ', ''); if (token) { await db.refreshTokens.delete({ token }); } return true; }, },};Клиент: Apollo Client с токеном
Заголовок раздела «Клиент: Apollo Client с токеном»import { ApolloClient, InMemoryCache, createHttpLink, from } from '@apollo/client';import { setContext } from '@apollo/client/link/context';import { onError } from '@apollo/client/link/error';
const httpLink = createHttpLink({ uri: '/graphql' });
// Добавляем токен в каждый запросconst authLink = setContext((_, { headers }) => { const token = localStorage.getItem('accessToken'); return { headers: { ...headers, authorization: token ? `Bearer ${token}` : '', }, };});
// Обработка ошибок авторизацииconst errorLink = onError(({ graphQLErrors, operation, forward }) => { if (graphQLErrors?.some(e => e.extensions?.code === 'UNAUTHENTICATED')) { // Попытка обновить токен return new Observable((observer) => { refreshAccessToken() .then(newToken => { localStorage.setItem('accessToken', newToken); // Повторяем запрос с новым токеном operation.setContext(({ headers }) => ({ headers: { ...headers, authorization: `Bearer ${newToken}` }, })); forward(operation).subscribe(observer); }) .catch(() => { // Refresh провалился — логаут localStorage.clear(); window.location.href = '/login'; }); }); }});
export const client = new ApolloClient({ link: from([errorLink, authLink, httpLink]), cache: new InMemoryCache(),});Практика
Заголовок раздела «Практика»- Реализуй
registerиloginмутации с JWT - Добавь текущего пользователя в Apollo context
- Создай helper
assertAuthenticatedи защити приватные resolvers - Реализуй графику прав с graphql-shield (admin/editor/reader)
- Добавь обновление access token через refresh token
- JWT в
Authorization: Bearer <token>заголовке - Извлекаем пользователя в
contextфункции сервера assertAuthenticated/assertRole— helpers для проверокgraphql-shield— декларативная матрица прав- Access token (короткий) + Refresh token (длинный) — best practice
Следующий урок → Pagination →