Apollo Server’da Kullanıcı Kimlik Doğrulama ve Yetkilendirme

Modern web uygulamalarında kimlik doğrulama ve yetkilendirme, güvenli bir API’nin temel taşlarından ikisidir. Apollo Server ile GraphQL API geliştirirken bu iki kavramı doğru uygulamak, hem kullanıcı verilerini korumak hem de sisteme yetkisiz erişimi önlemek açısından kritik önem taşır. Bu yazıda, gerçek dünya senaryoları üzerinden Apollo Server’da kimlik doğrulama ve yetkilendirme mekanizmalarını adım adım ele alacağız.

Kimlik Doğrulama ve Yetkilendirme Farkı

Önce kavramları netleştirelim çünkü bu ikisi sıkça karıştırılır.

Kimlik Doğrulama (Authentication): “Sen kimsin?” sorusuna cevap verir. Kullanıcının sisteme giriş yapıp yapmadığını kontrol eder. JWT token, session, API key gibi mekanizmalar bu kategoriye girer.

Yetkilendirme (Authorization): “Bunu yapmaya iznin var mı?” sorusuna cevap verir. Kimliği doğrulanmış kullanıcının belirli bir kaynağa erişip erişemeyeceğini, hangi işlemleri gerçekleştirebileceğini belirler.

Apollo Server bu iki katmanı birbirinden bağımsız olarak ele almanıza olanak tanır. Context mekanizması sayesinde her request’te kullanıcı bilgisini çözümleyebilir, resolver’larda da yetkilendirme kontrollerini uygulayabilirsiniz.

Proje Kurulumu

Başlamadan önce gerekli paketleri kuralım:

npm init -y
npm install @apollo/server graphql jsonwebtoken bcryptjs express @as-integrations/express
npm install -D typescript @types/node @types/jsonwebtoken @types/bcryptjs ts-node

Temel Apollo Server yapısını oluşturalım:

mkdir -p src/{resolvers,middleware,utils}
touch src/index.ts src/schema.ts src/context.ts

Context ile Kullanıcı Bilgisini Taşımak

Apollo Server’ın context fonksiyonu, her gelen HTTP isteğinde çalışır ve resolver’lara aktarılan ortak bir nesne döndürür. JWT tabanlı kimlik doğrulama için bu mekanizma mükemmel bir çözüm sunar.

# src/context.ts

import { Request } from 'express';
import jwt from 'jsonwebtoken';

const JWT_SECRET = process.env.JWT_SECRET || 'super-secret-key';

export interface UserPayload {
  id: string;
  email: string;
  role: 'ADMIN' | 'EDITOR' | 'USER';
}

export interface Context {
  user: UserPayload | null;
}

export async function createContext({ req }: { req: Request }): Promise<Context> {
  const authHeader = req.headers.authorization || '';

  if (!authHeader.startsWith('Bearer ')) {
    return { user: null };
  }

  const token = authHeader.replace('Bearer ', '');

  try {
    const decoded = jwt.verify(token, JWT_SECRET) as UserPayload;
    return { user: decoded };
  } catch (error) {
    // Token gecersiz ya da suresi dolmus
    console.warn('Gecersiz token:', error.message);
    return { user: null };
  }
}

Bu yaklaşımın güzel yanı şu: Token geçersiz olduğunda hata fırlatmak yerine user: null döndürüyoruz. Hata yönetimini resolver katmanına bırakıyoruz, böylece public endpoint’ler sorunsuz çalışmaya devam eder.

GraphQL Schema Tasarımı

Kimlik doğrulama ve yetkilendirme senaryolarını kapsayan bir şema tasarlayalım:

# src/schema.ts

import { gql } from 'graphql-tag';

export const typeDefs = gql`
  enum Role {
    ADMIN
    EDITOR
    USER
  }

  type User {
    id: ID!
    email: String!
    role: Role!
    createdAt: String!
  }

  type AuthPayload {
    token: String!
    user: User!
  }

  type Post {
    id: ID!
    title: String!
    content: String!
    author: User!
    published: Boolean!
  }

  type Query {
    me: User
    users: [User!]!
    posts: [Post!]!
    adminDashboard: String
  }

  type Mutation {
    register(email: String!, password: String!): AuthPayload!
    login(email: String!, password: String!): AuthPayload!
    createPost(title: String!, content: String!): Post!
    publishPost(postId: ID!): Post!
    deleteUser(userId: ID!): Boolean!
  }
`;

Yardımcı Fonksiyonlar: Auth Guard’lar

Her resolver’da tekrar tekrar aynı kontrolleri yazmak yerine, yeniden kullanılabilir guard fonksiyonları oluşturmak kodunuzu hem temiz hem de bakımı kolay tutar:

# src/utils/authGuards.ts

import { GraphQLError } from 'graphql';
import { Context, UserPayload } from '../context';

export function requireAuth(context: Context): UserPayload {
  if (!context.user) {
    throw new GraphQLError('Bu islemi yapmak icin giris yapmaniz gerekiyor.', {
      extensions: {
        code: 'UNAUTHENTICATED',
        http: { status: 401 }
      }
    });
  }
  return context.user;
}

export function requireRole(
  context: Context,
  allowedRoles: Array<'ADMIN' | 'EDITOR' | 'USER'>
): UserPayload {
  const user = requireAuth(context);

  if (!allowedRoles.includes(user.role)) {
    throw new GraphQLError(
      `Bu islemi yapmak icin yetkiniz yok. Gerekli rol: ${allowedRoles.join(' veya ')}`,
      {
        extensions: {
          code: 'FORBIDDEN',
          http: { status: 403 }
        }
      }
    );
  }

  return user;
}

export function requireOwnerOrAdmin(
  context: Context,
  resourceOwnerId: string
): UserPayload {
  const user = requireAuth(context);

  if (user.role !== 'ADMIN' && user.id !== resourceOwnerId) {
    throw new GraphQLError('Bu kaynaga erisim yetkiniz bulunmuyor.', {
      extensions: {
        code: 'FORBIDDEN',
        http: { status: 403 }
      }
    });
  }

  return user;
}

Resolver’larda Kimlik Doğrulama

Şimdi bu guard’ları gerçek resolver’larda kullanalım. Önce mutation resolver’larından başlayalım:

# src/resolvers/mutations.ts

import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { requireAuth, requireRole } from '../utils/authGuards';
import { Context } from '../context';

const JWT_SECRET = process.env.JWT_SECRET || 'super-secret-key';

// Gercek uygulamada bu bir veritabani olacak
const users: any[] = [];
const posts: any[] = [];

export const mutations = {
  register: async (_: any, { email, password }: any) => {
    const existingUser = users.find(u => u.email === email);
    if (existingUser) {
      throw new Error('Bu email adresi zaten kayitli.');
    }

    const hashedPassword = await bcrypt.hash(password, 12);
    const newUser = {
      id: String(users.length + 1),
      email,
      password: hashedPassword,
      role: 'USER',
      createdAt: new Date().toISOString()
    };

    users.push(newUser);

    const token = jwt.sign(
      { id: newUser.id, email: newUser.email, role: newUser.role },
      JWT_SECRET,
      { expiresIn: '7d' }
    );

    return { token, user: newUser };
  },

  login: async (_: any, { email, password }: any) => {
    const user = users.find(u => u.email === email);
    if (!user) {
      // Guvenlik icin genel hata mesaji kullan
      throw new Error('Gecersiz email veya sifre.');
    }

    const validPassword = await bcrypt.compare(password, user.password);
    if (!validPassword) {
      throw new Error('Gecersiz email veya sifre.');
    }

    const token = jwt.sign(
      { id: user.id, email: user.email, role: user.role },
      JWT_SECRET,
      { expiresIn: '7d' }
    );

    return { token, user };
  },

  createPost: async (_: any, { title, content }: any, context: Context) => {
    // Sadece giris yapmis kullanicilar post olusturabilir
    const user = requireAuth(context);

    const newPost = {
      id: String(posts.length + 1),
      title,
      content,
      authorId: user.id,
      published: false,
      createdAt: new Date().toISOString()
    };

    posts.push(newPost);
    return { ...newPost, author: users.find(u => u.id === user.id) };
  },

  publishPost: async (_: any, { postId }: any, context: Context) => {
    // Sadece ADMIN ve EDITOR rolleri post yayinlayabilir
    requireRole(context, ['ADMIN', 'EDITOR']);

    const post = posts.find(p => p.id === postId);
    if (!post) {
      throw new Error('Post bulunamadi.');
    }

    post.published = true;
    return { ...post, author: users.find(u => u.id === post.authorId) };
  },

  deleteUser: async (_: any, { userId }: any, context: Context) => {
    // Sadece ADMIN kullanici silebilir
    requireRole(context, ['ADMIN']);

    const userIndex = users.findIndex(u => u.id === userId);
    if (userIndex === -1) {
      throw new Error('Kullanici bulunamadi.');
    }

    users.splice(userIndex, 1);
    return true;
  }
};

Query Resolver’larda Yetkilendirme

# src/resolvers/queries.ts

import { requireAuth, requireRole } from '../utils/authGuards';
import { Context } from '../context';

const users: any[] = [];
const posts: any[] = [];

export const queries = {
  me: (_: any, __: any, context: Context) => {
    // Giris yapilmamissa null don, hata verme
    if (!context.user) return null;
    return users.find(u => u.id === context.user!.id);
  },

  users: (_: any, __: any, context: Context) => {
    // Kullanici listesi sadece admin'e acik
    requireRole(context, ['ADMIN']);
    return users;
  },

  posts: (_: any, __: any, context: Context) => {
    // Giris yapmis kullanicilar tum postlari gorebilir
    // Giris yapmayanlar sadece yayinlanmislari gorebilir
    if (context.user) {
      return posts.map(p => ({
        ...p,
        author: users.find(u => u.id === p.authorId)
      }));
    }

    return posts
      .filter(p => p.published)
      .map(p => ({
        ...p,
        author: users.find(u => u.id === p.authorId)
      }));
  },

  adminDashboard: (_: any, __: any, context: Context) => {
    requireRole(context, ['ADMIN']);
    return 'Admin paneline hos geldiniz! Sistem durumu: Saglıklı';
  }
};

Ana Server Konfigürasyonu

Tüm parçaları bir araya getirelim:

# src/index.ts

import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@as-integrations/express';
import express from 'express';
import { typeDefs } from './schema';
import { queries } from './resolvers/queries';
import { mutations } from './resolvers/mutations';
import { createContext } from './context';

const resolvers = {
  Query: queries,
  Mutation: mutations
};

async function startServer() {
  const app = express();
  app.use(express.json());

  const server = new ApolloServer({
    typeDefs,
    resolvers,
    formatError: (formattedError, error) => {
      // Production ortaminda stack trace'i gizle
      if (process.env.NODE_ENV === 'production') {
        return {
          message: formattedError.message,
          extensions: {
            code: formattedError.extensions?.code
          }
        };
      }
      return formattedError;
    }
  });

  await server.start();

  app.use(
    '/graphql',
    expressMiddleware(server, {
      context: createContext
    })
  );

  const PORT = process.env.PORT || 4000;
  app.listen(PORT, () => {
    console.log(`Server calisiyor: http://localhost:${PORT}/graphql`);
  });
}

startServer().catch(console.error);

Refresh Token Mekanizması

Gerçek dünya uygulamalarında sadece access token yeterli değildir. Kısa ömürlü access token ve uzun ömürlü refresh token kombinasyonu kullanmak güvenliği artırır:

# src/utils/tokenUtils.ts

import jwt from 'jsonwebtoken';
import { UserPayload } from '../context';

const ACCESS_SECRET = process.env.ACCESS_SECRET || 'access-secret';
const REFRESH_SECRET = process.env.REFRESH_SECRET || 'refresh-secret';

export function generateAccessToken(payload: UserPayload): string {
  return jwt.sign(payload, ACCESS_SECRET, { expiresIn: '15m' });
}

export function generateRefreshToken(userId: string): string {
  return jwt.sign({ id: userId }, REFRESH_SECRET, { expiresIn: '30d' });
}

export function verifyAccessToken(token: string): UserPayload | null {
  try {
    return jwt.verify(token, ACCESS_SECRET) as UserPayload;
  } catch {
    return null;
  }
}

export function verifyRefreshToken(token: string): { id: string } | null {
  try {
    return jwt.verify(token, REFRESH_SECRET) as { id: string };
  } catch {
    return null;
  }
}

// Schema'ya eklenecek mutation ornegi
export const refreshTokenMutation = `
  refreshAccessToken(refreshToken: String!): AuthPayload!
`;

// Resolver ornegi
export async function refreshAccessTokenResolver(
  _: any,
  { refreshToken }: { refreshToken: string },
  users: any[]
) {
  const payload = verifyRefreshToken(refreshToken);

  if (!payload) {
    throw new Error('Gecersiz veya suresi dolmus refresh token.');
  }

  const user = users.find(u => u.id === payload.id);
  if (!user) {
    throw new Error('Kullanici bulunamadi.');
  }

  const newAccessToken = generateAccessToken({
    id: user.id,
    email: user.email,
    role: user.role
  });

  return { token: newAccessToken, user };
}

Directive ile Field-Level Yetkilendirme

Daha gelişmiş bir yaklaşım olarak GraphQL directive’leri kullanarak field seviyesinde yetkilendirme uygulayabilirsiniz:

# src/directives/authDirective.ts

import { mapSchema, getDirective, MapperKind } from '@graphql-tools/utils';
import { defaultFieldResolver, GraphQLSchema } from 'graphql';
import { GraphQLError } from 'graphql';

export function authDirectiveTransformer(schema: GraphQLSchema) {
  return mapSchema(schema, {
    [MapperKind.OBJECT_FIELD]: (fieldConfig) => {
      const authDirective = getDirective(schema, fieldConfig, 'auth')?.[0];

      if (authDirective) {
        const { requires } = authDirective;
        const { resolve = defaultFieldResolver } = fieldConfig;

        fieldConfig.resolve = async function (source, args, context, info) {
          if (!context.user) {
            throw new GraphQLError('Kimlik dogrulamasi gerekli.', {
              extensions: { code: 'UNAUTHENTICATED' }
            });
          }

          if (requires && context.user.role !== requires) {
            throw new GraphQLError(`Bu alan icin ${requires} rolü gereklidir.`, {
              extensions: { code: 'FORBIDDEN' }
            });
          }

          return resolve(source, args, context, info);
        };
      }

      return fieldConfig;
    }
  });
}

// Schema'da kullanim ornegi:
// directive @auth(requires: Role) on FIELD_DEFINITION
//
// type Query {
//   adminStats: AdminStats @auth(requires: ADMIN)
//   myProfile: User @auth
// }

Gerçek Dünya Önerileri

Apollo Server’da kimlik doğrulama ve yetkilendirme uygularken dikkat etmeniz gereken bazı kritik noktalar var:

Token Güvenliği:

  • JWT secret’larını environment variable olarak saklayın, asla kaynak koduna gömmeyın
  • Access token süresini kısa tutun (15 dakika idealdir)
  • Refresh token’ları veritabanında saklayın ve logout işleminde geçersiz kılın

Hata Mesajları:

  • Login işlemlerinde “Kullanicı bulunamadi” veya “Sifre yanlış” gibi ayrı mesajlar vermeyin. Her ikisi için de “Gecersiz email veya sifre” kullanın. Bu, kullanıcı enumeration saldırılarını önler.
  • Production’da stack trace bilgisi dışarı sızdırmayın

Rate Limiting:

  • Login endpoint’lerine rate limiting uygulayın. Brute force saldırılarını engellemek için graphql-rate-limit paketi kullanabilirsiniz.
  • Başarısız giriş denemelerini loglayın

Context Performansı:

  • Her request’te veritabanına kullanıcı sorgusu atmak yerine JWT payload’ını kullanın
  • Eğer veritabanı sorgusu gerekiyorsa DataLoader ile önbellekleme yapın

Test Edilebilirlik:

  • Guard fonksiyonlarını bağımsız test edebilmek için context nesnesini dependency injection ile geçirin
  • Her rol için ayrı test senaryoları yazın

Sonuç

Apollo Server’da kimlik doğrulama ve yetkilendirme, düzgün kurgulandığında hem güvenli hem de esnek bir yapı sunar. Context mekanizması ile JWT token’ı çözümleyip kullanıcı bilgisini tüm resolver’lara taşımak, tekrar eden kod yazmaktan kurtarır. Guard fonksiyonları ile yetkilendirme mantığını merkezi bir yerde toplamak, hem bakımı kolaylaştırır hem de değişiklikleri tek noktadan uygulamanıza olanak tanır.

Gerçek bir projede bu yapıya ek olarak refresh token rotasyonu, blacklist mekanizması ve rate limiting mutlaka eklenmelidir. Directive tabanlı yetkilendirme ise büyüyen projelerde field seviyesinde ince ayar yapmanızı sağlar ve schema’yı kendi kendini belgeleyen bir yapıya kavuşturur. GraphQL’in tip güvenliği ile bu güvenlik katmanlarını birleştirdiğinizde, hem geliştirici deneyimi hem de sistem güvenliği açısından sağlam bir zemine sahip olursunuz.

Bir yanıt yazın

E-posta adresiniz yayınlanmayacak. Gerekli alanlar * ile işaretlenmişlerdir